mirror of
https://github.com/bitfireAT/davx5-ose.git
synced 2026-08-07 05:44:54 -04:00
Compare commits
312
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3f1e58d513 | ||
|
|
2740a1bd3c | ||
|
|
03f8176b07 | ||
|
|
5c10a6aa2a | ||
|
|
2b1fd66da0 | ||
|
|
555de288a0 | ||
|
|
f467c0449b | ||
|
|
bedc1742c1 | ||
|
|
9ad5406712 | ||
|
|
6f536ba3a6 | ||
|
|
c12d569410 | ||
|
|
d04690fcd9 | ||
|
|
7b96af094d | ||
|
|
c6424ec3e6 | ||
|
|
f590571563 | ||
|
|
c5346325fb | ||
|
|
81c5bab3cb | ||
|
|
d5078c9d5c | ||
|
|
236fbdc936 | ||
|
|
4d96ad5c3e | ||
|
|
3aeadf3b67 | ||
|
|
a0a088724b | ||
|
|
a10c5b7656 | ||
|
|
b9b5a244b0 | ||
|
|
7d8ae2a2c4 | ||
|
|
017c472523 | ||
|
|
a502750e95 | ||
|
|
5f91c793af | ||
|
|
a564ddf79b | ||
|
|
bc6fb6b3aa | ||
|
|
b70c55fcf0 | ||
|
|
71c3221cb3 | ||
|
|
b7cb66296a | ||
|
|
be24e5db47 | ||
|
|
d44914700f | ||
|
|
7d2486d4ed | ||
|
|
4cc0968d9c | ||
|
|
3565428721 | ||
|
|
65be56fa57 | ||
|
|
d8ddad5b73 | ||
|
|
2ad3bc92ba | ||
|
|
a9a8cb981f | ||
|
|
bf0783e624 | ||
|
|
819e709613 | ||
|
|
14bd8c0bb8 | ||
|
|
51c7f8f5f2 | ||
|
|
714d63b78c | ||
|
|
eab494d3fa | ||
|
|
1c5e16b093 | ||
|
|
2bcd6e4686 | ||
|
|
029319c6c8 | ||
|
|
e07a55870c | ||
|
|
cd299ec83a | ||
|
|
d764a48a06 | ||
|
|
88b6dee6b0 | ||
|
|
131c633549 | ||
|
|
f3c9e406f9 | ||
|
|
9e23cdffb5 | ||
|
|
a776b65d66 | ||
|
|
76e83e1269 | ||
|
|
a1d6c69fad | ||
|
|
2248ba6508 | ||
|
|
d6ee73444e | ||
|
|
0a472905f7 | ||
|
|
2f7debb42f | ||
|
|
370367724c | ||
|
|
4dcde1140d | ||
|
|
64ad708316 | ||
|
|
d4e3864410 | ||
|
|
3b71808534 | ||
|
|
3132ffbd7f | ||
|
|
d553e1840c | ||
|
|
f368456ee6 | ||
|
|
6feb45eaa9 | ||
|
|
88baa19d86 | ||
|
|
de5903b375 | ||
|
|
ed0eefc971 | ||
|
|
33510ff203 | ||
|
|
05ded45711 | ||
|
|
3fab975a28 | ||
|
|
e767dcbf38 | ||
|
|
0d193bfba1 | ||
|
|
f1a4253c88 | ||
|
|
199d8b8eae | ||
|
|
41dae8bcb3 | ||
|
|
956698208a | ||
|
|
ea925e132b | ||
|
|
c87371ab1d | ||
|
|
666ddc972b | ||
|
|
9817594da1 | ||
|
|
dfec72ce6b | ||
|
|
46c5d7201d | ||
|
|
949f78f1ad | ||
|
|
675536f1d5 | ||
|
|
e5aedec9e1 | ||
|
|
aa13ccf35f | ||
|
|
cc25babc27 | ||
|
|
58fd2d02d7 | ||
|
|
353094d268 | ||
|
|
5e819e44a4 | ||
|
|
0080cf683c | ||
|
|
ba4e7a6499 | ||
|
|
526c0e2381 | ||
|
|
f7b31d219a | ||
|
|
47639c4511 | ||
|
|
6dd310637e | ||
|
|
e46e85c7b2 | ||
|
|
8f8e0f9ce8 | ||
|
|
5fd771e43d | ||
|
|
9b45a6b7c6 | ||
|
|
faaaa330a7 | ||
|
|
712467d933 | ||
|
|
cb0041bfed | ||
|
|
9ae61f6233 | ||
|
|
6045a988b4 | ||
|
|
c034789069 | ||
|
|
d98787a5bc | ||
|
|
3c1b9a7288 | ||
|
|
35cd11299d | ||
|
|
34ba52979c | ||
|
|
a14dfe5d39 | ||
|
|
232eaa1d6d | ||
|
|
d40af10b1f | ||
|
|
364d79535a | ||
|
|
d30077657c | ||
|
|
4b8c44fe5b | ||
|
|
88b5a26c3b | ||
|
|
d5807e44af | ||
|
|
c8ddec9651 | ||
|
|
0a70d91883 | ||
|
|
7a4fc19814 | ||
|
|
ef92753ac6 | ||
|
|
91d4e38227 | ||
|
|
ca38cbe935 | ||
|
|
29068389c7 | ||
|
|
d2df3c9027 | ||
|
|
d34efb103d | ||
|
|
2fd0ac2dd0 | ||
|
|
4f796669f6 | ||
|
|
fb7f974987 | ||
|
|
900f1fd857 | ||
|
|
e0a5748da3 | ||
|
|
c714785f47 | ||
|
|
fa17659ce8 | ||
|
|
8d4eeb763b | ||
|
|
3c1e9302df | ||
|
|
f2d5fe32c5 | ||
|
|
08639b2e89 | ||
|
|
5b50aa024e | ||
|
|
d8fb9c8373 | ||
|
|
b3dda2c112 | ||
|
|
ae8bf4d49e | ||
|
|
8e5ca5a72d | ||
|
|
2e78e1e746 | ||
|
|
fb4321f35f | ||
|
|
7d7e2584ed | ||
|
|
2b9dfc668f | ||
|
|
118d873cdf | ||
|
|
e7e30e9f2f | ||
|
|
520df3654d | ||
|
|
f0fe8e7620 | ||
|
|
783fead048 | ||
|
|
bd8470e7eb | ||
|
|
ec0e792f12 | ||
|
|
593d907e3a | ||
|
|
2c5b93ce43 | ||
|
|
68d48bb0f2 | ||
|
|
3acc05b605 | ||
|
|
3813c1e0c8 | ||
|
|
4e87186dd8 | ||
|
|
5e0a3e8b9f | ||
|
|
3bd96955cc | ||
|
|
3952181df2 | ||
|
|
054dba6ff8 | ||
|
|
68781fd96e | ||
|
|
00a400569a | ||
|
|
08b1886f9e | ||
|
|
d2d7ce8fe3 | ||
|
|
1354afebf7 | ||
|
|
08c8390202 | ||
|
|
d688eb5196 | ||
|
|
b757cba521 | ||
|
|
4fea7880e7 | ||
|
|
9555c066bd | ||
|
|
290ce7ad80 | ||
|
|
55ad06e328 | ||
|
|
19ab4a14ce | ||
|
|
ac940b3a12 | ||
|
|
7416c62c97 | ||
|
|
eb70f8690f | ||
|
|
bab84d7d0f | ||
|
|
ae145d897e | ||
|
|
d675fb961e | ||
|
|
967bc50256 | ||
|
|
2617063835 | ||
|
|
7fbdf96546 | ||
|
|
f6cb92dd6d | ||
|
|
b49bdda7e8 | ||
|
|
19b54748cd | ||
|
|
41ce609237 | ||
|
|
aafcc36c4d | ||
|
|
2496a3bf05 | ||
|
|
54e6426dc4 | ||
|
|
4542da7d89 | ||
|
|
977409511a | ||
|
|
ad8c832819 | ||
|
|
389af2b738 | ||
|
|
be2e15e463 | ||
|
|
c7c13520f9 | ||
|
|
317144630c | ||
|
|
34bc27fa79 | ||
|
|
210735a500 | ||
|
|
b30733c64b | ||
|
|
91234a688f | ||
|
|
5675e544b5 | ||
|
|
42a261b84e | ||
|
|
0d1825cbf3 | ||
|
|
9b8fc983cd | ||
|
|
3bde3758fc | ||
|
|
fd1f59d124 | ||
|
|
9886507b7d | ||
|
|
144643d6af | ||
|
|
14875f63ea | ||
|
|
28e567cf78 | ||
|
|
7997606550 | ||
|
|
fb0552de46 | ||
|
|
03c15a6924 | ||
|
|
c3b2929f88 | ||
|
|
eb2537a278 | ||
|
|
0b9727cca6 | ||
|
|
61231b4233 | ||
|
|
59252d7471 | ||
|
|
6ffa6fa9a7 | ||
|
|
03ee9a037b | ||
|
|
7ab13d648e | ||
|
|
25c54cce62 | ||
|
|
f0e45c71f5 | ||
|
|
fa528a64e9 | ||
|
|
c6aed90c96 | ||
|
|
2280f899ee | ||
|
|
a283cbbae5 | ||
|
|
bb95a25b91 | ||
|
|
f1ccd01708 | ||
|
|
c498225064 | ||
|
|
879b137cfc | ||
|
|
84379f7ee1 | ||
|
|
a594fd3d14 | ||
|
|
100b78a6a4 | ||
|
|
758711acb2 | ||
|
|
c90b6075db | ||
|
|
7109915e6e | ||
|
|
e8cf9fd5ab | ||
|
|
3a49815220 | ||
|
|
96881bd986 | ||
|
|
c08a0bdc43 | ||
|
|
773b2ee992 | ||
|
|
c2181c55d3 | ||
|
|
8449684dd2 | ||
|
|
28e7c91658 | ||
|
|
51867c5f3f | ||
|
|
1786b73ac6 | ||
|
|
1df3ddbe74 | ||
|
|
5ee8d76b34 | ||
|
|
5723225475 | ||
|
|
f73f6ca43c | ||
|
|
753c4b05a5 | ||
|
|
2e34fa686d | ||
|
|
a735564bc1 | ||
|
|
552f6b6936 | ||
|
|
50f7006e59 | ||
|
|
6ac5fe0204 | ||
|
|
19bfe5c5f2 | ||
|
|
212cd8ddb0 | ||
|
|
c30195d9ba | ||
|
|
3ca063416e | ||
|
|
940d622402 | ||
|
|
814abc60ed | ||
|
|
220ba4b151 | ||
|
|
777e124b54 | ||
|
|
f32493986b | ||
|
|
5025a61cd1 | ||
|
|
89a516bfd1 | ||
|
|
af71ed8bc5 | ||
|
|
fc29988dc6 | ||
|
|
77c947da14 | ||
|
|
ff901ce91f | ||
|
|
85a6b68a56 | ||
|
|
89050d88c6 | ||
|
|
ba0350c83d | ||
|
|
515969c4b8 | ||
|
|
9a8d29e774 | ||
|
|
2880b05b5d | ||
|
|
d6cff63f2d | ||
|
|
be6aa1b6a2 | ||
|
|
9ec4a4015d | ||
|
|
9dbc32d30b | ||
|
|
b63fc70cfb | ||
|
|
0142e63257 | ||
|
|
aaa7d71ae3 | ||
|
|
4adf3001ac | ||
|
|
5ccdafa074 | ||
|
|
fce2b85991 | ||
|
|
e5ebf10dc0 | ||
|
|
0f0acd62a3 | ||
|
|
2414b42867 | ||
|
|
243dac9952 | ||
|
|
12248b8bb9 | ||
|
|
d872bd06e5 | ||
|
|
065aa3fc84 | ||
|
|
20ee4e03f3 | ||
|
|
241e15404f | ||
|
|
4a00ba647d |
No files matched your search
@@ -0,0 +1,20 @@
|
||||
image: registry.gitlab.com/bitfireat/davdroid:latest
|
||||
|
||||
before_script:
|
||||
- git submodule update --init --recursive
|
||||
- export GRADLE_USER_HOME=`pwd`/.gradle; chmod +x gradlew
|
||||
- emulator64-x86 -avd test -no-audio -no-window & wait-for-emulator.sh
|
||||
- wget -q https://f-droid.org/repo/org.dmfs.tasks_481.apk && adb install org.dmfs.tasks_481.apk
|
||||
|
||||
cache:
|
||||
paths:
|
||||
- .gradle/
|
||||
|
||||
test:
|
||||
script:
|
||||
- ./gradlew check mergeAndroidReports
|
||||
artifacts:
|
||||
paths:
|
||||
- app/build/outputs/lint-results-debug.html
|
||||
- app/build/reports
|
||||
- build/reports
|
||||
+6
-6
@@ -1,12 +1,12 @@
|
||||
[submodule "dav4android"]
|
||||
path = dav4android
|
||||
url = https://gitlab.com/bitfireAT/dav4android.git
|
||||
url = ../dav4android.git
|
||||
[submodule "ical4android"]
|
||||
path = ical4android
|
||||
url = https://gitlab.com/bitfireAT/ical4android.git
|
||||
url = ../ical4android.git
|
||||
[submodule "vcard4android"]
|
||||
path = vcard4android
|
||||
url = https://gitlab.com/bitfireAT/vcard4android.git
|
||||
[submodule "MemorizingTrustManager"]
|
||||
path = MemorizingTrustManager
|
||||
url = https://github.com/ge0rg/MemorizingTrustManager
|
||||
url = ../vcard4android.git
|
||||
[submodule "cert4android"]
|
||||
path = cert4android
|
||||
url = ../cert4android.git
|
||||
File renamed without changes.
Submodule MemorizingTrustManager deleted from 9e30ffdf7d.
@@ -1,18 +1,26 @@
|
||||
|
||||
DAVDROID
|
||||
[](https://gitlab.com/bitfireAT/davdroid/commits/master)
|
||||
|
||||
|
||||
DAVdroid
|
||||
========
|
||||
|
||||
Please see the [DAVdroid Web site](https://davdroid.bitfire.at) for
|
||||
detailled information about DAVdroid.
|
||||
comprehensive information about DAVdroid.
|
||||
|
||||
DAVdroid is licensed under the [GPLv3 License](COPYING).
|
||||
DAVdroid is licensed under the [GPLv3 License](LICENSE).
|
||||
|
||||
News and updates: [@davdroidapp](https://twitter.com/davdroidapp)
|
||||
News and updates: [@davdroidapp](https://twitter.com/davdroidapp) on Twitter /
|
||||
[davdroid-announce](https://davdroid.bitfire.at/download/newsletter/) mailing list
|
||||
|
||||
Help and discussion: [DAVdroid forums](https://davdroid.bitfire.at/forums)
|
||||
|
||||
**If you want to support DAVdroid, please consider [donating to DAVdroid](https://davdroid.bitfire.at/donate/)
|
||||
or [purchasing it](https://davdroid.bitfire.at/download/).**
|
||||
|
||||
Parts of DAVdroid 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
|
||||
* [ical4android](https://gitlab.com/bitfireAT/ical4android) – iCalendar processing and Calendar Provider access
|
||||
* [vcard4android](https://gitlab.com/bitfireAT/vcard4android) – VCard processing and Contacts Provider access
|
||||
@@ -26,7 +34,5 @@ Those libraries are used by DAVdroid (alphabetically):
|
||||
* [dnsjava](http://www.xbill.org/dnsjava/) – [BSD License](http://www.xbill.org/dnsjava/dnsjava-current/LICENSE)
|
||||
* [ez-vcard](https://code.google.com/p/ez-vcard/) – [New BSD License](http://opensource.org/licenses/BSD-3-Clause)
|
||||
* [iCal4j](http://ical4j.sourceforge.net/) – [New BSD License](http://sourceforge.net/p/ical4j/ical4j/ci/default/tree/LICENSE)
|
||||
* [MemorizingTrustManager](https://github.com/ge0rg/MemorizingTrustManager) – [MIT License](https://raw.githubusercontent.com/ge0rg/MemorizingTrustManager/master/LICENSE.txt)
|
||||
* [okhttp](https://square.github.io/okhttp/) – [Apache License, Version 2.0](https://square.github.io/okhttp/#license)
|
||||
* [Project Lombok](http://projectlombok.org/) – [MIT License](http://opensource.org/licenses/mit-license.php)
|
||||
* [SLF4J](http://www.slf4j.org/) – [MIT License](http://www.slf4j.org/license.html)
|
||||
+65
-26
@@ -1,5 +1,5 @@
|
||||
/*
|
||||
* Copyright (c) 2013 – 2015 Ricki Hirner (bitfire web engineering).
|
||||
* Copyright (c) Ricki Hirner (bitfire web engineering).
|
||||
* All rights reserved. This program and the accompanying materials
|
||||
* are made available under the terms of the GNU Public License v3.0
|
||||
* which accompanies this distribution, and is available at
|
||||
@@ -7,20 +7,30 @@
|
||||
*/
|
||||
|
||||
apply plugin: 'com.android.application'
|
||||
apply plugin: 'kotlin-android'
|
||||
apply plugin: 'kotlin-android-extensions'
|
||||
|
||||
android {
|
||||
compileSdkVersion 23
|
||||
buildToolsVersion '23.0.1'
|
||||
compileSdkVersion 26
|
||||
buildToolsVersion '26.0.0'
|
||||
|
||||
defaultConfig {
|
||||
applicationId "at.bitfire.davdroid"
|
||||
minSdkVersion 14
|
||||
targetSdkVersion 22
|
||||
|
||||
versionCode 82
|
||||
versionName "0.9.0.4"
|
||||
minSdkVersion 16
|
||||
targetSdkVersion 25
|
||||
|
||||
buildConfigField "java.util.Date", "buildTime", "new java.util.Date()"
|
||||
versionCode 160
|
||||
|
||||
buildConfigField "long", "buildTime", System.currentTimeMillis() + "L"
|
||||
buildConfigField "boolean", "customCerts", "true"
|
||||
}
|
||||
|
||||
productFlavors {
|
||||
standard {
|
||||
versionName "1.6.5-ose"
|
||||
buildConfigField "boolean", "customCerts", "true"
|
||||
}
|
||||
}
|
||||
|
||||
buildTypes {
|
||||
@@ -32,39 +42,68 @@ android {
|
||||
proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.txt'
|
||||
}
|
||||
}
|
||||
|
||||
lintOptions {
|
||||
disable 'GoogleAppIndexingWarning' // we don't need Google indexing, thanks
|
||||
disable 'GradleDependency'
|
||||
disable 'IconColors'
|
||||
disable 'IconLauncherShape'
|
||||
disable 'IconMissingDensityFolder'
|
||||
disable 'InconsistentLayout'
|
||||
disable 'MergeRootFrame'
|
||||
disable 'MissingTranslation'
|
||||
disable 'OldTargetApi'
|
||||
disable 'ImpliedQuantity', 'MissingQuantity'
|
||||
disable 'MissingTranslation', 'ExtraTranslation' // translations from Transifex are not always up to date
|
||||
disable 'Recycle' // doesn't understand Lombok's @Cleanup
|
||||
disable 'RtlEnabled'
|
||||
disable 'RtlHardcoded'
|
||||
disable 'Typos'
|
||||
}
|
||||
packagingOptions {
|
||||
exclude 'LICENSE'
|
||||
exclude 'META-INF/LICENSE.txt'
|
||||
exclude 'META-INF/NOTICE.txt'
|
||||
exclude 'META-INF/DEPENDENCIES'
|
||||
exclude 'META-INF/LICENSE'
|
||||
}
|
||||
|
||||
defaultConfig {
|
||||
testInstrumentationRunner "android.support.test.runner.AndroidJUnitRunner"
|
||||
}
|
||||
}
|
||||
|
||||
configurations.all {
|
||||
exclude module: 'commons-logging' // undocumented part of Android
|
||||
}
|
||||
|
||||
dependencies {
|
||||
compile 'dnsjava:dnsjava:2.1.7'
|
||||
compile 'org.apache.commons:commons-lang3:3.4'
|
||||
provided 'org.projectlombok:lombok:1.16.6'
|
||||
compile('org.slf4j:slf4j-android:1.7.12')
|
||||
|
||||
compile project(':cert4android')
|
||||
compile project(':dav4android')
|
||||
compile project(':ical4android')
|
||||
compile project(':vcard4android')
|
||||
|
||||
compile project(':MemorizingTrustManager')
|
||||
compile "org.jetbrains.kotlin:kotlin-stdlib-jre7:$kotlin_version"
|
||||
|
||||
androidTestCompile 'com.squareup.okhttp:mockwebserver:2.6.0-SNAPSHOT'
|
||||
//noinspection GradleDynamicVersion
|
||||
compile 'com.android.support:appcompat-v7:26.+'
|
||||
//noinspection GradleDynamicVersion
|
||||
compile 'com.android.support:cardview-v7:26.+'
|
||||
//noinspection GradleDynamicVersion
|
||||
compile 'com.android.support:design:26.+'
|
||||
//noinspection GradleDynamicVersion
|
||||
compile 'com.android.support:preference-v14:26.+'
|
||||
|
||||
compile 'com.github.yukuku:ambilwarna:2.0.1'
|
||||
|
||||
compile 'com.squareup.okhttp3:logging-interceptor:3.8.1'
|
||||
compile 'commons-io:commons-io:2.5'
|
||||
compile 'dnsjava:dnsjava:2.1.8'
|
||||
compile 'org.apache.commons:commons-lang3:3.6'
|
||||
compile 'org.apache.commons:commons-collections4:4.1'
|
||||
provided 'org.projectlombok:lombok:1.16.16'
|
||||
|
||||
// for tests
|
||||
//noinspection GradleDynamicVersion
|
||||
androidTestCompile('com.android.support.test:runner:+') {
|
||||
exclude group: 'com.android.support', module: 'support-annotations'
|
||||
}
|
||||
//noinspection GradleDynamicVersion
|
||||
androidTestCompile('com.android.support.test:rules:+') {
|
||||
exclude group: 'com.android.support', module: 'support-annotations'
|
||||
}
|
||||
androidTestCompile 'junit:junit:4.12'
|
||||
androidTestCompile 'com.squareup.okhttp3:mockwebserver:3.8.1'
|
||||
|
||||
testCompile 'junit:junit:4.12'
|
||||
testCompile 'com.squareup.okhttp3:mockwebserver:3.8.1'
|
||||
}
|
||||
+22
-19
@@ -1,36 +1,39 @@
|
||||
|
||||
# ProGuard usage for DAVdroid:
|
||||
# shrinking yes - main reason for using ProGuard
|
||||
# optimization no - too risky
|
||||
# obfuscation no - DAVdroid is open-source
|
||||
# preverification no (Android default)
|
||||
# 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
|
||||
|
||||
# ez-vcard
|
||||
-dontwarn com.fasterxml.jackson.** # Jackson JSON Processor (for jCards) not used
|
||||
-dontwarn freemarker.** # freemarker templating library (for creating hCards) not used
|
||||
-dontwarn org.jsoup.** # jsoup library (for hCard parsing) not used
|
||||
-dontwarn ezvcard.io.json.** # JSON serializer (for jCards) not used
|
||||
-dontwarn freemarker.** # freemarker templating library (for creating hCards) not used
|
||||
-dontwarn org.jsoup.** # jsoup library (for hCard parsing) not used
|
||||
-dontwarn sun.misc.Perf
|
||||
-keep class ezvcard.property.** { *; } # keep all VCard properties (created at runtime)
|
||||
-keep class ezvcard.property.** { *; } # keep all VCard properties (created at runtime)
|
||||
|
||||
# ical4j: ignore unused dynamic libraries
|
||||
-dontwarn aQute.**
|
||||
-dontwarn groovy.** # Groovy-based ContentBuilder not used
|
||||
-dontwarn groovy.** # Groovy-based ContentBuilder not used
|
||||
-dontwarn org.codehaus.groovy.**
|
||||
-dontwarn org.apache.commons.logging.** # Commons logging is not available
|
||||
-dontwarn net.fortuna.ical4j.model.** # ignore warnings from Groovy dependency
|
||||
-keep class net.fortuna.ical4j.model.** { *; } # keep all model classes (properties/factories, created at runtime)
|
||||
-dontwarn net.fortuna.ical4j.model.** # ignore warnings from Groovy dependency
|
||||
-dontwarn org.apache.log4j.** # ignore warnings from log4j dependency
|
||||
-keep class net.fortuna.ical4j.** { *; } # keep all model classes (properties/factories, created at runtime)
|
||||
|
||||
# okhttp
|
||||
-dontwarn java.nio.file.** # not available on Android
|
||||
-dontwarn org.codehaus.mojo.animal_sniffer.IgnoreJRERequirement
|
||||
|
||||
# MemorizingTrustManager
|
||||
-dontwarn de.duenndns.ssl.MemorizingTrustManager
|
||||
-dontwarn okio.**
|
||||
-dontwarn javax.annotation.Nullable
|
||||
-dontwarn javax.annotation.ParametersAreNonnullByDefault
|
||||
|
||||
# dnsjava
|
||||
-dontwarn sun.net.spi.nameservice.** # not available on Android
|
||||
-dontwarn sun.net.spi.nameservice.** # not available on Android
|
||||
|
||||
# DAVdroid + libs
|
||||
-keep class at.bitfire.** { *; } # all DAVdroid code is required
|
||||
-keep class at.bitfire.** { *; } # all DAVdroid code is required
|
||||
@@ -1,39 +0,0 @@
|
||||
/*
|
||||
* Copyright © 2013 – 2015 Ricki Hirner (bitfire web engineering).
|
||||
* All rights reserved. This program and the accompanying materials
|
||||
* are made available under the terms of the GNU Public License v3.0
|
||||
* which accompanies this distribution, and is available at
|
||||
* http://www.gnu.org/licenses/gpl.html
|
||||
*/
|
||||
package at.bitfire.davdroid;
|
||||
|
||||
import junit.framework.TestCase;
|
||||
|
||||
import java.util.Arrays;
|
||||
|
||||
|
||||
public class ArrayUtilsTest extends TestCase {
|
||||
|
||||
public void testPartition() {
|
||||
// n == 0
|
||||
assertTrue(Arrays.deepEquals(
|
||||
new Long[0][0],
|
||||
ArrayUtils.partition(new Long[] { }, 5)));
|
||||
|
||||
// n < max
|
||||
assertTrue(Arrays.deepEquals(
|
||||
new Long[][] { { 1l, 2l } },
|
||||
ArrayUtils.partition(new Long[] { 1l, 2l }, 5)));
|
||||
|
||||
// n == max
|
||||
assertTrue(Arrays.deepEquals(
|
||||
new Long[][] { { 1l, 2l }, { 3l, 4l } },
|
||||
ArrayUtils.partition(new Long[] { 1l, 2l, 3l, 4l }, 2)));
|
||||
|
||||
// n > max
|
||||
assertTrue(Arrays.deepEquals(
|
||||
new Long[][] { { 1l, 2l, 3l, 4l, 5l }, { 6l, 7l, 8l, 9l, 10l }, { 11l } },
|
||||
ArrayUtils.partition(new Long[] { 1l, 2l, 3l, 4l, 5l, 6l, 7l, 8l, 9l, 10l, 11l }, 5)));
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,53 +0,0 @@
|
||||
/*
|
||||
* Copyright © 2013 – 2015 Ricki Hirner (bitfire web engineering).
|
||||
* All rights reserved. This program and the accompanying materials
|
||||
* are made available under the terms of the GNU Public License v3.0
|
||||
* which accompanies this distribution, and is available at
|
||||
* http://www.gnu.org/licenses/gpl.html
|
||||
*/
|
||||
|
||||
package at.bitfire.davdroid;
|
||||
|
||||
import android.test.InstrumentationTestCase;
|
||||
|
||||
import com.squareup.okhttp.ConnectionSpec;
|
||||
import com.squareup.okhttp.TlsVersion;
|
||||
import com.squareup.okhttp.mockwebserver.MockWebServer;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.Collections;
|
||||
|
||||
public class HttpClientTest extends InstrumentationTestCase {
|
||||
|
||||
final MockWebServer server = new MockWebServer();
|
||||
HttpClient httpClient;
|
||||
|
||||
@Override
|
||||
public void setUp() throws IOException {
|
||||
httpClient = new HttpClient(null, getInstrumentation().getTargetContext().getApplicationContext());
|
||||
|
||||
httpClient.setConnectionSpecs(Collections.singletonList(new ConnectionSpec.Builder(ConnectionSpec.MODERN_TLS)
|
||||
.tlsVersions(TlsVersion.TLS_1_2)
|
||||
.build()));
|
||||
|
||||
server.start();
|
||||
server.useHttps(new SSLSocketFactoryCompat(null), false);
|
||||
|
||||
assertEquals("https", server.url("/").scheme());
|
||||
}
|
||||
|
||||
@Override
|
||||
public void tearDown() throws IOException {
|
||||
server.shutdown();
|
||||
}
|
||||
|
||||
public void testTLSVersion() throws IOException {
|
||||
// FIXME
|
||||
/*server.enqueue(new MockResponse().setResponseCode(204));
|
||||
Response response = httpClient.newCall(new Request.Builder()
|
||||
.get().url(server.url("/"))
|
||||
.build()).execute();
|
||||
assertTrue(response.isSuccessful());*/
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
/*
|
||||
* Copyright © 2013 – 2015 Ricki Hirner (bitfire web engineering).
|
||||
* 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
|
||||
@@ -8,45 +8,50 @@
|
||||
|
||||
package at.bitfire.davdroid;
|
||||
|
||||
import android.os.Build;
|
||||
|
||||
import com.squareup.okhttp.mockwebserver.MockWebServer;
|
||||
|
||||
import junit.framework.TestCase;
|
||||
import org.junit.After;
|
||||
import org.junit.Before;
|
||||
import org.junit.Test;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.net.Socket;
|
||||
|
||||
import javax.net.ssl.SSLSocket;
|
||||
|
||||
public class SSLSocketFactoryCompatTest extends TestCase {
|
||||
import at.bitfire.cert4android.CustomCertManager;
|
||||
import okhttp3.mockwebserver.MockWebServer;
|
||||
|
||||
SSLSocketFactoryCompat factory = new SSLSocketFactoryCompat(null);
|
||||
import static android.support.test.InstrumentationRegistry.getTargetContext;
|
||||
import static junit.framework.TestCase.assertFalse;
|
||||
import static org.apache.commons.lang3.ArrayUtils.contains;
|
||||
import static org.junit.Assert.assertTrue;
|
||||
|
||||
public class SSLSocketFactoryCompatTest {
|
||||
|
||||
SSLSocketFactoryCompat factory;
|
||||
MockWebServer server = new MockWebServer();
|
||||
|
||||
@Override
|
||||
protected void setUp() throws Exception {
|
||||
@Before
|
||||
public void startServer() throws Exception {
|
||||
factory = new SSLSocketFactoryCompat(new CustomCertManager(getTargetContext().getApplicationContext(), true));
|
||||
server.start();
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void tearDown() throws Exception {
|
||||
@After
|
||||
public void stopServer() throws Exception {
|
||||
server.shutdown();
|
||||
}
|
||||
|
||||
|
||||
@Test
|
||||
public void testUpgradeTLS() throws IOException {
|
||||
Socket s = factory.createSocket(server.getHostName(), server.getPort());
|
||||
assertTrue(s instanceof SSLSocket);
|
||||
|
||||
SSLSocket ssl = (SSLSocket)s;
|
||||
assertFalse(org.apache.commons.lang3.ArrayUtils.contains(ssl.getEnabledProtocols(), "SSLv3"));
|
||||
assertTrue(org.apache.commons.lang3.ArrayUtils.contains(ssl.getEnabledProtocols(), "TLSv1"));
|
||||
|
||||
if (Build.VERSION.SDK_INT >= 16) {
|
||||
assertTrue(org.apache.commons.lang3.ArrayUtils.contains(ssl.getEnabledProtocols(), "TLSv1.1"));
|
||||
assertTrue(org.apache.commons.lang3.ArrayUtils.contains(ssl.getEnabledProtocols(), "TLSv1.2"));
|
||||
}
|
||||
assertFalse(contains(ssl.getEnabledProtocols(), "SSLv3"));
|
||||
assertTrue(contains(ssl.getEnabledProtocols(), "TLSv1"));
|
||||
assertTrue(contains(ssl.getEnabledProtocols(), "TLSv1.1"));
|
||||
assertTrue(contains(ssl.getEnabledProtocols(), "TLSv1.2"));
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,27 +0,0 @@
|
||||
/*
|
||||
* Copyright © 2013 – 2015 Ricki Hirner (bitfire web engineering).
|
||||
* All rights reserved. This program and the accompanying materials
|
||||
* are made available under the terms of the GNU Public License v3.0
|
||||
* which accompanies this distribution, and is available at
|
||||
* http://www.gnu.org/licenses/gpl.html
|
||||
*/
|
||||
|
||||
package at.bitfire.davdroid;
|
||||
|
||||
import android.util.Log;
|
||||
|
||||
import java.net.URI;
|
||||
import java.net.URISyntaxException;
|
||||
|
||||
public class TestConstants {
|
||||
public static final String ROBOHYDRA_BASE = "http://192.168.0.11:3000/";
|
||||
|
||||
public static URI roboHydra;
|
||||
static {
|
||||
try {
|
||||
roboHydra = new URI(ROBOHYDRA_BASE);
|
||||
} catch(URISyntaxException e) {
|
||||
Log.wtf("davdroid.test.Constants", "Invalid RoboHydra base URL");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
/*
|
||||
* Copyright © Ricki Hirner (bitfire web engineering).
|
||||
* All rights reserved. This program and the accompanying materials
|
||||
* are made available under the terms of the GNU Public License v3.0
|
||||
* which accompanies this distribution, and is available at
|
||||
* http://www.gnu.org/licenses/gpl.html
|
||||
*/
|
||||
|
||||
package at.bitfire.davdroid.model;
|
||||
|
||||
import android.content.ContentValues;
|
||||
|
||||
import org.junit.Test;
|
||||
|
||||
import java.io.IOException;
|
||||
|
||||
import at.bitfire.dav4android.DavResource;
|
||||
import at.bitfire.dav4android.exception.DavException;
|
||||
import at.bitfire.dav4android.exception.HttpException;
|
||||
import at.bitfire.dav4android.property.ResourceType;
|
||||
import at.bitfire.davdroid.HttpClient;
|
||||
import at.bitfire.davdroid.model.ServiceDB.Collections;
|
||||
import okhttp3.mockwebserver.MockResponse;
|
||||
import okhttp3.mockwebserver.MockWebServer;
|
||||
|
||||
import static org.junit.Assert.assertEquals;
|
||||
import static org.junit.Assert.assertFalse;
|
||||
import static org.junit.Assert.assertNull;
|
||||
import static org.junit.Assert.assertTrue;
|
||||
|
||||
public class CollectionInfoTest {
|
||||
|
||||
MockWebServer server = new MockWebServer();
|
||||
|
||||
@Test
|
||||
public void testFromDavResource() throws IOException, HttpException, DavException {
|
||||
// r/w address book
|
||||
server.enqueue(new MockResponse()
|
||||
.setResponseCode(207)
|
||||
.setBody("<multistatus xmlns='DAV:' xmlns:CARD='urn:ietf:params:xml:ns:carddav'>" +
|
||||
"<response>" +
|
||||
" <href>/</href>" +
|
||||
" <propstat><prop>" +
|
||||
" <resourcetype><collection/><CARD:addressbook/></resourcetype>" +
|
||||
" <displayname>My Contacts</displayname>" +
|
||||
" <CARD:addressbook-description>My Contacts Description</CARD:addressbook-description>" +
|
||||
" </prop></propstat>" +
|
||||
"</response>" +
|
||||
"</multistatus>"));
|
||||
|
||||
DavResource dav = new DavResource(HttpClient.create(null), server.url("/"));
|
||||
dav.propfind(0, ResourceType.NAME);
|
||||
CollectionInfo info = new CollectionInfo(dav);
|
||||
assertEquals(CollectionInfo.Type.ADDRESS_BOOK, info.getType());
|
||||
assertFalse(info.getReadOnly());
|
||||
assertEquals("My Contacts", info.getDisplayName());
|
||||
assertEquals("My Contacts Description", info.getDescription());
|
||||
|
||||
// read-only calendar, no display name
|
||||
server.enqueue(new MockResponse()
|
||||
.setResponseCode(207)
|
||||
.setBody("<multistatus xmlns='DAV:' xmlns:CAL='urn:ietf:params:xml:ns:caldav' xmlns:ICAL='http://apple.com/ns/ical/'>" +
|
||||
"<response>" +
|
||||
" <href>/</href>" +
|
||||
" <propstat><prop>" +
|
||||
" <resourcetype><collection/><CAL:calendar/></resourcetype>" +
|
||||
" <current-user-privilege-set><privilege><read/></privilege></current-user-privilege-set>" +
|
||||
" <CAL:calendar-description>My Calendar</CAL:calendar-description>" +
|
||||
" <CAL:calendar-timezone>tzdata</CAL:calendar-timezone>" +
|
||||
" <ICAL:calendar-color>#ff0000</ICAL:calendar-color>" +
|
||||
" </prop></propstat>" +
|
||||
"</response>" +
|
||||
"</multistatus>"));
|
||||
|
||||
dav = new DavResource(HttpClient.create(null), server.url("/"));
|
||||
dav.propfind(0, ResourceType.NAME);
|
||||
info = new CollectionInfo(dav);
|
||||
assertEquals(CollectionInfo.Type.CALENDAR, info.getType());
|
||||
assertTrue(info.getReadOnly());
|
||||
assertNull(info.getDisplayName());
|
||||
assertEquals("My Calendar", info.getDescription());
|
||||
assertEquals(0xFFFF0000, (int)info.getColor());
|
||||
assertEquals("tzdata", info.getTimeZone());
|
||||
assertTrue(info.getSupportsVEVENT());
|
||||
assertTrue(info.getSupportsVTODO());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testFromDB() {
|
||||
ContentValues values = new ContentValues();
|
||||
values.put(Collections.ID, 1);
|
||||
values.put(Collections.SERVICE_ID, 1);
|
||||
values.put(Collections.URL, "http://example.com");
|
||||
values.put(Collections.READ_ONLY, 1);
|
||||
values.put(Collections.DISPLAY_NAME, "display name");
|
||||
values.put(Collections.DESCRIPTION, "description");
|
||||
values.put(Collections.COLOR, 0xFFFF0000);
|
||||
values.put(Collections.TIME_ZONE, "tzdata");
|
||||
values.put(Collections.SUPPORTS_VEVENT, 1);
|
||||
values.put(Collections.SUPPORTS_VTODO, 1);
|
||||
values.put(Collections.SYNC, 1);
|
||||
|
||||
CollectionInfo info = new CollectionInfo(values);
|
||||
assertEquals(1, (long)info.getId());
|
||||
assertEquals(1, (long)info.getServiceID());
|
||||
assertEquals("http://example.com", info.getUrl());
|
||||
assertTrue(info.getReadOnly());
|
||||
assertEquals("display name", info.getDisplayName());
|
||||
assertEquals("description", info.getDescription());
|
||||
assertEquals(0xFFFF0000, (int)info.getColor());
|
||||
assertEquals("tzdata", info.getTimeZone());
|
||||
assertTrue(info.getSupportsVEVENT());
|
||||
assertTrue(info.getSupportsVTODO());
|
||||
assertTrue(info.getSelected());
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,33 +0,0 @@
|
||||
/*
|
||||
* Copyright © 2013 – 2015 Ricki Hirner (bitfire web engineering).
|
||||
* All rights reserved. This program and the accompanying materials
|
||||
* are made available under the terms of the GNU Public License v3.0
|
||||
* which accompanies this distribution, and is available at
|
||||
* http://www.gnu.org/licenses/gpl.html
|
||||
*/
|
||||
|
||||
package at.bitfire.davdroid.syncadapter;
|
||||
|
||||
import junit.framework.TestCase;
|
||||
|
||||
import java.io.IOException;
|
||||
|
||||
import at.bitfire.davdroid.resource.DavResourceFinder;
|
||||
|
||||
public class DavResourceFinderTest extends TestCase {
|
||||
|
||||
DavResourceFinder finder;
|
||||
|
||||
@Override
|
||||
protected void setUp() {
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void tearDown() throws IOException {
|
||||
}
|
||||
|
||||
public void testFindResources() {
|
||||
// TODO
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,200 @@
|
||||
/*
|
||||
* Copyright © Ricki Hirner (bitfire web engineering).
|
||||
* All rights reserved. This program and the accompanying materials
|
||||
* are made available under the terms of the GNU Public License v3.0
|
||||
* which accompanies this distribution, and is available at
|
||||
* http://www.gnu.org/licenses/gpl.html
|
||||
*/
|
||||
|
||||
package at.bitfire.davdroid.ui.setup;
|
||||
|
||||
import org.junit.After;
|
||||
import org.junit.Before;
|
||||
import org.junit.Test;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.net.URI;
|
||||
|
||||
import at.bitfire.dav4android.DavResource;
|
||||
import at.bitfire.dav4android.exception.DavException;
|
||||
import at.bitfire.dav4android.exception.HttpException;
|
||||
import at.bitfire.dav4android.property.AddressbookHomeSet;
|
||||
import at.bitfire.dav4android.property.ResourceType;
|
||||
import at.bitfire.davdroid.HttpClient;
|
||||
import at.bitfire.davdroid.log.Logger;
|
||||
import at.bitfire.davdroid.ui.setup.DavResourceFinder.Configuration.ServiceInfo;
|
||||
import okhttp3.OkHttpClient;
|
||||
import okhttp3.mockwebserver.Dispatcher;
|
||||
import okhttp3.mockwebserver.MockResponse;
|
||||
import okhttp3.mockwebserver.MockWebServer;
|
||||
import okhttp3.mockwebserver.RecordedRequest;
|
||||
|
||||
import static android.support.test.InstrumentationRegistry.getTargetContext;
|
||||
import static junit.framework.TestCase.assertFalse;
|
||||
import static org.junit.Assert.assertEquals;
|
||||
import static org.junit.Assert.assertNull;
|
||||
import static org.junit.Assert.assertTrue;
|
||||
|
||||
public class DavResourceFinderTest {
|
||||
|
||||
MockWebServer server = new MockWebServer();
|
||||
|
||||
DavResourceFinder finder;
|
||||
OkHttpClient client;
|
||||
LoginCredentials credentials;
|
||||
|
||||
private static final String
|
||||
PATH_NO_DAV = "/nodav",
|
||||
|
||||
PATH_CALDAV = "/caldav",
|
||||
PATH_CARDDAV = "/carddav",
|
||||
PATH_CALDAV_AND_CARDDAV = "/both-caldav-carddav",
|
||||
|
||||
SUBPATH_PRINCIPAL = "/principal",
|
||||
SUBPATH_ADDRESSBOOK_HOMESET = "/addressbooks",
|
||||
SUBPATH_ADDRESSBOOK = "/addressbooks/private-contacts";
|
||||
|
||||
@Before
|
||||
public void initServerAndClient() throws Exception {
|
||||
server.setDispatcher(new TestDispatcher());
|
||||
server.start();
|
||||
|
||||
credentials = new LoginCredentials(URI.create("/"), "mock", "12345");
|
||||
finder = new DavResourceFinder(getTargetContext(), credentials);
|
||||
|
||||
client = HttpClient.create(null);
|
||||
client = HttpClient.addAuthentication(client, credentials.getUserName(), credentials.getPassword());
|
||||
}
|
||||
|
||||
@After
|
||||
public void stopServer() throws Exception {
|
||||
server.shutdown();
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testRememberIfAddressBookOrHomeset() throws IOException, HttpException, DavException {
|
||||
ServiceInfo info;
|
||||
|
||||
// before dav.propfind(), no info is available
|
||||
DavResource dav = new DavResource(client, server.url(PATH_CARDDAV + SUBPATH_PRINCIPAL));
|
||||
finder.rememberIfAddressBookOrHomeset(dav, info = new ServiceInfo());
|
||||
assertEquals(0, info.getCollections().size());
|
||||
assertEquals(0, info.getHomeSets().size());
|
||||
|
||||
// recognize home set
|
||||
dav.propfind(0, AddressbookHomeSet.NAME);
|
||||
finder.rememberIfAddressBookOrHomeset(dav, info = new ServiceInfo());
|
||||
assertEquals(0, info.getCollections().size());
|
||||
assertEquals(1, info.getHomeSets().size());
|
||||
assertEquals(server.url(PATH_CARDDAV + SUBPATH_ADDRESSBOOK_HOMESET + "/").uri(), info.getHomeSets().iterator().next());
|
||||
|
||||
// recognize address book
|
||||
dav = new DavResource(client, server.url(PATH_CARDDAV + SUBPATH_ADDRESSBOOK));
|
||||
dav.propfind(0, ResourceType.NAME);
|
||||
finder.rememberIfAddressBookOrHomeset(dav, info = new ServiceInfo());
|
||||
assertEquals(1, info.getCollections().size());
|
||||
assertEquals(server.url(PATH_CARDDAV + SUBPATH_ADDRESSBOOK + "/").uri(), info.getCollections().keySet().iterator().next());
|
||||
assertEquals(0, info.getHomeSets().size());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testProvidesService() throws IOException {
|
||||
assertFalse(finder.providesService(server.url(PATH_NO_DAV), DavResourceFinder.Service.CALDAV));
|
||||
assertFalse(finder.providesService(server.url(PATH_NO_DAV), DavResourceFinder.Service.CARDDAV));
|
||||
|
||||
assertTrue(finder.providesService(server.url(PATH_CALDAV), DavResourceFinder.Service.CALDAV));
|
||||
assertFalse(finder.providesService(server.url(PATH_CALDAV), DavResourceFinder.Service.CARDDAV));
|
||||
|
||||
assertTrue(finder.providesService(server.url(PATH_CARDDAV), DavResourceFinder.Service.CARDDAV));
|
||||
assertFalse(finder.providesService(server.url(PATH_CARDDAV), DavResourceFinder.Service.CALDAV));
|
||||
|
||||
assertTrue(finder.providesService(server.url(PATH_CALDAV_AND_CARDDAV), DavResourceFinder.Service.CALDAV));
|
||||
assertTrue(finder.providesService(server.url(PATH_CALDAV_AND_CARDDAV), DavResourceFinder.Service.CARDDAV));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testGetCurrentUserPrincipal() throws IOException, HttpException, DavException {
|
||||
assertNull(finder.getCurrentUserPrincipal(server.url(PATH_NO_DAV), DavResourceFinder.Service.CALDAV));
|
||||
assertNull(finder.getCurrentUserPrincipal(server.url(PATH_NO_DAV), DavResourceFinder.Service.CARDDAV));
|
||||
|
||||
assertEquals(
|
||||
server.url(PATH_CALDAV + SUBPATH_PRINCIPAL).uri(),
|
||||
finder.getCurrentUserPrincipal(server.url(PATH_CALDAV), DavResourceFinder.Service.CALDAV)
|
||||
);
|
||||
assertNull(finder.getCurrentUserPrincipal(server.url(PATH_CALDAV), DavResourceFinder.Service.CARDDAV));
|
||||
|
||||
assertEquals(
|
||||
server.url(PATH_CARDDAV + SUBPATH_PRINCIPAL).uri(),
|
||||
finder.getCurrentUserPrincipal(server.url(PATH_CARDDAV), DavResourceFinder.Service.CARDDAV)
|
||||
);
|
||||
assertNull(finder.getCurrentUserPrincipal(server.url(PATH_CARDDAV), DavResourceFinder.Service.CALDAV));
|
||||
}
|
||||
|
||||
|
||||
// mock server
|
||||
|
||||
public class TestDispatcher extends Dispatcher {
|
||||
|
||||
@Override
|
||||
public MockResponse dispatch(RecordedRequest rq) throws InterruptedException {
|
||||
if (!checkAuth(rq)) {
|
||||
MockResponse authenticate = new MockResponse().setResponseCode(401);
|
||||
authenticate.setHeader("WWW-Authenticate", "Basic realm=\"test\"");
|
||||
return authenticate;
|
||||
}
|
||||
|
||||
String path = rq.getPath();
|
||||
|
||||
if ("OPTIONS".equalsIgnoreCase(rq.getMethod())) {
|
||||
String dav = null;
|
||||
if (path.startsWith(PATH_CALDAV))
|
||||
dav = "calendar-access";
|
||||
else if (path.startsWith(PATH_CARDDAV))
|
||||
dav = "addressbook";
|
||||
else if (path.startsWith(PATH_CALDAV_AND_CARDDAV))
|
||||
dav = "calendar-access, addressbook";
|
||||
MockResponse response = new MockResponse().setResponseCode(200);
|
||||
if (dav != null)
|
||||
response.addHeader("DAV", dav);
|
||||
return response;
|
||||
|
||||
} else if ("PROPFIND".equalsIgnoreCase(rq.getMethod())) {
|
||||
String props = null;
|
||||
switch (path) {
|
||||
case PATH_CALDAV:
|
||||
case PATH_CARDDAV:
|
||||
props = "<current-user-principal><href>" + path + SUBPATH_PRINCIPAL + "</href></current-user-principal>";
|
||||
break;
|
||||
|
||||
case PATH_CARDDAV + SUBPATH_PRINCIPAL:
|
||||
props = "<CARD:addressbook-home-set>" +
|
||||
" <href>" + PATH_CARDDAV + SUBPATH_ADDRESSBOOK_HOMESET + "</href>" +
|
||||
"</CARD:addressbook-home-set>";
|
||||
break;
|
||||
case PATH_CARDDAV + SUBPATH_ADDRESSBOOK:
|
||||
props = "<resourcetype>" +
|
||||
" <collection/>" +
|
||||
" <CARD:addressbook/>" +
|
||||
"</resourcetype>";
|
||||
break;
|
||||
}
|
||||
Logger.log.info("Sending props: " + props);
|
||||
return new MockResponse()
|
||||
.setResponseCode(207)
|
||||
.setBody("<multistatus xmlns='DAV:' xmlns:CARD='urn:ietf:params:xml:ns:carddav'>" +
|
||||
"<response>" +
|
||||
" <href>" + rq.getPath() + "</href>" +
|
||||
" <propstat><prop>" + props + "</prop></propstat>" +
|
||||
"</response>" +
|
||||
"</multistatus>");
|
||||
}
|
||||
|
||||
return new MockResponse().setResponseCode(404);
|
||||
}
|
||||
|
||||
private boolean checkAuth(RecordedRequest rq) {
|
||||
return "Basic bW9jazoxMjM0NQ==".equals(rq.getHeader("Authorization"));
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -5,64 +5,81 @@
|
||||
~ 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 xmlns:android="http://schemas.android.com/apk/res/android" xmlns:tools="http://schemas.android.com/tools"
|
||||
package="at.bitfire.davdroid"
|
||||
android:installLocation="internalOnly">
|
||||
-->
|
||||
<manifest package="at.bitfire.davdroid"
|
||||
xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:tools="http://schemas.android.com/tools"
|
||||
android:installLocation="internalOnly">
|
||||
|
||||
<!-- normal permissions -->
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
<uses-permission android:name="android.permission.GET_ACCOUNTS" />
|
||||
<uses-permission android:name="android.permission.READ_SYNC_SETTINGS" />
|
||||
<uses-permission android:name="android.permission.WRITE_SYNC_SETTINGS" />
|
||||
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE"/>
|
||||
<uses-permission android:name="android.permission.ACCESS_WIFI_STATE"/>
|
||||
<uses-permission android:name="android.permission.INTERNET"/>
|
||||
<uses-permission android:name="android.permission.READ_SYNC_SETTINGS"/>
|
||||
<uses-permission android:name="android.permission.READ_SYNC_STATS"/>
|
||||
<uses-permission android:name="android.permission.WRITE_SYNC_SETTINGS"/>
|
||||
<uses-permission android:name="android.permission.REQUEST_IGNORE_BATTERY_OPTIMIZATIONS"/>
|
||||
|
||||
<!-- legacy permissions -->
|
||||
<!-- for writing external log files; permission only required for SDK <= 18 because since then,
|
||||
writing to app-private directory doesn't require extra permissions -->
|
||||
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" android:maxSdkVersion="18" tools:ignore="UnusedAttribute"/>
|
||||
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" android:maxSdkVersion="18" tools:ignore="UnusedAttribute"/>
|
||||
<!-- account management permissions not required for own accounts since API level 22 -->
|
||||
<uses-permission android:name="android.permission.AUTHENTICATE_ACCOUNTS" android:maxSdkVersion="22"/>
|
||||
<uses-permission android:name="android.permission.GET_ACCOUNTS" android:maxSdkVersion="22"/>
|
||||
<uses-permission android:name="android.permission.MANAGE_ACCOUNTS" android:maxSdkVersion="22"/>
|
||||
|
||||
<!--
|
||||
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 -->
|
||||
<uses-permission android:name="android.permission.AUTHENTICATE_ACCOUNTS" />
|
||||
<uses-permission android:name="android.permission.READ_CONTACTS" /> <!-- android.permission-group.CONTACTS -->
|
||||
<uses-permission android:name="android.permission.WRITE_CONTACTS" />
|
||||
<uses-permission android:name="android.permission.READ_CALENDAR" /> <!-- android.permission-group.CALENDAR -->
|
||||
<uses-permission android:name="android.permission.WRITE_CALENDAR" />
|
||||
<!-- android.permission-group.CONTACTS -->
|
||||
<uses-permission android:name="android.permission.READ_CONTACTS"/>
|
||||
<uses-permission android:name="android.permission.WRITE_CONTACTS"/>
|
||||
<!-- android.permission-group.CALENDAR -->
|
||||
<uses-permission android:name="android.permission.READ_CALENDAR"/>
|
||||
<uses-permission android:name="android.permission.WRITE_CALENDAR"/>
|
||||
|
||||
<!-- ical4android declares task access permissions -->
|
||||
|
||||
<application
|
||||
android:allowBackup="true" android:fullBackupContent="false" tools:ignore="UnusedAttribute"
|
||||
android:icon="@drawable/ic_launcher"
|
||||
android:name=".App"
|
||||
android:allowBackup="true"
|
||||
android:fullBackupContent="false"
|
||||
android:networkSecurityConfig="@xml/network_security_config"
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
android:label="@string/app_name"
|
||||
android:theme="@style/AppTheme">
|
||||
android:theme="@style/AppTheme"
|
||||
tools:ignore="UnusedAttribute">
|
||||
|
||||
<receiver
|
||||
android:name=".model.Settings$ReinitSettingsReceiver"
|
||||
android:exported="false"
|
||||
android:process=":sync">
|
||||
<intent-filter>
|
||||
<action android:name="at.bitfire.davdroid.REINIT_SETTINGS"/>
|
||||
</intent-filter>
|
||||
</receiver>
|
||||
<receiver
|
||||
android:name=".AccountSettings$AppUpdatedReceiver"
|
||||
android:exported="true">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.PACKAGE_REPLACED" />
|
||||
<data android:scheme="package" android:path="at.bitfire.davdroid" />
|
||||
</intent-filter>
|
||||
</receiver>
|
||||
|
||||
<!-- account type "DAVdroid" -->
|
||||
<service
|
||||
android:name=".syncadapter.AccountAuthenticatorService"
|
||||
android:exported="false">
|
||||
<intent-filter>
|
||||
<action android:name="android.accounts.AccountAuthenticator" />
|
||||
<action android:name="android.accounts.AccountAuthenticator"/>
|
||||
</intent-filter>
|
||||
|
||||
<meta-data
|
||||
android:name="android.accounts.AccountAuthenticator"
|
||||
android:resource="@xml/account_authenticator" />
|
||||
</service>
|
||||
|
||||
<service
|
||||
android:name=".syncadapter.ContactsSyncAdapterService"
|
||||
android:exported="true"
|
||||
android:process=":sync"
|
||||
tools:ignore="ExportedService">
|
||||
<intent-filter>
|
||||
<action android:name="android.content.SyncAdapter" />
|
||||
</intent-filter>
|
||||
<meta-data
|
||||
android:name="android.content.SyncAdapter"
|
||||
android:resource="@xml/sync_contacts" />
|
||||
<meta-data
|
||||
android:name="android.provider.CONTACTS_STRUCTURE"
|
||||
android:resource="@xml/contacts" />
|
||||
android:resource="@xml/account_authenticator"/>
|
||||
</service>
|
||||
<service
|
||||
android:name=".syncadapter.CalendarsSyncAdapterService"
|
||||
@@ -70,59 +87,155 @@
|
||||
android:process=":sync"
|
||||
tools:ignore="ExportedService">
|
||||
<intent-filter>
|
||||
<action android:name="android.content.SyncAdapter" />
|
||||
<action android:name="android.content.SyncAdapter"/>
|
||||
</intent-filter>
|
||||
|
||||
<meta-data
|
||||
android:name="android.content.SyncAdapter"
|
||||
android:resource="@xml/sync_calendars" />
|
||||
android:resource="@xml/sync_calendars"/>
|
||||
</service>
|
||||
|
||||
<service
|
||||
android:name=".syncadapter.TasksSyncAdapterService"
|
||||
android:exported="true"
|
||||
android:process=":sync"
|
||||
tools:ignore="ExportedService">
|
||||
<intent-filter>
|
||||
<action android:name="android.content.SyncAdapter" />
|
||||
<action android:name="android.content.SyncAdapter"/>
|
||||
</intent-filter>
|
||||
|
||||
<meta-data
|
||||
android:name="android.content.SyncAdapter"
|
||||
android:resource="@xml/sync_tasks" />
|
||||
android:resource="@xml/sync_tasks"/>
|
||||
</service>
|
||||
|
||||
<activity
|
||||
android:name=".ui.MainActivity"
|
||||
android:label="@string/app_name" >
|
||||
<!-- account type "DAVdroid Address book" -->
|
||||
<service
|
||||
android:name=".syncadapter.NullAuthenticatorService"
|
||||
android:exported="false">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
<action android:name="android.accounts.AccountAuthenticator"/>
|
||||
</intent-filter>
|
||||
</activity>
|
||||
<activity
|
||||
android:name=".ui.DebugInfoActivity"
|
||||
android:label="@string/debug_info_title"
|
||||
android:exported="true">
|
||||
</activity>
|
||||
<activity
|
||||
android:name=".ui.setup.AddAccountActivity"
|
||||
android:excludeFromRecents="true" >
|
||||
</activity>
|
||||
<activity
|
||||
android:name=".ui.settings.SettingsActivity"
|
||||
android:label="@string/settings_title" >
|
||||
|
||||
<meta-data
|
||||
android:name="android.accounts.AccountAuthenticator"
|
||||
android:resource="@xml/account_authenticator_address_book"/>
|
||||
</service>
|
||||
<provider
|
||||
android:authorities="@string/address_books_authority"
|
||||
android:exported="false"
|
||||
android:label="@string/address_books_authority_title"
|
||||
android:name=".syncadapter.AddressBookProvider"
|
||||
android:multiprocess="false"/>
|
||||
<service
|
||||
android:name=".syncadapter.AddressBooksSyncAdapterService"
|
||||
android:exported="true"
|
||||
android:process=":sync"
|
||||
tools:ignore="ExportedService">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MANAGE_NETWORK_USAGE" />
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
<action android:name="android.content.SyncAdapter"/>
|
||||
</intent-filter>
|
||||
</activity>
|
||||
|
||||
<meta-data
|
||||
android:name="android.content.SyncAdapter"
|
||||
android:resource="@xml/sync_address_books"/>
|
||||
</service>
|
||||
<service
|
||||
android:name=".syncadapter.ContactsSyncAdapterService"
|
||||
android:exported="true"
|
||||
android:process=":sync"
|
||||
tools:ignore="ExportedService">
|
||||
<intent-filter>
|
||||
<action android:name="android.content.SyncAdapter"/>
|
||||
</intent-filter>
|
||||
|
||||
<meta-data
|
||||
android:name="android.content.SyncAdapter"
|
||||
android:resource="@xml/sync_contacts"/>
|
||||
<meta-data
|
||||
android:name="android.provider.CONTACTS_STRUCTURE"
|
||||
android:resource="@xml/contacts"/>
|
||||
</service>
|
||||
|
||||
<service
|
||||
android:name=".DavService"
|
||||
android:enabled="true">
|
||||
</service>
|
||||
|
||||
<receiver android:name=".AccountsChangedReceiver">
|
||||
<intent-filter>
|
||||
<action android:name="android.accounts.LOGIN_ACCOUNTS_CHANGED"/>
|
||||
</intent-filter>
|
||||
</receiver>
|
||||
|
||||
<receiver android:name=".PackageChangedReceiver">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.PACKAGE_ADDED"/>
|
||||
<action android:name="android.intent.action.PACKAGE_FULLY_REMOVED"/>
|
||||
<data android:scheme="package"/>
|
||||
</intent-filter>
|
||||
</receiver>
|
||||
|
||||
<activity
|
||||
android:name=".ui.settings.AccountActivity"
|
||||
android:label="@string/settings_title"
|
||||
android:parentActivityName=".ui.settings.SettingsActivity" tools:ignore="UnusedAttribute">
|
||||
android:name=".ui.AccountsActivity"
|
||||
android:label="@string/app_name"
|
||||
android:theme="@style/AppTheme.NoActionBar">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN"/>
|
||||
<category android:name="android.intent.category.LAUNCHER"/>
|
||||
</intent-filter>
|
||||
</activity>
|
||||
|
||||
<!-- MemorizingTrustManager -->
|
||||
<activity android:name="de.duenndns.ssl.MemorizingActivity"
|
||||
android:theme="@android:style/Theme.Holo.Light.Dialog.NoActionBar"/>
|
||||
<activity
|
||||
android:name=".ui.AboutActivity"
|
||||
android:label="@string/app_name"
|
||||
android:theme="@style/AppTheme.NoActionBar"
|
||||
android:parentActivityName=".ui.AccountsActivity"/>
|
||||
|
||||
<activity
|
||||
android:name=".ui.AppSettingsActivity"
|
||||
android:label="@string/app_settings"
|
||||
android:parentActivityName=".ui.AccountsActivity"/>
|
||||
|
||||
<activity android:name=".ui.PermissionsActivity"
|
||||
android:label="@string/permissions_title"
|
||||
android:parentActivityName=".ui.AccountsActivity"/>
|
||||
|
||||
<activity
|
||||
android:name=".ui.setup.LoginActivity"
|
||||
android:label="@string/login_title"
|
||||
android:parentActivityName=".ui.AccountsActivity">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN"/>
|
||||
</intent-filter>
|
||||
</activity>
|
||||
|
||||
<activity
|
||||
android:name=".ui.AccountActivity"
|
||||
android:parentActivityName=".ui.AccountsActivity">
|
||||
</activity>
|
||||
<activity android:name=".ui.AccountSettingsActivity"/>
|
||||
<activity android:name=".ui.CreateAddressBookActivity"
|
||||
android:label="@string/create_addressbook"/>
|
||||
<activity android:name=".ui.CreateCalendarActivity"
|
||||
android:label="@string/create_calendar"/>
|
||||
|
||||
<activity
|
||||
android:name=".ui.DebugInfoActivity"
|
||||
android:exported="true"
|
||||
android:label="@string/debug_info_title">
|
||||
</activity>
|
||||
<provider
|
||||
android:name="android.support.v4.content.FileProvider"
|
||||
android:authorities="@string/authority_log_provider"
|
||||
android:grantUriPermissions="true"
|
||||
android:exported="false">
|
||||
<meta-data
|
||||
android:name="android.support.FILE_PROVIDER_PATHS"
|
||||
android:resource="@xml/log_paths" />
|
||||
</provider>
|
||||
|
||||
|
||||
</application>
|
||||
|
||||
</manifest>
|
||||
@@ -0,0 +1,153 @@
|
||||
<h3>Apache License, Version 2.0, January 2004</h3>
|
||||
<p><a href="http://www.apache.org/licenses/">http://www.apache.org/licenses/</a> </p>
|
||||
<p>TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION</p>
|
||||
<p><strong><a name="definitions">1. Definitions</a></strong>.</p>
|
||||
<p>"License" shall mean the terms and conditions for use, reproduction, and
|
||||
distribution as defined by Sections 1 through 9 of this document.</p>
|
||||
<p>"Licensor" shall mean the copyright owner or entity authorized by the
|
||||
copyright owner that is granting the License.</p>
|
||||
<p>"Legal Entity" shall mean the union of the acting entity and all other
|
||||
entities that control, are controlled by, or are under common control with
|
||||
that entity. For the purposes of this definition, "control" means (i) the
|
||||
power, direct or indirect, to cause the direction or management of such
|
||||
entity, whether by contract or otherwise, or (ii) ownership of fifty
|
||||
percent (50%) or more of the outstanding shares, or (iii) beneficial
|
||||
ownership of such entity.</p>
|
||||
<p>"You" (or "Your") shall mean an individual or Legal Entity exercising
|
||||
permissions granted by this License.</p>
|
||||
<p>"Source" form shall mean the preferred form for making modifications,
|
||||
including but not limited to software source code, documentation source,
|
||||
and configuration files.</p>
|
||||
<p>"Object" form shall mean any form resulting from mechanical transformation
|
||||
or translation of a Source form, including but not limited to compiled
|
||||
object code, generated documentation, and conversions to other media types.</p>
|
||||
<p>"Work" shall mean the work of authorship, whether in Source or Object form,
|
||||
made available under the License, as indicated by a copyright notice that
|
||||
is included in or attached to the work (an example is provided in the
|
||||
Appendix below).</p>
|
||||
<p>"Derivative Works" shall mean any work, whether in Source or Object form,
|
||||
that is based on (or derived from) the Work and for which the editorial
|
||||
revisions, annotations, elaborations, or other modifications represent, as
|
||||
a whole, an original work of authorship. For the purposes of this License,
|
||||
Derivative Works shall not include works that remain separable from, or
|
||||
merely link (or bind by name) to the interfaces of, the Work and Derivative
|
||||
Works thereof.</p>
|
||||
<p>"Contribution" shall mean any work of authorship, including the original
|
||||
version of the Work and any modifications or additions to that Work or
|
||||
Derivative Works thereof, that is intentionally submitted to Licensor for
|
||||
inclusion in the Work by the copyright owner or by an individual or Legal
|
||||
Entity authorized to submit on behalf of the copyright owner. For the
|
||||
purposes of this definition, "submitted" means any form of electronic,
|
||||
verbal, or written communication sent to the Licensor or its
|
||||
representatives, including but not limited to communication on electronic
|
||||
mailing lists, source code control systems, and issue tracking systems that
|
||||
are managed by, or on behalf of, the Licensor for the purpose of discussing
|
||||
and improving the Work, but excluding communication that is conspicuously
|
||||
marked or otherwise designated in writing by the copyright owner as "Not a
|
||||
Contribution."</p>
|
||||
<p>"Contributor" shall mean Licensor and any individual or Legal Entity on
|
||||
behalf of whom a Contribution has been received by Licensor and
|
||||
subsequently incorporated within the Work.</p>
|
||||
<p><strong><a name="copyright">2. Grant of Copyright License</a></strong>. Subject to the
|
||||
terms and conditions of this License, each Contributor hereby grants to You
|
||||
a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
copyright license to reproduce, prepare Derivative Works of, publicly
|
||||
display, publicly perform, sublicense, and distribute the Work and such
|
||||
Derivative Works in Source or Object form.</p>
|
||||
<p><strong><a name="patent">3. Grant of Patent License</a></strong>. Subject to the terms
|
||||
and conditions of this License, each Contributor hereby grants to You a
|
||||
perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
(except as stated in this section) patent license to make, have made, use,
|
||||
offer to sell, sell, import, and otherwise transfer the Work, where such
|
||||
license applies only to those patent claims licensable by such Contributor
|
||||
that are necessarily infringed by their Contribution(s) alone or by
|
||||
combination of their Contribution(s) with the Work to which such
|
||||
Contribution(s) was submitted. If You institute patent litigation against
|
||||
any entity (including a cross-claim or counterclaim in a lawsuit) alleging
|
||||
that the Work or a Contribution incorporated within the Work constitutes
|
||||
direct or contributory patent infringement, then any patent licenses
|
||||
granted to You under this License for that Work shall terminate as of the
|
||||
date such litigation is filed.</p>
|
||||
<p><strong><a name="redistribution">4. Redistribution</a></strong>. You may reproduce and
|
||||
distribute copies of the Work or Derivative Works thereof in any medium,
|
||||
with or without modifications, and in Source or Object form, provided that
|
||||
You meet the following conditions:</p>
|
||||
|
||||
<p>a. You must give any other recipients of the Work or Derivative Works a
|
||||
copy of this License; and</p>
|
||||
|
||||
<p>b. You must cause any modified files to carry prominent notices stating
|
||||
that You changed the files; and</p>
|
||||
|
||||
<p>c. You must retain, in the Source form of any Derivative Works that You
|
||||
distribute, all copyright, patent, trademark, and attribution notices from
|
||||
the Source form of the Work, excluding those notices that do not pertain to
|
||||
any part of the Derivative Works; and</p>
|
||||
|
||||
<p>d. If the Work includes a "NOTICE" text file as part of its distribution,
|
||||
then any Derivative Works that You distribute must include a readable copy
|
||||
of the attribution notices contained within such NOTICE file, excluding
|
||||
those notices that do not pertain to any part of the Derivative Works, in
|
||||
at least one of the following places: within a NOTICE text file distributed
|
||||
as part of the Derivative Works; within the Source form or documentation,
|
||||
if provided along with the Derivative Works; or, within a display generated
|
||||
by the Derivative Works, if and wherever such third-party notices normally
|
||||
appear. The contents of the NOTICE file are for informational purposes only
|
||||
and do not modify the License. You may add Your own attribution notices
|
||||
within Derivative Works that You distribute, alongside or as an addendum to
|
||||
the NOTICE text from the Work, provided that such additional attribution
|
||||
notices cannot be construed as modifying the License.
|
||||
<br/>
|
||||
<br/>
|
||||
You may add Your own copyright statement to Your modifications and may
|
||||
provide additional or different license terms and conditions for use,
|
||||
reproduction, or distribution of Your modifications, or for any such
|
||||
Derivative Works as a whole, provided Your use, reproduction, and
|
||||
distribution of the Work otherwise complies with the conditions stated in
|
||||
this License.
|
||||
</p>
|
||||
|
||||
<p><strong><a name="contributions">5. Submission of Contributions</a></strong>. Unless You
|
||||
explicitly state otherwise, any Contribution intentionally submitted for
|
||||
inclusion in the Work by You to the Licensor shall be under the terms and
|
||||
conditions of this License, without any additional terms or conditions.
|
||||
Notwithstanding the above, nothing herein shall supersede or modify the
|
||||
terms of any separate license agreement you may have executed with Licensor
|
||||
regarding such Contributions.</p>
|
||||
<p><strong><a name="trademarks">6. Trademarks</a></strong>. This License does not grant
|
||||
permission to use the trade names, trademarks, service marks, or product
|
||||
names of the Licensor, except as required for reasonable and customary use
|
||||
in describing the origin of the Work and reproducing the content of the
|
||||
NOTICE file.</p>
|
||||
<p><strong><a name="no-warranty">7. Disclaimer of Warranty</a></strong>. Unless required by
|
||||
applicable law or agreed to in writing, Licensor provides the Work (and
|
||||
each Contributor provides its Contributions) on an "AS IS" BASIS, WITHOUT
|
||||
WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied, including,
|
||||
without limitation, any warranties or conditions of TITLE,
|
||||
NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A PARTICULAR PURPOSE. You
|
||||
are solely responsible for determining the appropriateness of using or
|
||||
redistributing the Work and assume any risks associated with Your exercise
|
||||
of permissions under this License.</p>
|
||||
<p><strong><a name="no-liability">8. Limitation of Liability</a></strong>. In no event and
|
||||
under no legal theory, whether in tort (including negligence), contract, or
|
||||
otherwise, unless required by applicable law (such as deliberate and
|
||||
grossly negligent acts) or agreed to in writing, shall any Contributor be
|
||||
liable to You for damages, including any direct, indirect, special,
|
||||
incidental, or consequential damages of any character arising as a result
|
||||
of this License or out of the use or inability to use the Work (including
|
||||
but not limited to damages for loss of goodwill, work stoppage, computer
|
||||
failure or malfunction, or any and all other commercial damages or losses),
|
||||
even if such Contributor has been advised of the possibility of such
|
||||
damages.</p>
|
||||
<p><strong><a name="additional">9. Accepting Warranty or Additional Liability</a></strong>.
|
||||
While redistributing the Work or Derivative Works thereof, You may choose
|
||||
to offer, and charge a fee for, acceptance of support, warranty, indemnity,
|
||||
or other liability obligations and/or rights consistent with this License.
|
||||
However, in accepting such obligations, You may act only on Your own behalf
|
||||
and on Your sole responsibility, not on behalf of any other Contributor,
|
||||
and only if You agree to indemnify, defend, and hold each Contributor
|
||||
harmless for any liability incurred by, or claims asserted against, such
|
||||
Contributor by reason of your accepting any such warranty or additional
|
||||
liability.</p>
|
||||
|
||||
<p>END OF TERMS AND CONDITIONS</p>
|
||||
@@ -0,0 +1,28 @@
|
||||
<h3>BSD License (3-clause)</h3>
|
||||
|
||||
<p>Redistribution and use in source and binary forms, with or without
|
||||
modification, are permitted provided that the following conditions
|
||||
are met:</p>
|
||||
|
||||
<p>o Redistributions of source code must retain the above copyright
|
||||
notice, this list of conditions and the following disclaimer.</p>
|
||||
|
||||
<p>o Redistributions in binary form must reproduce the above copyright
|
||||
notice, this list of conditions and the following disclaimer in the
|
||||
documentation and/or other materials provided with the distribution.</p>
|
||||
|
||||
<p>o Neither the name of Ben Fortuna nor the names of any other contributors
|
||||
may be used to endorse or promote products derived from this software
|
||||
without specific prior written permission.</p>
|
||||
|
||||
<p>THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
|
||||
"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
|
||||
LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
|
||||
A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR
|
||||
CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL,
|
||||
EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO,
|
||||
PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR
|
||||
PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF
|
||||
LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING
|
||||
NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
|
||||
SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.</p>
|
||||
@@ -0,0 +1,23 @@
|
||||
<h3>BSD License</h3>
|
||||
|
||||
<p>Redistribution and use in source and binary forms, with or without
|
||||
modification, are permitted provided that the following conditions are met:</p>
|
||||
|
||||
<p>1. Redistributions of source code must retain the above copyright notice, this
|
||||
list of conditions and the following disclaimer.</p>
|
||||
|
||||
<p>2. Redistributions in binary form must reproduce the above copyright notice,
|
||||
this list of conditions and the following disclaimer in the documentation
|
||||
and/or other materials provided with the distribution.</p>
|
||||
|
||||
<p>THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
|
||||
ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
|
||||
WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
|
||||
DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR
|
||||
ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
|
||||
(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
|
||||
LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND
|
||||
ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
|
||||
(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
|
||||
SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.</p>
|
||||
|
||||
@@ -0,0 +1,628 @@
|
||||
<h3 style="text-align: center;">GNU GENERAL PUBLIC LICENSE</h3>
|
||||
<p style="text-align: center;">Version 3, 29 June 2007</p>
|
||||
|
||||
<p>Copyright © 2007 Free Software Foundation, Inc.
|
||||
<<a href="http://fsf.org/">http://fsf.org/</a>></p><p>
|
||||
Everyone is permitted to copy and distribute verbatim copies
|
||||
of this license document, but changing it is not allowed.</p>
|
||||
|
||||
<h3><a name="preamble"></a>Preamble</h3>
|
||||
|
||||
<p>The GNU General Public License is a free, copyleft license for
|
||||
software and other kinds of works.</p>
|
||||
|
||||
<p>The licenses for most software and other practical works are designed
|
||||
to take away your freedom to share and change the works. By contrast,
|
||||
the GNU General Public License is intended to guarantee your freedom to
|
||||
share and change all versions of a program--to make sure it remains free
|
||||
software for all its users. We, the Free Software Foundation, use the
|
||||
GNU General Public License for most of our software; it applies also to
|
||||
any other work released this way by its authors. You can apply it to
|
||||
your programs, too.</p>
|
||||
|
||||
<p>When we speak of free software, we are referring to freedom, not
|
||||
price. Our General Public Licenses are designed to make sure that you
|
||||
have the freedom to distribute copies of free software (and charge for
|
||||
them if you wish), that you receive source code or can get it if you
|
||||
want it, that you can change the software or use pieces of it in new
|
||||
free programs, and that you know you can do these things.</p>
|
||||
|
||||
<p>To protect your rights, we need to prevent others from denying you
|
||||
these rights or asking you to surrender the rights. Therefore, you have
|
||||
certain responsibilities if you distribute copies of the software, or if
|
||||
you modify it: responsibilities to respect the freedom of others.</p>
|
||||
|
||||
<p>For example, if you distribute copies of such a program, whether
|
||||
gratis or for a fee, you must pass on to the recipients the same
|
||||
freedoms that you received. You must make sure that they, too, receive
|
||||
or can get the source code. And you must show them these terms so they
|
||||
know their rights.</p>
|
||||
|
||||
<p>Developers that use the GNU GPL protect your rights with two steps:
|
||||
(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
|
||||
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
|
||||
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
|
||||
products. If such problems arise substantially in other domains, we
|
||||
stand ready to extend this provision to those domains in future versions
|
||||
of the GPL, as needed to protect the freedom of users.</p>
|
||||
|
||||
<p>Finally, every program is threatened constantly by software patents.
|
||||
States should not allow patents to restrict development and use of
|
||||
software on general-purpose computers, but in those that do, we wish to
|
||||
avoid the special danger that patents applied to a free program could
|
||||
make it effectively proprietary. To prevent this, the GPL assures that
|
||||
patents cannot be used to render the program non-free.</p>
|
||||
|
||||
<p>The precise terms and conditions for copying, distribution and
|
||||
modification follow.</p>
|
||||
|
||||
<h3><a name="terms"></a>TERMS AND CONDITIONS</h3>
|
||||
|
||||
<h4><a name="section0"></a>0. Definitions.</h4>
|
||||
|
||||
<p>“This License” refers to version 3 of the GNU General Public License.</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>
|
||||
|
||||
<p>To “modify” a work means to copy from or adapt all or part of the work
|
||||
in a fashion requiring copyright permission, other than the making of an
|
||||
exact copy. The resulting work is called a “modified version” of the
|
||||
earlier work or a work “based on” the earlier work.</p>
|
||||
|
||||
<p>A “covered work” means either the unmodified Program or a work based
|
||||
on the Program.</p>
|
||||
|
||||
<p>To “propagate” a work means to do anything with it that, without
|
||||
permission, would make you directly or secondarily liable for
|
||||
infringement under applicable copyright law, except executing it on a
|
||||
computer or modifying a private copy. Propagation includes copying,
|
||||
distribution (with or without modification), making available to the
|
||||
public, and in some countries other activities as well.</p>
|
||||
|
||||
<p>To “convey” a work means any kind of propagation that enables other
|
||||
parties to make or receive copies. Mere interaction with a user through
|
||||
a computer network, with no transfer of a copy, is not conveying.</p>
|
||||
|
||||
<p>An interactive user interface displays “Appropriate Legal Notices”
|
||||
to the extent that it includes a convenient and prominently visible
|
||||
feature that (1) displays an appropriate copyright notice, and (2)
|
||||
tells the user that there is no warranty for the work (except to the
|
||||
extent that warranties are provided), that licensees may convey the
|
||||
work under this License, and how to view a copy of this License. If
|
||||
the interface presents a list of user commands or options, such as a
|
||||
menu, a prominent item in the list meets this criterion.</p>
|
||||
|
||||
<h4><a name="section1"></a>1. Source Code.</h4>
|
||||
|
||||
<p>The “source code” for a work means the preferred form of the work
|
||||
for making modifications to it. “Object code” means any non-source
|
||||
form of a work.</p>
|
||||
|
||||
<p>A “Standard Interface” means an interface that either is an official
|
||||
standard defined by a recognized standards body, or, in the case of
|
||||
interfaces specified for a particular programming language, one that
|
||||
is widely used among developers working in that language.</p>
|
||||
|
||||
<p>The “System Libraries” of an executable work include anything, other
|
||||
than the work as a whole, that (a) is included in the normal form of
|
||||
packaging a Major Component, but which is not part of that Major
|
||||
Component, and (b) serves only to enable use of the work with that
|
||||
Major Component, or to implement a Standard Interface for which an
|
||||
implementation is available to the public in source code form. A
|
||||
“Major Component”, in this context, means a major essential component
|
||||
(kernel, window system, and so on) of the specific operating system
|
||||
(if any) on which the executable work runs, or a compiler used to
|
||||
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
|
||||
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
|
||||
includes interface definition files associated with source files for
|
||||
the work, and the source code for shared libraries and dynamically
|
||||
linked subprograms that the work is specifically designed to require,
|
||||
such as by intimate data communication or control flow between those
|
||||
subprograms and other parts of the work.</p>
|
||||
|
||||
<p>The Corresponding Source need not include anything that users
|
||||
can regenerate automatically from other parts of the Corresponding
|
||||
Source.</p>
|
||||
|
||||
<p>The Corresponding Source for a work in source code form is that
|
||||
same work.</p>
|
||||
|
||||
<h4><a name="section2"></a>2. Basic Permissions.</h4>
|
||||
|
||||
<p>All rights granted under this License are granted for the term of
|
||||
copyright on the Program, and are irrevocable provided the stated
|
||||
conditions are met. This License explicitly affirms your unlimited
|
||||
permission to run the unmodified Program. The output from running a
|
||||
covered work is covered by this License only if the output, given its
|
||||
content, constitutes a covered work. This License acknowledges your
|
||||
rights of fair use or other equivalent, as provided by copyright law.</p>
|
||||
|
||||
<p>You may make, run and propagate covered works that you do not
|
||||
convey, without conditions so long as your license otherwise remains
|
||||
in force. You may convey covered works to others for the sole purpose
|
||||
of having them make modifications exclusively for you, or provide you
|
||||
with facilities for running those works, provided that you comply with
|
||||
the terms of this License in conveying all material for which you do
|
||||
not control copyright. Those thus making or running the covered works
|
||||
for you must do so exclusively on your behalf, under your direction
|
||||
and control, on terms that prohibit them from making any copies of
|
||||
your copyrighted material outside their relationship with you.</p>
|
||||
|
||||
<p>Conveying under any other circumstances is permitted solely under
|
||||
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>
|
||||
|
||||
<p>No covered work shall be deemed part of an effective technological
|
||||
measure under any applicable law fulfilling obligations under article
|
||||
11 of the WIPO copyright treaty adopted on 20 December 1996, or
|
||||
similar laws prohibiting or restricting circumvention of such
|
||||
measures.</p>
|
||||
|
||||
<p>When you convey a covered work, you waive any legal power to forbid
|
||||
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
|
||||
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
|
||||
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
|
||||
non-permissive terms added in accord with section 7 apply to the code;
|
||||
keep intact all notices of the absence of any warranty; and give all
|
||||
recipients a copy of this License along with the Program.</p>
|
||||
|
||||
<p>You may charge any price or no price for each copy that you convey,
|
||||
and you may offer support or warranty protection for a fee.</p>
|
||||
|
||||
<h4><a name="section5"></a>5. Conveying Modified Source Versions.</h4>
|
||||
|
||||
<p>You may convey a work based on the Program, or the modifications to
|
||||
produce it from the Program, in the form of source code under the
|
||||
terms of section 4, provided that you also meet all of these conditions:</p>
|
||||
|
||||
<ul>
|
||||
<li>a) The work must carry prominent notices stating that you modified
|
||||
it, and giving a relevant date.</li>
|
||||
|
||||
<li>b) The work must carry prominent notices stating that it is
|
||||
released under this License and any conditions added under section
|
||||
7. This requirement modifies the requirement in section 4 to
|
||||
“keep intact all notices”.</li>
|
||||
|
||||
<li>c) You must license the entire work, as a whole, under this
|
||||
License to anyone who comes into possession of a copy. This
|
||||
License will therefore apply, along with any applicable section 7
|
||||
additional terms, to the whole of the work, and all its parts,
|
||||
regardless of how they are packaged. This License gives no
|
||||
permission to license the work in any other way, but it does not
|
||||
invalidate such permission if you have separately received it.</li>
|
||||
|
||||
<li>d) If the work has interactive user interfaces, each must display
|
||||
Appropriate Legal Notices; however, if the Program has interactive
|
||||
interfaces that do not display Appropriate Legal Notices, your
|
||||
work need not make them do so.</li>
|
||||
</ul>
|
||||
|
||||
<p>A compilation of a covered work with other separate and independent
|
||||
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
|
||||
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>
|
||||
|
||||
<h4><a name="section6"></a>6. Conveying Non-Source Forms.</h4>
|
||||
|
||||
<p>You may convey a covered work in object code form under the terms
|
||||
of sections 4 and 5, provided that you also convey the
|
||||
machine-readable Corresponding Source under the terms of this License,
|
||||
in one of these ways:</p>
|
||||
|
||||
<ul>
|
||||
<li>a) Convey the object code in, or embodied in, a physical product
|
||||
(including a physical distribution medium), accompanied by the
|
||||
Corresponding Source fixed on a durable physical medium
|
||||
customarily used for software interchange.</li>
|
||||
|
||||
<li>b) Convey the object code in, or embodied in, a physical product
|
||||
(including a physical distribution medium), accompanied by a
|
||||
written offer, valid for at least three years and valid for as
|
||||
long as you offer spare parts or customer support for that product
|
||||
model, to give anyone who possesses the object code either (1) a
|
||||
copy of the Corresponding Source for all the software in the
|
||||
product that is covered by this License, on a durable physical
|
||||
medium customarily used for software interchange, for a price no
|
||||
more than your reasonable cost of physically performing this
|
||||
conveying of source, or (2) access to copy the
|
||||
Corresponding Source from a network server at no charge.</li>
|
||||
|
||||
<li>c) Convey individual copies of the object code with a copy of the
|
||||
written offer to provide the Corresponding Source. This
|
||||
alternative is allowed only occasionally and noncommercially, and
|
||||
only if you received the object code with such an offer, in accord
|
||||
with subsection 6b.</li>
|
||||
|
||||
<li>d) Convey the object code by offering access from a designated
|
||||
place (gratis or for a charge), and offer equivalent access to the
|
||||
Corresponding Source in the same way through the same place at no
|
||||
further charge. You need not require recipients to copy the
|
||||
Corresponding Source along with the object code. If the place to
|
||||
copy the object code is a network server, the Corresponding Source
|
||||
may be on a different server (operated by you or a third party)
|
||||
that supports equivalent copying facilities, provided you maintain
|
||||
clear directions next to the object code saying where to find the
|
||||
Corresponding Source. Regardless of what server hosts the
|
||||
Corresponding Source, you remain obligated to ensure that it is
|
||||
available for as long as needed to satisfy these requirements.</li>
|
||||
|
||||
<li>e) Convey the object code using peer-to-peer transmission, provided
|
||||
you inform other peers where the object code and Corresponding
|
||||
Source of the work are being offered to the general public at no
|
||||
charge under subsection 6d.</li>
|
||||
</ul>
|
||||
|
||||
<p>A separable portion of the object code, whose source code is excluded
|
||||
from the Corresponding Source as a System Library, need not be
|
||||
included in conveying the object code work.</p>
|
||||
|
||||
<p>A “User Product” is either (1) a “consumer product”, which means any
|
||||
tangible personal property which is normally used for personal, family,
|
||||
or household purposes, or (2) anything designed or sold for incorporation
|
||||
into a dwelling. In determining whether a product is a consumer product,
|
||||
doubtful cases shall be resolved in favor of coverage. For a particular
|
||||
product received by a particular user, “normally used” refers to a
|
||||
typical or common use of that class of product, regardless of the status
|
||||
of the particular user or of the way in which the particular user
|
||||
actually uses, or expects or is expected to use, the product. A product
|
||||
is a consumer product regardless of whether the product has substantial
|
||||
commercial, industrial or non-consumer uses, unless such uses represent
|
||||
the only significant mode of use of the product.</p>
|
||||
|
||||
<p>“Installation Information” for a User Product means any methods,
|
||||
procedures, authorization keys, or other information required to install
|
||||
and execute modified versions of a covered work in that User Product from
|
||||
a modified version of its Corresponding Source. The information must
|
||||
suffice to ensure that the continued functioning of the modified object
|
||||
code is in no case prevented or interfered with solely because
|
||||
modification has been made.</p>
|
||||
|
||||
<p>If you convey an object code work under this section in, or with, or
|
||||
specifically for use in, a User Product, and the conveying occurs as
|
||||
part of a transaction in which the right of possession and use of the
|
||||
User Product is transferred to the recipient in perpetuity or for a
|
||||
fixed term (regardless of how the transaction is characterized), the
|
||||
Corresponding Source conveyed under this section must be accompanied
|
||||
by the Installation Information. But this requirement does not apply
|
||||
if neither you nor any third party retains the ability to install
|
||||
modified object code on the User Product (for example, the work has
|
||||
been installed in ROM).</p>
|
||||
|
||||
<p>The requirement to provide Installation Information does not include a
|
||||
requirement to continue to provide support service, warranty, or updates
|
||||
for a work that has been modified or installed by the recipient, or for
|
||||
the User Product in which it has been modified or installed. Access to a
|
||||
network may be denied when the modification itself materially and
|
||||
adversely affects the operation of the network or violates the rules and
|
||||
protocols for communication across the network.</p>
|
||||
|
||||
<p>Corresponding Source conveyed, and Installation Information provided,
|
||||
in accord with this section must be in a format that is publicly
|
||||
documented (and with an implementation available to the public in
|
||||
source code form), and must require no special password or key for
|
||||
unpacking, reading or copying.</p>
|
||||
|
||||
<h4><a name="section7"></a>7. Additional Terms.</h4>
|
||||
|
||||
<p>“Additional permissions” are terms that supplement the terms of this
|
||||
License by making exceptions from one or more of its conditions.
|
||||
Additional permissions that are applicable to the entire Program shall
|
||||
be treated as though they were included in this License, to the extent
|
||||
that they are valid under applicable law. If additional permissions
|
||||
apply only to part of the Program, that part may be used separately
|
||||
under those permissions, but the entire Program remains governed by
|
||||
this License without regard to the additional permissions.</p>
|
||||
|
||||
<p>When you convey a copy of a covered work, you may at your option
|
||||
remove any additional permissions from that copy, or from any part of
|
||||
it. (Additional permissions may be written to require their own
|
||||
removal in certain cases when you modify the work.) You may place
|
||||
additional permissions on material, added by you to a covered work,
|
||||
for which you have or can give appropriate copyright permission.</p>
|
||||
|
||||
<p>Notwithstanding any other provision of this License, for material you
|
||||
add to a covered work, you may (if authorized by the copyright holders of
|
||||
that material) supplement the terms of this License with terms:</p>
|
||||
|
||||
<ul>
|
||||
<li>a) Disclaiming warranty or limiting liability differently from the
|
||||
terms of sections 15 and 16 of this License; or</li>
|
||||
|
||||
<li>b) Requiring preservation of specified reasonable legal notices or
|
||||
author attributions in that material or in the Appropriate Legal
|
||||
Notices displayed by works containing it; or</li>
|
||||
|
||||
<li>c) Prohibiting misrepresentation of the origin of that material, or
|
||||
requiring that modified versions of such material be marked in
|
||||
reasonable ways as different from the original version; or</li>
|
||||
|
||||
<li>d) Limiting the use for publicity purposes of names of licensors or
|
||||
authors of the material; or</li>
|
||||
|
||||
<li>e) Declining to grant rights under trademark law for use of some
|
||||
trade names, trademarks, or service marks; or</li>
|
||||
|
||||
<li>f) Requiring indemnification of licensors and authors of that
|
||||
material by anyone who conveys the material (or modified versions of
|
||||
it) with contractual assumptions of liability to the recipient, for
|
||||
any liability that these contractual assumptions directly impose on
|
||||
those licensors and authors.</li>
|
||||
</ul>
|
||||
|
||||
<p>All other non-permissive additional terms are considered “further
|
||||
restrictions” within the meaning of section 10. If the Program as you
|
||||
received it, or any part of it, contains a notice stating that it is
|
||||
governed by this License along with a term that is a further
|
||||
restriction, you may remove that term. If a license document contains
|
||||
a further restriction but permits relicensing or conveying under this
|
||||
License, you may add to a covered work material governed by the terms
|
||||
of that license document, provided that the further restriction does
|
||||
not survive such relicensing or conveying.</p>
|
||||
|
||||
<p>If you add terms to a covered work in accord with this section, you
|
||||
must place, in the relevant source files, a statement of the
|
||||
additional terms that apply to those files, or a notice indicating
|
||||
where to find the applicable terms.</p>
|
||||
|
||||
<p>Additional terms, permissive or non-permissive, may be stated in the
|
||||
form of a separately written license, or stated as exceptions;
|
||||
the above requirements apply either way.</p>
|
||||
|
||||
<h4><a name="section8"></a>8. Termination.</h4>
|
||||
|
||||
<p>You may not propagate or modify a covered work except as expressly
|
||||
provided under this License. Any attempt otherwise to propagate or
|
||||
modify it is void, and will automatically terminate your rights under
|
||||
this License (including any patent licenses granted under the third
|
||||
paragraph of section 11).</p>
|
||||
|
||||
<p>However, if you cease all violation of this License, then your
|
||||
license from a particular copyright holder is reinstated (a)
|
||||
provisionally, unless and until the copyright holder explicitly and
|
||||
finally terminates your license, and (b) permanently, if the copyright
|
||||
holder fails to notify you of the violation by some reasonable means
|
||||
prior to 60 days after the cessation.</p>
|
||||
|
||||
<p>Moreover, your license from a particular copyright holder is
|
||||
reinstated permanently if the copyright holder notifies you of the
|
||||
violation by some reasonable means, this is the first time you have
|
||||
received notice of violation of this License (for any work) from that
|
||||
copyright holder, and you cure the violation prior to 30 days after
|
||||
your receipt of the notice.</p>
|
||||
|
||||
<p>Termination of your rights under this section does not terminate the
|
||||
licenses of parties who have received copies or rights from you under
|
||||
this License. If your rights have been terminated and not permanently
|
||||
reinstated, you do not qualify to receive new licenses for the same
|
||||
material under section 10.</p>
|
||||
|
||||
<h4><a name="section9"></a>9. Acceptance Not Required for Having Copies.</h4>
|
||||
|
||||
<p>You are not required to accept this License in order to receive or
|
||||
run a copy of the Program. Ancillary propagation of a covered work
|
||||
occurring solely as a consequence of using peer-to-peer transmission
|
||||
to receive a copy likewise does not require acceptance. However,
|
||||
nothing other than this License grants you permission to propagate or
|
||||
modify any covered work. These actions infringe copyright if you do
|
||||
not accept this License. Therefore, by modifying or propagating a
|
||||
covered work, you indicate your acceptance of this License to do so.</p>
|
||||
|
||||
<h4><a name="section10"></a>10. Automatic Licensing of Downstream Recipients.</h4>
|
||||
|
||||
<p>Each time you convey a covered work, the recipient automatically
|
||||
receives a license from the original licensors, to run, modify and
|
||||
propagate that work, subject to this License. You are not responsible
|
||||
for enforcing compliance by third parties with this License.</p>
|
||||
|
||||
<p>An “entity transaction” is a transaction transferring control of an
|
||||
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
|
||||
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>
|
||||
|
||||
<p>You may not impose any further restrictions on the exercise of the
|
||||
rights granted or affirmed under this License. For example, you may
|
||||
not impose a license fee, royalty, or other charge for exercise of
|
||||
rights granted under this License, and you may not initiate litigation
|
||||
(including a cross-claim or counterclaim in a lawsuit) alleging that
|
||||
any patent claim is infringed by making, using, selling, offering for
|
||||
sale, or importing the Program or any portion of it.</p>
|
||||
|
||||
<h4><a name="section11"></a>11. Patents.</h4>
|
||||
|
||||
<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>
|
||||
|
||||
<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,
|
||||
but do not include claims that would be infringed only as a
|
||||
consequence of further modification of the contributor version. For
|
||||
purposes of this definition, “control” includes the right to grant
|
||||
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
|
||||
make, use, sell, offer for sale, import and otherwise run, modify and
|
||||
propagate the contents of its contributor version.</p>
|
||||
|
||||
<p>In the following three paragraphs, a “patent license” is any express
|
||||
agreement or commitment, however denominated, not to enforce a patent
|
||||
(such as an express permission to practice a patent or covenant not to
|
||||
sue for patent infringement). To “grant” such a patent license to a
|
||||
party means to make such an agreement or commitment not to enforce a
|
||||
patent against the party.</p>
|
||||
|
||||
<p>If you convey a covered work, knowingly relying on a patent license,
|
||||
and the Corresponding Source of the work is not available for anyone
|
||||
to copy, free of charge and under the terms of this License, through a
|
||||
publicly available network server or other readily accessible means,
|
||||
then you must either (1) cause the Corresponding Source to be so
|
||||
available, or (2) arrange to deprive yourself of the benefit of the
|
||||
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
|
||||
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
|
||||
receiving the covered work authorizing them to use, propagate, modify
|
||||
or convey a specific copy of the covered work, then the patent license
|
||||
you grant is automatically extended to all recipients of the covered
|
||||
work and works based on it.</p>
|
||||
|
||||
<p>A patent license is “discriminatory” if it does not include within
|
||||
the scope of its coverage, prohibits the exercise of, or is
|
||||
conditioned on the non-exercise of one or more of the rights that are
|
||||
specifically granted under this License. You may not convey a covered
|
||||
work if you are a party to an arrangement with a third party that is
|
||||
in the business of distributing software, under which you make payment
|
||||
to the third party based on the extent of your activity of conveying
|
||||
the work, and under which the third party grants, to any of the
|
||||
parties who would receive the covered work from you, a discriminatory
|
||||
patent license (a) in connection with copies of the covered work
|
||||
conveyed by you (or copies made from those copies), or (b) primarily
|
||||
for and in connection with specific products or compilations that
|
||||
contain the covered work, unless you entered into that arrangement,
|
||||
or that patent license was granted, prior to 28 March 2007.</p>
|
||||
|
||||
<p>Nothing in this License shall be construed as excluding or limiting
|
||||
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>
|
||||
|
||||
<p>If conditions are imposed on you (whether by court order, agreement or
|
||||
otherwise) that contradict the conditions of this License, they do not
|
||||
excuse you from the conditions of this License. If you cannot convey a
|
||||
covered work so as to satisfy simultaneously your obligations under this
|
||||
License and any other pertinent obligations, then as a consequence you may
|
||||
not convey it at all. For example, if you agree to terms that obligate you
|
||||
to collect a royalty for further conveying from those to whom you convey
|
||||
the Program, the only way you could satisfy both those terms and this
|
||||
License would be to refrain entirely from conveying the Program.</p>
|
||||
|
||||
<h4><a name="section13"></a>13. Use with the GNU Affero General Public License.</h4>
|
||||
|
||||
<p>Notwithstanding any other provision of this License, you have
|
||||
permission to link or combine any covered work with a work licensed
|
||||
under version 3 of the GNU Affero General Public License into a single
|
||||
combined work, and to convey the resulting work. The terms of this
|
||||
License will continue to apply to the part which is the covered work,
|
||||
but the special requirements of the GNU Affero General Public License,
|
||||
section 13, concerning interaction through a network will apply to the
|
||||
combination as such.</p>
|
||||
|
||||
<h4><a name="section14"></a>14. Revised Versions of this License.</h4>
|
||||
|
||||
<p>The Free Software Foundation may publish revised and/or new versions of
|
||||
the GNU General Public License from time to time. Such new versions will
|
||||
be similar in spirit to the present version, but may differ in detail to
|
||||
address new problems or concerns.</p>
|
||||
|
||||
<p>Each version is given a distinguishing version number. If the
|
||||
Program specifies that a certain numbered version of the GNU General
|
||||
Public License “or any later version” applies to it, you have the
|
||||
option of following the terms and conditions either of that numbered
|
||||
version or of any later version published by the Free Software
|
||||
Foundation. If the Program does not specify a version number of the
|
||||
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
|
||||
public statement of acceptance of a version permanently authorizes you
|
||||
to choose that version for the Program.</p>
|
||||
|
||||
<p>Later license versions may give you additional or different
|
||||
permissions. However, no additional obligations are imposed on any
|
||||
author or copyright holder as a result of your choosing to follow a
|
||||
later version.</p>
|
||||
|
||||
<h4><a name="section15"></a>15. Disclaimer of Warranty.</h4>
|
||||
|
||||
<p>THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
|
||||
APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
|
||||
HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM “AS IS” WITHOUT WARRANTY
|
||||
OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
|
||||
THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
|
||||
PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
|
||||
IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
|
||||
ALL NECESSARY SERVICING, REPAIR OR CORRECTION.</p>
|
||||
|
||||
<h4><a name="section16"></a>16. Limitation of Liability.</h4>
|
||||
|
||||
<p>IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
|
||||
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
|
||||
THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
|
||||
GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
|
||||
USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
|
||||
DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
|
||||
PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
|
||||
EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
|
||||
SUCH DAMAGES.</p>
|
||||
|
||||
<h4><a name="section17"></a>17. Interpretation of Sections 15 and 16.</h4>
|
||||
|
||||
<p>If the disclaimer of warranty and limitation of liability provided
|
||||
above cannot be given local legal effect according to their terms,
|
||||
reviewing courts shall apply local law that most closely approximates
|
||||
an absolute waiver of all civil liability in connection with the
|
||||
Program, unless a warranty or assumption of liability accompanies a
|
||||
copy of the Program in return for a fee.</p>
|
||||
|
||||
<p>END OF TERMS AND CONDITIONS</p>
|
||||
@@ -0,0 +1,7 @@
|
||||
<h3>The MIT License (MIT)</h3>
|
||||
|
||||
<p>Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:</p>
|
||||
|
||||
<p>The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.</p>
|
||||
|
||||
<p>THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.</p>
|
||||
@@ -0,0 +1,455 @@
|
||||
/*
|
||||
* Copyright © Ricki Hirner (bitfire web engineering).
|
||||
* All rights reserved. This program and the accompanying materials
|
||||
* are made available under the terms of the GNU Public License v3.0
|
||||
* which accompanies this distribution, and is available at
|
||||
* http://www.gnu.org/licenses/gpl.html
|
||||
*/
|
||||
package at.bitfire.davdroid
|
||||
|
||||
import android.accounts.Account
|
||||
import android.accounts.AccountManager
|
||||
import android.annotation.SuppressLint
|
||||
import android.content.*
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import android.os.Parcel
|
||||
import android.os.RemoteException
|
||||
import android.provider.CalendarContract
|
||||
import android.provider.ContactsContract
|
||||
import at.bitfire.davdroid.log.Logger
|
||||
import at.bitfire.davdroid.model.CollectionInfo
|
||||
import at.bitfire.davdroid.model.ServiceDB
|
||||
import at.bitfire.davdroid.model.ServiceDB.*
|
||||
import at.bitfire.davdroid.model.ServiceDB.Collections
|
||||
import at.bitfire.davdroid.resource.LocalAddressBook
|
||||
import at.bitfire.davdroid.resource.LocalCalendar
|
||||
import at.bitfire.davdroid.resource.LocalTaskList
|
||||
import at.bitfire.ical4android.AndroidCalendar
|
||||
import at.bitfire.ical4android.AndroidTaskList
|
||||
import at.bitfire.ical4android.CalendarStorageException
|
||||
import at.bitfire.vcard4android.ContactsStorageException
|
||||
import at.bitfire.vcard4android.GroupMethod
|
||||
import okhttp3.HttpUrl
|
||||
import java.util.*
|
||||
import java.util.logging.Level
|
||||
|
||||
class AccountSettings @Throws(InvalidAccountException::class) constructor(
|
||||
val context: Context,
|
||||
val account: Account
|
||||
) {
|
||||
|
||||
companion object {
|
||||
|
||||
val CURRENT_VERSION = 6
|
||||
val KEY_SETTINGS_VERSION = "version"
|
||||
|
||||
val KEY_USERNAME = "user_name"
|
||||
|
||||
val KEY_WIFI_ONLY = "wifi_only" // sync on WiFi only (default: false)
|
||||
val KEY_WIFI_ONLY_SSID = "wifi_only_ssid" // restrict sync to specific WiFi SSID
|
||||
|
||||
/** Time range limitation to the past [in days]
|
||||
value = null default value (DEFAULT_TIME_RANGE_PAST_DAYS)
|
||||
< 0 (-1) no limit
|
||||
>= 0 entries more than n days in the past won't be synchronized
|
||||
*/
|
||||
val KEY_TIME_RANGE_PAST_DAYS = "time_range_past_days"
|
||||
val DEFAULT_TIME_RANGE_PAST_DAYS = 90
|
||||
|
||||
/* Whether DAVdroid sets the local calendar color to the value from service DB at every sync
|
||||
value = null (not existing) true (default)
|
||||
"0" false */
|
||||
val KEY_MANAGE_CALENDAR_COLORS = "manage_calendar_colors"
|
||||
|
||||
/** Contact group method:
|
||||
value = null (not existing) groups as separate VCards (default)
|
||||
"CATEGORIES" groups are per-contact CATEGORIES
|
||||
*/
|
||||
val KEY_CONTACT_GROUP_METHOD = "contact_group_method"
|
||||
|
||||
@JvmField
|
||||
val SYNC_INTERVAL_MANUALLY = -1L
|
||||
|
||||
@JvmStatic
|
||||
fun initialUserData(userName: String): Bundle
|
||||
{
|
||||
val bundle = Bundle(2)
|
||||
bundle.putString(KEY_SETTINGS_VERSION, CURRENT_VERSION.toString())
|
||||
bundle.putString(KEY_USERNAME, userName)
|
||||
return bundle
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
val accountManager: AccountManager = AccountManager.get(context)
|
||||
|
||||
init {
|
||||
synchronized(AccountSettings::class.java) {
|
||||
val versionStr = accountManager.getUserData(account, KEY_SETTINGS_VERSION) ?: throw InvalidAccountException(account)
|
||||
var version = 0
|
||||
try {
|
||||
version = Integer.parseInt(versionStr)
|
||||
} catch (e: NumberFormatException) {
|
||||
}
|
||||
Logger.log.fine("Account ${account.name} has version $version, current version: $CURRENT_VERSION")
|
||||
|
||||
if (version < CURRENT_VERSION)
|
||||
update(version)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
// authentication settings
|
||||
|
||||
fun username(): String? = accountManager.getUserData(account, KEY_USERNAME)
|
||||
fun username(userName: String) = accountManager.setUserData(account, KEY_USERNAME, userName)
|
||||
|
||||
fun password(): String? = accountManager.getPassword(account)
|
||||
fun password(password: String) = accountManager.setPassword(account, password)
|
||||
|
||||
|
||||
// sync. settings
|
||||
|
||||
fun getSyncInterval(authority: String): Long? {
|
||||
if (ContentResolver.getIsSyncable(account, authority) <= 0)
|
||||
return null
|
||||
|
||||
return if (ContentResolver.getSyncAutomatically(account, authority))
|
||||
ContentResolver.getPeriodicSyncs(account, authority).firstOrNull()?.period ?: SYNC_INTERVAL_MANUALLY
|
||||
else
|
||||
SYNC_INTERVAL_MANUALLY
|
||||
}
|
||||
|
||||
fun setSyncInterval(authority: String, seconds: Long) {
|
||||
if (seconds == SYNC_INTERVAL_MANUALLY) {
|
||||
ContentResolver.setSyncAutomatically(account, authority, false)
|
||||
} else {
|
||||
ContentResolver.setSyncAutomatically(account, authority, true)
|
||||
ContentResolver.addPeriodicSync(account, authority, Bundle(), seconds)
|
||||
}
|
||||
}
|
||||
|
||||
fun getSyncWifiOnly() = accountManager.getUserData(account, KEY_WIFI_ONLY) != null
|
||||
fun setSyncWiFiOnly(wiFiOnly: Boolean) =
|
||||
accountManager.setUserData(account, KEY_WIFI_ONLY, if (wiFiOnly) "1" else null)
|
||||
|
||||
fun getSyncWifiOnlySSID(): String? = accountManager.getUserData(account, KEY_WIFI_ONLY_SSID)
|
||||
fun setSyncWifiOnlySSID(ssid: String?) = accountManager.setUserData(account, KEY_WIFI_ONLY_SSID, ssid)
|
||||
|
||||
|
||||
// CalDAV settings
|
||||
|
||||
fun getTimeRangePastDays(): Int? {
|
||||
val strDays = accountManager.getUserData(account, KEY_TIME_RANGE_PAST_DAYS)
|
||||
if (strDays != null) {
|
||||
val days = Integer.valueOf(strDays)
|
||||
return if (days < 0) null else days
|
||||
} else
|
||||
return DEFAULT_TIME_RANGE_PAST_DAYS
|
||||
}
|
||||
|
||||
fun setTimeRangePastDays(days: Int?) =
|
||||
accountManager.setUserData(account, KEY_TIME_RANGE_PAST_DAYS, (days ?: -1).toString())
|
||||
|
||||
fun getManageCalendarColors() = accountManager.getUserData(account, KEY_MANAGE_CALENDAR_COLORS) == null
|
||||
fun setManageCalendarColors(manage: Boolean) =
|
||||
accountManager.setUserData(account, KEY_MANAGE_CALENDAR_COLORS, if (manage) null else "0")
|
||||
|
||||
|
||||
// CardDAV settings
|
||||
|
||||
fun getGroupMethod(): GroupMethod {
|
||||
val name = accountManager.getUserData(account, KEY_CONTACT_GROUP_METHOD)
|
||||
return if (name != null)
|
||||
GroupMethod.valueOf(name)
|
||||
else
|
||||
GroupMethod.GROUP_VCARDS
|
||||
}
|
||||
|
||||
fun setGroupMethod(method: GroupMethod) {
|
||||
val name = if (method == GroupMethod.GROUP_VCARDS) null else method.name
|
||||
accountManager.setUserData(account, KEY_CONTACT_GROUP_METHOD, name)
|
||||
}
|
||||
|
||||
|
||||
// update from previous account settings
|
||||
|
||||
private fun update(baseVersion: Int) {
|
||||
for (toVersion in baseVersion+1 .. CURRENT_VERSION) {
|
||||
val fromVersion = toVersion-1
|
||||
Logger.log.info("Updating account ${account.name} from version $fromVersion to $toVersion")
|
||||
try {
|
||||
val updateProc = this::class.java.getDeclaredMethod("update_${fromVersion}_$toVersion")
|
||||
updateProc.invoke(this)
|
||||
accountManager.setUserData(account, KEY_SETTINGS_VERSION, toVersion.toString())
|
||||
} catch (e: Exception) {
|
||||
Logger.log.log(Level.SEVERE, "Couldn't update account settings", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Suppress("unused")
|
||||
private fun update_1_2() {
|
||||
/* - KEY_ADDRESSBOOK_URL ("addressbook_url"),
|
||||
- KEY_ADDRESSBOOK_CTAG ("addressbook_ctag"),
|
||||
- KEY_ADDRESSBOOK_VCARD_VERSION ("addressbook_vcard_version") are not used anymore (now stored in ContactsContract.SyncState)
|
||||
- KEY_LAST_ANDROID_VERSION ("last_android_version") has been added
|
||||
*/
|
||||
|
||||
// move previous address book info to ContactsContract.SyncState
|
||||
val provider = context.contentResolver.acquireContentProviderClient(ContactsContract.AUTHORITY) ?:
|
||||
throw ContactsStorageException("Couldn't access Contacts provider")
|
||||
|
||||
try {
|
||||
val addr = LocalAddressBook(context, account, provider)
|
||||
|
||||
// until now, ContactsContract.Settings.UNGROUPED_VISIBLE was not set explicitly
|
||||
val values = ContentValues()
|
||||
values.put(ContactsContract.Settings.UNGROUPED_VISIBLE, 1)
|
||||
addr.updateSettings(values)
|
||||
|
||||
val url = accountManager.getUserData(account, "addressbook_url")
|
||||
if (!url.isNullOrEmpty())
|
||||
addr.setURL(url)
|
||||
accountManager.setUserData(account, "addressbook_url", null)
|
||||
|
||||
val cTag = accountManager.getUserData (account, "addressbook_ctag")
|
||||
if (!cTag.isNullOrEmpty())
|
||||
addr.setCTag(cTag)
|
||||
accountManager.setUserData(account, "addressbook_ctag", null)
|
||||
} finally {
|
||||
if (Build.VERSION.SDK_INT >= 24)
|
||||
provider.close()
|
||||
else
|
||||
@Suppress("deprecation")
|
||||
provider.release()
|
||||
}
|
||||
}
|
||||
|
||||
@Suppress("unused")
|
||||
private fun update_2_3() {
|
||||
// Don't show a warning for Android updates anymore
|
||||
accountManager.setUserData(account, "last_android_version", null)
|
||||
|
||||
var serviceCardDAV: Long? = null
|
||||
var serviceCalDAV: Long? = null
|
||||
|
||||
ServiceDB.OpenHelper(context).use { dbHelper ->
|
||||
val db = dbHelper.writableDatabase
|
||||
// we have to create the WebDAV Service database only from the old address book, calendar and task list URLs
|
||||
|
||||
// CardDAV: migrate address books
|
||||
context.contentResolver.acquireContentProviderClient(ContactsContract.AUTHORITY)?.let { client ->
|
||||
try {
|
||||
val addrBook = LocalAddressBook(context, account, client)
|
||||
val url = addrBook.getURL()
|
||||
Logger.log.fine("Migrating address book $url")
|
||||
|
||||
// insert CardDAV service
|
||||
val values = ContentValues(3)
|
||||
values.put(Services.ACCOUNT_NAME, account.name)
|
||||
values.put(Services.SERVICE, Services.SERVICE_CARDDAV)
|
||||
serviceCardDAV = db.insert(Services._TABLE, null, values)
|
||||
|
||||
// insert address book
|
||||
values.clear()
|
||||
values.put(Collections.SERVICE_ID, serviceCardDAV)
|
||||
values.put(Collections.URL, url)
|
||||
values.put(Collections.SYNC, 1)
|
||||
db.insert(Collections._TABLE, null, values)
|
||||
|
||||
// insert home set
|
||||
HttpUrl.parse(url)?.let {
|
||||
val homeSet = it.resolve("../")
|
||||
values.clear()
|
||||
values.put(HomeSets.SERVICE_ID, serviceCardDAV)
|
||||
values.put(HomeSets.URL, homeSet.toString())
|
||||
db.insert(HomeSets._TABLE, null, values)
|
||||
}
|
||||
} catch (e: ContactsStorageException) {
|
||||
Logger.log.log(Level.SEVERE, "Couldn't migrate address book", e)
|
||||
} finally {
|
||||
if (Build.VERSION.SDK_INT >= 24)
|
||||
client.close()
|
||||
else
|
||||
@Suppress("deprecation")
|
||||
client.release()
|
||||
}
|
||||
}
|
||||
|
||||
// CalDAV: migrate calendars + task lists
|
||||
val collections = HashSet<String>()
|
||||
val homeSets = HashSet<HttpUrl>()
|
||||
|
||||
context.contentResolver.acquireContentProviderClient(CalendarContract.AUTHORITY)?.let { client ->
|
||||
try {
|
||||
val calendars = AndroidCalendar.find(account, client, LocalCalendar.Factory, null, null)
|
||||
for (calendar in calendars)
|
||||
calendar.name?.let { url ->
|
||||
Logger.log.fine("Migrating calendar $url")
|
||||
collections.add(url)
|
||||
HttpUrl.parse(url)?.resolve("../")?.let { homeSets.add(it) }
|
||||
}
|
||||
} catch (e: CalendarStorageException) {
|
||||
Logger.log.log(Level.SEVERE, "Couldn't migrate calendars", e)
|
||||
} finally {
|
||||
if (Build.VERSION.SDK_INT >= 24)
|
||||
client.close()
|
||||
else
|
||||
@Suppress("deprecation")
|
||||
client.release()
|
||||
}
|
||||
}
|
||||
|
||||
AndroidTaskList.acquireTaskProvider(context.contentResolver)?.use { provider ->
|
||||
try {
|
||||
val taskLists = AndroidTaskList.find(account, provider, LocalTaskList.Factory, null, null)
|
||||
for (taskList in taskLists)
|
||||
taskList.syncId?.let { url ->
|
||||
Logger.log.fine("Migrating task list $url")
|
||||
collections.add(url)
|
||||
HttpUrl.parse(url)?.resolve("../")?.let { homeSets.add(it) }
|
||||
}
|
||||
} catch (e: CalendarStorageException) {
|
||||
Logger.log.log(Level.SEVERE, "Couldn't migrate task lists", e)
|
||||
}
|
||||
}
|
||||
|
||||
if (!collections.isEmpty()) {
|
||||
// insert CalDAV service
|
||||
val values = ContentValues(3)
|
||||
values.put(Services.ACCOUNT_NAME, account.name)
|
||||
values.put(Services.SERVICE, Services.SERVICE_CALDAV)
|
||||
serviceCalDAV = db.insert(Services._TABLE, null, values)
|
||||
|
||||
// insert collections
|
||||
for (url in collections) {
|
||||
values.clear()
|
||||
values.put(Collections.SERVICE_ID, serviceCalDAV)
|
||||
values.put(Collections.URL, url)
|
||||
values.put(Collections.SYNC, 1)
|
||||
db.insert(Collections._TABLE, null, values)
|
||||
}
|
||||
|
||||
// insert home sets
|
||||
for (homeSet in homeSets) {
|
||||
values.clear()
|
||||
values.put(HomeSets.SERVICE_ID, serviceCalDAV)
|
||||
values.put(HomeSets.URL, homeSet.toString())
|
||||
db.insert(HomeSets._TABLE, null, values)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// initiate service detection (refresh) to get display names, colors etc.
|
||||
val refresh = Intent(context, DavService::class.java)
|
||||
refresh.action = DavService.ACTION_REFRESH_COLLECTIONS
|
||||
serviceCardDAV?.let {
|
||||
refresh.putExtra(DavService.EXTRA_DAV_SERVICE_ID, it)
|
||||
context.startService(refresh)
|
||||
}
|
||||
serviceCalDAV?.let {
|
||||
refresh.putExtra(DavService.EXTRA_DAV_SERVICE_ID, it)
|
||||
context.startService(refresh)
|
||||
}
|
||||
}
|
||||
|
||||
@Suppress("unused")
|
||||
private fun update_3_4() {
|
||||
setGroupMethod(GroupMethod.CATEGORIES)
|
||||
}
|
||||
|
||||
/* Android 7.1.1 OpenTasks fix */
|
||||
@Suppress("unused")
|
||||
private fun update_4_5() {
|
||||
// call PackageChangedReceiver which then enables/disables OpenTasks sync when it's (not) available
|
||||
PackageChangedReceiver.updateTaskSync(context)
|
||||
}
|
||||
|
||||
@Suppress("unused")
|
||||
private fun update_5_6() {
|
||||
context.contentResolver.acquireContentProviderClient(ContactsContract.AUTHORITY)?.use { provider ->
|
||||
// don't run syncs during the migration
|
||||
ContentResolver.setIsSyncable(account, ContactsContract.AUTHORITY, 0)
|
||||
ContentResolver.setIsSyncable(account, App.addressBooksAuthority, 0)
|
||||
ContentResolver.cancelSync(account, null)
|
||||
|
||||
val parcel = Parcel.obtain()
|
||||
try {
|
||||
// get previous address book settings (including URL)
|
||||
val raw = ContactsContract.SyncState.get(provider, account)
|
||||
if (raw == null)
|
||||
Logger.log.info("No contacts sync state, ignoring account")
|
||||
else {
|
||||
parcel.unmarshall(raw, 0, raw.size)
|
||||
parcel.setDataPosition(0)
|
||||
val params = parcel.readBundle()
|
||||
val url = params.getString("url")
|
||||
if (url == null)
|
||||
Logger.log.info("No address book URL, ignoring account")
|
||||
else {
|
||||
// create new address book
|
||||
val info = CollectionInfo(url)
|
||||
info.type = CollectionInfo.Type.ADDRESS_BOOK
|
||||
info.displayName = account.name
|
||||
Logger.log.log(Level.INFO, "Creating new address book account", url)
|
||||
val addressBookAccount = Account(LocalAddressBook.accountName(account, info), App.addressBookAccountType)
|
||||
if (!accountManager.addAccountExplicitly(addressBookAccount, null, LocalAddressBook.initialUserData(account, info.url)))
|
||||
throw ContactsStorageException("Couldn't create address book account")
|
||||
|
||||
// move contacts to new address book
|
||||
Logger.log.info("Moving contacts from $account to $addressBookAccount")
|
||||
val newAccount = ContentValues(2)
|
||||
newAccount.put(ContactsContract.RawContacts.ACCOUNT_NAME, addressBookAccount.name)
|
||||
newAccount.put(ContactsContract.RawContacts.ACCOUNT_TYPE, addressBookAccount.type)
|
||||
val affected = provider.update(ContactsContract.RawContacts.CONTENT_URI.buildUpon()
|
||||
.appendQueryParameter(ContactsContract.RawContacts.ACCOUNT_NAME, account.name)
|
||||
.appendQueryParameter(ContactsContract.RawContacts.ACCOUNT_TYPE, account.type)
|
||||
.appendQueryParameter(ContactsContract.CALLER_IS_SYNCADAPTER, "true").build(),
|
||||
newAccount,
|
||||
"${ContactsContract.RawContacts.ACCOUNT_NAME}=? AND ${ContactsContract.RawContacts.ACCOUNT_TYPE}=?",
|
||||
arrayOf(account.name, account.type))
|
||||
Logger.log.info("$affected contacts moved to new address book")
|
||||
}
|
||||
|
||||
ContactsContract.SyncState.set(provider, account, null)
|
||||
}
|
||||
} catch(e: RemoteException) {
|
||||
throw ContactsStorageException("Couldn't migrate contacts to new address book", e)
|
||||
} finally {
|
||||
parcel.recycle()
|
||||
}
|
||||
}
|
||||
|
||||
// update version number so that further syncs don't repeat the migration
|
||||
accountManager.setUserData(account, KEY_SETTINGS_VERSION, "6")
|
||||
|
||||
// request sync of new address book account
|
||||
ContentResolver.setIsSyncable(account, App.addressBooksAuthority, 1)
|
||||
setSyncInterval(App.addressBooksAuthority, Constants.DEFAULT_SYNC_INTERVAL)
|
||||
}
|
||||
|
||||
|
||||
class AppUpdatedReceiver: BroadcastReceiver() {
|
||||
|
||||
@SuppressLint("UnsafeProtectedBroadcastReceiver,MissingPermission")
|
||||
override fun onReceive(context: Context, intent: Intent?) {
|
||||
Logger.log.info("DAVdroid was updated, checking for AccountSettings version")
|
||||
|
||||
// peek into AccountSettings to initiate a possible migration
|
||||
val accountManager = AccountManager.get(context)
|
||||
for (account in accountManager.getAccountsByType(context.getString(R.string.account_type)))
|
||||
try {
|
||||
Logger.log.info("Checking account ${account.name}")
|
||||
AccountSettings(context, account)
|
||||
} catch(e: InvalidAccountException) {
|
||||
Logger.log.log(Level.SEVERE, "Couldn't check for updated account settings", e)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
/*
|
||||
* Copyright © Ricki Hirner (bitfire web engineering).
|
||||
* All rights reserved. This program and the accompanying materials
|
||||
* are made available under the terms of the GNU Public License v3.0
|
||||
* which accompanies this distribution, and is available at
|
||||
* http://www.gnu.org/licenses/gpl.html
|
||||
*/
|
||||
|
||||
package at.bitfire.davdroid
|
||||
|
||||
import android.accounts.AccountManager
|
||||
import android.accounts.OnAccountsUpdateListener
|
||||
import android.content.BroadcastReceiver
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import java.util.*
|
||||
|
||||
class AccountsChangedReceiver: BroadcastReceiver() {
|
||||
|
||||
companion object {
|
||||
|
||||
private val listeners = LinkedList<OnAccountsUpdateListener>()
|
||||
|
||||
@JvmStatic
|
||||
fun registerListener(listener: OnAccountsUpdateListener, callImmediately: Boolean) {
|
||||
listeners += listener
|
||||
if (callImmediately)
|
||||
listener.onAccountsUpdated(null)
|
||||
}
|
||||
|
||||
@JvmStatic
|
||||
fun unregisterListener(listener: OnAccountsUpdateListener) {
|
||||
listeners -= listener
|
||||
}
|
||||
}
|
||||
|
||||
override fun onReceive(context: Context, intent: Intent) {
|
||||
if (intent.action == AccountManager.LOGIN_ACCOUNTS_CHANGED_ACTION) {
|
||||
val serviceIntent = Intent(context, DavService::class.java)
|
||||
serviceIntent.action = DavService.ACTION_ACCOUNTS_UPDATED
|
||||
context.startService(serviceIntent)
|
||||
|
||||
for (listener in listeners)
|
||||
listener.onAccountsUpdated(null)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
/*
|
||||
* Copyright © Ricki Hirner (bitfire web engineering).
|
||||
* All rights reserved. This program and the accompanying materials
|
||||
* are made available under the terms of the GNU Public License v3.0
|
||||
* which accompanies this distribution, and is available at
|
||||
* http://www.gnu.org/licenses/gpl.html
|
||||
*/
|
||||
|
||||
package at.bitfire.davdroid
|
||||
|
||||
import android.app.Application
|
||||
import android.content.Context
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.drawable.BitmapDrawable
|
||||
import at.bitfire.davdroid.log.Logger
|
||||
|
||||
class App: Application() {
|
||||
|
||||
companion object {
|
||||
|
||||
@JvmField val DISTRUST_SYSTEM_CERTIFICATES = "distrustSystemCerts"
|
||||
@JvmField val LOG_TO_EXTERNAL_STORAGE = "logToExternalStorage"
|
||||
@JvmField val OVERRIDE_PROXY = "overrideProxy"
|
||||
@JvmField val OVERRIDE_PROXY_HOST = "overrideProxyHost"
|
||||
@JvmField val OVERRIDE_PROXY_PORT = "overrideProxyPort"
|
||||
|
||||
@JvmField val OVERRIDE_PROXY_HOST_DEFAULT = "localhost"
|
||||
@JvmField val OVERRIDE_PROXY_PORT_DEFAULT = 8118
|
||||
|
||||
lateinit var addressBookAccountType: String
|
||||
lateinit var addressBooksAuthority: String
|
||||
|
||||
@JvmStatic
|
||||
fun getLauncherBitmap(context: Context): Bitmap? {
|
||||
val drawableLogo = if (android.os.Build.VERSION.SDK_INT >= 21)
|
||||
context.getDrawable(R.mipmap.ic_launcher)
|
||||
else
|
||||
@Suppress("deprecation")
|
||||
context.resources.getDrawable(R.mipmap.ic_launcher)
|
||||
return if (drawableLogo is BitmapDrawable)
|
||||
drawableLogo.bitmap
|
||||
else
|
||||
null
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
|
||||
CustomCertificates.reinitCertManager(this)
|
||||
Logger.reinitLogger(this)
|
||||
|
||||
addressBookAccountType = getString(R.string.account_type_address_book)
|
||||
addressBooksAuthority = getString(R.string.address_books_authority)
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,33 +0,0 @@
|
||||
/*
|
||||
* Copyright © 2013 – 2015 Ricki Hirner (bitfire web engineering).
|
||||
* All rights reserved. This program and the accompanying materials
|
||||
* are made available under the terms of the GNU Public License v3.0
|
||||
* which accompanies this distribution, and is available at
|
||||
* http://www.gnu.org/licenses/gpl.html
|
||||
*/
|
||||
package at.bitfire.davdroid;
|
||||
|
||||
import java.lang.reflect.Array;
|
||||
|
||||
public class ArrayUtils {
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
public static <T> T[][] partition(T[] bigArray, int max) {
|
||||
int nItems = bigArray.length;
|
||||
int nPartArrays = (nItems + max-1)/max;
|
||||
|
||||
T[][] partArrays = (T[][])Array.newInstance(bigArray.getClass().getComponentType(), nPartArrays, 0);
|
||||
|
||||
// nItems is now the number of remaining items
|
||||
for (int i = 0; nItems > 0; i++) {
|
||||
int n = (nItems < max) ? nItems : max;
|
||||
partArrays[i] = (T[])Array.newInstance(bigArray.getClass().getComponentType(), n);
|
||||
System.arraycopy(bigArray, i*max, partArrays[i], 0, n);
|
||||
|
||||
nItems -= n;
|
||||
}
|
||||
|
||||
return partArrays;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,29 +0,0 @@
|
||||
/*
|
||||
* Copyright © 2013 – 2015 Ricki Hirner (bitfire web engineering).
|
||||
* All rights reserved. This program and the accompanying materials
|
||||
* are made available under the terms of the GNU Public License v3.0
|
||||
* which accompanies this distribution, and is available at
|
||||
* http://www.gnu.org/licenses/gpl.html
|
||||
*/
|
||||
package at.bitfire.davdroid;
|
||||
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
public class Constants {
|
||||
public static final String
|
||||
ACCOUNT_TYPE = "bitfire.at.davdroid",
|
||||
WEB_URL_MAIN = "https://davdroid.bitfire.at/?pk_campaign=davdroid-app",
|
||||
WEB_URL_HELP = "https://davdroid.bitfire.at/configuration?pk_campaign=davdroid-app";
|
||||
|
||||
public static final Logger log = LoggerFactory.getLogger("davdroid");
|
||||
|
||||
// notification IDs
|
||||
public final static int
|
||||
NOTIFICATION_ANDROID_VERSION_UPDATED = 0,
|
||||
NOTIFICATION_ACCOUNT_SETTINGS_UPDATED = 1,
|
||||
NOTIFICATION_CONTACTS_SYNC = 10,
|
||||
NOTIFICATION_CALENDAR_SYNC = 11,
|
||||
NOTIFICATION_TASK_SYNC = 12;
|
||||
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
/*
|
||||
* Copyright © Ricki Hirner (bitfire web engineering).
|
||||
* All rights reserved. This program and the accompanying materials
|
||||
* are made available under the terms of the GNU Public License v3.0
|
||||
* which accompanies this distribution, and is available at
|
||||
* http://www.gnu.org/licenses/gpl.html
|
||||
*/
|
||||
package at.bitfire.davdroid;
|
||||
|
||||
object Constants {
|
||||
|
||||
// notification IDs
|
||||
@JvmField val NOTIFICATION_EXTERNAL_FILE_LOGGING = 1
|
||||
@JvmField val NOTIFICATION_REFRESH_COLLECTIONS = 2
|
||||
@JvmField val NOTIFICATION_CONTACTS_SYNC = 10
|
||||
@JvmField val NOTIFICATION_CALENDAR_SYNC = 11
|
||||
@JvmField val NOTIFICATION_TASK_SYNC = 12
|
||||
@JvmField val NOTIFICATION_PERMISSIONS = 20
|
||||
|
||||
@JvmField
|
||||
val DEFAULT_SYNC_INTERVAL = 4 * 3600L // 4 hours
|
||||
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
/*
|
||||
* Copyright © Ricki Hirner (bitfire web engineering).
|
||||
* All rights reserved. This program and the accompanying materials
|
||||
* are made available under the terms of the GNU Public License v3.0
|
||||
* which accompanies this distribution, and is available at
|
||||
* http://www.gnu.org/licenses/gpl.html
|
||||
*/
|
||||
|
||||
package at.bitfire.davdroid
|
||||
|
||||
import android.content.Context
|
||||
import android.os.Build
|
||||
import at.bitfire.cert4android.CustomCertManager
|
||||
import at.bitfire.davdroid.model.ServiceDB
|
||||
import at.bitfire.davdroid.model.Settings
|
||||
import okhttp3.internal.tls.OkHostnameVerifier
|
||||
import javax.net.ssl.HostnameVerifier
|
||||
import javax.net.ssl.SSLSocketFactory
|
||||
|
||||
object CustomCertificates {
|
||||
|
||||
@JvmField var certManager: CustomCertManager? = null
|
||||
var sslSocketFactoryCompat: SSLSocketFactory? = null
|
||||
var hostnameVerifier: HostnameVerifier? = null
|
||||
|
||||
fun reinitCertManager(context: Context) {
|
||||
if (BuildConfig.customCerts) {
|
||||
certManager?.close()
|
||||
|
||||
ServiceDB.OpenHelper(context).use { dbHelper ->
|
||||
val settings = Settings(dbHelper.readableDatabase)
|
||||
|
||||
val mgr = CustomCertManager(context.applicationContext, !settings.getBoolean(App.DISTRUST_SYSTEM_CERTIFICATES, false))
|
||||
certManager = mgr
|
||||
hostnameVerifier = mgr.hostnameVerifier(OkHostnameVerifier.INSTANCE)
|
||||
sslSocketFactoryCompat = SSLSocketFactoryCompat(mgr)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,377 @@
|
||||
/*
|
||||
* Copyright © Ricki Hirner (bitfire web engineering).
|
||||
* All rights reserved. This program and the accompanying materials
|
||||
* are made available under the terms of the GNU Public License v3.0
|
||||
* which accompanies this distribution, and is available at
|
||||
* http://www.gnu.org/licenses/gpl.html
|
||||
*/
|
||||
|
||||
package at.bitfire.davdroid
|
||||
|
||||
import android.accounts.Account
|
||||
import android.accounts.AccountManager
|
||||
import android.annotation.SuppressLint
|
||||
import android.app.PendingIntent
|
||||
import android.app.Service
|
||||
import android.content.ContentValues
|
||||
import android.content.Intent
|
||||
import android.database.DatabaseUtils
|
||||
import android.database.sqlite.SQLiteDatabase
|
||||
import android.os.Binder
|
||||
import android.support.v4.app.NotificationManagerCompat
|
||||
import android.support.v7.app.NotificationCompat
|
||||
import at.bitfire.dav4android.DavResource
|
||||
import at.bitfire.dav4android.UrlUtils
|
||||
import at.bitfire.dav4android.exception.DavException
|
||||
import at.bitfire.dav4android.exception.HttpException
|
||||
import at.bitfire.dav4android.property.*
|
||||
import at.bitfire.davdroid.log.Logger
|
||||
import at.bitfire.davdroid.model.CollectionInfo
|
||||
import at.bitfire.davdroid.model.ServiceDB.*
|
||||
import at.bitfire.davdroid.model.ServiceDB.Collections
|
||||
import at.bitfire.davdroid.resource.LocalAddressBook
|
||||
import at.bitfire.davdroid.ui.DebugInfoActivity
|
||||
import at.bitfire.vcard4android.ContactsStorageException
|
||||
import okhttp3.HttpUrl
|
||||
import org.apache.commons.collections4.iterators.IteratorChain
|
||||
import org.apache.commons.collections4.iterators.SingletonIterator
|
||||
import java.io.IOException
|
||||
import java.lang.ref.WeakReference
|
||||
import java.util.*
|
||||
import java.util.logging.Level
|
||||
import kotlin.concurrent.thread
|
||||
|
||||
class DavService: Service() {
|
||||
|
||||
companion object {
|
||||
@JvmField val ACTION_ACCOUNTS_UPDATED = "accountsUpdated"
|
||||
@JvmField val ACTION_REFRESH_COLLECTIONS = "refreshCollections"
|
||||
@JvmField val EXTRA_DAV_SERVICE_ID = "davServiceID"
|
||||
}
|
||||
|
||||
private val runningRefresh = HashSet<Long>()
|
||||
private val refreshingStatusListeners = LinkedList<WeakReference<RefreshingStatusListener>>()
|
||||
|
||||
|
||||
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
||||
intent?.let {
|
||||
val id = intent.getLongExtra(EXTRA_DAV_SERVICE_ID, -1)
|
||||
|
||||
when (intent.action) {
|
||||
ACTION_ACCOUNTS_UPDATED ->
|
||||
cleanupAccounts()
|
||||
ACTION_REFRESH_COLLECTIONS ->
|
||||
if (runningRefresh.add(id)) {
|
||||
thread { refreshCollections(id) }
|
||||
refreshingStatusListeners.forEach { it.get()?.onDavRefreshStatusChanged(id, true) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return START_NOT_STICKY
|
||||
}
|
||||
|
||||
|
||||
/* BOUND SERVICE PART
|
||||
for communicating with the activities
|
||||
*/
|
||||
|
||||
interface RefreshingStatusListener {
|
||||
fun onDavRefreshStatusChanged(id: Long, refreshing: Boolean)
|
||||
}
|
||||
|
||||
private val binder = InfoBinder()
|
||||
|
||||
inner class InfoBinder: Binder() {
|
||||
fun isRefreshing(id: Long) = runningRefresh.contains(id)
|
||||
|
||||
fun addRefreshingStatusListener(listener: RefreshingStatusListener, callImmediate: Boolean) {
|
||||
refreshingStatusListeners += WeakReference<RefreshingStatusListener>(listener)
|
||||
if (callImmediate)
|
||||
runningRefresh.forEach { id -> listener.onDavRefreshStatusChanged(id, true) }
|
||||
}
|
||||
|
||||
fun removeRefreshingStatusListener(listener: RefreshingStatusListener) {
|
||||
val iter = refreshingStatusListeners.iterator()
|
||||
while (iter.hasNext()) {
|
||||
val item = iter.next().get()
|
||||
if (listener == item)
|
||||
iter.remove()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onBind(intent: Intent?) = binder
|
||||
|
||||
|
||||
|
||||
/* ACTION RUNNABLES
|
||||
which actually do the work
|
||||
*/
|
||||
|
||||
@SuppressLint("MissingPermission")
|
||||
fun cleanupAccounts() {
|
||||
Logger.log.info("Cleaning up orphaned accounts")
|
||||
|
||||
OpenHelper(this).use { dbHelper ->
|
||||
val db = dbHelper.writableDatabase
|
||||
|
||||
val sqlAccountNames = LinkedList<String>()
|
||||
val accountNames = HashSet<String>()
|
||||
val accountManager = AccountManager.get(this)
|
||||
for (account in accountManager.getAccountsByType(getString(R.string.account_type))) {
|
||||
sqlAccountNames.add(DatabaseUtils.sqlEscapeString(account.name))
|
||||
accountNames += account.name
|
||||
}
|
||||
|
||||
// delete orphaned address book accounts
|
||||
accountManager.getAccountsByType(App.addressBookAccountType)
|
||||
.map { LocalAddressBook(this, it, null) }
|
||||
.forEach {
|
||||
try {
|
||||
if (!accountNames.contains(it.getMainAccount().name))
|
||||
it.delete()
|
||||
} catch(e: ContactsStorageException) {
|
||||
Logger.log.log(Level.SEVERE, "Couldn't get address book main account", e)
|
||||
}
|
||||
}
|
||||
|
||||
// delete orphaned services in DB
|
||||
if (sqlAccountNames.isEmpty())
|
||||
db.delete(Services._TABLE, null, null)
|
||||
else
|
||||
db.delete(Services._TABLE, "${Services.ACCOUNT_NAME} NOT IN (${sqlAccountNames.joinToString(",")})", null)
|
||||
}
|
||||
}
|
||||
|
||||
fun refreshCollections(service: Long) {
|
||||
OpenHelper(this@DavService).use { dbHelper ->
|
||||
val db = dbHelper.writableDatabase
|
||||
|
||||
val serviceType by lazy {
|
||||
db.query(Services._TABLE, arrayOf(Services.SERVICE), "${Services.ID}=?", arrayOf(service.toString()), null, null, null)?.use { cursor ->
|
||||
if (cursor.moveToNext())
|
||||
return@lazy cursor.getString(0)
|
||||
} ?: throw IllegalArgumentException("Service not found")
|
||||
}
|
||||
|
||||
val account by lazy {
|
||||
db.query(Services._TABLE, arrayOf(Services.ACCOUNT_NAME), "${Services.ID}=?", arrayOf(service.toString()), null, null, null)?.use { cursor ->
|
||||
if (cursor.moveToNext())
|
||||
return@lazy Account(cursor.getString(0), getString(R.string.account_type))
|
||||
}
|
||||
throw IllegalArgumentException("Account not found")
|
||||
}
|
||||
|
||||
val homeSets by lazy {
|
||||
val homeSets = mutableSetOf<HttpUrl>()
|
||||
db.query(HomeSets._TABLE, arrayOf(HomeSets.URL), "${HomeSets.SERVICE_ID}=?", arrayOf(service.toString()), null, null, null)?.use { cursor ->
|
||||
while (cursor.moveToNext())
|
||||
HttpUrl.parse(cursor.getString(0))?.let { homeSets += it }
|
||||
}
|
||||
homeSets
|
||||
}
|
||||
|
||||
val collections by lazy {
|
||||
val collections = mutableMapOf<HttpUrl, CollectionInfo>()
|
||||
db.query(Collections._TABLE, null, "${Collections.SERVICE_ID}=?", arrayOf(service.toString()), null, null, null)?.use { cursor ->
|
||||
while (cursor.moveToNext()) {
|
||||
val values = ContentValues()
|
||||
DatabaseUtils.cursorRowToContentValues(cursor, values)
|
||||
values.getAsString(Collections.URL)?.let { url ->
|
||||
HttpUrl.parse(url)?.let { collections.put(it, CollectionInfo(values)) }
|
||||
}
|
||||
}
|
||||
}
|
||||
collections
|
||||
}
|
||||
|
||||
fun readPrincipal(): HttpUrl? {
|
||||
db.query(Services._TABLE, arrayOf(Services.PRINCIPAL), "${Services.ID}=?", arrayOf(service.toString()), null, null, null)?.use { cursor ->
|
||||
if (cursor.moveToNext())
|
||||
cursor.getString(0)?.let { return HttpUrl.parse(it) }
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if the given URL defines home sets and adds them to the home set list.
|
||||
* @param dav DavResource to check
|
||||
*/
|
||||
@Throws(IOException::class, HttpException::class, DavException::class)
|
||||
fun queryHomeSets(dav: DavResource) {
|
||||
when (serviceType) {
|
||||
Services.SERVICE_CARDDAV -> {
|
||||
dav.propfind(0, AddressbookHomeSet.NAME, GroupMembership.NAME)
|
||||
for ((resource, addressbookHomeSet) in dav.findProperties(AddressbookHomeSet.NAME) as List<Pair<DavResource, AddressbookHomeSet>>)
|
||||
for (href in addressbookHomeSet.hrefs)
|
||||
resource.location.resolve(href)?.let { homeSets += UrlUtils.withTrailingSlash(it) }
|
||||
}
|
||||
Services.SERVICE_CALDAV -> {
|
||||
dav.propfind(0, CalendarHomeSet.NAME, CalendarProxyReadFor.NAME, CalendarProxyWriteFor.NAME, GroupMembership.NAME)
|
||||
for ((resource, calendarHomeSet) in dav.findProperties(CalendarHomeSet.NAME) as List<Pair<DavResource, CalendarHomeSet>>)
|
||||
for (href in calendarHomeSet.hrefs)
|
||||
resource.location.resolve(href)?.let { homeSets.add(UrlUtils.withTrailingSlash(it)) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun saveHomeSets() {
|
||||
db.delete(HomeSets._TABLE, "${HomeSets.SERVICE_ID}=?", arrayOf(service.toString()))
|
||||
for (homeSet in homeSets) {
|
||||
val values = ContentValues(2)
|
||||
values.put(HomeSets.SERVICE_ID, service)
|
||||
values.put(HomeSets.URL, homeSet.toString())
|
||||
db.insertOrThrow(HomeSets._TABLE, null, values)
|
||||
}
|
||||
}
|
||||
|
||||
fun saveCollections() {
|
||||
db.delete(Collections._TABLE, "${HomeSets.SERVICE_ID}=?", arrayOf(service.toString()))
|
||||
for ((_,collection) in collections) {
|
||||
val values = collection.toDB()
|
||||
Logger.log.log(Level.FINE, "Saving collection", values)
|
||||
values.put(Collections.SERVICE_ID, service)
|
||||
db.insertWithOnConflict(Collections._TABLE, null, values, SQLiteDatabase.CONFLICT_REPLACE)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
try {
|
||||
Logger.log.info("Refreshing $serviceType collections of service #$service")
|
||||
|
||||
// create authenticating OkHttpClient (credentials taken from account settings)
|
||||
val httpClient = HttpClient.create(this@DavService, account)
|
||||
|
||||
// refresh home set list (from principal)
|
||||
readPrincipal()?.let { principalUrl ->
|
||||
Logger.log.fine("Querying principal $principalUrl for home sets")
|
||||
val principal = DavResource(httpClient, principalUrl)
|
||||
queryHomeSets(principal)
|
||||
|
||||
// refresh home sets: calendar-proxy-read/write-for
|
||||
for ((resource, proxyRead) in principal.findProperties(CalendarProxyReadFor.NAME) as List<Pair<DavResource, CalendarProxyReadFor>>)
|
||||
for (href in proxyRead.hrefs) {
|
||||
Logger.log.fine("Principal is a read-only proxy for $href, checking for home sets")
|
||||
resource.location.resolve(href)?.let { queryHomeSets(DavResource(httpClient, it)) }
|
||||
}
|
||||
for ((resource, proxyWrite) in principal.findProperties(CalendarProxyWriteFor.NAME) as List<Pair<DavResource, CalendarProxyWriteFor>>)
|
||||
for (href in proxyWrite.hrefs) {
|
||||
Logger.log.fine("Principal is a read/write proxy for $href, checking for home sets")
|
||||
resource.location.resolve(href)?.let { queryHomeSets(DavResource(httpClient, it)) }
|
||||
}
|
||||
|
||||
// refresh home sets: direct group memberships
|
||||
(principal.properties[GroupMembership.NAME] as GroupMembership?)?.let { groupMembership ->
|
||||
for (href in groupMembership.hrefs) {
|
||||
Logger.log.fine("Principal is member of group $href, checking for home sets")
|
||||
principal.location.resolve(href)?.let { url ->
|
||||
try {
|
||||
queryHomeSets(DavResource(httpClient, url))
|
||||
} catch(e: HttpException) {
|
||||
Logger.log.log(Level.WARNING, "Couldn't query member group", e)
|
||||
} catch(e: DavException) {
|
||||
Logger.log.log(Level.WARNING, "Couldn't query member group", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 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(), 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 && info.type == CollectionInfo.Type.CALENDAR))
|
||||
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 && info.type != CollectionInfo.Type.CALENDAR))
|
||||
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 }
|
||||
|
||||
db.beginTransactionNonExclusive()
|
||||
try {
|
||||
saveHomeSets()
|
||||
saveCollections()
|
||||
db.setTransactionSuccessful()
|
||||
} finally {
|
||||
db.endTransaction()
|
||||
}
|
||||
|
||||
} catch(e: InvalidAccountException) {
|
||||
Logger.log.log(Level.SEVERE, "Invalid account", e)
|
||||
} catch(e: Exception) {
|
||||
Logger.log.log(Level.SEVERE, "Couldn't refresh collection list", e)
|
||||
|
||||
val debugIntent = Intent(this@DavService, DebugInfoActivity::class.java)
|
||||
debugIntent.putExtra(DebugInfoActivity.KEY_THROWABLE, e)
|
||||
debugIntent.putExtra(DebugInfoActivity.KEY_ACCOUNT, account)
|
||||
|
||||
val nm = NotificationManagerCompat.from(this@DavService)
|
||||
val notify = NotificationCompat.Builder(this@DavService)
|
||||
.setSmallIcon(R.drawable.ic_error_light)
|
||||
.setLargeIcon(App.getLauncherBitmap(this@DavService))
|
||||
.setContentTitle(getString(R.string.dav_service_refresh_failed))
|
||||
.setContentText(getString(R.string.dav_service_refresh_couldnt_refresh))
|
||||
.setContentIntent(PendingIntent.getActivity(this@DavService, 0, debugIntent, PendingIntent.FLAG_UPDATE_CURRENT))
|
||||
.build()
|
||||
nm.notify(Constants.NOTIFICATION_REFRESH_COLLECTIONS, notify)
|
||||
} finally {
|
||||
runningRefresh.remove(service)
|
||||
refreshingStatusListeners.forEach { it.get()?.onDavRefreshStatusChanged(service, false) }
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,31 +0,0 @@
|
||||
/*
|
||||
* Copyright © 2013 – 2015 Ricki Hirner (bitfire web engineering).
|
||||
* All rights reserved. This program and the accompanying materials
|
||||
* are made available under the terms of the GNU Public License v3.0
|
||||
* which accompanies this distribution, and is available at
|
||||
* http://www.gnu.org/licenses/gpl.html
|
||||
*/
|
||||
|
||||
package at.bitfire.davdroid;
|
||||
|
||||
import java.util.regex.Matcher;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
public class DavUtils {
|
||||
public static final int calendarGreen = 0xFFC3EA6E;
|
||||
|
||||
public static int CalDAVtoARGBColor(String davColor) {
|
||||
int color = calendarGreen; // fallback: "DAVdroid green"
|
||||
if (davColor != null) {
|
||||
Pattern p = Pattern.compile("#?(\\p{XDigit}{6})(\\p{XDigit}{2})?");
|
||||
Matcher m = p.matcher(davColor);
|
||||
if (m.find()) {
|
||||
int color_rgb = Integer.parseInt(m.group(1), 16);
|
||||
int color_alpha = m.group(2) != null ? (Integer.parseInt(m.group(2), 16) & 0xFF) : 0xFF;
|
||||
color = (color_alpha << 24) | color_rgb;
|
||||
} else
|
||||
Constants.log.warn("Couldn't parse color " + davColor + ", using DAVdroid green");
|
||||
}
|
||||
return color;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
/*
|
||||
* Copyright © Ricki Hirner (bitfire web engineering).
|
||||
* All rights reserved. This program and the accompanying materials
|
||||
* are made available under the terms of the GNU Public License v3.0
|
||||
* which accompanies this distribution, and is available at
|
||||
* http://www.gnu.org/licenses/gpl.html
|
||||
*/
|
||||
|
||||
package at.bitfire.davdroid;
|
||||
|
||||
import okhttp3.HttpUrl
|
||||
import java.util.*
|
||||
|
||||
object DavUtils {
|
||||
|
||||
@JvmStatic
|
||||
fun ARGBtoCalDAVColor(colorWithAlpha: Int): String {
|
||||
val alpha = (colorWithAlpha shr 24) and 0xFF
|
||||
val color = colorWithAlpha and 0xFFFFFF
|
||||
return String.format("#%06X%02X", color, alpha)
|
||||
}
|
||||
|
||||
@JvmStatic
|
||||
fun lastSegmentOfUrl(url: String): String {
|
||||
val httpUrl = HttpUrl.parse(url) ?: throw IllegalArgumentException("url not parsable")
|
||||
|
||||
// the list returned by HttpUrl.pathSegments() is unmodifiable, so we have to create a copy
|
||||
val segments = LinkedList<String>(httpUrl.pathSegments())
|
||||
Collections.reverse(segments)
|
||||
|
||||
for (segment in segments)
|
||||
if (segment.isNotEmpty())
|
||||
return segment
|
||||
|
||||
return "/"
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,154 +0,0 @@
|
||||
/*
|
||||
* Copyright © 2013 – 2015 Ricki Hirner (bitfire web engineering).
|
||||
* All rights reserved. This program and the accompanying materials
|
||||
* are made available under the terms of the GNU Public License v3.0
|
||||
* which accompanies this distribution, and is available at
|
||||
* http://www.gnu.org/licenses/gpl.html
|
||||
*/
|
||||
|
||||
package at.bitfire.davdroid;
|
||||
|
||||
import android.content.Context;
|
||||
import android.os.Build;
|
||||
|
||||
import com.squareup.okhttp.Credentials;
|
||||
import com.squareup.okhttp.Interceptor;
|
||||
import com.squareup.okhttp.OkHttpClient;
|
||||
import com.squareup.okhttp.Request;
|
||||
import com.squareup.okhttp.Response;
|
||||
import com.squareup.okhttp.internal.tls.OkHostnameVerifier;
|
||||
import com.squareup.okhttp.logging.HttpLoggingInterceptor;
|
||||
|
||||
import org.slf4j.Logger;
|
||||
|
||||
import java.io.BufferedReader;
|
||||
import java.io.IOException;
|
||||
import java.io.StringReader;
|
||||
import java.text.SimpleDateFormat;
|
||||
import java.util.Locale;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
import at.bitfire.dav4android.BasicDigestAuthenticator;
|
||||
import de.duenndns.ssl.MemorizingTrustManager;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
|
||||
public class HttpClient extends OkHttpClient {
|
||||
private final int MAX_LOG_LINE_LENGTH = 85;
|
||||
|
||||
final static UserAgentInterceptor userAgentInterceptor = new UserAgentInterceptor();
|
||||
|
||||
static final String userAgent;
|
||||
static {
|
||||
String date = new SimpleDateFormat("yyyy/MM/dd", Locale.US).format(BuildConfig.buildTime);
|
||||
userAgent = "DAVdroid/" + BuildConfig.VERSION_NAME + " (" + date + "; dav4android) Android/" + Build.VERSION.RELEASE;
|
||||
}
|
||||
|
||||
final Logger log;
|
||||
final Context context;
|
||||
protected String username, password;
|
||||
|
||||
|
||||
protected HttpClient(Logger log, Context context) {
|
||||
super();
|
||||
this.log = (log != null) ? log : Constants.log;
|
||||
this.context = context;
|
||||
|
||||
if (context != null) {
|
||||
// use MemorizingTrustManager to manage self-signed certificates
|
||||
MemorizingTrustManager mtm = new MemorizingTrustManager(context);
|
||||
setSslSocketFactory(new SSLSocketFactoryCompat(mtm));
|
||||
setHostnameVerifier(mtm.wrapHostnameVerifier(OkHostnameVerifier.INSTANCE));
|
||||
}
|
||||
|
||||
// set timeouts
|
||||
setConnectTimeout(30, TimeUnit.SECONDS);
|
||||
setWriteTimeout(15, TimeUnit.SECONDS);
|
||||
setReadTimeout(45, TimeUnit.SECONDS);
|
||||
|
||||
// add User-Agent to every request
|
||||
networkInterceptors().add(userAgentInterceptor);
|
||||
|
||||
// enable verbose logs, if requested
|
||||
if (this.log.isTraceEnabled()) {
|
||||
HttpLoggingInterceptor logger = new HttpLoggingInterceptor(new HttpLoggingInterceptor.Logger() {
|
||||
@Override
|
||||
public void log(String message) {
|
||||
BufferedReader reader = new BufferedReader(new StringReader(message));
|
||||
String line;
|
||||
try {
|
||||
while ((line = reader.readLine()) != null) {
|
||||
int len = line.length();
|
||||
for (int pos = 0; pos < len; pos += MAX_LOG_LINE_LENGTH)
|
||||
if (pos < len - MAX_LOG_LINE_LENGTH)
|
||||
HttpClient.this.log.trace(line.substring(pos, pos + MAX_LOG_LINE_LENGTH) + "\\");
|
||||
else
|
||||
HttpClient.this.log.trace(line.substring(pos));
|
||||
}
|
||||
} catch(IOException e) {
|
||||
// for some reason, we couldn't split our message
|
||||
HttpClient.this.log.trace(message);
|
||||
}
|
||||
}
|
||||
});
|
||||
logger.setLevel(HttpLoggingInterceptor.Level.BODY);
|
||||
interceptors().add(logger);
|
||||
}
|
||||
}
|
||||
|
||||
public HttpClient(Logger log, Context context, String username, String password, boolean preemptive) {
|
||||
this(log, context);
|
||||
|
||||
// authentication
|
||||
this.username = username;
|
||||
this.password = password;
|
||||
if (preemptive)
|
||||
networkInterceptors().add(new PreemptiveAuthenticationInterceptor(username, password));
|
||||
else
|
||||
setAuthenticator(new BasicDigestAuthenticator(null, username, password));
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a new HttpClient (based on another one) which can be used to download external resources:
|
||||
* 1. it does not use preemptive authentication
|
||||
* 2. it only authenticates against a given host
|
||||
* @param client user name and password from this client will be used
|
||||
* @param host authentication will be restricted to this host
|
||||
*/
|
||||
public HttpClient(Logger log, HttpClient client, String host) {
|
||||
this(log, client.context);
|
||||
|
||||
username = client.username;
|
||||
password = client.password;
|
||||
setAuthenticator(new BasicDigestAuthenticator(host, username, password));
|
||||
}
|
||||
|
||||
// for testing (mock server doesn't need auth)
|
||||
protected HttpClient() {
|
||||
this(null, null, null, null, false);
|
||||
}
|
||||
|
||||
|
||||
static class UserAgentInterceptor implements Interceptor {
|
||||
@Override
|
||||
public Response intercept(Chain chain) throws IOException {
|
||||
Request request = chain.request().newBuilder()
|
||||
.header("User-Agent", userAgent)
|
||||
.build();
|
||||
return chain.proceed(request);
|
||||
}
|
||||
}
|
||||
|
||||
@RequiredArgsConstructor
|
||||
static class PreemptiveAuthenticationInterceptor implements Interceptor {
|
||||
final String username, password;
|
||||
|
||||
@Override
|
||||
public Response intercept(Chain chain) throws IOException {
|
||||
Request request = chain.request().newBuilder()
|
||||
.header("Authorization", Credentials.basic(username, password))
|
||||
.build();
|
||||
return chain.proceed(request);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
/*
|
||||
* 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.Context
|
||||
import android.os.Build
|
||||
import at.bitfire.dav4android.BasicDigestAuthHandler
|
||||
import at.bitfire.dav4android.UrlUtils
|
||||
import at.bitfire.davdroid.log.Logger
|
||||
import at.bitfire.davdroid.model.ServiceDB
|
||||
import at.bitfire.davdroid.model.Settings
|
||||
import okhttp3.Interceptor
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Response
|
||||
import okhttp3.logging.HttpLoggingInterceptor
|
||||
import java.net.InetSocketAddress
|
||||
import java.net.Proxy
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.*
|
||||
import java.util.concurrent.TimeUnit
|
||||
import java.util.logging.Level
|
||||
|
||||
class HttpClient private constructor() {
|
||||
|
||||
companion object {
|
||||
|
||||
private val client = OkHttpClient()
|
||||
private val userAgentInterceptor = UserAgentInterceptor()
|
||||
|
||||
private val userAgentDate = SimpleDateFormat("yyyy/MM/dd", Locale.US).format(Date(BuildConfig.buildTime))
|
||||
private val userAgent = "DAVdroid/${BuildConfig.VERSION_NAME} ($userAgentDate; dav4android; okhttp3) Android/${Build.VERSION.RELEASE}"
|
||||
|
||||
|
||||
@JvmStatic
|
||||
@JvmOverloads
|
||||
fun create(context: Context?, settings: AccountSettings? = null, logger: java.util.logging.Logger = Logger.log): OkHttpClient {
|
||||
var builder = defaultBuilder(context, logger)
|
||||
|
||||
// use account settings for authentication
|
||||
settings?.let {
|
||||
val userName = it.username()
|
||||
val password = it.password()
|
||||
if (userName != null && password != null)
|
||||
builder = addAuthentication(builder, null, userName, password)
|
||||
}
|
||||
|
||||
return builder.build()
|
||||
}
|
||||
|
||||
@JvmStatic
|
||||
@Throws(InvalidAccountException::class)
|
||||
fun create(context: Context, account: Account) =
|
||||
create(context, AccountSettings(context, account))
|
||||
|
||||
|
||||
private fun defaultBuilder(context: Context?, logger: java.util.logging.Logger): OkHttpClient.Builder {
|
||||
val builder = client.newBuilder()
|
||||
|
||||
// use MemorizingTrustManager to manage self-signed certificates
|
||||
if (CustomCertificates.sslSocketFactoryCompat != null && CustomCertificates.certManager != null)
|
||||
builder.sslSocketFactory(CustomCertificates.sslSocketFactoryCompat, CustomCertificates.certManager)
|
||||
CustomCertificates.hostnameVerifier?.let { builder.hostnameVerifier(it) }
|
||||
|
||||
// set timeouts
|
||||
builder.connectTimeout(30, TimeUnit.SECONDS)
|
||||
builder.writeTimeout(30, TimeUnit.SECONDS)
|
||||
builder.readTimeout(120, TimeUnit.SECONDS)
|
||||
|
||||
// don't allow redirects, because it would break PROPFIND handling
|
||||
builder.followRedirects(false)
|
||||
|
||||
// custom proxy support
|
||||
context?.let {
|
||||
ServiceDB.OpenHelper(it).use { dbHelper ->
|
||||
try {
|
||||
val settings = Settings(dbHelper.readableDatabase)
|
||||
if (settings.getBoolean(App.OVERRIDE_PROXY, false)) {
|
||||
val address = InetSocketAddress(
|
||||
settings.getString(App.OVERRIDE_PROXY_HOST, App.OVERRIDE_PROXY_HOST_DEFAULT),
|
||||
settings.getInt(App.OVERRIDE_PROXY_PORT, App.OVERRIDE_PROXY_PORT_DEFAULT)
|
||||
)
|
||||
|
||||
val proxy = Proxy(Proxy.Type.HTTP, address)
|
||||
builder.proxy(proxy)
|
||||
Logger.log.log(Level.INFO, "Using proxy", proxy)
|
||||
}
|
||||
} catch(e: Exception) {
|
||||
Logger.log.log(Level.SEVERE, "Can't set proxy, ignoring", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// add User-Agent to every request
|
||||
builder.addNetworkInterceptor(userAgentInterceptor)
|
||||
|
||||
// add cookie store for non-persistent cookies (some services like Horde use cookies for session tracking)
|
||||
builder.cookieJar(MemoryCookieStore())
|
||||
|
||||
// add network logging, if requested
|
||||
if (logger.isLoggable(Level.FINEST)) {
|
||||
val loggingInterceptor = HttpLoggingInterceptor(HttpLoggingInterceptor.Logger {
|
||||
message -> logger.finest(message)
|
||||
})
|
||||
loggingInterceptor.level = HttpLoggingInterceptor.Level.BODY
|
||||
builder.addInterceptor(loggingInterceptor)
|
||||
}
|
||||
|
||||
return builder
|
||||
}
|
||||
|
||||
fun addAuthentication(builder: OkHttpClient.Builder, host: String?, username: String, password: String): OkHttpClient.Builder {
|
||||
val authHandler = BasicDigestAuthHandler(UrlUtils.hostToDomain(host), username, password);
|
||||
return builder
|
||||
.addNetworkInterceptor(authHandler)
|
||||
.authenticator(authHandler)
|
||||
}
|
||||
|
||||
@JvmOverloads
|
||||
@JvmStatic
|
||||
fun addAuthentication(client: OkHttpClient, host: String? = null, username: String, password: String): OkHttpClient {
|
||||
val builder = client.newBuilder()
|
||||
addAuthentication(builder, host, username, password)
|
||||
return builder.build()
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
private class UserAgentInterceptor: Interceptor {
|
||||
|
||||
override fun intercept(chain: Interceptor.Chain): Response {
|
||||
val locale = Locale.getDefault()
|
||||
val request = chain.request().newBuilder()
|
||||
.header("User-Agent", userAgent)
|
||||
.header("Accept-Language", "${locale.language}-${locale.country}, ${locale.language};q=0.7, *;q=0.5")
|
||||
.build()
|
||||
return chain.proceed(request)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
/*
|
||||
* Copyright © Ricki Hirner (bitfire web engineering).
|
||||
* All rights reserved. This program and the accompanying materials
|
||||
* are made available under the terms of the GNU Public License v3.0
|
||||
* which accompanies this distribution, and is available at
|
||||
* http://www.gnu.org/licenses/gpl.html
|
||||
*/
|
||||
|
||||
package at.bitfire.davdroid
|
||||
|
||||
import android.accounts.Account
|
||||
|
||||
class InvalidAccountException(account: Account): Exception("Invalid account: $account")
|
||||
@@ -0,0 +1,63 @@
|
||||
/*
|
||||
* Copyright © Ricki Hirner (bitfire web engineering).
|
||||
* All rights reserved. This program and the accompanying materials
|
||||
* are made available under the terms of the GNU Public License v3.0
|
||||
* which accompanies this distribution, and is available at
|
||||
* http://www.gnu.org/licenses/gpl.html
|
||||
*/
|
||||
|
||||
package at.bitfire.davdroid
|
||||
|
||||
import okhttp3.Cookie
|
||||
import okhttp3.CookieJar
|
||||
import okhttp3.HttpUrl
|
||||
import org.apache.commons.collections4.keyvalue.MultiKey
|
||||
import org.apache.commons.collections4.map.HashedMap
|
||||
import org.apache.commons.collections4.map.MultiKeyMap
|
||||
import java.util.*
|
||||
|
||||
/**
|
||||
* Primitive cookie store that stores cookies in a (volatile) hash map.
|
||||
* Will be sufficient for session cookies.
|
||||
*/
|
||||
class MemoryCookieStore: CookieJar {
|
||||
|
||||
/**
|
||||
* Stored cookies. The multi-key consists of three parts: name, domain, and path.
|
||||
* This ensures that cookies can be overwritten. [RFC 6265 5.3 Storage Model]
|
||||
* Not thread-safe!
|
||||
*/
|
||||
val storage = MultiKeyMap.multiKeyMap(HashedMap<MultiKey<out String>, Cookie>())!!
|
||||
|
||||
override fun saveFromResponse(url: HttpUrl, cookies: List<Cookie>) {
|
||||
synchronized(storage) {
|
||||
for (cookie in cookies)
|
||||
storage.put(cookie.name(), cookie.domain(), cookie.path(), cookie)
|
||||
}
|
||||
}
|
||||
|
||||
override fun loadForRequest(url: HttpUrl): List<Cookie> {
|
||||
val cookies = LinkedList<Cookie>()
|
||||
|
||||
synchronized(storage) {
|
||||
val iter = storage.mapIterator()
|
||||
while (iter.hasNext()) {
|
||||
iter.next()
|
||||
val cookie = iter.value
|
||||
|
||||
// remove expired cookies
|
||||
if (cookie.expiresAt() <= System.currentTimeMillis()) {
|
||||
iter.remove()
|
||||
continue
|
||||
}
|
||||
|
||||
// add applicable cookies
|
||||
if (cookie.matches(url))
|
||||
cookies += cookie
|
||||
}
|
||||
}
|
||||
|
||||
return cookies
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
/*
|
||||
* Copyright © Ricki Hirner (bitfire web engineering).
|
||||
* All rights reserved. This program and the accompanying materials
|
||||
* are made available under the terms of the GNU Public License v3.0
|
||||
* which accompanies this distribution, and is available at
|
||||
* http://www.gnu.org/licenses/gpl.html
|
||||
*/
|
||||
|
||||
package at.bitfire.davdroid
|
||||
|
||||
import android.accounts.Account
|
||||
import android.annotation.SuppressLint
|
||||
import android.content.BroadcastReceiver
|
||||
import android.content.ContentResolver
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.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
|
||||
|
||||
class PackageChangedReceiver: BroadcastReceiver() {
|
||||
|
||||
@SuppressLint("MissingPermission")
|
||||
override fun onReceive(context: Context, intent: Intent) {
|
||||
if (intent.action == Intent.ACTION_PACKAGE_ADDED || intent.action == Intent.ACTION_PACKAGE_FULLY_REMOVED)
|
||||
updateTaskSync(context)
|
||||
}
|
||||
|
||||
|
||||
companion object {
|
||||
|
||||
@JvmStatic
|
||||
fun updateTaskSync(context: Context) {
|
||||
val tasksInstalled = LocalTaskList.tasksProviderAvailable(context)
|
||||
Logger.log.info("Package (un)installed; OpenTasks provider now available = $tasksInstalled")
|
||||
|
||||
// check all accounts and (de)activate OpenTasks if a CalDAV service is defined
|
||||
ServiceDB.OpenHelper(context).use { dbHelper ->
|
||||
val db = dbHelper.readableDatabase
|
||||
|
||||
db.query(Services._TABLE, arrayOf(Services.ACCOUNT_NAME),
|
||||
"${Services.SERVICE}=?", arrayOf(Services.SERVICE_CALDAV), null, null, null)?.use { cursor ->
|
||||
while (cursor.moveToNext()) {
|
||||
val account = Account(cursor.getString(0), context.getString(R.string.account_type))
|
||||
|
||||
if (tasksInstalled) {
|
||||
if (ContentResolver.getIsSyncable(account, TaskProvider.ProviderName.OpenTasks.authority) <= 0) {
|
||||
ContentResolver.setIsSyncable(account, TaskProvider.ProviderName.OpenTasks.authority, 1)
|
||||
ContentResolver.setSyncAutomatically(account, TaskProvider.ProviderName.OpenTasks.authority, true)
|
||||
ContentResolver.addPeriodicSync(account, TaskProvider.ProviderName.OpenTasks.authority, Bundle(), Constants.DEFAULT_SYNC_INTERVAL.toLong())
|
||||
}
|
||||
} else
|
||||
ContentResolver.setIsSyncable(account, TaskProvider.ProviderName.OpenTasks.authority, 0)
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,175 +0,0 @@
|
||||
/*
|
||||
* Copyright © 2013 – 2015 Ricki Hirner (bitfire web engineering).
|
||||
* All rights reserved. This program and the accompanying materials
|
||||
* are made available under the terms of the GNU Public License v3.0
|
||||
* which accompanies this distribution, and is available at
|
||||
* http://www.gnu.org/licenses/gpl.html
|
||||
*/
|
||||
|
||||
package at.bitfire.davdroid;
|
||||
|
||||
import android.os.Build;
|
||||
import android.text.TextUtils;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.net.InetAddress;
|
||||
import java.net.Socket;
|
||||
import java.net.UnknownHostException;
|
||||
import java.security.GeneralSecurityException;
|
||||
import java.util.Arrays;
|
||||
import java.util.HashSet;
|
||||
import java.util.LinkedList;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
|
||||
import javax.net.ssl.SSLContext;
|
||||
import javax.net.ssl.SSLSocket;
|
||||
import javax.net.ssl.SSLSocketFactory;
|
||||
import javax.net.ssl.X509TrustManager;
|
||||
|
||||
import de.duenndns.ssl.MemorizingTrustManager;
|
||||
import lombok.Cleanup;
|
||||
|
||||
public class SSLSocketFactoryCompat extends SSLSocketFactory {
|
||||
|
||||
private SSLSocketFactory defaultFactory;
|
||||
|
||||
// Android 5.0+ (API level21) provides reasonable default settings
|
||||
// but it still allows SSLv3
|
||||
// https://developer.android.com/reference/javax/net/ssl/SSLSocket.html
|
||||
static String protocols[] = null, cipherSuites[] = null;
|
||||
static {
|
||||
try {
|
||||
@Cleanup SSLSocket socket = (SSLSocket)SSLSocketFactory.getDefault().createSocket();
|
||||
if (socket != null) {
|
||||
/* set reasonable protocol versions */
|
||||
// - enable all supported protocols (enables TLSv1.1 and TLSv1.2 on Android <5.0)
|
||||
// - remove all SSL versions (especially SSLv3) because they're insecure now
|
||||
List<String> protocols = new LinkedList<>();
|
||||
for (String protocol : socket.getSupportedProtocols())
|
||||
if (!protocol.toUpperCase(Locale.US).contains("SSL"))
|
||||
protocols.add(protocol);
|
||||
Constants.log.debug("Setting allowed TLS protocols: " + TextUtils.join(", ", protocols));
|
||||
SSLSocketFactoryCompat.protocols = protocols.toArray(new String[protocols.size()]);
|
||||
|
||||
/* set up reasonable cipher suites */
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.LOLLIPOP) {
|
||||
// choose known secure cipher suites
|
||||
List<String> allowedCiphers = Arrays.asList(
|
||||
// TLS 1.2
|
||||
"TLS_RSA_WITH_AES_256_GCM_SHA384",
|
||||
"TLS_RSA_WITH_AES_128_GCM_SHA256",
|
||||
"TLS_ECDHE_ECDSA_WITH_AES_128_CBC_SHA256",
|
||||
"TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256",
|
||||
"TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384",
|
||||
"TLS_ECDHE_RSA_WITH_AES_128_CBC_SHA256",
|
||||
"TLS_ECHDE_RSA_WITH_AES_128_GCM_SHA256",
|
||||
// maximum interoperability
|
||||
"TLS_RSA_WITH_3DES_EDE_CBC_SHA",
|
||||
"TLS_RSA_WITH_AES_128_CBC_SHA",
|
||||
// additionally
|
||||
"TLS_RSA_WITH_AES_256_CBC_SHA",
|
||||
"TLS_ECDHE_ECDSA_WITH_3DES_EDE_CBC_SHA",
|
||||
"TLS_ECDHE_ECDSA_WITH_AES_128_CBC_SHA",
|
||||
"TLS_ECDHE_RSA_WITH_3DES_EDE_CBC_SHA",
|
||||
"TLS_ECDHE_RSA_WITH_AES_128_CBC_SHA");
|
||||
List<String> availableCiphers = Arrays.asList(socket.getSupportedCipherSuites());
|
||||
Constants.log.debug("Available cipher suites: " + TextUtils.join(", ", availableCiphers));
|
||||
Constants.log.debug("Cipher suites enabled by default: " + TextUtils.join(", ", socket.getEnabledCipherSuites()));
|
||||
|
||||
// take all allowed ciphers that are available and put them into preferredCiphers
|
||||
HashSet<String> preferredCiphers = new HashSet<>(allowedCiphers);
|
||||
preferredCiphers.retainAll(availableCiphers);
|
||||
|
||||
/* For maximum security, preferredCiphers should *replace* enabled ciphers (thus disabling
|
||||
* ciphers which are enabled by default, but have become unsecure), but I guess for
|
||||
* the security level of DAVdroid and maximum compatibility, disabling of insecure
|
||||
* ciphers should be a server-side task */
|
||||
|
||||
// add preferred ciphers to enabled ciphers
|
||||
HashSet<String> enabledCiphers = preferredCiphers;
|
||||
enabledCiphers.addAll(new HashSet<>(Arrays.asList(socket.getEnabledCipherSuites())));
|
||||
|
||||
Constants.log.debug("Enabling (only) those TLS ciphers: " + TextUtils.join(", ", enabledCiphers));
|
||||
SSLSocketFactoryCompat.cipherSuites = enabledCiphers.toArray(new String[enabledCiphers.size()]);
|
||||
}
|
||||
}
|
||||
} catch (IOException e) {
|
||||
Constants.log.error("Couldn't determine default TLS settings");
|
||||
}
|
||||
}
|
||||
|
||||
public SSLSocketFactoryCompat(MemorizingTrustManager mtm) {
|
||||
try {
|
||||
SSLContext sslContext = SSLContext.getInstance("TLS");
|
||||
sslContext.init(null, (mtm != null) ? new X509TrustManager[] { mtm } : null, null);
|
||||
defaultFactory = sslContext.getSocketFactory();
|
||||
} catch (GeneralSecurityException e) {
|
||||
throw new AssertionError(); // The system has no TLS. Just give up.
|
||||
}
|
||||
}
|
||||
|
||||
private void upgradeTLS(SSLSocket ssl) {
|
||||
if (protocols != null) {
|
||||
Constants.log.debug("Setting allowed TLS protocols: " + TextUtils.join(", ", protocols));
|
||||
ssl.setEnabledProtocols(protocols);
|
||||
}
|
||||
|
||||
if (Build.VERSION.SDK_INT < 20 && cipherSuites != null) {
|
||||
Constants.log.debug("Setting allowed TLS ciphers: " + TextUtils.join(", ", cipherSuites));
|
||||
ssl.setEnabledCipherSuites(cipherSuites);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@Override
|
||||
public String[] getDefaultCipherSuites() {
|
||||
return cipherSuites;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String[] getSupportedCipherSuites() {
|
||||
return cipherSuites;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Socket createSocket(Socket s, String host, int port, boolean autoClose) throws IOException {
|
||||
Socket ssl = defaultFactory.createSocket(s, host, port, autoClose);
|
||||
if (ssl instanceof SSLSocket)
|
||||
upgradeTLS((SSLSocket)ssl);
|
||||
return ssl;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Socket createSocket(String host, int port) throws IOException, UnknownHostException {
|
||||
Socket ssl = defaultFactory.createSocket(host, port);
|
||||
if (ssl instanceof SSLSocket)
|
||||
upgradeTLS((SSLSocket)ssl);
|
||||
return ssl;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Socket createSocket(String host, int port, InetAddress localHost, int localPort) throws IOException, UnknownHostException {
|
||||
Socket ssl = defaultFactory.createSocket(host, port, localHost, localPort);
|
||||
if (ssl instanceof SSLSocket)
|
||||
upgradeTLS((SSLSocket)ssl);
|
||||
return ssl;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Socket createSocket(InetAddress host, int port) throws IOException {
|
||||
Socket ssl = defaultFactory.createSocket(host, port);
|
||||
if (ssl instanceof SSLSocket)
|
||||
upgradeTLS((SSLSocket)ssl);
|
||||
return ssl;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Socket createSocket(InetAddress address, int port, InetAddress localAddress, int localPort) throws IOException {
|
||||
Socket ssl = defaultFactory.createSocket(address, port, localAddress, localPort);
|
||||
if (ssl instanceof SSLSocket)
|
||||
upgradeTLS((SSLSocket)ssl);
|
||||
return ssl;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
/*
|
||||
* Copyright © Ricki Hirner (bitfire web engineering).
|
||||
* All rights reserved. This program and the accompanying materials
|
||||
* are made available under the terms of the GNU Public License v3.0
|
||||
* which accompanies this distribution, and is available at
|
||||
* http://www.gnu.org/licenses/gpl.html
|
||||
*/
|
||||
|
||||
package at.bitfire.davdroid
|
||||
|
||||
import android.os.Build
|
||||
import at.bitfire.davdroid.log.Logger
|
||||
import java.io.IOException
|
||||
import java.net.InetAddress
|
||||
import java.net.Socket
|
||||
import java.security.GeneralSecurityException
|
||||
import java.util.*
|
||||
import javax.net.ssl.SSLContext
|
||||
import javax.net.ssl.SSLSocket
|
||||
import javax.net.ssl.SSLSocketFactory
|
||||
import javax.net.ssl.X509TrustManager
|
||||
|
||||
class SSLSocketFactoryCompat(trustManager: X509TrustManager): SSLSocketFactory() {
|
||||
|
||||
private var delegate: SSLSocketFactory
|
||||
|
||||
companion object {
|
||||
// Android 5.0+ (API level 21) provides reasonable default settings
|
||||
// but it still allows SSLv3
|
||||
// https://developer.android.com/reference/javax/net/ssl/SSLSocket.html
|
||||
var protocols: Array<String>? = null
|
||||
var cipherSuites: Array<String>? = null
|
||||
init {
|
||||
if (Build.VERSION.SDK_INT >= 23) {
|
||||
// Since Android 6.0 (API level 23),
|
||||
// - TLSv1.1 and TLSv1.2 is enabled by default
|
||||
// - SSLv3 is disabled by default
|
||||
// - all modern ciphers are activated by default
|
||||
protocols = null
|
||||
cipherSuites = null
|
||||
Logger.log.fine("Using device default TLS protocols/ciphers")
|
||||
} else {
|
||||
val socket = SSLSocketFactory.getDefault().createSocket() as SSLSocket?
|
||||
try {
|
||||
socket?.let {
|
||||
/* set reasonable protocol versions */
|
||||
// - enable all supported protocols (enables TLSv1.1 and TLSv1.2 on Android <5.0)
|
||||
// - remove all SSL versions (especially SSLv3) because they're insecure now
|
||||
val _protocols = LinkedList<String>()
|
||||
for (protocol in socket.supportedProtocols.filterNot { it.contains("SSL", true) })
|
||||
_protocols += protocol
|
||||
Logger.log.info("Enabling (only) these TLS protocols: ${_protocols.joinToString(", ")}")
|
||||
protocols = _protocols.toTypedArray()
|
||||
|
||||
/* set up reasonable cipher suites */
|
||||
val knownCiphers = arrayOf<String>(
|
||||
// TLS 1.2
|
||||
"TLS_RSA_WITH_AES_256_GCM_SHA384",
|
||||
"TLS_RSA_WITH_AES_128_GCM_SHA256",
|
||||
"TLS_ECDHE_ECDSA_WITH_AES_128_CBC_SHA256",
|
||||
"TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256",
|
||||
"TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384",
|
||||
"TLS_ECDHE_RSA_WITH_AES_128_CBC_SHA256",
|
||||
"TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256",
|
||||
// maximum interoperability
|
||||
"TLS_RSA_WITH_3DES_EDE_CBC_SHA",
|
||||
"SSL_RSA_WITH_3DES_EDE_CBC_SHA",
|
||||
"TLS_RSA_WITH_AES_128_CBC_SHA",
|
||||
// additionally
|
||||
"TLS_RSA_WITH_AES_256_CBC_SHA",
|
||||
"TLS_ECDHE_ECDSA_WITH_3DES_EDE_CBC_SHA",
|
||||
"TLS_ECDHE_ECDSA_WITH_AES_128_CBC_SHA",
|
||||
"TLS_ECDHE_RSA_WITH_3DES_EDE_CBC_SHA",
|
||||
"TLS_ECDHE_RSA_WITH_AES_128_CBC_SHA"
|
||||
)
|
||||
val availableCiphers = socket.supportedCipherSuites
|
||||
Logger.log.info("Available cipher suites: ${availableCiphers.joinToString(", ")}")
|
||||
|
||||
/* For maximum security, preferredCiphers should *replace* enabled ciphers (thus
|
||||
* disabling ciphers which are enabled by default, but have become unsecure), but for
|
||||
* the security level of DAVdroid and maximum compatibility, disabling of insecure
|
||||
* ciphers should be a server-side task */
|
||||
|
||||
// for the final set of enabled ciphers, take the ciphers enabled by default, ...
|
||||
val _cipherSuites = LinkedList<String>()
|
||||
_cipherSuites.addAll(socket.enabledCipherSuites)
|
||||
Logger.log.fine("Cipher suites enabled by default: ${_cipherSuites.joinToString(", ")}")
|
||||
// ... add explicitly allowed ciphers ...
|
||||
_cipherSuites.addAll(knownCiphers)
|
||||
// ... and keep only those which are actually available
|
||||
_cipherSuites.retainAll(availableCiphers)
|
||||
|
||||
Logger.log.info("Enabling (only) these TLS ciphers: " + _cipherSuites.joinToString(", "))
|
||||
cipherSuites = _cipherSuites.toTypedArray()
|
||||
}
|
||||
} catch(e: IOException) {
|
||||
Logger.log.severe("Couldn't determine default TLS settings")
|
||||
} finally {
|
||||
socket?.close() // doesn't implement Closeable on all supported Android versions
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
init {
|
||||
try {
|
||||
val sslContext = SSLContext.getInstance("TLS")
|
||||
sslContext.init(null, arrayOf(trustManager), null)
|
||||
delegate = sslContext.socketFactory
|
||||
} catch (e: GeneralSecurityException) {
|
||||
throw IllegalStateException() // system has no TLS
|
||||
}
|
||||
}
|
||||
|
||||
override fun getDefaultCipherSuites(): Array<String>? = cipherSuites ?: delegate.defaultCipherSuites
|
||||
override fun getSupportedCipherSuites(): Array<String>? = cipherSuites ?: delegate.supportedCipherSuites
|
||||
|
||||
override fun createSocket(s: Socket, host: String, port: Int, autoClose: Boolean): Socket {
|
||||
val ssl = delegate.createSocket(s, host, port, autoClose)
|
||||
if (ssl is SSLSocket)
|
||||
upgradeTLS(ssl)
|
||||
return ssl
|
||||
}
|
||||
|
||||
override fun createSocket(host: String, port: Int): Socket {
|
||||
val ssl = delegate.createSocket(host, port)
|
||||
if (ssl is SSLSocket)
|
||||
upgradeTLS(ssl)
|
||||
return ssl
|
||||
}
|
||||
|
||||
override fun createSocket(host: String, port: Int, localHost: InetAddress, localPort: Int): Socket {
|
||||
val ssl = delegate.createSocket(host, port, localHost, localPort)
|
||||
if (ssl is SSLSocket)
|
||||
upgradeTLS(ssl)
|
||||
return ssl
|
||||
}
|
||||
|
||||
override fun createSocket(host: InetAddress, port: Int): Socket {
|
||||
val ssl = delegate.createSocket(host, port)
|
||||
if (ssl is SSLSocket)
|
||||
upgradeTLS(ssl)
|
||||
return ssl
|
||||
}
|
||||
|
||||
override fun createSocket(address: InetAddress, port: Int, localAddress: InetAddress, localPort: Int): Socket {
|
||||
val ssl = delegate.createSocket(address, port, localAddress, localPort)
|
||||
if (ssl is SSLSocket)
|
||||
upgradeTLS(ssl)
|
||||
return ssl
|
||||
}
|
||||
|
||||
|
||||
private fun upgradeTLS(ssl: SSLSocket) {
|
||||
protocols?.let { ssl.enabledProtocols = it }
|
||||
cipherSuites?.let { ssl.enabledCipherSuites = it }
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,379 +0,0 @@
|
||||
/*
|
||||
* Copyright © 2013 – 2015 Ricki Hirner (bitfire web engineering).
|
||||
* All rights reserved. This program and the accompanying materials
|
||||
* are made available under the terms of the GNU Public License v3.0
|
||||
* which accompanies this distribution, and is available at
|
||||
* http://www.gnu.org/licenses/gpl.html
|
||||
*/
|
||||
|
||||
package at.bitfire.davdroid.log;
|
||||
|
||||
import org.apache.commons.lang3.exception.ExceptionUtils;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.Marker;
|
||||
|
||||
import java.io.PrintWriter;
|
||||
|
||||
import lombok.Getter;
|
||||
|
||||
/**
|
||||
* A DAVdroid logger base class that wraps all calls around some standard log() calls.
|
||||
* @throws UnsupportedOperationException for all methods with Marker
|
||||
* arguments (as Markers are not used by DAVdroid logging).
|
||||
*/
|
||||
public abstract class CustomLogger implements Logger {
|
||||
|
||||
private static final String
|
||||
PREFIX_ERROR = "[error] ",
|
||||
PREFIX_WARN = "[warn ] ",
|
||||
PREFIX_INFO = "[info ] ",
|
||||
PREFIX_DEBUG = "[debug] ",
|
||||
PREFIX_TRACE = "[trace] ";
|
||||
|
||||
@Getter
|
||||
protected String name;
|
||||
|
||||
protected PrintWriter writer;
|
||||
protected boolean verbose;
|
||||
|
||||
|
||||
// CUSTOM LOGGING METHODS
|
||||
|
||||
protected void log(String prefix, String msg) {
|
||||
writer.write(prefix + msg + "\n");
|
||||
}
|
||||
|
||||
protected void log(String prefix, String format, Object arg) {
|
||||
writer.write(prefix + format.replace("{}", arg.toString()) + "\n");
|
||||
}
|
||||
|
||||
protected void log(String prefix, String format, Object arg1, Object arg2) {
|
||||
writer.write(prefix + format.replaceFirst("\\{\\}", arg1.toString()).replaceFirst("\\{\\}", arg2.toString()) + "\n");
|
||||
}
|
||||
|
||||
protected void log(String prefix, String format, Object... args) {
|
||||
String message = prefix;
|
||||
for (Object arg : args)
|
||||
format.replaceFirst("\\{\\}", arg.toString());
|
||||
writer.write(prefix + format + "\n");
|
||||
}
|
||||
|
||||
protected void log(String prefix, String msg, Throwable t) {
|
||||
writer.write(prefix + msg + " - EXCEPTION:\n");
|
||||
t.printStackTrace(writer);
|
||||
ExceptionUtils.printRootCauseStackTrace(t, writer);
|
||||
}
|
||||
|
||||
|
||||
// STANDARD CALLS
|
||||
|
||||
@Override
|
||||
public boolean isTraceEnabled() {
|
||||
return verbose;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void trace(String msg) {
|
||||
if (verbose)
|
||||
log(PREFIX_TRACE, msg);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void trace(String format, Object arg) {
|
||||
if (verbose)
|
||||
log(PREFIX_TRACE, format, arg);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void trace(String format, Object arg1, Object arg2) {
|
||||
if (verbose) log(PREFIX_TRACE, format, arg1, arg2);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void trace(String format, Object... arguments) {
|
||||
if (verbose)
|
||||
log(PREFIX_TRACE, format, arguments);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void trace(String msg, Throwable t) {
|
||||
if (verbose)
|
||||
log(PREFIX_TRACE, msg, t);
|
||||
}
|
||||
|
||||
|
||||
@Override
|
||||
public boolean isDebugEnabled() {
|
||||
return true;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void debug(String msg) {
|
||||
log(PREFIX_DEBUG, msg);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void debug(String format, Object arg) {
|
||||
log(PREFIX_DEBUG, format, arg);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void debug(String format, Object arg1, Object arg2) {
|
||||
log(PREFIX_DEBUG, format, arg1, arg2);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void debug(String format, Object... arguments) {
|
||||
log(PREFIX_DEBUG, format, arguments);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void debug(String msg, Throwable t) {
|
||||
log(PREFIX_DEBUG, msg, t);
|
||||
}
|
||||
|
||||
|
||||
@Override
|
||||
public boolean isInfoEnabled() {
|
||||
return true;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void info(String msg) {
|
||||
log(PREFIX_INFO, msg);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void info(String format, Object arg) {
|
||||
log(PREFIX_INFO, format, arg);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void info(String format, Object arg1, Object arg2) {
|
||||
log(PREFIX_INFO, format, arg1, arg2);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void info(String format, Object... arguments) {
|
||||
log(PREFIX_INFO, format, arguments);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void info(String msg, Throwable t) {
|
||||
log(PREFIX_INFO, msg, t);
|
||||
}
|
||||
|
||||
|
||||
@Override
|
||||
public boolean isWarnEnabled() {
|
||||
return true;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void warn(String msg) {
|
||||
log(PREFIX_WARN, msg);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void warn(String format, Object arg) {
|
||||
log(PREFIX_WARN, format, arg);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void warn(String format, Object... arguments) {
|
||||
log(PREFIX_WARN, format, arguments);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void warn(String format, Object arg1, Object arg2) {
|
||||
log(PREFIX_WARN, format, arg1, arg2);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void warn(String msg, Throwable t) {
|
||||
log(PREFIX_WARN, msg, t);
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean isErrorEnabled() {
|
||||
return true;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void error(String msg) {
|
||||
log(PREFIX_ERROR, msg);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void error(String format, Object arg) {
|
||||
log(PREFIX_ERROR, format, arg);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void error(String format, Object arg1, Object arg2) {
|
||||
log(PREFIX_ERROR, format, arg1, arg2);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void error(String format, Object... arguments) {
|
||||
log(PREFIX_ERROR, format, arguments);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void error(String msg, Throwable t) {
|
||||
log(PREFIX_ERROR, msg, t);
|
||||
}
|
||||
|
||||
|
||||
// CALLS WITH MARKER
|
||||
|
||||
@Override
|
||||
public boolean isTraceEnabled(Marker marker) {
|
||||
throw new UnsupportedOperationException();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void trace(Marker marker, String msg) {
|
||||
throw new UnsupportedOperationException();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void trace(Marker marker, String format, Object arg) {
|
||||
throw new UnsupportedOperationException();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void trace(Marker marker, String format, Object arg1, Object arg2) {
|
||||
throw new UnsupportedOperationException();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void trace(Marker marker, String format, Object... argArray) {
|
||||
throw new UnsupportedOperationException();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void trace(Marker marker, String msg, Throwable t) {
|
||||
throw new UnsupportedOperationException();
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean isDebugEnabled(Marker marker) {
|
||||
throw new UnsupportedOperationException();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void debug(Marker marker, String msg) {
|
||||
throw new UnsupportedOperationException();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void debug(Marker marker, String format, Object arg) {
|
||||
throw new UnsupportedOperationException();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void debug(Marker marker, String format, Object arg1, Object arg2) {
|
||||
throw new UnsupportedOperationException();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void debug(Marker marker, String format, Object... arguments) {
|
||||
throw new UnsupportedOperationException();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void debug(Marker marker, String msg, Throwable t) {
|
||||
throw new UnsupportedOperationException();
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean isInfoEnabled(Marker marker) {
|
||||
throw new UnsupportedOperationException();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void info(Marker marker, String msg) {
|
||||
throw new UnsupportedOperationException();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void info(Marker marker, String format, Object arg) {
|
||||
throw new UnsupportedOperationException();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void info(Marker marker, String format, Object arg1, Object arg2) {
|
||||
throw new UnsupportedOperationException();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void info(Marker marker, String format, Object... arguments) {
|
||||
throw new UnsupportedOperationException();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void info(Marker marker, String msg, Throwable t) {
|
||||
throw new UnsupportedOperationException();
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean isWarnEnabled(Marker marker) {
|
||||
throw new UnsupportedOperationException();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void warn(Marker marker, String msg) {
|
||||
throw new UnsupportedOperationException();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void warn(Marker marker, String format, Object arg) {
|
||||
throw new UnsupportedOperationException();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void warn(Marker marker, String format, Object arg1, Object arg2) {
|
||||
throw new UnsupportedOperationException();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void warn(Marker marker, String format, Object... arguments) {
|
||||
throw new UnsupportedOperationException();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void warn(Marker marker, String msg, Throwable t) {
|
||||
throw new UnsupportedOperationException();
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean isErrorEnabled(Marker marker) {
|
||||
throw new UnsupportedOperationException();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void error(Marker marker, String msg) {
|
||||
throw new UnsupportedOperationException();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void error(Marker marker, String format, Object arg) {
|
||||
throw new UnsupportedOperationException();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void error(Marker marker, String format, Object arg1, Object arg2) {
|
||||
throw new UnsupportedOperationException();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void error(Marker marker, String format, Object... arguments) {
|
||||
throw new UnsupportedOperationException();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void error(Marker marker, String msg, Throwable t) {
|
||||
throw new UnsupportedOperationException();
|
||||
}
|
||||
}
|
||||
@@ -1,40 +0,0 @@
|
||||
/*
|
||||
* Copyright © 2013 – 2015 Ricki Hirner (bitfire web engineering).
|
||||
* All rights reserved. This program and the accompanying materials
|
||||
* are made available under the terms of the GNU Public License v3.0
|
||||
* which accompanies this distribution, and is available at
|
||||
* http://www.gnu.org/licenses/gpl.html
|
||||
*/
|
||||
|
||||
package at.bitfire.davdroid.log;
|
||||
|
||||
import android.content.Context;
|
||||
|
||||
import java.io.Closeable;
|
||||
import java.io.File;
|
||||
import java.io.IOException;
|
||||
import java.io.PrintWriter;
|
||||
|
||||
public class ExternalFileLogger extends CustomLogger implements Closeable {
|
||||
|
||||
public ExternalFileLogger(Context context, String fileName, boolean verbose) throws IOException {
|
||||
this.verbose = verbose;
|
||||
|
||||
File dir = getDirectory(context);
|
||||
if (dir == null)
|
||||
throw new IOException("External media not available for log creation");
|
||||
|
||||
File log = new File(dir, name = fileName);
|
||||
writer = new PrintWriter(log);
|
||||
}
|
||||
|
||||
public static File getDirectory(Context context) {
|
||||
return context.getExternalFilesDir(null);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void close() throws IOException {
|
||||
writer.close();
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
/*
|
||||
* Copyright © Ricki Hirner (bitfire web engineering).
|
||||
* All rights reserved. This program and the accompanying materials
|
||||
* are made available under the terms of the GNU Public License v3.0
|
||||
* which accompanies this distribution, and is available at
|
||||
* http://www.gnu.org/licenses/gpl.html
|
||||
*/
|
||||
|
||||
package at.bitfire.davdroid.log
|
||||
|
||||
import android.util.Log
|
||||
|
||||
import org.apache.commons.lang3.math.NumberUtils
|
||||
|
||||
import java.util.logging.Handler
|
||||
import java.util.logging.Level
|
||||
import java.util.logging.LogRecord
|
||||
|
||||
object LogcatHandler: Handler() {
|
||||
|
||||
private val MAX_LINE_LENGTH = 3000
|
||||
|
||||
init {
|
||||
formatter = PlainTextFormatter.LOGCAT
|
||||
level = Level.ALL
|
||||
}
|
||||
|
||||
override fun publish(r: LogRecord) {
|
||||
val text = formatter.format(r)
|
||||
val level = r.level.intValue()
|
||||
|
||||
val end = text.length
|
||||
var pos = 0
|
||||
while (pos < end) {
|
||||
val line = text.substring(pos, NumberUtils.min(pos + MAX_LINE_LENGTH, end))
|
||||
when {
|
||||
level >= Level.SEVERE.intValue() -> Log.e(r.loggerName, line)
|
||||
level >= Level.WARNING.intValue() -> Log.w(r.loggerName, line)
|
||||
level >= Level.CONFIG.intValue() -> Log.i(r.loggerName, line)
|
||||
level >= Level.FINER.intValue() -> Log.d(r.loggerName, line)
|
||||
else -> Log.v(r.loggerName, line)
|
||||
}
|
||||
pos += MAX_LINE_LENGTH
|
||||
}
|
||||
}
|
||||
|
||||
override fun flush() {}
|
||||
override fun close() {}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
/*
|
||||
* Copyright © Ricki Hirner (bitfire web engineering).
|
||||
* All rights reserved. This program and the accompanying materials
|
||||
* are made available under the terms of the GNU Public License v3.0
|
||||
* which accompanies this distribution, and is available at
|
||||
* http://www.gnu.org/licenses/gpl.html
|
||||
*/
|
||||
|
||||
package at.bitfire.davdroid.log
|
||||
|
||||
import android.content.Context
|
||||
import android.os.Process
|
||||
import android.support.v4.app.NotificationCompat
|
||||
import android.support.v4.app.NotificationManagerCompat
|
||||
import android.util.Log
|
||||
import at.bitfire.davdroid.App
|
||||
import at.bitfire.davdroid.Constants
|
||||
import at.bitfire.davdroid.R
|
||||
import at.bitfire.davdroid.model.ServiceDB
|
||||
import at.bitfire.davdroid.model.Settings
|
||||
import org.apache.commons.lang3.time.DateFormatUtils
|
||||
import java.io.File
|
||||
import java.io.IOException
|
||||
import java.util.logging.FileHandler
|
||||
import java.util.logging.Level
|
||||
import java.util.logging.Logger
|
||||
|
||||
object Logger {
|
||||
|
||||
@JvmField
|
||||
val log = Logger.getLogger("davdroid")!!
|
||||
init {
|
||||
at.bitfire.dav4android.Constants.log = Logger.getLogger("davdroid.dav4android")
|
||||
at.bitfire.cert4android.Constants.log = Logger.getLogger("davdroid.cert4android")
|
||||
}
|
||||
|
||||
fun reinitLogger(context: Context) {
|
||||
ServiceDB.OpenHelper(context).use { dbHelper ->
|
||||
val settings = Settings(dbHelper.getReadableDatabase())
|
||||
|
||||
val logToFile = settings.getBoolean(App.LOG_TO_EXTERNAL_STORAGE, false)
|
||||
val logVerbose = logToFile || Log.isLoggable(log.name, Log.DEBUG)
|
||||
|
||||
log.info("Verbose logging: $logVerbose")
|
||||
|
||||
// set logging level according to preferences
|
||||
val rootLogger = Logger.getLogger("")
|
||||
rootLogger.level = if (logVerbose) Level.ALL else Level.INFO
|
||||
|
||||
// remove all handlers and add our own logcat handler
|
||||
rootLogger.useParentHandlers = false
|
||||
rootLogger.handlers.forEach { rootLogger.removeHandler(it) }
|
||||
rootLogger.addHandler(LogcatHandler)
|
||||
|
||||
val nm = NotificationManagerCompat.from(context)
|
||||
// log to external file according to preferences
|
||||
if (logToFile) {
|
||||
val builder = NotificationCompat.Builder(context)
|
||||
builder .setSmallIcon(R.drawable.ic_sd_storage_light)
|
||||
.setLargeIcon(App.getLauncherBitmap(context))
|
||||
.setContentTitle(context.getString(R.string.logging_davdroid_file_logging))
|
||||
.setLocalOnly(true)
|
||||
|
||||
val dir = context.getExternalFilesDir(null)
|
||||
if (dir != null)
|
||||
try {
|
||||
val fileName = File(dir, "davdroid-${Process.myPid()}-${DateFormatUtils.format(System.currentTimeMillis(), "yyyyMMdd-HHmmss")}.txt").toString()
|
||||
log.info("Logging to $fileName")
|
||||
|
||||
val fileHandler = FileHandler(fileName)
|
||||
fileHandler.formatter = PlainTextFormatter.DEFAULT
|
||||
log.addHandler(fileHandler)
|
||||
builder .setContentText(dir.path)
|
||||
.setSubText(context.getString(R.string.logging_to_external_storage_warning))
|
||||
.setCategory(NotificationCompat.CATEGORY_STATUS)
|
||||
.setPriority(NotificationCompat.PRIORITY_HIGH)
|
||||
.setStyle(NotificationCompat.BigTextStyle()
|
||||
.bigText(context.getString(R.string.logging_to_external_storage, dir.path)))
|
||||
.setOngoing(true)
|
||||
|
||||
} catch (e: IOException) {
|
||||
log.log(Level.SEVERE, "Couldn't create external log file", e)
|
||||
|
||||
builder .setContentText(context.getString(R.string.logging_couldnt_create_file, e.localizedMessage))
|
||||
.setCategory(NotificationCompat.CATEGORY_ERROR)
|
||||
}
|
||||
else
|
||||
builder.setContentText(context.getString(R.string.logging_no_external_storage))
|
||||
|
||||
nm.notify(Constants.NOTIFICATION_EXTERNAL_FILE_LOGGING, builder.build())
|
||||
} else
|
||||
nm.cancel(Constants.NOTIFICATION_EXTERNAL_FILE_LOGGING)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
/*
|
||||
* Copyright © Ricki Hirner (bitfire web engineering).
|
||||
* All rights reserved. This program and the accompanying materials
|
||||
* are made available under the terms of the GNU Public License v3.0
|
||||
* which accompanies this distribution, and is available at
|
||||
* http://www.gnu.org/licenses/gpl.html
|
||||
*/
|
||||
|
||||
package at.bitfire.davdroid.log
|
||||
|
||||
import org.apache.commons.lang3.exception.ExceptionUtils
|
||||
import org.apache.commons.lang3.time.DateFormatUtils
|
||||
import java.util.logging.Formatter
|
||||
import java.util.logging.LogRecord
|
||||
|
||||
class PlainTextFormatter private constructor(
|
||||
val logcat: Boolean
|
||||
): Formatter() {
|
||||
|
||||
companion object {
|
||||
@JvmField val LOGCAT = PlainTextFormatter(true)
|
||||
@JvmField val DEFAULT = PlainTextFormatter(false)
|
||||
}
|
||||
|
||||
override fun format(r: LogRecord): String {
|
||||
val builder = StringBuilder()
|
||||
|
||||
if (!logcat)
|
||||
builder .append(DateFormatUtils.format(r.millis, "yyyy-MM-dd HH:mm:ss"))
|
||||
.append(" ").append(r.threadID).append(" ")
|
||||
|
||||
if (r.sourceClassName.replaceFirst("\\$.*", "") != r.loggerName) {
|
||||
val className = shortClassName(r.sourceClassName)
|
||||
if (className != "ical4android.AndroidAppender")
|
||||
builder.append("[").append(className).append("] ")
|
||||
}
|
||||
|
||||
builder.append(r.message)
|
||||
|
||||
r.thrown?.let {
|
||||
builder .append("\nEXCEPTION ")
|
||||
.append(ExceptionUtils.getStackTrace(it))
|
||||
}
|
||||
|
||||
r.parameters?.let {
|
||||
for ((idx, param) in it.withIndex())
|
||||
builder.append("\n\tPARAMETER #").append(idx).append(" = ").append(param)
|
||||
}
|
||||
|
||||
if (!logcat)
|
||||
builder.append("\n")
|
||||
|
||||
return builder.toString()
|
||||
}
|
||||
|
||||
private fun shortClassName(className: String) = className
|
||||
.replace("at.bitfire.davdroid.", "")
|
||||
.replace("at.bitfire.", "")
|
||||
.replace(Regex("\\$.*$"), "")
|
||||
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
/*
|
||||
* Copyright © Ricki Hirner (bitfire web engineering).
|
||||
* All rights reserved. This program and the accompanying materials
|
||||
* are made available under the terms of the GNU Public License v3.0
|
||||
* which accompanies this distribution, and is available at
|
||||
* http://www.gnu.org/licenses/gpl.html
|
||||
*/
|
||||
|
||||
package at.bitfire.davdroid.log
|
||||
|
||||
import java.util.logging.Handler;
|
||||
import java.util.logging.LogRecord;
|
||||
|
||||
class StringHandler: Handler() {
|
||||
|
||||
val builder = StringBuilder()
|
||||
|
||||
init {
|
||||
formatter = PlainTextFormatter.DEFAULT
|
||||
}
|
||||
|
||||
override fun publish(record: LogRecord) {
|
||||
builder.append(formatter.format(record))
|
||||
}
|
||||
|
||||
override fun flush() {}
|
||||
override fun close() {}
|
||||
|
||||
override fun toString() = builder.toString()
|
||||
|
||||
}
|
||||
@@ -1,35 +0,0 @@
|
||||
/*
|
||||
* Copyright © 2013 – 2015 Ricki Hirner (bitfire web engineering).
|
||||
* All rights reserved. This program and the accompanying materials
|
||||
* are made available under the terms of the GNU Public License v3.0
|
||||
* which accompanies this distribution, and is available at
|
||||
* http://www.gnu.org/licenses/gpl.html
|
||||
*/
|
||||
|
||||
package at.bitfire.davdroid.log;
|
||||
|
||||
import java.io.PrintWriter;
|
||||
import java.io.StringWriter;
|
||||
|
||||
import lombok.Getter;
|
||||
|
||||
public class StringLogger extends CustomLogger {
|
||||
|
||||
@Getter protected final String name;
|
||||
|
||||
protected final StringWriter stringWriter = new StringWriter();
|
||||
|
||||
|
||||
public StringLogger(String name, boolean verbose) {
|
||||
this.name = name;
|
||||
this.verbose = verbose;
|
||||
|
||||
writer = new PrintWriter(stringWriter);
|
||||
}
|
||||
|
||||
public String toString() {
|
||||
return stringWriter.toString();
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
/*
|
||||
* Copyright © Ricki Hirner (bitfire web engineering).
|
||||
* All rights reserved. This program and the accompanying materials
|
||||
* are made available under the terms of the GNU Public License v3.0
|
||||
* which accompanies this distribution, and is available at
|
||||
* http://www.gnu.org/licenses/gpl.html
|
||||
*/
|
||||
|
||||
package at.bitfire.davdroid.model
|
||||
|
||||
import android.content.ContentValues
|
||||
import at.bitfire.dav4android.DavResource
|
||||
import at.bitfire.dav4android.property.*
|
||||
import at.bitfire.davdroid.model.ServiceDB.Collections
|
||||
import java.io.Serializable
|
||||
|
||||
data class CollectionInfo @JvmOverloads constructor(
|
||||
val url: String,
|
||||
|
||||
var id: Long? = null,
|
||||
var serviceID: Long? = null,
|
||||
|
||||
var type: Type? = null,
|
||||
|
||||
var readOnly: Boolean = false,
|
||||
var displayName: String? = null,
|
||||
var description: String? = null,
|
||||
var color: Int? = null,
|
||||
|
||||
var timeZone: String? = null,
|
||||
var supportsVEVENT: Boolean = false,
|
||||
var supportsVTODO: Boolean = false,
|
||||
|
||||
var selected: Boolean = false,
|
||||
|
||||
// non-persistent properties
|
||||
var confirmed: Boolean = false
|
||||
): Serializable {
|
||||
|
||||
enum class Type {
|
||||
ADDRESS_BOOK,
|
||||
CALENDAR
|
||||
}
|
||||
|
||||
companion object {
|
||||
|
||||
@JvmField
|
||||
val DAV_PROPERTIES = arrayOf(
|
||||
ResourceType.NAME,
|
||||
CurrentUserPrivilegeSet.NAME,
|
||||
DisplayName.NAME,
|
||||
AddressbookDescription.NAME, SupportedAddressData.NAME,
|
||||
CalendarDescription.NAME, CalendarColor.NAME, SupportedCalendarComponentSet.NAME
|
||||
)
|
||||
|
||||
}
|
||||
|
||||
|
||||
constructor(dav: DavResource): this(dav.location.toString()) {
|
||||
(dav.properties[ResourceType.NAME] as ResourceType?)?.let { type ->
|
||||
when {
|
||||
type.types.contains(ResourceType.ADDRESSBOOK) -> this.type = Type.ADDRESS_BOOK
|
||||
type.types.contains(ResourceType.CALENDAR) -> this.type = Type.CALENDAR
|
||||
}
|
||||
}
|
||||
|
||||
(dav.properties[CurrentUserPrivilegeSet.NAME] as CurrentUserPrivilegeSet?)?.let { privilegeSet ->
|
||||
readOnly = !privilegeSet.mayWriteContent
|
||||
}
|
||||
|
||||
(dav.properties[DisplayName.NAME] as DisplayName?)?.let {
|
||||
if (!it.displayName.isNullOrEmpty())
|
||||
displayName = it.displayName
|
||||
}
|
||||
|
||||
when (type) {
|
||||
Type.ADDRESS_BOOK -> {
|
||||
(dav.properties[AddressbookDescription.NAME] as AddressbookDescription?)?.let { description = it.description }
|
||||
}
|
||||
Type.CALENDAR -> {
|
||||
(dav.properties[CalendarDescription.NAME] as CalendarDescription?)?.let { description = it.description }
|
||||
(dav.properties[CalendarColor.NAME] as CalendarColor?)?.let { color = it.color }
|
||||
(dav.properties[CalendarTimezone.NAME] as CalendarTimezone?)?.let { timeZone = it.vTimeZone }
|
||||
|
||||
supportsVEVENT = true
|
||||
supportsVTODO = true
|
||||
(dav.properties[SupportedCalendarComponentSet.NAME] as SupportedCalendarComponentSet?)?.let {
|
||||
supportsVEVENT = it.supportsEvents
|
||||
supportsVTODO = it.supportsTasks
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
constructor(values: ContentValues): this(values.getAsString(Collections.URL)) {
|
||||
id = values.getAsLong(Collections.ID)
|
||||
serviceID = values.getAsLong(Collections.SERVICE_ID)
|
||||
|
||||
readOnly = values.getAsInteger(Collections.READ_ONLY) != 0
|
||||
displayName = values.getAsString(Collections.DISPLAY_NAME)
|
||||
description = values.getAsString(Collections.DESCRIPTION)
|
||||
|
||||
color = values.getAsInteger(Collections.COLOR)
|
||||
|
||||
timeZone = values.getAsString(Collections.TIME_ZONE)
|
||||
supportsVEVENT = getAsBooleanOrNull(values, Collections.SUPPORTS_VEVENT) ?: false
|
||||
supportsVTODO = getAsBooleanOrNull(values, Collections.SUPPORTS_VTODO) ?: false
|
||||
|
||||
selected = values.getAsInteger(Collections.SYNC) != 0
|
||||
}
|
||||
|
||||
fun toDB(): ContentValues {
|
||||
val values = ContentValues()
|
||||
// Collections.SERVICE_ID is never changed
|
||||
|
||||
values.put(Collections.URL, url)
|
||||
values.put(Collections.READ_ONLY, if (readOnly) 1 else 0)
|
||||
values.put(Collections.DISPLAY_NAME, displayName)
|
||||
values.put(Collections.DESCRIPTION, description)
|
||||
values.put(Collections.COLOR, color)
|
||||
|
||||
values.put(Collections.TIME_ZONE, timeZone)
|
||||
values.put(Collections.SUPPORTS_VEVENT, if (supportsVEVENT) 1 else 0)
|
||||
values.put(Collections.SUPPORTS_VTODO, if (supportsVTODO) 1 else 0)
|
||||
|
||||
values.put(Collections.SYNC, if (selected) 1 else 0)
|
||||
return values
|
||||
}
|
||||
|
||||
|
||||
private fun getAsBooleanOrNull(values: ContentValues, field: String): Boolean? {
|
||||
val i = values.getAsInteger(field)
|
||||
return if (i == null)
|
||||
null
|
||||
else
|
||||
(i != 0)
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,176 @@
|
||||
/*
|
||||
* Copyright © Ricki Hirner (bitfire web engineering).
|
||||
* All rights reserved. This program and the accompanying materials
|
||||
* are made available under the terms of the GNU Public License v3.0
|
||||
* which accompanies this distribution, and is available at
|
||||
* http://www.gnu.org/licenses/gpl.html
|
||||
*/
|
||||
|
||||
package at.bitfire.davdroid.model
|
||||
|
||||
import android.content.ContentValues
|
||||
import android.content.Context
|
||||
import android.database.sqlite.SQLiteDatabase
|
||||
import android.database.sqlite.SQLiteException
|
||||
import android.database.sqlite.SQLiteOpenHelper
|
||||
import at.bitfire.davdroid.log.Logger
|
||||
import java.io.Closeable
|
||||
|
||||
class ServiceDB {
|
||||
|
||||
object Settings {
|
||||
@JvmField val _TABLE = "settings"
|
||||
@JvmField val NAME = "setting"
|
||||
@JvmField val VALUE = "value"
|
||||
}
|
||||
|
||||
object Services {
|
||||
@JvmField val _TABLE = "services"
|
||||
@JvmField val ID = "_id"
|
||||
@JvmField val ACCOUNT_NAME = "accountName"
|
||||
@JvmField val SERVICE = "service"
|
||||
@JvmField val PRINCIPAL = "principal"
|
||||
|
||||
// allowed values for SERVICE column
|
||||
@JvmField val SERVICE_CALDAV = "caldav"
|
||||
@JvmField val SERVICE_CARDDAV = "carddav"
|
||||
}
|
||||
|
||||
object HomeSets {
|
||||
@JvmField val _TABLE = "homesets"
|
||||
@JvmField val ID = "_id"
|
||||
@JvmField val SERVICE_ID = "serviceID"
|
||||
@JvmField val URL = "url"
|
||||
}
|
||||
|
||||
object Collections {
|
||||
@JvmField val _TABLE = "collections"
|
||||
@JvmField val ID = "_id"
|
||||
@JvmField val SERVICE_ID = "serviceID"
|
||||
@JvmField val URL = "url"
|
||||
@JvmField val READ_ONLY = "readOnly"
|
||||
@JvmField val DISPLAY_NAME = "displayName"
|
||||
@JvmField val DESCRIPTION = "description"
|
||||
@JvmField val COLOR = "color"
|
||||
@JvmField val TIME_ZONE = "timezone"
|
||||
@JvmField val SUPPORTS_VEVENT = "supportsVEVENT"
|
||||
@JvmField val SUPPORTS_VTODO = "supportsVTODO"
|
||||
@JvmField val SYNC = "sync"
|
||||
}
|
||||
|
||||
companion object {
|
||||
|
||||
@JvmStatic
|
||||
fun onRenameAccount(db: SQLiteDatabase, oldName: String, newName: String) {
|
||||
val values = ContentValues(1)
|
||||
values.put(Services.ACCOUNT_NAME, newName)
|
||||
db.update(Services._TABLE, values, Services.ACCOUNT_NAME + "=?", arrayOf(oldName))
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
class OpenHelper(
|
||||
context: Context
|
||||
): SQLiteOpenHelper(context, DATABASE_NAME, null, DATABASE_VERSION), Closeable {
|
||||
|
||||
companion object {
|
||||
val DATABASE_NAME = "services.db"
|
||||
val DATABASE_VERSION = 1
|
||||
}
|
||||
|
||||
override fun onConfigure(db: SQLiteDatabase) {
|
||||
setWriteAheadLoggingEnabled(true)
|
||||
db.setForeignKeyConstraintsEnabled(true)
|
||||
}
|
||||
|
||||
override fun onCreate(db: SQLiteDatabase) {
|
||||
Logger.log.info("Creating database " + db.path)
|
||||
|
||||
db.execSQL("CREATE TABLE ${Settings._TABLE}(" +
|
||||
"${Settings.NAME} TEXT NOT NULL," +
|
||||
"${Settings.VALUE} TEXT NOT NULL)")
|
||||
db.execSQL("CREATE UNIQUE INDEX settings_name ON ${Settings._TABLE} (${Settings.NAME})")
|
||||
|
||||
db.execSQL("CREATE TABLE ${Services._TABLE}(" +
|
||||
"${Services.ID} INTEGER PRIMARY KEY AUTOINCREMENT," +
|
||||
"${Services.ACCOUNT_NAME} TEXT NOT NULL," +
|
||||
"${Services.SERVICE} TEXT NOT NULL," +
|
||||
"${Services.PRINCIPAL} TEXT NULL)")
|
||||
db.execSQL("CREATE UNIQUE INDEX services_account ON ${Services._TABLE} (${Services.ACCOUNT_NAME},${Services.SERVICE})")
|
||||
|
||||
db.execSQL("CREATE TABLE ${HomeSets._TABLE}(" +
|
||||
"${HomeSets.ID} INTEGER PRIMARY KEY AUTOINCREMENT," +
|
||||
"${HomeSets.SERVICE_ID} INTEGER NOT NULL REFERENCES ${Services._TABLE} ON DELETE CASCADE," +
|
||||
"${HomeSets.URL} TEXT NOT NULL)")
|
||||
db.execSQL("CREATE UNIQUE INDEX homesets_service_url ON ${HomeSets._TABLE}(${HomeSets.SERVICE_ID},${HomeSets.URL})")
|
||||
|
||||
db.execSQL("CREATE TABLE ${Collections._TABLE}(" +
|
||||
"${Collections.ID} INTEGER PRIMARY KEY AUTOINCREMENT," +
|
||||
"${Collections.SERVICE_ID} INTEGER NOT NULL REFERENCES ${Services._TABLE} ON DELETE CASCADE," +
|
||||
"${Collections.URL} TEXT NOT NULL," +
|
||||
"${Collections.READ_ONLY} INTEGER DEFAULT 0 NOT NULL," +
|
||||
"${Collections.DISPLAY_NAME} TEXT NULL," +
|
||||
"${Collections.DESCRIPTION} TEXT NULL," +
|
||||
"${Collections.COLOR} INTEGER NULL," +
|
||||
"${Collections.TIME_ZONE} TEXT NULL," +
|
||||
"${Collections.SUPPORTS_VEVENT} INTEGER NULL," +
|
||||
"${Collections.SUPPORTS_VTODO} INTEGER NULL," +
|
||||
"${Collections.SYNC} INTEGER DEFAULT 0 NOT NULL)")
|
||||
db.execSQL("CREATE UNIQUE INDEX collections_service_url ON ${Collections._TABLE}(${Collections.SERVICE_ID},${Collections.URL})")
|
||||
}
|
||||
|
||||
override fun onUpgrade(db: SQLiteDatabase, oldVersion: Int, newVersion: Int) {
|
||||
// no different versions yet
|
||||
}
|
||||
|
||||
|
||||
fun dump(sb: StringBuilder) {
|
||||
val db = readableDatabase
|
||||
db.beginTransactionNonExclusive()
|
||||
|
||||
// iterate through all tables
|
||||
db.query("sqlite_master", arrayOf("name"), "type='table'", null, null, null, null).use { cursorTables ->
|
||||
while (cursorTables.moveToNext()) {
|
||||
val table = cursorTables.getString(0)
|
||||
sb.append(table).append("\n")
|
||||
db.query(table, null, null, null, null, null, null).use { cursor ->
|
||||
// print columns
|
||||
val cols = cursor.columnCount
|
||||
sb.append("\t| ")
|
||||
for (i in 0 .. cols-1)
|
||||
sb .append(" ")
|
||||
.append(cursor.getColumnName(i))
|
||||
.append(" |")
|
||||
sb.append("\n")
|
||||
|
||||
// print rows
|
||||
while (cursor.moveToNext()) {
|
||||
sb.append("\t| ")
|
||||
for (i in 0 .. cols-1) {
|
||||
sb.append(" ")
|
||||
try {
|
||||
val value = cursor.getString(i)
|
||||
if (value != null)
|
||||
sb.append(value
|
||||
.replace("\r", "<CR>")
|
||||
.replace("\n", "<LF>"))
|
||||
else
|
||||
sb.append("<null>")
|
||||
|
||||
} catch (e: SQLiteException) {
|
||||
sb.append("<unprintable>")
|
||||
}
|
||||
sb.append(" |")
|
||||
}
|
||||
sb.append("\n")
|
||||
}
|
||||
sb.append("----------\n")
|
||||
}
|
||||
}
|
||||
db.endTransaction()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
/*
|
||||
* Copyright © Ricki Hirner (bitfire web engineering).
|
||||
* All rights reserved. This program and the accompanying materials
|
||||
* are made available under the terms of the GNU Public License v3.0
|
||||
* which accompanies this distribution, and is available at
|
||||
* http://www.gnu.org/licenses/gpl.html
|
||||
*/
|
||||
|
||||
package at.bitfire.davdroid.model
|
||||
|
||||
import android.content.BroadcastReceiver
|
||||
import android.content.ContentValues;
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.database.sqlite.SQLiteDatabase
|
||||
import at.bitfire.davdroid.CustomCertificates
|
||||
import at.bitfire.davdroid.log.Logger
|
||||
|
||||
class Settings(
|
||||
val db: SQLiteDatabase
|
||||
) {
|
||||
|
||||
fun getBoolean(name: String, defaultValue: Boolean): Boolean {
|
||||
db.query(ServiceDB.Settings._TABLE, arrayOf(ServiceDB.Settings.VALUE),
|
||||
"${ServiceDB.Settings.NAME}=?", arrayOf(name), null, null, null)?.use { cursor ->
|
||||
if (cursor.moveToNext() && !cursor.isNull(0))
|
||||
return cursor.getInt(0) != 0
|
||||
}
|
||||
return defaultValue
|
||||
}
|
||||
|
||||
fun putBoolean(name: String, value: Boolean) {
|
||||
val values = ContentValues(2)
|
||||
values.put(ServiceDB.Settings.NAME, name)
|
||||
values.put(ServiceDB.Settings.VALUE, if (value) 1 else 0)
|
||||
db.insertWithOnConflict(ServiceDB.Settings._TABLE, null, values, SQLiteDatabase.CONFLICT_REPLACE)
|
||||
}
|
||||
|
||||
|
||||
fun getInt(name: String, defaultValue: Int): Int {
|
||||
db.query(ServiceDB.Settings._TABLE, arrayOf(ServiceDB.Settings.VALUE),
|
||||
"${ServiceDB.Settings.NAME}=?", arrayOf(name), null, null, null)?.use { cursor ->
|
||||
if (cursor.moveToNext() && !cursor.isNull(0))
|
||||
return if (cursor.isNull(0)) defaultValue else cursor.getInt(0)
|
||||
}
|
||||
return defaultValue
|
||||
}
|
||||
|
||||
fun putInt(name: String, value: Int?) {
|
||||
val values = ContentValues(2)
|
||||
values.put(ServiceDB.Settings.NAME, name)
|
||||
values.put(ServiceDB.Settings.VALUE, value)
|
||||
db.insertWithOnConflict(ServiceDB.Settings._TABLE, null, values, SQLiteDatabase.CONFLICT_REPLACE)
|
||||
}
|
||||
|
||||
|
||||
fun getString(name: String, defaultValue: String?): String? {
|
||||
db.query(ServiceDB.Settings._TABLE, arrayOf(ServiceDB.Settings.VALUE),
|
||||
"${ServiceDB.Settings.NAME}=?", arrayOf(name), null, null, null)?.use { cursor ->
|
||||
if (cursor.moveToNext())
|
||||
return cursor.getString(0)
|
||||
}
|
||||
return defaultValue
|
||||
}
|
||||
|
||||
fun putString(name: String, value: String?) {
|
||||
val values = ContentValues(2)
|
||||
values.put(ServiceDB.Settings.NAME, name)
|
||||
values.put(ServiceDB.Settings.VALUE, value)
|
||||
db.insertWithOnConflict(ServiceDB.Settings._TABLE, null, values, SQLiteDatabase.CONFLICT_REPLACE)
|
||||
}
|
||||
|
||||
|
||||
fun remove(name: String) {
|
||||
db.delete(ServiceDB.Settings._TABLE, "${ServiceDB.Settings.NAME}=?", arrayOf(name))
|
||||
}
|
||||
|
||||
|
||||
class ReinitSettingsReceiver: BroadcastReceiver() {
|
||||
|
||||
companion object {
|
||||
@JvmField val ACTION_REINIT_SETTINGS = "at.bitfire.davdroid.REINIT_SETTINGS"
|
||||
}
|
||||
|
||||
override fun onReceive(context: Context, intent: Intent) {
|
||||
Logger.log.info("Received broadcast: re-initializing settings (logger/cert manager)")
|
||||
|
||||
CustomCertificates.reinitCertManager(context)
|
||||
Logger.reinitLogger(context)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
/*
|
||||
* Copyright © Ricki Hirner (bitfire web engineering).
|
||||
* All rights reserved. This program and the accompanying materials
|
||||
* are made available under the terms of the GNU Public License v3.0
|
||||
* which accompanies this distribution, and is available at
|
||||
* http://www.gnu.org/licenses/gpl.html
|
||||
*/
|
||||
|
||||
package at.bitfire.davdroid.model;
|
||||
|
||||
import android.provider.ContactsContract.RawContacts;
|
||||
|
||||
object UnknownProperties {
|
||||
|
||||
@JvmField
|
||||
val CONTENT_ITEM_TYPE = "x.davdroid/unknown-properties"
|
||||
|
||||
|
||||
@JvmField
|
||||
val MIMETYPE = RawContacts.Data.MIMETYPE
|
||||
|
||||
@JvmField
|
||||
val RAW_CONTACT_ID = RawContacts.Data.RAW_CONTACT_ID
|
||||
|
||||
@JvmField
|
||||
val UNKNOWN_PROPERTIES = RawContacts.Data.DATA1
|
||||
|
||||
}
|
||||
@@ -1,459 +0,0 @@
|
||||
/*
|
||||
* Copyright © 2013 – 2015 Ricki Hirner (bitfire web engineering).
|
||||
* All rights reserved. This program and the accompanying materials
|
||||
* are made available under the terms of the GNU Public License v3.0
|
||||
* which accompanies this distribution, and is available at
|
||||
* http://www.gnu.org/licenses/gpl.html
|
||||
*/
|
||||
package at.bitfire.davdroid.resource;
|
||||
|
||||
import android.content.Context;
|
||||
import android.text.TextUtils;
|
||||
|
||||
import com.squareup.okhttp.HttpUrl;
|
||||
|
||||
import org.slf4j.Logger;
|
||||
import org.xbill.DNS.Lookup;
|
||||
import org.xbill.DNS.Record;
|
||||
import org.xbill.DNS.SRVRecord;
|
||||
import org.xbill.DNS.TXTRecord;
|
||||
import org.xbill.DNS.Type;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.net.URI;
|
||||
import java.net.URISyntaxException;
|
||||
import java.util.HashMap;
|
||||
import java.util.HashSet;
|
||||
import java.util.LinkedList;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
|
||||
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.AddressbookDescription;
|
||||
import at.bitfire.dav4android.property.AddressbookHomeSet;
|
||||
import at.bitfire.dav4android.property.CalendarColor;
|
||||
import at.bitfire.dav4android.property.CalendarDescription;
|
||||
import at.bitfire.dav4android.property.CalendarHomeSet;
|
||||
import at.bitfire.dav4android.property.CalendarTimezone;
|
||||
import at.bitfire.dav4android.property.CurrentUserPrincipal;
|
||||
import at.bitfire.dav4android.property.CurrentUserPrivilegeSet;
|
||||
import at.bitfire.dav4android.property.DisplayName;
|
||||
import at.bitfire.dav4android.property.ResourceType;
|
||||
import at.bitfire.dav4android.property.SupportedCalendarComponentSet;
|
||||
import at.bitfire.davdroid.HttpClient;
|
||||
import lombok.NonNull;
|
||||
|
||||
public class DavResourceFinder {
|
||||
protected enum Service {
|
||||
CALDAV("caldav"),
|
||||
CARDDAV("carddav");
|
||||
|
||||
final String name;
|
||||
Service(String name) { this.name = name;}
|
||||
@Override public String toString() { return name; }
|
||||
};
|
||||
|
||||
protected final Logger log;
|
||||
protected final Context context;
|
||||
protected final HttpClient httpClient;
|
||||
protected final ServerInfo serverInfo;
|
||||
|
||||
protected Map<HttpUrl, ServerInfo.ResourceInfo>
|
||||
addressbooks = new HashMap<>(),
|
||||
calendars = new HashMap<>(),
|
||||
taskLists = new HashMap<>();
|
||||
|
||||
|
||||
public DavResourceFinder(Logger log, Context context, ServerInfo serverInfo) {
|
||||
this.log = log;
|
||||
this.context = context;
|
||||
this.serverInfo = serverInfo;
|
||||
|
||||
httpClient = new HttpClient(log, context, serverInfo.getUserName(), serverInfo.getPassword(), serverInfo.authPreemptive);
|
||||
}
|
||||
|
||||
public void findResources() {
|
||||
try {
|
||||
findResources(Service.CARDDAV);
|
||||
findResources(Service.CALDAV);
|
||||
} catch(URISyntaxException e) {
|
||||
log.warn("Invalid user-given URI", e);
|
||||
}
|
||||
}
|
||||
|
||||
public void findResources(Service service) throws URISyntaxException {
|
||||
URI baseURI = serverInfo.getBaseURI();
|
||||
String domain = null;
|
||||
|
||||
HttpUrl principalUrl = null;
|
||||
Set<HttpUrl> homeSets = new HashSet<>();
|
||||
|
||||
if (service == Service.CALDAV) {
|
||||
calendars.clear();
|
||||
taskLists.clear();
|
||||
} else if (service == Service.CARDDAV)
|
||||
addressbooks.clear();
|
||||
|
||||
log.info("*** STARTING COLLECTION DISCOVERY FOR SERVICE " + service.name.toUpperCase(Locale.US) + "***");
|
||||
if ("http".equals(baseURI.getScheme()) || "https".equals(baseURI.getScheme())) {
|
||||
HttpUrl userURL = HttpUrl.get(baseURI);
|
||||
|
||||
/* check whether:
|
||||
1. user-given URL is a calendar
|
||||
2. user-given URL has a calendar-home-set property (i.e. is a principal URL)
|
||||
*/
|
||||
log.info("Check whether user-given URL is a calendar collection and/or contains <calendar-home-set> and/or has <current-user-principal>");
|
||||
DavResource davBase = new DavResource(log, httpClient, userURL);
|
||||
try {
|
||||
if (service == Service.CALDAV) {
|
||||
davBase.propfind(0,
|
||||
CalendarHomeSet.NAME, SupportedCalendarComponentSet.NAME,
|
||||
ResourceType.NAME, DisplayName.NAME, CalendarColor.NAME, CalendarDescription.NAME, CalendarTimezone.NAME, CurrentUserPrivilegeSet.NAME,
|
||||
CurrentUserPrincipal.NAME
|
||||
);
|
||||
addIfCalendar(davBase);
|
||||
} else if (service == Service.CARDDAV) {
|
||||
davBase.propfind(0,
|
||||
AddressbookHomeSet.NAME,
|
||||
ResourceType.NAME, DisplayName.NAME, AddressbookDescription.NAME, CurrentUserPrivilegeSet.NAME,
|
||||
CurrentUserPrincipal.NAME
|
||||
);
|
||||
addIfAddressBook(davBase);
|
||||
}
|
||||
} catch (IOException|HttpException|DavException e) {
|
||||
log.debug("PROPFIND on user-given URL failed", e);
|
||||
}
|
||||
|
||||
if (service == Service.CALDAV) {
|
||||
CalendarHomeSet calendarHomeSet = (CalendarHomeSet)davBase.properties.get(CalendarHomeSet.NAME);
|
||||
if (calendarHomeSet != null) {
|
||||
log.info("Found <calendar-home-set> at user-given URL");
|
||||
for (String href : calendarHomeSet.hrefs) {
|
||||
HttpUrl url = userURL.resolve(href);
|
||||
if (url != null)
|
||||
homeSets.add(url);
|
||||
}
|
||||
}
|
||||
} else if (service == Service.CARDDAV) {
|
||||
AddressbookHomeSet addressbookHomeSet = (AddressbookHomeSet) davBase.properties.get(AddressbookHomeSet.NAME);
|
||||
if (addressbookHomeSet != null) {
|
||||
log.info("Found <addressbook-home-set> at user-given URL");
|
||||
for (String href : addressbookHomeSet.hrefs) {
|
||||
HttpUrl url = userURL.resolve(href);
|
||||
if (url != null)
|
||||
homeSets.add(url);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* When home sets haven already been found, skip further searching.
|
||||
* Otherwise (no home sets found), treat the user-given URL as "initial context path" for service discovery.
|
||||
*
|
||||
* Keep in mind that the CalDAV principal URL must not be the CardDAV principal URL! */
|
||||
if (homeSets.isEmpty())
|
||||
try {
|
||||
log.info("No home sets found, looking for <current-user-principal>");
|
||||
|
||||
davBase.options();
|
||||
if ((service == Service.CALDAV && davBase.capabilities.contains("calendar-access")) ||
|
||||
(service == Service.CARDDAV && davBase.capabilities.contains("addressbook"))) {
|
||||
CurrentUserPrincipal currentUserPrincipal = (CurrentUserPrincipal)davBase.properties.get(CurrentUserPrincipal.NAME);
|
||||
if (currentUserPrincipal != null && currentUserPrincipal.href != null)
|
||||
principalUrl = davBase.location.resolve(currentUserPrincipal.href);
|
||||
}
|
||||
} catch(IOException|HttpException|DavException e) {
|
||||
log.debug("Couldn't find <current-user-principal> at user-given URL", e);
|
||||
}
|
||||
|
||||
if (principalUrl == null)
|
||||
try {
|
||||
log.info("User-given URL doesn't contain <current-user-principal>, trying /.well-known/" + service.name);
|
||||
principalUrl = getCurrentUserPrincipal(userURL.resolve("/.well-known/" + service.name));
|
||||
} catch(IOException|HttpException|DavException e) {
|
||||
log.debug("Couldn't determine <current-user-principal> from well-known " + service + " path", e);
|
||||
}
|
||||
|
||||
if (principalUrl == null)
|
||||
// still no principal URL, try service discovery with "domain" = user-given host name
|
||||
domain = baseURI.getHost();
|
||||
|
||||
} else if ("mailto".equals(baseURI.getScheme())) {
|
||||
String mailbox = baseURI.getSchemeSpecificPart();
|
||||
|
||||
// determine service FQDN
|
||||
int posAt = mailbox.lastIndexOf("@");
|
||||
if (posAt == -1)
|
||||
throw new URISyntaxException(mailbox, "Missing @ sign");
|
||||
|
||||
domain = mailbox.substring(posAt + 1);
|
||||
}
|
||||
|
||||
if (principalUrl == null && domain != null) {
|
||||
log.info("No principal URL yet, trying SRV/TXT records with domain " + domain);
|
||||
try {
|
||||
principalUrl = discoverPrincipalUrl(domain, service);
|
||||
} catch (IOException|HttpException|DavException e) {
|
||||
log.info("Couldn't find principal URL using service discovery");
|
||||
}
|
||||
}
|
||||
|
||||
// principal URL has been found, get addressbook-home-set/calendar-home-set
|
||||
if (principalUrl != null) {
|
||||
log.info("Principal URL=" + principalUrl + ", getting <calendar-home-set>");
|
||||
try {
|
||||
DavResource principal = new DavResource(log, httpClient, principalUrl);
|
||||
|
||||
if (service == Service.CALDAV) {
|
||||
principal.propfind(0, CalendarHomeSet.NAME);
|
||||
CalendarHomeSet calendarHomeSet = (CalendarHomeSet) principal.properties.get(CalendarHomeSet.NAME);
|
||||
if (calendarHomeSet != null) {
|
||||
log.info("Found <calendar-home-set> at principal URL");
|
||||
for (String href : calendarHomeSet.hrefs) {
|
||||
HttpUrl url = principal.location.resolve(href);
|
||||
if (url != null)
|
||||
homeSets.add(url);
|
||||
}
|
||||
}
|
||||
} else if (service == Service.CARDDAV) {
|
||||
principal.propfind(0, AddressbookHomeSet.NAME);
|
||||
AddressbookHomeSet addressbookHomeSet = (AddressbookHomeSet) principal.properties.get(AddressbookHomeSet.NAME);
|
||||
if (addressbookHomeSet != null) {
|
||||
log.info("Found <addressbook-home-set> at principal URL");
|
||||
for (String href : addressbookHomeSet.hrefs) {
|
||||
HttpUrl url = principal.location.resolve(href);
|
||||
if (url != null)
|
||||
homeSets.add(url);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
} catch (IOException|HttpException|DavException e) {
|
||||
log.debug("PROPFIND on " + principalUrl + " failed", e);
|
||||
}
|
||||
}
|
||||
|
||||
// now query all home sets
|
||||
for (HttpUrl url : homeSets)
|
||||
if (service == Service.CALDAV)
|
||||
try {
|
||||
log.info("Listing calendar collections in home set " + url);
|
||||
DavResource homeSet = new DavResource(log, httpClient, url);
|
||||
homeSet.propfind(1, SupportedCalendarComponentSet.NAME, ResourceType.NAME, DisplayName.NAME, CurrentUserPrivilegeSet.NAME,
|
||||
CalendarColor.NAME, CalendarDescription.NAME, CalendarTimezone.NAME);
|
||||
|
||||
// home set should not be a calendar, but some servers have only one calendar and it's the home set
|
||||
addIfCalendar(homeSet);
|
||||
|
||||
// members of the home set can be calendars, too
|
||||
for (DavResource member : homeSet.members)
|
||||
addIfCalendar(member);
|
||||
} catch (IOException | HttpException | DavException e) {
|
||||
log.debug("PROPFIND on " + url + " failed", e);
|
||||
}
|
||||
else if (service == Service.CARDDAV)
|
||||
try {
|
||||
log.info("Listing address books in home set " + url);
|
||||
DavResource homeSet = new DavResource(log, httpClient, url);
|
||||
homeSet.propfind(1, ResourceType.NAME, DisplayName.NAME, CurrentUserPrivilegeSet.NAME, AddressbookDescription.NAME);
|
||||
|
||||
// home set should not be an address book, but some servers have only one address book and it's the home set
|
||||
addIfAddressBook(homeSet);
|
||||
|
||||
// members of the home set can be calendars, too
|
||||
for (DavResource member : homeSet.members)
|
||||
addIfAddressBook(member);
|
||||
} catch (IOException | HttpException | DavException e) {
|
||||
log.debug("PROPFIND on " + url + " failed", e);
|
||||
}
|
||||
|
||||
if (service == Service.CALDAV) {
|
||||
serverInfo.setCalendars(calendars.values().toArray(new ServerInfo.ResourceInfo[calendars.size()]));
|
||||
serverInfo.setTaskLists(taskLists.values().toArray(new ServerInfo.ResourceInfo[taskLists.size()]));
|
||||
} else if (service == Service.CARDDAV)
|
||||
serverInfo.setAddressBooks(addressbooks.values().toArray(new ServerInfo.ResourceInfo[addressbooks.size()]));
|
||||
}
|
||||
|
||||
/**
|
||||
* If the given DavResource is a #{@link ResourceType#ADDRESSBOOK}, add it to #{@link #addressbooks}.
|
||||
* @param dav DavResource to check
|
||||
*/
|
||||
protected void addIfAddressBook(@NonNull DavResource dav) {
|
||||
ResourceType resourceType = (ResourceType)dav.properties.get(ResourceType.NAME);
|
||||
if (resourceType != null && resourceType.types.contains(ResourceType.ADDRESSBOOK)) {
|
||||
dav.location = UrlUtils.withTrailingSlash(dav.location);
|
||||
log.info("Found address book at " + dav.location);
|
||||
|
||||
addressbooks.put(dav.location, resourceInfo(dav, ServerInfo.ResourceInfo.Type.ADDRESS_BOOK));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* If the given DavResource is a #{@link ResourceType#CALENDAR}:
|
||||
* <ul>
|
||||
* <li>add it to #{@link #calendars} if it supports VEVENT</li>
|
||||
* <li>add it to #{@link #taskLists} if it supports VTODO</li>
|
||||
* </ul>
|
||||
* @param dav DavResource to check
|
||||
*/
|
||||
protected void addIfCalendar(@NonNull DavResource dav) {
|
||||
ResourceType resourceType = (ResourceType)dav.properties.get(ResourceType.NAME);
|
||||
if (resourceType != null && resourceType.types.contains(ResourceType.CALENDAR)) {
|
||||
dav.location = UrlUtils.withTrailingSlash(dav.location);
|
||||
log.info("Found calendar collection at " + dav.location);
|
||||
|
||||
boolean supportsEvents = true, supportsTasks = true;
|
||||
SupportedCalendarComponentSet supportedCalendarComponentSet = (SupportedCalendarComponentSet)dav.properties.get(SupportedCalendarComponentSet.NAME);
|
||||
if (supportedCalendarComponentSet != null) {
|
||||
supportsEvents = supportedCalendarComponentSet.supportsEvents;
|
||||
supportsTasks = supportedCalendarComponentSet.supportsTasks;
|
||||
}
|
||||
if (supportsEvents)
|
||||
calendars.put(dav.location, resourceInfo(dav, ServerInfo.ResourceInfo.Type.CALENDAR));
|
||||
if (supportsTasks)
|
||||
taskLists.put(dav.location, resourceInfo(dav, ServerInfo.ResourceInfo.Type.CALENDAR));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds a #{@link at.bitfire.davdroid.resource.ServerInfo.ResourceInfo} from a given
|
||||
* #{@link DavResource}. Uses these DAV properties:
|
||||
* <ul>
|
||||
* <li>calendars: current-user-properties, current-user-privilege-set, displayname, calendar-description, calendar-color</li>
|
||||
* <li>address books: current-user-properties, current-user-privilege-set, displayname, addressbook-description</li>
|
||||
* </ul>. Make sure you have queried these properties from the DavResource.
|
||||
* @param dav DavResource to take the resource info from
|
||||
* @param type must be ADDRESS_BOOK or CALENDAR
|
||||
* @return ResourceInfo which represents the DavResource
|
||||
*/
|
||||
protected ServerInfo.ResourceInfo resourceInfo(DavResource dav, ServerInfo.ResourceInfo.Type type) {
|
||||
boolean readOnly = false;
|
||||
CurrentUserPrivilegeSet privilegeSet = (CurrentUserPrivilegeSet)dav.properties.get(CurrentUserPrivilegeSet.NAME);
|
||||
if (privilegeSet != null)
|
||||
readOnly = !privilegeSet.mayWriteContent;
|
||||
|
||||
String title = null;
|
||||
DisplayName displayName = (DisplayName)dav.properties.get(DisplayName.NAME);
|
||||
if (displayName != null)
|
||||
title = displayName.displayName;
|
||||
if (TextUtils.isEmpty(title))
|
||||
title = UrlUtils.lastSegment(dav.location);
|
||||
|
||||
String description = null;
|
||||
Integer color = null;
|
||||
if (type == ServerInfo.ResourceInfo.Type.ADDRESS_BOOK) {
|
||||
AddressbookDescription addressbookDescription = (AddressbookDescription)dav.properties.get(AddressbookDescription.NAME);
|
||||
if (addressbookDescription != null)
|
||||
description = addressbookDescription.description;
|
||||
} else if (type == ServerInfo.ResourceInfo.Type.CALENDAR) {
|
||||
CalendarDescription calendarDescription = (CalendarDescription)dav.properties.get(CalendarDescription.NAME);
|
||||
if (calendarDescription != null)
|
||||
description = calendarDescription.description;
|
||||
|
||||
CalendarColor calendarColor = (CalendarColor)dav.properties.get(CalendarColor.NAME);
|
||||
if (calendarColor != null)
|
||||
color = calendarColor.color;
|
||||
}
|
||||
|
||||
return new ServerInfo.ResourceInfo(
|
||||
type,
|
||||
readOnly,
|
||||
UrlUtils.withTrailingSlash(dav.location).toString(),
|
||||
title,
|
||||
description,
|
||||
color
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Try to find the principal URL by performing service discovery on a given domain name.
|
||||
* @param domain domain name, e.g. "icloud.com"
|
||||
* @param service service to discover (CALDAV or CARDDAV)
|
||||
* @return principal URL, or null if none found
|
||||
*/
|
||||
protected HttpUrl discoverPrincipalUrl(String domain, Service service) throws IOException, HttpException, DavException {
|
||||
String scheme = null;
|
||||
String fqdn = null;
|
||||
Integer port = null;
|
||||
List<String> paths = new LinkedList<>(); // there may be multiple paths to try
|
||||
|
||||
final String query = "_" + service.name + "s._tcp." + domain;
|
||||
log.debug("Looking up SRV records for " + query);
|
||||
Record[] records = new Lookup(query, Type.SRV).run();
|
||||
if (records != null && records.length >= 1) {
|
||||
// choose SRV record to use (query may return multiple SRV records)
|
||||
SRVRecord srv = selectSRVRecord(records);
|
||||
|
||||
scheme = "https";
|
||||
fqdn = srv.getTarget().toString(true);
|
||||
port = srv.getPort();
|
||||
log.info("Found " + service + " service: fqdn=" + fqdn + ", port=" + port);
|
||||
|
||||
// look for TXT record too (for initial context path)
|
||||
records = new Lookup(query, Type.TXT).run();
|
||||
if (records != null)
|
||||
for (Record record : records)
|
||||
if (record instanceof TXTRecord)
|
||||
for (String segment : (List<String>) ((TXTRecord) record).getStrings())
|
||||
if (segment.startsWith("path=")) {
|
||||
paths.add(segment.substring(5));
|
||||
log.info("Found TXT record; initial context path=" + paths);
|
||||
break;
|
||||
}
|
||||
|
||||
// if there's TXT record and if it it's wrong, try well-known
|
||||
paths.add("/.well-known/" + service.name);
|
||||
// if this fails, too, try "/"
|
||||
paths.add("/");
|
||||
}
|
||||
|
||||
for (String path : paths) {
|
||||
if (!TextUtils.isEmpty(scheme) && !TextUtils.isEmpty(fqdn) && port != null && paths != null) {
|
||||
HttpUrl initialContextPath = new HttpUrl.Builder()
|
||||
.scheme(scheme)
|
||||
.host(fqdn).port(port)
|
||||
.encodedPath(path)
|
||||
.build();
|
||||
|
||||
log.info("Trying to determine principal from initial context path=" + initialContextPath);
|
||||
HttpUrl principal = getCurrentUserPrincipal(initialContextPath);
|
||||
if (principal != null)
|
||||
return principal;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Queries a given URL for current-user-principal
|
||||
* @param url URL to query with PROPFIND (Depth: 0)
|
||||
* @return current-user-principal URL, or null if none
|
||||
*/
|
||||
protected HttpUrl getCurrentUserPrincipal(HttpUrl url) throws IOException, HttpException, DavException {
|
||||
DavResource dav = new DavResource(log, httpClient, url);
|
||||
dav.propfind(0, CurrentUserPrincipal.NAME);
|
||||
CurrentUserPrincipal currentUserPrincipal = (CurrentUserPrincipal) dav.properties.get(CurrentUserPrincipal.NAME);
|
||||
if (currentUserPrincipal != null && currentUserPrincipal.href != null) {
|
||||
HttpUrl principal = url.resolve(currentUserPrincipal.href);
|
||||
if (principal != null) {
|
||||
log.info("Found current-user-principal: " + principal);
|
||||
return principal;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
// helpers
|
||||
|
||||
private SRVRecord selectSRVRecord(Record[] records) {
|
||||
if (records.length > 1)
|
||||
log.warn("Multiple SRV records not supported yet; using first one");
|
||||
return (SRVRecord)records[0];
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,207 +0,0 @@
|
||||
/*
|
||||
* Copyright © 2013 – 2015 Ricki Hirner (bitfire web engineering).
|
||||
* All rights reserved. This program and the accompanying materials
|
||||
* are made available under the terms of the GNU Public License v3.0
|
||||
* which accompanies this distribution, and is available at
|
||||
* http://www.gnu.org/licenses/gpl.html
|
||||
*/
|
||||
package at.bitfire.davdroid.resource;
|
||||
|
||||
import android.accounts.Account;
|
||||
import android.content.ContentProviderClient;
|
||||
import android.content.ContentUris;
|
||||
import android.content.ContentValues;
|
||||
import android.database.Cursor;
|
||||
import android.os.Bundle;
|
||||
import android.os.Parcel;
|
||||
import android.os.RemoteException;
|
||||
import android.provider.ContactsContract;
|
||||
import android.provider.ContactsContract.CommonDataKinds.GroupMembership;
|
||||
import android.provider.ContactsContract.Data;
|
||||
import android.provider.ContactsContract.Groups;
|
||||
import android.provider.ContactsContract.RawContacts;
|
||||
|
||||
import java.util.LinkedList;
|
||||
import java.util.List;
|
||||
|
||||
import at.bitfire.davdroid.Constants;
|
||||
import at.bitfire.vcard4android.AndroidAddressBook;
|
||||
import at.bitfire.vcard4android.AndroidContact;
|
||||
import at.bitfire.vcard4android.AndroidGroupFactory;
|
||||
import at.bitfire.vcard4android.ContactsStorageException;
|
||||
import lombok.Cleanup;
|
||||
|
||||
|
||||
public class LocalAddressBook extends AndroidAddressBook implements LocalCollection {
|
||||
|
||||
protected static final String
|
||||
SYNC_STATE_CTAG = "ctag",
|
||||
SYNC_STATE_URL = "url";
|
||||
|
||||
private Bundle syncState = new Bundle();
|
||||
|
||||
|
||||
public LocalAddressBook(Account account, ContentProviderClient provider) {
|
||||
super(account, provider, AndroidGroupFactory.INSTANCE, LocalContact.Factory.INSTANCE);
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Returns an array of local contacts, excluding those which have been modified locally (and not uploaded yet).
|
||||
*/
|
||||
@Override
|
||||
public LocalContact[] getAll() throws ContactsStorageException {
|
||||
return (LocalContact[])queryContacts(null, null);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns an array of local contacts which have been deleted locally. (DELETED != 0).
|
||||
*/
|
||||
@Override
|
||||
public LocalContact[] getDeleted() throws ContactsStorageException {
|
||||
return (LocalContact[])queryContacts(RawContacts.DELETED + "!=0", null);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns an array of local contacts which have been changed locally (DIRTY != 0).
|
||||
*/
|
||||
@Override
|
||||
public LocalContact[] getDirty() throws ContactsStorageException {
|
||||
return (LocalContact[])queryContacts(RawContacts.DIRTY + "!=0", null);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns an array of local contacts which don't have a file name yet.
|
||||
*/
|
||||
@Override
|
||||
public LocalContact[] getWithoutFileName() throws ContactsStorageException {
|
||||
return (LocalContact[])queryContacts(AndroidContact.COLUMN_FILENAME + " IS NULL", null);
|
||||
}
|
||||
|
||||
|
||||
// GROUPS
|
||||
|
||||
/**
|
||||
* Finds the first group with the given title.
|
||||
* @param displayName title of the group to look for
|
||||
* @return group with given title, or null if none
|
||||
*/
|
||||
@SuppressWarnings("Recycle")
|
||||
public LocalGroup findGroupByTitle(String displayName) throws ContactsStorageException {
|
||||
try {
|
||||
@Cleanup Cursor cursor = provider.query(syncAdapterURI(Groups.CONTENT_URI),
|
||||
new String[] { Groups._ID },
|
||||
ContactsContract.Groups.TITLE + "=?", new String[] { displayName }, null);
|
||||
if (cursor != null && cursor.moveToNext())
|
||||
return new LocalGroup(this, cursor.getLong(0));
|
||||
} catch (RemoteException e) {
|
||||
throw new ContactsStorageException("Couldn't find local contact group", e);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
@SuppressWarnings("Recycle")
|
||||
public LocalGroup[] getDeletedGroups() throws ContactsStorageException {
|
||||
List<LocalGroup> groups = new LinkedList<>();
|
||||
try {
|
||||
@Cleanup Cursor cursor = provider.query(syncAdapterURI(Groups.CONTENT_URI),
|
||||
new String[] { Groups._ID },
|
||||
Groups.DELETED + "!=0", null, null);
|
||||
while (cursor != null && cursor.moveToNext())
|
||||
groups.add(new LocalGroup(this, cursor.getLong(0)));
|
||||
} catch (RemoteException e) {
|
||||
throw new ContactsStorageException("Couldn't query deleted groups", e);
|
||||
}
|
||||
return groups.toArray(new LocalGroup[groups.size()]);
|
||||
}
|
||||
|
||||
@SuppressWarnings("Recycle")
|
||||
public LocalGroup[] getDirtyGroups() throws ContactsStorageException {
|
||||
List<LocalGroup> groups = new LinkedList<>();
|
||||
try {
|
||||
@Cleanup Cursor cursor = provider.query(syncAdapterURI(Groups.CONTENT_URI),
|
||||
new String[] { Groups._ID },
|
||||
Groups.DIRTY + "!=0", null, null);
|
||||
while (cursor != null && cursor.moveToNext())
|
||||
groups.add(new LocalGroup(this, cursor.getLong(0)));
|
||||
} catch (RemoteException e) {
|
||||
throw new ContactsStorageException("Couldn't query dirty groups", e);
|
||||
}
|
||||
return groups.toArray(new LocalGroup[groups.size()]);
|
||||
}
|
||||
|
||||
@SuppressWarnings("Recycle")
|
||||
public void markMembersDirty(long groupId) throws ContactsStorageException {
|
||||
ContentValues dirty = new ContentValues(1);
|
||||
dirty.put(RawContacts.DIRTY, 1);
|
||||
try {
|
||||
// query all GroupMemberships of this groupId, mark every corresponding raw contact as DIRTY
|
||||
@Cleanup Cursor cursor = provider.query(syncAdapterURI(Data.CONTENT_URI),
|
||||
new String[] { GroupMembership.RAW_CONTACT_ID },
|
||||
Data.MIMETYPE + "=? AND " + GroupMembership.GROUP_ROW_ID + "=?",
|
||||
new String[] { GroupMembership.CONTENT_ITEM_TYPE, String.valueOf(groupId) }, null);
|
||||
while (cursor != null && cursor.moveToNext()) {
|
||||
long id = cursor.getLong(0);
|
||||
Constants.log.debug("Marking raw contact #" + id + " as dirty");
|
||||
provider.update(syncAdapterURI(ContentUris.withAppendedId(RawContacts.CONTENT_URI, id)), dirty, null, null);
|
||||
}
|
||||
} catch (RemoteException e) {
|
||||
throw new ContactsStorageException("Couldn't query dirty groups", e);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// SYNC STATE
|
||||
|
||||
@SuppressWarnings("Recycle")
|
||||
protected void readSyncState() throws ContactsStorageException {
|
||||
@Cleanup("recycle") Parcel parcel = Parcel.obtain();
|
||||
byte[] raw = getSyncState();
|
||||
if (raw != null) {
|
||||
parcel.unmarshall(raw, 0, raw.length);
|
||||
parcel.setDataPosition(0);
|
||||
syncState = parcel.readBundle();
|
||||
} else
|
||||
syncState.clear();
|
||||
}
|
||||
|
||||
@SuppressWarnings("Recycle")
|
||||
protected void writeSyncState() throws ContactsStorageException {
|
||||
@Cleanup("recycle") Parcel parcel = Parcel.obtain();
|
||||
parcel.writeBundle(syncState);
|
||||
setSyncState(parcel.marshall());
|
||||
}
|
||||
|
||||
public String getURL() throws ContactsStorageException {
|
||||
synchronized (syncState) {
|
||||
readSyncState();
|
||||
return syncState.getString(SYNC_STATE_URL);
|
||||
}
|
||||
}
|
||||
|
||||
public void setURL(String url) throws ContactsStorageException {
|
||||
synchronized (syncState) {
|
||||
readSyncState();
|
||||
syncState.putString(SYNC_STATE_URL, url);
|
||||
writeSyncState();
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getCTag() throws ContactsStorageException {
|
||||
synchronized (syncState) {
|
||||
readSyncState();
|
||||
return syncState.getString(SYNC_STATE_CTAG);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void setCTag(String cTag) throws ContactsStorageException {
|
||||
synchronized (syncState) {
|
||||
readSyncState();
|
||||
syncState.putString(SYNC_STATE_CTAG, cTag);
|
||||
writeSyncState();
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,357 @@
|
||||
/*
|
||||
* Copyright © Ricki Hirner (bitfire web engineering).
|
||||
* All rights reserved. This program and the accompanying materials
|
||||
* are made available under the terms of the GNU Public License v3.0
|
||||
* which accompanies this distribution, and is available at
|
||||
* http://www.gnu.org/licenses/gpl.html
|
||||
*/
|
||||
package at.bitfire.davdroid.resource
|
||||
|
||||
import android.accounts.Account
|
||||
import android.accounts.AccountManager
|
||||
import android.content.*
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import android.os.RemoteException
|
||||
import android.provider.ContactsContract
|
||||
import android.provider.ContactsContract.CommonDataKinds.GroupMembership
|
||||
import android.provider.ContactsContract.Groups
|
||||
import android.provider.ContactsContract.RawContacts
|
||||
import android.util.Base64
|
||||
import at.bitfire.davdroid.App
|
||||
import at.bitfire.davdroid.DavUtils
|
||||
import at.bitfire.davdroid.log.Logger
|
||||
import at.bitfire.davdroid.model.CollectionInfo
|
||||
import at.bitfire.vcard4android.*
|
||||
import java.io.ByteArrayOutputStream
|
||||
import java.io.FileNotFoundException
|
||||
import java.util.*
|
||||
import java.util.logging.Level
|
||||
|
||||
class LocalAddressBook(
|
||||
private val context: Context,
|
||||
account: Account,
|
||||
provider: ContentProviderClient?
|
||||
): AndroidAddressBook<LocalContact, LocalGroup>(account, provider, LocalContact.Factory, LocalGroup.Factory), LocalCollection<LocalResource> {
|
||||
|
||||
companion object {
|
||||
|
||||
val USER_DATA_MAIN_ACCOUNT_TYPE = "real_account_type"
|
||||
val USER_DATA_MAIN_ACCOUNT_NAME = "real_account_name"
|
||||
val USER_DATA_URL = "url"
|
||||
val USER_DATA_CTAG = "ctag"
|
||||
|
||||
@JvmStatic
|
||||
@Throws(ContactsStorageException::class)
|
||||
fun create(context: Context, provider: ContentProviderClient, mainAccount: Account, info: CollectionInfo): LocalAddressBook {
|
||||
val accountManager = AccountManager.get(context)
|
||||
|
||||
val account = Account(accountName(mainAccount, info), App.addressBookAccountType)
|
||||
if (!accountManager.addAccountExplicitly(account, null, initialUserData(mainAccount, info.url)))
|
||||
throw ContactsStorageException("Couldn't create address book account")
|
||||
|
||||
val addressBook = LocalAddressBook(context, account, provider)
|
||||
ContentResolver.setSyncAutomatically(account, ContactsContract.AUTHORITY, true)
|
||||
return addressBook
|
||||
}
|
||||
|
||||
@JvmStatic
|
||||
@Throws(ContactsStorageException::class)
|
||||
fun find(context: Context, provider: ContentProviderClient, mainAccount: Account?): List<LocalAddressBook> {
|
||||
val accountManager = AccountManager.get(context)
|
||||
|
||||
val result = LinkedList<LocalAddressBook>()
|
||||
accountManager.getAccountsByType(App.addressBookAccountType)
|
||||
.map { LocalAddressBook(context, it, provider) }
|
||||
.filter { mainAccount == null || it.getMainAccount() == mainAccount }
|
||||
.forEach { result += it }
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
@JvmStatic
|
||||
fun accountName(mainAccount: Account, info: CollectionInfo): String {
|
||||
val baos = ByteArrayOutputStream()
|
||||
baos.write(info.url.hashCode())
|
||||
val hash = Base64.encodeToString(baos.toByteArray(), Base64.NO_WRAP or Base64.NO_PADDING)
|
||||
|
||||
val sb = StringBuilder(if (info.displayName.isNullOrEmpty()) DavUtils.lastSegmentOfUrl(info.url) else info.displayName)
|
||||
sb .append(" (")
|
||||
.append(mainAccount.name)
|
||||
.append(" ")
|
||||
.append(hash)
|
||||
.append(")")
|
||||
return sb.toString()
|
||||
}
|
||||
|
||||
@JvmStatic
|
||||
fun initialUserData(mainAccount: Account, url: String): Bundle {
|
||||
val bundle = Bundle(3)
|
||||
bundle.putString(USER_DATA_MAIN_ACCOUNT_NAME, mainAccount.name)
|
||||
bundle.putString(USER_DATA_MAIN_ACCOUNT_TYPE, mainAccount.type)
|
||||
bundle.putString(USER_DATA_URL, url)
|
||||
return bundle
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether contact groups (LocalGroup resources) are included in query results for
|
||||
* {@link #getAll()}, {@link #getDeleted()}, {@link #getDirty()} and
|
||||
* {@link #getWithoutFileName()}.
|
||||
*/
|
||||
var includeGroups = true
|
||||
|
||||
|
||||
/* operations on the collection (address book) itself */
|
||||
|
||||
@Throws(ContactsStorageException::class)
|
||||
fun update(info: CollectionInfo) {
|
||||
val newAccountName = accountName(getMainAccount(), info)
|
||||
if (account.name != newAccountName && Build.VERSION.SDK_INT >= 21) {
|
||||
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)
|
||||
account = future.result
|
||||
}
|
||||
|
||||
// make sure it will still be synchronized when contacts are updated
|
||||
ContentResolver.setSyncAutomatically(account, ContactsContract.AUTHORITY, true)
|
||||
}
|
||||
|
||||
@Throws(ContactsStorageException::class)
|
||||
fun delete() {
|
||||
val accountManager = AccountManager.get(context)
|
||||
try {
|
||||
if (Build.VERSION.SDK_INT >= 22)
|
||||
accountManager.removeAccount(account, null, null, null)
|
||||
else
|
||||
@Suppress("deprecation")
|
||||
accountManager.removeAccount(account, null, null)
|
||||
} catch(e: Exception) {
|
||||
throw ContactsStorageException("Couldn't remove address book", e)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/* operations on members (contacts/groups) */
|
||||
|
||||
@Throws(ContactsStorageException::class, FileNotFoundException::class)
|
||||
fun findContactByUID(uid: String): LocalContact {
|
||||
val contacts = queryContacts("${AndroidContact.COLUMN_UID}=?", arrayOf(uid))
|
||||
if (contacts.isEmpty())
|
||||
throw FileNotFoundException()
|
||||
return contacts.first()
|
||||
}
|
||||
|
||||
@Throws(ContactsStorageException::class)
|
||||
override fun getAll(): List<LocalResource> {
|
||||
val all = LinkedList<LocalResource>()
|
||||
all.addAll(queryContacts(null, null))
|
||||
if (includeGroups)
|
||||
all.addAll(queryGroups(null, null))
|
||||
return all
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns an array of local contacts/groups which have been deleted locally. (DELETED != 0).
|
||||
*/
|
||||
@Throws(ContactsStorageException::class)
|
||||
override fun getDeleted(): List<LocalResource> {
|
||||
val deleted = LinkedList<LocalResource>()
|
||||
deleted.addAll(getDeletedContacts())
|
||||
if (includeGroups)
|
||||
deleted.addAll(getDeletedGroups())
|
||||
return deleted
|
||||
}
|
||||
|
||||
/**
|
||||
* Queries all contacts with DIRTY flag and checks whether their data checksum has changed, i.e.
|
||||
* if they're "really dirty" (= data has changed, not only metadata, which is not hashed).
|
||||
* The DIRTY flag is removed from contacts which are not "really dirty", i.e. from contacts
|
||||
* whose contact data checksum has not changed.
|
||||
* @return number of "really dirty" contacts
|
||||
*/
|
||||
@Throws(ContactsStorageException::class)
|
||||
fun verifyDirty(): Int {
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.N || Build.VERSION.SDK_INT >= Build.VERSION_CODES.O)
|
||||
throw IllegalStateException("verifyDirty() should not be called on Android != 7")
|
||||
|
||||
var reallyDirty = 0
|
||||
for (contact in getDirtyContacts())
|
||||
try {
|
||||
val lastHash = contact.getLastHashCode()
|
||||
val currentHash = contact.dataHashCode()
|
||||
if (lastHash == currentHash) {
|
||||
// hash is code still the same, contact is not "really dirty" (only metadata been have changed)
|
||||
Logger.log.log(Level.FINE, "Contact data hash has not changed, resetting dirty flag", contact)
|
||||
contact.resetDirty()
|
||||
} else {
|
||||
Logger.log.log(Level.FINE, "Contact data has changed from hash $lastHash to $currentHash", contact)
|
||||
reallyDirty++
|
||||
}
|
||||
} catch(e: FileNotFoundException) {
|
||||
throw ContactsStorageException("Couldn't calculate hash code", e)
|
||||
}
|
||||
|
||||
if (includeGroups)
|
||||
reallyDirty += getDirtyGroups().size
|
||||
|
||||
return reallyDirty
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns an array of local contacts/groups which have been changed locally (DIRTY != 0).
|
||||
*/
|
||||
@Throws(ContactsStorageException::class)
|
||||
override fun getDirty(): List<LocalResource> {
|
||||
val dirty = LinkedList<LocalResource>()
|
||||
dirty.addAll(getDirtyContacts())
|
||||
if (includeGroups)
|
||||
dirty.addAll(getDirtyGroups())
|
||||
return dirty
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns an array of local contacts which don't have a file name yet.
|
||||
*/
|
||||
@Throws(ContactsStorageException::class)
|
||||
override fun getWithoutFileName(): List<LocalResource> {
|
||||
val nameless = LinkedList<LocalResource>()
|
||||
nameless.addAll(queryContacts("${AndroidContact.COLUMN_FILENAME} IS NULL", null))
|
||||
if (includeGroups)
|
||||
nameless.addAll(queryGroups("${AndroidGroup.COLUMN_FILENAME} IS NULL", null))
|
||||
return nameless
|
||||
}
|
||||
|
||||
@Throws(ContactsStorageException::class)
|
||||
fun getDeletedContacts() = queryContacts("${RawContacts.DELETED} != 0", null)
|
||||
|
||||
@Throws(ContactsStorageException::class)
|
||||
fun getDirtyContacts() = queryContacts("${RawContacts.DIRTY} != 0", null)
|
||||
|
||||
@Throws(ContactsStorageException::class)
|
||||
fun getDeletedGroups() = queryGroups("${Groups.DELETED} != 0", null)
|
||||
|
||||
@Throws(ContactsStorageException::class)
|
||||
fun getDirtyGroups() = queryGroups("${Groups.DIRTY} != 0", null)
|
||||
|
||||
@Throws(ContactsStorageException::class)
|
||||
fun getByGroupMembership(groupID: Long): List<LocalContact> {
|
||||
try {
|
||||
val ids = HashSet<Long>()
|
||||
|
||||
provider!!.query(syncAdapterURI(ContactsContract.Data.CONTENT_URI),
|
||||
arrayOf(RawContacts.Data.RAW_CONTACT_ID),
|
||||
"(${GroupMembership.MIMETYPE}=? AND ${GroupMembership.GROUP_ROW_ID}=?) OR (${CachedGroupMembership.MIMETYPE}=? AND ${CachedGroupMembership.GROUP_ID}=?)",
|
||||
arrayOf(GroupMembership.CONTENT_ITEM_TYPE, groupID.toString(), CachedGroupMembership.CONTENT_ITEM_TYPE, groupID.toString()),
|
||||
null)?.use { cursor ->
|
||||
while (cursor.moveToNext())
|
||||
ids += cursor.getLong(0)
|
||||
}
|
||||
|
||||
return ids.map { id -> LocalContact(this, id, null, null) }
|
||||
} catch (e: RemoteException) {
|
||||
throw ContactsStorageException("Couldn't query contacts", e)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/* special group operations */
|
||||
|
||||
/**
|
||||
* Finds the first group with the given title. If there is no group with this
|
||||
* title, a new group is created.
|
||||
* @param title title of the group to look for
|
||||
* @return id of the group with given title
|
||||
* @throws ContactsStorageException on contact provider errors
|
||||
*/
|
||||
@Throws(ContactsStorageException::class)
|
||||
fun findOrCreateGroup(title: String): Long {
|
||||
try {
|
||||
provider!!.query(syncAdapterURI(Groups.CONTENT_URI), arrayOf(Groups._ID),
|
||||
"${Groups.TITLE}=?", arrayOf(title), null)?.use { cursor ->
|
||||
if (cursor.moveToNext())
|
||||
return cursor.getLong(0)
|
||||
}
|
||||
|
||||
val values = ContentValues(1)
|
||||
values.put(Groups.TITLE, title)
|
||||
val uri = provider.insert(syncAdapterURI(Groups.CONTENT_URI), values)
|
||||
return ContentUris.parseId(uri)
|
||||
} catch(e: RemoteException) {
|
||||
throw ContactsStorageException("Couldn't find local contact group", e)
|
||||
}
|
||||
}
|
||||
|
||||
@Throws(ContactsStorageException::class)
|
||||
fun removeEmptyGroups() {
|
||||
// find groups without members
|
||||
/** should be done using {@link Groups.SUMMARY_COUNT}, but it's not implemented in Android yet */
|
||||
queryGroups(null, null).filter { it.getMembers().isEmpty() }.forEach { group ->
|
||||
Logger.log.log(Level.FINE, "Deleting group", group)
|
||||
group.delete()
|
||||
}
|
||||
}
|
||||
|
||||
@Throws(ContactsStorageException::class)
|
||||
fun removeGroups() {
|
||||
try {
|
||||
provider!!.delete(syncAdapterURI(Groups.CONTENT_URI), null, null)
|
||||
} catch(e: RemoteException) {
|
||||
throw ContactsStorageException("Couldn't remove all groups", e)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// SETTINGS
|
||||
|
||||
@Throws(ContactsStorageException::class)
|
||||
fun getMainAccount(): Account {
|
||||
val accountManager = AccountManager.get(context)
|
||||
val name = accountManager.getUserData(account, USER_DATA_MAIN_ACCOUNT_NAME)
|
||||
val type = accountManager.getUserData(account, USER_DATA_MAIN_ACCOUNT_TYPE)
|
||||
if (name != null && type != null)
|
||||
return Account(name, type)
|
||||
else
|
||||
throw ContactsStorageException("Address book doesn't exist anymore")
|
||||
}
|
||||
|
||||
fun setMainAccount(mainAccount: Account) {
|
||||
val accountManager = AccountManager.get(context)
|
||||
accountManager.setUserData(account, USER_DATA_MAIN_ACCOUNT_NAME, mainAccount.name)
|
||||
accountManager.setUserData(account, USER_DATA_MAIN_ACCOUNT_TYPE, mainAccount.type)
|
||||
}
|
||||
|
||||
@Throws(ContactsStorageException::class)
|
||||
fun getURL(): String {
|
||||
val accountManager = AccountManager.get(context)
|
||||
return accountManager.getUserData(account, USER_DATA_URL) ?: throw ContactsStorageException("Address book has no URL")
|
||||
}
|
||||
|
||||
fun setURL(url: String) {
|
||||
val accountManager = AccountManager.get(context)
|
||||
accountManager.setUserData(account, USER_DATA_URL, url)
|
||||
}
|
||||
|
||||
override fun getCTag(): String? {
|
||||
val accountManager = AccountManager.get(context)
|
||||
return accountManager.getUserData(account, USER_DATA_CTAG)
|
||||
}
|
||||
|
||||
override fun setCTag(cTag: String?) {
|
||||
val accountManager = AccountManager.get(context)
|
||||
accountManager.setUserData(account, USER_DATA_CTAG, cTag)
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,251 +0,0 @@
|
||||
/*
|
||||
* Copyright © 2013 – 2015 Ricki Hirner (bitfire web engineering).
|
||||
* All rights reserved. This program and the accompanying materials
|
||||
* are made available under the terms of the GNU Public License v3.0
|
||||
* which accompanies this distribution, and is available at
|
||||
* http://www.gnu.org/licenses/gpl.html
|
||||
*/
|
||||
|
||||
package at.bitfire.davdroid.resource;
|
||||
|
||||
import android.accounts.Account;
|
||||
import android.annotation.TargetApi;
|
||||
import android.content.ContentProviderClient;
|
||||
import android.content.ContentProviderOperation;
|
||||
import android.content.ContentResolver;
|
||||
import android.content.ContentUris;
|
||||
import android.content.ContentValues;
|
||||
import android.database.Cursor;
|
||||
import android.net.Uri;
|
||||
import android.os.Build;
|
||||
import android.os.RemoteException;
|
||||
import android.provider.CalendarContract;
|
||||
import android.provider.CalendarContract.Calendars;
|
||||
import android.provider.CalendarContract.Events;
|
||||
import android.provider.CalendarContract.Reminders;
|
||||
import android.text.TextUtils;
|
||||
|
||||
import net.fortuna.ical4j.model.component.VTimeZone;
|
||||
|
||||
import org.apache.commons.lang3.StringUtils;
|
||||
|
||||
import java.io.FileNotFoundException;
|
||||
import java.util.LinkedList;
|
||||
import java.util.List;
|
||||
|
||||
import at.bitfire.davdroid.Constants;
|
||||
import at.bitfire.ical4android.AndroidCalendar;
|
||||
import at.bitfire.ical4android.AndroidCalendarFactory;
|
||||
import at.bitfire.ical4android.BatchOperation;
|
||||
import at.bitfire.ical4android.CalendarStorageException;
|
||||
import at.bitfire.ical4android.DateUtils;
|
||||
import at.bitfire.vcard4android.ContactsStorageException;
|
||||
import lombok.Cleanup;
|
||||
|
||||
public class LocalCalendar extends AndroidCalendar implements LocalCollection {
|
||||
|
||||
public static final int defaultColor = 0xFFC3EA6E; // "DAVdroid green"
|
||||
|
||||
public static final String COLUMN_CTAG = Calendars.CAL_SYNC1;
|
||||
|
||||
protected static final int
|
||||
DIRTY_INCREASE_SEQUENCE = 1,
|
||||
DIRTY_DONT_INCREASE_SEQUENCE = 2;
|
||||
|
||||
static String[] BASE_INFO_COLUMNS = new String[] {
|
||||
Events._ID,
|
||||
Events._SYNC_ID,
|
||||
LocalEvent.COLUMN_ETAG
|
||||
};
|
||||
|
||||
@Override
|
||||
protected String[] eventBaseInfoColumns() {
|
||||
return BASE_INFO_COLUMNS;
|
||||
}
|
||||
|
||||
|
||||
protected LocalCalendar(Account account, ContentProviderClient provider, long id) {
|
||||
super(account, provider, LocalEvent.Factory.INSTANCE, id);
|
||||
}
|
||||
|
||||
@TargetApi(15)
|
||||
public static Uri create(Account account, ContentResolver resolver, ServerInfo.ResourceInfo info) throws CalendarStorageException {
|
||||
@Cleanup("release") ContentProviderClient provider = resolver.acquireContentProviderClient(CalendarContract.AUTHORITY);
|
||||
if (provider == null)
|
||||
throw new CalendarStorageException("Couldn't acquire ContentProviderClient for " + CalendarContract.AUTHORITY);
|
||||
|
||||
ContentValues values = new ContentValues();
|
||||
values.put(Calendars.NAME, info.getUrl());
|
||||
values.put(Calendars.CALENDAR_DISPLAY_NAME, info.getTitle());
|
||||
values.put(Calendars.CALENDAR_COLOR, info.color != null ? info.color : defaultColor);
|
||||
|
||||
if (info.isReadOnly())
|
||||
values.put(Calendars.CALENDAR_ACCESS_LEVEL, Calendars.CAL_ACCESS_READ);
|
||||
else {
|
||||
values.put(Calendars.CALENDAR_ACCESS_LEVEL, Calendars.CAL_ACCESS_OWNER);
|
||||
values.put(Calendars.CAN_MODIFY_TIME_ZONE, 1);
|
||||
values.put(Calendars.CAN_ORGANIZER_RESPOND, 1);
|
||||
}
|
||||
|
||||
values.put(Calendars.OWNER_ACCOUNT, account.name);
|
||||
values.put(Calendars.SYNC_EVENTS, 1);
|
||||
values.put(Calendars.VISIBLE, 1);
|
||||
if (!TextUtils.isEmpty(info.timezone)) {
|
||||
VTimeZone timeZone = DateUtils.parseVTimeZone(info.timezone);
|
||||
if (timeZone != null && timeZone.getTimeZoneId() != null)
|
||||
values.put(Calendars.CALENDAR_TIME_ZONE, DateUtils.findAndroidTimezoneID(timeZone.getTimeZoneId().getValue()));
|
||||
}
|
||||
values.put(Calendars.ALLOWED_REMINDERS, Reminders.METHOD_ALERT);
|
||||
if (Build.VERSION.SDK_INT >= 15) {
|
||||
values.put(Calendars.ALLOWED_AVAILABILITY, StringUtils.join(new int[] { Reminders.AVAILABILITY_TENTATIVE, Reminders.AVAILABILITY_FREE, Reminders.AVAILABILITY_BUSY }, ","));
|
||||
values.put(Calendars.ALLOWED_ATTENDEE_TYPES, StringUtils.join(new int[] { CalendarContract.Attendees.TYPE_OPTIONAL, CalendarContract.Attendees.TYPE_REQUIRED, CalendarContract.Attendees.TYPE_RESOURCE }, ", "));
|
||||
}
|
||||
return create(account, provider, values);
|
||||
}
|
||||
|
||||
|
||||
@Override
|
||||
public LocalResource[] getAll() throws CalendarStorageException, ContactsStorageException {
|
||||
return (LocalEvent[])queryEvents(Events.ORIGINAL_ID + " IS NULL", null);
|
||||
}
|
||||
|
||||
@Override
|
||||
public LocalEvent[] getDeleted() throws CalendarStorageException {
|
||||
return (LocalEvent[])queryEvents(Events.DELETED + "!=0 AND " + Events.ORIGINAL_ID + " IS NULL", null);
|
||||
}
|
||||
|
||||
@Override
|
||||
public LocalEvent[] getWithoutFileName() throws CalendarStorageException {
|
||||
return (LocalEvent[])queryEvents(Events._SYNC_ID + " IS NULL AND " + Events.ORIGINAL_ID + " IS NULL", null);
|
||||
}
|
||||
|
||||
@Override
|
||||
public LocalResource[] getDirty() throws CalendarStorageException, FileNotFoundException {
|
||||
List<LocalResource> dirty = new LinkedList<>();
|
||||
|
||||
// get dirty events which are not required to have an increased SEQUENCE value
|
||||
for (LocalEvent event : (LocalEvent[])queryEvents(Events.DIRTY + "=" + DIRTY_DONT_INCREASE_SEQUENCE + " AND " + Events.ORIGINAL_ID + " IS NULL", null))
|
||||
dirty.add(event);
|
||||
|
||||
// get dirty events which are required to have an increased SEQUENCE value
|
||||
for (LocalEvent event : (LocalEvent[])queryEvents(Events.DIRTY + "=" + DIRTY_INCREASE_SEQUENCE + " AND " + Events.ORIGINAL_ID + " IS NULL", null)) {
|
||||
if (event.getEvent().sequence == null) // sequence has not been assigned yet (i.e. this event was just locally created)
|
||||
event.getEvent().sequence = 0;
|
||||
else
|
||||
event.getEvent().sequence++;
|
||||
dirty.add(event);
|
||||
}
|
||||
|
||||
return dirty.toArray(new LocalResource[dirty.size()]);
|
||||
}
|
||||
|
||||
|
||||
@Override
|
||||
@SuppressWarnings("Recycle")
|
||||
public String getCTag() throws CalendarStorageException {
|
||||
try {
|
||||
@Cleanup Cursor cursor = provider.query(calendarSyncURI(), new String[] { COLUMN_CTAG }, null, null, null);
|
||||
if (cursor != null && cursor.moveToNext())
|
||||
return cursor.getString(0);
|
||||
} catch (RemoteException e) {
|
||||
throw new CalendarStorageException("Couldn't read local (last known) CTag", e);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void setCTag(String cTag) throws CalendarStorageException, ContactsStorageException {
|
||||
try {
|
||||
ContentValues values = new ContentValues(1);
|
||||
values.put(COLUMN_CTAG, cTag);
|
||||
provider.update(calendarSyncURI(), values, null, null);
|
||||
} catch (RemoteException e) {
|
||||
throw new CalendarStorageException("Couldn't write local (last known) CTag", e);
|
||||
}
|
||||
}
|
||||
|
||||
@SuppressWarnings("Recycle")
|
||||
public void processDirtyExceptions() throws CalendarStorageException {
|
||||
// process deleted exceptions
|
||||
Constants.log.info("Processing deleted exceptions");
|
||||
try {
|
||||
@Cleanup Cursor cursor = provider.query(
|
||||
syncAdapterURI(Events.CONTENT_URI),
|
||||
new String[] { Events._ID, Events.ORIGINAL_ID, LocalEvent.COLUMN_SEQUENCE },
|
||||
Events.DELETED + "!=0 AND " + Events.ORIGINAL_ID + " IS NOT NULL", null, null);
|
||||
while (cursor != null && cursor.moveToNext()) {
|
||||
Constants.log.debug("Found deleted exception, removing; then re-schuling original event");
|
||||
long id = cursor.getLong(0), // can't be null (by definition)
|
||||
originalID = cursor.getLong(1); // can't be null (by query)
|
||||
int sequence = cursor.isNull(2) ? 0 : cursor.getInt(2);
|
||||
|
||||
// get original event's SEQUENCE
|
||||
@Cleanup Cursor cursor2 = provider.query(
|
||||
syncAdapterURI(ContentUris.withAppendedId(Events.CONTENT_URI, originalID)),
|
||||
new String[] { LocalEvent.COLUMN_SEQUENCE },
|
||||
null, null, null);
|
||||
int originalSequence = cursor.isNull(0) ? 0 : cursor.getInt(0);
|
||||
|
||||
BatchOperation batch = new BatchOperation(provider);
|
||||
// re-schedule original event and set it to DIRTY
|
||||
batch.enqueue(ContentProviderOperation.newUpdate(
|
||||
syncAdapterURI(ContentUris.withAppendedId(Events.CONTENT_URI, originalID)))
|
||||
.withValue(LocalEvent.COLUMN_SEQUENCE, originalSequence)
|
||||
.withValue(Events.DIRTY, DIRTY_INCREASE_SEQUENCE)
|
||||
.build());
|
||||
// remove exception
|
||||
batch.enqueue(ContentProviderOperation.newDelete(
|
||||
syncAdapterURI(ContentUris.withAppendedId(Events.CONTENT_URI, id))).build());
|
||||
batch.commit();
|
||||
}
|
||||
} catch (RemoteException e) {
|
||||
throw new CalendarStorageException("Couldn't process locally modified exception", e);
|
||||
}
|
||||
|
||||
// process dirty exceptions
|
||||
Constants.log.info("Processing dirty exceptions");
|
||||
try {
|
||||
@Cleanup Cursor cursor = provider.query(
|
||||
syncAdapterURI(Events.CONTENT_URI),
|
||||
new String[] { Events._ID, Events.ORIGINAL_ID, LocalEvent.COLUMN_SEQUENCE },
|
||||
Events.DIRTY + "!=0 AND " + Events.ORIGINAL_ID + " IS NOT NULL", null, null);
|
||||
while (cursor != null && cursor.moveToNext()) {
|
||||
Constants.log.debug("Found dirty exception, increasing SEQUENCE to re-schedule");
|
||||
long id = cursor.getLong(0), // can't be null (by definition)
|
||||
originalID = cursor.getLong(1); // can't be null (by query)
|
||||
int sequence = cursor.isNull(2) ? 0 : cursor.getInt(2);
|
||||
|
||||
BatchOperation batch = new BatchOperation(provider);
|
||||
// original event to DIRTY
|
||||
batch.enqueue(ContentProviderOperation.newUpdate(
|
||||
syncAdapterURI(ContentUris.withAppendedId(Events.CONTENT_URI, originalID)))
|
||||
.withValue(Events.DIRTY, DIRTY_DONT_INCREASE_SEQUENCE)
|
||||
.build());
|
||||
// increase SEQUENCE and set DIRTY to 0
|
||||
batch.enqueue(ContentProviderOperation.newUpdate(
|
||||
syncAdapterURI(ContentUris.withAppendedId(Events.CONTENT_URI, id)))
|
||||
.withValue(LocalEvent.COLUMN_SEQUENCE, sequence + 1)
|
||||
.withValue(Events.DIRTY, 0)
|
||||
.build());
|
||||
batch.commit();
|
||||
}
|
||||
} catch (RemoteException e) {
|
||||
throw new CalendarStorageException("Couldn't process locally modified exception", e);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
public static class Factory implements AndroidCalendarFactory {
|
||||
public static final Factory INSTANCE = new Factory();
|
||||
|
||||
@Override
|
||||
public AndroidCalendar newInstance(Account account, ContentProviderClient provider, long id) {
|
||||
return new LocalCalendar(account, provider, id);
|
||||
}
|
||||
|
||||
@Override
|
||||
public AndroidCalendar[] newArray(int size) {
|
||||
return new LocalCalendar[size];
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,238 @@
|
||||
/*
|
||||
* Copyright © Ricki Hirner (bitfire web engineering).
|
||||
* All rights reserved. This program and the accompanying materials
|
||||
* are made available under the terms of the GNU Public License v3.0
|
||||
* which accompanies this distribution, and is available at
|
||||
* http://www.gnu.org/licenses/gpl.html
|
||||
*/
|
||||
|
||||
package at.bitfire.davdroid.resource
|
||||
|
||||
import android.accounts.Account
|
||||
import android.content.ContentProviderClient
|
||||
import android.content.ContentProviderOperation
|
||||
import android.content.ContentUris
|
||||
import android.content.ContentValues
|
||||
import android.net.Uri
|
||||
import android.os.RemoteException
|
||||
import android.provider.CalendarContract
|
||||
import android.provider.CalendarContract.*
|
||||
import at.bitfire.davdroid.DavUtils
|
||||
import at.bitfire.davdroid.log.Logger
|
||||
import at.bitfire.davdroid.model.CollectionInfo
|
||||
import at.bitfire.ical4android.*
|
||||
import java.io.FileNotFoundException
|
||||
import java.util.*
|
||||
import java.util.logging.Level
|
||||
|
||||
class LocalCalendar private constructor(
|
||||
account: Account,
|
||||
provider: ContentProviderClient,
|
||||
id: Long
|
||||
): AndroidCalendar<LocalEvent>(account, provider, LocalEvent.Factory, id), LocalCollection<LocalEvent> {
|
||||
|
||||
companion object {
|
||||
|
||||
val defaultColor = 0xFF8bc34a.toInt() // light green 500
|
||||
|
||||
val COLUMN_CTAG = Calendars.CAL_SYNC1
|
||||
|
||||
val BASE_INFO_COLUMNS = arrayOf(
|
||||
Events._ID,
|
||||
Events._SYNC_ID,
|
||||
LocalEvent.COLUMN_ETAG
|
||||
)
|
||||
|
||||
|
||||
@JvmStatic
|
||||
@Throws(CalendarStorageException::class)
|
||||
fun create(account: Account, provider: ContentProviderClient, info: CollectionInfo): Uri {
|
||||
val values = valuesFromCollectionInfo(info, true)
|
||||
|
||||
// ACCOUNT_NAME and ACCOUNT_TYPE are required (see docs)! If it's missing, other apps will crash.
|
||||
values.put(Calendars.ACCOUNT_NAME, account.name)
|
||||
values.put(Calendars.ACCOUNT_TYPE, account.type)
|
||||
values.put(Calendars.OWNER_ACCOUNT, account.name)
|
||||
|
||||
// flag as visible & synchronizable at creation, might be changed by user at any time
|
||||
values.put(Calendars.VISIBLE, 1)
|
||||
values.put(Calendars.SYNC_EVENTS, 1)
|
||||
|
||||
return create(account, provider, values)
|
||||
}
|
||||
|
||||
private fun valuesFromCollectionInfo(info: CollectionInfo, withColor: Boolean): ContentValues {
|
||||
val values = ContentValues()
|
||||
values.put(Calendars.NAME, info.url)
|
||||
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 ?: defaultColor)
|
||||
|
||||
if (info.readOnly)
|
||||
values.put(Calendars.CALENDAR_ACCESS_LEVEL, Calendars.CAL_ACCESS_READ)
|
||||
else {
|
||||
values.put(Calendars.CALENDAR_ACCESS_LEVEL, Calendars.CAL_ACCESS_OWNER)
|
||||
values.put(Calendars.CAN_MODIFY_TIME_ZONE, 1)
|
||||
values.put(Calendars.CAN_ORGANIZER_RESPOND, 1)
|
||||
}
|
||||
|
||||
info.timeZone?.let { tzData ->
|
||||
try {
|
||||
val timeZone = DateUtils.parseVTimeZone(tzData)
|
||||
timeZone.timeZoneId?.let { tzId ->
|
||||
values.put(Calendars.CALENDAR_TIME_ZONE, DateUtils.findAndroidTimezoneID(tzId.value))
|
||||
}
|
||||
} catch(e: IllegalArgumentException) {
|
||||
Logger.log.log(Level.WARNING, "Couldn't parse calendar default time zone", e)
|
||||
}
|
||||
}
|
||||
values.put(Calendars.ALLOWED_REMINDERS, Reminders.METHOD_ALERT)
|
||||
values.put(Calendars.ALLOWED_AVAILABILITY, "${Reminders.AVAILABILITY_TENTATIVE},${Reminders.AVAILABILITY_FREE},${Reminders.AVAILABILITY_BUSY}")
|
||||
values.put(Calendars.ALLOWED_ATTENDEE_TYPES, "${CalendarContract.Attendees.TYPE_OPTIONAL},${CalendarContract.Attendees.TYPE_REQUIRED},${CalendarContract.Attendees.TYPE_RESOURCE}")
|
||||
return values
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
override fun eventBaseInfoColumns() = BASE_INFO_COLUMNS
|
||||
|
||||
|
||||
@Throws(CalendarStorageException::class)
|
||||
fun update(info: CollectionInfo, updateColor: Boolean) =
|
||||
update(valuesFromCollectionInfo(info, updateColor))
|
||||
|
||||
@Throws(CalendarStorageException::class)
|
||||
override fun getAll(): List<LocalEvent> =
|
||||
queryEvents("${Events.ORIGINAL_ID} IS NULL", null)
|
||||
|
||||
@Throws(CalendarStorageException::class)
|
||||
override fun getDeleted() =
|
||||
queryEvents("${Events.DELETED}!=0 AND ${Events.ORIGINAL_ID} IS NULL", null)
|
||||
|
||||
@Throws(CalendarStorageException::class)
|
||||
override fun getWithoutFileName() =
|
||||
queryEvents("${Events._SYNC_ID} IS NULL AND ${Events.ORIGINAL_ID} IS NULL", null)
|
||||
|
||||
@Throws(FileNotFoundException::class, CalendarStorageException::class)
|
||||
override fun getDirty(): List<LocalEvent> {
|
||||
val dirty = LinkedList<LocalEvent>()
|
||||
|
||||
// get dirty events which are required to have an increased SEQUENCE value
|
||||
for (localEvent in queryEvents("${Events.DIRTY}!=0 AND ${Events.ORIGINAL_ID} IS NULL", null)) {
|
||||
val event = localEvent.event!!
|
||||
val sequence = event.sequence
|
||||
if (event.sequence == null) // sequence has not been assigned yet (i.e. this event was just locally created)
|
||||
event.sequence = 0
|
||||
else if (localEvent.weAreOrganizer)
|
||||
event.sequence = sequence!! + 1
|
||||
dirty += localEvent
|
||||
}
|
||||
|
||||
return dirty
|
||||
}
|
||||
|
||||
@Throws(CalendarStorageException::class)
|
||||
override fun getCTag(): String? =
|
||||
try {
|
||||
provider.query(calendarSyncURI(), arrayOf(COLUMN_CTAG), null, null, null)?.use { cursor ->
|
||||
if (cursor.moveToNext())
|
||||
return cursor.getString(0)
|
||||
}
|
||||
null
|
||||
} catch (e: RemoteException) {
|
||||
throw CalendarStorageException("Couldn't read local (last known) CTag", e)
|
||||
}
|
||||
|
||||
@Throws(CalendarStorageException::class)
|
||||
override fun setCTag(cTag: String?) {
|
||||
try {
|
||||
val values = ContentValues(1)
|
||||
values.put(COLUMN_CTAG, cTag)
|
||||
provider.update(calendarSyncURI(), values, null, null)
|
||||
} catch (e: RemoteException) {
|
||||
throw CalendarStorageException("Couldn't write local (last known) CTag", e)
|
||||
}
|
||||
}
|
||||
|
||||
@Throws(CalendarStorageException::class)
|
||||
fun processDirtyExceptions() {
|
||||
try {
|
||||
// process deleted exceptions
|
||||
Logger.log.info("Processing deleted exceptions")
|
||||
provider.query(
|
||||
syncAdapterURI(Events.CONTENT_URI),
|
||||
arrayOf(Events._ID, Events.ORIGINAL_ID, LocalEvent.COLUMN_SEQUENCE),
|
||||
"${Events.DELETED}!=0 AND ${Events.ORIGINAL_ID} IS NOT NULL", null, null)?.use { cursor ->
|
||||
while (cursor.moveToNext()) {
|
||||
Logger.log.fine("Found deleted exception, removing; then re-scheduling original event")
|
||||
val id = cursor.getLong(0) // can't be null (by definition)
|
||||
val originalID = cursor.getLong(1) // can't be null (by query)
|
||||
|
||||
val batch = BatchOperation(provider)
|
||||
|
||||
// get original event's SEQUENCE
|
||||
provider.query(
|
||||
syncAdapterURI(ContentUris.withAppendedId(Events.CONTENT_URI, originalID)),
|
||||
arrayOf(LocalEvent.COLUMN_SEQUENCE),
|
||||
null, null, null)?.use { cursor2 ->
|
||||
val originalSequence = if (cursor2.isNull(0)) 0 else cursor2.getInt(0)
|
||||
|
||||
// re-schedule original event and set it to DIRTY
|
||||
batch.enqueue(BatchOperation.Operation(
|
||||
ContentProviderOperation.newUpdate(syncAdapterURI(ContentUris.withAppendedId(Events.CONTENT_URI, originalID)))
|
||||
.withValue(LocalEvent.COLUMN_SEQUENCE, originalSequence + 1)
|
||||
.withValue(Events.DIRTY, 1)
|
||||
))
|
||||
}
|
||||
|
||||
// remove exception
|
||||
batch.enqueue(BatchOperation.Operation(
|
||||
ContentProviderOperation.newDelete(syncAdapterURI(ContentUris.withAppendedId(Events.CONTENT_URI, id)))
|
||||
))
|
||||
batch.commit()
|
||||
}
|
||||
}
|
||||
|
||||
// process dirty exceptions
|
||||
Logger.log.info("Processing dirty exceptions")
|
||||
provider.query(
|
||||
syncAdapterURI(Events.CONTENT_URI),
|
||||
arrayOf(Events._ID, Events.ORIGINAL_ID, LocalEvent.COLUMN_SEQUENCE),
|
||||
"${Events.DIRTY}!=0 AND ${Events.ORIGINAL_ID} IS NOT NULL", null, null)?.use { cursor ->
|
||||
while (cursor.moveToNext()) {
|
||||
Logger.log.fine("Found dirty exception, increasing SEQUENCE to re-schedule")
|
||||
val id = cursor.getLong(0) // can't be null (by definition)
|
||||
val originalID = cursor.getLong(1) // can't be null (by query)
|
||||
val sequence = if (cursor.isNull(2)) 0 else cursor.getInt(2)
|
||||
|
||||
val batch = BatchOperation(provider)
|
||||
// original event to DIRTY
|
||||
batch.enqueue(BatchOperation.Operation (
|
||||
ContentProviderOperation.newUpdate(syncAdapterURI(ContentUris.withAppendedId(Events.CONTENT_URI, originalID)))
|
||||
.withValue(Events.DIRTY, 1)
|
||||
))
|
||||
// increase SEQUENCE and set DIRTY to 0
|
||||
batch.enqueue(BatchOperation.Operation (
|
||||
ContentProviderOperation.newUpdate(syncAdapterURI(ContentUris.withAppendedId(Events.CONTENT_URI, id)))
|
||||
.withValue(LocalEvent.COLUMN_SEQUENCE, sequence + 1)
|
||||
.withValue(Events.DIRTY, 0)
|
||||
))
|
||||
batch.commit()
|
||||
}
|
||||
}
|
||||
} catch (e: RemoteException) {
|
||||
throw CalendarStorageException("Couldn't process locally modified exception", e)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
object Factory: AndroidCalendarFactory<LocalCalendar> {
|
||||
|
||||
override fun newInstance(account: Account, provider: ContentProviderClient, id: Long) =
|
||||
LocalCalendar(account, provider, id)
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,27 +0,0 @@
|
||||
/*
|
||||
* Copyright © 2013 – 2015 Ricki Hirner (bitfire web engineering).
|
||||
* All rights reserved. This program and the accompanying materials
|
||||
* are made available under the terms of the GNU Public License v3.0
|
||||
* which accompanies this distribution, and is available at
|
||||
* http://www.gnu.org/licenses/gpl.html
|
||||
*/
|
||||
|
||||
package at.bitfire.davdroid.resource;
|
||||
|
||||
import java.io.FileNotFoundException;
|
||||
|
||||
import at.bitfire.ical4android.CalendarStorageException;
|
||||
import at.bitfire.vcard4android.ContactsStorageException;
|
||||
|
||||
public interface LocalCollection {
|
||||
|
||||
LocalResource[] getDeleted() throws CalendarStorageException, ContactsStorageException;
|
||||
LocalResource[] getWithoutFileName() throws CalendarStorageException, ContactsStorageException;
|
||||
LocalResource[] getDirty() throws CalendarStorageException, ContactsStorageException, FileNotFoundException;
|
||||
|
||||
LocalResource[] getAll() throws CalendarStorageException, ContactsStorageException;
|
||||
|
||||
String getCTag() throws CalendarStorageException, ContactsStorageException;
|
||||
void setCTag(String cTag) throws CalendarStorageException, ContactsStorageException;
|
||||
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
/*
|
||||
* Copyright © Ricki Hirner (bitfire web engineering).
|
||||
* All rights reserved. This program and the accompanying materials
|
||||
* are made available under the terms of the GNU Public License v3.0
|
||||
* which accompanies this distribution, and is available at
|
||||
* http://www.gnu.org/licenses/gpl.html
|
||||
*/
|
||||
|
||||
package at.bitfire.davdroid.resource
|
||||
|
||||
import at.bitfire.ical4android.CalendarStorageException
|
||||
import at.bitfire.vcard4android.ContactsStorageException
|
||||
import java.io.FileNotFoundException
|
||||
|
||||
interface LocalCollection<out T: LocalResource> {
|
||||
|
||||
@Throws(CalendarStorageException::class, ContactsStorageException::class)
|
||||
fun getDeleted(): List<T>
|
||||
|
||||
@Throws(CalendarStorageException::class, ContactsStorageException::class)
|
||||
fun getWithoutFileName(): List<T>
|
||||
|
||||
@Throws(CalendarStorageException::class, ContactsStorageException::class, FileNotFoundException::class)
|
||||
fun getDirty(): List<T>
|
||||
|
||||
@Throws(CalendarStorageException::class, ContactsStorageException::class)
|
||||
fun getAll(): List<T>
|
||||
|
||||
@Throws(CalendarStorageException::class, ContactsStorageException::class)
|
||||
fun getCTag(): String?
|
||||
|
||||
@Throws(CalendarStorageException::class, ContactsStorageException::class)
|
||||
fun setCTag(cTag: String?)
|
||||
|
||||
}
|
||||
@@ -1,142 +0,0 @@
|
||||
/*
|
||||
* Copyright © 2013 – 2015 Ricki Hirner (bitfire web engineering).
|
||||
* All rights reserved. This program and the accompanying materials
|
||||
* are made available under the terms of the GNU Public License v3.0
|
||||
* which accompanies this distribution, and is available at
|
||||
* http://www.gnu.org/licenses/gpl.html
|
||||
*/
|
||||
|
||||
package at.bitfire.davdroid.resource;
|
||||
|
||||
import android.content.ContentProviderOperation;
|
||||
import android.content.ContentValues;
|
||||
import android.os.RemoteException;
|
||||
import android.provider.ContactsContract;
|
||||
import android.provider.ContactsContract.CommonDataKinds.GroupMembership;
|
||||
|
||||
import java.io.FileNotFoundException;
|
||||
|
||||
import at.bitfire.davdroid.BuildConfig;
|
||||
import at.bitfire.davdroid.Constants;
|
||||
import at.bitfire.vcard4android.AndroidAddressBook;
|
||||
import at.bitfire.vcard4android.AndroidContact;
|
||||
import at.bitfire.vcard4android.AndroidContactFactory;
|
||||
import at.bitfire.vcard4android.BatchOperation;
|
||||
import at.bitfire.vcard4android.Contact;
|
||||
import at.bitfire.vcard4android.ContactsStorageException;
|
||||
import ezvcard.Ezvcard;
|
||||
|
||||
public class LocalContact extends AndroidContact implements LocalResource {
|
||||
static {
|
||||
Contact.productID = "+//IDN bitfire.at//DAVdroid/" + BuildConfig.VERSION_NAME + " vcard4android ez-vcard/" + Ezvcard.VERSION;
|
||||
}
|
||||
|
||||
protected LocalContact(AndroidAddressBook addressBook, long id, String fileName, String eTag) {
|
||||
super(addressBook, id, fileName, eTag);
|
||||
}
|
||||
|
||||
public LocalContact(AndroidAddressBook addressBook, Contact contact, String fileName, String eTag) {
|
||||
super(addressBook, contact, fileName, eTag);
|
||||
}
|
||||
|
||||
public void clearDirty(String eTag) throws ContactsStorageException {
|
||||
try {
|
||||
ContentValues values = new ContentValues(1);
|
||||
values.put(ContactsContract.RawContacts.DIRTY, 0);
|
||||
values.put(COLUMN_ETAG, eTag);
|
||||
addressBook.provider.update(rawContactSyncURI(), values, null, null);
|
||||
|
||||
this.eTag = eTag;
|
||||
} catch (RemoteException e) {
|
||||
throw new ContactsStorageException("Couldn't clear dirty flag", e);
|
||||
}
|
||||
}
|
||||
|
||||
public void updateFileNameAndUID(String uid) throws ContactsStorageException {
|
||||
try {
|
||||
String newFileName = uid + ".vcf";
|
||||
|
||||
ContentValues values = new ContentValues(2);
|
||||
values.put(COLUMN_FILENAME, newFileName);
|
||||
values.put(COLUMN_UID, uid);
|
||||
addressBook.provider.update(rawContactSyncURI(), values, null, null);
|
||||
|
||||
fileName = newFileName;
|
||||
} catch (RemoteException e) {
|
||||
throw new ContactsStorageException("Couldn't update UID", e);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// group support
|
||||
|
||||
@Override
|
||||
protected void populateGroupMembership(ContentValues row) {
|
||||
if (row.containsKey(GroupMembership.GROUP_ROW_ID)) {
|
||||
long groupId = row.getAsLong(GroupMembership.GROUP_ROW_ID);
|
||||
|
||||
// fetch group
|
||||
LocalGroup group = new LocalGroup(addressBook, groupId);
|
||||
try {
|
||||
Contact groupInfo = group.getContact();
|
||||
|
||||
// add to CATEGORIES
|
||||
contact.getCategories().add(groupInfo.displayName);
|
||||
} catch (FileNotFoundException|ContactsStorageException e) {
|
||||
Constants.log.warn("Couldn't find assigned group #" + groupId + ", ignoring membership", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void insertGroupMemberships(BatchOperation batch) throws ContactsStorageException {
|
||||
for (String category : contact.getCategories()) {
|
||||
// Is there already a category with this display name?
|
||||
LocalGroup group = ((LocalAddressBook)addressBook).findGroupByTitle(category);
|
||||
|
||||
if (group == null) {
|
||||
// no, we have to create the group before inserting the membership
|
||||
|
||||
Contact groupInfo = new Contact();
|
||||
groupInfo.displayName = category;
|
||||
group = new LocalGroup(addressBook, groupInfo);
|
||||
group.create();
|
||||
}
|
||||
|
||||
Long groupId = group.getId();
|
||||
if (groupId != null) {
|
||||
ContentProviderOperation.Builder builder = ContentProviderOperation.newInsert(dataSyncURI());
|
||||
if (id == null)
|
||||
builder.withValueBackReference(GroupMembership.RAW_CONTACT_ID, 0);
|
||||
else
|
||||
builder.withValue(GroupMembership.RAW_CONTACT_ID, id);
|
||||
builder .withValue(GroupMembership.MIMETYPE, GroupMembership.CONTENT_ITEM_TYPE)
|
||||
.withValue(GroupMembership.GROUP_ROW_ID, groupId);
|
||||
batch.enqueue(builder.build());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// factory
|
||||
|
||||
static class Factory extends AndroidContactFactory {
|
||||
static final Factory INSTANCE = new Factory();
|
||||
|
||||
@Override
|
||||
public LocalContact newInstance(AndroidAddressBook addressBook, long id, String fileName, String eTag) {
|
||||
return new LocalContact(addressBook, id, fileName, eTag);
|
||||
}
|
||||
|
||||
@Override
|
||||
public LocalContact newInstance(AndroidAddressBook addressBook, Contact contact, String fileName, String eTag) {
|
||||
return new LocalContact(addressBook, contact, fileName, eTag);
|
||||
}
|
||||
|
||||
public LocalContact[] newArray(int size) {
|
||||
return new LocalContact[size];
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,273 @@
|
||||
/*
|
||||
* Copyright © Ricki Hirner (bitfire web engineering).
|
||||
* All rights reserved. This program and the accompanying materials
|
||||
* are made available under the terms of the GNU Public License v3.0
|
||||
* which accompanies this distribution, and is available at
|
||||
* http://www.gnu.org/licenses/gpl.html
|
||||
*/
|
||||
|
||||
package at.bitfire.davdroid.resource
|
||||
|
||||
import android.content.ContentProviderOperation
|
||||
import android.content.ContentValues
|
||||
import android.os.Build
|
||||
import android.os.RemoteException
|
||||
import android.provider.ContactsContract
|
||||
import android.provider.ContactsContract.CommonDataKinds.GroupMembership
|
||||
import android.provider.ContactsContract.RawContacts.Data
|
||||
import at.bitfire.davdroid.BuildConfig
|
||||
import at.bitfire.davdroid.log.Logger
|
||||
import at.bitfire.davdroid.model.UnknownProperties
|
||||
import at.bitfire.vcard4android.*
|
||||
import ezvcard.Ezvcard
|
||||
import java.io.FileNotFoundException
|
||||
import java.util.*
|
||||
|
||||
class LocalContact: AndroidContact, LocalResource {
|
||||
|
||||
companion object {
|
||||
|
||||
init {
|
||||
Contact.productID = "+//IDN bitfire.at//DAVdroid/" + BuildConfig.VERSION_NAME + " ez-vcard/" + Ezvcard.VERSION
|
||||
}
|
||||
|
||||
val COLUMN_HASHCODE = ContactsContract.RawContacts.SYNC3
|
||||
|
||||
}
|
||||
|
||||
private val cachedGroupMemberships = HashSet<Long>()
|
||||
private val groupMemberships = HashSet<Long>()
|
||||
|
||||
|
||||
constructor(addressBook: AndroidAddressBook<LocalContact,*>, id: Long, fileName: String?, eTag: String?):
|
||||
super(addressBook, id, fileName, eTag)
|
||||
|
||||
constructor(addressBook: AndroidAddressBook<LocalContact,*>, contact: Contact, fileName: String?, eTag: String?):
|
||||
super(addressBook, contact, fileName, eTag)
|
||||
|
||||
|
||||
@Throws(ContactsStorageException::class)
|
||||
fun resetDirty() {
|
||||
val values = ContentValues(1)
|
||||
values.put(ContactsContract.RawContacts.DIRTY, 0)
|
||||
try {
|
||||
addressBook.provider!!.update(rawContactSyncURI(), values, null, null)
|
||||
} catch(e: RemoteException) {
|
||||
throw ContactsStorageException("Couldn't clear dirty flag", e)
|
||||
}
|
||||
}
|
||||
|
||||
@Throws(ContactsStorageException::class)
|
||||
override fun clearDirty(eTag: String?) {
|
||||
try {
|
||||
val values = ContentValues(3)
|
||||
values.put(COLUMN_ETAG, eTag)
|
||||
values.put(ContactsContract.RawContacts.DIRTY, 0)
|
||||
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N && Build.VERSION.SDK_INT < Build.VERSION_CODES.O) {
|
||||
// workaround for Android 7 which sets DIRTY flag when only meta-data is changed
|
||||
val hashCode = dataHashCode()
|
||||
values.put(COLUMN_HASHCODE, hashCode)
|
||||
Logger.log.finer("Clearing dirty flag with eTag = $eTag, contact hash = $hashCode")
|
||||
}
|
||||
|
||||
addressBook.provider!!.update(rawContactSyncURI(), values, null, null)
|
||||
|
||||
this.eTag = eTag
|
||||
} catch(e: Exception) {
|
||||
throw ContactsStorageException("Couldn't clear dirty flag", e)
|
||||
}
|
||||
}
|
||||
|
||||
@Throws(ContactsStorageException::class)
|
||||
override fun prepareForUpload() {
|
||||
try {
|
||||
val uid = UUID.randomUUID().toString()
|
||||
val newFileName = uid + ".vcf"
|
||||
|
||||
val values = ContentValues(2)
|
||||
values.put(COLUMN_FILENAME, newFileName)
|
||||
values.put(COLUMN_UID, uid)
|
||||
addressBook.provider!!.update(rawContactSyncURI(), values, null, null)
|
||||
|
||||
fileName = newFileName
|
||||
} catch(e: RemoteException) {
|
||||
throw ContactsStorageException("Couldn't update UID", e)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@Throws(ContactsStorageException::class)
|
||||
override fun populateData(mimeType: String, row: ContentValues) {
|
||||
when (mimeType) {
|
||||
CachedGroupMembership.CONTENT_ITEM_TYPE ->
|
||||
cachedGroupMemberships.add(row.getAsLong(CachedGroupMembership.GROUP_ID))
|
||||
GroupMembership.CONTENT_ITEM_TYPE ->
|
||||
groupMemberships.add(row.getAsLong(GroupMembership.GROUP_ROW_ID))
|
||||
UnknownProperties.CONTENT_ITEM_TYPE ->
|
||||
try {
|
||||
contact!!.unknownProperties = row.getAsString(UnknownProperties.UNKNOWN_PROPERTIES)
|
||||
} catch(e: FileNotFoundException) {
|
||||
throw ContactsStorageException("Couldn't fetch data rows", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Throws(ContactsStorageException::class)
|
||||
override fun insertDataRows(batch: BatchOperation) {
|
||||
super.insertDataRows(batch)
|
||||
|
||||
try {
|
||||
contact!!.unknownProperties?.let { unknownProperties ->
|
||||
val op: BatchOperation.Operation
|
||||
val builder = ContentProviderOperation.newInsert(dataSyncURI())
|
||||
if (id == null)
|
||||
op = BatchOperation.Operation(builder, UnknownProperties.RAW_CONTACT_ID, 0)
|
||||
else {
|
||||
op = BatchOperation.Operation(builder)
|
||||
builder.withValue(UnknownProperties.RAW_CONTACT_ID, id)
|
||||
}
|
||||
builder .withValue(UnknownProperties.MIMETYPE, UnknownProperties.CONTENT_ITEM_TYPE)
|
||||
.withValue(UnknownProperties.UNKNOWN_PROPERTIES, unknownProperties)
|
||||
batch.enqueue(op)
|
||||
}
|
||||
} catch(e: FileNotFoundException) {
|
||||
throw ContactsStorageException("Couldn't insert data rows", e)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Calculates a hash code from the contact's data (VCard) and group memberships.
|
||||
* Attention: re-reads {@link #contact} from the database, discarding all changes in memory
|
||||
* @return hash code of contact data (including group memberships)
|
||||
*/
|
||||
@Throws(FileNotFoundException::class, ContactsStorageException::class)
|
||||
internal fun dataHashCode(): Int {
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.N || Build.VERSION.SDK_INT >= Build.VERSION_CODES.O)
|
||||
throw IllegalStateException("dataHashCode() should not be called on Android != 7")
|
||||
|
||||
// reset contact so that getContact() reads from database
|
||||
contact = null
|
||||
|
||||
// groupMemberships is filled by getContact()
|
||||
val dataHash = contact!!.hashCode()
|
||||
val groupHash = groupMemberships.hashCode()
|
||||
Logger.log.finest("Calculated data hash = $dataHash, group memberships hash = $groupHash")
|
||||
return dataHash xor groupHash
|
||||
}
|
||||
|
||||
@Throws(ContactsStorageException::class)
|
||||
fun updateHashCode(batch: BatchOperation?) {
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.N || Build.VERSION.SDK_INT >= Build.VERSION_CODES.O)
|
||||
throw IllegalStateException("updateHashCode() should not be called on Android != 7")
|
||||
|
||||
val values = ContentValues(1)
|
||||
try {
|
||||
val hashCode = dataHashCode()
|
||||
Logger.log.fine("Storing contact hash = $hashCode")
|
||||
values.put(COLUMN_HASHCODE, hashCode)
|
||||
|
||||
if (batch == null)
|
||||
addressBook.provider!!.update(rawContactSyncURI(), values, null, null)
|
||||
else {
|
||||
val builder = ContentProviderOperation
|
||||
.newUpdate(rawContactSyncURI())
|
||||
.withValues(values)
|
||||
batch.enqueue(BatchOperation.Operation(builder))
|
||||
}
|
||||
} catch(e: Exception) {
|
||||
throw ContactsStorageException("Couldn't store contact checksum", e)
|
||||
}
|
||||
}
|
||||
|
||||
@Throws(ContactsStorageException::class)
|
||||
fun getLastHashCode(): Int {
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.N || Build.VERSION.SDK_INT >= Build.VERSION_CODES.O)
|
||||
throw IllegalStateException("getLastHashCode() should not be called on Android != 7")
|
||||
|
||||
try {
|
||||
addressBook.provider!!.query(rawContactSyncURI(), arrayOf(COLUMN_HASHCODE), null, null, null)?.use { c ->
|
||||
if (c.moveToNext() && !c.isNull(0))
|
||||
return c.getInt(0)
|
||||
}
|
||||
return 0
|
||||
} catch(e: RemoteException) {
|
||||
throw ContactsStorageException("Could't read last hash code", e)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
fun addToGroup(batch: BatchOperation, groupID: Long) {
|
||||
batch.enqueue(BatchOperation.Operation(
|
||||
ContentProviderOperation.newInsert(dataSyncURI())
|
||||
.withValue(GroupMembership.MIMETYPE, GroupMembership.CONTENT_ITEM_TYPE)
|
||||
.withValue(GroupMembership.RAW_CONTACT_ID, id)
|
||||
.withValue(GroupMembership.GROUP_ROW_ID, groupID)
|
||||
))
|
||||
groupMemberships.add(groupID)
|
||||
|
||||
batch.enqueue(BatchOperation.Operation(
|
||||
ContentProviderOperation.newInsert(dataSyncURI())
|
||||
.withValue(CachedGroupMembership.MIMETYPE, CachedGroupMembership.CONTENT_ITEM_TYPE)
|
||||
.withValue(CachedGroupMembership.RAW_CONTACT_ID, id)
|
||||
.withValue(CachedGroupMembership.GROUP_ID, groupID)
|
||||
.withYieldAllowed(true)
|
||||
))
|
||||
cachedGroupMemberships.add(groupID)
|
||||
}
|
||||
|
||||
fun removeGroupMemberships(batch: BatchOperation) {
|
||||
batch.enqueue(BatchOperation.Operation(
|
||||
ContentProviderOperation.newDelete(dataSyncURI())
|
||||
.withSelection(
|
||||
Data.RAW_CONTACT_ID + "=? AND " + Data.MIMETYPE + " IN (?,?)",
|
||||
arrayOf(id.toString(), GroupMembership.CONTENT_ITEM_TYPE, CachedGroupMembership.CONTENT_ITEM_TYPE)
|
||||
)
|
||||
.withYieldAllowed(true)
|
||||
))
|
||||
groupMemberships.clear()
|
||||
cachedGroupMemberships.clear()
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the IDs of all groups the contact was member of (cached memberships).
|
||||
* Cached memberships are kept in sync with memberships by DAVdroid and are used to determine
|
||||
* whether a membership has been deleted/added when a raw contact is dirty.
|
||||
* @return set of {@link GroupMembership#GROUP_ROW_ID} (may be empty)
|
||||
* @throws ContactsStorageException on contact provider errors
|
||||
* @throws FileNotFoundException if the current contact can't be found
|
||||
*/
|
||||
@Throws(FileNotFoundException::class, ContactsStorageException::class)
|
||||
fun getCachedGroupMemberships(): Set<Long> {
|
||||
contact
|
||||
return cachedGroupMemberships
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the IDs of all groups the contact is member of.
|
||||
* @return set of {@link GroupMembership#GROUP_ROW_ID}s (may be empty)
|
||||
* @throws ContactsStorageException on contact provider errors
|
||||
* @throws FileNotFoundException if the current contact can't be found
|
||||
*/
|
||||
@Throws(FileNotFoundException::class, ContactsStorageException::class)
|
||||
fun getGroupMemberships(): Set<Long> {
|
||||
contact
|
||||
return groupMemberships
|
||||
}
|
||||
|
||||
|
||||
// factory
|
||||
|
||||
object Factory: AndroidContactFactory<LocalContact> {
|
||||
|
||||
override fun newInstance(addressBook: AndroidAddressBook<LocalContact, *>, id: Long, fileName: String?, eTag: String?) =
|
||||
LocalContact(addressBook, id, fileName, eTag)
|
||||
|
||||
override fun newInstance(addressBook: AndroidAddressBook<LocalContact, *>, contact: Contact, fileName: String?, eTag: String?) =
|
||||
LocalContact(addressBook, contact, fileName, eTag)
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,146 +0,0 @@
|
||||
/*
|
||||
* Copyright © 2013 – 2015 Ricki Hirner (bitfire web engineering).
|
||||
* All rights reserved. This program and the accompanying materials
|
||||
* are made available under the terms of the GNU Public License v3.0
|
||||
* which accompanies this distribution, and is available at
|
||||
* http://www.gnu.org/licenses/gpl.html
|
||||
*/
|
||||
|
||||
package at.bitfire.davdroid.resource;
|
||||
|
||||
import android.annotation.TargetApi;
|
||||
import android.content.ContentProviderOperation;
|
||||
import android.content.ContentValues;
|
||||
import android.os.Build;
|
||||
import android.os.RemoteException;
|
||||
import android.provider.CalendarContract;
|
||||
import android.provider.CalendarContract.Events;
|
||||
|
||||
import net.fortuna.ical4j.model.property.ProdId;
|
||||
|
||||
import at.bitfire.davdroid.BuildConfig;
|
||||
import at.bitfire.ical4android.AndroidCalendar;
|
||||
import at.bitfire.ical4android.AndroidEvent;
|
||||
import at.bitfire.ical4android.AndroidEventFactory;
|
||||
import at.bitfire.ical4android.CalendarStorageException;
|
||||
import at.bitfire.ical4android.Event;
|
||||
import lombok.Getter;
|
||||
import lombok.NonNull;
|
||||
import lombok.Setter;
|
||||
|
||||
@TargetApi(17)
|
||||
public class LocalEvent extends AndroidEvent implements LocalResource {
|
||||
static {
|
||||
Event.prodId = new ProdId("+//IDN bitfire.at//DAVdroid/" + BuildConfig.VERSION_NAME + " ical4android ical4j/2.x");
|
||||
}
|
||||
|
||||
static final String COLUMN_ETAG = CalendarContract.Events.SYNC_DATA1,
|
||||
COLUMN_UID = Build.VERSION.SDK_INT >= 17 ? Events.UID_2445 : Events.SYNC_DATA2,
|
||||
COLUMN_SEQUENCE = CalendarContract.Events.SYNC_DATA3;
|
||||
|
||||
@Getter protected String fileName;
|
||||
@Getter @Setter protected String eTag;
|
||||
|
||||
public LocalEvent(@NonNull AndroidCalendar calendar, Event event, String fileName, String eTag) {
|
||||
super(calendar, event);
|
||||
this.fileName = fileName;
|
||||
this.eTag = eTag;
|
||||
}
|
||||
|
||||
protected LocalEvent(@NonNull AndroidCalendar calendar, long id, ContentValues baseInfo) {
|
||||
super(calendar, id, baseInfo);
|
||||
if (baseInfo != null) {
|
||||
fileName = baseInfo.getAsString(Events._SYNC_ID);
|
||||
eTag = baseInfo.getAsString(COLUMN_ETAG);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/* process LocalEvent-specific fields */
|
||||
|
||||
@Override
|
||||
protected void populateEvent(ContentValues values) {
|
||||
super.populateEvent(values);
|
||||
fileName = values.getAsString(Events._SYNC_ID);
|
||||
eTag = values.getAsString(COLUMN_ETAG);
|
||||
event.uid = values.getAsString(COLUMN_UID);
|
||||
|
||||
event.sequence = values.getAsInteger(COLUMN_SEQUENCE);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void buildEvent(Event recurrence, ContentProviderOperation.Builder builder) {
|
||||
super.buildEvent(recurrence, builder);
|
||||
|
||||
boolean buildException = recurrence != null;
|
||||
Event eventToBuild = buildException ? recurrence : event;
|
||||
|
||||
builder .withValue(COLUMN_UID, event.uid)
|
||||
.withValue(COLUMN_SEQUENCE, eventToBuild.sequence)
|
||||
.withValue(CalendarContract.Events.DIRTY, 0)
|
||||
.withValue(CalendarContract.Events.DELETED, 0);
|
||||
|
||||
if (buildException)
|
||||
builder.withValue(Events.ORIGINAL_SYNC_ID, fileName);
|
||||
else
|
||||
builder .withValue(Events._SYNC_ID, fileName)
|
||||
.withValue(COLUMN_ETAG, eTag);
|
||||
}
|
||||
|
||||
|
||||
/* custom queries */
|
||||
|
||||
public void updateFileNameAndUID(String uid) throws CalendarStorageException {
|
||||
try {
|
||||
String newFileName = uid + ".ics";
|
||||
|
||||
ContentValues values = new ContentValues(2);
|
||||
values.put(Events._SYNC_ID, newFileName);
|
||||
values.put(COLUMN_UID, uid);
|
||||
calendar.provider.update(eventSyncURI(), values, null, null);
|
||||
|
||||
fileName = newFileName;
|
||||
if (event != null)
|
||||
event.uid = uid;
|
||||
|
||||
} catch (RemoteException e) {
|
||||
throw new CalendarStorageException("Couldn't update UID", e);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void clearDirty(String eTag) throws CalendarStorageException {
|
||||
try {
|
||||
ContentValues values = new ContentValues(2);
|
||||
values.put(CalendarContract.Events.DIRTY, 0);
|
||||
values.put(COLUMN_ETAG, eTag);
|
||||
if (event != null)
|
||||
values.put(COLUMN_SEQUENCE, event.sequence);
|
||||
calendar.provider.update(eventSyncURI(), values, null, null);
|
||||
|
||||
this.eTag = eTag;
|
||||
} catch (RemoteException e) {
|
||||
throw new CalendarStorageException("Couldn't update UID", e);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
static class Factory implements AndroidEventFactory {
|
||||
static final Factory INSTANCE = new Factory();
|
||||
|
||||
@Override
|
||||
public AndroidEvent newInstance(AndroidCalendar calendar, long id, ContentValues baseInfo) {
|
||||
return new LocalEvent(calendar, id, baseInfo);
|
||||
}
|
||||
|
||||
@Override
|
||||
public AndroidEvent newInstance(AndroidCalendar calendar, Event event) {
|
||||
return new LocalEvent(calendar, event, null, null);
|
||||
}
|
||||
|
||||
@Override
|
||||
public AndroidEvent[] newArray(int size) {
|
||||
return new LocalEvent[size];
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
/*
|
||||
* Copyright © Ricki Hirner (bitfire web engineering).
|
||||
* All rights reserved. This program and the accompanying materials
|
||||
* are made available under the terms of the GNU Public License v3.0
|
||||
* which accompanies this distribution, and is available at
|
||||
* http://www.gnu.org/licenses/gpl.html
|
||||
*/
|
||||
|
||||
package at.bitfire.davdroid.resource
|
||||
|
||||
import android.content.ContentProviderOperation
|
||||
import android.content.ContentValues
|
||||
import android.os.Build
|
||||
import android.provider.CalendarContract
|
||||
import android.provider.CalendarContract.Events
|
||||
import at.bitfire.davdroid.BuildConfig
|
||||
import at.bitfire.ical4android.*
|
||||
import net.fortuna.ical4j.model.property.ProdId
|
||||
import java.io.FileNotFoundException
|
||||
import java.util.*
|
||||
|
||||
class LocalEvent: AndroidEvent, LocalResource {
|
||||
|
||||
companion object {
|
||||
init {
|
||||
iCalendar.prodId = ProdId("+//IDN bitfire.at//DAVdroid/" + BuildConfig.VERSION_NAME + " ical4j/2.x")
|
||||
}
|
||||
|
||||
val COLUMN_ETAG = CalendarContract.Events.SYNC_DATA1
|
||||
val COLUMN_UID = if (Build.VERSION.SDK_INT >= 17) Events.UID_2445 else Events.SYNC_DATA2
|
||||
val COLUMN_SEQUENCE = CalendarContract.Events.SYNC_DATA3
|
||||
}
|
||||
|
||||
override var fileName: String? = null
|
||||
override var eTag: String? = null
|
||||
|
||||
var weAreOrganizer = true
|
||||
|
||||
|
||||
constructor(calendar: AndroidCalendar<*>, event: Event, fileName: String?, eTag: String?): super(calendar, event) {
|
||||
this.fileName = fileName
|
||||
this.eTag = eTag
|
||||
}
|
||||
|
||||
private constructor(calendar: AndroidCalendar<*>, id: Long, baseInfo: ContentValues?): super(calendar, id, baseInfo) {
|
||||
baseInfo?.let {
|
||||
fileName = it.getAsString(Events._SYNC_ID)
|
||||
eTag = it.getAsString(COLUMN_ETAG)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/* process LocalEvent-specific fields */
|
||||
|
||||
@Throws(FileNotFoundException::class, CalendarStorageException::class)
|
||||
override fun populateEvent(row: ContentValues) {
|
||||
super.populateEvent(row)
|
||||
val event = requireNotNull(event)
|
||||
|
||||
fileName = row.getAsString(Events._SYNC_ID)
|
||||
eTag = row.getAsString(COLUMN_ETAG)
|
||||
event.uid = row.getAsString(COLUMN_UID)
|
||||
|
||||
event.sequence = row.getAsInteger(COLUMN_SEQUENCE)
|
||||
if (Build.VERSION.SDK_INT >= 17) {
|
||||
val isOrganizer = row.getAsInteger(Events.IS_ORGANIZER)
|
||||
weAreOrganizer = isOrganizer != null && isOrganizer != 0
|
||||
} else {
|
||||
val organizer = row.getAsString(Events.ORGANIZER)
|
||||
weAreOrganizer = organizer == null || organizer == calendar.account.name
|
||||
}
|
||||
}
|
||||
|
||||
@Throws(FileNotFoundException::class, CalendarStorageException::class)
|
||||
override fun buildEvent(recurrence: Event?, builder: ContentProviderOperation.Builder) {
|
||||
super.buildEvent(recurrence, builder)
|
||||
val event = requireNotNull(event)
|
||||
|
||||
val buildException = recurrence != null
|
||||
val eventToBuild = recurrence ?: event
|
||||
|
||||
builder .withValue(COLUMN_UID, event.uid)
|
||||
.withValue(COLUMN_SEQUENCE, eventToBuild.sequence)
|
||||
.withValue(CalendarContract.Events.DIRTY, 0)
|
||||
.withValue(CalendarContract.Events.DELETED, 0)
|
||||
|
||||
if (buildException)
|
||||
builder .withValue(Events.ORIGINAL_SYNC_ID, fileName)
|
||||
else
|
||||
builder .withValue(Events._SYNC_ID, fileName)
|
||||
.withValue(COLUMN_ETAG, eTag)
|
||||
}
|
||||
|
||||
|
||||
/* custom queries */
|
||||
|
||||
@Throws(CalendarStorageException::class)
|
||||
override fun prepareForUpload() {
|
||||
try {
|
||||
var uid: String? = null
|
||||
calendar.provider.query(eventSyncURI(), arrayOf(COLUMN_UID), null, null, null)?.use { cursor ->
|
||||
if (cursor.moveToNext())
|
||||
uid = cursor.getString(0)
|
||||
}
|
||||
if (uid == null)
|
||||
uid = UUID.randomUUID().toString()
|
||||
|
||||
val newFileName = "$uid.ics"
|
||||
|
||||
val values = ContentValues(2)
|
||||
values.put(Events._SYNC_ID, newFileName)
|
||||
values.put(COLUMN_UID, uid)
|
||||
calendar.provider.update(eventSyncURI(), values, null, null)
|
||||
|
||||
fileName = newFileName
|
||||
event!!.uid = uid
|
||||
|
||||
} catch(e: Exception) {
|
||||
throw CalendarStorageException("Couldn't update UID", e)
|
||||
}
|
||||
}
|
||||
|
||||
@Throws(CalendarStorageException::class)
|
||||
override fun clearDirty(eTag: String?) {
|
||||
try {
|
||||
val values = ContentValues(2)
|
||||
values.put(CalendarContract.Events.DIRTY, 0)
|
||||
values.put(COLUMN_ETAG, eTag)
|
||||
values.put(COLUMN_SEQUENCE, event!!.sequence);
|
||||
calendar.provider.update(eventSyncURI(), values, null, null)
|
||||
|
||||
this.eTag = eTag
|
||||
} catch (e: Exception) {
|
||||
throw CalendarStorageException("Couldn't update UID", e)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
object Factory: AndroidEventFactory<LocalEvent> {
|
||||
|
||||
override fun newInstance(calendar: AndroidCalendar<*>, id: Long, baseInfo: ContentValues?) =
|
||||
LocalEvent(calendar, id, baseInfo)
|
||||
|
||||
override fun newInstance(calendar: AndroidCalendar<*>, event: Event) =
|
||||
LocalEvent(calendar, event, null, null)
|
||||
|
||||
}
|
||||
}
|
||||
@@ -1,35 +0,0 @@
|
||||
/*
|
||||
* Copyright © 2013 – 2015 Ricki Hirner (bitfire web engineering).
|
||||
* All rights reserved. This program and the accompanying materials
|
||||
* are made available under the terms of the GNU Public License v3.0
|
||||
* which accompanies this distribution, and is available at
|
||||
* http://www.gnu.org/licenses/gpl.html
|
||||
*/
|
||||
|
||||
package at.bitfire.davdroid.resource;
|
||||
|
||||
import android.content.ContentValues;
|
||||
import android.provider.ContactsContract;
|
||||
|
||||
import at.bitfire.vcard4android.AndroidAddressBook;
|
||||
import at.bitfire.vcard4android.AndroidGroup;
|
||||
import at.bitfire.vcard4android.Contact;
|
||||
import at.bitfire.vcard4android.ContactsStorageException;
|
||||
|
||||
public class LocalGroup extends AndroidGroup {
|
||||
|
||||
public LocalGroup(AndroidAddressBook addressBook, long id) {
|
||||
super(addressBook, id);
|
||||
}
|
||||
|
||||
public LocalGroup(AndroidAddressBook addressBook, Contact contact) {
|
||||
super(addressBook, contact);
|
||||
}
|
||||
|
||||
public void clearDirty() throws ContactsStorageException {
|
||||
ContentValues values = new ContentValues(1);
|
||||
values.put(ContactsContract.Groups.DIRTY, 0);
|
||||
update(values);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,242 @@
|
||||
/*
|
||||
* Copyright © Ricki Hirner (bitfire web engineering).
|
||||
* All rights reserved. This program and the accompanying materials
|
||||
* are made available under the terms of the GNU Public License v3.0
|
||||
* which accompanies this distribution, and is available at
|
||||
* http://www.gnu.org/licenses/gpl.html
|
||||
*/
|
||||
|
||||
package at.bitfire.davdroid.resource
|
||||
|
||||
import android.content.ContentProviderOperation
|
||||
import android.content.ContentUris
|
||||
import android.content.ContentValues
|
||||
import android.os.Build
|
||||
import android.os.Parcel
|
||||
import android.os.RemoteException
|
||||
import android.provider.ContactsContract
|
||||
import android.provider.ContactsContract.CommonDataKinds.GroupMembership
|
||||
import android.provider.ContactsContract.Groups
|
||||
import android.provider.ContactsContract.RawContacts
|
||||
import android.provider.ContactsContract.RawContacts.Data
|
||||
import at.bitfire.dav4android.Constants
|
||||
import at.bitfire.vcard4android.*
|
||||
import java.io.FileNotFoundException
|
||||
import java.util.*
|
||||
import java.util.logging.Level
|
||||
|
||||
class LocalGroup: AndroidGroup, LocalResource {
|
||||
|
||||
companion object {
|
||||
|
||||
/** marshaled list of member UIDs, as sent by server */
|
||||
val COLUMN_PENDING_MEMBERS = Groups.SYNC3
|
||||
|
||||
/**
|
||||
* Processes all groups with non-null {@link #COLUMN_PENDING_MEMBERS}: the pending memberships
|
||||
* are (if possible) applied, keeping cached memberships in sync.
|
||||
* @param addressBook address book to take groups from
|
||||
* @throws ContactsStorageException on contact provider errors
|
||||
*/
|
||||
@JvmStatic
|
||||
@Throws(ContactsStorageException::class)
|
||||
fun applyPendingMemberships(addressBook: LocalAddressBook) {
|
||||
try {
|
||||
addressBook.provider!!.query(
|
||||
addressBook.syncAdapterURI(Groups.CONTENT_URI),
|
||||
arrayOf(Groups._ID, COLUMN_PENDING_MEMBERS),
|
||||
"$COLUMN_PENDING_MEMBERS IS NOT NULL", null,
|
||||
null
|
||||
)?.use { cursor ->
|
||||
val batch = BatchOperation(addressBook.provider)
|
||||
while (cursor.moveToNext()) {
|
||||
val id = cursor.getLong(0)
|
||||
Constants.log.fine("Assigning members to group $id")
|
||||
|
||||
// required for workaround for Android 7 which sets DIRTY flag when only meta-data is changed
|
||||
val changeContactIDs = HashSet<Long>()
|
||||
|
||||
// delete all memberships and cached memberships for this group
|
||||
for (contact in addressBook.getByGroupMembership(id)) {
|
||||
contact.removeGroupMemberships(batch)
|
||||
changeContactIDs.add(contact.id!!)
|
||||
}
|
||||
|
||||
// extract list of member UIDs
|
||||
val members = LinkedList<String>()
|
||||
val raw = cursor.getBlob(1)
|
||||
val parcel = Parcel.obtain()
|
||||
try {
|
||||
parcel.unmarshall(raw, 0, raw.size)
|
||||
parcel.setDataPosition(0)
|
||||
parcel.readStringList(members)
|
||||
} finally {
|
||||
parcel.recycle()
|
||||
}
|
||||
|
||||
// insert memberships
|
||||
for (uid in members) {
|
||||
Constants.log.fine("Assigning member: $uid")
|
||||
try {
|
||||
val member = addressBook.findContactByUID(uid)
|
||||
member.addToGroup(batch, id)
|
||||
changeContactIDs.add(member.id!!)
|
||||
} catch(e: FileNotFoundException) {
|
||||
Constants.log.log(Level.WARNING, "Group member not found: $uid", e)
|
||||
}
|
||||
}
|
||||
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N && Build.VERSION.SDK_INT < Build.VERSION_CODES.O)
|
||||
// workaround for Android 7 which sets DIRTY flag when only meta-data is changed
|
||||
changeContactIDs
|
||||
.map { LocalContact(addressBook, it, null, null) }
|
||||
.forEach { it.updateHashCode(batch) }
|
||||
|
||||
// remove pending memberships
|
||||
batch.enqueue(BatchOperation.Operation(
|
||||
ContentProviderOperation.newUpdate(addressBook.syncAdapterURI(ContentUris.withAppendedId(Groups.CONTENT_URI, id)))
|
||||
.withValue(COLUMN_PENDING_MEMBERS, null)
|
||||
.withYieldAllowed(true)
|
||||
))
|
||||
|
||||
batch.commit()
|
||||
}
|
||||
}
|
||||
} catch(e: RemoteException) {
|
||||
throw ContactsStorageException("Couldn't get pending memberships", e)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
constructor(addressBook: AndroidAddressBook<out AndroidContact, LocalGroup>, id: Long, fileName: String?, eTag: String?):
|
||||
super(addressBook, id, fileName, eTag)
|
||||
|
||||
constructor(addressBook: AndroidAddressBook<out AndroidContact, LocalGroup>, contact: Contact, fileName: String?, eTag: String?):
|
||||
super(addressBook, contact, fileName, eTag)
|
||||
|
||||
|
||||
@Throws(ContactsStorageException::class)
|
||||
override fun clearDirty(eTag: String?) {
|
||||
val id = requireNotNull(id)
|
||||
|
||||
val values = ContentValues(2)
|
||||
values.put(Groups.DIRTY, 0)
|
||||
values.put(COLUMN_ETAG, eTag)
|
||||
this.eTag = eTag
|
||||
update(values)
|
||||
|
||||
// update cached group memberships
|
||||
val batch = BatchOperation(addressBook.provider!!)
|
||||
|
||||
// delete cached group memberships
|
||||
batch.enqueue(BatchOperation.Operation(
|
||||
ContentProviderOperation.newDelete(addressBook.syncAdapterURI(ContactsContract.Data.CONTENT_URI))
|
||||
.withSelection(
|
||||
CachedGroupMembership.MIMETYPE + "=? AND " + CachedGroupMembership.GROUP_ID + "=?",
|
||||
arrayOf(CachedGroupMembership.CONTENT_ITEM_TYPE, id.toString())
|
||||
)
|
||||
))
|
||||
|
||||
// insert updated cached group memberships
|
||||
for (member in getMembers())
|
||||
batch.enqueue(BatchOperation.Operation(
|
||||
ContentProviderOperation.newInsert(addressBook.syncAdapterURI(ContactsContract.Data.CONTENT_URI))
|
||||
.withValue(CachedGroupMembership.MIMETYPE, CachedGroupMembership.CONTENT_ITEM_TYPE)
|
||||
.withValue(CachedGroupMembership.RAW_CONTACT_ID, member)
|
||||
.withValue(CachedGroupMembership.GROUP_ID, id)
|
||||
.withYieldAllowed(true)
|
||||
))
|
||||
|
||||
batch.commit()
|
||||
}
|
||||
|
||||
@Throws(ContactsStorageException::class)
|
||||
override fun prepareForUpload() {
|
||||
val uid = UUID.randomUUID().toString()
|
||||
val newFileName = "$uid.vcf"
|
||||
|
||||
val values = ContentValues(2)
|
||||
values.put(COLUMN_FILENAME, newFileName)
|
||||
values.put(COLUMN_UID, uid)
|
||||
update(values)
|
||||
|
||||
fileName = newFileName
|
||||
}
|
||||
|
||||
@Throws(FileNotFoundException::class, ContactsStorageException::class)
|
||||
override fun contentValues(): ContentValues {
|
||||
val values = super.contentValues()
|
||||
|
||||
val members = Parcel.obtain()
|
||||
try {
|
||||
members.writeStringList(contact!!.members)
|
||||
values.put(COLUMN_PENDING_MEMBERS, members.marshall())
|
||||
} finally {
|
||||
members.recycle()
|
||||
}
|
||||
return values
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Marks all members of the current group as dirty.
|
||||
*/
|
||||
@Throws(ContactsStorageException::class)
|
||||
fun markMembersDirty() {
|
||||
val batch = BatchOperation(addressBook.provider!!)
|
||||
|
||||
for (member in getMembers())
|
||||
batch.enqueue(BatchOperation.Operation(
|
||||
ContentProviderOperation.newUpdate(addressBook.syncAdapterURI(ContentUris.withAppendedId(RawContacts.CONTENT_URI, member)))
|
||||
.withValue(RawContacts.DIRTY, 1)
|
||||
.withYieldAllowed(true)
|
||||
))
|
||||
|
||||
batch.commit()
|
||||
}
|
||||
|
||||
|
||||
// helpers
|
||||
|
||||
/**
|
||||
* Lists all members of this group.
|
||||
* @return list of all members' raw contact IDs
|
||||
* @throws ContactsStorageException on contact provider errorst
|
||||
*/
|
||||
@Throws(ContactsStorageException::class)
|
||||
internal fun getMembers(): List<Long> {
|
||||
val id = requireNotNull(id)
|
||||
val members = LinkedList<Long>()
|
||||
try {
|
||||
addressBook.provider!!.query(
|
||||
addressBook.syncAdapterURI(ContactsContract.Data.CONTENT_URI),
|
||||
arrayOf(Data.RAW_CONTACT_ID),
|
||||
"${GroupMembership.MIMETYPE}=? AND ${GroupMembership.GROUP_ROW_ID}=?",
|
||||
arrayOf(GroupMembership.CONTENT_ITEM_TYPE, id.toString()),
|
||||
null
|
||||
)?.use { cursor ->
|
||||
while (cursor.moveToNext())
|
||||
members.add(cursor.getLong(0))
|
||||
}
|
||||
} catch(e: RemoteException) {
|
||||
throw ContactsStorageException("Couldn't list group members", e)
|
||||
}
|
||||
return members
|
||||
}
|
||||
|
||||
|
||||
// factory
|
||||
|
||||
object Factory: AndroidGroupFactory<LocalGroup> {
|
||||
|
||||
override fun newInstance(addressBook: AndroidAddressBook<out AndroidContact, LocalGroup>, id: Long, fileName: String?, eTag: String?) =
|
||||
LocalGroup(addressBook, id, fileName, eTag)
|
||||
|
||||
override fun newInstance(addressBook: AndroidAddressBook<out AndroidContact, LocalGroup>, contact: Contact, fileName: String?, eTag: String?) =
|
||||
LocalGroup(addressBook, contact, fileName, eTag)
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,26 +0,0 @@
|
||||
/*
|
||||
* Copyright © 2013 – 2015 Ricki Hirner (bitfire web engineering).
|
||||
* All rights reserved. This program and the accompanying materials
|
||||
* are made available under the terms of the GNU Public License v3.0
|
||||
* which accompanies this distribution, and is available at
|
||||
* http://www.gnu.org/licenses/gpl.html
|
||||
*/
|
||||
|
||||
package at.bitfire.davdroid.resource;
|
||||
|
||||
import at.bitfire.ical4android.CalendarStorageException;
|
||||
import at.bitfire.vcard4android.ContactsStorageException;
|
||||
|
||||
public interface LocalResource {
|
||||
|
||||
Long getId();
|
||||
|
||||
String getFileName();
|
||||
String getETag();
|
||||
|
||||
int delete() throws CalendarStorageException, ContactsStorageException;
|
||||
|
||||
void updateFileNameAndUID(String uuid) throws CalendarStorageException, ContactsStorageException;
|
||||
void clearDirty(String eTag) throws CalendarStorageException, ContactsStorageException;
|
||||
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
/*
|
||||
* Copyright © Ricki Hirner (bitfire web engineering).
|
||||
* All rights reserved. This program and the accompanying materials
|
||||
* are made available under the terms of the GNU Public License v3.0
|
||||
* which accompanies this distribution, and is available at
|
||||
* http://www.gnu.org/licenses/gpl.html
|
||||
*/
|
||||
|
||||
package at.bitfire.davdroid.resource
|
||||
|
||||
import at.bitfire.ical4android.CalendarStorageException
|
||||
import at.bitfire.vcard4android.ContactsStorageException
|
||||
|
||||
interface LocalResource {
|
||||
|
||||
val id: Long?
|
||||
|
||||
var fileName: String?
|
||||
var eTag: String?
|
||||
|
||||
@Throws(CalendarStorageException::class, ContactsStorageException::class)
|
||||
fun delete(): Int
|
||||
|
||||
@Throws(CalendarStorageException::class, ContactsStorageException::class)
|
||||
fun prepareForUpload()
|
||||
|
||||
@Throws(CalendarStorageException::class, ContactsStorageException::class)
|
||||
fun clearDirty(eTag: String?)
|
||||
|
||||
}
|
||||
@@ -1,138 +0,0 @@
|
||||
/*
|
||||
* Copyright © 2013 – 2015 Ricki Hirner (bitfire web engineering).
|
||||
* All rights reserved. This program and the accompanying materials
|
||||
* are made available under the terms of the GNU Public License v3.0
|
||||
* which accompanies this distribution, and is available at
|
||||
* http://www.gnu.org/licenses/gpl.html
|
||||
*/
|
||||
|
||||
package at.bitfire.davdroid.resource;
|
||||
|
||||
import android.content.ContentProviderOperation;
|
||||
import android.content.ContentValues;
|
||||
import android.os.RemoteException;
|
||||
import android.provider.CalendarContract.Events;
|
||||
|
||||
import net.fortuna.ical4j.model.property.ProdId;
|
||||
|
||||
import org.dmfs.provider.tasks.TaskContract.Tasks;
|
||||
|
||||
import java.io.FileNotFoundException;
|
||||
import java.text.ParseException;
|
||||
|
||||
import at.bitfire.davdroid.BuildConfig;
|
||||
import at.bitfire.ical4android.AndroidTask;
|
||||
import at.bitfire.ical4android.AndroidTaskFactory;
|
||||
import at.bitfire.ical4android.AndroidTaskList;
|
||||
import at.bitfire.ical4android.CalendarStorageException;
|
||||
import at.bitfire.ical4android.Task;
|
||||
import lombok.Getter;
|
||||
import lombok.NonNull;
|
||||
import lombok.Setter;
|
||||
|
||||
public class LocalTask extends AndroidTask implements LocalResource {
|
||||
static {
|
||||
Task.prodId = new ProdId("+//IDN bitfire.at//DAVdroid/" + BuildConfig.VERSION_NAME + " ical4android ical4j/2.x");
|
||||
}
|
||||
|
||||
static final String COLUMN_ETAG = Tasks.SYNC1,
|
||||
COLUMN_UID = Tasks.SYNC2,
|
||||
COLUMN_SEQUENCE = Tasks.SYNC3;
|
||||
|
||||
@Getter protected String fileName;
|
||||
@Getter @Setter protected String eTag;
|
||||
|
||||
public LocalTask(@NonNull AndroidTaskList taskList, Task task, String fileName, String eTag) {
|
||||
super(taskList, task);
|
||||
this.fileName = fileName;
|
||||
this.eTag = eTag;
|
||||
}
|
||||
|
||||
protected LocalTask(@NonNull AndroidTaskList taskList, long id, ContentValues baseInfo) {
|
||||
super(taskList, id);
|
||||
if (baseInfo != null) {
|
||||
fileName = baseInfo.getAsString(Events._SYNC_ID);
|
||||
eTag = baseInfo.getAsString(COLUMN_ETAG);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/* process LocalTask-specific fields */
|
||||
|
||||
@Override
|
||||
protected void populateTask(ContentValues values) throws FileNotFoundException, RemoteException, ParseException {
|
||||
super.populateTask(values);
|
||||
|
||||
fileName = values.getAsString(Events._SYNC_ID);
|
||||
eTag = values.getAsString(COLUMN_ETAG);
|
||||
task.uid = values.getAsString(COLUMN_UID);
|
||||
|
||||
task.sequence = values.getAsInteger(COLUMN_SEQUENCE);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void buildTask(ContentProviderOperation.Builder builder, boolean update) {
|
||||
super.buildTask(builder, update);
|
||||
builder .withValue(Tasks._SYNC_ID, fileName)
|
||||
.withValue(COLUMN_UID, task.uid)
|
||||
.withValue(COLUMN_SEQUENCE, task.sequence)
|
||||
.withValue(COLUMN_ETAG, eTag);
|
||||
}
|
||||
|
||||
|
||||
/* custom queries */
|
||||
|
||||
public void updateFileNameAndUID(String uid) throws CalendarStorageException {
|
||||
try {
|
||||
String newFileName = uid + ".ics";
|
||||
|
||||
ContentValues values = new ContentValues(2);
|
||||
values.put(Tasks._SYNC_ID, newFileName);
|
||||
values.put(COLUMN_UID, uid);
|
||||
taskList.provider.client.update(taskSyncURI(), values, null, null);
|
||||
|
||||
fileName = newFileName;
|
||||
if (task != null)
|
||||
task.uid = uid;
|
||||
|
||||
} catch (RemoteException e) {
|
||||
throw new CalendarStorageException("Couldn't update UID", e);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void clearDirty(String eTag) throws CalendarStorageException {
|
||||
try {
|
||||
ContentValues values = new ContentValues(2);
|
||||
values.put(Tasks._DIRTY, 0);
|
||||
values.put(COLUMN_ETAG, eTag);
|
||||
if (task != null)
|
||||
values.put(COLUMN_SEQUENCE, task.sequence);
|
||||
taskList.provider.client.update(taskSyncURI(), values, null, null);
|
||||
|
||||
this.eTag = eTag;
|
||||
} catch (RemoteException e) {
|
||||
throw new CalendarStorageException("Couldn't update _DIRTY/ETag/SEQUENCE", e);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
static class Factory implements AndroidTaskFactory {
|
||||
static final Factory INSTANCE = new Factory();
|
||||
|
||||
@Override
|
||||
public LocalTask newInstance(AndroidTaskList taskList, long id, ContentValues baseInfo) {
|
||||
return new LocalTask(taskList, id, baseInfo);
|
||||
}
|
||||
|
||||
@Override
|
||||
public LocalTask newInstance(AndroidTaskList taskList, Task task) {
|
||||
return new LocalTask(taskList, task, null, null);
|
||||
}
|
||||
|
||||
@Override
|
||||
public LocalTask[] newArray(int size) {
|
||||
return new LocalTask[size];
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
/*
|
||||
* Copyright © Ricki Hirner (bitfire web engineering).
|
||||
* All rights reserved. This program and the accompanying materials
|
||||
* are made available under the terms of the GNU Public License v3.0
|
||||
* which accompanies this distribution, and is available at
|
||||
* http://www.gnu.org/licenses/gpl.html
|
||||
*/
|
||||
|
||||
package at.bitfire.davdroid.resource
|
||||
|
||||
import android.content.ContentProviderOperation
|
||||
import android.content.ContentValues
|
||||
import android.provider.CalendarContract.Events
|
||||
import at.bitfire.ical4android.*
|
||||
import org.dmfs.provider.tasks.TaskContract.Tasks
|
||||
import java.io.FileNotFoundException
|
||||
import java.text.ParseException
|
||||
import java.util.*
|
||||
|
||||
class LocalTask: AndroidTask, LocalResource {
|
||||
|
||||
companion object {
|
||||
val COLUMN_ETAG = Tasks.SYNC1
|
||||
val COLUMN_UID = Tasks.SYNC2
|
||||
val COLUMN_SEQUENCE = Tasks.SYNC3
|
||||
}
|
||||
|
||||
override var fileName: String? = null
|
||||
override var eTag: String? = null
|
||||
|
||||
constructor(taskList: AndroidTaskList<*>, task: Task, fileName: String?, eTag: String?): super(taskList, task) {
|
||||
this.fileName = fileName
|
||||
this.eTag = eTag
|
||||
}
|
||||
|
||||
private constructor(taskList: AndroidTaskList<*>, id: Long, baseInfo: ContentValues?): super(taskList, id) {
|
||||
baseInfo?.let {
|
||||
fileName = it.getAsString(Events._SYNC_ID)
|
||||
eTag = it.getAsString(COLUMN_ETAG)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/* process LocalTask-specific fields */
|
||||
|
||||
@Throws(ParseException::class)
|
||||
override fun populateTask(values: ContentValues) {
|
||||
super.populateTask(values)
|
||||
|
||||
fileName = values.getAsString(Events._SYNC_ID)
|
||||
eTag = values.getAsString(COLUMN_ETAG)
|
||||
|
||||
val task = requireNotNull(task)
|
||||
task.uid = values.getAsString(COLUMN_UID)
|
||||
task.sequence = values.getAsInteger(COLUMN_SEQUENCE)
|
||||
}
|
||||
|
||||
@Throws(FileNotFoundException::class, CalendarStorageException::class)
|
||||
override fun buildTask(builder: ContentProviderOperation.Builder, update: Boolean) {
|
||||
super.buildTask(builder, update)
|
||||
val task = requireNotNull(task)
|
||||
|
||||
builder .withValue(Tasks._SYNC_ID, fileName)
|
||||
.withValue(COLUMN_UID, task.uid)
|
||||
.withValue(COLUMN_SEQUENCE, task.sequence)
|
||||
.withValue(COLUMN_ETAG, eTag)
|
||||
}
|
||||
|
||||
|
||||
/* custom queries */
|
||||
|
||||
@Throws(CalendarStorageException::class)
|
||||
override fun prepareForUpload() {
|
||||
try {
|
||||
val uid = UUID.randomUUID().toString()
|
||||
val newFileName = uid + ".ics"
|
||||
|
||||
val values = ContentValues(2)
|
||||
values.put(Tasks._SYNC_ID, newFileName)
|
||||
values.put(COLUMN_UID, uid)
|
||||
taskList.provider.client.update(taskSyncURI(), values, null, null)
|
||||
|
||||
fileName = newFileName
|
||||
|
||||
task!!.uid = uid
|
||||
} catch (e: Exception) {
|
||||
throw CalendarStorageException("Couldn't update UID", e)
|
||||
}
|
||||
}
|
||||
|
||||
@Throws(CalendarStorageException::class)
|
||||
override fun clearDirty(eTag: String?) {
|
||||
try {
|
||||
val values = ContentValues(2)
|
||||
values.put(Tasks._DIRTY, 0)
|
||||
values.put(COLUMN_ETAG, eTag)
|
||||
|
||||
values.put(COLUMN_SEQUENCE, task!!.sequence)
|
||||
taskList.provider.client.update(taskSyncURI(), values, null, null)
|
||||
|
||||
this.eTag = eTag
|
||||
} catch (e: Exception) {
|
||||
throw CalendarStorageException("Couldn't update _DIRTY/ETag/SEQUENCE", e)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
object Factory: AndroidTaskFactory<LocalTask> {
|
||||
|
||||
override fun newInstance(calendar: AndroidTaskList<*>, id: Long, baseInfo: ContentValues?) =
|
||||
LocalTask(calendar, id, baseInfo)
|
||||
|
||||
override fun newInstance(calendar: AndroidTaskList<*>, task: Task) =
|
||||
LocalTask(calendar, task, null, null)
|
||||
|
||||
}
|
||||
}
|
||||
@@ -1,152 +0,0 @@
|
||||
/*
|
||||
* Copyright © 2013 – 2015 Ricki Hirner (bitfire web engineering).
|
||||
* All rights reserved. This program and the accompanying materials
|
||||
* are made available under the terms of the GNU Public License v3.0
|
||||
* which accompanies this distribution, and is available at
|
||||
* http://www.gnu.org/licenses/gpl.html
|
||||
*/
|
||||
|
||||
package at.bitfire.davdroid.resource;
|
||||
|
||||
import android.accounts.Account;
|
||||
import android.content.ContentResolver;
|
||||
import android.content.ContentValues;
|
||||
import android.database.Cursor;
|
||||
import android.net.Uri;
|
||||
import android.os.RemoteException;
|
||||
|
||||
import org.dmfs.provider.tasks.TaskContract.TaskLists;
|
||||
import org.dmfs.provider.tasks.TaskContract.Tasks;
|
||||
|
||||
import java.io.FileNotFoundException;
|
||||
|
||||
import at.bitfire.ical4android.AndroidTaskList;
|
||||
import at.bitfire.ical4android.AndroidTaskListFactory;
|
||||
import at.bitfire.ical4android.CalendarStorageException;
|
||||
import at.bitfire.ical4android.TaskProvider;
|
||||
import lombok.Cleanup;
|
||||
import lombok.NonNull;
|
||||
|
||||
public class LocalTaskList extends AndroidTaskList implements LocalCollection {
|
||||
|
||||
public static final int defaultColor = 0xFFC3EA6E; // "DAVdroid green"
|
||||
|
||||
public static final String COLUMN_CTAG = TaskLists.SYNC_VERSION;
|
||||
|
||||
static String[] BASE_INFO_COLUMNS = new String[] {
|
||||
Tasks._ID,
|
||||
Tasks._SYNC_ID,
|
||||
LocalTask.COLUMN_ETAG
|
||||
};
|
||||
|
||||
// can be cached, because after installing OpenTasks, you have to re-install DAVdroid anyway
|
||||
private static Boolean tasksProviderAvailable;
|
||||
|
||||
|
||||
@Override
|
||||
protected String[] taskBaseInfoColumns() {
|
||||
return BASE_INFO_COLUMNS;
|
||||
}
|
||||
|
||||
|
||||
protected LocalTaskList(Account account, TaskProvider provider, long id) {
|
||||
super(account, provider, LocalTask.Factory.INSTANCE, id);
|
||||
}
|
||||
|
||||
public static Uri create(Account account, ContentResolver resolver, ServerInfo.ResourceInfo info) throws CalendarStorageException {
|
||||
TaskProvider provider = TaskProvider.acquire(resolver, TaskProvider.ProviderName.OpenTasks);
|
||||
if (provider == null)
|
||||
throw new CalendarStorageException("Couldn't access OpenTasks provider");
|
||||
|
||||
ContentValues values = new ContentValues();
|
||||
values.put(TaskLists._SYNC_ID, info.getUrl());
|
||||
values.put(TaskLists.LIST_NAME, info.getTitle());
|
||||
values.put(TaskLists.LIST_COLOR, info.color != null ? info.color : defaultColor);
|
||||
values.put(TaskLists.OWNER, account.name);
|
||||
values.put(TaskLists.SYNC_ENABLED, 1);
|
||||
values.put(TaskLists.VISIBLE, 1);
|
||||
|
||||
return create(account, provider, values);
|
||||
}
|
||||
|
||||
|
||||
@Override
|
||||
public LocalTask[] getAll() throws CalendarStorageException {
|
||||
return (LocalTask[])queryTasks(null, null);
|
||||
}
|
||||
|
||||
@Override
|
||||
public LocalTask[] getDeleted() throws CalendarStorageException {
|
||||
return (LocalTask[])queryTasks(Tasks._DELETED + "!=0", null);
|
||||
}
|
||||
|
||||
@Override
|
||||
public LocalTask[] getWithoutFileName() throws CalendarStorageException {
|
||||
return (LocalTask[])queryTasks(Tasks._SYNC_ID + " IS NULL", null);
|
||||
}
|
||||
|
||||
@Override
|
||||
public LocalResource[] getDirty() throws CalendarStorageException, FileNotFoundException {
|
||||
LocalTask[] tasks = (LocalTask[])queryTasks(Tasks._DIRTY + "!=0", null);
|
||||
if (tasks != null)
|
||||
for (LocalTask task : tasks) {
|
||||
if (task.getTask().sequence == null) // sequence has not been assigned yet (i.e. this task was just locally created)
|
||||
task.getTask().sequence = 0;
|
||||
else
|
||||
task.getTask().sequence++;
|
||||
}
|
||||
return tasks;
|
||||
}
|
||||
|
||||
|
||||
@Override
|
||||
@SuppressWarnings("Recycle")
|
||||
public String getCTag() throws CalendarStorageException {
|
||||
try {
|
||||
@Cleanup Cursor cursor = provider.client.query(taskListSyncUri(), new String[] { COLUMN_CTAG }, null, null, null);
|
||||
if (cursor != null && cursor.moveToNext())
|
||||
return cursor.getString(0);
|
||||
} catch (RemoteException e) {
|
||||
throw new CalendarStorageException("Couldn't read local (last known) CTag", e);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void setCTag(String cTag) throws CalendarStorageException {
|
||||
try {
|
||||
ContentValues values = new ContentValues(1);
|
||||
values.put(COLUMN_CTAG, cTag);
|
||||
provider.client.update(taskListSyncUri(), values, null, null);
|
||||
} catch (RemoteException e) {
|
||||
throw new CalendarStorageException("Couldn't write local (last known) CTag", e);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// helpers
|
||||
|
||||
public static boolean tasksProviderAvailable(@NonNull ContentResolver resolver) {
|
||||
if (tasksProviderAvailable != null)
|
||||
return tasksProviderAvailable;
|
||||
else {
|
||||
TaskProvider provider = TaskProvider.acquire(resolver, TaskProvider.ProviderName.OpenTasks);
|
||||
return tasksProviderAvailable = (provider != null);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
public static class Factory implements AndroidTaskListFactory {
|
||||
public static final Factory INSTANCE = new Factory();
|
||||
|
||||
@Override
|
||||
public AndroidTaskList newInstance(Account account, TaskProvider provider, long id) {
|
||||
return new LocalTaskList(account, provider, id);
|
||||
}
|
||||
|
||||
@Override
|
||||
public AndroidTaskList[] newArray(int size) {
|
||||
return new LocalTaskList[size];
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
/*
|
||||
* Copyright © Ricki Hirner (bitfire web engineering).
|
||||
* All rights reserved. This program and the accompanying materials
|
||||
* are made available under the terms of the GNU Public License v3.0
|
||||
* which accompanies this distribution, and is available at
|
||||
* http://www.gnu.org/licenses/gpl.html
|
||||
*/
|
||||
|
||||
package at.bitfire.davdroid.resource
|
||||
|
||||
import android.accounts.Account
|
||||
import android.content.ContentProviderClient
|
||||
import android.content.ContentResolver
|
||||
import android.content.ContentValues
|
||||
import android.content.Context
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import at.bitfire.davdroid.DavUtils
|
||||
import at.bitfire.davdroid.model.CollectionInfo
|
||||
import at.bitfire.ical4android.AndroidTaskList
|
||||
import at.bitfire.ical4android.AndroidTaskListFactory
|
||||
import at.bitfire.ical4android.CalendarStorageException
|
||||
import at.bitfire.ical4android.TaskProvider
|
||||
import org.dmfs.provider.tasks.TaskContract.TaskLists
|
||||
import org.dmfs.provider.tasks.TaskContract.Tasks
|
||||
import java.io.FileNotFoundException
|
||||
|
||||
class LocalTaskList private constructor(
|
||||
account: Account,
|
||||
provider: TaskProvider,
|
||||
id: Long
|
||||
): AndroidTaskList<LocalTask>(account, provider, LocalTask.Factory, id), LocalCollection<LocalTask> {
|
||||
|
||||
companion object {
|
||||
|
||||
val defaultColor = 0xFFC3EA6E.toInt() // "DAVdroid green"
|
||||
|
||||
val COLUMN_CTAG = TaskLists.SYNC_VERSION
|
||||
|
||||
val BASE_INFO_COLUMNS = arrayOf(
|
||||
Tasks._ID,
|
||||
Tasks._SYNC_ID,
|
||||
LocalTask.COLUMN_ETAG
|
||||
)
|
||||
|
||||
@JvmStatic
|
||||
fun tasksProviderAvailable(context: Context): Boolean {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M)
|
||||
return context.packageManager.resolveContentProvider(TaskProvider.ProviderName.OpenTasks.authority, 0) != null
|
||||
else {
|
||||
val provider = TaskProvider.acquire(context.contentResolver, TaskProvider.ProviderName.OpenTasks)
|
||||
provider?.use { return true }
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
@JvmStatic
|
||||
@Throws(CalendarStorageException::class)
|
||||
fun create(account: Account, provider: TaskProvider, info: CollectionInfo): Uri {
|
||||
val values = valuesFromCollectionInfo(info, true)
|
||||
values.put(TaskLists.OWNER, account.name)
|
||||
values.put(TaskLists.SYNC_ENABLED, 1)
|
||||
values.put(TaskLists.VISIBLE, 1)
|
||||
return create(account, provider, values)
|
||||
}
|
||||
|
||||
@JvmStatic
|
||||
@Throws(Exception::class)
|
||||
fun onRenameAccount(resolver: ContentResolver, oldName: String, newName: String) {
|
||||
var client: ContentProviderClient? = null
|
||||
try {
|
||||
client = resolver.acquireContentProviderClient(TaskProvider.ProviderName.OpenTasks.authority)
|
||||
client?.use {
|
||||
val values = ContentValues(1)
|
||||
values.put(Tasks.ACCOUNT_NAME, newName)
|
||||
it.update(Tasks.getContentUri(TaskProvider.ProviderName.OpenTasks.authority), values, "${Tasks.ACCOUNT_NAME}=?", arrayOf(oldName))
|
||||
}
|
||||
} finally {
|
||||
if (Build.VERSION.SDK_INT >= 24)
|
||||
client?.close()
|
||||
else
|
||||
client?.release()
|
||||
}
|
||||
}
|
||||
|
||||
private fun valuesFromCollectionInfo(info: CollectionInfo, withColor: Boolean): ContentValues {
|
||||
val values = ContentValues(3)
|
||||
values.put(TaskLists._SYNC_ID, info.url)
|
||||
values.put(TaskLists.LIST_NAME, if (info.displayName.isNullOrBlank()) DavUtils.lastSegmentOfUrl(info.url) else info.displayName)
|
||||
|
||||
if (withColor)
|
||||
values.put(TaskLists.LIST_COLOR, info.color ?: defaultColor)
|
||||
|
||||
return values
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
override fun taskBaseInfoColumns() = BASE_INFO_COLUMNS
|
||||
|
||||
|
||||
@Throws(CalendarStorageException::class)
|
||||
fun update(info: CollectionInfo, updateColor: Boolean) {
|
||||
update(valuesFromCollectionInfo(info, updateColor));
|
||||
}
|
||||
|
||||
|
||||
@Throws(CalendarStorageException::class)
|
||||
override fun getAll() = queryTasks(null, null)
|
||||
|
||||
@Throws(CalendarStorageException::class)
|
||||
override fun getDeleted() = queryTasks("${Tasks._DELETED}!=0", null)
|
||||
|
||||
@Throws(CalendarStorageException::class)
|
||||
override fun getWithoutFileName() = queryTasks("${Tasks._SYNC_ID} IS NULL", null)
|
||||
|
||||
@Throws(FileNotFoundException::class, CalendarStorageException::class)
|
||||
override fun getDirty(): List<LocalTask> {
|
||||
val tasks = queryTasks("${Tasks._DIRTY}!=0", null)
|
||||
for (localTask in tasks) {
|
||||
val task = requireNotNull(localTask.task)
|
||||
val sequence = task.sequence
|
||||
if (sequence == null) // sequence has not been assigned yet (i.e. this task was just locally created)
|
||||
task.sequence = 0
|
||||
else
|
||||
task.sequence = sequence + 1
|
||||
}
|
||||
return tasks
|
||||
}
|
||||
|
||||
|
||||
@Throws(CalendarStorageException::class)
|
||||
override fun getCTag(): String? =
|
||||
try {
|
||||
provider.client.query(taskListSyncUri(), arrayOf(COLUMN_CTAG), null, null, null)?.use { cursor ->
|
||||
if (cursor.moveToNext())
|
||||
return cursor.getString(0)
|
||||
}
|
||||
null
|
||||
} catch(e: Exception) {
|
||||
throw CalendarStorageException("Couldn't read local (last known) CTag", e)
|
||||
}
|
||||
|
||||
@Throws(CalendarStorageException::class)
|
||||
override fun setCTag(cTag: String?) {
|
||||
try {
|
||||
val values = ContentValues(1)
|
||||
values.put(COLUMN_CTAG, cTag)
|
||||
provider.client.update(taskListSyncUri(), values, null, null)
|
||||
} catch (e: Exception) {
|
||||
throw CalendarStorageException("Couldn't write local (last known) CTag", e)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
object Factory: AndroidTaskListFactory<LocalTaskList> {
|
||||
|
||||
override fun newInstance(account: Account, provider: TaskProvider, id: Long) =
|
||||
LocalTaskList(account, provider, id)
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,87 +0,0 @@
|
||||
/*
|
||||
* Copyright © 2013 – 2015 Ricki Hirner (bitfire web engineering).
|
||||
* All rights reserved. This program and the accompanying materials
|
||||
* are made available under the terms of the GNU Public License v3.0
|
||||
* which accompanies this distribution, and is available at
|
||||
* http://www.gnu.org/licenses/gpl.html
|
||||
*/
|
||||
package at.bitfire.davdroid.resource;
|
||||
|
||||
import java.io.Serializable;
|
||||
import java.net.URI;
|
||||
|
||||
import lombok.Data;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
|
||||
@RequiredArgsConstructor(suppressConstructorProperties=true)
|
||||
@Data
|
||||
public class ServerInfo implements Serializable {
|
||||
final private URI baseURI;
|
||||
final private String userName, password;
|
||||
final boolean authPreemptive;
|
||||
|
||||
private String logs;
|
||||
|
||||
private ResourceInfo
|
||||
addressBooks[] = new ResourceInfo[0],
|
||||
calendars[] = new ResourceInfo[0],
|
||||
taskLists[] = new ResourceInfo[0];
|
||||
|
||||
|
||||
public boolean hasEnabledCalendars() {
|
||||
for (ResourceInfo calendar : calendars)
|
||||
if (calendar.enabled)
|
||||
return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
public boolean isEmpty() {
|
||||
return addressBooks.length == 0 && calendars.length == 0 && taskLists.length == 0;
|
||||
}
|
||||
|
||||
|
||||
@RequiredArgsConstructor(suppressConstructorProperties=true)
|
||||
@Data
|
||||
public static class ResourceInfo implements Serializable {
|
||||
|
||||
public enum Type {
|
||||
ADDRESS_BOOK,
|
||||
CALENDAR
|
||||
}
|
||||
|
||||
boolean enabled = false;
|
||||
|
||||
final Type type;
|
||||
final boolean readOnly;
|
||||
|
||||
final String url, // absolute URL of resource
|
||||
title,
|
||||
description;
|
||||
final Integer color;
|
||||
|
||||
/** full VTIMEZONE definition (not the TZ ID) */
|
||||
String timezone;
|
||||
|
||||
|
||||
// copy constructor
|
||||
|
||||
@Override
|
||||
public ResourceInfo clone() {
|
||||
return new ResourceInfo(this);
|
||||
}
|
||||
|
||||
private ResourceInfo(ResourceInfo src) {
|
||||
enabled = src.enabled;
|
||||
type = src.type;
|
||||
readOnly = src.readOnly;
|
||||
|
||||
url = src.url;
|
||||
title = src.title;
|
||||
description = src.description;
|
||||
color = src.color;
|
||||
|
||||
timezone = src.timezone;
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
@@ -1,88 +0,0 @@
|
||||
/*
|
||||
* Copyright © 2013 – 2015 Ricki Hirner (bitfire web engineering).
|
||||
* All rights reserved. This program and the accompanying materials
|
||||
* are made available under the terms of the GNU Public License v3.0
|
||||
* which accompanies this distribution, and is available at
|
||||
* http://www.gnu.org/licenses/gpl.html
|
||||
*/
|
||||
package at.bitfire.davdroid.syncadapter;
|
||||
|
||||
import android.accounts.AbstractAccountAuthenticator;
|
||||
import android.accounts.Account;
|
||||
import android.accounts.AccountAuthenticatorResponse;
|
||||
import android.accounts.AccountManager;
|
||||
import android.accounts.NetworkErrorException;
|
||||
import android.app.Service;
|
||||
import android.content.Context;
|
||||
import android.content.Intent;
|
||||
import android.os.Bundle;
|
||||
import android.os.IBinder;
|
||||
|
||||
import at.bitfire.davdroid.ui.setup.AddAccountActivity;
|
||||
|
||||
public class AccountAuthenticatorService extends Service {
|
||||
private static AccountAuthenticator accountAuthenticator;
|
||||
|
||||
private AccountAuthenticator getAuthenticator() {
|
||||
if (accountAuthenticator != null)
|
||||
return accountAuthenticator;
|
||||
return accountAuthenticator = new AccountAuthenticator(this);
|
||||
}
|
||||
|
||||
@Override
|
||||
public IBinder onBind(Intent intent) {
|
||||
if (intent.getAction().equals(android.accounts.AccountManager.ACTION_AUTHENTICATOR_INTENT))
|
||||
return getAuthenticator().getIBinder();
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
private static class AccountAuthenticator extends AbstractAccountAuthenticator {
|
||||
final Context context;
|
||||
|
||||
public AccountAuthenticator(Context context) {
|
||||
super(context);
|
||||
this.context = context;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Bundle addAccount(AccountAuthenticatorResponse response, String accountType, String authTokenType,
|
||||
String[] requiredFeatures, Bundle options) throws NetworkErrorException {
|
||||
Intent intent = new Intent(context, AddAccountActivity.class);
|
||||
intent.putExtra(AccountManager.KEY_ACCOUNT_AUTHENTICATOR_RESPONSE, response);
|
||||
Bundle bundle = new Bundle();
|
||||
bundle.putParcelable(AccountManager.KEY_INTENT, intent);
|
||||
return bundle;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Bundle confirmCredentials(AccountAuthenticatorResponse response, Account account, Bundle options) throws NetworkErrorException {
|
||||
return null;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Bundle editProperties(AccountAuthenticatorResponse response, String accountType) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Bundle getAuthToken(AccountAuthenticatorResponse response, Account account, String authTokenType, Bundle options) throws NetworkErrorException {
|
||||
return null;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getAuthTokenLabel(String authTokenType) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Bundle hasFeatures(AccountAuthenticatorResponse response, Account account, String[] features) throws NetworkErrorException {
|
||||
return null;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Bundle updateCredentials(AccountAuthenticatorResponse response, Account account, String authTokenType, Bundle options) throws NetworkErrorException {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
/*
|
||||
* Copyright © Ricki Hirner (bitfire web engineering).
|
||||
* All rights reserved. This program and the accompanying materials
|
||||
* are made available under the terms of the GNU Public License v3.0
|
||||
* which accompanies this distribution, and is available at
|
||||
* http://www.gnu.org/licenses/gpl.html
|
||||
*/
|
||||
package at.bitfire.davdroid.syncadapter
|
||||
|
||||
import android.accounts.AbstractAccountAuthenticator
|
||||
import android.accounts.Account
|
||||
import android.accounts.AccountAuthenticatorResponse
|
||||
import android.accounts.AccountManager
|
||||
import android.app.Service
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.os.Bundle
|
||||
import at.bitfire.davdroid.ui.setup.LoginActivity
|
||||
|
||||
class AccountAuthenticatorService: Service() {
|
||||
|
||||
private lateinit var accountAuthenticator: AccountAuthenticator
|
||||
|
||||
override fun onCreate() {
|
||||
accountAuthenticator = AccountAuthenticator(this)
|
||||
}
|
||||
|
||||
override fun onBind(intent: Intent?) =
|
||||
accountAuthenticator.iBinder.takeIf { intent?.action == android.accounts.AccountManager.ACTION_AUTHENTICATOR_INTENT }
|
||||
|
||||
|
||||
private class AccountAuthenticator(
|
||||
val context: Context
|
||||
): AbstractAccountAuthenticator(context) {
|
||||
|
||||
override fun addAccount(response: AccountAuthenticatorResponse?, accountType: String?, authTokenType: String?, requiredFeatures: Array<String>?, options: Bundle?): Bundle {
|
||||
val intent = Intent(context, LoginActivity::class.java)
|
||||
intent.putExtra(AccountManager.KEY_ACCOUNT_AUTHENTICATOR_RESPONSE, response)
|
||||
val bundle = Bundle(1)
|
||||
bundle.putParcelable(AccountManager.KEY_INTENT, intent)
|
||||
return bundle
|
||||
}
|
||||
|
||||
override fun editProperties(response: AccountAuthenticatorResponse?, accountType: String?) = null
|
||||
override fun getAuthTokenLabel(p0: String?) = null
|
||||
override fun confirmCredentials(p0: AccountAuthenticatorResponse?, p1: Account?, p2: Bundle?) = null
|
||||
override fun updateCredentials(p0: AccountAuthenticatorResponse?, p1: Account?, p2: String?, p3: Bundle?) = null
|
||||
override fun getAuthToken(p0: AccountAuthenticatorResponse?, p1: Account?, p2: String?, p3: Bundle?) = null
|
||||
override fun hasFeatures(p0: AccountAuthenticatorResponse?, p1: Account?, p2: Array<out String>?) = null
|
||||
|
||||
}
|
||||
}
|
||||
@@ -1,271 +0,0 @@
|
||||
/*
|
||||
* Copyright © 2013 – 2015 Ricki Hirner (bitfire web engineering).
|
||||
* All rights reserved. This program and the accompanying materials
|
||||
* are made available under the terms of the GNU Public License v3.0
|
||||
* which accompanies this distribution, and is available at
|
||||
* http://www.gnu.org/licenses/gpl.html
|
||||
*/
|
||||
package at.bitfire.davdroid.syncadapter;
|
||||
|
||||
import android.accounts.Account;
|
||||
import android.accounts.AccountManager;
|
||||
import android.annotation.TargetApi;
|
||||
import android.app.Notification;
|
||||
import android.app.NotificationManager;
|
||||
import android.content.ContentProviderClient;
|
||||
import android.content.ContentResolver;
|
||||
import android.content.ContentUris;
|
||||
import android.content.ContentValues;
|
||||
import android.content.Context;
|
||||
import android.content.PeriodicSync;
|
||||
import android.database.Cursor;
|
||||
import android.net.Uri;
|
||||
import android.os.Build;
|
||||
import android.os.Bundle;
|
||||
import android.provider.CalendarContract;
|
||||
import android.provider.CalendarContract.Calendars;
|
||||
import android.provider.ContactsContract;
|
||||
import android.text.TextUtils;
|
||||
|
||||
import org.apache.commons.lang3.math.NumberUtils;
|
||||
|
||||
import java.net.URI;
|
||||
import java.net.URISyntaxException;
|
||||
import java.util.List;
|
||||
|
||||
import at.bitfire.davdroid.Constants;
|
||||
import at.bitfire.davdroid.R;
|
||||
import at.bitfire.davdroid.resource.LocalAddressBook;
|
||||
import at.bitfire.davdroid.resource.ServerInfo;
|
||||
import at.bitfire.vcard4android.ContactsStorageException;
|
||||
import lombok.Cleanup;
|
||||
|
||||
public class AccountSettings {
|
||||
private final static int CURRENT_VERSION = 2;
|
||||
private final static String
|
||||
KEY_SETTINGS_VERSION = "version",
|
||||
|
||||
KEY_USERNAME = "user_name",
|
||||
KEY_AUTH_PREEMPTIVE = "auth_preemptive",
|
||||
KEY_LOG_TO_EXTERNAL_FILE = "log_external_file",
|
||||
KEY_LOG_VERBOSE = "log_verbose",
|
||||
KEY_LAST_ANDROID_VERSION = "last_android_version";
|
||||
|
||||
public final static long SYNC_INTERVAL_MANUALLY = -1;
|
||||
|
||||
final Context context;
|
||||
final AccountManager accountManager;
|
||||
final Account account;
|
||||
|
||||
|
||||
public AccountSettings(Context context, Account account) {
|
||||
this.context = context;
|
||||
this.account = account;
|
||||
|
||||
accountManager = AccountManager.get(context);
|
||||
|
||||
synchronized(AccountSettings.class) {
|
||||
int version = 0;
|
||||
try {
|
||||
version = Integer.parseInt(accountManager.getUserData(account, KEY_SETTINGS_VERSION));
|
||||
} catch(NumberFormatException e) {
|
||||
}
|
||||
Constants.log.info("AccountSettings version: v" + version + ", should be: " + version);
|
||||
|
||||
if (version < CURRENT_VERSION) {
|
||||
showNotification(Constants.NOTIFICATION_ACCOUNT_SETTINGS_UPDATED,
|
||||
context.getString(R.string.settings_version_update_title),
|
||||
context.getString(R.string.settings_version_update_description));
|
||||
update(version);
|
||||
}
|
||||
|
||||
// check whether Android version has changed
|
||||
String lastAndroidVersionInt = accountManager.getUserData(account, KEY_LAST_ANDROID_VERSION);
|
||||
if (lastAndroidVersionInt != null && NumberUtils.toInt(lastAndroidVersionInt) < Build.VERSION.SDK_INT) {
|
||||
// notify user
|
||||
showNotification(Constants.NOTIFICATION_ANDROID_VERSION_UPDATED,
|
||||
context.getString(R.string.settings_android_update_title),
|
||||
context.getString(R.string.settings_android_update_description));
|
||||
}
|
||||
accountManager.setUserData(account, KEY_LAST_ANDROID_VERSION, String.valueOf(Build.VERSION.SDK_INT));
|
||||
}
|
||||
}
|
||||
|
||||
@TargetApi(21)
|
||||
protected void showNotification(int id, String title, String message) {
|
||||
NotificationManager nm = (NotificationManager)context.getSystemService(Context.NOTIFICATION_SERVICE);
|
||||
Notification.Builder n = new Notification.Builder(context);
|
||||
if (Build.VERSION.SDK_INT >= 16) {
|
||||
n.setPriority(Notification.PRIORITY_HIGH);
|
||||
n.setStyle(new Notification.BigTextStyle().bigText(message));
|
||||
} if (Build.VERSION.SDK_INT >= 20)
|
||||
n.setLocalOnly(true);
|
||||
if (Build.VERSION.SDK_INT >= 21)
|
||||
n.setCategory(Notification.CATEGORY_SYSTEM);
|
||||
n.setSmallIcon(R.drawable.ic_launcher);
|
||||
n.setContentTitle(title);
|
||||
n.setContentText(message);
|
||||
nm.notify(id, Build.VERSION.SDK_INT >= 16 ? n.build() : n.getNotification());
|
||||
}
|
||||
|
||||
|
||||
public static Bundle createBundle(ServerInfo serverInfo) {
|
||||
Bundle bundle = new Bundle();
|
||||
bundle.putString(KEY_SETTINGS_VERSION, String.valueOf(CURRENT_VERSION));
|
||||
bundle.putString(KEY_USERNAME, serverInfo.getUserName());
|
||||
bundle.putString(KEY_AUTH_PREEMPTIVE, Boolean.toString(serverInfo.isAuthPreemptive()));
|
||||
return bundle;
|
||||
}
|
||||
|
||||
|
||||
// authentication settings
|
||||
|
||||
public String username() { return accountManager.getUserData(account, KEY_USERNAME); }
|
||||
public void username(String userName) { accountManager.setUserData(account, KEY_USERNAME, userName); }
|
||||
|
||||
public String password() { return accountManager.getPassword(account); }
|
||||
public void password(String password) { accountManager.setPassword(account, password); }
|
||||
|
||||
public boolean preemptiveAuth() { return Boolean.parseBoolean(accountManager.getUserData(account, KEY_AUTH_PREEMPTIVE)); }
|
||||
public void preemptiveAuth(boolean preemptive) { accountManager.setUserData(account, KEY_AUTH_PREEMPTIVE, Boolean.toString(preemptive)); }
|
||||
|
||||
|
||||
// logging settings
|
||||
|
||||
public boolean logToExternalFile() { return Boolean.parseBoolean(accountManager.getUserData(account, KEY_LOG_TO_EXTERNAL_FILE)); }
|
||||
public void logToExternalFile(boolean newValue) { accountManager.setUserData(account, KEY_LOG_TO_EXTERNAL_FILE, Boolean.toString(newValue)); }
|
||||
|
||||
public boolean logVerbose() { return Boolean.parseBoolean(accountManager.getUserData(account, KEY_LOG_VERBOSE)); }
|
||||
public void logVerbose(boolean newValue) { accountManager.setUserData(account, KEY_LOG_VERBOSE, Boolean.toString(newValue)); }
|
||||
|
||||
|
||||
// sync. settings
|
||||
|
||||
public Long getSyncInterval(String authority) {
|
||||
if (ContentResolver.getIsSyncable(account, authority) <= 0)
|
||||
return null;
|
||||
|
||||
if (ContentResolver.getSyncAutomatically(account, authority)) {
|
||||
List<PeriodicSync> syncs = ContentResolver.getPeriodicSyncs(account, authority);
|
||||
if (syncs.isEmpty())
|
||||
return SYNC_INTERVAL_MANUALLY;
|
||||
else
|
||||
return syncs.get(0).period;
|
||||
} else
|
||||
return SYNC_INTERVAL_MANUALLY;
|
||||
}
|
||||
|
||||
public void setSyncInterval(String authority, long seconds) {
|
||||
if (seconds == SYNC_INTERVAL_MANUALLY) {
|
||||
ContentResolver.setSyncAutomatically(account, authority, false);
|
||||
} else {
|
||||
ContentResolver.setSyncAutomatically(account, authority, true);
|
||||
ContentResolver.addPeriodicSync(account, authority, new Bundle(), seconds);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// update from previous account settings
|
||||
|
||||
private void update(int fromVersion) {
|
||||
for (int toVersion = fromVersion + 1; toVersion <= CURRENT_VERSION; toVersion++)
|
||||
updateTo(toVersion);
|
||||
}
|
||||
|
||||
private void updateTo(int toVersion) {
|
||||
final int fromVersion = toVersion - 1;
|
||||
Constants.log.info("Updating account settings from v" + fromVersion + " to " + toVersion);
|
||||
try {
|
||||
switch (toVersion) {
|
||||
case 1:
|
||||
update_0_1();
|
||||
break;
|
||||
case 2:
|
||||
update_1_2();
|
||||
break;
|
||||
default:
|
||||
Constants.log.error("Don't know how to update settings from v" + fromVersion + " to v" + toVersion);
|
||||
}
|
||||
} catch(Exception e) {
|
||||
Constants.log.error("Couldn't update account settings (DAVdroid will probably crash)!", e);
|
||||
}
|
||||
}
|
||||
|
||||
@SuppressWarnings("Recycle")
|
||||
private void update_0_1() throws URISyntaxException {
|
||||
String v0_principalURL = accountManager.getUserData(account, "principal_url"),
|
||||
v0_addressBookPath = accountManager.getUserData(account, "addressbook_path");
|
||||
Constants.log.debug("Old principal URL = " + v0_principalURL);
|
||||
Constants.log.debug("Old address book path = " + v0_addressBookPath);
|
||||
|
||||
URI principalURI = new URI(v0_principalURL);
|
||||
|
||||
// update address book
|
||||
if (v0_addressBookPath != null) {
|
||||
String addressBookURL = principalURI.resolve(v0_addressBookPath).toASCIIString();
|
||||
Constants.log.debug("New address book URL = " + addressBookURL);
|
||||
accountManager.setUserData(account, "addressbook_url", addressBookURL);
|
||||
}
|
||||
|
||||
// update calendars
|
||||
ContentResolver resolver = context.getContentResolver();
|
||||
Uri calendars = Calendars.CONTENT_URI.buildUpon()
|
||||
.appendQueryParameter(Calendars.ACCOUNT_NAME, account.name)
|
||||
.appendQueryParameter(Calendars.ACCOUNT_TYPE, account.type)
|
||||
.appendQueryParameter(CalendarContract.CALLER_IS_SYNCADAPTER, "true").build();
|
||||
@Cleanup Cursor cursor = resolver.query(calendars, new String[] { Calendars._ID, Calendars.NAME }, null, null, null);
|
||||
while (cursor != null && cursor.moveToNext()) {
|
||||
int id = cursor.getInt(0);
|
||||
String v0_path = cursor.getString(1),
|
||||
v1_url = principalURI.resolve(v0_path).toASCIIString();
|
||||
Constants.log.debug("Updating calendar #" + id + " name: " + v0_path + " -> " + v1_url);
|
||||
Uri calendar = ContentUris.appendId(Calendars.CONTENT_URI.buildUpon()
|
||||
.appendQueryParameter(Calendars.ACCOUNT_NAME, account.name)
|
||||
.appendQueryParameter(Calendars.ACCOUNT_TYPE, account.type)
|
||||
.appendQueryParameter(CalendarContract.CALLER_IS_SYNCADAPTER, "true"), id).build();
|
||||
ContentValues newValues = new ContentValues(1);
|
||||
newValues.put(Calendars.NAME, v1_url);
|
||||
if (resolver.update(calendar, newValues, null, null) != 1)
|
||||
Constants.log.debug("Number of modified calendars != 1");
|
||||
}
|
||||
|
||||
accountManager.setUserData(account, "principal_url", null);
|
||||
accountManager.setUserData(account, "addressbook_path", null);
|
||||
|
||||
accountManager.setUserData(account, KEY_SETTINGS_VERSION, "1");
|
||||
}
|
||||
|
||||
@SuppressWarnings("Recycle")
|
||||
private void update_1_2() throws ContactsStorageException {
|
||||
/* - KEY_ADDRESSBOOK_URL ("addressbook_url"),
|
||||
- KEY_ADDRESSBOOK_CTAG ("addressbook_ctag"),
|
||||
- KEY_ADDRESSBOOK_VCARD_VERSION ("addressbook_vcard_version") are not used anymore (now stored in ContactsContract.SyncState)
|
||||
- KEY_LAST_ANDROID_VERSION ("last_android_version") has been added
|
||||
*/
|
||||
|
||||
// move previous address book info to ContactsContract.SyncState
|
||||
@Cleanup("release") ContentProviderClient provider = context.getContentResolver().acquireContentProviderClient(ContactsContract.AUTHORITY);
|
||||
if (provider == null)
|
||||
throw new ContactsStorageException("Couldn't access Contacts provider");
|
||||
|
||||
LocalAddressBook addr = new LocalAddressBook(account, provider);
|
||||
|
||||
// until now, ContactsContract.Settings.UNGROUPED_VISIBLE was not set explicitly
|
||||
ContentValues values = new ContentValues();
|
||||
values.put(ContactsContract.Settings.UNGROUPED_VISIBLE, 1);
|
||||
addr.updateSettings(values);
|
||||
|
||||
String url = accountManager.getUserData(account, "addressbook_url");
|
||||
if (!TextUtils.isEmpty(url))
|
||||
addr.setURL(url);
|
||||
accountManager.setUserData(account, "addressbook_url", null);
|
||||
|
||||
String cTag = accountManager.getUserData(account, "addressbook_ctag");
|
||||
if (!TextUtils.isEmpty(cTag))
|
||||
addr.setCTag(cTag);
|
||||
accountManager.setUserData(account, "addressbook_ctag", null);
|
||||
|
||||
accountManager.setUserData(account, KEY_SETTINGS_VERSION, "2");
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
/*
|
||||
* Copyright © Ricki Hirner (bitfire web engineering).
|
||||
* All rights reserved. This program and the accompanying materials
|
||||
* are made available under the terms of the GNU Public License v3.0
|
||||
* which accompanies this distribution, and is available at
|
||||
* http://www.gnu.org/licenses/gpl.html
|
||||
*/
|
||||
|
||||
package at.bitfire.davdroid.syncadapter
|
||||
|
||||
import android.content.ContentProvider
|
||||
import android.content.ContentValues
|
||||
import android.net.Uri
|
||||
|
||||
class AddressBookProvider: ContentProvider() {
|
||||
|
||||
override fun onCreate() = false
|
||||
override fun insert(p0: Uri?, p1: ContentValues?) = null
|
||||
override fun query(p0: Uri?, p1: Array<out String>?, p2: String?, p3: Array<out String>?, p4: String?) = null
|
||||
override fun update(p0: Uri?, p1: ContentValues?, p2: String?, p3: Array<out String>?) = 0
|
||||
override fun delete(p0: Uri?, p1: String?, p2: Array<out String>?) = 0
|
||||
override fun getType(p0: Uri?) = null
|
||||
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
/*
|
||||
* Copyright © Ricki Hirner (bitfire web engineering).
|
||||
* All rights reserved. This program and the accompanying materials
|
||||
* are made available under the terms of the GNU Public License v3.0
|
||||
* which accompanies this distribution, and is available at
|
||||
* http://www.gnu.org/licenses/gpl.html
|
||||
*/
|
||||
package at.bitfire.davdroid.syncadapter
|
||||
|
||||
import android.accounts.Account
|
||||
import android.accounts.AccountManager
|
||||
import android.content.*
|
||||
import android.database.DatabaseUtils
|
||||
import android.os.Bundle
|
||||
import android.provider.ContactsContract
|
||||
import at.bitfire.davdroid.AccountSettings
|
||||
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.resource.LocalAddressBook
|
||||
import at.bitfire.vcard4android.ContactsStorageException
|
||||
import java.util.logging.Level
|
||||
|
||||
class AddressBooksSyncAdapterService: SyncAdapterService() {
|
||||
|
||||
override fun syncAdapter() = AddressBooksSyncAdapter(this)
|
||||
|
||||
|
||||
protected class AddressBooksSyncAdapter(
|
||||
context: Context
|
||||
): SyncAdapter(context) {
|
||||
|
||||
override fun sync(account: Account, extras: Bundle, authority: String, addressBooksProvider: ContentProviderClient, syncResult: SyncResult) {
|
||||
val contactsProvider = context.contentResolver.acquireContentProviderClient(ContactsContract.AUTHORITY)
|
||||
if (contactsProvider == null) {
|
||||
Logger.log.severe("Couldn't access contacts provider")
|
||||
syncResult.databaseError = true
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
val settings = AccountSettings(context, account)
|
||||
|
||||
/* don't run sync if
|
||||
- sync conditions (e.g. "sync only in WiFi") are not met AND
|
||||
- this is is an automatic sync (i.e. manual syncs are run regardless of sync conditions)
|
||||
*/
|
||||
if (!extras.containsKey(ContentResolver.SYNC_EXTRAS_MANUAL) && !checkSyncConditions(settings))
|
||||
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) {
|
||||
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
|
||||
|
||||
fun getService() =
|
||||
db.query(ServiceDB.Services._TABLE, arrayOf(ServiceDB.Services.ID),
|
||||
"${ServiceDB.Services.ACCOUNT_NAME}=? AND ${ServiceDB.Services.SERVICE}=?",
|
||||
arrayOf(account.name, ServiceDB.Services.SERVICE_CARDDAV), null, null, null)?.use { c ->
|
||||
if (c.moveToNext())
|
||||
c.getLong(0)
|
||||
else
|
||||
null
|
||||
}
|
||||
|
||||
fun remoteAddressBooks(service: Long?): MutableMap<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
|
||||
}
|
||||
|
||||
// enumerate remote and local address books
|
||||
val service = getService()
|
||||
val remote = remoteAddressBooks(service)
|
||||
|
||||
// delete/update local address books
|
||||
for (addressBook in LocalAddressBook.find(context, provider, account)) {
|
||||
val url = addressBook.getURL()
|
||||
val info = remote[url]
|
||||
if (info == null) {
|
||||
Logger.log.log(Level.INFO, "Deleting obsolete local address book", url)
|
||||
addressBook.delete()
|
||||
} else {
|
||||
// remote CollectionInfo found for this local collection, update data
|
||||
try {
|
||||
Logger.log.log(Level.FINE, "Updating local address book $url", info)
|
||||
addressBook.update(info)
|
||||
} catch(e: ContactsStorageException) {
|
||||
Logger.log.log(Level.WARNING, "Couldn't rename address book account", e)
|
||||
}
|
||||
// we already have a local address book for this remote collection, don't take into consideration anymore
|
||||
remote -= url
|
||||
}
|
||||
}
|
||||
|
||||
// create new local address books
|
||||
for ((_, info) in remote) {
|
||||
Logger.log.log(Level.INFO, "Adding local address book", info)
|
||||
LocalAddressBook.create(context, provider, account, info)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,222 +0,0 @@
|
||||
/*
|
||||
* Copyright © 2013 – 2015 Ricki Hirner (bitfire web engineering).
|
||||
* All rights reserved. This program and the accompanying materials
|
||||
* are made available under the terms of the GNU Public License v3.0
|
||||
* which accompanies this distribution, and is available at
|
||||
* http://www.gnu.org/licenses/gpl.html
|
||||
*/
|
||||
|
||||
package at.bitfire.davdroid.syncadapter;
|
||||
|
||||
import android.accounts.Account;
|
||||
import android.content.ContentValues;
|
||||
import android.content.Context;
|
||||
import android.content.SyncResult;
|
||||
import android.os.Bundle;
|
||||
import android.provider.CalendarContract.Calendars;
|
||||
import android.text.TextUtils;
|
||||
|
||||
import com.squareup.okhttp.HttpUrl;
|
||||
import com.squareup.okhttp.MediaType;
|
||||
import com.squareup.okhttp.RequestBody;
|
||||
import com.squareup.okhttp.ResponseBody;
|
||||
|
||||
import org.apache.commons.codec.Charsets;
|
||||
import org.apache.commons.lang3.StringUtils;
|
||||
|
||||
import java.io.ByteArrayInputStream;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.nio.charset.Charset;
|
||||
import java.util.HashMap;
|
||||
import java.util.LinkedList;
|
||||
import java.util.List;
|
||||
|
||||
import at.bitfire.dav4android.DavCalendar;
|
||||
import at.bitfire.dav4android.DavResource;
|
||||
import at.bitfire.dav4android.exception.DavException;
|
||||
import at.bitfire.dav4android.exception.HttpException;
|
||||
import at.bitfire.dav4android.property.CalendarColor;
|
||||
import at.bitfire.dav4android.property.CalendarData;
|
||||
import at.bitfire.dav4android.property.DisplayName;
|
||||
import at.bitfire.dav4android.property.GetCTag;
|
||||
import at.bitfire.dav4android.property.GetContentType;
|
||||
import at.bitfire.dav4android.property.GetETag;
|
||||
import at.bitfire.davdroid.ArrayUtils;
|
||||
import at.bitfire.davdroid.Constants;
|
||||
import at.bitfire.davdroid.R;
|
||||
import at.bitfire.davdroid.resource.LocalCalendar;
|
||||
import at.bitfire.davdroid.resource.LocalEvent;
|
||||
import at.bitfire.davdroid.resource.LocalResource;
|
||||
import at.bitfire.ical4android.CalendarStorageException;
|
||||
import at.bitfire.ical4android.Event;
|
||||
import at.bitfire.ical4android.InvalidCalendarException;
|
||||
import at.bitfire.vcard4android.ContactsStorageException;
|
||||
import lombok.Cleanup;
|
||||
|
||||
public class CalendarSyncManager extends SyncManager {
|
||||
|
||||
protected static final int MAX_MULTIGET = 20;
|
||||
|
||||
|
||||
public CalendarSyncManager(Context context, Account account, Bundle extras, String authority, SyncResult result, LocalCalendar calendar) {
|
||||
super(Constants.NOTIFICATION_CALENDAR_SYNC, context, account, extras, authority, result);
|
||||
localCollection = calendar;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected String getSyncErrorTitle() {
|
||||
return context.getString(R.string.sync_error_calendar, account.name);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void prepare() {
|
||||
Thread.currentThread().setContextClassLoader(context.getClassLoader()); // required for ical4j
|
||||
|
||||
collectionURL = HttpUrl.parse(localCalendar().getName());
|
||||
davCollection = new DavCalendar(log, httpClient, collectionURL);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void queryCapabilities() throws DavException, IOException, HttpException, CalendarStorageException {
|
||||
davCollection.propfind(0, DisplayName.NAME, CalendarColor.NAME, GetCTag.NAME);
|
||||
|
||||
// update name and color
|
||||
DisplayName pDisplayName = (DisplayName)davCollection.properties.get(DisplayName.NAME);
|
||||
String displayName = (pDisplayName != null && !TextUtils.isEmpty(pDisplayName.displayName)) ?
|
||||
pDisplayName.displayName : collectionURL.toString();
|
||||
|
||||
CalendarColor pColor = (CalendarColor)davCollection.properties.get(CalendarColor.NAME);
|
||||
int color = (pColor != null && pColor.color != null) ? pColor.color : LocalCalendar.defaultColor;
|
||||
|
||||
ContentValues values = new ContentValues(2);
|
||||
log.info("Setting new calendar name \"" + displayName + "\" and color 0x" + Integer.toHexString(color));
|
||||
values.put(Calendars.CALENDAR_DISPLAY_NAME, displayName);
|
||||
values.put(Calendars.CALENDAR_COLOR, color);
|
||||
localCalendar().update(values);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void prepareDirty() throws CalendarStorageException, ContactsStorageException {
|
||||
super.prepareDirty();
|
||||
|
||||
localCalendar().processDirtyExceptions();
|
||||
}
|
||||
|
||||
@Override
|
||||
protected RequestBody prepareUpload(LocalResource resource) throws IOException, CalendarStorageException {
|
||||
LocalEvent local = (LocalEvent)resource;
|
||||
return RequestBody.create(
|
||||
DavCalendar.MIME_ICALENDAR,
|
||||
local.getEvent().toStream().toByteArray()
|
||||
);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void listRemote() throws IOException, HttpException, DavException {
|
||||
// fetch list of remote VEVENTs and build hash table to index file name
|
||||
davCalendar().calendarQuery("VEVENT");
|
||||
remoteResources = new HashMap<>(davCollection.members.size());
|
||||
for (DavResource iCal : davCollection.members) {
|
||||
String fileName = iCal.fileName();
|
||||
log.debug("Found remote VEVENT: " + fileName);
|
||||
remoteResources.put(fileName, iCal);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void downloadRemote() throws IOException, HttpException, DavException, CalendarStorageException {
|
||||
log.info("Downloading " + toDownload.size() + " events (" + MAX_MULTIGET + " at once)");
|
||||
|
||||
// download new/updated iCalendars from server
|
||||
for (DavResource[] bunch : ArrayUtils.partition(toDownload.toArray(new DavResource[toDownload.size()]), MAX_MULTIGET)) {
|
||||
if (Thread.interrupted())
|
||||
return;
|
||||
log.info("Downloading " + StringUtils.join(bunch, ", "));
|
||||
|
||||
if (bunch.length == 1) {
|
||||
// only one contact, use GET
|
||||
DavResource remote = bunch[0];
|
||||
|
||||
ResponseBody body = remote.get("text/calendar");
|
||||
String eTag = ((GetETag)remote.properties.get(GetETag.NAME)).eTag;
|
||||
|
||||
Charset charset = Charsets.UTF_8;
|
||||
MediaType contentType = body.contentType();
|
||||
if (contentType != null)
|
||||
charset = contentType.charset(Charsets.UTF_8);
|
||||
|
||||
@Cleanup InputStream stream = body.byteStream();
|
||||
processVEvent(remote.fileName(), eTag, stream, charset);
|
||||
|
||||
} else {
|
||||
// multiple contacts, use multi-get
|
||||
List<HttpUrl> urls = new LinkedList<>();
|
||||
for (DavResource remote : bunch)
|
||||
urls.add(remote.location);
|
||||
davCalendar().multiget(urls.toArray(new HttpUrl[urls.size()]));
|
||||
|
||||
// process multiget results
|
||||
for (DavResource remote : davCollection.members) {
|
||||
String eTag;
|
||||
GetETag getETag = (GetETag)remote.properties.get(GetETag.NAME);
|
||||
if (getETag != null)
|
||||
eTag = getETag.eTag;
|
||||
else
|
||||
throw new DavException("Received multi-get response without ETag");
|
||||
|
||||
Charset charset = Charsets.UTF_8;
|
||||
GetContentType getContentType = (GetContentType)remote.properties.get(GetContentType.NAME);
|
||||
if (getContentType != null && getContentType.type != null) {
|
||||
MediaType type = MediaType.parse(getContentType.type);
|
||||
if (type != null)
|
||||
charset = type.charset(Charsets.UTF_8);
|
||||
}
|
||||
|
||||
CalendarData calendarData = (CalendarData)remote.properties.get(CalendarData.NAME);
|
||||
if (calendarData == null || calendarData.iCalendar == null)
|
||||
throw new DavException("Received multi-get response without address data");
|
||||
|
||||
@Cleanup InputStream stream = new ByteArrayInputStream(calendarData.iCalendar.getBytes());
|
||||
processVEvent(remote.fileName(), eTag, stream, charset);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// helpers
|
||||
|
||||
private LocalCalendar localCalendar() { return ((LocalCalendar)localCollection); }
|
||||
private DavCalendar davCalendar() { return (DavCalendar)davCollection; }
|
||||
|
||||
private void processVEvent(String fileName, String eTag, InputStream stream, Charset charset) throws IOException, CalendarStorageException {
|
||||
Event[] events;
|
||||
try {
|
||||
events = Event.fromStream(stream, charset);
|
||||
} catch (InvalidCalendarException e) {
|
||||
log.error("Received invalid iCalendar, ignoring");
|
||||
return;
|
||||
}
|
||||
|
||||
if (events != null && events.length == 1) {
|
||||
Event newData = events[0];
|
||||
|
||||
// delete local event, if it exists
|
||||
LocalEvent localEvent = (LocalEvent)localResources.get(fileName);
|
||||
if (localEvent != null) {
|
||||
log.info("Updating " + fileName + " in local calendar");
|
||||
localEvent.setETag(eTag);
|
||||
localEvent.update(newData);
|
||||
syncResult.stats.numUpdates++;
|
||||
} else {
|
||||
log.info("Adding " + fileName + " to local calendar");
|
||||
localEvent = new LocalEvent(localCalendar(), newData, fileName, eTag);
|
||||
localEvent.add();
|
||||
syncResult.stats.numInserts++;
|
||||
}
|
||||
} else
|
||||
log.error("Received VCALENDAR with not exactly one VEVENT with UID, but without RECURRENCE-ID; ignoring " + fileName);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,210 @@
|
||||
/*
|
||||
* Copyright © Ricki Hirner (bitfire web engineering).
|
||||
* All rights reserved. This program and the accompanying materials
|
||||
* are made available under the terms of the GNU Public License v3.0
|
||||
* which accompanies this distribution, and is available at
|
||||
* http://www.gnu.org/licenses/gpl.html
|
||||
*/
|
||||
|
||||
package at.bitfire.davdroid.syncadapter
|
||||
|
||||
import android.accounts.Account
|
||||
import android.content.ContentProviderClient
|
||||
import android.content.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.davdroid.AccountSettings
|
||||
import at.bitfire.davdroid.Constants
|
||||
import at.bitfire.davdroid.R
|
||||
import at.bitfire.davdroid.log.Logger
|
||||
import at.bitfire.davdroid.resource.LocalCalendar
|
||||
import at.bitfire.davdroid.resource.LocalEvent
|
||||
import at.bitfire.davdroid.resource.LocalResource
|
||||
import at.bitfire.ical4android.Event
|
||||
import at.bitfire.ical4android.InvalidCalendarException
|
||||
import okhttp3.HttpUrl
|
||||
import okhttp3.RequestBody
|
||||
import org.apache.commons.collections4.ListUtils
|
||||
import java.io.ByteArrayOutputStream
|
||||
import java.io.Reader
|
||||
import java.io.StringReader
|
||||
import java.util.*
|
||||
import java.util.logging.Level
|
||||
|
||||
/**
|
||||
* Synchronization manager for CalDAV collections; handles events ({@code VEVENT}).
|
||||
*/
|
||||
class CalendarSyncManager(
|
||||
context: Context,
|
||||
account: Account,
|
||||
settings: AccountSettings,
|
||||
extras: Bundle,
|
||||
authority: String,
|
||||
syncResult: SyncResult,
|
||||
val provider: ContentProviderClient,
|
||||
val localCalendar: LocalCalendar
|
||||
): SyncManager(context, account, settings, extras, authority, syncResult, "calendar/${localCalendar.id}") {
|
||||
|
||||
val MAX_MULTIGET = 20
|
||||
|
||||
|
||||
init {
|
||||
localCollection = localCalendar
|
||||
}
|
||||
|
||||
override fun notificationId() = Constants.NOTIFICATION_CALENDAR_SYNC
|
||||
|
||||
override fun getSyncErrorTitle() = context.getString(R.string.sync_error_calendar, account.name)!!
|
||||
|
||||
|
||||
override fun prepare(): Boolean {
|
||||
collectionURL = HttpUrl.parse(localCalendar.name ?: return false) ?: return false
|
||||
davCollection = DavCalendar(httpClient, collectionURL)
|
||||
return true
|
||||
}
|
||||
|
||||
override fun queryCapabilities() {
|
||||
davCollection.propfind(0, GetCTag.NAME)
|
||||
}
|
||||
|
||||
override fun prepareDirty() {
|
||||
super.prepareDirty()
|
||||
localCalendar.processDirtyExceptions()
|
||||
}
|
||||
|
||||
override fun prepareUpload(resource: LocalResource): RequestBody {
|
||||
if (resource is LocalEvent) {
|
||||
val event = requireNotNull(resource.event)
|
||||
Logger.log.log(Level.FINE, "Preparing upload of event ${resource.fileName}", event)
|
||||
|
||||
val os = ByteArrayOutputStream()
|
||||
event.write(os)
|
||||
|
||||
return RequestBody.create(
|
||||
DavCalendar.MIME_ICALENDAR,
|
||||
os.toByteArray()
|
||||
)
|
||||
} else
|
||||
throw IllegalArgumentException("resource must be a LocalEvent")
|
||||
}
|
||||
|
||||
override fun listRemote() {
|
||||
// calculate time range limits
|
||||
var limitStart: Date? = null
|
||||
settings.getTimeRangePastDays()?.let { pastDays ->
|
||||
val calendar = Calendar.getInstance()
|
||||
calendar.add(Calendar.DAY_OF_MONTH, -pastDays)
|
||||
limitStart = calendar.time
|
||||
}
|
||||
|
||||
// fetch list of remote VEVENTs and build hash table to index file name
|
||||
val calendar = davCalendar()
|
||||
currentDavResource = calendar
|
||||
calendar.calendarQuery("VEVENT", limitStart, null)
|
||||
|
||||
remoteResources = HashMap<String, DavResource>(davCollection.members.size)
|
||||
for (iCal in davCollection.members) {
|
||||
val fileName = iCal.fileName()
|
||||
Logger.log.fine("Found remote VEVENT: $fileName")
|
||||
remoteResources[fileName] = iCal
|
||||
}
|
||||
|
||||
currentDavResource = null
|
||||
}
|
||||
|
||||
override fun downloadRemote() {
|
||||
Logger.log.info("Downloading ${toDownload.size} events ($MAX_MULTIGET at once)")
|
||||
|
||||
// download new/updated iCalendars from server
|
||||
for (bunch in ListUtils.partition(toDownload.toList(), MAX_MULTIGET)) {
|
||||
if (Thread.interrupted())
|
||||
return
|
||||
|
||||
Logger.log.info("Downloading ${bunch.joinToString(", ")}")
|
||||
|
||||
if (bunch.size == 1) {
|
||||
// only one contact, use GET
|
||||
val remote = bunch.first()
|
||||
currentDavResource = remote
|
||||
|
||||
val body = remote.get("text/calendar")
|
||||
|
||||
// CalDAV servers MUST return ETag on GET [https://tools.ietf.org/html/rfc4791#section-5.3.4]
|
||||
val eTag = remote.properties[GetETag.NAME] as GetETag?
|
||||
if (eTag == null || eTag.eTag.isNullOrEmpty())
|
||||
throw DavException("Received CalDAV GET response without ETag for ${remote.location}")
|
||||
|
||||
body.charStream()?.use { reader ->
|
||||
processVEvent(remote.fileName(), eTag.eTag!!, reader)
|
||||
}
|
||||
|
||||
} else {
|
||||
// multiple contacts, use multi-get
|
||||
val calendar = davCalendar()
|
||||
currentDavResource = calendar
|
||||
calendar.multiget(bunch.map { it.location })
|
||||
|
||||
// process multiget results
|
||||
for (remote in davCollection.members) {
|
||||
currentDavResource = remote
|
||||
|
||||
val eTag = (remote.properties[GetETag.NAME] as GetETag?)?.eTag
|
||||
?: throw DavException("Received multi-get response without ETag")
|
||||
|
||||
val calendarData = remote.properties[CalendarData.NAME] as CalendarData?
|
||||
val iCalendar = calendarData?.iCalendar
|
||||
?: throw DavException("Received multi-get response without event data")
|
||||
|
||||
processVEvent(remote.fileName(), eTag, StringReader(iCalendar))
|
||||
}
|
||||
}
|
||||
|
||||
currentDavResource = null
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// helpers
|
||||
|
||||
private fun davCalendar() = davCollection as DavCalendar
|
||||
|
||||
private fun processVEvent(fileName: String, eTag: String, reader: Reader) {
|
||||
val events: List<Event>
|
||||
try {
|
||||
events = Event.fromReader(reader)
|
||||
} catch (e: InvalidCalendarException) {
|
||||
Logger.log.log(Level.SEVERE, "Received invalid iCalendar, ignoring", e)
|
||||
return
|
||||
}
|
||||
|
||||
if (events.size == 1) {
|
||||
val newData = events.first()
|
||||
|
||||
// delete local event, if it exists
|
||||
val localEvent = localResources[fileName] as LocalEvent?
|
||||
currentLocalResource = localEvent
|
||||
if (localEvent != null) {
|
||||
Logger.log.info("Updating $fileName in local calendar")
|
||||
localEvent.eTag = eTag
|
||||
localEvent.update(newData)
|
||||
syncResult.stats.numUpdates++
|
||||
} else {
|
||||
Logger.log.info("Adding $fileName to local calendar")
|
||||
val newEvent = LocalEvent(localCalendar, newData, fileName, eTag)
|
||||
currentLocalResource = newEvent
|
||||
newEvent.add()
|
||||
syncResult.stats.numInserts++
|
||||
}
|
||||
} else
|
||||
Logger.log.severe("Received VCALENDAR with not exactly one VEVENT with UID, but without RECURRENCE-ID; ignoring $fileName")
|
||||
|
||||
currentLocalResource = null
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,68 +0,0 @@
|
||||
/*
|
||||
* Copyright © 2013 – 2015 Ricki Hirner (bitfire web engineering).
|
||||
* All rights reserved. This program and the accompanying materials
|
||||
* are made available under the terms of the GNU Public License v3.0
|
||||
* which accompanies this distribution, and is available at
|
||||
* http://www.gnu.org/licenses/gpl.html
|
||||
*/
|
||||
package at.bitfire.davdroid.syncadapter;
|
||||
|
||||
import android.accounts.Account;
|
||||
import android.app.Service;
|
||||
import android.content.AbstractThreadedSyncAdapter;
|
||||
import android.content.ContentProviderClient;
|
||||
import android.content.Context;
|
||||
import android.content.Intent;
|
||||
import android.content.SyncResult;
|
||||
import android.os.Bundle;
|
||||
import android.os.IBinder;
|
||||
import android.provider.CalendarContract;
|
||||
|
||||
import at.bitfire.davdroid.Constants;
|
||||
import at.bitfire.davdroid.resource.LocalCalendar;
|
||||
import at.bitfire.ical4android.CalendarStorageException;
|
||||
|
||||
public class CalendarsSyncAdapterService extends Service {
|
||||
private static SyncAdapter syncAdapter;
|
||||
|
||||
@Override
|
||||
public void onCreate() {
|
||||
if (syncAdapter == null)
|
||||
syncAdapter = new SyncAdapter(getApplicationContext());
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onDestroy() {
|
||||
syncAdapter = null;
|
||||
}
|
||||
|
||||
@Override
|
||||
public IBinder onBind(Intent intent) {
|
||||
return syncAdapter.getSyncAdapterBinder();
|
||||
}
|
||||
|
||||
|
||||
private static class SyncAdapter extends AbstractThreadedSyncAdapter {
|
||||
public SyncAdapter(Context context) {
|
||||
super(context, false);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onPerformSync(Account account, Bundle extras, String authority, ContentProviderClient provider, SyncResult syncResult) {
|
||||
Constants.log.info("Starting calendar sync (" + authority + ")");
|
||||
|
||||
try {
|
||||
for (LocalCalendar calendar : (LocalCalendar[])LocalCalendar.find(account, provider, LocalCalendar.Factory.INSTANCE, CalendarContract.Calendars.SYNC_EVENTS + "!=0", null)) {
|
||||
Constants.log.info("Synchronizing calendar #" + calendar.getId() + ", URL: " + calendar.getName());
|
||||
CalendarSyncManager syncManager = new CalendarSyncManager(getContext(), account, extras, authority, syncResult, calendar);
|
||||
syncManager.performSync();
|
||||
}
|
||||
} catch (CalendarStorageException e) {
|
||||
Constants.log.error("Couldn't enumerate local calendars", e);
|
||||
}
|
||||
|
||||
Constants.log.info("Calendar sync complete");
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
/*
|
||||
* Copyright © Ricki Hirner (bitfire web engineering).
|
||||
* All rights reserved. This program and the accompanying materials
|
||||
* are made available under the terms of the GNU Public License v3.0
|
||||
* which accompanies this distribution, and is available at
|
||||
* http://www.gnu.org/licenses/gpl.html
|
||||
*/
|
||||
package at.bitfire.davdroid.syncadapter
|
||||
|
||||
import android.accounts.Account
|
||||
import android.content.*
|
||||
import android.database.DatabaseUtils
|
||||
import android.os.Bundle
|
||||
import android.provider.CalendarContract
|
||||
import at.bitfire.davdroid.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.resource.LocalCalendar
|
||||
import at.bitfire.ical4android.AndroidCalendar
|
||||
import java.util.logging.Level
|
||||
|
||||
class CalendarsSyncAdapterService: SyncAdapterService() {
|
||||
|
||||
override fun syncAdapter() = SyncAdapter(this)
|
||||
|
||||
|
||||
protected class SyncAdapter(
|
||||
context: Context
|
||||
): SyncAdapterService.SyncAdapter(context) {
|
||||
|
||||
override fun sync(account: Account, extras: Bundle, authority: String, provider: ContentProviderClient, syncResult: SyncResult) {
|
||||
try {
|
||||
val settings = AccountSettings(context, account)
|
||||
|
||||
/* don't run sync if
|
||||
- sync conditions (e.g. "sync only in WiFi") are not met AND
|
||||
- this is is an automatic sync (i.e. manual syncs are run regardless of sync conditions)
|
||||
*/
|
||||
if (!extras.containsKey(ContentResolver.SYNC_EXTRAS_MANUAL) && !checkSyncConditions(settings))
|
||||
return
|
||||
|
||||
updateLocalCalendars(provider, account, settings)
|
||||
|
||||
for (calendar in AndroidCalendar.find(account, provider, LocalCalendar.Factory, "${CalendarContract.Calendars.SYNC_EVENTS}!=0", null)) {
|
||||
Logger.log.info("Synchronizing calendar #${calendar.id}, URL: ${calendar.name}")
|
||||
CalendarSyncManager(context, account, settings, extras, authority, syncResult, provider, calendar)
|
||||
.performSync()
|
||||
}
|
||||
} catch(e: Exception) {
|
||||
Logger.log.log(Level.SEVERE, "Couldn't sync calendars", e)
|
||||
}
|
||||
|
||||
Logger.log.info("Calendar sync complete")
|
||||
}
|
||||
|
||||
private fun updateLocalCalendars(provider: ContentProviderClient, account: Account, settings: AccountSettings) {
|
||||
ServiceDB.OpenHelper(context).use { dbHelper ->
|
||||
val db = dbHelper.readableDatabase
|
||||
|
||||
fun getService() =
|
||||
db.query(ServiceDB.Services._TABLE, arrayOf(ServiceDB.Services.ID),
|
||||
"${ServiceDB.Services.ACCOUNT_NAME}=? AND ${ServiceDB.Services.SERVICE}=?",
|
||||
arrayOf(account.name, ServiceDB.Services.SERVICE_CALDAV), null, null, null)?.use { c ->
|
||||
if (c.moveToNext())
|
||||
c.getLong(0)
|
||||
else
|
||||
null
|
||||
}
|
||||
|
||||
fun remoteCalendars(service: Long?): MutableMap<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
|
||||
}
|
||||
|
||||
// enumerate remote and local calendars
|
||||
val service = getService()
|
||||
val remote = remoteCalendars(service)
|
||||
|
||||
// delete/update local calendars
|
||||
val updateColors = settings.getManageCalendarColors()
|
||||
|
||||
for (calendar in AndroidCalendar.find(account, provider, LocalCalendar.Factory, null, null))
|
||||
calendar.name?.let { url ->
|
||||
val info = remote[url]
|
||||
if (info == null) {
|
||||
Logger.log.log(Level.INFO, "Deleting obsolete local calendar", url)
|
||||
calendar.delete()
|
||||
} else {
|
||||
// remote CollectionInfo found for this local collection, update data
|
||||
Logger.log.log(Level.FINE, "Updating local calendar $url", info)
|
||||
calendar.update(info, updateColors)
|
||||
// we already have a local calendar for this remote collection, don't take into consideration anymore
|
||||
remote -= url
|
||||
}
|
||||
}
|
||||
|
||||
// create new local calendars
|
||||
for ((_, info) in remote) {
|
||||
Logger.log.log(Level.INFO, "Adding local calendar", info)
|
||||
LocalCalendar.create(account, provider, info)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,60 +0,0 @@
|
||||
/*
|
||||
* Copyright © 2013 – 2015 Ricki Hirner (bitfire web engineering).
|
||||
* All rights reserved. This program and the accompanying materials
|
||||
* are made available under the terms of the GNU Public License v3.0
|
||||
* which accompanies this distribution, and is available at
|
||||
* http://www.gnu.org/licenses/gpl.html
|
||||
*/
|
||||
package at.bitfire.davdroid.syncadapter;
|
||||
|
||||
import android.accounts.Account;
|
||||
import android.app.Service;
|
||||
import android.content.AbstractThreadedSyncAdapter;
|
||||
import android.content.ContentProviderClient;
|
||||
import android.content.Context;
|
||||
import android.content.Intent;
|
||||
import android.content.SyncResult;
|
||||
import android.os.Bundle;
|
||||
import android.os.IBinder;
|
||||
|
||||
import at.bitfire.davdroid.Constants;
|
||||
|
||||
public class ContactsSyncAdapterService extends Service {
|
||||
private static ContactsSyncAdapter syncAdapter;
|
||||
|
||||
@Override
|
||||
public void onCreate() {
|
||||
if (syncAdapter == null)
|
||||
syncAdapter = new ContactsSyncAdapter(getApplicationContext());
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onDestroy() {
|
||||
syncAdapter = null;
|
||||
}
|
||||
|
||||
@Override
|
||||
public IBinder onBind(Intent intent) {
|
||||
return syncAdapter.getSyncAdapterBinder();
|
||||
}
|
||||
|
||||
|
||||
private static class ContactsSyncAdapter extends AbstractThreadedSyncAdapter {
|
||||
public ContactsSyncAdapter(Context context) {
|
||||
super(context, false);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onPerformSync(Account account, Bundle extras, String authority, ContentProviderClient provider, SyncResult syncResult) {
|
||||
Constants.log.info("Starting address book sync (" + authority + ")");
|
||||
|
||||
ContactsSyncManager syncManager = new ContactsSyncManager(getContext(), account, extras, authority, provider, syncResult);
|
||||
syncManager.performSync();
|
||||
|
||||
Constants.log.info("Address book sync complete");
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
/*
|
||||
* Copyright © Ricki Hirner (bitfire web engineering).
|
||||
* All rights reserved. This program and the accompanying materials
|
||||
* are made available under the terms of the GNU Public License v3.0
|
||||
* which accompanies this distribution, and is available at
|
||||
* http://www.gnu.org/licenses/gpl.html
|
||||
*/
|
||||
|
||||
package at.bitfire.davdroid.syncadapter
|
||||
|
||||
import android.accounts.Account
|
||||
import android.content.ContentProviderClient
|
||||
import android.content.ContentResolver
|
||||
import android.content.Context
|
||||
import android.content.SyncResult
|
||||
import android.os.Bundle
|
||||
import at.bitfire.davdroid.AccountSettings
|
||||
import at.bitfire.davdroid.log.Logger
|
||||
import at.bitfire.davdroid.resource.LocalAddressBook
|
||||
import java.util.logging.Level
|
||||
|
||||
class ContactsSyncAdapterService: SyncAdapterService() {
|
||||
|
||||
override fun syncAdapter() = ContactsSyncAdapter(this)
|
||||
|
||||
|
||||
protected class ContactsSyncAdapter(
|
||||
context: Context
|
||||
): SyncAdapter(context) {
|
||||
|
||||
override fun sync(account: Account, extras: Bundle, authority: String, provider: ContentProviderClient, syncResult: SyncResult) {
|
||||
try {
|
||||
val addressBook = LocalAddressBook(context, account, provider)
|
||||
|
||||
val settings = AccountSettings(context, addressBook.getMainAccount())
|
||||
if (!extras.containsKey(ContentResolver.SYNC_EXTRAS_MANUAL) && !checkSyncConditions(settings))
|
||||
return
|
||||
|
||||
Logger.log.info("Synchronizing address book: ${addressBook.getURL()}")
|
||||
Logger.log.info("Taking settings from: ${addressBook.getMainAccount()}")
|
||||
|
||||
ContactsSyncManager(context, account, settings, extras, authority, syncResult, provider, addressBook)
|
||||
.performSync()
|
||||
} catch(e: Exception) {
|
||||
Logger.log.log(Level.SEVERE, "Couldn't sync contacts", e)
|
||||
}
|
||||
|
||||
Logger.log.info("Contacts sync complete")
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,289 +0,0 @@
|
||||
/*
|
||||
* Copyright © 2013 – 2015 Ricki Hirner (bitfire web engineering).
|
||||
* All rights reserved. This program and the accompanying materials
|
||||
* are made available under the terms of the GNU Public License v3.0
|
||||
* which accompanies this distribution, and is available at
|
||||
* http://www.gnu.org/licenses/gpl.html
|
||||
*/
|
||||
|
||||
package at.bitfire.davdroid.syncadapter;
|
||||
|
||||
import android.accounts.Account;
|
||||
import android.content.ContentProviderClient;
|
||||
import android.content.Context;
|
||||
import android.content.SyncResult;
|
||||
import android.os.Bundle;
|
||||
|
||||
import com.squareup.okhttp.HttpUrl;
|
||||
import com.squareup.okhttp.MediaType;
|
||||
import com.squareup.okhttp.Request;
|
||||
import com.squareup.okhttp.RequestBody;
|
||||
import com.squareup.okhttp.Response;
|
||||
import com.squareup.okhttp.ResponseBody;
|
||||
|
||||
import org.apache.commons.codec.Charsets;
|
||||
import org.apache.commons.lang3.StringUtils;
|
||||
|
||||
import java.io.ByteArrayInputStream;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.nio.charset.Charset;
|
||||
import java.util.HashMap;
|
||||
import java.util.LinkedList;
|
||||
import java.util.List;
|
||||
|
||||
import at.bitfire.dav4android.DavAddressBook;
|
||||
import at.bitfire.dav4android.DavResource;
|
||||
import at.bitfire.dav4android.exception.DavException;
|
||||
import at.bitfire.dav4android.exception.HttpException;
|
||||
import at.bitfire.dav4android.property.AddressData;
|
||||
import at.bitfire.dav4android.property.GetCTag;
|
||||
import at.bitfire.dav4android.property.GetContentType;
|
||||
import at.bitfire.dav4android.property.GetETag;
|
||||
import at.bitfire.dav4android.property.SupportedAddressData;
|
||||
import at.bitfire.davdroid.ArrayUtils;
|
||||
import at.bitfire.davdroid.Constants;
|
||||
import at.bitfire.davdroid.HttpClient;
|
||||
import at.bitfire.davdroid.R;
|
||||
import at.bitfire.davdroid.resource.LocalAddressBook;
|
||||
import at.bitfire.davdroid.resource.LocalContact;
|
||||
import at.bitfire.davdroid.resource.LocalGroup;
|
||||
import at.bitfire.davdroid.resource.LocalResource;
|
||||
import at.bitfire.vcard4android.Contact;
|
||||
import at.bitfire.vcard4android.ContactsStorageException;
|
||||
import ezvcard.VCardVersion;
|
||||
import ezvcard.util.IOUtils;
|
||||
import lombok.Cleanup;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
|
||||
public class ContactsSyncManager extends SyncManager {
|
||||
protected static final int MAX_MULTIGET = 10;
|
||||
|
||||
final protected ContentProviderClient provider;
|
||||
protected boolean hasVCard4;
|
||||
|
||||
|
||||
public ContactsSyncManager(Context context, Account account, Bundle extras, String authority, ContentProviderClient provider, SyncResult result) {
|
||||
super(Constants.NOTIFICATION_CONTACTS_SYNC, context, account, extras, authority, result);
|
||||
this.provider = provider;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected String getSyncErrorTitle() {
|
||||
return context.getString(R.string.sync_error_contacts, account.name);
|
||||
}
|
||||
|
||||
|
||||
@Override
|
||||
protected void prepare() throws ContactsStorageException {
|
||||
// prepare local address book
|
||||
localCollection = new LocalAddressBook(account, provider);
|
||||
|
||||
String url = localAddressBook().getURL();
|
||||
if (url == null)
|
||||
throw new ContactsStorageException("Couldn't get address book URL");
|
||||
collectionURL = HttpUrl.parse(url);
|
||||
davCollection = new DavAddressBook(log, httpClient, collectionURL);
|
||||
|
||||
processChangedGroups();
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void queryCapabilities() throws DavException, IOException, HttpException {
|
||||
// prepare remote address book
|
||||
hasVCard4 = false;
|
||||
davCollection.propfind(0, SupportedAddressData.NAME, GetCTag.NAME);
|
||||
SupportedAddressData supportedAddressData = (SupportedAddressData) davCollection.properties.get(SupportedAddressData.NAME);
|
||||
if (supportedAddressData != null)
|
||||
for (MediaType type : supportedAddressData.types)
|
||||
if ("text/vcard; version=4.0".equalsIgnoreCase(type.toString()))
|
||||
hasVCard4 = true;
|
||||
log.info("Server advertises VCard/4 support: " + hasVCard4);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected RequestBody prepareUpload(LocalResource resource) throws IOException, ContactsStorageException {
|
||||
LocalContact local = (LocalContact)resource;
|
||||
return RequestBody.create(
|
||||
hasVCard4 ? DavAddressBook.MIME_VCARD4 : DavAddressBook.MIME_VCARD3_UTF8,
|
||||
local.getContact().toStream(hasVCard4 ? VCardVersion.V4_0 : VCardVersion.V3_0).toByteArray()
|
||||
);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void listRemote() throws IOException, HttpException, DavException {
|
||||
// fetch list of remote VCards and build hash table to index file name
|
||||
|
||||
try {
|
||||
davAddressBook().addressbookQuery();
|
||||
} catch(HttpException e) {
|
||||
/* non-successful responses to CARDDAV:addressbook-query with empty filter, tested on 2015/10/21
|
||||
* fastmail.com 403 Forbidden (DAV:error CARDDAV:supported-filter)
|
||||
* mailbox.org (OpenXchange) 400 Bad Request
|
||||
* SOGo 207 Multi-status, but without entries http://www.sogo.nu/bugs/view.php?id=3370
|
||||
* Zimbra ZCS 500 Server Error https://bugzilla.zimbra.com/show_bug.cgi?id=101902
|
||||
*/
|
||||
if (e.status == 400 || e.status == 403 || e.status == 500 || e.status == 501) {
|
||||
log.warn("Server error on REPORT addressbook-query, falling back to PROPFIND", e);
|
||||
davAddressBook().propfind(1, GetETag.NAME);
|
||||
} else
|
||||
// no defined fallback, pass through exception
|
||||
throw e;
|
||||
}
|
||||
|
||||
remoteResources = new HashMap<>(davCollection.members.size());
|
||||
for (DavResource vCard : davCollection.members) {
|
||||
String fileName = vCard.fileName();
|
||||
log.debug("Found remote VCard: " + fileName);
|
||||
remoteResources.put(fileName, vCard);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void downloadRemote() throws IOException, HttpException, DavException, ContactsStorageException {
|
||||
log.info("Downloading " + toDownload.size() + " contacts (" + MAX_MULTIGET + " at once)");
|
||||
|
||||
// prepare downloader which may be used to download external resource like contact photos
|
||||
Contact.Downloader downloader = new ResourceDownloader(httpClient, collectionURL);
|
||||
|
||||
// download new/updated VCards from server
|
||||
for (DavResource[] bunch : ArrayUtils.partition(toDownload.toArray(new DavResource[toDownload.size()]), MAX_MULTIGET)) {
|
||||
if (Thread.interrupted())
|
||||
return;
|
||||
|
||||
log.info("Downloading " + StringUtils.join(bunch, ", "));
|
||||
|
||||
if (bunch.length == 1) {
|
||||
// only one contact, use GET
|
||||
DavResource remote = bunch[0];
|
||||
|
||||
ResponseBody body = remote.get("text/vcard;version=4.0, text/vcard;charset=utf-8;q=0.8, text/vcard;q=0.5");
|
||||
String eTag = ((GetETag) remote.properties.get(GetETag.NAME)).eTag;
|
||||
|
||||
Charset charset = Charsets.UTF_8;
|
||||
MediaType contentType = body.contentType();
|
||||
if (contentType != null)
|
||||
charset = contentType.charset(Charsets.UTF_8);
|
||||
|
||||
@Cleanup InputStream stream = body.byteStream();
|
||||
processVCard(remote.fileName(), eTag, stream, charset, downloader);
|
||||
|
||||
} else {
|
||||
// multiple contacts, use multi-get
|
||||
List<HttpUrl> urls = new LinkedList<>();
|
||||
for (DavResource remote : bunch)
|
||||
urls.add(remote.location);
|
||||
davAddressBook().multiget(urls.toArray(new HttpUrl[urls.size()]), hasVCard4);
|
||||
|
||||
// process multiget results
|
||||
for (DavResource remote : davCollection.members) {
|
||||
String eTag;
|
||||
GetETag getETag = (GetETag) remote.properties.get(GetETag.NAME);
|
||||
if (getETag != null)
|
||||
eTag = getETag.eTag;
|
||||
else
|
||||
throw new DavException("Received multi-get response without ETag");
|
||||
|
||||
Charset charset = Charsets.UTF_8;
|
||||
GetContentType getContentType = (GetContentType) remote.properties.get(GetContentType.NAME);
|
||||
if (getContentType != null && getContentType.type != null) {
|
||||
MediaType type = MediaType.parse(getContentType.type);
|
||||
if (type != null)
|
||||
charset = type.charset(Charsets.UTF_8);
|
||||
}
|
||||
|
||||
AddressData addressData = (AddressData) remote.properties.get(AddressData.NAME);
|
||||
if (addressData == null || addressData.vCard == null)
|
||||
throw new DavException("Received multi-get response without address data");
|
||||
|
||||
@Cleanup InputStream stream = new ByteArrayInputStream(addressData.vCard.getBytes());
|
||||
processVCard(remote.fileName(), eTag, stream, charset, downloader);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// helpers
|
||||
|
||||
private DavAddressBook davAddressBook() { return (DavAddressBook)davCollection; }
|
||||
private LocalAddressBook localAddressBook() { return (LocalAddressBook)localCollection; }
|
||||
|
||||
private void processChangedGroups() throws ContactsStorageException {
|
||||
LocalAddressBook addressBook = localAddressBook();
|
||||
|
||||
// groups with DELETED=1: remove group finally
|
||||
for (LocalGroup group : addressBook.getDeletedGroups()) {
|
||||
long groupId = group.getId();
|
||||
log.debug("Finally removing group #" + groupId);
|
||||
// remove group memberships, but not as sync adapter (should marks contacts as DIRTY)
|
||||
// NOTE: doesn't work that way because Contact Provider removes the group memberships even for DELETED groups
|
||||
// addressBook.removeGroupMemberships(groupId, false);
|
||||
group.delete();
|
||||
}
|
||||
|
||||
// groups with DIRTY=1: mark all memberships as dirty, then clean DIRTY flag of group
|
||||
for (LocalGroup group : addressBook.getDirtyGroups()) {
|
||||
long groupId = group.getId();
|
||||
log.debug("Marking members of modified group #" + groupId + " as dirty");
|
||||
addressBook.markMembersDirty(groupId);
|
||||
group.clearDirty();
|
||||
}
|
||||
}
|
||||
|
||||
private void processVCard(String fileName, String eTag, InputStream stream, Charset charset, Contact.Downloader downloader) throws IOException, ContactsStorageException {
|
||||
Contact contacts[] = Contact.fromStream(stream, charset, downloader);
|
||||
if (contacts != null && contacts.length == 1) {
|
||||
Contact newData = contacts[0];
|
||||
|
||||
// update local contact, if it exists
|
||||
LocalContact localContact = (LocalContact)localResources.get(fileName);
|
||||
if (localContact != null) {
|
||||
log.info("Updating " + fileName + " in local address book");
|
||||
localContact.eTag = eTag;
|
||||
localContact.update(newData);
|
||||
syncResult.stats.numUpdates++;
|
||||
} else {
|
||||
log.info("Adding " + fileName + " to local address book");
|
||||
localContact = new LocalContact(localAddressBook(), newData, fileName, eTag);
|
||||
localContact.add();
|
||||
syncResult.stats.numInserts++;
|
||||
}
|
||||
} else
|
||||
log.error("Received VCard with not exactly one VCARD, ignoring " + fileName);
|
||||
}
|
||||
|
||||
|
||||
// downloader helper class
|
||||
|
||||
@RequiredArgsConstructor
|
||||
private class ResourceDownloader implements Contact.Downloader {
|
||||
final HttpClient httpClient;
|
||||
final HttpUrl baseUrl;
|
||||
|
||||
@Override
|
||||
public byte[] download(String url, String accepts) {
|
||||
HttpUrl httpUrl = HttpUrl.parse(url);
|
||||
HttpClient resourceClient = new HttpClient(log, httpClient, httpUrl.host());
|
||||
try {
|
||||
Response response = resourceClient.newCall(new Request.Builder()
|
||||
.get()
|
||||
.url(httpUrl)
|
||||
.build()).execute();
|
||||
|
||||
ResponseBody body = response.body();
|
||||
if (body != null) {
|
||||
@Cleanup InputStream stream = body.byteStream();
|
||||
if (response.isSuccessful() && stream != null) {
|
||||
return IOUtils.toByteArray(stream);
|
||||
} else
|
||||
log.error("Couldn't download external resource");
|
||||
}
|
||||
} catch(IOException e) {
|
||||
log.error("Couldn't download external resource", e);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,458 @@
|
||||
/*
|
||||
* Copyright © Ricki Hirner (bitfire web engineering).
|
||||
* All rights reserved. This program and the accompanying materials
|
||||
* are made available under the terms of the GNU Public License v3.0
|
||||
* which accompanies this distribution, and is available at
|
||||
* http://www.gnu.org/licenses/gpl.html
|
||||
*/
|
||||
|
||||
package at.bitfire.davdroid.syncadapter
|
||||
|
||||
import android.accounts.Account
|
||||
import android.content.*
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import android.os.RemoteException
|
||||
import android.provider.ContactsContract
|
||||
import android.provider.ContactsContract.Groups
|
||||
import at.bitfire.dav4android.DavAddressBook
|
||||
import at.bitfire.dav4android.DavResource
|
||||
import at.bitfire.dav4android.exception.DavException
|
||||
import at.bitfire.dav4android.property.*
|
||||
import at.bitfire.davdroid.AccountSettings
|
||||
import at.bitfire.davdroid.Constants
|
||||
import at.bitfire.davdroid.HttpClient
|
||||
import at.bitfire.davdroid.R
|
||||
import at.bitfire.davdroid.log.Logger
|
||||
import at.bitfire.davdroid.resource.LocalAddressBook
|
||||
import at.bitfire.davdroid.resource.LocalContact
|
||||
import at.bitfire.davdroid.resource.LocalGroup
|
||||
import at.bitfire.davdroid.resource.LocalResource
|
||||
import at.bitfire.vcard4android.BatchOperation
|
||||
import at.bitfire.vcard4android.Contact
|
||||
import at.bitfire.vcard4android.ContactsStorageException
|
||||
import at.bitfire.vcard4android.GroupMethod
|
||||
import ezvcard.VCardVersion
|
||||
import okhttp3.HttpUrl
|
||||
import okhttp3.Request
|
||||
import okhttp3.RequestBody
|
||||
import org.apache.commons.collections4.ListUtils
|
||||
import java.io.*
|
||||
import java.util.*
|
||||
import java.util.logging.Level
|
||||
|
||||
/**
|
||||
* <p>Synchronization manager for CardDAV collections; handles contacts and groups.</p>
|
||||
*
|
||||
* <p></p>Group handling differs according to the {@link #groupMethod}. There are two basic methods to
|
||||
* handle/manage groups:</p>
|
||||
* <ul>
|
||||
* <li>{@code CATEGORIES}: groups memberships are attached to each contact and represented as
|
||||
* "category". When a group is dirty or has been deleted, all its members have to be set to
|
||||
* dirty, too (because they have to be uploaded without the respective category). This
|
||||
* is done in {@link #prepareDirty()}. Empty groups can be deleted without further processing,
|
||||
* which is done in {@link #postProcess()} because groups may become empty after downloading
|
||||
* updated remoted contacts.</li>
|
||||
* <li>Groups as separate VCards: individual and group contacts (with a list of member UIDs) are
|
||||
* distinguished. When a local group is dirty, its members don't need to be set to dirty.
|
||||
* <ol>
|
||||
* <li>However, when a contact is dirty, it has
|
||||
* to be checked whether its group memberships have changed. In this case, the respective
|
||||
* groups have to be set to dirty. For instance, if contact A is in group G and H, and then
|
||||
* group membership of G is removed, the contact will be set to dirty because of the changed
|
||||
* {@link android.provider.ContactsContract.CommonDataKinds.GroupMembership}. DAVdroid will
|
||||
* then have to check whether the group memberships have actually changed, and if so,
|
||||
* all affected groups have to be set to dirty. To detect changes in group memberships,
|
||||
* DAVdroid always mirrors all {@link android.provider.ContactsContract.CommonDataKinds.GroupMembership}
|
||||
* data rows in respective {@link at.bitfire.vcard4android.CachedGroupMembership} rows.
|
||||
* If the cached group memberships are not the same as the current group member ships, the
|
||||
* difference set (in our example G, because its in the cached memberships, but not in the
|
||||
* actual ones) is marked as dirty. This is done in {@link #prepareDirty()}.</li>
|
||||
* <li>When downloading remote contacts, groups (+ member information) may be received
|
||||
* by the actual members. Thus, the member lists have to be cached until all VCards
|
||||
* are received. This is done by caching the member UIDs of each group in
|
||||
* {@link LocalGroup#COLUMN_PENDING_MEMBERS}. In {@link #postProcess()},
|
||||
* these "pending memberships" are assigned to the actual contacs and then cleaned up.</li>
|
||||
* </ol>
|
||||
* </ul>
|
||||
*/
|
||||
class ContactsSyncManager(
|
||||
context: Context,
|
||||
account: Account,
|
||||
settings: AccountSettings,
|
||||
extras: Bundle,
|
||||
authority: String,
|
||||
syncResult: SyncResult,
|
||||
val provider: ContentProviderClient,
|
||||
val localAddressBook: LocalAddressBook
|
||||
): SyncManager(context, account, settings, extras, authority, syncResult, "addressBook") {
|
||||
|
||||
val MAX_MULTIGET = 10
|
||||
|
||||
private var hasVCard4 = false
|
||||
private lateinit var groupMethod: GroupMethod
|
||||
|
||||
|
||||
init {
|
||||
localCollection = localAddressBook
|
||||
}
|
||||
|
||||
override fun notificationId() = Constants.NOTIFICATION_CONTACTS_SYNC
|
||||
|
||||
override fun getSyncErrorTitle() = context.getString(R.string.sync_error_contacts, account.name)!!
|
||||
|
||||
|
||||
override fun prepare(): Boolean {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N && Build.VERSION.SDK_INT < Build.VERSION_CODES.O) {
|
||||
// workaround for Android 7 which sets DIRTY flag when only meta-data is changed
|
||||
val reallyDirty = localAddressBook.verifyDirty()
|
||||
val deleted = localAddressBook.getDeleted().size
|
||||
if (extras.containsKey(ContentResolver.SYNC_EXTRAS_UPLOAD) && reallyDirty == 0 && deleted == 0) {
|
||||
Logger.log.info("This sync was called to up-sync dirty/deleted contacts, but no contacts have been changed")
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// set up Contacts Provider Settings
|
||||
val values = ContentValues(2)
|
||||
values.put(ContactsContract.Settings.SHOULD_SYNC, 1)
|
||||
values.put(ContactsContract.Settings.UNGROUPED_VISIBLE, 1)
|
||||
localAddressBook.updateSettings(values)
|
||||
|
||||
collectionURL = HttpUrl.parse(localAddressBook.getURL()) ?: return false
|
||||
davCollection = DavAddressBook(httpClient, collectionURL)
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
override fun queryCapabilities() {
|
||||
// prepare remote address book
|
||||
davCollection.propfind(0, SupportedAddressData.NAME, GetCTag.NAME)
|
||||
(davCollection.properties[SupportedAddressData.NAME] as SupportedAddressData?)?.let {
|
||||
hasVCard4 = it.hasVCard4()
|
||||
}
|
||||
Logger.log.info("Server advertises VCard/4 support: $hasVCard4")
|
||||
|
||||
groupMethod = settings.getGroupMethod()
|
||||
Logger.log.info("Contact group method: $groupMethod")
|
||||
|
||||
localAddressBook.includeGroups = groupMethod == GroupMethod.GROUP_VCARDS
|
||||
}
|
||||
|
||||
override fun prepareDirty() {
|
||||
super.prepareDirty()
|
||||
|
||||
if (groupMethod == GroupMethod.CATEGORIES) {
|
||||
/* groups memberships are represented as contact CATEGORIES */
|
||||
|
||||
// groups with DELETED=1: set all members to dirty, then remove group
|
||||
for (group in localAddressBook.getDeletedGroups()) {
|
||||
Logger.log.fine("Finally removing group $group")
|
||||
// useless because Android deletes group memberships as soon as a group is set to DELETED:
|
||||
// group.markMembersDirty()
|
||||
group.delete()
|
||||
}
|
||||
|
||||
// groups with DIRTY=1: mark all memberships as dirty, then clean DIRTY flag of group
|
||||
for (group in localAddressBook.getDirtyGroups()) {
|
||||
Logger.log.fine("Marking members of modified group $group as dirty")
|
||||
group.markMembersDirty()
|
||||
group.clearDirty(null)
|
||||
}
|
||||
} else {
|
||||
/* groups as separate VCards: there are group contacts and individual contacts */
|
||||
|
||||
// mark groups with changed members as dirty
|
||||
val batch = BatchOperation(localAddressBook.provider!!)
|
||||
for (contact in localAddressBook.getDirtyContacts())
|
||||
try {
|
||||
Logger.log.fine("Looking for changed group memberships of contact ${contact.fileName}")
|
||||
val cachedGroups = contact.getCachedGroupMemberships()
|
||||
val currentGroups = contact.getGroupMemberships()
|
||||
for (groupID in cachedGroups disjunct currentGroups) {
|
||||
Logger.log.fine("Marking group as dirty: $groupID")
|
||||
batch.enqueue(BatchOperation.Operation(
|
||||
ContentProviderOperation.newUpdate(localAddressBook.syncAdapterURI(ContentUris.withAppendedId(Groups.CONTENT_URI, groupID)))
|
||||
.withValue(Groups.DIRTY, 1)
|
||||
.withYieldAllowed(true)
|
||||
))
|
||||
}
|
||||
} catch(e: FileNotFoundException) {
|
||||
}
|
||||
batch.commit()
|
||||
}
|
||||
}
|
||||
|
||||
override fun prepareUpload(resource: LocalResource): RequestBody {
|
||||
val contact: Contact
|
||||
if (resource is LocalContact) {
|
||||
contact = resource.contact!!
|
||||
|
||||
if (groupMethod == GroupMethod.CATEGORIES) {
|
||||
// add groups as CATEGORIES
|
||||
for (groupID in resource.getGroupMemberships()) {
|
||||
try {
|
||||
provider.query(
|
||||
localAddressBook.syncAdapterURI(ContentUris.withAppendedId(Groups.CONTENT_URI, groupID)),
|
||||
arrayOf(Groups.TITLE), null, null, null
|
||||
)?.use { cursor ->
|
||||
if (cursor.moveToNext()) {
|
||||
val title = cursor.getString(0)
|
||||
if (!title.isNullOrEmpty())
|
||||
contact.categories.add(title)
|
||||
}
|
||||
}
|
||||
} catch(e: RemoteException) {
|
||||
throw ContactsStorageException("Couldn't find group for adding CATEGORIES", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if (resource is LocalGroup)
|
||||
contact = resource.contact!!
|
||||
else
|
||||
throw IllegalArgumentException("resource must be a LocalContact or a LocalGroup")
|
||||
|
||||
Logger.log.log(Level.FINE, "Preparing upload of VCard ${resource.fileName}", contact)
|
||||
|
||||
val os = ByteArrayOutputStream()
|
||||
contact.write(if (hasVCard4) VCardVersion.V4_0 else VCardVersion.V3_0, groupMethod, os)
|
||||
|
||||
return RequestBody.create(
|
||||
if (hasVCard4) DavAddressBook.MIME_VCARD4 else DavAddressBook.MIME_VCARD3_UTF8,
|
||||
os.toByteArray()
|
||||
)
|
||||
}
|
||||
|
||||
override fun listRemote() {
|
||||
val addressBook = davAddressBook()
|
||||
currentDavResource = addressBook
|
||||
|
||||
// fetch list of remote VCards and build hash table to index file name
|
||||
addressBook.propfind(1, ResourceType.NAME, GetETag.NAME)
|
||||
|
||||
remoteResources = HashMap<String, DavResource>(davCollection.members.size)
|
||||
for (vCard in davCollection.members) {
|
||||
// ignore member collections
|
||||
val type = vCard.properties[ResourceType.NAME] as ResourceType?
|
||||
if (type != null && type.types.contains(ResourceType.COLLECTION))
|
||||
continue
|
||||
|
||||
val fileName = vCard.fileName()
|
||||
Logger.log.fine("Found remote VCard: $fileName")
|
||||
remoteResources[fileName] = vCard
|
||||
}
|
||||
|
||||
currentDavResource = null
|
||||
}
|
||||
|
||||
override fun downloadRemote() {
|
||||
Logger.log.info("Downloading ${toDownload.size} contacts ($MAX_MULTIGET at once)")
|
||||
|
||||
// prepare downloader which may be used to download external resource like contact photos
|
||||
val downloader = ResourceDownloader(collectionURL)
|
||||
|
||||
// download new/updated VCards from server
|
||||
for (bunch in ListUtils.partition(toDownload.toList(), MAX_MULTIGET)) {
|
||||
if (Thread.interrupted())
|
||||
return
|
||||
|
||||
Logger.log.info("Downloading ${bunch.joinToString(", ")}")
|
||||
|
||||
if (bunch.size == 1) {
|
||||
// only one contact, use GET
|
||||
val remote = bunch.first()
|
||||
currentDavResource = remote
|
||||
|
||||
val body = remote.get("text/vcard;version=4.0, text/vcard;charset=utf-8;q=0.8, text/vcard;q=0.5")
|
||||
|
||||
// CardDAV servers MUST return ETag on GET [https://tools.ietf.org/html/rfc6352#section-6.3.2.3]
|
||||
val eTag = remote.properties[GetETag.NAME] as GetETag?
|
||||
if (eTag == null || eTag.eTag.isNullOrEmpty())
|
||||
throw DavException("Received CardDAV GET response without ETag for ${remote.location}")
|
||||
|
||||
body.charStream().use { reader ->
|
||||
processVCard(remote.fileName(), eTag.eTag!!, reader, downloader)
|
||||
}
|
||||
|
||||
} else {
|
||||
// multiple contacts, use multi-get
|
||||
val addressBook = davAddressBook()
|
||||
currentDavResource = addressBook
|
||||
addressBook.multiget(bunch.map { it.location }, hasVCard4)
|
||||
|
||||
// process multi-get results
|
||||
for (remote in davCollection.members) {
|
||||
currentDavResource = remote
|
||||
|
||||
val eTag = (remote.properties[GetETag.NAME] as GetETag?)?.eTag
|
||||
?: throw DavException("Received multi-get response without ETag")
|
||||
|
||||
val addressData = remote.properties[AddressData.NAME] as AddressData?
|
||||
val vCard = addressData?.vCard
|
||||
?: throw DavException("Received multi-get response without address data")
|
||||
|
||||
processVCard(remote.fileName(), eTag, StringReader(vCard), downloader)
|
||||
}
|
||||
}
|
||||
|
||||
currentDavResource = null
|
||||
}
|
||||
}
|
||||
|
||||
override fun postProcess() {
|
||||
if (groupMethod == GroupMethod.CATEGORIES) {
|
||||
/* VCard3 group handling: groups memberships are represented as contact CATEGORIES */
|
||||
|
||||
// remove empty groups
|
||||
Logger.log.info("Removing empty groups")
|
||||
localAddressBook.removeEmptyGroups()
|
||||
|
||||
} else {
|
||||
/* VCard4 group handling: there are group contacts and individual contacts */
|
||||
Logger.log.info("Assigning memberships of downloaded contact groups")
|
||||
LocalGroup.applyPendingMemberships(localAddressBook)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// helpers
|
||||
|
||||
private fun davAddressBook() = davCollection as DavAddressBook
|
||||
|
||||
private fun processVCard(fileName: String, eTag: String, reader: Reader, downloader: Contact.Downloader) {
|
||||
Logger.log.info("Processing CardDAV resource $fileName")
|
||||
val contacts = Contact.fromReader(reader, downloader)
|
||||
if (contacts.isEmpty()) {
|
||||
Logger.log.warning("Received VCard without data, ignoring")
|
||||
return
|
||||
} else if (contacts.size > 1)
|
||||
Logger.log.warning("Received multiple VCards, using first one")
|
||||
|
||||
val newData = contacts.first()
|
||||
|
||||
if (groupMethod == GroupMethod.CATEGORIES && newData.group) {
|
||||
groupMethod = GroupMethod.GROUP_VCARDS
|
||||
Logger.log.warning("Received group VCard although group method is CATEGORIES. Deleting all groups; new group method: $groupMethod")
|
||||
localAddressBook.removeGroups()
|
||||
settings.setGroupMethod(groupMethod)
|
||||
}
|
||||
|
||||
// update local contact, if it exists
|
||||
var local = localResources[fileName]
|
||||
currentLocalResource = local
|
||||
if (local != null) {
|
||||
Logger.log.log(Level.INFO, "Updating $fileName in local address book", newData)
|
||||
|
||||
if (local is LocalGroup && newData.group) {
|
||||
// update group
|
||||
local.eTag = eTag
|
||||
local.updateFromServer(newData)
|
||||
syncResult.stats.numUpdates++
|
||||
|
||||
} else if (local is LocalContact && !newData.group) {
|
||||
// update contact
|
||||
local.eTag = eTag
|
||||
local.update(newData)
|
||||
syncResult.stats.numUpdates++
|
||||
|
||||
} else {
|
||||
// group has become an individual contact or vice versa
|
||||
local.delete()
|
||||
local = null
|
||||
}
|
||||
}
|
||||
|
||||
if (local == null) {
|
||||
if (newData.group) {
|
||||
Logger.log.log(Level.INFO, "Creating local group", newData)
|
||||
val group = LocalGroup(localAddressBook, newData, fileName, eTag)
|
||||
currentLocalResource = group
|
||||
group.create()
|
||||
|
||||
local = group
|
||||
} else {
|
||||
Logger.log.log(Level.INFO, "Creating local contact", newData)
|
||||
val contact = LocalContact(localAddressBook, newData, fileName, eTag)
|
||||
currentLocalResource = contact
|
||||
contact.create()
|
||||
|
||||
local = contact
|
||||
}
|
||||
syncResult.stats.numInserts++
|
||||
}
|
||||
|
||||
if (groupMethod == GroupMethod.CATEGORIES && local is LocalContact) {
|
||||
// VCard3: update group memberships from CATEGORIES
|
||||
currentLocalResource = local
|
||||
|
||||
val batch = BatchOperation(provider)
|
||||
Logger.log.log(Level.FINE, "Removing contact group memberships")
|
||||
local.removeGroupMemberships(batch)
|
||||
|
||||
for (category in local.contact!!.categories) {
|
||||
val groupID = localAddressBook.findOrCreateGroup(category)
|
||||
Logger.log.log(Level.FINE, "Adding membership in group $category ($groupID)")
|
||||
local.addToGroup(batch, groupID)
|
||||
}
|
||||
|
||||
batch.commit()
|
||||
}
|
||||
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N && Build.VERSION.SDK_INT < Build.VERSION_CODES.O && local is LocalContact)
|
||||
// workaround for Android 7 which sets DIRTY flag when only meta-data is changed
|
||||
local.updateHashCode(null)
|
||||
|
||||
currentLocalResource = null
|
||||
}
|
||||
|
||||
|
||||
// downloader helper class
|
||||
|
||||
private inner class ResourceDownloader(
|
||||
val baseUrl: HttpUrl
|
||||
): Contact.Downloader {
|
||||
|
||||
override fun download(url: String, accepts: String): ByteArray? {
|
||||
val httpUrl = HttpUrl.parse(url)
|
||||
if (httpUrl == null) {
|
||||
Logger.log.log(Level.SEVERE, "Invalid external resource URL", url)
|
||||
return null
|
||||
}
|
||||
|
||||
val host = httpUrl.host()
|
||||
if (host == null) {
|
||||
Logger.log.log(Level.SEVERE, "External resource URL doesn't specify a host name", url)
|
||||
return null
|
||||
}
|
||||
|
||||
var resourceClient = HttpClient.create(context)
|
||||
|
||||
// authenticate only against a certain host, and only upon request
|
||||
val username = settings.username()
|
||||
val password = settings.password()
|
||||
if (username != null && password != null)
|
||||
resourceClient = HttpClient.addAuthentication(resourceClient, baseUrl.host(), username, password)
|
||||
|
||||
// allow redirects
|
||||
resourceClient = resourceClient.newBuilder()
|
||||
.followRedirects(true)
|
||||
.build()
|
||||
|
||||
try {
|
||||
val response = resourceClient.newCall(Request.Builder()
|
||||
.get()
|
||||
.url(httpUrl)
|
||||
.build()).execute()
|
||||
|
||||
if (response.isSuccessful)
|
||||
return response.body()?.bytes()
|
||||
else
|
||||
Logger.log.warning("Couldn't download external resource")
|
||||
} catch(e: IOException) {
|
||||
Logger.log.log(Level.SEVERE, "Couldn't download external resource", e)
|
||||
}
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
/*
|
||||
* Copyright © Ricki Hirner (bitfire web engineering).
|
||||
* All rights reserved. This program and the accompanying materials
|
||||
* are made available under the terms of the GNU Public License v3.0
|
||||
* which accompanies this distribution, and is available at
|
||||
* http://www.gnu.org/licenses/gpl.html
|
||||
*/
|
||||
|
||||
package at.bitfire.davdroid.syncadapter
|
||||
|
||||
import android.accounts.AbstractAccountAuthenticator
|
||||
import android.accounts.Account
|
||||
import android.accounts.AccountAuthenticatorResponse
|
||||
import android.accounts.AccountManager
|
||||
import android.app.Service
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.os.Bundle
|
||||
import at.bitfire.davdroid.ui.AccountsActivity
|
||||
|
||||
class NullAuthenticatorService: Service() {
|
||||
|
||||
private lateinit var accountAuthenticator: AccountAuthenticator
|
||||
|
||||
override fun onCreate() {
|
||||
accountAuthenticator = AccountAuthenticator(this)
|
||||
}
|
||||
|
||||
override fun onBind(intent: Intent?) =
|
||||
accountAuthenticator.iBinder.takeIf { intent?.action == android.accounts.AccountManager.ACTION_AUTHENTICATOR_INTENT }
|
||||
|
||||
|
||||
private class AccountAuthenticator(
|
||||
val context: Context
|
||||
): AbstractAccountAuthenticator(context) {
|
||||
|
||||
override fun addAccount(response: AccountAuthenticatorResponse?, accountType: String?, authTokenType: String?, requiredFeatures: Array<String>?, options: Bundle?): Bundle {
|
||||
val intent = Intent(context, AccountsActivity::class.java)
|
||||
intent.putExtra(AccountManager.KEY_ACCOUNT_AUTHENTICATOR_RESPONSE, response)
|
||||
val bundle = Bundle(1)
|
||||
bundle.putParcelable(AccountManager.KEY_INTENT, intent)
|
||||
return bundle
|
||||
}
|
||||
|
||||
override fun editProperties(response: AccountAuthenticatorResponse?, accountType: String?) = null
|
||||
override fun getAuthTokenLabel(p0: String?) = null
|
||||
override fun confirmCredentials(p0: AccountAuthenticatorResponse?, p1: Account?, p2: Bundle?) = null
|
||||
override fun updateCredentials(p0: AccountAuthenticatorResponse?, p1: Account?, p2: String?, p3: Bundle?) = null
|
||||
override fun getAuthToken(p0: AccountAuthenticatorResponse?, p1: Account?, p2: String?, p3: Bundle?) = null
|
||||
override fun hasFeatures(p0: AccountAuthenticatorResponse?, p1: Account?, p2: Array<out String>?) = null
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
/*
|
||||
* 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.app.PendingIntent
|
||||
import android.app.Service
|
||||
import android.content.*
|
||||
import android.net.ConnectivityManager
|
||||
import android.net.wifi.WifiManager
|
||||
import android.os.Bundle
|
||||
import android.support.v4.app.NotificationCompat
|
||||
import android.support.v4.app.NotificationManagerCompat
|
||||
import at.bitfire.davdroid.AccountSettings
|
||||
import at.bitfire.davdroid.App
|
||||
import at.bitfire.davdroid.Constants
|
||||
import at.bitfire.davdroid.R
|
||||
import at.bitfire.davdroid.log.Logger
|
||||
import at.bitfire.davdroid.ui.PermissionsActivity
|
||||
import java.util.*
|
||||
import java.util.logging.Level
|
||||
|
||||
abstract class SyncAdapterService: Service() {
|
||||
|
||||
abstract protected fun syncAdapter(): AbstractThreadedSyncAdapter
|
||||
|
||||
override fun onBind(intent: Intent?) = syncAdapter().syncAdapterBinder!!
|
||||
|
||||
|
||||
abstract class SyncAdapter(
|
||||
context: Context
|
||||
): AbstractThreadedSyncAdapter(context, false) {
|
||||
|
||||
abstract fun sync(account: Account, extras: Bundle, authority: String, provider: ContentProviderClient, syncResult: SyncResult)
|
||||
|
||||
override fun onPerformSync(account: Account, extras: Bundle, authority: String, provider: ContentProviderClient, syncResult: SyncResult) {
|
||||
Logger.log.log(Level.INFO, "$authority sync of $account has been initiated", extras.keySet().joinToString(", "))
|
||||
|
||||
// required for dav4android (ServiceLoader)
|
||||
Thread.currentThread().contextClassLoader = context.classLoader
|
||||
|
||||
sync(account, extras, authority, provider, syncResult)
|
||||
|
||||
Logger.log.info("Sync for $authority complete")
|
||||
}
|
||||
|
||||
override fun onSecurityException(account: Account, extras: Bundle, authority: String, syncResult: SyncResult) {
|
||||
Logger.log.log(Level.WARNING, "Security exception when opening content provider for $authority")
|
||||
syncResult.databaseError = true
|
||||
|
||||
val intent = Intent(context, PermissionsActivity::class.java)
|
||||
intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||
|
||||
val notify = NotificationCompat.Builder(context)
|
||||
.setSmallIcon(R.drawable.ic_error_light)
|
||||
.setLargeIcon(App.getLauncherBitmap(context))
|
||||
.setContentTitle(context.getString(R.string.sync_error_permissions))
|
||||
.setContentText(context.getString(R.string.sync_error_permissions_text))
|
||||
.setContentIntent(PendingIntent.getActivity(context, 0, intent, PendingIntent.FLAG_UPDATE_CURRENT))
|
||||
.setCategory(NotificationCompat.CATEGORY_ERROR)
|
||||
.build()
|
||||
val nm = NotificationManagerCompat.from(context)
|
||||
nm.notify(Constants.NOTIFICATION_PERMISSIONS, notify)
|
||||
}
|
||||
|
||||
protected fun checkSyncConditions(settings: AccountSettings): Boolean {
|
||||
if (settings.getSyncWifiOnly()) {
|
||||
val connectivityManager = context.getSystemService(CONNECTIVITY_SERVICE) as ConnectivityManager
|
||||
val network = connectivityManager.activeNetworkInfo
|
||||
if (network == null || network.type != ConnectivityManager.TYPE_WIFI || !network.isConnected) {
|
||||
Logger.log.info("Not on connected WiFi, stopping")
|
||||
return false
|
||||
}
|
||||
|
||||
settings.getSyncWifiOnlySSID()?.let { onlySSID ->
|
||||
val quotedSSID = "\"$onlySSID\""
|
||||
val wifi = context.applicationContext.getSystemService(WIFI_SERVICE) as WifiManager
|
||||
val info = wifi.connectionInfo
|
||||
if (info == null || info.ssid != quotedSSID) {
|
||||
Logger.log.info("Connected to wrong WiFi network (${info.ssid}, required: $quotedSSID), ignoring")
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,459 +0,0 @@
|
||||
/*
|
||||
* Copyright © 2013 – 2015 Ricki Hirner (bitfire web engineering).
|
||||
* All rights reserved. This program and the accompanying materials
|
||||
* are made available under the terms of the GNU Public License v3.0
|
||||
* which accompanies this distribution, and is available at
|
||||
* http://www.gnu.org/licenses/gpl.html
|
||||
*/
|
||||
package at.bitfire.davdroid.syncadapter;
|
||||
|
||||
import android.accounts.Account;
|
||||
import android.annotation.TargetApi;
|
||||
import android.app.Notification;
|
||||
import android.app.NotificationManager;
|
||||
import android.app.PendingIntent;
|
||||
import android.content.ContentResolver;
|
||||
import android.content.Context;
|
||||
import android.content.Intent;
|
||||
import android.content.SyncResult;
|
||||
import android.os.Build;
|
||||
import android.os.Bundle;
|
||||
import android.text.TextUtils;
|
||||
|
||||
import com.squareup.okhttp.HttpUrl;
|
||||
import com.squareup.okhttp.RequestBody;
|
||||
|
||||
import org.slf4j.Logger;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.Date;
|
||||
import java.util.HashMap;
|
||||
import java.util.HashSet;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.UUID;
|
||||
|
||||
import at.bitfire.dav4android.DavResource;
|
||||
import at.bitfire.dav4android.exception.ConflictException;
|
||||
import at.bitfire.dav4android.exception.DavException;
|
||||
import at.bitfire.dav4android.exception.HttpException;
|
||||
import at.bitfire.dav4android.exception.PreconditionFailedException;
|
||||
import at.bitfire.dav4android.exception.ServiceUnavailableException;
|
||||
import at.bitfire.dav4android.exception.UnauthorizedException;
|
||||
import at.bitfire.dav4android.property.GetCTag;
|
||||
import at.bitfire.dav4android.property.GetETag;
|
||||
import at.bitfire.davdroid.Constants;
|
||||
import at.bitfire.davdroid.HttpClient;
|
||||
import at.bitfire.davdroid.R;
|
||||
import at.bitfire.davdroid.log.ExternalFileLogger;
|
||||
import at.bitfire.davdroid.resource.LocalCollection;
|
||||
import at.bitfire.davdroid.resource.LocalResource;
|
||||
import at.bitfire.davdroid.ui.DebugInfoActivity;
|
||||
import at.bitfire.davdroid.ui.settings.AccountActivity;
|
||||
import at.bitfire.ical4android.CalendarStorageException;
|
||||
import at.bitfire.vcard4android.ContactsStorageException;
|
||||
|
||||
abstract public class SyncManager {
|
||||
|
||||
protected final int SYNC_PHASE_PREPARE = 0,
|
||||
SYNC_PHASE_QUERY_CAPABILITIES = 1,
|
||||
SYNC_PHASE_PROCESS_LOCALLY_DELETED = 2,
|
||||
SYNC_PHASE_PREPARE_DIRTY = 3,
|
||||
SYNC_PHASE_UPLOAD_DIRTY = 4,
|
||||
SYNC_PHASE_CHECK_SYNC_STATE = 5,
|
||||
SYNC_PHASE_LIST_LOCAL = 6,
|
||||
SYNC_PHASE_LIST_REMOTE = 7,
|
||||
SYNC_PHASE_COMPARE_LOCAL_REMOTE = 8,
|
||||
SYNC_PHASE_DOWNLOAD_REMOTE = 9,
|
||||
SYNC_PHASE_SAVE_SYNC_STATE = 10;
|
||||
|
||||
protected final NotificationManager notificationManager;
|
||||
protected final int notificationId;
|
||||
|
||||
protected final Context context;
|
||||
protected final Account account;
|
||||
protected final Bundle extras;
|
||||
protected final String authority;
|
||||
protected final SyncResult syncResult;
|
||||
|
||||
protected final AccountSettings settings;
|
||||
protected LocalCollection localCollection;
|
||||
|
||||
protected Logger log;
|
||||
|
||||
protected final HttpClient httpClient;
|
||||
protected HttpUrl collectionURL;
|
||||
protected DavResource davCollection;
|
||||
|
||||
|
||||
/** remote CTag at the time of {@link #listRemote()} */
|
||||
protected String remoteCTag = null;
|
||||
|
||||
/** sync-able resources in the local collection, as enumerated by {@link #listLocal()} */
|
||||
protected Map<String, LocalResource> localResources;
|
||||
|
||||
/** sync-able resources in the remote collection, as enumerated by {@link #listRemote()} */
|
||||
protected Map<String, DavResource> remoteResources;
|
||||
|
||||
/** resources which have changed on the server, as determined by {@link #compareLocalRemote()} */
|
||||
protected Set<DavResource> toDownload;
|
||||
|
||||
|
||||
|
||||
public SyncManager(int notificationId, Context context, Account account, Bundle extras, String authority, SyncResult syncResult) {
|
||||
this.context = context;
|
||||
this.account = account;
|
||||
this.extras = extras;
|
||||
this.authority = authority;
|
||||
this.syncResult = syncResult;
|
||||
|
||||
// get account settings and log to file (if requested)
|
||||
settings = new AccountSettings(context, account);
|
||||
try {
|
||||
if (settings.logToExternalFile())
|
||||
log = new ExternalFileLogger(context, "davdroid-SyncManager-" + account.name + "-" + authority + ".txt", settings.logVerbose());
|
||||
} catch(IOException e) {
|
||||
log.error("Couldn't log to external file", e);
|
||||
}
|
||||
if (log == null)
|
||||
log = Constants.log;
|
||||
|
||||
// create HttpClient with given logger
|
||||
httpClient = new HttpClient(log, context, settings.username(), settings.password(), settings.preemptiveAuth());
|
||||
|
||||
// dismiss previous error notifications
|
||||
notificationManager = (NotificationManager) context.getSystemService(Context.NOTIFICATION_SERVICE);
|
||||
notificationManager.cancel(account.name, this.notificationId = notificationId);
|
||||
}
|
||||
|
||||
protected abstract String getSyncErrorTitle();
|
||||
|
||||
@TargetApi(21)
|
||||
public void performSync() {
|
||||
int syncPhase = SYNC_PHASE_PREPARE;
|
||||
try {
|
||||
log.info("Preparing synchronization");
|
||||
prepare();
|
||||
|
||||
if (Thread.interrupted())
|
||||
return;
|
||||
syncPhase = SYNC_PHASE_QUERY_CAPABILITIES;
|
||||
log.info("Querying capabilities");
|
||||
queryCapabilities();
|
||||
|
||||
syncPhase = SYNC_PHASE_PROCESS_LOCALLY_DELETED;
|
||||
log.info("Processing locally deleted entries");
|
||||
processLocallyDeleted();
|
||||
|
||||
if (Thread.interrupted())
|
||||
return;
|
||||
syncPhase = SYNC_PHASE_PREPARE_DIRTY;
|
||||
log.info("Locally preparing dirty entries");
|
||||
prepareDirty();
|
||||
|
||||
syncPhase = SYNC_PHASE_UPLOAD_DIRTY;
|
||||
log.info("Uploading dirty entries");
|
||||
uploadDirty();
|
||||
|
||||
syncPhase = SYNC_PHASE_CHECK_SYNC_STATE;
|
||||
log.info("Checking sync state");
|
||||
if (checkSyncState()) {
|
||||
syncPhase = SYNC_PHASE_LIST_LOCAL;
|
||||
log.info("Listing local entries");
|
||||
listLocal();
|
||||
|
||||
if (Thread.interrupted())
|
||||
return;
|
||||
syncPhase = SYNC_PHASE_LIST_REMOTE;
|
||||
log.info("Listing remote entries");
|
||||
listRemote();
|
||||
|
||||
if (Thread.interrupted())
|
||||
return;
|
||||
syncPhase = SYNC_PHASE_COMPARE_LOCAL_REMOTE;
|
||||
log.info("Comparing local/remote entries");
|
||||
compareLocalRemote();
|
||||
|
||||
syncPhase = SYNC_PHASE_DOWNLOAD_REMOTE;
|
||||
log.info("Downloading remote entries");
|
||||
downloadRemote();
|
||||
|
||||
syncPhase = SYNC_PHASE_SAVE_SYNC_STATE;
|
||||
log.info("Saving sync state");
|
||||
saveSyncState();
|
||||
} else
|
||||
log.info("Remote collection didn't change, skipping remote sync");
|
||||
|
||||
} catch (IOException|ServiceUnavailableException e) {
|
||||
log.error("I/O exception during sync, trying again later", e);
|
||||
syncResult.stats.numIoExceptions++;
|
||||
|
||||
if (e instanceof ServiceUnavailableException) {
|
||||
Date retryAfter = ((ServiceUnavailableException) e).retryAfter;
|
||||
if (retryAfter != null) {
|
||||
// how many seconds to wait? getTime() returns ms, so divide by 1000
|
||||
syncResult.delayUntil = (retryAfter.getTime() - new Date().getTime()) / 1000;
|
||||
}
|
||||
}
|
||||
|
||||
} catch(Exception e) {
|
||||
final int messageString;
|
||||
|
||||
if (e instanceof UnauthorizedException) {
|
||||
log.error("Not authorized anymore", e);
|
||||
messageString = R.string.sync_error_unauthorized;
|
||||
syncResult.stats.numAuthExceptions++;
|
||||
} else if (e instanceof HttpException || e instanceof DavException) {
|
||||
log.error("HTTP/DAV Exception during sync", e);
|
||||
messageString = R.string.sync_error_http_dav;
|
||||
syncResult.stats.numParseExceptions++;
|
||||
} else if (e instanceof CalendarStorageException || e instanceof ContactsStorageException) {
|
||||
log.error("Couldn't access local storage", e);
|
||||
messageString = R.string.sync_error_local_storage;
|
||||
syncResult.databaseError = true;
|
||||
} else {
|
||||
log.error("Unknown sync error", e);
|
||||
messageString = R.string.sync_error;
|
||||
syncResult.stats.numParseExceptions++;
|
||||
}
|
||||
|
||||
final Intent detailsIntent;
|
||||
if (e instanceof UnauthorizedException) {
|
||||
detailsIntent = new Intent(context, AccountActivity.class);
|
||||
detailsIntent.putExtra(AccountActivity.EXTRA_ACCOUNT, account);
|
||||
} else {
|
||||
detailsIntent = new Intent(context, DebugInfoActivity.class);
|
||||
detailsIntent.putExtra(DebugInfoActivity.KEY_EXCEPTION, e);
|
||||
detailsIntent.putExtra(DebugInfoActivity.KEY_ACCOUNT, account);
|
||||
detailsIntent.putExtra(DebugInfoActivity.KEY_AUTHORITY, authority);
|
||||
detailsIntent.putExtra(DebugInfoActivity.KEY_PHASE, syncPhase);
|
||||
}
|
||||
|
||||
Notification.Builder builder = new Notification.Builder(context);
|
||||
Notification notification;
|
||||
builder .setSmallIcon(R.drawable.ic_launcher)
|
||||
.setContentTitle(getSyncErrorTitle())
|
||||
.setContentIntent(PendingIntent.getActivity(context, notificationId, detailsIntent, PendingIntent.FLAG_UPDATE_CURRENT));
|
||||
|
||||
if (Build.VERSION.SDK_INT >= 20)
|
||||
builder.setLocalOnly(true);
|
||||
|
||||
try {
|
||||
String[] phases = context.getResources().getStringArray(R.array.sync_error_phases);
|
||||
String message = context.getString(messageString, phases[syncPhase]);
|
||||
builder.setContentText(message);
|
||||
} catch (IndexOutOfBoundsException ex) {
|
||||
// should never happen
|
||||
}
|
||||
|
||||
if (Build.VERSION.SDK_INT >= 16) {
|
||||
if (Build.VERSION.SDK_INT >= 21)
|
||||
builder.setCategory(Notification.CATEGORY_ERROR);
|
||||
notification = builder.build();
|
||||
} else {
|
||||
notification = builder.getNotification();
|
||||
}
|
||||
notificationManager.notify(account.name, notificationId, notification);
|
||||
} finally {
|
||||
if (log instanceof ExternalFileLogger)
|
||||
try {
|
||||
((ExternalFileLogger)log).close();
|
||||
} catch (IOException e) {
|
||||
Constants.log.error("Couldn't close external log file", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
abstract protected void prepare() throws ContactsStorageException;
|
||||
|
||||
abstract protected void queryCapabilities() throws IOException, HttpException, DavException, CalendarStorageException, ContactsStorageException;
|
||||
|
||||
/**
|
||||
* Process locally deleted entries (DELETE them on the server as well).
|
||||
* Checks Thread.interrupted() before each request to allow quick sync cancellation.
|
||||
*/
|
||||
protected void processLocallyDeleted() throws CalendarStorageException, ContactsStorageException {
|
||||
// Remove locally deleted entries from server (if they have a name, i.e. if they were uploaded before),
|
||||
// but only if they don't have changed on the server. Then finally remove them from the local address book.
|
||||
LocalResource[] localList = localCollection.getDeleted();
|
||||
for (LocalResource local : localList) {
|
||||
if (Thread.interrupted())
|
||||
return;
|
||||
|
||||
final String fileName = local.getFileName();
|
||||
if (!TextUtils.isEmpty(fileName)) {
|
||||
log.info(fileName + " has been deleted locally -> deleting from server");
|
||||
try {
|
||||
new DavResource(log, httpClient, collectionURL.newBuilder().addPathSegment(fileName).build())
|
||||
.delete(local.getETag());
|
||||
} catch (IOException|HttpException e) {
|
||||
log.warn("Couldn't delete " + fileName + " from server; ignoring (may be downloaded again)");
|
||||
}
|
||||
} else
|
||||
log.info("Removing local record #" + local.getId() + " which has been deleted locally and was never uploaded");
|
||||
local.delete();
|
||||
syncResult.stats.numDeletes++;
|
||||
}
|
||||
}
|
||||
|
||||
protected void prepareDirty() throws CalendarStorageException, ContactsStorageException {
|
||||
// assign file names and UIDs to new contacts so that we can use the file name as an index
|
||||
for (LocalResource local : localCollection.getWithoutFileName()) {
|
||||
String uuid = UUID.randomUUID().toString();
|
||||
log.info("Found local record #" + local.getId() + " without file name; assigning file name/UID based on " + uuid);
|
||||
local.updateFileNameAndUID(uuid);
|
||||
}
|
||||
}
|
||||
|
||||
abstract protected RequestBody prepareUpload(LocalResource resource) throws IOException, CalendarStorageException, ContactsStorageException;
|
||||
|
||||
/**
|
||||
* Uploads dirty records to the server, using a PUT request for each record.
|
||||
* Checks Thread.interrupted() before each request to allow quick sync cancellation.
|
||||
*/
|
||||
protected void uploadDirty() throws IOException, HttpException, CalendarStorageException, ContactsStorageException {
|
||||
// upload dirty contacts
|
||||
for (LocalResource local : localCollection.getDirty()) {
|
||||
if (Thread.interrupted())
|
||||
return;
|
||||
|
||||
final String fileName = local.getFileName();
|
||||
|
||||
DavResource remote = new DavResource(log, httpClient, collectionURL.newBuilder().addPathSegment(fileName).build());
|
||||
|
||||
// generate entity to upload (VCard, iCal, whatever)
|
||||
RequestBody body = prepareUpload(local);
|
||||
|
||||
try {
|
||||
|
||||
if (local.getETag() == null) {
|
||||
log.info("Uploading new record " + fileName);
|
||||
remote.put(body, null, true);
|
||||
} else {
|
||||
log.info("Uploading locally modified record " + fileName);
|
||||
remote.put(body, local.getETag(), false);
|
||||
}
|
||||
|
||||
} catch (ConflictException|PreconditionFailedException e) {
|
||||
// we can't interact with the user to resolve the conflict, so we treat 409 like 412
|
||||
log.info("Resource has been modified on the server before upload, ignoring", e);
|
||||
}
|
||||
|
||||
String eTag = null;
|
||||
GetETag newETag = (GetETag) remote.properties.get(GetETag.NAME);
|
||||
if (newETag != null) {
|
||||
eTag = newETag.eTag;
|
||||
log.debug("Received new ETag=" + eTag + " after uploading");
|
||||
} else
|
||||
log.debug("Didn't receive new ETag after uploading, setting to null");
|
||||
|
||||
local.clearDirty(eTag);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks the current sync state (e.g. CTag) and whether synchronization from remote is required.
|
||||
* @return <ul>
|
||||
* <li><code>true</code> if the remote collection has changed, i.e. synchronization from remote is required</li>
|
||||
* <li><code>false</code> if the remote collection hasn't changed</li>
|
||||
* </ul>
|
||||
*/
|
||||
protected boolean checkSyncState() throws CalendarStorageException, ContactsStorageException {
|
||||
// check CTag (ignore on manual sync)
|
||||
GetCTag getCTag = (GetCTag)davCollection.properties.get(GetCTag.NAME);
|
||||
if (getCTag != null)
|
||||
remoteCTag = getCTag.cTag;
|
||||
|
||||
String localCTag = null;
|
||||
if (extras.containsKey(ContentResolver.SYNC_EXTRAS_MANUAL))
|
||||
log.info("Manual sync, ignoring CTag");
|
||||
else
|
||||
localCTag = localCollection.getCTag();
|
||||
|
||||
if (remoteCTag != null && remoteCTag.equals(localCTag)) {
|
||||
log.info("Remote collection didn't change (CTag=" + remoteCTag + "), no need to query children");
|
||||
return false;
|
||||
} else
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Lists all local resources which should be taken into account for synchronization into {@link #localResources}.
|
||||
*/
|
||||
protected void listLocal() throws CalendarStorageException, ContactsStorageException {
|
||||
// fetch list of local contacts and build hash table to index file name
|
||||
LocalResource[] localList = localCollection.getAll();
|
||||
localResources = new HashMap<>(localList.length);
|
||||
for (LocalResource resource : localList) {
|
||||
log.debug("Found local resource: " + resource.getFileName());
|
||||
localResources.put(resource.getFileName(), resource);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Lists all members of the remote collection which should be taken into account for synchronization into {@link #remoteResources}.
|
||||
*/
|
||||
abstract protected void listRemote() throws IOException, HttpException, DavException;
|
||||
|
||||
/**
|
||||
* Compares {@link #localResources} and {@link #remoteResources} by file name and ETag:
|
||||
* <ul>
|
||||
* <li>Local resources which are not available in the remote collection (anymore) will be removed.</li>
|
||||
* <li>Resources whose remote ETag has changed will be added into {@link #toDownload}</li>
|
||||
* </ul>
|
||||
*/
|
||||
protected void compareLocalRemote() throws IOException, HttpException, DavException, CalendarStorageException, ContactsStorageException {
|
||||
/* check which contacts
|
||||
1. are not present anymore remotely -> delete immediately on local side
|
||||
2. updated remotely -> add to downloadNames
|
||||
3. added remotely -> add to downloadNames
|
||||
*/
|
||||
toDownload = new HashSet<>();
|
||||
for (String localName : localResources.keySet()) {
|
||||
DavResource remote = remoteResources.get(localName);
|
||||
if (remote == null) {
|
||||
log.info(localName + " is not on server anymore, deleting");
|
||||
localResources.get(localName).delete();
|
||||
syncResult.stats.numDeletes++;
|
||||
} else {
|
||||
// contact is still on server, check whether it has been updated remotely
|
||||
GetETag getETag = (GetETag) remote.properties.get(GetETag.NAME);
|
||||
if (getETag == null || getETag.eTag == null)
|
||||
throw new DavException("Server didn't provide ETag");
|
||||
String localETag = localResources.get(localName).getETag(),
|
||||
remoteETag = getETag.eTag;
|
||||
if (remoteETag.equals(localETag))
|
||||
syncResult.stats.numSkippedEntries++;
|
||||
else {
|
||||
log.info(localName + " has been changed on server (current ETag=" + remoteETag + ", last known ETag=" + localETag + ")");
|
||||
toDownload.add(remote);
|
||||
}
|
||||
|
||||
// remote entry has been seen, remove from list
|
||||
remoteResources.remove(localName);
|
||||
}
|
||||
}
|
||||
|
||||
// add all unseen (= remotely added) remote contacts
|
||||
if (!remoteResources.isEmpty()) {
|
||||
log.info("New resources have been found on the server: " + TextUtils.join(", ", remoteResources.keySet()));
|
||||
toDownload.addAll(remoteResources.values());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Downloads the remote resources in {@link #toDownload} and stores them locally.
|
||||
* Must check Thread.interrupted() periodically to allow quick sync cancellation.
|
||||
*/
|
||||
abstract protected void downloadRemote() throws IOException, HttpException, DavException, ContactsStorageException, CalendarStorageException;
|
||||
|
||||
protected void saveSyncState() throws CalendarStorageException, ContactsStorageException {
|
||||
/* Save sync state (CTag). It doesn't matter if it has changed during the sync process
|
||||
(for instance, because another client has uploaded changes), because this will simply
|
||||
cause all remote entries to be listed at the next sync. */
|
||||
log.info("Saving CTag=" + remoteCTag);
|
||||
localCollection.setCTag(remoteCTag);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,462 @@
|
||||
/*
|
||||
* Copyright © Ricki Hirner (bitfire web engineering).
|
||||
* All rights reserved. This program and the accompanying materials
|
||||
* are made available under the terms of the GNU Public License v3.0
|
||||
* which accompanies this distribution, and is available at
|
||||
* http://www.gnu.org/licenses/gpl.html
|
||||
*/
|
||||
package at.bitfire.davdroid.syncadapter
|
||||
|
||||
import android.accounts.Account
|
||||
import android.app.PendingIntent
|
||||
import android.content.ContentResolver
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.SyncResult
|
||||
import android.net.Uri
|
||||
import android.os.Bundle
|
||||
import android.support.v4.app.NotificationManagerCompat
|
||||
import android.support.v7.app.NotificationCompat
|
||||
import at.bitfire.dav4android.DavResource
|
||||
import at.bitfire.dav4android.exception.*
|
||||
import at.bitfire.dav4android.property.GetCTag
|
||||
import at.bitfire.dav4android.property.GetETag
|
||||
import at.bitfire.davdroid.AccountSettings
|
||||
import at.bitfire.davdroid.App
|
||||
import at.bitfire.davdroid.HttpClient
|
||||
import at.bitfire.davdroid.R
|
||||
import at.bitfire.davdroid.log.Logger
|
||||
import at.bitfire.davdroid.resource.LocalCollection
|
||||
import at.bitfire.davdroid.resource.LocalResource
|
||||
import at.bitfire.davdroid.ui.AccountSettingsActivity
|
||||
import at.bitfire.davdroid.ui.DebugInfoActivity
|
||||
import at.bitfire.ical4android.CalendarStorageException
|
||||
import at.bitfire.vcard4android.ContactsStorageException
|
||||
import okhttp3.HttpUrl
|
||||
import okhttp3.RequestBody
|
||||
import java.io.IOException
|
||||
import java.util.*
|
||||
import java.util.logging.Level
|
||||
|
||||
abstract class SyncManager(
|
||||
val context: Context,
|
||||
val account: Account,
|
||||
val settings: AccountSettings,
|
||||
val extras: Bundle,
|
||||
val authority: String,
|
||||
val syncResult: SyncResult,
|
||||
val uniqueCollectionId: String
|
||||
) {
|
||||
|
||||
val SYNC_PHASE_PREPARE = 0
|
||||
val SYNC_PHASE_QUERY_CAPABILITIES = 1
|
||||
val SYNC_PHASE_PROCESS_LOCALLY_DELETED = 2
|
||||
val SYNC_PHASE_PREPARE_DIRTY = 3
|
||||
val SYNC_PHASE_UPLOAD_DIRTY = 4
|
||||
val SYNC_PHASE_CHECK_SYNC_STATE = 5
|
||||
val SYNC_PHASE_LIST_LOCAL = 6
|
||||
val SYNC_PHASE_LIST_REMOTE = 7
|
||||
val SYNC_PHASE_COMPARE_LOCAL_REMOTE = 8
|
||||
val SYNC_PHASE_DOWNLOAD_REMOTE = 9
|
||||
val SYNC_PHASE_POST_PROCESSING = 10
|
||||
val SYNC_PHASE_SAVE_SYNC_STATE = 11
|
||||
|
||||
protected val notificationManager = NotificationManagerCompat.from(context)!!
|
||||
|
||||
protected lateinit var localCollection: LocalCollection<*>
|
||||
|
||||
protected val httpClient = HttpClient.create(context, settings)
|
||||
protected lateinit var collectionURL: HttpUrl
|
||||
protected lateinit var davCollection: DavResource
|
||||
|
||||
|
||||
/** state information for debug info (local resource) */
|
||||
protected var currentLocalResource: LocalResource? = null
|
||||
|
||||
/** state information for debug info (remote resource) */
|
||||
protected var currentDavResource: DavResource? = null
|
||||
|
||||
|
||||
/** remote CTag at the time of {@link #listRemote()} */
|
||||
protected var remoteCTag: String? = null
|
||||
|
||||
/** sync-able resources in the local collection, as enumerated by {@link #listLocal()} */
|
||||
protected lateinit var localResources: MutableMap<String, LocalResource>
|
||||
|
||||
/** sync-able resources in the remote collection, as enumerated by {@link #listRemote()} */
|
||||
protected lateinit var remoteResources: MutableMap<String, DavResource>
|
||||
|
||||
/** resources which have changed on the server, as determined by {@link #compareLocalRemote()} */
|
||||
protected val toDownload = mutableSetOf<DavResource>()
|
||||
|
||||
|
||||
companion object {
|
||||
infix fun <T> Set<T>.disjunct(other: Set<T>) = (this - other) union (other - this)
|
||||
}
|
||||
|
||||
|
||||
protected abstract fun notificationId(): Int
|
||||
protected abstract fun getSyncErrorTitle(): String
|
||||
|
||||
fun performSync() {
|
||||
// dismiss previous error notifications
|
||||
notificationManager.cancel(uniqueCollectionId, notificationId())
|
||||
|
||||
var syncPhase = SYNC_PHASE_PREPARE
|
||||
try {
|
||||
Logger.log.info("Preparing synchronization")
|
||||
if (!prepare()) {
|
||||
Logger.log.info("No reason to synchronize, aborting")
|
||||
return
|
||||
}
|
||||
|
||||
if (Thread.interrupted())
|
||||
return
|
||||
syncPhase = SYNC_PHASE_QUERY_CAPABILITIES
|
||||
Logger.log.info("Querying capabilities")
|
||||
queryCapabilities()
|
||||
|
||||
syncPhase = SYNC_PHASE_PROCESS_LOCALLY_DELETED
|
||||
Logger.log.info("Processing locally deleted entries")
|
||||
processLocallyDeleted()
|
||||
|
||||
if (Thread.interrupted())
|
||||
return
|
||||
syncPhase = SYNC_PHASE_PREPARE_DIRTY
|
||||
Logger.log.info("Locally preparing dirty entries")
|
||||
prepareDirty()
|
||||
|
||||
syncPhase = SYNC_PHASE_UPLOAD_DIRTY
|
||||
Logger.log.info("Uploading dirty entries")
|
||||
uploadDirty()
|
||||
|
||||
syncPhase = SYNC_PHASE_CHECK_SYNC_STATE
|
||||
Logger.log.info("Checking sync state")
|
||||
if (checkSyncState()) {
|
||||
syncPhase = SYNC_PHASE_LIST_LOCAL
|
||||
Logger.log.info("Listing local entries")
|
||||
listLocal()
|
||||
|
||||
if (Thread.interrupted())
|
||||
return
|
||||
syncPhase = SYNC_PHASE_LIST_REMOTE
|
||||
Logger.log.info("Listing remote entries")
|
||||
listRemote()
|
||||
|
||||
if (Thread.interrupted())
|
||||
return
|
||||
syncPhase = SYNC_PHASE_COMPARE_LOCAL_REMOTE
|
||||
Logger.log.info("Comparing local/remote entries")
|
||||
compareLocalRemote()
|
||||
|
||||
syncPhase = SYNC_PHASE_DOWNLOAD_REMOTE
|
||||
Logger.log.info("Downloading remote entries")
|
||||
downloadRemote()
|
||||
|
||||
syncPhase = SYNC_PHASE_POST_PROCESSING
|
||||
Logger.log.info("Post-processing")
|
||||
postProcess()
|
||||
|
||||
syncPhase = SYNC_PHASE_SAVE_SYNC_STATE
|
||||
Logger.log.info("Saving sync state")
|
||||
saveSyncState()
|
||||
} else
|
||||
Logger.log.info("Remote collection didn't change, skipping remote sync")
|
||||
|
||||
} catch(e: IOException) {
|
||||
Logger.log.log(Level.WARNING, "I/O exception during sync, trying again later", e)
|
||||
syncResult.stats.numIoExceptions++
|
||||
} catch(e: ServiceUnavailableException) {
|
||||
Logger.log.log(Level.WARNING, "Got 503 Service unavailable, trying again later", e)
|
||||
syncResult.stats.numIoExceptions++
|
||||
e.retryAfter?.let { retryAfter ->
|
||||
// how many seconds to wait? getTime() returns ms, so divide by 1000
|
||||
syncResult.delayUntil = (retryAfter.time - Date().time) / 1000
|
||||
}
|
||||
} catch(e: Throwable) {
|
||||
val messageString: Int
|
||||
|
||||
when (e) {
|
||||
is UnauthorizedException -> {
|
||||
Logger.log.log(Level.SEVERE, "Not authorized anymore", e)
|
||||
messageString = R.string.sync_error_unauthorized
|
||||
syncResult.stats.numAuthExceptions++
|
||||
}
|
||||
is HttpException, is DavException -> {
|
||||
Logger.log.log(Level.SEVERE, "HTTP/DAV Exception during sync", e)
|
||||
messageString = R.string.sync_error_http_dav
|
||||
syncResult.stats.numParseExceptions++
|
||||
}
|
||||
is CalendarStorageException, is ContactsStorageException -> {
|
||||
Logger.log.log(Level.SEVERE, "Couldn't access local storage", e)
|
||||
messageString = R.string.sync_error_local_storage
|
||||
syncResult.databaseError = true
|
||||
}
|
||||
else -> {
|
||||
Logger.log.log(Level.SEVERE, "Unknown sync error", e)
|
||||
messageString = R.string.sync_error
|
||||
syncResult.stats.numParseExceptions++
|
||||
}
|
||||
}
|
||||
|
||||
val detailsIntent: Intent
|
||||
if (e is UnauthorizedException) {
|
||||
detailsIntent = Intent(context, AccountSettingsActivity::class.java)
|
||||
detailsIntent.putExtra(AccountSettingsActivity.EXTRA_ACCOUNT, account)
|
||||
} else {
|
||||
detailsIntent = Intent(context, DebugInfoActivity::class.java)
|
||||
detailsIntent.putExtra(DebugInfoActivity.KEY_THROWABLE, e)
|
||||
detailsIntent.putExtra(DebugInfoActivity.KEY_ACCOUNT, account)
|
||||
detailsIntent.putExtra(DebugInfoActivity.KEY_AUTHORITY, authority)
|
||||
detailsIntent.putExtra(DebugInfoActivity.KEY_PHASE, syncPhase)
|
||||
currentLocalResource?.let { detailsIntent.putExtra(DebugInfoActivity.KEY_LOCAL_RESOURCE, it.toString()) }
|
||||
currentDavResource?.let { detailsIntent.putExtra(DebugInfoActivity.KEY_REMOTE_RESOURCE, it.toString()) }
|
||||
}
|
||||
|
||||
// to make the PendingIntent unique
|
||||
detailsIntent.data = Uri.parse("uri://${javaClass.name}/$uniqueCollectionId")
|
||||
|
||||
val builder = NotificationCompat.Builder(context)
|
||||
builder .setSmallIcon(R.drawable.ic_error_light)
|
||||
.setLargeIcon(App.getLauncherBitmap(context))
|
||||
.setContentTitle(getSyncErrorTitle())
|
||||
.setContentIntent(PendingIntent.getActivity(context, 0, detailsIntent, PendingIntent.FLAG_CANCEL_CURRENT))
|
||||
.setCategory(NotificationCompat.CATEGORY_ERROR)
|
||||
|
||||
try {
|
||||
val phases = context.resources.getStringArray(R.array.sync_error_phases)
|
||||
val message = context.getString(messageString, phases[syncPhase])
|
||||
builder.setContentText(message)
|
||||
} catch (ex: IndexOutOfBoundsException) {
|
||||
// should never happen
|
||||
}
|
||||
|
||||
notificationManager.notify(uniqueCollectionId, notificationId(), builder.build())
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/** Prepares synchronization (for instance, allocates necessary resources).
|
||||
* @return whether actual synchronization is required / can be made. true = synchronization
|
||||
* shall be continued, false = synchronization can be skipped */
|
||||
abstract protected fun prepare(): Boolean
|
||||
|
||||
abstract protected fun queryCapabilities()
|
||||
|
||||
/**
|
||||
* Process locally deleted entries (DELETE them on the server as well).
|
||||
* Checks Thread.interrupted() before each request to allow quick sync cancellation.
|
||||
*/
|
||||
protected fun processLocallyDeleted() {
|
||||
// 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.getDeleted()
|
||||
for (local in localList) {
|
||||
if (Thread.interrupted())
|
||||
return
|
||||
|
||||
currentLocalResource = local
|
||||
|
||||
val fileName = local.fileName
|
||||
if (fileName != null) {
|
||||
Logger.log.info("$fileName has been deleted locally -> deleting from server")
|
||||
|
||||
val remote = DavResource(httpClient, collectionURL.newBuilder().addPathSegment(fileName).build())
|
||||
currentDavResource = remote
|
||||
try {
|
||||
remote.delete(local.eTag)
|
||||
} 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++
|
||||
|
||||
currentLocalResource = null
|
||||
currentDavResource = null
|
||||
}
|
||||
}
|
||||
|
||||
protected open fun prepareDirty() {
|
||||
// assign file names and UIDs to new contacts so that we can use the file name as an index
|
||||
Logger.log.info("Looking for contacts/groups without file name")
|
||||
for (local in localCollection.getWithoutFileName()) {
|
||||
currentLocalResource = local
|
||||
|
||||
Logger.log.fine("Found local record #${local.id} without file name; generating file name/UID if necessary")
|
||||
local.prepareForUpload()
|
||||
|
||||
currentLocalResource = null
|
||||
}
|
||||
}
|
||||
|
||||
abstract protected fun prepareUpload(resource: LocalResource): RequestBody
|
||||
|
||||
/**
|
||||
* Uploads dirty records to the server, using a PUT request for each record.
|
||||
* Checks Thread.interrupted() before each request to allow quick sync cancellation.
|
||||
*/
|
||||
protected fun uploadDirty() {
|
||||
// upload dirty contacts
|
||||
for (local in localCollection.getDirty()) {
|
||||
if (Thread.interrupted())
|
||||
return
|
||||
|
||||
currentLocalResource = local
|
||||
val fileName = local.fileName
|
||||
|
||||
val remote = DavResource(httpClient, collectionURL.newBuilder().addPathSegment(fileName).build())
|
||||
currentDavResource = 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)
|
||||
}
|
||||
} 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.NAME] as GetETag?
|
||||
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)
|
||||
|
||||
currentLocalResource = null
|
||||
currentDavResource = null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks the current sync state (e.g. CTag) and whether synchronization from remote is required.
|
||||
* @return <ul>
|
||||
* <li><code>true</code> if the remote collection has changed, i.e. synchronization from remote is required</li>
|
||||
* <li><code>false</code> if the remote collection hasn't changed</li>
|
||||
* </ul>
|
||||
*/
|
||||
protected fun checkSyncState(): Boolean {
|
||||
// check CTag (ignore on manual sync)
|
||||
(davCollection.properties[GetCTag.NAME] as GetCTag?)?.let { remoteCTag = it.cTag }
|
||||
|
||||
val localCTag = if (extras.containsKey(ContentResolver.SYNC_EXTRAS_MANUAL)) {
|
||||
Logger.log.info("Manual sync, ignoring CTag")
|
||||
null
|
||||
} else
|
||||
localCollection.getCTag()
|
||||
|
||||
return if (remoteCTag != null && remoteCTag == localCTag) {
|
||||
Logger.log.info("Remote collection didn't change (CTag=$remoteCTag), no need to query children")
|
||||
false
|
||||
} else
|
||||
true
|
||||
}
|
||||
|
||||
/**
|
||||
* Lists all local resources which should be taken into account for synchronization into {@link #localResources}.
|
||||
*/
|
||||
protected fun listLocal() {
|
||||
// fetch list of local contacts and build hash table to index file name
|
||||
val localList = localCollection.getAll()
|
||||
val resources = HashMap<String, LocalResource>(localList.size)
|
||||
for (resource in localList) {
|
||||
Logger.log.fine("Found local resource: ${resource.fileName}")
|
||||
resource.fileName?.let { resources[it] = resource }
|
||||
}
|
||||
localResources = resources
|
||||
}
|
||||
|
||||
/**
|
||||
* Lists all members of the remote collection which should be taken into account for synchronization into {@link #remoteResources}.
|
||||
*/
|
||||
abstract protected fun listRemote()
|
||||
|
||||
/**
|
||||
* Compares {@link #localResources} and {@link #remoteResources} by file name and ETag:
|
||||
* <ul>
|
||||
* <li>Local resources which are not available in the remote collection (anymore) will be removed.</li>
|
||||
* <li>Resources whose remote ETag has changed will be added into {@link #toDownload}</li>
|
||||
* </ul>
|
||||
*/
|
||||
protected fun compareLocalRemote() {
|
||||
/* check which contacts
|
||||
1. are not present anymore remotely -> delete immediately on local side
|
||||
2. updated remotely -> add to downloadNames
|
||||
3. added remotely -> add to downloadNames
|
||||
*/
|
||||
toDownload.clear()
|
||||
for ((name,local) in localResources) {
|
||||
val remote = remoteResources[name]
|
||||
currentDavResource = remote
|
||||
|
||||
if (remote == null) {
|
||||
Logger.log.info("$name is not on server anymore, deleting")
|
||||
currentLocalResource = local
|
||||
local.delete()
|
||||
syncResult.stats.numDeletes++
|
||||
} else {
|
||||
// contact is still on server, check whether it has been updated remotely
|
||||
val getETag = remote.properties[GetETag.NAME] as GetETag?
|
||||
if (getETag == null || getETag.eTag == null)
|
||||
throw DavException("Server didn't provide ETag")
|
||||
val localETag = local.eTag
|
||||
val remoteETag = getETag.eTag
|
||||
if (remoteETag == localETag) {
|
||||
Logger.log.fine("$name has not been changed on server (ETag still $remoteETag)")
|
||||
syncResult.stats.numSkippedEntries++
|
||||
} else {
|
||||
Logger.log.info("$name has been changed on server (current ETag=$remoteETag, last known ETag=$localETag)")
|
||||
toDownload.add(remote)
|
||||
}
|
||||
|
||||
// remote entry has been seen, remove from list
|
||||
remoteResources.remove(name)
|
||||
|
||||
currentDavResource = null
|
||||
currentLocalResource = null
|
||||
}
|
||||
}
|
||||
|
||||
// add all unseen (= remotely added) remote contacts
|
||||
if (remoteResources.isNotEmpty()) {
|
||||
Logger.log.info("New resources have been found on the server: ${remoteResources.keys.joinToString(", ")}")
|
||||
toDownload.addAll(remoteResources.values)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Downloads the remote resources in {@link #toDownload} and stores them locally.
|
||||
* Must check Thread.interrupted() periodically to allow quick sync cancellation.
|
||||
*/
|
||||
abstract protected fun downloadRemote()
|
||||
|
||||
/**
|
||||
* For post-processing of entries, for instance assigning groups.
|
||||
*/
|
||||
protected open fun postProcess() {}
|
||||
|
||||
protected fun saveSyncState() {
|
||||
/* Save sync state (CTag). It doesn't matter if it has changed during the sync process
|
||||
(for instance, because another client has uploaded changes), because this will simply
|
||||
cause all remote entries to be listed at the next sync. */
|
||||
Logger.log.info("Saving CTag=$remoteCTag")
|
||||
localCollection.setCTag(remoteCTag)
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,73 +0,0 @@
|
||||
/*
|
||||
* Copyright © 2013 – 2015 Ricki Hirner (bitfire web engineering).
|
||||
* All rights reserved. This program and the accompanying materials
|
||||
* are made available under the terms of the GNU Public License v3.0
|
||||
* which accompanies this distribution, and is available at
|
||||
* http://www.gnu.org/licenses/gpl.html
|
||||
*/
|
||||
package at.bitfire.davdroid.syncadapter;
|
||||
|
||||
import android.accounts.Account;
|
||||
import android.app.Service;
|
||||
import android.content.AbstractThreadedSyncAdapter;
|
||||
import android.content.ContentProviderClient;
|
||||
import android.content.Context;
|
||||
import android.content.Intent;
|
||||
import android.content.SyncResult;
|
||||
import android.os.Bundle;
|
||||
import android.os.IBinder;
|
||||
|
||||
import at.bitfire.davdroid.Constants;
|
||||
import at.bitfire.davdroid.resource.LocalTaskList;
|
||||
import at.bitfire.ical4android.CalendarStorageException;
|
||||
import at.bitfire.ical4android.TaskProvider;
|
||||
import lombok.Cleanup;
|
||||
|
||||
public class TasksSyncAdapterService extends Service {
|
||||
private static SyncAdapter syncAdapter;
|
||||
|
||||
@Override
|
||||
public void onCreate() {
|
||||
if (syncAdapter == null)
|
||||
syncAdapter = new SyncAdapter(getApplicationContext());
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onDestroy() {
|
||||
syncAdapter = null;
|
||||
}
|
||||
|
||||
@Override
|
||||
public IBinder onBind(Intent intent) {
|
||||
return syncAdapter.getSyncAdapterBinder();
|
||||
}
|
||||
|
||||
|
||||
private static class SyncAdapter extends AbstractThreadedSyncAdapter {
|
||||
public SyncAdapter(Context context) {
|
||||
super(context, false);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onPerformSync(Account account, Bundle extras, String authority, ContentProviderClient providerClient, SyncResult syncResult) {
|
||||
Constants.log.info("Starting task sync (" + authority + ")");
|
||||
|
||||
try {
|
||||
@Cleanup TaskProvider provider = TaskProvider.acquire(getContext().getContentResolver(), TaskProvider.ProviderName.OpenTasks);
|
||||
if (provider == null)
|
||||
throw new CalendarStorageException("Couldn't access OpenTasks provider");
|
||||
|
||||
for (LocalTaskList taskList : (LocalTaskList[])LocalTaskList.find(account, provider, LocalTaskList.Factory.INSTANCE, null, null)) {
|
||||
Constants.log.info("Synchronizing task list #" + taskList.getId() + ", URL: " + taskList.getSyncId());
|
||||
TasksSyncManager syncManager = new TasksSyncManager(getContext(), account, extras, authority, provider, syncResult, taskList);
|
||||
syncManager.performSync();
|
||||
}
|
||||
} catch (CalendarStorageException e) {
|
||||
Constants.log.error("Couldn't enumerate local task lists", e);
|
||||
}
|
||||
|
||||
Constants.log.info("Task sync complete");
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
/*
|
||||
* Copyright © Ricki Hirner (bitfire web engineering).
|
||||
* All rights reserved. This program and the accompanying materials
|
||||
* are made available under the terms of the GNU Public License v3.0
|
||||
* which accompanies this distribution, and is available at
|
||||
* http://www.gnu.org/licenses/gpl.html
|
||||
*/
|
||||
package at.bitfire.davdroid.syncadapter
|
||||
|
||||
import android.accounts.Account
|
||||
import android.content.*
|
||||
import android.database.DatabaseUtils
|
||||
import android.os.Bundle
|
||||
import 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.ServiceDB.Services
|
||||
import at.bitfire.davdroid.resource.LocalTaskList
|
||||
import at.bitfire.ical4android.AndroidTaskList
|
||||
import at.bitfire.ical4android.TaskProvider
|
||||
import org.dmfs.provider.tasks.TaskContract
|
||||
import java.util.logging.Level
|
||||
|
||||
/**
|
||||
* Synchronization manager for CalDAV collections; handles tasks ({@code VTODO}).
|
||||
*/
|
||||
class TasksSyncAdapterService: SyncAdapterService() {
|
||||
|
||||
override fun syncAdapter() = SyncAdapter(this)
|
||||
|
||||
|
||||
protected class SyncAdapter(
|
||||
context: Context
|
||||
): SyncAdapterService.SyncAdapter(context) {
|
||||
|
||||
override fun sync(account: Account, extras: Bundle, authority: String, provider: ContentProviderClient, syncResult: SyncResult) {
|
||||
try {
|
||||
val taskProvider = TaskProvider.fromProviderClient(provider)
|
||||
val settings = AccountSettings(context, account)
|
||||
/* don't run sync if
|
||||
- sync conditions (e.g. "sync only in WiFi") are not met AND
|
||||
- this is is an automatic sync (i.e. manual syncs are run regardless of sync conditions)
|
||||
*/
|
||||
if (!extras.containsKey(ContentResolver.SYNC_EXTRAS_MANUAL) && !checkSyncConditions(settings))
|
||||
return
|
||||
|
||||
updateLocalTaskLists(taskProvider, account, settings)
|
||||
|
||||
for (taskList in AndroidTaskList.find(account, taskProvider, LocalTaskList.Factory, "${TaskContract.TaskLists.SYNC_ENABLED}!=0", null)) {
|
||||
Logger.log.info("Synchronizing task list #${taskList.id} [${taskList.syncId}]")
|
||||
TasksSyncManager(context, account, settings, extras, authority, syncResult, taskProvider, taskList)
|
||||
.performSync()
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Logger.log.log(Level.SEVERE, "Couldn't sync task lists", e)
|
||||
}
|
||||
|
||||
Logger.log.info("Task sync complete")
|
||||
}
|
||||
|
||||
private fun updateLocalTaskLists(provider: TaskProvider, account: Account, settings: AccountSettings) {
|
||||
ServiceDB.OpenHelper(context).use { dbHelper ->
|
||||
val db = dbHelper.readableDatabase
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
// enumerate remote and local task lists
|
||||
val service = getService()
|
||||
val remote = remoteTaskLists(service)
|
||||
|
||||
// delete/update local task lists
|
||||
val updateColors = settings.getManageCalendarColors()
|
||||
|
||||
for (list in AndroidTaskList.find(account, provider, LocalTaskList.Factory, null, null))
|
||||
list.syncId?.let { url ->
|
||||
val info = remote[url]
|
||||
if (info == null) {
|
||||
Logger.log.fine("Deleting obsolete local task list $url")
|
||||
list.delete()
|
||||
} else {
|
||||
// remote CollectionInfo found for this local collection, update data
|
||||
Logger.log.log(Level.FINE, "Updating local task list $url", info)
|
||||
list.update(info, updateColors)
|
||||
// we already have a local task list for this remote collection, don't take into consideration anymore
|
||||
remote -= url
|
||||
}
|
||||
}
|
||||
|
||||
// create new local task lists
|
||||
for ((_,info) in remote) {
|
||||
Logger.log.log(Level.INFO, "Adding local task list", info)
|
||||
LocalTaskList.create(account, provider, info)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,221 +0,0 @@
|
||||
/*
|
||||
* Copyright © 2013 – 2015 Ricki Hirner (bitfire web engineering).
|
||||
* All rights reserved. This program and the accompanying materials
|
||||
* are made available under the terms of the GNU Public License v3.0
|
||||
* which accompanies this distribution, and is available at
|
||||
* http://www.gnu.org/licenses/gpl.html
|
||||
*/
|
||||
|
||||
package at.bitfire.davdroid.syncadapter;
|
||||
|
||||
import android.accounts.Account;
|
||||
import android.content.ContentValues;
|
||||
import android.content.Context;
|
||||
import android.content.SyncResult;
|
||||
import android.os.Bundle;
|
||||
import android.text.TextUtils;
|
||||
|
||||
import com.squareup.okhttp.HttpUrl;
|
||||
import com.squareup.okhttp.MediaType;
|
||||
import com.squareup.okhttp.RequestBody;
|
||||
import com.squareup.okhttp.ResponseBody;
|
||||
|
||||
import org.apache.commons.codec.Charsets;
|
||||
import org.apache.commons.lang3.StringUtils;
|
||||
import org.dmfs.provider.tasks.TaskContract.TaskLists;
|
||||
|
||||
import java.io.ByteArrayInputStream;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.nio.charset.Charset;
|
||||
import java.util.HashMap;
|
||||
import java.util.LinkedList;
|
||||
import java.util.List;
|
||||
|
||||
import at.bitfire.dav4android.DavCalendar;
|
||||
import at.bitfire.dav4android.DavResource;
|
||||
import at.bitfire.dav4android.exception.DavException;
|
||||
import at.bitfire.dav4android.exception.HttpException;
|
||||
import at.bitfire.dav4android.property.CalendarColor;
|
||||
import at.bitfire.dav4android.property.CalendarData;
|
||||
import at.bitfire.dav4android.property.DisplayName;
|
||||
import at.bitfire.dav4android.property.GetCTag;
|
||||
import at.bitfire.dav4android.property.GetContentType;
|
||||
import at.bitfire.dav4android.property.GetETag;
|
||||
import at.bitfire.davdroid.ArrayUtils;
|
||||
import at.bitfire.davdroid.Constants;
|
||||
import at.bitfire.davdroid.R;
|
||||
import at.bitfire.davdroid.resource.LocalCalendar;
|
||||
import at.bitfire.davdroid.resource.LocalResource;
|
||||
import at.bitfire.davdroid.resource.LocalTask;
|
||||
import at.bitfire.davdroid.resource.LocalTaskList;
|
||||
import at.bitfire.ical4android.CalendarStorageException;
|
||||
import at.bitfire.ical4android.InvalidCalendarException;
|
||||
import at.bitfire.ical4android.Task;
|
||||
import at.bitfire.ical4android.TaskProvider;
|
||||
import lombok.Cleanup;
|
||||
|
||||
public class TasksSyncManager extends SyncManager {
|
||||
|
||||
protected static final int MAX_MULTIGET = 30;
|
||||
|
||||
final protected TaskProvider provider;
|
||||
|
||||
|
||||
public TasksSyncManager(Context context, Account account, Bundle extras, String authority, TaskProvider provider, SyncResult result, LocalTaskList taskList) {
|
||||
super(Constants.NOTIFICATION_TASK_SYNC, context, account, extras, authority, result);
|
||||
this.provider = provider;
|
||||
localCollection = taskList;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected String getSyncErrorTitle() {
|
||||
return context.getString(R.string.sync_error_tasks, account.name);
|
||||
}
|
||||
|
||||
|
||||
@Override
|
||||
protected void prepare() {
|
||||
Thread.currentThread().setContextClassLoader(context.getClassLoader()); // required for ical4j
|
||||
|
||||
collectionURL = HttpUrl.parse(localTaskList().getSyncId());
|
||||
davCollection = new DavCalendar(log, httpClient, collectionURL);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void queryCapabilities() throws DavException, IOException, HttpException, CalendarStorageException {
|
||||
davCollection.propfind(0, DisplayName.NAME, CalendarColor.NAME, GetCTag.NAME);
|
||||
|
||||
// update name and color
|
||||
DisplayName pDisplayName = (DisplayName)davCollection.properties.get(DisplayName.NAME);
|
||||
String displayName = (pDisplayName != null && !TextUtils.isEmpty(pDisplayName.displayName)) ?
|
||||
pDisplayName.displayName : collectionURL.toString();
|
||||
|
||||
CalendarColor pColor = (CalendarColor)davCollection.properties.get(CalendarColor.NAME);
|
||||
int color = (pColor != null && pColor.color != null) ? pColor.color : LocalCalendar.defaultColor;
|
||||
|
||||
ContentValues values = new ContentValues(2);
|
||||
log.info("Setting new task list name \"" + displayName + "\" and color 0x" + Integer.toHexString(color));
|
||||
values.put(TaskLists.LIST_NAME, displayName);
|
||||
values.put(TaskLists.LIST_COLOR, color);
|
||||
localTaskList().update(values);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected RequestBody prepareUpload(LocalResource resource) throws IOException, CalendarStorageException {
|
||||
LocalTask local = (LocalTask)resource;
|
||||
return RequestBody.create(
|
||||
DavCalendar.MIME_ICALENDAR,
|
||||
local.getTask().toStream().toByteArray()
|
||||
);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void listRemote() throws IOException, HttpException, DavException {
|
||||
// fetch list of remote VTODOs and build hash table to index file name
|
||||
davCalendar().calendarQuery("VTODO");
|
||||
remoteResources = new HashMap<>(davCollection.members.size());
|
||||
for (DavResource vCard : davCollection.members) {
|
||||
String fileName = vCard.fileName();
|
||||
log.debug("Found remote VTODO: " + fileName);
|
||||
remoteResources.put(fileName, vCard);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void downloadRemote() throws IOException, HttpException, DavException, CalendarStorageException {
|
||||
log.info("Downloading " + toDownload.size() + " tasks (" + MAX_MULTIGET + " at once)");
|
||||
|
||||
// download new/updated iCalendars from server
|
||||
for (DavResource[] bunch : ArrayUtils.partition(toDownload.toArray(new DavResource[toDownload.size()]), MAX_MULTIGET)) {
|
||||
if (Thread.interrupted())
|
||||
return;
|
||||
|
||||
log.info("Downloading " + StringUtils.join(bunch, ", "));
|
||||
|
||||
if (bunch.length == 1) {
|
||||
// only one contact, use GET
|
||||
DavResource remote = bunch[0];
|
||||
|
||||
ResponseBody body = remote.get("text/calendar");
|
||||
String eTag = ((GetETag)remote.properties.get(GetETag.NAME)).eTag;
|
||||
|
||||
Charset charset = Charsets.UTF_8;
|
||||
MediaType contentType = body.contentType();
|
||||
if (contentType != null)
|
||||
charset = contentType.charset(Charsets.UTF_8);
|
||||
|
||||
@Cleanup InputStream stream = body.byteStream();
|
||||
processVTodo(remote.fileName(), eTag, stream, charset);
|
||||
|
||||
} else {
|
||||
// multiple contacts, use multi-get
|
||||
List<HttpUrl> urls = new LinkedList<>();
|
||||
for (DavResource remote : bunch)
|
||||
urls.add(remote.location);
|
||||
davCalendar().multiget(urls.toArray(new HttpUrl[urls.size()]));
|
||||
|
||||
// process multiget results
|
||||
for (DavResource remote : davCollection.members) {
|
||||
String eTag;
|
||||
GetETag getETag = (GetETag)remote.properties.get(GetETag.NAME);
|
||||
if (getETag != null)
|
||||
eTag = getETag.eTag;
|
||||
else
|
||||
throw new DavException("Received multi-get response without ETag");
|
||||
|
||||
Charset charset = Charsets.UTF_8;
|
||||
GetContentType getContentType = (GetContentType)remote.properties.get(GetContentType.NAME);
|
||||
if (getContentType != null && getContentType.type != null) {
|
||||
MediaType type = MediaType.parse(getContentType.type);
|
||||
if (type != null)
|
||||
charset = type.charset(Charsets.UTF_8);
|
||||
}
|
||||
|
||||
CalendarData calendarData = (CalendarData)remote.properties.get(CalendarData.NAME);
|
||||
if (calendarData == null || calendarData.iCalendar == null)
|
||||
throw new DavException("Received multi-get response without address data");
|
||||
|
||||
@Cleanup InputStream stream = new ByteArrayInputStream(calendarData.iCalendar.getBytes());
|
||||
processVTodo(remote.fileName(), eTag, stream, charset);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// helpers
|
||||
|
||||
private LocalTaskList localTaskList() { return ((LocalTaskList)localCollection); }
|
||||
private DavCalendar davCalendar() { return (DavCalendar)davCollection; }
|
||||
|
||||
private void processVTodo(String fileName, String eTag, InputStream stream, Charset charset) throws IOException, CalendarStorageException {
|
||||
Task[] tasks = null;
|
||||
try {
|
||||
tasks = Task.fromStream(stream, charset);
|
||||
} catch (InvalidCalendarException e) {
|
||||
log.error("Received invalid iCalendar, ignoring", e);
|
||||
return;
|
||||
}
|
||||
|
||||
if (tasks != null && tasks.length == 1) {
|
||||
Task newData = tasks[0];
|
||||
|
||||
// update local task, if it exists
|
||||
LocalTask localTask = (LocalTask)localResources.get(fileName);
|
||||
if (localTask != null) {
|
||||
log.info("Updating " + fileName + " in local tasklist");
|
||||
localTask.setETag(eTag);
|
||||
localTask.update(newData);
|
||||
syncResult.stats.numUpdates++;
|
||||
} else {
|
||||
log.info("Adding " + fileName + " to local task list");
|
||||
localTask = new LocalTask(localTaskList(), newData, fileName, eTag);
|
||||
localTask.add();
|
||||
syncResult.stats.numInserts++;
|
||||
}
|
||||
} else
|
||||
log.error("Received VCALENDAR with not exactly one VTODO; ignoring " + fileName);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,192 @@
|
||||
/*
|
||||
* Copyright © Ricki Hirner (bitfire web engineering).
|
||||
* All rights reserved. This program and the accompanying materials
|
||||
* are made available under the terms of the GNU Public License v3.0
|
||||
* which accompanies this distribution, and is available at
|
||||
* http://www.gnu.org/licenses/gpl.html
|
||||
*/
|
||||
|
||||
package at.bitfire.davdroid.syncadapter
|
||||
|
||||
import android.accounts.Account
|
||||
import android.content.Context
|
||||
import android.content.SyncResult
|
||||
import android.os.Bundle
|
||||
import at.bitfire.dav4android.DavCalendar
|
||||
import at.bitfire.dav4android.DavResource
|
||||
import at.bitfire.dav4android.exception.DavException
|
||||
import at.bitfire.dav4android.property.CalendarData
|
||||
import at.bitfire.dav4android.property.GetCTag
|
||||
import at.bitfire.dav4android.property.GetETag
|
||||
import at.bitfire.davdroid.AccountSettings
|
||||
import at.bitfire.davdroid.Constants
|
||||
import at.bitfire.davdroid.R
|
||||
import at.bitfire.davdroid.log.Logger
|
||||
import at.bitfire.davdroid.resource.LocalResource
|
||||
import at.bitfire.davdroid.resource.LocalTask
|
||||
import at.bitfire.davdroid.resource.LocalTaskList
|
||||
import at.bitfire.ical4android.InvalidCalendarException
|
||||
import at.bitfire.ical4android.Task
|
||||
import at.bitfire.ical4android.TaskProvider
|
||||
import okhttp3.HttpUrl
|
||||
import okhttp3.RequestBody
|
||||
import org.apache.commons.collections4.ListUtils
|
||||
import java.io.ByteArrayOutputStream
|
||||
import java.io.Reader
|
||||
import java.io.StringReader
|
||||
import java.util.*
|
||||
import java.util.logging.Level
|
||||
|
||||
class TasksSyncManager(
|
||||
context: Context,
|
||||
account: Account,
|
||||
settings: AccountSettings,
|
||||
extras: Bundle,
|
||||
authority: String,
|
||||
syncResult: SyncResult,
|
||||
val provider: TaskProvider,
|
||||
val localTaskList: LocalTaskList
|
||||
): SyncManager(context, account, settings, extras, authority, syncResult, "taskList/${localTaskList.id}") {
|
||||
|
||||
val MAX_MULTIGET = 30
|
||||
|
||||
|
||||
init {
|
||||
localCollection = localTaskList
|
||||
}
|
||||
|
||||
override fun notificationId() = Constants.NOTIFICATION_TASK_SYNC
|
||||
|
||||
override fun getSyncErrorTitle() = context.getString(R.string.sync_error_tasks, account.name)!!
|
||||
|
||||
|
||||
override fun prepare(): Boolean {
|
||||
collectionURL = HttpUrl.parse(localTaskList.syncId ?: return false) ?: return false
|
||||
davCollection = DavCalendar(httpClient, collectionURL)
|
||||
return true
|
||||
}
|
||||
|
||||
override fun queryCapabilities() {
|
||||
davCollection.propfind(0, GetCTag.NAME)
|
||||
}
|
||||
|
||||
override fun prepareUpload(resource: LocalResource): RequestBody {
|
||||
if (resource is LocalTask) {
|
||||
val task = requireNotNull(resource.task)
|
||||
Logger.log.log(Level.FINE, "Preparing upload of task ${resource.fileName}", task)
|
||||
|
||||
val os = ByteArrayOutputStream()
|
||||
task.write(os)
|
||||
|
||||
return RequestBody.create(
|
||||
DavCalendar.MIME_ICALENDAR,
|
||||
os.toByteArray()
|
||||
)
|
||||
} else
|
||||
throw IllegalArgumentException("resource must be a LocalTask")
|
||||
}
|
||||
|
||||
override fun listRemote() {
|
||||
// fetch list of remote VTODOs and build hash table to index file name
|
||||
val calendar = davCalendar()
|
||||
currentDavResource = calendar
|
||||
calendar.calendarQuery("VTODO", null, null)
|
||||
|
||||
remoteResources = HashMap<String, DavResource>(davCollection.members.size)
|
||||
for (vCard in davCollection.members) {
|
||||
val fileName = vCard.fileName()
|
||||
Logger.log.fine("Found remote VTODO: $fileName")
|
||||
remoteResources[fileName] = vCard
|
||||
}
|
||||
|
||||
currentDavResource = null
|
||||
}
|
||||
|
||||
override fun downloadRemote() {
|
||||
Logger.log.info("Downloading ${toDownload.size} tasks ($MAX_MULTIGET at once)")
|
||||
|
||||
// download new/updated iCalendars from server
|
||||
for (bunch in ListUtils.partition(toDownload.toList(), MAX_MULTIGET)) {
|
||||
if (Thread.interrupted())
|
||||
return
|
||||
|
||||
Logger.log.info("Downloading ${bunch.joinToString(", ")}")
|
||||
|
||||
if (bunch.size == 1) {
|
||||
// only one contact, use GET
|
||||
val remote = bunch.first()
|
||||
currentDavResource = remote
|
||||
|
||||
val body = remote.get("text/calendar")
|
||||
|
||||
// CalDAV servers MUST return ETag on GET [https://tools.ietf.org/html/rfc4791#section-5.3.4]
|
||||
val eTag = remote.properties[GetETag.NAME] as GetETag?
|
||||
if (eTag == null || eTag.eTag.isNullOrEmpty())
|
||||
throw DavException("Received CalDAV GET response without ETag for ${remote.location}")
|
||||
|
||||
body.charStream().use { reader ->
|
||||
processVTodo(remote.fileName(), eTag.eTag!!, reader)
|
||||
}
|
||||
|
||||
} else {
|
||||
// multiple contacts, use multi-get
|
||||
val calendar = davCalendar()
|
||||
currentDavResource = calendar
|
||||
calendar.multiget(bunch.map { it.location })
|
||||
|
||||
// process multiget results
|
||||
for (remote in davCollection.members) {
|
||||
currentDavResource = remote
|
||||
|
||||
val eTag = (remote.properties[GetETag.NAME] as GetETag?)?.eTag
|
||||
?: throw DavException("Received multi-get response without ETag")
|
||||
|
||||
val calendarData = remote.properties[CalendarData.NAME] as CalendarData?
|
||||
val iCalendar = calendarData?.iCalendar
|
||||
?: throw DavException("Received multi-get response without task data")
|
||||
|
||||
processVTodo(remote.fileName(), eTag, StringReader(iCalendar))
|
||||
}
|
||||
}
|
||||
|
||||
currentDavResource = null
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// helpers
|
||||
|
||||
private fun davCalendar() = davCollection as DavCalendar
|
||||
|
||||
private fun processVTodo(fileName: String, eTag: String, reader: Reader) {
|
||||
val tasks: List<Task>
|
||||
try {
|
||||
tasks = Task.fromReader(reader)
|
||||
} catch (e: InvalidCalendarException) {
|
||||
Logger.log.log(Level.SEVERE, "Received invalid iCalendar, ignoring", e)
|
||||
return
|
||||
}
|
||||
|
||||
if (tasks.size == 1) {
|
||||
val newData = tasks.first()
|
||||
|
||||
// update local task, if it exists
|
||||
val localTask = localResources[fileName] as LocalTask?
|
||||
currentLocalResource = localTask
|
||||
if (localTask != null) {
|
||||
Logger.log.info("Updating $fileName in local tasklist")
|
||||
localTask.eTag = eTag
|
||||
localTask.update(newData)
|
||||
syncResult.stats.numUpdates++
|
||||
} else {
|
||||
Logger.log.info("Adding $fileName to local task list")
|
||||
val newTask = LocalTask(localTaskList, newData, fileName, eTag)
|
||||
currentLocalResource = newTask
|
||||
newTask.add()
|
||||
syncResult.stats.numInserts++
|
||||
}
|
||||
} else
|
||||
Logger.log.severe("Received VCALENDAR with not exactly one VTODO; ignoring $fileName")
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,192 @@
|
||||
/*
|
||||
* Copyright © Ricki Hirner (bitfire web engineering).
|
||||
* All rights reserved. This program and the accompanying materials
|
||||
* are made available under the terms of the GNU Public License v3.0
|
||||
* which accompanies this distribution, and is available at
|
||||
* http://www.gnu.org/licenses/gpl.html
|
||||
*/
|
||||
|
||||
package at.bitfire.davdroid.ui
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.content.Context
|
||||
import android.os.Bundle
|
||||
import android.support.v4.app.Fragment
|
||||
import android.support.v4.app.FragmentManager
|
||||
import android.support.v4.app.FragmentPagerAdapter
|
||||
import android.support.v4.app.LoaderManager
|
||||
import android.support.v4.content.AsyncTaskLoader
|
||||
import android.support.v4.content.Loader
|
||||
import android.support.v7.app.AppCompatActivity
|
||||
import android.text.Html
|
||||
import android.text.Spanned
|
||||
import android.text.util.Linkify
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import at.bitfire.davdroid.BuildConfig
|
||||
import at.bitfire.davdroid.R
|
||||
import at.bitfire.davdroid.log.Logger
|
||||
import ezvcard.Ezvcard
|
||||
import kotlinx.android.synthetic.main.about_component.view.*
|
||||
import kotlinx.android.synthetic.main.activity_about.*
|
||||
import org.apache.commons.io.IOUtils
|
||||
import java.io.IOException
|
||||
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?
|
||||
)
|
||||
private lateinit var components: Array<ComponentInfo>
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
|
||||
components = arrayOf(
|
||||
ComponentInfo(
|
||||
null, BuildConfig.VERSION_NAME, getString(R.string.homepage_url),
|
||||
"Ricki Hirner, Bernhard Stockmann (bitfire web engineering)",
|
||||
null, null
|
||||
), ComponentInfo(
|
||||
"AmbilWarna", null, "https://github.com/yukuku/ambilwarna",
|
||||
"Yuku", R.string.about_license_info_no_warranty, "apache2.html"
|
||||
), ComponentInfo(
|
||||
"Apache Commons", null, "http://commons.apache.org/",
|
||||
"Apache Software Foundation", R.string.about_license_info_no_warranty, "apache2.html"
|
||||
), ComponentInfo(
|
||||
"dnsjava", null, "http://dnsjava.org/",
|
||||
"Brian Wellington", R.string.about_license_info_no_warranty, "bsd.html"
|
||||
), ComponentInfo(
|
||||
"ez-vcard", Ezvcard.VERSION, "https://github.com/mangstadt/ez-vcard",
|
||||
"Michael Angstadt", R.string.about_license_info_no_warranty, "bsd.html"
|
||||
), ComponentInfo(
|
||||
"ical4j", "2.x", "https://ical4j.github.io/",
|
||||
"Ben Fortuna", R.string.about_license_info_no_warranty, "bsd-3clause.html"
|
||||
), ComponentInfo(
|
||||
"OkHttp", null, "https://square.github.io/okhttp/",
|
||||
"Square, Inc.", R.string.about_license_info_no_warranty, "apache2.html"
|
||||
), ComponentInfo(
|
||||
"Project Lombok", null, "https://projectlombok.org/",
|
||||
"The Project Lombok Authors", R.string.about_license_info_no_warranty, "mit.html"
|
||||
)
|
||||
)
|
||||
|
||||
setContentView(R.layout.activity_about)
|
||||
|
||||
setSupportActionBar(toolbar)
|
||||
supportActionBar?.setDisplayHomeAsUpEnabled(true)
|
||||
|
||||
viewpager.adapter = TabsAdapter(supportFragmentManager)
|
||||
tabs.setupWithViewPager(viewpager)
|
||||
}
|
||||
|
||||
|
||||
private inner class TabsAdapter(
|
||||
fm: FragmentManager
|
||||
): FragmentPagerAdapter(fm) {
|
||||
|
||||
override fun getCount() = components.size
|
||||
|
||||
override fun getPageTitle(position: Int) =
|
||||
components[position].title ?: getString(R.string.app_name)!!
|
||||
|
||||
override fun getItem(position: Int) = ComponentFragment.instantiate(position)
|
||||
}
|
||||
|
||||
class ComponentFragment: Fragment(), LoaderManager.LoaderCallbacks<Spanned> {
|
||||
|
||||
companion object {
|
||||
val KEY_POSITION = "position"
|
||||
val KEY_FILE_NAME = "fileName"
|
||||
|
||||
fun instantiate(position: Int): ComponentFragment {
|
||||
val frag = ComponentFragment()
|
||||
val args = Bundle(1)
|
||||
args.putInt(KEY_POSITION, position)
|
||||
frag.arguments = args
|
||||
return frag
|
||||
}
|
||||
}
|
||||
|
||||
@SuppressLint("SetTextI18n")
|
||||
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
|
||||
val info = (activity as AboutActivity).components[arguments.getInt(KEY_POSITION)]
|
||||
|
||||
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)
|
||||
v.license_info.visibility = View.GONE
|
||||
else
|
||||
v.license_info.setText(info.licenseInfo)
|
||||
|
||||
// load and format license text
|
||||
if (info.licenseTextFile == null) {
|
||||
v.license_header.visibility = View.GONE
|
||||
v.license_text.visibility = View.GONE
|
||||
} else {
|
||||
val args = Bundle(1)
|
||||
args.putString(KEY_FILE_NAME, info.licenseTextFile)
|
||||
loaderManager.initLoader(0, args, this)
|
||||
}
|
||||
|
||||
return v
|
||||
}
|
||||
|
||||
override fun onCreateLoader(id: Int, args: Bundle) =
|
||||
LicenseLoader(context, args.getString(KEY_FILE_NAME))
|
||||
|
||||
override fun onLoadFinished(loader: Loader<Spanned>, license: Spanned?) {
|
||||
view?.let { v ->
|
||||
v.license_text.autoLinkMask = Linkify.EMAIL_ADDRESSES or Linkify.WEB_URLS
|
||||
v.license_text.text = license
|
||||
}
|
||||
}
|
||||
|
||||
override fun onLoaderReset(loader: Loader<Spanned>) {}
|
||||
}
|
||||
|
||||
class LicenseLoader(
|
||||
context: Context,
|
||||
val fileName: String
|
||||
): AsyncTaskLoader<Spanned>(context) {
|
||||
|
||||
var content: Spanned? = null
|
||||
|
||||
override fun onStartLoading() {
|
||||
if (content == null)
|
||||
forceLoad()
|
||||
else
|
||||
deliverResult(content)
|
||||
}
|
||||
|
||||
override fun loadInBackground(): Spanned? {
|
||||
Logger.log.fine("Loading license file $fileName")
|
||||
try {
|
||||
context.resources.assets.open(fileName).use {
|
||||
content = Html.fromHtml(IOUtils.toString(it, Charsets.UTF_8))
|
||||
return content
|
||||
}
|
||||
} catch(e: IOException) {
|
||||
Logger.log.log(Level.SEVERE, "Couldn't read license file", e)
|
||||
return null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,599 @@
|
||||
/*
|
||||
* Copyright © Ricki Hirner (bitfire web engineering).
|
||||
* All rights reserved. This program and the accompanying materials
|
||||
* are made available under the terms of the GNU Public License v3.0
|
||||
* which accompanies this distribution, and is available at
|
||||
* http://www.gnu.org/licenses/gpl.html
|
||||
*/
|
||||
|
||||
package at.bitfire.davdroid.ui
|
||||
|
||||
import android.accounts.Account
|
||||
import android.accounts.AccountManager
|
||||
import android.app.AlertDialog
|
||||
import android.app.Dialog
|
||||
import android.app.LoaderManager
|
||||
import android.content.*
|
||||
import android.database.DatabaseUtils
|
||||
import android.database.sqlite.SQLiteDatabase
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import android.os.IBinder
|
||||
import android.provider.CalendarContract
|
||||
import android.provider.ContactsContract
|
||||
import android.support.design.widget.Snackbar
|
||||
import android.support.v4.app.DialogFragment
|
||||
import android.support.v7.app.AppCompatActivity
|
||||
import android.support.v7.widget.Toolbar
|
||||
import android.view.*
|
||||
import android.widget.*
|
||||
import at.bitfire.davdroid.App
|
||||
import at.bitfire.davdroid.CustomCertificates
|
||||
import at.bitfire.davdroid.DavService
|
||||
import at.bitfire.davdroid.R
|
||||
import at.bitfire.davdroid.log.Logger
|
||||
import at.bitfire.davdroid.model.CollectionInfo
|
||||
import at.bitfire.davdroid.model.ServiceDB
|
||||
import at.bitfire.davdroid.model.ServiceDB.*
|
||||
import at.bitfire.davdroid.model.ServiceDB.Collections
|
||||
import at.bitfire.davdroid.resource.LocalAddressBook
|
||||
import at.bitfire.davdroid.resource.LocalTaskList
|
||||
import at.bitfire.ical4android.TaskProvider
|
||||
import at.bitfire.vcard4android.ContactsStorageException
|
||||
import kotlinx.android.synthetic.main.activity_account.*
|
||||
import java.util.*
|
||||
import java.util.logging.Level
|
||||
|
||||
class AccountActivity: AppCompatActivity(), Toolbar.OnMenuItemClickListener, PopupMenu.OnMenuItemClickListener, LoaderManager.LoaderCallbacks<AccountActivity.AccountInfo> {
|
||||
|
||||
companion object {
|
||||
@JvmField val EXTRA_ACCOUNT = "account"
|
||||
|
||||
private fun requestSync(account: Account) {
|
||||
val authorities = arrayOf(
|
||||
App.addressBooksAuthority,
|
||||
CalendarContract.AUTHORITY,
|
||||
TaskProvider.ProviderName.OpenTasks.authority
|
||||
)
|
||||
|
||||
for (authority in authorities) {
|
||||
val extras = Bundle(2)
|
||||
extras.putBoolean(ContentResolver.SYNC_EXTRAS_MANUAL, true) // manual sync
|
||||
extras.putBoolean(ContentResolver.SYNC_EXTRAS_EXPEDITED, true) // run immediately (don't queue)
|
||||
ContentResolver.requestSync(account, authority, extras)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
lateinit var account: Account
|
||||
var accountInfo: AccountInfo? = null
|
||||
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
|
||||
account = intent.getParcelableExtra(EXTRA_ACCOUNT)
|
||||
title = account.name
|
||||
|
||||
setContentView(R.layout.activity_account)
|
||||
|
||||
val icMenu = if (Build.VERSION.SDK_INT >= 21)
|
||||
getDrawable(R.drawable.ic_menu_light)
|
||||
else
|
||||
resources.getDrawable(R.drawable.ic_menu_light)
|
||||
|
||||
// CardDAV toolbar
|
||||
carddav_menu.overflowIcon = icMenu
|
||||
carddav_menu.inflateMenu(R.menu.carddav_actions)
|
||||
carddav_menu.setOnMenuItemClickListener(this)
|
||||
|
||||
// CalDAV toolbar
|
||||
caldav_menu.overflowIcon = icMenu
|
||||
caldav_menu.inflateMenu(R.menu.caldav_actions)
|
||||
caldav_menu.setOnMenuItemClickListener(this)
|
||||
|
||||
// load CardDAV/CalDAV collections
|
||||
loaderManager.initLoader(0, intent.extras, this)
|
||||
}
|
||||
|
||||
override fun onPause() {
|
||||
super.onPause()
|
||||
CustomCertificates.certManager?.let { it.appInForeground = false }
|
||||
}
|
||||
|
||||
override fun onResume() {
|
||||
super.onResume()
|
||||
CustomCertificates.certManager?.let { it.appInForeground = true }
|
||||
}
|
||||
|
||||
override fun onCreateOptionsMenu(menu: Menu): Boolean {
|
||||
menuInflater.inflate(R.menu.activity_account, menu)
|
||||
return true
|
||||
}
|
||||
|
||||
override fun onPrepareOptionsMenu(menu: Menu): Boolean {
|
||||
val itemRename = menu.findItem(R.id.rename_account)
|
||||
// renameAccount is available for API level 21+
|
||||
itemRename.isVisible = Build.VERSION.SDK_INT >= 21
|
||||
return super.onPrepareOptionsMenu(menu)
|
||||
}
|
||||
|
||||
override fun onOptionsItemSelected(item: MenuItem): Boolean {
|
||||
when (item.itemId) {
|
||||
R.id.sync_now ->
|
||||
requestSync()
|
||||
R.id.settings -> {
|
||||
val intent = Intent(this, AccountSettingsActivity::class.java)
|
||||
intent.putExtra(AccountSettingsActivity.EXTRA_ACCOUNT, account)
|
||||
startActivity(intent)
|
||||
}
|
||||
R.id.rename_account ->
|
||||
RenameAccountFragment.newInstance(account).show(supportFragmentManager, null)
|
||||
R.id.delete_account -> {
|
||||
AlertDialog.Builder(this)
|
||||
.setIcon(R.drawable.ic_error_dark)
|
||||
.setTitle(R.string.account_delete_confirmation_title)
|
||||
.setMessage(R.string.account_delete_confirmation_text)
|
||||
.setNegativeButton(android.R.string.no, null)
|
||||
.setPositiveButton(android.R.string.yes, { _, _ ->
|
||||
deleteAccount()
|
||||
})
|
||||
.show()
|
||||
}
|
||||
else ->
|
||||
return super.onOptionsItemSelected(item)
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
override fun onMenuItemClick(item: MenuItem): Boolean {
|
||||
when (item.itemId) {
|
||||
R.id.refresh_address_books ->
|
||||
accountInfo?.carddav?.let { carddav ->
|
||||
val intent = Intent(this, DavService::class.java)
|
||||
intent.action = DavService.ACTION_REFRESH_COLLECTIONS
|
||||
intent.putExtra(DavService.EXTRA_DAV_SERVICE_ID, carddav.id)
|
||||
startService(intent)
|
||||
}
|
||||
R.id.create_address_book -> {
|
||||
val intent = Intent(this, CreateAddressBookActivity::class.java)
|
||||
intent.putExtra(CreateAddressBookActivity.EXTRA_ACCOUNT, account)
|
||||
startActivity(intent)
|
||||
}
|
||||
R.id.refresh_calendars ->
|
||||
accountInfo?.caldav?.let { caldav ->
|
||||
val intent = Intent(this, DavService::class.java)
|
||||
intent.action = DavService.ACTION_REFRESH_COLLECTIONS
|
||||
intent.putExtra(DavService.EXTRA_DAV_SERVICE_ID, caldav.id)
|
||||
startService(intent)
|
||||
}
|
||||
R.id.create_calendar -> {
|
||||
val intent = Intent(this, CreateCalendarActivity::class.java)
|
||||
intent.putExtra(CreateCalendarActivity.EXTRA_ACCOUNT, account)
|
||||
startActivity(intent)
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
|
||||
private val onItemClickListener = AdapterView.OnItemClickListener { parent, _, position, _ ->
|
||||
val list = parent as ListView
|
||||
val adapter = list.adapter as ArrayAdapter<CollectionInfo>
|
||||
val info = adapter.getItem(position)
|
||||
|
||||
val nowChecked = !info.selected
|
||||
|
||||
OpenHelper(this@AccountActivity).use { dbHelper ->
|
||||
val db = dbHelper.writableDatabase
|
||||
db.beginTransactionNonExclusive()
|
||||
|
||||
val values = ContentValues(1)
|
||||
values.put(Collections.SYNC, if (nowChecked) 1 else 0)
|
||||
db.update(Collections._TABLE, values, "${Collections.ID}=?", arrayOf(info.id.toString()))
|
||||
|
||||
db.setTransactionSuccessful()
|
||||
db.endTransaction()
|
||||
|
||||
info.selected = nowChecked
|
||||
adapter.notifyDataSetChanged()
|
||||
}
|
||||
}
|
||||
|
||||
private val onItemLongClickListener = AdapterView.OnItemLongClickListener { parent, view, position, _ ->
|
||||
val list = parent as ListView
|
||||
val adapter = list.adapter as ArrayAdapter<CollectionInfo>
|
||||
val info = adapter.getItem(position)
|
||||
|
||||
val popup = PopupMenu(this@AccountActivity, view)
|
||||
popup.inflate(R.menu.account_collection_operations)
|
||||
popup.setOnMenuItemClickListener({ item ->
|
||||
when (item.itemId) {
|
||||
R.id.delete_collection ->
|
||||
DeleteCollectionFragment.ConfirmDeleteCollectionFragment.newInstance(account, info).show(getSupportFragmentManager(), null);
|
||||
}
|
||||
true
|
||||
})
|
||||
popup.show()
|
||||
|
||||
// long click was handled
|
||||
true
|
||||
}
|
||||
|
||||
|
||||
/* LOADERS AND LOADED DATA */
|
||||
|
||||
class AccountInfo {
|
||||
var carddav: ServiceInfo? = null
|
||||
var caldav: ServiceInfo? = null
|
||||
|
||||
class ServiceInfo {
|
||||
var id: Long? = null
|
||||
var refreshing = false
|
||||
|
||||
var hasHomeSets = false
|
||||
var collections = listOf<CollectionInfo>()
|
||||
}
|
||||
}
|
||||
|
||||
override fun onCreateLoader(id: Int, args: Bundle?) =
|
||||
AccountLoader(this, account)
|
||||
|
||||
fun reload() {
|
||||
loaderManager.restartLoader(0, intent.extras, this)
|
||||
}
|
||||
|
||||
override fun onLoadFinished(loader: Loader<AccountInfo>, info: AccountInfo?) {
|
||||
accountInfo = info
|
||||
|
||||
carddav.visibility = info?.carddav?.let { carddav ->
|
||||
carddav_refreshing.visibility = if (carddav.refreshing) View.VISIBLE else View.GONE
|
||||
|
||||
address_books.isEnabled = !carddav.refreshing
|
||||
address_books.alpha = if (carddav.refreshing) 0.5f else 1f
|
||||
|
||||
carddav_menu.menu.findItem(R.id.create_address_book).isEnabled = carddav.hasHomeSets
|
||||
|
||||
val adapter = AddressBookAdapter(this)
|
||||
adapter.addAll(carddav.collections)
|
||||
address_books.adapter = adapter
|
||||
address_books.onItemClickListener = onItemClickListener
|
||||
address_books.onItemLongClickListener = onItemLongClickListener
|
||||
|
||||
View.VISIBLE
|
||||
} ?: View.GONE
|
||||
|
||||
caldav.visibility = info?.caldav?.let { caldav ->
|
||||
caldav_refreshing.visibility = if (caldav.refreshing) View.VISIBLE else View.GONE
|
||||
|
||||
calendars.isEnabled = !caldav.refreshing
|
||||
calendars.alpha = if (caldav.refreshing) 0.5f else 1f
|
||||
|
||||
caldav_menu.menu.findItem(R.id.create_calendar).isEnabled = caldav.hasHomeSets
|
||||
|
||||
val adapter = CalendarAdapter(this)
|
||||
adapter.addAll(caldav.collections)
|
||||
calendars.adapter = adapter
|
||||
calendars.onItemClickListener = onItemClickListener
|
||||
calendars.onItemLongClickListener = onItemLongClickListener
|
||||
|
||||
View.VISIBLE
|
||||
} ?: View.GONE
|
||||
}
|
||||
|
||||
override fun onLoaderReset(loader: Loader<AccountInfo>) {
|
||||
address_books?.adapter = null
|
||||
calendars?.adapter = null
|
||||
}
|
||||
|
||||
|
||||
class AccountLoader(
|
||||
context: Context,
|
||||
val account: Account
|
||||
): AsyncTaskLoader<AccountInfo>(context), DavService.RefreshingStatusListener, ServiceConnection, SyncStatusObserver {
|
||||
|
||||
private var davService: DavService.InfoBinder? = null
|
||||
private lateinit var syncStatusListener: Any
|
||||
|
||||
override fun onStartLoading() {
|
||||
syncStatusListener = ContentResolver.addStatusChangeListener(ContentResolver.SYNC_OBSERVER_TYPE_ACTIVE, this)
|
||||
context.bindService(Intent(context, DavService::class.java), this, Context.BIND_AUTO_CREATE)
|
||||
}
|
||||
|
||||
override fun onStopLoading() {
|
||||
davService?.removeRefreshingStatusListener(this)
|
||||
context.unbindService(this)
|
||||
ContentResolver.removeStatusChangeListener(syncStatusListener)
|
||||
}
|
||||
|
||||
override fun onServiceConnected(name: ComponentName, service: IBinder) {
|
||||
davService = service as DavService.InfoBinder
|
||||
service.addRefreshingStatusListener(this, false)
|
||||
|
||||
forceLoad()
|
||||
}
|
||||
|
||||
override fun onServiceDisconnected(name: ComponentName) {
|
||||
davService = null
|
||||
}
|
||||
|
||||
override fun onDavRefreshStatusChanged(id: Long, refreshing: Boolean) =
|
||||
forceLoad()
|
||||
|
||||
override fun onStatusChanged(which: Int) =
|
||||
forceLoad()
|
||||
|
||||
override fun loadInBackground(): AccountInfo {
|
||||
val info = AccountInfo()
|
||||
|
||||
OpenHelper(context).use { dbHelper ->
|
||||
val db = dbHelper.readableDatabase
|
||||
db.query(
|
||||
Services._TABLE,
|
||||
arrayOf(Services.ID, Services.SERVICE),
|
||||
"${Services.ACCOUNT_NAME}=?", arrayOf(account.name),
|
||||
null, null, null).use { cursor ->
|
||||
while (cursor.moveToNext()) {
|
||||
val id = cursor.getLong(0)
|
||||
when (cursor.getString(1)) {
|
||||
Services.SERVICE_CARDDAV -> {
|
||||
val carddav = AccountInfo.ServiceInfo()
|
||||
info.carddav = carddav
|
||||
carddav.id = id
|
||||
carddav.refreshing =
|
||||
davService?.isRefreshing(id) ?: false ||
|
||||
ContentResolver.isSyncActive(account, App.addressBooksAuthority)
|
||||
|
||||
val accountManager = AccountManager.get(context)
|
||||
for (addrBookAccount in accountManager.getAccountsByType(App.addressBookAccountType)) {
|
||||
val addressBook = LocalAddressBook(context, addrBookAccount, null)
|
||||
try {
|
||||
if (account == addressBook.getMainAccount())
|
||||
carddav.refreshing = carddav.refreshing || ContentResolver.isSyncActive(addrBookAccount, ContactsContract.AUTHORITY)
|
||||
} catch(e: ContactsStorageException) {
|
||||
}
|
||||
}
|
||||
|
||||
carddav.hasHomeSets = hasHomeSets(db, id)
|
||||
carddav.collections = readCollections(db, id)
|
||||
}
|
||||
Services.SERVICE_CALDAV -> {
|
||||
val caldav = AccountInfo.ServiceInfo()
|
||||
info.caldav = caldav
|
||||
caldav.id = id
|
||||
caldav.refreshing =
|
||||
davService?.isRefreshing(id) ?: false ||
|
||||
ContentResolver.isSyncActive(account, CalendarContract.AUTHORITY) ||
|
||||
ContentResolver.isSyncActive(account, TaskProvider.ProviderName.OpenTasks.authority)
|
||||
caldav.hasHomeSets = hasHomeSets(db, id)
|
||||
caldav.collections = readCollections(db, id)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
return info
|
||||
}
|
||||
|
||||
private fun hasHomeSets(db: SQLiteDatabase, service: Long): Boolean {
|
||||
db.query(ServiceDB.HomeSets._TABLE, null, "${ServiceDB.HomeSets.SERVICE_ID}=?",
|
||||
arrayOf(service.toString()), null, null, null)?.use { cursor ->
|
||||
return cursor.count > 0
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
private fun readCollections(db: SQLiteDatabase, service: Long): List<CollectionInfo> {
|
||||
val collections = LinkedList<CollectionInfo>()
|
||||
db.query(Collections._TABLE, null, Collections.SERVICE_ID + "=?", arrayOf(service.toString()),
|
||||
null, null, "${Collections.SUPPORTS_VEVENT} DESC,${Collections.DISPLAY_NAME}").use { cursor ->
|
||||
while (cursor.moveToNext()) {
|
||||
val values = ContentValues(cursor.columnCount)
|
||||
DatabaseUtils.cursorRowToContentValues(cursor, values)
|
||||
collections.add(CollectionInfo(values))
|
||||
}
|
||||
}
|
||||
return collections
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
/* LIST ADAPTERS */
|
||||
|
||||
class AddressBookAdapter(
|
||||
context: Context
|
||||
): ArrayAdapter<CollectionInfo>(context, R.layout.account_carddav_item) {
|
||||
|
||||
override fun getView(position: Int, v: View?, parent: ViewGroup?): View {
|
||||
val v = v ?: LayoutInflater.from(context).inflate(R.layout.account_carddav_item, parent, false)
|
||||
val info = getItem(position)
|
||||
|
||||
val checked: CheckBox = v.findViewById(R.id.checked)
|
||||
checked.isChecked = info.selected
|
||||
|
||||
var tv: TextView = v.findViewById(R.id.title)
|
||||
tv.text = if (!info.displayName.isNullOrBlank()) info.displayName else info.url
|
||||
|
||||
tv = v.findViewById(R.id.description)
|
||||
if (info.description.isNullOrBlank())
|
||||
tv.visibility = View.GONE
|
||||
else {
|
||||
tv.visibility = View.VISIBLE
|
||||
tv.text = info.description
|
||||
}
|
||||
|
||||
tv = v.findViewById(R.id.read_only)
|
||||
tv.visibility = if (info.readOnly) View.VISIBLE else View.GONE
|
||||
|
||||
return v
|
||||
}
|
||||
}
|
||||
|
||||
class CalendarAdapter(
|
||||
context: Context
|
||||
): ArrayAdapter<CollectionInfo>(context, R.layout.account_caldav_item) {
|
||||
|
||||
override fun getView(position: Int, v: View?, parent: ViewGroup?): View {
|
||||
val v = v ?: LayoutInflater.from(context).inflate(R.layout.account_caldav_item, parent, false)
|
||||
val info = getItem(position)
|
||||
|
||||
val checked: CheckBox = v.findViewById(R.id.checked)
|
||||
checked.isChecked = info.selected
|
||||
|
||||
val vColor: View = v.findViewById(R.id.color)
|
||||
vColor.visibility = info.color?.let {
|
||||
vColor.setBackgroundColor(it)
|
||||
View.VISIBLE
|
||||
} ?: View.GONE
|
||||
|
||||
var tv: TextView = v.findViewById(R.id.title)
|
||||
tv.text = if (!info.displayName.isNullOrBlank()) info.displayName else info.url
|
||||
|
||||
tv = v.findViewById(R.id.description)
|
||||
if (info.description.isNullOrBlank())
|
||||
tv.visibility = View.GONE
|
||||
else {
|
||||
tv.visibility = View.VISIBLE
|
||||
tv.text = info.description
|
||||
}
|
||||
|
||||
tv = v.findViewById(R.id.read_only)
|
||||
tv.visibility = if (info.readOnly) View.VISIBLE else View.GONE
|
||||
|
||||
tv = v.findViewById(R.id.events)
|
||||
tv.visibility = if (info.supportsVEVENT) View.VISIBLE else View.GONE
|
||||
|
||||
tv = v.findViewById(R.id.tasks)
|
||||
tv.visibility = if (info.supportsVTODO) View.VISIBLE else View.GONE
|
||||
|
||||
return v
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/* DIALOG FRAGMENTS */
|
||||
|
||||
class RenameAccountFragment: DialogFragment() {
|
||||
|
||||
companion object {
|
||||
|
||||
val ARG_ACCOUNT = "account"
|
||||
|
||||
fun newInstance(account: Account): RenameAccountFragment {
|
||||
val fragment = RenameAccountFragment()
|
||||
val args = Bundle(1)
|
||||
args.putParcelable(ARG_ACCOUNT, account)
|
||||
fragment.arguments = args
|
||||
return fragment
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
|
||||
val oldAccount: Account = arguments.getParcelable(ARG_ACCOUNT)
|
||||
|
||||
val editText = EditText(activity)
|
||||
editText.setText(oldAccount.name)
|
||||
|
||||
return AlertDialog.Builder(activity)
|
||||
.setTitle(R.string.account_rename)
|
||||
.setMessage(R.string.account_rename_new_name)
|
||||
.setView(editText)
|
||||
.setPositiveButton(R.string.account_rename_rename, DialogInterface.OnClickListener { dialog, which ->
|
||||
val newName = editText.text.toString()
|
||||
|
||||
if (newName == oldAccount.name)
|
||||
return@OnClickListener
|
||||
|
||||
val accountManager = AccountManager.get(activity)
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP)
|
||||
accountManager.renameAccount(oldAccount, newName, { _ ->
|
||||
Logger.log.info("Updating account name references")
|
||||
|
||||
// cancel maybe running synchronization
|
||||
ContentResolver.cancelSync(oldAccount, null)
|
||||
for (addrBookAccount in accountManager.getAccountsByType(App.addressBookAccountType))
|
||||
ContentResolver.cancelSync(addrBookAccount, null)
|
||||
|
||||
// update account name references in database
|
||||
OpenHelper(activity).use { dbHelper ->
|
||||
ServiceDB.onRenameAccount(dbHelper.getWritableDatabase(), oldAccount.name, newName)
|
||||
}
|
||||
|
||||
// update main account of address book accounts
|
||||
try {
|
||||
for (addrBookAccount in accountManager.getAccountsByType(App.addressBookAccountType)) {
|
||||
val provider = activity.contentResolver.acquireContentProviderClient(ContactsContract.AUTHORITY)
|
||||
try {
|
||||
if (provider != null) {
|
||||
val addressBook = LocalAddressBook(activity, addrBookAccount, provider)
|
||||
if (oldAccount == addressBook.getMainAccount())
|
||||
addressBook.setMainAccount(Account(newName, oldAccount.type))
|
||||
}
|
||||
} finally {
|
||||
if (Build.VERSION.SDK_INT >= 24)
|
||||
provider?.close()
|
||||
else
|
||||
provider?.release()
|
||||
}
|
||||
|
||||
}
|
||||
} catch(e: ContactsStorageException) {
|
||||
Logger.log.log(Level.SEVERE, "Couldn't update address book accounts", e)
|
||||
}
|
||||
|
||||
// calendar provider doesn't allow changing account_name of Events
|
||||
// (all events will have to be downloaded again)
|
||||
|
||||
// update account_name of local tasks
|
||||
try {
|
||||
LocalTaskList.onRenameAccount(activity.contentResolver, oldAccount.name, newName)
|
||||
} catch(e: Exception) {
|
||||
Logger.log.log(Level.SEVERE, "Couldn't propagate new account name to tasks provider", e)
|
||||
}
|
||||
|
||||
// synchronize again
|
||||
requestSync(Account(newName, oldAccount.type))
|
||||
}, null)
|
||||
activity.finish()
|
||||
})
|
||||
.setNegativeButton(android.R.string.cancel, { _, _ -> })
|
||||
.create()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/* USER ACTIONS */
|
||||
|
||||
private fun deleteAccount() {
|
||||
val accountManager = AccountManager.get(this)
|
||||
|
||||
if (Build.VERSION.SDK_INT >= 22)
|
||||
accountManager.removeAccount(account, this, { future ->
|
||||
try {
|
||||
if (future.result.getBoolean(AccountManager.KEY_BOOLEAN_RESULT))
|
||||
finish()
|
||||
} catch(e: Exception) {
|
||||
Logger.log.log(Level.SEVERE, "Couldn't remove account", e)
|
||||
}
|
||||
}, null)
|
||||
else
|
||||
accountManager.removeAccount(account, { future ->
|
||||
try {
|
||||
if (future.result)
|
||||
finish()
|
||||
} catch (e: Exception) {
|
||||
Logger.log.log(Level.SEVERE, "Couldn't remove account", e)
|
||||
}
|
||||
}, null)
|
||||
}
|
||||
|
||||
private fun requestSync() {
|
||||
requestSync(account)
|
||||
Snackbar.make(findViewById(R.id.parent), R.string.account_synchronizing_now, Snackbar.LENGTH_LONG).show()
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
/*
|
||||
* Copyright © Ricki Hirner (bitfire web engineering).
|
||||
* All rights reserved. This program and the accompanying materials
|
||||
* are made available under the terms of the GNU Public License v3.0
|
||||
* which accompanies this distribution, and is available at
|
||||
* http://www.gnu.org/licenses/gpl.html
|
||||
*/
|
||||
|
||||
package at.bitfire.davdroid.ui
|
||||
|
||||
import android.accounts.Account
|
||||
import android.accounts.AccountManager
|
||||
import android.accounts.OnAccountsUpdateListener
|
||||
import android.app.ListFragment
|
||||
import android.app.LoaderManager
|
||||
import android.content.AsyncTaskLoader
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.Loader
|
||||
import android.os.Bundle
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.widget.AbsListView
|
||||
import android.widget.AdapterView
|
||||
import android.widget.ArrayAdapter
|
||||
import at.bitfire.davdroid.AccountsChangedReceiver
|
||||
import at.bitfire.davdroid.R
|
||||
import kotlinx.android.synthetic.main.account_list_item.view.*
|
||||
|
||||
class AccountListFragment: ListFragment(), LoaderManager.LoaderCallbacks<Array<Account>> {
|
||||
|
||||
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
|
||||
listAdapter = AccountListAdapter(activity)
|
||||
|
||||
return inflater.inflate(R.layout.account_list, container, false)
|
||||
}
|
||||
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
super.onViewCreated(view, savedInstanceState)
|
||||
loaderManager.initLoader(0, arguments, this)
|
||||
|
||||
listView.choiceMode = AbsListView.CHOICE_MODE_SINGLE
|
||||
listView.onItemClickListener = AdapterView.OnItemClickListener { _, _, position, _ ->
|
||||
val account = listAdapter.getItem(position) as Account
|
||||
val intent = Intent(activity, AccountActivity::class.java)
|
||||
intent.putExtra(AccountActivity.EXTRA_ACCOUNT, account)
|
||||
startActivity(intent)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// loader
|
||||
|
||||
override fun onCreateLoader(id: Int, args: Bundle?) =
|
||||
AccountLoader(activity)
|
||||
|
||||
override fun onLoadFinished(loader: Loader<Array<Account>>, accounts: Array<Account>) {
|
||||
val adapter = listAdapter as AccountListAdapter
|
||||
adapter.clear()
|
||||
adapter.addAll(*accounts)
|
||||
}
|
||||
|
||||
override fun onLoaderReset(loader: Loader<Array<Account>>) {
|
||||
(listAdapter as AccountListAdapter).clear()
|
||||
}
|
||||
|
||||
class AccountLoader(
|
||||
context: Context
|
||||
): AsyncTaskLoader<Array<Account>>(context), OnAccountsUpdateListener {
|
||||
|
||||
override fun onStartLoading() =
|
||||
AccountsChangedReceiver.registerListener(this, true)
|
||||
|
||||
override fun onStopLoading() =
|
||||
AccountsChangedReceiver.unregisterListener(this)
|
||||
|
||||
override fun onAccountsUpdated(accounts: Array<Account>?) =
|
||||
forceLoad()
|
||||
|
||||
override fun loadInBackground(): Array<Account> =
|
||||
AccountManager.get(context).getAccountsByType(context.getString(R.string.account_type))
|
||||
|
||||
}
|
||||
|
||||
|
||||
// list adapter
|
||||
|
||||
class AccountListAdapter(
|
||||
context: Context
|
||||
): ArrayAdapter<Account>(context, R.layout.account_list_item) {
|
||||
|
||||
override fun getView(position: Int, v: View?, parent: ViewGroup?): View {
|
||||
val account = getItem(position)
|
||||
|
||||
val v = v ?: LayoutInflater.from(context).inflate(R.layout.account_list_item, parent, false)
|
||||
v.account_name.text = account.name
|
||||
|
||||
return v
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,265 @@
|
||||
/*
|
||||
* Copyright © Ricki Hirner (bitfire web engineering).
|
||||
* All rights reserved. This program and the accompanying materials
|
||||
* are made available under the terms of the GNU Public License v3.0
|
||||
* which accompanies this distribution, and is available at
|
||||
* http://www.gnu.org/licenses/gpl.html
|
||||
*/
|
||||
|
||||
package at.bitfire.davdroid.ui
|
||||
|
||||
import android.accounts.Account
|
||||
import android.content.ContentResolver
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.SyncStatusObserver
|
||||
import android.os.Bundle
|
||||
import android.provider.CalendarContract
|
||||
import android.support.v4.app.DialogFragment
|
||||
import android.support.v4.app.LoaderManager
|
||||
import android.support.v4.app.NavUtils
|
||||
import android.support.v4.content.AsyncTaskLoader
|
||||
import android.support.v4.content.Loader
|
||||
import android.support.v7.app.AppCompatActivity
|
||||
import android.support.v7.preference.*
|
||||
import android.support.v7.preference.Preference.OnPreferenceChangeListener
|
||||
import android.view.MenuItem
|
||||
import at.bitfire.davdroid.*
|
||||
import at.bitfire.davdroid.R
|
||||
import at.bitfire.davdroid.log.Logger
|
||||
import at.bitfire.ical4android.TaskProvider
|
||||
import at.bitfire.vcard4android.GroupMethod
|
||||
import org.apache.commons.lang3.StringUtils
|
||||
|
||||
class AccountSettingsActivity: AppCompatActivity() {
|
||||
|
||||
companion object {
|
||||
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<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(bundle: Bundle?, s: String?) {
|
||||
addPreferencesFromResource(R.xml.settings_account)
|
||||
}
|
||||
|
||||
override fun onCreateLoader(id: Int, args: Bundle) =
|
||||
AccountSettingsLoader(context, args.getParcelable(EXTRA_ACCOUNT))
|
||||
|
||||
override fun onLoadFinished(loader: Loader<AccountSettings>, settings: AccountSettings?) {
|
||||
if (settings == null) {
|
||||
activity.finish()
|
||||
return
|
||||
}
|
||||
|
||||
// category: authentication
|
||||
val prefUserName = findPreference("username") as EditTextPreference
|
||||
prefUserName.summary = settings.username()
|
||||
prefUserName.text = settings.username()
|
||||
prefUserName.onPreferenceChangeListener = OnPreferenceChangeListener { _, newValue ->
|
||||
settings.username(newValue as String)
|
||||
loaderManager.restartLoader(0, arguments, this)
|
||||
false
|
||||
}
|
||||
|
||||
val prefPassword = findPreference("password") as EditTextPreference
|
||||
prefPassword.onPreferenceChangeListener = OnPreferenceChangeListener { _, newValue ->
|
||||
settings.password(newValue as String)
|
||||
loaderManager.restartLoader(0, arguments, this)
|
||||
false
|
||||
}
|
||||
|
||||
// category: synchronization
|
||||
val prefSyncContacts = findPreference("sync_interval_contacts") as ListPreference
|
||||
val syncIntervalContacts = settings.getSyncInterval(App.addressBooksAuthority)
|
||||
if (syncIntervalContacts != null) {
|
||||
prefSyncContacts.value = syncIntervalContacts.toString()
|
||||
if (syncIntervalContacts == AccountSettings.SYNC_INTERVAL_MANUALLY)
|
||||
prefSyncContacts.setSummary(R.string.settings_sync_summary_manually)
|
||||
else
|
||||
prefSyncContacts.summary = getString(R.string.settings_sync_summary_periodically, syncIntervalContacts / 60)
|
||||
prefSyncContacts.onPreferenceChangeListener = OnPreferenceChangeListener { _, newValue ->
|
||||
settings.setSyncInterval(App.addressBooksAuthority, (newValue as String).toLong())
|
||||
loaderManager.restartLoader(0, arguments, this)
|
||||
false
|
||||
}
|
||||
} else
|
||||
prefSyncContacts.isVisible = false
|
||||
|
||||
val prefSyncCalendars = findPreference("sync_interval_calendars") as ListPreference
|
||||
val syncIntervalCalendars = settings.getSyncInterval(CalendarContract.AUTHORITY)
|
||||
if (syncIntervalCalendars != null) {
|
||||
prefSyncCalendars.value = syncIntervalCalendars.toString()
|
||||
if (syncIntervalCalendars == AccountSettings.SYNC_INTERVAL_MANUALLY)
|
||||
prefSyncCalendars.setSummary(R.string.settings_sync_summary_manually)
|
||||
else
|
||||
prefSyncCalendars.summary = getString(R.string.settings_sync_summary_periodically, syncIntervalCalendars / 60)
|
||||
prefSyncCalendars.onPreferenceChangeListener = Preference.OnPreferenceChangeListener { _, newValue ->
|
||||
settings.setSyncInterval(CalendarContract.AUTHORITY, (newValue as String).toLong())
|
||||
loaderManager.restartLoader(0, arguments, this)
|
||||
false
|
||||
}
|
||||
} else
|
||||
prefSyncCalendars.isVisible = false
|
||||
|
||||
val prefSyncTasks = findPreference("sync_interval_tasks") as ListPreference
|
||||
val syncIntervalTasks = settings.getSyncInterval(TaskProvider.ProviderName.OpenTasks.authority)
|
||||
if (syncIntervalTasks != null) {
|
||||
prefSyncTasks.value = syncIntervalTasks.toString()
|
||||
if (syncIntervalTasks == AccountSettings.SYNC_INTERVAL_MANUALLY)
|
||||
prefSyncTasks.setSummary(R.string.settings_sync_summary_manually)
|
||||
else
|
||||
prefSyncTasks.summary = getString(R.string.settings_sync_summary_periodically, syncIntervalTasks / 60)
|
||||
prefSyncTasks.onPreferenceChangeListener = Preference.OnPreferenceChangeListener { _, newValue ->
|
||||
settings.setSyncInterval(TaskProvider.ProviderName.OpenTasks.authority, (newValue as String).toLong())
|
||||
loaderManager.restartLoader(0, arguments, this)
|
||||
false
|
||||
}
|
||||
} else
|
||||
prefSyncTasks.isVisible = false
|
||||
|
||||
val prefWifiOnly = findPreference("sync_wifi_only") as SwitchPreferenceCompat
|
||||
prefWifiOnly.isChecked = settings.getSyncWifiOnly()
|
||||
prefWifiOnly.onPreferenceChangeListener = Preference.OnPreferenceChangeListener { _, wifiOnly ->
|
||||
settings.setSyncWiFiOnly(wifiOnly as Boolean)
|
||||
loaderManager.restartLoader(0, arguments, this)
|
||||
false
|
||||
}
|
||||
|
||||
val prefWifiOnlySSID = findPreference("sync_wifi_only_ssid") as EditTextPreference
|
||||
val onlySSID = settings.getSyncWifiOnlySSID()
|
||||
prefWifiOnlySSID.text = onlySSID
|
||||
if (onlySSID != null)
|
||||
prefWifiOnlySSID.summary = getString(R.string.settings_sync_wifi_only_ssid_on, onlySSID)
|
||||
else
|
||||
prefWifiOnlySSID.setSummary(R.string.settings_sync_wifi_only_ssid_off)
|
||||
prefWifiOnlySSID.onPreferenceChangeListener = Preference.OnPreferenceChangeListener { _, newValue ->
|
||||
settings.setSyncWifiOnlySSID(StringUtils.trimToNull(newValue as String))
|
||||
loaderManager.restartLoader(0, arguments, this)
|
||||
false
|
||||
}
|
||||
|
||||
// category: CardDAV
|
||||
val prefGroupMethod = findPreference("contact_group_method") as ListPreference
|
||||
if (syncIntervalContacts != null) {
|
||||
prefGroupMethod.value = settings.getGroupMethod().name
|
||||
prefGroupMethod.summary = prefGroupMethod.entry
|
||||
prefGroupMethod.onPreferenceChangeListener = Preference.OnPreferenceChangeListener { _, groupMethod ->
|
||||
settings.setGroupMethod(GroupMethod.valueOf(groupMethod as String))
|
||||
loaderManager.restartLoader(0, arguments, this)
|
||||
false
|
||||
}
|
||||
} else
|
||||
prefGroupMethod.isEnabled = false
|
||||
|
||||
// category: CalDAV
|
||||
val prefTimeRangePastDays = findPreference("time_range_past_days") as EditTextPreference
|
||||
if (syncIntervalCalendars != null) {
|
||||
val pastDays = settings.getTimeRangePastDays()
|
||||
if (pastDays != null) {
|
||||
prefTimeRangePastDays.text = pastDays.toString()
|
||||
prefTimeRangePastDays.summary = resources.getQuantityString(R.plurals.settings_sync_time_range_past_days, pastDays, pastDays)
|
||||
} else {
|
||||
prefTimeRangePastDays.text = null
|
||||
prefTimeRangePastDays.setSummary(R.string.settings_sync_time_range_past_none)
|
||||
}
|
||||
prefTimeRangePastDays.onPreferenceChangeListener = Preference.OnPreferenceChangeListener { _, newValue ->
|
||||
var days: Int
|
||||
try {
|
||||
days = (newValue as String).toInt()
|
||||
} catch(ignored: NumberFormatException) {
|
||||
days = -1
|
||||
}
|
||||
settings.setTimeRangePastDays(if (days < 0) null else days)
|
||||
loaderManager.restartLoader(0, arguments, this)
|
||||
false
|
||||
}
|
||||
} else
|
||||
prefTimeRangePastDays.isEnabled = false
|
||||
|
||||
val prefManageColors = findPreference("manage_calendar_colors") as SwitchPreferenceCompat
|
||||
if (syncIntervalCalendars != null || syncIntervalTasks != null) {
|
||||
prefManageColors.isChecked = settings.getManageCalendarColors()
|
||||
prefManageColors.onPreferenceChangeListener = Preference.OnPreferenceChangeListener { _, newValue ->
|
||||
settings.setManageCalendarColors(newValue as Boolean)
|
||||
loaderManager.restartLoader(0, arguments, this)
|
||||
false
|
||||
}
|
||||
} else
|
||||
prefManageColors.isEnabled = false
|
||||
|
||||
}
|
||||
|
||||
override fun onLoaderReset(loader: Loader<AccountSettings>) {
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
class AccountSettingsLoader(
|
||||
context: Context,
|
||||
val account: Account
|
||||
): AsyncTaskLoader<AccountSettings>(context), SyncStatusObserver {
|
||||
|
||||
lateinit var listenerHandle: Any
|
||||
|
||||
override fun onStartLoading() {
|
||||
forceLoad()
|
||||
listenerHandle = ContentResolver.addStatusChangeListener(ContentResolver.SYNC_OBSERVER_TYPE_SETTINGS, this)
|
||||
}
|
||||
|
||||
override fun onStopLoading() =
|
||||
ContentResolver.removeStatusChangeListener(listenerHandle)
|
||||
|
||||
override fun abandon() = onStopLoading()
|
||||
|
||||
override fun loadInBackground() =
|
||||
try {
|
||||
AccountSettings(context, account)
|
||||
} catch(e: InvalidAccountException) {
|
||||
null
|
||||
}
|
||||
|
||||
override fun onStatusChanged(which: Int) {
|
||||
Logger.log.fine("Reloading account settings")
|
||||
forceLoad()
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
/*
|
||||
* Copyright © Ricki Hirner (bitfire web engineering).
|
||||
* All rights reserved. This program and the accompanying materials
|
||||
* are made available under the terms of the GNU Public License v3.0
|
||||
* which accompanies this distribution, and is available at
|
||||
* http://www.gnu.org/licenses/gpl.html
|
||||
*/
|
||||
|
||||
package at.bitfire.davdroid.ui
|
||||
|
||||
import android.accounts.AccountManager
|
||||
import android.content.ContentResolver
|
||||
import android.content.Intent
|
||||
import android.content.SyncStatusObserver
|
||||
import android.net.Uri
|
||||
import android.os.Bundle
|
||||
import android.support.design.widget.NavigationView
|
||||
import android.support.design.widget.Snackbar
|
||||
import android.support.v4.view.GravityCompat
|
||||
import android.support.v7.app.ActionBarDrawerToggle
|
||||
import android.support.v7.app.AppCompatActivity
|
||||
import android.view.MenuItem
|
||||
import android.view.View
|
||||
import at.bitfire.davdroid.BuildConfig
|
||||
import at.bitfire.davdroid.R
|
||||
import at.bitfire.davdroid.ui.setup.LoginActivity
|
||||
import kotlinx.android.synthetic.main.accounts_content.*
|
||||
import kotlinx.android.synthetic.main.activity_accounts.*
|
||||
import java.util.*
|
||||
|
||||
class AccountsActivity: AppCompatActivity(), NavigationView.OnNavigationItemSelectedListener, SyncStatusObserver {
|
||||
|
||||
private var syncStatusSnackbar: Snackbar? = null
|
||||
private var syncStatusObserver: Any? = null
|
||||
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
setContentView(R.layout.activity_accounts)
|
||||
|
||||
setSupportActionBar(toolbar)
|
||||
|
||||
fab.setOnClickListener({
|
||||
startActivity(Intent(this@AccountsActivity, LoginActivity::class.java))
|
||||
})
|
||||
|
||||
val toggle = ActionBarDrawerToggle(
|
||||
this, drawer_layout, toolbar, R.string.navigation_drawer_open, R.string.navigation_drawer_close)
|
||||
drawer_layout.setDrawerListener(toggle)
|
||||
toggle.syncState()
|
||||
|
||||
nav_view.setNavigationItemSelectedListener(this)
|
||||
nav_view.itemIconTintList = null
|
||||
|
||||
if (savedInstanceState == null && packageName != getCallingPackage()) {
|
||||
// first call
|
||||
val ft = supportFragmentManager.beginTransaction()
|
||||
StartupDialogFragment.getStartupDialogs(this).forEach { ft.add(it, null) }
|
||||
ft.commit()
|
||||
}
|
||||
}
|
||||
|
||||
override fun onResume() {
|
||||
super.onResume()
|
||||
|
||||
onStatusChanged(ContentResolver.SYNC_OBSERVER_TYPE_SETTINGS)
|
||||
syncStatusObserver = ContentResolver.addStatusChangeListener(ContentResolver.SYNC_OBSERVER_TYPE_SETTINGS, this)
|
||||
}
|
||||
|
||||
override fun onPause() {
|
||||
super.onPause()
|
||||
syncStatusObserver?.let {
|
||||
ContentResolver.removeStatusChangeListener(it)
|
||||
syncStatusObserver = null
|
||||
}
|
||||
}
|
||||
|
||||
override fun onStatusChanged(which: Int) {
|
||||
syncStatusSnackbar?.let {
|
||||
it.dismiss()
|
||||
syncStatusSnackbar = null
|
||||
}
|
||||
|
||||
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)
|
||||
})
|
||||
syncStatusSnackbar = snackbar
|
||||
snackbar.show()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
override fun onBackPressed() {
|
||||
if (drawer_layout.isDrawerOpen(GravityCompat.START))
|
||||
drawer_layout.closeDrawer(GravityCompat.START)
|
||||
else
|
||||
super.onBackPressed()
|
||||
}
|
||||
|
||||
override fun onNavigationItemSelected(item: MenuItem): Boolean {
|
||||
var processed = true
|
||||
when (item.itemId) {
|
||||
R.id.nav_about ->
|
||||
startActivity(Intent(this, AboutActivity::class.java))
|
||||
R.id.nav_app_settings ->
|
||||
startActivity(Intent(this, AppSettingsActivity::class.java))
|
||||
R.id.nav_twitter ->
|
||||
startActivity(Intent(Intent.ACTION_VIEW, Uri.parse("https://twitter.com/davdroidapp")))
|
||||
R.id.nav_website ->
|
||||
startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(getString(R.string.homepage_url))))
|
||||
R.id.nav_faq ->
|
||||
startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(getString(R.string.navigation_drawer_faq_url))))
|
||||
R.id.nav_forums ->
|
||||
startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(getString(R.string.homepage_url))
|
||||
.buildUpon().appendEncodedPath("forums/").build()))
|
||||
R.id.nav_donate ->
|
||||
startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(getString(R.string.homepage_url))
|
||||
.buildUpon().appendEncodedPath("donate/").build()))
|
||||
else ->
|
||||
processed = false
|
||||
}
|
||||
|
||||
drawer_layout.closeDrawer(GravityCompat.START)
|
||||
return processed
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
/*
|
||||
* 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.content.Intent
|
||||
import android.os.Bundle
|
||||
import android.support.design.widget.Snackbar
|
||||
import android.support.v7.app.AppCompatActivity
|
||||
import android.support.v7.preference.EditTextPreference
|
||||
import android.support.v7.preference.Preference
|
||||
import android.support.v7.preference.PreferenceFragmentCompat
|
||||
import android.support.v7.preference.SwitchPreferenceCompat
|
||||
import at.bitfire.davdroid.App
|
||||
import at.bitfire.davdroid.CustomCertificates
|
||||
import at.bitfire.davdroid.R
|
||||
import at.bitfire.davdroid.log.Logger
|
||||
import at.bitfire.davdroid.model.ServiceDB
|
||||
import at.bitfire.davdroid.model.Settings
|
||||
import java.net.URI
|
||||
import java.net.URISyntaxException
|
||||
|
||||
class AppSettingsActivity: AppCompatActivity() {
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
|
||||
if (savedInstanceState == null)
|
||||
supportFragmentManager.beginTransaction()
|
||||
.replace(android.R.id.content, SettingsFragment())
|
||||
.commit()
|
||||
}
|
||||
|
||||
|
||||
class SettingsFragment: PreferenceFragmentCompat() {
|
||||
lateinit var dbHelper: ServiceDB.OpenHelper
|
||||
lateinit var settings: Settings
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
dbHelper = ServiceDB.OpenHelper(context)
|
||||
settings = Settings(dbHelper.readableDatabase)
|
||||
|
||||
// will call onCreatePreferences, so settings should be already initialized
|
||||
super.onCreate(savedInstanceState)
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
super.onDestroy()
|
||||
dbHelper.close()
|
||||
}
|
||||
|
||||
override fun onCreatePreferences(bundle: Bundle?, s: String?) {
|
||||
addPreferencesFromResource(R.xml.settings_app)
|
||||
|
||||
val prefResetHints = findPreference("reset_hints")
|
||||
prefResetHints.onPreferenceClickListener = Preference.OnPreferenceClickListener {
|
||||
resetHints()
|
||||
true
|
||||
}
|
||||
|
||||
val prefOverrideProxy = findPreference("override_proxy") as SwitchPreferenceCompat
|
||||
prefOverrideProxy.isChecked = settings.getBoolean(App.OVERRIDE_PROXY, false)
|
||||
prefOverrideProxy.onPreferenceChangeListener = Preference.OnPreferenceChangeListener { _, newValue ->
|
||||
settings.putBoolean(App.OVERRIDE_PROXY, newValue as Boolean)
|
||||
true
|
||||
}
|
||||
|
||||
val prefProxyHost = findPreference("proxy_host") as EditTextPreference
|
||||
val proxyHost = settings.getString(App.OVERRIDE_PROXY_HOST, App.OVERRIDE_PROXY_HOST_DEFAULT)
|
||||
prefProxyHost.text = proxyHost
|
||||
prefProxyHost.summary = proxyHost
|
||||
prefProxyHost.onPreferenceChangeListener = Preference.OnPreferenceChangeListener { _, newValue ->
|
||||
val host = newValue as String
|
||||
try {
|
||||
URI(null, host, null, null)
|
||||
settings.putString(App.OVERRIDE_PROXY_HOST, host)
|
||||
prefProxyHost.summary = host
|
||||
true
|
||||
} catch(e: URISyntaxException) {
|
||||
Snackbar.make(view!!, e.localizedMessage, Snackbar.LENGTH_LONG).show()
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
val prefProxyPort = findPreference("proxy_port") as EditTextPreference
|
||||
val proxyPort = settings.getString(App.OVERRIDE_PROXY_PORT, App.OVERRIDE_PROXY_PORT_DEFAULT.toString())
|
||||
prefProxyPort.text = proxyPort
|
||||
prefProxyPort.summary = proxyPort
|
||||
prefProxyPort.onPreferenceChangeListener = Preference.OnPreferenceChangeListener { _, newValue ->
|
||||
var port: Int
|
||||
try {
|
||||
port = Integer.parseInt(newValue as String)
|
||||
} catch(e: NumberFormatException) {
|
||||
port = App.OVERRIDE_PROXY_PORT_DEFAULT
|
||||
}
|
||||
settings.putInt(App.OVERRIDE_PROXY_PORT, port)
|
||||
prefProxyPort.text = port.toString()
|
||||
prefProxyPort.summary = port.toString()
|
||||
true
|
||||
}
|
||||
|
||||
val prefDistrustSystemCerts = findPreference("distrust_system_certs") as SwitchPreferenceCompat
|
||||
if (CustomCertificates.certManager == null)
|
||||
prefDistrustSystemCerts.isVisible = false
|
||||
else
|
||||
prefDistrustSystemCerts.isChecked = settings.getBoolean(App.DISTRUST_SYSTEM_CERTIFICATES, false)
|
||||
prefDistrustSystemCerts.onPreferenceClickListener = Preference.OnPreferenceClickListener {
|
||||
setDistrustSystemCerts(prefDistrustSystemCerts.isChecked)
|
||||
true
|
||||
}
|
||||
|
||||
val prefResetCertificates = findPreference("reset_certificates")
|
||||
if (CustomCertificates.certManager == null)
|
||||
prefResetCertificates.isVisible = false
|
||||
prefResetCertificates.onPreferenceClickListener = Preference.OnPreferenceClickListener {
|
||||
resetCertificates()
|
||||
true
|
||||
}
|
||||
|
||||
val prefLogToExternalStorage = findPreference("log_to_external_storage") as SwitchPreferenceCompat
|
||||
prefLogToExternalStorage.isChecked = settings.getBoolean(App.LOG_TO_EXTERNAL_STORAGE, false)
|
||||
prefLogToExternalStorage.onPreferenceClickListener = Preference.OnPreferenceClickListener {
|
||||
setExternalLogging(prefLogToExternalStorage.isChecked)
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
private fun resetHints() {
|
||||
settings.remove(StartupDialogFragment.HINT_BATTERY_OPTIMIZATIONS)
|
||||
settings.remove(StartupDialogFragment.HINT_GOOGLE_PLAY_ACCOUNTS_REMOVED)
|
||||
settings.remove(StartupDialogFragment.HINT_OPENTASKS_NOT_INSTALLED)
|
||||
Snackbar.make(view!!, R.string.app_settings_reset_hints_success, Snackbar.LENGTH_LONG).show()
|
||||
}
|
||||
|
||||
private fun setDistrustSystemCerts(distrust: Boolean) {
|
||||
settings.putBoolean(App.DISTRUST_SYSTEM_CERTIFICATES, distrust)
|
||||
|
||||
// re-initialize certificate manager
|
||||
CustomCertificates.reinitCertManager(context)
|
||||
|
||||
// reinitialize certificate manager of :sync process
|
||||
context.sendBroadcast(Intent(Settings.ReinitSettingsReceiver.ACTION_REINIT_SETTINGS))
|
||||
}
|
||||
|
||||
private fun resetCertificates() {
|
||||
CustomCertificates.certManager?.resetCertificates()
|
||||
Snackbar.make(view!!, getString(R.string.app_settings_reset_certificates_success), Snackbar.LENGTH_LONG).show()
|
||||
}
|
||||
|
||||
private fun setExternalLogging(externalLogging: Boolean) {
|
||||
settings.putBoolean(App.LOG_TO_EXTERNAL_STORAGE, externalLogging)
|
||||
|
||||
// reinitialize logger of default process
|
||||
Logger.reinitLogger(context)
|
||||
|
||||
// reinitialize logger of :sync process
|
||||
context.sendBroadcast(Intent(Settings.ReinitSettingsReceiver.ACTION_REINIT_SETTINGS))
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
/*
|
||||
* Copyright © Ricki Hirner (bitfire web engineering).
|
||||
* All rights reserved. This program and the accompanying materials
|
||||
* are made available under the terms of the GNU Public License v3.0
|
||||
* which accompanies this distribution, and is available at
|
||||
* http://www.gnu.org/licenses/gpl.html
|
||||
*/
|
||||
|
||||
package at.bitfire.davdroid.ui
|
||||
|
||||
import android.accounts.Account
|
||||
import android.app.LoaderManager
|
||||
import android.content.AsyncTaskLoader
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.Loader
|
||||
import android.os.Bundle
|
||||
import android.support.v4.app.NavUtils
|
||||
import android.support.v7.app.AppCompatActivity
|
||||
import android.view.Menu
|
||||
import android.view.MenuItem
|
||||
import android.widget.ArrayAdapter
|
||||
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 org.apache.commons.lang3.StringUtils
|
||||
import java.util.*
|
||||
|
||||
class CreateAddressBookActivity: AppCompatActivity(), LoaderManager.LoaderCallbacks<CreateAddressBookActivity.AccountInfo> {
|
||||
|
||||
companion object {
|
||||
val EXTRA_ACCOUNT = "account"
|
||||
}
|
||||
|
||||
private lateinit var account: Account
|
||||
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
|
||||
account = intent.getParcelableExtra(EXTRA_ACCOUNT)
|
||||
|
||||
supportActionBar?.setDisplayHomeAsUpEnabled(true)
|
||||
setContentView(R.layout.activity_create_address_book)
|
||||
|
||||
loaderManager.initLoader(0, intent.extras, this)
|
||||
}
|
||||
|
||||
override fun onCreateOptionsMenu(menu: Menu): Boolean {
|
||||
menuInflater.inflate(R.menu.activity_create_collection, menu)
|
||||
return true
|
||||
}
|
||||
|
||||
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
|
||||
|
||||
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())
|
||||
|
||||
if (ok) {
|
||||
info.type = CollectionInfo.Type.ADDRESS_BOOK
|
||||
CreateCollectionFragment.newInstance(account, info).show(supportFragmentManager, null)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
override fun onCreateLoader(id: Int, args: Bundle?) = AccountInfoLoader(this, account)
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onLoaderReset(loader: Loader<AccountInfo>) {
|
||||
}
|
||||
|
||||
class AccountInfo {
|
||||
val homeSets = LinkedList<String>()
|
||||
}
|
||||
|
||||
class AccountInfoLoader(
|
||||
context: Context,
|
||||
val account: Account
|
||||
): AsyncTaskLoader<AccountInfo>(context) {
|
||||
|
||||
override fun onStartLoading() = forceLoad()
|
||||
|
||||
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)
|
||||
|
||||
db.query(ServiceDB.HomeSets._TABLE, arrayOf(ServiceDB.HomeSets.URL),
|
||||
"${ServiceDB.HomeSets.SERVICE_ID}=?", arrayOf(strServiceID), null, null, null).use { c ->
|
||||
while (c.moveToNext())
|
||||
info.homeSets += c.getString(0)
|
||||
}
|
||||
}
|
||||
}
|
||||
return info
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,171 @@
|
||||
/*
|
||||
* Copyright © Ricki Hirner (bitfire web engineering).
|
||||
* All rights reserved. This program and the accompanying materials
|
||||
* are made available under the terms of the GNU Public License v3.0
|
||||
* which accompanies this distribution, and is available at
|
||||
* http://www.gnu.org/licenses/gpl.html
|
||||
*/
|
||||
|
||||
package at.bitfire.davdroid.ui
|
||||
|
||||
import android.accounts.Account
|
||||
import android.app.LoaderManager
|
||||
import android.content.AsyncTaskLoader
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.Loader
|
||||
import android.graphics.drawable.ColorDrawable
|
||||
import android.os.Bundle
|
||||
import android.support.v4.app.NavUtils
|
||||
import android.support.v7.app.AppCompatActivity
|
||||
import android.view.Menu
|
||||
import android.view.MenuItem
|
||||
import android.widget.ArrayAdapter
|
||||
import at.bitfire.davdroid.R
|
||||
import at.bitfire.davdroid.model.CollectionInfo
|
||||
import at.bitfire.davdroid.model.ServiceDB
|
||||
import at.bitfire.ical4android.DateUtils
|
||||
import kotlinx.android.synthetic.main.activity_create_calendar.*
|
||||
import net.fortuna.ical4j.model.Calendar
|
||||
import okhttp3.HttpUrl
|
||||
import org.apache.commons.lang3.StringUtils
|
||||
import yuku.ambilwarna.AmbilWarnaDialog
|
||||
import java.util.*
|
||||
|
||||
class CreateCalendarActivity: AppCompatActivity(), LoaderManager.LoaderCallbacks<CreateCalendarActivity.AccountInfo> {
|
||||
|
||||
companion object {
|
||||
val EXTRA_ACCOUNT = "account"
|
||||
}
|
||||
|
||||
private lateinit var account: Account
|
||||
|
||||
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()
|
||||
}
|
||||
|
||||
loaderManager.initLoader(0, null, this)
|
||||
}
|
||||
|
||||
override fun onCreateOptionsMenu(menu: Menu): Boolean {
|
||||
menuInflater.inflate(R.menu.activity_create_collection, menu)
|
||||
return true
|
||||
}
|
||||
|
||||
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
|
||||
|
||||
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
|
||||
|
||||
DateUtils.tzRegistry.getTimeZone(time_zone.selectedItem as String)?.let { tz ->
|
||||
val cal = Calendar()
|
||||
cal.components += tz.vTimeZone
|
||||
info.timeZone = cal.toString()
|
||||
}
|
||||
|
||||
when (type.checkedRadioButtonId) {
|
||||
R.id.type_events ->
|
||||
info.supportsVEVENT = true
|
||||
R.id.type_tasks ->
|
||||
info.supportsVTODO = true
|
||||
R.id.type_events_and_tasks -> {
|
||||
info.supportsVEVENT = true
|
||||
info.supportsVTODO = true
|
||||
}
|
||||
}
|
||||
|
||||
if (ok) {
|
||||
info.type = CollectionInfo.Type.CALENDAR
|
||||
CreateCollectionFragment.newInstance(account, info).show(supportFragmentManager, null)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
override fun onCreateLoader(id: Int, args: Bundle?) = AccountInfoLoader(this, account)
|
||||
|
||||
override fun onLoadFinished(loader: Loader<AccountInfo>, info: AccountInfo?) {
|
||||
val timeZones = TimeZone.getAvailableIDs()
|
||||
time_zone.adapter = ArrayAdapter<String>(this, android.R.layout.simple_spinner_dropdown_item, timeZones)
|
||||
|
||||
// select system time zone
|
||||
val defaultTimeZone = TimeZone.getDefault().id
|
||||
for (i in 0 .. timeZones.size - 1)
|
||||
if (timeZones[i] == defaultTimeZone) {
|
||||
time_zone.setSelection(i)
|
||||
break
|
||||
}
|
||||
|
||||
info?.let {
|
||||
home_sets.adapter = ArrayAdapter<String>(this, android.R.layout.simple_spinner_dropdown_item, info.homeSets)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onLoaderReset(loader: Loader<AccountInfo>) {}
|
||||
|
||||
|
||||
class AccountInfo {
|
||||
val homeSets = LinkedList<String>()
|
||||
}
|
||||
|
||||
class AccountInfoLoader(
|
||||
context: Context,
|
||||
val account: Account
|
||||
): AsyncTaskLoader<AccountInfo>(context) {
|
||||
|
||||
override fun onStartLoading() = forceLoad()
|
||||
|
||||
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)
|
||||
|
||||
db.query(ServiceDB.HomeSets._TABLE, arrayOf(ServiceDB.HomeSets.URL),
|
||||
"${ServiceDB.HomeSets.SERVICE_ID}=?", arrayOf(strServiceID), null, null, null).use { c ->
|
||||
while (c.moveToNext())
|
||||
info.homeSets += c.getString(0)
|
||||
}
|
||||
}
|
||||
}
|
||||
return info
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,222 @@
|
||||
/*
|
||||
* Copyright © Ricki Hirner (bitfire web engineering).
|
||||
* All rights reserved. This program and the accompanying materials
|
||||
* are made available under the terms of the GNU Public License v3.0
|
||||
* which accompanies this distribution, and is available at
|
||||
* http://www.gnu.org/licenses/gpl.html
|
||||
*/
|
||||
|
||||
package at.bitfire.davdroid.ui
|
||||
|
||||
import android.accounts.Account
|
||||
import android.app.Dialog
|
||||
import android.app.ProgressDialog
|
||||
import android.content.Context
|
||||
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.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 okhttp3.HttpUrl
|
||||
import java.io.IOException
|
||||
import java.io.StringWriter
|
||||
import java.util.logging.Level
|
||||
|
||||
@Suppress("DEPRECATION")
|
||||
class CreateCollectionFragment: DialogFragment(), LoaderManager.LoaderCallbacks<Exception> {
|
||||
|
||||
companion object {
|
||||
|
||||
val ARG_ACCOUNT = "account"
|
||||
val ARG_COLLECTION_INFO = "collectionInfo"
|
||||
|
||||
fun newInstance(account: Account, info: CollectionInfo): CreateCollectionFragment {
|
||||
val frag = CreateCollectionFragment()
|
||||
val args = Bundle(2)
|
||||
args.putParcelable(ARG_ACCOUNT, account)
|
||||
args.putSerializable(ARG_COLLECTION_INFO, info)
|
||||
frag.arguments = args
|
||||
return frag
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
private lateinit var account: Account
|
||||
private lateinit var info: CollectionInfo
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
|
||||
account = arguments.getParcelable(ARG_ACCOUNT)
|
||||
info = arguments.getSerializable(ARG_COLLECTION_INFO) as CollectionInfo
|
||||
|
||||
loaderManager.initLoader(0, null, this)
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
|
||||
override fun onCreateLoader(id: Int, args: Bundle?) = CreateCollectionLoader(context, account, info)
|
||||
|
||||
override fun onLoadFinished(loader: Loader<Exception>, exception: Exception?) {
|
||||
dismissAllowingStateLoss()
|
||||
|
||||
activity?.let { parent ->
|
||||
if (exception != null)
|
||||
fragmentManager.beginTransaction()
|
||||
.add(ExceptionInfoFragment.newInstance(exception, account), null)
|
||||
.commitAllowingStateLoss()
|
||||
else
|
||||
parent.finish()
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
override fun onLoaderReset(loader: Loader<Exception>) {}
|
||||
|
||||
|
||||
class CreateCollectionLoader(
|
||||
context: Context,
|
||||
val account: Account,
|
||||
val info: CollectionInfo
|
||||
): AsyncTaskLoader<Exception>(context) {
|
||||
|
||||
override fun onStartLoading() = forceLoad()
|
||||
|
||||
override fun loadInBackground(): Exception? {
|
||||
val writer = StringWriter()
|
||||
try {
|
||||
val serializer = XmlUtils.newSerializer()
|
||||
with(serializer) {
|
||||
setOutput(writer)
|
||||
startDocument("UTF-8", null)
|
||||
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")
|
||||
}
|
||||
|
||||
info.color?.let {
|
||||
startTag(XmlUtils.NS_APPLE_ICAL, "calendar-color")
|
||||
text(DavUtils.ARGBtoCalDAVColor(it))
|
||||
endTag(XmlUtils.NS_APPLE_ICAL, "calendar-color")
|
||||
}
|
||||
|
||||
info.timeZone?.let {
|
||||
startTag(XmlUtils.NS_CALDAV, "calendar-timezone")
|
||||
cdsect(it)
|
||||
endTag(XmlUtils.NS_CALDAV, "calendar-timezone")
|
||||
}
|
||||
|
||||
startTag(XmlUtils.NS_CALDAV, "supported-calendar-component-set")
|
||||
if (info.supportsVEVENT) {
|
||||
startTag(XmlUtils.NS_CALDAV, "comp")
|
||||
attribute(null, "name", "VEVENT")
|
||||
endTag(XmlUtils.NS_CALDAV, "comp")
|
||||
}
|
||||
if (info.supportsVTODO) {
|
||||
startTag(XmlUtils.NS_CALDAV, "comp")
|
||||
attribute(null, "name", "VTODO")
|
||||
endTag(XmlUtils.NS_CALDAV, "comp")
|
||||
}
|
||||
endTag(XmlUtils.NS_CALDAV, "supported-calendar-component-set")
|
||||
}
|
||||
|
||||
endTag(XmlUtils.NS_WEBDAV, "prop")
|
||||
endTag(XmlUtils.NS_WEBDAV, "set")
|
||||
endTag(XmlUtils.NS_WEBDAV, "mkcol")
|
||||
endDocument()
|
||||
}
|
||||
} catch(e: IOException) {
|
||||
Logger.log.log(Level.SEVERE, "Couldn't assemble Extended MKCOL request", e)
|
||||
}
|
||||
|
||||
try {
|
||||
val client = HttpClient.create(context, account)
|
||||
val collection = DavResource(client, HttpUrl.parse(info.url)!!)
|
||||
|
||||
// create collection on remote server
|
||||
collection.mkCol(writer.toString())
|
||||
|
||||
// now insert collection into database:
|
||||
ServiceDB.OpenHelper(context).use { dbHelper ->
|
||||
val db = dbHelper.writableDatabase
|
||||
|
||||
// 1. find service ID
|
||||
val serviceType = when (info.type) {
|
||||
CollectionInfo.Type.ADDRESS_BOOK -> ServiceDB.Services.SERVICE_CARDDAV
|
||||
CollectionInfo.Type.CALENDAR -> ServiceDB.Services.SERVICE_CALDAV
|
||||
else -> throw IllegalArgumentException("Collection must be an address book or calendar")
|
||||
}
|
||||
db.query(ServiceDB.Services._TABLE, arrayOf(ServiceDB.Services.ID),
|
||||
"${ServiceDB.Services.ACCOUNT_NAME}=? AND ${ServiceDB.Services.SERVICE}=?",
|
||||
arrayOf(account.name, serviceType), null, null, null).use { c ->
|
||||
|
||||
assert(c.moveToNext())
|
||||
val serviceID = c.getLong(0)
|
||||
|
||||
// 2. add collection to service
|
||||
val values = info.toDB()
|
||||
values.put(ServiceDB.Collections.SERVICE_ID, serviceID)
|
||||
db.insert(ServiceDB.Collections._TABLE, null, values)
|
||||
}
|
||||
}
|
||||
} catch(e: Exception) {
|
||||
return e
|
||||
}
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,237 +0,0 @@
|
||||
/*
|
||||
* Copyright © 2013 – 2015 Ricki Hirner (bitfire web engineering).
|
||||
* All rights reserved. This program and the accompanying materials
|
||||
* are made available under the terms of the GNU Public License v3.0
|
||||
* which accompanies this distribution, and is available at
|
||||
* http://www.gnu.org/licenses/gpl.html
|
||||
*/
|
||||
|
||||
package at.bitfire.davdroid.ui;
|
||||
|
||||
import android.accounts.Account;
|
||||
import android.app.Activity;
|
||||
import android.app.LoaderManager;
|
||||
import android.content.AsyncTaskLoader;
|
||||
import android.content.ContentResolver;
|
||||
import android.content.Context;
|
||||
import android.content.Intent;
|
||||
import android.content.Loader;
|
||||
import android.content.pm.PackageInfo;
|
||||
import android.content.pm.PackageManager;
|
||||
import android.net.Uri;
|
||||
import android.os.Build;
|
||||
import android.os.Bundle;
|
||||
import android.provider.ContactsContract;
|
||||
import android.text.TextUtils;
|
||||
import android.view.Menu;
|
||||
import android.view.MenuItem;
|
||||
import android.widget.TextView;
|
||||
|
||||
import org.apache.commons.lang3.exception.ExceptionUtils;
|
||||
|
||||
import java.io.File;
|
||||
import java.io.FileWriter;
|
||||
import java.io.IOException;
|
||||
|
||||
import at.bitfire.dav4android.exception.HttpException;
|
||||
import at.bitfire.davdroid.BuildConfig;
|
||||
import at.bitfire.davdroid.Constants;
|
||||
import at.bitfire.davdroid.R;
|
||||
|
||||
public class DebugInfoActivity extends Activity implements LoaderManager.LoaderCallbacks<String> {
|
||||
public static final String
|
||||
KEY_EXCEPTION = "exception",
|
||||
KEY_LOGS = "logs",
|
||||
KEY_ACCOUNT = "account",
|
||||
KEY_AUTHORITY = "authority",
|
||||
KEY_PHASE = "phase";
|
||||
|
||||
TextView tvReport;
|
||||
String report;
|
||||
|
||||
@Override
|
||||
protected void onCreate(Bundle savedInstanceState) {
|
||||
super.onCreate(savedInstanceState);
|
||||
|
||||
setContentView(R.layout.debug_info_activity);
|
||||
tvReport = (TextView)findViewById(R.id.text_report);
|
||||
|
||||
getLoaderManager().initLoader(0, getIntent().getExtras(), this);
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean onCreateOptionsMenu(Menu menu) {
|
||||
getMenuInflater().inflate(R.menu.exception_details_activity, menu);
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
public void onShare(MenuItem item) {
|
||||
if (!TextUtils.isEmpty(report)) {
|
||||
Intent sendIntent = new Intent();
|
||||
sendIntent.setAction(Intent.ACTION_SEND);
|
||||
sendIntent.setType("text/plain");
|
||||
sendIntent.putExtra(Intent.EXTRA_SUBJECT, "DAVdroid Exception Details");
|
||||
|
||||
try {
|
||||
File reportFile = reportFile();
|
||||
FileWriter writer = new FileWriter(reportFile());
|
||||
writer.write(report);
|
||||
writer.close();
|
||||
|
||||
sendIntent.putExtra(Intent.EXTRA_STREAM, Uri.fromFile(reportFile));
|
||||
} catch (IOException e) {
|
||||
// let's hope the report is < 1 MB
|
||||
sendIntent.putExtra(Intent.EXTRA_TEXT, report);
|
||||
}
|
||||
|
||||
startActivityForResult(sendIntent, 0);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onActivityResult(int requestCode, int resultCode, Intent data) {
|
||||
reportFile().delete();
|
||||
}
|
||||
|
||||
File reportFile() {
|
||||
return new File(getExternalCacheDir(), "error-report.txt");
|
||||
}
|
||||
|
||||
|
||||
@Override
|
||||
public Loader<String> onCreateLoader(int id, Bundle args) {
|
||||
return new ReportLoader(this, args);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onLoadFinished(Loader<String> loader, String data) {
|
||||
if (data != null)
|
||||
tvReport.setText(report = data);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onLoaderReset(Loader<String> loader) {
|
||||
}
|
||||
|
||||
|
||||
static class ReportLoader extends AsyncTaskLoader<String> {
|
||||
|
||||
final Bundle extras;
|
||||
|
||||
public ReportLoader(Context context, Bundle extras) {
|
||||
super(context);
|
||||
this.extras = extras;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onStartLoading() {
|
||||
forceLoad();
|
||||
}
|
||||
|
||||
@Override
|
||||
public String loadInBackground() {
|
||||
Exception exception = null;
|
||||
String logs = null,
|
||||
authority = null;
|
||||
Account account = null;
|
||||
int phase = -1;
|
||||
|
||||
if (extras != null) {
|
||||
exception = (Exception)extras.getSerializable(KEY_EXCEPTION);
|
||||
logs = extras.getString(KEY_LOGS);
|
||||
account = extras.getParcelable(KEY_ACCOUNT);
|
||||
authority = extras.getString(KEY_AUTHORITY);
|
||||
phase = extras.getInt(KEY_PHASE, -1);
|
||||
}
|
||||
|
||||
StringBuilder report = new StringBuilder();
|
||||
|
||||
// begin with most specific information
|
||||
|
||||
if (phase != -1)
|
||||
report.append("SYNCHRONIZATION INFO\nSynchronization phase: " + phase + "\n");
|
||||
if (account != null)
|
||||
report.append("Account name: " + account.name + "\n");
|
||||
if (authority != null)
|
||||
report.append("Authority: " + authority + "\n\n");
|
||||
|
||||
if (exception instanceof HttpException) {
|
||||
HttpException http = (HttpException)exception;
|
||||
if (http.request != null)
|
||||
report.append("HTTP REQUEST:\n" + http.request + "\n\n");
|
||||
if (http.response != null)
|
||||
report.append("HTTP RESPONSE:\n" + http.response + "\n\n");
|
||||
}
|
||||
|
||||
if (exception != null) {
|
||||
report.append("STACK TRACE:\n");
|
||||
for (String stackTrace : ExceptionUtils.getRootCauseStackTrace(exception))
|
||||
report.append(stackTrace + "\n");
|
||||
report.append("\n");
|
||||
}
|
||||
|
||||
if (logs != null)
|
||||
report.append("LOGS:\n" + logs + "\n");
|
||||
|
||||
try {
|
||||
PackageManager pm = getContext().getPackageManager();
|
||||
String installedFrom = pm.getInstallerPackageName(BuildConfig.APPLICATION_ID);
|
||||
if (TextUtils.isEmpty(installedFrom))
|
||||
installedFrom = "APK (directly)";
|
||||
else {
|
||||
PackageInfo installer = pm.getPackageInfo(installedFrom, PackageManager.GET_META_DATA);
|
||||
if (installer != null)
|
||||
installedFrom = pm.getApplicationLabel(installer.applicationInfo).toString();
|
||||
}
|
||||
boolean workaroundInstalled = false;
|
||||
try {
|
||||
workaroundInstalled = pm.getPackageInfo("at.bitfire.davdroid.jbworkaround", 0) != null;
|
||||
} catch(PackageManager.NameNotFoundException e) {}
|
||||
report.append(
|
||||
"SOFTWARE INFORMATION\n" +
|
||||
"DAVdroid version: " + BuildConfig.VERSION_NAME + " (" + BuildConfig.VERSION_CODE + ") " + BuildConfig.buildTime.toString() + "\n" +
|
||||
"Installed from: " + installedFrom + "\n" +
|
||||
"JB Workaround installed: " + (workaroundInstalled ? "yes" : "no") + "\n\n"
|
||||
);
|
||||
} catch(Exception ex) {
|
||||
Constants.log.error("Couldn't get software information", ex);
|
||||
}
|
||||
|
||||
report.append(
|
||||
"CONFIGURATION\n" +
|
||||
"System-wide synchronization: " + (ContentResolver.getMasterSyncAutomatically() ? "automatically" : "manually") + "\n\n"
|
||||
);
|
||||
/* TODO
|
||||
AccountManager accountManager = AccountManager.get(getContext());
|
||||
for (Account acc : accountManager.getAccountsByType(Constants.ACCOUNT_TYPE)) {
|
||||
report.append(
|
||||
" Account: " + acc.name + "\n" +
|
||||
" Address book synchronization: " + syncStatus(acc, ContactsContract.AUTHORITY) + "\n" +
|
||||
" Calendar synchronization: " + syncStatus(acc, CalendarContract.AUTHORITY) + "\n" +
|
||||
" OpenTasks synchronization: " + syncStatus(acc, "org.dmfs.tasks") + "\n"
|
||||
);
|
||||
}*/
|
||||
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 (Exception ex) {
|
||||
Constants.log.error("Couldn't get system details", ex);
|
||||
}
|
||||
|
||||
return report.toString();
|
||||
}
|
||||
|
||||
protected String syncStatus(Account account, String authority) {
|
||||
return ContentResolver.getIsSyncable(account, authority) > 0 ?
|
||||
(ContentResolver.getSyncAutomatically(account, ContactsContract.AUTHORITY) ? "automatically" : "manually") :
|
||||
"—";
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,281 @@
|
||||
/*
|
||||
* 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.LoaderManager
|
||||
import android.content.*
|
||||
import android.content.pm.PackageManager
|
||||
import android.net.ConnectivityManager
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import android.os.PowerManager
|
||||
import android.provider.CalendarContract
|
||||
import android.provider.ContactsContract
|
||||
import android.support.v4.content.ContextCompat
|
||||
import android.support.v4.content.FileProvider
|
||||
import android.support.v7.app.AppCompatActivity
|
||||
import android.view.Menu
|
||||
import android.view.MenuItem
|
||||
import at.bitfire.dav4android.exception.HttpException
|
||||
import at.bitfire.davdroid.AccountSettings
|
||||
import at.bitfire.davdroid.BuildConfig
|
||||
import at.bitfire.davdroid.InvalidAccountException
|
||||
import at.bitfire.davdroid.R
|
||||
import at.bitfire.davdroid.log.Logger
|
||||
import at.bitfire.davdroid.model.ServiceDB
|
||||
import at.bitfire.davdroid.resource.LocalAddressBook
|
||||
import at.bitfire.ical4android.TaskProvider
|
||||
import at.bitfire.vcard4android.ContactsStorageException
|
||||
import kotlinx.android.synthetic.main.activity_debug_info.*
|
||||
import org.apache.commons.lang3.exception.ExceptionUtils
|
||||
import 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> {
|
||||
|
||||
companion object {
|
||||
@JvmField val KEY_THROWABLE = "throwable"
|
||||
@JvmField val KEY_LOGS = "logs"
|
||||
@JvmField val KEY_ACCOUNT = "account"
|
||||
@JvmField val KEY_AUTHORITY = "authority"
|
||||
@JvmField val KEY_PHASE = "phase"
|
||||
@JvmField val KEY_LOCAL_RESOURCE = "localResource"
|
||||
@JvmField val KEY_REMOTE_RESOURCE = "remoteResource"
|
||||
}
|
||||
|
||||
var report: String? = null
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
setContentView(R.layout.activity_debug_info)
|
||||
|
||||
loaderManager.initLoader(0, intent.extras, this)
|
||||
}
|
||||
|
||||
override fun onCreateOptionsMenu(menu: Menu): Boolean {
|
||||
menuInflater.inflate(R.menu.activity_debug_info, menu)
|
||||
return true
|
||||
}
|
||||
|
||||
|
||||
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")
|
||||
|
||||
try {
|
||||
val debugInfoDir = File(cacheDir, "debug-info")
|
||||
debugInfoDir.mkdir()
|
||||
|
||||
val reportFile = File(debugInfoDir, "debug.txt")
|
||||
Logger.log.fine("Writing debug info to ${reportFile.absolutePath}")
|
||||
val writer = FileWriter(reportFile)
|
||||
writer.write(it)
|
||||
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)
|
||||
|
||||
} catch(e: IOException) {
|
||||
// creating an attachment failed, so send it inline
|
||||
sendIntent.putExtra(Intent.EXTRA_TEXT, it)
|
||||
}
|
||||
|
||||
startActivity(Intent.createChooser(sendIntent, null))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
override fun onCreateLoader(id: Int, args: Bundle?) =
|
||||
ReportLoader(this, args)
|
||||
|
||||
override fun onLoadFinished(loader: Loader<String>, data: String?) {
|
||||
data?.let {
|
||||
report = it
|
||||
text_report.text = it
|
||||
}
|
||||
}
|
||||
|
||||
override fun onLoaderReset(loader: Loader<String>) {}
|
||||
|
||||
|
||||
class ReportLoader(
|
||||
context: Context,
|
||||
val extras: Bundle?
|
||||
): AsyncTaskLoader<String>(context) {
|
||||
|
||||
override fun onStartLoading() = forceLoad()
|
||||
|
||||
override fun loadInBackground(): String {
|
||||
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")
|
||||
}
|
||||
throwable.response?.let {
|
||||
report.append("HTTP RESPONSE:\n$it\n")
|
||||
}
|
||||
}
|
||||
|
||||
extras?.getString(KEY_LOCAL_RESOURCE)?.let {
|
||||
report.append("\nLOCAL RESOURCE:\n$it\n")
|
||||
}
|
||||
extras?.getString(KEY_REMOTE_RESOURCE)?.let {
|
||||
report.append("\nREMOTE RESOURCE:\n$it\n")
|
||||
}
|
||||
|
||||
throwable?.let {
|
||||
report.append("\nEXCEPTION:\n${ExceptionUtils.getStackTrace(throwable)}")
|
||||
}
|
||||
|
||||
// logs (for instance, from failed resource detection)
|
||||
extras?.getString(KEY_LOGS)?.let {
|
||||
report.append("\nLOGS:\n$it\n")
|
||||
}
|
||||
|
||||
// software information
|
||||
try {
|
||||
val pm = context.packageManager
|
||||
val installedFrom = pm.getInstallerPackageName(BuildConfig.APPLICATION_ID) ?: "APK (directly)"
|
||||
var workaroundInstalled = false
|
||||
try {
|
||||
workaroundInstalled = pm.getPackageInfo("${BuildConfig.APPLICATION_ID}.jbworkaround", 0) != null
|
||||
} catch(e: PackageManager.NameNotFoundException) {
|
||||
}
|
||||
val formatter = SimpleDateFormat.getDateInstance()
|
||||
report.append("\nSOFTWARE INFORMATION\n" +
|
||||
"Package: ${BuildConfig.APPLICATION_ID}\n" +
|
||||
"Version: ${BuildConfig.VERSION_NAME} (${BuildConfig.VERSION_CODE}) from ${formatter.format(Date(BuildConfig.buildTime))}\n")
|
||||
.append("Installed from: $installedFrom\n")
|
||||
.append("JB Workaround installed: ${if (workaroundInstalled) "yes" else "no"}\n\n")
|
||||
} catch(e: Exception) {
|
||||
Logger.log.log(Level.SEVERE, "Couldn't get software information", e)
|
||||
}
|
||||
|
||||
// connectivity
|
||||
report.append("CONNECTIVITY (at the moment)\n")
|
||||
val connectivityManager = context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
|
||||
connectivityManager.activeNetworkInfo?.let { networkInfo ->
|
||||
val type = when (networkInfo.type) {
|
||||
ConnectivityManager.TYPE_WIFI -> "WiFi"
|
||||
ConnectivityManager.TYPE_MOBILE -> "mobile"
|
||||
else -> "type: ${networkInfo.type}"
|
||||
}
|
||||
report.append("Active connection: $type, ${networkInfo.detailedState}\n")
|
||||
}
|
||||
if (Build.VERSION.SDK_INT >= 23)
|
||||
connectivityManager.defaultProxy?.let { proxy ->
|
||||
report.append("System default proxy: ${proxy.host}:${proxy.port}")
|
||||
}
|
||||
report.append("\n")
|
||||
|
||||
report.append("CONFIGURATION\n")
|
||||
// power saving
|
||||
val powerManager = context.getSystemService(Context.POWER_SERVICE) as PowerManager
|
||||
if (Build.VERSION.SDK_INT >= 23)
|
||||
report.append("Power saving disabled: ")
|
||||
.append(if (powerManager.isIgnoringBatteryOptimizations(BuildConfig.APPLICATION_ID)) "yes" else "no")
|
||||
.append("\n")
|
||||
// permissions
|
||||
for (permission in arrayOf(Manifest.permission.READ_CONTACTS, Manifest.permission.WRITE_CONTACTS,
|
||||
Manifest.permission.READ_CALENDAR, Manifest.permission.WRITE_CALENDAR,
|
||||
PermissionsActivity.PERMISSION_READ_TASKS, PermissionsActivity.PERMISSION_WRITE_TASKS))
|
||||
report.append(permission).append(" permission: ")
|
||||
.append(if (ContextCompat.checkSelfPermission(context, permission) == PackageManager.PERMISSION_GRANTED) "granted" else "denied")
|
||||
.append("\n")
|
||||
// system-wide sync settings
|
||||
report.append("System-wide synchronization: ")
|
||||
.append(if (ContentResolver.getMasterSyncAutomatically()) "automatically" else "manually")
|
||||
.append("\n")
|
||||
// main accounts
|
||||
val accountManager = AccountManager.get(context)
|
||||
for (acct in accountManager.getAccountsByType(context.getString(R.string.account_type)))
|
||||
try {
|
||||
val settings = AccountSettings(context, acct)
|
||||
report.append("Account: ${acct.name}\n" +
|
||||
" Address book sync. interval: ${syncStatus(settings, context.getString(R.string.address_books_authority))}\n" +
|
||||
" Calendar sync. interval: ${syncStatus(settings, CalendarContract.AUTHORITY)}\n" +
|
||||
" OpenTasks sync. interval: ${syncStatus(settings, TaskProvider.ProviderName.OpenTasks.authority)}\n" +
|
||||
" WiFi only: ").append(settings.getSyncWifiOnly())
|
||||
settings.getSyncWifiOnlySSID()?.let {
|
||||
report.append(", SSID: ${settings.getSyncWifiOnlySSID()}")
|
||||
}
|
||||
report.append("\n [CardDAV] Contact group method: ${settings.getGroupMethod()}")
|
||||
.append("\n [CalDAV] Time range (past days): ${settings.getTimeRangePastDays()}")
|
||||
.append("\n Manage calendar colors: ${settings.getManageCalendarColors()}")
|
||||
.append("\n")
|
||||
} catch(e: InvalidAccountException) {
|
||||
report.append("$acct is invalid (unsupported settings version) or does not exist\n")
|
||||
}
|
||||
// address book accounts
|
||||
for (acct in accountManager.getAccountsByType(context.getString(R.string.account_type_address_book)))
|
||||
try {
|
||||
val addressBook = LocalAddressBook(context, acct, null)
|
||||
report.append("Address book account: ${acct.name}\n" +
|
||||
" Main account: ${addressBook.getMainAccount()}\n" +
|
||||
" URL: ${addressBook.getURL()}\n" +
|
||||
" Sync automatically: ").append(ContentResolver.getSyncAutomatically(acct, ContactsContract.AUTHORITY)).append("\n")
|
||||
} catch(e: ContactsStorageException) {
|
||||
report.append("$acct is invalid: ${e.message}\n")
|
||||
}
|
||||
report.append("\n")
|
||||
|
||||
ServiceDB.OpenHelper(context).use { dbHelper ->
|
||||
report.append("SQLITE DUMP\n")
|
||||
dbHelper.dump(report)
|
||||
report.append("\n")
|
||||
}
|
||||
|
||||
try {
|
||||
report.append(
|
||||
"SYSTEM INFORMATION\n" +
|
||||
"Android version: ${Build.VERSION.RELEASE} (${Build.DISPLAY})\n" +
|
||||
"Device: ${Build.MANUFACTURER} ${Build.MODEL} (${Build.DEVICE})\n\n"
|
||||
)
|
||||
} catch(e: Exception) {
|
||||
Logger.log.log(Level.SEVERE, "Couldn't get system details", e)
|
||||
}
|
||||
|
||||
report.append("--- END DEBUG INFO ---\n")
|
||||
return report.toString()
|
||||
}
|
||||
|
||||
private fun syncStatus(settings: AccountSettings, authority: String): String {
|
||||
val interval = settings.getSyncInterval(authority)
|
||||
return if (interval != null) {
|
||||
if (interval == AccountSettings.SYNC_INTERVAL_MANUALLY) "manually" else "${interval/60} min"
|
||||
} else
|
||||
"—"
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
Loaded 100 of 309 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user