diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index a5c8b103ae616508449ebaa66b93e90d04b385a6..e0097ceeb3b813d6baba4b28ce589fdf6a1c6fe0 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -1,30 +1,22 @@ -image: registry.gitlab.com/bitfireat/davdroid:latest +image: "registry.gitlab.e.foundation:5000/e/apps/docker-android-apps-cicd:latest" + +stages: +- build before_script: - - git submodule update --init --recursive - - export GRADLE_USER_HOME=`pwd`/.gradle; chmod +x gradlew +- git submodule update --init --recursive +- export GRADLE_USER_HOME=$(pwd)/.gradle +- chmod +x ./gradlew cache: + key: ${CI_PROJECT_ID} paths: - - .gradle/ - - apk/ - -test: - script: - - (cd /sdk/emulator; ./emulator @test -no-audio -no-window & wait-for-emulator.sh) - - ./gradlew check mergeAndroidReports - artifacts: - paths: - - app/build/outputs/lint-results-debug.html - - app/build/reports - - build/reports + - .gradle/ -pages: +build: + stage: build script: - - ./gradlew app:dokka - - mkdir public && mv app/build/dokka public + - ./gradlew build artifacts: paths: - - public - only: - - master-ose + - app/build/outputs/apk/standard/ diff --git a/.gitmodules b/.gitmodules index 938ab7295241f4c5e3aa11b3a322ad09095e0e80..a5552e3aea9f54cd7fbdfeada82452c7d37ead7a 100644 --- a/.gitmodules +++ b/.gitmodules @@ -1,12 +1,16 @@ -[submodule "dav4android"] - path = dav4android - url = ../dav4android.git [submodule "ical4android"] path = ical4android - url = ../ical4android.git + url = https://gitlab.e.foundation/e/apps/ical4android.git + branch = sprint_freetown [submodule "vcard4android"] path = vcard4android - url = ../vcard4android.git + url = https://gitlab.e.foundation/e/apps/vcard4android.git + branch = sprint_freetown [submodule "cert4android"] path = cert4android - url = ../cert4android.git + url = https://gitlab.e.foundation/e/apps/cert4android.git + branch = sprint_freetown +[submodule "dav4jvm"] + path = dav4jvm + url = https://gitlab.e.foundation/e/apps/dav4android.git + branch = sprint_freetown diff --git a/AUTHORS b/AUTHORS new file mode 100644 index 0000000000000000000000000000000000000000..6a3fe19af22a2d1c6da67834485ff369ef369c89 --- /dev/null +++ b/AUTHORS @@ -0,0 +1,3 @@ +© 2018-2019 - Author: Nihar Thakkar +© 2018-2019 - Author: Vincent Bourgmayer +© 2018-2019 - Author: Romain Hunault diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000000000000000000000000000000000000..6d85563ddf25caf53a2e216b712f86a9916e2b11 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,47 @@ + +Contributing to DAVx⁵ +===================== + +**Thank you for your interest in contributing to DAVx⁵!** + +Because you're reading this, you're probably interested in +contributing to the DAVx⁵ code. [Other ways to contribute: +see here.](https://www.davx5.com/donate#c306) + +To contribute: + +1. It's good idea to have a look at the [DAVx⁵ Roadmap](https://gitlab.com/bitfireAT/davx5-ose/wikis/Roadmap) + to see whether the change is already planned. Maybe there's even a link to a + corresponding forum thread there. +1. Determine which project the changes shall go to. There's + the DAVx⁵ main project (this repo), and the [related + libraries](README.md). +1. Please post to the [DAVx⁵ development forum](https://www.davx5.com/forums) + before doing actual work (unless you do it only for yourself, of course). + This will help to coordinate activities and you'll also get hints + about where to start and possible pitfalls. +1. Fork the repository. +1. Do the changes in your repository. +1. Submit a pull request to the original project. +1. Post in the forum again (to make sure the pull request is being notified). + + +Questions, discussion +===================== + +We're happy to see questions, discussions etc. in the +[DAVx⁵ development forum](https://www.davx5.com/forums)! + + +Licensing +========= + +All code has to be licensed under the GPL. + +We (bitfire.at, initial developers) are also asking you to double-license the +code so that we can also use it for related non-open source projects like +[Managed DAVx⁵](https://www.davx5.com/organizations/managed-davx5). + +Please find more about this in the Contributor's License Agreement (CLA) +we'll send to you if you want to contribute. + diff --git a/README.md b/README.md index 94684a5b02b972f39847dba570298fd979f99ed6..76aa1cd900272fdbc69fc26f7fe5721acd4079c8 100644 --- a/README.md +++ b/README.md @@ -1,40 +1,37 @@ - -[![build status](https://gitlab.com/bitfireAT/davdroid/badges/master-ose/build.svg)](https://gitlab.com/bitfireAT/davdroid/commits/master-ose) - - -DAVdroid +Account Manager ======== -Please see the [DAVdroid Web site](https://www.davdroid.com) for -comprehensive information about DAVdroid. +Account Manager is a fork of DAVx⁵. + +Please see the [DAVx⁵ Web site](https://www.davx5.com) for +comprehensive information about DAVx⁵. -DAVdroid is licensed under the [GPLv3 License](LICENSE). +DAVx⁵ is licensed under the [GPLv3 License](LICENSE). -News and updates: [@davdroidapp](https://twitter.com/davdroidapp) on Twitter +News and updates: [@davx5app](https://twitter.com/davx5app) on Twitter -Help and discussion: [DAVdroid forums](https://www.davdroid.com/forums/) +Help, discussion, feature requests, bug reports and "issues": [DAVx⁵ forums](https://www.davx5.com/forums) -**If you want to support DAVdroid, please consider [donating to DAVdroid](https://www.davdroid.com/donate/) -or [purchasing it](https://www.davdroid.com/download/).** +**If you want to support DAVx⁵, please consider [donating to DAVx⁵](https://www.davx5.com/donate) +or [purchasing it](https://www.davx5.com/download).** -Generated KDoc: https://bitfireAT.gitlab.io/davdroid/dokka/app/ +Generated KDoc: https://bitfireAT.gitlab.io/davx5-ose/dokka/app/ -Parts of DAVdroid have been outsourced into these libraries: +Parts of DAVx⁵ have been outsourced into these libraries: * [cert4android](https://gitlab.com/bitfireAT/cert4android) – custom certificate management -* [dav4android](https://gitlab.com/bitfireAT/dav4android) – WebDAV/CalDav/CardDAV framework +* [dav4jvm](https://gitlab.com/bitfireAT/dav4jvm) – WebDAV/CalDav/CardDAV framework * [ical4android](https://gitlab.com/bitfireAT/ical4android) – iCalendar processing and Calendar Provider access -* [vcard4android](https://gitlab.com/bitfireAT/vcard4android) – VCard processing and Contacts Provider access +* [vcard4android](https://gitlab.com/bitfireAT/vcard4android) – vCard processing and Contacts Provider access USED THIRD-PARTY LIBRARIES ========================== -Those libraries are used by DAVdroid (alphabetically): +Those libraries are used by DAVx⁵ (alphabetically): -* [Ambilwarna](https://github.com/yukuku/ambilwarna) – [Apache License, Version 2.0](https://github.com/yukuku/ambilwarna/blob/master/LICENSE) +* [Color Picker](https://github.com/jaredrummler/ColorPicker) – [Apache License, Version 2.0](https://github.com/jaredrummler/ColorPicker/LICENSE) * [dnsjava](http://www.xbill.org/dnsjava/) – [BSD License](http://www.xbill.org/dnsjava/dnsjava-current/LICENSE) * [ez-vcard](https://github.com/mangstadt/ez-vcard) – [New BSD License](http://opensource.org/licenses/BSD-3-Clause) * [iCal4j](https://github.com/ical4j/ical4j) – [New BSD License](http://sourceforge.net/p/ical4j/ical4j/ci/default/tree/LICENSE) * [okhttp](https://square.github.io/okhttp) – [Apache License, Version 2.0](https://square.github.io/okhttp/#license) -* [Project Lombok](http://projectlombok.org/) – [MIT License](http://opensource.org/licenses/mit-license.php) diff --git a/app/build.gradle b/app/build.gradle index 23ee887aaf6cd7ec8ae3d23b98c6b6997ca5420d..7729183cfc5b931549e2b205db5080a29c7e0fe9 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -9,32 +9,42 @@ apply plugin: 'com.android.application' apply plugin: 'kotlin-android' apply plugin: 'kotlin-android-extensions' -apply plugin: 'org.jetbrains.dokka-android' +apply plugin: 'kotlin-kapt' +apply plugin: 'org.jetbrains.dokka' android { - compileSdkVersion 28 - buildToolsVersion '28.0.3' + compileSdkVersion 29 + buildToolsVersion '29.0.2' defaultConfig { - applicationId "at.bitfire.davdroid" + applicationId "foundation.e.accountmanager" - versionCode 248 + versionCode 328 + versionName '2.6.3' buildConfigField "long", "buildTime", System.currentTimeMillis() + "L" buildConfigField "boolean", "customCerts", "true" - minSdkVersion 19 // Android 4.4 - targetSdkVersion 28 // Android 9.0 + minSdkVersion 24 // Android 7.1 + targetSdkVersion 29 // Android 10.0 + multiDexEnabled true // >64k methods for Android 4.4 + + buildConfigField "String", "userAgent", "\"DAVx5\"" // when using this, make sure that notification icons are real bitmaps vectorDrawables.useSupportLibrary = true } - flavorDimensions "type" + compileOptions { + sourceCompatibility JavaVersion.VERSION_1_8 + targetCompatibility JavaVersion.VERSION_1_8 + } + + dataBinding.enabled = true + + flavorDimensions "distribution" productFlavors { standard { - versionName "2.0.5-ose" - - buildConfigField "boolean", "customCerts", "true" + versionName "2.6.3-ose" } } @@ -52,7 +62,6 @@ android { disable 'GoogleAppIndexingWarning' // we don't need Google indexing, thanks disable 'ImpliedQuantity', 'MissingQuantity' // quantities from Transifex may vary disable 'MissingTranslation', 'ExtraTranslation' // translations from Transifex are not always up to date - disable "OnClick" // doesn't recognize Kotlin onClick methods disable 'RtlEnabled' disable 'RtlHardcoded' disable 'Typos' @@ -63,38 +72,85 @@ android { } defaultConfig { - testInstrumentationRunner "android.support.test.runner.AndroidJUnitRunner" + manifestPlaceholders = [ + 'appAuthRedirectScheme': 'net.openid.appauthdemo' + ] + testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" + } + + dokka.configuration { + sourceLink { + url = "https://gitlab.com/bitfireAT/davx5-ose/tree/master-ose/" + lineSuffix = "#L" + } + jdkVersion = 7 + + externalDocumentationLink { + url = new URL("https://bitfireat.gitlab.io/cert4android/dokka/cert4android/") + packageListUrl = new URL("https://bitfireat.gitlab.io/cert4android/dokka/cert4android/package-list") + } + externalDocumentationLink { + url = new URL("https://bitfireat.gitlab.io/dav4jvm/dokka/dav4jvm/") + packageListUrl = new URL("https://bitfireat.gitlab.io/dav4jvm/dokka/dav4jvm/package-list") + } + externalDocumentationLink { + url = new URL("https://bitfireat.gitlab.io/ical4android/dokka/ical4android/") + packageListUrl = new URL("https://bitfireat.gitlab.io/ical4android/dokka/ical4android/package-list") + } + externalDocumentationLink { + url = new URL("https://bitfireat.gitlab.io/vcard4android/dokka/vcard4android/") + packageListUrl = new URL("https://bitfireat.gitlab.io/vcard4android/dokka/vcard4android/package-list") + } } } dependencies { implementation project(':cert4android') - implementation project(':dav4android') implementation project(':ical4android') implementation project(':vcard4android') - implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlin_version" - - implementation 'com.android.support:appcompat-v7:28.0.0' - implementation 'com.android.support:cardview-v7:28.0.0' - implementation 'com.android.support:design:28.0.0' - implementation 'com.android.support:preference-v14:28.0.0' - - implementation 'com.github.yukuku:ambilwarna:2.0.1' - implementation 'com.mikepenz:aboutlibraries:6.0.9' + implementation 'androidx.multidex:multidex:2.0.1' + implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk8:${versions.kotlin}" + + implementation 'androidx.appcompat:appcompat:1.1.0' + implementation 'androidx.cardview:cardview:1.0.0' + implementation 'androidx.constraintlayout:constraintlayout:1.1.3' + implementation 'androidx.fragment:fragment-ktx:1.1.0' + implementation 'androidx.lifecycle:lifecycle-extensions:2.1.0' + implementation 'androidx.lifecycle:lifecycle-viewmodel-ktx:2.1.0' + implementation 'androidx.paging:paging-runtime-ktx:2.1.0' + implementation 'androidx.preference:preference:1.1.0' + implementation 'androidx.swiperefreshlayout:swiperefreshlayout:1.1.0-alpha03' + implementation 'com.google.android:flexbox:1.1.0' + implementation 'com.google.android.material:material:1.2.0-alpha03' + implementation 'androidx.browser:browser:1.0.0' + + def room_version = '2.2.2' + implementation "androidx.room:room-runtime:$room_version" + implementation "androidx.room:room-ktx:$room_version" + kapt "androidx.room:room-compiler:$room_version" + + implementation(':dav4jvm') { + exclude group: 'org.ogce', module: 'xpp3' // Android comes with its own XmlPullParser + } - implementation 'com.squareup.okhttp3:logging-interceptor:3.11.0' + implementation 'com.jaredrummler:colorpicker:1.1.0' + implementation('com.mikepenz:aboutlibraries:7.0.4') + implementation "com.squareup.okhttp3:okhttp:${versions.okhttp}" + implementation "com.squareup.okhttp3:logging-interceptor:${versions.okhttp}" implementation 'commons-io:commons-io:2.6' - implementation 'dnsjava:dnsjava:2.1.8' - implementation 'org.apache.commons:commons-lang3:3.7' - implementation 'org.apache.commons:commons-collections4:4.1' + implementation 'dnsjava:dnsjava:2.1.9' + implementation 'org.apache.commons:commons-collections4:4.4' + implementation 'org.apache.commons:commons-lang3:3.9' + implementation 'net.openid:appauth:0.7.0' + implementation 'com.google.android.material:material:1.0.0' // for tests - androidTestImplementation 'com.android.support.test:runner:1.0.2' - androidTestImplementation 'com.android.support.test:rules:1.0.2' + androidTestImplementation 'androidx.test:runner:1.2.0' + androidTestImplementation 'androidx.test:rules:1.2.0' androidTestImplementation 'junit:junit:4.12' - androidTestImplementation 'com.squareup.okhttp3:mockwebserver:3.11.0' + androidTestImplementation "com.squareup.okhttp3:mockwebserver:${versions.okhttp}" testImplementation 'junit:junit:4.12' - testImplementation 'com.squareup.okhttp3:mockwebserver:3.11.0' + testImplementation "com.squareup.okhttp3:mockwebserver:${versions.okhttp}" } diff --git a/app/proguard-rules.txt b/app/proguard-rules.txt index 2b5c8fcdaf8ff87b4530b754c42010f9d3b3d6f4..d443b2e9a21de0fa0e6efcddf7f74f8befa94b11 100644 --- a/app/proguard-rules.txt +++ b/app/proguard-rules.txt @@ -1,8 +1,8 @@ -# ProGuard usage for DAVdroid: +# ProGuard usage for DAVx⁵: # shrinking yes (main reason for using ProGuard) # optimization yes -# obfuscation no (DAVdroid is open-source) +# obfuscation no (DAVx⁵ is open-source) # preverification no -dontobfuscate @@ -15,6 +15,12 @@ # Kotlin -dontwarn kotlin.** +# https://github.com/material-components/material-components-android/issues/387 +-keep class com.google.android.material.tabs.** {*;} + +# Apache Commons +-dontwarn javax.script.** + # ez-vcard -dontwarn ezvcard.io.json.** # JSON serializer (for jCards) not used -dontwarn freemarker.** # freemarker templating library (for creating hCards) not used @@ -32,13 +38,19 @@ -keep class org.threeten.bp.** { *; } # keep ThreeTen (for time zone processing) # okhttp +-dontwarn javax.annotation.** -dontwarn okio.** --dontwarn javax.annotation.Nullable --dontwarn javax.annotation.ParametersAreNonnullByDefault +-dontwarn org.codehaus.mojo.animal_sniffer.IgnoreJRERequirement -dontwarn org.conscrypt.** # dnsjava -dontwarn sun.net.spi.nameservice.** # not available on Android -# DAVdroid + libs --keep class at.bitfire.** { *; } # all DAVdroid code is required +# DAVx⁵ + libs +-keep class foundation.e.** { *; } # all DAVx⁵ code is required + +# we use enum classes (https://www.guardsquare.com/en/products/proguard/manual/examples#enumerations) +-keepclassmembers,allowoptimization enum * { + public static **[] values(); + public static ** valueOf(java.lang.String); +} diff --git a/app/src/androidTest/java/at/bitfire/davdroid/CustomTlsSocketFactoryTest.kt b/app/src/androidTest/java/at/bitfire/davdroid/CustomTlsSocketFactoryTest.kt deleted file mode 100644 index 30d9b6441ac45d918ff7be668cd6e739ac5aba4d..0000000000000000000000000000000000000000 --- a/app/src/androidTest/java/at/bitfire/davdroid/CustomTlsSocketFactoryTest.kt +++ /dev/null @@ -1,128 +0,0 @@ -/* - * Copyright © Ricki Hirner (bitfire web engineering). - * All rights reserved. This program and the accompanying materials - * are made available under the terms of the GNU Public License v3.0 - * which accompanies this distribution, and is available at - * http://www.gnu.org/licenses/gpl.html - */ - -package at.bitfire.davdroid - -import android.support.test.InstrumentationRegistry.getInstrumentation -import at.bitfire.cert4android.CustomCertManager -import okhttp3.OkHttpClient -import okhttp3.Request -import okhttp3.mockwebserver.MockWebServer -import org.apache.commons.io.IOUtils -import org.apache.commons.lang3.ArrayUtils.contains -import org.junit.After -import org.junit.Assert.* -import org.junit.Before -import org.junit.Test -import java.net.Socket -import java.security.KeyFactory -import java.security.KeyStore -import java.security.Principal -import java.security.cert.CertificateFactory -import java.security.cert.X509Certificate -import java.security.spec.PKCS8EncodedKeySpec -import javax.net.ssl.SSLSocket -import javax.net.ssl.TrustManagerFactory -import javax.net.ssl.X509ExtendedKeyManager -import javax.net.ssl.X509TrustManager - -class CustomTlsSocketFactoryTest { - - private lateinit var certMgr: CustomCertManager - private lateinit var factory: CustomTlsSocketFactory - private val server = MockWebServer() - - @Before - fun startServer() { - certMgr = CustomCertManager(getInstrumentation().context, false, true) - factory = CustomTlsSocketFactory(null, certMgr) - server.start() - } - - @After - fun stopServer() { - server.shutdown() - certMgr.close() - } - - - @Test - fun testSendClientCertificate() { - var public: X509Certificate? = null - javaClass.classLoader.getResourceAsStream("sample.crt").use { - public = CertificateFactory.getInstance("X509").generateCertificate(it) as? X509Certificate - } - assertNotNull(public) - - val keyFactory = KeyFactory.getInstance("RSA") - val private = keyFactory.generatePrivate(PKCS8EncodedKeySpec(readResource("sample.key"))) - assertNotNull(private) - - val keyStore = KeyStore.getInstance("AndroidKeyStore") - keyStore.load(null) - val alias = "sample" - keyStore.setKeyEntry(alias, private, null, arrayOf(public)) - assertTrue(keyStore.containsAlias(alias)) - - val trustManagerFactory = TrustManagerFactory.getInstance("X509") - trustManagerFactory.init(null as KeyStore?) - val trustManager = trustManagerFactory.trustManagers.first() as X509TrustManager - - val factory = CustomTlsSocketFactory(object: X509ExtendedKeyManager() { - override fun getServerAliases(p0: String?, p1: Array?): Array? = null - override fun chooseServerAlias(p0: String?, p1: Array?, p2: Socket?) = null - - override fun getClientAliases(p0: String?, p1: Array?) = - arrayOf(alias) - - override fun chooseClientAlias(p0: Array?, p1: Array?, 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) - } - } - -} diff --git a/app/src/androidTest/java/at/bitfire/davdroid/settings/DefaultsProviderTest.kt b/app/src/androidTest/java/at/bitfire/davdroid/settings/DefaultsProviderTest.kt deleted file mode 100644 index 96081ba9dbdfd3f31040002b091ce2b47cbebbf3..0000000000000000000000000000000000000000 --- a/app/src/androidTest/java/at/bitfire/davdroid/settings/DefaultsProviderTest.kt +++ /dev/null @@ -1,39 +0,0 @@ -/* - * Copyright © Ricki Hirner (bitfire web engineering). - * All rights reserved. This program and the accompanying materials - * are made available under the terms of the GNU Public License v3.0 - * which accompanies this distribution, and is available at - * http://www.gnu.org/licenses/gpl.html - */ - -package at.bitfire.davdroid.settings - -import at.bitfire.davdroid.App -import junit.framework.Assert.assertEquals -import junit.framework.Assert.assertFalse -import org.junit.Test - -class DefaultsProviderTest { - - private val provider: Provider = DefaultsProvider() - - @Test - fun testHas() { - assertEquals(Pair(false, true), provider.has("notExisting")) - assertEquals(Pair(true, true), provider.has(App.OVERRIDE_PROXY)) - } - - @Test - fun testGet() { - assertEquals(Pair("localhost", true), provider.getString(App.OVERRIDE_PROXY_HOST)) - assertEquals(Pair(8118, true), provider.getInt(App.OVERRIDE_PROXY_PORT)) - } - - @Test - fun testPutRemove() { - assertEquals(Pair(false, true), provider.isWritable(App.OVERRIDE_PROXY)) - assertFalse(provider.putBoolean(App.OVERRIDE_PROXY, true)) - assertFalse(provider.remove(App.OVERRIDE_PROXY)) - } - -} \ No newline at end of file diff --git a/app/src/androidTest/java/at/bitfire/davdroid/settings/SettingsTest.kt b/app/src/androidTest/java/at/bitfire/davdroid/settings/SettingsTest.kt deleted file mode 100644 index bc986bb6168bd88160cfe600e144ddb4e443c957..0000000000000000000000000000000000000000 --- a/app/src/androidTest/java/at/bitfire/davdroid/settings/SettingsTest.kt +++ /dev/null @@ -1,44 +0,0 @@ -/* - * Copyright © Ricki Hirner (bitfire web engineering). - * All rights reserved. This program and the accompanying materials - * are made available under the terms of the GNU Public License v3.0 - * which accompanies this distribution, and is available at - * http://www.gnu.org/licenses/gpl.html - */ - -package at.bitfire.davdroid.settings - -import android.support.test.InstrumentationRegistry -import android.support.test.InstrumentationRegistry.getTargetContext -import at.bitfire.davdroid.App -import junit.framework.Assert.assertFalse -import junit.framework.Assert.assertTrue -import org.junit.After -import org.junit.Before -import org.junit.Test - -class SettingsTest { - - lateinit var settings: Settings.Stub - - @Before - fun init() { - InstrumentationRegistry.getContext().isRestricted - settings = Settings.getInstance(getTargetContext())!! - } - - @After - fun shutdown() { - settings.close() - } - - - @Test - fun testHas() { - assertFalse(settings.has("notExisting")) - - // provided by DefaultsProvider - assertTrue(settings.has(App.OVERRIDE_PROXY)) - } - -} \ No newline at end of file diff --git a/app/src/test/java/at/bitfire/davdroid/HttpClientTest.kt b/app/src/androidTest/java/foundation/e/accountmanager/HttpClientTest.kt similarity index 98% rename from app/src/test/java/at/bitfire/davdroid/HttpClientTest.kt rename to app/src/androidTest/java/foundation/e/accountmanager/HttpClientTest.kt index 220afffbd34ec97fbe9b4174cf51109788b14dde..8b526117a14328b5891fdfba4cc912d0c9b550e7 100644 --- a/app/src/test/java/at/bitfire/davdroid/HttpClientTest.kt +++ b/app/src/androidTest/java/foundation/e/accountmanager/HttpClientTest.kt @@ -6,7 +6,7 @@ * http://www.gnu.org/licenses/gpl.html */ -package at.bitfire.davdroid +package foundation.e.accountmanager import okhttp3.Request import okhttp3.mockwebserver.MockResponse diff --git a/app/src/androidTest/java/at/bitfire/davdroid/model/CollectionInfoTest.kt b/app/src/androidTest/java/foundation/e/accountmanager/model/CollectionTest.kt similarity index 53% rename from app/src/androidTest/java/at/bitfire/davdroid/model/CollectionInfoTest.kt rename to app/src/androidTest/java/foundation/e/accountmanager/model/CollectionTest.kt index 481d156f59ec4580b9b2abe5ede457e92dc6fa00..b0f8693b413b1d618ac57234b26170204ab0d3c4 100644 --- a/app/src/androidTest/java/at/bitfire/davdroid/model/CollectionInfoTest.kt +++ b/app/src/androidTest/java/foundation/e/accountmanager/model/CollectionTest.kt @@ -6,14 +6,12 @@ * http://www.gnu.org/licenses/gpl.html */ -package at.bitfire.davdroid.model +package foundation.e.accountmanager.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 androidx.test.filters.SmallTest +import foundation.e.dav4jvm.DavResource +import foundation.e.dav4jvm.property.ResourceType +import foundation.e.accountmanager.HttpClient import okhttp3.HttpUrl import okhttp3.mockwebserver.MockResponse import okhttp3.mockwebserver.MockWebServer @@ -22,7 +20,7 @@ import org.junit.Assert.* import org.junit.Before import org.junit.Test -class CollectionInfoTest { +class CollectionTest { private lateinit var httpClient: HttpClient private val server = MockWebServer() @@ -40,7 +38,7 @@ class CollectionInfoTest { @Test @SmallTest - fun testFromDavResource() { + fun testFromDavResponseAddressBook() { // r/w address book server.enqueue(MockResponse() .setResponseCode(207) @@ -55,17 +53,24 @@ class CollectionInfoTest { "" + "")) - var info: CollectionInfo? = null + lateinit var info: Collection DavResource(httpClient.okHttpClient, server.url("/")) .propfind(0, ResourceType.NAME) { response, _ -> - info = CollectionInfo(response) + info = Collection.fromDavResponse(response) ?: throw IllegalArgumentException() } - assertEquals(CollectionInfo.Type.ADDRESS_BOOK, info?.type) - assertTrue(info!!.privWriteContent) - assertTrue(info!!.privUnbind) - assertEquals("My Contacts", info?.displayName) - assertEquals("My Contacts Description", info?.description) + assertEquals(Collection.TYPE_ADDRESSBOOK, info.type) + assertTrue(info.privWriteContent) + assertTrue(info.privUnbind) + assertNull(info.supportsVEVENT) + assertNull(info.supportsVTODO) + assertNull(info.supportsVJOURNAL) + assertEquals("My Contacts", info.displayName) + assertEquals("My Contacts Description", info.description) + } + @Test + @SmallTest + fun testFromDavResponseCalendar() { // read-only calendar, no display name server.enqueue(MockResponse() .setResponseCode(207) @@ -82,53 +87,48 @@ class CollectionInfoTest { "" + "")) - info = null + lateinit var info: Collection DavResource(httpClient.okHttpClient, server.url("/")) .propfind(0, ResourceType.NAME) { response, _ -> - info = CollectionInfo(response) - } - assertEquals(CollectionInfo.Type.CALENDAR, info?.type) - assertFalse(info!!.privWriteContent) - assertFalse(info!!.privUnbind) - assertNull(info?.displayName) - assertEquals("My Calendar", info?.description) - assertEquals(0xFFFF0000.toInt(), info?.color) - assertEquals("tzdata", info?.timeZone) - assertTrue(info!!.supportsVEVENT) - assertTrue(info!!.supportsVTODO) + info = Collection.fromDavResponse(response) ?: throw IllegalArgumentException() + } + assertEquals(Collection.TYPE_CALENDAR, info.type) + assertFalse(info.privWriteContent) + assertFalse(info.privUnbind) + assertNull(info.displayName) + assertEquals("My Calendar", info.description) + assertEquals(0xFFFF0000.toInt(), info.color) + assertEquals("tzdata", info.timezone) + assertTrue(info.supportsVEVENT!!) + assertTrue(info.supportsVTODO!!) + assertTrue(info.supportsVJOURNAL!!) } @Test - fun testFromDB() { - val values = ContentValues() - values.put(Collections.ID, 1) - values.put(Collections.SERVICE_ID, 1) - values.put(Collections.TYPE, CollectionInfo.Type.CALENDAR.name) - values.put(Collections.URL, "http://example.com") - values.put(Collections.PRIV_WRITE_CONTENT, 0) - values.put(Collections.PRIV_UNBIND, 0) - values.put(Collections.DISPLAY_NAME, "display name") - values.put(Collections.DESCRIPTION, "description") - values.put(Collections.COLOR, 0xFFFF0000) - values.put(Collections.TIME_ZONE, "tzdata") - values.put(Collections.SUPPORTS_VEVENT, 1) - values.put(Collections.SUPPORTS_VTODO, 1) - values.put(Collections.SYNC, 1) + @SmallTest + fun testFromDavResponseWebcal() { + // Webcal subscription + server.enqueue(MockResponse() + .setResponseCode(207) + .setBody("" + + "" + + " /webcal1" + + " " + + " Sample Subscription" + + " " + + " webcals://example.com/1.ics" + + " " + + "" + + "")) - 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) - assertFalse(info.privWriteContent) - assertFalse(info.privUnbind) - assertEquals("display name", info.displayName) - assertEquals("description", info.description) - assertEquals(0xFFFF0000.toInt(), info.color) - assertEquals("tzdata", info.timeZone) - assertTrue(info.supportsVEVENT) - assertTrue(info.supportsVTODO) - assertTrue(info.selected) + lateinit var info: Collection + DavResource(httpClient.okHttpClient, server.url("/")) + .propfind(0, ResourceType.NAME) { response, _ -> + info = Collection.fromDavResponse(response) ?: throw IllegalArgumentException() + } + assertEquals(Collection.TYPE_WEBCAL, info.type) + assertEquals("Sample Subscription", info.displayName) + assertEquals(HttpUrl.get("https://example.com/1.ics"), info.source) } } diff --git a/app/src/androidTest/java/foundation/e/accountmanager/model/DaoToolsTest.kt b/app/src/androidTest/java/foundation/e/accountmanager/model/DaoToolsTest.kt new file mode 100644 index 0000000000000000000000000000000000000000..83f7fdd1af7f28b8c8fa88f92624590ef998ad58 --- /dev/null +++ b/app/src/androidTest/java/foundation/e/accountmanager/model/DaoToolsTest.kt @@ -0,0 +1,63 @@ +package foundation.e.accountmanager.model + +import androidx.room.Room +import androidx.test.platform.app.InstrumentationRegistry +import okhttp3.HttpUrl +import org.junit.After +import org.junit.Assert.* +import org.junit.Before +import org.junit.Test + +class DaoToolsTest { + + private lateinit var db: AppDatabase + + @Before + fun createDb() { + val context = InstrumentationRegistry.getInstrumentation().context + db = Room.inMemoryDatabaseBuilder(context, AppDatabase::class.java).build() + } + + @After + fun closeDb() { + db.close() + } + + @Test + fun testSyncAll() { + val serviceDao = db.serviceDao() + val service = Service(id=0, accountName="test", type=Service.TYPE_CALDAV, principal = null) + service.id = serviceDao.insertOrReplace(service) + + val homeSetDao = db.homeSetDao() + val entry1 = HomeSet(id=1, serviceId=service.id, url=HttpUrl.get("https://example.com/1")) + val entry3 = HomeSet(id=3, serviceId=service.id, url=HttpUrl.get("https://example.com/3")) + val oldItems = listOf( + entry1, + HomeSet(id=2, serviceId=service.id, url=HttpUrl.get("https://example.com/2")), + entry3 + ) + homeSetDao.insert(oldItems) + + val newItems = mutableMapOf() + newItems[entry1.url] = entry1 + + // no id, because identity is given by the url + val updated = HomeSet(id=0, serviceId=service.id, + url=HttpUrl.get("https://example.com/2"), displayName="Updated Entry") + newItems[updated.url] = updated + + val created = HomeSet(id=4, serviceId=service.id, url=HttpUrl.get("https://example.com/4")) + newItems[created.url] = created + + DaoTools(homeSetDao).syncAll(oldItems, newItems, { it.url }) + + val afterSync = homeSetDao.getByService(service.id) + assertEquals(afterSync.size, 3) + assertFalse(afterSync.contains(entry3)) + assertTrue(afterSync.contains(entry1)) + assertTrue(afterSync.contains(updated)) + assertTrue(afterSync.contains(created)) + } + +} \ No newline at end of file diff --git a/app/src/androidTest/java/foundation/e/accountmanager/settings/DefaultsSettingsProviderTest.kt b/app/src/androidTest/java/foundation/e/accountmanager/settings/DefaultsSettingsProviderTest.kt new file mode 100644 index 0000000000000000000000000000000000000000..672c839b812cc7c3c244fea5803554899b9b33cd --- /dev/null +++ b/app/src/androidTest/java/foundation/e/accountmanager/settings/DefaultsSettingsProviderTest.kt @@ -0,0 +1,38 @@ +/* + * Copyright © Ricki Hirner (bitfire web engineering). + * All rights reserved. This program and the accompanying materials + * are made available under the terms of the GNU Public License v3.0 + * which accompanies this distribution, and is available at + * http://www.gnu.org/licenses/gpl.html + */ + +package foundation.e.accountmanager.settings + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Test + +class DefaultsSettingsProviderTest { + + private val provider: SettingsProvider = DefaultsProvider() + + @Test + fun testHas() { + assertEquals(Pair(false, true), provider.has("notExisting")) + assertEquals(Pair(true, true), provider.has(Settings.OVERRIDE_PROXY)) + } + + @Test + fun testGet() { + assertEquals(Pair("localhost", true), provider.getString(Settings.OVERRIDE_PROXY_HOST)) + assertEquals(Pair(8118, true), provider.getInt(Settings.OVERRIDE_PROXY_PORT)) + } + + @Test + fun testPutRemove() { + assertEquals(Pair(false, true), provider.isWritable(Settings.OVERRIDE_PROXY)) + assertFalse(provider.putBoolean(Settings.OVERRIDE_PROXY, true)) + assertFalse(provider.remove(Settings.OVERRIDE_PROXY)) + } + +} \ No newline at end of file diff --git a/app/src/androidTest/java/foundation/e/accountmanager/settings/SettingsTest.kt b/app/src/androidTest/java/foundation/e/accountmanager/settings/SettingsTest.kt new file mode 100644 index 0000000000000000000000000000000000000000..e7a433857fef96628c10febcfc39365dd6ed14f1 --- /dev/null +++ b/app/src/androidTest/java/foundation/e/accountmanager/settings/SettingsTest.kt @@ -0,0 +1,34 @@ +/* + * Copyright © Ricki Hirner (bitfire web engineering). + * All rights reserved. This program and the accompanying materials + * are made available under the terms of the GNU Public License v3.0 + * which accompanies this distribution, and is available at + * http://www.gnu.org/licenses/gpl.html + */ + +package foundation.e.accountmanager.settings + +import androidx.test.platform.app.InstrumentationRegistry +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test + +class SettingsTest { + + lateinit var settings: Settings + + @Before + fun initialize() { + settings = Settings.getInstance(InstrumentationRegistry.getInstrumentation().targetContext) + } + + @Test + fun testHas() { + assertFalse(settings.has("notExisting")) + + // provided by DefaultsProvider + assertTrue(settings.has(Settings.OVERRIDE_PROXY)) + } + +} \ No newline at end of file diff --git a/app/src/androidTest/java/foundation/e/accountmanager/syncadapter/SyncAdapterServiceTest.kt b/app/src/androidTest/java/foundation/e/accountmanager/syncadapter/SyncAdapterServiceTest.kt new file mode 100644 index 0000000000000000000000000000000000000000..d39369670c97e1c7b5083ffe98314cc9ebc19fea --- /dev/null +++ b/app/src/androidTest/java/foundation/e/accountmanager/syncadapter/SyncAdapterServiceTest.kt @@ -0,0 +1,31 @@ +package foundation.e.accountmanager.syncadapter + +import android.os.Bundle +import androidx.test.filters.SmallTest +import foundation.e.accountmanager.syncadapter.SyncAdapterService.SyncAdapter +import org.junit.Assert.assertArrayEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +class SyncAdapterServiceTest { + + @Test + @SmallTest + fun testPriorityCollections() { + val extras = Bundle() + assertTrue(SyncAdapter.priorityCollections(extras).isEmpty()) + + extras.putString(SyncAdapterService.SYNC_EXTRAS_PRIORITY_COLLECTIONS, "") + assertTrue(SyncAdapter.priorityCollections(extras).isEmpty()) + + extras.putString(SyncAdapterService.SYNC_EXTRAS_PRIORITY_COLLECTIONS, "123") + assertArrayEquals(longArrayOf(123), SyncAdapter.priorityCollections(extras).toLongArray()) + + extras.putString(SyncAdapterService.SYNC_EXTRAS_PRIORITY_COLLECTIONS, ",x,") + assertTrue(SyncAdapter.priorityCollections(extras).isEmpty()) + + extras.putString(SyncAdapterService.SYNC_EXTRAS_PRIORITY_COLLECTIONS, "1,2,3") + assertArrayEquals(longArrayOf(1,2,3), SyncAdapter.priorityCollections(extras).toLongArray()) + } + +} \ No newline at end of file diff --git a/app/src/androidTest/java/at/bitfire/davdroid/ui/setup/DavResourceFinderTest.kt b/app/src/androidTest/java/foundation/e/accountmanager/ui/setup/DavResourceFinderTest.kt similarity index 87% rename from app/src/androidTest/java/at/bitfire/davdroid/ui/setup/DavResourceFinderTest.kt rename to app/src/androidTest/java/foundation/e/accountmanager/ui/setup/DavResourceFinderTest.kt index 9a911950b64eff803a4da614309e1e09ed753eb1..b039f741d1f08be3593605d2bd5dfe9742091ac1 100644 --- a/app/src/androidTest/java/at/bitfire/davdroid/ui/setup/DavResourceFinderTest.kt +++ b/app/src/androidTest/java/foundation/e/accountmanager/ui/setup/DavResourceFinderTest.kt @@ -6,17 +6,18 @@ * 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 +package foundation.e.accountmanager.ui.setup + +import android.app.Application +import androidx.test.filters.SmallTest +import androidx.test.platform.app.InstrumentationRegistry +import foundation.e.dav4jvm.DavResource +import foundation.e.dav4jvm.property.AddressbookHomeSet +import foundation.e.dav4jvm.property.ResourceType +import foundation.e.accountmanager.HttpClient +import foundation.e.accountmanager.log.Logger +import foundation.e.accountmanager.model.Credentials +import foundation.e.accountmanager.ui.setup.DavResourceFinder.Configuration.ServiceInfo import okhttp3.mockwebserver.Dispatcher import okhttp3.mockwebserver.MockResponse import okhttp3.mockwebserver.MockWebServer @@ -44,18 +45,21 @@ class DavResourceFinderTest { lateinit var finder: DavResourceFinder lateinit var client: HttpClient - lateinit var loginInfo: LoginInfo + lateinit var loginModel: LoginModel @Before fun initServerAndClient() { server.setDispatcher(TestDispatcher()) server.start() - loginInfo = LoginInfo(URI.create("/"), Credentials("mock", "12345")) - finder = DavResourceFinder(getTargetContext(), loginInfo) + val application = InstrumentationRegistry.getInstrumentation().targetContext.applicationContext as Application + loginModel = LoginModel() + loginModel.baseURI = URI.create("/") + loginModel.credentials = Credentials("mock", "12345") + finder = DavResourceFinder(InstrumentationRegistry.getInstrumentation().targetContext, loginModel) client = HttpClient.Builder() - .addAuthentication(null, loginInfo.credentials) + .addAuthentication(null, loginModel.credentials!!) .build() } diff --git a/app/src/androidTest/res/drawable-hdpi/ic_launcher.png b/app/src/androidTest/res/drawable-hdpi/ic_launcher.png deleted file mode 100644 index 96a442e5b8e9394ccf50bab9988cb2316026245d..0000000000000000000000000000000000000000 Binary files a/app/src/androidTest/res/drawable-hdpi/ic_launcher.png and /dev/null differ diff --git a/app/src/androidTest/res/drawable-ldpi/ic_launcher.png b/app/src/androidTest/res/drawable-ldpi/ic_launcher.png deleted file mode 100644 index 99238729d8753585237a65b91c7cde426c90baef..0000000000000000000000000000000000000000 Binary files a/app/src/androidTest/res/drawable-ldpi/ic_launcher.png and /dev/null differ diff --git a/app/src/androidTest/res/drawable-mdpi/ic_launcher.png b/app/src/androidTest/res/drawable-mdpi/ic_launcher.png deleted file mode 100644 index 359047dfa4ed206e41e2354f9c6b307e713efe32..0000000000000000000000000000000000000000 Binary files a/app/src/androidTest/res/drawable-mdpi/ic_launcher.png and /dev/null differ diff --git a/app/src/androidTest/res/drawable-xhdpi/ic_launcher.png b/app/src/androidTest/res/drawable-xhdpi/ic_launcher.png deleted file mode 100644 index 71c6d760f05183ef8a47c614d8d13380c8528499..0000000000000000000000000000000000000000 Binary files a/app/src/androidTest/res/drawable-xhdpi/ic_launcher.png and /dev/null differ diff --git a/app/src/androidTest/resources/sample.crt b/app/src/androidTest/resources/sample.crt deleted file mode 100644 index c8139807ced56b2a9859a38e7180dc87d07ab1d6..0000000000000000000000000000000000000000 --- a/app/src/androidTest/resources/sample.crt +++ /dev/null @@ -1,24 +0,0 @@ ------BEGIN CERTIFICATE----- -MIIEEzCCAfsCAQEwDQYJKoZIhvcNAQEFBQAwRjELMAkGA1UEBhMCQ0ExEzARBgNV -BAgMClNvbWUtU3RhdGUxEDAOBgNVBAoMB0NBIENlcnQxEDAOBgNVBAMMB0NBIENl -cnQwHhcNMTgwMTEzMjAyOTI5WhcNMTkwMTEzMjAyOTI5WjBZMQswCQYDVQQGEwJD -QTETMBEGA1UECAwKU29tZS1TdGF0ZTEhMB8GA1UECgwYSW50ZXJuZXQgV2lkZ2l0 -cyBQdHkgTHRkMRIwEAYDVQQDDAlVc2VyIENlcnQwggEiMA0GCSqGSIb3DQEBAQUA -A4IBDwAwggEKAoIBAQDqOyHAeG4psE/f6i/eTfwbhn6j7WaFXxZiSOWwpQZmzRrx -MrfkABJCk0X7KNgCaJcmBkG9G1Ri4HfKrxvJFswMXknlq+0ulGBk7oDnZM+pihuX -3D9VCWMMkCqYhLCGADj2zB2mkX4LpcMRi6XoOetKURE/vcIy7rSLAtJM6ZRdftfh -2ZxnautS1Tyujh9Au3NI/+Of80tT/nA+oBJQeT1fB/ga1OQlZP5kjSaA7IPiIbTz -QBO+r898MvqK/lwsvOYnWAp7TY03z+vPfCs0zjijZEl9Wrl0hW6o5db5kU1v5bcr -p87hxFJsGD2HIr2y6kvYfL2hn+h9iANyYdRnUgapAgMBAAEwDQYJKoZIhvcNAQEF -BQADggIBAHANsiJITedXPyp89lVMEmGY3zKtOqgQ3tqjvjlNt2sdPnj7wmZbmrNd -sa90S/UwOn8PzEFOVxYy1BPlljlEjtjmc4OHMcm4P4Zv36uawHilmK8V+zT59gCK -ftB5FP2TLFUFi2X9o8J06d0xJRE77uewN155NV4RmPuP4b/tMmeixoQppHqLqEr5 -lgEUnt3Mh1ctmeFQFJR6lJ01hlB0gdpVHIhzrVLTO3uo8ePLJTmxP6tyKl/HXj9F -mpVsKb1kriKwbkGczfw99OUZeUVbTwQOR07r0SrG71B7IuDvxIORnhQc1OUjt7ob -wjdaZauAHxpGBRu+hw9Yqaxchk9Gldy1nEjGyyVCD0FU5taXbl8PhBWEDc4U9tI+ -xVNmPpsSuCsbz3Mjd1YIVRGL99vLrKsQcj+TNM+jJKKRKes3ihl+l/0FwG6UuO7L -EvjlUg5hOtYi1D7xuYyMjroGBGh7swYMt6w4eCDbcjzcCkaCi0H2pScM/rLBpDjS -LIoGCvZ1LBdi933/iOj1/8dxGZwY6fEgcyiD2n0xAgYIniLWjEZXOMdIK5FNTNga -Tswanvp+6Noa4oIu/hl/LXvPMsouaWfSEbRe0Dshi3GpLj3YtEHoN9DHB8bn7jy5 -34By81GT41m5kq3hWP//x9kSHYSADpbovCbKbElU1qSt6vTVR4nq ------END CERTIFICATE----- diff --git a/app/src/androidTest/resources/sample.key b/app/src/androidTest/resources/sample.key deleted file mode 100644 index af8f903d41f5948cda0eb46c3f471d2ed013a099..0000000000000000000000000000000000000000 Binary files a/app/src/androidTest/resources/sample.key and /dev/null differ diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 2dbdfb9816081176547f0d752438b154d99d2964..66670365ae6a3ec95f704aae8f240480a0ff4455 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -1,12 +1,5 @@ - - @@ -25,13 +18,6 @@ - - - - @@ -41,23 +27,25 @@ - - + + + + - - + @@ -78,11 +66,17 @@ + android:parentActivityName=".ui.AccountsActivity" + android:exported="true"> + + + + @@ -90,10 +84,10 @@ - - + android:name=".ui.account.AccountActivity" + android:parentActivityName=".ui.AccountsActivity" + android:theme="@style/AppTheme.NoActionBar"/> + + + + + android:resource="@xml/debug_paths" /> - + @@ -130,7 +127,6 @@ @@ -143,7 +139,6 @@ @@ -154,7 +149,7 @@ android:resource="@xml/sync_tasks"/> - + @@ -175,7 +170,6 @@ @@ -188,7 +182,6 @@ @@ -200,8 +193,268 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/aidl/at/bitfire/davdroid/settings/ISettings.aidl b/app/src/main/aidl/at/bitfire/davdroid/settings/ISettings.aidl deleted file mode 100644 index 5d0e05dedcccbf7499987a25db154a5d68a1f708..0000000000000000000000000000000000000000 --- a/app/src/main/aidl/at/bitfire/davdroid/settings/ISettings.aidl +++ /dev/null @@ -1,28 +0,0 @@ -package at.bitfire.davdroid.settings; - -import at.bitfire.davdroid.settings.ISettingsObserver; - -interface ISettings { - - void forceReload(); - - boolean has(String key); - - boolean getBoolean(String key, boolean defaultValue); - int getInt(String key, int defaultValue); - long getLong(String key, long defaultValue); - String getString(String key, String defaultValue); - - boolean isWritable(String key); - - boolean putBoolean(String key, boolean value); - boolean putInt(String key, int value); - boolean putLong(String key, long value); - boolean putString(String key, String value); - - boolean remove(String key); - - void registerObserver(ISettingsObserver observer); - void unregisterObserver(ISettingsObserver observer); - -} diff --git a/app/src/main/aidl/at/bitfire/davdroid/settings/ISettingsObserver.aidl b/app/src/main/aidl/at/bitfire/davdroid/settings/ISettingsObserver.aidl deleted file mode 100644 index ba122edc3f5486a0c8b024e968a8d9a5087ba3bc..0000000000000000000000000000000000000000 --- a/app/src/main/aidl/at/bitfire/davdroid/settings/ISettingsObserver.aidl +++ /dev/null @@ -1,7 +0,0 @@ -package at.bitfire.davdroid.settings; - -interface ISettingsObserver { - - void onSettingsChanged(); - -} diff --git a/app/src/main/ic_launcher-web.png b/app/src/main/ic_launcher-web.png index a531285cdfab8e8e637906f659400892297b3484..cafde5df9d1e89e1bdc0c9800cacbdabf7bd084a 100644 Binary files a/app/src/main/ic_launcher-web.png and b/app/src/main/ic_launcher-web.png differ diff --git a/app/src/main/java/at/bitfire/davdroid/CustomTlsSocketFactory.kt b/app/src/main/java/at/bitfire/davdroid/CustomTlsSocketFactory.kt deleted file mode 100644 index 9ade6eed1747eab3bbceab665ea996b11827a1d0..0000000000000000000000000000000000000000 --- a/app/src/main/java/at/bitfire/davdroid/CustomTlsSocketFactory.kt +++ /dev/null @@ -1,165 +0,0 @@ -/* - * Copyright © Ricki Hirner (bitfire web engineering). - * All rights reserved. This program and the accompanying materials - * are made available under the terms of the GNU Public License v3.0 - * which accompanies this distribution, and is available at - * http://www.gnu.org/licenses/gpl.html - */ - -package at.bitfire.davdroid - -import android.os.Build -import at.bitfire.davdroid.log.Logger -import java.io.IOException -import java.net.InetAddress -import java.net.Socket -import java.security.GeneralSecurityException -import java.util.* -import javax.net.ssl.* - -/** - * Custom TLS socket factory with support for - * - enabling/disabling algorithms depending on the Android version, - * - client certificate authentication - */ -class CustomTlsSocketFactory( - keyManager: KeyManager?, - trustManager: X509TrustManager -): SSLSocketFactory() { - - private var delegate: SSLSocketFactory - - companion object { - // Android 5.0+ (API level 21) provides reasonable default settings - // but it still allows SSLv3 - // https://developer.android.com/reference/javax/net/ssl/SSLSocket.html - var protocols: Array? = null - var cipherSuites: Array? = null - init { - if (Build.VERSION.SDK_INT >= 23) { - // Since Android 6.0 (API level 23), - // - TLSv1.1 and TLSv1.2 is enabled by default - // - SSLv3 is disabled by default - // - all modern ciphers are activated by default - protocols = null - cipherSuites = null - Logger.log.fine("Using device default TLS protocols/ciphers") - } else { - (SSLSocketFactory.getDefault().createSocket() as? SSLSocket)?.use { socket -> - try { - /* set reasonable protocol versions */ - // - enable all supported protocols (enables TLSv1.1 and TLSv1.2 on Android <5.0) - // - remove all SSL versions (especially SSLv3) because they're insecure now - val _protocols = LinkedList() - for (protocol in socket.supportedProtocols.filterNot { it.contains("SSL", true) }) - _protocols += protocol - Logger.log.info("Enabling (only) these TLS protocols: ${_protocols.joinToString(", ")}") - protocols = _protocols.toTypedArray() - - /* set up reasonable cipher suites */ - val knownCiphers = arrayOf( - // TLS 1.2 - "TLS_RSA_WITH_AES_256_GCM_SHA384", - "TLS_RSA_WITH_AES_128_GCM_SHA256", - "TLS_ECDHE_ECDSA_WITH_AES_128_CBC_SHA256", - "TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256", - "TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384", - "TLS_ECDHE_RSA_WITH_AES_128_CBC_SHA256", - "TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256", - // maximum interoperability - "TLS_RSA_WITH_3DES_EDE_CBC_SHA", - "SSL_RSA_WITH_3DES_EDE_CBC_SHA", - "TLS_RSA_WITH_AES_128_CBC_SHA", - // additionally - "TLS_RSA_WITH_AES_256_CBC_SHA", - "TLS_ECDHE_ECDSA_WITH_3DES_EDE_CBC_SHA", - "TLS_ECDHE_ECDSA_WITH_AES_128_CBC_SHA", - "TLS_ECDHE_RSA_WITH_3DES_EDE_CBC_SHA", - "TLS_ECDHE_RSA_WITH_AES_128_CBC_SHA" - ) - val availableCiphers = socket.supportedCipherSuites - Logger.log.info("Available cipher suites: ${availableCiphers.joinToString(", ")}") - - /* For maximum security, preferredCiphers should *replace* enabled ciphers (thus - * disabling ciphers which are enabled by default, but have become unsecure), but for - * the security level of DAVdroid and maximum compatibility, disabling of insecure - * ciphers should be a server-side task */ - - // for the final set of enabled ciphers, take the ciphers enabled by default, ... - val _cipherSuites = LinkedList() - _cipherSuites.addAll(socket.enabledCipherSuites) - Logger.log.fine("Cipher suites enabled by default: ${_cipherSuites.joinToString(", ")}") - // ... add explicitly allowed ciphers ... - _cipherSuites.addAll(knownCiphers) - // ... and keep only those which are actually available - _cipherSuites.retainAll(availableCiphers) - - Logger.log.info("Enabling (only) these TLS ciphers: " + _cipherSuites.joinToString(", ")) - cipherSuites = _cipherSuites.toTypedArray() - } catch (e: IOException) { - Logger.log.severe("Couldn't determine default TLS settings") - } - } - } - } - } - - - init { - try { - val sslContext = SSLContext.getInstance("TLS") - sslContext.init( - if (keyManager != null) arrayOf(keyManager) else null, - arrayOf(trustManager), - null) - delegate = sslContext.socketFactory - } catch (e: GeneralSecurityException) { - throw IllegalStateException() // system has no TLS - } - } - - override fun getDefaultCipherSuites(): Array? = cipherSuites ?: delegate.defaultCipherSuites - override fun getSupportedCipherSuites(): Array? = cipherSuites ?: delegate.supportedCipherSuites - - override fun createSocket(s: Socket, host: String, port: Int, autoClose: Boolean): Socket { - val ssl = delegate.createSocket(s, host, port, autoClose) - if (ssl is SSLSocket) - upgradeTLS(ssl) - return ssl - } - - override fun createSocket(host: String, port: Int): Socket { - val ssl = delegate.createSocket(host, port) - if (ssl is SSLSocket) - upgradeTLS(ssl) - return ssl - } - - override fun createSocket(host: String, port: Int, localHost: InetAddress, localPort: Int): Socket { - val ssl = delegate.createSocket(host, port, localHost, localPort) - if (ssl is SSLSocket) - upgradeTLS(ssl) - return ssl - } - - override fun createSocket(host: InetAddress, port: Int): Socket { - val ssl = delegate.createSocket(host, port) - if (ssl is SSLSocket) - upgradeTLS(ssl) - return ssl - } - - override fun createSocket(address: InetAddress, port: Int, localAddress: InetAddress, localPort: Int): Socket { - val ssl = delegate.createSocket(address, port, localAddress, localPort) - if (ssl is SSLSocket) - upgradeTLS(ssl) - return ssl - } - - - private fun upgradeTLS(ssl: SSLSocket) { - protocols?.let { ssl.enabledProtocols = it } - cipherSuites?.let { ssl.enabledCipherSuites = it } - } - -} diff --git a/app/src/main/java/at/bitfire/davdroid/DavService.kt b/app/src/main/java/at/bitfire/davdroid/DavService.kt deleted file mode 100644 index aa93d829f1ee921fa06050c311afc23b83c3f882..0000000000000000000000000000000000000000 --- a/app/src/main/java/at/bitfire/davdroid/DavService.kt +++ /dev/null @@ -1,405 +0,0 @@ -/* - * Copyright © Ricki Hirner (bitfire web engineering). - * All rights reserved. This program and the accompanying materials - * are made available under the terms of the GNU Public License v3.0 - * which accompanies this distribution, and is available at - * http://www.gnu.org/licenses/gpl.html - */ - -package at.bitfire.davdroid - -import android.accounts.Account -import android.app.PendingIntent -import android.app.Service -import android.content.ContentResolver -import android.content.ContentValues -import android.content.Intent -import android.database.DatabaseUtils -import android.database.sqlite.SQLiteDatabase -import android.os.Binder -import android.os.Bundle -import android.support.v4.app.NotificationCompat -import android.support.v4.app.NotificationManagerCompat -import at.bitfire.dav4android.DavResource -import at.bitfire.dav4android.Response -import at.bitfire.dav4android.UrlUtils -import at.bitfire.dav4android.exception.DavException -import at.bitfire.dav4android.exception.HttpException -import at.bitfire.dav4android.property.* -import at.bitfire.davdroid.log.Logger -import at.bitfire.davdroid.model.CollectionInfo -import at.bitfire.davdroid.model.ServiceDB.* -import at.bitfire.davdroid.model.ServiceDB.Collections -import at.bitfire.davdroid.settings.Settings -import at.bitfire.davdroid.ui.DebugInfoActivity -import at.bitfire.davdroid.ui.NotificationUtils -import okhttp3.HttpUrl -import okhttp3.OkHttpClient -import java.io.IOException -import java.lang.ref.WeakReference -import java.util.* -import java.util.logging.Level -import kotlin.concurrent.thread - -class DavService: Service() { - - companion object { - const val ACTION_REFRESH_COLLECTIONS = "refreshCollections" - const val EXTRA_DAV_SERVICE_ID = "davServiceID" - - /** Initialize a forced synchronization. Expects intent data - to be an URI of this format: - contents://// - **/ - const val ACTION_FORCE_SYNC = "forceSync" - } - - private val runningRefresh = HashSet() - private val refreshingStatusListeners = LinkedList>() - - - override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { - intent?.let { - val id = intent.getLongExtra(EXTRA_DAV_SERVICE_ID, -1) - - when (intent.action) { - ACTION_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) - } - } - } - - return START_NOT_STICKY - } - - - /* BOUND SERVICE PART - for communicating with the activities - */ - - interface RefreshingStatusListener { - fun onDavRefreshStatusChanged(id: Long, refreshing: Boolean) - } - - private val binder = InfoBinder() - - inner class InfoBinder: Binder() { - fun isRefreshing(id: Long) = runningRefresh.contains(id) - - fun addRefreshingStatusListener(listener: RefreshingStatusListener, callImmediate: Boolean) { - refreshingStatusListeners += WeakReference(listener) - if (callImmediate) - runningRefresh.forEach { id -> listener.onDavRefreshStatusChanged(id, true) } - } - - fun removeRefreshingStatusListener(listener: RefreshingStatusListener) { - val iter = refreshingStatusListeners.iterator() - while (iter.hasNext()) { - val item = iter.next().get() - if (listener == item) - iter.remove() - } - } - } - - override fun onBind(intent: Intent?) = binder - - - - /* ACTION RUNNABLES - which actually do the work - */ - - 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) - } - - private fun refreshCollections(service: Long) { - OpenHelper(this@DavService).use { dbHelper -> - val db = dbHelper.writableDatabase - - val serviceType by lazy { - db.query(Services._TABLE, arrayOf(Services.SERVICE), "${Services.ID}=?", arrayOf(service.toString()), null, null, null)?.use { cursor -> - if (cursor.moveToNext()) - return@lazy cursor.getString(0) - } ?: throw IllegalArgumentException("Service not found") - } - - val account by lazy { - db.query(Services._TABLE, arrayOf(Services.ACCOUNT_NAME), "${Services.ID}=?", arrayOf(service.toString()), null, null, null)?.use { cursor -> - if (cursor.moveToNext()) - return@lazy Account(cursor.getString(0), getString(R.string.account_type)) - } - throw IllegalArgumentException("Account not found") - } - - val homeSets by lazy { - val homeSets = mutableSetOf() - db.query(HomeSets._TABLE, arrayOf(HomeSets.URL), "${HomeSets.SERVICE_ID}=?", arrayOf(service.toString()), null, null, null)?.use { cursor -> - while (cursor.moveToNext()) - HttpUrl.parse(cursor.getString(0))?.let { homeSets += it } - } - homeSets - } - - val collections by lazy { - val collections = mutableMapOf() - db.query(Collections._TABLE, null, "${Collections.SERVICE_ID}=?", arrayOf(service.toString()), null, null, null)?.use { cursor -> - while (cursor.moveToNext()) { - val values = ContentValues(cursor.columnCount) - DatabaseUtils.cursorRowToContentValues(cursor, values) - values.getAsString(Collections.URL)?.let { url -> - HttpUrl.parse(url)?.let { collections.put(it, CollectionInfo(values)) } - } - } - } - collections - } - - fun readPrincipal(): HttpUrl? { - db.query(Services._TABLE, arrayOf(Services.PRINCIPAL), "${Services.ID}=?", arrayOf(service.toString()), null, null, null)?.use { cursor -> - if (cursor.moveToNext()) - cursor.getString(0)?.let { return HttpUrl.parse(it) } - } - return null - } - - /** - * Checks if the given URL defines home sets and adds them to the home set list. - * - * @throws IOException - * @throws HttpException - * @throws DavException - */ - fun queryHomeSets(client: OkHttpClient, url: HttpUrl, recurse: Boolean = true) { - var related = setOf() - - fun findRelated(root: HttpUrl, dav: Response) { - // refresh home sets: calendar-proxy-read/write-for - dav[CalendarProxyReadFor::class.java]?.let { - for (href in it.hrefs) { - Logger.log.fine("Principal is a read-only proxy for $href, checking for home sets") - root.resolve(href)?.let { - related += it - } - } - } - dav[CalendarProxyWriteFor::class.java]?.let { - for (href in it.hrefs) { - Logger.log.fine("Principal is a read/write proxy for $href, checking for home sets") - root.resolve(href)?.let { - related += it - } - } - } - - // refresh home sets: direct group memberships - dav[GroupMembership::class.java]?.let { - for (href in it.hrefs) { - Logger.log.fine("Principal is member of group $href, checking for home sets") - root.resolve(href)?.let { - related += it - } - } - } - } - - val dav = DavResource(client, url) - when (serviceType) { - Services.SERVICE_CARDDAV -> - try { - dav.propfind(0, AddressbookHomeSet.NAME, GroupMembership.NAME) { response, _ -> - response[AddressbookHomeSet::class.java]?.let { homeSet -> - for (href in homeSet.hrefs) - dav.location.resolve(href)?.let { homeSets += UrlUtils.withTrailingSlash(it) } - } - - if (recurse) - findRelated(dav.location, response) - } - } catch (e: HttpException) { - if (e.code/100 == 4) - Logger.log.log(Level.INFO, "Ignoring Client Error 4xx while looking for addressbook home sets", e) - else - throw e - } - Services.SERVICE_CALDAV -> { - try { - dav.propfind(0, CalendarHomeSet.NAME, CalendarProxyReadFor.NAME, CalendarProxyWriteFor.NAME, GroupMembership.NAME) { response, _ -> - response[CalendarHomeSet::class.java]?.let { homeSet -> - for (href in homeSet.hrefs) - dav.location.resolve(href)?.let { homeSets.add(UrlUtils.withTrailingSlash(it)) } - } - - if (recurse) - findRelated(dav.location, response) - } - } catch (e: HttpException) { - if (e.code/100 == 4) - Logger.log.log(Level.INFO, "Ignoring Client Error 4xx while looking for calendar home sets", e) - else - throw e - } - } - } - - for (resource in related) - queryHomeSets(client, resource, false) - } - - fun saveHomeSets() { - db.delete(HomeSets._TABLE, "${HomeSets.SERVICE_ID}=?", arrayOf(service.toString())) - for (homeSet in homeSets) { - val values = ContentValues(2) - values.put(HomeSets.SERVICE_ID, service) - values.put(HomeSets.URL, homeSet.toString()) - db.insertOrThrow(HomeSets._TABLE, null, values) - } - } - - fun saveCollections() { - db.delete(Collections._TABLE, "${HomeSets.SERVICE_ID}=?", arrayOf(service.toString())) - for ((_,collection) in collections) { - val values = collection.toDB() - Logger.log.log(Level.FINE, "Saving collection", values) - values.put(Collections.SERVICE_ID, service) - db.insertWithOnConflict(Collections._TABLE, null, values, SQLiteDatabase.CONFLICT_REPLACE) - } - } - - - try { - Logger.log.info("Refreshing $serviceType collections of service #$service") - - Settings.getInstance(this)?.use { settings -> - // create authenticating OkHttpClient (credentials taken from account settings) - HttpClient.Builder(this, settings, AccountSettings(this, settings, account)) - .setForeground(true) - .build().use { client -> - val httpClient = client.okHttpClient - - // refresh home set list (from principal) - readPrincipal()?.let { principalUrl -> - Logger.log.fine("Querying principal $principalUrl for home sets") - queryHomeSets(httpClient, principalUrl) - } - - // remember selected collections - val selectedCollections = HashSet() - collections.values - .filter { it.selected } - .forEach { (url, _) -> selectedCollections += url } - - // now refresh collections (taken from home sets) - val itHomeSets = homeSets.iterator() - while (itHomeSets.hasNext()) { - val homeSetUrl = itHomeSets.next() - Logger.log.fine("Listing home set $homeSetUrl") - - try { - DavResource(httpClient, homeSetUrl).propfind(1, *CollectionInfo.DAV_PROPERTIES) { response, _ -> - if (!response.isSuccess()) - return@propfind - - val info = CollectionInfo(response) - info.confirmed = true - Logger.log.log(Level.FINE, "Found collection", info) - - if ((serviceType == Services.SERVICE_CARDDAV && info.type == CollectionInfo.Type.ADDRESS_BOOK) || - (serviceType == Services.SERVICE_CALDAV && arrayOf(CollectionInfo.Type.CALENDAR, CollectionInfo.Type.WEBCAL).contains(info.type))) - collections[response.href] = info - } - } catch(e: HttpException) { - if (e.code in arrayOf(403, 404, 410)) - // delete home set only if it was not accessible (40x) - itHomeSets.remove() - } - } - - // check/refresh unconfirmed collections - val itCollections = collections.entries.iterator() - while (itCollections.hasNext()) { - val (url, info) = itCollections.next() - if (!info.confirmed) - try { - DavResource(httpClient, url).propfind(0, *CollectionInfo.DAV_PROPERTIES) { response, _ -> - if (!response.isSuccess()) - return@propfind - - val info = CollectionInfo(response) - info.confirmed = true - - // remove unusable collections - if ((serviceType == Services.SERVICE_CARDDAV && info.type != CollectionInfo.Type.ADDRESS_BOOK) || - (serviceType == Services.SERVICE_CALDAV && !arrayOf(CollectionInfo.Type.CALENDAR, CollectionInfo.Type.WEBCAL).contains(info.type)) || - (info.type == CollectionInfo.Type.WEBCAL && info.source == null)) - itCollections.remove() - } - } catch(e: HttpException) { - if (e.code in arrayOf(403, 404, 410)) - // delete collection only if it was not accessible (40x) - itCollections.remove() - else - throw e - } - } - - // restore selections - for (url in selectedCollections) - collections[url]?.let { it.selected = true } - } - - } - - db.beginTransactionNonExclusive() - try { - saveHomeSets() - saveCollections() - db.setTransactionSuccessful() - } finally { - db.endTransaction() - } - - } catch(e: InvalidAccountException) { - Logger.log.log(Level.SEVERE, "Invalid account", e) - } catch(e: Exception) { - Logger.log.log(Level.SEVERE, "Couldn't refresh collection list", e) - - val debugIntent = Intent(this, DebugInfoActivity::class.java) - debugIntent.putExtra(DebugInfoActivity.KEY_THROWABLE, e) - debugIntent.putExtra(DebugInfoActivity.KEY_ACCOUNT, account) - - val notify = NotificationUtils.newBuilder(this) - .setSmallIcon(R.drawable.ic_sync_error_notification) - .setContentTitle(getString(R.string.dav_service_refresh_failed)) - .setContentText(getString(R.string.dav_service_refresh_couldnt_refresh)) - .setContentIntent(PendingIntent.getActivity(this, 0, debugIntent, PendingIntent.FLAG_UPDATE_CURRENT)) - .setSubText(account.name) - .setCategory(NotificationCompat.CATEGORY_ERROR) - .build() - NotificationManagerCompat.from(this) - .notify(service.toString(), NotificationUtils.NOTIFY_REFRESH_COLLECTIONS, notify) - } finally { - runningRefresh.remove(service) - refreshingStatusListeners.forEach { it.get()?.onDavRefreshStatusChanged(service, false) } - } - } - - } - -} \ No newline at end of file diff --git a/app/src/main/java/at/bitfire/davdroid/PackageChangedReceiver.kt b/app/src/main/java/at/bitfire/davdroid/PackageChangedReceiver.kt deleted file mode 100644 index 35b304ce4bba912230f9c9eb919fbad5bfe01002..0000000000000000000000000000000000000000 --- a/app/src/main/java/at/bitfire/davdroid/PackageChangedReceiver.kt +++ /dev/null @@ -1,64 +0,0 @@ -/* - * Copyright © Ricki Hirner (bitfire web engineering). - * All rights reserved. This program and the accompanying materials - * are made available under the terms of the GNU Public License v3.0 - * which accompanies this distribution, and is available at - * http://www.gnu.org/licenses/gpl.html - */ - -package at.bitfire.davdroid - -import android.accounts.Account -import android.annotation.SuppressLint -import android.content.BroadcastReceiver -import android.content.ContentResolver -import android.content.Context -import android.content.Intent -import android.os.Bundle -import at.bitfire.davdroid.log.Logger -import at.bitfire.davdroid.model.ServiceDB -import at.bitfire.davdroid.model.ServiceDB.Services -import at.bitfire.davdroid.resource.LocalTaskList -import at.bitfire.ical4android.TaskProvider - -class PackageChangedReceiver: BroadcastReceiver() { - - @SuppressLint("MissingPermission") - override fun onReceive(context: Context, intent: Intent) { - if (intent.action == Intent.ACTION_PACKAGE_ADDED || intent.action == Intent.ACTION_PACKAGE_FULLY_REMOVED) - updateTaskSync(context) - } - - - companion object { - - fun updateTaskSync(context: Context) { - val tasksInstalled = LocalTaskList.tasksProviderAvailable(context) - Logger.log.info("Package (un)installed; OpenTasks provider now available = $tasksInstalled") - - // check all accounts and (de)activate OpenTasks if a CalDAV service is defined - ServiceDB.OpenHelper(context).use { dbHelper -> - val db = dbHelper.readableDatabase - - db.query(Services._TABLE, arrayOf(Services.ACCOUNT_NAME), - "${Services.SERVICE}=?", arrayOf(Services.SERVICE_CALDAV), null, null, null)?.use { cursor -> - while (cursor.moveToNext()) { - val account = Account(cursor.getString(0), context.getString(R.string.account_type)) - - if (tasksInstalled) { - if (ContentResolver.getIsSyncable(account, TaskProvider.ProviderName.OpenTasks.authority) <= 0) { - ContentResolver.setIsSyncable(account, TaskProvider.ProviderName.OpenTasks.authority, 1) - ContentResolver.setSyncAutomatically(account, TaskProvider.ProviderName.OpenTasks.authority, true) - ContentResolver.addPeriodicSync(account, TaskProvider.ProviderName.OpenTasks.authority, Bundle(), Constants.DEFAULT_SYNC_INTERVAL) - } - } else - ContentResolver.setIsSyncable(account, TaskProvider.ProviderName.OpenTasks.authority, 0) - - } - } - } - } - - } - -} diff --git a/app/src/main/java/at/bitfire/davdroid/log/Logger.kt b/app/src/main/java/at/bitfire/davdroid/log/Logger.kt deleted file mode 100644 index ed280e8770a1baa5d9a164e0a1e887a533e49b95..0000000000000000000000000000000000000000 --- a/app/src/main/java/at/bitfire/davdroid/log/Logger.kt +++ /dev/null @@ -1,106 +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.log - -import android.app.PendingIntent -import android.content.Context -import android.content.Intent -import android.content.SharedPreferences -import android.os.Process -import android.preference.PreferenceManager -import android.support.v4.app.NotificationCompat -import android.support.v4.app.NotificationManagerCompat -import android.util.Log -import at.bitfire.davdroid.R -import at.bitfire.davdroid.ui.AppSettingsActivity -import at.bitfire.davdroid.ui.NotificationUtils -import org.apache.commons.lang3.time.DateFormatUtils -import java.io.File -import java.io.IOException -import java.util.logging.FileHandler -import java.util.logging.Level - -object Logger { - - const val LOG_TO_EXTERNAL_STORAGE = "log_to_external_storage" - - val log = java.util.logging.Logger.getLogger("davdroid")!! - - - private lateinit var preferences: SharedPreferences - - fun initialize(context: Context) { - preferences = PreferenceManager.getDefaultSharedPreferences(context) - preferences.registerOnSharedPreferenceChangeListener { _, s -> - if (s == LOG_TO_EXTERNAL_STORAGE) - reinitialize(context.applicationContext) - } - - reinitialize(context.applicationContext) - } - - private fun reinitialize(context: Context) { - val logToFile = preferences.getBoolean(LOG_TO_EXTERNAL_STORAGE, false) - val logVerbose = logToFile || Log.isLoggable(log.name, Log.DEBUG) - - log.info("Verbose logging: $logVerbose; to file: $logToFile") - - // set logging level according to preferences - val rootLogger = java.util.logging.Logger.getLogger("") - rootLogger.level = if (logVerbose) Level.ALL else Level.INFO - - // remove all handlers and add our own logcat handler - rootLogger.useParentHandlers = false - rootLogger.handlers.forEach { rootLogger.removeHandler(it) } - rootLogger.addHandler(LogcatHandler) - - val nm = NotificationManagerCompat.from(context) - // log to external file according to preferences - if (logToFile) { - val builder = NotificationUtils.newBuilder(context, NotificationUtils.CHANNEL_DEBUG) - builder .setSmallIcon(R.drawable.ic_sd_storage_notification) - .setContentTitle(context.getString(R.string.logging_davdroid_file_logging)) - .setLocalOnly(true) - - val dir = context.getExternalFilesDir(null) - if (dir != null) - try { - val fileName = File(dir, "davdroid-${Process.myPid()}-${DateFormatUtils.format(System.currentTimeMillis(), "yyyyMMdd-HHmmss")}.txt").toString() - log.info("Logging to $fileName") - - val fileHandler = FileHandler(fileName) - fileHandler.formatter = PlainTextFormatter.DEFAULT - rootLogger.addHandler(fileHandler) - - val prefIntent = Intent(context, AppSettingsActivity::class.java) - prefIntent.putExtra(AppSettingsActivity.EXTRA_SCROLL_TO, LOG_TO_EXTERNAL_STORAGE) - - builder .setContentText(dir.path) - .setCategory(NotificationCompat.CATEGORY_STATUS) - .setPriority(NotificationCompat.PRIORITY_HIGH) - .setContentIntent(PendingIntent.getActivity(context, 0, prefIntent, PendingIntent.FLAG_UPDATE_CURRENT)) - .setStyle(NotificationCompat.BigTextStyle().bigText(context.getString(R.string.logging_to_external_storage, dir.path))) - .setOngoing(true) - - } catch(e: IOException) { - log.log(Level.SEVERE, "Couldn't create external log file", e) - 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(NotificationUtils.NOTIFY_EXTERNAL_FILE_LOGGING, builder.build()) - } else - nm.cancel(NotificationUtils.NOTIFY_EXTERNAL_FILE_LOGGING) - } - -} \ No newline at end of file diff --git a/app/src/main/java/at/bitfire/davdroid/model/CollectionInfo.kt b/app/src/main/java/at/bitfire/davdroid/model/CollectionInfo.kt deleted file mode 100644 index eaa942f63ef381620330429d0809faa1dc44e0ef..0000000000000000000000000000000000000000 --- a/app/src/main/java/at/bitfire/davdroid/model/CollectionInfo.kt +++ /dev/null @@ -1,251 +0,0 @@ -/* - * Copyright © Ricki Hirner (bitfire web engineering). - * All rights reserved. This program and the accompanying materials - * are made available under the terms of the GNU Public License v3.0 - * which accompanies this distribution, and is available at - * http://www.gnu.org/licenses/gpl.html - */ - -package at.bitfire.davdroid.model - -import android.content.ContentValues -import android.os.Parcel -import android.os.Parcelable -import at.bitfire.dav4android.Response -import at.bitfire.dav4android.UrlUtils -import at.bitfire.dav4android.property.* -import at.bitfire.davdroid.model.ServiceDB.Collections -import okhttp3.HttpUrl - -/** - * Represents a WebDAV collection. - * - * @constructor always appends a trailing slash to the URL - */ -data class CollectionInfo( - - /** - * URL of the collection (including trailing slash) - */ - val url: HttpUrl, - - var id: Long? = null, - var serviceID: Long? = null, - - var type: Type? = null, - - var privWriteContent: Boolean = true, - var privUnbind: Boolean = true, - var forceReadOnly: Boolean = false, - var displayName: String? = null, - var description: String? = null, - var color: Int? = null, - - var timeZone: String? = null, - var supportsVEVENT: Boolean = false, - var supportsVTODO: Boolean = false, - var selected: Boolean = false, - - // subscriptions - var source: String? = null, - - // non-persistent properties - var confirmed: Boolean = false -): Parcelable { - - enum class Type { - ADDRESS_BOOK, - CALENDAR, - WEBCAL // iCalendar subscription - } - - constructor(dav: Response): this(UrlUtils.withTrailingSlash(dav.href)) { - dav[ResourceType::class.java]?.let { type -> - when { - type.types.contains(ResourceType.ADDRESSBOOK) -> this.type = Type.ADDRESS_BOOK - type.types.contains(ResourceType.CALENDAR) -> this.type = Type.CALENDAR - type.types.contains(ResourceType.SUBSCRIBED) -> this.type = Type.WEBCAL - } - } - - dav[CurrentUserPrivilegeSet::class.java]?.let { privilegeSet -> - privWriteContent = privilegeSet.mayWriteContent - privUnbind = privilegeSet.mayUnbind - } - - dav[DisplayName::class.java]?.let { - if (!it.displayName.isNullOrEmpty()) - displayName = it.displayName - } - - when (type) { - Type.ADDRESS_BOOK -> { - dav[AddressbookDescription::class.java]?.let { description = it.description } - } - Type.CALENDAR, Type.WEBCAL -> { - dav[CalendarDescription::class.java]?.let { description = it.description } - dav[CalendarColor::class.java]?.let { color = it.color } - dav[CalendarTimezone::class.java]?.let { timeZone = it.vTimeZone } - - if (type == Type.CALENDAR) { - supportsVEVENT = true - supportsVTODO = true - dav[SupportedCalendarComponentSet::class.java]?.let { - supportsVEVENT = it.supportsEvents - supportsVTODO = it.supportsTasks - } - } else { // Type.WEBCAL - dav[Source::class.java]?.let { source = it.hrefs.firstOrNull() } - supportsVEVENT = true - } - } - } - } - - - constructor(values: ContentValues): this(UrlUtils.withTrailingSlash(HttpUrl.parse(values.getAsString(Collections.URL))!!)) { - id = values.getAsLong(Collections.ID) - serviceID = values.getAsLong(Collections.SERVICE_ID) - type = try { - Type.valueOf(values.getAsString(Collections.TYPE)) - } catch (e: Exception) { - null - } - - privWriteContent = values.getAsInteger(Collections.PRIV_WRITE_CONTENT) != 0 - privUnbind = values.getAsInteger(Collections.PRIV_UNBIND) != 0 - forceReadOnly = values.getAsInteger(Collections.FORCE_READ_ONLY) != 0 - displayName = values.getAsString(Collections.DISPLAY_NAME) - description = values.getAsString(Collections.DESCRIPTION) - - color = values.getAsInteger(Collections.COLOR) - - timeZone = values.getAsString(Collections.TIME_ZONE) - supportsVEVENT = getAsBooleanOrNull(values, Collections.SUPPORTS_VEVENT) ?: false - supportsVTODO = getAsBooleanOrNull(values, Collections.SUPPORTS_VTODO) ?: false - - source = values.getAsString(Collections.SOURCE) - - selected = values.getAsInteger(Collections.SYNC) != 0 - } - - fun toDB(): ContentValues { - val values = ContentValues() - // Collections.SERVICE_ID is never changed - type?.let { values.put(Collections.TYPE, it.name) } - - values.put(Collections.URL, url.toString()) - values.put(Collections.PRIV_WRITE_CONTENT, if (privWriteContent) 1 else 0) - values.put(Collections.PRIV_UNBIND, if (privUnbind) 1 else 0) - values.put(Collections.FORCE_READ_ONLY, if (forceReadOnly) 1 else 0) - values.put(Collections.DISPLAY_NAME, displayName) - values.put(Collections.DESCRIPTION, description) - values.put(Collections.COLOR, color) - - values.put(Collections.TIME_ZONE, timeZone) - values.put(Collections.SUPPORTS_VEVENT, if (supportsVEVENT) 1 else 0) - values.put(Collections.SUPPORTS_VTODO, if (supportsVTODO) 1 else 0) - - values.put(Collections.SOURCE, source) - - values.put(Collections.SYNC, if (selected) 1 else 0) - return values - } - - - private fun getAsBooleanOrNull(values: ContentValues, field: String): Boolean? { - val i = values.getAsInteger(field) - return if (i == null) - null - else - (i != 0) - } - - - override fun describeContents(): Int = 0 - override fun writeToParcel(dest: Parcel, flags: Int) { - fun 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 (privWriteContent) 1 else 0) - dest.writeByte(if (privUnbind) 1 else 0) - - dest.writeByte(if (forceReadOnly) 1 else 0) - dest.writeString(displayName) - dest.writeString(description) - writeOrNull(color) { dest.writeInt(it) } - - dest.writeString(timeZone) - dest.writeByte(if (supportsVEVENT) 1 else 0) - dest.writeByte(if (supportsVTODO) 1 else 0) - dest.writeByte(if (selected) 1 else 0) - - dest.writeString(source) - - dest.writeByte(if (confirmed) 1 else 0) - } - - companion object CREATOR : Parcelable.Creator { - - val DAV_PROPERTIES = arrayOf( - ResourceType.NAME, - CurrentUserPrivilegeSet.NAME, - DisplayName.NAME, - AddressbookDescription.NAME, SupportedAddressData.NAME, - CalendarDescription.NAME, CalendarColor.NAME, SupportedCalendarComponentSet.NAME, - Source.NAME - ) - - override fun createFromParcel(parcel: Parcel): CollectionInfo { - fun 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.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(size) - - } - -} \ No newline at end of file diff --git a/app/src/main/java/at/bitfire/davdroid/model/ServiceDB.kt b/app/src/main/java/at/bitfire/davdroid/model/ServiceDB.kt deleted file mode 100644 index c7422363d6d0dc69476b5078da10f0ed4bc6db8b..0000000000000000000000000000000000000000 --- a/app/src/main/java/at/bitfire/davdroid/model/ServiceDB.kt +++ /dev/null @@ -1,236 +0,0 @@ -/* - * Copyright © Ricki Hirner (bitfire web engineering). - * All rights reserved. This program and the accompanying materials - * are made available under the terms of the GNU Public License v3.0 - * which accompanies this distribution, and is available at - * http://www.gnu.org/licenses/gpl.html - */ - -package at.bitfire.davdroid.model - -import android.content.ContentValues -import android.content.Context -import android.database.sqlite.SQLiteDatabase -import android.database.sqlite.SQLiteException -import android.database.sqlite.SQLiteOpenHelper -import android.preference.PreferenceManager -import at.bitfire.davdroid.App -import at.bitfire.davdroid.log.Logger -import at.bitfire.davdroid.ui.StartupDialogFragment -import java.util.logging.Level - -class ServiceDB { - - object Services { - const val _TABLE = "services" - const val ID = "_id" - const val ACCOUNT_NAME = "accountName" - const val SERVICE = "service" - const val PRINCIPAL = "principal" - - // allowed values for SERVICE column - const val SERVICE_CALDAV = "caldav" - const val SERVICE_CARDDAV = "carddav" - } - - object HomeSets { - const val _TABLE = "homesets" - const val ID = "_id" - const val SERVICE_ID = "serviceID" - const val URL = "url" - } - - object Collections { - const val _TABLE = "collections" - const val ID = "_id" - const val TYPE = "type" - const val SERVICE_ID = "serviceID" - const val URL = "url" - const val PRIV_WRITE_CONTENT = "privWriteContent" - const val PRIV_UNBIND = "privUnbind" - const val FORCE_READ_ONLY = "forceReadOnly" - const val DISPLAY_NAME = "displayName" - const val DESCRIPTION = "description" - const val COLOR = "color" - const val TIME_ZONE = "timezone" - const val SUPPORTS_VEVENT = "supportsVEVENT" - const val SUPPORTS_VTODO = "supportsVTODO" - const val SOURCE = "source" - const val SYNC = "sync" - } - - companion object { - - fun onRenameAccount(db: SQLiteDatabase, oldName: String, newName: String) { - val values = ContentValues(1) - values.put(Services.ACCOUNT_NAME, newName) - db.updateWithOnConflict(Services._TABLE, values, Services.ACCOUNT_NAME + "=?", arrayOf(oldName), SQLiteDatabase.CONFLICT_REPLACE) - } - - } - - - class OpenHelper( - val context: Context - ): SQLiteOpenHelper(context, DATABASE_NAME, null, DATABASE_VERSION), AutoCloseable { - - companion object { - const val DATABASE_NAME = "services.db" - const val DATABASE_VERSION = 5 - } - - override fun onConfigure(db: SQLiteDatabase) { - setWriteAheadLoggingEnabled(true) - db.setForeignKeyConstraintsEnabled(true) - } - - override fun onCreate(db: SQLiteDatabase) { - Logger.log.info("Creating database " + db.path) - - db.execSQL("CREATE TABLE ${Services._TABLE}(" + - "${Services.ID} INTEGER PRIMARY KEY AUTOINCREMENT," + - "${Services.ACCOUNT_NAME} TEXT NOT NULL," + - "${Services.SERVICE} TEXT NOT NULL," + - "${Services.PRINCIPAL} TEXT NULL)") - db.execSQL("CREATE UNIQUE INDEX services_account ON ${Services._TABLE} (${Services.ACCOUNT_NAME},${Services.SERVICE})") - - db.execSQL("CREATE TABLE ${HomeSets._TABLE}(" + - "${HomeSets.ID} INTEGER PRIMARY KEY AUTOINCREMENT," + - "${HomeSets.SERVICE_ID} INTEGER NOT NULL REFERENCES ${Services._TABLE} ON DELETE CASCADE," + - "${HomeSets.URL} TEXT NOT NULL)") - db.execSQL("CREATE UNIQUE INDEX homesets_service_url ON ${HomeSets._TABLE}(${HomeSets.SERVICE_ID},${HomeSets.URL})") - - db.execSQL("CREATE TABLE ${Collections._TABLE}(" + - "${Collections.ID} INTEGER PRIMARY KEY AUTOINCREMENT," + - "${Collections.SERVICE_ID} INTEGER NOT NULL REFERENCES ${Services._TABLE} ON DELETE CASCADE," + - "${Collections.TYPE} TEXT NOT NULL," + - "${Collections.URL} TEXT NOT NULL," + - "${Collections.PRIV_WRITE_CONTENT} INTEGER DEFAULT 0 NOT NULL," + - "${Collections.PRIV_UNBIND} INTEGER DEFAULT 0 NOT NULL," + - "${Collections.FORCE_READ_ONLY} INTEGER DEFAULT 0 NOT NULL," + - "${Collections.DISPLAY_NAME} TEXT NULL," + - "${Collections.DESCRIPTION} TEXT NULL," + - "${Collections.COLOR} INTEGER NULL," + - "${Collections.TIME_ZONE} TEXT NULL," + - "${Collections.SUPPORTS_VEVENT} INTEGER NULL," + - "${Collections.SUPPORTS_VTODO} INTEGER NULL," + - "${Collections.SOURCE} TEXT NULL," + - "${Collections.SYNC} INTEGER DEFAULT 0 NOT NULL)") - db.execSQL("CREATE UNIQUE INDEX collections_service_url ON ${Collections._TABLE}(${Collections.SERVICE_ID},${Collections.URL})") - } - - override fun onUpgrade(db: SQLiteDatabase, oldVersion: Int, newVersion: Int) { - for (upgradeFrom in oldVersion until newVersion) { - val upgradeTo = upgradeFrom + 1 - Logger.log.info("Upgrading database from version $upgradeFrom to $upgradeTo") - try { - val upgradeProc = this::class.java.getDeclaredMethod("upgrade_${upgradeFrom}_$upgradeTo", SQLiteDatabase::class.java) - upgradeProc.invoke(this, db) - } catch(e: Exception) { - Logger.log.log(Level.SEVERE, "Couldn't upgrade database", e) - } - } - } - - @Suppress("unused") - private fun upgrade_4_5(db: SQLiteDatabase) { - db.execSQL("ALTER TABLE ${Collections._TABLE} ADD COLUMN ${Collections.PRIV_WRITE_CONTENT} INTEGER DEFAULT 0 NOT NULL") - db.execSQL("UPDATE ${Collections._TABLE} SET ${Collections.PRIV_WRITE_CONTENT}=NOT readOnly") - - db.execSQL("ALTER TABLE ${Collections._TABLE} ADD COLUMN ${Collections.PRIV_UNBIND} INTEGER DEFAULT 0 NOT NULL") - db.execSQL("UPDATE ${Collections._TABLE} SET ${Collections.PRIV_UNBIND}=NOT readOnly") - - // there's no DROP COLUMN in SQLite, so just keep the "readOnly" column - } - - @Suppress("unused") - private fun upgrade_3_4(db: SQLiteDatabase) { - db.execSQL("ALTER TABLE ${Collections._TABLE} ADD COLUMN ${Collections.FORCE_READ_ONLY} INTEGER DEFAULT 0 NOT NULL") - } - - @Suppress("unused") - private fun upgrade_2_3(db: SQLiteDatabase) { - val edit = PreferenceManager.getDefaultSharedPreferences(context).edit() - try { - db.query("settings", arrayOf("setting", "value"), null, null, null, null, null).use { cursor -> - while (cursor.moveToNext()) { - when (cursor.getString(0)) { - "distrustSystemCerts" -> edit.putBoolean(App.DISTRUST_SYSTEM_CERTIFICATES, cursor.getInt(1) != 0) - "overrideProxy" -> edit.putBoolean(App.OVERRIDE_PROXY, cursor.getInt(1) != 0) - "overrideProxyHost" -> edit.putString(App.OVERRIDE_PROXY_HOST, cursor.getString(1)) - "overrideProxyPort" -> edit.putInt(App.OVERRIDE_PROXY_PORT, cursor.getInt(1)) - - StartupDialogFragment.HINT_GOOGLE_PLAY_ACCOUNTS_REMOVED -> - edit.putBoolean(StartupDialogFragment.HINT_GOOGLE_PLAY_ACCOUNTS_REMOVED, cursor.getInt(1) != 0) - StartupDialogFragment.HINT_OPENTASKS_NOT_INSTALLED -> - edit.putBoolean(StartupDialogFragment.HINT_OPENTASKS_NOT_INSTALLED, cursor.getInt(1) != 0) - } - } - } - db.execSQL("DROP TABLE settings") - } finally { - edit.apply() - } - } - - @Suppress("unused") - private fun upgrade_1_2(db: SQLiteDatabase) { - db.execSQL("ALTER TABLE ${Collections._TABLE} ADD COLUMN ${Collections.TYPE} TEXT NOT NULL DEFAULT ''") - db.execSQL("ALTER TABLE ${Collections._TABLE} ADD COLUMN ${Collections.SOURCE} TEXT NULL") - db.execSQL("UPDATE ${Collections._TABLE} SET ${Collections.TYPE}=(" + - "SELECT CASE ${Services.SERVICE} WHEN ? THEN ? ELSE ? END " + - "FROM ${Services._TABLE} WHERE ${Services.ID}=${Collections._TABLE}.${Collections.SERVICE_ID}" + - ")", - arrayOf(Services.SERVICE_CALDAV, CollectionInfo.Type.CALENDAR, CollectionInfo.Type.ADDRESS_BOOK)) - } - - - fun dump(sb: StringBuilder) { - val db = readableDatabase - db.beginTransactionNonExclusive() - - // iterate through all tables - db.query("sqlite_master", arrayOf("name"), "type='table'", null, null, null, null).use { cursorTables -> - while (cursorTables.moveToNext()) { - val table = cursorTables.getString(0) - sb.append(table).append("\n") - db.query(table, null, null, null, null, null, null).use { cursor -> - // print columns - val cols = cursor.columnCount - sb.append("\t| ") - for (i in 0 until cols) - sb .append(" ") - .append(cursor.getColumnName(i)) - .append(" |") - sb.append("\n") - - // print rows - while (cursor.moveToNext()) { - sb.append("\t| ") - for (i in 0 until cols) { - sb.append(" ") - try { - val value = cursor.getString(i) - if (value != null) - sb.append(value - .replace("\r", "") - .replace("\n", "")) - else - sb.append("") - - } catch (e: SQLiteException) { - sb.append("") - } - sb.append(" |") - } - sb.append("\n") - } - sb.append("----------\n") - } - } - db.endTransaction() - } - } - } - -} \ No newline at end of file diff --git a/app/src/main/java/at/bitfire/davdroid/resource/LocalCollection.kt b/app/src/main/java/at/bitfire/davdroid/resource/LocalCollection.kt deleted file mode 100644 index dd15129e4d354b052788af1086ca8fd99a7d0e7d..0000000000000000000000000000000000000000 --- a/app/src/main/java/at/bitfire/davdroid/resource/LocalCollection.kt +++ /dev/null @@ -1,38 +0,0 @@ -/* - * Copyright © Ricki Hirner (bitfire web engineering). - * All rights reserved. This program and the accompanying materials - * are made available under the terms of the GNU Public License v3.0 - * which accompanies this distribution, and is available at - * http://www.gnu.org/licenses/gpl.html - */ - -package at.bitfire.davdroid.resource - -import at.bitfire.davdroid.model.SyncState - -interface LocalCollection> { - - /** collection title (used for user notifications etc.) **/ - val title: String - - var lastSyncState: SyncState? - - fun findDeleted(): List - fun findDirty(): List - - fun findByName(name: String): T? - - - /** - * 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 - -} diff --git a/app/src/main/java/at/bitfire/davdroid/settings/Settings.kt b/app/src/main/java/at/bitfire/davdroid/settings/Settings.kt deleted file mode 100644 index 93c657dba04825f1972e19a7ba3320adcfa80e36..0000000000000000000000000000000000000000 --- a/app/src/main/java/at/bitfire/davdroid/settings/Settings.kt +++ /dev/null @@ -1,275 +0,0 @@ -/* - * Copyright © Ricki Hirner (bitfire web engineering). - * All rights reserved. This program and the accompanying materials - * are made available under the terms of the GNU Public License v3.0 - * which accompanies this distribution, and is available at - * http://www.gnu.org/licenses/gpl.html - */ - -package at.bitfire.davdroid.settings - -import android.annotation.TargetApi -import android.app.Service -import android.content.ComponentName -import android.content.Context -import android.content.Intent -import android.content.ServiceConnection -import android.os.Build -import android.os.Handler -import android.os.IBinder -import android.os.Looper -import at.bitfire.davdroid.log.Logger -import java.lang.ref.WeakReference -import java.util.* -import java.util.logging.Level - -class Settings: Service(), Provider.Observer { - - private val providers = LinkedList() - private val observers = LinkedList>() - - @TargetApi(Build.VERSION_CODES.LOLLIPOP) - override fun onCreate() { - Logger.log.info("Initializing Settings service") - - // always add a defaults provider first - providers.add(DefaultsProvider()) - - // always add a provider for local preferences - providers.add(SharedPreferencesProvider(this)) - } - - override fun onDestroy() { - Logger.log.info("Shutting down Settings service") - providers.forEach { it.close() } - providers.clear() - } - - override fun onTrimMemory(level: Int) { - stopSelf() - } - - - fun forceReload() { - providers.forEach { it.forceReload() } - } - - override fun onReload() { - observers.forEach { - Handler(Looper.getMainLooper()).post { - it.get()?.onSettingsChanged() - } - } - } - - - private fun has(key: String): Boolean { - Logger.log.fine("Looking for setting $key") - var result = false - for (provider in providers) - try { - val (value, further) = provider.has(key) - Logger.log.finer("${provider::class.java.simpleName}: has $key = $value, continue: $further") - if (value) { - result = true - break - } - if (!further) - break - } catch(e: Exception) { - Logger.log.log(Level.SEVERE, "Couldn't look up setting in $provider", e) - } - Logger.log.fine("Looking for setting $key -> $result") - return result - } - - private fun getValue(key: String, reader: (Provider) -> Pair): T? { - Logger.log.fine("Looking up setting $key") - var result: T? = null - for (provider in providers) - try { - val (value, further) = reader(provider) - Logger.log.finer("${provider::class.java.simpleName}: value = $value, continue: $further") - value?.let { result = it } - if (!further) - break - } catch(e: Exception) { - Logger.log.log(Level.SEVERE, "Couldn't read setting from $provider", e) - } - Logger.log.fine("Looked up setting $key -> $result") - return result - } - - fun getBoolean(key: String) = - getValue(key) { provider -> provider.getBoolean(key) } - - fun getInt(key: String) = - getValue(key) { provider -> provider.getInt(key) } - - fun getLong(key: String) = - getValue(key) { provider -> provider.getLong(key) } - - fun getString(key: String) = - getValue(key) { provider -> provider.getString(key) } - - - fun isWritable(key: String): Boolean { - for (provider in providers) { - val (value, further) = provider.isWritable(key) - if (value) - return true - if (!further) - return false - } - return false - } - - private fun putValue(key: String, value: T?, writer: (Provider) -> Boolean): Boolean { - Logger.log.fine("Trying to write setting $key = $value") - for (provider in providers) { - val (writable, further) = provider.isWritable(key) - Logger.log.finer("${provider::class.java.simpleName}: writable = $writable, continue: $further") - if (writable) - return try { - writer(provider) - } catch (e: Exception) { - Logger.log.log(Level.SEVERE, "Couldn't write setting to $provider", e) - false - } - if (!further) - return false - } - return false - } - - fun putBoolean(key: String, value: Boolean?) = - putValue(key, value) { provider -> provider.putBoolean(key, value) } - - fun putInt(key: String, value: Int?) = - putValue(key, value) { provider -> provider.putInt(key, value) } - - fun putLong(key: String, value: Long?) = - putValue(key, value) { provider -> provider.putLong(key, value) } - - fun putString(key: String, value: String?) = - putValue(key, value) { provider -> provider.putString(key, value) } - - fun remove(key: String): Boolean { - var deleted = false - providers.forEach { deleted = deleted || it.remove(key) } - return deleted - } - - - val binder = object: ISettings.Stub() { - - override fun forceReload() = - this@Settings.forceReload() - - override fun has(key: String) = - this@Settings.has(key) - - override fun getBoolean(key: String, defaultValue: Boolean) = - this@Settings.getBoolean(key) ?: defaultValue - - override fun getInt(key: String, defaultValue: Int) = - this@Settings.getInt(key) ?: defaultValue - - override fun getLong(key: String, defaultValue: Long) = - this@Settings.getLong(key) ?: defaultValue - - override fun getString(key: String, defaultValue: String?) = - this@Settings.getString(key) ?: defaultValue - - override fun isWritable(key: String) = - this@Settings.isWritable(key) - - override fun remove(key: String) = - this@Settings.remove(key) - - override fun putBoolean(key: String, value: Boolean) = - this@Settings.putBoolean(key, value) - - override fun putString(key: String, value: String?) = - this@Settings.putString(key, value) - - override fun putInt(key: String, value: Int) = - this@Settings.putInt(key, value) - - override fun putLong(key: String, value: Long) = - this@Settings.putLong(key, value) - - override fun registerObserver(observer: ISettingsObserver) { - observers += WeakReference(observer) - } - - override fun unregisterObserver(observer: ISettingsObserver) { - observers.removeAll { it.get() == observer } - } - - } - - override fun onBind(intent: Intent?) = binder - - - class Stub( - delegate: ISettings, - private val context: Context, - private val serviceConn: ServiceConnection? - ): ISettings by delegate, AutoCloseable { - - override fun close() { - try { - context.unbindService(serviceConn) - } catch(e: Exception) { - Logger.log.log(Level.SEVERE, "Couldn't unbind Settings service", e) - } - } - - } - - companion object { - - fun getInstance(context: Context): Stub? { - if (Looper.getMainLooper().thread == Thread.currentThread()) - throw IllegalStateException("Must not be called from main thread") - - var service: ISettings? = null - val serviceLock = Object() - val serviceConn = object: ServiceConnection { - override fun onServiceConnected(name: ComponentName, binder: IBinder) { - synchronized(serviceLock) { - service = ISettings.Stub.asInterface(binder) - serviceLock.notify() - } - } - override fun onServiceDisconnected(name: ComponentName) { - service = null - } - } - - if (!context.bindService(Intent(context, Settings::class.java), serviceConn, Context.BIND_AUTO_CREATE or Context.BIND_IMPORTANT)) - return null - - synchronized(serviceLock) { - if (service == null) - try { - serviceLock.wait() - } catch(e: InterruptedException) { - } - - if (service == null) { - try { - context.unbindService(serviceConn) - } catch (e: IllegalArgumentException) { - } - return null - } - } - - return Stub(service!!, context, serviceConn) - } - - } - -} diff --git a/app/src/main/java/at/bitfire/davdroid/syncadapter/AddressBooksSyncAdapterService.kt b/app/src/main/java/at/bitfire/davdroid/syncadapter/AddressBooksSyncAdapterService.kt deleted file mode 100644 index 2694ee5b2aac54dee1e7197e52ee640ccebff91e..0000000000000000000000000000000000000000 --- a/app/src/main/java/at/bitfire/davdroid/syncadapter/AddressBooksSyncAdapterService.kt +++ /dev/null @@ -1,158 +0,0 @@ -/* - * Copyright © Ricki Hirner (bitfire web engineering). - * All rights reserved. This program and the accompanying materials - * are made available under the terms of the GNU Public License v3.0 - * which accompanies this distribution, and is available at - * http://www.gnu.org/licenses/gpl.html - */ -package at.bitfire.davdroid.syncadapter - -import android.Manifest -import android.accounts.Account -import android.content.* -import android.content.pm.PackageManager -import android.database.DatabaseUtils -import android.os.Build -import android.os.Bundle -import android.provider.ContactsContract -import android.support.v4.content.ContextCompat -import at.bitfire.davdroid.AccountSettings -import at.bitfire.davdroid.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.ISettings -import at.bitfire.davdroid.ui.AccountActivity -import okhttp3.HttpUrl -import java.util.logging.Level - -class AddressBooksSyncAdapterService : SyncAdapterService() { - - override fun syncAdapter() = AddressBooksSyncAdapter(this) - - - class AddressBooksSyncAdapter( - context: Context - ) : SyncAdapter(context) { - - override fun sync(settings: ISettings, account: Account, extras: Bundle, authority: String, provider: ContentProviderClient, syncResult: SyncResult) { - try { - val accountSettings = AccountSettings(context, settings, account) - - /* don't run sync if - - sync conditions (e.g. "sync only in WiFi") are not met AND - - this is is an automatic sync (i.e. manual syncs are run regardless of sync conditions) - */ - if (!extras.containsKey(ContentResolver.SYNC_EXTRAS_MANUAL) && !checkSyncConditions(accountSettings)) - return - - updateLocalAddressBooks(account, syncResult) - - for (addressBookAccount in LocalAddressBook.findAll(context, null, account).map { it.account }) { - Logger.log.log(Level.INFO, "Running sync for address book", addressBookAccount) - val syncExtras = Bundle(extras) - syncExtras.putBoolean(ContentResolver.SYNC_EXTRAS_IGNORE_SETTINGS, true) - syncExtras.putBoolean(ContentResolver.SYNC_EXTRAS_IGNORE_BACKOFF, true) - ContentResolver.requestSync(addressBookAccount, ContactsContract.AUTHORITY, syncExtras) - } - } catch (e: Exception) { - Logger.log.log(Level.SEVERE, "Couldn't sync address books", e) - } - - Logger.log.info("Address book sync complete") - } - - private fun updateLocalAddressBooks(account: Account, syncResult: SyncResult) { - ServiceDB.OpenHelper(context).use { dbHelper -> - val db = dbHelper.readableDatabase - - fun getService() = - db.query(ServiceDB.Services._TABLE, arrayOf(ServiceDB.Services.ID), - "${ServiceDB.Services.ACCOUNT_NAME}=? AND ${ServiceDB.Services.SERVICE}=?", - arrayOf(account.name, ServiceDB.Services.SERVICE_CARDDAV), null, null, null)?.use { c -> - if (c.moveToNext()) - c.getLong(0) - else - null - } - - fun remoteAddressBooks(service: Long?): MutableMap { - val collections = mutableMapOf() - service?.let { - db.query(Collections._TABLE, null, - Collections.SERVICE_ID + "=? AND " + Collections.SYNC, arrayOf(service.toString()), null, null, null)?.use { cursor -> - while (cursor.moveToNext()) { - val values = ContentValues(cursor.columnCount) - DatabaseUtils.cursorRowToContentValues(cursor, values) - val info = CollectionInfo(values) - collections[info.url] = info - } - } - } - return collections - } - - // enumerate remote and local address books - val service = getService() - val remote = remoteAddressBooks(service) - - if (ContextCompat.checkSelfPermission(context, Manifest.permission.WRITE_CONTACTS) != PackageManager.PERMISSION_GRANTED) { - if (remote.isEmpty()) { - Logger.log.info("No contacts permission, but no address book selected for synchronization") - return - } else { - // no contacts permission, but address books should be synchronized -> show notification - val intent = Intent(context, AccountActivity::class.java) - intent.putExtra(AccountActivity.EXTRA_ACCOUNT, account) - intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) - - notifyPermissions(intent) - } - } - - val contactsProvider = context.contentResolver.acquireContentProviderClient(ContactsContract.AUTHORITY) - try { - if (contactsProvider == null) { - Logger.log.severe("Couldn't access contacts provider") - syncResult.databaseError = true - return - } - - // delete/update local address books - for (addressBook in LocalAddressBook.findAll(context, contactsProvider, account)) { - val url = HttpUrl.parse(addressBook.url)!! - val info = remote[url] - if (info == null) { - Logger.log.log(Level.INFO, "Deleting obsolete local address book", url) - addressBook.delete() - } else { - // remote CollectionInfo found for this local collection, update data - try { - Logger.log.log(Level.FINE, "Updating local address book $url", info) - addressBook.update(info) - } catch (e: Exception) { - Logger.log.log(Level.WARNING, "Couldn't rename address book account", e) - } - // we already have a local address book for this remote collection, don't take into consideration anymore - remote -= url - } - } - - // create new local address books - for ((_, info) in remote) { - Logger.log.log(Level.INFO, "Adding local address book", info) - LocalAddressBook.create(context, contactsProvider, account, info) - } - } finally { - if (Build.VERSION.SDK_INT >= 24) - contactsProvider?.close() - else - contactsProvider?.release() - } - } - } - - } - -} diff --git a/app/src/main/java/at/bitfire/davdroid/syncadapter/CalendarsSyncAdapterService.kt b/app/src/main/java/at/bitfire/davdroid/syncadapter/CalendarsSyncAdapterService.kt deleted file mode 100644 index 2691bdec9cfd1d003643ed4a4235bcc90659b0df..0000000000000000000000000000000000000000 --- a/app/src/main/java/at/bitfire/davdroid/syncadapter/CalendarsSyncAdapterService.kt +++ /dev/null @@ -1,129 +0,0 @@ -/* - * Copyright © Ricki Hirner (bitfire web engineering). - * All rights reserved. This program and the accompanying materials - * are made available under the terms of the GNU Public License v3.0 - * which accompanies this distribution, and is available at - * http://www.gnu.org/licenses/gpl.html - */ -package at.bitfire.davdroid.syncadapter - -import android.accounts.Account -import android.content.* -import android.database.DatabaseUtils -import android.os.Bundle -import android.provider.CalendarContract -import at.bitfire.davdroid.AccountSettings -import at.bitfire.davdroid.log.Logger -import at.bitfire.davdroid.model.CollectionInfo -import at.bitfire.davdroid.model.ServiceDB -import at.bitfire.davdroid.model.ServiceDB.Collections -import at.bitfire.davdroid.resource.LocalCalendar -import at.bitfire.davdroid.settings.ISettings -import at.bitfire.ical4android.AndroidCalendar -import okhttp3.HttpUrl -import java.util.logging.Level - -class CalendarsSyncAdapterService: SyncAdapterService() { - - override fun syncAdapter() = CalendarsSyncAdapter(this) - - - class CalendarsSyncAdapter( - context: Context - ): SyncAdapter(context) { - - override fun sync(settings: ISettings, account: Account, extras: Bundle, authority: String, provider: ContentProviderClient, syncResult: SyncResult) { - try { - val accountSettings = AccountSettings(context, settings, account) - - /* don't run sync if - - sync conditions (e.g. "sync only in WiFi") are not met AND - - this is is an automatic sync (i.e. manual syncs are run regardless of sync conditions) - */ - if (!extras.containsKey(ContentResolver.SYNC_EXTRAS_MANUAL) && !checkSyncConditions(accountSettings)) - return - - if (accountSettings.getEventColors()) - AndroidCalendar.insertColors(provider, account) - else - AndroidCalendar.removeColors(provider, account) - - updateLocalCalendars(provider, account, accountSettings) - - 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, calendar).use { - it.performSync() - } - } - } catch(e: Exception) { - Logger.log.log(Level.SEVERE, "Couldn't sync calendars", e) - } - Logger.log.info("Calendar sync complete") - } - - private fun updateLocalCalendars(provider: ContentProviderClient, account: Account, settings: AccountSettings) { - ServiceDB.OpenHelper(context).use { dbHelper -> - val db = dbHelper.readableDatabase - - fun getService() = - db.query(ServiceDB.Services._TABLE, arrayOf(ServiceDB.Services.ID), - "${ServiceDB.Services.ACCOUNT_NAME}=? AND ${ServiceDB.Services.SERVICE}=?", - arrayOf(account.name, ServiceDB.Services.SERVICE_CALDAV), null, null, null)?.use { c -> - if (c.moveToNext()) - c.getLong(0) - else - null - } - - fun remoteCalendars(service: Long?): MutableMap { - val collections = mutableMapOf() - service?.let { - db.query(Collections._TABLE, null, - "${Collections.SERVICE_ID}=? AND ${Collections.SUPPORTS_VEVENT}!=0 AND ${Collections.SYNC}", - arrayOf(service.toString()), null, null, null).use { cursor -> - while (cursor.moveToNext()) { - val values = ContentValues(cursor.columnCount) - DatabaseUtils.cursorRowToContentValues(cursor, values) - val info = CollectionInfo(values) - collections[info.url] = info - } - } - } - return collections - } - - // enumerate remote and local calendars - val service = getService() - val remote = remoteCalendars(service) - - // delete/update local calendars - val updateColors = settings.getManageCalendarColors() - - for (calendar in AndroidCalendar.find(account, provider, LocalCalendar.Factory, null, null)) - calendar.name?.let { - val url = HttpUrl.parse(it)!! - val info = remote[url] - if (info == null) { - Logger.log.log(Level.INFO, "Deleting obsolete local calendar", url) - calendar.delete() - } else { - // remote CollectionInfo found for this local collection, update data - Logger.log.log(Level.FINE, "Updating local calendar $url", info) - calendar.update(info, updateColors) - // we already have a local calendar for this remote collection, don't take into consideration anymore - remote -= url - } - } - - // create new local calendars - for ((_, info) in remote) { - Logger.log.log(Level.INFO, "Adding local calendar", info) - LocalCalendar.create(account, provider, info) - } - } - } - - } - -} diff --git a/app/src/main/java/at/bitfire/davdroid/syncadapter/TasksSyncAdapterService.kt b/app/src/main/java/at/bitfire/davdroid/syncadapter/TasksSyncAdapterService.kt deleted file mode 100644 index 6a2c3d351231d88b6f468a98ef082abc834c6db0..0000000000000000000000000000000000000000 --- a/app/src/main/java/at/bitfire/davdroid/syncadapter/TasksSyncAdapterService.kt +++ /dev/null @@ -1,162 +0,0 @@ -/* - * Copyright © Ricki Hirner (bitfire web engineering). - * All rights reserved. This program and the accompanying materials - * are made available under the terms of the GNU Public License v3.0 - * which accompanies this distribution, and is available at - * http://www.gnu.org/licenses/gpl.html - */ -package at.bitfire.davdroid.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 -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 okhttp3.HttpUrl -import org.dmfs.tasks.contract.TaskContract -import java.util.logging.Level - -/** - * Synchronization manager for CalDAV collections; handles tasks ({@code VTODO}). - */ -class TasksSyncAdapterService: SyncAdapterService() { - - override fun syncAdapter() = TasksSyncAdapter(this) - - - class TasksSyncAdapter( - context: Context - ): SyncAdapter(context) { - - override fun sync(settings: ISettings, account: Account, extras: Bundle, authority: String, provider: ContentProviderClient, syncResult: SyncResult) { - try { - 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 - - this is is an automatic sync (i.e. manual syncs are run regardless of sync conditions) - */ - if (!extras.containsKey(ContentResolver.SYNC_EXTRAS_MANUAL) && !checkSyncConditions(accountSettings)) - return - - updateLocalTaskLists(taskProvider, account, accountSettings) - - 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, 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") - } - - private fun updateLocalTaskLists(provider: TaskProvider, account: Account, settings: AccountSettings) { - ServiceDB.OpenHelper(context).use { dbHelper -> - val db = dbHelper.readableDatabase - - fun getService() = - db.query(Services._TABLE, arrayOf(Services.ID), - "${Services.ACCOUNT_NAME}=? AND ${Services.SERVICE}=?", - arrayOf(account.name, Services.SERVICE_CALDAV), null, null, null)?.use { c -> - if (c.moveToNext()) - c.getLong(0) - else - null - } - - fun remoteTaskLists(service: Long?): MutableMap { - val collections = mutableMapOf() - service?.let { - db.query(Collections._TABLE, null, - "${Collections.SERVICE_ID}=? AND ${Collections.SUPPORTS_VTODO}!=0 AND ${Collections.SYNC}", - arrayOf(service.toString()), null, null, null)?.use { cursor -> - while (cursor.moveToNext()) { - val values = ContentValues(cursor.columnCount) - DatabaseUtils.cursorRowToContentValues(cursor, values) - val info = CollectionInfo(values) - collections[info.url] = info - } - } - } - return collections - } - - // enumerate remote and local task lists - val service = getService() - val remote = remoteTaskLists(service) - - // delete/update local task lists - val updateColors = settings.getManageCalendarColors() - - for (list in AndroidTaskList.find(account, provider, LocalTaskList.Factory, null, null)) - list.syncId?.let { - val url = HttpUrl.parse(it)!! - val info = remote[url] - if (info == null) { - Logger.log.fine("Deleting obsolete local task list $url") - list.delete() - } else { - // remote CollectionInfo found for this local collection, update data - Logger.log.log(Level.FINE, "Updating local task list $url", info) - list.update(info, updateColors) - // we already have a local task list for this remote collection, don't take into consideration anymore - remote -= url - } - } - - // create new local task lists - for ((_,info) in remote) { - Logger.log.log(Level.INFO, "Adding local task list", info) - LocalTaskList.create(account, provider, info) - } - } - } - - } - -} diff --git a/app/src/main/java/at/bitfire/davdroid/ui/AccountActivity.kt b/app/src/main/java/at/bitfire/davdroid/ui/AccountActivity.kt deleted file mode 100644 index ffc49f88ea46557b64a1a45f29c588813bc3895b..0000000000000000000000000000000000000000 --- a/app/src/main/java/at/bitfire/davdroid/ui/AccountActivity.kt +++ /dev/null @@ -1,807 +0,0 @@ -/* - * Copyright © Ricki Hirner (bitfire web engineering). - * All rights reserved. This program and the accompanying materials - * are made available under the terms of the GNU Public License v3.0 - * which accompanies this distribution, and is available at - * http://www.gnu.org/licenses/gpl.html - */ - -package at.bitfire.davdroid.ui - -import android.Manifest -import android.accounts.Account -import android.accounts.AccountManager -import android.annotation.SuppressLint -import android.app.Dialog -import android.app.LoaderManager -import android.content.* -import android.content.pm.PackageManager -import android.database.DatabaseUtils -import android.database.sqlite.SQLiteDatabase -import android.net.Uri -import android.os.AsyncTask -import android.os.Build -import android.os.Bundle -import android.os.IBinder -import android.provider.CalendarContract -import android.provider.ContactsContract -import android.support.design.widget.Snackbar -import android.support.v4.app.ActivityCompat -import android.support.v4.app.DialogFragment -import android.support.v4.content.ContextCompat -import android.support.v7.app.AlertDialog -import android.support.v7.app.AppCompatActivity -import android.support.v7.widget.Toolbar -import android.view.* -import android.widget.* -import at.bitfire.davdroid.DavService -import at.bitfire.davdroid.R -import at.bitfire.davdroid.log.Logger -import at.bitfire.davdroid.model.CollectionInfo -import at.bitfire.davdroid.model.ServiceDB -import at.bitfire.davdroid.model.ServiceDB.* -import at.bitfire.davdroid.model.ServiceDB.Collections -import at.bitfire.davdroid.resource.LocalAddressBook -import at.bitfire.davdroid.resource.LocalTaskList -import at.bitfire.ical4android.TaskProvider -import kotlinx.android.synthetic.main.account_caldav_item.view.* -import kotlinx.android.synthetic.main.activity_account.* -import java.lang.ref.WeakReference -import java.util.* -import java.util.logging.Level - -class AccountActivity: AppCompatActivity(), Toolbar.OnMenuItemClickListener, PopupMenu.OnMenuItemClickListener, LoaderManager.LoaderCallbacks { - - companion object { - const val EXTRA_ACCOUNT = "account" - - private fun requestSync(context: Context, account: Account) { - val authorities = arrayOf( - context.getString(R.string.address_books_authority), - CalendarContract.AUTHORITY, - TaskProvider.ProviderName.OpenTasks.authority - ) - - for (authority in authorities) { - val extras = Bundle(2) - extras.putBoolean(ContentResolver.SYNC_EXTRAS_MANUAL, true) // manual sync - extras.putBoolean(ContentResolver.SYNC_EXTRAS_EXPEDITED, true) // run immediately (don't queue) - ContentResolver.requestSync(account, authority, extras) - } - } - - } - - lateinit var account: Account - private var accountInfo: AccountInfo? = null - - - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - - // account may be a DAVdroid address book account -> use main account in this case - account = LocalAddressBook.mainAccount(this, - requireNotNull(intent.getParcelableExtra(EXTRA_ACCOUNT))) - title = account.name - - setContentView(R.layout.activity_account) - - val icMenu = if (Build.VERSION.SDK_INT >= 21) - getDrawable(R.drawable.ic_menu_light) - else - resources.getDrawable(R.drawable.ic_menu_light) - - // CardDAV toolbar - carddav_menu.overflowIcon = icMenu - carddav_menu.inflateMenu(R.menu.carddav_actions) - carddav_menu.setOnMenuItemClickListener(this) - - // CalDAV toolbar - caldav_menu.overflowIcon = icMenu - caldav_menu.inflateMenu(R.menu.caldav_actions) - caldav_menu.setOnMenuItemClickListener(this) - - // Webcal toolbar - webcal_menu.overflowIcon = icMenu - webcal_menu.inflateMenu(R.menu.webcal_actions) - webcal_menu.setOnMenuItemClickListener(this) - - // load CardDAV/CalDAV collections - loaderManager.initLoader(0, null, this) - } - - override fun onRequestPermissionsResult(requestCode: Int, permissions: Array, grantResults: IntArray) { - if (grantResults.any { it == PackageManager.PERMISSION_GRANTED }) - // we've got additional permissions; try to load everything again - reload() - } - - override fun onCreateOptionsMenu(menu: Menu): Boolean { - menuInflater.inflate(R.menu.activity_account, menu) - return true - } - - override fun onPrepareOptionsMenu(menu: Menu): Boolean { - val itemRename = menu.findItem(R.id.rename_account) - // renameAccount is available for API level 21+ - itemRename.isVisible = Build.VERSION.SDK_INT >= 21 - return super.onPrepareOptionsMenu(menu) - } - - override fun onOptionsItemSelected(item: MenuItem): Boolean { - when (item.itemId) { - R.id.sync_now -> - requestSync() - R.id.settings -> { - val intent = Intent(this, AccountSettingsActivity::class.java) - intent.putExtra(AccountSettingsActivity.EXTRA_ACCOUNT, account) - startActivity(intent) - } - R.id.rename_account -> - RenameAccountFragment.newInstance(account).show(supportFragmentManager, null) - R.id.delete_account -> { - AlertDialog.Builder(this) - .setIcon(R.drawable.ic_error_dark) - .setTitle(R.string.account_delete_confirmation_title) - .setMessage(R.string.account_delete_confirmation_text) - .setNegativeButton(android.R.string.no, null) - .setPositiveButton(android.R.string.yes) { _, _ -> - deleteAccount() - } - .show() - } - else -> - return super.onOptionsItemSelected(item) - } - return true - } - - override fun onMenuItemClick(item: MenuItem): Boolean { - when (item.itemId) { - R.id.refresh_address_books -> - accountInfo?.carddav?.let { carddav -> - val intent = Intent(this, DavService::class.java) - intent.action = DavService.ACTION_REFRESH_COLLECTIONS - intent.putExtra(DavService.EXTRA_DAV_SERVICE_ID, carddav.id) - startService(intent) - } - R.id.create_address_book -> { - val intent = Intent(this, CreateAddressBookActivity::class.java) - intent.putExtra(CreateAddressBookActivity.EXTRA_ACCOUNT, account) - startActivity(intent) - } - R.id.refresh_calendars -> - accountInfo?.caldav?.let { caldav -> - val intent = Intent(this, DavService::class.java) - intent.action = DavService.ACTION_REFRESH_COLLECTIONS - intent.putExtra(DavService.EXTRA_DAV_SERVICE_ID, caldav.id) - startService(intent) - } - R.id.create_calendar -> { - val intent = Intent(this, CreateCalendarActivity::class.java) - intent.putExtra(CreateCalendarActivity.EXTRA_ACCOUNT, account) - startActivity(intent) - } - } - return false - } - - - private val onItemClickListener = AdapterView.OnItemClickListener { parent, view, position, _ -> - if (!view.isEnabled) - return@OnItemClickListener - - val list = parent as ListView - val adapter = list.adapter as ArrayAdapter - val info = adapter.getItem(position) - val nowChecked = !info.selected - - SelectCollectionTask(applicationContext, info, nowChecked, WeakReference(adapter), WeakReference(view)).execute() - } - - private val onActionOverflowListener = { anchor: View, info: CollectionInfo -> - val popup = PopupMenu(this, anchor, Gravity.RIGHT) - popup.inflate(R.menu.account_collection_operations) - - with(popup.menu.findItem(R.id.force_read_only)) { - if (info.privWriteContent) - isChecked = info.forceReadOnly - else - isVisible = false - } - - popup.menu.findItem(R.id.delete_collection).isVisible = info.privUnbind - - popup.setOnMenuItemClickListener { item -> - when (item.itemId) { - R.id.force_read_only -> { - val nowChecked = !item.isChecked - SetReadOnlyTask(WeakReference(this), info.id!!, nowChecked).execute() - } - R.id.delete_collection -> - DeleteCollectionFragment.ConfirmDeleteCollectionFragment.newInstance(account, info).show(supportFragmentManager, null) - R.id.properties -> - CollectionInfoFragment.newInstance(info).show(supportFragmentManager, null) - } - true - } - popup.show() - - // long click was handled - true - } - - private val webcalOnItemClickListener = AdapterView.OnItemClickListener { parent, _, position, _ -> - val info = parent.getItemAtPosition(position) as CollectionInfo - var uri = Uri.parse(info.source) - - val nowChecked = !info.selected - if (nowChecked) { - // subscribe to Webcal feed - when { - uri.scheme.equals("http", true) -> uri = uri.buildUpon().scheme("webcal").build() - uri.scheme.equals("https", true) -> uri = uri.buildUpon().scheme("webcals").build() - } - - val intent = Intent(Intent.ACTION_VIEW, uri) - info.displayName?.let { intent.putExtra("title", it) } - info.color?.let { intent.putExtra("color", it) } - if (packageManager.resolveActivity(intent, 0) != null) - startActivity(intent) - else { - val snack = Snackbar.make(parent, R.string.account_no_webcal_handler_found, Snackbar.LENGTH_LONG) - - val installIntent = Intent(Intent.ACTION_VIEW, Uri.parse("market://details?id=at.bitfire.icsdroid")) - if (packageManager.resolveActivity(installIntent, 0) != null) - snack.setAction(R.string.account_install_icsdroid) { - startActivity(installIntent) - } - - snack.show() - } - } else { - // unsubscribe from Webcal feed - if (ContextCompat.checkSelfPermission(this, Manifest.permission.WRITE_CALENDAR) == PackageManager.PERMISSION_GRANTED) - contentResolver.acquireContentProviderClient(CalendarContract.AUTHORITY)?.let { provider -> - try { - provider.delete(CalendarContract.Calendars.CONTENT_URI, "${CalendarContract.Calendars.NAME}=?", arrayOf(info.source)) - reload() - } finally { - provider.release() - } - } - } - } - - - /* TASKS */ - - @SuppressLint("StaticFieldLeak") - class SelectCollectionTask( - val applicationContext: Context, - - val info: CollectionInfo, - val nowChecked: Boolean, - val adapter: WeakReference>, - val view: WeakReference - ): AsyncTask() { - - override fun onPreExecute() { - view.get()?.isEnabled = false - } - - override fun doInBackground(vararg params: Void?): Void? { - val values = ContentValues(1) - values.put(Collections.SYNC, if (nowChecked) 1 else 0) - - OpenHelper(applicationContext).use { dbHelper -> - val db = dbHelper.writableDatabase - db.update(Collections._TABLE, values, "${Collections.ID}=?", arrayOf(info.id.toString())) - } - - return null - } - - override fun onPostExecute(result: Void?) { - info.selected = nowChecked - adapter.get()?.notifyDataSetChanged() - view.get()?.isEnabled = true - } - - } - - class SetReadOnlyTask( - val activity: WeakReference, - val id: Long, - val nowChecked: Boolean - ): AsyncTask() { - - override fun doInBackground(vararg params: Void?): Void? { - activity.get()?.let { context -> - OpenHelper(context).use { dbHelper -> - val values = ContentValues(1) - values.put(Collections.FORCE_READ_ONLY, nowChecked) - - val db = dbHelper.writableDatabase - db.update(Collections._TABLE, values, "${Collections.ID}=?", arrayOf(id.toString())) - } - } - return null - } - - override fun onPostExecute(result: Void?) { - activity.get()?.reload() - } - - } - - - /* LOADERS AND LOADED DATA */ - - class AccountInfo { - var carddav: ServiceInfo? = null - var caldav: ServiceInfo? = null - - class ServiceInfo { - var id: Long? = null - var refreshing = false - - var hasHomeSets = false - var collections = listOf() - } - } - - override fun onCreateLoader(id: Int, args: Bundle?) = - AccountLoader(this, account) - - fun reload() { - loaderManager.restartLoader(0, null, this) - } - - override fun onLoadFinished(loader: Loader, info: AccountInfo?) { - accountInfo = info - - if (info?.caldav?.collections?.any { it.selected } != true && - info?.carddav?.collections?.any { it.selected} != true) - select_collections_hint.visibility = View.VISIBLE - - carddav.visibility = info?.carddav?.let { carddav -> - carddav_refreshing.visibility = if (carddav.refreshing) View.VISIBLE else View.GONE - - address_books.isEnabled = !carddav.refreshing - address_books.alpha = if (carddav.refreshing) 0.5f else 1f - - carddav_menu.menu.findItem(R.id.create_address_book).isEnabled = carddav.hasHomeSets - - val adapter = AddressBookAdapter(this) - adapter.addAll(carddav.collections) - address_books.adapter = adapter - address_books.onItemClickListener = onItemClickListener - - View.VISIBLE - } ?: View.GONE - - caldav.visibility = info?.caldav?.let { caldav -> - caldav_refreshing.visibility = if (caldav.refreshing) View.VISIBLE else View.GONE - - calendars.isEnabled = !caldav.refreshing - calendars.alpha = if (caldav.refreshing) 0.5f else 1f - - caldav_menu.menu.findItem(R.id.create_calendar).isEnabled = caldav.hasHomeSets - - val adapter = CalendarAdapter(this) - adapter.addAll(caldav.collections.filter { it.type == CollectionInfo.Type.CALENDAR }) - calendars.adapter = adapter - calendars.onItemClickListener = onItemClickListener - - View.VISIBLE - } ?: View.GONE - - webcal.visibility = info?.caldav?.let { - val collections = it.collections.filter { it.type == CollectionInfo.Type.WEBCAL } - - val adapter = CalendarAdapter(this) - adapter.addAll(collections) - webcals.adapter = adapter - webcals.onItemClickListener = webcalOnItemClickListener - - if (collections.isNotEmpty()) - View.VISIBLE - else - View.GONE - } ?: View.GONE - - // ask for permissions - val requiredPermissions = mutableSetOf() - 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) { - address_books?.adapter = null - calendars?.adapter = null - } - - - class AccountLoader( - context: Context, - val account: Account - ): AsyncTaskLoader(context), DavService.RefreshingStatusListener, SyncStatusObserver { - - private var syncStatusListener: Any? = null - - private var davServiceConn: ServiceConnection? = null - private var davService: DavService.InfoBinder? = null - - override fun onStartLoading() { - // get notified when sync status changes - if (syncStatusListener == null) - syncStatusListener = ContentResolver.addStatusChangeListener(ContentResolver.SYNC_OBSERVER_TYPE_ACTIVE, this) - - // bind to DavService to get notified when it's running - if (davServiceConn == null) { - davServiceConn = object: ServiceConnection { - override fun onServiceConnected(name: ComponentName, service: IBinder) { - // get notified when DavService is running - davService = service as DavService.InfoBinder - service.addRefreshingStatusListener(this@AccountLoader, false) - - onContentChanged() - } - - override fun onServiceDisconnected(name: ComponentName) { - davService = null - } - } - context.bindService(Intent(context, DavService::class.java), davServiceConn, Context.BIND_AUTO_CREATE) - } else - forceLoad() - } - - override fun onReset() { - ContentResolver.removeStatusChangeListener(syncStatusListener) - - davService?.removeRefreshingStatusListener(this) - davServiceConn?.let { - context.unbindService(it) - davServiceConn = null - } - } - - override fun onDavRefreshStatusChanged(id: Long, refreshing: Boolean) = - onContentChanged() - - override fun onStatusChanged(which: Int) = - onContentChanged() - - override fun loadInBackground(): AccountInfo { - val info = AccountInfo() - - OpenHelper(context).use { dbHelper -> - val db = dbHelper.readableDatabase - db.query( - Services._TABLE, - arrayOf(Services.ID, Services.SERVICE), - "${Services.ACCOUNT_NAME}=?", arrayOf(account.name), - null, null, null).use { cursor -> - while (cursor.moveToNext()) { - val id = cursor.getLong(0) - when (cursor.getString(1)) { - Services.SERVICE_CARDDAV -> { - val carddav = AccountInfo.ServiceInfo() - info.carddav = carddav - carddav.id = id - carddav.refreshing = - davService?.isRefreshing(id) ?: false || - ContentResolver.isSyncActive(account, context.getString(R.string.address_books_authority)) - - val accountManager = AccountManager.get(context) - for (addrBookAccount in accountManager.getAccountsByType(context.getString(R.string.account_type_address_book))) { - val addressBook = LocalAddressBook(context, addrBookAccount, null) - try { - if (account == addressBook.mainAccount) - carddav.refreshing = carddav.refreshing || ContentResolver.isSyncActive(addrBookAccount, ContactsContract.AUTHORITY) - } catch(e: Exception) { - } - } - - carddav.hasHomeSets = hasHomeSets(db, id) - carddav.collections = readCollections(db, id) - } - Services.SERVICE_CALDAV -> { - val caldav = AccountInfo.ServiceInfo() - info.caldav = caldav - caldav.id = id - caldav.refreshing = - davService?.isRefreshing(id) ?: false || - ContentResolver.isSyncActive(account, CalendarContract.AUTHORITY) || - ContentResolver.isSyncActive(account, TaskProvider.ProviderName.OpenTasks.authority) - caldav.hasHomeSets = hasHomeSets(db, id) - caldav.collections = readCollections(db, id) - } - } - } - } - } - - return info - } - - private fun hasHomeSets(db: SQLiteDatabase, service: Long): Boolean { - db.query(ServiceDB.HomeSets._TABLE, null, "${ServiceDB.HomeSets.SERVICE_ID}=?", - arrayOf(service.toString()), null, null, null)?.use { cursor -> - return cursor.count > 0 - } - return false - } - - private fun readCollections(db: SQLiteDatabase, service: Long): List { - val collections = LinkedList() - db.query(Collections._TABLE, null, Collections.SERVICE_ID + "=?", arrayOf(service.toString()), - null, null, "${Collections.SUPPORTS_VEVENT} DESC,${Collections.DISPLAY_NAME}").use { cursor -> - while (cursor.moveToNext()) { - val values = ContentValues(cursor.columnCount) - DatabaseUtils.cursorRowToContentValues(cursor, values) - collections.add(CollectionInfo(values)) - } - } - - // Webcal: check whether calendar is already subscribed by ICSdroid - // (or any other app that stores the URL in Calendars.NAME) - val webcalCollections = collections.filter { it.type == CollectionInfo.Type.WEBCAL } - if (webcalCollections.isNotEmpty() && ContextCompat.checkSelfPermission(context, Manifest.permission.READ_CALENDAR) == PackageManager.PERMISSION_GRANTED) - context.contentResolver.acquireContentProviderClient(CalendarContract.AUTHORITY)?.let { provider -> - try { - for (info in webcalCollections) { - provider.query(CalendarContract.Calendars.CONTENT_URI, null, - "${CalendarContract.Calendars.NAME}=?", arrayOf(info.source), null)?.use { cursor -> - if (cursor.moveToNext()) - info.selected = true - } - } - } finally { - provider.release() - } - } - - return collections - } - - } - - - /* LIST ADAPTERS */ - - class AddressBookAdapter( - context: Context - ): ArrayAdapter(context, R.layout.account_carddav_item) { - override fun getView(position: Int, v: View?, parent: ViewGroup?): View { - val v = v ?: LayoutInflater.from(context).inflate(R.layout.account_carddav_item, parent, false) - val info = getItem(position) - - val checked: CheckBox = v.findViewById(R.id.checked) - checked.isChecked = info.selected - - var tv: TextView = v.findViewById(R.id.title) - tv.text = if (!info.displayName.isNullOrBlank()) info.displayName else info.url.toString() - - tv = v.findViewById(R.id.description) - if (info.description.isNullOrBlank()) - tv.visibility = View.GONE - else { - tv.visibility = View.VISIBLE - tv.text = info.description - } - - v.findViewById(R.id.read_only).visibility = - if (!info.privWriteContent || info.forceReadOnly) View.VISIBLE else View.GONE - - v.findViewById(R.id.action_overflow).setOnClickListener { view -> - @Suppress("ReplaceSingleLineLet") - (context as? AccountActivity)?.let { - it.onActionOverflowListener(view, info) - } - } - - return v - } - } - - class CalendarAdapter( - context: Context - ): ArrayAdapter(context, R.layout.account_caldav_item) { - override fun getView(position: Int, v: View?, parent: ViewGroup?): View { - val v = v ?: LayoutInflater.from(context).inflate(R.layout.account_caldav_item, parent, false) - val info = getItem(position) - - val enabled = info.selected || info.supportsVEVENT || info.supportsVTODO - v.isEnabled = enabled - v.checked.isEnabled = enabled - - val checked: CheckBox = v.findViewById(R.id.checked) - checked.isChecked = info.selected - - val vColor: View = v.findViewById(R.id.color) - vColor.visibility = info.color?.let { - vColor.setBackgroundColor(it) - View.VISIBLE - } ?: View.INVISIBLE - - var tv: TextView = v.findViewById(R.id.title) - tv.text = if (!info.displayName.isNullOrBlank()) info.displayName else info.url.toString() - - tv = v.findViewById(R.id.description) - if (info.description.isNullOrBlank()) - tv.visibility = View.GONE - else { - tv.visibility = View.VISIBLE - tv.text = info.description - } - - v.findViewById(R.id.read_only).visibility = - if (!info.privWriteContent || info.forceReadOnly) View.VISIBLE else View.GONE - - v.findViewById(R.id.events).visibility = - if (info.supportsVEVENT) View.VISIBLE else View.GONE - - v.findViewById(R.id.tasks).visibility = - if (info.supportsVTODO) View.VISIBLE else View.GONE - - val overflow = v.findViewById(R.id.action_overflow) - if (info.type == CollectionInfo.Type.WEBCAL) - overflow.visibility = View.GONE - else - overflow.setOnClickListener { view -> - (context as? AccountActivity)?.let { - it.onActionOverflowListener(view, info) - } - } - - return v - } - } - - - /* DIALOG FRAGMENTS */ - - class RenameAccountFragment: DialogFragment() { - - companion object { - - const val ARG_ACCOUNT = "account" - - fun newInstance(account: Account): RenameAccountFragment { - val fragment = RenameAccountFragment() - val args = Bundle(1) - args.putParcelable(ARG_ACCOUNT, account) - fragment.arguments = args - return fragment - } - - } - - override fun onCreateDialog(savedInstanceState: Bundle?): Dialog { - val oldAccount: Account = arguments!!.getParcelable(ARG_ACCOUNT) - - val editText = EditText(activity) - editText.setText(oldAccount.name) - - return AlertDialog.Builder(activity!!) - .setTitle(R.string.account_rename) - .setMessage(R.string.account_rename_new_name) - .setView(editText) - .setPositiveButton(R.string.account_rename_rename, DialogInterface.OnClickListener { _, _ -> - val newName = editText.text.toString() - - if (newName == oldAccount.name) - return@OnClickListener - - val accountManager = AccountManager.get(activity) - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) - accountManager.renameAccount(oldAccount, newName, { _ -> - Logger.log.info("Updating account name references") - - // cancel maybe running synchronization - ContentResolver.cancelSync(oldAccount, null) - for (addrBookAccount in accountManager.getAccountsByType(getString(R.string.account_type_address_book))) - ContentResolver.cancelSync(addrBookAccount, null) - - // update account name references in database - OpenHelper(requireActivity()).use { dbHelper -> - ServiceDB.onRenameAccount(dbHelper.writableDatabase, oldAccount.name, newName) - } - - // update main account of address book accounts - if (ContextCompat.checkSelfPermission(requireActivity(), Manifest.permission.WRITE_CONTACTS) == PackageManager.PERMISSION_GRANTED) - try { - requireActivity().contentResolver.acquireContentProviderClient(ContactsContract.AUTHORITY)?.let { provider -> - for (addrBookAccount in accountManager.getAccountsByType(getString(R.string.account_type_address_book))) - try { - val addressBook = LocalAddressBook(requireActivity(), addrBookAccount, provider) - if (oldAccount == addressBook.mainAccount) - addressBook.mainAccount = Account(newName, oldAccount.type) - } finally { - if (Build.VERSION.SDK_INT >= 24) - provider.close() - else - provider.release() - } - } - } catch(e: Exception) { - Logger.log.log(Level.SEVERE, "Couldn't update address book accounts", e) - } - - // calendar provider doesn't allow changing account_name of Events - // (all events will have to be downloaded again) - - // update account_name of local tasks - try { - LocalTaskList.onRenameAccount(activity!!.contentResolver, oldAccount.name, newName) - } catch(e: Exception) { - Logger.log.log(Level.SEVERE, "Couldn't propagate new account name to tasks provider", e) - } - - // synchronize again - requestSync(activity!!, Account(newName, oldAccount.type)) - }, null) - activity!!.finish() - }) - .setNegativeButton(android.R.string.cancel) { _, _ -> } - .create() - } - } - - - /* USER ACTIONS */ - - private fun deleteAccount() { - val accountManager = AccountManager.get(this) - - if (Build.VERSION.SDK_INT >= 22) - accountManager.removeAccount(account, this, { future -> - try { - if (future.result.getBoolean(AccountManager.KEY_BOOLEAN_RESULT)) - finish() - } catch(e: Exception) { - Logger.log.log(Level.SEVERE, "Couldn't remove account", e) - } - }, null) - else - accountManager.removeAccount(account, { future -> - try { - if (future.result) - finish() - } catch (e: Exception) { - Logger.log.log(Level.SEVERE, "Couldn't remove account", e) - } - }, null) - } - - private fun requestSync() { - requestSync(this, account) - Snackbar.make(findViewById(R.id.parent), R.string.account_synchronizing_now, Snackbar.LENGTH_LONG).show() - } - -} diff --git a/app/src/main/java/at/bitfire/davdroid/ui/AccountListFragment.kt b/app/src/main/java/at/bitfire/davdroid/ui/AccountListFragment.kt deleted file mode 100644 index afd7f7dcd3cdc5799866e862b353d47355cc6432..0000000000000000000000000000000000000000 --- a/app/src/main/java/at/bitfire/davdroid/ui/AccountListFragment.kt +++ /dev/null @@ -1,114 +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.accounts.Account -import android.accounts.AccountManager -import android.accounts.OnAccountsUpdateListener -import android.app.ListFragment -import android.app.LoaderManager -import android.content.AsyncTaskLoader -import android.content.Context -import android.content.Intent -import android.content.Loader -import android.os.Bundle -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup -import android.widget.AbsListView -import android.widget.AdapterView -import android.widget.ArrayAdapter -import at.bitfire.davdroid.R -import kotlinx.android.synthetic.main.account_list_item.view.* - -class AccountListFragment: ListFragment(), LoaderManager.LoaderCallbacks> { - - override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View { - listAdapter = AccountListAdapter(activity) - - return inflater.inflate(R.layout.account_list, container, false) - } - - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - super.onViewCreated(view, savedInstanceState) - loaderManager.initLoader(0, arguments, this) - - listView.choiceMode = AbsListView.CHOICE_MODE_SINGLE - listView.onItemClickListener = AdapterView.OnItemClickListener { _, _, position, _ -> - val account = listAdapter.getItem(position) as Account - val intent = Intent(activity, AccountActivity::class.java) - intent.putExtra(AccountActivity.EXTRA_ACCOUNT, account) - startActivity(intent) - } - } - - - // loader - - override fun onCreateLoader(id: Int, args: Bundle?) = - AccountLoader(activity) - - override fun onLoadFinished(loader: Loader>, accounts: Array) { - val adapter = listAdapter as AccountListAdapter - adapter.clear() - adapter.addAll(*accounts) - } - - override fun onLoaderReset(loader: Loader>) { - (listAdapter as AccountListAdapter).clear() - } - - class AccountLoader( - context: Context - ): AsyncTaskLoader>(context) { - - private val accountManager = AccountManager.get(context)!! - private var listener: OnAccountsUpdateListener? = null - - override fun onStartLoading() { - if (listener == null) { - listener = OnAccountsUpdateListener { onContentChanged() } - accountManager.addOnAccountsUpdatedListener(listener, null, false) - } - - forceLoad() - } - - override fun onReset() { - listener?.let { - try { - accountManager.removeOnAccountsUpdatedListener(it) - } catch(ignored: IllegalArgumentException) {} - listener = null - } - } - - override fun loadInBackground(): Array = - AccountManager.get(context).getAccountsByType(context.getString(R.string.account_type)) - - } - - - // list adapter - - class AccountListAdapter( - context: Context - ): ArrayAdapter(context, R.layout.account_list_item) { - - override fun getView(position: Int, v: View?, parent: ViewGroup?): View { - val account = getItem(position) - - val v = v ?: LayoutInflater.from(context).inflate(R.layout.account_list_item, parent, false) - v.account_name.text = account.name - - return v - } - } - -} diff --git a/app/src/main/java/at/bitfire/davdroid/ui/AccountSettingsActivity.kt b/app/src/main/java/at/bitfire/davdroid/ui/AccountSettingsActivity.kt deleted file mode 100644 index 945c978a0fdd475074f0047e9f80d0cc790bb4fb..0000000000000000000000000000000000000000 --- a/app/src/main/java/at/bitfire/davdroid/ui/AccountSettingsActivity.kt +++ /dev/null @@ -1,394 +0,0 @@ -/* - * Copyright © Ricki Hirner (bitfire web engineering). - * All rights reserved. This program and the accompanying materials - * are made available under the terms of the GNU Public License v3.0 - * which accompanies this distribution, and is available at - * http://www.gnu.org/licenses/gpl.html - */ - -package at.bitfire.davdroid.ui - -import android.Manifest -import android.accounts.Account -import android.content.ContentResolver -import android.content.Context -import android.content.Intent -import android.content.SyncStatusObserver -import android.content.pm.PackageManager -import android.os.Build -import android.os.Bundle -import android.os.Handler -import android.os.Looper -import android.provider.CalendarContract -import android.security.KeyChain -import android.support.v4.app.ActivityCompat -import android.support.v4.app.DialogFragment -import android.support.v4.app.LoaderManager -import android.support.v4.app.NavUtils -import android.support.v4.content.ContextCompat -import android.support.v4.content.Loader -import android.support.v7.app.AlertDialog -import android.support.v7.app.AppCompatActivity -import android.support.v7.preference.* -import android.view.MenuItem -import at.bitfire.davdroid.AccountSettings -import at.bitfire.davdroid.InvalidAccountException -import at.bitfire.davdroid.R -import at.bitfire.davdroid.model.Credentials -import at.bitfire.davdroid.resource.LocalCalendar -import at.bitfire.davdroid.settings.ISettings -import at.bitfire.ical4android.AndroidCalendar -import at.bitfire.ical4android.TaskProvider -import at.bitfire.vcard4android.GroupMethod -import org.apache.commons.lang3.StringUtils - -class AccountSettingsActivity: AppCompatActivity() { - - companion object { - const val EXTRA_ACCOUNT = "account" - } - - private lateinit var account: Account - - - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - - account = intent.getParcelableExtra(EXTRA_ACCOUNT) - title = getString(R.string.settings_title, account.name) - - supportActionBar?.setDisplayHomeAsUpEnabled(true) - - if (savedInstanceState == null) - supportFragmentManager.beginTransaction() - .replace(android.R.id.content, DialogFragment.instantiate(this, AccountSettingsFragment::class.java.name, intent.extras)) - .commit() - } - - override fun onOptionsItemSelected(item: MenuItem) = - if (item.itemId == android.R.id.home) { - val intent = Intent(this, AccountActivity::class.java) - intent.putExtra(AccountActivity.EXTRA_ACCOUNT, account) - NavUtils.navigateUpTo(this, intent) - true - } else - false - - - class AccountSettingsFragment: PreferenceFragmentCompat(), LoaderManager.LoaderCallbacks> { - lateinit var account: Account - - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - - account = arguments!!.getParcelable(EXTRA_ACCOUNT) - loaderManager.initLoader(0, arguments, this) - } - - override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) { - addPreferencesFromResource(R.xml.settings_account) - } - - override fun onCreateLoader(id: Int, args: Bundle?) = - AccountSettingsLoader(requireActivity(), args!!.getParcelable(EXTRA_ACCOUNT)) - - override fun onLoadFinished(loader: Loader>, result: Pair?) { - val (settings, accountSettings) = result ?: return - - // preference group: authentication - val prefUserName = findPreference("username") as EditTextPreference - val prefPassword = findPreference("password") as EditTextPreference - val prefCertAlias = findPreference("certificate_alias") as Preference - - val credentials = accountSettings.credentials() - when (credentials.type) { - Credentials.Type.UsernamePassword -> { - prefUserName.isVisible = true - prefUserName.summary = credentials.userName - prefUserName.text = credentials.userName - prefUserName.onPreferenceChangeListener = Preference.OnPreferenceChangeListener { _, newValue -> - accountSettings.credentials(Credentials(newValue as String, credentials.password)) - loaderManager.restartLoader(0, arguments, this) - false - } - - prefPassword.isVisible = true - prefPassword.onPreferenceChangeListener = Preference.OnPreferenceChangeListener { _, newValue -> - accountSettings.credentials(Credentials(credentials.userName, newValue as String)) - loaderManager.restartLoader(0, arguments, this) - false - } - - prefCertAlias.isVisible = false - } - Credentials.Type.ClientCertificate -> { - prefUserName.isVisible = false - prefPassword.isVisible = false - - prefCertAlias.isVisible = true - prefCertAlias.summary = credentials.certificateAlias - prefCertAlias.setOnPreferenceClickListener { - KeyChain.choosePrivateKeyAlias(activity, { alias -> - accountSettings.credentials(Credentials(certificateAlias = alias)) - Handler(Looper.getMainLooper()).post { - loaderManager.restartLoader(0, arguments, this) - } - }, null, null, null, -1, credentials.certificateAlias) - true - } - } - } - - // preference group: sync - // those are null if the respective sync type is not available for this account: - val syncIntervalContacts = accountSettings.getSyncInterval(getString(R.string.address_books_authority)) - val syncIntervalCalendars = accountSettings.getSyncInterval(CalendarContract.AUTHORITY) - val syncIntervalTasks = accountSettings.getSyncInterval(TaskProvider.ProviderName.OpenTasks.authority) - - (findPreference("sync_interval_contacts") as ListPreference).let { - if (syncIntervalContacts != null) { - it.isVisible = true - it.value = syncIntervalContacts.toString() - if (syncIntervalContacts == AccountSettings.SYNC_INTERVAL_MANUALLY) - it.setSummary(R.string.settings_sync_summary_manually) - else - it.summary = getString(R.string.settings_sync_summary_periodically, syncIntervalContacts / 60) - it.onPreferenceChangeListener = Preference.OnPreferenceChangeListener { _, newValue -> - accountSettings.setSyncInterval(getString(R.string.address_books_authority), (newValue as String).toLong()) - loaderManager.restartLoader(0, arguments, this) - false - } - } else - it.isVisible = false - } - - (findPreference("sync_interval_calendars") as ListPreference).let { - if (syncIntervalCalendars != null) { - it.isVisible = true - it.value = syncIntervalCalendars.toString() - if (syncIntervalCalendars == AccountSettings.SYNC_INTERVAL_MANUALLY) - it.setSummary(R.string.settings_sync_summary_manually) - else - it.summary = getString(R.string.settings_sync_summary_periodically, syncIntervalCalendars / 60) - it.onPreferenceChangeListener = Preference.OnPreferenceChangeListener { _, newValue -> - accountSettings.setSyncInterval(CalendarContract.AUTHORITY, (newValue as String).toLong()) - loaderManager.restartLoader(0, arguments, this) - false - } - } else - it.isVisible = false - } - - (findPreference("sync_interval_tasks") as ListPreference).let { - if (syncIntervalTasks != null) { - it.isVisible = true - it.value = syncIntervalTasks.toString() - if (syncIntervalTasks == AccountSettings.SYNC_INTERVAL_MANUALLY) - it.setSummary(R.string.settings_sync_summary_manually) - else - it.summary = getString(R.string.settings_sync_summary_periodically, syncIntervalTasks / 60) - it.onPreferenceChangeListener = Preference.OnPreferenceChangeListener { _, newValue -> - accountSettings.setSyncInterval(TaskProvider.ProviderName.OpenTasks.authority, (newValue as String).toLong()) - loaderManager.restartLoader(0, arguments, this) - false - } - } else - it.isVisible = false - } - - val prefWifiOnly = findPreference("sync_wifi_only") as SwitchPreferenceCompat - prefWifiOnly.isEnabled = !settings.has(AccountSettings.KEY_WIFI_ONLY) - prefWifiOnly.isChecked = accountSettings.getSyncWifiOnly() - prefWifiOnly.onPreferenceChangeListener = Preference.OnPreferenceChangeListener { _, wifiOnly -> - accountSettings.setSyncWiFiOnly(wifiOnly as Boolean) - loaderManager.restartLoader(0, arguments, this) - false - } - - val prefWifiOnlySSIDs = findPreference("sync_wifi_only_ssids") as EditTextPreference - val onlySSIDs = accountSettings.getSyncWifiOnlySSIDs()?.joinToString(", ") - prefWifiOnlySSIDs.text = onlySSIDs - if (onlySSIDs != null) - prefWifiOnlySSIDs.summary = getString(R.string.settings_sync_wifi_only_ssids_on, onlySSIDs) - else - prefWifiOnlySSIDs.setSummary(R.string.settings_sync_wifi_only_ssids_off) - prefWifiOnlySSIDs.onPreferenceChangeListener = Preference.OnPreferenceChangeListener { _, newValue -> - accountSettings.setSyncWifiOnlySSIDs((newValue as String).split(',').mapNotNull { StringUtils.trimToNull(it) }.distinct()) - loaderManager.restartLoader(0, arguments, this) - false - } - - // getting the WiFi name requires location permission (and active location services) since Android 8.1 - // see https://issuetracker.google.com/issues/70633700 - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O_MR1 && - accountSettings.getSyncWifiOnly() && onlySSIDs != null && - ContextCompat.checkSelfPermission(requireActivity(), Manifest.permission.ACCESS_COARSE_LOCATION) != PackageManager.PERMISSION_GRANTED) - ActivityCompat.requestPermissions(requireActivity(), arrayOf(Manifest.permission.ACCESS_COARSE_LOCATION), 0) - - // preference group: CardDAV - (findPreference("contact_group_method") as ListPreference).let { - if (syncIntervalContacts != null) { - it.isVisible = true - it.value = accountSettings.getGroupMethod().name - it.summary = it.entry - if (settings.has(AccountSettings.KEY_CONTACT_GROUP_METHOD)) - it.isEnabled = false - else { - it.isEnabled = true - it.onPreferenceChangeListener = Preference.OnPreferenceChangeListener { _, groupMethod -> - AlertDialog.Builder(requireActivity()) - .setIcon(R.drawable.ic_error_dark) - .setTitle(R.string.settings_contact_group_method_change) - .setMessage(R.string.settings_contact_group_method_change_reload_contacts) - .setPositiveButton(android.R.string.ok) { _, _ -> - // change group method - accountSettings.setGroupMethod(GroupMethod.valueOf(groupMethod as String)) - loaderManager.restartLoader(0, arguments, this) - - // reload all contacts - val args = Bundle(1) - args.putBoolean(ContentResolver.SYNC_EXTRAS_MANUAL, true) - ContentResolver.requestSync(account, getString(R.string.address_books_authority), args) - } - .setNegativeButton(android.R.string.cancel, null) - .show() - false - } - } - } else - it.isVisible = false - } - - // preference group: CalDAV - (findPreference("time_range_past_days") as EditTextPreference).let { - if (syncIntervalCalendars != null) { - it.isVisible = true - val pastDays = accountSettings.getTimeRangePastDays() - if (pastDays != null) { - it.text = pastDays.toString() - it.summary = resources.getQuantityString(R.plurals.settings_sync_time_range_past_days, pastDays, pastDays) - } else { - it.text = null - it.setSummary(R.string.settings_sync_time_range_past_none) - } - it.onPreferenceChangeListener = Preference.OnPreferenceChangeListener { _, newValue -> - val days = try { - (newValue as String).toInt() - } catch(e: NumberFormatException) { - -1 - } - accountSettings.setTimeRangePastDays(if (days < 0) null else days) - - // reset sync state of all calendars in this account to trigger a full sync - if (ContextCompat.checkSelfPermission(requireContext(), Manifest.permission.WRITE_CALENDAR) == PackageManager.PERMISSION_GRANTED) { - requireContext().contentResolver.acquireContentProviderClient(CalendarContract.AUTHORITY)?.let { provider -> - try { - AndroidCalendar.find(account, provider, LocalCalendar.Factory, null, null).forEach { calendar -> - calendar.lastSyncState = null - } - } finally { - if (Build.VERSION.SDK_INT >= 24) - provider.close() - else - provider.release() - } - } - } - - loaderManager.restartLoader(0, arguments, this) - false - } - } else - it.isVisible = false - } - - (findPreference("manage_calendar_colors") as SwitchPreferenceCompat).let { - if (syncIntervalCalendars != null || syncIntervalTasks != null) { - it.isVisible = true - it.isEnabled = !settings.has(AccountSettings.KEY_MANAGE_CALENDAR_COLORS) - it.isChecked = accountSettings.getManageCalendarColors() - it.onPreferenceChangeListener = Preference.OnPreferenceChangeListener { _, newValue -> - accountSettings.setManageCalendarColors(newValue as Boolean) - loaderManager.restartLoader(0, arguments, this) - false - } - } else - it.isVisible = false - } - - (findPreference("event_colors") as SwitchPreferenceCompat).let { - if (syncIntervalCalendars != null) { - it.isVisible = true - it.isEnabled = !settings.has(AccountSettings.KEY_EVENT_COLORS) - it.isChecked = accountSettings.getEventColors() - it.onPreferenceChangeListener = Preference.OnPreferenceChangeListener { _, newValue -> - if (newValue as Boolean) { - accountSettings.setEventColors(true) - loaderManager.restartLoader(0, arguments, this) - } else - AlertDialog.Builder(requireActivity()) - .setIcon(R.drawable.ic_error_dark) - .setTitle(R.string.settings_event_colors) - .setMessage(R.string.settings_event_colors_off_confirm) - .setNegativeButton(android.R.string.cancel, null) - .setPositiveButton(android.R.string.ok) { _, _ -> - accountSettings.setEventColors(false) - loaderManager.restartLoader(0, arguments, this) - } - .show() - false - } - } else - it.isVisible = false - } - } - - override fun onLoaderReset(loader: Loader>) { - } - - } - - - class AccountSettingsLoader( - context: Context, - val account: Account - ): SettingsLoader?>(context), SyncStatusObserver { - - private var listenerHandle: Any? = null - - override fun onStartLoading() { - super.onStartLoading() - - if (listenerHandle == null) - listenerHandle = ContentResolver.addStatusChangeListener(ContentResolver.SYNC_OBSERVER_TYPE_SETTINGS, this) - } - - override fun onReset() { - super.onReset() - - listenerHandle?.let { - ContentResolver.removeStatusChangeListener(it) - listenerHandle = null - } - } - - override fun loadInBackground(): Pair? { - settings?.let { settings -> - try { - return Pair( - settings, - AccountSettings(context, settings, account) - ) - } catch (e: InvalidAccountException) { - } - } - return null - } - - override fun onStatusChanged(which: Int) { - onContentChanged() - } - - } - -} diff --git a/app/src/main/java/at/bitfire/davdroid/ui/AppSettingsActivity.kt b/app/src/main/java/at/bitfire/davdroid/ui/AppSettingsActivity.kt deleted file mode 100644 index 214bf38b2367cc9744253eaeaaafac5b28b60efc..0000000000000000000000000000000000000000 --- a/app/src/main/java/at/bitfire/davdroid/ui/AppSettingsActivity.kt +++ /dev/null @@ -1,197 +0,0 @@ -/* - * Copyright © Ricki Hirner (bitfire web engineering). - * All rights reserved. This program and the accompanying materials - * are made available under the terms of the GNU Public License v3.0 - * which accompanies this distribution, and is available at - * http://www.gnu.org/licenses/gpl.html - */ - -package at.bitfire.davdroid.ui - -import android.app.ActivityManager -import android.content.ComponentName -import android.content.Context -import android.content.Intent -import android.content.ServiceConnection -import android.os.Bundle -import android.os.IBinder -import android.os.Process -import android.support.design.widget.Snackbar -import android.support.v7.app.AppCompatActivity -import android.support.v7.preference.EditTextPreference -import android.support.v7.preference.Preference -import android.support.v7.preference.PreferenceFragmentCompat -import android.support.v7.preference.SwitchPreferenceCompat -import at.bitfire.cert4android.CustomCertManager -import at.bitfire.davdroid.App -import at.bitfire.davdroid.BuildConfig -import at.bitfire.davdroid.R -import at.bitfire.davdroid.log.Logger -import at.bitfire.davdroid.settings.ISettings -import at.bitfire.davdroid.settings.ISettingsObserver -import at.bitfire.davdroid.settings.Settings -import java.net.URI -import java.net.URISyntaxException - -class AppSettingsActivity: AppCompatActivity() { - - companion object { - const val EXTRA_SCROLL_TO = "scrollTo" - } - - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - - if (savedInstanceState == null) { - val fragment = SettingsFragment() - fragment.arguments = intent.extras - supportFragmentManager.beginTransaction() - .replace(android.R.id.content, fragment) - .commit() - } - } - - - class SettingsFragment: PreferenceFragmentCompat() { - - val observer = object: ISettingsObserver.Stub() { - override fun onSettingsChanged() { - loadSettings() - } - } - - var settings: ISettings? = null - var settingsSvc: ServiceConnection? = object: ServiceConnection { - override fun onServiceConnected(name: ComponentName, binder: IBinder) { - settings = ISettings.Stub.asInterface(binder) - settings?.registerObserver(observer) - loadSettings() - } - override fun onServiceDisconnected(name: ComponentName) { - settings?.unregisterObserver(observer) - settings = null - } - } - - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - - if (!activity!!.bindService(Intent(activity, Settings::class.java), settingsSvc, Context.BIND_AUTO_CREATE)) - settingsSvc = null - } - - override fun onDestroy() { - super.onDestroy() - settingsSvc?.let { activity!!.unbindService(it) } - } - - override fun onCreatePreferences(bundle: Bundle?, s: String?) { - addPreferencesFromResource(R.xml.settings_app) - - // UI settings - val prefResetHints = findPreference("reset_hints") - prefResetHints.onPreferenceClickListener = Preference.OnPreferenceClickListener { - resetHints() - false - } - - // security settings - val prefDistrustSystemCerts = findPreference(App.DISTRUST_SYSTEM_CERTIFICATES) - prefDistrustSystemCerts.isVisible = BuildConfig.customCerts - prefDistrustSystemCerts.isEnabled = true - - val prefResetCertificates = findPreference("reset_certificates") - prefResetCertificates.isVisible = BuildConfig.customCerts - prefResetCertificates.isEnabled = true - prefResetCertificates.onPreferenceClickListener = Preference.OnPreferenceClickListener { - resetCertificates() - false - } - - arguments?.getString(EXTRA_SCROLL_TO)?.let { scrollToPreference(it) } - } - - private fun loadSettings() { - val settings = requireNotNull(settings) - - // connection settings - val prefOverrideProxy = findPreference(App.OVERRIDE_PROXY) as SwitchPreferenceCompat - prefOverrideProxy.isChecked = settings.getBoolean(App.OVERRIDE_PROXY, false) - prefOverrideProxy.isEnabled = settings.isWritable(App.OVERRIDE_PROXY) - - val prefProxyHost = findPreference(App.OVERRIDE_PROXY_HOST) as EditTextPreference - prefProxyHost.isEnabled = settings.isWritable(App.OVERRIDE_PROXY_HOST) - val proxyHost = settings.getString(App.OVERRIDE_PROXY_HOST, App.OVERRIDE_PROXY_HOST_DEFAULT) - prefProxyHost.text = proxyHost - prefProxyHost.summary = proxyHost - prefProxyHost.onPreferenceChangeListener = Preference.OnPreferenceChangeListener { _, newValue -> - val host = newValue as String - try { - URI(null, host, null, null) - settings.putString(App.OVERRIDE_PROXY_HOST, host) - prefProxyHost.summary = host - true - } catch(e: URISyntaxException) { - Snackbar.make(view!!, e.localizedMessage, Snackbar.LENGTH_LONG).show() - false - } - } - - val prefProxyPort = findPreference(App.OVERRIDE_PROXY_PORT) as EditTextPreference - prefProxyHost.isEnabled = settings.isWritable(App.OVERRIDE_PROXY_PORT) - val proxyPort = settings.getInt(App.OVERRIDE_PROXY_PORT, App.OVERRIDE_PROXY_PORT_DEFAULT) - prefProxyPort.text = proxyPort.toString() - prefProxyPort.summary = proxyPort.toString() - prefProxyPort.onPreferenceChangeListener = Preference.OnPreferenceChangeListener { _, newValue -> - try { - val port = Integer.parseInt(newValue as String) - if (port in 1..65535) { - settings.putInt(App.OVERRIDE_PROXY_PORT, port) - prefProxyPort.text = port.toString() - prefProxyPort.summary = port.toString() - true - } else - false - } catch(e: NumberFormatException) { - false - } - } - - // security settings - val prefDistrustSystemCerts = findPreference(App.DISTRUST_SYSTEM_CERTIFICATES) as SwitchPreferenceCompat - prefDistrustSystemCerts.isChecked = settings.getBoolean(App.DISTRUST_SYSTEM_CERTIFICATES, false) - - // debugging settings - val prefLogToExternalStorage = findPreference(Logger.LOG_TO_EXTERNAL_STORAGE) as SwitchPreferenceCompat - prefLogToExternalStorage.onPreferenceChangeListener = Preference.OnPreferenceChangeListener { _, newValue -> - val context = activity!! - Logger.initialize(context) - - // kill a potential :sync process, so that the new logger settings will be used - val am = context.getSystemService(Context.ACTIVITY_SERVICE) as ActivityManager - am.runningAppProcesses.forEach { - if (it.pid != Process.myPid()) { - Logger.log.info("Killing ${it.processName} process, pid = ${it.pid}") - Process.killProcess(it.pid) - } - } - true - } - } - - private fun resetHints() { - settings?.remove(StartupDialogFragment.HINT_AUTOSTART_PERMISSIONS) - settings?.remove(StartupDialogFragment.HINT_BATTERY_OPTIMIZATIONS) - settings?.remove(StartupDialogFragment.HINT_GOOGLE_PLAY_ACCOUNTS_REMOVED) - settings?.remove(StartupDialogFragment.HINT_OPENTASKS_NOT_INSTALLED) - Snackbar.make(view!!, R.string.app_settings_reset_hints_success, Snackbar.LENGTH_LONG).show() - } - - private fun resetCertificates() { - if (CustomCertManager.resetCertificates(activity!!)) - Snackbar.make(view!!, getString(R.string.app_settings_reset_certificates_success), Snackbar.LENGTH_LONG).show() - } - - } - -} diff --git a/app/src/main/java/at/bitfire/davdroid/ui/CollectionInfoFragment.kt b/app/src/main/java/at/bitfire/davdroid/ui/CollectionInfoFragment.kt deleted file mode 100644 index e241e95f6538ac12a6cdd21a99744c3df4e1447d..0000000000000000000000000000000000000000 --- a/app/src/main/java/at/bitfire/davdroid/ui/CollectionInfoFragment.kt +++ /dev/null @@ -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 - -import android.annotation.SuppressLint -import android.app.Dialog -import android.os.Bundle -import android.support.v4.app.DialogFragment -import android.support.v7.app.AlertDialog -import at.bitfire.davdroid.R -import at.bitfire.davdroid.model.CollectionInfo -import kotlinx.android.synthetic.main.collection_properties.view.* - -class CollectionInfoFragment : DialogFragment() { - - companion object { - - private const val ARGS_INFO = "info" - - fun newInstance(info: CollectionInfo): CollectionInfoFragment { - val frag = CollectionInfoFragment() - val args = Bundle(1) - args.putParcelable(ARGS_INFO, info) - frag.arguments = args - return frag - } - - } - - @SuppressLint("InflateParams") - override fun onCreateDialog(savedInstanceState: Bundle?): Dialog { - val info = arguments!![ARGS_INFO] as CollectionInfo - - val view = requireActivity().layoutInflater.inflate(R.layout.collection_properties, null) - view.url.text = info.url.toString() - - return AlertDialog.Builder(requireActivity()) - .setTitle(info.displayName) - .setView(view) - .create() - } - -} \ No newline at end of file diff --git a/app/src/main/java/at/bitfire/davdroid/ui/CreateAddressBookActivity.kt b/app/src/main/java/at/bitfire/davdroid/ui/CreateAddressBookActivity.kt deleted file mode 100644 index 422219594e5a0efbbddb34a2806782f735dcaf0e..0000000000000000000000000000000000000000 --- a/app/src/main/java/at/bitfire/davdroid/ui/CreateAddressBookActivity.kt +++ /dev/null @@ -1,132 +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.accounts.Account -import android.app.LoaderManager -import android.content.AsyncTaskLoader -import android.content.Context -import android.content.Intent -import android.content.Loader -import android.os.Bundle -import android.support.v4.app.NavUtils -import android.support.v7.app.AppCompatActivity -import android.view.Menu -import android.view.MenuItem -import android.widget.ArrayAdapter -import at.bitfire.davdroid.R -import at.bitfire.davdroid.model.CollectionInfo -import at.bitfire.davdroid.model.ServiceDB -import kotlinx.android.synthetic.main.activity_create_address_book.* -import okhttp3.HttpUrl -import org.apache.commons.lang3.StringUtils -import java.util.* - -class CreateAddressBookActivity: AppCompatActivity(), LoaderManager.LoaderCallbacks { - - companion object { - const val EXTRA_ACCOUNT = "account" - } - - private lateinit var account: Account - - - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - - account = intent.getParcelableExtra(EXTRA_ACCOUNT) - - supportActionBar?.setDisplayHomeAsUpEnabled(true) - setContentView(R.layout.activity_create_address_book) - - loaderManager.initLoader(0, intent.extras, this) - } - - override fun onCreateOptionsMenu(menu: Menu): Boolean { - menuInflater.inflate(R.menu.activity_create_collection, menu) - return true - } - - override fun onOptionsItemSelected(item: MenuItem) = - if (item.itemId == android.R.id.home) { - val intent = Intent(this, AccountActivity::class.java) - intent.putExtra(AccountActivity.EXTRA_ACCOUNT, account) - NavUtils.navigateUpTo(this, intent) - true - } else - false - - fun onCreateCollection(item: MenuItem) { - val homeSet = home_sets.selectedItem as String - - var ok = true - HttpUrl.parse(homeSet)?.let { - val info = CollectionInfo(it.resolve(UUID.randomUUID().toString() + "/")!!) - info.displayName = display_name.text.toString() - if (info.displayName.isNullOrBlank()) { - display_name.error = getString(R.string.create_collection_display_name_required) - ok = false - } - - info.description = StringUtils.trimToNull(description.text.toString()) - - if (ok) { - info.type = CollectionInfo.Type.ADDRESS_BOOK - CreateCollectionFragment.newInstance(account, info).show(supportFragmentManager, null) - } - } - } - - - override fun onCreateLoader(id: Int, args: Bundle?) = AccountInfoLoader(this, account) - - override fun onLoadFinished(loader: Loader, info: AccountInfo?) { - info?.let { - home_sets.adapter = ArrayAdapter(this, android.R.layout.simple_spinner_dropdown_item, it.homeSets) - } - } - - override fun onLoaderReset(loader: Loader) { - } - - class AccountInfo { - val homeSets = LinkedList() - } - - class AccountInfoLoader( - context: Context, - val account: Account - ): AsyncTaskLoader(context) { - - override fun onStartLoading() = forceLoad() - - override fun loadInBackground(): AccountInfo? { - val info = AccountInfo() - ServiceDB.OpenHelper(context).use { dbHelper -> - // find DAV service and home sets - val db = dbHelper.readableDatabase - db.query(ServiceDB.Services._TABLE, arrayOf(ServiceDB.Services.ID), - "${ServiceDB.Services.ACCOUNT_NAME}=? AND ${ServiceDB.Services.SERVICE}=?", - arrayOf(account.name, ServiceDB.Services.SERVICE_CARDDAV), null, null, null).use { cursor -> - if (!cursor.moveToNext()) - return null - val strServiceID = cursor.getString(0) - - db.query(ServiceDB.HomeSets._TABLE, arrayOf(ServiceDB.HomeSets.URL), - "${ServiceDB.HomeSets.SERVICE_ID}=?", arrayOf(strServiceID), null, null, null).use { c -> - while (c.moveToNext()) - info.homeSets += c.getString(0) - } - } - } - return info - } - } - -} diff --git a/app/src/main/java/at/bitfire/davdroid/ui/CreateCalendarActivity.kt b/app/src/main/java/at/bitfire/davdroid/ui/CreateCalendarActivity.kt deleted file mode 100644 index c1c25a093c35fa2874df2b376912af8d612fb170..0000000000000000000000000000000000000000 --- a/app/src/main/java/at/bitfire/davdroid/ui/CreateCalendarActivity.kt +++ /dev/null @@ -1,171 +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.accounts.Account -import android.app.LoaderManager -import android.content.AsyncTaskLoader -import android.content.Context -import android.content.Intent -import android.content.Loader -import android.graphics.drawable.ColorDrawable -import android.os.Bundle -import android.support.v4.app.NavUtils -import android.support.v7.app.AppCompatActivity -import android.view.Menu -import android.view.MenuItem -import android.widget.ArrayAdapter -import at.bitfire.davdroid.R -import at.bitfire.davdroid.model.CollectionInfo -import at.bitfire.davdroid.model.ServiceDB -import at.bitfire.ical4android.DateUtils -import kotlinx.android.synthetic.main.activity_create_calendar.* -import net.fortuna.ical4j.model.Calendar -import okhttp3.HttpUrl -import org.apache.commons.lang3.StringUtils -import yuku.ambilwarna.AmbilWarnaDialog -import java.util.* - -class CreateCalendarActivity: AppCompatActivity(), LoaderManager.LoaderCallbacks { - - companion object { - const val EXTRA_ACCOUNT = "account" - } - - private lateinit var account: Account - - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - - account = intent.extras.getParcelable(EXTRA_ACCOUNT) - - supportActionBar?.setDisplayHomeAsUpEnabled(true) - - setContentView(R.layout.activity_create_calendar) - color.setOnClickListener { _ -> - AmbilWarnaDialog(this, (color.background as ColorDrawable).color, true, object: AmbilWarnaDialog.OnAmbilWarnaListener { - override fun onCancel(dialog: AmbilWarnaDialog) {} - override fun onOk(dialog: AmbilWarnaDialog, rgb: Int) = - color.setBackgroundColor(rgb) - }).show() - } - - loaderManager.initLoader(0, null, this) - } - - override fun onCreateOptionsMenu(menu: Menu): Boolean { - menuInflater.inflate(R.menu.activity_create_collection, menu) - return true - } - - override fun onOptionsItemSelected(item: MenuItem) = - if (item.itemId == android.R.id.home) { - val intent = Intent(this, AccountActivity::class.java) - intent.putExtra(AccountActivity.EXTRA_ACCOUNT, account) - NavUtils.navigateUpTo(this, intent) - true - } else - false - - fun onCreateCollection(item: MenuItem) { - val homeSet = home_sets.selectedItem as String - - var ok = true - HttpUrl.parse(homeSet)?.let { - val info = CollectionInfo(it.resolve(UUID.randomUUID().toString() + "/")!!) - info.displayName = display_name.text.toString() - if (info.displayName.isNullOrBlank()) { - display_name.error = getString(R.string.create_collection_display_name_required) - ok = false - } - - info.description = StringUtils.trimToNull(description.text.toString()) - info.color = (color.background as ColorDrawable).color - - DateUtils.tzRegistry.getTimeZone(time_zone.selectedItem as String)?.let { tz -> - val cal = Calendar() - cal.components += tz.vTimeZone - info.timeZone = cal.toString() - } - - when (type.checkedRadioButtonId) { - R.id.type_events -> - info.supportsVEVENT = true - R.id.type_tasks -> - info.supportsVTODO = true - R.id.type_events_and_tasks -> { - info.supportsVEVENT = true - info.supportsVTODO = true - } - } - - if (ok) { - info.type = CollectionInfo.Type.CALENDAR - CreateCollectionFragment.newInstance(account, info).show(supportFragmentManager, null) - } - } - } - - - override fun onCreateLoader(id: Int, args: Bundle?) = AccountInfoLoader(this, account) - - override fun onLoadFinished(loader: Loader, info: AccountInfo?) { - val timeZones = TimeZone.getAvailableIDs() - time_zone.adapter = ArrayAdapter(this, android.R.layout.simple_spinner_dropdown_item, timeZones) - - // select system time zone - val defaultTimeZone = TimeZone.getDefault().id - for (i in 0 until timeZones.size) - if (timeZones[i] == defaultTimeZone) { - time_zone.setSelection(i) - break - } - - info?.let { - home_sets.adapter = ArrayAdapter(this, android.R.layout.simple_spinner_dropdown_item, info.homeSets) - } - } - - override fun onLoaderReset(loader: Loader) {} - - - class AccountInfo { - val homeSets = LinkedList() - } - - class AccountInfoLoader( - context: Context, - val account: Account - ): AsyncTaskLoader(context) { - - override fun onStartLoading() = forceLoad() - - override fun loadInBackground(): AccountInfo? { - val info = AccountInfo() - ServiceDB.OpenHelper(context).use { dbHelper -> - val db = dbHelper.readableDatabase - db.query(ServiceDB.Services._TABLE, arrayOf(ServiceDB.Services.ID), - "${ServiceDB.Services.ACCOUNT_NAME}=? AND ${ServiceDB.Services.SERVICE}=?", - arrayOf(account.name, ServiceDB.Services.SERVICE_CALDAV), null, null, null).use { cursor -> - if (!cursor.moveToNext()) - return null - val strServiceID = cursor.getString(0) - - db.query(ServiceDB.HomeSets._TABLE, arrayOf(ServiceDB.HomeSets.URL), - "${ServiceDB.HomeSets.SERVICE_ID}=?", arrayOf(strServiceID), null, null, null).use { c -> - while (c.moveToNext()) - info.homeSets += c.getString(0) - } - } - } - return info - } - } - -} diff --git a/app/src/main/java/at/bitfire/davdroid/ui/CreateCollectionFragment.kt b/app/src/main/java/at/bitfire/davdroid/ui/CreateCollectionFragment.kt deleted file mode 100644 index fde9dd610d845cd6777f072ad8037d697bf00455..0000000000000000000000000000000000000000 --- a/app/src/main/java/at/bitfire/davdroid/ui/CreateCollectionFragment.kt +++ /dev/null @@ -1,229 +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.accounts.Account -import android.app.Dialog -import android.app.ProgressDialog -import android.content.Context -import android.os.Bundle -import android.support.v4.app.DialogFragment -import android.support.v4.app.LoaderManager -import android.support.v4.content.AsyncTaskLoader -import android.support.v4.content.Loader -import at.bitfire.dav4android.DavResource -import at.bitfire.dav4android.XmlUtils -import at.bitfire.davdroid.AccountSettings -import at.bitfire.davdroid.DavUtils -import at.bitfire.davdroid.HttpClient -import at.bitfire.davdroid.R -import at.bitfire.davdroid.log.Logger -import at.bitfire.davdroid.model.CollectionInfo -import at.bitfire.davdroid.model.ServiceDB -import at.bitfire.davdroid.settings.Settings -import java.io.IOException -import java.io.StringWriter -import java.util.logging.Level - -@Suppress("DEPRECATION") -class CreateCollectionFragment: DialogFragment(), LoaderManager.LoaderCallbacks { - - companion object { - - 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.putParcelable(ARG_COLLECTION_INFO, info) - frag.arguments = args - return frag - } - - } - - private lateinit var account: Account - private lateinit var info: CollectionInfo - - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - - val args = requireNotNull(arguments) - account = args.getParcelable(ARG_ACCOUNT) - info = args.getParcelable(ARG_COLLECTION_INFO) - - loaderManager.initLoader(0, null, this) - } - - override fun onCreateDialog(savedInstanceState: Bundle?): Dialog { - val progress = ProgressDialog(context) - progress.setTitle(R.string.create_collection_creating) - progress.setMessage(getString(R.string.please_wait)) - progress.isIndeterminate = true - progress.setCanceledOnTouchOutside(false) - isCancelable = false - return progress - } - - - override fun onCreateLoader(id: Int, args: Bundle?) = CreateCollectionLoader(requireActivity(), account, info) - - override fun onLoadFinished(loader: Loader, exception: Exception?) { - dismiss() - - activity?.let { parent -> - if (exception != null) - requireFragmentManager().beginTransaction() - .add(ExceptionInfoFragment.newInstance(exception, account), null) - .commit() - else - parent.finish() - } - - } - - override fun onLoaderReset(loader: Loader) {} - - - class CreateCollectionLoader( - context: Context, - val account: Account, - val info: CollectionInfo - ): AsyncTaskLoader(context) { - - override fun onStartLoading() = forceLoad() - - override fun loadInBackground(): Exception? { - val writer = StringWriter() - try { - val serializer = XmlUtils.newSerializer() - with(serializer) { - setOutput(writer) - startDocument("UTF-8", null) - setPrefix("", XmlUtils.NS_WEBDAV) - setPrefix("CAL", XmlUtils.NS_CALDAV) - setPrefix("CARD", XmlUtils.NS_CARDDAV) - - startTag(XmlUtils.NS_WEBDAV, "mkcol") - startTag(XmlUtils.NS_WEBDAV, "set") - startTag(XmlUtils.NS_WEBDAV, "prop") - startTag(XmlUtils.NS_WEBDAV, "resourcetype") - startTag(XmlUtils.NS_WEBDAV, "collection") - endTag(XmlUtils.NS_WEBDAV, "collection") - if (info.type == CollectionInfo.Type.ADDRESS_BOOK) { - startTag(XmlUtils.NS_CARDDAV, "addressbook") - endTag(XmlUtils.NS_CARDDAV, "addressbook") - } else if (info.type == CollectionInfo.Type.CALENDAR) { - startTag(XmlUtils.NS_CALDAV, "calendar") - endTag(XmlUtils.NS_CALDAV, "calendar") - } - endTag(XmlUtils.NS_WEBDAV, "resourcetype") - info.displayName?.let { - startTag(XmlUtils.NS_WEBDAV, "displayname") - text(it) - endTag(XmlUtils.NS_WEBDAV, "displayname") - } - - // addressbook-specific properties - if (info.type == CollectionInfo.Type.ADDRESS_BOOK) { - info.description?.let { - startTag(XmlUtils.NS_CARDDAV, "addressbook-description") - text(it) - endTag(XmlUtils.NS_CARDDAV, "addressbook-description") - } - } - - // calendar-specific properties - if (info.type == CollectionInfo.Type.CALENDAR) { - info.description?.let { - startTag(XmlUtils.NS_CALDAV, "calendar-description") - text(it) - endTag(XmlUtils.NS_CALDAV, "calendar-description") - } - - info.color?.let { - startTag(XmlUtils.NS_APPLE_ICAL, "calendar-color") - text(DavUtils.ARGBtoCalDAVColor(it)) - endTag(XmlUtils.NS_APPLE_ICAL, "calendar-color") - } - - info.timeZone?.let { - startTag(XmlUtils.NS_CALDAV, "calendar-timezone") - cdsect(it) - endTag(XmlUtils.NS_CALDAV, "calendar-timezone") - } - - startTag(XmlUtils.NS_CALDAV, "supported-calendar-component-set") - if (info.supportsVEVENT) { - startTag(XmlUtils.NS_CALDAV, "comp") - attribute(null, "name", "VEVENT") - endTag(XmlUtils.NS_CALDAV, "comp") - } - if (info.supportsVTODO) { - startTag(XmlUtils.NS_CALDAV, "comp") - attribute(null, "name", "VTODO") - endTag(XmlUtils.NS_CALDAV, "comp") - } - endTag(XmlUtils.NS_CALDAV, "supported-calendar-component-set") - } - - endTag(XmlUtils.NS_WEBDAV, "prop") - endTag(XmlUtils.NS_WEBDAV, "set") - endTag(XmlUtils.NS_WEBDAV, "mkcol") - endDocument() - } - } catch(e: IOException) { - Logger.log.log(Level.SEVERE, "Couldn't assemble Extended MKCOL request", e) - } - - Settings.getInstance(context)?.use { settings -> - HttpClient.Builder(context, settings, AccountSettings(context, settings, account)) - .setForeground(true) - .build().use { httpClient -> - try { - val collection = DavResource(httpClient.okHttpClient, info.url) - - // create collection on remote server - collection.mkCol(writer.toString()) {} - - // now insert collection into database: - ServiceDB.OpenHelper(context).use { dbHelper -> - val db = dbHelper.writableDatabase - - // 1. find service ID - val serviceType = when (info.type) { - CollectionInfo.Type.ADDRESS_BOOK -> ServiceDB.Services.SERVICE_CARDDAV - CollectionInfo.Type.CALENDAR -> ServiceDB.Services.SERVICE_CALDAV - else -> throw IllegalArgumentException("Collection must be an address book or calendar") - } - db.query(ServiceDB.Services._TABLE, arrayOf(ServiceDB.Services.ID), - "${ServiceDB.Services.ACCOUNT_NAME}=? AND ${ServiceDB.Services.SERVICE}=?", - arrayOf(account.name, serviceType), null, null, null).use { c -> - - assert(c.moveToNext()) - val serviceID = c.getLong(0) - - // 2. add collection to service - val values = info.toDB() - values.put(ServiceDB.Collections.SERVICE_ID, serviceID) - db.insert(ServiceDB.Collections._TABLE, null, values) - } - } - } catch(e: Exception) { - return e - } - } - } - return null - } - } - -} diff --git a/app/src/main/java/at/bitfire/davdroid/ui/DebugInfoActivity.kt b/app/src/main/java/at/bitfire/davdroid/ui/DebugInfoActivity.kt deleted file mode 100644 index ce9d2219c3c47b6543dcefdbb75bdd8d23e54230..0000000000000000000000000000000000000000 --- a/app/src/main/java/at/bitfire/davdroid/ui/DebugInfoActivity.kt +++ /dev/null @@ -1,306 +0,0 @@ -/* - * Copyright © Ricki Hirner (bitfire web engineering). - * All rights reserved. This program and the accompanying materials - * are made available under the terms of the GNU Public License v3.0 - * which accompanies this distribution, and is available at - * http://www.gnu.org/licenses/gpl.html - */ - -package at.bitfire.davdroid.ui - -import android.Manifest -import android.accounts.Account -import android.accounts.AccountManager -import android.content.ContentResolver -import android.content.Context -import android.content.Intent -import android.content.pm.PackageManager -import android.net.ConnectivityManager -import android.os.Build -import android.os.Bundle -import android.os.PowerManager -import android.provider.CalendarContract -import android.provider.ContactsContract -import android.support.v4.app.LoaderManager -import android.support.v4.content.AsyncTaskLoader -import android.support.v4.content.ContextCompat -import android.support.v4.content.FileProvider -import android.support.v4.content.Loader -import android.support.v7.app.AppCompatActivity -import android.util.Log -import android.view.Menu -import android.view.MenuItem -import at.bitfire.dav4android.exception.HttpException -import at.bitfire.davdroid.AccountSettings -import at.bitfire.davdroid.BuildConfig -import at.bitfire.davdroid.InvalidAccountException -import at.bitfire.davdroid.R -import at.bitfire.davdroid.log.Logger -import at.bitfire.davdroid.model.ServiceDB -import at.bitfire.davdroid.resource.LocalAddressBook -import at.bitfire.davdroid.settings.Settings -import at.bitfire.ical4android.TaskProvider -import kotlinx.android.synthetic.main.activity_debug_info.* -import java.io.File -import java.io.FileWriter -import java.io.IOException -import java.text.SimpleDateFormat -import java.util.* -import java.util.logging.Level - -class DebugInfoActivity: AppCompatActivity(), LoaderManager.LoaderCallbacks { - - companion object { - const val KEY_THROWABLE = "throwable" - const val KEY_LOGS = "logs" - const val KEY_ACCOUNT = "account" - const val KEY_AUTHORITY = "authority" - const val KEY_PHASE = "phase" - const val KEY_LOCAL_RESOURCE = "localResource" - const val KEY_REMOTE_RESOURCE = "remoteResource" - } - - private var report: String? = null - - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - setContentView(R.layout.activity_debug_info) - - supportLoaderManager.initLoader(0, intent.extras, this) - } - - override fun onCreateOptionsMenu(menu: Menu): Boolean { - menuInflater.inflate(R.menu.activity_debug_info, menu) - return true - } - - - fun onShare(item: MenuItem) { - report?.let { - val sendIntent = Intent() - sendIntent.action = Intent.ACTION_SEND - sendIntent.type = "text/plain" - sendIntent.putExtra(Intent.EXTRA_SUBJECT, "${getString(R.string.app_name)} ${BuildConfig.VERSION_NAME} debug info") - - try { - val debugInfoDir = File(cacheDir, "debug-info") - debugInfoDir.mkdir() - - val reportFile = File(debugInfoDir, "debug.txt") - Logger.log.fine("Writing debug info to ${reportFile.absolutePath}") - val writer = FileWriter(reportFile) - writer.write(it) - writer.close() - - sendIntent.putExtra(Intent.EXTRA_STREAM, FileProvider.getUriForFile(this, getString(R.string.authority_log_provider), reportFile)) - sendIntent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) - - } catch(e: IOException) { - // creating an attachment failed, so send it inline - sendIntent.putExtra(Intent.EXTRA_TEXT, it) - } - - startActivity(Intent.createChooser(sendIntent, null)) - } - } - - - override fun onCreateLoader(id: Int, args: Bundle?) = - ReportLoader(this, args) - - override fun onLoadFinished(loader: Loader, data: String?) { - data?.let { - report = it - - text_report.text = it - text_report.setTextIsSelectable(true) - } - } - - override fun onLoaderReset(loader: Loader) {} - - - class ReportLoader( - context: Context, - val extras: Bundle? - ): AsyncTaskLoader(context) { - - var result: String? = null - - override fun onStartLoading() { - if (result != null) - deliverResult(result) - else - forceLoad() - } - - override fun loadInBackground(): String { - Logger.log.info("Building debug info") - val report = StringBuilder("--- BEGIN DEBUG INFO ---\n") - - // begin with most specific information - extras?.getInt(KEY_PHASE, -1).takeIf { it != -1 }?.let { - report.append("SYNCHRONIZATION INFO\nSynchronization phase: $it\n") - } - extras?.getParcelable(KEY_ACCOUNT)?.let { - report.append("Account name: ${it.name}\n") - } - extras?.getString(KEY_AUTHORITY)?.let { - report.append("Authority: $it\n") - } - - // exception details - val throwable = extras?.getSerializable(KEY_THROWABLE) as Throwable? - if (throwable is HttpException) { - throwable.request?.let { - report.append("\nHTTP REQUEST:\n$it\n") - 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") - } - } - - extras?.getString(KEY_LOCAL_RESOURCE)?.let { - report.append("\nLOCAL RESOURCE:\n$it\n") - } - extras?.getString(KEY_REMOTE_RESOURCE)?.let { - report.append("\nREMOTE RESOURCE:\n$it\n") - } - - throwable?.let { - report.append("\nEXCEPTION:\n${Log.getStackTraceString(throwable)}") - } - - // logs (for instance, from failed resource detection) - extras?.getString(KEY_LOGS)?.let { - report.append("\nLOGS:\n$it\n") - } - - // software information - try { - val pm = context.packageManager - val installedFrom = pm.getInstallerPackageName(BuildConfig.APPLICATION_ID) ?: "APK (directly)" - var workaroundInstalled = false - try { - workaroundInstalled = pm.getPackageInfo("${BuildConfig.APPLICATION_ID}.jbworkaround", 0) != null - } catch(e: PackageManager.NameNotFoundException) { - } - val formatter = SimpleDateFormat.getDateInstance() - report.append("\nSOFTWARE INFORMATION\n" + - "Package: ${BuildConfig.APPLICATION_ID}\n" + - "Version: ${BuildConfig.VERSION_NAME} (${BuildConfig.VERSION_CODE}) from ${formatter.format(Date(BuildConfig.buildTime))}\n") - .append("Installed from: $installedFrom\n") - .append("JB Workaround installed: ${if (workaroundInstalled) "yes" else "no"}\n\n") - } catch(e: Exception) { - Logger.log.log(Level.SEVERE, "Couldn't get software information", e) - } - - // connectivity - report.append("CONNECTIVITY (at the moment)\n") - val connectivityManager = context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager - connectivityManager.activeNetworkInfo?.let { networkInfo -> - val type = when (networkInfo.type) { - ConnectivityManager.TYPE_WIFI -> "WiFi" - ConnectivityManager.TYPE_MOBILE -> "mobile" - else -> "type: ${networkInfo.type}" - } - report.append("Active connection: $type, ${networkInfo.detailedState}\n") - } - if (Build.VERSION.SDK_INT >= 23) - connectivityManager.defaultProxy?.let { proxy -> - report.append("System default proxy: ${proxy.host}:${proxy.port}") - } - report.append("\n") - - report.append("CONFIGURATION\n") - // power saving - val powerManager = context.getSystemService(Context.POWER_SERVICE) as PowerManager - if (Build.VERSION.SDK_INT >= 23) - report.append("Power saving disabled: ") - .append(if (powerManager.isIgnoringBatteryOptimizations(BuildConfig.APPLICATION_ID)) "yes" else "no") - .append("\n") - // permissions - for (permission in arrayOf(Manifest.permission.READ_CONTACTS, Manifest.permission.WRITE_CONTACTS, - Manifest.permission.READ_CALENDAR, Manifest.permission.WRITE_CALENDAR, - TaskProvider.PERMISSION_READ_TASKS, TaskProvider.PERMISSION_WRITE_TASKS)) - report.append(permission).append(" permission: ") - .append(if (ContextCompat.checkSelfPermission(context, permission) == PackageManager.PERMISSION_GRANTED) "granted" else "denied") - .append("\n") - // system-wide sync settings - report.append("System-wide synchronization: ") - .append(if (ContentResolver.getMasterSyncAutomatically()) "automatically" else "manually") - .append("\n") - // main accounts - val accountManager = AccountManager.get(context) - Settings.getInstance(context)?.let { settings -> - for (acct in accountManager.getAccountsByType(context.getString(R.string.account_type))) { - try { - val accountSettings = AccountSettings(context, settings, acct) - report.append("Account: ${acct.name}\n" + - " Address book sync. interval: ${syncStatus(accountSettings, context.getString(R.string.address_books_authority))}\n" + - " Calendar sync. interval: ${syncStatus(accountSettings, CalendarContract.AUTHORITY)}\n" + - " OpenTasks sync. interval: ${syncStatus(accountSettings, TaskProvider.ProviderName.OpenTasks.authority)}\n" + - " WiFi only: ").append(accountSettings.getSyncWifiOnly()) - accountSettings.getSyncWifiOnlySSIDs()?.let { - report.append(", SSIDs: ${accountSettings.getSyncWifiOnlySSIDs()}") - } - report.append("\n [CardDAV] Contact group method: ${accountSettings.getGroupMethod()}") - .append("\n [CalDAV] Time range (past days): ${accountSettings.getTimeRangePastDays()}") - .append("\n Manage calendar colors: ${accountSettings.getManageCalendarColors()}") - .append("\n") - } catch (e: InvalidAccountException) { - report.append("$acct is invalid (unsupported settings version) or does not exist\n") - } - } - } - // address book accounts - for (acct in accountManager.getAccountsByType(context.getString(R.string.account_type_address_book))) - try { - val addressBook = LocalAddressBook(context, acct, null) - report.append("Address book account: ${acct.name}\n" + - " Main account: ${addressBook.mainAccount}\n" + - " URL: ${addressBook.url}\n" + - " Sync automatically: ").append(ContentResolver.getSyncAutomatically(acct, ContactsContract.AUTHORITY)).append("\n") - } catch(e: Exception) { - report.append("$acct is invalid: ${e.message}\n") - } - report.append("\n") - - ServiceDB.OpenHelper(context).use { dbHelper -> - report.append("SQLITE DUMP\n") - dbHelper.dump(report) - report.append("\n") - } - - try { - report.append( - "SYSTEM INFORMATION\n" + - "Android version: ${Build.VERSION.RELEASE} (${Build.DISPLAY})\n" + - "Device: ${Build.MANUFACTURER} ${Build.MODEL} (${Build.DEVICE})\n\n" - ) - } catch(e: Exception) { - Logger.log.log(Level.SEVERE, "Couldn't get system details", e) - } - - report.append("--- END DEBUG INFO ---\n") - - report.toString().let { - result = it - return it - } - } - - private fun syncStatus(settings: AccountSettings, authority: String): String { - val interval = settings.getSyncInterval(authority) - return if (interval != null) { - if (interval == AccountSettings.SYNC_INTERVAL_MANUALLY) "manually" else "${interval/60} min" - } else - "—" - } - } - -} diff --git a/app/src/main/java/at/bitfire/davdroid/ui/DefaultAccountsDrawerHandler.kt b/app/src/main/java/at/bitfire/davdroid/ui/DefaultAccountsDrawerHandler.kt deleted file mode 100644 index 7d6b1e618c73e57c94b97ce5bf3d8fc7fd0238d3..0000000000000000000000000000000000000000 --- a/app/src/main/java/at/bitfire/davdroid/ui/DefaultAccountsDrawerHandler.kt +++ /dev/null @@ -1,64 +0,0 @@ -/* - * Copyright © Ricki Hirner (bitfire web engineering). - * All rights reserved. This program and the accompanying materials - * are made available under the terms of the GNU Public License v3.0 - * which accompanies this distribution, and is available at - * http://www.gnu.org/licenses/gpl.html - */ - -package at.bitfire.davdroid.ui - -import android.app.Activity -import android.content.Intent -import android.net.Uri -import android.view.Menu -import android.view.MenuItem -import at.bitfire.davdroid.App -import at.bitfire.davdroid.BuildConfig -import at.bitfire.davdroid.R -import at.bitfire.davdroid.settings.ISettings - -class DefaultAccountsDrawerHandler: IAccountsDrawerHandler { - - companion object { - private const val BETA_FEEDBACK_URI = "mailto:support@davdroid.com?subject=${BuildConfig.APPLICATION_ID}/${BuildConfig.VERSION_NAME} feedback (${BuildConfig.VERSION_CODE})" - } - - - override fun onSettingsChanged(settings: ISettings?, menu: Menu) { - if (BuildConfig.VERSION_NAME.contains("-beta") || BuildConfig.VERSION_NAME.contains("-rc")) - menu.findItem(R.id.nav_beta_feedback).isVisible = true - } - - override fun onNavigationItemSelected(activity: Activity, item: MenuItem): Boolean { - when (item.itemId) { - R.id.nav_about -> - activity.startActivity(Intent(activity, AboutActivity::class.java)) - R.id.nav_app_settings -> - activity.startActivity(Intent(activity, AppSettingsActivity::class.java)) - R.id.nav_beta_feedback -> - UiUtils.launchUri(activity, Uri.parse(BETA_FEEDBACK_URI), Intent.ACTION_SENDTO) - R.id.nav_twitter -> - UiUtils.launchUri(activity, Uri.parse("https://twitter.com/davdroidapp")) - R.id.nav_website -> - UiUtils.launchUri(activity, App.homepageUrl(activity)) - R.id.nav_manual -> - UiUtils.launchUri(activity, App.homepageUrl(activity) - .buildUpon().appendEncodedPath("manual/").build()) - R.id.nav_faq -> - UiUtils.launchUri(activity, App.homepageUrl(activity) - .buildUpon().appendEncodedPath("faq/").build()) - R.id.nav_forums -> - UiUtils.launchUri(activity, App.homepageUrl(activity) - .buildUpon().appendEncodedPath("forums/").build()) - R.id.nav_donate -> - UiUtils.launchUri(activity, App.homepageUrl(activity) - .buildUpon().appendEncodedPath("donate/").build()) - else -> - return false - } - - return true - } - -} diff --git a/app/src/main/java/at/bitfire/davdroid/ui/DeleteCollectionFragment.kt b/app/src/main/java/at/bitfire/davdroid/ui/DeleteCollectionFragment.kt deleted file mode 100644 index 87ba6fa82ee0452bc5c82b855388b8208fb27841..0000000000000000000000000000000000000000 --- a/app/src/main/java/at/bitfire/davdroid/ui/DeleteCollectionFragment.kt +++ /dev/null @@ -1,148 +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.accounts.Account -import android.app.Dialog -import android.app.ProgressDialog -import android.content.Context -import android.os.Bundle -import android.support.v4.app.DialogFragment -import android.support.v4.app.LoaderManager -import android.support.v4.content.AsyncTaskLoader -import android.support.v4.content.Loader -import android.support.v7.app.AlertDialog -import at.bitfire.dav4android.DavResource -import at.bitfire.davdroid.AccountSettings -import at.bitfire.davdroid.HttpClient -import at.bitfire.davdroid.R -import at.bitfire.davdroid.model.CollectionInfo -import at.bitfire.davdroid.model.ServiceDB -import at.bitfire.davdroid.settings.Settings - -@Suppress("DEPRECATION") -class DeleteCollectionFragment: DialogFragment(), LoaderManager.LoaderCallbacks { - - companion object { - const val ARG_ACCOUNT = "account" - const val ARG_COLLECTION_INFO = "collectionInfo" - } - - private lateinit var account: Account - private lateinit var collectionInfo: CollectionInfo - - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - - account = arguments!!.getParcelable(ARG_ACCOUNT) - collectionInfo = arguments!!.getParcelable(ARG_COLLECTION_INFO) - - loaderManager.initLoader(0, null, this) - } - - override fun onCreateDialog(savedInstanceState: Bundle?): Dialog { - val progress = ProgressDialog(context) - progress.setTitle(R.string.delete_collection_deleting_collection) - progress.setMessage(getString(R.string.please_wait)) - progress.isIndeterminate = true - progress.setCanceledOnTouchOutside(false) - isCancelable = false - return progress - } - - - override fun onCreateLoader(id: Int, args: Bundle?) = - DeleteCollectionLoader(activity!!, account, collectionInfo) - - override fun onLoadFinished(loader: Loader, exception: Exception?) { - dismiss() - - if (exception != null) - requireFragmentManager().beginTransaction() - .add(ExceptionInfoFragment.newInstance(exception, account), null) - .commit() - else - (activity as? AccountActivity)?.reload() - } - - override fun onLoaderReset(loader: Loader) {} - - - class DeleteCollectionLoader( - context: Context, - val account: Account, - val collectionInfo: CollectionInfo - ): AsyncTaskLoader(context) { - - override fun onStartLoading() = forceLoad() - - override fun loadInBackground(): Exception? { - Settings.getInstance(context)?.use { settings -> - HttpClient.Builder(context, settings, AccountSettings(context, settings, account)) - .setForeground(true) - .build().use { httpClient -> - try { - val collection = DavResource(httpClient.okHttpClient, collectionInfo.url) - - // delete collection from server - collection.delete(null) {} - - // delete collection locally - ServiceDB.OpenHelper(context).use { dbHelper -> - val db = dbHelper.writableDatabase - db.delete(ServiceDB.Collections._TABLE, "${ServiceDB.Collections.ID}=?", arrayOf(collectionInfo.id.toString())) - } - } catch(e: Exception) { - return e - } - } - } - return null - } - } - - - class ConfirmDeleteCollectionFragment: DialogFragment() { - - companion object { - - fun newInstance(account: Account, collectionInfo: CollectionInfo): DialogFragment { - val frag = ConfirmDeleteCollectionFragment() - val args = Bundle(2) - args.putParcelable(ARG_ACCOUNT, account) - args.putParcelable(ARG_COLLECTION_INFO, collectionInfo) - frag.arguments = args - return frag - } - - } - - override fun onCreateDialog(savedInstanceState: Bundle?): Dialog { - val collectionInfo = arguments!!.getParcelable(ARG_COLLECTION_INFO) as CollectionInfo - val name = if (collectionInfo.displayName.isNullOrBlank()) - collectionInfo.url.toString() - else - collectionInfo.displayName - - return AlertDialog.Builder(activity!!) - .setTitle(R.string.delete_collection_confirm_title) - .setMessage(getString(R.string.delete_collection_confirm_warning, name)) - .setPositiveButton(android.R.string.yes) { _, _ -> - val frag = DeleteCollectionFragment() - frag.arguments = arguments - frag.show(fragmentManager, null) - } - .setNegativeButton(android.R.string.no) { _, _ -> - dismiss() - } - .create() - } - } - -} diff --git a/app/src/main/java/at/bitfire/davdroid/ui/SettingsLoader.kt b/app/src/main/java/at/bitfire/davdroid/ui/SettingsLoader.kt deleted file mode 100644 index fbe9f572f6e1aac40ab0d8559a56e15f56b186ff..0000000000000000000000000000000000000000 --- a/app/src/main/java/at/bitfire/davdroid/ui/SettingsLoader.kt +++ /dev/null @@ -1,65 +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.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 - -abstract class SettingsLoader( - context: Context -): AsyncTaskLoader(context) { - - val handler = Handler() - val settingsObserver = object: ISettingsObserver.Stub() { - override fun onSettingsChanged() { - handler.post { - onContentChanged() - } - } - } - - private var settingsSvc: ServiceConnection? = null - var settings: ISettings? = null - - override fun onStartLoading() { - 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 onReset() { - settingsSvc?.let { - context.unbindService(it) - settingsSvc = null - } - } - -} \ No newline at end of file diff --git a/app/src/main/java/at/bitfire/davdroid/ui/setup/AccountDetailsFragment.kt b/app/src/main/java/at/bitfire/davdroid/ui/setup/AccountDetailsFragment.kt deleted file mode 100644 index 16c784235ba5db9821adeea093fc653f5ed568f3..0000000000000000000000000000000000000000 --- a/app/src/main/java/at/bitfire/davdroid/ui/setup/AccountDetailsFragment.kt +++ /dev/null @@ -1,285 +0,0 @@ -/* - * Copyright © Ricki Hirner (bitfire web engineering). - * All rights reserved. This program and the accompanying materials - * are made available under the terms of the GNU Public License v3.0 - * which accompanies this distribution, and is available at - * http://www.gnu.org/licenses/gpl.html - */ - -package at.bitfire.davdroid.ui.setup - -import android.accounts.Account -import android.accounts.AccountManager -import android.annotation.SuppressLint -import android.app.Activity -import android.content.ContentResolver -import android.content.ContentValues -import android.content.Context -import android.content.Intent -import android.database.sqlite.SQLiteDatabase -import android.os.AsyncTask -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 -import at.bitfire.davdroid.* -import at.bitfire.davdroid.log.Logger -import at.bitfire.davdroid.model.ServiceDB.* -import at.bitfire.davdroid.resource.LocalTaskList -import at.bitfire.davdroid.settings.ISettings -import at.bitfire.davdroid.ui.SettingsLoader -import at.bitfire.davdroid.ui.setup.AccountDetailsFragment.CreateSettings -import at.bitfire.ical4android.TaskProvider -import at.bitfire.vcard4android.GroupMethod -import kotlinx.android.synthetic.main.login_account_details.* -import kotlinx.android.synthetic.main.login_account_details.view.* -import java.lang.ref.WeakReference -import java.util.logging.Level - -class AccountDetailsFragment: Fragment(), LoaderManager.LoaderCallbacks { - - companion object { - const val KEY_CONFIG = "config" - - fun newInstance(config: DavResourceFinder.Configuration): AccountDetailsFragment { - val frag = AccountDetailsFragment() - val args = Bundle(1) - args.putParcelable(KEY_CONFIG, config) - frag.arguments = args - return frag - } - } - - var groupMethod: GroupMethod? = null - var settings: ISettings? = null - - - override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View { - val v = inflater.inflate(R.layout.login_account_details, container, false) - - v.back.setOnClickListener { _ -> - requireFragmentManager().popBackStack() - } - - val args = requireNotNull(arguments) - val config = args.getParcelable(KEY_CONFIG) as DavResourceFinder.Configuration - - 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 - settings?.let { - if (it.has(AccountSettings.KEY_CONTACT_GROUP_METHOD)) - v.contact_group_method.isEnabled = false - } - - v.create_account.setOnClickListener { _ -> - val name = v.account_name.text.toString() - if (name.isEmpty()) - v.account_name.error = getString(R.string.login_account_name_required) - else - settings?.let { - val idx = view!!.contact_group_method.selectedItemPosition - val groupMethodName = resources.getStringArray(R.array.settings_contact_group_method_values)[idx] - - v.create_account.visibility = View.GONE - v.create_account_progress.visibility = View.VISIBLE - - CreateAccountTask(requireActivity().applicationContext, WeakReference(requireActivity()), - it, name, - args.getParcelable(KEY_CONFIG) as DavResourceFinder.Configuration, - GroupMethod.valueOf(groupMethodName)).execute() - } - } - - loaderManager.initLoader(0, null, this) - - return v - } - - override fun onCreateLoader(code: Int, args: Bundle?) = - GroupMethodLoader(requireActivity()) - - override fun onLoadFinished(loader: Loader, result: CreateSettings?) { - settings = (result ?: return).settings - groupMethod = result.groupMethod - - 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 - - view.create_account.isEnabled = true - } - } - - override fun onLoaderReset(loader: Loader) { - settings = null - groupMethod = null - view?.create_account?.isEnabled = false - } - - - @SuppressLint("StaticFieldLeak") // we'll only keep the application Context - class CreateAccountTask( - val applicationContext: Context, - val activityRef: WeakReference, - val settings: ISettings, - - val accountName: String, - val config: DavResourceFinder.Configuration, - val groupMethod: GroupMethod - ): AsyncTask() { - - override fun doInBackground(vararg params: Void?): Boolean { - val account = Account(accountName, applicationContext.getString(R.string.account_type)) - - // create Android account - val userData = AccountSettings.initialUserData(config.credentials) - Logger.log.log(Level.INFO, "Creating Android account with initial config", arrayOf(account, userData)) - - val accountManager = AccountManager.get(applicationContext) - 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(applicationContext).use { dbHelper -> - val db = dbHelper.writableDatabase - try { - val accountSettings = AccountSettings(applicationContext, settings, account) - - val refreshIntent = Intent(applicationContext, DavService::class.java) - refreshIntent.action = DavService.ACTION_REFRESH_COLLECTIONS - - if (config.cardDAV != null) { - // insert CardDAV service - val id = insertService(db, accountName, Services.SERVICE_CARDDAV, config.cardDAV) - - // start CardDAV service detection (refresh collections) - refreshIntent.putExtra(DavService.EXTRA_DAV_SERVICE_ID, id) - applicationContext.startService(refreshIntent) - - // initial CardDAV account settings - accountSettings.setGroupMethod(groupMethod) - - // contact sync is automatically enabled by isAlwaysSyncable="true" in res/xml/sync_address_books.xml - accountSettings.setSyncInterval(applicationContext.getString(R.string.address_books_authority), Constants.DEFAULT_SYNC_INTERVAL) - } else - ContentResolver.setIsSyncable(account, applicationContext.getString(R.string.address_books_authority), 0) - - if (config.calDAV != null) { - // insert CalDAV service - val id = insertService(db, accountName, Services.SERVICE_CALDAV, config.calDAV) - - // start CalDAV service detection (refresh collections) - refreshIntent.putExtra(DavService.EXTRA_DAV_SERVICE_ID, id) - applicationContext.startService(refreshIntent) - - // calendar sync is automatically enabled by isAlwaysSyncable="true" in res/xml/sync_calendars.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(applicationContext)) { - ContentResolver.setIsSyncable(account, TaskProvider.ProviderName.OpenTasks.authority, 1) - accountSettings.setSyncInterval(TaskProvider.ProviderName.OpenTasks.authority, Constants.DEFAULT_SYNC_INTERVAL) - } - } else - ContentResolver.setIsSyncable(account, CalendarContract.AUTHORITY, 0) - - } catch(e: InvalidAccountException) { - Logger.log.log(Level.SEVERE, "Couldn't access account settings", e) - return false - } - } - return true - } - - override fun onPostExecute(result: Boolean) { - activityRef.get()?.let { activity -> - if (result) { - activity.setResult(Activity.RESULT_OK) - activity.finish() - } else { - Snackbar.make(activity.findViewById(android.R.id.content), R.string.login_account_not_created, Snackbar.LENGTH_LONG).show() - - activity.create_account.visibility = View.VISIBLE - activity.create_account_progress.visibility = View.GONE - } - } - } - - private fun insertService(db: SQLiteDatabase, accountName: String, service: String, info: DavResourceFinder.Configuration.ServiceInfo): Long { - // insert service - val values = ContentValues(3) - values.put(Services.ACCOUNT_NAME, accountName) - values.put(Services.SERVICE, service) - info.principal?.let { values.put(Services.PRINCIPAL, it.toString()) } - val serviceID = db.insertWithOnConflict(Services._TABLE, null, values, SQLiteDatabase.CONFLICT_REPLACE) - - // insert home sets - for (homeSet in info.homeSets) { - val values = ContentValues(2) - values.put(HomeSets.SERVICE_ID, serviceID) - values.put(HomeSets.URL, homeSet.toString()) - db.insertWithOnConflict(HomeSets._TABLE, null, values, SQLiteDatabase.CONFLICT_REPLACE) - } - - // insert collections - for (collection in info.collections.values) { - val values = collection.toDB() - values.put(Collections.SERVICE_ID, serviceID) - db.insertWithOnConflict(Collections._TABLE, null, values, SQLiteDatabase.CONFLICT_REPLACE) - } - - return serviceID - } - - } - - - data class CreateSettings( - val settings: ISettings, - val groupMethod: GroupMethod? - ) - - class GroupMethodLoader( - context: Context - ): SettingsLoader(context) { - - override fun loadInBackground(): CreateSettings? { - settings?.let { settings -> - var groupMethod: GroupMethod? = null - settings.getString(AccountSettings.KEY_CONTACT_GROUP_METHOD, null)?.let { - try { - groupMethod = GroupMethod.valueOf(it) - } catch (e: IllegalArgumentException) { - } - } - - return CreateSettings( - settings, - groupMethod - ) - } - return null - } - - } - -} diff --git a/app/src/main/java/at/bitfire/davdroid/ui/setup/DefaultLoginCredentialsFragment.kt b/app/src/main/java/at/bitfire/davdroid/ui/setup/DefaultLoginCredentialsFragment.kt deleted file mode 100644 index 401f96e7dceebf61d2fbac1d09366787451d71a7..0000000000000000000000000000000000000000 --- a/app/src/main/java/at/bitfire/davdroid/ui/setup/DefaultLoginCredentialsFragment.kt +++ /dev/null @@ -1,204 +0,0 @@ -/* - * Copyright © Ricki Hirner (bitfire web engineering). - * All rights reserved. This program and the accompanying materials - * are made available under the terms of the GNU Public License v3.0 - * which accompanies this distribution, and is available at - * http://www.gnu.org/licenses/gpl.html - */ - -package at.bitfire.davdroid.ui.setup - -import android.net.Uri -import android.os.Bundle -import android.os.Handler -import android.os.Looper -import android.security.KeyChain -import android.support.v4.app.Fragment -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup -import android.widget.CompoundButton -import at.bitfire.dav4android.Constants -import at.bitfire.davdroid.R -import kotlinx.android.synthetic.main.login_credentials_fragment.view.* -import java.net.IDN -import java.net.URI -import java.net.URISyntaxException -import java.util.logging.Level - -class DefaultLoginCredentialsFragment: Fragment(), CompoundButton.OnCheckedChangeListener { - - override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View { - val v = inflater.inflate(R.layout.login_credentials_fragment, container, false) - - if (savedInstanceState == null) { - // first call - activity?.intent?.let { - // we've got initial login data - val url = it.getStringExtra(LoginActivity.EXTRA_URL) - val username = it.getStringExtra(LoginActivity.EXTRA_USERNAME) - val password = it.getStringExtra(LoginActivity.EXTRA_PASSWORD) - - if (url != null) { - v.login_type_urlpwd.isChecked = true - v.urlpwd_base_url.setText(url) - v.urlpwd_user_name.setText(username) - v.urlpwd_password.setText(password) - } else { - v.login_type_email.isChecked = true - v.email_address.setText(username) - v.email_password.setText(password) - } - } - } - - v.urlcert_select_cert.setOnClickListener { - KeyChain.choosePrivateKeyAlias(activity, { alias -> - Handler(Looper.getMainLooper()).post { - v.urlcert_cert_alias.text = alias - v.urlcert_cert_alias.error = null - } - }, null, null, null, -1, view!!.urlcert_cert_alias.text.toString()) - } - - v.login.setOnClickListener { - validateLoginData()?.let { info -> - DetectConfigurationFragment.newInstance(info).show(fragmentManager, null) - } - } - - // initialize to Login by email - onCheckedChanged(v) - - v.login_type_email.setOnCheckedChangeListener(this) - v.login_type_urlpwd.setOnCheckedChangeListener(this) - v.login_type_urlcert.setOnCheckedChangeListener(this) - - return v - } - - override fun onCheckedChanged(buttonView: CompoundButton, isChecked: Boolean) { - onCheckedChanged(view!!) - } - - private fun onCheckedChanged(v: View) { - v.login_type_email_details.visibility = if (v.login_type_email.isChecked) View.VISIBLE else View.GONE - v.login_type_urlpwd_details.visibility = if (v.login_type_urlpwd.isChecked) View.VISIBLE else View.GONE - v.login_type_urlcert_details.visibility = if (v.login_type_urlcert.isChecked) View.VISIBLE else View.GONE - } - - private fun validateLoginData(): LoginInfo? { - val view = requireNotNull(view) - when { - // Login with email address - view.login_type_email.isChecked -> { - var uri: URI? = null - var valid = true - - val email = view.email_address.text.toString() - if (!email.matches(Regex(".+@.+"))) { - view.email_address.error = getString(R.string.login_email_address_error) - valid = false - } else - try { - uri = URI("mailto", email, null) - } catch (e: URISyntaxException) { - view.email_address.error = e.localizedMessage - valid = false - } - - val password = view.email_password.text.toString() - if (password.isEmpty()) { - view.email_password.error = getString(R.string.login_password_required) - valid = false - } - - return if (valid && uri != null) - LoginInfo(uri, email, password) - else - null - - } - - // Login with URL and user name - view.login_type_urlpwd.isChecked -> { - var valid = true - - val baseUrl = Uri.parse(view.urlpwd_base_url.text.toString()) - val uri = validateBaseUrl(baseUrl, false) { message -> - view.urlpwd_base_url.error = message - valid = false - } - - val userName = view.urlpwd_user_name.text.toString() - if (userName.isBlank()) { - view.urlpwd_user_name.error = getString(R.string.login_user_name_required) - valid = false - } - - val password = view.urlpwd_password.text.toString() - if (password.isEmpty()) { - view.urlpwd_password.error = getString(R.string.login_password_required) - valid = false - } - - return if (valid && uri != null) - LoginInfo(uri, userName, password) - else - null - } - - // Login with URL and client certificate - view.login_type_urlcert.isChecked -> { - var valid = true - - val baseUrl = Uri.parse(view.urlcert_base_url.text.toString()) - val uri = validateBaseUrl(baseUrl, true) { message -> - view.urlcert_base_url.error = message - valid = false - } - - val alias = view.urlcert_cert_alias.text.toString() - if (alias.isEmpty()) { - view.urlcert_cert_alias.error = "" - valid = false - } - - if (valid && uri != null) - return LoginInfo(uri, certificateAlias = alias) - } - } - - return null - } - - private fun validateBaseUrl(baseUrl: Uri, httpsRequired: Boolean, reportError: (String) -> Unit): URI? { - var uri: URI? = null - val scheme = baseUrl.scheme - if ((!httpsRequired && scheme.equals("http", true)) || scheme.equals("https", true)) { - var host = baseUrl.host - if (host.isNullOrBlank()) - reportError(getString(R.string.login_url_host_name_required)) - else - try { - host = IDN.toASCII(host) - } catch (e: IllegalArgumentException) { - Constants.log.log(Level.WARNING, "Host name not conforming to RFC 3490", e) - } - - val path = baseUrl.encodedPath - val port = baseUrl.port - try { - uri = URI(baseUrl.scheme, null, host, port, path, null, null) - } catch (e: URISyntaxException) { - reportError(e.localizedMessage) - } - } else - reportError(getString(if (httpsRequired) - R.string.login_url_must_be_https - else - R.string.login_url_must_be_http_or_https)) - return uri - } - -} diff --git a/app/src/main/java/at/bitfire/davdroid/ui/setup/DetectConfigurationFragment.kt b/app/src/main/java/at/bitfire/davdroid/ui/setup/DetectConfigurationFragment.kt deleted file mode 100644 index 6a0b9e392009289c364fbe9524f3fdf4fd2cf2e9..0000000000000000000000000000000000000000 --- a/app/src/main/java/at/bitfire/davdroid/ui/setup/DetectConfigurationFragment.kt +++ /dev/null @@ -1,141 +0,0 @@ -/* - * Copyright © Ricki Hirner (bitfire web engineering). - * All rights reserved. This program and the accompanying materials - * are made available under the terms of the GNU Public License v3.0 - * which accompanies this distribution, and is available at - * http://www.gnu.org/licenses/gpl.html - */ - -package at.bitfire.davdroid.ui.setup - -import android.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 -import at.bitfire.davdroid.ui.setup.DavResourceFinder.Configuration -import java.lang.ref.WeakReference - -@Suppress("DEPRECATION") -class DetectConfigurationFragment: DialogFragment(), LoaderManager.LoaderCallbacks { - - companion object { - const val ARG_LOGIN_CREDENTIALS = "credentials" - - fun newInstance(credentials: LoginInfo): DetectConfigurationFragment { - val frag = DetectConfigurationFragment() - val args = Bundle(1) - args.putParcelable(ARG_LOGIN_CREDENTIALS, credentials) - frag.arguments = args - return frag - } - } - - - override fun onCreateDialog(savedInstancebState: Bundle?): Dialog { - val progress = ProgressDialog(activity) - progress.setTitle(R.string.login_configuration_detection) - progress.setMessage(getString(R.string.login_querying_server)) - progress.isIndeterminate = true - progress.setCanceledOnTouchOutside(false) - return progress - } - - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - loaderManager.initLoader(0, arguments, this) - } - - override fun onCancel(dialog: DialogInterface?) { - Logger.log.info("Cancelling resource detection") - loaderManager.getLoader(0)?.cancelLoad() - } - - - override fun onCreateLoader(id: Int, args: Bundle?) = - ServerConfigurationLoader(requireActivity(), args!!.getParcelable(ARG_LOGIN_CREDENTIALS)) - - override fun onLoadFinished(loader: Loader, data: Configuration?) { - data?.let { - if (it.calDAV == null && it.cardDAV == null) - // no service found: show error message - requireFragmentManager().beginTransaction() - .add(NothingDetectedFragment.newInstance(it.logs), null) - .commit() - else - // service found: continue - requireFragmentManager().beginTransaction() - .replace(android.R.id.content, AccountDetailsFragment.newInstance(data)) - .addToBackStack(null) - .commit() - } - - dismiss() - } - - override fun onLoaderReset(loader: Loader) {} - - - class NothingDetectedFragment: DialogFragment() { - - companion object { - const val KEY_LOGS = "logs" - - fun newInstance(logs: String): NothingDetectedFragment { - val args = Bundle() - args.putString(KEY_LOGS, logs) - val fragment = NothingDetectedFragment() - fragment.arguments = args - return fragment - } - } - - override fun onCreateDialog(savedInstanceState: Bundle?) = - 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)) - startActivity(intent) - } - .setPositiveButton(android.R.string.ok) { _, _ -> - // dismiss - } - .create()!! - - } - - - class ServerConfigurationLoader( - context: Context, - private val credentials: LoginInfo - ): AsyncTaskLoader(context) { - - private var workingThread: WeakReference? = null - - override fun onStartLoading() = forceLoad() - - override fun cancelLoadInBackground() { - Logger.log.warning("Shutting down resource detection") - workingThread?.get()?.interrupt() - } - - override fun loadInBackground(): Configuration { - workingThread = WeakReference(Thread.currentThread()) - return DavResourceFinder(context, credentials).findInitialConfiguration() - } - - } - -} diff --git a/app/src/main/java/at/bitfire/davdroid/ui/setup/LoginActivity.kt b/app/src/main/java/at/bitfire/davdroid/ui/setup/LoginActivity.kt deleted file mode 100644 index 84d2e5ba9c9dfbf19d888ae8935e55fc6bfa685d..0000000000000000000000000000000000000000 --- a/app/src/main/java/at/bitfire/davdroid/ui/setup/LoginActivity.kt +++ /dev/null @@ -1,68 +0,0 @@ -/* - * Copyright © Ricki Hirner (bitfire web engineering). - * All rights reserved. This program and the accompanying materials - * are made available under the terms of the GNU Public License v3.0 - * which accompanies this distribution, and is available at - * http://www.gnu.org/licenses/gpl.html - */ - -package at.bitfire.davdroid.ui.setup - -import android.os.Bundle -import android.support.v7.app.AppCompatActivity -import android.view.Menu -import android.view.MenuItem -import at.bitfire.davdroid.App -import at.bitfire.davdroid.R -import at.bitfire.davdroid.ui.UiUtils -import java.util.* - -/** - * Activity to initially connect to a server and create an account. - * Fields for server/user data can be pre-filled with extras in the Intent. - */ -class LoginActivity: AppCompatActivity() { - - companion object { - /** - * 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. - */ - 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. - */ - const val EXTRA_USERNAME = "username" - - /** - * When set, the password field will be set to this value. - */ - const val EXTRA_PASSWORD = "password" - } - - - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - - if (savedInstanceState == null) - // first call, add first login fragment - supportFragmentManager.beginTransaction() - .replace(android.R.id.content, DefaultLoginCredentialsFragment()) - .commit() - } - - override fun onCreateOptionsMenu(menu: Menu): Boolean { - menuInflater.inflate(R.menu.activity_login, menu) - return true - } - - - fun showHelp(item: MenuItem) { - UiUtils.launchUri(this, App.homepageUrl(this).buildUpon() - .appendEncodedPath("tested-with/") - .build()) - } - -} diff --git a/app/src/main/java/at/bitfire/davdroid/ui/setup/LoginInfo.kt b/app/src/main/java/at/bitfire/davdroid/ui/setup/LoginInfo.kt deleted file mode 100644 index e724f0c5a2aa120164963d21a48c34d466983d48..0000000000000000000000000000000000000000 --- a/app/src/main/java/at/bitfire/davdroid/ui/setup/LoginInfo.kt +++ /dev/null @@ -1,44 +0,0 @@ -/* - * Copyright © Ricki Hirner (bitfire web engineering). - * All rights reserved. This program and the accompanying materials - * are made available under the terms of the GNU Public License v3.0 - * which accompanies this distribution, and is available at - * http://www.gnu.org/licenses/gpl.html - */ - -package at.bitfire.davdroid.ui.setup - -import android.os.Parcel -import android.os.Parcelable -import at.bitfire.davdroid.model.Credentials -import java.net.URI - -data class LoginInfo( - val uri: URI, - 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.writeSerializable(credentials) - } - - - companion object CREATOR : Parcelable.Creator { - - override fun createFromParcel(source: Parcel) = - LoginInfo( - source.readSerializable() as URI, - source.readSerializable() as Credentials - ) - - override fun newArray(size: Int) = arrayOfNulls(size) - - } - -} diff --git a/app/src/main/java/at/bitfire/davdroid/ui/widget/MaximizedListView.kt b/app/src/main/java/at/bitfire/davdroid/ui/widget/MaximizedListView.kt deleted file mode 100644 index 51bb3bd6b448200d3c788fc500461a28def08f46..0000000000000000000000000000000000000000 --- a/app/src/main/java/at/bitfire/davdroid/ui/widget/MaximizedListView.kt +++ /dev/null @@ -1,52 +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.View -import android.widget.ListView - -class MaximizedListView( - context: Context, - attrs: AttributeSet -): ListView(context, attrs) { - - override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) { - super.onMeasure(widthMeasureSpec, heightMeasureSpec) - - val widthMode = MeasureSpec.getMode(widthMeasureSpec) - val widthSize = MeasureSpec.getSize(widthMeasureSpec) - val heightMode = MeasureSpec.getMode(heightMeasureSpec) - val heightSize = MeasureSpec.getSize(heightMeasureSpec) - - var width = 0 - var height = 0 - - if (widthMode == MeasureSpec.EXACTLY || widthMode == MeasureSpec.AT_MOST) - width = widthSize - - if (heightMode == MeasureSpec.EXACTLY) - height = heightSize - else { - adapter?.let { listAdapter -> - val widthSpec = View.MeasureSpec.makeMeasureSpec(width, MeasureSpec.EXACTLY) - for (i in 0 until listAdapter.count) { - val listItem = listAdapter.getView(i, null, this) - listItem.measure(widthSpec, View.MeasureSpec.UNSPECIFIED) - height += listItem.measuredHeight - } - height += dividerHeight * (listAdapter.count - 1) - } - } - - setMeasuredDimension(width, height) - } - -} diff --git a/app/src/main/java/at/bitfire/davdroid/App.kt b/app/src/main/java/foundation/e/accountmanager/App.kt similarity index 66% rename from app/src/main/java/at/bitfire/davdroid/App.kt rename to app/src/main/java/foundation/e/accountmanager/App.kt index da9185dba924bce62986603a645c44a943917e65..f9559cc40464f6b629e045b8cb302a13f9952ad5 100644 --- a/app/src/main/java/at/bitfire/davdroid/App.kt +++ b/app/src/main/java/foundation/e/accountmanager/App.kt @@ -6,9 +6,8 @@ * http://www.gnu.org/licenses/gpl.html */ -package at.bitfire.davdroid +package foundation.e.accountmanager -import android.app.Application import android.content.Context import android.content.Intent import android.content.IntentFilter @@ -17,30 +16,23 @@ import android.graphics.drawable.BitmapDrawable import android.net.Uri import android.os.Build import android.os.StrictMode -import android.support.v7.app.AppCompatDelegate -import at.bitfire.davdroid.log.Logger -import at.bitfire.davdroid.ui.NotificationUtils +import androidx.appcompat.app.AppCompatDelegate +import androidx.appcompat.content.res.AppCompatResources +import androidx.multidex.MultiDexApplication +import foundation.e.accountmanager.log.Logger +import foundation.e.accountmanager.ui.DebugInfoActivity +import foundation.e.accountmanager.ui.NotificationUtils +import java.util.logging.Level import kotlin.concurrent.thread +import kotlin.system.exitProcess -class App: Application() { +@Suppress("unused") +class App: MultiDexApplication(), Thread.UncaughtExceptionHandler { companion object { - const val DISTRUST_SYSTEM_CERTIFICATES = "distrust_system_certs" - const val OVERRIDE_PROXY = "override_proxy" - const val OVERRIDE_PROXY_HOST = "override_proxy_host" - const val OVERRIDE_PROXY_PORT = "override_proxy_port" - - const val OVERRIDE_PROXY_HOST_DEFAULT = "localhost" - const val OVERRIDE_PROXY_PORT_DEFAULT = 8118 - - fun getLauncherBitmap(context: Context): Bitmap? { - val drawableLogo = if (android.os.Build.VERSION.SDK_INT >= 21) - context.getDrawable(R.mipmap.ic_launcher) - else - @Suppress("deprecation") - context.resources.getDrawable(R.mipmap.ic_launcher) + val drawableLogo = AppCompatResources.getDrawable(context, R.mipmap.ic_launcher) return if (drawableLogo is BitmapDrawable) drawableLogo.bitmap else @@ -61,7 +53,8 @@ class App: Application() { super.onCreate() Logger.initialize(this) - if (BuildConfig.DEBUG) { + if (BuildConfig.DEBUG) + // debug builds StrictMode.setVmPolicy(StrictMode.VmPolicy.Builder() .detectActivityLeaks() .detectFileUriExposure() @@ -70,13 +63,9 @@ class App: Application() { .detectLeakedSqlLiteObjects() .penaltyLog() .build()) - - // main thread - StrictMode.setThreadPolicy(StrictMode.ThreadPolicy.Builder() - .detectAll() - .penaltyLog() - .build()) - } + else // if (BuildConfig.FLAVOR == FLAVOR_STANDARD) + // handle uncaught exceptions in non-debug standard flavor + Thread.setDefaultUncaughtExceptionHandler(this) if (Build.VERSION.SDK_INT <= 21) AppCompatDelegate.setCompatVectorFromResourcesEnabled(true) @@ -97,4 +86,15 @@ class App: Application() { } } + override fun uncaughtException(t: Thread, e: Throwable) { + Logger.log.log(Level.SEVERE, "Unhandled exception!", e) + + val intent = Intent(this, DebugInfoActivity::class.java) + intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + intent.putExtra(DebugInfoActivity.KEY_THROWABLE, e) + startActivity(intent) + + exitProcess(1) + } + } diff --git a/app/src/main/java/foundation/e/accountmanager/CompatUtils.kt b/app/src/main/java/foundation/e/accountmanager/CompatUtils.kt new file mode 100644 index 0000000000000000000000000000000000000000..5d010c91e3722a4260104bc1b9b2127635136f3f --- /dev/null +++ b/app/src/main/java/foundation/e/accountmanager/CompatUtils.kt @@ -0,0 +1,12 @@ +package foundation.e.accountmanager + +import android.content.ContentProviderClient +import android.os.Build + +@Suppress("DEPRECATION") +fun ContentProviderClient.closeCompat() { + if (Build.VERSION.SDK_INT >= 24) + close() + else + release() +} diff --git a/app/src/main/java/at/bitfire/davdroid/Constants.kt b/app/src/main/java/foundation/e/accountmanager/Constants.kt similarity index 65% rename from app/src/main/java/at/bitfire/davdroid/Constants.kt rename to app/src/main/java/foundation/e/accountmanager/Constants.kt index 3bcf9d347a49d4a194e9f62b05794e347a844819..48124751d348190535f59026e016df4dcbdd8c84 100644 --- a/app/src/main/java/at/bitfire/davdroid/Constants.kt +++ b/app/src/main/java/foundation/e/accountmanager/Constants.kt @@ -5,17 +5,19 @@ * which accompanies this distribution, and is available at * http://www.gnu.org/licenses/gpl.html */ -package at.bitfire.davdroid +package foundation.e.accountmanager object Constants { const val DAVDROID_GREEN_RGBA = 0xFF8bc34a.toInt() - const val DEFAULT_SYNC_INTERVAL = 4 * 3600L // 4 hours + // NOTE: Android 7 and up don't allow 2 min sync frequencies unless system frameworks are modified + const val DEFAULT_CALENDAR_SYNC_INTERVAL = 2 * 60L // 2 minutes + const val DEFAULT_CONTACTS_SYNC_INTERVAL = 15 * 60L // 15 minutes /** * Context label for [org.apache.commons.lang3.exception.ContextedException]. - * Context value is the [at.bitfire.davdroid.resource.LocalResource] + * Context value is the [foundation.e.accountmanager.resource.LocalResource] * which is related to the exception cause. */ const val EXCEPTION_CONTEXT_LOCAL_RESOURCE = "localResource" @@ -27,4 +29,7 @@ object Constants { */ const val EXCEPTION_CONTEXT_REMOTE_RESOURCE = "remoteResource" + const val AUTH_TOKEN_TYPE = "oauth2-access-token" + + const val EELO_SYNC_URL = "https://ecloud.global" } diff --git a/app/src/main/java/foundation/e/accountmanager/DavService.kt b/app/src/main/java/foundation/e/accountmanager/DavService.kt new file mode 100644 index 0000000000000000000000000000000000000000..a1c94980b7031c096085ddf1346d1927b1a7cbad --- /dev/null +++ b/app/src/main/java/foundation/e/accountmanager/DavService.kt @@ -0,0 +1,382 @@ +/* + * 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 foundation.e.accountmanager + +import android.accounts.Account +import android.app.PendingIntent +import android.content.ContentResolver +import android.content.Intent +import android.os.Binder +import android.os.Bundle +import androidx.core.app.NotificationCompat +import androidx.core.app.NotificationManagerCompat +import androidx.room.Transaction +import foundation.e.dav4jvm.DavResource +import foundation.e.dav4jvm.Response +import foundation.e.dav4jvm.UrlUtils +import foundation.e.dav4jvm.exception.HttpException +import foundation.e.dav4jvm.property.* +import foundation.e.accountmanager.log.Logger +import foundation.e.accountmanager.model.* +import foundation.e.accountmanager.model.Collection +import foundation.e.accountmanager.settings.AccountSettings +import foundation.e.accountmanager.ui.DebugInfoActivity +import foundation.e.accountmanager.ui.NotificationUtils +import net.openid.appauth.AuthState +import okhttp3.HttpUrl +import okhttp3.OkHttpClient +import java.lang.ref.WeakReference +import java.util.* +import java.util.logging.Level +import kotlin.concurrent.thread + +class DavService: android.app.Service() { + + companion object { + const val ACTION_REFRESH_COLLECTIONS = "refreshCollections" + const val EXTRA_DAV_SERVICE_ID = "davServiceID" + + /** Initialize a forced synchronization. Expects intent data + to be an URI of this format: + contents://// + **/ + const val ACTION_FORCE_SYNC = "forceSync" + + val DAV_COLLECTION_PROPERTIES = arrayOf( + ResourceType.NAME, + CurrentUserPrivilegeSet.NAME, + DisplayName.NAME, + AddressbookDescription.NAME, SupportedAddressData.NAME, + CalendarDescription.NAME, CalendarColor.NAME, SupportedCalendarComponentSet.NAME, + Source.NAME + ) + + } + + private val runningRefresh = HashSet() + private val refreshingStatusListeners = LinkedList>() + + + override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { + intent?.let { + val id = intent.getLongExtra(EXTRA_DAV_SERVICE_ID, -1) + + when (intent.action) { + ACTION_REFRESH_COLLECTIONS -> + if (runningRefresh.add(id)) { + refreshingStatusListeners.forEach { listener -> + listener.get()?.onDavRefreshStatusChanged(id, true) + } + thread { refreshCollections(id) } + } + + ACTION_FORCE_SYNC -> { + val uri = intent.data!! + val authority = uri.authority!! + val account = Account( + uri.pathSegments[1], + uri.pathSegments[0] + ) + forceSync(authority, account) + } + } + } + + return START_NOT_STICKY + } + + + /* BOUND SERVICE PART + for communicating with the activities + */ + + interface RefreshingStatusListener { + fun onDavRefreshStatusChanged(id: Long, refreshing: Boolean) + } + + private val binder = InfoBinder() + + inner class InfoBinder: Binder() { + fun isRefreshing(id: Long) = runningRefresh.contains(id) + + fun addRefreshingStatusListener(listener: RefreshingStatusListener, callImmediateIfRunning: Boolean) { + refreshingStatusListeners += WeakReference(listener) + if (callImmediateIfRunning) + runningRefresh.forEach { id -> listener.onDavRefreshStatusChanged(id, true) } + } + + fun removeRefreshingStatusListener(listener: RefreshingStatusListener) { + val iter = refreshingStatusListeners.iterator() + while (iter.hasNext()) { + val item = iter.next().get() + if (listener == item) + iter.remove() + } + } + } + + override fun onBind(intent: Intent?) = binder + + + + /* ACTION RUNNABLES + which actually do the work + */ + + 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) + } + + private fun refreshCollections(serviceId: Long) { + val db = AppDatabase.getInstance(this) + val homeSetDao = db.homeSetDao() + val collectionDao = db.collectionDao() + + val service = db.serviceDao().get(serviceId) ?: throw IllegalArgumentException("Service not found") + val account = Account(service.accountName, service.accountType) + + val homeSets = homeSetDao.getByService(serviceId).associateBy { it.url }.toMutableMap() + val collections = collectionDao.getByService(serviceId).associateBy { it.url }.toMutableMap() + + /** + * Checks if the given URL defines home sets and adds them to the home set list. + * + * @throws java.io.IOException + * @throws HttpException + * @throws foundation.e.dav4jvm.exception.DavException + */ + fun queryHomeSets(client: OkHttpClient, url: HttpUrl, accessToken: String?, recurse: Boolean = true) { + val related = mutableSetOf() + + fun findRelated(root: HttpUrl, dav: Response) { + // refresh home sets: calendar-proxy-read/write-for + dav[CalendarProxyReadFor::class.java]?.let { + for (href in it.hrefs) { + Logger.log.fine("Principal is a read-only proxy for $href, checking for home sets") + root.resolve(href)?.let { proxyReadFor -> + related += proxyReadFor + } + } + } + dav[CalendarProxyWriteFor::class.java]?.let { + for (href in it.hrefs) { + Logger.log.fine("Principal is a read/write proxy for $href, checking for home sets") + root.resolve(href)?.let { proxyWriteFor -> + related += proxyWriteFor + } + } + } + + // refresh home sets: direct group memberships + dav[GroupMembership::class.java]?.let { + for (href in it.hrefs) { + Logger.log.fine("Principal is member of group $href, checking for home sets") + root.resolve(href)?.let { groupMembership -> + related += groupMembership + } + } + } + } + + val dav = DavResource(client, url, accessToken) + when (service.type) { + Service.TYPE_CARDDAV -> + try { + dav.propfind(0, DisplayName.NAME, AddressbookHomeSet.NAME, GroupMembership.NAME) { response, _ -> + response[AddressbookHomeSet::class.java]?.let { homeSet -> + for (href in homeSet.hrefs) + dav.location.resolve(href)?.let { + val foundUrl = UrlUtils.withTrailingSlash(it) + homeSets[foundUrl] = HomeSet(0, service.id, foundUrl) + } + } + + if (recurse) + findRelated(dav.location, response) + } + } catch (e: HttpException) { + if (e.code/100 == 4) + Logger.log.log(Level.INFO, "Ignoring Client Error 4xx while looking for addressbook home sets", e) + else + throw e + } + Service.TYPE_CALDAV -> { + try { + dav.propfind(0, DisplayName.NAME, CalendarHomeSet.NAME, CalendarProxyReadFor.NAME, CalendarProxyWriteFor.NAME, GroupMembership.NAME) { response, _ -> + response[CalendarHomeSet::class.java]?.let { homeSet -> + for (href in homeSet.hrefs) + dav.location.resolve(href)?.let { + val foundUrl = UrlUtils.withTrailingSlash(it) + homeSets[foundUrl] = HomeSet(0, service.id, foundUrl) + } + } + + if (recurse) + findRelated(dav.location, response) + } + } catch (e: HttpException) { + if (e.code/100 == 4) + Logger.log.log(Level.INFO, "Ignoring Client Error 4xx while looking for calendar home sets", e) + else + throw e + } + } + } + + for (resource in related) + queryHomeSets(client, resource, accessToken, false) + } + + @Transaction + fun saveHomesets() { + DaoTools(homeSetDao).syncAll( + homeSetDao.getByService(serviceId), + homeSets, + { it.url }) + } + + @Transaction + fun saveCollections() { + DaoTools(collectionDao).syncAll( + collectionDao.getByService(serviceId), + collections, { it.url }) { new, old -> + new.forceReadOnly = old.forceReadOnly + new.sync = old.sync + } + } + + fun saveResults() { + saveHomesets() + saveCollections() + } + + try { + Logger.log.info("Refreshing ${service.type} collections of service #$service") + + // cancel previous notification + NotificationManagerCompat.from(this) + .cancel(service.toString(), NotificationUtils.NOTIFY_REFRESH_COLLECTIONS) + + // create authenticating OkHttpClient (credentials taken from account settings) + HttpClient.Builder(this, AccountSettings(this, account)) + .setForeground(true) + .build().use { client -> + val httpClient = client.okHttpClient + + var authState : String? = null + + service.authState?.let { authstate -> + authState = AuthState.jsonDeserialize(authstate).accessToken + } + + // refresh home set list (from principal) + service.principal?.let { principalUrl -> + Logger.log.fine("Querying principal $principalUrl for home sets") + queryHomeSets(httpClient, principalUrl, authState) + } + + // now refresh homesets and their member collections + val itHomeSets = homeSets.iterator() + while (itHomeSets.hasNext()) { + val homeSet = itHomeSets.next() + Logger.log.fine("Listing home set ${homeSet.key}") + + try { + DavResource(httpClient, homeSet.key, authState).propfind(1, *DAV_COLLECTION_PROPERTIES) { response, relation -> + if (!response.isSuccess()) + return@propfind + + if (relation == Response.HrefRelation.SELF) { + // this response is about the homeset itself + homeSet.value.displayName = response[DisplayName::class.java]?.displayName + homeSet.value.privBind = response[CurrentUserPrivilegeSet::class.java]?.mayBind ?: true + } + + // in any case, check whether the response is about a useable collection + val info = Collection.fromDavResponse(response) ?: return@propfind + info.serviceId = serviceId + info.confirmed = true + Logger.log.log(Level.FINE, "Found collection", info) + + // remember usable collections + if ((service.type == Service.TYPE_CARDDAV && info.type == Collection.TYPE_ADDRESSBOOK) || + (service.type == Service.TYPE_CALDAV && arrayOf(Collection.TYPE_CALENDAR, Collection.TYPE_WEBCAL).contains(info.type))) + collections[response.href] = info + } + } catch(e: HttpException) { + if (e.code in arrayOf(403, 404, 410)) + // delete home set only if it was not accessible (40x) + itHomeSets.remove() + } + } + + // check/refresh unconfirmed collections + val itCollections = collections.entries.iterator() + while (itCollections.hasNext()) { + val (url, info) = itCollections.next() + if (!info.confirmed) + try { + DavResource(httpClient, url, authState).propfind(0, *DAV_COLLECTION_PROPERTIES) { response, _ -> + if (!response.isSuccess()) + return@propfind + + val collection = Collection.fromDavResponse(response) ?: return@propfind + collection.confirmed = true + + // remove unusable collections + if ((service.type == Service.TYPE_CARDDAV && collection.type != Collection.TYPE_ADDRESSBOOK) || + (service.type == Service.TYPE_CALDAV && !arrayOf(Collection.TYPE_CALENDAR, Collection.TYPE_WEBCAL).contains(collection.type)) || + (collection.type == Collection.TYPE_WEBCAL && collection.source == null)) + itCollections.remove() + } + } catch(e: HttpException) { + if (e.code in arrayOf(403, 404, 410)) + // delete collection only if it was not accessible (40x) + itCollections.remove() + else + throw e + } + } + } + + saveResults() + + } catch(e: InvalidAccountException) { + Logger.log.log(Level.SEVERE, "Invalid account", e) + } catch(e: Exception) { + Logger.log.log(Level.SEVERE, "Couldn't refresh collection list", e) + + val debugIntent = Intent(this, DebugInfoActivity::class.java) + debugIntent.putExtra(DebugInfoActivity.KEY_THROWABLE, e) + debugIntent.putExtra(DebugInfoActivity.KEY_ACCOUNT, account) + + val notify = NotificationUtils.newBuilder(this, NotificationUtils.CHANNEL_GENERAL) + .setSmallIcon(R.drawable.ic_sync_problem_notify) + .setContentTitle(getString(R.string.dav_service_refresh_failed)) + .setContentText(getString(R.string.dav_service_refresh_couldnt_refresh)) + .setContentIntent(PendingIntent.getActivity(this, 0, debugIntent, PendingIntent.FLAG_UPDATE_CURRENT)) + .setSubText(account.name) + .setCategory(NotificationCompat.CATEGORY_ERROR) + .build() + NotificationManagerCompat.from(this) + .notify(serviceId.toString(), NotificationUtils.NOTIFY_REFRESH_COLLECTIONS, notify) + } finally { + runningRefresh.remove(serviceId) + refreshingStatusListeners.mapNotNull { it.get() }.forEach { + it.onDavRefreshStatusChanged(serviceId, false) + } + } + + } + +} diff --git a/app/src/main/java/at/bitfire/davdroid/DavUtils.kt b/app/src/main/java/foundation/e/accountmanager/DavUtils.kt similarity index 62% rename from app/src/main/java/at/bitfire/davdroid/DavUtils.kt rename to app/src/main/java/foundation/e/accountmanager/DavUtils.kt index f4a17febb51d6521a7219d0f21acc307b7316fa2..6b44d0557f27245efa294addf950e315677c9e02 100644 --- a/app/src/main/java/at/bitfire/davdroid/DavUtils.kt +++ b/app/src/main/java/foundation/e/accountmanager/DavUtils.kt @@ -6,13 +6,18 @@ * http://www.gnu.org/licenses/gpl.html */ -package at.bitfire.davdroid +package foundation.e.accountmanager +import android.accounts.Account import android.annotation.TargetApi +import android.content.ContentResolver import android.content.Context import android.net.ConnectivityManager import android.os.Build -import at.bitfire.davdroid.log.Logger +import android.os.Bundle +import android.provider.CalendarContract +import foundation.e.accountmanager.log.Logger +import foundation.e.ical4android.TaskProvider import okhttp3.HttpUrl import org.xbill.DNS.* import java.util.* @@ -28,6 +33,7 @@ object DavUtils { return String.format("#%06X%02X", color, alpha) } + fun lastSegmentOfUrl(url: HttpUrl): String { // the list returned by HttpUrl.pathSegments() is unmodifiable, so we have to create a copy val segments = LinkedList(url.pathSegments()) @@ -45,14 +51,18 @@ object DavUtils { get the active DNS servers). */ val connectivity = context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager val activeLink = connectivity.getLinkProperties(connectivity.activeNetwork) - val simpleResolvers = activeLink.dnsServers.map { - Logger.log.fine("Using DNS server ${it.hostAddress}") - val resolver = SimpleResolver() - resolver.setAddress(it) - resolver - } - val resolver = ExtendedResolver(simpleResolvers.toTypedArray()) - lookup.setResolver(resolver) + if (activeLink != null) { + // get DNS servers of active network link and set them for dnsjava so that it can send SRV queries + val simpleResolvers = activeLink.dnsServers.map { + Logger.log.fine("Using DNS server ${it.hostAddress}") + val resolver = SimpleResolver() + resolver.setAddress(it) + resolver + } + val resolver = ExtendedResolver(simpleResolvers.toTypedArray()) + lookup.setResolver(resolver) + } else + Logger.log.severe("Couldn't determine DNS servers, dnsjava queries (SRV/TXT records) won't work") } } @@ -79,4 +89,19 @@ object DavUtils { return paths } + + fun requestSync(context: Context, account: Account) { + val authorities = arrayOf( + context.getString(R.string.address_books_authority), + CalendarContract.AUTHORITY, + TaskProvider.ProviderName.OpenTasks.authority + ) + + for (authority in authorities) { + val extras = Bundle(2) + extras.putBoolean(ContentResolver.SYNC_EXTRAS_MANUAL, true) // manual sync + extras.putBoolean(ContentResolver.SYNC_EXTRAS_EXPEDITED, true) // run immediately (don't queue) + ContentResolver.requestSync(account, authority, extras) + } + } } diff --git a/app/src/main/java/at/bitfire/davdroid/HttpClient.kt b/app/src/main/java/foundation/e/accountmanager/HttpClient.kt similarity index 70% rename from app/src/main/java/at/bitfire/davdroid/HttpClient.kt rename to app/src/main/java/foundation/e/accountmanager/HttpClient.kt index 99d555696c07f4c0ccfe7ce1ac45452e6169aefb..ad6bd4b73f0942a11240429695e2a091016854dd 100644 --- a/app/src/main/java/at/bitfire/davdroid/HttpClient.kt +++ b/app/src/main/java/foundation/e/accountmanager/HttpClient.kt @@ -6,22 +6,20 @@ * http://www.gnu.org/licenses/gpl.html */ -package at.bitfire.davdroid +package foundation.e.accountmanager 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 -import okhttp3.OkHttpClient -import okhttp3.Response +import foundation.e.cert4android.CustomCertManager +import foundation.e.dav4jvm.BasicDigestAuthHandler +import foundation.e.dav4jvm.Constants +import foundation.e.dav4jvm.UrlUtils +import foundation.e.accountmanager.log.Logger +import foundation.e.accountmanager.model.Credentials +import foundation.e.accountmanager.settings.AccountSettings +import foundation.e.accountmanager.settings.Settings +import okhttp3.* import okhttp3.internal.tls.OkHostnameVerifier import okhttp3.logging.HttpLoggingInterceptor import java.io.File @@ -34,10 +32,7 @@ import java.text.SimpleDateFormat import java.util.* import java.util.concurrent.TimeUnit import java.util.logging.Level -import javax.net.ssl.KeyManager -import javax.net.ssl.TrustManagerFactory -import javax.net.ssl.X509ExtendedKeyManager -import javax.net.ssl.X509TrustManager +import javax.net.ssl.* class HttpClient private constructor( val okHttpClient: OkHttpClient, @@ -45,8 +40,11 @@ class HttpClient private constructor( ): AutoCloseable { companion object { + /** max. size of disk cache (10 MB) */ + const val DISK_CACHE_MAX_SIZE: Long = 10*1024*1024 + /** [OkHttpClient] singleton to build all clients from */ - val sharedClient = OkHttpClient.Builder() + val sharedClient: OkHttpClient = OkHttpClient.Builder() // set timeouts .connectTimeout(15, TimeUnit.SECONDS) .writeTimeout(30, TimeUnit.SECONDS) @@ -58,21 +56,23 @@ class HttpClient private constructor( // add User-Agent to every request .addNetworkInterceptor(UserAgentInterceptor) - .build()!! + .build() } + override fun close() { + okHttpClient.cache()?.close() certManager?.close() } class Builder( val context: Context? = null, - val settings: ISettings? = null, accountSettings: AccountSettings? = null, val logger: java.util.logging.Logger = Logger.log ) { private var certManager: CustomCertManager? = null private var certificateAlias: String? = null + private var cache: Cache? = null private val orig = sharedClient.newBuilder() @@ -89,32 +89,36 @@ class HttpClient private constructor( orig.addInterceptor(loggingInterceptor) } - settings?.let { + context?.let { + val settings = Settings.getInstance(context) + // custom proxy support try { - if (settings.getBoolean(App.OVERRIDE_PROXY, false)) { + if (settings.getBoolean(Settings.OVERRIDE_PROXY) == true) { val address = InetSocketAddress( - settings.getString(App.OVERRIDE_PROXY_HOST, App.OVERRIDE_PROXY_HOST_DEFAULT), - settings.getInt(App.OVERRIDE_PROXY_PORT, App.OVERRIDE_PROXY_PORT_DEFAULT) + settings.getString(Settings.OVERRIDE_PROXY_HOST) + ?: Settings.OVERRIDE_PROXY_HOST_DEFAULT, + settings.getInt(Settings.OVERRIDE_PROXY_PORT) + ?: Settings.OVERRIDE_PROXY_PORT_DEFAULT ) val proxy = Proxy(Proxy.Type.HTTP, address) orig.proxy(proxy) Logger.log.log(Level.INFO, "Using proxy", proxy) } - } catch(e: Exception) { + } catch (e: Exception) { Logger.log.log(Level.SEVERE, "Can't set proxy, ignoring", e) } - context?.let { - if (BuildConfig.customCerts) - customCertManager(CustomCertManager(context, true, !settings.getBoolean(App.DISTRUST_SYSTEM_CERTIFICATES, false))) + //if (BuildConfig.customCerts) + customCertManager(CustomCertManager(context, true, + !(settings.getBoolean(Settings.DISTRUST_SYSTEM_CERTIFICATES) + ?: Settings.DISTRUST_SYSTEM_CERTIFICATES_DEFAULT))) + } - // use account settings for authentication - accountSettings?.let { - addAuthentication(null, it.credentials()) - } - } + // use account settings for authentication + accountSettings?.let { + addAuthentication(null, it.credentials()) } } @@ -124,12 +128,12 @@ class HttpClient private constructor( fun withDiskCache(): Builder { val context = context ?: throw IllegalArgumentException("Context is required to find the cache directory") - for (dir in arrayOf(context.externalCacheDir, context.cacheDir)) { + for (dir in arrayOf(context.externalCacheDir, context.cacheDir).filterNotNull()) { if (dir.exists() && dir.canWrite()) { val cacheDir = File(dir, "HttpClient") cacheDir.mkdir() Logger.log.fine("Using disk cache: $cacheDir") - orig.cache(Cache(cacheDir, 10*1024*1024)) + orig.cache(Cache(cacheDir, DISK_CACHE_MAX_SIZE)) break } } @@ -174,14 +178,16 @@ class HttpClient private constructor( ?: OkHostnameVerifier.INSTANCE var keyManager: KeyManager? = null - try { - certificateAlias?.let { alias -> - // get client certificate and private key + certificateAlias?.let { alias -> + try { + val context = requireNotNull(context) + + // get provider certificate and private key val certs = KeyChain.getCertificateChain(context, alias) ?: return@let val key = KeyChain.getPrivateKey(context, alias) ?: return@let - logger.fine("Using client certificate $alias for authentication (chain length: ${certs.size})") + logger.fine("Using provider certificate $alias for authentication (chain length: ${certs.size})") - // create Android KeyStore (performs key operations without revealing secret data to DAVdroid) + // create Android KeyStore (performs key operations without revealing secret data to DAVx5) val keyStore = KeyStore.getInstance("AndroidKeyStore") keyStore.load(null) @@ -202,12 +208,21 @@ class HttpClient private constructor( override fun getPrivateKey(forAlias: String?) = key.takeIf { forAlias == alias } } + + // HTTP/2 doesn't support client certificates (yet) + // see https://tools.ietf.org/html/draft-ietf-httpbis-http2-secondary-certs-04 + orig.protocols(listOf(Protocol.HTTP_1_1)) + } catch (e: Exception) { + logger.log(Level.SEVERE, "Couldn't set up provider certificate authentication", e) } - } catch (e: Exception) { - logger.log(Level.SEVERE, "Couldn't set up client certificate authentication", e) } - orig.sslSocketFactory(CustomTlsSocketFactory(keyManager, trustManager), trustManager) + val sslContext = SSLContext.getInstance("TLS") + sslContext.init( + if (keyManager != null) arrayOf(keyManager) else null, + arrayOf(trustManager), + null) + orig.sslSocketFactory(sslContext.socketFactory, trustManager) orig.hostnameVerifier(hostnameVerifier) return HttpClient(orig.build(), certManager) @@ -217,11 +232,11 @@ class HttpClient private constructor( private object UserAgentInterceptor: Interceptor { - // 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}" + private val userAgent = "${BuildConfig.userAgent}/${BuildConfig.VERSION_NAME} ($userAgentDate; dav4jvm; " + + "okhttp/${Constants.okhttpVersion}) Android/${Build.VERSION.RELEASE}" override fun intercept(chain: Interceptor.Chain): Response { val locale = Locale.getDefault() diff --git a/app/src/main/java/at/bitfire/davdroid/InvalidAccountException.kt b/app/src/main/java/foundation/e/accountmanager/InvalidAccountException.kt similarity index 92% rename from app/src/main/java/at/bitfire/davdroid/InvalidAccountException.kt rename to app/src/main/java/foundation/e/accountmanager/InvalidAccountException.kt index 5fc0f0999934b812f55b66004103945ede14f73d..ce2bbd85809d9ad15da4d2aeec751e1e3c57942a 100644 --- a/app/src/main/java/at/bitfire/davdroid/InvalidAccountException.kt +++ b/app/src/main/java/foundation/e/accountmanager/InvalidAccountException.kt @@ -6,7 +6,7 @@ * http://www.gnu.org/licenses/gpl.html */ -package at.bitfire.davdroid +package foundation.e.accountmanager import android.accounts.Account diff --git a/app/src/main/java/at/bitfire/davdroid/MemoryCookieStore.kt b/app/src/main/java/foundation/e/accountmanager/MemoryCookieStore.kt similarity index 98% rename from app/src/main/java/at/bitfire/davdroid/MemoryCookieStore.kt rename to app/src/main/java/foundation/e/accountmanager/MemoryCookieStore.kt index c0cd32d4dbce7429d97c128c3e8e4d8177c168a4..a8011a8552e4fd390a226ca163d084e2c11a6c3f 100644 --- a/app/src/main/java/at/bitfire/davdroid/MemoryCookieStore.kt +++ b/app/src/main/java/foundation/e/accountmanager/MemoryCookieStore.kt @@ -6,7 +6,7 @@ * http://www.gnu.org/licenses/gpl.html */ -package at.bitfire.davdroid +package foundation.e.accountmanager import okhttp3.Cookie import okhttp3.CookieJar diff --git a/app/src/main/java/foundation/e/accountmanager/PackageChangedReceiver.kt b/app/src/main/java/foundation/e/accountmanager/PackageChangedReceiver.kt new file mode 100644 index 0000000000000000000000000000000000000000..26540e1172eae80c0c72da5859a451a0b600abb5 --- /dev/null +++ b/app/src/main/java/foundation/e/accountmanager/PackageChangedReceiver.kt @@ -0,0 +1,58 @@ +/* + * 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 foundation.e.accountmanager + +import android.accounts.Account +import android.content.BroadcastReceiver +import android.content.ContentResolver +import android.content.Context +import android.content.Intent +import android.os.Bundle +import androidx.annotation.WorkerThread +import foundation.e.accountmanager.log.Logger +import foundation.e.accountmanager.model.AppDatabase +import foundation.e.accountmanager.model.Service +import foundation.e.accountmanager.resource.LocalTaskList +import foundation.e.ical4android.TaskProvider.ProviderName.OpenTasks +import kotlin.concurrent.thread + +class PackageChangedReceiver: BroadcastReceiver() { + + companion object { + + @WorkerThread + fun updateTaskSync(context: Context) { + val tasksInstalled = LocalTaskList.tasksProviderAvailable(context) + Logger.log.info("Tasks provider available = $tasksInstalled") + + // check all accounts and (de)activate OpenTasks if a CalDAV service is defined + val db = AppDatabase.getInstance(context) + db.serviceDao().getByType(Service.TYPE_CALDAV).forEach { service -> + val account = Account(service.accountName, service.accountType) + if (tasksInstalled) { + if (ContentResolver.getIsSyncable(account, OpenTasks.authority) <= 0) { + ContentResolver.setIsSyncable(account, OpenTasks.authority, 1) + ContentResolver.addPeriodicSync(account, OpenTasks.authority, Bundle(), Constants.DEFAULT_CALENDAR_SYNC_INTERVAL) + } + } else + ContentResolver.setIsSyncable(account, OpenTasks.authority, 0) + + } + } + + } + + + override fun onReceive(context: Context, intent: Intent) { + thread { + updateTaskSync(context) + } + } + +} diff --git a/app/src/main/java/foundation/e/accountmanager/authorization/IdentityProvider.java b/app/src/main/java/foundation/e/accountmanager/authorization/IdentityProvider.java new file mode 100644 index 0000000000000000000000000000000000000000..0839228006873bb982351877234ea5b242fb1de6 --- /dev/null +++ b/app/src/main/java/foundation/e/accountmanager/authorization/IdentityProvider.java @@ -0,0 +1,257 @@ +package foundation.e.accountmanager.authorization; + +/* + * Copyright 2015 The AppAuth Authors. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the + * License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either + * express or implied. See the License for the specific language governing permissions and + * limitations under the License. + */ + +import android.content.Context; +import android.content.res.Resources; +import android.net.Uri; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.annotation.StringRes; + +import foundation.e.accountmanager.R; + +import net.openid.appauth.AuthorizationServiceConfiguration; +import net.openid.appauth.AuthorizationServiceConfiguration.RetrieveConfigurationCallback; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; + +/** + * An abstraction of identity providers, containing all necessary info for the demo app. + */ +public class IdentityProvider +{ + + /** + * Value used to indicate that a configured property is not specified or required. + */ + public static final int NOT_SPECIFIED = -1; + + public static final IdentityProvider GOOGLE = new IdentityProvider( + "Google", + R.string.google_discovery_uri, + NOT_SPECIFIED, // auth endpoint is discovered + NOT_SPECIFIED, // token endpoint is discovered + R.string.google_client_id, + NOT_SPECIFIED, // client secret is not required for Google + R.string.google_auth_redirect_uri, + R.string.google_scope_string, + R.string.google_name); + + public static final List PROVIDERS = Arrays.asList( + GOOGLE); + + public static List getEnabledProviders(Context context) + { + ArrayList providers = new ArrayList<>(); + for (IdentityProvider provider : PROVIDERS) + { + provider.readConfiguration(context); + providers.add(provider); + } + return providers; + } + + @NonNull + public final String name; + + @StringRes + public final int buttonContentDescriptionRes; + + @StringRes + private final int mDiscoveryEndpointRes; + + @StringRes + private final int mAuthEndpointRes; + + @StringRes + private final int mTokenEndpointRes; + + @StringRes + private final int mClientIdRes; + + @StringRes + private final int mClientSecretRes; + + @StringRes + private final int mRedirectUriRes; + + @StringRes + private final int mScopeRes; + + private boolean mConfigurationRead = false; + private Uri mDiscoveryEndpoint; + private Uri mAuthEndpoint; + private Uri mTokenEndpoint; + private String mClientId; + private String mClientSecret; + private Uri mRedirectUri; + private String mScope; + + IdentityProvider( + @NonNull String name, + @StringRes int discoveryEndpointRes, + @StringRes int authEndpointRes, + @StringRes int tokenEndpointRes, + @StringRes int clientIdRes, + @StringRes int clientSecretRes, + @StringRes int redirectUriRes, + @StringRes int scopeRes, + @StringRes int buttonContentDescriptionRes) + { + if (!isSpecified(discoveryEndpointRes) + && !isSpecified(authEndpointRes) + && !isSpecified(tokenEndpointRes)) + { + throw new IllegalArgumentException( + "the discovery endpoint or the auth and token endpoints must be specified"); + } + + this.name = name; + this.mDiscoveryEndpointRes = discoveryEndpointRes; + this.mAuthEndpointRes = authEndpointRes; + this.mTokenEndpointRes = tokenEndpointRes; + this.mClientIdRes = checkSpecified(clientIdRes, "clientIdRes"); + this.mClientSecretRes = clientSecretRes; + this.mRedirectUriRes = checkSpecified(redirectUriRes, "redirectUriRes"); + this.mScopeRes = checkSpecified(scopeRes, "scopeRes"); + this.buttonContentDescriptionRes = + checkSpecified(buttonContentDescriptionRes, "buttonContentDescriptionRes"); + } + + /** + * This must be called before any of the getters will function. + */ + public void readConfiguration(Context context) + { + if (mConfigurationRead) + { + return; + } + + Resources res = context.getResources(); + + mDiscoveryEndpoint = isSpecified(mDiscoveryEndpointRes) + ? getUriResource(res, mDiscoveryEndpointRes, "discoveryEndpointRes") + : null; + mAuthEndpoint = isSpecified(mAuthEndpointRes) + ? getUriResource(res, mAuthEndpointRes, "authEndpointRes") + : null; + mTokenEndpoint = isSpecified(mTokenEndpointRes) + ? getUriResource(res, mTokenEndpointRes, "tokenEndpointRes") + : null; + mClientId = res.getString(mClientIdRes); + mClientSecret = isSpecified(mClientSecretRes) ? res.getString(mClientSecretRes) : null; + mRedirectUri = getUriResource(res, mRedirectUriRes, "mRedirectUriRes"); + mScope = res.getString(mScopeRes); + + mConfigurationRead = true; + } + + private void checkConfigurationRead() + { + if (!mConfigurationRead) + { + throw new IllegalStateException("Configuration not read"); + } + } + + @Nullable + public Uri getDiscoveryEndpoint() + { + checkConfigurationRead(); + return mDiscoveryEndpoint; + } + + @Nullable + public Uri getAuthEndpoint() + { + checkConfigurationRead(); + return mAuthEndpoint; + } + + @Nullable + public Uri getTokenEndpoint() + { + checkConfigurationRead(); + return mTokenEndpoint; + } + + @NonNull + public String getClientId() + { + checkConfigurationRead(); + return mClientId; + } + + @Nullable + public String getClientSecret() + { + checkConfigurationRead(); + return mClientSecret; + } + + @NonNull + public Uri getRedirectUri() + { + checkConfigurationRead(); + return mRedirectUri; + } + + @NonNull + public String getScope() + { + checkConfigurationRead(); + return mScope; + } + + public void retrieveConfig(Context context, + RetrieveConfigurationCallback callback) + { + readConfiguration(context); + if (getDiscoveryEndpoint() != null) + { + AuthorizationServiceConfiguration.fetchFromUrl(mDiscoveryEndpoint, callback); + } + else + { + AuthorizationServiceConfiguration config = + new AuthorizationServiceConfiguration(mAuthEndpoint, mTokenEndpoint, null); + callback.onFetchConfigurationCompleted(config, null); + } + } + + private static boolean isSpecified(int value) + { + return value != NOT_SPECIFIED; + } + + private static int checkSpecified(int value, String valueName) + { + if (value == NOT_SPECIFIED) + { + throw new IllegalArgumentException(valueName + " must be specified"); + } + return value; + } + + private static Uri getUriResource(Resources res, @StringRes int resId, String resName) + { + return Uri.parse(res.getString(resId)); + } +} + diff --git a/app/src/main/java/at/bitfire/davdroid/log/LogcatHandler.kt b/app/src/main/java/foundation/e/accountmanager/log/LogcatHandler.kt similarity index 97% rename from app/src/main/java/at/bitfire/davdroid/log/LogcatHandler.kt rename to app/src/main/java/foundation/e/accountmanager/log/LogcatHandler.kt index 83c317aba5f79390a95df3db6d7a89c07abe6f2a..4afe085e8908d515fe51c1cd8a2b122610a6a803 100644 --- a/app/src/main/java/at/bitfire/davdroid/log/LogcatHandler.kt +++ b/app/src/main/java/foundation/e/accountmanager/log/LogcatHandler.kt @@ -6,7 +6,7 @@ * http://www.gnu.org/licenses/gpl.html */ -package at.bitfire.davdroid.log +package foundation.e.accountmanager.log import android.util.Log diff --git a/app/src/main/java/foundation/e/accountmanager/log/Logger.kt b/app/src/main/java/foundation/e/accountmanager/log/Logger.kt new file mode 100644 index 0000000000000000000000000000000000000000..aba82a08f67ac526a1efca7f3ef563302980fae3 --- /dev/null +++ b/app/src/main/java/foundation/e/accountmanager/log/Logger.kt @@ -0,0 +1,137 @@ +/* + * 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 foundation.e.accountmanager.log + +import android.annotation.SuppressLint +import android.app.PendingIntent +import android.content.Context +import android.content.Intent +import android.content.SharedPreferences +import android.util.Log +import android.widget.Toast +import androidx.core.app.NotificationCompat +import androidx.core.app.NotificationManagerCompat +import androidx.core.content.FileProvider +import androidx.preference.PreferenceManager +import foundation.e.accountmanager.R +import foundation.e.accountmanager.ui.AppSettingsActivity +import foundation.e.accountmanager.ui.NotificationUtils +import java.io.File +import java.io.IOException +import java.util.logging.FileHandler +import java.util.logging.Level + +@SuppressLint("StaticFieldLeak") // we'll only keep an app context +object Logger : SharedPreferences.OnSharedPreferenceChangeListener { + + private const val LOG_TO_FILE = "log_to_file" + + val log = java.util.logging.Logger.getLogger("davx5") + + private lateinit var context: Context + private lateinit var preferences: SharedPreferences + + fun initialize(someContext: Context) { + context = someContext.applicationContext + preferences = PreferenceManager.getDefaultSharedPreferences(context) + preferences.registerOnSharedPreferenceChangeListener(this) + + reinitialize() + } + + override fun onSharedPreferenceChanged(sharedPreferences: SharedPreferences, key: String) { + if (key == LOG_TO_FILE) { + log.info("Logging settings changed; re-initializing logger") + reinitialize() + } + } + + private fun reinitialize() { + val logToFile = preferences.getBoolean(LOG_TO_FILE, false) + val logVerbose = logToFile || Log.isLoggable(log.name, Log.DEBUG) + + log.info("Verbose logging: $logVerbose; to file: $logToFile") + + // set logging level according to preferences + val rootLogger = java.util.logging.Logger.getLogger("") + rootLogger.level = if (logVerbose) Level.ALL else Level.INFO + + // remove all handlers and add our own logcat handler + rootLogger.useParentHandlers = false + rootLogger.handlers.forEach { rootLogger.removeHandler(it) } + rootLogger.addHandler(LogcatHandler) + + val nm = NotificationManagerCompat.from(context) + // log to external file according to preferences + if (logToFile) { + val builder = NotificationUtils.newBuilder(context, NotificationUtils.CHANNEL_DEBUG) + builder .setSmallIcon(R.drawable.ic_sd_card_notify) + .setContentTitle(context.getString(R.string.logging_notification_title)) + + val logDir = debugDir(context) ?: return + val logFile = File(logDir, "davx5-log.txt") + + try { + val fileHandler = FileHandler(logFile.toString(), true) + fileHandler.formatter = PlainTextFormatter.DEFAULT + rootLogger.addHandler(fileHandler) + + val prefIntent = Intent(context, AppSettingsActivity::class.java) + prefIntent.putExtra(AppSettingsActivity.EXTRA_SCROLL_TO, LOG_TO_FILE) + prefIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + + builder .setContentText(logDir.path) + .setCategory(NotificationCompat.CATEGORY_STATUS) + .setPriority(NotificationCompat.PRIORITY_HIGH) + .setContentText(context.getString(R.string.logging_notification_text)) + .setContentIntent(PendingIntent.getActivity(context, 0, prefIntent, PendingIntent.FLAG_UPDATE_CURRENT)) + .setOngoing(true) + + // add "Share" action + val logFileUri = FileProvider.getUriForFile(context, context.getString(R.string.authority_debug_provider), logFile) + log.fine("Now logging to file: $logFile -> $logFileUri") + + val shareIntent = Intent(Intent.ACTION_SEND) + shareIntent.putExtra(Intent.EXTRA_SUBJECT, "DAVx⁵ logs") + shareIntent.putExtra(Intent.EXTRA_STREAM, logFileUri) + shareIntent.type = "text/plain" + shareIntent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_ACTIVITY_NEW_TASK) + val chooserIntent = Intent.createChooser(shareIntent, null) + val shareAction = NotificationCompat.Action.Builder(R.drawable.ic_share_notify, + context.getString(R.string.logging_notification_send_log), + PendingIntent.getActivity(context, 0, chooserIntent, PendingIntent.FLAG_UPDATE_CURRENT)) + builder.addAction(shareAction.build()) + } catch(e: IOException) { + log.log(Level.SEVERE, "Couldn't create log file", e) + Toast.makeText(context, context.getString(R.string.logging_couldnt_create_file), Toast.LENGTH_LONG).show() + } + + nm.notify(NotificationUtils.NOTIFY_EXTERNAL_FILE_LOGGING, builder.build()) + } else { + nm.cancel(NotificationUtils.NOTIFY_EXTERNAL_FILE_LOGGING) + + // delete old logs + debugDir(context)?.deleteRecursively() + } + } + + + private fun debugDir(context: Context): File? { + val dir = File(context.filesDir, "debug") + if (dir.exists() && dir.isDirectory) + return dir + + if (dir.mkdir()) + return dir + + Toast.makeText(context, context.getString(R.string.logging_couldnt_create_file), Toast.LENGTH_LONG).show() + return null + } + +} \ No newline at end of file diff --git a/app/src/main/java/at/bitfire/davdroid/log/PlainTextFormatter.kt b/app/src/main/java/foundation/e/accountmanager/log/PlainTextFormatter.kt similarity index 97% rename from app/src/main/java/at/bitfire/davdroid/log/PlainTextFormatter.kt rename to app/src/main/java/foundation/e/accountmanager/log/PlainTextFormatter.kt index d546b73c17498c9cd6420fa2339268e693cbadaf..0c008703094fd85d4ccf6fb25454a184b309f8c1 100644 --- a/app/src/main/java/at/bitfire/davdroid/log/PlainTextFormatter.kt +++ b/app/src/main/java/foundation/e/accountmanager/log/PlainTextFormatter.kt @@ -6,7 +6,7 @@ * http://www.gnu.org/licenses/gpl.html */ -package at.bitfire.davdroid.log +package foundation.e.accountmanager.log import org.apache.commons.lang3.StringUtils import org.apache.commons.lang3.exception.ExceptionUtils diff --git a/app/src/main/java/at/bitfire/davdroid/log/StringHandler.kt b/app/src/main/java/foundation/e/accountmanager/log/StringHandler.kt similarity index 94% rename from app/src/main/java/at/bitfire/davdroid/log/StringHandler.kt rename to app/src/main/java/foundation/e/accountmanager/log/StringHandler.kt index 4938319e8d7dde794578883d4ba9ae70fe4d2866..02c9083a7b54ef108005bfb02b5d251494d5a5a4 100644 --- a/app/src/main/java/at/bitfire/davdroid/log/StringHandler.kt +++ b/app/src/main/java/foundation/e/accountmanager/log/StringHandler.kt @@ -6,7 +6,7 @@ * http://www.gnu.org/licenses/gpl.html */ -package at.bitfire.davdroid.log +package foundation.e.accountmanager.log import java.util.logging.Handler import java.util.logging.LogRecord diff --git a/app/src/main/java/foundation/e/accountmanager/model/AppDatabase.kt b/app/src/main/java/foundation/e/accountmanager/model/AppDatabase.kt new file mode 100644 index 0000000000000000000000000000000000000000..6b885e3bbe111c7ff1af30c62b271cd0e014bf9b --- /dev/null +++ b/app/src/main/java/foundation/e/accountmanager/model/AppDatabase.kt @@ -0,0 +1,227 @@ +package foundation.e.accountmanager.model + +import android.content.Context +import android.database.sqlite.SQLiteException +import android.database.sqlite.SQLiteQueryBuilder +import androidx.room.Database +import androidx.room.Room +import androidx.room.RoomDatabase +import androidx.room.TypeConverters +import androidx.room.migration.Migration +import androidx.sqlite.db.SupportSQLiteDatabase +import foundation.e.accountmanager.log.Logger + +@Suppress("ClassName") +@Database(entities = [ + Service::class, + HomeSet::class, + Collection::class +], version = 7) +@TypeConverters(Converters::class) +abstract class AppDatabase: RoomDatabase() { + + abstract fun serviceDao(): ServiceDao + abstract fun homeSetDao(): HomeSetDao + abstract fun collectionDao(): CollectionDao + + companion object { + + private var INSTANCE: AppDatabase? = null + + @Synchronized + fun getInstance(context: Context): AppDatabase { + INSTANCE?.let { return it } + + val db = Room.databaseBuilder(context.applicationContext, AppDatabase::class.java, "services.db") + .addMigrations( + Migration1_2, + Migration2_3, + Migration3_4, + Migration4_5, + Migration5_6, + Migration6_7 + ) + .fallbackToDestructiveMigration() // as a last fallback, recreate database instead of crashing + .build() + INSTANCE = db + + return db + } + + } + + fun dump(sb: StringBuilder) { + val db = openHelper.readableDatabase + db.beginTransactionNonExclusive() + + // iterate through all tables + db.query(SQLiteQueryBuilder.buildQueryString(false, "sqlite_master", arrayOf("name"), "type='table'", null, null, null, null)).use { cursorTables -> + while (cursorTables.moveToNext()) { + val table = cursorTables.getString(0) + sb.append(table).append("\n") + db.query("SELECT * FROM $table").use { cursor -> + // print columns + val cols = cursor.columnCount + sb.append("\t| ") + for (i in 0 until cols) + sb .append(" ") + .append(cursor.getColumnName(i)) + .append(" |") + sb.append("\n") + + // print rows + while (cursor.moveToNext()) { + sb.append("\t| ") + for (i in 0 until cols) { + sb.append(" ") + try { + val value = cursor.getString(i) + if (value != null) + sb.append(value + .replace("\r", "") + .replace("\n", "")) + else + sb.append("") + + } catch (e: SQLiteException) { + sb.append("") + } + sb.append(" |") + } + sb.append("\n") + } + sb.append("----------\n") + } + } + db.endTransaction() + } + } + + + // migrations + + object Migration6_7: Migration(6, 7) { + override fun migrate(db: SupportSQLiteDatabase) { + db.execSQL("ALTER TABLE homeset ADD COLUMN privBind INTEGER NOT NULL DEFAULT 1") + db.execSQL("ALTER TABLE homeset ADD COLUMN displayName TEXT DEFAULT NULL") + } + } + + object Migration5_6: Migration(5, 6) { + override fun migrate(db: SupportSQLiteDatabase) { + val sql = arrayOf( + // migrate "services" to "service": rename columns, make id NOT NULL + "CREATE TABLE service(" + + "id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT," + + "accountName TEXT NOT NULL," + + "authState TEXT," + + "accountType TEXT," + + "addressBookAccountType TEXT," + + "type TEXT NOT NULL," + + "principal TEXT DEFAULT NULL" + + ")", + "CREATE UNIQUE INDEX index_service_accountName_type ON service(accountName, type)", + "INSERT INTO service(id, accountName, authState, accountType, addressBookAccountType, type, principal) SELECT _id, accountName, authState, accountType, addressBookAccountType, service, principal FROM services", + "DROP TABLE services", + + // migrate "homesets" to "homeset": rename columns, make id NOT NULL + "CREATE TABLE homeset(" + + "id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT," + + "serviceId INTEGER NOT NULL," + + "url TEXT NOT NULL," + + "FOREIGN KEY (serviceId) REFERENCES service(id) ON DELETE CASCADE" + + ")", + "CREATE UNIQUE INDEX index_homeset_serviceId_url ON homeset(serviceId, url)", + "INSERT INTO homeset(id, serviceId, url) SELECT _id, serviceID, url FROM homesets", + "DROP TABLE homesets", + + // migrate "collections" to "collection": rename columns, make id NOT NULL + "CREATE TABLE collection(" + + "id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT," + + "serviceId INTEGER NOT NULL," + + "type TEXT NOT NULL," + + "url TEXT NOT NULL," + + "privWriteContent INTEGER NOT NULL DEFAULT 1," + + "privUnbind INTEGER NOT NULL DEFAULT 1," + + "forceReadOnly INTEGER NOT NULL DEFAULT 0," + + "displayName TEXT DEFAULT NULL," + + "description TEXT DEFAULT NULL," + + "color INTEGER DEFAULT NULL," + + "timezone TEXT DEFAULT NULL," + + "supportsVEVENT INTEGER DEFAULT NULL," + + "supportsVTODO INTEGER DEFAULT NULL," + + "supportsVJOURNAL INTEGER DEFAULT NULL," + + "source TEXT DEFAULT NULL," + + "sync INTEGER NOT NULL DEFAULT 0," + + "FOREIGN KEY (serviceId) REFERENCES service(id) ON DELETE CASCADE" + + ")", + "CREATE INDEX index_collection_serviceId_type ON collection(serviceId,type)", + "INSERT INTO collection(id, serviceId, type, url, privWriteContent, privUnbind, forceReadOnly, displayName, description, color, timezone, supportsVEVENT, supportsVTODO, source, sync) " + + "SELECT _id, serviceID, type, url, privWriteContent, privUnbind, forceReadOnly, displayName, description, color, timezone, supportsVEVENT, supportsVTODO, source, sync FROM collections", + "DROP TABLE collections" + ) + sql.forEach { db.execSQL(it) } + } + } + + object Migration4_5: Migration(4, 5) { + override fun migrate(db: SupportSQLiteDatabase) { + db.execSQL("ALTER TABLE collections ADD COLUMN privWriteContent INTEGER DEFAULT 0 NOT NULL") + db.execSQL("UPDATE collections SET privWriteContent=NOT readOnly") + + db.execSQL("ALTER TABLE collections ADD COLUMN privUnbind INTEGER DEFAULT 0 NOT NULL") + db.execSQL("UPDATE collections SET privUnbind=NOT readOnly") + + // there's no DROP COLUMN in SQLite, so just keep the "readOnly" column + } + } + + object Migration3_4: Migration(3, 4) { + override fun migrate(db: SupportSQLiteDatabase) { + db.execSQL("ALTER TABLE collections ADD COLUMN forceReadOnly INTEGER DEFAULT 0 NOT NULL") + } + } + + object Migration2_3: Migration(2, 3) { + override fun migrate(db: SupportSQLiteDatabase) { + // We don't have access to the context in a Room migration now, so + // we will just drop those settings from old DAVx5 versions. + Logger.log.warning("Dropping settings distrustSystemCerts and overrideProxy*") + + /*val edit = PreferenceManager.getDefaultSharedPreferences(context).edit() + try { + db.query("settings", arrayOf("setting", "value"), null, null, null, null, null).use { cursor -> + while (cursor.moveToNext()) { + when (cursor.getString(0)) { + "distrustSystemCerts" -> edit.putBoolean(App.DISTRUST_SYSTEM_CERTIFICATES, cursor.getInt(1) != 0) + "overrideProxy" -> edit.putBoolean(App.OVERRIDE_PROXY, cursor.getInt(1) != 0) + "overrideProxyHost" -> edit.putString(App.OVERRIDE_PROXY_HOST, cursor.getString(1)) + "overrideProxyPort" -> edit.putInt(App.OVERRIDE_PROXY_PORT, cursor.getInt(1)) + + StartupDialogFragment.HINT_GOOGLE_PLAY_ACCOUNTS_REMOVED -> + edit.putBoolean(StartupDialogFragment.HINT_GOOGLE_PLAY_ACCOUNTS_REMOVED, cursor.getInt(1) != 0) + StartupDialogFragment.HINT_OPENTASKS_NOT_INSTALLED -> + edit.putBoolean(StartupDialogFragment.HINT_OPENTASKS_NOT_INSTALLED, cursor.getInt(1) != 0) + } + } + } + db.execSQL("DROP TABLE settings") + } finally { + edit.apply() + }*/ + } + } + + object Migration1_2: Migration(1, 2) { + override fun migrate(db: SupportSQLiteDatabase) { + db.execSQL("ALTER TABLE collections ADD COLUMN type TEXT NOT NULL DEFAULT ''") + db.execSQL("ALTER TABLE collections ADD COLUMN source TEXT DEFAULT NULL") + db.execSQL("UPDATE collections SET type=(" + + "SELECT CASE service WHEN ? THEN ? ELSE ? END " + + "FROM services WHERE _id=collections.serviceID" + + ")", + arrayOf("caldav", "CALENDAR", "ADDRESS_BOOK")) + } + } + +} diff --git a/app/src/main/java/foundation/e/accountmanager/model/Collection.kt b/app/src/main/java/foundation/e/accountmanager/model/Collection.kt new file mode 100644 index 0000000000000000000000000000000000000000..ede34873c3f67c744ebaf213b6cad034eac66035 --- /dev/null +++ b/app/src/main/java/foundation/e/accountmanager/model/Collection.kt @@ -0,0 +1,157 @@ +package foundation.e.accountmanager.model + +import androidx.room.* +import foundation.e.dav4jvm.Response +import foundation.e.dav4jvm.UrlUtils +import foundation.e.dav4jvm.property.* +import foundation.e.accountmanager.DavUtils +import okhttp3.HttpUrl + +@Entity(tableName = "collection", + foreignKeys = [ + ForeignKey(entity = Service::class, parentColumns = arrayOf("id"), childColumns = arrayOf("serviceId"), onDelete = ForeignKey.CASCADE) + ], + indices = [ + Index("serviceId","type") + ] +) +data class Collection( + @PrimaryKey(autoGenerate = true) + override var id: Long = 0, + + var serviceId: Long = 0, + + var type: String, + var url: HttpUrl, + + var privWriteContent: Boolean = true, + var privUnbind: Boolean = true, + var forceReadOnly: Boolean = false, + + var displayName: String? = null, + var description: String? = null, + + // CalDAV only + var color: Int? = null, + + /** timezone definition (full VTIMEZONE) - not a TZID! **/ + var timezone: String? = null, + + /** whether the collection supports VEVENT; in case of calendars: null means true */ + var supportsVEVENT: Boolean? = null, + + /** whether the collection supports VTODO; in case of calendars: null means true */ + var supportsVTODO: Boolean? = null, + + /** whether the collection supports VJOURNAL; in case of calendars: null means true */ + var supportsVJOURNAL: Boolean? = null, + + /** Webcal subscription source URL */ + var source: HttpUrl? = null, + + /** whether this collection has been selected for synchronization */ + var sync: Boolean = true + +): IdEntity() { + + companion object { + + const val TYPE_ADDRESSBOOK = "ADDRESS_BOOK" + const val TYPE_CALENDAR = "CALENDAR" + const val TYPE_WEBCAL = "WEBCAL" + + /** + * Generates a collection entity from a WebDAV response. + * @param dav WebDAV response + * @return null if the response doesn't represent a collection + */ + fun fromDavResponse(dav: Response): Collection? { + val url = UrlUtils.withTrailingSlash(dav.href) + val type: String = dav[ResourceType::class.java]?.let { resourceType -> + when { + resourceType.types.contains(ResourceType.ADDRESSBOOK) -> TYPE_ADDRESSBOOK + resourceType.types.contains(ResourceType.CALENDAR) -> TYPE_CALENDAR + resourceType.types.contains(ResourceType.SUBSCRIBED) -> TYPE_WEBCAL + else -> null + } + } ?: return null + + var privWriteContent = true + var privUnbind = true + dav[CurrentUserPrivilegeSet::class.java]?.let { privilegeSet -> + privWriteContent = privilegeSet.mayWriteContent + privUnbind = privilegeSet.mayUnbind + } + + var displayName: String? = null + dav[DisplayName::class.java]?.let { + if (!it.displayName.isNullOrEmpty()) + displayName = it.displayName + } + + var description: String? = null + var color: Int? = null + var timezone: String? = null + var supportsVEVENT: Boolean? = null + var supportsVTODO: Boolean? = null + var supportsVJOURNAL: Boolean? = null + var source: HttpUrl? = null + when (type) { + TYPE_ADDRESSBOOK -> { + dav[AddressbookDescription::class.java]?.let { description = it.description } + } + TYPE_CALENDAR, TYPE_WEBCAL -> { + dav[CalendarDescription::class.java]?.let { description = it.description } + dav[CalendarColor::class.java]?.let { color = it.color } + dav[CalendarTimezone::class.java]?.let { timezone = it.vTimeZone } + + if (type == TYPE_CALENDAR) { + supportsVEVENT = true + supportsVTODO = true + supportsVJOURNAL = true + dav[SupportedCalendarComponentSet::class.java]?.let { + supportsVEVENT = it.supportsEvents + supportsVTODO = it.supportsTasks + supportsVJOURNAL = it.supportsJournal + } + } else { // Type.WEBCAL + dav[Source::class.java]?.let { source = it.hrefs.firstOrNull()?.let { rawHref -> + val href = rawHref + .replace("^webcal://".toRegex(), "http://") + .replace("^webcals://".toRegex(), "https://") + HttpUrl.parse(href) + } } + supportsVEVENT = true + } + } + } + + return Collection( + type = type, + url = url, + privWriteContent = privWriteContent, + privUnbind = privUnbind, + displayName = displayName, + description = description, + color = color, + timezone = timezone, + supportsVEVENT = supportsVEVENT, + supportsVTODO = supportsVTODO, + supportsVJOURNAL = supportsVJOURNAL, + source = source + ) + } + + } + + + // non-persistent properties + @Ignore + var confirmed: Boolean = false + + + // calculated properties + fun title() = displayName ?: DavUtils.lastSegmentOfUrl(url) + fun readOnly() = forceReadOnly || !privWriteContent + +} diff --git a/app/src/main/java/foundation/e/accountmanager/model/CollectionDao.kt b/app/src/main/java/foundation/e/accountmanager/model/CollectionDao.kt new file mode 100644 index 0000000000000000000000000000000000000000..1c6a6a6260586ce7af10a956eefd0636b68102ac --- /dev/null +++ b/app/src/main/java/foundation/e/accountmanager/model/CollectionDao.kt @@ -0,0 +1,43 @@ +package foundation.e.accountmanager.model + +import androidx.lifecycle.LiveData +import androidx.paging.DataSource +import androidx.room.Dao +import androidx.room.Insert +import androidx.room.OnConflictStrategy +import androidx.room.Query + +@Dao +interface CollectionDao: SyncableDao { + + @Query("SELECT * FROM collection WHERE id=:id") + fun get(id: Long): Collection? + + @Query("SELECT * FROM collection WHERE serviceId=:serviceId") + fun getByService(serviceId: Long): List + + @Query("SELECT * FROM collection WHERE serviceId=:serviceId AND type=:type") + fun getByServiceAndType(serviceId: Long, type: String): List + + @Query("SELECT * FROM collection WHERE serviceId=:serviceId AND type=:type ORDER BY displayName, url") + fun pageByServiceAndType(serviceId: Long, type: String): DataSource.Factory + + @Query("SELECT * FROM collection WHERE serviceId=:serviceId AND sync ORDER BY displayName, url") + fun getByServiceAndSync(serviceId: Long): List + + @Query("SELECT COUNT(*) FROM collection WHERE serviceId=:serviceId AND sync") + fun observeHasSyncByService(serviceId: Long): LiveData + + @Query("SELECT * FROM collection WHERE serviceId=:serviceId AND supportsVEVENT AND sync ORDER BY displayName, url") + fun getSyncCalendars(serviceId: Long): List + + @Query("SELECT * FROM collection WHERE serviceId=:serviceId AND supportsVTODO AND sync ORDER BY displayName, url") + fun getSyncTaskLists(serviceId: Long): List + + @Insert(onConflict = OnConflictStrategy.REPLACE) + fun insertOrReplace(collection: Collection) + + @Insert + fun insert(collection: Collection) + +} diff --git a/app/src/main/java/foundation/e/accountmanager/model/Converters.kt b/app/src/main/java/foundation/e/accountmanager/model/Converters.kt new file mode 100644 index 0000000000000000000000000000000000000000..5fe5e21cc5ae3352687c0dc5e194734f9ea5d213 --- /dev/null +++ b/app/src/main/java/foundation/e/accountmanager/model/Converters.kt @@ -0,0 +1,16 @@ +package foundation.e.accountmanager.model + +import androidx.room.TypeConverter +import okhttp3.HttpUrl + +class Converters { + + @TypeConverter + fun httpUrlToString(url: HttpUrl?) = + url?.toString() + + @TypeConverter + fun stringToHttpUrl(url: String?): HttpUrl? = + url?.let { HttpUrl.parse(it) } + +} \ No newline at end of file diff --git a/app/src/main/java/at/bitfire/davdroid/model/Credentials.kt b/app/src/main/java/foundation/e/accountmanager/model/Credentials.kt similarity index 69% rename from app/src/main/java/at/bitfire/davdroid/model/Credentials.kt rename to app/src/main/java/foundation/e/accountmanager/model/Credentials.kt index e2123da5623d4adb8d4a0cbe4aed30e5739c5074..6d941b6dad0a97b1d5158227524de2a4350a9e4b 100644 --- a/app/src/main/java/at/bitfire/davdroid/model/Credentials.kt +++ b/app/src/main/java/foundation/e/accountmanager/model/Credentials.kt @@ -6,18 +6,22 @@ * http://www.gnu.org/licenses/gpl.html */ -package at.bitfire.davdroid.model +package foundation.e.accountmanager.model -import java.io.Serializable +import net.openid.appauth.AuthState +import java.net.URI class Credentials( val userName: String? = null, val password: String? = null, - val certificateAlias: String? = null -): Serializable { + val authState: AuthState? = null, + val certificateAlias: String? = null, + val serverUri: URI? = null +) { enum class Type { UsernamePassword, + OAuth, ClientCertificate } @@ -27,14 +31,16 @@ class Credentials( type = when { !certificateAlias.isNullOrEmpty() -> Type.ClientCertificate + !userName.isNullOrEmpty() && (authState != null) -> + Type.OAuth !userName.isNullOrEmpty() && !password.isNullOrEmpty() -> Type.UsernamePassword else -> - throw IllegalArgumentException("Either username/password or certificate alias must be set") + throw IllegalArgumentException("Invalid account type/credentials") } } override fun toString() = "Credentials(type=$type, userName=$userName, certificateAlias=$certificateAlias)" -} \ No newline at end of file +} diff --git a/app/src/main/java/foundation/e/accountmanager/model/DaoTools.kt b/app/src/main/java/foundation/e/accountmanager/model/DaoTools.kt new file mode 100644 index 0000000000000000000000000000000000000000..e29171b20024c4cff01b62950a3007df0c847687 --- /dev/null +++ b/app/src/main/java/foundation/e/accountmanager/model/DaoTools.kt @@ -0,0 +1,41 @@ +package foundation.e.accountmanager.model + +import foundation.e.accountmanager.log.Logger +import java.util.logging.Level + +class DaoTools(dao: SyncableDao): SyncableDao by dao { + + /** + * Synchronizes a list of "old" elements with a list of "new" elements so that the list + * only contain equal elements. + * + * @param allOld list of old elements + * @param allNew map of new elements (stored in key map) + * @param selectKey generates a unique key from the element (will be called on old elements) + * @param prepareNew prepares new elements (can be used to take over properties of old elements) + */ + fun syncAll(allOld: List, allNew: Map, selectKey: (T) -> K, prepareNew: (new: T, old: T) -> Unit = { _, _ -> }) { + Logger.log.log(Level.FINE, "Syncing tables", arrayOf(allOld, allNew)) + val remainingNew = allNew.toMutableMap() + allOld.forEach { old -> + val key = selectKey(old) + val matchingNew = remainingNew[key] + if (matchingNew != null) { + // keep this old item, but maybe update it + matchingNew.id = old.id // identity is proven by key + prepareNew(matchingNew, old) + + if (matchingNew != old) + update(matchingNew) + + // remove from remainingNew + remainingNew -= key + } else { + // this old item is not present anymore, delete it + delete(old) + } + } + insert(remainingNew.values.toList()) + } + +} \ No newline at end of file diff --git a/app/src/main/java/foundation/e/accountmanager/model/HomeSet.kt b/app/src/main/java/foundation/e/accountmanager/model/HomeSet.kt new file mode 100644 index 0000000000000000000000000000000000000000..7e6f7ab2e5e92498bf8038227506e2119a2e7439 --- /dev/null +++ b/app/src/main/java/foundation/e/accountmanager/model/HomeSet.kt @@ -0,0 +1,28 @@ +package foundation.e.accountmanager.model + +import androidx.room.Entity +import androidx.room.ForeignKey +import androidx.room.Index +import androidx.room.PrimaryKey +import okhttp3.HttpUrl + +@Entity(tableName = "homeset", + foreignKeys = [ + ForeignKey(entity = Service::class, parentColumns = arrayOf("id"), childColumns = arrayOf("serviceId"), onDelete = ForeignKey.CASCADE) + ], + indices = [ + // index by service; no duplicate URLs per service + Index("serviceId", "url", unique = true) + ] +) +data class HomeSet( + @PrimaryKey(autoGenerate = true) + override var id: Long, + + var serviceId: Long, + var url: HttpUrl, + + var privBind: Boolean = true, + + var displayName: String? = null +): IdEntity() \ No newline at end of file diff --git a/app/src/main/java/foundation/e/accountmanager/model/HomeSetDao.kt b/app/src/main/java/foundation/e/accountmanager/model/HomeSetDao.kt new file mode 100644 index 0000000000000000000000000000000000000000..80ac89fa718e8dc8e1c5b44f88226f9f272c272d --- /dev/null +++ b/app/src/main/java/foundation/e/accountmanager/model/HomeSetDao.kt @@ -0,0 +1,21 @@ +package foundation.e.accountmanager.model + +import androidx.room.Dao +import androidx.room.Insert +import androidx.room.OnConflictStrategy +import androidx.room.Query + +@Dao +interface HomeSetDao: SyncableDao { + + @Query("SELECT * FROM homeset WHERE serviceId=:serviceId") + fun getByService(serviceId: Long): List + + @Query("SELECT * FROM homeset WHERE serviceId=:serviceId AND privBind") + fun getBindableByService(serviceId: Long): List + + @Insert(onConflict = OnConflictStrategy.REPLACE) + fun insertOrReplace(homeSet: HomeSet): Long + + +} \ No newline at end of file diff --git a/app/src/main/java/foundation/e/accountmanager/model/IdEntity.kt b/app/src/main/java/foundation/e/accountmanager/model/IdEntity.kt new file mode 100644 index 0000000000000000000000000000000000000000..d2033741d3b7c46fd83a9b4baa3a3ddcd8d81b1a --- /dev/null +++ b/app/src/main/java/foundation/e/accountmanager/model/IdEntity.kt @@ -0,0 +1,5 @@ +package foundation.e.accountmanager.model + +abstract class IdEntity { + abstract var id: Long +} \ No newline at end of file diff --git a/app/src/main/java/foundation/e/accountmanager/model/Service.kt b/app/src/main/java/foundation/e/accountmanager/model/Service.kt new file mode 100644 index 0000000000000000000000000000000000000000..0a4d880f3b0842c90723a36faf68f6c33b4e3032 --- /dev/null +++ b/app/src/main/java/foundation/e/accountmanager/model/Service.kt @@ -0,0 +1,32 @@ +package foundation.e.accountmanager.model + +import androidx.room.Entity +import androidx.room.Index +import androidx.room.PrimaryKey +import okhttp3.HttpUrl + +@Entity(tableName = "service", + indices = [ + // only one service per type and account + Index("accountName", "type", unique = true) + ]) +data class Service( + @PrimaryKey(autoGenerate = true) + var id: Long, + + var accountName: String, + + var authState: String?, + var accountType: String?, + var addressBookAccountType: String?, + + var type: String, + var principal: HttpUrl? +) { + + companion object { + const val TYPE_CALDAV = "caldav" + const val TYPE_CARDDAV = "carddav" + } + +} diff --git a/app/src/main/java/foundation/e/accountmanager/model/ServiceDao.kt b/app/src/main/java/foundation/e/accountmanager/model/ServiceDao.kt new file mode 100644 index 0000000000000000000000000000000000000000..55e82cd930a5ccfc6d3e3e32266e6936731c56cb --- /dev/null +++ b/app/src/main/java/foundation/e/accountmanager/model/ServiceDao.kt @@ -0,0 +1,38 @@ +package foundation.e.accountmanager.model + +import androidx.room.Dao +import androidx.room.Insert +import androidx.room.OnConflictStrategy +import androidx.room.Query + +@Dao +interface ServiceDao { + + @Query("SELECT * FROM service WHERE accountName=:accountName AND type=:type") + fun getByAccountAndType(accountName: String, type: String): Service? + + @Query("SELECT id FROM service WHERE accountName=:accountName AND type=:type") + fun getIdByAccountAndType(accountName: String, type: String): Long? + + @Query("SELECT * FROM service WHERE id=:id") + fun get(id: Long): Service? + + @Query("SELECT * FROM service WHERE type=:type") + fun getByType(type: String): List + + @Query("SELECT * FROM service WHERE accountName=:accountName") + fun getByAccountName(accountName: String): Service? + + @Insert(onConflict = OnConflictStrategy.REPLACE) + fun insertOrReplace(service: Service): Long + + @Query("DELETE FROM service") + fun deleteAll() + + @Query("DELETE FROM service WHERE accountName NOT IN (:accountNames)") + fun deleteExceptAccounts(accountNames: Array) + + @Query("UPDATE service SET accountName=:newName WHERE accountName=:oldName") + fun renameAccount(oldName: String, newName: String) + +} diff --git a/app/src/main/java/at/bitfire/davdroid/model/SyncState.kt b/app/src/main/java/foundation/e/accountmanager/model/SyncState.kt similarity index 95% rename from app/src/main/java/at/bitfire/davdroid/model/SyncState.kt rename to app/src/main/java/foundation/e/accountmanager/model/SyncState.kt index 57e6ceea49a8d7843ed314df41d287e10ce2c290..dfb6051abe4932c678fda934ee910e8b4a61ad7b 100644 --- a/app/src/main/java/at/bitfire/davdroid/model/SyncState.kt +++ b/app/src/main/java/foundation/e/accountmanager/model/SyncState.kt @@ -6,9 +6,9 @@ * http://www.gnu.org/licenses/gpl.html */ -package at.bitfire.davdroid.model +package foundation.e.accountmanager.model -import at.bitfire.dav4android.property.SyncToken +import foundation.e.dav4jvm.property.SyncToken import org.json.JSONException import org.json.JSONObject diff --git a/app/src/main/java/foundation/e/accountmanager/model/SyncableDao.kt b/app/src/main/java/foundation/e/accountmanager/model/SyncableDao.kt new file mode 100644 index 0000000000000000000000000000000000000000..fffa0557a97b4ee38515510b3b0d519eb221c1df --- /dev/null +++ b/app/src/main/java/foundation/e/accountmanager/model/SyncableDao.kt @@ -0,0 +1,18 @@ +package foundation.e.accountmanager.model + +import androidx.room.Delete +import androidx.room.Insert +import androidx.room.Update + +interface SyncableDao { + + @Insert + fun insert(items: List) + + @Update + fun update(item: T) + + @Delete + fun delete(item: T) + +} \ No newline at end of file diff --git a/app/src/main/java/at/bitfire/davdroid/model/UnknownProperties.kt b/app/src/main/java/foundation/e/accountmanager/model/UnknownProperties.kt similarity index 93% rename from app/src/main/java/at/bitfire/davdroid/model/UnknownProperties.kt rename to app/src/main/java/foundation/e/accountmanager/model/UnknownProperties.kt index f53654bb66a83e4b894d0848e2a9ea7115c33037..782f075cb43a486bccd602bdb491c836e5a13f30 100644 --- a/app/src/main/java/at/bitfire/davdroid/model/UnknownProperties.kt +++ b/app/src/main/java/foundation/e/accountmanager/model/UnknownProperties.kt @@ -6,7 +6,7 @@ * http://www.gnu.org/licenses/gpl.html */ -package at.bitfire.davdroid.model +package foundation.e.accountmanager.model import android.provider.ContactsContract.RawContacts diff --git a/app/src/main/java/at/bitfire/davdroid/resource/LocalAddress.kt b/app/src/main/java/foundation/e/accountmanager/resource/LocalAddress.kt similarity index 81% rename from app/src/main/java/at/bitfire/davdroid/resource/LocalAddress.kt rename to app/src/main/java/foundation/e/accountmanager/resource/LocalAddress.kt index 76062a01c06696ed39fe6f0d9855e85ec57bd0dd..ac9110cad9d833763b37a95bbaa771bb0574507e 100644 --- a/app/src/main/java/at/bitfire/davdroid/resource/LocalAddress.kt +++ b/app/src/main/java/foundation/e/accountmanager/resource/LocalAddress.kt @@ -6,9 +6,9 @@ * http://www.gnu.org/licenses/gpl.html */ -package at.bitfire.davdroid.resource +package foundation.e.accountmanager.resource -import at.bitfire.vcard4android.Contact +import foundation.e.vcard4android.Contact interface LocalAddress: LocalResource { diff --git a/app/src/main/java/at/bitfire/davdroid/resource/LocalAddressBook.kt b/app/src/main/java/foundation/e/accountmanager/resource/LocalAddressBook.kt similarity index 68% rename from app/src/main/java/at/bitfire/davdroid/resource/LocalAddressBook.kt rename to app/src/main/java/foundation/e/accountmanager/resource/LocalAddressBook.kt index 5cfa048d8ae1cb8cf55b9c5caf77449722cab9cc..5abbce9b8254d0b10e34a8d75cff04e3e66f588f 100644 --- a/app/src/main/java/at/bitfire/davdroid/resource/LocalAddressBook.kt +++ b/app/src/main/java/foundation/e/accountmanager/resource/LocalAddressBook.kt @@ -5,11 +5,10 @@ * which accompanies this distribution, and is available at * http://www.gnu.org/licenses/gpl.html */ -package at.bitfire.davdroid.resource +package foundation.e.accountmanager.resource import android.accounts.Account import android.accounts.AccountManager -import android.annotation.TargetApi import android.content.* import android.os.Build import android.os.Bundle @@ -19,21 +18,22 @@ import android.provider.ContactsContract.CommonDataKinds.GroupMembership import android.provider.ContactsContract.Groups import android.provider.ContactsContract.RawContacts import android.util.Base64 -import at.bitfire.davdroid.DavUtils -import at.bitfire.davdroid.R -import at.bitfire.davdroid.log.Logger -import at.bitfire.davdroid.model.CollectionInfo -import at.bitfire.davdroid.model.SyncState -import at.bitfire.vcard4android.* +import foundation.e.accountmanager.DavUtils +import foundation.e.accountmanager.R +import foundation.e.accountmanager.log.Logger +import foundation.e.accountmanager.model.Collection +import foundation.e.accountmanager.model.AppDatabase +import foundation.e.accountmanager.model.SyncState +import foundation.e.vcard4android.* import java.io.ByteArrayOutputStream 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 + * account and there is no such thing as "address books". So, DAVx5 creates a "DAVx5 * address book" account for every CardDAV address book. These accounts are bound to a - * DAVdroid main account. + * DAVx5 main account. */ class LocalAddressBook( private val context: Context, @@ -48,13 +48,27 @@ class LocalAddressBook( const val USER_DATA_URL = "url" const val USER_DATA_READ_ONLY = "read_only" - fun create(context: Context, provider: ContentProviderClient, mainAccount: Account, info: CollectionInfo): LocalAddressBook { + fun create(context: Context, provider: ContentProviderClient, mainAccount: Account, info: Collection): LocalAddressBook { val accountManager = AccountManager.get(context) + + var account = Account(accountName(mainAccount, info), context.getString(R.string.account_type_address_book)) - val account = Account(accountName(mainAccount, info), context.getString(R.string.account_type_address_book)) - if (!accountManager.addAccountExplicitly(account, null, initialUserData(mainAccount, info.url.toString()))) + val db = AppDatabase.getInstance(context) + val service = db.serviceDao().getByAccountName(mainAccount.name) ?: throw IllegalArgumentException("Service not found") + account = Account(accountName(mainAccount, info), service.addressBookAccountType) + + val userData = initialUserData(mainAccount, info.url.toString()) + Logger.log.log(Level.INFO, "Creating local address book $account", userData) + if (!accountManager.addAccountExplicitly(account, null, userData)) throw IllegalStateException("Couldn't create address book account") + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.N) + // Android < 7 seems to lose the initial user data sometimes, so set it a second time + // https://forums.bitfire.at/post/11644 + userData.keySet().forEach { key -> + accountManager.setUserData(account, key, userData.getString(key)) + } + val addressBook = LocalAddressBook(context, account, provider) ContentResolver.setSyncAutomatically(account, ContactsContract.AUTHORITY, true) @@ -63,22 +77,34 @@ class LocalAddressBook( values.put(ContactsContract.Settings.SHOULD_SYNC, 1) values.put(ContactsContract.Settings.UNGROUPED_VISIBLE, 1) addressBook.settings = values + addressBook.readOnly = !info.privWriteContent || info.forceReadOnly return addressBook } + + fun findAll(context: Context, provider: ContentProviderClient?, mainAccount: Account?): List { + val accountManager = AccountManager.get(context) + val accounts = ArrayList() + accountManager.getAccountsByType(context.getString(R.string.account_type_eelo_address_book)).forEach { accounts.add(it) } + accountManager.getAccountsByType(context.getString(R.string.account_type_google_address_book)).forEach { accounts.add(it) } + accountManager.getAccountsByType(context.getString(R.string.account_type_address_book)).forEach { accounts.add(it) } - 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) } + return accounts.toTypedArray().map { LocalAddressBook(context, it, provider) } .filter { mainAccount == null || it.mainAccount == mainAccount } .toList() + } - fun accountName(mainAccount: Account, info: CollectionInfo): String { + fun accountName(mainAccount: Account, info: Collection): String { val baos = ByteArrayOutputStream() baos.write(info.url.hashCode()) val hash = Base64.encodeToString(baos.toByteArray(), Base64.NO_WRAP or Base64.NO_PADDING) - val sb = StringBuilder(if (info.displayName.isNullOrEmpty()) DavUtils.lastSegmentOfUrl(info.url) else info.displayName) + val sb = StringBuilder(info.displayName.let { + if (it.isNullOrEmpty()) + DavUtils.lastSegmentOfUrl(info.url) + else + it + }) sb.append(" (${mainAccount.name} $hash)") return sb.toString() } @@ -92,7 +118,10 @@ class LocalAddressBook( } fun mainAccount(context: Context, account: Account): Account = - if (account.type == context.getString(R.string.account_type_address_book)) { + if (account.type == context.getString(R.string.account_type_address_book) || + account.type == context.getString(R.string.account_type_eelo_address_book) || + account.type == context.getString(R.string.account_type_google_address_book) + ) { val manager = AccountManager.get(context) Account( manager.getUserData(account, USER_DATA_MAIN_ACCOUNT_NAME), @@ -125,7 +154,7 @@ class LocalAddressBook( if (name != null && type != null) return Account(name, type) else - throw IllegalStateException("Address book doesn't exist anymore") + throw IllegalStateException("No main account assigned to address book account") } } set(newMainAccount) { @@ -142,7 +171,7 @@ class LocalAddressBook( ?: throw IllegalStateException("Address book has no URL") set(url) = AccountManager.get(context).setUserData(account, USER_DATA_URL, url) - var readOnly: Boolean + override var readOnly: Boolean get() = AccountManager.get(context).getUserData(account, USER_DATA_READ_ONLY) != null set(readOnly) = AccountManager.get(context).setUserData(account, USER_DATA_READ_ONLY, if (readOnly) "1" else null) @@ -163,7 +192,7 @@ class LocalAddressBook( if (includeGroups) { values.clear() values.put(LocalGroup.COLUMN_FLAGS, flags) - number += provider.update(groupsSyncUri(), values, "${Groups.DIRTY}=0", null) + number += provider.update(groupsSyncUri(), values, "NOT ${Groups.DIRTY}", null) } return number @@ -171,19 +200,18 @@ class LocalAddressBook( override fun removeNotDirtyMarked(flags: Int): Int { var number = provider!!.delete(rawContactsSyncUri(), - "${RawContacts.DIRTY}=0 AND ${LocalContact.COLUMN_FLAGS}=?", arrayOf(flags.toString())) + "NOT ${RawContacts.DIRTY} AND ${LocalContact.COLUMN_FLAGS}=?", arrayOf(flags.toString())) if (includeGroups) number += provider.delete(groupsSyncUri(), - "${Groups.DIRTY}=0 AND ${LocalGroup.COLUMN_FLAGS}=?", arrayOf(flags.toString())) + "NOT ${Groups.DIRTY} AND ${LocalGroup.COLUMN_FLAGS}=?", arrayOf(flags.toString())) return number } - fun update(info: CollectionInfo) { + fun update(info: Collection) { val newAccountName = accountName(mainAccount, info) - @TargetApi(Build.VERSION_CODES.LOLLIPOP) if (account.name != newAccountName && Build.VERSION.SDK_INT >= 21) { // no need to re-assign contacts to new account, because they will be deleted by contacts provider in any case val accountManager = AccountManager.get(context) @@ -191,8 +219,23 @@ class LocalAddressBook( account = future.result } - Constants.log.info("Address book write permission? = ${info.privWriteContent}") - readOnly = !info.privWriteContent || info.forceReadOnly + val nowReadOnly = !info.privWriteContent || info.forceReadOnly + if (nowReadOnly != readOnly) { + Constants.log.info("Address book now read-only = $nowReadOnly, updating contacts") + + // update address book itself + readOnly = nowReadOnly + + // update raw contacts + val rawContactValues = ContentValues(1) + rawContactValues.put(RawContacts.RAW_CONTACT_IS_READ_ONLY, if (nowReadOnly) 1 else 0) + provider!!.update(rawContactsSyncUri(), rawContactValues, null, null) + + // update data rows + val dataValues = ContentValues(1) + dataValues.put(ContactsContract.Data.IS_READ_ONLY, if (nowReadOnly) 1 else 0) + provider.update(syncAdapterURI(ContactsContract.Data.CONTENT_URI), dataValues, null, null) + } // make sure it will still be synchronized when contacts are updated ContentResolver.setSyncAutomatically(account, ContactsContract.AUTHORITY, true) @@ -200,6 +243,7 @@ class LocalAddressBook( fun delete() { val accountManager = AccountManager.get(context) + @Suppress("DEPRECATION") if (Build.VERSION.SDK_INT >= 22) accountManager.removeAccount(account, null, null, null) else @@ -228,8 +272,8 @@ class LocalAddressBook( else findDeletedContacts() - fun findDeletedContacts() = queryContacts("${RawContacts.DELETED}!=0", null) - fun findDeletedGroups() = queryGroups("${Groups.DELETED}!=0", null) + fun findDeletedContacts() = queryContacts(RawContacts.DELETED, null) + fun findDeletedGroups() = queryGroups(Groups.DELETED, null) /** * Returns an array of local contacts/groups which have been changed locally (DIRTY != 0). @@ -240,16 +284,30 @@ class LocalAddressBook( findDirtyContacts() + findDirtyGroups() else findDirtyContacts() + fun findDirtyContacts() = queryContacts(RawContacts.DIRTY, null) + fun findDirtyGroups() = queryGroups(Groups.DIRTY, null) - fun findDirtyContacts() = queryContacts("${RawContacts.DIRTY}!=0", null) - fun findDirtyGroups() = queryGroups("${Groups.DIRTY}!=0", null) - - private fun queryContactsGroups(whereContacts: String?, whereArgsContacts: Array?, whereGroups: String?, whereArgsGroups: Array?): List { - val contacts = queryContacts(whereContacts, whereArgsContacts) - return if (includeGroups) - contacts + queryGroups(whereGroups, whereArgsGroups) - else - contacts + override fun findDirtyWithoutNameOrUid() = + if (includeGroups) + findDirtyContactsWithoutNameOrUid() + findDirtyGroupsWithoutNameOrUid() + else + findDirtyContactsWithoutNameOrUid() + private fun findDirtyContactsWithoutNameOrUid() = queryContacts( + "${RawContacts.DIRTY} AND (${AndroidContact.COLUMN_FILENAME} IS NULL OR ${AndroidContact.COLUMN_UID} IS NULL)", + null) + private fun findDirtyGroupsWithoutNameOrUid() = queryGroups( + "${Groups.DIRTY} AND (${AndroidGroup.COLUMN_FILENAME} IS NULL OR ${AndroidGroup.COLUMN_UID} IS NULL)", + null) + + override fun forgetETags() { + if (includeGroups) { + val values = ContentValues(1) + values.putNull(AndroidGroup.COLUMN_ETAG) + provider!!.update(groupsSyncUri(), values, null, null) + } + val values = ContentValues(1) + values.putNull(AndroidContact.COLUMN_ETAG) + provider!!.update(rawContactsSyncUri(), values, null, null) } @@ -318,7 +376,7 @@ class LocalAddressBook( val values = ContentValues(1) values.put(Groups.TITLE, title) - val uri = provider.insert(syncAdapterURI(Groups.CONTENT_URI), values) + val uri = provider.insert(syncAdapterURI(Groups.CONTENT_URI), values) ?: throw RemoteException("Couldn't create contact group") return ContentUris.parseId(uri) } @@ -331,4 +389,4 @@ class LocalAddressBook( } } -} \ No newline at end of file +} diff --git a/app/src/main/java/at/bitfire/davdroid/resource/LocalCalendar.kt b/app/src/main/java/foundation/e/accountmanager/resource/LocalCalendar.kt similarity index 80% rename from app/src/main/java/at/bitfire/davdroid/resource/LocalCalendar.kt rename to app/src/main/java/foundation/e/accountmanager/resource/LocalCalendar.kt index 1ff5dc600d5d31e2b9b49f3233b718351f80e6ba..be34631cc4d1fe1ea4c4147003e9f96fdbd7f205 100644 --- a/app/src/main/java/at/bitfire/davdroid/resource/LocalCalendar.kt +++ b/app/src/main/java/foundation/e/accountmanager/resource/LocalCalendar.kt @@ -6,7 +6,7 @@ * http://www.gnu.org/licenses/gpl.html */ -package at.bitfire.davdroid.resource +package foundation.e.accountmanager.resource import android.accounts.Account import android.content.ContentProviderClient @@ -14,17 +14,17 @@ import android.content.ContentProviderOperation import android.content.ContentUris import android.content.ContentValues import android.net.Uri -import android.provider.CalendarContract -import android.provider.CalendarContract.* -import 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.AndroidCalendar -import at.bitfire.ical4android.AndroidCalendarFactory -import at.bitfire.ical4android.BatchOperation -import at.bitfire.ical4android.DateUtils +import android.provider.CalendarContract.Calendars +import android.provider.CalendarContract.Events +import foundation.e.accountmanager.Constants +import foundation.e.accountmanager.DavUtils +import foundation.e.accountmanager.log.Logger +import foundation.e.accountmanager.model.Collection +import foundation.e.accountmanager.model.SyncState +import foundation.e.ical4android.AndroidCalendar +import foundation.e.ical4android.AndroidCalendarFactory +import foundation.e.ical4android.BatchOperation +import foundation.e.ical4android.DateUtils import java.util.* import java.util.logging.Level @@ -38,7 +38,7 @@ class LocalCalendar private constructor( private const val COLUMN_SYNC_STATE = Calendars.CAL_SYNC1 - fun create(account: Account, provider: ContentProviderClient, info: CollectionInfo): Uri { + fun create(account: Account, provider: ContentProviderClient, info: Collection): Uri { val values = valuesFromCollectionInfo(info, true) // ACCOUNT_NAME and ACCOUNT_TYPE are required (see docs)! If it's missing, other apps will crash. @@ -52,7 +52,7 @@ class LocalCalendar private constructor( return create(account, provider, values) } - private fun valuesFromCollectionInfo(info: CollectionInfo, withColor: Boolean): ContentValues { + private fun valuesFromCollectionInfo(info: Collection, withColor: Boolean): ContentValues { val values = ContentValues() values.put(Calendars.NAME, info.url.toString()) values.put(Calendars.CALENDAR_DISPLAY_NAME, if (info.displayName.isNullOrBlank()) DavUtils.lastSegmentOfUrl(info.url) else info.displayName) @@ -67,7 +67,7 @@ class LocalCalendar private constructor( } else values.put(Calendars.CALENDAR_ACCESS_LEVEL, Calendars.CAL_ACCESS_READ) - info.timeZone?.let { tzData -> + info.timezone?.let { tzData -> try { val timeZone = DateUtils.parseVTimeZone(tzData) timeZone.timeZoneId?.let { tzId -> @@ -77,9 +77,10 @@ class LocalCalendar private constructor( Logger.log.log(Level.WARNING, "Couldn't parse calendar default time zone", e) } } - values.put(Calendars.ALLOWED_REMINDERS, "${Reminders.METHOD_ALERT},${Reminders.METHOD_EMAIL}") - values.put(Calendars.ALLOWED_AVAILABILITY, "${Reminders.AVAILABILITY_TENTATIVE},${Reminders.AVAILABILITY_FREE},${Reminders.AVAILABILITY_BUSY}") - values.put(Calendars.ALLOWED_ATTENDEE_TYPES, "${CalendarContract.Attendees.TYPE_OPTIONAL},${CalendarContract.Attendees.TYPE_REQUIRED},${CalendarContract.Attendees.TYPE_RESOURCE}") + + // add base values for Calendars + values.putAll(calendarBaseValues) + return values } } @@ -88,7 +89,7 @@ class LocalCalendar private constructor( get() = displayName ?: id.toString() override var lastSyncState: SyncState? - get() = provider.query(calendarSyncURI(), arrayOf(COLUMN_SYNC_STATE), null, null, null)?.let { cursor -> + get() = provider.query(calendarSyncURI(), arrayOf(COLUMN_SYNC_STATE), null, null, null)?.use { cursor -> if (cursor.moveToNext()) return SyncState.fromString(cursor.getString(0)) else @@ -101,18 +102,18 @@ class LocalCalendar private constructor( } - fun update(info: CollectionInfo, updateColor: Boolean) = + fun update(info: Collection, updateColor: Boolean) = update(valuesFromCollectionInfo(info, updateColor)) override fun findDeleted() = - queryEvents("${Events.DELETED}!=0 AND ${Events.ORIGINAL_ID} IS NULL", null) + queryEvents("${Events.DELETED} AND ${Events.ORIGINAL_ID} IS NULL", null) override fun findDirty(): List { val dirty = LinkedList() // get dirty events which are required to have an increased SEQUENCE value - for (localEvent in queryEvents("${Events.DIRTY}!=0 AND ${Events.ORIGINAL_ID} IS NULL", null)) { + for (localEvent in queryEvents("${Events.DIRTY} AND ${Events.ORIGINAL_ID} IS NULL", null)) { val event = localEvent.event!! val sequence = event.sequence if (event.sequence == null) // sequence has not been assigned yet (i.e. this event was just locally created) @@ -125,6 +126,10 @@ class LocalCalendar private constructor( return dirty } + override fun findDirtyWithoutNameOrUid() = + queryEvents("${Events.DIRTY} AND ${Events.ORIGINAL_ID} IS NULL AND " + + "(${Events._SYNC_ID} IS NULL OR ${Events.UID_2445} IS NULL)", null) + override fun findByName(name: String) = queryEvents("${Events._SYNC_ID}=?", arrayOf(name)).firstOrNull() @@ -133,15 +138,22 @@ class LocalCalendar private constructor( val values = ContentValues(1) values.put(LocalEvent.COLUMN_FLAGS, flags) return provider.update(eventsSyncURI(), values, - "${Events.CALENDAR_ID}=? AND ${Events.DIRTY}=0 AND ${Events.ORIGINAL_ID} IS NULL", + "${Events.CALENDAR_ID}=? AND NOT ${Events.DIRTY} AND ${Events.ORIGINAL_ID} IS NULL", arrayOf(id.toString())) } override fun removeNotDirtyMarked(flags: Int) = provider.delete(eventsSyncURI(), - "${Events.CALENDAR_ID}=? AND ${Events.DIRTY}=0 AND ${Events.ORIGINAL_ID} IS NULL AND ${LocalEvent.COLUMN_FLAGS}=?", + "${Events.CALENDAR_ID}=? AND NOT ${Events.DIRTY} AND ${Events.ORIGINAL_ID} IS NULL AND ${LocalEvent.COLUMN_FLAGS}=?", arrayOf(id.toString(), flags.toString())) + override fun forgetETags() { + val values = ContentValues(1) + values.putNull(LocalEvent.COLUMN_ETAG) + provider.update(eventsSyncURI(), values, "${Events.CALENDAR_ID}=?", + arrayOf(id.toString())) + } + fun processDirtyExceptions() { // process deleted exceptions @@ -149,7 +161,7 @@ class LocalCalendar private constructor( provider.query( syncAdapterURI(Events.CONTENT_URI), arrayOf(Events._ID, Events.ORIGINAL_ID, LocalEvent.COLUMN_SEQUENCE), - "${Events.CALENDAR_ID}=? AND ${Events.DELETED}!=0 AND ${Events.ORIGINAL_ID} IS NOT NULL", + "${Events.CALENDAR_ID}=? AND ${Events.DELETED} AND ${Events.ORIGINAL_ID} IS NOT NULL", arrayOf(id.toString()), null)?.use { cursor -> while (cursor.moveToNext()) { Logger.log.fine("Found deleted exception, removing and re-scheduling original event (if available)") @@ -189,7 +201,7 @@ class LocalCalendar private constructor( provider.query( syncAdapterURI(Events.CONTENT_URI), arrayOf(Events._ID, Events.ORIGINAL_ID, LocalEvent.COLUMN_SEQUENCE), - "${Events.CALENDAR_ID}=? AND ${Events.DIRTY}!=0 AND ${Events.ORIGINAL_ID} IS NOT NULL", + "${Events.CALENDAR_ID}=? AND ${Events.DIRTY} AND ${Events.ORIGINAL_ID} IS NOT NULL", arrayOf(id.toString()), null)?.use { cursor -> while (cursor.moveToNext()) { Logger.log.fine("Found dirty exception, increasing SEQUENCE to re-schedule") diff --git a/app/src/main/java/foundation/e/accountmanager/resource/LocalCollection.kt b/app/src/main/java/foundation/e/accountmanager/resource/LocalCollection.kt new file mode 100644 index 0000000000000000000000000000000000000000..c88fe8b33926d51b0fbdde0ceb0079121a87cf5c --- /dev/null +++ b/app/src/main/java/foundation/e/accountmanager/resource/LocalCollection.kt @@ -0,0 +1,85 @@ +/* + * 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 foundation.e.accountmanager.resource + +import android.provider.CalendarContract.Events +import foundation.e.accountmanager.model.SyncState + +interface LocalCollection> { + + /** collection title (used for user notifications etc.) **/ + val title: String + + var lastSyncState: SyncState? + + /** + * Finds local resources of this collection which have been marked as *deleted* by the user + * or an app acting on their behalf. + * + * @return list of resources marked as *deleted* + */ + fun findDeleted(): List + + /** + * Finds local resources of this collection which have been marked as *dirty*, i.e. resources + * which have been modified by the user or an app acting on their behalf. + * + * @return list of resources marked as *dirty* + */ + fun findDirty(): List + + /** + * Finds local resources of this collection which do not have a file name and/or UID, but + * need one for synchronization. + * + * For instance, exceptions of recurring events are local resources but do not need their + * own file name/UID because they're sent with the same UID as the main event. + * + * @return list of resources which need file name and UID for synchronization, but don't have both of them + */ + fun findDirtyWithoutNameOrUid(): List + + /** + * Finds a local resource of this collection with a given file name. (File names are assigned + * by the sync adapter.) + * + * @param name file name to look for + * @return resource with the given name, or null if none + */ + fun findByName(name: String): T? + + + /** + * Sets the [LocalEvent.COLUMN_FLAGS] value for entries which are not dirty ([Events.DIRTY] is 0) + * and have an [Events.ORIGINAL_ID] of null. + * + * @param flags value of flags to set (for instance, [LocalResource.FLAG_REMOTELY_PRESENT]]) + * + * @return number of marked entries + */ + fun markNotDirty(flags: Int): Int + + /** + * Removes entries which are not dirty ([Events.DIRTY] is 0 and an [Events.ORIGINAL_ID] is null) with + * a given flag combination. + * + * @param flags exact flags value to remove entries with (for instance, if this is [LocalResource.FLAG_REMOTELY_PRESENT]], + * all entries with exactly this flag will be removed) + * + * @return number of removed entries + */ + fun removeNotDirtyMarked(flags: Int): Int + + + /** + * Forgets the ETags of all members so that they will be reloaded from the server during sync. + */ + fun forgetETags() + +} diff --git a/app/src/main/java/at/bitfire/davdroid/resource/LocalContact.kt b/app/src/main/java/foundation/e/accountmanager/resource/LocalContact.kt similarity index 95% rename from app/src/main/java/at/bitfire/davdroid/resource/LocalContact.kt rename to app/src/main/java/foundation/e/accountmanager/resource/LocalContact.kt index 6936568871f6bd989dec3b05a0387b0d38cd2254..6cca0dc23fed4e703910719fbef413ab070ab11e 100644 --- a/app/src/main/java/at/bitfire/davdroid/resource/LocalContact.kt +++ b/app/src/main/java/foundation/e/accountmanager/resource/LocalContact.kt @@ -6,7 +6,7 @@ * http://www.gnu.org/licenses/gpl.html */ -package at.bitfire.davdroid.resource +package foundation.e.accountmanager.resource import android.content.ContentProviderOperation import android.content.ContentValues @@ -15,10 +15,10 @@ import android.os.RemoteException import android.provider.ContactsContract import android.provider.ContactsContract.CommonDataKinds.GroupMembership import android.provider.ContactsContract.RawContacts.Data -import at.bitfire.davdroid.BuildConfig -import at.bitfire.davdroid.log.Logger -import at.bitfire.davdroid.model.UnknownProperties -import at.bitfire.vcard4android.* +import foundation.e.accountmanager.BuildConfig +import foundation.e.accountmanager.log.Logger +import foundation.e.accountmanager.model.UnknownProperties +import foundation.e.vcard4android.* import ezvcard.Ezvcard import java.io.FileNotFoundException import java.util.* @@ -27,7 +27,7 @@ class LocalContact: AndroidContact, LocalAddress { companion object { init { - Contact.productID = "+//IDN bitfire.at//DAVdroid/" + BuildConfig.VERSION_NAME + " ez-vcard/" + Ezvcard.VERSION + Contact.productID = "+//IDN bitfire.at//${BuildConfig.userAgent}/${BuildConfig.VERSION_NAME} ez-vcard/" + Ezvcard.VERSION } const val COLUMN_FLAGS = ContactsContract.RawContacts.SYNC4 @@ -93,7 +93,7 @@ class LocalContact: AndroidContact, LocalAddress { override fun updateFlags(flags: Int) { val values = ContentValues(1) - values.put(LocalContact.COLUMN_FLAGS, flags) + values.put(COLUMN_FLAGS, flags) addressBook.provider!!.update(rawContactSyncURI(), values, null, null) this.flags = flags @@ -214,7 +214,7 @@ class LocalContact: AndroidContact, LocalAddress { /** * Returns the IDs of all groups the contact was member of (cached memberships). - * Cached memberships are kept in sync with memberships by DAVdroid and are used to determine + * Cached memberships are kept in sync with memberships by DAVx5 and are used to determine * whether a membership has been deleted/added when a raw contact is dirty. * @return set of {@link GroupMembership#GROUP_ROW_ID} (may be empty) * @throws FileNotFoundException if the current contact can't be found diff --git a/app/src/main/java/at/bitfire/davdroid/resource/LocalEvent.kt b/app/src/main/java/foundation/e/accountmanager/resource/LocalEvent.kt similarity index 83% rename from app/src/main/java/at/bitfire/davdroid/resource/LocalEvent.kt rename to app/src/main/java/foundation/e/accountmanager/resource/LocalEvent.kt index 696e4153777689dcff9682d35b14d5f2f3583bec..dff4eda28ad96a2962bf58b166f38301c50505eb 100644 --- a/app/src/main/java/at/bitfire/davdroid/resource/LocalEvent.kt +++ b/app/src/main/java/foundation/e/accountmanager/resource/LocalEvent.kt @@ -6,14 +6,13 @@ * http://www.gnu.org/licenses/gpl.html */ -package at.bitfire.davdroid.resource +package foundation.e.accountmanager.resource import android.content.ContentProviderOperation import android.content.ContentValues -import android.provider.CalendarContract import android.provider.CalendarContract.Events -import at.bitfire.davdroid.BuildConfig -import at.bitfire.ical4android.* +import foundation.e.accountmanager.BuildConfig +import foundation.e.ical4android.* import net.fortuna.ical4j.model.property.ProdId import java.util.* @@ -21,12 +20,12 @@ class LocalEvent: AndroidEvent, LocalResource { companion object { init { - ICalendar.prodId = ProdId("+//IDN bitfire.at//DAVdroid/" + BuildConfig.VERSION_NAME + " ical4j/" + Constants.ical4jVersion) + ICalendar.prodId = ProdId("+//IDN bitfire.at//${BuildConfig.userAgent}/${BuildConfig.VERSION_NAME} ical4j/" + Constants.ical4jVersion) } - const val COLUMN_ETAG = CalendarContract.Events.SYNC_DATA1 - const val COLUMN_FLAGS = CalendarContract.Events.SYNC_DATA2 - const val COLUMN_SEQUENCE = CalendarContract.Events.SYNC_DATA3 + const val COLUMN_ETAG = Events.SYNC_DATA1 + const val COLUMN_FLAGS = Events.SYNC_DATA2 + const val COLUMN_SEQUENCE = Events.SYNC_DATA3 } override var fileName: String? = null @@ -72,9 +71,9 @@ class LocalEvent: AndroidEvent, LocalResource { builder .withValue(Events.UID_2445, event.uid) .withValue(COLUMN_SEQUENCE, eventToBuild.sequence) - .withValue(CalendarContract.Events.DIRTY, 0) - .withValue(CalendarContract.Events.DELETED, 0) - .withValue(LocalEvent.COLUMN_FLAGS, flags) + .withValue(Events.DIRTY, 0) + .withValue(Events.DELETED, 0) + .withValue(COLUMN_FLAGS, flags) if (buildException) builder .withValue(Events.ORIGINAL_SYNC_ID, fileName) @@ -106,7 +105,7 @@ class LocalEvent: AndroidEvent, LocalResource { override fun clearDirty(eTag: String?) { val values = ContentValues(2) - values.put(CalendarContract.Events.DIRTY, 0) + values.put(Events.DIRTY, 0) values.put(COLUMN_ETAG, eTag) values.put(COLUMN_SEQUENCE, event!!.sequence) calendar.provider.update(eventSyncURI(), values, null, null) diff --git a/app/src/main/java/at/bitfire/davdroid/resource/LocalGroup.kt b/app/src/main/java/foundation/e/accountmanager/resource/LocalGroup.kt similarity index 98% rename from app/src/main/java/at/bitfire/davdroid/resource/LocalGroup.kt rename to app/src/main/java/foundation/e/accountmanager/resource/LocalGroup.kt index b8d755740fa018d3eb0bdb755923e2767cf5053d..7f66fa8a24eebcd391aa659580f6ba4e144b5973 100644 --- a/app/src/main/java/at/bitfire/davdroid/resource/LocalGroup.kt +++ b/app/src/main/java/foundation/e/accountmanager/resource/LocalGroup.kt @@ -6,7 +6,7 @@ * http://www.gnu.org/licenses/gpl.html */ -package at.bitfire.davdroid.resource +package foundation.e.accountmanager.resource import android.content.ContentProviderOperation import android.content.ContentUris @@ -20,8 +20,8 @@ import android.provider.ContactsContract.CommonDataKinds.GroupMembership import android.provider.ContactsContract.Groups import android.provider.ContactsContract.RawContacts import android.provider.ContactsContract.RawContacts.Data -import at.bitfire.dav4android.Constants -import at.bitfire.vcard4android.* +import foundation.e.dav4jvm.Constants +import foundation.e.vcard4android.* import java.util.* class LocalGroup: AndroidGroup, LocalAddress { @@ -200,7 +200,7 @@ class LocalGroup: AndroidGroup, LocalAddress { override fun updateFlags(flags: Int) { val values = ContentValues(1) - values.put(LocalGroup.COLUMN_FLAGS, flags) + values.put(COLUMN_FLAGS, flags) addressBook.provider!!.update(groupSyncUri(), values, null, null) this.flags = flags diff --git a/app/src/main/java/at/bitfire/davdroid/resource/LocalResource.kt b/app/src/main/java/foundation/e/accountmanager/resource/LocalResource.kt similarity index 68% rename from app/src/main/java/at/bitfire/davdroid/resource/LocalResource.kt rename to app/src/main/java/foundation/e/accountmanager/resource/LocalResource.kt index 9ecc3edf14cc697548ff0aa2007d1312ad4f34a0..f04bb3e40747320926a13b80b639a5ff2de1f08f 100644 --- a/app/src/main/java/at/bitfire/davdroid/resource/LocalResource.kt +++ b/app/src/main/java/foundation/e/accountmanager/resource/LocalResource.kt @@ -6,7 +6,7 @@ * http://www.gnu.org/licenses/gpl.html */ -package at.bitfire.davdroid.resource +package foundation.e.accountmanager.resource import android.net.Uri @@ -28,14 +28,34 @@ interface LocalResource { */ val id: Long? + /** + * Remote file name for the resource, for instance `mycontact.vcf`. + */ val fileName: String? var eTag: String? val flags: Int + /** + * Generates a new UID and file name and assigns them to this resource. Typically used + * before uploading a resource which has just been created locally. + */ fun assignNameAndUID() + + /** + * Unsets the /dirty/ field of the resource. Typically used after successfully uploading a + * locally modified resource. + * + * @param eTag ETag of the uploaded resource as returned by the server (null if the server didn't return one) + */ fun clearDirty(eTag: String?) + + /** + * Sets (local) flags of the resource. At the moment, the only allowed values are + * 0 and [FLAG_REMOTELY_PRESENT]. + */ fun updateFlags(flags: Int) + /** * Adds the data object to the content provider and ensures that the dirty flag is clear. * @return content URI of the created row (e.g. event URI) diff --git a/app/src/main/java/at/bitfire/davdroid/resource/LocalTask.kt b/app/src/main/java/foundation/e/accountmanager/resource/LocalTask.kt similarity index 91% rename from app/src/main/java/at/bitfire/davdroid/resource/LocalTask.kt rename to app/src/main/java/foundation/e/accountmanager/resource/LocalTask.kt index 638b9d0ecf96ece4730896f078fc879ac6aa220d..2e88281973e352fd3f8034142f2626b1b0266618 100644 --- a/app/src/main/java/at/bitfire/davdroid/resource/LocalTask.kt +++ b/app/src/main/java/foundation/e/accountmanager/resource/LocalTask.kt @@ -6,14 +6,14 @@ * http://www.gnu.org/licenses/gpl.html */ -package at.bitfire.davdroid.resource +package foundation.e.accountmanager.resource import android.content.ContentProviderOperation import android.content.ContentValues -import at.bitfire.ical4android.AndroidTask -import at.bitfire.ical4android.AndroidTaskFactory -import at.bitfire.ical4android.AndroidTaskList -import at.bitfire.ical4android.Task +import foundation.e.ical4android.AndroidTask +import foundation.e.ical4android.AndroidTaskFactory +import foundation.e.ical4android.AndroidTaskList +import foundation.e.ical4android.Task import org.dmfs.tasks.contract.TaskContract.Tasks import java.util.* @@ -50,7 +50,6 @@ class LocalTask: AndroidTask, LocalResource { override fun buildTask(builder: ContentProviderOperation.Builder, update: Boolean) { super.buildTask(builder, update) - val task = requireNotNull(task) builder .withValue(Tasks._SYNC_ID, fileName) .withValue(COLUMN_ETAG, eTag) diff --git a/app/src/main/java/at/bitfire/davdroid/resource/LocalTaskList.kt b/app/src/main/java/foundation/e/accountmanager/resource/LocalTaskList.kt similarity index 74% rename from app/src/main/java/at/bitfire/davdroid/resource/LocalTaskList.kt rename to app/src/main/java/foundation/e/accountmanager/resource/LocalTaskList.kt index 49278134eaedc9ccf4aada8934b9bb069c1e366d..6b2e7eb9a76968f73bb1a52c8154aee5112c38a1 100644 --- a/app/src/main/java/at/bitfire/davdroid/resource/LocalTaskList.kt +++ b/app/src/main/java/foundation/e/accountmanager/resource/LocalTaskList.kt @@ -6,23 +6,25 @@ * http://www.gnu.org/licenses/gpl.html */ -package at.bitfire.davdroid.resource +package foundation.e.accountmanager.resource import android.accounts.Account +import android.annotation.SuppressLint import android.content.ContentProviderClient import android.content.ContentResolver import android.content.ContentValues import android.content.Context import android.net.Uri import 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.TaskProvider +import foundation.e.accountmanager.Constants +import foundation.e.accountmanager.DavUtils +import foundation.e.accountmanager.closeCompat +import foundation.e.accountmanager.log.Logger +import foundation.e.accountmanager.model.Collection +import foundation.e.accountmanager.model.SyncState +import foundation.e.ical4android.AndroidTaskList +import foundation.e.ical4android.AndroidTaskListFactory +import foundation.e.ical4android.TaskProvider import org.dmfs.tasks.contract.TaskContract.TaskLists import org.dmfs.tasks.contract.TaskContract.Tasks import java.util.logging.Level @@ -38,14 +40,18 @@ class LocalTaskList private constructor( fun tasksProviderAvailable(context: Context): Boolean { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) return context.packageManager.resolveContentProvider(TaskProvider.ProviderName.OpenTasks.authority, 0) != null - else { - val provider = TaskProvider.acquire(context, TaskProvider.ProviderName.OpenTasks) - provider?.use { return true } + else + try { + TaskProvider.acquire(context, TaskProvider.ProviderName.OpenTasks)?.use { + return true + } + } catch (e: Exception) { + // couldn't acquire task provider + } return false - } } - fun create(account: Account, provider: TaskProvider, info: CollectionInfo): Uri { + fun create(account: Account, provider: TaskProvider, info: Collection): Uri { val values = valuesFromCollectionInfo(info, true) values.put(TaskLists.OWNER, account.name) values.put(TaskLists.SYNC_ENABLED, 1) @@ -53,6 +59,7 @@ class LocalTaskList private constructor( return create(account, provider, values) } + @SuppressLint("Recycle") @Throws(Exception::class) fun onRenameAccount(resolver: ContentResolver, oldName: String, newName: String) { var client: ContentProviderClient? = null @@ -64,14 +71,11 @@ class LocalTaskList private constructor( it.update(Tasks.getContentUri(TaskProvider.ProviderName.OpenTasks.authority), values, "${Tasks.ACCOUNT_NAME}=?", arrayOf(oldName)) } } finally { - if (Build.VERSION.SDK_INT >= 24) - client?.close() - else - client?.release() + client?.closeCompat() } } - private fun valuesFromCollectionInfo(info: CollectionInfo, withColor: Boolean): ContentValues { + private fun valuesFromCollectionInfo(info: Collection, withColor: Boolean): ContentValues { val values = ContentValues(3) values.put(TaskLists._SYNC_ID, info.url.toString()) values.put(TaskLists.LIST_NAME, if (info.displayName.isNullOrBlank()) DavUtils.lastSegmentOfUrl(info.url) else info.displayName) @@ -109,14 +113,14 @@ class LocalTaskList private constructor( } - fun update(info: CollectionInfo, updateColor: Boolean) = + fun update(info: Collection, updateColor: Boolean) = update(valuesFromCollectionInfo(info, updateColor)) - override fun findDeleted() = queryTasks("${Tasks._DELETED}!=0", null) + override fun findDeleted() = queryTasks(Tasks._DELETED, null) override fun findDirty(): List { - val tasks = queryTasks("${Tasks._DIRTY}!=0", null) + val tasks = queryTasks(Tasks._DIRTY, null) for (localTask in tasks) { val task = requireNotNull(localTask.task) val sequence = task.sequence @@ -128,6 +132,9 @@ class LocalTaskList private constructor( return tasks } + override fun findDirtyWithoutNameOrUid() = + queryTasks("${Tasks._DIRTY} AND (${Tasks._SYNC_ID} IS NULL OR ${Tasks._UID} IS NULL)", null) + override fun findByName(name: String) = queryTasks("${Tasks._SYNC_ID}=?", arrayOf(name)).firstOrNull() @@ -142,9 +149,16 @@ class LocalTaskList private constructor( override fun removeNotDirtyMarked(flags: Int) = provider.client.delete(tasksSyncUri(), - "${Tasks.LIST_ID}=? AND ${Tasks._DIRTY}=0 AND ${LocalTask.COLUMN_FLAGS}=?", + "${Tasks.LIST_ID}=? AND NOT ${Tasks._DIRTY} AND ${LocalTask.COLUMN_FLAGS}=?", arrayOf(id.toString(), flags.toString())) + override fun forgetETags() { + val values = ContentValues(1) + values.putNull(LocalEvent.COLUMN_ETAG) + provider.client.update(tasksSyncUri(), values, "${Tasks.LIST_ID}=?", + arrayOf(id.toString())) + } + object Factory: AndroidTaskListFactory { diff --git a/app/src/main/java/at/bitfire/davdroid/AccountSettings.kt b/app/src/main/java/foundation/e/accountmanager/settings/AccountSettings.kt similarity index 55% rename from app/src/main/java/at/bitfire/davdroid/AccountSettings.kt rename to app/src/main/java/foundation/e/accountmanager/settings/AccountSettings.kt index ab254f6b61eedba01072e4832198cddeffcfce90..de8f792dc14d66bb3e274fba421139966d0b9f56 100644 --- a/app/src/main/java/at/bitfire/davdroid/AccountSettings.kt +++ b/app/src/main/java/foundation/e/accountmanager/settings/AccountSettings.kt @@ -5,39 +5,39 @@ * which accompanies this distribution, and is available at * http://www.gnu.org/licenses/gpl.html */ -package at.bitfire.davdroid +package foundation.e.accountmanager.settings import android.accounts.Account import android.accounts.AccountManager import android.annotation.SuppressLint -import android.content.* -import android.os.Build +import android.content.ContentResolver +import android.content.ContentUris +import android.content.ContentValues +import android.content.Context +import android.content.pm.PackageManager import android.os.Bundle import android.os.Parcel import android.os.RemoteException import android.provider.CalendarContract import android.provider.ContactsContract -import 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 -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 androidx.core.content.ContextCompat +import foundation.e.accountmanager.* +import foundation.e.accountmanager.log.Logger +import foundation.e.accountmanager.model.AppDatabase +import foundation.e.accountmanager.model.Collection +import foundation.e.accountmanager.model.Credentials +import foundation.e.accountmanager.model.Service +import foundation.e.accountmanager.resource.LocalAddressBook +import foundation.e.accountmanager.resource.LocalTask +import foundation.e.ical4android.AndroidCalendar +import foundation.e.ical4android.TaskProvider +import foundation.e.ical4android.TaskProvider.ProviderName.OpenTasks +import foundation.e.vcard4android.ContactsStorageException +import foundation.e.vcard4android.GroupMethod +import net.openid.appauth.AuthState import okhttp3.HttpUrl import org.apache.commons.lang3.StringUtils import org.dmfs.tasks.contract.TaskContract -import java.util.* import java.util.logging.Level /** @@ -47,35 +47,45 @@ import java.util.logging.Level */ class AccountSettings( val context: Context, - val settings: ISettings, val account: Account ) { companion object { - const val CURRENT_VERSION = 8 + const val CURRENT_VERSION = 10 const val KEY_SETTINGS_VERSION = "version" const val KEY_USERNAME = "user_name" + const val KEY_EMAIL_ADDRESS = "email_address" + const val KEY_AUTH_STATE = "auth_state" const val KEY_CERTIFICATE_ALIAS = "certificate_alias" const val KEY_WIFI_ONLY = "wifi_only" // sync on WiFi only (default: false) + const val WIFI_ONLY_DEFAULT = false const val KEY_WIFI_ONLY_SSIDS = "wifi_only_ssids" // restrict sync to specific WiFi SSIDs - /** Time range limitation to the past [in days] - value = null default value (DEFAULT_TIME_RANGE_PAST_DAYS) - < 0 (-1) no limit - >= 0 entries more than n days in the past won't be synchronized + /** Time range limitation to the past [in days]. Values: + * + * - null: default value (DEFAULT_TIME_RANGE_PAST_DAYS) + * - <0 (typically -1): no limit + * - n>0: entries more than n days in the past won't be synchronized */ const val KEY_TIME_RANGE_PAST_DAYS = "time_range_past_days" const val DEFAULT_TIME_RANGE_PAST_DAYS = 90 - /* Whether DAVdroid sets the local calendar color to the value from service DB at every sync + /** + * Whether a default alarm shall be assigned to received events/tasks which don't have an alarm. + * Value can be null (no default alarm) or an integer (default alarm shall be created this + * number of minutes before the event/task). + */ + const val KEY_DEFAULT_ALARM = "default_alarm" + + /* Whether DAVx5 sets the local calendar color to the value from service DB at every sync value = null (not existing) true (default) "0" false */ const val KEY_MANAGE_CALENDAR_COLORS = "manage_calendar_colors" - /* Whether DAVdroid populates and uses CalendarContract.Colors + /* Whether DAVx5 populates and uses CalendarContract.Colors value = null (not existing) false (default) "1" true */ const val KEY_EVENT_COLORS = "event_colors" @@ -88,24 +98,36 @@ class AccountSettings( const val SYNC_INTERVAL_MANUALLY = -1L - fun initialUserData(credentials: Credentials): Bundle { + fun initialUserData(credentials: Credentials, baseURL: String?): Bundle { val bundle = Bundle(2) bundle.putString(KEY_SETTINGS_VERSION, CURRENT_VERSION.toString()) when (credentials.type) { - Credentials.Type.UsernamePassword -> + Credentials.Type.UsernamePassword -> { bundle.putString(KEY_USERNAME, credentials.userName) + bundle.putString(KEY_EMAIL_ADDRESS, credentials.userName) + } + Credentials.Type.OAuth -> { + bundle.putString(KEY_USERNAME, credentials.userName) + bundle.putString(KEY_EMAIL_ADDRESS, credentials.userName) + bundle.putString(KEY_AUTH_STATE, credentials.authState!!.jsonSerializeString()) + } Credentials.Type.ClientCertificate -> bundle.putString(KEY_CERTIFICATE_ALIAS, credentials.certificateAlias) } + + if (!baseURL.isNullOrEmpty()) { + bundle.putString("oc_base_url", baseURL) + } return bundle } } - - + + val accountManager: AccountManager = AccountManager.get(context) + val settings = Settings.getInstance(context) init { synchronized(AccountSettings::class.java) { @@ -125,16 +147,35 @@ class AccountSettings( // authentication settings - fun credentials() = Credentials( - accountManager.getUserData(account, KEY_USERNAME), - accountManager.getPassword(account), - accountManager.getUserData(account, KEY_CERTIFICATE_ALIAS) - ) + fun credentials(): Credentials { + if (accountManager.getUserData(account, KEY_AUTH_STATE).isNullOrEmpty()) { + return Credentials( + accountManager.getUserData(account, KEY_USERNAME), + accountManager.getPassword(account), + null, + accountManager.getUserData(account, KEY_CERTIFICATE_ALIAS)) + } + else { + return Credentials( + accountManager.getUserData(account, KEY_USERNAME), + accountManager.getPassword(account), + AuthState.jsonDeserialize(accountManager.getUserData(account, KEY_AUTH_STATE)), + accountManager.getUserData(account, KEY_CERTIFICATE_ALIAS)) + } + } fun credentials(credentials: Credentials) { - accountManager.setUserData(account, KEY_USERNAME, credentials.userName) - accountManager.setPassword(account, credentials.password) - accountManager.setUserData(account, KEY_CERTIFICATE_ALIAS, credentials.certificateAlias) + if (credentials.authState == null) { + accountManager.setUserData(account, KEY_USERNAME, credentials.userName) + accountManager.setPassword(account, credentials.password) + accountManager.setUserData(account, KEY_CERTIFICATE_ALIAS, credentials.certificateAlias) + } + else { + accountManager.setUserData(account, KEY_USERNAME, credentials.userName) + accountManager.setPassword(account, credentials.password) + accountManager.setUserData(account, KEY_AUTH_STATE, credentials.authState.jsonSerializeString()) + accountManager.setUserData(account, KEY_CERTIFICATE_ALIAS, credentials.certificateAlias) + } } @@ -160,14 +201,14 @@ class AccountSettings( } fun getSyncWifiOnly() = if (settings.has(KEY_WIFI_ONLY)) - settings.getBoolean(KEY_WIFI_ONLY, false) + settings.getBoolean(KEY_WIFI_ONLY) ?: WIFI_ONLY_DEFAULT else accountManager.getUserData(account, KEY_WIFI_ONLY) != null fun setSyncWiFiOnly(wiFiOnly: Boolean) = accountManager.setUserData(account, KEY_WIFI_ONLY, if (wiFiOnly) "1" else null) fun getSyncWifiOnlySSIDs(): List? = (if (settings.has(KEY_WIFI_ONLY_SSIDS)) - settings.getString(KEY_WIFI_ONLY_SSIDS, null) + settings.getString(KEY_WIFI_ONLY_SSIDS) else accountManager.getUserData(account, KEY_WIFI_ONLY_SSIDS))?.split(',') fun setSyncWifiOnlySSIDs(ssids: List?) = @@ -179,8 +220,11 @@ class AccountSettings( fun getTimeRangePastDays(): Int? { val strDays = accountManager.getUserData(account, KEY_TIME_RANGE_PAST_DAYS) return if (strDays != null) { - val days = Integer.valueOf(strDays) - if (days < 0) null else days + val days = strDays.toInt() + if (days < 0) + null + else + days } else DEFAULT_TIME_RANGE_PAST_DAYS } @@ -188,15 +232,44 @@ class AccountSettings( fun setTimeRangePastDays(days: Int?) = accountManager.setUserData(account, KEY_TIME_RANGE_PAST_DAYS, (days ?: -1).toString()) + /** + * Takes the default alarm setting (in this order) from + * + * 1. the local account settings + * 2. the settings provider (unless the value is -1 there). + * + * @return A default reminder shall be created this number of minutes before the start of every + * non-full-day event without reminder. *null*: No default reminders shall be created. + */ + fun getDefaultAlarm() = + accountManager.getUserData(account, KEY_DEFAULT_ALARM)?.toInt() ?: + settings.getInt(KEY_DEFAULT_ALARM).takeIf { it != -1 } + + /** + * Sets the default alarm value in the local account settings, if the new value differs + * from the value of the settings provider. If the new value is the same as the value of + * the settings provider, the local setting will be deleted, so that the settings provider + * value applies. + * + * @param minBefore The number of minutes a default reminder shall be created before the + * start of every non-full-day event without reminder. *null*: No default reminders shall be created. + */ + fun setDefaultAlarm(minBefore: Int?) = + accountManager.setUserData(account, KEY_DEFAULT_ALARM, + if (minBefore == settings.getInt(KEY_DEFAULT_ALARM).takeIf { it != -1 }) + null + else + minBefore?.toString()) + fun getManageCalendarColors() = if (settings.has(KEY_MANAGE_CALENDAR_COLORS)) - settings.getBoolean(KEY_MANAGE_CALENDAR_COLORS, false) + settings.getBoolean(KEY_MANAGE_CALENDAR_COLORS) ?: false else accountManager.getUserData(account, KEY_MANAGE_CALENDAR_COLORS) == null fun setManageCalendarColors(manage: Boolean) = accountManager.setUserData(account, KEY_MANAGE_CALENDAR_COLORS, if (manage) null else "0") fun getEventColors() = if (settings.has(KEY_EVENT_COLORS)) - settings.getBoolean(KEY_EVENT_COLORS, false) + settings.getBoolean(KEY_EVENT_COLORS) ?: false else accountManager.getUserData(account, KEY_EVENT_COLORS) != null fun setEventColors(useColors: Boolean) = @@ -205,7 +278,7 @@ class AccountSettings( // CardDAV settings fun getGroupMethod(): GroupMethod { - val name = settings.getString(KEY_CONTACT_GROUP_METHOD, null) ?: + val name = settings.getString(KEY_CONTACT_GROUP_METHOD) ?: accountManager.getUserData(account, KEY_CONTACT_GROUP_METHOD) if (name != null) try { @@ -224,7 +297,7 @@ class AccountSettings( // update from previous account settings private fun update(baseVersion: Int) { - for (toVersion in baseVersion+1 .. CURRENT_VERSION) { + for (toVersion in baseVersion+1 ..CURRENT_VERSION) { val fromVersion = toVersion-1 Logger.log.info("Updating account ${account.name} from version $fromVersion to $toVersion") try { @@ -239,19 +312,58 @@ class AccountSettings( } } - @Suppress("unused") + + @Suppress("unused","FunctionName") + /** + * Task synchronization now handles alarms, categories, relations and unknown properties. + * Setting task ETags to null will cause them to be downloaded (and parsed) again. + * + * Also update the allowed reminder types for calendars. + **/ + private fun update_9_10() { + TaskProvider.acquire(context, OpenTasks)?.use { provider -> + val tasksUri = TaskProvider.syncAdapterUri(provider.tasksUri(), account) + val emptyETag = ContentValues(1) + emptyETag.putNull(LocalTask.COLUMN_ETAG) + provider.client.update(tasksUri, emptyETag, "${TaskContract.Tasks._DIRTY}=0 AND ${TaskContract.Tasks._DELETED}=0", null) + } + + if (ContextCompat.checkSelfPermission(context, android.Manifest.permission.WRITE_CALENDAR) == PackageManager.PERMISSION_GRANTED) + context.contentResolver.acquireContentProviderClient(CalendarContract.AUTHORITY)?.let { provider -> + provider.update(AndroidCalendar.syncAdapterURI(CalendarContract.Calendars.CONTENT_URI, account), + AndroidCalendar.calendarBaseValues, null, null) + provider.closeCompat() + } + } + + @Suppress("unused","FunctionName") + /** + * It seems that somehow some non-CalDAV accounts got OpenTasks syncable, which caused battery problems. + * Disable it on those accounts for the future. + */ + private fun update_8_9() { + val db = AppDatabase.getInstance(context) + val hasCalDAV = db.serviceDao().getByAccountAndType(account.name, Service.TYPE_CALDAV) != null + if (!hasCalDAV && ContentResolver.getIsSyncable(account, OpenTasks.authority) != 0) { + Logger.log.info("Disabling OpenTasks sync for $account") + ContentResolver.setIsSyncable(account, OpenTasks.authority, 0) + } + } + + @Suppress("unused","FunctionName") + @SuppressLint("Recycle") /** * There is a mistake in this method. [TaskContract.Tasks.SYNC_VERSION] is used to store the * SEQUENCE and should not be used for the eTag. */ private fun update_7_8() { - TaskProvider.acquire(context, TaskProvider.ProviderName.OpenTasks)?.let { provider -> + TaskProvider.acquire(context, OpenTasks)?.use { provider -> // ETag is now in sync_version instead of sync1 // UID is now in _uid instead of sync2 provider.client.query(TaskProvider.syncAdapterUri(provider.tasksUri(), account), arrayOf(TaskContract.Tasks._ID, TaskContract.Tasks.SYNC1, TaskContract.Tasks.SYNC2), "${TaskContract.Tasks.ACCOUNT_TYPE}=? AND ${TaskContract.Tasks.ACCOUNT_NAME}=?", - arrayOf(account.type, account.name), null).use { cursor -> + arrayOf(account.type, account.name), null)!!.use { cursor -> while (cursor.moveToNext()) { val id = cursor.getLong(0) val eTag = cursor.getString(1) @@ -271,16 +383,14 @@ class AccountSettings( } @Suppress("unused") + @SuppressLint("Recycle") private fun update_6_7() { // add calendar colors context.contentResolver.acquireContentProviderClient(CalendarContract.AUTHORITY)?.let { provider -> try { AndroidCalendar.insertColors(provider, account) } finally { - if (Build.VERSION.SDK_INT >= 24) - provider.close() - else - provider.release() + provider.closeCompat() } } @@ -291,7 +401,7 @@ class AccountSettings( } @Suppress("unused") - @SuppressLint("ParcelClassLoader") + @SuppressLint("Recycle", "ParcelClassLoader") private fun update_5_6() { context.contentResolver.acquireContentProviderClient(ContactsContract.AUTHORITY)?.let { provider -> val parcel = Parcel.obtain() @@ -308,15 +418,13 @@ class AccountSettings( else { parcel.unmarshall(raw, 0, raw.size) parcel.setDataPosition(0) - val params = parcel.readBundle() + val params = parcel.readBundle()!! val url = params.getString("url")?.let { HttpUrl.parse(it) } if (url == null) Logger.log.info("No address book URL, ignoring account") else { // create new address book - val info = CollectionInfo(url) - info.type = CollectionInfo.Type.ADDRESS_BOOK - info.displayName = account.name + val info = Collection(url = url, type = Collection.TYPE_ADDRESSBOOK, displayName = account.name) Logger.log.log(Level.INFO, "Creating new address book account", url) val addressBookAccount = Account(LocalAddressBook.accountName(account, info), context.getString(R.string.account_type_address_book)) if (!accountManager.addAccountExplicitly(addressBookAccount, null, LocalAddressBook.initialUserData(account, info.url.toString()))) @@ -343,10 +451,7 @@ class AccountSettings( throw ContactsStorageException("Couldn't migrate contacts to new address book", e) } finally { parcel.recycle() - if (Build.VERSION.SDK_INT >= 24) - provider.close() - else - provider.release() + provider.closeCompat() } } @@ -355,7 +460,7 @@ class AccountSettings( // request sync of new address book account ContentResolver.setIsSyncable(account, context.getString(R.string.address_books_authority), 1) - setSyncInterval(context.getString(R.string.address_books_authority), Constants.DEFAULT_SYNC_INTERVAL) + setSyncInterval(context.getString(R.string.address_books_authority), Constants.DEFAULT_CONTACTS_SYNC_INTERVAL) } /* Android 7.1.1 OpenTasks fix */ @@ -370,171 +475,6 @@ class AccountSettings( setGroupMethod(GroupMethod.CATEGORIES) } - @Suppress("unused") - private fun update_2_3() { - // Don't show a warning for Android updates anymore - accountManager.setUserData(account, "last_android_version", null) - - var serviceCardDAV: Long? = null - var serviceCalDAV: Long? = null - - ServiceDB.OpenHelper(context).use { dbHelper -> - val db = dbHelper.writableDatabase - // we have to create the WebDAV Service database only from the old address book, calendar and task list URLs - - // CardDAV: migrate address books - context.contentResolver.acquireContentProviderClient(ContactsContract.AUTHORITY)?.let { client -> - try { - val addrBook = LocalAddressBook(context, account, client) - val url = addrBook.url - Logger.log.fine("Migrating address book $url") - - // insert CardDAV service - val values = ContentValues(3) - values.put(Services.ACCOUNT_NAME, account.name) - values.put(Services.SERVICE, Services.SERVICE_CARDDAV) - serviceCardDAV = db.insert(Services._TABLE, null, values) - - // insert address book - values.clear() - values.put(Collections.SERVICE_ID, serviceCardDAV) - values.put(Collections.URL, url) - values.put(Collections.SYNC, 1) - db.insert(Collections._TABLE, null, values) - - // insert home set - HttpUrl.parse(url)?.let { - val homeSet = it.resolve("../") - values.clear() - values.put(HomeSets.SERVICE_ID, serviceCardDAV) - values.put(HomeSets.URL, homeSet.toString()) - db.insert(HomeSets._TABLE, null, values) - } - } catch (e: ContactsStorageException) { - Logger.log.log(Level.SEVERE, "Couldn't migrate address book", e) - } finally { - if (Build.VERSION.SDK_INT >= 24) - client.close() - else - @Suppress("deprecation") - client.release() - } - } - - // CalDAV: migrate calendars + task lists - val collections = HashSet() - val homeSets = HashSet() - - context.contentResolver.acquireContentProviderClient(CalendarContract.AUTHORITY)?.let { client -> - try { - val calendars = AndroidCalendar.find(account, client, LocalCalendar.Factory, null, null) - for (calendar in calendars) - calendar.name?.let { url -> - Logger.log.fine("Migrating calendar $url") - collections.add(url) - HttpUrl.parse(url)?.resolve("../")?.let { homeSets.add(it) } - } - } catch (e: CalendarStorageException) { - Logger.log.log(Level.SEVERE, "Couldn't migrate calendars", e) - } finally { - if (Build.VERSION.SDK_INT >= 24) - client.close() - else - @Suppress("deprecation") - client.release() - } - } - - AndroidTaskList.acquireTaskProvider(context)?.use { provider -> - try { - val taskLists = AndroidTaskList.find(account, provider, LocalTaskList.Factory, null, null) - for (taskList in taskLists) - taskList.syncId?.let { url -> - Logger.log.fine("Migrating task list $url") - collections.add(url) - HttpUrl.parse(url)?.resolve("../")?.let { homeSets.add(it) } - } - } catch (e: CalendarStorageException) { - Logger.log.log(Level.SEVERE, "Couldn't migrate task lists", e) - } - } - - if (!collections.isEmpty()) { - // insert CalDAV service - val values = ContentValues(3) - values.put(Services.ACCOUNT_NAME, account.name) - values.put(Services.SERVICE, Services.SERVICE_CALDAV) - serviceCalDAV = db.insert(Services._TABLE, null, values) - - // insert collections - for (url in collections) { - values.clear() - values.put(Collections.SERVICE_ID, serviceCalDAV) - values.put(Collections.URL, url) - values.put(Collections.SYNC, 1) - db.insert(Collections._TABLE, null, values) - } - - // insert home sets - for (homeSet in homeSets) { - values.clear() - values.put(HomeSets.SERVICE_ID, serviceCalDAV) - values.put(HomeSets.URL, homeSet.toString()) - db.insert(HomeSets._TABLE, null, values) - } - } - } - - // initiate service detection (refresh) to get display names, colors etc. - val refresh = Intent(context, DavService::class.java) - refresh.action = DavService.ACTION_REFRESH_COLLECTIONS - serviceCardDAV?.let { - refresh.putExtra(DavService.EXTRA_DAV_SERVICE_ID, it) - context.startService(refresh) - } - serviceCalDAV?.let { - refresh.putExtra(DavService.EXTRA_DAV_SERVICE_ID, it) - context.startService(refresh) - } - } - - @Suppress("unused") - @SuppressLint("Recycle") - private fun update_1_2() { - /* - KEY_ADDRESSBOOK_URL ("addressbook_url"), - - KEY_ADDRESSBOOK_CTAG ("addressbook_ctag"), - - KEY_ADDRESSBOOK_VCARD_VERSION ("addressbook_vcard_version") are not used anymore (now stored in ContactsContract.SyncState) - - KEY_LAST_ANDROID_VERSION ("last_android_version") has been added - */ - - // move previous address book info to ContactsContract.SyncState - val provider = context.contentResolver.acquireContentProviderClient(ContactsContract.AUTHORITY) ?: - throw ContactsStorageException("Couldn't access Contacts provider") - - try { - val addr = LocalAddressBook(context, account, provider) - - // until now, ContactsContract.Settings.UNGROUPED_VISIBLE was not set explicitly - val values = ContentValues() - values.put(ContactsContract.Settings.UNGROUPED_VISIBLE, 1) - addr.settings = values - - val url = accountManager.getUserData(account, "addressbook_url") - if (!url.isNullOrEmpty()) - addr.url = url - accountManager.setUserData(account, "addressbook_url", null) - - val cTag = accountManager.getUserData (account, "addressbook_ctag") - if (!cTag.isNullOrEmpty()) - addr.lastSyncState = SyncState(SyncState.Type.CTAG, cTag) - accountManager.setUserData(account, "addressbook_ctag", null) - } finally { - if (Build.VERSION.SDK_INT >= 24) - provider.close() - else - @Suppress("deprecation") - provider.release() - } - } + // updates from AccountSettings version 2 and below are not supported anymore } diff --git a/app/src/main/java/at/bitfire/davdroid/settings/DefaultsProvider.kt b/app/src/main/java/foundation/e/accountmanager/settings/DefaultsProvider.kt similarity index 76% rename from app/src/main/java/at/bitfire/davdroid/settings/DefaultsProvider.kt rename to app/src/main/java/foundation/e/accountmanager/settings/DefaultsProvider.kt index 40c87c8a8fdb4cff35d5f2625388fd60f3556728..dd47350949096e7d0eb7ba5c0b7719c525ed688e 100644 --- a/app/src/main/java/at/bitfire/davdroid/settings/DefaultsProvider.kt +++ b/app/src/main/java/foundation/e/accountmanager/settings/DefaultsProvider.kt @@ -6,34 +6,33 @@ * http://www.gnu.org/licenses/gpl.html */ -package at.bitfire.davdroid.settings +package foundation.e.accountmanager.settings -import at.bitfire.davdroid.App +import android.content.Context open class DefaultsProvider( private val allowOverride: Boolean = true -): Provider { +): SettingsProvider { open val booleanDefaults = mapOf( - Pair(App.DISTRUST_SYSTEM_CERTIFICATES, false), - Pair(App.OVERRIDE_PROXY, false) + Pair(Settings.DISTRUST_SYSTEM_CERTIFICATES, Settings.DISTRUST_SYSTEM_CERTIFICATES_DEFAULT), + Pair(Settings.OVERRIDE_PROXY, Settings.OVERRIDE_PROXY_DEFAULT) ) open val intDefaults = mapOf( - Pair(App.OVERRIDE_PROXY_PORT, App.OVERRIDE_PROXY_PORT_DEFAULT) + Pair(Settings.OVERRIDE_PROXY_PORT, Settings.OVERRIDE_PROXY_PORT_DEFAULT) ) open val longDefaults = mapOf() open val stringDefaults = mapOf( - Pair(App.OVERRIDE_PROXY_HOST, App.OVERRIDE_PROXY_HOST_DEFAULT) + Pair(Settings.OVERRIDE_PROXY_HOST, Settings.OVERRIDE_PROXY_HOST_DEFAULT) ) - - override fun close() { + override fun forceReload() { } - override fun forceReload() { + override fun close() { } @@ -71,4 +70,9 @@ open class DefaultsProvider( override fun remove(key: String) = false + + class Factory : ISettingsProviderFactory { + override fun getProviders(context: Context) = listOf(DefaultsProvider()) + } + } \ No newline at end of file diff --git a/app/src/main/java/foundation/e/accountmanager/settings/ISettingsProviderFactory.kt b/app/src/main/java/foundation/e/accountmanager/settings/ISettingsProviderFactory.kt new file mode 100644 index 0000000000000000000000000000000000000000..7607f10573103c9584b974310e047744b0b372d3 --- /dev/null +++ b/app/src/main/java/foundation/e/accountmanager/settings/ISettingsProviderFactory.kt @@ -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 foundation.e.accountmanager.settings + +import android.content.Context + +interface ISettingsProviderFactory { + + fun getProviders(context: Context): List + +} \ No newline at end of file diff --git a/app/src/main/java/foundation/e/accountmanager/settings/Settings.kt b/app/src/main/java/foundation/e/accountmanager/settings/Settings.kt new file mode 100644 index 0000000000000000000000000000000000000000..1a951cae697fbfd2581552b49db03897f5a51eb5 --- /dev/null +++ b/app/src/main/java/foundation/e/accountmanager/settings/Settings.kt @@ -0,0 +1,194 @@ +/* + * 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 foundation.e.accountmanager.settings + +import android.content.Context +import androidx.annotation.WorkerThread +import foundation.e.accountmanager.log.Logger +import java.lang.ref.WeakReference +import java.util.* +import java.util.logging.Level + +@WorkerThread +class Settings( + appContext: Context +) { + + companion object { + + // settings keys and default values + const val DISTRUST_SYSTEM_CERTIFICATES = "distrust_system_certs" + const val DISTRUST_SYSTEM_CERTIFICATES_DEFAULT = false + const val OVERRIDE_PROXY = "override_proxy" + const val OVERRIDE_PROXY_DEFAULT = false + const val OVERRIDE_PROXY_HOST = "override_proxy_host" + const val OVERRIDE_PROXY_PORT = "override_proxy_port" + + const val OVERRIDE_PROXY_HOST_DEFAULT = "localhost" + const val OVERRIDE_PROXY_PORT_DEFAULT = 8118 + + + private var singleton: Settings? = null + + fun getInstance(context: Context): Settings { + singleton?.let { return it } + + val newInstance = Settings(context.applicationContext) + singleton = newInstance + return newInstance + } + + } + + private val providers = LinkedList() + private val observers = LinkedList>() + + init { + val factories = ServiceLoader.load(ISettingsProviderFactory::class.java) + Logger.log.fine("Loading settings providers from ${factories.count()} factories") + factories.forEach { factory -> + providers.addAll(factory.getProviders(appContext)) + } + } + + fun forceReload() { + providers.forEach { + it.forceReload() + } + onSettingsChanged() + } + + + /*** OBSERVERS ***/ + + fun addOnChangeListener(observer: OnChangeListener) { + observers += WeakReference(observer) + } + + fun removeOnChangeListener(observer: OnChangeListener) { + observers.removeAll { it.get() == null || it.get() == observer } + } + + fun onSettingsChanged() { + observers.mapNotNull { it.get() }.forEach { + it.onSettingsChanged() + } + } + + + /*** SETTINGS ACCESS ***/ + + fun has(key: String): Boolean { + Logger.log.fine("Looking for setting $key") + var result = false + for (provider in providers) + try { + val (value, further) = provider.has(key) + Logger.log.finer("${provider::class.java.simpleName}: has $key = $value, continue: $further") + if (value) { + result = true + break + } + if (!further) + break + } catch(e: Exception) { + Logger.log.log(Level.SEVERE, "Couldn't look up setting in $provider", e) + } + Logger.log.fine("Looking for setting $key -> $result") + return result + } + + private fun getValue(key: String, reader: (SettingsProvider) -> Pair): T? { + Logger.log.fine("Looking up setting $key") + var result: T? = null + for (provider in providers) + try { + val (value, further) = reader(provider) + Logger.log.finer("${provider::class.java.simpleName}: value = $value, continue: $further") + value?.let { result = it } + if (!further) + break + } catch(e: Exception) { + Logger.log.log(Level.SEVERE, "Couldn't read setting from $provider", e) + } + Logger.log.fine("Looked up setting $key -> $result") + return result + } + + fun getBoolean(key: String) = + getValue(key) { provider -> provider.getBoolean(key) } + + fun getInt(key: String) = + getValue(key) { provider -> provider.getInt(key) } + + fun getLong(key: String) = + getValue(key) { provider -> provider.getLong(key) } + + fun getString(key: String) = + getValue(key) { provider -> provider.getString(key) } + + + fun isWritable(key: String): Boolean { + for (provider in providers) { + val (value, further) = provider.isWritable(key) + if (value) + return true + if (!further) + return false + } + return false + } + + private fun putValue(key: String, value: T?, writer: (SettingsProvider) -> Boolean): Boolean { + Logger.log.fine("Trying to write setting $key = $value") + for (provider in providers) { + val (writable, further) = provider.isWritable(key) + Logger.log.finer("${provider::class.java.simpleName}: writable = $writable, continue: $further") + if (writable) + return try { + writer(provider) + } catch (e: Exception) { + Logger.log.log(Level.SEVERE, "Couldn't write setting to $provider", e) + false + } + if (!further) + return false + } + return false + } + + fun putBoolean(key: String, value: Boolean?) = + putValue(key, value) { provider -> provider.putBoolean(key, value) } + + fun putInt(key: String, value: Int?) = + putValue(key, value) { provider -> provider.putInt(key, value) } + + fun putLong(key: String, value: Long?) = + putValue(key, value) { provider -> provider.putLong(key, value) } + + fun putString(key: String, value: String?) = + putValue(key, value) { provider -> provider.putString(key, value) } + + fun remove(key: String): Boolean { + var deleted = false + providers.forEach { deleted = deleted || it.remove(key) } + return deleted + } + + + interface OnChangeListener { + /** + * Will be called when something has changed in a [SettingsProvider]. + * Runs in worker thread! + */ + @WorkerThread + fun onSettingsChanged() + } + +} diff --git a/app/src/main/java/at/bitfire/davdroid/settings/Provider.kt b/app/src/main/java/foundation/e/accountmanager/settings/SettingsProvider.kt similarity index 85% rename from app/src/main/java/at/bitfire/davdroid/settings/Provider.kt rename to app/src/main/java/foundation/e/accountmanager/settings/SettingsProvider.kt index 7819a1ecbdf77ab201a76a51377550611d807be5..c83ed1d6932b8735a2aba3a684d8b7179cf5c9d3 100644 --- a/app/src/main/java/at/bitfire/davdroid/settings/Provider.kt +++ b/app/src/main/java/foundation/e/accountmanager/settings/SettingsProvider.kt @@ -6,13 +6,12 @@ * http://www.gnu.org/licenses/gpl.html */ -package at.bitfire.davdroid.settings +package foundation.e.accountmanager.settings -import java.io.Closeable - -interface Provider: Closeable { +interface SettingsProvider { fun forceReload() + fun close() fun has(key: String): Pair @@ -30,9 +29,4 @@ interface Provider: Closeable { fun remove(key: String): Boolean - - interface Observer { - fun onReload() - } - } \ No newline at end of file diff --git a/app/src/main/java/at/bitfire/davdroid/settings/SharedPreferencesProvider.kt b/app/src/main/java/foundation/e/accountmanager/settings/SharedPreferencesProvider.kt similarity index 82% rename from app/src/main/java/at/bitfire/davdroid/settings/SharedPreferencesProvider.kt rename to app/src/main/java/foundation/e/accountmanager/settings/SharedPreferencesProvider.kt index e13b8f60b48ebafa9613558da24dceba133cd0fc..47b71a777fa7394dda9ddcf65334f6619c1c4b0d 100644 --- a/app/src/main/java/at/bitfire/davdroid/settings/SharedPreferencesProvider.kt +++ b/app/src/main/java/foundation/e/accountmanager/settings/SharedPreferencesProvider.kt @@ -6,18 +6,18 @@ * http://www.gnu.org/licenses/gpl.html */ -package at.bitfire.davdroid.settings +package foundation.e.accountmanager.settings import android.content.Context import android.content.Context.MODE_PRIVATE import android.content.SharedPreferences import android.preference.PreferenceManager -import at.bitfire.davdroid.log.Logger -import at.bitfire.davdroid.model.ServiceDB +import foundation.e.accountmanager.log.Logger +import foundation.e.accountmanager.model.AppDatabase class SharedPreferencesProvider( - context: Context -): Provider { + val context: Context +): SettingsProvider, SharedPreferences.OnSharedPreferenceChangeListener { companion object { private const val META_VERSION = "version" @@ -34,12 +34,19 @@ class SharedPreferencesProvider( firstCall(context) meta.edit().putInt(META_VERSION, CURRENT_VERSION).apply() } + + preferences.registerOnSharedPreferenceChangeListener(this) + } + + override fun forceReload() { } override fun close() { + preferences.unregisterOnSharedPreferenceChangeListener(this) } - override fun forceReload() { + override fun onSharedPreferenceChanged(sharedPreferences: SharedPreferences, key: String) { + Settings.getInstance(context).onSettingsChanged() } @@ -69,7 +76,7 @@ class SharedPreferencesProvider( override fun isWritable(key: String) = - Pair(true, true) + Pair(first = true, second = true) private fun putValue(key: String, value: T?, writer: (SharedPreferences.Editor, T) -> Unit): Boolean { return if (value == null) @@ -114,7 +121,12 @@ class SharedPreferencesProvider( edit.apply() // open ServiceDB to upgrade it and possibly migrate settings - ServiceDB.OpenHelper(context).use { it.readableDatabase } + AppDatabase.getInstance(context) + } + + + class Factory : ISettingsProviderFactory { + override fun getProviders(context: Context) = listOf(SharedPreferencesProvider(context)) } } \ No newline at end of file diff --git a/app/src/main/java/at/bitfire/davdroid/syncadapter/AccountAuthenticatorService.kt b/app/src/main/java/foundation/e/accountmanager/syncadapter/AccountAuthenticatorService.kt similarity index 51% rename from app/src/main/java/at/bitfire/davdroid/syncadapter/AccountAuthenticatorService.kt rename to app/src/main/java/foundation/e/accountmanager/syncadapter/AccountAuthenticatorService.kt index 64bdcc85de2eba9308e7beba4d48c7d31b286858..72d11cca3fa4299a2ececf99e81e76d8b7b22273 100644 --- a/app/src/main/java/at/bitfire/davdroid/syncadapter/AccountAuthenticatorService.kt +++ b/app/src/main/java/foundation/e/accountmanager/syncadapter/AccountAuthenticatorService.kt @@ -5,65 +5,68 @@ * which accompanies this distribution, and is available at * http://www.gnu.org/licenses/gpl.html */ -package at.bitfire.davdroid.syncadapter +package foundation.e.accountmanager.syncadapter 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 androidx.annotation.WorkerThread +import foundation.e.accountmanager.R +import foundation.e.accountmanager.log.Logger +import foundation.e.accountmanager.model.AppDatabase +import foundation.e.accountmanager.resource.LocalAddressBook +import foundation.e.accountmanager.ui.setup.LoginActivity import java.util.logging.Level +import kotlin.concurrent.thread /** - * Account authenticator for the main DAVdroid account type. + * Account authenticator for the main DAVx5 account type. * - * Gets started when a DAVdroid account is removed, too, so it also watches for account removals + * Gets started when a DAVx5 account is removed, too, so it also watches for account removals * and contains the corresponding cleanup code. */ class AccountAuthenticatorService: Service(), OnAccountsUpdateListener { companion object { + @WorkerThread fun cleanupAccounts(context: Context) { Logger.log.info("Cleaning up orphaned accounts") - ServiceDB.OpenHelper(context).use { dbHelper -> - val db = dbHelper.writableDatabase - - val sqlAccountNames = LinkedList() - val accountNames = HashSet() - 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) - } + val accountManager = AccountManager.get(context) + val accountNames = HashSet() + val accounts = ArrayList() + accountManager.getAccountsByType(context.getString(R.string.eelo_account_type)).forEach { accounts.add(it) } + accountManager.getAccountsByType(context.getString(R.string.google_account_type)).forEach { accounts.add(it) } + accountManager.getAccountsByType(context.getString(R.string.account_type)).forEach { accounts.add(it) } + for (account in accounts.toTypedArray()) + accountNames += account.name + + // delete orphaned address book accounts + val addressBookAccounts = ArrayList() + accountManager.getAccountsByType(context.getString(R.string.account_type_eelo_address_book)).forEach { addressBookAccounts.add(it) } + accountManager.getAccountsByType(context.getString(R.string.account_type_google_address_book)).forEach { addressBookAccounts.add(it) } + accountManager.getAccountsByType(context.getString(R.string.account_type_address_book)).forEach { addressBookAccounts.add(it) } + addressBookAccounts.map { LocalAddressBook(context, it, null) } + .forEach { + try { + if (!accountNames.contains(it.mainAccount.name)) + it.delete() + } catch(e: Exception) { + Logger.log.log(Level.SEVERE, "Couldn't delete address book account", e) } - - // delete orphaned services in DB - if (sqlAccountNames.isEmpty()) - db.delete(ServiceDB.Services._TABLE, null, null) - else - db.delete(ServiceDB.Services._TABLE, "${ServiceDB.Services.ACCOUNT_NAME} NOT IN (${sqlAccountNames.joinToString(",")})", null) - } + } + + // delete orphaned services in DB + val db = AppDatabase.getInstance(context) + val serviceDao = db.serviceDao() + if (accountNames.isEmpty()) + serviceDao.deleteAll() + else + serviceDao.deleteExceptAccounts(accountNames.toTypedArray()) } } @@ -85,11 +88,13 @@ class AccountAuthenticatorService: Service(), OnAccountsUpdateListener { } override fun onBind(intent: Intent?) = - accountAuthenticator.iBinder.takeIf { intent?.action == android.accounts.AccountManager.ACTION_AUTHENTICATOR_INTENT } + accountAuthenticator.iBinder.takeIf { intent?.action == AccountManager.ACTION_AUTHENTICATOR_INTENT } override fun onAccountsUpdated(accounts: Array?) { - cleanupAccounts(this) + thread { + cleanupAccounts(this) + } } diff --git a/app/src/main/java/at/bitfire/davdroid/syncadapter/AddressBookProvider.kt b/app/src/main/java/foundation/e/accountmanager/syncadapter/AddressBookProvider.kt similarity index 50% rename from app/src/main/java/at/bitfire/davdroid/syncadapter/AddressBookProvider.kt rename to app/src/main/java/foundation/e/accountmanager/syncadapter/AddressBookProvider.kt index f9d2429cdd955a0a9ab86144fe5a00a7c135d1ad..75e88883211e91f5837c8f3ef9ba4a157caeb02e 100644 --- a/app/src/main/java/at/bitfire/davdroid/syncadapter/AddressBookProvider.kt +++ b/app/src/main/java/foundation/e/accountmanager/syncadapter/AddressBookProvider.kt @@ -6,19 +6,20 @@ * http://www.gnu.org/licenses/gpl.html */ -package at.bitfire.davdroid.syncadapter +package foundation.e.accountmanager.syncadapter import android.content.ContentProvider import android.content.ContentValues import android.net.Uri +@Suppress("ImplicitNullableNothingType") class AddressBookProvider: ContentProvider() { override fun onCreate() = false - override fun insert(p0: Uri?, p1: ContentValues?) = null - override fun query(p0: Uri?, p1: Array?, p2: String?, p3: Array?, p4: String?) = null - override fun update(p0: Uri?, p1: ContentValues?, p2: String?, p3: Array?) = 0 - override fun delete(p0: Uri?, p1: String?, p2: Array?) = 0 - override fun getType(p0: Uri?) = null + override fun insert(p0: Uri, p1: ContentValues?) = null + override fun query(p0: Uri, p1: Array?, p2: String?, p3: Array?, p4: String?) = null + override fun update(p0: Uri, p1: ContentValues?, p2: String?, p3: Array?) = 0 + override fun delete(p0: Uri, p1: String?, p2: Array?) = 0 + override fun getType(p0: Uri) = null } diff --git a/app/src/main/java/foundation/e/accountmanager/syncadapter/AddressBooksSyncAdapterService.kt b/app/src/main/java/foundation/e/accountmanager/syncadapter/AddressBooksSyncAdapterService.kt new file mode 100644 index 0000000000000000000000000000000000000000..11c4fc8e1cf7447b71b57d58fc24ad2e5ca0bf86 --- /dev/null +++ b/app/src/main/java/foundation/e/accountmanager/syncadapter/AddressBooksSyncAdapterService.kt @@ -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 foundation.e.accountmanager.syncadapter + +import android.Manifest +import android.accounts.Account +import android.content.* +import android.content.pm.PackageManager +import android.os.Bundle +import android.provider.ContactsContract +import androidx.core.content.ContextCompat +import foundation.e.accountmanager.closeCompat +import foundation.e.accountmanager.log.Logger +import foundation.e.accountmanager.model.AppDatabase +import foundation.e.accountmanager.model.Collection +import foundation.e.accountmanager.model.Service +import foundation.e.accountmanager.resource.LocalAddressBook +import foundation.e.accountmanager.settings.AccountSettings +import foundation.e.accountmanager.ui.account.AccountActivity +import okhttp3.HttpUrl +import java.util.logging.Level + +class AddressBooksSyncAdapterService : SyncAdapterService() { + + override fun syncAdapter() = AddressBooksSyncAdapter(this) + + + class AddressBooksSyncAdapter( + context: Context + ) : SyncAdapter(context) { + + override fun sync(account: Account, extras: Bundle, authority: String, provider: ContentProviderClient, syncResult: SyncResult) { + try { + val accountSettings = AccountSettings(context, account) + + /* don't run sync if + - sync conditions (e.g. "sync only in WiFi") are not met AND + - this is is an automatic sync (i.e. manual syncs are run regardless of sync conditions) + */ + if (!extras.containsKey(ContentResolver.SYNC_EXTRAS_MANUAL) && !checkSyncConditions(accountSettings)) + return + + if (updateLocalAddressBooks(account, syncResult)) + for (addressBookAccount in LocalAddressBook.findAll(context, null, account).map { it.account }) { + Logger.log.log(Level.INFO, "Running sync for address book", addressBookAccount) + val syncExtras = Bundle(extras) + syncExtras.putBoolean(ContentResolver.SYNC_EXTRAS_IGNORE_SETTINGS, true) + syncExtras.putBoolean(ContentResolver.SYNC_EXTRAS_IGNORE_BACKOFF, true) + ContentResolver.requestSync(addressBookAccount, ContactsContract.AUTHORITY, syncExtras) + } + } catch (e: Exception) { + Logger.log.log(Level.SEVERE, "Couldn't sync address books", e) + } + + Logger.log.info("Address book sync complete") + } + + private fun updateLocalAddressBooks(account: Account, syncResult: SyncResult): Boolean { + val db = AppDatabase.getInstance(context) + val service = db.serviceDao().getByAccountAndType(account.name, Service.TYPE_CARDDAV) + + val remoteAddressBooks = mutableMapOf() + if (service != null) + for (collection in db.collectionDao().getByServiceAndSync(service.id)) + remoteAddressBooks[collection.url] = collection + + if (ContextCompat.checkSelfPermission(context, Manifest.permission.WRITE_CONTACTS) != PackageManager.PERMISSION_GRANTED) { + if (remoteAddressBooks.isEmpty()) + Logger.log.info("No contacts permission, but no address book selected for synchronization") + else { + // no contacts permission, but address books should be synchronized -> show notification + val intent = Intent(context, AccountActivity::class.java) + intent.putExtra(AccountActivity.EXTRA_ACCOUNT, account) + intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + + notifyPermissions(intent) + } + return false + } + + val contactsProvider = context.contentResolver.acquireContentProviderClient(ContactsContract.AUTHORITY) + try { + if (contactsProvider == null) { + Logger.log.severe("Couldn't access contacts provider") + syncResult.databaseError = true + return false + } + + // delete/update local address books + for (addressBook in LocalAddressBook.findAll(context, contactsProvider, account)) { + val url = HttpUrl.parse(addressBook.url)!! + val info = remoteAddressBooks[url] + if (info == null) { + Logger.log.log(Level.INFO, "Deleting obsolete local address book", url) + addressBook.delete() + } else { + // remote CollectionInfo found for this local collection, update data + try { + Logger.log.log(Level.FINE, "Updating local address book $url", info) + addressBook.update(info) + } catch (e: Exception) { + Logger.log.log(Level.WARNING, "Couldn't rename address book account", e) + } + // we already have a local address book for this remote collection, don't take into consideration anymore + remoteAddressBooks -= url + } + } + + // create new local address books + for ((_, info) in remoteAddressBooks) { + Logger.log.log(Level.INFO, "Adding local address book", info) + LocalAddressBook.create(context, contactsProvider, account, info) + } + } finally { + contactsProvider?.closeCompat() + } + + return true + } + + } + +} diff --git a/app/src/main/java/at/bitfire/davdroid/syncadapter/CalendarSyncManager.kt b/app/src/main/java/foundation/e/accountmanager/syncadapter/CalendarSyncManager.kt similarity index 68% rename from app/src/main/java/at/bitfire/davdroid/syncadapter/CalendarSyncManager.kt rename to app/src/main/java/foundation/e/accountmanager/syncadapter/CalendarSyncManager.kt index aa56d43f5dc21b6567cd50d12d80082251bc4ab0..bbdb535c3635e7ef9c5c0b7d290625e46dcae1dc 100644 --- a/app/src/main/java/at/bitfire/davdroid/syncadapter/CalendarSyncManager.kt +++ b/app/src/main/java/foundation/e/accountmanager/syncadapter/CalendarSyncManager.kt @@ -6,28 +6,30 @@ * http://www.gnu.org/licenses/gpl.html */ -package at.bitfire.davdroid.syncadapter +package foundation.e.accountmanager.syncadapter import android.accounts.Account import android.content.Context import android.content.SyncResult import android.os.Bundle -import at.bitfire.dav4android.DavCalendar -import at.bitfire.dav4android.DavResource -import at.bitfire.dav4android.DavResponseCallback -import at.bitfire.dav4android.Response -import at.bitfire.dav4android.exception.DavException -import at.bitfire.dav4android.property.* -import at.bitfire.davdroid.AccountSettings -import at.bitfire.davdroid.DavUtils -import at.bitfire.davdroid.log.Logger -import at.bitfire.davdroid.model.SyncState -import at.bitfire.davdroid.resource.LocalCalendar -import at.bitfire.davdroid.resource.LocalEvent -import at.bitfire.davdroid.resource.LocalResource -import at.bitfire.davdroid.settings.ISettings -import at.bitfire.ical4android.Event -import at.bitfire.ical4android.InvalidCalendarException +import foundation.e.dav4jvm.DavCalendar +import foundation.e.dav4jvm.DavResource +import foundation.e.dav4jvm.DavResponseCallback +import foundation.e.dav4jvm.Response +import foundation.e.dav4jvm.exception.DavException +import foundation.e.dav4jvm.property.* +import foundation.e.accountmanager.DavUtils +import foundation.e.accountmanager.R +import foundation.e.accountmanager.log.Logger +import foundation.e.accountmanager.model.SyncState +import foundation.e.accountmanager.resource.LocalCalendar +import foundation.e.accountmanager.resource.LocalEvent +import foundation.e.accountmanager.resource.LocalResource +import foundation.e.accountmanager.settings.AccountSettings +import foundation.e.ical4android.Event +import foundation.e.ical4android.InvalidCalendarException +import net.fortuna.ical4j.model.Dur +import net.fortuna.ical4j.model.component.VAlarm import okhttp3.HttpUrl import okhttp3.RequestBody import java.io.ByteArrayOutputStream @@ -41,18 +43,17 @@ import java.util.logging.Level */ class CalendarSyncManager( context: Context, - settings: ISettings, account: Account, accountSettings: AccountSettings, extras: Bundle, authority: String, syncResult: SyncResult, localCalendar: LocalCalendar -): SyncManager(context, settings, account, accountSettings, extras, authority, syncResult, localCalendar) { +): SyncManager(context, account, accountSettings, extras, authority, syncResult, localCalendar) { override fun prepare(): Boolean { collectionURL = HttpUrl.parse(localCollection.name ?: return false) ?: return false - davCollection = DavCalendar(httpClient.okHttpClient, collectionURL) + davCollection = DavCalendar(httpClient.okHttpClient, collectionURL, accountSettings.credentials().authState?.accessToken) // if there are dirty exceptions for events, mark their master events as dirty, too localCollection.processDirtyExceptions() @@ -65,8 +66,8 @@ class CalendarSyncManager( var syncState: SyncState? = null it.propfind(0, SupportedReportSet.NAME, GetCTag.NAME, SyncToken.NAME) { response, relation -> if (relation == Response.HrefRelation.SELF) { - response[SupportedReportSet::class.java]?.let { - hasCollectionSync = it.reports.contains(SupportedReportSet.SYNC_COLLECTION) + response[SupportedReportSet::class.java]?.let { supported -> + hasCollectionSync = supported.reports.contains(SupportedReportSet.SYNC_COLLECTION) } syncState = syncState(response) @@ -115,7 +116,7 @@ class CalendarSyncManager( if (bunch.size == 1) { val remote = bunch.first() // only one contact, use GET - useRemote(DavResource(httpClient.okHttpClient, remote)) { resource -> + useRemote(DavResource(httpClient.okHttpClient, remote, accountSettings.credentials().authState?.accessToken)) { resource -> resource.get(DavCalendar.MIME_ICALENDAR.toString()) { response -> // CalDAV servers MUST return ETag on GET [https://tools.ietf.org/html/rfc4791#section-5.3.4] val eTag = response.header("ETag")?.let { GetETag(it).eTag } @@ -131,6 +132,11 @@ class CalendarSyncManager( useRemoteCollection { it.multiget(bunch) { response, _ -> useRemote(response) { + if (!response.isSuccess()) { + Logger.log.warning("Received non-successful multiget response for ${response.href}") + return@useRemote + } + val eTag = response[GetETag::class.java]?.eTag ?: throw DavException("Received multi-get response without ETag") @@ -153,26 +159,34 @@ class CalendarSyncManager( private fun processVEvent(fileName: String, eTag: String, reader: Reader) { val events: List try { - events = Event.fromReader(reader) + events = Event.eventsFromReader(reader) } catch (e: InvalidCalendarException) { Logger.log.log(Level.SEVERE, "Received invalid iCalendar, ignoring", e) - // TODO show notification + notifyInvalidResource(e, fileName) return } if (events.size == 1) { - val newData = events.first() + val event = events.first() + + // set default reminder for non-full-day events, if requested + val defaultAlarmMinBefore = accountSettings.getDefaultAlarm() + if (defaultAlarmMinBefore != null && !event.isAllDay() && event.alarms.isEmpty()) { + val alarm = VAlarm(Dur(0, 0, -defaultAlarmMinBefore, 0)) + Logger.log.log(Level.FINE, "${event.uid}: Adding default alarm", alarm) + event.alarms += alarm + } - // delete local event, if it exists + // update local event, if it exists useLocal(localCollection.findByName(fileName)) { local -> if (local != null) { - Logger.log.info("Updating $fileName in local calendar") + Logger.log.log(Level.INFO, "Updating $fileName in local calendar", event) local.eTag = eTag - local.update(newData) + local.update(event) syncResult.stats.numUpdates++ } else { - Logger.log.info("Adding $fileName to local calendar") - useLocal(LocalEvent(localCollection, newData, fileName, eTag, LocalResource.FLAG_REMOTELY_PRESENT)) { + Logger.log.log(Level.INFO, "Adding $fileName to local calendar", event) + useLocal(LocalEvent(localCollection, event, fileName, eTag, LocalResource.FLAG_REMOTELY_PRESENT)) { it.add() } syncResult.stats.numInserts++ @@ -182,4 +196,7 @@ class CalendarSyncManager( Logger.log.info("Received VCALENDAR with not exactly one VEVENT with UID and without RECURRENCE-ID; ignoring $fileName") } + override fun notifyInvalidResourceTitle(): String = + context.getString(R.string.sync_invalid_event) + } diff --git a/app/src/main/java/foundation/e/accountmanager/syncadapter/CalendarsSyncAdapterService.kt b/app/src/main/java/foundation/e/accountmanager/syncadapter/CalendarsSyncAdapterService.kt new file mode 100644 index 0000000000000000000000000000000000000000..cfbb607e5fa5390cf2b3b1c87138bfd77d13f030 --- /dev/null +++ b/app/src/main/java/foundation/e/accountmanager/syncadapter/CalendarsSyncAdapterService.kt @@ -0,0 +1,136 @@ +/* + * 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 foundation.e.accountmanager.syncadapter + +import android.accounts.Account +import android.content.ContentProviderClient +import android.content.ContentResolver +import android.content.Context +import android.content.SyncResult +import android.os.Bundle +import android.os.AsyncTask +import android.provider.CalendarContract +import foundation.e.accountmanager.log.Logger +import foundation.e.accountmanager.model.AppDatabase +import foundation.e.accountmanager.model.Collection +import foundation.e.accountmanager.model.Credentials +import foundation.e.accountmanager.model.Service +import foundation.e.accountmanager.resource.LocalCalendar +import foundation.e.accountmanager.settings.AccountSettings +import foundation.e.ical4android.AndroidCalendar +import net.openid.appauth.AuthorizationService +import okhttp3.HttpUrl +import java.util.logging.Level + +class CalendarsSyncAdapterService: SyncAdapterService() { + + override fun syncAdapter() = CalendarsSyncAdapter(this) + + + class CalendarsSyncAdapter( + context: Context + ): SyncAdapter(context) { + + override fun sync(account: Account, extras: Bundle, authority: String, provider: ContentProviderClient, syncResult: SyncResult) { + try { + val accountSettings = AccountSettings(context, account) + + /* don't run sync if + - sync conditions (e.g. "sync only in WiFi") are not met AND + - this is is an automatic sync (i.e. manual syncs are run regardless of sync conditions) + */ + if (!extras.containsKey(ContentResolver.SYNC_EXTRAS_MANUAL) && !checkSyncConditions(accountSettings)) + return + + if (accountSettings.getEventColors()) + AndroidCalendar.insertColors(provider, account) + else + AndroidCalendar.removeColors(provider, account) + + updateLocalCalendars(provider, account, accountSettings) + + val priorityCalendars = priorityCollections(extras) + val calendars = AndroidCalendar + .find(account, provider, LocalCalendar.Factory, "${CalendarContract.Calendars.SYNC_EVENTS}!=0", null) + .sortedByDescending { priorityCalendars.contains(it.id) } + for (calendar in calendars) { + Logger.log.info("Synchronizing calendar #${calendar.id}, URL: ${calendar.name}") + CalendarSyncManager(context, account, accountSettings, extras, authority, syncResult, calendar).use { + val authState = accountSettings.credentials().authState + if (authState != null) + { + if (authState.needsTokenRefresh) + { + val tokenRequest = authState.createTokenRefreshRequest() + + AuthorizationService(context).performTokenRequest(tokenRequest, + AuthorizationService.TokenResponseCallback { tokenResponse, ex -> + authState.update(tokenResponse, ex) + accountSettings.credentials(Credentials(account.name, null, authState, null)) + it.accountSettings.credentials(Credentials(it.account.name, null, authState, null)) + object : AsyncTask() { + override fun doInBackground(vararg params: Void): Void? { + it.performSync() + return null + } + }.execute() + }) + } + else { + it.performSync() + } + } + else { + it.performSync() + } + } + } + } catch(e: Exception) { + Logger.log.log(Level.SEVERE, "Couldn't sync calendars", e) + } + Logger.log.info("Calendar sync complete") + } + + private fun updateLocalCalendars(provider: ContentProviderClient, account: Account, settings: AccountSettings) { + val db = AppDatabase.getInstance(context) + val service = db.serviceDao().getByAccountAndType(account.name, Service.TYPE_CALDAV) + + val remoteCalendars = mutableMapOf() + if (service != null) + for (collection in db.collectionDao().getSyncCalendars(service.id)) { + remoteCalendars[collection.url] = collection + } + + // delete/update local calendars + val updateColors = settings.getManageCalendarColors() + for (calendar in AndroidCalendar.find(account, provider, LocalCalendar.Factory, null, null)) + calendar.name?.let { + val url = HttpUrl.parse(it)!! + val info = remoteCalendars[url] + if (info == null) { + Logger.log.log(Level.INFO, "Deleting obsolete local calendar", url) + calendar.delete() + } else { + // remote CollectionInfo found for this local collection, update data + Logger.log.log(Level.FINE, "Updating local calendar $url", info) + calendar.update(info, updateColors) + // we already have a local calendar for this remote collection, don't take into consideration anymore + remoteCalendars -= url + } + } + + // create new local calendars + for ((_, info) in remoteCalendars) { + Logger.log.log(Level.INFO, "Adding local calendar", info) + LocalCalendar.create(account, provider, info) + } + } + + } + +} diff --git a/app/src/main/java/foundation/e/accountmanager/syncadapter/ContactsSyncAdapterService.kt b/app/src/main/java/foundation/e/accountmanager/syncadapter/ContactsSyncAdapterService.kt new file mode 100644 index 0000000000000000000000000000000000000000..5febaafd276205b6acda8c0222426b399d4d661d --- /dev/null +++ b/app/src/main/java/foundation/e/accountmanager/syncadapter/ContactsSyncAdapterService.kt @@ -0,0 +1,107 @@ +/* + * 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 foundation.e.accountmanager.syncadapter + +import android.accounts.Account +import android.content.ContentProviderClient +import android.content.ContentResolver +import android.content.Context +import android.content.SyncResult +import android.os.AsyncTask +import android.os.Bundle +import android.provider.ContactsContract +import foundation.e.accountmanager.log.Logger +import foundation.e.accountmanager.resource.LocalAddressBook +import foundation.e.accountmanager.model.Credentials +import foundation.e.accountmanager.settings.AccountSettings +import net.openid.appauth.AuthorizationService +import java.util.logging.Level + +class ContactsSyncAdapterService: SyncAdapterService() { + + companion object { + const val PREVIOUS_GROUP_METHOD = "previous_group_method" + } + + override fun syncAdapter() = ContactsSyncAdapter(this) + + + class ContactsSyncAdapter( + context: Context + ): SyncAdapter(context) { + + override fun sync(account: Account, extras: Bundle, authority: String, provider: ContentProviderClient, syncResult: SyncResult) { + try { + val addressBook = LocalAddressBook(context, account, provider) + val accountSettings = AccountSettings(context, addressBook.mainAccount) + + // handle group method change + val groupMethod = accountSettings.getGroupMethod().name + accountSettings.accountManager.getUserData(account, PREVIOUS_GROUP_METHOD)?.let { previousGroupMethod -> + if (previousGroupMethod != groupMethod) { + Logger.log.info("Group method changed, deleting all local contacts/groups") + + // delete all local contacts and groups so that they will be downloaded again + provider.delete(addressBook.syncAdapterURI(ContactsContract.RawContacts.CONTENT_URI), null, null) + provider.delete(addressBook.syncAdapterURI(ContactsContract.Groups.CONTENT_URI), null, null) + + // reset sync state + addressBook.syncState = null + } + } + accountSettings.accountManager.setUserData(account, PREVIOUS_GROUP_METHOD, groupMethod) + + /* don't run sync if + - sync conditions (e.g. "sync only in WiFi") are not met AND + - this is is an automatic sync (i.e. manual syncs are run regardless of sync conditions) + */ + if (!extras.containsKey(ContentResolver.SYNC_EXTRAS_MANUAL) && !checkSyncConditions(accountSettings)) + return + + Logger.log.info("Synchronizing address book: ${addressBook.url}") + Logger.log.info("Taking settings from: ${addressBook.mainAccount}") + + ContactsSyncManager(context, account, accountSettings, extras, authority, syncResult, provider, addressBook).use { + val authState = accountSettings.credentials().authState + if (authState != null) + { + if (authState.needsTokenRefresh) + { + val tokenRequest = authState.createTokenRefreshRequest() + + AuthorizationService(context).performTokenRequest(tokenRequest, + AuthorizationService.TokenResponseCallback { tokenResponse, ex -> + authState.update(tokenResponse, ex) + accountSettings.credentials(Credentials(account.name, null, authState, null)) + it.accountSettings.credentials(Credentials(it.account.name, null, authState, null)) + object : AsyncTask() { + override fun doInBackground(vararg params: Void): Void? { + it.performSync() + return null + } + }.execute() + }) + } + else { + it.performSync() + } + } + else { + it.performSync() + } + } + } catch(e: Exception) { + Logger.log.log(Level.SEVERE, "Couldn't sync contacts", e) + } + Logger.log.info("Contacts sync complete") + } + + } + +} diff --git a/app/src/main/java/at/bitfire/davdroid/syncadapter/ContactsSyncManager.kt b/app/src/main/java/foundation/e/accountmanager/syncadapter/ContactsSyncManager.kt similarity index 80% rename from app/src/main/java/at/bitfire/davdroid/syncadapter/ContactsSyncManager.kt rename to app/src/main/java/foundation/e/accountmanager/syncadapter/ContactsSyncManager.kt index 54409f403ec1e6ad4c6059dad615a8dbd2524c1d..ab87e50227f6c185f002f3f0472d3e49763a42b9 100644 --- a/app/src/main/java/at/bitfire/davdroid/syncadapter/ContactsSyncManager.kt +++ b/app/src/main/java/foundation/e/accountmanager/syncadapter/ContactsSyncManager.kt @@ -6,33 +6,31 @@ * http://www.gnu.org/licenses/gpl.html */ -package at.bitfire.davdroid.syncadapter +package foundation.e.accountmanager.syncadapter import android.accounts.Account import android.content.* import android.os.Build import android.os.Bundle import android.provider.ContactsContract.Groups -import android.support.v4.app.NotificationCompat -import at.bitfire.dav4android.DavAddressBook -import at.bitfire.dav4android.DavResource -import at.bitfire.dav4android.DavResponseCallback -import at.bitfire.dav4android.Response -import at.bitfire.dav4android.exception.DavException -import at.bitfire.dav4android.property.* -import at.bitfire.davdroid.AccountSettings -import at.bitfire.davdroid.DavUtils -import at.bitfire.davdroid.HttpClient -import at.bitfire.davdroid.R -import at.bitfire.davdroid.log.Logger -import at.bitfire.davdroid.model.SyncState -import at.bitfire.davdroid.resource.* -import at.bitfire.davdroid.settings.ISettings -import at.bitfire.davdroid.ui.NotificationUtils -import at.bitfire.vcard4android.BatchOperation -import at.bitfire.vcard4android.Contact -import at.bitfire.vcard4android.GroupMethod +import foundation.e.dav4jvm.DavAddressBook +import foundation.e.dav4jvm.DavResource +import foundation.e.dav4jvm.DavResponseCallback +import foundation.e.dav4jvm.Response +import foundation.e.dav4jvm.exception.DavException +import foundation.e.dav4jvm.property.* +import foundation.e.accountmanager.DavUtils +import foundation.e.accountmanager.HttpClient +import foundation.e.accountmanager.R +import foundation.e.accountmanager.log.Logger +import foundation.e.accountmanager.model.SyncState +import foundation.e.accountmanager.resource.* +import foundation.e.accountmanager.settings.AccountSettings +import foundation.e.vcard4android.BatchOperation +import foundation.e.vcard4android.Contact +import foundation.e.vcard4android.GroupMethod import ezvcard.VCardVersion +import ezvcard.io.CannotParseException import okhttp3.HttpUrl import okhttp3.Request import okhttp3.RequestBody @@ -59,11 +57,11 @@ import java.util.logging.Level * to be checked whether its group memberships have changed. In this case, the respective * groups have to be set to dirty. For instance, if contact A is in group G and H, and then * group membership of G is removed, the contact will be set to dirty because of the changed - * [android.provider.ContactsContract.CommonDataKinds.GroupMembership]. DAVdroid will + * [android.provider.ContactsContract.CommonDataKinds.GroupMembership]. DAVx5 will * then have to check whether the group memberships have actually changed, and if so, * all affected groups have to be set to dirty. To detect changes in group memberships, - * DAVdroid always mirrors all [android.provider.ContactsContract.CommonDataKinds.GroupMembership] - * data rows in respective [at.bitfire.vcard4android.CachedGroupMembership] rows. + * DAVx5 always mirrors all [android.provider.ContactsContract.CommonDataKinds.GroupMembership] + * data rows in respective [foundation.e.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]. @@ -76,7 +74,6 @@ import java.util.logging.Level */ class ContactsSyncManager( context: Context, - settings: ISettings, account: Account, accountSettings: AccountSettings, extras: Bundle, @@ -84,14 +81,14 @@ class ContactsSyncManager( syncResult: SyncResult, val provider: ContentProviderClient, localAddressBook: LocalAddressBook -): SyncManager(context, settings, account, accountSettings, extras, authority, syncResult, localAddressBook) { +): SyncManager(context, account, accountSettings, extras, authority, syncResult, localAddressBook) { companion object { infix fun Set.disjunct(other: Set) = (this - other) union (other - this) } private val readOnly = localAddressBook.readOnly - private var numDiscarded = 0 + private val accessToken: String? = accountSettings.credentials().authState?.accessToken private var hasVCard4 = false private val groupMethod = accountSettings.getGroupMethod() @@ -114,7 +111,7 @@ class ContactsSyncManager( } collectionURL = HttpUrl.parse(localCollection.url) ?: return false - davCollection = DavAddressBook(httpClient.okHttpClient, collectionURL) + davCollection = DavAddressBook(httpClient.okHttpClient, collectionURL, accountSettings.credentials().authState?.accessToken) resourceDownloader = ResourceDownloader(davCollection.location) @@ -130,12 +127,12 @@ class ContactsSyncManager( var syncState: SyncState? = null it.propfind(0, SupportedAddressData.NAME, SupportedReportSet.NAME, GetCTag.NAME, SyncToken.NAME) { response, relation -> if (relation == Response.HrefRelation.SELF) { - response[SupportedAddressData::class.java]?.let { - hasVCard4 = it.hasVCard4() + response[SupportedAddressData::class.java]?.let { supported -> + hasVCard4 = supported.hasVCard4() } - response[SupportedReportSet::class.java]?.let { - hasCollectionSync = it.reports.contains(SupportedReportSet.SYNC_COLLECTION) + response[SupportedReportSet::class.java]?.let { supported -> + hasCollectionSync = supported.reports.contains(SupportedReportSet.SYNC_COLLECTION) } syncState = syncState(response) @@ -154,45 +151,35 @@ class ContactsSyncManager( else SyncAlgorithm.PROPFIND_REPORT - override fun processLocallyDeleted(): Boolean { - if (readOnly) { - for (group in localCollection.findDeletedGroups()) { - Logger.log.warning("Restoring locally deleted group (read-only address book!)") - useLocal(group) { it.resetDeleted() } - numDiscarded++ - } + override fun processLocallyDeleted() = + if (readOnly) { + for (group in localCollection.findDeletedGroups()) { + Logger.log.warning("Restoring locally deleted group (read-only address book!)") + useLocal(group) { it.resetDeleted() } + } - for (contact in localCollection.findDeletedContacts()) { - Logger.log.warning("Restoring locally deleted contact (read-only address book!)") - useLocal(contact) { it.resetDeleted() } - numDiscarded++ - } + for (contact in localCollection.findDeletedContacts()) { + Logger.log.warning("Restoring locally deleted contact (read-only address book!)") + useLocal(contact) { it.resetDeleted() } + } - if (numDiscarded > 0) - notifyDiscardedChange() - return false - } else - // mirror deletions to remote collection (DELETE) - return super.processLocallyDeleted() - } + false + } else + // mirror deletions to remote collection (DELETE) + super.processLocallyDeleted() override fun uploadDirty(): Boolean { if (readOnly) { for (group in localCollection.findDirtyGroups()) { Logger.log.warning("Resetting locally modified group to ETag=null (read-only address book!)") useLocal(group) { it.clearDirty(null) } - numDiscarded++ } for (contact in localCollection.findDirtyContacts()) { Logger.log.warning("Resetting locally modified contact to ETag=null (read-only address book!)") useLocal(contact) { it.clearDirty(null) } - numDiscarded++ } - if (numDiscarded > 0) - notifyDiscardedChange() - } else { if (groupMethod == GroupMethod.CATEGORIES) { /* groups memberships are represented as contact CATEGORIES */ @@ -241,20 +228,6 @@ class ContactsSyncManager( return super.uploadDirty() } - private fun notifyDiscardedChange() { - val notification = NotificationUtils.newBuilder(context, NotificationUtils.CHANNEL_SYNC_STATUS) - .setSmallIcon(R.drawable.ic_delete_notification) - .setContentTitle(context.getString(R.string.sync_contacts_read_only_address_book)) - .setContentText(context.resources.getQuantityString(R.plurals.sync_contacts_local_contact_changes_discarded, numDiscarded, numDiscarded)) - .setNumber(numDiscarded) - .setSubText(account.name) - .setCategory(NotificationCompat.CATEGORY_STATUS) - .setPriority(NotificationCompat.PRIORITY_MIN) - .setLocalOnly(true) - .build() - notificationManager.notify("discarded_${account.name}", 0, notification) - } - override fun prepareUpload(resource: LocalAddress): RequestBody = useLocal(resource) { val contact: Contact if (resource is LocalContact) { @@ -301,7 +274,7 @@ class ContactsSyncManager( if (bunch.size == 1) { val remote = bunch.first() // only one contact, use GET - useRemote(DavResource(httpClient.okHttpClient, remote)) { resource -> + useRemote(DavResource(httpClient.okHttpClient, remote, accountSettings.credentials().authState?.accessToken)) { resource -> resource.get("text/vcard;version=4.0, text/vcard;charset=utf-8;q=0.8, text/vcard;q=0.5") { response -> // CardDAV servers MUST return ETag on GET [https://tools.ietf.org/html/rfc6352#section-6.3.2.3] val eTag = response.header("ETag")?.let { GetETag(it).eTag } @@ -317,6 +290,11 @@ class ContactsSyncManager( useRemoteCollection { it.multiget(bunch, hasVCard4) { response, _ -> useRemote(response) { + if (!response.isSuccess()) { + Logger.log.warning("Received non-successful multiget response for ${response.href}") + return@useRemote + } + val eTag = response[GetETag::class.java]?.eTag ?: throw DavException("Received multi-get response without ETag") @@ -351,19 +329,24 @@ class ContactsSyncManager( private fun processVCard(fileName: String, eTag: String, reader: Reader, downloader: Contact.Downloader) { Logger.log.info("Processing CardDAV resource $fileName") - // TODO catch and show notification on CannotParseException - val contacts = Contact.fromReader(reader, downloader) + val contacts = try { + Contact.fromReader(reader, downloader) + } catch (e: CannotParseException) { + Logger.log.log(Level.SEVERE, "Received invalid vCard, ignoring", e) + notifyInvalidResource(e, fileName) + return + } if (contacts.isEmpty()) { - Logger.log.warning("Received VCard without data, ignoring") + Logger.log.warning("Received vCard without data, ignoring") return } else if (contacts.size > 1) - Logger.log.warning("Received multiple VCards, using first one") + Logger.log.warning("Received multiple vCards, using first one") val newData = contacts.first() if (groupMethod == GroupMethod.CATEGORIES && newData.group) { - Logger.log.warning("Received group VCard although group method is CATEGORIES. Saving as regular contact") + Logger.log.warning("Received group vCard although group method is CATEGORIES. Saving as regular contact") newData.group = false } @@ -397,15 +380,15 @@ class ContactsSyncManager( if (local == null) { if (newData.group) { Logger.log.log(Level.INFO, "Creating local group", newData) - useLocal(LocalGroup(localCollection, newData, fileName, eTag, LocalResource.FLAG_REMOTELY_PRESENT)) { - it.add() - local = it + useLocal(LocalGroup(localCollection, newData, fileName, eTag, LocalResource.FLAG_REMOTELY_PRESENT)) { group -> + group.add() + local = group } } else { Logger.log.log(Level.INFO, "Creating local contact", newData) - useLocal(LocalContact(localCollection, newData, fileName, eTag, LocalResource.FLAG_REMOTELY_PRESENT)) { - it.add() - local = it + useLocal(LocalContact(localCollection, newData, fileName, eTag, LocalResource.FLAG_REMOTELY_PRESENT)) { contact -> + contact.add() + local = contact } } syncResult.stats.numInserts++ @@ -447,12 +430,6 @@ class ContactsSyncManager( return null } - val host = httpUrl.host() - if (host == null) { - Logger.log.log(Level.SEVERE, "External resource URL doesn't specify a host name", url) - return null - } - // authenticate only against a certain host, and only upon request val builder = HttpClient.Builder(context, baseUrl.host(), accountSettings.credentials()) @@ -461,10 +438,17 @@ class ContactsSyncManager( val client = builder.build() try { - val response = client.okHttpClient.newCall(Request.Builder() - .get() - .url(httpUrl) - .build()).execute() + val requestBuilder = Request.Builder() + .get() + .url(httpUrl) + + if (accessToken!!.isNotEmpty()) { + requestBuilder.header("Authorization", "Bearer $accessToken") + } + + val response = client.okHttpClient.newCall(requestBuilder + .build()) + .execute() if (response.isSuccessful) return response.body()?.bytes() @@ -479,4 +463,7 @@ class ContactsSyncManager( } } + override fun notifyInvalidResourceTitle(): String = + context.getString(R.string.sync_invalid_contact) + } diff --git a/app/src/main/java/foundation/e/accountmanager/syncadapter/EeloAccountAuthenticatorService.kt b/app/src/main/java/foundation/e/accountmanager/syncadapter/EeloAccountAuthenticatorService.kt new file mode 100644 index 0000000000000000000000000000000000000000..a90e7db49e916c93954f7d5817ba7345c028edce --- /dev/null +++ b/app/src/main/java/foundation/e/accountmanager/syncadapter/EeloAccountAuthenticatorService.kt @@ -0,0 +1,138 @@ +/* + * 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 foundation.e.accountmanager.syncadapter + +import android.accounts.* +import android.app.Service +import android.content.Context +import android.content.Intent +import android.database.DatabaseUtils +import android.os.Bundle +import androidx.annotation.WorkerThread +import foundation.e.accountmanager.R +import foundation.e.accountmanager.log.Logger +import foundation.e.accountmanager.model.AppDatabase +import foundation.e.accountmanager.resource.LocalAddressBook +import foundation.e.accountmanager.ui.setup.LoginActivity +import java.util.* +import java.util.logging.Level +import kotlin.concurrent.thread +import kotlin.collections.ArrayList +import kotlin.collections.HashSet + +/** + * Account authenticator for the eelo account type. + * + * Gets started when an eelo account is removed, too, so it also watches for account removals + * and contains the corresponding cleanup code. + */ +class EeloAccountAuthenticatorService : Service(), OnAccountsUpdateListener { + + companion object { + + fun cleanupAccounts(context: Context) { + Logger.log.info("Cleaning up orphaned accounts") + + val accountManager = AccountManager.get(context) + + val accountNames = HashSet() + + val accounts = ArrayList() + accountManager.getAccountsByType(context.getString(R.string.eelo_account_type)).forEach { accounts.add(it) } + accountManager.getAccountsByType(context.getString(R.string.google_account_type)).forEach { accounts.add(it) } + accountManager.getAccountsByType(context.getString(R.string.account_type)).forEach { accounts.add(it) } + for (account in accounts.toTypedArray()) + accountNames += account.name + + // delete orphaned address book accounts + val addressBookAccounts = ArrayList() + accountManager.getAccountsByType(context.getString(R.string.account_type_eelo_address_book)).forEach { addressBookAccounts.add(it) } + accountManager.getAccountsByType(context.getString(R.string.account_type_google_address_book)).forEach { addressBookAccounts.add(it) } + accountManager.getAccountsByType(context.getString(R.string.account_type_address_book)).forEach { addressBookAccounts.add(it) } + addressBookAccounts.map { LocalAddressBook(context, it, null) } + .forEach { + try { + if (!accountNames.contains(it.mainAccount.name)) + it.delete() + } catch(e: Exception) { + Logger.log.log(Level.SEVERE, "Couldn't delete address book account", e) + } + } + + // delete orphaned services in DB + val db = AppDatabase.getInstance(context) + val serviceDao = db.serviceDao() + if (accountNames.isEmpty()) + serviceDao.deleteAll() + else + serviceDao.deleteExceptAccounts(accountNames.toTypedArray()) + } + + } + + + 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?) { + thread { + cleanupAccounts(this) + val eeloAccounts = ArrayList(accounts?.asList()?: emptyList()) + eeloAccounts.removeIf { it.type != getString(R.string.eelo_account_type) } + eeloAccounts.removeAll(accountManager.getAccountsByType(getString( + R.string.eelo_account_type))) + for (removedAccount in eeloAccounts) { + val intent = Intent("drive.services.ResetService") + intent.setPackage(getString(R.string.e_drive_package_name)) + intent.putExtra(AccountManager.KEY_ACCOUNT_NAME, removedAccount.name) + intent.putExtra(AccountManager.KEY_ACCOUNT_TYPE, removedAccount.type) + startService(intent) + } + } + } + + + private class AccountAuthenticator( + val context: Context + ) : AbstractAccountAuthenticator(context) { + + override fun addAccount(response: AccountAuthenticatorResponse?, accountType: String?, authTokenType: String?, requiredFeatures: Array?, options: Bundle?): Bundle { + val intent = Intent(context, LoginActivity::class.java) + intent.putExtra(AccountManager.KEY_ACCOUNT_AUTHENTICATOR_RESPONSE, response) + intent.putExtra(LoginActivity.SETUP_ACCOUNT_PROVIDER_TYPE, LoginActivity.ACCOUNT_PROVIDER_EELO) + val bundle = Bundle(1) + bundle.putParcelable(AccountManager.KEY_INTENT, intent) + return bundle + } + + override fun editProperties(response: AccountAuthenticatorResponse?, accountType: String?) = null + override fun getAuthTokenLabel(p0: String?) = null + override fun confirmCredentials(p0: AccountAuthenticatorResponse?, p1: Account?, p2: Bundle?) = null + override fun updateCredentials(p0: AccountAuthenticatorResponse?, p1: Account?, p2: String?, p3: Bundle?) = null + override fun getAuthToken(p0: AccountAuthenticatorResponse?, p1: Account?, p2: String?, p3: Bundle?) = null + override fun hasFeatures(p0: AccountAuthenticatorResponse?, p1: Account?, p2: Array?) = null + + } +} + diff --git a/app/src/main/java/foundation/e/accountmanager/syncadapter/EeloAddressBooksSyncAdapterService.kt b/app/src/main/java/foundation/e/accountmanager/syncadapter/EeloAddressBooksSyncAdapterService.kt new file mode 100644 index 0000000000000000000000000000000000000000..54033ed07012ae7df2fc3f7e9ebec13afbddad39 --- /dev/null +++ b/app/src/main/java/foundation/e/accountmanager/syncadapter/EeloAddressBooksSyncAdapterService.kt @@ -0,0 +1,129 @@ +/* + * 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 foundation.e.accountmanager.syncadapter + +import android.Manifest +import android.accounts.Account +import android.content.* +import android.content.pm.PackageManager +import android.os.Bundle +import android.provider.ContactsContract +import androidx.core.content.ContextCompat +import foundation.e.accountmanager.closeCompat +import foundation.e.accountmanager.log.Logger +import foundation.e.accountmanager.model.AppDatabase +import foundation.e.accountmanager.model.Collection +import foundation.e.accountmanager.model.Service +import foundation.e.accountmanager.resource.LocalAddressBook +import foundation.e.accountmanager.settings.AccountSettings +import foundation.e.accountmanager.ui.account.AccountActivity +import okhttp3.HttpUrl +import java.util.logging.Level + +class EeloAddressBooksSyncAdapterService : SyncAdapterService() { + + override fun syncAdapter() = AddressBooksSyncAdapter(this) + + + class AddressBooksSyncAdapter( + context: Context + ) : SyncAdapter(context) { + + override fun sync(account: Account, extras: Bundle, authority: String, provider: ContentProviderClient, syncResult: SyncResult) { + try { + val accountSettings = AccountSettings(context, account) + + /* don't run sync if + - sync conditions (e.g. "sync only in WiFi") are not met AND + - this is is an automatic sync (i.e. manual syncs are run regardless of sync conditions) + */ + if (!extras.containsKey(ContentResolver.SYNC_EXTRAS_MANUAL) && !checkSyncConditions(accountSettings)) + return + + if (updateLocalAddressBooks(account, syncResult)) + for (addressBookAccount in LocalAddressBook.findAll(context, null, account).map { it.account }) { + Logger.log.log(Level.INFO, "Running sync for address book", addressBookAccount) + val syncExtras = Bundle(extras) + syncExtras.putBoolean(ContentResolver.SYNC_EXTRAS_IGNORE_SETTINGS, true) + syncExtras.putBoolean(ContentResolver.SYNC_EXTRAS_IGNORE_BACKOFF, true) + ContentResolver.requestSync(addressBookAccount, ContactsContract.AUTHORITY, syncExtras) + } + } catch (e: Exception) { + Logger.log.log(Level.SEVERE, "Couldn't sync address books", e) + } + + Logger.log.info("Address book sync complete") + } + + private fun updateLocalAddressBooks(account: Account, syncResult: SyncResult): Boolean { + val db = AppDatabase.getInstance(context) + val service = db.serviceDao().getByAccountAndType(account.name, Service.TYPE_CARDDAV) + + val remoteAddressBooks = mutableMapOf() + if (service != null) + for (collection in db.collectionDao().getByServiceAndSync(service.id)) + remoteAddressBooks[collection.url] = collection + + if (ContextCompat.checkSelfPermission(context, Manifest.permission.WRITE_CONTACTS) != PackageManager.PERMISSION_GRANTED) { + if (remoteAddressBooks.isEmpty()) + Logger.log.info("No contacts permission, but no address book selected for synchronization") + else { + // no contacts permission, but address books should be synchronized -> show notification + val intent = Intent(context, AccountActivity::class.java) + intent.putExtra(AccountActivity.EXTRA_ACCOUNT, account) + intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + + notifyPermissions(intent) + } + return false + } + + val contactsProvider = context.contentResolver.acquireContentProviderClient(ContactsContract.AUTHORITY) + try { + if (contactsProvider == null) { + Logger.log.severe("Couldn't access contacts provider") + syncResult.databaseError = true + return false + } + + // delete/update local address books + for (addressBook in LocalAddressBook.findAll(context, contactsProvider, account)) { + val url = HttpUrl.parse(addressBook.url)!! + val info = remoteAddressBooks[url] + if (info == null) { + Logger.log.log(Level.INFO, "Deleting obsolete local address book", url) + addressBook.delete() + } else { + // remote CollectionInfo found for this local collection, update data + try { + Logger.log.log(Level.FINE, "Updating local address book $url", info) + addressBook.update(info) + } catch (e: Exception) { + Logger.log.log(Level.WARNING, "Couldn't rename address book account", e) + } + // we already have a local address book for this remote collection, don't take into consideration anymore + remoteAddressBooks -= url + } + } + + // create new local address books + for ((_, info) in remoteAddressBooks) { + Logger.log.log(Level.INFO, "Adding local address book", info) + LocalAddressBook.create(context, contactsProvider, account, info) + } + } finally { + contactsProvider?.closeCompat() + } + + return true + } + + } + +} + diff --git a/app/src/main/java/foundation/e/accountmanager/syncadapter/EeloAppDataSyncAdapterService.kt b/app/src/main/java/foundation/e/accountmanager/syncadapter/EeloAppDataSyncAdapterService.kt new file mode 100644 index 0000000000000000000000000000000000000000..0ece7937fbae17dec2fc13ca64ccbcb147fe2bb8 --- /dev/null +++ b/app/src/main/java/foundation/e/accountmanager/syncadapter/EeloAppDataSyncAdapterService.kt @@ -0,0 +1,29 @@ +/* + * 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 foundation.e.accountmanager.syncadapter + +import android.accounts.Account +import android.content.* +import android.os.Bundle + +class EeloAppDataSyncAdapterService : SyncAdapterService() { + + override fun syncAdapter() = CalendarsSyncAdapter(this) + + + class CalendarsSyncAdapter( + context: Context + ): SyncAdapter(context) { + + override fun sync(account: Account, extras: Bundle, authority: String, provider: ContentProviderClient, syncResult: SyncResult) { + // Unused + } + } + +} + diff --git a/app/src/main/java/foundation/e/accountmanager/syncadapter/EeloCalendarsSyncAdapterService.kt b/app/src/main/java/foundation/e/accountmanager/syncadapter/EeloCalendarsSyncAdapterService.kt new file mode 100644 index 0000000000000000000000000000000000000000..e56ac9090ab3ab3b69f0d299ab34fdc267793102 --- /dev/null +++ b/app/src/main/java/foundation/e/accountmanager/syncadapter/EeloCalendarsSyncAdapterService.kt @@ -0,0 +1,137 @@ +/* + * 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 foundation.e.accountmanager.syncadapter + +import android.accounts.Account +import android.content.ContentProviderClient +import android.content.ContentResolver +import android.content.Context +import android.content.SyncResult +import android.os.Bundle +import android.os.AsyncTask +import android.provider.CalendarContract +import foundation.e.accountmanager.log.Logger +import foundation.e.accountmanager.model.AppDatabase +import foundation.e.accountmanager.model.Collection +import foundation.e.accountmanager.model.Credentials +import foundation.e.accountmanager.model.Service +import foundation.e.accountmanager.resource.LocalCalendar +import foundation.e.accountmanager.settings.AccountSettings +import foundation.e.ical4android.AndroidCalendar +import net.openid.appauth.AuthorizationService +import okhttp3.HttpUrl +import java.util.logging.Level + +class EeloCalendarsSyncAdapterService: SyncAdapterService() { + + override fun syncAdapter() = CalendarsSyncAdapter(this) + + + class CalendarsSyncAdapter( + context: Context + ): SyncAdapter(context) { + + override fun sync(account: Account, extras: Bundle, authority: String, provider: ContentProviderClient, syncResult: SyncResult) { + try { + val accountSettings = AccountSettings(context, account) + + /* don't run sync if + - sync conditions (e.g. "sync only in WiFi") are not met AND + - this is is an automatic sync (i.e. manual syncs are run regardless of sync conditions) + */ + if (!extras.containsKey(ContentResolver.SYNC_EXTRAS_MANUAL) && !checkSyncConditions(accountSettings)) + return + + if (accountSettings.getEventColors()) + AndroidCalendar.insertColors(provider, account) + else + AndroidCalendar.removeColors(provider, account) + + updateLocalCalendars(provider, account, accountSettings) + + val priorityCalendars = priorityCollections(extras) + val calendars = AndroidCalendar + .find(account, provider, LocalCalendar.Factory, "${CalendarContract.Calendars.SYNC_EVENTS}!=0", null) + .sortedByDescending { priorityCalendars.contains(it.id) } + for (calendar in calendars) { + Logger.log.info("Synchronizing calendar #${calendar.id}, URL: ${calendar.name}") + CalendarSyncManager(context, account, accountSettings, extras, authority, syncResult, calendar).use { + val authState = accountSettings.credentials().authState + if (authState != null) + { + if (authState.needsTokenRefresh) + { + val tokenRequest = authState.createTokenRefreshRequest() + + AuthorizationService(context).performTokenRequest(tokenRequest, + AuthorizationService.TokenResponseCallback { tokenResponse, ex -> + authState.update(tokenResponse, ex) + accountSettings.credentials(Credentials(account.name, null, authState, null)) + it.accountSettings.credentials(Credentials(it.account.name, null, authState, null)) + object : AsyncTask() { + override fun doInBackground(vararg params: Void): Void? { + it.performSync() + return null + } + }.execute() + }) + } + else { + it.performSync() + } + } + else { + it.performSync() + } + } + } + } catch(e: Exception) { + Logger.log.log(Level.SEVERE, "Couldn't sync calendars", e) + } + Logger.log.info("Calendar sync complete") + } + + private fun updateLocalCalendars(provider: ContentProviderClient, account: Account, settings: AccountSettings) { + val db = AppDatabase.getInstance(context) + val service = db.serviceDao().getByAccountAndType(account.name, Service.TYPE_CALDAV) + + val remoteCalendars = mutableMapOf() + if (service != null) + for (collection in db.collectionDao().getSyncCalendars(service.id)) { + remoteCalendars[collection.url] = collection + } + + // delete/update local calendars + val updateColors = settings.getManageCalendarColors() + for (calendar in AndroidCalendar.find(account, provider, LocalCalendar.Factory, null, null)) + calendar.name?.let { + val url = HttpUrl.parse(it)!! + val info = remoteCalendars[url] + if (info == null) { + Logger.log.log(Level.INFO, "Deleting obsolete local calendar", url) + calendar.delete() + } else { + // remote CollectionInfo found for this local collection, update data + Logger.log.log(Level.FINE, "Updating local calendar $url", info) + calendar.update(info, updateColors) + // we already have a local calendar for this remote collection, don't take into consideration anymore + remoteCalendars -= url + } + } + + // create new local calendars + for ((_, info) in remoteCalendars) { + Logger.log.log(Level.INFO, "Adding local calendar", info) + LocalCalendar.create(account, provider, info) + } + } + + } + +} + diff --git a/app/src/main/java/at/bitfire/davdroid/syncadapter/ContactsSyncAdapterService.kt b/app/src/main/java/foundation/e/accountmanager/syncadapter/EeloContactsSyncAdapterService.kt similarity index 72% rename from app/src/main/java/at/bitfire/davdroid/syncadapter/ContactsSyncAdapterService.kt rename to app/src/main/java/foundation/e/accountmanager/syncadapter/EeloContactsSyncAdapterService.kt index d5d9b982763c705d688e2b64b1f53bc5dff5498e..ddbd0e7fe2f698d18062f3570670fd1f38df613e 100644 --- a/app/src/main/java/at/bitfire/davdroid/syncadapter/ContactsSyncAdapterService.kt +++ b/app/src/main/java/foundation/e/accountmanager/syncadapter/EeloContactsSyncAdapterService.kt @@ -1,12 +1,12 @@ /* - * Copyright © 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 * http://www.gnu.org/licenses/gpl.html */ -package at.bitfire.davdroid.syncadapter +package foundation.e.accountmanager.syncadapter import android.accounts.Account import android.content.ContentProviderClient @@ -15,13 +15,12 @@ import android.content.Context import android.content.SyncResult import android.os.Bundle import android.provider.ContactsContract -import at.bitfire.davdroid.AccountSettings -import at.bitfire.davdroid.log.Logger -import at.bitfire.davdroid.resource.LocalAddressBook -import at.bitfire.davdroid.settings.ISettings +import foundation.e.accountmanager.log.Logger +import foundation.e.accountmanager.resource.LocalAddressBook +import foundation.e.accountmanager.settings.AccountSettings import java.util.logging.Level -class ContactsSyncAdapterService: SyncAdapterService() { +class EeloContactsSyncAdapterService: SyncAdapterService() { companion object { const val PREVIOUS_GROUP_METHOD = "previous_group_method" @@ -34,10 +33,10 @@ class ContactsSyncAdapterService: SyncAdapterService() { context: Context ): SyncAdapter(context) { - override fun sync(settings: ISettings, account: Account, extras: Bundle, authority: String, provider: ContentProviderClient, syncResult: SyncResult) { + override fun sync(account: Account, extras: Bundle, authority: String, provider: ContentProviderClient, syncResult: SyncResult) { try { val addressBook = LocalAddressBook(context, account, provider) - val accountSettings = AccountSettings(context, settings, addressBook.mainAccount) + val accountSettings = AccountSettings(context, addressBook.mainAccount) // handle group method change val groupMethod = accountSettings.getGroupMethod().name @@ -55,13 +54,17 @@ class ContactsSyncAdapterService: SyncAdapterService() { } accountSettings.accountManager.setUserData(account, PREVIOUS_GROUP_METHOD, groupMethod) + /* don't run sync if + - sync conditions (e.g. "sync only in WiFi") are not met AND + - this is is an automatic sync (i.e. manual syncs are run regardless of sync conditions) + */ if (!extras.containsKey(ContentResolver.SYNC_EXTRAS_MANUAL) && !checkSyncConditions(accountSettings)) return Logger.log.info("Synchronizing address book: ${addressBook.url}") Logger.log.info("Taking settings from: ${addressBook.mainAccount}") - ContactsSyncManager(context, settings, account, accountSettings, extras, authority, syncResult, provider, addressBook).use { + ContactsSyncManager(context, account, accountSettings, extras, authority, syncResult, provider, addressBook).use { it.performSync() } } catch(e: Exception) { @@ -73,3 +76,4 @@ class ContactsSyncAdapterService: SyncAdapterService() { } } + diff --git a/app/src/main/java/foundation/e/accountmanager/syncadapter/EeloEmailSyncAdapterService.kt b/app/src/main/java/foundation/e/accountmanager/syncadapter/EeloEmailSyncAdapterService.kt new file mode 100644 index 0000000000000000000000000000000000000000..79f6160c02bb56dd7d75ba95a57c7bf123648edb --- /dev/null +++ b/app/src/main/java/foundation/e/accountmanager/syncadapter/EeloEmailSyncAdapterService.kt @@ -0,0 +1,29 @@ +/* + * 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 foundation.e.accountmanager.syncadapter + +import android.accounts.Account +import android.content.* +import android.os.Bundle + +class EeloEmailSyncAdapterService : SyncAdapterService() { + + override fun syncAdapter() = CalendarsSyncAdapter(this) + + + class CalendarsSyncAdapter( + context: Context + ): SyncAdapter(context) { + + override fun sync(account: Account, extras: Bundle, authority: String, provider: ContentProviderClient, syncResult: SyncResult) { + // Unused + } + } + +} + diff --git a/app/src/main/java/foundation/e/accountmanager/syncadapter/EeloMediaSyncAdapterService.kt b/app/src/main/java/foundation/e/accountmanager/syncadapter/EeloMediaSyncAdapterService.kt new file mode 100644 index 0000000000000000000000000000000000000000..6c7b93614485bc31877c5714eea0c6944a27b795 --- /dev/null +++ b/app/src/main/java/foundation/e/accountmanager/syncadapter/EeloMediaSyncAdapterService.kt @@ -0,0 +1,29 @@ +/* + * 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 foundation.e.accountmanager.syncadapter + +import android.accounts.Account +import android.content.* +import android.os.Bundle + +class EeloMediaSyncAdapterService : SyncAdapterService() { + + override fun syncAdapter() = CalendarsSyncAdapter(this) + + + class CalendarsSyncAdapter( + context: Context + ): SyncAdapter(context) { + + override fun sync(account: Account, extras: Bundle, authority: String, provider: ContentProviderClient, syncResult: SyncResult) { + // Unused + } + } + +} + diff --git a/app/src/main/java/foundation/e/accountmanager/syncadapter/EeloNotesSyncAdapterService.kt b/app/src/main/java/foundation/e/accountmanager/syncadapter/EeloNotesSyncAdapterService.kt new file mode 100644 index 0000000000000000000000000000000000000000..35f00c81b92b2192037257be984835535a602d03 --- /dev/null +++ b/app/src/main/java/foundation/e/accountmanager/syncadapter/EeloNotesSyncAdapterService.kt @@ -0,0 +1,29 @@ +/* + * 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 foundation.e.accountmanager.syncadapter + +import android.accounts.Account +import android.content.* +import android.os.Bundle + +class EeloNotesSyncAdapterService : SyncAdapterService() { + + override fun syncAdapter() = CalendarsSyncAdapter(this) + + + class CalendarsSyncAdapter( + context: Context + ): SyncAdapter(context) { + + override fun sync(account: Account, extras: Bundle, authority: String, provider: ContentProviderClient, syncResult: SyncResult) { + // Unused + } + } + +} + diff --git a/app/src/main/java/foundation/e/accountmanager/syncadapter/EeloNullAuthenticatorService.kt b/app/src/main/java/foundation/e/accountmanager/syncadapter/EeloNullAuthenticatorService.kt new file mode 100644 index 0000000000000000000000000000000000000000..9781821125619aece7b455a2f9ac472309a0a2b5 --- /dev/null +++ b/app/src/main/java/foundation/e/accountmanager/syncadapter/EeloNullAuthenticatorService.kt @@ -0,0 +1,54 @@ +/* + * Copyright © Ricki Hirner (bitfire web engineering). + * All rights reserved. This program and the accompanying materials + * are made available under the terms of the GNU Public License v3.0 + * which accompanies this distribution, and is available at + * http://www.gnu.org/licenses/gpl.html + */ +package foundation.e.accountmanager.syncadapter + +import android.accounts.AbstractAccountAuthenticator +import android.accounts.Account +import android.accounts.AccountAuthenticatorResponse +import android.accounts.AccountManager +import android.app.Service +import android.content.Context +import android.content.Intent +import android.os.Bundle +import foundation.e.accountmanager.ui.AccountsActivity + +class EeloNullAuthenticatorService: Service() { + + private lateinit var accountAuthenticator: AccountAuthenticator + + override fun onCreate() { + accountAuthenticator = AccountAuthenticator(this) + } + + override fun onBind(intent: Intent?) = + accountAuthenticator.iBinder.takeIf { intent?.action == AccountManager.ACTION_AUTHENTICATOR_INTENT } + + + private class AccountAuthenticator( + val context: Context + ): AbstractAccountAuthenticator(context) { + + override fun addAccount(response: AccountAuthenticatorResponse?, accountType: String?, authTokenType: String?, requiredFeatures: Array?, options: Bundle?): Bundle { + val intent = Intent(context, AccountsActivity::class.java) + intent.putExtra(AccountManager.KEY_ACCOUNT_AUTHENTICATOR_RESPONSE, response) + val bundle = Bundle(1) + bundle.putParcelable(AccountManager.KEY_INTENT, intent) + return bundle + } + + override fun editProperties(response: AccountAuthenticatorResponse?, accountType: String?) = null + override fun getAuthTokenLabel(p0: String?) = null + override fun confirmCredentials(p0: AccountAuthenticatorResponse?, p1: Account?, p2: Bundle?) = null + override fun updateCredentials(p0: AccountAuthenticatorResponse?, p1: Account?, p2: String?, p3: Bundle?) = null + override fun getAuthToken(p0: AccountAuthenticatorResponse?, p1: Account?, p2: String?, p3: Bundle?) = null + override fun hasFeatures(p0: AccountAuthenticatorResponse?, p1: Account?, p2: Array?) = null + + } + +} + diff --git a/app/src/main/java/foundation/e/accountmanager/syncadapter/EeloTasksSyncAdapterService.kt b/app/src/main/java/foundation/e/accountmanager/syncadapter/EeloTasksSyncAdapterService.kt new file mode 100644 index 0000000000000000000000000000000000000000..2f5840e12bb6d90fca0c0139f36c0969afcd412f --- /dev/null +++ b/app/src/main/java/foundation/e/accountmanager/syncadapter/EeloTasksSyncAdapterService.kt @@ -0,0 +1,174 @@ +/* + * 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 foundation.e.accountmanager.syncadapter + +import android.accounts.Account +import android.accounts.AccountManager +import android.app.PendingIntent +import android.content.* +import android.content.pm.PackageManager +import android.graphics.drawable.BitmapDrawable +import android.net.Uri +import android.os.Build +import android.os.Bundle +import android.os.AsyncTask +import androidx.core.app.NotificationCompat +import androidx.core.app.NotificationManagerCompat +import foundation.e.accountmanager.R +import foundation.e.accountmanager.log.Logger +import foundation.e.accountmanager.model.AppDatabase +import foundation.e.accountmanager.model.Collection +import foundation.e.accountmanager.model.Credentials +import foundation.e.accountmanager.model.Service +import foundation.e.accountmanager.resource.LocalTaskList +import foundation.e.accountmanager.settings.AccountSettings +import foundation.e.accountmanager.ui.NotificationUtils +import foundation.e.ical4android.AndroidTaskList +import foundation.e.ical4android.TaskProvider +import net.openid.appauth.AuthorizationService +import okhttp3.HttpUrl +import org.dmfs.tasks.contract.TaskContract +import java.util.logging.Level + +/** + * Synchronization manager for CalDAV collections; handles tasks ({@code VTODO}). + */ +class EeloTasksSyncAdapterService: SyncAdapterService() { + + override fun syncAdapter() = TasksSyncAdapter(this) + + + class TasksSyncAdapter( + context: Context + ): SyncAdapter(context) { + + override fun sync(account: Account, extras: Bundle, authority: String, provider: ContentProviderClient, syncResult: SyncResult) { + try { + val taskProvider = TaskProvider.fromProviderClient(context, provider) + + // make sure account can be seen by OpenTasks + if (Build.VERSION.SDK_INT >= 26) + AccountManager.get(context).setAccountVisibility(account, taskProvider.name.packageName, AccountManager.VISIBILITY_VISIBLE) + + val accountSettings = AccountSettings(context, account) + /* don't run sync if + - sync conditions (e.g. "sync only in WiFi") are not met AND + - this is is an automatic sync (i.e. manual syncs are run regardless of sync conditions) + */ + if (!extras.containsKey(ContentResolver.SYNC_EXTRAS_MANUAL) && !checkSyncConditions(accountSettings)) + return + + updateLocalTaskLists(taskProvider, account, accountSettings) + + val priorityTaskLists = priorityCollections(extras) + val taskLists = AndroidTaskList + .find(account, taskProvider, LocalTaskList.Factory, "${TaskContract.TaskLists.SYNC_ENABLED}!=0", null) + .sortedByDescending { priorityTaskLists.contains(it.id) } + for (taskList in taskLists) { + Logger.log.info("Synchronizing task list #${taskList.id} [${taskList.syncId}]") + TasksSyncManager(context, account, accountSettings, extras, authority, syncResult, taskList).use { + val authState = accountSettings.credentials().authState + if (authState != null) + { + if (authState.needsTokenRefresh) + { + val tokenRequest = authState.createTokenRefreshRequest() + + AuthorizationService(context).performTokenRequest(tokenRequest, + AuthorizationService.TokenResponseCallback { tokenResponse, ex -> + authState.update(tokenResponse, ex) + accountSettings.credentials(Credentials(account.name, null, authState, null)) + it.accountSettings.credentials(Credentials(it.account.name, null, authState, null)) + object : AsyncTask() { + override fun doInBackground(vararg params: Void): Void? { + it.performSync() + return null + } + }.execute() + }) + } + else { + it.performSync() + } + } + else { + 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, NotificationUtils.CHANNEL_SYNC_ERRORS) + .setSmallIcon(R.drawable.ic_sync_problem_notify) + .setContentTitle(context.getString(R.string.sync_error_opentasks_too_old)) + .setContentText(message) + .setStyle(NotificationCompat.BigTextStyle().bigText(message)) + .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") + } + + private fun updateLocalTaskLists(provider: TaskProvider, account: Account, settings: AccountSettings) { + val db = AppDatabase.getInstance(context) + val service = db.serviceDao().getByAccountAndType(account.name, Service.TYPE_CALDAV) + + val remoteTaskLists = mutableMapOf() + if (service != null) + for (collection in db.collectionDao().getSyncTaskLists(service.id)) { + remoteTaskLists[collection.url] = collection + } + + // delete/update local task lists + val updateColors = settings.getManageCalendarColors() + + for (list in AndroidTaskList.find(account, provider, LocalTaskList.Factory, null, null)) + list.syncId?.let { + val url = HttpUrl.parse(it)!! + val info = remoteTaskLists[url] + if (info == null) { + Logger.log.fine("Deleting obsolete local task list $url") + list.delete() + } else { + // remote CollectionInfo found for this local collection, update data + Logger.log.log(Level.FINE, "Updating local task list $url", info) + list.update(info, updateColors) + // we already have a local task list for this remote collection, don't take into consideration anymore + remoteTaskLists -= url + } + } + + // create new local task lists + for ((_,info) in remoteTaskLists) { + Logger.log.log(Level.INFO, "Adding local task list", info) + LocalTaskList.create(account, provider, info) + } + } + + } + +} + diff --git a/app/src/main/java/foundation/e/accountmanager/syncadapter/GoogleAccountAuthenticatorService.kt b/app/src/main/java/foundation/e/accountmanager/syncadapter/GoogleAccountAuthenticatorService.kt new file mode 100644 index 0000000000000000000000000000000000000000..92b82ef871ef428126bff9c08837a638dfbe7fe2 --- /dev/null +++ b/app/src/main/java/foundation/e/accountmanager/syncadapter/GoogleAccountAuthenticatorService.kt @@ -0,0 +1,157 @@ +/* + * 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 foundation.e.accountmanager.syncadapter + +import android.accounts.* +import android.app.Service +import android.content.Context +import android.content.Intent +import android.database.DatabaseUtils +import android.os.Bundle +import androidx.annotation.WorkerThread +import foundation.e.accountmanager.R +import foundation.e.accountmanager.log.Logger +import foundation.e.accountmanager.model.AppDatabase +import foundation.e.accountmanager.resource.LocalAddressBook +import foundation.e.accountmanager.ui.setup.LoginActivity +import java.util.* +import java.util.logging.Level +import kotlin.concurrent.thread +import android.accounts.AccountManager +import foundation.e.accountmanager.settings.AccountSettings +import net.openid.appauth.AuthState +import net.openid.appauth.AuthorizationService + +/** + * Account authenticator for the Google account type. + * + * Gets started when a Google account is removed, too, so it also watches for account removals + * and contains the corresponding cleanup code. + */ +class GoogleAccountAuthenticatorService : Service(), OnAccountsUpdateListener { + + companion object { + + fun cleanupAccounts(context: Context) { + Logger.log.info("Cleaning up orphaned accounts") + + val accountManager = AccountManager.get(context) + + val accountNames = HashSet() + val accounts = ArrayList() + accountManager.getAccountsByType(context.getString(R.string.eelo_account_type)).forEach { accounts.add(it) } + accountManager.getAccountsByType(context.getString(R.string.google_account_type)).forEach { accounts.add(it) } + accountManager.getAccountsByType(context.getString(R.string.account_type)).forEach { accounts.add(it) } + for (account in accounts.toTypedArray()) + accountNames += account.name + + // delete orphaned address book accounts + val addressBookAccounts = ArrayList() + accountManager.getAccountsByType(context.getString(R.string.account_type_eelo_address_book)).forEach { addressBookAccounts.add(it) } + accountManager.getAccountsByType(context.getString(R.string.account_type_google_address_book)).forEach { addressBookAccounts.add(it) } + accountManager.getAccountsByType(context.getString(R.string.account_type_address_book)).forEach { addressBookAccounts.add(it) } + addressBookAccounts.map { LocalAddressBook(context, it, null) } + .forEach { + try { + if (!accountNames.contains(it.mainAccount.name)) + it.delete() + } catch(e: Exception) { + Logger.log.log(Level.SEVERE, "Couldn't delete address book account", e) + } + } + + // delete orphaned services in DB + val db = AppDatabase.getInstance(context) + val serviceDao = db.serviceDao() + if (accountNames.isEmpty()) + serviceDao.deleteAll() + else + serviceDao.deleteExceptAccounts(accountNames.toTypedArray()) + } + + } + + 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?) { + thread { + cleanupAccounts(this) + } + } + + + private class AccountAuthenticator( + val context: Context + ) : AbstractAccountAuthenticator(context) { + + override fun addAccount(response: AccountAuthenticatorResponse?, accountType: String?, authTokenType: String?, requiredFeatures: Array?, options: Bundle?): Bundle { + val intent = Intent(context, LoginActivity::class.java) + intent.putExtra(AccountManager.KEY_ACCOUNT_AUTHENTICATOR_RESPONSE, response) + intent.putExtra(LoginActivity.SETUP_ACCOUNT_PROVIDER_TYPE, LoginActivity.ACCOUNT_PROVIDER_GOOGLE) + val bundle = Bundle(1) + bundle.putParcelable(AccountManager.KEY_INTENT, intent) + return bundle + } + + override fun editProperties(response: AccountAuthenticatorResponse?, accountType: String?) = null + override fun getAuthTokenLabel(p0: String?) = null + override fun confirmCredentials(p0: AccountAuthenticatorResponse?, p1: Account?, p2: Bundle?) = null + override fun updateCredentials(p0: AccountAuthenticatorResponse?, p1: Account?, p2: String?, p3: Bundle?) = null + override fun getAuthToken(response: AccountAuthenticatorResponse?, account: Account?, authTokenType: String?, options: Bundle?): Bundle { + val accountManager = AccountManager.get(context) + val authState = AuthState.jsonDeserialize(accountManager.getUserData(account, AccountSettings.KEY_AUTH_STATE)) + + if (authState != null) { + if (authState.needsTokenRefresh) { + val tokenRequest = authState.createTokenRefreshRequest() + + AuthorizationService(context).performTokenRequest(tokenRequest) { tokenResponse, ex -> + authState.update(tokenResponse, ex) + accountManager.setUserData(account, AccountSettings.KEY_AUTH_STATE, authState.jsonSerializeString()) + val result = Bundle() + result.putString(AccountManager.KEY_ACCOUNT_NAME, account!!.name) + result.putString(AccountManager.KEY_ACCOUNT_TYPE, account.type) + result.putString(AccountManager.KEY_AUTHTOKEN, authState.accessToken) + response?.onResult(result) + } + } + else { + val result = Bundle() + result.putString(AccountManager.KEY_ACCOUNT_NAME, account!!.name) + result.putString(AccountManager.KEY_ACCOUNT_TYPE, account.type) + result.putString(AccountManager.KEY_AUTHTOKEN, authState.accessToken) + return result + } + } + + val result = Bundle() + result.putInt(AccountManager.KEY_ACCOUNT_AUTHENTICATOR_RESPONSE, AccountManager.ERROR_CODE_UNSUPPORTED_OPERATION) + return result + } + override fun hasFeatures(p0: AccountAuthenticatorResponse?, p1: Account?, p2: Array?) = null + + } +} + diff --git a/app/src/main/java/foundation/e/accountmanager/syncadapter/GoogleAddressBooksSyncAdapterService.kt b/app/src/main/java/foundation/e/accountmanager/syncadapter/GoogleAddressBooksSyncAdapterService.kt new file mode 100644 index 0000000000000000000000000000000000000000..944b071fd7b2d1a4b21e91b5876c475e66ddbc94 --- /dev/null +++ b/app/src/main/java/foundation/e/accountmanager/syncadapter/GoogleAddressBooksSyncAdapterService.kt @@ -0,0 +1,129 @@ +/* + * 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 foundation.e.accountmanager.syncadapter + +import android.Manifest +import android.accounts.Account +import android.content.* +import android.content.pm.PackageManager +import android.os.Bundle +import android.provider.ContactsContract +import androidx.core.content.ContextCompat +import foundation.e.accountmanager.closeCompat +import foundation.e.accountmanager.log.Logger +import foundation.e.accountmanager.model.AppDatabase +import foundation.e.accountmanager.model.Collection +import foundation.e.accountmanager.model.Service +import foundation.e.accountmanager.resource.LocalAddressBook +import foundation.e.accountmanager.settings.AccountSettings +import foundation.e.accountmanager.ui.account.AccountActivity +import okhttp3.HttpUrl +import java.util.logging.Level + +class GoogleAddressBooksSyncAdapterService : SyncAdapterService() { + + override fun syncAdapter() = AddressBooksSyncAdapter(this) + + + class AddressBooksSyncAdapter( + context: Context + ) : SyncAdapter(context) { + + override fun sync(account: Account, extras: Bundle, authority: String, provider: ContentProviderClient, syncResult: SyncResult) { + try { + val accountSettings = AccountSettings(context, account) + + /* don't run sync if + - sync conditions (e.g. "sync only in WiFi") are not met AND + - this is is an automatic sync (i.e. manual syncs are run regardless of sync conditions) + */ + if (!extras.containsKey(ContentResolver.SYNC_EXTRAS_MANUAL) && !checkSyncConditions(accountSettings)) + return + + if (updateLocalAddressBooks(account, syncResult)) + for (addressBookAccount in LocalAddressBook.findAll(context, null, account).map { it.account }) { + Logger.log.log(Level.INFO, "Running sync for address book", addressBookAccount) + val syncExtras = Bundle(extras) + syncExtras.putBoolean(ContentResolver.SYNC_EXTRAS_IGNORE_SETTINGS, true) + syncExtras.putBoolean(ContentResolver.SYNC_EXTRAS_IGNORE_BACKOFF, true) + ContentResolver.requestSync(addressBookAccount, ContactsContract.AUTHORITY, syncExtras) + } + } catch (e: Exception) { + Logger.log.log(Level.SEVERE, "Couldn't sync address books", e) + } + + Logger.log.info("Address book sync complete") + } + + private fun updateLocalAddressBooks(account: Account, syncResult: SyncResult): Boolean { + val db = AppDatabase.getInstance(context) + val service = db.serviceDao().getByAccountAndType(account.name, Service.TYPE_CARDDAV) + + val remoteAddressBooks = mutableMapOf() + if (service != null) + for (collection in db.collectionDao().getByServiceAndSync(service.id)) + remoteAddressBooks[collection.url] = collection + + if (ContextCompat.checkSelfPermission(context, Manifest.permission.WRITE_CONTACTS) != PackageManager.PERMISSION_GRANTED) { + if (remoteAddressBooks.isEmpty()) + Logger.log.info("No contacts permission, but no address book selected for synchronization") + else { + // no contacts permission, but address books should be synchronized -> show notification + val intent = Intent(context, AccountActivity::class.java) + intent.putExtra(AccountActivity.EXTRA_ACCOUNT, account) + intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + + notifyPermissions(intent) + } + return false + } + + val contactsProvider = context.contentResolver.acquireContentProviderClient(ContactsContract.AUTHORITY) + try { + if (contactsProvider == null) { + Logger.log.severe("Couldn't access contacts provider") + syncResult.databaseError = true + return false + } + + // delete/update local address books + for (addressBook in LocalAddressBook.findAll(context, contactsProvider, account)) { + val url = HttpUrl.parse(addressBook.url)!! + val info = remoteAddressBooks[url] + if (info == null) { + Logger.log.log(Level.INFO, "Deleting obsolete local address book", url) + addressBook.delete() + } else { + // remote CollectionInfo found for this local collection, update data + try { + Logger.log.log(Level.FINE, "Updating local address book $url", info) + addressBook.update(info) + } catch (e: Exception) { + Logger.log.log(Level.WARNING, "Couldn't rename address book account", e) + } + // we already have a local address book for this remote collection, don't take into consideration anymore + remoteAddressBooks -= url + } + } + + // create new local address books + for ((_, info) in remoteAddressBooks) { + Logger.log.log(Level.INFO, "Adding local address book", info) + LocalAddressBook.create(context, contactsProvider, account, info) + } + } finally { + contactsProvider?.closeCompat() + } + + return true + } + + } + +} + diff --git a/app/src/main/java/foundation/e/accountmanager/syncadapter/GoogleCalendarsSyncAdapterService.kt b/app/src/main/java/foundation/e/accountmanager/syncadapter/GoogleCalendarsSyncAdapterService.kt new file mode 100644 index 0000000000000000000000000000000000000000..c30a8ebef53492b7374c236cb6cfa65d99a4a3d2 --- /dev/null +++ b/app/src/main/java/foundation/e/accountmanager/syncadapter/GoogleCalendarsSyncAdapterService.kt @@ -0,0 +1,137 @@ +/* + * 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 foundation.e.accountmanager.syncadapter + +import android.accounts.Account +import android.content.ContentProviderClient +import android.content.ContentResolver +import android.content.Context +import android.content.SyncResult +import android.os.Bundle +import android.os.AsyncTask +import android.provider.CalendarContract +import foundation.e.accountmanager.log.Logger +import foundation.e.accountmanager.model.AppDatabase +import foundation.e.accountmanager.model.Collection +import foundation.e.accountmanager.model.Credentials +import foundation.e.accountmanager.model.Service +import foundation.e.accountmanager.resource.LocalCalendar +import foundation.e.accountmanager.settings.AccountSettings +import foundation.e.ical4android.AndroidCalendar +import net.openid.appauth.AuthorizationService +import okhttp3.HttpUrl +import java.util.logging.Level + +class GoogleCalendarsSyncAdapterService: SyncAdapterService() { + + override fun syncAdapter() = CalendarsSyncAdapter(this) + + + class CalendarsSyncAdapter( + context: Context + ): SyncAdapter(context) { + + override fun sync(account: Account, extras: Bundle, authority: String, provider: ContentProviderClient, syncResult: SyncResult) { + try { + val accountSettings = AccountSettings(context, account) + + /* don't run sync if + - sync conditions (e.g. "sync only in WiFi") are not met AND + - this is is an automatic sync (i.e. manual syncs are run regardless of sync conditions) + */ + if (!extras.containsKey(ContentResolver.SYNC_EXTRAS_MANUAL) && !checkSyncConditions(accountSettings)) + return + + if (accountSettings.getEventColors()) + AndroidCalendar.insertColors(provider, account) + else + AndroidCalendar.removeColors(provider, account) + + updateLocalCalendars(provider, account, accountSettings) + + val priorityCalendars = priorityCollections(extras) + val calendars = AndroidCalendar + .find(account, provider, LocalCalendar.Factory, "${CalendarContract.Calendars.SYNC_EVENTS}!=0", null) + .sortedByDescending { priorityCalendars.contains(it.id) } + for (calendar in calendars) { + Logger.log.info("Synchronizing calendar #${calendar.id}, URL: ${calendar.name}") + CalendarSyncManager(context, account, accountSettings, extras, authority, syncResult, calendar).use { + val authState = accountSettings.credentials().authState + if (authState != null) + { + if (authState.needsTokenRefresh) + { + val tokenRequest = authState.createTokenRefreshRequest() + + AuthorizationService(context).performTokenRequest(tokenRequest, + AuthorizationService.TokenResponseCallback { tokenResponse, ex -> + authState.update(tokenResponse, ex) + accountSettings.credentials(Credentials(account.name, null, authState, null)) + it.accountSettings.credentials(Credentials(it.account.name, null, authState, null)) + object : AsyncTask() { + override fun doInBackground(vararg params: Void): Void? { + it.performSync() + return null + } + }.execute() + }) + } + else { + it.performSync() + } + } + else { + it.performSync() + } + } + } + } catch(e: Exception) { + Logger.log.log(Level.SEVERE, "Couldn't sync calendars", e) + } + Logger.log.info("Calendar sync complete") + } + + private fun updateLocalCalendars(provider: ContentProviderClient, account: Account, settings: AccountSettings) { + val db = AppDatabase.getInstance(context) + val service = db.serviceDao().getByAccountAndType(account.name, Service.TYPE_CALDAV) + + val remoteCalendars = mutableMapOf() + if (service != null) + for (collection in db.collectionDao().getSyncCalendars(service.id)) { + remoteCalendars[collection.url] = collection + } + + // delete/update local calendars + val updateColors = settings.getManageCalendarColors() + for (calendar in AndroidCalendar.find(account, provider, LocalCalendar.Factory, null, null)) + calendar.name?.let { + val url = HttpUrl.parse(it)!! + val info = remoteCalendars[url] + if (info == null) { + Logger.log.log(Level.INFO, "Deleting obsolete local calendar", url) + calendar.delete() + } else { + // remote CollectionInfo found for this local collection, update data + Logger.log.log(Level.FINE, "Updating local calendar $url", info) + calendar.update(info, updateColors) + // we already have a local calendar for this remote collection, don't take into consideration anymore + remoteCalendars -= url + } + } + + // create new local calendars + for ((_, info) in remoteCalendars) { + Logger.log.log(Level.INFO, "Adding local calendar", info) + LocalCalendar.create(account, provider, info) + } + } + + } + +} + diff --git a/app/src/main/java/foundation/e/accountmanager/syncadapter/GoogleContactsSyncAdapterService.kt b/app/src/main/java/foundation/e/accountmanager/syncadapter/GoogleContactsSyncAdapterService.kt new file mode 100644 index 0000000000000000000000000000000000000000..179d785a5d246cd073dd84f9ead1eb47f13b0219 --- /dev/null +++ b/app/src/main/java/foundation/e/accountmanager/syncadapter/GoogleContactsSyncAdapterService.kt @@ -0,0 +1,79 @@ +/* + * 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 foundation.e.accountmanager.syncadapter + +import android.accounts.Account +import android.content.ContentProviderClient +import android.content.ContentResolver +import android.content.Context +import android.content.SyncResult +import android.os.Bundle +import android.provider.ContactsContract +import foundation.e.accountmanager.log.Logger +import foundation.e.accountmanager.resource.LocalAddressBook +import foundation.e.accountmanager.settings.AccountSettings +import java.util.logging.Level + +class GoogleContactsSyncAdapterService: SyncAdapterService() { + + companion object { + const val PREVIOUS_GROUP_METHOD = "previous_group_method" + } + + override fun syncAdapter() = ContactsSyncAdapter(this) + + + class ContactsSyncAdapter( + context: Context + ): SyncAdapter(context) { + + override fun sync(account: Account, extras: Bundle, authority: String, provider: ContentProviderClient, syncResult: SyncResult) { + try { + val addressBook = LocalAddressBook(context, account, provider) + val accountSettings = AccountSettings(context, addressBook.mainAccount) + + // handle group method change + val groupMethod = accountSettings.getGroupMethod().name + accountSettings.accountManager.getUserData(account, PREVIOUS_GROUP_METHOD)?.let { previousGroupMethod -> + if (previousGroupMethod != groupMethod) { + Logger.log.info("Group method changed, deleting all local contacts/groups") + + // delete all local contacts and groups so that they will be downloaded again + provider.delete(addressBook.syncAdapterURI(ContactsContract.RawContacts.CONTENT_URI), null, null) + provider.delete(addressBook.syncAdapterURI(ContactsContract.Groups.CONTENT_URI), null, null) + + // reset sync state + addressBook.syncState = null + } + } + accountSettings.accountManager.setUserData(account, PREVIOUS_GROUP_METHOD, groupMethod) + + /* don't run sync if + - sync conditions (e.g. "sync only in WiFi") are not met AND + - this is is an automatic sync (i.e. manual syncs are run regardless of sync conditions) + */ + if (!extras.containsKey(ContentResolver.SYNC_EXTRAS_MANUAL) && !checkSyncConditions(accountSettings)) + return + + Logger.log.info("Synchronizing address book: ${addressBook.url}") + Logger.log.info("Taking settings from: ${addressBook.mainAccount}") + + ContactsSyncManager(context, account, accountSettings, extras, authority, syncResult, provider, addressBook).use { + it.performSync() + } + } catch(e: Exception) { + Logger.log.log(Level.SEVERE, "Couldn't sync contacts", e) + } + Logger.log.info("Contacts sync complete") + } + + } + +} + diff --git a/app/src/main/java/foundation/e/accountmanager/syncadapter/GoogleEmailSyncAdapterService.kt b/app/src/main/java/foundation/e/accountmanager/syncadapter/GoogleEmailSyncAdapterService.kt new file mode 100644 index 0000000000000000000000000000000000000000..8b6ecb44dab30af7fd7794a3c1027fc476e72c68 --- /dev/null +++ b/app/src/main/java/foundation/e/accountmanager/syncadapter/GoogleEmailSyncAdapterService.kt @@ -0,0 +1,29 @@ +/* + * 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 foundation.e.accountmanager.syncadapter + +import android.accounts.Account +import android.content.* +import android.os.Bundle + +class GoogleEmailSyncAdapterService : SyncAdapterService() { + + override fun syncAdapter() = CalendarsSyncAdapter(this) + + + class CalendarsSyncAdapter( + context: Context + ): SyncAdapter(context) { + + override fun sync(account: Account, extras: Bundle, authority: String, provider: ContentProviderClient, syncResult: SyncResult) { + // Unused + } + } + +} + diff --git a/app/src/main/java/foundation/e/accountmanager/syncadapter/GoogleNullAuthenticatorService.kt b/app/src/main/java/foundation/e/accountmanager/syncadapter/GoogleNullAuthenticatorService.kt new file mode 100644 index 0000000000000000000000000000000000000000..1d0b56fba65497b5db076854ffe20abb7e1ea54b --- /dev/null +++ b/app/src/main/java/foundation/e/accountmanager/syncadapter/GoogleNullAuthenticatorService.kt @@ -0,0 +1,54 @@ +/* + * Copyright © Ricki Hirner (bitfire web engineering). + * All rights reserved. This program and the accompanying materials + * are made available under the terms of the GNU Public License v3.0 + * which accompanies this distribution, and is available at + * http://www.gnu.org/licenses/gpl.html + */ +package foundation.e.accountmanager.syncadapter + +import android.accounts.AbstractAccountAuthenticator +import android.accounts.Account +import android.accounts.AccountAuthenticatorResponse +import android.accounts.AccountManager +import android.app.Service +import android.content.Context +import android.content.Intent +import android.os.Bundle +import foundation.e.accountmanager.ui.AccountsActivity + +class GoogleNullAuthenticatorService: Service() { + + private lateinit var accountAuthenticator: AccountAuthenticator + + override fun onCreate() { + accountAuthenticator = AccountAuthenticator(this) + } + + override fun onBind(intent: Intent?) = + accountAuthenticator.iBinder.takeIf { intent?.action == AccountManager.ACTION_AUTHENTICATOR_INTENT } + + + private class AccountAuthenticator( + val context: Context + ): AbstractAccountAuthenticator(context) { + + override fun addAccount(response: AccountAuthenticatorResponse?, accountType: String?, authTokenType: String?, requiredFeatures: Array?, options: Bundle?): Bundle { + val intent = Intent(context, AccountsActivity::class.java) + intent.putExtra(AccountManager.KEY_ACCOUNT_AUTHENTICATOR_RESPONSE, response) + val bundle = Bundle(1) + bundle.putParcelable(AccountManager.KEY_INTENT, intent) + return bundle + } + + override fun editProperties(response: AccountAuthenticatorResponse?, accountType: String?) = null + override fun getAuthTokenLabel(p0: String?) = null + override fun confirmCredentials(p0: AccountAuthenticatorResponse?, p1: Account?, p2: Bundle?) = null + override fun updateCredentials(p0: AccountAuthenticatorResponse?, p1: Account?, p2: String?, p3: Bundle?) = null + override fun getAuthToken(p0: AccountAuthenticatorResponse?, p1: Account?, p2: String?, p3: Bundle?) = null + override fun hasFeatures(p0: AccountAuthenticatorResponse?, p1: Account?, p2: Array?) = null + + } + +} + diff --git a/app/src/main/java/foundation/e/accountmanager/syncadapter/GoogleTasksSyncAdapterService.kt b/app/src/main/java/foundation/e/accountmanager/syncadapter/GoogleTasksSyncAdapterService.kt new file mode 100644 index 0000000000000000000000000000000000000000..8574a88c54ce9279ac29b69c98150af3b0812de1 --- /dev/null +++ b/app/src/main/java/foundation/e/accountmanager/syncadapter/GoogleTasksSyncAdapterService.kt @@ -0,0 +1,174 @@ +/* + * 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 foundation.e.accountmanager.syncadapter + +import android.accounts.Account +import android.accounts.AccountManager +import android.app.PendingIntent +import android.content.* +import android.content.pm.PackageManager +import android.graphics.drawable.BitmapDrawable +import android.net.Uri +import android.os.Build +import android.os.Bundle +import android.os.AsyncTask +import androidx.core.app.NotificationCompat +import androidx.core.app.NotificationManagerCompat +import foundation.e.accountmanager.R +import foundation.e.accountmanager.log.Logger +import foundation.e.accountmanager.model.AppDatabase +import foundation.e.accountmanager.model.Collection +import foundation.e.accountmanager.model.Credentials +import foundation.e.accountmanager.model.Service +import foundation.e.accountmanager.resource.LocalTaskList +import foundation.e.accountmanager.settings.AccountSettings +import foundation.e.accountmanager.ui.NotificationUtils +import foundation.e.ical4android.AndroidTaskList +import foundation.e.ical4android.TaskProvider +import net.openid.appauth.AuthorizationService +import okhttp3.HttpUrl +import org.dmfs.tasks.contract.TaskContract +import java.util.logging.Level + +/** + * Synchronization manager for CalDAV collections; handles tasks ({@code VTODO}). + */ +class GoogleTasksSyncAdapterService: SyncAdapterService() { + + override fun syncAdapter() = TasksSyncAdapter(this) + + + class TasksSyncAdapter( + context: Context + ): SyncAdapter(context) { + + override fun sync(account: Account, extras: Bundle, authority: String, provider: ContentProviderClient, syncResult: SyncResult) { + try { + val taskProvider = TaskProvider.fromProviderClient(context, provider) + + // make sure account can be seen by OpenTasks + if (Build.VERSION.SDK_INT >= 26) + AccountManager.get(context).setAccountVisibility(account, taskProvider.name.packageName, AccountManager.VISIBILITY_VISIBLE) + + val accountSettings = AccountSettings(context, account) + /* don't run sync if + - sync conditions (e.g. "sync only in WiFi") are not met AND + - this is is an automatic sync (i.e. manual syncs are run regardless of sync conditions) + */ + if (!extras.containsKey(ContentResolver.SYNC_EXTRAS_MANUAL) && !checkSyncConditions(accountSettings)) + return + + updateLocalTaskLists(taskProvider, account, accountSettings) + + val priorityTaskLists = priorityCollections(extras) + val taskLists = AndroidTaskList + .find(account, taskProvider, LocalTaskList.Factory, "${TaskContract.TaskLists.SYNC_ENABLED}!=0", null) + .sortedByDescending { priorityTaskLists.contains(it.id) } + for (taskList in taskLists) { + Logger.log.info("Synchronizing task list #${taskList.id} [${taskList.syncId}]") + TasksSyncManager(context, account, accountSettings, extras, authority, syncResult, taskList).use { + val authState = accountSettings.credentials().authState + if (authState != null) + { + if (authState.needsTokenRefresh) + { + val tokenRequest = authState.createTokenRefreshRequest() + + AuthorizationService(context).performTokenRequest(tokenRequest, + AuthorizationService.TokenResponseCallback { tokenResponse, ex -> + authState.update(tokenResponse, ex) + accountSettings.credentials(Credentials(account.name, null, authState, null)) + it.accountSettings.credentials(Credentials(it.account.name, null, authState, null)) + object : AsyncTask() { + override fun doInBackground(vararg params: Void): Void? { + it.performSync() + return null + } + }.execute() + }) + } + else { + it.performSync() + } + } + else { + 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, NotificationUtils.CHANNEL_SYNC_ERRORS) + .setSmallIcon(R.drawable.ic_sync_problem_notify) + .setContentTitle(context.getString(R.string.sync_error_opentasks_too_old)) + .setContentText(message) + .setStyle(NotificationCompat.BigTextStyle().bigText(message)) + .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") + } + + private fun updateLocalTaskLists(provider: TaskProvider, account: Account, settings: AccountSettings) { + val db = AppDatabase.getInstance(context) + val service = db.serviceDao().getByAccountAndType(account.name, Service.TYPE_CALDAV) + + val remoteTaskLists = mutableMapOf() + if (service != null) + for (collection in db.collectionDao().getSyncTaskLists(service.id)) { + remoteTaskLists[collection.url] = collection + } + + // delete/update local task lists + val updateColors = settings.getManageCalendarColors() + + for (list in AndroidTaskList.find(account, provider, LocalTaskList.Factory, null, null)) + list.syncId?.let { + val url = HttpUrl.parse(it)!! + val info = remoteTaskLists[url] + if (info == null) { + Logger.log.fine("Deleting obsolete local task list $url") + list.delete() + } else { + // remote CollectionInfo found for this local collection, update data + Logger.log.log(Level.FINE, "Updating local task list $url", info) + list.update(info, updateColors) + // we already have a local task list for this remote collection, don't take into consideration anymore + remoteTaskLists -= url + } + } + + // create new local task lists + for ((_,info) in remoteTaskLists) { + Logger.log.log(Level.INFO, "Adding local task list", info) + LocalTaskList.create(account, provider, info) + } + } + + } + +} + diff --git a/app/src/main/java/at/bitfire/davdroid/syncadapter/NullAuthenticatorService.kt b/app/src/main/java/foundation/e/accountmanager/syncadapter/NullAuthenticatorService.kt similarity index 93% rename from app/src/main/java/at/bitfire/davdroid/syncadapter/NullAuthenticatorService.kt rename to app/src/main/java/foundation/e/accountmanager/syncadapter/NullAuthenticatorService.kt index 1e52749ba863455ec1c7256342d5df8251c45deb..a6611cbb93d652f5419a187d9e902e96b74c98d3 100644 --- a/app/src/main/java/at/bitfire/davdroid/syncadapter/NullAuthenticatorService.kt +++ b/app/src/main/java/foundation/e/accountmanager/syncadapter/NullAuthenticatorService.kt @@ -5,7 +5,7 @@ * which accompanies this distribution, and is available at * http://www.gnu.org/licenses/gpl.html */ -package at.bitfire.davdroid.syncadapter +package foundation.e.accountmanager.syncadapter import android.accounts.AbstractAccountAuthenticator import android.accounts.Account @@ -15,7 +15,7 @@ import android.app.Service import android.content.Context import android.content.Intent import android.os.Bundle -import at.bitfire.davdroid.ui.AccountsActivity +import foundation.e.accountmanager.ui.AccountsActivity class NullAuthenticatorService: Service() { @@ -26,7 +26,7 @@ class NullAuthenticatorService: Service() { } override fun onBind(intent: Intent?) = - accountAuthenticator.iBinder.takeIf { intent?.action == android.accounts.AccountManager.ACTION_AUTHENTICATOR_INTENT } + accountAuthenticator.iBinder.takeIf { intent?.action == AccountManager.ACTION_AUTHENTICATOR_INTENT } private class AccountAuthenticator( diff --git a/app/src/main/java/at/bitfire/davdroid/syncadapter/SyncAdapterService.kt b/app/src/main/java/foundation/e/accountmanager/syncadapter/SyncAdapterService.kt similarity index 53% rename from app/src/main/java/at/bitfire/davdroid/syncadapter/SyncAdapterService.kt rename to app/src/main/java/foundation/e/accountmanager/syncadapter/SyncAdapterService.kt index 5c72c7a71f9152b94047abc45b7e9e39121f7224..986704f354a6b3f65b2623e3ffd28ca322d6de0d 100644 --- a/app/src/main/java/at/bitfire/davdroid/syncadapter/SyncAdapterService.kt +++ b/app/src/main/java/foundation/e/accountmanager/syncadapter/SyncAdapterService.kt @@ -6,7 +6,7 @@ * http://www.gnu.org/licenses/gpl.html */ -package at.bitfire.davdroid.syncadapter +package foundation.e.accountmanager.syncadapter import android.Manifest import android.accounts.Account @@ -15,20 +15,19 @@ import android.app.Service import android.content.* import android.content.pm.PackageManager import android.net.ConnectivityManager +import android.net.NetworkCapabilities import android.net.wifi.WifiManager import android.os.Build import android.os.Bundle -import android.support.v4.app.NotificationCompat -import android.support.v4.app.NotificationManagerCompat -import android.support.v4.content.ContextCompat -import at.bitfire.davdroid.AccountSettings -import at.bitfire.davdroid.R -import 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 androidx.core.app.NotificationCompat +import androidx.core.app.NotificationManagerCompat +import androidx.core.content.ContextCompat +import foundation.e.accountmanager.R +import foundation.e.accountmanager.log.Logger +import foundation.e.accountmanager.settings.AccountSettings +import foundation.e.accountmanager.ui.account.SettingsActivity +import foundation.e.accountmanager.ui.NotificationUtils +import foundation.e.accountmanager.ui.account.AccountActivity import java.lang.ref.WeakReference import java.util.* import java.util.logging.Level @@ -41,6 +40,38 @@ abstract class SyncAdapterService: Service() { * is terminated and the `finally` block which cleans up [runningSyncs] is not * executed. */ private val runningSyncs = mutableListOf>>() + + /** + * Specifies an list of IDs which are requested to be synchronized before + * the other collections. For instance, if some calendars of a CalDAV + * account are visible in the calendar app and others are hidden, the visible calendars can + * be synchronized first, so that the "Refresh" action in the calendar app is more responsive. + * + * Extra type: String (comma-separated list of IDs) + * + * In case of calendar sync, the extra value is a list of Android calendar IDs. + * In case of task sync, the extra value is an a list of OpenTask task list IDs. + */ + const val SYNC_EXTRAS_PRIORITY_COLLECTIONS = "priority_collections" + + /** + * Requests a re-synchronization of all entries. For instance, if this extra is + * set for a calendar sync, all remote events will be listed and checked for remote + * changes again. + * + * Useful if settings which modify the remote resource list (like the CalDAV setting + * "sync events n days in the past") have been changed. + */ + const val SYNC_EXTRAS_RESYNC = "resync" + + /** + * Requests a full re-synchronization of all entries. For instance, if this extra is + * set for an address book sync, all contacts will be downloaded again and updated in the + * local storage. + * + * Useful if settings which modify parsing/local behavior have been changed. + */ + const val SYNC_EXTRAS_FULL_RESYNC = "full_resync" } protected abstract fun syncAdapter(): AbstractThreadedSyncAdapter @@ -52,7 +83,23 @@ abstract class SyncAdapterService: Service() { context: Context ): AbstractThreadedSyncAdapter(context, false) { - abstract fun sync(settings: ISettings, account: Account, extras: Bundle, authority: String, provider: ContentProviderClient, syncResult: SyncResult) + companion object { + fun priorityCollections(extras: Bundle): Set { + val ids = mutableSetOf() + extras.getString(SYNC_EXTRAS_PRIORITY_COLLECTIONS)?.let { rawIds -> + for (rawId in rawIds.split(',')) + try { + ids += rawId.toLong() + } catch (e: NumberFormatException) { + Logger.log.log(Level.WARNING, "Couldn't parse SYNC_EXTRAS_PRIORITY_COLLECTIONS", e) + } + } + return ids + } + } + + + abstract fun sync(account: Account, extras: Bundle, authority: String, provider: ContentProviderClient, syncResult: SyncResult) override fun onPerformSync(account: Account, extras: Bundle, authority: String, provider: ContentProviderClient, syncResult: SyncResult) { Logger.log.log(Level.INFO, "$authority sync of $account has been initiated", extras.keySet().joinToString(", ")) @@ -68,22 +115,11 @@ abstract class SyncAdapterService: Service() { } try { - // required for dav4android (ServiceLoader) + // required for dav4jvm (ServiceLoader) Thread.currentThread().contextClassLoader = context.classLoader - // load app settings - Settings.getInstance(context).use { settings -> - if (settings == null) { - syncResult.databaseError = true - Logger.log.severe("Couldn't connect to Settings service, aborting sync") - return - } - - //if (runSync) { - SyncManager.cancelNotifications(NotificationManagerCompat.from(context), authority, account) - sync(settings, account, extras, authority, provider, syncResult) - //} - } + SyncManager.cancelNotifications(NotificationManagerCompat.from(context), authority, account) + sync(account, extras, authority, provider, syncResult) } finally { synchronized(runningSyncs) { runningSyncs.removeAll { it.get() == null || it.get() == currentSync } @@ -106,20 +142,37 @@ abstract class SyncAdapterService: Service() { protected fun checkSyncConditions(settings: AccountSettings): Boolean { if (settings.getSyncWifiOnly()) { + // WiFi required val connectivityManager = context.getSystemService(CONNECTIVITY_SERVICE) as ConnectivityManager - val network = connectivityManager.activeNetworkInfo - if (network == null || network.type != ConnectivityManager.TYPE_WIFI || !network.isConnected) { + + // check for connected WiFi network + var wifiAvailable = false + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) { + connectivityManager.allNetworks.forEach { network -> + connectivityManager.getNetworkCapabilities(network)?.let { capabilities -> + if (capabilities.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) && + capabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_VALIDATED)) + wifiAvailable = true + } + } + } else { + val network = connectivityManager.activeNetworkInfo + if (network?.isConnected == true && network.type == ConnectivityManager.TYPE_WIFI) + wifiAvailable = true + } + if (!wifiAvailable) { Logger.log.info("Not on connected WiFi, stopping") return false } + // if execution reaches this point, we're on a connected WiFi settings.getSyncWifiOnlySSIDs()?.let { onlySSIDs -> // getting the WiFi name requires location permission (and active location services) since Android 8.1 // see https://issuetracker.google.com/issues/70633700 if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O_MR1 && - ContextCompat.checkSelfPermission(context, Manifest.permission.ACCESS_COARSE_LOCATION) != PackageManager.PERMISSION_GRANTED) { - val intent = Intent(context, AccountSettingsActivity::class.java) - intent.putExtra(AccountActivity.EXTRA_ACCOUNT, settings.account) + ContextCompat.checkSelfPermission(context, Manifest.permission.ACCESS_FINE_LOCATION) != PackageManager.PERMISSION_GRANTED) { + val intent = Intent(context, SettingsActivity::class.java) + intent.putExtra(SettingsActivity.EXTRA_ACCOUNT, settings.account) intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) notifyPermissions(intent) @@ -138,7 +191,7 @@ abstract class SyncAdapterService: Service() { protected fun notifyPermissions(intent: Intent) { val notify = NotificationUtils.newBuilder(context, NotificationUtils.CHANNEL_SYNC_ERRORS) - .setSmallIcon(R.drawable.ic_sync_error_notification) + .setSmallIcon(R.drawable.ic_sync_problem_notify) .setContentTitle(context.getString(R.string.sync_error_permissions)) .setContentText(context.getString(R.string.sync_error_permissions_text)) .setContentIntent(PendingIntent.getActivity(context, 0, intent, PendingIntent.FLAG_UPDATE_CURRENT)) diff --git a/app/src/main/java/at/bitfire/davdroid/syncadapter/SyncManager.kt b/app/src/main/java/foundation/e/accountmanager/syncadapter/SyncManager.kt similarity index 82% rename from app/src/main/java/at/bitfire/davdroid/syncadapter/SyncManager.kt rename to app/src/main/java/foundation/e/accountmanager/syncadapter/SyncManager.kt index b18c29761ac957d215b08c1ca6c983accbe96e89..83c639d4ced3e1e32e8e3dbd4ead7be441e23a65 100644 --- a/app/src/main/java/at/bitfire/davdroid/syncadapter/SyncManager.kt +++ b/app/src/main/java/foundation/e/accountmanager/syncadapter/SyncManager.kt @@ -6,37 +6,38 @@ * http://www.gnu.org/licenses/gpl.html */ -package at.bitfire.davdroid.syncadapter +package foundation.e.accountmanager.syncadapter import android.accounts.Account import android.app.PendingIntent -import android.content.* +import android.content.ContentUris +import android.content.Context +import android.content.Intent +import android.content.SyncResult import android.net.Uri import android.os.Bundle import android.os.RemoteException import android.provider.CalendarContract import android.provider.ContactsContract -import android.support.v4.app.NotificationCompat -import android.support.v4.app.NotificationManagerCompat -import at.bitfire.dav4android.* -import at.bitfire.dav4android.exception.* -import at.bitfire.dav4android.property.GetCTag -import at.bitfire.dav4android.property.GetETag -import at.bitfire.dav4android.property.SyncToken -import at.bitfire.davdroid.* -import at.bitfire.davdroid.BuildConfig -import at.bitfire.davdroid.Constants -import at.bitfire.davdroid.R -import at.bitfire.davdroid.log.Logger -import at.bitfire.davdroid.model.SyncState -import at.bitfire.davdroid.resource.* -import at.bitfire.davdroid.settings.ISettings -import at.bitfire.davdroid.ui.AccountSettingsActivity -import at.bitfire.davdroid.ui.DebugInfoActivity -import at.bitfire.davdroid.ui.NotificationUtils -import at.bitfire.ical4android.CalendarStorageException -import at.bitfire.ical4android.TaskProvider -import at.bitfire.vcard4android.ContactsStorageException +import androidx.core.app.NotificationCompat +import androidx.core.app.NotificationManagerCompat +import foundation.e.dav4jvm.* +import foundation.e.dav4jvm.exception.* +import foundation.e.dav4jvm.property.GetCTag +import foundation.e.dav4jvm.property.GetETag +import foundation.e.dav4jvm.property.SyncToken +import foundation.e.accountmanager.* +import foundation.e.accountmanager.Constants +import foundation.e.accountmanager.log.Logger +import foundation.e.accountmanager.model.SyncState +import foundation.e.accountmanager.resource.* +import foundation.e.accountmanager.settings.AccountSettings +import foundation.e.accountmanager.ui.DebugInfoActivity +import foundation.e.accountmanager.ui.NotificationUtils +import foundation.e.accountmanager.ui.account.SettingsActivity +import foundation.e.ical4android.CalendarStorageException +import foundation.e.ical4android.TaskProvider +import foundation.e.vcard4android.ContactsStorageException import okhttp3.HttpUrl import okhttp3.RequestBody import org.apache.commons.lang3.exception.ContextedException @@ -50,11 +51,11 @@ import java.util.concurrent.* import java.util.concurrent.atomic.AtomicInteger import java.util.logging.Level import javax.net.ssl.SSLHandshakeException +import kotlin.math.min @Suppress("MemberVisibilityCanBePrivate") abstract class SyncManager, out CollectionType: LocalCollection, RemoteType: DavCollection>( val context: Context, - val settings: ISettings, val account: Account, val accountSettings: AccountSettings, val extras: Bundle, @@ -70,8 +71,10 @@ abstract class SyncManager, out CollectionType: L companion object { - val MAX_PROCESSING_THREADS = Math.max(Runtime.getRuntime().availableProcessors(), 4) - val MAX_DOWNLOAD_THREADS = Math.max(Runtime.getRuntime().availableProcessors(), 4) + val MAX_PROCESSING_THREADS = // nCPU/2 (rounded up for case of 1 CPU), but max. 4 + min((Runtime.getRuntime().availableProcessors()+1)/2, 4) + val MAX_DOWNLOAD_THREADS = // one (if one CPU), 2 otherwise + min(Runtime.getRuntime().availableProcessors(), 2) const val MAX_MULTIGET_RESOURCES = 10 fun cancelNotifications(manager: NotificationManagerCompat, authority: String, account: Account) = @@ -82,6 +85,10 @@ abstract class SyncManager, out CollectionType: L } + init { + Logger.log.info("SyncManager: using up to $MAX_PROCESSING_THREADS processing threads and $MAX_DOWNLOAD_THREADS download threads") + } + private val mainAccount = if (localCollection is LocalAddressBook) localCollection.mainAccount else @@ -90,7 +97,7 @@ abstract class SyncManager, out CollectionType: L protected val notificationManager = NotificationManagerCompat.from(context) protected val notificationTag = notificationTag(authority, mainAccount) - protected val httpClient = HttpClient.Builder(context, settings, accountSettings).build() + protected val httpClient = HttpClient.Builder(context, accountSettings).build() protected lateinit var collectionURL: HttpUrl protected lateinit var davCollection: RemoteType @@ -123,6 +130,17 @@ abstract class SyncManager, out CollectionType: L uploadDirty() abortIfCancelled() + if (extras.containsKey(SyncAdapterService.SYNC_EXTRAS_FULL_RESYNC)) { + Logger.log.info("Forcing re-synchronization of all entries") + + // forget sync state of collection (→ initial sync in case of SyncAlgorithm.COLLECTION_SYNC) + localCollection.lastSyncState = null + remoteSyncState = null + + // forget sync state of members (→ download all members again and update them locally) + localCollection.forgetETags() + } + if (modificationsSent || syncRequired(remoteSyncState)) when (syncAlgorithm()) { SyncAlgorithm.PROPFIND_REPORT -> { @@ -149,9 +167,9 @@ abstract class SyncManager, out CollectionType: L localCollection.lastSyncState = remoteSyncState } SyncAlgorithm.COLLECTION_SYNC -> { - var initialSync = false - var syncState = localCollection.lastSyncState?.takeIf { it.type == SyncState.Type.SYNC_TOKEN } + + var initialSync = false if (syncState == null) { Logger.log.info("Starting initial sync") initialSync = true @@ -170,7 +188,7 @@ abstract class SyncManager, out CollectionType: L syncState = SyncState.fromSyncToken(result.first, initialSync) furtherChanges = result.second } catch(e: HttpException) { - if (e.errors.any { it.name == Property.Name(XmlUtils.NS_WEBDAV, "valid-sync-token") }) { + if (e.errors.contains(Error.VALID_SYNC_TOKEN)) { Logger.log.info("Sync token invalid, performing initial sync") initialSync = true resetPresentRemotely() @@ -270,7 +288,7 @@ abstract class SyncManager, out CollectionType: L if (fileName != null) { Logger.log.info("$fileName has been deleted locally -> deleting from server (ETag ${local.eTag})") - useRemote(DavResource(httpClient.okHttpClient, collectionURL.newBuilder().addPathSegment(fileName).build())) { remote -> + useRemote(DavResource(httpClient.okHttpClient, collectionURL.newBuilder().addPathSegment(fileName).build(), accountSettings.credentials().authState?.accessToken)) { remote -> try { remote.delete(local.eTag) {} numDeleted++ @@ -296,25 +314,27 @@ abstract class SyncManager, out CollectionType: L protected open fun uploadDirty(): Boolean { var numUploaded = 0 - // upload dirty contacts + // make sure all resources have file name and UID before uploading them + for (local in localCollection.findDirtyWithoutNameOrUid()) + useLocal(local) { + Logger.log.fine("Generating file name/UID for local resource #${local.id}") + local.assignNameAndUID() + } + + // upload dirty resources 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 eTag: String? = null - val processETag: (response: okhttp3.Response) -> Unit = { - it.header("ETag")?.let { - eTag = GetETag(it).eTag + val processETag: (response: okhttp3.Response) -> Unit = { response -> + response.header("ETag")?.let { getETag -> + eTag = GetETag(getETag).eTag } } try { @@ -326,10 +346,20 @@ abstract class SyncManager, out CollectionType: L remote.put(body, local.eTag, false, processETag) } numUploaded++ + } catch(e: ForbiddenException) { + // HTTP 403 Forbidden + // If and only if the upload failed because of missing permissions, treat it like 412. + if (e.errors.contains(Error.NEED_PRIVILEGES)) + Logger.log.log(Level.INFO, "Couldn't upload because of missing permissions, ignoring", e) + else + throw e } catch(e: ConflictException) { - // we can't interact with the user to resolve the conflict, so we treat 409 like 412 + // HTTP 409 Conflict + // We can't interact with the user to resolve the conflict, so we treat 409 like 412. Logger.log.log(Level.INFO, "Edit conflict, ignoring", e) } catch(e: PreconditionFailedException) { + // HTTP 412 Precondition failed: Resource has been modified on the server in the meanwhile. + // Ignore this condition so that the resource can be downloaded and reset again. Logger.log.log(Level.INFO, "Resource has been modified on the server before upload, ignoring", e) } @@ -356,16 +386,18 @@ abstract class SyncManager, out CollectionType: L * [uploadDirty] were true), a sync is always required and this method * should *not* be evaluated. * + * Will return _true_ if [SyncAdapterService.SYNC_EXTRAS_RESYNC] and/or + * [SyncAdapterService.SYNC_EXTRAS_FULL_RESYNC] is set in [extras]. + * * @param state remote sync state to compare local sync state with * * @return whether data has been changed on the server, i.e. whether running the * sync algorithm is required */ protected open fun syncRequired(state: SyncState?): Boolean { - if (syncAlgorithm() == SyncAlgorithm.PROPFIND_REPORT && extras.containsKey(ContentResolver.SYNC_EXTRAS_MANUAL)) { - Logger.log.info("Manual sync in PROPFIND/REPORT mode, forcing sync") + if (extras.containsKey(SyncAdapterService.SYNC_EXTRAS_RESYNC) || + extras.containsKey(SyncAdapterService.SYNC_EXTRAS_FULL_RESYNC)) return true - } val localState = localCollection.lastSyncState Logger.log.info("Local sync state = $localState, remote sync state = $state") @@ -445,7 +477,7 @@ abstract class SyncManager, out CollectionType: L return@listRemote // ignore collections - if (response[at.bitfire.dav4android.property.ResourceType::class.java]?.types?.contains(at.bitfire.dav4android.property.ResourceType.COLLECTION) == true) + if (response[foundation.e.dav4jvm.property.ResourceType::class.java]?.types?.contains(foundation.e.dav4jvm.property.ResourceType.COLLECTION) == true) return@listRemote val name = response.hrefName() @@ -642,26 +674,20 @@ abstract class SyncManager, out CollectionType: L val contentIntent: Intent var viewItemAction: NotificationCompat.Action? = null - if (e is UnauthorizedException) { - contentIntent = Intent(context, AccountSettingsActivity::class.java) - contentIntent.putExtra(AccountSettingsActivity.EXTRA_ACCOUNT, account) + if ((account.type == context.getString(R.string.account_type) || + account.type == context.getString(R.string.eelo_account_type) || + account.type == context.getString(R.string.google_account_type)) && + (e is UnauthorizedException || e is NotFoundException)) { + contentIntent = Intent(context, SettingsActivity::class.java) + contentIntent.putExtra(SettingsActivity.EXTRA_ACCOUNT, + if (authority == ContactsContract.AUTHORITY) + mainAccount + else + account) } else { - contentIntent = Intent(context, DebugInfoActivity::class.java) - contentIntent.putExtra(DebugInfoActivity.KEY_THROWABLE, e) - - contentIntent.putExtra(DebugInfoActivity.KEY_ACCOUNT, account) - contentIntent.putExtra(DebugInfoActivity.KEY_AUTHORITY, authority) - - // use current local/remote resource - if (local != null) { - // pass local resource info to debug info - contentIntent.putExtra(DebugInfoActivity.KEY_LOCAL_RESOURCE, local.toString()) - - // generate "view item" action + contentIntent = buildDebugInfoIntent(e, local, remote) + if (local != null) viewItemAction = buildViewItemAction(local) - } - if (remote != null) - contentIntent.putExtra(DebugInfoActivity.KEY_REMOTE_RESOURCE, remote.toString()) } // to make the PendingIntent unique @@ -678,7 +704,7 @@ abstract class SyncManager, out CollectionType: L } val builder = NotificationUtils.newBuilder(context, channel) - builder .setSmallIcon(R.drawable.ic_sync_error_notification) + builder .setSmallIcon(R.drawable.ic_sync_problem_notify) .setContentTitle(localCollection.title) .setContentText(message) .setStyle(NotificationCompat.BigTextStyle(builder).bigText(message)) @@ -693,6 +719,19 @@ abstract class SyncManager, out CollectionType: L notificationManager.notify(notificationTag, NotificationUtils.NOTIFY_SYNC_ERROR, builder.build()) } + private fun buildDebugInfoIntent(e: Throwable, local: ResourceType?, remote: HttpUrl?) = + Intent(context, DebugInfoActivity::class.java).apply { + putExtra(DebugInfoActivity.KEY_ACCOUNT, account) + putExtra(DebugInfoActivity.KEY_AUTHORITY, authority) + putExtra(DebugInfoActivity.KEY_THROWABLE, e) + + // pass current local/remote resource + if (local != null) + putExtra(DebugInfoActivity.KEY_LOCAL_RESOURCE, local.toString()) + if (remote != null) + putExtra(DebugInfoActivity.KEY_REMOTE_RESOURCE, remote.toString()) + } + private fun buildRetryAction(): NotificationCompat.Action { val retryIntent = Intent(context, DavService::class.java) retryIntent.action = DavService.ACTION_FORCE_SYNC @@ -740,6 +779,7 @@ abstract class SyncManager, out CollectionType: L null } + fun checkResults(results: MutableCollection>) { val iter = results.iterator() while (iter.hasNext()) { @@ -755,6 +795,23 @@ abstract class SyncManager, out CollectionType: L } } + protected fun notifyInvalidResource(e: Throwable, fileName: String) { + val intent = buildDebugInfoIntent(e, null, collectionURL.resolve(fileName)) + + val builder = NotificationUtils.newBuilder(context, NotificationUtils.CHANNEL_SYNC_WARNINGS) + builder .setSmallIcon(R.drawable.ic_warning_notify) + .setContentTitle(notifyInvalidResourceTitle()) + .setContentText(context.getString(R.string.sync_invalid_resources_ignoring)) + .setSubText(mainAccount.name) + .setContentIntent(PendingIntent.getActivity(context, 0, intent, PendingIntent.FLAG_UPDATE_CURRENT)) + .setAutoCancel(true) + .setOnlyAlertOnce(true) + .priority = NotificationCompat.PRIORITY_LOW + notificationManager.notify(notificationTag, NotificationUtils.NOTIFY_INVALID_RESOURCE, builder.build()) + } + + protected abstract fun notifyInvalidResourceTitle(): String + protected fun useLocal(local: T, body: (T) -> R): R { try { return body(local) @@ -824,4 +881,4 @@ abstract class SyncManager, out CollectionType: L } -} \ No newline at end of file +} diff --git a/app/src/main/java/foundation/e/accountmanager/syncadapter/TasksSyncAdapterService.kt b/app/src/main/java/foundation/e/accountmanager/syncadapter/TasksSyncAdapterService.kt new file mode 100644 index 0000000000000000000000000000000000000000..088b7b1bd32ab5bd0c583f5664a886af6e2a537e --- /dev/null +++ b/app/src/main/java/foundation/e/accountmanager/syncadapter/TasksSyncAdapterService.kt @@ -0,0 +1,173 @@ +/* + * 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 foundation.e.accountmanager.syncadapter + +import android.accounts.Account +import android.accounts.AccountManager +import android.app.PendingIntent +import android.content.* +import android.content.pm.PackageManager +import android.graphics.drawable.BitmapDrawable +import android.net.Uri +import android.os.Build +import android.os.Bundle +import android.os.AsyncTask +import androidx.core.app.NotificationCompat +import androidx.core.app.NotificationManagerCompat +import foundation.e.accountmanager.R +import foundation.e.accountmanager.log.Logger +import foundation.e.accountmanager.model.AppDatabase +import foundation.e.accountmanager.model.Collection +import foundation.e.accountmanager.model.Credentials +import foundation.e.accountmanager.model.Service +import foundation.e.accountmanager.resource.LocalTaskList +import foundation.e.accountmanager.settings.AccountSettings +import foundation.e.accountmanager.ui.NotificationUtils +import foundation.e.ical4android.AndroidTaskList +import foundation.e.ical4android.TaskProvider +import net.openid.appauth.AuthorizationService +import okhttp3.HttpUrl +import org.dmfs.tasks.contract.TaskContract +import java.util.logging.Level + +/** + * Synchronization manager for CalDAV collections; handles tasks ({@code VTODO}). + */ +class TasksSyncAdapterService: SyncAdapterService() { + + override fun syncAdapter() = TasksSyncAdapter(this) + + + class TasksSyncAdapter( + context: Context + ): SyncAdapter(context) { + + override fun sync(account: Account, extras: Bundle, authority: String, provider: ContentProviderClient, syncResult: SyncResult) { + try { + val taskProvider = TaskProvider.fromProviderClient(context, provider) + + // make sure account can be seen by OpenTasks + if (Build.VERSION.SDK_INT >= 26) + AccountManager.get(context).setAccountVisibility(account, taskProvider.name.packageName, AccountManager.VISIBILITY_VISIBLE) + + val accountSettings = AccountSettings(context, account) + /* don't run sync if + - sync conditions (e.g. "sync only in WiFi") are not met AND + - this is is an automatic sync (i.e. manual syncs are run regardless of sync conditions) + */ + if (!extras.containsKey(ContentResolver.SYNC_EXTRAS_MANUAL) && !checkSyncConditions(accountSettings)) + return + + updateLocalTaskLists(taskProvider, account, accountSettings) + + val priorityTaskLists = priorityCollections(extras) + val taskLists = AndroidTaskList + .find(account, taskProvider, LocalTaskList.Factory, "${TaskContract.TaskLists.SYNC_ENABLED}!=0", null) + .sortedByDescending { priorityTaskLists.contains(it.id) } + for (taskList in taskLists) { + Logger.log.info("Synchronizing task list #${taskList.id} [${taskList.syncId}]") + TasksSyncManager(context, account, accountSettings, extras, authority, syncResult, taskList).use { + val authState = accountSettings.credentials().authState + if (authState != null) + { + if (authState.needsTokenRefresh) + { + val tokenRequest = authState.createTokenRefreshRequest() + + AuthorizationService(context).performTokenRequest(tokenRequest, + AuthorizationService.TokenResponseCallback { tokenResponse, ex -> + authState.update(tokenResponse, ex) + accountSettings.credentials(Credentials(account.name, null, authState, null)) + it.accountSettings.credentials(Credentials(it.account.name, null, authState, null)) + object : AsyncTask() { + override fun doInBackground(vararg params: Void): Void? { + it.performSync() + return null + } + }.execute() + }) + } + else { + it.performSync() + } + } + else { + 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, NotificationUtils.CHANNEL_SYNC_ERRORS) + .setSmallIcon(R.drawable.ic_sync_problem_notify) + .setContentTitle(context.getString(R.string.sync_error_opentasks_too_old)) + .setContentText(message) + .setStyle(NotificationCompat.BigTextStyle().bigText(message)) + .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") + } + + private fun updateLocalTaskLists(provider: TaskProvider, account: Account, settings: AccountSettings) { + val db = AppDatabase.getInstance(context) + val service = db.serviceDao().getByAccountAndType(account.name, Service.TYPE_CALDAV) + + val remoteTaskLists = mutableMapOf() + if (service != null) + for (collection in db.collectionDao().getSyncTaskLists(service.id)) { + remoteTaskLists[collection.url] = collection + } + + // delete/update local task lists + val updateColors = settings.getManageCalendarColors() + + for (list in AndroidTaskList.find(account, provider, LocalTaskList.Factory, null, null)) + list.syncId?.let { + val url = HttpUrl.parse(it)!! + val info = remoteTaskLists[url] + if (info == null) { + Logger.log.fine("Deleting obsolete local task list $url") + list.delete() + } else { + // remote CollectionInfo found for this local collection, update data + Logger.log.log(Level.FINE, "Updating local task list $url", info) + list.update(info, updateColors) + // we already have a local task list for this remote collection, don't take into consideration anymore + remoteTaskLists -= url + } + } + + // create new local task lists + for ((_,info) in remoteTaskLists) { + Logger.log.log(Level.INFO, "Adding local task list", info) + LocalTaskList.create(account, provider, info) + } + } + + } + +} diff --git a/app/src/main/java/at/bitfire/davdroid/syncadapter/TasksSyncManager.kt b/app/src/main/java/foundation/e/accountmanager/syncadapter/TasksSyncManager.kt similarity index 71% rename from app/src/main/java/at/bitfire/davdroid/syncadapter/TasksSyncManager.kt rename to app/src/main/java/foundation/e/accountmanager/syncadapter/TasksSyncManager.kt index a55001c2b793667b9819dbedd871d774e14b0db9..aa0370d1f07835ae87b2d2c816073b49f2457d1e 100644 --- a/app/src/main/java/at/bitfire/davdroid/syncadapter/TasksSyncManager.kt +++ b/app/src/main/java/foundation/e/accountmanager/syncadapter/TasksSyncManager.kt @@ -6,31 +6,32 @@ * http://www.gnu.org/licenses/gpl.html */ -package at.bitfire.davdroid.syncadapter +package foundation.e.accountmanager.syncadapter import android.accounts.Account import android.content.Context import android.content.SyncResult import android.os.Bundle -import at.bitfire.dav4android.DavCalendar -import at.bitfire.dav4android.DavResource -import at.bitfire.dav4android.DavResponseCallback -import at.bitfire.dav4android.Response -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.DavUtils -import at.bitfire.davdroid.log.Logger -import at.bitfire.davdroid.model.SyncState -import at.bitfire.davdroid.resource.LocalResource -import at.bitfire.davdroid.resource.LocalTask -import at.bitfire.davdroid.resource.LocalTaskList -import at.bitfire.davdroid.settings.ISettings -import at.bitfire.ical4android.InvalidCalendarException -import at.bitfire.ical4android.Task +import foundation.e.dav4jvm.DavCalendar +import foundation.e.dav4jvm.DavResource +import foundation.e.dav4jvm.DavResponseCallback +import foundation.e.dav4jvm.Response +import foundation.e.dav4jvm.exception.DavException +import foundation.e.dav4jvm.property.CalendarData +import foundation.e.dav4jvm.property.GetCTag +import foundation.e.dav4jvm.property.GetETag +import foundation.e.dav4jvm.property.SyncToken +import foundation.e.accountmanager.DavUtils +import foundation.e.accountmanager.R +import foundation.e.accountmanager.log.Logger +import foundation.e.accountmanager.model.SyncState +import foundation.e.accountmanager.resource.LocalResource +import foundation.e.accountmanager.resource.LocalTask +import foundation.e.accountmanager.resource.LocalTaskList +import foundation.e.accountmanager.settings.AccountSettings +import foundation.e.ical4android.Constants +import foundation.e.ical4android.InvalidCalendarException +import foundation.e.ical4android.Task import okhttp3.HttpUrl import okhttp3.RequestBody import java.io.ByteArrayOutputStream @@ -43,18 +44,17 @@ import java.util.logging.Level */ class TasksSyncManager( context: Context, - settings: ISettings, account: Account, accountSettings: AccountSettings, extras: Bundle, authority: String, syncResult: SyncResult, localCollection: LocalTaskList -): SyncManager(context, settings, account, accountSettings, extras, authority, syncResult, localCollection) { +): SyncManager(context, account, accountSettings, extras, authority, syncResult, localCollection) { override fun prepare(): Boolean { collectionURL = HttpUrl.parse(localCollection.syncId ?: return false) ?: return false - davCollection = DavCalendar(httpClient.okHttpClient, collectionURL) + davCollection = DavCalendar(httpClient.okHttpClient, collectionURL, accountSettings.credentials().authState?.accessToken) return true } @@ -112,6 +112,11 @@ class TasksSyncManager( useRemoteCollection { it.multiget(bunch) { response, _ -> useRemote(response) { + if (!response.isSuccess()) { + Logger.log.warning("Received non-successful multiget response for ${response.href}") + return@useRemote + } + val eTag = response[GetETag::class.java]?.eTag ?: throw DavException("Received multi-get response without ETag") @@ -126,6 +131,8 @@ class TasksSyncManager( } override fun postProcess() { + val touched = localCollection.touchRelations() + Constants.log.info("Touched $touched relations") } // helpers @@ -133,10 +140,10 @@ class TasksSyncManager( private fun processVTodo(fileName: String, eTag: String, reader: Reader) { val tasks: List try { - tasks = Task.fromReader(reader) + tasks = Task.tasksFromReader(reader) } catch (e: InvalidCalendarException) { Logger.log.log(Level.SEVERE, "Received invalid iCalendar, ignoring", e) - // TODO show notification + notifyInvalidResource(e, fileName) return } @@ -146,12 +153,12 @@ class TasksSyncManager( // update local task, if it exists useLocal(localCollection.findByName(fileName)) { local -> if (local != null) { - Logger.log.info("Updating $fileName in local task list") + Logger.log.log(Level.INFO, "Updating $fileName in local task list", newData) local.eTag = eTag local.update(newData) syncResult.stats.numUpdates++ } else { - Logger.log.info("Adding $fileName to local task list") + Logger.log.log(Level.INFO, "Adding $fileName to local task list", newData) useLocal(LocalTask(localCollection, newData, fileName, eTag, LocalResource.FLAG_REMOTELY_PRESENT)) { it.add() } @@ -162,4 +169,7 @@ class TasksSyncManager( Logger.log.info("Received VCALENDAR with not exactly one VTODO; ignoring $fileName") } -} \ No newline at end of file + override fun notifyInvalidResourceTitle(): String = + context.getString(R.string.sync_invalid_task) + +} diff --git a/app/src/main/java/at/bitfire/davdroid/ui/AboutActivity.kt b/app/src/main/java/foundation/e/accountmanager/ui/AboutActivity.kt similarity index 53% rename from app/src/main/java/at/bitfire/davdroid/ui/AboutActivity.kt rename to app/src/main/java/foundation/e/accountmanager/ui/AboutActivity.kt index 2ea61897b382caa34197a9290487fca4a168df12..429a6ecb2c94c921c2838e35420ff0e3553ee5b3 100644 --- a/app/src/main/java/at/bitfire/davdroid/ui/AboutActivity.kt +++ b/app/src/main/java/foundation/e/accountmanager/ui/AboutActivity.kt @@ -6,30 +6,33 @@ * http://www.gnu.org/licenses/gpl.html */ -package at.bitfire.davdroid.ui +package foundation.e.accountmanager.ui -import android.content.Context +import android.app.Application +import android.os.Build import android.os.Bundle -import android.support.v4.app.Fragment -import android.support.v4.app.FragmentManager -import android.support.v4.app.FragmentPagerAdapter -import android.support.v4.app.LoaderManager -import android.support.v4.content.AsyncTaskLoader -import android.support.v4.content.Loader -import android.support.v7.app.AppCompatActivity -import android.text.Html import android.text.Spanned +import android.util.DisplayMetrics import android.view.* -import at.bitfire.davdroid.App -import at.bitfire.davdroid.BuildConfig -import at.bitfire.davdroid.R -import at.bitfire.davdroid.log.Logger +import androidx.appcompat.app.AppCompatActivity +import androidx.core.text.HtmlCompat +import androidx.fragment.app.Fragment +import androidx.fragment.app.FragmentManager +import androidx.fragment.app.FragmentPagerAdapter +import androidx.lifecycle.AndroidViewModel +import androidx.lifecycle.MutableLiveData +import androidx.lifecycle.Observer +import androidx.lifecycle.ViewModelProviders +import foundation.e.accountmanager.App +import foundation.e.accountmanager.BuildConfig +import foundation.e.accountmanager.R import com.mikepenz.aboutlibraries.LibsBuilder -import kotlinx.android.synthetic.main.about_davdroid.* +import kotlinx.android.synthetic.main.about.* import kotlinx.android.synthetic.main.activity_about.* import org.apache.commons.io.IOUtils import java.text.SimpleDateFormat import java.util.* +import kotlin.concurrent.thread class AboutActivity: AppCompatActivity() { @@ -44,7 +47,6 @@ class AboutActivity: AppCompatActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - setContentView(R.layout.activity_about) setSupportActionBar(toolbar) @@ -66,72 +68,69 @@ class AboutActivity: AppCompatActivity() { private inner class TabsAdapter( fm: FragmentManager - ): FragmentPagerAdapter(fm) { + ): FragmentPagerAdapter(fm, BEHAVIOR_RESUME_ONLY_CURRENT_FRAGMENT) { override fun getCount() = 2 - override fun getPageTitle(position: Int) = + override fun getPageTitle(position: Int): String = when (position) { - 1 -> getString(R.string.about_libraries) - else -> getString(R.string.app_name) - }!! + 0 -> getString(R.string.app_name) + else -> getString(R.string.about_libraries) + } override fun getItem(position: Int) = when (position) { - 1 -> LibsBuilder() + 0 -> AppFragment() + else -> LibsBuilder() .withAutoDetect(false) .withFields(R.string::class.java.fields) .withLicenseShown(true) .supportFragment() - else -> DavdroidFragment() - }!! + } } - class DavdroidFragment: Fragment(), LoaderManager.LoaderCallbacks { + class AppFragment: Fragment() { override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?) = - inflater.inflate(R.layout.about_davdroid, container, false)!! + inflater.inflate(R.layout.about, container, false)!! override fun onViewCreated(view: View, savedInstanceState: Bundle?) { app_name.text = getString(R.string.app_name) app_version.text = getString(R.string.about_version, BuildConfig.VERSION_NAME, BuildConfig.VERSION_CODE) build_time.text = getString(R.string.about_build_date, SimpleDateFormat.getDateInstance().format(BuildConfig.buildTime)) - pixels.text = Html.fromHtml(pixelsHtml) - - if (true /* open-source version */) { - warranty.text = Html.fromHtml(getString(R.string.about_license_info_no_warranty)) - loaderManager.initLoader(0, null, this) - } - } + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) + icon.setImageDrawable(resources.getDrawableForDensity(R.mipmap.ic_launcher, DisplayMetrics.DENSITY_XXXHIGH)) - override fun onCreateLoader(id: Int, args: Bundle?) = - HtmlAssetLoader(requireActivity(), "gplv3.html") + pixels.text = HtmlCompat.fromHtml(pixelsHtml, HtmlCompat.FROM_HTML_MODE_LEGACY) - override fun onLoadFinished(loader: Loader, license: Spanned?) { - Logger.log.info("LOAD FINISHED") - license_text.text = license - } + if (true /* open-source version */) { + warranty.setText(R.string.about_license_info_no_warranty) - override fun onLoaderReset(loader: Loader) { + val model = ViewModelProviders.of(this).get(LicenseModel::class.java) + model.htmlText.observe(this, Observer { spanned -> + license_text.text = spanned + }) + } } } - class HtmlAssetLoader( - context: Context, - val fileName: String - ): AsyncTaskLoader(context) { + class LicenseModel( + application: Application + ): AndroidViewModel(application) { - override fun onStartLoading() { - forceLoad() - } + val htmlText = MutableLiveData() - override fun loadInBackground(): Spanned = - context.resources.assets.open(fileName).use { - Html.fromHtml(IOUtils.toString(it, Charsets.UTF_8)) + init { + thread { + getApplication().resources.assets.open("gplv3.html").use { + val spanned = HtmlCompat.fromHtml(IOUtils.toString(it, Charsets.UTF_8), HtmlCompat.FROM_HTML_MODE_LEGACY) + htmlText.postValue(spanned) } + } + } } diff --git a/app/src/main/java/foundation/e/accountmanager/ui/AccountListFragment.kt b/app/src/main/java/foundation/e/accountmanager/ui/AccountListFragment.kt new file mode 100644 index 0000000000000000000000000000000000000000..2e8a7abe39998efc885e604da899796343df2b72 --- /dev/null +++ b/app/src/main/java/foundation/e/accountmanager/ui/AccountListFragment.kt @@ -0,0 +1,179 @@ +/* + * 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 foundation.e.accountmanager.ui + +import android.accounts.Account +import android.accounts.AccountManager +import android.accounts.OnAccountsUpdateListener +import android.app.Application +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import android.content.IntentFilter +import android.net.ConnectivityManager +import android.net.Network +import android.net.NetworkCapabilities +import android.net.NetworkRequest +import android.os.Build +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.AbsListView +import android.widget.AdapterView +import android.widget.ArrayAdapter +import androidx.fragment.app.ListFragment +import androidx.lifecycle.AndroidViewModel +import androidx.lifecycle.MutableLiveData +import androidx.lifecycle.Observer +import androidx.lifecycle.ViewModelProviders +import foundation.e.accountmanager.R +import foundation.e.accountmanager.ui.account.AccountActivity +import kotlinx.android.synthetic.main.account_list.* +import kotlinx.android.synthetic.main.account_list_item.view.* + +class AccountListFragment: ListFragment() { + + override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View { + listAdapter = AccountListAdapter(requireActivity()) + + val model = ViewModelProviders.of(this).get(Model::class.java) + model.accounts.observe(this, Observer { accounts -> + val adapter = listAdapter as AccountListAdapter + adapter.clear() + adapter.addAll(*accounts) + }) + + model.networkAvailable.observe(this, Observer { networkAvailable -> + no_network_info.visibility = if (networkAvailable) View.GONE else View.VISIBLE + }) + + return inflater.inflate(R.layout.account_list, container, false) + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + + listView.choiceMode = AbsListView.CHOICE_MODE_SINGLE + listView.onItemClickListener = AdapterView.OnItemClickListener { _, _, position, _ -> + val account = listAdapter!!.getItem(position) as Account + val intent = Intent(activity, AccountActivity::class.java) + intent.putExtra(AccountActivity.EXTRA_ACCOUNT, account) + startActivity(intent) + } + } + + + // list adapter + + class AccountListAdapter( + context: Context + ): ArrayAdapter(context, R.layout.account_list_item) { + + override fun getView(position: Int, _v: View?, parent: ViewGroup): View { + val account = getItem(position)!! + + val v = _v ?: LayoutInflater.from(context).inflate(R.layout.account_list_item, parent, false) + v.account_name.text = account.name + + return v + } + } + + + class Model( + application: Application + ): AndroidViewModel(application), OnAccountsUpdateListener { + + val accounts = MutableLiveData>() + + val networkAvailable = MutableLiveData() + private var networkCallback: ConnectivityManager.NetworkCallback? = null + private var networkReceiver: BroadcastReceiver? = null + + private val accountManager = AccountManager.get(getApplication())!! + private val connectivityManager = application.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager + init { + accountManager.addOnAccountsUpdatedListener(this, null, true) + + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M) { // API level <26 + networkReceiver = object: BroadcastReceiver() { + init { + update() + } + + override fun onReceive(context: Context?, intent: Intent?) = update() + + private fun update() { + networkAvailable.postValue(connectivityManager.allNetworkInfo.any { it.isConnected }) + } + } + application.registerReceiver(networkReceiver, IntentFilter(ConnectivityManager.CONNECTIVITY_ACTION)) + + } else { // API level >= 26 + networkAvailable.postValue(false) + + // check for working (e.g. WiFi after captive portal login) Internet connection + val networkRequest = NetworkRequest.Builder() + .addCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) + .addCapability(NetworkCapabilities.NET_CAPABILITY_VALIDATED) + .build() + val callback = object: ConnectivityManager.NetworkCallback() { + val availableNetworks = hashSetOf() + + override fun onAvailable(network: Network) { + availableNetworks += network + update() + } + + override fun onLost(network: Network) { + availableNetworks -= network + update() + } + + private fun update() { + networkAvailable.postValue(availableNetworks.isNotEmpty()) + } + } + connectivityManager.registerNetworkCallback(networkRequest, callback) + networkCallback = callback + } + } + + override fun onCleared() { + accountManager.removeOnAccountsUpdatedListener(this) + + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M) + networkReceiver?.let { + getApplication().unregisterReceiver(it) + } + + else + networkCallback?.let { + connectivityManager.unregisterNetworkCallback(it) + } + } + + override fun onAccountsUpdated(newAccounts: Array) { + val context = getApplication() + + val account = ArrayList() + val accountManager = AccountManager.get(context) + accountManager.getAccountsByType(context.getString(R.string.eelo_account_type)).forEach { account.add(it) } + accountManager.getAccountsByType(context.getString(R.string.google_account_type)).forEach { account.add(it) } + accountManager.getAccountsByType(context.getString(R.string.account_type)).forEach { account.add(it) } + + accounts.postValue( + account.toTypedArray() + ) + } + + } + +} diff --git a/app/src/main/java/at/bitfire/davdroid/ui/AccountsActivity.kt b/app/src/main/java/foundation/e/accountmanager/ui/AccountsActivity.kt similarity index 57% rename from app/src/main/java/at/bitfire/davdroid/ui/AccountsActivity.kt rename to app/src/main/java/foundation/e/accountmanager/ui/AccountsActivity.kt index 57014dc3caecbb907a17edcd3e2b1fe56cc672e7..0ed59fe1904b25c47c9daeb6dd4eb92a6c647b2c 100644 --- a/app/src/main/java/at/bitfire/davdroid/ui/AccountsActivity.kt +++ b/app/src/main/java/foundation/e/accountmanager/ui/AccountsActivity.kt @@ -6,89 +6,66 @@ * http://www.gnu.org/licenses/gpl.html */ -package at.bitfire.davdroid.ui +package foundation.e.accountmanager.ui import android.accounts.AccountManager import android.content.ContentResolver -import android.content.Context import android.content.Intent import android.content.SyncStatusObserver import android.os.Bundle -import android.support.design.widget.NavigationView -import android.support.design.widget.Snackbar -import android.support.v4.app.LoaderManager -import android.support.v4.content.Loader -import android.support.v4.view.GravityCompat -import android.support.v7.app.ActionBarDrawerToggle -import android.support.v7.app.AppCompatActivity import android.view.MenuItem -import at.bitfire.davdroid.App -import at.bitfire.davdroid.R -import at.bitfire.davdroid.settings.ISettings -import at.bitfire.davdroid.ui.setup.LoginActivity +import androidx.appcompat.app.ActionBarDrawerToggle +import androidx.appcompat.app.AppCompatActivity +import androidx.core.view.GravityCompat +import foundation.e.accountmanager.R +import foundation.e.accountmanager.settings.Settings +import foundation.e.accountmanager.ui.setup.LoginActivity +import com.google.android.material.navigation.NavigationView +import com.google.android.material.snackbar.Snackbar import kotlinx.android.synthetic.main.accounts_content.* import kotlinx.android.synthetic.main.activity_accounts.* +import kotlinx.android.synthetic.main.activity_accounts.view.* -class AccountsActivity: AppCompatActivity(), NavigationView.OnNavigationItemSelectedListener, LoaderManager.LoaderCallbacks, SyncStatusObserver { +class AccountsActivity: AppCompatActivity(), NavigationView.OnNavigationItemSelectedListener, SyncStatusObserver { companion object { val accountsDrawerHandler = DefaultAccountsDrawerHandler() - private const val fragTagStartup = "startup" + const val fragTagStartup = "startup" } + private lateinit var settings: Settings + private var syncStatusSnackbar: Snackbar? = null private var syncStatusObserver: Any? = null override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - setContentView(R.layout.activity_accounts) + settings = Settings.getInstance(this) + setContentView(R.layout.activity_accounts) setSupportActionBar(toolbar) + if (supportFragmentManager.findFragmentByTag(fragTagStartup) == null) { + val ft = supportFragmentManager.beginTransaction() + StartupDialogFragment.getStartupDialogs(this).forEach { ft.add(it, fragTagStartup) } + ft.commit() + } + fab.setOnClickListener { startActivity(Intent(this, LoginActivity::class.java)) } + fab.show() + accountsDrawerHandler.initMenu(this, drawer_layout.nav_view.menu) val toggle = ActionBarDrawerToggle( this, drawer_layout, toolbar, R.string.navigation_drawer_open, R.string.navigation_drawer_close) - drawer_layout.setDrawerListener(toggle) + drawer_layout.addDrawerListener(toggle) toggle.syncState() nav_view.setNavigationItemSelectedListener(this) nav_view.itemIconTintList = null - - /* When the DAVdroid main activity is started, start a Settings service that stays in memory - for better performance. The service stops itself when memory is trimmed. */ - val settingsIntent = Intent(this, Settings::class.java) - startService(settingsIntent) - - val args = Bundle(1) - supportLoaderManager.initLoader(0, args, this) - } - - override fun onCreateLoader(code: Int, args: Bundle?) = - SettingsLoader(this) - - override fun onLoadFinished(loader: Loader, result: Settings?) { - val result = result ?: return - - if (supportFragmentManager.findFragmentByTag(fragTagStartup) == null) { - val ft = supportFragmentManager.beginTransaction() - StartupDialogFragment.getStartupDialogs(this, result.settings).forEach { ft.add(it, fragTagStartup) } - ft.commit() - } - - nav_view?.menu?.let { - accountsDrawerHandler.onSettingsChanged(result.settings, it) - } - } - - override fun onLoaderReset(loader: Loader) { - nav_view?.menu?.let { - accountsDrawerHandler.onSettingsChanged(null, it) - } } override fun onResume() { @@ -138,27 +115,4 @@ class AccountsActivity: AppCompatActivity(), NavigationView.OnNavigationItemSele return processed } - - class Settings( - val settings: ISettings - ) - - class SettingsLoader( - context: Context - ): at.bitfire.davdroid.ui.SettingsLoader(context) { - - override fun loadInBackground(): Settings? { - settings?.let { - val accountManager = AccountManager.get(context) - val accounts = accountManager.getAccountsByType(context.getString(R.string.account_type)) - - return Settings( - it - ) - } - return null - } - - } - } diff --git a/app/src/main/java/foundation/e/accountmanager/ui/AppSettingsActivity.kt b/app/src/main/java/foundation/e/accountmanager/ui/AppSettingsActivity.kt new file mode 100644 index 0000000000000000000000000000000000000000..8a6537815ca0c202490cf651d37692256d60054e --- /dev/null +++ b/app/src/main/java/foundation/e/accountmanager/ui/AppSettingsActivity.kt @@ -0,0 +1,162 @@ +/* + * 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 foundation.e.accountmanager.ui + +import android.content.Intent +import android.os.Build +import android.os.Bundle +import androidx.appcompat.app.AppCompatActivity +import androidx.preference.EditTextPreference +import androidx.preference.Preference +import androidx.preference.PreferenceFragmentCompat +import androidx.preference.SwitchPreferenceCompat +import foundation.e.cert4android.CustomCertManager +import foundation.e.accountmanager.BuildConfig +import foundation.e.accountmanager.R +import foundation.e.accountmanager.settings.Settings +import com.google.android.material.snackbar.Snackbar +import java.net.URI +import java.net.URISyntaxException + +class AppSettingsActivity: AppCompatActivity() { + + companion object { + const val EXTRA_SCROLL_TO = "scrollTo" + } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + if (savedInstanceState == null) { + val fragment = SettingsFragment() + fragment.arguments = intent.extras + supportFragmentManager.beginTransaction() + .replace(android.R.id.content, fragment) + .commit() + } + } + + + class SettingsFragment: PreferenceFragmentCompat(), Settings.OnChangeListener { + + override fun onCreatePreferences(bundle: Bundle?, s: String?) { + addPreferencesFromResource(R.xml.settings_app) + loadSettings() + + // UI settings + findPreference("notification_settings")!!.apply { + if (Build.VERSION.SDK_INT >= 26) + onPreferenceClickListener = Preference.OnPreferenceClickListener { + startActivity(Intent(android.provider.Settings.ACTION_APP_NOTIFICATION_SETTINGS).apply { + putExtra(android.provider.Settings.EXTRA_APP_PACKAGE, BuildConfig.APPLICATION_ID) + }) + false + } + else + isVisible = false + } + findPreference("reset_hints")!!.onPreferenceClickListener = Preference.OnPreferenceClickListener { + resetHints() + false + } + + // security settings + findPreference(Settings.DISTRUST_SYSTEM_CERTIFICATES)!!.apply { + isVisible = BuildConfig.customCerts + isEnabled = true + } + findPreference("reset_certificates")!!.apply { + isVisible = BuildConfig.customCerts + isEnabled = true + onPreferenceClickListener = Preference.OnPreferenceClickListener { + resetCertificates() + false + } + } + + arguments?.getString(EXTRA_SCROLL_TO)?.let { key -> + scrollToPreference(key) + } + } + + private fun loadSettings() { + val settings = Settings.getInstance(requireActivity()) + + // connection settings + findPreference(Settings.OVERRIDE_PROXY)!!.apply { + isChecked = settings.getBoolean(Settings.OVERRIDE_PROXY) ?: Settings.OVERRIDE_PROXY_DEFAULT + isEnabled = settings.isWritable(Settings.OVERRIDE_PROXY) + } + + findPreference(Settings.OVERRIDE_PROXY_HOST)!!.apply { + isEnabled = settings.isWritable(Settings.OVERRIDE_PROXY_HOST) + val proxyHost = settings.getString(Settings.OVERRIDE_PROXY_HOST) ?: Settings.OVERRIDE_PROXY_HOST_DEFAULT + text = proxyHost + summary = proxyHost + onPreferenceChangeListener = Preference.OnPreferenceChangeListener { _, newValue -> + val host = newValue as String + try { + URI(null, host, null, null) + settings.putString(Settings.OVERRIDE_PROXY_HOST, host) + summary = host + true + } catch(e: URISyntaxException) { + Snackbar.make(view!!, e.localizedMessage, Snackbar.LENGTH_LONG).show() + false + } + } + } + + findPreference(Settings.OVERRIDE_PROXY_PORT)!!.apply { + isEnabled = settings.isWritable(Settings.OVERRIDE_PROXY_PORT) + val proxyPort = settings.getInt(Settings.OVERRIDE_PROXY_PORT) ?: Settings.OVERRIDE_PROXY_PORT_DEFAULT + text = proxyPort.toString() + summary = proxyPort.toString() + onPreferenceChangeListener = Preference.OnPreferenceChangeListener { _, newValue -> + try { + val port = Integer.parseInt(newValue as String) + if (port in 1..65535) { + settings.putInt(Settings.OVERRIDE_PROXY_PORT, port) + text = port.toString() + summary = port.toString() + true + } else + false + } catch(e: NumberFormatException) { + false + } + } + } + + // security settings + findPreference(Settings.DISTRUST_SYSTEM_CERTIFICATES)!! + .isChecked = settings.getBoolean(Settings.DISTRUST_SYSTEM_CERTIFICATES) ?: Settings.DISTRUST_SYSTEM_CERTIFICATES_DEFAULT + } + + override fun onSettingsChanged() { + loadSettings() + } + + + private fun resetHints() { + val settings = Settings.getInstance(requireActivity()) + settings.remove(StartupDialogFragment.HINT_AUTOSTART_PERMISSIONS) + settings.remove(StartupDialogFragment.HINT_BATTERY_OPTIMIZATIONS) + settings.remove(StartupDialogFragment.HINT_OPENTASKS_NOT_INSTALLED) + Snackbar.make(view!!, R.string.app_settings_reset_hints_success, Snackbar.LENGTH_LONG).show() + } + + private fun resetCertificates() { + if (CustomCertManager.resetCertificates(activity!!)) + Snackbar.make(view!!, getString(R.string.app_settings_reset_certificates_success), Snackbar.LENGTH_LONG).show() + } + + } + +} diff --git a/app/src/main/java/foundation/e/accountmanager/ui/CreateAddressBookActivity.kt b/app/src/main/java/foundation/e/accountmanager/ui/CreateAddressBookActivity.kt new file mode 100644 index 0000000000000000000000000000000000000000..f9f3984a8b5b728fa8f7b5c2aba7ceed4e6d002d --- /dev/null +++ b/app/src/main/java/foundation/e/accountmanager/ui/CreateAddressBookActivity.kt @@ -0,0 +1,141 @@ +/* + * 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 foundation.e.accountmanager.ui + +import android.accounts.Account +import android.app.Application +import android.content.Intent +import android.os.Bundle +import android.view.Menu +import android.view.MenuItem +import androidx.annotation.MainThread +import androidx.appcompat.app.AppCompatActivity +import androidx.core.app.NavUtils +import androidx.databinding.DataBindingUtil +import androidx.lifecycle.AndroidViewModel +import androidx.lifecycle.MutableLiveData +import androidx.lifecycle.ViewModelProviders +import foundation.e.accountmanager.R +import foundation.e.accountmanager.databinding.ActivityCreateAddressBookBinding +import foundation.e.accountmanager.model.AppDatabase +import foundation.e.accountmanager.model.Collection +import foundation.e.accountmanager.model.Service +import foundation.e.accountmanager.ui.account.AccountActivity +import org.apache.commons.lang3.StringUtils +import java.util.* +import kotlin.concurrent.thread + +class CreateAddressBookActivity: AppCompatActivity() { + + companion object { + const val EXTRA_ACCOUNT = "account" + } + + private lateinit var model: Model + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + supportActionBar?.setDisplayHomeAsUpEnabled(true) + + model = ViewModelProviders.of(this).get(Model::class.java) + (intent?.getParcelableExtra(EXTRA_ACCOUNT) as? Account)?.let { + model.initialize(it) + } + + val binding = DataBindingUtil.setContentView(this, R.layout.activity_create_address_book) + binding.lifecycleOwner = this + binding.model = model + } + + override fun onCreateOptionsMenu(menu: Menu): Boolean { + menuInflater.inflate(R.menu.activity_create_collection, menu) + return true + } + + override fun onOptionsItemSelected(item: MenuItem) = + if (item.itemId == android.R.id.home) { + val intent = Intent(this, AccountActivity::class.java) + intent.putExtra(AccountActivity.EXTRA_ACCOUNT, model.account) + NavUtils.navigateUpTo(this, intent) + true + } else + false + + fun onCreateCollection(item: MenuItem) { + var ok = true + + val args = Bundle() + args.putString(CreateCollectionFragment.ARG_SERVICE_TYPE, Service.TYPE_CARDDAV) + + val parent = model.homeSets.value?.getItem(model.idxHomeSet.value!!) ?: return + args.putString(CreateCollectionFragment.ARG_URL, parent.url.resolve(UUID.randomUUID().toString() + "/").toString()) + + val displayName = model.displayName.value + if (displayName.isNullOrBlank()) { + model.displayNameError.value = getString(R.string.create_collection_display_name_required) + ok = false + } else { + args.putString(CreateCollectionFragment.ARG_DISPLAY_NAME, displayName) + model.displayNameError.value = null + } + + StringUtils.trimToNull(model.description.value)?.let { + args.putString(CreateCollectionFragment.ARG_DESCRIPTION, it) + } + + if (ok) { + args.putParcelable(CreateCollectionFragment.ARG_ACCOUNT, model.account) + args.putString(CreateCollectionFragment.ARG_TYPE, Collection.TYPE_ADDRESSBOOK) + val frag = CreateCollectionFragment() + frag.arguments = args + frag.show(supportFragmentManager, null) + } + } + + + class Model( + application: Application + ) : AndroidViewModel(application) { + + var account: Account? = null + + val displayName = MutableLiveData() + val displayNameError = MutableLiveData() + + val description = MutableLiveData() + + val homeSets = MutableLiveData() + val idxHomeSet = MutableLiveData() + + @MainThread + fun initialize(account: Account) { + if (this.account != null) + return + this.account = account + + thread { + // load account info + val adapter = HomeSetAdapter(getApplication()) + + val db = AppDatabase.getInstance(getApplication()) + db.serviceDao().getByAccountAndType(account.name, Service.TYPE_CARDDAV)?.let { service -> + val homeSets = db.homeSetDao().getBindableByService(service.id) + adapter.addAll(homeSets) + } + + if (!adapter.isEmpty) { + homeSets.postValue(adapter) + idxHomeSet.postValue(0) + } + } + } + + } + +} diff --git a/app/src/main/java/foundation/e/accountmanager/ui/CreateCalendarActivity.kt b/app/src/main/java/foundation/e/accountmanager/ui/CreateCalendarActivity.kt new file mode 100644 index 0000000000000000000000000000000000000000..dbc22c478a3fdefbd01d21f57e9d26826badf5c1 --- /dev/null +++ b/app/src/main/java/foundation/e/accountmanager/ui/CreateCalendarActivity.kt @@ -0,0 +1,250 @@ +/* + * 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 foundation.e.accountmanager.ui + +import android.accounts.Account +import android.app.Application +import android.content.Context +import android.content.Intent +import android.graphics.drawable.ColorDrawable +import android.os.Bundle +import android.view.Menu +import android.view.MenuItem +import android.widget.ArrayAdapter +import android.widget.Filter +import androidx.annotation.MainThread +import androidx.appcompat.app.AppCompatActivity +import androidx.core.app.NavUtils +import androidx.databinding.DataBindingUtil +import androidx.lifecycle.AndroidViewModel +import androidx.lifecycle.MutableLiveData +import androidx.lifecycle.ViewModelProviders +import foundation.e.accountmanager.Constants +import foundation.e.accountmanager.R +import foundation.e.accountmanager.databinding.ActivityCreateCalendarBinding +import foundation.e.accountmanager.model.AppDatabase +import foundation.e.accountmanager.model.Collection +import foundation.e.accountmanager.model.Service +import foundation.e.accountmanager.ui.account.AccountActivity +import foundation.e.ical4android.DateUtils +import com.jaredrummler.android.colorpicker.ColorPickerDialog +import com.jaredrummler.android.colorpicker.ColorPickerDialogListener +import kotlinx.android.synthetic.main.activity_create_calendar.* +import net.fortuna.ical4j.model.Calendar +import org.apache.commons.lang3.StringUtils +import java.util.* +import kotlin.concurrent.thread + +class CreateCalendarActivity: AppCompatActivity(), ColorPickerDialogListener { + + companion object { + const val EXTRA_ACCOUNT = "account" + } + + private lateinit var model: Model + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + supportActionBar?.setDisplayHomeAsUpEnabled(true) + + model = ViewModelProviders.of(this).get(Model::class.java) + (intent?.getParcelableExtra(EXTRA_ACCOUNT) as? Account)?.let { + model.initialize(it) + } + + val binding = DataBindingUtil.setContentView(this, R.layout.activity_create_calendar) + binding.lifecycleOwner = this + binding.model = model + + binding.color.setOnClickListener { + ColorPickerDialog.newBuilder() + .setShowAlphaSlider(false) + .setColor((color.background as ColorDrawable).color) + .show(this) + } + + binding.timezone.setAdapter(model.timezones) + } + + override fun onColorSelected(dialogId: Int, rgb: Int) { + model.color.value = rgb + } + + override fun onDialogDismissed(dialogId: Int) { + // color selection dismissed + } + + override fun onCreateOptionsMenu(menu: Menu): Boolean { + menuInflater.inflate(R.menu.activity_create_collection, menu) + return true + } + + override fun onOptionsItemSelected(item: MenuItem) = + if (item.itemId == android.R.id.home) { + val intent = Intent(this, AccountActivity::class.java) + intent.putExtra(AccountActivity.EXTRA_ACCOUNT, model.account) + NavUtils.navigateUpTo(this, intent) + true + } else + false + + fun onCreateCollection(item: MenuItem) { + var ok = true + + val args = Bundle() + args.putString(CreateCollectionFragment.ARG_SERVICE_TYPE, Service.TYPE_CALDAV) + + val parent = model.homeSets.value?.getItem(model.idxHomeSet.value!!) ?: return + args.putString(CreateCollectionFragment.ARG_URL, parent.url.resolve(UUID.randomUUID().toString() + "/").toString()) + + val displayName = model.displayName.value + if (displayName.isNullOrBlank()) { + model.displayNameError.value = getString(R.string.create_collection_display_name_required) + ok = false + } else { + args.putString(CreateCollectionFragment.ARG_DISPLAY_NAME, displayName) + model.displayNameError.value = null + } + + StringUtils.trimToNull(model.description.value)?.let { + args.putString(CreateCollectionFragment.ARG_DESCRIPTION, it) + } + + model.color.value?.let { + args.putInt(CreateCollectionFragment.ARG_COLOR, it) + } + + val tzId = model.timezone.value + if (tzId.isNullOrBlank()) { + model.timezoneError.value = getString(R.string.create_calendar_time_zone_required) + ok = false + } else { + DateUtils.tzRegistry.getTimeZone(tzId)?.let { tz -> + val cal = Calendar() + cal.components += tz.vTimeZone + args.putString(CreateCollectionFragment.ARG_TIMEZONE, cal.toString()) + } + model.timezoneError.value = null + } + + val supportsVEVENT = model.supportVEVENT.value ?: false + val supportsVTODO = model.supportVTODO.value ?: false + val supportsVJOURNAL = model.supportVJOURNAL.value ?: false + if (!supportsVEVENT && !supportsVTODO && !supportsVJOURNAL) { + ok = false + model.typeError.value = "" + } else + model.typeError.value = null + + if (!supportsVEVENT || !supportsVTODO || !supportsVJOURNAL) { + // only if there's at least one component set not supported; don't include + // information about supported components otherwise (means: everything supported) + args.putBoolean(CreateCollectionFragment.ARG_SUPPORTS_VEVENT, supportsVEVENT) + args.putBoolean(CreateCollectionFragment.ARG_SUPPORTS_VTODO, supportsVTODO) + args.putBoolean(CreateCollectionFragment.ARG_SUPPORTS_VJOURNAL, supportsVJOURNAL) + } + + if (ok) { + args.putParcelable(CreateCollectionFragment.ARG_ACCOUNT, model.account) + args.putString(CreateCollectionFragment.ARG_TYPE, Collection.TYPE_CALENDAR) + val frag = CreateCollectionFragment() + frag.arguments = args + frag.show(supportFragmentManager, null) + } + } + + class TimeZoneAdapter( + context: Context + ): ArrayAdapter(context, android.R.layout.simple_list_item_1, android.R.id.text1) { + + val tz = TimeZone.getAvailableIDs()!! + + override fun getFilter(): Filter { + return object: Filter() { + override fun performFiltering(constraint: CharSequence?): FilterResults { + val filtered = constraint?.let { + tz.filter { it.contains(constraint, true) } + } ?: listOf() + val results = FilterResults() + results.values = filtered + results.count = filtered.size + return results + } + override fun publishResults(constraint: CharSequence?, results: FilterResults) { + clear() + @Suppress("UNCHECKED_CAST") addAll(results.values as List) + if (results.count >= 0) + notifyDataSetChanged() + else + notifyDataSetInvalidated() + } + } + } + + } + + + class Model( + application: Application + ): AndroidViewModel(application) { + + var account: Account? = null + + val displayName = MutableLiveData() + val displayNameError = MutableLiveData() + + val description = MutableLiveData() + val color = MutableLiveData() + + val homeSets = MutableLiveData() + val idxHomeSet = MutableLiveData() + + val timezones = TimeZoneAdapter(application) + val timezone = MutableLiveData() + val timezoneError = MutableLiveData() + + val typeError = MutableLiveData() + val supportVEVENT = MutableLiveData() + val supportVTODO = MutableLiveData() + val supportVJOURNAL = MutableLiveData() + + @MainThread + fun initialize(account: Account) { + if (this.account != null) + return + this.account = account + + color.value = Constants.DAVDROID_GREEN_RGBA + + timezone.value = TimeZone.getDefault().id + + supportVEVENT.value = true + supportVTODO.value = true + supportVJOURNAL.value = true + + thread { + // load account info + val adapter = HomeSetAdapter(getApplication()) + + val db = AppDatabase.getInstance(getApplication()) + db.serviceDao().getByAccountAndType(account.name, Service.TYPE_CALDAV)?.let { service -> + val homeSets = db.homeSetDao().getBindableByService(service.id) + adapter.addAll(homeSets) + } + + if (!adapter.isEmpty) { + homeSets.postValue(adapter) + idxHomeSet.postValue(0) + } + } + } + + } + +} diff --git a/app/src/main/java/foundation/e/accountmanager/ui/CreateCollectionFragment.kt b/app/src/main/java/foundation/e/accountmanager/ui/CreateCollectionFragment.kt new file mode 100644 index 0000000000000000000000000000000000000000..3f7b93c47e6e082f51a9cbf159bd6c084799383a --- /dev/null +++ b/app/src/main/java/foundation/e/accountmanager/ui/CreateCollectionFragment.kt @@ -0,0 +1,238 @@ +/* + * Copyright © Ricki Hirner (bitfire web engineering). + * All rights reserved. This program and the accompanying materials + * are made available under the terms of the GNU Public License v3.0 + * which accompanies this distribution, and is available at + * http://www.gnu.org/licenses/gpl.html + */ + +package foundation.e.accountmanager.ui + +import android.accounts.Account +import android.app.Application +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.fragment.app.DialogFragment +import androidx.lifecycle.* +import foundation.e.dav4jvm.DavResource +import foundation.e.dav4jvm.XmlUtils +import foundation.e.accountmanager.DavUtils +import foundation.e.accountmanager.HttpClient +import foundation.e.accountmanager.R +import foundation.e.accountmanager.log.Logger +import foundation.e.accountmanager.model.AppDatabase +import foundation.e.accountmanager.model.Collection +import foundation.e.accountmanager.settings.AccountSettings +import okhttp3.HttpUrl +import java.io.IOException +import java.io.StringWriter +import java.util.logging.Level +import kotlin.concurrent.thread + +class CreateCollectionFragment: DialogFragment() { + + companion object { + const val ARG_ACCOUNT = "account" + const val ARG_SERVICE_TYPE = "serviceType" + + const val ARG_TYPE = "type" + const val ARG_URL = "url" + const val ARG_DISPLAY_NAME = "displayName" + const val ARG_DESCRIPTION = "description" + + // CalDAV only + const val ARG_COLOR = "color" + const val ARG_TIMEZONE = "timezone" + const val ARG_SUPPORTS_VEVENT = "supportsVEVENT" + const val ARG_SUPPORTS_VTODO = "supportsVTODO" + const val ARG_SUPPORTS_VJOURNAL = "supportsVJOURNAL" + } + + private lateinit var model: Model + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + val args = arguments ?: throw IllegalArgumentException() + + model = ViewModelProviders.of(this).get(Model::class.java) + model.account = args.getParcelable(ARG_ACCOUNT) ?: throw IllegalArgumentException("ARG_ACCOUNT required") + model.serviceType = args.getString(ARG_SERVICE_TYPE) ?: throw java.lang.IllegalArgumentException("ARG_SERVICE_TYPE required") + + model.collection = Collection( + type = args.getString(ARG_TYPE) ?: throw IllegalArgumentException("ARG_TYPE required"), + url = HttpUrl.parse(args.getString(ARG_URL) ?: throw IllegalArgumentException("ARG_URL required"))!!, + displayName = args.getString(ARG_DISPLAY_NAME), + description = args.getString(ARG_DESCRIPTION), + + color = args.ifDefined(ARG_COLOR) { it.getInt(ARG_COLOR) }, + timezone = args.getString(ARG_TIMEZONE), + supportsVEVENT = args.ifDefined(ARG_SUPPORTS_VEVENT) { it.getBoolean(ARG_SUPPORTS_VEVENT) }, + supportsVTODO = args.ifDefined(ARG_SUPPORTS_VTODO) { it.getBoolean(ARG_SUPPORTS_VTODO) }, + supportsVJOURNAL = args.ifDefined(ARG_SUPPORTS_VJOURNAL) { it.getBoolean(ARG_SUPPORTS_VJOURNAL) }, + + sync = true /* by default, sync collections which just have been created */ + ) + + model.createCollection().observe(this, Observer { exception -> + if (exception == null) + requireActivity().finish() + else { + dismiss() + requireFragmentManager().beginTransaction() + .add(ExceptionInfoFragment.newInstance(exception, model.account), null) + .commit() + } + }) + } + + private fun Bundle.ifDefined(name: String, getter: (Bundle) -> T): T? = + if (containsKey(name)) + getter(this) + else + null + + override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View { + val v = inflater.inflate(R.layout.create_collection, container, false) + isCancelable = false + return v + } + + + class Model( + application: Application + ): AndroidViewModel(application) { + + lateinit var account: Account + lateinit var serviceType: String + lateinit var collection: Collection + + val result = MutableLiveData() + + fun createCollection(): LiveData { + thread { + HttpClient.Builder(getApplication(), AccountSettings(getApplication(), account)) + .setForeground(true) + .build().use { httpClient -> + try { + val dav = DavResource(httpClient.okHttpClient, collection.url) + + // create collection on remote server + dav.mkCol(generateXml()) {} + + // no HTTP error -> create collection locally + val db = AppDatabase.getInstance(getApplication()) + db.serviceDao().getByAccountAndType(account.name, serviceType)?.let { service -> + collection.serviceId = service.id + db.collectionDao().insert(collection) + } + + // post success + result.postValue(null) + } catch (e: Exception) { + // post error + result.postValue(e) + } + } + } + return result + } + + private fun generateXml(): String { + val writer = StringWriter() + try { + val serializer = XmlUtils.newSerializer() + with(serializer) { + setOutput(writer) + startDocument("UTF-8", null) + setPrefix("", XmlUtils.NS_WEBDAV) + setPrefix("CAL", XmlUtils.NS_CALDAV) + setPrefix("CARD", XmlUtils.NS_CARDDAV) + + startTag(XmlUtils.NS_WEBDAV, "mkcol") + startTag(XmlUtils.NS_WEBDAV, "set") + startTag(XmlUtils.NS_WEBDAV, "prop") + startTag(XmlUtils.NS_WEBDAV, "resourcetype") + startTag(XmlUtils.NS_WEBDAV, "collection") + endTag(XmlUtils.NS_WEBDAV, "collection") + if (collection.type == Collection.TYPE_ADDRESSBOOK) { + startTag(XmlUtils.NS_CARDDAV, "addressbook") + endTag(XmlUtils.NS_CARDDAV, "addressbook") + } else if (collection.type == Collection.TYPE_CALENDAR) { + startTag(XmlUtils.NS_CALDAV, "calendar") + endTag(XmlUtils.NS_CALDAV, "calendar") + } + endTag(XmlUtils.NS_WEBDAV, "resourcetype") + collection.displayName?.let { + startTag(XmlUtils.NS_WEBDAV, "displayname") + text(it) + endTag(XmlUtils.NS_WEBDAV, "displayname") + } + + // addressbook-specific properties + if (collection.type == Collection.TYPE_ADDRESSBOOK) { + collection.description?.let { + startTag(XmlUtils.NS_CARDDAV, "addressbook-description") + text(it) + endTag(XmlUtils.NS_CARDDAV, "addressbook-description") + } + } + + // calendar-specific properties + if (collection.type == Collection.TYPE_CALENDAR) { + collection.description?.let { + startTag(XmlUtils.NS_CALDAV, "calendar-description") + text(it) + endTag(XmlUtils.NS_CALDAV, "calendar-description") + } + + collection.color?.let { + startTag(XmlUtils.NS_APPLE_ICAL, "calendar-color") + text(DavUtils.ARGBtoCalDAVColor(it)) + endTag(XmlUtils.NS_APPLE_ICAL, "calendar-color") + } + + collection.timezone?.let { + startTag(XmlUtils.NS_CALDAV, "calendar-timezone") + cdsect(it) + endTag(XmlUtils.NS_CALDAV, "calendar-timezone") + } + + if (collection.supportsVEVENT != null || collection.supportsVTODO != null || collection.supportsVJOURNAL != null) { + // only if there's at least one explicitly supported calendar component set, otherwise don't include the property + startTag(XmlUtils.NS_CALDAV, "supported-calendar-component-set") + if (collection.supportsVEVENT != false) { + startTag(XmlUtils.NS_CALDAV, "comp") + attribute(null, "name", "VEVENT") + endTag(XmlUtils.NS_CALDAV, "comp") + } + if (collection.supportsVTODO != false) { + startTag(XmlUtils.NS_CALDAV, "comp") + attribute(null, "name", "VTODO") + endTag(XmlUtils.NS_CALDAV, "comp") + } + if (collection.supportsVJOURNAL != false) { + startTag(XmlUtils.NS_CALDAV, "comp") + attribute(null, "name", "VJOURNAL") + endTag(XmlUtils.NS_CALDAV, "comp") + } + endTag(XmlUtils.NS_CALDAV, "supported-calendar-component-set") + } + } + + endTag(XmlUtils.NS_WEBDAV, "prop") + endTag(XmlUtils.NS_WEBDAV, "set") + endTag(XmlUtils.NS_WEBDAV, "mkcol") + endDocument() + } + } catch(e: IOException) { + Logger.log.log(Level.SEVERE, "Couldn't assemble Extended MKCOL request", e) + } + + return writer.toString() + } + + } + +} diff --git a/app/src/main/java/foundation/e/accountmanager/ui/DebugInfoActivity.kt b/app/src/main/java/foundation/e/accountmanager/ui/DebugInfoActivity.kt new file mode 100644 index 0000000000000000000000000000000000000000..0dce1369f91ff147d41fcf6699081a7fe2763cfa --- /dev/null +++ b/app/src/main/java/foundation/e/accountmanager/ui/DebugInfoActivity.kt @@ -0,0 +1,361 @@ +/* + * 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 foundation.e.accountmanager.ui + +import android.Manifest +import android.accounts.Account +import android.accounts.AccountManager +import android.app.Application +import android.content.ContentResolver +import android.content.ContentUris +import android.content.Context +import android.content.Intent +import android.content.pm.ApplicationInfo +import android.content.pm.PackageManager +import android.net.ConnectivityManager +import android.os.Build +import android.os.Bundle +import android.os.PowerManager +import android.provider.CalendarContract +import android.provider.ContactsContract +import android.util.Log +import android.view.Menu +import android.view.MenuItem +import androidx.appcompat.app.AppCompatActivity +import androidx.core.app.NotificationManagerCompat +import androidx.core.app.ShareCompat +import androidx.core.content.ContextCompat +import androidx.core.content.FileProvider +import androidx.core.content.pm.PackageInfoCompat +import androidx.databinding.DataBindingUtil +import androidx.lifecycle.AndroidViewModel +import androidx.lifecycle.MutableLiveData +import androidx.lifecycle.ViewModelProviders +import foundation.e.dav4jvm.exception.HttpException +import foundation.e.accountmanager.BuildConfig +import foundation.e.accountmanager.InvalidAccountException +import foundation.e.accountmanager.R +import foundation.e.accountmanager.databinding.ActivityDebugInfoBinding +import foundation.e.accountmanager.log.Logger +import foundation.e.accountmanager.model.AppDatabase +import foundation.e.accountmanager.resource.LocalAddressBook +import foundation.e.accountmanager.settings.AccountSettings +import foundation.e.ical4android.TaskProvider +import org.apache.commons.lang3.exception.ExceptionUtils +import org.dmfs.tasks.contract.TaskContract +import java.io.File +import java.io.FileWriter +import java.io.IOException +import java.util.logging.Level +import kotlin.concurrent.thread + +class DebugInfoActivity: AppCompatActivity() { + + companion object { + const val KEY_THROWABLE = "throwable" + const val KEY_LOGS = "logs" + const val KEY_ACCOUNT = "account" + const val KEY_AUTHORITY = "authority" + const val KEY_LOCAL_RESOURCE = "localResource" + const val KEY_REMOTE_RESOURCE = "remoteResource" + } + + private lateinit var model: ReportModel + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + model = ViewModelProviders.of(this).get(ReportModel::class.java) + model.initialize(intent.extras) + + val binding = DataBindingUtil.setContentView(this, R.layout.activity_debug_info) + binding.model = model + binding.lifecycleOwner = this + } + + override fun onCreateOptionsMenu(menu: Menu): Boolean { + menuInflater.inflate(R.menu.activity_debug_info, menu) + return true + } + + + fun onShare(item: MenuItem) { + model.report.value?.let { report -> + val builder = ShareCompat.IntentBuilder.from(this) + .setSubject("${getString(R.string.app_name)} ${BuildConfig.VERSION_NAME} debug info") + .setText(getString(R.string.debug_info_logs_attached)) + .setType("text/plain") + + try { + val debugInfoDir = File(filesDir, "debug") + if (!(debugInfoDir.exists() && debugInfoDir.isDirectory) && !debugInfoDir.mkdir()) + throw IOException("Couldn't create debug directory") + + val reportFile = File(debugInfoDir, "davx5-info.txt") + Logger.log.fine("Writing debug info to ${reportFile.absolutePath}") + val writer = FileWriter(reportFile) + writer.write(report) + writer.close() + + builder.setStream(FileProvider.getUriForFile(this, getString(R.string.authority_debug_provider), reportFile)) + builder.intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_ACTIVITY_NEW_TASK) + } catch(e: IOException) { + // creating an attachment failed, so send it inline + val text = "Couldn't create debug info file: " + Log.getStackTraceString(e) + "\n\n$report" + builder.setText(text) + } + + builder.startChooser() + } + } + + + class ReportModel(application: Application): AndroidViewModel(application) { + + private var initialized = false + val report = MutableLiveData() + + fun initialize(extras: Bundle?) { + if (initialized) + return + + Logger.log.info("Generating debug info report") + initialized = true + + thread { + val context = getApplication() + val text = StringBuilder("--- BEGIN DEBUG INFO ---\n") + + // begin with most specific information + extras?.getParcelable(KEY_ACCOUNT)?.let { + text.append("SYNCHRONIZATION INFO\nAccount name: ${it.name}\n") + } + extras?.getString(KEY_AUTHORITY)?.let { + text.append("Authority: $it\n") + } + + // exception details + val throwable = extras?.getSerializable(KEY_THROWABLE) as Throwable? + if (throwable is HttpException) { + throwable.request?.let { request -> + text.append("\nHTTP REQUEST:\n$request\n") + throwable.requestBody?.let { text.append(it) } + text.append("\n\n") + } + throwable.response?.let { response -> + text.append("HTTP RESPONSE:\n$response\n") + throwable.responseBody?.let { text.append(it) } + text.append("\n\n") + } + } + + extras?.getString(KEY_LOCAL_RESOURCE)?.let { + text.append("\nLOCAL RESOURCE:\n$it\n") + } + extras?.getString(KEY_REMOTE_RESOURCE)?.let { + text.append("\nREMOTE RESOURCE:\n$it\n") + } + + throwable?.let { + // Log.getStackTraceString(e) returns "" in case of UnknownHostException + text.append("\nEXCEPTION:\n${ExceptionUtils.getStackTrace(throwable)}") + } + + // logs (for instance, from failed resource detection) + extras?.getString(KEY_LOGS)?.let { + text.append("\nLOGS:\n$it\n") + } + + // software information + try { + text.append("\nSOFTWARE INFORMATION\n") + val pm = context.packageManager + val appIDs = mutableSetOf( // we always want info about these packages + BuildConfig.APPLICATION_ID, // DAVx5 + "${BuildConfig.APPLICATION_ID}.jbworkaround", // DAVdroid JB Workaround + "org.dmfs.tasks" // OpenTasks + ) + // add info about contact, calendar, task provider + for (authority in arrayOf(ContactsContract.AUTHORITY, CalendarContract.AUTHORITY, TaskProvider.ProviderName.OpenTasks.authority)) + pm.resolveContentProvider(authority, 0)?.let { appIDs += it.packageName } + // add info about available contact, calendar, task apps + for (uri in arrayOf(ContactsContract.Contacts.CONTENT_URI, CalendarContract.Events.CONTENT_URI, TaskContract.Tasks.getContentUri(TaskProvider.ProviderName.OpenTasks.authority))) { + val viewIntent = Intent(Intent.ACTION_VIEW, ContentUris.withAppendedId(uri, 1)) + for (info in pm.queryIntentActivities(viewIntent, 0)) + appIDs += info.activityInfo.packageName + } + + for (appID in appIDs) + try { + val info = pm.getPackageInfo(appID, 0) + text .append("* ").append(appID) + .append(" ").append(info.versionName) + .append(" (").append(PackageInfoCompat.getLongVersionCode(info)).append(")") + pm.getInstallerPackageName(appID)?.let { installer -> + text.append(" from ").append(installer) + } + info.applicationInfo?.let { applicationInfo -> + if (!applicationInfo.enabled) + text.append(" disabled!") + if (applicationInfo.flags.and(ApplicationInfo.FLAG_EXTERNAL_STORAGE) != 0) + text.append(" on external storage!") + } + text.append("\n") + } catch(e: PackageManager.NameNotFoundException) { + } + } catch(e: Exception) { + Logger.log.log(Level.SEVERE, "Couldn't get software information", e) + } + + // connectivity + text.append("\nCONNECTIVITY (at the moment)\n") + val connectivityManager = context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) { + connectivityManager.allNetworks.forEach { network -> + val capabilities = connectivityManager.getNetworkCapabilities(network) + text.append("- $capabilities\n") + } + } else { + connectivityManager.activeNetworkInfo?.let { networkInfo -> + val type = when (networkInfo.type) { + ConnectivityManager.TYPE_WIFI -> "WiFi" + ConnectivityManager.TYPE_MOBILE -> "mobile" + else -> "type: ${networkInfo.type}" + } + text.append("Active connection: $type, ${networkInfo.detailedState}\n") + } + } + if (Build.VERSION.SDK_INT >= 23) + connectivityManager.defaultProxy?.let { proxy -> + text.append("System default proxy: ${proxy.host}:${proxy.port}\n") + } + text.append("\n") + + text.append("CONFIGURATION\n") + // power saving + val powerManager = context.getSystemService(Context.POWER_SERVICE) as PowerManager + if (Build.VERSION.SDK_INT >= 23) + text.append("Power saving disabled: ") + .append(if (powerManager.isIgnoringBatteryOptimizations(BuildConfig.APPLICATION_ID)) "yes" else "no") + .append("\n") + // notifications + val nm = NotificationManagerCompat.from(context) + text.append("Notifications (") + text.append(if (nm.areNotificationsEnabled()) + "not blocked" + else + "BLOCKED!") + text.append("):\n") + if (Build.VERSION.SDK_INT >= 26) { + val channelsWithoutGroup = nm.notificationChannels.toMutableSet() + for (group in nm.notificationChannelGroups) { + text.append(" [group] ${group.id}") + if (Build.VERSION.SDK_INT >= 28) + text.append(" isBlocked=${group.isBlocked}") + text.append("\n") + + for (channel in group.channels) { + text.append(" ${channel.id}: importance=${channel.importance}\n") + channelsWithoutGroup -= channel + } + } + for (channel in channelsWithoutGroup) + text.append(" ${channel.id}: importance=${channel.importance}\n") + } + // permissions + text.append("Permissions:\n") + for (permission in arrayOf(Manifest.permission.READ_CONTACTS, Manifest.permission.WRITE_CONTACTS, + Manifest.permission.READ_CALENDAR, Manifest.permission.WRITE_CALENDAR, + TaskProvider.PERMISSION_READ_TASKS, TaskProvider.PERMISSION_WRITE_TASKS, + Manifest.permission.ACCESS_COARSE_LOCATION)) { + val shortPermission = permission.replace(Regex("^.+\\.permission\\."), "") + text .append(" $shortPermission: ") + .append(if (ContextCompat.checkSelfPermission(context, permission) == PackageManager.PERMISSION_GRANTED) + "granted" + else + "denied") + .append("\n") + } + // system-wide sync settings + text.append("System-wide synchronization: ") + .append(if (ContentResolver.getMasterSyncAutomatically()) "automatically" else "manually") + .append("\n\n") + + // main accounts + text.append("ACCOUNTS\n") + val accountManager = AccountManager.get(context) + val accounts = ArrayList() + accountManager.getAccountsByType(context.getString(R.string.eelo_account_type)).forEach { accounts.add(it) } + accountManager.getAccountsByType(context.getString(R.string.google_account_type)).forEach { accounts.add(it) } + accountManager.getAccountsByType(context.getString(R.string.account_type)).forEach { accounts.add(it) } + + for (acct in accounts.toTypedArray()) { + try { + val accountSettings = AccountSettings(context, acct) + text.append("Account: ${acct.name}\n" + + " Address book sync. interval: ${syncStatus(accountSettings, context.getString(R.string.address_books_authority))}\n" + + " Calendar sync. interval: ${syncStatus(accountSettings, CalendarContract.AUTHORITY)}\n" + + " OpenTasks sync. interval: ${syncStatus(accountSettings, TaskProvider.ProviderName.OpenTasks.authority)}\n" + + " WiFi only: ").append(accountSettings.getSyncWifiOnly()) + accountSettings.getSyncWifiOnlySSIDs()?.let { + text.append(", SSIDs: ${accountSettings.getSyncWifiOnlySSIDs()}") + } + text.append("\n [CardDAV] Contact group method: ${accountSettings.getGroupMethod()}") + .append("\n [CalDAV] Time range (past days): ${accountSettings.getTimeRangePastDays()}") + .append("\n Manage calendar colors: ${accountSettings.getManageCalendarColors()}") + .append("\n Use event colors: ${accountSettings.getEventColors()}") + .append("\n") + } catch (e: InvalidAccountException) { + text.append("$acct is invalid (unsupported settings version) or does not exist\n") + } + } + + // address book accounts + for (acct in accounts.toTypedArray()) + try { + val addressBook = LocalAddressBook(context, acct, null) + text.append("Address book account: ${acct.name}\n" + + " Main account: ${addressBook.mainAccount}\n" + + " URL: ${addressBook.url}\n" + + " Sync automatically: ").append(ContentResolver.getSyncAutomatically(acct, ContactsContract.AUTHORITY)).append("\n") + } catch(e: Exception) { + text.append("$acct is invalid: ${e.message}\n") + } + text.append("\n") + + text.append("SQLITE DUMP\n") + AppDatabase.getInstance(context).dump(text) + text.append("\n") + + try { + text.append( + "SYSTEM INFORMATION\n" + + "Android version: ${Build.VERSION.RELEASE} (${Build.DISPLAY})\n" + + "Device: ${Build.MANUFACTURER} ${Build.MODEL} (${Build.DEVICE})\n\n" + ) + } catch(e: Exception) { + Logger.log.log(Level.SEVERE, "Couldn't get system details", e) + } + + text.append("--- END DEBUG INFO ---\n") + report.postValue(text.toString()) + } + } + + private fun syncStatus(settings: AccountSettings, authority: String): String { + val interval = settings.getSyncInterval(authority) + return if (interval != null) { + if (interval == AccountSettings.SYNC_INTERVAL_MANUALLY) "manually" else "${interval/60} min" + } else + "—" + } + + } + +} diff --git a/app/src/main/java/foundation/e/accountmanager/ui/DefaultAccountsDrawerHandler.kt b/app/src/main/java/foundation/e/accountmanager/ui/DefaultAccountsDrawerHandler.kt new file mode 100644 index 0000000000000000000000000000000000000000..34c1af9586d451645f1411f7a00721288e6558a9 --- /dev/null +++ b/app/src/main/java/foundation/e/accountmanager/ui/DefaultAccountsDrawerHandler.kt @@ -0,0 +1,38 @@ +/* + * Copyright © Ricki Hirner (bitfire web engineering). + * All rights reserved. This program and the accompanying materials + * are made available under the terms of the GNU Public License v3.0 + * which accompanies this distribution, and is available at + * http://www.gnu.org/licenses/gpl.html + */ + +package foundation.e.accountmanager.ui + +import android.app.Activity +import android.content.Context +import android.content.Intent +import android.view.Menu +import android.view.MenuItem +import android.widget.Toast +import foundation.e.accountmanager.R + +class DefaultAccountsDrawerHandler: IAccountsDrawerHandler { + + override fun initMenu(context: Context, menu: Menu) { + // TODO Provide option for beta feedback + } + + 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)) + else -> + return false + } + + return true + } + +} diff --git a/app/src/main/java/foundation/e/accountmanager/ui/DeleteCollectionFragment.kt b/app/src/main/java/foundation/e/accountmanager/ui/DeleteCollectionFragment.kt new file mode 100644 index 0000000000000000000000000000000000000000..9885c365fe44240d7c0b9aa230322f99d7420a38 --- /dev/null +++ b/app/src/main/java/foundation/e/accountmanager/ui/DeleteCollectionFragment.kt @@ -0,0 +1,137 @@ +/* + * 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 foundation.e.accountmanager.ui + +import android.accounts.Account +import android.app.Application +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.annotation.MainThread +import androidx.fragment.app.DialogFragment +import androidx.lifecycle.* +import foundation.e.dav4jvm.DavResource +import foundation.e.accountmanager.HttpClient +import foundation.e.accountmanager.databinding.DeleteCollectionBinding +import foundation.e.accountmanager.model.AppDatabase +import foundation.e.accountmanager.model.Collection +import foundation.e.accountmanager.settings.AccountSettings +import kotlin.concurrent.thread + +class DeleteCollectionFragment: DialogFragment() { + + companion object { + const val ARG_ACCOUNT = "account" + const val ARG_COLLECTION_ID = "collectionId" + + fun newInstance(account: Account, collectionId: Long): DialogFragment { + val frag = DeleteCollectionFragment() + val args = Bundle(2) + args.putParcelable(ARG_ACCOUNT, account) + args.putLong(ARG_COLLECTION_ID, collectionId) + frag.arguments = args + return frag + } + } + + private lateinit var model: DeleteCollectionModel + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + model = ViewModelProviders.of(this).get(DeleteCollectionModel::class.java) + model.initialize( + arguments!!.getParcelable(ARG_ACCOUNT)!!, + arguments!!.getLong(ARG_COLLECTION_ID) + ) + } + + override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? { + val binding = DeleteCollectionBinding.inflate(layoutInflater, null, false) + binding.lifecycleOwner = this + binding.model = model + + binding.ok.setOnClickListener { + isCancelable = false + binding.progress.visibility = View.VISIBLE + binding.controls.visibility = View.GONE + + model.deleteCollection().observe(this, Observer { exception -> + if (exception != null) + requireFragmentManager().beginTransaction() + .add(ExceptionInfoFragment.newInstance(exception, model.account), null) + .commit() + dismiss() + }) + } + + binding.cancel.setOnClickListener { + dismiss() + } + + return binding.root + } + + + class DeleteCollectionModel( + application: Application + ): AndroidViewModel(application) { + + var account: Account? = null + var collectionInfo: Collection? = null + + val db = AppDatabase.getInstance(application) + + val confirmation = MutableLiveData() + val result = MutableLiveData() + + @MainThread + fun initialize(account: Account, collectionId: Long) { + if (this.account == null) + this.account = account + + if (collectionInfo == null) + thread { + collectionInfo = db.collectionDao().get(collectionId) + } + } + + fun deleteCollection(): LiveData { + thread { + val account = account ?: return@thread + val collectionInfo = collectionInfo ?: return@thread + + val context = getApplication() + HttpClient.Builder(context, AccountSettings(context, account)) + .setForeground(true) + .build().use { httpClient -> + try { + val collection = DavResource(httpClient.okHttpClient, collectionInfo.url) + + // delete collection from server + collection.delete(null) {} + + // delete collection locally + db.collectionDao().delete(collectionInfo) + + // return success + result.postValue(null) + + } catch(e: Exception) { + // return error + result.postValue(e) + } + } + } + return result + } + + } + +} diff --git a/app/src/main/java/at/bitfire/davdroid/ui/ExceptionInfoFragment.kt b/app/src/main/java/foundation/e/accountmanager/ui/ExceptionInfoFragment.kt similarity index 87% rename from app/src/main/java/at/bitfire/davdroid/ui/ExceptionInfoFragment.kt rename to app/src/main/java/foundation/e/accountmanager/ui/ExceptionInfoFragment.kt index e9a262087228546c372a69e08480bcc6b8bb87ea..057764ff211721710fd00f93322241890a646951 100644 --- a/app/src/main/java/at/bitfire/davdroid/ui/ExceptionInfoFragment.kt +++ b/app/src/main/java/foundation/e/accountmanager/ui/ExceptionInfoFragment.kt @@ -6,16 +6,16 @@ * http://www.gnu.org/licenses/gpl.html */ -package at.bitfire.davdroid.ui +package foundation.e.accountmanager.ui import android.accounts.Account import android.app.Dialog import android.content.Intent import android.os.Bundle -import android.support.v4.app.DialogFragment -import android.support.v7.app.AlertDialog -import at.bitfire.dav4android.exception.HttpException -import at.bitfire.davdroid.R +import androidx.fragment.app.DialogFragment +import foundation.e.dav4jvm.exception.HttpException +import foundation.e.accountmanager.R +import com.google.android.material.dialog.MaterialAlertDialogBuilder import java.io.IOException class ExceptionInfoFragment: DialogFragment() { @@ -45,7 +45,7 @@ class ExceptionInfoFragment: DialogFragment() { else -> R.string.exception } - val dialog = AlertDialog.Builder(requireActivity()) + val dialog = MaterialAlertDialogBuilder(requireActivity()) .setIcon(R.drawable.ic_error_dark) .setTitle(title) .setMessage(exception::class.java.name + "\n" + exception.localizedMessage) diff --git a/app/src/main/java/foundation/e/accountmanager/ui/HomeSetAdapter.kt b/app/src/main/java/foundation/e/accountmanager/ui/HomeSetAdapter.kt new file mode 100644 index 0000000000000000000000000000000000000000..8017cae58b3e30168e8524d155029cc6eb80657b --- /dev/null +++ b/app/src/main/java/foundation/e/accountmanager/ui/HomeSetAdapter.kt @@ -0,0 +1,42 @@ +package foundation.e.accountmanager.ui + +import android.content.Context +import android.text.TextUtils +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.ArrayAdapter +import android.widget.TextView +import foundation.e.accountmanager.model.HomeSet + +class HomeSetAdapter( + context: Context +): ArrayAdapter(context, android.R.layout.simple_list_item_2, android.R.id.text1) { + + override fun getView(position: Int, convertView: View?, parent: ViewGroup): View { + val data = getItem(position)!! + + val v: View + if (!data.displayName.isNullOrBlank()) { + v = convertView ?: LayoutInflater.from(context).inflate(android.R.layout.simple_list_item_2, null, false) + v.findViewById(android.R.id.text1).text = data.displayName + v.findViewById(android.R.id.text2).apply { + text = data.url.toString() + setSingleLine() + ellipsize = TextUtils.TruncateAt.START + } + } else { + v = convertView ?: LayoutInflater.from(context).inflate(android.R.layout.simple_list_item_1, null, false) + v.findViewById(android.R.id.text1).apply { + text = data.url.toString() + setSingleLine() + ellipsize = TextUtils.TruncateAt.START + } + } + return v + } + + override fun getDropDownView(position: Int, convertView: View?, parent: ViewGroup) = + getView(position, convertView, parent) + +} \ No newline at end of file diff --git a/app/src/main/java/at/bitfire/davdroid/ui/IAccountsDrawerHandler.kt b/app/src/main/java/foundation/e/accountmanager/ui/IAccountsDrawerHandler.kt similarity index 78% rename from app/src/main/java/at/bitfire/davdroid/ui/IAccountsDrawerHandler.kt rename to app/src/main/java/foundation/e/accountmanager/ui/IAccountsDrawerHandler.kt index b0241de0fd0ba2f282da0da47c8d16317b9884c5..e9cc6e281a676de000371d2cfe1963bab6694687 100644 --- a/app/src/main/java/at/bitfire/davdroid/ui/IAccountsDrawerHandler.kt +++ b/app/src/main/java/foundation/e/accountmanager/ui/IAccountsDrawerHandler.kt @@ -6,16 +6,16 @@ * http://www.gnu.org/licenses/gpl.html */ -package at.bitfire.davdroid.ui +package foundation.e.accountmanager.ui import android.app.Activity +import android.content.Context import android.view.Menu import android.view.MenuItem -import at.bitfire.davdroid.settings.ISettings interface IAccountsDrawerHandler { - fun onSettingsChanged(settings: ISettings?, menu: Menu) + fun initMenu(context: Context, menu: Menu) fun onNavigationItemSelected(activity: Activity, item: MenuItem): Boolean diff --git a/app/src/main/java/at/bitfire/davdroid/ui/NotificationUtils.kt b/app/src/main/java/foundation/e/accountmanager/ui/NotificationUtils.kt similarity index 56% rename from app/src/main/java/at/bitfire/davdroid/ui/NotificationUtils.kt rename to app/src/main/java/foundation/e/accountmanager/ui/NotificationUtils.kt index a41af4c7f439c4cb07bc8e4dd30a0e8542d4efcd..5e9923289491f61ee5cffb846b78fcb45f950331 100644 --- a/app/src/main/java/at/bitfire/davdroid/ui/NotificationUtils.kt +++ b/app/src/main/java/foundation/e/accountmanager/ui/NotificationUtils.kt @@ -6,7 +6,7 @@ * http://www.gnu.org/licenses/gpl.html */ -package at.bitfire.davdroid.ui +package foundation.e.accountmanager.ui import android.annotation.TargetApi import android.app.NotificationChannel @@ -14,9 +14,9 @@ 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 +import androidx.core.app.NotificationCompat +import foundation.e.accountmanager.App +import foundation.e.accountmanager.R object NotificationUtils { @@ -24,6 +24,7 @@ object NotificationUtils { const val NOTIFY_EXTERNAL_FILE_LOGGING = 1 const val NOTIFY_REFRESH_COLLECTIONS = 2 const val NOTIFY_SYNC_ERROR = 10 + const val NOTIFY_INVALID_RESOURCE = 11 const val NOTIFY_OPENTASKS = 20 const val NOTIFY_PERMISSIONS = 21 const val NOTIFY_LICENSE = 100 @@ -34,8 +35,8 @@ object NotificationUtils { private const val CHANNEL_SYNC = "sync" const val CHANNEL_SYNC_ERRORS = "syncProblems" + const val CHANNEL_SYNC_WARNINGS = "syncWarnings" const val CHANNEL_SYNC_IO_ERRORS = "syncIoErrors" - const val CHANNEL_SYNC_STATUS = "syncStatus" fun createChannels(context: Context) { @@ -43,26 +44,29 @@ object NotificationUtils { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { val nm = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager - val syncChannelGroup = NotificationChannelGroup(CHANNEL_SYNC, context.getString(R.string.notification_channel_sync)) - nm.createNotificationChannelGroup(syncChannelGroup) - val syncChannels = arrayOf( - NotificationChannel(CHANNEL_SYNC_ERRORS, context.getString(R.string.notification_channel_sync_errors), NotificationManager.IMPORTANCE_DEFAULT), - NotificationChannel(CHANNEL_SYNC_IO_ERRORS, context.getString(R.string.notification_channel_sync_io_errors), NotificationManager.IMPORTANCE_MIN), - NotificationChannel(CHANNEL_SYNC_STATUS, context.getString(R.string.notification_channel_sync_status), NotificationManager.IMPORTANCE_MIN) - ) - syncChannels.forEach { - it.group = CHANNEL_SYNC - } + nm.createNotificationChannelGroup(NotificationChannelGroup(CHANNEL_SYNC, context.getString(R.string.notification_channel_sync))) nm.createNotificationChannels(listOf( NotificationChannel(CHANNEL_DEBUG, context.getString(R.string.notification_channel_debugging), NotificationManager.IMPORTANCE_HIGH), NotificationChannel(CHANNEL_GENERAL, context.getString(R.string.notification_channel_general), NotificationManager.IMPORTANCE_DEFAULT), - *syncChannels + + NotificationChannel(CHANNEL_SYNC_ERRORS, context.getString(R.string.notification_channel_sync_errors), NotificationManager.IMPORTANCE_DEFAULT).apply { + description = context.getString(R.string.notification_channel_sync_errors_desc) + group = CHANNEL_SYNC + }, + NotificationChannel(CHANNEL_SYNC_WARNINGS, context.getString(R.string.notification_channel_sync_warnings), NotificationManager.IMPORTANCE_LOW).apply { + description = context.getString(R.string.notification_channel_sync_warnings_desc) + group = CHANNEL_SYNC + }, + NotificationChannel(CHANNEL_SYNC_IO_ERRORS, context.getString(R.string.notification_channel_sync_io_errors), NotificationManager.IMPORTANCE_MIN).apply { + description = context.getString(R.string.notification_channel_sync_io_errors_desc) + group = CHANNEL_SYNC + } )) } } - fun newBuilder(context: Context, channel: String = CHANNEL_GENERAL): NotificationCompat.Builder { + fun newBuilder(context: Context, channel: String): NotificationCompat.Builder { val builder = NotificationCompat.Builder(context, channel) .setColor(context.resources.getColor(R.color.primaryColor)) diff --git a/app/src/main/java/at/bitfire/davdroid/ui/StartupDialogFragment.kt b/app/src/main/java/foundation/e/accountmanager/ui/StartupDialogFragment.kt similarity index 52% rename from app/src/main/java/at/bitfire/davdroid/ui/StartupDialogFragment.kt rename to app/src/main/java/foundation/e/accountmanager/ui/StartupDialogFragment.kt index cec98f7286cacbf837018af39457109a570b43b5..fc7474f5b50cecd5389600196ccc868803ed025e 100644 --- a/app/src/main/java/at/bitfire/davdroid/ui/StartupDialogFragment.kt +++ b/app/src/main/java/foundation/e/accountmanager/ui/StartupDialogFragment.kt @@ -6,39 +6,32 @@ * http://www.gnu.org/licenses/gpl.html */ -package at.bitfire.davdroid.ui +package foundation.e.accountmanager.ui import android.annotation.SuppressLint import android.annotation.TargetApi import android.app.Dialog import android.content.Context import android.content.DialogInterface -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.App -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.text.WordUtils +import androidx.fragment.app.DialogFragment +import foundation.e.accountmanager.App +import foundation.e.accountmanager.BuildConfig +import foundation.e.accountmanager.R +import foundation.e.accountmanager.log.Logger +import foundation.e.accountmanager.resource.LocalTaskList +import foundation.e.accountmanager.settings.Settings +import com.google.android.material.dialog.MaterialAlertDialogBuilder import java.util.* -import java.util.logging.Level -class StartupDialogFragment: DialogFragment(), LoaderManager.LoaderCallbacks { +class StartupDialogFragment: DialogFragment() { enum class Mode { AUTOSTART_PERMISSIONS, BATTERY_OPTIMIZATIONS, - GOOGLE_PLAY_ACCOUNTS_REMOVED, OPENTASKS_NOT_INSTALLED, OSE_DONATE } @@ -52,39 +45,32 @@ class StartupDialogFragment: DialogFragment(), LoaderManager.LoaderCallbacks { + fun getStartupDialogs(context: Context): List { val dialogs = LinkedList() + val settings = Settings.getInstance(context) - if (System.currentTimeMillis() > settings.getLong(SETTING_NEXT_DONATION_POPUP, 0)) + 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) { - // Play store - if (Build.VERSION.SDK_INT < Build.VERSION_CODES.LOLLIPOP && // only on Android <5 - settings.getBoolean(HINT_GOOGLE_PLAY_ACCOUNTS_REMOVED, true)) // and only when "Don't show again" hasn't been clicked yet - dialogs += StartupDialogFragment.instantiate(Mode.GOOGLE_PLAY_ACCOUNTS_REMOVED) - }*/ - // battery optimization white-listing - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M && settings.getBoolean(HINT_BATTERY_OPTIMIZATIONS, true)) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M && settings.getBoolean(HINT_BATTERY_OPTIMIZATIONS) != false) { val powerManager = context.getSystemService(Context.POWER_SERVICE) as PowerManager if (!powerManager.isIgnoringBatteryOptimizations(BuildConfig.APPLICATION_ID)) - dialogs.add(StartupDialogFragment.instantiate(Mode.BATTERY_OPTIMIZATIONS)) + dialogs.add(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)) + if (autostartManufacturers.contains(Build.MANUFACTURER.toLowerCase()) && settings.getBoolean(HINT_AUTOSTART_PERMISSIONS) != false) + dialogs.add(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)) + if (true /* don't show in other flavors */) + if (!LocalTaskList.tasksProviderAvailable(context) && settings.getBoolean(HINT_OPENTASKS_NOT_INSTALLED) != false) + dialogs.add(instantiate(Mode.OPENTASKS_NOT_INSTALLED)) return dialogs.reversed() } @@ -99,50 +85,31 @@ class StartupDialogFragment: DialogFragment(), LoaderManager.LoaderCallbacks, result: ISettings?) { - settings = result - } - - override fun onLoaderReset(loader: Loader) { - settings = null - } - - + @SuppressLint("BatteryLife") override fun onCreateDialog(savedInstanceState: Bundle?): Dialog { isCancelable = false + val settings = Settings.getInstance(requireActivity()) val activity = requireActivity() - val mode = Mode.valueOf(arguments!!.getString(ARGS_MODE)) - return when (mode) { + return when (Mode.valueOf(arguments!!.getString(ARGS_MODE)!!)) { Mode.AUTOSTART_PERMISSIONS -> - AlertDialog.Builder(activity) + MaterialAlertDialogBuilder(activity) .setIcon(R.drawable.ic_error_dark) .setTitle(R.string.startup_autostart_permission) - .setMessage(getString(R.string.startup_autostart_permission_message, WordUtils.capitalize(Build.MANUFACTURER.toLowerCase()))) + .setMessage(getString(R.string.startup_autostart_permission_message, Build.MANUFACTURER)) .setPositiveButton(R.string.startup_more_info) { _, _ -> UiUtils.launchUri(requireActivity(), App.homepageUrl(requireActivity()).buildUpon() - .appendPath("faq").appendEncodedPath("synchronization-is-not-run-as-expected/").build()) + .appendPath("faq").appendPath("synchronization-is-not-run-as-expected").build()) } .setNeutralButton(R.string.startup_not_now) { _, _ -> } .setNegativeButton(R.string.startup_dont_show_again) { _, _ -> - settings?.putBoolean(HINT_AUTOSTART_PERMISSIONS, false) + settings.putBoolean(HINT_AUTOSTART_PERMISSIONS, false) } .create() Mode.BATTERY_OPTIMIZATIONS -> - AlertDialog.Builder(activity) + MaterialAlertDialogBuilder(activity) .setIcon(R.drawable.ic_info_dark) .setTitle(R.string.startup_battery_optimization) .setMessage(R.string.startup_battery_optimization_message) @@ -152,80 +119,46 @@ class StartupDialogFragment: DialogFragment(), LoaderManager.LoaderCallbacks } .setNegativeButton(R.string.startup_dont_show_again) { _: DialogInterface, _: Int -> - settings?.putBoolean(HINT_BATTERY_OPTIMIZATIONS, false) - } - .create() - - Mode.GOOGLE_PLAY_ACCOUNTS_REMOVED -> { - var icon: Drawable? = null - try { - icon = activity.packageManager.getApplicationIcon("com.android.vending").current - } catch(e: PackageManager.NameNotFoundException) { - Logger.log.log(Level.WARNING, "Can't load Play Store icon", e) - } - return AlertDialog.Builder(activity) - .setIcon(icon) - .setTitle(R.string.startup_google_play_accounts_removed) - .setMessage(R.string.startup_google_play_accounts_removed_message) - .setPositiveButton(R.string.startup_more_info) { _, _ -> - UiUtils.launchUri(requireActivity(), App.homepageUrl(requireActivity()).buildUpon() - .appendPath("faq").appendEncodedPath("accounts-gone-after-reboot-or-update/").build()) - } - .setNeutralButton(R.string.startup_not_now) { _, _ -> } - .setNegativeButton(R.string.startup_dont_show_again) { _, _ -> - settings?.putBoolean(HINT_GOOGLE_PLAY_ACCOUNTS_REMOVED, false) + settings.putBoolean(HINT_BATTERY_OPTIMIZATIONS, false) } .create() - } Mode.OPENTASKS_NOT_INSTALLED -> { val builder = StringBuilder(getString(R.string.startup_opentasks_not_installed_message)) if (Build.VERSION.SDK_INT < 23) - builder.append("\n\n").append(getString(R.string.startup_opentasks_reinstall_davdroid)) - return AlertDialog.Builder(activity) + builder.append("\n\n").append(getString(R.string.startup_opentasks_reinstall_davx5)) + return MaterialAlertDialogBuilder(activity) .setIcon(R.drawable.ic_playlist_add_check_dark) .setTitle(R.string.startup_opentasks_not_installed) .setMessage(builder.toString()) .setPositiveButton(R.string.startup_opentasks_not_installed_install) { _, _ -> - if (!UiUtils.launchUri(requireActivity(), Uri.parse("market://details?id=org.dmfs.tasks"))) + if (!UiUtils.launchUri(requireActivity(), Uri.parse("market://details?id=org.dmfs.tasks"), toastInstallBrowser = false)) Logger.log.warning("No market app available, can't install OpenTasks") } .setNeutralButton(R.string.startup_not_now) { _, _ -> } .setNegativeButton(R.string.startup_dont_show_again) { _: DialogInterface, _: Int -> - settings?.putBoolean(HINT_OPENTASKS_NOT_INSTALLED, false) + settings.putBoolean(HINT_OPENTASKS_NOT_INSTALLED, false) } .create() } Mode.OSE_DONATE -> - return AlertDialog.Builder(activity) + return MaterialAlertDialogBuilder(activity) .setIcon(R.mipmap.ic_launcher) .setTitle(R.string.startup_donate) .setMessage(R.string.startup_donate_message) .setPositiveButton(R.string.startup_donate_now) { _, _ -> UiUtils.launchUri(requireActivity(), App.homepageUrl(requireActivity()).buildUpon() - .appendEncodedPath("donate/") + .appendPath("donate") .build()) - settings?.putLong(SETTING_NEXT_DONATION_POPUP, System.currentTimeMillis() + 30 * 86400000L) // 30 days + settings.putLong(SETTING_NEXT_DONATION_POPUP, System.currentTimeMillis() + 30 * 86400000L) // 30 days } .setNegativeButton(R.string.startup_donate_later) { _, _ -> - settings?.putLong(SETTING_NEXT_DONATION_POPUP, System.currentTimeMillis() + 14 * 86400000L) // 14 days + settings.putLong(SETTING_NEXT_DONATION_POPUP, System.currentTimeMillis() + 14 * 86400000L) // 14 days } .create() } } - - class SettingsLoader( - context: Context - ): at.bitfire.davdroid.ui.SettingsLoader(context) { - - override fun loadInBackground(): ISettings? { - settings?.let { return it } - return null - } - - } - } diff --git a/app/src/main/java/at/bitfire/davdroid/ui/UiUtils.kt b/app/src/main/java/foundation/e/accountmanager/ui/UiUtils.kt similarity index 60% rename from app/src/main/java/at/bitfire/davdroid/ui/UiUtils.kt rename to app/src/main/java/foundation/e/accountmanager/ui/UiUtils.kt index 9f8d1d0d8b6a684452766fa09aa8f2848e12d1e5..da71eae760422120afcc76f3d431482159cafcc1 100644 --- a/app/src/main/java/at/bitfire/davdroid/ui/UiUtils.kt +++ b/app/src/main/java/foundation/e/accountmanager/ui/UiUtils.kt @@ -6,11 +6,13 @@ * http://www.gnu.org/licenses/gpl.html */ -package at.bitfire.davdroid.ui +package foundation.e.accountmanager.ui import android.content.Context import android.content.Intent import android.net.Uri +import android.widget.Toast +import foundation.e.accountmanager.R object UiUtils { @@ -18,13 +20,20 @@ object UiUtils { * Starts the [Intent.ACTION_VIEW] intent with the given URL, if possible. * If the intent can't be resolved (for instance, because there is no browser * installed), this method does nothing. + * + * @param toastInstallBrowser whether to show "Please install a browser" toast when + * the Intent could not be resolved + * + * @return true on success, false if the Intent could not be resolved (for instance, because + * there is no user agent installed) */ - fun launchUri(context: Context, uri: Uri, action: String = Intent.ACTION_VIEW): Boolean { + fun launchUri(context: Context, uri: Uri, action: String = Intent.ACTION_VIEW, toastInstallBrowser: Boolean = true): Boolean { val intent = Intent(action, uri) if (intent.resolveActivity(context.packageManager) != null) { context.startActivity(intent) return true - } + } else if (toastInstallBrowser) + Toast.makeText(context, R.string.install_browser, Toast.LENGTH_LONG).show() return false } diff --git a/app/src/main/java/foundation/e/accountmanager/ui/account/AccountActivity.kt b/app/src/main/java/foundation/e/accountmanager/ui/account/AccountActivity.kt new file mode 100644 index 0000000000000000000000000000000000000000..945fb929f8ba596b494266b1d2e00b41589f93e0 --- /dev/null +++ b/app/src/main/java/foundation/e/accountmanager/ui/account/AccountActivity.kt @@ -0,0 +1,353 @@ +package foundation.e.accountmanager.ui.account + +import android.Manifest +import android.accounts.Account +import android.accounts.AccountManager +import android.app.Application +import android.content.Context +import android.content.Intent +import android.content.pm.PackageManager +import android.os.Build +import android.os.Bundle +import android.os.Handler +import android.os.Looper +import android.view.Menu +import android.view.MenuItem +import androidx.annotation.MainThread +import androidx.appcompat.app.AppCompatActivity +import androidx.core.app.ActivityCompat +import androidx.core.content.ContextCompat +import androidx.fragment.app.Fragment +import androidx.fragment.app.FragmentStatePagerAdapter +import androidx.lifecycle.* +import foundation.e.accountmanager.DavUtils +import foundation.e.accountmanager.R +import foundation.e.accountmanager.log.Logger +import foundation.e.accountmanager.model.AppDatabase +import foundation.e.accountmanager.model.Collection +import foundation.e.accountmanager.model.Service +import foundation.e.accountmanager.resource.LocalTaskList +import foundation.e.ical4android.TaskProvider +import com.google.android.material.dialog.MaterialAlertDialogBuilder +import com.google.android.material.snackbar.Snackbar +import kotlinx.android.synthetic.main.activity_account.* +import java.util.concurrent.Executors +import java.util.logging.Level +import kotlin.concurrent.thread + +class AccountActivity: AppCompatActivity() { + + companion object { + const val EXTRA_ACCOUNT = "account" + } + + lateinit var model: Model + + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + model = ViewModelProviders.of(this).get(Model::class.java) + (intent.getParcelableExtra(EXTRA_ACCOUNT) as? Account)?.let { account -> + model.initialize(account) + } + + title = model.account.name + setContentView(R.layout.activity_account) + setSupportActionBar(toolbar) + supportActionBar?.setDisplayHomeAsUpEnabled(true) + + tab_layout.setupWithViewPager(view_pager) + val tabsAdapter = TabsAdapter(this) + view_pager.adapter = tabsAdapter + model.cardDavService.observe(this, Observer { + tabsAdapter.cardDavSvcId = it + }) + model.calDavService.observe(this, Observer { + tabsAdapter.calDavSvcId = it + }) + + model.askForPermissions.observe(this, Observer { permissions -> + if (permissions.isNotEmpty()) + ActivityCompat.requestPermissions(this, permissions.toTypedArray(), 0) + }) + + sync.setOnClickListener { + DavUtils.requestSync(this, model.account) + Snackbar.make(view_pager, R.string.account_synchronizing_now, Snackbar.LENGTH_LONG).show() + } + } + + override fun onCreateOptionsMenu(menu: Menu?): Boolean { + menuInflater.inflate(R.menu.activity_account, menu) + return true + } + + override fun onPrepareOptionsMenu(menu: Menu): Boolean { + val itemRename = menu.findItem(R.id.rename_account) + // renameAccount is available for API level 21+ + itemRename.isVisible = Build.VERSION.SDK_INT >= 21 + return super.onPrepareOptionsMenu(menu) + } + + override fun onRequestPermissionsResult(requestCode: Int, permissions: Array, grantResults: IntArray) { + if (grantResults.contains(PackageManager.PERMISSION_GRANTED)) + model.gotPermissions() + } + + + // menu actions + + fun openAccountSettings(menuItem: MenuItem) { + val intent = Intent(this, SettingsActivity::class.java) + intent.putExtra(SettingsActivity.EXTRA_ACCOUNT, model.account) + startActivity(intent, null) + } + + fun renameAccount(menuItem: MenuItem) { + if (Build.VERSION.SDK_INT >= 21) + RenameAccountFragment.newInstance(model.account).show(supportFragmentManager, null) + } + + fun deleteAccount(menuItem: MenuItem) { + MaterialAlertDialogBuilder(this) + .setIcon(R.drawable.ic_error_dark) + .setTitle(R.string.account_delete_confirmation_title) + .setMessage(R.string.account_delete_confirmation_text) + .setNegativeButton(android.R.string.no, null) + .setPositiveButton(android.R.string.yes) { _, _ -> + deleteAccount() + } + .show() + } + + private fun deleteAccount() { + val accountManager = AccountManager.get(this) + + if (Build.VERSION.SDK_INT >= 22) + accountManager.removeAccount(model.account, this, { future -> + try { + if (future.result.getBoolean(AccountManager.KEY_BOOLEAN_RESULT)) + Handler(Looper.getMainLooper()).post { + finish() + } + } catch(e: Exception) { + Logger.log.log(Level.SEVERE, "Couldn't remove account", e) + } + }, null) + else + accountManager.removeAccount(model.account, { future -> + try { + if (future.result) + Handler(Looper.getMainLooper()).post { + finish() + } + } catch (e: Exception) { + Logger.log.log(Level.SEVERE, "Couldn't remove account", e) + } + }, null) + } + + + // adapter + + class TabsAdapter( + val activity: AppCompatActivity + ): FragmentStatePagerAdapter(activity.supportFragmentManager, BEHAVIOR_RESUME_ONLY_CURRENT_FRAGMENT) { + + var cardDavSvcId: Long? = null + set(value) { + field = value + recalculate() + } + var calDavSvcId: Long? = null + set(value) { + field = value + recalculate() + } + + private var idxCardDav: Int? = null + private var idxCalDav: Int? = null + private var idxWebcal: Int? = null + + private fun recalculate() { + var currentIndex = 0 + + idxCardDav = if (cardDavSvcId != null) + currentIndex++ + else + null + + if (calDavSvcId != null) { + idxCalDav = currentIndex++ + idxWebcal = currentIndex + } else { + idxCalDav = null + idxWebcal = null + } + + notifyDataSetChanged() + } + + override fun getCount() = + (if (idxCardDav != null) 1 else 0) + + (if (idxCalDav != null) 1 else 0) + + (if (idxWebcal != null) 1 else 0) + + override fun getItem(position: Int): Fragment { + val args = Bundle(1) + when (position) { + idxCardDav -> { + val frag = AddressBooksFragment() + args.putLong(CollectionsFragment.EXTRA_SERVICE_ID, cardDavSvcId!!) + args.putString(CollectionsFragment.EXTRA_COLLECTION_TYPE, Collection.TYPE_ADDRESSBOOK) + frag.arguments = args + return frag + } + idxCalDav -> { + val frag = CalendarsFragment() + args.putLong(CollectionsFragment.EXTRA_SERVICE_ID, calDavSvcId!!) + args.putString(CollectionsFragment.EXTRA_COLLECTION_TYPE, Collection.TYPE_CALENDAR) + frag.arguments = args + return frag + } + idxWebcal -> { + val frag = WebcalFragment() + args.putLong(CollectionsFragment.EXTRA_SERVICE_ID, calDavSvcId!!) + args.putString(CollectionsFragment.EXTRA_COLLECTION_TYPE, Collection.TYPE_WEBCAL) + frag.arguments = args + return frag + } + } + throw IllegalArgumentException() + } + + override fun getPageTitle(position: Int): String = + when (position) { + idxCardDav -> activity.getString(R.string.account_carddav) + idxCalDav -> activity.getString(R.string.account_caldav) + idxWebcal -> activity.getString(R.string.account_webcal) + else -> throw IllegalArgumentException() + } + + } + + + // model + + class Model(application: Application): AndroidViewModel(application) { + + private var initialized = false + lateinit var account: Account + private set + + private val db = AppDatabase.getInstance(application) + private val executor = Executors.newSingleThreadExecutor() + + val cardDavService = MutableLiveData() + val calDavService = MutableLiveData() + + private val needContactPermissions: LiveData = Transformations.switchMap(cardDavService) { cardDavId -> + if (cardDavId != null) + db.collectionDao().observeHasSyncByService(cardDavId) + else + MutableLiveData().apply { value = false } + } + private val needCalendarPermissions: LiveData = Transformations.map(calDavService) { calDavId -> + calDavId != null + } + val askForPermissions = PermissionCalculator(application, needContactPermissions, needCalendarPermissions) + + + @MainThread + fun initialize(account: Account) { + if (initialized) + return + initialized = true + + this.account = account + + thread { + cardDavService.postValue(db.serviceDao().getIdByAccountAndType(account.name, Service.TYPE_CARDDAV)) + calDavService.postValue(db.serviceDao().getIdByAccountAndType(account.name, Service.TYPE_CALDAV)) + } + } + + fun gotPermissions() { + askForPermissions.calculate() + } + + fun toggleSync(item: Collection) = + executor.execute { + val newItem = item.copy(sync = !item.sync) + db.collectionDao().update(newItem) + } + + fun toggleReadOnly(item: Collection) = + executor.execute { + val newItem = item.copy(forceReadOnly = !item.forceReadOnly) + db.collectionDao().update(newItem) + } + + } + + class PermissionCalculator( + val context: Context, + needContactPermissions: LiveData, + needCalendarPermissions: LiveData + ): MediatorLiveData>() { + + companion object { + val contactPermissions = arrayOf( + Manifest.permission.READ_CONTACTS, + Manifest.permission.WRITE_CONTACTS + ) + val calendarPermissions = arrayOf( + Manifest.permission.READ_CALENDAR, + Manifest.permission.WRITE_CALENDAR + ) + val taskPermissions = arrayOf( + TaskProvider.PERMISSION_READ_TASKS, + TaskProvider.PERMISSION_WRITE_TASKS + ) + } + + private var usesContacts: Boolean? = null + private var usesCalendars: Boolean? = null + + init { + addSource(needContactPermissions) { + usesContacts = it + calculate() + } + addSource(needCalendarPermissions) { + usesCalendars = it + calculate() + } + } + + fun calculate() { + val contacts = usesContacts ?: return + val calendar = usesCalendars ?: return + + val required = mutableListOf() + if (contacts) + required.addAll(contactPermissions) + + if (calendar) { + required.addAll(calendarPermissions) + if (LocalTaskList.tasksProviderAvailable(context)) + required.addAll(taskPermissions) + } + + // only ask for permissions which are not granted + val askFor = required.filter { + ContextCompat.checkSelfPermission(context, it) == PackageManager.PERMISSION_DENIED + } + if (value != askFor) + value = askFor + } + + } + +} diff --git a/app/src/main/java/foundation/e/accountmanager/ui/account/AddressBooksFragment.kt b/app/src/main/java/foundation/e/accountmanager/ui/account/AddressBooksFragment.kt new file mode 100644 index 0000000000000000000000000000000000000000..2a3ff5d12e1cc9864479090130a6bc9da37997af --- /dev/null +++ b/app/src/main/java/foundation/e/accountmanager/ui/account/AddressBooksFragment.kt @@ -0,0 +1,69 @@ +package foundation.e.accountmanager.ui.account + +import android.content.Intent +import android.view.* +import foundation.e.accountmanager.R +import foundation.e.accountmanager.model.Collection +import foundation.e.accountmanager.ui.CreateAddressBookActivity +import kotlinx.android.synthetic.main.account_carddav_item.view.* + +class AddressBooksFragment: CollectionsFragment() { + + override val noCollectionsStringId = R.string.account_no_address_books + + override fun onCreateOptionsMenu(menu: Menu, inflater: MenuInflater) = + inflater.inflate(R.menu.carddav_actions, menu) + + override fun onOptionsItemSelected(item: MenuItem): Boolean { + if (super.onOptionsItemSelected(item)) + return true + + if (item.itemId == R.id.create_address_book) { + val intent = Intent(requireActivity(), CreateAddressBookActivity::class.java) + intent.putExtra(CreateAddressBookActivity.EXTRA_ACCOUNT, accountModel.account) + startActivity(intent) + return true + } + + return false + } + + override fun createAdapter() = AddressBookAdapter(accountModel) + + + class AddressBookViewHolder( + parent: ViewGroup, + accountModel: AccountActivity.Model + ): CollectionViewHolder(parent, R.layout.account_carddav_item, accountModel) { + + override fun bindTo(item: Collection) { + val v = itemView + v.sync.isChecked = item.sync + v.title.text = item.title() + + if (item.description.isNullOrBlank()) + v.description.visibility = View.GONE + else { + v.description.text = item.description + v.description.visibility = View.VISIBLE + } + + v.read_only.visibility = if (item.readOnly()) View.VISIBLE else View.GONE + + itemView.setOnClickListener { + accountModel.toggleSync(item) + } + v.action_overflow.setOnClickListener(CollectionPopupListener(accountModel, item)) + } + } + + class AddressBookAdapter( + accountModel: AccountActivity.Model + ): CollectionAdapter(accountModel) { + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int) = + AddressBookViewHolder(parent, accountModel) + + } + +} \ No newline at end of file diff --git a/app/src/main/java/foundation/e/accountmanager/ui/account/CalendarsFragment.kt b/app/src/main/java/foundation/e/accountmanager/ui/account/CalendarsFragment.kt new file mode 100644 index 0000000000000000000000000000000000000000..724025bcfd333b5e1814d192c300417599c2a75f --- /dev/null +++ b/app/src/main/java/foundation/e/accountmanager/ui/account/CalendarsFragment.kt @@ -0,0 +1,75 @@ +package foundation.e.accountmanager.ui.account + +import android.content.Intent +import android.view.* +import foundation.e.accountmanager.Constants +import foundation.e.accountmanager.R +import foundation.e.accountmanager.model.Collection +import foundation.e.accountmanager.ui.CreateCalendarActivity +import kotlinx.android.synthetic.main.account_caldav_item.view.* + +class CalendarsFragment: CollectionsFragment() { + + override val noCollectionsStringId = R.string.account_no_calendars + + override fun onCreateOptionsMenu(menu: Menu, inflater: MenuInflater) = + inflater.inflate(R.menu.caldav_actions, menu) + + override fun onOptionsItemSelected(item: MenuItem): Boolean { + if (super.onOptionsItemSelected(item)) + return true + + if (item.itemId == R.id.create) { + val intent = Intent(requireActivity(), CreateCalendarActivity::class.java) + intent.putExtra(CreateCalendarActivity.EXTRA_ACCOUNT, accountModel.account) + startActivity(intent) + return true + } + + return false + } + + override fun createAdapter(): CollectionAdapter = CalendarAdapter(accountModel) + + + class CalendarViewHolder( + parent: ViewGroup, + accountModel: AccountActivity.Model + ): CollectionViewHolder(parent, R.layout.account_caldav_item, accountModel) { + + override fun bindTo(item: Collection) { + val v = itemView + v.color.setBackgroundColor(item.color ?: Constants.DAVDROID_GREEN_RGBA) + + v.sync.isChecked = item.sync + v.title.text = item.title() + + if (item.description.isNullOrBlank()) + v.description.visibility = View.GONE + else { + v.description.text = item.description + v.description.visibility = View.VISIBLE + } + + v.read_only.visibility = if (item.readOnly()) View.VISIBLE else View.GONE + v.events.visibility = if (item.supportsVEVENT == true) View.VISIBLE else View.GONE + v.tasks.visibility = if (item.supportsVTODO == true) View.VISIBLE else View.GONE + + itemView.setOnClickListener { + accountModel.toggleSync(item) + } + v.action_overflow.setOnClickListener(CollectionPopupListener(accountModel, item)) + } + + } + + class CalendarAdapter( + accountModel: AccountActivity.Model + ): CollectionAdapter(accountModel) { + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int) = + CalendarViewHolder(parent, accountModel) + + } + +} \ No newline at end of file diff --git a/app/src/main/java/foundation/e/accountmanager/ui/account/CollectionInfoFragment.kt b/app/src/main/java/foundation/e/accountmanager/ui/account/CollectionInfoFragment.kt new file mode 100644 index 0000000000000000000000000000000000000000..cc64e8bd7201aa20be68ed52dccf54f2e5bf67d6 --- /dev/null +++ b/app/src/main/java/foundation/e/accountmanager/ui/account/CollectionInfoFragment.kt @@ -0,0 +1,78 @@ +/* + * 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 foundation.e.accountmanager.ui.account + +import android.app.Application +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.annotation.UiThread +import androidx.fragment.app.DialogFragment +import androidx.lifecycle.AndroidViewModel +import androidx.lifecycle.MutableLiveData +import androidx.lifecycle.ViewModelProviders +import foundation.e.accountmanager.databinding.CollectionPropertiesBinding +import foundation.e.accountmanager.model.AppDatabase +import foundation.e.accountmanager.model.Collection +import kotlin.concurrent.thread + +class CollectionInfoFragment: DialogFragment() { + + companion object { + + private const val ARGS_COLLECTION_ID = "collectionId" + + fun newInstance(collectionId: Long): CollectionInfoFragment { + val frag = CollectionInfoFragment() + val args = Bundle(1) + args.putLong(ARGS_COLLECTION_ID, collectionId) + frag.arguments = args + return frag + } + + } + + override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? { + val model = ViewModelProviders.of(this).get(Model::class.java) + arguments?.getLong(ARGS_COLLECTION_ID)?.let { id -> + model.initialize(id) + } + + val view = CollectionPropertiesBinding.inflate(inflater, container, false) + view.lifecycleOwner = this + view.model = model + + return view.root + } + + + class Model( + application: Application + ): AndroidViewModel(application) { + + var collection = MutableLiveData() + + private var initialized = false + + @UiThread + fun initialize(collectionId: Long) { + if (initialized) + return + initialized = true + + thread { + val db = AppDatabase.getInstance(getApplication()) + collection.postValue(db.collectionDao().get(collectionId)) + } + } + + } + +} \ No newline at end of file diff --git a/app/src/main/java/foundation/e/accountmanager/ui/account/CollectionsFragment.kt b/app/src/main/java/foundation/e/accountmanager/ui/account/CollectionsFragment.kt new file mode 100644 index 0000000000000000000000000000000000000000..967474dcfebbc26cc4b32c473783dfa106cc5cce --- /dev/null +++ b/app/src/main/java/foundation/e/accountmanager/ui/account/CollectionsFragment.kt @@ -0,0 +1,317 @@ +package foundation.e.accountmanager.ui.account + +import android.app.Application +import android.content.* +import android.os.Bundle +import android.os.IBinder +import android.provider.CalendarContract +import android.provider.ContactsContract +import android.view.* +import android.widget.PopupMenu +import androidx.annotation.WorkerThread +import androidx.appcompat.app.AppCompatActivity +import androidx.fragment.app.Fragment +import androidx.lifecycle.* +import androidx.paging.PagedList +import androidx.paging.PagedListAdapter +import androidx.paging.toLiveData +import androidx.recyclerview.widget.DiffUtil +import androidx.recyclerview.widget.LinearLayoutManager +import androidx.recyclerview.widget.RecyclerView +import androidx.swiperefreshlayout.widget.SwipeRefreshLayout +import foundation.e.accountmanager.Constants +import foundation.e.accountmanager.DavService +import foundation.e.accountmanager.R +import foundation.e.accountmanager.model.AppDatabase +import foundation.e.accountmanager.model.Collection +import foundation.e.accountmanager.resource.LocalAddressBook +import foundation.e.accountmanager.resource.LocalTaskList +import foundation.e.accountmanager.ui.DeleteCollectionFragment +import foundation.e.ical4android.TaskProvider +import kotlinx.android.synthetic.main.account_collections.* +import java.util.concurrent.Executors + +abstract class CollectionsFragment: Fragment(), SwipeRefreshLayout.OnRefreshListener { + + companion object { + const val EXTRA_SERVICE_ID = "serviceId" + const val EXTRA_COLLECTION_TYPE = "collectionType" + } + + lateinit var accountModel: AccountActivity.Model + lateinit var model: Model + + abstract val noCollectionsStringId: Int + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + accountModel = ViewModelProviders.of(requireActivity()).get(AccountActivity.Model::class.java) + model = ViewModelProviders.of(this).get(Model::class.java) + model.initialize( + accountModel, + arguments?.getLong(EXTRA_SERVICE_ID) ?: throw IllegalArgumentException("EXTRA_SERVICE_ID required"), + arguments?.getString(EXTRA_COLLECTION_TYPE) ?: throw IllegalArgumentException("EXTRA_COLLECTION_TYPE required") + ) + + setHasOptionsMenu(true) + } + + override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View = + inflater.inflate(R.layout.account_collections, container, false) + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + + model.isRefreshing.observe(viewLifecycleOwner, Observer { nowRefreshing -> + swipe_refresh.isRefreshing = nowRefreshing + }) + model.collections.observe(viewLifecycleOwner, Observer { collections -> + val colors = collections.orEmpty() + .filterNotNull() + .mapNotNull { it.color } + .distinct() + .ifEmpty { listOf(Constants.DAVDROID_GREEN_RGBA) } + swipe_refresh.setColorSchemeColors(*colors.toIntArray()) + }) + swipe_refresh.setOnRefreshListener(this) + + val updateProgress = Observer { + if (model.isSyncActive.value == true) { + progress.isIndeterminate = true + progress.alpha = 1.0f + progress.visibility = View.VISIBLE + } else { + if (model.isSyncPending.value == true) { + progress.visibility = View.VISIBLE + progress.alpha = 0.2f + progress.isIndeterminate = false + progress.progress = 100 + } else + progress.visibility = View.INVISIBLE + } + } + model.isSyncPending.observe(viewLifecycleOwner, updateProgress) + model.isSyncActive.observe(viewLifecycleOwner, updateProgress) + + val adapter = createAdapter() + list.layoutManager = LinearLayoutManager(requireActivity()) + list.adapter = adapter + model.collections.observe(viewLifecycleOwner, Observer { data -> + adapter.submitList(data) + + if (data.isEmpty()) { + list.visibility = View.GONE + empty.visibility = View.VISIBLE + } else { + list.visibility = View.VISIBLE + empty.visibility = View.GONE + } + }) + + no_collections.setText(noCollectionsStringId) + } + + protected abstract fun createAdapter(): CollectionAdapter + + override fun onOptionsItemSelected(item: MenuItem) = + when (item.itemId) { + R.id.refresh -> { + onRefresh() + true + } + else -> + false + } + + override fun onRefresh() { + model.refresh() + } + + + + abstract class CollectionViewHolder( + parent: ViewGroup, + itemLayout: Int, + protected val accountModel: AccountActivity.Model + ): RecyclerView.ViewHolder( + LayoutInflater.from(parent.context).inflate(itemLayout, parent, false) + ) { + abstract fun bindTo(item: Collection) + } + + abstract class CollectionAdapter( + protected val accountModel: AccountActivity.Model + ): PagedListAdapter(DIFF_CALLBACK) { + + companion object { + private val DIFF_CALLBACK = object: DiffUtil.ItemCallback() { + override fun areItemsTheSame(oldItem: Collection, newItem: Collection) = + oldItem.id == newItem.id + + override fun areContentsTheSame(oldItem: Collection, newItem: Collection) = + oldItem == newItem + } + } + + override fun onBindViewHolder(holder: CollectionViewHolder, position: Int) { + getItem(position)?.let { item -> + holder.bindTo(item) + } + } + + } + + class CollectionPopupListener( + private val accountModel: AccountActivity.Model, + private val item: Collection + ): View.OnClickListener { + + override fun onClick(anchor: View) { + val fragmentManager = (anchor.context as AppCompatActivity).supportFragmentManager + val popup = PopupMenu(anchor.context, anchor, Gravity.RIGHT) + popup.inflate(R.menu.account_collection_operations) + + with(popup.menu.findItem(R.id.force_read_only)) { + if (item.type == Collection.TYPE_WEBCAL) + // Webcal collections are always read-only + isVisible = false + else { + // non-Webcal collection + if (item.privWriteContent) + isChecked = item.forceReadOnly + else + isVisible = false + } + } + popup.menu.findItem(R.id.delete_collection).isVisible = item.privUnbind + + popup.setOnMenuItemClickListener { menuItem -> + when (menuItem.itemId) { + R.id.force_read_only -> { + accountModel.toggleReadOnly(item) + } + R.id.properties -> + CollectionInfoFragment.newInstance(item.id).show(fragmentManager, null) + R.id.delete_collection -> + DeleteCollectionFragment.newInstance(accountModel.account, item.id).show(fragmentManager, null) + } + true + } + popup.show() + } + + } + + + class Model(application: Application): AndroidViewModel(application), DavService.RefreshingStatusListener, SyncStatusObserver { + + private val db = AppDatabase.getInstance(application) + private val executor = Executors.newSingleThreadExecutor() + + private lateinit var accountModel: AccountActivity.Model + val serviceId = MutableLiveData() + private lateinit var collectionType: String + + val collections: LiveData> = + Transformations.switchMap(serviceId) { service -> + db.collectionDao().pageByServiceAndType(service, collectionType).toLiveData(25) + } + + // observe DavService refresh status + @Volatile + private var davService: DavService.InfoBinder? = null + private var davServiceConn: ServiceConnection? = null + private val svcConn = object: ServiceConnection { + override fun onServiceConnected(name: ComponentName?, service: IBinder?) { + val svc = service as DavService.InfoBinder + davService = svc + svc.addRefreshingStatusListener(this@Model, true) + } + override fun onServiceDisconnected(name: ComponentName?) { + davService = null + } + } + val isRefreshing = MutableLiveData() + + // observe whether sync is active + private var syncStatusHandle: Any? = null + val isSyncActive = MutableLiveData() + val isSyncPending = MutableLiveData() + + + fun initialize(accountModel: AccountActivity.Model, id: Long, collectionType: String) { + this.accountModel = accountModel + this.collectionType = collectionType + if (serviceId.value == null) + serviceId.value = id + + val context = getApplication() + if (context.bindService(Intent(context, DavService::class.java), svcConn, Context.BIND_AUTO_CREATE)) + davServiceConn = svcConn + + executor.submit { + syncStatusHandle = ContentResolver.addStatusChangeListener(ContentResolver.SYNC_OBSERVER_TYPE_PENDING + ContentResolver.SYNC_OBSERVER_TYPE_ACTIVE, this) + checkSyncStatus() + } + } + + override fun onCleared() { + syncStatusHandle?.let { ContentResolver.removeStatusChangeListener(it) } + + davService?.removeRefreshingStatusListener(this) + davServiceConn?.let { + getApplication().unbindService(it) + davServiceConn = null + } + } + + fun refresh() { + val context = getApplication() + val intent = Intent(context, DavService::class.java) + intent.action = DavService.ACTION_REFRESH_COLLECTIONS + intent.putExtra(DavService.EXTRA_DAV_SERVICE_ID, serviceId.value) + context.startService(intent) + } + + @WorkerThread + override fun onDavRefreshStatusChanged(id: Long, refreshing: Boolean) { + if (id == serviceId.value) + isRefreshing.postValue(refreshing) + } + + override fun onStatusChanged(which: Int) { + executor.submit { + checkSyncStatus() + } + } + + private fun checkSyncStatus() { + val context = getApplication() + if (collectionType == Collection.TYPE_ADDRESSBOOK) { + val mainAuthority = context.getString(R.string.address_books_authority) + val mainSyncActive = ContentResolver.isSyncActive(accountModel.account, mainAuthority) + val mainSyncPending = ContentResolver.isSyncPending(accountModel.account, mainAuthority) + + val accounts = LocalAddressBook.findAll(context, null, accountModel.account) + val syncActive = accounts.any { ContentResolver.isSyncActive(it.account, ContactsContract.AUTHORITY) } + val syncPending = accounts.any { ContentResolver.isSyncPending(it.account, ContactsContract.AUTHORITY) } + + isSyncActive.postValue(mainSyncActive || syncActive) + isSyncPending.postValue(mainSyncPending || syncPending) + } else { + val authorities = mutableListOf(CalendarContract.AUTHORITY) + if (LocalTaskList.tasksProviderAvailable(context)) + authorities += TaskProvider.ProviderName.OpenTasks.authority + isSyncActive.postValue(authorities.any { + ContentResolver.isSyncActive(accountModel.account, it) + }) + isSyncPending.postValue(authorities.any { + ContentResolver.isSyncPending(accountModel.account, it) + }) + } + } + + } + +} \ No newline at end of file diff --git a/app/src/main/java/foundation/e/accountmanager/ui/account/RenameAccountFragment.kt b/app/src/main/java/foundation/e/accountmanager/ui/account/RenameAccountFragment.kt new file mode 100644 index 0000000000000000000000000000000000000000..73319f060e1f1d8b7c3547c426be48678af62122 --- /dev/null +++ b/app/src/main/java/foundation/e/accountmanager/ui/account/RenameAccountFragment.kt @@ -0,0 +1,181 @@ +package foundation.e.accountmanager.ui.account + +import android.Manifest +import android.accounts.Account +import android.accounts.AccountManager +import android.annotation.SuppressLint +import android.app.Application +import android.app.Dialog +import android.content.ContentResolver +import android.content.DialogInterface +import android.content.pm.PackageManager +import android.os.Build +import android.os.Bundle +import android.os.Looper +import android.provider.CalendarContract +import android.provider.ContactsContract +import android.widget.EditText +import android.widget.LinearLayout +import androidx.annotation.RequiresApi +import androidx.core.content.ContextCompat +import androidx.fragment.app.DialogFragment +import androidx.lifecycle.AndroidViewModel +import androidx.lifecycle.MutableLiveData +import androidx.lifecycle.Observer +import androidx.lifecycle.ViewModelProviders +import foundation.e.accountmanager.DavUtils +import foundation.e.accountmanager.R +import foundation.e.accountmanager.closeCompat +import foundation.e.accountmanager.log.Logger +import foundation.e.accountmanager.model.AppDatabase +import foundation.e.accountmanager.resource.LocalAddressBook +import foundation.e.accountmanager.resource.LocalTaskList +import foundation.e.accountmanager.settings.AccountSettings +import foundation.e.ical4android.TaskProvider +import com.google.android.material.dialog.MaterialAlertDialogBuilder +import java.util.logging.Level +import kotlin.concurrent.thread + +@RequiresApi(Build.VERSION_CODES.LOLLIPOP) +class RenameAccountFragment: DialogFragment() { + + companion object { + + private const val ARG_ACCOUNT = "account" + + fun newInstance(account: Account): RenameAccountFragment { + val fragment = RenameAccountFragment() + val args = Bundle(1) + args.putParcelable(ARG_ACCOUNT, account) + fragment.arguments = args + return fragment + } + + } + + @SuppressLint("Recycle") + override fun onCreateDialog(savedInstanceState: Bundle?): Dialog { + val oldAccount: Account = arguments!!.getParcelable(ARG_ACCOUNT)!! + + val editText = EditText(requireActivity()).apply { + setText(oldAccount.name) + requestFocus() + } + val layout = LinearLayout(requireContext()) + val density = requireActivity().resources.displayMetrics.density.toInt() + layout.setPadding(8*density, 8*density, 8*density, 8*density) + layout.addView(editText) + + val model = ViewModelProviders.of(this).get(Model::class.java) + model.finished.observe(this, Observer { + this@RenameAccountFragment.requireActivity().finish() + }) + + return MaterialAlertDialogBuilder(requireActivity()) + .setTitle(R.string.account_rename) + .setMessage(R.string.account_rename_new_name) + .setView(layout) + .setPositiveButton(R.string.account_rename_rename, DialogInterface.OnClickListener { _, _ -> + val newName = editText.text.toString() + + if (newName == oldAccount.name) + return@OnClickListener + + model.renameAccount(oldAccount, newName) + }) + .setNegativeButton(android.R.string.cancel) { _, _ -> } + .create() + } + + + class Model( + application: Application + ): AndroidViewModel(application) { + + val finished = MutableLiveData() + + fun renameAccount(oldAccount: Account, newName: String) { + val context = getApplication() + + thread { + // remember sync intervals + val oldSettings = AccountSettings(context, oldAccount) + val authorities = arrayOf( + context.getString(R.string.address_books_authority), + CalendarContract.AUTHORITY, + TaskProvider.ProviderName.OpenTasks.authority + ) + val syncIntervals = authorities.map { Pair(it, oldSettings.getSyncInterval(it)) } + + val accountManager = AccountManager.get(context) + accountManager.renameAccount(oldAccount, newName, { + thread { + onAccountRenamed(accountManager, oldAccount, newName, syncIntervals) + } + }, null) + } + } + + @SuppressLint("Recycle") + fun onAccountRenamed(accountManager: AccountManager, oldAccount: Account, newName: String, syncIntervals: List>) { + // account has now been renamed + Logger.log.info("Updating account name references") + val context = getApplication() + + // cancel maybe running synchronization + ContentResolver.cancelSync(oldAccount, null) + for (addrBookAccount in accountManager.getAccountsByType(context.getString(R.string.account_type_address_book))) + ContentResolver.cancelSync(addrBookAccount, null) + + // update account name references in database + val db = AppDatabase.getInstance(context) + Logger.log.log(Level.INFO, "Main thread", Looper.getMainLooper().thread) + Logger.log.log(Level.INFO, "Current thread", Thread.currentThread()) + db.serviceDao().renameAccount(oldAccount.name, newName) + + // update main account of address book accounts + if (ContextCompat.checkSelfPermission(context, Manifest.permission.WRITE_CONTACTS) == PackageManager.PERMISSION_GRANTED) + try { + context.contentResolver.acquireContentProviderClient(ContactsContract.AUTHORITY)?.let { provider -> + for (addrBookAccount in accountManager.getAccountsByType(context.getString(R.string.account_type_address_book))) + try { + val addressBook = LocalAddressBook(context, addrBookAccount, provider) + if (oldAccount == addressBook.mainAccount) + addressBook.mainAccount = Account(newName, oldAccount.type) + } finally { + provider.closeCompat() + } + } + } catch (e: Exception) { + Logger.log.log(Level.SEVERE, "Couldn't update address book accounts", e) + } + + // calendar provider doesn't allow changing account_name of Events + // (all events will have to be downloaded again) + + // update account_name of local tasks + try { + LocalTaskList.onRenameAccount(context.contentResolver, oldAccount.name, newName) + } catch (e: Exception) { + Logger.log.log(Level.SEVERE, "Couldn't propagate new account name to tasks provider", e) + } + + // retain sync intervals + val newAccount = Account(newName, oldAccount.type) + val newSettings = AccountSettings(context, newAccount) + for ((authority, interval) in syncIntervals) { + if (interval == null) + ContentResolver.setIsSyncable(newAccount, authority, 0) + else { + ContentResolver.setIsSyncable(newAccount, authority, 1) + newSettings.setSyncInterval(authority, interval) + } + } + + // synchronize again + DavUtils.requestSync(context, newAccount) + } + + } + +} \ No newline at end of file diff --git a/app/src/main/java/foundation/e/accountmanager/ui/account/SettingsActivity.kt b/app/src/main/java/foundation/e/accountmanager/ui/account/SettingsActivity.kt new file mode 100644 index 0000000000000000000000000000000000000000..58b6cd7a20bd602eaf8ab07069fbf2ed8dbedfb4 --- /dev/null +++ b/app/src/main/java/foundation/e/accountmanager/ui/account/SettingsActivity.kt @@ -0,0 +1,581 @@ +/* + * 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 foundation.e.accountmanager.ui.account + +import android.Manifest +import android.accounts.Account +import android.accounts.AccountManager +import android.app.Application +import android.content.ContentResolver +import android.content.Intent +import android.content.SyncStatusObserver +import android.content.pm.PackageManager +import android.os.Build +import android.os.Bundle +import android.provider.CalendarContract +import android.security.KeyChain +import android.view.MenuItem +import androidx.appcompat.app.AppCompatActivity +import androidx.core.app.NavUtils +import androidx.core.content.ContextCompat +import androidx.fragment.app.DialogFragment +import androidx.lifecycle.* +import androidx.preference.* +import foundation.e.accountmanager.App +import foundation.e.accountmanager.R +import foundation.e.accountmanager.model.Credentials +import foundation.e.accountmanager.settings.AccountSettings +import foundation.e.accountmanager.settings.Settings +import foundation.e.accountmanager.syncadapter.SyncAdapterService +import foundation.e.ical4android.TaskProvider +import foundation.e.vcard4android.GroupMethod +import com.google.android.material.dialog.MaterialAlertDialogBuilder +import org.apache.commons.lang3.StringUtils + +class SettingsActivity: AppCompatActivity() { + + companion object { + const val EXTRA_ACCOUNT = "account" + } + + private lateinit var account: Account + + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + account = intent.getParcelableExtra(EXTRA_ACCOUNT) ?: throw IllegalArgumentException("EXTRA_ACCOUNT must be set") + title = getString(R.string.settings_title, account.name) + + supportActionBar?.setDisplayHomeAsUpEnabled(true) + + if (savedInstanceState == null) + supportFragmentManager.beginTransaction() + .replace(android.R.id.content, DialogFragment.instantiate(this, AccountSettingsFragment::class.java.name, intent.extras)) + .commit() + } + + override fun onOptionsItemSelected(item: MenuItem) = + if (item.itemId == android.R.id.home) { + val intent = Intent(this, AccountActivity::class.java) + intent.putExtra(AccountActivity.EXTRA_ACCOUNT, account) + NavUtils.navigateUpTo(this, intent) + true + } else + false + + + class AccountSettingsFragment: PreferenceFragmentCompat() { + private lateinit var settings: Settings + lateinit var account: Account + + private lateinit var model: Model + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + settings = Settings.getInstance(requireActivity()) + account = arguments!!.getParcelable(EXTRA_ACCOUNT)!! + + model = ViewModelProviders.of(this).get(Model::class.java) + model.initialize(account) + + initSettings() + } + + override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) { + addPreferencesFromResource(R.xml.settings_account) + } + + private fun launchSetup():Boolean { + AccountManager.get(context).addAccount(getString(R.string.google_account_type), + null, null, null, activity, null, + null) + return true + } + + private fun initSettings() { + //val accountSettings = AccountSettings(requireActivity(), account) + + // preference group: sync + findPreference(getString(R.string.settings_sync_interval_contacts_key))!!.let { + model.syncIntervalContacts.observe(this, Observer { interval -> + if (interval != null) { + it.isEnabled = true + it.isVisible = true + it.value = interval.toString() + if (interval == AccountSettings.SYNC_INTERVAL_MANUALLY) + it.setSummary(R.string.settings_sync_summary_manually) + else + it.summary = getString(R.string.settings_sync_summary_periodically, interval / 60) + it.onPreferenceChangeListener = Preference.OnPreferenceChangeListener { pref, newValue -> + pref.isEnabled = false + model.updateSyncInterval(getString(R.string.address_books_authority), (newValue as String).toLong()) + false + } + } else + it.isVisible = false + }) + } + findPreference(getString(R.string.settings_sync_interval_calendars_key))!!.let { + model.syncIntervalCalendars.observe(this, Observer { interval -> + if (interval != null) { + it.isEnabled = true + it.isVisible = true + it.value = interval.toString() + if (interval == AccountSettings.SYNC_INTERVAL_MANUALLY) + it.setSummary(R.string.settings_sync_summary_manually) + else + it.summary = getString(R.string.settings_sync_summary_periodically, interval / 60) + it.onPreferenceChangeListener = Preference.OnPreferenceChangeListener { pref, newValue -> + pref.isEnabled = false + model.updateSyncInterval(CalendarContract.AUTHORITY, (newValue as String).toLong()) + false + } + } else + it.isVisible = false + }) + } + findPreference(getString(R.string.settings_sync_interval_tasks_key))!!.let { + model.syncIntervalTasks.observe(this, Observer { interval -> + if (interval != null) { + it.isEnabled = true + it.isVisible = true + it.value = interval.toString() + if (interval == AccountSettings.SYNC_INTERVAL_MANUALLY) + it.setSummary(R.string.settings_sync_summary_manually) + else + it.summary = getString(R.string.settings_sync_summary_periodically, interval / 60) + it.onPreferenceChangeListener = Preference.OnPreferenceChangeListener { pref, newValue -> + pref.isEnabled = false + model.updateSyncInterval(TaskProvider.ProviderName.OpenTasks.authority, (newValue as String).toLong()) + false + } + } else + it.isVisible = false + }) + } + + findPreference(getString(R.string.settings_sync_wifi_only_key))!!.let { + model.syncWifiOnly.observe(this, Observer { wifiOnly -> + it.isEnabled = !settings.has(AccountSettings.KEY_WIFI_ONLY) + it.isChecked = wifiOnly + it.onPreferenceChangeListener = Preference.OnPreferenceChangeListener { _, wifiOnly -> + model.updateSyncWifiOnly(wifiOnly as Boolean) + false + } + }) + } + + findPreference("sync_wifi_only_ssids")!!.let { + model.syncWifiOnlySSIDs.observe(this, Observer { onlySSIDs -> + if (onlySSIDs != null) { + it.text = onlySSIDs.joinToString(", ") + it.summary = getString(if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O_MR1) + R.string.settings_sync_wifi_only_ssids_on_location_services + else R.string.settings_sync_wifi_only_ssids_on, onlySSIDs.joinToString(", ")) + } else { + it.text = "" + it.setSummary(R.string.settings_sync_wifi_only_ssids_off) + } + it.onPreferenceChangeListener = Preference.OnPreferenceChangeListener { _, newValue -> + val newOnlySSIDs = (newValue as String) + .split(',') + .mapNotNull { StringUtils.trimToNull(it) } + .distinct() + model.updateSyncWifiOnlySSIDs(newOnlySSIDs) + false + } + }) + } + + model.askForPermissions.observe(this, Observer { permissions -> + if (permissions.any { ContextCompat.checkSelfPermission(requireActivity(), it) != PackageManager.PERMISSION_GRANTED }) { + if (permissions.any { shouldShowRequestPermissionRationale(it) }) + // show rationale before requesting permissions + MaterialAlertDialogBuilder(requireActivity()) + .setIcon(R.drawable.ic_network_wifi_dark) + .setTitle(R.string.settings_sync_wifi_only_ssids) + .setMessage(R.string.settings_sync_wifi_only_ssids_location_permission) + .setPositiveButton(android.R.string.ok) { _, _ -> + requestPermissions(permissions.toTypedArray(), 0) + } + .setNeutralButton(R.string.settings_more_info_faq) { _, _ -> + val faqUrl = App.homepageUrl(requireActivity()).buildUpon() + .appendPath("faq").appendPath("wifi-ssid-restriction-location-permission") + .build() + val intent = Intent(Intent.ACTION_VIEW, faqUrl) + startActivity(Intent.createChooser(intent, null)) + } + .show() + else + // request permissions without rationale + requestPermissions(permissions.toTypedArray(), 0) + } + }) + + // preference group: authentication + val prefCredentials = findPreference("credentials")!! + val prefUserName = findPreference("username")!! + val prefPassword = findPreference("password")!! + val prefCertAlias = findPreference("certificate_alias")!! + model.credentials.observe(this, Observer { credentials -> + when (credentials.type) { + Credentials.Type.UsernamePassword -> { + prefCredentials.isVisible = false + prefUserName.isVisible = true + prefUserName.summary = credentials.userName + prefUserName.text = credentials.userName + prefUserName.onPreferenceChangeListener = Preference.OnPreferenceChangeListener { _, newValue -> + model.updateCredentials(Credentials(newValue as String, credentials.password)) + false + } + + prefPassword.isVisible = true + prefPassword.onPreferenceChangeListener = Preference.OnPreferenceChangeListener { _, newValue -> + model.updateCredentials(Credentials(credentials.userName, newValue as String)) + false + } + + prefCertAlias.isVisible = false + } + Credentials.Type.ClientCertificate -> { + prefCredentials.isVisible = false + prefUserName.isVisible = false + prefPassword.isVisible = false + + prefCertAlias.isVisible = true + prefCertAlias.summary = credentials.certificateAlias + prefCertAlias.setOnPreferenceClickListener { + KeyChain.choosePrivateKeyAlias(requireActivity(), { alias -> + model.updateCredentials(Credentials(certificateAlias = alias)) + }, null, null, null, -1, credentials.certificateAlias) + true + } + } + Credentials.Type.OAuth -> { + prefCredentials.isVisible = true + prefCredentials.setOnPreferenceClickListener{launchSetup()} + prefUserName.isVisible = false + prefPassword.isVisible = false + prefCertAlias.isVisible = false + } + } + }) + + // preference group: CalDAV + findPreference(getString(R.string.settings_sync_time_range_past_key))!!.let { + model.timeRangePastDays.observe(this, Observer { pastDays -> + if (model.syncIntervalCalendars.value != null) { + it.isVisible = true + if (pastDays != null) { + it.text = pastDays.toString() + it.summary = resources.getQuantityString(R.plurals.settings_sync_time_range_past_days, pastDays, pastDays) + } else { + it.text = null + it.setSummary(R.string.settings_sync_time_range_past_none) + } + it.onPreferenceChangeListener = Preference.OnPreferenceChangeListener { _, newValue -> + val days = try { + (newValue as String).toInt() + } catch(e: NumberFormatException) { + -1 + } + model.updateTimeRangePastDays(if (days < 0) null else days) + false + } + } else + it.isVisible = false + }) + } + + findPreference(getString(R.string.settings_key_default_alarm))!!.let { + model.defaultAlarmMinBefore.observe(this, Observer { minBefore -> + if (minBefore != null) { + it.text = minBefore.toString() + it.summary = resources.getQuantityString(R.plurals.settings_default_alarm_on, minBefore, minBefore) + } else { + it.text = null + it.summary = getString(R.string.settings_default_alarm_off) + } + it.onPreferenceChangeListener = Preference.OnPreferenceChangeListener { _, newValue -> + val minBefore = try { + (newValue as String).toInt() + } catch (e: java.lang.NumberFormatException) { + null + } + model.updateDefaultAlarm(minBefore) + false + } + }) + } + + findPreference(getString(R.string.settings_manage_calendar_colors_key))!!.let { + model.manageCalendarColors.observe(this, Observer { manageCalendarColors -> + if (model.syncIntervalCalendars.value != null || model.syncIntervalTasks.value != null) { + it.isVisible = true + it.isEnabled = !settings.has(AccountSettings.KEY_MANAGE_CALENDAR_COLORS) + it.isChecked = manageCalendarColors + it.onPreferenceChangeListener = Preference.OnPreferenceChangeListener { _, newValue -> + model.updateManageCalendarColors(newValue as Boolean) + false + } + } else + it.isVisible = false + }) + } + + findPreference(getString(R.string.settings_event_colors_key))!!.let { + model.eventColors.observe(this, Observer { eventColors -> + if (model.syncIntervalCalendars.value != null) { + it.isVisible = true + it.isEnabled = !settings.has(AccountSettings.KEY_EVENT_COLORS) + it.isChecked = eventColors + it.onPreferenceChangeListener = Preference.OnPreferenceChangeListener { _, newValue -> + model.updateEventColors(newValue as Boolean) + false + } + } else + it.isVisible = false + }) + } + + // preference group: CardDAV + findPreference(getString(R.string.settings_contact_group_method_key))!!.let { + model.contactGroupMethod.observe(this, Observer { groupMethod -> + if (model.syncIntervalContacts.value != null) { + it.isVisible = true + it.value = groupMethod.name + it.summary = it.entry + if (settings.has(AccountSettings.KEY_CONTACT_GROUP_METHOD)) + it.isEnabled = false + else { + it.isEnabled = true + it.onPreferenceChangeListener = Preference.OnPreferenceChangeListener { _, groupMethod -> + model.updateContactGroupMethod(GroupMethod.valueOf(groupMethod as String)) + false + } + } + } else + it.isVisible = false + }) + } + } + + override fun onRequestPermissionsResult(requestCode: Int, permissions: Array, grantResults: IntArray) { + super.onRequestPermissionsResult(requestCode, permissions, grantResults) + + if (grantResults.any { it == PackageManager.PERMISSION_DENIED }) { + // location permission denied, reset SSID restriction + model.updateSyncWifiOnlySSIDs(null) + + MaterialAlertDialogBuilder(requireActivity()) + .setIcon(R.drawable.ic_network_wifi_dark) + .setTitle(R.string.settings_sync_wifi_only_ssids) + .setMessage(R.string.settings_sync_wifi_only_ssids_location_permission) + .setPositiveButton(android.R.string.ok) { _, _ -> } + .setNeutralButton(R.string.settings_more_info_faq) { _, _ -> + val faqUrl = App.homepageUrl(requireActivity()).buildUpon() + .appendPath("faq").appendPath("wifi-ssid-restriction-location-permission") + .build() + val intent = Intent(Intent.ACTION_VIEW, faqUrl) + startActivity(Intent.createChooser(intent, null)) + } + .show() + } + } + + } + + + class Model(app: Application): AndroidViewModel(app), SyncStatusObserver, Settings.OnChangeListener { + + private var account: Account? = null + private var accountSettings: AccountSettings? = null + + private val settings = Settings.getInstance(app) + private var statusChangeListener: Any? = null + + // settings + val syncIntervalContacts = MutableLiveData() + val syncIntervalCalendars = MutableLiveData() + val syncIntervalTasks = MutableLiveData() + val syncWifiOnly = MutableLiveData() + val syncWifiOnlySSIDs = MutableLiveData>() + + val credentials = MutableLiveData() + + val timeRangePastDays = MutableLiveData() + val defaultAlarmMinBefore = MutableLiveData() + val manageCalendarColors = MutableLiveData() + val eventColors = MutableLiveData() + + val contactGroupMethod = MutableLiveData() + + // derived values + val askForPermissions = object: MediatorLiveData>() { + init { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O_MR1) { + addSource(syncWifiOnly) { calculate() } + addSource(syncWifiOnlySSIDs) { calculate() } + } + } + private fun calculate() { + val wifiOnly = syncWifiOnly.value ?: return + val wifiOnlySSIDs = syncWifiOnlySSIDs.value ?: return + + val permissions = mutableListOf() + if (wifiOnly && wifiOnlySSIDs.isNotEmpty()) { + // Android 8.1+: getting the WiFi name requires location permission (and active location services) + permissions += Manifest.permission.ACCESS_FINE_LOCATION + + // Android 10+: getting the Wifi name in the background (= while syncing) requires extra permission + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) + permissions += Manifest.permission.ACCESS_BACKGROUND_LOCATION + } + + if (permissions != value) + postValue(permissions) + } + } + + + fun initialize(account: Account) { + if (this.account != null) + // already initialized + return + + this.account = account + accountSettings = AccountSettings(getApplication(), account) + + settings.addOnChangeListener(this) + statusChangeListener = ContentResolver.addStatusChangeListener(ContentResolver.SYNC_OBSERVER_TYPE_SETTINGS, this) + + reload() + } + + override fun onCleared() { + super.onCleared() + + statusChangeListener?.let { + ContentResolver.removeStatusChangeListener(it) + } + settings.removeOnChangeListener(this) + } + + override fun onStatusChanged(which: Int) { + reload() + } + + override fun onSettingsChanged() { + reload() + } + + private fun reload() { + val accountSettings = accountSettings ?: return + val context = getApplication() + + syncIntervalContacts.postValue(accountSettings.getSyncInterval(context.getString(R.string.address_books_authority))) + syncIntervalCalendars.postValue(accountSettings.getSyncInterval(CalendarContract.AUTHORITY)) + syncIntervalTasks.postValue(accountSettings.getSyncInterval(TaskProvider.ProviderName.OpenTasks.authority)) + syncWifiOnly.postValue(accountSettings.getSyncWifiOnly()) + syncWifiOnlySSIDs.postValue(accountSettings.getSyncWifiOnlySSIDs()) + + credentials.postValue(accountSettings.credentials()) + + timeRangePastDays.postValue(accountSettings.getTimeRangePastDays()) + defaultAlarmMinBefore.postValue(accountSettings.getDefaultAlarm()) + manageCalendarColors.postValue(accountSettings.getManageCalendarColors()) + eventColors.postValue(accountSettings.getEventColors()) + + contactGroupMethod.postValue(accountSettings.getGroupMethod()) + } + + + fun updateSyncInterval(authority: String, syncInterval: Long) { + accountSettings?.setSyncInterval(authority, syncInterval) + reload() + } + + fun updateSyncWifiOnly(wifiOnly: Boolean) { + accountSettings?.setSyncWiFiOnly(wifiOnly) + reload() + } + + fun updateSyncWifiOnlySSIDs(ssids: List?) { + accountSettings?.setSyncWifiOnlySSIDs(ssids) + reload() + } + + fun updateCredentials(credentials: Credentials) { + accountSettings?.credentials(credentials) + reload() + } + + fun updateTimeRangePastDays(days: Int?) { + accountSettings?.setTimeRangePastDays(days) + reload() + + resyncCalendars(fullResync = false, tasks = false) + } + + fun updateDefaultAlarm(minBefore: Int?) { + accountSettings?.setDefaultAlarm(minBefore) + reload() + + resyncCalendars(fullResync = true, tasks = false) + } + + fun updateManageCalendarColors(manage: Boolean) { + accountSettings?.setManageCalendarColors(manage) + reload() + + resyncCalendars(fullResync = false, tasks = true) + } + + fun updateEventColors(manageColors: Boolean) { + accountSettings?.setEventColors(manageColors) + reload() + + resyncCalendars(fullResync = true, tasks = false) + } + + fun updateContactGroupMethod(groupMethod: GroupMethod) { + accountSettings?.setGroupMethod(groupMethod) + reload() + + resync(getApplication().getString(R.string.address_books_authority), fullResync = true) + } + + /** + * Initiates calendar re-synchronization. + * + * @param fullResync whether sync shall download all events again + * (_true_: sets [SyncAdapterService.SYNC_EXTRAS_FULL_RESYNC], + * _false_: sets [ContentResolver.SYNC_EXTRAS_MANUAL]) + * @param tasks whether tasks shall be synchronized, too (false: only events, true: events and tasks) + */ + private fun resyncCalendars(fullResync: Boolean, tasks: Boolean) { + resync(CalendarContract.AUTHORITY, fullResync) + if (tasks) + resync(TaskProvider.ProviderName.OpenTasks.authority, fullResync) + } + + private fun resync(authority: String, fullResync: Boolean) { + val args = Bundle(1) + args.putBoolean(if (fullResync) + SyncAdapterService.SYNC_EXTRAS_FULL_RESYNC + else + SyncAdapterService.SYNC_EXTRAS_RESYNC, true) + + ContentResolver.requestSync(account, authority, args) + } + + } + +} diff --git a/app/src/main/java/foundation/e/accountmanager/ui/account/WebcalFragment.kt b/app/src/main/java/foundation/e/accountmanager/ui/account/WebcalFragment.kt new file mode 100644 index 0000000000000000000000000000000000000000..0a9c439d36c3f57e5836c9fddcfbea5b53645e4a --- /dev/null +++ b/app/src/main/java/foundation/e/accountmanager/ui/account/WebcalFragment.kt @@ -0,0 +1,301 @@ +package foundation.e.accountmanager.ui.account + +import android.Manifest +import android.app.Activity +import android.app.Application +import android.content.ContentProviderClient +import android.content.Context +import android.content.Intent +import android.content.pm.PackageManager +import android.database.ContentObserver +import android.net.Uri +import android.os.Bundle +import android.os.Handler +import android.os.HandlerThread +import android.provider.CalendarContract +import android.provider.CalendarContract.Calendars +import android.view.Menu +import android.view.MenuInflater +import android.view.View +import android.view.ViewGroup +import androidx.annotation.WorkerThread +import androidx.core.content.ContextCompat +import androidx.lifecycle.* +import androidx.room.Transaction +import foundation.e.dav4jvm.UrlUtils +import foundation.e.accountmanager.Constants +import foundation.e.accountmanager.R +import foundation.e.accountmanager.closeCompat +import foundation.e.accountmanager.log.Logger +import foundation.e.accountmanager.model.AppDatabase +import foundation.e.accountmanager.model.Collection +import com.google.android.material.snackbar.Snackbar +import kotlinx.android.synthetic.main.account_caldav_item.view.* +import okhttp3.HttpUrl +import java.util.logging.Level + +class WebcalFragment: CollectionsFragment() { + + override val noCollectionsStringId = R.string.account_no_webcals + + private lateinit var webcalModel: WebcalModel + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + webcalModel = ViewModelProviders.of(this).get(WebcalModel::class.java) + webcalModel.calendarPermission.observe(this, Observer { granted -> + if (!granted) + requestPermissions(arrayOf(Manifest.permission.READ_CALENDAR), 0) + }) + webcalModel.subscribedUrls.observe(this, Observer { urls -> + Logger.log.log(Level.FINE, "Got Android calendar list", urls.keys) + }) + + webcalModel.initialize(arguments?.getLong(EXTRA_SERVICE_ID) ?: throw IllegalArgumentException("EXTRA_SERVICE_ID required")) + } + + override fun onRequestPermissionsResult(requestCode: Int, permissions: Array, grantResults: IntArray) = + webcalModel.calendarPermission.check() + + override fun onCreateOptionsMenu(menu: Menu, inflater: MenuInflater) = + inflater.inflate(R.menu.caldav_actions, menu) + + override fun onPrepareOptionsMenu(menu: Menu) { + super.onPrepareOptionsMenu(menu) + menu.findItem(R.id.create).isVisible = false + } + + + override fun createAdapter(): CollectionAdapter = WebcalAdapter(accountModel, webcalModel) + + + class CalendarViewHolder( + private val parent: ViewGroup, + accountModel: AccountActivity.Model, + private val webcalModel: WebcalModel + ): CollectionViewHolder(parent, R.layout.account_caldav_item, accountModel) { + + override fun bindTo(item: Collection) { + val v = itemView + v.color.setBackgroundColor(item.color ?: Constants.DAVDROID_GREEN_RGBA) + + v.sync.isChecked = item.sync + v.title.text = item.title() + + if (item.description.isNullOrBlank()) + v.description.visibility = View.GONE + else { + v.description.text = item.description + v.description.visibility = View.VISIBLE + } + + v.read_only.visibility = View.VISIBLE + v.events.visibility = if (item.supportsVEVENT == true) View.VISIBLE else View.GONE + v.tasks.visibility = if (item.supportsVTODO == true) View.VISIBLE else View.GONE + + itemView.setOnClickListener { + if (item.sync) + webcalModel.unsubscribe(item) + else + subscribe(item) + } + v.action_overflow.setOnClickListener(CollectionPopupListener(accountModel, item)) + } + + private fun subscribe(item: Collection) { + var uri = Uri.parse(item.source.toString()) + when { + uri.scheme.equals("http", true) -> uri = uri.buildUpon().scheme("webcal").build() + uri.scheme.equals("https", true) -> uri = uri.buildUpon().scheme("webcals").build() + } + + val intent = Intent(Intent.ACTION_VIEW, uri) + item.displayName?.let { intent.putExtra("title", it) } + item.color?.let { intent.putExtra("color", it) } + + Logger.log.info("Intent: ${intent.extras}") + + val activity = parent.context as Activity + if (activity.packageManager.resolveActivity(intent, 0) != null) + activity.startActivity(intent) + else { + val snack = Snackbar.make(parent, R.string.account_no_webcal_handler_found, Snackbar.LENGTH_LONG) + + val installIntent = Intent(Intent.ACTION_VIEW, Uri.parse("market://details?id=foundation.e.icsdroid")) + if (activity.packageManager.resolveActivity(installIntent, 0) != null) + snack.setAction(R.string.account_install_icsx5) { + activity.startActivityForResult(installIntent, 0) + } + + snack.show() + } + + } + + } + + class WebcalAdapter( + accountModel: AccountActivity.Model, + private val webcalModel: WebcalModel + ): CollectionAdapter(accountModel) { + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int) = + CalendarViewHolder(parent, accountModel, webcalModel) + + } + + + class WebcalModel(application: Application): AndroidViewModel(application) { + + private var initialized = false + private var serviceId: Long = 0 + + private val workerThread = HandlerThread(javaClass.simpleName) + init { workerThread.start() } + val workerHandler = Handler(workerThread.looper) + + private val db = AppDatabase.getInstance(application) + private val resolver = application.contentResolver + + val calendarPermission = CalendarPermission(application) + private val calendarProvider = object: MediatorLiveData() { + var havePermission = false + + init { + addSource(calendarPermission) { granted -> + havePermission = granted + if (granted) + connect() + else + disconnect() + } + } + + override fun onActive() { + super.onActive() + connect() + } + + fun connect() { + if (havePermission && value == null) + value = resolver.acquireContentProviderClient(CalendarContract.AUTHORITY) + } + + override fun onInactive() { + super.onInactive() + disconnect() + } + + fun disconnect() { + value?.closeCompat() + value = null + } + } + val subscribedUrls = object: MediatorLiveData>() { + var provider: ContentProviderClient? = null + var observer: ContentObserver? = null + + init { + addSource(calendarProvider) { provider -> + this.provider = provider + if (provider != null) { + connect() + } else + unregisterObserver() + } + } + + override fun onActive() { + super.onActive() + connect() + } + + private fun connect() { + unregisterObserver() + provider?.let { provider -> + val newObserver = object: ContentObserver(workerHandler) { + override fun onChange(selfChange: Boolean) { + queryCalendars(provider) + } + } + getApplication().contentResolver.registerContentObserver(Calendars.CONTENT_URI, false, newObserver) + observer = newObserver + + workerHandler.post { + queryCalendars(provider) + } + } + } + + override fun onInactive() { + super.onInactive() + unregisterObserver() + } + + private fun unregisterObserver() { + observer?.let { + application.contentResolver.unregisterContentObserver(it) + observer = null + } + } + + @WorkerThread + @Transaction + private fun queryCalendars(provider: ContentProviderClient) { + // query subscribed URLs from Android calendar list + val subscriptions = mutableMapOf() + provider.query(Calendars.CONTENT_URI, arrayOf(Calendars._ID, Calendars.NAME),null, null, null)?.use { cursor -> + while (cursor.moveToNext()) + cursor.getString(1)?.let { rawName -> + HttpUrl.parse(rawName)?.let { url -> + subscriptions[url] = cursor.getLong(0) + } + } + } + + // update "sync" field in database accordingly (will update UI) + db.collectionDao().getByServiceAndType(serviceId, Collection.TYPE_WEBCAL).forEach { webcal -> + val newSync = subscriptions.keys + .any { webcal.source?.let { source -> UrlUtils.equals(source, it) } ?: false } + if (newSync != webcal.sync) + db.collectionDao().update(webcal.copy(sync = newSync)) + } + + postValue(subscriptions) + } + } + + + fun initialize(dbServiceId: Long) { + if (initialized) + return + initialized = true + + serviceId = dbServiceId + calendarPermission.check() + } + + fun unsubscribe(webcal: Collection) { + workerHandler.post { + subscribedUrls.value?.get(webcal.source)?.let { id -> + // delete subscription from Android calendar list + calendarProvider.value?.delete(Calendars.CONTENT_URI, + "${Calendars._ID}=?", arrayOf(id.toString())) + } + } + } + + } + + class CalendarPermission(val context: Context): LiveData() { + init { + check() + } + + fun check() { + value = ContextCompat.checkSelfPermission(context, Manifest.permission.READ_CALENDAR) == PackageManager.PERMISSION_GRANTED + } + } + +} \ No newline at end of file diff --git a/app/src/main/java/foundation/e/accountmanager/ui/setup/AccountDetailsFragment.kt b/app/src/main/java/foundation/e/accountmanager/ui/setup/AccountDetailsFragment.kt new file mode 100644 index 0000000000000000000000000000000000000000..9c65f7817cebaf934fc7d043527bb674e7324656 --- /dev/null +++ b/app/src/main/java/foundation/e/accountmanager/ui/setup/AccountDetailsFragment.kt @@ -0,0 +1,317 @@ +/* + * 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 foundation.e.accountmanager.ui.setup + +import android.accounts.Account +import android.accounts.AccountAuthenticatorResponse +import android.accounts.AccountManager +import android.app.Activity +import android.app.Application +import android.content.ContentResolver +import android.content.Intent +import android.os.Bundle +import android.provider.CalendarContract +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.Toast +import androidx.fragment.app.Fragment +import androidx.lifecycle.* +import foundation.e.accountmanager.Constants +import foundation.e.accountmanager.DavService +import foundation.e.accountmanager.InvalidAccountException +import foundation.e.accountmanager.R +import foundation.e.accountmanager.databinding.LoginAccountDetailsBinding +import foundation.e.accountmanager.log.Logger +import foundation.e.accountmanager.model.AppDatabase +import foundation.e.accountmanager.model.Credentials +import foundation.e.accountmanager.model.HomeSet +import foundation.e.accountmanager.model.Service +import foundation.e.accountmanager.resource.LocalTaskList +import foundation.e.accountmanager.settings.AccountSettings +import foundation.e.accountmanager.settings.Settings +import foundation.e.ical4android.TaskProvider +import foundation.e.vcard4android.GroupMethod +import com.google.android.material.snackbar.Snackbar +import java.util.logging.Level +import kotlin.concurrent.thread + +class AccountDetailsFragment: Fragment() { + + private lateinit var loginModel: LoginModel + private lateinit var model: AccountDetailsModel + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + loginModel = ViewModelProviders.of(requireActivity()).get(LoginModel::class.java) + model = ViewModelProviders.of(this).get(AccountDetailsModel::class.java) + } + + override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View { + val v = LoginAccountDetailsBinding.inflate(inflater, container, false) + v.lifecycleOwner = viewLifecycleOwner + v.details = model + + val config = loginModel.configuration ?: throw IllegalStateException() + + model.name.value = loginModel.credentials?.userName ?: + config.calDAV?.email ?: + loginModel.credentials?.certificateAlias + + // CardDAV-specific + val settings = Settings.getInstance(requireActivity()) + v.carddav.visibility = if (config.cardDAV != null) View.VISIBLE else View.GONE + if (settings.has(AccountSettings.KEY_CONTACT_GROUP_METHOD)) + v.contactGroupMethod.isEnabled = false + + v.createAccount.setOnClickListener { + val name = model.name.value + if (name.isNullOrBlank()) + model.nameError.value = getString(R.string.login_account_name_required) + else { + // check whether account name already exists + val am = AccountManager.get(requireActivity()) + if (am.getAccountsByType(getString(R.string.account_type)).any { it.name == name }) { + model.nameError.value = getString(R.string.login_account_name_already_taken) + return@setOnClickListener + } + + val idx = v.contactGroupMethod.selectedItemPosition + val groupMethodName = resources.getStringArray(R.array.settings_contact_group_method_values)[idx] + + v.createAccountProgress.visibility = View.VISIBLE + v.createAccount.visibility = View.GONE + + model.createAccount( + activity!!, + name, + loginModel.credentials!!, + config, + GroupMethod.valueOf(groupMethodName) + ).observe(this, Observer { success -> + if (success) + requireActivity().finish() + else { + Snackbar.make(requireActivity().findViewById(android.R.id.content), R.string.login_account_not_created, Snackbar.LENGTH_LONG).show() + + v.createAccountProgress.visibility = View.GONE + v.createAccount.visibility = View.VISIBLE + } + }) + } + } + + val forcedGroupMethod = settings.getString(AccountSettings.KEY_CONTACT_GROUP_METHOD)?.let { GroupMethod.valueOf(it) } + if (forcedGroupMethod != null) { + v.contactGroupMethod.isEnabled = false + for ((i, method) in resources.getStringArray(R.array.settings_contact_group_method_values).withIndex()) { + if (method == forcedGroupMethod.name) { + v.contactGroupMethod.setSelection(i) + break + } + } + } else + v.contactGroupMethod.isEnabled = true + + if (activity!!.intent.getStringExtra(LoginActivity.SETUP_ACCOUNT_PROVIDER_TYPE) == LoginActivity.ACCOUNT_PROVIDER_EELO || + activity!!.intent.getStringExtra(LoginActivity.SETUP_ACCOUNT_PROVIDER_TYPE) == LoginActivity.ACCOUNT_PROVIDER_GOOGLE) { + val name = model.name.value + if (name.isNullOrBlank()) + model.nameError.value = getString(R.string.login_account_name_required) + else { + val idx = v.contactGroupMethod.selectedItemPosition + val groupMethodName = resources.getStringArray(R.array.settings_contact_group_method_values)[idx] + + model.createAccount( + activity!!, + name, + loginModel.credentials!!, + config, + GroupMethod.valueOf(groupMethodName) + ).observe(this, Observer { success -> + if (success) { + Toast.makeText(context, "Added account successfully", Toast.LENGTH_LONG).show() + requireActivity().setResult(Activity.RESULT_OK) + requireActivity().finish() + if (activity!!.intent.hasExtra(AccountManager + .KEY_ACCOUNT_AUTHENTICATOR_RESPONSE) && activity!!.intent + .getParcelableExtra(AccountManager + .KEY_ACCOUNT_AUTHENTICATOR_RESPONSE) != null) { + activity!!.intent + .getParcelableExtra(AccountManager + .KEY_ACCOUNT_AUTHENTICATOR_RESPONSE).onResult(null) + } + + if (activity!!.intent.getStringExtra(LoginActivity.SETUP_ACCOUNT_PROVIDER_TYPE) == LoginActivity.ACCOUNT_PROVIDER_EELO) { + val intent = Intent("drive.services.InitializerService") + intent.setPackage(getString(R.string.e_drive_package_name)) + intent.putExtra(AccountManager.KEY_ACCOUNT_NAME, name) + intent.putExtra(AccountManager.KEY_ACCOUNT_TYPE, getString(R.string.eelo_account_type)) + activity!!.startService(intent) + } + } + }) + } + } + + return v.root + } + + + class AccountDetailsModel( + application: Application + ): AndroidViewModel(application) { + + val name = MutableLiveData() + val nameError = MutableLiveData() + + fun createAccount(activity: Activity, name: String, credentials: Credentials, config: DavResourceFinder.Configuration, groupMethod: GroupMethod): LiveData { + val result = MutableLiveData() + val context = getApplication() + thread { + var accountType = context!!.getString(R.string.account_type) + var addressBookAccountType = context!!.getString(R.string.account_type_address_book) + + var baseURL : String? = null + if (config.calDAV != null) { + baseURL = config.calDAV.principal.toString() + } + + when (activity!!.intent.getStringExtra(LoginActivity.SETUP_ACCOUNT_PROVIDER_TYPE)) { + LoginActivity.ACCOUNT_PROVIDER_EELO -> { + accountType = context!!.getString(R.string.eelo_account_type) + addressBookAccountType = context!!.getString(R.string.account_type_eelo_address_book) + baseURL = credentials.serverUri.toString() + } + LoginActivity.ACCOUNT_PROVIDER_GOOGLE -> { + accountType = context!!.getString(R.string.google_account_type) + addressBookAccountType = context!!.getString(R.string.account_type_google_address_book) + baseURL = null + } + } + + val account = Account(credentials.userName, accountType) + + // create Android account + val userData = AccountSettings.initialUserData(credentials, baseURL) + Logger.log.log(Level.INFO, "Creating Android account with initial config", arrayOf(account, userData)) + + val accountManager = AccountManager.get(context) + + if (!accountManager.addAccountExplicitly(account, credentials.password, userData)) { + if (accountType == context.getString(R.string.google_account_type)) { + for (googleAccount in accountManager.getAccountsByType(context.getString( + R.string.google_account_type))) { + if (userData.get(AccountSettings.KEY_EMAIL_ADDRESS) == accountManager + .getUserData(account, AccountSettings.KEY_EMAIL_ADDRESS)) { + accountManager.setUserData(googleAccount, AccountSettings.KEY_AUTH_STATE, + userData.getString(AccountSettings.KEY_AUTH_STATE)) + } + } + } + else { + return@thread + } + } + + if (!credentials.authState?.accessToken.isNullOrEmpty()) { + accountManager.setAuthToken(account, Constants.AUTH_TOKEN_TYPE, credentials.authState!!.accessToken) + } + + if (!credentials.password.isNullOrEmpty()) { + accountManager.setPassword(account, credentials.password) + } + + ContentResolver.setSyncAutomatically(account, context.getString(R.string.notes_authority), true) + ContentResolver.setSyncAutomatically(account, context.getString(R.string.email_authority), true) + ContentResolver.setSyncAutomatically(account, context.getString(R.string.media_authority), true) + ContentResolver.setSyncAutomatically(account, context.getString(R.string.app_data_authority), true) + + // add entries for account to service DB + Logger.log.log(Level.INFO, "Writing account configuration to database", config) + val db = AppDatabase.getInstance(context) + try { + val accountSettings = AccountSettings(context, account) + + val refreshIntent = Intent(context, DavService::class.java) + refreshIntent.action = DavService.ACTION_REFRESH_COLLECTIONS + + if (config.cardDAV != null) { + // insert CardDAV service + + val id = insertService(db, credentials.userName!!, credentials.authState?.jsonSerializeString(), accountType, addressBookAccountType, Service.TYPE_CARDDAV, config.cardDAV) + + // initial CardDAV account settings + accountSettings.setGroupMethod(groupMethod) + + // start CardDAV service detection (refresh collections) + refreshIntent.putExtra(DavService.EXTRA_DAV_SERVICE_ID, id) + context.startService(refreshIntent) + + // contact sync is automatically enabled by isAlwaysSyncable="true" in res/xml/sync_address_books.xml + accountSettings.setSyncInterval(context.getString(R.string.address_books_authority), Constants.DEFAULT_CALENDAR_SYNC_INTERVAL) + } else + ContentResolver.setIsSyncable(account, context.getString(R.string.address_books_authority), 0) + + if (config.calDAV != null) { + // insert CalDAV service + val id = insertService(db, credentials.userName!!, credentials.authState?.jsonSerializeString(), accountType, addressBookAccountType, Service.TYPE_CALDAV, config.calDAV) + + // start CalDAV service detection (refresh collections) + refreshIntent.putExtra(DavService.EXTRA_DAV_SERVICE_ID, id) + context.startService(refreshIntent) + + // calendar sync is automatically enabled by isAlwaysSyncable="true" in res/xml/sync_calendars.xml + accountSettings.setSyncInterval(CalendarContract.AUTHORITY, Constants.DEFAULT_CALENDAR_SYNC_INTERVAL) + + // enable task sync if OpenTasks is installed + // further changes will be handled by PackageChangedReceiver + if (LocalTaskList.tasksProviderAvailable(context)) { + ContentResolver.setIsSyncable(account, TaskProvider.ProviderName.OpenTasks.authority, 1) + accountSettings.setSyncInterval(TaskProvider.ProviderName.OpenTasks.authority, Constants.DEFAULT_CALENDAR_SYNC_INTERVAL) + } + } else { + ContentResolver.setIsSyncable(account, CalendarContract.AUTHORITY, 0) + ContentResolver.setIsSyncable(account, TaskProvider.ProviderName.OpenTasks.authority, 0) + } + + } catch(e: InvalidAccountException) { + Logger.log.log(Level.SEVERE, "Couldn't access account settings", e) + result.postValue(false) + return@thread + } + result.postValue(true) + } + return result + } + + private fun insertService(db: AppDatabase, accountName: String, authState: String?, accountType: String, addressBookAccountType: String, type: String, info: DavResourceFinder.Configuration.ServiceInfo): Long { + // insert service + val service = Service(0, accountName, authState, accountType, addressBookAccountType, type, info.principal) + val serviceId = db.serviceDao().insertOrReplace(service) + + // insert home sets + val homeSetDao = db.homeSetDao() + for (homeSet in info.homeSets) { + homeSetDao.insertOrReplace(HomeSet(0, serviceId, homeSet)) + } + + // insert collections + val collectionDao = db.collectionDao() + for (collection in info.collections.values) { + collection.serviceId = serviceId + collectionDao.insertOrReplace(collection) + } + + return serviceId + } + + } + +} diff --git a/app/src/main/java/at/bitfire/davdroid/ui/setup/DavResourceFinder.kt b/app/src/main/java/foundation/e/accountmanager/ui/setup/DavResourceFinder.kt similarity index 78% rename from app/src/main/java/at/bitfire/davdroid/ui/setup/DavResourceFinder.kt rename to app/src/main/java/foundation/e/accountmanager/ui/setup/DavResourceFinder.kt index 7491854bf5ebf916da4daa5b2d88e83ac6b5ddf4..b7c5d5320a70fb1b208529330acb314e0bd5fc28 100644 --- a/app/src/main/java/at/bitfire/davdroid/ui/setup/DavResourceFinder.kt +++ b/app/src/main/java/foundation/e/accountmanager/ui/setup/DavResourceFinder.kt @@ -5,26 +5,19 @@ * which accompanies this distribution, and is available at * http://www.gnu.org/licenses/gpl.html */ -package at.bitfire.davdroid.ui.setup +package foundation.e.accountmanager.ui.setup -import android.annotation.SuppressLint -import android.annotation.TargetApi import android.content.Context -import android.os.Build -import android.os.Parcel -import android.os.Parcelable -import at.bitfire.dav4android.DavResource -import at.bitfire.dav4android.Response -import at.bitfire.dav4android.UrlUtils -import at.bitfire.dav4android.exception.DavException -import at.bitfire.dav4android.exception.HttpException -import at.bitfire.dav4android.property.* -import 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 foundation.e.dav4jvm.DavResource +import foundation.e.dav4jvm.Response +import foundation.e.dav4jvm.UrlUtils +import foundation.e.dav4jvm.exception.DavException +import foundation.e.dav4jvm.exception.HttpException +import foundation.e.dav4jvm.property.* +import foundation.e.accountmanager.DavUtils +import foundation.e.accountmanager.HttpClient +import foundation.e.accountmanager.log.StringHandler +import foundation.e.accountmanager.model.Collection import okhttp3.HttpUrl import org.apache.commons.lang3.builder.ReflectionToStringBuilder import org.xbill.DNS.Lookup @@ -40,7 +33,7 @@ import java.util.logging.Logger class DavResourceFinder( val context: Context, - private val loginInfo: LoginInfo + private val loginModel: LoginModel ): AutoCloseable { enum class Service(val wellKnownName: String) { @@ -50,21 +43,19 @@ class DavResourceFinder( override fun toString() = wellKnownName } - val log = Logger.getLogger("davdroid.DavResourceFinder")!! + val log = Logger.getLogger("davdroid.DavResourceFinder") private val logBuffer = StringHandler() init { log.level = Level.FINEST log.addHandler(logBuffer) } - private val settings = Settings.getInstance(context) - private val httpClient: HttpClient = HttpClient.Builder(context, settings, logger = log) - .addAuthentication(null, loginInfo.credentials) + private val httpClient: HttpClient = HttpClient.Builder(context, logger = log) + .addAuthentication(null, loginModel.credentials!!) .setForeground(true) .build() override fun close() { - settings?.close() httpClient.close() } @@ -98,7 +89,6 @@ class DavResourceFinder( } return Configuration( - loginInfo.credentials, cardDavConfig, calDavConfig, logBuffer.toString() ) @@ -106,7 +96,7 @@ class DavResourceFinder( private fun findInitialConfiguration(service: Service): Configuration.ServiceInfo? { // user-given base URI (either mailto: URI or http(s):// URL) - val baseURI = loginInfo.uri + val baseURI = loginModel.baseURI!! // domain for service discovery var discoveryFQDN: String? = null @@ -155,12 +145,13 @@ class DavResourceFinder( if (config.principal != null && service == Service.CALDAV) // query email address (CalDAV scheduling: calendar-user-address-set) try { - DavResource(httpClient.okHttpClient, config.principal!!, log).propfind(0, CalendarUserAddressSet.NAME) { response, _ -> + DavResource(httpClient.okHttpClient, config.principal!!, null, log).propfind(0, CalendarUserAddressSet.NAME) { response, _ -> response[CalendarUserAddressSet::class.java]?.let { addressSet -> for (href in addressSet.hrefs) try { val uri = URI(href) if (uri.scheme.equals("mailto", true)) + log.info("myenail: ${uri.schemeSpecificPart}") config.email = uri.schemeSpecificPart } catch(e: URISyntaxException) { log.log(Level.WARNING, "Couldn't parse user address", e) @@ -183,7 +174,7 @@ class DavResourceFinder( private fun checkUserGivenURL(baseURL: HttpUrl, service: Service, config: Configuration.ServiceInfo) { log.info("Checking user-given URL: $baseURL") - val davBase = DavResource(httpClient.okHttpClient, baseURL, log) + val davBase = DavResource(httpClient.okHttpClient, baseURL, loginModel.credentials?.authState?.accessToken, log) try { when (service) { Service.CARDDAV -> { @@ -196,7 +187,7 @@ class DavResourceFinder( } } Service.CALDAV -> { - davBase.propfind(0, + davBase.propfind(1, ResourceType.NAME, DisplayName.NAME, CalendarColor.NAME, CalendarDescription.NAME, CalendarTimezone.NAME, CurrentUserPrivilegeSet.NAME, SupportedCalendarComponentSet.NAME, CalendarHomeSet.NAME, CurrentUserPrincipal.NAME @@ -230,7 +221,7 @@ class DavResourceFinder( // Is it an address book and/or principal? dav[ResourceType::class.java]?.let { if (it.types.contains(ResourceType.ADDRESSBOOK)) { - val info = CollectionInfo(dav) + val info = Collection.fromDavResponse(dav)!! log.info("Found address book at ${info.url}") config.collections[info.url] = info } @@ -264,7 +255,7 @@ class DavResourceFinder( * @param dav response whose properties are evaluated * @param config structure where the results are stored into */ - fun scanCalDavResponse(dav: Response, config: Configuration.ServiceInfo) { + private fun scanCalDavResponse(dav: Response, config: Configuration.ServiceInfo) { var principal: HttpUrl? = null // check for current-user-principal @@ -275,7 +266,7 @@ class DavResourceFinder( // Is it a calendar book and/or principal? dav[ResourceType::class.java]?.let { if (it.types.contains(ResourceType.CALENDAR)) { - val info = CollectionInfo(dav) + val info = Collection.fromDavResponse(dav)!! log.info("Found calendar at ${info.url}") config.collections[info.url] = info } @@ -306,7 +297,7 @@ class DavResourceFinder( fun providesService(url: HttpUrl, service: Service): Boolean { var provided = false try { - DavResource(httpClient.okHttpClient, url, log).options { capabilities, _ -> + DavResource(httpClient.okHttpClient, url, loginModel.credentials?.authState?.accessToken, log).options { capabilities, _ -> if ((service == Service.CARDDAV && capabilities.contains("addressbook")) || (service == Service.CALDAV && capabilities.contains("calendar-access"))) provided = true @@ -392,7 +383,7 @@ class DavResourceFinder( @Throws(IOException::class, HttpException::class, DavException::class) fun getCurrentUserPrincipal(url: HttpUrl, service: Service?): HttpUrl? { var principal: HttpUrl? = null - DavResource(httpClient.okHttpClient, url, log).propfind(0, CurrentUserPrincipal.NAME) { response, _ -> + DavResource(httpClient.okHttpClient, url, loginModel.credentials?.authState?.accessToken, log).propfind(0, CurrentUserPrincipal.NAME) { response, _ -> response[CurrentUserPrincipal::class.java]?.href?.let { href -> response.requestedUrl.resolve(href)?.let { log.info("Found current-user-principal: $it") @@ -421,18 +412,16 @@ class DavResourceFinder( // data classes class Configuration( - val credentials: Credentials, - val cardDAV: ServiceInfo?, val calDAV: ServiceInfo?, val logs: String - ): Parcelable { + ) { data class ServiceInfo( var principal: HttpUrl? = null, val homeSets: MutableSet = HashSet(), - val collections: MutableMap = HashMap(), + val collections: MutableMap = HashMap(), var email: String? = null ) @@ -443,74 +432,6 @@ class DavResourceFinder( return builder.toString() } - - override fun describeContents() = 0 - - @SuppressLint("NewApi") - override fun writeToParcel(dest: Parcel, flags: Int) { @TargetApi(Build.VERSION_CODES.N) - 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) - } - - - companion object CREATOR : Parcelable.Creator { - - override fun createFromParcel(source: Parcel): Configuration { - fun readCollections(): MutableMap { - val size = source.readInt() - val map = HashMap(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(size) - - } - } } diff --git a/app/src/main/java/foundation/e/accountmanager/ui/setup/DefaultLoginCredentialsFragment.kt b/app/src/main/java/foundation/e/accountmanager/ui/setup/DefaultLoginCredentialsFragment.kt new file mode 100644 index 0000000000000000000000000000000000000000..53283baafd11254d288f9b5645b3a3e1e59768e7 --- /dev/null +++ b/app/src/main/java/foundation/e/accountmanager/ui/setup/DefaultLoginCredentialsFragment.kt @@ -0,0 +1,162 @@ +/* + * 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 foundation.e.accountmanager.ui.setup + +import android.content.Intent +import android.net.MailTo +import android.os.Bundle +import android.os.Handler +import android.os.Looper +import android.security.KeyChain +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.fragment.app.Fragment +import androidx.lifecycle.ViewModelProviders +import foundation.e.accountmanager.R +import foundation.e.accountmanager.databinding.LoginCredentialsFragmentBinding +import foundation.e.accountmanager.model.Credentials +import java.net.URI +import java.net.URISyntaxException + +class DefaultLoginCredentialsFragment: Fragment() { + + private lateinit var model: DefaultLoginCredentialsModel + private lateinit var loginModel: LoginModel + + override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View { + model = ViewModelProviders.of(this).get(DefaultLoginCredentialsModel::class.java) + loginModel = ViewModelProviders.of(requireActivity()).get(LoginModel::class.java) + + val v = LoginCredentialsFragmentBinding.inflate(inflater, container, false) + v.lifecycleOwner = viewLifecycleOwner + v.model = model + + // initialize model on first call + if (savedInstanceState == null) + activity?.intent?.let { model.initialize(it) } + + v.selectCertificate.setOnClickListener { + KeyChain.choosePrivateKeyAlias(requireActivity(), { alias -> + Handler(Looper.getMainLooper()).post { + model.certificateAlias.value = alias + } + }, null, null, null, -1, model.certificateAlias.value) + } + + v.login.setOnClickListener { + if (validate()) + requireFragmentManager().beginTransaction() + .replace(android.R.id.content, DetectConfigurationFragment(), null) + .addToBackStack(null) + .commit() + } + + return v.root + } + + private fun validate(): Boolean { + var valid = false + + fun validateUrl() { + model.baseUrlError.value = null + try { + val originalUrl = model.baseUrl.value.orEmpty() + val uri = URI(originalUrl) + if (uri.scheme.equals("http", true) || uri.scheme.equals("https", true)) { + // http:// or https:// scheme → OK + valid = true + loginModel.baseURI = uri + } else if (uri.scheme == null) { + // empty URL scheme, assume https:// + model.baseUrl.value = "https://$originalUrl" + validateUrl() + } else + model.baseUrlError.value = getString(R.string.login_url_must_be_http_or_https) + } catch (e: Exception) { + model.baseUrlError.value = e.localizedMessage + } + } + + fun validatePassword(): String? { + model.passwordError.value = null + val password = model.password.value + if (password.isNullOrEmpty()) { + valid = false + model.passwordError.value = getString(R.string.login_password_required) + } + return password + } + + when { + model.loginWithEmailAddress.value == true -> { + // login with email address + model.usernameError.value = null + val email = model.username.value.orEmpty() + if (email.matches(Regex(".+@.+"))) { + // already looks like an email address + try { + loginModel.baseURI = URI(MailTo.MAILTO_SCHEME, email, null) + valid = true + } catch (e: URISyntaxException) { + model.usernameError.value = e.localizedMessage + } + } else { + valid = false + model.usernameError.value = getString(R.string.login_email_address_error) + } + + val password = validatePassword() + + if (valid) + loginModel.credentials = Credentials(email, password, null) + } + + model.loginWithUrlAndUsername.value == true -> { + validateUrl() + + model.usernameError.value = null + val username = model.username.value + if (username.isNullOrEmpty()) { + valid = false + model.usernameError.value = getString(R.string.login_user_name_required) + } + + val password = validatePassword() + + if (valid) + loginModel.credentials = Credentials(username, password, null) + } + + model.loginWithUrlAndCertificate.value == true -> { + validateUrl() + + model.certificateAliasError.value = null + val alias = model.certificateAlias.value + if (alias.isNullOrBlank()) { + valid = false + model.certificateAliasError.value = "" // error icon without text + } + + if (valid) + loginModel.credentials = Credentials(null, null, null, alias) + } + } + + return valid + } + + + class Factory: ILoginCredentialsFragment { + + override fun getFragment(intent: Intent) = DefaultLoginCredentialsFragment() + + } + +} diff --git a/app/src/main/java/foundation/e/accountmanager/ui/setup/DefaultLoginCredentialsModel.kt b/app/src/main/java/foundation/e/accountmanager/ui/setup/DefaultLoginCredentialsModel.kt new file mode 100644 index 0000000000000000000000000000000000000000..140966072ead54d5b827686e778d196a43f210db --- /dev/null +++ b/app/src/main/java/foundation/e/accountmanager/ui/setup/DefaultLoginCredentialsModel.kt @@ -0,0 +1,53 @@ +package foundation.e.accountmanager.ui.setup + +import android.content.Intent +import androidx.annotation.MainThread +import androidx.lifecycle.MutableLiveData +import androidx.lifecycle.ViewModel + +class DefaultLoginCredentialsModel: ViewModel() { + + private var initialized = false + + val loginWithEmailAddress = MutableLiveData() + val loginWithUrlAndUsername = MutableLiveData() + val loginWithUrlAndCertificate = MutableLiveData() + + val baseUrl = MutableLiveData() + val baseUrlError = MutableLiveData() + + /** user name or email address */ + val username = MutableLiveData() + val usernameError = MutableLiveData() + + val password = MutableLiveData() + val passwordError = MutableLiveData() + + val certificateAlias = MutableLiveData() + val certificateAliasError = MutableLiveData() + + init { + loginWithEmailAddress.value = true + } + + @MainThread + fun initialize(intent: Intent) { + if (initialized) + return + initialized = true + + // we've got initial login data + val givenUrl = intent.getStringExtra(LoginActivity.EXTRA_URL) + val givenUsername = intent.getStringExtra(LoginActivity.EXTRA_USERNAME) + val givenPassword = intent.getStringExtra(LoginActivity.EXTRA_PASSWORD) + + if (givenUrl != null) { + loginWithUrlAndUsername.value = true + baseUrl.value = givenUrl + } else + loginWithEmailAddress.value = true + username.value = givenUsername + password.value = givenPassword + } + +} diff --git a/app/src/main/java/foundation/e/accountmanager/ui/setup/DetectConfigurationFragment.kt b/app/src/main/java/foundation/e/accountmanager/ui/setup/DetectConfigurationFragment.kt new file mode 100644 index 0000000000000000000000000000000000000000..77363c2a4a7547c9e3e75da8d4ecf4b103d9ac2f --- /dev/null +++ b/app/src/main/java/foundation/e/accountmanager/ui/setup/DetectConfigurationFragment.kt @@ -0,0 +1,127 @@ +/* + * 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 foundation.e.accountmanager.ui.setup + +import android.app.Application +import android.app.Dialog +import android.content.Intent +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.fragment.app.DialogFragment +import androidx.fragment.app.Fragment +import androidx.lifecycle.* +import foundation.e.accountmanager.R +import foundation.e.accountmanager.log.Logger +import foundation.e.accountmanager.ui.DebugInfoActivity +import com.google.android.material.dialog.MaterialAlertDialogBuilder +import java.lang.ref.WeakReference +import java.util.logging.Level +import kotlin.concurrent.thread + +class DetectConfigurationFragment: Fragment() { + + private lateinit var loginModel: LoginModel + private lateinit var model: DetectConfigurationModel + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + loginModel = ViewModelProviders.of(requireActivity()).get(LoginModel::class.java) + model = ViewModelProviders.of(this).get(DetectConfigurationModel::class.java) + + model.detectConfiguration(loginModel).observe(this, Observer { result -> + // save result for next step + loginModel.configuration = result + + // remove "Detecting configuration" fragment, it shouldn't come back + requireFragmentManager().popBackStack() + + if (result.calDAV != null || result.cardDAV != null) + requireFragmentManager().beginTransaction() + .replace(android.R.id.content, AccountDetailsFragment()) + .addToBackStack(null) + .commit() + else + requireFragmentManager().beginTransaction() + .add(NothingDetectedFragment(), null) + .commit() + }) + } + + override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View = + inflater.inflate(R.layout.detect_configuration, container, false)!! + + + class DetectConfigurationModel( + application: Application + ): AndroidViewModel(application) { + + private var detectionThread: WeakReference? = null + private var result = MutableLiveData() + + fun detectConfiguration(loginModel: LoginModel): LiveData { + synchronized(result) { + if (detectionThread != null) + // detection already running + return result + } + + thread { + synchronized(result) { + detectionThread = WeakReference(Thread.currentThread()) + } + + try { + DavResourceFinder(getApplication(), loginModel).use { finder -> + result.postValue(finder.findInitialConfiguration()) + } + } catch(e: Exception) { + // exception, shouldn't happen + Logger.log.log(Level.SEVERE, "Internal resource detection error", e) + } + } + return result + } + + override fun onCleared() { + synchronized(result) { + detectionThread?.get()?.let { thread -> + Logger.log.info("Aborting resource detection") + thread.interrupt() + } + detectionThread = null + } + } + } + + + class NothingDetectedFragment: DialogFragment() { + + override fun onCreateDialog(savedInstanceState: Bundle?): Dialog { + val model = ViewModelProviders.of(requireActivity()).get(LoginModel::class.java) + return MaterialAlertDialogBuilder(requireActivity()) + .setTitle(R.string.login_configuration_detection) + .setIcon(R.drawable.ic_error_dark) + .setMessage(R.string.login_no_caldav_carddav) + .setCancelable(false) + .setNeutralButton(R.string.login_view_logs) { _, _ -> + val intent = Intent(activity, DebugInfoActivity::class.java) + intent.putExtra(DebugInfoActivity.KEY_LOGS, model.configuration?.logs) + startActivity(intent) + } + .setPositiveButton(android.R.string.ok) { _, _ -> + // just dismiss + } + .create()!! + } + + } + +} diff --git a/app/src/main/java/foundation/e/accountmanager/ui/setup/EeloAuthenticatorFragment.kt b/app/src/main/java/foundation/e/accountmanager/ui/setup/EeloAuthenticatorFragment.kt new file mode 100644 index 0000000000000000000000000000000000000000..7f4f82e0f11b739962245682b650f9a47fd1e91e --- /dev/null +++ b/app/src/main/java/foundation/e/accountmanager/ui/setup/EeloAuthenticatorFragment.kt @@ -0,0 +1,178 @@ +package foundation.e.accountmanager.ui.setup + +import android.content.Context +import android.net.MailTo +import android.os.* +import androidx.fragment.app.Fragment +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.Toast +import android.net.Uri +import android.net.ConnectivityManager + +import foundation.e.accountmanager.R +import androidx.lifecycle.ViewModelProviders +import org.json.JSONObject +import foundation.e.accountmanager.databinding.FragmentEeloAuthenticatorBinding +import kotlinx.android.synthetic.main.fragment_eelo_authenticator.* +import foundation.e.dav4jvm.Constants +import kotlinx.android.synthetic.main.fragment_eelo_authenticator.view.* +import java.net.IDN +import java.net.URI +import java.net.URISyntaxException +import java.util.logging.Level +import foundation.e.accountmanager.model.Credentials + +class EeloAuthenticatorFragment : Fragment() { + + private lateinit var model: EeloAuthenticatorModel + private lateinit var loginModel: LoginModel + + val TOGGLE_BUTTON_CHECKED_KEY = "toggle_button_checked" + var toggleButtonState = false + + private fun isNetworkAvailable(): Boolean { + val connectivityManager = activity!!.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager + val activeNetworkInfo = connectivityManager.activeNetworkInfo + return activeNetworkInfo != null && activeNetworkInfo.isConnectedOrConnecting + } + + override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, + savedInstanceState: Bundle?): View? { + + model = ViewModelProviders.of(this).get(EeloAuthenticatorModel::class.java) + loginModel = ViewModelProviders.of(requireActivity()).get(LoginModel::class.java) + + if (!isNetworkAvailable()) { + Toast.makeText(context, "Please check your internet connection", Toast.LENGTH_LONG).show() + activity!!.finish() + } + + val v = FragmentEeloAuthenticatorBinding.inflate(inflater, container, false) + v.lifecycleOwner = this + v.model = model + + v.root.urlpwd_other_account_title_panel.setOnClickListener() { expandCollapse() } + + v.root.urlpwd_user_name.setOnFocusChangeListener() { v, hasFocus -> + if (!hasFocus ) { + if (v.urlpwd_user_name.text.toString().contains("@")) { + val dns = v.urlpwd_user_name.text.toString().substringAfter("@") + val pre_custom_url = "https://" + dns + view!!.urlpwd_server_uri.setText(pre_custom_url) + } else { + view!!.urlpwd_server_uri.setText("") + } + } + } + + v.login.setOnClickListener { login() } + + // code below is to draw toggle button in its correct state and show or hide server url input field + //add by Vincent, 18/02/2019 + if(savedInstanceState != null){ + toggleButtonState = savedInstanceState.getBoolean(TOGGLE_BUTTON_CHECKED_KEY, false) + } + + //This allow the button to be redraw in the correct state if user turn screen + if(toggleButtonState == true) { + v.root.expand_collapse_button.setChecked(toggleButtonState) + v.root.urlpwd_server_uri_layout.setVisibility(View.VISIBLE) + v.root.urlpwd_server_uri.setEnabled(true) + }else{ + v.root.urlpwd_server_uri_layout.setVisibility(View.GONE) + v.root.urlpwd_server_uri.setEnabled(false) + } + + return v.root + } + + override fun onSaveInstanceState(outState: Bundle) { + outState.putBoolean(TOGGLE_BUTTON_CHECKED_KEY, toggleButtonState) + super.onSaveInstanceState(outState) + } + + private fun login() { + if (!isNetworkAvailable()) { + Toast.makeText(context, "Please check your internet connection", Toast.LENGTH_LONG).show() + activity!!.finish() + } + + if ((urlpwd_user_name.text.toString() != "") && (urlpwd_password.text.toString() != "")) { + if (validate()) + requireFragmentManager().beginTransaction() + .replace(android.R.id.content, DetectConfigurationFragment(), null) + .addToBackStack(null) + .commit() + } else { + Toast.makeText(context, "Please enter a valid username and password", Toast.LENGTH_LONG).show() + } + + } + + private fun validate(): Boolean { + var valid = false + + var serverUrl = foundation.e.accountmanager.Constants.EELO_SYNC_URL + + fun validateUrl() { + + if(toggleButtonState == true) { + serverUrl = view!!.urlpwd_server_uri.text.toString(); + } else { + serverUrl = foundation.e.accountmanager.Constants.EELO_SYNC_URL + } + + model.baseUrlError.value = null + try { + val uri = URI(serverUrl) + if (uri.scheme.equals("http", true) || uri.scheme.equals("https", true)) { + valid = true + loginModel.baseURI = uri + } else + model.baseUrlError.value = getString(R.string.login_url_must_be_http_or_https) + } catch (e: Exception) { + model.baseUrlError.value = e.localizedMessage + } + } + + when { + + model.loginWithUrlAndTokens.value == true -> { + validateUrl() + + val userName = view!!.urlpwd_user_name.text.toString() + val password = view!!.urlpwd_password.text.toString() + + if (loginModel.baseURI != null) { + valid = true + loginModel.credentials = Credentials(userName.toLowerCase(), password, null, null, loginModel.baseURI) + } + } + + } + + return valid + } + + /** + * Show/Hide panel containing server's uri input field. + */ + private fun expandCollapse(){ + //inverse state of Toggle button + expand_collapse_button.setChecked(!expand_collapse_button.isChecked()) + + if(expand_collapse_button.isChecked) { + urlpwd_server_uri_layout.setVisibility(View.VISIBLE) + urlpwd_server_uri.setEnabled(true) + toggleButtonState = true; + } + else { + urlpwd_server_uri_layout.setVisibility(View.GONE) + urlpwd_server_uri.setEnabled(false) + toggleButtonState = false; + } + } +} + diff --git a/app/src/main/java/foundation/e/accountmanager/ui/setup/EeloAuthenticatorModel.kt b/app/src/main/java/foundation/e/accountmanager/ui/setup/EeloAuthenticatorModel.kt new file mode 100644 index 0000000000000000000000000000000000000000..60f4abbdf99d613276f14a75be37c2997348ceab --- /dev/null +++ b/app/src/main/java/foundation/e/accountmanager/ui/setup/EeloAuthenticatorModel.kt @@ -0,0 +1,50 @@ +package foundation.e.accountmanager.ui.setup + +import android.content.Intent +import android.net.Uri +import androidx.lifecycle.MutableLiveData +import androidx.lifecycle.ViewModel + +class EeloAuthenticatorModel: ViewModel() { + + private var initialized = false + + val loginWithUrlAndTokens = MutableLiveData() + + val baseUrl = MutableLiveData() + val baseUrlError = MutableLiveData() + + val emailAddress = MutableLiveData() + val emailAddressError = MutableLiveData() + + val username = MutableLiveData() + val usernameError = MutableLiveData() + + val password = MutableLiveData() + val passwordError = MutableLiveData() + + val certificateAlias = MutableLiveData() + val certificateAliasError = MutableLiveData() + + init { + loginWithUrlAndTokens.value = true + } + + fun initialize(intent: Intent) { + if (initialized) + return + + // we've got initial login data + val givenUrl = intent.getStringExtra(LoginActivity.EXTRA_URL) + val givenUsername = intent.getStringExtra(LoginActivity.EXTRA_USERNAME) + val givenPassword = intent.getStringExtra(LoginActivity.EXTRA_PASSWORD) + + baseUrl.value = givenUrl + + password.value = givenPassword + + initialized = true + } + +} + diff --git a/app/src/main/java/foundation/e/accountmanager/ui/setup/GoogleAuthenticatorFragment.kt b/app/src/main/java/foundation/e/accountmanager/ui/setup/GoogleAuthenticatorFragment.kt new file mode 100644 index 0000000000000000000000000000000000000000..4d2d35531e4c143c8ee5e9eab8e271cb1cf27145 --- /dev/null +++ b/app/src/main/java/foundation/e/accountmanager/ui/setup/GoogleAuthenticatorFragment.kt @@ -0,0 +1,405 @@ +package foundation.e.accountmanager.ui.setup + +import android.app.Activity +import android.app.PendingIntent +import android.content.Context +import android.content.Intent +import android.net.MailTo +import android.os.* +import androidx.fragment.app.Fragment +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import foundation.e.dav4jvm.Constants + +import foundation.e.accountmanager.authorization.IdentityProvider + +import foundation.e.accountmanager.R +import androidx.lifecycle.ViewModelProviders +import net.openid.appauth.* +import org.json.JSONException +import java.io.BufferedReader +import java.io.IOException +import java.io.InputStream +import java.io.InputStreamReader +import java.net.* +import org.json.JSONObject +import java.util.HashMap + +import java.util.logging.Level +import foundation.e.accountmanager.databinding.FragmentGoogleAuthenticatorBinding +import foundation.e.accountmanager.model.Credentials +import kotlinx.android.synthetic.main.fragment_google_authenticator.* +import android.net.ConnectivityManager +import android.widget.Toast + +class GoogleAuthenticatorFragment : Fragment(), AuthorizationService.TokenResponseCallback { + + private lateinit var model: GoogleAuthenticatorModel + private lateinit var loginModel: LoginModel + + private val extraAuthServiceDiscovery = "authServiceDiscovery" + private val extraClientSecret = "clientSecret" + + private var authState: AuthState? = null + private var authorizationService: AuthorizationService? = null + + private val bufferSize = 1024 + private var userInfoJson: JSONObject? = null + + private fun isNetworkAvailable(): Boolean { + val connectivityManager = activity!!.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager + val activeNetworkInfo = connectivityManager.activeNetworkInfo + return activeNetworkInfo != null && activeNetworkInfo.isConnectedOrConnecting + } + + override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, + savedInstanceState: Bundle?): View? { + + model = ViewModelProviders.of(this).get(GoogleAuthenticatorModel::class.java) + loginModel = ViewModelProviders.of(requireActivity()).get(LoginModel::class.java) + + if (!isNetworkAvailable()) { + Toast.makeText(context, "Please check your internet connection", Toast.LENGTH_LONG).show() + activity!!.finish() + } + + // Initialise the authorization service + authorizationService = AuthorizationService(context!!) + + val v = FragmentGoogleAuthenticatorBinding.inflate(inflater, container, false) + v.lifecycleOwner = this + v.model = model + + activity?.intent?.let { + model.initialize(it) + + if (!with(it) { getBooleanExtra(LoginActivity.ACCOUNT_PROVIDER_GOOGLE_AUTH_COMPLETE, false) }) { + // Get all the account providers + val providers = IdentityProvider.getEnabledProviders(context) + + // Iterate over the account providers + for (idp in providers) { + val retrieveCallback = AuthorizationServiceConfiguration.RetrieveConfigurationCallback { serviceConfiguration, ex -> + if (ex == null && serviceConfiguration != null) { + makeAuthRequest(serviceConfiguration, idp) + } + else { + Toast.makeText(context, "Login failed, please try again later", Toast.LENGTH_LONG).show() + activity!!.finish() + } + } + + if (idp.name == getString(R.string.google_name)) { + // Get configurations for the Google account provider + idp.retrieveConfig(context, retrieveCallback) + } + } + } + else { + if (authState == null) { + val response = AuthorizationResponse.fromIntent(activity!!.intent) + val ex = AuthorizationException.fromIntent(activity!!.intent) + authState = AuthState(response, ex) + + if (response != null) { + exchangeAuthorizationCode(response) + } + else { + Toast.makeText(context, "Login failed, please try again later", Toast.LENGTH_LONG).show() + activity!!.finish() + } + } + } + } + + return v.root + } + + private fun makeAuthRequest( + serviceConfig: AuthorizationServiceConfiguration, + idp: IdentityProvider) { + + if (!isNetworkAvailable()) { + Toast.makeText(context, "Please check your internet connection", Toast.LENGTH_LONG).show() + activity!!.finish() + } + + val authRequest = AuthorizationRequest.Builder( + serviceConfig, + idp.clientId, + ResponseTypeValues.CODE, + idp.redirectUri) + .setScope(idp.scope) + .build() + + authorizationService?.performAuthorizationRequest( + authRequest, + createPostAuthorizationIntent( + context!!, + authRequest, + serviceConfig.discoveryDoc, + idp.clientSecret), + authorizationService?.createCustomTabsIntentBuilder()!! + .build()) + + requireActivity().setResult(Activity.RESULT_OK) + requireActivity().finish() + } + + private fun createPostAuthorizationIntent( + context: Context, + request: AuthorizationRequest, + discoveryDoc: AuthorizationServiceDiscovery?, + clientSecret: String?): PendingIntent { + val intent = Intent(context, LoginActivity::class.java) + + if (discoveryDoc != null) { + intent.putExtra(extraAuthServiceDiscovery, discoveryDoc.docJson.toString()) + } + + if (clientSecret != null) { + intent.putExtra(extraClientSecret, clientSecret) + } + + intent.putExtra(LoginActivity.SETUP_ACCOUNT_PROVIDER_TYPE, LoginActivity.ACCOUNT_PROVIDER_GOOGLE) + intent.putExtra(LoginActivity.ACCOUNT_PROVIDER_GOOGLE_AUTH_COMPLETE, true) + + return PendingIntent.getActivity(context, request.hashCode(), intent, 0) + } + + private fun exchangeAuthorizationCode(authorizationResponse: AuthorizationResponse) { + if (!isNetworkAvailable()) { + Toast.makeText(context, "Please check your internet connection", Toast.LENGTH_LONG).show() + activity!!.finish() + } + + val additionalParams = HashMap() + if (getClientSecretFromIntent(activity!!.intent) != null) { + additionalParams["client_secret"] = getClientSecretFromIntent(activity!!.intent) + } + performTokenRequest(authorizationResponse.createTokenExchangeRequest(additionalParams)) + } + + private fun getClientSecretFromIntent(intent: Intent): String? { + return if (!intent.hasExtra(extraClientSecret)) { + null + } + else intent.getStringExtra(extraClientSecret) + } + + + private fun performTokenRequest(request: TokenRequest) { + if (!isNetworkAvailable()) { + Toast.makeText(context, "Please check your internet connection", Toast.LENGTH_LONG).show() + activity!!.finish() + } + + authorizationService?.performTokenRequest( + request, this) + } + + override fun onTokenRequestCompleted(response: TokenResponse?, ex: AuthorizationException?) { + authState?.update(response, ex) + + getAccountInfo() + } + + private fun getAccountInfo() { + if (!isNetworkAvailable()) { + Toast.makeText(context, "Please check your internet connection", Toast.LENGTH_LONG).show() + activity!!.finish() + } + + val discoveryDoc = getDiscoveryDocFromIntent(activity!!.intent) + + if (!authState!!.isAuthorized + || discoveryDoc == null + || discoveryDoc.userinfoEndpoint == null) { + Toast.makeText(context, "Login failed, please try again later", Toast.LENGTH_LONG).show() + activity!!.finish() + } + else { + object : AsyncTask() { + override fun doInBackground(vararg params: Void): Void? { + if (fetchUserInfo()) { + Toast.makeText(context, "Login failed, please try again later", Toast.LENGTH_LONG).show() + activity!!.finish() + } + + return null + } + }.execute() + } + } + + private fun getDiscoveryDocFromIntent(intent: Intent): AuthorizationServiceDiscovery? { + if (!intent.hasExtra(extraAuthServiceDiscovery)) { + return null + } + val discoveryJson = intent.getStringExtra(extraAuthServiceDiscovery) + try { + return AuthorizationServiceDiscovery(JSONObject(discoveryJson)) + } + catch (ex: JSONException) { + throw IllegalStateException("Malformed JSON in discovery doc") + } + catch (ex: AuthorizationServiceDiscovery.MissingArgumentException) { + throw IllegalStateException("Malformed JSON in discovery doc") + } + + } + + private fun fetchUserInfo(): Boolean { + var error = false + + if (authState!!.authorizationServiceConfiguration == null) { + return true + } + + authState!!.performActionWithFreshTokens(authorizationService!!, AuthState.AuthStateAction { accessToken, _, ex -> + if (ex != null) { + error = true + return@AuthStateAction + } + + val discoveryDoc = getDiscoveryDocFromIntent(activity!!.intent) + ?: throw IllegalStateException("no available discovery doc") + + val userInfoEndpoint: URL + try { + userInfoEndpoint = URL(discoveryDoc.userinfoEndpoint!!.toString()) + } + catch (urlEx: MalformedURLException) { + error = true + return@AuthStateAction + } + + var userInfoResponse: InputStream? = null + try { + val conn = userInfoEndpoint.openConnection() as HttpURLConnection + conn.setRequestProperty("Authorization", "Bearer " + accessToken!!) + conn.instanceFollowRedirects = false + userInfoResponse = conn.inputStream + val response = readStream(userInfoResponse) + updateUserInfo(JSONObject(response)) + } + catch (ioEx: IOException) { + error = true + } + catch (jsonEx: JSONException) { + error = true + } + finally { + if (userInfoResponse != null) { + try { + userInfoResponse.close() + } + catch (ioEx: IOException) { + error = true + } + + } + } + }) + + return error + } + + @Throws(IOException::class) + private fun readStream(stream: InputStream?): String { + val br = BufferedReader(InputStreamReader(stream!!)) + val buffer = CharArray(bufferSize) + val sb = StringBuilder() + var readCount = br.read(buffer) + while (readCount != -1) { + sb.append(buffer, 0, readCount) + readCount = br.read(buffer) + } + return sb.toString() + } + + private fun updateUserInfo(jsonObject: JSONObject) { + Handler(Looper.getMainLooper()).post { + userInfoJson = jsonObject + onAccountInfoGotten() + } + } + + private fun onAccountInfoGotten() { + if (!isNetworkAvailable()) { + Toast.makeText(context, "Please check your internet connection", Toast.LENGTH_LONG).show() + activity!!.finish() + } + + if (userInfoJson != null) { + try { + + var emailAddress = "" + if (userInfoJson!!.has("email")) { + emailAddress = userInfoJson!!.getString("email") + } + + if (validate(emailAddress, authState!!)) + requireFragmentManager().beginTransaction() + .replace(android.R.id.content, DetectConfigurationFragment(), null) + .addToBackStack(null) + .commit() + + } + catch (ex: JSONException) { + Toast.makeText(context, "Login failed, please try again later", Toast.LENGTH_LONG).show() + activity!!.finish() + } + + } + else { + Toast.makeText(context, "Login failed, please try again later", Toast.LENGTH_LONG).show() + activity!!.finish() + } + + } + + private fun validate(emailAddress: String, authState: AuthState): Boolean { + var valid = false + + fun validateUrl() { + model.baseUrlError.value = null + try { + val uri = URI("https://apidata.googleusercontent.com/caldav/v2/$emailAddress/user") + if (uri.scheme.equals("http", true) || uri.scheme.equals("https", true)) { + valid = true + loginModel.baseURI = uri + } else + model.baseUrlError.value = getString(R.string.login_url_must_be_http_or_https) + } catch (e: Exception) { + model.baseUrlError.value = e.localizedMessage + } + } + + when { + + model.loginWithUrlAndTokens.value == true -> { + validateUrl() + + model.usernameError.value = null + + if (loginModel.baseURI != null) { + valid = true + loginModel.credentials = Credentials(emailAddress, null, authState, null) + } + } + + } + + return valid + } + + override fun onDestroy() { + super.onDestroy() + authorizationService?.dispose() + } + + +} + diff --git a/app/src/main/java/foundation/e/accountmanager/ui/setup/GoogleAuthenticatorModel.kt b/app/src/main/java/foundation/e/accountmanager/ui/setup/GoogleAuthenticatorModel.kt new file mode 100644 index 0000000000000000000000000000000000000000..34607440137aa3a37b8a6e791fbc2fd15b8ccd45 --- /dev/null +++ b/app/src/main/java/foundation/e/accountmanager/ui/setup/GoogleAuthenticatorModel.kt @@ -0,0 +1,50 @@ +package foundation.e.accountmanager.ui.setup + +import android.content.Intent +import android.net.Uri +import androidx.lifecycle.MutableLiveData +import androidx.lifecycle.ViewModel + +class GoogleAuthenticatorModel: ViewModel() { + + private var initialized = false + + val loginWithUrlAndTokens = MutableLiveData() + + val baseUrl = MutableLiveData() + val baseUrlError = MutableLiveData() + + val emailAddress = MutableLiveData() + val emailAddressError = MutableLiveData() + + val username = MutableLiveData() + val usernameError = MutableLiveData() + + val password = MutableLiveData() + val passwordError = MutableLiveData() + + val certificateAlias = MutableLiveData() + val certificateAliasError = MutableLiveData() + + init { + loginWithUrlAndTokens.value = true + } + + fun initialize(intent: Intent) { + if (initialized) + return + + // we've got initial login data + val givenUrl = intent.getStringExtra(LoginActivity.EXTRA_URL) + val givenUsername = intent.getStringExtra(LoginActivity.EXTRA_USERNAME) + val givenPassword = intent.getStringExtra(LoginActivity.EXTRA_PASSWORD) + + baseUrl.value = givenUrl + + password.value = givenPassword + + initialized = true + } + +} + diff --git a/app/src/main/java/foundation/e/accountmanager/ui/setup/ILoginCredentialsFragment.kt b/app/src/main/java/foundation/e/accountmanager/ui/setup/ILoginCredentialsFragment.kt new file mode 100644 index 0000000000000000000000000000000000000000..b55736af269fd4d205173105b9b101149d736fd3 --- /dev/null +++ b/app/src/main/java/foundation/e/accountmanager/ui/setup/ILoginCredentialsFragment.kt @@ -0,0 +1,18 @@ +/* + * 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 foundation.e.accountmanager.ui.setup + +import android.content.Intent +import androidx.fragment.app.Fragment + +interface ILoginCredentialsFragment { + + fun getFragment(intent: Intent): Fragment? + +} \ No newline at end of file diff --git a/app/src/main/java/foundation/e/accountmanager/ui/setup/LoginActivity.kt b/app/src/main/java/foundation/e/accountmanager/ui/setup/LoginActivity.kt new file mode 100644 index 0000000000000000000000000000000000000000..cdc5874d84cecd858be64e76d033e6c74538801a --- /dev/null +++ b/app/src/main/java/foundation/e/accountmanager/ui/setup/LoginActivity.kt @@ -0,0 +1,95 @@ +/* + * 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 foundation.e.accountmanager.ui.setup + +import android.os.Bundle +import android.view.MenuItem +import androidx.appcompat.app.AppCompatActivity +import androidx.fragment.app.Fragment +import foundation.e.accountmanager.App +import foundation.e.accountmanager.log.Logger +import foundation.e.accountmanager.ui.UiUtils +import java.util.* + +/** + * Activity to initially connect to a server and create an account. + * Fields for server/user data can be pre-filled with extras in the Intent. + */ +class LoginActivity: AppCompatActivity() { + + companion object { + /** + * 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. + */ + 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. + */ + const val EXTRA_USERNAME = "username" + + /** + * When set, the password field will be set to this value. + */ + const val EXTRA_PASSWORD = "password" + + const val SETUP_ACCOUNT_PROVIDER_TYPE = "setup_account_provider_type" + const val ACCOUNT_PROVIDER_EELO = "eelo" + const val ACCOUNT_PROVIDER_GOOGLE = "google" + const val ACCOUNT_PROVIDER_GOOGLE_AUTH_COMPLETE = "google_auth_complete" + } + + private val loginFragmentLoader = ServiceLoader.load(ILoginCredentialsFragment::class.java)!! + + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + if (savedInstanceState == null) { + // first call, add first login fragment + var fragment: Fragment? = null + for (factory in loginFragmentLoader) + fragment = fragment ?: factory.getFragment(intent) + + if (fragment != null) { + when (intent.getStringExtra(SETUP_ACCOUNT_PROVIDER_TYPE)) { + ACCOUNT_PROVIDER_EELO -> { + supportFragmentManager.beginTransaction() + .replace(android.R.id.content, EeloAuthenticatorFragment()) + .commit() + } + ACCOUNT_PROVIDER_GOOGLE -> { + supportFragmentManager.beginTransaction() + .replace(android.R.id.content, GoogleAuthenticatorFragment()) + .commit() + } + else -> + // first call, add first login fragment + supportFragmentManager.beginTransaction() + .replace(android.R.id.content, fragment) + .commit() + } + } else + Logger.log.severe("Couldn't create LoginFragment") + } + } + + override fun onOptionsItemSelected(item: MenuItem?): Boolean { + when { + item?.itemId == android.R.id.home -> { + finish() + return true + } + } + return false + } + +} diff --git a/app/src/main/java/foundation/e/accountmanager/ui/setup/LoginModel.kt b/app/src/main/java/foundation/e/accountmanager/ui/setup/LoginModel.kt new file mode 100644 index 0000000000000000000000000000000000000000..0ebd6cc55b57f8080b1e8c1d2a0220f5e878f000 --- /dev/null +++ b/app/src/main/java/foundation/e/accountmanager/ui/setup/LoginModel.kt @@ -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 foundation.e.accountmanager.ui.setup + +import androidx.lifecycle.ViewModel +import foundation.e.accountmanager.model.Credentials +import java.net.URI + +class LoginModel: ViewModel() { + + var baseURI: URI? = null + var credentials: Credentials? = null + + var configuration: DavResourceFinder.Configuration? = null + +} diff --git a/app/src/main/java/foundation/e/accountmanager/ui/setup/NextcloudLoginFlowFragment.kt b/app/src/main/java/foundation/e/accountmanager/ui/setup/NextcloudLoginFlowFragment.kt new file mode 100644 index 0000000000000000000000000000000000000000..c62094d5b509dbbf4c337aedf142fe2de6fcde6d --- /dev/null +++ b/app/src/main/java/foundation/e/accountmanager/ui/setup/NextcloudLoginFlowFragment.kt @@ -0,0 +1,101 @@ +package foundation.e.accountmanager.ui.setup + +import android.annotation.SuppressLint +import android.content.Intent +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.webkit.WebView +import android.webkit.WebViewClient +import androidx.fragment.app.Fragment +import androidx.lifecycle.ViewModelProviders +import foundation.e.accountmanager.BuildConfig +import foundation.e.accountmanager.log.Logger +import foundation.e.accountmanager.model.Credentials +import okhttp3.HttpUrl +import java.net.URI +import java.util.logging.Level + +class NextcloudLoginFlowFragment: Fragment() { + + companion object { + + /** Set this to 1 to indicate that Login Flow shall be used. */ + const val EXTRA_LOGIN_FLOW = "loginFlow" + + /** Path to DAV endpoint (e.g. `/remote.php/dav`). Will be appended to the + * server URL returned by Login Flow without further processing. */ + const val EXTRA_DAV_PATH = "davPath" + } + + lateinit var loginModel: LoginModel + + @SuppressLint("SetJavaScriptEnabled") + override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? { + loginModel = ViewModelProviders.of(requireActivity()).get(LoginModel::class.java) + + val webView = WebView(requireActivity()) + webView.settings.apply { + javaScriptEnabled = true + userAgentString = BuildConfig.userAgent + } + webView.loadUrl( + requireActivity().intent.data.toString(), // https://nextcloud.example.com/index.php/login/flow + mapOf(Pair("OCS-APIREQUEST", "true")) + ) + webView.webViewClient = object: WebViewClient() { + override fun shouldOverrideUrlLoading(view: WebView, url: String) = + if (url.startsWith("nc://login")) { + onReceivedNcUrl(url) + true + } else { + Logger.log.info("Didn't handle $url") + false + } + } + return webView + } + + private fun onReceivedNcUrl(url: String) { + val format = Regex("^nc://login/server:(.+)&user:(.+)&password:(.+)$") + val match = format.find(url) + if (match != null) { + // determine DAV URL from root URL + try { + val serverUrl = match.groupValues[1] + val davPath = requireActivity().intent.getStringExtra(EXTRA_DAV_PATH) + loginModel.baseURI = if (davPath != null) + HttpUrl.get(serverUrl + davPath).uri() + else + URI.create(serverUrl) + + loginModel.credentials = Credentials( + userName = match.groupValues[2], + password = match.groupValues[3] + ) + + // continue to next fragment + requireFragmentManager().beginTransaction() + .replace(android.R.id.content, DetectConfigurationFragment(), null) + .addToBackStack(null) + .commit() + } catch (e: IllegalArgumentException) { + Logger.log.log(Level.SEVERE, "Couldn't parse server argument of nc URL: $url", e) + } + } else + Logger.log.severe("Unknown format of nc URL: $url") + } + + + class Factory : ILoginCredentialsFragment { + + override fun getFragment(intent: Intent) = + if (intent.hasExtra(EXTRA_LOGIN_FLOW)) + NextcloudLoginFlowFragment() + else + null + + } + +} diff --git a/app/src/main/java/foundation/e/accountmanager/ui/widget/BindingAdapters.kt b/app/src/main/java/foundation/e/accountmanager/ui/widget/BindingAdapters.kt new file mode 100644 index 0000000000000000000000000000000000000000..ed19788f22ba321cb9d98df8537b5457c2fef191 --- /dev/null +++ b/app/src/main/java/foundation/e/accountmanager/ui/widget/BindingAdapters.kt @@ -0,0 +1,26 @@ +package foundation.e.accountmanager.ui.widget + +import android.text.method.LinkMovementMethod +import android.widget.TextView +import androidx.core.text.HtmlCompat +import androidx.databinding.BindingAdapter + +object BindingAdapters { + + @BindingAdapter("error") + @JvmStatic + fun setError(textView: TextView, error: String?) { + textView.error = error + } + + @BindingAdapter("html") + @JvmStatic + fun setHtml(textView: TextView, html: String?) { + if (html != null) { + textView.text = HtmlCompat.fromHtml(html, HtmlCompat.FROM_HTML_MODE_LEGACY) + textView.movementMethod = LinkMovementMethod.getInstance() + } else + textView.text = null + } + +} diff --git a/app/src/main/java/at/bitfire/davdroid/ui/widget/IntEditTextPreference.kt b/app/src/main/java/foundation/e/accountmanager/ui/widget/IntEditTextPreference.kt similarity index 89% rename from app/src/main/java/at/bitfire/davdroid/ui/widget/IntEditTextPreference.kt rename to app/src/main/java/foundation/e/accountmanager/ui/widget/IntEditTextPreference.kt index 20862e97f34587f6d50bb51311a104683603f0b5..01989a037316811a55cf29101f0e5524ed7c938b 100644 --- a/app/src/main/java/at/bitfire/davdroid/ui/widget/IntEditTextPreference.kt +++ b/app/src/main/java/foundation/e/accountmanager/ui/widget/IntEditTextPreference.kt @@ -6,11 +6,11 @@ * http://www.gnu.org/licenses/gpl.html */ -package at.bitfire.davdroid.ui.widget +package foundation.e.accountmanager.ui.widget import android.content.Context -import android.support.v7.preference.EditTextPreference import android.util.AttributeSet +import androidx.preference.EditTextPreference class IntEditTextPreference( context: Context, diff --git a/app/src/main/lombok.config b/app/src/main/lombok.config deleted file mode 100644 index 79a22b8dc715825c50725f80f77ba8fd2ce28f8f..0000000000000000000000000000000000000000 --- a/app/src/main/lombok.config +++ /dev/null @@ -1,2 +0,0 @@ -lombok.addGeneratedAnnotation = false -lombok.anyConstructor.suppressConstructorProperties = true diff --git a/app/src/main/res/drawable-anydpi-v24/ic_delete_notify.xml b/app/src/main/res/drawable-anydpi-v24/ic_delete_notify.xml new file mode 100644 index 0000000000000000000000000000000000000000..51c7e5d0daf9e0caef41527c91b8e89c094b7bc3 --- /dev/null +++ b/app/src/main/res/drawable-anydpi-v24/ic_delete_notify.xml @@ -0,0 +1,13 @@ + + + + + diff --git a/app/src/main/res/drawable-anydpi-v24/ic_sd_card_notify.xml b/app/src/main/res/drawable-anydpi-v24/ic_sd_card_notify.xml new file mode 100644 index 0000000000000000000000000000000000000000..f284d1a89e80a7c0e877ef2bf6cbb3bf672e2d7d --- /dev/null +++ b/app/src/main/res/drawable-anydpi-v24/ic_sd_card_notify.xml @@ -0,0 +1,13 @@ + + + + + diff --git a/app/src/main/res/drawable-anydpi-v24/ic_share_notify.xml b/app/src/main/res/drawable-anydpi-v24/ic_share_notify.xml new file mode 100644 index 0000000000000000000000000000000000000000..86af14821c14d2144a4c6eeb6b46426bed4307a3 --- /dev/null +++ b/app/src/main/res/drawable-anydpi-v24/ic_share_notify.xml @@ -0,0 +1,13 @@ + + + + + diff --git a/app/src/main/res/drawable-anydpi-v24/ic_sync_problem_notify.xml b/app/src/main/res/drawable-anydpi-v24/ic_sync_problem_notify.xml new file mode 100644 index 0000000000000000000000000000000000000000..ed44b85de1030033722bdb249ed0432dbd9120d3 --- /dev/null +++ b/app/src/main/res/drawable-anydpi-v24/ic_sync_problem_notify.xml @@ -0,0 +1,13 @@ + + + + + diff --git a/app/src/main/res/drawable-anydpi-v24/ic_warning_notify.xml b/app/src/main/res/drawable-anydpi-v24/ic_warning_notify.xml new file mode 100644 index 0000000000000000000000000000000000000000..ed235312183e41833a4c1e841fd1b04c1ab3ee1d --- /dev/null +++ b/app/src/main/res/drawable-anydpi-v24/ic_warning_notify.xml @@ -0,0 +1,12 @@ + + + + + diff --git a/app/src/main/res/drawable-hdpi/ic_delete_notification.png b/app/src/main/res/drawable-hdpi/ic_delete_notification.png deleted file mode 100644 index b98a35ccc0038a36dda131dbdc9e1cf4e0440010..0000000000000000000000000000000000000000 Binary files a/app/src/main/res/drawable-hdpi/ic_delete_notification.png and /dev/null differ diff --git a/app/src/main/res/drawable-hdpi/ic_delete_notify.png b/app/src/main/res/drawable-hdpi/ic_delete_notify.png new file mode 100644 index 0000000000000000000000000000000000000000..f5e70ad8ff6979e425a2b115fba5ba91967a12b0 Binary files /dev/null and b/app/src/main/res/drawable-hdpi/ic_delete_notify.png differ diff --git a/app/src/main/res/drawable-hdpi/ic_sd_card_notify.png b/app/src/main/res/drawable-hdpi/ic_sd_card_notify.png new file mode 100644 index 0000000000000000000000000000000000000000..3a9d800888330f5521adcb064b7b6cafe38a0405 Binary files /dev/null and b/app/src/main/res/drawable-hdpi/ic_sd_card_notify.png differ diff --git a/app/src/main/res/drawable-hdpi/ic_sd_storage_notification.png b/app/src/main/res/drawable-hdpi/ic_sd_storage_notification.png deleted file mode 100644 index 0e90998db1a0e3d96f16c03aa04b5dc8637f52c2..0000000000000000000000000000000000000000 Binary files a/app/src/main/res/drawable-hdpi/ic_sd_storage_notification.png and /dev/null differ diff --git a/app/src/main/res/drawable-hdpi/ic_share_notify.png b/app/src/main/res/drawable-hdpi/ic_share_notify.png new file mode 100644 index 0000000000000000000000000000000000000000..e922c4c5b1042f02026476cce62a8cf594f470c8 Binary files /dev/null and b/app/src/main/res/drawable-hdpi/ic_share_notify.png differ diff --git a/app/src/main/res/drawable-hdpi/ic_sync_error_notification.png b/app/src/main/res/drawable-hdpi/ic_sync_error_notification.png deleted file mode 100644 index 4b89ca1b7e447eaf3dbc6cc499597572531cedca..0000000000000000000000000000000000000000 Binary files a/app/src/main/res/drawable-hdpi/ic_sync_error_notification.png and /dev/null differ diff --git a/app/src/main/res/drawable-hdpi/ic_sync_problem_notify.png b/app/src/main/res/drawable-hdpi/ic_sync_problem_notify.png new file mode 100644 index 0000000000000000000000000000000000000000..9562504d168da234a5661faf66259ffcf72e9a74 Binary files /dev/null and b/app/src/main/res/drawable-hdpi/ic_sync_problem_notify.png differ diff --git a/app/src/main/res/drawable-hdpi/ic_warning_notify.png b/app/src/main/res/drawable-hdpi/ic_warning_notify.png new file mode 100644 index 0000000000000000000000000000000000000000..a70656ab883a17d89259a3b01ebdce2ccd8532d8 Binary files /dev/null and b/app/src/main/res/drawable-hdpi/ic_warning_notify.png differ diff --git a/app/src/main/res/drawable-mdpi/ic_delete_notification.png b/app/src/main/res/drawable-mdpi/ic_delete_notification.png deleted file mode 100644 index af9b2acc378112fafdf8d20cff9797d8c2cb6858..0000000000000000000000000000000000000000 Binary files a/app/src/main/res/drawable-mdpi/ic_delete_notification.png and /dev/null differ diff --git a/app/src/main/res/drawable-mdpi/ic_delete_notify.png b/app/src/main/res/drawable-mdpi/ic_delete_notify.png new file mode 100644 index 0000000000000000000000000000000000000000..d7af2040a7054d15c91881e9e23d3040418dbe88 Binary files /dev/null and b/app/src/main/res/drawable-mdpi/ic_delete_notify.png differ diff --git a/app/src/main/res/drawable-mdpi/ic_sd_card_notify.png b/app/src/main/res/drawable-mdpi/ic_sd_card_notify.png new file mode 100644 index 0000000000000000000000000000000000000000..dec3be01e131f79cbb003a0c33b55a647ac1b38b Binary files /dev/null and b/app/src/main/res/drawable-mdpi/ic_sd_card_notify.png differ diff --git a/app/src/main/res/drawable-mdpi/ic_sd_storage_notification.png b/app/src/main/res/drawable-mdpi/ic_sd_storage_notification.png deleted file mode 100644 index f703df262bb8236bf41a8c36a2fdd0497ee3e361..0000000000000000000000000000000000000000 Binary files a/app/src/main/res/drawable-mdpi/ic_sd_storage_notification.png and /dev/null differ diff --git a/app/src/main/res/drawable-mdpi/ic_share_notify.png b/app/src/main/res/drawable-mdpi/ic_share_notify.png new file mode 100644 index 0000000000000000000000000000000000000000..1a2e5094de8953091dbc3295635cab903912fe00 Binary files /dev/null and b/app/src/main/res/drawable-mdpi/ic_share_notify.png differ diff --git a/app/src/main/res/drawable-mdpi/ic_sync_error_notification.png b/app/src/main/res/drawable-mdpi/ic_sync_error_notification.png deleted file mode 100644 index d0d0287d892e9d94b893c22ce87db4ce25c35ab6..0000000000000000000000000000000000000000 Binary files a/app/src/main/res/drawable-mdpi/ic_sync_error_notification.png and /dev/null differ diff --git a/app/src/main/res/drawable-mdpi/ic_sync_problem_notify.png b/app/src/main/res/drawable-mdpi/ic_sync_problem_notify.png new file mode 100644 index 0000000000000000000000000000000000000000..7129c90460e9053008a15ae2468fdbd0c3c475ea Binary files /dev/null and b/app/src/main/res/drawable-mdpi/ic_sync_problem_notify.png differ diff --git a/app/src/main/res/drawable-mdpi/ic_warning_notify.png b/app/src/main/res/drawable-mdpi/ic_warning_notify.png new file mode 100644 index 0000000000000000000000000000000000000000..95b115cb20b838d06736ad244cccfd5593636391 Binary files /dev/null and b/app/src/main/res/drawable-mdpi/ic_warning_notify.png differ diff --git a/app/src/main/res/drawable-xhdpi/ic_delete_notification.png b/app/src/main/res/drawable-xhdpi/ic_delete_notification.png deleted file mode 100644 index cbb42d5d7b5094e72877d5037fcad3d2f2b6424c..0000000000000000000000000000000000000000 Binary files a/app/src/main/res/drawable-xhdpi/ic_delete_notification.png and /dev/null differ diff --git a/app/src/main/res/drawable-xhdpi/ic_delete_notify.png b/app/src/main/res/drawable-xhdpi/ic_delete_notify.png new file mode 100644 index 0000000000000000000000000000000000000000..08be1c0236d10912395cb291d78cef238336a96a Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/ic_delete_notify.png differ diff --git a/app/src/main/res/drawable-xhdpi/ic_sd_card_notify.png b/app/src/main/res/drawable-xhdpi/ic_sd_card_notify.png new file mode 100644 index 0000000000000000000000000000000000000000..5bde5977d2498d8d338db09389493cd236700859 Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/ic_sd_card_notify.png differ diff --git a/app/src/main/res/drawable-xhdpi/ic_sd_storage_notification.png b/app/src/main/res/drawable-xhdpi/ic_sd_storage_notification.png deleted file mode 100644 index aad682e6c32f7159de572a76e6958bb368de3cf7..0000000000000000000000000000000000000000 Binary files a/app/src/main/res/drawable-xhdpi/ic_sd_storage_notification.png and /dev/null differ diff --git a/app/src/main/res/drawable-xhdpi/ic_share_notify.png b/app/src/main/res/drawable-xhdpi/ic_share_notify.png new file mode 100644 index 0000000000000000000000000000000000000000..3ebd48aaedddc03f1300e29cb7e7919a377a4a1d Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/ic_share_notify.png differ diff --git a/app/src/main/res/drawable-xhdpi/ic_sync_error_notification.png b/app/src/main/res/drawable-xhdpi/ic_sync_error_notification.png deleted file mode 100644 index a7c521bb1af1dcdb9683af715dbca66291261609..0000000000000000000000000000000000000000 Binary files a/app/src/main/res/drawable-xhdpi/ic_sync_error_notification.png and /dev/null differ diff --git a/app/src/main/res/drawable-xhdpi/ic_sync_problem_notify.png b/app/src/main/res/drawable-xhdpi/ic_sync_problem_notify.png new file mode 100644 index 0000000000000000000000000000000000000000..45685aa617d482a194c7ea77765372067ee2a47f Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/ic_sync_problem_notify.png differ diff --git a/app/src/main/res/drawable-xhdpi/ic_warning_notify.png b/app/src/main/res/drawable-xhdpi/ic_warning_notify.png new file mode 100644 index 0000000000000000000000000000000000000000..7a70e843b5916f7498b6802f04db8e70b868186b Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/ic_warning_notify.png differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_account_provider_eelo.png b/app/src/main/res/drawable-xxhdpi/ic_account_provider_eelo.png new file mode 100644 index 0000000000000000000000000000000000000000..be7f21f840662fa3658879dd7a963de5640f4d31 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_account_provider_eelo.png differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_account_provider_google.png b/app/src/main/res/drawable-xxhdpi/ic_account_provider_google.png new file mode 100644 index 0000000000000000000000000000000000000000..2154694f964da71ead68a6ff4baa784a21e76a03 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_account_provider_google.png differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_delete_notification.png b/app/src/main/res/drawable-xxhdpi/ic_delete_notification.png deleted file mode 100644 index d6eb54a12d82c2b28628df670b2333beb1dc17bd..0000000000000000000000000000000000000000 Binary files a/app/src/main/res/drawable-xxhdpi/ic_delete_notification.png and /dev/null differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_delete_notify.png b/app/src/main/res/drawable-xxhdpi/ic_delete_notify.png new file mode 100644 index 0000000000000000000000000000000000000000..7602c963758fd21057f3a9a702d1403e95d4b730 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_delete_notify.png differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_sd_card_notify.png b/app/src/main/res/drawable-xxhdpi/ic_sd_card_notify.png new file mode 100644 index 0000000000000000000000000000000000000000..07497af0ed39e49ef45303ccf282ab86d258568f Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_sd_card_notify.png differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_sd_storage_notification.png b/app/src/main/res/drawable-xxhdpi/ic_sd_storage_notification.png deleted file mode 100644 index e795ef52cf52ea44cab6c5675b3bf47f4a8f660e..0000000000000000000000000000000000000000 Binary files a/app/src/main/res/drawable-xxhdpi/ic_sd_storage_notification.png and /dev/null differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_share_notify.png b/app/src/main/res/drawable-xxhdpi/ic_share_notify.png new file mode 100644 index 0000000000000000000000000000000000000000..0d35687bc9559b424eb01f725f3e2b39de05022e Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_share_notify.png differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_sync_error_notification.png b/app/src/main/res/drawable-xxhdpi/ic_sync_error_notification.png deleted file mode 100644 index c3a5c2c04bd9675959a1f49a9e06b2cbc2e9b154..0000000000000000000000000000000000000000 Binary files a/app/src/main/res/drawable-xxhdpi/ic_sync_error_notification.png and /dev/null differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_sync_problem_notify.png b/app/src/main/res/drawable-xxhdpi/ic_sync_problem_notify.png new file mode 100644 index 0000000000000000000000000000000000000000..6a2d99f75af1a14183f8f5713da4e70518327d63 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_sync_problem_notify.png differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_warning_notify.png b/app/src/main/res/drawable-xxhdpi/ic_warning_notify.png new file mode 100644 index 0000000000000000000000000000000000000000..6a8ffc431fd27843486f2520bc081cb24156ec84 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_warning_notify.png differ diff --git a/app/src/main/res/drawable/custom_url_button_value.xml b/app/src/main/res/drawable/custom_url_button_value.xml new file mode 100644 index 0000000000000000000000000000000000000000..fd4b1bbeb6a59950bf42ef0f6afd2ed27ec4e7d0 --- /dev/null +++ b/app/src/main/res/drawable/custom_url_button_value.xml @@ -0,0 +1,10 @@ + + + + + + + + diff --git a/app/src/main/res/drawable/ic_adb_dark.xml b/app/src/main/res/drawable/ic_adb_dark.xml new file mode 100644 index 0000000000000000000000000000000000000000..3be00f0a553b61437c4d75233c33b2b2dcfc661c --- /dev/null +++ b/app/src/main/res/drawable/ic_adb_dark.xml @@ -0,0 +1,5 @@ + + + diff --git a/app/src/main/res/drawable/ic_add_light.xml b/app/src/main/res/drawable/ic_add_white.xml similarity index 100% rename from app/src/main/res/drawable/ic_add_light.xml rename to app/src/main/res/drawable/ic_add_white.xml diff --git a/app/src/main/res/drawable/ic_attach_money_dark.xml b/app/src/main/res/drawable/ic_attach_money_dark.xml deleted file mode 100644 index 9db797f0565182fe913a9db48fa3a171439e06b4..0000000000000000000000000000000000000000 --- a/app/src/main/res/drawable/ic_attach_money_dark.xml +++ /dev/null @@ -1,13 +0,0 @@ - - - - - diff --git a/app/src/main/res/drawable/ic_bug_report_dark.xml b/app/src/main/res/drawable/ic_bug_report_dark.xml new file mode 100644 index 0000000000000000000000000000000000000000..948a5be51c12ffc59d080ab5de7a95593efc13ba --- /dev/null +++ b/app/src/main/res/drawable/ic_bug_report_dark.xml @@ -0,0 +1,5 @@ + + + diff --git a/app/src/main/res/drawable/ic_cloud_off_dark.xml b/app/src/main/res/drawable/ic_cloud_off_dark.xml new file mode 100644 index 0000000000000000000000000000000000000000..8f26f9e53eeaeafa7c093017134947d4b6de06e8 --- /dev/null +++ b/app/src/main/res/drawable/ic_cloud_off_dark.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/ic_contacts_dark.xml b/app/src/main/res/drawable/ic_contacts_dark.xml new file mode 100644 index 0000000000000000000000000000000000000000..aee4a2c7c8e43c3b4a2bb58849b6ac34b97c1cba --- /dev/null +++ b/app/src/main/res/drawable/ic_contacts_dark.xml @@ -0,0 +1,5 @@ + + + diff --git a/app/src/main/res/drawable/ic_date_range_dark.xml b/app/src/main/res/drawable/ic_date_range_dark.xml new file mode 100644 index 0000000000000000000000000000000000000000..ded97e98c5bb6e35f572fa50da85a15d8f379508 --- /dev/null +++ b/app/src/main/res/drawable/ic_date_range_dark.xml @@ -0,0 +1,5 @@ + + + diff --git a/app/src/main/res/drawable/ic_event_light.xml b/app/src/main/res/drawable/ic_event_light.xml deleted file mode 100644 index 3652d8e234b91b314b37ff5f3da41d832c8b3b5f..0000000000000000000000000000000000000000 --- a/app/src/main/res/drawable/ic_event_light.xml +++ /dev/null @@ -1,17 +0,0 @@ - - - - - diff --git a/app/src/main/res/drawable/ic_expand_less.xml b/app/src/main/res/drawable/ic_expand_less.xml new file mode 100644 index 0000000000000000000000000000000000000000..30a106fec9749c057e507837c9bf7a2dcb31e5dc --- /dev/null +++ b/app/src/main/res/drawable/ic_expand_less.xml @@ -0,0 +1,10 @@ + + + + diff --git a/app/src/main/res/drawable/ic_expand_more.xml b/app/src/main/res/drawable/ic_expand_more.xml new file mode 100644 index 0000000000000000000000000000000000000000..77316f76e6b5c438b29976c1e704168e2d63c837 --- /dev/null +++ b/app/src/main/res/drawable/ic_expand_more.xml @@ -0,0 +1,10 @@ + + + + diff --git a/app/src/main/res/drawable/ic_forum_dark.xml b/app/src/main/res/drawable/ic_forum_dark.xml deleted file mode 100644 index fd14bef910133cdfcf56ed990e5f9f609d3e4d50..0000000000000000000000000000000000000000 --- a/app/src/main/res/drawable/ic_forum_dark.xml +++ /dev/null @@ -1,18 +0,0 @@ - - - - - diff --git a/app/src/main/res/drawable/ic_group_dark.xml b/app/src/main/res/drawable/ic_group_dark.xml new file mode 100644 index 0000000000000000000000000000000000000000..2a6a0c4eb1f6deb1eb0c3b5b6a0c85350a6ba169 --- /dev/null +++ b/app/src/main/res/drawable/ic_group_dark.xml @@ -0,0 +1,5 @@ + + + diff --git a/app/src/main/res/drawable/ic_help_action.xml b/app/src/main/res/drawable/ic_help_action.xml deleted file mode 100644 index f2c5c9320c1b9fba8adf6b01f7e1afd82b01f9e8..0000000000000000000000000000000000000000 --- a/app/src/main/res/drawable/ic_help_action.xml +++ /dev/null @@ -1,17 +0,0 @@ - - - - - diff --git a/app/src/main/res/drawable/ic_help_dark.xml b/app/src/main/res/drawable/ic_help_dark.xml deleted file mode 100644 index 091ef75fba69627b8f011af1439cf09990611e1d..0000000000000000000000000000000000000000 --- a/app/src/main/res/drawable/ic_help_dark.xml +++ /dev/null @@ -1,18 +0,0 @@ - - - - - diff --git a/app/src/main/res/drawable/ic_home_dark.xml b/app/src/main/res/drawable/ic_home_dark.xml deleted file mode 100644 index 2452758f0fd573fdc0f65212bf4e72a4cd4e2ee8..0000000000000000000000000000000000000000 --- a/app/src/main/res/drawable/ic_home_dark.xml +++ /dev/null @@ -1,5 +0,0 @@ - - - diff --git a/app/src/main/res/drawable/ic_launcher_foreground.xml b/app/src/main/res/drawable/ic_launcher_foreground.xml new file mode 100644 index 0000000000000000000000000000000000000000..0cae7307bcaed231a19a0847a68a39fcee1e6ad0 --- /dev/null +++ b/app/src/main/res/drawable/ic_launcher_foreground.xml @@ -0,0 +1,36 @@ + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_menu_light.xml b/app/src/main/res/drawable/ic_menu_light.xml deleted file mode 100644 index 811eef7cf341983e301c718299c13cc3159a9bc2..0000000000000000000000000000000000000000 --- a/app/src/main/res/drawable/ic_menu_light.xml +++ /dev/null @@ -1,17 +0,0 @@ - - - - - diff --git a/app/src/main/res/drawable/ic_network_wifi_dark.xml b/app/src/main/res/drawable/ic_network_wifi_dark.xml new file mode 100644 index 0000000000000000000000000000000000000000..1ad040383eb018278e7f7cd77d6e0336b7e43310 --- /dev/null +++ b/app/src/main/res/drawable/ic_network_wifi_dark.xml @@ -0,0 +1,6 @@ + + + + diff --git a/app/src/main/res/drawable/ic_notifications_dark.xml b/app/src/main/res/drawable/ic_notifications_dark.xml new file mode 100644 index 0000000000000000000000000000000000000000..a9edcb2407102b5dbb3355f25ab93967d79a1c82 --- /dev/null +++ b/app/src/main/res/drawable/ic_notifications_dark.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/ic_people_light.xml b/app/src/main/res/drawable/ic_people_light.xml deleted file mode 100644 index 7721a01c667e770601008e660dd6544ce890f702..0000000000000000000000000000000000000000 --- a/app/src/main/res/drawable/ic_people_light.xml +++ /dev/null @@ -1,17 +0,0 @@ - - - - - diff --git a/app/src/main/res/drawable/ic_settings_action.xml b/app/src/main/res/drawable/ic_settings_action.xml index 11824d3af4b6b8d19aabca988bad86b204856365..1e98e2d91c712824598eecd80729dcd43dc9cc67 100644 --- a/app/src/main/res/drawable/ic_settings_action.xml +++ b/app/src/main/res/drawable/ic_settings_action.xml @@ -1,9 +1,6 @@ - - + + + diff --git a/app/src/main/res/drawable/ic_share_action.xml b/app/src/main/res/drawable/ic_share_action.xml index d52782ea3a82ef3ecbaf9d92033d49d81adf47c2..219e8cd7e02b7b502657f0d6e9386ae37339a255 100644 --- a/app/src/main/res/drawable/ic_share_action.xml +++ b/app/src/main/res/drawable/ic_share_action.xml @@ -1,17 +1,6 @@ - - - - + + + diff --git a/app/src/main/res/drawable/ic_signal_cellular_off_dark.xml b/app/src/main/res/drawable/ic_signal_cellular_off_dark.xml new file mode 100644 index 0000000000000000000000000000000000000000..bd7da287f49181561663aad53599ae5f84795056 --- /dev/null +++ b/app/src/main/res/drawable/ic_signal_cellular_off_dark.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/ic_sync_action.xml b/app/src/main/res/drawable/ic_sync_action.xml index 9a6ec77ea227a06f0868d34c11cd45e5b696b42e..5af579d61a72e42e08b47c7653e7013805cbb4d4 100644 --- a/app/src/main/res/drawable/ic_sync_action.xml +++ b/app/src/main/res/drawable/ic_sync_action.xml @@ -1,9 +1,6 @@ - - + + + diff --git a/app/src/main/res/drawable/ic_sync_white.xml b/app/src/main/res/drawable/ic_sync_white.xml new file mode 100644 index 0000000000000000000000000000000000000000..864d97e185110dd3276669c9aed2ded56b7c6b35 --- /dev/null +++ b/app/src/main/res/drawable/ic_sync_white.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_touch_app_dark.xml b/app/src/main/res/drawable/ic_touch_app_dark.xml deleted file mode 100644 index 4451156182660f05a3268dcbf343bcd0e8f06260..0000000000000000000000000000000000000000 --- a/app/src/main/res/drawable/ic_touch_app_dark.xml +++ /dev/null @@ -1,18 +0,0 @@ - - - - - diff --git a/app/src/main/res/drawable/ic_touch_app_dark_compat.xml b/app/src/main/res/drawable/ic_touch_app_dark_compat.xml deleted file mode 100644 index 73aaa0512991335c1c371ed147f3e922705cfb65..0000000000000000000000000000000000000000 --- a/app/src/main/res/drawable/ic_touch_app_dark_compat.xml +++ /dev/null @@ -1,11 +0,0 @@ - - - - - \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_visibility_dark.xml b/app/src/main/res/drawable/ic_visibility_dark.xml deleted file mode 100644 index def11add0a3d076b33bb7b597967e96ca92b3972..0000000000000000000000000000000000000000 --- a/app/src/main/res/drawable/ic_visibility_dark.xml +++ /dev/null @@ -1,5 +0,0 @@ - - - diff --git a/app/src/main/res/drawable/ic_visibility_off_dark.xml b/app/src/main/res/drawable/ic_visibility_off_dark.xml deleted file mode 100644 index a73d6154c900ddf7dff1c350725c1c8de14de09e..0000000000000000000000000000000000000000 --- a/app/src/main/res/drawable/ic_visibility_off_dark.xml +++ /dev/null @@ -1,5 +0,0 @@ - - - diff --git a/app/src/main/res/drawable/text_color.xml b/app/src/main/res/drawable/text_color.xml deleted file mode 100644 index bc109747fb73579a9844c5295876938e97ea7ae8..0000000000000000000000000000000000000000 --- a/app/src/main/res/drawable/text_color.xml +++ /dev/null @@ -1,13 +0,0 @@ - - - - - - - \ No newline at end of file diff --git a/app/src/main/res/drawable/twitter.xml b/app/src/main/res/drawable/twitter.xml deleted file mode 100644 index 9fc528b68d6b2908f905002dd10ab5c4a9ed8e21..0000000000000000000000000000000000000000 --- a/app/src/main/res/drawable/twitter.xml +++ /dev/null @@ -1,33 +0,0 @@ - - - - - - - - - \ No newline at end of file diff --git a/app/src/main/res/layout/about_davdroid.xml b/app/src/main/res/layout/about.xml similarity index 91% rename from app/src/main/res/layout/about_davdroid.xml rename to app/src/main/res/layout/about.xml index e7c41e7171b19034c6ca9c26be456d8a51c94ae9..71c6a80ea8ac8bfd2a135a1783eaf426857429c1 100644 --- a/app/src/main/res/layout/about_davdroid.xml +++ b/app/src/main/res/layout/about.xml @@ -11,6 +11,7 @@ xmlns:tools="http://schemas.android.com/tools" android:layout_width="match_parent" android:layout_height="match_parent" + xmlns:app="http://schemas.android.com/apk/res-auto" android:gravity="center_horizontal"> - - \ No newline at end of file + diff --git a/app/src/main/res/layout/account_caldav_item.xml b/app/src/main/res/layout/account_caldav_item.xml index 750cc1fe3f3ec1ca050472bc0b55016f0689314b..f49e7ac53eaced39aa405f1e383cc5dc2e07a46d 100644 --- a/app/src/main/res/layout/account_caldav_item.xml +++ b/app/src/main/res/layout/account_caldav_item.xml @@ -32,7 +32,7 @@ android:orientation="horizontal"> - + \ No newline at end of file diff --git a/app/src/main/res/layout/account_carddav_item.xml b/app/src/main/res/layout/account_carddav_item.xml index 0eead0ed76df10043ea17f03f1691ac7fe587908..9cdf098a301d0f6739775eddd06543454612100d 100644 --- a/app/src/main/res/layout/account_carddav_item.xml +++ b/app/src/main/res/layout/account_carddav_item.xml @@ -19,13 +19,13 @@ android:gravity="center_vertical"> + android:clickable="false" /> + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/account_list.xml b/app/src/main/res/layout/account_list.xml index ae144a8c8a140555bd9a976c9ce8d28ca66d14c3..f3cc7aa895778c2e1288b750158c38eb9b1d29ba 100644 --- a/app/src/main/res/layout/account_list.xml +++ b/app/src/main/res/layout/account_list.xml @@ -10,10 +10,22 @@ + + - - + \ No newline at end of file diff --git a/app/src/main/res/layout/accounts_content.xml b/app/src/main/res/layout/accounts_content.xml index 62546ae84786ab965333b18932e74664ec9e9cce..02cbe4d55719cd9f8b09c7740d3de1f1fdad7b92 100644 --- a/app/src/main/res/layout/accounts_content.xml +++ b/app/src/main/res/layout/accounts_content.xml @@ -7,43 +7,44 @@ ~ http://www.gnu.org/licenses/gpl.html --> - + tools:context="foundation.e.accountmanager.ui.AccountsActivity"> - - + android:layout_height="?attr/actionBarSize"/> - + + app:layout_behavior="com.google.android.material.appbar.AppBarLayout$ScrollingViewBehavior"/> - + android:tooltipText="@string/login_create_account" + app:srcCompat="@drawable/ic_add_white"/> - + diff --git a/app/src/main/res/layout/activity_about.xml b/app/src/main/res/layout/activity_about.xml index d8fc57fb9b67d8daaa2e7dc2a39460b0eca95ae5..29af333ac1b7354439b9592c3a908b3132550064 100644 --- a/app/src/main/res/layout/activity_about.xml +++ b/app/src/main/res/layout/activity_about.xml @@ -7,36 +7,35 @@ ~ http://www.gnu.org/licenses/gpl.html --> - + - - + android:layout_height="?attr/actionBarSize" + app:popupTheme="?attr/actionBarPopupTheme" /> - + android:layout_height="wrap_content" + style="@style/Widget.MaterialComponents.TabLayout.Colored" /> - + - + app:layout_behavior="com.google.android.material.appbar.AppBarLayout$ScrollingViewBehavior"/> - + \ No newline at end of file diff --git a/app/src/main/res/layout/activity_account.xml b/app/src/main/res/layout/activity_account.xml index 5d2ed170145f1f3d4da2ed69922d85885b64daca..5752291c1a4325b4b661138ce13045310fa3683d 100644 --- a/app/src/main/res/layout/activity_account.xml +++ b/app/src/main/res/layout/activity_account.xml @@ -1,149 +1,51 @@ - - - - - - - - - + + + + + android:theme="?attr/actionBarTheme"> - - - - - - - - - - - - - + android:layout_height="?attr/actionBarSize" + app:popupTheme="?attr/actionBarPopupTheme" /> - - - - - + style="@style/Widget.MaterialComponents.TabLayout.Colored" /> - + - - - - - - - - - - - - - - - + app:layout_behavior="android.support.design.widget.AppBarLayout$ScrollingViewBehavior" /> - + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/activity_accounts.xml b/app/src/main/res/layout/activity_accounts.xml index 8d000c4cbdfd5aa7ca273bd1feacfd91e55f5ffa..977ecaf9fbaeb082080e5747ff1ad6dc7cda6d57 100644 --- a/app/src/main/res/layout/activity_accounts.xml +++ b/app/src/main/res/layout/activity_accounts.xml @@ -7,7 +7,7 @@ ~ http://www.gnu.org/licenses/gpl.html --> - - - + diff --git a/app/src/main/res/layout/activity_create_address_book.xml b/app/src/main/res/layout/activity_create_address_book.xml index 05e6dcf30bc37015483cee44edf3246c00015966..0b9b7e3d5be7814c678e9fa60c11c2ec04f4f07c 100644 --- a/app/src/main/res/layout/activity_create_address_book.xml +++ b/app/src/main/res/layout/activity_create_address_book.xml @@ -7,55 +7,78 @@ ~ http://www.gnu.org/licenses/gpl.html --> - - - - - - - - - - - - - - + + + + + + + + + + android:layout_height="wrap_content"> + + + + + + + + + + + + + + - - \ No newline at end of file + \ No newline at end of file diff --git a/app/src/main/res/layout/activity_create_calendar.xml b/app/src/main/res/layout/activity_create_calendar.xml index c532dab71ecd3db758757404e899e6233b7a28e7..29e75363b59c5828f7457c65ce5fd477b2a57cc4 100644 --- a/app/src/main/res/layout/activity_create_calendar.xml +++ b/app/src/main/res/layout/activity_create_calendar.xml @@ -7,118 +7,143 @@ ~ http://www.gnu.org/licenses/gpl.html --> - - - - - - - - - - - + + + + + + + + + - - - + android:layout_height="wrap_content"> - + + + + android:layout_marginLeft="8dp" + android:background="@{model.color}" + android:contentDescription="@string/create_collection_color" + app:layout_constraintStart_toEndOf="@id/display_name" + app:layout_constraintEnd_toEndOf="parent" + app:layout_constraintTop_toTopOf="@id/display_name" + app:layout_constraintBottom_toBottomOf="@id/display_name"/> + + + + - - - - - - - - - - - + + android:adapter="@{model.homeSets}" + android:selectedItemPosition="@={model.idxHomeSet}" + app:layout_constraintStart_toStartOf="parent" + app:layout_constraintTop_toBottomOf="@+id/homesets_title" /> - + + android:text="@={model.timezone}" + app:error="@{model.timezoneError}" + app:layout_constraintTop_toBottomOf="@+id/timezone_title" + app:layout_constraintStart_toStartOf="parent"/> - + - - - - - - \ No newline at end of file + app:flexWrap="wrap" + app:layout_constraintStart_toStartOf="parent" + app:layout_constraintTop_toBottomOf="@id/type"> + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/activity_debug_info.xml b/app/src/main/res/layout/activity_debug_info.xml index cdfc24c48d53f7355d00f48c82e63f392caaa9b5..4b2709e9e5baf73865deeb516abb4721629b565b 100644 --- a/app/src/main/res/layout/activity_debug_info.xml +++ b/app/src/main/res/layout/activity_debug_info.xml @@ -7,18 +7,45 @@ ~ http://www.gnu.org/licenses/gpl.html --> - + + + + + + + + + + - - - - \ No newline at end of file + android:layout_height="wrap_content"> + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/collection_properties.xml b/app/src/main/res/layout/collection_properties.xml index 860d54aeaaad72f52413ab32990f8eca3fe17c1d..1b4031beabbdf83ffe0b1e5b103f4b449f0b0cd5 100644 --- a/app/src/main/res/layout/collection_properties.xml +++ b/app/src/main/res/layout/collection_properties.xml @@ -7,32 +7,51 @@ ~ http://www.gnu.org/licenses/gpl.html --> - + - + + + - + - + android:orientation="vertical" + android:padding="10dp"> + + + + + + - + - \ No newline at end of file + + \ No newline at end of file diff --git a/app/src/main/res/layout/create_collection.xml b/app/src/main/res/layout/create_collection.xml new file mode 100644 index 0000000000000000000000000000000000000000..05323b1ce231e808dd5cb9fc1b36020cf756268d --- /dev/null +++ b/app/src/main/res/layout/create_collection.xml @@ -0,0 +1,22 @@ + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/delete_collection.xml b/app/src/main/res/layout/delete_collection.xml new file mode 100644 index 0000000000000000000000000000000000000000..784dcd1d5cb7a3c4cf00b1d2116056a4166da05c --- /dev/null +++ b/app/src/main/res/layout/delete_collection.xml @@ -0,0 +1,80 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/detect_configuration.xml b/app/src/main/res/layout/detect_configuration.xml new file mode 100644 index 0000000000000000000000000000000000000000..179d88e97df78b0d3731de605b56c2420d5e40ba --- /dev/null +++ b/app/src/main/res/layout/detect_configuration.xml @@ -0,0 +1,29 @@ + + + + + + + + + + + diff --git a/app/src/main/res/layout/fragment_eelo_authenticator.xml b/app/src/main/res/layout/fragment_eelo_authenticator.xml new file mode 100644 index 0000000000000000000000000000000000000000..dc2cc766a1de90878a0cc814fcc959a04b90fc17 --- /dev/null +++ b/app/src/main/res/layout/fragment_eelo_authenticator.xml @@ -0,0 +1,170 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +