Compare commits

...
Author SHA1 Message Date
Ricki Hirner 216579680d Add maxOccurs to contacts.xml group membership (allows editing of contacts again) 2018-05-28 11:56:47 +02:00
Ricki Hirner fe8b50c76d Fetch translations from Transifex 2018-05-28 10:47:33 +02:00
Ricki Hirner 9ab7b3b105 Add group memberships to contacts.xml so that they can be edited with some Contacts apps 2018-05-28 10:47:04 +02:00
Ricki Hirner b90ec5b64c Version bump to 1.11.4-beta2 2018-05-27 16:28:41 +02:00
Ricki Hirner b197ac7da2 Show HTTP request/response in debug info 2018-05-27 16:28:02 +02:00
Ricki Hirner 9aa58d170f Use US locale for date in User-Agent 2018-05-27 15:34:30 +02:00
Ricki Hirner 553b782339 Fix lateinit problem 2018-05-26 12:42:02 +02:00
Ricki Hirner 8b6376e01d Use HttpUrl whenever possible
* HttpUrl is the preferred class because we use URLs mainly for okhttp
* don't use URI or String for URLs, if possible
* HttpUrl is not Serializable, so use Parcelable for data classes with HttpUrl

X
2018-05-26 12:29:42 +02:00
Ricki Hirner 5da229ed88 Use weak reference for sync adapter thread lock 2018-05-26 12:29:38 +02:00
Ricki Hirner a0bb681f48 Don't show InterruptedIOException; tests 2018-05-25 10:57:03 +02:00
Ricki Hirner 2e3987e6af Version bump to 1.11.4-beta1 2018-05-24 14:14:27 +02:00
Ricki Hirner f72588a691 Update ical4android 2018-05-24 14:14:27 +02:00
Ricki Hirner 56fc2121f8 Integrate adaptive icon 2018-05-24 14:14:27 +02:00
Lokesh Krishna c863d05f3f Adaptive icon 2018-05-24 14:14:27 +02:00
Ricki Hirner f0ff12ebb9 dav4android update (immutable responses) 2018-05-24 14:14:27 +02:00
Ricki Hirner 87cb43b0e6 Fetch translations from Transifex 2018-05-24 14:14:27 +02:00
Ricki Hirner 2e073897b8 Collection sync: handle 403 with valid-sync-token precondition 2018-05-24 14:14:27 +02:00
Ricki Hirner bdc032d1b9 Update build.gradle 2018-05-12 11:27:19 +02:00
Ricki Hirner d07fcdcddc ical4android: reduce size of sent VTIMEZONEs 2018-05-11 23:18:54 +02:00
Ricki Hirner 5f2f151e14 Version bump to 1.11.3 2018-05-10 12:57:00 +02:00
Ricki Hirner 33ddb06272 Enable Collection Synchronization for CalDAV when past time event limit is disabled 2018-05-10 12:55:32 +02:00
Ricki Hirner ef9f13d1d6 Version bump to 1.11.2 2018-05-04 11:01:06 +02:00
Ricki Hirner 6b9520287b Collection sync: mark skipped entries as locally present 2018-05-01 16:34:38 +02:00
Ricki Hirner 3944ab0222 Version bump to 1.11.2-beta1 2018-05-01 10:19:57 +02:00
Ricki Hirner 103459464c Improve collection sync for contacts 2018-04-30 12:42:15 +02:00
Ricki Hirner be2d10278d Collection sync: don't download already available resources 2018-04-30 12:41:27 +02:00
Ricki Hirner f4b864b3d0 Support collection sync (RFC 6578) for contacts 2018-04-30 12:41:21 +02:00
Ricki Hirner a576701ee3 Code cleanup (lint) 2018-04-28 21:23:37 +02:00
Ricki Hirner 40bca54c09 Fix memory leak in vcard4android 2018-04-27 00:54:01 +02:00
Ricki Hirner 88efbd7a00 Add message to local storage error notification 2018-04-26 12:29:16 +02:00
Ricki Hirner 292eb3bb52 Fetch translations from Transifex 2018-04-26 10:53:53 +02:00
Ricki Hirner bfddf92a3b Don't throw exception when content provider doesn't return all results; ignore RemoteException on getting contacts sync state 2018-04-26 10:53:27 +02:00
Ricki Hirner 872bfa2f40 Version bump to 1.11.1 2018-04-26 10:52:30 +02:00
Ricki Hirner 73d9dff1b7 Show and use ical4j and okhttp version numbers when possible 2018-04-25 01:04:13 +02:00
Ricki Hirner fd84ff37e5 Log remote resource info for tasks, too 2018-04-19 08:30:23 +02:00
Ricki Hirner d280ce9d63 Treat InterruptedIOException like IOException 2018-04-13 15:24:03 +02:00
Ricki Hirner 67c7be900a Fetch translations from Transifex 2018-04-13 10:24:33 +02:00
Ricki Hirner c9cd6de476 Version bump to 1.11 2018-04-13 10:19:29 +02:00
Ricki Hirner 924ef0243d Fetch translations from Transifex 2018-03-29 14:41:22 +02:00
Ricki Hirner 62d9668081 Version bump to 1.11-rc1 2018-03-29 14:41:00 +02:00
Ricki Hirner ab6fa7ecbf Update gradle, kotlin, build tools 2018-03-29 14:07:52 +02:00
Ricki Hirner 74b0e5cd31 Fetch translations from Transifex 2018-03-26 09:37:19 +02:00
Ricki Hirner e2960ab572 Ask for LOCATION_COARSE permission for WiFi name detection on Android 8.1+ 2018-03-26 09:36:14 +02:00
Ricki Hirner 50bd119871 Don't re-schedule non-existent master events of deleted exceptions 2018-03-24 23:14:03 +01:00
Ricki Hirner c1f3165f5d Improve OpenTasks install message 2018-03-20 12:26:13 +01:00
Ricki Hirner 915cd6ec44 Restrict more method names by interfaces 2018-03-16 13:16:17 +01:00
Ricki Hirner 73e936aeb5 Fix sync bugs 2018-03-15 19:37:04 +01:00
Ricki Hirner 190fc020ae Minor fixes 2018-03-15 18:14:28 +01:00
Ricki Hirner c40a6bca2f OSE fixes 2018-03-15 17:37:19 +01:00
Ricki Hirner 3129fd909f Use Google repo 2018-03-15 13:11:00 +01:00
Ricki Hirner 31c77a1d57 Themeing, minor refactoring 2018-03-15 13:05:57 +01:00
Ricki Hirner 380562bd28 Add "app auto-start permission" startup dialog for specific vendors 2018-03-15 13:04:38 +01:00
Ricki Hirner 80b1cb55c7 Tests, minor refactoring 2018-03-15 13:01:41 +01:00
Ricki Hirner 59dd66383e Sync error notifications: retry action, lower importance of IOEXceptions 2018-03-15 13:01:19 +01:00
Ricki Hirner 44af04e310 Account activity: remove SnackBar message when a read-only address book is selected 2018-03-15 13:00:11 +01:00
Ricki Hirner adf45cb569 Show startup fragments only once per AccountActivity lifecycle 2018-03-15 12:59:58 +01:00
Ricki Hirner ab88020c15 Sync logic fix, theming, ProGuard 2018-03-15 12:58:56 +01:00
Ricki Hirner e27c6fde08 Update to support library 27.1.0 and use it wherever possible
* Fragment transactions can now be done in onLoadFinished().
2018-03-15 12:57:51 +01:00
Ricki Hirner 3135af78af Tests 2018-03-15 12:44:43 +01:00
Ricki Hirner 9200ec89e1 Further improve notifications 2018-03-15 12:44:23 +01:00
Ricki Hirner b37a2ad0e0 Remove Project Lombok from About
* not used anymore because of Kotlin
* thanks to Project Lombok!
2018-03-15 12:43:44 +01:00
Ricki Hirner 1df42350cc Remove PermissionsActivity
* asking for permissions is already (and better) done by AccountActivity
2018-03-15 12:43:32 +01:00
Ricki Hirner 3dbd5e3d18 Synchronization error messages / notifications 2018-03-15 12:41:53 +01:00
Ricki Hirner 7fdcb3710b Rewrite sync algorithm, prepare for WebDAV collection sync 2018-03-15 12:40:32 +01:00
Ricki Hirner 2f9f4f1d7b Optimize notification icons
* don't show DAVdroid icon unless there's a strong relationship to DAVdroid itself
2018-03-15 12:39:13 +01:00
Ricki Hirner 9fc3921b32 Account activity: show progress bar at beginning 2018-01-30 14:58:50 +01:00
Ricki Hirner 4f4a22a14e ical4android update 2018-01-27 22:57:34 +01:00
Ricki Hirner 16c44d8ad3 OpenTasks is installed by ical4android tests 2018-01-25 15:11:24 +01:00
Ricki Hirner 6cd2aa783e Prefer AutoCloseable over Closeable 2018-01-25 15:09:57 +01:00
Ricki Hirner c2e161dac6 lib updates 2018-01-25 15:04:43 +01:00
Ricki Hirner 5a686a9a4c Version bump to 1.10.1.1 2018-01-20 17:11:58 +01:00
Ricki Hirner 7ba98d1c71 Hotfix: don't clear event colors 2018-01-20 17:08:29 +01:00
Ricki Hirner fee5ab5064 Version bump to 1.10.1 2018-01-20 15:26:15 +01:00
Ricki Hirner 6e1b42e6a4 Fetch translations from Transifex 2018-01-20 15:23:32 +01:00
Ricki Hirner c1ce953e3e ical4android: work around missing account separation when updating events
* fixes removed eventColor_index fields when there is at least one DAVdroid
  account with enabled calendar colors and at least one account with disabled
  calendar colors
2018-01-20 15:01:05 +01:00
Ricki Hirner 3a6db13df8 Update Kotlin 2018-01-18 16:17:29 +01:00
Ricki Hirner d1d771b16a Version bump to 1.10 2018-01-18 16:11:54 +01:00
Ricki Hirner f986d9807e Fetch translations from Transifex 2018-01-18 16:11:14 +01:00
Ricki Hirner e3f33a5603 Test for client certificates 2018-01-18 16:10:26 +01:00
Ricki Hirner 702888e9bd Login activity: add padding; sync: re-throw Interrupted(IO)Exception 2018-01-15 21:33:00 +01:00
Ricki Hirner 6b41849cae Fetch translations from Transifex 2018-01-15 20:52:13 +01:00
Ricki Hirner 9dfb23b1d2 Remove unnecessary strings 2018-01-15 20:49:47 +01:00
Ricki Hirner 3468473f63 Improve sync error notifications
* refactor checking for cancelled sync
* notify on SSLHandshakeException (except when a certificate was rejected by cert4android)
* show exception cause in debug info
2018-01-15 20:43:07 +01:00
Ricki Hirner bc63559a24 Login activity
* use TextInputLayout for input fields
* use support library instead of custom EditPassword widget
* improve client certificate UI
2018-01-15 14:00:03 +01:00
Ricki Hirner 3689df1385 Login with client certificates
* setup UI: login with URL and client certificate
* account settings UI: show either username/password or client certificate alias
* AccountSettings: serve credentials in generalized Credentials objects
* HttpClient: use Credentials (instead of username/password) for authentication
* HttpClient: always use CustomTlsSocketFactory
* CustomTlsSocketFactory: support client certificates
2018-01-13 22:56:13 +01:00
Ricki Hirner dd3b326f66 Version bump to 1.9.10 2018-01-03 12:14:12 +01:00
Ricki Hirner e2ca9eb7d7 dav4android update 2018-01-02 20:26:56 +01:00
Ricki Hirner b001be1626 Fetch translations from Transifex 2018-01-02 19:41:53 +01:00
Ricki Hirner e09714af32 Fix DB upgrade logic 2018-01-02 19:29:42 +01:00
Ricki Hirner a4a816296b Refactoring 2018-01-01 15:13:57 +01:00
Ricki Hirner 65db706e06 Do contact provider settings only at address book creation (fixes changed contact visibility at sync) 2017-12-31 13:38:49 +01:00
Ricki Hirner abd0eb533e Version bump to 1.9.9 2017-12-27 13:06:58 +01:00
Ricki Hirner 323cd48a30 Fetch translations from Transifex 2017-12-26 13:08:09 +01:00
Ricki Hirner 07b59fb6be Update for OpenTasks support
* ical4android update
* use uid field for tasks
* require OpenTasks 1.1.8.2
2017-12-26 13:08:04 +01:00
Ricki Hirner 3e2e17bdb2 Fix crash 2017-12-18 09:25:09 +01:00
Ricki Hirner 73c5c9ef64 Fix string resource 2017-12-16 22:09:21 +01:00
Ricki Hirner 12261a23ea Version bump to 1.9.8.1 2017-12-16 22:02:55 +01:00
Ricki Hirner e19b045494 Fetch translations from Transifex 2017-12-16 22:01:35 +01:00
Ricki Hirner 076962e5ed Specify DNS server for dnsjava explicitly 2017-12-16 21:55:29 +01:00
Ricki Hirner 04306ed919 Version bump to 1.9.8 2017-12-16 18:56:16 +01:00
Ricki Hirner 93abef95f7 Fetch translations from Transifex 2017-12-16 18:54:16 +01:00
Ricki Hirner 54cc3bec9f Refactor Loaders, HttpClient
* improve Loader implementation
* use one shared HttpClient singleton
2017-12-16 18:47:48 +01:00
Ricki Hirner d4324bcf8e Navigation drawer: add link to manual 2017-12-11 22:58:16 +01:00
Ricki Hirner eff4438e9f Version bump to 1.9.7 2017-12-11 20:02:30 +01:00
Ricki Hirner 1f7298f947 Don't rely on LOGIN_ACCOUNTS_CHANGED_ACTION 2017-12-11 18:17:28 +01:00
144 changed files with 5278 additions and 4299 deletions

No files matched your search

-1
View File
@@ -12,7 +12,6 @@ cache:
test:
script:
- (cd /sdk/emulator; ./emulator @test -no-audio -no-window & wait-for-emulator.sh)
- (mkdir -p apk && cd apk && wget -q https://f-droid.org/repo/org.dmfs.tasks_481.apk && adb install org.dmfs.tasks_481.apk)
- ./gradlew check mergeAndroidReports
artifacts:
paths:
+24 -32
View File
@@ -13,12 +13,12 @@ apply plugin: 'org.jetbrains.dokka-android'
android {
compileSdkVersion 27
buildToolsVersion '27.0.1'
buildToolsVersion '27.0.3'
defaultConfig {
applicationId "at.bitfire.davdroid"
versionCode 194
versionCode 228
buildConfigField "long", "buildTime", System.currentTimeMillis() + "L"
buildConfigField "boolean", "customCerts", "true"
@@ -33,10 +33,9 @@ android {
productFlavors {
standard {
versionName "1.9.6-ose"
versionName "1.11.4-beta4-ose"
buildConfigField "boolean", "customCerts", "true"
buildConfigField "boolean", "customCertsUI", "true"
}
}
@@ -55,7 +54,6 @@ android {
disable 'ImpliedQuantity', 'MissingQuantity' // quantities from Transifex may vary
disable 'MissingTranslation', 'ExtraTranslation' // translations from Transifex are not always up to date
disable "OnClick" // doesn't recognize Kotlin onClick methods
disable 'Recycle' // doesn't understand Lombok's @Cleanup
disable 'RtlEnabled'
disable 'RtlHardcoded'
disable 'Typos'
@@ -71,38 +69,32 @@ android {
}
dependencies {
compile project(':cert4android')
compile project(':dav4android')
compile project(':ical4android')
compile project(':vcard4android')
implementation project(':cert4android')
implementation project(':dav4android')
implementation project(':ical4android')
implementation project(':vcard4android')
compile "org.jetbrains.kotlin:kotlin-stdlib-jre7:$kotlin_version"
implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlin_version"
compile 'com.android.support:appcompat-v7:27.0.2'
compile 'com.android.support:cardview-v7:27.0.2'
compile 'com.android.support:design:27.0.2'
compile 'com.android.support:preference-v14:27.0.2'
implementation 'com.android.support:appcompat-v7:27.1.1'
implementation 'com.android.support:cardview-v7:27.1.1'
implementation 'com.android.support:design:27.1.1'
implementation 'com.android.support:preference-v14:27.1.1'
compile 'com.github.yukuku:ambilwarna:2.0.1'
implementation 'com.github.yukuku:ambilwarna:2.0.1'
compile 'com.squareup.okhttp3:logging-interceptor:3.9.1'
compile 'commons-io:commons-io:2.6'
compile 'dnsjava:dnsjava:2.1.8'
compile 'org.apache.commons:commons-lang3:3.6'
compile 'org.apache.commons:commons-collections4:4.1'
implementation 'com.squareup.okhttp3:logging-interceptor:3.10.0'
implementation 'commons-io:commons-io:2.6'
implementation 'dnsjava:dnsjava:2.1.8'
implementation 'org.apache.commons:commons-lang3:3.7'
implementation 'org.apache.commons:commons-collections4:4.1'
// 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.9.1'
androidTestImplementation 'com.android.support.test:runner:1.0.2'
androidTestImplementation 'com.android.support.test:rules:1.0.2'
androidTestImplementation 'junit:junit:4.12'
androidTestImplementation 'com.squareup.okhttp3:mockwebserver:3.10.0'
testCompile 'junit:junit:4.12'
testCompile 'com.squareup.okhttp3:mockwebserver:3.9.1'
testImplementation 'junit:junit:4.12'
testImplementation 'com.squareup.okhttp3:mockwebserver:3.10.0'
}
+2 -1
View File
@@ -19,7 +19,7 @@
-dontwarn ezvcard.io.json.** # JSON serializer (for jCards) not used
-dontwarn freemarker.** # freemarker templating library (for creating hCards) not used
-dontwarn org.jsoup.** # jsoup library (for hCard parsing) not used
-keep class ezvcard.property.** { *; } # keep all VCard properties (created at runtime)
-keep class ezvcard.property.** { *; } # keep all vCard properties (created at runtime)
# ical4j: ignore unused dynamic libraries
-dontwarn aQute.**
@@ -35,6 +35,7 @@
-dontwarn okio.**
-dontwarn javax.annotation.Nullable
-dontwarn javax.annotation.ParametersAreNonnullByDefault
-dontwarn org.conscrypt.**
# dnsjava
-dontwarn sun.net.spi.nameservice.** # not available on Android
+1
View File
@@ -0,0 +1 @@
espressoTest
@@ -0,0 +1,128 @@
/*
* Copyright © Ricki Hirner (bitfire web engineering).
* All rights reserved. This program and the accompanying materials
* are made available under the terms of the GNU Public License v3.0
* which accompanies this distribution, and is available at
* http://www.gnu.org/licenses/gpl.html
*/
package at.bitfire.davdroid
import android.support.test.InstrumentationRegistry.getInstrumentation
import at.bitfire.cert4android.CustomCertManager
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.mockwebserver.MockWebServer
import org.apache.commons.io.IOUtils
import org.apache.commons.lang3.ArrayUtils.contains
import org.junit.After
import org.junit.Assert.*
import org.junit.Before
import org.junit.Test
import java.net.Socket
import java.security.KeyFactory
import java.security.KeyStore
import java.security.Principal
import java.security.cert.CertificateFactory
import java.security.cert.X509Certificate
import java.security.spec.PKCS8EncodedKeySpec
import javax.net.ssl.SSLSocket
import javax.net.ssl.TrustManagerFactory
import javax.net.ssl.X509ExtendedKeyManager
import javax.net.ssl.X509TrustManager
class CustomTlsSocketFactoryTest {
private lateinit var certMgr: CustomCertManager
private lateinit var factory: CustomTlsSocketFactory
private val server = MockWebServer()
@Before
fun startServer() {
certMgr = CustomCertManager(getInstrumentation().context, false, true)
factory = CustomTlsSocketFactory(null, certMgr)
server.start()
}
@After
fun stopServer() {
server.shutdown()
certMgr.close()
}
@Test
fun testSendClientCertificate() {
var public: X509Certificate? = null
javaClass.classLoader.getResourceAsStream("sample.crt").use {
public = CertificateFactory.getInstance("X509").generateCertificate(it) as? X509Certificate
}
assertNotNull(public)
val keyFactory = KeyFactory.getInstance("RSA")
val private = keyFactory.generatePrivate(PKCS8EncodedKeySpec(readResource("sample.key")))
assertNotNull(private)
val keyStore = KeyStore.getInstance("AndroidKeyStore")
keyStore.load(null)
val alias = "sample"
keyStore.setKeyEntry(alias, private, null, arrayOf(public))
assertTrue(keyStore.containsAlias(alias))
val trustManagerFactory = TrustManagerFactory.getInstance("X509")
trustManagerFactory.init(null as KeyStore?)
val trustManager = trustManagerFactory.trustManagers.first() as X509TrustManager
val factory = CustomTlsSocketFactory(object: X509ExtendedKeyManager() {
override fun getServerAliases(p0: String?, p1: Array<out Principal>?): Array<String>? = null
override fun chooseServerAlias(p0: String?, p1: Array<out Principal>?, p2: Socket?) = null
override fun getClientAliases(p0: String?, p1: Array<out Principal>?) =
arrayOf(alias)
override fun chooseClientAlias(p0: Array<out String>?, p1: Array<out Principal>?, p2: Socket?) =
alias
override fun getCertificateChain(forAlias: String?) =
arrayOf(public).takeIf { forAlias == alias }
override fun getPrivateKey(forAlias: String?) =
private.takeIf { forAlias == alias }
}, trustManager)
/* known client cert test URLs (thanks!):
* - https://prod.idrix.eu/secure/
* - https://server.cryptomix.com/secure/
*/
val client = OkHttpClient.Builder()
.sslSocketFactory(factory, trustManager)
.build()
client.newCall(Request.Builder()
.get()
.url("https://prod.idrix.eu/secure/")
.build()).execute().use { response ->
assertTrue(response.isSuccessful)
assertTrue(response.body()!!.string().contains("CN=User Cert,O=Internet Widgits Pty Ltd,ST=Some-State,C=CA"))
}
}
@Test
fun testUpgradeTLS() {
val s = factory.createSocket(server.hostName, server.port)
assertTrue(s is SSLSocket)
val ssl = s as SSLSocket
assertFalse(contains(ssl.enabledProtocols, "SSLv3"))
assertTrue(contains(ssl.enabledProtocols, "TLSv1"))
assertTrue(contains(ssl.enabledProtocols, "TLSv1.1"))
assertTrue(contains(ssl.enabledProtocols, "TLSv1.2"))
}
private fun readResource(name: String): ByteArray {
this.javaClass.classLoader.getResourceAsStream(name).use {
return IOUtils.toByteArray(it)
}
}
}
@@ -1,63 +0,0 @@
/*
* Copyright © Ricki Hirner (bitfire web engineering).
* All rights reserved. This program and the accompanying materials
* are made available under the terms of the GNU Public License v3.0
* which accompanies this distribution, and is available at
* http://www.gnu.org/licenses/gpl.html
*/
package at.bitfire.davdroid;
import org.junit.After;
import org.junit.Before;
import org.junit.Test;
import java.io.IOException;
import java.net.Socket;
import javax.net.ssl.SSLSocket;
import javax.net.ssl.TrustManagerFactory;
import javax.net.ssl.X509TrustManager;
import at.bitfire.cert4android.CustomCertManager;
import okhttp3.mockwebserver.MockWebServer;
import static android.support.test.InstrumentationRegistry.getInstrumentation;
import static android.support.test.InstrumentationRegistry.getTargetContext;
import static junit.framework.TestCase.assertFalse;
import static org.apache.commons.lang3.ArrayUtils.contains;
import static org.junit.Assert.assertTrue;
public class SSLSocketFactoryCompatTest {
CustomCertManager certMgr;
SSLSocketFactoryCompat factory;
MockWebServer server = new MockWebServer();
@Before
public void startServer() throws Exception {
certMgr = new CustomCertManager(getInstrumentation().getContext(), false, true);
factory = new SSLSocketFactoryCompat(certMgr);
server.start();
}
@After
public void stopServer() throws Exception {
server.shutdown();
certMgr.close();
}
@Test
public void testUpgradeTLS() throws IOException {
Socket s = factory.createSocket(server.getHostName(), server.getPort());
assertTrue(s instanceof SSLSocket);
SSLSocket ssl = (SSLSocket)s;
assertFalse(contains(ssl.getEnabledProtocols(), "SSLv3"));
assertTrue(contains(ssl.getEnabledProtocols(), "TLSv1"));
assertTrue(contains(ssl.getEnabledProtocols(), "TLSv1.1"));
assertTrue(contains(ssl.getEnabledProtocols(), "TLSv1.2"));
}
}
@@ -1,133 +0,0 @@
/*
* Copyright © Ricki Hirner (bitfire web engineering).
* All rights reserved. This program and the accompanying materials
* are made available under the terms of the GNU Public License v3.0
* which accompanies this distribution, and is available at
* http://www.gnu.org/licenses/gpl.html
*/
package at.bitfire.davdroid.model;
import android.content.ContentValues;
import org.junit.After;
import org.junit.Before;
import org.junit.Test;
import java.io.IOException;
import at.bitfire.dav4android.DavResource;
import at.bitfire.dav4android.exception.DavException;
import at.bitfire.dav4android.exception.HttpException;
import at.bitfire.dav4android.property.ResourceType;
import at.bitfire.davdroid.HttpClient;
import at.bitfire.davdroid.model.ServiceDB.Collections;
import okhttp3.mockwebserver.MockResponse;
import okhttp3.mockwebserver.MockWebServer;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertNull;
import static org.junit.Assert.assertTrue;
public class CollectionInfoTest {
HttpClient httpClient;
MockWebServer server = new MockWebServer();
@Before
public void setUp() {
httpClient = new HttpClient.Builder().build();
}
@After
public void shutDown() {
httpClient.close();
}
@Test
public void testFromDavResource() throws IOException, HttpException, DavException {
// r/w address book
server.enqueue(new MockResponse()
.setResponseCode(207)
.setBody("<multistatus xmlns='DAV:' xmlns:CARD='urn:ietf:params:xml:ns:carddav'>" +
"<response>" +
" <href>/</href>" +
" <propstat><prop>" +
" <resourcetype><collection/><CARD:addressbook/></resourcetype>" +
" <displayname>My Contacts</displayname>" +
" <CARD:addressbook-description>My Contacts Description</CARD:addressbook-description>" +
" </prop></propstat>" +
"</response>" +
"</multistatus>"));
DavResource dav = new DavResource(httpClient.getOkHttpClient(), server.url("/"));
dav.propfind(0, ResourceType.NAME);
CollectionInfo info = new CollectionInfo(dav);
assertEquals(CollectionInfo.Type.ADDRESS_BOOK, info.getType());
assertFalse(info.getReadOnly());
assertEquals("My Contacts", info.getDisplayName());
assertEquals("My Contacts Description", info.getDescription());
// read-only calendar, no display name
server.enqueue(new MockResponse()
.setResponseCode(207)
.setBody("<multistatus xmlns='DAV:' xmlns:CAL='urn:ietf:params:xml:ns:caldav' xmlns:ICAL='http://apple.com/ns/ical/'>" +
"<response>" +
" <href>/</href>" +
" <propstat><prop>" +
" <resourcetype><collection/><CAL:calendar/></resourcetype>" +
" <current-user-privilege-set><privilege><read/></privilege></current-user-privilege-set>" +
" <CAL:calendar-description>My Calendar</CAL:calendar-description>" +
" <CAL:calendar-timezone>tzdata</CAL:calendar-timezone>" +
" <ICAL:calendar-color>#ff0000</ICAL:calendar-color>" +
" </prop></propstat>" +
"</response>" +
"</multistatus>"));
dav = new DavResource(httpClient.getOkHttpClient(), server.url("/"));
dav.propfind(0, ResourceType.NAME);
info = new CollectionInfo(dav);
assertEquals(CollectionInfo.Type.CALENDAR, info.getType());
assertTrue(info.getReadOnly());
assertNull(info.getDisplayName());
assertEquals("My Calendar", info.getDescription());
assertEquals(0xFFFF0000, (int)info.getColor());
assertEquals("tzdata", info.getTimeZone());
assertTrue(info.getSupportsVEVENT());
assertTrue(info.getSupportsVTODO());
}
@Test
public void testFromDB() {
ContentValues values = new ContentValues();
values.put(Collections.ID, 1);
values.put(Collections.SERVICE_ID, 1);
values.put(Collections.TYPE, CollectionInfo.Type.CALENDAR.name());
values.put(Collections.URL, "http://example.com");
values.put(Collections.READ_ONLY, 1);
values.put(Collections.DISPLAY_NAME, "display name");
values.put(Collections.DESCRIPTION, "description");
values.put(Collections.COLOR, 0xFFFF0000);
values.put(Collections.TIME_ZONE, "tzdata");
values.put(Collections.SUPPORTS_VEVENT, 1);
values.put(Collections.SUPPORTS_VTODO, 1);
values.put(Collections.SYNC, 1);
CollectionInfo info = new CollectionInfo(values);
assertEquals(CollectionInfo.Type.CALENDAR, info.getType());
assertEquals(1, (long)info.getId());
assertEquals(1, (long)info.getServiceID());
assertEquals("http://example.com", info.getUrl());
assertTrue(info.getReadOnly());
assertEquals("display name", info.getDisplayName());
assertEquals("description", info.getDescription());
assertEquals(0xFFFF0000, (int)info.getColor());
assertEquals("tzdata", info.getTimeZone());
assertTrue(info.getSupportsVEVENT());
assertTrue(info.getSupportsVTODO());
assertTrue(info.getSelected());
}
}
@@ -0,0 +1,128 @@
/*
* 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.support.test.filters.SmallTest
import at.bitfire.dav4android.DavResource
import at.bitfire.dav4android.property.ResourceType
import at.bitfire.davdroid.HttpClient
import at.bitfire.davdroid.model.ServiceDB.Collections
import okhttp3.HttpUrl
import okhttp3.mockwebserver.MockResponse
import okhttp3.mockwebserver.MockWebServer
import org.junit.After
import org.junit.Assert.*
import org.junit.Before
import org.junit.Test
class CollectionInfoTest {
private lateinit var httpClient: HttpClient
private val server = MockWebServer()
@Before
fun setUp() {
httpClient = HttpClient.Builder().build()
}
@After
fun shutDown() {
httpClient.close()
}
@Test
@SmallTest
fun testFromDavResource() {
// r/w address book
server.enqueue(MockResponse()
.setResponseCode(207)
.setBody("<multistatus xmlns='DAV:' xmlns:CARD='urn:ietf:params:xml:ns:carddav'>" +
"<response>" +
" <href>/</href>" +
" <propstat><prop>" +
" <resourcetype><collection/><CARD:addressbook/></resourcetype>" +
" <displayname>My Contacts</displayname>" +
" <CARD:addressbook-description>My Contacts Description</CARD:addressbook-description>" +
" </prop></propstat>" +
"</response>" +
"</multistatus>"))
DavResource(httpClient.okHttpClient, server.url("/"))
.propfind(0, ResourceType.NAME).use {
val info = CollectionInfo(it)
assertEquals(CollectionInfo.Type.ADDRESS_BOOK, info.type)
assertFalse(info.readOnly)
assertEquals("My Contacts", info.displayName)
assertEquals("My Contacts Description", info.description)
}
// read-only calendar, no display name
server.enqueue(MockResponse()
.setResponseCode(207)
.setBody("<multistatus xmlns='DAV:' xmlns:CAL='urn:ietf:params:xml:ns:caldav' xmlns:ICAL='http://apple.com/ns/ical/'>" +
"<response>" +
" <href>/</href>" +
" <propstat><prop>" +
" <resourcetype><collection/><CAL:calendar/></resourcetype>" +
" <current-user-privilege-set><privilege><read/></privilege></current-user-privilege-set>" +
" <CAL:calendar-description>My Calendar</CAL:calendar-description>" +
" <CAL:calendar-timezone>tzdata</CAL:calendar-timezone>" +
" <ICAL:calendar-color>#ff0000</ICAL:calendar-color>" +
" </prop></propstat>" +
"</response>" +
"</multistatus>"))
DavResource(httpClient.okHttpClient, server.url("/"))
.propfind(0, ResourceType.NAME).use {
val info = CollectionInfo(it)
assertEquals(CollectionInfo.Type.CALENDAR, info.type)
assertTrue(info.readOnly)
assertNull(info.displayName)
assertEquals("My Calendar", info.description)
assertEquals(0xFFFF0000.toInt(), info.color)
assertEquals("tzdata", info.timeZone)
assertTrue(info.supportsVEVENT)
assertTrue(info.supportsVTODO)
}
}
@Test
fun testFromDB() {
val values = ContentValues()
values.put(Collections.ID, 1)
values.put(Collections.SERVICE_ID, 1)
values.put(Collections.TYPE, CollectionInfo.Type.CALENDAR.name)
values.put(Collections.URL, "http://example.com")
values.put(Collections.READ_ONLY, 1)
values.put(Collections.DISPLAY_NAME, "display name")
values.put(Collections.DESCRIPTION, "description")
values.put(Collections.COLOR, 0xFFFF0000)
values.put(Collections.TIME_ZONE, "tzdata")
values.put(Collections.SUPPORTS_VEVENT, 1)
values.put(Collections.SUPPORTS_VTODO, 1)
values.put(Collections.SYNC, 1)
val info = CollectionInfo(values)
assertEquals(CollectionInfo.Type.CALENDAR, info.type)
assertEquals(1.toLong(), info.id)
assertEquals(1.toLong(), info.serviceID)
assertEquals(HttpUrl.parse("http://example.com/"), info.url)
assertTrue(info.readOnly)
assertEquals("display name", info.displayName)
assertEquals("description", info.description)
assertEquals(0xFFFF0000.toInt(), info.color)
assertEquals("tzdata", info.timeZone)
assertTrue(info.supportsVEVENT)
assertTrue(info.supportsVTODO)
assertTrue(info.selected)
}
}
@@ -8,11 +8,11 @@
package at.bitfire.davdroid.settings
import android.content.ServiceConnection
import android.support.test.InstrumentationRegistry
import android.support.test.InstrumentationRegistry.getTargetContext
import at.bitfire.davdroid.App
import junit.framework.Assert.*
import junit.framework.Assert.assertFalse
import junit.framework.Assert.assertTrue
import org.junit.After
import org.junit.Before
import org.junit.Test
@@ -1,200 +0,0 @@
/*
* Copyright © Ricki Hirner (bitfire web engineering).
* All rights reserved. This program and the accompanying materials
* are made available under the terms of the GNU Public License v3.0
* which accompanies this distribution, and is available at
* http://www.gnu.org/licenses/gpl.html
*/
package at.bitfire.davdroid.ui.setup;
import org.junit.After;
import org.junit.Before;
import org.junit.Test;
import java.io.IOException;
import java.net.URI;
import at.bitfire.dav4android.DavResource;
import at.bitfire.dav4android.exception.DavException;
import at.bitfire.dav4android.exception.HttpException;
import at.bitfire.dav4android.property.AddressbookHomeSet;
import at.bitfire.dav4android.property.ResourceType;
import at.bitfire.davdroid.HttpClient;
import at.bitfire.davdroid.log.Logger;
import at.bitfire.davdroid.ui.setup.DavResourceFinder.Configuration.ServiceInfo;
import okhttp3.mockwebserver.Dispatcher;
import okhttp3.mockwebserver.MockResponse;
import okhttp3.mockwebserver.MockWebServer;
import okhttp3.mockwebserver.RecordedRequest;
import static android.support.test.InstrumentationRegistry.getTargetContext;
import static junit.framework.TestCase.assertFalse;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertNull;
import static org.junit.Assert.assertTrue;
public class DavResourceFinderTest {
MockWebServer server = new MockWebServer();
DavResourceFinder finder;
HttpClient client;
LoginCredentials credentials;
private static final String
PATH_NO_DAV = "/nodav",
PATH_CALDAV = "/caldav",
PATH_CARDDAV = "/carddav",
PATH_CALDAV_AND_CARDDAV = "/both-caldav-carddav",
SUBPATH_PRINCIPAL = "/principal",
SUBPATH_ADDRESSBOOK_HOMESET = "/addressbooks",
SUBPATH_ADDRESSBOOK = "/addressbooks/private-contacts";
@Before
public void initServerAndClient() throws Exception {
server.setDispatcher(new TestDispatcher());
server.start();
credentials = new LoginCredentials(URI.create("/"), "mock", "12345");
finder = new DavResourceFinder(getTargetContext(), credentials);
client = new HttpClient.Builder()
.addAuthentication(null, credentials.getUserName(), credentials.getPassword())
.build();
}
@After
public void stopServer() throws Exception {
server.shutdown();
}
@Test
public void testRememberIfAddressBookOrHomeset() throws IOException, HttpException, DavException {
ServiceInfo info;
// before dav.propfind(), no info is available
DavResource dav = new DavResource(client.getOkHttpClient(), server.url(PATH_CARDDAV + SUBPATH_PRINCIPAL));
finder.rememberIfAddressBookOrHomeset(dav, info = new ServiceInfo());
assertEquals(0, info.getCollections().size());
assertEquals(0, info.getHomeSets().size());
// recognize home set
dav.propfind(0, AddressbookHomeSet.NAME);
finder.rememberIfAddressBookOrHomeset(dav, info = new ServiceInfo());
assertEquals(0, info.getCollections().size());
assertEquals(1, info.getHomeSets().size());
assertEquals(server.url(PATH_CARDDAV + SUBPATH_ADDRESSBOOK_HOMESET + "/").uri(), info.getHomeSets().iterator().next());
// recognize address book
dav = new DavResource(client.getOkHttpClient(), server.url(PATH_CARDDAV + SUBPATH_ADDRESSBOOK));
dav.propfind(0, ResourceType.NAME);
finder.rememberIfAddressBookOrHomeset(dav, info = new ServiceInfo());
assertEquals(1, info.getCollections().size());
assertEquals(server.url(PATH_CARDDAV + SUBPATH_ADDRESSBOOK + "/").uri(), info.getCollections().keySet().iterator().next());
assertEquals(0, info.getHomeSets().size());
}
@Test
public void testProvidesService() throws IOException {
assertFalse(finder.providesService(server.url(PATH_NO_DAV), DavResourceFinder.Service.CALDAV));
assertFalse(finder.providesService(server.url(PATH_NO_DAV), DavResourceFinder.Service.CARDDAV));
assertTrue(finder.providesService(server.url(PATH_CALDAV), DavResourceFinder.Service.CALDAV));
assertFalse(finder.providesService(server.url(PATH_CALDAV), DavResourceFinder.Service.CARDDAV));
assertTrue(finder.providesService(server.url(PATH_CARDDAV), DavResourceFinder.Service.CARDDAV));
assertFalse(finder.providesService(server.url(PATH_CARDDAV), DavResourceFinder.Service.CALDAV));
assertTrue(finder.providesService(server.url(PATH_CALDAV_AND_CARDDAV), DavResourceFinder.Service.CALDAV));
assertTrue(finder.providesService(server.url(PATH_CALDAV_AND_CARDDAV), DavResourceFinder.Service.CARDDAV));
}
@Test
public void testGetCurrentUserPrincipal() throws IOException, HttpException, DavException {
assertNull(finder.getCurrentUserPrincipal(server.url(PATH_NO_DAV), DavResourceFinder.Service.CALDAV));
assertNull(finder.getCurrentUserPrincipal(server.url(PATH_NO_DAV), DavResourceFinder.Service.CARDDAV));
assertEquals(
server.url(PATH_CALDAV + SUBPATH_PRINCIPAL).uri(),
finder.getCurrentUserPrincipal(server.url(PATH_CALDAV), DavResourceFinder.Service.CALDAV)
);
assertNull(finder.getCurrentUserPrincipal(server.url(PATH_CALDAV), DavResourceFinder.Service.CARDDAV));
assertEquals(
server.url(PATH_CARDDAV + SUBPATH_PRINCIPAL).uri(),
finder.getCurrentUserPrincipal(server.url(PATH_CARDDAV), DavResourceFinder.Service.CARDDAV)
);
assertNull(finder.getCurrentUserPrincipal(server.url(PATH_CARDDAV), DavResourceFinder.Service.CALDAV));
}
// mock server
public class TestDispatcher extends Dispatcher {
@Override
public MockResponse dispatch(RecordedRequest rq) throws InterruptedException {
if (!checkAuth(rq)) {
MockResponse authenticate = new MockResponse().setResponseCode(401);
authenticate.setHeader("WWW-Authenticate", "Basic realm=\"test\"");
return authenticate;
}
String path = rq.getPath();
if ("OPTIONS".equalsIgnoreCase(rq.getMethod())) {
String dav = null;
if (path.startsWith(PATH_CALDAV))
dav = "calendar-access";
else if (path.startsWith(PATH_CARDDAV))
dav = "addressbook";
else if (path.startsWith(PATH_CALDAV_AND_CARDDAV))
dav = "calendar-access, addressbook";
MockResponse response = new MockResponse().setResponseCode(200);
if (dav != null)
response.addHeader("DAV", dav);
return response;
} else if ("PROPFIND".equalsIgnoreCase(rq.getMethod())) {
String props = null;
switch (path) {
case PATH_CALDAV:
case PATH_CARDDAV:
props = "<current-user-principal><href>" + path + SUBPATH_PRINCIPAL + "</href></current-user-principal>";
break;
case PATH_CARDDAV + SUBPATH_PRINCIPAL:
props = "<CARD:addressbook-home-set>" +
" <href>" + PATH_CARDDAV + SUBPATH_ADDRESSBOOK_HOMESET + "</href>" +
"</CARD:addressbook-home-set>";
break;
case PATH_CARDDAV + SUBPATH_ADDRESSBOOK:
props = "<resourcetype>" +
" <collection/>" +
" <CARD:addressbook/>" +
"</resourcetype>";
break;
}
Logger.log.info("Sending props: " + props);
return new MockResponse()
.setResponseCode(207)
.setBody("<multistatus xmlns='DAV:' xmlns:CARD='urn:ietf:params:xml:ns:carddav'>" +
"<response>" +
" <href>" + rq.getPath() + "</href>" +
" <propstat><prop>" + props + "</prop></propstat>" +
"</response>" +
"</multistatus>");
}
return new MockResponse().setResponseCode(404);
}
private boolean checkAuth(RecordedRequest rq) {
return "Basic bW9jazoxMjM0NQ==".equals(rq.getHeader("Authorization"));
}
}
}
@@ -0,0 +1,191 @@
/*
* Copyright © Ricki Hirner (bitfire web engineering).
* All rights reserved. This program and the accompanying materials
* are made available under the terms of the GNU Public License v3.0
* which accompanies this distribution, and is available at
* http://www.gnu.org/licenses/gpl.html
*/
package at.bitfire.davdroid.ui.setup
import android.support.test.InstrumentationRegistry.getTargetContext
import android.support.test.filters.SmallTest
import at.bitfire.dav4android.DavResource
import at.bitfire.dav4android.property.AddressbookHomeSet
import at.bitfire.dav4android.property.ResourceType
import at.bitfire.davdroid.HttpClient
import at.bitfire.davdroid.log.Logger
import at.bitfire.davdroid.model.Credentials
import at.bitfire.davdroid.ui.setup.DavResourceFinder.Configuration.ServiceInfo
import okhttp3.mockwebserver.Dispatcher
import okhttp3.mockwebserver.MockResponse
import okhttp3.mockwebserver.MockWebServer
import okhttp3.mockwebserver.RecordedRequest
import org.junit.After
import org.junit.Assert.*
import org.junit.Before
import org.junit.Test
import java.net.URI
class DavResourceFinderTest {
companion object {
private const val PATH_NO_DAV = "/nodav"
private const val PATH_CALDAV = "/caldav"
private const val PATH_CARDDAV = "/carddav"
private const val PATH_CALDAV_AND_CARDDAV = "/both-caldav-carddav"
private const val SUBPATH_PRINCIPAL = "/principal"
private const val SUBPATH_ADDRESSBOOK_HOMESET = "/addressbooks"
private const val SUBPATH_ADDRESSBOOK = "/addressbooks/private-contacts"
}
val server = MockWebServer()
lateinit var finder: DavResourceFinder
lateinit var client: HttpClient
lateinit var loginInfo: LoginInfo
@Before
fun initServerAndClient() {
server.setDispatcher(TestDispatcher())
server.start()
loginInfo = LoginInfo(URI.create("/"), Credentials("mock", "12345"))
finder = DavResourceFinder(getTargetContext(), loginInfo)
client = HttpClient.Builder()
.addAuthentication(null, loginInfo.credentials)
.build()
}
@After
fun stopServer() {
server.shutdown()
}
@Test
@SmallTest
fun testRememberIfAddressBookOrHomeset() {
// recognize home set
DavResource(client.okHttpClient, server.url(PATH_CARDDAV + SUBPATH_PRINCIPAL))
.propfind(0, AddressbookHomeSet.NAME).use {
ServiceInfo().let { info ->
finder.rememberIfAddressBookOrHomeset(it, info)
assertEquals(0, info.collections.size)
assertEquals(1, info.homeSets.size)
assertEquals(server.url("$PATH_CARDDAV$SUBPATH_ADDRESSBOOK_HOMESET/"), info.homeSets.first())
}
}
// recognize address book
DavResource(client.okHttpClient, server.url(PATH_CARDDAV + SUBPATH_ADDRESSBOOK))
.propfind(0, ResourceType.NAME).use {
ServiceInfo().let { info ->
finder.rememberIfAddressBookOrHomeset(it, info)
assertEquals(1, info.collections.size)
assertEquals(server.url("$PATH_CARDDAV$SUBPATH_ADDRESSBOOK/"), info.collections.keys.first())
assertEquals(0, info.homeSets.size)
}
}
}
@Test
fun testProvidesService() {
assertFalse(finder.providesService(server.url(PATH_NO_DAV), DavResourceFinder.Service.CALDAV))
assertFalse(finder.providesService(server.url(PATH_NO_DAV), DavResourceFinder.Service.CARDDAV))
assertTrue(finder.providesService(server.url(PATH_CALDAV), DavResourceFinder.Service.CALDAV))
assertFalse(finder.providesService(server.url(PATH_CALDAV), DavResourceFinder.Service.CARDDAV))
assertTrue(finder.providesService(server.url(PATH_CARDDAV), DavResourceFinder.Service.CARDDAV))
assertFalse(finder.providesService(server.url(PATH_CARDDAV), DavResourceFinder.Service.CALDAV))
assertTrue(finder.providesService(server.url(PATH_CALDAV_AND_CARDDAV), DavResourceFinder.Service.CALDAV))
assertTrue(finder.providesService(server.url(PATH_CALDAV_AND_CARDDAV), DavResourceFinder.Service.CARDDAV))
}
@Test
fun testGetCurrentUserPrincipal() {
assertNull(finder.getCurrentUserPrincipal(server.url(PATH_NO_DAV), DavResourceFinder.Service.CALDAV))
assertNull(finder.getCurrentUserPrincipal(server.url(PATH_NO_DAV), DavResourceFinder.Service.CARDDAV))
assertEquals(
server.url(PATH_CALDAV + SUBPATH_PRINCIPAL),
finder.getCurrentUserPrincipal(server.url(PATH_CALDAV), DavResourceFinder.Service.CALDAV)
)
assertNull(finder.getCurrentUserPrincipal(server.url(PATH_CALDAV), DavResourceFinder.Service.CARDDAV))
assertEquals(
server.url(PATH_CARDDAV + SUBPATH_PRINCIPAL),
finder.getCurrentUserPrincipal(server.url(PATH_CARDDAV), DavResourceFinder.Service.CARDDAV)
)
assertNull(finder.getCurrentUserPrincipal(server.url(PATH_CARDDAV), DavResourceFinder.Service.CALDAV))
}
// mock server
class TestDispatcher: Dispatcher() {
override fun dispatch(rq: RecordedRequest): MockResponse {
if (!checkAuth(rq)) {
val authenticate = MockResponse().setResponseCode(401)
authenticate.setHeader("WWW-Authenticate", "Basic realm=\"test\"")
return authenticate
}
val path = rq.path
if (rq.method.equals("OPTIONS", true)) {
val dav = when {
path.startsWith(PATH_CALDAV) -> "calendar-access"
path.startsWith(PATH_CARDDAV) -> "addressbook"
path.startsWith(PATH_CALDAV_AND_CARDDAV) -> "calendar-access, addressbook"
else -> null
}
val response = MockResponse().setResponseCode(200)
if (dav != null)
response.addHeader("DAV", dav)
return response
} else if (rq.method.equals("PROPFIND", true)) {
val props: String?
when (path) {
PATH_CALDAV,
PATH_CARDDAV ->
props = "<current-user-principal><href>$path$SUBPATH_PRINCIPAL</href></current-user-principal>"
PATH_CARDDAV + SUBPATH_PRINCIPAL ->
props = "<CARD:addressbook-home-set>" +
" <href>$PATH_CARDDAV$SUBPATH_ADDRESSBOOK_HOMESET</href>" +
"</CARD:addressbook-home-set>"
PATH_CARDDAV + SUBPATH_ADDRESSBOOK ->
props = "<resourcetype>" +
" <collection/>" +
" <CARD:addressbook/>" +
"</resourcetype>"
else -> props = null
}
Logger.log.info("Sending props: $props")
return MockResponse()
.setResponseCode(207)
.setBody("<multistatus xmlns='DAV:' xmlns:CARD='urn:ietf:params:xml:ns:carddav'>" +
"<response>" +
" <href>${rq.path}</href>" +
" <propstat><prop>$props</prop></propstat>" +
"</response>" +
"</multistatus>")
}
return MockResponse().setResponseCode(404)
}
private fun checkAuth(rq: RecordedRequest) =
rq.getHeader("Authorization") == "Basic bW9jazoxMjM0NQ=="
}
}
+24
View File
@@ -0,0 +1,24 @@
-----BEGIN CERTIFICATE-----
MIIEEzCCAfsCAQEwDQYJKoZIhvcNAQEFBQAwRjELMAkGA1UEBhMCQ0ExEzARBgNV
BAgMClNvbWUtU3RhdGUxEDAOBgNVBAoMB0NBIENlcnQxEDAOBgNVBAMMB0NBIENl
cnQwHhcNMTgwMTEzMjAyOTI5WhcNMTkwMTEzMjAyOTI5WjBZMQswCQYDVQQGEwJD
QTETMBEGA1UECAwKU29tZS1TdGF0ZTEhMB8GA1UECgwYSW50ZXJuZXQgV2lkZ2l0
cyBQdHkgTHRkMRIwEAYDVQQDDAlVc2VyIENlcnQwggEiMA0GCSqGSIb3DQEBAQUA
A4IBDwAwggEKAoIBAQDqOyHAeG4psE/f6i/eTfwbhn6j7WaFXxZiSOWwpQZmzRrx
MrfkABJCk0X7KNgCaJcmBkG9G1Ri4HfKrxvJFswMXknlq+0ulGBk7oDnZM+pihuX
3D9VCWMMkCqYhLCGADj2zB2mkX4LpcMRi6XoOetKURE/vcIy7rSLAtJM6ZRdftfh
2ZxnautS1Tyujh9Au3NI/+Of80tT/nA+oBJQeT1fB/ga1OQlZP5kjSaA7IPiIbTz
QBO+r898MvqK/lwsvOYnWAp7TY03z+vPfCs0zjijZEl9Wrl0hW6o5db5kU1v5bcr
p87hxFJsGD2HIr2y6kvYfL2hn+h9iANyYdRnUgapAgMBAAEwDQYJKoZIhvcNAQEF
BQADggIBAHANsiJITedXPyp89lVMEmGY3zKtOqgQ3tqjvjlNt2sdPnj7wmZbmrNd
sa90S/UwOn8PzEFOVxYy1BPlljlEjtjmc4OHMcm4P4Zv36uawHilmK8V+zT59gCK
ftB5FP2TLFUFi2X9o8J06d0xJRE77uewN155NV4RmPuP4b/tMmeixoQppHqLqEr5
lgEUnt3Mh1ctmeFQFJR6lJ01hlB0gdpVHIhzrVLTO3uo8ePLJTmxP6tyKl/HXj9F
mpVsKb1kriKwbkGczfw99OUZeUVbTwQOR07r0SrG71B7IuDvxIORnhQc1OUjt7ob
wjdaZauAHxpGBRu+hw9Yqaxchk9Gldy1nEjGyyVCD0FU5taXbl8PhBWEDc4U9tI+
xVNmPpsSuCsbz3Mjd1YIVRGL99vLrKsQcj+TNM+jJKKRKes3ihl+l/0FwG6UuO7L
EvjlUg5hOtYi1D7xuYyMjroGBGh7swYMt6w4eCDbcjzcCkaCi0H2pScM/rLBpDjS
LIoGCvZ1LBdi933/iOj1/8dxGZwY6fEgcyiD2n0xAgYIniLWjEZXOMdIK5FNTNga
Tswanvp+6Noa4oIu/hl/LXvPMsouaWfSEbRe0Dshi3GpLj3YtEHoN9DHB8bn7jy5
34By81GT41m5kq3hWP//x9kSHYSADpbovCbKbElU1qSt6vTVR4nq
-----END CERTIFICATE-----
Binary file not shown.
+5 -19
View File
@@ -40,6 +40,10 @@
<uses-permission android:name="android.permission.READ_CALENDAR"/>
<uses-permission android:name="android.permission.WRITE_CALENDAR"/>
<!-- android.permission-group.LOCATION -->
<!-- required since Android 8.1 to get the WiFi name (for "sync in Wifi only" feature) -->
<uses-permission-sdk-23 android:name="android.permission.ACCESS_COARSE_LOCATION"/>
<!-- ical4android declares task access permissions -->
<application
@@ -49,26 +53,12 @@
android:networkSecurityConfig="@xml/network_security_config"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:theme="@style/AppTheme"
android:theme="@style/AppThemeExt"
tools:ignore="UnusedAttribute">
<service android:name=".DavService"/>
<service android:name=".settings.Settings"/>
<receiver android:name=".AccountsChangedReceiver">
<intent-filter>
<action android:name="android.accounts.LOGIN_ACCOUNTS_CHANGED"/>
</intent-filter>
</receiver>
<receiver android:name=".PackageChangedReceiver">
<intent-filter>
<action android:name="android.intent.action.PACKAGE_ADDED"/>
<action android:name="android.intent.action.PACKAGE_FULLY_REMOVED"/>
<data android:scheme="package"/>
</intent-filter>
</receiver>
<activity
android:name=".ui.AccountsActivity"
android:label="@string/app_name"
@@ -90,10 +80,6 @@
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"
-7
View File
@@ -1,7 +0,0 @@
<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>
@@ -9,10 +9,8 @@ package at.bitfire.davdroid
import android.accounts.Account
import android.accounts.AccountManager
import android.content.ContentResolver
import android.content.ContentValues
import android.content.Context
import android.content.Intent
import android.annotation.SuppressLint
import android.content.*
import android.os.Build
import android.os.Bundle
import android.os.Parcel
@@ -21,9 +19,11 @@ 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.Credentials
import at.bitfire.davdroid.model.ServiceDB
import at.bitfire.davdroid.model.ServiceDB.*
import at.bitfire.davdroid.model.ServiceDB.Collections
import at.bitfire.davdroid.model.SyncState
import at.bitfire.davdroid.resource.LocalAddressBook
import at.bitfire.davdroid.resource.LocalCalendar
import at.bitfire.davdroid.resource.LocalTaskList
@@ -31,14 +31,21 @@ import at.bitfire.davdroid.settings.ISettings
import at.bitfire.ical4android.AndroidCalendar
import at.bitfire.ical4android.AndroidTaskList
import at.bitfire.ical4android.CalendarStorageException
import at.bitfire.ical4android.TaskProvider
import at.bitfire.vcard4android.ContactsStorageException
import at.bitfire.vcard4android.GroupMethod
import okhttp3.HttpUrl
import org.apache.commons.lang3.StringUtils
import org.dmfs.tasks.contract.TaskContract
import java.util.*
import java.util.logging.Level
class AccountSettings @Throws(InvalidAccountException::class) constructor(
/**
* Manages settings of an account.
*
* @throws InvalidAccountException on construction when the account doesn't exist (anymore)
*/
class AccountSettings(
val context: Context,
val settings: ISettings,
val account: Account
@@ -46,45 +53,52 @@ class AccountSettings @Throws(InvalidAccountException::class) constructor(
companion object {
val CURRENT_VERSION = 7
val KEY_SETTINGS_VERSION = "version"
const val CURRENT_VERSION = 8
const val KEY_SETTINGS_VERSION = "version"
val KEY_USERNAME = "user_name"
const val KEY_USERNAME = "user_name"
const val KEY_CERTIFICATE_ALIAS = "certificate_alias"
val KEY_WIFI_ONLY = "wifi_only" // sync on WiFi only (default: false)
val KEY_WIFI_ONLY_SSIDS = "wifi_only_ssids" // restrict sync to specific WiFi SSIDs
const val KEY_WIFI_ONLY = "wifi_only" // sync on WiFi only (default: false)
const val KEY_WIFI_ONLY_SSIDS = "wifi_only_ssids" // restrict sync to specific WiFi SSIDs
/** Time range limitation to the past [in days]
value = null default value (DEFAULT_TIME_RANGE_PAST_DAYS)
< 0 (-1) no limit
>= 0 entries more than n days in the past won't be synchronized
*/
val KEY_TIME_RANGE_PAST_DAYS = "time_range_past_days"
val DEFAULT_TIME_RANGE_PAST_DAYS = 90
const val KEY_TIME_RANGE_PAST_DAYS = "time_range_past_days"
const val DEFAULT_TIME_RANGE_PAST_DAYS = 90
/* Whether DAVdroid sets the local calendar color to the value from service DB at every sync
value = null (not existing) true (default)
"0" false */
val KEY_MANAGE_CALENDAR_COLORS = "manage_calendar_colors"
const val KEY_MANAGE_CALENDAR_COLORS = "manage_calendar_colors"
/* Whether DAVdroid populates and uses CalendarContract.Colors
value = null (not existing) false (default)
"1" true */
val KEY_EVENT_COLORS = "event_colors"
const val KEY_EVENT_COLORS = "event_colors"
/** Contact group method:
value = null (not existing) groups as separate VCards (default)
"CATEGORIES" groups are per-contact CATEGORIES
*/
val KEY_CONTACT_GROUP_METHOD = "contact_group_method"
const val KEY_CONTACT_GROUP_METHOD = "contact_group_method"
@JvmField
val SYNC_INTERVAL_MANUALLY = -1L
const val SYNC_INTERVAL_MANUALLY = -1L
fun initialUserData(userName: String): Bundle {
fun initialUserData(credentials: Credentials): Bundle {
val bundle = Bundle(2)
bundle.putString(KEY_SETTINGS_VERSION, CURRENT_VERSION.toString())
bundle.putString(KEY_USERNAME, userName)
when (credentials.type) {
Credentials.Type.UsernamePassword ->
bundle.putString(KEY_USERNAME, credentials.userName)
Credentials.Type.ClientCertificate ->
bundle.putString(KEY_CERTIFICATE_ALIAS, credentials.certificateAlias)
}
return bundle
}
@@ -111,11 +125,17 @@ class AccountSettings @Throws(InvalidAccountException::class) constructor(
// authentication settings
fun username(): String? = accountManager.getUserData(account, KEY_USERNAME)
fun username(userName: String) = accountManager.setUserData(account, KEY_USERNAME, userName)
fun credentials() = Credentials(
accountManager.getUserData(account, KEY_USERNAME),
accountManager.getPassword(account),
accountManager.getUserData(account, KEY_CERTIFICATE_ALIAS)
)
fun password(): String? = accountManager.getPassword(account)
fun password(password: String) = accountManager.setPassword(account, password)
fun credentials(credentials: Credentials) {
accountManager.setUserData(account, KEY_USERNAME, credentials.userName)
accountManager.setPassword(account, credentials.password)
accountManager.setUserData(account, KEY_CERTIFICATE_ALIAS, credentials.certificateAlias)
}
// sync. settings
@@ -158,11 +178,11 @@ class AccountSettings @Throws(InvalidAccountException::class) constructor(
fun getTimeRangePastDays(): Int? {
val strDays = accountManager.getUserData(account, KEY_TIME_RANGE_PAST_DAYS)
if (strDays != null) {
return if (strDays != null) {
val days = Integer.valueOf(strDays)
return if (days < 0) null else days
if (days < 0) null else days
} else
return DEFAULT_TIME_RANGE_PAST_DAYS
DEFAULT_TIME_RANGE_PAST_DAYS
}
fun setTimeRangePastDays(days: Int?) =
@@ -210,6 +230,8 @@ class AccountSettings @Throws(InvalidAccountException::class) constructor(
try {
val updateProc = this::class.java.getDeclaredMethod("update_${fromVersion}_$toVersion")
updateProc.invoke(this)
Logger.log.info("Account version update successful")
accountManager.setUserData(account, KEY_SETTINGS_VERSION, toVersion.toString())
} catch (e: Exception) {
Logger.log.log(Level.SEVERE, "Couldn't update account settings", e)
@@ -217,6 +239,37 @@ class AccountSettings @Throws(InvalidAccountException::class) constructor(
}
}
@Suppress("unused")
/**
* There is a mistake in this method. [TaskContract.Tasks.SYNC_VERSION] is used to store the
* SEQUENCE and should not be used for the eTag.
*/
private fun update_7_8() {
TaskProvider.acquire(context, TaskProvider.ProviderName.OpenTasks)?.let { provider ->
// ETag is now in sync_version instead of sync1
// UID is now in _uid instead of sync2
provider.client.query(TaskProvider.syncAdapterUri(provider.tasksUri(), account),
arrayOf(TaskContract.Tasks._ID, TaskContract.Tasks.SYNC1, TaskContract.Tasks.SYNC2),
"${TaskContract.Tasks.ACCOUNT_TYPE}=? AND ${TaskContract.Tasks.ACCOUNT_NAME}=?",
arrayOf(account.type, account.name), null).use { cursor ->
while (cursor.moveToNext()) {
val id = cursor.getLong(0)
val eTag = cursor.getString(1)
val uid = cursor.getString(2)
val values = ContentValues(4)
values.put(TaskContract.Tasks._UID, uid)
values.put(TaskContract.Tasks.SYNC_VERSION, eTag)
values.putNull(TaskContract.Tasks.SYNC1)
values.putNull(TaskContract.Tasks.SYNC2)
Logger.log.log(Level.FINER, "Updating task $id", values)
provider.client.update(
TaskProvider.syncAdapterUri(ContentUris.withAppendedId(provider.tasksUri(), id), account),
values, null, null)
}
}
}
}
@Suppress("unused")
private fun update_6_7() {
// add calendar colors
@@ -238,6 +291,7 @@ class AccountSettings @Throws(InvalidAccountException::class) constructor(
}
@Suppress("unused")
@SuppressLint("ParcelClassLoader")
private fun update_5_6() {
context.contentResolver.acquireContentProviderClient(ContactsContract.AUTHORITY)?.let { provider ->
val parcel = Parcel.obtain()
@@ -255,7 +309,7 @@ class AccountSettings @Throws(InvalidAccountException::class) constructor(
parcel.unmarshall(raw, 0, raw.size)
parcel.setDataPosition(0)
val params = parcel.readBundle()
val url = params.getString("url")
val url = params.getString("url")?.let { HttpUrl.parse(it) }
if (url == null)
Logger.log.info("No address book URL, ignoring account")
else {
@@ -265,7 +319,7 @@ class AccountSettings @Throws(InvalidAccountException::class) constructor(
info.displayName = account.name
Logger.log.log(Level.INFO, "Creating new address book account", url)
val addressBookAccount = Account(LocalAddressBook.accountName(account, info), context.getString(R.string.account_type_address_book))
if (!accountManager.addAccountExplicitly(addressBookAccount, null, LocalAddressBook.initialUserData(account, info.url)))
if (!accountManager.addAccountExplicitly(addressBookAccount, null, LocalAddressBook.initialUserData(account, info.url.toString())))
throw ContactsStorageException("Couldn't create address book account")
// move contacts to new address book
@@ -332,7 +386,7 @@ class AccountSettings @Throws(InvalidAccountException::class) constructor(
context.contentResolver.acquireContentProviderClient(ContactsContract.AUTHORITY)?.let { client ->
try {
val addrBook = LocalAddressBook(context, account, client)
val url = addrBook.getURL()
val url = addrBook.url
Logger.log.fine("Migrating address book $url")
// insert CardDAV service
@@ -391,7 +445,7 @@ class AccountSettings @Throws(InvalidAccountException::class) constructor(
}
}
AndroidTaskList.acquireTaskProvider(context.contentResolver)?.use { provider ->
AndroidTaskList.acquireTaskProvider(context)?.use { provider ->
try {
val taskLists = AndroidTaskList.find(account, provider, LocalTaskList.Factory, null, null)
for (taskList in taskLists)
@@ -445,6 +499,7 @@ class AccountSettings @Throws(InvalidAccountException::class) constructor(
}
@Suppress("unused")
@SuppressLint("Recycle")
private fun update_1_2() {
/* - KEY_ADDRESSBOOK_URL ("addressbook_url"),
- KEY_ADDRESSBOOK_CTAG ("addressbook_ctag"),
@@ -462,16 +517,16 @@ class AccountSettings @Throws(InvalidAccountException::class) constructor(
// until now, ContactsContract.Settings.UNGROUPED_VISIBLE was not set explicitly
val values = ContentValues()
values.put(ContactsContract.Settings.UNGROUPED_VISIBLE, 1)
addr.updateSettings(values)
addr.settings = values
val url = accountManager.getUserData(account, "addressbook_url")
if (!url.isNullOrEmpty())
addr.setURL(url)
addr.url = url
accountManager.setUserData(account, "addressbook_url", null)
val cTag = accountManager.getUserData (account, "addressbook_ctag")
if (!cTag.isNullOrEmpty())
addr.setCTag(cTag)
addr.lastSyncState = SyncState(SyncState.Type.CTAG, cTag)
accountManager.setUserData(account, "addressbook_ctag", null)
} finally {
if (Build.VERSION.SDK_INT >= 24)
@@ -1,48 +0,0 @@
/*
* Copyright © Ricki Hirner (bitfire web engineering).
* All rights reserved. This program and the accompanying materials
* are made available under the terms of the GNU Public License v3.0
* which accompanies this distribution, and is available at
* http://www.gnu.org/licenses/gpl.html
*/
package at.bitfire.davdroid
import android.accounts.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)
}
}
}
+25 -7
View File
@@ -10,26 +10,29 @@ package at.bitfire.davdroid
import android.app.Application
import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import android.graphics.Bitmap
import android.graphics.drawable.BitmapDrawable
import android.os.Build
import android.support.v7.app.AppCompatDelegate
import at.bitfire.davdroid.log.Logger
import at.bitfire.davdroid.ui.NotificationUtils
import kotlin.concurrent.thread
class App: Application() {
companion object {
@JvmField val DISTRUST_SYSTEM_CERTIFICATES = "distrust_system_certs"
@JvmField val OVERRIDE_PROXY = "override_proxy"
@JvmField val OVERRIDE_PROXY_HOST = "override_proxy_host"
@JvmField val OVERRIDE_PROXY_PORT = "override_proxy_port"
const val DISTRUST_SYSTEM_CERTIFICATES = "distrust_system_certs"
const val OVERRIDE_PROXY = "override_proxy"
const val OVERRIDE_PROXY_HOST = "override_proxy_host"
const val OVERRIDE_PROXY_PORT = "override_proxy_port"
@JvmField val OVERRIDE_PROXY_HOST_DEFAULT = "localhost"
@JvmField val OVERRIDE_PROXY_PORT_DEFAULT = 8118
const val OVERRIDE_PROXY_HOST_DEFAULT = "localhost"
const val OVERRIDE_PROXY_PORT_DEFAULT = 8118
@JvmStatic
fun getLauncherBitmap(context: Context): Bitmap? {
val drawableLogo = if (android.os.Build.VERSION.SDK_INT >= 21)
context.getDrawable(R.mipmap.ic_launcher)
@@ -51,6 +54,21 @@ class App: Application() {
if (Build.VERSION.SDK_INT <= 21)
AppCompatDelegate.setCompatVectorFromResourcesEnabled(true)
NotificationUtils.createChannels(this)
// don't block UI for some background checks
thread {
// watch installed/removed apps
val tasksFilter = IntentFilter()
tasksFilter.addAction(Intent.ACTION_PACKAGE_ADDED)
tasksFilter.addAction(Intent.ACTION_PACKAGE_FULLY_REMOVED)
tasksFilter.addDataScheme("package")
registerReceiver(PackageChangedReceiver(), tasksFilter)
// check whether a tasks app is currently installed
PackageChangedReceiver.updateTaskSync(this)
}
}
}
@@ -5,19 +5,12 @@
* which accompanies this distribution, and is available at
* http://www.gnu.org/licenses/gpl.html
*/
package at.bitfire.davdroid;
package at.bitfire.davdroid
object Constants {
// notification IDs
@JvmField val NOTIFICATION_EXTERNAL_FILE_LOGGING = 1
@JvmField val NOTIFICATION_REFRESH_COLLECTIONS = 2
@JvmField val NOTIFICATION_CONTACTS_SYNC = 10
@JvmField val NOTIFICATION_CALENDAR_SYNC = 11
@JvmField val NOTIFICATION_TASK_SYNC = 12
@JvmField val NOTIFICATION_PERMISSIONS = 20
const val DAVDROID_GREEN_RGBA = 0xFF8bc34a.toInt()
@JvmField
val DEFAULT_SYNC_INTERVAL = 4 * 3600L // 4 hours
const val DEFAULT_SYNC_INTERVAL = 4 * 3600L // 4 hours
}
@@ -15,12 +15,15 @@ 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
import javax.net.ssl.*
class SSLSocketFactoryCompat(
/**
* Custom TLS socket factory with support for
* - enabling/disabling algorithms depending on the Android version,
* - client certificate authentication
*/
class CustomTlsSocketFactory(
keyManager: KeyManager?,
trustManager: X509TrustManager
): SSLSocketFactory() {
@@ -54,7 +57,7 @@ class SSLSocketFactoryCompat(
protocols = _protocols.toTypedArray()
/* set up reasonable cipher suites */
val knownCiphers = arrayOf<String>(
val knownCiphers = arrayOf(
// TLS 1.2
"TLS_RSA_WITH_AES_256_GCM_SHA384",
"TLS_RSA_WITH_AES_128_GCM_SHA256",
@@ -105,7 +108,10 @@ class SSLSocketFactoryCompat(
init {
try {
val sslContext = SSLContext.getInstance("TLS")
sslContext.init(null, arrayOf(trustManager), null)
sslContext.init(
if (keyManager != null) arrayOf(keyManager) else null,
arrayOf(trustManager),
null)
delegate = sslContext.socketFactory
} catch (e: GeneralSecurityException) {
throw IllegalStateException() // system has no TLS
@@ -9,17 +9,19 @@
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.ContentResolver
import android.content.ContentValues
import android.content.Intent
import android.database.DatabaseUtils
import android.database.sqlite.SQLiteDatabase
import android.os.Binder
import android.os.Bundle
import android.support.v4.app.NotificationCompat
import android.support.v4.app.NotificationManagerCompat
import at.bitfire.dav4android.DavResource
import at.bitfire.dav4android.DavResponse
import at.bitfire.dav4android.UrlUtils
import at.bitfire.dav4android.exception.DavException
import at.bitfire.dav4android.exception.HttpException
@@ -28,11 +30,9 @@ 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.settings.Settings
import at.bitfire.davdroid.ui.DebugInfoActivity
import at.bitfire.davdroid.ui.NotificationUtils
import at.bitfire.vcard4android.ContactsStorageException
import okhttp3.HttpUrl
import org.apache.commons.collections4.iterators.IteratorChain
import org.apache.commons.collections4.iterators.SingletonIterator
@@ -45,9 +45,14 @@ 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"
const val ACTION_REFRESH_COLLECTIONS = "refreshCollections"
const val EXTRA_DAV_SERVICE_ID = "davServiceID"
/** Initialize a forced synchronization. Expects intent data
to be an URI of this format:
contents://<authority>/<account.type>/<account name>
**/
const val ACTION_FORCE_SYNC = "forceSync"
}
private val runningRefresh = HashSet<Long>()
@@ -59,13 +64,20 @@ class DavService: Service() {
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) }
}
ACTION_FORCE_SYNC -> {
val authority = intent.data.authority
val account = Account(
intent.data.pathSegments[1],
intent.data.pathSegments[0]
)
forceSync(authority, account)
}
}
}
@@ -110,42 +122,15 @@ class DavService: Service() {
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(getString(R.string.account_type_address_book))
.map { LocalAddressBook(this, it, null) }
.forEach {
try {
if (!accountNames.contains(it.getMainAccount().name))
it.delete()
} catch(e: ContactsStorageException) {
Logger.log.log(Level.SEVERE, "Couldn't get address book main account", e)
}
}
// delete orphaned services in DB
if (sqlAccountNames.isEmpty())
db.delete(Services._TABLE, null, null)
else
db.delete(Services._TABLE, "${Services.ACCOUNT_NAME} NOT IN (${sqlAccountNames.joinToString(",")})", null)
}
private fun forceSync(authority: String, account: Account) {
Logger.log.info("Forcing $authority synchronization of $account")
val extras = Bundle(2)
extras.putBoolean(ContentResolver.SYNC_EXTRAS_MANUAL, true) // manual sync
extras.putBoolean(ContentResolver.SYNC_EXTRAS_EXPEDITED, true) // run immediately (don't queue)
ContentResolver.requestSync(account, authority, extras)
}
fun refreshCollections(service: Long) {
private fun refreshCollections(service: Long) {
OpenHelper(this@DavService).use { dbHelper ->
val db = dbHelper.writableDatabase
@@ -197,23 +182,63 @@ class DavService: Service() {
/**
* Checks if the given URL defines home sets and adds them to the home set list.
* @param dav DavResource to check
* @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) }
fun queryHomeSets(dav: DavResource, recurse: Boolean = true) {
var response: DavResponse? = null
try {
when (serviceType) {
Services.SERVICE_CARDDAV -> {
response = dav.propfind(0, AddressbookHomeSet.NAME, GroupMembership.NAME)
for ((resource, addressbookHomeSet) in response.searchProperties(AddressbookHomeSet::class.java))
for (href in addressbookHomeSet.hrefs)
resource.url.resolve(href)?.let { homeSets += UrlUtils.withTrailingSlash(it) }
}
Services.SERVICE_CALDAV -> {
response = dav.propfind(0, CalendarHomeSet.NAME, CalendarProxyReadFor.NAME, CalendarProxyWriteFor.NAME, GroupMembership.NAME)
for ((resource, calendarHomeSet) in response.searchProperties(CalendarHomeSet::class.java))
for (href in calendarHomeSet.hrefs)
resource.url.resolve(href)?.let { homeSets.add(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)) }
response = requireNotNull(response)
if (recurse) {
// refresh home sets: calendar-proxy-read/write-for
for ((resource, proxyRead) in response.searchProperties(CalendarProxyReadFor::class.java))
for (href in proxyRead.hrefs) {
Logger.log.fine("Principal is a read-only proxy for $href, checking for home sets")
resource.url.resolve(href)?.let {
queryHomeSets(DavResource(dav.httpClient, it), false)
}
}
for ((resource, proxyWrite) in response.searchProperties(CalendarProxyWriteFor::class.java))
for (href in proxyWrite.hrefs) {
Logger.log.fine("Principal is a read/write proxy for $href, checking for home sets")
resource.url.resolve(href)?.let {
queryHomeSets(DavResource(dav.httpClient, it), false)
}
}
// refresh home sets: direct group memberships
response[GroupMembership::class.java]?.let { groupMembership ->
for (href in groupMembership.hrefs) {
Logger.log.fine("Principal is member of group $href, checking for home sets")
response.url.resolve(href)?.let { url ->
try {
queryHomeSets(DavResource(dav.httpClient, url), false)
} 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)
}
}
}
}
}
} finally {
response?.close()
}
}
@@ -251,43 +276,14 @@ class DavService: Service() {
// 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)
}
}
}
}
queryHomeSets(DavResource(httpClient, principalUrl))
}
// remember selected collections
val selectedCollections = HashSet<HttpUrl>()
collections.values
.filter { it.selected }
.forEach { (url,_) -> HttpUrl.parse(url)?.let { selectedCollections.add(it) } }
.forEach { (url,_) -> selectedCollections.add(url) }
// now refresh collections (taken from home sets)
val itHomeSets = homeSets.iterator()
@@ -297,20 +293,21 @@ class DavService: Service() {
val homeSet = DavResource(httpClient, homeSetUrl)
try {
homeSet.propfind(1, *CollectionInfo.DAV_PROPERTIES)
val itCollections = IteratorChain<DavResource>(homeSet.members.iterator(), homeSet.related.iterator(), SingletonIterator(homeSet))
while (itCollections.hasNext()) {
val member = itCollections.next()
val info = CollectionInfo(member)
info.confirmed = true
Logger.log.log(Level.FINE, "Found collection", info)
homeSet.propfind(1, *CollectionInfo.DAV_PROPERTIES).use { response ->
val itCollections = IteratorChain<DavResponse>(response.members.iterator(), response.related.iterator(), SingletonIterator(response))
while (itCollections.hasNext()) {
val member = itCollections.next()
val info = CollectionInfo(member)
info.confirmed = true
Logger.log.log(Level.FINE, "Found collection", info)
if ((serviceType == Services.SERVICE_CARDDAV && info.type == CollectionInfo.Type.ADDRESS_BOOK) ||
(serviceType == Services.SERVICE_CALDAV && arrayOf(CollectionInfo.Type.CALENDAR, CollectionInfo.Type.WEBCAL).contains(info.type)))
collections[member.location] = info
if ((serviceType == Services.SERVICE_CARDDAV && info.type == CollectionInfo.Type.ADDRESS_BOOK) ||
(serviceType == Services.SERVICE_CALDAV && arrayOf(CollectionInfo.Type.CALENDAR, CollectionInfo.Type.WEBCAL).contains(info.type)))
collections[member.url] = info
}
}
} catch(e: HttpException) {
if (e.status in arrayOf(403, 404, 410))
if (e.code in arrayOf(403, 404, 410))
// delete home set only if it was not accessible (40x)
itHomeSets.remove()
}
@@ -323,17 +320,18 @@ class DavService: Service() {
if (!info.confirmed)
try {
val collection = DavResource(httpClient, url)
collection.propfind(0, *CollectionInfo.DAV_PROPERTIES)
val info = CollectionInfo(collection)
info.confirmed = true
collection.propfind(0, *CollectionInfo.DAV_PROPERTIES).use { response ->
val info = CollectionInfo(response)
info.confirmed = true
// remove unusable collections
if ((serviceType == Services.SERVICE_CARDDAV && info.type != CollectionInfo.Type.ADDRESS_BOOK) ||
(serviceType == Services.SERVICE_CALDAV && !arrayOf(CollectionInfo.Type.CALENDAR, CollectionInfo.Type.WEBCAL).contains(info.type)) ||
(info.type == CollectionInfo.Type.WEBCAL && info.source == null))
itCollections.remove()
// remove unusable collections
if ((serviceType == Services.SERVICE_CARDDAV && info.type != CollectionInfo.Type.ADDRESS_BOOK) ||
(serviceType == Services.SERVICE_CALDAV && !arrayOf(CollectionInfo.Type.CALENDAR, CollectionInfo.Type.WEBCAL).contains(info.type)) ||
(info.type == CollectionInfo.Type.WEBCAL && info.source == null))
itCollections.remove()
}
} catch(e: HttpException) {
if (e.status in arrayOf(403, 404, 410))
if (e.code in arrayOf(403, 404, 410))
// delete collection only if it was not accessible (40x)
itCollections.remove()
else
@@ -362,19 +360,20 @@ class DavService: Service() {
} catch(e: Exception) {
Logger.log.log(Level.SEVERE, "Couldn't refresh collection list", e)
val debugIntent = Intent(this@DavService, DebugInfoActivity::class.java)
val debugIntent = Intent(this, DebugInfoActivity::class.java)
debugIntent.putExtra(DebugInfoActivity.KEY_THROWABLE, e)
debugIntent.putExtra(DebugInfoActivity.KEY_ACCOUNT, account)
val nm = NotificationUtils.createChannels(this)
val notify = NotificationCompat.Builder(this, NotificationUtils.CHANNEL_SYNC_PROBLEMS)
val notify = NotificationUtils.newBuilder(this)
.setSmallIcon(R.drawable.ic_sync_error_notification)
.setLargeIcon(App.getLauncherBitmap(this))
.setContentTitle(getString(R.string.dav_service_refresh_failed))
.setContentText(getString(R.string.dav_service_refresh_couldnt_refresh))
.setContentIntent(PendingIntent.getActivity(this, 0, debugIntent, PendingIntent.FLAG_UPDATE_CURRENT))
.setSubText(account.name)
.setCategory(NotificationCompat.CATEGORY_ERROR)
.build()
nm.notify(Constants.NOTIFICATION_REFRESH_COLLECTIONS, notify)
NotificationManagerCompat.from(this)
.notify(service.toString(), NotificationUtils.NOTIFY_REFRESH_COLLECTIONS, notify)
} finally {
runningRefresh.remove(service)
refreshingStatusListeners.forEach { it.get()?.onDavRefreshStatusChanged(service, false) }
@@ -6,37 +6,54 @@
* http://www.gnu.org/licenses/gpl.html
*/
package at.bitfire.davdroid;
package at.bitfire.davdroid
import android.annotation.TargetApi
import android.content.Context
import android.net.ConnectivityManager
import android.os.Build
import at.bitfire.davdroid.log.Logger
import okhttp3.HttpUrl
import org.xbill.DNS.Record
import org.xbill.DNS.SRVRecord
import org.xbill.DNS.TXTRecord
import org.xbill.DNS.*
import java.util.*
/**
* Some WebDAV and related network utility methods
*/
object DavUtils {
@JvmStatic
fun ARGBtoCalDAVColor(colorWithAlpha: Int): String {
val alpha = (colorWithAlpha shr 24) and 0xFF
val color = colorWithAlpha and 0xFFFFFF
return String.format("#%06X%02X", color, alpha)
}
@JvmStatic
fun lastSegmentOfUrl(url: String): String {
val httpUrl = HttpUrl.parse(url) ?: throw IllegalArgumentException("url not parsable")
fun lastSegmentOfUrl(url: HttpUrl): String {
// the list returned by HttpUrl.pathSegments() is unmodifiable, so we have to create a copy
val segments = LinkedList<String>(httpUrl.pathSegments())
Collections.reverse(segments)
val segments = LinkedList<String>(url.pathSegments())
segments.reverse()
for (segment in segments)
if (segment.isNotEmpty())
return segment
return segments.firstOrNull { it.isNotEmpty() } ?: "/"
}
return "/"
fun prepareLookup(context: Context, lookup: Lookup) {
@TargetApi(Build.VERSION_CODES.O)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
/* Since Android 8, the system properties net.dns1, net.dns2, ... are not available anymore.
The current version of dnsjava relies on these properties to find the default name servers,
so we have to add the servers explicitly (fortunately, there's an Android API to
get the active DNS servers). */
val connectivity = context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
val activeLink = connectivity.getLinkProperties(connectivity.activeNetwork)
val simpleResolvers = activeLink.dnsServers.map {
Logger.log.fine("Using DNS server ${it.hostAddress}")
val resolver = SimpleResolver()
resolver.setAddress(it)
resolver
}
val resolver = ExtendedResolver(simpleResolvers.toTypedArray())
lookup.setResolver(resolver)
}
}
fun selectSRVRecord(records: Array<Record>?): SRVRecord? {
@@ -6,14 +6,17 @@
* http://www.gnu.org/licenses/gpl.html
*/
package at.bitfire.davdroid;
package at.bitfire.davdroid
import android.content.Context
import android.os.Build
import android.security.KeyChain
import at.bitfire.cert4android.CustomCertManager
import at.bitfire.dav4android.BasicDigestAuthHandler
import at.bitfire.dav4android.Constants
import at.bitfire.dav4android.UrlUtils
import at.bitfire.davdroid.log.Logger
import at.bitfire.davdroid.model.Credentials
import at.bitfire.davdroid.settings.ISettings
import okhttp3.Cache
import okhttp3.Interceptor
@@ -21,45 +24,59 @@ import okhttp3.OkHttpClient
import okhttp3.Response
import okhttp3.internal.tls.OkHostnameVerifier
import okhttp3.logging.HttpLoggingInterceptor
import java.io.Closeable
import java.io.File
import java.net.InetSocketAddress
import java.net.Proxy
import java.net.Socket
import java.security.KeyStore
import java.security.Principal
import java.text.SimpleDateFormat
import java.util.*
import java.util.concurrent.TimeUnit
import java.util.logging.Level
import javax.net.ssl.KeyManager
import javax.net.ssl.TrustManagerFactory
import javax.net.ssl.X509ExtendedKeyManager
import javax.net.ssl.X509TrustManager
class HttpClient private constructor(
val okHttpClient: OkHttpClient,
private val certManager: CustomCertManager?
): Closeable {
): AutoCloseable {
companion object {
/** [OkHttpClient] singleton to build all clients from */
val sharedClient = OkHttpClient.Builder()
// set timeouts
.connectTimeout(30, TimeUnit.SECONDS)
.writeTimeout(30, TimeUnit.SECONDS)
.readTimeout(120, TimeUnit.SECONDS)
// don't allow redirects by default, because it would break PROPFIND handling
.followRedirects(false)
// add User-Agent to every request
.addNetworkInterceptor(UserAgentInterceptor)
.build()!!
}
override fun close() {
certManager?.close()
}
class Builder @JvmOverloads constructor(
class Builder(
val context: Context? = null,
val settings: ISettings? = null,
accountSettings: AccountSettings? = null,
logger: java.util.logging.Logger = Logger.log
val logger: java.util.logging.Logger = Logger.log
) {
var certManager: CustomCertManager? = null
private val orig = OkHttpClient.Builder()
private var certManager: CustomCertManager? = null
private var certificateAlias: String? = null
private val orig = sharedClient.newBuilder()
init {
// set timeouts
orig.connectTimeout(30, TimeUnit.SECONDS)
orig.writeTimeout(30, TimeUnit.SECONDS)
orig.readTimeout(120, TimeUnit.SECONDS)
// don't allow redirects by default, because it would break PROPFIND handling
orig.followRedirects(false)
// add User-Agent to every request
orig.addNetworkInterceptor(UserAgentInterceptor)
// add cookie store for non-persistent cookies (some services like Horde use cookies for session tracking)
orig.cookieJar(MemoryCookieStore())
@@ -91,21 +108,18 @@ class HttpClient private constructor(
context?.let {
if (BuildConfig.customCerts)
customCertManager(CustomCertManager(context, BuildConfig.customCertsUI, !settings.getBoolean(App.DISTRUST_SYSTEM_CERTIFICATES, false)))
customCertManager(CustomCertManager(context, true, !settings.getBoolean(App.DISTRUST_SYSTEM_CERTIFICATES, false)))
// use account settings for authentication
accountSettings?.let {
val userName = accountSettings.username()
val password = accountSettings.password()
if (userName != null && password != null)
addAuthentication(null, userName, password)
addAuthentication(null, it.credentials())
}
}
}
}
constructor(context: Context, host: String?, username: String, password: String): this(context) {
addAuthentication(host, username, password)
constructor(context: Context, host: String?, credentials: Credentials): this(context) {
addAuthentication(host, credentials)
}
fun withDiskCache(): Builder {
@@ -129,29 +143,85 @@ class HttpClient private constructor(
fun customCertManager(manager: CustomCertManager) {
certManager = manager
orig.sslSocketFactory(SSLSocketFactoryCompat(manager), manager)
orig.hostnameVerifier(manager.hostnameVerifier(OkHostnameVerifier.INSTANCE))
}
fun setForeground(foreground: Boolean): Builder {
certManager?.appInForeground = foreground
return this
}
fun addAuthentication(host: String?, username: String, password: String): Builder {
val authHandler = BasicDigestAuthHandler(UrlUtils.hostToDomain(host), username, password)
orig .addNetworkInterceptor(authHandler)
.authenticator(authHandler)
fun addAuthentication(host: String?, credentials: Credentials): Builder {
when (credentials.type) {
Credentials.Type.UsernamePassword -> {
val authHandler = BasicDigestAuthHandler(UrlUtils.hostToDomain(host), credentials.userName!!, credentials.password!!)
orig .addNetworkInterceptor(authHandler)
.authenticator(authHandler)
}
Credentials.Type.ClientCertificate -> {
certificateAlias = credentials.certificateAlias
}
}
return this
}
fun build() = HttpClient(orig.build(), certManager)
fun build(): HttpClient {
val trustManager = certManager ?: {
val factory = TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm())
factory.init(null as KeyStore?)
factory.trustManagers.first() as X509TrustManager
}()
val hostnameVerifier = certManager?.hostnameVerifier(OkHostnameVerifier.INSTANCE)
?: OkHostnameVerifier.INSTANCE
var keyManager: KeyManager? = null
try {
certificateAlias?.let { alias ->
// get client certificate and private key
val certs = KeyChain.getCertificateChain(context, alias) ?: return@let
val key = KeyChain.getPrivateKey(context, alias) ?: return@let
logger.fine("Using client certificate $alias for authentication (chain length: ${certs.size})")
// create Android KeyStore (performs key operations without revealing secret data to DAVdroid)
val keyStore = KeyStore.getInstance("AndroidKeyStore")
keyStore.load(null)
// create KeyManager
keyManager = object: X509ExtendedKeyManager() {
override fun getServerAliases(p0: String?, p1: Array<out Principal>?): Array<String>? = null
override fun chooseServerAlias(p0: String?, p1: Array<out Principal>?, p2: Socket?) = null
override fun getClientAliases(p0: String?, p1: Array<out Principal>?) =
arrayOf(alias)
override fun chooseClientAlias(p0: Array<out String>?, p1: Array<out Principal>?, p2: Socket?) =
alias
override fun getCertificateChain(forAlias: String?) =
certs.takeIf { forAlias == alias }
override fun getPrivateKey(forAlias: String?) =
key.takeIf { forAlias == alias }
}
}
} catch (e: Exception) {
logger.log(Level.SEVERE, "Couldn't set up client certificate authentication", e)
}
orig.sslSocketFactory(CustomTlsSocketFactory(keyManager, trustManager), trustManager)
orig.hostnameVerifier(hostnameVerifier)
return HttpClient(orig.build(), certManager)
}
}
private object UserAgentInterceptor: Interceptor {
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}"
// use Locale.US because numbers may be encoded as non-ASCII characters in other locales
private val userAgentDateFormat = SimpleDateFormat("yyyy/MM/dd", Locale.US)
private val userAgentDate = userAgentDateFormat.format(Date(BuildConfig.buildTime))
private val userAgent = "DAVdroid/${BuildConfig.VERSION_NAME} ($userAgentDate; dav4android; okhttp/${Constants.okHttpVersion}) Android/${Build.VERSION.RELEASE}"
override fun intercept(chain: Interceptor.Chain): Response {
val locale = Locale.getDefault()
@@ -27,7 +27,7 @@ class MemoryCookieStore: CookieJar {
* This ensures that cookies can be overwritten. [RFC 6265 5.3 Storage Model]
* Not thread-safe!
*/
val storage = MultiKeyMap.multiKeyMap(HashedMap<MultiKey<out String>, Cookie>())!!
private val storage = MultiKeyMap.multiKeyMap(HashedMap<MultiKey<out String>, Cookie>())!!
override fun saveFromResponse(url: HttpUrl, cookies: List<Cookie>) {
synchronized(storage) {
@@ -32,7 +32,6 @@ class PackageChangedReceiver: BroadcastReceiver() {
companion object {
@JvmStatic
fun updateTaskSync(context: Context) {
val tasksInstalled = LocalTaskList.tasksProviderAvailable(context)
Logger.log.info("Package (un)installed; OpenTasks provider now available = $tasksInstalled")
@@ -50,7 +49,7 @@ class PackageChangedReceiver: BroadcastReceiver() {
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())
ContentResolver.addPeriodicSync(account, TaskProvider.ProviderName.OpenTasks.authority, Bundle(), Constants.DEFAULT_SYNC_INTERVAL)
}
} else
ContentResolver.setIsSyncable(account, TaskProvider.ProviderName.OpenTasks.authority, 0)
@@ -18,7 +18,7 @@ import java.util.logging.LogRecord
object LogcatHandler: Handler() {
private val MAX_LINE_LENGTH = 3000
private const val MAX_LINE_LENGTH = 3000
init {
formatter = PlainTextFormatter.LOGCAT
@@ -15,9 +15,8 @@ import android.content.SharedPreferences
import android.os.Process
import android.preference.PreferenceManager
import android.support.v4.app.NotificationCompat
import android.support.v4.app.NotificationManagerCompat
import android.util.Log
import at.bitfire.davdroid.App
import at.bitfire.davdroid.Constants
import at.bitfire.davdroid.R
import at.bitfire.davdroid.ui.AppSettingsActivity
import at.bitfire.davdroid.ui.NotificationUtils
@@ -29,13 +28,12 @@ import java.util.logging.Level
object Logger {
val LOG_TO_EXTERNAL_STORAGE = "log_to_external_storage"
const val LOG_TO_EXTERNAL_STORAGE = "log_to_external_storage"
@JvmField
val log = java.util.logging.Logger.getLogger("davdroid")!!
lateinit private var preferences: SharedPreferences
private lateinit var preferences: SharedPreferences
fun initialize(context: Context) {
preferences = PreferenceManager.getDefaultSharedPreferences(context)
@@ -62,12 +60,11 @@ object Logger {
rootLogger.handlers.forEach { rootLogger.removeHandler(it) }
rootLogger.addHandler(LogcatHandler)
val nm = NotificationUtils.createChannels(context)
val nm = NotificationManagerCompat.from(context)
// log to external file according to preferences
if (logToFile) {
val builder = NotificationCompat.Builder(context, NotificationUtils.CHANNEL_DEBUG)
val builder = NotificationUtils.newBuilder(context, NotificationUtils.CHANNEL_DEBUG)
builder .setSmallIcon(R.drawable.ic_sd_storage_notification)
.setLargeIcon(App.getLauncherBitmap(context))
.setContentTitle(context.getString(R.string.logging_davdroid_file_logging))
.setLocalOnly(true)
@@ -88,22 +85,22 @@ object Logger {
.setCategory(NotificationCompat.CATEGORY_STATUS)
.setPriority(NotificationCompat.PRIORITY_HIGH)
.setContentIntent(PendingIntent.getActivity(context, 0, prefIntent, PendingIntent.FLAG_UPDATE_CURRENT))
.setStyle(NotificationCompat.BigTextStyle()
.bigText(context.getString(R.string.logging_to_external_storage, dir.path)))
.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))
val message = context.getString(R.string.logging_couldnt_create_file, e.localizedMessage)
builder .setContentText(message)
.setStyle(NotificationCompat.BigTextStyle().bigText(message))
.setCategory(NotificationCompat.CATEGORY_ERROR)
}
else
builder.setContentText(context.getString(R.string.logging_no_external_storage))
nm.notify(Constants.NOTIFICATION_EXTERNAL_FILE_LOGGING, builder.build())
nm.notify(NotificationUtils.NOTIFY_EXTERNAL_FILE_LOGGING, builder.build())
} else
nm.cancel(Constants.NOTIFICATION_EXTERNAL_FILE_LOGGING)
nm.cancel(NotificationUtils.NOTIFY_EXTERNAL_FILE_LOGGING)
}
}
@@ -19,10 +19,10 @@ class PlainTextFormatter private constructor(
): Formatter() {
companion object {
@JvmField val LOGCAT = PlainTextFormatter(true)
@JvmField val DEFAULT = PlainTextFormatter(false)
val LOGCAT = PlainTextFormatter(true)
val DEFAULT = PlainTextFormatter(false)
val MAX_MESSAGE_LENGTH = 20000
const val MAX_MESSAGE_LENGTH = 20000
}
override fun format(r: LogRecord): String {
@@ -8,8 +8,8 @@
package at.bitfire.davdroid.log
import java.util.logging.Handler;
import java.util.logging.LogRecord;
import java.util.logging.Handler
import java.util.logging.LogRecord
class StringHandler: Handler() {
@@ -9,13 +9,25 @@
package at.bitfire.davdroid.model
import android.content.ContentValues
import at.bitfire.dav4android.DavResource
import android.os.Parcel
import android.os.Parcelable
import at.bitfire.dav4android.DavResponse
import at.bitfire.dav4android.UrlUtils
import at.bitfire.dav4android.property.*
import at.bitfire.davdroid.model.ServiceDB.Collections
import java.io.Serializable
import okhttp3.HttpUrl
data class CollectionInfo @JvmOverloads constructor(
val url: String,
/**
* Represents a WebDAV collection.
*
* @constructor always appends a trailing slash to the URL
*/
data class CollectionInfo(
/**
* URL of the collection (including trailing slash)
*/
val url: HttpUrl,
var id: Long? = null,
var serviceID: Long? = null,
@@ -38,7 +50,7 @@ data class CollectionInfo @JvmOverloads constructor(
// non-persistent properties
var confirmed: Boolean = false
): Serializable {
): Parcelable {
enum class Type {
ADDRESS_BOOK,
@@ -48,7 +60,6 @@ data class CollectionInfo @JvmOverloads constructor(
companion object {
@JvmField
val DAV_PROPERTIES = arrayOf(
ResourceType.NAME,
CurrentUserPrivilegeSet.NAME,
@@ -61,8 +72,8 @@ data class CollectionInfo @JvmOverloads constructor(
}
constructor(dav: DavResource): this(dav.location.toString()) {
(dav.properties[ResourceType.NAME] as ResourceType?)?.let { type ->
constructor(dav: DavResponse): this(UrlUtils.withTrailingSlash(dav.url)) {
dav[ResourceType::class.java]?.let { type ->
when {
type.types.contains(ResourceType.ADDRESSBOOK) -> this.type = Type.ADDRESS_BOOK
type.types.contains(ResourceType.CALENDAR) -> this.type = Type.CALENDAR
@@ -70,33 +81,33 @@ data class CollectionInfo @JvmOverloads constructor(
}
}
(dav.properties[CurrentUserPrivilegeSet.NAME] as CurrentUserPrivilegeSet?)?.let { privilegeSet ->
dav[CurrentUserPrivilegeSet::class.java]?.let { privilegeSet ->
readOnly = !privilegeSet.mayWriteContent
}
(dav.properties[DisplayName.NAME] as DisplayName?)?.let {
dav[DisplayName::class.java]?.let {
if (!it.displayName.isNullOrEmpty())
displayName = it.displayName
}
when (type) {
Type.ADDRESS_BOOK -> {
(dav.properties[AddressbookDescription.NAME] as AddressbookDescription?)?.let { description = it.description }
dav[AddressbookDescription::class.java]?.let { description = it.description }
}
Type.CALENDAR, Type.WEBCAL -> {
(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 }
dav[CalendarDescription::class.java]?.let { description = it.description }
dav[CalendarColor::class.java]?.let { color = it.color }
dav[CalendarTimezone::class.java]?.let { timeZone = it.vTimeZone }
if (type == Type.CALENDAR) {
supportsVEVENT = true
supportsVTODO = true
(dav.properties[SupportedCalendarComponentSet.NAME] as SupportedCalendarComponentSet?)?.let {
dav[SupportedCalendarComponentSet::class.java]?.let {
supportsVEVENT = it.supportsEvents
supportsVTODO = it.supportsTasks
}
} else { // Type.WEBCAL
(dav.properties[Source.NAME] as Source?)?.let { source = it.hrefs.firstOrNull() }
dav[Source::class.java]?.let { source = it.hrefs.firstOrNull() }
supportsVEVENT = true
}
}
@@ -104,7 +115,7 @@ data class CollectionInfo @JvmOverloads constructor(
}
constructor(values: ContentValues): this(values.getAsString(Collections.URL)) {
constructor(values: ContentValues): this(UrlUtils.withTrailingSlash(HttpUrl.parse(values.getAsString(Collections.URL))!!)) {
id = values.getAsLong(Collections.ID)
serviceID = values.getAsLong(Collections.SERVICE_ID)
type = try {
@@ -134,7 +145,7 @@ data class CollectionInfo @JvmOverloads constructor(
// Collections.SERVICE_ID is never changed
type?.let { values.put(Collections.TYPE, it.name) }
values.put(Collections.URL, url)
values.put(Collections.URL, url.toString())
values.put(Collections.READ_ONLY, if (readOnly) 1 else 0)
values.put(Collections.FORCE_READ_ONLY, if (forceReadOnly) 1 else 0)
values.put(Collections.DISPLAY_NAME, displayName)
@@ -160,4 +171,79 @@ data class CollectionInfo @JvmOverloads constructor(
(i != 0)
}
override fun describeContents(): Int = 0
override fun writeToParcel(dest: Parcel, flags: Int) {
fun<T> writeOrNull(value: T?, write: (T) -> Unit) {
if (value == null)
dest.writeByte(0)
else {
dest.writeByte(1)
write(value)
}
}
dest.writeString(url.toString())
writeOrNull(id, { dest.writeLong(it) })
writeOrNull(serviceID, { dest.writeLong(it) })
dest.writeString(type?.name)
dest.writeByte(if (readOnly) 1 else 0)
dest.writeByte(if (forceReadOnly) 1 else 0)
dest.writeString(displayName)
dest.writeString(description)
writeOrNull(color, { dest.writeInt(it) })
dest.writeString(timeZone)
dest.writeByte(if (supportsVEVENT) 1 else 0)
dest.writeByte(if (supportsVTODO) 1 else 0)
dest.writeByte(if (selected) 1 else 0)
dest.writeString(source)
dest.writeByte(if (confirmed) 1 else 0)
}
@Suppress("unused")
@JvmField
val CREATOR = object: Parcelable.Creator<CollectionInfo> {
override fun createFromParcel(parcel: Parcel): CollectionInfo {
fun<T> readOrNull(parcel: Parcel, read: () -> T): T? {
return if (parcel.readByte() == 0.toByte())
null
else
read()
}
return CollectionInfo(
HttpUrl.parse(parcel.readString())!!,
readOrNull(parcel, { parcel.readLong() }),
readOrNull(parcel, { parcel.readLong() }),
parcel.readString()?.let { Type.valueOf(it) },
parcel.readByte() != 0.toByte(),
parcel.readByte() != 0.toByte(),
parcel.readString(),
parcel.readString(),
readOrNull(parcel, { parcel.readInt() }),
parcel.readString(),
parcel.readByte() != 0.toByte(),
parcel.readByte() != 0.toByte(),
parcel.readByte() != 0.toByte(),
parcel.readString(),
parcel.readByte() != 0.toByte()
)
}
override fun newArray(size: Int) = arrayOfNulls<CollectionInfo>(size)
}
}
@@ -0,0 +1,40 @@
/*
* 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 java.io.Serializable
class Credentials(
val userName: String? = null,
val password: String? = null,
val certificateAlias: String? = null
): Serializable {
enum class Type {
UsernamePassword,
ClientCertificate
}
val type: Type
init {
type = when {
!certificateAlias.isNullOrEmpty() ->
Type.ClientCertificate
!userName.isNullOrEmpty() && !password.isNullOrEmpty() ->
Type.UsernamePassword
else ->
throw IllegalArgumentException("Either username/password or certificate alias must be set")
}
}
override fun toString() =
"Credentials(type=$type, userName=$userName, certificateAlias=$certificateAlias)"
}
@@ -17,57 +17,49 @@ import android.preference.PreferenceManager
import at.bitfire.davdroid.App
import at.bitfire.davdroid.log.Logger
import at.bitfire.davdroid.ui.StartupDialogFragment
import java.io.Closeable
import java.util.logging.Level
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"
const val _TABLE = "services"
const val ID = "_id"
const val ACCOUNT_NAME = "accountName"
const val SERVICE = "service"
const val PRINCIPAL = "principal"
// allowed values for SERVICE column
@JvmField val SERVICE_CALDAV = "caldav"
@JvmField val SERVICE_CARDDAV = "carddav"
const val SERVICE_CALDAV = "caldav"
const val SERVICE_CARDDAV = "carddav"
}
object HomeSets {
@JvmField val _TABLE = "homesets"
@JvmField val ID = "_id"
@JvmField val SERVICE_ID = "serviceID"
@JvmField val URL = "url"
const val _TABLE = "homesets"
const val ID = "_id"
const val SERVICE_ID = "serviceID"
const val URL = "url"
}
object Collections {
@JvmField val _TABLE = "collections"
@JvmField val ID = "_id"
@JvmField val TYPE = "type"
@JvmField val SERVICE_ID = "serviceID"
@JvmField val URL = "url"
@JvmField val READ_ONLY = "readOnly"
@JvmField val FORCE_READ_ONLY = "forceReadOnly"
@JvmField val DISPLAY_NAME = "displayName"
@JvmField val DESCRIPTION = "description"
@JvmField val COLOR = "color"
@JvmField val TIME_ZONE = "timezone"
@JvmField val SUPPORTS_VEVENT = "supportsVEVENT"
@JvmField val SUPPORTS_VTODO = "supportsVTODO"
@JvmField val SOURCE = "source"
@JvmField val SYNC = "sync"
const val _TABLE = "collections"
const val ID = "_id"
const val TYPE = "type"
const val SERVICE_ID = "serviceID"
const val URL = "url"
const val READ_ONLY = "readOnly"
const val FORCE_READ_ONLY = "forceReadOnly"
const val DISPLAY_NAME = "displayName"
const val DESCRIPTION = "description"
const val COLOR = "color"
const val TIME_ZONE = "timezone"
const val SUPPORTS_VEVENT = "supportsVEVENT"
const val SUPPORTS_VTODO = "supportsVTODO"
const val SOURCE = "source"
const val SYNC = "sync"
}
companion object {
@JvmStatic
fun onRenameAccount(db: SQLiteDatabase, oldName: String, newName: String) {
val values = ContentValues(1)
values.put(Services.ACCOUNT_NAME, newName)
@@ -79,11 +71,11 @@ class ServiceDB {
class OpenHelper(
val context: Context
): SQLiteOpenHelper(context, DATABASE_NAME, null, DATABASE_VERSION), Closeable {
): SQLiteOpenHelper(context, DATABASE_NAME, null, DATABASE_VERSION), AutoCloseable {
companion object {
val DATABASE_NAME = "services.db"
val DATABASE_VERSION = 4
const val DATABASE_NAME = "services.db"
const val DATABASE_VERSION = 4
}
override fun onConfigure(db: SQLiteDatabase) {
@@ -127,7 +119,7 @@ class ServiceDB {
override fun onUpgrade(db: SQLiteDatabase, oldVersion: Int, newVersion: Int) {
for (upgradeFrom in oldVersion until newVersion) {
val upgradeTo = oldVersion + 1
val upgradeTo = upgradeFrom + 1
Logger.log.info("Upgrading database from version $upgradeFrom to $upgradeTo")
try {
val upgradeProc = this::class.java.getDeclaredMethod("upgrade_${upgradeFrom}_$upgradeTo", SQLiteDatabase::class.java)
@@ -193,7 +185,7 @@ class ServiceDB {
// print columns
val cols = cursor.columnCount
sb.append("\t| ")
for (i in 0 .. cols-1)
for (i in 0 until cols)
sb .append(" ")
.append(cursor.getColumnName(i))
.append(" |")
@@ -202,7 +194,7 @@ class ServiceDB {
// print rows
while (cursor.moveToNext()) {
sb.append("\t| ")
for (i in 0 .. cols-1) {
for (i in 0 until cols) {
sb.append(" ")
try {
val value = cursor.getString(i)
@@ -228,4 +220,4 @@ class ServiceDB {
}
}
}
}
@@ -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.model
import at.bitfire.dav4android.property.SyncToken
import org.json.JSONException
import org.json.JSONObject
data class SyncState(
val type: Type,
val value: String,
/**
* Whether this sync state occurred during an initial sync as described
* in RFC 6578, which means the initial sync is not complete yet.
*/
var initialSync: Boolean? = null
) {
companion object {
private const val KEY_TYPE = "type"
private const val KEY_VALUE = "value"
private const val KEY_INITIAL_SYNC = "initialSync"
fun fromString(s: String?): SyncState? {
if (s == null)
return null
return try {
val json = JSONObject(s)
SyncState(
Type.valueOf(json.getString(KEY_TYPE)),
json.getString(KEY_VALUE),
try { json.getBoolean(KEY_INITIAL_SYNC) } catch(e: JSONException) { null }
)
} catch (e: JSONException) {
null
}
}
fun fromSyncToken(token: SyncToken) =
token.token?.let { SyncState(Type.SYNC_TOKEN, it) }
}
enum class Type { CTAG, SYNC_TOKEN }
override fun toString(): String {
val json = JSONObject()
json.put(KEY_TYPE, type.name)
json.put(KEY_VALUE, value)
initialSync?.let { json.put(KEY_INITIAL_SYNC, it) }
return json.toString()
}
}
@@ -6,23 +6,16 @@
* http://www.gnu.org/licenses/gpl.html
*/
package at.bitfire.davdroid.model;
package at.bitfire.davdroid.model
import android.provider.ContactsContract.RawContacts;
import android.provider.ContactsContract.RawContacts
object UnknownProperties {
@JvmField
val CONTENT_ITEM_TYPE = "x.davdroid/unknown-properties"
const 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
const val MIMETYPE = RawContacts.Data.MIMETYPE
const val RAW_CONTACT_ID = RawContacts.Data.RAW_CONTACT_ID
const val UNKNOWN_PROPERTIES = RawContacts.Data.DATA1
}
@@ -0,0 +1,17 @@
/*
* Copyright © Ricki Hirner (bitfire web engineering).
* All rights reserved. This program and the accompanying materials
* are made available under the terms of the GNU Public License v3.0
* which accompanies this distribution, and is available at
* http://www.gnu.org/licenses/gpl.html
*/
package at.bitfire.davdroid.resource
import at.bitfire.vcard4android.Contact
interface LocalAddress: LocalResource<Contact> {
fun resetDeleted()
}
@@ -9,6 +9,7 @@ package at.bitfire.davdroid.resource
import android.accounts.Account
import android.accounts.AccountManager
import android.annotation.TargetApi
import android.content.*
import android.os.Build
import android.os.Bundle
@@ -22,70 +23,66 @@ import at.bitfire.davdroid.DavUtils
import at.bitfire.davdroid.R
import at.bitfire.davdroid.log.Logger
import at.bitfire.davdroid.model.CollectionInfo
import at.bitfire.davdroid.model.SyncState
import at.bitfire.vcard4android.*
import java.io.ByteArrayOutputStream
import java.io.FileNotFoundException
import java.util.*
import java.util.logging.Level
/**
* A local address book. Requires an own Android account, because Android manages contacts per
* account and there is no such thing as "address books". So, DAVdroid creates a "DAVdroid
* address book" account for every CardDAV address book. These accounts are bound to a
* DAVdroid main account.
*/
class LocalAddressBook(
private val context: Context,
account: Account,
provider: ContentProviderClient?
): AndroidAddressBook<LocalContact, LocalGroup>(account, provider, LocalContact.Factory, LocalGroup.Factory), LocalCollection<LocalResource> {
): AndroidAddressBook<LocalContact, LocalGroup>(account, provider, LocalContact.Factory, LocalGroup.Factory), LocalCollection<LocalAddress> {
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_READ_ONLY = "read_only"
val USER_DATA_CTAG = "ctag"
const val USER_DATA_MAIN_ACCOUNT_TYPE = "real_account_type"
const val USER_DATA_MAIN_ACCOUNT_NAME = "real_account_name"
const val USER_DATA_URL = "url"
const val USER_DATA_READ_ONLY = "read_only"
@JvmStatic
@Throws(ContactsStorageException::class)
fun create(context: Context, provider: ContentProviderClient, mainAccount: Account, info: CollectionInfo): LocalAddressBook {
val accountManager = AccountManager.get(context)
val account = Account(accountName(mainAccount, info), context.getString(R.string.account_type_address_book))
if (!accountManager.addAccountExplicitly(account, null, initialUserData(mainAccount, info.url)))
throw ContactsStorageException("Couldn't create address book account")
if (!accountManager.addAccountExplicitly(account, null, initialUserData(mainAccount, info.url.toString())))
throw IllegalStateException("Couldn't create address book account")
val addressBook = LocalAddressBook(context, account, provider)
ContentResolver.setSyncAutomatically(account, ContactsContract.AUTHORITY, true)
// initialize Contacts Provider Settings
val values = ContentValues(2)
values.put(ContactsContract.Settings.SHOULD_SYNC, 1)
values.put(ContactsContract.Settings.UNGROUPED_VISIBLE, 1)
addressBook.settings = values
return addressBook
}
@JvmStatic
@Throws(ContactsStorageException::class)
fun find(context: Context, provider: ContentProviderClient, mainAccount: Account?): List<LocalAddressBook> {
val accountManager = AccountManager.get(context)
fun findAll(context: Context, provider: ContentProviderClient, mainAccount: Account?) = AccountManager.get(context)
.getAccountsByType(context.getString(R.string.account_type_address_book))
.map { LocalAddressBook(context, it, provider) }
.filter { mainAccount == null || it.mainAccount == mainAccount }
.toList()
val result = LinkedList<LocalAddressBook>()
accountManager.getAccountsByType(context.getString(R.string.account_type_address_book))
.map { LocalAddressBook(context, it, provider) }
.filter { mainAccount == null || it.getMainAccount() == mainAccount }
.forEach { result += it }
return result
}
@JvmStatic
fun accountName(mainAccount: Account, info: CollectionInfo): String {
val baos = ByteArrayOutputStream()
baos.write(info.url.hashCode())
val hash = Base64.encodeToString(baos.toByteArray(), Base64.NO_WRAP or Base64.NO_PADDING)
val sb = StringBuilder(if (info.displayName.isNullOrEmpty()) DavUtils.lastSegmentOfUrl(info.url) else info.displayName)
sb .append(" (")
.append(mainAccount.name)
.append(" ")
.append(hash)
.append(")")
sb.append(" (${mainAccount.name} $hash)")
return sb.toString()
}
@JvmStatic
fun initialUserData(mainAccount: Account, url: String): Bundle {
val bundle = Bundle(3)
bundle.putString(USER_DATA_MAIN_ACCOUNT_NAME, mainAccount.name)
@@ -94,21 +91,97 @@ class LocalAddressBook(
return bundle
}
fun mainAccount(context: Context, account: Account): Account =
if (account.type == context.getString(R.string.account_type_address_book)) {
val manager = AccountManager.get(context)
Account(
manager.getUserData(account, USER_DATA_MAIN_ACCOUNT_NAME),
manager.getUserData(account, USER_DATA_MAIN_ACCOUNT_TYPE)
)
} else
account
}
override val title = account.name!!
/**
* Whether contact groups (LocalGroup resources) are included in query results for
* {@link #getAll()}, {@link #getDeleted()}, {@link #getDirty()} and
* {@link #getWithoutFileName()}.
* Whether contact groups ([LocalGroup]) are included in query results
* and are affected by updates/deletes on generic members.
*
* For instance, if this option is disabled, [findDirty] will find only dirty [LocalContact]s,
* but if it is enabled, [findDirty] will find dirty [LocalContact]s and [LocalGroup]s.
*/
var includeGroups = true
private var _mainAccount: Account? = null
var mainAccount: Account
get() {
_mainAccount?.let { return it }
val accountManager = AccountManager.get(context)
val name = accountManager.getUserData(account, USER_DATA_MAIN_ACCOUNT_NAME)
val type = accountManager.getUserData(account, USER_DATA_MAIN_ACCOUNT_TYPE)
if (name != null && type != null)
return Account(name, type)
else
throw IllegalStateException("Address book doesn't exist anymore")
}
set(account) {
val accountManager = AccountManager.get(context)
accountManager.setUserData(account, USER_DATA_MAIN_ACCOUNT_NAME, account.name)
accountManager.setUserData(account, USER_DATA_MAIN_ACCOUNT_TYPE, account.type)
_mainAccount = account
}
var url: String
get() = AccountManager.get(context).getUserData(account, USER_DATA_URL)
?: throw IllegalStateException("Address book has no URL")
set(url) = AccountManager.get(context).setUserData(account, USER_DATA_URL, url)
var readOnly: Boolean
get() = AccountManager.get(context).getUserData(account, USER_DATA_READ_ONLY) != null
set(readOnly) = AccountManager.get(context).setUserData(account, USER_DATA_READ_ONLY, if (readOnly) "1" else null)
override var lastSyncState: SyncState?
get() = syncState?.let { SyncState.fromString(String(it)) }
set(state) {
syncState = state?.toString()?.toByteArray()
}
/* operations on the collection (address book) itself */
@Throws(ContactsStorageException::class)
override fun markNotDirty(flags: Int): Int {
val values = ContentValues(1)
values.put(LocalContact.COLUMN_FLAGS, flags)
var number = provider!!.update(rawContactsSyncUri(), values, "${RawContacts.DIRTY}=0", null)
if (includeGroups) {
values.clear()
values.put(LocalGroup.COLUMN_FLAGS, flags)
number += provider.update(groupsSyncUri(), values, "${Groups.DIRTY}=0", null)
}
return number
}
override fun removeNotDirtyMarked(flags: Int): Int {
var number = provider!!.delete(rawContactsSyncUri(),
"${RawContacts.DIRTY}=0 AND ${LocalContact.COLUMN_FLAGS}=?", arrayOf(flags.toString()))
if (includeGroups)
number += provider.delete(groupsSyncUri(),
"${Groups.DIRTY}=0 AND ${LocalGroup.COLUMN_FLAGS}=?", arrayOf(flags.toString()))
return number
}
fun update(info: CollectionInfo) {
val newAccountName = accountName(getMainAccount(), info)
val newAccountName = accountName(mainAccount, info)
@TargetApi(Build.VERSION_CODES.LOLLIPOP)
if (account.name != newAccountName && Build.VERSION.SDK_INT >= 21) {
val accountManager = AccountManager.get(context)
val future = accountManager.renameAccount(account, newAccountName, {
@@ -119,7 +192,7 @@ class LocalAddressBook(
values.put(RawContacts.ACCOUNT_NAME, newAccountName)
provider.update(syncAdapterURI(RawContacts.CONTENT_URI), values, "${RawContacts.ACCOUNT_NAME}=?", arrayOf(account.name))
}
} catch(e: RemoteException) {
} catch (e: RemoteException) {
Logger.log.log(Level.WARNING, "Couldn't re-assign contacts to new account name", e)
}
}, null)
@@ -127,147 +200,111 @@ class LocalAddressBook(
}
Constants.log.info("Address book read-only? = ${info.readOnly}")
setReadOnly(info.readOnly || info.forceReadOnly)
readOnly = info.readOnly || info.forceReadOnly
// 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)
}
if (Build.VERSION.SDK_INT >= 22)
accountManager.removeAccount(account, null, null, null)
else
accountManager.removeAccount(account, null, null)
}
/* 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()
override fun findByName(name: String): LocalAddress? {
val result = queryContacts("${AndroidContact.COLUMN_FILENAME}=?", arrayOf(name)).firstOrNull()
return if (includeGroups)
result ?: queryGroups("${AndroidGroup.COLUMN_FILENAME}=?", arrayOf(name)).firstOrNull()
else
result
}
@Throws(ContactsStorageException::class)
override fun getAll(): List<LocalResource> {
val all = LinkedList<LocalResource>()
all.addAll(queryContacts(null, null))
if (includeGroups)
all.addAll(queryGroups(null, null))
return all
}
/**
* Returns an array of local contacts/groups which have been deleted locally. (DELETED != 0).
* @throws RemoteException on content provider errors
*/
@Throws(ContactsStorageException::class)
override fun getDeleted(): List<LocalResource> {
val deleted = LinkedList<LocalResource>()
deleted.addAll(getDeletedContacts())
if (includeGroups)
deleted.addAll(getDeletedGroups())
return deleted
override fun findDeleted() =
if (includeGroups)
findDeletedContacts() + findDeletedGroups()
else
findDeletedContacts()
fun findDeletedContacts() = queryContacts("${RawContacts.DELETED}!=0", null)
fun findDeletedGroups() = queryGroups("${Groups.DELETED}!=0", null)
/**
* Returns an array of local contacts/groups which have been changed locally (DIRTY != 0).
* @throws RemoteException on content provider errors
*/
override fun findDirty() =
if (includeGroups)
findDirtyContacts() + findDirtyGroups()
else
findDirtyContacts()
fun findDirtyContacts() = queryContacts("${RawContacts.DIRTY}!=0", null)
fun findDirtyGroups() = queryGroups("${Groups.DIRTY}!=0", null)
private fun queryContactsGroups(whereContacts: String?, whereArgsContacts: Array<String>?, whereGroups: String?, whereArgsGroups: Array<String>?): List<LocalAddress> {
val contacts = queryContacts(whereContacts, whereArgsContacts)
return if (includeGroups)
contacts + queryGroups(whereGroups, whereArgsGroups)
else
contacts
}
/**
* Queries all contacts with DIRTY flag and checks whether their data checksum has changed, i.e.
* if they're "really dirty" (= data has changed, not only metadata, which is not hashed).
* The DIRTY flag is removed from contacts which are not "really dirty", i.e. from contacts
* whose contact data checksum has not changed.
* @return number of "really dirty" contacts
* @throws RemoteException on content provider errors
*/
@Throws(ContactsStorageException::class)
fun verifyDirty(): Int {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.N || Build.VERSION.SDK_INT >= Build.VERSION_CODES.O)
throw IllegalStateException("verifyDirty() should not be called on Android != 7")
var reallyDirty = 0
for (contact in 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)
for (contact in findDirtyContacts()) {
val lastHash = contact.getLastHashCode()
val currentHash = contact.dataHashCode()
if (lastHash == currentHash) {
// hash is code still the same, contact is not "really dirty" (only metadata been have changed)
Logger.log.log(Level.FINE, "Contact data hash has not changed, resetting dirty flag", contact)
contact.resetDirty()
} else {
Logger.log.log(Level.FINE, "Contact data has changed from hash $lastHash to $currentHash", contact)
reallyDirty++
}
}
if (includeGroups)
reallyDirty += getDirtyGroups().size
reallyDirty += findDirtyGroups().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)
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 { findContactByID(it) }
}
@@ -276,29 +313,23 @@ class LocalAddressBook(
/**
* Finds the first group with the given title. If there is no group with this
* title, a new group is created.
* @param title title of the group to look for
* @return id of the group with given title
* @throws ContactsStorageException on contact provider errors
* @param title title of the group to look for
* @return id of the group with given title
* @throws RemoteException on content 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)
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)
}
@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 */
@@ -308,52 +339,4 @@ class LocalAddressBook(
}
}
@Throws(ContactsStorageException::class)
fun removeGroups() {
try {
provider!!.delete(syncAdapterURI(Groups.CONTENT_URI), null, null)
} catch(e: RemoteException) {
throw ContactsStorageException("Couldn't remove all groups", e)
}
}
// SETTINGS
@Throws(ContactsStorageException::class)
fun getMainAccount(): Account {
val accountManager = AccountManager.get(context)
val name = accountManager.getUserData(account, USER_DATA_MAIN_ACCOUNT_NAME)
val type = accountManager.getUserData(account, USER_DATA_MAIN_ACCOUNT_TYPE)
if (name != null && type != null)
return Account(name, type)
else
throw ContactsStorageException("Address book doesn't exist anymore")
}
fun setMainAccount(mainAccount: Account) {
val accountManager = AccountManager.get(context)
accountManager.setUserData(account, USER_DATA_MAIN_ACCOUNT_NAME, mainAccount.name)
accountManager.setUserData(account, USER_DATA_MAIN_ACCOUNT_TYPE, mainAccount.type)
}
@Throws(ContactsStorageException::class)
fun getURL() =
AccountManager.get(context).getUserData(account, USER_DATA_URL) ?: throw ContactsStorageException("Address book has no URL")
fun setURL(url: String) =
AccountManager.get(context).setUserData(account, USER_DATA_URL, url)
fun getReadOnly() =
AccountManager.get(context).getUserData(account, USER_DATA_READ_ONLY) != null
fun setReadOnly(readOnly: Boolean) =
AccountManager.get(context).setUserData(account, USER_DATA_READ_ONLY, if (readOnly) "1" else null)
override fun getCTag(): String? =
AccountManager.get(context).getUserData(account, USER_DATA_CTAG)
override fun setCTag(cTag: String?) =
AccountManager.get(context).setUserData(account, USER_DATA_CTAG, cTag)
}
}
@@ -14,14 +14,17 @@ import android.content.ContentProviderOperation
import android.content.ContentUris
import android.content.ContentValues
import android.net.Uri
import android.os.RemoteException
import android.provider.CalendarContract
import android.provider.CalendarContract.*
import at.bitfire.davdroid.Constants
import at.bitfire.davdroid.DavUtils
import at.bitfire.davdroid.log.Logger
import at.bitfire.davdroid.model.CollectionInfo
import at.bitfire.ical4android.*
import java.io.FileNotFoundException
import at.bitfire.davdroid.model.SyncState
import at.bitfire.ical4android.AndroidCalendar
import at.bitfire.ical4android.AndroidCalendarFactory
import at.bitfire.ical4android.BatchOperation
import at.bitfire.ical4android.DateUtils
import java.util.*
import java.util.logging.Level
@@ -33,18 +36,8 @@ class LocalCalendar private constructor(
companion object {
val defaultColor = 0xFF8bc34a.toInt() // light green 500
private const val COLUMN_SYNC_STATE = Calendars.CAL_SYNC1
val COLUMN_CTAG = Calendars.CAL_SYNC1
val BASE_INFO_COLUMNS = arrayOf(
Events._ID,
Events._SYNC_ID,
LocalEvent.COLUMN_ETAG
)
@Throws(CalendarStorageException::class)
fun create(account: Account, provider: ContentProviderClient, info: CollectionInfo): Uri {
val values = valuesFromCollectionInfo(info, true)
@@ -56,18 +49,16 @@ class LocalCalendar private constructor(
// flag as visible & synchronizable at creation, might be changed by user at any time
values.put(Calendars.VISIBLE, 1)
values.put(Calendars.SYNC_EVENTS, 1)
val uri = create(account, provider, values)
return uri
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.NAME, info.url.toString())
values.put(Calendars.CALENDAR_DISPLAY_NAME, if (info.displayName.isNullOrBlank()) DavUtils.lastSegmentOfUrl(info.url) else info.displayName)
if (withColor)
values.put(Calendars.CALENDAR_COLOR, info.color ?: defaultColor)
values.put(Calendars.CALENDAR_COLOR, info.color ?: Constants.DAVDROID_GREEN_RGBA)
if (info.readOnly || info.forceReadOnly)
values.put(Calendars.CALENDAR_ACCESS_LEVEL, Calendars.CAL_ACCESS_READ)
@@ -92,31 +83,33 @@ class LocalCalendar private constructor(
values.put(Calendars.ALLOWED_ATTENDEE_TYPES, "${CalendarContract.Attendees.TYPE_OPTIONAL},${CalendarContract.Attendees.TYPE_REQUIRED},${CalendarContract.Attendees.TYPE_RESOURCE}")
return values
}
}
override val title: String
get() = displayName ?: id.toString()
override fun eventBaseInfoColumns() = BASE_INFO_COLUMNS
override var lastSyncState: SyncState?
get() = provider.query(calendarSyncURI(), arrayOf(COLUMN_SYNC_STATE), null, null, null)?.let { cursor ->
if (cursor.moveToNext())
return SyncState.fromString(cursor.getString(0))
else
null
}
set(state) {
val values = ContentValues(1)
values.put(COLUMN_SYNC_STATE, state.toString())
provider.update(calendarSyncURI(), values, null, null)
}
@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() =
override fun findDeleted() =
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> {
override fun findDirty(): List<LocalEvent> {
val dirty = LinkedList<LocalEvent>()
// get dirty events which are required to have an increased SEQUENCE value
@@ -133,50 +126,46 @@ class LocalCalendar private constructor(
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)
}
override fun findByName(name: String) =
queryEvents("${Events._SYNC_ID}=?", arrayOf(name)).firstOrNull()
@Throws(CalendarStorageException::class)
override fun setCTag(cTag: String?) {
try {
val values = ContentValues(1)
values.put(COLUMN_CTAG, cTag)
provider.update(calendarSyncURI(), values, null, null)
} catch (e: RemoteException) {
throw CalendarStorageException("Couldn't write local (last known) CTag", e)
}
override fun markNotDirty(flags: Int): Int {
val values = ContentValues(1)
values.put(LocalEvent.COLUMN_FLAGS, flags)
return provider.update(eventsSyncURI(), values,
"${Events.CALENDAR_ID}=? AND ${Events.DIRTY}=0 AND ${Events.ORIGINAL_ID} IS NULL",
arrayOf(id.toString()))
}
@Throws(CalendarStorageException::class)
override fun removeNotDirtyMarked(flags: Int) =
provider.delete(eventsSyncURI(),
"${Events.CALENDAR_ID}=? AND ${Events.DIRTY}=0 AND ${Events.ORIGINAL_ID} IS NULL AND ${LocalEvent.COLUMN_FLAGS}=?",
arrayOf(id.toString(), flags.toString()))
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)
// process deleted exceptions
Logger.log.info("Processing deleted exceptions")
provider.query(
syncAdapterURI(Events.CONTENT_URI),
arrayOf(Events._ID, Events.ORIGINAL_ID, LocalEvent.COLUMN_SEQUENCE),
"${Events.CALENDAR_ID}=? AND ${Events.DELETED}!=0 AND ${Events.ORIGINAL_ID} IS NOT NULL",
arrayOf(id.toString()), null)?.use { cursor ->
while (cursor.moveToNext()) {
Logger.log.fine("Found deleted exception, removing and re-scheduling original event (if available)")
val id = cursor.getLong(0) // can't be null (by definition)
val originalID = cursor.getLong(1) // can't be null (by query)
val batch = BatchOperation(provider)
val batch = BatchOperation(provider)
// get original event's SEQUENCE
provider.query(
syncAdapterURI(ContentUris.withAppendedId(Events.CONTENT_URI, originalID)),
arrayOf(LocalEvent.COLUMN_SEQUENCE),
null, null, null)?.use { cursor2 ->
// get original event's SEQUENCE
provider.query(
syncAdapterURI(ContentUris.withAppendedId(Events.CONTENT_URI, originalID)),
arrayOf(LocalEvent.COLUMN_SEQUENCE),
null, null, null)?.use { cursor2 ->
if (cursor2.moveToNext()) {
// original event is available
val originalSequence = if (cursor2.isNull(0)) 0 else cursor2.getInt(0)
// re-schedule original event and set it to DIRTY
@@ -186,44 +175,43 @@ class LocalCalendar private constructor(
.withValue(Events.DIRTY, 1)
))
}
// remove exception
batch.enqueue(BatchOperation.Operation(
ContentProviderOperation.newDelete(syncAdapterURI(ContentUris.withAppendedId(Events.CONTENT_URI, id)))
))
batch.commit()
}
}
// 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()
}
// completely remove deleted exception
batch.enqueue(BatchOperation.Operation(
ContentProviderOperation.newDelete(syncAdapterURI(ContentUris.withAppendedId(Events.CONTENT_URI, id)))
))
batch.commit()
}
}
// process dirty exceptions
Logger.log.info("Processing dirty exceptions")
provider.query(
syncAdapterURI(Events.CONTENT_URI),
arrayOf(Events._ID, Events.ORIGINAL_ID, LocalEvent.COLUMN_SEQUENCE),
"${Events.CALENDAR_ID}=? AND ${Events.DIRTY}!=0 AND ${Events.ORIGINAL_ID} IS NOT NULL",
arrayOf(id.toString()), null)?.use { cursor ->
while (cursor.moveToNext()) {
Logger.log.fine("Found dirty exception, increasing SEQUENCE to re-schedule")
val id = cursor.getLong(0) // can't be null (by definition)
val originalID = cursor.getLong(1) // can't be null (by query)
val sequence = if (cursor.isNull(2)) 0 else cursor.getInt(2)
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)
}
}
@@ -235,4 +223,4 @@ class LocalCalendar private constructor(
}
}
}
@@ -8,28 +8,31 @@
package at.bitfire.davdroid.resource
import at.bitfire.ical4android.CalendarStorageException
import at.bitfire.vcard4android.ContactsStorageException
import java.io.FileNotFoundException
import at.bitfire.davdroid.model.SyncState
interface LocalCollection<out T: LocalResource> {
interface LocalCollection<out T: LocalResource<*>> {
@Throws(CalendarStorageException::class, ContactsStorageException::class)
fun getDeleted(): List<T>
/** collection title (used for user notifications etc.) **/
val title: String
@Throws(CalendarStorageException::class, ContactsStorageException::class)
fun getWithoutFileName(): List<T>
var lastSyncState: SyncState?
@Throws(CalendarStorageException::class, ContactsStorageException::class, FileNotFoundException::class)
fun getDirty(): List<T>
fun findDeleted(): List<T>
fun findDirty(): List<T>
@Throws(CalendarStorageException::class, ContactsStorageException::class)
fun getAll(): List<T>
fun findByName(name: String): T?
@Throws(CalendarStorageException::class, ContactsStorageException::class)
fun getCTag(): String?
@Throws(CalendarStorageException::class, ContactsStorageException::class)
fun setCTag(cTag: String?)
/**
* Marks all entries which are not dirty with the given flags only.
* @return number of marked entries
**/
fun markNotDirty(flags: Int): Int
/**
* Removes all entries with are not dirty and are marked with exactly the given flags.
* @return number of removed entries
*/
fun removeNotDirtyMarked(flags: Int): Int
}
@@ -23,129 +23,110 @@ import ezvcard.Ezvcard
import java.io.FileNotFoundException
import java.util.*
class LocalContact: AndroidContact, LocalResource {
class LocalContact: AndroidContact, LocalAddress {
companion object {
init {
Contact.productID = "+//IDN bitfire.at//DAVdroid/" + BuildConfig.VERSION_NAME + " ez-vcard/" + Ezvcard.VERSION
}
val COLUMN_HASHCODE = ContactsContract.RawContacts.SYNC3
const val COLUMN_FLAGS = ContactsContract.RawContacts.SYNC4
const val COLUMN_HASHCODE = ContactsContract.RawContacts.SYNC3
}
private val cachedGroupMemberships = HashSet<Long>()
private val groupMemberships = HashSet<Long>()
override var flags: Int = 0
constructor(addressBook: AndroidAddressBook<LocalContact,*>, 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 resetDeleted() {
val values = ContentValues(1)
values.put(ContactsContract.RawContacts.DELETED, 0)
try {
addressBook.provider!!.update(rawContactSyncURI(), values, null, null)
} catch(e: RemoteException) {
throw ContactsStorageException("Couldn't clear deleted flag", e)
}
constructor(addressBook: AndroidAddressBook<LocalContact,*>, values: ContentValues)
: super(addressBook, values) {
flags = values.getAsInteger(COLUMN_FLAGS) ?: 0
}
constructor(addressBook: AndroidAddressBook<LocalContact,*>, contact: Contact, fileName: String?, eTag: String?, flags: Int)
: super(addressBook, contact, fileName, eTag) {
this.flags = flags
}
override fun assignNameAndUID() {
val uid = UUID.randomUUID().toString()
val newFileName = "$uid.vcf"
val values = ContentValues(2)
values.put(COLUMN_FILENAME, newFileName)
values.put(COLUMN_UID, uid)
addressBook.provider!!.update(rawContactSyncURI(), values, null, null)
fileName = newFileName
}
override fun clearDirty(eTag: String?) {
val values = ContentValues(3)
values.put(COLUMN_ETAG, eTag)
values.put(ContactsContract.RawContacts.DIRTY, 0)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N && Build.VERSION.SDK_INT < Build.VERSION_CODES.O) {
// workaround for Android 7 which sets DIRTY flag when only meta-data is changed
val hashCode = dataHashCode()
values.put(COLUMN_HASHCODE, hashCode)
Logger.log.finer("Clearing dirty flag with eTag = $eTag, contact hash = $hashCode")
}
addressBook.provider!!.update(rawContactSyncURI(), values, null, null)
this.eTag = eTag
}
override fun resetDeleted() {
val values = ContentValues(1)
values.put(ContactsContract.Groups.DELETED, 0)
addressBook.provider!!.update(rawContactSyncURI(), values, null, null)
}
@Throws(ContactsStorageException::class)
fun resetDirty() {
val values = ContentValues(1)
values.put(ContactsContract.RawContacts.DIRTY, 0)
try {
addressBook.provider!!.update(rawContactSyncURI(), values, null, null)
} catch(e: RemoteException) {
throw ContactsStorageException("Couldn't clear dirty flag", e)
}
addressBook.provider!!.update(rawContactSyncURI(), values, null, null)
}
@Throws(ContactsStorageException::class)
override fun clearDirty(eTag: String?) {
try {
val values = ContentValues(3)
values.put(COLUMN_ETAG, eTag)
values.put(ContactsContract.RawContacts.DIRTY, 0)
override fun updateFlags(flags: Int) {
val values = ContentValues(1)
values.put(LocalContact.COLUMN_FLAGS, flags)
addressBook.provider!!.update(rawContactSyncURI(), values, null, null)
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)
}
this.flags = flags
}
@Throws(ContactsStorageException::class)
override fun populateData(mimeType: String, row: ContentValues) {
when (mimeType) {
CachedGroupMembership.CONTENT_ITEM_TYPE ->
cachedGroupMemberships.add(row.getAsLong(CachedGroupMembership.GROUP_ID))
cachedGroupMemberships += row.getAsLong(CachedGroupMembership.GROUP_ID)
GroupMembership.CONTENT_ITEM_TYPE ->
groupMemberships.add(row.getAsLong(GroupMembership.GROUP_ROW_ID))
groupMemberships += 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)
}
contact!!.unknownProperties = row.getAsString(UnknownProperties.UNKNOWN_PROPERTIES)
}
}
@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)
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)
}
} catch(e: FileNotFoundException) {
throw ContactsStorageException("Couldn't insert data rows", e)
builder .withValue(UnknownProperties.MIMETYPE, UnknownProperties.CONTENT_ITEM_TYPE)
.withValue(UnknownProperties.UNKNOWN_PROPERTIES, unknownProperties)
batch.enqueue(op)
}
}
@@ -154,7 +135,6 @@ class LocalContact: AndroidContact, LocalResource {
* 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")
@@ -169,44 +149,34 @@ class LocalContact: AndroidContact, LocalResource {
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)
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)
if (batch == null)
addressBook.provider!!.update(rawContactSyncURI(), values, null, null)
else {
val builder = ContentProviderOperation
.newUpdate(rawContactSyncURI())
.withValues(values)
batch.enqueue(BatchOperation.Operation(builder))
}
}
@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)
addressBook.provider!!.query(rawContactSyncURI(), arrayOf(COLUMN_HASHCODE), null, null, null)?.use { c ->
if (c.moveToNext() && !c.isNull(0))
return c.getInt(0)
}
return 0
}
@@ -217,7 +187,7 @@ class LocalContact: AndroidContact, LocalResource {
.withValue(GroupMembership.RAW_CONTACT_ID, id)
.withValue(GroupMembership.GROUP_ROW_ID, groupID)
))
groupMemberships.add(groupID)
groupMemberships += groupID
batch.enqueue(BatchOperation.Operation(
ContentProviderOperation.newInsert(dataSyncURI())
@@ -226,7 +196,7 @@ class LocalContact: AndroidContact, LocalResource {
.withValue(CachedGroupMembership.GROUP_ID, groupID)
.withYieldAllowed(true)
))
cachedGroupMemberships.add(groupID)
cachedGroupMemberships += groupID
}
fun removeGroupMemberships(batch: BatchOperation) {
@@ -247,10 +217,9 @@ class LocalContact: AndroidContact, LocalResource {
* 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 if the current contact can't be found
* @throws RemoteException on contacts provider errors
*/
@Throws(FileNotFoundException::class, ContactsStorageException::class)
fun getCachedGroupMemberships(): Set<Long> {
contact
return cachedGroupMemberships
@@ -259,26 +228,26 @@ class LocalContact: AndroidContact, LocalResource {
/**
* 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 if the current contact can't be found
* @throws RemoteException on contacts provider errors
*/
@Throws(FileNotFoundException::class, ContactsStorageException::class)
fun getGroupMemberships(): Set<Long> {
contact
return groupMemberships
}
// data rows
override fun buildContact(builder: ContentProviderOperation.Builder, update: Boolean) {
builder.withValue(COLUMN_FLAGS, flags)
super.buildContact(builder, update)
}
// factory
object Factory: AndroidContactFactory<LocalContact> {
override fun 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)
override fun fromProvider(addressBook: AndroidAddressBook<LocalContact, *>, values: ContentValues) =
LocalContact(addressBook, values)
}
}
@@ -15,56 +15,54 @@ 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 {
class LocalEvent: AndroidEvent, LocalResource<Event> {
companion object {
init {
iCalendar.prodId = ProdId("+//IDN bitfire.at//DAVdroid/" + BuildConfig.VERSION_NAME + " ical4j/2.x")
ICalendar.prodId = ProdId("+//IDN bitfire.at//DAVdroid/" + BuildConfig.VERSION_NAME + " ical4j/" + Constants.ical4jVersion)
}
val COLUMN_ETAG = CalendarContract.Events.SYNC_DATA1
val COLUMN_SEQUENCE = CalendarContract.Events.SYNC_DATA3
const val COLUMN_ETAG = CalendarContract.Events.SYNC_DATA1
const val COLUMN_FLAGS = CalendarContract.Events.SYNC_DATA2
const val COLUMN_SEQUENCE = CalendarContract.Events.SYNC_DATA3
}
override var fileName: String? = null
private set
override var eTag: String? = null
override var flags: Int = 0
private set
var weAreOrganizer = true
constructor(calendar: AndroidCalendar<*>, event: Event, fileName: String?, eTag: String?): super(calendar, event) {
constructor(calendar: AndroidCalendar<*>, event: Event, fileName: String?, eTag: String?, flags: Int): super(calendar, event) {
this.fileName = fileName
this.eTag = eTag
this.flags = flags
}
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)
}
private constructor(calendar: AndroidCalendar<*>, values: ContentValues): super(calendar, values) {
fileName = values.getAsString(Events._SYNC_ID)
eTag = values.getAsString(COLUMN_ETAG)
flags = values.getAsInteger(COLUMN_FLAGS) ?: 0
}
/* 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(Events.UID_2445)
event.sequence = row.getAsInteger(COLUMN_SEQUENCE)
val isOrganizer = row.getAsInteger(Events.IS_ORGANIZER)
weAreOrganizer = isOrganizer != null && isOrganizer != 0
}
@Throws(FileNotFoundException::class, CalendarStorageException::class)
override fun buildEvent(recurrence: Event?, builder: ContentProviderOperation.Builder) {
super.buildEvent(recurrence, builder)
val event = requireNotNull(event)
@@ -76,6 +74,7 @@ class LocalEvent: AndroidEvent, LocalResource {
.withValue(COLUMN_SEQUENCE, eventToBuild.sequence)
.withValue(CalendarContract.Events.DIRTY, 0)
.withValue(CalendarContract.Events.DELETED, 0)
.withValue(LocalEvent.COLUMN_FLAGS, flags)
if (buildException)
builder .withValue(Events.ORIGINAL_SYNC_ID, fileName)
@@ -85,57 +84,48 @@ class LocalEvent: AndroidEvent, LocalResource {
}
/* custom queries */
@Throws(CalendarStorageException::class)
override fun prepareForUpload() {
try {
var uid: String? = null
calendar.provider.query(eventSyncURI(), arrayOf(Events.UID_2445), null, null, null)?.use { cursor ->
if (cursor.moveToNext())
uid = cursor.getString(0)
}
if (uid == null)
uid = UUID.randomUUID().toString()
val newFileName = "$uid.ics"
val values = ContentValues(2)
values.put(Events._SYNC_ID, newFileName)
values.put(Events.UID_2445, uid)
calendar.provider.update(eventSyncURI(), values, null, null)
fileName = newFileName
event!!.uid = uid
} catch(e: Exception) {
throw CalendarStorageException("Couldn't update UID", e)
override fun assignNameAndUID() {
var uid: String? = null
calendar.provider.query(eventSyncURI(), arrayOf(Events.UID_2445), null, null, null)?.use { cursor ->
if (cursor.moveToNext())
uid = cursor.getString(0)
}
if (uid == null)
uid = UUID.randomUUID().toString()
val newFileName = "$uid.ics"
val values = ContentValues(2)
values.put(Events._SYNC_ID, newFileName)
values.put(Events.UID_2445, uid)
calendar.provider.update(eventSyncURI(), values, null, null)
fileName = newFileName
event!!.uid = uid
}
@Throws(CalendarStorageException::class)
override fun clearDirty(eTag: String?) {
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)
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)
}
this.eTag = eTag
}
override fun updateFlags(flags: Int) {
val values = ContentValues(1)
values.put(COLUMN_FLAGS, flags)
calendar.provider.update(eventSyncURI(), values, null, null)
this.flags = flags
}
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)
override fun fromProvider(calendar: AndroidCalendar<*>, values: ContentValues) =
LocalEvent(calendar, values)
}
}
@@ -11,6 +11,7 @@ package at.bitfire.davdroid.resource
import android.content.ContentProviderOperation
import android.content.ContentUris
import android.content.ContentValues
import android.net.Uri
import android.os.Build
import android.os.Parcel
import android.os.RemoteException
@@ -21,116 +22,126 @@ 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 {
class LocalGroup: AndroidGroup, LocalAddress {
companion object {
const val COLUMN_FLAGS = Groups.SYNC4
/** marshaled list of member UIDs, as sent by server */
val COLUMN_PENDING_MEMBERS = Groups.SYNC3
const 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")
addressBook.provider!!.query(
addressBook.groupsSyncUri(),
arrayOf(Groups._ID, COLUMN_PENDING_MEMBERS),
"$COLUMN_PENDING_MEMBERS IS NOT NULL", null,
null
)?.use { cursor ->
val batch = BatchOperation(addressBook.provider)
while (cursor.moveToNext()) {
val id = cursor.getLong(0)
Constants.log.fine("Assigning members to group $id")
// required for workaround for Android 7 which sets DIRTY flag when only meta-data is changed
val changeContactIDs = HashSet<Long>()
// required for workaround for Android 7 which sets DIRTY flag when only meta-data is changed
val changeContactIDs = HashSet<Long>()
// delete all memberships and cached memberships for this group
for (contact in addressBook.getByGroupMembership(id)) {
contact.removeGroupMemberships(batch)
changeContactIDs.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()
// delete all memberships and cached memberships for this group
for (contact in addressBook.getByGroupMembership(id)) {
contact.removeGroupMemberships(batch)
changeContactIDs += 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")
addressBook.findContactByUID(uid)?.let { member ->
member.addToGroup(batch, id)
changeContactIDs += member.id!!
} ?: Constants.log.warning("Group member not found: $uid")
}
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N && Build.VERSION.SDK_INT < Build.VERSION_CODES.O)
// workaround for Android 7 which sets DIRTY flag when only meta-data is changed
changeContactIDs
.map { addressBook.findContactByID(it) }
.forEach { it.updateHashCode(batch) }
// remove pending memberships
batch.enqueue(BatchOperation.Operation(
ContentProviderOperation.newUpdate(addressBook.syncAdapterURI(ContentUris.withAppendedId(Groups.CONTENT_URI, id)))
.withValue(COLUMN_PENDING_MEMBERS, null)
.withYieldAllowed(true)
))
batch.commit()
}
} catch(e: RemoteException) {
throw ContactsStorageException("Couldn't get pending memberships", e)
}
}
}
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)
override var flags: Int = 0
@Throws(ContactsStorageException::class)
fun resetDeleted() {
val uri = ContentUris.withAppendedId(addressBook.syncAdapterURI(ContactsContract.Groups.CONTENT_URI), requireNotNull(id))
val values = ContentValues(1)
values.put(ContactsContract.Groups.DELETED, 0)
try {
addressBook.provider!!.update(uri, values, null, null)
} catch(e: RemoteException) {
throw ContactsStorageException("Couldn't clear deleted flag", e)
}
constructor(addressBook: AndroidAddressBook<out AndroidContact, LocalGroup>, values: ContentValues)
: super(addressBook, values) {
flags = values.getAsInteger(COLUMN_FLAGS) ?: 0
}
constructor(addressBook: AndroidAddressBook<out AndroidContact, LocalGroup>, contact: Contact, fileName: String?, eTag: String?, flags: Int)
: super(addressBook, contact, fileName, eTag) {
this.flags = flags
}
override fun contentValues(): ContentValues {
val values = super.contentValues()
values.put(COLUMN_FLAGS, flags)
val members = Parcel.obtain()
try {
members.writeStringList(contact!!.members)
values.put(COLUMN_PENDING_MEMBERS, members.marshall())
} finally {
members.recycle()
}
return values
}
override fun assignNameAndUID() {
val uid = UUID.randomUUID().toString()
val newFileName = "$uid.vcf"
val values = ContentValues(2)
values.put(COLUMN_FILENAME, newFileName)
values.put(COLUMN_UID, uid)
update(values)
fileName = newFileName
}
@Throws(ContactsStorageException::class)
override fun clearDirty(eTag: String?) {
val id = requireNotNull(id)
@@ -165,38 +176,9 @@ class LocalGroup: AndroidGroup, LocalResource {
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!!)
@@ -210,31 +192,45 @@ class LocalGroup: AndroidGroup, LocalResource {
batch.commit()
}
override fun resetDeleted() {
val values = ContentValues(1)
values.put(Groups.DELETED, 0)
addressBook.provider!!.update(groupSyncUri(), values, null, null)
}
override fun updateFlags(flags: Int) {
val values = ContentValues(1)
values.put(LocalGroup.COLUMN_FLAGS, flags)
addressBook.provider!!.update(groupSyncUri(), values, null, null)
this.flags = flags
}
// helpers
private fun groupSyncUri(): Uri {
val id = requireNotNull(id)
return ContentUris.withAppendedId(addressBook.groupsSyncUri(), id)
}
/**
* Lists all members of this group.
* @return list of all members' raw contact IDs
* @throws ContactsStorageException on contact provider errorst
* @throws RemoteException on contact provider errors
*/
@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)
addressBook.provider!!.query(
addressBook.syncAdapterURI(ContactsContract.Data.CONTENT_URI),
arrayOf(Data.RAW_CONTACT_ID),
"${GroupMembership.MIMETYPE}=? AND ${GroupMembership.GROUP_ROW_ID}=?",
arrayOf(GroupMembership.CONTENT_ITEM_TYPE, id.toString()),
null
)?.use { cursor ->
while (cursor.moveToNext())
members += cursor.getLong(0)
}
return members
}
@@ -243,13 +239,8 @@ class LocalGroup: AndroidGroup, LocalResource {
// 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)
override fun fromProvider(addressBook: AndroidAddressBook<out AndroidContact, LocalGroup>, values: ContentValues) =
LocalGroup(addressBook, values)
}
}
@@ -8,23 +8,50 @@
package at.bitfire.davdroid.resource
import at.bitfire.ical4android.CalendarStorageException
import at.bitfire.vcard4android.ContactsStorageException
import android.net.Uri
interface LocalResource {
interface LocalResource<in TData: Any> {
companion object {
/**
* Resource is present on remote server. This flag is used to identify resources
* which are not present on the remote server anymore and can be deleted at the end
* of the synchronization.
*/
const val FLAG_REMOTELY_PRESENT = 1
}
/**
* Unique ID which identifies the resource in the local storage. May be null if the
* resource has not been saved yet.
*/
val id: Long?
var fileName: String?
val fileName: String?
var eTag: String?
val flags: Int
@Throws(CalendarStorageException::class, ContactsStorageException::class)
fun assignNameAndUID()
fun clearDirty(eTag: String?)
fun updateFlags(flags: Int)
/**
* Adds the data object to the content provider and ensures that the dirty flag is clear.
* @return content URI of the created row (e.g. event URI)
*/
fun add(): Uri
/**
* Updates the data object in the content provider and ensures that the dirty flag is clear.
* @return content URI of the updated row (e.g. event URI)
*/
fun update(data: TData): Uri
/**
* Deletes the data object from the content provider.
* @return number of affected rows
*/
fun delete(): Int
@Throws(CalendarStorageException::class, ContactsStorageException::class)
fun prepareForUpload()
@Throws(CalendarStorageException::class, ContactsStorageException::class)
fun clearDirty(eTag: String?)
}
@@ -10,108 +10,93 @@ 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 at.bitfire.ical4android.AndroidTask
import at.bitfire.ical4android.AndroidTaskFactory
import at.bitfire.ical4android.AndroidTaskList
import at.bitfire.ical4android.Task
import org.dmfs.tasks.contract.TaskContract.Tasks
import java.util.*
class LocalTask: AndroidTask, LocalResource {
class LocalTask: AndroidTask, LocalResource<Task> {
companion object {
val COLUMN_ETAG = Tasks.SYNC1
val COLUMN_UID = Tasks.SYNC2
val COLUMN_SEQUENCE = Tasks.SYNC3
const val COLUMN_ETAG = Tasks.SYNC1
const val COLUMN_FLAGS = Tasks.SYNC2
}
override var fileName: String? = null
override var eTag: String? = null
constructor(taskList: AndroidTaskList<*>, task: Task, fileName: String?, eTag: String?): super(taskList, task) {
override var flags = 0
private set
constructor(taskList: AndroidTaskList<*>, task: Task, fileName: String?, eTag: String?, flags: Int)
: super(taskList, task) {
this.fileName = fileName
this.eTag = eTag
this.flags = flags
}
private constructor(taskList: AndroidTaskList<*>, id: Long, baseInfo: ContentValues?): super(taskList, id) {
baseInfo?.let {
fileName = it.getAsString(Events._SYNC_ID)
eTag = it.getAsString(COLUMN_ETAG)
}
private constructor(taskList: AndroidTaskList<*>, values: ContentValues): super(taskList) {
id = values.getAsLong(Tasks._ID)
fileName = values.getAsString(Tasks._SYNC_ID)
eTag = values.getAsString(COLUMN_ETAG)
flags = values.getAsInteger(COLUMN_FLAGS) ?: 0
}
/* process LocalTask-specific fields */
@Throws(ParseException::class)
override fun populateTask(values: ContentValues) {
super.populateTask(values)
fileName = values.getAsString(Events._SYNC_ID)
eTag = values.getAsString(COLUMN_ETAG)
val task = requireNotNull(task)
task.uid = values.getAsString(COLUMN_UID)
task.sequence = values.getAsInteger(COLUMN_SEQUENCE)
}
@Throws(FileNotFoundException::class, CalendarStorageException::class)
override fun buildTask(builder: ContentProviderOperation.Builder, update: Boolean) {
super.buildTask(builder, update)
val task = requireNotNull(task)
builder .withValue(Tasks._SYNC_ID, fileName)
.withValue(COLUMN_UID, task.uid)
.withValue(COLUMN_SEQUENCE, task.sequence)
.withValue(COLUMN_ETAG, eTag)
.withValue(COLUMN_FLAGS, flags)
}
/* custom queries */
@Throws(CalendarStorageException::class)
override fun prepareForUpload() {
try {
val uid = UUID.randomUUID().toString()
val newFileName = uid + ".ics"
override fun assignNameAndUID() {
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)
val values = ContentValues(2)
values.put(Tasks._SYNC_ID, newFileName)
values.put(Tasks._UID, uid)
taskList.provider.client.update(taskSyncURI(), values, null, null)
fileName = newFileName
fileName = newFileName
task!!.uid = uid
} catch (e: Exception) {
throw CalendarStorageException("Couldn't update UID", e)
}
task!!.uid = uid
}
@Throws(CalendarStorageException::class)
override fun clearDirty(eTag: String?) {
try {
val values = ContentValues(2)
values.put(Tasks._DIRTY, 0)
values.put(COLUMN_ETAG, eTag)
val values = ContentValues(3)
values.put(Tasks._DIRTY, 0)
values.put(COLUMN_ETAG, eTag)
values.put(Tasks.SYNC_VERSION, task!!.sequence)
taskList.provider.client.update(taskSyncURI(), values, null, null)
values.put(COLUMN_SEQUENCE, task!!.sequence)
this.eTag = eTag
}
override fun updateFlags(flags: Int) {
if (id != null) {
val values = ContentValues(1)
values.put(COLUMN_FLAGS, flags)
taskList.provider.client.update(taskSyncURI(), values, null, null)
this.eTag = eTag
} catch (e: Exception) {
throw CalendarStorageException("Couldn't update _DIRTY/ETag/SEQUENCE", e)
}
this.flags = flags
}
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)
override fun fromProvider(taskList: AndroidTaskList<*>, values: ContentValues) =
LocalTask(taskList, values)
}
}
@@ -15,15 +15,17 @@ import android.content.ContentValues
import android.content.Context
import android.net.Uri
import android.os.Build
import at.bitfire.davdroid.Constants
import at.bitfire.davdroid.DavUtils
import at.bitfire.davdroid.log.Logger
import at.bitfire.davdroid.model.CollectionInfo
import at.bitfire.davdroid.model.SyncState
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
import org.dmfs.tasks.contract.TaskContract.TaskLists
import org.dmfs.tasks.contract.TaskContract.Tasks
import java.util.logging.Level
class LocalTaskList private constructor(
account: Account,
@@ -33,29 +35,16 @@ class LocalTaskList private constructor(
companion object {
val defaultColor = 0xFFC3EA6E.toInt() // "DAVdroid green"
val COLUMN_CTAG = TaskLists.SYNC_VERSION
val BASE_INFO_COLUMNS = arrayOf(
Tasks._ID,
Tasks._SYNC_ID,
LocalTask.COLUMN_ETAG
)
@JvmStatic
fun tasksProviderAvailable(context: Context): Boolean {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M)
return context.packageManager.resolveContentProvider(TaskProvider.ProviderName.OpenTasks.authority, 0) != null
else {
val provider = TaskProvider.acquire(context.contentResolver, TaskProvider.ProviderName.OpenTasks)
val provider = TaskProvider.acquire(context, 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)
@@ -64,7 +53,6 @@ class LocalTaskList private constructor(
return create(account, provider, values)
}
@JvmStatic
@Throws(Exception::class)
fun onRenameAccount(resolver: ContentResolver, oldName: String, newName: String) {
var client: ContentProviderClient? = null
@@ -85,38 +73,49 @@ class LocalTaskList private constructor(
private fun valuesFromCollectionInfo(info: CollectionInfo, withColor: Boolean): ContentValues {
val values = ContentValues(3)
values.put(TaskLists._SYNC_ID, info.url)
values.put(TaskLists._SYNC_ID, info.url.toString())
values.put(TaskLists.LIST_NAME, if (info.displayName.isNullOrBlank()) DavUtils.lastSegmentOfUrl(info.url) else info.displayName)
if (withColor)
values.put(TaskLists.LIST_COLOR, info.color ?: defaultColor)
values.put(TaskLists.LIST_COLOR, info.color ?: Constants.DAVDROID_GREEN_RGBA)
return values
}
}
override val title: String
get() = name ?: id.toString()
override fun taskBaseInfoColumns() = BASE_INFO_COLUMNS
override var lastSyncState: SyncState?
get() {
try {
provider.client.query(taskListSyncUri(), arrayOf(TaskLists.SYNC_VERSION),
null, null, null)?.use { cursor ->
if (cursor.moveToNext())
cursor.getString(0)?.let {
return SyncState.fromString(it)
}
}
} catch (e: Exception) {
Logger.log.log(Level.WARNING, "Couldn't read sync state", e)
}
return null
}
set(state) {
val values = ContentValues(1)
values.put(TaskLists.SYNC_VERSION, state?.toString())
provider.client.update(taskListSyncUri(), values, null, null)
}
@Throws(CalendarStorageException::class)
fun update(info: CollectionInfo, updateColor: Boolean) {
update(valuesFromCollectionInfo(info, updateColor));
}
fun update(info: CollectionInfo, updateColor: Boolean) =
update(valuesFromCollectionInfo(info, updateColor))
@Throws(CalendarStorageException::class)
override fun getAll() = queryTasks(null, null)
override fun findDeleted() = queryTasks("${Tasks._DELETED}!=0", 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> {
override fun findDirty(): List<LocalTask> {
val tasks = queryTasks("${Tasks._DIRTY}!=0", null)
for (localTask in tasks) {
val task = requireNotNull(localTask.task)
@@ -129,30 +128,23 @@ class LocalTaskList private constructor(
return tasks
}
override fun findByName(name: String) =
queryTasks("${Tasks._SYNC_ID}=?", arrayOf(name)).firstOrNull()
@Throws(CalendarStorageException::class)
override fun getCTag(): String? =
try {
provider.client.query(taskListSyncUri(), arrayOf(COLUMN_CTAG), null, null, null)?.use { cursor ->
if (cursor.moveToNext())
return cursor.getString(0)
}
null
} catch(e: Exception) {
throw CalendarStorageException("Couldn't read local (last known) CTag", e)
}
@Throws(CalendarStorageException::class)
override fun setCTag(cTag: String?) {
try {
val values = ContentValues(1)
values.put(COLUMN_CTAG, cTag)
provider.client.update(taskListSyncUri(), values, null, null)
} catch (e: Exception) {
throw CalendarStorageException("Couldn't write local (last known) CTag", e)
}
override fun markNotDirty(flags: Int): Int {
val values = ContentValues(1)
values.put(LocalTask.COLUMN_FLAGS, flags)
return provider.client.update(tasksSyncUri(), values,
"${Tasks.LIST_ID}=? AND ${Tasks._DIRTY}=0",
arrayOf(id.toString()))
}
override fun removeNotDirtyMarked(flags: Int) =
provider.client.delete(tasksSyncUri(),
"${Tasks.LIST_ID}=? AND ${Tasks._DIRTY}=0 AND ${LocalTask.COLUMN_FLAGS}=?",
arrayOf(id.toString(), flags.toString()))
object Factory: AndroidTaskListFactory<LocalTaskList> {
@@ -161,4 +153,4 @@ class LocalTaskList private constructor(
}
}
}
@@ -14,18 +14,18 @@ open class DefaultsProvider(
private val allowOverride: Boolean = true
): Provider {
open val booleanDefaults = mapOf<String, Boolean>(
open val booleanDefaults = mapOf(
Pair(App.DISTRUST_SYSTEM_CERTIFICATES, false),
Pair(App.OVERRIDE_PROXY, false)
)
open val intDefaults = mapOf<String, Int>(
open val intDefaults = mapOf(
Pair(App.OVERRIDE_PROXY_PORT, App.OVERRIDE_PROXY_PORT_DEFAULT)
)
open val longDefaults = mapOf<String, Long>()
open val stringDefaults = mapOf<String, String>(
open val stringDefaults = mapOf(
Pair(App.OVERRIDE_PROXY_HOST, App.OVERRIDE_PROXY_HOST_DEFAULT)
)
@@ -19,7 +19,6 @@ import android.os.Handler
import android.os.IBinder
import android.os.Looper
import at.bitfire.davdroid.log.Logger
import java.io.Closeable
import java.lang.ref.WeakReference
import java.util.*
import java.util.logging.Level
@@ -216,8 +215,8 @@ class Settings: Service(), Provider.Observer {
class Stub(
delegate: ISettings,
private val context: Context,
private val serviceConn: ServiceConnection
): ISettings by delegate, Closeable {
private val serviceConn: ServiceConnection?
): ISettings by delegate, AutoCloseable {
override fun close() {
try {
@@ -20,8 +20,8 @@ class SharedPreferencesProvider(
): Provider {
companion object {
private val META_VERSION = "version"
private val CURRENT_VERSION = 0
private const val META_VERSION = "version"
private const val CURRENT_VERSION = 0
}
private val preferences = PreferenceManager.getDefaultSharedPreferences(context)
@@ -72,14 +72,14 @@ class SharedPreferencesProvider(
Pair(true, true)
private fun<T> putValue(key: String, value: T?, writer: (SharedPreferences.Editor, T) -> Unit): Boolean {
if (value == null)
return remove(key)
return if (value == null)
remove(key)
else {
Logger.log.fine("Writing setting $key = $value")
val edit = preferences.edit()
writer(edit, value)
edit.apply()
return true
true
}
}
@@ -7,28 +7,92 @@
*/
package at.bitfire.davdroid.syncadapter
import android.accounts.AbstractAccountAuthenticator
import android.accounts.Account
import android.accounts.AccountAuthenticatorResponse
import android.accounts.AccountManager
import android.accounts.*
import android.app.Service
import android.content.Context
import android.content.Intent
import android.database.DatabaseUtils
import android.os.Bundle
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.davdroid.ui.setup.LoginActivity
import java.util.*
import java.util.logging.Level
class AccountAuthenticatorService: Service() {
/**
* Account authenticator for the main DAVdroid account type.
*
* Gets started when a DAVdroid account is removed, too, so it also watches for account removals
* and contains the corresponding cleanup code.
*/
class AccountAuthenticatorService: Service(), OnAccountsUpdateListener {
companion object {
fun cleanupAccounts(context: Context) {
Logger.log.info("Cleaning up orphaned accounts")
ServiceDB.OpenHelper(context).use { dbHelper ->
val db = dbHelper.writableDatabase
val sqlAccountNames = LinkedList<String>()
val accountNames = HashSet<String>()
val accountManager = AccountManager.get(context)
for (account in accountManager.getAccountsByType(context.getString(R.string.account_type))) {
sqlAccountNames.add(DatabaseUtils.sqlEscapeString(account.name))
accountNames += account.name
}
// delete orphaned address book accounts
accountManager.getAccountsByType(context.getString(R.string.account_type_address_book))
.map { LocalAddressBook(context, it, null) }
.forEach {
try {
if (!accountNames.contains(it.mainAccount.name))
it.delete()
} catch(e: Exception) {
Logger.log.log(Level.SEVERE, "Couldn't delete address book account", e)
}
}
// delete orphaned services in DB
if (sqlAccountNames.isEmpty())
db.delete(ServiceDB.Services._TABLE, null, null)
else
db.delete(ServiceDB.Services._TABLE, "${ServiceDB.Services.ACCOUNT_NAME} NOT IN (${sqlAccountNames.joinToString(",")})", null)
}
}
}
private lateinit var accountManager: AccountManager
private lateinit var accountAuthenticator: AccountAuthenticator
override fun onCreate() {
accountManager = AccountManager.get(this)
accountManager.addOnAccountsUpdatedListener(this, null, true)
accountAuthenticator = AccountAuthenticator(this)
}
override fun onDestroy() {
super.onDestroy()
accountManager.removeOnAccountsUpdatedListener(this)
}
override fun onBind(intent: Intent?) =
accountAuthenticator.iBinder.takeIf { intent?.action == android.accounts.AccountManager.ACTION_AUTHENTICATOR_INTENT }
override fun onAccountsUpdated(accounts: Array<out Account>?) {
cleanupAccounts(this)
}
private class AccountAuthenticator(
val context: Context
): AbstractAccountAuthenticator(context) {
@@ -21,7 +21,7 @@ import at.bitfire.davdroid.model.ServiceDB
import at.bitfire.davdroid.model.ServiceDB.Collections
import at.bitfire.davdroid.resource.LocalAddressBook
import at.bitfire.davdroid.settings.ISettings
import at.bitfire.vcard4android.ContactsStorageException
import okhttp3.HttpUrl
import java.util.logging.Level
class AddressBooksSyncAdapterService: SyncAdapterService() {
@@ -29,11 +29,11 @@ class AddressBooksSyncAdapterService: SyncAdapterService() {
override fun syncAdapter() = AddressBooksSyncAdapter(this)
protected class AddressBooksSyncAdapter(
class AddressBooksSyncAdapter(
context: Context
): SyncAdapter(context) {
override fun sync(settings: ISettings, account: Account, extras: Bundle, authority: String, addressBooksProvider: ContentProviderClient, syncResult: SyncResult) {
override fun sync(settings: ISettings, account: Account, extras: Bundle, authority: String, provider: ContentProviderClient, syncResult: SyncResult) {
val contactsProvider = context.contentResolver.acquireContentProviderClient(ContactsContract.AUTHORITY)
if (contactsProvider == null) {
Logger.log.severe("Couldn't access contacts provider")
@@ -82,8 +82,8 @@ class AddressBooksSyncAdapterService: SyncAdapterService() {
null
}
fun remoteAddressBooks(service: Long?): MutableMap<String, CollectionInfo> {
val collections = mutableMapOf<String, CollectionInfo>()
fun remoteAddressBooks(service: Long?): MutableMap<HttpUrl, CollectionInfo> {
val collections = mutableMapOf<HttpUrl, CollectionInfo>()
service?.let {
db.query(Collections._TABLE, null,
Collections.SERVICE_ID + "=? AND " + Collections.SYNC, arrayOf(service.toString()), null, null, null)?.use { cursor ->
@@ -103,8 +103,8 @@ class AddressBooksSyncAdapterService: SyncAdapterService() {
val remote = remoteAddressBooks(service)
// delete/update local address books
for (addressBook in LocalAddressBook.find(context, provider, account)) {
val url = addressBook.getURL()
for (addressBook in LocalAddressBook.findAll(context, provider, account)) {
val url = HttpUrl.parse(addressBook.url)!!
val info = remote[url]
if (info == null) {
Logger.log.log(Level.INFO, "Deleting obsolete local address book", url)
@@ -114,7 +114,7 @@ class AddressBooksSyncAdapterService: SyncAdapterService() {
try {
Logger.log.log(Level.FINE, "Updating local address book $url", info)
addressBook.update(info)
} catch(e: ContactsStorageException) {
} catch(e: Exception) {
Logger.log.log(Level.WARNING, "Couldn't rename address book account", e)
}
// we already have a local address book for this remote collection, don't take into consideration anymore
@@ -0,0 +1,318 @@
/*
* Copyright © Ricki Hirner (bitfire web engineering).
* All rights reserved. This program and the accompanying materials
* are made available under the terms of the GNU Public License v3.0
* which accompanies this distribution, and is available at
* http://www.gnu.org/licenses/gpl.html
*/
package at.bitfire.davdroid.syncadapter
import android.accounts.Account
import android.content.ContentResolver
import android.content.Context
import android.content.SyncResult
import android.os.Bundle
import at.bitfire.dav4android.DavCollection
import at.bitfire.dav4android.DavResource
import at.bitfire.dav4android.DavResponse
import at.bitfire.dav4android.exception.ConflictException
import at.bitfire.dav4android.exception.DavException
import at.bitfire.dav4android.exception.HttpException
import at.bitfire.dav4android.exception.PreconditionFailedException
import at.bitfire.dav4android.property.GetCTag
import at.bitfire.dav4android.property.GetETag
import at.bitfire.dav4android.property.SyncToken
import at.bitfire.davdroid.AccountSettings
import at.bitfire.davdroid.HttpClient
import at.bitfire.davdroid.log.Logger
import at.bitfire.davdroid.model.SyncState
import at.bitfire.davdroid.resource.LocalCollection
import at.bitfire.davdroid.resource.LocalResource
import at.bitfire.davdroid.settings.ISettings
import okhttp3.HttpUrl
import okhttp3.RequestBody
import java.util.logging.Level
abstract class BaseDavSyncManager<ResourceType: LocalResource<*>, out CollectionType: LocalCollection<ResourceType>, RemoteType: DavCollection>(
context: Context,
settings: ISettings,
account: Account,
accountSettings: AccountSettings,
extras: Bundle,
authority: String,
syncResult: SyncResult,
localCollection: CollectionType
): SyncManager<ResourceType, CollectionType>(context, settings, account, accountSettings, extras, authority, syncResult, localCollection), AutoCloseable {
companion object {
/**
* How many updates are requested per collection-sync REPORT. We use a rather small number
* because the response has to be parsed and held in memory, which is a spare resource on
* handheld devices.
*/
@Suppress("unused")
const val COLLECTION_SYNC_LIMIT = 1
}
protected val httpClient = HttpClient.Builder(context, settings, accountSettings).build()
protected lateinit var collectionURL: HttpUrl
protected lateinit var davCollection: RemoteType
protected var hasCollectionSync = false
override fun close() {
httpClient.close()
}
override fun prepare() = true
protected fun syncState(dav: DavResponse) =
dav[SyncToken::class.java]?.token?.let {
SyncState(SyncState.Type.SYNC_TOKEN, it)
} ?:
dav[GetCTag::class.java]?.cTag?.let {
SyncState(SyncState.Type.CTAG, it)
}
override fun querySyncState(): SyncState? =
davCollection.propfind(0, GetCTag.NAME, SyncToken.NAME).use {
syncState(it)
}
/**
* Process locally deleted entries (DELETE them on the server as well).
* Checks for thread interruption before each request to allow quick sync cancellation.
*/
override fun processLocallyDeleted(): Boolean {
var numDeleted = 0
// Remove locally deleted entries from server (if they have a name, i.e. if they were uploaded before),
// but only if they don't have changed on the server. Then finally remove them from the local address book.
val localList = localCollection.findDeleted()
for (local in localList)
useLocal(local, {
abortIfCancelled()
val fileName = local.fileName
if (fileName != null) {
Logger.log.info("$fileName has been deleted locally -> deleting from server")
useRemote(DavResource(httpClient.okHttpClient, collectionURL.newBuilder().addPathSegment(fileName).build()), { remote ->
try {
remote.delete(local.eTag)
numDeleted++
} catch (e: HttpException) {
Logger.log.warning("Couldn't delete $fileName from server; ignoring (may be downloaded again)")
}
})
} else
Logger.log.info("Removing local record #${local.id} which has been deleted locally and was never uploaded")
local.delete()
syncResult.stats.numDeletes++
})
Logger.log.info("Removed $numDeleted record(s) from server")
return numDeleted > 0
}
protected abstract fun prepareUpload(resource: ResourceType): RequestBody
/**
* Uploads dirty records to the server, using a PUT request for each record.
* Checks for thread interruption before each request to allow quick sync cancellation.
*/
override fun uploadDirty(): Boolean {
var numUploaded = 0
// upload dirty contacts
for (local in localCollection.findDirty())
useLocal(local, {
abortIfCancelled()
if (local.fileName == null) {
Logger.log.fine("Generating file name/UID for local record #${local.id}")
local.assignNameAndUID()
}
val fileName = local.fileName!!
useRemote(DavResource(httpClient.okHttpClient, collectionURL.newBuilder().addPathSegment(fileName).build()), { remote ->
// generate entity to upload (VCard, iCal, whatever)
val body = prepareUpload(local)
var response: DavResponse? = null
try {
response = if (local.eTag == null) {
Logger.log.info("Uploading new record $fileName")
remote.put(body, null, true)
} else {
Logger.log.info("Uploading locally modified record $fileName")
remote.put(body, local.eTag, false)
}
numUploaded++
} catch(e: ConflictException) {
// we can't interact with the user to resolve the conflict, so we treat 409 like 412
Logger.log.log(Level.INFO, "Edit conflict, ignoring", e)
} catch(e: PreconditionFailedException) {
Logger.log.log(Level.INFO, "Resource has been modified on the server before upload, ignoring", e)
} finally {
response?.close()
}
val newETag = response?.get(GetETag::class.java)
val eTag: String?
if (newETag != null) {
eTag = newETag.eTag
Logger.log.fine("Received new ETag=$eTag after uploading")
} else {
Logger.log.fine("Didn't receive new ETag after uploading, setting to null")
eTag = null
}
local.clearDirty(eTag)
})
})
Logger.log.info("Sent $numUploaded record(s) to server")
return numUploaded > 0
}
override fun syncRequired(state: SyncState?): Boolean {
if (syncAlgorithm() == SyncAlgorithm.PROPFIND_REPORT && extras.containsKey(ContentResolver.SYNC_EXTRAS_MANUAL)) {
Logger.log.info("Manual sync in PROPFIND/REPORT mode, forcing sync")
return true
}
val localState = localCollection.lastSyncState
Logger.log.info("Local sync state = $localState, remote sync state = $state")
return when {
state?.type == SyncState.Type.SYNC_TOKEN -> {
val lastKnownToken = localState?.takeIf { it.type == SyncState.Type.SYNC_TOKEN }?.value
lastKnownToken != state.value
}
state?.type == SyncState.Type.CTAG -> {
val lastKnownCTag = localState?.takeIf { it.type == SyncState.Type.CTAG }?.value
lastKnownCTag != state.value
}
else ->
true
}
}
override fun resetPresentRemotely() {
val number = localCollection.markNotDirty(0)
Logger.log.info("Number of local non-dirty entries: $number")
}
override fun compareLocalRemote(remoteResources: Map<String, DavResponse>): RemoteChanges {
/* check which resources are
1. updated remotely -> update
2. added remotely -> update
3. not present remotely anymore -> ignore (because they will be deleted by deleteObsolete()
*/
val changes = RemoteChanges(null, false)
for ((name, remote) in remoteResources)
useLocal(localCollection.findByName(name), { local ->
if (local == null) {
Logger.log.info("$name has been added remotely")
changes.updated += remote
} else {
val localETag = local.eTag
val remoteETag = remote[GetETag::class.java]?.eTag ?: throw DavException("Server didn't provide ETag")
if (localETag == remoteETag)
Logger.log.fine("$name has not been changed on server (ETag still $remoteETag)")
else {
Logger.log.info("$name has been changed on server (current ETag=$remoteETag, last known ETag=$localETag)")
changes.updated += remote
}
// mark as remotely present, so that this resource won't be deleted at the end
local.updateFlags(LocalResource.FLAG_REMOTELY_PRESENT)
}
})
return changes
}
override fun listRemoteChanges(state: SyncState?): RemoteChanges {
val dav = /*try {
davCollection.reportChanges(
state?.takeIf { state.type == SyncState.Type.SYNC_TOKEN }?.value,
false, COLLECTION_SYNC_LIMIT,
GetETag.NAME)
} catch(e: HttpException) {
if (e.status == 507)*/
// some servers don't like the limit, try again without
davCollection.reportChanges(
state?.takeIf { state.type == SyncState.Type.SYNC_TOKEN }?.value,
false, null,
GetETag.NAME)
/*else
throw e
}*/
dav.use {
val changes = RemoteChanges(dav.syncToken?.let { SyncState.fromSyncToken(it) }, dav.furtherResults)
for (member in dav.members) {
// ignore if resource is existing locally with same ETag
// (happens at initial sync, when resources are already present locally)
var skip = false
localCollection.findByName(member.fileName())?.let { local ->
member[GetETag::class.java]?.eTag?.let { remoteETag ->
if (local.eTag == remoteETag) {
Logger.log.info("${local.fileName} is already available with ETag $remoteETag, skipping update")
skip = true
// mark as remotely present, so that this resource won't be deleted at the end
local.updateFlags(LocalResource.FLAG_REMOTELY_PRESENT)
}
}
}
if (!skip)
changes.updated += member
}
for (member in dav.removedMembers)
changes.deleted += member.fileName()
Logger.log.log(Level.INFO, "Received list of changed/removed resources", changes)
return changes
}
}
override fun deleteNotPresentRemotely() {
val removed = localCollection.removeNotDirtyMarked(0)
Logger.log.info("Removed $removed local resources which are not present on the server anymore")
}
override fun postProcess() {
}
protected fun<T: LocalResource<*>?, R> useLocal(local: T, body: (T) -> R): R {
local?.let { currentLocalResource.push(it) }
val result = body(local)
local?.let { currentLocalResource.pop() }
return result
}
protected fun<T: DavResource, R> useRemote(remote: T, body: (T) -> R): R {
currentRemoteResource.push(remote.location)
val result = body(remote)
currentRemoteResource.pop()
return result
}
protected fun<T> useRemote(remote: DavResponse, body: (DavResponse) -> T): T {
currentRemoteResource.push(remote.url)
val result = body(remote)
currentRemoteResource.pop()
return result
}
protected fun<R> useRemoteCollection(body: (RemoteType) -> R) =
useRemote(davCollection, body)
}
@@ -9,18 +9,15 @@
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.DavResponse
import at.bitfire.dav4android.exception.DavException
import at.bitfire.dav4android.property.CalendarData
import at.bitfire.dav4android.property.GetCTag
import at.bitfire.dav4android.property.GetETag
import at.bitfire.dav4android.property.*
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
@@ -30,7 +27,6 @@ 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
@@ -38,7 +34,7 @@ import java.util.*
import java.util.logging.Level
/**
* Synchronization manager for CalDAV collections; handles events ({@code VEVENT}).
* Synchronization manager for CalDAV collections; handles events (VEVENT)
*/
class CalendarSyncManager(
context: Context,
@@ -48,53 +44,58 @@ class CalendarSyncManager(
extras: Bundle,
authority: String,
syncResult: SyncResult,
val provider: ContentProviderClient,
val localCalendar: LocalCalendar
): SyncManager(context, settings, account, accountSettings, extras, authority, syncResult, "calendar/${localCalendar.id}") {
localCalendar: LocalCalendar
): BaseDavSyncManager<LocalEvent, LocalCalendar, DavCalendar>(context, settings, account, accountSettings, extras, authority, syncResult, localCalendar) {
val MAX_MULTIGET = 20
init {
localCollection = localCalendar
companion object {
const val MULTIGET_MAX_RESOURCES = 30
}
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
if (!super.prepare())
return false
collectionURL = HttpUrl.parse(localCollection.name ?: return false) ?: return false
davCollection = DavCalendar(httpClient.okHttpClient, collectionURL)
// if there are dirty exceptions for events, mark their master events as dirty, too
localCollection.processDirtyExceptions()
return true
}
override fun queryCapabilities() {
davCollection.propfind(0, GetCTag.NAME)
}
override fun queryCapabilities() =
useRemoteCollection {
it.propfind(0, SupportedReportSet.NAME, GetCTag.NAME, SyncToken.NAME).use { dav ->
dav[SupportedReportSet::class.java]?.let {
hasCollectionSync = it.reports.contains(SupportedReportSet.SYNC_COLLECTION)
}
Logger.log.info("Server supports Collection Sync: $hasCollectionSync")
override fun prepareDirty() {
super.prepareDirty()
localCalendar.processDirtyExceptions()
}
syncState(dav)
}
}
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)
override fun syncAlgorithm() = if (accountSettings.getTimeRangePastDays() != null || !hasCollectionSync)
SyncAlgorithm.PROPFIND_REPORT
else
SyncAlgorithm.COLLECTION_SYNC
val os = ByteArrayOutputStream()
event.write(os)
override fun prepareUpload(resource: LocalEvent): RequestBody = useLocal(resource, {
val event = requireNotNull(resource.event)
Logger.log.log(Level.FINE, "Preparing upload of event ${resource.fileName}", event)
return RequestBody.create(
DavCalendar.MIME_ICALENDAR,
os.toByteArray()
)
} else
throw IllegalArgumentException("resource must be a LocalEvent")
}
val os = ByteArrayOutputStream()
event.write(os)
override fun listRemote() {
RequestBody.create(
DavCalendar.MIME_ICALENDAR_UTF8,
os.toByteArray()
)
})
override fun listAllRemote(): Map<String, DavResponse> {
// calculate time range limits
var limitStart: Date? = null
accountSettings.getTimeRangePastDays()?.let { pastDays ->
@@ -103,77 +104,74 @@ class CalendarSyncManager(
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(davCollection.members.size)
for (iCal in davCollection.members) {
val fileName = iCal.fileName()
Logger.log.fine("Found remote VEVENT: $fileName")
remoteResources[fileName] = iCal
return useRemoteCollection { remote ->
// fetch list of remote VEVENTs and build hash table to index file name
Logger.log.info("Querying events since $limitStart")
remote.calendarQuery("VEVENT", limitStart, null).use { dav ->
val result = LinkedHashMap<String, DavResponse>(dav.members.size)
for (iCal in dav.members) {
val fileName = iCal.fileName()
Logger.log.fine("Found remote VEVENT: $fileName")
result[fileName] = iCal
}
result
}
}
currentDavResource = null
}
override fun downloadRemote() {
Logger.log.info("Downloading ${toDownload.size} events ($MAX_MULTIGET at once)")
override fun processRemoteChanges(changes: RemoteChanges) {
for (name in changes.deleted)
localCollection.findByName(name)?.let {
Logger.log.info("Deleting local event $name")
useLocal(it, { local -> local.delete() })
syncResult.stats.numDeletes++
}
// download new/updated iCalendars from server
for (bunch in ListUtils.partition(toDownload.toList(), MAX_MULTIGET)) {
if (Thread.interrupted())
return
val toDownload = changes.updated.map { it.url }
Logger.log.info("Downloading ${toDownload.size} resources ($MULTIGET_MAX_RESOURCES at once)")
Logger.log.info("Downloading ${bunch.joinToString(", ")}")
if (bunch.size == 1) {
for (bunch in toDownload.chunked(MULTIGET_MAX_RESOURCES)) {
if (bunch.size == 1)
// only one contact, use GET
val remote = bunch.first()
currentDavResource = remote
useRemote(DavResource(httpClient.okHttpClient, bunch.first()), {
it.get(DavCalendar.MIME_ICALENDAR.toString()).use { dav ->
// CalDAV servers MUST return ETag on GET [https://tools.ietf.org/html/rfc4791#section-5.3.4]
val eTag = dav[GetETag::class.java]?.eTag
?: throw DavException("Received CalDAV GET response without ETag for ${dav.url}")
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 {
dav.body?.charStream()?.use { reader ->
processVEvent(dav.fileName(), eTag, reader)
}
}
})
else {
// multiple contacts, use multi-get
val calendar = davCalendar()
currentDavResource = calendar
calendar.multiget(bunch.map { it.location })
useRemoteCollection {
it.multiget(bunch).use { dav ->
// process multiget results
for (remote in davCollection.members) {
currentDavResource = remote
// process multiget results
for (remote in dav.members)
useRemote(remote, {
val eTag = remote[GetETag::class.java]?.eTag
?: throw DavException("Received multi-get response without ETag")
val eTag = (remote.properties[GetETag.NAME] as GetETag?)?.eTag
?: throw DavException("Received multi-get response without ETag")
val calendarData = remote[CalendarData::class.java]
val iCalendar = calendarData?.iCalendar
?: throw DavException("Received multi-get response without task data")
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))
processVEvent(remote.fileName(), eTag, StringReader(iCalendar))
})
}
}
}
currentDavResource = null
abortIfCancelled()
}
}
// helpers
private fun davCalendar() = davCollection as DavCalendar
private fun processVEvent(fileName: String, eTag: String, reader: Reader) {
val events: List<Event>
try {
@@ -187,24 +185,22 @@ class CalendarSyncManager(
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++
}
useLocal(localCollection.findByName(fileName), { local ->
if (local != null) {
Logger.log.info("Updating $fileName in local calendar")
local.eTag = eTag
local.update(newData)
syncResult.stats.numUpdates++
} else {
Logger.log.info("Adding $fileName to local calendar")
useLocal(LocalEvent(localCollection, newData, fileName, eTag, LocalResource.FLAG_REMOTELY_PRESENT), {
it.add()
})
syncResult.stats.numInserts++
}
})
} else
Logger.log.severe("Received VCALENDAR with not exactly one VEVENT with UID, but without RECURRENCE-ID; ignoring $fileName")
currentLocalResource = null
Logger.log.info("Received VCALENDAR with not exactly one VEVENT with UID and without RECURRENCE-ID; ignoring $fileName")
}
}
@@ -20,6 +20,7 @@ import at.bitfire.davdroid.model.ServiceDB.Collections
import at.bitfire.davdroid.resource.LocalCalendar
import at.bitfire.davdroid.settings.ISettings
import at.bitfire.ical4android.AndroidCalendar
import okhttp3.HttpUrl
import java.util.logging.Level
class CalendarsSyncAdapterService: SyncAdapterService() {
@@ -51,7 +52,7 @@ class CalendarsSyncAdapterService: SyncAdapterService() {
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, settings, account, accountSettings, extras, authority, syncResult, provider, calendar).use {
CalendarSyncManager(context, settings, account, accountSettings, extras, authority, syncResult, calendar).use {
it.performSync()
}
}
@@ -75,8 +76,8 @@ class CalendarsSyncAdapterService: SyncAdapterService() {
null
}
fun remoteCalendars(service: Long?): MutableMap<String, CollectionInfo> {
val collections = mutableMapOf<String, CollectionInfo>()
fun remoteCalendars(service: Long?): MutableMap<HttpUrl, CollectionInfo> {
val collections = mutableMapOf<HttpUrl, CollectionInfo>()
service?.let {
db.query(Collections._TABLE, null,
"${Collections.SERVICE_ID}=? AND ${Collections.SUPPORTS_VEVENT}!=0 AND ${Collections.SYNC}",
@@ -100,7 +101,8 @@ class CalendarsSyncAdapterService: SyncAdapterService() {
val updateColors = settings.getManageCalendarColors()
for (calendar in AndroidCalendar.find(account, provider, LocalCalendar.Factory, null, null))
calendar.name?.let { url ->
calendar.name?.let {
val url = HttpUrl.parse(it)!!
val info = remote[url]
if (info == null) {
Logger.log.log(Level.INFO, "Deleting obsolete local calendar", url)
@@ -24,7 +24,7 @@ import java.util.logging.Level
class ContactsSyncAdapterService: SyncAdapterService() {
companion object {
val PREVIOUS_GROUP_METHOD = "previous_group_method"
const val PREVIOUS_GROUP_METHOD = "previous_group_method"
}
override fun syncAdapter() = ContactsSyncAdapter(this)
@@ -37,7 +37,7 @@ class ContactsSyncAdapterService: SyncAdapterService() {
override fun sync(settings: ISettings, account: Account, extras: Bundle, authority: String, provider: ContentProviderClient, syncResult: SyncResult) {
try {
val addressBook = LocalAddressBook(context, account, provider)
val accountSettings = AccountSettings(context, settings, addressBook.getMainAccount())
val accountSettings = AccountSettings(context, settings, addressBook.mainAccount)
// handle group method change
val groupMethod = accountSettings.getGroupMethod().name
@@ -49,8 +49,8 @@ class ContactsSyncAdapterService: SyncAdapterService() {
provider.delete(addressBook.syncAdapterURI(ContactsContract.RawContacts.CONTENT_URI), null, null)
provider.delete(addressBook.syncAdapterURI(ContactsContract.Groups.CONTENT_URI), null, null)
// reset CTag
addressBook.setCTag(null)
// reset sync state
addressBook.syncState = null
}
}
accountSettings.accountManager.setUserData(account, PREVIOUS_GROUP_METHOD, groupMethod)
@@ -58,8 +58,8 @@ class ContactsSyncAdapterService: SyncAdapterService() {
if (!extras.containsKey(ContentResolver.SYNC_EXTRAS_MANUAL) && !checkSyncConditions(accountSettings))
return
Logger.log.info("Synchronizing address book: ${addressBook.getURL()}")
Logger.log.info("Taking settings from: ${addressBook.getMainAccount()}")
Logger.log.info("Synchronizing address book: ${addressBook.url}")
Logger.log.info("Taking settings from: ${addressBook.mainAccount}")
ContactsSyncManager(context, settings, account, accountSettings, extras, authority, syncResult, provider, addressBook).use {
it.performSync()
@@ -12,68 +12,66 @@ 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 android.support.v4.app.NotificationCompat
import at.bitfire.dav4android.DavAddressBook
import at.bitfire.dav4android.DavResource
import at.bitfire.dav4android.DavResponse
import at.bitfire.dav4android.exception.DavException
import at.bitfire.dav4android.property.*
import at.bitfire.davdroid.*
import at.bitfire.davdroid.AccountSettings
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.davdroid.model.SyncState
import at.bitfire.davdroid.resource.*
import at.bitfire.davdroid.settings.ISettings
import at.bitfire.davdroid.ui.NotificationUtils
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>
* Synchronization manager for CardDAV collections; handles contacts and groups.
*
* <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 remote 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>
* Group handling differs according to the {@link #groupMethod}. There are two basic methods to
* handle/manage groups:
*
* 1. CATEGORIES: groups memberships are attached to each contact and represented as
* "category". When a group is dirty or has been deleted, all its members have to be set to
* dirty, too (because they have to be uploaded without the respective category). This
* is done in [uploadDirty]. Empty groups can be deleted without further processing,
* which is done in [postProcess] because groups may become empty after downloading
* updated remote contacts.
*
* 2. Groups as separate VCards: individual and group contacts (with a list of member UIDs) are
* distinguished. When a local group is dirty, its members don't need to be set to dirty.
*
* However, when a contact is dirty, it has
* to be checked whether its group memberships have changed. In this case, the respective
* groups have to be set to dirty. For instance, if contact A is in group G and H, and then
* group membership of G is removed, the contact will be set to dirty because of the changed
* [android.provider.ContactsContract.CommonDataKinds.GroupMembership]. 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 [android.provider.ContactsContract.CommonDataKinds.GroupMembership]
* data rows in respective [at.bitfire.vcard4android.CachedGroupMembership] rows.
* If the cached group memberships are not the same as the current group member ships, the
* difference set (in our example G, because its in the cached memberships, but not in the
* actual ones) is marked as dirty. This is done in [uploadDirty].
*
* When downloading remote contacts, groups (+ member information) may be received
* by the actual members. Thus, the member lists have to be cached until all VCards
* are received. This is done by caching the member UIDs of each group in
* [LocalGroup.COLUMN_PENDING_MEMBERS]. In [postProcess],
* these "pending memberships" are assigned to the actual contacts and then cleaned up.
*/
class ContactsSyncManager(
context: Context,
@@ -84,100 +82,102 @@ class ContactsSyncManager(
authority: String,
syncResult: SyncResult,
val provider: ContentProviderClient,
private val localAddressBook: LocalAddressBook
): SyncManager(context, settings, account, accountSettings, extras, authority, syncResult, "addressBook") {
localAddressBook: LocalAddressBook
): BaseDavSyncManager<LocalAddress, LocalAddressBook, DavAddressBook>(context, settings, account, accountSettings, extras, authority, syncResult, localAddressBook) {
companion object {
private val MAX_MULTIGET = 10
private const val MULTIGET_MAX_RESOURCES = 10
infix fun <T> Set<T>.disjunct(other: Set<T>) = (this - other) union (other - this)
}
private val readOnly = localAddressBook.getReadOnly()
private val readOnly = localAddressBook.readOnly
private var numDiscarded = 0
private var hasVCard4 = false
private val groupMethod = accountSettings.getGroupMethod()
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 (!super.prepare())
return false
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N && Build.VERSION.SDK_INT < Build.VERSION_CODES.O) {
// workaround for Android 7 which sets DIRTY flag when only meta-data is changed
val reallyDirty = localAddressBook.verifyDirty()
val deleted = localAddressBook.getDeleted().size
val reallyDirty = localCollection.verifyDirty()
val deleted = localCollection.findDeleted().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
collectionURL = HttpUrl.parse(localCollection.url) ?: return false
davCollection = DavAddressBook(httpClient.okHttpClient, 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")
override fun queryCapabilities(): SyncState? {
Logger.log.info("Contact group method: $groupMethod")
// in case of GROUP_VCARDs, treat groups as contacts in the local address book
localAddressBook.includeGroups = groupMethod == GroupMethod.GROUP_VCARDS
localCollection.includeGroups = groupMethod == GroupMethod.GROUP_VCARDS
return useRemoteCollection {
it.propfind(0, SupportedAddressData.NAME, SupportedReportSet.NAME, GetCTag.NAME, SyncToken.NAME).use { dav ->
dav[SupportedAddressData::class.java]?.let {
hasVCard4 = it.hasVCard4()
}
Logger.log.info("Server supports vCard/4: $hasVCard4")
dav[SupportedReportSet::class.java]?.let {
hasCollectionSync = it.reports.contains(SupportedReportSet.SYNC_COLLECTION)
}
Logger.log.info("Server supports Collection Sync: $hasCollectionSync")
syncState(dav)
}
}
}
override fun processLocallyDeleted() {
override fun syncAlgorithm() = if (hasCollectionSync)
SyncAlgorithm.COLLECTION_SYNC
else
SyncAlgorithm.PROPFIND_REPORT
override fun processLocallyDeleted(): Boolean {
if (readOnly) {
for (group in localAddressBook.getDeletedGroups()) {
for (group in localCollection.findDeletedGroups()) {
Logger.log.warning("Restoring locally deleted group (read-only address book!)")
group.resetDeleted()
useLocal(group, { it.resetDeleted() })
numDiscarded++
}
for (contact in localAddressBook.getDeletedContacts()) {
for (contact in localCollection.findDeletedContacts()) {
Logger.log.warning("Restoring locally deleted contact (read-only address book!)")
contact.resetDeleted()
useLocal(contact, { it.resetDeleted() })
numDiscarded++
}
if (numDiscarded > 0)
notifyDiscardedChange()
return false
} else
// mirror deletions to remote collection (DELETE)
super.processLocallyDeleted()
return super.processLocallyDeleted()
}
override fun prepareDirty() {
// generate UID/file name for newly created contacts
super.prepareDirty()
override fun uploadDirty(): Boolean {
if (readOnly) {
for (group in localAddressBook.getDirtyGroups()) {
for (group in localCollection.findDirtyGroups()) {
Logger.log.warning("Resetting locally modified group to ETag=null (read-only address book!)")
group.clearDirty(null)
useLocal(group, { it.clearDirty(null) })
numDiscarded++
}
for (contact in localAddressBook.getDirtyContacts()) {
for (contact in localCollection.findDirtyContacts()) {
Logger.log.warning("Resetting locally modified contact to ETag=null (read-only address book!)")
contact.clearDirty(null)
useLocal(contact, { it.clearDirty(null) })
numDiscarded++
}
@@ -189,25 +189,27 @@ class ContactsSyncManager(
/* groups memberships are represented as contact CATEGORIES */
// groups with DELETED=1: set all members to dirty, then remove group
for (group in localAddressBook.getDeletedGroups()) {
for (group in localCollection.findDeletedGroups()) {
Logger.log.fine("Finally removing group $group")
// useless because Android deletes group memberships as soon as a group is set to DELETED:
// group.markMembersDirty()
group.delete()
useLocal(group, { it.delete() })
}
// groups with DIRTY=1: mark all memberships as dirty, then clean DIRTY flag of group
for (group in localAddressBook.getDirtyGroups()) {
for (group in localCollection.findDirtyGroups()) {
Logger.log.fine("Marking members of modified group $group as dirty")
group.markMembersDirty()
group.clearDirty(null)
useLocal(group, {
it.markMembersDirty()
it.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())
val batch = BatchOperation(localCollection.provider!!)
for (contact in localCollection.findDirtyContacts())
try {
Logger.log.fine("Looking for changed group memberships of contact ${contact.fileName}")
val cachedGroups = contact.getCachedGroupMemberships()
@@ -215,7 +217,7 @@ class ContactsSyncManager(
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)))
ContentProviderOperation.newUpdate(localCollection.syncAdapterURI(ContentUris.withAppendedId(Groups.CONTENT_URI, groupID)))
.withValue(Groups.DIRTY, 1)
.withYieldAllowed(true)
))
@@ -225,25 +227,26 @@ class ContactsSyncManager(
batch.commit()
}
}
// generate UID/file name for newly created contacts
return super.uploadDirty()
}
private fun notifyDiscardedChange() {
val notification = NotificationCompat.Builder(context, NotificationUtils.CHANNEL_SYNC_STATUS)
val notification = NotificationUtils.newBuilder(context, NotificationUtils.CHANNEL_SYNC_STATUS)
.setSmallIcon(R.drawable.ic_delete_notification)
.setLargeIcon(App.getLauncherBitmap(context))
.setContentTitle(context.getString(R.string.sync_contacts_read_only_address_book))
.setContentText(context.resources.getQuantityString(R.plurals.sync_contacts_local_contact_changes_discarded, numDiscarded, numDiscarded))
.setNumber(numDiscarded)
.setSubText(account.name)
.setCategory(NotificationCompat.CATEGORY_ERROR)
.setPriority(NotificationCompat.PRIORITY_LOW)
.setCategory(NotificationCompat.CATEGORY_STATUS)
.setPriority(NotificationCompat.PRIORITY_MIN)
.setLocalOnly(true)
.setAutoCancel(true)
.build()
val nm = NotificationUtils.createChannels(context)
nm.notify("discarded_${account.name}", 0, notification)
notificationManager.notify("discarded_${account.name}", 0, notification)
}
override fun prepareUpload(resource: LocalResource): RequestBody {
override fun prepareUpload(resource: LocalAddress): RequestBody = useLocal(resource, {
val contact: Contact
if (resource is LocalContact) {
contact = resource.contact!!
@@ -251,111 +254,107 @@ class ContactsSyncManager(
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)
}
provider.query(
localCollection.syncAdapterURI(ContentUris.withAppendedId(Groups.CONTENT_URI, groupID)),
arrayOf(Groups.TITLE), null, null, null
)?.use { cursor ->
if (cursor.moveToNext()) {
val title = cursor.getString(0)
if (!title.isNullOrEmpty())
contact.categories.add(title)
}
} 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")
throw IllegalArgumentException("resource must be LocalContact or 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(
RequestBody.create(
if (hasVCard4) DavAddressBook.MIME_VCARD4 else DavAddressBook.MIME_VCARD3_UTF8,
os.toByteArray()
)
}
override fun listRemote() {
val addressBook = davAddressBook()
currentDavResource = addressBook
})
override fun listAllRemote() = useRemoteCollection {
// fetch list of remote VCards and build hash table to index file name
addressBook.propfind(1, ResourceType.NAME, GetETag.NAME)
it.propfind(1, ResourceType.NAME, GetETag.NAME).use { dav ->
val result = LinkedHashMap<String, DavResponse>(dav.members.size)
for (vCard in dav.members) {
// ignore member collections
var ignore = false
vCard[ResourceType::class.java]?.let { type ->
if (type.types.contains(ResourceType.COLLECTION))
ignore = true
}
if (ignore)
continue
remoteResources = HashMap(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
val fileName = vCard.fileName()
Logger.log.fine("Found remote VCard: $fileName")
result[fileName] = vCard
}
result
}
currentDavResource = null
}
override fun downloadRemote() {
Logger.log.info("Downloading ${toDownload.size} contacts ($MAX_MULTIGET at once)")
override fun processRemoteChanges(changes: RemoteChanges) {
for (name in changes.deleted)
localCollection.findByName(name)?.let {
Logger.log.info("Deleting local address $name")
useLocal(it, { it.delete() })
syncResult.stats.numDeletes++
}
val toDownload = changes.updated.map { it.url }
Logger.log.info("Downloading ${toDownload.size} resources ($MULTIGET_MAX_RESOURCES at once)")
// prepare downloader which may be used to download external resource like contact photos
val downloader = ResourceDownloader(collectionURL)
// download new/updated VCards from server
for (bunch in ListUtils.partition(toDownload.toList(), MAX_MULTIGET)) {
if (Thread.interrupted())
return
Logger.log.info("Downloading ${bunch.joinToString(", ")}")
if (bunch.size == 1) {
for (bunch in toDownload.chunked(CalendarSyncManager.MULTIGET_MAX_RESOURCES)) {
if (bunch.size == 1)
// only one contact, use GET
val remote = bunch.first()
currentDavResource = remote
useRemote(DavResource(httpClient.okHttpClient, bunch.first()), {
it.get("text/vcard;version=4.0, text/vcard;charset=utf-8;q=0.8, text/vcard;q=0.5").use { dav ->
// CardDAV servers MUST return ETag on GET [https://tools.ietf.org/html/rfc6352#section-6.3.2.3]
val eTag = dav[GetETag::class.java]?.eTag
?: throw DavException("Received CardDAV GET response without ETag for ${dav.url}")
val body = remote.get("text/vcard;version=4.0, text/vcard;charset=utf-8;q=0.8, text/vcard;q=0.5")
dav.body?.charStream()?.use { reader ->
processVCard(dav.fileName(), eTag, reader, downloader)
}
}
})
// 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 {
else {
// multiple contacts, use multi-get
val addressBook = davAddressBook()
currentDavResource = addressBook
addressBook.multiget(bunch.map { it.location }, hasVCard4)
useRemoteCollection {
it.multiget(bunch, hasVCard4).use { dav ->
// process multi-get results
for (remote in dav.members)
useRemote(remote, {
val eTag = remote[GetETag::class.java]?.eTag
?: throw DavException("Received multi-get response without ETag")
// process multi-get results
for (remote in davCollection.members) {
currentDavResource = remote
val addressData = remote[AddressData::class.java]
val vCard = addressData?.vCard
?: throw DavException("Received multi-get response without address data")
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)
processVCard(remote.fileName(), eTag, StringReader(vCard), downloader)
})
}
}
}
currentDavResource = null
abortIfCancelled()
}
}
@@ -365,20 +364,18 @@ class ContactsSyncManager(
// remove empty groups
Logger.log.info("Removing empty groups")
localAddressBook.removeEmptyGroups()
localCollection.removeEmptyGroups()
} else {
/* VCard4 group handling: there are group contacts and individual contacts */
Logger.log.info("Assigning memberships of downloaded contact groups")
LocalGroup.applyPendingMemberships(localAddressBook)
LocalGroup.applyPendingMemberships(localCollection)
}
}
// 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)
@@ -396,71 +393,69 @@ class ContactsSyncManager(
}
// 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)
useLocal(localCollection.findByName(fileName), {
var local = it
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++
if (local is LocalGroup && newData.group) {
// update group
local.eTag = eTag
local.flags = LocalResource.FLAG_REMOTELY_PRESENT
local.update(newData)
syncResult.stats.numUpdates++
} else if (local is LocalContact && !newData.group) {
// update contact
local.eTag = eTag
local.update(newData)
syncResult.stats.numUpdates++
} else if (local is LocalContact && !newData.group) {
// update contact
local.eTag = eTag
local.flags = LocalResource.FLAG_REMOTELY_PRESENT
local.update(newData)
syncResult.stats.numUpdates++
} 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)
} else {
// group has become an individual contact or vice versa
local.delete()
local = null
}
}
batch.commit()
}
if (local == null) {
if (newData.group) {
Logger.log.log(Level.INFO, "Creating local group", newData)
useLocal(LocalGroup(localCollection, newData, fileName, eTag, LocalResource.FLAG_REMOTELY_PRESENT), {
it.add()
local = it
})
} else {
Logger.log.log(Level.INFO, "Creating local contact", newData)
useLocal(LocalContact(localCollection, newData, fileName, eTag, LocalResource.FLAG_REMOTELY_PRESENT), {
it.add()
local = it
})
}
syncResult.stats.numInserts++
}
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)
if (groupMethod == GroupMethod.CATEGORIES)
(local as? LocalContact)?.let { localContact ->
// VCard3: update group memberships from CATEGORIES
val batch = BatchOperation(provider)
Logger.log.log(Level.FINE, "Removing contact group memberships")
localContact.removeGroupMemberships(batch)
currentLocalResource = null
for (category in localContact.contact!!.categories) {
val groupID = localCollection.findOrCreateGroup(category)
Logger.log.log(Level.FINE, "Adding membership in group $category ($groupID)")
localContact.addToGroup(batch, groupID)
}
batch.commit()
}
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N && Build.VERSION.SDK_INT < Build.VERSION_CODES.O)
// workaround for Android 7 which sets DIRTY flag when only meta-data is changed
(local as? LocalContact)?.updateHashCode(null)
})
}
@@ -484,12 +479,7 @@ class ContactsSyncManager(
}
// authenticate only against a certain host, and only upon request
val username = accountSettings.username()
val password = accountSettings.password()
val builder = if (username != null && password != null)
HttpClient.Builder(context, baseUrl.host(), username, password)
else
HttpClient.Builder(context)
val builder = HttpClient.Builder(context, baseUrl.host(), accountSettings.credentials())
// allow redirects
builder.followRedirects(true)
@@ -5,7 +5,6 @@
* which accompanies this distribution, and is available at
* http://www.gnu.org/licenses/gpl.html
*/
package at.bitfire.davdroid.syncadapter
import android.accounts.AbstractAccountAuthenticator
@@ -8,33 +8,42 @@
package at.bitfire.davdroid.syncadapter
import android.Manifest
import android.accounts.Account
import android.app.PendingIntent
import android.app.Service
import android.content.*
import android.content.pm.PackageManager
import android.net.ConnectivityManager
import android.net.wifi.WifiManager
import android.os.Build
import android.os.Bundle
import android.support.v4.app.NotificationCompat
import android.support.v4.app.NotificationManagerCompat
import android.support.v4.content.ContextCompat
import at.bitfire.davdroid.AccountSettings
import at.bitfire.davdroid.App
import at.bitfire.davdroid.Constants
import at.bitfire.davdroid.R
import at.bitfire.davdroid.log.Logger
import at.bitfire.davdroid.settings.ISettings
import at.bitfire.davdroid.settings.Settings
import at.bitfire.davdroid.ui.AccountActivity
import at.bitfire.davdroid.ui.AccountSettingsActivity
import at.bitfire.davdroid.ui.NotificationUtils
import at.bitfire.davdroid.ui.PermissionsActivity
import java.lang.ref.WeakReference
import java.util.*
import java.util.logging.Level
abstract class SyncAdapterService: Service() {
companion object {
val runningSyncs = Collections.synchronizedSet(mutableSetOf<Pair<String, Account>>())
/** Keep a list of running syncs to block multiple calls at the same time,
* like run by some devices. Weak references are used for the case that a thread
* is terminated and the `finally` block which cleans up [runningSyncs] is not
* executed. */
private val runningSyncs = mutableListOf<WeakReference<Pair<String, Account>>>()
}
abstract protected fun syncAdapter(): AbstractThreadedSyncAdapter
protected abstract fun syncAdapter(): AbstractThreadedSyncAdapter
override fun onBind(intent: Intent?) = syncAdapter().syncAdapterBinder!!
@@ -50,9 +59,12 @@ abstract class SyncAdapterService: Service() {
// prevent multiple syncs of the same authority to be run for the same account
val currentSync = Pair(authority, account)
if (!runningSyncs.add(currentSync)) {
Logger.log.warning("There's already another $authority sync running for $account, aborting")
return
synchronized(runningSyncs) {
if (runningSyncs.any { it.get() == currentSync }) {
Logger.log.warning("There's already another $authority sync running for $account, aborting")
return
}
runningSyncs += WeakReference(currentSync)
}
try {
@@ -67,31 +79,29 @@ abstract class SyncAdapterService: Service() {
return
}
sync(settings, account, extras, authority, provider, syncResult)
//if (runSync) {
SyncManager.cancelNotifications(NotificationManagerCompat.from(context), authority, account)
sync(settings, account, extras, authority, provider, syncResult)
//}
}
Logger.log.info("Sync for $authority complete")
} finally {
runningSyncs -= currentSync
synchronized(runningSyncs) {
runningSyncs.removeAll { it.get() == null || it.get() == currentSync }
}
}
Logger.log.info("Sync for $currentSync finished")
}
override fun onSecurityException(account: Account, extras: Bundle, authority: String, syncResult: SyncResult) {
Logger.log.log(Level.WARNING, "Security exception when opening content provider for $authority")
syncResult.databaseError = true
val intent = Intent(context, PermissionsActivity::class.java)
val intent = Intent(context, AccountActivity::class.java)
intent.putExtra(AccountActivity.EXTRA_ACCOUNT, account)
intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
val notify = NotificationCompat.Builder(context, NotificationUtils.CHANNEL_SYNC_PROBLEMS)
.setSmallIcon(R.drawable.ic_sync_error_notification)
.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 = NotificationUtils.createChannels(context)
nm.notify(Constants.NOTIFICATION_PERMISSIONS, notify)
notifyPermissions(intent)
}
protected fun checkSyncConditions(settings: AccountSettings): Boolean {
@@ -104,6 +114,17 @@ abstract class SyncAdapterService: Service() {
}
settings.getSyncWifiOnlySSIDs()?.let { onlySSIDs ->
// getting the WiFi name requires location permission (and active location services) since Android 8.1
// see https://issuetracker.google.com/issues/70633700
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O_MR1 &&
ContextCompat.checkSelfPermission(context, Manifest.permission.ACCESS_COARSE_LOCATION) != PackageManager.PERMISSION_GRANTED) {
val intent = Intent(context, AccountSettingsActivity::class.java)
intent.putExtra(AccountActivity.EXTRA_ACCOUNT, settings.account)
intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
notifyPermissions(intent)
}
val wifi = context.applicationContext.getSystemService(WIFI_SERVICE) as WifiManager
val info = wifi.connectionInfo
if (info == null || !onlySSIDs.contains(info.ssid.trim('"'))) {
@@ -115,6 +136,18 @@ abstract class SyncAdapterService: Service() {
return true
}
private fun notifyPermissions(intent: Intent) {
val notify = NotificationUtils.newBuilder(context, NotificationUtils.CHANNEL_SYNC_ERRORS)
.setSmallIcon(R.drawable.ic_sync_error_notification)
.setContentTitle(context.getString(R.string.sync_error_permissions))
.setContentText(context.getString(R.string.sync_error_permissions_text))
.setContentIntent(PendingIntent.getActivity(context, 0, intent, PendingIntent.FLAG_UPDATE_CURRENT))
.setCategory(NotificationCompat.CATEGORY_ERROR)
.setAutoCancel(true)
.build()
NotificationManagerCompat.from(context).notify(NotificationUtils.NOTIFY_PERMISSIONS, notify)
}
}
}
@@ -5,42 +5,54 @@
* 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.ContentUris
import android.content.Context
import android.content.Intent
import android.content.SyncResult
import android.net.Uri
import android.os.Bundle
import android.os.RemoteException
import android.provider.CalendarContract
import android.provider.ContactsContract
import android.support.v4.app.NotificationCompat
import at.bitfire.dav4android.DavResource
import at.bitfire.dav4android.exception.*
import at.bitfire.dav4android.property.GetCTag
import at.bitfire.dav4android.property.GetETag
import android.support.v4.app.NotificationManagerCompat
import at.bitfire.dav4android.DavResponse
import at.bitfire.dav4android.Property
import at.bitfire.dav4android.XmlUtils
import at.bitfire.dav4android.exception.DavException
import at.bitfire.dav4android.exception.HttpException
import at.bitfire.dav4android.exception.ServiceUnavailableException
import at.bitfire.dav4android.exception.UnauthorizedException
import at.bitfire.davdroid.AccountSettings
import at.bitfire.davdroid.App
import at.bitfire.davdroid.HttpClient
import at.bitfire.davdroid.BuildConfig
import at.bitfire.davdroid.DavService
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.model.SyncState
import at.bitfire.davdroid.resource.*
import at.bitfire.davdroid.settings.ISettings
import at.bitfire.davdroid.ui.AccountSettingsActivity
import at.bitfire.davdroid.ui.DebugInfoActivity
import at.bitfire.davdroid.ui.NotificationUtils
import at.bitfire.ical4android.CalendarStorageException
import at.bitfire.ical4android.MiscUtils
import at.bitfire.ical4android.TaskProvider
import at.bitfire.vcard4android.ContactsStorageException
import okhttp3.HttpUrl
import okhttp3.RequestBody
import java.io.Closeable
import org.dmfs.tasks.contract.TaskContract
import java.io.IOException
import java.io.InterruptedIOException
import java.security.cert.CertificateException
import java.util.*
import java.util.logging.Level
import javax.net.ssl.SSLHandshakeException
abstract class SyncManager(
abstract class SyncManager<out ResourceType: LocalResource<*>, out CollectionType: LocalCollection<ResourceType>>(
val context: Context,
val settings: ISettings,
val account: Account,
@@ -48,423 +60,468 @@ abstract class SyncManager(
val extras: Bundle,
val authority: String,
val syncResult: SyncResult,
val uniqueCollectionId: String
): Closeable {
val localCollection: CollectionType
): AutoCloseable {
companion object {
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
fun cancelNotifications(manager: NotificationManagerCompat, authority: String, account: Account) =
manager.cancel(notificationTag(authority, account), NotificationUtils.NOTIFY_SYNC_ERROR)
infix fun <T> Set<T>.disjunct(other: Set<T>) = (this - other) union (other - this)
private fun notificationTag(authority: String, account: Account) =
"$authority-${account.name}".hashCode().toString()
}
protected val notificationManager = NotificationUtils.createChannels(context)
private val mainAccount = if (localCollection is LocalAddressBook)
localCollection.mainAccount
else
account
protected lateinit var localCollection: LocalCollection<*>
protected val notificationManager = NotificationManagerCompat.from(context)
protected val notificationTag = notificationTag(authority, mainAccount)
protected val httpClient = HttpClient.Builder(context, settings, accountSettings).build()
protected lateinit var collectionURL: HttpUrl
protected lateinit var davCollection: DavResource
/** Local resource we're currently operating on. Used for error notifications. **/
protected val currentLocalResource = LinkedList<LocalResource<*>>()
/** Remote resource we're currently operating on. Used for error notifications. **/
protected val currentRemoteResource = LinkedList<HttpUrl>()
/** 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>()
protected abstract fun notificationId(): Int
protected abstract fun getSyncErrorTitle(): String
@Suppress("UNUSED_VALUE")
fun performSync() {
// dismiss previous error notifications
notificationManager.cancel(uniqueCollectionId, notificationId())
notificationManager.cancel(notificationTag, NotificationUtils.NOTIFY_SYNC_ERROR)
var syncPhase = SYNC_PHASE_PREPARE
try {
Logger.log.info("Preparing synchronization")
if (!prepare()) {
Logger.log.info("No reason to synchronize, aborting")
return
}
abortIfCancelled()
if (Thread.interrupted())
return
syncPhase = SYNC_PHASE_QUERY_CAPABILITIES
Logger.log.info("Querying capabilities")
queryCapabilities()
Logger.log.info("Querying server capabilities")
var remoteSyncState = queryCapabilities()
abortIfCancelled()
syncPhase = SYNC_PHASE_PROCESS_LOCALLY_DELETED
Logger.log.info("Processing locally deleted entries")
processLocallyDeleted()
Logger.log.info("Sending local deletes/updates to server")
val modificationsSent =
processLocallyDeleted() ||
uploadDirty()
abortIfCancelled()
if (Thread.interrupted())
return
syncPhase = SYNC_PHASE_PREPARE_DIRTY
Logger.log.info("Locally preparing dirty entries")
prepareDirty()
if (modificationsSent || syncRequired(remoteSyncState))
when (syncAlgorithm()) {
SyncAlgorithm.PROPFIND_REPORT -> {
Logger.log.info("Sync algorithm: full listing as one result (PROPFIND/REPORT)")
resetPresentRemotely()
syncPhase = SYNC_PHASE_UPLOAD_DIRTY
Logger.log.info("Uploading dirty entries")
uploadDirty()
// get current sync state
if (modificationsSent)
remoteSyncState = querySyncState()
syncPhase = SYNC_PHASE_CHECK_SYNC_STATE
Logger.log.info("Checking sync state")
if (checkSyncState()) {
syncPhase = SYNC_PHASE_LIST_LOCAL
Logger.log.info("Listing local resources")
listLocal()
// list all entries at current sync state (which may be the same as or newer than remoteSyncState)
Logger.log.info("Listing remote entries")
val remote = listAllRemote()
abortIfCancelled()
if (Thread.interrupted())
return
syncPhase = SYNC_PHASE_LIST_REMOTE
Logger.log.info("Listing remote resources")
listRemote()
Logger.log.info("Comparing local/remote entries")
val changes = compareLocalRemote(remote)
if (Thread.interrupted())
return
syncPhase = SYNC_PHASE_COMPARE_LOCAL_REMOTE
Logger.log.info("Comparing local/remote entries")
compareLocalRemote()
Logger.log.info("Processing remote changes")
processRemoteChanges(changes)
syncPhase = SYNC_PHASE_DOWNLOAD_REMOTE
Logger.log.info("Downloading remote entries")
downloadRemote()
Logger.log.info("Deleting entries which are not present remotely anymore")
deleteNotPresentRemotely()
syncPhase = SYNC_PHASE_POST_PROCESSING
Logger.log.info("Post-processing")
postProcess()
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")
Logger.log.log(Level.INFO, "Saving sync state", remoteSyncState)
localCollection.lastSyncState = remoteSyncState
}
SyncAlgorithm.COLLECTION_SYNC -> {
var initialSync = false
} catch(e: IOException) {
Logger.log.log(Level.WARNING, "I/O exception during sync, trying again later", e)
syncResult.stats.numIoExceptions++
} catch(e: ServiceUnavailableException) {
var syncState = localCollection.lastSyncState?.takeIf { it.type == SyncState.Type.SYNC_TOKEN }
Logger.log.info("Listing changes since $syncState")
var changes: RemoteChanges? = try {
listRemoteChanges(syncState)
} catch(e: HttpException) {
if (e.errors.contains(Property.Name(XmlUtils.NS_WEBDAV, "valid-sync-token"))) {
Logger.log.info("Sync token stale, retrying without sync-token")
syncState = null
listRemoteChanges(null)
} else
throw e
}
if (syncState == null) {
Logger.log.info("Starting initial sync")
initialSync = true
resetPresentRemotely()
}
if (syncState?.initialSync == true) {
Logger.log.info("Continuing initial sync")
initialSync = true
}
while (changes != null) {
Logger.log.info("Processing received changes")
processRemoteChanges(changes)
// save sync state and keep whether we're in initial sync
syncState = changes.state ?: throw DavException("Received sync-collection without sync-token")
syncState.initialSync = initialSync
Logger.log.log(Level.INFO, "Saving sync state", syncState)
localCollection.lastSyncState = syncState
// request next bunch of changes (if available), or exit loop
changes = if (changes.furtherChanges)
listRemoteChanges(syncState)
else {
Logger.log.info("No more changes available on server")
null
}
}
if (initialSync) {
// initial sync is finished, remove all local resources which have
// not been sent by the server
Logger.log.info("Deleting local resources which are not on server (anymore)")
deleteNotPresentRemotely()
// remove initial sync flag
syncState!!.initialSync = false
Logger.log.log(Level.INFO, "Initial sync completed, saving sync state", syncState)
localCollection.lastSyncState = syncState
}
Logger.log.info("Post-processing")
postProcess()
}
}
else
Logger.log.info("Remote collection didn't change, no reason to sync")
}
// sync was cancelled: re-throw to SyncAdapterService
catch (e: InterruptedException) { throw e }
catch (e: InterruptedIOException) { throw e }
// specific I/O errors
catch (e: SSLHandshakeException) {
Logger.log.log(Level.WARNING, "SSL handshake failed", e)
// when a certificate is rejected by cert4android, the cause will be a CertificateException
if (!BuildConfig.customCerts || e.cause !is CertificateException)
notifyException(e)
}
// specific HTTP errors
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, NotificationUtils.CHANNEL_SYNC_PROBLEMS)
builder .setSmallIcon(R.drawable.ic_sync_error_notification)
.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())
}
}
override fun close() {
httpClient.close()
// all others
catch (e: Throwable) { notifyException(e) }
}
/** 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()
protected abstract fun prepare(): Boolean
/**
* Process locally deleted entries (DELETE them on the server as well).
* Checks Thread.interrupted() before each request to allow quick sync cancellation.
* Queries the server for synchronization capabilities like specific report types,
* data formats etc.
*
* Should also query and save the initial sync state (e.g. CTag/sync-token).
*
* @return current sync state
*/
protected open 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.okHttpClient, 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
protected abstract fun queryCapabilities(): SyncState?
/**
* Uploads dirty records to the server, using a PUT request for each record.
* Checks Thread.interrupted() before each request to allow quick sync cancellation.
* Queries the remote sync state of the collection.
*
* @return sync state (may be null)
*/
protected open fun uploadDirty() {
// upload dirty contacts
for (local in localCollection.getDirty()) {
if (Thread.interrupted())
return
currentLocalResource = local
val fileName = local.fileName
val remote = DavResource(httpClient.okHttpClient, 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
}
}
protected abstract fun querySyncState(): SyncState?
/**
* 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>
* Forwards local deletions to the server.
*
* @return whether remote resources have been deleted
*/
protected open fun checkSyncState(): Boolean {
// check CTag (ignore on manual sync)
(davCollection.properties[GetCTag.NAME] as GetCTag?)?.let { remoteCTag = it.cTag }
protected abstract fun processLocallyDeleted(): Boolean
val localCTag = if (extras.containsKey(ContentResolver.SYNC_EXTRAS_MANUAL)) {
Logger.log.info("Manual sync, ignoring CTag")
/**
* Uploads locally modified resources to the server.
*
* @return whether resources have been uploaded
*/
protected abstract fun uploadDirty(): Boolean
/**
* Determines whether a sync is required because there were changes on the server.
* For instance, this method can compare the collection's CTag/sync-token with
* the last known local value.
*
* When local changes have been uploaded ([processLocallyDeleted] and/or
* [uploadDirty] were true), a sync is always required and this method
* should not be evaluated.
*
* @param state remote sync state to compare local sync state with
*
* @return whether data has been changed on the server = whether running the
* sync algorithm is required
*/
protected abstract fun syncRequired(state: SyncState?): Boolean
/**
* Determines which sync algorithm to use.
* @return
* - [SyncAlgorithm.PROPFIND_REPORT]: list all resources (with plain WebDAV
* PROPFIND or specific REPORT requests), then compare and synchronize
* - [SyncAlgorithm.COLLECTION_SYNC]: use incremental collection synchronization (RFC 6578)
*/
protected abstract fun syncAlgorithm(): SyncAlgorithm
/**
* Marks all local resources which shall be taken into consideration for this
* sync as "synchronizing". Purpose of marking is that resources which have been marked
* and are not present remotely anymore can be deleted.
*
* Used together with [deleteNotPresentRemotely].
*/
protected abstract fun resetPresentRemotely()
/**
* Lists all remote resources which should be taken into account for synchronization.
* Will be used if incremental synchronization is not available.
*
* @return map with resource names (like "mycontact.vcf") as keys and the resources
*/
protected abstract fun listAllRemote(): Map<String, DavResponse>
/**
* Compares local resources which are marked for synchronization and remote resources by file name and ETag.
* Remote resources
* + which are not present locally
* + whose ETag has changed since the last sync (i.e. remote ETag != locally known last remote ETag)
* will be saved as "updated" in the result.
*
* Must mark all found remote resources as "present remotely", so that a later execution of
* [deleteNotPresentRemotely] doesn't (locally) delete any currently available remote resources.
*
* @param remoteResources map of remote resource names and resources
*
* @return List of updated resources on the server. The "deleted" list remains empty. Sync
* state will be null.
*/
protected abstract fun compareLocalRemote(remoteResources: Map<String, DavResponse>): RemoteChanges
/**
* Lists remote changes (incremental sync).
*
* Must mark all found remote resources as "present remotely", so that a later execution of
* [deleteNotPresentRemotely] doesn't (locally) delete any currently available remote resources.
*
* @return list of of remote changes together with the sync state after those changes
*/
protected abstract fun listRemoteChanges(state: SyncState?): RemoteChanges
/**
* Processes remote changes:
* + downloads and locally saves remotely updated resources
* + locally deletes remotely deleted resources
*
* Should call [abortIfCancelled] from time to time, for instance
* after downloading a resource.
*
* Must mark downloaded resources as present on server.
*
* @param changes list of remotely updated and deleted resources
*/
protected abstract fun processRemoteChanges(changes: RemoteChanges)
/**
* Locally deletes entries which are
* 1. not dirty and
* 2. not marked as [LocalResource.FLAG_REMOTELY_PRESENT].
*
* Used together with [resetPresentRemotely] when a full listing has been received from
* the server to locally delete resources which are not present remotely (anymore).
*/
protected abstract fun deleteNotPresentRemotely()
/**
* Post-processing of synchronized entries, for instance contact group membership operations.
*/
protected abstract fun postProcess()
/**
* Throws an [InterruptedException] if the current thread has been interrupted,
* most probably because synchronization was cancelled by the user.
*
* @throws InterruptedException (which will be caught by [performSync])
* */
protected fun abortIfCancelled() {
if (Thread.interrupted())
throw InterruptedException("Sync was cancelled")
}
private fun notifyException(e: Throwable) {
val message: String
when (e) {
is IOException,
is InterruptedIOException -> {
Logger.log.log(Level.WARNING, "I/O error", e)
message = context.getString(R.string.sync_error_io, e.localizedMessage)
syncResult.stats.numIoExceptions++
}
is UnauthorizedException -> {
Logger.log.log(Level.SEVERE, "Not authorized anymore", e)
message = context.getString(R.string.sync_error_authentication_failed)
syncResult.stats.numAuthExceptions++
}
is HttpException, is DavException -> {
Logger.log.log(Level.SEVERE, "HTTP/DAV exception", e)
message = context.getString(R.string.sync_error_http_dav, e.localizedMessage)
syncResult.stats.numParseExceptions++ // numIoExceptions would indicate a soft error
}
is CalendarStorageException, is ContactsStorageException, is RemoteException -> {
Logger.log.log(Level.SEVERE, "Couldn't access local storage", e)
message = context.getString(R.string.sync_error_local_storage, e.localizedMessage)
syncResult.databaseError = true
}
else -> {
Logger.log.log(Level.SEVERE, "Unclassified sync error", e)
message = e.localizedMessage ?: e::class.java.simpleName
syncResult.stats.numParseExceptions++
}
}
val contentIntent: Intent
var viewItemAction: NotificationCompat.Action? = null
if (e is UnauthorizedException) {
contentIntent = Intent(context, AccountSettingsActivity::class.java)
contentIntent.putExtra(AccountSettingsActivity.EXTRA_ACCOUNT, account)
} else {
contentIntent = Intent(context, DebugInfoActivity::class.java)
contentIntent.putExtra(DebugInfoActivity.KEY_THROWABLE, e)
contentIntent.putExtra(DebugInfoActivity.KEY_ACCOUNT, account)
contentIntent.putExtra(DebugInfoActivity.KEY_AUTHORITY, authority)
// use current local/remote resource
currentLocalResource.firstOrNull()?.let { local ->
// pass local resource info to debug info
contentIntent.putExtra(DebugInfoActivity.KEY_LOCAL_RESOURCE, local.toString())
// generate "view item" action
viewItemAction = buildViewItemAction(local)
}
currentRemoteResource.firstOrNull()?.let { remote ->
contentIntent.putExtra(DebugInfoActivity.KEY_REMOTE_RESOURCE, remote.toString())
}
}
// to make the PendingIntent unique
contentIntent.data = Uri.parse("davdroid:exception/${e.hashCode()}")
val channel: String
val priority: Int
if (e is IOException) {
channel = NotificationUtils.CHANNEL_SYNC_IO_ERRORS
priority = NotificationCompat.PRIORITY_MIN
} else {
channel = NotificationUtils.CHANNEL_SYNC_ERRORS
priority = NotificationCompat.PRIORITY_DEFAULT
}
val builder = NotificationUtils.newBuilder(context, channel)
builder .setSmallIcon(R.drawable.ic_sync_error_notification)
.setContentTitle(localCollection.title)
.setContentText(message)
.setStyle(NotificationCompat.BigTextStyle(builder).bigText(message))
.setSubText(mainAccount.name)
.setOnlyAlertOnce(true)
.setContentIntent(PendingIntent.getActivity(context, 0, contentIntent, PendingIntent.FLAG_UPDATE_CURRENT))
.setPriority(priority)
.setCategory(NotificationCompat.CATEGORY_ERROR)
viewItemAction?.let { builder.addAction(it) }
builder.addAction(buildRetryAction())
notificationManager.notify(notificationTag, NotificationUtils.NOTIFY_SYNC_ERROR, builder.build())
}
private fun buildRetryAction(): NotificationCompat.Action {
val retryIntent = Intent(context, DavService::class.java)
retryIntent.action = DavService.ACTION_FORCE_SYNC
val syncAuthority: String
val syncAccount: Account
if (authority == ContactsContract.AUTHORITY) {
// if this is a contacts sync, retry syncing all address books of the main account
syncAuthority = context.getString(R.string.address_books_authority)
syncAccount = mainAccount
} else {
syncAuthority = authority
syncAccount = account
}
retryIntent.data = Uri.parse("sync://").buildUpon()
.authority(syncAuthority)
.appendPath(syncAccount.type)
.appendPath(syncAccount.name)
.build()
return NotificationCompat.Action(
android.R.drawable.ic_menu_rotate, context.getString(R.string.sync_error_retry),
PendingIntent.getService(context, 0, retryIntent, PendingIntent.FLAG_UPDATE_CURRENT))
}
private fun buildViewItemAction(local: LocalResource<*>): NotificationCompat.Action? {
Logger.log.log(Level.FINE, "Adding view action for local resource", local)
val intent = local.id?.let { id ->
when (local) {
is LocalContact ->
Intent(Intent.ACTION_VIEW, ContentUris.withAppendedId(ContactsContract.RawContacts.CONTENT_URI, id))
is LocalEvent ->
Intent(Intent.ACTION_VIEW, ContentUris.withAppendedId(CalendarContract.Events.CONTENT_URI, id))
is LocalTask ->
Intent(Intent.ACTION_VIEW, ContentUris.withAppendedId(TaskContract.Tasks.getContentUri(TaskProvider.ProviderName.OpenTasks.authority), id))
else ->
null
}
}
return if (intent != null && context.packageManager.resolveActivity(intent, 0) != null)
NotificationCompat.Action(android.R.drawable.ic_menu_view, context.getString(R.string.sync_error_view_item),
PendingIntent.getActivity(context, 0, intent, PendingIntent.FLAG_UPDATE_CURRENT))
else
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 open 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
Logger.log.info("Found ${localResources.size} local resources")
enum class SyncAlgorithm {
PROPFIND_REPORT,
COLLECTION_SYNC
}
/**
* Lists all members of the remote collection which should be taken into account for synchronization into {@link #remoteResources}.
*/
abstract protected fun listRemote()
class RemoteChanges(
val state: SyncState?,
val furtherChanges: Boolean
) {
val deleted = LinkedList<String>()
val updated = LinkedList<DavResponse>()
/**
* 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 open 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 localETag = local.eTag
val getETag = remote.properties[GetETag.NAME] as GetETag?
val remoteETag = getETag?.eTag ?: throw DavException("Server didn't provide 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)
}
override fun toString() = MiscUtils.reflectionToString(this)
}
/**
* 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 open 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)
}
}
}
@@ -8,10 +8,17 @@
package at.bitfire.davdroid.syncadapter
import android.accounts.Account
import android.app.PendingIntent
import android.content.*
import android.content.pm.PackageManager
import android.database.DatabaseUtils
import android.graphics.drawable.BitmapDrawable
import android.net.Uri
import android.os.Bundle
import android.support.v4.app.NotificationCompat
import android.support.v4.app.NotificationManagerCompat
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
@@ -19,9 +26,11 @@ import at.bitfire.davdroid.model.ServiceDB.Collections
import at.bitfire.davdroid.model.ServiceDB.Services
import at.bitfire.davdroid.resource.LocalTaskList
import at.bitfire.davdroid.settings.ISettings
import at.bitfire.davdroid.ui.NotificationUtils
import at.bitfire.ical4android.AndroidTaskList
import at.bitfire.ical4android.TaskProvider
import org.dmfs.provider.tasks.TaskContract
import okhttp3.HttpUrl
import org.dmfs.tasks.contract.TaskContract
import java.util.logging.Level
/**
@@ -38,7 +47,7 @@ class TasksSyncAdapterService: SyncAdapterService() {
override fun sync(settings: ISettings, account: Account, extras: Bundle, authority: String, provider: ContentProviderClient, syncResult: SyncResult) {
try {
val taskProvider = TaskProvider.fromProviderClient(provider)
val taskProvider = TaskProvider.fromProviderClient(context, provider)
val accountSettings = AccountSettings(context, settings, account)
/* don't run sync if
- sync conditions (e.g. "sync only in WiFi") are not met AND
@@ -51,12 +60,36 @@ class TasksSyncAdapterService: SyncAdapterService() {
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, settings, account, accountSettings, extras, authority, syncResult, taskProvider, taskList).use {
TasksSyncManager(context, settings, account, accountSettings, extras, authority, syncResult, taskList).use {
it.performSync()
}
}
} catch (e: TaskProvider.ProviderTooOldException) {
val nm = NotificationManagerCompat.from(context)
val message = context.getString(R.string.sync_error_opentasks_required_version, e.provider.minVersionName, e.installedVersionName)
val notify = NotificationUtils.newBuilder(context)
.setSmallIcon(R.drawable.ic_sync_error_notification)
.setContentTitle(context.getString(R.string.sync_error_opentasks_too_old))
.setContentText(message)
.setStyle(NotificationCompat.BigTextStyle().bigText(message))
.setCategory(NotificationCompat.CATEGORY_ERROR)
try {
val icon = context.packageManager.getApplicationIcon(e.provider.packageName)
if (icon is BitmapDrawable)
notify.setLargeIcon(icon.bitmap)
} catch(ignored: PackageManager.NameNotFoundException) {}
val intent = Intent(Intent.ACTION_VIEW, Uri.parse("market://details?id=${e.provider.packageName}"))
if (intent.resolveActivity(context.packageManager) != null)
notify .setContentIntent(PendingIntent.getActivity(context, 0, intent, PendingIntent.FLAG_UPDATE_CURRENT))
.setAutoCancel(true)
nm.notify(NotificationUtils.NOTIFY_OPENTASKS, notify.build())
syncResult.databaseError = true
} catch (e: Exception) {
Logger.log.log(Level.SEVERE, "Couldn't sync task lists", e)
syncResult.databaseError = true
}
Logger.log.info("Task sync complete")
@@ -76,8 +109,8 @@ class TasksSyncAdapterService: SyncAdapterService() {
null
}
fun remoteTaskLists(service: Long?): MutableMap<String, CollectionInfo> {
val collections = mutableMapOf<String, CollectionInfo>()
fun remoteTaskLists(service: Long?): MutableMap<HttpUrl, CollectionInfo> {
val collections = mutableMapOf<HttpUrl, CollectionInfo>()
service?.let {
db.query(Collections._TABLE, null,
"${Collections.SERVICE_ID}=? AND ${Collections.SUPPORTS_VTODO}!=0 AND ${Collections.SYNC}",
@@ -101,7 +134,8 @@ class TasksSyncAdapterService: SyncAdapterService() {
val updateColors = settings.getManageCalendarColors()
for (list in AndroidTaskList.find(account, provider, LocalTaskList.Factory, null, null))
list.syncId?.let { url ->
list.syncId?.let {
val url = HttpUrl.parse(it)!!
val info = remote[url]
if (info == null) {
Logger.log.fine("Deleting obsolete local task list $url")
@@ -13,13 +13,14 @@ 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.DavResponse
import at.bitfire.dav4android.exception.DavException
import at.bitfire.dav4android.property.CalendarData
import at.bitfire.dav4android.property.GetCTag
import at.bitfire.dav4android.property.GetETag
import at.bitfire.dav4android.property.SyncToken
import at.bitfire.davdroid.AccountSettings
import at.bitfire.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
@@ -27,16 +28,17 @@ import at.bitfire.davdroid.resource.LocalTaskList
import at.bitfire.davdroid.settings.ISettings
import at.bitfire.ical4android.InvalidCalendarException
import at.bitfire.ical4android.Task
import at.bitfire.ical4android.TaskProvider
import okhttp3.HttpUrl
import okhttp3.RequestBody
import org.apache.commons.collections4.ListUtils
import java.io.ByteArrayOutputStream
import java.io.Reader
import java.io.StringReader
import java.util.*
import java.util.logging.Level
/**
* Synchronization manager for CalDAV collections; handles tasks (VTODO)
*/
class TasksSyncManager(
context: Context,
settings: ISettings,
@@ -45,119 +47,106 @@ class TasksSyncManager(
extras: Bundle,
authority: String,
syncResult: SyncResult,
val provider: TaskProvider,
val localTaskList: LocalTaskList
): SyncManager(context, settings, account, accountSettings, extras, authority, syncResult, "taskList/${localTaskList.id}") {
localCollection: LocalTaskList
): BaseDavSyncManager<LocalTask, LocalTaskList, DavCalendar>(context, settings, account, accountSettings, extras, authority, syncResult, localCollection) {
val MAX_MULTIGET = 30
init {
localCollection = localTaskList
companion object {
const val MULTIGET_MAX_RESOURCES = 30
}
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
if (!super.prepare())
return false
val url = localCollection.syncId ?: return false
collectionURL = HttpUrl.parse(url) ?: return false
davCollection = DavCalendar(httpClient.okHttpClient, 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(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))
override fun queryCapabilities() =
useRemoteCollection {
it.propfind(0, GetCTag.NAME, SyncToken.NAME).use { dav ->
syncState(dav)
}
}
currentDavResource = null
override fun syncAlgorithm() = SyncAlgorithm.PROPFIND_REPORT
override fun prepareUpload(resource: LocalTask): RequestBody = useLocal(resource, {
val task = requireNotNull(resource.task)
Logger.log.log(Level.FINE, "Preparing upload of task ${resource.fileName}", task)
val os = ByteArrayOutputStream()
task.write(os)
RequestBody.create(
DavCalendar.MIME_ICALENDAR_UTF8,
os.toByteArray()
)
})
override fun listAllRemote() = useRemoteCollection { remote ->
remote.calendarQuery("VTODO", null, null).use { dav ->
val result = LinkedHashMap<String, DavResponse>(dav.members.size)
for (vCard in dav.members) {
val fileName = vCard.fileName()
Logger.log.fine("Found remote VTODO: $fileName")
result[fileName] = vCard
}
result
}
}
override fun processRemoteChanges(changes: RemoteChanges) {
for (name in changes.deleted) {
localCollection.findByName(name)?.let {
Logger.log.info("Deleting local task $name")
useLocal(it, { it.delete() })
syncResult.stats.numDeletes++
}
}
// helpers
val toDownload = changes.updated.map { it.url }
Logger.log.info("Downloading ${toDownload.size} resources ($MULTIGET_MAX_RESOURCES at once)")
private fun davCalendar() = davCollection as DavCalendar
for (bunch in toDownload.chunked(MULTIGET_MAX_RESOURCES)) {
if (bunch.size == 1)
// only one contact, use GET
useRemote(DavResource(httpClient.okHttpClient, bunch.first()), { remote ->
remote.get(DavCalendar.MIME_ICALENDAR.toString()).use { dav ->
// CalDAV servers MUST return ETag on GET [https://tools.ietf.org/html/rfc4791#section-5.3.4]
val eTag = dav[GetETag::class.java]?.eTag
?: throw DavException("Received CalDAV GET response without ETag for ${remote.location}")
dav.body?.charStream()?.use { reader ->
processVTodo(remote.fileName(), eTag, reader)
}
}
})
else {
// multiple contacts, use multi-get
davCollection.multiget(bunch).use { dav ->
// process multiget results
for (remote in dav.members)
useRemote(remote, {
val eTag = remote[GetETag::class.java]?.eTag
?: throw DavException("Received multi-get response without ETag")
val calendarData = remote[CalendarData::class.java]
val iCalendar = calendarData?.iCalendar
?: throw DavException("Received multi-get response without task data")
processVTodo(remote.fileName(), eTag, StringReader(iCalendar))
})
}
}
abortIfCancelled()
}
}
private fun processVTodo(fileName: String, eTag: String, reader: Reader) {
val tasks: List<Task>
@@ -172,22 +161,22 @@ class TasksSyncManager(
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++
}
useLocal(localCollection.findByName(fileName), { local ->
if (local != null) {
Logger.log.info("Updating $fileName in local task list")
local.eTag = eTag
local.update(newData)
syncResult.stats.numUpdates++
} else {
Logger.log.info("Adding $fileName to local task list")
useLocal(LocalTask(localCollection, newData, fileName, eTag, LocalResource.FLAG_REMOTELY_PRESENT), {
it.add()
})
syncResult.stats.numInserts++
}
})
} else
Logger.log.severe("Received VCALENDAR with not exactly one VTODO; ignoring $fileName")
Logger.log.info("Received VCALENDAR with not exactly one VTODO; ignoring $fileName")
}
}
}
@@ -56,7 +56,7 @@ class AboutActivity: AppCompatActivity() {
ComponentInfo(
null, BuildConfig.VERSION_NAME, getString(R.string.homepage_url),
"Ricki Hirner, Bernhard Stockmann (bitfire web engineering)",
null, null
R.string.about_license_info_no_warranty, "gpl-3.0-standalone.html"
), ComponentInfo(
"AmbilWarna", null, "https://github.com/yukuku/ambilwarna",
"Yuku", R.string.about_license_info_no_warranty, "apache2.html"
@@ -70,14 +70,11 @@ class AboutActivity: AppCompatActivity() {
"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/",
"ical4j", at.bitfire.ical4android.Constants.ical4jVersion, "https://ical4j.github.io/",
"Ben Fortuna", R.string.about_license_info_no_warranty, "bsd-3clause.html"
), ComponentInfo(
"OkHttp", null, "https://square.github.io/okhttp/",
"OkHttp", at.bitfire.dav4android.Constants.okHttpVersion, "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"
)
)
@@ -107,8 +104,8 @@ class AboutActivity: AppCompatActivity() {
class ComponentFragment: Fragment(), LoaderManager.LoaderCallbacks<Spanned> {
companion object {
val KEY_POSITION = "position"
val KEY_FILE_NAME = "fileName"
const val KEY_POSITION = "position"
const val KEY_FILE_NAME = "fileName"
fun instantiate(position: Int): ComponentFragment {
val frag = ComponentFragment()
@@ -168,8 +165,8 @@ class AboutActivity: AppCompatActivity() {
return v
}
override fun onCreateLoader(id: Int, args: Bundle) =
LicenseLoader(activity!!, args.getString(KEY_FILE_NAME))
override fun onCreateLoader(id: Int, args: Bundle?) =
LicenseLoader(activity!!, args!!.getString(KEY_FILE_NAME))
override fun onLoadFinished(loader: Loader<Spanned>, license: Spanned?) {
view?.let { v ->
@@ -186,13 +183,13 @@ class AboutActivity: AppCompatActivity() {
val fileName: String
): AsyncTaskLoader<Spanned>(context) {
var content: Spanned? = null
private var content: Spanned? = null
override fun onStartLoading() {
if (content == null)
forceLoad()
else
if (content != null)
deliverResult(content)
else
forceLoad()
}
override fun loadInBackground(): Spanned? {
@@ -42,7 +42,6 @@ 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.account_caldav_item.view.*
import kotlinx.android.synthetic.main.activity_account.*
import java.util.*
@@ -51,7 +50,7 @@ import java.util.logging.Level
class AccountActivity: AppCompatActivity(), Toolbar.OnMenuItemClickListener, PopupMenu.OnMenuItemClickListener, LoaderManager.LoaderCallbacks<AccountActivity.AccountInfo> {
companion object {
@JvmField val EXTRA_ACCOUNT = "account"
const val EXTRA_ACCOUNT = "account"
private fun requestSync(context: Context, account: Account) {
val authorities = arrayOf(
@@ -77,7 +76,9 @@ class AccountActivity: AppCompatActivity(), Toolbar.OnMenuItemClickListener, Pop
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
account = intent.getParcelableExtra(EXTRA_ACCOUNT)
// account may be a DAVdroid address book account -> use main account in this case
account = LocalAddressBook.mainAccount(this,
requireNotNull(intent.getParcelableExtra(EXTRA_ACCOUNT)))
title = account.name
setContentView(R.layout.activity_account)
@@ -206,9 +207,6 @@ class AccountActivity: AppCompatActivity(), Toolbar.OnMenuItemClickListener, Pop
info.selected = nowChecked
adapter.notifyDataSetChanged()
if (nowChecked && info.type == CollectionInfo.Type.ADDRESS_BOOK && info.readOnly)
Snackbar.make(parent, R.string.account_read_only_address_book_selected, Snackbar.LENGTH_LONG).show()
}
private val onActionOverflowListener = { anchor: View, info: CollectionInfo ->
@@ -240,7 +238,7 @@ class AccountActivity: AppCompatActivity(), Toolbar.OnMenuItemClickListener, Pop
}
}
R.id.delete_collection ->
DeleteCollectionFragment.ConfirmDeleteCollectionFragment.newInstance(account, info).show(getSupportFragmentManager(), null);
DeleteCollectionFragment.ConfirmDeleteCollectionFragment.newInstance(account, info).show(supportFragmentManager, null)
}
true
})
@@ -363,6 +361,33 @@ class AccountActivity: AppCompatActivity(), Toolbar.OnMenuItemClickListener, Pop
else
View.GONE
} ?: View.GONE
// ask for permissions
val requiredPermissions = mutableSetOf<String>()
info?.carddav?.let { carddav ->
if (carddav.collections.any { it.type == CollectionInfo.Type.ADDRESS_BOOK }) {
requiredPermissions += Manifest.permission.READ_CONTACTS
requiredPermissions += Manifest.permission.WRITE_CONTACTS
}
}
info?.caldav?.let { caldav ->
if (caldav.collections.any { it.type == CollectionInfo.Type.CALENDAR }) {
requiredPermissions += Manifest.permission.READ_CALENDAR
requiredPermissions += Manifest.permission.WRITE_CALENDAR
if (LocalTaskList.tasksProviderAvailable(this)) {
requiredPermissions += TaskProvider.PERMISSION_READ_TASKS
requiredPermissions += TaskProvider.PERMISSION_WRITE_TASKS
}
}
if (caldav.collections.any { it.type == CollectionInfo.Type.WEBCAL })
requiredPermissions += Manifest.permission.READ_CALENDAR
}
val askPermissions = requiredPermissions.filter { ActivityCompat.checkSelfPermission(this, it) != PackageManager.PERMISSION_GRANTED }
if (askPermissions.isNotEmpty())
ActivityCompat.requestPermissions(this, askPermissions.toTypedArray(), 0)
}
override fun onLoaderReset(loader: Loader<AccountInfo>) {
@@ -372,44 +397,58 @@ class AccountActivity: AppCompatActivity(), Toolbar.OnMenuItemClickListener, Pop
class AccountLoader(
val activity: AccountActivity,
context: Context,
val account: Account
): AsyncTaskLoader<AccountInfo>(activity), DavService.RefreshingStatusListener, ServiceConnection, SyncStatusObserver {
): AsyncTaskLoader<AccountInfo>(context), DavService.RefreshingStatusListener, SyncStatusObserver {
private var syncStatusListener: Any? = null
private var davServiceConn: ServiceConnection? = null
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)
// get notified when sync status changes
if (syncStatusListener == null)
syncStatusListener = ContentResolver.addStatusChangeListener(ContentResolver.SYNC_OBSERVER_TYPE_ACTIVE, this)
// bind to DavService to get notified when it's running
if (davServiceConn == null) {
davServiceConn = object: ServiceConnection {
override fun onServiceConnected(name: ComponentName, service: IBinder) {
// get notified when DavService is running
davService = service as DavService.InfoBinder
service.addRefreshingStatusListener(this@AccountLoader, false)
onContentChanged()
}
override fun onServiceDisconnected(name: ComponentName) {
davService = null
}
}
context.bindService(Intent(context, DavService::class.java), davServiceConn, Context.BIND_AUTO_CREATE)
} else
forceLoad()
}
override fun onStopLoading() {
davService?.removeRefreshingStatusListener(this)
context.unbindService(this)
override fun onReset() {
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
davService?.removeRefreshingStatusListener(this)
davServiceConn?.let {
context.unbindService(it)
davServiceConn = null
}
}
override fun onDavRefreshStatusChanged(id: Long, refreshing: Boolean) =
forceLoad()
onContentChanged()
override fun onStatusChanged(which: Int) =
forceLoad()
onContentChanged()
override fun loadInBackground(): AccountInfo {
val info = AccountInfo()
val requiredPermissions = mutableSetOf<String>()
OpenHelper(context).use { dbHelper ->
val db = dbHelper.readableDatabase
@@ -433,14 +472,14 @@ class AccountActivity: AppCompatActivity(), Toolbar.OnMenuItemClickListener, Pop
for (addrBookAccount in accountManager.getAccountsByType(context.getString(R.string.account_type_address_book))) {
val addressBook = LocalAddressBook(context, addrBookAccount, null)
try {
if (account == addressBook.getMainAccount())
if (account == addressBook.mainAccount)
carddav.refreshing = carddav.refreshing || ContentResolver.isSyncActive(addrBookAccount, ContactsContract.AUTHORITY)
} catch(e: ContactsStorageException) {
} catch(e: Exception) {
}
}
carddav.hasHomeSets = hasHomeSets(db, id)
carddav.collections = readCollections(db, id, requiredPermissions)
carddav.collections = readCollections(db, id)
}
Services.SERVICE_CALDAV -> {
val caldav = AccountInfo.ServiceInfo()
@@ -451,17 +490,13 @@ class AccountActivity: AppCompatActivity(), Toolbar.OnMenuItemClickListener, Pop
ContentResolver.isSyncActive(account, CalendarContract.AUTHORITY) ||
ContentResolver.isSyncActive(account, TaskProvider.ProviderName.OpenTasks.authority)
caldav.hasHomeSets = hasHomeSets(db, id)
caldav.collections = readCollections(db, id, requiredPermissions)
caldav.collections = readCollections(db, id)
}
}
}
}
}
val askPermissions = requiredPermissions.filter { ActivityCompat.checkSelfPermission(context, it) != PackageManager.PERMISSION_GRANTED }
if (askPermissions.isNotEmpty())
ActivityCompat.requestPermissions(activity, askPermissions.toTypedArray(), 0)
return info
}
@@ -473,7 +508,7 @@ class AccountActivity: AppCompatActivity(), Toolbar.OnMenuItemClickListener, Pop
return false
}
private fun readCollections(db: SQLiteDatabase, service: Long, requiredPermissions: MutableSet<String>): List<CollectionInfo> {
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 ->
@@ -502,19 +537,6 @@ class AccountActivity: AppCompatActivity(), Toolbar.OnMenuItemClickListener, Pop
}
}
if (collections.any { it.type == CollectionInfo.Type.ADDRESS_BOOK } && ContextCompat.checkSelfPermission(context, Manifest.permission.READ_CONTACTS) != PackageManager.PERMISSION_GRANTED) {
requiredPermissions += Manifest.permission.READ_CONTACTS
requiredPermissions += Manifest.permission.WRITE_CONTACTS
}
if (collections.any { it.type == CollectionInfo.Type.CALENDAR }) {
requiredPermissions += Manifest.permission.READ_CALENDAR
requiredPermissions += Manifest.permission.WRITE_CALENDAR
requiredPermissions += TaskProvider.PERMISSION_READ_TASKS
requiredPermissions += TaskProvider.PERMISSION_WRITE_TASKS
}
if (collections.any { it.type == CollectionInfo.Type.WEBCAL })
requiredPermissions += Manifest.permission.READ_CALENDAR
return collections
}
@@ -534,7 +556,7 @@ class AccountActivity: AppCompatActivity(), Toolbar.OnMenuItemClickListener, Pop
checked.isChecked = info.selected
var tv: TextView = v.findViewById(R.id.title)
tv.text = if (!info.displayName.isNullOrBlank()) info.displayName else info.url
tv.text = if (!info.displayName.isNullOrBlank()) info.displayName else info.url.toString()
tv = v.findViewById(R.id.description)
if (info.description.isNullOrBlank())
@@ -578,7 +600,7 @@ class AccountActivity: AppCompatActivity(), Toolbar.OnMenuItemClickListener, Pop
} ?: View.INVISIBLE
var tv: TextView = v.findViewById(R.id.title)
tv.text = if (!info.displayName.isNullOrBlank()) info.displayName else info.url
tv.text = if (!info.displayName.isNullOrBlank()) info.displayName else info.url.toString()
tv = v.findViewById(R.id.description)
if (info.description.isNullOrBlank())
@@ -618,7 +640,7 @@ class AccountActivity: AppCompatActivity(), Toolbar.OnMenuItemClickListener, Pop
companion object {
val ARG_ACCOUNT = "account"
const val ARG_ACCOUNT = "account"
fun newInstance(account: Account): RenameAccountFragment {
val fragment = RenameAccountFragment()
@@ -640,7 +662,7 @@ class AccountActivity: AppCompatActivity(), Toolbar.OnMenuItemClickListener, Pop
.setTitle(R.string.account_rename)
.setMessage(R.string.account_rename_new_name)
.setView(editText)
.setPositiveButton(R.string.account_rename_rename, DialogInterface.OnClickListener { dialog, which ->
.setPositiveButton(R.string.account_rename_rename, DialogInterface.OnClickListener { _, _ ->
val newName = editText.text.toString()
if (newName == oldAccount.name)
@@ -668,8 +690,8 @@ class AccountActivity: AppCompatActivity(), Toolbar.OnMenuItemClickListener, Pop
try {
if (provider != null) {
val addressBook = LocalAddressBook(activity!!, addrBookAccount, provider)
if (oldAccount == addressBook.getMainAccount())
addressBook.setMainAccount(Account(newName, oldAccount.type))
if (oldAccount == addressBook.mainAccount)
addressBook.mainAccount = Account(newName, oldAccount.type)
}
} finally {
if (Build.VERSION.SDK_INT >= 24)
@@ -679,7 +701,7 @@ class AccountActivity: AppCompatActivity(), Toolbar.OnMenuItemClickListener, Pop
}
}
} catch(e: ContactsStorageException) {
} catch(e: Exception) {
Logger.log.log(Level.SEVERE, "Couldn't update address book accounts", e)
}
@@ -24,7 +24,6 @@ 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.*
@@ -67,16 +66,28 @@ class AccountListFragment: ListFragment(), LoaderManager.LoaderCallbacks<Array<A
class AccountLoader(
context: Context
): AsyncTaskLoader<Array<Account>>(context), OnAccountsUpdateListener {
): AsyncTaskLoader<Array<Account>>(context) {
override fun onStartLoading() =
AccountsChangedReceiver.registerListener(this, true)
private val accountManager = AccountManager.get(context)!!
private var listener: OnAccountsUpdateListener? = null
override fun onStopLoading() =
AccountsChangedReceiver.unregisterListener(this)
override fun onStartLoading() {
if (listener == null) {
listener = OnAccountsUpdateListener { onContentChanged() }
accountManager.addOnAccountsUpdatedListener(listener, null, false)
}
override fun onAccountsUpdated(accounts: Array<Account>?) =
forceLoad()
forceLoad()
}
override fun onReset() {
listener?.let {
try {
accountManager.removeOnAccountsUpdatedListener(it)
} catch(ignored: IllegalArgumentException) {}
listener = null
}
}
override fun loadInBackground(): Array<Account> =
AccountManager.get(context).getAccountsByType(context.getString(R.string.account_type))
@@ -8,24 +8,33 @@
package at.bitfire.davdroid.ui
import android.Manifest
import android.accounts.Account
import android.app.DialogFragment
import android.app.LoaderManager
import android.content.*
import android.content.ContentResolver
import android.content.Context
import android.content.Intent
import android.content.SyncStatusObserver
import android.content.pm.PackageManager
import android.os.Build
import android.os.Bundle
import android.os.Handler
import android.os.Looper
import android.provider.CalendarContract
import android.support.v14.preference.PreferenceFragment
import android.security.KeyChain
import android.support.v4.app.ActivityCompat
import android.support.v4.app.DialogFragment
import android.support.v4.app.LoaderManager
import android.support.v4.app.NavUtils
import android.support.v4.content.ContextCompat
import android.support.v4.content.Loader
import android.support.v7.app.AlertDialog
import android.support.v7.app.AppCompatActivity
import android.support.v7.preference.EditTextPreference
import android.support.v7.preference.ListPreference
import android.support.v7.preference.Preference
import android.support.v7.preference.SwitchPreferenceCompat
import android.support.v7.preference.*
import android.view.MenuItem
import at.bitfire.davdroid.AccountSettings
import at.bitfire.davdroid.InvalidAccountException
import at.bitfire.davdroid.R
import at.bitfire.davdroid.model.Credentials
import at.bitfire.davdroid.settings.ISettings
import at.bitfire.ical4android.TaskProvider
import at.bitfire.vcard4android.GroupMethod
@@ -34,7 +43,7 @@ import org.apache.commons.lang3.StringUtils
class AccountSettingsActivity: AppCompatActivity() {
companion object {
val EXTRA_ACCOUNT = "account"
const val EXTRA_ACCOUNT = "account"
}
private lateinit var account: Account
@@ -49,7 +58,7 @@ class AccountSettingsActivity: AppCompatActivity() {
supportActionBar?.setDisplayHomeAsUpEnabled(true)
if (savedInstanceState == null)
fragmentManager.beginTransaction()
supportFragmentManager.beginTransaction()
.replace(android.R.id.content, DialogFragment.instantiate(this, AccountSettingsFragment::class.java.name, intent.extras))
.commit()
}
@@ -64,13 +73,13 @@ class AccountSettingsActivity: AppCompatActivity() {
false
class AccountSettingsFragment: PreferenceFragment(), LoaderManager.LoaderCallbacks<Pair<ISettings, AccountSettings>?> {
class AccountSettingsFragment: PreferenceFragmentCompat(), LoaderManager.LoaderCallbacks<Pair<ISettings, AccountSettings>> {
lateinit var account: Account
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
account = arguments.getParcelable(EXTRA_ACCOUNT)
account = arguments!!.getParcelable(EXTRA_ACCOUNT)
loaderManager.initLoader(0, arguments, this)
}
@@ -78,27 +87,54 @@ class AccountSettingsActivity: AppCompatActivity() {
addPreferencesFromResource(R.xml.settings_account)
}
override fun onCreateLoader(id: Int, args: Bundle) =
AccountSettingsLoader(activity, args.getParcelable(EXTRA_ACCOUNT))
override fun onCreateLoader(id: Int, args: Bundle?) =
AccountSettingsLoader(requireActivity(), args!!.getParcelable(EXTRA_ACCOUNT))
override fun onLoadFinished(loader: Loader<Pair<ISettings, AccountSettings>?>, result: Pair<ISettings, AccountSettings>?) {
override fun onLoadFinished(loader: Loader<Pair<ISettings, AccountSettings>>, result: Pair<ISettings, AccountSettings>?) {
val (settings, accountSettings) = result ?: return
// preference group: authentication
val prefUserName = findPreference("username") as EditTextPreference
prefUserName.summary = accountSettings.username()
prefUserName.text = accountSettings.username()
prefUserName.onPreferenceChangeListener = Preference.OnPreferenceChangeListener { _, newValue ->
accountSettings.username(newValue as String)
loaderManager.restartLoader(0, arguments, this)
false
}
val prefPassword = findPreference("password") as EditTextPreference
prefPassword.onPreferenceChangeListener = Preference.OnPreferenceChangeListener { _, newValue ->
accountSettings.password(newValue as String)
loaderManager.restartLoader(0, arguments, this)
false
val prefCertAlias = findPreference("certificate_alias") as Preference
val credentials = accountSettings.credentials()
when (credentials.type) {
Credentials.Type.UsernamePassword -> {
prefUserName.isVisible = true
prefUserName.summary = credentials.userName
prefUserName.text = credentials.userName
prefUserName.onPreferenceChangeListener = Preference.OnPreferenceChangeListener { _, newValue ->
accountSettings.credentials(Credentials(newValue as String, credentials.password))
loaderManager.restartLoader(0, arguments, this)
false
}
prefPassword.isVisible = true
prefPassword.onPreferenceChangeListener = Preference.OnPreferenceChangeListener { _, newValue ->
accountSettings.credentials(Credentials(credentials.userName, newValue as String))
loaderManager.restartLoader(0, arguments, this)
false
}
prefCertAlias.isVisible = false
}
Credentials.Type.ClientCertificate -> {
prefUserName.isVisible = false
prefPassword.isVisible = false
prefCertAlias.isVisible = true
prefCertAlias.summary = credentials.certificateAlias
prefCertAlias.setOnPreferenceClickListener {
KeyChain.choosePrivateKeyAlias(activity, { alias ->
accountSettings.credentials(Credentials(certificateAlias = alias))
Handler(Looper.getMainLooper()).post({
loaderManager.restartLoader(0, arguments, this)
})
}, null, null, null, -1, credentials.certificateAlias)
true
}
}
}
// preference group: sync
@@ -175,11 +211,18 @@ class AccountSettingsActivity: AppCompatActivity() {
else
prefWifiOnlySSIDs.setSummary(R.string.settings_sync_wifi_only_ssids_off)
prefWifiOnlySSIDs.onPreferenceChangeListener = Preference.OnPreferenceChangeListener { _, newValue ->
accountSettings.setSyncWifiOnlySSIDs((newValue as String).split(',').map { StringUtils.trimToNull(it) }.filterNotNull().distinct())
accountSettings.setSyncWifiOnlySSIDs((newValue as String).split(',').mapNotNull { StringUtils.trimToNull(it) }.distinct())
loaderManager.restartLoader(0, arguments, this)
false
}
// getting the WiFi name requires location permission (and active location services) since Android 8.1
// see https://issuetracker.google.com/issues/70633700
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O_MR1 &&
accountSettings.getSyncWifiOnly() && onlySSIDs != null &&
ContextCompat.checkSelfPermission(requireActivity(), Manifest.permission.ACCESS_COARSE_LOCATION) != PackageManager.PERMISSION_GRANTED)
ActivityCompat.requestPermissions(requireActivity(), arrayOf(Manifest.permission.ACCESS_COARSE_LOCATION), 0)
// preference group: CardDAV
(findPreference("contact_group_method") as ListPreference).let {
if (syncIntervalContacts != null) {
@@ -191,7 +234,7 @@ class AccountSettingsActivity: AppCompatActivity() {
else {
it.isEnabled = true
it.onPreferenceChangeListener = Preference.OnPreferenceChangeListener { _, groupMethod ->
AlertDialog.Builder(activity)
AlertDialog.Builder(requireActivity())
.setIcon(R.drawable.ic_error_dark)
.setTitle(R.string.settings_contact_group_method_change)
.setMessage(R.string.settings_contact_group_method_change_reload_contacts)
@@ -227,11 +270,10 @@ class AccountSettingsActivity: AppCompatActivity() {
it.setSummary(R.string.settings_sync_time_range_past_none)
}
it.onPreferenceChangeListener = Preference.OnPreferenceChangeListener { _, newValue ->
var days: Int
try {
days = (newValue as String).toInt()
} catch(ignored: NumberFormatException) {
days = -1
val days = try {
(newValue as String).toInt()
} catch(e: NumberFormatException) {
-1
}
accountSettings.setTimeRangePastDays(if (days < 0) null else days)
loaderManager.restartLoader(0, arguments, this)
@@ -265,7 +307,7 @@ class AccountSettingsActivity: AppCompatActivity() {
accountSettings.setEventColors(true)
loaderManager.restartLoader(0, arguments, this)
} else
AlertDialog.Builder(activity)
AlertDialog.Builder(requireActivity())
.setIcon(R.drawable.ic_error_dark)
.setTitle(R.string.settings_event_colors)
.setMessage(R.string.settings_event_colors_off_confirm)
@@ -282,7 +324,7 @@ class AccountSettingsActivity: AppCompatActivity() {
}
}
override fun onLoaderReset(loader: Loader<Pair<ISettings, AccountSettings>?>) {
override fun onLoaderReset(loader: Loader<Pair<ISettings, AccountSettings>>) {
}
}
@@ -293,17 +335,22 @@ class AccountSettingsActivity: AppCompatActivity() {
val account: Account
): SettingsLoader<Pair<ISettings, AccountSettings>?>(context), SyncStatusObserver {
var listenerHandle: Any? = null
private var listenerHandle: Any? = null
override fun onStartLoading() {
super.onStartLoading()
listenerHandle = ContentResolver.addStatusChangeListener(ContentResolver.SYNC_OBSERVER_TYPE_SETTINGS, this@AccountSettingsLoader)
if (listenerHandle == null)
listenerHandle = ContentResolver.addStatusChangeListener(ContentResolver.SYNC_OBSERVER_TYPE_SETTINGS, this)
}
override fun onStopLoading() {
super.onStopLoading()
listenerHandle?.let { ContentResolver.removeStatusChangeListener(it) }
listenerHandle = null
override fun onReset() {
super.onReset()
listenerHandle?.let {
ContentResolver.removeStatusChangeListener(it)
listenerHandle = null
}
}
override fun loadInBackground(): Pair<ISettings, AccountSettings>? {
@@ -320,7 +367,7 @@ class AccountSettingsActivity: AppCompatActivity() {
}
override fun onStatusChanged(which: Int) {
forceLoad()
onContentChanged()
}
}
@@ -10,33 +10,32 @@ package at.bitfire.davdroid.ui
import android.accounts.AccountManager
import android.content.ContentResolver
import android.content.Context
import android.content.Intent
import android.content.SyncStatusObserver
import android.net.Uri
import android.app.LoaderManager
import android.content.*
import android.os.Bundle
import android.support.design.widget.NavigationView
import android.support.design.widget.Snackbar
import android.support.v4.app.LoaderManager
import android.support.v4.content.Loader
import android.support.v4.view.GravityCompat
import android.support.v7.app.ActionBarDrawerToggle
import android.support.v7.app.AppCompatActivity
import android.view.Menu
import android.view.MenuItem
import android.view.View
import at.bitfire.davdroid.App
import at.bitfire.davdroid.BuildConfig
import at.bitfire.davdroid.R
import at.bitfire.davdroid.settings.ISettings
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, LoaderManager.LoaderCallbacks<AccountsActivity.Settings>, SyncStatusObserver {
companion object {
private val EXTRA_CREATE_STARTUP_FRAGMENTS = "createStartupFragments"
val accountsDrawerHandler = DefaultAccountsDrawerHandler()
private const val fragTagStartup = "startup"
}
private var syncStatusSnackbar: Snackbar? = null
@@ -50,7 +49,7 @@ class AccountsActivity: AppCompatActivity(), NavigationView.OnNavigationItemSele
setSupportActionBar(toolbar)
fab.setOnClickListener({
startActivity(Intent(this@AccountsActivity, LoginActivity::class.java))
startActivity(Intent(this, LoginActivity::class.java))
})
val toggle = ActionBarDrawerToggle(
@@ -60,8 +59,6 @@ class AccountsActivity: AppCompatActivity(), NavigationView.OnNavigationItemSele
nav_view.setNavigationItemSelectedListener(this)
nav_view.itemIconTintList = null
if (BuildConfig.VERSION_NAME.contains("-beta") || BuildConfig.VERSION_NAME.contains("-rc"))
nav_view.menu.findItem(R.id.nav_beta_feedback).isVisible = true
/* When the DAVdroid main activity is started, start a Settings service that stays in memory
for better performance. The service stops itself when memory is trimmed. */
@@ -69,24 +66,30 @@ class AccountsActivity: AppCompatActivity(), NavigationView.OnNavigationItemSele
startService(settingsIntent)
val args = Bundle(1)
args.putBoolean(EXTRA_CREATE_STARTUP_FRAGMENTS, savedInstanceState == null && packageName != callingPackage)
loaderManager.initLoader(0, args, this)
supportLoaderManager.initLoader(0, args, this)
}
override fun onCreateLoader(code: Int, args: Bundle) =
SettingsLoader(this, args.getBoolean(EXTRA_CREATE_STARTUP_FRAGMENTS))
override fun onCreateLoader(code: Int, args: Bundle?) =
SettingsLoader(this)
override fun onLoadFinished(loader: Loader<Settings>?, result: Settings?) {
override fun onLoadFinished(loader: Loader<Settings>, result: Settings?) {
val result = result ?: return
if (result.createStartupFragments) {
val ft = fragmentManager.beginTransaction()
StartupDialogFragment.getStartupDialogs(this, result.settings).forEach { ft.add(it, null) }
ft.commitAllowingStateLoss()
if (supportFragmentManager.findFragmentByTag(fragTagStartup) == null) {
val ft = supportFragmentManager.beginTransaction()
StartupDialogFragment.getStartupDialogs(this, result.settings).forEach { ft.add(it, fragTagStartup) }
ft.commit()
}
nav_view?.menu?.let {
accountsDrawerHandler.onSettingsChanged(result.settings, it)
}
}
override fun onLoaderReset(loader: Loader<Settings>?) {
override fun onLoaderReset(loader: Loader<Settings>) {
nav_view?.menu?.let {
accountsDrawerHandler.onSettingsChanged(null, it)
}
}
override fun onResume() {
@@ -131,43 +134,18 @@ class AccountsActivity: AppCompatActivity(), NavigationView.OnNavigationItemSele
}
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_beta_feedback ->
startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(getString(R.string.beta_feedback_url))))
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
}
val processed = accountsDrawerHandler.onNavigationItemSelected(this, item)
drawer_layout.closeDrawer(GravityCompat.START)
return processed
}
class Settings(
val settings: ISettings,
val createStartupFragments: Boolean
val settings: ISettings
)
class SettingsLoader(
context: Context,
private val createStartupFragments: Boolean
context: Context
): at.bitfire.davdroid.ui.SettingsLoader<Settings>(context) {
override fun loadInBackground(): Settings? {
@@ -176,8 +154,7 @@ class AccountsActivity: AppCompatActivity(), NavigationView.OnNavigationItemSele
val accounts = accountManager.getAccountsByType(context.getString(R.string.account_type))
return Settings(
it,
createStartupFragments
it
)
}
return null
@@ -36,7 +36,7 @@ import java.net.URISyntaxException
class AppSettingsActivity: AppCompatActivity() {
companion object {
val EXTRA_SCROLL_TO = "scrollTo"
const val EXTRA_SCROLL_TO = "scrollTo"
}
override fun onCreate(savedInstanceState: Bundle?) {
@@ -98,11 +98,11 @@ class AppSettingsActivity: AppCompatActivity() {
// security settings
val prefDistrustSystemCerts = findPreference(App.DISTRUST_SYSTEM_CERTIFICATES)
prefDistrustSystemCerts.isVisible = BuildConfig.customCerts
prefDistrustSystemCerts.isEnabled = BuildConfig.customCertsUI
prefDistrustSystemCerts.isEnabled = true
val prefResetCertificates = findPreference("reset_certificates")
prefResetCertificates.isVisible = BuildConfig.customCerts
prefResetCertificates.isEnabled = BuildConfig.customCertsUI
prefResetCertificates.isEnabled = true
prefResetCertificates.onPreferenceClickListener = Preference.OnPreferenceClickListener {
resetCertificates()
false
@@ -164,8 +164,11 @@ class AppSettingsActivity: AppCompatActivity() {
// debugging settings
val prefLogToExternalStorage = findPreference(Logger.LOG_TO_EXTERNAL_STORAGE) as SwitchPreferenceCompat
prefLogToExternalStorage.onPreferenceChangeListener = Preference.OnPreferenceChangeListener { _, newValue ->
val context = activity!!
Logger.initialize(context)
// kill a potential :sync process, so that the new logger settings will be used
val am = activity!!.getSystemService(Context.ACTIVITY_SERVICE) as ActivityManager
val am = context.getSystemService(Context.ACTIVITY_SERVICE) as ActivityManager
am.runningAppProcesses.forEach {
if (it.pid != Process.myPid()) {
Logger.log.info("Killing ${it.processName} process, pid = ${it.pid}")
@@ -177,6 +180,7 @@ class AppSettingsActivity: AppCompatActivity() {
}
private fun resetHints() {
settings?.remove(StartupDialogFragment.HINT_AUTOSTART_PERMISSIONS)
settings?.remove(StartupDialogFragment.HINT_BATTERY_OPTIMIZATIONS)
settings?.remove(StartupDialogFragment.HINT_GOOGLE_PLAY_ACCOUNTS_REMOVED)
settings?.remove(StartupDialogFragment.HINT_OPENTASKS_NOT_INSTALLED)
@@ -184,7 +188,7 @@ class AppSettingsActivity: AppCompatActivity() {
}
private fun resetCertificates() {
if (BuildConfig.customCertsUI && CustomCertManager.resetCertificates(activity!!))
if (CustomCertManager.resetCertificates(activity!!))
Snackbar.make(view!!, getString(R.string.app_settings_reset_certificates_success), Snackbar.LENGTH_LONG).show()
}
@@ -31,7 +31,7 @@ import java.util.*
class CreateAddressBookActivity: AppCompatActivity(), LoaderManager.LoaderCallbacks<CreateAddressBookActivity.AccountInfo> {
companion object {
val EXTRA_ACCOUNT = "account"
const val EXTRA_ACCOUNT = "account"
}
private lateinit var account: Account
@@ -67,7 +67,7 @@ class CreateAddressBookActivity: AppCompatActivity(), LoaderManager.LoaderCallba
var ok = true
HttpUrl.parse(homeSet)?.let {
val info = CollectionInfo(it.resolve(UUID.randomUUID().toString() + "/").toString())
val info = CollectionInfo(it.resolve(UUID.randomUUID().toString() + "/")!!)
info.displayName = display_name.text.toString()
if (info.displayName.isNullOrBlank()) {
display_name.error = getString(R.string.create_collection_display_name_required)
@@ -35,7 +35,7 @@ import java.util.*
class CreateCalendarActivity: AppCompatActivity(), LoaderManager.LoaderCallbacks<CreateCalendarActivity.AccountInfo> {
companion object {
val EXTRA_ACCOUNT = "account"
const val EXTRA_ACCOUNT = "account"
}
private lateinit var account: Account
@@ -78,7 +78,7 @@ class CreateCalendarActivity: AppCompatActivity(), LoaderManager.LoaderCallbacks
var ok = true
HttpUrl.parse(homeSet)?.let {
val info = CollectionInfo(it.resolve(UUID.randomUUID().toString() + "/").toString())
val info = CollectionInfo(it.resolve(UUID.randomUUID().toString() + "/")!!)
info.displayName = display_name.text.toString()
if (info.displayName.isNullOrBlank()) {
display_name.error = getString(R.string.create_collection_display_name_required)
@@ -121,7 +121,7 @@ class CreateCalendarActivity: AppCompatActivity(), LoaderManager.LoaderCallbacks
// select system time zone
val defaultTimeZone = TimeZone.getDefault().id
for (i in 0 .. timeZones.size - 1)
for (i in 0 until timeZones.size)
if (timeZones[i] == defaultTimeZone) {
time_zone.setSelection(i)
break
@@ -27,7 +27,6 @@ import at.bitfire.davdroid.log.Logger
import at.bitfire.davdroid.model.CollectionInfo
import at.bitfire.davdroid.model.ServiceDB
import at.bitfire.davdroid.settings.Settings
import okhttp3.HttpUrl
import java.io.IOException
import java.io.StringWriter
import java.util.logging.Level
@@ -37,14 +36,14 @@ class CreateCollectionFragment: DialogFragment(), LoaderManager.LoaderCallbacks<
companion object {
val ARG_ACCOUNT = "account"
val ARG_COLLECTION_INFO = "collectionInfo"
const val ARG_ACCOUNT = "account"
const 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)
args.putParcelable(ARG_COLLECTION_INFO, info)
frag.arguments = args
return frag
}
@@ -57,8 +56,9 @@ class CreateCollectionFragment: DialogFragment(), LoaderManager.LoaderCallbacks<
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
account = arguments!!.getParcelable(ARG_ACCOUNT)
info = arguments!!.getSerializable(ARG_COLLECTION_INFO) as CollectionInfo
val args = requireNotNull(arguments)
account = args.getParcelable(ARG_ACCOUNT)
info = args.getParcelable(ARG_COLLECTION_INFO)
loaderManager.initLoader(0, null, this)
}
@@ -74,16 +74,16 @@ class CreateCollectionFragment: DialogFragment(), LoaderManager.LoaderCallbacks<
}
override fun onCreateLoader(id: Int, args: Bundle?) = CreateCollectionLoader(activity!!, account, info)
override fun onCreateLoader(id: Int, args: Bundle?) = CreateCollectionLoader(requireActivity(), account, info)
override fun onLoadFinished(loader: Loader<Exception>, exception: Exception?) {
dismissAllowingStateLoss()
dismiss()
activity?.let { parent ->
if (exception != null)
fragmentManager!!.beginTransaction()
requireFragmentManager().beginTransaction()
.add(ExceptionInfoFragment.newInstance(exception, account), null)
.commitAllowingStateLoss()
.commit()
else
parent.finish()
}
@@ -189,7 +189,7 @@ class CreateCollectionFragment: DialogFragment(), LoaderManager.LoaderCallbacks<
.setForeground(true)
.build().use { httpClient ->
try {
val collection = DavResource(httpClient.okHttpClient, HttpUrl.parse(info.url)!!)
val collection = DavResource(httpClient.okHttpClient, info.url)
// create collection on remote server
collection.mkCol(writer.toString())
@@ -11,8 +11,9 @@ 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.ContentResolver
import android.content.Context
import android.content.Intent
import android.content.pm.PackageManager
import android.net.ConnectivityManager
import android.os.Build
@@ -20,9 +21,13 @@ import android.os.Bundle
import android.os.PowerManager
import android.provider.CalendarContract
import android.provider.ContactsContract
import android.support.v4.app.LoaderManager
import android.support.v4.content.AsyncTaskLoader
import android.support.v4.content.ContextCompat
import android.support.v4.content.FileProvider
import android.support.v4.content.Loader
import android.support.v7.app.AppCompatActivity
import android.util.Log
import android.view.Menu
import android.view.MenuItem
import at.bitfire.dav4android.exception.HttpException
@@ -35,9 +40,7 @@ import at.bitfire.davdroid.model.ServiceDB
import at.bitfire.davdroid.resource.LocalAddressBook
import at.bitfire.davdroid.settings.Settings
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
@@ -48,13 +51,13 @@ 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"
const val KEY_THROWABLE = "throwable"
const val KEY_LOGS = "logs"
const val KEY_ACCOUNT = "account"
const val KEY_AUTHORITY = "authority"
const val KEY_PHASE = "phase"
const val KEY_LOCAL_RESOURCE = "localResource"
const val KEY_REMOTE_RESOURCE = "remoteResource"
}
private var report: String? = null
@@ -63,7 +66,7 @@ class DebugInfoActivity: AppCompatActivity(), LoaderManager.LoaderCallbacks<Stri
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_debug_info)
loaderManager.initLoader(0, intent.extras, this)
supportLoaderManager.initLoader(0, intent.extras, this)
}
override fun onCreateOptionsMenu(menu: Menu): Boolean {
@@ -120,9 +123,17 @@ class DebugInfoActivity: AppCompatActivity(), LoaderManager.LoaderCallbacks<Stri
val extras: Bundle?
): AsyncTaskLoader<String>(context) {
override fun onStartLoading() = forceLoad()
var result: String? = null
override fun onStartLoading() {
if (result != null)
deliverResult(result)
else
forceLoad()
}
override fun loadInBackground(): String {
Logger.log.info("Building debug info")
val report = StringBuilder("--- BEGIN DEBUG INFO ---\n")
// begin with most specific information
@@ -140,10 +151,14 @@ class DebugInfoActivity: AppCompatActivity(), LoaderManager.LoaderCallbacks<Stri
val throwable = extras?.getSerializable(KEY_THROWABLE) as Throwable?
if (throwable is HttpException) {
throwable.request?.let {
report.append("\nHTTP REQUEST:\n$it\n\n")
report.append("\nHTTP REQUEST:\n$it\n")
throwable.requestBody?.let { report.append(it) }
report.append("\n\n")
}
throwable.response?.let {
report.append("HTTP RESPONSE:\n$it\n")
throwable.responseBody?.let { report.append(it) }
report.append("\n\n")
}
}
@@ -155,7 +170,7 @@ class DebugInfoActivity: AppCompatActivity(), LoaderManager.LoaderCallbacks<Stri
}
throwable?.let {
report.append("\nEXCEPTION:\n${ExceptionUtils.getStackTrace(throwable)}")
report.append("\nEXCEPTION:\n${Log.getStackTraceString(throwable)}")
}
// logs (for instance, from failed resource detection)
@@ -245,10 +260,10 @@ class DebugInfoActivity: AppCompatActivity(), LoaderManager.LoaderCallbacks<Stri
try {
val addressBook = LocalAddressBook(context, acct, null)
report.append("Address book account: ${acct.name}\n" +
" Main account: ${addressBook.getMainAccount()}\n" +
" URL: ${addressBook.getURL()}\n" +
" Main account: ${addressBook.mainAccount}\n" +
" URL: ${addressBook.url}\n" +
" Sync automatically: ").append(ContentResolver.getSyncAutomatically(acct, ContactsContract.AUTHORITY)).append("\n")
} catch(e: ContactsStorageException) {
} catch(e: Exception) {
report.append("$acct is invalid: ${e.message}\n")
}
report.append("\n")
@@ -270,7 +285,11 @@ class DebugInfoActivity: AppCompatActivity(), LoaderManager.LoaderCallbacks<Stri
}
report.append("--- END DEBUG INFO ---\n")
return report.toString()
report.toString().let {
result = it
return it
}
}
private fun syncStatus(settings: AccountSettings, authority: String): String {
@@ -0,0 +1,68 @@
/*
* Copyright © Ricki Hirner (bitfire web engineering).
* All rights reserved. This program and the accompanying materials
* are made available under the terms of the GNU Public License v3.0
* which accompanies this distribution, and is available at
* http://www.gnu.org/licenses/gpl.html
*/
package at.bitfire.davdroid.ui
import android.app.Activity
import android.content.Intent
import android.net.Uri
import android.view.Menu
import android.view.MenuItem
import at.bitfire.davdroid.App
import at.bitfire.davdroid.BuildConfig
import at.bitfire.davdroid.R
import at.bitfire.davdroid.settings.ISettings
class DefaultAccountsDrawerHandler: IAccountsDrawerHandler {
companion object {
private const val BETA_FEEDBACK_URI = "mailto:support@davdroid.com?subject=${BuildConfig.APPLICATION_ID}/${BuildConfig.VERSION_NAME} feedback (${BuildConfig.VERSION_CODE})"
}
override fun onSettingsChanged(settings: ISettings?, menu: Menu) {
if (BuildConfig.VERSION_NAME.contains("-beta") || BuildConfig.VERSION_NAME.contains("-rc"))
menu.findItem(R.id.nav_beta_feedback).isVisible = true
}
override fun onNavigationItemSelected(activity: Activity, item: MenuItem): Boolean {
when (item.itemId) {
R.id.nav_about ->
activity.startActivity(Intent(activity, AboutActivity::class.java))
R.id.nav_app_settings ->
activity.startActivity(Intent(activity, AppSettingsActivity::class.java))
R.id.nav_beta_feedback -> {
val intent = Intent(Intent.ACTION_SENDTO, Uri.parse(BETA_FEEDBACK_URI))
if (activity.packageManager.resolveActivity(intent, 0) != null)
activity.startActivity(intent)
}
R.id.nav_twitter ->
activity.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse("https://twitter.com/davdroidapp")))
R.id.nav_website ->
activity.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(activity.getString(R.string.homepage_url))))
R.id.nav_manual ->
activity.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(activity.getString(R.string.homepage_url))
.buildUpon().appendEncodedPath("manual/").build()))
R.id.nav_faq ->
activity.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(activity.getString(R.string.homepage_url))
.buildUpon().appendEncodedPath("faq/").build()))
R.id.nav_forums ->
activity.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(activity.getString(R.string.homepage_url))
.buildUpon().appendEncodedPath("forums/").build()))
R.id.nav_donate ->
//if (BuildConfig.FLAVOR != App.FLAVOR_GOOGLE_PLAY)
activity.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(activity.getString(R.string.homepage_url))
.buildUpon().appendEncodedPath("donate/").build()))
else ->
return false
}
return true
}
}
@@ -25,14 +25,13 @@ import at.bitfire.davdroid.R
import at.bitfire.davdroid.model.CollectionInfo
import at.bitfire.davdroid.model.ServiceDB
import at.bitfire.davdroid.settings.Settings
import okhttp3.HttpUrl
@Suppress("DEPRECATION")
class DeleteCollectionFragment: DialogFragment(), LoaderManager.LoaderCallbacks<Exception> {
companion object {
val ARG_ACCOUNT = "account"
val ARG_COLLECTION_INFO = "collectionInfo"
const val ARG_ACCOUNT = "account"
const val ARG_COLLECTION_INFO = "collectionInfo"
}
private lateinit var account: Account
@@ -62,12 +61,12 @@ class DeleteCollectionFragment: DialogFragment(), LoaderManager.LoaderCallbacks<
DeleteCollectionLoader(activity!!, account, collectionInfo)
override fun onLoadFinished(loader: Loader<Exception>, exception: Exception?) {
dismissAllowingStateLoss()
dismiss()
if (exception != null)
fragmentManager!!.beginTransaction()
requireFragmentManager().beginTransaction()
.add(ExceptionInfoFragment.newInstance(exception, account), null)
.commitAllowingStateLoss()
.commit()
else
(activity as? AccountActivity)?.reload()
}
@@ -89,7 +88,7 @@ class DeleteCollectionFragment: DialogFragment(), LoaderManager.LoaderCallbacks<
.setForeground(true)
.build().use { httpClient ->
try {
val collection = DavResource(httpClient.okHttpClient, HttpUrl.parse(collectionInfo.url)!!)
val collection = DavResource(httpClient.okHttpClient, collectionInfo.url)
// delete collection from server
collection.delete(null)
@@ -117,7 +116,7 @@ class DeleteCollectionFragment: DialogFragment(), LoaderManager.LoaderCallbacks<
val frag = ConfirmDeleteCollectionFragment()
val args = Bundle(2)
args.putParcelable(ARG_ACCOUNT, account)
args.putSerializable(ARG_COLLECTION_INFO, collectionInfo)
args.putParcelable(ARG_COLLECTION_INFO, collectionInfo)
frag.arguments = args
return frag
}
@@ -125,9 +124,9 @@ class DeleteCollectionFragment: DialogFragment(), LoaderManager.LoaderCallbacks<
}
override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
val collectionInfo = arguments!!.getSerializable(ARG_COLLECTION_INFO) as CollectionInfo
val collectionInfo = arguments!!.getParcelable(ARG_COLLECTION_INFO) as CollectionInfo
val name = if (collectionInfo.displayName.isNullOrBlank())
collectionInfo.url
collectionInfo.url.toString()
else
collectionInfo.displayName
@@ -21,8 +21,8 @@ import java.io.IOException
class ExceptionInfoFragment: DialogFragment() {
companion object {
val ARG_ACCOUNT = "account"
val ARG_EXCEPTION = "exception"
const val ARG_ACCOUNT = "account"
const val ARG_EXCEPTION = "exception"
fun newInstance(exception: Exception, account: Account?): ExceptionInfoFragment {
val frag = ExceptionInfoFragment()
@@ -35,8 +35,9 @@ class ExceptionInfoFragment: DialogFragment() {
}
override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
val exception = arguments!!.getSerializable(ARG_EXCEPTION) as Exception
val account: Account? = arguments!!.getParcelable(ARG_ACCOUNT)
val args = requireNotNull(arguments)
val exception = args.getSerializable(ARG_EXCEPTION) as Exception
val account: Account? = args.getParcelable(ARG_ACCOUNT)
val title = when (exception) {
is HttpException -> R.string.exception_httpexception
@@ -44,7 +45,7 @@ class ExceptionInfoFragment: DialogFragment() {
else -> R.string.exception
}
val dialog = AlertDialog.Builder(activity!!)
val dialog = AlertDialog.Builder(requireActivity())
.setIcon(R.drawable.ic_error_dark)
.setTitle(title)
.setMessage(exception::class.java.name + "\n" + exception.localizedMessage)
@@ -0,0 +1,22 @@
/*
* Copyright © Ricki Hirner (bitfire web engineering).
* All rights reserved. This program and the accompanying materials
* are made available under the terms of the GNU Public License v3.0
* which accompanies this distribution, and is available at
* http://www.gnu.org/licenses/gpl.html
*/
package at.bitfire.davdroid.ui
import android.app.Activity
import android.view.Menu
import android.view.MenuItem
import at.bitfire.davdroid.settings.ISettings
interface IAccountsDrawerHandler {
fun onSettingsChanged(settings: ISettings?, menu: Menu)
fun onNavigationItemSelected(activity: Activity, item: MenuItem): Boolean
}
@@ -8,29 +8,68 @@
package at.bitfire.davdroid.ui
import android.annotation.TargetApi
import android.app.NotificationChannel
import android.app.NotificationChannelGroup
import android.app.NotificationManager
import android.content.Context
import android.os.Build
import android.support.v4.app.NotificationCompat
import at.bitfire.davdroid.App
import at.bitfire.davdroid.R
object NotificationUtils {
val CHANNEL_DEBUG = "debug"
val CHANNEL_SYNC_STATUS = "syncStatus"
val CHANNEL_SYNC_PROBLEMS = "syncProblems"
// notification IDs
const val NOTIFY_EXTERNAL_FILE_LOGGING = 1
const val NOTIFY_REFRESH_COLLECTIONS = 2
const val NOTIFY_SYNC_ERROR = 10
const val NOTIFY_OPENTASKS = 20
const val NOTIFY_PERMISSIONS = 21
const val NOTIFY_LICENSE = 100
fun createChannels(context: Context): NotificationManager {
val nm = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
// notification channels
const val CHANNEL_GENERAL = "general"
const val CHANNEL_DEBUG = "debug"
private const val CHANNEL_SYNC = "sync"
const val CHANNEL_SYNC_ERRORS = "syncProblems"
const val CHANNEL_SYNC_IO_ERRORS = "syncIoErrors"
const val CHANNEL_SYNC_STATUS = "syncStatus"
fun createChannels(context: Context) {
@TargetApi(Build.VERSION_CODES.O)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val nm = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
val syncChannelGroup = NotificationChannelGroup(CHANNEL_SYNC, context.getString(R.string.notification_channel_sync))
nm.createNotificationChannelGroup(syncChannelGroup)
val syncChannels = arrayOf(
NotificationChannel(CHANNEL_SYNC_ERRORS, context.getString(R.string.notification_channel_sync_errors), NotificationManager.IMPORTANCE_DEFAULT),
NotificationChannel(CHANNEL_SYNC_IO_ERRORS, context.getString(R.string.notification_channel_sync_io_errors), NotificationManager.IMPORTANCE_MIN),
NotificationChannel(CHANNEL_SYNC_STATUS, context.getString(R.string.notification_channel_sync_status), NotificationManager.IMPORTANCE_MIN)
)
syncChannels.forEach {
it.group = CHANNEL_SYNC
}
if (Build.VERSION.SDK_INT >= 26)
nm.createNotificationChannels(listOf(
NotificationChannel(CHANNEL_DEBUG, context.getString(R.string.notification_channel_debugging), NotificationManager.IMPORTANCE_LOW),
NotificationChannel(CHANNEL_SYNC_STATUS, context.getString(R.string.notification_channel_sync_status), NotificationManager.IMPORTANCE_LOW),
NotificationChannel(CHANNEL_SYNC_PROBLEMS, context.getString(R.string.notification_channel_sync_problems), NotificationManager.IMPORTANCE_DEFAULT)
NotificationChannel(CHANNEL_DEBUG, context.getString(R.string.notification_channel_debugging), NotificationManager.IMPORTANCE_HIGH),
NotificationChannel(CHANNEL_GENERAL, context.getString(R.string.notification_channel_general), NotificationManager.IMPORTANCE_DEFAULT),
*syncChannels
))
}
}
return nm
fun newBuilder(context: Context, channel: String = CHANNEL_GENERAL): NotificationCompat.Builder {
val builder = NotificationCompat.Builder(context, channel)
.setColor(context.resources.getColor(R.color.primaryColor))
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.N)
builder.setLargeIcon(App.getLauncherBitmap(context))
return builder
}
}
@@ -1,90 +0,0 @@
/*
* Copyright © Ricki Hirner (bitfire web engineering).
* All rights reserved. This program and the accompanying materials
* are made available under the terms of the GNU Public License v3.0
* which accompanies this distribution, and is available at
* http://www.gnu.org/licenses/gpl.html
*/
package at.bitfire.davdroid.ui
import android.Manifest
import android.content.pm.PackageManager
import android.os.Bundle
import android.support.v4.app.ActivityCompat
import android.support.v7.app.AppCompatActivity
import android.view.View
import at.bitfire.davdroid.Constants
import at.bitfire.davdroid.R
import at.bitfire.davdroid.resource.LocalTaskList
import at.bitfire.ical4android.TaskProvider
import kotlinx.android.synthetic.main.activity_permissions.*
class PermissionsActivity: AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_permissions)
}
override fun onResume() {
super.onResume()
refresh()
}
private fun refresh() {
val noCalendarPermissions =
ActivityCompat.checkSelfPermission(this, Manifest.permission.READ_CALENDAR) != PackageManager.PERMISSION_GRANTED ||
ActivityCompat.checkSelfPermission(this, Manifest.permission.WRITE_CALENDAR) != PackageManager.PERMISSION_GRANTED;
calendar_permissions.visibility = if (noCalendarPermissions) View.VISIBLE else View.GONE
val noContactsPermissions =
ActivityCompat.checkSelfPermission(this, Manifest.permission.READ_CONTACTS) != PackageManager.PERMISSION_GRANTED ||
ActivityCompat.checkSelfPermission(this, Manifest.permission.WRITE_CONTACTS) != PackageManager.PERMISSION_GRANTED;
contacts_permissions.visibility = if (noContactsPermissions) View.VISIBLE else View.GONE
val noTaskPermissions: Boolean
if (LocalTaskList.tasksProviderAvailable(this)) {
noTaskPermissions =
ActivityCompat.checkSelfPermission(this, TaskProvider.PERMISSION_READ_TASKS) != PackageManager.PERMISSION_GRANTED ||
ActivityCompat.checkSelfPermission(this, TaskProvider.PERMISSION_WRITE_TASKS) != PackageManager.PERMISSION_GRANTED
findViewById<View>(R.id.opentasks_permissions).visibility = if (noTaskPermissions) View.VISIBLE else View.GONE
} else {
findViewById<View>(R.id.opentasks_permissions).visibility = View.GONE;
noTaskPermissions = false
}
if (!noCalendarPermissions && !noContactsPermissions && !noTaskPermissions) {
val nm = NotificationUtils.createChannels(this)
nm.cancel(Constants.NOTIFICATION_PERMISSIONS)
finish()
}
}
fun requestCalendarPermissions(v: View) {
ActivityCompat.requestPermissions(this, arrayOf(
Manifest.permission.READ_CALENDAR,
Manifest.permission.WRITE_CALENDAR
), 0)
}
fun requestContactsPermissions(v: View) {
ActivityCompat.requestPermissions(this, arrayOf(
Manifest.permission.READ_CONTACTS,
Manifest.permission.WRITE_CONTACTS
), 0)
}
fun requestOpenTasksPermissions(v: View) {
ActivityCompat.requestPermissions(this, arrayOf(
TaskProvider.PERMISSION_READ_TASKS,
TaskProvider.PERMISSION_WRITE_TASKS
), 0)
}
override fun onRequestPermissionsResult(requestCode: Int, permissions: Array<String>, grantResults: IntArray) {
super.onRequestPermissionsResult(requestCode, permissions, grantResults)
refresh()
}
}
@@ -8,9 +8,13 @@
package at.bitfire.davdroid.ui
import android.content.*
import android.content.ComponentName
import android.content.Context
import android.content.Intent
import android.content.ServiceConnection
import android.os.Handler
import android.os.IBinder
import android.support.v4.content.AsyncTaskLoader
import at.bitfire.davdroid.settings.ISettings
import at.bitfire.davdroid.settings.ISettingsObserver
import at.bitfire.davdroid.settings.Settings
@@ -23,33 +27,39 @@ abstract class SettingsLoader<T>(
val settingsObserver = object: ISettingsObserver.Stub() {
override fun onSettingsChanged() {
handler.post {
forceLoad()
onContentChanged()
}
}
}
private var settingsSvc: ServiceConnection? = null
var settings: ISettings? = null
private val settingsSvc = object: ServiceConnection {
override fun onServiceConnected(name: ComponentName?, binder: IBinder) {
settings = ISettings.Stub.asInterface(binder)
settings!!.registerObserver(settingsObserver)
forceLoad()
}
override fun onServiceDisconnected(name: ComponentName?) {
settings!!.unregisterObserver(settingsObserver)
settings = null
}
}
override fun onStartLoading() {
context.bindService(Intent(context, Settings::class.java), settingsSvc, Context.BIND_AUTO_CREATE)
if (settingsSvc != null)
forceLoad()
else {
settingsSvc = object: ServiceConnection {
override fun onServiceConnected(name: ComponentName?, binder: IBinder) {
settings = ISettings.Stub.asInterface(binder)
settings!!.registerObserver(settingsObserver)
onContentChanged()
}
override fun onServiceDisconnected(name: ComponentName?) {
settings!!.unregisterObserver(settingsObserver)
settings = null
}
}
context.bindService(Intent(context, Settings::class.java), settingsSvc, Context.BIND_AUTO_CREATE)
}
}
override fun onStopLoading() {
context.unbindService(settingsSvc)
override fun onReset() {
settingsSvc?.let {
context.unbindService(it)
settingsSvc = null
}
}
}
@@ -11,30 +11,32 @@ package at.bitfire.davdroid.ui
import android.annotation.SuppressLint
import android.annotation.TargetApi
import android.app.Dialog
import android.app.DialogFragment
import android.app.LoaderManager
import android.content.Context
import android.content.DialogInterface
import android.content.Intent
import android.content.Loader
import android.content.pm.PackageManager
import android.graphics.drawable.Drawable
import android.net.Uri
import android.os.Build
import android.os.Bundle
import android.os.PowerManager
import android.support.v4.app.DialogFragment
import android.support.v4.app.LoaderManager
import android.support.v4.content.Loader
import android.support.v7.app.AlertDialog
import at.bitfire.davdroid.BuildConfig
import at.bitfire.davdroid.R
import at.bitfire.davdroid.log.Logger
import at.bitfire.davdroid.resource.LocalTaskList
import at.bitfire.davdroid.settings.ISettings
import org.apache.commons.lang3.StringUtils
import java.util.*
import java.util.logging.Level
class StartupDialogFragment: DialogFragment(), LoaderManager.LoaderCallbacks<ISettings?> {
class StartupDialogFragment: DialogFragment(), LoaderManager.LoaderCallbacks<ISettings> {
enum class Mode {
AUTOSTART_PERMISSIONS,
BATTERY_OPTIMIZATIONS,
GOOGLE_PLAY_ACCOUNTS_REMOVED,
OPENTASKS_NOT_INSTALLED,
@@ -43,19 +45,23 @@ class StartupDialogFragment: DialogFragment(), LoaderManager.LoaderCallbacks<ISe
companion object {
private val SETTING_NEXT_DONATION_POPUP = "time_nextDonationPopup"
private const val SETTING_NEXT_DONATION_POPUP = "time_nextDonationPopup"
@JvmField val HINT_BATTERY_OPTIMIZATIONS = "hint_BatteryOptimizations"
@JvmField val HINT_GOOGLE_PLAY_ACCOUNTS_REMOVED = "hint_GooglePlayAccountsRemoved"
@JvmField val HINT_OPENTASKS_NOT_INSTALLED = "hint_OpenTasksNotInstalled"
const val HINT_AUTOSTART_PERMISSIONS = "hint_AutostartPermissions"
// see https://github.com/jaredrummler/AndroidDeviceNames/blob/master/json/ for manufacturer values
private val autostartManufacturers = arrayOf("huawei", "letv", "oneplus", "vivo", "xiaomi", "zte")
val ARGS_MODE = "mode"
const val HINT_BATTERY_OPTIMIZATIONS = "hint_BatteryOptimizations"
const val HINT_GOOGLE_PLAY_ACCOUNTS_REMOVED = "hint_GooglePlayAccountsRemoved"
const val HINT_OPENTASKS_NOT_INSTALLED = "hint_OpenTasksNotInstalled"
const val ARGS_MODE = "mode"
fun getStartupDialogs(context: Context, settings: ISettings): List<StartupDialogFragment> {
val dialogs = LinkedList<StartupDialogFragment>()
/* if (System.currentTimeMillis() > settings.getLong(SETTING_NEXT_DONATION_POPUP, 0))
dialogs += StartupDialogFragment.instantiate(Mode.OSE_DONATE) */
if (System.currentTimeMillis() > settings.getLong(SETTING_NEXT_DONATION_POPUP, 0))
dialogs += StartupDialogFragment.instantiate(Mode.OSE_DONATE)
// store-specific information
/*if (BuildConfig.FLAVOR == App.FLAVOR_GOOGLE_PLAY) {
@@ -72,6 +78,10 @@ class StartupDialogFragment: DialogFragment(), LoaderManager.LoaderCallbacks<ISe
dialogs.add(StartupDialogFragment.instantiate(Mode.BATTERY_OPTIMIZATIONS))
}
// vendor-specific auto-start information
if (autostartManufacturers.contains(Build.MANUFACTURER.toLowerCase()) && settings.getBoolean(HINT_AUTOSTART_PERMISSIONS, true))
dialogs.add(StartupDialogFragment.instantiate(Mode.AUTOSTART_PERMISSIONS))
// OpenTasks information
if (!LocalTaskList.tasksProviderAvailable(context) && settings.getBoolean(HINT_OPENTASKS_NOT_INSTALLED, true))
dialogs.add(StartupDialogFragment.instantiate(Mode.OPENTASKS_NOT_INSTALLED))
@@ -97,14 +107,14 @@ class StartupDialogFragment: DialogFragment(), LoaderManager.LoaderCallbacks<ISe
loaderManager.initLoader(0, null, this)
}
override fun onCreateLoader(code: Int, args: Bundle?): Loader<ISettings?> =
SettingsLoader(activity)
override fun onCreateLoader(code: Int, args: Bundle?) =
SettingsLoader(requireActivity())
override fun onLoadFinished(loader: Loader<ISettings?>?, result: ISettings?) {
override fun onLoadFinished(loader: Loader<ISettings>, result: ISettings?) {
settings = result
}
override fun onLoaderReset(loader: Loader<ISettings?>?) {
override fun onLoaderReset(loader: Loader<ISettings>) {
settings = null
}
@@ -113,20 +123,38 @@ class StartupDialogFragment: DialogFragment(), LoaderManager.LoaderCallbacks<ISe
override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
isCancelable = false
val mode = Mode.valueOf(arguments.getString(ARGS_MODE))
val activity = requireActivity()
val mode = Mode.valueOf(arguments!!.getString(ARGS_MODE))
return when (mode) {
Mode.AUTOSTART_PERMISSIONS ->
AlertDialog.Builder(activity)
.setIcon(R.drawable.ic_error_dark)
.setTitle(getString(R.string.startup_autostart_permission, StringUtils.capitalize(Build.MANUFACTURER)))
.setMessage(R.string.startup_autostart_permission_message)
.setPositiveButton(R.string.startup_more_info, { _, _ ->
val intent = Intent(Intent.ACTION_VIEW, Uri.parse(getString(R.string.homepage_url)).buildUpon()
.appendPath("faq").appendPath("automatic-synchronization-is-not-run-as-expected").build())
if (intent.resolveActivity(activity.packageManager) != null)
activity.startActivity(intent)
})
.setNeutralButton(android.R.string.ok, { _, _ -> })
.setNegativeButton(R.string.startup_dont_show_again, { _, _ ->
settings?.putBoolean(HINT_AUTOSTART_PERMISSIONS, false)
})
.create()
Mode.BATTERY_OPTIMIZATIONS ->
AlertDialog.Builder(activity)
.setIcon(R.drawable.ic_info_dark)
.setTitle(R.string.startup_battery_optimization)
.setMessage(R.string.startup_battery_optimization_message)
.setPositiveButton(android.R.string.ok, { _, _ -> })
.setNeutralButton(R.string.startup_battery_optimization_disable, @TargetApi(Build.VERSION_CODES.M) { _, _ ->
.setPositiveButton(R.string.startup_battery_optimization_disable, @TargetApi(Build.VERSION_CODES.M) { _, _ ->
val intent = Intent(android.provider.Settings.ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS,
Uri.parse("package:" + BuildConfig.APPLICATION_ID))
if (intent.resolveActivity(activity.packageManager) != null)
activity.startActivity(intent)
})
.setNeutralButton(android.R.string.ok, { _, _ -> })
.setNegativeButton(R.string.startup_dont_show_again, { _: DialogInterface, _: Int ->
settings?.putBoolean(HINT_BATTERY_OPTIMIZATIONS, false)
})
@@ -143,11 +171,13 @@ class StartupDialogFragment: DialogFragment(), LoaderManager.LoaderCallbacks<ISe
.setIcon(icon)
.setTitle(R.string.startup_google_play_accounts_removed)
.setMessage(R.string.startup_google_play_accounts_removed_message)
.setPositiveButton(android.R.string.ok, { _, _ -> })
.setNeutralButton(R.string.startup_google_play_accounts_removed_more_info, { _, _ ->
val intent = Intent(Intent.ACTION_VIEW, Uri.parse(getString(R.string.navigation_drawer_faq_url)))
activity.startActivity(intent)
.setPositiveButton(R.string.startup_more_info, { _, _ ->
val intent = Intent(Intent.ACTION_VIEW, Uri.parse(getString(R.string.homepage_url)).buildUpon()
.appendPath("faq").appendPath("accounts-gone-after-reboot-or-update").build())
if (intent.resolveActivity(activity.packageManager) != null)
activity.startActivity(intent)
})
.setNeutralButton(android.R.string.ok, { _, _ -> })
.setNegativeButton(R.string.startup_dont_show_again, { _, _ ->
settings?.putBoolean(HINT_GOOGLE_PLAY_ACCOUNTS_REMOVED, false)
})
@@ -162,14 +192,14 @@ class StartupDialogFragment: DialogFragment(), LoaderManager.LoaderCallbacks<ISe
.setIcon(R.drawable.ic_playlist_add_check_dark)
.setTitle(R.string.startup_opentasks_not_installed)
.setMessage(builder.toString())
.setPositiveButton(android.R.string.ok, { _, _ -> })
.setNeutralButton(R.string.startup_opentasks_not_installed_install, { _, _ ->
.setPositiveButton(R.string.startup_opentasks_not_installed_install, { _, _ ->
val intent = Intent(Intent.ACTION_VIEW, Uri.parse("market://details?id=org.dmfs.tasks"))
if (intent.resolveActivity(activity.packageManager) != null)
activity.startActivity(intent)
else
Logger.log.warning("No market app available, can't install OpenTasks")
})
.setNeutralButton(android.R.string.ok, { _, _ -> })
.setNegativeButton(R.string.startup_dont_show_again, { _: DialogInterface, _: Int ->
settings?.putBoolean(HINT_OPENTASKS_NOT_INSTALLED, false)
})
@@ -11,13 +11,17 @@ package at.bitfire.davdroid.ui.setup
import android.accounts.Account
import android.accounts.AccountManager
import android.app.Activity
import android.app.Fragment
import android.app.LoaderManager
import android.content.*
import android.content.ContentResolver
import android.content.ContentValues
import android.content.Context
import android.content.Intent
import android.database.sqlite.SQLiteDatabase
import android.os.Bundle
import android.provider.CalendarContract
import android.support.design.widget.Snackbar
import android.support.v4.app.Fragment
import android.support.v4.app.LoaderManager
import android.support.v4.content.Loader
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
@@ -36,17 +40,15 @@ import java.util.logging.Level
class AccountDetailsFragment: Fragment(), LoaderManager.LoaderCallbacks<CreateSettings> {
companion object {
val KEY_CONFIG = "config"
const val KEY_CONFIG = "config"
fun newInstance(config: DavResourceFinder.Configuration): AccountDetailsFragment {
val frag = AccountDetailsFragment()
val args = Bundle(1)
args.putSerializable(KEY_CONFIG, config)
args.putParcelable(KEY_CONFIG, config)
frag.arguments = args
return frag
}
}
var groupMethod: GroupMethod? = null
@@ -57,15 +59,15 @@ class AccountDetailsFragment: Fragment(), LoaderManager.LoaderCallbacks<CreateSe
val v = inflater.inflate(R.layout.login_account_details, container, false)
v.back.setOnClickListener({ _ ->
fragmentManager.popBackStack()
requireFragmentManager().popBackStack()
})
val config = arguments.getSerializable(KEY_CONFIG) as DavResourceFinder.Configuration
val args = requireNotNull(arguments)
val config = args.getParcelable(KEY_CONFIG) as DavResourceFinder.Configuration
v.account_name.setText(if (config.calDAV != null && config.calDAV.email != null)
config.calDAV.email
else
config.userName)
v.account_name.setText(config.calDAV?.email ?:
config.credentials.userName ?:
config.credentials.certificateAlias)
// CardDAV-specific
v.carddav.visibility = if (config.cardDAV != null) View.VISIBLE else View.GONE
@@ -79,9 +81,9 @@ class AccountDetailsFragment: Fragment(), LoaderManager.LoaderCallbacks<CreateSe
if (name.isEmpty())
v.account_name.error = getString(R.string.login_account_name_required)
else {
if (createAccount(name, arguments.getSerializable(KEY_CONFIG) as DavResourceFinder.Configuration)) {
activity.setResult(Activity.RESULT_OK)
activity.finish()
if (createAccount(name, args.getParcelable(KEY_CONFIG) as DavResourceFinder.Configuration)) {
requireActivity().setResult(Activity.RESULT_OK)
requireActivity().finish()
} else
Snackbar.make(v, R.string.login_account_not_created, Snackbar.LENGTH_LONG).show()
}
@@ -92,28 +94,30 @@ class AccountDetailsFragment: Fragment(), LoaderManager.LoaderCallbacks<CreateSe
return v
}
override fun onCreateLoader(code: Int, args: Bundle?): Loader<CreateSettings?> =
GroupMethodLoader(activity)
override fun onCreateLoader(code: Int, args: Bundle?) =
GroupMethodLoader(requireActivity())
override fun onLoadFinished(loader: Loader<CreateSettings?>?, result: CreateSettings?) {
override fun onLoadFinished(loader: Loader<CreateSettings>, result: CreateSettings?) {
settings = (result ?: return).settings
groupMethod = result.groupMethod
if (result.groupMethod != null) {
view.contact_group_method.isEnabled = false
for ((i, method) in resources.getStringArray(R.array.settings_contact_group_method_values).withIndex()) {
if (method == result.groupMethod.name) {
view.contact_group_method.setSelection(i)
break
view?.let { view ->
if (result.groupMethod != null) {
view.contact_group_method.isEnabled = false
for ((i, method) in resources.getStringArray(R.array.settings_contact_group_method_values).withIndex()) {
if (method == result.groupMethod.name) {
view.contact_group_method.setSelection(i)
break
}
}
}
} else
view.contact_group_method.isEnabled = true
} else
view.contact_group_method.isEnabled = true
view.create_account.isEnabled = true
view.create_account.isEnabled = true
}
}
override fun onLoaderReset(loader: Loader<CreateSettings?>?) {
override fun onLoaderReset(loader: Loader<CreateSettings>) {
settings = null
groupMethod = null
view?.create_account?.isEnabled = false
@@ -125,19 +129,19 @@ class AccountDetailsFragment: Fragment(), LoaderManager.LoaderCallbacks<CreateSe
val settings = settings ?: return false
// create Android account
val userData = AccountSettings.initialUserData(config.userName)
val userData = AccountSettings.initialUserData(config.credentials)
Logger.log.log(Level.INFO, "Creating Android account with initial config", arrayOf(account, userData))
val accountManager = AccountManager.get(activity)
if (!accountManager.addAccountExplicitly(account, config.password, userData))
if (!accountManager.addAccountExplicitly(account, config.credentials.password, userData))
return false
// add entries for account to service DB
Logger.log.log(Level.INFO, "Writing account configuration to database", config)
OpenHelper(activity).use { dbHelper ->
OpenHelper(requireActivity()).use { dbHelper ->
val db = dbHelper.writableDatabase
try {
val accountSettings = AccountSettings(activity, settings, account)
val accountSettings = AccountSettings(requireActivity(), settings, account)
val refreshIntent = Intent(activity, DavService::class.java)
refreshIntent.action = DavService.ACTION_REFRESH_COLLECTIONS
@@ -148,10 +152,10 @@ class AccountDetailsFragment: Fragment(), LoaderManager.LoaderCallbacks<CreateSe
// start CardDAV service detection (refresh collections)
refreshIntent.putExtra(DavService.EXTRA_DAV_SERVICE_ID, id)
activity.startService(refreshIntent)
requireActivity().startService(refreshIntent)
// initial CardDAV account settings
val idx = view.contact_group_method.selectedItemPosition
val idx = view!!.contact_group_method.selectedItemPosition
val groupMethodName = resources.getStringArray(R.array.settings_contact_group_method_values)[idx]
accountSettings.setGroupMethod(GroupMethod.valueOf(groupMethodName))
@@ -166,14 +170,14 @@ class AccountDetailsFragment: Fragment(), LoaderManager.LoaderCallbacks<CreateSe
// start CalDAV service detection (refresh collections)
refreshIntent.putExtra(DavService.EXTRA_DAV_SERVICE_ID, id)
activity.startService(refreshIntent)
requireActivity().startService(refreshIntent)
// calendar sync is automatically enabled by isAlwaysSyncable="true" in res/xml/sync_contacts.xml
accountSettings.setSyncInterval(CalendarContract.AUTHORITY, Constants.DEFAULT_SYNC_INTERVAL)
// enable task sync if OpenTasks is installed
// further changes will be handled by PackageChangedReceiver
if (LocalTaskList.tasksProviderAvailable(activity)) {
if (LocalTaskList.tasksProviderAvailable(requireActivity())) {
ContentResolver.setIsSyncable(account, TaskProvider.ProviderName.OpenTasks.authority, 1)
accountSettings.setSyncInterval(TaskProvider.ProviderName.OpenTasks.authority, Constants.DEFAULT_SYNC_INTERVAL)
}
@@ -222,7 +226,7 @@ class AccountDetailsFragment: Fragment(), LoaderManager.LoaderCallbacks<CreateSe
class GroupMethodLoader(
context: Context
): SettingsLoader<CreateSettings?>(context) {
): SettingsLoader<CreateSettings>(context) {
override fun loadInBackground(): CreateSettings? {
settings?.let { settings ->
@@ -8,7 +8,10 @@
package at.bitfire.davdroid.ui.setup
import android.content.Context
import android.os.Parcel
import android.os.Parcelable
import at.bitfire.dav4android.DavResource
import at.bitfire.dav4android.DavResponse
import at.bitfire.dav4android.UrlUtils
import at.bitfire.dav4android.exception.DavException
import at.bitfire.dav4android.exception.HttpException
@@ -17,15 +20,13 @@ import at.bitfire.davdroid.DavUtils
import at.bitfire.davdroid.HttpClient
import at.bitfire.davdroid.log.StringHandler
import at.bitfire.davdroid.model.CollectionInfo
import at.bitfire.davdroid.model.Credentials
import at.bitfire.davdroid.settings.Settings
import okhttp3.HttpUrl
import org.apache.commons.lang3.builder.ReflectionToStringBuilder
import org.apache.commons.lang3.builder.ToStringBuilder
import org.xbill.DNS.Lookup
import org.xbill.DNS.Type
import java.io.Closeable
import java.io.IOException
import java.io.Serializable
import java.net.URI
import java.net.URISyntaxException
import java.util.*
@@ -34,8 +35,8 @@ import java.util.logging.Logger
class DavResourceFinder(
val context: Context,
private val credentials: LoginCredentials
): Closeable {
private val loginInfo: LoginInfo
): AutoCloseable {
enum class Service(val wellKnownName: String) {
CALDAV("caldav"),
@@ -53,13 +54,13 @@ class DavResourceFinder(
private val settings = Settings.getInstance(context)
private val httpClient: HttpClient = HttpClient.Builder(context, settings, logger = log)
.addAuthentication(null, credentials.userName, credentials.password)
.addAuthentication(null, loginInfo.credentials)
.setForeground(true)
.build()
fun cancel() {
log.warning("Shutting down resource detection")
httpClient.okHttpClient.dispatcher().executorService().shutdownNow()
httpClient.okHttpClient.dispatcher().executorService().shutdown()
httpClient.okHttpClient.connectionPool().evictAll()
}
@@ -74,7 +75,7 @@ class DavResourceFinder(
val calDavConfig = findInitialConfiguration(Service.CALDAV)
return Configuration(
credentials.userName, credentials.password,
loginInfo.credentials,
cardDavConfig, calDavConfig,
logBuffer.toString()
)
@@ -82,7 +83,7 @@ class DavResourceFinder(
private fun findInitialConfiguration(service: Service): Configuration.ServiceInfo? {
// user-given base URI (either mailto: URI or http(s):// URL)
val baseURI = credentials.uri
val baseURI = loginInfo.uri
// domain for service discovery
var discoveryFQDN: String? = null
@@ -128,18 +129,19 @@ class DavResourceFinder(
if (config.principal != null && service == Service.CALDAV) {
// query email address (CalDAV scheduling: calendar-user-address-set)
val davPrincipal = DavResource(httpClient.okHttpClient, HttpUrl.get(config.principal)!!, log)
val davPrincipal = DavResource(httpClient.okHttpClient, config.principal!!, log)
try {
davPrincipal.propfind(0, CalendarUserAddressSet.NAME)
(davPrincipal.properties[CalendarUserAddressSet.NAME] as CalendarUserAddressSet?)?.let { addressSet ->
for (href in addressSet.hrefs)
try {
val uri = URI(href)
if (uri.scheme.equals("mailto", true))
config.email = uri.schemeSpecificPart
} catch(e: URISyntaxException) {
log.log(Level.WARNING, "Unparseable user address", e)
}
davPrincipal.propfind(0, CalendarUserAddressSet.NAME).use { response ->
response[CalendarUserAddressSet::class.java]?.let { addressSet ->
for (href in addressSet.hrefs)
try {
val uri = URI(href)
if (uri.scheme.equals("mailto", true))
config.email = uri.schemeSpecificPart
} catch(e: URISyntaxException) {
log.log(Level.WARNING, "Couldn't parse user address", e)
}
}
}
} catch(e: Exception) {
log.log(Level.WARNING, "Couldn't query user email address", e)
@@ -161,47 +163,50 @@ class DavResourceFinder(
try {
val davBase = DavResource(httpClient.okHttpClient, baseURL, log)
when (service) {
Service.CARDDAV -> {
davBase.propfind(0,
ResourceType.NAME, DisplayName.NAME, AddressbookDescription.NAME,
AddressbookHomeSet.NAME,
CurrentUserPrincipal.NAME
)
rememberIfAddressBookOrHomeset(davBase, config)
}
Service.CALDAV -> {
davBase.propfind(0,
ResourceType.NAME, DisplayName.NAME, CalendarColor.NAME, CalendarDescription.NAME, CalendarTimezone.NAME, CurrentUserPrivilegeSet.NAME, SupportedCalendarComponentSet.NAME,
CalendarHomeSet.NAME,
CurrentUserPrincipal.NAME
)
rememberIfCalendarOrHomeset(davBase, config)
}
}
// check for current-user-principal
davBase.findProperty(CurrentUserPrincipal.NAME)?.let { (dav, second) ->
val currentUserPrincipal = second as CurrentUserPrincipal?
currentUserPrincipal?.href?.let {
principal = dav.location.resolve(it)
}
}
// check for resource type "principal"
if (principal == null)
for ((dav, second) in davBase.findProperties(ResourceType.NAME)) {
val resourceType = second as ResourceType?
if (resourceType != null && resourceType.types.contains(ResourceType.PRINCIPAL)) {
principal = dav.location
break
lateinit var response: DavResponse
try {
when (service) {
Service.CARDDAV -> {
response = davBase.propfind(0,
ResourceType.NAME, DisplayName.NAME, AddressbookDescription.NAME,
AddressbookHomeSet.NAME,
CurrentUserPrincipal.NAME
)
rememberIfAddressBookOrHomeset(response, config)
}
Service.CALDAV -> {
response = davBase.propfind(0,
ResourceType.NAME, DisplayName.NAME, CalendarColor.NAME, CalendarDescription.NAME, CalendarTimezone.NAME, CurrentUserPrivilegeSet.NAME, SupportedCalendarComponentSet.NAME,
CalendarHomeSet.NAME,
CurrentUserPrincipal.NAME
)
rememberIfCalendarOrHomeset(response, config)
}
}
// check for current-user-principal
response.searchProperty(CurrentUserPrincipal::class.java)?.let { (dav, currentUserPrincipal) ->
currentUserPrincipal.href?.let {
principal = dav.url.resolve(it)
}
}
// check for resource type "principal"
if (principal == null)
for ((dav, resourceType) in response.searchProperties(ResourceType::class.java)) {
if (resourceType.types.contains(ResourceType.PRINCIPAL)) {
principal = dav.url
break
}
}
} finally {
response.close()
}
// If a principal has been detected successfully, ensure that it provides the required service.
principal?.let {
if (providesService(it, service))
config.principal = it.uri()
config.principal = it
}
} catch(e: Exception) {
log.log(Level.FINE, "PROPFIND/OPTIONS on user-given URL failed", e)
@@ -209,55 +214,50 @@ class DavResourceFinder(
}
/**
* If #dav references an address book or an address book home set, it will added to
* config.collections or config.homesets. Only evaluates already known properties,
* does not call dav.propfind()! URLs will be stored with trailing "/".
* If [dav] references an address book or an address book home set, it will added to
* config.collections or config.homesets. URLs will be stored with trailing "/".
* @param dav resource whose properties are evaluated
* @param config structure where the address book (collection) and/or home set is stored into (if found)
*/
fun rememberIfAddressBookOrHomeset(dav: DavResource, config: Configuration.ServiceInfo) {
fun rememberIfAddressBookOrHomeset(dav: DavResponse, config: Configuration.ServiceInfo) {
// Is there an address book?
for ((addressBook, second) in dav.findProperties(ResourceType.NAME)) {
val resourceType = second as ResourceType
for ((addressBook, resourceType) in dav.searchProperties(ResourceType::class.java)) {
if (resourceType.types.contains(ResourceType.ADDRESSBOOK)) {
addressBook.location = UrlUtils.withTrailingSlash(addressBook.location)
log.info("Found address book at ${addressBook.location}")
config.collections.put(addressBook.location.uri(), CollectionInfo(addressBook))
val info = CollectionInfo(addressBook)
log.info("Found address book at ${info.url}")
config.collections[info.url] = info
}
}
// Is there an addressbook-home-set?
for ((dav, second) in dav.findProperties(AddressbookHomeSet.NAME)) {
val homeSet = second as AddressbookHomeSet
for ((dav, homeSet) in dav.searchProperties(AddressbookHomeSet::class.java)) {
for (href in homeSet.hrefs) {
dav.location.resolve(href)?.let {
dav.url.resolve(href)?.let {
val location = UrlUtils.withTrailingSlash(it)
log.info("Found address book home-set at $location")
config.homeSets.add(location.uri())
config.homeSets += location
}
}
}
}
fun rememberIfCalendarOrHomeset(dav: DavResource, config: Configuration.ServiceInfo) {
private fun rememberIfCalendarOrHomeset(dav: DavResponse, config: Configuration.ServiceInfo) {
// Is the collection a calendar collection?
for ((calendar, second) in dav.findProperties(ResourceType.NAME)) {
val resourceType = second as ResourceType
for ((calendar, resourceType) in dav.searchProperties(ResourceType::class.java)) {
if (resourceType.types.contains(ResourceType.CALENDAR)) {
calendar.location = UrlUtils.withTrailingSlash(calendar.location)
log.info("Found calendar at ${calendar.location}")
config.collections.put(calendar.location.uri(), CollectionInfo(calendar))
val info = CollectionInfo(calendar)
log.info("Found calendar at ${info.url}")
config.collections[info.url] = info
}
}
// Is there an calendar-home-set?
for ((dav, second) in dav.findProperties(CalendarHomeSet.NAME)) {
val homeSet = second as CalendarHomeSet
for ((dav, homeSet) in dav.searchProperties(CalendarHomeSet::class.java)) {
for (href in homeSet.hrefs) {
dav.location.resolve(href)?.let {
dav.url.resolve(href)?.let {
val location = UrlUtils.withTrailingSlash(it)
log.info("Found calendar home-set at $location")
config.homeSets.add(location.uri())
config.homeSets += location
}
}
}
@@ -268,14 +268,14 @@ class DavResourceFinder(
fun providesService(url: HttpUrl, service: Service): Boolean {
val davPrincipal = DavResource(httpClient.okHttpClient, url, log)
try {
davPrincipal.options()
if ((service == Service.CARDDAV && davPrincipal.capabilities.contains("addressbook")) ||
(service == Service.CALDAV && davPrincipal.capabilities.contains("calendar-access")))
return true
davPrincipal.options().use {
val capabilities = it.capabilities
if ((service == Service.CARDDAV && capabilities.contains("addressbook")) ||
(service == Service.CALDAV && capabilities.contains("calendar-access")))
return true
}
} catch(e: Exception) {
log.log(Level.SEVERE, "Couldn't detect services on " + url, e)
log.log(Level.SEVERE, "Couldn't detect services on $url", e)
if (e !is HttpException && e !is DavException)
throw e
}
@@ -291,7 +291,7 @@ class DavResourceFinder(
* @return principal URL, or null if none found
*/
@Throws(IOException::class, HttpException::class, DavException::class)
private fun discoverPrincipalUrl(domain: String, service: Service): URI? {
private fun discoverPrincipalUrl(domain: String, service: Service): HttpUrl? {
val scheme: String
val fqdn: String
var port = 443
@@ -299,7 +299,9 @@ class DavResourceFinder(
val query = "_${service.wellKnownName}s._tcp.$domain"
log.fine("Looking up SRV records for $query")
val srv = DavUtils.selectSRVRecord(Lookup(query, Type.SRV).run())
val srvLookup = Lookup(query, Type.SRV)
DavUtils.prepareLookup(context, srvLookup)
val srv = DavUtils.selectSRVRecord(srvLookup.run())
if (srv != null) {
// choose SRV record to use (query may return multiple SRV records)
scheme = "https"
@@ -315,7 +317,9 @@ class DavResourceFinder(
}
// look for TXT record too (for initial context path)
paths.addAll(DavUtils.pathsFromTXTRecords(Lookup(query, Type.TXT).run()))
val txtLookup = Lookup(query, Type.TXT)
DavUtils.prepareLookup(context, txtLookup)
paths.addAll(DavUtils.pathsFromTXTRecords(txtLookup.run()))
// if there's TXT record and if it it's wrong, try well-known
paths.add("/.well-known/" + service.wellKnownName)
@@ -347,23 +351,22 @@ class DavResourceFinder(
* @return current-user-principal URL that provides required service, or null if none
*/
@Throws(IOException::class, HttpException::class, DavException::class)
fun getCurrentUserPrincipal(url: HttpUrl, service: Service?): URI? {
fun getCurrentUserPrincipal(url: HttpUrl, service: Service?): HttpUrl? {
val dav = DavResource(httpClient.okHttpClient, url, log)
dav.propfind(0, CurrentUserPrincipal.NAME)
dav.propfind(0, CurrentUserPrincipal.NAME).use {
it.searchProperty(CurrentUserPrincipal::class.java)?.let { (dav, currentUserPrincipal) ->
currentUserPrincipal.href?.let { href ->
dav.url.resolve(href)?.let { principal ->
log.info("Found current-user-principal: $principal")
dav.findProperty(CurrentUserPrincipal.NAME)?.let { (dav, second) ->
val currentUserPrincipal = second as CurrentUserPrincipal
currentUserPrincipal.href?.let { href ->
dav.location.resolve(href)?.let { principal ->
log.info("Found current-user-principal: $principal")
// service check
if (service != null && !providesService(principal, service)) {
log.info("$principal doesn't provide required $service service")
return null
}
// service check
if (service != null && !providesService(principal, service)) {
log.info("$principal doesn't provide required $service service")
return null
return principal
}
return principal.uri()
}
}
}
@@ -374,25 +377,21 @@ class DavResourceFinder(
// data classes
class Configuration(
val userName: String,
val password: String,
val credentials: Credentials,
val cardDAV: ServiceInfo?,
val calDAV: ServiceInfo?,
val logs: String
): Serializable {
// We have to use URI here because HttpUrl is not serializable!
): Parcelable {
class ServiceInfo: Serializable {
var principal: URI? = null
val homeSets = HashSet<URI>()
val collections = HashMap<URI, CollectionInfo>()
data class ServiceInfo(
var principal: HttpUrl? = null,
val homeSets: MutableSet<HttpUrl> = HashSet(),
val collections: MutableMap<HttpUrl, CollectionInfo> = HashMap(),
var email: String? = null
override fun toString() = ToStringBuilder.reflectionToString(this)!!
}
var email: String? = null
)
override fun toString(): String {
val builder = ReflectionToStringBuilder(this)
@@ -400,6 +399,74 @@ class DavResourceFinder(
return builder.toString()
}
override fun describeContents() = 0
override fun writeToParcel(dest: Parcel, flags: Int) {
fun writeServiceInfo(info: ServiceInfo?) {
if (info == null)
dest.writeByte(0)
else {
dest.writeByte(1)
dest.writeString(info.principal?.toString())
dest.writeInt(info.homeSets.size)
info.homeSets.forEach { dest.writeString(it.toString()) }
dest.writeInt(info.collections.size)
info.collections.forEach { url, collectionInfo ->
dest.writeString(url.toString())
dest.writeParcelable(collectionInfo, 0)
}
dest.writeString(info.email)
}
}
dest.writeSerializable(credentials)
writeServiceInfo(cardDAV)
writeServiceInfo(calDAV)
dest.writeString(logs)
}
@Suppress("unused")
@JvmField
val CREATOR = object: Parcelable.Creator<Configuration> {
override fun createFromParcel(source: Parcel): Configuration {
fun readCollections(): MutableMap<HttpUrl, CollectionInfo> {
val size = source.readInt()
val map = HashMap<HttpUrl, CollectionInfo>(size)
(1..size).forEach {
val url = HttpUrl.parse(source.readString())!!
map[url] = source.readParcelable(Thread.currentThread().contextClassLoader)
}
return map
}
fun readServiceInfo(): ServiceInfo? {
return if (source.readByte() == 0.toByte())
null
else
ServiceInfo(
source.readString()?.let { HttpUrl.parse(it) },
(1..source.readInt()).map { HttpUrl.parse(source.readString())!! }.toMutableSet(),
readCollections()
)
}
return Configuration(
source.readSerializable() as Credentials,
readServiceInfo(),
readServiceInfo(),
source.readString()
)
}
override fun newArray(size: Int) = arrayOfNulls<Configuration>(size)
}
}
}
@@ -8,9 +8,12 @@
package at.bitfire.davdroid.ui.setup
import android.app.Fragment
import android.net.Uri
import android.os.Bundle
import android.os.Handler
import android.os.Looper
import android.security.KeyChain
import android.support.v4.app.Fragment
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
@@ -37,10 +40,10 @@ class DefaultLoginCredentialsFragment: Fragment(), CompoundButton.OnCheckedChang
val password = it.getStringExtra(LoginActivity.EXTRA_PASSWORD)
if (url != null) {
v.login_type_url.isChecked = true
v.base_url.setText(url)
v.user_name.setText(username)
v.url_password.setText(password)
v.login_type_urlpwd.isChecked = true
v.urlpwd_base_url.setText(url)
v.urlpwd_user_name.setText(username)
v.urlpwd_password.setText(password)
} else {
v.login_type_email.isChecked = true
v.email_address.setText(username)
@@ -49,110 +52,153 @@ class DefaultLoginCredentialsFragment: Fragment(), CompoundButton.OnCheckedChang
}
}
v.login.setOnClickListener({ _ ->
validateLoginData()?.let { credentials ->
DetectConfigurationFragment.newInstance(credentials).show(fragmentManager, null)
v.urlcert_select_cert.setOnClickListener {
KeyChain.choosePrivateKeyAlias(activity, { alias ->
Handler(Looper.getMainLooper()).post({
v.urlcert_cert_alias.text = alias
v.urlcert_cert_alias.error = null
})
}, null, null, null, -1, view!!.urlcert_cert_alias.text.toString())
}
v.login.setOnClickListener {
validateLoginData()?.let { info ->
DetectConfigurationFragment.newInstance(info).show(fragmentManager, null)
}
})
}
// initialize to Login by email
onCheckedChanged(v)
v.login_type_email.setOnCheckedChangeListener(this)
v.login_type_url.setOnCheckedChangeListener(this)
v.login_type_urlpwd.setOnCheckedChangeListener(this)
v.login_type_urlcert.setOnCheckedChangeListener(this)
return v
}
override fun onCheckedChanged(buttonView: CompoundButton, isChecked: Boolean) {
onCheckedChanged(view)
onCheckedChanged(view!!)
}
private fun onCheckedChanged(v: View) {
val loginByEmail = !v.login_type_url.isChecked
v.login_type_email_details.visibility = if (loginByEmail) View.VISIBLE else View.GONE
v.login_type_url_details.visibility = if (loginByEmail) View.GONE else View.VISIBLE
(if (loginByEmail) v.email_address else v.base_url).requestFocus()
v.login_type_email_details.visibility = if (v.login_type_email.isChecked) View.VISIBLE else View.GONE
v.login_type_urlpwd_details.visibility = if (v.login_type_urlpwd.isChecked) View.VISIBLE else View.GONE
v.login_type_urlcert_details.visibility = if (v.login_type_urlcert.isChecked) View.VISIBLE else View.GONE
}
private fun validateLoginData(): LoginCredentials? {
if (view.login_type_email.isChecked) {
var uri: URI? = null
var valid = true
private fun validateLoginData(): LoginInfo? {
val view = requireNotNull(view)
when {
// Login with email address
view.login_type_email.isChecked -> {
var uri: URI? = null
var valid = true
val email = view.email_address.text.toString()
if (!email.matches(Regex(".+@.+"))) {
view.email_address.error = getString(R.string.login_email_address_error)
valid = false
} else
try {
uri = URI("mailto", email, null)
} catch(e: URISyntaxException) {
view.email_address.error = e.localizedMessage
valid = false
}
val password = view.email_password.getText().toString()
if (password.isEmpty()) {
view.email_password.setError(getString(R.string.login_password_required))
valid = false
}
return if (valid && uri != null)
LoginCredentials(uri, email, password)
else
null
} else if (view.login_type_url.isChecked) {
var uri: URI? = null
var valid = true
val baseUrl = Uri.parse(view.base_url.text.toString())
val scheme = baseUrl.scheme
if (scheme.equals("http", true) || scheme.equals("https", true)) {
var host = baseUrl.host
if (host.isNullOrBlank()) {
view.base_url.error = getString(R.string.login_url_host_name_required)
val email = view.email_address.text.toString()
if (!email.matches(Regex(".+@.+"))) {
view.email_address.error = getString(R.string.login_email_address_error)
valid = false
} else
try {
host = IDN.toASCII(host)
} catch(e: IllegalArgumentException) {
Constants.log.log(Level.WARNING, "Host name not conforming to RFC 3490", e)
uri = URI("mailto", email, null)
} catch (e: URISyntaxException) {
view.email_address.error = e.localizedMessage
valid = false
}
val path = baseUrl.encodedPath
val port = baseUrl.port
try {
uri = URI(baseUrl.scheme, null, host, port, path, null, null)
} catch(e: URISyntaxException) {
view.base_url.error = e.localizedMessage
val password = view.email_password.text.toString()
if (password.isEmpty()) {
view.email_password.error = getString(R.string.login_password_required)
valid = false
}
} else {
view.base_url.error = getString(R.string.login_url_must_be_http_or_https)
valid = false
return if (valid && uri != null)
LoginInfo(uri, email, password)
else
null
}
val userName = view.user_name.text.toString()
if (userName.isBlank()) {
view.user_name.error = getString(R.string.login_user_name_required)
valid = false
// Login with URL and user name
view.login_type_urlpwd.isChecked -> {
var valid = true
val baseUrl = Uri.parse(view.urlpwd_base_url.text.toString())
val uri = validateBaseUrl(baseUrl, false, { message ->
view.urlpwd_base_url.error = message
valid = false
})
val userName = view.urlpwd_user_name.text.toString()
if (userName.isBlank()) {
view.urlpwd_user_name.error = getString(R.string.login_user_name_required)
valid = false
}
val password = view.urlpwd_password.text.toString()
if (password.isEmpty()) {
view.urlpwd_password.error = getString(R.string.login_password_required)
valid = false
}
return if (valid && uri != null)
LoginInfo(uri, userName, password)
else
null
}
val password = view.url_password.getText().toString()
if (password.isEmpty()) {
view.url_password.setError(getString(R.string.login_password_required))
valid = false
}
// Login with URL and client certificate
view.login_type_urlcert.isChecked -> {
var valid = true
return if (valid && uri != null)
LoginCredentials(uri, userName, password)
else
null
val baseUrl = Uri.parse(view.urlcert_base_url.text.toString())
val uri = validateBaseUrl(baseUrl, true, { message ->
view.urlcert_base_url.error = message
valid = false
})
val alias = view.urlcert_cert_alias.text.toString()
if (alias.isEmpty()) {
view.urlcert_cert_alias.error = ""
valid = false
}
if (valid && uri != null)
return LoginInfo(uri, certificateAlias = alias)
}
}
return null
}
private fun validateBaseUrl(baseUrl: Uri, httpsRequired: Boolean, reportError: (String) -> Unit): URI? {
var uri: URI? = null
val scheme = baseUrl.scheme
if ((!httpsRequired && scheme.equals("http", true)) || scheme.equals("https", true)) {
var host = baseUrl.host
if (host.isNullOrBlank())
reportError(getString(R.string.login_url_host_name_required))
else
try {
host = IDN.toASCII(host)
} catch (e: IllegalArgumentException) {
Constants.log.log(Level.WARNING, "Host name not conforming to RFC 3490", e)
}
val path = baseUrl.encodedPath
val port = baseUrl.port
try {
uri = URI(baseUrl.scheme, null, host, port, path, null, null)
} catch (e: URISyntaxException) {
reportError(e.localizedMessage)
}
} else
reportError(getString(if (httpsRequired)
R.string.login_url_must_be_https
else
R.string.login_url_must_be_http_or_https))
return uri
}
}
@@ -8,9 +8,17 @@
package at.bitfire.davdroid.ui.setup
import android.app.*
import android.content.*
import android.app.Dialog
import android.app.ProgressDialog
import android.content.Context
import android.content.DialogInterface
import android.content.Intent
import android.os.Bundle
import android.support.v4.app.DialogFragment
import android.support.v4.app.LoaderManager
import android.support.v4.content.AsyncTaskLoader
import android.support.v4.content.Loader
import android.support.v7.app.AlertDialog
import at.bitfire.davdroid.R
import at.bitfire.davdroid.log.Logger
import at.bitfire.davdroid.ui.DebugInfoActivity
@@ -21,9 +29,9 @@ import kotlin.concurrent.thread
class DetectConfigurationFragment: DialogFragment(), LoaderManager.LoaderCallbacks<Configuration> {
companion object {
val ARG_LOGIN_CREDENTIALS = "credentials"
const val ARG_LOGIN_CREDENTIALS = "credentials"
fun newInstance(credentials: LoginCredentials): DetectConfigurationFragment {
fun newInstance(credentials: LoginInfo): DetectConfigurationFragment {
val frag = DetectConfigurationFragment()
val args = Bundle(1)
args.putParcelable(ARG_LOGIN_CREDENTIALS, credentials)
@@ -53,25 +61,25 @@ class DetectConfigurationFragment: DialogFragment(), LoaderManager.LoaderCallbac
}
override fun onCreateLoader(id: Int, args: Bundle) =
ServerConfigurationLoader(activity, args.getParcelable(ARG_LOGIN_CREDENTIALS))
override fun onCreateLoader(id: Int, args: Bundle?) =
ServerConfigurationLoader(requireActivity(), args!!.getParcelable(ARG_LOGIN_CREDENTIALS))
override fun onLoadFinished(loader: Loader<Configuration>, data: Configuration?) {
data?.let {
if (it.calDAV == null && it.cardDAV == null)
// no service found: show error message
fragmentManager.beginTransaction()
requireFragmentManager().beginTransaction()
.add(NothingDetectedFragment.newInstance(it.logs), null)
.commitAllowingStateLoss()
.commit()
else
// service found: continue
fragmentManager.beginTransaction()
requireFragmentManager().beginTransaction()
.replace(android.R.id.content, AccountDetailsFragment.newInstance(data))
.addToBackStack(null)
.commitAllowingStateLoss()
.commit()
}
dismissAllowingStateLoss()
dismiss()
}
override fun onLoaderReset(loader: Loader<Configuration>) {}
@@ -80,7 +88,7 @@ class DetectConfigurationFragment: DialogFragment(), LoaderManager.LoaderCallbac
class NothingDetectedFragment: DialogFragment() {
companion object {
val KEY_LOGS = "logs"
const val KEY_LOGS = "logs"
fun newInstance(logs: String): NothingDetectedFragment {
val args = Bundle()
@@ -92,13 +100,13 @@ class DetectConfigurationFragment: DialogFragment(), LoaderManager.LoaderCallbac
}
override fun onCreateDialog(savedInstanceState: Bundle?) =
AlertDialog.Builder(activity)
AlertDialog.Builder(requireActivity())
.setTitle(R.string.login_configuration_detection)
.setIcon(R.drawable.ic_error_dark)
.setMessage(R.string.login_no_caldav_carddav)
.setNeutralButton(R.string.login_view_logs, { _, _ ->
val intent = Intent(activity, DebugInfoActivity::class.java)
intent.putExtra(DebugInfoActivity.KEY_LOGS, arguments.getString(KEY_LOGS))
intent.putExtra(DebugInfoActivity.KEY_LOGS, arguments!!.getString(KEY_LOGS))
startActivity(intent)
})
.setPositiveButton(android.R.string.ok, { _, _ ->
@@ -111,18 +119,18 @@ class DetectConfigurationFragment: DialogFragment(), LoaderManager.LoaderCallbac
class ServerConfigurationLoader(
context: Context,
val credentials: LoginCredentials
private val credentials: LoginInfo
): AsyncTaskLoader<Configuration>(context) {
var resourceFinder: DavResourceFinder? = null
private var resourceFinder: DavResourceFinder? = null
override fun onStartLoading() = forceLoad()
override fun onCancelLoad(): Boolean {
override fun cancelLoadInBackground() {
thread {
resourceFinder?.cancel()
resourceFinder = null
}
return true
}
override fun loadInBackground(): Configuration {
@@ -28,21 +28,18 @@ class LoginActivity: AppCompatActivity() {
* When set, "login by URL" will be activated by default, and the URL field will be set to this value.
* When not set, "login by email" will be activated by default.
*/
@JvmField
val EXTRA_URL = "url"
const val EXTRA_URL = "url"
/**
* When set, and {@link #EXTRA_PASSWORD} is set too, the user name field will be set to this value.
* When set, and {@link #EXTRA_URL} is not set, the email address field will be set to this value.
*/
@JvmField
val EXTRA_USERNAME = "username"
const val EXTRA_USERNAME = "username"
/**
* When set, the password field will be set to this value.
*/
@JvmField
val EXTRA_PASSWORD = "password"
const val EXTRA_PASSWORD = "password"
}
@@ -51,7 +48,7 @@ class LoginActivity: AppCompatActivity() {
if (savedInstanceState == null)
// first call, add first login fragment
fragmentManager.beginTransaction()
supportFragmentManager.beginTransaction()
.replace(android.R.id.content, DefaultLoginCredentialsFragment())
.commit()
}
@@ -10,34 +10,35 @@ package at.bitfire.davdroid.ui.setup
import android.os.Parcel
import android.os.Parcelable
import at.bitfire.davdroid.model.Credentials
import java.net.URI
data class LoginCredentials(
data class LoginInfo(
val uri: URI,
val userName: String,
val password: String
val credentials: Credentials
): Parcelable {
constructor(uri: URI, userName: String? = null, password: String? = null, certificateAlias: String? = null):
this(uri, Credentials(userName, password, certificateAlias))
override fun describeContents() = 0
override fun writeToParcel(dest: Parcel, flags: Int) {
dest.writeSerializable(uri)
dest.writeString(userName)
dest.writeString(password)
dest.writeSerializable(credentials)
}
companion object {
@JvmField
val CREATOR = object: Parcelable.Creator<LoginCredentials> {
val CREATOR = object: Parcelable.Creator<LoginInfo> {
override fun createFromParcel(source: Parcel) =
LoginCredentials(
LoginInfo(
source.readSerializable() as URI,
source.readString(),
source.readString()
source.readSerializable() as Credentials
)
override fun newArray(size: Int) = arrayOfNulls<LoginCredentials>(size)
override fun newArray(size: Int) = arrayOfNulls<LoginInfo>(size)
}
}
@@ -1,49 +0,0 @@
/*
* Copyright © Ricki Hirner (bitfire web engineering).
* All rights reserved. This program and the accompanying materials
* are made available under the terms of the GNU Public License v3.0
* which accompanies this distribution, and is available at
* http://www.gnu.org/licenses/gpl.html
*/
package at.bitfire.davdroid.ui.widget
import android.content.Context
import android.util.AttributeSet
import android.view.inputmethod.EditorInfo
import android.widget.CompoundButton
import android.widget.LinearLayout
import at.bitfire.davdroid.R
import kotlinx.android.synthetic.main.edit_password.view.*
class EditPassword(
context: Context,
attrs: AttributeSet?
): LinearLayout(context, attrs) {
val NS_ANDROID = "http://schemas.android.com/apk/res/android";
constructor(context: Context): this(context, null)
init {
inflate(context, R.layout.edit_password, this)
attrs?.let {
password.setHint(it.getAttributeResourceValue(NS_ANDROID, "hint", 0))
password.setText(it.getAttributeValue(NS_ANDROID, "text"))
}
show_password.setOnCheckedChangeListener({ _: CompoundButton, isChecked: Boolean ->
var inputType = password.inputType and EditorInfo.TYPE_MASK_VARIATION.inv()
inputType = inputType or if (isChecked) EditorInfo.TYPE_TEXT_VARIATION_VISIBLE_PASSWORD else EditorInfo.TYPE_TEXT_VARIATION_PASSWORD
password.inputType = inputType
})
}
fun getText() = password.text!!
fun setText(text: CharSequence?) = password.setText(text)
fun setError(error: CharSequence?) {
password.error = error
}
}
@@ -37,7 +37,7 @@ class MaximizedListView(
else {
adapter?.let { listAdapter ->
val widthSpec = View.MeasureSpec.makeMeasureSpec(width, MeasureSpec.EXACTLY)
for (i in 0 .. listAdapter.count - 1) {
for (i in 0 until listAdapter.count) {
val listItem = listAdapter.getView(i, null, this)
listItem.measure(widthSpec, View.MeasureSpec.UNSPECIFIED)
height += listItem.measuredHeight
@@ -1,15 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
~ Copyright © 2013 2016 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
-->
<selector xmlns:android="http://schemas.android.com/apk/res/android">
<item android:state_checked="true"
android:drawable="@drawable/ic_visibility_dark" />
<item android:state_checked="false"
android:drawable="@drawable/ic_visibility_off_dark" />
</selector>
+1 -1
View File
@@ -1,6 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
~ Copyright © 2013 2016 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
@@ -1,6 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
~ Copyright © 2013 2016 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
@@ -1,6 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
~ Copyright © 2013 2016 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
+1 -1
View File
@@ -1,6 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
~ Copyright © 2013 2016 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
@@ -1,4 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
~ Copyright © Ricki Hirner (bitfire web engineering).
~ All rights reserved. This program and the accompanying materials
~ are made available under the terms of the GNU Public License v3.0
~ which accompanies this distribution, and is available at
~ http://www.gnu.org/licenses/gpl.html
-->
<LinearLayout
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
+1 -1
View File
@@ -1,6 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
~ Copyright © 2013 2016 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
+1 -2
View File
@@ -1,6 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
~ Copyright © 2013 2016 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
@@ -10,7 +10,6 @@
<android.support.design.widget.CoordinatorLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:orientation="vertical"
android:fitsSystemWindows="true"
android:layout_width="match_parent"
android:layout_height="match_parent">
+1 -3
View File
@@ -1,6 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
~ Copyright © 2013 2016 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
@@ -47,7 +47,6 @@
style="@style/Widget.AppCompat.ProgressBar.Horizontal"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:visibility="gone"
android:indeterminate="true"/>
<at.bitfire.davdroid.ui.widget.MaximizedListView
@@ -87,7 +86,6 @@
style="@style/Widget.AppCompat.ProgressBar.Horizontal"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:visibility="gone"
android:indeterminate="true"/>
<at.bitfire.davdroid.ui.widget.MaximizedListView
@@ -1,6 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
~ Copyright © 2013 2016 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
@@ -14,7 +14,6 @@
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:fitsSystemWindows="true"
tools:openDrawer="start">
<include layout="@layout/accounts_content"/>
@@ -1,6 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
~ Copyright © 2013 2016 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
@@ -1,6 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
~ Copyright © 2013 2016 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
@@ -1,6 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
~ 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
@@ -1,103 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
~ Copyright © 2013 2016 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
-->
<ScrollView xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:layout_margin="@dimen/activity_margin">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical">
<LinearLayout
android:id="@+id/calendar_permissions"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:layout_marginBottom="16dp"
tools:ignore="UselessParent">
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
style="@style/TextView.Heading"
android:text="@string/permissions_calendar"/>
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="@string/permissions_calendar_details"/>
<Button
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/permissions_calendar_request"
android:onClick="requestCalendarPermissions"/>
</LinearLayout>
<LinearLayout
android:id="@+id/contacts_permissions"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:layout_marginBottom="16dp"
tools:ignore="UselessParent">
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
style="@style/TextView.Heading"
android:text="@string/permissions_contacts"/>
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="@string/permissions_contacts_details"/>
<Button
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/permissions_contacts_request"
android:onClick="requestContactsPermissions"/>
</LinearLayout>
<LinearLayout
android:id="@+id/opentasks_permissions"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
tools:ignore="UselessParent">
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
style="@style/TextView.Heading"
android:text="@string/permissions_opentasks"/>
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="@string/permissions_opentasks_details"/>
<Button
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/permissions_opentasks_request"
android:onClick="requestOpenTasksPermissions"/>
</LinearLayout>
</LinearLayout>
</ScrollView>
-29
View File
@@ -1,29 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
~ Copyright © 2013 2016 Ricki Hirner (bitfire web engineering).
~ All rights reserved. This program and the accompanying materials
~ are made available under the terms of the GNU Public License v3.0
~ which accompanies this distribution, and is available at
~ http://www.gnu.org/licenses/gpl.html
-->
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent">
<EditText
android:id="@+id/password"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:inputType="textPassword"/>
<CheckBox
android:id="@+id/show_password"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginLeft="4dp"
android:button="@drawable/password_eye_button"
android:checked="false"/>
</LinearLayout>
Loaded 100 of 144 files, more files were not shown because too many files have changed in this diff. Show more