diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index abd6190902776ab41154eab057010ff082d94660..6138883c0aea252ee032b5ad6eaf435489dd57d6 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -35,5 +35,5 @@ jobs: uses: softprops/action-gh-release@v0.1.14 with: prerelease: ${{ contains(github.ref_name, '-alpha') || contains(github.ref_name, '-beta') || contains(github.ref_name, '-rc') }} - files: app/build/outputs/apk/standard/release/*.apk + files: app/build/outputs/apk/ose/release/*.apk fail_on_unmatched_files: true diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml new file mode 100644 index 0000000000000000000000000000000000000000..9f2435a4da4a1ca8c30508d0463a2c107f7cb234 --- /dev/null +++ b/.gitlab-ci.yml @@ -0,0 +1,24 @@ +image: "registry.gitlab.e.foundation/e/os/docker-android-apps-cicd:latest" + +stages: + - build + +before_script: + - git submodule sync + - git submodule update --init --recursive --force + - echo email.key $PEPPER >> local.properties + - export GRADLE_USER_HOME=$(pwd)/.gradle + - chmod +x ./gradlew + +cache: + key: ${CI_PROJECT_ID} + paths: + - .gradle/ + +build: + stage: build + script: + - ./gradlew build -x test + artifacts: + paths: + - app/build/outputs/apk/ose/ diff --git a/.gitmodules b/.gitmodules index 6a6b94f2b08376fabf4aa0847f656dafe62879e4..b23c031a137895f2bdabe62599aa62cc69e61547 100644 --- a/.gitmodules +++ b/.gitmodules @@ -1,9 +1,14 @@ [submodule "ical4android"] path = ical4android - url = https://github.com/bitfireAT/ical4android.git + url = https://gitlab.e.foundation/e/os/ical4android.git + branch = main [submodule "vcard4android"] path = vcard4android url = https://github.com/bitfireAT/vcard4android.git [submodule "cert4android"] path = cert4android url = https://github.com/bitfireAT/cert4android.git +[submodule "dav4android"] + path = dav4android + url = https://gitlab.e.foundation/e/apps/dav4android.git + branch = main diff --git a/AUTHORS b/AUTHORS index 9c6461d513671d2b33dac50f9123b373191216c8..990d9f6a50eb5d399e06badc9e75fe85d4a306d2 100644 --- a/AUTHORS +++ b/AUTHORS @@ -6,6 +6,16 @@ Ricki Hirner (bitfire.at) Bernhard Stockmann (bitfire.at) - Sunik Kupfer (bitfire.at) Patrick Lang (techbee.at) + +# This is the list of significant contributors to AccountManager. +# +# This does not necessarily list everyone who has contributed work. +# To see the full list of contributors, see the revision history in +# source control. + +© 2018-2019 - Author: Nihar Thakkar +© 2018-2022 - Author: Vincent Bourgmayer (murena) +© 2018-2022 - Author: Romain Hunault (murena) +© 2021-2022 - Author: Fahim Salam Chowdhury (murena) diff --git a/README.md b/README.md index eb83a4164ca26e3eeeafa8b81b83c7f0419c4ffa..af2f68477b798c6a35d1ae1673c5c1f40aba8fec 100644 --- a/README.md +++ b/README.md @@ -1,15 +1,7 @@ +![accountManager logo](app/src/main/res/mipmap-xxxhdpi/ic_launcher.png) +Account Manager -[![Website](https://img.shields.io/website?style=flat-square&up_color=%237cb342&url=https%3A%2F%2Fwww.davx5.com)](https://www.davx5.com/) -[![Twitter](https://img.shields.io/twitter/follow/davx5app?color=%237cb342&label=%40davx5app&style=flat-square)](https://twitter.com/davx5app) -[![F-Droid](https://img.shields.io/f-droid/v/at.bitfire.davdroid?style=flat-square)](https://f-droid.org/packages/at.bitfire.davdroid/) -[![License](https://img.shields.io/github/license/bitfireAT/davx5-ose?style=flat-square)](https://github.com/bitfireAT/davx5-ose/blob/main/LICENSE) -[![Development tests](https://github.com/bitfireAT/davx5-ose/actions/workflows/test-dev.yml/badge.svg)](https://github.com/bitfireAT/davx5-ose/actions/workflows/test-dev.yml) - -![DAVx⁵ logo](app/src/main/res/mipmap-xxxhdpi/ic_launcher.png) - - -DAVx⁵ -======== +Account Manager is a fork of DAVx⁵. Please see the [DAVx⁵ Web site](https://www.davx5.com) for comprehensive information about DAVx⁵. diff --git a/app/build.gradle b/app/build.gradle index b0970d16e7936993d3ad9a76f256b5a1d5f25995..93cd7f8931a42df802b481ee8b7a1d6a687bfbb8 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -6,25 +6,31 @@ apply plugin: 'com.android.application' apply plugin: 'com.mikepenz.aboutlibraries.plugin' apply plugin: 'dagger.hilt.android.plugin' apply plugin: 'kotlin-android' +apply plugin: 'kotlin-android-extensions' apply plugin: 'kotlin-kapt' android { compileSdkVersion 32 buildToolsVersion '32.0.0' + aaptOptions { + additionalParameters '-I', 'e-ui-sdk.jar' + } + defaultConfig { - applicationId "at.bitfire.davdroid" + applicationId "foundation.e.accountmanager" - versionCode 402020000 - versionName '4.2.2' + versionCode 402030300 + versionName '4.2.3.3' buildConfigField "long", "buildTime", System.currentTimeMillis() + "L" setProperty "archivesBaseName", "davx5-ose-" + getVersionName() - minSdkVersion 21 // Android 5 + minSdkVersion 24 // Android 7.1 targetSdkVersion 32 // Android 12v2 buildConfigField "String", "userAgent", "\"DAVx5\"" + buildConfigField "String", "EMAIL_KEY", "\"${emailKey()}\"" testInstrumentationRunner "at.bitfire.davdroid.CustomTestRunner" @@ -53,7 +59,7 @@ android { flavorDimensions "distribution" productFlavors { - standard { + ose { versionNameSuffix "-ose" } } @@ -79,8 +85,6 @@ android { proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules-release.pro' shrinkResources true - - signingConfig signingConfigs.bitfire } } @@ -93,12 +97,21 @@ android { excludes += ['META-INF/*.md'] } } + + defaultConfig { + manifestPlaceholders = [ + 'appAuthRedirectScheme': 'net.openid.appauthdemo' + ] + } } dependencies { + compileOnly files("../e-ui-sdk.jar") + implementation project(':cert4android') implementation project(':ical4android') implementation project(':vcard4android') + implementation project(':dav4android') implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:${versions.kotlin}" implementation "org.jetbrains.kotlinx:kotlinx-coroutines-core:1.6.1" @@ -107,12 +120,12 @@ dependencies { implementation "com.google.dagger:hilt-android:${versions.hilt}" kapt "com.google.dagger:hilt-android-compiler:${versions.hilt}" - implementation 'androidx.appcompat:appcompat:1.4.2' + implementation 'androidx.appcompat:appcompat:1.5.0' implementation 'androidx.browser:browser:1.4.0' implementation 'androidx.cardview:cardview:1.0.0' implementation 'androidx.constraintlayout:constraintlayout:2.1.4' implementation 'androidx.core:core-ktx:1.8.0' - implementation 'androidx.fragment:fragment-ktx:1.5.1' + implementation 'androidx.fragment:fragment-ktx:1.5.2' implementation 'androidx.lifecycle:lifecycle-extensions:2.2.0' implementation 'androidx.lifecycle:lifecycle-viewmodel-ktx:2.5.1' implementation 'androidx.paging:paging-runtime-ktx:3.1.1' @@ -120,6 +133,7 @@ dependencies { implementation 'androidx.security:security-crypto:1.1.0-alpha03' implementation 'androidx.swiperefreshlayout:swiperefreshlayout:1.1.0' implementation 'com.google.android.flexbox:flexbox:3.0.0' + implementation 'androidx.recyclerview:recyclerview:1.2.1' implementation 'com.google.android.material:material:1.6.1' def room_version = '2.4.3' @@ -131,14 +145,11 @@ dependencies { implementation 'com.jaredrummler:colorpicker:1.1.0' implementation "com.github.AppIntro:AppIntro:${versions.appIntro}" - implementation("com.github.bitfireAT:dav4jvm:${versions.dav4jvm}") { - exclude group: 'junit' - } implementation "com.mikepenz:aboutlibraries:${versions.aboutLibraries}" implementation "com.squareup.okhttp3:okhttp:${versions.okhttp}" implementation "com.squareup.okhttp3:okhttp-brotli:${versions.okhttp}" implementation "com.squareup.okhttp3:logging-interceptor:${versions.okhttp}" - implementation 'commons-io:commons-io:2.11.0' + implementation 'commons-io:commons-io:2.8.0' // don't update until API level 26 (Android 8) is the minimum API (DAVx5#130) //noinspection GradleDependency - dnsjava 3+ needs Java 8/Android 7 implementation 'dnsjava:dnsjava:2.1.9' //noinspection GradleDependency @@ -147,6 +158,8 @@ dependencies { implementation "org.apache.commons:commons-lang3:${versions.commonsLang}" //noinspection GradleDependency implementation "org.apache.commons:commons-text:${versions.commonsText}" + implementation 'net.openid:appauth:0.11.1' + implementation 'junit:junit:4.13.2' // for tests androidTestImplementation "com.google.dagger:hilt-android-testing:${versions.hilt}" @@ -158,8 +171,16 @@ dependencies { androidTestImplementation 'androidx.test.ext:junit-ktx:1.1.3' androidTestImplementation "com.squareup.okhttp3:mockwebserver:${versions.okhttp}" androidTestImplementation 'io.mockk:mockk-android:1.12.3' - androidTestImplementation 'junit:junit:4.13.2' testImplementation "com.squareup.okhttp3:mockwebserver:${versions.okhttp}" - testImplementation 'junit:junit:4.13.2' +} + +def emailKey() { + Properties properties = new Properties() + try { + properties.load(project.rootProject.file('local.properties').newDataInputStream()) + } catch (ignored) { + // Ignore + } + return properties.getProperty("email.key", "invalid") } diff --git a/app/schemas/at.bitfire.davdroid.db.AppDatabase/11.json b/app/schemas/at.bitfire.davdroid.db.AppDatabase/11.json index 77f44ee87b79fc3e6b5a9c7baf54f05e14c6e6db..d34f2da1c44141fc6c46af81359ddb541835bf42 100644 --- a/app/schemas/at.bitfire.davdroid.db.AppDatabase/11.json +++ b/app/schemas/at.bitfire.davdroid.db.AppDatabase/11.json @@ -2,11 +2,11 @@ "formatVersion": 1, "database": { "version": 11, - "identityHash": "223aa7f0fd53730921ca212a663585d8", + "identityHash": "f9b5aba8e529d0a97714784626add644", "entities": [ { "tableName": "service", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `accountName` TEXT NOT NULL, `type` TEXT NOT NULL, `principal` TEXT)", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `accountName` TEXT NOT NULL, `authState` TEXT, `accountType` TEXT, `addressBookAccountType` TEXT, `type` TEXT NOT NULL, `principal` TEXT)", "fields": [ { "fieldPath": "id", @@ -20,6 +20,24 @@ "affinity": "TEXT", "notNull": true }, + { + "fieldPath": "authState", + "columnName": "authState", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "accountType", + "columnName": "accountType", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "addressBookAccountType", + "columnName": "addressBookAccountType", + "affinity": "TEXT", + "notNull": false + }, { "fieldPath": "type", "columnName": "type", @@ -47,6 +65,7 @@ "accountName", "type" ], + "orders": [], "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_service_accountName_type` ON `${TABLE_NAME}` (`accountName`, `type`)" } ], @@ -107,6 +126,7 @@ "serviceId", "url" ], + "orders": [], "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_homeset_serviceId_url` ON `${TABLE_NAME}` (`serviceId`, `url`)" } ], @@ -251,6 +271,7 @@ "serviceId", "type" ], + "orders": [], "createSql": "CREATE INDEX IF NOT EXISTS `index_collection_serviceId_type` ON `${TABLE_NAME}` (`serviceId`, `type`)" }, { @@ -260,6 +281,7 @@ "homeSetId", "type" ], + "orders": [], "createSql": "CREATE INDEX IF NOT EXISTS `index_collection_homeSetId_type` ON `${TABLE_NAME}` (`homeSetId`, `type`)" }, { @@ -268,6 +290,7 @@ "columnNames": [ "url" ], + "orders": [], "createSql": "CREATE INDEX IF NOT EXISTS `index_collection_url` ON `${TABLE_NAME}` (`url`)" } ], @@ -339,6 +362,7 @@ "collectionId", "authority" ], + "orders": [], "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_syncstats_collectionId_authority` ON `${TABLE_NAME}` (`collectionId`, `authority`)" } ], @@ -466,6 +490,7 @@ "parentId", "name" ], + "orders": [], "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_webdav_document_mountId_parentId_name` ON `${TABLE_NAME}` (`mountId`, `parentId`, `name`)" } ], @@ -530,7 +555,7 @@ "views": [], "setupQueries": [ "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", - "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '223aa7f0fd53730921ca212a663585d8')" + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'f9b5aba8e529d0a97714784626add644')" ] } } \ No newline at end of file diff --git a/app/src/androidTest/java/at/bitfire/davdroid/InitCalendarProviderRule.kt b/app/src/androidTest/java/at/bitfire/davdroid/InitCalendarProviderRule.kt index bcbe1e1b41fc6de9fd497b2078d21deee0353c7f..89bdc46fee3a7ebd3bf7bf3d24e02853abcdfd1a 100644 --- a/app/src/androidTest/java/at/bitfire/davdroid/InitCalendarProviderRule.kt +++ b/app/src/androidTest/java/at/bitfire/davdroid/InitCalendarProviderRule.kt @@ -4,11 +4,15 @@ package at.bitfire.davdroid +import android.Manifest import android.accounts.Account import android.content.ContentUris import android.content.ContentValues +import android.os.Build import android.provider.CalendarContract +import androidx.annotation.RequiresApi import androidx.test.platform.app.InstrumentationRegistry +import androidx.test.rule.GrantPermissionRule import at.bitfire.davdroid.log.Logger import at.bitfire.davdroid.resource.LocalCalendar import at.bitfire.davdroid.resource.LocalEvent @@ -16,60 +20,77 @@ import at.bitfire.ical4android.AndroidCalendar import at.bitfire.ical4android.Event import net.fortuna.ical4j.model.property.DtStart import net.fortuna.ical4j.model.property.RRule +import org.junit.rules.RuleChain import org.junit.rules.TestRule import org.junit.runner.Description import org.junit.runners.model.Statement /** - * JUnit ClassRule which initializes the AOSP CalendarProvider - * Needed for some "flaky" tests which would otherwise only succeed on second run + * JUnit ClassRule which initializes the AOSP CalendarProvider. + * Needed for some "flaky" tests which would otherwise only succeed on second run. + * + * Currently tested on development machine (Ryzen) with Android 12 images (with/without Google Play). + * Calendar provider behaves quite randomly, so it may or may not work. If you (the reader + * if this comment) can find out on how to initialize the calendar provider so that the + * tests are reliably run after `adb shell pm clear com.android.providers.calendar`, + * please let us know! + * + * If you run tests manually, just make sure to ignore the first run after the calendar + * provider has been accessed the first time. */ -class InitCalendarProviderRule : TestRule { +class InitCalendarProviderRule private constructor(): TestRule { companion object { - private val account = Account("LocalCalendarTest", CalendarContract.ACCOUNT_TYPE_LOCAL) - private val context = InstrumentationRegistry.getInstrumentation().targetContext - private val provider = context.contentResolver.acquireContentProviderClient(CalendarContract.AUTHORITY)!! - private val uri = AndroidCalendar.create(account, provider, ContentValues()) - private val calendar = AndroidCalendar.findByID(account, provider, LocalCalendar.Factory, ContentUris.parseId(uri)) + fun getInstance() = RuleChain + .outerRule(GrantPermissionRule.grant(Manifest.permission.READ_CALENDAR, Manifest.permission.WRITE_CALENDAR)) + .around(InitCalendarProviderRule()) } override fun apply(base: Statement, description: Description): Statement { - Logger.log.info("Before test: ${description.displayName}") + Logger.log.info("Initializing calendar provider before running ${description.displayName}") + return InitCalendarProviderStatement(base) + } - Logger.log.info("Initializing CalendarProvider (InitCalendarProviderRule)") - // single event init - val normalEvent = Event().apply { - dtStart = DtStart("20220120T010203Z") - summary = "Event with 1 instance" - } - val normalLocalEvent = LocalEvent(calendar, normalEvent, null, null, null, 0) - normalLocalEvent.add() - LocalEvent.numInstances(provider, account, normalLocalEvent.id!!) + class InitCalendarProviderStatement(val base: Statement): Statement() { + + override fun evaluate() { + if (Build.VERSION.SDK_INT < 31) + Logger.log.warning("Calendar provider initialization may or may not work. See InitCalendarProviderRule") + initCalendarProvider() - // recurring event init - val recurringEvent = Event().apply { - dtStart = DtStart("20220120T010203Z") - summary = "Event over 22 years" - rRules.add(RRule("FREQ=YEARLY;UNTIL=20740119T010203Z")) // year needs to be >2074 (not supported by Android <11 Calendar Storage) + base.evaluate() } - val localRecurringEvent = LocalEvent(calendar, recurringEvent, null, null, null, 0) - localRecurringEvent.add() - LocalEvent.numInstances(provider, account, localRecurringEvent.id!!) - // Run test - Logger.log.info("Evaluating test..") - return try { - object : Statement() { - @Throws(Throwable::class) - override fun evaluate() { - base.evaluate() + private fun initCalendarProvider() { + val account = Account("LocalCalendarTest", CalendarContract.ACCOUNT_TYPE_LOCAL) + val context = InstrumentationRegistry.getInstrumentation().targetContext + val provider = context.contentResolver.acquireContentProviderClient(CalendarContract.AUTHORITY)!! + val uri = AndroidCalendar.create(account, provider, ContentValues()) + val calendar = AndroidCalendar.findByID(account, provider, LocalCalendar.Factory, ContentUris.parseId(uri)) + try { + // single event init + val normalEvent = Event().apply { + dtStart = DtStart("20220120T010203Z") + summary = "Event with 1 instance" } + val normalLocalEvent = LocalEvent(calendar, normalEvent, null, null, null, 0) + normalLocalEvent.add() + LocalEvent.numInstances(provider, account, normalLocalEvent.id!!) + + // recurring event init + val recurringEvent = Event().apply { + dtStart = DtStart("20220120T010203Z") + summary = "Event over 22 years" + rRules.add(RRule("FREQ=YEARLY;UNTIL=20740119T010203Z")) // year needs to be >2074 (not supported by Android <11 Calendar Storage) + } + val localRecurringEvent = LocalEvent(calendar, recurringEvent, null, null, null, 0) + localRecurringEvent.add() + LocalEvent.numInstances(provider, account, localRecurringEvent.id!!) + } finally { + calendar.delete() } - } finally { - Logger.log.info("After test: $description") - calendar.delete() } } -} + +} \ No newline at end of file diff --git a/app/src/androidTest/java/at/bitfire/davdroid/resource/LocalCalendarTest.kt b/app/src/androidTest/java/at/bitfire/davdroid/resource/LocalCalendarTest.kt index a92cc256e9a3a69b974671499f3f5d0fe8ca7cb4..945a180da17f8ce4521420cc8c887d276f73a9c3 100644 --- a/app/src/androidTest/java/at/bitfire/davdroid/resource/LocalCalendarTest.kt +++ b/app/src/androidTest/java/at/bitfire/davdroid/resource/LocalCalendarTest.kt @@ -31,12 +31,8 @@ class LocalCalendarTest { companion object { @JvmField - @ClassRule(order = 0) - val permissionRule = GrantPermissionRule.grant(Manifest.permission.READ_CALENDAR, Manifest.permission.WRITE_CALENDAR)!! - - @JvmField - @ClassRule(order = 1) - val initCalendarProviderRule: TestRule = InitCalendarProviderRule() + @ClassRule + val initCalendarProviderRule: TestRule = InitCalendarProviderRule.getInstance() private lateinit var provider: ContentProviderClient diff --git a/app/src/androidTest/java/at/bitfire/davdroid/resource/LocalEventTest.kt b/app/src/androidTest/java/at/bitfire/davdroid/resource/LocalEventTest.kt index e3e43bf44a2adfd47901e01d5d85106389833c0e..b76349ee06869539527b3b87b030af48c810ed55 100644 --- a/app/src/androidTest/java/at/bitfire/davdroid/resource/LocalEventTest.kt +++ b/app/src/androidTest/java/at/bitfire/davdroid/resource/LocalEventTest.kt @@ -6,7 +6,9 @@ package at.bitfire.davdroid.resource import android.Manifest import android.accounts.Account -import android.content.* +import android.content.ContentProviderClient +import android.content.ContentUris +import android.content.ContentValues import android.os.Build import android.provider.CalendarContract import android.provider.CalendarContract.ACCOUNT_TYPE_LOCAL @@ -25,17 +27,15 @@ import net.fortuna.ical4j.model.property.* import org.junit.* import org.junit.Assert.* import org.junit.rules.TestRule +import java.util.* class LocalEventTest { companion object { - @JvmField - @ClassRule(order = 0) - val permissionRule = GrantPermissionRule.grant(Manifest.permission.READ_CALENDAR, Manifest.permission.WRITE_CALENDAR)!! @JvmField - @ClassRule(order = 1) - val initCalendarProviderRule: TestRule = InitCalendarProviderRule() + @ClassRule + val initCalendarProviderRule: TestRule = InitCalendarProviderRule.getInstance() private val account = Account("LocalCalendarTest", ACCOUNT_TYPE_LOCAL) @@ -107,7 +107,7 @@ class LocalEventTest { } @Test - // Flaky, Needs rec event init of CalendarProvider + // flaky, needs InitCalendarProviderRule fun testNumDirectInstances_Recurring_LateEnd() { val event = Event().apply { dtStart = DtStart("20220120T010203Z") @@ -124,7 +124,7 @@ class LocalEventTest { } @Test - // Flaky, Needs rec event init of CalendarProvider + // flaky, needs InitCalendarProviderRule fun testNumDirectInstances_Recurring_ManyInstances() { val event = Event().apply { dtStart = DtStart("20220120T010203Z") @@ -141,7 +141,7 @@ class LocalEventTest { } @Test - // Flaky, Needs single event init of CalendarProvider + // flaky, needs InitCalendarProviderRule fun testNumDirectInstances_RecurringWithExdate() { val event = Event().apply { dtStart = DtStart(Date("20220120T010203Z")) @@ -180,7 +180,7 @@ class LocalEventTest { @Test - // Flaky, Needs single or rec event init of CalendarProvider + // flaky, needs InitCalendarProviderRule fun testNumInstances_SingleInstance() { val event = Event().apply { dtStart = DtStart("20220120T010203Z") @@ -219,7 +219,7 @@ class LocalEventTest { } @Test - // Flaky, Needs rec event init of CalendarProvider + // flaky, needs InitCalendarProviderRule fun testNumInstances_Recurring_LateEnd() { val event = Event().apply { dtStart = DtStart("20220120T010203Z") @@ -236,7 +236,7 @@ class LocalEventTest { } @Test - // Flaky, Needs rec event init of CalendarProvider + // flaky, needs InitCalendarProviderRule fun testNumInstances_Recurring_ManyInstances() { val event = Event().apply { dtStart = DtStart("20220120T010203Z") @@ -306,6 +306,89 @@ class LocalEventTest { } + @Test + fun testPrepareForUpload_NoUid() { + // create event + val event = Event().apply { + dtStart = DtStart("20220120T010203Z") + summary = "Event without uid" + } + val localEvent = LocalEvent(calendar, event, null, null, null, 0) + localEvent.add() // save it to calendar storage + + // prepare for upload - this should generate a new random uuid, returned as filename + val fileNameWithSuffix = localEvent.prepareForUpload() + val fileName = fileNameWithSuffix.removeSuffix(".ics") + + // throws an exception if fileName is not an UUID + UUID.fromString(fileName) + + // UID in calendar storage should be the same as file name + provider.query( + ContentUris.withAppendedId(Events.CONTENT_URI, localEvent.id!!).asSyncAdapter(account), + arrayOf(Events.UID_2445), null, null, null + )!!.use { cursor -> + cursor.moveToFirst() + assertEquals(fileName, cursor.getString(0)) + } + } + + @Test + fun testPrepareForUpload_NormalUid() { + // create event + val event = Event().apply { + dtStart = DtStart("20220120T010203Z") + summary = "Event with normal uid" + uid = "some-event@hostname.tld" // old UID format, UUID would be new format + } + val localEvent = LocalEvent(calendar, event, null, null, null, 0) + localEvent.add() // save it to calendar storage + + // prepare for upload - this should use the UID for the file name + val fileNameWithSuffix = localEvent.prepareForUpload() + val fileName = fileNameWithSuffix.removeSuffix(".ics") + + assertEquals(event.uid, fileName) + + // UID in calendar storage should still be set, too + provider.query( + ContentUris.withAppendedId(Events.CONTENT_URI, localEvent.id!!).asSyncAdapter(account), + arrayOf(Events.UID_2445), null, null, null + )!!.use { cursor -> + cursor.moveToFirst() + assertEquals(fileName, cursor.getString(0)) + } + } + + @Test + fun testPrepareForUpload_UidHasDangerousChars() { + // create event + val event = Event().apply { + dtStart = DtStart("20220120T010203Z") + summary = "Event with funny uid" + uid = "https://www.example.com/events/asdfewfe-cxyb-ewrws-sadfrwerxyvser-asdfxye-" + } + val localEvent = LocalEvent(calendar, event, null, null, null, 0) + localEvent.add() // save it to calendar storage + + // prepare for upload - this should generate a new random uuid, returned as filename + val fileNameWithSuffix = localEvent.prepareForUpload() + val fileName = fileNameWithSuffix.removeSuffix(".ics") + + // throws an exception if fileName is not an UUID + UUID.fromString(fileName) + + // UID in calendar storage shouldn't have been changed + provider.query( + ContentUris.withAppendedId(Events.CONTENT_URI, localEvent.id!!).asSyncAdapter(account), + arrayOf(Events.UID_2445), null, null, null + )!!.use { cursor -> + cursor.moveToFirst() + assertEquals(event.uid, cursor.getString(0)) + } + } + + @Test fun testDeleteDirtyEventsWithoutInstances_NoInstances_Exdate() { // TODO @@ -360,7 +443,6 @@ class LocalEventTest { } @Test - // Flaky, Needs single event init OR rec event init of CalendarProvider fun testDeleteDirtyEventsWithoutInstances_Recurring_Instances() { val event = Event().apply { dtStart = DtStart("20220120T010203Z") diff --git a/app/src/androidTest/java/at/bitfire/davdroid/settings/AccountSettingsTest.kt b/app/src/androidTest/java/at/bitfire/davdroid/settings/AccountSettingsTest.kt index 612f3899a26df368d32e1e3c91bb911cc29f5dcd..f2060cad3b55ca9cbf02e5b5af18fa62579b2a16 100644 --- a/app/src/androidTest/java/at/bitfire/davdroid/settings/AccountSettingsTest.kt +++ b/app/src/androidTest/java/at/bitfire/davdroid/settings/AccountSettingsTest.kt @@ -43,7 +43,7 @@ class AccountSettingsTest { fun setUp() { hiltRule.inject() - assertTrue(AccountUtils.createAccount(context, account, AccountSettings.initialUserData(fakeCredentials))) + assertTrue(AccountUtils.createAccount(context, account, AccountSettings.initialUserData(fakeCredentials, null))) ContentResolver.setIsSyncable(account, CalendarContract.AUTHORITY, 1) ContentResolver.setIsSyncable(account, ContactsContract.AUTHORITY, 0) } 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/androidTestStandard/java/at/bitfire/davdroid/OkhttpClientTest.kt b/app/src/androidTestOse/java/at/bitfire/davdroid/OkhttpClientTest.kt similarity index 100% rename from app/src/androidTestStandard/java/at/bitfire/davdroid/OkhttpClientTest.kt rename to app/src/androidTestOse/java/at/bitfire/davdroid/OkhttpClientTest.kt diff --git a/app/src/androidTestStandard/java/at/bitfire/davdroid/syncadapter/AccountUtilsTest.kt b/app/src/androidTestOse/java/at/bitfire/davdroid/syncadapter/AccountUtilsTest.kt similarity index 100% rename from app/src/androidTestStandard/java/at/bitfire/davdroid/syncadapter/AccountUtilsTest.kt rename to app/src/androidTestOse/java/at/bitfire/davdroid/syncadapter/AccountUtilsTest.kt diff --git a/app/src/androidTestStandard/java/at/bitfire/davdroid/syncadapter/LocalTestCollection.kt b/app/src/androidTestOse/java/at/bitfire/davdroid/syncadapter/LocalTestCollection.kt similarity index 100% rename from app/src/androidTestStandard/java/at/bitfire/davdroid/syncadapter/LocalTestCollection.kt rename to app/src/androidTestOse/java/at/bitfire/davdroid/syncadapter/LocalTestCollection.kt diff --git a/app/src/androidTestStandard/java/at/bitfire/davdroid/syncadapter/LocalTestResource.kt b/app/src/androidTestOse/java/at/bitfire/davdroid/syncadapter/LocalTestResource.kt similarity index 100% rename from app/src/androidTestStandard/java/at/bitfire/davdroid/syncadapter/LocalTestResource.kt rename to app/src/androidTestOse/java/at/bitfire/davdroid/syncadapter/LocalTestResource.kt diff --git a/app/src/androidTestStandard/java/at/bitfire/davdroid/syncadapter/SyncAdapterServiceTest.kt b/app/src/androidTestOse/java/at/bitfire/davdroid/syncadapter/SyncAdapterServiceTest.kt similarity index 100% rename from app/src/androidTestStandard/java/at/bitfire/davdroid/syncadapter/SyncAdapterServiceTest.kt rename to app/src/androidTestOse/java/at/bitfire/davdroid/syncadapter/SyncAdapterServiceTest.kt diff --git a/app/src/androidTestStandard/java/at/bitfire/davdroid/syncadapter/SyncAdapterTest.kt b/app/src/androidTestOse/java/at/bitfire/davdroid/syncadapter/SyncAdapterTest.kt similarity index 100% rename from app/src/androidTestStandard/java/at/bitfire/davdroid/syncadapter/SyncAdapterTest.kt rename to app/src/androidTestOse/java/at/bitfire/davdroid/syncadapter/SyncAdapterTest.kt diff --git a/app/src/androidTestStandard/java/at/bitfire/davdroid/syncadapter/SyncManagerTest.kt b/app/src/androidTestOse/java/at/bitfire/davdroid/syncadapter/SyncManagerTest.kt similarity index 99% rename from app/src/androidTestStandard/java/at/bitfire/davdroid/syncadapter/SyncManagerTest.kt rename to app/src/androidTestOse/java/at/bitfire/davdroid/syncadapter/SyncManagerTest.kt index b494110cdee3725e5ca180be7c3839a47982021b..963665489d1d06dc02bed6a4c6d5040945758106 100644 --- a/app/src/androidTestStandard/java/at/bitfire/davdroid/syncadapter/SyncManagerTest.kt +++ b/app/src/androidTestOse/java/at/bitfire/davdroid/syncadapter/SyncManagerTest.kt @@ -53,7 +53,7 @@ class SyncManagerTest { @BeforeClass @JvmStatic fun createAccount() { - assertTrue(AccountManager.get(context).addAccountExplicitly(account, "test", AccountSettings.initialUserData(Credentials("test", "test")))) + assertTrue(AccountManager.get(context).addAccountExplicitly(account, "test", AccountSettings.initialUserData(Credentials("test", "test"), null))) } @AfterClass diff --git a/app/src/androidTestStandard/java/at/bitfire/davdroid/syncadapter/TestSyncManager.kt b/app/src/androidTestOse/java/at/bitfire/davdroid/syncadapter/TestSyncManager.kt similarity index 100% rename from app/src/androidTestStandard/java/at/bitfire/davdroid/syncadapter/TestSyncManager.kt rename to app/src/androidTestOse/java/at/bitfire/davdroid/syncadapter/TestSyncManager.kt diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 957d808596442ee0805a85ab42faad734f3f211a..ae49a5f558c00b6b850dbadbc48dc653ef566844 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -53,7 +53,18 @@ - + + + + + + + + - + @@ -104,6 +115,7 @@ android:name=".ui.setup.LoginActivity" android:label="@string/login_title" android:parentActivityName=".ui.AccountsActivity" + android:excludeFromRecents="true" android:exported="true"> @@ -259,8 +271,284 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/ic_launcher-web.png b/app/src/main/ic_launcher-web.png index 4cc81409080c7754124235d4d31c11ce7a8434f0..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/Constants.kt b/app/src/main/java/at/bitfire/davdroid/Constants.kt index 8e6c59bc981c5ef5bdb768df26f0ad1c1c40751b..ef5d0960dd99d5f447e64a5efa74c4ee180822f8 100644 --- a/app/src/main/java/at/bitfire/davdroid/Constants.kt +++ b/app/src/main/java/at/bitfire/davdroid/Constants.kt @@ -10,6 +10,10 @@ object Constants { // gplay billing const val BILLINGCLIENT_CONNECTION_MAX_RETRIES = 4 + // 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] @@ -24,4 +28,8 @@ object Constants { */ const val EXCEPTION_CONTEXT_REMOTE_RESOURCE = "remoteResource" + const val AUTH_TOKEN_TYPE = "oauth2-access-token" + + const val EELO_SYNC_HOST = "ecloud.global" + const val E_SYNC_URL = "e.email" } diff --git a/app/src/main/java/at/bitfire/davdroid/DavService.kt b/app/src/main/java/at/bitfire/davdroid/DavService.kt index eefa5c484e8e5bcb9bdfe117a48f8bc92f5b6d9b..50dac87f333eb664a54a4198851e1892b5073f86 100644 --- a/app/src/main/java/at/bitfire/davdroid/DavService.kt +++ b/app/src/main/java/at/bitfire/davdroid/DavService.kt @@ -29,6 +29,7 @@ import at.bitfire.davdroid.settings.SettingsManager import at.bitfire.davdroid.ui.DebugInfoActivity import at.bitfire.davdroid.ui.NotificationUtils import dagger.hilt.android.AndroidEntryPoint +import net.openid.appauth.AuthState import okhttp3.HttpUrl import okhttp3.OkHttpClient import java.lang.ref.WeakReference @@ -171,7 +172,7 @@ class DavService: IntentService("DavService") { val collectionDao = db.collectionDao() val service = db.serviceDao().get(serviceId) ?: throw IllegalArgumentException("Service not found") - val account = Account(service.accountName, getString(R.string.account_type)) + 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() @@ -190,7 +191,7 @@ class DavService: IntentService("DavService") { * @throws HttpException * @throws at.bitfire.dav4jvm.exception.DavException */ - fun queryHomeSets(client: OkHttpClient, url: HttpUrl, personal: Boolean = true) { + fun queryHomeSets(client: OkHttpClient, url: HttpUrl, accessToken: String?, personal: Boolean = true) { val related = mutableSetOf() fun findRelated(root: HttpUrl, dav: Response) { @@ -223,7 +224,7 @@ class DavService: IntentService("DavService") { } } - val dav = DavResource(client, url) + val dav = DavResource(client, url, accessToken) when (service.type) { Service.TYPE_CARDDAV -> try { @@ -270,7 +271,8 @@ class DavService: IntentService("DavService") { // query related homesets (those that do not belong to the current-user-principal) for (resource in related) - queryHomeSets(client, resource, false) + queryHomeSets(client, resource, accessToken, false) + } fun saveHomesets() { @@ -303,91 +305,145 @@ class DavService: IntentService("DavService") { HttpClient.Builder(this, AccountSettings(this, account)) .setForeground(true) .build().use { client -> - val httpClient = client.okHttpClient - - // refresh home set list (from principal) - service.principal?.let { principalUrl -> - Logger.log.fine("Querying principal $principalUrl for home sets") - queryHomeSets(httpClient, principalUrl) - } - - // now refresh homesets and their member collections - val itHomeSets = homeSets.iterator() - while (itHomeSets.hasNext()) { - val (homeSetUrl, homeSet) = itHomeSets.next() - Logger.log.fine("Listing home set $homeSetUrl") - - try { - DavResource(httpClient, homeSetUrl).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.displayName = response[DisplayName::class.java]?.displayName - homeSet.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.refHomeSet = homeSet - info.confirmed = true + val httpClient = client.okHttpClient - // whether new collections are selected for synchronization by default (controlled by managed setting) - info.sync = syncAllCollections - - info.owner = response[Owner::class.java]?.href?.let { response.href.resolve(it) } - Logger.log.log(Level.FINE, "Found collection", info) + var accessToken : String? = null + service.authState?.let { + accessToken = AuthState.jsonDeserialize(it).accessToken + } - // 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() + // refresh home set list (from principal) + service.principal?.let { principalUrl -> + Logger.log.fine("Querying principal $principalUrl for home sets") + queryHomeSets(httpClient, principalUrl, accessToken) } - } - // check/refresh unconfirmed collections - val collectionsIter = collections.entries.iterator() - while (collectionsIter.hasNext()) { - val currentCollection = collectionsIter.next() - val (url, info) = currentCollection - if (!info.confirmed) - try { - // this collection doesn't belong to a homeset anymore, otherwise it would have been confirmed - info.homeSetId = null +// 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}") - DavResource(httpClient, url).propfind(0, *DAV_COLLECTION_PROPERTIES) { response, _ -> + try { + DavResource(httpClient, homeSet.key, accessToken).propfind( + 1, + *DAV_COLLECTION_PROPERTIES + ) { response, relation -> if (!response.isSuccess()) return@propfind - val collection = Collection.fromDavResponse(response) ?: return@propfind - collection.serviceId = info.serviceId // use same service ID as previous entry - collection.confirmed = true + 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))) { + // Ignore "recently contacted" accounts since it is buggy and causes error 501 + if (!info.url.toString().contains(AccountSettings.CONTACTS_APP_INTERACTION)) { + 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() + } + } - // 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)) + // check/refresh unconfirmed collections + val collectionsIter = collections.entries.iterator() + while (collectionsIter.hasNext()) { + val currentCollection = collectionsIter.next() + val (url, info) = currentCollection + if (!info.confirmed) + try { + // this collection doesn't belong to a homeset anymore, otherwise it would have been confirmed + info.homeSetId = null + + DavResource(httpClient, url).propfind( + 0, + *DAV_COLLECTION_PROPERTIES + ) { response, _ -> + if (!response.isSuccess()) + return@propfind + + val collection = + Collection.fromDavResponse(response) ?: return@propfind + collection.serviceId = + info.serviceId // use same service ID as previous entry + 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) + ) + collectionsIter.remove() + else + // update this collection in list + currentCollection.setValue(collection) + } + } catch (e: HttpException) { + if (e.code in arrayOf(403, 404, 410)) + // delete collection only if it was not accessible (40x) collectionsIter.remove() else - // update this collection in list - currentCollection.setValue(collection) + throw e } - } catch(e: HttpException) { - if (e.code in arrayOf(403, 404, 410)) + } + + // check/refresh unconfirmed collections + val itCollections = collections.entries.iterator() + while (itCollections.hasNext()) { + val (url, info) = itCollections.next() + if (!info.confirmed) + try { + DavResource(httpClient, url, accessToken).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) - collectionsIter.remove() - else - throw e - } + itCollections.remove() + else + throw e + } + } } - } db.runInTransaction { saveHomesets() diff --git a/app/src/main/java/at/bitfire/davdroid/ECloudAccountHelper.kt b/app/src/main/java/at/bitfire/davdroid/ECloudAccountHelper.kt new file mode 100644 index 0000000000000000000000000000000000000000..2e07362eb6919cfac5e2e8a062856b8a1c079187 --- /dev/null +++ b/app/src/main/java/at/bitfire/davdroid/ECloudAccountHelper.kt @@ -0,0 +1,42 @@ +/* + * Copyright ECORP SAS 2022 + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package at.bitfire.davdroid + +import android.accounts.AccountManager +import android.app.Activity +import android.content.Context +import com.google.android.material.dialog.MaterialAlertDialogBuilder + +object ECloudAccountHelper { + + fun alreadyHasECloudAccount(context: Context) : Boolean { + val accountManager = AccountManager.get(context) + val eCloudAccounts = accountManager.getAccountsByType(context.getString(R.string.eelo_account_type)) + return eCloudAccounts.isNotEmpty() + } + + fun showMultipleECloudAccountNotAcceptedDialog(activity: Activity) { + MaterialAlertDialogBuilder(activity, R.style.CustomAlertDialogStyle) + .setIcon(R.drawable.ic_error) + .setMessage(R.string.multiple_ecloud_account_not_permitted_message) + .setCancelable(false) + .setPositiveButton(android.R.string.ok) { _, _ -> + activity.finish() + } + .show() + } +} \ No newline at end of file diff --git a/app/src/main/java/at/bitfire/davdroid/MailAccountSyncHelper.kt b/app/src/main/java/at/bitfire/davdroid/MailAccountSyncHelper.kt new file mode 100644 index 0000000000000000000000000000000000000000..42e78eb37803682661ca464ef550c3439c62e194 --- /dev/null +++ b/app/src/main/java/at/bitfire/davdroid/MailAccountSyncHelper.kt @@ -0,0 +1,53 @@ +/* + * Copyright ECORP SAS 2022 + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package at.bitfire.davdroid + +import android.content.ComponentName +import android.content.Context +import android.content.Intent + +object MailAccountSyncHelper { + + private const val MAIL_PACKAGE = "foundation.e.mail" + private const val MAIL_RECEIVER_CLASS = "com.fsck.k9.account.AccountSyncReceiver" + private const val ACTION_PREFIX = "foundation.e.accountmanager.account." + + fun accountLoggedIn(applicationContext : Context?) { + val intent = getIntent() + intent.action = ACTION_PREFIX + "create" + applicationContext?.sendBroadcast(intent) + } + + fun accountLoggedOut(applicationContext: Context?, email: String?) { + email?.let { + if (!it.contains("@")) { + return@let + } + val intent = getIntent() + intent.action = ACTION_PREFIX + "remove" + intent.putExtra("account", it) + applicationContext?.sendBroadcast(intent) + } + } + + private fun getIntent() : Intent { + val intent = Intent() + intent.addFlags(Intent.FLAG_INCLUDE_STOPPED_PACKAGES) + intent.component = ComponentName(MAIL_PACKAGE, MAIL_RECEIVER_CLASS) + return intent + } +} \ No newline at end of file diff --git a/app/src/main/java/at/bitfire/davdroid/authorization/IdentityProvider.java b/app/src/main/java/at/bitfire/davdroid/authorization/IdentityProvider.java new file mode 100644 index 0000000000000000000000000000000000000000..7277d3bdfca1ad5a021cf04b23502a6ee72bed70 --- /dev/null +++ b/app/src/main/java/at/bitfire/davdroid/authorization/IdentityProvider.java @@ -0,0 +1,236 @@ +/* + * Copyright ECORP SAS 2022 + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package at.bitfire.davdroid.authorization; + +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 net.openid.appauth.AuthorizationServiceConfiguration; +import net.openid.appauth.AuthorizationServiceConfiguration.RetrieveConfigurationCallback; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; + +import at.bitfire.davdroid.R; + +/** + * 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/db/AppDatabase.kt b/app/src/main/java/at/bitfire/davdroid/db/AppDatabase.kt index fc56d4c69a723ddb8ba94a2a0291ac4b5359230b..4e3e9480c86b405321538101756a3400c28e0edb 100644 --- a/app/src/main/java/at/bitfire/davdroid/db/AppDatabase.kt +++ b/app/src/main/java/at/bitfire/davdroid/db/AppDatabase.kt @@ -37,8 +37,7 @@ import javax.inject.Singleton WebDavDocument::class, WebDavMount::class ], exportSchema = true, version = 11, autoMigrations = [ - AutoMigration(from = 9, to = 10), - AutoMigration(from = 10, to = 11) + AutoMigration(from = 9, to = 10) ]) @TypeConverters(Converters::class) abstract class AppDatabase: RoomDatabase() { @@ -80,6 +79,13 @@ abstract class AppDatabase: RoomDatabase() { // migrations val migrations: Array = arrayOf( + object : Migration(10, 11) { + override fun migrate(database: SupportSQLiteDatabase) { + database.execSQL("CREATE TABLE IF NOT EXISTS `webdav_document` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `mountId` INTEGER NOT NULL, `parentId` INTEGER, `name` TEXT NOT NULL, `isDirectory` INTEGER NOT NULL, `displayName` TEXT, `mimeType` TEXT, `eTag` TEXT, `lastModified` INTEGER, `size` INTEGER, `mayBind` INTEGER, `mayUnbind` INTEGER, `mayWriteContent` INTEGER, `quotaAvailable` INTEGER, `quotaUsed` INTEGER, FOREIGN KEY(`mountId`) REFERENCES `webdav_mount`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`parentId`) REFERENCES `webdav_document`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )") + database.execSQL("CREATE UNIQUE INDEX IF NOT EXISTS `index_webdav_document_mountId_parentId_name` ON `webdav_document` (`mountId`, `parentId`, `name`)") + } + }, + object : Migration(8, 9) { override fun migrate(db: SupportSQLiteDatabase) { db.execSQL("CREATE TABLE syncstats (" + @@ -116,11 +122,14 @@ abstract class AppDatabase: RoomDatabase() { "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, type, principal) SELECT _id, accountName, service, principal FROM services", + "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 diff --git a/app/src/main/java/at/bitfire/davdroid/db/Collection.kt b/app/src/main/java/at/bitfire/davdroid/db/Collection.kt index eee29ed14a87e531c9e0a466a21250aa5c7f0019..213f54849a32e9d9da506d21ea8c16892497d296 100644 --- a/app/src/main/java/at/bitfire/davdroid/db/Collection.kt +++ b/app/src/main/java/at/bitfire/davdroid/db/Collection.kt @@ -61,7 +61,7 @@ data class Collection( var source: HttpUrl? = null, /** whether this collection has been selected for synchronization */ - var sync: Boolean = false + var sync: Boolean = true ): IdEntity { @@ -171,4 +171,4 @@ data class Collection( fun title() = displayName ?: DavUtils.lastSegmentOfUrl(url) fun readOnly() = forceReadOnly || !privWriteContent -} \ No newline at end of file +} diff --git a/app/src/main/java/at/bitfire/davdroid/db/Credentials.kt b/app/src/main/java/at/bitfire/davdroid/db/Credentials.kt index f2f4758b1185a3000dd4896e43b2c498ec182ad8..45af127502199a0b2b481a617f134c592f3e8c61 100644 --- a/app/src/main/java/at/bitfire/davdroid/db/Credentials.kt +++ b/app/src/main/java/at/bitfire/davdroid/db/Credentials.kt @@ -4,10 +4,15 @@ package at.bitfire.davdroid.db +import net.openid.appauth.AuthState +import java.net.URI + data class Credentials( val userName: String? = null, val password: String? = null, - val certificateAlias: String? = null + val authState: AuthState? = null, + val certificateAlias: String? = null, + val serverUri: URI? = null ) { override fun toString(): String { diff --git a/app/src/main/java/at/bitfire/davdroid/db/Service.kt b/app/src/main/java/at/bitfire/davdroid/db/Service.kt index 0287f6c215042fa5ceaf7b45194ee2ac4b68af70..1e757519a41ae48e23c4acaf27328429ca9a7ceb 100644 --- a/app/src/main/java/at/bitfire/davdroid/db/Service.kt +++ b/app/src/main/java/at/bitfire/davdroid/db/Service.kt @@ -19,6 +19,11 @@ data class Service( override var id: Long, var accountName: String, + + var authState: String?, + var accountType: String?, + var addressBookAccountType: String?, + var type: String, var principal: HttpUrl? diff --git a/app/src/main/java/at/bitfire/davdroid/db/ServiceDao.kt b/app/src/main/java/at/bitfire/davdroid/db/ServiceDao.kt index 62ed226c9f3e28c9214aea99d34e7824bb2bf90e..d45bb779dc6348fa86a9795f08ffd75f3f117f4c 100644 --- a/app/src/main/java/at/bitfire/davdroid/db/ServiceDao.kt +++ b/app/src/main/java/at/bitfire/davdroid/db/ServiceDao.kt @@ -22,6 +22,10 @@ interface ServiceDao { @Query("SELECT * FROM service WHERE id=:id") fun get(id: Long): Service? + + @Query("SELECT * FROM service WHERE accountName=:accountName") + fun getByAccountName(accountName: String): Service? + @Insert(onConflict = OnConflictStrategy.REPLACE) fun insertOrReplace(service: Service): Long diff --git a/app/src/main/java/at/bitfire/davdroid/resource/LocalAddressBook.kt b/app/src/main/java/at/bitfire/davdroid/resource/LocalAddressBook.kt index eba09f8173e75611d57a8ad2907bacd10a069307..98076add8106154013d729f71b3a44848bcdacca 100644 --- a/app/src/main/java/at/bitfire/davdroid/resource/LocalAddressBook.kt +++ b/app/src/main/java/at/bitfire/davdroid/resource/LocalAddressBook.kt @@ -8,6 +8,8 @@ import android.accounts.AccountManager import android.content.* import android.os.Build import android.os.Bundle +import android.os.Handler +import android.os.Looper import android.os.RemoteException import android.provider.ContactsContract import android.provider.ContactsContract.CommonDataKinds.GroupMembership @@ -16,6 +18,7 @@ import android.provider.ContactsContract.RawContacts import android.util.Base64 import at.bitfire.davdroid.DavUtils import at.bitfire.davdroid.R +import at.bitfire.davdroid.db.AppDatabase import at.bitfire.davdroid.db.Collection import at.bitfire.davdroid.db.SyncState import at.bitfire.davdroid.log.Logger @@ -23,6 +26,8 @@ import at.bitfire.davdroid.settings.AccountSettings import at.bitfire.davdroid.syncadapter.AccountUtils import at.bitfire.davdroid.syncadapter.SyncUtils import at.bitfire.vcard4android.* +import at.bitfire.davdroid.MailAccountSyncHelper + import java.io.ByteArrayOutputStream import java.util.* import java.util.logging.Level @@ -46,8 +51,10 @@ open 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: Collection): LocalAddressBook { - val account = Account(accountName(mainAccount, info), context.getString(R.string.account_type_address_book)) + fun create(context: Context, db: AppDatabase, provider: ContentProviderClient, mainAccount: Account, info: Collection): LocalAddressBook { + val service = db.serviceDao().getByAccountName(mainAccount.name) ?: throw IllegalArgumentException("Service not found") + val 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 (!AccountUtils.createAccount(context, account, userData)) @@ -65,18 +72,18 @@ open class LocalAddressBook( 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) } - .filter { - try { - it.mainAccount == mainAccount - } catch(e: IllegalStateException) { - false - } - } + return accounts.toTypedArray().map { LocalAddressBook(context, it, provider) } + .filter { mainAccount == null || it.mainAccount == mainAccount } .toList() + } fun accountName(mainAccount: Account, info: Collection): String { val baos = ByteArrayOutputStream() @@ -102,7 +109,10 @@ open 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) val accountName = manager.getUserData(account, USER_DATA_MAIN_ACCOUNT_NAME) val accountType = manager.getUserData(account, USER_DATA_MAIN_ACCOUNT_TYPE) @@ -233,11 +243,43 @@ open class LocalAddressBook( fun delete() { val accountManager = AccountManager.get(context) + val email = accountManager.getUserData(account, AccountSettings.KEY_EMAIL_ADDRESS) + @Suppress("DEPRECATION") - if (Build.VERSION.SDK_INT >= 22) - accountManager.removeAccount(account, null, null, null) - else - accountManager.removeAccount(account, null, null) + if (Build.VERSION.SDK_INT >= 22) { + removeAccount(accountManager, email) + } + else { + removeAccountForOlderSdk(accountManager, email) + } + } + + private fun removeAccountForOlderSdk(accountManager: AccountManager, email: String?) { + accountManager.removeAccount(account, { + try { + if (it.result) { + Handler(Looper.getMainLooper()).post { + MailAccountSyncHelper.accountLoggedOut(context.applicationContext, email) + } + } + } catch (e: Exception) { + Logger.log.log(Level.SEVERE, "Couldn't remove account", e) + } + }, null) + } + + private fun removeAccount(accountManager: AccountManager, email: String?) { + accountManager.removeAccount(account, null, { + try { + if (it.result.getBoolean(AccountManager.KEY_BOOLEAN_RESULT)) { + Handler(Looper.getMainLooper()).post { + MailAccountSyncHelper.accountLoggedOut(context.applicationContext, email) + } + } + } catch (e: Exception) { + Logger.log.log(Level.SEVERE, "Couldn't remove account", e) + } + }, null) } @@ -369,6 +411,7 @@ open class LocalAddressBook( * @return id of the group with given title * @throws RemoteException on content provider errors */ + @Synchronized fun findOrCreateGroup(title: String): Long { provider!!.query(syncAdapterURI(Groups.CONTENT_URI), arrayOf(Groups._ID), "${Groups.TITLE}=?", arrayOf(title), null)?.use { cursor -> diff --git a/app/src/main/java/at/bitfire/davdroid/resource/LocalCalendar.kt b/app/src/main/java/at/bitfire/davdroid/resource/LocalCalendar.kt index 90240734470d4b724ae8e1dbe8a2d98c091368d5..bb6e29b5395821b5f53563abb3feacd142463be8 100644 --- a/app/src/main/java/at/bitfire/davdroid/resource/LocalCalendar.kt +++ b/app/src/main/java/at/bitfire/davdroid/resource/LocalCalendar.kt @@ -19,8 +19,8 @@ import at.bitfire.davdroid.log.Logger import at.bitfire.ical4android.AndroidCalendar import at.bitfire.ical4android.AndroidCalendarFactory import at.bitfire.ical4android.BatchOperation -import at.bitfire.ical4android.DateUtils -import at.bitfire.ical4android.MiscUtils.UriHelper.asSyncAdapter +import at.bitfire.ical4android.util.DateUtils +import at.bitfire.ical4android.util.MiscUtils.UriHelper.asSyncAdapter import java.util.* import java.util.logging.Level diff --git a/app/src/main/java/at/bitfire/davdroid/resource/LocalEvent.kt b/app/src/main/java/at/bitfire/davdroid/resource/LocalEvent.kt index e5a64e0265308926bfb8c769abd9f951448bf51a..495e7d42f8ff916fc7d0fb08476ee6cda89f2706 100644 --- a/app/src/main/java/at/bitfire/davdroid/resource/LocalEvent.kt +++ b/app/src/main/java/at/bitfire/davdroid/resource/LocalEvent.kt @@ -12,7 +12,7 @@ import android.provider.CalendarContract import android.provider.CalendarContract.Events import at.bitfire.davdroid.BuildConfig import at.bitfire.ical4android.* -import at.bitfire.ical4android.MiscUtils.UriHelper.asSyncAdapter +import at.bitfire.ical4android.util.MiscUtils.UriHelper.asSyncAdapter import net.fortuna.ical4j.model.property.ProdId import org.apache.commons.lang3.StringUtils import java.util.* @@ -193,27 +193,50 @@ class LocalEvent: AndroidEvent, LocalResource { } + /** + * Creates and sets a new UID in the calendar provider, if no UID is already set. + * It also returns the desired file name for the event for further processing in the sync algorithm. + * + * @return file name to use at upload + */ override fun prepareForUpload(): String { - var uid: String? = null + // fetch UID_2445 from calendar provider + var dbUid: String? = null calendar.provider.query(eventSyncURI(), arrayOf(Events.UID_2445), null, null, null)?.use { cursor -> if (cursor.moveToNext()) - uid = StringUtils.trimToNull(cursor.getString(0)) + dbUid = StringUtils.trimToNull(cursor.getString(0)) } - if (uid == null) { + // make sure that UID is set + val uid: String = dbUid ?: { // generate new UID - uid = UUID.randomUUID().toString() + val newUid = UUID.randomUUID().toString() + // update in calendar provider val values = ContentValues(1) - values.put(Events.UID_2445, uid) + values.put(Events.UID_2445, newUid) calendar.provider.update(eventSyncURI(), values, null, null) - event!!.uid = uid - } + // Update this event + event?.uid = newUid + + newUid + }() - return "$uid.ics" + val uidIsGoodFilename = uid.all { char -> + // see RFC 2396 2.2 + char.isLetterOrDigit() || arrayOf( // allow letters and digits + ';',':','@','&','=','+','$',',', // allow reserved characters except '/' and '?' + '-','_','.','!','~','*','\'','(',')' // allow unreserved characters + ).contains(char) + } + return if (uidIsGoodFilename) + "$uid.ics" // use UID as file name + else + "${UUID.randomUUID()}.ics" // UID would be dangerous as file name, use random UUID instead } + override fun clearDirty(fileName: String?, eTag: String?, scheduleTag: String?) { val values = ContentValues(5) if (fileName != null) diff --git a/app/src/main/java/at/bitfire/davdroid/settings/AccountSettings.kt b/app/src/main/java/at/bitfire/davdroid/settings/AccountSettings.kt index 8d414c121ab07cd9c17585c76b5b4fa6f05ef8c1..120b9523b60751e25826fff1f03481550506329f 100644 --- a/app/src/main/java/at/bitfire/davdroid/settings/AccountSettings.kt +++ b/app/src/main/java/at/bitfire/davdroid/settings/AccountSettings.kt @@ -21,6 +21,7 @@ import android.util.Base64 import androidx.annotation.WorkerThread import androidx.core.content.ContextCompat import androidx.preference.PreferenceManager +import at.bitfire.davdroid.Constants import at.bitfire.davdroid.InvalidAccountException import at.bitfire.davdroid.R import at.bitfire.davdroid.closeCompat @@ -47,6 +48,7 @@ import dagger.hilt.android.EntryPointAccessors import dagger.hilt.components.SingletonComponent import net.fortuna.ical4j.model.Property import net.fortuna.ical4j.model.property.Url +import net.openid.appauth.AuthState import okhttp3.HttpUrl.Companion.toHttpUrlOrNull import org.apache.commons.lang3.StringUtils import org.dmfs.tasks.contract.TaskContract @@ -89,6 +91,8 @@ class AccountSettings( const val KEY_SYNC_INTERVAL_TASKS = "sync_interval_tasks" 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) @@ -133,16 +137,27 @@ class AccountSettings( const val SYNC_INTERVAL_MANUALLY = -1L + const val CONTACTS_APP_INTERACTION = "z-app-generated--contactsinteraction--recent/" - fun initialUserData(credentials: Credentials?): Bundle { + fun initialUserData(credentials: Credentials?, baseURL: String?): Bundle { val bundle = Bundle(2) bundle.putString(KEY_SETTINGS_VERSION, CURRENT_VERSION.toString()) if (credentials != null) { - if (credentials.userName != null) + if (credentials.userName != null) { bundle.putString(KEY_USERNAME, credentials.userName) - if (credentials.certificateAlias != null) + bundle.putString(KEY_EMAIL_ADDRESS, credentials.userName) + } + if (credentials.certificateAlias != null) { bundle.putString(KEY_CERTIFICATE_ALIAS, credentials.certificateAlias) + } + if (credentials.authState != null) { + bundle.putString(KEY_AUTH_STATE, credentials.authState.jsonSerializeString()) + } + } + + if (!baseURL.isNullOrEmpty()) { + bundle.putString("oc_base_url", baseURL) } return bundle @@ -200,12 +215,12 @@ class AccountSettings( init { when (argAccount.type) { - context.getString(R.string.account_type_address_book) -> { + context.getString(R.string.account_type_address_book), context.getString(R.string.account_type_eelo_address_book), context.getString(R.string.account_type_google_address_book) -> { /* argAccount is an address book account, which is not a main account. However settings are stored in the main account, so resolve and use the main account instead. */ account = LocalAddressBook.mainAccount(context, argAccount) } - context.getString(R.string.account_type) -> + context.getString(R.string.account_type), context.getString(R.string.google_account_type), context.getString(R.string.eelo_account_type) -> account = argAccount else -> throw IllegalArgumentException("Account type not supported") @@ -229,16 +244,37 @@ class AccountSettings( // authentication settings - fun credentials() = Credentials( - accountManager.getUserData(account, KEY_USERNAME), - accountManager.getPassword(account), - accountManager.getUserData(account, KEY_CERTIFICATE_ALIAS) - ) + fun credentials(): Credentials { + return if (accountManager.getUserData(account, KEY_AUTH_STATE).isNullOrEmpty()) { + Credentials( + accountManager.getUserData(account, KEY_USERNAME), + accountManager.getPassword(account), + null, + accountManager.getUserData(account, KEY_CERTIFICATE_ALIAS) + ) + } else { + 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) + } + } @@ -498,6 +534,16 @@ class AccountSettings( edit.remove(overrideProxyPort) } edit.apply() + + setGroupMethod(GroupMethod.CATEGORIES) + val service = db.serviceDao().getByAccountAndType(account.name, Service.TYPE_CARDDAV) + if (service != null) { + for (collection in db.collectionDao().getByServiceAndSync(service.id)) { + if(collection.url.toString().contains(CONTACTS_APP_INTERACTION)) { + db.collectionDao().delete(collection) + } + } + } } @@ -730,7 +776,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), 4*3600) + setSyncInterval(context.getString(R.string.address_books_authority), Constants.DEFAULT_CONTACTS_SYNC_INTERVAL) } /* Android 7.1.1 OpenTasks fix */ diff --git a/app/src/main/java/at/bitfire/davdroid/syncadapter/AccountsUpdatedListener.kt b/app/src/main/java/at/bitfire/davdroid/syncadapter/AccountsUpdatedListener.kt index 68804cb70514023f6077a67993f5f5217890d17b..d38040f6061e0a8737072dab576651d5c7b339cc 100644 --- a/app/src/main/java/at/bitfire/davdroid/syncadapter/AccountsUpdatedListener.kt +++ b/app/src/main/java/at/bitfire/davdroid/syncadapter/AccountsUpdatedListener.kt @@ -68,34 +68,41 @@ class AccountsUpdatedListener private constructor( @Synchronized private fun cleanupAccounts(context: Context, accounts: Array) { - Logger.log.log(Level.INFO, "Cleaning up accounts. Current accounts:", accounts) + Logger.log.log(Level.INFO, "Cleaning up accounts. Current accounts") - val mainAccountType = context.getString(R.string.account_type) - val mainAccountNames = accounts - .filter { account -> account.type == mainAccountType } - .map { it.name } + val accountManager = AccountManager.get(context) + val accountNames = HashSet() + val accountFromManager = ArrayList() - val addressBookAccountType = context.getString(R.string.account_type_address_book) - val addressBooks = accounts - .filter { account -> account.type == addressBookAccountType } - .map { addressBookAccount -> LocalAddressBook(context, addressBookAccount, null) } - for (addressBook in addressBooks) { - try { - if (!mainAccountNames.contains(addressBook.mainAccount.name)) - // the main account for this address book doesn't exist anymore - addressBook.delete() - } catch(e: Exception) { - Logger.log.log(Level.SEVERE, "Couldn't delete address book account", e) - } + accountManager.getAccountsByType(context.getString(R.string.eelo_account_type)).forEach { accountFromManager.add(it) } + accountManager.getAccountsByType(context.getString(R.string.google_account_type)).forEach { accountFromManager.add(it) } + accountManager.getAccountsByType(context.getString(R.string.account_type)).forEach { accountFromManager.add(it) } + + for (account in accountFromManager.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 = EntryPointAccessors.fromApplication(context, AccountsUpdatedListenerEntryPoint::class.java).appDatabase() val serviceDao = db.serviceDao() - if (mainAccountNames.isEmpty()) + if (accountNames.isEmpty()) serviceDao.deleteAll() else - serviceDao.deleteExceptAccounts(mainAccountNames.toTypedArray()) + serviceDao.deleteExceptAccounts(accountNames.toTypedArray()) } - } \ No newline at end of file diff --git a/app/src/main/java/at/bitfire/davdroid/syncadapter/AddressBooksSyncAdapterService.kt b/app/src/main/java/at/bitfire/davdroid/syncadapter/AddressBooksSyncAdapterService.kt index 275ab782ca025bb86f85d394a3972075227f485b..3996eb2e4c5c6863e044a73a91346b050d0b8b56 100644 --- a/app/src/main/java/at/bitfire/davdroid/syncadapter/AddressBooksSyncAdapterService.kt +++ b/app/src/main/java/at/bitfire/davdroid/syncadapter/AddressBooksSyncAdapterService.kt @@ -108,7 +108,7 @@ class AddressBooksSyncAdapterService : SyncAdapterService() { // 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) + LocalAddressBook.create(context, db, contactsProvider, account, info) } } finally { contactsProvider?.closeCompat() diff --git a/app/src/main/java/at/bitfire/davdroid/syncadapter/CalendarSyncManager.kt b/app/src/main/java/at/bitfire/davdroid/syncadapter/CalendarSyncManager.kt index c384005515b7185b0ab41549d1408bd1074452b2..30839b784176d7deff67f7c27501dc64f526c7eb 100644 --- a/app/src/main/java/at/bitfire/davdroid/syncadapter/CalendarSyncManager.kt +++ b/app/src/main/java/at/bitfire/davdroid/syncadapter/CalendarSyncManager.kt @@ -22,7 +22,7 @@ import at.bitfire.davdroid.resource.LocalCalendar import at.bitfire.davdroid.resource.LocalEvent import at.bitfire.davdroid.resource.LocalResource import at.bitfire.davdroid.settings.AccountSettings -import at.bitfire.ical4android.DateUtils +import at.bitfire.ical4android.util.DateUtils import at.bitfire.ical4android.Event import at.bitfire.ical4android.InvalidCalendarException import net.fortuna.ical4j.model.Component @@ -55,7 +55,7 @@ class CalendarSyncManager( override fun prepare(): Boolean { collectionURL = (localCollection.name ?: return false).toHttpUrlOrNull() ?: 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() diff --git a/app/src/main/java/at/bitfire/davdroid/syncadapter/CalendarsSyncAdapterService.kt b/app/src/main/java/at/bitfire/davdroid/syncadapter/CalendarsSyncAdapterService.kt index 83633b5ba525045d54c2c6b7f2813b317da9b906..67b16cb8c3f84384744610828acd0af97f93153e 100644 --- a/app/src/main/java/at/bitfire/davdroid/syncadapter/CalendarsSyncAdapterService.kt +++ b/app/src/main/java/at/bitfire/davdroid/syncadapter/CalendarsSyncAdapterService.kt @@ -9,15 +9,18 @@ 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 at.bitfire.davdroid.HttpClient import at.bitfire.davdroid.db.AppDatabase import at.bitfire.davdroid.db.Collection +import at.bitfire.davdroid.db.Credentials import at.bitfire.davdroid.db.Service import at.bitfire.davdroid.log.Logger import at.bitfire.davdroid.resource.LocalCalendar import at.bitfire.davdroid.settings.AccountSettings import at.bitfire.ical4android.AndroidCalendar +import net.openid.appauth.AuthorizationService import okhttp3.HttpUrl import okhttp3.HttpUrl.Companion.toHttpUrl import java.util.logging.Level @@ -60,7 +63,42 @@ class CalendarsSyncAdapterService: SyncAdapterService() { for (calendar in calendars) { Logger.log.info("Synchronizing calendar #${calendar.id}, URL: ${calendar.name}") CalendarSyncManager(context, account, accountSettings, extras, httpClient.value, authority, syncResult, calendar).let { - it.performSync() + val authState = accountSettings.credentials().authState + if (authState != null) { + if (authState.needsTokenRefresh) { + val tokenRequest = authState.createTokenRefreshRequest() + + AuthorizationService(context).performTokenRequest(tokenRequest) { 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) { diff --git a/app/src/main/java/at/bitfire/davdroid/syncadapter/ContactsSyncAdapterService.kt b/app/src/main/java/at/bitfire/davdroid/syncadapter/ContactsSyncAdapterService.kt index 454d06535ea4f0420dfb2bb12aa49d5b6435bf87..5de00c3d1cd273fabea671266d7d4afe80ab7c8b 100644 --- a/app/src/main/java/at/bitfire/davdroid/syncadapter/ContactsSyncAdapterService.kt +++ b/app/src/main/java/at/bitfire/davdroid/syncadapter/ContactsSyncAdapterService.kt @@ -9,13 +9,16 @@ 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 at.bitfire.davdroid.HttpClient import at.bitfire.davdroid.db.AppDatabase +import at.bitfire.davdroid.db.Credentials import at.bitfire.davdroid.log.Logger import at.bitfire.davdroid.resource.LocalAddressBook import at.bitfire.davdroid.settings.AccountSettings +import net.openid.appauth.AuthorizationService import java.util.logging.Level class ContactsSyncAdapterService: SyncAdapterService() { @@ -64,7 +67,42 @@ class ContactsSyncAdapterService: SyncAdapterService() { Logger.log.info("Taking settings from: ${addressBook.mainAccount}") ContactsSyncManager(context, account, accountSettings, httpClient.value, extras, authority, syncResult, provider, addressBook).let { - it.performSync() + val authState = accountSettings.credentials().authState + if (authState != null) { + if (authState.needsTokenRefresh) { + val tokenRequest = authState.createTokenRefreshRequest() + + AuthorizationService(context).performTokenRequest(tokenRequest) { 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) diff --git a/app/src/main/java/at/bitfire/davdroid/syncadapter/ContactsSyncManager.kt b/app/src/main/java/at/bitfire/davdroid/syncadapter/ContactsSyncManager.kt index 2401a0a0d7adaf4141f13d94c961eee80d669f32..85b04623085f63e540019b25db07ac206787aa98 100644 --- a/app/src/main/java/at/bitfire/davdroid/syncadapter/ContactsSyncManager.kt +++ b/app/src/main/java/at/bitfire/davdroid/syncadapter/ContactsSyncManager.kt @@ -95,6 +95,7 @@ class ContactsSyncManager( } private val readOnly = localAddressBook.readOnly + private val accessToken: String? = accountSettings.credentials().authState?.accessToken private var hasVCard4 = false private var hasJCard = false @@ -121,7 +122,7 @@ class ContactsSyncManager( } collectionURL = localCollection.url.toHttpUrlOrNull() ?: return false - davCollection = DavAddressBook(httpClient.okHttpClient, collectionURL) + davCollection = DavAddressBook(httpClient.okHttpClient, collectionURL, accountSettings.credentials().authState?.accessToken) resourceDownloader = ResourceDownloader(davCollection.location) @@ -377,10 +378,17 @@ class ContactsSyncManager( .build() try { - val response = client.okHttpClient.newCall(Request.Builder() - .get() - .url(httpUrl) - .build()).execute() + val requestBuilder = Request.Builder() + .get() + .url(httpUrl) + + if (!accessToken.isNullOrEmpty()) { + requestBuilder.header("Authorization", "Bearer $accessToken") + } + + val response = client.okHttpClient.newCall(requestBuilder + .build()) + .execute() if (response.isSuccessful) return response.body?.bytes() diff --git a/app/src/main/java/at/bitfire/davdroid/syncadapter/EeloAccountAuthenticatorService.kt b/app/src/main/java/at/bitfire/davdroid/syncadapter/EeloAccountAuthenticatorService.kt new file mode 100644 index 0000000000000000000000000000000000000000..f9d71fd4c53dd04db278e12c502b011ae76784f1 --- /dev/null +++ b/app/src/main/java/at/bitfire/davdroid/syncadapter/EeloAccountAuthenticatorService.kt @@ -0,0 +1,195 @@ +/* + * Copyright ECORP SAS 2022 + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package at.bitfire.davdroid.syncadapter + +import android.accounts.* +import android.app.Service +import android.content.Context +import android.content.Intent +import android.os.Bundle +import at.bitfire.davdroid.R +import at.bitfire.davdroid.db.AppDatabase +import at.bitfire.davdroid.log.Logger +import at.bitfire.davdroid.resource.LocalAddressBook +import at.bitfire.davdroid.ui.setup.LoginActivity +import dagger.hilt.android.EntryPointAccessors +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import java.util.logging.Level + +/** + * 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, db: AppDatabase) { + 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 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 == AccountManager.ACTION_AUTHENTICATOR_INTENT } + + override fun onAccountsUpdated(accounts: Array?) { + /* onAccountsUpdated may be called from the main thread, but cleanupAccounts + requires disk (database) access. So we launch it in a separate thread. */ + CoroutineScope(Dispatchers.Default).launch { + val db = EntryPointAccessors.fromApplication( + applicationContext, + AccountsUpdatedListener.AccountsUpdatedListenerEntryPoint::class.java + ).appDatabase() + + cleanupAccounts(applicationContext, db) + + 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 + ) + ).toSet() + ) + + 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( + response: AccountAuthenticatorResponse?, + account: Account?, + authTokenType: String?, + options: Bundle? + ) = null + + override fun hasFeatures( + p0: AccountAuthenticatorResponse?, + p1: Account?, + p2: Array? + ) = null + } +} diff --git a/app/src/main/java/at/bitfire/davdroid/syncadapter/EeloAddressBooksSyncAdapterService.kt b/app/src/main/java/at/bitfire/davdroid/syncadapter/EeloAddressBooksSyncAdapterService.kt new file mode 100644 index 0000000000000000000000000000000000000000..cac6e633c9a7e3c9d67f998de784e52118741c05 --- /dev/null +++ b/app/src/main/java/at/bitfire/davdroid/syncadapter/EeloAddressBooksSyncAdapterService.kt @@ -0,0 +1,168 @@ +/* + * Copyright ECORP SAS 2022 + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package at.bitfire.davdroid.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 at.bitfire.davdroid.HttpClient +import at.bitfire.davdroid.PermissionUtils +import at.bitfire.davdroid.closeCompat +import at.bitfire.davdroid.db.AppDatabase +import at.bitfire.davdroid.db.Collection +import at.bitfire.davdroid.db.Service +import at.bitfire.davdroid.log.Logger +import at.bitfire.davdroid.resource.LocalAddressBook +import at.bitfire.davdroid.settings.AccountSettings +import at.bitfire.davdroid.ui.account.AccountActivity +import okhttp3.HttpUrl +import okhttp3.HttpUrl.Companion.toHttpUrlOrNull +import java.util.logging.Level + +class EeloAddressBooksSyncAdapterService : SyncAdapterService() { + + override fun syncAdapter() = AddressBooksSyncAdapter(this, appDatabase) + + class AddressBooksSyncAdapter( + context: Context, + db: AppDatabase + ) : SyncAdapter(context, db) { + + override fun sync( + account: Account, + extras: Bundle, + authority: String, + httpClient: Lazy, + 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 service = db.serviceDao().getByAccountAndType(account.name, Service.TYPE_CARDDAV) + + val remoteAddressBooks = mutableMapOf() + if (service != null) + for (collection in db.collectionDao().getByServiceAndSync(service.id)) { + if(collection.url.toString().contains(AccountSettings.CONTACTS_APP_INTERACTION)) { + db.collectionDao().delete(collection) + } + 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) + + PermissionUtils.notifyPermissions(context, 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 = addressBook.url.toHttpUrlOrNull()!! + 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, db, contactsProvider, account, info) + } + } finally { + contactsProvider?.closeCompat() + } + + return true + } + + } + +} + diff --git a/app/src/main/java/at/bitfire/davdroid/syncadapter/EeloAppDataSyncAdapterService.kt b/app/src/main/java/at/bitfire/davdroid/syncadapter/EeloAppDataSyncAdapterService.kt new file mode 100644 index 0000000000000000000000000000000000000000..0136139b65addfdae6732526315177e4e2513b1f --- /dev/null +++ b/app/src/main/java/at/bitfire/davdroid/syncadapter/EeloAppDataSyncAdapterService.kt @@ -0,0 +1,46 @@ +/* + * Copyright ECORP SAS 2022 + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package at.bitfire.davdroid.syncadapter + +import android.accounts.Account +import android.content.* +import android.os.Bundle +import at.bitfire.davdroid.HttpClient +import at.bitfire.davdroid.db.AppDatabase + +class EeloAppDataSyncAdapterService : SyncAdapterService() { + + override fun syncAdapter() = EeloAppDataSyncAdapter(this, appDatabase) + + class EeloAppDataSyncAdapter( + context: Context, + db: AppDatabase + ): SyncAdapter(context, db) { + + override fun sync( + account: Account, + extras: Bundle, + authority: String, + httpClient: Lazy, + provider: ContentProviderClient, + syncResult: SyncResult + ) { + // Unused + } + } +} + diff --git a/app/src/main/java/at/bitfire/davdroid/syncadapter/EeloCalendarsSyncAdapterService.kt b/app/src/main/java/at/bitfire/davdroid/syncadapter/EeloCalendarsSyncAdapterService.kt new file mode 100644 index 0000000000000000000000000000000000000000..26e1a9f044d7aefc59884abc74068eabf0e48498 --- /dev/null +++ b/app/src/main/java/at/bitfire/davdroid/syncadapter/EeloCalendarsSyncAdapterService.kt @@ -0,0 +1,192 @@ +/* + * Copyright ECORP SAS 2022 + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package at.bitfire.davdroid.syncadapter + +import android.accounts.Account +import android.content.ContentProviderClient +import android.content.ContentResolver +import android.content.Context +import android.content.SyncResult +import android.os.AsyncTask +import android.os.Bundle +import android.provider.CalendarContract +import at.bitfire.davdroid.HttpClient +import at.bitfire.davdroid.db.AppDatabase +import at.bitfire.davdroid.db.Collection +import at.bitfire.davdroid.db.Credentials +import at.bitfire.davdroid.db.Service +import at.bitfire.davdroid.log.Logger +import at.bitfire.davdroid.resource.LocalCalendar +import at.bitfire.davdroid.settings.AccountSettings +import at.bitfire.ical4android.AndroidCalendar +import net.openid.appauth.AuthorizationService +import okhttp3.HttpUrl +import okhttp3.HttpUrl.Companion.toHttpUrlOrNull +import java.util.logging.Level + +class EeloCalendarsSyncAdapterService : SyncAdapterService() { + + override fun syncAdapter() = CalendarsSyncAdapter(this, appDatabase) + + class CalendarsSyncAdapter( + context: Context, + appDatabase: AppDatabase + ) : SyncAdapter(context, appDatabase) { + override fun sync( + account: Account, + extras: Bundle, + authority: String, + httpClient: Lazy, + 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, + httpClient.value, + authority, + syncResult, + calendar + ).let { + val authState = accountSettings.credentials().authState + if (authState != null) { + if (authState.needsTokenRefresh) { + val tokenRequest = authState.createTokenRefreshRequest() + + AuthorizationService(context).performTokenRequest(tokenRequest) { 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 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 = it.toHttpUrlOrNull()!! + 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/EeloContactsSyncAdapterService.kt b/app/src/main/java/at/bitfire/davdroid/syncadapter/EeloContactsSyncAdapterService.kt new file mode 100644 index 0000000000000000000000000000000000000000..b5c0419d8984679be71d63cfc670c859806696cb --- /dev/null +++ b/app/src/main/java/at/bitfire/davdroid/syncadapter/EeloContactsSyncAdapterService.kt @@ -0,0 +1,95 @@ +/* + * Copyright ECORP SAS 2022 + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package at.bitfire.davdroid.syncadapter + +import android.accounts.Account +import android.content.ContentProviderClient +import android.content.ContentResolver +import android.content.Context +import android.content.SyncResult +import android.os.Bundle +import android.provider.ContactsContract +import at.bitfire.davdroid.HttpClient +import at.bitfire.davdroid.db.AppDatabase +import at.bitfire.davdroid.log.Logger +import at.bitfire.davdroid.resource.LocalAddressBook +import at.bitfire.davdroid.settings.AccountSettings +import java.util.logging.Level + +class EeloContactsSyncAdapterService: SyncAdapterService() { + + companion object { + const val PREVIOUS_GROUP_METHOD = "previous_group_method" + } + + override fun syncAdapter() = ContactsSyncAdapter(this, appDatabase) + + + class ContactsSyncAdapter( + context: Context, + db: AppDatabase + ): SyncAdapter(context, db) { + + override fun sync( + account: Account, + extras: Bundle, + authority: String, + httpClient: Lazy, + 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, httpClient.value, extras, authority, syncResult, provider, addressBook).let { + 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/EeloEmailSyncAdapterService.kt b/app/src/main/java/at/bitfire/davdroid/syncadapter/EeloEmailSyncAdapterService.kt new file mode 100644 index 0000000000000000000000000000000000000000..ea1319c3b25595563a6d920e4f40fe01ce9fee02 --- /dev/null +++ b/app/src/main/java/at/bitfire/davdroid/syncadapter/EeloEmailSyncAdapterService.kt @@ -0,0 +1,46 @@ +/* + * Copyright ECORP SAS 2022 + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package at.bitfire.davdroid.syncadapter + +import android.accounts.Account +import android.content.* +import android.os.Bundle +import at.bitfire.davdroid.HttpClient +import at.bitfire.davdroid.db.AppDatabase + +class EeloEmailSyncAdapterService : SyncAdapterService() { + + override fun syncAdapter() = EeloEmailSyncAdapter(this, appDatabase) + + + class EeloEmailSyncAdapter( + context: Context, + db: AppDatabase + ): SyncAdapter(context, db) { + override fun sync( + account: Account, + extras: Bundle, + authority: String, + httpClient: Lazy, + provider: ContentProviderClient, + syncResult: SyncResult + ) { + // Unused + } + } +} + diff --git a/app/src/main/java/at/bitfire/davdroid/syncadapter/EeloMediaSyncAdapterService.kt b/app/src/main/java/at/bitfire/davdroid/syncadapter/EeloMediaSyncAdapterService.kt new file mode 100644 index 0000000000000000000000000000000000000000..e7af69c3e668e5d58d0887e06d4896ecbba4bdcd --- /dev/null +++ b/app/src/main/java/at/bitfire/davdroid/syncadapter/EeloMediaSyncAdapterService.kt @@ -0,0 +1,46 @@ +/* + * Copyright ECORP SAS 2022 + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package at.bitfire.davdroid.syncadapter + +import android.accounts.Account +import android.content.* +import android.os.Bundle +import at.bitfire.davdroid.HttpClient +import at.bitfire.davdroid.db.AppDatabase + +class EeloMediaSyncAdapterService : SyncAdapterService() { + + override fun syncAdapter() = EeloMediaSyncAdapter(this, appDatabase) + + + class EeloMediaSyncAdapter( + context: Context, + db: AppDatabase + ): SyncAdapter(context, db) { + + override fun sync( + account: Account, + extras: Bundle, + authority: String, + httpClient: Lazy, + provider: ContentProviderClient, + syncResult: SyncResult + ) { + // Unused + } + } +} diff --git a/app/src/main/java/at/bitfire/davdroid/syncadapter/EeloMeteredEdriveSyncAdapterService.kt b/app/src/main/java/at/bitfire/davdroid/syncadapter/EeloMeteredEdriveSyncAdapterService.kt new file mode 100644 index 0000000000000000000000000000000000000000..2702a7a4c42b6e2579a078c638456d0b2e073608 --- /dev/null +++ b/app/src/main/java/at/bitfire/davdroid/syncadapter/EeloMeteredEdriveSyncAdapterService.kt @@ -0,0 +1,47 @@ +/* + * Copyright ECORP SAS 2022 + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package at.bitfire.davdroid.syncadapter + +import android.accounts.Account +import android.content.ContentProviderClient +import android.content.Context +import android.content.SyncResult +import android.os.Bundle +import at.bitfire.davdroid.HttpClient +import at.bitfire.davdroid.db.AppDatabase + +class EeloMeteredEdriveSyncAdapterService : SyncAdapterService() { + + override fun syncAdapter() = EeloMeteredEdriveSyncAdapter(this, appDatabase) + + class EeloMeteredEdriveSyncAdapter( + context: Context, + db: AppDatabase + ): SyncAdapter(context, db) { + + override fun sync( + account: Account, + extras: Bundle, + authority: String, + httpClient: Lazy, + provider: ContentProviderClient, + syncResult: SyncResult + ) { + // Unused + } + } +} \ No newline at end of file diff --git a/app/src/main/java/at/bitfire/davdroid/syncadapter/EeloNotesSyncAdapterService.kt b/app/src/main/java/at/bitfire/davdroid/syncadapter/EeloNotesSyncAdapterService.kt new file mode 100644 index 0000000000000000000000000000000000000000..86a1daa897f51c01d0184e166f1fab6600d8d405 --- /dev/null +++ b/app/src/main/java/at/bitfire/davdroid/syncadapter/EeloNotesSyncAdapterService.kt @@ -0,0 +1,45 @@ +/* + * Copyright ECORP SAS 2022 + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package at.bitfire.davdroid.syncadapter + +import android.accounts.Account +import android.content.* +import android.os.Bundle +import at.bitfire.davdroid.HttpClient +import at.bitfire.davdroid.db.AppDatabase + +class EeloNotesSyncAdapterService : SyncAdapterService() { + + override fun syncAdapter() = EeloNotesSyncAdapter(this, appDatabase) + + class EeloNotesSyncAdapter( + context: Context, + db: AppDatabase + ): SyncAdapter(context, db) { + + override fun sync( + account: Account, + extras: Bundle, + authority: String, + httpClient: Lazy, + provider: ContentProviderClient, + syncResult: SyncResult + ) { + // Unused + } + } +} diff --git a/app/src/main/java/at/bitfire/davdroid/syncadapter/EeloNullAuthenticatorService.kt b/app/src/main/java/at/bitfire/davdroid/syncadapter/EeloNullAuthenticatorService.kt new file mode 100644 index 0000000000000000000000000000000000000000..58367a45a433170ebd315a53beb14c7c5042e0e7 --- /dev/null +++ b/app/src/main/java/at/bitfire/davdroid/syncadapter/EeloNullAuthenticatorService.kt @@ -0,0 +1,61 @@ +/* + * Copyright ECORP SAS 2022 + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package at.bitfire.davdroid.syncadapter + +import android.accounts.AbstractAccountAuthenticator +import android.accounts.Account +import android.accounts.AccountAuthenticatorResponse +import android.accounts.AccountManager +import android.app.Service +import android.content.Context +import android.content.Intent +import android.os.Bundle +import at.bitfire.davdroid.ui.AccountsActivity + +class 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/at/bitfire/davdroid/syncadapter/EeloTasksSyncAdapterService.kt b/app/src/main/java/at/bitfire/davdroid/syncadapter/EeloTasksSyncAdapterService.kt new file mode 100644 index 0000000000000000000000000000000000000000..2dbbd4c8016eb7b36723a8b63b8fbf88ac89b946 --- /dev/null +++ b/app/src/main/java/at/bitfire/davdroid/syncadapter/EeloTasksSyncAdapterService.kt @@ -0,0 +1,203 @@ +/* + * Copyright ECORP SAS 2022 + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package at.bitfire.davdroid.syncadapter + +import android.accounts.Account +import android.accounts.AccountManager +import android.content.ContentProviderClient +import android.content.ContentResolver +import android.content.Context +import android.content.SyncResult +import android.os.AsyncTask +import android.os.Build +import android.os.Bundle +import at.bitfire.davdroid.HttpClient +import at.bitfire.davdroid.db.AppDatabase +import at.bitfire.davdroid.db.Collection +import at.bitfire.davdroid.db.Credentials +import at.bitfire.davdroid.db.Service +import at.bitfire.davdroid.log.Logger +import at.bitfire.davdroid.resource.LocalTaskList +import at.bitfire.davdroid.settings.AccountSettings +import at.bitfire.ical4android.AndroidTaskList +import at.bitfire.ical4android.TaskProvider +import net.openid.appauth.AuthorizationService +import okhttp3.HttpUrl +import okhttp3.HttpUrl.Companion.toHttpUrlOrNull +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, appDatabase) + + + class TasksSyncAdapter( + context: Context, + appDatabase: AppDatabase + ) : SyncAdapter(context, appDatabase) { + + override fun sync( + account: Account, + extras: Bundle, + authority: String, + httpClient: Lazy, + provider: ContentProviderClient, + syncResult: SyncResult + ) { + try { + val providerName = TaskProvider.ProviderName.fromAuthority(authority) + val taskProvider = TaskProvider.fromProviderClient(context, providerName, 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, + httpClient.value, + extras, + authority, + syncResult, + taskList + ).let { + 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) { + SyncUtils.notifyProviderTooOld(context, e) + 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 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 = it.toHttpUrlOrNull()!! + 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/GoogleAccountAuthenticatorService.kt b/app/src/main/java/at/bitfire/davdroid/syncadapter/GoogleAccountAuthenticatorService.kt new file mode 100644 index 0000000000000000000000000000000000000000..82fe2de0f648ed0e9eacd48272a44dc03b6d9f7f --- /dev/null +++ b/app/src/main/java/at/bitfire/davdroid/syncadapter/GoogleAccountAuthenticatorService.kt @@ -0,0 +1,218 @@ +/* + * Copyright ECORP SAS 2022 + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package at.bitfire.davdroid.syncadapter + +import android.accounts.* +import android.app.Service +import android.content.Context +import android.content.Intent +import android.os.Bundle +import at.bitfire.davdroid.R +import at.bitfire.davdroid.db.AppDatabase +import at.bitfire.davdroid.log.Logger +import at.bitfire.davdroid.resource.LocalAddressBook +import at.bitfire.davdroid.settings.AccountSettings +import at.bitfire.davdroid.ui.setup.LoginActivity +import dagger.hilt.android.EntryPointAccessors +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import net.openid.appauth.AuthState +import net.openid.appauth.AuthorizationService +import java.util.logging.Level + +/** + * 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, db: AppDatabase) { + 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 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 == AccountManager.ACTION_AUTHENTICATOR_INTENT } + + override fun onAccountsUpdated(accounts: Array?) { + /* onAccountsUpdated may be called from the main thread, but cleanupAccounts + requires disk (database) access. So we launch it in a separate thread. */ + CoroutineScope(Dispatchers.Default).launch { + val db = EntryPointAccessors.fromApplication( + applicationContext, + AccountsUpdatedListener.AccountsUpdatedListenerEntryPoint::class.java + ).appDatabase() + + cleanupAccounts(applicationContext, db) + } + } + + 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/at/bitfire/davdroid/syncadapter/GoogleAddressBooksSyncAdapterService.kt b/app/src/main/java/at/bitfire/davdroid/syncadapter/GoogleAddressBooksSyncAdapterService.kt new file mode 100644 index 0000000000000000000000000000000000000000..128a26f660b8caca68d34833a07c71721b4aa2d9 --- /dev/null +++ b/app/src/main/java/at/bitfire/davdroid/syncadapter/GoogleAddressBooksSyncAdapterService.kt @@ -0,0 +1,164 @@ +/* + * Copyright ECORP SAS 2022 + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package at.bitfire.davdroid.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 at.bitfire.davdroid.HttpClient +import at.bitfire.davdroid.PermissionUtils +import at.bitfire.davdroid.closeCompat +import at.bitfire.davdroid.db.AppDatabase +import at.bitfire.davdroid.db.Collection +import at.bitfire.davdroid.db.Service +import at.bitfire.davdroid.log.Logger +import at.bitfire.davdroid.resource.LocalAddressBook +import at.bitfire.davdroid.settings.AccountSettings +import at.bitfire.davdroid.ui.account.AccountActivity +import okhttp3.HttpUrl +import okhttp3.HttpUrl.Companion.toHttpUrlOrNull +import java.util.logging.Level + +class GoogleAddressBooksSyncAdapterService : SyncAdapterService() { + + override fun syncAdapter() = AddressBooksSyncAdapter(this, appDatabase) + + class AddressBooksSyncAdapter( + context: Context, + db: AppDatabase + ) : SyncAdapter(context, db) { + + override fun sync( + account: Account, + extras: Bundle, + authority: String, + httpClient: Lazy, + 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 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) + + PermissionUtils.notifyPermissions(context, 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 = addressBook.url.toHttpUrlOrNull()!! + 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, db, contactsProvider, account, info) + } + } finally { + contactsProvider?.closeCompat() + } + + return true + } + + } + +} + diff --git a/app/src/main/java/at/bitfire/davdroid/syncadapter/GoogleCalendarsSyncAdapterService.kt b/app/src/main/java/at/bitfire/davdroid/syncadapter/GoogleCalendarsSyncAdapterService.kt new file mode 100644 index 0000000000000000000000000000000000000000..643a649cd1ce7f9ab229cbaa717085658f14aef0 --- /dev/null +++ b/app/src/main/java/at/bitfire/davdroid/syncadapter/GoogleCalendarsSyncAdapterService.kt @@ -0,0 +1,190 @@ +/* + * Copyright ECORP SAS 2022 + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package at.bitfire.davdroid.syncadapter + +import android.accounts.Account +import android.content.ContentProviderClient +import android.content.ContentResolver +import android.content.Context +import android.content.SyncResult +import android.os.AsyncTask +import android.os.Bundle +import android.provider.CalendarContract +import at.bitfire.davdroid.HttpClient +import at.bitfire.davdroid.db.AppDatabase +import at.bitfire.davdroid.db.Collection +import at.bitfire.davdroid.db.Credentials +import at.bitfire.davdroid.db.Service +import at.bitfire.davdroid.log.Logger +import at.bitfire.davdroid.resource.LocalCalendar +import at.bitfire.davdroid.settings.AccountSettings +import at.bitfire.ical4android.AndroidCalendar +import net.openid.appauth.AuthorizationService +import okhttp3.HttpUrl +import okhttp3.HttpUrl.Companion.toHttpUrlOrNull +import java.util.logging.Level + +class GoogleCalendarsSyncAdapterService : SyncAdapterService() { + + override fun syncAdapter() = CalendarsSyncAdapter(this, appDatabase) + + + class CalendarsSyncAdapter( + context: Context, + db: AppDatabase + ) : SyncAdapter(context, db) { + + override fun sync( + account: Account, + extras: Bundle, + authority: String, + httpClient: Lazy, + 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, + httpClient.value, + authority, + syncResult, + calendar + ).let { + 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 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 = it.toHttpUrlOrNull()!! + 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/GoogleContactsSyncAdapterService.kt b/app/src/main/java/at/bitfire/davdroid/syncadapter/GoogleContactsSyncAdapterService.kt new file mode 100644 index 0000000000000000000000000000000000000000..fd8c3ade1460f4d99a9914f5fb868f402044a205 --- /dev/null +++ b/app/src/main/java/at/bitfire/davdroid/syncadapter/GoogleContactsSyncAdapterService.kt @@ -0,0 +1,118 @@ +/* + * Copyright ECORP SAS 2022 + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package at.bitfire.davdroid.syncadapter + +import android.accounts.Account +import android.content.ContentProviderClient +import android.content.ContentResolver +import android.content.Context +import android.content.SyncResult +import android.os.Bundle +import android.provider.ContactsContract +import at.bitfire.davdroid.HttpClient +import at.bitfire.davdroid.db.AppDatabase +import at.bitfire.davdroid.log.Logger +import at.bitfire.davdroid.resource.LocalAddressBook +import at.bitfire.davdroid.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, appDatabase) + + class ContactsSyncAdapter( + context: Context, + db: AppDatabase + ) : SyncAdapter(context, db) { + + override fun sync( + account: Account, + extras: Bundle, + authority: String, + httpClient: Lazy, + 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, + httpClient.value, + extras, + authority, + syncResult, + provider, + addressBook + ).performSync() + } catch (e: Exception) { + Logger.log.log(Level.SEVERE, "Couldn't sync contacts", e) + } + Logger.log.info("Contacts sync complete") + } + } +} + diff --git a/app/src/main/java/at/bitfire/davdroid/syncadapter/GoogleEmailSyncAdapterService.kt b/app/src/main/java/at/bitfire/davdroid/syncadapter/GoogleEmailSyncAdapterService.kt new file mode 100644 index 0000000000000000000000000000000000000000..458237d98fd1ed239721092801cf95b187cc7b6a --- /dev/null +++ b/app/src/main/java/at/bitfire/davdroid/syncadapter/GoogleEmailSyncAdapterService.kt @@ -0,0 +1,45 @@ +/* + * Copyright ECORP SAS 2022 + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package at.bitfire.davdroid.syncadapter + +import android.accounts.Account +import android.content.* +import android.os.Bundle +import at.bitfire.davdroid.HttpClient +import at.bitfire.davdroid.db.AppDatabase + +class GoogleEmailSyncAdapterService : SyncAdapterService() { + + override fun syncAdapter() = GoogleEmailSyncAdapter(this, appDatabase) + + class GoogleEmailSyncAdapter( + context: Context, + db: AppDatabase + ): SyncAdapter(context, db) { + + override fun sync( + account: Account, + extras: Bundle, + authority: String, + httpClient: Lazy, + provider: ContentProviderClient, + syncResult: SyncResult + ) { + // Unused + } + } +} \ No newline at end of file diff --git a/app/src/main/java/at/bitfire/davdroid/syncadapter/GoogleNullAuthenticatorService.kt b/app/src/main/java/at/bitfire/davdroid/syncadapter/GoogleNullAuthenticatorService.kt new file mode 100644 index 0000000000000000000000000000000000000000..0485928f26e3a4c4b0219d528064ca7e8262a8f5 --- /dev/null +++ b/app/src/main/java/at/bitfire/davdroid/syncadapter/GoogleNullAuthenticatorService.kt @@ -0,0 +1,61 @@ +/* + * Copyright ECORP SAS 2022 + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package at.bitfire.davdroid.syncadapter + +import android.accounts.AbstractAccountAuthenticator +import android.accounts.Account +import android.accounts.AccountAuthenticatorResponse +import android.accounts.AccountManager +import android.app.Service +import android.content.Context +import android.content.Intent +import android.os.Bundle +import at.bitfire.davdroid.ui.AccountsActivity + +class 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/at/bitfire/davdroid/syncadapter/GoogleTasksSyncAdapterService.kt b/app/src/main/java/at/bitfire/davdroid/syncadapter/GoogleTasksSyncAdapterService.kt new file mode 100644 index 0000000000000000000000000000000000000000..8a3d0713f4a217f2dcce91aee947fbbe824ab282 --- /dev/null +++ b/app/src/main/java/at/bitfire/davdroid/syncadapter/GoogleTasksSyncAdapterService.kt @@ -0,0 +1,201 @@ +/* + * Copyright ECORP SAS 2022 + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package at.bitfire.davdroid.syncadapter + +import android.accounts.Account +import android.accounts.AccountManager +import android.content.ContentProviderClient +import android.content.ContentResolver +import android.content.Context +import android.content.SyncResult +import android.os.AsyncTask +import android.os.Build +import android.os.Bundle +import at.bitfire.davdroid.HttpClient +import at.bitfire.davdroid.db.AppDatabase +import at.bitfire.davdroid.db.Collection +import at.bitfire.davdroid.db.Credentials +import at.bitfire.davdroid.db.Service +import at.bitfire.davdroid.log.Logger +import at.bitfire.davdroid.resource.LocalTaskList +import at.bitfire.davdroid.settings.AccountSettings +import at.bitfire.ical4android.AndroidTaskList +import at.bitfire.ical4android.TaskProvider +import net.openid.appauth.AuthorizationService +import okhttp3.HttpUrl +import okhttp3.HttpUrl.Companion.toHttpUrlOrNull +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, appDatabase) + + + class TasksSyncAdapter( + context: Context, + db: AppDatabase + ) : SyncAdapter(context, db) { + + override fun sync( + account: Account, + extras: Bundle, + authority: String, + httpClient: Lazy, + provider: ContentProviderClient, + syncResult: SyncResult + ) { + try { + val providerName = TaskProvider.ProviderName.fromAuthority(authority) + val taskProvider = TaskProvider.fromProviderClient(context, providerName, 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, + httpClient.value, + extras, + authority, + syncResult, + taskList + ).let { + 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) { + SyncUtils.notifyProviderTooOld(context, e) + 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 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 = it.toHttpUrlOrNull()!! + 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/JtxSyncManager.kt b/app/src/main/java/at/bitfire/davdroid/syncadapter/JtxSyncManager.kt index 5ecdc496a49c0f24181cb7b5beb2cc315d36ed1d..e33b0e2941f63edcd6a396447a5f4690b570e04d 100644 --- a/app/src/main/java/at/bitfire/davdroid/syncadapter/JtxSyncManager.kt +++ b/app/src/main/java/at/bitfire/davdroid/syncadapter/JtxSyncManager.kt @@ -47,7 +47,7 @@ class JtxSyncManager( override fun prepare(): Boolean { collectionURL = (localCollection.url ?: return false).toHttpUrlOrNull() ?: return false - davCollection = DavCalendar(httpClient.okHttpClient, collectionURL) + davCollection = DavCalendar(httpClient.okHttpClient, collectionURL, accountSettings.credentials().authState?.accessToken) return true } diff --git a/app/src/main/java/at/bitfire/davdroid/syncadapter/SyncManager.kt b/app/src/main/java/at/bitfire/davdroid/syncadapter/SyncManager.kt index 443f75ba77ea0a2bd5a289839c93fbf4687500ce..34b4f47104b36e6410b4ee7cd2342ce1d4786985 100644 --- a/app/src/main/java/at/bitfire/davdroid/syncadapter/SyncManager.kt +++ b/app/src/main/java/at/bitfire/davdroid/syncadapter/SyncManager.kt @@ -323,7 +323,7 @@ abstract class SyncManager, out CollectionType: L val lastETag = if (lastScheduleTag == null) local.eTag else null Logger.log.info("$fileName has been deleted locally -> deleting from server (ETag $lastETag / schedule-tag $lastScheduleTag)") - remoteExceptionContext(DavResource(httpClient.okHttpClient, collectionURL.newBuilder().addPathSegment(fileName).build())) { remote -> + remoteExceptionContext(DavResource(httpClient.okHttpClient, collectionURL.newBuilder().addPathSegment(fileName).build(), accountSettings.credentials().authState?.accessToken)) { remote -> try { remote.delete(ifETag = lastETag, ifScheduleTag = lastScheduleTag) {} numDeleted++ @@ -712,6 +712,13 @@ abstract class SyncManager, out CollectionType: L Logger.log.log(Level.SEVERE, "Not authorized anymore", e) message = context.getString(R.string.sync_error_authentication_failed) syncResult.stats.numAuthExceptions++ + if (account.type.toLowerCase(Locale.getDefault()).contains("google")) { + /* TODO Investigate deeper why this exception sometimes happens + * https://gitlab.e.foundation/e/backlog/-/issues/3430 + */ + Logger.log.log(Level.WARNING, "Authorization error. Do not notify the user") + return + } } is HttpException, is DavException -> { Logger.log.log(Level.SEVERE, "HTTP/DAV exception", e) @@ -732,7 +739,10 @@ abstract class SyncManager, out CollectionType: L val contentIntent: Intent var viewItemAction: NotificationCompat.Action? = null - if (e is UnauthorizedException) { + 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) diff --git a/app/src/main/java/at/bitfire/davdroid/syncadapter/SyncUtils.kt b/app/src/main/java/at/bitfire/davdroid/syncadapter/SyncUtils.kt index a808dd905906e5d30aac8588a635d8c946eba3a2..c9e9bc5abb6fa76389898738f8ecefab7ed86278 100644 --- a/app/src/main/java/at/bitfire/davdroid/syncadapter/SyncUtils.kt +++ b/app/src/main/java/at/bitfire/davdroid/syncadapter/SyncUtils.kt @@ -17,6 +17,7 @@ import android.os.Build import androidx.annotation.WorkerThread import androidx.core.app.NotificationCompat import androidx.core.app.NotificationManagerCompat +import at.bitfire.davdroid.Constants import at.bitfire.davdroid.InvalidAccountException import at.bitfire.davdroid.PermissionUtils import at.bitfire.davdroid.R @@ -104,7 +105,7 @@ object SyncUtils { // check all accounts and (de)activate task provider(s) if a CalDAV service is defined val db = EntryPointAccessors.fromApplication(context, SyncUtilsEntryPoint::class.java).appDatabase() val accountManager = AccountManager.get(context) - for (account in accountManager.getAccountsByType(context.getString(R.string.account_type))) { + for (account in accountManager.accounts) { val hasCalDAV = db.serviceDao().getByAccountAndType(account.name, Service.TYPE_CALDAV) != null for (providerName in TaskProvider.ProviderName.values()) { val isSyncable = ContentResolver.getIsSyncable(account, providerName.authority) // may be -1 (unknown state) @@ -133,7 +134,7 @@ object SyncUtils { ContentResolver.setIsSyncable(account, authority, 1) try { val settings = AccountSettings(context, account) - val interval = settings.getSavedTasksSyncInterval() ?: settingsManager.getLong(Settings.DEFAULT_SYNC_INTERVAL) + val interval = settings.getSavedTasksSyncInterval() ?: Constants.DEFAULT_CALENDAR_SYNC_INTERVAL settings.setSyncInterval(authority, interval) } catch (e: InvalidAccountException) { // account has already been removed diff --git a/app/src/main/java/at/bitfire/davdroid/syncadapter/TasksSyncAdapterService.kt b/app/src/main/java/at/bitfire/davdroid/syncadapter/TasksSyncAdapterService.kt index 8b03e8035134f0e7908a924e6cf90494ff373ee6..c61070f8d14bd2384561d7280faf4d26cb6d1651 100644 --- a/app/src/main/java/at/bitfire/davdroid/syncadapter/TasksSyncAdapterService.kt +++ b/app/src/main/java/at/bitfire/davdroid/syncadapter/TasksSyncAdapterService.kt @@ -20,6 +20,11 @@ import at.bitfire.davdroid.resource.LocalTaskList import at.bitfire.davdroid.settings.AccountSettings import at.bitfire.ical4android.AndroidTaskList import at.bitfire.ical4android.TaskProvider +import android.os.AsyncTask +import androidx.core.app.NotificationCompat +import androidx.core.app.NotificationManagerCompat +import at.bitfire.davdroid.db.Credentials +import net.openid.appauth.AuthorizationService import okhttp3.HttpUrl import okhttp3.HttpUrl.Companion.toHttpUrl import org.dmfs.tasks.contract.TaskContract @@ -70,7 +75,42 @@ open class TasksSyncAdapterService: SyncAdapterService() { for (taskList in taskLists) { Logger.log.info("Synchronizing task list #${taskList.id} [${taskList.syncId}]") TasksSyncManager(context, account, accountSettings, httpClient.value, extras, authority, syncResult, taskList).let { - it.performSync() + val authState = accountSettings.credentials().authState + if (authState != null) { + if (authState.needsTokenRefresh) { + val tokenRequest = authState.createTokenRefreshRequest() + + AuthorizationService(context).performTokenRequest(tokenRequest) { 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) { diff --git a/app/src/main/java/at/bitfire/davdroid/syncadapter/TasksSyncManager.kt b/app/src/main/java/at/bitfire/davdroid/syncadapter/TasksSyncManager.kt index d1dbc363ea5d613e9374c1626853dc007a00564e..bb02ce57ff013a87331796736aaf749a6f3a78dc 100644 --- a/app/src/main/java/at/bitfire/davdroid/syncadapter/TasksSyncManager.kt +++ b/app/src/main/java/at/bitfire/davdroid/syncadapter/TasksSyncManager.kt @@ -50,7 +50,7 @@ class TasksSyncManager( override fun prepare(): Boolean { collectionURL = (localCollection.syncId ?: return false).toHttpUrlOrNull() ?: return false - davCollection = DavCalendar(httpClient.okHttpClient, collectionURL) + davCollection = DavCalendar(httpClient.okHttpClient, collectionURL, accountSettings.credentials().authState?.accessToken) return true } diff --git a/app/src/main/java/at/bitfire/davdroid/ui/AccountListFragment.kt b/app/src/main/java/at/bitfire/davdroid/ui/AccountListFragment.kt index a1fe1f1aeeb4b5afe55de2ee8e0546cc4d64f1cb..253ac1944e12e761372c24ee1e389765cda1debe 100644 --- a/app/src/main/java/at/bitfire/davdroid/ui/AccountListFragment.kt +++ b/app/src/main/java/at/bitfire/davdroid/ui/AccountListFragment.kt @@ -255,9 +255,14 @@ class AccountListFragment: Fragment() { val context = getApplication() val collator = Collator.getInstance() - val sortedAccounts = accountManager - .getAccountsByType(context.getString(R.string.account_type)) - .sortedArrayWith { a, b -> + val accountsFromManager = ArrayList() + val accountManager = AccountManager.get(context) + accountManager.getAccountsByType(context.getString(R.string.eelo_account_type)).forEach { accountsFromManager.add(it) } + accountManager.getAccountsByType(context.getString(R.string.google_account_type)).forEach { accountsFromManager.add(it) } + accountManager.getAccountsByType(context.getString(R.string.account_type)).forEach { accountsFromManager.add(it) } + + val sortedAccounts = accountsFromManager + .sortedWith { a, b -> collator.compare(a.name, b.name) } val accountsWithInfo = sortedAccounts.map { account -> diff --git a/app/src/main/java/at/bitfire/davdroid/ui/BaseAccountsDrawerHandler.kt b/app/src/main/java/at/bitfire/davdroid/ui/BaseAccountsDrawerHandler.kt index fb8bb50f4dc7d12610b179d2696eb6d83120fd84..151453809893ada9c7fdbc9dcd7a3f4cae1b9dd1 100644 --- a/app/src/main/java/at/bitfire/davdroid/ui/BaseAccountsDrawerHandler.kt +++ b/app/src/main/java/at/bitfire/davdroid/ui/BaseAccountsDrawerHandler.kt @@ -7,12 +7,9 @@ package at.bitfire.davdroid.ui import android.app.Activity import android.content.Context import android.content.Intent -import android.net.Uri import android.view.Menu import android.view.MenuItem -import android.widget.Toast import androidx.annotation.CallSuper -import at.bitfire.davdroid.BuildConfig import at.bitfire.davdroid.R /** @@ -20,15 +17,9 @@ import at.bitfire.davdroid.R */ abstract class BaseAccountsDrawerHandler: AccountsDrawerHandler { - companion object { - private const val BETA_FEEDBACK_URI = "mailto:play@bitfire.at?subject=${BuildConfig.APPLICATION_ID}/${BuildConfig.VERSION_NAME} feedback (${BuildConfig.VERSION_CODE})" - } - - @CallSuper override fun initMenu(context: Context, menu: Menu) { - if (BuildConfig.VERSION_NAME.contains("-alpha") || BuildConfig.VERSION_NAME.contains("-beta") || BuildConfig.VERSION_NAME.contains("-rc")) - menu.findItem(R.id.nav_beta_feedback).isVisible = true + // TODO Provide option for beta feedback } @CallSuper @@ -36,15 +27,6 @@ abstract class BaseAccountsDrawerHandler: AccountsDrawerHandler { when (item.itemId) { R.id.nav_about -> activity.startActivity(Intent(activity, AboutActivity::class.java)) - R.id.nav_beta_feedback -> - if (!UiUtils.launchUri( - activity, - Uri.parse(BETA_FEEDBACK_URI), - Intent.ACTION_SENDTO, - false - ) - ) - Toast.makeText(activity, R.string.install_email_client, Toast.LENGTH_LONG).show() R.id.nav_app_settings -> activity.startActivity(Intent(activity, AppSettingsActivity::class.java)) } diff --git a/app/src/main/java/at/bitfire/davdroid/ui/DebugInfoActivity.kt b/app/src/main/java/at/bitfire/davdroid/ui/DebugInfoActivity.kt index 8573047d7edc12db163756be1c6c951cb835583d..a8c63ad873a21176377ad95715f625e8c90b2a77 100644 --- a/app/src/main/java/at/bitfire/davdroid/ui/DebugInfoActivity.kt +++ b/app/src/main/java/at/bitfire/davdroid/ui/DebugInfoActivity.kt @@ -43,8 +43,8 @@ import at.bitfire.davdroid.log.Logger import at.bitfire.davdroid.resource.LocalAddressBook import at.bitfire.davdroid.settings.AccountSettings import at.bitfire.davdroid.settings.SettingsManager -import at.bitfire.ical4android.MiscUtils.ContentProviderClientHelper.closeCompat import at.bitfire.ical4android.TaskProvider.ProviderName +import at.bitfire.ical4android.util.MiscUtils.ContentProviderClientHelper.closeCompat import at.techbee.jtx.JtxContract import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.lifecycle.HiltViewModel @@ -65,6 +65,7 @@ import java.util.logging.Level import java.util.zip.ZipEntry import java.util.zip.ZipOutputStream import javax.inject.Inject +import kotlin.collections.ArrayList @AndroidEntryPoint class DebugInfoActivity: AppCompatActivity() { @@ -449,10 +450,11 @@ class DebugInfoActivity: AppCompatActivity() { writer.append('\n') writer.append("\nACCOUNTS\n\n") - // main accounts + val accountManager = AccountManager.get(context) - val mainAccounts = accountManager.getAccountsByType(context.getString(R.string.account_type)) - val addressBookAccounts = accountManager.getAccountsByType(context.getString(R.string.account_type_address_book)).toMutableList() + val mainAccounts = getMainAccounts(accountManager) + val addressBookAccounts = getAddressBookAccounts(accountManager) + for (account in mainAccounts) { dumpMainAccount(account, writer) @@ -489,6 +491,28 @@ class DebugInfoActivity: AppCompatActivity() { debugInfo.postValue(debugInfoFile) } + private fun getMainAccounts(accountManager: AccountManager): ArrayList { + val mainAccounts = ArrayList() + accountManager.getAccountsByType(context.getString(R.string.eelo_account_type)) + .forEach { mainAccounts.add(it) } + accountManager.getAccountsByType(context.getString(R.string.google_account_type)) + .forEach { mainAccounts.add(it) } + accountManager.getAccountsByType(context.getString(R.string.account_type)) + .forEach { mainAccounts.add(it) } + return mainAccounts + } + + private fun getAddressBookAccounts(accountManager: AccountManager): ArrayList { + 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) } + return addressBookAccounts + } + fun generateZip(onSuccess: (File) -> Unit) { viewModelScope.launch(Dispatchers.IO) { try { diff --git a/app/src/main/java/at/bitfire/davdroid/ui/ExceptionInfoFragment.kt b/app/src/main/java/at/bitfire/davdroid/ui/ExceptionInfoFragment.kt index 05df1bcf3c90743b6a7022de0689016eb8aa3a3c..81307123be919dcf332e245652aacb1a71628005 100644 --- a/app/src/main/java/at/bitfire/davdroid/ui/ExceptionInfoFragment.kt +++ b/app/src/main/java/at/bitfire/davdroid/ui/ExceptionInfoFragment.kt @@ -40,7 +40,7 @@ class ExceptionInfoFragment: DialogFragment() { else -> R.string.exception } - val dialog = MaterialAlertDialogBuilder(requireActivity()) + val dialog = MaterialAlertDialogBuilder(requireActivity(), R.style.CustomAlertDialogStyle) .setIcon(R.drawable.ic_error) .setTitle(title) .setMessage(exception::class.java.name + "\n" + exception.localizedMessage) diff --git a/app/src/main/java/at/bitfire/davdroid/ui/OseAccountsDrawerHandler.kt b/app/src/main/java/at/bitfire/davdroid/ui/OseAccountsDrawerHandler.kt index dd15c7bfc62ee167b288765b5ac430caf65d753f..368908788b720e6cd4cbdf2b8f210cb68aee979b 100644 --- a/app/src/main/java/at/bitfire/davdroid/ui/OseAccountsDrawerHandler.kt +++ b/app/src/main/java/at/bitfire/davdroid/ui/OseAccountsDrawerHandler.kt @@ -6,9 +6,7 @@ package at.bitfire.davdroid.ui import android.app.Activity import android.content.Intent -import android.net.Uri import android.view.MenuItem -import at.bitfire.davdroid.App import at.bitfire.davdroid.R import at.bitfire.davdroid.ui.webdav.WebdavMountsActivity import javax.inject.Inject @@ -18,50 +16,10 @@ import javax.inject.Inject */ class OseAccountsDrawerHandler @Inject constructor(): BaseAccountsDrawerHandler() { - companion object { - const val COMMUNITY_URL = "https://github.com/bitfireAT/davx5-ose/discussions" - } - override fun onNavigationItemSelected(activity: Activity, item: MenuItem) { when (item.itemId) { - - R.id.nav_twitter -> - UiUtils.launchUri( - activity, - Uri.parse("https://twitter.com/" + activity.getString(R.string.twitter_handle)) - ) - R.id.nav_webdav_mounts -> activity.startActivity(Intent(activity, WebdavMountsActivity::class.java)) - - R.id.nav_website -> - UiUtils.launchUri( - activity, - App.homepageUrl(activity) - ) - R.id.nav_manual -> - UiUtils.launchUri( - activity, - App.homepageUrl(activity).buildUpon().appendPath("manual").build() - ) - R.id.nav_faq -> - UiUtils.launchUri( - activity, - App.homepageUrl(activity).buildUpon().appendPath("faq").build() - ) - R.id.nav_community -> - UiUtils.launchUri(activity, Uri.parse(COMMUNITY_URL)) - R.id.nav_donate -> - UiUtils.launchUri( - activity, - App.homepageUrl(activity).buildUpon().appendPath("donate").build() - ) - R.id.nav_privacy -> - UiUtils.launchUri( - activity, - App.homepageUrl(activity).buildUpon().appendPath("privacy").build() - ) - else -> super.onNavigationItemSelected(activity, item) } diff --git a/app/src/main/java/at/bitfire/davdroid/ui/account/AccountActivity.kt b/app/src/main/java/at/bitfire/davdroid/ui/account/AccountActivity.kt index 607f25afdc35e1c4a5ae4bde201203910110c3a9..91ef9620d9d8c6d93dbf887db25b42a90f3e7fab 100644 --- a/app/src/main/java/at/bitfire/davdroid/ui/account/AccountActivity.kt +++ b/app/src/main/java/at/bitfire/davdroid/ui/account/AccountActivity.kt @@ -38,6 +38,8 @@ import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.NonCancellable import kotlinx.coroutines.launch +import at.bitfire.davdroid.MailAccountSyncHelper + import java.util.logging.Level import javax.inject.Inject @@ -112,7 +114,7 @@ class AccountActivity: AppCompatActivity() { } fun deleteAccount(menuItem: MenuItem) { - MaterialAlertDialogBuilder(this) + MaterialAlertDialogBuilder(this, R.style.CustomAlertDialogStyle) .setIcon(R.drawable.ic_error) .setTitle(R.string.account_delete_confirmation_title) .setMessage(R.string.account_delete_confirmation_text) @@ -125,29 +127,40 @@ class AccountActivity: AppCompatActivity() { private fun deleteAccount() { val accountManager = AccountManager.get(this) + val email = accountManager.getUserData(model.account, AccountSettings.KEY_EMAIL_ADDRESS) 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) + removeAccount(accountManager, email) 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) + removeAccountForOlderSdk(accountManager, email) + } + + private fun removeAccountForOlderSdk(accountManager: AccountManager, email: String?) { + accountManager.removeAccount(model.account, { future -> + try { + if (future.result) + Handler(Looper.getMainLooper()).post { + MailAccountSyncHelper.accountLoggedOut(applicationContext, email) + finish() + } + } catch (e: Exception) { + Logger.log.log(Level.SEVERE, "Couldn't remove account", e) + } + }, null) + } + + private fun removeAccount(accountManager: AccountManager, email: String?) { + accountManager.removeAccount(model.account, this, { future -> + try { + if (future.result.getBoolean(AccountManager.KEY_BOOLEAN_RESULT)) + Handler(Looper.getMainLooper()).post { + MailAccountSyncHelper.accountLoggedOut(applicationContext, email) + finish() + } + } catch (e: Exception) { + Logger.log.log(Level.SEVERE, "Couldn't remove account", e) + } + }, null) } diff --git a/app/src/main/java/at/bitfire/davdroid/ui/account/CollectionsFragment.kt b/app/src/main/java/at/bitfire/davdroid/ui/account/CollectionsFragment.kt index f4e197b46f34eea0d292f8fdf53a360b74a46e5b..4e195262cd07d009fe5f6ea8f44594d8828c74bb 100644 --- a/app/src/main/java/at/bitfire/davdroid/ui/account/CollectionsFragment.kt +++ b/app/src/main/java/at/bitfire/davdroid/ui/account/CollectionsFragment.kt @@ -12,6 +12,7 @@ import android.provider.ContactsContract import android.view.* import android.widget.PopupMenu import androidx.annotation.AnyThread +import androidx.core.content.ContextCompat import androidx.fragment.app.Fragment import androidx.fragment.app.FragmentManager import androidx.fragment.app.activityViewModels @@ -126,7 +127,7 @@ abstract class CollectionsFragment: Fragment(), SwipeRefreshLayout.OnRefreshList lifecycleScope.launch { val colors = data.flow.map { pagingData -> pagingData.map { collection -> - collection.color ?: Constants.DAVDROID_GREEN_RGBA + collection.color ?: ContextCompat.getColor(requireContext(), R.color.accentColor) } } data.flow.collectLatest { pagingData -> diff --git a/app/src/main/java/at/bitfire/davdroid/ui/account/CreateCalendarActivity.kt b/app/src/main/java/at/bitfire/davdroid/ui/account/CreateCalendarActivity.kt index 830368e85d8e13e37e1cdb778d04632e64426af4..453e64beb5b4e39daedaab43e1f68a5baa946a1d 100644 --- a/app/src/main/java/at/bitfire/davdroid/ui/account/CreateCalendarActivity.kt +++ b/app/src/main/java/at/bitfire/davdroid/ui/account/CreateCalendarActivity.kt @@ -28,7 +28,7 @@ import at.bitfire.davdroid.db.Collection import at.bitfire.davdroid.db.HomeSet import at.bitfire.davdroid.db.Service import at.bitfire.davdroid.ui.HomeSetAdapter -import at.bitfire.ical4android.DateUtils +import at.bitfire.ical4android.util.DateUtils import com.jaredrummler.android.colorpicker.ColorPickerDialog import com.jaredrummler.android.colorpicker.ColorPickerDialogListener import dagger.assisted.Assisted diff --git a/app/src/main/java/at/bitfire/davdroid/ui/account/RenameAccountFragment.kt b/app/src/main/java/at/bitfire/davdroid/ui/account/RenameAccountFragment.kt index ca8c3985979f8108a8ca5dea728df3726810dd22..b216edc913d480955c17197e06954df507ff909a 100644 --- a/app/src/main/java/at/bitfire/davdroid/ui/account/RenameAccountFragment.kt +++ b/app/src/main/java/at/bitfire/davdroid/ui/account/RenameAccountFragment.kt @@ -84,7 +84,7 @@ class RenameAccountFragment: DialogFragment() { this@RenameAccountFragment.requireActivity().finish() }) - return MaterialAlertDialogBuilder(requireActivity()) + return MaterialAlertDialogBuilder(requireActivity(), R.style.CustomAlertDialogStyle) .setTitle(R.string.account_rename) .setMessage(R.string.account_rename_new_name) .setView(layout) @@ -141,6 +141,17 @@ class RenameAccountFragment: DialogFragment() { } } + private fun getAddressBookAccounts(accountManager: AccountManager): ArrayList { + 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) } + return addressBookAccounts + } + @SuppressLint("Recycle") @WorkerThread fun onAccountRenamed(accountManager: AccountManager, oldAccount: Account, newName: String, syncIntervals: List>) { @@ -149,7 +160,8 @@ class RenameAccountFragment: DialogFragment() { // cancel maybe running synchronization ContentResolver.cancelSync(oldAccount, null) - for (addrBookAccount in accountManager.getAccountsByType(context.getString(R.string.account_type_address_book))) + + for (addrBookAccount in getAddressBookAccounts(accountManager)) ContentResolver.cancelSync(addrBookAccount, null) // update account name references in database @@ -166,7 +178,7 @@ class RenameAccountFragment: DialogFragment() { try { context.contentResolver.acquireContentProviderClient(ContactsContract.AUTHORITY)?.let { provider -> try { - for (addrBookAccount in accountManager.getAccountsByType(context.getString(R.string.account_type_address_book))) { + for (addrBookAccount in getAddressBookAccounts(accountManager)) { val addressBook = LocalAddressBook(context, addrBookAccount, provider) if (oldAccount == addressBook.mainAccount) addressBook.mainAccount = Account(newName, oldAccount.type) diff --git a/app/src/main/java/at/bitfire/davdroid/ui/account/SettingsActivity.kt b/app/src/main/java/at/bitfire/davdroid/ui/account/SettingsActivity.kt index b4a58e02fe022d9eadafdacfefc434cbdc519986..ddf18e5c716f5e48beed103ac93726a983088d33 100644 --- a/app/src/main/java/at/bitfire/davdroid/ui/account/SettingsActivity.kt +++ b/app/src/main/java/at/bitfire/davdroid/ui/account/SettingsActivity.kt @@ -5,6 +5,7 @@ package at.bitfire.davdroid.ui.account import android.accounts.Account +import android.accounts.AccountManager import android.annotation.SuppressLint import android.content.ContentResolver import android.content.Context @@ -119,6 +120,13 @@ class SettingsActivity: AppCompatActivity() { checkWifiPermissions() } + 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() { // preference group: sync findPreference(getString(R.string.settings_sync_interval_contacts_key))!!.let { @@ -219,6 +227,7 @@ class SettingsActivity: AppCompatActivity() { } // preference group: authentication + val prefCredentials = findPreference("credentials")!! val prefUserName = findPreference("username")!! val prefPassword = findPreference("password")!! val prefCertAlias = findPreference("certificate_alias")!! @@ -226,23 +235,39 @@ class SettingsActivity: AppCompatActivity() { prefUserName.summary = credentials.userName prefUserName.text = credentials.userName prefUserName.onPreferenceChangeListener = Preference.OnPreferenceChangeListener { _, newUserName -> - model.updateCredentials(Credentials(newUserName as String, credentials.password, credentials.certificateAlias)) + model.updateCredentials(Credentials(newUserName as String, credentials.password, credentials.authState, credentials.certificateAlias)) false } if (credentials.userName != null) { - prefPassword.isVisible = true - prefPassword.onPreferenceChangeListener = Preference.OnPreferenceChangeListener { _, newPassword -> - model.updateCredentials(Credentials(credentials.userName, newPassword as String, credentials.certificateAlias)) - false + if (credentials.authState != null) { + prefPassword.isVisible = false + prefCredentials.isVisible = true + prefCredentials.setOnPreferenceClickListener{launchSetup()} + } else { + prefPassword.isVisible = true + prefCredentials.isVisible = false + prefPassword.onPreferenceChangeListener = + Preference.OnPreferenceChangeListener { _, newPassword -> + model.updateCredentials( + Credentials( + credentials.userName, + newPassword as String, + credentials.authState, + credentials.certificateAlias + ) + ) + false + } } } else prefPassword.isVisible = false + prefCredentials.isVisible = false prefCertAlias.summary = credentials.certificateAlias ?: getString(R.string.settings_certificate_alias_empty) prefCertAlias.setOnPreferenceClickListener { KeyChain.choosePrivateKeyAlias(requireActivity(), { newAlias -> - model.updateCredentials(Credentials(credentials.userName, credentials.password, newAlias)) + model.updateCredentials(Credentials(credentials.userName, credentials.password, credentials.authState, newAlias)) }, null, null, null, -1, credentials.certificateAlias) true } 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 index 074aabd3d99834b23b6109fe522527e1c95ae308..35248273dd7012ac7dfe1643180d33c6e285f399 100644 --- a/app/src/main/java/at/bitfire/davdroid/ui/setup/AccountDetailsFragment.kt +++ b/app/src/main/java/at/bitfire/davdroid/ui/setup/AccountDetailsFragment.kt @@ -5,7 +5,9 @@ package at.bitfire.davdroid.ui.setup import android.accounts.Account +import android.accounts.AccountAuthenticatorResponse import android.accounts.AccountManager +import android.app.Activity import android.content.ContentResolver import android.content.Context import android.content.Intent @@ -16,12 +18,15 @@ import android.view.LayoutInflater import android.view.View import android.view.ViewGroup import android.widget.ArrayAdapter +import android.widget.Toast import androidx.fragment.app.Fragment import androidx.fragment.app.activityViewModels import androidx.fragment.app.viewModels import androidx.lifecycle.* +import at.bitfire.davdroid.Constants import at.bitfire.davdroid.DavService import at.bitfire.davdroid.InvalidAccountException +import at.bitfire.davdroid.MailAccountSyncHelper import at.bitfire.davdroid.R import at.bitfire.davdroid.databinding.LoginAccountDetailsBinding import at.bitfire.davdroid.db.AppDatabase @@ -31,7 +36,6 @@ import at.bitfire.davdroid.db.Service import at.bitfire.davdroid.log.Logger import at.bitfire.davdroid.resource.TaskUtils import at.bitfire.davdroid.settings.AccountSettings -import at.bitfire.davdroid.settings.Settings import at.bitfire.davdroid.settings.SettingsManager import at.bitfire.davdroid.syncadapter.AccountUtils import at.bitfire.davdroid.ui.account.AccountActivity @@ -64,8 +68,8 @@ class AccountDetailsFragment : Fragment() { // default account name model.name.value = - config.calDAV?.emails?.firstOrNull() - ?: loginModel.credentials?.userName + loginModel.credentials?.userName + ?: config.calDAV?.emails?.firstOrNull() ?: loginModel.credentials?.certificateAlias ?: loginModel.baseURI?.host @@ -74,6 +78,8 @@ class AccountDetailsFragment : Fragment() { if (settings.containsKey(AccountSettings.KEY_CONTACT_GROUP_METHOD)) v.contactGroupMethod.isEnabled = false + v.contactGroupMethod.setSelection(1) + // CalDAV-specific config.calDAV?.let { val accountNameAdapter = ArrayAdapter(requireActivity(), android.R.layout.simple_list_item_1, it.emails) @@ -99,6 +105,7 @@ class AccountDetailsFragment : Fragment() { v.createAccount.visibility = View.GONE model.createAccount( + requireActivity(), name, loginModel.credentials, config, @@ -107,11 +114,6 @@ class AccountDetailsFragment : Fragment() { if (success) { // close Create account activity requireActivity().finish() - // open Account activity for created account - val intent = Intent(requireActivity(), AccountActivity::class.java) - val account = Account(name, getString(R.string.account_type)) - intent.putExtra(AccountActivity.EXTRA_ACCOUNT, account) - startActivity(intent) } else { Snackbar.make(requireActivity().findViewById(android.R.id.content), R.string.login_account_not_created, Snackbar.LENGTH_LONG).show() @@ -134,6 +136,49 @@ class AccountDetailsFragment : Fragment() { } else v.contactGroupMethod.isEnabled = true + if (requireActivity().intent.getStringExtra(LoginActivity.SETUP_ACCOUNT_PROVIDER_TYPE) == LoginActivity.ACCOUNT_PROVIDER_EELO || + requireActivity().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( + requireActivity(), + name, + loginModel.credentials!!, + config, + GroupMethod.valueOf(groupMethodName) + ).observe(viewLifecycleOwner, Observer { success -> + if (success) { + Toast.makeText(context, R.string.message_account_added_successfully, Toast.LENGTH_LONG).show() + MailAccountSyncHelper.accountLoggedIn(context?.applicationContext) + requireActivity().setResult(Activity.RESULT_OK) + requireActivity().finish() + + if (requireActivity().intent.hasExtra(AccountManager + .KEY_ACCOUNT_AUTHENTICATOR_RESPONSE) && requireActivity().intent + .getParcelableExtra(AccountManager + .KEY_ACCOUNT_AUTHENTICATOR_RESPONSE) != null) { + requireActivity().intent + .getParcelableExtra(AccountManager + .KEY_ACCOUNT_AUTHENTICATOR_RESPONSE)?.onResult(null) + } + + if (requireActivity().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)) + requireActivity().startService(intent) + } + } + }) + } + } + return v.root } @@ -154,25 +199,73 @@ class AccountDetailsFragment : Fragment() { nameError.value = null } - fun createAccount(name: String, credentials: Credentials?, config: DavResourceFinder.Configuration, groupMethod: GroupMethod): LiveData { + fun createAccount(activity: Activity, name: String, credentials: Credentials?, config: DavResourceFinder.Configuration, groupMethod: GroupMethod): LiveData { val result = MutableLiveData() viewModelScope.launch(Dispatchers.Default + NonCancellable) { - val account = Account(name, context.getString(R.string.account_type)) + 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) + 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 (!AccountUtils.createAccount(context, account, userData, credentials?.password)) { - result.postValue(false) - return@launch + 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 { + result.postValue(false) + return@launch + } } + 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) + ContentResolver.setSyncAutomatically(account, context.getString(R.string.metered_edrive_authority), true) + // add entries for account to service DB Logger.log.log(Level.INFO, "Writing account configuration to database", config) try { val accountSettings = AccountSettings(context, account) - val defaultSyncInterval = settingsManager.getLong(Settings.DEFAULT_SYNC_INTERVAL) + val defaultSyncInterval = Constants.DEFAULT_CALENDAR_SYNC_INTERVAL val refreshIntent = Intent(context, DavService::class.java) refreshIntent.action = DavService.ACTION_REFRESH_COLLECTIONS @@ -180,7 +273,14 @@ class AccountDetailsFragment : Fragment() { val addrBookAuthority = context.getString(R.string.address_books_authority) if (config.cardDAV != null) { // insert CardDAV service - val id = insertService(name, Service.TYPE_CARDDAV, config.cardDAV) + val id = insertService( + credentials?.userName ?: "", + credentials?.authState?.jsonSerializeString(), + accountType, + addressBookAccountType, + Service.TYPE_CARDDAV, + config.cardDAV + ) // initial CardDAV account settings accountSettings.setGroupMethod(groupMethod) @@ -197,7 +297,14 @@ class AccountDetailsFragment : Fragment() { if (config.calDAV != null) { // insert CalDAV service - val id = insertService(name, Service.TYPE_CALDAV, config.calDAV) + val id = insertService( + 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) @@ -226,9 +333,16 @@ class AccountDetailsFragment : Fragment() { return result } - private fun insertService(accountName: String, type: String, info: DavResourceFinder.Configuration.ServiceInfo): Long { + private fun insertService( + accountName: String, + authState: String?, + accountType: String, + addressBookAccountType: String, + type: String, + info: DavResourceFinder.Configuration.ServiceInfo + ): Long { // insert service - val service = Service(0, accountName, type, info.principal) + val service = Service(0, accountName, authState, accountType, addressBookAccountType, type, info.principal) val serviceId = db.serviceDao().insertOrReplace(service) // insert home sets diff --git a/app/src/main/java/at/bitfire/davdroid/ui/setup/CreateAccountActivity.kt b/app/src/main/java/at/bitfire/davdroid/ui/setup/CreateAccountActivity.kt new file mode 100644 index 0000000000000000000000000000000000000000..4eeb391fbc01e4ff879531f4ea12656988c2b479 --- /dev/null +++ b/app/src/main/java/at/bitfire/davdroid/ui/setup/CreateAccountActivity.kt @@ -0,0 +1,35 @@ +/* + * Copyright © ECORP SAS 2022. + * 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.view.MenuItem +import androidx.appcompat.app.AppCompatActivity +import at.bitfire.davdroid.R + +class CreateAccountActivity : AppCompatActivity() { + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setContentView(R.layout.activity_create_account) + + supportFragmentManager.beginTransaction().apply { + replace(R.id.content, SendInviteFragment()) + commit() + } + } + + override fun onOptionsItemSelected(item: MenuItem): Boolean { + if (item.itemId == android.R.id.home) { + finish() + return true + } + return false + } +} \ No newline at end of file diff --git a/app/src/main/java/at/bitfire/davdroid/ui/setup/DavResourceFinder.kt b/app/src/main/java/at/bitfire/davdroid/ui/setup/DavResourceFinder.kt index f4f4b7bc01d5422af27ff5f49d2c516e0c34b494..a86b7697e820460bcc82d29ff2fa030f791eb7b3 100644 --- a/app/src/main/java/at/bitfire/davdroid/ui/setup/DavResourceFinder.kt +++ b/app/src/main/java/at/bitfire/davdroid/ui/setup/DavResourceFinder.kt @@ -163,7 +163,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 -> { @@ -176,7 +176,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 @@ -199,13 +199,15 @@ class DavResourceFinder( fun queryEmailAddress(principal: HttpUrl): List { val mailboxes = LinkedList() try { - DavResource(httpClient.okHttpClient, principal, log).propfind(0, CalendarUserAddressSet.NAME) { response, _ -> + DavResource(httpClient.okHttpClient, 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)) + if (uri.scheme.equals("mailto", true)) { + log.info("myenail: ${uri.schemeSpecificPart}") mailboxes.add(uri.schemeSpecificPart) + } } catch(e: URISyntaxException) { log.log(Level.WARNING, "Couldn't parse user address", e) } @@ -313,7 +315,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 @@ -401,7 +403,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") 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 index 445079025c282ff85b97e9a500042ca53e3c18fa..24f939cf89bd6a9865d958f60ad7d6ad6d70e64a 100644 --- a/app/src/main/java/at/bitfire/davdroid/ui/setup/DefaultLoginCredentialsFragment.kt +++ b/app/src/main/java/at/bitfire/davdroid/ui/setup/DefaultLoginCredentialsFragment.kt @@ -179,7 +179,7 @@ class DefaultLoginCredentialsFragment : Fragment() { loginModel.credentials = when { // username/password and client certificate model.loginUseUsernamePassword.value == true && model.loginUseClientCertificate.value == true -> - Credentials(username, password, alias) + Credentials(username, password, null, alias) // user/name password only model.loginUseUsernamePassword.value == true && model.loginUseClientCertificate.value == false -> 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 index 48afdcfe5255fcd4bc00226fd37b3e19e566206c..e90b228947cfce5f2087416072899f483bdccf97 100644 --- a/app/src/main/java/at/bitfire/davdroid/ui/setup/DetectConfigurationFragment.kt +++ b/app/src/main/java/at/bitfire/davdroid/ui/setup/DetectConfigurationFragment.kt @@ -17,10 +17,12 @@ import androidx.fragment.app.viewModels import androidx.lifecycle.AndroidViewModel import androidx.lifecycle.LiveData import androidx.lifecycle.MutableLiveData +import at.bitfire.davdroid.Constants import at.bitfire.davdroid.R import at.bitfire.davdroid.log.Logger import at.bitfire.davdroid.ui.DebugInfoActivity import com.google.android.material.dialog.MaterialAlertDialogBuilder +import at.bitfire.davdroid.ECloudAccountHelper import java.lang.ref.WeakReference import java.util.logging.Level import kotlin.concurrent.thread @@ -33,6 +35,11 @@ class DetectConfigurationFragment: Fragment() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) + if (model.blockProceedWithLogin(loginModel)) { + ECloudAccountHelper.showMultipleECloudAccountNotAcceptedDialog(requireActivity()) + return + } + model.detectConfiguration(loginModel).observe(this, { result -> // save result for next step loginModel.configuration = result @@ -63,6 +70,16 @@ class DetectConfigurationFragment: Fragment() { private var detectionThread: WeakReference? = null private var result = MutableLiveData() + /** + * User can't login using multiple ecloud accounts. + * This method checks if the login host is eelo_host then, check user already has any eelo account set up. + * If found eCloundAccount return true, false otherwise. + */ + fun blockProceedWithLogin(loginModel: LoginModel) : Boolean { + val context = getApplication() + return (loginModel.baseURI?.host.equals(Constants.EELO_SYNC_HOST) && ECloudAccountHelper.alreadyHasECloudAccount(context)) + } + fun detectConfiguration(loginModel: LoginModel): LiveData { synchronized(result) { if (detectionThread != null) @@ -108,10 +125,11 @@ class DetectConfigurationFragment: Fragment() { if (model.configuration?.encountered401 == true) message += "\n\n" + getString(R.string.login_username_password_wrong) - return MaterialAlertDialogBuilder(requireActivity()) + return MaterialAlertDialogBuilder(requireActivity(), R.style.CustomAlertDialogStyle) .setTitle(R.string.login_configuration_detection) .setIcon(R.drawable.ic_error) .setMessage(message) + .setCancelable(false) .setNeutralButton(R.string.login_view_logs) { _, _ -> val intent = DebugInfoActivity.IntentBuilder(requireActivity()) .withLogs(model.configuration?.logs) diff --git a/app/src/main/java/at/bitfire/davdroid/ui/setup/EeloAuthenticatorFragment.kt b/app/src/main/java/at/bitfire/davdroid/ui/setup/EeloAuthenticatorFragment.kt new file mode 100644 index 0000000000000000000000000000000000000000..626a2808534a0ba339458eb2929554e44f895dc4 --- /dev/null +++ b/app/src/main/java/at/bitfire/davdroid/ui/setup/EeloAuthenticatorFragment.kt @@ -0,0 +1,198 @@ +/* + * Copyright ECORP SAS 2022 + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package at.bitfire.davdroid.ui.setup + +import android.content.Context +import android.net.ConnectivityManager +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.Toast +import androidx.core.widget.doOnTextChanged +import androidx.fragment.app.Fragment +import androidx.fragment.app.activityViewModels +import androidx.fragment.app.viewModels +import at.bitfire.davdroid.Constants +import at.bitfire.davdroid.ECloudAccountHelper +import at.bitfire.davdroid.R +import at.bitfire.davdroid.databinding.FragmentEeloAuthenticatorBinding +import at.bitfire.davdroid.db.Credentials +import kotlinx.android.synthetic.main.fragment_eelo_authenticator.* +import kotlinx.android.synthetic.main.fragment_eelo_authenticator.view.* +import java.net.URI + +class EeloAuthenticatorFragment : Fragment() { + + private val model by viewModels() + private val loginModel by activityViewModels() + + val TOGGLE_BUTTON_CHECKED_KEY = "toggle_button_checked" + var toggleButtonState = false + + private fun isNetworkAvailable(): Boolean { + val connectivityManager = requireActivity().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? { + + if (!isNetworkAvailable()) { + Toast.makeText(context, "Please check your internet connection", Toast.LENGTH_LONG).show() + requireActivity().finish() + } + + val v = FragmentEeloAuthenticatorBinding.inflate(inflater, container, false) + v.lifecycleOwner = this + v.model = model + + v.root.server_toggle_button.setOnClickListener() { expandCollapse() } + + v.root.sign_in.setOnClickListener { login() } + + v.root.urlpwd_user_name.doOnTextChanged { text, _, _, _ -> + val domain = computeDomain(text) + if (domain.isEmpty()) { + requireView().urlpwd_server_uri_layout.hint = getString(R.string.login_server_uri) + } else { + requireView().urlpwd_server_uri_layout.hint = getString(R.string.login_server_uri_custom, domain) + } + } + + // 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) { + v.root.server_toggle_button.setCompoundDrawablesWithIntrinsicBounds(null, null , resources.getDrawable(R.drawable.ic_expand_less), null) + v.root.urlpwd_server_uri_layout.setVisibility(View.VISIBLE) + v.root.urlpwd_server_uri.setEnabled(true) + } else { + v.root.server_toggle_button.setCompoundDrawablesWithIntrinsicBounds(null, null , resources.getDrawable(R.drawable.ic_expand_more), null) + v.root.urlpwd_server_uri_layout.setVisibility(View.GONE) + v.root.urlpwd_server_uri.setEnabled(false) + } + return v.root + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + if (model.blockProceedWithLogin()) { + ECloudAccountHelper.showMultipleECloudAccountNotAcceptedDialog(requireActivity()) + } + } + + override fun onSaveInstanceState(outState: Bundle) { + outState.putBoolean(TOGGLE_BUTTON_CHECKED_KEY, toggleButtonState) + super.onSaveInstanceState(outState) + } + + private fun computeDomain(username: CharSequence?) : String { + var domain = "" + if (!username.isNullOrEmpty() && username.toString().contains("@")) { + var dns = username.toString().substringAfter("@") + if (dns == Constants.E_SYNC_URL) { + dns = Constants.EELO_SYNC_HOST + } + domain = "https://$dns" + } + return domain + } + + private fun login() { + if (!isNetworkAvailable()) { + Toast.makeText(context, "Please check your internet connection", Toast.LENGTH_LONG).show() + requireActivity().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 = requireView().urlpwd_server_uri.text.toString() + + if (serverUrl.isEmpty()) { + serverUrl = computeDomain(requireView().urlpwd_user_name.text.toString()) + } + + fun validateUrl() { + + 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 = requireView().urlpwd_user_name.text.toString() + val password = requireView().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() { + if (!toggleButtonState) { + server_toggle_button.setCompoundDrawablesWithIntrinsicBounds(null, null , resources.getDrawable(R.drawable.ic_expand_less), null) + urlpwd_server_uri_layout.setVisibility(View.VISIBLE) + urlpwd_server_uri.setEnabled(true) + toggleButtonState = true + } else { + server_toggle_button.setCompoundDrawablesWithIntrinsicBounds(null, null , resources.getDrawable(R.drawable.ic_expand_more), null) + urlpwd_server_uri_layout.setVisibility(View.GONE) + urlpwd_server_uri.setEnabled(false) + toggleButtonState = false + } + } +} diff --git a/app/src/main/java/at/bitfire/davdroid/ui/setup/EeloAuthenticatorModel.kt b/app/src/main/java/at/bitfire/davdroid/ui/setup/EeloAuthenticatorModel.kt new file mode 100644 index 0000000000000000000000000000000000000000..ce607af20cf88c454b59cc16796c6541ecf9967c --- /dev/null +++ b/app/src/main/java/at/bitfire/davdroid/ui/setup/EeloAuthenticatorModel.kt @@ -0,0 +1,70 @@ +/* + * Copyright ECORP SAS 2022 + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package at.bitfire.davdroid.ui.setup + +import android.app.Application +import android.content.Intent +import androidx.lifecycle.AndroidViewModel +import androidx.lifecycle.MutableLiveData +import at.bitfire.davdroid.ECloudAccountHelper + +class EeloAuthenticatorModel(application: Application) : AndroidViewModel(application) { + + 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 + } + + fun blockProceedWithLogin(): Boolean { + val context = getApplication() + return ECloudAccountHelper.alreadyHasECloudAccount(context) + } +} diff --git a/app/src/main/java/at/bitfire/davdroid/ui/setup/GoogleAuthenticatorFragment.kt b/app/src/main/java/at/bitfire/davdroid/ui/setup/GoogleAuthenticatorFragment.kt new file mode 100644 index 0000000000000000000000000000000000000000..cba6d8e54663fc4620e9008eae6a2980720f160a --- /dev/null +++ b/app/src/main/java/at/bitfire/davdroid/ui/setup/GoogleAuthenticatorFragment.kt @@ -0,0 +1,434 @@ +/* + * Copyright ECORP SAS 2022 + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package at.bitfire.davdroid.ui.setup + +import android.app.Activity +import android.app.PendingIntent +import android.content.Context +import android.content.Intent +import android.net.ConnectivityManager +import android.os.AsyncTask +import android.os.Bundle +import android.os.Handler +import android.os.Looper +import android.text.Layout +import android.text.SpannableString +import android.text.style.AlignmentSpan +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.Toast +import androidx.fragment.app.Fragment +import androidx.fragment.app.activityViewModels +import androidx.fragment.app.viewModels +import at.bitfire.davdroid.R +import at.bitfire.davdroid.authorization.IdentityProvider +import at.bitfire.davdroid.databinding.FragmentGoogleAuthenticatorBinding +import at.bitfire.davdroid.db.Credentials +import com.google.android.material.dialog.MaterialAlertDialogBuilder +import net.openid.appauth.* +import org.json.JSONException +import org.json.JSONObject +import java.io.BufferedReader +import java.io.IOException +import java.io.InputStream +import java.io.InputStreamReader +import java.net.HttpURLConnection +import java.net.MalformedURLException +import java.net.URI +import java.net.URL + +class GoogleAuthenticatorFragment : Fragment(), AuthorizationService.TokenResponseCallback { + + private val model by viewModels() + private val loginModel by activityViewModels() + + 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 = requireActivity().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 { + // Initialise the authorization service + authorizationService = AuthorizationService(requireContext()) + + val v = FragmentGoogleAuthenticatorBinding.inflate(inflater, container, false) + v.lifecycleOwner = this + v.model = model + + activity?.intent?.let { + model.initialize(it) + val builder = MaterialAlertDialogBuilder(requireContext(), R.style.CustomAlertDialogStyle) + + if (!with(it) { getBooleanExtra(LoginActivity.ACCOUNT_PROVIDER_GOOGLE_AUTH_COMPLETE, false) }) { + val title = SpannableString(getString(R.string.google_alert_title)) + // alert dialog title align center + title.setSpan( + AlignmentSpan.Standard(Layout.Alignment.ALIGN_CENTER), + 0, + title.length, + 0 + ) + + builder.setTitle(title) + builder.setMessage(getString(R.string.google_alert_message)) + builder.setPositiveButton(android.R.string.yes) { dialog, which -> + // 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 if (!isNetworkAvailable()) { + Toast.makeText(context, "Please check your internet connection", Toast.LENGTH_LONG).show() + requireActivity().finish() + } else { + Toast.makeText(context, "Login failed, please try again later", Toast.LENGTH_LONG).show() + requireActivity().finish() + } + } + + if (idp.name == getString(R.string.google_name)) { + // Get configurations for the Google account provider + idp.retrieveConfig(context, retrieveCallback) + } + } + } + builder.setCancelable(false) + + val dialog = builder.create() + dialog.show() + } + else { + if (authState == null) { + val response = AuthorizationResponse.fromIntent(requireActivity().intent) + val ex = AuthorizationException.fromIntent(requireActivity().intent) + authState = AuthState(response, ex) + + if (response != null) { + exchangeAuthorizationCode(response) + } else if (!isNetworkAvailable()) { + Toast.makeText(context, "Please check your internet connection", Toast.LENGTH_LONG).show() + requireActivity().finish() + } else { + Toast.makeText(context, "Login failed, please try again later", Toast.LENGTH_LONG).show() + requireActivity().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() + requireActivity().finish() + } + + val authRequest = AuthorizationRequest.Builder( + serviceConfig, + idp.clientId, + ResponseTypeValues.CODE, + idp.redirectUri) + .setScope(idp.scope) + .build() + + authorizationService?.performAuthorizationRequest( + authRequest, + createPostAuthorizationIntent( + requireContext(), + 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() + requireActivity().finish() + } + + val additionalParams = HashMap() + if (getClientSecretFromIntent(requireActivity().intent) != null) { + additionalParams["client_secret"] = getClientSecretFromIntent(requireActivity().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() + requireActivity().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() + requireActivity().finish() + } + + val discoveryDoc = getDiscoveryDocFromIntent(requireActivity().intent) + + if (!authState!!.isAuthorized + || discoveryDoc == null + || discoveryDoc.userinfoEndpoint == null) { + Toast.makeText(context, "Login failed, please try again later", Toast.LENGTH_LONG).show() + requireActivity().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() + requireActivity().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(requireActivity().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() + requireActivity().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() + requireActivity().finish() + } + + } + else { + Toast.makeText(context, "Login failed, please try again later", Toast.LENGTH_LONG).show() + requireActivity().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/at/bitfire/davdroid/ui/setup/GoogleAuthenticatorModel.kt b/app/src/main/java/at/bitfire/davdroid/ui/setup/GoogleAuthenticatorModel.kt new file mode 100644 index 0000000000000000000000000000000000000000..d7721753794b516c2f7930a8f300603a895785cd --- /dev/null +++ b/app/src/main/java/at/bitfire/davdroid/ui/setup/GoogleAuthenticatorModel.kt @@ -0,0 +1,48 @@ +package at.bitfire.davdroid.ui.setup + +import android.content.Intent +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/at/bitfire/davdroid/ui/setup/InviteSuccessfulFragment.kt b/app/src/main/java/at/bitfire/davdroid/ui/setup/InviteSuccessfulFragment.kt new file mode 100644 index 0000000000000000000000000000000000000000..4ee8cc6fb57bdacc7e81ed7e8d7bee5a1026ddc6 --- /dev/null +++ b/app/src/main/java/at/bitfire/davdroid/ui/setup/InviteSuccessfulFragment.kt @@ -0,0 +1,66 @@ +/* + * Copyright © ECORP SAS 2022. + * 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.AccountManager +import android.accounts.AccountManagerCallback +import android.app.Activity.RESULT_OK +import android.content.Intent +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.Button +import android.widget.TextView +import androidx.appcompat.app.AppCompatActivity +import androidx.fragment.app.Fragment +import at.bitfire.davdroid.R + +class InviteSuccessfulFragment : Fragment() { + + override fun onCreateView( + inflater: LayoutInflater, container: ViewGroup?, + savedInstanceState: Bundle? + ): View? { + (activity as AppCompatActivity?)?.supportActionBar?.hide() + return inflater.inflate(R.layout.fragment_invite_successful, container, false) + } + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + + val instructions = view.findViewById(R.id.instructions) + val formattedText = view.context.getText(R.string.instructions) + instructions.text = formattedText + view.findViewById