diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml new file mode 100644 index 0000000000000000000000000000000000000000..6eba7446fd98cea72207349484a77b44cb4f0040 --- /dev/null +++ b/.github/FUNDING.yml @@ -0,0 +1,3 @@ + +liberapay: DAVx5 +custom: [ 'https://www.davx5.com/donate' ] diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000000000000000000000000000000000000..abd6190902776ab41154eab057010ff082d94660 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,39 @@ +name: Create release +on: + push: + tags: + - v* +jobs: + build: + name: Create release + permissions: + contents: write + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v2 + with: + submodules: true + - uses: actions/setup-java@v2 + with: + distribution: 'temurin' + java-version: 11 + cache: 'gradle' + - uses: gradle/wrapper-validation-action@v1 + + - name: Prepare keystore + run: echo ${{ secrets.android_keystore_base64 }} | base64 -d >$GITHUB_WORKSPACE/keystore.jks + + - name: Build signed package + run: ./gradlew app:assembleRelease + env: + ANDROID_KEYSTORE: ${{ github.workspace }}/keystore.jks + ANDROID_KEYSTORE_PASSWORD: ${{ secrets.android_keystore_password }} + ANDROID_KEY_ALIAS: ${{ secrets.android_key_alias }} + ANDROID_KEY_PASSWORD: ${{ secrets.android_key_password }} + + - name: Create Github release + 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 + fail_on_unmatched_files: true diff --git a/.github/workflows/test-dev.yml b/.github/workflows/test-dev.yml new file mode 100644 index 0000000000000000000000000000000000000000..503eeb7cb00238af7713cc6e2911c046ac1e0b1e --- /dev/null +++ b/.github/workflows/test-dev.yml @@ -0,0 +1,62 @@ +name: Development tests +on: [push, pull_request] +jobs: + test: + name: Tests without emulator + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v2 + with: + submodules: true + - uses: actions/setup-java@v2 + with: + distribution: zulu + java-version: 11 + cache: gradle + - uses: gradle/wrapper-validation-action@v1 + + - name: Run lint and unit tests + run: ./gradlew app:check + - name: Archive results + uses: actions/upload-artifact@v2 + with: + name: test-results + path: | + app/build/outputs/lint* + app/build/reports + + test_on_emulator: + name: Tests with emulator + runs-on: privileged + container: + image: ghcr.io/bitfireat/docker-android-ci:main + options: --privileged + env: + ANDROID_HOME: /sdk + ANDROID_AVD_HOME: /root/.android/avd + steps: + - uses: actions/checkout@v2 + with: + submodules: true + - uses: gradle/wrapper-validation-action@v1 + + - name: Cache gradle dependencies + uses: actions/cache@v2 + with: + key: ${{ runner.os }} + path: | + ~/.gradle/caches + ~/.gradle/wrapper + + - name: Start emulator + run: start-emulator.sh + - name: Run connected tests + run: ./gradlew app:connectedCheck + - name: Archive results + if: always() + uses: actions/upload-artifact@v2 + with: + name: test-results + path: | + app/build/reports + diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 8189e7a08da23c30f970e5928a3d8d2c29c13779..c947008e814da4e062c9aa9854b7fe419442033e 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -1,31 +1,24 @@ -image: registry.gitlab.com/bitfireat/docker-android-emulator:latest +image: "registry.gitlab.e.foundation/e/os/docker-android-apps-cicd:latest" + +stages: + - build before_script: - - git submodule update --init --recursive - - export GRADLE_USER_HOME=`pwd`/.gradle; chmod +x gradlew + - git submodule 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/ - -test: - tags: - - privileged - script: - - start-emulator.sh - - ./gradlew app:check app:connectedCheck - artifacts: - paths: - - app/build/outputs/lint-results-debug.html - - app/build/reports - - build/reports + - .gradle/ -pages: +build: + stage: build script: - - ./gradlew app:dokka - - mkdir public && mv app/build/dokka public + - ./gradlew build -x test artifacts: paths: - - public - only: - - master-ose + - app/build/outputs/apk/standard/ diff --git a/.gitmodules b/.gitmodules index 8e63578264313b75ccb2f6c0392d72ae87c1fc60..647569050cd2c2f9c29040c5126fb02baecae1c2 100644 --- a/.gitmodules +++ b/.gitmodules @@ -1,9 +1,13 @@ [submodule "ical4android"] path = ical4android - url = https://gitlab.com/bitfireAT/ical4android.git + url = https://github.com/bitfireAT/ical4android.git [submodule "vcard4android"] path = vcard4android - url = https://gitlab.com/bitfireAT/vcard4android.git + url = https://github.com/bitfireAT/vcard4android.git [submodule "cert4android"] path = cert4android - url = https://gitlab.com/bitfireAT/cert4android.git + 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/.tx/config b/.tx/config new file mode 100644 index 0000000000000000000000000000000000000000..37d6937ea3f465d84b005e1281ab7f76d1d1f0a3 --- /dev/null +++ b/.tx/config @@ -0,0 +1,30 @@ +[main] +host = https://www.transifex.com + +[davx5.app] +source_file = app/src/main/res/values/strings.xml +source_lang = en +minimum_perc = 0 +file_filter = app/src/main/res/values-/strings.xml +trans.de = app/src/main/res/values-de/strings.xml +trans.tr_TR = app/src/main/res/values-tr/strings.xml +trans.zh_CN = app/src/main/res/values-zh/strings.xml +type = ANDROID + +[davx5.metadata-short-description] +file_filter = fastlane/metadata/android//short_description.txt +minimum_perc = 100 +source_file = fastlane/metadata/android/en-US/short_description.txt +source_lang = en +trans.sl_SI = fastlane/metadata/android/sl-SI/short_description.txt +trans.zh_CN = fastlane/metadata/android/zh/short_description.txt +type = TXT + +[davx5.metadata-full-description] +file_filter = fastlane/metadata/android//full_description.txt +minimum_perc = 100 +source_file = fastlane/metadata/android/en-US/full_description.txt +source_lang = en +trans.sl_SI = fastlane/metadata/android/sl-SI/full_description.txt +trans.zh_CN = fastlane/metadata/android/zh/full_description.txt +type = TXT diff --git a/AUTHORS b/AUTHORS new file mode 100644 index 0000000000000000000000000000000000000000..990d9f6a50eb5d399e06badc9e75fe85d4a306d2 --- /dev/null +++ b/AUTHORS @@ -0,0 +1,21 @@ +# This is the list of significant contributors to DAVx5. +# +# This does not necessarily list everyone who has contributed work. +# To see the full list of contributors, see the revision history in +# source control. + +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/CONTRIBUTING.md b/CONTRIBUTING.md index 6d85563ddf25caf53a2e216b712f86a9916e2b11..0ac7be253f85d6813a882acbe6086097d0977bd6 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,47 +1,39 @@ -Contributing to DAVx⁵ -===================== - **Thank you for your interest in contributing to DAVx⁵!** -Because you're reading this, you're probably interested in -contributing to the DAVx⁵ code. [Other ways to contribute: -see here.](https://www.davx5.com/donate#c306) -To contribute: +# Licensing + +All work in this repository is [licensed under the GPLv3](LICENSE). + +We (bitfire.at, initial and main contributors) are also asking you to give us +permission to use your contribution for related non-open source projects +like [Managed DAVx⁵](https://www.davx5.com/organizations/managed-davx5). + +If you send us a pull request, our CLA bot will ask you to sign the +Contributor's License Agreement so that we can use your contribution. -1. It's good idea to have a look at the [DAVx⁵ Roadmap](https://gitlab.com/bitfireAT/davx5-ose/wikis/Roadmap) - to see whether the change is already planned. Maybe there's even a link to a - corresponding forum thread there. -1. Determine which project the changes shall go to. There's - the DAVx⁵ main project (this repo), and the [related - libraries](README.md). -1. Please post to the [DAVx⁵ development forum](https://www.davx5.com/forums) - before doing actual work (unless you do it only for yourself, of course). - This will help to coordinate activities and you'll also get hints - about where to start and possible pitfalls. -1. Fork the repository. -1. Do the changes in your repository. -1. Submit a pull request to the original project. -1. Post in the forum again (to make sure the pull request is being notified). +# Copyright -Questions, discussion -===================== +Make sure that every file that contains significant work (at least every code file) +starts with the copyright header: -We're happy to see questions, discussions etc. in the -[DAVx⁵ development forum](https://www.davx5.com/forums)! +``` +/*************************************************************************************************** + * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. + **************************************************************************************************/ +``` +You can set this in Android Studio: -Licensing -========= +1. Settings / Editor / Copyright / Copyright Profiles +2. Paste the text above (without the stars). +3. Set Formatting: separator before and after, length: 100. +4. Set this copyright profile as the default profile for the project. -All code has to be licensed under the GPL. -We (bitfire.at, initial developers) are also asking you to double-license the -code so that we can also use it for related non-open source projects like -[Managed DAVx⁵](https://www.davx5.com/organizations/managed-davx5). +# Authors -Please find more about this in the Contributor's License Agreement (CLA) -we'll send to you if you want to contribute. +If you make significant contributions, feel free to add yourself to the [AUTHORS file](AUTHORS). diff --git a/README.md b/README.md index 2a68c139c62c8475b20252d9c600261e3da6abf1..af2f68477b798c6a35d1ae1673c5c1f40aba8fec 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,7 @@ -![DAVx⁵ logo](app/src/main/res/mipmap-xxxhdpi/ic_launcher.png) +![accountManager logo](app/src/main/res/mipmap-xxxhdpi/ic_launcher.png) +Account Manager -DAVx⁵ -======== +Account Manager is a fork of DAVx⁵. Please see the [DAVx⁵ Web site](https://www.davx5.com) for comprehensive information about DAVx⁵. @@ -10,27 +10,24 @@ DAVx⁵ is licensed under the [GPLv3 License](LICENSE). News and updates: [@davx5app](https://twitter.com/davx5app) on Twitter -Help, discussion, feature requests, bug reports and "issues": [DAVx⁵ forums](https://www.davx5.com/forums) - -**If you want to support DAVx⁵, please consider [donating to DAVx⁵](https://www.davx5.com/donate) -or [purchasing it](https://www.davx5.com/download).** - -Generated KDoc: https://bitfireAT.gitlab.io/davx5-ose/dokka/app/ +**Help, discussion, feature requests, bug reports: [DAVx⁵ forums](https://www.davx5.com/forums)** Parts of DAVx⁵ have been outsourced into these libraries: -* [cert4android](https://gitlab.com/bitfireAT/cert4android) – custom certificate management -* [dav4jvm](https://gitlab.com/bitfireAT/dav4jvm) – WebDAV/CalDav/CardDAV framework -* [ical4android](https://gitlab.com/bitfireAT/ical4android) – iCalendar processing and Calendar Provider access -* [vcard4android](https://gitlab.com/bitfireAT/vcard4android) – vCard processing and Contacts Provider access +* [cert4android](https://github.com/bitfireAT/cert4android) – custom certificate management +* [dav4jvm](https://github.com/bitfireAT/dav4jvm) – WebDAV/CalDav/CardDAV framework +* [ical4android](https://github.com/bitfireAT/ical4android) – iCalendar processing and Calendar Provider access +* [vcard4android](https://github.com/bitfireAT/vcard4android) – vCard processing and Contacts Provider access + +**If you want to support DAVx⁵, please consider [donating to DAVx⁵](https://www.davx5.com/donate) +or [purchasing it](https://www.davx5.com/download).** USED THIRD-PARTY LIBRARIES ========================== -Those libraries are used by DAVx⁵ (alphabetically): +The most important libraries which are used by DAVx⁵ (alphabetically): -* [Color Picker](https://github.com/jaredrummler/ColorPicker) – [Apache License, Version 2.0](https://github.com/jaredrummler/ColorPicker/LICENSE) * [dnsjava](http://www.xbill.org/dnsjava/) – [BSD License](http://www.xbill.org/dnsjava/dnsjava-current/LICENSE) * [ez-vcard](https://github.com/mangstadt/ez-vcard) – [New BSD License](http://opensource.org/licenses/BSD-3-Clause) * [iCal4j](https://github.com/ical4j/ical4j) – [New BSD License](http://sourceforge.net/p/ical4j/ical4j/ci/default/tree/LICENSE) diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000000000000000000000000000000000000..71610447029b934ee251f9a627d2778f75003caf --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,5 @@ +# Security Policy + +## Reporting a Vulnerability + +Please report security vulnerabilities using our [secure support form](https://www.davx5.com/support) or via email to support-en@davx5.com. diff --git a/app/build.gradle b/app/build.gradle index 29c069c27a381bdaa45e04392dc511250c9a9248..d39390944af99f79a82b4d506c12dac9015f9d60 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -1,147 +1,186 @@ -/* - * Copyright (c) Ricki Hirner (bitfire web engineering). - * All rights reserved. This program and the accompanying materials - * are made available under the terms of the GNU Public License v3.0 - * which accompanies this distribution, and is available at - * http://www.gnu.org/licenses/gpl.html - */ +/*************************************************************************************************** + * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. + **************************************************************************************************/ 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' -apply plugin: 'org.jetbrains.dokka' android { - compileSdkVersion 29 - buildToolsVersion '29.0.2' + compileSdkVersion 32 + buildToolsVersion '32.0.0' + + aaptOptions { + additionalParameters '-I', 'e-ui-sdk.jar' + } defaultConfig { - applicationId "at.bitfire.davdroid" + applicationId "foundation.e.accountmanager" - versionCode 328 - versionName '2.6.3' + versionCode 402030200 + versionName '4.2.3.2' buildConfigField "long", "buildTime", System.currentTimeMillis() + "L" - buildConfigField "boolean", "customCerts", "true" - minSdkVersion 19 // Android 4.4 - targetSdkVersion 29 // Android 10.0 - multiDexEnabled true // >64k methods for Android 4.4 + setProperty "archivesBaseName", "davx5-ose-" + getVersionName() + + minSdkVersion 24 // Android 7.1 + targetSdkVersion 32 // Android 12v2 buildConfigField "String", "userAgent", "\"DAVx5\"" + buildConfigField "String", "EMAIL_KEY", "\"${emailKey()}\"" + + testInstrumentationRunner "at.bitfire.davdroid.CustomTestRunner" - // when using this, make sure that notification icons are real bitmaps - vectorDrawables.useSupportLibrary = true + kapt { + arguments { + arg("room.schemaLocation", "$projectDir/schemas") + } + } } compileOptions { + // enable because ical4android requires desugaring + coreLibraryDesugaringEnabled true + sourceCompatibility JavaVersion.VERSION_1_8 targetCompatibility JavaVersion.VERSION_1_8 } + kotlinOptions { + jvmTarget = "1.8" + } - dataBinding.enabled = true + buildFeatures { + viewBinding = true + dataBinding = true + } flavorDimensions "distribution" productFlavors { standard { - versionName "2.6.3-ose" + versionNameSuffix "-ose" + } + } + + sourceSets { + androidTest.assets.srcDirs += files("$projectDir/schemas".toString()) + } + + signingConfigs { + bitfire { + storeFile file(System.getenv("ANDROID_KEYSTORE") ?: "/dev/null") + storePassword System.getenv("ANDROID_KEYSTORE_PASSWORD") + keyAlias System.getenv("ANDROID_KEY_ALIAS") + keyPassword System.getenv("ANDROID_KEY_PASSWORD") } } buildTypes { debug { - minifyEnabled false } release { minifyEnabled true - proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.txt' + proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules-release.pro' + + shrinkResources true } } - lintOptions { - disable 'GoogleAppIndexingWarning' // we don't need Google indexing, thanks - disable 'ImpliedQuantity', 'MissingQuantity' // quantities from Transifex may vary - disable 'MissingTranslation', 'ExtraTranslation' // translations from Transifex are not always up to date - disable 'RtlEnabled' - disable 'RtlHardcoded' - disable 'Typos' + lint { + disable 'GoogleAppIndexingWarning', 'ImpliedQuantity', 'MissingQuantity', 'MissingTranslation', 'ExtraTranslation', 'RtlEnabled', 'RtlHardcoded', 'Typos', 'NullSafeMutableLiveData' } + packagingOptions { - exclude 'META-INF/DEPENDENCIES' - exclude 'META-INF/LICENSE' + resources { + excludes += ['META-INF/*.md'] + } } defaultConfig { - testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" - } - - dokka.configuration { - sourceLink { - url = "https://gitlab.com/bitfireAT/davx5-ose/tree/master-ose/" - lineSuffix = "#L" - } - jdkVersion = 7 - - externalDocumentationLink { - url = new URL("https://bitfireat.gitlab.io/cert4android/dokka/cert4android/") - packageListUrl = new URL("https://bitfireat.gitlab.io/cert4android/dokka/cert4android/package-list") - } - externalDocumentationLink { - url = new URL("https://bitfireat.gitlab.io/dav4jvm/dokka/dav4jvm/") - packageListUrl = new URL("https://bitfireat.gitlab.io/dav4jvm/dokka/dav4jvm/package-list") - } - externalDocumentationLink { - url = new URL("https://bitfireat.gitlab.io/ical4android/dokka/ical4android/") - packageListUrl = new URL("https://bitfireat.gitlab.io/ical4android/dokka/ical4android/package-list") - } - externalDocumentationLink { - url = new URL("https://bitfireat.gitlab.io/vcard4android/dokka/vcard4android/") - packageListUrl = new URL("https://bitfireat.gitlab.io/vcard4android/dokka/vcard4android/package-list") - } + 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 'androidx.multidex:multidex:2.0.1' - implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk8:${versions.kotlin}" + implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:${versions.kotlin}" + implementation "org.jetbrains.kotlinx:kotlinx-coroutines-core:1.6.1" + coreLibraryDesugaring 'com.android.tools:desugar_jdk_libs:1.1.6' - implementation 'androidx.appcompat:appcompat:1.1.0' + implementation "com.google.dagger:hilt-android:${versions.hilt}" + kapt "com.google.dagger:hilt-android-compiler:${versions.hilt}" + + 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:1.1.3' - implementation 'androidx.fragment:fragment-ktx:1.1.0' - implementation 'androidx.lifecycle:lifecycle-extensions:2.1.0' - implementation 'androidx.lifecycle:lifecycle-viewmodel-ktx:2.1.0' - implementation 'androidx.paging:paging-runtime-ktx:2.1.0' - implementation 'androidx.preference:preference:1.1.0' - implementation 'androidx.swiperefreshlayout:swiperefreshlayout:1.1.0-alpha03' - implementation 'com.google.android:flexbox:1.1.0' - implementation 'com.google.android.material:material:1.2.0-alpha03' - - def room_version = '2.2.2' + implementation 'androidx.constraintlayout:constraintlayout:2.1.4' + implementation 'androidx.core:core-ktx:1.8.0' + 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' + implementation 'androidx.preference:preference-ktx:1.2.0' + 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' implementation "androidx.room:room-runtime:$room_version" implementation "androidx.room:room-ktx:$room_version" + implementation "androidx.room:room-paging:$room_version" kapt "androidx.room:room-compiler:$room_version" + androidTestImplementation "androidx.room:room-testing:$room_version" - implementation 'com.gitlab.bitfireAT:dav4jvm:1.0' implementation 'com.jaredrummler:colorpicker:1.1.0' - implementation('com.mikepenz:aboutlibraries:7.0.4') + implementation "com.github.AppIntro:AppIntro:${versions.appIntro}" + implementation "com.mikepenz:aboutlibraries:${versions.aboutLibraries}" implementation "com.squareup.okhttp3:okhttp:${versions.okhttp}" + implementation "com.squareup.okhttp3:okhttp-brotli:${versions.okhttp}" implementation "com.squareup.okhttp3:logging-interceptor:${versions.okhttp}" - implementation 'commons-io:commons-io:2.6' + implementation 'commons-io:commons-io:2.11.0' + //noinspection GradleDependency - dnsjava 3+ needs Java 8/Android 7 implementation 'dnsjava:dnsjava:2.1.9' - implementation 'org.apache.commons:commons-collections4:4.4' - implementation 'org.apache.commons:commons-lang3:3.9' + //noinspection GradleDependency + implementation "org.apache.commons:commons-collections4:${versions.commonsCollections}" + //noinspection GradleDependency + 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 'androidx.test:runner:1.2.0' - androidTestImplementation 'androidx.test:rules:1.2.0' - androidTestImplementation 'junit:junit:4.12' + androidTestImplementation "com.google.dagger:hilt-android-testing:${versions.hilt}" + kaptAndroidTest "com.google.dagger:hilt-android-compiler:${versions.hilt}" + + androidTestImplementation 'androidx.test:core-ktx:1.4.0' + androidTestImplementation 'androidx.test:runner:1.4.0' + androidTestImplementation 'androidx.test:rules:1.4.0' + androidTestImplementation 'androidx.test.ext:junit-ktx:1.1.3' androidTestImplementation "com.squareup.okhttp3:mockwebserver:${versions.okhttp}" + androidTestImplementation 'io.mockk:mockk-android:1.12.3' - testImplementation 'junit:junit:4.12' testImplementation "com.squareup.okhttp3:mockwebserver:${versions.okhttp}" } + +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/proguard-rules-release.pro b/app/proguard-rules-release.pro new file mode 100644 index 0000000000000000000000000000000000000000..2d3ee962c0d3a044081faf48632d88e1631c71d3 --- /dev/null +++ b/app/proguard-rules-release.pro @@ -0,0 +1,28 @@ + +# R8 usage for DAVx⁵: +# shrinking yes (only in release builds) +# optimization yes (on by R8 defaults) +# obfuscation no (open-source) + +-dontobfuscate +-printusage build/reports/r8-usage.txt + +# ez-vcard: keep all vCard properties/parameters (used via reflection) +-keep class ezvcard.io.scribe.** { *; } +-keep class ezvcard.property.** { *; } +-keep class ezvcard.parameter.** { *; } + +# ical4j: keep all iCalendar properties/parameters (used via reflection) +-keep class net.fortuna.ical4j.** { *; } + +# XmlPullParser +-keep class org.xmlpull.** { *; } + +# DAVx⁵ + libs +-keep class at.bitfire.** { *; } # all DAVx⁵ code is required + +# we use enum classes (https://www.guardsquare.com/en/products/proguard/manual/examples#enumerations) +-keepclassmembers,allowoptimization enum * { + public static **[] values(); + public static ** valueOf(java.lang.String); +} diff --git a/app/proguard-rules.txt b/app/proguard-rules.txt deleted file mode 100644 index e019474f0e1819d0afed195732fbb4b70b90e279..0000000000000000000000000000000000000000 --- a/app/proguard-rules.txt +++ /dev/null @@ -1,56 +0,0 @@ - -# ProGuard usage for DAVx⁵: -# shrinking yes (main reason for using ProGuard) -# optimization yes -# obfuscation no (DAVx⁵ is open-source) -# preverification no - --dontobfuscate - --optimizations !code/simplification/arithmetic,!code/simplification/cast,!field/*,!class/merging/* --optimizationpasses 5 --allowaccessmodification --dontpreverify - -# Kotlin --dontwarn kotlin.** - -# https://github.com/material-components/material-components-android/issues/387 --keep class com.google.android.material.tabs.** {*;} - -# Apache Commons --dontwarn javax.script.** - -# ez-vcard --dontwarn ezvcard.io.json.** # JSON serializer (for jCards) not used --dontwarn freemarker.** # freemarker templating library (for creating hCards) not used --dontwarn org.jsoup.** # jsoup library (for hCard parsing) not used --keep class ezvcard.property.** { *; } # keep all vCard properties (created at runtime) - -# ical4j: ignore unused dynamic libraries --dontwarn aQute.** --dontwarn groovy.** # Groovy-based ContentBuilder not used --dontwarn javax.cache.** # no JCache support in Android --dontwarn net.fortuna.ical4j.model.** --dontwarn org.codehaus.groovy.** --dontwarn org.apache.log4j.** # ignore warnings from log4j dependency --keep class net.fortuna.ical4j.** { *; } # keep all model classes (properties/factories, created at runtime) --keep class org.threeten.bp.** { *; } # keep ThreeTen (for time zone processing) - -# okhttp --dontwarn javax.annotation.** --dontwarn okio.** --dontwarn org.codehaus.mojo.animal_sniffer.IgnoreJRERequirement --dontwarn org.conscrypt.** - -# dnsjava --dontwarn sun.net.spi.nameservice.** # not available on Android - -# DAVx⁵ + libs --keep class at.bitfire.** { *; } # all DAVx⁵ code is required - -# we use enum classes (https://www.guardsquare.com/en/products/proguard/manual/examples#enumerations) --keepclassmembers,allowoptimization enum * { - public static **[] values(); - public static ** valueOf(java.lang.String); -} diff --git a/app/schemas/at.bitfire.davdroid.db.AppDatabase/10.json b/app/schemas/at.bitfire.davdroid.db.AppDatabase/10.json new file mode 100644 index 0000000000000000000000000000000000000000..f1cee143d28581cb90a402d80e7f08bd3b738e6c --- /dev/null +++ b/app/schemas/at.bitfire.davdroid.db.AppDatabase/10.json @@ -0,0 +1,398 @@ +{ + "formatVersion": 1, + "database": { + "version": 10, + "identityHash": "6fcabe50cbd00a4215dbe536a565dd2a", + "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)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "accountName", + "columnName": "accountName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "principal", + "columnName": "principal", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": true + }, + "indices": [ + { + "name": "index_service_accountName_type", + "unique": true, + "columnNames": [ + "accountName", + "type" + ], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_service_accountName_type` ON `${TABLE_NAME}` (`accountName`, `type`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "homeset", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `serviceId` INTEGER NOT NULL, `personal` INTEGER NOT NULL, `url` TEXT NOT NULL, `privBind` INTEGER NOT NULL, `displayName` TEXT, FOREIGN KEY(`serviceId`) REFERENCES `service`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "serviceId", + "columnName": "serviceId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "personal", + "columnName": "personal", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "privBind", + "columnName": "privBind", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "displayName", + "columnName": "displayName", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": true + }, + "indices": [ + { + "name": "index_homeset_serviceId_url", + "unique": true, + "columnNames": [ + "serviceId", + "url" + ], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_homeset_serviceId_url` ON `${TABLE_NAME}` (`serviceId`, `url`)" + } + ], + "foreignKeys": [ + { + "table": "service", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "serviceId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "collection", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `serviceId` INTEGER NOT NULL, `homeSetId` INTEGER, `type` TEXT NOT NULL, `url` TEXT NOT NULL, `privWriteContent` INTEGER NOT NULL, `privUnbind` INTEGER NOT NULL, `forceReadOnly` INTEGER NOT NULL, `displayName` TEXT, `description` TEXT, `owner` TEXT, `color` INTEGER, `timezone` TEXT, `supportsVEVENT` INTEGER, `supportsVTODO` INTEGER, `supportsVJOURNAL` INTEGER, `source` TEXT, `sync` INTEGER NOT NULL, FOREIGN KEY(`serviceId`) REFERENCES `service`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`homeSetId`) REFERENCES `homeset`(`id`) ON UPDATE NO ACTION ON DELETE SET NULL )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "serviceId", + "columnName": "serviceId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "homeSetId", + "columnName": "homeSetId", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "privWriteContent", + "columnName": "privWriteContent", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "privUnbind", + "columnName": "privUnbind", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "forceReadOnly", + "columnName": "forceReadOnly", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "displayName", + "columnName": "displayName", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "description", + "columnName": "description", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "owner", + "columnName": "owner", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "color", + "columnName": "color", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "timezone", + "columnName": "timezone", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "supportsVEVENT", + "columnName": "supportsVEVENT", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "supportsVTODO", + "columnName": "supportsVTODO", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "supportsVJOURNAL", + "columnName": "supportsVJOURNAL", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "source", + "columnName": "source", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "sync", + "columnName": "sync", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": true + }, + "indices": [ + { + "name": "index_collection_serviceId_type", + "unique": false, + "columnNames": [ + "serviceId", + "type" + ], + "createSql": "CREATE INDEX IF NOT EXISTS `index_collection_serviceId_type` ON `${TABLE_NAME}` (`serviceId`, `type`)" + }, + { + "name": "index_collection_homeSetId_type", + "unique": false, + "columnNames": [ + "homeSetId", + "type" + ], + "createSql": "CREATE INDEX IF NOT EXISTS `index_collection_homeSetId_type` ON `${TABLE_NAME}` (`homeSetId`, `type`)" + }, + { + "name": "index_collection_url", + "unique": false, + "columnNames": [ + "url" + ], + "createSql": "CREATE INDEX IF NOT EXISTS `index_collection_url` ON `${TABLE_NAME}` (`url`)" + } + ], + "foreignKeys": [ + { + "table": "service", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "serviceId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "homeset", + "onDelete": "SET NULL", + "onUpdate": "NO ACTION", + "columns": [ + "homeSetId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "syncstats", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `collectionId` INTEGER NOT NULL, `authority` TEXT NOT NULL, `lastSync` INTEGER NOT NULL, FOREIGN KEY(`collectionId`) REFERENCES `collection`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "collectionId", + "columnName": "collectionId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "authority", + "columnName": "authority", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lastSync", + "columnName": "lastSync", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": true + }, + "indices": [ + { + "name": "index_syncstats_collectionId_authority", + "unique": true, + "columnNames": [ + "collectionId", + "authority" + ], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_syncstats_collectionId_authority` ON `${TABLE_NAME}` (`collectionId`, `authority`)" + } + ], + "foreignKeys": [ + { + "table": "collection", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "collectionId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "webdav_mount", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL, `url` TEXT NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": true + }, + "indices": [], + "foreignKeys": [] + } + ], + "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, '6fcabe50cbd00a4215dbe536a565dd2a')" + ] + } +} \ No newline at end of file diff --git a/app/schemas/at.bitfire.davdroid.db.AppDatabase/11.json b/app/schemas/at.bitfire.davdroid.db.AppDatabase/11.json new file mode 100644 index 0000000000000000000000000000000000000000..d34f2da1c44141fc6c46af81359ddb541835bf42 --- /dev/null +++ b/app/schemas/at.bitfire.davdroid.db.AppDatabase/11.json @@ -0,0 +1,561 @@ +{ + "formatVersion": 1, + "database": { + "version": 11, + "identityHash": "f9b5aba8e529d0a97714784626add644", + "entities": [ + { + "tableName": "service", + "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", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "accountName", + "columnName": "accountName", + "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", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "principal", + "columnName": "principal", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": true + }, + "indices": [ + { + "name": "index_service_accountName_type", + "unique": true, + "columnNames": [ + "accountName", + "type" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_service_accountName_type` ON `${TABLE_NAME}` (`accountName`, `type`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "homeset", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `serviceId` INTEGER NOT NULL, `personal` INTEGER NOT NULL, `url` TEXT NOT NULL, `privBind` INTEGER NOT NULL, `displayName` TEXT, FOREIGN KEY(`serviceId`) REFERENCES `service`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "serviceId", + "columnName": "serviceId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "personal", + "columnName": "personal", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "privBind", + "columnName": "privBind", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "displayName", + "columnName": "displayName", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": true + }, + "indices": [ + { + "name": "index_homeset_serviceId_url", + "unique": true, + "columnNames": [ + "serviceId", + "url" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_homeset_serviceId_url` ON `${TABLE_NAME}` (`serviceId`, `url`)" + } + ], + "foreignKeys": [ + { + "table": "service", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "serviceId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "collection", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `serviceId` INTEGER NOT NULL, `homeSetId` INTEGER, `type` TEXT NOT NULL, `url` TEXT NOT NULL, `privWriteContent` INTEGER NOT NULL, `privUnbind` INTEGER NOT NULL, `forceReadOnly` INTEGER NOT NULL, `displayName` TEXT, `description` TEXT, `owner` TEXT, `color` INTEGER, `timezone` TEXT, `supportsVEVENT` INTEGER, `supportsVTODO` INTEGER, `supportsVJOURNAL` INTEGER, `source` TEXT, `sync` INTEGER NOT NULL, FOREIGN KEY(`serviceId`) REFERENCES `service`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`homeSetId`) REFERENCES `homeset`(`id`) ON UPDATE NO ACTION ON DELETE SET NULL )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "serviceId", + "columnName": "serviceId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "homeSetId", + "columnName": "homeSetId", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "privWriteContent", + "columnName": "privWriteContent", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "privUnbind", + "columnName": "privUnbind", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "forceReadOnly", + "columnName": "forceReadOnly", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "displayName", + "columnName": "displayName", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "description", + "columnName": "description", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "owner", + "columnName": "owner", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "color", + "columnName": "color", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "timezone", + "columnName": "timezone", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "supportsVEVENT", + "columnName": "supportsVEVENT", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "supportsVTODO", + "columnName": "supportsVTODO", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "supportsVJOURNAL", + "columnName": "supportsVJOURNAL", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "source", + "columnName": "source", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "sync", + "columnName": "sync", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": true + }, + "indices": [ + { + "name": "index_collection_serviceId_type", + "unique": false, + "columnNames": [ + "serviceId", + "type" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_collection_serviceId_type` ON `${TABLE_NAME}` (`serviceId`, `type`)" + }, + { + "name": "index_collection_homeSetId_type", + "unique": false, + "columnNames": [ + "homeSetId", + "type" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_collection_homeSetId_type` ON `${TABLE_NAME}` (`homeSetId`, `type`)" + }, + { + "name": "index_collection_url", + "unique": false, + "columnNames": [ + "url" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_collection_url` ON `${TABLE_NAME}` (`url`)" + } + ], + "foreignKeys": [ + { + "table": "service", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "serviceId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "homeset", + "onDelete": "SET NULL", + "onUpdate": "NO ACTION", + "columns": [ + "homeSetId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "syncstats", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `collectionId` INTEGER NOT NULL, `authority` TEXT NOT NULL, `lastSync` INTEGER NOT NULL, FOREIGN KEY(`collectionId`) REFERENCES `collection`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "collectionId", + "columnName": "collectionId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "authority", + "columnName": "authority", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lastSync", + "columnName": "lastSync", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": true + }, + "indices": [ + { + "name": "index_syncstats_collectionId_authority", + "unique": true, + "columnNames": [ + "collectionId", + "authority" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_syncstats_collectionId_authority` ON `${TABLE_NAME}` (`collectionId`, `authority`)" + } + ], + "foreignKeys": [ + { + "table": "collection", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "collectionId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "webdav_document", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`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 )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "mountId", + "columnName": "mountId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "parentId", + "columnName": "parentId", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "isDirectory", + "columnName": "isDirectory", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "displayName", + "columnName": "displayName", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "mimeType", + "columnName": "mimeType", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "eTag", + "columnName": "eTag", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "lastModified", + "columnName": "lastModified", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "size", + "columnName": "size", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "mayBind", + "columnName": "mayBind", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "mayUnbind", + "columnName": "mayUnbind", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "mayWriteContent", + "columnName": "mayWriteContent", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "quotaAvailable", + "columnName": "quotaAvailable", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "quotaUsed", + "columnName": "quotaUsed", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": true + }, + "indices": [ + { + "name": "index_webdav_document_mountId_parentId_name", + "unique": true, + "columnNames": [ + "mountId", + "parentId", + "name" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_webdav_document_mountId_parentId_name` ON `${TABLE_NAME}` (`mountId`, `parentId`, `name`)" + } + ], + "foreignKeys": [ + { + "table": "webdav_mount", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "mountId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "webdav_document", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "parentId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "webdav_mount", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL, `url` TEXT NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": true + }, + "indices": [], + "foreignKeys": [] + } + ], + "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, 'f9b5aba8e529d0a97714784626add644')" + ] + } +} \ No newline at end of file diff --git a/app/schemas/at.bitfire.davdroid.db.AppDatabase/8.json b/app/schemas/at.bitfire.davdroid.db.AppDatabase/8.json new file mode 100644 index 0000000000000000000000000000000000000000..681fb4981087a96427f490083f4c2cbee1023545 --- /dev/null +++ b/app/schemas/at.bitfire.davdroid.db.AppDatabase/8.json @@ -0,0 +1,298 @@ +{ + "formatVersion": 1, + "database": { + "version": 8, + "identityHash": "b8699ef3cc4c62e8851df4360fb69e00", + "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)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "accountName", + "columnName": "accountName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "principal", + "columnName": "principal", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": true + }, + "indices": [ + { + "name": "index_service_accountName_type", + "unique": true, + "columnNames": [ + "accountName", + "type" + ], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_service_accountName_type` ON `${TABLE_NAME}` (`accountName`, `type`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "homeset", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `serviceId` INTEGER NOT NULL, `personal` INTEGER NOT NULL, `url` TEXT NOT NULL, `privBind` INTEGER NOT NULL, `displayName` TEXT, FOREIGN KEY(`serviceId`) REFERENCES `service`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "serviceId", + "columnName": "serviceId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "personal", + "columnName": "personal", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "privBind", + "columnName": "privBind", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "displayName", + "columnName": "displayName", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": true + }, + "indices": [ + { + "name": "index_homeset_serviceId_url", + "unique": true, + "columnNames": [ + "serviceId", + "url" + ], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_homeset_serviceId_url` ON `${TABLE_NAME}` (`serviceId`, `url`)" + } + ], + "foreignKeys": [ + { + "table": "service", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "serviceId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "collection", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `serviceId` INTEGER NOT NULL, `homeSetId` INTEGER, `type` TEXT NOT NULL, `url` TEXT NOT NULL, `privWriteContent` INTEGER NOT NULL, `privUnbind` INTEGER NOT NULL, `forceReadOnly` INTEGER NOT NULL, `displayName` TEXT, `description` TEXT, `owner` TEXT, `color` INTEGER, `timezone` TEXT, `supportsVEVENT` INTEGER, `supportsVTODO` INTEGER, `supportsVJOURNAL` INTEGER, `source` TEXT, `sync` INTEGER NOT NULL, FOREIGN KEY(`serviceId`) REFERENCES `service`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`homeSetId`) REFERENCES `homeset`(`id`) ON UPDATE NO ACTION ON DELETE SET NULL )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "serviceId", + "columnName": "serviceId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "homeSetId", + "columnName": "homeSetId", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "privWriteContent", + "columnName": "privWriteContent", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "privUnbind", + "columnName": "privUnbind", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "forceReadOnly", + "columnName": "forceReadOnly", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "displayName", + "columnName": "displayName", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "description", + "columnName": "description", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "owner", + "columnName": "owner", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "color", + "columnName": "color", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "timezone", + "columnName": "timezone", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "supportsVEVENT", + "columnName": "supportsVEVENT", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "supportsVTODO", + "columnName": "supportsVTODO", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "supportsVJOURNAL", + "columnName": "supportsVJOURNAL", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "source", + "columnName": "source", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "sync", + "columnName": "sync", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": true + }, + "indices": [ + { + "name": "index_collection_serviceId_type", + "unique": false, + "columnNames": [ + "serviceId", + "type" + ], + "createSql": "CREATE INDEX IF NOT EXISTS `index_collection_serviceId_type` ON `${TABLE_NAME}` (`serviceId`, `type`)" + }, + { + "name": "index_collection_homeSetId_type", + "unique": false, + "columnNames": [ + "homeSetId", + "type" + ], + "createSql": "CREATE INDEX IF NOT EXISTS `index_collection_homeSetId_type` ON `${TABLE_NAME}` (`homeSetId`, `type`)" + } + ], + "foreignKeys": [ + { + "table": "service", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "serviceId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "homeset", + "onDelete": "SET NULL", + "onUpdate": "NO ACTION", + "columns": [ + "homeSetId" + ], + "referencedColumns": [ + "id" + ] + } + ] + } + ], + "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, 'b8699ef3cc4c62e8851df4360fb69e00')" + ] + } +} \ No newline at end of file diff --git a/app/schemas/at.bitfire.davdroid.db.AppDatabase/9.json b/app/schemas/at.bitfire.davdroid.db.AppDatabase/9.json new file mode 100644 index 0000000000000000000000000000000000000000..a2a0035b4a312dc8d57fed5e10c010ed7fc6a1a1 --- /dev/null +++ b/app/schemas/at.bitfire.davdroid.db.AppDatabase/9.json @@ -0,0 +1,366 @@ +{ + "formatVersion": 1, + "database": { + "version": 9, + "identityHash": "7e4bfdf7f9fa3529c333cf9485f8cf50", + "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)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "accountName", + "columnName": "accountName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "principal", + "columnName": "principal", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": true + }, + "indices": [ + { + "name": "index_service_accountName_type", + "unique": true, + "columnNames": [ + "accountName", + "type" + ], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_service_accountName_type` ON `${TABLE_NAME}` (`accountName`, `type`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "homeset", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `serviceId` INTEGER NOT NULL, `personal` INTEGER NOT NULL, `url` TEXT NOT NULL, `privBind` INTEGER NOT NULL, `displayName` TEXT, FOREIGN KEY(`serviceId`) REFERENCES `service`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "serviceId", + "columnName": "serviceId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "personal", + "columnName": "personal", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "privBind", + "columnName": "privBind", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "displayName", + "columnName": "displayName", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": true + }, + "indices": [ + { + "name": "index_homeset_serviceId_url", + "unique": true, + "columnNames": [ + "serviceId", + "url" + ], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_homeset_serviceId_url` ON `${TABLE_NAME}` (`serviceId`, `url`)" + } + ], + "foreignKeys": [ + { + "table": "service", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "serviceId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "collection", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `serviceId` INTEGER NOT NULL, `homeSetId` INTEGER, `type` TEXT NOT NULL, `url` TEXT NOT NULL, `privWriteContent` INTEGER NOT NULL, `privUnbind` INTEGER NOT NULL, `forceReadOnly` INTEGER NOT NULL, `displayName` TEXT, `description` TEXT, `owner` TEXT, `color` INTEGER, `timezone` TEXT, `supportsVEVENT` INTEGER, `supportsVTODO` INTEGER, `supportsVJOURNAL` INTEGER, `source` TEXT, `sync` INTEGER NOT NULL, FOREIGN KEY(`serviceId`) REFERENCES `service`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`homeSetId`) REFERENCES `homeset`(`id`) ON UPDATE NO ACTION ON DELETE SET NULL )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "serviceId", + "columnName": "serviceId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "homeSetId", + "columnName": "homeSetId", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "privWriteContent", + "columnName": "privWriteContent", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "privUnbind", + "columnName": "privUnbind", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "forceReadOnly", + "columnName": "forceReadOnly", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "displayName", + "columnName": "displayName", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "description", + "columnName": "description", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "owner", + "columnName": "owner", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "color", + "columnName": "color", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "timezone", + "columnName": "timezone", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "supportsVEVENT", + "columnName": "supportsVEVENT", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "supportsVTODO", + "columnName": "supportsVTODO", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "supportsVJOURNAL", + "columnName": "supportsVJOURNAL", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "source", + "columnName": "source", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "sync", + "columnName": "sync", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": true + }, + "indices": [ + { + "name": "index_collection_serviceId_type", + "unique": false, + "columnNames": [ + "serviceId", + "type" + ], + "createSql": "CREATE INDEX IF NOT EXISTS `index_collection_serviceId_type` ON `${TABLE_NAME}` (`serviceId`, `type`)" + }, + { + "name": "index_collection_homeSetId_type", + "unique": false, + "columnNames": [ + "homeSetId", + "type" + ], + "createSql": "CREATE INDEX IF NOT EXISTS `index_collection_homeSetId_type` ON `${TABLE_NAME}` (`homeSetId`, `type`)" + }, + { + "name": "index_collection_url", + "unique": false, + "columnNames": [ + "url" + ], + "createSql": "CREATE INDEX IF NOT EXISTS `index_collection_url` ON `${TABLE_NAME}` (`url`)" + } + ], + "foreignKeys": [ + { + "table": "service", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "serviceId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "homeset", + "onDelete": "SET NULL", + "onUpdate": "NO ACTION", + "columns": [ + "homeSetId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "syncstats", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `collectionId` INTEGER NOT NULL, `authority` TEXT NOT NULL, `lastSync` INTEGER NOT NULL, FOREIGN KEY(`collectionId`) REFERENCES `collection`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "collectionId", + "columnName": "collectionId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "authority", + "columnName": "authority", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lastSync", + "columnName": "lastSync", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": true + }, + "indices": [ + { + "name": "index_syncstats_collectionId_authority", + "unique": true, + "columnNames": [ + "collectionId", + "authority" + ], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_syncstats_collectionId_authority` ON `${TABLE_NAME}` (`collectionId`, `authority`)" + } + ], + "foreignKeys": [ + { + "table": "collection", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "collectionId" + ], + "referencedColumns": [ + "id" + ] + } + ] + } + ], + "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, '7e4bfdf7f9fa3529c333cf9485f8cf50')" + ] + } +} \ No newline at end of file diff --git a/app/src/androidTest/AndroidManifest.xml b/app/src/androidTest/AndroidManifest.xml new file mode 100644 index 0000000000000000000000000000000000000000..4e6c77599991e6498d1189b4504c328d7a9ed689 --- /dev/null +++ b/app/src/androidTest/AndroidManifest.xml @@ -0,0 +1,16 @@ + + + + + + + + + + + + + diff --git a/app/src/androidTest/java/at/bitfire/davdroid/Android10ResolverTest.kt b/app/src/androidTest/java/at/bitfire/davdroid/Android10ResolverTest.kt new file mode 100644 index 0000000000000000000000000000000000000000..8ef157c28b573aa77a1e3e5e1fdf305374023a91 --- /dev/null +++ b/app/src/androidTest/java/at/bitfire/davdroid/Android10ResolverTest.kt @@ -0,0 +1,35 @@ +/*************************************************************************************************** + * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. + **************************************************************************************************/ + +package at.bitfire.davdroid + +import android.os.Build +import androidx.test.filters.SdkSuppress +import org.junit.Assert.assertEquals +import org.junit.Test +import org.xbill.DNS.ARecord +import org.xbill.DNS.Lookup +import org.xbill.DNS.Type +import java.net.Inet4Address +import java.net.InetAddress + +class Android10ResolverTest { + + val FQDN_DAVX5 = "www.google.com" + + @Test + @SdkSuppress(minSdkVersion = Build.VERSION_CODES.Q) + fun testResolveA() { + val www = InetAddress.getAllByName(FQDN_DAVX5).filterIsInstance(Inet4Address::class.java).first() + + val srvLookup = Lookup(FQDN_DAVX5, Type.A) + srvLookup.setResolver(Android10Resolver) + val resultGeneric = srvLookup.run() + assertEquals(1, resultGeneric.size) + + val result = resultGeneric.first() as ARecord + assertEquals(www, result.address) + } + +} \ No newline at end of file diff --git a/app/src/androidTest/java/at/bitfire/davdroid/CustomTestRunner.kt b/app/src/androidTest/java/at/bitfire/davdroid/CustomTestRunner.kt new file mode 100644 index 0000000000000000000000000000000000000000..a61c22fa25d7f72202e1a05ef5f3a121c4ff63bf --- /dev/null +++ b/app/src/androidTest/java/at/bitfire/davdroid/CustomTestRunner.kt @@ -0,0 +1,17 @@ +/*************************************************************************************************** + * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. + **************************************************************************************************/ + +package at.bitfire.davdroid + +import android.content.Context +import androidx.test.runner.AndroidJUnitRunner +import dagger.hilt.android.HiltAndroidApp +import dagger.hilt.android.testing.HiltTestApplication + +class CustomTestRunner : AndroidJUnitRunner() { + + override fun newApplication(cl: ClassLoader, name: String, context: Context) = + super.newApplication(cl, HiltTestApplication::class.java.name, context) + +} \ No newline at end of file diff --git a/app/src/androidTest/java/at/bitfire/davdroid/HttpClientTest.kt b/app/src/androidTest/java/at/bitfire/davdroid/HttpClientTest.kt index 220afffbd34ec97fbe9b4174cf51109788b14dde..f7a979b6327cef9a3575c8ba0fc56ca50e016ae3 100644 --- a/app/src/androidTest/java/at/bitfire/davdroid/HttpClientTest.kt +++ b/app/src/androidTest/java/at/bitfire/davdroid/HttpClientTest.kt @@ -1,19 +1,17 @@ -/* - * Copyright © Ricki Hirner (bitfire web engineering). - * All rights reserved. This program and the accompanying materials - * are made available under the terms of the GNU Public License v3.0 - * which accompanies this distribution, and is available at - * http://www.gnu.org/licenses/gpl.html - */ +/*************************************************************************************************** + * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. + **************************************************************************************************/ package at.bitfire.davdroid +import android.security.NetworkSecurityPolicy import okhttp3.Request import okhttp3.mockwebserver.MockResponse import okhttp3.mockwebserver.MockWebServer import org.junit.After import org.junit.Assert.assertEquals import org.junit.Assert.assertNull +import org.junit.Assume import org.junit.Before import org.junit.Test @@ -39,6 +37,7 @@ class HttpClientTest { @Test fun testCookies() { + Assume.assumeTrue(NetworkSecurityPolicy.getInstance().isCleartextTrafficPermitted) val url = server.url("/test") // set cookie for root path (/) and /test path in first response diff --git a/app/src/androidTest/java/at/bitfire/davdroid/InitCalendarProviderRule.kt b/app/src/androidTest/java/at/bitfire/davdroid/InitCalendarProviderRule.kt new file mode 100644 index 0000000000000000000000000000000000000000..bcbe1e1b41fc6de9fd497b2078d21deee0353c7f --- /dev/null +++ b/app/src/androidTest/java/at/bitfire/davdroid/InitCalendarProviderRule.kt @@ -0,0 +1,75 @@ +/*************************************************************************************************** + * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. + **************************************************************************************************/ + +package at.bitfire.davdroid + +import android.accounts.Account +import android.content.ContentUris +import android.content.ContentValues +import android.provider.CalendarContract +import androidx.test.platform.app.InstrumentationRegistry +import at.bitfire.davdroid.log.Logger +import at.bitfire.davdroid.resource.LocalCalendar +import at.bitfire.davdroid.resource.LocalEvent +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.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 + */ +class InitCalendarProviderRule : 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)) + } + + override fun apply(base: Statement, description: Description): Statement { + Logger.log.info("Before test: ${description.displayName}") + + 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!!) + + // 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!!) + + // Run test + Logger.log.info("Evaluating test..") + return try { + object : Statement() { + @Throws(Throwable::class) + override fun evaluate() { + base.evaluate() + } + } + } finally { + Logger.log.info("After test: $description") + calendar.delete() + } + } +} diff --git a/app/src/androidTest/java/at/bitfire/davdroid/MockingModule.kt b/app/src/androidTest/java/at/bitfire/davdroid/MockingModule.kt new file mode 100644 index 0000000000000000000000000000000000000000..41cda464bcb465e38d83c24c8a126efd43cd234b --- /dev/null +++ b/app/src/androidTest/java/at/bitfire/davdroid/MockingModule.kt @@ -0,0 +1,39 @@ +/*************************************************************************************************** + * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. + **************************************************************************************************/ + +package at.bitfire.davdroid + +import android.content.Context +import androidx.room.Room +import at.bitfire.davdroid.db.AppDatabase +import at.bitfire.davdroid.settings.SettingsManager +import dagger.Module +import dagger.Provides +import dagger.hilt.android.qualifiers.ApplicationContext +import dagger.hilt.components.SingletonComponent +import dagger.hilt.testing.TestInstallIn +import io.mockk.spyk +import javax.inject.Singleton + +@Module +@TestInstallIn( + components = [ SingletonComponent::class ], + replaces = [ + AppDatabase.AppDatabaseModule::class, + SettingsManager.SettingsManagerModule::class + ] +) +class MockingModule { + + @Provides + @Singleton + fun spykAppDatabase(@ApplicationContext context: Context): AppDatabase = + spyk(Room.inMemoryDatabaseBuilder(context, AppDatabase::class.java).build()) + + @Provides + @Singleton + fun spykSettingsManager(@ApplicationContext context: Context): SettingsManager = + spyk(SettingsManager(context)) + +} \ No newline at end of file diff --git a/app/src/androidTest/java/at/bitfire/davdroid/TestUtils.kt b/app/src/androidTest/java/at/bitfire/davdroid/TestUtils.kt new file mode 100644 index 0000000000000000000000000000000000000000..2ae8acf8b7480cf55f026c639b75675117525b50 --- /dev/null +++ b/app/src/androidTest/java/at/bitfire/davdroid/TestUtils.kt @@ -0,0 +1,14 @@ +/*************************************************************************************************** + * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. + **************************************************************************************************/ + +package at.bitfire.davdroid + +import android.app.Application +import androidx.test.platform.app.InstrumentationRegistry + +object TestUtils { + + val targetApplication by lazy { InstrumentationRegistry.getInstrumentation().targetContext.applicationContext as Application } + +} \ No newline at end of file diff --git a/app/src/androidTest/java/at/bitfire/davdroid/db/AppDatabaseTest.kt b/app/src/androidTest/java/at/bitfire/davdroid/db/AppDatabaseTest.kt new file mode 100644 index 0000000000000000000000000000000000000000..2598c45255de18658f16c7b1642b5cdf7fd67b66 --- /dev/null +++ b/app/src/androidTest/java/at/bitfire/davdroid/db/AppDatabaseTest.kt @@ -0,0 +1,42 @@ +/*************************************************************************************************** + * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. + **************************************************************************************************/ + +package at.bitfire.davdroid.db + +import androidx.room.Room +import androidx.room.testing.MigrationTestHelper +import androidx.sqlite.db.framework.FrameworkSQLiteOpenHelperFactory +import androidx.test.platform.app.InstrumentationRegistry +import org.junit.Rule +import org.junit.Test + +class AppDatabaseTest { + + val TEST_DB = "test" + + @Rule + @JvmField + val helper = MigrationTestHelper( + InstrumentationRegistry.getInstrumentation(), + AppDatabase::class.java.canonicalName, + FrameworkSQLiteOpenHelperFactory() + ) + + + @Test + fun testAllMigrations() { + // DB schema is available since version 8 + helper.createDatabase(TEST_DB, 8).close() + + Room.databaseBuilder( + InstrumentationRegistry.getInstrumentation().targetContext, + AppDatabase::class.java, + TEST_DB + ).addMigrations(*AppDatabase.migrations).build().apply { + openHelper.writableDatabase + close() + } + } + +} \ No newline at end of file diff --git a/app/src/androidTest/java/at/bitfire/davdroid/model/CollectionTest.kt b/app/src/androidTest/java/at/bitfire/davdroid/db/CollectionTest.kt similarity index 83% rename from app/src/androidTest/java/at/bitfire/davdroid/model/CollectionTest.kt rename to app/src/androidTest/java/at/bitfire/davdroid/db/CollectionTest.kt index 68b446eb78d54d8099c5378bcb58dd4a1d0c7cde..addf472574510bea3ec65ebe1f4d0789248cd398 100644 --- a/app/src/androidTest/java/at/bitfire/davdroid/model/CollectionTest.kt +++ b/app/src/androidTest/java/at/bitfire/davdroid/db/CollectionTest.kt @@ -1,33 +1,50 @@ -/* - * Copyright © Ricki Hirner (bitfire web engineering). - * All rights reserved. This program and the accompanying materials - * are made available under the terms of the GNU Public License v3.0 - * which accompanies this distribution, and is available at - * http://www.gnu.org/licenses/gpl.html - */ +/*************************************************************************************************** + * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. + **************************************************************************************************/ -package at.bitfire.davdroid.model +package at.bitfire.davdroid.db +import android.security.NetworkSecurityPolicy import androidx.test.filters.SmallTest import at.bitfire.dav4jvm.DavResource import at.bitfire.dav4jvm.property.ResourceType import at.bitfire.davdroid.HttpClient -import okhttp3.HttpUrl +import at.bitfire.davdroid.settings.SettingsManager +import dagger.hilt.android.testing.HiltAndroidRule +import dagger.hilt.android.testing.HiltAndroidTest +import okhttp3.HttpUrl.Companion.toHttpUrl import okhttp3.mockwebserver.MockResponse import okhttp3.mockwebserver.MockWebServer import org.junit.After import org.junit.Assert.* +import org.junit.Assume import org.junit.Before +import org.junit.Rule import org.junit.Test +import javax.inject.Inject +@HiltAndroidTest class CollectionTest { + @get:Rule + val hiltRule = HiltAndroidRule(this) + + @Inject + lateinit var settingsManager: SettingsManager + + @Before + fun inject() { + hiltRule.inject() + } + + private lateinit var httpClient: HttpClient private val server = MockWebServer() @Before fun setUp() { httpClient = HttpClient.Builder().build() + Assume.assumeTrue(NetworkSecurityPolicy.getInstance().isCleartextTrafficPermitted) } @After @@ -128,7 +145,7 @@ class CollectionTest { } assertEquals(Collection.TYPE_WEBCAL, info.type) assertEquals("Sample Subscription", info.displayName) - assertEquals(HttpUrl.get("https://example.com/1.ics"), info.source) + assertEquals("https://example.com/1.ics".toHttpUrl(), info.source) } } diff --git a/app/src/androidTest/java/at/bitfire/davdroid/model/DaoToolsTest.kt b/app/src/androidTest/java/at/bitfire/davdroid/db/DaoToolsTest.kt similarity index 61% rename from app/src/androidTest/java/at/bitfire/davdroid/model/DaoToolsTest.kt rename to app/src/androidTest/java/at/bitfire/davdroid/db/DaoToolsTest.kt index 2caf0b1c572f159700d6f1789a4d13d6a198d3db..885e327ae9fff372d031a97d9d71b541a002d4a0 100644 --- a/app/src/androidTest/java/at/bitfire/davdroid/model/DaoToolsTest.kt +++ b/app/src/androidTest/java/at/bitfire/davdroid/db/DaoToolsTest.kt @@ -1,8 +1,13 @@ -package at.bitfire.davdroid.model +/*************************************************************************************************** + * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. + **************************************************************************************************/ + +package at.bitfire.davdroid.db import androidx.room.Room import androidx.test.platform.app.InstrumentationRegistry import okhttp3.HttpUrl +import okhttp3.HttpUrl.Companion.toHttpUrl import org.junit.After import org.junit.Assert.* import org.junit.Before @@ -30,11 +35,11 @@ class DaoToolsTest { service.id = serviceDao.insertOrReplace(service) val homeSetDao = db.homeSetDao() - val entry1 = HomeSet(id=1, serviceId=service.id, url=HttpUrl.get("https://example.com/1")) - val entry3 = HomeSet(id=3, serviceId=service.id, url=HttpUrl.get("https://example.com/3")) + val entry1 = HomeSet(id=1, serviceId=service.id, personal=true, url= "https://example.com/1".toHttpUrl()) + val entry3 = HomeSet(id=3, serviceId=service.id, personal=true, url= "https://example.com/3".toHttpUrl()) val oldItems = listOf( entry1, - HomeSet(id=2, serviceId=service.id, url=HttpUrl.get("https://example.com/2")), + HomeSet(id=2, serviceId=service.id, personal=true, url= "https://example.com/2".toHttpUrl()), entry3 ) homeSetDao.insert(oldItems) @@ -43,11 +48,11 @@ class DaoToolsTest { newItems[entry1.url] = entry1 // no id, because identity is given by the url - val updated = HomeSet(id=0, serviceId=service.id, - url=HttpUrl.get("https://example.com/2"), displayName="Updated Entry") + val updated = HomeSet(id=0, serviceId=service.id, personal=true, + url= "https://example.com/2".toHttpUrl(), displayName="Updated Entry") newItems[updated.url] = updated - val created = HomeSet(id=4, serviceId=service.id, url=HttpUrl.get("https://example.com/4")) + val created = HomeSet(id=4, serviceId=service.id, personal=true, url= "https://example.com/4".toHttpUrl()) newItems[created.url] = created DaoTools(homeSetDao).syncAll(oldItems, newItems, { it.url }) diff --git a/app/src/androidTest/java/at/bitfire/davdroid/resource/LocalAddressBookTest.kt b/app/src/androidTest/java/at/bitfire/davdroid/resource/LocalAddressBookTest.kt new file mode 100644 index 0000000000000000000000000000000000000000..d8281384dcbba6a3e466a4d86ad9d3365b9e5fd0 --- /dev/null +++ b/app/src/androidTest/java/at/bitfire/davdroid/resource/LocalAddressBookTest.kt @@ -0,0 +1,77 @@ +/*************************************************************************************************** + * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. + **************************************************************************************************/ + +package at.bitfire.davdroid.resource + +import android.accounts.Account +import android.accounts.AccountManager +import androidx.test.platform.app.InstrumentationRegistry +import at.bitfire.davdroid.R +import dagger.hilt.android.testing.HiltAndroidRule +import dagger.hilt.android.testing.HiltAndroidTest +import org.junit.After +import org.junit.Before +import org.junit.Rule +import org.junit.Test + +@HiltAndroidTest +class LocalAddressBookTest { + + @get:Rule() + val hiltRule = HiltAndroidRule(this) + + val context = InstrumentationRegistry.getInstrumentation().targetContext + + val mainAccountType = context.getString(R.string.account_type) + val mainAccount = Account("main", mainAccountType) + + val addressBookAccountType = context.getString(R.string.account_type_address_book) + val addressBookAccount = Account("sub", addressBookAccountType) + + val accountManager = AccountManager.get(context) + + @Before + fun setUp() { + hiltRule.inject() + + // TODO DOES NOT WORK: the account immediately starts to sync, which creates the sync adapter services. + // The services however can't be created because Hilt is "not ready" (although it has been initialized in the line above). + // assertTrue(AccountUtils.createAccount(context, mainAccount, AccountSettings.initialUserData(null))) + } + + @After + fun cleanup() { + accountManager.removeAccount(addressBookAccount, null, null) + accountManager.removeAccount(mainAccount, null, null) + } + + + // TODO see above + /*@Test + fun testMainAccount_AddressBookAccount_WithMainAccount() { + // create address book account + assertTrue(accountManager.addAccountExplicitly(addressBookAccount, null, Bundle().apply { + putString(LocalAddressBook.USER_DATA_MAIN_ACCOUNT_NAME, mainAccount.name) + putString(LocalAddressBook.USER_DATA_MAIN_ACCOUNT_TYPE, mainAccount.type) + })) + + // check mainAccount() + assertEquals(mainAccount, LocalAddressBook.mainAccount(context, addressBookAccount)) + } + + @Test(expected = IllegalArgumentException::class) + fun testMainAccount_AddressBookAccount_WithoutMainAccount() { + // create address book account + assertTrue(accountManager.addAccountExplicitly(addressBookAccount, null, Bundle())) + + // check mainAccount(); should fail because there's no main account + LocalAddressBook.mainAccount(context, addressBookAccount) + }*/ + + @Test(expected = IllegalArgumentException::class) + fun testMainAccount_OtherAccount() { + LocalAddressBook.mainAccount(context, Account("Other Account", "com.example")) + } + +} \ 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 new file mode 100644 index 0000000000000000000000000000000000000000..a92cc256e9a3a69b974671499f3f5d0fe8ca7cb4 --- /dev/null +++ b/app/src/androidTest/java/at/bitfire/davdroid/resource/LocalCalendarTest.kt @@ -0,0 +1,151 @@ +/*************************************************************************************************** + * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. + **************************************************************************************************/ + +package at.bitfire.davdroid.resource + +import android.Manifest +import android.accounts.Account +import android.content.ContentProviderClient +import android.content.ContentUris +import android.content.ContentValues +import android.provider.CalendarContract +import android.provider.CalendarContract.ACCOUNT_TYPE_LOCAL +import android.provider.CalendarContract.Events +import androidx.test.platform.app.InstrumentationRegistry +import androidx.test.rule.GrantPermissionRule +import at.bitfire.davdroid.InitCalendarProviderRule +import at.bitfire.ical4android.AndroidCalendar +import at.bitfire.ical4android.Event +import at.bitfire.ical4android.MiscUtils.ContentProviderClientHelper.closeCompat +import at.bitfire.ical4android.MiscUtils.UriHelper.asSyncAdapter +import net.fortuna.ical4j.model.property.DtStart +import net.fortuna.ical4j.model.property.RRule +import net.fortuna.ical4j.model.property.RecurrenceId +import net.fortuna.ical4j.model.property.Status +import org.junit.* +import org.junit.Assert.assertEquals +import org.junit.rules.TestRule + +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() + + private lateinit var provider: ContentProviderClient + + @BeforeClass + @JvmStatic + fun setUpProvider() { + val context = InstrumentationRegistry.getInstrumentation().targetContext + provider = context.contentResolver.acquireContentProviderClient(CalendarContract.AUTHORITY)!! + } + + @AfterClass + @JvmStatic + fun closeProvider() { + provider.closeCompat() + } + + } + + private val account = Account("LocalCalendarTest", ACCOUNT_TYPE_LOCAL) + private lateinit var calendar: LocalCalendar + + @Before + fun prepare() { + val uri = AndroidCalendar.create(account, provider, ContentValues()) + calendar = AndroidCalendar.findByID(account, provider, LocalCalendar.Factory, ContentUris.parseId(uri)) + } + + @After + fun shutdown() { + calendar.delete() + } + + + @Test + fun testDeleteDirtyEventsWithoutInstances_NoInstances_CancelledExceptions() { + // create recurring event with only deleted/cancelled instances + val event = Event().apply { + dtStart = DtStart("20220120T010203Z") + summary = "Event with 3 instances" + rRules.add(RRule("FREQ=DAILY;COUNT=3")) + exceptions.add(Event().apply { + recurrenceId = RecurrenceId("20220120T010203Z") + dtStart = DtStart("20220120T010203Z") + summary = "Cancelled exception on 1st day" + status = Status.VEVENT_CANCELLED + }) + exceptions.add(Event().apply { + recurrenceId = RecurrenceId("20220121T010203Z") + dtStart = DtStart("20220121T010203Z") + summary = "Cancelled exception on 2nd day" + status = Status.VEVENT_CANCELLED + }) + exceptions.add(Event().apply { + recurrenceId = RecurrenceId("20220122T010203Z") + dtStart = DtStart("20220122T010203Z") + summary = "Cancelled exception on 3rd day" + status = Status.VEVENT_CANCELLED + }) + } + val localEvent = LocalEvent(calendar, event, "filename.ics", null, null, LocalResource.FLAG_REMOTELY_PRESENT) + localEvent.add() + val eventId = localEvent.id!! + + // set event as dirty + provider.update(ContentUris.withAppendedId(Events.CONTENT_URI.asSyncAdapter(account), eventId), ContentValues(1).apply { + put(Events.DIRTY, 1) + }, null, null) + + // this method should mark the event as deleted + calendar.deleteDirtyEventsWithoutInstances() + + // verify that event is now marked as deleted + provider.query( + ContentUris.withAppendedId(Events.CONTENT_URI.asSyncAdapter(account), eventId), + arrayOf(Events.DELETED), null, null, null + )!!.use { cursor -> + cursor.moveToNext() + assertEquals(1, cursor.getInt(0)) + } + } + + @Test + // Flaky, Needs single or rec init of CalendarProvider (InitCalendarProviderRule) + fun testDeleteDirtyEventsWithoutInstances_Recurring_Instances() { + val event = Event().apply { + dtStart = DtStart("20220120T010203Z") + summary = "Event with 3 instances" + rRules.add(RRule("FREQ=DAILY;COUNT=3")) + } + val localEvent = LocalEvent(calendar, event, "filename.ics", null, null, LocalResource.FLAG_REMOTELY_PRESENT) + localEvent.add() + val eventId = localEvent.id!! + + // set event as dirty + provider.update(ContentUris.withAppendedId(Events.CONTENT_URI.asSyncAdapter(account), eventId), ContentValues(1).apply { + put(Events.DIRTY, 1) + }, null, null) + + // this method should mark the event as deleted + calendar.deleteDirtyEventsWithoutInstances() + + // verify that event is not marked as deleted + provider.query( + ContentUris.withAppendedId(Events.CONTENT_URI.asSyncAdapter(account), eventId), + arrayOf(Events.DELETED), null, null, null + )!!.use { cursor -> + cursor.moveToNext() + assertEquals(0, cursor.getInt(0)) + } + } + +} \ No newline at end of file diff --git a/app/src/androidTest/java/at/bitfire/davdroid/resource/LocalEventTest.kt b/app/src/androidTest/java/at/bitfire/davdroid/resource/LocalEventTest.kt new file mode 100644 index 0000000000000000000000000000000000000000..e3e43bf44a2adfd47901e01d5d85106389833c0e --- /dev/null +++ b/app/src/androidTest/java/at/bitfire/davdroid/resource/LocalEventTest.kt @@ -0,0 +1,392 @@ +/*************************************************************************************************** + * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. + **************************************************************************************************/ + +package at.bitfire.davdroid.resource + +import android.Manifest +import android.accounts.Account +import android.content.* +import android.os.Build +import android.provider.CalendarContract +import android.provider.CalendarContract.ACCOUNT_TYPE_LOCAL +import android.provider.CalendarContract.Events +import androidx.test.platform.app.InstrumentationRegistry +import androidx.test.rule.GrantPermissionRule +import at.bitfire.davdroid.InitCalendarProviderRule +import at.bitfire.ical4android.AndroidCalendar +import at.bitfire.ical4android.Event +import at.bitfire.ical4android.MiscUtils.ContentProviderClientHelper.closeCompat +import at.techbee.jtx.JtxContract.asSyncAdapter +import net.fortuna.ical4j.model.Date +import net.fortuna.ical4j.model.DateList +import net.fortuna.ical4j.model.parameter.Value +import net.fortuna.ical4j.model.property.* +import org.junit.* +import org.junit.Assert.* +import org.junit.rules.TestRule + +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() + + private val account = Account("LocalCalendarTest", ACCOUNT_TYPE_LOCAL) + + private lateinit var provider: ContentProviderClient + private lateinit var calendar: LocalCalendar + + @BeforeClass + @JvmStatic + fun connect() { + val context = InstrumentationRegistry.getInstrumentation().targetContext + provider = context.contentResolver.acquireContentProviderClient(CalendarContract.AUTHORITY)!! + } + + @AfterClass + @JvmStatic + fun disconnect() { + provider.closeCompat() + } + } + + @Before + fun createCalendar() { + val uri = AndroidCalendar.create(account, provider, ContentValues()) + calendar = AndroidCalendar.findByID(account, provider, LocalCalendar.Factory, ContentUris.parseId(uri)) + } + + @After + fun removeCalendar() { + calendar.delete() + } + + + @Test + fun testNumDirectInstances_SingleInstance() { + val event = Event().apply { + dtStart = DtStart("20220120T010203Z") + summary = "Event with 1 instance" + } + val localEvent = LocalEvent(calendar, event, null, null, null, 0) + localEvent.add() + + assertEquals(1, LocalEvent.numDirectInstances(provider, account, localEvent.id!!)) + } + + @Test + fun testNumDirectInstances_Recurring() { + val event = Event().apply { + dtStart = DtStart("20220120T010203Z") + summary = "Event with 5 instances" + rRules.add(RRule("FREQ=DAILY;COUNT=5")) + } + val localEvent = LocalEvent(calendar, event, null, null, null, 0) + localEvent.add() + + assertEquals(5, LocalEvent.numDirectInstances(provider, account, localEvent.id!!)) + } + + @Test + fun testNumDirectInstances_Recurring_Endless() { + val event = Event().apply { + dtStart = DtStart("20220120T010203Z") + summary = "Event without end" + rRules.add(RRule("FREQ=DAILY")) + } + val localEvent = LocalEvent(calendar, event, null, null, null, 0) + localEvent.add() + + assertNull(LocalEvent.numDirectInstances(provider, account, localEvent.id!!)) + } + + @Test + // Flaky, Needs rec event init of CalendarProvider + fun testNumDirectInstances_Recurring_LateEnd() { + val event = Event().apply { + dtStart = DtStart("20220120T010203Z") + summary = "Event with 53 years" + rRules.add(RRule("FREQ=YEARLY;UNTIL=20740119T010203Z")) // year 2074 is not supported by Android <11 Calendar Storage + } + val localEvent = LocalEvent(calendar, event, null, null, null, 0) + localEvent.add() + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) + assertEquals(52, LocalEvent.numDirectInstances(provider, account, localEvent.id!!)) + else + assertNull(LocalEvent.numDirectInstances(provider, account, localEvent.id!!)) + } + + @Test + // Flaky, Needs rec event init of CalendarProvider + fun testNumDirectInstances_Recurring_ManyInstances() { + val event = Event().apply { + dtStart = DtStart("20220120T010203Z") + summary = "Event with 2 years" + rRules.add(RRule("FREQ=DAILY;UNTIL=20240120T010203Z")) + } + val localEvent = LocalEvent(calendar, event, null, null, null, 0) + localEvent.add() + val number = LocalEvent.numDirectInstances(provider, account, localEvent.id!!) + + // Some android versions (i.e. <=Q and S) return 365*2 instances (wrong, 365*2+1 => correct), + // but we are satisfied with either result for now + assertTrue(number == 365*2 || number == 365*2+1) + } + + @Test + // Flaky, Needs single event init of CalendarProvider + fun testNumDirectInstances_RecurringWithExdate() { + val event = Event().apply { + dtStart = DtStart(Date("20220120T010203Z")) + summary = "Event with 5 instances" + rRules.add(RRule("FREQ=DAILY;COUNT=5")) + exDates.add(ExDate(DateList("20220121T010203Z", Value.DATE_TIME))) + } + val localEvent = LocalEvent(calendar, event, null, null, null, 0) + localEvent.add() + + assertEquals(4, LocalEvent.numDirectInstances(provider, account, localEvent.id!!)) + } + + @Test + fun testNumDirectInstances_RecurringWithExceptions() { + val event = Event().apply { + dtStart = DtStart("20220120T010203Z") + summary = "Event with 5 instances" + rRules.add(RRule("FREQ=DAILY;COUNT=5")) + exceptions.add(Event().apply { + recurrenceId = RecurrenceId("20220122T010203Z") + dtStart = DtStart("20220122T130203Z") + summary = "Exception on 3rd day" + }) + exceptions.add(Event().apply { + recurrenceId = RecurrenceId("20220124T010203Z") + dtStart = DtStart("20220122T160203Z") + summary = "Exception on 5th day" + }) + } + val localEvent = LocalEvent(calendar, event, "filename.ics", null, null, 0) + localEvent.add() + + assertEquals(5-2, LocalEvent.numDirectInstances(provider, account, localEvent.id!!)) + } + + + @Test + // Flaky, Needs single or rec event init of CalendarProvider + fun testNumInstances_SingleInstance() { + val event = Event().apply { + dtStart = DtStart("20220120T010203Z") + summary = "Event with 1 instance" + } + val localEvent = LocalEvent(calendar, event, null, null, null, 0) + localEvent.add() + + assertEquals(1, LocalEvent.numInstances(provider, account, localEvent.id!!)) + } + + @Test + fun testNumInstances_Recurring() { + val event = Event().apply { + dtStart = DtStart("20220120T010203Z") + summary = "Event with 5 instances" + rRules.add(RRule("FREQ=DAILY;COUNT=5")) + } + val localEvent = LocalEvent(calendar, event, null, null, null, 0) + localEvent.add() + + assertEquals(5, LocalEvent.numInstances(provider, account, localEvent.id!!)) + } + + @Test + fun testNumInstances_Recurring_Endless() { + val event = Event().apply { + dtStart = DtStart("20220120T010203Z") + summary = "Event with infinite instances" + rRules.add(RRule("FREQ=YEARLY")) + } + val localEvent = LocalEvent(calendar, event, null, null, null, 0) + localEvent.add() + + assertNull(LocalEvent.numInstances(provider, account, localEvent.id!!)) + } + + @Test + // Flaky, Needs rec event init of CalendarProvider + fun testNumInstances_Recurring_LateEnd() { + val event = Event().apply { + dtStart = DtStart("20220120T010203Z") + summary = "Event over 22 years" + rRules.add(RRule("FREQ=YEARLY;UNTIL=20740119T010203Z")) // year 2074 not supported by Android <11 Calendar Storage + } + val localEvent = LocalEvent(calendar, event, null, null, null, 0) + localEvent.add() + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) + assertEquals(52, LocalEvent.numInstances(provider, account, localEvent.id!!)) + else + assertNull(LocalEvent.numInstances(provider, account, localEvent.id!!)) + } + + @Test + // Flaky, Needs rec event init of CalendarProvider + fun testNumInstances_Recurring_ManyInstances() { + val event = Event().apply { + dtStart = DtStart("20220120T010203Z") + summary = "Event over two years" + rRules.add(RRule("FREQ=DAILY;UNTIL=20240120T010203Z")) + } + val localEvent = LocalEvent(calendar, event, null, null, null, 0) + localEvent.add() + + assertEquals( + if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.Q) + 365*2 // Android <10: does not include UNTIL (incorrect!) + else + 365*2 + 1, // Android ≥10: includes UNTIL (correct) + LocalEvent.numInstances(provider, account, localEvent.id!!)) + } + + @Test + fun testNumInstances_RecurringWithExceptions() { + val event = Event().apply { + dtStart = DtStart("20220120T010203Z") + summary = "Event with 6 instances" + rRules.add(RRule("FREQ=DAILY;COUNT=6")) + exceptions.add(Event().apply { + recurrenceId = RecurrenceId("20220122T010203Z") + dtStart = DtStart("20220122T130203Z") + summary = "Exception on 3rd day" + }) + exceptions.add(Event().apply { + recurrenceId = RecurrenceId("20220124T010203Z") + dtStart = DtStart("20220122T160203Z") + summary = "Exception on 5th day" + }) + } + val localEvent = LocalEvent(calendar, event, "filename.ics", null, null, 0) + val uri = localEvent.add() + + calendar.findById(localEvent.id!!) + + assertEquals(6, LocalEvent.numInstances(provider, account, localEvent.id!!)) + } + + + @Test + fun testMarkEventAsDeleted() { + // Create event + val event = Event().apply { + dtStart = DtStart("20220120T010203Z") + summary = "A fine event" + } + val localEvent = LocalEvent(calendar, event, null, null, null, 0) + localEvent.add() + + // Delete event + LocalEvent.markAsDeleted(provider, account, localEvent.id!!) + + // Get the status of whether the event is deleted + provider.query( + ContentUris.withAppendedId(Events.CONTENT_URI, localEvent.id!!).asSyncAdapter(account), + arrayOf(Events.DELETED), + null, + null, null + )!!.use { cursor -> + cursor.moveToFirst() + assertEquals(1, cursor.getInt(0)) + } + } + + + @Test + fun testDeleteDirtyEventsWithoutInstances_NoInstances_Exdate() { + // TODO + } + + @Test + fun testDeleteDirtyEventsWithoutInstances_NoInstances_CancelledExceptions() { + // create recurring event with only deleted/cancelled instances + val event = Event().apply { + dtStart = DtStart("20220120T010203Z") + summary = "Event with 3 instances" + rRules.add(RRule("FREQ=DAILY;COUNT=3")) + exceptions.add(Event().apply { + recurrenceId = RecurrenceId("20220120T010203Z") + dtStart = DtStart("20220120T010203Z") + summary = "Cancelled exception on 1st day" + status = Status.VEVENT_CANCELLED + }) + exceptions.add(Event().apply { + recurrenceId = RecurrenceId("20220121T010203Z") + dtStart = DtStart("20220121T010203Z") + summary = "Cancelled exception on 2nd day" + status = Status.VEVENT_CANCELLED + }) + exceptions.add(Event().apply { + recurrenceId = RecurrenceId("20220122T010203Z") + dtStart = DtStart("20220122T010203Z") + summary = "Cancelled exception on 3rd day" + status = Status.VEVENT_CANCELLED + }) + } + val localEvent = LocalEvent(calendar, event, "filename.ics", null, null, LocalResource.FLAG_REMOTELY_PRESENT) + localEvent.add() + val eventId = localEvent.id!! + + // set event as dirty + provider.update(ContentUris.withAppendedId(Events.CONTENT_URI.asSyncAdapter(account), eventId), ContentValues(1).apply { + put(Events.DIRTY, 1) + }, null, null) + + // this method should mark the event as deleted + calendar.deleteDirtyEventsWithoutInstances() + + // verify that event is now marked as deleted + provider.query( + ContentUris.withAppendedId(Events.CONTENT_URI.asSyncAdapter(account), eventId), + arrayOf(Events.DELETED), null, null, null + )!!.use { cursor -> + cursor.moveToNext() + assertEquals(1, cursor.getInt(0)) + } + } + + @Test + // Flaky, Needs single event init OR rec event init of CalendarProvider + fun testDeleteDirtyEventsWithoutInstances_Recurring_Instances() { + val event = Event().apply { + dtStart = DtStart("20220120T010203Z") + summary = "Event with 3 instances" + rRules.add(RRule("FREQ=DAILY;COUNT=3")) + } + val localEvent = LocalEvent(calendar, event, "filename.ics", null, null, LocalResource.FLAG_REMOTELY_PRESENT) + localEvent.add() + val eventId = localEvent.id!! + + // set event as dirty + provider.update(ContentUris.withAppendedId(Events.CONTENT_URI.asSyncAdapter(account), eventId), ContentValues(1).apply { + put(Events.DIRTY, 1) + }, null, null) + + // this method should mark the event as deleted + calendar.deleteDirtyEventsWithoutInstances() + + // verify that event is not marked as deleted + provider.query( + ContentUris.withAppendedId(Events.CONTENT_URI.asSyncAdapter(account), eventId), + arrayOf(Events.DELETED), null, null, null + )!!.use { cursor -> + cursor.moveToNext() + assertEquals(0, cursor.getInt(0)) + } + } + +} diff --git a/app/src/androidTest/java/at/bitfire/davdroid/resource/LocalGroupTest.kt b/app/src/androidTest/java/at/bitfire/davdroid/resource/LocalGroupTest.kt new file mode 100644 index 0000000000000000000000000000000000000000..02bf89a134cb88e48c67c418c11566a180d87f3e --- /dev/null +++ b/app/src/androidTest/java/at/bitfire/davdroid/resource/LocalGroupTest.kt @@ -0,0 +1,267 @@ +/*************************************************************************************************** + * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. + **************************************************************************************************/ + +package at.bitfire.davdroid.resource + +import android.Manifest +import android.content.ContentProviderClient +import android.content.ContentUris +import android.content.ContentValues +import android.provider.ContactsContract +import android.provider.ContactsContract.CommonDataKinds.GroupMembership +import androidx.test.platform.app.InstrumentationRegistry +import androidx.test.rule.GrantPermissionRule +import at.bitfire.davdroid.settings.SettingsManager +import at.bitfire.vcard4android.BatchOperation +import at.bitfire.vcard4android.CachedGroupMembership +import at.bitfire.vcard4android.Contact +import at.bitfire.vcard4android.GroupMethod +import dagger.hilt.android.testing.HiltAndroidRule +import dagger.hilt.android.testing.HiltAndroidTest +import org.junit.* +import org.junit.Assert.* +import javax.inject.Inject + +@HiltAndroidTest +class LocalGroupTest { + + @get:Rule + val hiltRule = HiltAndroidRule(this) + + @Inject + lateinit var settingsManager: SettingsManager + + @Before + fun inject() { + hiltRule.inject() + } + + companion object { + @JvmField + @ClassRule + val permissionRule = GrantPermissionRule.grant(Manifest.permission.READ_CONTACTS, Manifest.permission.WRITE_CONTACTS)!! + + private lateinit var provider: ContentProviderClient + + private lateinit var addressBookGroupsAsCategories: LocalTestAddressBook + private lateinit var addressBookGroupsAsVCards: LocalTestAddressBook + + @BeforeClass + @JvmStatic + fun connect() { + val context = InstrumentationRegistry.getInstrumentation().targetContext + provider = context.contentResolver.acquireContentProviderClient(ContactsContract.AUTHORITY)!! + assertNotNull(provider) + + addressBookGroupsAsCategories = LocalTestAddressBook(context, provider, GroupMethod.CATEGORIES) + addressBookGroupsAsVCards = LocalTestAddressBook(context, provider, GroupMethod.GROUP_VCARDS) + } + + @AfterClass + @JvmStatic + fun disconnect() { + @Suppress("DEPRECATION") + provider.release() + } + } + + private fun newGroup(addressBook: LocalAddressBook = addressBookGroupsAsCategories): LocalGroup = + LocalGroup(addressBook, + Contact().apply { + displayName = "Test Group" + }, null, null, 0 + ).apply { + add() + } + + + + @Before + fun clearContacts() { + addressBookGroupsAsCategories.clear() + addressBookGroupsAsVCards.clear() + } + + + @Test + fun testApplyPendingMemberships_addPendingMembership() { + val ab = addressBookGroupsAsVCards + + val contact1 = LocalContact(ab, Contact().apply { + uid = "test1" + displayName = "Test" + }, "test1.vcf", null, 0) + contact1.add() + + val group = newGroup(ab) + // set pending membership of contact1 + ab.provider!!.update( + ContentUris.withAppendedId(ab.groupsSyncUri(), group.id!!), + ContentValues().apply { + put(LocalGroup.COLUMN_PENDING_MEMBERS, LocalGroup.PendingMemberships(setOf("test1")).toString()) + }, + null, null + ) + + // pending membership -> contact1 should be added to group + LocalGroup.applyPendingMemberships(ab) + + // check group membership + ab.provider!!.query( + ab.syncAdapterURI(ContactsContract.Data.CONTENT_URI), arrayOf(GroupMembership.GROUP_ROW_ID, GroupMembership.RAW_CONTACT_ID), + "${GroupMembership.MIMETYPE}=?", arrayOf(GroupMembership.CONTENT_ITEM_TYPE), + null + )!!.use { cursor -> + assertTrue(cursor.moveToNext()) + assertEquals(group.id, cursor.getLong(0)) + assertEquals(contact1.id, cursor.getLong(1)) + + assertFalse(cursor.moveToNext()) + } + // check cached group membership + ab.provider!!.query( + ab.syncAdapterURI(ContactsContract.Data.CONTENT_URI), arrayOf(CachedGroupMembership.GROUP_ID, CachedGroupMembership.RAW_CONTACT_ID), + "${CachedGroupMembership.MIMETYPE}=?", arrayOf(CachedGroupMembership.CONTENT_ITEM_TYPE), + null + )!!.use { cursor -> + assertTrue(cursor.moveToNext()) + assertEquals(group.id, cursor.getLong(0)) + assertEquals(contact1.id, cursor.getLong(1)) + + assertFalse(cursor.moveToNext()) + } + } + + @Test + fun testApplyPendingMemberships_removeMembership() { + val ab = addressBookGroupsAsVCards + + val contact1 = LocalContact(ab, Contact().apply { + uid = "test1" + displayName = "Test" + }, "test1.vcf", null, 0) + contact1.add() + + val group = newGroup(ab) + + // add contact1 to group + val batch = BatchOperation(ab.provider!!) + contact1.addToGroup(batch, group.id!!) + batch.commit() + + // no pending memberships -> membership should be removed + LocalGroup.applyPendingMemberships(ab) + + // check group membership + ab.provider!!.query( + ab.syncAdapterURI(ContactsContract.Data.CONTENT_URI), arrayOf(GroupMembership.GROUP_ROW_ID, GroupMembership.RAW_CONTACT_ID), + "${GroupMembership.MIMETYPE}=?", arrayOf(GroupMembership.CONTENT_ITEM_TYPE), + null + )!!.use { cursor -> + assertFalse(cursor.moveToNext()) + } + // check cached group membership + ab.provider!!.query( + ab.syncAdapterURI(ContactsContract.Data.CONTENT_URI), arrayOf(CachedGroupMembership.GROUP_ID, CachedGroupMembership.RAW_CONTACT_ID), + "${CachedGroupMembership.MIMETYPE}=?", arrayOf(CachedGroupMembership.CONTENT_ITEM_TYPE), + null + )!!.use { cursor -> + assertFalse(cursor.moveToNext()) + } + } + + + @Test + fun testClearDirty_addCachedGroupMembership() { + val ab = addressBookGroupsAsCategories + val group = newGroup(ab) + + val contact1 = LocalContact(ab, Contact().apply { displayName = "Test" }, "fn.vcf", null, 0) + contact1.add() + + // insert group membership, but no cached group membership + ab.provider!!.insert( + ab.syncAdapterURI(ContactsContract.Data.CONTENT_URI), ContentValues().apply { + put(GroupMembership.MIMETYPE, GroupMembership.CONTENT_ITEM_TYPE) + put(GroupMembership.RAW_CONTACT_ID, contact1.id) + put(GroupMembership.GROUP_ROW_ID, group.id) + } + ) + + group.clearDirty(null, null) + + // check cached group membership + ab.provider!!.query( + ab.syncAdapterURI(ContactsContract.Data.CONTENT_URI), arrayOf(CachedGroupMembership.GROUP_ID, CachedGroupMembership.RAW_CONTACT_ID), + "${CachedGroupMembership.MIMETYPE}=?", arrayOf(CachedGroupMembership.CONTENT_ITEM_TYPE), + null + )!!.use { cursor -> + assertTrue(cursor.moveToNext()) + assertEquals(group.id, cursor.getLong(0)) + assertEquals(contact1.id, cursor.getLong(1)) + + assertFalse(cursor.moveToNext()) + } + } + + @Test + fun testClearDirty_removeCachedGroupMembership() { + val ab = addressBookGroupsAsCategories + val group = newGroup(ab) + + val contact1 = LocalContact(ab, Contact().apply { displayName = "Test" }, "fn.vcf", null, 0) + contact1.add() + + // insert cached group membership, but no group membership + ab.provider!!.insert( + ab.syncAdapterURI(ContactsContract.Data.CONTENT_URI), ContentValues().apply { + put(CachedGroupMembership.MIMETYPE, CachedGroupMembership.CONTENT_ITEM_TYPE) + put(CachedGroupMembership.RAW_CONTACT_ID, contact1.id) + put(CachedGroupMembership.GROUP_ID, group.id) + } + ) + + group.clearDirty(null, null) + + // cached group membership should be gone + ab.provider!!.query( + ab.syncAdapterURI(ContactsContract.Data.CONTENT_URI), arrayOf(CachedGroupMembership.GROUP_ID, CachedGroupMembership.RAW_CONTACT_ID), + "${CachedGroupMembership.MIMETYPE}=?", arrayOf(CachedGroupMembership.CONTENT_ITEM_TYPE), + null + )!!.use { cursor -> + assertFalse(cursor.moveToNext()) + } + } + + + @Test + fun testMarkMembersDirty() { + val ab = addressBookGroupsAsCategories + val group = newGroup(ab) + + val contact1 = LocalContact(ab, Contact().apply { displayName = "Test" }, "fn.vcf", null, 0) + contact1.add() + + val batch = BatchOperation(ab.provider!!) + contact1.addToGroup(batch, group.id!!) + batch.commit() + + assertEquals(0, ab.findDirty().size) + group.markMembersDirty() + assertEquals(contact1.id, ab.findDirty().first().id) + } + + + @Test + fun testPrepareForUpload() { + val group = newGroup() + assertNull(group.getContact().uid) + + val fileName = group.prepareForUpload() + val newUid = group.getContact().uid + assertNotNull(newUid) + assertEquals("$newUid.vcf", fileName) + } + +} \ No newline at end of file diff --git a/app/src/androidTest/java/at/bitfire/davdroid/resource/LocalTestAddressBook.kt b/app/src/androidTest/java/at/bitfire/davdroid/resource/LocalTestAddressBook.kt new file mode 100644 index 0000000000000000000000000000000000000000..ff274f5d9da027dbd9256b4c1f7bafb26597579c --- /dev/null +++ b/app/src/androidTest/java/at/bitfire/davdroid/resource/LocalTestAddressBook.kt @@ -0,0 +1,38 @@ +/*************************************************************************************************** + * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. + **************************************************************************************************/ + +package at.bitfire.davdroid.resource + +import android.accounts.Account +import android.content.ContentProviderClient +import android.content.Context +import at.bitfire.vcard4android.GroupMethod + +class LocalTestAddressBook( + context: Context, + provider: ContentProviderClient, + override val groupMethod: GroupMethod +): LocalAddressBook(context, ACCOUNT, provider) { + + companion object { + val ACCOUNT = Account("LocalTestAddressBook", "at.bitfire.davdroid.test") + } + + override var mainAccount: Account + get() = throw NotImplementedError() + set(value) = throw NotImplementedError() + + override var readOnly: Boolean + get() = false + set(value) = throw NotImplementedError() + + + fun clear() { + for (contact in queryContacts(null, null)) + contact.delete() + for (group in queryGroups(null, null)) + group.delete() + } + +} \ No newline at end of file diff --git a/app/src/androidTest/java/at/bitfire/davdroid/resource/contactrow/CachedGroupMembershipHandlerTest.kt b/app/src/androidTest/java/at/bitfire/davdroid/resource/contactrow/CachedGroupMembershipHandlerTest.kt new file mode 100644 index 0000000000000000000000000000000000000000..c8516cc2416882c3876a67d42148efc9446baf99 --- /dev/null +++ b/app/src/androidTest/java/at/bitfire/davdroid/resource/contactrow/CachedGroupMembershipHandlerTest.kt @@ -0,0 +1,63 @@ +/*************************************************************************************************** + * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. + **************************************************************************************************/ + +package at.bitfire.davdroid.resource.contactrow + +import android.Manifest +import android.content.ContentProviderClient +import android.content.ContentValues +import android.provider.ContactsContract +import androidx.test.platform.app.InstrumentationRegistry +import androidx.test.rule.GrantPermissionRule +import at.bitfire.davdroid.resource.LocalContact +import at.bitfire.davdroid.resource.LocalTestAddressBook +import at.bitfire.vcard4android.CachedGroupMembership +import at.bitfire.vcard4android.Contact +import at.bitfire.vcard4android.GroupMethod +import org.junit.* +import org.junit.Assert.assertArrayEquals + +class CachedGroupMembershipHandlerTest { + + companion object { + + @JvmField + @ClassRule + val permissionRule = GrantPermissionRule.grant(Manifest.permission.READ_CONTACTS, Manifest.permission.WRITE_CONTACTS)!! + + private lateinit var provider: ContentProviderClient + private lateinit var addressBook: LocalTestAddressBook + + @BeforeClass + @JvmStatic + fun connect() { + val context = InstrumentationRegistry.getInstrumentation().context + provider = context.contentResolver.acquireContentProviderClient(ContactsContract.AUTHORITY)!! + Assert.assertNotNull(provider) + + addressBook = LocalTestAddressBook(context, provider, GroupMethod.GROUP_VCARDS) + } + + @AfterClass + @JvmStatic + fun disconnect() { + @Suppress("DEPRECATION") + provider.release() + } + + } + + + @Test + fun testMembership() { + val contact = Contact() + val localContact = LocalContact(addressBook, contact, null, null, 0) + CachedGroupMembershipHandler(localContact).handle(ContentValues().apply { + put(CachedGroupMembership.GROUP_ID, 123456) + put(CachedGroupMembership.RAW_CONTACT_ID, 789) + }, contact) + assertArrayEquals(arrayOf(123456L), localContact.cachedGroupMemberships.toArray()) + } + +} \ No newline at end of file diff --git a/app/src/androidTest/java/at/bitfire/davdroid/resource/contactrow/GroupMembershipBuilderTest.kt b/app/src/androidTest/java/at/bitfire/davdroid/resource/contactrow/GroupMembershipBuilderTest.kt new file mode 100644 index 0000000000000000000000000000000000000000..e1c58b9300d16c7055a416a97d6cee4e43871995 --- /dev/null +++ b/app/src/androidTest/java/at/bitfire/davdroid/resource/contactrow/GroupMembershipBuilderTest.kt @@ -0,0 +1,75 @@ +/*************************************************************************************************** + * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. + **************************************************************************************************/ + +package at.bitfire.davdroid.resource.contactrow + +import android.Manifest +import android.content.ContentProviderClient +import android.net.Uri +import android.provider.ContactsContract +import android.provider.ContactsContract.CommonDataKinds.GroupMembership +import androidx.test.platform.app.InstrumentationRegistry +import androidx.test.rule.GrantPermissionRule +import at.bitfire.davdroid.resource.LocalTestAddressBook +import at.bitfire.vcard4android.Contact +import at.bitfire.vcard4android.GroupMethod +import org.junit.* +import org.junit.Assert.assertEquals + +class GroupMembershipBuilderTest { + + companion object { + @JvmField + @ClassRule + val permissionRule = GrantPermissionRule.grant(Manifest.permission.READ_CONTACTS, Manifest.permission.WRITE_CONTACTS)!! + + private lateinit var provider: ContentProviderClient + + private lateinit var addressBookGroupsAsCategories: LocalTestAddressBook + private lateinit var addressBookGroupsAsVCards: LocalTestAddressBook + + @BeforeClass + @JvmStatic + fun connect() { + val context = InstrumentationRegistry.getInstrumentation().targetContext + provider = context.contentResolver.acquireContentProviderClient(ContactsContract.AUTHORITY)!! + Assert.assertNotNull(provider) + + addressBookGroupsAsCategories = LocalTestAddressBook(context, provider, GroupMethod.CATEGORIES) + addressBookGroupsAsVCards = LocalTestAddressBook(context, provider, GroupMethod.GROUP_VCARDS) + } + + @AfterClass + @JvmStatic + fun disconnect() { + @Suppress("DEPRECATION") + provider.release() + } + } + + + @Test + fun testCategories_GroupsAsCategories() { + val contact = Contact().apply { + categories += "TEST GROUP" + } + GroupMembershipBuilder(Uri.EMPTY, null, contact, addressBookGroupsAsCategories).build().also { result -> + assertEquals(1, result.size) + assertEquals(GroupMembership.CONTENT_ITEM_TYPE, result[0].values[GroupMembership.MIMETYPE]) + assertEquals(addressBookGroupsAsCategories.findOrCreateGroup("TEST GROUP"), result[0].values[GroupMembership.GROUP_ROW_ID]) + } + } + + @Test + fun testCategories_GroupsAsVCards() { + val contact = Contact().apply { + categories += "TEST GROUP" + } + GroupMembershipBuilder(Uri.EMPTY, null, contact, addressBookGroupsAsVCards).build().also { result -> + // group membership is constructed during post-processing + assertEquals(0, result.size) + } + } + +} \ No newline at end of file diff --git a/app/src/androidTest/java/at/bitfire/davdroid/resource/contactrow/GroupMembershipHandlerTest.kt b/app/src/androidTest/java/at/bitfire/davdroid/resource/contactrow/GroupMembershipHandlerTest.kt new file mode 100644 index 0000000000000000000000000000000000000000..953900eae0fd4cd9a15e8641b72bfb68501afd19 --- /dev/null +++ b/app/src/androidTest/java/at/bitfire/davdroid/resource/contactrow/GroupMembershipHandlerTest.kt @@ -0,0 +1,79 @@ +/*************************************************************************************************** + * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. + **************************************************************************************************/ + +package at.bitfire.davdroid.resource.contactrow + +import android.Manifest +import android.content.ContentProviderClient +import android.content.ContentValues +import android.provider.ContactsContract +import androidx.test.platform.app.InstrumentationRegistry +import androidx.test.rule.GrantPermissionRule +import at.bitfire.davdroid.resource.LocalContact +import at.bitfire.davdroid.resource.LocalTestAddressBook +import at.bitfire.vcard4android.CachedGroupMembership +import at.bitfire.vcard4android.Contact +import at.bitfire.vcard4android.GroupMethod +import org.junit.* +import org.junit.Assert.assertArrayEquals +import org.junit.Assert.assertTrue + +class GroupMembershipHandlerTest { + + @JvmField + @Rule + val permissionRule = GrantPermissionRule.grant(Manifest.permission.READ_CONTACTS, Manifest.permission.WRITE_CONTACTS)!! + + private lateinit var provider: ContentProviderClient + + private lateinit var addressBookGroupsAsCategories: LocalTestAddressBook + private var addressBookGroupsAsCategoriesGroup: Long = -1 + + private lateinit var addressBookGroupsAsVCards: LocalTestAddressBook + + @Before + fun connect() { + val context = InstrumentationRegistry.getInstrumentation().targetContext + provider = context.contentResolver.acquireContentProviderClient(ContactsContract.AUTHORITY)!! + Assert.assertNotNull(provider) + + addressBookGroupsAsCategories = LocalTestAddressBook(context, provider, GroupMethod.CATEGORIES) + addressBookGroupsAsCategoriesGroup = addressBookGroupsAsCategories.findOrCreateGroup("TEST GROUP") + + addressBookGroupsAsVCards = LocalTestAddressBook(context, provider, GroupMethod.GROUP_VCARDS) + } + + @After + fun disconnect() { + @Suppress("DEPRECATION") + provider.release() + } + + + @Test + fun testMembership_GroupsAsCategories() { + val contact = Contact() + val localContact = LocalContact(addressBookGroupsAsCategories, contact, null, null, 0) + GroupMembershipHandler(localContact).handle(ContentValues().apply { + put(CachedGroupMembership.GROUP_ID, addressBookGroupsAsCategoriesGroup) + put(CachedGroupMembership.RAW_CONTACT_ID, -1) + }, contact) + assertArrayEquals(arrayOf(addressBookGroupsAsCategoriesGroup), localContact.groupMemberships.toArray()) + assertArrayEquals(arrayOf("TEST GROUP"), contact.categories.toArray()) + } + + + @Test + fun testMembership_GroupsAsVCards() { + val contact = Contact() + val localContact = LocalContact(addressBookGroupsAsVCards, contact, null, null, 0) + GroupMembershipHandler(localContact).handle(ContentValues().apply { + put(CachedGroupMembership.GROUP_ID, 12345) // because the group name is not queried and put into CATEGORIES, the group doesn't have to really exist + put(CachedGroupMembership.RAW_CONTACT_ID, -1) + }, contact) + assertArrayEquals(arrayOf(12345L), localContact.groupMemberships.toArray()) + assertTrue(contact.categories.isEmpty()) + } + +} \ No newline at end of file diff --git a/app/src/androidTest/java/at/bitfire/davdroid/resource/contactrow/UnknownPropertiesBuilderTest.kt b/app/src/androidTest/java/at/bitfire/davdroid/resource/contactrow/UnknownPropertiesBuilderTest.kt new file mode 100644 index 0000000000000000000000000000000000000000..8b1461467129a984d5283f9642a3daa532800ba7 --- /dev/null +++ b/app/src/androidTest/java/at/bitfire/davdroid/resource/contactrow/UnknownPropertiesBuilderTest.kt @@ -0,0 +1,32 @@ +/*************************************************************************************************** + * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. + **************************************************************************************************/ + +package at.bitfire.davdroid.resource.contactrow + +import android.net.Uri +import at.bitfire.vcard4android.Contact +import org.junit.Assert.assertEquals +import org.junit.Test + +class UnknownPropertiesBuilderTest { + + @Test + fun testUnknownProperties_None() { + UnknownPropertiesBuilder(Uri.EMPTY, null, Contact()).build().also { result -> + assertEquals(0, result.size) + } + } + + @Test + fun testUnknownProperties_Properties() { + UnknownPropertiesBuilder(Uri.EMPTY, null, Contact().apply { + unknownProperties = "X-TEST:12345" + }).build().also { result -> + assertEquals(1, result.size) + assertEquals(UnknownProperties.CONTENT_ITEM_TYPE, result[0].values[UnknownProperties.MIMETYPE]) + assertEquals("X-TEST:12345", result[0].values[UnknownProperties.UNKNOWN_PROPERTIES]) + } + } + +} \ No newline at end of file diff --git a/app/src/androidTest/java/at/bitfire/davdroid/resource/contactrow/UnknownPropertiesHandlerTest.kt b/app/src/androidTest/java/at/bitfire/davdroid/resource/contactrow/UnknownPropertiesHandlerTest.kt new file mode 100644 index 0000000000000000000000000000000000000000..73ba4648bb2d0d00112795dd540ee37329bcd132 --- /dev/null +++ b/app/src/androidTest/java/at/bitfire/davdroid/resource/contactrow/UnknownPropertiesHandlerTest.kt @@ -0,0 +1,33 @@ +/*************************************************************************************************** + * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. + **************************************************************************************************/ + +package at.bitfire.davdroid.resource.contactrow + +import android.content.ContentValues +import at.bitfire.vcard4android.Contact +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test + +class UnknownPropertiesHandlerTest { + + @Test + fun testUnknownProperties_Empty() { + val contact = Contact() + UnknownPropertiesHandler.handle(ContentValues().apply { + putNull(UnknownProperties.UNKNOWN_PROPERTIES) + }, contact) + assertNull(contact.unknownProperties) + } + + @Test + fun testUnknownProperties_Values() { + val contact = Contact() + UnknownPropertiesHandler.handle(ContentValues().apply { + put(UnknownProperties.UNKNOWN_PROPERTIES, "X-TEST:12345") + }, contact) + assertEquals("X-TEST:12345", contact.unknownProperties) + } + +} \ No newline at end of file diff --git a/app/src/androidTest/java/at/bitfire/davdroid/settings/AccountSettingsTest.kt b/app/src/androidTest/java/at/bitfire/davdroid/settings/AccountSettingsTest.kt new file mode 100644 index 0000000000000000000000000000000000000000..f2060cad3b55ca9cbf02e5b5af18fa62579b2a16 --- /dev/null +++ b/app/src/androidTest/java/at/bitfire/davdroid/settings/AccountSettingsTest.kt @@ -0,0 +1,88 @@ +/*************************************************************************************************** + * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. + **************************************************************************************************/ + +package at.bitfire.davdroid.settings + +import android.accounts.Account +import android.accounts.AccountManager +import android.content.ContentResolver +import android.os.Build +import android.provider.CalendarContract +import android.provider.ContactsContract +import androidx.test.platform.app.InstrumentationRegistry +import at.bitfire.davdroid.R +import at.bitfire.davdroid.db.Credentials +import at.bitfire.davdroid.syncadapter.AccountUtils +import dagger.hilt.android.testing.HiltAndroidRule +import dagger.hilt.android.testing.HiltAndroidTest +import org.junit.After +import org.junit.Assert.* +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import java.util.concurrent.TimeUnit +import javax.inject.Inject + +@HiltAndroidTest +class AccountSettingsTest { + + @get:Rule + val hiltRule = HiltAndroidRule(this) + + @Inject + lateinit var settingsManager: SettingsManager + + + val context = InstrumentationRegistry.getInstrumentation().targetContext + + val account = Account("Test Account", context.getString(R.string.account_type)) + val fakeCredentials = Credentials("test", "test") + + @Before + fun setUp() { + hiltRule.inject() + + assertTrue(AccountUtils.createAccount(context, account, AccountSettings.initialUserData(fakeCredentials, null))) + ContentResolver.setIsSyncable(account, CalendarContract.AUTHORITY, 1) + ContentResolver.setIsSyncable(account, ContactsContract.AUTHORITY, 0) + } + + @After + fun removeAccount() { + val futureResult = AccountManager.get(context).removeAccount(account, {}, null) + assertTrue(futureResult.getResult(10, TimeUnit.SECONDS)) + } + + + @Test + fun testSyncIntervals() { + val settings = AccountSettings(context, account) + val presetIntervals = context.resources.getStringArray(R.array.settings_sync_interval_seconds) + .map { it.toLong() } + .filter { it != AccountSettings.SYNC_INTERVAL_MANUALLY } + for (interval in presetIntervals) { + assertTrue(settings.setSyncInterval(CalendarContract.AUTHORITY, interval)) + assertEquals(interval, settings.getSyncInterval(CalendarContract.AUTHORITY)) + } + } + + @Test + fun testSyncIntervals_IsNotSyncable() { + val settings = AccountSettings(context, account) + val interval = 15*60L // 15 min + val result = settings.setSyncInterval(ContactsContract.AUTHORITY, interval) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) // below Android 7, Android returns true for whatever reason + assertFalse(result) + } + + @Test + fun testSyncIntervals_TooShort() { + val settings = AccountSettings(context, account) + val interval = 60L // 1 min is not supported by Android + val result = settings.setSyncInterval(CalendarContract.AUTHORITY, interval) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) // below Android 7, Android returns true for whatever reason + assertFalse(result) + } + +} \ No newline at end of file diff --git a/app/src/androidTest/java/at/bitfire/davdroid/settings/DefaultsSettingsProviderTest.kt b/app/src/androidTest/java/at/bitfire/davdroid/settings/DefaultsSettingsProviderTest.kt deleted file mode 100644 index 2d84e147f880c0ee6deb80d0ef3ed3bdcbbb7c01..0000000000000000000000000000000000000000 --- a/app/src/androidTest/java/at/bitfire/davdroid/settings/DefaultsSettingsProviderTest.kt +++ /dev/null @@ -1,38 +0,0 @@ -/* - * Copyright © Ricki Hirner (bitfire web engineering). - * All rights reserved. This program and the accompanying materials - * are made available under the terms of the GNU Public License v3.0 - * which accompanies this distribution, and is available at - * http://www.gnu.org/licenses/gpl.html - */ - -package at.bitfire.davdroid.settings - -import org.junit.Assert.assertEquals -import org.junit.Assert.assertFalse -import org.junit.Test - -class DefaultsSettingsProviderTest { - - private val provider: SettingsProvider = DefaultsProvider() - - @Test - fun testHas() { - assertEquals(Pair(false, true), provider.has("notExisting")) - assertEquals(Pair(true, true), provider.has(Settings.OVERRIDE_PROXY)) - } - - @Test - fun testGet() { - assertEquals(Pair("localhost", true), provider.getString(Settings.OVERRIDE_PROXY_HOST)) - assertEquals(Pair(8118, true), provider.getInt(Settings.OVERRIDE_PROXY_PORT)) - } - - @Test - fun testPutRemove() { - assertEquals(Pair(false, true), provider.isWritable(Settings.OVERRIDE_PROXY)) - assertFalse(provider.putBoolean(Settings.OVERRIDE_PROXY, true)) - assertFalse(provider.remove(Settings.OVERRIDE_PROXY)) - } - -} \ No newline at end of file diff --git a/app/src/androidTest/java/at/bitfire/davdroid/settings/SettingsManagerTest.kt b/app/src/androidTest/java/at/bitfire/davdroid/settings/SettingsManagerTest.kt new file mode 100644 index 0000000000000000000000000000000000000000..9045d17feb23c9fb725bba89399fd14fe120ad89 --- /dev/null +++ b/app/src/androidTest/java/at/bitfire/davdroid/settings/SettingsManagerTest.kt @@ -0,0 +1,41 @@ +/*************************************************************************************************** + * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. + **************************************************************************************************/ + +package at.bitfire.davdroid.settings + +import dagger.hilt.android.testing.HiltAndroidRule +import dagger.hilt.android.testing.HiltAndroidTest +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import javax.inject.Inject + +@HiltAndroidTest +class SettingsManagerTest { + + @get:Rule + val hiltRule = HiltAndroidRule(this) + + @Inject lateinit var settingsManager: SettingsManager + + @Before + fun inject() { + hiltRule.inject() + } + + + @Test + fun testContainsKey_NotExisting() { + assertFalse(settingsManager.containsKey("notExisting")) + } + + @Test + fun testContainsKey_Existing() { + // provided by DefaultsProvider + assertEquals(Settings.PROXY_TYPE_SYSTEM, settingsManager.getInt(Settings.PROXY_TYPE)) + } + +} \ No newline at end of file diff --git a/app/src/androidTest/java/at/bitfire/davdroid/settings/SettingsTest.kt b/app/src/androidTest/java/at/bitfire/davdroid/settings/SettingsTest.kt deleted file mode 100644 index 7e7d7feeea1c6edf351dccebfc8c415c512cc56d..0000000000000000000000000000000000000000 --- a/app/src/androidTest/java/at/bitfire/davdroid/settings/SettingsTest.kt +++ /dev/null @@ -1,34 +0,0 @@ -/* - * Copyright © Ricki Hirner (bitfire web engineering). - * All rights reserved. This program and the accompanying materials - * are made available under the terms of the GNU Public License v3.0 - * which accompanies this distribution, and is available at - * http://www.gnu.org/licenses/gpl.html - */ - -package at.bitfire.davdroid.settings - -import androidx.test.platform.app.InstrumentationRegistry -import org.junit.Assert.assertFalse -import org.junit.Assert.assertTrue -import org.junit.Before -import org.junit.Test - -class SettingsTest { - - lateinit var settings: Settings - - @Before - fun initialize() { - settings = Settings.getInstance(InstrumentationRegistry.getInstrumentation().targetContext) - } - - @Test - fun testHas() { - assertFalse(settings.has("notExisting")) - - // provided by DefaultsProvider - assertTrue(settings.has(Settings.OVERRIDE_PROXY)) - } - -} \ No newline at end of file diff --git a/app/src/androidTest/java/at/bitfire/davdroid/ui/DebugInfoActivityTest.kt b/app/src/androidTest/java/at/bitfire/davdroid/ui/DebugInfoActivityTest.kt new file mode 100644 index 0000000000000000000000000000000000000000..d60b2584bf9b370def75d1d20849ce3fc302abe0 --- /dev/null +++ b/app/src/androidTest/java/at/bitfire/davdroid/ui/DebugInfoActivityTest.kt @@ -0,0 +1,37 @@ +/*************************************************************************************************** + * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. + **************************************************************************************************/ + +package at.bitfire.davdroid.ui + +import androidx.test.platform.app.InstrumentationRegistry +import org.junit.Assert.assertEquals +import org.junit.Test + +class DebugInfoActivityTest { + + @Test + fun testIntentBuilder_LargeLocalResource() { + val a = 'A'.code.toByte() + val intent = DebugInfoActivity.IntentBuilder(InstrumentationRegistry.getInstrumentation().context) + .withLocalResource(String(ByteArray(1024*1024, { a }))) + .build() + val expected = StringBuilder(DebugInfoActivity.IntentBuilder.MAX_ELEMENT_SIZE) + expected.append(String(ByteArray(DebugInfoActivity.IntentBuilder.MAX_ELEMENT_SIZE - 3, { a }))) + expected.append("...") + assertEquals(expected.toString(), intent.getStringExtra("localResource")) + } + + @Test + fun testIntentBuilder_LargeLogs() { + val a = 'A'.code.toByte() + val intent = DebugInfoActivity.IntentBuilder(InstrumentationRegistry.getInstrumentation().context) + .withLogs(String(ByteArray(1024*1024, { a }))) + .build() + val expected = StringBuilder(DebugInfoActivity.IntentBuilder.MAX_ELEMENT_SIZE) + expected.append(String(ByteArray(DebugInfoActivity.IntentBuilder.MAX_ELEMENT_SIZE - 3, { a }))) + expected.append("...") + assertEquals(expected.toString(), intent.getStringExtra("logs")) + } + +} \ No newline at end of file diff --git a/app/src/androidTest/java/at/bitfire/davdroid/ui/setup/DavResourceFinderTest.kt b/app/src/androidTest/java/at/bitfire/davdroid/ui/setup/DavResourceFinderTest.kt index 6d65c66b3c20a17aa54a6bc11ea66cb983050c8c..5f0546e928406c8c94fa0b9060fde339a6016e62 100644 --- a/app/src/androidTest/java/at/bitfire/davdroid/ui/setup/DavResourceFinderTest.kt +++ b/app/src/androidTest/java/at/bitfire/davdroid/ui/setup/DavResourceFinderTest.kt @@ -1,35 +1,49 @@ -/* - * Copyright © Ricki Hirner (bitfire web engineering). - * All rights reserved. This program and the accompanying materials - * are made available under the terms of the GNU Public License v3.0 - * which accompanies this distribution, and is available at - * http://www.gnu.org/licenses/gpl.html - */ +/*************************************************************************************************** + * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. + **************************************************************************************************/ package at.bitfire.davdroid.ui.setup -import android.app.Application +import android.security.NetworkSecurityPolicy import androidx.test.filters.SmallTest import androidx.test.platform.app.InstrumentationRegistry import at.bitfire.dav4jvm.DavResource import at.bitfire.dav4jvm.property.AddressbookHomeSet import at.bitfire.dav4jvm.property.ResourceType import at.bitfire.davdroid.HttpClient +import at.bitfire.davdroid.db.Credentials import at.bitfire.davdroid.log.Logger -import at.bitfire.davdroid.model.Credentials +import at.bitfire.davdroid.settings.SettingsManager import at.bitfire.davdroid.ui.setup.DavResourceFinder.Configuration.ServiceInfo +import dagger.hilt.android.testing.HiltAndroidRule +import dagger.hilt.android.testing.HiltAndroidTest import okhttp3.mockwebserver.Dispatcher import okhttp3.mockwebserver.MockResponse import okhttp3.mockwebserver.MockWebServer import okhttp3.mockwebserver.RecordedRequest import org.junit.After import org.junit.Assert.* +import org.junit.Assume import org.junit.Before +import org.junit.Rule import org.junit.Test import java.net.URI +import javax.inject.Inject +@HiltAndroidTest class DavResourceFinderTest { + @get:Rule + val hiltRule = HiltAndroidRule(this) + + @Inject + lateinit var settingsManager: SettingsManager + + @Before + fun inject() { + hiltRule.inject() + } + companion object { private const val PATH_NO_DAV = "/nodav" private const val PATH_CALDAV = "/caldav" @@ -49,10 +63,9 @@ class DavResourceFinderTest { @Before fun initServerAndClient() { - server.setDispatcher(TestDispatcher()) + server.dispatcher = TestDispatcher() server.start() - val application = InstrumentationRegistry.getInstrumentation().targetContext.applicationContext as Application loginModel = LoginModel() loginModel.baseURI = URI.create("/") loginModel.credentials = Credentials("mock", "12345") @@ -61,6 +74,8 @@ class DavResourceFinderTest { client = HttpClient.Builder() .addAuthentication(null, loginModel.credentials!!) .build() + + Assume.assumeTrue(NetworkSecurityPolicy.getInstance().isCleartextTrafficPermitted) } @After @@ -126,21 +141,31 @@ class DavResourceFinderTest { assertNull(finder.getCurrentUserPrincipal(server.url(PATH_CARDDAV), DavResourceFinder.Service.CALDAV)) } + @Test + fun testQueryEmailAddress() { + var info = ServiceInfo() + assertArrayEquals( + arrayOf("email1@example.com", "email2@example.com"), + finder.queryEmailAddress(server.url(PATH_CALDAV + SUBPATH_PRINCIPAL)).toTypedArray() + ) + assertTrue(finder.queryEmailAddress(server.url(PATH_CARDDAV + SUBPATH_PRINCIPAL)).isEmpty()) + } + // mock server class TestDispatcher: Dispatcher() { - override fun dispatch(rq: RecordedRequest): MockResponse { - if (!checkAuth(rq)) { + override fun dispatch(request: RecordedRequest): MockResponse { + if (!checkAuth(request)) { val authenticate = MockResponse().setResponseCode(401) authenticate.setHeader("WWW-Authenticate", "Basic realm=\"test\"") return authenticate } - val path = rq.path + val path = request.path!! - if (rq.method.equals("OPTIONS", true)) { + if (request.method.equals("OPTIONS", true)) { val dav = when { path.startsWith(PATH_CALDAV) -> "calendar-access" path.startsWith(PATH_CARDDAV) -> "addressbook" @@ -151,7 +176,7 @@ class DavResourceFinderTest { if (dav != null) response.addHeader("DAV", dav) return response - } else if (rq.method.equals("PROPFIND", true)) { + } else if (request.method.equals("PROPFIND", true)) { val props: String? when (path) { PATH_CALDAV, @@ -169,14 +194,21 @@ class DavResourceFinderTest { " " + "" + PATH_CALDAV + SUBPATH_PRINCIPAL -> + props = "" + + " urn:unknown-entry" + + " mailto:email1@example.com" + + " mailto:email2@example.com" + + "" + else -> props = null } Logger.log.info("Sending props: $props") return MockResponse() .setResponseCode(207) - .setBody("" + + .setBody("" + "" + - " ${rq.path}" + + " ${request.path}" + " $props" + "" + "") diff --git a/app/src/androidTest/java/at/bitfire/davdroid/ui/webdav/AddWebdavMountActivityTest.kt b/app/src/androidTest/java/at/bitfire/davdroid/ui/webdav/AddWebdavMountActivityTest.kt new file mode 100644 index 0000000000000000000000000000000000000000..b62ff918c7b16a40697e50d4c6fa3948dfdcbe03 --- /dev/null +++ b/app/src/androidTest/java/at/bitfire/davdroid/ui/webdav/AddWebdavMountActivityTest.kt @@ -0,0 +1,76 @@ +/*************************************************************************************************** + * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. + **************************************************************************************************/ + +package at.bitfire.davdroid.ui.webdav + +import android.security.NetworkSecurityPolicy +import androidx.test.platform.app.InstrumentationRegistry +import at.bitfire.davdroid.db.AppDatabase +import at.bitfire.davdroid.db.WebDavMount +import dagger.hilt.android.testing.HiltAndroidRule +import dagger.hilt.android.testing.HiltAndroidTest +import io.mockk.spyk +import okhttp3.mockwebserver.MockResponse +import okhttp3.mockwebserver.MockWebServer +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Assume +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import javax.inject.Inject + +@HiltAndroidTest +class AddWebdavMountActivityTest { + + @get:Rule + val hiltRule = HiltAndroidRule(this) + + @Inject + lateinit var db: AppDatabase + + @Before + fun setUp() { + hiltRule.inject() + + model = spyk(AddWebdavMountActivity.Model(InstrumentationRegistry.getInstrumentation().targetContext, db)) + + Assume.assumeTrue(NetworkSecurityPolicy.getInstance().isCleartextTrafficPermitted) + } + + + lateinit var model: AddWebdavMountActivity.Model + val web = MockWebServer() + + @Test + fun testHasWebDav_NoDavHeader() { + web.enqueue(MockResponse().setResponseCode(200)) + assertFalse(model.hasWebDav(WebDavMount(name = "Test", url = web.url("/")), null)) + } + + @Test + fun testHasWebDav_DavClass_1() { + web.enqueue(MockResponse() + .setResponseCode(200) + .addHeader("DAV", "1")) + assertTrue(model.hasWebDav(WebDavMount(name = "Test", url = web.url("/")), null)) + } + + @Test + fun testHasWebDav_DavClass_1and2() { + web.enqueue(MockResponse() + .setResponseCode(200) + .addHeader("DAV", "1,2")) + assertTrue(model.hasWebDav(WebDavMount(name = "Test", url = web.url("/")), null)) + } + + @Test + fun testHasWebDav_DavClass_2() { + web.enqueue(MockResponse() + .setResponseCode(200) + .addHeader("DAV", "2")) + assertTrue(model.hasWebDav(WebDavMount(name = "Test", url = web.url("/")), null)) + } + +} \ No newline at end of file diff --git a/app/src/androidTest/java/at/bitfire/davdroid/webdav/CredentialsStoreTest.kt b/app/src/androidTest/java/at/bitfire/davdroid/webdav/CredentialsStoreTest.kt new file mode 100644 index 0000000000000000000000000000000000000000..bc5cc28c9f0f88bc023b940a28c1bf3a43b8c1c7 --- /dev/null +++ b/app/src/androidTest/java/at/bitfire/davdroid/webdav/CredentialsStoreTest.kt @@ -0,0 +1,26 @@ +/*************************************************************************************************** + * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. + **************************************************************************************************/ + +package at.bitfire.davdroid.webdav + +import androidx.test.platform.app.InstrumentationRegistry +import at.bitfire.davdroid.db.Credentials +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test + +class CredentialsStoreTest { + + private val store = CredentialsStore(InstrumentationRegistry.getInstrumentation().targetContext) + + @Test + fun testSetGetDelete() { + store.setCredentials(0, Credentials(userName = "myname", password = "12345")) + assertEquals(Credentials(userName = "myname", password = "12345"), store.getCredentials(0)) + + store.setCredentials(0, null) + assertNull(store.getCredentials(0)) + } + +} \ No newline at end of file diff --git a/app/src/androidTest/java/at/bitfire/davdroid/webdav/MemoryCacheTest.kt b/app/src/androidTest/java/at/bitfire/davdroid/webdav/MemoryCacheTest.kt new file mode 100644 index 0000000000000000000000000000000000000000..882ba06dbee670c5176cd7b373871652714ba6db --- /dev/null +++ b/app/src/androidTest/java/at/bitfire/davdroid/webdav/MemoryCacheTest.kt @@ -0,0 +1,78 @@ +/*************************************************************************************************** + * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. + **************************************************************************************************/ + +package at.bitfire.davdroid.webdav + +import at.bitfire.davdroid.webdav.cache.MemoryCache +import org.apache.commons.io.FileUtils +import org.junit.Assert.* +import org.junit.Before +import org.junit.Test + +class MemoryCacheTest { + + companion object { + val SAMPLE_KEY1 = "key1" + val SAMPLE_CONTENT1 = "Sample Content 1".toByteArray() + val SAMPLE_CONTENT2 = "Another Content".toByteArray() + } + + lateinit var storage: MemoryCache + + + @Before + fun createStorage() { + storage = MemoryCache(1*FileUtils.ONE_MB.toInt()) + } + + + @Test + fun testGet() { + // no entry yet, get should return null + assertNull(storage.get(SAMPLE_KEY1)) + + // add entry + storage.getOrPut(SAMPLE_KEY1) { SAMPLE_CONTENT1 } + assertArrayEquals(SAMPLE_CONTENT1, storage.get(SAMPLE_KEY1)) + } + + @Test + fun testGetOrPut() { + assertNull(storage.get(SAMPLE_KEY1)) + // no entry yet; SAMPLE_CONTENT1 should be generated + var calledGenerateSampleContent1 = false + assertArrayEquals(SAMPLE_CONTENT1, storage.getOrPut(SAMPLE_KEY1) { + calledGenerateSampleContent1 = true + SAMPLE_CONTENT1 + }) + assertTrue(calledGenerateSampleContent1) + assertNotNull(storage.get(SAMPLE_KEY1)) + + // now there's a SAMPLE_CONTENT1 entry, it should be returned while SAMPLE_CONTENT2 is not generated + var calledGenerateSampleContent2 = false + assertArrayEquals(SAMPLE_CONTENT1, storage.getOrPut(SAMPLE_KEY1) { + calledGenerateSampleContent2 = true + SAMPLE_CONTENT2 + }) + assertFalse(calledGenerateSampleContent2) + } + + @Test + fun testMaxCacheSize() { + // Cache size is 1 MB. Add 11*100 kB -> the first entry should be gone then + for (i in 0 until 11) { + val key = "key$i" + storage.getOrPut(key) { + ByteArray(100 * FileUtils.ONE_KB.toInt()) { i.toByte() } + } + assertNotNull(storage.get(key)) + } + + // now key0 should have been evicted and only key1..key11 should be there + assertNull(storage.get("key0")) + for (i in 1 until 11) + assertNotNull(storage.get("key$i")) + } + +} \ No newline at end of file diff --git a/app/src/androidTest/java/at/bitfire/davdroid/webdav/SegmentedCacheTest.kt b/app/src/androidTest/java/at/bitfire/davdroid/webdav/SegmentedCacheTest.kt new file mode 100644 index 0000000000000000000000000000000000000000..07ef30b7fc1a7504dc51a0dad177af4412514940 --- /dev/null +++ b/app/src/androidTest/java/at/bitfire/davdroid/webdav/SegmentedCacheTest.kt @@ -0,0 +1,159 @@ +/*************************************************************************************************** + * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. + **************************************************************************************************/ + +package at.bitfire.davdroid.webdav + +import at.bitfire.davdroid.webdav.cache.Cache +import at.bitfire.davdroid.webdav.cache.SegmentedCache +import org.apache.commons.io.FileUtils +import org.junit.Assert.assertArrayEquals +import org.junit.Assert.assertEquals +import org.junit.Test + +class SegmentedCacheTest { + + companion object { + const val PAGE_SIZE = 100*FileUtils.ONE_KB.toInt() + + const val SAMPLE_KEY1 = "key1" + const val PAGE2_SIZE = 123 + } + + val noCache = object: Cache> { + override fun get(key: SegmentedCache.SegmentKey) = null + override fun getOrPut(key: SegmentedCache.SegmentKey, generate: () -> ByteArray) = generate() + } + + @Test + fun testRead_AcrossPages() { + val cache = SegmentedCache(PAGE_SIZE, object: SegmentedCache.PageLoader { + override fun load(key: SegmentedCache.SegmentKey, segmentSize: Int) = + when (key.segment) { + 0 -> ByteArray(PAGE_SIZE) { 1 } + 1 -> ByteArray(PAGE2_SIZE) { 2 } + else -> throw IndexOutOfBoundsException() + } + }, noCache) + val dst = ByteArray(20) + assertEquals(20, cache.read(SAMPLE_KEY1, (PAGE_SIZE - 10).toLong(), dst.size, dst)) + assertArrayEquals(ByteArray(20) { i -> + if (i < 10) + 1 + else + 2 + }, dst) + } + + @Test + fun testRead_AcrossPagesAndEOF() { + val cache = SegmentedCache(PAGE_SIZE, object: SegmentedCache.PageLoader { + override fun load(key: SegmentedCache.SegmentKey, segmentSize: Int) = + when (key.segment) { + 0 -> ByteArray(PAGE_SIZE) { 1 } + 1 -> ByteArray(PAGE2_SIZE) { 2 } + else -> throw IndexOutOfBoundsException() + } + }, noCache) + val dst = ByteArray(10 + PAGE2_SIZE + 10) + assertEquals(10 + PAGE2_SIZE, cache.read(SAMPLE_KEY1, (PAGE_SIZE - 10).toLong(), dst.size, dst)) + assertArrayEquals(ByteArray(10 + PAGE2_SIZE) { i -> + if (i < 10) + 1 + else + 2 + }, dst.copyOf(10 + PAGE2_SIZE)) + } + + @Test + fun testRead_ExactlyPageSize_BufferAlsoPageSize() { + var loadCalled = 0 + val cache = SegmentedCache(PAGE_SIZE, object: SegmentedCache.PageLoader { + override fun load(key: SegmentedCache.SegmentKey, segmentSize: Int): ByteArray { + loadCalled++ + if (key.segment == 0) + return ByteArray(PAGE_SIZE) + else + throw IndexOutOfBoundsException() + } + }, noCache) + val dst = ByteArray(PAGE_SIZE) + assertEquals(PAGE_SIZE, cache.read(SAMPLE_KEY1, 0, dst.size, dst)) + assertEquals(1, loadCalled) + } + + @Test + fun testRead_ExactlyPageSize_ButLargerBuffer() { + var loadCalled = 0 + val cache = SegmentedCache(PAGE_SIZE, object: SegmentedCache.PageLoader { + override fun load(key: SegmentedCache.SegmentKey, segmentSize: Int): ByteArray { + loadCalled++ + if (key.segment == 0) + return ByteArray(PAGE_SIZE) + else + throw IndexOutOfBoundsException() + } + }, noCache) + val dst = ByteArray(PAGE_SIZE + 10) // 10 bytes more so that the second segment is read + assertEquals(PAGE_SIZE, cache.read(SAMPLE_KEY1, 0, dst.size, dst)) + assertEquals(2, loadCalled) + } + + @Test + fun testRead_Offset() { + val cache = SegmentedCache(PAGE_SIZE, object: SegmentedCache.PageLoader { + override fun load(key: SegmentedCache.SegmentKey, segmentSize: Int): ByteArray { + if (key.segment == 0) + return ByteArray(PAGE_SIZE) { 1 } + else + throw IndexOutOfBoundsException() + } + }, noCache) + val dst = ByteArray(PAGE_SIZE) + assertEquals(PAGE_SIZE - 100, cache.read(SAMPLE_KEY1, 100, dst.size, dst)) + assertArrayEquals(ByteArray(PAGE_SIZE) { i -> + if (i < PAGE_SIZE - 100) + 1 + else + 0 + }, dst) + } + + @Test + fun testRead_OnlyOnePageSmallerThanPageSize_From0() { + val contentSize = 123 + val cache = SegmentedCache(PAGE_SIZE, object: SegmentedCache.PageLoader { + override fun load(key: SegmentedCache.SegmentKey, segmentSize: Int) = + when (key.segment) { + 0 -> ByteArray(contentSize) { it.toByte() } + else -> throw IndexOutOfBoundsException() + } + }, noCache) + + // read less than content size + var dst = ByteArray(10) // 10 < contentSize + assertEquals(10, cache.read(SAMPLE_KEY1, 0, dst.size, dst)) + assertArrayEquals(ByteArray(10) { it.toByte() }, dst) + + // read more than content size + dst = ByteArray(1000) // 1000 > contentSize + assertEquals(contentSize, cache.read(SAMPLE_KEY1, 0, dst.size, dst)) + assertArrayEquals(ByteArray(1000) { i -> + if (i < contentSize) + i.toByte() + else + 0 + }, dst) + } + + @Test + fun testRead_ZeroByteFile() { + val cache = SegmentedCache(PAGE_SIZE, object: SegmentedCache.PageLoader { + override fun load(key: SegmentedCache.SegmentKey, segmentSize: Int) = + throw IndexOutOfBoundsException() + }, noCache) + val dst = ByteArray(10) + assertEquals(0, cache.read(SAMPLE_KEY1, 10, dst.size, dst)) + } + +} \ No newline at end of file diff --git a/app/src/androidTest/res/drawable-hdpi/ic_launcher.png b/app/src/androidTest/res/drawable-hdpi/ic_launcher.png deleted file mode 100644 index 96a442e5b8e9394ccf50bab9988cb2316026245d..0000000000000000000000000000000000000000 Binary files a/app/src/androidTest/res/drawable-hdpi/ic_launcher.png and /dev/null differ diff --git a/app/src/androidTest/res/drawable-ldpi/ic_launcher.png b/app/src/androidTest/res/drawable-ldpi/ic_launcher.png deleted file mode 100644 index 99238729d8753585237a65b91c7cde426c90baef..0000000000000000000000000000000000000000 Binary files a/app/src/androidTest/res/drawable-ldpi/ic_launcher.png and /dev/null differ diff --git a/app/src/androidTest/res/drawable-mdpi/ic_launcher.png b/app/src/androidTest/res/drawable-mdpi/ic_launcher.png deleted file mode 100644 index 359047dfa4ed206e41e2354f9c6b307e713efe32..0000000000000000000000000000000000000000 Binary files a/app/src/androidTest/res/drawable-mdpi/ic_launcher.png and /dev/null differ diff --git a/app/src/androidTest/res/drawable-xhdpi/ic_launcher.png b/app/src/androidTest/res/drawable-xhdpi/ic_launcher.png deleted file mode 100644 index 71c6d760f05183ef8a47c614d8d13380c8528499..0000000000000000000000000000000000000000 Binary files a/app/src/androidTest/res/drawable-xhdpi/ic_launcher.png and /dev/null differ diff --git a/app/src/androidTest/res/values/strings.xml b/app/src/androidTest/res/values/strings.xml index 3be7ececa6b438477ea8ae5dad78c5ec354bae7d..8a99b48d898ec9411cfc2540068cd6e6c365065c 100644 --- a/app/src/androidTest/res/values/strings.xml +++ b/app/src/androidTest/res/values/strings.xml @@ -1,6 +1,6 @@ + + @@ -29,7 +31,8 @@ + - fine location (Android 10) --> + @@ -43,17 +46,33 @@ android:icon="@mipmap/ic_launcher" android:label="@string/app_name" android:theme="@style/AppTheme" - tools:ignore="UnusedAttribute"> + android:resizeableActivity="true" + tools:ignore="UnusedAttribute" + android:supportsRtl="true"> + + + + + + + + + + android:theme="@style/AppTheme.NoActionBar" + android:exported="true"> - + @@ -70,46 +89,78 @@ android:exported="true"> + + + + + + + + + + android:parentActivityName=".ui.AccountsActivity" + android:excludeFromRecents="true" + android:exported="true"> + + + + + + + + + + - - - + + + + - - - - - - - + android:name=".ui.webdav.WebdavMountsActivity" + android:label="@string/webdav_mounts_title" + android:parentActivityName=".ui.AccountsActivity" + android:exported="true" /> + - @@ -130,28 +180,58 @@ - + + + + + + + + + + + + - + android:resource="@xml/sync_tasks_org"/> + + + + + + android:name=".syncadapter.AddressBookAuthenticatorService" + android:exported="true"> @@ -164,8 +244,7 @@ android:authorities="@string/address_books_authority" android:exported="false" android:label="@string/address_books_authority_title" - android:name=".syncadapter.AddressBookProvider" - android:multiprocess="false"/> + android:name=".syncadapter.AddressBookProvider" /> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/assets/known-base-urls.txt b/app/src/main/assets/known-base-urls.txt new file mode 100644 index 0000000000000000000000000000000000000000..c25e951c3ad96ee301f9308986fbb506ca6601d6 --- /dev/null +++ b/app/src/main/assets/known-base-urls.txt @@ -0,0 +1,22 @@ +carddav.a1.net +aol.com +dav.edis.at +dav.fruux.com +caldav.gmx.net +carddav.gmx.net +icloud.com +cloud.liberta.vip +office.luckycloud.de +calendar.mail.ru +mailbox.org +mailfence.com +posteo.de:8443 +live.teambox.eu +spica.t-online.de +caldav.calendar.yahoo.com +yandex.ru +webmail.your-server.de/rpc.php/ +calendar.zoho.com +calendar.zoho.eu +contacts.zoho.com +contacts.zoho.eu diff --git a/app/src/main/assets/translators.json b/app/src/main/assets/translators.json new file mode 100644 index 0000000000000000000000000000000000000000..90603ca2feba284ef8df2317f82353703a05296f --- /dev/null +++ b/app/src/main/assets/translators.json @@ -0,0 +1 @@ +{"ar_SA":["abdunnasir"],"bg":["dpa_transifex"],"ca":["Kintu","jordibrus","zagur"],"cs":["pavelb","tomas.odehnal"],"da":["Tntdruid_","knutztar","mjjzf","twikedk"],"de":["Atalanttore","TheName","Wyrrrd","YvanM","amandablue","anestiskaci","corppneq","crit12","hammaschlach","maxkl","nicolas_git","owncube"],"el":["KristinaQejvanaj","anestiskaci","diamond_gr"],"es":["Ark74","Elhea","GranPC","aluaces","jcvielma","plaguna","polkhas","xphnx"],"eu":["Osoitz","Thadah","cockeredradiation"],"fa":["Numb","ahangarha","amiraliakbari","joojoojoo","maryambehzi","mtashackori","taranehsaei"],"fi_FI":["raketti"],"fr":["AlainR","Amadeen","Floflr","Llorc","LoiX07","Novick","Poussinou","Thecross","YvanM","alkino2","boutil","callmemagnus","chfo","chrcha","grenatrad","jokx","mathieugfortin","vincen","ÉricB."],"fr_FR":["Llorc","Poussinou","chrcha"],"gl":["aluaces","pikamoku"],"hu":["Roshek","jtg"],"it":["Damtux","FranzMari","ed0","malaerba","noccio","nwandy","rickyroo","technezio"],"it_IT":["malaerba"],"ja":["Naofumi","yanorei32"],"nb_NO":["elonus"],"nl":["XtremeNova","davtemp","dehart","erikhubers","frankyboy1963","glotzbach","toonvangerwen"],"pl":["TORminator","TheName","Valdnet","gsz","mg6","oskarjakiela"],"pt":["amalvarenga","wanderlei.huttel"],"pt_BR":["wanderlei.huttel"],"ru":["aigoshin","anm","astalavister","nick.savin","vaddd"],"sk_SK":["brango67","tiborepcek"],"sl_SI":["MrLaaky","uroszor"],"sr":["daimonion"],"sv":["campbelldavid"],"szl":["chlodny"],"tr_TR":["ooguz","pultars"],"uk":["androsua","olexn","twixi007"],"uk_UA":["astalavister"],"zh_CN":["anolir","jxj2zzz79pfp9bpo","linuxbckp","mofitt2016","oksjd","phy","spice2wolf"],"zh_TW":["linuxbckp","mofitt2016","phy","waiabsfabuloushk"]} 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/Android10Resolver.kt b/app/src/main/java/at/bitfire/davdroid/Android10Resolver.kt new file mode 100644 index 0000000000000000000000000000000000000000..614484231251f5e389d18358816186c38fb159d0 --- /dev/null +++ b/app/src/main/java/at/bitfire/davdroid/Android10Resolver.kt @@ -0,0 +1,82 @@ +/*************************************************************************************************** + * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. + **************************************************************************************************/ + +package at.bitfire.davdroid + +import android.net.DnsResolver +import android.os.Build +import androidx.annotation.RequiresApi +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.asExecutor +import kotlinx.coroutines.runBlocking +import org.xbill.DNS.Message +import org.xbill.DNS.Resolver +import org.xbill.DNS.ResolverListener +import org.xbill.DNS.TSIG + +/** + * dnsjava Resolver that uses Android's [DnsResolver] API, which is available since Android 10. + */ +@RequiresApi(Build.VERSION_CODES.Q) +object Android10Resolver: Resolver { + + private val executor = Dispatchers.IO.asExecutor() + private val resolver = DnsResolver.getInstance() + + + override fun send(query: Message): Message = runBlocking { + val future = CompletableDeferred() + + resolver.rawQuery(null, query.toWire(), DnsResolver.FLAG_EMPTY, executor, null, object: DnsResolver.Callback { + override fun onAnswer(rawAnswer: ByteArray, rcode: Int) { + future.complete(Message((rawAnswer))) + } + + override fun onError(error: DnsResolver.DnsException) { + future.completeExceptionally(error) + } + }) + + future.await() + } + + override fun sendAsync(query: Message, listener: ResolverListener) = + // currently not used by dnsjava, so no need to implement it + throw NotImplementedError() + + + override fun setPort(port: Int) { + // not applicable + } + + override fun setTCP(flag: Boolean) { + // not applicable + } + + override fun setIgnoreTruncation(flag: Boolean) { + // not applicable + } + + override fun setEDNS(level: Int) { + // not applicable + } + + override fun setEDNS(level: Int, payloadSize: Int, flags: Int, options: MutableList?) { + // not applicable + } + + override fun setTSIGKey(key: TSIG?) { + // not applicable + } + + override fun setTimeout(secs: Int, msecs: Int) { + // not applicable + } + + override fun setTimeout(secs: Int) { + // not applicable + } + +} \ No newline at end of file diff --git a/app/src/main/java/at/bitfire/davdroid/App.kt b/app/src/main/java/at/bitfire/davdroid/App.kt index 65d9c9ac394fcecc60d2277f9ad6023fd17c7d3b..c6aa1d245e1ad90f5af1b33bc968118986f2bb49 100644 --- a/app/src/main/java/at/bitfire/davdroid/App.kt +++ b/app/src/main/java/at/bitfire/davdroid/App.kt @@ -1,43 +1,35 @@ -/* - * Copyright © Ricki Hirner (bitfire web engineering). - * All rights reserved. This program and the accompanying materials - * are made available under the terms of the GNU Public License v3.0 - * which accompanies this distribution, and is available at - * http://www.gnu.org/licenses/gpl.html - */ +/*************************************************************************************************** + * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. + **************************************************************************************************/ package at.bitfire.davdroid +import android.app.Application import android.content.Context -import android.content.Intent -import android.content.IntentFilter -import android.graphics.Bitmap -import android.graphics.drawable.BitmapDrawable import android.net.Uri -import android.os.Build import android.os.StrictMode -import androidx.appcompat.app.AppCompatDelegate import androidx.appcompat.content.res.AppCompatResources -import androidx.multidex.MultiDexApplication +import androidx.core.graphics.drawable.toBitmap import at.bitfire.davdroid.log.Logger +import at.bitfire.davdroid.settings.AccountSettings +import at.bitfire.davdroid.syncadapter.AccountsUpdatedListener +import at.bitfire.davdroid.syncadapter.SyncUtils import at.bitfire.davdroid.ui.DebugInfoActivity import at.bitfire.davdroid.ui.NotificationUtils +import at.bitfire.davdroid.ui.UiUtils +import dagger.hilt.android.HiltAndroidApp import java.util.logging.Level +import javax.inject.Inject import kotlin.concurrent.thread import kotlin.system.exitProcess -@Suppress("unused") -class App: MultiDexApplication(), Thread.UncaughtExceptionHandler { +@HiltAndroidApp +class App: Application(), Thread.UncaughtExceptionHandler { companion object { - fun getLauncherBitmap(context: Context): Bitmap? { - val drawableLogo = AppCompatResources.getDrawable(context, R.mipmap.ic_launcher) - return if (drawableLogo is BitmapDrawable) - drawableLogo.bitmap - else - null - } + fun getLauncherBitmap(context: Context) = + AppCompatResources.getDrawable(context, R.mipmap.ic_launcher)?.toBitmap() fun homepageUrl(context: Context) = Uri.parse(context.getString(R.string.homepage_url)).buildUpon() @@ -48,6 +40,9 @@ class App: MultiDexApplication(), Thread.UncaughtExceptionHandler { } + @Inject lateinit var accountsUpdatedListener: AccountsUpdatedListener + @Inject lateinit var storageLowReceiver: StorageLowReceiver + override fun onCreate() { super.onCreate() @@ -67,31 +62,43 @@ class App: MultiDexApplication(), Thread.UncaughtExceptionHandler { // handle uncaught exceptions in non-debug standard flavor Thread.setDefaultUncaughtExceptionHandler(this) - if (Build.VERSION.SDK_INT <= 21) - AppCompatDelegate.setCompatVectorFromResourcesEnabled(true) - NotificationUtils.createChannels(this) + // set light/dark mode + UiUtils.setTheme(this) // when this is called in the asynchronous thread below, it recreates + // some current activity and causes an IllegalStateException in rare cases + // don't block UI for some background checks thread { - // watch installed/removed apps - val tasksFilter = IntentFilter() - tasksFilter.addAction(Intent.ACTION_PACKAGE_ADDED) - tasksFilter.addAction(Intent.ACTION_PACKAGE_FULLY_REMOVED) - tasksFilter.addDataScheme("package") - registerReceiver(PackageChangedReceiver(), tasksFilter) + // watch for account changes/deletions + accountsUpdatedListener.listen() + // foreground service (possible workaround for devices which prevent DAVx5 from being started) + ForegroundService.startIfActive(this) + + // watch storage because low storage means synchronization is stopped + storageLowReceiver.listen() + + // watch installed/removed apps + TasksWatcher.watch(this) // check whether a tasks app is currently installed - PackageChangedReceiver.updateTaskSync(this) + SyncUtils.updateTaskSync(this) + + // create/update app shortcuts + UiUtils.updateShortcuts(this) + + // check/repair sync intervals + AccountSettings.repairSyncIntervals(this) } } override fun uncaughtException(t: Thread, e: Throwable) { Logger.log.log(Level.SEVERE, "Unhandled exception!", e) - val intent = Intent(this, DebugInfoActivity::class.java) - intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) - intent.putExtra(DebugInfoActivity.KEY_THROWABLE, e) + val intent = DebugInfoActivity.IntentBuilder(this) + .withCause(e) + .newTask() + .build() startActivity(intent) exitProcess(1) diff --git a/app/src/main/java/at/bitfire/davdroid/BootCompletedReceiver.kt b/app/src/main/java/at/bitfire/davdroid/BootCompletedReceiver.kt new file mode 100644 index 0000000000000000000000000000000000000000..e91a057ea391fef3858cb3b30656616f9e45b499 --- /dev/null +++ b/app/src/main/java/at/bitfire/davdroid/BootCompletedReceiver.kt @@ -0,0 +1,26 @@ +/*************************************************************************************************** + * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. + **************************************************************************************************/ + +package at.bitfire.davdroid + +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import at.bitfire.davdroid.log.Logger + +/** + * There are circumstances when Android drops automatic sync of accounts and resets them + * to manual synchronization, for instance when the device is booted into safe mode. + * + * This receiver causes the app to be started when the device is rebooted. When the app + * is started, it checks (and repairs, if necessary) the sync intervals in [App.onCreate]. + */ +class BootCompletedReceiver: BroadcastReceiver() { + + override fun onReceive(context: Context, intent: Intent) { + Logger.log.info("Device has been rebooted; checking sync intervals etc.") + // sync intervals are checked in App.onCreate() + } + +} \ No newline at end of file diff --git a/app/src/main/java/at/bitfire/davdroid/CompatUtils.kt b/app/src/main/java/at/bitfire/davdroid/CompatUtils.kt index 45d2c1f609ba7ba13485d3083cdec5fcd777b67b..d705c8974b889f8cebf67c1717385db9de127e81 100644 --- a/app/src/main/java/at/bitfire/davdroid/CompatUtils.kt +++ b/app/src/main/java/at/bitfire/davdroid/CompatUtils.kt @@ -1,3 +1,7 @@ +/*************************************************************************************************** + * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. + **************************************************************************************************/ + package at.bitfire.davdroid import android.content.ContentProviderClient diff --git a/app/src/main/java/at/bitfire/davdroid/ConcurrentUtils.kt b/app/src/main/java/at/bitfire/davdroid/ConcurrentUtils.kt new file mode 100644 index 0000000000000000000000000000000000000000..57d41ff71c7cc5e6dfa2d8bd78c10525a2a9aa20 --- /dev/null +++ b/app/src/main/java/at/bitfire/davdroid/ConcurrentUtils.kt @@ -0,0 +1,39 @@ +/*************************************************************************************************** + * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. + **************************************************************************************************/ + +package at.bitfire.davdroid + +import java.util.* + +object ConcurrentUtils { + + private val running = Collections.synchronizedSet(HashSet()) + + + /** + * Guards a code block by a key – the block will only run when there is currently no + * other running code block with the same key (compared by [Object.equals]). + * + * @param key guarding key to determine whether the code block will be run + * @param block this code block will be run, but not more than one time at once per key + * + * @return *true* if the code block was executed (i.e. there was no running code block with this key); + * *false* if there was already another running block with that key, so that the code block wasn't executed + */ + fun runSingle(key: Any, block: () -> Unit): Boolean { + if (!running.add(key)) // already running? + return false // this key is already in use, refuse execution + // key is now in running + + try { + block() + return true + + } finally { + running.remove(key) + // key is now not in running anymore; further calls will succeed + } + } + +} \ No newline at end of file diff --git a/app/src/main/java/at/bitfire/davdroid/Constants.kt b/app/src/main/java/at/bitfire/davdroid/Constants.kt index 3bcf9d347a49d4a194e9f62b05794e347a844819..ef5d0960dd99d5f447e64a5efa74c4ee180822f8 100644 --- a/app/src/main/java/at/bitfire/davdroid/Constants.kt +++ b/app/src/main/java/at/bitfire/davdroid/Constants.kt @@ -1,17 +1,18 @@ -/* - * Copyright © Ricki Hirner (bitfire web engineering). - * All rights reserved. This program and the accompanying materials - * are made available under the terms of the GNU Public License v3.0 - * which accompanies this distribution, and is available at - * http://www.gnu.org/licenses/gpl.html - */ +/*************************************************************************************************** + * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. + **************************************************************************************************/ package at.bitfire.davdroid object Constants { const val DAVDROID_GREEN_RGBA = 0xFF8bc34a.toInt() - const val DEFAULT_SYNC_INTERVAL = 4 * 3600L // 4 hours + // 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]. @@ -27,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 724ccab5a644db670e41efa869ba0faca2e9e65e..50dac87f333eb664a54a4198851e1892b5073f86 100644 --- a/app/src/main/java/at/bitfire/davdroid/DavService.kt +++ b/app/src/main/java/at/bitfire/davdroid/DavService.kt @@ -1,43 +1,49 @@ -/* - * Copyright © Ricki Hirner (bitfire web engineering). - * All rights reserved. This program and the accompanying materials - * are made available under the terms of the GNU Public License v3.0 - * which accompanies this distribution, and is available at - * http://www.gnu.org/licenses/gpl.html - */ +/*************************************************************************************************** + * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. + **************************************************************************************************/ package at.bitfire.davdroid import android.accounts.Account +import android.app.IntentService import android.app.PendingIntent import android.content.ContentResolver +import android.content.Context import android.content.Intent import android.os.Binder import android.os.Bundle +import androidx.annotation.WorkerThread import androidx.core.app.NotificationCompat import androidx.core.app.NotificationManagerCompat -import androidx.room.Transaction import at.bitfire.dav4jvm.DavResource import at.bitfire.dav4jvm.Response import at.bitfire.dav4jvm.UrlUtils import at.bitfire.dav4jvm.exception.HttpException import at.bitfire.dav4jvm.property.* +import at.bitfire.davdroid.db.* +import at.bitfire.davdroid.db.Collection import at.bitfire.davdroid.log.Logger -import at.bitfire.davdroid.model.* -import at.bitfire.davdroid.model.Collection import at.bitfire.davdroid.settings.AccountSettings +import at.bitfire.davdroid.settings.Settings +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 import java.util.* import java.util.logging.Level -import kotlin.concurrent.thread +import javax.inject.Inject +import kotlin.collections.* -class DavService: android.app.Service() { +@Suppress("DEPRECATION") +@AndroidEntryPoint +class DavService: IntentService("DavService") { companion object { + const val ACTION_REFRESH_COLLECTIONS = "refreshCollections" const val EXTRA_DAV_SERVICE_ID = "davServiceID" @@ -51,43 +57,63 @@ class DavService: android.app.Service() { ResourceType.NAME, CurrentUserPrivilegeSet.NAME, DisplayName.NAME, + Owner.NAME, AddressbookDescription.NAME, SupportedAddressData.NAME, CalendarDescription.NAME, CalendarColor.NAME, SupportedCalendarComponentSet.NAME, Source.NAME ) + fun refreshCollections(context: Context, serviceId: Long) { + val intent = Intent(context, DavService::class.java) + intent.action = DavService.ACTION_REFRESH_COLLECTIONS + intent.putExtra(DavService.EXTRA_DAV_SERVICE_ID, serviceId) + context.startService(intent) + } + } - private val runningRefresh = HashSet() - private val refreshingStatusListeners = LinkedList>() + @Inject lateinit var db: AppDatabase + @Inject lateinit var settings: SettingsManager + /** + * List of [Service] IDs for which the collections are currently refreshed + */ + private val runningRefresh = Collections.synchronizedSet(HashSet()) - override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { - intent?.let { - val id = intent.getLongExtra(EXTRA_DAV_SERVICE_ID, -1) + /** + * Currently registered [RefreshingStatusListener]s, which will be notified + * when a collection refresh status changes + */ + private val refreshingStatusListeners = Collections.synchronizedList(LinkedList>()) - when (intent.action) { - ACTION_REFRESH_COLLECTIONS -> - if (runningRefresh.add(id)) { - refreshingStatusListeners.forEach { listener -> - listener.get()?.onDavRefreshStatusChanged(id, true) - } - thread { refreshCollections(id) } + @WorkerThread + override fun onHandleIntent(intent: Intent?) { + if (intent == null) + return + + val id = intent.getLongExtra(EXTRA_DAV_SERVICE_ID, -1) + + when (intent.action) { + ACTION_REFRESH_COLLECTIONS -> + if (runningRefresh.add(id)) { + refreshingStatusListeners.forEach { listener -> + listener.get()?.onDavRefreshStatusChanged(id, true) } - ACTION_FORCE_SYNC -> { - val uri = intent.data!! - val authority = uri.authority!! - val account = Account( - uri.pathSegments[1], - uri.pathSegments[0] - ) - forceSync(authority, account) + refreshCollections(id) } + + ACTION_FORCE_SYNC -> { + val uri = intent.data!! + val authority = uri.authority!! + val account = Account( + uri.pathSegments[1], + uri.pathSegments[0] + ) + forceSync(authority, account) } - } - return START_NOT_STICKY + } } @@ -107,14 +133,17 @@ class DavService: android.app.Service() { fun addRefreshingStatusListener(listener: RefreshingStatusListener, callImmediateIfRunning: Boolean) { refreshingStatusListeners += WeakReference(listener) if (callImmediateIfRunning) - runningRefresh.forEach { id -> listener.onDavRefreshStatusChanged(id, true) } + synchronized(runningRefresh) { + for (id in runningRefresh) + listener.onDavRefreshStatusChanged(id, true) + } } fun removeRefreshingStatusListener(listener: RefreshingStatusListener) { val iter = refreshingStatusListeners.iterator() while (iter.hasNext()) { val item = iter.next().get() - if (listener == item) + if (item == listener || item == null) iter.remove() } } @@ -137,12 +166,13 @@ class DavService: android.app.Service() { } private fun refreshCollections(serviceId: Long) { - val db = AppDatabase.getInstance(this) + val syncAllCollections = settings.getBoolean(Settings.SYNC_ALL_COLLECTIONS) + val homeSetDao = db.homeSetDao() 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() @@ -150,11 +180,18 @@ class DavService: android.app.Service() { /** * Checks if the given URL defines home sets and adds them to the home set list. * + * @param personal Whether this is the "outer" call of the recursion. + * + * *true* = found home sets belong to the current-user-principal; recurse if + * calendar proxies or group memberships are found + * + * *false* = found home sets don't directly belong to the current-user-principal; don't recurse + * * @throws java.io.IOException * @throws HttpException * @throws at.bitfire.dav4jvm.exception.DavException */ - fun queryHomeSets(client: OkHttpClient, url: HttpUrl, recurse: Boolean = true) { + fun queryHomeSets(client: OkHttpClient, url: HttpUrl, accessToken: String?, personal: Boolean = true) { val related = mutableSetOf() fun findRelated(root: HttpUrl, dav: Response) { @@ -187,7 +224,7 @@ class DavService: android.app.Service() { } } - val dav = DavResource(client, url) + val dav = DavResource(client, url, accessToken) when (service.type) { Service.TYPE_CARDDAV -> try { @@ -196,11 +233,11 @@ class DavService: android.app.Service() { for (href in homeSet.hrefs) dav.location.resolve(href)?.let { val foundUrl = UrlUtils.withTrailingSlash(it) - homeSets[foundUrl] = HomeSet(0, service.id, foundUrl) + homeSets[foundUrl] = HomeSet(0, service.id, personal, foundUrl) } } - if (recurse) + if (personal) findRelated(dav.location, response) } } catch (e: HttpException) { @@ -216,11 +253,11 @@ class DavService: android.app.Service() { for (href in homeSet.hrefs) dav.location.resolve(href)?.let { val foundUrl = UrlUtils.withTrailingSlash(it) - homeSets[foundUrl] = HomeSet(0, service.id, foundUrl) + homeSets[foundUrl] = HomeSet(0, service.id, personal, foundUrl) } } - if (recurse) + if (personal) findRelated(dav.location, response) } } catch (e: HttpException) { @@ -232,132 +269,207 @@ class DavService: android.app.Service() { } } + // 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) + } - @Transaction fun saveHomesets() { + // syncAll sets the ID of the new homeset to the ID of the old one when the URLs are matching DaoTools(homeSetDao).syncAll( homeSetDao.getByService(serviceId), homeSets, { it.url }) } - @Transaction fun saveCollections() { + // syncAll sets the ID of the new collection to the ID of the old one when the URLs are matching DaoTools(collectionDao).syncAll( collectionDao.getByService(serviceId), collections, { it.url }) { new, old -> + // use old settings of "force read only" and "sync", regardless of detection results new.forceReadOnly = old.forceReadOnly new.sync = old.sync } } - fun saveResults() { - saveHomesets() - saveCollections() - } - try { Logger.log.info("Refreshing ${service.type} collections of service #$service") // cancel previous notification NotificationManagerCompat.from(this) - .cancel(service.toString(), NotificationUtils.NOTIFY_REFRESH_COLLECTIONS) + .cancel(serviceId.toString(), NotificationUtils.NOTIFY_REFRESH_COLLECTIONS) // create authenticating OkHttpClient (credentials taken from account settings) HttpClient.Builder(this, AccountSettings(this, account)) .setForeground(true) .build().use { client -> - val httpClient = client.okHttpClient - - // refresh home set list (from principal) - service.principal?.let { principalUrl -> - Logger.log.fine("Querying principal $principalUrl for home sets") - queryHomeSets(httpClient, principalUrl) - } + val httpClient = client.okHttpClient - // now refresh homesets and their member collections - val itHomeSets = homeSets.iterator() - while (itHomeSets.hasNext()) { - val homeSet = itHomeSets.next() - Logger.log.fine("Listing home set ${homeSet.key}") - - try { - DavResource(httpClient, homeSet.key).propfind(1, *DAV_COLLECTION_PROPERTIES) { response, relation -> - if (!response.isSuccess()) - return@propfind - - if (relation == Response.HrefRelation.SELF) { - // this response is about the homeset itself - homeSet.value.displayName = response[DisplayName::class.java]?.displayName - homeSet.value.privBind = response[CurrentUserPrivilegeSet::class.java]?.mayBind ?: true - } - - // in any case, check whether the response is about a useable collection - val info = Collection.fromDavResponse(response) ?: return@propfind - info.serviceId = serviceId - info.confirmed = true - Logger.log.log(Level.FINE, "Found collection", info) + 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 itCollections = collections.entries.iterator() - while (itCollections.hasNext()) { - val (url, info) = itCollections.next() - if (!info.confirmed) +// now refresh homesets and their member collections + val itHomeSets = homeSets.iterator() + while (itHomeSets.hasNext()) { + val homeSet = itHomeSets.next() + Logger.log.fine("Listing home set ${homeSet.key}") + try { - DavResource(httpClient, url).propfind(0, *DAV_COLLECTION_PROPERTIES) { response, _ -> + 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.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() + 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) { + } catch (e: HttpException) { if (e.code in arrayOf(403, 404, 410)) - // delete collection only if it was not accessible (40x) - itCollections.remove() - else - throw e + // delete home set only if it was not accessible (40x) + itHomeSets.remove() } + } + + // 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 + throw e + } + } + + // check/refresh unconfirmed collections + val itCollections = collections.entries.iterator() + while (itCollections.hasNext()) { + val (url, info) = itCollections.next() + if (!info.confirmed) + try { + DavResource(httpClient, url, 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) + itCollections.remove() + else + throw e + } + } } - } - saveResults() + db.runInTransaction { + saveHomesets() + + // use refHomeSet (if available) to determine homeset ID + for (collection in collections.values) + collection.refHomeSet?.let { homeSet -> + collection.homeSetId = homeSet.id + } + saveCollections() + } } catch(e: InvalidAccountException) { Logger.log.log(Level.SEVERE, "Invalid account", e) } catch(e: Exception) { Logger.log.log(Level.SEVERE, "Couldn't refresh collection list", e) - val debugIntent = Intent(this, DebugInfoActivity::class.java) - debugIntent.putExtra(DebugInfoActivity.KEY_THROWABLE, e) - debugIntent.putExtra(DebugInfoActivity.KEY_ACCOUNT, account) - + val debugIntent = DebugInfoActivity.IntentBuilder(this) + .withCause(e) + .withAccount(account) + .build() val notify = NotificationUtils.newBuilder(this, NotificationUtils.CHANNEL_GENERAL) .setSmallIcon(R.drawable.ic_sync_problem_notify) .setContentTitle(getString(R.string.dav_service_refresh_failed)) .setContentText(getString(R.string.dav_service_refresh_couldnt_refresh)) - .setContentIntent(PendingIntent.getActivity(this, 0, debugIntent, PendingIntent.FLAG_UPDATE_CURRENT)) + .setContentIntent(PendingIntent.getActivity(this, 0, debugIntent, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE)) .setSubText(account.name) .setCategory(NotificationCompat.CATEGORY_ERROR) .build() diff --git a/app/src/main/java/at/bitfire/davdroid/DavUtils.kt b/app/src/main/java/at/bitfire/davdroid/DavUtils.kt index 700778773479e01864f6b06535b9863a216dd890..db9a195bc009e298cd598e8ff8244574e726d682 100644 --- a/app/src/main/java/at/bitfire/davdroid/DavUtils.kt +++ b/app/src/main/java/at/bitfire/davdroid/DavUtils.kt @@ -1,25 +1,26 @@ -/* - * Copyright © Ricki Hirner (bitfire web engineering). - * All rights reserved. This program and the accompanying materials - * are made available under the terms of the GNU Public License v3.0 - * which accompanies this distribution, and is available at - * http://www.gnu.org/licenses/gpl.html - */ +/*************************************************************************************************** + * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. + **************************************************************************************************/ package at.bitfire.davdroid import android.accounts.Account -import android.annotation.TargetApi import android.content.ContentResolver import android.content.Context import android.net.ConnectivityManager import android.os.Build import android.os.Bundle import android.provider.CalendarContract +import android.provider.ContactsContract +import androidx.core.content.getSystemService import at.bitfire.davdroid.log.Logger -import at.bitfire.ical4android.TaskProvider +import at.bitfire.davdroid.resource.LocalAddressBook +import at.bitfire.davdroid.resource.TaskUtils import okhttp3.HttpUrl +import okhttp3.MediaType +import okhttp3.MediaType.Companion.toMediaType import org.xbill.DNS.* +import java.net.InetAddress import java.util.* /** @@ -27,53 +28,123 @@ import java.util.* */ object DavUtils { + enum class SyncStatus { + ACTIVE, PENDING, IDLE + } + + val DNS_QUAD9 = InetAddress.getByAddress(byteArrayOf(9,9,9,9)) + + const val MIME_TYPE_ACCEPT_ALL = "*/*" + + val MEDIA_TYPE_JCARD = "application/vcard+json".toMediaType() + val MEDIA_TYPE_OCTET_STREAM = "application/octet-stream".toMediaType() + val MEDIA_TYPE_VCARD = "text/vcard".toMediaType() + + + @Suppress("FunctionName") fun ARGBtoCalDAVColor(colorWithAlpha: Int): String { val alpha = (colorWithAlpha shr 24) and 0xFF val color = colorWithAlpha and 0xFFFFFF - return String.format("#%06X%02X", color, alpha) + return String.format(Locale.ROOT, "#%06X%02X", color, alpha) } fun lastSegmentOfUrl(url: HttpUrl): String { // the list returned by HttpUrl.pathSegments() is unmodifiable, so we have to create a copy - val segments = LinkedList(url.pathSegments()) + val segments = LinkedList(url.pathSegments) segments.reverse() return segments.firstOrNull { it.isNotEmpty() } ?: "/" } + fun prepareLookup(context: Context, lookup: Lookup) { - @TargetApi(Build.VERSION_CODES.O) - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + if (Build.VERSION.SDK_INT >= 29) { + /* Since Android 10, there's a native DnsResolver API that allows to send SRV queries without + knowing which DNS servers have to be used. DNS over TLS is now also supported. */ + Logger.log.fine("Using Android 10+ DnsResolver") + lookup.setResolver(Android10Resolver) + + } else if (Build.VERSION.SDK_INT >= 26) { /* Since Android 8, the system properties net.dns1, net.dns2, ... are not available anymore. The current version of dnsjava relies on these properties to find the default name servers, so we have to add the servers explicitly (fortunately, there's an Android API to - get the active DNS servers). */ - val connectivity = context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager - val activeLink = connectivity.getLinkProperties(connectivity.activeNetwork) - if (activeLink != null) { - // get DNS servers of active network link and set them for dnsjava so that it can send SRV queries - val simpleResolvers = activeLink.dnsServers.map { - Logger.log.fine("Using DNS server ${it.hostAddress}") - val resolver = SimpleResolver() - resolver.setAddress(it) - resolver + get the DNS servers of the network connections). */ + val dnsServers = LinkedList() + + val connectivity = context.getSystemService()!! + connectivity.allNetworks.forEach { network -> + val active = connectivity.getNetworkInfo(network)?.isConnected ?: false + connectivity.getLinkProperties(network)?.let { link -> + if (active) + // active connection, insert at top of list + dnsServers.addAll(0, link.dnsServers) + else + // inactive connection, insert at end of list + dnsServers.addAll(link.dnsServers) } - val resolver = ExtendedResolver(simpleResolvers.toTypedArray()) - lookup.setResolver(resolver) - } else - Logger.log.severe("Couldn't determine DNS servers, dnsjava queries (SRV/TXT records) won't work") + } + + // fallback: add Quad9 DNS in case that no other DNS works + dnsServers.add(DNS_QUAD9) + + val uniqueDnsServers = LinkedHashSet(dnsServers) + val simpleResolvers = uniqueDnsServers.map { dns -> + Logger.log.fine("Adding DNS server ${dns.hostAddress}") + SimpleResolver().apply { + setAddress(dns) + } + } + val resolver = ExtendedResolver(simpleResolvers.toTypedArray()) + lookup.setResolver(resolver) } } - fun selectSRVRecord(records: Array?): SRVRecord? { - val srvRecords = records?.filterIsInstance(SRVRecord::class.java) - srvRecords?.let { - if (it.size > 1) - Logger.log.warning("Multiple SRV records not supported yet; using first one") - return it.firstOrNull() + fun selectSRVRecord(records: Array?): SRVRecord? { + if (records == null) + return null + + val srvRecords = records.filterIsInstance(SRVRecord::class.java) + if (srvRecords.size <= 1) + return srvRecords.firstOrNull() + + /* RFC 2782 + + Priority + The priority of this target host. A client MUST attempt to + contact the target host with the lowest-numbered priority it can + reach; target hosts with the same priority SHOULD be tried in an + order defined by the weight field. [...] + + Weight + A server selection mechanism. The weight field specifies a + relative weight for entries with the same priority. [...] + + To select a target to be contacted next, arrange all SRV RRs + (that have not been ordered yet) in any order, except that all + those with weight 0 are placed at the beginning of the list. + + Compute the sum of the weights of those RRs, and with each RR + associate the running sum in the selected order. Then choose a + uniform random number between 0 and the sum computed + (inclusive), and select the RR whose running sum value is the + first in the selected order which is greater than or equal to + the random number selected. The target host specified in the + selected SRV RR is the next one to be contacted by the client. + */ + val minPriority = srvRecords.map { it.priority }.minOrNull() + val useableRecords = srvRecords.filter { it.priority == minPriority }.sortedBy { it.weight != 0 } + + val map = TreeMap() + var runningWeight = 0 + for (record in useableRecords) { + val weight = record.weight + runningWeight += weight + map[runningWeight] = record } - return null + + val selector = (0..runningWeight).random() + return map.ceilingEntry(selector)!!.value } fun pathsFromTXTRecords(records: Array?): List { @@ -90,18 +161,82 @@ object DavUtils { } - fun requestSync(context: Context, account: Account) { - val authorities = arrayOf( - context.getString(R.string.address_books_authority), - CalendarContract.AUTHORITY, - TaskProvider.ProviderName.OpenTasks.authority - ) + /** + * Returns the sync status of a given account. Checks the account itself and possible + * sub-accounts (address book accounts). + * + * @param authorities sync authorities to check (usually taken from [syncAuthorities]) + * + * @return sync status of the given account + */ + fun accountSyncStatus(context: Context, authorities: Iterable, account: Account): SyncStatus { + // check active syncs + if (authorities.any { ContentResolver.isSyncActive(account, it) }) + return SyncStatus.ACTIVE + + val addrBookAccounts = LocalAddressBook.findAll(context, null, account).map { it.account } + if (addrBookAccounts.any { ContentResolver.isSyncActive(it, ContactsContract.AUTHORITY) }) + return SyncStatus.ACTIVE - for (authority in authorities) { + // check get pending syncs + if (authorities.any { ContentResolver.isSyncPending(account, it) } || + addrBookAccounts.any { ContentResolver.isSyncPending(it, ContactsContract.AUTHORITY) }) + return SyncStatus.PENDING + + return SyncStatus.IDLE + } + + /** + * Requests an immediate, manual sync of all available authorities for the given account. + * + * @param account account to sync + */ + fun requestSync(context: Context, account: Account) { + for (authority in syncAuthorities(context)) { val extras = Bundle(2) extras.putBoolean(ContentResolver.SYNC_EXTRAS_MANUAL, true) // manual sync extras.putBoolean(ContentResolver.SYNC_EXTRAS_EXPEDITED, true) // run immediately (don't queue) ContentResolver.requestSync(account, authority, extras) } } + + /** + * Returns a list of all available sync authorities for main accounts (!= address book accounts): + * + * 1. address books authority (not [ContactsContract.AUTHORITY], but the one which manages address book accounts) + * 1. calendar authority + * 1. tasks authority (if available) + * + * Checking the availability of authorities may be relatively expensive, so the + * result should be cached for the current operation. + * + * @return list of available sync authorities for main accounts + */ + fun syncAuthorities(context: Context): List { + val result = mutableListOf( + context.getString(R.string.address_books_authority), + CalendarContract.AUTHORITY + ) + + TaskUtils.currentProvider(context)?.let { taskProvider -> + result += taskProvider.authority + } + + return result + } + + + // extension methods + + /** + * Compares MIME type and subtype of two MediaTypes. Does _not_ compare parameters + * like `charset` or `version`. + * + * @param other MediaType to compare with + * + * @return *true* if type and subtype match; *false* if they don't + */ + fun MediaType.sameTypeAs(other: MediaType) = + type == other.type && subtype == other.subtype + } 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/ForegroundService.kt b/app/src/main/java/at/bitfire/davdroid/ForegroundService.kt new file mode 100644 index 0000000000000000000000000000000000000000..b7460e95d801862eca66c8353fa96de99c07f899 --- /dev/null +++ b/app/src/main/java/at/bitfire/davdroid/ForegroundService.kt @@ -0,0 +1,118 @@ +/*************************************************************************************************** + * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. + **************************************************************************************************/ + +package at.bitfire.davdroid + +import android.app.PendingIntent +import android.app.Service +import android.content.Context +import android.content.Intent +import android.os.Build +import android.os.PowerManager +import androidx.core.app.NotificationCompat +import androidx.core.app.NotificationManagerCompat +import at.bitfire.davdroid.settings.Settings +import at.bitfire.davdroid.settings.SettingsManager +import at.bitfire.davdroid.ui.AppSettingsActivity +import at.bitfire.davdroid.ui.NotificationUtils +import dagger.hilt.EntryPoint +import dagger.hilt.InstallIn +import dagger.hilt.android.EntryPointAccessors +import dagger.hilt.components.SingletonComponent + +class ForegroundService : Service() { + + companion object { + + @EntryPoint + @InstallIn(SingletonComponent::class) + interface ForegroundServiceEntryPoint { + fun settingsManager(): SettingsManager + } + + /** + * Starts/stops a foreground service, according to the app setting [Settings.FOREGROUND_SERVICE] + * if [Settings.BATTERY_OPTIMIZATION] is enabled - meaning DAVx5 is whitelisted from optimization. + */ + const val ACTION_FOREGROUND = "foreground" + + + /** + * Whether the app is currently exempted from battery optimization. + * @return true if battery optimization is not applied to the current app; false if battery optimization is applied + */ + fun batteryOptimizationWhitelisted(context: Context) = + if (Build.VERSION.SDK_INT >= 23) { // battery optimization exists since Android 6 (SDK level 23) + val powerManager = context.getSystemService(PowerManager::class.java) + powerManager.isIgnoringBatteryOptimizations(BuildConfig.APPLICATION_ID) + } else + true + + /** + * Whether the foreground service is enabled (checked) in the app settings. + * @return true: foreground service enabled; false: foreground service not enabled + */ + fun foregroundServiceActivated(context: Context): Boolean { + val settingsManager = EntryPointAccessors.fromApplication(context, ForegroundServiceEntryPoint::class.java).settingsManager() + return settingsManager.getBooleanOrNull(Settings.FOREGROUND_SERVICE) == true + } + + /** + * Starts the foreground service when enabled in the app settings and applicable. + */ + fun startIfActive(context: Context) { + if (foregroundServiceActivated(context)) { + if (batteryOptimizationWhitelisted(context)) { + val serviceIntent = Intent(ACTION_FOREGROUND, null, context, ForegroundService::class.java) + if (Build.VERSION.SDK_INT >= 26) + context.startForegroundService(serviceIntent) + else + context.startService(serviceIntent) + } else + notifyBatteryOptimization(context) + } + } + + private fun notifyBatteryOptimization(context: Context) { + val settingsIntent = Intent(context, AppSettingsActivity::class.java).apply { + putExtra(AppSettingsActivity.EXTRA_SCROLL_TO, Settings.BATTERY_OPTIMIZATION) + } + val pendingSettingsIntent = PendingIntent.getActivity(context, 0, settingsIntent, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE) + val builder = + NotificationCompat.Builder(context, NotificationUtils.CHANNEL_DEBUG) + .setSmallIcon(R.drawable.ic_warning_notify) + .setContentTitle(context.getString(R.string.battery_optimization_notify_title)) + .setContentText(context.getString(R.string.battery_optimization_notify_text)) + .setContentIntent(pendingSettingsIntent) + .setCategory(NotificationCompat.CATEGORY_ERROR) + + val nm = NotificationManagerCompat.from(context) + nm.notify(NotificationUtils.NOTIFY_BATTERY_OPTIMIZATION, builder.build()) + } + } + + + override fun onBind(intent: Intent?): Nothing? = null + + override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { + if (foregroundServiceActivated(this)) { + val settingsIntent = Intent(this, AppSettingsActivity::class.java).apply { + putExtra(AppSettingsActivity.EXTRA_SCROLL_TO, Settings.FOREGROUND_SERVICE) + } + val builder = NotificationCompat.Builder(this, NotificationUtils.CHANNEL_STATUS) + .setSmallIcon(R.drawable.ic_foreground_notify) + .setContentTitle(getString(R.string.foreground_service_notify_title)) + .setContentText(getString(R.string.foreground_service_notify_text)) + .setStyle(NotificationCompat.BigTextStyle()) + .setContentIntent(PendingIntent.getActivity(this, 0, settingsIntent, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE)) + .setCategory(NotificationCompat.CATEGORY_STATUS) + startForeground(NotificationUtils.NOTIFY_FOREGROUND, builder.build()) + return START_STICKY + } else { + stopForeground(true) + return START_NOT_STICKY + } + } + +} \ No newline at end of file diff --git a/app/src/main/java/at/bitfire/davdroid/HttpClient.kt b/app/src/main/java/at/bitfire/davdroid/HttpClient.kt index 3f1e7b4fb4762d312d543f5035e5607f65d33771..9e1863bcef679b0ad713fe272334e3359a92a0b0 100644 --- a/app/src/main/java/at/bitfire/davdroid/HttpClient.kt +++ b/app/src/main/java/at/bitfire/davdroid/HttpClient.kt @@ -1,10 +1,6 @@ -/* - * Copyright © Ricki Hirner (bitfire web engineering). - * All rights reserved. This program and the accompanying materials - * are made available under the terms of the GNU Public License v3.0 - * which accompanies this distribution, and is available at - * http://www.gnu.org/licenses/gpl.html - */ +/*************************************************************************************************** + * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. + **************************************************************************************************/ package at.bitfire.davdroid @@ -13,13 +9,18 @@ import android.os.Build import android.security.KeyChain import at.bitfire.cert4android.CustomCertManager import at.bitfire.dav4jvm.BasicDigestAuthHandler -import at.bitfire.dav4jvm.Constants import at.bitfire.dav4jvm.UrlUtils +import at.bitfire.davdroid.db.Credentials import at.bitfire.davdroid.log.Logger -import at.bitfire.davdroid.model.Credentials import at.bitfire.davdroid.settings.AccountSettings import at.bitfire.davdroid.settings.Settings +import at.bitfire.davdroid.settings.SettingsManager +import dagger.hilt.EntryPoint +import dagger.hilt.InstallIn +import dagger.hilt.android.EntryPointAccessors +import dagger.hilt.components.SingletonComponent import okhttp3.* +import okhttp3.brotli.BrotliInterceptor import okhttp3.internal.tls.OkHostnameVerifier import okhttp3.logging.HttpLoggingInterceptor import java.io.File @@ -35,108 +36,148 @@ import java.util.logging.Level import javax.net.ssl.* class HttpClient private constructor( - val okHttpClient: OkHttpClient, - private val certManager: CustomCertManager? + val okHttpClient: OkHttpClient, + private val certManager: CustomCertManager? ): AutoCloseable { + @EntryPoint + @InstallIn(SingletonComponent::class) + interface HttpClientEntryPoint { + fun settingsManager(): SettingsManager + } + companion object { /** max. size of disk cache (10 MB) */ const val DISK_CACHE_MAX_SIZE: Long = 10*1024*1024 - /** [OkHttpClient] singleton to build all clients from */ - val sharedClient: OkHttpClient = OkHttpClient.Builder() - // set timeouts + /** Base Builder to build all clients from. Use rarely; [OkHttpClient]s should + * be reused as much as possible. */ + fun baseBuilder() = + OkHttpClient.Builder() + // Set timeouts. According to [AbstractThreadedSyncAdapter], when there is no network + // traffic within a minute, a sync will be cancelled. .connectTimeout(15, TimeUnit.SECONDS) .writeTimeout(30, TimeUnit.SECONDS) .readTimeout(120, TimeUnit.SECONDS) + .pingInterval( + 45, + TimeUnit.SECONDS + ) // avoid cancellation because of missing traffic; only works for HTTP/2 + + // keep TLS 1.0 and 1.1 for now; remove when major browsers have dropped it (probably 2020) + .connectionSpecs( + listOf( + ConnectionSpec.CLEARTEXT, + ConnectionSpec.COMPATIBLE_TLS + ) + ) // don't allow redirects by default, because it would break PROPFIND handling .followRedirects(false) // add User-Agent to every request - .addNetworkInterceptor(UserAgentInterceptor) - - .build() + .addInterceptor(UserAgentInterceptor) } override fun close() { - okHttpClient.cache()?.close() + okHttpClient.cache?.close() certManager?.close() } class Builder( - val context: Context? = null, - accountSettings: AccountSettings? = null, - val logger: java.util.logging.Logger = Logger.log + val context: Context? = null, + accountSettings: AccountSettings? = null, + val logger: java.util.logging.Logger? = Logger.log, + val loggerLevel: HttpLoggingInterceptor.Level = HttpLoggingInterceptor.Level.BODY ) { - private var certManager: CustomCertManager? = null + + fun interface CertManagerProducer { + fun certManager(): CustomCertManager + } + + private var appInForeground = false + private var certManagerProducer: CertManagerProducer? = null private var certificateAlias: String? = null - private var cache: Cache? = null + private var offerCompression: Boolean = false - private val orig = sharedClient.newBuilder() + // default cookie store for non-persistent cookies (some services like Horde use cookies for session tracking) + private var cookieStore: CookieJar? = MemoryCookieStore() - init { - // add cookie store for non-persistent cookies (some services like Horde use cookies for session tracking) - orig.cookieJar(MemoryCookieStore()) + private val orig = baseBuilder() + init { // add network logging, if requested - if (logger.isLoggable(Level.FINEST)) { - val loggingInterceptor = HttpLoggingInterceptor(HttpLoggingInterceptor.Logger { - message -> logger.finest(message) - }) - loggingInterceptor.level = HttpLoggingInterceptor.Level.BODY - orig.addInterceptor(loggingInterceptor) + if (logger != null && logger.isLoggable(Level.FINEST)) { + val loggingInterceptor = HttpLoggingInterceptor { message -> logger.finest(message) } + loggingInterceptor.level = loggerLevel + orig.addNetworkInterceptor(loggingInterceptor) } - context?.let { - val settings = Settings.getInstance(context) + if (context != null) { + val settings = EntryPointAccessors.fromApplication(context, HttpClientEntryPoint::class.java).settingsManager() // custom proxy support try { - if (settings.getBoolean(Settings.OVERRIDE_PROXY) == true) { - val address = InetSocketAddress( - settings.getString(Settings.OVERRIDE_PROXY_HOST) - ?: Settings.OVERRIDE_PROXY_HOST_DEFAULT, - settings.getInt(Settings.OVERRIDE_PROXY_PORT) - ?: Settings.OVERRIDE_PROXY_PORT_DEFAULT - ) - - val proxy = Proxy(Proxy.Type.HTTP, address) + val proxyTypeValue = settings.getInt(Settings.PROXY_TYPE) + if (proxyTypeValue != Settings.PROXY_TYPE_SYSTEM) { + // we set our own proxy + val address by lazy { // lazy because not required for PROXY_TYPE_NONE + InetSocketAddress( + settings.getString(Settings.PROXY_HOST), + settings.getInt(Settings.PROXY_PORT) + ) + } + val proxy = + when (proxyTypeValue) { + Settings.PROXY_TYPE_NONE -> Proxy.NO_PROXY + Settings.PROXY_TYPE_HTTP -> Proxy(Proxy.Type.HTTP, address) + Settings.PROXY_TYPE_SOCKS -> Proxy(Proxy.Type.SOCKS, address) + else -> throw IllegalArgumentException("Invalid proxy type") + } orig.proxy(proxy) - Logger.log.log(Level.INFO, "Using proxy", proxy) + Logger.log.log(Level.INFO, "Using proxy setting", proxy) } } catch (e: Exception) { Logger.log.log(Level.SEVERE, "Can't set proxy, ignoring", e) } - //if (BuildConfig.customCerts) - customCertManager(CustomCertManager(context, true, - !(settings.getBoolean(Settings.DISTRUST_SYSTEM_CERTIFICATES) - ?: Settings.DISTRUST_SYSTEM_CERTIFICATES_DEFAULT))) + customCertManager() { + // by default, use a CustomCertManager that respects the "distrust system certificates" setting + val trustSystemCerts = !settings.getBoolean(Settings.DISTRUST_SYSTEM_CERTIFICATES) + CustomCertManager(context, true /*BuildConfig.customCertsUI*/, trustSystemCerts) + } } - // use account settings for authentication + // use account settings for authentication and cookies accountSettings?.let { addAuthentication(null, it.credentials()) } } - constructor(context: Context, host: String?, credentials: Credentials): this(context) { - addAuthentication(host, credentials) + constructor(context: Context, host: String?, credentials: Credentials?): this(context) { + if (credentials != null) + addAuthentication(host, credentials) } - fun withDiskCache(): Builder { - val context = context ?: throw IllegalArgumentException("Context is required to find the cache directory") - for (dir in arrayOf(context.externalCacheDir, context.cacheDir).filterNotNull()) { - if (dir.exists() && dir.canWrite()) { - val cacheDir = File(dir, "HttpClient") - cacheDir.mkdir() - Logger.log.fine("Using disk cache: $cacheDir") - orig.cache(Cache(cacheDir, DISK_CACHE_MAX_SIZE)) - break - } + fun addAuthentication(host: String?, credentials: Credentials, insecurePreemptive: Boolean = false): Builder { + if (credentials.userName != null && credentials.password != null) { + val authHandler = BasicDigestAuthHandler(UrlUtils.hostToDomain(host), credentials.userName, credentials.password, insecurePreemptive) + orig.addNetworkInterceptor(authHandler) + .authenticator(authHandler) } + if (credentials.certificateAlias != null) + certificateAlias = credentials.certificateAlias + return this + } + + fun allowCompression(allow: Boolean): Builder { + offerCompression = allow + return this + } + + fun cookieStore(store: CookieJar?): Builder { + cookieStore = store return this } @@ -145,98 +186,107 @@ class HttpClient private constructor( return this } - fun customCertManager(manager: CustomCertManager) { - certManager = manager + fun customCertManager(producer: CertManagerProducer) { + certManagerProducer = producer } fun setForeground(foreground: Boolean): Builder { - certManager?.appInForeground = foreground + appInForeground = foreground return this } - fun addAuthentication(host: String?, credentials: Credentials): Builder { - when (credentials.type) { - Credentials.Type.UsernamePassword -> { - val authHandler = BasicDigestAuthHandler(UrlUtils.hostToDomain(host), credentials.userName!!, credentials.password!!) - orig .addNetworkInterceptor(authHandler) - .authenticator(authHandler) - } - Credentials.Type.ClientCertificate -> { - certificateAlias = credentials.certificateAlias + fun withDiskCache(context: Context): Builder { + for (dir in arrayOf(context.externalCacheDir, context.cacheDir).filterNotNull()) { + if (dir.exists() && dir.canWrite()) { + val cacheDir = File(dir, "HttpClient") + cacheDir.mkdir() + Logger.log.fine("Using disk cache: $cacheDir") + orig.cache(Cache(cacheDir, DISK_CACHE_MAX_SIZE)) + break } } return this } fun build(): HttpClient { - val trustManager = certManager ?: { - val factory = TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm()) - factory.init(null as KeyStore?) - factory.trustManagers.first() as X509TrustManager - }() + cookieStore?.let { + orig.cookieJar(it) + } - val hostnameVerifier = certManager?.hostnameVerifier(OkHostnameVerifier.INSTANCE) - ?: OkHostnameVerifier.INSTANCE + if (offerCompression) + // offer Brotli and gzip compression + orig.addInterceptor(BrotliInterceptor) var keyManager: KeyManager? = null certificateAlias?.let { alias -> - try { - val context = requireNotNull(context) + val context = requireNotNull(context) - // get provider certificate and private key - val certs = KeyChain.getCertificateChain(context, alias) ?: return@let - val key = KeyChain.getPrivateKey(context, alias) ?: return@let - logger.fine("Using provider certificate $alias for authentication (chain length: ${certs.size})") + // get provider certificate and private key + val certs = KeyChain.getCertificateChain(context, alias) ?: return@let + val key = KeyChain.getPrivateKey(context, alias) ?: return@let + logger?.fine("Using provider certificate $alias for authentication (chain length: ${certs.size})") - // create Android KeyStore (performs key operations without revealing secret data to DAVx5) - val keyStore = KeyStore.getInstance("AndroidKeyStore") - keyStore.load(null) + // create KeyManager + keyManager = object : X509ExtendedKeyManager() { + override fun getServerAliases(p0: String?, p1: Array?): Array? = null + override fun chooseServerAlias(p0: String?, p1: Array?, p2: Socket?) = null - // create KeyManager - keyManager = object: X509ExtendedKeyManager() { - override fun getServerAliases(p0: String?, p1: Array?): Array? = null - override fun chooseServerAlias(p0: String?, p1: Array?, p2: Socket?) = null + override fun getClientAliases(p0: String?, p1: Array?) = + arrayOf(alias) - override fun getClientAliases(p0: String?, p1: Array?) = - arrayOf(alias) + override fun chooseClientAlias(p0: Array?, p1: Array?, p2: Socket?) = + alias - override fun chooseClientAlias(p0: Array?, p1: Array?, p2: Socket?) = - alias + override fun getCertificateChain(forAlias: String?) = + certs.takeIf { forAlias == alias } - override fun getCertificateChain(forAlias: String?) = - certs.takeIf { forAlias == alias } - - override fun getPrivateKey(forAlias: String?) = - key.takeIf { forAlias == alias } - } - - // HTTP/2 doesn't support client certificates (yet) - // see https://tools.ietf.org/html/draft-ietf-httpbis-http2-secondary-certs-04 - orig.protocols(listOf(Protocol.HTTP_1_1)) - } catch (e: Exception) { - logger.log(Level.SEVERE, "Couldn't set up provider certificate authentication", e) + override fun getPrivateKey(forAlias: String?) = + key.takeIf { forAlias == alias } } + + // HTTP/2 doesn't support client certificates (yet) + // see https://tools.ietf.org/html/draft-ietf-httpbis-http2-secondary-certs-04 + orig.protocols(listOf(Protocol.HTTP_1_1)) } - val sslContext = SSLContext.getInstance("TLS") - sslContext.init( + if (certManagerProducer != null || keyManager != null) { + val certManager = certManagerProducer?.certManager() + certManager?.appInForeground = appInForeground + + val trustManager = certManager ?: { // fall back to system default trust manager + val factory = TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm()) + factory.init(null as KeyStore?) + factory.trustManagers.first() as X509TrustManager + }() + + val hostnameVerifier = certManager?.hostnameVerifier(OkHostnameVerifier) + ?: OkHostnameVerifier + + val sslContext = SSLContext.getInstance("TLS") + sslContext.init( if (keyManager != null) arrayOf(keyManager) else null, arrayOf(trustManager), null) - orig.sslSocketFactory(sslContext.socketFactory, trustManager) - orig.hostnameVerifier(hostnameVerifier) + orig.sslSocketFactory(sslContext.socketFactory, trustManager) + orig.hostnameVerifier(hostnameVerifier) - return HttpClient(orig.build(), certManager) + return HttpClient(orig.build(), certManager) + } else + return HttpClient(orig.build(), null) } } private object UserAgentInterceptor: Interceptor { - // use Locale.US because numbers may be encoded as non-ASCII characters in other locales - private val userAgentDateFormat = SimpleDateFormat("yyyy/MM/dd", Locale.US) + // use Locale.ROOT because numbers may be encoded as non-ASCII characters in other locales + private val userAgentDateFormat = SimpleDateFormat("yyyy/MM/dd", Locale.ROOT) private val userAgentDate = userAgentDateFormat.format(Date(BuildConfig.buildTime)) private val userAgent = "${BuildConfig.userAgent}/${BuildConfig.VERSION_NAME} ($userAgentDate; dav4jvm; " + - "okhttp/${Constants.okhttpVersion}) Android/${Build.VERSION.RELEASE}" + "okhttp/${OkHttp.VERSION}) Android/${Build.VERSION.RELEASE}" + + init { + Logger.log.info("Will set \"User-Agent: $userAgent\" for further requests") + } override fun intercept(chain: Interceptor.Chain): Response { val locale = Locale.getDefault() diff --git a/app/src/main/java/at/bitfire/davdroid/InvalidAccountException.kt b/app/src/main/java/at/bitfire/davdroid/InvalidAccountException.kt index 5fc0f0999934b812f55b66004103945ede14f73d..8301940acde6ad39ace21951a6ced252840f813d 100644 --- a/app/src/main/java/at/bitfire/davdroid/InvalidAccountException.kt +++ b/app/src/main/java/at/bitfire/davdroid/InvalidAccountException.kt @@ -1,10 +1,6 @@ -/* - * Copyright © Ricki Hirner (bitfire web engineering). - * All rights reserved. This program and the accompanying materials - * are made available under the terms of the GNU Public License v3.0 - * which accompanies this distribution, and is available at - * http://www.gnu.org/licenses/gpl.html - */ +/*************************************************************************************************** + * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. + **************************************************************************************************/ package at.bitfire.davdroid 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/MemoryCookieStore.kt b/app/src/main/java/at/bitfire/davdroid/MemoryCookieStore.kt index c0cd32d4dbce7429d97c128c3e8e4d8177c168a4..250913d0afe409f58e5c8c89d2ad0827e2b1eb27 100644 --- a/app/src/main/java/at/bitfire/davdroid/MemoryCookieStore.kt +++ b/app/src/main/java/at/bitfire/davdroid/MemoryCookieStore.kt @@ -1,10 +1,6 @@ -/* - * Copyright © Ricki Hirner (bitfire web engineering). - * All rights reserved. This program and the accompanying materials - * are made available under the terms of the GNU Public License v3.0 - * which accompanies this distribution, and is available at - * http://www.gnu.org/licenses/gpl.html - */ +/*************************************************************************************************** + * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. + **************************************************************************************************/ package at.bitfire.davdroid @@ -32,7 +28,7 @@ class MemoryCookieStore: CookieJar { override fun saveFromResponse(url: HttpUrl, cookies: List) { synchronized(storage) { for (cookie in cookies) - storage.put(cookie.name(), cookie.domain(), cookie.path(), cookie) + storage.put(cookie.name, cookie.domain, cookie.path, cookie) } } @@ -46,7 +42,7 @@ class MemoryCookieStore: CookieJar { val cookie = iter.value // remove expired cookies - if (cookie.expiresAt() <= System.currentTimeMillis()) { + if (cookie.expiresAt <= System.currentTimeMillis()) { iter.remove() continue } diff --git a/app/src/main/java/at/bitfire/davdroid/PackageChangedReceiver.kt b/app/src/main/java/at/bitfire/davdroid/PackageChangedReceiver.kt index f24f2324c2117304367f1e96ae0d80d5ff4e3274..f476de81bf78d2da994621081bdaeb7c39976d43 100644 --- a/app/src/main/java/at/bitfire/davdroid/PackageChangedReceiver.kt +++ b/app/src/main/java/at/bitfire/davdroid/PackageChangedReceiver.kt @@ -1,58 +1,29 @@ -/* - * Copyright © Ricki Hirner (bitfire web engineering). - * All rights reserved. This program and the accompanying materials - * are made available under the terms of the GNU Public License v3.0 - * which accompanies this distribution, and is available at - * http://www.gnu.org/licenses/gpl.html - */ +/*************************************************************************************************** + * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. + **************************************************************************************************/ package at.bitfire.davdroid -import android.accounts.Account import android.content.BroadcastReceiver -import android.content.ContentResolver import android.content.Context import android.content.Intent -import android.os.Bundle -import androidx.annotation.WorkerThread -import at.bitfire.davdroid.log.Logger -import at.bitfire.davdroid.model.AppDatabase -import at.bitfire.davdroid.model.Service -import at.bitfire.davdroid.resource.LocalTaskList -import at.bitfire.ical4android.TaskProvider.ProviderName.OpenTasks -import kotlin.concurrent.thread +import android.content.IntentFilter -class PackageChangedReceiver: BroadcastReceiver() { +abstract class PackageChangedReceiver( + val context: Context +): BroadcastReceiver(), AutoCloseable { - companion object { - - @WorkerThread - fun updateTaskSync(context: Context) { - val tasksInstalled = LocalTaskList.tasksProviderAvailable(context) - Logger.log.info("Tasks provider available = $tasksInstalled") - - // check all accounts and (de)activate OpenTasks if a CalDAV service is defined - val db = AppDatabase.getInstance(context) - db.serviceDao().getByType(Service.TYPE_CALDAV).forEach { service -> - val account = Account(service.accountName, context.getString(R.string.account_type)) - if (tasksInstalled) { - if (ContentResolver.getIsSyncable(account, OpenTasks.authority) <= 0) { - ContentResolver.setIsSyncable(account, OpenTasks.authority, 1) - ContentResolver.addPeriodicSync(account, OpenTasks.authority, Bundle(), Constants.DEFAULT_SYNC_INTERVAL) - } - } else - ContentResolver.setIsSyncable(account, OpenTasks.authority, 0) - - } + init { + val filter = IntentFilter(Intent.ACTION_PACKAGE_ADDED).apply { + addAction(Intent.ACTION_PACKAGE_CHANGED) + addAction(Intent.ACTION_PACKAGE_REMOVED) + addDataScheme("package") } - + context.registerReceiver(this, filter) } - - override fun onReceive(context: Context, intent: Intent) { - thread { - updateTaskSync(context) - } + override fun close() { + context.unregisterReceiver(this) } -} +} \ No newline at end of file diff --git a/app/src/main/java/at/bitfire/davdroid/PermissionUtils.kt b/app/src/main/java/at/bitfire/davdroid/PermissionUtils.kt new file mode 100644 index 0000000000000000000000000000000000000000..44529956d96d63d9c721be7cf73879badd7b98ee --- /dev/null +++ b/app/src/main/java/at/bitfire/davdroid/PermissionUtils.kt @@ -0,0 +1,132 @@ +/*************************************************************************************************** + * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. + **************************************************************************************************/ + +package at.bitfire.davdroid + +import android.Manifest +import android.app.PendingIntent +import android.content.Context +import android.content.Intent +import android.content.pm.PackageManager +import android.location.LocationManager +import android.net.Uri +import android.os.Build +import androidx.core.app.NotificationCompat +import androidx.core.app.NotificationManagerCompat +import androidx.core.content.ContextCompat +import androidx.core.location.LocationManagerCompat +import at.bitfire.davdroid.log.Logger +import at.bitfire.davdroid.ui.NotificationUtils +import at.bitfire.davdroid.ui.PermissionsActivity + +object PermissionUtils { + + val CONTACT_PERMISSIONS = arrayOf( + Manifest.permission.READ_CONTACTS, + Manifest.permission.WRITE_CONTACTS + ) + val CALENDAR_PERMISSIONS = arrayOf( + Manifest.permission.READ_CALENDAR, + Manifest.permission.WRITE_CALENDAR + ) + + val WIFI_SSID_PERMISSIONS = + when { + Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q -> + arrayOf(Manifest.permission.ACCESS_FINE_LOCATION, Manifest.permission.ACCESS_BACKGROUND_LOCATION) + Build.VERSION.SDK_INT >= Build.VERSION_CODES.O_MR1 -> + arrayOf(Manifest.permission.ACCESS_COARSE_LOCATION) + else -> + arrayOf() + } + + /** + * Checks whether all conditions to access the current WiFi's SSID are met: + * + * 1. location permissions ([WIFI_SSID_PERMISSIONS]) granted (Android 8.1+) + * 2. location enabled (Android 9+) + * + * @return *true* if SSID can be obtained; *false* if the SSID will be or something like that + */ + fun canAccessWifiSsid(context: Context): Boolean { + // before Android 8.1, SSIDs are always readable + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O_MR1) + return true + + val locationAvailable = + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.P) + true // Android <9 doesn't require active location services + else + ContextCompat.getSystemService(context, LocationManager::class.java)?.let { locationManager -> + LocationManagerCompat.isLocationEnabled(locationManager) + } ?: /* location feature not available on this device */ false + + return havePermissions(context, WIFI_SSID_PERMISSIONS) && + locationAvailable + } + + /** + * Whether this app declares the given permission (regardless of whether it has been granted or not). + * + * @param permission permission to check + * + * @return *true* if this app declares [permission] in the manifest; *false* otherwise + */ + fun declaresPermission(packageManager: PackageManager, permission: String): Boolean { + val info = packageManager.getPackageInfo(BuildConfig.APPLICATION_ID, PackageManager.GET_PERMISSIONS) + return info.requestedPermissions.contains(permission) + } + + /** + * Checks whether at least one of the given permissions is granted. + * + * @param context context to check + * @param permissions array of permissions to check + * + * @return whether at least one of [permissions] is granted + */ + fun haveAnyPermission(context: Context, permissions: Array) = + permissions.any { ContextCompat.checkSelfPermission(context, it) == PackageManager.PERMISSION_GRANTED } + + /** + * Checks whether all given permissions are granted. + * + * @param context context to check + * @param permissions array of permissions to check + * + * @return whether all [permissions] are granted + */ + fun havePermissions(context: Context, permissions: Array) = + permissions.all { ContextCompat.checkSelfPermission(context, it) == PackageManager.PERMISSION_GRANTED } + + /** + * Shows a notification about missing permissions. + * + * @param context notification context + * @param intent will be set as content Intent; if null, an Intent to launch PermissionsActivity will be used + */ + fun notifyPermissions(context: Context, intent: Intent?) { + val contentIntent = intent ?: Intent(context, PermissionsActivity::class.java) + val notify = NotificationUtils.newBuilder(context, NotificationUtils.CHANNEL_SYNC_ERRORS) + .setSmallIcon(R.drawable.ic_sync_problem_notify) + .setContentTitle(context.getString(R.string.sync_error_permissions)) + .setContentText(context.getString(R.string.sync_error_permissions_text)) + .setContentIntent(PendingIntent.getActivity(context, 0, contentIntent, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE)) + .setCategory(NotificationCompat.CATEGORY_ERROR) + .setAutoCancel(true) + .build() + NotificationManagerCompat.from(context) + .notify(NotificationUtils.NOTIFY_PERMISSIONS, notify) + } + + fun showAppSettings(context: Context) { + val intent = Intent(android.provider.Settings.ACTION_APPLICATION_DETAILS_SETTINGS, + Uri.fromParts("package", BuildConfig.APPLICATION_ID, null)) + if (intent.resolveActivity(context.packageManager) != null) + context.startActivity(intent) + else + Logger.log.warning("App settings Intent not resolvable") + } + +} \ No newline at end of file diff --git a/app/src/main/java/at/bitfire/davdroid/StorageLowReceiver.kt b/app/src/main/java/at/bitfire/davdroid/StorageLowReceiver.kt new file mode 100644 index 0000000000000000000000000000000000000000..e6b34d662a3e9baee3aba71bf879971c93d8b915 --- /dev/null +++ b/app/src/main/java/at/bitfire/davdroid/StorageLowReceiver.kt @@ -0,0 +1,89 @@ +/*************************************************************************************************** + * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. + **************************************************************************************************/ + +package at.bitfire.davdroid + +import android.app.PendingIntent +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import android.content.IntentFilter +import android.provider.Settings +import androidx.core.app.NotificationCompat +import androidx.core.app.NotificationManagerCompat +import androidx.lifecycle.MutableLiveData +import at.bitfire.davdroid.log.Logger +import at.bitfire.davdroid.ui.NotificationUtils +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.android.qualifiers.ApplicationContext +import dagger.hilt.components.SingletonComponent +import javax.inject.Singleton + +class StorageLowReceiver private constructor( + val context: Context +): BroadcastReceiver(), AutoCloseable { + + @Module + @InstallIn(SingletonComponent::class) + object storageLowReceiverModule { + @Provides + @Singleton + fun storageLowReceiver(@ApplicationContext context: Context) = StorageLowReceiver(context) + } + + + val storageLow = MutableLiveData(false) + + fun listen() { + Logger.log.fine("Listening for device storage low/OK broadcasts") + val filter = IntentFilter().apply { + addAction(Intent.ACTION_DEVICE_STORAGE_LOW) + addAction(Intent.ACTION_DEVICE_STORAGE_OK) + } + context.registerReceiver(this, filter) + } + + override fun close() { + context.unregisterReceiver(this) + } + + + override fun onReceive(context: Context, intent: Intent) { + when (intent.action) { + Intent.ACTION_DEVICE_STORAGE_LOW -> onStorageLow() + Intent.ACTION_DEVICE_STORAGE_OK -> onStorageOk() + } + } + + fun onStorageLow() { + Logger.log.warning("Low storage, sync will not be started by Android!") + + storageLow.postValue(true) + + val notify = NotificationUtils.newBuilder(context, NotificationUtils.CHANNEL_SYNC_ERRORS) + .setSmallIcon(R.drawable.ic_storage_notify) + .setCategory(NotificationCompat.CATEGORY_ERROR) + .setContentTitle(context.getString(R.string.storage_low_notify_title)) + .setContentText(context.getString(R.string.storage_low_notify_text)) + + val settingsIntent = Intent(Settings.ACTION_INTERNAL_STORAGE_SETTINGS) + if (settingsIntent.resolveActivity(context.packageManager) != null) + notify.setContentIntent(PendingIntent.getActivity(context, 0, settingsIntent, PendingIntent.FLAG_CANCEL_CURRENT or PendingIntent.FLAG_IMMUTABLE)) + + val nm = NotificationManagerCompat.from(context) + nm.notify(NotificationUtils.NOTIFY_LOW_STORAGE, notify.build()) + } + + fun onStorageOk() { + Logger.log.info("Storage OK again") + + storageLow.postValue(false) + + val nm = NotificationManagerCompat.from(context) + nm.cancel(NotificationUtils.NOTIFY_LOW_STORAGE) + } + +} \ No newline at end of file diff --git a/app/src/main/java/at/bitfire/davdroid/TasksWatcher.kt b/app/src/main/java/at/bitfire/davdroid/TasksWatcher.kt new file mode 100644 index 0000000000000000000000000000000000000000..410908bb365a376a6d66584b7e41003f82aea974 --- /dev/null +++ b/app/src/main/java/at/bitfire/davdroid/TasksWatcher.kt @@ -0,0 +1,31 @@ +/*************************************************************************************************** + * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. + **************************************************************************************************/ + +package at.bitfire.davdroid + +import android.content.Context +import android.content.Intent +import at.bitfire.davdroid.syncadapter.SyncUtils.updateTaskSync +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch + +class TasksWatcher protected constructor( + context: Context +): PackageChangedReceiver(context) { + + companion object { + + fun watch(context: Context) = TasksWatcher(context) + + } + + + override fun onReceive(context: Context, intent: Intent) { + CoroutineScope(Dispatchers.Default).launch { + updateTaskSync(context) + } + } + +} diff --git a/app/src/main/java/at/bitfire/davdroid/TextTable.kt b/app/src/main/java/at/bitfire/davdroid/TextTable.kt new file mode 100644 index 0000000000000000000000000000000000000000..9928cfe6f9d983739cb7c526ee8865ed453bb004 --- /dev/null +++ b/app/src/main/java/at/bitfire/davdroid/TextTable.kt @@ -0,0 +1,83 @@ +/*************************************************************************************************** + * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. + **************************************************************************************************/ + +package at.bitfire.davdroid + +import org.apache.commons.lang3.StringUtils +import java.util.* + +class TextTable( + vararg val headers: String +) { + + companion object { + + fun indent(str: String, pos: Int): String = + " ".repeat(pos) + + str.split('\n').joinToString("\n" + " ".repeat(pos)) + + } + + + private val lines = mutableListOf>() + + fun addLine(vararg values: Any?) { + if (values.size != headers.size) + throw IllegalArgumentException("Table line must have ${headers.size} column(s)") + lines += values.map { + it?.toString() ?: "—" + }.toTypedArray() + } + + override fun toString(): String { + val sb = StringBuilder() + + val headerWidths = headers.map { it.length } + val colWidths = Array(headers.size) { colIdx -> + Collections.max(listOf(headerWidths[colIdx]) + lines.map { it[colIdx] }.map { it.length }) + } + + // first line + sb.append("\n┌") + for (colIdx in headers.indices) + sb .append(StringUtils.repeat('─', colWidths[colIdx] + 2)) + .append(if (colIdx == headers.size - 1) '┐' else '┬') + sb.append('\n') + + // header + sb.append('│') + for (colIdx in headers.indices) + sb .append(' ') + .append(StringUtils.rightPad(headers[colIdx], colWidths[colIdx] + 1)) + .append('│') + sb.append('\n') + + // separator between header and body + sb.append('├') + for (colIdx in headers.indices) { + sb .append(StringUtils.repeat('─', colWidths[colIdx] + 2)) + .append(if (colIdx == headers.size - 1) '┤' else '┼') + } + sb.append('\n') + + // body + for (line in lines) { + for (colIdx in headers.indices) + sb .append("│ ") + .append(StringUtils.rightPad(line[colIdx], colWidths[colIdx] + 1)) + sb.append("│\n") + } + + // last line + sb.append("└") + for (colIdx in headers.indices) { + sb .append("─".repeat(colWidths[colIdx] + 2)) + .append(if (colIdx == headers.size - 1) '┘' else '┴') + } + sb.append("\n\n") + + return sb.toString() + } + +} \ 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 new file mode 100644 index 0000000000000000000000000000000000000000..e582d1bc8f6d66da499b19d3b720005b77aa9df8 --- /dev/null +++ b/app/src/main/java/at/bitfire/davdroid/db/AppDatabase.kt @@ -0,0 +1,277 @@ +/*************************************************************************************************** + * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. + **************************************************************************************************/ + +package at.bitfire.davdroid.db + +import android.accounts.AccountManager +import android.app.PendingIntent +import android.content.Context +import android.content.Intent +import android.database.sqlite.SQLiteQueryBuilder +import androidx.core.app.NotificationCompat +import androidx.core.app.NotificationManagerCompat +import androidx.core.database.getStringOrNull +import androidx.room.* +import androidx.room.migration.Migration +import androidx.sqlite.db.SupportSQLiteDatabase +import at.bitfire.davdroid.R +import at.bitfire.davdroid.TextTable +import at.bitfire.davdroid.log.Logger +import at.bitfire.davdroid.ui.AccountsActivity +import at.bitfire.davdroid.ui.NotificationUtils +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.android.qualifiers.ApplicationContext +import dagger.hilt.components.SingletonComponent +import java.io.Writer +import javax.inject.Singleton + +@Suppress("ClassName") +@Database(entities = [ + Service::class, + HomeSet::class, + Collection::class, + SyncStats::class, + WebDavDocument::class, + WebDavMount::class +], exportSchema = true, version = 11, autoMigrations = [ + AutoMigration(from = 9, to = 10), + AutoMigration(from = 10, to = 11) +]) +@TypeConverters(Converters::class) +abstract class AppDatabase: RoomDatabase() { + + @Module + @InstallIn(SingletonComponent::class) + object AppDatabaseModule { + @Provides + @Singleton + fun appDatabase(@ApplicationContext context: Context): AppDatabase = + Room.databaseBuilder(context.applicationContext, AppDatabase::class.java, "services.db") + .addMigrations(*migrations) + .fallbackToDestructiveMigration() // as a last fallback, recreate database instead of crashing + .addCallback(object: Callback() { + override fun onDestructiveMigration(db: SupportSQLiteDatabase) { + val nm = NotificationManagerCompat.from(context) + val launcherIntent = Intent(context, AccountsActivity::class.java) + val notify = NotificationUtils.newBuilder(context, NotificationUtils.CHANNEL_GENERAL) + .setSmallIcon(R.drawable.ic_warning_notify) + .setContentTitle(context.getString(R.string.database_destructive_migration_title)) + .setContentText(context.getString(R.string.database_destructive_migration_text)) + .setCategory(NotificationCompat.CATEGORY_ERROR) + .setContentIntent(PendingIntent.getActivity(context, 0, launcherIntent, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE)) + .setAutoCancel(true) + .build() + nm.notify(NotificationUtils.NOTIFY_DATABASE_CORRUPTED, notify) + + // remove all accounts because they're unfortunately useless without database + val am = AccountManager.get(context) + for (account in am.getAccountsByType(context.getString(R.string.account_type))) + am.removeAccount(account, null, null) + } + }) + .build() + } + + companion object { + + // migrations + + val migrations: Array = arrayOf( + object : Migration(8, 9) { + override fun migrate(db: SupportSQLiteDatabase) { + db.execSQL("CREATE TABLE syncstats (" + + "id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT," + + "collectionId INTEGER NOT NULL REFERENCES collection(id) ON DELETE CASCADE," + + "authority TEXT NOT NULL," + + "lastSync INTEGER NOT NULL)") + db.execSQL("CREATE UNIQUE INDEX index_syncstats_collectionId_authority ON syncstats(collectionId, authority)") + + db.execSQL("CREATE INDEX index_collection_url ON collection(url)") + } + }, + + object : Migration(7, 8) { + override fun migrate(db: SupportSQLiteDatabase) { + db.execSQL("ALTER TABLE homeset ADD COLUMN personal INTEGER NOT NULL DEFAULT 1") + db.execSQL("ALTER TABLE collection ADD COLUMN homeSetId INTEGER DEFAULT NULL REFERENCES homeset(id) ON DELETE SET NULL") + db.execSQL("ALTER TABLE collection ADD COLUMN owner TEXT DEFAULT NULL") + db.execSQL("CREATE INDEX index_collection_homeSetId_type ON collection(homeSetId, type)") + } + }, + + object : Migration(6, 7) { + override fun migrate(db: SupportSQLiteDatabase) { + db.execSQL("ALTER TABLE homeset ADD COLUMN privBind INTEGER NOT NULL DEFAULT 1") + db.execSQL("ALTER TABLE homeset ADD COLUMN displayName TEXT DEFAULT NULL") + } + }, + + object : Migration(5, 6) { + override fun migrate(db: SupportSQLiteDatabase) { + val sql = arrayOf( + // migrate "services" to "service": rename columns, make id NOT NULL + "CREATE TABLE service(" + + "id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT," + + "accountName TEXT NOT NULL," + + "authState TEXT," + + "accountType TEXT," + + "addressBookAccountType TEXT," + + "type TEXT NOT NULL," + + "principal TEXT DEFAULT NULL" + + ")", + "CREATE UNIQUE INDEX index_service_accountName_type ON service(accountName, type)", + "INSERT INTO service(id, accountName, authState, accountType, addressBookAccountType, type, principal) SELECT _id, accountName, authState, accountType, addressBookAccountType, service, principal FROM services", + "DROP TABLE services", + + // migrate "homesets" to "homeset": rename columns, make id NOT NULL + "CREATE TABLE homeset(" + + "id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT," + + "serviceId INTEGER NOT NULL," + + "url TEXT NOT NULL," + + "FOREIGN KEY (serviceId) REFERENCES service(id) ON DELETE CASCADE" + + ")", + "CREATE UNIQUE INDEX index_homeset_serviceId_url ON homeset(serviceId, url)", + "INSERT INTO homeset(id, serviceId, url) SELECT _id, serviceID, url FROM homesets", + "DROP TABLE homesets", + + // migrate "collections" to "collection": rename columns, make id NOT NULL + "CREATE TABLE collection(" + + "id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT," + + "serviceId INTEGER NOT NULL," + + "type TEXT NOT NULL," + + "url TEXT NOT NULL," + + "privWriteContent INTEGER NOT NULL DEFAULT 1," + + "privUnbind INTEGER NOT NULL DEFAULT 1," + + "forceReadOnly INTEGER NOT NULL DEFAULT 0," + + "displayName TEXT DEFAULT NULL," + + "description TEXT DEFAULT NULL," + + "color INTEGER DEFAULT NULL," + + "timezone TEXT DEFAULT NULL," + + "supportsVEVENT INTEGER DEFAULT NULL," + + "supportsVTODO INTEGER DEFAULT NULL," + + "supportsVJOURNAL INTEGER DEFAULT NULL," + + "source TEXT DEFAULT NULL," + + "sync INTEGER NOT NULL DEFAULT 0," + + "FOREIGN KEY (serviceId) REFERENCES service(id) ON DELETE CASCADE" + + ")", + "CREATE INDEX index_collection_serviceId_type ON collection(serviceId,type)", + "INSERT INTO collection(id, serviceId, type, url, privWriteContent, privUnbind, forceReadOnly, displayName, description, color, timezone, supportsVEVENT, supportsVTODO, source, sync) " + + "SELECT _id, serviceID, type, url, privWriteContent, privUnbind, forceReadOnly, displayName, description, color, timezone, supportsVEVENT, supportsVTODO, source, sync FROM collections", + "DROP TABLE collections" + ) + sql.forEach { db.execSQL(it) } + } + }, + + object : Migration(4, 5) { + override fun migrate(db: SupportSQLiteDatabase) { + db.execSQL("ALTER TABLE collections ADD COLUMN privWriteContent INTEGER DEFAULT 0 NOT NULL") + db.execSQL("UPDATE collections SET privWriteContent=NOT readOnly") + + db.execSQL("ALTER TABLE collections ADD COLUMN privUnbind INTEGER DEFAULT 0 NOT NULL") + db.execSQL("UPDATE collections SET privUnbind=NOT readOnly") + + // there's no DROP COLUMN in SQLite, so just keep the "readOnly" column + } + }, + + object : Migration(3, 4) { + override fun migrate(db: SupportSQLiteDatabase) { + db.execSQL("ALTER TABLE collections ADD COLUMN forceReadOnly INTEGER DEFAULT 0 NOT NULL") + } + }, + + object : Migration(2, 3) { + override fun migrate(db: SupportSQLiteDatabase) { + // We don't have access to the context in a Room migration now, so + // we will just drop those settings from old DAVx5 versions. + Logger.log.warning("Dropping settings distrustSystemCerts and overrideProxy*") + + /*val edit = PreferenceManager.getDefaultSharedPreferences(context).edit() + try { + db.query("settings", arrayOf("setting", "value"), null, null, null, null, null).use { cursor -> + while (cursor.moveToNext()) { + when (cursor.getString(0)) { + "distrustSystemCerts" -> edit.putBoolean(App.DISTRUST_SYSTEM_CERTIFICATES, cursor.getInt(1) != 0) + "overrideProxy" -> edit.putBoolean(App.OVERRIDE_PROXY, cursor.getInt(1) != 0) + "overrideProxyHost" -> edit.putString(App.OVERRIDE_PROXY_HOST, cursor.getString(1)) + "overrideProxyPort" -> edit.putInt(App.OVERRIDE_PROXY_PORT, cursor.getInt(1)) + + StartupDialogFragment.HINT_GOOGLE_PLAY_ACCOUNTS_REMOVED -> + edit.putBoolean(StartupDialogFragment.HINT_GOOGLE_PLAY_ACCOUNTS_REMOVED, cursor.getInt(1) != 0) + StartupDialogFragment.HINT_OPENTASKS_NOT_INSTALLED -> + edit.putBoolean(StartupDialogFragment.HINT_OPENTASKS_NOT_INSTALLED, cursor.getInt(1) != 0) + } + } + } + db.execSQL("DROP TABLE settings") + } finally { + edit.apply() + }*/ + } + }, + + object : Migration(1, 2) { + override fun migrate(db: SupportSQLiteDatabase) { + db.execSQL("ALTER TABLE collections ADD COLUMN type TEXT NOT NULL DEFAULT ''") + db.execSQL("ALTER TABLE collections ADD COLUMN source TEXT DEFAULT NULL") + db.execSQL("UPDATE collections SET type=(" + + "SELECT CASE service WHEN ? THEN ? ELSE ? END " + + "FROM services WHERE _id=collections.serviceID" + + ")", + arrayOf("caldav", "CALENDAR", "ADDRESS_BOOK")) + } + } + ) + + } + + + // DAOs + + abstract fun serviceDao(): ServiceDao + abstract fun homeSetDao(): HomeSetDao + abstract fun collectionDao(): CollectionDao + abstract fun syncStatsDao(): SyncStatsDao + abstract fun webDavDocumentDao(): WebDavDocumentDao + abstract fun webDavMountDao(): WebDavMountDao + + + // helpers + + fun dump(writer: Writer, ignoreTables: Array) { + val db = openHelper.readableDatabase + db.beginTransactionNonExclusive() + + // iterate through all tables + db.query(SQLiteQueryBuilder.buildQueryString(false, "sqlite_master", arrayOf("name"), "type='table'", null, null, null, null)).use { cursorTables -> + while (cursorTables.moveToNext()) { + val tableName = cursorTables.getString(0) + if (ignoreTables.contains(tableName)) { + writer.append("$tableName: ") + db.query("SELECT COUNT(*) FROM $tableName").use { cursor -> + if (cursor.moveToNext()) + writer.append("${cursor.getInt(0)} row(s), data not listed here\n\n") + } + } else { + writer.append("$tableName\n") + db.query("SELECT * FROM $tableName").use { cursor -> + val table = TextTable(*cursor.columnNames) + val cols = cursor.columnCount + // print rows + while (cursor.moveToNext()) { + val values = Array(cols) { idx -> cursor.getStringOrNull(idx) } + table.addLine(*values) + } + writer.append(table.toString()) + } + } + } + db.endTransaction() + } + } + +} \ No newline at end of file diff --git a/app/src/main/java/at/bitfire/davdroid/model/Collection.kt b/app/src/main/java/at/bitfire/davdroid/db/Collection.kt similarity index 76% rename from app/src/main/java/at/bitfire/davdroid/model/Collection.kt rename to app/src/main/java/at/bitfire/davdroid/db/Collection.kt index 4b527ec7890bb23ec4a68c5ccc95394428db8b71..213f54849a32e9d9da506d21ea8c16892497d296 100644 --- a/app/src/main/java/at/bitfire/davdroid/model/Collection.kt +++ b/app/src/main/java/at/bitfire/davdroid/db/Collection.kt @@ -1,4 +1,8 @@ -package at.bitfire.davdroid.model +/*************************************************************************************************** + * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. + **************************************************************************************************/ + +package at.bitfire.davdroid.db import androidx.room.* import at.bitfire.dav4jvm.Response @@ -6,13 +10,18 @@ import at.bitfire.dav4jvm.UrlUtils import at.bitfire.dav4jvm.property.* import at.bitfire.davdroid.DavUtils import okhttp3.HttpUrl +import okhttp3.HttpUrl.Companion.toHttpUrlOrNull +import org.apache.commons.lang3.StringUtils @Entity(tableName = "collection", foreignKeys = [ - ForeignKey(entity = Service::class, parentColumns = arrayOf("id"), childColumns = arrayOf("serviceId"), onDelete = ForeignKey.CASCADE) + ForeignKey(entity = Service::class, parentColumns = arrayOf("id"), childColumns = arrayOf("serviceId"), onDelete = ForeignKey.CASCADE), + ForeignKey(entity = HomeSet::class, parentColumns = arrayOf("id"), childColumns = arrayOf("homeSetId"), onDelete = ForeignKey.SET_NULL) ], indices = [ - Index("serviceId","type") + Index("serviceId","type"), + Index("homeSetId","type"), + Index("url") ] ) data class Collection( @@ -20,6 +29,7 @@ data class Collection( override var id: Long = 0, var serviceId: Long = 0, + var homeSetId: Long? = null, var type: String, var url: HttpUrl, @@ -30,6 +40,7 @@ data class Collection( var displayName: String? = null, var description: String? = null, + var owner: HttpUrl? = null, // CalDAV only var color: Int? = null, @@ -50,9 +61,13 @@ data class Collection( var source: HttpUrl? = null, /** whether this collection has been selected for synchronization */ - var sync: Boolean = false + var sync: Boolean = true + +): IdEntity { + + @Ignore + var refHomeSet: HomeSet? = null -): IdEntity() { companion object { @@ -83,10 +98,9 @@ data class Collection( privUnbind = privilegeSet.mayUnbind } - var displayName: String? = null - dav[DisplayName::class.java]?.let { - if (!it.displayName.isNullOrEmpty()) - displayName = it.displayName + val displayName = StringUtils.trimToNull(dav[DisplayName::class.java]?.displayName) + val owner = dav[Owner::class.java]?.href?.let { ownerHref -> + dav.href.resolve(ownerHref) } var description: String? = null @@ -115,12 +129,14 @@ data class Collection( supportsVJOURNAL = it.supportsJournal } } else { // Type.WEBCAL - dav[Source::class.java]?.let { source = it.hrefs.firstOrNull()?.let { rawHref -> - val href = rawHref - .replace("^webcal://".toRegex(), "http://") - .replace("^webcals://".toRegex(), "https://") - HttpUrl.parse(href) - } } + dav[Source::class.java]?.let { + source = it.hrefs.firstOrNull()?.let { rawHref -> + val href = rawHref + .replace("^webcal://".toRegex(), "http://") + .replace("^webcals://".toRegex(), "https://") + href.toHttpUrlOrNull() + } + } supportsVEVENT = true } } @@ -132,6 +148,7 @@ data class Collection( privWriteContent = privWriteContent, privUnbind = privUnbind, displayName = displayName, + owner = owner, description = description, color = color, timezone = timezone, @@ -154,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/CollectionDao.kt b/app/src/main/java/at/bitfire/davdroid/db/CollectionDao.kt new file mode 100644 index 0000000000000000000000000000000000000000..6e72ede3fde363077ddd46315e04b2abaa70a730 --- /dev/null +++ b/app/src/main/java/at/bitfire/davdroid/db/CollectionDao.kt @@ -0,0 +1,65 @@ +/*************************************************************************************************** + * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. + **************************************************************************************************/ + +package at.bitfire.davdroid.db + +import androidx.lifecycle.LiveData +import androidx.paging.PagingSource +import androidx.room.Dao +import androidx.room.Insert +import androidx.room.OnConflictStrategy +import androidx.room.Query + +@Dao +interface CollectionDao: SyncableDao { + + @Query("SELECT DISTINCT color FROM collection WHERE serviceId=:id") + fun colorsByServiceLive(id: Long): LiveData> + + @Query("SELECT * FROM collection WHERE id=:id") + fun get(id: Long): Collection? + + @Query("SELECT * FROM collection WHERE id=:id") + fun getLive(id: Long): LiveData + + @Query("SELECT * FROM collection WHERE serviceId=:serviceId") + fun getByService(serviceId: Long): List + + @Query("SELECT * FROM collection WHERE serviceId=:serviceId AND type=:type ORDER BY displayName, url") + fun getByServiceAndType(serviceId: Long, type: String): List + + /** + * Returns collections which + * - support VEVENT and/or VTODO (= supported calendar collections), or + * - have supportsVEVENT = supportsVTODO = null (= address books) + */ + @Query("SELECT * FROM collection WHERE serviceId=:serviceId AND type=:type " + + "AND (supportsVTODO OR supportsVEVENT OR supportsVJOURNAL OR (supportsVEVENT IS NULL AND supportsVTODO IS NULL AND supportsVJOURNAL IS NULL)) ORDER BY displayName, URL") + fun pageByServiceAndType(serviceId: Long, type: String): PagingSource + + @Query("SELECT * FROM collection WHERE serviceId=:serviceId AND sync") + fun getByServiceAndSync(serviceId: Long): List + + @Query("SELECT collection.* FROM collection, homeset WHERE collection.serviceId=:serviceId AND type=:type AND homeSetId=homeset.id AND homeset.personal ORDER BY collection.displayName, collection.url") + fun pagePersonalByServiceAndType(serviceId: Long, type: String): PagingSource + + @Query("SELECT * FROM collection WHERE url=:url") + fun getByUrl(url: String): Collection? + + @Query("SELECT * FROM collection WHERE serviceId=:serviceId AND type='${Collection.TYPE_CALENDAR}' AND supportsVEVENT AND sync ORDER BY displayName, url") + fun getSyncCalendars(serviceId: Long): List + + @Query("SELECT * FROM collection WHERE serviceId=:serviceId AND type='${Collection.TYPE_CALENDAR}' AND (supportsVTODO OR supportsVJOURNAL) AND sync ORDER BY displayName, url") + fun getSyncJtxCollections(serviceId: Long): List + + @Query("SELECT * FROM collection WHERE serviceId=:serviceId AND type='${Collection.TYPE_CALENDAR}' AND supportsVTODO AND sync ORDER BY displayName, url") + fun getSyncTaskLists(serviceId: Long): List + + @Insert(onConflict = OnConflictStrategy.REPLACE) + fun insertOrReplace(collection: Collection) + + @Insert + fun insert(collection: Collection) + +} diff --git a/app/src/main/java/at/bitfire/davdroid/db/Converters.kt b/app/src/main/java/at/bitfire/davdroid/db/Converters.kt new file mode 100644 index 0000000000000000000000000000000000000000..f472cd9606dce7411a96cb8d0edd75eb074290a1 --- /dev/null +++ b/app/src/main/java/at/bitfire/davdroid/db/Converters.kt @@ -0,0 +1,31 @@ +/*************************************************************************************************** + * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. + **************************************************************************************************/ + +package at.bitfire.davdroid.db + +import androidx.room.TypeConverter +import okhttp3.HttpUrl +import okhttp3.HttpUrl.Companion.toHttpUrlOrNull +import okhttp3.MediaType +import okhttp3.MediaType.Companion.toMediaTypeOrNull + +class Converters { + + @TypeConverter + fun httpUrlToString(url: HttpUrl?) = + url?.toString() + + @TypeConverter + fun mediaTypeToString(mediaType: MediaType?) = + mediaType?.toString() + + @TypeConverter + fun stringToHttpUrl(url: String?): HttpUrl? = + url?.toHttpUrlOrNull() + + @TypeConverter + fun stringToMediaType(mimeType: String?): MediaType? = + mimeType?.toMediaTypeOrNull() + +} \ 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 new file mode 100644 index 0000000000000000000000000000000000000000..45af127502199a0b2b481a617f134c592f3e8c61 --- /dev/null +++ b/app/src/main/java/at/bitfire/davdroid/db/Credentials.kt @@ -0,0 +1,23 @@ +/*************************************************************************************************** + * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. + **************************************************************************************************/ + +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 authState: AuthState? = null, + val certificateAlias: String? = null, + val serverUri: URI? = null +) { + + override fun toString(): String { + val maskedPassword = "*****".takeIf { password != null } + return "Credentials(userName=$userName, password=$maskedPassword, certificateAlias=$certificateAlias)" + } + +} \ No newline at end of file diff --git a/app/src/main/java/at/bitfire/davdroid/model/DaoTools.kt b/app/src/main/java/at/bitfire/davdroid/db/DaoTools.kt similarity index 74% rename from app/src/main/java/at/bitfire/davdroid/model/DaoTools.kt rename to app/src/main/java/at/bitfire/davdroid/db/DaoTools.kt index c834c6d8ba58ad202f0e8e1203a09a36f44703ec..49460433eef34f9e4725ba0009df0bac57301b5c 100644 --- a/app/src/main/java/at/bitfire/davdroid/model/DaoTools.kt +++ b/app/src/main/java/at/bitfire/davdroid/db/DaoTools.kt @@ -1,4 +1,8 @@ -package at.bitfire.davdroid.model +/*************************************************************************************************** + * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. + **************************************************************************************************/ + +package at.bitfire.davdroid.db import at.bitfire.davdroid.log.Logger import java.util.logging.Level @@ -35,7 +39,12 @@ class DaoTools(dao: SyncableDao): SyncableDao by dao { delete(old) } } - insert(remainingNew.values.toList()) + + val toInsert = remainingNew.values.toList() + val insertIds = insert(toInsert) + insertIds.withIndex().forEach { (idx, id) -> + toInsert[idx].id = id + } } } \ No newline at end of file diff --git a/app/src/main/java/at/bitfire/davdroid/model/HomeSet.kt b/app/src/main/java/at/bitfire/davdroid/db/HomeSet.kt similarity index 53% rename from app/src/main/java/at/bitfire/davdroid/model/HomeSet.kt rename to app/src/main/java/at/bitfire/davdroid/db/HomeSet.kt index bfc4058e6d0a64edf424f06872bff94f81348a8f..9725fcd1ba79866df02b90d95dc3503b07c28eda 100644 --- a/app/src/main/java/at/bitfire/davdroid/model/HomeSet.kt +++ b/app/src/main/java/at/bitfire/davdroid/db/HomeSet.kt @@ -1,4 +1,8 @@ -package at.bitfire.davdroid.model +/*************************************************************************************************** + * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. + **************************************************************************************************/ + +package at.bitfire.davdroid.db import androidx.room.Entity import androidx.room.ForeignKey @@ -8,7 +12,7 @@ import okhttp3.HttpUrl @Entity(tableName = "homeset", foreignKeys = [ - ForeignKey(entity = Service::class, parentColumns = arrayOf("id"), childColumns = arrayOf("serviceId"), onDelete = ForeignKey.CASCADE) + ForeignKey(entity = Service::class, parentColumns = ["id"], childColumns = ["serviceId"], onDelete = ForeignKey.CASCADE) ], indices = [ // index by service; no duplicate URLs per service @@ -20,9 +24,15 @@ data class HomeSet( override var id: Long, var serviceId: Long, + + /** + * Whether this homeset belongs to the [Service.principal] given by [serviceId]. + */ + var personal: Boolean, + var url: HttpUrl, var privBind: Boolean = true, var displayName: String? = null -): IdEntity() \ No newline at end of file +): IdEntity \ No newline at end of file diff --git a/app/src/main/java/at/bitfire/davdroid/model/HomeSetDao.kt b/app/src/main/java/at/bitfire/davdroid/db/HomeSetDao.kt similarity index 50% rename from app/src/main/java/at/bitfire/davdroid/model/HomeSetDao.kt rename to app/src/main/java/at/bitfire/davdroid/db/HomeSetDao.kt index 076d48bf716de2161be8be61245717ea0fc606e5..9d8c2aa2950c81645ad3ec4608816aacf76d2589 100644 --- a/app/src/main/java/at/bitfire/davdroid/model/HomeSetDao.kt +++ b/app/src/main/java/at/bitfire/davdroid/db/HomeSetDao.kt @@ -1,5 +1,10 @@ -package at.bitfire.davdroid.model +/*************************************************************************************************** + * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. + **************************************************************************************************/ +package at.bitfire.davdroid.db + +import androidx.lifecycle.LiveData import androidx.room.Dao import androidx.room.Insert import androidx.room.OnConflictStrategy @@ -14,8 +19,10 @@ interface HomeSetDao: SyncableDao { @Query("SELECT * FROM homeset WHERE serviceId=:serviceId AND privBind") fun getBindableByService(serviceId: Long): List + @Query("SELECT COUNT(*) FROM homeset WHERE serviceId=:serviceId AND privBind") + fun hasBindableByServiceLive(serviceId: Long): LiveData + @Insert(onConflict = OnConflictStrategy.REPLACE) fun insertOrReplace(homeSet: HomeSet): Long - } \ No newline at end of file diff --git a/app/src/main/java/at/bitfire/davdroid/db/IdEntity.kt b/app/src/main/java/at/bitfire/davdroid/db/IdEntity.kt new file mode 100644 index 0000000000000000000000000000000000000000..ff90c6a08b9e01f1bba1aa8f1a2a6258bcac1277 --- /dev/null +++ b/app/src/main/java/at/bitfire/davdroid/db/IdEntity.kt @@ -0,0 +1,13 @@ +/*************************************************************************************************** + * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. + **************************************************************************************************/ + +package at.bitfire.davdroid.db + +/** + * A model with a primary ID. Must be overriden with `@PrimaryKey(autoGenerate = true)`. + * Required for [DaoTools] so that ID fields of all model classes have the same schema. + */ +interface IdEntity { + var id: Long +} \ No newline at end of file diff --git a/app/src/main/java/at/bitfire/davdroid/model/Service.kt b/app/src/main/java/at/bitfire/davdroid/db/Service.kt similarity index 53% rename from app/src/main/java/at/bitfire/davdroid/model/Service.kt rename to app/src/main/java/at/bitfire/davdroid/db/Service.kt index 80a031f3e5747400eb64ce307ba9d65ba00c079b..1e757519a41ae48e23c4acaf27328429ca9a7ceb 100644 --- a/app/src/main/java/at/bitfire/davdroid/model/Service.kt +++ b/app/src/main/java/at/bitfire/davdroid/db/Service.kt @@ -1,4 +1,8 @@ -package at.bitfire.davdroid.model +/*************************************************************************************************** + * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. + **************************************************************************************************/ + +package at.bitfire.davdroid.db import androidx.room.Entity import androidx.room.Index @@ -12,13 +16,18 @@ import okhttp3.HttpUrl ]) data class Service( @PrimaryKey(autoGenerate = true) - var id: Long, + override var id: Long, var accountName: String, + + var authState: String?, + var accountType: String?, + var addressBookAccountType: String?, + var type: String, var principal: HttpUrl? -) { +): IdEntity { companion object { const val TYPE_CALDAV = "caldav" diff --git a/app/src/main/java/at/bitfire/davdroid/model/ServiceDao.kt b/app/src/main/java/at/bitfire/davdroid/db/ServiceDao.kt similarity index 65% rename from app/src/main/java/at/bitfire/davdroid/model/ServiceDao.kt rename to app/src/main/java/at/bitfire/davdroid/db/ServiceDao.kt index 0e99d2f7d73b86b89050cda746dae0ae207d4d3b..d45bb779dc6348fa86a9795f08ffd75f3f117f4c 100644 --- a/app/src/main/java/at/bitfire/davdroid/model/ServiceDao.kt +++ b/app/src/main/java/at/bitfire/davdroid/db/ServiceDao.kt @@ -1,5 +1,10 @@ -package at.bitfire.davdroid.model +/*************************************************************************************************** + * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. + **************************************************************************************************/ +package at.bitfire.davdroid.db + +import androidx.lifecycle.LiveData import androidx.room.Dao import androidx.room.Insert import androidx.room.OnConflictStrategy @@ -12,13 +17,14 @@ interface ServiceDao { fun getByAccountAndType(accountName: String, type: String): Service? @Query("SELECT id FROM service WHERE accountName=:accountName AND type=:type") - fun getIdByAccountAndType(accountName: String, type: String): Long? + fun getIdByAccountAndType(accountName: String, type: String): LiveData @Query("SELECT * FROM service WHERE id=:id") fun get(id: Long): Service? - @Query("SELECT * FROM service WHERE type=:type") - fun getByType(type: String): List + + @Query("SELECT * FROM service WHERE accountName=:accountName") + fun getByAccountName(accountName: String): Service? @Insert(onConflict = OnConflictStrategy.REPLACE) fun insertOrReplace(service: Service): Long diff --git a/app/src/main/java/at/bitfire/davdroid/model/SyncState.kt b/app/src/main/java/at/bitfire/davdroid/db/SyncState.kt similarity index 82% rename from app/src/main/java/at/bitfire/davdroid/model/SyncState.kt rename to app/src/main/java/at/bitfire/davdroid/db/SyncState.kt index ee374bba5ddad76d6e6029672bd8e35c9c382716..9c89ef7aa602f0acff1cf93f176713877bd5653e 100644 --- a/app/src/main/java/at/bitfire/davdroid/model/SyncState.kt +++ b/app/src/main/java/at/bitfire/davdroid/db/SyncState.kt @@ -1,12 +1,8 @@ -/* - * Copyright © Ricki Hirner (bitfire web engineering). - * All rights reserved. This program and the accompanying materials - * are made available under the terms of the GNU Public License v3.0 - * which accompanies this distribution, and is available at - * http://www.gnu.org/licenses/gpl.html - */ - -package at.bitfire.davdroid.model +/*************************************************************************************************** + * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. + **************************************************************************************************/ + +package at.bitfire.davdroid.db import at.bitfire.dav4jvm.property.SyncToken import org.json.JSONException diff --git a/app/src/main/java/at/bitfire/davdroid/db/SyncStats.kt b/app/src/main/java/at/bitfire/davdroid/db/SyncStats.kt new file mode 100644 index 0000000000000000000000000000000000000000..da6e598eeaa1469b0b9238379c80bc27666bd528 --- /dev/null +++ b/app/src/main/java/at/bitfire/davdroid/db/SyncStats.kt @@ -0,0 +1,28 @@ +/*************************************************************************************************** + * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. + **************************************************************************************************/ + +package at.bitfire.davdroid.db + +import androidx.room.Entity +import androidx.room.ForeignKey +import androidx.room.Index +import androidx.room.PrimaryKey + +@Entity(tableName = "syncstats", + foreignKeys = [ + ForeignKey(childColumns = arrayOf("collectionId"), entity = Collection::class, parentColumns = arrayOf("id"), onDelete = ForeignKey.CASCADE) + ], + indices = [ + Index("collectionId", "authority", unique = true), + ] +) +data class SyncStats( + @PrimaryKey(autoGenerate = true) + val id: Long, + + val collectionId: Long, + val authority: String, + + var lastSync: Long +) \ No newline at end of file diff --git a/app/src/main/java/at/bitfire/davdroid/db/SyncStatsDao.kt b/app/src/main/java/at/bitfire/davdroid/db/SyncStatsDao.kt new file mode 100644 index 0000000000000000000000000000000000000000..da702d5f2d8907988dcee1d0ffee278ba5bfb014 --- /dev/null +++ b/app/src/main/java/at/bitfire/davdroid/db/SyncStatsDao.kt @@ -0,0 +1,17 @@ +/*************************************************************************************************** + * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. + **************************************************************************************************/ + +package at.bitfire.davdroid.db + +import androidx.room.Dao +import androidx.room.Insert +import androidx.room.OnConflictStrategy + +@Dao +interface SyncStatsDao { + + @Insert(onConflict = OnConflictStrategy.REPLACE) + fun insertOrReplace(syncStats: SyncStats) + +} \ No newline at end of file diff --git a/app/src/main/java/at/bitfire/davdroid/db/SyncableDao.kt b/app/src/main/java/at/bitfire/davdroid/db/SyncableDao.kt new file mode 100644 index 0000000000000000000000000000000000000000..745e156fcd478f8f8801aa3e74d49cee2a597b1e --- /dev/null +++ b/app/src/main/java/at/bitfire/davdroid/db/SyncableDao.kt @@ -0,0 +1,23 @@ +/*************************************************************************************************** + * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. + **************************************************************************************************/ + +package at.bitfire.davdroid.db + +import androidx.room.Delete +import androidx.room.Insert +import androidx.room.OnConflictStrategy +import androidx.room.Update + +interface SyncableDao { + + @Insert(onConflict = OnConflictStrategy.REPLACE) + fun insert(items: List): LongArray + + @Update + fun update(item: T) + + @Delete + fun delete(item: T) + +} \ No newline at end of file diff --git a/app/src/main/java/at/bitfire/davdroid/db/WebDavDocument.kt b/app/src/main/java/at/bitfire/davdroid/db/WebDavDocument.kt new file mode 100644 index 0000000000000000000000000000000000000000..8854ab5c93afe44bd082e35c2a0108fe72c9994a --- /dev/null +++ b/app/src/main/java/at/bitfire/davdroid/db/WebDavDocument.kt @@ -0,0 +1,119 @@ +/*************************************************************************************************** + * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. + **************************************************************************************************/ + +package at.bitfire.davdroid.db + +import android.annotation.SuppressLint +import android.os.Bundle +import android.provider.DocumentsContract.Document +import android.webkit.MimeTypeMap +import androidx.room.Entity +import androidx.room.ForeignKey +import androidx.room.Index +import androidx.room.PrimaryKey +import at.bitfire.davdroid.DavUtils.MEDIA_TYPE_OCTET_STREAM +import okhttp3.HttpUrl +import okhttp3.MediaType +import okhttp3.MediaType.Companion.toMediaTypeOrNull +import java.io.FileNotFoundException + +@Entity( + tableName = "webdav_document", + foreignKeys = [ + ForeignKey(entity = WebDavMount::class, parentColumns = ["id"], childColumns = ["mountId"], onDelete = ForeignKey.CASCADE), + ForeignKey(entity = WebDavDocument::class, parentColumns = ["id"], childColumns = ["parentId"], onDelete = ForeignKey.CASCADE) + ], + indices = [ + Index("mountId", "parentId", "name", unique = true) + ] +) +data class WebDavDocument( + + @PrimaryKey(autoGenerate = true) + override var id: Long = 0, + + /** refers to the [WebDavMount] the document belongs to */ + val mountId: Long, + + /** refers to parent document (*null* when this document is a root document) */ + var parentId: Long?, + + /** file name (without any slashes) */ + var name: String, + var isDirectory: Boolean = false, + + var displayName: String? = null, + var mimeType: MediaType? = null, + var eTag: String? = null, + var lastModified: Long? = null, + var size: Long? = null, + + var mayBind: Boolean? = null, + var mayUnbind: Boolean? = null, + var mayWriteContent: Boolean? = null, + + var quotaAvailable: Long? = null, + var quotaUsed: Long? = null + +): IdEntity { + + @SuppressLint("InlinedApi") + fun toBundle(parent: WebDavDocument?): Bundle { + if (parent?.isDirectory == false) + throw IllegalArgumentException("Parent must be a directory") + + val bundle = Bundle() + bundle.putString(Document.COLUMN_DOCUMENT_ID, id.toString()) + bundle.putString(Document.COLUMN_DISPLAY_NAME, name) + + displayName?.let { bundle.putString(Document.COLUMN_SUMMARY, it) } + size?.let { bundle.putLong(Document.COLUMN_SIZE, it) } + lastModified?.let { bundle.putLong(Document.COLUMN_LAST_MODIFIED, it) } + + // see RFC 3744 appendix B for required privileges for the various operations + var flags = Document.FLAG_SUPPORTS_COPY + if (isDirectory) { + bundle.putString(Document.COLUMN_MIME_TYPE, Document.MIME_TYPE_DIR) + if (mayBind != false) + flags += Document.FLAG_DIR_SUPPORTS_CREATE + } else { + val reportedMimeType = mimeType ?: + MimeTypeMap.getSingleton().getMimeTypeFromExtension( + MimeTypeMap.getFileExtensionFromUrl(name) + )?.toMediaTypeOrNull() ?: + MEDIA_TYPE_OCTET_STREAM + + bundle.putString(Document.COLUMN_MIME_TYPE, reportedMimeType.toString()) + if (mimeType?.type == "image") + flags += Document.FLAG_SUPPORTS_THUMBNAIL + if (mayWriteContent != false) + flags += Document.FLAG_SUPPORTS_WRITE + } + if (parent?.mayUnbind != false) + flags += Document.FLAG_SUPPORTS_DELETE or + Document.FLAG_SUPPORTS_MOVE or + Document.FLAG_SUPPORTS_RENAME + bundle.putInt(Document.COLUMN_FLAGS, flags) + + return bundle + } + + fun toHttpUrl(db: AppDatabase): HttpUrl { + val mount = db.webDavMountDao().getById(mountId) + + val segments = mutableListOf(name) + var parentIter = parentId + while (parentIter != null) { + val parent = db.webDavDocumentDao().get(parentIter) ?: throw FileNotFoundException() + segments += parent.name + parentIter = parent.parentId + } + + val builder = mount.url.newBuilder() + for (segment in segments.reversed()) + builder.addPathSegment(segment) + return builder.build() + } + +} \ No newline at end of file diff --git a/app/src/main/java/at/bitfire/davdroid/db/WebDavDocumentDao.kt b/app/src/main/java/at/bitfire/davdroid/db/WebDavDocumentDao.kt new file mode 100644 index 0000000000000000000000000000000000000000..ec6556fc96e06edc1a2780e58c8a2483b86a15c7 --- /dev/null +++ b/app/src/main/java/at/bitfire/davdroid/db/WebDavDocumentDao.kt @@ -0,0 +1,52 @@ +/*************************************************************************************************** + * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. + **************************************************************************************************/ + +package at.bitfire.davdroid.db + +import androidx.lifecycle.LiveData +import androidx.room.* + +@Dao +interface WebDavDocumentDao: SyncableDao { + + @Query("SELECT * FROM webdav_document WHERE id=:id") + fun get(id: Long): WebDavDocument? + + @Query("SELECT * FROM webdav_document WHERE mountId=:mountId AND (parentId=:parentId OR (parentId IS NULL AND :parentId IS NULL)) AND name=:name") + fun getByParentAndName(mountId: Long, parentId: Long?, name: String): WebDavDocument? + + @Query("SELECT * FROM webdav_document WHERE parentId=:parentId") + fun getChildren(parentId: Long): List + + @Query("SELECT * FROM webdav_document WHERE parentId IS NULL") + fun getRootsLive(): LiveData> + + @Insert(onConflict = OnConflictStrategy.REPLACE) + fun insertOrReplace(document: WebDavDocument): Long + + @Query("DELETE FROM webdav_document WHERE parentId=:parentId") + fun removeChildren(parentId: Long) + + + // complex operations + + @Transaction + fun getOrCreateRoot(mount: WebDavMount): WebDavDocument { + getByParentAndName(mount.id, null, "")?.let { existing -> + return existing + } + + val newDoc = WebDavDocument( + mountId = mount.id, + parentId = null, + name = "", + isDirectory = true, + displayName = mount.name + ) + val id = insertOrReplace(newDoc) + newDoc.id = id + return newDoc + } + +} \ No newline at end of file diff --git a/app/src/main/java/at/bitfire/davdroid/db/WebDavMount.kt b/app/src/main/java/at/bitfire/davdroid/db/WebDavMount.kt new file mode 100644 index 0000000000000000000000000000000000000000..89cb8ddd93005f4c3b6937e15e83d3a8e0930fe6 --- /dev/null +++ b/app/src/main/java/at/bitfire/davdroid/db/WebDavMount.kt @@ -0,0 +1,24 @@ +/*************************************************************************************************** + * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. + **************************************************************************************************/ + +package at.bitfire.davdroid.db + +import androidx.room.Entity +import androidx.room.PrimaryKey +import okhttp3.HttpUrl + +@Entity(tableName = "webdav_mount") +data class WebDavMount( + @PrimaryKey(autoGenerate = true) + override var id: Long = 0, + + /** display name of the WebDAV mount */ + var name: String, + + /** URL of the WebDAV service, including trailing slash */ + var url: HttpUrl + + // credentials are stored using CredentialsStore + +): IdEntity \ No newline at end of file diff --git a/app/src/main/java/at/bitfire/davdroid/db/WebDavMountDao.kt b/app/src/main/java/at/bitfire/davdroid/db/WebDavMountDao.kt new file mode 100644 index 0000000000000000000000000000000000000000..827d0ff65415a7625cee443345793662c1a5bf08 --- /dev/null +++ b/app/src/main/java/at/bitfire/davdroid/db/WebDavMountDao.kt @@ -0,0 +1,31 @@ +/*************************************************************************************************** + * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. + **************************************************************************************************/ + +package at.bitfire.davdroid.db + +import androidx.lifecycle.LiveData +import androidx.room.Dao +import androidx.room.Delete +import androidx.room.Insert +import androidx.room.Query + +@Dao +interface WebDavMountDao { + + @Delete + fun delete(mount: WebDavMount) + + @Query("SELECT * FROM webdav_mount ORDER BY name, url") + fun getAll(): List + + @Query("SELECT * FROM webdav_mount ORDER BY name, url") + fun getAllLive(): LiveData> + + @Query("SELECT * FROM webdav_mount WHERE id=:id") + fun getById(id: Long): WebDavMount + + @Insert + fun insert(mount: WebDavMount): Long + +} \ No newline at end of file diff --git a/app/src/main/java/at/bitfire/davdroid/log/LogcatHandler.kt b/app/src/main/java/at/bitfire/davdroid/log/LogcatHandler.kt index 83c317aba5f79390a95df3db6d7a89c07abe6f2a..20823083cabf95500a016d6125297dfa53bc9b8b 100644 --- a/app/src/main/java/at/bitfire/davdroid/log/LogcatHandler.kt +++ b/app/src/main/java/at/bitfire/davdroid/log/LogcatHandler.kt @@ -1,10 +1,6 @@ -/* - * Copyright © Ricki Hirner (bitfire web engineering). - * All rights reserved. This program and the accompanying materials - * are made available under the terms of the GNU Public License v3.0 - * which accompanies this distribution, and is available at - * http://www.gnu.org/licenses/gpl.html - */ +/*************************************************************************************************** + * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. + **************************************************************************************************/ package at.bitfire.davdroid.log diff --git a/app/src/main/java/at/bitfire/davdroid/log/Logger.kt b/app/src/main/java/at/bitfire/davdroid/log/Logger.kt index 1e6ade5462875c407f260d38f2da12f7e4089769..b366de799b3ed74932810a48f925758b1bfc8f4e 100644 --- a/app/src/main/java/at/bitfire/davdroid/log/Logger.kt +++ b/app/src/main/java/at/bitfire/davdroid/log/Logger.kt @@ -1,44 +1,43 @@ -/* - * Copyright © Ricki Hirner (bitfire web engineering). - * All rights reserved. This program and the accompanying materials - * are made available under the terms of the GNU Public License v3.0 - * which accompanies this distribution, and is available at - * http://www.gnu.org/licenses/gpl.html - */ +/*************************************************************************************************** + * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. + **************************************************************************************************/ package at.bitfire.davdroid.log -import android.annotation.SuppressLint +import android.app.Application import android.app.PendingIntent -import android.content.Context import android.content.Intent import android.content.SharedPreferences +import android.os.Process import android.util.Log import android.widget.Toast import androidx.core.app.NotificationCompat import androidx.core.app.NotificationManagerCompat -import androidx.core.content.FileProvider import androidx.preference.PreferenceManager +import at.bitfire.davdroid.BuildConfig import at.bitfire.davdroid.R import at.bitfire.davdroid.ui.AppSettingsActivity +import at.bitfire.davdroid.ui.DebugInfoActivity import at.bitfire.davdroid.ui.NotificationUtils import java.io.File import java.io.IOException +import java.util.* import java.util.logging.FileHandler import java.util.logging.Level -@SuppressLint("StaticFieldLeak") // we'll only keep an app context object Logger : SharedPreferences.OnSharedPreferenceChangeListener { + const val LOGGER_NAME = "davx5" private const val LOG_TO_FILE = "log_to_file" - val log = java.util.logging.Logger.getLogger("davx5") + val log: java.util.logging.Logger = java.util.logging.Logger.getLogger(LOGGER_NAME) - private lateinit var context: Context + private lateinit var context: Application private lateinit var preferences: SharedPreferences - fun initialize(someContext: Context) { - context = someContext.applicationContext + + fun initialize(app: Application) { + context = app preferences = PreferenceManager.getDefaultSharedPreferences(context) preferences.registerOnSharedPreferenceChangeListener(this) @@ -52,9 +51,10 @@ object Logger : SharedPreferences.OnSharedPreferenceChangeListener { } } + @Synchronized private fun reinitialize() { val logToFile = preferences.getBoolean(LOG_TO_FILE, false) - val logVerbose = logToFile || Log.isLoggable(log.name, Log.DEBUG) + val logVerbose = logToFile || BuildConfig.DEBUG || Log.isLoggable(log.name, Log.DEBUG) log.info("Verbose logging: $logVerbose; to file: $logToFile") @@ -62,67 +62,73 @@ object Logger : SharedPreferences.OnSharedPreferenceChangeListener { val rootLogger = java.util.logging.Logger.getLogger("") rootLogger.level = if (logVerbose) Level.ALL else Level.INFO - // remove all handlers and add our own logcat handler + // reset all handlers and add our own logcat handler rootLogger.useParentHandlers = false - rootLogger.handlers.forEach { rootLogger.removeHandler(it) } + rootLogger.handlers.forEach { handler -> + rootLogger.removeHandler(handler) + if (handler is FileHandler) // gracefully close previous verbose-logging FileHandlers + handler.close() + } rootLogger.addHandler(LogcatHandler) val nm = NotificationManagerCompat.from(context) // log to external file according to preferences if (logToFile) { - val builder = NotificationUtils.newBuilder(context, NotificationUtils.CHANNEL_DEBUG) - builder .setSmallIcon(R.drawable.ic_sd_card_notify) - .setContentTitle(context.getString(R.string.logging_notification_title)) - - val logDir = debugDir(context) ?: return + val logDir = debugDir() ?: return val logFile = File(logDir, "davx5-log.txt") + if (logFile.createNewFile()) + logFile.writeText("Log file created at ${Date()}; PID ${Process.myPid()}; UID ${Process.myUid()}\n") try { - val fileHandler = FileHandler(logFile.toString(), true) - fileHandler.formatter = PlainTextFormatter.DEFAULT + val fileHandler = FileHandler(logFile.toString(), true).apply { + formatter = PlainTextFormatter.DEFAULT + } rootLogger.addHandler(fileHandler) + log.info("Now logging to file: $logFile") - val prefIntent = Intent(context, AppSettingsActivity::class.java) - prefIntent.putExtra(AppSettingsActivity.EXTRA_SCROLL_TO, LOG_TO_FILE) - prefIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) - - builder .setContentText(logDir.path) + val builder = NotificationUtils.newBuilder(context, NotificationUtils.CHANNEL_DEBUG) + builder .setSmallIcon(R.drawable.ic_sd_card_notify) + .setContentTitle(context.getString(R.string.app_settings_logging)) .setCategory(NotificationCompat.CATEGORY_STATUS) .setPriority(NotificationCompat.PRIORITY_HIGH) - .setContentText(context.getString(R.string.logging_notification_text)) - .setContentIntent(PendingIntent.getActivity(context, 0, prefIntent, PendingIntent.FLAG_UPDATE_CURRENT)) + .setContentText(context.getString(R.string.logging_notification_text, context.getString(R.string.app_name))) .setOngoing(true) - // add "Share" action - val logFileUri = FileProvider.getUriForFile(context, context.getString(R.string.authority_debug_provider), logFile) - log.fine("Now logging to file: $logFile -> $logFileUri") - - val shareIntent = Intent(Intent.ACTION_SEND) - shareIntent.putExtra(Intent.EXTRA_SUBJECT, "DAVx⁵ logs") - shareIntent.putExtra(Intent.EXTRA_STREAM, logFileUri) - shareIntent.type = "text/plain" - shareIntent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_ACTIVITY_NEW_TASK) - val chooserIntent = Intent.createChooser(shareIntent, null) - val shareAction = NotificationCompat.Action.Builder(R.drawable.ic_share_notify, - context.getString(R.string.logging_notification_send_log), - PendingIntent.getActivity(context, 0, chooserIntent, PendingIntent.FLAG_UPDATE_CURRENT)) - builder.addAction(shareAction.build()) + val shareIntent = DebugInfoActivity.IntentBuilder(context) + .withLogFile(logFile) + .newTask() + .share() + val pendingShare = PendingIntent.getActivity(context, 0, shareIntent, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE) + builder.addAction(NotificationCompat.Action.Builder( + R.drawable.ic_share, + context.getString(R.string.logging_notification_view_share), + pendingShare + ).build()) + + val prefIntent = Intent(context, AppSettingsActivity::class.java) + prefIntent.putExtra(AppSettingsActivity.EXTRA_SCROLL_TO, LOG_TO_FILE) + prefIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + val pendingPref = PendingIntent.getActivity(context, 0, prefIntent, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE) + builder.addAction(NotificationCompat.Action.Builder( + R.drawable.ic_settings, + context.getString(R.string.logging_notification_disable), + pendingPref + ).build()) + + nm.notify(NotificationUtils.NOTIFY_VERBOSE_LOGGING, builder.build()) } catch(e: IOException) { log.log(Level.SEVERE, "Couldn't create log file", e) Toast.makeText(context, context.getString(R.string.logging_couldnt_create_file), Toast.LENGTH_LONG).show() } - - nm.notify(NotificationUtils.NOTIFY_EXTERNAL_FILE_LOGGING, builder.build()) } else { - nm.cancel(NotificationUtils.NOTIFY_EXTERNAL_FILE_LOGGING) - - // delete old logs - debugDir(context)?.deleteRecursively() + // verbose logging is disabled -> cancel notification and remove old logs + nm.cancel(NotificationUtils.NOTIFY_VERBOSE_LOGGING) + debugDir()?.deleteRecursively() } } - private fun debugDir(context: Context): File? { + private fun debugDir(): File? { val dir = File(context.filesDir, "debug") if (dir.exists() && dir.isDirectory) return dir diff --git a/app/src/main/java/at/bitfire/davdroid/log/PlainTextFormatter.kt b/app/src/main/java/at/bitfire/davdroid/log/PlainTextFormatter.kt index d546b73c17498c9cd6420fa2339268e693cbadaf..fd902e3ce3dd96a70cd7754d5acb0c65a181eee6 100644 --- a/app/src/main/java/at/bitfire/davdroid/log/PlainTextFormatter.kt +++ b/app/src/main/java/at/bitfire/davdroid/log/PlainTextFormatter.kt @@ -1,16 +1,13 @@ -/* - * Copyright © Ricki Hirner (bitfire web engineering). - * All rights reserved. This program and the accompanying materials - * are made available under the terms of the GNU Public License v3.0 - * which accompanies this distribution, and is available at - * http://www.gnu.org/licenses/gpl.html - */ +/*************************************************************************************************** + * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. + **************************************************************************************************/ package at.bitfire.davdroid.log import org.apache.commons.lang3.StringUtils import org.apache.commons.lang3.exception.ExceptionUtils import org.apache.commons.lang3.time.DateFormatUtils +import java.util.* import java.util.logging.Formatter import java.util.logging.LogRecord @@ -29,7 +26,7 @@ class PlainTextFormatter private constructor( val builder = StringBuilder() if (!logcat) - builder .append(DateFormatUtils.format(r.millis, "yyyy-MM-dd HH:mm:ss")) + builder .append(DateFormatUtils.format(r.millis, "yyyy-MM-dd HH:mm:ss", Locale.ROOT)) .append(" ").append(r.threadID).append(" ") val className = shortClassName(r.sourceClassName) diff --git a/app/src/main/java/at/bitfire/davdroid/log/StringHandler.kt b/app/src/main/java/at/bitfire/davdroid/log/StringHandler.kt index 4938319e8d7dde794578883d4ba9ae70fe4d2866..748bdcfe25bd91555c59b9bf1413d5dd0f80e779 100644 --- a/app/src/main/java/at/bitfire/davdroid/log/StringHandler.kt +++ b/app/src/main/java/at/bitfire/davdroid/log/StringHandler.kt @@ -1,10 +1,6 @@ -/* - * Copyright © Ricki Hirner (bitfire web engineering). - * All rights reserved. This program and the accompanying materials - * are made available under the terms of the GNU Public License v3.0 - * which accompanies this distribution, and is available at - * http://www.gnu.org/licenses/gpl.html - */ +/*************************************************************************************************** + * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. + **************************************************************************************************/ package at.bitfire.davdroid.log diff --git a/app/src/main/java/at/bitfire/davdroid/model/AppDatabase.kt b/app/src/main/java/at/bitfire/davdroid/model/AppDatabase.kt deleted file mode 100644 index 2738e95d6577839d4b4e8021cacc4bfbe6921d7d..0000000000000000000000000000000000000000 --- a/app/src/main/java/at/bitfire/davdroid/model/AppDatabase.kt +++ /dev/null @@ -1,224 +0,0 @@ -package at.bitfire.davdroid.model - -import android.content.Context -import android.database.sqlite.SQLiteException -import android.database.sqlite.SQLiteQueryBuilder -import androidx.room.Database -import androidx.room.Room -import androidx.room.RoomDatabase -import androidx.room.TypeConverters -import androidx.room.migration.Migration -import androidx.sqlite.db.SupportSQLiteDatabase -import at.bitfire.davdroid.log.Logger - -@Suppress("ClassName") -@Database(entities = [ - Service::class, - HomeSet::class, - Collection::class -], version = 7) -@TypeConverters(Converters::class) -abstract class AppDatabase: RoomDatabase() { - - abstract fun serviceDao(): ServiceDao - abstract fun homeSetDao(): HomeSetDao - abstract fun collectionDao(): CollectionDao - - companion object { - - private var INSTANCE: AppDatabase? = null - - @Synchronized - fun getInstance(context: Context): AppDatabase { - INSTANCE?.let { return it } - - val db = Room.databaseBuilder(context.applicationContext, AppDatabase::class.java, "services.db") - .addMigrations( - Migration1_2, - Migration2_3, - Migration3_4, - Migration4_5, - Migration5_6, - Migration6_7 - ) - .fallbackToDestructiveMigration() // as a last fallback, recreate database instead of crashing - .build() - INSTANCE = db - - return db - } - - } - - fun dump(sb: StringBuilder) { - val db = openHelper.readableDatabase - db.beginTransactionNonExclusive() - - // iterate through all tables - db.query(SQLiteQueryBuilder.buildQueryString(false, "sqlite_master", arrayOf("name"), "type='table'", null, null, null, null)).use { cursorTables -> - while (cursorTables.moveToNext()) { - val table = cursorTables.getString(0) - sb.append(table).append("\n") - db.query("SELECT * FROM $table").use { cursor -> - // print columns - val cols = cursor.columnCount - sb.append("\t| ") - for (i in 0 until cols) - sb .append(" ") - .append(cursor.getColumnName(i)) - .append(" |") - sb.append("\n") - - // print rows - while (cursor.moveToNext()) { - sb.append("\t| ") - for (i in 0 until cols) { - sb.append(" ") - try { - val value = cursor.getString(i) - if (value != null) - sb.append(value - .replace("\r", "") - .replace("\n", "")) - else - sb.append("") - - } catch (e: SQLiteException) { - sb.append("") - } - sb.append(" |") - } - sb.append("\n") - } - sb.append("----------\n") - } - } - db.endTransaction() - } - } - - - // migrations - - object Migration6_7: Migration(6, 7) { - override fun migrate(db: SupportSQLiteDatabase) { - db.execSQL("ALTER TABLE homeset ADD COLUMN privBind INTEGER NOT NULL DEFAULT 1") - db.execSQL("ALTER TABLE homeset ADD COLUMN displayName TEXT DEFAULT NULL") - } - } - - object Migration5_6: Migration(5, 6) { - override fun migrate(db: SupportSQLiteDatabase) { - val sql = arrayOf( - // migrate "services" to "service": rename columns, make id NOT NULL - "CREATE TABLE service(" + - "id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT," + - "accountName TEXT NOT NULL," + - "type TEXT NOT NULL," + - "principal TEXT DEFAULT NULL" + - ")", - "CREATE UNIQUE INDEX index_service_accountName_type ON service(accountName, type)", - "INSERT INTO service(id, accountName, type, principal) SELECT _id, accountName, service, principal FROM services", - "DROP TABLE services", - - // migrate "homesets" to "homeset": rename columns, make id NOT NULL - "CREATE TABLE homeset(" + - "id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT," + - "serviceId INTEGER NOT NULL," + - "url TEXT NOT NULL," + - "FOREIGN KEY (serviceId) REFERENCES service(id) ON DELETE CASCADE" + - ")", - "CREATE UNIQUE INDEX index_homeset_serviceId_url ON homeset(serviceId, url)", - "INSERT INTO homeset(id, serviceId, url) SELECT _id, serviceID, url FROM homesets", - "DROP TABLE homesets", - - // migrate "collections" to "collection": rename columns, make id NOT NULL - "CREATE TABLE collection(" + - "id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT," + - "serviceId INTEGER NOT NULL," + - "type TEXT NOT NULL," + - "url TEXT NOT NULL," + - "privWriteContent INTEGER NOT NULL DEFAULT 1," + - "privUnbind INTEGER NOT NULL DEFAULT 1," + - "forceReadOnly INTEGER NOT NULL DEFAULT 0," + - "displayName TEXT DEFAULT NULL," + - "description TEXT DEFAULT NULL," + - "color INTEGER DEFAULT NULL," + - "timezone TEXT DEFAULT NULL," + - "supportsVEVENT INTEGER DEFAULT NULL," + - "supportsVTODO INTEGER DEFAULT NULL," + - "supportsVJOURNAL INTEGER DEFAULT NULL," + - "source TEXT DEFAULT NULL," + - "sync INTEGER NOT NULL DEFAULT 0," + - "FOREIGN KEY (serviceId) REFERENCES service(id) ON DELETE CASCADE" + - ")", - "CREATE INDEX index_collection_serviceId_type ON collection(serviceId,type)", - "INSERT INTO collection(id, serviceId, type, url, privWriteContent, privUnbind, forceReadOnly, displayName, description, color, timezone, supportsVEVENT, supportsVTODO, source, sync) " + - "SELECT _id, serviceID, type, url, privWriteContent, privUnbind, forceReadOnly, displayName, description, color, timezone, supportsVEVENT, supportsVTODO, source, sync FROM collections", - "DROP TABLE collections" - ) - sql.forEach { db.execSQL(it) } - } - } - - object Migration4_5: Migration(4, 5) { - override fun migrate(db: SupportSQLiteDatabase) { - db.execSQL("ALTER TABLE collections ADD COLUMN privWriteContent INTEGER DEFAULT 0 NOT NULL") - db.execSQL("UPDATE collections SET privWriteContent=NOT readOnly") - - db.execSQL("ALTER TABLE collections ADD COLUMN privUnbind INTEGER DEFAULT 0 NOT NULL") - db.execSQL("UPDATE collections SET privUnbind=NOT readOnly") - - // there's no DROP COLUMN in SQLite, so just keep the "readOnly" column - } - } - - object Migration3_4: Migration(3, 4) { - override fun migrate(db: SupportSQLiteDatabase) { - db.execSQL("ALTER TABLE collections ADD COLUMN forceReadOnly INTEGER DEFAULT 0 NOT NULL") - } - } - - object Migration2_3: Migration(2, 3) { - override fun migrate(db: SupportSQLiteDatabase) { - // We don't have access to the context in a Room migration now, so - // we will just drop those settings from old DAVx5 versions. - Logger.log.warning("Dropping settings distrustSystemCerts and overrideProxy*") - - /*val edit = PreferenceManager.getDefaultSharedPreferences(context).edit() - try { - db.query("settings", arrayOf("setting", "value"), null, null, null, null, null).use { cursor -> - while (cursor.moveToNext()) { - when (cursor.getString(0)) { - "distrustSystemCerts" -> edit.putBoolean(App.DISTRUST_SYSTEM_CERTIFICATES, cursor.getInt(1) != 0) - "overrideProxy" -> edit.putBoolean(App.OVERRIDE_PROXY, cursor.getInt(1) != 0) - "overrideProxyHost" -> edit.putString(App.OVERRIDE_PROXY_HOST, cursor.getString(1)) - "overrideProxyPort" -> edit.putInt(App.OVERRIDE_PROXY_PORT, cursor.getInt(1)) - - StartupDialogFragment.HINT_GOOGLE_PLAY_ACCOUNTS_REMOVED -> - edit.putBoolean(StartupDialogFragment.HINT_GOOGLE_PLAY_ACCOUNTS_REMOVED, cursor.getInt(1) != 0) - StartupDialogFragment.HINT_OPENTASKS_NOT_INSTALLED -> - edit.putBoolean(StartupDialogFragment.HINT_OPENTASKS_NOT_INSTALLED, cursor.getInt(1) != 0) - } - } - } - db.execSQL("DROP TABLE settings") - } finally { - edit.apply() - }*/ - } - } - - object Migration1_2: Migration(1, 2) { - override fun migrate(db: SupportSQLiteDatabase) { - db.execSQL("ALTER TABLE collections ADD COLUMN type TEXT NOT NULL DEFAULT ''") - db.execSQL("ALTER TABLE collections ADD COLUMN source TEXT DEFAULT NULL") - db.execSQL("UPDATE collections SET type=(" + - "SELECT CASE service WHEN ? THEN ? ELSE ? END " + - "FROM services WHERE _id=collections.serviceID" + - ")", - arrayOf("caldav", "CALENDAR", "ADDRESS_BOOK")) - } - } - -} \ No newline at end of file diff --git a/app/src/main/java/at/bitfire/davdroid/model/CollectionDao.kt b/app/src/main/java/at/bitfire/davdroid/model/CollectionDao.kt deleted file mode 100644 index 6818c63b0bff673d7da20397182ed96551a41509..0000000000000000000000000000000000000000 --- a/app/src/main/java/at/bitfire/davdroid/model/CollectionDao.kt +++ /dev/null @@ -1,43 +0,0 @@ -package at.bitfire.davdroid.model - -import androidx.lifecycle.LiveData -import androidx.paging.DataSource -import androidx.room.Dao -import androidx.room.Insert -import androidx.room.OnConflictStrategy -import androidx.room.Query - -@Dao -interface CollectionDao: SyncableDao { - - @Query("SELECT * FROM collection WHERE id=:id") - fun get(id: Long): Collection? - - @Query("SELECT * FROM collection WHERE serviceId=:serviceId") - fun getByService(serviceId: Long): List - - @Query("SELECT * FROM collection WHERE serviceId=:serviceId AND type=:type") - fun getByServiceAndType(serviceId: Long, type: String): List - - @Query("SELECT * FROM collection WHERE serviceId=:serviceId AND type=:type ORDER BY displayName, url") - fun pageByServiceAndType(serviceId: Long, type: String): DataSource.Factory - - @Query("SELECT * FROM collection WHERE serviceId=:serviceId AND sync ORDER BY displayName, url") - fun getByServiceAndSync(serviceId: Long): List - - @Query("SELECT COUNT(*) FROM collection WHERE serviceId=:serviceId AND sync") - fun observeHasSyncByService(serviceId: Long): LiveData - - @Query("SELECT * FROM collection WHERE serviceId=:serviceId AND supportsVEVENT AND sync ORDER BY displayName, url") - fun getSyncCalendars(serviceId: Long): List - - @Query("SELECT * FROM collection WHERE serviceId=:serviceId AND supportsVTODO AND sync ORDER BY displayName, url") - fun getSyncTaskLists(serviceId: Long): List - - @Insert(onConflict = OnConflictStrategy.REPLACE) - fun insertOrReplace(collection: Collection) - - @Insert - fun insert(collection: Collection) - -} diff --git a/app/src/main/java/at/bitfire/davdroid/model/Converters.kt b/app/src/main/java/at/bitfire/davdroid/model/Converters.kt deleted file mode 100644 index 6b56bb26e58591f569beb98c095c8a7595123271..0000000000000000000000000000000000000000 --- a/app/src/main/java/at/bitfire/davdroid/model/Converters.kt +++ /dev/null @@ -1,16 +0,0 @@ -package at.bitfire.davdroid.model - -import androidx.room.TypeConverter -import okhttp3.HttpUrl - -class Converters { - - @TypeConverter - fun httpUrlToString(url: HttpUrl?) = - url?.toString() - - @TypeConverter - fun stringToHttpUrl(url: String?): HttpUrl? = - url?.let { HttpUrl.parse(it) } - -} \ No newline at end of file diff --git a/app/src/main/java/at/bitfire/davdroid/model/Credentials.kt b/app/src/main/java/at/bitfire/davdroid/model/Credentials.kt deleted file mode 100644 index c5dbd53d09d64b9fe9bb05a4d6492443ab67455f..0000000000000000000000000000000000000000 --- a/app/src/main/java/at/bitfire/davdroid/model/Credentials.kt +++ /dev/null @@ -1,38 +0,0 @@ -/* - * Copyright © Ricki Hirner (bitfire web engineering). - * All rights reserved. This program and the accompanying materials - * are made available under the terms of the GNU Public License v3.0 - * which accompanies this distribution, and is available at - * http://www.gnu.org/licenses/gpl.html - */ - -package at.bitfire.davdroid.model - -class Credentials( - val userName: String? = null, - val password: String? = null, - val certificateAlias: String? = null -) { - - enum class Type { - UsernamePassword, - ClientCertificate - } - - val type: Type - - init { - type = when { - !certificateAlias.isNullOrEmpty() -> - Type.ClientCertificate - !userName.isNullOrEmpty() && !password.isNullOrEmpty() -> - Type.UsernamePassword - else -> - throw IllegalArgumentException("Either username/password or certificate alias must be set") - } - } - - override fun toString() = - "Credentials(type=$type, userName=$userName, certificateAlias=$certificateAlias)" - -} \ No newline at end of file diff --git a/app/src/main/java/at/bitfire/davdroid/model/IdEntity.kt b/app/src/main/java/at/bitfire/davdroid/model/IdEntity.kt deleted file mode 100644 index ae9f176cbf8c7fa131ec349d8e1048df9b3e8486..0000000000000000000000000000000000000000 --- a/app/src/main/java/at/bitfire/davdroid/model/IdEntity.kt +++ /dev/null @@ -1,5 +0,0 @@ -package at.bitfire.davdroid.model - -abstract class IdEntity { - abstract var id: Long -} \ No newline at end of file diff --git a/app/src/main/java/at/bitfire/davdroid/model/SyncableDao.kt b/app/src/main/java/at/bitfire/davdroid/model/SyncableDao.kt deleted file mode 100644 index cac2180fb019d6a6ea37e2f06863763cdf264ba0..0000000000000000000000000000000000000000 --- a/app/src/main/java/at/bitfire/davdroid/model/SyncableDao.kt +++ /dev/null @@ -1,18 +0,0 @@ -package at.bitfire.davdroid.model - -import androidx.room.Delete -import androidx.room.Insert -import androidx.room.Update - -interface SyncableDao { - - @Insert - fun insert(items: List) - - @Update - fun update(item: T) - - @Delete - fun delete(item: T) - -} \ No newline at end of file diff --git a/app/src/main/java/at/bitfire/davdroid/model/UnknownProperties.kt b/app/src/main/java/at/bitfire/davdroid/model/UnknownProperties.kt deleted file mode 100644 index f53654bb66a83e4b894d0848e2a9ea7115c33037..0000000000000000000000000000000000000000 --- a/app/src/main/java/at/bitfire/davdroid/model/UnknownProperties.kt +++ /dev/null @@ -1,21 +0,0 @@ -/* - * Copyright © Ricki Hirner (bitfire web engineering). - * All rights reserved. This program and the accompanying materials - * are made available under the terms of the GNU Public License v3.0 - * which accompanies this distribution, and is available at - * http://www.gnu.org/licenses/gpl.html - */ - -package at.bitfire.davdroid.model - -import android.provider.ContactsContract.RawContacts - -object UnknownProperties { - - const val CONTENT_ITEM_TYPE = "x.davdroid/unknown-properties" - - const val MIMETYPE = RawContacts.Data.MIMETYPE - const val RAW_CONTACT_ID = RawContacts.Data.RAW_CONTACT_ID - const val UNKNOWN_PROPERTIES = RawContacts.Data.DATA1 - -} diff --git a/app/src/main/java/at/bitfire/davdroid/resource/LocalAddress.kt b/app/src/main/java/at/bitfire/davdroid/resource/LocalAddress.kt index 76062a01c06696ed39fe6f0d9855e85ec57bd0dd..206cba0d656cf2271e8625b4f1689db8d216556d 100644 --- a/app/src/main/java/at/bitfire/davdroid/resource/LocalAddress.kt +++ b/app/src/main/java/at/bitfire/davdroid/resource/LocalAddress.kt @@ -1,10 +1,6 @@ -/* - * Copyright © Ricki Hirner (bitfire web engineering). - * All rights reserved. This program and the accompanying materials - * are made available under the terms of the GNU Public License v3.0 - * which accompanies this distribution, and is available at - * http://www.gnu.org/licenses/gpl.html - */ +/*************************************************************************************************** + * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. + **************************************************************************************************/ package at.bitfire.davdroid.resource 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 d895647913f9715dd829cbd83c8c58d97c35644f..98076add8106154013d729f71b3a44848bcdacca 100644 --- a/app/src/main/java/at/bitfire/davdroid/resource/LocalAddressBook.kt +++ b/app/src/main/java/at/bitfire/davdroid/resource/LocalAddressBook.kt @@ -1,10 +1,6 @@ -/* - * Copyright © Ricki Hirner (bitfire web engineering). - * All rights reserved. This program and the accompanying materials - * are made available under the terms of the GNU Public License v3.0 - * which accompanies this distribution, and is available at - * http://www.gnu.org/licenses/gpl.html - */ +/*************************************************************************************************** + * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. + **************************************************************************************************/ package at.bitfire.davdroid.resource import android.accounts.Account @@ -12,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 @@ -20,10 +18,16 @@ 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 -import at.bitfire.davdroid.model.Collection -import at.bitfire.davdroid.model.SyncState +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 @@ -34,7 +38,7 @@ import java.util.logging.Level * address book" account for every CardDAV address book. These accounts are bound to a * DAVx5 main account. */ -class LocalAddressBook( +open class LocalAddressBook( private val context: Context, account: Account, provider: ContentProviderClient? @@ -47,24 +51,17 @@ 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 accountManager = AccountManager.get(context) + 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 account = Account(accountName(mainAccount, info), context.getString(R.string.account_type_address_book)) val userData = initialUserData(mainAccount, info.url.toString()) Logger.log.log(Level.INFO, "Creating local address book $account", userData) - if (!accountManager.addAccountExplicitly(account, null, userData)) + if (!AccountUtils.createAccount(context, account, userData)) throw IllegalStateException("Couldn't create address book account") - if (Build.VERSION.SDK_INT < Build.VERSION_CODES.N) - // Android < 7 seems to lose the initial user data sometimes, so set it a second time - // https://forums.bitfire.at/post/11644 - userData.keySet().forEach { key -> - accountManager.setUserData(account, key, userData.getString(key)) - } - val addressBook = LocalAddressBook(context, account, provider) - ContentResolver.setSyncAutomatically(account, ContactsContract.AUTHORITY, true) + addressBook.updateSyncSettings() // initialize Contacts Provider Settings val values = ContentValues(2) @@ -75,12 +72,18 @@ 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) } + 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() @@ -106,41 +109,53 @@ class LocalAddressBook( } fun mainAccount(context: Context, account: Account): Account = - if (account.type == context.getString(R.string.account_type_address_book)) { - val manager = AccountManager.get(context) - Account( - manager.getUserData(account, USER_DATA_MAIN_ACCOUNT_NAME), - manager.getUserData(account, USER_DATA_MAIN_ACCOUNT_TYPE) - ) - } else - account + 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) + if (accountName == null || accountType == null) + throw IllegalArgumentException("Address book account does not have a main account") + Account(accountName, accountType) + } else + throw IllegalArgumentException("Account is not an address book account") } + override val tag: String + get() = "contacts-${account.name}" + override val title = account.name!! /** * Whether contact groups ([LocalGroup]) are included in query results * and are affected by updates/deletes on generic members. * - * For instance, if this option is disabled, [findDirty] will find only dirty [LocalContact]s, + * For instance, if groupMethod is GROUP_VCARDS, [findDirty] will find only dirty [LocalContact]s, * but if it is enabled, [findDirty] will find dirty [LocalContact]s and [LocalGroup]s. */ - var includeGroups = true + open val groupMethod: GroupMethod by lazy { + val accountSettings = AccountSettings(context, mainAccount) + accountSettings.getGroupMethod() + } + val includeGroups + get() = groupMethod == GroupMethod.GROUP_VCARDS private var _mainAccount: Account? = null - var mainAccount: Account + /** + * The associated main account which this address book accounts belongs to. + * + * @throws IllegalArgumentException when [account] is not an address book account or when no main account is assigned + */ + open var mainAccount: Account get() { _mainAccount?.let { return it } - AccountManager.get(context).let { accountManager -> - val name = accountManager.getUserData(account, USER_DATA_MAIN_ACCOUNT_NAME) - val type = accountManager.getUserData(account, USER_DATA_MAIN_ACCOUNT_TYPE) - if (name != null && type != null) - return Account(name, type) - else - throw IllegalStateException("No main account assigned to address book account") - } + val result = mainAccount(context, account) + _mainAccount = result + return result } set(newMainAccount) { AccountManager.get(context).let { accountManager -> @@ -177,7 +192,7 @@ class LocalAddressBook( if (includeGroups) { values.clear() values.put(LocalGroup.COLUMN_FLAGS, flags) - number += provider.update(groupsSyncUri(), values, "NOT ${Groups.DIRTY}", null) + number += provider!!.update(groupsSyncUri(), values, "NOT ${Groups.DIRTY}", null) } return number @@ -188,7 +203,7 @@ class LocalAddressBook( "NOT ${RawContacts.DIRTY} AND ${LocalContact.COLUMN_FLAGS}=?", arrayOf(flags.toString())) if (includeGroups) - number += provider.delete(groupsSyncUri(), + number += provider!!.delete(groupsSyncUri(), "NOT ${Groups.DIRTY} AND ${LocalGroup.COLUMN_FLAGS}=?", arrayOf(flags.toString())) return number @@ -219,20 +234,69 @@ class LocalAddressBook( // update data rows val dataValues = ContentValues(1) dataValues.put(ContactsContract.Data.IS_READ_ONLY, if (nowReadOnly) 1 else 0) - provider.update(syncAdapterURI(ContactsContract.Data.CONTENT_URI), dataValues, null, null) + provider!!.update(syncAdapterURI(ContactsContract.Data.CONTENT_URI), dataValues, null, null) } // make sure it will still be synchronized when contacts are updated - ContentResolver.setSyncAutomatically(account, ContactsContract.AUTHORITY, true) + updateSyncSettings() } 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) + } + + + /** + * Updates the sync framework settings for this address book: + * + * - Contacts sync of this address book account shall be possible → isSyncable = 1 + * - When a contact is changed, a sync shall be initiated (ContactsSyncAdapter) -> syncAutomatically = true + * - However, we don't want a periodic (ContactsSyncAdapter) sync for this address book + * because contact synchronization is handled by AddressBooksSyncAdapter + * (which has its own periodic sync according to the account's contacts sync interval). */ + fun updateSyncSettings() { + if (ContentResolver.getIsSyncable(account, ContactsContract.AUTHORITY) != 1) + ContentResolver.setIsSyncable(account, ContactsContract.AUTHORITY, 1) + if (!ContentResolver.getSyncAutomatically(account, ContactsContract.AUTHORITY)) + ContentResolver.setSyncAutomatically(account, ContactsContract.AUTHORITY, true) + SyncUtils.removePeriodicSyncs(account, ContactsContract.AUTHORITY) } @@ -272,18 +336,6 @@ class LocalAddressBook( fun findDirtyContacts() = queryContacts(RawContacts.DIRTY, null) fun findDirtyGroups() = queryGroups(Groups.DIRTY, null) - override fun findDirtyWithoutNameOrUid() = - if (includeGroups) - findDirtyContactsWithoutNameOrUid() + findDirtyGroupsWithoutNameOrUid() - else - findDirtyContactsWithoutNameOrUid() - private fun findDirtyContactsWithoutNameOrUid() = queryContacts( - "${RawContacts.DIRTY} AND (${AndroidContact.COLUMN_FILENAME} IS NULL OR ${AndroidContact.COLUMN_UID} IS NULL)", - null) - private fun findDirtyGroupsWithoutNameOrUid() = queryGroups( - "${Groups.DIRTY} AND (${AndroidGroup.COLUMN_FILENAME} IS NULL OR ${AndroidGroup.COLUMN_UID} IS NULL)", - null) - override fun forgetETags() { if (includeGroups) { val values = ContentValues(1) @@ -296,6 +348,27 @@ class LocalAddressBook( } + fun getContactIdsByGroupMembership(groupId: Long): List { + val ids = LinkedList() + provider!!.query(syncAdapterURI(ContactsContract.Data.CONTENT_URI), arrayOf(GroupMembership.RAW_CONTACT_ID), + "(${GroupMembership.MIMETYPE}=? AND ${GroupMembership.GROUP_ROW_ID}=?)", + arrayOf(GroupMembership.CONTENT_ITEM_TYPE, groupId.toString()), null)?.use { cursor -> + while (cursor.moveToNext()) + ids += cursor.getLong(0) + } + return ids + } + + fun getContactUidFromId(contactId: Long): String? { + provider!!.query(rawContactsSyncUri(), arrayOf(AndroidContact.COLUMN_UID), + "${RawContacts._ID}=?", arrayOf(contactId.toString()), null)?.use { cursor -> + if (cursor.moveToNext()) + return cursor.getString(0) + } + return null + } + + /** * Queries all contacts with DIRTY flag and checks whether their data checksum has changed, i.e. * if they're "really dirty" (= data has changed, not only metadata, which is not hashed). @@ -328,20 +401,6 @@ class LocalAddressBook( return reallyDirty } - fun getByGroupMembership(groupID: Long): List { - val ids = HashSet() - provider!!.query(syncAdapterURI(ContactsContract.Data.CONTENT_URI), - arrayOf(RawContacts.Data.RAW_CONTACT_ID), - "(${GroupMembership.MIMETYPE}=? AND ${GroupMembership.GROUP_ROW_ID}=?) OR (${CachedGroupMembership.MIMETYPE}=? AND ${CachedGroupMembership.GROUP_ID}=?)", - arrayOf(GroupMembership.CONTENT_ITEM_TYPE, groupID.toString(), CachedGroupMembership.CONTENT_ITEM_TYPE, groupID.toString()), - null)?.use { cursor -> - while (cursor.moveToNext()) - ids += cursor.getLong(0) - } - - return ids.map { findContactByID(it) } - } - /* special group operations */ @@ -352,6 +411,7 @@ 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 -> @@ -361,7 +421,7 @@ class LocalAddressBook( val values = ContentValues(1) values.put(Groups.TITLE, title) - val uri = provider.insert(syncAdapterURI(Groups.CONTENT_URI), values) ?: throw RemoteException("Couldn't create contact group") + val uri = provider!!.insert(syncAdapterURI(Groups.CONTENT_URI), values) ?: throw RemoteException("Couldn't create contact group") return ContentUris.parseId(uri) } 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 a8c8d2de8353d4fbbfc016c8c7509839bb1e63a3..90240734470d4b724ae8e1dbe8a2d98c091368d5 100644 --- a/app/src/main/java/at/bitfire/davdroid/resource/LocalCalendar.kt +++ b/app/src/main/java/at/bitfire/davdroid/resource/LocalCalendar.kt @@ -1,16 +1,11 @@ -/* - * Copyright © Ricki Hirner (bitfire web engineering). - * All rights reserved. This program and the accompanying materials - * are made available under the terms of the GNU Public License v3.0 - * which accompanies this distribution, and is available at - * http://www.gnu.org/licenses/gpl.html - */ +/*************************************************************************************************** + * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. + **************************************************************************************************/ package at.bitfire.davdroid.resource import android.accounts.Account import android.content.ContentProviderClient -import android.content.ContentProviderOperation import android.content.ContentUris import android.content.ContentValues import android.net.Uri @@ -18,13 +13,14 @@ import android.provider.CalendarContract.Calendars import android.provider.CalendarContract.Events import at.bitfire.davdroid.Constants import at.bitfire.davdroid.DavUtils +import at.bitfire.davdroid.db.Collection +import at.bitfire.davdroid.db.SyncState import at.bitfire.davdroid.log.Logger -import at.bitfire.davdroid.model.Collection -import at.bitfire.davdroid.model.SyncState import at.bitfire.ical4android.AndroidCalendar import at.bitfire.ical4android.AndroidCalendarFactory import at.bitfire.ical4android.BatchOperation import at.bitfire.ical4android.DateUtils +import at.bitfire.ical4android.MiscUtils.UriHelper.asSyncAdapter import java.util.* import java.util.logging.Level @@ -44,6 +40,9 @@ class LocalCalendar private constructor( // ACCOUNT_NAME and ACCOUNT_TYPE are required (see docs)! If it's missing, other apps will crash. values.put(Calendars.ACCOUNT_NAME, account.name) values.put(Calendars.ACCOUNT_TYPE, account.type) + + // Email address for scheduling. Used by the calendar provider to determine whether the + // user is ORGANIZER/ATTENDEE for a certain event. values.put(Calendars.OWNER_ACCOUNT, account.name) // flag as visible & synchronizable at creation, might be changed by user at any time @@ -83,8 +82,12 @@ class LocalCalendar private constructor( return values } + } + override val tag: String + get() = "events-${account.name}-$id" + override val title: String get() = displayName ?: id.toString() @@ -112,24 +115,34 @@ class LocalCalendar private constructor( override fun findDirty(): List { val dirty = LinkedList() - // get dirty events which are required to have an increased SEQUENCE value + /* + * RFC 5545 3.8.7.4. Sequence Number + * When a calendar component is created, its sequence number is 0. It is monotonically incremented by the "Organizer's" + * CUA each time the "Organizer" makes a significant revision to the calendar component. + */ for (localEvent in queryEvents("${Events.DIRTY} AND ${Events.ORIGINAL_ID} IS NULL", null)) { - val event = localEvent.event!! - val sequence = event.sequence - if (event.sequence == null) // sequence has not been assigned yet (i.e. this event was just locally created) - event.sequence = 0 - else if (localEvent.weAreOrganizer) - event.sequence = sequence!! + 1 + try { + val event = requireNotNull(localEvent.event) + + val nonGroupScheduled = event.attendees.isEmpty() + val weAreOrganizer = localEvent.weAreOrganizer + + val sequence = event.sequence + if (sequence == null) + // sequence has not been assigned yet (i.e. this event was just locally created) + event.sequence = 0 + else if (nonGroupScheduled || weAreOrganizer) // increase sequence + event.sequence = sequence + 1 + + } catch(e: Exception) { + Logger.log.log(Level.WARNING, "Couldn't check/increase sequence", e) + } dirty += localEvent } return dirty } - override fun findDirtyWithoutNameOrUid() = - queryEvents("${Events.DIRTY} AND ${Events.ORIGINAL_ID} IS NULL AND " + - "(${Events._SYNC_ID} IS NULL OR ${Events.UID_2445} IS NULL)", null) - override fun findByName(name: String) = queryEvents("${Events._SYNC_ID}=?", arrayOf(name)).firstOrNull() @@ -137,20 +150,34 @@ class LocalCalendar private constructor( override fun markNotDirty(flags: Int): Int { val values = ContentValues(1) values.put(LocalEvent.COLUMN_FLAGS, flags) - return provider.update(eventsSyncURI(), values, + return provider.update(Events.CONTENT_URI.asSyncAdapter(account), values, "${Events.CALENDAR_ID}=? AND NOT ${Events.DIRTY} AND ${Events.ORIGINAL_ID} IS NULL", arrayOf(id.toString())) } - override fun removeNotDirtyMarked(flags: Int) = - provider.delete(eventsSyncURI(), - "${Events.CALENDAR_ID}=? AND NOT ${Events.DIRTY} AND ${Events.ORIGINAL_ID} IS NULL AND ${LocalEvent.COLUMN_FLAGS}=?", - arrayOf(id.toString(), flags.toString())) + override fun removeNotDirtyMarked(flags: Int): Int { + var deleted = 0 + // list all non-dirty events with the given flags and delete every row + its exceptions + provider.query(Events.CONTENT_URI.asSyncAdapter(account), arrayOf(Events._ID), + "${Events.CALENDAR_ID}=? AND NOT ${Events.DIRTY} AND ${Events.ORIGINAL_ID} IS NULL AND ${LocalEvent.COLUMN_FLAGS}=?", + arrayOf(id.toString(), flags.toString()), null)?.use { cursor -> + val batch = BatchOperation(provider) + while (cursor.moveToNext()) { + val id = cursor.getLong(0) + // delete event and possible exceptions (content provider doesn't delete exceptions itself) + batch.enqueue(BatchOperation.CpoBuilder + .newDelete(Events.CONTENT_URI.asSyncAdapter(account)) + .withSelection("${Events._ID}=? OR ${Events.ORIGINAL_ID}=?", arrayOf(id.toString(), id.toString()))) + } + deleted = batch.commit() + } + return deleted + } override fun forgetETags() { val values = ContentValues(1) values.putNull(LocalEvent.COLUMN_ETAG) - provider.update(eventsSyncURI(), values, "${Events.CALENDAR_ID}=?", + provider.update(Events.CONTENT_URI.asSyncAdapter(account), values, "${Events.CALENDAR_ID}=?", arrayOf(id.toString())) } @@ -159,7 +186,7 @@ class LocalCalendar private constructor( // process deleted exceptions Logger.log.info("Processing deleted exceptions") provider.query( - syncAdapterURI(Events.CONTENT_URI), + Events.CONTENT_URI.asSyncAdapter(account), arrayOf(Events._ID, Events.ORIGINAL_ID, LocalEvent.COLUMN_SEQUENCE), "${Events.CALENDAR_ID}=? AND ${Events.DELETED} AND ${Events.ORIGINAL_ID} IS NOT NULL", arrayOf(id.toString()), null)?.use { cursor -> @@ -172,7 +199,7 @@ class LocalCalendar private constructor( // get original event's SEQUENCE provider.query( - syncAdapterURI(ContentUris.withAppendedId(Events.CONTENT_URI, originalID)), + ContentUris.withAppendedId(Events.CONTENT_URI, originalID).asSyncAdapter(account), arrayOf(LocalEvent.COLUMN_SEQUENCE), null, null, null)?.use { cursor2 -> if (cursor2.moveToNext()) { @@ -180,18 +207,15 @@ class LocalCalendar private constructor( val originalSequence = if (cursor2.isNull(0)) 0 else cursor2.getInt(0) // re-schedule original event and set it to DIRTY - batch.enqueue(BatchOperation.Operation( - ContentProviderOperation.newUpdate(syncAdapterURI(ContentUris.withAppendedId(Events.CONTENT_URI, originalID))) - .withValue(LocalEvent.COLUMN_SEQUENCE, originalSequence + 1) - .withValue(Events.DIRTY, 1) - )) + batch.enqueue(BatchOperation.CpoBuilder + .newUpdate(ContentUris.withAppendedId(Events.CONTENT_URI, originalID).asSyncAdapter(account)) + .withValue(LocalEvent.COLUMN_SEQUENCE, originalSequence + 1) + .withValue(Events.DIRTY, 1)) } } // completely remove deleted exception - batch.enqueue(BatchOperation.Operation( - ContentProviderOperation.newDelete(syncAdapterURI(ContentUris.withAppendedId(Events.CONTENT_URI, id))) - )) + batch.enqueue(BatchOperation.CpoBuilder.newDelete(ContentUris.withAppendedId(Events.CONTENT_URI, id).asSyncAdapter(account))) batch.commit() } } @@ -199,7 +223,7 @@ class LocalCalendar private constructor( // process dirty exceptions Logger.log.info("Processing dirty exceptions") provider.query( - syncAdapterURI(Events.CONTENT_URI), + Events.CONTENT_URI.asSyncAdapter(account), arrayOf(Events._ID, Events.ORIGINAL_ID, LocalEvent.COLUMN_SEQUENCE), "${Events.CALENDAR_ID}=? AND ${Events.DIRTY} AND ${Events.ORIGINAL_ID} IS NOT NULL", arrayOf(id.toString()), null)?.use { cursor -> @@ -211,21 +235,46 @@ class LocalCalendar private constructor( val batch = BatchOperation(provider) // original event to DIRTY - batch.enqueue(BatchOperation.Operation ( - ContentProviderOperation.newUpdate(syncAdapterURI(ContentUris.withAppendedId(Events.CONTENT_URI, originalID))) - .withValue(Events.DIRTY, 1) - )) + batch.enqueue(BatchOperation.CpoBuilder + .newUpdate(ContentUris.withAppendedId(Events.CONTENT_URI, originalID).asSyncAdapter(account)) + .withValue(Events.DIRTY, 1)) // increase SEQUENCE and set DIRTY to 0 - batch.enqueue(BatchOperation.Operation ( - ContentProviderOperation.newUpdate(syncAdapterURI(ContentUris.withAppendedId(Events.CONTENT_URI, id))) - .withValue(LocalEvent.COLUMN_SEQUENCE, sequence + 1) - .withValue(Events.DIRTY, 0) - )) + batch.enqueue(BatchOperation.CpoBuilder + .newUpdate(ContentUris.withAppendedId(Events.CONTENT_URI, id).asSyncAdapter(account)) + .withValue(LocalEvent.COLUMN_SEQUENCE, sequence + 1) + .withValue(Events.DIRTY, 0)) batch.commit() } } } + /** + * Marks dirty events (which are not already marked as deleted) which got no valid instances as "deleted" + * + * @return number of affected events + */ + fun deleteDirtyEventsWithoutInstances() { + provider.query( + Events.CONTENT_URI.asSyncAdapter(account), + arrayOf(Events._ID), + "${Events.DIRTY} AND NOT ${Events.DELETED} AND ${Events.ORIGINAL_ID} IS NULL", // Get dirty main events (and no exception events) + null, null + )?.use { cursor -> + while (cursor.moveToNext()) { + val eventID = cursor.getLong(0) + + // get number of instances + val numEventInstances = LocalEvent.numInstances(provider, account, eventID) + + // delete event if there are no instances + if (numEventInstances == 0) { + Logger.log.info("Marking event #$eventID without instances as deleted") + LocalEvent.markAsDeleted(provider, account, eventID) + } + } + } + } + object Factory: AndroidCalendarFactory { diff --git a/app/src/main/java/at/bitfire/davdroid/resource/LocalCollection.kt b/app/src/main/java/at/bitfire/davdroid/resource/LocalCollection.kt index 570f0b50f7f86f12ab09d2f8dd4fd5bf12645b28..2d451f9ba23f73702570735f19ac633961208581 100644 --- a/app/src/main/java/at/bitfire/davdroid/resource/LocalCollection.kt +++ b/app/src/main/java/at/bitfire/davdroid/resource/LocalCollection.kt @@ -1,18 +1,17 @@ -/* - * Copyright © Ricki Hirner (bitfire web engineering). - * All rights reserved. This program and the accompanying materials - * are made available under the terms of the GNU Public License v3.0 - * which accompanies this distribution, and is available at - * http://www.gnu.org/licenses/gpl.html - */ +/*************************************************************************************************** + * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. + **************************************************************************************************/ package at.bitfire.davdroid.resource import android.provider.CalendarContract.Events -import at.bitfire.davdroid.model.SyncState +import at.bitfire.davdroid.db.SyncState interface LocalCollection> { + /** a tag that uniquely identifies the collection (DAVx5-wide) */ + val tag: String + /** collection title (used for user notifications etc.) **/ val title: String @@ -34,17 +33,6 @@ interface LocalCollection> { */ fun findDirty(): List - /** - * Finds local resources of this collection which do not have a file name and/or UID, but - * need one for synchronization. - * - * For instance, exceptions of recurring events are local resources but do not need their - * own file name/UID because they're sent with the same UID as the main event. - * - * @return list of resources which need file name and UID for synchronization, but don't have both of them - */ - fun findDirtyWithoutNameOrUid(): List - /** * Finds a local resource of this collection with a given file name. (File names are assigned * by the sync adapter.) diff --git a/app/src/main/java/at/bitfire/davdroid/resource/LocalContact.kt b/app/src/main/java/at/bitfire/davdroid/resource/LocalContact.kt index dbbf06ba26788ff5a9d81e50455c8c01f09845bc..a1c6afc20d1d645016ca02dbb3cef92210d0fbaf 100644 --- a/app/src/main/java/at/bitfire/davdroid/resource/LocalContact.kt +++ b/app/src/main/java/at/bitfire/davdroid/resource/LocalContact.kt @@ -1,14 +1,9 @@ -/* - * Copyright © Ricki Hirner (bitfire web engineering). - * All rights reserved. This program and the accompanying materials - * are made available under the terms of the GNU Public License v3.0 - * which accompanies this distribution, and is available at - * http://www.gnu.org/licenses/gpl.html - */ +/*************************************************************************************************** + * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. + **************************************************************************************************/ package at.bitfire.davdroid.resource -import android.content.ContentProviderOperation import android.content.ContentValues import android.os.Build import android.os.RemoteException @@ -17,14 +12,18 @@ import android.provider.ContactsContract.CommonDataKinds.GroupMembership import android.provider.ContactsContract.RawContacts.Data import at.bitfire.davdroid.BuildConfig import at.bitfire.davdroid.log.Logger -import at.bitfire.davdroid.model.UnknownProperties +import at.bitfire.davdroid.resource.contactrow.* import at.bitfire.vcard4android.* import ezvcard.Ezvcard +import org.apache.commons.lang3.StringUtils import java.io.FileNotFoundException import java.util.* class LocalContact: AndroidContact, LocalAddress { + override val addressBook: LocalAddressBook + get() = super.addressBook as LocalAddressBook + companion object { init { Contact.productID = "+//IDN bitfire.at//${BuildConfig.userAgent}/${BuildConfig.VERSION_NAME} ez-vcard/" + Ezvcard.VERSION @@ -34,36 +33,61 @@ class LocalContact: AndroidContact, LocalAddress { const val COLUMN_HASHCODE = ContactsContract.RawContacts.SYNC3 } - private val cachedGroupMemberships = HashSet() - private val groupMemberships = HashSet() + internal val cachedGroupMemberships = HashSet() + internal val groupMemberships = HashSet() + + override var scheduleTag: String? + get() = null + set(_) = throw NotImplementedError() override var flags: Int = 0 - constructor(addressBook: AndroidAddressBook, values: ContentValues) - : super(addressBook, values) { + + constructor(addressBook: LocalAddressBook, values: ContentValues): super(addressBook, values) { flags = values.getAsInteger(COLUMN_FLAGS) ?: 0 } - constructor(addressBook: AndroidAddressBook, contact: Contact, fileName: String?, eTag: String?, flags: Int) - : super(addressBook, contact, fileName, eTag) { - this.flags = flags + constructor(addressBook: LocalAddressBook, contact: Contact, fileName: String?, eTag: String?, _flags: Int): super(addressBook, contact, fileName, eTag) { + flags = _flags } + init { + processor.registerHandler(CachedGroupMembershipHandler(this)) + processor.registerHandler(GroupMembershipHandler(this)) + processor.registerHandler(UnknownPropertiesHandler) + processor.registerBuilderFactory(GroupMembershipBuilder.Factory(addressBook)) + processor.registerBuilderFactory(UnknownPropertiesBuilder.Factory) + } - override fun assignNameAndUID() { - val uid = UUID.randomUUID().toString() - val newFileName = "$uid.vcf" - val values = ContentValues(2) - values.put(COLUMN_FILENAME, newFileName) - values.put(COLUMN_UID, uid) - addressBook.provider!!.update(rawContactSyncURI(), values, null, null) + override fun prepareForUpload(): String { + var uid: String? = null + addressBook.provider!!.query(rawContactSyncURI(), arrayOf(COLUMN_UID), null, null, null)?.use { cursor -> + if (cursor.moveToNext()) + uid = StringUtils.trimToNull(cursor.getString(0)) + } + + if (uid == null) { + // generate new UID + uid = UUID.randomUUID().toString() + + val values = ContentValues(1) + values.put(COLUMN_UID, uid) + addressBook.provider!!.update(rawContactSyncURI(), values, null, null) + + getContact().uid = uid + } - fileName = newFileName + return "$uid.vcf" } - override fun clearDirty(eTag: String?) { - val values = ContentValues(3) + override fun clearDirty(fileName: String?, eTag: String?, scheduleTag: String?) { + if (scheduleTag != null) + throw IllegalArgumentException("Contacts must not have a Schedule-Tag") + + val values = ContentValues(4) + if (fileName != null) + values.put(COLUMN_FILENAME, fileName) values.put(COLUMN_ETAG, eTag) values.put(ContactsContract.RawContacts.DIRTY, 0) @@ -76,6 +100,8 @@ class LocalContact: AndroidContact, LocalAddress { addressBook.provider!!.update(rawContactSyncURI(), values, null, null) + if (fileName != null) + this.fileName = fileName this.eTag = eTag } @@ -100,36 +126,6 @@ class LocalContact: AndroidContact, LocalAddress { } - override fun populateData(mimeType: String, row: ContentValues) { - when (mimeType) { - CachedGroupMembership.CONTENT_ITEM_TYPE -> - cachedGroupMemberships += row.getAsLong(CachedGroupMembership.GROUP_ID) - GroupMembership.CONTENT_ITEM_TYPE -> - groupMemberships += row.getAsLong(GroupMembership.GROUP_ROW_ID) - UnknownProperties.CONTENT_ITEM_TYPE -> - contact!!.unknownProperties = row.getAsString(UnknownProperties.UNKNOWN_PROPERTIES) - } - } - - override fun insertDataRows(batch: BatchOperation) { - super.insertDataRows(batch) - - contact!!.unknownProperties?.let { unknownProperties -> - val op: BatchOperation.Operation - val builder = ContentProviderOperation.newInsert(dataSyncURI()) - if (id == null) - op = BatchOperation.Operation(builder, UnknownProperties.RAW_CONTACT_ID, 0) - else { - op = BatchOperation.Operation(builder) - builder.withValue(UnknownProperties.RAW_CONTACT_ID, id) - } - builder .withValue(UnknownProperties.MIMETYPE, UnknownProperties.CONTENT_ITEM_TYPE) - .withValue(UnknownProperties.UNKNOWN_PROPERTIES, unknownProperties) - batch.enqueue(op) - } - } - - /** * Calculates a hash code from the contact's data (VCard) and group memberships. * Attention: re-reads {@link #contact} from the database, discarding all changes in memory @@ -140,10 +136,10 @@ class LocalContact: AndroidContact, LocalAddress { throw IllegalStateException("dataHashCode() should not be called on Android != 7") // reset contact so that getContact() reads from database - contact = null + _contact = null // groupMemberships is filled by getContact() - val dataHash = contact!!.hashCode() + val dataHash = getContact().hashCode() val groupHash = groupMemberships.hashCode() Logger.log.finest("Calculated data hash = $dataHash, group memberships hash = $groupHash") return dataHash xor groupHash @@ -153,19 +149,17 @@ class LocalContact: AndroidContact, LocalAddress { if (Build.VERSION.SDK_INT < Build.VERSION_CODES.N || Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) throw IllegalStateException("updateHashCode() should not be called on Android != 7") - val values = ContentValues(1) val hashCode = dataHashCode() Logger.log.fine("Storing contact hash = $hashCode") - values.put(COLUMN_HASHCODE, hashCode) - if (batch == null) + if (batch == null) { + val values = ContentValues(1) + values.put(COLUMN_HASHCODE, hashCode) addressBook.provider!!.update(rawContactSyncURI(), values, null, null) - else { - val builder = ContentProviderOperation + } else + batch.enqueue(BatchOperation.CpoBuilder .newUpdate(rawContactSyncURI()) - .withValues(values) - batch.enqueue(BatchOperation.Operation(builder)) - } + .withValue(COLUMN_HASHCODE, hashCode)) } fun getLastHashCode(): Int { @@ -181,33 +175,29 @@ class LocalContact: AndroidContact, LocalAddress { fun addToGroup(batch: BatchOperation, groupID: Long) { - batch.enqueue(BatchOperation.Operation( - ContentProviderOperation.newInsert(dataSyncURI()) - .withValue(GroupMembership.MIMETYPE, GroupMembership.CONTENT_ITEM_TYPE) - .withValue(GroupMembership.RAW_CONTACT_ID, id) - .withValue(GroupMembership.GROUP_ROW_ID, groupID) - )) + batch.enqueue(BatchOperation.CpoBuilder + .newInsert(dataSyncURI()) + .withValue(GroupMembership.MIMETYPE, GroupMembership.CONTENT_ITEM_TYPE) + .withValue(GroupMembership.RAW_CONTACT_ID, id) + .withValue(GroupMembership.GROUP_ROW_ID, groupID)) groupMemberships += groupID - batch.enqueue(BatchOperation.Operation( - ContentProviderOperation.newInsert(dataSyncURI()) - .withValue(CachedGroupMembership.MIMETYPE, CachedGroupMembership.CONTENT_ITEM_TYPE) - .withValue(CachedGroupMembership.RAW_CONTACT_ID, id) - .withValue(CachedGroupMembership.GROUP_ID, groupID) - .withYieldAllowed(true) - )) + batch.enqueue(BatchOperation.CpoBuilder + .newInsert(dataSyncURI()) + .withValue(CachedGroupMembership.MIMETYPE, CachedGroupMembership.CONTENT_ITEM_TYPE) + .withValue(CachedGroupMembership.RAW_CONTACT_ID, id) + .withValue(CachedGroupMembership.GROUP_ID, groupID) + ) cachedGroupMemberships += groupID } fun removeGroupMemberships(batch: BatchOperation) { - batch.enqueue(BatchOperation.Operation( - ContentProviderOperation.newDelete(dataSyncURI()) - .withSelection( - Data.RAW_CONTACT_ID + "=? AND " + Data.MIMETYPE + " IN (?,?)", - arrayOf(id.toString(), GroupMembership.CONTENT_ITEM_TYPE, CachedGroupMembership.CONTENT_ITEM_TYPE) - ) - .withYieldAllowed(true) - )) + batch.enqueue(BatchOperation.CpoBuilder + .newDelete(dataSyncURI()) + .withSelection( + "${Data.RAW_CONTACT_ID}=? AND ${Data.MIMETYPE} IN (?,?)", + arrayOf(id.toString(), GroupMembership.CONTENT_ITEM_TYPE, CachedGroupMembership.CONTENT_ITEM_TYPE) + )) groupMemberships.clear() cachedGroupMemberships.clear() } @@ -221,7 +211,7 @@ class LocalContact: AndroidContact, LocalAddress { * @throws RemoteException on contacts provider errors */ fun getCachedGroupMemberships(): Set { - contact + getContact() return cachedGroupMemberships } @@ -232,13 +222,13 @@ class LocalContact: AndroidContact, LocalAddress { * @throws RemoteException on contacts provider errors */ fun getGroupMemberships(): Set { - contact + getContact() return groupMemberships } // data rows - override fun buildContact(builder: ContentProviderOperation.Builder, update: Boolean) { + override fun buildContact(builder: BatchOperation.CpoBuilder, update: Boolean) { builder.withValue(COLUMN_FLAGS, flags) super.buildContact(builder, update) } @@ -247,7 +237,7 @@ class LocalContact: AndroidContact, LocalAddress { object Factory: AndroidContactFactory { override fun fromProvider(addressBook: AndroidAddressBook, values: ContentValues) = - LocalContact(addressBook, values) + LocalContact(addressBook as LocalAddressBook, values) } } 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 1fa57e5f030c138d135113386f955574c6155eb8..e5a64e0265308926bfb8c769abd9f951448bf51a 100644 --- a/app/src/main/java/at/bitfire/davdroid/resource/LocalEvent.kt +++ b/app/src/main/java/at/bitfire/davdroid/resource/LocalEvent.kt @@ -1,58 +1,164 @@ -/* - * Copyright © Ricki Hirner (bitfire web engineering). - * All rights reserved. This program and the accompanying materials - * are made available under the terms of the GNU Public License v3.0 - * which accompanies this distribution, and is available at - * http://www.gnu.org/licenses/gpl.html - */ +/*************************************************************************************************** + * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. + **************************************************************************************************/ package at.bitfire.davdroid.resource -import android.content.ContentProviderOperation +import android.accounts.Account +import android.content.ContentProviderClient +import android.content.ContentUris import android.content.ContentValues +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 net.fortuna.ical4j.model.property.ProdId +import org.apache.commons.lang3.StringUtils import java.util.* class LocalEvent: AndroidEvent, LocalResource { companion object { init { - ICalendar.prodId = ProdId("+//IDN bitfire.at//${BuildConfig.userAgent}/${BuildConfig.VERSION_NAME} ical4j/" + Constants.ical4jVersion) + ICalendar.prodId = ProdId("${BuildConfig.userAgent}/${BuildConfig.VERSION_NAME} ical4j/" + Ical4Android.ical4jVersion) } const val COLUMN_ETAG = Events.SYNC_DATA1 const val COLUMN_FLAGS = Events.SYNC_DATA2 const val COLUMN_SEQUENCE = Events.SYNC_DATA3 + const val COLUMN_SCHEDULE_TAG = Events.SYNC_DATA4 + + /** + * Marks the event as deleted + * @param eventID + */ + fun markAsDeleted(provider: ContentProviderClient, account: Account, eventID: Long) { + provider.update( + ContentUris.withAppendedId( + Events.CONTENT_URI, + eventID + ).asSyncAdapter(account), + ContentValues(1).apply { + put(Events.DELETED, 1) + }, + null,null + ) + } + + /** + * Finds the amount of direct instances this event has (without exceptions); used by [numInstances] + * to find the number of instances of exceptions. + * + * The number of returned instances may vary with the Android version. + * + * @return number of direct event instances (not counting instances of exceptions); *null* if + * the number can't be determined or if the event has no last date (recurring event without last instance) + */ + fun numDirectInstances(provider: ContentProviderClient, account: Account, eventID: Long): Int? { + // query event to get first and last instance + var first: Long? = null + var last: Long? = null + provider.query( + ContentUris.withAppendedId( + Events.CONTENT_URI, + eventID + ), + arrayOf(Events.DTSTART, Events.LAST_DATE), null, null, null + )?.use { cursor -> + cursor.moveToNext() + if (!cursor.isNull(0)) + first = cursor.getLong(0) + if (!cursor.isNull(1)) + last = cursor.getLong(1) + } + // if this event doesn't have a last occurence, it's endless and always has instances + if (first == null || last == null) + return null + + /* We can't use Long.MIN_VALUE and Long.MAX_VALUE because Android generates the instances + on the fly and it doesn't accept those values. So we use the first/last actual occurence + of the event (calculated by Android). */ + val instancesUri = CalendarContract.Instances.CONTENT_URI.asSyncAdapter(account) + .buildUpon() + .appendPath(first.toString()) // begin timestamp + .appendPath(last.toString()) // end timestamp + .build() + + var numInstances = 0 + provider.query( + instancesUri, null, + "${CalendarContract.Instances.EVENT_ID}=?", arrayOf(eventID.toString()), + null + )?.use { cursor -> + numInstances += cursor.count + } + return numInstances + } + + /** + * Finds the total number of instances this event has (including instances of exceptions) + * + * The number of returned instances may vary with the Android version. + * + * @return number of direct event instances (not counting instances of exceptions); *null* if + * the number can't be determined or if the event has no last date (recurring event without last instance) + */ + fun numInstances(provider: ContentProviderClient, account: Account, eventID: Long): Int? { + // num instances of the main event + var numInstances = numDirectInstances(provider, account, eventID) ?: return null + + // add the number of instances of every main event's exception + provider.query( + Events.CONTENT_URI, + arrayOf(Events._ID), + "${Events.ORIGINAL_ID}=?", // get exception events of the main event + arrayOf("$eventID"), null + )?.use { exceptionsEventCursor -> + while (exceptionsEventCursor.moveToNext()) { + val exceptionEventID = exceptionsEventCursor.getLong(0) + val exceptionInstances = numDirectInstances(provider, account, exceptionEventID) + + if (exceptionInstances == null) + // number of instances of exception can't be determined; so the total number of instances is also unclear + return null + + numInstances += exceptionInstances + } + } + return numInstances + } + } override var fileName: String? = null private set override var eTag: String? = null + override var scheduleTag: String? = null override var flags: Int = 0 private set - var weAreOrganizer = true + var weAreOrganizer = false + private set - constructor(calendar: AndroidCalendar<*>, event: Event, fileName: String?, eTag: String?, flags: Int): super(calendar, event) { + constructor(calendar: AndroidCalendar<*>, event: Event, fileName: String?, eTag: String?, scheduleTag: String?, flags: Int): super(calendar, event) { this.fileName = fileName this.eTag = eTag + this.scheduleTag = scheduleTag this.flags = flags } private constructor(calendar: AndroidCalendar<*>, values: ContentValues): super(calendar, values) { fileName = values.getAsString(Events._SYNC_ID) eTag = values.getAsString(COLUMN_ETAG) + scheduleTag = values.getAsString(COLUMN_SCHEDULE_TAG) flags = values.getAsInteger(COLUMN_FLAGS) ?: 0 } - override fun populateEvent(row: ContentValues) { - super.populateEvent(row) + override fun populateEvent(row: ContentValues, groupScheduled: Boolean) { val event = requireNotNull(event) event.uid = row.getAsString(Events.UID_2445) @@ -60,10 +166,11 @@ class LocalEvent: AndroidEvent, LocalResource { val isOrganizer = row.getAsInteger(Events.IS_ORGANIZER) weAreOrganizer = isOrganizer != null && isOrganizer != 0 + + super.populateEvent(row, groupScheduled) } - override fun buildEvent(recurrence: Event?, builder: ContentProviderOperation.Builder) { - super.buildEvent(recurrence, builder) + override fun buildEvent(recurrence: Event?, builder: BatchOperation.CpoBuilder) { val event = requireNotNull(event) val buildException = recurrence != null @@ -80,37 +187,47 @@ class LocalEvent: AndroidEvent, LocalResource { else builder .withValue(Events._SYNC_ID, fileName) .withValue(COLUMN_ETAG, eTag) + .withValue(COLUMN_SCHEDULE_TAG, scheduleTag) + + super.buildEvent(recurrence, builder) } - override fun assignNameAndUID() { + override fun prepareForUpload(): String { var uid: String? = null calendar.provider.query(eventSyncURI(), arrayOf(Events.UID_2445), null, null, null)?.use { cursor -> if (cursor.moveToNext()) - uid = cursor.getString(0) + uid = StringUtils.trimToNull(cursor.getString(0)) } - if (uid == null) + + if (uid == null) { + // generate new UID uid = UUID.randomUUID().toString() - val newFileName = "$uid.ics" + val values = ContentValues(1) + values.put(Events.UID_2445, uid) + calendar.provider.update(eventSyncURI(), values, null, null) - val values = ContentValues(2) - values.put(Events._SYNC_ID, newFileName) - values.put(Events.UID_2445, uid) - calendar.provider.update(eventSyncURI(), values, null, null) + event!!.uid = uid + } - fileName = newFileName - event!!.uid = uid + return "$uid.ics" } - override fun clearDirty(eTag: String?) { - val values = ContentValues(2) - values.put(Events.DIRTY, 0) + override fun clearDirty(fileName: String?, eTag: String?, scheduleTag: String?) { + val values = ContentValues(5) + if (fileName != null) + values.put(Events._SYNC_ID, fileName) values.put(COLUMN_ETAG, eTag) + values.put(COLUMN_SCHEDULE_TAG, scheduleTag) values.put(COLUMN_SEQUENCE, event!!.sequence) + values.put(Events.DIRTY, 0) calendar.provider.update(eventSyncURI(), values, null, null) + if (fileName != null) + this.fileName = fileName this.eTag = eTag + this.scheduleTag = scheduleTag } override fun updateFlags(flags: Int) { diff --git a/app/src/main/java/at/bitfire/davdroid/resource/LocalGroup.kt b/app/src/main/java/at/bitfire/davdroid/resource/LocalGroup.kt index 5878a4b068f29bbb6b4f0fa664b6a63fbe3ad5e6..56e555a97af0961c2c06b28e62fdad7b647a2b99 100644 --- a/app/src/main/java/at/bitfire/davdroid/resource/LocalGroup.kt +++ b/app/src/main/java/at/bitfire/davdroid/resource/LocalGroup.kt @@ -1,27 +1,22 @@ -/* - * Copyright © Ricki Hirner (bitfire web engineering). - * All rights reserved. This program and the accompanying materials - * are made available under the terms of the GNU Public License v3.0 - * which accompanies this distribution, and is available at - * http://www.gnu.org/licenses/gpl.html - */ +/*************************************************************************************************** + * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. + **************************************************************************************************/ package at.bitfire.davdroid.resource -import android.content.ContentProviderOperation import android.content.ContentUris import android.content.ContentValues import android.net.Uri import android.os.Build -import android.os.Parcel import android.os.RemoteException import android.provider.ContactsContract import android.provider.ContactsContract.CommonDataKinds.GroupMembership import android.provider.ContactsContract.Groups import android.provider.ContactsContract.RawContacts import android.provider.ContactsContract.RawContacts.Data -import at.bitfire.dav4jvm.Constants +import at.bitfire.davdroid.log.Logger import at.bitfire.vcard4android.* +import org.apache.commons.lang3.StringUtils import java.util.* class LocalGroup: AndroidGroup, LocalAddress { @@ -30,83 +25,88 @@ class LocalGroup: AndroidGroup, LocalAddress { const val COLUMN_FLAGS = Groups.SYNC4 - /** marshaled list of member UIDs, as sent by server */ + /** List of member UIDs, as sent by server. This list will be used to establish + * the group memberships when all groups and contacts have been synchronized. + * Use [PendingMemberships] to create/read the list. */ const val COLUMN_PENDING_MEMBERS = Groups.SYNC3 /** - * Processes all groups with non-null {@link #COLUMN_PENDING_MEMBERS}: the pending memberships - * are (if possible) applied, keeping cached memberships in sync. + * Processes all groups with non-null [COLUMN_PENDING_MEMBERS]: the pending memberships + * are applied (if possible) to keep cached memberships in sync. + * * @param addressBook address book to take groups from */ fun applyPendingMemberships(addressBook: LocalAddressBook) { - addressBook.provider!!.query( - addressBook.groupsSyncUri(), - arrayOf(Groups._ID, COLUMN_PENDING_MEMBERS), - "$COLUMN_PENDING_MEMBERS IS NOT NULL", null, - null - )?.use { cursor -> - val batch = BatchOperation(addressBook.provider) - while (cursor.moveToNext()) { - val id = cursor.getLong(0) - Constants.log.fine("Assigning members to group $id") - - // required for workaround for Android 7 which sets DIRTY flag when only meta-data is changed - val changeContactIDs = HashSet() - - // delete all memberships and cached memberships for this group - for (contact in addressBook.getByGroupMembership(id)) { - contact.removeGroupMemberships(batch) - changeContactIDs += contact.id!! - } + Logger.log.info("Assigning memberships of contact groups") - // extract list of member UIDs - val members = LinkedList() - val raw = cursor.getBlob(1) - val parcel = Parcel.obtain() - try { - parcel.unmarshall(raw, 0, raw.size) - parcel.setDataPosition(0) - parcel.readStringList(members) - } finally { - parcel.recycle() - } + addressBook.allGroups { group -> + val groupId = group.id!! + val pendingMemberUids = group.pendingMemberships.toMutableSet() + val batch = BatchOperation(addressBook.provider!!) + + // required for workaround for Android 7 which sets DIRTY flag when only meta-data is changed + val changeContactIDs = HashSet() + + // process members which are currently in this group, but shouldn't be + for (currentMemberId in addressBook.getContactIdsByGroupMembership(groupId)) { + val uid = addressBook.getContactUidFromId(currentMemberId) ?: continue - // insert memberships - for (uid in members) { - Constants.log.fine("Assigning member: $uid") - addressBook.findContactByUID(uid)?.let { member -> - member.addToGroup(batch, id) - changeContactIDs += member.id!! - } ?: Constants.log.warning("Group member not found: $uid") + if (!pendingMemberUids.contains(uid)) { + Logger.log.fine("$currentMemberId removed from group $groupId; removing group membership") + val currentMember = addressBook.findContactById(currentMemberId) + currentMember.removeGroupMemberships(batch) + + // Android 7 hack + changeContactIDs += currentMemberId } - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N && Build.VERSION.SDK_INT < Build.VERSION_CODES.O) - // workaround for Android 7 which sets DIRTY flag when only meta-data is changed - changeContactIDs - .map { addressBook.findContactByID(it) } - .forEach { it.updateHashCode(batch) } + // UID is processed, remove from pendingMembers + pendingMemberUids -= uid + } + // now pendingMemberUids contains all UIDs which are not assigned yet + + // process members which should be in this group, but aren't + for (missingMemberUid in pendingMemberUids) { + val missingMember = addressBook.findContactByUid(missingMemberUid) + if (missingMember == null) { + Logger.log.warning("Group $groupId has member $missingMemberUid which is not found in the address book; ignoring") + continue + } - // remove pending memberships - batch.enqueue(BatchOperation.Operation( - ContentProviderOperation.newUpdate(addressBook.syncAdapterURI(ContentUris.withAppendedId(Groups.CONTENT_URI, id))) - .withValue(COLUMN_PENDING_MEMBERS, null) - .withYieldAllowed(true) - )) + Logger.log.fine("Assigning member $missingMember to group $groupId") + missingMember.addToGroup(batch, groupId) - batch.commit() + // Android 7 hack + changeContactIDs += missingMember.id!! } + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N && Build.VERSION.SDK_INT < Build.VERSION_CODES.O) + // workaround for Android 7 which sets DIRTY flag when only meta-data is changed + changeContactIDs + .map { addressBook.findContactById(it) } + .forEach { it.updateHashCode(batch) } + + batch.commit() } } } + override var scheduleTag: String? + get() = null + set(value) = throw NotImplementedError() + override var flags: Int = 0 + var pendingMemberships = setOf() - constructor(addressBook: AndroidAddressBook, values: ContentValues) - : super(addressBook, values) { + + constructor(addressBook: AndroidAddressBook, values: ContentValues) : super(addressBook, values) { flags = values.getAsInteger(COLUMN_FLAGS) ?: 0 + values.getAsString(COLUMN_PENDING_MEMBERS)?.let { members -> + pendingMemberships = PendingMemberships.fromString(members).uids + } } constructor(addressBook: AndroidAddressBook, contact: Contact, fileName: String?, eTag: String?, flags: Int) @@ -118,60 +118,66 @@ class LocalGroup: AndroidGroup, LocalAddress { override fun contentValues(): ContentValues { val values = super.contentValues() values.put(COLUMN_FLAGS, flags) - - val members = Parcel.obtain() - try { - members.writeStringList(contact!!.members) - values.put(COLUMN_PENDING_MEMBERS, members.marshall()) - } finally { - members.recycle() - } + values.put(COLUMN_PENDING_MEMBERS, PendingMemberships(getContact().members).toString()) return values } - override fun assignNameAndUID() { - val uid = UUID.randomUUID().toString() - val newFileName = "$uid.vcf" + override fun prepareForUpload(): String { + var uid: String? = null + addressBook.provider!!.query(groupSyncUri(), arrayOf(AndroidContact.COLUMN_UID), null, null, null)?.use { cursor -> + if (cursor.moveToNext()) + uid = StringUtils.trimToNull(cursor.getString(0)) + } - val values = ContentValues(2) - values.put(COLUMN_FILENAME, newFileName) - values.put(COLUMN_UID, uid) - update(values) + if (uid == null) { + // generate new UID + uid = UUID.randomUUID().toString() + + val values = ContentValues(1) + values.put(AndroidContact.COLUMN_UID, uid) + addressBook.provider!!.update(groupSyncUri(), values, null, null) + + _contact?.uid = uid + } - fileName = newFileName + return "$uid.vcf" } - override fun clearDirty(eTag: String?) { + override fun clearDirty(fileName: String?, eTag: String?, scheduleTag: String?) { + if (scheduleTag != null) + throw IllegalArgumentException("Contact groups must not have a Schedule-Tag") val id = requireNotNull(id) - val values = ContentValues(2) + val values = ContentValues(3) + if (fileName != null) + values.put(COLUMN_FILENAME, fileName) + values.putNull(COLUMN_ETAG) // don't save changed ETag but null, so that the group is downloaded again, so that pendingMembers is updated values.put(Groups.DIRTY, 0) - values.put(COLUMN_ETAG, eTag) - this.eTag = eTag update(values) + if (fileName != null) + this.fileName = fileName + this.eTag = null + // update cached group memberships val batch = BatchOperation(addressBook.provider!!) - // delete cached group memberships - batch.enqueue(BatchOperation.Operation( - ContentProviderOperation.newDelete(addressBook.syncAdapterURI(ContactsContract.Data.CONTENT_URI)) - .withSelection( - CachedGroupMembership.MIMETYPE + "=? AND " + CachedGroupMembership.GROUP_ID + "=?", - arrayOf(CachedGroupMembership.CONTENT_ITEM_TYPE, id.toString()) - ) - )) + // delete old cached group memberships + batch.enqueue(BatchOperation.CpoBuilder + .newDelete(addressBook.syncAdapterURI(ContactsContract.Data.CONTENT_URI)) + .withSelection( + CachedGroupMembership.MIMETYPE + "=? AND " + CachedGroupMembership.GROUP_ID + "=?", + arrayOf(CachedGroupMembership.CONTENT_ITEM_TYPE, id.toString()) + )) // insert updated cached group memberships for (member in getMembers()) - batch.enqueue(BatchOperation.Operation( - ContentProviderOperation.newInsert(addressBook.syncAdapterURI(ContactsContract.Data.CONTENT_URI)) - .withValue(CachedGroupMembership.MIMETYPE, CachedGroupMembership.CONTENT_ITEM_TYPE) - .withValue(CachedGroupMembership.RAW_CONTACT_ID, member) - .withValue(CachedGroupMembership.GROUP_ID, id) - .withYieldAllowed(true) - )) + batch.enqueue(BatchOperation.CpoBuilder + .newInsert(addressBook.syncAdapterURI(ContactsContract.Data.CONTENT_URI)) + .withValue(CachedGroupMembership.MIMETYPE, CachedGroupMembership.CONTENT_ITEM_TYPE) + .withValue(CachedGroupMembership.RAW_CONTACT_ID, member) + .withValue(CachedGroupMembership.GROUP_ID, id)) batch.commit() } @@ -183,11 +189,9 @@ class LocalGroup: AndroidGroup, LocalAddress { val batch = BatchOperation(addressBook.provider!!) for (member in getMembers()) - batch.enqueue(BatchOperation.Operation( - ContentProviderOperation.newUpdate(addressBook.syncAdapterURI(ContentUris.withAppendedId(RawContacts.CONTENT_URI, member))) - .withValue(RawContacts.DIRTY, 1) - .withYieldAllowed(true) - )) + batch.enqueue(BatchOperation.CpoBuilder + .newUpdate(addressBook.syncAdapterURI(ContentUris.withAppendedId(RawContacts.CONTENT_URI, member))) + .withValue(RawContacts.DIRTY, 1)) batch.commit() } @@ -236,6 +240,25 @@ class LocalGroup: AndroidGroup, LocalAddress { } + // helper class for COLUMN_PENDING_MEMBERSHIPS blob + + class PendingMemberships( + /** list of member UIDs that shall be assigned **/ + val uids: Set + ) { + + companion object { + const val SEPARATOR = '\n' + + fun fromString(value: String) = + PendingMemberships(value.split(SEPARATOR).toSet()) + } + + override fun toString() = uids.joinToString(SEPARATOR.toString()) + + } + + // factory object Factory: AndroidGroupFactory { @@ -243,4 +266,4 @@ class LocalGroup: AndroidGroup, LocalAddress { LocalGroup(addressBook, values) } -} +} \ No newline at end of file diff --git a/app/src/main/java/at/bitfire/davdroid/resource/LocalJtxCollection.kt b/app/src/main/java/at/bitfire/davdroid/resource/LocalJtxCollection.kt new file mode 100644 index 0000000000000000000000000000000000000000..53760c46f284044f2cf0140e995af64022f624ce --- /dev/null +++ b/app/src/main/java/at/bitfire/davdroid/resource/LocalJtxCollection.kt @@ -0,0 +1,92 @@ +/*************************************************************************************************** + * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. + **************************************************************************************************/ + +package at.bitfire.davdroid.resource + +import android.accounts.Account +import android.content.ContentProviderClient +import android.content.ContentValues +import at.bitfire.davdroid.DavUtils +import at.bitfire.davdroid.db.Collection +import at.bitfire.davdroid.db.SyncState +import at.bitfire.ical4android.JtxCollection +import at.bitfire.ical4android.JtxCollectionFactory +import at.bitfire.ical4android.JtxICalObject +import at.techbee.jtx.JtxContract + +class LocalJtxCollection(account: Account, client: ContentProviderClient, id: Long): + JtxCollection(account, client, LocalJtxICalObject.Factory, id), + LocalCollection{ + + companion object { + + fun create(account: Account, client: ContentProviderClient, info: Collection) { + val values = valuesFromCollection(info, account) + create(account, client, values) + } + + fun valuesFromCollection(info: Collection, account: Account) = + ContentValues().apply { + put(JtxContract.JtxCollection.URL, info.url.toString()) + put(JtxContract.JtxCollection.DISPLAYNAME, info.displayName ?: DavUtils.lastSegmentOfUrl(info.url)) + put(JtxContract.JtxCollection.DESCRIPTION, info.description) + put(JtxContract.JtxCollection.OWNER, info.owner?.toString()) + put(JtxContract.JtxCollection.COLOR, info.color) + put(JtxContract.JtxCollection.SUPPORTSVEVENT, info.supportsVEVENT) + put(JtxContract.JtxCollection.SUPPORTSVJOURNAL, info.supportsVJOURNAL) + put(JtxContract.JtxCollection.SUPPORTSVTODO, info.supportsVTODO) + put(JtxContract.JtxCollection.ACCOUNT_NAME, account.name) + put(JtxContract.JtxCollection.ACCOUNT_TYPE, account.type) + put(JtxContract.JtxCollection.READONLY, info.forceReadOnly || !info.privWriteContent) + } + } + + override val tag: String + get() = "jtx-${account.name}-$id" + override val title: String + get() = displayname ?: id.toString() + override var lastSyncState: SyncState? + get() = SyncState.fromString(syncstate) + set(value) { syncstate = value.toString() } + + fun updateCollection(info: Collection) { + val values = valuesFromCollection(info, account) + update(values) + } + + override fun findDeleted(): List { + val values = queryDeletedICalObjects() + val localJtxICalObjects = mutableListOf() + values.forEach { + localJtxICalObjects.add(LocalJtxICalObject.Factory.fromProvider(this, it)) + } + return localJtxICalObjects + } + + override fun findDirty(): List { + val values = queryDirtyICalObjects() + val localJtxICalObjects = mutableListOf() + values.forEach { + localJtxICalObjects.add(LocalJtxICalObject.Factory.fromProvider(this, it)) + } + return localJtxICalObjects + } + + override fun findByName(name: String): LocalJtxICalObject? { + val values = queryByFilename(name) ?: return null + return LocalJtxICalObject.Factory.fromProvider(this, values) + } + + override fun markNotDirty(flags: Int)= updateSetFlags(flags) + + override fun removeNotDirtyMarked(flags: Int) = deleteByFlags(flags) + + override fun forgetETags() = updateSetETag(null) + + + object Factory: JtxCollectionFactory { + override fun newInstance(account: Account, client: ContentProviderClient, id: Long) = LocalJtxCollection(account, client, id) + } + +} \ No newline at end of file diff --git a/app/src/main/java/at/bitfire/davdroid/resource/LocalJtxICalObject.kt b/app/src/main/java/at/bitfire/davdroid/resource/LocalJtxICalObject.kt new file mode 100644 index 0000000000000000000000000000000000000000..1627fc576a8c0e780012626ea75646e1a73fdf02 --- /dev/null +++ b/app/src/main/java/at/bitfire/davdroid/resource/LocalJtxICalObject.kt @@ -0,0 +1,50 @@ +/*************************************************************************************************** + * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. + **************************************************************************************************/ + +package at.bitfire.davdroid.resource + +import android.content.ContentValues +import at.bitfire.ical4android.JtxCollection +import at.bitfire.ical4android.JtxICalObject +import at.bitfire.ical4android.JtxICalObjectFactory +import at.techbee.jtx.JtxContract + +class LocalJtxICalObject( + collection: JtxCollection<*>, + fileName: String?, + eTag: String?, + scheduleTag: String?, + flags: Int +) : + JtxICalObject(collection), + LocalResource { + + + init { + this.fileName = fileName + this.eTag = eTag + this.flags = flags + this.scheduleTag = scheduleTag + } + + + object Factory : JtxICalObjectFactory { + + override fun fromProvider( + collection: JtxCollection, + values: ContentValues + ): LocalJtxICalObject { + val fileName = values.getAsString(JtxContract.JtxICalObject.FILENAME) + val eTag = values.getAsString(JtxContract.JtxICalObject.ETAG) + val scheduleTag = values.getAsString(JtxContract.JtxICalObject.SCHEDULETAG) + val flags = values.getAsInteger(JtxContract.JtxICalObject.FLAGS)?: 0 + + val localJtxICalObject = LocalJtxICalObject(collection, fileName, eTag, scheduleTag, flags) + localJtxICalObject.populateFromContentValues(values) + + return localJtxICalObject + } + + } +} \ No newline at end of file diff --git a/app/src/main/java/at/bitfire/davdroid/resource/LocalResource.kt b/app/src/main/java/at/bitfire/davdroid/resource/LocalResource.kt index 01c3b5332122d71f1a55a5dddfaf0fc74e592204..933d63f9c6c223d893a8223153205fcc1ef7f04a 100644 --- a/app/src/main/java/at/bitfire/davdroid/resource/LocalResource.kt +++ b/app/src/main/java/at/bitfire/davdroid/resource/LocalResource.kt @@ -1,10 +1,6 @@ -/* - * Copyright © Ricki Hirner (bitfire web engineering). - * All rights reserved. This program and the accompanying materials - * are made available under the terms of the GNU Public License v3.0 - * which accompanies this distribution, and is available at - * http://www.gnu.org/licenses/gpl.html - */ +/*************************************************************************************************** + * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. + **************************************************************************************************/ package at.bitfire.davdroid.resource @@ -29,25 +25,42 @@ interface LocalResource { val id: Long? /** - * Remote file name for the resource, for instance `mycontact.vcf`. + * Remote file name for the resource, for instance `mycontact.vcf`. Also used to determine whether + * a dirty record has just been created (in this case, [fileName] is *null*) or modified + * (in this case, [fileName] is the remote file name). */ val fileName: String? + + /** remote ETag for the resource */ var eTag: String? + + /** remote Schedule-Tag for the resource */ + var scheduleTag: String? + + /** bitfield of flags; currently either [FLAG_REMOTELY_PRESENT] or 0 */ val flags: Int /** - * Generates a new UID and file name and assigns them to this resource. Typically used - * before uploading a resource which has just been created locally. + * Prepares the resource for uploading: + * + * 1. If the resource doesn't have an UID yet, this method generates one and writes it to the content provider. + * 2. The new file name which can be used for the upload is derived from the UID and returned, but not + * saved to the content provider. The sync manager is responsible for saving the file name that + * was actually used. + * + * @return new file name of the resource (like ".vcf") */ - fun assignNameAndUID() + fun prepareForUpload(): String /** * Unsets the /dirty/ field of the resource. Typically used after successfully uploading a * locally modified resource. * + * @param fileName If this argument is not *null*, [LocalResource.fileName] will be set to its value. * @param eTag ETag of the uploaded resource as returned by the server (null if the server didn't return one) + * @param scheduleTag CalDAV Schedule-Tag of the uploaded resource as returned by the server (null if not applicable or if the server didn't return one) */ - fun clearDirty(eTag: String?) + fun clearDirty(fileName: String?, eTag: String?, scheduleTag: String? = null) /** * Sets (local) flags of the resource. At the moment, the only allowed values are @@ -58,18 +71,21 @@ interface LocalResource { /** * Adds the data object to the content provider and ensures that the dirty flag is clear. + * * @return content URI of the created row (e.g. event URI) */ fun add(): Uri /** * Updates the data object in the content provider and ensures that the dirty flag is clear. + * * @return content URI of the updated row (e.g. event URI) */ fun update(data: TData): Uri /** * Deletes the data object from the content provider. + * * @return number of affected rows */ fun delete(): Int diff --git a/app/src/main/java/at/bitfire/davdroid/resource/LocalTask.kt b/app/src/main/java/at/bitfire/davdroid/resource/LocalTask.kt index 75c3cc09d357c1531642b81db2a03dad730322b5..20632582420d7569938bf3529dabd0f668b2d0b9 100644 --- a/app/src/main/java/at/bitfire/davdroid/resource/LocalTask.kt +++ b/app/src/main/java/at/bitfire/davdroid/resource/LocalTask.kt @@ -1,19 +1,11 @@ -/* - * Copyright © Ricki Hirner (bitfire web engineering). - * All rights reserved. This program and the accompanying materials - * are made available under the terms of the GNU Public License v3.0 - * which accompanies this distribution, and is available at - * http://www.gnu.org/licenses/gpl.html - */ +/*************************************************************************************************** + * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. + **************************************************************************************************/ package at.bitfire.davdroid.resource -import android.content.ContentProviderOperation import android.content.ContentValues -import at.bitfire.ical4android.AndroidTask -import at.bitfire.ical4android.AndroidTaskFactory -import at.bitfire.ical4android.AndroidTaskList -import at.bitfire.ical4android.Task +import at.bitfire.ical4android.* import org.dmfs.tasks.contract.TaskContract.Tasks import java.util.* @@ -25,6 +17,8 @@ class LocalTask: AndroidTask, LocalResource { } override var fileName: String? = null + + override var scheduleTag: String? = null override var eTag: String? = null override var flags = 0 @@ -48,7 +42,7 @@ class LocalTask: AndroidTask, LocalResource { /* process LocalTask-specific fields */ - override fun buildTask(builder: ContentProviderOperation.Builder, update: Boolean) { + override fun buildTask(builder: BatchOperation.CpoBuilder, update: Boolean) { super.buildTask(builder, update) builder .withValue(Tasks._SYNC_ID, fileName) @@ -59,27 +53,41 @@ class LocalTask: AndroidTask, LocalResource { /* custom queries */ - override fun assignNameAndUID() { - val uid = UUID.randomUUID().toString() - val newFileName = "$uid.ics" + override fun prepareForUpload(): String { + var uid: String? = null + taskList.provider.client.query(taskSyncURI(), arrayOf(Tasks._UID), null, null, null)?.use { cursor -> + if (cursor.moveToNext()) + uid = cursor.getString(0) + } - val values = ContentValues(2) - values.put(Tasks._SYNC_ID, newFileName) - values.put(Tasks._UID, uid) - taskList.provider.client.update(taskSyncURI(), values, null, null) + if (uid == null) { + // generate new UID + uid = UUID.randomUUID().toString() + + val values = ContentValues(1) + values.put(Tasks._UID, uid) + taskList.provider.client.update(taskSyncURI(), values, null, null) - fileName = newFileName + task!!.uid = uid + } - task!!.uid = uid + return "$uid.ics" } - override fun clearDirty(eTag: String?) { - val values = ContentValues(3) - values.put(Tasks._DIRTY, 0) + override fun clearDirty(fileName: String?, eTag: String?, scheduleTag: String?) { + if (scheduleTag != null) + Ical4Android.log.fine("Schedule-Tag for tasks not supported yet, won't save") + + val values = ContentValues(4) + if (fileName != null) + values.put(Tasks._SYNC_ID, fileName) values.put(COLUMN_ETAG, eTag) values.put(Tasks.SYNC_VERSION, task!!.sequence) + values.put(Tasks._DIRTY, 0) taskList.provider.client.update(taskSyncURI(), values, null, null) + if (fileName != null) + this.fileName = fileName this.eTag = eTag } diff --git a/app/src/main/java/at/bitfire/davdroid/resource/LocalTaskList.kt b/app/src/main/java/at/bitfire/davdroid/resource/LocalTaskList.kt index 662723c731f2b766f7f6831114abb4997780b9fb..20ea115a1e207f5f5a6325f04409249b5fcb8056 100644 --- a/app/src/main/java/at/bitfire/davdroid/resource/LocalTaskList.kt +++ b/app/src/main/java/at/bitfire/davdroid/resource/LocalTaskList.kt @@ -1,27 +1,19 @@ -/* - * Copyright © Ricki Hirner (bitfire web engineering). - * All rights reserved. This program and the accompanying materials - * are made available under the terms of the GNU Public License v3.0 - * which accompanies this distribution, and is available at - * http://www.gnu.org/licenses/gpl.html - */ +/*************************************************************************************************** + * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. + **************************************************************************************************/ package at.bitfire.davdroid.resource import android.accounts.Account import android.annotation.SuppressLint -import android.content.ContentProviderClient -import android.content.ContentResolver import android.content.ContentValues import android.content.Context import android.net.Uri -import android.os.Build import at.bitfire.davdroid.Constants import at.bitfire.davdroid.DavUtils -import at.bitfire.davdroid.closeCompat +import at.bitfire.davdroid.db.Collection +import at.bitfire.davdroid.db.SyncState import at.bitfire.davdroid.log.Logger -import at.bitfire.davdroid.model.Collection -import at.bitfire.davdroid.model.SyncState import at.bitfire.ical4android.AndroidTaskList import at.bitfire.ical4android.AndroidTaskListFactory import at.bitfire.ical4android.TaskProvider @@ -37,20 +29,6 @@ class LocalTaskList private constructor( companion object { - fun tasksProviderAvailable(context: Context): Boolean { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) - return context.packageManager.resolveContentProvider(TaskProvider.ProviderName.OpenTasks.authority, 0) != null - else - try { - TaskProvider.acquire(context, TaskProvider.ProviderName.OpenTasks)?.use { - return true - } - } catch (e: Exception) { - // couldn't acquire task provider - } - return false - } - fun create(account: Account, provider: TaskProvider, info: Collection): Uri { val values = valuesFromCollectionInfo(info, true) values.put(TaskLists.OWNER, account.name) @@ -61,17 +39,15 @@ class LocalTaskList private constructor( @SuppressLint("Recycle") @Throws(Exception::class) - fun onRenameAccount(resolver: ContentResolver, oldName: String, newName: String) { - var client: ContentProviderClient? = null - try { - client = resolver.acquireContentProviderClient(TaskProvider.ProviderName.OpenTasks.authority) - client?.use { - val values = ContentValues(1) - values.put(Tasks.ACCOUNT_NAME, newName) - it.update(Tasks.getContentUri(TaskProvider.ProviderName.OpenTasks.authority), values, "${Tasks.ACCOUNT_NAME}=?", arrayOf(oldName)) - } - } finally { - client?.closeCompat() + fun onRenameAccount(context: Context, oldName: String, newName: String) { + TaskProvider.acquire(context)?.use { provider -> + val values = ContentValues(1) + values.put(Tasks.ACCOUNT_NAME, newName) + provider.client.update( + Tasks.getContentUri(provider.name.authority), + values, + "${Tasks.ACCOUNT_NAME}=?", arrayOf(oldName) + ) } } @@ -88,6 +64,9 @@ class LocalTaskList private constructor( } + override val tag: String + get() = "tasks-${account.name}-$id" + override val title: String get() = name ?: id.toString() @@ -122,19 +101,20 @@ class LocalTaskList private constructor( override fun findDirty(): List { val tasks = queryTasks(Tasks._DIRTY, null) for (localTask in tasks) { - val task = requireNotNull(localTask.task) - val sequence = task.sequence - if (sequence == null) // sequence has not been assigned yet (i.e. this task was just locally created) - task.sequence = 0 - else - task.sequence = sequence + 1 + try { + val task = requireNotNull(localTask.task) + val sequence = task.sequence + if (sequence == null) // sequence has not been assigned yet (i.e. this task was just locally created) + task.sequence = 0 + else // task was modified, increase sequence + task.sequence = sequence + 1 + } catch(e: Exception) { + Logger.log.log(Level.WARNING, "Couldn't check/increase sequence", e) + } } return tasks } - override fun findDirtyWithoutNameOrUid() = - queryTasks("${Tasks._DIRTY} AND (${Tasks._SYNC_ID} IS NULL OR ${Tasks._UID} IS NULL)", null) - override fun findByName(name: String) = queryTasks("${Tasks._SYNC_ID}=?", arrayOf(name)).firstOrNull() diff --git a/app/src/main/java/at/bitfire/davdroid/resource/TaskUtils.kt b/app/src/main/java/at/bitfire/davdroid/resource/TaskUtils.kt new file mode 100644 index 0000000000000000000000000000000000000000..705ec653551b735193aa34aaad0a45897f0f85fe --- /dev/null +++ b/app/src/main/java/at/bitfire/davdroid/resource/TaskUtils.kt @@ -0,0 +1,50 @@ +/*************************************************************************************************** + * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. + **************************************************************************************************/ + +package at.bitfire.davdroid.resource + +import android.content.Context +import at.bitfire.davdroid.settings.Settings +import at.bitfire.davdroid.settings.SettingsManager +import at.bitfire.davdroid.syncadapter.SyncUtils +import at.bitfire.ical4android.TaskProvider.ProviderName +import dagger.hilt.EntryPoint +import dagger.hilt.InstallIn +import dagger.hilt.android.EntryPointAccessors +import dagger.hilt.components.SingletonComponent +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch + +object TaskUtils { + + @EntryPoint + @InstallIn(SingletonComponent::class) + interface TaskUtilsEntryPoint { + fun settingsManager(): SettingsManager + } + + fun currentProvider(context: Context): ProviderName? { + val settingsManager = EntryPointAccessors.fromApplication(context, TaskUtilsEntryPoint::class.java).settingsManager() + val preferredAuthority = settingsManager.getString(Settings.PREFERRED_TASKS_PROVIDER) + ProviderName.values() + .sortedByDescending { it.authority == preferredAuthority } + .forEach { providerName -> + if (context.packageManager.resolveContentProvider(providerName.authority, 0) != null) + return providerName + } + return null + } + + fun isAvailable(context: Context) = currentProvider(context) != null + + fun setPreferredProvider(context: Context, providerName: ProviderName) { + val settingsManager = EntryPointAccessors.fromApplication(context, TaskUtilsEntryPoint::class.java).settingsManager() + settingsManager.putString(Settings.PREFERRED_TASKS_PROVIDER, providerName.authority) + CoroutineScope(Dispatchers.Default).launch { + SyncUtils.updateTaskSync(context) + } + } + +} \ No newline at end of file diff --git a/app/src/main/java/at/bitfire/davdroid/resource/contactrow/CachedGroupMembershipHandler.kt b/app/src/main/java/at/bitfire/davdroid/resource/contactrow/CachedGroupMembershipHandler.kt new file mode 100644 index 0000000000000000000000000000000000000000..6ea2f7f66ad982433f1428666982cbafa7192a4f --- /dev/null +++ b/app/src/main/java/at/bitfire/davdroid/resource/contactrow/CachedGroupMembershipHandler.kt @@ -0,0 +1,28 @@ +/*************************************************************************************************** + * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. + **************************************************************************************************/ + +package at.bitfire.davdroid.resource.contactrow + +import android.content.ContentValues +import at.bitfire.davdroid.log.Logger +import at.bitfire.davdroid.resource.LocalContact +import at.bitfire.vcard4android.CachedGroupMembership +import at.bitfire.vcard4android.Contact +import at.bitfire.vcard4android.GroupMethod +import at.bitfire.vcard4android.contactrow.DataRowHandler + +class CachedGroupMembershipHandler(val localContact: LocalContact): DataRowHandler() { + + override fun forMimeType() = CachedGroupMembership.CONTENT_ITEM_TYPE + + override fun handle(values: ContentValues, contact: Contact) { + super.handle(values, contact) + + if (localContact.addressBook.groupMethod == GroupMethod.GROUP_VCARDS) + localContact.cachedGroupMemberships += values.getAsLong(CachedGroupMembership.GROUP_ID) + else + Logger.log.warning("Ignoring cached group membership for group method CATEGORIES") + } + +} \ No newline at end of file diff --git a/app/src/main/java/at/bitfire/davdroid/resource/contactrow/GroupMembershipBuilder.kt b/app/src/main/java/at/bitfire/davdroid/resource/contactrow/GroupMembershipBuilder.kt new file mode 100644 index 0000000000000000000000000000000000000000..c092352171bdcd7b6538c1c4ad19bd670d9182ae --- /dev/null +++ b/app/src/main/java/at/bitfire/davdroid/resource/contactrow/GroupMembershipBuilder.kt @@ -0,0 +1,43 @@ +/*************************************************************************************************** + * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. + **************************************************************************************************/ + +package at.bitfire.davdroid.resource.contactrow + +import android.net.Uri +import android.provider.ContactsContract.CommonDataKinds.GroupMembership +import at.bitfire.davdroid.resource.LocalAddressBook +import at.bitfire.vcard4android.BatchOperation +import at.bitfire.vcard4android.Contact +import at.bitfire.vcard4android.GroupMethod +import at.bitfire.vcard4android.contactrow.DataRowBuilder +import java.util.* + +class GroupMembershipBuilder(dataRowUri: Uri, rawContactId: Long?, contact: Contact, val addressBook: LocalAddressBook) + : DataRowBuilder(Factory.MIME_TYPE, dataRowUri, rawContactId, contact) { + + override fun build(): List { + val result = LinkedList() + + if (addressBook.groupMethod == GroupMethod.CATEGORIES) + for (category in contact.categories) + result += newDataRow().withValue(GroupMembership.GROUP_ROW_ID, addressBook.findOrCreateGroup(category)) + else { + // GroupMethod.GROUP_VCARDS -> memberships are handled by LocalGroups (and not by the members = LocalContacts, which we are processing here) + // TODO: CATEGORIES <-> unknown properties + } + + return result + } + + + class Factory(val addressBook: LocalAddressBook): DataRowBuilder.Factory { + companion object { + const val MIME_TYPE = GroupMembership.CONTENT_ITEM_TYPE + } + override fun mimeType() = MIME_TYPE + override fun newInstance(dataRowUri: Uri, rawContactId: Long?, contact: Contact) = + GroupMembershipBuilder(dataRowUri, rawContactId, contact, addressBook) + } + +} \ No newline at end of file diff --git a/app/src/main/java/at/bitfire/davdroid/resource/contactrow/GroupMembershipHandler.kt b/app/src/main/java/at/bitfire/davdroid/resource/contactrow/GroupMembershipHandler.kt new file mode 100644 index 0000000000000000000000000000000000000000..e95ab4308d2ebf125635a8a08362cc3e01bf819d --- /dev/null +++ b/app/src/main/java/at/bitfire/davdroid/resource/contactrow/GroupMembershipHandler.kt @@ -0,0 +1,40 @@ +/*************************************************************************************************** + * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. + **************************************************************************************************/ + +package at.bitfire.davdroid.resource.contactrow + +import android.content.ContentValues +import android.provider.ContactsContract.CommonDataKinds.GroupMembership +import at.bitfire.davdroid.log.Logger +import at.bitfire.davdroid.resource.LocalContact +import at.bitfire.vcard4android.Contact +import at.bitfire.vcard4android.GroupMethod +import at.bitfire.vcard4android.contactrow.DataRowHandler +import org.apache.commons.lang3.StringUtils +import java.io.FileNotFoundException + +class GroupMembershipHandler(val localContact: LocalContact): DataRowHandler() { + + override fun forMimeType() = GroupMembership.CONTENT_ITEM_TYPE + + override fun handle(values: ContentValues, contact: Contact) { + super.handle(values, contact) + + val groupId = values.getAsLong(GroupMembership.GROUP_ROW_ID) + localContact.groupMemberships += groupId + + if (localContact.addressBook.groupMethod == GroupMethod.CATEGORIES) { + try { + val group = localContact.addressBook.findGroupById(groupId) + StringUtils.trimToNull(group.getContact().displayName)?.let { groupName -> + Logger.log.fine("Adding membership in group $groupName as category") + contact.categories.add(groupName) + } + } catch (ignored: FileNotFoundException) { + Logger.log.warning("Contact is member in group $groupId which doesn't exist anymore") + } + } + } + +} \ No newline at end of file diff --git a/app/src/main/java/at/bitfire/davdroid/resource/contactrow/UnknownProperties.kt b/app/src/main/java/at/bitfire/davdroid/resource/contactrow/UnknownProperties.kt new file mode 100644 index 0000000000000000000000000000000000000000..c7f55b358535dabe8a53b938ccef81b493dad485 --- /dev/null +++ b/app/src/main/java/at/bitfire/davdroid/resource/contactrow/UnknownProperties.kt @@ -0,0 +1,17 @@ +/*************************************************************************************************** + * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. + **************************************************************************************************/ + +package at.bitfire.davdroid.resource.contactrow + +import android.provider.ContactsContract.RawContacts + +object UnknownProperties { + + const val CONTENT_ITEM_TYPE = "x.davdroid/unknown-properties" + + const val MIMETYPE = RawContacts.Data.MIMETYPE + const val RAW_CONTACT_ID = RawContacts.Data.RAW_CONTACT_ID + const val UNKNOWN_PROPERTIES = RawContacts.Data.DATA1 + +} diff --git a/app/src/main/java/at/bitfire/davdroid/resource/contactrow/UnknownPropertiesBuilder.kt b/app/src/main/java/at/bitfire/davdroid/resource/contactrow/UnknownPropertiesBuilder.kt new file mode 100644 index 0000000000000000000000000000000000000000..6209e53badac5dbe96f43e0f8d4b11f7c8ec1c97 --- /dev/null +++ b/app/src/main/java/at/bitfire/davdroid/resource/contactrow/UnknownPropertiesBuilder.kt @@ -0,0 +1,31 @@ +/*************************************************************************************************** + * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. + **************************************************************************************************/ + +package at.bitfire.davdroid.resource.contactrow + +import android.net.Uri +import at.bitfire.vcard4android.BatchOperation +import at.bitfire.vcard4android.Contact +import at.bitfire.vcard4android.contactrow.DataRowBuilder +import java.util.* + +class UnknownPropertiesBuilder(dataRowUri: Uri, rawContactId: Long?, contact: Contact) + : DataRowBuilder(Factory.mimeType(), dataRowUri, rawContactId, contact) { + + override fun build(): List { + val result = LinkedList() + contact.unknownProperties?.let { unknownProperties -> + result += newDataRow().withValue(UnknownProperties.UNKNOWN_PROPERTIES, unknownProperties) + } + return result + } + + + object Factory: DataRowBuilder.Factory { + override fun mimeType() = UnknownProperties.CONTENT_ITEM_TYPE + override fun newInstance(dataRowUri: Uri, rawContactId: Long?, contact: Contact) = + UnknownPropertiesBuilder(dataRowUri, rawContactId, contact) + } + +} \ No newline at end of file diff --git a/app/src/main/java/at/bitfire/davdroid/resource/contactrow/UnknownPropertiesHandler.kt b/app/src/main/java/at/bitfire/davdroid/resource/contactrow/UnknownPropertiesHandler.kt new file mode 100644 index 0000000000000000000000000000000000000000..9ed0fbdc228cc92a4fd5cb8ff0de411e00788de0 --- /dev/null +++ b/app/src/main/java/at/bitfire/davdroid/resource/contactrow/UnknownPropertiesHandler.kt @@ -0,0 +1,21 @@ +/*************************************************************************************************** + * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. + **************************************************************************************************/ + +package at.bitfire.davdroid.resource.contactrow + +import android.content.ContentValues +import at.bitfire.vcard4android.Contact +import at.bitfire.vcard4android.contactrow.DataRowHandler + +object UnknownPropertiesHandler: DataRowHandler() { + + override fun forMimeType() = UnknownProperties.CONTENT_ITEM_TYPE + + override fun handle(values: ContentValues, contact: Contact) { + super.handle(values, contact) + + contact.unknownProperties = values.getAsString(UnknownProperties.UNKNOWN_PROPERTIES) + } + +} \ No newline at end of file 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 737e333aea06e29c1522bda5e5ca1fe73248c6c6..aa3790b3c8f7cf6842d0730c1dacd067a93a4bf8 100644 --- a/app/src/main/java/at/bitfire/davdroid/settings/AccountSettings.kt +++ b/app/src/main/java/at/bitfire/davdroid/settings/AccountSettings.kt @@ -1,10 +1,6 @@ -/* - * Copyright © Ricki Hirner (bitfire web engineering). - * All rights reserved. This program and the accompanying materials - * are made available under the terms of the GNU Public License v3.0 - * which accompanies this distribution, and is available at - * http://www.gnu.org/licenses/gpl.html - */ +/*************************************************************************************************** + * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. + **************************************************************************************************/ package at.bitfire.davdroid.settings import android.accounts.Account @@ -19,46 +15,87 @@ import android.os.Bundle import android.os.Parcel import android.os.RemoteException import android.provider.CalendarContract +import android.provider.CalendarContract.ExtendedProperties import android.provider.ContactsContract +import android.util.Base64 +import androidx.annotation.WorkerThread import androidx.core.content.ContextCompat -import at.bitfire.davdroid.* +import androidx.preference.PreferenceManager +import at.bitfire.davdroid.Constants +import at.bitfire.davdroid.InvalidAccountException +import at.bitfire.davdroid.R +import at.bitfire.davdroid.closeCompat +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.model.AppDatabase -import at.bitfire.davdroid.model.Collection -import at.bitfire.davdroid.model.Credentials -import at.bitfire.davdroid.model.Service import at.bitfire.davdroid.resource.LocalAddressBook import at.bitfire.davdroid.resource.LocalTask +import at.bitfire.davdroid.resource.TaskUtils +import at.bitfire.davdroid.syncadapter.SyncUtils import at.bitfire.ical4android.AndroidCalendar +import at.bitfire.ical4android.AndroidEvent import at.bitfire.ical4android.TaskProvider import at.bitfire.ical4android.TaskProvider.ProviderName.OpenTasks +import at.bitfire.ical4android.UnknownProperty import at.bitfire.vcard4android.ContactsStorageException import at.bitfire.vcard4android.GroupMethod -import okhttp3.HttpUrl +import at.techbee.jtx.JtxContract.asSyncAdapter +import dagger.hilt.EntryPoint +import dagger.hilt.InstallIn +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 +import java.io.ByteArrayInputStream +import java.io.ObjectInputStream import java.util.logging.Level /** * Manages settings of an account. * + * @param context Required to access account settings + * @param argAccount Account to take settings from. If this account is an address book account, + * settings will be taken from the corresponding main account instead. + * * @throws InvalidAccountException on construction when the account doesn't exist (anymore) + * @throws IllegalArgumentException when the account type is not _DAVx5_ or _DAVx5 address book_ */ +@Suppress("FunctionName") class AccountSettings( - val context: Context, - val account: Account + val context: Context, + argAccount: Account ) { + @EntryPoint + @InstallIn(SingletonComponent::class) + interface AccountSettingsEntryPoint { + fun appDatabase(): AppDatabase + fun settingsManager(): SettingsManager + } + companion object { - const val CURRENT_VERSION = 10 + const val CURRENT_VERSION = 13 const val KEY_SETTINGS_VERSION = "version" + const val KEY_SYNC_INTERVAL_ADDRESSBOOKS = "sync_interval_addressbooks" + const val KEY_SYNC_INTERVAL_CALENDARS = "sync_interval_calendars" + + /** Stores the tasks sync interval (in seconds) so that it can be set again when the provider is switched */ + 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) - const val WIFI_ONLY_DEFAULT = false const val KEY_WIFI_ONLY_SSIDS = "wifi_only_ssids" // restrict sync to specific WiFi SSIDs /** Time range limitation to the past [in days]. Values: @@ -77,45 +114,119 @@ class AccountSettings( */ const val KEY_DEFAULT_ALARM = "default_alarm" - /* Whether DAVx5 sets the local calendar color to the value from service DB at every sync - value = null (not existing) true (default) - "0" false */ + /** Whether DAVx5 sets the local calendar color to the value from service DB at every sync + value = *null* (not existing): true (default); + "0" false */ const val KEY_MANAGE_CALENDAR_COLORS = "manage_calendar_colors" - /* Whether DAVx5 populates and uses CalendarContract.Colors - value = null (not existing) false (default) - "1" true */ + /** Whether DAVx5 populates and uses CalendarContract.Colors + value = *null* (not existing) false (default); + "1" true */ const val KEY_EVENT_COLORS = "event_colors" /** Contact group method: - value = null (not existing) groups as separate VCards (default) - "CATEGORIES" groups are per-contact CATEGORIES + *null (not existing)* groups as separate vCards (default); + "CATEGORIES" groups are per-contact CATEGORIES */ const val KEY_CONTACT_GROUP_METHOD = "contact_group_method" + /** UI preference: Show only personal collections + value = *null* (not existing) show all collections (default); + "1" show only personal collections */ + const val KEY_SHOW_ONLY_PERSONAL = "show_only_personal" + const val SYNC_INTERVAL_MANUALLY = -1L - fun initialUserData(credentials: Credentials): Bundle { + const val CONTACTS_APP_INTERACTION = "z-app-generated--contactsinteraction--recent/" + + fun initialUserData(credentials: Credentials?, baseURL: String?): Bundle { val bundle = Bundle(2) bundle.putString(KEY_SETTINGS_VERSION, CURRENT_VERSION.toString()) - when (credentials.type) { - Credentials.Type.UsernamePassword -> + if (credentials != null) { + if (credentials.userName != null) { bundle.putString(KEY_USERNAME, credentials.userName) - Credentials.Type.ClientCertificate -> + 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 } + fun repairSyncIntervals(context: Context) { + val addressBooksAuthority = context.getString(R.string.address_books_authority) + val taskAuthority = TaskUtils.currentProvider(context)?.authority + + val am = AccountManager.get(context) + for (account in am.getAccountsByType(context.getString(R.string.account_type))) + try { + val settings = AccountSettings(context, account) + + // repair address book sync + settings.getSavedAddressbooksSyncInterval()?.let { shouldBe -> + val current = settings.getSyncInterval(addressBooksAuthority) + if (current != shouldBe) { + Logger.log.warning("${account.name}: $addressBooksAuthority sync interval should be $shouldBe but is $current -> setting to $current") + settings.setSyncInterval(addressBooksAuthority, shouldBe) + } + } + + // repair calendar sync + settings.getSavedCalendarsSyncInterval()?.let { shouldBe -> + val current = settings.getSyncInterval(CalendarContract.AUTHORITY) + if (current != shouldBe) { + Logger.log.warning("${account.name}: ${CalendarContract.AUTHORITY} sync interval should be $shouldBe but is $current -> setting to $current") + settings.setSyncInterval(CalendarContract.AUTHORITY, shouldBe) + } + } + + if (taskAuthority != null) + // repair calendar sync + settings.getSavedTasksSyncInterval()?.let { shouldBe -> + val current = settings.getSyncInterval(taskAuthority) + if (current != shouldBe) { + Logger.log.warning("${account.name}: $taskAuthority sync interval should be $shouldBe but is $current -> setting to $current") + settings.setSyncInterval(taskAuthority, shouldBe) + } + } + } catch (ignored: InvalidAccountException) { + // account doesn't exist (anymore) + } + } + } - - + + + val db = EntryPointAccessors.fromApplication(context, AccountSettingsEntryPoint::class.java).appDatabase() + val settings = EntryPointAccessors.fromApplication(context, AccountSettingsEntryPoint::class.java).settingsManager() + val accountManager: AccountManager = AccountManager.get(context) - val settings = Settings.getInstance(context) + val account: Account init { + when (argAccount.type) { + context.getString(R.string.account_type_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) -> + account = argAccount + else -> + throw IllegalArgumentException("Account type not supported") + } + + // synchronize because account migration must only be run one time synchronized(AccountSettings::class.java) { val versionStr = accountManager.getUserData(account, KEY_SETTINGS_VERSION) ?: throw InvalidAccountException(account) var version = 0 @@ -133,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) + } + } @@ -158,26 +290,90 @@ class AccountSettings( SYNC_INTERVAL_MANUALLY } - fun setSyncInterval(authority: String, seconds: Long) { - if (seconds == SYNC_INTERVAL_MANUALLY) { - ContentResolver.setSyncAutomatically(account, authority, false) - } else { - ContentResolver.setSyncAutomatically(account, authority, true) - ContentResolver.addPeriodicSync(account, authority, Bundle(), seconds) + /** + * Sets the sync interval and enables/disables automatic sync for the given account and authority. + * Does *not* call [ContentResolver.setIsSyncable]. + * + * This method blocks until the settings have arrived in the sync framework, so it should not + * be called from the UI thread. + * + * @param authority sync authority (like [CalendarContract.AUTHORITY]) + * @param seconds if [SYNC_INTERVAL_MANUALLY]: automatic sync will be disabled; + * otherwise: automatic sync will be enabled and set to the given number of seconds + * + * @return whether the sync interval was successfully set + */ + @WorkerThread + fun setSyncInterval(authority: String, seconds: Long): Boolean { + /* Ugly hack: because there is no callback for when the sync status/interval has been + updated, we need to make this call blocking. */ + val setInterval: () -> Boolean = + if (seconds == SYNC_INTERVAL_MANUALLY) { + { + Logger.log.fine("Disabling automatic sync of $account/$authority") + ContentResolver.setSyncAutomatically(account, authority, false) + + /* return */ !ContentResolver.getSyncAutomatically(account, authority) + } + } else { + { + Logger.log.fine("Setting automatic sync of $account/$authority to $seconds seconds") + ContentResolver.setSyncAutomatically(account, authority, true) + ContentResolver.addPeriodicSync(account, authority, Bundle(), seconds) + + /* return */ ContentResolver.getSyncAutomatically(account, authority) && + ContentResolver.getPeriodicSyncs(account, authority).firstOrNull()?.period == seconds + } + } + + // try up to 10 times with 100 ms pause + var success = false + for (idxTry in 0 until 10) { + success = setInterval() + if (success) + break + Thread.sleep(100) } + + if (!success) + return false + + // store sync interval in account settings (used when the provider is switched) + when { + authority == context.getString(R.string.address_books_authority) -> + accountManager.setUserData(account, KEY_SYNC_INTERVAL_ADDRESSBOOKS, seconds.toString()) + + authority == CalendarContract.AUTHORITY -> + accountManager.setUserData(account, KEY_SYNC_INTERVAL_CALENDARS, seconds.toString()) + + TaskProvider.ProviderName.values().any { it.authority == authority } -> + accountManager.setUserData(account, KEY_SYNC_INTERVAL_TASKS, seconds.toString()) + } + + return true } - fun getSyncWifiOnly() = if (settings.has(KEY_WIFI_ONLY)) - settings.getBoolean(KEY_WIFI_ONLY) ?: WIFI_ONLY_DEFAULT - else - accountManager.getUserData(account, KEY_WIFI_ONLY) != null + fun getSavedAddressbooksSyncInterval() = accountManager.getUserData(account, KEY_SYNC_INTERVAL_ADDRESSBOOKS)?.toLong() + fun getSavedCalendarsSyncInterval() = accountManager.getUserData(account, KEY_SYNC_INTERVAL_CALENDARS)?.toLong() + fun getSavedTasksSyncInterval() = accountManager.getUserData(account, KEY_SYNC_INTERVAL_TASKS)?.toLong() + + fun getSyncWifiOnly() = + if (settings.containsKey(KEY_WIFI_ONLY)) + settings.getBoolean(KEY_WIFI_ONLY) + else + accountManager.getUserData(account, KEY_WIFI_ONLY) != null fun setSyncWiFiOnly(wiFiOnly: Boolean) = accountManager.setUserData(account, KEY_WIFI_ONLY, if (wiFiOnly) "1" else null) - fun getSyncWifiOnlySSIDs(): List? = (if (settings.has(KEY_WIFI_ONLY_SSIDS)) - settings.getString(KEY_WIFI_ONLY_SSIDS) - else - accountManager.getUserData(account, KEY_WIFI_ONLY_SSIDS))?.split(',') + fun getSyncWifiOnlySSIDs(): List? = + if (getSyncWifiOnly()) { + val strSsids = if (settings.containsKey(KEY_WIFI_ONLY_SSIDS)) + settings.getString(KEY_WIFI_ONLY_SSIDS) + else + accountManager.getUserData(account, KEY_WIFI_ONLY_SSIDS) + strSsids?.split(',') + } else + null fun setSyncWifiOnlySSIDs(ssids: List?) = accountManager.setUserData(account, KEY_WIFI_ONLY_SSIDS, StringUtils.trimToNull(ssids?.joinToString(","))) @@ -210,7 +406,7 @@ class AccountSettings( */ fun getDefaultAlarm() = accountManager.getUserData(account, KEY_DEFAULT_ALARM)?.toInt() ?: - settings.getInt(KEY_DEFAULT_ALARM).takeIf { it != -1 } + settings.getIntOrNull(KEY_DEFAULT_ALARM)?.takeIf { it != -1 } /** * Sets the default alarm value in the local account settings, if the new value differs @@ -223,20 +419,20 @@ class AccountSettings( */ fun setDefaultAlarm(minBefore: Int?) = accountManager.setUserData(account, KEY_DEFAULT_ALARM, - if (minBefore == settings.getInt(KEY_DEFAULT_ALARM).takeIf { it != -1 }) + if (minBefore == settings.getIntOrNull(KEY_DEFAULT_ALARM)?.takeIf { it != -1 }) null else minBefore?.toString()) - fun getManageCalendarColors() = if (settings.has(KEY_MANAGE_CALENDAR_COLORS)) - settings.getBoolean(KEY_MANAGE_CALENDAR_COLORS) ?: false + fun getManageCalendarColors() = if (settings.containsKey(KEY_MANAGE_CALENDAR_COLORS)) + settings.getBoolean(KEY_MANAGE_CALENDAR_COLORS) else accountManager.getUserData(account, KEY_MANAGE_CALENDAR_COLORS) == null fun setManageCalendarColors(manage: Boolean) = accountManager.setUserData(account, KEY_MANAGE_CALENDAR_COLORS, if (manage) null else "0") - fun getEventColors() = if (settings.has(KEY_EVENT_COLORS)) - settings.getBoolean(KEY_EVENT_COLORS) ?: false + fun getEventColors() = if (settings.containsKey(KEY_EVENT_COLORS)) + settings.getBoolean(KEY_EVENT_COLORS) else accountManager.getUserData(account, KEY_EVENT_COLORS) != null fun setEventColors(useColors: Boolean) = @@ -261,6 +457,28 @@ class AccountSettings( } + // UI settings + + /** + * Whether only personal collections should be shown. + * + * @return [Pair] of values: + * + * 1. (first) whether only personal collections should be shown + * 2. (second) whether the user shall be able to change the setting (= setting not locked) + */ + fun getShowOnlyPersonal(): Pair = + when (settings.getIntOrNull(KEY_SHOW_ONLY_PERSONAL)) { + 0 -> Pair(false, false) + 1 -> Pair(true, false) + else /* including -1 */ -> Pair(accountManager.getUserData(account, KEY_SHOW_ONLY_PERSONAL) != null, true) + } + + fun setShowOnlyPersonal(showOnlyPersonal: Boolean) { + accountManager.setUserData(account, KEY_SHOW_ONLY_PERSONAL, if (showOnlyPersonal) "1" else null) + } + + // update from previous account settings private fun update(baseVersion: Int) { @@ -280,6 +498,137 @@ class AccountSettings( } + @Suppress("unused","FunctionName") + /** + * Not a per-account migration, but not a database migration, too, so it fits best there. + * Best future solution would be that SettingsManager manages versions and migrations. + * + * Updates proxy settings from override_proxy_* to proxy_type, proxy_host, proxy_port. + */ + private fun update_12_13() { + // proxy settings are managed by SharedPreferencesProvider + val preferences = PreferenceManager.getDefaultSharedPreferences(context) + + // old setting names + val overrideProxy = "override_proxy" + val overrideProxyHost = "override_proxy_host" + val overrideProxyPort = "override_proxy_port" + + val edit = preferences.edit() + if (preferences.contains(overrideProxy)) { + if (preferences.getBoolean(overrideProxy, false)) + // override_proxy set, migrate to proxy_type = HTTP + edit.putInt(Settings.PROXY_TYPE, Settings.PROXY_TYPE_HTTP) + edit.remove(overrideProxy) + } + if (preferences.contains(overrideProxyHost)) { + preferences.getString(overrideProxyHost, null)?.let { host -> + edit.putString(Settings.PROXY_HOST, host) + } + edit.remove(overrideProxyHost) + } + if (preferences.contains(overrideProxyPort)) { + val port = preferences.getInt(overrideProxyPort, 0) + if (port != 0) + edit.putInt(Settings.PROXY_PORT, port) + 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) + } + } + } + } + + + @Suppress("unused","FunctionName") + /** + * Store event URLs as URL (extended property) instead of unknown property. At the same time, + * convert legacy unknown properties to the current format. + */ + private fun update_11_12() { + if (ContextCompat.checkSelfPermission(context, android.Manifest.permission.WRITE_CALENDAR) == PackageManager.PERMISSION_GRANTED) + context.contentResolver.acquireContentProviderClient(CalendarContract.AUTHORITY)?.use { provider -> + // Attention: CalendarProvider does NOT limit the results of the ExtendedProperties query + // to the given account! So all extended properties will be processed number-of-accounts times. + val extUri = ExtendedProperties.CONTENT_URI.asSyncAdapter(account) + + provider.query(extUri, arrayOf( + ExtendedProperties._ID, // idx 0 + ExtendedProperties.NAME, // idx 1 + ExtendedProperties.VALUE // idx 2 + ), null, null, null)?.use { cursor -> + while (cursor.moveToNext()) { + val id = cursor.getLong(0) + val rawValue = cursor.getString(2) + + val uri by lazy { + ContentUris.withAppendedId(ExtendedProperties.CONTENT_URI, id).asSyncAdapter(account) + } + + when (cursor.getString(1)) { + UnknownProperty.CONTENT_ITEM_TYPE -> { + // unknown property; check whether it's a URL + try { + val property = UnknownProperty.fromJsonString(rawValue) + if (property is Url) { // rewrite to MIMETYPE_URL + val newValues = ContentValues(2) + newValues.put(ExtendedProperties.NAME, AndroidEvent.MIMETYPE_URL) + newValues.put(ExtendedProperties.VALUE, property.value) + provider.update(uri, newValues, null, null) + } + } catch (e: Exception) { + Logger.log.log(Level.WARNING, "Couldn't rewrite URL from unknown property to ${AndroidEvent.MIMETYPE_URL}", e) + } + } + "unknown-property" -> { + // unknown property (deprecated format); convert to current format + try { + val stream = ByteArrayInputStream(Base64.decode(rawValue, Base64.NO_WRAP)) + ObjectInputStream(stream).use { + (it.readObject() as? Property)?.let { property -> + // rewrite to current format + val newValues = ContentValues(2) + newValues.put(ExtendedProperties.NAME, UnknownProperty.CONTENT_ITEM_TYPE) + newValues.put(ExtendedProperties.VALUE, UnknownProperty.toJsonString(property)) + provider.update(uri, newValues, null, null) + } + } + } catch(e: Exception) { + Logger.log.log(Level.WARNING, "Couldn't rewrite deprecated unknown property to current format", e) + } + } + "unknown-property.v2" -> { + // unknown property (deprecated MIME type); rewrite to current MIME type + val newValues = ContentValues(1) + newValues.put(ExtendedProperties.NAME, UnknownProperty.CONTENT_ITEM_TYPE) + provider.update(uri, newValues, null, null) + } + } + } + } + } + } + + @Suppress("unused","FunctionName") + /** + * The tasks sync interval should be stored in account settings. It's used to set the sync interval + * again when the tasks provider is switched. + */ + private fun update_10_11() { + TaskUtils.currentProvider(context)?.let { provider -> + val interval = getSyncInterval(provider.authority) + if (interval != null) + accountManager.setUserData(account, KEY_SYNC_INTERVAL_TASKS, interval.toString()) + } + } + @Suppress("unused","FunctionName") /** * Task synchronization now handles alarms, categories, relations and unknown properties. @@ -289,15 +638,16 @@ class AccountSettings( **/ private fun update_9_10() { TaskProvider.acquire(context, OpenTasks)?.use { provider -> - val tasksUri = TaskProvider.syncAdapterUri(provider.tasksUri(), account) + val tasksUri = provider.tasksUri().asSyncAdapter(account) val emptyETag = ContentValues(1) emptyETag.putNull(LocalTask.COLUMN_ETAG) provider.client.update(tasksUri, emptyETag, "${TaskContract.Tasks._DIRTY}=0 AND ${TaskContract.Tasks._DELETED}=0", null) } + @SuppressLint("Recycle") if (ContextCompat.checkSelfPermission(context, android.Manifest.permission.WRITE_CALENDAR) == PackageManager.PERMISSION_GRANTED) context.contentResolver.acquireContentProviderClient(CalendarContract.AUTHORITY)?.let { provider -> - provider.update(AndroidCalendar.syncAdapterURI(CalendarContract.Calendars.CONTENT_URI, account), + provider.update(CalendarContract.Calendars.CONTENT_URI.asSyncAdapter(account), AndroidCalendar.calendarBaseValues, null, null) provider.closeCompat() } @@ -309,7 +659,6 @@ class AccountSettings( * Disable it on those accounts for the future. */ private fun update_8_9() { - val db = AppDatabase.getInstance(context) val hasCalDAV = db.serviceDao().getByAccountAndType(account.name, Service.TYPE_CALDAV) != null if (!hasCalDAV && ContentResolver.getIsSyncable(account, OpenTasks.authority) != 0) { Logger.log.info("Disabling OpenTasks sync for $account") @@ -327,7 +676,7 @@ class AccountSettings( TaskProvider.acquire(context, OpenTasks)?.use { provider -> // ETag is now in sync_version instead of sync1 // UID is now in _uid instead of sync2 - provider.client.query(TaskProvider.syncAdapterUri(provider.tasksUri(), account), + provider.client.query(provider.tasksUri().asSyncAdapter(account), arrayOf(TaskContract.Tasks._ID, TaskContract.Tasks.SYNC1, TaskContract.Tasks.SYNC2), "${TaskContract.Tasks.ACCOUNT_TYPE}=? AND ${TaskContract.Tasks.ACCOUNT_NAME}=?", arrayOf(account.type, account.name), null)!!.use { cursor -> @@ -342,7 +691,7 @@ class AccountSettings( values.putNull(TaskContract.Tasks.SYNC2) Logger.log.log(Level.FINER, "Updating task $id", values) provider.client.update( - TaskProvider.syncAdapterUri(ContentUris.withAppendedId(provider.tasksUri(), id), account), + ContentUris.withAppendedId(provider.tasksUri(), id).asSyncAdapter(account), values, null, null) } } @@ -386,7 +735,7 @@ class AccountSettings( parcel.unmarshall(raw, 0, raw.size) parcel.setDataPosition(0) val params = parcel.readBundle()!! - val url = params.getString("url")?.let { HttpUrl.parse(it) } + val url = params.getString("url")?.toHttpUrlOrNull() if (url == null) Logger.log.info("No address book URL, ignoring account") else { @@ -427,14 +776,14 @@ class AccountSettings( // request sync of new address book account ContentResolver.setIsSyncable(account, context.getString(R.string.address_books_authority), 1) - setSyncInterval(context.getString(R.string.address_books_authority), Constants.DEFAULT_SYNC_INTERVAL) + setSyncInterval(context.getString(R.string.address_books_authority), Constants.DEFAULT_CONTACTS_SYNC_INTERVAL) } /* Android 7.1.1 OpenTasks fix */ @Suppress("unused") private fun update_4_5() { // call PackageChangedReceiver which then enables/disables OpenTasks sync when it's (not) available - PackageChangedReceiver.updateTaskSync(context) + SyncUtils.updateTaskSync(context) } @Suppress("unused") diff --git a/app/src/main/java/at/bitfire/davdroid/settings/BaseDefaultsProvider.kt b/app/src/main/java/at/bitfire/davdroid/settings/BaseDefaultsProvider.kt new file mode 100644 index 0000000000000000000000000000000000000000..a20faa071df1c74e653f94b731720323ca5e84a5 --- /dev/null +++ b/app/src/main/java/at/bitfire/davdroid/settings/BaseDefaultsProvider.kt @@ -0,0 +1,65 @@ +/*************************************************************************************************** + * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. + **************************************************************************************************/ + +package at.bitfire.davdroid.settings + +import android.content.Context +import at.bitfire.davdroid.TextTable +import java.io.Writer + +abstract class BaseDefaultsProvider( + val context: Context, + val settingsManager: SettingsManager +): SettingsProvider { + + abstract val booleanDefaults: Map + abstract val intDefaults: Map + abstract val longDefaults: Map + abstract val stringDefaults: Map + + + override fun canWrite() = false + + override fun close() { + // override this, if needed + } + + override fun forceReload() { + // override this, if needed + } + + + override fun contains(key: String) = + booleanDefaults.containsKey(key) || + intDefaults.containsKey(key) || + longDefaults.containsKey(key) || + stringDefaults.containsKey(key) + + override fun getBoolean(key: String) = booleanDefaults[key] + override fun getInt(key: String) = intDefaults[key] + override fun getLong(key: String) = longDefaults[key] + override fun getString(key: String) = stringDefaults[key] + + override fun putBoolean(key: String, value: Boolean?) = throw NotImplementedError() + override fun putInt(key: String, value: Int?) = throw NotImplementedError() + override fun putLong(key: String, value: Long?) = throw NotImplementedError() + override fun putString(key: String, value: String?) = throw NotImplementedError() + + override fun remove(key: String) = throw NotImplementedError() + + + override fun dump(writer: Writer) { + val strValues = mutableMapOf() + strValues.putAll(booleanDefaults.mapValues { (_, value) -> value.toString() }) + strValues.putAll(intDefaults.mapValues { (_, value) -> value.toString() }) + strValues.putAll(longDefaults.mapValues { (_, value) -> value.toString() }) + strValues.putAll(stringDefaults) + + val table = TextTable("Setting", "Value") + for ((key, value) in strValues.toSortedMap()) + table.addLine(key, value) + writer.write(table.toString()) + } + +} \ No newline at end of file diff --git a/app/src/main/java/at/bitfire/davdroid/settings/DefaultsProvider.kt b/app/src/main/java/at/bitfire/davdroid/settings/DefaultsProvider.kt index 4dfe65fc3e6e07eeadf1cc0541b57ec596f244ac..3390bba5118122621baad30c078dc1a998433c78 100644 --- a/app/src/main/java/at/bitfire/davdroid/settings/DefaultsProvider.kt +++ b/app/src/main/java/at/bitfire/davdroid/settings/DefaultsProvider.kt @@ -1,78 +1,98 @@ -/* - * Copyright © Ricki Hirner (bitfire web engineering). - * All rights reserved. This program and the accompanying materials - * are made available under the terms of the GNU Public License v3.0 - * which accompanies this distribution, and is available at - * http://www.gnu.org/licenses/gpl.html - */ +/*************************************************************************************************** + * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. + **************************************************************************************************/ package at.bitfire.davdroid.settings +import android.content.BroadcastReceiver import android.content.Context +import android.content.Intent +import android.content.IntentFilter +import android.net.ConnectivityManager +import android.os.Build +import androidx.core.content.getSystemService +import dagger.Binds +import dagger.Module +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import dagger.multibindings.IntKey +import dagger.multibindings.IntoMap +import javax.inject.Inject + +class DefaultsProvider( + context: Context, + settingsManager: SettingsManager +): BaseDefaultsProvider(context, settingsManager) { + + override val booleanDefaults = mutableMapOf( + Pair(Settings.DISTRUST_SYSTEM_CERTIFICATES, false), + Pair(Settings.SYNC_ALL_COLLECTIONS, false) + ) -open class DefaultsProvider( - private val allowOverride: Boolean = true -): SettingsProvider { + override val intDefaults = mapOf( + Pair(Settings.PROXY_TYPE, Settings.PROXY_TYPE_SYSTEM), + Pair(Settings.PROXY_PORT, 9050) // Orbot SOCKS + ) - open val booleanDefaults = mapOf( - Pair(Settings.DISTRUST_SYSTEM_CERTIFICATES, Settings.DISTRUST_SYSTEM_CERTIFICATES_DEFAULT), - Pair(Settings.OVERRIDE_PROXY, Settings.OVERRIDE_PROXY_DEFAULT) + override val longDefaults = mapOf( + Pair(Settings.DEFAULT_SYNC_INTERVAL, 4*3600) /* 4 hours */ ) - open val intDefaults = mapOf( - Pair(Settings.OVERRIDE_PROXY_PORT, Settings.OVERRIDE_PROXY_PORT_DEFAULT) + override val stringDefaults = mapOf( + Pair(Settings.PROXY_HOST, "localhost") ) - open val longDefaults = mapOf() + val dataSaverChangedListener by lazy { + object: BroadcastReceiver() { + override fun onReceive(context: Context, intent: Intent) { + evaluateDataSaver(true) + } + } + } - open val stringDefaults = mapOf( - Pair(Settings.OVERRIDE_PROXY_HOST, Settings.OVERRIDE_PROXY_HOST_DEFAULT) - ) + + init { + if (Build.VERSION.SDK_INT >= 24) { + val dataSaverChangedFilter = IntentFilter(ConnectivityManager.ACTION_RESTRICT_BACKGROUND_CHANGED) + context.registerReceiver(dataSaverChangedListener, dataSaverChangedFilter) + evaluateDataSaver() + } + } override fun forceReload() { + evaluateDataSaver() } override fun close() { + if (Build.VERSION.SDK_INT >= 24) + context.unregisterReceiver(dataSaverChangedListener) } - - private fun hasKey(key: String) = - booleanDefaults.containsKey(key) || - intDefaults.containsKey(key) || - longDefaults.containsKey(key) || - stringDefaults.containsKey(key) - - override fun has(key: String): Pair { - val has = hasKey(key) - return Pair(has, allowOverride || !has) + fun evaluateDataSaver(notify: Boolean = false) { + if (Build.VERSION.SDK_INT >= 24) { + context.getSystemService()?.let { connectivityManager -> + if (connectivityManager.restrictBackgroundStatus == ConnectivityManager.RESTRICT_BACKGROUND_STATUS_ENABLED) + booleanDefaults[AccountSettings.KEY_WIFI_ONLY] = true + else + booleanDefaults -= AccountSettings.KEY_WIFI_ONLY + } + if (notify) + settingsManager.onSettingsChanged() + } } - override fun getBoolean(key: String) = - Pair(booleanDefaults[key], allowOverride || !booleanDefaults.containsKey(key)) - - override fun getInt(key: String) = - Pair(intDefaults[key], allowOverride || !intDefaults.containsKey(key)) - - override fun getLong(key: String) = - Pair(longDefaults[key], allowOverride || !longDefaults.containsKey(key)) - - override fun getString(key: String) = - Pair(stringDefaults[key], allowOverride || !stringDefaults.containsKey(key)) - - - override fun isWritable(key: String) = Pair(false, allowOverride || !hasKey(key)) - - override fun putBoolean(key: String, value: Boolean?) = false - override fun putInt(key: String, value: Int?) = false - override fun putLong(key: String, value: Long?) = false - override fun putString(key: String, value: String?) = false - - override fun remove(key: String) = false - + class Factory @Inject constructor(): SettingsProviderFactory { + override fun getProviders(context: Context, settingsManager: SettingsManager) = + listOf(DefaultsProvider(context, settingsManager)) + } - class Factory : ISettingsProviderFactory { - override fun getProviders(context: Context) = listOf(DefaultsProvider()) + @Module + @InstallIn(SingletonComponent::class) + abstract class DefaultsProviderFactoryModule { + @Binds + @IntoMap @IntKey(/* priority */ 0) + abstract fun factory(impl: Factory): SettingsProviderFactory } } \ No newline at end of file diff --git a/app/src/main/java/at/bitfire/davdroid/settings/ISettingsProviderFactory.kt b/app/src/main/java/at/bitfire/davdroid/settings/ISettingsProviderFactory.kt deleted file mode 100644 index 9615f9c511f3b89c7e9c44a28dc7222efb6de57c..0000000000000000000000000000000000000000 --- a/app/src/main/java/at/bitfire/davdroid/settings/ISettingsProviderFactory.kt +++ /dev/null @@ -1,17 +0,0 @@ -/* - * Copyright © Ricki Hirner (bitfire web engineering). - * All rights reserved. This program and the accompanying materials - * are made available under the terms of the GNU Public License v3.0 - * which accompanies this distribution, and is available at - * http://www.gnu.org/licenses/gpl.html - */ - -package at.bitfire.davdroid.settings - -import android.content.Context - -interface ISettingsProviderFactory { - - fun getProviders(context: Context): List - -} \ No newline at end of file diff --git a/app/src/main/java/at/bitfire/davdroid/settings/Settings.kt b/app/src/main/java/at/bitfire/davdroid/settings/Settings.kt index d075101d5523445ad1e26efb2b91fba879a0ac19..67599ded1dcf56aa5b4811cbc6f90a28c3ebc608 100644 --- a/app/src/main/java/at/bitfire/davdroid/settings/Settings.kt +++ b/app/src/main/java/at/bitfire/davdroid/settings/Settings.kt @@ -1,194 +1,42 @@ -/* - * Copyright © Ricki Hirner (bitfire web engineering). - * All rights reserved. This program and the accompanying materials - * are made available under the terms of the GNU Public License v3.0 - * which accompanies this distribution, and is available at - * http://www.gnu.org/licenses/gpl.html - */ +/*************************************************************************************************** + * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. + **************************************************************************************************/ package at.bitfire.davdroid.settings -import android.content.Context -import androidx.annotation.WorkerThread -import at.bitfire.davdroid.log.Logger -import java.lang.ref.WeakReference -import java.util.* -import java.util.logging.Level +import androidx.appcompat.app.AppCompatDelegate -@WorkerThread -class Settings( - appContext: Context -) { +object Settings { - companion object { + const val BATTERY_OPTIMIZATION = "battery_optimization" + const val FOREGROUND_SERVICE = "foreground_service" - // settings keys and default values - const val DISTRUST_SYSTEM_CERTIFICATES = "distrust_system_certs" - const val DISTRUST_SYSTEM_CERTIFICATES_DEFAULT = false - const val OVERRIDE_PROXY = "override_proxy" - const val OVERRIDE_PROXY_DEFAULT = false - const val OVERRIDE_PROXY_HOST = "override_proxy_host" - const val OVERRIDE_PROXY_PORT = "override_proxy_port" + const val DISTRUST_SYSTEM_CERTIFICATES = "distrust_system_certs" - const val OVERRIDE_PROXY_HOST_DEFAULT = "localhost" - const val OVERRIDE_PROXY_PORT_DEFAULT = 8118 + const val PROXY_TYPE = "proxy_type" + const val PROXY_TYPE_SYSTEM = -1 + const val PROXY_TYPE_NONE = 0 + const val PROXY_TYPE_HTTP = 1 + const val PROXY_TYPE_SOCKS = 2 + const val PROXY_HOST = "proxy_host" + const val PROXY_PORT = "proxy_port" + /** + * Default sync interval (long), in seconds. + * Used to initialize an account. + */ + const val DEFAULT_SYNC_INTERVAL = "default_sync_interval" - private var singleton: Settings? = null + /** + * Preferred theme (light/dark). Value must be one of [AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM] + * (default if setting is missing), [AppCompatDelegate.MODE_NIGHT_NO] or [AppCompatDelegate.MODE_NIGHT_YES]. + */ + const val PREFERRED_THEME = "preferred_theme" + const val PREFERRED_THEME_DEFAULT = AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM - fun getInstance(context: Context): Settings { - singleton?.let { return it } - - val newInstance = Settings(context.applicationContext) - singleton = newInstance - return newInstance - } - - } - - private val providers = LinkedList() - private val observers = LinkedList>() - - init { - val factories = ServiceLoader.load(ISettingsProviderFactory::class.java) - Logger.log.fine("Loading settings providers from ${factories.count()} factories") - factories.forEach { factory -> - providers.addAll(factory.getProviders(appContext)) - } - } - - fun forceReload() { - providers.forEach { - it.forceReload() - } - onSettingsChanged() - } - - - /*** OBSERVERS ***/ - - fun addOnChangeListener(observer: OnChangeListener) { - observers += WeakReference(observer) - } - - fun removeOnChangeListener(observer: OnChangeListener) { - observers.removeAll { it.get() == null || it.get() == observer } - } - - fun onSettingsChanged() { - observers.mapNotNull { it.get() }.forEach { - it.onSettingsChanged() - } - } - - - /*** SETTINGS ACCESS ***/ - - fun has(key: String): Boolean { - Logger.log.fine("Looking for setting $key") - var result = false - for (provider in providers) - try { - val (value, further) = provider.has(key) - Logger.log.finer("${provider::class.java.simpleName}: has $key = $value, continue: $further") - if (value) { - result = true - break - } - if (!further) - break - } catch(e: Exception) { - Logger.log.log(Level.SEVERE, "Couldn't look up setting in $provider", e) - } - Logger.log.fine("Looking for setting $key -> $result") - return result - } - - private fun getValue(key: String, reader: (SettingsProvider) -> Pair): T? { - Logger.log.fine("Looking up setting $key") - var result: T? = null - for (provider in providers) - try { - val (value, further) = reader(provider) - Logger.log.finer("${provider::class.java.simpleName}: value = $value, continue: $further") - value?.let { result = it } - if (!further) - break - } catch(e: Exception) { - Logger.log.log(Level.SEVERE, "Couldn't read setting from $provider", e) - } - Logger.log.fine("Looked up setting $key -> $result") - return result - } - - fun getBoolean(key: String) = - getValue(key) { provider -> provider.getBoolean(key) } - - fun getInt(key: String) = - getValue(key) { provider -> provider.getInt(key) } - - fun getLong(key: String) = - getValue(key) { provider -> provider.getLong(key) } - - fun getString(key: String) = - getValue(key) { provider -> provider.getString(key) } - - - fun isWritable(key: String): Boolean { - for (provider in providers) { - val (value, further) = provider.isWritable(key) - if (value) - return true - if (!further) - return false - } - return false - } - - private fun putValue(key: String, value: T?, writer: (SettingsProvider) -> Boolean): Boolean { - Logger.log.fine("Trying to write setting $key = $value") - for (provider in providers) { - val (writable, further) = provider.isWritable(key) - Logger.log.finer("${provider::class.java.simpleName}: writable = $writable, continue: $further") - if (writable) - return try { - writer(provider) - } catch (e: Exception) { - Logger.log.log(Level.SEVERE, "Couldn't write setting to $provider", e) - false - } - if (!further) - return false - } - return false - } - - fun putBoolean(key: String, value: Boolean?) = - putValue(key, value) { provider -> provider.putBoolean(key, value) } - - fun putInt(key: String, value: Int?) = - putValue(key, value) { provider -> provider.putInt(key, value) } - - fun putLong(key: String, value: Long?) = - putValue(key, value) { provider -> provider.putLong(key, value) } - - fun putString(key: String, value: String?) = - putValue(key, value) { provider -> provider.putString(key, value) } - - fun remove(key: String): Boolean { - var deleted = false - providers.forEach { deleted = deleted || it.remove(key) } - return deleted - } - - - interface OnChangeListener { - /** - * Will be called when something has changed in a [SettingsProvider]. - * Runs in worker thread! - */ - @WorkerThread - fun onSettingsChanged() - } + const val PREFERRED_TASKS_PROVIDER = "preferred_tasks_provider" + /** whether detected collections are selected for synchronization for default */ + const val SYNC_ALL_COLLECTIONS = "sync_all_collections" + } diff --git a/app/src/main/java/at/bitfire/davdroid/settings/SettingsManager.kt b/app/src/main/java/at/bitfire/davdroid/settings/SettingsManager.kt new file mode 100644 index 0000000000000000000000000000000000000000..8e23fc4787d33c27fb736e846b2c7ffbff0b00d1 --- /dev/null +++ b/app/src/main/java/at/bitfire/davdroid/settings/SettingsManager.kt @@ -0,0 +1,192 @@ +/*************************************************************************************************** + * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. + **************************************************************************************************/ + +package at.bitfire.davdroid.settings + +import android.content.Context +import android.util.NoSuchPropertyException +import androidx.annotation.AnyThread +import at.bitfire.davdroid.log.Logger +import dagger.Module +import dagger.Provides +import dagger.hilt.EntryPoint +import dagger.hilt.InstallIn +import dagger.hilt.android.EntryPointAccessors +import dagger.hilt.android.qualifiers.ApplicationContext +import dagger.hilt.components.SingletonComponent +import java.io.Writer +import java.lang.ref.WeakReference +import java.util.* +import java.util.logging.Level +import javax.inject.Singleton + +/** + * Settings manager which coordinates [SettingsProvider]s to read/write + * application settings. + */ +class SettingsManager internal constructor( + context: Context +) { + + @Module + @InstallIn(SingletonComponent::class) + object SettingsManagerModule { + @Provides + @Singleton + fun settingsManager(@ApplicationContext context: Context) = SettingsManager(context) + } + + @EntryPoint + @InstallIn(SingletonComponent::class) + interface SettingsManagerEntryPoint { + fun factories(): Map + } + + private val providers = LinkedList() + private var writeProvider: SettingsProvider? = null + + private val observers = LinkedList>() + + init { + val factories = EntryPointAccessors.fromApplication(context, SettingsManagerEntryPoint::class.java) + .factories() // get factories from Hilt + .toSortedMap() // sort by Int key + .values.reversed() // take reverse-sorted values (because high priority numbers shall be processed first) + for (factory in factories) { + Logger.log.fine("Loading settings providers from $factory") + providers.addAll(factory.getProviders(context, this)) + } + + writeProvider = providers.firstOrNull() { it.canWrite() } + Logger.log.fine("Changed settings are handled by $writeProvider") + } + + /** + * Requests all providers to reload their settings. + */ + @AnyThread + fun forceReload() { + for (provider in providers) + provider.forceReload() + onSettingsChanged() + } + + + /*** OBSERVERS ***/ + + fun addOnChangeListener(observer: OnChangeListener) { + synchronized(observers) { + observers += WeakReference(observer) + } + } + + fun removeOnChangeListener(observer: OnChangeListener) { + synchronized(observers) { + observers.removeAll { it.get() == null || it.get() == observer } + } + } + + /** + * Notifies registered listeners about changes in the configuration. + * Should be called by config providers when settings have changed. + */ + @AnyThread + fun onSettingsChanged() { + synchronized(observers) { + for (observer in observers.mapNotNull { it.get() }) + observer.onSettingsChanged() + } + } + + + /*** SETTINGS ACCESS ***/ + + fun containsKey(key: String) = providers.any { it.contains(key) } + + private fun getValue(key: String, reader: (SettingsProvider) -> T?): T? { + Logger.log.fine("Looking up setting $key") + val result: T? = null + for (provider in providers) + try { + val value = reader(provider) + Logger.log.finer("${provider::class.java.simpleName}: $key = $value") + if (value != null) { + Logger.log.fine("Looked up setting $key -> $value") + return value + } + } catch(e: Exception) { + Logger.log.log(Level.SEVERE, "Couldn't read setting from $provider", e) + } + Logger.log.fine("Looked up setting $key -> no result") + return result + } + + fun getBooleanOrNull(key: String): Boolean? = getValue(key) { provider -> provider.getBoolean(key) } + fun getBoolean(key: String): Boolean = getBooleanOrNull(key) ?: throw NoSuchPropertyException(key) + + fun getIntOrNull(key: String): Int? = getValue(key) { provider -> provider.getInt(key) } + fun getInt(key: String): Int = getIntOrNull(key) ?: throw NoSuchPropertyException(key) + + fun getLongOrNull(key: String): Long? = getValue(key) { provider -> provider.getLong(key) } + fun getLong(key: String) = getLongOrNull(key) ?: throw NoSuchPropertyException(key) + + fun getString(key: String) = getValue(key) { provider -> provider.getString(key) } + + + fun isWritable(key: String): Boolean { + for (provider in providers) { + if (provider.canWrite()) + return true + else if (provider.contains(key)) + // non-writeable provider contains this key -> setting will always be provided by this read-only provider + return false + } + return false + } + + private fun putValue(key: String, value: T?, writer: (SettingsProvider) -> Unit) { + Logger.log.fine("Trying to write setting $key = $value") + val provider = writeProvider ?: return + try { + writer(provider) + } catch (e: Exception) { + Logger.log.log(Level.SEVERE, "Couldn't write setting to $writeProvider", e) + } + } + + fun putBoolean(key: String, value: Boolean?) = + putValue(key, value) { provider -> provider.putBoolean(key, value) } + + fun putInt(key: String, value: Int?) = + putValue(key, value) { provider -> provider.putInt(key, value) } + + fun putLong(key: String, value: Long?) = + putValue(key, value) { provider -> provider.putLong(key, value) } + + fun putString(key: String, value: String?) = + putValue(key, value) { provider -> provider.putString(key, value) } + + fun remove(key: String) = putString(key, null) + + + /*** HELPERS ***/ + + fun dump(writer: Writer) { + for ((idx, provider) in providers.withIndex()) { + writer.write("${idx + 1}. ${provider::class.java.simpleName} canWrite=${provider.canWrite()}\n") + provider.dump(writer) + } + } + + + interface OnChangeListener { + /** + * Will be called when something has changed in a [SettingsProvider]. + * May run in worker thread! + */ + @AnyThread + fun onSettingsChanged() + } + +} \ No newline at end of file diff --git a/app/src/main/java/at/bitfire/davdroid/settings/SettingsProvider.kt b/app/src/main/java/at/bitfire/davdroid/settings/SettingsProvider.kt index 9d89b7e1aa61b2e3b9f5483081f53828938653f1..d950b74f8cd97b78ab4195d36d9603310308197a 100644 --- a/app/src/main/java/at/bitfire/davdroid/settings/SettingsProvider.kt +++ b/app/src/main/java/at/bitfire/davdroid/settings/SettingsProvider.kt @@ -1,32 +1,50 @@ -/* - * Copyright © Ricki Hirner (bitfire web engineering). - * All rights reserved. This program and the accompanying materials - * are made available under the terms of the GNU Public License v3.0 - * which accompanies this distribution, and is available at - * http://www.gnu.org/licenses/gpl.html - */ +/*************************************************************************************************** + * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. + **************************************************************************************************/ package at.bitfire.davdroid.settings +import androidx.annotation.AnyThread +import java.io.Writer + +/** + * Defines a settings provider, which provides settings from a certain source + * to the [SettingsManager]. + * + * Implementations must be thread-safe and synchronize get/put operations on their own. + */ interface SettingsProvider { - fun forceReload() + /** + * Whether this provider can write settings. + * + * If this method returns false, the put...() methods will never be called for this provider. + * + * @return true = this provider provides read/write settings; + * false = this provider provides read-only settings + */ + fun canWrite(): Boolean + fun close() - fun has(key: String): Pair + @AnyThread + fun forceReload() + + fun contains(key: String): Boolean + + fun getBoolean(key: String): Boolean? + fun getInt(key: String): Int? + fun getLong(key: String): Long? + fun getString(key: String): String? - fun getBoolean(key: String): Pair - fun getInt(key: String): Pair - fun getLong(key: String): Pair - fun getString(key: String): Pair + fun putBoolean(key: String, value: Boolean?) + fun putInt(key: String, value: Int?) + fun putLong(key: String, value: Long?) + fun putString(key: String, value: String?) - fun isWritable(key: String): Pair + fun remove(key: String) - fun putBoolean(key: String, value: Boolean?): Boolean - fun putInt(key: String, value: Int?): Boolean - fun putLong(key: String, value: Long?): Boolean - fun putString(key: String, value: String?): Boolean - fun remove(key: String): Boolean + fun dump(writer: Writer) } \ No newline at end of file diff --git a/app/src/main/java/at/bitfire/davdroid/settings/SettingsProviderFactory.kt b/app/src/main/java/at/bitfire/davdroid/settings/SettingsProviderFactory.kt new file mode 100644 index 0000000000000000000000000000000000000000..edf351e2d111345817cada1c1892c155f0138ba4 --- /dev/null +++ b/app/src/main/java/at/bitfire/davdroid/settings/SettingsProviderFactory.kt @@ -0,0 +1,13 @@ +/*************************************************************************************************** + * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. + **************************************************************************************************/ + +package at.bitfire.davdroid.settings + +import android.content.Context + +interface SettingsProviderFactory { + + fun getProviders(context: Context, settingsManager: SettingsManager): Iterable + +} \ No newline at end of file diff --git a/app/src/main/java/at/bitfire/davdroid/settings/SharedPreferencesProvider.kt b/app/src/main/java/at/bitfire/davdroid/settings/SharedPreferencesProvider.kt index d5c92b238ca895b15ebd82f878e1d7d405aea279..ac2d9e3b7fddfd0e574ae47b2d5371fc67dafee1 100644 --- a/app/src/main/java/at/bitfire/davdroid/settings/SharedPreferencesProvider.kt +++ b/app/src/main/java/at/bitfire/davdroid/settings/SharedPreferencesProvider.kt @@ -1,22 +1,27 @@ -/* - * Copyright © Ricki Hirner (bitfire web engineering). - * All rights reserved. This program and the accompanying materials - * are made available under the terms of the GNU Public License v3.0 - * which accompanies this distribution, and is available at - * http://www.gnu.org/licenses/gpl.html - */ +/*************************************************************************************************** + * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. + **************************************************************************************************/ package at.bitfire.davdroid.settings import android.content.Context import android.content.Context.MODE_PRIVATE import android.content.SharedPreferences -import android.preference.PreferenceManager +import androidx.preference.PreferenceManager +import at.bitfire.davdroid.TextTable import at.bitfire.davdroid.log.Logger -import at.bitfire.davdroid.model.AppDatabase +import dagger.Binds +import dagger.Module +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import dagger.multibindings.IntKey +import dagger.multibindings.IntoMap +import java.io.Writer +import javax.inject.Inject class SharedPreferencesProvider( - val context: Context + val context: Context, + val settingsManager: SettingsManager ): SettingsProvider, SharedPreferences.OnSharedPreferenceChangeListener { companion object { @@ -31,7 +36,7 @@ class SharedPreferencesProvider( val version = meta.getInt(META_VERSION, -1) if (version == -1) { // first call, check whether to migrate from SQLite database (DAVdroid <1.9) - firstCall(context) + firstCall() meta.edit().putInt(META_VERSION, CURRENT_VERSION).apply() } @@ -45,48 +50,46 @@ class SharedPreferencesProvider( preferences.unregisterOnSharedPreferenceChangeListener(this) } + override fun canWrite() = true + override fun onSharedPreferenceChanged(sharedPreferences: SharedPreferences, key: String) { - Settings.getInstance(context).onSettingsChanged() + settingsManager.onSettingsChanged() } - override fun has(key: String) = - Pair(preferences.contains(key), true) - - private fun getValue(key: String, reader: (SharedPreferences) -> T): Pair { - if (preferences.contains(key)) - return Pair( - try { reader(preferences) } catch(e: ClassCastException) { null }, - true) + override fun contains(key: String) = preferences.contains(key) - return Pair(null, true) - } + private fun getValue(key: String, reader: (SharedPreferences) -> T): T? = + try { + if (preferences.contains(key)) + reader(preferences) + else + null + } catch(e: ClassCastException) { + null + } - override fun getBoolean(key: String): Pair = + override fun getBoolean(key: String) = getValue(key) { preferences -> preferences.getBoolean(key, /* will never be used: */ false) } - override fun getInt(key: String): Pair = + override fun getInt(key: String) = getValue(key) { preferences -> preferences.getInt(key, /* will never be used: */ -1) } - override fun getLong(key: String): Pair = + override fun getLong(key: String) = getValue(key) { preferences -> preferences.getLong(key, /* will never be used: */ -1) } - override fun getString(key: String): Pair = - getValue(key) { preferences -> preferences.getString(key, /* will never be used: */ null) } + override fun getString(key: String): String? = + preferences.getString(key, /* will never be used: */ null) - override fun isWritable(key: String) = - Pair(first = true, second = true) - - private fun putValue(key: String, value: T?, writer: (SharedPreferences.Editor, T) -> Unit): Boolean { - return if (value == null) + private fun putValue(key: String, value: T?, writer: (SharedPreferences.Editor, T) -> Unit) { + if (value == null) remove(key) else { Logger.log.fine("Writing setting $key = $value") val edit = preferences.edit() writer(edit, value) edit.apply() - true } } @@ -102,16 +105,21 @@ class SharedPreferencesProvider( override fun putString(key: String, value: String?) = putValue(key, value) { editor, v -> editor.putString(key, v) } - override fun remove(key: String): Boolean { + override fun remove(key: String) { Logger.log.fine("Removing setting $key") - preferences.edit() - .remove(key) - .apply() - return true + preferences.edit().remove(key).apply() + } + + + override fun dump(writer: Writer) { + val table = TextTable("Setting", "Value") + for ((key, value) in preferences.all.toSortedMap()) + table.addLine(key, value) + writer.write(table.toString()) } - private fun firstCall(context: Context) { + private fun firstCall() { // remove possible artifacts from DAVdroid <1.9 val edit = preferences.edit() edit.remove("override_proxy") @@ -119,14 +127,19 @@ class SharedPreferencesProvider( edit.remove("proxy_port") edit.remove("log_to_external_storage") edit.apply() - - // open ServiceDB to upgrade it and possibly migrate settings - AppDatabase.getInstance(context) } - class Factory : ISettingsProviderFactory { - override fun getProviders(context: Context) = listOf(SharedPreferencesProvider(context)) + class Factory @Inject constructor() : SettingsProviderFactory { + override fun getProviders(context: Context, settingsManager: SettingsManager) = listOf(SharedPreferencesProvider(context, settingsManager)) + } + + @Module + @InstallIn(SingletonComponent::class) + abstract class SharedPreferencesProviderFactoryModule { + @Binds + @IntoMap @IntKey(/* priority */ 10) + abstract fun factory(impl: Factory): SettingsProviderFactory } } \ No newline at end of file diff --git a/app/src/main/java/at/bitfire/davdroid/syncadapter/AccountAuthenticatorService.kt b/app/src/main/java/at/bitfire/davdroid/syncadapter/AccountAuthenticatorService.kt index 35db21e04672e69ae5b9a520d4df6a3ad835666f..8244e02f84bb25e4ad0eafbe2c3d3195442a1e4c 100644 --- a/app/src/main/java/at/bitfire/davdroid/syncadapter/AccountAuthenticatorService.kt +++ b/app/src/main/java/at/bitfire/davdroid/syncadapter/AccountAuthenticatorService.kt @@ -1,95 +1,34 @@ -/* - * Copyright © Ricki Hirner (bitfire web engineering). - * All rights reserved. This program and the accompanying materials - * are made available under the terms of the GNU Public License v3.0 - * which accompanies this distribution, and is available at - * http://www.gnu.org/licenses/gpl.html - */ +/*************************************************************************************************** + * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. + **************************************************************************************************/ package at.bitfire.davdroid.syncadapter -import android.accounts.* +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 androidx.annotation.WorkerThread -import at.bitfire.davdroid.R -import at.bitfire.davdroid.log.Logger -import at.bitfire.davdroid.model.AppDatabase -import at.bitfire.davdroid.resource.LocalAddressBook import at.bitfire.davdroid.ui.setup.LoginActivity -import java.util.logging.Level -import kotlin.concurrent.thread /** * Account authenticator for the main DAVx5 account type. - * - * Gets started when a DAVx5 account is removed, too, so it also watches for account removals - * and contains the corresponding cleanup code. */ -class AccountAuthenticatorService: Service(), OnAccountsUpdateListener { - - companion object { - - @WorkerThread - fun cleanupAccounts(context: Context) { - Logger.log.info("Cleaning up orphaned accounts") - - val accountManager = AccountManager.get(context) - val accountNames = accountManager.getAccountsByType(context.getString(R.string.account_type)) - .map { it.name } - - // delete orphaned address book accounts - accountManager.getAccountsByType(context.getString(R.string.account_type_address_book)) - .map { LocalAddressBook(context, it, null) } - .forEach { - try { - if (!accountNames.contains(it.mainAccount.name)) - it.delete() - } catch(e: Exception) { - Logger.log.log(Level.SEVERE, "Couldn't delete address book account", e) - } - } - - // delete orphaned services in DB - val db = AppDatabase.getInstance(context) - val serviceDao = db.serviceDao() - if (accountNames.isEmpty()) - serviceDao.deleteAll() - else - serviceDao.deleteExceptAccounts(accountNames.toTypedArray()) - } - - } +class AccountAuthenticatorService: Service() { - - 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?) { - thread { - cleanupAccounts(this) - } - } - - private class AccountAuthenticator( val context: Context ): AbstractAccountAuthenticator(context) { @@ -110,4 +49,5 @@ class AccountAuthenticatorService: Service(), OnAccountsUpdateListener { override fun hasFeatures(p0: AccountAuthenticatorResponse?, p1: Account?, p2: Array?) = null } + } diff --git a/app/src/main/java/at/bitfire/davdroid/syncadapter/AccountUtils.kt b/app/src/main/java/at/bitfire/davdroid/syncadapter/AccountUtils.kt new file mode 100644 index 0000000000000000000000000000000000000000..add388be2f985a47d8b2e74f915ee2faf8cfd063 --- /dev/null +++ b/app/src/main/java/at/bitfire/davdroid/syncadapter/AccountUtils.kt @@ -0,0 +1,67 @@ +/*************************************************************************************************** + * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. + **************************************************************************************************/ + +package at.bitfire.davdroid.syncadapter + +import android.accounts.Account +import android.accounts.AccountManager +import android.content.Context +import android.os.Bundle +import at.bitfire.davdroid.log.Logger + +object AccountUtils { + + /** + * Creates an account and makes sure the user data are set correctly. + * + * @param context operating context + * @param account account to create + * @param userData user data to set + * + * @return whether the account has been created + * + * @throws IllegalArgumentException when user data contains non-String values + * @throws IllegalStateException if user data can't be set + */ + fun createAccount(context: Context, account: Account, userData: Bundle, password: String? = null): Boolean { + // validate user data + for (key in userData.keySet()) { + userData.get(key)?.let { entry -> + if (entry !is String) + throw IllegalArgumentException("userData[$key] is ${entry::class.java} (expected: String)") + } + } + + // create account + val manager = AccountManager.get(context) + if (!manager.addAccountExplicitly(account, password, userData)) + return false + + // Android seems to lose the initial user data sometimes, so set it a second time if that happens + // https://forums.bitfire.at/post/11644 + if (!verifyUserData(context, account, userData)) + for (key in userData.keySet()) + manager.setUserData(account, key, userData.getString(key)) + + if (!verifyUserData(context, account, userData)) + throw IllegalStateException("Android doesn't store user data in account") + + return true + } + + private fun verifyUserData(context: Context, account: Account, userData: Bundle): Boolean { + val accountManager = AccountManager.get(context) + userData.keySet().forEach { key -> + val stored = accountManager.getUserData(account, key) + val expected = userData.getString(key) + if (stored != expected) { + Logger.log.warning("Stored user data \"$stored\" differs from expected data \"$expected\" for $key") + return false + } + } + return true + } + + +} \ No newline at end of file diff --git a/app/src/main/java/at/bitfire/davdroid/syncadapter/AccountsUpdatedListener.kt b/app/src/main/java/at/bitfire/davdroid/syncadapter/AccountsUpdatedListener.kt new file mode 100644 index 0000000000000000000000000000000000000000..d38040f6061e0a8737072dab576651d5c7b339cc --- /dev/null +++ b/app/src/main/java/at/bitfire/davdroid/syncadapter/AccountsUpdatedListener.kt @@ -0,0 +1,108 @@ +/*************************************************************************************************** + * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. + **************************************************************************************************/ + +package at.bitfire.davdroid.syncadapter + +import android.accounts.Account +import android.accounts.AccountManager +import android.accounts.OnAccountsUpdateListener +import android.content.Context +import androidx.annotation.AnyThread +import at.bitfire.davdroid.R +import at.bitfire.davdroid.db.AppDatabase +import at.bitfire.davdroid.log.Logger +import at.bitfire.davdroid.resource.LocalAddressBook +import dagger.Module +import dagger.Provides +import dagger.hilt.EntryPoint +import dagger.hilt.InstallIn +import dagger.hilt.android.EntryPointAccessors +import dagger.hilt.android.qualifiers.ApplicationContext +import dagger.hilt.components.SingletonComponent +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import java.util.logging.Level +import javax.inject.Singleton + +class AccountsUpdatedListener private constructor( + val context: Context +): OnAccountsUpdateListener { + + @Module + @InstallIn(SingletonComponent::class) + object AccountsUpdatedListenerModule { + @Provides + @Singleton + fun accountsUpdatedListener(@ApplicationContext context: Context) = AccountsUpdatedListener(context) + } + + @EntryPoint + @InstallIn(SingletonComponent::class) + interface AccountsUpdatedListenerEntryPoint { + fun appDatabase(): AppDatabase + } + + + fun listen() { + val accountManager = AccountManager.get(context) + accountManager.addOnAccountsUpdatedListener(this, null, true) + } + + /** + * Called when the main accounts have been updated, including when a main account has been + * removed. In the latter case, this method fulfills two tasks: + * + * 1. Remove related address book accounts. + * 2. Remove related service entries from the [AppDatabase]. + */ + @AnyThread + 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 { + cleanupAccounts(context, accounts) + } + } + + @Synchronized + private fun cleanupAccounts(context: Context, accounts: Array) { + Logger.log.log(Level.INFO, "Cleaning up accounts. Current accounts") + + val accountManager = AccountManager.get(context) + val accountNames = HashSet() + val accountFromManager = ArrayList() + + 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 (accountNames.isEmpty()) + serviceDao.deleteAll() + else + serviceDao.deleteExceptAccounts(accountNames.toTypedArray()) + } +} \ No newline at end of file diff --git a/app/src/main/java/at/bitfire/davdroid/syncadapter/NullAuthenticatorService.kt b/app/src/main/java/at/bitfire/davdroid/syncadapter/AddressBookAuthenticatorService.kt similarity index 84% rename from app/src/main/java/at/bitfire/davdroid/syncadapter/NullAuthenticatorService.kt rename to app/src/main/java/at/bitfire/davdroid/syncadapter/AddressBookAuthenticatorService.kt index bb4b400f81f3e94574d67952607ab2f9a0aa7f5c..38f8d1f477aaaa9116c6fb55005edd5cde62048f 100644 --- a/app/src/main/java/at/bitfire/davdroid/syncadapter/NullAuthenticatorService.kt +++ b/app/src/main/java/at/bitfire/davdroid/syncadapter/AddressBookAuthenticatorService.kt @@ -1,10 +1,6 @@ -/* - * Copyright © Ricki Hirner (bitfire web engineering). - * All rights reserved. This program and the accompanying materials - * are made available under the terms of the GNU Public License v3.0 - * which accompanies this distribution, and is available at - * http://www.gnu.org/licenses/gpl.html - */ +/*************************************************************************************************** + * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. + **************************************************************************************************/ package at.bitfire.davdroid.syncadapter import android.accounts.AbstractAccountAuthenticator @@ -17,7 +13,7 @@ import android.content.Intent import android.os.Bundle import at.bitfire.davdroid.ui.AccountsActivity -class NullAuthenticatorService: Service() { +class AddressBookAuthenticatorService: Service() { private lateinit var accountAuthenticator: AccountAuthenticator diff --git a/app/src/main/java/at/bitfire/davdroid/syncadapter/AddressBookProvider.kt b/app/src/main/java/at/bitfire/davdroid/syncadapter/AddressBookProvider.kt index e911d8bd6ff477d84c7d8ec968129075301c2a6a..b6d16101d00842b971575c7d2290b5ff0d620969 100644 --- a/app/src/main/java/at/bitfire/davdroid/syncadapter/AddressBookProvider.kt +++ b/app/src/main/java/at/bitfire/davdroid/syncadapter/AddressBookProvider.kt @@ -1,10 +1,6 @@ -/* - * Copyright © Ricki Hirner (bitfire web engineering). - * All rights reserved. This program and the accompanying materials - * are made available under the terms of the GNU Public License v3.0 - * which accompanies this distribution, and is available at - * http://www.gnu.org/licenses/gpl.html - */ +/*************************************************************************************************** + * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. + **************************************************************************************************/ package at.bitfire.davdroid.syncadapter 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 e8ed7b78681f925a53d45131127759ea1fad53b3..3996eb2e4c5c6863e044a73a91346b050d0b8b56 100644 --- a/app/src/main/java/at/bitfire/davdroid/syncadapter/AddressBooksSyncAdapterService.kt +++ b/app/src/main/java/at/bitfire/davdroid/syncadapter/AddressBooksSyncAdapterService.kt @@ -1,40 +1,41 @@ -/* - * Copyright © Ricki Hirner (bitfire web engineering). - * All rights reserved. This program and the accompanying materials - * are made available under the terms of the GNU Public License v3.0 - * which accompanies this distribution, and is available at - * http://www.gnu.org/licenses/gpl.html - */ +/*************************************************************************************************** + * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. + **************************************************************************************************/ package at.bitfire.davdroid.syncadapter import android.Manifest import android.accounts.Account -import android.content.* +import android.content.ContentProviderClient +import android.content.ContentResolver +import android.content.Context +import android.content.SyncResult import android.content.pm.PackageManager import android.os.Bundle import android.provider.ContactsContract import androidx.core.content.ContextCompat +import at.bitfire.davdroid.HttpClient 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.model.AppDatabase -import at.bitfire.davdroid.model.Collection -import at.bitfire.davdroid.model.Service import at.bitfire.davdroid.resource.LocalAddressBook import at.bitfire.davdroid.settings.AccountSettings -import at.bitfire.davdroid.ui.account.AccountActivity import okhttp3.HttpUrl +import okhttp3.HttpUrl.Companion.toHttpUrl import java.util.logging.Level class AddressBooksSyncAdapterService : SyncAdapterService() { - override fun syncAdapter() = AddressBooksSyncAdapter(this) + override fun syncAdapter() = AddressBooksSyncAdapter(this, appDatabase) class AddressBooksSyncAdapter( - context: Context - ) : SyncAdapter(context) { + context: Context, + appDatabase: AppDatabase + ) : SyncAdapter(context, appDatabase) { - override fun sync(account: Account, extras: Bundle, authority: String, provider: ContentProviderClient, syncResult: SyncResult) { + override fun sync(account: Account, extras: Bundle, authority: String, httpClient: Lazy, provider: ContentProviderClient, syncResult: SyncResult) { try { val accountSettings = AccountSettings(context, account) @@ -61,7 +62,6 @@ class AddressBooksSyncAdapterService : SyncAdapterService() { } private fun updateLocalAddressBooks(account: Account, syncResult: SyncResult): Boolean { - val db = AppDatabase.getInstance(context) val service = db.serviceDao().getByAccountAndType(account.name, Service.TYPE_CARDDAV) val remoteAddressBooks = mutableMapOf() @@ -72,14 +72,8 @@ class AddressBooksSyncAdapterService : SyncAdapterService() { if (ContextCompat.checkSelfPermission(context, Manifest.permission.WRITE_CONTACTS) != PackageManager.PERMISSION_GRANTED) { if (remoteAddressBooks.isEmpty()) Logger.log.info("No contacts permission, but no address book selected for synchronization") - else { - // no contacts permission, but address books should be synchronized -> show notification - val intent = Intent(context, AccountActivity::class.java) - intent.putExtra(AccountActivity.EXTRA_ACCOUNT, account) - intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) - - notifyPermissions(intent) - } + else + Logger.log.warning("No contacts permission, but address books are selected for synchronization") return false } @@ -93,7 +87,7 @@ class AddressBooksSyncAdapterService : SyncAdapterService() { // delete/update local address books for (addressBook in LocalAddressBook.findAll(context, contactsProvider, account)) { - val url = HttpUrl.parse(addressBook.url)!! + val url = addressBook.url.toHttpUrl() val info = remoteAddressBooks[url] if (info == null) { Logger.log.log(Level.INFO, "Deleting obsolete local address book", url) @@ -114,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 d0ed2cf07fd5fa312a8e9a009e13333a0021bc62..f1048aeceef5c45ffce8283206f6eaadd699cba2 100644 --- a/app/src/main/java/at/bitfire/davdroid/syncadapter/CalendarSyncManager.kt +++ b/app/src/main/java/at/bitfire/davdroid/syncadapter/CalendarSyncManager.kt @@ -1,10 +1,6 @@ -/* - * Copyright © Ricki Hirner (bitfire web engineering). - * All rights reserved. This program and the accompanying materials - * are made available under the terms of the GNU Public License v3.0 - * which accompanies this distribution, and is available at - * http://www.gnu.org/licenses/gpl.html - */ +/*************************************************************************************************** + * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. + **************************************************************************************************/ package at.bitfire.davdroid.syncadapter @@ -13,28 +9,33 @@ import android.content.Context import android.content.SyncResult import android.os.Bundle import at.bitfire.dav4jvm.DavCalendar -import at.bitfire.dav4jvm.DavResource import at.bitfire.dav4jvm.DavResponseCallback import at.bitfire.dav4jvm.Response import at.bitfire.dav4jvm.exception.DavException import at.bitfire.dav4jvm.property.* import at.bitfire.davdroid.DavUtils +import at.bitfire.davdroid.HttpClient import at.bitfire.davdroid.R +import at.bitfire.davdroid.db.SyncState import at.bitfire.davdroid.log.Logger -import at.bitfire.davdroid.model.SyncState import at.bitfire.davdroid.resource.LocalCalendar import at.bitfire.davdroid.resource.LocalEvent import at.bitfire.davdroid.resource.LocalResource import at.bitfire.davdroid.settings.AccountSettings +import at.bitfire.ical4android.DateUtils import at.bitfire.ical4android.Event import at.bitfire.ical4android.InvalidCalendarException -import net.fortuna.ical4j.model.Dur +import net.fortuna.ical4j.model.Component import net.fortuna.ical4j.model.component.VAlarm import okhttp3.HttpUrl +import okhttp3.HttpUrl.Companion.toHttpUrlOrNull import okhttp3.RequestBody +import okhttp3.RequestBody.Companion.toRequestBody +import org.apache.commons.io.FileUtils import java.io.ByteArrayOutputStream import java.io.Reader import java.io.StringReader +import java.time.Duration import java.util.* import java.util.logging.Level @@ -42,39 +43,46 @@ import java.util.logging.Level * Synchronization manager for CalDAV collections; handles events (VEVENT) */ class CalendarSyncManager( - context: Context, - account: Account, - accountSettings: AccountSettings, - extras: Bundle, - authority: String, - syncResult: SyncResult, - localCalendar: LocalCalendar -): SyncManager(context, account, accountSettings, extras, authority, syncResult, localCalendar) { + context: Context, + account: Account, + accountSettings: AccountSettings, + extras: Bundle, + httpClient: HttpClient, + authority: String, + syncResult: SyncResult, + localCalendar: LocalCalendar +): SyncManager(context, account, accountSettings, httpClient, extras, authority, syncResult, localCalendar) { override fun prepare(): Boolean { - collectionURL = HttpUrl.parse(localCollection.name ?: return false) ?: return false - davCollection = DavCalendar(httpClient.okHttpClient, collectionURL) + collectionURL = (localCollection.name ?: return false).toHttpUrlOrNull() ?: return false + 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() + // now find dirty events that have no instances and set them to deleted + localCollection.deleteDirtyEventsWithoutInstances() + return true } override fun queryCapabilities(): SyncState? = - useRemoteCollection { + remoteExceptionContext { var syncState: SyncState? = null - it.propfind(0, SupportedReportSet.NAME, GetCTag.NAME, SyncToken.NAME) { response, relation -> + it.propfind(0, MaxICalendarSize.NAME, SupportedReportSet.NAME, GetCTag.NAME, SyncToken.NAME) { response, relation -> if (relation == Response.HrefRelation.SELF) { + response[MaxICalendarSize::class.java]?.maxSize?.let { maxSize -> + Logger.log.info("Calendar accepts events up to ${FileUtils.byteCountToDisplaySize(maxSize)}") + } + response[SupportedReportSet::class.java]?.let { supported -> hasCollectionSync = supported.reports.contains(SupportedReportSet.SYNC_COLLECTION) } - syncState = syncState(response) } } - Logger.log.info("Server supports Collection Sync: $hasCollectionSync") + Logger.log.info("Calendar supports Collection Sync: $hasCollectionSync") syncState } @@ -83,17 +91,14 @@ class CalendarSyncManager( else SyncAlgorithm.COLLECTION_SYNC - override fun prepareUpload(resource: LocalEvent): RequestBody = useLocal(resource) { + override fun generateUpload(resource: LocalEvent): RequestBody = localExceptionContext(resource) { val event = requireNotNull(resource.event) Logger.log.log(Level.FINE, "Preparing upload of event ${resource.fileName}", event) val os = ByteArrayOutputStream() event.write(os) - RequestBody.create( - DavCalendar.MIME_ICALENDAR_UTF8, - os.toByteArray() - ) + os.toByteArray().toRequestBody(DavCalendar.MIME_ICALENDAR_UTF8) } override fun listAllRemote(callback: DavResponseCallback) { @@ -105,49 +110,34 @@ class CalendarSyncManager( limitStart = calendar.time } - return useRemoteCollection { remote -> + return remoteExceptionContext { remote -> Logger.log.info("Querying events since $limitStart") - remote.calendarQuery("VEVENT", limitStart, null, callback) + remote.calendarQuery(Component.VEVENT, limitStart, null, callback) } } override fun downloadRemote(bunch: List) { Logger.log.info("Downloading ${bunch.size} iCalendars: $bunch") - if (bunch.size == 1) { - val remote = bunch.first() - // only one contact, use GET - useRemote(DavResource(httpClient.okHttpClient, remote)) { resource -> - resource.get(DavCalendar.MIME_ICALENDAR.toString()) { response -> - // CalDAV servers MUST return ETag on GET [https://tools.ietf.org/html/rfc4791#section-5.3.4] - val eTag = response.header("ETag")?.let { GetETag(it).eTag } - ?: throw DavException("Received CalDAV GET response without ETag") - - response.body()!!.use { - processVEvent(resource.fileName(), eTag, it.charStream()) + remoteExceptionContext { + it.multiget(bunch) { response, _ -> + responseExceptionContext(response) { + if (!response.isSuccess()) { + Logger.log.warning("Received non-successful multiget response for ${response.href}") + return@responseExceptionContext } - } - } - } else - // multiple iCalendars, use calendar-multi-get - useRemoteCollection { - it.multiget(bunch) { response, _ -> - useRemote(response) { - if (!response.isSuccess()) { - Logger.log.warning("Received non-successful multiget response for ${response.href}") - return@useRemote - } - val eTag = response[GetETag::class.java]?.eTag - ?: throw DavException("Received multi-get response without ETag") + val eTag = response[GetETag::class.java]?.eTag + ?: throw DavException("Received multi-get response without ETag") + val scheduleTag = response[ScheduleTag::class.java]?.scheduleTag - val calendarData = response[CalendarData::class.java] - val iCal = calendarData?.iCalendar - ?: throw DavException("Received multi-get response without address data") + val calendarData = response[CalendarData::class.java] + val iCal = calendarData?.iCalendar + ?: throw DavException("Received multi-get response without address data") - processVEvent(DavUtils.lastSegmentOfUrl(response.href), eTag, StringReader(iCal)) - } + processVEvent(DavUtils.lastSegmentOfUrl(response.href), eTag, scheduleTag, StringReader(iCal)) } } + } } override fun postProcess() { @@ -156,7 +146,7 @@ class CalendarSyncManager( // helpers - private fun processVEvent(fileName: String, eTag: String, reader: Reader) { + private fun processVEvent(fileName: String, eTag: String, scheduleTag: String?, reader: Reader) { val events: List try { events = Event.eventsFromReader(reader) @@ -171,22 +161,23 @@ class CalendarSyncManager( // set default reminder for non-full-day events, if requested val defaultAlarmMinBefore = accountSettings.getDefaultAlarm() - if (defaultAlarmMinBefore != null && !event.isAllDay() && event.alarms.isEmpty()) { - val alarm = VAlarm(Dur(0, 0, -defaultAlarmMinBefore, 0)) + if (defaultAlarmMinBefore != null && DateUtils.isDateTime(event.dtStart) && event.alarms.isEmpty()) { + val alarm = VAlarm(Duration.ofMinutes(-defaultAlarmMinBefore.toLong())) Logger.log.log(Level.FINE, "${event.uid}: Adding default alarm", alarm) event.alarms += alarm } // update local event, if it exists - useLocal(localCollection.findByName(fileName)) { local -> + localExceptionContext(localCollection.findByName(fileName)) { local -> if (local != null) { Logger.log.log(Level.INFO, "Updating $fileName in local calendar", event) local.eTag = eTag + local.scheduleTag = scheduleTag local.update(event) syncResult.stats.numUpdates++ } else { Logger.log.log(Level.INFO, "Adding $fileName to local calendar", event) - useLocal(LocalEvent(localCollection, event, fileName, eTag, LocalResource.FLAG_REMOTELY_PRESENT)) { + localExceptionContext(LocalEvent(localCollection, event, fileName, eTag, scheduleTag, LocalResource.FLAG_REMOTELY_PRESENT)) { it.add() } syncResult.stats.numInserts++ 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 8830e4e717372dcd299b3015239c880252825b03..67b16cb8c3f84384744610828acd0af97f93153e 100644 --- a/app/src/main/java/at/bitfire/davdroid/syncadapter/CalendarsSyncAdapterService.kt +++ b/app/src/main/java/at/bitfire/davdroid/syncadapter/CalendarsSyncAdapterService.kt @@ -1,10 +1,6 @@ -/* - * Copyright © Ricki Hirner (bitfire web engineering). - * All rights reserved. This program and the accompanying materials - * are made available under the terms of the GNU Public License v3.0 - * which accompanies this distribution, and is available at - * http://www.gnu.org/licenses/gpl.html - */ +/*************************************************************************************************** + * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. + **************************************************************************************************/ package at.bitfire.davdroid.syncadapter import android.accounts.Account @@ -13,27 +9,36 @@ 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.model.AppDatabase -import at.bitfire.davdroid.model.Collection -import at.bitfire.davdroid.model.Service import at.bitfire.davdroid.resource.LocalCalendar import at.bitfire.davdroid.settings.AccountSettings import at.bitfire.ical4android.AndroidCalendar +import net.openid.appauth.AuthorizationService import okhttp3.HttpUrl +import okhttp3.HttpUrl.Companion.toHttpUrl import java.util.logging.Level +import kotlin.collections.component1 +import kotlin.collections.component2 +import kotlin.collections.set class CalendarsSyncAdapterService: SyncAdapterService() { - override fun syncAdapter() = CalendarsSyncAdapter(this) + override fun syncAdapter() = CalendarsSyncAdapter(this, appDatabase) class CalendarsSyncAdapter( - context: Context - ): SyncAdapter(context) { + context: Context, + appDatabase: AppDatabase + ) : SyncAdapter(context, appDatabase) { - override fun sync(account: Account, extras: Bundle, authority: String, provider: ContentProviderClient, syncResult: SyncResult) { + override fun sync(account: Account, extras: Bundle, authority: String, httpClient: Lazy, provider: ContentProviderClient, syncResult: SyncResult) { try { val accountSettings = AccountSettings(context, account) @@ -57,8 +62,43 @@ class CalendarsSyncAdapterService: SyncAdapterService() { .sortedByDescending { priorityCalendars.contains(it.id) } for (calendar in calendars) { Logger.log.info("Synchronizing calendar #${calendar.id}, URL: ${calendar.name}") - CalendarSyncManager(context, account, accountSettings, extras, authority, syncResult, calendar).use { - it.performSync() + 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) { @@ -68,7 +108,6 @@ class CalendarsSyncAdapterService: SyncAdapterService() { } private fun updateLocalCalendars(provider: ContentProviderClient, account: Account, settings: AccountSettings) { - val db = AppDatabase.getInstance(context) val service = db.serviceDao().getByAccountAndType(account.name, Service.TYPE_CALDAV) val remoteCalendars = mutableMapOf() @@ -81,7 +120,7 @@ class CalendarsSyncAdapterService: SyncAdapterService() { val updateColors = settings.getManageCalendarColors() for (calendar in AndroidCalendar.find(account, provider, LocalCalendar.Factory, null, null)) calendar.name?.let { - val url = HttpUrl.parse(it)!! + val url = it.toHttpUrl() val info = remoteCalendars[url] if (info == null) { Logger.log.log(Level.INFO, "Deleting obsolete local calendar", url) 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 ce4ac8c567bb7dc97c887328a332dab5b4024c81..5de00c3d1cd273fabea671266d7d4afe80ab7c8b 100644 --- a/app/src/main/java/at/bitfire/davdroid/syncadapter/ContactsSyncAdapterService.kt +++ b/app/src/main/java/at/bitfire/davdroid/syncadapter/ContactsSyncAdapterService.kt @@ -1,10 +1,6 @@ -/* - * Copyright © Ricki Hirner (bitfire web engineering). - * All rights reserved. This program and the accompanying materials - * are made available under the terms of the GNU Public License v3.0 - * which accompanies this distribution, and is available at - * http://www.gnu.org/licenses/gpl.html - */ +/*************************************************************************************************** + * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. + **************************************************************************************************/ package at.bitfire.davdroid.syncadapter @@ -13,11 +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() { @@ -26,17 +27,18 @@ class ContactsSyncAdapterService: SyncAdapterService() { const val PREVIOUS_GROUP_METHOD = "previous_group_method" } - override fun syncAdapter() = ContactsSyncAdapter(this) + override fun syncAdapter() = ContactsSyncAdapter(this, appDatabase) class ContactsSyncAdapter( - context: Context - ): SyncAdapter(context) { + context: Context, + appDatabase: AppDatabase + ) : SyncAdapter(context, appDatabase) { - override fun sync(account: Account, extras: Bundle, authority: String, provider: ContentProviderClient, syncResult: SyncResult) { + override fun sync(account: Account, extras: Bundle, authority: String, httpClient: Lazy, provider: ContentProviderClient, syncResult: SyncResult) { try { + val accountSettings = AccountSettings(context, account) val addressBook = LocalAddressBook(context, account, provider) - val accountSettings = AccountSettings(context, addressBook.mainAccount) // handle group method change val groupMethod = accountSettings.getGroupMethod().name @@ -64,8 +66,43 @@ class ContactsSyncAdapterService: SyncAdapterService() { Logger.log.info("Synchronizing address book: ${addressBook.url}") Logger.log.info("Taking settings from: ${addressBook.mainAccount}") - ContactsSyncManager(context, account, accountSettings, extras, authority, syncResult, provider, addressBook).use { - it.performSync() + ContactsSyncManager(context, account, accountSettings, httpClient.value, extras, authority, syncResult, provider, addressBook).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 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 37283599682f08e93e896dfc6ba977444f151121..85b04623085f63e540019b25db07ac206787aa98 100644 --- a/app/src/main/java/at/bitfire/davdroid/syncadapter/ContactsSyncManager.kt +++ b/app/src/main/java/at/bitfire/davdroid/syncadapter/ContactsSyncManager.kt @@ -1,40 +1,46 @@ -/* - * Copyright © Ricki Hirner (bitfire web engineering). - * All rights reserved. This program and the accompanying materials - * are made available under the terms of the GNU Public License v3.0 - * which accompanies this distribution, and is available at - * http://www.gnu.org/licenses/gpl.html - */ +/*************************************************************************************************** + * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. + **************************************************************************************************/ package at.bitfire.davdroid.syncadapter import android.accounts.Account -import android.content.* +import android.content.ContentProviderClient +import android.content.ContentResolver +import android.content.Context +import android.content.SyncResult import android.os.Build import android.os.Bundle -import android.provider.ContactsContract.Groups import at.bitfire.dav4jvm.DavAddressBook -import at.bitfire.dav4jvm.DavResource import at.bitfire.dav4jvm.DavResponseCallback import at.bitfire.dav4jvm.Response import at.bitfire.dav4jvm.exception.DavException import at.bitfire.dav4jvm.property.* import at.bitfire.davdroid.DavUtils +import at.bitfire.davdroid.DavUtils.sameTypeAs import at.bitfire.davdroid.HttpClient import at.bitfire.davdroid.R +import at.bitfire.davdroid.db.SyncState import at.bitfire.davdroid.log.Logger -import at.bitfire.davdroid.model.SyncState import at.bitfire.davdroid.resource.* import at.bitfire.davdroid.settings.AccountSettings -import at.bitfire.vcard4android.BatchOperation +import at.bitfire.davdroid.syncadapter.groups.CategoriesStrategy +import at.bitfire.davdroid.syncadapter.groups.VCard4Strategy import at.bitfire.vcard4android.Contact import at.bitfire.vcard4android.GroupMethod import ezvcard.VCardVersion import ezvcard.io.CannotParseException import okhttp3.HttpUrl +import okhttp3.HttpUrl.Companion.toHttpUrlOrNull +import okhttp3.MediaType import okhttp3.Request import okhttp3.RequestBody -import java.io.* +import okhttp3.RequestBody.Companion.toRequestBody +import org.apache.commons.io.FileUtils +import java.io.ByteArrayOutputStream +import java.io.IOException +import java.io.Reader +import java.io.StringReader import java.util.logging.Level /** @@ -73,24 +79,30 @@ import java.util.logging.Level * these "pending memberships" are assigned to the actual contacts and then cleaned up. */ class ContactsSyncManager( - context: Context, - account: Account, - accountSettings: AccountSettings, - extras: Bundle, - authority: String, - syncResult: SyncResult, - val provider: ContentProviderClient, - localAddressBook: LocalAddressBook -): SyncManager(context, account, accountSettings, extras, authority, syncResult, localAddressBook) { + context: Context, + account: Account, + accountSettings: AccountSettings, + httpClient: HttpClient, + extras: Bundle, + authority: String, + syncResult: SyncResult, + val provider: ContentProviderClient, + localAddressBook: LocalAddressBook +): SyncManager(context, account, accountSettings, httpClient, extras, authority, syncResult, localAddressBook) { companion object { infix fun Set.disjunct(other: Set) = (this - other) union (other - this) } private val readOnly = localAddressBook.readOnly + private val accessToken: String? = accountSettings.credentials().authState?.accessToken private var hasVCard4 = false - private val groupMethod = accountSettings.getGroupMethod() + private var hasJCard = false + private val groupStrategy = when (accountSettings.getGroupMethod()) { + GroupMethod.GROUP_VCARDS -> VCard4Strategy(localAddressBook) + GroupMethod.CATEGORIES -> CategoriesStrategy(localAddressBook) + } /** * Used to download images which are referenced by URL @@ -109,37 +121,40 @@ class ContactsSyncManager( } } - collectionURL = HttpUrl.parse(localCollection.url) ?: return false - davCollection = DavAddressBook(httpClient.okHttpClient, collectionURL) + collectionURL = localCollection.url.toHttpUrlOrNull() ?: return false + davCollection = DavAddressBook(httpClient.okHttpClient, collectionURL, accountSettings.credentials().authState?.accessToken) resourceDownloader = ResourceDownloader(davCollection.location) + Logger.log.info("Contact group strategy: ${groupStrategy::class.java.simpleName}") return true } override fun queryCapabilities(): SyncState? { - Logger.log.info("Contact group method: $groupMethod") - // in case of GROUP_VCARDs, treat groups as contacts in the local address book - localCollection.includeGroups = groupMethod == GroupMethod.GROUP_VCARDS - - return useRemoteCollection { + return remoteExceptionContext { var syncState: SyncState? = null - it.propfind(0, SupportedAddressData.NAME, SupportedReportSet.NAME, GetCTag.NAME, SyncToken.NAME) { response, relation -> + it.propfind(0, MaxVCardSize.NAME, SupportedAddressData.NAME, SupportedReportSet.NAME, GetCTag.NAME, SyncToken.NAME) { response, relation -> if (relation == Response.HrefRelation.SELF) { + response[MaxVCardSize::class.java]?.maxSize?.let { maxSize -> + Logger.log.info("Address book accepts vCards up to ${FileUtils.byteCountToDisplaySize(maxSize)}") + } + response[SupportedAddressData::class.java]?.let { supported -> hasVCard4 = supported.hasVCard4() - } + // temporarily disable jCard because of https://github.com/nextcloud/server/issues/29693 + // hasJCard = supported.hasJCard() + } response[SupportedReportSet::class.java]?.let { supported -> hasCollectionSync = supported.reports.contains(SupportedReportSet.SYNC_COLLECTION) } - syncState = syncState(response) } } - Logger.log.info("Server supports vCard/4: $hasVCard4") - Logger.log.info("Server supports Collection Sync: $hasCollectionSync") + // Logger.log.info("Server supports jCard: $hasJCard") + Logger.log.info("Address book supports vCard4: $hasVCard4") + Logger.log.info("Address book supports Collection Sync: $hasCollectionSync") syncState } @@ -154,12 +169,12 @@ class ContactsSyncManager( if (readOnly) { for (group in localCollection.findDeletedGroups()) { Logger.log.warning("Restoring locally deleted group (read-only address book!)") - useLocal(group) { it.resetDeleted() } + localExceptionContext(group) { it.resetDeleted() } } for (contact in localCollection.findDeletedContacts()) { Logger.log.warning("Restoring locally deleted contact (read-only address book!)") - useLocal(contact) { it.resetDeleted() } + localExceptionContext(contact) { it.resetDeleted() } } false @@ -171,165 +186,113 @@ class ContactsSyncManager( if (readOnly) { for (group in localCollection.findDirtyGroups()) { Logger.log.warning("Resetting locally modified group to ETag=null (read-only address book!)") - useLocal(group) { it.clearDirty(null) } + localExceptionContext(group) { it.clearDirty(null, null) } } for (contact in localCollection.findDirtyContacts()) { Logger.log.warning("Resetting locally modified contact to ETag=null (read-only address book!)") - useLocal(contact) { it.clearDirty(null) } + localExceptionContext(contact) { it.clearDirty(null, null) } } - } else { - if (groupMethod == GroupMethod.CATEGORIES) { - /* groups memberships are represented as contact CATEGORIES */ - - // groups with DELETED=1: set all members to dirty, then remove group - for (group in localCollection.findDeletedGroups()) { - Logger.log.fine("Finally removing group $group") - // useless because Android deletes group memberships as soon as a group is set to DELETED: - // group.markMembersDirty() - useLocal(group) { it.delete() } - } - - // groups with DIRTY=1: mark all memberships as dirty, then clean DIRTY flag of group - for (group in localCollection.findDirtyGroups()) { - Logger.log.fine("Marking members of modified group $group as dirty") - useLocal(group) { - it.markMembersDirty() - it.clearDirty(null) - } - } - } else { - /* groups as separate VCards: there are group contacts and individual contacts */ - - // mark groups with changed members as dirty - val batch = BatchOperation(localCollection.provider!!) - for (contact in localCollection.findDirtyContacts()) - try { - Logger.log.fine("Looking for changed group memberships of contact ${contact.fileName}") - val cachedGroups = contact.getCachedGroupMemberships() - val currentGroups = contact.getGroupMemberships() - for (groupID in cachedGroups disjunct currentGroups) { - Logger.log.fine("Marking group as dirty: $groupID") - batch.enqueue(BatchOperation.Operation( - ContentProviderOperation.newUpdate(localCollection.syncAdapterURI(ContentUris.withAppendedId(Groups.CONTENT_URI, groupID))) - .withValue(Groups.DIRTY, 1) - .withYieldAllowed(true) - )) - } - } catch(e: FileNotFoundException) { - } - batch.commit() - } - } + } else + // we only need to handle changes in groups when the address book is read/write + groupStrategy.beforeUploadDirty() // generate UID/file name for newly created contacts return super.uploadDirty() } - override fun prepareUpload(resource: LocalAddress): RequestBody = useLocal(resource) { - val contact: Contact - if (resource is LocalContact) { - contact = resource.contact!! - - if (groupMethod == GroupMethod.CATEGORIES) { - // add groups as CATEGORIES - for (groupID in resource.getGroupMemberships()) { - provider.query( - localCollection.syncAdapterURI(ContentUris.withAppendedId(Groups.CONTENT_URI, groupID)), - arrayOf(Groups.TITLE), null, null, null - )?.use { cursor -> - if (cursor.moveToNext()) { - val title = cursor.getString(0) - if (!title.isNullOrEmpty()) - contact.categories.add(title) - } - } - } + override fun generateUpload(resource: LocalAddress): RequestBody = + localExceptionContext(resource) { + val contact: Contact = when (resource) { + is LocalContact -> resource.getContact() + is LocalGroup -> resource.getContact() + else -> throw IllegalArgumentException("resource must be LocalContact or LocalGroup") } - } else if (resource is LocalGroup) - contact = resource.contact!! - else - throw IllegalArgumentException("resource must be LocalContact or LocalGroup") - Logger.log.log(Level.FINE, "Preparing upload of VCard ${resource.fileName}", contact) + Logger.log.log(Level.FINE, "Preparing upload of vCard ${resource.fileName}", contact) - val os = ByteArrayOutputStream() - contact.write(if (hasVCard4) VCardVersion.V4_0 else VCardVersion.V3_0, groupMethod, os) + val os = ByteArrayOutputStream() + val mimeType: MediaType + when { + hasJCard -> { + mimeType = DavAddressBook.MIME_JCARD + contact.writeJCard(os) + } + hasVCard4 -> { + mimeType = DavAddressBook.MIME_VCARD4 + contact.writeVCard(VCardVersion.V4_0, os) + } + else -> { + mimeType = DavAddressBook.MIME_VCARD3_UTF8 + contact.writeVCard(VCardVersion.V3_0, os) + } + } - RequestBody.create( - if (hasVCard4) DavAddressBook.MIME_VCARD4 else DavAddressBook.MIME_VCARD3_UTF8, - os.toByteArray() - ) - } + return@localExceptionContext(os.toByteArray().toRequestBody(mimeType)) + } override fun listAllRemote(callback: DavResponseCallback) = - useRemoteCollection { + remoteExceptionContext { it.propfind(1, ResourceType.NAME, GetETag.NAME, callback = callback) } override fun downloadRemote(bunch: List) { - Logger.log.info("Downloading ${bunch.size} vCards: $bunch") - if (bunch.size == 1) { - val remote = bunch.first() - // only one contact, use GET - useRemote(DavResource(httpClient.okHttpClient, remote)) { resource -> - resource.get("text/vcard;version=4.0, text/vcard;charset=utf-8;q=0.8, text/vcard;q=0.5") { response -> - // CardDAV servers MUST return ETag on GET [https://tools.ietf.org/html/rfc6352#section-6.3.2.3] - val eTag = response.header("ETag")?.let { GetETag(it).eTag } - ?: throw DavException("Received CardDAV GET response without ETag") - - response.body()!!.use { - processVCard(resource.fileName(), eTag, it.charStream(), resourceDownloader) - } + Logger.log.info("Downloading ${bunch.size} vCard(s): $bunch") + remoteExceptionContext { + val contentType: String? + val version: String? + when { + hasJCard -> { + contentType = DavUtils.MEDIA_TYPE_JCARD.toString() + version = VCardVersion.V4_0.version + } + hasVCard4 -> { + contentType = DavUtils.MEDIA_TYPE_VCARD.toString() + version = VCardVersion.V4_0.version + } + else -> { + contentType = DavUtils.MEDIA_TYPE_VCARD.toString() + version = null // 3.0 is the default version; don't request 3.0 explicitly because maybe some vCard3-only servers don't understand it } } - } else - // multiple vCards, use addressbook-multi-get - useRemoteCollection { - it.multiget(bunch, hasVCard4) { response, _ -> - useRemote(response) { - if (!response.isSuccess()) { - Logger.log.warning("Received non-successful multiget response for ${response.href}") - return@useRemote - } - - val eTag = response[GetETag::class.java]?.eTag - ?: throw DavException("Received multi-get response without ETag") - - val addressData = response[AddressData::class.java] - val vCard = addressData?.vCard - ?: throw DavException("Received multi-get response without address data") - - processVCard(DavUtils.lastSegmentOfUrl(response.href), eTag, StringReader(vCard), resourceDownloader) + it.multiget(bunch, contentType, version) { response, _ -> + responseExceptionContext(response) { + if (!response.isSuccess()) { + Logger.log.warning("Received non-successful multiget response for ${response.href}") + return@responseExceptionContext } + + val eTag = response[GetETag::class.java]?.eTag + ?: throw DavException("Received multi-get response without ETag") + + var isJCard = hasJCard // assume that server has sent what we have requested (we ask for jCard only when the server advertises it) + response[GetContentType::class.java]?.type?.let { type -> + isJCard = type.sameTypeAs(DavUtils.MEDIA_TYPE_JCARD) + } + + val addressData = response[AddressData::class.java] + val card = addressData?.card + ?: throw DavException("Received multi-get response without address data") + + processCard(DavUtils.lastSegmentOfUrl(response.href), eTag, StringReader(card), isJCard, resourceDownloader) } } + } } override fun postProcess() { - if (groupMethod == GroupMethod.CATEGORIES) { - /* VCard3 group handling: groups memberships are represented as contact CATEGORIES */ - - // remove empty groups - Logger.log.info("Removing empty groups") - localCollection.removeEmptyGroups() - - } else { - /* VCard4 group handling: there are group contacts and individual contacts */ - Logger.log.info("Assigning memberships of downloaded contact groups") - LocalGroup.applyPendingMemberships(localCollection) - } + groupStrategy.postProcess() } // helpers - private fun processVCard(fileName: String, eTag: String, reader: Reader, downloader: Contact.Downloader) { + private fun processCard(fileName: String, eTag: String, reader: Reader, jCard: Boolean, downloader: Contact.Downloader) { Logger.log.info("Processing CardDAV resource $fileName") val contacts = try { - Contact.fromReader(reader, downloader) + Contact.fromReader(reader, jCard, downloader) } catch (e: CannotParseException) { Logger.log.log(Level.SEVERE, "Received invalid vCard, ignoring", e) notifyInvalidResource(e, fileName) @@ -343,14 +306,10 @@ class ContactsSyncManager( Logger.log.warning("Received multiple vCards, using first one") val newData = contacts.first() - - if (groupMethod == GroupMethod.CATEGORIES && newData.group) { - Logger.log.warning("Received group vCard although group method is CATEGORIES. Saving as regular contact") - newData.group = false - } + groupStrategy.verifyContactBeforeSaving(newData) // update local contact, if it exists - useLocal(localCollection.findByName(fileName)) { + localExceptionContext(localCollection.findByName(fileName)) { var local = it if (local != null) { Logger.log.log(Level.INFO, "Updating $fileName in local address book", newData) @@ -370,7 +329,7 @@ class ContactsSyncManager( syncResult.stats.numUpdates++ } else { - // group has become an individual contact or vice versa + // group has become an individual contact or vice versa, delete and create with new type local.delete() local = null } @@ -379,13 +338,13 @@ class ContactsSyncManager( if (local == null) { if (newData.group) { Logger.log.log(Level.INFO, "Creating local group", newData) - useLocal(LocalGroup(localCollection, newData, fileName, eTag, LocalResource.FLAG_REMOTELY_PRESENT)) { group -> + localExceptionContext(LocalGroup(localCollection, newData, fileName, eTag, LocalResource.FLAG_REMOTELY_PRESENT)) { group -> group.add() local = group } } else { Logger.log.log(Level.INFO, "Creating local contact", newData) - useLocal(LocalContact(localCollection, newData, fileName, eTag, LocalResource.FLAG_REMOTELY_PRESENT)) { contact -> + localExceptionContext(LocalContact(localCollection, newData, fileName, eTag, LocalResource.FLAG_REMOTELY_PRESENT)) { contact -> contact.add() local = contact } @@ -393,24 +352,8 @@ class ContactsSyncManager( syncResult.stats.numInserts++ } - if (groupMethod == GroupMethod.CATEGORIES) - (local as? LocalContact)?.let { localContact -> - // VCard3: update group memberships from CATEGORIES - val batch = BatchOperation(provider) - Logger.log.log(Level.FINE, "Removing contact group memberships") - localContact.removeGroupMemberships(batch) - - for (category in localContact.contact!!.categories) { - val groupID = localCollection.findOrCreateGroup(category) - Logger.log.log(Level.FINE, "Adding membership in group $category ($groupID)") - localContact.addToGroup(batch, groupID) - } - - batch.commit() - } - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N && Build.VERSION.SDK_INT < Build.VERSION_CODES.O) - // workaround for Android 7 which sets DIRTY flag when only meta-data is changed + // workaround for Android 7 which sets DIRTY flag when only meta-data is changed (local as? LocalContact)?.updateHashCode(null) } } @@ -423,27 +366,32 @@ class ContactsSyncManager( ): Contact.Downloader { override fun download(url: String, accepts: String): ByteArray? { - val httpUrl = HttpUrl.parse(url) + val httpUrl = url.toHttpUrlOrNull() if (httpUrl == null) { Logger.log.log(Level.SEVERE, "Invalid external resource URL", url) return null } // authenticate only against a certain host, and only upon request - val builder = HttpClient.Builder(context, baseUrl.host(), accountSettings.credentials()) - - // allow redirects - builder.followRedirects(true) + val client = HttpClient.Builder(context, baseUrl.host, accountSettings.credentials()) + .followRedirects(true) // allow redirects + .build() - val client = builder.build() try { - val response = client.okHttpClient.newCall(Request.Builder() - .get() - .url(httpUrl) - .build()).execute() + val requestBuilder = Request.Builder() + .get() + .url(httpUrl) + + if (!accessToken.isNullOrEmpty()) { + requestBuilder.header("Authorization", "Bearer $accessToken") + } + + val response = client.okHttpClient.newCall(requestBuilder + .build()) + .execute() if (response.isSuccessful) - return response.body()?.bytes() + return response.body?.bytes() else Logger.log.warning("Couldn't download external resource") } catch(e: IOException) { 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..03698e99828513e30b2578fb36d2606a9ea6c26c --- /dev/null +++ b/app/src/main/java/at/bitfire/davdroid/syncadapter/EeloAccountAuthenticatorService.kt @@ -0,0 +1,191 @@ +/* + * 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.AndroidEntryPoint +import java.util.logging.Level +import javax.inject.Inject +import kotlin.concurrent.thread + +/** + * 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. + */ + +@AndroidEntryPoint +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()) + } + + } + + @Inject + lateinit var db: AppDatabase + + private lateinit var accountManager: AccountManager + private lateinit var accountAuthenticator: AccountAuthenticator + + override fun onCreate() { + accountManager = AccountManager.get(this) + accountManager.addOnAccountsUpdatedListener(this, null, true) + + accountAuthenticator = AccountAuthenticator(this) + } + + override fun onDestroy() { + super.onDestroy() + accountManager.removeOnAccountsUpdatedListener(this) + } + + override fun onBind(intent: Intent?) = + accountAuthenticator.iBinder.takeIf { intent?.action == android.accounts.AccountManager.ACTION_AUTHENTICATOR_INTENT } + + + override fun onAccountsUpdated(accounts: Array?) { + thread { + cleanupAccounts(this, 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..a0a877a5e688e764afc4869bbd672e4fc225315d --- /dev/null +++ b/app/src/main/java/at/bitfire/davdroid/syncadapter/GoogleAccountAuthenticatorService.kt @@ -0,0 +1,194 @@ +/* + * 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.AndroidEntryPoint +import net.openid.appauth.AuthState +import net.openid.appauth.AuthorizationService +import java.util.logging.Level +import javax.inject.Inject +import kotlin.concurrent.thread + +/** + * 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. + */ + +@AndroidEntryPoint +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()) + } + + } + + @Inject + lateinit var db: AppDatabase + + private lateinit var accountManager: AccountManager + private lateinit var accountAuthenticator: AccountAuthenticator + + override fun onCreate() { + accountManager = AccountManager.get(this) + accountManager.addOnAccountsUpdatedListener(this, null, true) + + accountAuthenticator = AccountAuthenticator(this) + } + + override fun onDestroy() { + super.onDestroy() + accountManager.removeOnAccountsUpdatedListener(this) + } + + override fun onBind(intent: Intent?) = + accountAuthenticator.iBinder.takeIf { intent?.action == android.accounts.AccountManager.ACTION_AUTHENTICATOR_INTENT } + + + override fun onAccountsUpdated(accounts: Array?) { + thread { + cleanupAccounts(this, 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/JtxSyncAdapterService.kt b/app/src/main/java/at/bitfire/davdroid/syncadapter/JtxSyncAdapterService.kt new file mode 100644 index 0000000000000000000000000000000000000000..a5afb7d8ba8a8e1bc4fd62d4e0fab3739c0b7be8 --- /dev/null +++ b/app/src/main/java/at/bitfire/davdroid/syncadapter/JtxSyncAdapterService.kt @@ -0,0 +1,116 @@ +/*************************************************************************************************** + * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. + **************************************************************************************************/ + +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.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.Service +import at.bitfire.davdroid.log.Logger +import at.bitfire.davdroid.resource.LocalJtxCollection +import at.bitfire.davdroid.settings.AccountSettings +import at.bitfire.ical4android.JtxCollection +import at.bitfire.ical4android.TaskProvider +import okhttp3.HttpUrl +import okhttp3.HttpUrl.Companion.toHttpUrl +import java.util.logging.Level +import kotlin.collections.component1 +import kotlin.collections.component2 +import kotlin.collections.set + +class JtxSyncAdapterService: SyncAdapterService() { + + override fun syncAdapter() = JtxSyncAdapter(this, appDatabase) + + + class JtxSyncAdapter( + context: Context, + appDatabase: AppDatabase + ) : SyncAdapter(context, appDatabase) { + + override fun sync(account: Account, extras: Bundle, authority: String, httpClient: Lazy, provider: ContentProviderClient, syncResult: SyncResult) { + + try { + // check whether jtx Board is new enough + TaskProvider.checkVersion(context, TaskProvider.ProviderName.JtxBoard) + + // make sure account can be seen by task provider + if (Build.VERSION.SDK_INT >= 26) { + /* Warning: If setAccountVisibility is called, Android 12 broadcasts the + AccountManager.LOGIN_ACCOUNTS_CHANGED_ACTION Intent. This cancels running syncs + and starts them again! So make sure setAccountVisibility is only called when necessary. */ + val am = AccountManager.get(context) + if (am.getAccountVisibility(account, TaskProvider.ProviderName.JtxBoard.packageName) != AccountManager.VISIBILITY_VISIBLE) + am.setAccountVisibility(account, TaskProvider.ProviderName.JtxBoard.packageName, AccountManager.VISIBILITY_VISIBLE) + } + /* 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) + */ + val accountSettings = AccountSettings(context, account) + if (!extras.containsKey(ContentResolver.SYNC_EXTRAS_MANUAL) && !checkSyncConditions(accountSettings)) + return + + // sync list of collections + updateLocalCollections(account, provider) + + // sync contents of collections + val collections = JtxCollection.find(account, provider, context, LocalJtxCollection.Factory, null, null) + for (collection in collections) { + Logger.log.info("Synchronizing $collection") + JtxSyncManager(context, account, accountSettings, extras, httpClient.value, authority, syncResult, collection).let { + it.performSync() + } + } + + } catch (e: TaskProvider.ProviderTooOldException) { + SyncUtils.notifyProviderTooOld(context, e) + } catch (e: Exception) { + Logger.log.log(Level.SEVERE, "Couldn't sync jtx collections", e) + } + Logger.log.info("jtx sync complete") + } + + private fun updateLocalCollections(account: Account, client: ContentProviderClient) { + val service = db.serviceDao().getByAccountAndType(account.name, Service.TYPE_CALDAV) + + val remoteCollections = mutableMapOf() + if (service != null) + for (collection in db.collectionDao().getSyncJtxCollections(service.id)) + remoteCollections[collection.url] = collection + + for (jtxCollection in JtxCollection.find(account, client, context, LocalJtxCollection.Factory, null, null)) + jtxCollection.url?.let { strUrl -> + val url = strUrl.toHttpUrl() + val info = remoteCollections[url] + if (info == null) { + Logger.log.fine("Deleting obsolete local collection $url") + jtxCollection.delete() + } else { + // remote CollectionInfo found for this local collection, update data + Logger.log.log(Level.FINE, "Updating local collection $url", info) + jtxCollection.updateCollection(info) + // we already have a local task list for this remote collection, don't take into consideration anymore + remoteCollections -= url + } + } + + // create new local collections + for ((_,info) in remoteCollections) { + Logger.log.log(Level.INFO, "Adding local collections", info) + LocalJtxCollection.create(account, client, info) + } + } + } + +} \ No newline at end of file diff --git a/app/src/main/java/at/bitfire/davdroid/syncadapter/JtxSyncManager.kt b/app/src/main/java/at/bitfire/davdroid/syncadapter/JtxSyncManager.kt new file mode 100644 index 0000000000000000000000000000000000000000..e33b0e2941f63edcd6a396447a5f4690b570e04d --- /dev/null +++ b/app/src/main/java/at/bitfire/davdroid/syncadapter/JtxSyncManager.kt @@ -0,0 +1,158 @@ +/*************************************************************************************************** + * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. + **************************************************************************************************/ + +package at.bitfire.davdroid.syncadapter + +import android.accounts.Account +import android.content.Context +import android.content.SyncResult +import android.os.Bundle +import at.bitfire.dav4jvm.DavCalendar +import at.bitfire.dav4jvm.DavResponseCallback +import at.bitfire.dav4jvm.Response +import at.bitfire.dav4jvm.exception.DavException +import at.bitfire.dav4jvm.property.* +import at.bitfire.davdroid.DavUtils +import at.bitfire.davdroid.HttpClient +import at.bitfire.davdroid.R +import at.bitfire.davdroid.db.SyncState +import at.bitfire.davdroid.log.Logger +import at.bitfire.davdroid.resource.LocalJtxCollection +import at.bitfire.davdroid.resource.LocalJtxICalObject +import at.bitfire.davdroid.resource.LocalResource +import at.bitfire.davdroid.settings.AccountSettings +import at.bitfire.ical4android.InvalidCalendarException +import at.bitfire.ical4android.JtxICalObject +import okhttp3.HttpUrl +import okhttp3.HttpUrl.Companion.toHttpUrlOrNull +import okhttp3.RequestBody +import okhttp3.RequestBody.Companion.toRequestBody +import org.apache.commons.io.FileUtils +import java.io.ByteArrayOutputStream +import java.io.Reader +import java.io.StringReader +import java.util.logging.Level + +class JtxSyncManager( + context: Context, + account: Account, + accountSettings: AccountSettings, + extras: Bundle, + httpClient: HttpClient, + authority: String, + syncResult: SyncResult, + localCollection: LocalJtxCollection +): SyncManager(context, account, accountSettings, httpClient, extras, authority, syncResult, localCollection) { + + override fun prepare(): Boolean { + collectionURL = (localCollection.url ?: return false).toHttpUrlOrNull() ?: return false + davCollection = DavCalendar(httpClient.okHttpClient, collectionURL, accountSettings.credentials().authState?.accessToken) + + return true + } + + override fun queryCapabilities() = + remoteExceptionContext { + var syncState: SyncState? = null + it.propfind(0, GetCTag.NAME, MaxICalendarSize.NAME, SyncToken.NAME) { response, relation -> + if (relation == Response.HrefRelation.SELF) { + response[MaxICalendarSize::class.java]?.maxSize?.let { maxSize -> + Logger.log.info("Collection accepts resources up to ${FileUtils.byteCountToDisplaySize(maxSize)}") + } + + syncState = syncState(response) + } + } + syncState + } + + override fun generateUpload(resource: LocalJtxICalObject): RequestBody = localExceptionContext(resource) { + Logger.log.log(Level.FINE, "Preparing upload of icalobject ${resource.fileName}", resource) + val os = ByteArrayOutputStream() + resource.write(os) + os.toByteArray().toRequestBody(DavCalendar.MIME_ICALENDAR_UTF8) + } + + override fun syncAlgorithm() = SyncAlgorithm.PROPFIND_REPORT + + override fun listAllRemote(callback: DavResponseCallback) { + remoteExceptionContext { remote -> + if (localCollection.supportsVTODO) { + Logger.log.info("Querying tasks") + remote.calendarQuery("VTODO", null, null, callback) + } + + if (localCollection.supportsVJOURNAL) { + Logger.log.info("Querying journals") + remote.calendarQuery("VJOURNAL", null, null, callback) + } + } + } + + override fun downloadRemote(bunch: List) { + Logger.log.info("Downloading ${bunch.size} iCalendars: $bunch") + // multiple iCalendars, use calendar-multi-get + remoteExceptionContext { + it.multiget(bunch) { response, _ -> + responseExceptionContext(response) { + if (!response.isSuccess()) { + Logger.log.warning("Received non-successful multiget response for ${response.href}") + return@responseExceptionContext + } + + val eTag = response[GetETag::class.java]?.eTag + ?: throw DavException("Received multi-get response without ETag") + + val calendarData = response[CalendarData::class.java] + val iCal = calendarData?.iCalendar + ?: throw DavException("Received multi-get response without address data") + + processICalObject(DavUtils.lastSegmentOfUrl(response.href), eTag, StringReader(iCal)) + } + } + } + } + + override fun postProcess() { /* nothing to do */ } + + override fun notifyInvalidResourceTitle(): String = + context.getString(R.string.sync_invalid_event) + + + private fun processICalObject(fileName: String, eTag: String, reader: Reader) { + val icalobjects: MutableList = mutableListOf() + try { + // parse the reader content and return the list of ICalObjects + icalobjects.addAll(JtxICalObject.fromReader(reader, localCollection)) + } catch (e: InvalidCalendarException) { + Logger.log.log(Level.SEVERE, "Received invalid iCalendar, ignoring", e) + notifyInvalidResource(e, fileName) + return + } + + if (icalobjects.size == 1) { + val newData = icalobjects.first() + + // update local task, if it exists + localExceptionContext(localCollection.findByName(fileName)) { local -> + if (local != null) { + Logger.log.log(Level.INFO, "Updating $fileName in local task list", newData) + local.eTag = eTag + local.update(newData) + syncResult.stats.numUpdates++ + } else { + Logger.log.log(Level.INFO, "Adding $fileName to local task list", newData) + + localExceptionContext(LocalJtxICalObject(localCollection, fileName, eTag, null, LocalResource.FLAG_REMOTELY_PRESENT)) { + it.applyNewData(newData) + it.add() + } + syncResult.stats.numInserts++ + } + } + } else + Logger.log.info("Received VCALENDAR with not exactly one VTODO or VJOURNAL; ignoring $fileName") + } + +} \ No newline at end of file diff --git a/app/src/main/java/at/bitfire/davdroid/syncadapter/OpenTasksSyncAdapterService.kt b/app/src/main/java/at/bitfire/davdroid/syncadapter/OpenTasksSyncAdapterService.kt new file mode 100644 index 0000000000000000000000000000000000000000..29418ea37465f87dc318d03cc596d0b627a17f76 --- /dev/null +++ b/app/src/main/java/at/bitfire/davdroid/syncadapter/OpenTasksSyncAdapterService.kt @@ -0,0 +1,7 @@ +/*************************************************************************************************** + * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. + **************************************************************************************************/ + +package at.bitfire.davdroid.syncadapter + +class OpenTasksSyncAdapterService: TasksSyncAdapterService() \ No newline at end of file diff --git a/app/src/main/java/at/bitfire/davdroid/syncadapter/SyncAdapterService.kt b/app/src/main/java/at/bitfire/davdroid/syncadapter/SyncAdapterService.kt index 2d426df32579380807ad2a6a7982d90db0abefad..969cbd25afc8ab230ecd3301ed99e1f0cfee07c1 100644 --- a/app/src/main/java/at/bitfire/davdroid/syncadapter/SyncAdapterService.kt +++ b/app/src/main/java/at/bitfire/davdroid/syncadapter/SyncAdapterService.kt @@ -1,46 +1,33 @@ -/* - * Copyright © Ricki Hirner (bitfire web engineering). - * All rights reserved. This program and the accompanying materials - * are made available under the terms of the GNU Public License v3.0 - * which accompanies this distribution, and is available at - * http://www.gnu.org/licenses/gpl.html - */ +/*************************************************************************************************** + * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. + **************************************************************************************************/ package at.bitfire.davdroid.syncadapter -import android.Manifest import android.accounts.Account -import android.app.PendingIntent import android.app.Service import android.content.* -import android.content.pm.PackageManager import android.net.ConnectivityManager import android.net.NetworkCapabilities import android.net.wifi.WifiManager -import android.os.Build import android.os.Bundle -import androidx.core.app.NotificationCompat -import androidx.core.app.NotificationManagerCompat -import androidx.core.content.ContextCompat -import at.bitfire.davdroid.R +import androidx.core.content.getSystemService +import at.bitfire.davdroid.ConcurrentUtils +import at.bitfire.davdroid.HttpClient +import at.bitfire.davdroid.InvalidAccountException +import at.bitfire.davdroid.PermissionUtils +import at.bitfire.davdroid.db.AppDatabase import at.bitfire.davdroid.log.Logger import at.bitfire.davdroid.settings.AccountSettings -import at.bitfire.davdroid.ui.account.SettingsActivity -import at.bitfire.davdroid.ui.NotificationUtils -import at.bitfire.davdroid.ui.account.AccountActivity -import java.lang.ref.WeakReference -import java.util.* +import at.bitfire.davdroid.ui.account.WifiPermissionsActivity +import dagger.hilt.android.AndroidEntryPoint import java.util.logging.Level +import javax.inject.Inject +@AndroidEntryPoint abstract class SyncAdapterService: Service() { companion object { - /** Keep a list of running syncs to block multiple calls at the same time, - * like run by some devices. Weak references are used for the case that a thread - * is terminated and the `finally` block which cleans up [runningSyncs] is not - * executed. */ - private val runningSyncs = mutableListOf>>() - /** * Specifies an list of IDs which are requested to be synchronized before * the other collections. For instance, if some calendars of a CalDAV @@ -74,16 +61,33 @@ abstract class SyncAdapterService: Service() { const val SYNC_EXTRAS_FULL_RESYNC = "full_resync" } + @Inject lateinit var appDatabase: AppDatabase + + protected abstract fun syncAdapter(): AbstractThreadedSyncAdapter override fun onBind(intent: Intent?) = syncAdapter().syncAdapterBinder!! + /** + * Base class for our sync adapters. Guarantees that + * + * 1. not more than one sync adapter per account and authority is running at a time, + * 2. `Thread.currentThread().contextClassLoader` is set to the current context's class loader. + * + * Also provides some useful methods that can be used by derived sync adapters. + */ abstract class SyncAdapter( - context: Context - ): AbstractThreadedSyncAdapter(context, false) { + context: Context, + val db: AppDatabase + ): AbstractThreadedSyncAdapter( + context, + true // isSyncable shouldn't be -1 because DAVx5 sets it to 0 or 1. + // However, if it is -1 by accident, set it to 1 to avoid endless sync loops. + ) { companion object { + fun priorityCollections(extras: Bundle): Set { val ids = mutableSetOf() extras.getString(SYNC_EXTRAS_PRIORITY_COLLECTIONS)?.let { rawIds -> @@ -96,69 +100,74 @@ abstract class SyncAdapterService: Service() { } return ids } + } - abstract fun sync(account: Account, extras: Bundle, authority: String, provider: ContentProviderClient, syncResult: SyncResult) + abstract fun sync(account: Account, extras: Bundle, authority: String, httpClient: Lazy, provider: ContentProviderClient, syncResult: SyncResult) override fun onPerformSync(account: Account, extras: Bundle, authority: String, provider: ContentProviderClient, syncResult: SyncResult) { Logger.log.log(Level.INFO, "$authority sync of $account has been initiated", extras.keySet().joinToString(", ")) - // prevent multiple syncs of the same authority to be run for the same account - val currentSync = Pair(authority, account) - synchronized(runningSyncs) { - if (runningSyncs.any { it.get() == currentSync }) { - Logger.log.warning("There's already another $authority sync running for $account, aborting") - return - } - runningSyncs += WeakReference(currentSync) - } - - try { - // required for dav4jvm (ServiceLoader) + // prevent multiple syncs of the same authority and account to run simultaneously + val currentSyncKey = Pair(authority, account) + if (ConcurrentUtils.runSingle(currentSyncKey) { + // required for ServiceLoader -> ical4j -> ical4android Thread.currentThread().contextClassLoader = context.classLoader - SyncManager.cancelNotifications(NotificationManagerCompat.from(context), authority, account) - sync(account, extras, authority, provider, syncResult) - } finally { - synchronized(runningSyncs) { - runningSyncs.removeAll { it.get() == null || it.get() == currentSync } - } - } + val accountSettings by lazy { AccountSettings(context, account) } + val httpClient = lazy { HttpClient.Builder(context, accountSettings).build() } + + try { + val runSync = /* always true in open-source edition */ true - Logger.log.info("Sync for $currentSync finished") + if (runSync) + sync(account, extras, authority, httpClient, provider, syncResult) + + } catch (e: InvalidAccountException) { + Logger.log.log( + Level.WARNING, + "Account was removed during synchronization", + e + ) + } finally { + if (httpClient.isInitialized()) + httpClient.value.close() + } + }) + Logger.log.log(Level.INFO, "Sync for $currentSyncKey finished", syncResult) + else + Logger.log.warning("There's already another running sync for $currentSyncKey, aborting") } override fun onSecurityException(account: Account, extras: Bundle, authority: String, syncResult: SyncResult) { Logger.log.log(Level.WARNING, "Security exception when opening content provider for $authority") - syncResult.databaseError = true + } - val intent = Intent(context, AccountActivity::class.java) - intent.putExtra(AccountActivity.EXTRA_ACCOUNT, account) - intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + override fun onSyncCanceled() { + Logger.log.info("Sync thread cancelled! Interrupting sync") + super.onSyncCanceled() + } - notifyPermissions(intent) + override fun onSyncCanceled(thread: Thread) { + Logger.log.info("Sync thread ${thread.id} cancelled! Interrupting sync") + super.onSyncCanceled(thread) } + protected fun checkSyncConditions(settings: AccountSettings): Boolean { if (settings.getSyncWifiOnly()) { // WiFi required - val connectivityManager = context.getSystemService(CONNECTIVITY_SERVICE) as ConnectivityManager + val connectivityManager = context.getSystemService()!! // check for connected WiFi network var wifiAvailable = false - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) { - connectivityManager.allNetworks.forEach { network -> - connectivityManager.getNetworkCapabilities(network)?.let { capabilities -> - if (capabilities.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) && - capabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_VALIDATED)) - wifiAvailable = true - } + connectivityManager.allNetworks.forEach { network -> + connectivityManager.getNetworkCapabilities(network)?.let { capabilities -> + if (capabilities.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) && + capabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_VALIDATED)) + wifiAvailable = true } - } else { - val network = connectivityManager.activeNetworkInfo - if (network?.isConnected == true && network.type == ConnectivityManager.TYPE_WIFI) - wifiAvailable = true } if (!wifiAvailable) { Logger.log.info("Not on connected WiFi, stopping") @@ -167,40 +176,30 @@ abstract class SyncAdapterService: Service() { // if execution reaches this point, we're on a connected WiFi settings.getSyncWifiOnlySSIDs()?.let { onlySSIDs -> - // getting the WiFi name requires location permission (and active location services) since Android 8.1 - // see https://issuetracker.google.com/issues/70633700 - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O_MR1 && - ContextCompat.checkSelfPermission(context, Manifest.permission.ACCESS_FINE_LOCATION) != PackageManager.PERMISSION_GRANTED) { - val intent = Intent(context, SettingsActivity::class.java) - intent.putExtra(SettingsActivity.EXTRA_ACCOUNT, settings.account) + // check required permissions and location status + if (!PermissionUtils.canAccessWifiSsid(context)) { + // not all permissions granted; show notification + val intent = Intent(context, WifiPermissionsActivity::class.java) intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + intent.putExtra(WifiPermissionsActivity.EXTRA_ACCOUNT, settings.account) + PermissionUtils.notifyPermissions(context, intent) - notifyPermissions(intent) + Logger.log.warning("Can't access WiFi SSID, aborting sync") + return false } - val wifi = context.applicationContext.getSystemService(WIFI_SERVICE) as WifiManager + val wifi = context.getSystemService()!! val info = wifi.connectionInfo if (info == null || !onlySSIDs.contains(info.ssid.trim('"'))) { - Logger.log.info("Connected to wrong WiFi network (${info.ssid}), ignoring") + Logger.log.info("Connected to wrong WiFi network (${info.ssid}), aborting sync") return false - } + } else + Logger.log.fine("Connected to WiFi network ${info.ssid}") } } return true } - protected fun notifyPermissions(intent: Intent) { - val notify = NotificationUtils.newBuilder(context, NotificationUtils.CHANNEL_SYNC_ERRORS) - .setSmallIcon(R.drawable.ic_sync_problem_notify) - .setContentTitle(context.getString(R.string.sync_error_permissions)) - .setContentText(context.getString(R.string.sync_error_permissions_text)) - .setContentIntent(PendingIntent.getActivity(context, 0, intent, PendingIntent.FLAG_UPDATE_CURRENT)) - .setCategory(NotificationCompat.CATEGORY_ERROR) - .setAutoCancel(true) - .build() - NotificationManagerCompat.from(context).notify(NotificationUtils.NOTIFY_PERMISSIONS, notify) - } - } -} +} \ No newline at end of file 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 f31a2b2443433f06c7c22c3008a597a32f372da8..34b4f47104b36e6410b4ee7cd2342ce1d4786985 100644 --- a/app/src/main/java/at/bitfire/davdroid/syncadapter/SyncManager.kt +++ b/app/src/main/java/at/bitfire/davdroid/syncadapter/SyncManager.kt @@ -1,10 +1,6 @@ -/* - * Copyright © Ricki Hirner (bitfire web engineering). - * All rights reserved. This program and the accompanying materials - * are made available under the terms of the GNU Public License v3.0 - * which accompanies this distribution, and is available at - * http://www.gnu.org/licenses/gpl.html - */ +/*************************************************************************************************** + * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. + **************************************************************************************************/ package at.bitfire.davdroid.syncadapter @@ -25,44 +21,63 @@ import at.bitfire.dav4jvm.* import at.bitfire.dav4jvm.exception.* import at.bitfire.dav4jvm.property.GetCTag import at.bitfire.dav4jvm.property.GetETag +import at.bitfire.dav4jvm.property.ScheduleTag import at.bitfire.dav4jvm.property.SyncToken import at.bitfire.davdroid.* -import at.bitfire.davdroid.Constants +import at.bitfire.davdroid.db.AppDatabase +import at.bitfire.davdroid.db.SyncState +import at.bitfire.davdroid.db.SyncStats import at.bitfire.davdroid.log.Logger -import at.bitfire.davdroid.model.SyncState import at.bitfire.davdroid.resource.* import at.bitfire.davdroid.settings.AccountSettings import at.bitfire.davdroid.ui.DebugInfoActivity import at.bitfire.davdroid.ui.NotificationUtils import at.bitfire.davdroid.ui.account.SettingsActivity import at.bitfire.ical4android.CalendarStorageException +import at.bitfire.ical4android.Ical4Android import at.bitfire.ical4android.TaskProvider +import at.bitfire.ical4android.UsesThreadContextClassLoader import at.bitfire.vcard4android.ContactsStorageException +import dagger.hilt.EntryPoint +import dagger.hilt.InstallIn +import dagger.hilt.android.EntryPointAccessors +import dagger.hilt.components.SingletonComponent +import kotlinx.coroutines.* import okhttp3.HttpUrl import okhttp3.RequestBody +import org.apache.commons.io.FileUtils import org.apache.commons.lang3.exception.ContextedException import org.dmfs.tasks.contract.TaskContract import java.io.IOException import java.io.InterruptedIOException +import java.lang.ref.WeakReference import java.net.HttpURLConnection import java.security.cert.CertificateException import java.util.* -import java.util.concurrent.* +import java.util.concurrent.LinkedBlockingQueue +import java.util.concurrent.ThreadPoolExecutor +import java.util.concurrent.TimeUnit import java.util.concurrent.atomic.AtomicInteger import java.util.logging.Level import javax.net.ssl.SSLHandshakeException -import kotlin.math.min -@Suppress("MemberVisibilityCanBePrivate") +@UsesThreadContextClassLoader abstract class SyncManager, out CollectionType: LocalCollection, RemoteType: DavCollection>( - val context: Context, - val account: Account, - val accountSettings: AccountSettings, - val extras: Bundle, - val authority: String, - val syncResult: SyncResult, - val localCollection: CollectionType -): AutoCloseable { + val context: Context, + val account: Account, + val accountSettings: AccountSettings, + val httpClient: HttpClient, + val extras: Bundle, + val authority: String, + val syncResult: SyncResult, + val localCollection: CollectionType +) { + + @EntryPoint + @InstallIn(SingletonComponent::class) + interface SyncManagerEntryPoint { + fun appDatabase(): AppDatabase + } enum class SyncAlgorithm { PROPFIND_REPORT, @@ -70,43 +85,50 @@ abstract class SyncManager, out CollectionType: L } companion object { - - val MAX_PROCESSING_THREADS = // nCPU/2 (rounded up for case of 1 CPU), but max. 4 - min((Runtime.getRuntime().availableProcessors()+1)/2, 4) - val MAX_DOWNLOAD_THREADS = // one (if one CPU), 2 otherwise - min(Runtime.getRuntime().availableProcessors(), 2) + const val DEBUG_INFO_MAX_RESOURCE_DUMP_SIZE = 100*FileUtils.ONE_KB.toInt() const val MAX_MULTIGET_RESOURCES = 10 - fun cancelNotifications(manager: NotificationManagerCompat, authority: String, account: Account) = - manager.cancel(notificationTag(authority, account), NotificationUtils.NOTIFY_SYNC_ERROR) - - private fun notificationTag(authority: String, account: Account) = - "$authority-${account.name}".hashCode().toString() - + var _workDispatcher: WeakReference? = null + /** + * We use our own dispatcher to + * + * - make sure that all threads have [Thread.getContextClassLoader] set, which is required for dav4jvm and ical4j (because they rely on [ServiceLoader]), + * - control the global number of sync worker threads. + * + * Threads created by a service automatically have a contextClassLoader. + */ + fun getWorkDispatcher(): CoroutineDispatcher { + val cached = _workDispatcher?.get() + if (cached != null) + return cached + + val newDispatcher = ThreadPoolExecutor( + 0, Integer.min(Runtime.getRuntime().availableProcessors(), 4), + 10, TimeUnit.SECONDS, LinkedBlockingQueue() + ).asCoroutineDispatcher() + return newDispatcher + } } init { - Logger.log.info("SyncManager: using up to $MAX_PROCESSING_THREADS processing threads and $MAX_DOWNLOAD_THREADS download threads") + // required for ServiceLoader -> ical4j -> ical4android + Ical4Android.checkThreadContextClassLoader() } - private val mainAccount = if (localCollection is LocalAddressBook) + protected val mainAccount = if (localCollection is LocalAddressBook) localCollection.mainAccount else account protected val notificationManager = NotificationManagerCompat.from(context) - protected val notificationTag = notificationTag(authority, mainAccount) - - protected val httpClient = HttpClient.Builder(context, accountSettings).build() + protected val notificationTag = localCollection.tag protected lateinit var collectionURL: HttpUrl protected lateinit var davCollection: RemoteType protected var hasCollectionSync = false - override fun close() { - httpClient.close() - } + val workDispatcher = getWorkDispatcher() fun performSync() { @@ -119,16 +141,22 @@ abstract class SyncManager, out CollectionType: L Logger.log.info("No reason to synchronize, aborting") return@unwrapExceptions } - abortIfCancelled() + + // log sync time + val db = EntryPointAccessors.fromApplication(context, SyncManagerEntryPoint::class.java).appDatabase() + db.runInTransaction { + db.collectionDao().getByUrl(collectionURL.toString())?.let { collection -> + db.syncStatsDao().insertOrReplace( + SyncStats(0, collection.id, authority, System.currentTimeMillis()) + ) + } + } Logger.log.info("Querying server capabilities") var remoteSyncState = queryCapabilities() - abortIfCancelled() Logger.log.info("Sending local deletes/updates to server") - val modificationsSent = processLocallyDeleted() || - uploadDirty() - abortIfCancelled() + val modificationsSent = processLocallyDeleted() || uploadDirty() if (extras.containsKey(SyncAdapterService.SYNC_EXTRAS_FULL_RESYNC)) { Logger.log.info("Forcing re-synchronization of all entries") @@ -158,7 +186,7 @@ abstract class SyncManager, out CollectionType: L } Logger.log.info("Deleting entries which are not present remotely anymore") - syncResult.stats.numDeletes += deleteNotPresentRemotely() + deleteNotPresentRemotely() Logger.log.info("Post-processing") postProcess() @@ -199,11 +227,11 @@ abstract class SyncManager, out CollectionType: L } else throw e } - - Logger.log.log(Level.INFO, "Saving sync state", syncState) - localCollection.lastSyncState = syncState } + Logger.log.log(Level.INFO, "Saving sync state", syncState) + localCollection.lastSyncState = syncState + Logger.log.info("Server has further changes: $furtherChanges") } while(furtherChanges) @@ -227,9 +255,10 @@ abstract class SyncManager, out CollectionType: L }, { e, local, remote -> when (e) { - // sync was cancelled: re-throw to SyncAdapterService + // sync was cancelled or account has been removed: re-throw to SyncAdapterService is InterruptedException, - is InterruptedIOException -> + is InterruptedIOException, + is InvalidAccountException -> throw e // specific I/O errors @@ -237,7 +266,7 @@ abstract class SyncManager, out CollectionType: L Logger.log.log(Level.WARNING, "SSL handshake failed", e) // when a certificate is rejected by cert4android, the cause will be a CertificateException - if (!BuildConfig.customCerts || e.cause !is CertificateException) + if (e.cause !is CertificateException) notifyException(e, local, remote) } @@ -258,6 +287,11 @@ abstract class SyncManager, out CollectionType: L } + /** + * Prepares synchronization. Sets the lateinit properties [collectionURL] and [davCollection]. + * + * @return whether synchronization shall be performed + */ protected abstract fun prepare(): Boolean /** @@ -282,15 +316,16 @@ abstract class SyncManager, out CollectionType: L // but only if they don't have changed on the server. Then finally remove them from the local address book. val localList = localCollection.findDeleted() for (local in localList) { - abortIfCancelled() - useLocal(local) { + localExceptionContext(local) { val fileName = local.fileName if (fileName != null) { - Logger.log.info("$fileName has been deleted locally -> deleting from server (ETag ${local.eTag})") + val lastScheduleTag = local.scheduleTag + 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)") - useRemote(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(local.eTag) {} + remote.delete(ifETag = lastETag, ifScheduleTag = lastScheduleTag) {} numDeleted++ } catch (e: HttpException) { Logger.log.warning("Couldn't delete $fileName from server; ignoring (may be downloaded again)") @@ -307,75 +342,113 @@ abstract class SyncManager, out CollectionType: L } /** - * Uploads locally modified resources to the server (HTTP `PUT`). + * Uploads locally modified resources to the server. * * @return whether resources have been uploaded */ protected open fun uploadDirty(): Boolean { var numUploaded = 0 - // make sure all resources have file name and UID before uploading them - for (local in localCollection.findDirtyWithoutNameOrUid()) - useLocal(local) { - Logger.log.fine("Generating file name/UID for local resource #${local.id}") - local.assignNameAndUID() - } + // upload dirty resources (parallelized) + runBlocking(workDispatcher) { + for (local in localCollection.findDirty()) + launch { + localExceptionContext(local) { + uploadDirty(local) + numUploaded++ + } + } + } + syncResult.stats.numEntries += numUploaded + Logger.log.info("Sent $numUploaded record(s) to server") + return numUploaded > 0 + } - // upload dirty resources - for (local in localCollection.findDirty()) - useLocal(local) { - abortIfCancelled() + protected fun uploadDirty(local: ResourceType) { + val existingFileName = local.fileName - val fileName = local.fileName!! - useRemote(DavResource(httpClient.okHttpClient, collectionURL.newBuilder().addPathSegment(fileName).build())) { remote -> - // generate entity to upload (VCard, iCal, whatever) - val body = prepareUpload(local) + var newFileName: String? = null + var eTag: String? = null + var scheduleTag: String? = null + val readTagsFromResponse: (okhttp3.Response) -> Unit = { response -> + eTag = GetETag.fromResponse(response)?.eTag + scheduleTag = ScheduleTag.fromResponse(response)?.scheduleTag + } - var eTag: String? = null - val processETag: (response: okhttp3.Response) -> Unit = { response -> - response.header("ETag")?.let { getETag -> - eTag = GetETag(getETag).eTag - } - } - try { - if (local.eTag == null) { - Logger.log.info("Uploading new record $fileName") - remote.put(body, null, true, processETag) - } else { - Logger.log.info("Uploading locally modified record $fileName") - remote.put(body, local.eTag, false, processETag) - } - numUploaded++ - } catch(e: ForbiddenException) { - // HTTP 403 Forbidden - // If and only if the upload failed because of missing permissions, treat it like 412. - if (e.errors.contains(Error.NEED_PRIVILEGES)) - Logger.log.log(Level.INFO, "Couldn't upload because of missing permissions, ignoring", e) - else - throw e - } catch(e: ConflictException) { - // HTTP 409 Conflict - // We can't interact with the user to resolve the conflict, so we treat 409 like 412. - Logger.log.log(Level.INFO, "Edit conflict, ignoring", e) - } catch(e: PreconditionFailedException) { - // HTTP 412 Precondition failed: Resource has been modified on the server in the meanwhile. - // Ignore this condition so that the resource can be downloaded and reset again. - Logger.log.log(Level.INFO, "Resource has been modified on the server before upload, ignoring", e) - } + try { + if (existingFileName == null) { // new resource + newFileName = local.prepareForUpload() - if (eTag != null) - Logger.log.fine("Received new ETag=$eTag after uploading") - else - Logger.log.fine("Didn't receive new ETag after uploading, setting to null") + val uploadUrl = collectionURL.newBuilder().addPathSegment(newFileName).build() + remoteExceptionContext(DavResource(httpClient.okHttpClient, uploadUrl)) { remote -> + Logger.log.info("Uploading new record ${local.id} -> $newFileName") + remote.put(generateUpload(local), ifNoneMatch = true, callback = readTagsFromResponse) + } - local.clearDirty(eTag) + } else /* existingFileName != null */ { // updated resource + local.prepareForUpload() + + val uploadUrl = collectionURL.newBuilder().addPathSegment(existingFileName).build() + remoteExceptionContext(DavResource(httpClient.okHttpClient, uploadUrl)) { remote -> + val lastScheduleTag = local.scheduleTag + val lastETag = if (lastScheduleTag == null) local.eTag else null + Logger.log.info("Uploading modified record ${local.id} -> $existingFileName (ETag=$lastETag, Schedule-Tag=$lastScheduleTag)") + remote.put(generateUpload(local), ifETag = lastETag, ifScheduleTag = lastScheduleTag, callback = readTagsFromResponse) } } - Logger.log.info("Sent $numUploaded record(s) to server") - return numUploaded > 0 + } catch (e: ContextedException) { + when (val ex = e.cause) { + is ForbiddenException -> { + // HTTP 403 Forbidden + // If and only if the upload failed because of missing permissions, treat it like 412. + if (ex.errors.contains(Error.NEED_PRIVILEGES)) + Logger.log.log(Level.INFO, "Couldn't upload because of missing permissions, ignoring", ex) + else + throw e + } + is NotFoundException, is GoneException -> { + // HTTP 404 Not Found (i.e. either original resource or the whole collection is not there anymore) + if (local.scheduleTag != null || local.eTag != null) { // this was an update of a previously existing resource + Logger.log.info("Original version of locally modified resource is not there (anymore), trying as fresh upload") + if (local.scheduleTag != null) // contacts don't support scheduleTag, don't try to set it without check + local.scheduleTag = null + local.eTag = null + uploadDirty(local) // if this fails with 404, too, the collection is gone + return + } else + throw e // the collection is probably gone + } + is ConflictException -> { + // HTTP 409 Conflict + // We can't interact with the user to resolve the conflict, so we treat 409 like 412. + Logger.log.info("Edit conflict, ignoring") + } + is PreconditionFailedException -> { + // HTTP 412 Precondition failed: Resource has been modified on the server in the meanwhile. + // Ignore this condition so that the resource can be downloaded and reset again. + Logger.log.info("Resource has been modified on the server before upload, ignoring") + } + else -> throw e + } + } + + if (eTag != null) + Logger.log.fine("Received new ETag=$eTag after uploading") + else + Logger.log.fine("Didn't receive new ETag after uploading, setting to null") + + local.clearDirty(newFileName, eTag, scheduleTag) } - protected abstract fun prepareUpload(resource: ResourceType): RequestBody + /** + * Generates the request body (iCalendar or vCard) from a local resource. + * + * @param resource local resource to generate the body from + * + * @return iCalendar or vCard (content + Content-Type) that can be uploaded to the server + */ + protected abstract fun generateUpload(resource: ResourceType): RequestBody + /** * Determines whether a sync is required because there were changes on the server. @@ -401,17 +474,16 @@ abstract class SyncManager, out CollectionType: L val localState = localCollection.lastSyncState Logger.log.info("Local sync state = $localState, remote sync state = $state") - return when { - state?.type == SyncState.Type.SYNC_TOKEN -> { + return when (state?.type) { + SyncState.Type.SYNC_TOKEN -> { val lastKnownToken = localState?.takeIf { it.type == SyncState.Type.SYNC_TOKEN }?.value lastKnownToken != state.value } - state?.type == SyncState.Type.CTAG -> { + SyncState.Type.CTAG -> { val lastKnownCTag = localState?.takeIf { it.type == SyncState.Type.CTAG }?.value lastKnownCTag != state.value } - else -> - true + else -> true } } @@ -436,115 +508,94 @@ abstract class SyncManager, out CollectionType: L Logger.log.info("Number of local non-dirty entries: $number") } + /** + * Calls a callback to list remote resources. All resources from the returned + * list are downloaded and processed. + * + * @param listRemote function to list remote resources (for instance, all since a certain sync-token) + */ protected open fun syncRemote(listRemote: (DavResponseCallback) -> Unit) { - // results must be processed in main thread because exceptions must be thrown in main - // thread, so that they can be catched by SyncManager - val results = ConcurrentLinkedQueue>() - // thread-safe sync stats val nInserted = AtomicInteger() val nUpdated = AtomicInteger() val nDeleted = AtomicInteger() val nSkipped = AtomicInteger() - // download queue - val toDownload = LinkedBlockingQueue() - - // tasks from this executor create the download tasks (if necessary) - val processor = ThreadPoolExecutor(1, MAX_PROCESSING_THREADS, - 10, TimeUnit.SECONDS, - LinkedBlockingQueue(MAX_PROCESSING_THREADS), // accept up to MAX_PROCESSING_THREADS processing tasks - ThreadPoolExecutor.CallerRunsPolicy() // if the queue is full, run task in submitting thread - ) - - // this executor runs the actual download tasks - val downloader = ThreadPoolExecutor(0, MAX_DOWNLOAD_THREADS, - 10, TimeUnit.SECONDS, - LinkedBlockingQueue(MAX_DOWNLOAD_THREADS), // accept up to MAX_DOWNLOAD_THREADS download tasks - ThreadPoolExecutor.CallerRunsPolicy() // if the queue is full, run task in submitting thread - ) - fun downloadBunch() { - val bunch = LinkedList() - toDownload.drainTo(bunch, MAX_MULTIGET_RESOURCES) - results += downloader.submit { - downloadRemote(bunch) + runBlocking { + // download queue + val toDownload = LinkedBlockingQueue() + fun download(url: HttpUrl?) { + if (url != null) + toDownload += url + + if (toDownload.size >= MAX_MULTIGET_RESOURCES || url == null) { + while (toDownload.size > 0) { + val bunch = LinkedList() + toDownload.drainTo(bunch, MAX_MULTIGET_RESOURCES) + launch { + downloadRemote(bunch) + } + } + } } - } - listRemote { response, relation -> - // ignore non-members - if (relation != Response.HrefRelation.MEMBER) - return@listRemote - - // ignore collections - if (response[at.bitfire.dav4jvm.property.ResourceType::class.java]?.types?.contains(at.bitfire.dav4jvm.property.ResourceType.COLLECTION) == true) - return@listRemote - - val name = response.hrefName() - - if (response.isSuccess()) { - Logger.log.fine("Found remote resource: $name") - - results += processor.submit { - useLocal(localCollection.findByName(name)) { local -> - if (local == null) { - Logger.log.info("$name has been added remotely") - toDownload += response.href - nInserted.incrementAndGet() - } else { - val localETag = local.eTag - val remoteETag = response[GetETag::class.java]?.eTag ?: throw DavException("Server didn't provide ETag") - if (localETag == remoteETag) { - Logger.log.info("$name has not been changed on server (ETag still $remoteETag)") - nSkipped.incrementAndGet() - } else { - Logger.log.info("$name has been changed on server (current ETag=$remoteETag, last known ETag=$localETag)") - toDownload += response.href - nUpdated.incrementAndGet() + withContext(workDispatcher) { // structured concurrency: blocks until all inner coroutines are finished + listRemote { response, relation -> + // ignore non-members + if (relation != Response.HrefRelation.MEMBER) + return@listRemote + + // ignore collections + if (response[at.bitfire.dav4jvm.property.ResourceType::class.java]?.types?.contains(at.bitfire.dav4jvm.property.ResourceType.COLLECTION) == true) + return@listRemote + + val name = response.hrefName() + + if (response.isSuccess()) { + Logger.log.fine("Found remote resource: $name") + + launch { + localExceptionContext(localCollection.findByName(name)) { local -> + if (local == null) { + Logger.log.info("$name has been added remotely, queueing download") + download(response.href) + nInserted.incrementAndGet() + } else { + val localETag = local.eTag + val remoteETag = response[GetETag::class.java]?.eTag + ?: throw DavException("Server didn't provide ETag") + if (localETag == remoteETag) { + Logger.log.info("$name has not been changed on server (ETag still $remoteETag)") + nSkipped.incrementAndGet() + } else { + Logger.log.info("$name has been changed on server (current ETag=$remoteETag, last known ETag=$localETag)") + download(response.href) + nUpdated.incrementAndGet() + } + + // mark as remotely present, so that this resource won't be deleted at the end + local.updateFlags(LocalResource.FLAG_REMOTELY_PRESENT) + } } - - // mark as remotely present, so that this resource won't be deleted at the end - local.updateFlags(LocalResource.FLAG_REMOTELY_PRESENT) } - } - synchronized(processor) { - if (toDownload.size >= MAX_MULTIGET_RESOURCES) - // download another bunch of MAX_MULTIGET_RESOURCES resources - downloadBunch() - } - } - - } else if (response.status?.code == HttpURLConnection.HTTP_NOT_FOUND) { - // collection sync: resource has been deleted on remote server - results += processor.submit { - useLocal(localCollection.findByName(name)) { local -> - Logger.log.info("$name has been deleted on server, deleting locally") - local?.delete() - nDeleted.incrementAndGet() + } else if (response.status?.code == HttpURLConnection.HTTP_NOT_FOUND) { + // collection sync: resource has been deleted on remote server + launch { + localExceptionContext(localCollection.findByName(name)) { local -> + Logger.log.info("$name has been deleted on server, deleting locally") + local?.delete() + nDeleted.incrementAndGet() + } + } } } } - // check already available results for exceptions so that they don't become too many - checkResults(results) + // download remaining resources + download(null) } - // process remaining responses - processor.shutdown() - processor.awaitTermination(5, TimeUnit.MINUTES) - - // download remaining resources - if (toDownload.isNotEmpty()) - downloadBunch() - - // signal end of queue and wait for download thread - downloader.shutdown() - downloader.awaitTermination(5, TimeUnit.MINUTES) - - // check remaining results for exceptions - checkResults(results) - // update sync stats with(syncResult.stats) { numInserts += nInserted.get() @@ -585,6 +636,24 @@ abstract class SyncManager, out CollectionType: L return Pair(syncToken!!, furtherResults) } + /** + * Downloads and processes resources, given as a list of URLs. Will be called with a list + * of changed/new remote resources. + * + * Implementations should not use GET to fetch single resources, but always multi-get, even + * for single resources for these reasons: + * + * 1. GET can only be used without HTTP compression, because it may change the ETag. + * multi-get sends the ETag in the XML body, so there's no problem with compression. + * 2. Some servers are wrongly configured to suppress the ETag header in the response. + * With multi-get, the ETag is in the XML body, so it won't be affected by that. + * 3. If there are two methods to download resources (GET and multi-get), both methods + * have to be implemented, tested and maintained. Given that multi-get is required + * in any case, it's better to have only one method. + * 4. For users, it's strange behavior when DAVx5 can download multiple remote changes, + * but not a single one (or vice versa). So only one method is more user-friendly. + * 5. March 2020: iCloud now crashes with HTTP 500 upon CardDAV GET requests. + */ protected abstract fun downloadRemote(bunch: List) /** @@ -595,10 +664,10 @@ abstract class SyncManager, out CollectionType: L * Used together with [resetPresentRemotely] when a full listing has been received from * the server to locally delete resources which are not present remotely (anymore). */ - protected open fun deleteNotPresentRemotely(): Int { + protected open fun deleteNotPresentRemotely() { val removed = localCollection.removeNotDirtyMarked(0) Logger.log.info("Removed $removed local resources which are not present on the server anymore") - return removed + syncResult.stats.numDeletes += removed } /** @@ -609,17 +678,6 @@ abstract class SyncManager, out CollectionType: L // sync helpers - /** - * Throws an [InterruptedException] if the current thread has been interrupted, - * most probably because synchronization was cancelled by the user. - * - * @throws InterruptedException (which will be caught by [performSync]) - * */ - protected fun abortIfCancelled() { - if (Thread.interrupted()) - throw InterruptedException("Sync was cancelled") - } - protected fun syncState(dav: Response) = dav[SyncToken::class.java]?.token?.let { SyncState(SyncState.Type.SYNC_TOKEN, it) @@ -654,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) @@ -674,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) @@ -707,7 +775,7 @@ abstract class SyncManager, out CollectionType: L .setStyle(NotificationCompat.BigTextStyle(builder).bigText(message)) .setSubText(mainAccount.name) .setOnlyAlertOnce(true) - .setContentIntent(PendingIntent.getActivity(context, 0, contentIntent, PendingIntent.FLAG_UPDATE_CURRENT)) + .setContentIntent(PendingIntent.getActivity(context, 0, contentIntent, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE)) .setPriority(priority) .setCategory(NotificationCompat.CATEGORY_ERROR) viewItemAction?.let { builder.addAction(it) } @@ -717,17 +785,20 @@ abstract class SyncManager, out CollectionType: L } private fun buildDebugInfoIntent(e: Throwable, local: ResourceType?, remote: HttpUrl?) = - Intent(context, DebugInfoActivity::class.java).apply { - putExtra(DebugInfoActivity.KEY_ACCOUNT, account) - putExtra(DebugInfoActivity.KEY_AUTHORITY, authority) - putExtra(DebugInfoActivity.KEY_THROWABLE, e) - - // pass current local/remote resource - if (local != null) - putExtra(DebugInfoActivity.KEY_LOCAL_RESOURCE, local.toString()) - if (remote != null) - putExtra(DebugInfoActivity.KEY_REMOTE_RESOURCE, remote.toString()) - } + DebugInfoActivity.IntentBuilder(context) + .withAccount(account) + .withAuthority(authority) + .withCause(e) + .withLocalResource( + try { + local.toString() + } catch (e: OutOfMemoryError) { + // for instance because of a huge contact photo; maybe we're lucky and can fetch it + null + } + ) + .withRemoteResource(remote) + .build() private fun buildRetryAction(): NotificationCompat.Action { val retryIntent = Intent(context, DavService::class.java) @@ -752,7 +823,7 @@ abstract class SyncManager, out CollectionType: L return NotificationCompat.Action( android.R.drawable.ic_menu_rotate, context.getString(R.string.sync_error_retry), - PendingIntent.getService(context, 0, retryIntent, PendingIntent.FLAG_UPDATE_CURRENT)) + PendingIntent.getService(context, 0, retryIntent, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE)) } private fun buildViewItemAction(local: ResourceType): NotificationCompat.Action? { @@ -771,27 +842,11 @@ abstract class SyncManager, out CollectionType: L } return if (intent != null && context.packageManager.resolveActivity(intent, 0) != null) NotificationCompat.Action(android.R.drawable.ic_menu_view, context.getString(R.string.sync_error_view_item), - PendingIntent.getActivity(context, 0, intent, PendingIntent.FLAG_UPDATE_CURRENT)) + PendingIntent.getActivity(context, 0, intent, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE)) else null } - - fun checkResults(results: MutableCollection>) { - val iter = results.iterator() - while (iter.hasNext()) { - val result = iter.next() - if (result.isDone) { - try { - result.get() - } catch(e: ExecutionException) { - throw e.cause!! - } - iter.remove() - } - } - } - protected fun notifyInvalidResource(e: Throwable, fileName: String) { val intent = buildDebugInfoIntent(e, null, collectionURL.resolve(fileName)) @@ -800,7 +855,7 @@ abstract class SyncManager, out CollectionType: L .setContentTitle(notifyInvalidResourceTitle()) .setContentText(context.getString(R.string.sync_invalid_resources_ignoring)) .setSubText(mainAccount.name) - .setContentIntent(PendingIntent.getActivity(context, 0, intent, PendingIntent.FLAG_UPDATE_CURRENT)) + .setContentIntent(PendingIntent.getActivity(context, 0, intent, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE)) .setAutoCancel(true) .setOnlyAlertOnce(true) .priority = NotificationCompat.PRIORITY_LOW @@ -809,7 +864,7 @@ abstract class SyncManager, out CollectionType: L protected abstract fun notifyInvalidResourceTitle(): String - protected fun useLocal(local: T, body: (T) -> R): R { + protected fun localExceptionContext(local: T, body: (T) -> R): R { try { return body(local) } catch (e: ContextedException) { @@ -823,7 +878,7 @@ abstract class SyncManager, out CollectionType: L } } - protected fun useRemote(remote: T, body: (T) -> R): R { + protected fun remoteExceptionContext(remote: T, body: (T) -> R): R { try { return body(remote) } catch (e: ContextedException) { @@ -834,7 +889,7 @@ abstract class SyncManager, out CollectionType: L } } - protected fun useRemote(remote: Response, body: (Response) -> T): T { + protected fun responseExceptionContext(remote: Response, body: (Response) -> T): T { try { return body(remote) } catch (e: ContextedException) { @@ -845,8 +900,8 @@ abstract class SyncManager, out CollectionType: L } } - protected fun useRemoteCollection(body: (RemoteType) -> R) = - useRemote(davCollection, body) + protected fun remoteExceptionContext(body: (RemoteType) -> R) = + remoteExceptionContext(davCollection, body) private fun unwrapExceptions(body: () -> Unit, handler: (e: Throwable, local: ResourceType?, remote: HttpUrl?) -> Unit) { var ex: Throwable? = null @@ -877,5 +932,4 @@ abstract class SyncManager, out CollectionType: L handler(ex, local, remote) } - } \ No newline at end of file diff --git a/app/src/main/java/at/bitfire/davdroid/syncadapter/SyncUtils.kt b/app/src/main/java/at/bitfire/davdroid/syncadapter/SyncUtils.kt new file mode 100644 index 0000000000000000000000000000000000000000..c9e9bc5abb6fa76389898738f8ecefab7ed86278 --- /dev/null +++ b/app/src/main/java/at/bitfire/davdroid/syncadapter/SyncUtils.kt @@ -0,0 +1,148 @@ +/*************************************************************************************************** + * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. + **************************************************************************************************/ + +package at.bitfire.davdroid.syncadapter + +import android.accounts.Account +import android.accounts.AccountManager +import android.app.PendingIntent +import android.content.ContentResolver +import android.content.Context +import android.content.Intent +import android.content.pm.PackageManager +import android.graphics.drawable.BitmapDrawable +import android.net.Uri +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 +import at.bitfire.davdroid.db.AppDatabase +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.ui.NotificationUtils +import at.bitfire.ical4android.TaskProvider +import dagger.hilt.EntryPoint +import dagger.hilt.InstallIn +import dagger.hilt.android.EntryPointAccessors +import dagger.hilt.components.SingletonComponent + +object SyncUtils { + + @EntryPoint + @InstallIn(SingletonComponent::class) + interface SyncUtilsEntryPoint { + fun appDatabase(): AppDatabase + fun settingsManager(): SettingsManager + } + + /** + * Starts an Intent and redirects the user to the package in the market to update the app + * + * @param e the TaskProvider.ProviderTooOldException to be shown + */ + fun notifyProviderTooOld(context: Context, e: TaskProvider.ProviderTooOldException) { + val nm = NotificationManagerCompat.from(context) + val message = context.getString(R.string.sync_error_tasks_required_version, e.provider.minVersionName) + + val pm = context.packageManager + val tasksAppInfo = pm.getPackageInfo(e.provider.packageName, 0) + val tasksAppLabel = tasksAppInfo.applicationInfo.loadLabel(pm) + + val notify = NotificationUtils.newBuilder(context, NotificationUtils.CHANNEL_SYNC_ERRORS) + .setSmallIcon(R.drawable.ic_sync_problem_notify) + .setContentTitle(context.getString(R.string.sync_error_tasks_too_old, tasksAppLabel)) + .setContentText(message) + .setSubText("$tasksAppLabel ${e.installedVersionName}") + .setCategory(NotificationCompat.CATEGORY_ERROR) + + try { + val icon = pm.getApplicationIcon(e.provider.packageName) + if (icon is BitmapDrawable) + notify.setLargeIcon(icon.bitmap) + } catch (ignored: PackageManager.NameNotFoundException) { + // couldn't get provider app icon + } + + val intent = Intent(Intent.ACTION_VIEW, Uri.parse("market://details?id=${e.provider.packageName}")) + + var flags = PendingIntent.FLAG_UPDATE_CURRENT + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) + flags = flags or PendingIntent.FLAG_IMMUTABLE + + if (intent.resolveActivity(pm) != null) + notify.setContentIntent(PendingIntent.getActivity(context, 0, intent, flags)) + + nm.notify(NotificationUtils.NOTIFY_TASKS_PROVIDER_TOO_OLD, notify.build()) + } + + fun removePeriodicSyncs(account: Account, authority: String) { + for (sync in ContentResolver.getPeriodicSyncs(account, authority)) + ContentResolver.removePeriodicSync(sync.account, sync.authority, sync.extras) + } + + + // task sync utils + + @WorkerThread + fun updateTaskSync(context: Context) { + val tasksProvider = TaskUtils.currentProvider(context) + Logger.log.info("App launched or other package (un)installed; current tasks provider = $tasksProvider") + + var permissionsRequired = false // whether additional permissions are required + val currentProvider by lazy { // only this provider shall be enabled (null to disable all providers) + TaskUtils.currentProvider(context) + } + + // 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.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) + val shallBeSyncable = hasCalDAV && providerName == currentProvider + if ((shallBeSyncable && isSyncable != 1) || (!shallBeSyncable && isSyncable != 0)) { + // enable/disable sync + setSyncableFromSettings(context, account, providerName.authority, shallBeSyncable) + + // if sync has just been enabled: check whether additional permissions are required + if (shallBeSyncable && !PermissionUtils.havePermissions(context, providerName.permissions)) + permissionsRequired = true + } + } + } + + if (permissionsRequired) { + Logger.log.warning("Tasks synchronization is now enabled for at least one account, but permissions are not granted") + PermissionUtils.notifyPermissions(context, null) + } + } + + private fun setSyncableFromSettings(context: Context, account: Account, authority: String, syncable: Boolean) { + val settingsManager by lazy { EntryPointAccessors.fromApplication(context, SyncUtilsEntryPoint::class.java).settingsManager() } + if (syncable) { + Logger.log.info("Enabling $authority sync for $account") + ContentResolver.setIsSyncable(account, authority, 1) + try { + val settings = AccountSettings(context, account) + val interval = settings.getSavedTasksSyncInterval() ?: Constants.DEFAULT_CALENDAR_SYNC_INTERVAL + settings.setSyncInterval(authority, interval) + } catch (e: InvalidAccountException) { + // account has already been removed + } + } else { + Logger.log.info("Disabling $authority sync for $account") + ContentResolver.setIsSyncable(account, authority, 0) + } + } + +} \ No newline at end of file diff --git a/app/src/main/java/at/bitfire/davdroid/syncadapter/TasksOrgSyncAdapterService.kt b/app/src/main/java/at/bitfire/davdroid/syncadapter/TasksOrgSyncAdapterService.kt new file mode 100644 index 0000000000000000000000000000000000000000..0a387ae938c594fb1ced68e68e042eb62d837ed6 --- /dev/null +++ b/app/src/main/java/at/bitfire/davdroid/syncadapter/TasksOrgSyncAdapterService.kt @@ -0,0 +1,7 @@ +/*************************************************************************************************** + * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. + **************************************************************************************************/ + +package at.bitfire.davdroid.syncadapter + +class TasksOrgSyncAdapterService: TasksSyncAdapterService() \ No newline at end of file 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 3f33d9020d3a1dc73c993c3b98e3e1407404518d..c61070f8d14bd2384561d7280faf4d26cb6d1651 100644 --- a/app/src/main/java/at/bitfire/davdroid/syncadapter/TasksSyncAdapterService.kt +++ b/app/src/main/java/at/bitfire/davdroid/syncadapter/TasksSyncAdapterService.kt @@ -1,56 +1,62 @@ -/* - * Copyright © Ricki Hirner (bitfire web engineering). - * All rights reserved. This program and the accompanying materials - * are made available under the terms of the GNU Public License v3.0 - * which accompanies this distribution, and is available at - * http://www.gnu.org/licenses/gpl.html - */ +/*************************************************************************************************** + * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. + **************************************************************************************************/ package at.bitfire.davdroid.syncadapter import android.accounts.Account import android.accounts.AccountManager -import android.app.PendingIntent -import android.content.* -import android.content.pm.PackageManager -import android.graphics.drawable.BitmapDrawable -import android.net.Uri +import android.content.ContentProviderClient +import android.content.ContentResolver +import android.content.Context +import android.content.SyncResult import android.os.Build import android.os.Bundle -import androidx.core.app.NotificationCompat -import androidx.core.app.NotificationManagerCompat -import at.bitfire.davdroid.R +import at.bitfire.davdroid.HttpClient +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.model.AppDatabase -import at.bitfire.davdroid.model.Collection -import at.bitfire.davdroid.model.Service import at.bitfire.davdroid.resource.LocalTaskList import at.bitfire.davdroid.settings.AccountSettings -import at.bitfire.davdroid.ui.NotificationUtils 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 import java.util.logging.Level /** * Synchronization manager for CalDAV collections; handles tasks ({@code VTODO}). */ -class TasksSyncAdapterService: SyncAdapterService() { +open class TasksSyncAdapterService: SyncAdapterService() { - override fun syncAdapter() = TasksSyncAdapter(this) + override fun syncAdapter() = TasksSyncAdapter(this, appDatabase) class TasksSyncAdapter( - context: Context - ): SyncAdapter(context) { + context: Context, + appDatabase: AppDatabase, + ) : SyncAdapter(context, appDatabase) { - override fun sync(account: Account, extras: Bundle, authority: String, provider: ContentProviderClient, syncResult: SyncResult) { + override fun sync(account: Account, extras: Bundle, authority: String, httpClient: Lazy, provider: ContentProviderClient, syncResult: SyncResult) { try { - val taskProvider = TaskProvider.fromProviderClient(context, provider) - - // make sure account can be seen by OpenTasks - if (Build.VERSION.SDK_INT >= 26) - AccountManager.get(context).setAccountVisibility(account, taskProvider.name.packageName, AccountManager.VISIBILITY_VISIBLE) + val providerName = TaskProvider.ProviderName.fromAuthority(authority) + val taskProvider = TaskProvider.fromProviderClient(context, providerName, provider) + + // make sure account can be seen by task provider + if (Build.VERSION.SDK_INT >= 26) { + /* Warning: If setAccountVisibility is called, Android 12 broadcasts the + AccountManager.LOGIN_ACCOUNTS_CHANGED_ACTION Intent. This cancels running syncs + and starts them again! So make sure setAccountVisibility is only called when necessary. */ + val am = AccountManager.get(context) + if (am.getAccountVisibility(account, providerName.packageName) != AccountManager.VISIBILITY_VISIBLE) + am.setAccountVisibility(account, providerName.packageName, AccountManager.VISIBILITY_VISIBLE) + } val accountSettings = AccountSettings(context, account) /* don't run sync if @@ -68,32 +74,47 @@ class TasksSyncAdapterService: SyncAdapterService() { .sortedByDescending { priorityTaskLists.contains(it.id) } for (taskList in taskLists) { Logger.log.info("Synchronizing task list #${taskList.id} [${taskList.syncId}]") - TasksSyncManager(context, account, accountSettings, extras, authority, syncResult, taskList).use { - it.performSync() + 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) { tokenResponse, ex -> + authState.update(tokenResponse, ex) + accountSettings.credentials( + Credentials( + account.name, + null, + authState, + null + ) + ) + it.accountSettings.credentials( + Credentials( + it.account.name, + null, + authState, + null + ) + ) + object : AsyncTask() { + override fun doInBackground(vararg params: Void): Void? { + it.performSync() + return null + } + }.execute() + } + } else { + it.performSync() + } + } else { + it.performSync() + } } } } catch (e: TaskProvider.ProviderTooOldException) { - val nm = NotificationManagerCompat.from(context) - val message = context.getString(R.string.sync_error_opentasks_required_version, e.provider.minVersionName, e.installedVersionName) - val notify = NotificationUtils.newBuilder(context, NotificationUtils.CHANNEL_SYNC_ERRORS) - .setSmallIcon(R.drawable.ic_sync_problem_notify) - .setContentTitle(context.getString(R.string.sync_error_opentasks_too_old)) - .setContentText(message) - .setStyle(NotificationCompat.BigTextStyle().bigText(message)) - .setCategory(NotificationCompat.CATEGORY_ERROR) - - try { - val icon = context.packageManager.getApplicationIcon(e.provider.packageName) - if (icon is BitmapDrawable) - notify.setLargeIcon(icon.bitmap) - } catch(ignored: PackageManager.NameNotFoundException) {} - - val intent = Intent(Intent.ACTION_VIEW, Uri.parse("market://details?id=${e.provider.packageName}")) - if (intent.resolveActivity(context.packageManager) != null) - notify .setContentIntent(PendingIntent.getActivity(context, 0, intent, PendingIntent.FLAG_UPDATE_CURRENT)) - .setAutoCancel(true) - - nm.notify(NotificationUtils.NOTIFY_OPENTASKS, notify.build()) + SyncUtils.notifyProviderTooOld(context, e) syncResult.databaseError = true } catch (e: Exception) { Logger.log.log(Level.SEVERE, "Couldn't sync task lists", e) @@ -104,7 +125,6 @@ class TasksSyncAdapterService: SyncAdapterService() { } private fun updateLocalTaskLists(provider: TaskProvider, account: Account, settings: AccountSettings) { - val db = AppDatabase.getInstance(context) val service = db.serviceDao().getByAccountAndType(account.name, Service.TYPE_CALDAV) val remoteTaskLists = mutableMapOf() @@ -118,7 +138,7 @@ class TasksSyncAdapterService: SyncAdapterService() { for (list in AndroidTaskList.find(account, provider, LocalTaskList.Factory, null, null)) list.syncId?.let { - val url = HttpUrl.parse(it)!! + val url = it.toHttpUrl() val info = remoteTaskLists[url] if (info == null) { Logger.log.fine("Deleting obsolete local task list $url") @@ -141,4 +161,4 @@ class TasksSyncAdapterService: SyncAdapterService() { } -} +} \ No newline at end of file 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 a41b52916fd9404d88f82163e371de9329f77f7a..bb02ce57ff013a87331796736aaf749a6f3a78dc 100644 --- a/app/src/main/java/at/bitfire/davdroid/syncadapter/TasksSyncManager.kt +++ b/app/src/main/java/at/bitfire/davdroid/syncadapter/TasksSyncManager.kt @@ -1,10 +1,6 @@ -/* - * Copyright © Ricki Hirner (bitfire web engineering). - * All rights reserved. This program and the accompanying materials - * are made available under the terms of the GNU Public License v3.0 - * which accompanies this distribution, and is available at - * http://www.gnu.org/licenses/gpl.html - */ +/*************************************************************************************************** + * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. + **************************************************************************************************/ package at.bitfire.davdroid.syncadapter @@ -13,27 +9,26 @@ import android.content.Context import android.content.SyncResult import android.os.Bundle import at.bitfire.dav4jvm.DavCalendar -import at.bitfire.dav4jvm.DavResource import at.bitfire.dav4jvm.DavResponseCallback import at.bitfire.dav4jvm.Response import at.bitfire.dav4jvm.exception.DavException -import at.bitfire.dav4jvm.property.CalendarData -import at.bitfire.dav4jvm.property.GetCTag -import at.bitfire.dav4jvm.property.GetETag -import at.bitfire.dav4jvm.property.SyncToken +import at.bitfire.dav4jvm.property.* import at.bitfire.davdroid.DavUtils +import at.bitfire.davdroid.HttpClient import at.bitfire.davdroid.R +import at.bitfire.davdroid.db.SyncState import at.bitfire.davdroid.log.Logger -import at.bitfire.davdroid.model.SyncState import at.bitfire.davdroid.resource.LocalResource import at.bitfire.davdroid.resource.LocalTask import at.bitfire.davdroid.resource.LocalTaskList import at.bitfire.davdroid.settings.AccountSettings -import at.bitfire.ical4android.Constants import at.bitfire.ical4android.InvalidCalendarException import at.bitfire.ical4android.Task import okhttp3.HttpUrl +import okhttp3.HttpUrl.Companion.toHttpUrlOrNull import okhttp3.RequestBody +import okhttp3.RequestBody.Companion.toRequestBody +import org.apache.commons.io.FileUtils import java.io.ByteArrayOutputStream import java.io.Reader import java.io.StringReader @@ -43,49 +38,53 @@ import java.util.logging.Level * Synchronization manager for CalDAV collections; handles tasks (VTODO) */ class TasksSyncManager( - context: Context, - account: Account, - accountSettings: AccountSettings, - extras: Bundle, - authority: String, - syncResult: SyncResult, - localCollection: LocalTaskList -): SyncManager(context, account, accountSettings, extras, authority, syncResult, localCollection) { + context: Context, + account: Account, + accountSettings: AccountSettings, + httpClient: HttpClient, + extras: Bundle, + authority: String, + syncResult: SyncResult, + localCollection: LocalTaskList +): SyncManager(context, account, accountSettings, httpClient, extras, authority, syncResult, localCollection) { override fun prepare(): Boolean { - collectionURL = HttpUrl.parse(localCollection.syncId ?: return false) ?: return false - davCollection = DavCalendar(httpClient.okHttpClient, collectionURL) + collectionURL = (localCollection.syncId ?: return false).toHttpUrlOrNull() ?: return false + davCollection = DavCalendar(httpClient.okHttpClient, collectionURL, accountSettings.credentials().authState?.accessToken) return true } override fun queryCapabilities() = - useRemoteCollection { + remoteExceptionContext { var syncState: SyncState? = null - it.propfind(0, GetCTag.NAME, SyncToken.NAME) { response, relation -> - if (relation == Response.HrefRelation.SELF) - syncState = syncState(response) + it.propfind(0, MaxICalendarSize.NAME, GetCTag.NAME, SyncToken.NAME) { response, relation -> + if (relation == Response.HrefRelation.SELF) { + response[MaxICalendarSize::class.java]?.maxSize?.let { maxSize -> + Logger.log.info("Calendar accepts tasks up to ${FileUtils.byteCountToDisplaySize(maxSize)}") + } + + syncState = syncState(response) + } } + syncState } override fun syncAlgorithm() = SyncAlgorithm.PROPFIND_REPORT - override fun prepareUpload(resource: LocalTask): RequestBody = useLocal(resource) { + override fun generateUpload(resource: LocalTask): RequestBody = localExceptionContext(resource) { val task = requireNotNull(resource.task) Logger.log.log(Level.FINE, "Preparing upload of task ${resource.fileName}", task) val os = ByteArrayOutputStream() task.write(os) - RequestBody.create( - DavCalendar.MIME_ICALENDAR_UTF8, - os.toByteArray() - ) + os.toByteArray().toRequestBody(DavCalendar.MIME_ICALENDAR_UTF8) } override fun listAllRemote(callback: DavResponseCallback) { - useRemoteCollection { remote -> + remoteExceptionContext { remote -> Logger.log.info("Querying tasks") remote.calendarQuery("VTODO", null, null, callback) } @@ -93,46 +92,31 @@ class TasksSyncManager( override fun downloadRemote(bunch: List) { Logger.log.info("Downloading ${bunch.size} iCalendars: $bunch") - if (bunch.size == 1) { - val remote = bunch.first() - // only one contact, use GET - useRemote(DavResource(httpClient.okHttpClient, remote)) { resource -> - resource.get(DavCalendar.MIME_ICALENDAR.toString()) { response -> - // CalDAV servers MUST return ETag on GET [https://tools.ietf.org/html/rfc4791#section-5.3.4] - val eTag = response.header("ETag")?.let { GetETag(it).eTag } - ?: throw DavException("Received CalDAV GET response without ETag") - - response.body()!!.use { - processVTodo(resource.fileName(), eTag, it.charStream()) + // multiple iCalendars, use calendar-multi-get + remoteExceptionContext { + it.multiget(bunch) { response, _ -> + responseExceptionContext(response) { + if (!response.isSuccess()) { + Logger.log.warning("Received non-successful multiget response for ${response.href}") + return@responseExceptionContext } - } - } - } else - // multiple iCalendars, use calendar-multi-get - useRemoteCollection { - it.multiget(bunch) { response, _ -> - useRemote(response) { - if (!response.isSuccess()) { - Logger.log.warning("Received non-successful multiget response for ${response.href}") - return@useRemote - } - val eTag = response[GetETag::class.java]?.eTag - ?: throw DavException("Received multi-get response without ETag") + val eTag = response[GetETag::class.java]?.eTag + ?: throw DavException("Received multi-get response without ETag") - val calendarData = response[CalendarData::class.java] - val iCal = calendarData?.iCalendar - ?: throw DavException("Received multi-get response without address data") + val calendarData = response[CalendarData::class.java] + val iCal = calendarData?.iCalendar + ?: throw DavException("Received multi-get response without address data") - processVTodo(DavUtils.lastSegmentOfUrl(response.href), eTag, StringReader(iCal)) - } + processVTodo(DavUtils.lastSegmentOfUrl(response.href), eTag, StringReader(iCal)) } } + } } override fun postProcess() { val touched = localCollection.touchRelations() - Constants.log.info("Touched $touched relations") + Logger.log.info("Touched $touched relations") } // helpers @@ -151,7 +135,7 @@ class TasksSyncManager( val newData = tasks.first() // update local task, if it exists - useLocal(localCollection.findByName(fileName)) { local -> + localExceptionContext(localCollection.findByName(fileName)) { local -> if (local != null) { Logger.log.log(Level.INFO, "Updating $fileName in local task list", newData) local.eTag = eTag @@ -159,7 +143,7 @@ class TasksSyncManager( syncResult.stats.numUpdates++ } else { Logger.log.log(Level.INFO, "Adding $fileName to local task list", newData) - useLocal(LocalTask(localCollection, newData, fileName, eTag, LocalResource.FLAG_REMOTELY_PRESENT)) { + localExceptionContext(LocalTask(localCollection, newData, fileName, eTag, LocalResource.FLAG_REMOTELY_PRESENT)) { it.add() } syncResult.stats.numInserts++ diff --git a/app/src/main/java/at/bitfire/davdroid/syncadapter/groups/CategoriesStrategy.kt b/app/src/main/java/at/bitfire/davdroid/syncadapter/groups/CategoriesStrategy.kt new file mode 100644 index 0000000000000000000000000000000000000000..438906ce47391007a5831a5f7a67ae9c2ae3b0c0 --- /dev/null +++ b/app/src/main/java/at/bitfire/davdroid/syncadapter/groups/CategoriesStrategy.kt @@ -0,0 +1,42 @@ +/*************************************************************************************************** + * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. + **************************************************************************************************/ + +package at.bitfire.davdroid.syncadapter.groups + +import at.bitfire.davdroid.log.Logger +import at.bitfire.davdroid.resource.LocalAddressBook +import at.bitfire.vcard4android.Contact + +class CategoriesStrategy(val addressBook: LocalAddressBook): ContactGroupStrategy { + + override fun beforeUploadDirty() { + // groups with DELETED=1: set all members to dirty, then remove group + for (group in addressBook.findDeletedGroups()) { + Logger.log.fine("Finally removing group $group") + group.markMembersDirty() + group.delete() + } + + // groups with DIRTY=1: mark all members as dirty, then clean DIRTY flag of group + for (group in addressBook.findDirtyGroups()) { + Logger.log.fine("Marking members of modified group $group as dirty") + group.markMembersDirty() + group.clearDirty(null, null) + } + } + + override fun verifyContactBeforeSaving(contact: Contact) { + if (contact.group || contact.members.isNotEmpty()) { + Logger.log.warning("Received group vCard although group method is CATEGORIES. Saving as regular contact") + contact.group = false + contact.members.clear() + } + } + + override fun postProcess() { + Logger.log.info("Removing empty groups") + addressBook.removeEmptyGroups() + } + +} \ No newline at end of file diff --git a/app/src/main/java/at/bitfire/davdroid/syncadapter/groups/ContactGroupStrategy.kt b/app/src/main/java/at/bitfire/davdroid/syncadapter/groups/ContactGroupStrategy.kt new file mode 100644 index 0000000000000000000000000000000000000000..0d3d97e638f8c7ae193f10e03f00360c7ec9eb21 --- /dev/null +++ b/app/src/main/java/at/bitfire/davdroid/syncadapter/groups/ContactGroupStrategy.kt @@ -0,0 +1,15 @@ +/*************************************************************************************************** + * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. + **************************************************************************************************/ + +package at.bitfire.davdroid.syncadapter.groups + +import at.bitfire.vcard4android.Contact + +interface ContactGroupStrategy { + + fun beforeUploadDirty() + fun verifyContactBeforeSaving(contact: Contact) + fun postProcess() + +} \ No newline at end of file diff --git a/app/src/main/java/at/bitfire/davdroid/syncadapter/groups/VCard4Strategy.kt b/app/src/main/java/at/bitfire/davdroid/syncadapter/groups/VCard4Strategy.kt new file mode 100644 index 0000000000000000000000000000000000000000..3ba28af94b2e9e351584de3b727db164b18b568e --- /dev/null +++ b/app/src/main/java/at/bitfire/davdroid/syncadapter/groups/VCard4Strategy.kt @@ -0,0 +1,50 @@ +/*************************************************************************************************** + * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. + **************************************************************************************************/ + +package at.bitfire.davdroid.syncadapter.groups + +import android.content.ContentUris +import android.provider.ContactsContract +import at.bitfire.davdroid.log.Logger +import at.bitfire.davdroid.resource.LocalAddressBook +import at.bitfire.davdroid.resource.LocalGroup +import at.bitfire.davdroid.syncadapter.ContactsSyncManager.Companion.disjunct +import at.bitfire.vcard4android.BatchOperation +import at.bitfire.vcard4android.Contact +import java.io.FileNotFoundException + +class VCard4Strategy(val addressBook: LocalAddressBook): ContactGroupStrategy { + + override fun beforeUploadDirty() { + /* Mark groups with changed members as dirty: + 1. Iterate over all dirty contacts. + 2. Check whether group memberships have changed by comparing group memberships and cached group memberships. + 3. Mark groups which have been added to/removed from the contact as dirty so that they will be uploaded. + 4. Successful upload will reset dirty flag and update cached group memberships. + */ + val batch = BatchOperation(addressBook.provider!!) + for (contact in addressBook.findDirtyContacts()) + try { + Logger.log.fine("Looking for changed group memberships of contact ${contact.fileName}") + val cachedGroups = contact.getCachedGroupMemberships() + val currentGroups = contact.getGroupMemberships() + for (groupID in cachedGroups disjunct currentGroups) { + Logger.log.fine("Marking group as dirty: $groupID") + batch.enqueue(BatchOperation.CpoBuilder + .newUpdate(addressBook.syncAdapterURI(ContentUris.withAppendedId(ContactsContract.Groups.CONTENT_URI, groupID))) + .withValue(ContactsContract.Groups.DIRTY, 1)) + } + } catch(e: FileNotFoundException) { + } + batch.commit() + } + + override fun verifyContactBeforeSaving(contact: Contact) { + } + + override fun postProcess() { + LocalGroup.applyPendingMemberships(addressBook) + } + +} \ No newline at end of file diff --git a/app/src/main/java/at/bitfire/davdroid/ui/AboutActivity.kt b/app/src/main/java/at/bitfire/davdroid/ui/AboutActivity.kt index bbd68eed1bb03a000ce3749c6fd15dfc2c655836..036a648cdfdc7733e6d1f569a4ea722629639fd6 100644 --- a/app/src/main/java/at/bitfire/davdroid/ui/AboutActivity.kt +++ b/app/src/main/java/at/bitfire/davdroid/ui/AboutActivity.kt @@ -1,39 +1,55 @@ -/* - * Copyright © Ricki Hirner (bitfire web engineering). - * All rights reserved. This program and the accompanying materials - * are made available under the terms of the GNU Public License v3.0 - * which accompanies this distribution, and is available at - * http://www.gnu.org/licenses/gpl.html - */ +/*************************************************************************************************** + * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. + **************************************************************************************************/ package at.bitfire.davdroid.ui import android.app.Application +import android.content.Context import android.os.Build import android.os.Bundle import android.text.Spanned +import android.text.method.LinkMovementMethod import android.util.DisplayMetrics import android.view.* +import androidx.annotation.UiThread import androidx.appcompat.app.AppCompatActivity import androidx.core.text.HtmlCompat import androidx.fragment.app.Fragment import androidx.fragment.app.FragmentManager import androidx.fragment.app.FragmentPagerAdapter +import androidx.fragment.app.viewModels import androidx.lifecycle.AndroidViewModel +import androidx.lifecycle.MediatorLiveData import androidx.lifecycle.MutableLiveData -import androidx.lifecycle.Observer -import androidx.lifecycle.ViewModelProviders +import androidx.lifecycle.viewModelScope +import androidx.recyclerview.widget.LinearLayoutManager +import androidx.recyclerview.widget.RecyclerView import at.bitfire.davdroid.App import at.bitfire.davdroid.BuildConfig import at.bitfire.davdroid.R +import at.bitfire.davdroid.databinding.AboutBinding +import at.bitfire.davdroid.databinding.AboutLanguagesBinding +import at.bitfire.davdroid.databinding.AboutTranslationBinding +import at.bitfire.davdroid.databinding.ActivityAboutBinding +import com.mikepenz.aboutlibraries.Libs import com.mikepenz.aboutlibraries.LibsBuilder -import kotlinx.android.synthetic.main.about.* -import kotlinx.android.synthetic.main.activity_about.* +import dagger.BindsOptionalOf +import dagger.Module +import dagger.hilt.InstallIn +import dagger.hilt.android.AndroidEntryPoint +import dagger.hilt.android.components.ActivityComponent +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch import org.apache.commons.io.IOUtils +import org.json.JSONObject +import java.text.Collator import java.text.SimpleDateFormat import java.util.* -import kotlin.concurrent.thread +import javax.inject.Inject +import javax.inject.Qualifier +@AndroidEntryPoint class AboutActivity: AppCompatActivity() { companion object { @@ -45,19 +61,24 @@ class AboutActivity: AppCompatActivity() { } + private lateinit var binding: ActivityAboutBinding + + override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - setContentView(R.layout.activity_about) - setSupportActionBar(toolbar) + binding = ActivityAboutBinding.inflate(layoutInflater) + setContentView(binding.root) + + setSupportActionBar(binding.toolbar) supportActionBar?.setDisplayHomeAsUpEnabled(true) - viewpager.adapter = TabsAdapter(supportFragmentManager) - tabs.setupWithViewPager(viewpager, false) + binding.viewpager.adapter = TabsAdapter(supportFragmentManager) + binding.tabs.setupWithViewPager(binding.viewpager, false) } - override fun onCreateOptionsMenu(menu: Menu?): Boolean { - menuInflater.inflate(R.menu.about_davdroid, menu) + override fun onCreateOptionsMenu(menu: Menu): Boolean { + menuInflater.inflate(R.menu.activity_about, menu) return true } @@ -70,68 +91,213 @@ class AboutActivity: AppCompatActivity() { fm: FragmentManager ): FragmentPagerAdapter(fm, BEHAVIOR_RESUME_ONLY_CURRENT_FRAGMENT) { - override fun getCount() = 2 + override fun getCount() = 3 override fun getPageTitle(position: Int): String = when (position) { 0 -> getString(R.string.app_name) + 1 -> getString(R.string.about_translations) else -> getString(R.string.about_libraries) } override fun getItem(position: Int) = when (position) { 0 -> AppFragment() - else -> LibsBuilder() - .withAutoDetect(false) - .withFields(R.string::class.java.fields) - .withLicenseShown(true) - .supportFragment() + 1 -> LanguagesFragment() + else -> { + LibsBuilder() + .withFields(R.string::class.java.fields) // mandatory for non-standard build flavors + .withLicenseShown(true) + .withAboutIconShown(false) + + // https://github.com/mikepenz/AboutLibraries/issues/490 + .withLibraryModification("org_brotli__dec", Libs.LibraryFields.LIBRARY_NAME, "Brotli") + .withLibraryModification("org_brotli__dec", Libs.LibraryFields.AUTHOR_NAME, "Google") + + .supportFragment() + } } } + @Qualifier + @Retention(AnnotationRetention.BINARY) + annotation class LicenseFragment + + @Module + @InstallIn(ActivityComponent::class) + abstract class LicenseFragmentModule { + @BindsOptionalOf + @LicenseFragment + abstract fun licenseFragment(): Fragment + } + + @AndroidEntryPoint class AppFragment: Fragment() { - override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?) = - inflater.inflate(R.layout.about, container, false)!! + private var _binding: AboutBinding? = null + private val binding get() = _binding!! + val model by viewModels() + + @Inject + @LicenseFragment + lateinit var licenseFragment: Optional + + override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View { + _binding = AboutBinding.inflate(inflater, container, false) + return binding.root + } override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - app_name.text = getString(R.string.app_name) - app_version.text = getString(R.string.about_version, BuildConfig.VERSION_NAME, BuildConfig.VERSION_CODE) - build_time.text = getString(R.string.about_build_date, SimpleDateFormat.getDateInstance().format(BuildConfig.buildTime)) + binding.appName.setText(R.string.app_name) + binding.appVersion.text = getString(R.string.about_version, BuildConfig.VERSION_NAME, BuildConfig.VERSION_CODE) + binding.buildTime.text = getString(R.string.about_build_date, SimpleDateFormat.getDateInstance().format(BuildConfig.buildTime)) if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) - icon.setImageDrawable(resources.getDrawableForDensity(R.mipmap.ic_launcher, DisplayMetrics.DENSITY_XXXHIGH)) + binding.icon.setImageDrawable(resources.getDrawableForDensity(R.mipmap.ic_launcher, DisplayMetrics.DENSITY_XXXHIGH, null)) - pixels.text = HtmlCompat.fromHtml(pixelsHtml, HtmlCompat.FROM_HTML_MODE_LEGACY) + binding.pixels.text = HtmlCompat.fromHtml(pixelsHtml, HtmlCompat.FROM_HTML_MODE_LEGACY) if (true /* open-source version */) { - warranty.setText(R.string.about_license_info_no_warranty) + binding.warranty.setText(R.string.about_license_info_no_warranty) - val model = ViewModelProviders.of(this).get(LicenseModel::class.java) - model.htmlText.observe(this, Observer { spanned -> - license_text.text = spanned + model.initialize("gplv3.html", true) + model.htmlText.observe(viewLifecycleOwner, { spanned -> + binding.licenseText.text = spanned }) } } + override fun onDestroyView() { + super.onDestroyView() + _binding = null + } + + } + + class LanguagesFragment: Fragment() { + + private var _binding: AboutLanguagesBinding? = null + private val binding get() = _binding!! + val model by viewModels() + + override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View { + _binding = AboutLanguagesBinding.inflate(inflater, container, false) + return binding.root + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + model.initialize("translators.json", false) + model.translations.observe(viewLifecycleOwner, { translations -> + binding.translators.adapter = TranslationsAdapter(translations) + }) + + binding.translators.layoutManager = LinearLayoutManager(requireActivity()) + } + + override fun onDestroyView() { + super.onDestroyView() + _binding = null + } + + + class TranslationsAdapter( + val translations: List + ): RecyclerView.Adapter() { + + private lateinit var binding: AboutTranslationBinding + + class ViewHolder( + val context: Context, val binding: AboutTranslationBinding + ): RecyclerView.ViewHolder(binding.root) + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder { + binding = AboutTranslationBinding.inflate(LayoutInflater.from(parent.context), parent, false) + return ViewHolder(parent.context, binding) + } + + override fun onBindViewHolder(holder: ViewHolder, position: Int) { + val translation = translations[position] + holder.binding.apply { + language.text = translation.language + val profiles = translation.translators.map { "$it" } + translators.text = HtmlCompat.fromHtml( + holder.context.getString(R.string.about_translations_thanks, profiles.joinToString(", ")), + HtmlCompat.FROM_HTML_MODE_COMPACT) + translators.movementMethod = LinkMovementMethod.getInstance() + } + } + + override fun getItemCount() = translations.size + } + } - class LicenseModel( + + open class TextFileModel( application: Application ): AndroidViewModel(application) { + private var initialized = false val htmlText = MutableLiveData() + val plainText = MutableLiveData() + + @UiThread + fun initialize(assetName: String, html: Boolean) { + if (initialized) return + initialized = true + + viewModelScope.launch(Dispatchers.IO) { + getApplication().resources.assets.open(assetName).use { + val raw = IOUtils.toString(it, Charsets.UTF_8) + if (html) { + val spanned = HtmlCompat.fromHtml(raw, HtmlCompat.FROM_HTML_MODE_LEGACY) + htmlText.postValue(spanned) + } else + plainText.postValue(raw) + } + } + } + + } + + class TranslationsModel( + application: Application + ): TextFileModel(application) { + + class Translation( + val language: String, + val translators: Array + ) + + val translations = object: MediatorLiveData>() { + init { + addSource(plainText) { rawJson -> + // parse JSON + val jsonTranslations = JSONObject(rawJson) + val result = LinkedList() + for (langCode in jsonTranslations.keys()) { + val jsonTranslators = jsonTranslations.getJSONArray(langCode) + val translators = Array(jsonTranslators.length()) { + idx -> jsonTranslators.getString(idx) + } + + val langTag = langCode.replace('_', '-') + val language = Locale.forLanguageTag(langTag).displayName + result += Translation(language, translators) + } + + // sort translations by localized language name + val collator = Collator.getInstance() + result.sortWith { o1, o2 -> + collator.compare(o1.language, o2.language) + } - init { - thread { - getApplication().resources.assets.open("gplv3.html").use { - val spanned = HtmlCompat.fromHtml(IOUtils.toString(it, Charsets.UTF_8), HtmlCompat.FROM_HTML_MODE_LEGACY) - htmlText.postValue(spanned) + postValue(result) } } } } -} +} \ No newline at end of file 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 4c1e914475865ccfbb71fcaa163dacc5cf02b002..253ac1944e12e761372c24ee1e389765cda1debe 100644 --- a/app/src/main/java/at/bitfire/davdroid/ui/AccountListFragment.kt +++ b/app/src/main/java/at/bitfire/davdroid/ui/AccountListFragment.kt @@ -1,107 +1,190 @@ -/* - * Copyright © Ricki Hirner (bitfire web engineering). - * All rights reserved. This program and the accompanying materials - * are made available under the terms of the GNU Public License v3.0 - * which accompanies this distribution, and is available at - * http://www.gnu.org/licenses/gpl.html - */ +/*************************************************************************************************** + * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. + **************************************************************************************************/ package at.bitfire.davdroid.ui import android.accounts.Account import android.accounts.AccountManager import android.accounts.OnAccountsUpdateListener +import android.app.Activity import android.app.Application -import android.content.BroadcastReceiver -import android.content.Context -import android.content.Intent -import android.content.IntentFilter +import android.content.* import android.net.ConnectivityManager import android.net.Network import android.net.NetworkCapabilities import android.net.NetworkRequest import android.os.Build import android.os.Bundle -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup -import android.widget.AbsListView -import android.widget.AdapterView -import android.widget.ArrayAdapter -import androidx.fragment.app.ListFragment +import android.provider.Settings +import android.view.* +import androidx.core.content.getSystemService +import androidx.fragment.app.Fragment +import androidx.fragment.app.viewModels import androidx.lifecycle.AndroidViewModel import androidx.lifecycle.MutableLiveData -import androidx.lifecycle.Observer -import androidx.lifecycle.ViewModelProviders +import androidx.recyclerview.widget.DiffUtil +import androidx.recyclerview.widget.LinearLayoutManager +import androidx.recyclerview.widget.ListAdapter +import androidx.recyclerview.widget.RecyclerView +import at.bitfire.davdroid.DavUtils +import at.bitfire.davdroid.DavUtils.SyncStatus import at.bitfire.davdroid.R +import at.bitfire.davdroid.StorageLowReceiver +import at.bitfire.davdroid.databinding.AccountListBinding +import at.bitfire.davdroid.databinding.AccountListItemBinding import at.bitfire.davdroid.ui.account.AccountActivity -import kotlinx.android.synthetic.main.account_list.* -import kotlinx.android.synthetic.main.account_list_item.view.* +import dagger.hilt.android.AndroidEntryPoint +import java.text.Collator +import javax.inject.Inject -class AccountListFragment: ListFragment() { +@AndroidEntryPoint +class AccountListFragment: Fragment() { - override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View { - listAdapter = AccountListAdapter(requireActivity()) + @Inject lateinit var storageLowReceiver: StorageLowReceiver - val model = ViewModelProviders.of(this).get(Model::class.java) - model.accounts.observe(this, Observer { accounts -> - val adapter = listAdapter as AccountListAdapter - adapter.clear() - adapter.addAll(*accounts) - }) + private var _binding: AccountListBinding? = null + private val binding get() = _binding!! + val model by viewModels() - model.networkAvailable.observe(this, Observer { networkAvailable -> - no_network_info.visibility = if (networkAvailable) View.GONE else View.VISIBLE - }) + override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View { + setHasOptionsMenu(true) - return inflater.inflate(R.layout.account_list, container, false) + _binding = AccountListBinding.inflate(inflater, container, false) + return binding.root } override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) - listView.choiceMode = AbsListView.CHOICE_MODE_SINGLE - listView.onItemClickListener = AdapterView.OnItemClickListener { _, _, position, _ -> - val account = listAdapter!!.getItem(position) as Account - val intent = Intent(activity, AccountActivity::class.java) - intent.putExtra(AccountActivity.EXTRA_ACCOUNT, account) - startActivity(intent) + model.networkAvailable.observe(viewLifecycleOwner) { networkAvailable -> + binding.noNetworkInfo.visibility = if (networkAvailable) View.GONE else View.VISIBLE + } + binding.manageConnections.setOnClickListener { + val intent = Intent(Settings.ACTION_WIRELESS_SETTINGS) + if (intent.resolveActivity(requireActivity().packageManager) != null) + startActivity(intent) + } + + storageLowReceiver.storageLow.observe(viewLifecycleOwner) { storageLow -> + binding.lowStorageInfo.visibility = if (storageLow) View.VISIBLE else View.GONE + } + binding.manageStorage.setOnClickListener { + val intent = Intent(Settings.ACTION_INTERNAL_STORAGE_SETTINGS) + if (intent.resolveActivity(requireActivity().packageManager) != null) + startActivity(intent) + } + + val accountAdapter = AccountAdapter(requireActivity()) + binding.list.apply { + layoutManager = LinearLayoutManager(requireActivity()) + adapter = accountAdapter } + model.accounts.observe(viewLifecycleOwner, { accounts -> + if (accounts.isEmpty()) { + binding.list.visibility = View.GONE + binding.empty.visibility = View.VISIBLE + } else { + binding.list.visibility = View.VISIBLE + binding.empty.visibility = View.GONE + } + accountAdapter.submitList(accounts) + requireActivity().invalidateOptionsMenu() + }) } + override fun onCreateOptionsMenu(menu: Menu, inflater: MenuInflater) = + inflater.inflate(R.menu.activity_accounts, menu) - // list adapter + override fun onPrepareOptionsMenu(menu: Menu) { + // Show "Sync all" only when there is at least one account + model.accounts.value?.let { accounts -> + menu.findItem(R.id.syncAll).setVisible(accounts.isNotEmpty()) + } + } - class AccountListAdapter( - context: Context - ): ArrayAdapter(context, R.layout.account_list_item) { + override fun onDestroyView() { + super.onDestroyView() + _binding = null + } - override fun getView(position: Int, _v: View?, parent: ViewGroup): View { - val account = getItem(position)!! - val v = _v ?: LayoutInflater.from(context).inflate(R.layout.account_list_item, parent, false) - v.account_name.text = account.name + class AccountAdapter( + val activity: Activity + ): ListAdapter( + object: DiffUtil.ItemCallback() { + override fun areItemsTheSame(oldItem: Model.AccountInfo, newItem: Model.AccountInfo) = + oldItem.account == newItem.account + override fun areContentsTheSame(oldItem: Model.AccountInfo, newItem: Model.AccountInfo) = + oldItem == newItem + } + ) { + + class ViewHolder(val binding: AccountListItemBinding): RecyclerView.ViewHolder(binding.root) - return v + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder { + val binding = AccountListItemBinding.inflate(LayoutInflater.from(parent.context), parent, false) + return ViewHolder(binding) + } + + override fun onBindViewHolder(holder: ViewHolder, position: Int) { + val accountInfo = currentList[position] + + holder.binding.root.setOnClickListener { + val intent = Intent(activity, AccountActivity::class.java) + intent.putExtra(AccountActivity.EXTRA_ACCOUNT, accountInfo.account) + activity.startActivity(intent) + } + + when (accountInfo.status) { + SyncStatus.ACTIVE -> { + holder.binding.progress.apply { + alpha = 1.0f + isIndeterminate = true + visibility = View.VISIBLE + } + } + SyncStatus.PENDING -> { + holder.binding.progress.apply { + alpha = 0.4f + isIndeterminate = false + progress = 100 + visibility = View.VISIBLE + } + } + else -> holder.binding.progress.visibility = View.INVISIBLE + } + holder.binding.accountName.text = accountInfo.account.name } } class Model( application: Application - ): AndroidViewModel(application), OnAccountsUpdateListener { + ): AndroidViewModel(application), OnAccountsUpdateListener, SyncStatusObserver { + + data class AccountInfo( + val account: Account, + val status: SyncStatus + ) - val accounts = MutableLiveData>() + val accounts = MutableLiveData>() + val syncAuthorities by lazy { DavUtils.syncAuthorities(getApplication()) } val networkAvailable = MutableLiveData() private var networkCallback: ConnectivityManager.NetworkCallback? = null private var networkReceiver: BroadcastReceiver? = null private val accountManager = AccountManager.get(getApplication())!! - private val connectivityManager = application.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager + private val connectivityManager = application.getSystemService()!! init { + // watch accounts accountManager.addOnAccountsUpdatedListener(this, null, true) + // watch account status + ContentResolver.addStatusChangeListener(ContentResolver.SYNC_OBSERVER_TYPE_ACTIVE or ContentResolver.SYNC_OBSERVER_TYPE_PENDING, this) + + // watch connectivity if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M) { // API level <26 networkReceiver = object: BroadcastReceiver() { init { @@ -161,10 +244,31 @@ class AccountListFragment: ListFragment() { } override fun onAccountsUpdated(newAccounts: Array) { + reloadAccounts() + } + + override fun onStatusChanged(which: Int) { + reloadAccounts() + } + + private fun reloadAccounts() { val context = getApplication() - accounts.postValue( - AccountManager.get(context).getAccountsByType(context.getString(R.string.account_type)) - ) + val collator = Collator.getInstance() + + 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 -> + AccountInfo(account, DavUtils.accountSyncStatus(context, syncAuthorities, account)) + } + accounts.postValue(accountsWithInfo) } } diff --git a/app/src/main/java/at/bitfire/davdroid/ui/AccountsActivity.kt b/app/src/main/java/at/bitfire/davdroid/ui/AccountsActivity.kt index df5a6ef27df53ac8b0a46ad9947a962580aa63c7..a38d1d0ad736994ff8d4d8f4656a2fe3717059c5 100644 --- a/app/src/main/java/at/bitfire/davdroid/ui/AccountsActivity.kt +++ b/app/src/main/java/at/bitfire/davdroid/ui/AccountsActivity.kt @@ -1,118 +1,172 @@ -/* - * Copyright © Ricki Hirner (bitfire web engineering). - * All rights reserved. This program and the accompanying materials - * are made available under the terms of the GNU Public License v3.0 - * which accompanies this distribution, and is available at - * http://www.gnu.org/licenses/gpl.html - */ +/*************************************************************************************************** + * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. + **************************************************************************************************/ package at.bitfire.davdroid.ui import android.accounts.AccountManager +import android.app.Activity import android.content.ContentResolver +import android.content.Context import android.content.Intent import android.content.SyncStatusObserver +import android.content.pm.ShortcutManager +import android.os.Build import android.os.Bundle import android.view.MenuItem +import androidx.activity.viewModels +import androidx.annotation.AnyThread import androidx.appcompat.app.ActionBarDrawerToggle import androidx.appcompat.app.AppCompatActivity +import androidx.core.content.getSystemService import androidx.core.view.GravityCompat +import androidx.lifecycle.MutableLiveData +import androidx.lifecycle.ViewModel +import at.bitfire.davdroid.DavUtils import at.bitfire.davdroid.R -import at.bitfire.davdroid.settings.Settings +import at.bitfire.davdroid.databinding.ActivityAccountsBinding +import at.bitfire.davdroid.ui.intro.IntroActivity import at.bitfire.davdroid.ui.setup.LoginActivity import com.google.android.material.navigation.NavigationView import com.google.android.material.snackbar.Snackbar -import kotlinx.android.synthetic.main.accounts_content.* -import kotlinx.android.synthetic.main.activity_accounts.* -import kotlinx.android.synthetic.main.activity_accounts.view.* +import dagger.hilt.android.AndroidEntryPoint +import dagger.hilt.android.lifecycle.HiltViewModel +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import javax.inject.Inject -class AccountsActivity: AppCompatActivity(), NavigationView.OnNavigationItemSelectedListener, SyncStatusObserver { +@AndroidEntryPoint +class AccountsActivity: AppCompatActivity(), NavigationView.OnNavigationItemSelectedListener { companion object { - val accountsDrawerHandler = DefaultAccountsDrawerHandler() - - const val fragTagStartup = "startup" + const val REQUEST_INTRO = 0 } - private lateinit var settings: Settings + @Inject lateinit var accountsDrawerHandler: AccountsDrawerHandler + + private lateinit var binding: ActivityAccountsBinding + private val model by viewModels() private var syncStatusSnackbar: Snackbar? = null - private var syncStatusObserver: Any? = null override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - settings = Settings.getInstance(this) - - setContentView(R.layout.activity_accounts) - setSupportActionBar(toolbar) - if (supportFragmentManager.findFragmentByTag(fragTagStartup) == null) { - val ft = supportFragmentManager.beginTransaction() - StartupDialogFragment.getStartupDialogs(this).forEach { ft.add(it, fragTagStartup) } - ft.commit() + if (savedInstanceState == null) { + CoroutineScope(Dispatchers.Default).launch { + // use a separate thread to check whether IntroActivity should be shown + if (IntroActivity.shouldShowIntroActivity(this@AccountsActivity)) { + val intro = Intent(this@AccountsActivity, IntroActivity::class.java) + startActivityForResult(intro, REQUEST_INTRO) + } + } } - fab.setOnClickListener { + binding = ActivityAccountsBinding.inflate(layoutInflater) + setContentView(binding.root) + + binding.content.fab.setOnClickListener { startActivity(Intent(this, LoginActivity::class.java)) } - fab.show() + binding.content.fab.show() + + model.showSyncDisabled.observe(this) { syncDisabled -> + if (syncDisabled) { + val snackbar = Snackbar + .make(binding.content.coordinator, R.string.accounts_global_sync_disabled, Snackbar.LENGTH_INDEFINITE) + .setAction(R.string.accounts_global_sync_enable) { + ContentResolver.setMasterSyncAutomatically(true) + } + snackbar.show() + syncStatusSnackbar = snackbar + } else { + syncStatusSnackbar?.let { snackbar -> + snackbar.dismiss() + syncStatusSnackbar = null + } + } + } + + setSupportActionBar(binding.content.toolbar) - accountsDrawerHandler.initMenu(this, drawer_layout.nav_view.menu) val toggle = ActionBarDrawerToggle( - this, drawer_layout, toolbar, R.string.navigation_drawer_open, R.string.navigation_drawer_close) - drawer_layout.addDrawerListener(toggle) + this, binding.drawerLayout, binding.content.toolbar, R.string.navigation_drawer_open, R.string.navigation_drawer_close) + binding.drawerLayout.addDrawerListener(toggle) toggle.syncState() - nav_view.setNavigationItemSelectedListener(this) - nav_view.itemIconTintList = null + binding.navView.setNavigationItemSelectedListener(this) + binding.navView.itemIconTintList = null + + // handle "Sync all" intent from launcher shortcut + if (savedInstanceState == null && intent.action == Intent.ACTION_SYNC) + syncAllAccounts() } override fun onResume() { super.onResume() - - onStatusChanged(ContentResolver.SYNC_OBSERVER_TYPE_SETTINGS) - syncStatusObserver = ContentResolver.addStatusChangeListener(ContentResolver.SYNC_OBSERVER_TYPE_SETTINGS, this) - } - - override fun onPause() { - super.onPause() - - syncStatusObserver?.let { - ContentResolver.removeStatusChangeListener(it) - syncStatusObserver = null - } + accountsDrawerHandler.initMenu(this, binding.navView.menu) } - override fun onStatusChanged(which: Int) { - syncStatusSnackbar?.let { - it.dismiss() - syncStatusSnackbar = null - } - if (!ContentResolver.getMasterSyncAutomatically()) { - val snackbar = Snackbar - .make(findViewById(R.id.coordinator), R.string.accounts_global_sync_disabled, Snackbar.LENGTH_INDEFINITE) - .setAction(R.string.accounts_global_sync_enable) { - ContentResolver.setMasterSyncAutomatically(true) - } - syncStatusSnackbar = snackbar - snackbar.show() - } + override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) { + if (requestCode == REQUEST_INTRO && resultCode == Activity.RESULT_CANCELED) + finish() + else + super.onActivityResult(requestCode, resultCode, data) } - override fun onBackPressed() { - if (drawer_layout.isDrawerOpen(GravityCompat.START)) - drawer_layout.closeDrawer(GravityCompat.START) + if (binding.drawerLayout.isDrawerOpen(GravityCompat.START)) + binding.drawerLayout.closeDrawer(GravityCompat.START) else super.onBackPressed() } override fun onNavigationItemSelected(item: MenuItem): Boolean { - val processed = accountsDrawerHandler.onNavigationItemSelected(this, item) - drawer_layout.closeDrawer(GravityCompat.START) - return processed + accountsDrawerHandler.onNavigationItemSelected(this, item) + binding.drawerLayout.closeDrawer(GravityCompat.START) + return true + } + + + private fun allAccounts() = + AccountManager.get(this).getAccountsByType(getString(R.string.account_type)) + + fun syncAllAccounts(item: MenuItem? = null) { + if (Build.VERSION.SDK_INT >= 25) + getSystemService()?.reportShortcutUsed(UiUtils.SHORTCUT_SYNC_ALL) + + val accounts = allAccounts() + for (account in accounts) + DavUtils.requestSync(this, account) + } + + + @HiltViewModel + class Model @Inject constructor( + @ApplicationContext val context: Context + ): ViewModel(), SyncStatusObserver { + + private var syncStatusObserver: Any? = null + val showSyncDisabled = MutableLiveData(false) + + init { + syncStatusObserver = ContentResolver.addStatusChangeListener(ContentResolver.SYNC_OBSERVER_TYPE_SETTINGS, this) + onStatusChanged(ContentResolver.SYNC_OBSERVER_TYPE_SETTINGS) + } + + override fun onCleared() { + ContentResolver.removeStatusChangeListener(syncStatusObserver) + } + + @AnyThread + override fun onStatusChanged(which: Int) { + showSyncDisabled.postValue(!ContentResolver.getMasterSyncAutomatically()) + } + } -} +} \ No newline at end of file diff --git a/app/src/main/java/at/bitfire/davdroid/ui/AccountsDrawerHandler.kt b/app/src/main/java/at/bitfire/davdroid/ui/AccountsDrawerHandler.kt new file mode 100644 index 0000000000000000000000000000000000000000..524b8aa4ab9f08f68d6e9619434a01202f8823d7 --- /dev/null +++ b/app/src/main/java/at/bitfire/davdroid/ui/AccountsDrawerHandler.kt @@ -0,0 +1,18 @@ +/*************************************************************************************************** + * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. + **************************************************************************************************/ + +package at.bitfire.davdroid.ui + +import android.app.Activity +import android.content.Context +import android.view.Menu +import android.view.MenuItem + +interface AccountsDrawerHandler { + + fun initMenu(context: Context, menu: Menu) + + fun onNavigationItemSelected(activity: Activity, item: MenuItem) + +} \ No newline at end of file diff --git a/app/src/main/java/at/bitfire/davdroid/ui/AppSettingsActivity.kt b/app/src/main/java/at/bitfire/davdroid/ui/AppSettingsActivity.kt index 0ca133449e6411b8cab680497fb81ee4f7aade9e..78cdb1dda07cbb07ec4167f4b4aaebde78381923 100644 --- a/app/src/main/java/at/bitfire/davdroid/ui/AppSettingsActivity.kt +++ b/app/src/main/java/at/bitfire/davdroid/ui/AppSettingsActivity.kt @@ -1,35 +1,48 @@ -/* - * Copyright © Ricki Hirner (bitfire web engineering). - * All rights reserved. This program and the accompanying materials - * are made available under the terms of the GNU Public License v3.0 - * which accompanies this distribution, and is available at - * http://www.gnu.org/licenses/gpl.html - */ +/*************************************************************************************************** + * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. + **************************************************************************************************/ package at.bitfire.davdroid.ui import android.content.Intent +import android.graphics.drawable.InsetDrawable +import android.net.Uri import android.os.Build import android.os.Bundle +import android.os.PowerManager +import android.text.InputType +import androidx.activity.result.contract.ActivityResultContracts +import androidx.annotation.UiThread import androidx.appcompat.app.AppCompatActivity -import androidx.preference.EditTextPreference -import androidx.preference.Preference -import androidx.preference.PreferenceFragmentCompat -import androidx.preference.SwitchPreferenceCompat +import androidx.appcompat.app.AppCompatDelegate +import androidx.preference.* import at.bitfire.cert4android.CustomCertManager import at.bitfire.davdroid.BuildConfig +import at.bitfire.davdroid.ForegroundService import at.bitfire.davdroid.R +import at.bitfire.davdroid.resource.TaskUtils import at.bitfire.davdroid.settings.Settings +import at.bitfire.davdroid.settings.SettingsManager +import at.bitfire.davdroid.ui.intro.BatteryOptimizationsFragment +import at.bitfire.davdroid.ui.intro.OpenSourceFragment import com.google.android.material.snackbar.Snackbar +import dagger.hilt.android.AndroidEntryPoint +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch import java.net.URI import java.net.URISyntaxException +import javax.inject.Inject +import kotlin.math.roundToInt +@AndroidEntryPoint class AppSettingsActivity: AppCompatActivity() { companion object { const val EXTRA_SCROLL_TO = "scrollTo" } + override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) @@ -43,11 +56,18 @@ class AppSettingsActivity: AppCompatActivity() { } - class SettingsFragment: PreferenceFragmentCompat(), Settings.OnChangeListener { + @AndroidEntryPoint + class SettingsFragment: PreferenceFragmentCompat(), SettingsManager.OnChangeListener { + + @Inject lateinit var settings: SettingsManager + + val onBatteryOptimizationResult = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { + loadSettings() + } + override fun onCreatePreferences(bundle: Bundle?, s: String?) { addPreferencesFromResource(R.xml.settings_app) - loadSettings() // UI settings findPreference("notification_settings")!!.apply { @@ -67,65 +87,121 @@ class AppSettingsActivity: AppCompatActivity() { } // security settings - findPreference(Settings.DISTRUST_SYSTEM_CERTIFICATES)!!.apply { - isVisible = BuildConfig.customCerts - isEnabled = true - } findPreference("reset_certificates")!!.apply { - isVisible = BuildConfig.customCerts - isEnabled = true onPreferenceClickListener = Preference.OnPreferenceClickListener { resetCertificates() false } } + findPreference(Settings.PROXY_HOST)!!.apply { + this.setOnBindEditTextListener { + it.inputType = InputType.TYPE_TEXT_VARIATION_URI + } + } + + findPreference(Settings.PROXY_PORT)!!.apply { + this.setOnBindEditTextListener { + it.inputType = InputType.TYPE_CLASS_NUMBER + } + } + arguments?.getString(EXTRA_SCROLL_TO)?.let { key -> scrollToPreference(key) } } + override fun onStart() { + super.onStart() + settings.addOnChangeListener(this) + loadSettings() + } + + override fun onStop() { + super.onStop() + settings.removeOnChangeListener(this) + } + + @UiThread private fun loadSettings() { - val settings = Settings.getInstance(requireActivity()) - + // debug settings + findPreference(Settings.BATTERY_OPTIMIZATION)!!.apply { + // battery optimization exists since Android 6 (API level 23) + if (Build.VERSION.SDK_INT >= 23) { + val powerManager = requireActivity().getSystemService(PowerManager::class.java) + val whitelisted = powerManager.isIgnoringBatteryOptimizations(BuildConfig.APPLICATION_ID) + isChecked = whitelisted + isEnabled = !whitelisted + onPreferenceChangeListener = Preference.OnPreferenceChangeListener { _, nowChecked -> + if (nowChecked as Boolean) + onBatteryOptimizationResult.launch(Intent( + android.provider.Settings.ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS, + Uri.parse("package:" + BuildConfig.APPLICATION_ID) + )) + false + } + } else + isVisible = false + } + + findPreference(Settings.FOREGROUND_SERVICE)!!.apply { + isChecked = settings.getBooleanOrNull(Settings.FOREGROUND_SERVICE) == true + isEnabled = settings.getBooleanOrNull(Settings.BATTERY_OPTIMIZATION) == true + onPreferenceChangeListener = Preference.OnPreferenceChangeListener { _, newValue -> + settings.putBoolean(Settings.FOREGROUND_SERVICE, newValue as Boolean) + requireActivity().startService(Intent(ForegroundService.ACTION_FOREGROUND, null, requireActivity(), ForegroundService::class.java)) + false + } + } + // connection settings - findPreference(Settings.OVERRIDE_PROXY)!!.apply { - isChecked = settings.getBoolean(Settings.OVERRIDE_PROXY) ?: Settings.OVERRIDE_PROXY_DEFAULT - isEnabled = settings.isWritable(Settings.OVERRIDE_PROXY) + val proxyType = settings.getInt(Settings.PROXY_TYPE) + findPreference(Settings.PROXY_TYPE)!!.apply { + setValueIndex(entryValues.indexOf(proxyType.toString())) + summary = entry + onPreferenceChangeListener = Preference.OnPreferenceChangeListener { _, newValue -> + val proxyType = (newValue as String).toInt() + settings.putInt(Settings.PROXY_TYPE, proxyType) + false + } } - findPreference(Settings.OVERRIDE_PROXY_HOST)!!.apply { - isEnabled = settings.isWritable(Settings.OVERRIDE_PROXY_HOST) - val proxyHost = settings.getString(Settings.OVERRIDE_PROXY_HOST) ?: Settings.OVERRIDE_PROXY_HOST_DEFAULT + findPreference(Settings.PROXY_HOST)!!.apply { + isVisible = proxyType != Settings.PROXY_TYPE_SYSTEM && proxyType != Settings.PROXY_TYPE_NONE + isEnabled = settings.isWritable(Settings.PROXY_HOST) + + val proxyHost = settings.getString(Settings.PROXY_HOST) text = proxyHost summary = proxyHost onPreferenceChangeListener = Preference.OnPreferenceChangeListener { _, newValue -> val host = newValue as String try { URI(null, host, null, null) - settings.putString(Settings.OVERRIDE_PROXY_HOST, host) + settings.putString(Settings.PROXY_HOST, host) summary = host - true + false } catch(e: URISyntaxException) { - Snackbar.make(view!!, e.localizedMessage, Snackbar.LENGTH_LONG).show() + Snackbar.make(requireView(), e.reason, Snackbar.LENGTH_LONG).show() false } } } - findPreference(Settings.OVERRIDE_PROXY_PORT)!!.apply { - isEnabled = settings.isWritable(Settings.OVERRIDE_PROXY_PORT) - val proxyPort = settings.getInt(Settings.OVERRIDE_PROXY_PORT) ?: Settings.OVERRIDE_PROXY_PORT_DEFAULT + findPreference(Settings.PROXY_PORT)!!.apply { + isVisible = proxyType != Settings.PROXY_TYPE_SYSTEM && proxyType != Settings.PROXY_TYPE_NONE + isEnabled = settings.isWritable(Settings.PROXY_PORT) + + val proxyPort = settings.getInt(Settings.PROXY_PORT) text = proxyPort.toString() summary = proxyPort.toString() onPreferenceChangeListener = Preference.OnPreferenceChangeListener { _, newValue -> try { - val port = Integer.parseInt(newValue as String) + val port = (newValue as String).toInt() if (port in 1..65535) { - settings.putInt(Settings.OVERRIDE_PROXY_PORT, port) + settings.putInt(Settings.PROXY_PORT, port) text = port.toString() summary = port.toString() - true + false } else false } catch(e: NumberFormatException) { @@ -136,25 +212,64 @@ class AppSettingsActivity: AppCompatActivity() { // security settings findPreference(Settings.DISTRUST_SYSTEM_CERTIFICATES)!! - .isChecked = settings.getBoolean(Settings.DISTRUST_SYSTEM_CERTIFICATES) ?: Settings.DISTRUST_SYSTEM_CERTIFICATES_DEFAULT + .isChecked = settings.getBoolean(Settings.DISTRUST_SYSTEM_CERTIFICATES) + + // user interface settings + findPreference(Settings.PREFERRED_THEME)!!.apply { + val mode = settings.getIntOrNull(Settings.PREFERRED_THEME) ?: Settings.PREFERRED_THEME_DEFAULT + setValueIndex(entryValues.indexOf(mode.toString())) + summary = entry + + onPreferenceChangeListener = Preference.OnPreferenceChangeListener { _, newValue -> + val newMode = (newValue as String).toInt() + AppCompatDelegate.setDefaultNightMode(newMode) + settings.putInt(Settings.PREFERRED_THEME, newMode) + false + } + } + + // integration settings + findPreference(Settings.PREFERRED_TASKS_PROVIDER)!!.apply { + val pm = requireActivity().packageManager + val taskProvider = TaskUtils.currentProvider(requireActivity()) + if (taskProvider != null) { + val tasksAppInfo = pm.getApplicationInfo(taskProvider.packageName, 0) + val inset = (24*resources.displayMetrics.density).roundToInt() // 24dp + icon = InsetDrawable( + tasksAppInfo.loadIcon(pm), + 0, inset, inset, inset + ) + summary = getString(R.string.app_settings_tasks_provider_synchronizing_with, tasksAppInfo.loadLabel(pm)) + } else { + setIcon(R.drawable.ic_playlist_add_check) + setSummary(R.string.app_settings_tasks_provider_none) + } + setOnPreferenceClickListener { + startActivity(Intent(requireActivity(), TasksActivity::class.java)) + false + } + } } override fun onSettingsChanged() { - loadSettings() + // loadSettings must run in UI thread + CoroutineScope(Dispatchers.Main).launch { + if (isAdded) + loadSettings() + } } - private fun resetHints() { - val settings = Settings.getInstance(requireActivity()) - settings.remove(StartupDialogFragment.HINT_AUTOSTART_PERMISSIONS) - settings.remove(StartupDialogFragment.HINT_BATTERY_OPTIMIZATIONS) - settings.remove(StartupDialogFragment.HINT_OPENTASKS_NOT_INSTALLED) - Snackbar.make(view!!, R.string.app_settings_reset_hints_success, Snackbar.LENGTH_LONG).show() + settings.remove(BatteryOptimizationsFragment.Model.HINT_BATTERY_OPTIMIZATIONS) + settings.remove(BatteryOptimizationsFragment.Model.HINT_AUTOSTART_PERMISSION) + settings.remove(TasksFragment.Model.HINT_OPENTASKS_NOT_INSTALLED) + settings.remove(OpenSourceFragment.Model.SETTING_NEXT_DONATION_POPUP) + Snackbar.make(requireView(), R.string.app_settings_reset_hints_success, Snackbar.LENGTH_LONG).show() } private fun resetCertificates() { - if (CustomCertManager.resetCertificates(activity!!)) - Snackbar.make(view!!, getString(R.string.app_settings_reset_certificates_success), Snackbar.LENGTH_LONG).show() + if (CustomCertManager.resetCertificates(requireActivity())) + Snackbar.make(requireView(), getString(R.string.app_settings_reset_certificates_success), Snackbar.LENGTH_LONG).show() } } diff --git a/app/src/main/java/at/bitfire/davdroid/ui/BaseAccountsDrawerHandler.kt b/app/src/main/java/at/bitfire/davdroid/ui/BaseAccountsDrawerHandler.kt new file mode 100644 index 0000000000000000000000000000000000000000..151453809893ada9c7fdbc9dcd7a3f4cae1b9dd1 --- /dev/null +++ b/app/src/main/java/at/bitfire/davdroid/ui/BaseAccountsDrawerHandler.kt @@ -0,0 +1,35 @@ +/*************************************************************************************************** + * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. + **************************************************************************************************/ + +package at.bitfire.davdroid.ui + +import android.app.Activity +import android.content.Context +import android.content.Intent +import android.view.Menu +import android.view.MenuItem +import androidx.annotation.CallSuper +import at.bitfire.davdroid.R + +/** + * Default menu items control + */ +abstract class BaseAccountsDrawerHandler: AccountsDrawerHandler { + + @CallSuper + override fun initMenu(context: Context, menu: Menu) { + // TODO Provide option for beta feedback + } + + @CallSuper + override fun onNavigationItemSelected(activity: Activity, item: MenuItem) { + when (item.itemId) { + R.id.nav_about -> + activity.startActivity(Intent(activity, AboutActivity::class.java)) + R.id.nav_app_settings -> + activity.startActivity(Intent(activity, AppSettingsActivity::class.java)) + } + } + +} \ No newline at end of file diff --git a/app/src/main/java/at/bitfire/davdroid/ui/CreateAddressBookActivity.kt b/app/src/main/java/at/bitfire/davdroid/ui/CreateAddressBookActivity.kt deleted file mode 100644 index b77ec7c51b602671ac49ffcaca16fc8cf685406c..0000000000000000000000000000000000000000 --- a/app/src/main/java/at/bitfire/davdroid/ui/CreateAddressBookActivity.kt +++ /dev/null @@ -1,141 +0,0 @@ -/* - * Copyright © Ricki Hirner (bitfire web engineering). - * All rights reserved. This program and the accompanying materials - * are made available under the terms of the GNU Public License v3.0 - * which accompanies this distribution, and is available at - * http://www.gnu.org/licenses/gpl.html - */ - -package at.bitfire.davdroid.ui - -import android.accounts.Account -import android.app.Application -import android.content.Intent -import android.os.Bundle -import android.view.Menu -import android.view.MenuItem -import androidx.annotation.MainThread -import androidx.appcompat.app.AppCompatActivity -import androidx.core.app.NavUtils -import androidx.databinding.DataBindingUtil -import androidx.lifecycle.AndroidViewModel -import androidx.lifecycle.MutableLiveData -import androidx.lifecycle.ViewModelProviders -import at.bitfire.davdroid.R -import at.bitfire.davdroid.databinding.ActivityCreateAddressBookBinding -import at.bitfire.davdroid.model.AppDatabase -import at.bitfire.davdroid.model.Collection -import at.bitfire.davdroid.model.Service -import at.bitfire.davdroid.ui.account.AccountActivity -import org.apache.commons.lang3.StringUtils -import java.util.* -import kotlin.concurrent.thread - -class CreateAddressBookActivity: AppCompatActivity() { - - companion object { - const val EXTRA_ACCOUNT = "account" - } - - private lateinit var model: Model - - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - supportActionBar?.setDisplayHomeAsUpEnabled(true) - - model = ViewModelProviders.of(this).get(Model::class.java) - (intent?.getParcelableExtra(EXTRA_ACCOUNT) as? Account)?.let { - model.initialize(it) - } - - val binding = DataBindingUtil.setContentView(this, R.layout.activity_create_address_book) - binding.lifecycleOwner = this - binding.model = model - } - - override fun onCreateOptionsMenu(menu: Menu): Boolean { - menuInflater.inflate(R.menu.activity_create_collection, menu) - return true - } - - override fun onOptionsItemSelected(item: MenuItem) = - if (item.itemId == android.R.id.home) { - val intent = Intent(this, AccountActivity::class.java) - intent.putExtra(AccountActivity.EXTRA_ACCOUNT, model.account) - NavUtils.navigateUpTo(this, intent) - true - } else - false - - fun onCreateCollection(item: MenuItem) { - var ok = true - - val args = Bundle() - args.putString(CreateCollectionFragment.ARG_SERVICE_TYPE, Service.TYPE_CARDDAV) - - val parent = model.homeSets.value?.getItem(model.idxHomeSet.value!!) ?: return - args.putString(CreateCollectionFragment.ARG_URL, parent.url.resolve(UUID.randomUUID().toString() + "/").toString()) - - val displayName = model.displayName.value - if (displayName.isNullOrBlank()) { - model.displayNameError.value = getString(R.string.create_collection_display_name_required) - ok = false - } else { - args.putString(CreateCollectionFragment.ARG_DISPLAY_NAME, displayName) - model.displayNameError.value = null - } - - StringUtils.trimToNull(model.description.value)?.let { - args.putString(CreateCollectionFragment.ARG_DESCRIPTION, it) - } - - if (ok) { - args.putParcelable(CreateCollectionFragment.ARG_ACCOUNT, model.account) - args.putString(CreateCollectionFragment.ARG_TYPE, Collection.TYPE_ADDRESSBOOK) - val frag = CreateCollectionFragment() - frag.arguments = args - frag.show(supportFragmentManager, null) - } - } - - - class Model( - application: Application - ) : AndroidViewModel(application) { - - var account: Account? = null - - val displayName = MutableLiveData() - val displayNameError = MutableLiveData() - - val description = MutableLiveData() - - val homeSets = MutableLiveData() - val idxHomeSet = MutableLiveData() - - @MainThread - fun initialize(account: Account) { - if (this.account != null) - return - this.account = account - - thread { - // load account info - val adapter = HomeSetAdapter(getApplication()) - - val db = AppDatabase.getInstance(getApplication()) - db.serviceDao().getByAccountAndType(account.name, Service.TYPE_CARDDAV)?.let { service -> - val homeSets = db.homeSetDao().getBindableByService(service.id) - adapter.addAll(homeSets) - } - - if (!adapter.isEmpty) { - homeSets.postValue(adapter) - idxHomeSet.postValue(0) - } - } - } - - } - -} diff --git a/app/src/main/java/at/bitfire/davdroid/ui/DebugInfoActivity.kt b/app/src/main/java/at/bitfire/davdroid/ui/DebugInfoActivity.kt index 8479aac66607904e204ee06fbcec8b1ffcfc36cc..e87dec97047c6ee138575babe5ecd9abdd0787c4 100644 --- a/app/src/main/java/at/bitfire/davdroid/ui/DebugInfoActivity.kt +++ b/app/src/main/java/at/bitfire/davdroid/ui/DebugInfoActivity.kt @@ -1,352 +1,695 @@ -/* - * Copyright © Ricki Hirner (bitfire web engineering). - * All rights reserved. This program and the accompanying materials - * are made available under the terms of the GNU Public License v3.0 - * which accompanies this distribution, and is available at - * http://www.gnu.org/licenses/gpl.html - */ +/*************************************************************************************************** + * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. + **************************************************************************************************/ package at.bitfire.davdroid.ui -import android.Manifest import android.accounts.Account import android.accounts.AccountManager -import android.app.Application -import android.content.ContentResolver -import android.content.ContentUris -import android.content.Context -import android.content.Intent +import android.app.usage.UsageStatsManager +import android.content.* import android.content.pm.ApplicationInfo import android.content.pm.PackageManager import android.net.ConnectivityManager -import android.os.Build -import android.os.Bundle -import android.os.PowerManager +import android.net.Uri +import android.os.* import android.provider.CalendarContract import android.provider.ContactsContract -import android.util.Log -import android.view.Menu -import android.view.MenuItem +import android.view.View +import android.widget.Toast +import androidx.activity.viewModels +import androidx.annotation.UiThread import androidx.appcompat.app.AppCompatActivity import androidx.core.app.NotificationManagerCompat import androidx.core.app.ShareCompat import androidx.core.content.ContextCompat import androidx.core.content.FileProvider +import androidx.core.content.getSystemService import androidx.core.content.pm.PackageInfoCompat import androidx.databinding.DataBindingUtil -import androidx.lifecycle.AndroidViewModel import androidx.lifecycle.MutableLiveData -import androidx.lifecycle.ViewModelProviders +import androidx.lifecycle.Observer +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import at.bitfire.dav4jvm.exception.DavException import at.bitfire.dav4jvm.exception.HttpException import at.bitfire.davdroid.BuildConfig import at.bitfire.davdroid.InvalidAccountException import at.bitfire.davdroid.R +import at.bitfire.davdroid.TextTable import at.bitfire.davdroid.databinding.ActivityDebugInfoBinding +import at.bitfire.davdroid.db.AppDatabase import at.bitfire.davdroid.log.Logger -import at.bitfire.davdroid.model.AppDatabase import at.bitfire.davdroid.resource.LocalAddressBook import at.bitfire.davdroid.settings.AccountSettings -import at.bitfire.ical4android.TaskProvider +import at.bitfire.davdroid.settings.SettingsManager +import at.bitfire.ical4android.MiscUtils.ContentProviderClientHelper.closeCompat +import at.bitfire.ical4android.TaskProvider.ProviderName +import at.techbee.jtx.JtxContract +import dagger.hilt.android.AndroidEntryPoint +import dagger.hilt.android.lifecycle.HiltViewModel +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import okhttp3.HttpUrl +import org.apache.commons.io.ByteOrderMark +import org.apache.commons.io.FileUtils +import org.apache.commons.io.IOUtils +import org.apache.commons.lang3.StringUtils import org.apache.commons.lang3.exception.ExceptionUtils import org.dmfs.tasks.contract.TaskContract -import java.io.File -import java.io.FileWriter -import java.io.IOException +import java.io.* +import java.util.* import java.util.logging.Level -import kotlin.concurrent.thread +import java.util.zip.ZipEntry +import java.util.zip.ZipOutputStream +import javax.inject.Inject +@AndroidEntryPoint class DebugInfoActivity: AppCompatActivity() { companion object { - const val KEY_THROWABLE = "throwable" - const val KEY_LOGS = "logs" - const val KEY_ACCOUNT = "account" - const val KEY_AUTHORITY = "authority" - const val KEY_LOCAL_RESOURCE = "localResource" - const val KEY_REMOTE_RESOURCE = "remoteResource" + /** [android.accounts.Account] (as [android.os.Parcelable]) related to problem */ + private const val EXTRA_ACCOUNT = "account" + + /** sync authority name related to problem */ + private const val EXTRA_AUTHORITY = "authority" + + /** serialized [Throwable] that causes the problem */ + private const val EXTRA_CAUSE = "cause" + + /** dump of local resource related to the problem (plain-text [String]) */ + private const val EXTRA_LOCAL_RESOURCE = "localResource" + + /** logs related to the problem (path to log file, plain-text [String]) */ + private const val EXTRA_LOG_FILE = "logFile" + + /** logs related to the problem (plain-text [String]) */ + private const val EXTRA_LOGS = "logs" + + /** URL of remote resource related to the problem (plain-text [String]) */ + private const val EXTRA_REMOTE_RESOURCE = "remoteResource" + + const val FILE_DEBUG_INFO = "debug-info.txt" + const val FILE_LOGS = "logs.txt" } - private lateinit var model: ReportModel + private val model by viewModels() + override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - model = ViewModelProviders.of(this).get(ReportModel::class.java) - model.initialize(intent.extras) + model.generate(intent.extras) val binding = DataBindingUtil.setContentView(this, R.layout.activity_debug_info) binding.model = model binding.lifecycleOwner = this - } - override fun onCreateOptionsMenu(menu: Menu): Boolean { - menuInflater.inflate(R.menu.activity_debug_info, menu) - return true - } + model.cause.observe(this, Observer { cause -> + if (cause == null) + return@Observer + binding.causeCaption.text = when (cause) { + is HttpException -> getString(if (cause.code / 100 == 5) R.string.debug_info_server_error else R.string.debug_info_http_error) + is DavException -> getString(R.string.debug_info_webdav_error) + is IOException, is IOError -> getString(R.string.debug_info_io_error) + else -> cause::class.java.simpleName + } - fun onShare(item: MenuItem) { - model.report.value?.let { report -> - val builder = ShareCompat.IntentBuilder.from(this) - .setSubject("${getString(R.string.app_name)} ${BuildConfig.VERSION_NAME} debug info") - .setText(getString(R.string.debug_info_logs_attached)) - .setType("text/plain") + binding.causeText.text = getString( + if (cause is HttpException) + when { + cause.code == 403 -> R.string.debug_info_http_403_description + cause.code == 404 -> R.string.debug_info_http_404_description + cause.code/100 == 5 -> R.string.debug_info_http_5xx_description + else -> R.string.debug_info_unexpected_error + } + else + R.string.debug_info_unexpected_error + ) + }) + + model.debugInfo.observe(this, Observer { debugInfo -> + val showDebugInfo = View.OnClickListener { + val uri = FileProvider.getUriForFile(this, getString(R.string.authority_debug_provider), debugInfo) + val intent = Intent(Intent.ACTION_VIEW) + intent.setDataAndType(uri, "text/plain") + intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) + startActivity(Intent.createChooser(intent, null)) + } + binding.causeView.setOnClickListener(showDebugInfo) + binding.debugInfoView.setOnClickListener(showDebugInfo) - try { - val debugInfoDir = File(filesDir, "debug") - if (!(debugInfoDir.exists() && debugInfoDir.isDirectory) && !debugInfoDir.mkdir()) - throw IOException("Couldn't create debug directory") - - val reportFile = File(debugInfoDir, "davx5-info.txt") - Logger.log.fine("Writing debug info to ${reportFile.absolutePath}") - val writer = FileWriter(reportFile) - writer.write(report) - writer.close() - - builder.setStream(FileProvider.getUriForFile(this, getString(R.string.authority_debug_provider), reportFile)) - builder.intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_ACTIVITY_NEW_TASK) - } catch(e: IOException) { - // creating an attachment failed, so send it inline - val text = "Couldn't create debug info file: " + Log.getStackTraceString(e) + "\n\n$report" - builder.setText(text) + binding.fab.apply { + setOnClickListener { shareArchive() } + isEnabled = true } + binding.zipShare.setOnClickListener { shareArchive() } + }) + + model.logFile.observe(this, Observer { logs -> + binding.logsView.setOnClickListener { + val uri = FileProvider.getUriForFile(this, getString(R.string.authority_debug_provider), logs) + val intent = Intent(Intent.ACTION_VIEW) + intent.setDataAndType(uri, "text/plain") + intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) + startActivity(Intent.createChooser(intent, null)) + } + }) + } + fun shareArchive() { + model.generateZip { zipFile -> + val builder = ShareCompat.IntentBuilder.from(this) + .setSubject("${getString(R.string.app_name)} ${BuildConfig.VERSION_NAME} debug info") + .setText(getString(R.string.debug_info_attached)) + .setType("*/*") // application/zip won't show all apps that can manage binary files, like ShareViaHttp + .setStream(FileProvider.getUriForFile(this, getString(R.string.authority_debug_provider), zipFile)) + builder.intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_ACTIVITY_NEW_TASK) builder.startChooser() } } - class ReportModel(application: Application): AndroidViewModel(application) { + @HiltViewModel + class ReportModel @Inject constructor( + @ApplicationContext val context: Context + ): ViewModel() { + + @Inject lateinit var db: AppDatabase + @Inject lateinit var settings: SettingsManager private var initialized = false - val report = MutableLiveData() - fun initialize(extras: Bundle?) { + val cause = MutableLiveData() + var logFile = MutableLiveData() + val localResource = MutableLiveData() + val remoteResource = MutableLiveData() + val debugInfo = MutableLiveData() + + val zipProgress = MutableLiveData(false) + val zipFile = MutableLiveData() + + // private storage, not readable by others + private val debugInfoDir = File(context.filesDir, "debug") + + init { + // create debug info directory + if (!debugInfoDir.isDirectory && !debugInfoDir.mkdir()) + throw IOException("Couldn't create debug info directory") + } + + @UiThread + fun generate(extras: Bundle?) { if (initialized) return - - Logger.log.info("Generating debug info report") initialized = true - thread { - val context = getApplication() - val text = StringBuilder("--- BEGIN DEBUG INFO ---\n") + viewModelScope.launch(Dispatchers.Default) { + val logFileName = extras?.getString(EXTRA_LOG_FILE) + val logsText = extras?.getString(EXTRA_LOGS) + if (logFileName != null) { + val file = File(logFileName) + if (file.isFile && file.canRead()) + logFile.postValue(file) + else + Logger.log.warning("Can't read logs from $logFileName") + } else if (logsText != null) { + val file = File(debugInfoDir, FILE_LOGS) + if (!file.exists() || file.canWrite()) { + file.writer().buffered().use { writer -> + IOUtils.copy(StringReader(logsText), writer) + } + logFile.postValue(file) + } else + Logger.log.warning("Can't write logs to $file") + } + + val throwable = extras?.getSerializable(EXTRA_CAUSE) as? Throwable + cause.postValue(throwable) + + val local = extras?.getString(EXTRA_LOCAL_RESOURCE) + localResource.postValue(local) + + val remote = extras?.getString(EXTRA_REMOTE_RESOURCE) + remoteResource.postValue(remote) + + generateDebugInfo( + extras?.getParcelable(EXTRA_ACCOUNT), + extras?.getString(EXTRA_AUTHORITY), + throwable, + local, + remote + ) + } + } + + private fun generateDebugInfo(syncAccount: Account?, syncAuthority: String?, cause: Throwable?, localResource: String?, remoteResource: String?) { + val debugInfoFile = File(debugInfoDir, FILE_DEBUG_INFO) + debugInfoFile.writer().buffered().use { writer -> + writer.append(ByteOrderMark.UTF_BOM) + writer.append("--- BEGIN DEBUG INFO ---\n\n") // begin with most specific information - extras?.getParcelable(KEY_ACCOUNT)?.let { - text.append("SYNCHRONIZATION INFO\nAccount name: ${it.name}\n") + if (syncAccount != null || syncAuthority != null) { + writer.append("SYNCHRONIZATION INFO\n") + if (syncAccount != null) + writer.append("Account: $syncAccount\n") + if (syncAuthority != null) + writer.append("Authority: $syncAuthority\n") + writer.append("\n") } - extras?.getString(KEY_AUTHORITY)?.let { - text.append("Authority: $it\n") + + cause?.let { + // Log.getStackTraceString(e) returns "" in case of UnknownHostException + writer.append("EXCEPTION\n${ExceptionUtils.getStackTrace(cause)}\n") } // exception details - val throwable = extras?.getSerializable(KEY_THROWABLE) as Throwable? - if (throwable is HttpException) { - throwable.request?.let { request -> - text.append("\nHTTP REQUEST:\n$request\n") - throwable.requestBody?.let { text.append(it) } - text.append("\n\n") + if (cause is DavException) { + cause.request?.let { request -> + writer.append("HTTP REQUEST\n$request\n") + cause.requestBody?.let { writer.append(it) } + writer.append("\n\n") } - throwable.response?.let { response -> - text.append("HTTP RESPONSE:\n$response\n") - throwable.responseBody?.let { text.append(it) } - text.append("\n\n") + cause.response?.let { response -> + writer.append("HTTP RESPONSE\n$response\n") + cause.responseBody?.let { writer.append(it) } + writer.append("\n\n") } } - extras?.getString(KEY_LOCAL_RESOURCE)?.let { - text.append("\nLOCAL RESOURCE:\n$it\n") - } - extras?.getString(KEY_REMOTE_RESOURCE)?.let { - text.append("\nREMOTE RESOURCE:\n$it\n") - } + if (localResource != null) + writer.append("LOCAL RESOURCE\n$localResource\n\n") - throwable?.let { - // Log.getStackTraceString(e) returns "" in case of UnknownHostException - text.append("\nEXCEPTION:\n${ExceptionUtils.getStackTrace(throwable)}") - } - - // logs (for instance, from failed resource detection) - extras?.getString(KEY_LOGS)?.let { - text.append("\nLOGS:\n$it\n") - } + if (remoteResource != null) + writer.append("REMOTE RESOURCE\n$remoteResource\n\n") - // software information + // software info try { - text.append("\nSOFTWARE INFORMATION\n") + writer.append("SOFTWARE INFORMATION\n") + val table = TextTable("Package", "Version", "Code", "Installer", "Notes") val pm = context.packageManager - val appIDs = mutableSetOf( // we always want info about these packages - BuildConfig.APPLICATION_ID, // DAVx5 - "${BuildConfig.APPLICATION_ID}.jbworkaround", // DAVdroid JB Workaround - "org.dmfs.tasks" // OpenTasks + + val packageNames = mutableSetOf( // we always want info about these packages: + BuildConfig.APPLICATION_ID, // DAVx5 + ProviderName.JtxBoard.packageName, // jtx Board + ProviderName.OpenTasks.packageName, // OpenTasks + ProviderName.TasksOrg.packageName // tasks.org ) - // add info about contact, calendar, task provider - for (authority in arrayOf(ContactsContract.AUTHORITY, CalendarContract.AUTHORITY, TaskProvider.ProviderName.OpenTasks.authority)) - pm.resolveContentProvider(authority, 0)?.let { appIDs += it.packageName } - // add info about available contact, calendar, task apps - for (uri in arrayOf(ContactsContract.Contacts.CONTENT_URI, CalendarContract.Events.CONTENT_URI, TaskContract.Tasks.getContentUri(TaskProvider.ProviderName.OpenTasks.authority))) { - val viewIntent = Intent(Intent.ACTION_VIEW, ContentUris.withAppendedId(uri, 1)) + // ... and info about contact and calendar provider + for (authority in arrayOf(ContactsContract.AUTHORITY, CalendarContract.AUTHORITY)) + pm.resolveContentProvider(authority, 0)?.let { packageNames += it.packageName } + // ... and info about contact, calendar, task-editing apps + val dataUris = arrayOf( + ContactsContract.Contacts.CONTENT_URI, + CalendarContract.Events.CONTENT_URI, + TaskContract.Tasks.getContentUri(ProviderName.OpenTasks.authority), + TaskContract.Tasks.getContentUri(ProviderName.TasksOrg.authority) + ) + for (uri in dataUris) { + val viewIntent = Intent(Intent.ACTION_VIEW, ContentUris.withAppendedId(uri, /* some random ID */ 1)) for (info in pm.queryIntentActivities(viewIntent, 0)) - appIDs += info.activityInfo.packageName + packageNames += info.activityInfo.packageName } - for (appID in appIDs) + for (packageName in packageNames) try { - val info = pm.getPackageInfo(appID, 0) - text .append("* ").append(appID) - .append(" ").append(info.versionName) - .append(" (").append(PackageInfoCompat.getLongVersionCode(info)).append(")") - pm.getInstallerPackageName(appID)?.let { installer -> - text.append(" from ").append(installer) - } - info.applicationInfo?.let { applicationInfo -> - if (!applicationInfo.enabled) - text.append(" disabled!") - if (applicationInfo.flags.and(ApplicationInfo.FLAG_EXTERNAL_STORAGE) != 0) - text.append(" on external storage!") - } - text.append("\n") + val info = pm.getPackageInfo(packageName, 0) + val appInfo = info.applicationInfo + val notes = mutableListOf() + if (!appInfo.enabled) + notes += "disabled" + if (appInfo.flags.and(ApplicationInfo.FLAG_EXTERNAL_STORAGE) != 0) + notes += "on external storage" + table.addLine( + info.packageName, info.versionName, PackageInfoCompat.getLongVersionCode(info), + pm.getInstallerPackageName(info.packageName) ?: '—', notes.joinToString(", ") + ) } catch(e: PackageManager.NameNotFoundException) { } + writer.append(table.toString()) } catch(e: Exception) { Logger.log.log(Level.SEVERE, "Couldn't get software information", e) } + // system info + val locales: Any = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) + LocaleList.getAdjustedDefault() + else + Locale.getDefault() + writer.append( + "\nSYSTEM INFORMATION\n\n" + + "Android version: ${Build.VERSION.RELEASE} (${Build.DISPLAY})\n" + + "Device: ${Build.MANUFACTURER} ${Build.MODEL} (${Build.DEVICE})\n\n" + + "Locale(s): $locales\n" + + "Time zone: ${TimeZone.getDefault().id}\n" + ) + val filesPath = Environment.getDataDirectory() + val statFs = StatFs(filesPath.path) + writer.append("Internal memory ($filesPath): ") + .append(FileUtils.byteCountToDisplaySize(statFs.availableBytes)) + .append(" free of ") + .append(FileUtils.byteCountToDisplaySize(statFs.totalBytes)) + .append("\n\n") + // connectivity - text.append("\nCONNECTIVITY (at the moment)\n") - val connectivityManager = context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) { - connectivityManager.allNetworks.forEach { network -> - val capabilities = connectivityManager.getNetworkCapabilities(network) - text.append("- $capabilities\n") - } - } else { - connectivityManager.activeNetworkInfo?.let { networkInfo -> - val type = when (networkInfo.type) { - ConnectivityManager.TYPE_WIFI -> "WiFi" - ConnectivityManager.TYPE_MOBILE -> "mobile" - else -> "type: ${networkInfo.type}" + context.getSystemService()?.let { connectivityManager -> + writer.append("\nCONNECTIVITY\n\n") + val activeNetwork = if (Build.VERSION.SDK_INT >= 23) connectivityManager.activeNetwork else null + connectivityManager.allNetworks.sortedByDescending { it == activeNetwork }.forEach { network -> + val properties = connectivityManager.getLinkProperties(network) + connectivityManager.getNetworkCapabilities(network)?.let { capabilities -> + writer .append(if (network == activeNetwork) " ☒ " else " ☐ ") + .append(properties?.interfaceName ?: "?") + .append("\n - ") + .append(capabilities.toString().replace('&',' ')) + .append('\n') + } + if (properties != null) { + writer .append(" - DNS: ") + .append(properties.dnsServers.joinToString(", ") { it.hostAddress }) + if (Build.VERSION.SDK_INT >= 28 && properties.isPrivateDnsActive) + writer.append(" (private mode)") + writer.append('\n') } - text.append("Active connection: $type, ${networkInfo.detailedState}\n") } + writer.append('\n') + + if (Build.VERSION.SDK_INT >= 23) + connectivityManager.defaultProxy?.let { proxy -> + writer.append("System default proxy: ${proxy.host}:${proxy.port}\n") + } + if (Build.VERSION.SDK_INT >= 24) + writer.append("Data saver: ").append(when (connectivityManager.restrictBackgroundStatus) { + ConnectivityManager.RESTRICT_BACKGROUND_STATUS_ENABLED -> "enabled" + ConnectivityManager.RESTRICT_BACKGROUND_STATUS_WHITELISTED -> "whitelisted" + ConnectivityManager.RESTRICT_BACKGROUND_STATUS_DISABLED -> "disabled" + else -> connectivityManager.restrictBackgroundStatus.toString() + }).append('\n') + writer.append('\n') } - if (Build.VERSION.SDK_INT >= 23) - connectivityManager.defaultProxy?.let { proxy -> - text.append("System default proxy: ${proxy.host}:${proxy.port}\n") - } - text.append("\n") - text.append("CONFIGURATION\n") + writer.append("\nCONFIGURATION\n\n") // power saving - val powerManager = context.getSystemService(Context.POWER_SERVICE) as PowerManager + if (Build.VERSION.SDK_INT >= 28) + context.getSystemService()?.let { statsManager -> + val bucket = statsManager.appStandbyBucket + writer.append("App standby bucket: $bucket") + if (bucket > UsageStatsManager.STANDBY_BUCKET_ACTIVE) + writer.append(" (RESTRICTED!)") + writer.append('\n') + } if (Build.VERSION.SDK_INT >= 23) - text.append("Power saving disabled: ") - .append(if (powerManager.isIgnoringBatteryOptimizations(BuildConfig.APPLICATION_ID)) "yes" else "no") - .append("\n") + context.getSystemService()?.let { powerManager -> + writer.append("Power saving disabled: ") + .append(if (powerManager.isIgnoringBatteryOptimizations(BuildConfig.APPLICATION_ID)) "yes" else "no") + .append('\n') + } + // system-wide sync + writer .append("System-wide synchronization: ") + .append(if (ContentResolver.getMasterSyncAutomatically()) "automatically" else "manually") + .append('\n') // notifications val nm = NotificationManagerCompat.from(context) - text.append("Notifications (") - text.append(if (nm.areNotificationsEnabled()) - "not blocked" - else - "BLOCKED!") - text.append("):\n") + writer.append("\nNotifications") + if (!nm.areNotificationsEnabled()) + writer.append(" (blocked!)") + writer.append(":\n") if (Build.VERSION.SDK_INT >= 26) { val channelsWithoutGroup = nm.notificationChannels.toMutableSet() for (group in nm.notificationChannelGroups) { - text.append(" [group] ${group.id}") + writer.append(" - ${group.id}") if (Build.VERSION.SDK_INT >= 28) - text.append(" isBlocked=${group.isBlocked}") - text.append("\n") - + writer.append(" isBlocked=${group.isBlocked}") + writer.append('\n') for (channel in group.channels) { - text.append(" ${channel.id}: importance=${channel.importance}\n") + writer.append(" * ${channel.id}: importance=${channel.importance}\n") channelsWithoutGroup -= channel } } for (channel in channelsWithoutGroup) - text.append(" ${channel.id}: importance=${channel.importance}\n") + writer.append(" - ${channel.id}: importance=${channel.importance}\n") } + writer.append('\n') // permissions - text.append("Permissions:\n") - for (permission in arrayOf(Manifest.permission.READ_CONTACTS, Manifest.permission.WRITE_CONTACTS, - Manifest.permission.READ_CALENDAR, Manifest.permission.WRITE_CALENDAR, - TaskProvider.PERMISSION_READ_TASKS, TaskProvider.PERMISSION_WRITE_TASKS, - Manifest.permission.ACCESS_COARSE_LOCATION)) { - val shortPermission = permission.replace(Regex("^.+\\.permission\\."), "") - text .append(" $shortPermission: ") + writer.append("Permissions:\n") + val ownPkgInfo = context.packageManager.getPackageInfo(BuildConfig.APPLICATION_ID, PackageManager.GET_PERMISSIONS) + for (permission in ownPkgInfo.requestedPermissions) { + val shortPermission = permission.removePrefix("android.permission.") + writer .append(" - $shortPermission: ") .append(if (ContextCompat.checkSelfPermission(context, permission) == PackageManager.PERMISSION_GRANTED) "granted" else "denied") - .append("\n") + .append('\n') } - // system-wide sync settings - text.append("System-wide synchronization: ") - .append(if (ContentResolver.getMasterSyncAutomatically()) "automatically" else "manually") - .append("\n\n") + writer.append('\n') + writer.append("\nACCOUNTS\n\n") // main accounts - text.append("ACCOUNTS\n") val accountManager = AccountManager.get(context) - for (acct in accountManager.getAccountsByType(context.getString(R.string.account_type))) - try { - val accountSettings = AccountSettings(context, acct) - text.append("Account: ${acct.name}\n" + - " Address book sync. interval: ${syncStatus(accountSettings, context.getString(R.string.address_books_authority))}\n" + - " Calendar sync. interval: ${syncStatus(accountSettings, CalendarContract.AUTHORITY)}\n" + - " OpenTasks sync. interval: ${syncStatus(accountSettings, TaskProvider.ProviderName.OpenTasks.authority)}\n" + - " WiFi only: ").append(accountSettings.getSyncWifiOnly()) - accountSettings.getSyncWifiOnlySSIDs()?.let { - text.append(", SSIDs: ${accountSettings.getSyncWifiOnlySSIDs()}") + 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) } + + val addressBookAccounts = accountManager.getAccountsByType(context.getString(R.string.account_type_address_book)).toMutableList() + for (account in mainAccounts) { + dumpMainAccount(account, writer) + + val iter = addressBookAccounts.iterator() + while (iter.hasNext()) { + val addressBookAccount = iter.next() + val mainAccount = Account( + accountManager.getUserData(addressBookAccount, LocalAddressBook.USER_DATA_MAIN_ACCOUNT_NAME), + accountManager.getUserData(addressBookAccount, LocalAddressBook.USER_DATA_MAIN_ACCOUNT_TYPE) + ) + if (mainAccount == account) { + dumpAddressBookAccount(addressBookAccount, accountManager, writer) + iter.remove() } - text.append("\n [CardDAV] Contact group method: ${accountSettings.getGroupMethod()}") - .append("\n [CalDAV] Time range (past days): ${accountSettings.getTimeRangePastDays()}") - .append("\n Manage calendar colors: ${accountSettings.getManageCalendarColors()}") - .append("\n Use event colors: ${accountSettings.getEventColors()}") - .append("\n") - } catch (e: InvalidAccountException) { - text.append("$acct is invalid (unsupported settings version) or does not exist\n") - } - // address book accounts - for (acct in accountManager.getAccountsByType(context.getString(R.string.account_type_address_book))) - try { - val addressBook = LocalAddressBook(context, acct, null) - text.append("Address book account: ${acct.name}\n" + - " Main account: ${addressBook.mainAccount}\n" + - " URL: ${addressBook.url}\n" + - " Sync automatically: ").append(ContentResolver.getSyncAutomatically(acct, ContactsContract.AUTHORITY)).append("\n") - } catch(e: Exception) { - text.append("$acct is invalid: ${e.message}\n") } - text.append("\n") + } + if (addressBookAccounts.isNotEmpty()) { + writer.append("Address book accounts without main account:\n") + for (account in addressBookAccounts) + dumpAddressBookAccount(account, accountManager, writer) + } + + // database dump + writer.append("\nDATABASE DUMP\n\n") + db.dump(writer, arrayOf("webdav_document")) - text.append("SQLITE DUMP\n") - AppDatabase.getInstance(context).dump(text) - text.append("\n") + // app settings + writer.append("\nAPP SETTINGS\n\n") + settings.dump(writer) + writer.append("--- END DEBUG INFO ---\n") + writer.toString() + } + debugInfo.postValue(debugInfoFile) + } + + fun generateZip(onSuccess: (File) -> Unit) { + viewModelScope.launch(Dispatchers.IO) { try { - text.append( - "SYSTEM INFORMATION\n" + - "Android version: ${Build.VERSION.RELEASE} (${Build.DISPLAY})\n" + - "Device: ${Build.MANUFACTURER} ${Build.MODEL} (${Build.DEVICE})\n\n" - ) - } catch(e: Exception) { - Logger.log.log(Level.SEVERE, "Couldn't get system details", e) + zipProgress.postValue(true) + + val zipFile = File(debugInfoDir, "davx5-debug.zip") + Logger.log.fine("Writing debug info to ${zipFile.absolutePath}") + ZipOutputStream(zipFile.outputStream().buffered()).use { zip -> + zip.setLevel(9) + debugInfo.value?.let { debugInfo -> + zip.putNextEntry(ZipEntry("debug-info.txt")) + debugInfo.inputStream().use { + IOUtils.copy(it, zip) + } + zip.closeEntry() + } + + val logs = logFile.value + if (logs != null) { + // verbose logs available + zip.putNextEntry(ZipEntry(logs.name)) + logs.inputStream().use { + IOUtils.copy(it, zip) + } + zip.closeEntry() + } else { + // logcat (short logs) + try { + Runtime.getRuntime().exec("logcat -d").also { logcat -> + zip.putNextEntry(ZipEntry("logcat.txt")) + IOUtils.copy(logcat.inputStream, zip) + } + } catch (e: Exception) { + Logger.log.log(Level.SEVERE, "Couldn't attach logcat", e) + } + } + } + + withContext(Dispatchers.Main) { + onSuccess(zipFile) + } + } catch(e: IOException) { + // creating attachment with debug info failed + Logger.log.log(Level.SEVERE, "Couldn't attach debug info", e) + Toast.makeText(context, e.toString(), Toast.LENGTH_LONG).show() } + zipProgress.postValue(false) + } + } + + + private fun dumpMainAccount(account: Account, writer: Writer) { + writer.append(" - Account: ${account.name}\n") + writer.append(dumpAccount(account, AccountDumpInfo.mainAccount(context))) + try { + val accountSettings = AccountSettings(context, account) + writer.append(" WiFi only: ${accountSettings.getSyncWifiOnly()}") + accountSettings.getSyncWifiOnlySSIDs()?.let { ssids -> + writer.append(", SSIDs: ${ssids.joinToString(", ")}") + } + writer.append("\n Contact group method: ${accountSettings.getGroupMethod()}\n" + + " Time range (past days): ${accountSettings.getTimeRangePastDays()}\n" + + " Default alarm (min before): ${accountSettings.getDefaultAlarm()}\n" + + " Manage calendar colors: ${accountSettings.getManageCalendarColors()}\n" + + " Use event colors: ${accountSettings.getEventColors()}\n") + } catch(e: InvalidAccountException) { + writer.append("$e\n") + } + writer.append('\n') + } - text.append("--- END DEBUG INFO ---\n") - report.postValue(text.toString()) + private fun dumpAddressBookAccount(account: Account, accountManager: AccountManager, writer: Writer) { + writer.append(" * Address book: ${account.name}\n") + val table = dumpAccount(account, AccountDumpInfo.addressBookAccount()) + writer .append(TextTable.indent(table, 4)) + .append("URL: ${accountManager.getUserData(account, LocalAddressBook.USER_DATA_URL)}\n") + .append(" Read-only: ${accountManager.getUserData(account, LocalAddressBook.USER_DATA_READ_ONLY) ?: 0}\n\n") + } + + private fun dumpAccount(account: Account, infos: Iterable): String { + val table = TextTable("Authority", "Syncable", "Auto-sync", "Interval", "Entries") + for (info in infos) { + var nrEntries = "—" + var client: ContentProviderClient? = null + if (info.countUri != null) + try { + client = context.contentResolver.acquireContentProviderClient(info.authority) + if (client != null) { + client.query(info.countUri, null, "account_name=? AND account_type=?", arrayOf(account.name, account.type), null)?.use { cursor -> + nrEntries = "${cursor.count} ${info.countStr}" + } + } + } catch(ignored: Exception) { + } finally { + client?.closeCompat() + } + table.addLine( + info.authority, + ContentResolver.getIsSyncable(account, info.authority), + ContentResolver.getSyncAutomatically(account, info.authority), + ContentResolver.getPeriodicSyncs(account, info.authority).firstOrNull()?.let { periodicSync -> + "${periodicSync.period / 60} min" + }, + nrEntries + ) } + return table.toString() } - private fun syncStatus(settings: AccountSettings, authority: String): String { - val interval = settings.getSyncInterval(authority) - return if (interval != null) { - if (interval == AccountSettings.SYNC_INTERVAL_MANUALLY) "manually" else "${interval/60} min" - } else - "—" + } + + + data class AccountDumpInfo( + val authority: String, + val countUri: Uri?, + val countStr: String?) { + + companion object { + + fun mainAccount(context: Context) = listOf( + AccountDumpInfo(context.getString(R.string.address_books_authority), null, null), + AccountDumpInfo(CalendarContract.AUTHORITY, CalendarContract.Events.CONTENT_URI, "event(s)"), + AccountDumpInfo(ProviderName.JtxBoard.authority, JtxContract.JtxICalObject.CONTENT_URI, "jtx Board ICalObject(s)"), + AccountDumpInfo(ProviderName.OpenTasks.authority, TaskContract.Tasks.getContentUri(ProviderName.OpenTasks.authority), "OpenTasks task(s)"), + AccountDumpInfo(ProviderName.TasksOrg.authority, TaskContract.Tasks.getContentUri(ProviderName.TasksOrg.authority), "tasks.org task(s)"), + AccountDumpInfo(ContactsContract.AUTHORITY, ContactsContract.RawContacts.CONTENT_URI, "wrongly assigned raw contact(s)") + ) + + fun addressBookAccount() = listOf( + AccountDumpInfo(ContactsContract.AUTHORITY, ContactsContract.RawContacts.CONTENT_URI, "raw contact(s)") + ) + + } + + } + + + class IntentBuilder(context: Context) { + + companion object { + val MAX_ELEMENT_SIZE = 800*FileUtils.ONE_KB.toInt() + } + + val intent = Intent(context, DebugInfoActivity::class.java) + + fun newTask(): IntentBuilder { + intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + return this + } + + fun withAccount(account: Account?): IntentBuilder { + if (account != null) + intent.putExtra(EXTRA_ACCOUNT, account) + return this + } + + fun withAuthority(authority: String?): IntentBuilder { + if (authority != null) + intent.putExtra(EXTRA_AUTHORITY, authority) + return this + } + + fun withCause(throwable: Throwable?): IntentBuilder { + if (throwable != null) + intent.putExtra(EXTRA_CAUSE, throwable) + return this + } + + fun withLocalResource(dump: String?): IntentBuilder { + if (dump != null) + intent.putExtra(EXTRA_LOCAL_RESOURCE, StringUtils.abbreviate(dump, MAX_ELEMENT_SIZE)) + return this + } + + fun withLogFile(logFile: File?): IntentBuilder { + if (logFile != null) + intent.putExtra(EXTRA_LOG_FILE, logFile.absolutePath) + return this + } + + fun withLogs(logs: String?): IntentBuilder { + if (logs != null) + intent.putExtra(EXTRA_LOGS, StringUtils.abbreviate(logs, MAX_ELEMENT_SIZE)) + return this + } + + fun withRemoteResource(remote: HttpUrl?): IntentBuilder { + if (remote != null) + intent.putExtra(EXTRA_REMOTE_RESOURCE, remote.toString()) + return this + } + + + fun build() = intent + + fun share() = intent.apply { + action = Intent.ACTION_SEND } } diff --git a/app/src/main/java/at/bitfire/davdroid/ui/DefaultAccountsDrawerHandler.kt b/app/src/main/java/at/bitfire/davdroid/ui/DefaultAccountsDrawerHandler.kt deleted file mode 100644 index 058eb2302947e8fc48b6e890ae925d1a6b3fe6ab..0000000000000000000000000000000000000000 --- a/app/src/main/java/at/bitfire/davdroid/ui/DefaultAccountsDrawerHandler.kt +++ /dev/null @@ -1,72 +0,0 @@ -/* - * Copyright © Ricki Hirner (bitfire web engineering). - * All rights reserved. This program and the accompanying materials - * are made available under the terms of the GNU Public License v3.0 - * which accompanies this distribution, and is available at - * http://www.gnu.org/licenses/gpl.html - */ - -package at.bitfire.davdroid.ui - -import android.app.Activity -import android.content.Context -import android.content.Intent -import android.net.Uri -import android.view.Menu -import android.view.MenuItem -import android.widget.Toast -import at.bitfire.davdroid.App -import at.bitfire.davdroid.BuildConfig -import at.bitfire.davdroid.R - -class DefaultAccountsDrawerHandler: IAccountsDrawerHandler { - - companion object { - private const val BETA_FEEDBACK_URI = "mailto:play@bitfire.at?subject=${BuildConfig.APPLICATION_ID}/${BuildConfig.VERSION_NAME} feedback (${BuildConfig.VERSION_CODE})" - } - - - override fun initMenu(context: Context, menu: Menu) { - if (BuildConfig.VERSION_NAME.contains("-beta") || BuildConfig.VERSION_NAME.contains("-rc")) - menu.findItem(R.id.nav_beta_feedback).isVisible = true - } - - override fun onNavigationItemSelected(activity: Activity, item: MenuItem): Boolean { - when (item.itemId) { - R.id.nav_about -> - activity.startActivity(Intent(activity, AboutActivity::class.java)) - R.id.nav_app_settings -> - activity.startActivity(Intent(activity, AppSettingsActivity::class.java)) - R.id.nav_beta_feedback -> - if (!UiUtils.launchUri(activity, Uri.parse(BETA_FEEDBACK_URI), Intent.ACTION_SENDTO, false)) - Toast.makeText(activity, R.string.install_email_client, Toast.LENGTH_LONG).show() - R.id.nav_twitter -> - UiUtils.launchUri(activity, - Uri.parse("https://twitter.com/" + activity.getString(R.string.twitter_handle))) - 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_forums -> - UiUtils.launchUri(activity, - App.homepageUrl(activity).buildUpon().appendPath("forums").build()) - R.id.nav_donate -> - //if (BuildConfig.FLAVOR != App.FLAVOR_GOOGLE_PLAY) - UiUtils.launchUri(activity, - App.homepageUrl(activity).buildUpon().appendPath("donate").build()) - R.id.nav_privacy -> - UiUtils.launchUri(activity, - App.homepageUrl(activity).buildUpon().appendPath("privacy").build()) - else -> - return false - } - - return true - } - -} 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 5dd19c9f9f1f50f5db6930c76d7efb55a60ef23f..81307123be919dcf332e245652aacb1a71628005 100644 --- a/app/src/main/java/at/bitfire/davdroid/ui/ExceptionInfoFragment.kt +++ b/app/src/main/java/at/bitfire/davdroid/ui/ExceptionInfoFragment.kt @@ -1,16 +1,11 @@ -/* - * Copyright © Ricki Hirner (bitfire web engineering). - * All rights reserved. This program and the accompanying materials - * are made available under the terms of the GNU Public License v3.0 - * which accompanies this distribution, and is available at - * http://www.gnu.org/licenses/gpl.html - */ +/*************************************************************************************************** + * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. + **************************************************************************************************/ package at.bitfire.davdroid.ui import android.accounts.Account import android.app.Dialog -import android.content.Intent import android.os.Bundle import androidx.fragment.app.DialogFragment import at.bitfire.dav4jvm.exception.HttpException @@ -45,14 +40,15 @@ class ExceptionInfoFragment: DialogFragment() { else -> R.string.exception } - val dialog = MaterialAlertDialogBuilder(requireActivity()) - .setIcon(R.drawable.ic_error_dark) + val dialog = MaterialAlertDialogBuilder(requireActivity(), R.style.CustomAlertDialogStyle) + .setIcon(R.drawable.ic_error) .setTitle(title) .setMessage(exception::class.java.name + "\n" + exception.localizedMessage) .setNegativeButton(R.string.exception_show_details) { _, _ -> - val intent = Intent(activity, DebugInfoActivity::class.java) - intent.putExtra(DebugInfoActivity.KEY_THROWABLE, exception) - account?.let { intent.putExtra(DebugInfoActivity.KEY_ACCOUNT, it) } + val intent = DebugInfoActivity.IntentBuilder(requireActivity()) + .withAccount(account) + .withCause(exception) + .build() startActivity(intent) } .setPositiveButton(android.R.string.ok) { _, _ -> } diff --git a/app/src/main/java/at/bitfire/davdroid/ui/HomeSetAdapter.kt b/app/src/main/java/at/bitfire/davdroid/ui/HomeSetAdapter.kt index b74a3d0b5ab0c701c7a72136dc4e3f1046b2b660..b8f50ca7374262883d0f55fe7b4cef2f853a7d73 100644 --- a/app/src/main/java/at/bitfire/davdroid/ui/HomeSetAdapter.kt +++ b/app/src/main/java/at/bitfire/davdroid/ui/HomeSetAdapter.kt @@ -1,37 +1,42 @@ +/*************************************************************************************************** + * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. + **************************************************************************************************/ + package at.bitfire.davdroid.ui +import android.app.Application import android.content.Context import android.text.TextUtils import android.view.LayoutInflater import android.view.View import android.view.ViewGroup import android.widget.ArrayAdapter +import android.widget.Filter import android.widget.TextView -import at.bitfire.davdroid.model.HomeSet +import at.bitfire.davdroid.DavUtils +import at.bitfire.davdroid.R +import at.bitfire.davdroid.db.HomeSet class HomeSetAdapter( context: Context -): ArrayAdapter(context, android.R.layout.simple_list_item_2, android.R.id.text1) { +): ArrayAdapter(context, R.layout.text_list_item, android.R.id.text1) { + + init { + if (context is Application) + throw IllegalArgumentException("Pass the Activity context, otherwise dark mode won't work") + } override fun getView(position: Int, convertView: View?, parent: ViewGroup): View { val data = getItem(position)!! - val v: View - if (!data.displayName.isNullOrBlank()) { - v = convertView ?: LayoutInflater.from(context).inflate(android.R.layout.simple_list_item_2, null, false) - v.findViewById(android.R.id.text1).text = data.displayName - v.findViewById(android.R.id.text2).apply { - text = data.url.toString() - setSingleLine() - ellipsize = TextUtils.TruncateAt.START - } - } else { - v = convertView ?: LayoutInflater.from(context).inflate(android.R.layout.simple_list_item_1, null, false) - v.findViewById(android.R.id.text1).apply { - text = data.url.toString() - setSingleLine() - ellipsize = TextUtils.TruncateAt.START - } + val v: View = convertView ?: LayoutInflater.from(context).inflate(R.layout.text_list_item, parent, false) + v.findViewById(android.R.id.text1).apply { + text = data.displayName ?: DavUtils.lastSegmentOfUrl(data.url) + } + v.findViewById(android.R.id.text2).apply { + text = data.url.toString() + setSingleLine() + ellipsize = TextUtils.TruncateAt.START } return v } @@ -39,4 +44,15 @@ class HomeSetAdapter( override fun getDropDownView(position: Int, convertView: View?, parent: ViewGroup) = getView(position, convertView, parent) + + override fun getFilter() = object: Filter() { + override fun convertResultToString(resultValue: Any?): CharSequence { + val homeSet = resultValue as HomeSet + return homeSet.url.toString() + } + override fun performFiltering(constraint: CharSequence?) = FilterResults() + override fun publishResults(constraint: CharSequence?, results: FilterResults?) { + } + } + } \ No newline at end of file diff --git a/app/src/main/java/at/bitfire/davdroid/ui/IAccountsDrawerHandler.kt b/app/src/main/java/at/bitfire/davdroid/ui/IAccountsDrawerHandler.kt deleted file mode 100644 index 77eecabfe75ae491efb6b8f6ba6c6b56f49b5036..0000000000000000000000000000000000000000 --- a/app/src/main/java/at/bitfire/davdroid/ui/IAccountsDrawerHandler.kt +++ /dev/null @@ -1,22 +0,0 @@ -/* - * Copyright © Ricki Hirner (bitfire web engineering). - * All rights reserved. This program and the accompanying materials - * are made available under the terms of the GNU Public License v3.0 - * which accompanies this distribution, and is available at - * http://www.gnu.org/licenses/gpl.html - */ - -package at.bitfire.davdroid.ui - -import android.app.Activity -import android.content.Context -import android.view.Menu -import android.view.MenuItem - -interface IAccountsDrawerHandler { - - fun initMenu(context: Context, menu: Menu) - - fun onNavigationItemSelected(activity: Activity, item: MenuItem): Boolean - -} diff --git a/app/src/main/java/at/bitfire/davdroid/ui/NotificationUtils.kt b/app/src/main/java/at/bitfire/davdroid/ui/NotificationUtils.kt index da6a3f882b7a5d43ca9ca25a771e4a69c85e31a1..497a35bde68263ec9a034a5c4871649625d7506e 100644 --- a/app/src/main/java/at/bitfire/davdroid/ui/NotificationUtils.kt +++ b/app/src/main/java/at/bitfire/davdroid/ui/NotificationUtils.kt @@ -1,10 +1,6 @@ -/* - * Copyright © Ricki Hirner (bitfire web engineering). - * All rights reserved. This program and the accompanying materials - * are made available under the terms of the GNU Public License v3.0 - * which accompanies this distribution, and is available at - * http://www.gnu.org/licenses/gpl.html - */ +/*************************************************************************************************** + * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. + **************************************************************************************************/ package at.bitfire.davdroid.ui @@ -15,23 +11,32 @@ import android.app.NotificationManager import android.content.Context import android.os.Build import androidx.core.app.NotificationCompat +import androidx.core.content.getSystemService +import androidx.core.content.res.ResourcesCompat import at.bitfire.davdroid.App import at.bitfire.davdroid.R object NotificationUtils { // notification IDs - const val NOTIFY_EXTERNAL_FILE_LOGGING = 1 + const val NOTIFY_VERBOSE_LOGGING = 1 const val NOTIFY_REFRESH_COLLECTIONS = 2 + const val NOTIFY_FOREGROUND = 3 + const val NOTIFY_DATABASE_CORRUPTED = 4 + const val NOTIFY_BATTERY_OPTIMIZATION = 5 const val NOTIFY_SYNC_ERROR = 10 const val NOTIFY_INVALID_RESOURCE = 11 - const val NOTIFY_OPENTASKS = 20 + const val NOTIFY_WEBDAV_ACCESS = 12 + const val NOTIFY_LOW_STORAGE = 13 + const val NOTIFY_TASKS_PROVIDER_TOO_OLD = 20 const val NOTIFY_PERMISSIONS = 21 + const val NOTIFY_LICENSE = 100 // notification channels const val CHANNEL_GENERAL = "general" const val CHANNEL_DEBUG = "debug" + const val CHANNEL_STATUS = "status" private const val CHANNEL_SYNC = "sync" const val CHANNEL_SYNC_ERRORS = "syncProblems" @@ -42,13 +47,14 @@ object NotificationUtils { fun createChannels(context: Context) { @TargetApi(Build.VERSION_CODES.O) if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - val nm = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager + val nm = context.getSystemService()!! nm.createNotificationChannelGroup(NotificationChannelGroup(CHANNEL_SYNC, context.getString(R.string.notification_channel_sync))) nm.createNotificationChannels(listOf( NotificationChannel(CHANNEL_DEBUG, context.getString(R.string.notification_channel_debugging), NotificationManager.IMPORTANCE_HIGH), NotificationChannel(CHANNEL_GENERAL, context.getString(R.string.notification_channel_general), NotificationManager.IMPORTANCE_DEFAULT), + NotificationChannel(CHANNEL_STATUS, context.getString(R.string.notification_channel_status), NotificationManager.IMPORTANCE_LOW), NotificationChannel(CHANNEL_SYNC_ERRORS, context.getString(R.string.notification_channel_sync_errors), NotificationManager.IMPORTANCE_DEFAULT).apply { description = context.getString(R.string.notification_channel_sync_errors_desc) @@ -68,7 +74,7 @@ object NotificationUtils { fun newBuilder(context: Context, channel: String): NotificationCompat.Builder { val builder = NotificationCompat.Builder(context, channel) - .setColor(context.resources.getColor(R.color.primaryColor)) + .setColor(ResourcesCompat.getColor(context.resources, R.color.primaryColor, null)) if (Build.VERSION.SDK_INT < Build.VERSION_CODES.N) builder.setLargeIcon(App.getLauncherBitmap(context)) diff --git a/app/src/main/java/at/bitfire/davdroid/ui/OseAccountsDrawerHandler.kt b/app/src/main/java/at/bitfire/davdroid/ui/OseAccountsDrawerHandler.kt new file mode 100644 index 0000000000000000000000000000000000000000..368908788b720e6cd4cbdf2b8f210cb68aee979b --- /dev/null +++ b/app/src/main/java/at/bitfire/davdroid/ui/OseAccountsDrawerHandler.kt @@ -0,0 +1,28 @@ +/*************************************************************************************************** + * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. + **************************************************************************************************/ + +package at.bitfire.davdroid.ui + +import android.app.Activity +import android.content.Intent +import android.view.MenuItem +import at.bitfire.davdroid.R +import at.bitfire.davdroid.ui.webdav.WebdavMountsActivity +import javax.inject.Inject + +/** + * Default menu items control + */ +class OseAccountsDrawerHandler @Inject constructor(): BaseAccountsDrawerHandler() { + + override fun onNavigationItemSelected(activity: Activity, item: MenuItem) { + when (item.itemId) { + R.id.nav_webdav_mounts -> + activity.startActivity(Intent(activity, WebdavMountsActivity::class.java)) + else -> + super.onNavigationItemSelected(activity, item) + } + } + +} \ No newline at end of file diff --git a/app/src/main/java/at/bitfire/davdroid/ui/PermissionsActivity.kt b/app/src/main/java/at/bitfire/davdroid/ui/PermissionsActivity.kt new file mode 100644 index 0000000000000000000000000000000000000000..659538a083ad172d4a61db508fb35c26adac301e --- /dev/null +++ b/app/src/main/java/at/bitfire/davdroid/ui/PermissionsActivity.kt @@ -0,0 +1,21 @@ +/*************************************************************************************************** + * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. + **************************************************************************************************/ + +package at.bitfire.davdroid.ui + +import android.os.Bundle +import androidx.appcompat.app.AppCompatActivity + +class PermissionsActivity: AppCompatActivity() { + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + if (savedInstanceState == null) + supportFragmentManager.beginTransaction() + .add(android.R.id.content, PermissionsFragment()) + .commit() + } + +} \ No newline at end of file diff --git a/app/src/main/java/at/bitfire/davdroid/ui/PermissionsFragment.kt b/app/src/main/java/at/bitfire/davdroid/ui/PermissionsFragment.kt new file mode 100644 index 0000000000000000000000000000000000000000..9a914b7ecdc0b85ac4820bc28cfcff3d03c653ba --- /dev/null +++ b/app/src/main/java/at/bitfire/davdroid/ui/PermissionsFragment.kt @@ -0,0 +1,202 @@ +/*************************************************************************************************** + * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. + **************************************************************************************************/ + +package at.bitfire.davdroid.ui + +import android.app.Application +import android.content.Context +import android.content.Intent +import android.net.Uri +import android.os.Build +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.Toast +import androidx.annotation.MainThread +import androidx.fragment.app.Fragment +import androidx.fragment.app.viewModels +import androidx.lifecycle.AndroidViewModel +import androidx.lifecycle.MutableLiveData +import at.bitfire.davdroid.BuildConfig +import at.bitfire.davdroid.PackageChangedReceiver +import at.bitfire.davdroid.PermissionUtils +import at.bitfire.davdroid.PermissionUtils.CALENDAR_PERMISSIONS +import at.bitfire.davdroid.PermissionUtils.CONTACT_PERMISSIONS +import at.bitfire.davdroid.PermissionUtils.havePermissions +import at.bitfire.davdroid.R +import at.bitfire.davdroid.databinding.ActivityPermissionsBinding +import at.bitfire.ical4android.TaskProvider +import at.bitfire.ical4android.TaskProvider.ProviderName + +class PermissionsFragment: Fragment() { + + val model by viewModels() + + + override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View { + val binding = ActivityPermissionsBinding.inflate(inflater, container, false) + binding.lifecycleOwner = viewLifecycleOwner + binding.model = model + + binding.text.text = getString(R.string.permissions_text, getString(R.string.app_name)) + + model.needAutoResetPermission.observe(viewLifecycleOwner, { keepPermissions -> + if (keepPermissions == true && model.haveAutoResetPermission.value == false) { + Toast.makeText(requireActivity(), R.string.permissions_autoreset_instruction, Toast.LENGTH_LONG).show() + startActivity(Intent(Intent.ACTION_AUTO_REVOKE_PERMISSIONS, Uri.fromParts("package", BuildConfig.APPLICATION_ID, null))) + } + }) + model.needContactsPermissions.observe(viewLifecycleOwner, { needContacts -> + if (needContacts && model.haveContactsPermissions.value == false) + requestPermissions(CONTACT_PERMISSIONS, 0) + }) + model.needCalendarPermissions.observe(viewLifecycleOwner, { needCalendars -> + if (needCalendars && model.haveCalendarPermissions.value == false) + requestPermissions(CALENDAR_PERMISSIONS, 0) + }) + model.needOpenTasksPermissions.observe(viewLifecycleOwner, { needOpenTasks -> + if (needOpenTasks == true && model.haveOpenTasksPermissions.value == false) + requestPermissions(TaskProvider.PERMISSIONS_OPENTASKS, 0) + }) + model.needTasksOrgPermissions.observe(viewLifecycleOwner, { needTasksOrg -> + if (needTasksOrg == true && model.haveTasksOrgPermissions.value == false) + requestPermissions(TaskProvider.PERMISSIONS_TASKS_ORG, 0) + }) + model.needJtxPermissions.observe(viewLifecycleOwner, { needJtx -> + if (needJtx == true && model.haveJtxPermissions.value == false) + requestPermissions(TaskProvider.PERMISSIONS_JTX, 0) + }) + model.needAllPermissions.observe(viewLifecycleOwner, { needAll -> + if (needAll && model.haveAllPermissions.value == false) { + val all = mutableSetOf(*CONTACT_PERMISSIONS, *CALENDAR_PERMISSIONS) + if (model.haveOpenTasksPermissions.value != null) + all.addAll(TaskProvider.PERMISSIONS_OPENTASKS) + if (model.haveTasksOrgPermissions.value != null) + all.addAll(TaskProvider.PERMISSIONS_TASKS_ORG) + if (model.haveJtxPermissions.value != null) + all.addAll(TaskProvider.PERMISSIONS_JTX) + requestPermissions(all.toTypedArray(), 0) + } + }) + + binding.appSettings.setOnClickListener { + PermissionUtils.showAppSettings(requireActivity()) + } + + return binding.root + } + + override fun onResume() { + super.onResume() + model.checkPermissions() + } + + override fun onRequestPermissionsResult(requestCode: Int, permissions: Array, grantResults: IntArray) { + super.onRequestPermissionsResult(requestCode, permissions, grantResults) + model.checkPermissions() + } + + + class Model(app: Application): AndroidViewModel(app) { + + val haveAutoResetPermission = MutableLiveData() + val needAutoResetPermission = MutableLiveData() + + val haveContactsPermissions = MutableLiveData() + val needContactsPermissions = MutableLiveData() + val haveCalendarPermissions = MutableLiveData() + val needCalendarPermissions = MutableLiveData() + + val haveOpenTasksPermissions = MutableLiveData() + val needOpenTasksPermissions = MutableLiveData() + val haveTasksOrgPermissions = MutableLiveData() + val needTasksOrgPermissions = MutableLiveData() + val haveJtxPermissions = MutableLiveData() + val needJtxPermissions = MutableLiveData() + val tasksWatcher = object: PackageChangedReceiver(app) { + @MainThread + override fun onReceive(context: Context?, intent: Intent?) { + checkPermissions() + } + } + + val haveAllPermissions = MutableLiveData() + val needAllPermissions = MutableLiveData() + + init { + checkPermissions() + } + + override fun onCleared() { + tasksWatcher.close() + } + + @MainThread + fun checkPermissions() { + val pm = getApplication().packageManager + + // auto-reset permissions + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + val keepPermissions = pm.isAutoRevokeWhitelisted + haveAutoResetPermission.value = keepPermissions + needAutoResetPermission.value = keepPermissions + } + + // other permissions + val contactPermissions = havePermissions(getApplication(), CONTACT_PERMISSIONS) + haveContactsPermissions.value = contactPermissions + needContactsPermissions.value = contactPermissions + + val calendarPermissions = havePermissions(getApplication(), CALENDAR_PERMISSIONS) + haveCalendarPermissions.value = calendarPermissions + needCalendarPermissions.value = calendarPermissions + + // OpenTasks + val openTasksAvailable = pm.resolveContentProvider(ProviderName.OpenTasks.authority, 0) != null + var openTasksPermissions: Boolean? = null + if (openTasksAvailable) { + openTasksPermissions = havePermissions(getApplication(), TaskProvider.PERMISSIONS_OPENTASKS) + haveOpenTasksPermissions.value = openTasksPermissions + needOpenTasksPermissions.value = openTasksPermissions + } else { + haveOpenTasksPermissions.value = null + needOpenTasksPermissions.value = null + } + // tasks.org + val tasksOrgAvailable = pm.resolveContentProvider(ProviderName.TasksOrg.authority, 0) != null + var tasksOrgPermissions: Boolean? = null + if (tasksOrgAvailable) { + tasksOrgPermissions = havePermissions(getApplication(), TaskProvider.PERMISSIONS_TASKS_ORG) + haveTasksOrgPermissions.value = tasksOrgPermissions + needTasksOrgPermissions.value = tasksOrgPermissions + } else { + haveTasksOrgPermissions.value = null + needTasksOrgPermissions.value = null + } + // jtx Board + val jtxAvailable = pm.resolveContentProvider(ProviderName.JtxBoard.authority, 0) != null + var jtxPermissions: Boolean? = null + if (jtxAvailable) { + jtxPermissions = havePermissions(getApplication(), TaskProvider.PERMISSIONS_JTX) + haveJtxPermissions.value = jtxPermissions + needJtxPermissions.value = jtxPermissions + } else { + haveJtxPermissions.value = null + needJtxPermissions.value = null + } + + // "all permissions" switch + val allPermissions = contactPermissions && + calendarPermissions && + (!openTasksAvailable || openTasksPermissions == true) && + (!tasksOrgAvailable || tasksOrgPermissions == true) && + (!jtxAvailable || jtxPermissions == true) + haveAllPermissions.value = allPermissions + needAllPermissions.value = allPermissions + } + + } + +} diff --git a/app/src/main/java/at/bitfire/davdroid/ui/StartupDialogFragment.kt b/app/src/main/java/at/bitfire/davdroid/ui/StartupDialogFragment.kt deleted file mode 100644 index 3a989577ec6704a93e1a939af0940d0c8db1e8de..0000000000000000000000000000000000000000 --- a/app/src/main/java/at/bitfire/davdroid/ui/StartupDialogFragment.kt +++ /dev/null @@ -1,164 +0,0 @@ -/* - * Copyright © Ricki Hirner (bitfire web engineering). - * All rights reserved. This program and the accompanying materials - * are made available under the terms of the GNU Public License v3.0 - * which accompanies this distribution, and is available at - * http://www.gnu.org/licenses/gpl.html - */ - -package at.bitfire.davdroid.ui - -import android.annotation.SuppressLint -import android.annotation.TargetApi -import android.app.Dialog -import android.content.Context -import android.content.DialogInterface -import android.net.Uri -import android.os.Build -import android.os.Bundle -import android.os.PowerManager -import androidx.fragment.app.DialogFragment -import at.bitfire.davdroid.App -import at.bitfire.davdroid.BuildConfig -import at.bitfire.davdroid.R -import at.bitfire.davdroid.log.Logger -import at.bitfire.davdroid.resource.LocalTaskList -import at.bitfire.davdroid.settings.Settings -import com.google.android.material.dialog.MaterialAlertDialogBuilder -import java.util.* - -class StartupDialogFragment: DialogFragment() { - - enum class Mode { - AUTOSTART_PERMISSIONS, - BATTERY_OPTIMIZATIONS, - OPENTASKS_NOT_INSTALLED, - OSE_DONATE - } - - companion object { - - private const val SETTING_NEXT_DONATION_POPUP = "time_nextDonationPopup" - - const val HINT_AUTOSTART_PERMISSIONS = "hint_AutostartPermissions" - // see https://github.com/jaredrummler/AndroidDeviceNames/blob/master/json/ for manufacturer values - private val autostartManufacturers = arrayOf("huawei", "letv", "oneplus", "vivo", "xiaomi", "zte") - - const val HINT_BATTERY_OPTIMIZATIONS = "hint_BatteryOptimizations" - const val HINT_OPENTASKS_NOT_INSTALLED = "hint_OpenTasksNotInstalled" - - const val ARGS_MODE = "mode" - - fun getStartupDialogs(context: Context): List { - val dialogs = LinkedList() - val settings = Settings.getInstance(context) - - if (System.currentTimeMillis() > settings.getLong(SETTING_NEXT_DONATION_POPUP) ?: 0) - dialogs += StartupDialogFragment.instantiate(Mode.OSE_DONATE) - - // battery optimization white-listing - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M && settings.getBoolean(HINT_BATTERY_OPTIMIZATIONS) != false) { - val powerManager = context.getSystemService(Context.POWER_SERVICE) as PowerManager - if (!powerManager.isIgnoringBatteryOptimizations(BuildConfig.APPLICATION_ID)) - dialogs.add(instantiate(Mode.BATTERY_OPTIMIZATIONS)) - } - - // vendor-specific auto-start information - if (autostartManufacturers.contains(Build.MANUFACTURER.toLowerCase()) && settings.getBoolean(HINT_AUTOSTART_PERMISSIONS) != false) - dialogs.add(instantiate(Mode.AUTOSTART_PERMISSIONS)) - - // OpenTasks information - if (true /* don't show in other flavors */) - if (!LocalTaskList.tasksProviderAvailable(context) && settings.getBoolean(HINT_OPENTASKS_NOT_INSTALLED) != false) - dialogs.add(instantiate(Mode.OPENTASKS_NOT_INSTALLED)) - - return dialogs.reversed() - } - - fun instantiate(mode: Mode): StartupDialogFragment { - val frag = StartupDialogFragment() - val args = Bundle(1) - args.putString(ARGS_MODE, mode.name) - frag.arguments = args - return frag - } - - } - - - @SuppressLint("BatteryLife") - override fun onCreateDialog(savedInstanceState: Bundle?): Dialog { - isCancelable = false - - val settings = Settings.getInstance(requireActivity()) - val activity = requireActivity() - return when (Mode.valueOf(arguments!!.getString(ARGS_MODE)!!)) { - Mode.AUTOSTART_PERMISSIONS -> - MaterialAlertDialogBuilder(activity) - .setIcon(R.drawable.ic_error_dark) - .setTitle(R.string.startup_autostart_permission) - .setMessage(getString(R.string.startup_autostart_permission_message, Build.MANUFACTURER)) - .setPositiveButton(R.string.startup_more_info) { _, _ -> - UiUtils.launchUri(requireActivity(), App.homepageUrl(requireActivity()).buildUpon() - .appendPath("faq").appendPath("synchronization-is-not-run-as-expected").build()) - } - .setNeutralButton(R.string.startup_not_now) { _, _ -> } - .setNegativeButton(R.string.startup_dont_show_again) { _, _ -> - settings.putBoolean(HINT_AUTOSTART_PERMISSIONS, false) - } - .create() - - Mode.BATTERY_OPTIMIZATIONS -> - MaterialAlertDialogBuilder(activity) - .setIcon(R.drawable.ic_info_dark) - .setTitle(R.string.startup_battery_optimization) - .setMessage(R.string.startup_battery_optimization_message) - .setPositiveButton(R.string.startup_battery_optimization_disable) @TargetApi(Build.VERSION_CODES.M) { _, _ -> - UiUtils.launchUri(requireActivity(), Uri.parse("package:" + BuildConfig.APPLICATION_ID), - android.provider.Settings.ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS) - } - .setNeutralButton(R.string.startup_not_now) { _, _ -> } - .setNegativeButton(R.string.startup_dont_show_again) { _: DialogInterface, _: Int -> - settings.putBoolean(HINT_BATTERY_OPTIMIZATIONS, false) - } - .create() - - Mode.OPENTASKS_NOT_INSTALLED -> { - val builder = StringBuilder(getString(R.string.startup_opentasks_not_installed_message)) - if (Build.VERSION.SDK_INT < 23) - builder.append("\n\n").append(getString(R.string.startup_opentasks_reinstall_davx5)) - return MaterialAlertDialogBuilder(activity) - .setIcon(R.drawable.ic_playlist_add_check_dark) - .setTitle(R.string.startup_opentasks_not_installed) - .setMessage(builder.toString()) - .setPositiveButton(R.string.startup_opentasks_not_installed_install) { _, _ -> - if (!UiUtils.launchUri(requireActivity(), Uri.parse("market://details?id=org.dmfs.tasks"), toastInstallBrowser = false)) - Logger.log.warning("No market app available, can't install OpenTasks") - } - .setNeutralButton(R.string.startup_not_now) { _, _ -> } - .setNegativeButton(R.string.startup_dont_show_again) { _: DialogInterface, _: Int -> - settings.putBoolean(HINT_OPENTASKS_NOT_INSTALLED, false) - } - .create() - } - - Mode.OSE_DONATE -> - return MaterialAlertDialogBuilder(activity) - .setIcon(R.mipmap.ic_launcher) - .setTitle(R.string.startup_donate) - .setMessage(R.string.startup_donate_message) - .setPositiveButton(R.string.startup_donate_now) { _, _ -> - UiUtils.launchUri(requireActivity(), App.homepageUrl(requireActivity()).buildUpon() - .appendPath("donate") - .build()) - settings.putLong(SETTING_NEXT_DONATION_POPUP, System.currentTimeMillis() + 30 * 86400000L) // 30 days - } - .setNegativeButton(R.string.startup_donate_later) { _, _ -> - settings.putLong(SETTING_NEXT_DONATION_POPUP, System.currentTimeMillis() + 14 * 86400000L) // 14 days - } - .create() - - } - } - -} diff --git a/app/src/main/java/at/bitfire/davdroid/ui/TasksActivity.kt b/app/src/main/java/at/bitfire/davdroid/ui/TasksActivity.kt new file mode 100644 index 0000000000000000000000000000000000000000..30428918dcc6e83c06c0611ce7bafacadd19de39 --- /dev/null +++ b/app/src/main/java/at/bitfire/davdroid/ui/TasksActivity.kt @@ -0,0 +1,23 @@ +/*************************************************************************************************** + * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. + **************************************************************************************************/ + +package at.bitfire.davdroid.ui + +import android.os.Bundle +import androidx.appcompat.app.AppCompatActivity +import dagger.hilt.android.AndroidEntryPoint + +@AndroidEntryPoint +class TasksActivity: AppCompatActivity() { + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + if (savedInstanceState == null) + supportFragmentManager.beginTransaction() + .add(android.R.id.content, TasksFragment()) + .commit() + } + +} \ No newline at end of file diff --git a/app/src/main/java/at/bitfire/davdroid/ui/TasksFragment.kt b/app/src/main/java/at/bitfire/davdroid/ui/TasksFragment.kt new file mode 100644 index 0000000000000000000000000000000000000000..110152ea75b9c58c96520aa847c5ae234484c74e --- /dev/null +++ b/app/src/main/java/at/bitfire/davdroid/ui/TasksFragment.kt @@ -0,0 +1,195 @@ +/*************************************************************************************************** + * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. + **************************************************************************************************/ + +package at.bitfire.davdroid.ui + +import android.content.Context +import android.content.Intent +import android.content.pm.PackageManager +import android.net.Uri +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.annotation.AnyThread +import androidx.databinding.ObservableBoolean +import androidx.fragment.app.Fragment +import androidx.fragment.app.viewModels +import androidx.lifecycle.MutableLiveData +import androidx.lifecycle.ViewModel +import at.bitfire.davdroid.PackageChangedReceiver +import at.bitfire.davdroid.R +import at.bitfire.davdroid.databinding.ActivityTasksBinding +import at.bitfire.davdroid.resource.TaskUtils +import at.bitfire.davdroid.settings.SettingsManager +import at.bitfire.ical4android.TaskProvider.ProviderName +import com.google.android.material.snackbar.Snackbar +import dagger.hilt.android.AndroidEntryPoint +import dagger.hilt.android.lifecycle.HiltViewModel +import dagger.hilt.android.qualifiers.ApplicationContext +import javax.inject.Inject + +@AndroidEntryPoint +class TasksFragment: Fragment() { + + private var _binding: ActivityTasksBinding? = null + private val binding get() = _binding!! + val model by viewModels() + + + override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View { + _binding = ActivityTasksBinding.inflate(inflater, container, false) + binding.lifecycleOwner = viewLifecycleOwner + binding.model = model + + model.openTasksRequested.observe(viewLifecycleOwner) { shallBeInstalled -> + if (shallBeInstalled && model.openTasksInstalled.value == false) { + // uncheck switch for the moment (until the app is installed) + model.openTasksRequested.value = false + installApp(ProviderName.OpenTasks.packageName) + } + } + model.openTasksSelected.observe(viewLifecycleOwner) { selected -> + if (selected && model.currentProvider.value != ProviderName.OpenTasks) + model.selectPreferredProvider(ProviderName.OpenTasks) + } + + model.tasksOrgRequested.observe(viewLifecycleOwner) { shallBeInstalled -> + if (shallBeInstalled && model.tasksOrgInstalled.value == false) { + model.tasksOrgRequested.value = false + installApp(ProviderName.TasksOrg.packageName) + } + } + model.tasksOrgSelected.observe(viewLifecycleOwner) { selected -> + if (selected && model.currentProvider.value != ProviderName.TasksOrg) + model.selectPreferredProvider(ProviderName.TasksOrg) + } + + + model.jtxRequested.observe(viewLifecycleOwner) { shallBeInstalled -> + if (shallBeInstalled && model.jtxInstalled.value == false) { + model.jtxRequested.value = false + installApp(ProviderName.JtxBoard.packageName) + } + } + model.jtxSelected.observe(viewLifecycleOwner) { selected -> + if (selected && model.currentProvider.value != ProviderName.JtxBoard) + model.selectPreferredProvider(ProviderName.JtxBoard) + } + + binding.infoLeaveUnchecked.text = getString(R.string.intro_leave_unchecked, getString(R.string.app_settings_reset_hints)) + + return binding.root + } + + override fun onDestroyView() { + super.onDestroyView() + _binding = null + } + + private fun installApp(packageName: String) { + val uri = Uri.parse("market://details?id=$packageName") + val intent = Intent(Intent.ACTION_VIEW, uri) + if (intent.resolveActivity(requireActivity().packageManager) != null) + startActivity(intent) + else + Snackbar.make(binding.frame, R.string.intro_tasks_no_app_store, Snackbar.LENGTH_LONG).show() + } + + + @HiltViewModel + class Model @Inject constructor( + @ApplicationContext val context: Context, + val settings: SettingsManager + ) : ViewModel(), SettingsManager.OnChangeListener { + + companion object { + + /** + * Whether this fragment (which asks for OpenTasks installation) shall be shown. + * If this setting is true or null/not set, the notice shall be shown. Only if this + * setting is false, the notice shall not be shown. + */ + const val HINT_OPENTASKS_NOT_INSTALLED = "hint_OpenTasksNotInstalled" + + } + + val currentProvider = MutableLiveData() + val openTasksInstalled = MutableLiveData() + val openTasksRequested = MutableLiveData() + val openTasksSelected = MutableLiveData() + val tasksOrgInstalled = MutableLiveData() + val tasksOrgRequested = MutableLiveData() + val tasksOrgSelected = MutableLiveData() + val jtxInstalled = MutableLiveData() + val jtxRequested = MutableLiveData() + val jtxSelected = MutableLiveData() + val tasksWatcher = object: PackageChangedReceiver(context) { + override fun onReceive(context: Context?, intent: Intent?) { + checkInstalled() + } + } + + val dontShow = object: ObservableBoolean() { + override fun get() = settings.getBooleanOrNull(HINT_OPENTASKS_NOT_INSTALLED) == false + override fun set(dontShowAgain: Boolean) { + if (dontShowAgain) + settings.putBoolean(HINT_OPENTASKS_NOT_INSTALLED, false) + else + settings.remove(HINT_OPENTASKS_NOT_INSTALLED) + notifyChange() + } + } + + init { + checkInstalled() + settings.addOnChangeListener(this) + } + + override fun onCleared() { + settings.removeOnChangeListener(this) + tasksWatcher.close() + } + + @AnyThread + fun checkInstalled() { + val taskProvider = TaskUtils.currentProvider(context) + currentProvider.postValue(taskProvider) + + val openTasks = isInstalled(ProviderName.OpenTasks.packageName) + openTasksInstalled.postValue(openTasks) + openTasksRequested.postValue(openTasks) + openTasksSelected.postValue(taskProvider == ProviderName.OpenTasks) + + val tasksOrg = isInstalled(ProviderName.TasksOrg.packageName) + tasksOrgInstalled.postValue(tasksOrg) + tasksOrgRequested.postValue(tasksOrg) + tasksOrgSelected.postValue(taskProvider == ProviderName.TasksOrg) + + val jtxBoard = isInstalled(ProviderName.JtxBoard.packageName) + jtxInstalled.postValue(jtxBoard) + jtxRequested.postValue(jtxBoard) + jtxSelected.postValue(taskProvider == ProviderName.JtxBoard) + } + + private fun isInstalled(packageName: String): Boolean = + try { + context.packageManager.getPackageInfo(packageName, 0) + true + } catch (e: PackageManager.NameNotFoundException) { + false + } + + fun selectPreferredProvider(provider: ProviderName) { + TaskUtils.setPreferredProvider(context, provider) + } + + + override fun onSettingsChanged() { + checkInstalled() + } + + } + +} \ No newline at end of file diff --git a/app/src/main/java/at/bitfire/davdroid/ui/UiUtils.kt b/app/src/main/java/at/bitfire/davdroid/ui/UiUtils.kt index d9af118b046432b7bc6cbdc35a52c1131fff1ca2..1695203027c9e7f16fef135b1a8403e6c66227d0 100644 --- a/app/src/main/java/at/bitfire/davdroid/ui/UiUtils.kt +++ b/app/src/main/java/at/bitfire/davdroid/ui/UiUtils.kt @@ -1,21 +1,41 @@ -/* - * Copyright © Ricki Hirner (bitfire web engineering). - * All rights reserved. This program and the accompanying materials - * are made available under the terms of the GNU Public License v3.0 - * which accompanies this distribution, and is available at - * http://www.gnu.org/licenses/gpl.html - */ +/*************************************************************************************************** + * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. + **************************************************************************************************/ package at.bitfire.davdroid.ui +import android.content.ActivityNotFoundException import android.content.Context import android.content.Intent +import android.content.pm.ShortcutInfo +import android.content.pm.ShortcutManager +import android.graphics.drawable.Icon import android.net.Uri +import android.os.Build import android.widget.Toast +import androidx.appcompat.app.AppCompatDelegate +import androidx.core.content.getSystemService import at.bitfire.davdroid.R +import at.bitfire.davdroid.log.Logger +import at.bitfire.davdroid.settings.Settings +import at.bitfire.davdroid.settings.SettingsManager +import dagger.hilt.EntryPoint +import dagger.hilt.InstallIn +import dagger.hilt.android.EntryPointAccessors +import dagger.hilt.components.SingletonComponent +import java.util.logging.Level object UiUtils { + @EntryPoint + @InstallIn(SingletonComponent::class) + interface UiUtilsEntryPoint { + fun settingsManager(): SettingsManager + } + + const val SHORTCUT_SYNC_ALL = "syncAllAccounts" + const val SNACKBAR_LENGTH_VERY_LONG = 5000 // 5s + /** * Starts the [Intent.ACTION_VIEW] intent with the given URL, if possible. * If the intent can't be resolved (for instance, because there is no browser @@ -29,12 +49,40 @@ object UiUtils { */ fun launchUri(context: Context, uri: Uri, action: String = Intent.ACTION_VIEW, toastInstallBrowser: Boolean = true): Boolean { val intent = Intent(action, uri) - if (intent.resolveActivity(context.packageManager) != null) { + try { context.startActivity(intent) return true - } else if (toastInstallBrowser) + } catch (e: ActivityNotFoundException) { + // no browser available + } + + if (toastInstallBrowser) Toast.makeText(context, R.string.install_browser, Toast.LENGTH_LONG).show() + return false } + fun setTheme(context: Context) { + val settings = EntryPointAccessors.fromApplication(context, UiUtilsEntryPoint::class.java).settingsManager() + val mode = settings.getIntOrNull(Settings.PREFERRED_THEME) ?: Settings.PREFERRED_THEME_DEFAULT + AppCompatDelegate.setDefaultNightMode(mode) + } + + fun updateShortcuts(context: Context) { + if (Build.VERSION.SDK_INT >= 25) + context.getSystemService()?.let { shortcutManager -> + try { + shortcutManager.dynamicShortcuts = listOf( + ShortcutInfo.Builder(context, SHORTCUT_SYNC_ALL) + .setIcon(Icon.createWithResource(context, R.drawable.ic_sync_shortcut)) + .setShortLabel(context.getString(R.string.accounts_sync_all)) + .setIntent(Intent(Intent.ACTION_SYNC, null, context, AccountsActivity::class.java)) + .build() + ) + } catch(e: Exception) { + Logger.log.log(Level.WARNING, "Couldn't update dynamic shortcut(s)", e) + } + } + } + } \ No newline at end of file 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 51534d1ef60779ad57581c4e1bc60b98038388e9..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 @@ -1,65 +1,87 @@ +/*************************************************************************************************** + * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. + **************************************************************************************************/ + package at.bitfire.davdroid.ui.account -import android.Manifest import android.accounts.Account import android.accounts.AccountManager -import android.app.Application +import android.accounts.OnAccountsUpdateListener import android.content.Context import android.content.Intent -import android.content.pm.PackageManager import android.os.Build import android.os.Bundle import android.os.Handler import android.os.Looper import android.view.Menu import android.view.MenuItem -import androidx.annotation.MainThread +import androidx.activity.viewModels import androidx.appcompat.app.AppCompatActivity -import androidx.core.app.ActivityCompat -import androidx.core.content.ContextCompat import androidx.fragment.app.Fragment import androidx.fragment.app.FragmentStatePagerAdapter import androidx.lifecycle.* import at.bitfire.davdroid.DavUtils import at.bitfire.davdroid.R +import at.bitfire.davdroid.databinding.ActivityAccountBinding +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.model.AppDatabase -import at.bitfire.davdroid.model.Collection -import at.bitfire.davdroid.model.Service -import at.bitfire.davdroid.resource.LocalTaskList -import at.bitfire.ical4android.TaskProvider +import at.bitfire.davdroid.settings.AccountSettings import com.google.android.material.dialog.MaterialAlertDialogBuilder import com.google.android.material.snackbar.Snackbar -import kotlinx.android.synthetic.main.activity_account.* -import java.util.concurrent.Executors +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject +import dagger.hilt.android.AndroidEntryPoint +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 kotlin.concurrent.thread +import javax.inject.Inject +@AndroidEntryPoint class AccountActivity: AppCompatActivity() { companion object { const val EXTRA_ACCOUNT = "account" } - lateinit var model: Model + @Inject lateinit var modelFactory: Model.Factory + val model by viewModels { + val account = intent.getParcelableExtra(EXTRA_ACCOUNT) as? Account + ?: throw IllegalArgumentException("AccountActivity requires EXTRA_ACCOUNT") + object: ViewModelProvider.Factory { + @Suppress("UNCHECKED_CAST") + override fun create(modelClass: Class) = + modelFactory.create(account) as T + } + } + + private lateinit var binding: ActivityAccountBinding override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - model = ViewModelProviders.of(this).get(Model::class.java) - (intent.getParcelableExtra(EXTRA_ACCOUNT) as? Account)?.let { account -> - model.initialize(account) - } - title = model.account.name - setContentView(R.layout.activity_account) - setSupportActionBar(toolbar) + + binding = ActivityAccountBinding.inflate(layoutInflater) + setContentView(binding.root) + setSupportActionBar(binding.toolbar) supportActionBar?.setDisplayHomeAsUpEnabled(true) - tab_layout.setupWithViewPager(view_pager) + model.accountExists.observe(this, Observer { accountExists -> + if (!accountExists) + finish() + }) + + binding.tabLayout.setupWithViewPager(binding.viewPager) val tabsAdapter = TabsAdapter(this) - view_pager.adapter = tabsAdapter + binding.viewPager.adapter = tabsAdapter model.cardDavService.observe(this, Observer { tabsAdapter.cardDavSvcId = it }) @@ -67,34 +89,17 @@ class AccountActivity: AppCompatActivity() { tabsAdapter.calDavSvcId = it }) - model.askForPermissions.observe(this, Observer { permissions -> - if (permissions.isNotEmpty()) - ActivityCompat.requestPermissions(this, permissions.toTypedArray(), 0) - }) - - sync.setOnClickListener { + binding.sync.setOnClickListener { DavUtils.requestSync(this, model.account) - Snackbar.make(view_pager, R.string.account_synchronizing_now, Snackbar.LENGTH_LONG).show() + Snackbar.make(binding.viewPager, R.string.account_synchronizing_now, Snackbar.LENGTH_LONG).show() } } - override fun onCreateOptionsMenu(menu: Menu?): Boolean { + override fun onCreateOptionsMenu(menu: Menu): Boolean { menuInflater.inflate(R.menu.activity_account, menu) return true } - override fun onPrepareOptionsMenu(menu: Menu): Boolean { - val itemRename = menu.findItem(R.id.rename_account) - // renameAccount is available for API level 21+ - itemRename.isVisible = Build.VERSION.SDK_INT >= 21 - return super.onPrepareOptionsMenu(menu) - } - - override fun onRequestPermissionsResult(requestCode: Int, permissions: Array, grantResults: IntArray) { - if (grantResults.contains(PackageManager.PERMISSION_GRANTED)) - model.gotPermissions() - } - // menu actions @@ -105,13 +110,12 @@ class AccountActivity: AppCompatActivity() { } fun renameAccount(menuItem: MenuItem) { - if (Build.VERSION.SDK_INT >= 21) - RenameAccountFragment.newInstance(model.account).show(supportFragmentManager, null) + RenameAccountFragment.newInstance(model.account).show(supportFragmentManager, null) } fun deleteAccount(menuItem: MenuItem) { - MaterialAlertDialogBuilder(this) - .setIcon(R.drawable.ic_error_dark) + MaterialAlertDialogBuilder(this, R.style.CustomAlertDialogStyle) + .setIcon(R.drawable.ic_error) .setTitle(R.string.account_delete_confirmation_title) .setMessage(R.string.account_delete_confirmation_text) .setNegativeButton(android.R.string.no, null) @@ -123,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) } @@ -154,7 +169,7 @@ class AccountActivity: AppCompatActivity() { class TabsAdapter( val activity: AppCompatActivity ): FragmentStatePagerAdapter(activity.supportFragmentManager, BEHAVIOR_RESUME_ONLY_CURRENT_FRAGMENT) { - + var cardDavSvcId: Long? = null set(value) { field = value @@ -186,6 +201,7 @@ class AccountActivity: AppCompatActivity() { idxWebcal = null } + // reflect changes in UI notifyDataSetChanged() } @@ -222,6 +238,9 @@ class AccountActivity: AppCompatActivity() { throw IllegalArgumentException() } + // required to reload all fragments + override fun getItemPosition(obj: Any) = POSITION_NONE + override fun getPageTitle(position: Int): String = when (position) { idxCardDav -> activity.getString(R.string.account_carddav) @@ -235,117 +254,66 @@ class AccountActivity: AppCompatActivity() { // model - class Model(application: Application): AndroidViewModel(application) { - - private var initialized = false - lateinit var account: Account - private set - - private val db = AppDatabase.getInstance(application) - private val executor = Executors.newSingleThreadExecutor() - - val cardDavService = MutableLiveData() - val calDavService = MutableLiveData() + class Model @AssistedInject constructor( + @ApplicationContext val context: Context, + val db: AppDatabase, + @Assisted val account: Account + ): ViewModel(), OnAccountsUpdateListener { - private val needContactPermissions: LiveData = Transformations.switchMap(cardDavService) { cardDavId -> - if (cardDavId != null) - db.collectionDao().observeHasSyncByService(cardDavId) - else - MutableLiveData().apply { value = false } - } - private val needCalendarPermissions: LiveData = Transformations.map(calDavService) { calDavId -> - calDavId != null + @AssistedFactory + interface Factory { + fun create(account: Account): Model } - val askForPermissions = PermissionCalculator(application, needContactPermissions, needCalendarPermissions) + val accountManager = AccountManager.get(context)!! + val accountSettings by lazy { AccountSettings(context, account) } + + val accountExists = MutableLiveData() + val cardDavService = db.serviceDao().getIdByAccountAndType(account.name, Service.TYPE_CARDDAV) + val calDavService = db.serviceDao().getIdByAccountAndType(account.name, Service.TYPE_CALDAV) - @MainThread - fun initialize(account: Account) { - if (initialized) - return - initialized = true + val showOnlyPersonal = MutableLiveData() + val showOnlyPersonal_writable = MutableLiveData() - this.account = account - thread { - cardDavService.postValue(db.serviceDao().getIdByAccountAndType(account.name, Service.TYPE_CARDDAV)) - calDavService.postValue(db.serviceDao().getIdByAccountAndType(account.name, Service.TYPE_CALDAV)) + init { + accountManager.addOnAccountsUpdatedListener(this, null, true) + viewModelScope.launch(Dispatchers.IO) { + accountSettings.getShowOnlyPersonal().let { (value, locked) -> + showOnlyPersonal.postValue(value) + showOnlyPersonal_writable.postValue(locked) + } } } - fun gotPermissions() { - askForPermissions.calculate() + override fun onCleared() { + accountManager.removeOnAccountsUpdatedListener(this) } - fun toggleSync(item: Collection) = - executor.execute { - val newItem = item.copy(sync = !item.sync) - db.collectionDao().update(newItem) - } - - fun toggleReadOnly(item: Collection) = - executor.execute { - val newItem = item.copy(forceReadOnly = !item.forceReadOnly) - db.collectionDao().update(newItem) - } - - } - - class PermissionCalculator( - val context: Context, - needContactPermissions: LiveData, - needCalendarPermissions: LiveData - ): MediatorLiveData>() { - - companion object { - val contactPermissions = arrayOf( - Manifest.permission.READ_CONTACTS, - Manifest.permission.WRITE_CONTACTS - ) - val calendarPermissions = arrayOf( - Manifest.permission.READ_CALENDAR, - Manifest.permission.WRITE_CALENDAR - ) - val taskPermissions = arrayOf( - TaskProvider.PERMISSION_READ_TASKS, - TaskProvider.PERMISSION_WRITE_TASKS - ) + override fun onAccountsUpdated(accounts: Array) { + accountExists.postValue(accounts.contains(account)) } - private var usesContacts: Boolean? = null - private var usesCalendars: Boolean? = null - - init { - addSource(needContactPermissions) { - usesContacts = it - calculate() - } - addSource(needCalendarPermissions) { - usesCalendars = it - calculate() + fun toggleReadOnly(item: Collection) { + viewModelScope.launch(Dispatchers.IO + NonCancellable) { + val newItem = item.copy(forceReadOnly = !item.forceReadOnly) + db.collectionDao().update(newItem) } } - fun calculate() { - val contacts = usesContacts ?: return - val calendar = usesCalendars ?: return - - val required = mutableListOf() - if (contacts) - required.addAll(contactPermissions) - - if (calendar) { - required.addAll(calendarPermissions) - if (LocalTaskList.tasksProviderAvailable(context)) - required.addAll(taskPermissions) + fun toggleShowOnlyPersonal() { + showOnlyPersonal.value?.let { oldValue -> + val newValue = !oldValue + accountSettings.setShowOnlyPersonal(newValue) + showOnlyPersonal.postValue(newValue) } + } - // only ask for permissions which are not granted - val askFor = required.filter { - ContextCompat.checkSelfPermission(context, it) == PackageManager.PERMISSION_DENIED + fun toggleSync(item: Collection) { + viewModelScope.launch(Dispatchers.IO + NonCancellable) { + val newItem = item.copy(sync = !item.sync) + db.collectionDao().update(newItem) } - if (value != askFor) - value = askFor } } diff --git a/app/src/main/java/at/bitfire/davdroid/ui/account/AddressBooksFragment.kt b/app/src/main/java/at/bitfire/davdroid/ui/account/AddressBooksFragment.kt index 2bdb2e694731c2bc27898cf71994367c22180795..4a8e2da8bc37339d370ef353777e9839bb052504 100644 --- a/app/src/main/java/at/bitfire/davdroid/ui/account/AddressBooksFragment.kt +++ b/app/src/main/java/at/bitfire/davdroid/ui/account/AddressBooksFragment.kt @@ -1,11 +1,16 @@ +/*************************************************************************************************** + * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. + **************************************************************************************************/ + package at.bitfire.davdroid.ui.account import android.content.Intent import android.view.* +import androidx.fragment.app.FragmentManager +import at.bitfire.davdroid.PermissionUtils import at.bitfire.davdroid.R -import at.bitfire.davdroid.model.Collection -import at.bitfire.davdroid.ui.CreateAddressBookActivity -import kotlinx.android.synthetic.main.account_carddav_item.view.* +import at.bitfire.davdroid.databinding.AccountCarddavItemBinding +import at.bitfire.davdroid.db.Collection class AddressBooksFragment: CollectionsFragment() { @@ -14,6 +19,11 @@ class AddressBooksFragment: CollectionsFragment() { override fun onCreateOptionsMenu(menu: Menu, inflater: MenuInflater) = inflater.inflate(R.menu.carddav_actions, menu) + override fun onPrepareOptionsMenu(menu: Menu) { + menu.findItem(R.id.create_address_book).isVisible = model.hasWriteableCollections.value ?: false + super.onPrepareOptionsMenu(menu) + } + override fun onOptionsItemSelected(item: MenuItem): Boolean { if (super.onOptionsItemSelected(item)) return true @@ -28,41 +38,51 @@ class AddressBooksFragment: CollectionsFragment() { return false } - override fun createAdapter() = AddressBookAdapter(accountModel) + override fun checkPermissions() { + if (PermissionUtils.havePermissions(requireActivity(), PermissionUtils.CONTACT_PERMISSIONS)) + binding.permissionsCard.visibility = View.GONE + else { + binding.permissionsText.setText(R.string.account_carddav_missing_permissions) + binding.permissionsCard.visibility = View.VISIBLE + } + } + + override fun createAdapter() = AddressBookAdapter(accountModel, parentFragmentManager) class AddressBookViewHolder( - parent: ViewGroup, - accountModel: AccountActivity.Model - ): CollectionViewHolder(parent, R.layout.account_carddav_item, accountModel) { + parent: ViewGroup, + accountModel: AccountActivity.Model, + val fragmentManager: FragmentManager + ): CollectionViewHolder(parent, AccountCarddavItemBinding.inflate(LayoutInflater.from(parent.context), parent, false), accountModel) { override fun bindTo(item: Collection) { - val v = itemView - v.sync.isChecked = item.sync - v.title.text = item.title() + binding.sync.isChecked = item.sync + binding.title.text = item.title() if (item.description.isNullOrBlank()) - v.description.visibility = View.GONE + binding.description.visibility = View.GONE else { - v.description.text = item.description - v.description.visibility = View.VISIBLE + binding.description.text = item.description + binding.description.visibility = View.VISIBLE } - v.read_only.visibility = if (item.readOnly()) View.VISIBLE else View.GONE + binding.readOnly.visibility = if (item.readOnly()) View.VISIBLE else View.GONE itemView.setOnClickListener { accountModel.toggleSync(item) } - v.action_overflow.setOnClickListener(CollectionPopupListener(accountModel, item)) + binding.actionOverflow.setOnClickListener(CollectionPopupListener(accountModel, item, fragmentManager)) } } class AddressBookAdapter( - accountModel: AccountActivity.Model + accountModel: AccountActivity.Model, + val fragmentManager: FragmentManager ): CollectionAdapter(accountModel) { override fun onCreateViewHolder(parent: ViewGroup, viewType: Int) = - AddressBookViewHolder(parent, accountModel) + AddressBookViewHolder(parent, accountModel, fragmentManager) } diff --git a/app/src/main/java/at/bitfire/davdroid/ui/account/CalendarsFragment.kt b/app/src/main/java/at/bitfire/davdroid/ui/account/CalendarsFragment.kt index adf50855b96e0ba592aea8e74d8e756a172f252b..a74d53a489b6496294708bca0865e908b34b93db 100644 --- a/app/src/main/java/at/bitfire/davdroid/ui/account/CalendarsFragment.kt +++ b/app/src/main/java/at/bitfire/davdroid/ui/account/CalendarsFragment.kt @@ -1,12 +1,18 @@ +/*************************************************************************************************** + * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. + **************************************************************************************************/ + package at.bitfire.davdroid.ui.account import android.content.Intent import android.view.* +import androidx.fragment.app.FragmentManager import at.bitfire.davdroid.Constants +import at.bitfire.davdroid.PermissionUtils import at.bitfire.davdroid.R -import at.bitfire.davdroid.model.Collection -import at.bitfire.davdroid.ui.CreateCalendarActivity -import kotlinx.android.synthetic.main.account_caldav_item.view.* +import at.bitfire.davdroid.databinding.AccountCaldavItemBinding +import at.bitfire.davdroid.db.Collection +import at.bitfire.davdroid.resource.TaskUtils class CalendarsFragment: CollectionsFragment() { @@ -15,11 +21,16 @@ class CalendarsFragment: CollectionsFragment() { override fun onCreateOptionsMenu(menu: Menu, inflater: MenuInflater) = inflater.inflate(R.menu.caldav_actions, menu) + override fun onPrepareOptionsMenu(menu: Menu) { + menu.findItem(R.id.create_calendar).isVisible = model.hasWriteableCollections.value ?: false + super.onPrepareOptionsMenu(menu) + } + override fun onOptionsItemSelected(item: MenuItem): Boolean { if (super.onOptionsItemSelected(item)) return true - if (item.itemId == R.id.create) { + if (item.itemId == R.id.create_calendar) { val intent = Intent(requireActivity(), CreateCalendarActivity::class.java) intent.putExtra(CreateCalendarActivity.EXTRA_ACCOUNT, accountModel.account) startActivity(intent) @@ -29,46 +40,65 @@ class CalendarsFragment: CollectionsFragment() { return false } - override fun createAdapter(): CollectionAdapter = CalendarAdapter(accountModel) + + override fun checkPermissions() { + val calendarPermissions = PermissionUtils.havePermissions(requireActivity(), PermissionUtils.CALENDAR_PERMISSIONS) + val taskProvider = TaskUtils.currentProvider(requireActivity()) + val tasksPermissions = taskProvider == null || // no task provider OR + PermissionUtils.havePermissions(requireActivity(), taskProvider.permissions) // task permissions granted + if (calendarPermissions && tasksPermissions) + binding.permissionsCard.visibility = View.GONE + else { + binding.permissionsText.setText(when { + !calendarPermissions && tasksPermissions -> R.string.account_caldav_missing_calendar_permissions + calendarPermissions && !tasksPermissions -> R.string.account_caldav_missing_tasks_permissions + else -> R.string.account_caldav_missing_permissions + }) + binding.permissionsCard.visibility = View.VISIBLE + } + } + override fun createAdapter(): CollectionAdapter = CalendarAdapter(accountModel, parentFragmentManager) class CalendarViewHolder( - parent: ViewGroup, - accountModel: AccountActivity.Model - ): CollectionViewHolder(parent, R.layout.account_caldav_item, accountModel) { + parent: ViewGroup, + accountModel: AccountActivity.Model, + val fragmentManager: FragmentManager + ): CollectionViewHolder(parent, AccountCaldavItemBinding.inflate(LayoutInflater.from(parent.context), parent, false), accountModel) { override fun bindTo(item: Collection) { - val v = itemView - v.color.setBackgroundColor(item.color ?: Constants.DAVDROID_GREEN_RGBA) + binding.color.setBackgroundColor(item.color ?: Constants.DAVDROID_GREEN_RGBA) - v.sync.isChecked = item.sync - v.title.text = item.title() + binding.sync.isChecked = item.sync + binding.title.text = item.title() if (item.description.isNullOrBlank()) - v.description.visibility = View.GONE + binding.description.visibility = View.GONE else { - v.description.text = item.description - v.description.visibility = View.VISIBLE + binding.description.text = item.description + binding.description.visibility = View.VISIBLE } - v.read_only.visibility = if (item.readOnly()) View.VISIBLE else View.GONE - v.events.visibility = if (item.supportsVEVENT == true) View.VISIBLE else View.GONE - v.tasks.visibility = if (item.supportsVTODO == true) View.VISIBLE else View.GONE + binding.readOnly.visibility = if (item.readOnly()) View.VISIBLE else View.GONE + binding.events.visibility = if (item.supportsVEVENT == true) View.VISIBLE else View.GONE + binding.tasks.visibility = if (item.supportsVTODO == true) View.VISIBLE else View.GONE + binding.journals.visibility = if (item.supportsVJOURNAL == true) View.VISIBLE else View.GONE itemView.setOnClickListener { accountModel.toggleSync(item) } - v.action_overflow.setOnClickListener(CollectionPopupListener(accountModel, item)) + binding.actionOverflow.setOnClickListener(CollectionPopupListener(accountModel, item, fragmentManager)) } } class CalendarAdapter( - accountModel: AccountActivity.Model + accountModel: AccountActivity.Model, + val fragmentManager: FragmentManager ): CollectionAdapter(accountModel) { override fun onCreateViewHolder(parent: ViewGroup, viewType: Int) = - CalendarViewHolder(parent, accountModel) + CalendarViewHolder(parent, accountModel, fragmentManager) } diff --git a/app/src/main/java/at/bitfire/davdroid/ui/account/CollectionInfoFragment.kt b/app/src/main/java/at/bitfire/davdroid/ui/account/CollectionInfoFragment.kt index 10c464c4b9d4909f82734dff535db3d71b913578..cecdf8f349b75fc44ab60abc5a1202500c4e3800 100644 --- a/app/src/main/java/at/bitfire/davdroid/ui/account/CollectionInfoFragment.kt +++ b/app/src/main/java/at/bitfire/davdroid/ui/account/CollectionInfoFragment.kt @@ -1,28 +1,26 @@ -/* - * Copyright © Ricki Hirner (bitfire web engineering). - * All rights reserved. This program and the accompanying materials - * are made available under the terms of the GNU Public License v3.0 - * which accompanies this distribution, and is available at - * http://www.gnu.org/licenses/gpl.html - */ +/*************************************************************************************************** + * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. + **************************************************************************************************/ package at.bitfire.davdroid.ui.account -import android.app.Application import android.os.Bundle import android.view.LayoutInflater import android.view.View import android.view.ViewGroup -import androidx.annotation.UiThread import androidx.fragment.app.DialogFragment -import androidx.lifecycle.AndroidViewModel -import androidx.lifecycle.MutableLiveData -import androidx.lifecycle.ViewModelProviders +import androidx.fragment.app.viewModels +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider import at.bitfire.davdroid.databinding.CollectionPropertiesBinding -import at.bitfire.davdroid.model.AppDatabase -import at.bitfire.davdroid.model.Collection -import kotlin.concurrent.thread - +import at.bitfire.davdroid.db.AppDatabase +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject +import dagger.hilt.android.AndroidEntryPoint +import javax.inject.Inject + +@AndroidEntryPoint class CollectionInfoFragment: DialogFragment() { companion object { @@ -39,12 +37,16 @@ class CollectionInfoFragment: DialogFragment() { } - override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? { - val model = ViewModelProviders.of(this).get(Model::class.java) - arguments?.getLong(ARGS_COLLECTION_ID)?.let { id -> - model.initialize(id) + @Inject lateinit var modelFactory: Model.Factory + val model by viewModels() { + object : ViewModelProvider.Factory { + @Suppress("UNCHECKED_CAST") + override fun create(modelClass: Class) = + modelFactory.create(requireArguments().getLong(ARGS_COLLECTION_ID)) as T } + } + override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View { val view = CollectionPropertiesBinding.inflate(inflater, container, false) view.lifecycleOwner = this view.model = model @@ -53,26 +55,18 @@ class CollectionInfoFragment: DialogFragment() { } - class Model( - application: Application - ): AndroidViewModel(application) { - - var collection = MutableLiveData() + class Model @AssistedInject constructor( + val db: AppDatabase, + @Assisted collectionId: Long + ): ViewModel() { - private var initialized = false - - @UiThread - fun initialize(collectionId: Long) { - if (initialized) - return - initialized = true - - thread { - val db = AppDatabase.getInstance(getApplication()) - collection.postValue(db.collectionDao().get(collectionId)) - } + @AssistedFactory + interface Factory { + fun create(collectionId: Long): Model } + var collection = db.collectionDao().getLive(collectionId) + } } \ No newline at end of file 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 7dab33491bcee6a52e4365cc6497098d76058f43..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 @@ -1,6 +1,9 @@ +/*************************************************************************************************** + * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. + **************************************************************************************************/ + package at.bitfire.davdroid.ui.account -import android.app.Application import android.content.* import android.os.Bundle import android.os.IBinder @@ -8,29 +11,40 @@ import android.provider.CalendarContract import android.provider.ContactsContract import android.view.* import android.widget.PopupMenu -import androidx.annotation.WorkerThread -import androidx.appcompat.app.AppCompatActivity +import androidx.annotation.AnyThread +import androidx.core.content.ContextCompat import androidx.fragment.app.Fragment +import androidx.fragment.app.FragmentManager +import androidx.fragment.app.activityViewModels +import androidx.fragment.app.viewModels import androidx.lifecycle.* -import androidx.paging.PagedList -import androidx.paging.PagedListAdapter -import androidx.paging.toLiveData +import androidx.paging.* import androidx.recyclerview.widget.DiffUtil import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.RecyclerView import androidx.swiperefreshlayout.widget.SwipeRefreshLayout +import androidx.viewbinding.ViewBinding import at.bitfire.davdroid.Constants import at.bitfire.davdroid.DavService import at.bitfire.davdroid.R -import at.bitfire.davdroid.model.AppDatabase -import at.bitfire.davdroid.model.Collection +import at.bitfire.davdroid.databinding.AccountCollectionsBinding +import at.bitfire.davdroid.db.AppDatabase +import at.bitfire.davdroid.db.Collection import at.bitfire.davdroid.resource.LocalAddressBook -import at.bitfire.davdroid.resource.LocalTaskList -import at.bitfire.davdroid.ui.DeleteCollectionFragment -import at.bitfire.ical4android.TaskProvider -import kotlinx.android.synthetic.main.account_collections.* -import java.util.concurrent.Executors - +import at.bitfire.davdroid.resource.TaskUtils +import at.bitfire.davdroid.ui.PermissionsActivity +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject +import dagger.hilt.android.AndroidEntryPoint +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.launch +import javax.inject.Inject + +@AndroidEntryPoint abstract class CollectionsFragment: Fragment(), SwipeRefreshLayout.OnRefreshListener { companion object { @@ -38,81 +52,114 @@ abstract class CollectionsFragment: Fragment(), SwipeRefreshLayout.OnRefreshList const val EXTRA_COLLECTION_TYPE = "collectionType" } - lateinit var accountModel: AccountActivity.Model - lateinit var model: Model - - abstract val noCollectionsStringId: Int + private var _binding: AccountCollectionsBinding? = null + protected val binding get() = _binding!! + + val accountModel by activityViewModels() + @Inject lateinit var modelFactory: Model.Factory + val model by viewModels { + object: ViewModelProvider.Factory { + @Suppress("UNCHECKED_CAST") + override fun create(modelClass: Class): T = + modelFactory.create( + accountModel, + requireArguments().getLong(EXTRA_SERVICE_ID), + requireArguments().getString(EXTRA_COLLECTION_TYPE) ?: throw IllegalArgumentException("EXTRA_COLLECTION_TYPE required") + ) as T + } + } override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - - accountModel = ViewModelProviders.of(requireActivity()).get(AccountActivity.Model::class.java) - model = ViewModelProviders.of(this).get(Model::class.java) - model.initialize( - accountModel, - arguments?.getLong(EXTRA_SERVICE_ID) ?: throw IllegalArgumentException("EXTRA_SERVICE_ID required"), - arguments?.getString(EXTRA_COLLECTION_TYPE) ?: throw IllegalArgumentException("EXTRA_COLLECTION_TYPE required") - ) - setHasOptionsMenu(true) } - override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View = - inflater.inflate(R.layout.account_collections, container, false) + abstract val noCollectionsStringId: Int + + override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View { + _binding = AccountCollectionsBinding.inflate(inflater, container, false) + + binding.permissionsBtn.setOnClickListener { + startActivity(Intent(requireActivity(), PermissionsActivity::class.java)) + } + + return binding.root + } override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) model.isRefreshing.observe(viewLifecycleOwner, Observer { nowRefreshing -> - swipe_refresh.isRefreshing = nowRefreshing + binding.swipeRefresh.isRefreshing = nowRefreshing }) - model.collections.observe(viewLifecycleOwner, Observer { collections -> - val colors = collections.orEmpty() - .filterNotNull() - .mapNotNull { it.color } - .distinct() - .ifEmpty { listOf(Constants.DAVDROID_GREEN_RGBA) } - swipe_refresh.setColorSchemeColors(*colors.toIntArray()) + model.hasWriteableCollections.observe(viewLifecycleOwner, Observer { + requireActivity().invalidateOptionsMenu() }) - swipe_refresh.setOnRefreshListener(this) + model.collectionsColors.observe(viewLifecycleOwner, Observer { colors: List -> + val realColors = colors.filterNotNull() + if (realColors.isNotEmpty()) + binding.swipeRefresh.setColorSchemeColors(*realColors.toIntArray()) + }) + binding.swipeRefresh.setOnRefreshListener(this) val updateProgress = Observer { if (model.isSyncActive.value == true) { - progress.isIndeterminate = true - progress.alpha = 1.0f - progress.visibility = View.VISIBLE + binding.progress.isIndeterminate = true + binding.progress.alpha = 1.0f + binding.progress.visibility = View.VISIBLE } else { if (model.isSyncPending.value == true) { - progress.visibility = View.VISIBLE - progress.alpha = 0.2f - progress.isIndeterminate = false - progress.progress = 100 + binding.progress.visibility = View.VISIBLE + binding.progress.alpha = 0.2f + binding.progress.isIndeterminate = false + binding.progress.progress = 100 } else - progress.visibility = View.INVISIBLE + binding.progress.visibility = View.INVISIBLE } } model.isSyncPending.observe(viewLifecycleOwner, updateProgress) model.isSyncActive.observe(viewLifecycleOwner, updateProgress) val adapter = createAdapter() - list.layoutManager = LinearLayoutManager(requireActivity()) - list.adapter = adapter - model.collections.observe(viewLifecycleOwner, Observer { data -> - adapter.submitList(data) - - if (data.isEmpty()) { - list.visibility = View.GONE - empty.visibility = View.VISIBLE - } else { - list.visibility = View.VISIBLE - empty.visibility = View.GONE + binding.list.layoutManager = LinearLayoutManager(requireActivity()) + binding.list.adapter = adapter + model.collectionsPager.observe(viewLifecycleOwner, Observer { data -> + lifecycleScope.launch { + val colors = data.flow.map { pagingData -> + pagingData.map { collection -> + collection.color ?: ContextCompat.getColor(requireContext(), R.color.accentColor) + } + } + data.flow.collectLatest { pagingData -> + adapter.submitData(pagingData) + } } }) + adapter.addLoadStateListener { loadStates -> + if (loadStates.refresh is LoadState.NotLoading) { + if (adapter.itemCount > 0) { + binding.list.visibility = View.VISIBLE + binding.empty.visibility = View.GONE + } else { + binding.list.visibility = View.GONE + binding.empty.visibility = View.VISIBLE + } + } + } - no_collections.setText(noCollectionsStringId) + binding.noCollections.setText(noCollectionsStringId) } - protected abstract fun createAdapter(): CollectionAdapter + override fun onPrepareOptionsMenu(menu: Menu) { + menu.findItem(R.id.showOnlyPersonal).let { showOnlyPersonal -> + accountModel.showOnlyPersonal.value?.let { value -> + showOnlyPersonal.isChecked = value + } + accountModel.showOnlyPersonal_writable.value?.let { writable -> + showOnlyPersonal.isEnabled = writable + } + } + } override fun onOptionsItemSelected(item: MenuItem) = when (item.itemId) { @@ -120,6 +167,10 @@ abstract class CollectionsFragment: Fragment(), SwipeRefreshLayout.OnRefreshList onRefresh() true } + R.id.showOnlyPersonal -> { + accountModel.toggleShowOnlyPersonal() + true + } else -> false } @@ -128,21 +179,32 @@ abstract class CollectionsFragment: Fragment(), SwipeRefreshLayout.OnRefreshList model.refresh() } + override fun onResume() { + super.onResume() + checkPermissions() + } + override fun onDestroyView() { + super.onDestroyView() + _binding = null + } - abstract class CollectionViewHolder( - parent: ViewGroup, - itemLayout: Int, - protected val accountModel: AccountActivity.Model - ): RecyclerView.ViewHolder( - LayoutInflater.from(parent.context).inflate(itemLayout, parent, false) - ) { + + protected abstract fun checkPermissions() + protected abstract fun createAdapter(): CollectionAdapter + + + abstract class CollectionViewHolder( + parent: ViewGroup, + val binding: T, + protected val accountModel: AccountActivity.Model + ): RecyclerView.ViewHolder(binding.root) { abstract fun bindTo(item: Collection) } abstract class CollectionAdapter( - protected val accountModel: AccountActivity.Model - ): PagedListAdapter(DIFF_CALLBACK) { + protected val accountModel: AccountActivity.Model + ): PagingDataAdapter>(DIFF_CALLBACK) { companion object { private val DIFF_CALLBACK = object: DiffUtil.ItemCallback() { @@ -154,7 +216,7 @@ abstract class CollectionsFragment: Fragment(), SwipeRefreshLayout.OnRefreshList } } - override fun onBindViewHolder(holder: CollectionViewHolder, position: Int) { + override fun onBindViewHolder(holder: CollectionViewHolder<*>, position: Int) { getItem(position)?.let { item -> holder.bindTo(item) } @@ -163,12 +225,12 @@ abstract class CollectionsFragment: Fragment(), SwipeRefreshLayout.OnRefreshList } class CollectionPopupListener( - private val accountModel: AccountActivity.Model, - private val item: Collection + private val accountModel: AccountActivity.Model, + private val item: Collection, + private val fragmentManager: FragmentManager ): View.OnClickListener { override fun onClick(anchor: View) { - val fragmentManager = (anchor.context as AppCompatActivity).supportFragmentManager val popup = PopupMenu(anchor.context, anchor, Gravity.RIGHT) popup.inflate(R.menu.account_collection_operations) @@ -204,19 +266,35 @@ abstract class CollectionsFragment: Fragment(), SwipeRefreshLayout.OnRefreshList } - class Model(application: Application): AndroidViewModel(application), DavService.RefreshingStatusListener, SyncStatusObserver { - - private val db = AppDatabase.getInstance(application) - private val executor = Executors.newSingleThreadExecutor() + class Model @AssistedInject constructor( + @ApplicationContext val context: Context, + val db: AppDatabase, + @Assisted val accountModel: AccountActivity.Model, + @Assisted val serviceId: Long, + @Assisted val collectionType: String + ): ViewModel(), DavService.RefreshingStatusListener, SyncStatusObserver { - private lateinit var accountModel: AccountActivity.Model - val serviceId = MutableLiveData() - private lateinit var collectionType: String + @AssistedFactory + interface Factory { + fun create(accountModel: AccountActivity.Model, serviceId: Long, collectionType: String): Model + } - val collections: LiveData> = - Transformations.switchMap(serviceId) { service -> - db.collectionDao().pageByServiceAndType(service, collectionType).toLiveData(25) + // cache task provider + val taskProvider by lazy { TaskUtils.currentProvider(context) } + + val hasWriteableCollections = db.homeSetDao().hasBindableByServiceLive(serviceId) + val collectionsColors = db.collectionDao().colorsByServiceLive(serviceId) + val collectionsPager: LiveData> = + Transformations.map(accountModel.showOnlyPersonal) { onlyPersonal -> + Pager(PagingConfig(pageSize = 25)) { + if (onlyPersonal) + // show only personal collections + db.collectionDao().pagePersonalByServiceAndType(serviceId, collectionType) + else + // show all collections + db.collectionDao().pageByServiceAndType(serviceId, collectionType) } + } // observe DavService refresh status @Volatile @@ -240,18 +318,12 @@ abstract class CollectionsFragment: Fragment(), SwipeRefreshLayout.OnRefreshList val isSyncPending = MutableLiveData() - fun initialize(accountModel: AccountActivity.Model, id: Long, collectionType: String) { - this.accountModel = accountModel - this.collectionType = collectionType - if (serviceId.value == null) - serviceId.value = id - - val context = getApplication() + init { if (context.bindService(Intent(context, DavService::class.java), svcConn, Context.BIND_AUTO_CREATE)) davServiceConn = svcConn - executor.submit { - syncStatusHandle = ContentResolver.addStatusChangeListener(ContentResolver.SYNC_OBSERVER_TYPE_PENDING + ContentResolver.SYNC_OBSERVER_TYPE_ACTIVE, this) + viewModelScope.launch(Dispatchers.Default) { + syncStatusHandle = ContentResolver.addStatusChangeListener(ContentResolver.SYNC_OBSERVER_TYPE_PENDING + ContentResolver.SYNC_OBSERVER_TYPE_ACTIVE, this@Model) checkSyncStatus() } } @@ -261,48 +333,45 @@ abstract class CollectionsFragment: Fragment(), SwipeRefreshLayout.OnRefreshList davService?.removeRefreshingStatusListener(this) davServiceConn?.let { - getApplication().unbindService(it) + context.unbindService(it) davServiceConn = null } } fun refresh() { - val context = getApplication() - val intent = Intent(context, DavService::class.java) - intent.action = DavService.ACTION_REFRESH_COLLECTIONS - intent.putExtra(DavService.EXTRA_DAV_SERVICE_ID, serviceId.value) - context.startService(intent) + DavService.refreshCollections(context, serviceId) } - @WorkerThread + @AnyThread override fun onDavRefreshStatusChanged(id: Long, refreshing: Boolean) { - if (id == serviceId.value) + if (id == serviceId) isRefreshing.postValue(refreshing) } + @AnyThread override fun onStatusChanged(which: Int) { - executor.submit { - checkSyncStatus() - } + checkSyncStatus() } + @AnyThread + @Synchronized private fun checkSyncStatus() { - val context = getApplication() if (collectionType == Collection.TYPE_ADDRESSBOOK) { val mainAuthority = context.getString(R.string.address_books_authority) val mainSyncActive = ContentResolver.isSyncActive(accountModel.account, mainAuthority) val mainSyncPending = ContentResolver.isSyncPending(accountModel.account, mainAuthority) - val accounts = LocalAddressBook.findAll(context, null, accountModel.account) - val syncActive = accounts.any { ContentResolver.isSyncActive(it.account, ContactsContract.AUTHORITY) } - val syncPending = accounts.any { ContentResolver.isSyncPending(it.account, ContactsContract.AUTHORITY) } + val addrBookAccounts = LocalAddressBook.findAll(context, null, accountModel.account).map { it.account } + val syncActive = addrBookAccounts.any { ContentResolver.isSyncActive(it, ContactsContract.AUTHORITY) } + val syncPending = addrBookAccounts.any { ContentResolver.isSyncPending(it, ContactsContract.AUTHORITY) } isSyncActive.postValue(mainSyncActive || syncActive) isSyncPending.postValue(mainSyncPending || syncPending) } else { val authorities = mutableListOf(CalendarContract.AUTHORITY) - if (LocalTaskList.tasksProviderAvailable(context)) - authorities += TaskProvider.ProviderName.OpenTasks.authority + taskProvider?.let { + authorities += it.authority + } isSyncActive.postValue(authorities.any { ContentResolver.isSyncActive(accountModel.account, it) }) diff --git a/app/src/main/java/at/bitfire/davdroid/ui/account/CreateAddressBookActivity.kt b/app/src/main/java/at/bitfire/davdroid/ui/account/CreateAddressBookActivity.kt new file mode 100644 index 0000000000000000000000000000000000000000..7611dfff1637d427a7c839b5d4189ed5a00080fa --- /dev/null +++ b/app/src/main/java/at/bitfire/davdroid/ui/account/CreateAddressBookActivity.kt @@ -0,0 +1,166 @@ +/*************************************************************************************************** + * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. + **************************************************************************************************/ + +package at.bitfire.davdroid.ui.account + +import android.accounts.Account +import android.content.Intent +import android.os.Bundle +import android.text.Editable +import android.view.Menu +import android.view.MenuItem +import androidx.activity.viewModels +import androidx.appcompat.app.AppCompatActivity +import androidx.core.app.TaskStackBuilder +import androidx.databinding.DataBindingUtil +import androidx.lifecycle.MutableLiveData +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import androidx.lifecycle.viewModelScope +import at.bitfire.davdroid.R +import at.bitfire.davdroid.databinding.ActivityCreateAddressBookBinding +import at.bitfire.davdroid.db.AppDatabase +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 dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject +import dagger.hilt.android.AndroidEntryPoint +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import org.apache.commons.lang3.StringUtils +import java.util.* +import javax.inject.Inject + +@AndroidEntryPoint +class CreateAddressBookActivity: AppCompatActivity() { + + companion object { + const val EXTRA_ACCOUNT = "account" + } + + @Inject lateinit var modelFactory: Model.Factory + val model by viewModels() { + object: ViewModelProvider.Factory { + @Suppress("UNCHECKED_CAST") + override fun create(modelClass: Class): T { + val account = intent.getParcelableExtra(EXTRA_ACCOUNT) ?: throw IllegalArgumentException("EXTRA_ACCOUNT must be set") + return modelFactory.create(account) as T + } + } + } + + lateinit var binding: ActivityCreateAddressBookBinding + + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + supportActionBar?.setDisplayHomeAsUpEnabled(true) + + binding = DataBindingUtil.setContentView(this, R.layout.activity_create_address_book) + binding.lifecycleOwner = this + binding.model = model + + val homeSetAdapter = HomeSetAdapter(this) + model.homeSets.observe(this) { homeSets -> + homeSetAdapter.clear() + if (homeSets.isNotEmpty()) { + homeSetAdapter.addAll(homeSets) + val firstHomeSet = homeSets.first() + binding.homeset.setText(firstHomeSet.url.toString(), false) + model.homeSet = firstHomeSet + } + } + binding.homeset.setAdapter(homeSetAdapter) + binding.homeset.setOnItemClickListener { parent, view, position, id -> + model.homeSet = parent.getItemAtPosition(position) as HomeSet? + } + } + + override fun onCreateOptionsMenu(menu: Menu): Boolean { + menuInflater.inflate(R.menu.activity_create_collection, menu) + return true + } + + override fun supportShouldUpRecreateTask(targetIntent: Intent) = true + + override fun onPrepareSupportNavigateUpTaskStack(builder: TaskStackBuilder) { + builder.editIntentAt(builder.intentCount - 1)?.putExtra(AccountActivity.EXTRA_ACCOUNT, model.account) + } + + + fun onCreateCollection(item: MenuItem) { + var ok = true + + val args = Bundle() + args.putString(CreateCollectionFragment.ARG_SERVICE_TYPE, Service.TYPE_CARDDAV) + + val parent = model.homeSet + if (parent != null) { + binding.homesetLayout.error = null + args.putString(CreateCollectionFragment.ARG_URL, parent.url.resolve(UUID.randomUUID().toString() + "/").toString()) + } else { + binding.homesetLayout.error = getString(R.string.create_collection_home_set_required) + ok = false + } + + val displayName = model.displayName.value + if (displayName.isNullOrBlank()) { + model.displayNameError.value = getString(R.string.create_collection_display_name_required) + ok = false + } else { + args.putString(CreateCollectionFragment.ARG_DISPLAY_NAME, displayName) + model.displayNameError.value = null + } + + StringUtils.trimToNull(model.description.value)?.let { + args.putString(CreateCollectionFragment.ARG_DESCRIPTION, it) + } + + if (ok) { + args.putParcelable(CreateCollectionFragment.ARG_ACCOUNT, model.account) + args.putString(CreateCollectionFragment.ARG_TYPE, Collection.TYPE_ADDRESSBOOK) + val frag = CreateCollectionFragment() + frag.arguments = args + frag.show(supportFragmentManager, null) + } + } + + + class Model @AssistedInject constructor( + val db: AppDatabase, + @Assisted val account: Account + ) : ViewModel() { + + @AssistedFactory + interface Factory { + fun create(account: Account): Model + } + + val displayName = MutableLiveData() + val displayNameError = MutableLiveData() + + val description = MutableLiveData() + + val homeSets = MutableLiveData>() + var homeSet: HomeSet? = null + + init { + viewModelScope.launch(Dispatchers.IO) { + // load account info + db.serviceDao().getByAccountAndType(account.name, Service.TYPE_CARDDAV)?.let { service -> + homeSets.postValue(db.homeSetDao().getBindableByService(service.id)) + } + } + } + + fun clearNameError(s: Editable) { + displayNameError.value = null + } + + } + +} diff --git a/app/src/main/java/at/bitfire/davdroid/ui/CreateCalendarActivity.kt b/app/src/main/java/at/bitfire/davdroid/ui/account/CreateCalendarActivity.kt similarity index 53% rename from app/src/main/java/at/bitfire/davdroid/ui/CreateCalendarActivity.kt rename to app/src/main/java/at/bitfire/davdroid/ui/account/CreateCalendarActivity.kt index ae151048c756e53f6e2538aaa9a2ca01baad5d47..830368e85d8e13e37e1cdb778d04632e64426af4 100644 --- a/app/src/main/java/at/bitfire/davdroid/ui/CreateCalendarActivity.kt +++ b/app/src/main/java/at/bitfire/davdroid/ui/account/CreateCalendarActivity.kt @@ -1,75 +1,102 @@ -/* - * Copyright © Ricki Hirner (bitfire web engineering). - * All rights reserved. This program and the accompanying materials - * are made available under the terms of the GNU Public License v3.0 - * which accompanies this distribution, and is available at - * http://www.gnu.org/licenses/gpl.html - */ +/*************************************************************************************************** + * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. + **************************************************************************************************/ -package at.bitfire.davdroid.ui +package at.bitfire.davdroid.ui.account import android.accounts.Account -import android.app.Application import android.content.Context import android.content.Intent import android.graphics.drawable.ColorDrawable import android.os.Bundle -import android.view.Menu -import android.view.MenuItem +import android.view.* import android.widget.ArrayAdapter -import android.widget.Filter -import androidx.annotation.MainThread +import android.widget.TextView +import androidx.activity.viewModels import androidx.appcompat.app.AppCompatActivity import androidx.core.app.NavUtils import androidx.databinding.DataBindingUtil -import androidx.lifecycle.AndroidViewModel import androidx.lifecycle.MutableLiveData -import androidx.lifecycle.ViewModelProviders +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import androidx.lifecycle.viewModelScope import at.bitfire.davdroid.Constants import at.bitfire.davdroid.R import at.bitfire.davdroid.databinding.ActivityCreateCalendarBinding -import at.bitfire.davdroid.model.AppDatabase -import at.bitfire.davdroid.model.Collection -import at.bitfire.davdroid.model.Service -import at.bitfire.davdroid.ui.account.AccountActivity +import at.bitfire.davdroid.db.AppDatabase +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 com.jaredrummler.android.colorpicker.ColorPickerDialog import com.jaredrummler.android.colorpicker.ColorPickerDialogListener -import kotlinx.android.synthetic.main.activity_create_calendar.* +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject +import dagger.hilt.android.AndroidEntryPoint +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch import net.fortuna.ical4j.model.Calendar import org.apache.commons.lang3.StringUtils +import java.time.ZoneId +import java.time.format.TextStyle import java.util.* -import kotlin.concurrent.thread +import javax.inject.Inject +@AndroidEntryPoint class CreateCalendarActivity: AppCompatActivity(), ColorPickerDialogListener { companion object { const val EXTRA_ACCOUNT = "account" } - private lateinit var model: Model + @Inject lateinit var modelFactory: Model.Factory + val model by viewModels() { + object: ViewModelProvider.Factory { + @Suppress("UNCHECKED_CAST") + override fun create(modelClass: Class): T { + val account = intent.getParcelableExtra(EXTRA_ACCOUNT) ?: throw IllegalArgumentException("EXTRA_ACCOUNT must be set") + return modelFactory.create(account) as T + } + } + } + + lateinit var binding: ActivityCreateCalendarBinding + override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) supportActionBar?.setDisplayHomeAsUpEnabled(true) - model = ViewModelProviders.of(this).get(Model::class.java) - (intent?.getParcelableExtra(EXTRA_ACCOUNT) as? Account)?.let { - model.initialize(it) - } - - val binding = DataBindingUtil.setContentView(this, R.layout.activity_create_calendar) + binding = DataBindingUtil.setContentView(this, R.layout.activity_create_calendar) binding.lifecycleOwner = this binding.model = model binding.color.setOnClickListener { ColorPickerDialog.newBuilder() .setShowAlphaSlider(false) - .setColor((color.background as ColorDrawable).color) + .setColor((binding.color.background as ColorDrawable).color) .show(this) } - binding.timezone.setAdapter(model.timezones) + val homeSetAdapter = HomeSetAdapter(this) + model.homeSets.observe(this) { homeSets -> + homeSetAdapter.clear() + if (homeSets.isNotEmpty()) { + homeSetAdapter.addAll(homeSets) + val firstHomeSet = homeSets.first() + binding.homeset.setText(firstHomeSet.url.toString(), false) + model.homeSet = firstHomeSet + } + } + binding.homeset.setAdapter(homeSetAdapter) + binding.homeset.setOnItemClickListener { parent, _, position, _ -> + model.homeSet = parent.getItemAtPosition(position) as HomeSet? + } + + binding.timezone.setAdapter(TimeZoneAdapter(this)) + binding.timezone.setText(TimeZone.getDefault().id, false) } override fun onColorSelected(dialogId: Int, rgb: Int) { @@ -100,8 +127,17 @@ class CreateCalendarActivity: AppCompatActivity(), ColorPickerDialogListener { val args = Bundle() args.putString(CreateCollectionFragment.ARG_SERVICE_TYPE, Service.TYPE_CALDAV) - val parent = model.homeSets.value?.getItem(model.idxHomeSet.value!!) ?: return - args.putString(CreateCollectionFragment.ARG_URL, parent.url.resolve(UUID.randomUUID().toString() + "/").toString()) + val parent = model.homeSet + if (parent != null) { + binding.homesetLayout.error = null + args.putString( + CreateCollectionFragment.ARG_URL, + parent.url.resolve(UUID.randomUUID().toString() + "/").toString() + ) + } else { + binding.homesetLayout.error = getString(R.string.create_collection_home_set_required) + ok = false + } val displayName = model.displayName.value if (displayName.isNullOrBlank()) { @@ -120,12 +156,11 @@ class CreateCalendarActivity: AppCompatActivity(), ColorPickerDialogListener { args.putInt(CreateCollectionFragment.ARG_COLOR, it) } - val tzId = model.timezone.value - if (tzId.isNullOrBlank()) { - model.timezoneError.value = getString(R.string.create_calendar_time_zone_required) + val tzId = binding.timezone.text?.toString() + if (tzId.isNullOrBlank()) ok = false - } else { - DateUtils.tzRegistry.getTimeZone(tzId)?.let { tz -> + else { + DateUtils.ical4jTimeZone(tzId)?.let { tz -> val cal = Calendar() cal.components += tz.vTimeZone args.putString(CreateCollectionFragment.ARG_TIMEZONE, cal.toString()) @@ -142,7 +177,7 @@ class CreateCalendarActivity: AppCompatActivity(), ColorPickerDialogListener { } else model.typeError.value = null - if (!supportsVEVENT || !supportsVTODO || !supportsVJOURNAL) { + if (supportsVEVENT || supportsVTODO || supportsVJOURNAL) { // only if there's at least one component set not supported; don't include // information about supported components otherwise (means: everything supported) args.putBoolean(CreateCollectionFragment.ARG_SUPPORTS_VEVENT, supportsVEVENT) @@ -159,42 +194,39 @@ class CreateCalendarActivity: AppCompatActivity(), ColorPickerDialogListener { } } - class TimeZoneAdapter( - context: Context - ): ArrayAdapter(context, android.R.layout.simple_list_item_1, android.R.id.text1) { - - val tz = TimeZone.getAvailableIDs()!! - - override fun getFilter(): Filter { - return object: Filter() { - override fun performFiltering(constraint: CharSequence?): FilterResults { - val filtered = constraint?.let { - tz.filter { it.contains(constraint, true) } - } ?: listOf() - val results = FilterResults() - results.values = filtered - results.count = filtered.size - return results - } - override fun publishResults(constraint: CharSequence?, results: FilterResults) { - clear() - @Suppress("UNCHECKED_CAST") addAll(results.values as List) - if (results.count >= 0) - notifyDataSetChanged() - else - notifyDataSetInvalidated() - } - } + + class TimeZoneAdapter(context: Context): ArrayAdapter(context, R.layout.text_list_item, android.R.id.text1) { + + init { + addAll(TimeZone.getAvailableIDs().toList()) + } + + override fun getView(position: Int, convertView: View?, parent: ViewGroup): View { + val tzId = getItem(position)!! + val tz = ZoneId.of(tzId) + + val v: View = convertView ?: LayoutInflater.from(context).inflate(R.layout.text_list_item, parent, false) + v.findViewById(android.R.id.text1).text = tz.id + v.findViewById(android.R.id.text2).text = tz.getDisplayName(TextStyle.FULL, Locale.getDefault()) + + return v } + override fun getDropDownView(position: Int, convertView: View?, parent: ViewGroup) = + getView(position, convertView, parent) + } - class Model( - application: Application - ): AndroidViewModel(application) { + class Model @AssistedInject constructor( + val db: AppDatabase, + @Assisted val account: Account + ): ViewModel() { - var account: Account? = null + @AssistedFactory + interface Factory { + fun create(account: Account): Model + } val displayName = MutableLiveData() val displayNameError = MutableLiveData() @@ -202,11 +234,9 @@ class CreateCalendarActivity: AppCompatActivity(), ColorPickerDialogListener { val description = MutableLiveData() val color = MutableLiveData() - val homeSets = MutableLiveData() - val idxHomeSet = MutableLiveData() + val homeSets = MutableLiveData>() + var homeSet: HomeSet? = null - val timezones = TimeZoneAdapter(application) - val timezone = MutableLiveData() val timezoneError = MutableLiveData() val typeError = MutableLiveData() @@ -214,37 +244,21 @@ class CreateCalendarActivity: AppCompatActivity(), ColorPickerDialogListener { val supportVTODO = MutableLiveData() val supportVJOURNAL = MutableLiveData() - @MainThread - fun initialize(account: Account) { - if (this.account != null) - return - this.account = account - + init { color.value = Constants.DAVDROID_GREEN_RGBA - timezone.value = TimeZone.getDefault().id - supportVEVENT.value = true supportVTODO.value = true supportVJOURNAL.value = true - thread { + viewModelScope.launch(Dispatchers.IO) { // load account info - val adapter = HomeSetAdapter(getApplication()) - - val db = AppDatabase.getInstance(getApplication()) db.serviceDao().getByAccountAndType(account.name, Service.TYPE_CALDAV)?.let { service -> - val homeSets = db.homeSetDao().getBindableByService(service.id) - adapter.addAll(homeSets) - } - - if (!adapter.isEmpty) { - homeSets.postValue(adapter) - idxHomeSet.postValue(0) + homeSets.postValue(db.homeSetDao().getBindableByService(service.id)) } } } } -} +} \ No newline at end of file diff --git a/app/src/main/java/at/bitfire/davdroid/ui/CreateCollectionFragment.kt b/app/src/main/java/at/bitfire/davdroid/ui/account/CreateCollectionFragment.kt similarity index 69% rename from app/src/main/java/at/bitfire/davdroid/ui/CreateCollectionFragment.kt rename to app/src/main/java/at/bitfire/davdroid/ui/account/CreateCollectionFragment.kt index a59a4f53d9c804d2dff4356738a6e771f02e27f8..0414e0c61da7e89c1b41477c779d82f92480cec2 100644 --- a/app/src/main/java/at/bitfire/davdroid/ui/CreateCollectionFragment.kt +++ b/app/src/main/java/at/bitfire/davdroid/ui/account/CreateCollectionFragment.kt @@ -1,36 +1,44 @@ -/* - * Copyright © Ricki Hirner (bitfire web engineering). - * All rights reserved. This program and the accompanying materials - * are made available under the terms of the GNU Public License v3.0 - * which accompanies this distribution, and is available at - * http://www.gnu.org/licenses/gpl.html - */ +/*************************************************************************************************** + * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. + **************************************************************************************************/ -package at.bitfire.davdroid.ui +package at.bitfire.davdroid.ui.account import android.accounts.Account -import android.app.Application +import android.content.Context import android.os.Bundle import android.view.LayoutInflater import android.view.View import android.view.ViewGroup import androidx.fragment.app.DialogFragment +import androidx.fragment.app.viewModels import androidx.lifecycle.* import at.bitfire.dav4jvm.DavResource import at.bitfire.dav4jvm.XmlUtils +import at.bitfire.davdroid.DavService import at.bitfire.davdroid.DavUtils import at.bitfire.davdroid.HttpClient import at.bitfire.davdroid.R +import at.bitfire.davdroid.db.AppDatabase +import at.bitfire.davdroid.db.Collection import at.bitfire.davdroid.log.Logger -import at.bitfire.davdroid.model.AppDatabase -import at.bitfire.davdroid.model.Collection import at.bitfire.davdroid.settings.AccountSettings -import okhttp3.HttpUrl +import at.bitfire.davdroid.ui.ExceptionInfoFragment +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject +import dagger.hilt.android.AndroidEntryPoint +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.NonCancellable +import kotlinx.coroutines.launch +import okhttp3.HttpUrl.Companion.toHttpUrl import java.io.IOException import java.io.StringWriter import java.util.logging.Level -import kotlin.concurrent.thread +import javax.inject.Inject +@AndroidEntryPoint class CreateCollectionFragment: DialogFragment() { companion object { @@ -50,37 +58,45 @@ class CreateCollectionFragment: DialogFragment() { const val ARG_SUPPORTS_VJOURNAL = "supportsVJOURNAL" } - private lateinit var model: Model + @Inject lateinit var modelFactory: Model.Factory + val model by viewModels() { + object : ViewModelProvider.Factory { + @Suppress("UNCHECKED_CAST") + override fun create(modelClass: Class): T { + val args = requireArguments() + + val account: Account = args.getParcelable(ARG_ACCOUNT) ?: throw IllegalArgumentException("ARG_ACCOUNT required") + val serviceType = args.getString(ARG_SERVICE_TYPE) ?: throw java.lang.IllegalArgumentException("ARG_SERVICE_TYPE required") + val collection = Collection( + type = args.getString(ARG_TYPE) ?: throw IllegalArgumentException("ARG_TYPE required"), + url = (args.getString(ARG_URL) ?: throw IllegalArgumentException("ARG_URL required")).toHttpUrl(), + displayName = args.getString(ARG_DISPLAY_NAME), + description = args.getString(ARG_DESCRIPTION), + + color = args.ifDefined(ARG_COLOR) { it.getInt(ARG_COLOR) }, + timezone = args.getString(ARG_TIMEZONE), + supportsVEVENT = args.ifDefined(ARG_SUPPORTS_VEVENT) { it.getBoolean(ARG_SUPPORTS_VEVENT) }, + supportsVTODO = args.ifDefined(ARG_SUPPORTS_VTODO) { it.getBoolean(ARG_SUPPORTS_VTODO) }, + supportsVJOURNAL = args.ifDefined(ARG_SUPPORTS_VJOURNAL) { it.getBoolean(ARG_SUPPORTS_VJOURNAL) }, + + sync = true /* by default, sync collections which just have been created */ + ) + + return modelFactory.create(account, serviceType, collection) as T + } + } + } + override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - val args = arguments ?: throw IllegalArgumentException() - - model = ViewModelProviders.of(this).get(Model::class.java) - model.account = args.getParcelable(ARG_ACCOUNT) ?: throw IllegalArgumentException("ARG_ACCOUNT required") - model.serviceType = args.getString(ARG_SERVICE_TYPE) ?: throw java.lang.IllegalArgumentException("ARG_SERVICE_TYPE required") - - model.collection = Collection( - type = args.getString(ARG_TYPE) ?: throw IllegalArgumentException("ARG_TYPE required"), - url = HttpUrl.parse(args.getString(ARG_URL) ?: throw IllegalArgumentException("ARG_URL required"))!!, - displayName = args.getString(ARG_DISPLAY_NAME), - description = args.getString(ARG_DESCRIPTION), - - color = args.ifDefined(ARG_COLOR) { it.getInt(ARG_COLOR) }, - timezone = args.getString(ARG_TIMEZONE), - supportsVEVENT = args.ifDefined(ARG_SUPPORTS_VEVENT) { it.getBoolean(ARG_SUPPORTS_VEVENT) }, - supportsVTODO = args.ifDefined(ARG_SUPPORTS_VTODO) { it.getBoolean(ARG_SUPPORTS_VTODO) }, - supportsVJOURNAL = args.ifDefined(ARG_SUPPORTS_VJOURNAL) { it.getBoolean(ARG_SUPPORTS_VJOURNAL) }, - - sync = true /* by default, sync collections which just have been created */ - ) model.createCollection().observe(this, Observer { exception -> if (exception == null) requireActivity().finish() else { dismiss() - requireFragmentManager().beginTransaction() + parentFragmentManager.beginTransaction() .add(ExceptionInfoFragment.newInstance(exception, model.account), null) .commit() } @@ -100,19 +116,24 @@ class CreateCollectionFragment: DialogFragment() { } - class Model( - application: Application - ): AndroidViewModel(application) { - - lateinit var account: Account - lateinit var serviceType: String - lateinit var collection: Collection + class Model @AssistedInject constructor( + @ApplicationContext val context: Context, + val db: AppDatabase, + @Assisted val account: Account, + @Assisted val serviceType: String, + @Assisted val collection: Collection + ): ViewModel() { + + @AssistedFactory + interface Factory { + fun create(account: Account, serviceType: String, collection: Collection): Model + } val result = MutableLiveData() fun createCollection(): LiveData { - thread { - HttpClient.Builder(getApplication(), AccountSettings(getApplication(), account)) + viewModelScope.launch(Dispatchers.IO + NonCancellable) { + HttpClient.Builder(context, AccountSettings(context, account)) .setForeground(true) .build().use { httpClient -> try { @@ -122,10 +143,12 @@ class CreateCollectionFragment: DialogFragment() { dav.mkCol(generateXml()) {} // no HTTP error -> create collection locally - val db = AppDatabase.getInstance(getApplication()) db.serviceDao().getByAccountAndType(account.name, serviceType)?.let { service -> collection.serviceId = service.id db.collectionDao().insert(collection) + + // trigger service detection (because the collection may have other properties than the ones we have inserted) + DavService.refreshCollections(context, service.id) } // post success diff --git a/app/src/main/java/at/bitfire/davdroid/ui/DeleteCollectionFragment.kt b/app/src/main/java/at/bitfire/davdroid/ui/account/DeleteCollectionFragment.kt similarity index 55% rename from app/src/main/java/at/bitfire/davdroid/ui/DeleteCollectionFragment.kt rename to app/src/main/java/at/bitfire/davdroid/ui/account/DeleteCollectionFragment.kt index 0dd9399868e91327e50908ad2cce579e0ee4c498..cfcec86c506b43f595ba2e432cec8a23103ce52b 100644 --- a/app/src/main/java/at/bitfire/davdroid/ui/DeleteCollectionFragment.kt +++ b/app/src/main/java/at/bitfire/davdroid/ui/account/DeleteCollectionFragment.kt @@ -1,30 +1,36 @@ -/* - * Copyright © Ricki Hirner (bitfire web engineering). - * All rights reserved. This program and the accompanying materials - * are made available under the terms of the GNU Public License v3.0 - * which accompanies this distribution, and is available at - * http://www.gnu.org/licenses/gpl.html - */ +/*************************************************************************************************** + * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. + **************************************************************************************************/ -package at.bitfire.davdroid.ui +package at.bitfire.davdroid.ui.account import android.accounts.Account -import android.app.Application +import android.content.Context import android.os.Bundle import android.view.LayoutInflater import android.view.View import android.view.ViewGroup -import androidx.annotation.MainThread import androidx.fragment.app.DialogFragment +import androidx.fragment.app.viewModels import androidx.lifecycle.* import at.bitfire.dav4jvm.DavResource import at.bitfire.davdroid.HttpClient import at.bitfire.davdroid.databinding.DeleteCollectionBinding -import at.bitfire.davdroid.model.AppDatabase -import at.bitfire.davdroid.model.Collection +import at.bitfire.davdroid.db.AppDatabase +import at.bitfire.davdroid.db.Collection import at.bitfire.davdroid.settings.AccountSettings -import kotlin.concurrent.thread - +import at.bitfire.davdroid.ui.ExceptionInfoFragment +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject +import dagger.hilt.android.AndroidEntryPoint +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.NonCancellable +import kotlinx.coroutines.launch +import javax.inject.Inject + +@AndroidEntryPoint class DeleteCollectionFragment: DialogFragment() { companion object { @@ -41,18 +47,20 @@ class DeleteCollectionFragment: DialogFragment() { } } - private lateinit var model: DeleteCollectionModel - - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - model = ViewModelProviders.of(this).get(DeleteCollectionModel::class.java) - model.initialize( - arguments!!.getParcelable(ARG_ACCOUNT)!!, - arguments!!.getLong(ARG_COLLECTION_ID) - ) + @Inject lateinit var modelFactory: Model.Factory + val model by viewModels() { + object : ViewModelProvider.Factory { + @Suppress("UNCHECKED_CAST") + override fun create(modelClass: Class): T = + modelFactory.create( + requireArguments().getParcelable(ARG_ACCOUNT) ?: throw IllegalArgumentException("ARG_ACCOUNT required"), + requireArguments().getLong(ARG_COLLECTION_ID) + ) as T + } } - override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? { + + override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View { val binding = DeleteCollectionBinding.inflate(layoutInflater, null, false) binding.lifecycleOwner = this binding.model = model @@ -62,9 +70,9 @@ class DeleteCollectionFragment: DialogFragment() { binding.progress.visibility = View.VISIBLE binding.controls.visibility = View.GONE - model.deleteCollection().observe(this, Observer { exception -> + model.deleteCollection().observe(viewLifecycleOwner, Observer { exception -> if (exception != null) - requireFragmentManager().beginTransaction() + parentFragmentManager.beginTransaction() .add(ExceptionInfoFragment.newInstance(exception, model.account), null) .commit() dismiss() @@ -79,35 +87,33 @@ class DeleteCollectionFragment: DialogFragment() { } - class DeleteCollectionModel( - application: Application - ): AndroidViewModel(application) { + class Model @AssistedInject constructor( + @ApplicationContext val context: Context, + val db: AppDatabase, + @Assisted var account: Account, + @Assisted val collectionId: Long + ): ViewModel() { - var account: Account? = null - var collectionInfo: Collection? = null + @AssistedFactory + interface Factory { + fun create(account: Account, collectionId: Long): Model + } - val db = AppDatabase.getInstance(application) + var collectionInfo: Collection? = null val confirmation = MutableLiveData() val result = MutableLiveData() - @MainThread - fun initialize(account: Account, collectionId: Long) { - if (this.account == null) - this.account = account - - if (collectionInfo == null) - thread { - collectionInfo = db.collectionDao().get(collectionId) - } + init { + viewModelScope.launch(Dispatchers.IO) { + collectionInfo = db.collectionDao().get(collectionId) + } } fun deleteCollection(): LiveData { - thread { - val account = account ?: return@thread - val collectionInfo = collectionInfo ?: return@thread + viewModelScope.launch(Dispatchers.IO + NonCancellable) { + val collectionInfo = collectionInfo ?: return@launch - val context = getApplication() HttpClient.Builder(context, AccountSettings(context, account)) .setForeground(true) .build().use { httpClient -> @@ -134,4 +140,4 @@ class DeleteCollectionFragment: DialogFragment() { } -} +} \ No newline at end of file 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 1b4138be26010db2d70a0c345d9ce31195f97fe9..59f2d82bfb123757da687acfe71e019e727dc5b9 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 @@ -1,42 +1,53 @@ +/*************************************************************************************************** + * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. + **************************************************************************************************/ + package at.bitfire.davdroid.ui.account import android.Manifest import android.accounts.Account import android.accounts.AccountManager import android.annotation.SuppressLint -import android.app.Application import android.app.Dialog import android.content.ContentResolver +import android.content.Context import android.content.DialogInterface import android.content.pm.PackageManager -import android.os.Build import android.os.Bundle -import android.os.Looper import android.provider.CalendarContract import android.provider.ContactsContract import android.widget.EditText import android.widget.LinearLayout -import androidx.annotation.RequiresApi +import android.widget.Toast +import androidx.annotation.WorkerThread import androidx.core.content.ContextCompat import androidx.fragment.app.DialogFragment -import androidx.lifecycle.AndroidViewModel +import androidx.fragment.app.viewModels import androidx.lifecycle.MutableLiveData import androidx.lifecycle.Observer -import androidx.lifecycle.ViewModelProviders +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope import at.bitfire.davdroid.DavUtils +import at.bitfire.davdroid.InvalidAccountException import at.bitfire.davdroid.R import at.bitfire.davdroid.closeCompat +import at.bitfire.davdroid.db.AppDatabase import at.bitfire.davdroid.log.Logger -import at.bitfire.davdroid.model.AppDatabase import at.bitfire.davdroid.resource.LocalAddressBook import at.bitfire.davdroid.resource.LocalTaskList import at.bitfire.davdroid.settings.AccountSettings import at.bitfire.ical4android.TaskProvider import com.google.android.material.dialog.MaterialAlertDialogBuilder +import dagger.hilt.android.AndroidEntryPoint +import dagger.hilt.android.lifecycle.HiltViewModel +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.NonCancellable +import kotlinx.coroutines.launch import java.util.logging.Level -import kotlin.concurrent.thread +import javax.inject.Inject -@RequiresApi(Build.VERSION_CODES.LOLLIPOP) +@AndroidEntryPoint class RenameAccountFragment: DialogFragment() { companion object { @@ -53,9 +64,12 @@ class RenameAccountFragment: DialogFragment() { } + val model by viewModels() + + @SuppressLint("Recycle") override fun onCreateDialog(savedInstanceState: Bundle?): Dialog { - val oldAccount: Account = arguments!!.getParcelable(ARG_ACCOUNT)!! + val oldAccount: Account = requireArguments().getParcelable(ARG_ACCOUNT)!! val editText = EditText(requireActivity()).apply { setText(oldAccount.name) @@ -66,61 +80,72 @@ class RenameAccountFragment: DialogFragment() { layout.setPadding(8*density, 8*density, 8*density, 8*density) layout.addView(editText) - val model = ViewModelProviders.of(this).get(Model::class.java) model.finished.observe(this, Observer { this@RenameAccountFragment.requireActivity().finish() }) - return MaterialAlertDialogBuilder(requireActivity()) + return MaterialAlertDialogBuilder(requireActivity(), R.style.CustomAlertDialogStyle) .setTitle(R.string.account_rename) .setMessage(R.string.account_rename_new_name) .setView(layout) .setPositiveButton(R.string.account_rename_rename, DialogInterface.OnClickListener { _, _ -> val newName = editText.text.toString() - if (newName == oldAccount.name) return@OnClickListener model.renameAccount(oldAccount, newName) + + requireActivity().finish() }) .setNegativeButton(android.R.string.cancel) { _, _ -> } .create() } - class Model( - application: Application - ): AndroidViewModel(application) { + @HiltViewModel + class Model @Inject constructor( + @ApplicationContext val context: Context, + val db: AppDatabase + ): ViewModel() { val finished = MutableLiveData() fun renameAccount(oldAccount: Account, newName: String) { - val context = getApplication() - - thread { - // remember sync intervals - val oldSettings = AccountSettings(context, oldAccount) - val authorities = arrayOf( - context.getString(R.string.address_books_authority), - CalendarContract.AUTHORITY, - TaskProvider.ProviderName.OpenTasks.authority - ) - val syncIntervals = authorities.map { Pair(it, oldSettings.getSyncInterval(it)) } - - val accountManager = AccountManager.get(context) + // remember sync intervals + val oldSettings = try { + AccountSettings(context, oldAccount) + } catch (e: InvalidAccountException) { + Toast.makeText(context, R.string.account_invalid, Toast.LENGTH_LONG).show() + finished.value = true + return + } + + val authorities = arrayOf( + context.getString(R.string.address_books_authority), + CalendarContract.AUTHORITY, + TaskProvider.ProviderName.OpenTasks.authority + ) + val syncIntervals = authorities.map { Pair(it, oldSettings.getSyncInterval(it)) } + + val accountManager = AccountManager.get(context) + try { accountManager.renameAccount(oldAccount, newName, { - thread { - onAccountRenamed(accountManager, oldAccount, newName, syncIntervals) - } + if (it.result?.name == newName /* success */) + viewModelScope.launch(Dispatchers.Default + NonCancellable) { + onAccountRenamed(accountManager, oldAccount, newName, syncIntervals) + } }, null) + } catch (e: Exception) { + Logger.log.log(Level.WARNING, "Couldn't rename account", e) + Toast.makeText(context, R.string.account_rename_couldnt_rename, Toast.LENGTH_LONG).show() } } @SuppressLint("Recycle") + @WorkerThread fun onAccountRenamed(accountManager: AccountManager, oldAccount: Account, newName: String, syncIntervals: List>) { // account has now been renamed Logger.log.info("Updating account name references") - val context = getApplication() // cancel maybe running synchronization ContentResolver.cancelSync(oldAccount, null) @@ -128,23 +153,27 @@ class RenameAccountFragment: DialogFragment() { ContentResolver.cancelSync(addrBookAccount, null) // update account name references in database - val db = AppDatabase.getInstance(context) - Logger.log.log(Level.INFO, "Main thread", Looper.getMainLooper().thread) - Logger.log.log(Level.INFO, "Current thread", Thread.currentThread()) - db.serviceDao().renameAccount(oldAccount.name, newName) + try { + db.serviceDao().renameAccount(oldAccount.name, newName) + } catch (e: Exception) { + Toast.makeText(context, R.string.account_rename_couldnt_rename, Toast.LENGTH_LONG).show() + Logger.log.log(Level.SEVERE, "Couldn't update service DB", e) + return + } // update main account of address book accounts if (ContextCompat.checkSelfPermission(context, Manifest.permission.WRITE_CONTACTS) == PackageManager.PERMISSION_GRANTED) try { context.contentResolver.acquireContentProviderClient(ContactsContract.AUTHORITY)?.let { provider -> - for (addrBookAccount in accountManager.getAccountsByType(context.getString(R.string.account_type_address_book))) - try { + try { + for (addrBookAccount in accountManager.getAccountsByType(context.getString(R.string.account_type_address_book))) { val addressBook = LocalAddressBook(context, addrBookAccount, provider) if (oldAccount == addressBook.mainAccount) addressBook.mainAccount = Account(newName, oldAccount.type) - } finally { - provider.closeCompat() } + } finally { + provider.closeCompat() + } } } catch (e: Exception) { Logger.log.log(Level.SEVERE, "Couldn't update address book accounts", e) @@ -155,7 +184,7 @@ class RenameAccountFragment: DialogFragment() { // update account_name of local tasks try { - LocalTaskList.onRenameAccount(context.contentResolver, oldAccount.name, newName) + LocalTaskList.onRenameAccount(context, oldAccount.name, newName) } catch (e: Exception) { Logger.log.log(Level.SEVERE, "Couldn't propagate new account name to tasks provider", e) } 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 57360b6f716c6608c342d818dabc472f0377c167..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 @@ -1,55 +1,69 @@ -/* - * Copyright © Ricki Hirner (bitfire web engineering). - * All rights reserved. This program and the accompanying materials - * are made available under the terms of the GNU Public License v3.0 - * which accompanies this distribution, and is available at - * http://www.gnu.org/licenses/gpl.html - */ +/*************************************************************************************************** + * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. + **************************************************************************************************/ package at.bitfire.davdroid.ui.account -import android.Manifest import android.accounts.Account -import android.app.Application +import android.accounts.AccountManager +import android.annotation.SuppressLint import android.content.ContentResolver +import android.content.Context import android.content.Intent import android.content.SyncStatusObserver -import android.content.pm.PackageManager import android.os.Build import android.os.Bundle import android.provider.CalendarContract import android.security.KeyChain -import android.view.MenuItem +import android.text.InputType +import android.view.View +import android.widget.Toast import androidx.appcompat.app.AppCompatActivity -import androidx.core.app.NavUtils -import androidx.core.content.ContextCompat +import androidx.core.app.TaskStackBuilder import androidx.fragment.app.DialogFragment -import androidx.lifecycle.* +import androidx.fragment.app.viewModels +import androidx.lifecycle.MediatorLiveData +import androidx.lifecycle.MutableLiveData +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider import androidx.preference.* -import at.bitfire.davdroid.App +import at.bitfire.davdroid.InvalidAccountException +import at.bitfire.davdroid.PermissionUtils import at.bitfire.davdroid.R -import at.bitfire.davdroid.model.Credentials +import at.bitfire.davdroid.db.Credentials +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.SyncAdapterService +import at.bitfire.davdroid.ui.UiUtils import at.bitfire.ical4android.TaskProvider import at.bitfire.vcard4android.GroupMethod -import com.google.android.material.dialog.MaterialAlertDialogBuilder +import com.google.android.material.snackbar.Snackbar +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject +import dagger.hilt.android.AndroidEntryPoint +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch import org.apache.commons.lang3.StringUtils +import javax.inject.Inject +@AndroidEntryPoint class SettingsActivity: AppCompatActivity() { companion object { const val EXTRA_ACCOUNT = "account" } - private lateinit var account: Account + private val account by lazy { intent.getParcelableExtra(EXTRA_ACCOUNT) ?: throw IllegalArgumentException("EXTRA_ACCOUNT must be set") } override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - account = intent.getParcelableExtra(EXTRA_ACCOUNT) ?: throw IllegalArgumentException("EXTRA_ACCOUNT must be set") title = getString(R.string.settings_title, account.name) supportActionBar?.setDisplayHomeAsUpEnabled(true) @@ -60,43 +74,63 @@ class SettingsActivity: AppCompatActivity() { .commit() } - override fun onOptionsItemSelected(item: MenuItem) = - if (item.itemId == android.R.id.home) { - val intent = Intent(this, AccountActivity::class.java) - intent.putExtra(AccountActivity.EXTRA_ACCOUNT, account) - NavUtils.navigateUpTo(this, intent) - true - } else - false + override fun supportShouldUpRecreateTask(targetIntent: Intent) = true + override fun onPrepareSupportNavigateUpTaskStack(builder: TaskStackBuilder) { + builder.editIntentAt(builder.intentCount - 1)?.putExtra(AccountActivity.EXTRA_ACCOUNT, account) + } - class AccountSettingsFragment: PreferenceFragmentCompat() { - private lateinit var settings: Settings - lateinit var account: Account - - private lateinit var model: Model - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - settings = Settings.getInstance(requireActivity()) - account = arguments!!.getParcelable(EXTRA_ACCOUNT)!! + @AndroidEntryPoint + class AccountSettingsFragment(): PreferenceFragmentCompat() { - model = ViewModelProviders.of(this).get(Model::class.java) - model.initialize(account) + private val account by lazy { requireArguments().getParcelable(EXTRA_ACCOUNT)!! } + @Inject lateinit var settings: SettingsManager - initSettings() + @Inject lateinit var modelFactory: Model.Factory + val model by viewModels { + object: ViewModelProvider.Factory { + @Suppress("UNCHECKED_CAST") + override fun create(modelClass: Class) = + modelFactory.create(account) as T + } } + override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) { addPreferencesFromResource(R.xml.settings_account) + + findPreference(getString(R.string.settings_password_key))!!.setOnBindEditTextListener { password -> + password.inputType = InputType.TYPE_CLASS_TEXT.or(InputType.TYPE_TEXT_VARIATION_PASSWORD) + } } - private fun initSettings() { - //val accountSettings = AccountSettings(requireActivity(), account) + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + try { + initSettings() + } catch (e: InvalidAccountException) { + Toast.makeText(context, R.string.account_invalid, Toast.LENGTH_LONG).show() + requireActivity().finish() + } + } + + override fun onResume() { + super.onResume() + 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 { - model.syncIntervalContacts.observe(this, Observer { interval -> + model.syncIntervalContacts.observe(viewLifecycleOwner, { interval: Long? -> if (interval != null) { it.isEnabled = true it.isVisible = true @@ -106,7 +140,7 @@ class SettingsActivity: AppCompatActivity() { else it.summary = getString(R.string.settings_sync_summary_periodically, interval / 60) it.onPreferenceChangeListener = Preference.OnPreferenceChangeListener { pref, newValue -> - pref.isEnabled = false + pref.isEnabled = false // disable until updated setting is read from system again model.updateSyncInterval(getString(R.string.address_books_authority), (newValue as String).toLong()) false } @@ -115,7 +149,7 @@ class SettingsActivity: AppCompatActivity() { }) } findPreference(getString(R.string.settings_sync_interval_calendars_key))!!.let { - model.syncIntervalCalendars.observe(this, Observer { interval -> + model.syncIntervalCalendars.observe(viewLifecycleOwner, { interval: Long? -> if (interval != null) { it.isEnabled = true it.isVisible = true @@ -134,8 +168,9 @@ class SettingsActivity: AppCompatActivity() { }) } findPreference(getString(R.string.settings_sync_interval_tasks_key))!!.let { - model.syncIntervalTasks.observe(this, Observer { interval -> - if (interval != null) { + model.syncIntervalTasks.observe(viewLifecycleOwner, { interval: Long? -> + val provider = model.tasksProvider + if (provider != null && interval != null) { it.isEnabled = true it.isVisible = true it.value = interval.toString() @@ -145,7 +180,7 @@ class SettingsActivity: AppCompatActivity() { it.summary = getString(R.string.settings_sync_summary_periodically, interval / 60) it.onPreferenceChangeListener = Preference.OnPreferenceChangeListener { pref, newValue -> pref.isEnabled = false - model.updateSyncInterval(TaskProvider.ProviderName.OpenTasks.authority, (newValue as String).toLong()) + model.updateSyncInterval(provider.authority, (newValue as String).toLong()) false } } else @@ -154,8 +189,8 @@ class SettingsActivity: AppCompatActivity() { } findPreference(getString(R.string.settings_sync_wifi_only_key))!!.let { - model.syncWifiOnly.observe(this, Observer { wifiOnly -> - it.isEnabled = !settings.has(AccountSettings.KEY_WIFI_ONLY) + model.syncWifiOnly.observe(viewLifecycleOwner, { wifiOnly -> + it.isEnabled = !settings.containsKey(AccountSettings.KEY_WIFI_ONLY) it.isChecked = wifiOnly it.onPreferenceChangeListener = Preference.OnPreferenceChangeListener { _, wifiOnly -> model.updateSyncWifiOnly(wifiOnly as Boolean) @@ -164,11 +199,16 @@ class SettingsActivity: AppCompatActivity() { }) } - findPreference("sync_wifi_only_ssids")!!.let { - model.syncWifiOnlySSIDs.observe(this, Observer { onlySSIDs -> + findPreference(getString(R.string.settings_sync_wifi_only_ssids_key))!!.let { + model.syncWifiOnly.observe(viewLifecycleOwner, { wifiOnly -> + it.isEnabled = wifiOnly && settings.isWritable(AccountSettings.KEY_WIFI_ONLY_SSIDS) + }) + model.syncWifiOnlySSIDs.observe(viewLifecycleOwner, { onlySSIDs -> + checkWifiPermissions() + if (onlySSIDs != null) { it.text = onlySSIDs.joinToString(", ") - it.summary = getString(if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O_MR1) + it.summary = getString(if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) R.string.settings_sync_wifi_only_ssids_on_location_services else R.string.settings_sync_wifi_only_ssids_on, onlySSIDs.joinToString(", ")) } else { @@ -186,207 +226,220 @@ class SettingsActivity: AppCompatActivity() { }) } - model.askForPermissions.observe(this, Observer { permissions -> - if (permissions.any { ContextCompat.checkSelfPermission(requireActivity(), it) != PackageManager.PERMISSION_GRANTED }) { - if (permissions.any { shouldShowRequestPermissionRationale(it) }) - // show rationale before requesting permissions - MaterialAlertDialogBuilder(requireActivity()) - .setIcon(R.drawable.ic_network_wifi_dark) - .setTitle(R.string.settings_sync_wifi_only_ssids) - .setMessage(R.string.settings_sync_wifi_only_ssids_location_permission) - .setPositiveButton(android.R.string.ok) { _, _ -> - requestPermissions(permissions.toTypedArray(), 0) - } - .setNeutralButton(R.string.settings_more_info_faq) { _, _ -> - val faqUrl = App.homepageUrl(requireActivity()).buildUpon() - .appendPath("faq").appendPath("wifi-ssid-restriction-location-permission") - .build() - val intent = Intent(Intent.ACTION_VIEW, faqUrl) - startActivity(Intent.createChooser(intent, null)) - } - .show() - else - // request permissions without rationale - requestPermissions(permissions.toTypedArray(), 0) - } - }) - // preference group: authentication + val prefCredentials = findPreference("credentials")!! val prefUserName = findPreference("username")!! val prefPassword = findPreference("password")!! val prefCertAlias = findPreference("certificate_alias")!! - model.credentials.observe(this, Observer { credentials -> - when (credentials.type) { - Credentials.Type.UsernamePassword -> { - prefUserName.isVisible = true - prefUserName.summary = credentials.userName - prefUserName.text = credentials.userName - prefUserName.onPreferenceChangeListener = Preference.OnPreferenceChangeListener { _, newValue -> - model.updateCredentials(Credentials(newValue as String, credentials.password)) - false - } - - prefPassword.isVisible = true - prefPassword.onPreferenceChangeListener = Preference.OnPreferenceChangeListener { _, newValue -> - model.updateCredentials(Credentials(credentials.userName, newValue as String)) - false - } + model.credentials.observe(viewLifecycleOwner, { credentials -> + prefUserName.summary = credentials.userName + prefUserName.text = credentials.userName + prefUserName.onPreferenceChangeListener = Preference.OnPreferenceChangeListener { _, newUserName -> + model.updateCredentials(Credentials(newUserName as String, credentials.password, credentials.authState, credentials.certificateAlias)) + false + } - prefCertAlias.isVisible = false - } - Credentials.Type.ClientCertificate -> { - prefUserName.isVisible = false + if (credentials.userName != null) { + if (credentials.authState != null) { prefPassword.isVisible = false - - prefCertAlias.isVisible = true - prefCertAlias.summary = credentials.certificateAlias - prefCertAlias.setOnPreferenceClickListener { - KeyChain.choosePrivateKeyAlias(requireActivity(), { alias -> - model.updateCredentials(Credentials(certificateAlias = alias)) - }, null, null, null, -1, credentials.certificateAlias) - true - } + 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, credentials.authState, newAlias)) + }, null, null, null, -1, credentials.certificateAlias) + true } }) // preference group: CalDAV - findPreference(getString(R.string.settings_sync_time_range_past_key))!!.let { - model.timeRangePastDays.observe(this, Observer { pastDays -> - if (model.syncIntervalCalendars.value != null) { - it.isVisible = true - if (pastDays != null) { - it.text = pastDays.toString() - it.summary = resources.getQuantityString(R.plurals.settings_sync_time_range_past_days, pastDays, pastDays) - } else { - it.text = null - it.setSummary(R.string.settings_sync_time_range_past_none) - } - it.onPreferenceChangeListener = Preference.OnPreferenceChangeListener { _, newValue -> - val days = try { - (newValue as String).toInt() - } catch(e: NumberFormatException) { - -1 - } - model.updateTimeRangePastDays(if (days < 0) null else days) - false - } - } else - it.isVisible = false - }) - } - - findPreference(getString(R.string.settings_key_default_alarm))!!.let { - model.defaultAlarmMinBefore.observe(this, Observer { minBefore -> - if (minBefore != null) { - it.text = minBefore.toString() - it.summary = resources.getQuantityString(R.plurals.settings_default_alarm_on, minBefore, minBefore) - } else { - it.text = null - it.summary = getString(R.string.settings_default_alarm_off) + model.hasCalDav.observe(viewLifecycleOwner, { hasCalDav -> + if (!hasCalDav) + findPreference(getString(R.string.settings_caldav_key))!!.isVisible = false + else { + findPreference(getString(R.string.settings_caldav_key))!!.isVisible = true + + // when model.hasCalDav is available, model.syncInterval* are also available + // (because hasCalDav is calculated from them) + val hasCalendars = model.syncIntervalCalendars.value != null + + findPreference(getString(R.string.settings_sync_time_range_past_key))!!.let { pref -> + if (hasCalendars) + model.timeRangePastDays.observe(viewLifecycleOwner, { pastDays -> + if (model.syncIntervalCalendars.value != null) { + pref.isVisible = true + if (pastDays != null) { + pref.text = pastDays.toString() + pref.summary = resources.getQuantityString(R.plurals.settings_sync_time_range_past_days, pastDays, pastDays) + } else { + pref.text = null + pref.setSummary(R.string.settings_sync_time_range_past_none) + } + pref.onPreferenceChangeListener = Preference.OnPreferenceChangeListener { _, newValue -> + val days = try { + (newValue as String).toInt() + } catch(e: NumberFormatException) { + -1 + } + model.updateTimeRangePastDays(if (days < 0) null else days) + false + } + } else + pref.isVisible = false + }) + else + pref.isVisible = false } - it.onPreferenceChangeListener = Preference.OnPreferenceChangeListener { _, newValue -> - val minBefore = try { - (newValue as String).toInt() - } catch (e: java.lang.NumberFormatException) { - null - } - model.updateDefaultAlarm(minBefore) - false + + findPreference(getString(R.string.settings_key_default_alarm))!!.let { pref -> + if (hasCalendars) + model.defaultAlarmMinBefore.observe(viewLifecycleOwner, { minBefore -> + pref.isVisible = true + if (minBefore != null) { + pref.text = minBefore.toString() + pref.summary = resources.getQuantityString(R.plurals.settings_default_alarm_on, minBefore, minBefore) + } else { + pref.text = null + pref.summary = getString(R.string.settings_default_alarm_off) + } + pref.onPreferenceChangeListener = Preference.OnPreferenceChangeListener { _, newValue -> + val minBefore = try { + (newValue as String).toInt() + } catch (e: java.lang.NumberFormatException) { + null + } + model.updateDefaultAlarm(minBefore) + false + } + }) + else + pref.isVisible = false } - }) - } - findPreference(getString(R.string.settings_manage_calendar_colors_key))!!.let { - model.manageCalendarColors.observe(this, Observer { manageCalendarColors -> - if (model.syncIntervalCalendars.value != null || model.syncIntervalTasks.value != null) { - it.isVisible = true - it.isEnabled = !settings.has(AccountSettings.KEY_MANAGE_CALENDAR_COLORS) - it.isChecked = manageCalendarColors - it.onPreferenceChangeListener = Preference.OnPreferenceChangeListener { _, newValue -> - model.updateManageCalendarColors(newValue as Boolean) - false - } - } else - it.isVisible = false - }) - } + findPreference(getString(R.string.settings_manage_calendar_colors_key))!!.let { + model.manageCalendarColors.observe(viewLifecycleOwner, { manageCalendarColors -> + it.isEnabled = !settings.containsKey(AccountSettings.KEY_MANAGE_CALENDAR_COLORS) + it.isChecked = manageCalendarColors + it.onPreferenceChangeListener = Preference.OnPreferenceChangeListener { _, newValue -> + model.updateManageCalendarColors(newValue as Boolean) + false + } + }) + } - findPreference(getString(R.string.settings_event_colors_key))!!.let { - model.eventColors.observe(this, Observer { eventColors -> - if (model.syncIntervalCalendars.value != null) { - it.isVisible = true - it.isEnabled = !settings.has(AccountSettings.KEY_EVENT_COLORS) - it.isChecked = eventColors - it.onPreferenceChangeListener = Preference.OnPreferenceChangeListener { _, newValue -> - model.updateEventColors(newValue as Boolean) - false - } - } else - it.isVisible = false - }) - } + findPreference(getString(R.string.settings_event_colors_key))!!.let { pref -> + if (hasCalendars) + model.eventColors.observe(viewLifecycleOwner, { eventColors -> + pref.isVisible = true + pref.isEnabled = !settings.containsKey(AccountSettings.KEY_EVENT_COLORS) + pref.isChecked = eventColors + pref.onPreferenceChangeListener = Preference.OnPreferenceChangeListener { _, newValue -> + model.updateEventColors(newValue as Boolean) + false + } + }) + else + pref.isVisible = false + } + } + }) // preference group: CardDAV - findPreference(getString(R.string.settings_contact_group_method_key))!!.let { - model.contactGroupMethod.observe(this, Observer { groupMethod -> - if (model.syncIntervalContacts.value != null) { - it.isVisible = true - it.value = groupMethod.name - it.summary = it.entry - if (settings.has(AccountSettings.KEY_CONTACT_GROUP_METHOD)) - it.isEnabled = false - else { - it.isEnabled = true - it.onPreferenceChangeListener = Preference.OnPreferenceChangeListener { _, groupMethod -> - model.updateContactGroupMethod(GroupMethod.valueOf(groupMethod as String)) - false - } - } - } else - it.isVisible = false - }) - } + model.syncIntervalContacts.observe(viewLifecycleOwner, { contactsSyncInterval -> + val hasCardDav = contactsSyncInterval != null + if (!hasCardDav) + findPreference(getString(R.string.settings_carddav_key))!!.isVisible = false + else { + findPreference(getString(R.string.settings_carddav_key))!!.isVisible = true + findPreference(getString(R.string.settings_contact_group_method_key))!!.let { + model.contactGroupMethod.observe(viewLifecycleOwner, { groupMethod -> + if (model.syncIntervalContacts.value != null) { + it.isVisible = true + it.value = groupMethod.name + it.summary = it.entry + if (settings.containsKey(AccountSettings.KEY_CONTACT_GROUP_METHOD)) + it.isEnabled = false + else { + it.isEnabled = true + it.onPreferenceChangeListener = Preference.OnPreferenceChangeListener { _, groupMethod -> + model.updateContactGroupMethod(GroupMethod.valueOf(groupMethod as String)) + false + } + } + } else + it.isVisible = false + }) + } + } + }) } - override fun onRequestPermissionsResult(requestCode: Int, permissions: Array, grantResults: IntArray) { - super.onRequestPermissionsResult(requestCode, permissions, grantResults) - - if (grantResults.any { it == PackageManager.PERMISSION_DENIED }) { - // location permission denied, reset SSID restriction - model.updateSyncWifiOnlySSIDs(null) - - MaterialAlertDialogBuilder(requireActivity()) - .setIcon(R.drawable.ic_network_wifi_dark) - .setTitle(R.string.settings_sync_wifi_only_ssids) - .setMessage(R.string.settings_sync_wifi_only_ssids_location_permission) - .setPositiveButton(android.R.string.ok) { _, _ -> } - .setNeutralButton(R.string.settings_more_info_faq) { _, _ -> - val faqUrl = App.homepageUrl(requireActivity()).buildUpon() - .appendPath("faq").appendPath("wifi-ssid-restriction-location-permission") - .build() - val intent = Intent(Intent.ACTION_VIEW, faqUrl) - startActivity(Intent.createChooser(intent, null)) + @SuppressLint("WrongConstant") + private fun checkWifiPermissions() { + if (model.syncWifiOnlySSIDs.value != null && !PermissionUtils.canAccessWifiSsid(requireActivity())) + Snackbar.make(requireView(), R.string.settings_sync_wifi_only_ssids_permissions_required, UiUtils.SNACKBAR_LENGTH_VERY_LONG) + .setAction(R.string.settings_sync_wifi_only_ssids_permissions_action) { + val intent = Intent(requireActivity(), WifiPermissionsActivity::class.java) + intent.putExtra(WifiPermissionsActivity.EXTRA_ACCOUNT, account) + startActivity(intent) } - .show() - } + .show() } } - class Model(app: Application): AndroidViewModel(app), SyncStatusObserver, Settings.OnChangeListener { + class Model @AssistedInject constructor( + @ApplicationContext val context: Context, + val settings: SettingsManager, + @Assisted val account: Account + ): ViewModel(), SyncStatusObserver, SettingsManager.OnChangeListener { + + @AssistedFactory + interface Factory { + fun create(account: Account): Model + } - private var account: Account? = null private var accountSettings: AccountSettings? = null - private val settings = Settings.getInstance(app) private var statusChangeListener: Any? = null // settings val syncIntervalContacts = MutableLiveData() val syncIntervalCalendars = MutableLiveData() + val tasksProvider = TaskUtils.currentProvider(context) val syncIntervalTasks = MutableLiveData() + val hasCalDav = object: MediatorLiveData() { + init { + addSource(syncIntervalCalendars) { calculate() } + addSource(syncIntervalTasks) { calculate() } + } + private fun calculate() { + value = syncIntervalCalendars.value != null || syncIntervalTasks.value != null + } + } + val syncWifiOnly = MutableLiveData() val syncWifiOnlySSIDs = MutableLiveData>() @@ -399,41 +452,9 @@ class SettingsActivity: AppCompatActivity() { val contactGroupMethod = MutableLiveData() - // derived values - val askForPermissions = object: MediatorLiveData>() { - init { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O_MR1) { - addSource(syncWifiOnly) { calculate() } - addSource(syncWifiOnlySSIDs) { calculate() } - } - } - private fun calculate() { - val wifiOnly = syncWifiOnly.value ?: return - val wifiOnlySSIDs = syncWifiOnlySSIDs.value ?: return - - val permissions = mutableListOf() - if (wifiOnly && wifiOnlySSIDs.isNotEmpty()) { - // Android 8.1+: getting the WiFi name requires location permission (and active location services) - permissions += Manifest.permission.ACCESS_FINE_LOCATION - - // Android 10+: getting the Wifi name in the background (= while syncing) requires extra permission - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) - permissions += Manifest.permission.ACCESS_BACKGROUND_LOCATION - } - - if (permissions != value) - postValue(permissions) - } - } - - - fun initialize(account: Account) { - if (this.account != null) - // already initialized - return - this.account = account - accountSettings = AccountSettings(getApplication(), account) + init { + accountSettings = AccountSettings(context, account) settings.addOnChangeListener(this) statusChangeListener = ContentResolver.addStatusChangeListener(ContentResolver.SYNC_OBSERVER_TYPE_SETTINGS, this) @@ -446,25 +467,28 @@ class SettingsActivity: AppCompatActivity() { statusChangeListener?.let { ContentResolver.removeStatusChangeListener(it) + statusChangeListener = null } settings.removeOnChangeListener(this) } override fun onStatusChanged(which: Int) { + Logger.log.info("Sync settings changed") reload() } override fun onSettingsChanged() { + Logger.log.info("Settings changed") reload() } - private fun reload() { + fun reload() { val accountSettings = accountSettings ?: return - val context = getApplication() syncIntervalContacts.postValue(accountSettings.getSyncInterval(context.getString(R.string.address_books_authority))) syncIntervalCalendars.postValue(accountSettings.getSyncInterval(CalendarContract.AUTHORITY)) - syncIntervalTasks.postValue(accountSettings.getSyncInterval(TaskProvider.ProviderName.OpenTasks.authority)) + syncIntervalTasks.postValue(tasksProvider?.let { accountSettings.getSyncInterval(it.authority) }) + syncWifiOnly.postValue(accountSettings.getSyncWifiOnly()) syncWifiOnlySSIDs.postValue(accountSettings.getSyncWifiOnlySSIDs()) @@ -480,8 +504,10 @@ class SettingsActivity: AppCompatActivity() { fun updateSyncInterval(authority: String, syncInterval: Long) { - accountSettings?.setSyncInterval(authority, syncInterval) - reload() + CoroutineScope(Dispatchers.Default).launch { + accountSettings?.setSyncInterval(authority, syncInterval) + reload() + } } fun updateSyncWifiOnly(wifiOnly: Boolean) { @@ -503,7 +529,12 @@ class SettingsActivity: AppCompatActivity() { accountSettings?.setTimeRangePastDays(days) reload() - resyncCalendars(fullResync = false, tasks = false) + /* If the new setting is a certain number of days, no full resync is required, + because every sync will cause a REPORT calendar-query with the given number of days. + However, if the new setting is "all events", collection sync may/should be used, so + the last sync-token has to be reset, which is done by setting fullResync=true. + */ + resyncCalendars(fullResync = days == null, tasks = false) } fun updateDefaultAlarm(minBefore: Int?) { @@ -531,7 +562,7 @@ class SettingsActivity: AppCompatActivity() { accountSettings?.setGroupMethod(groupMethod) reload() - resync(getApplication().getString(R.string.address_books_authority), fullResync = true) + resync(context.getString(R.string.address_books_authority), fullResync = true) } /** diff --git a/app/src/main/java/at/bitfire/davdroid/ui/account/WebcalFragment.kt b/app/src/main/java/at/bitfire/davdroid/ui/account/WebcalFragment.kt index fe980c48cea4f3670ee365b632766700b3655265..b89bc0ca35e94f4af87388db72245540461b11bf 100644 --- a/app/src/main/java/at/bitfire/davdroid/ui/account/WebcalFragment.kt +++ b/app/src/main/java/at/bitfire/davdroid/ui/account/WebcalFragment.kt @@ -1,8 +1,10 @@ +/*************************************************************************************************** + * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. + **************************************************************************************************/ + package at.bitfire.davdroid.ui.account import android.Manifest -import android.app.Activity -import android.app.Application import android.content.ContentProviderClient import android.content.Context import android.content.Intent @@ -10,89 +12,107 @@ import android.content.pm.PackageManager import android.database.ContentObserver import android.net.Uri import android.os.Bundle -import android.os.Handler -import android.os.HandlerThread import android.provider.CalendarContract import android.provider.CalendarContract.Calendars -import android.view.Menu -import android.view.MenuInflater -import android.view.View -import android.view.ViewGroup +import android.view.* import androidx.annotation.WorkerThread import androidx.core.content.ContextCompat +import androidx.fragment.app.viewModels import androidx.lifecycle.* import androidx.room.Transaction import at.bitfire.dav4jvm.UrlUtils import at.bitfire.davdroid.Constants +import at.bitfire.davdroid.PermissionUtils import at.bitfire.davdroid.R import at.bitfire.davdroid.closeCompat +import at.bitfire.davdroid.databinding.AccountCaldavItemBinding +import at.bitfire.davdroid.db.AppDatabase +import at.bitfire.davdroid.db.Collection import at.bitfire.davdroid.log.Logger -import at.bitfire.davdroid.model.AppDatabase -import at.bitfire.davdroid.model.Collection import com.google.android.material.snackbar.Snackbar -import kotlinx.android.synthetic.main.account_caldav_item.view.* +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject +import dagger.hilt.android.AndroidEntryPoint +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch import okhttp3.HttpUrl +import okhttp3.HttpUrl.Companion.toHttpUrlOrNull import java.util.logging.Level +import javax.inject.Inject +import kotlin.collections.component1 +import kotlin.collections.component2 +import kotlin.collections.set +@AndroidEntryPoint class WebcalFragment: CollectionsFragment() { override val noCollectionsStringId = R.string.account_no_webcals - private lateinit var webcalModel: WebcalModel + @Inject lateinit var webcalModelFactory: WebcalModel.Factory + val webcalModel by viewModels() { + object : ViewModelProvider.Factory { + @Suppress("UNCHECKED_CAST") + override fun create(modelClass: Class) = + webcalModelFactory.create( + requireArguments().getLong(EXTRA_SERVICE_ID) + ) as T + } + } override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - webcalModel = ViewModelProviders.of(this).get(WebcalModel::class.java) - webcalModel.calendarPermission.observe(this, Observer { granted -> - if (!granted) - requestPermissions(arrayOf(Manifest.permission.READ_CALENDAR), 0) - }) webcalModel.subscribedUrls.observe(this, Observer { urls -> Logger.log.log(Level.FINE, "Got Android calendar list", urls.keys) }) - - webcalModel.initialize(arguments?.getLong(EXTRA_SERVICE_ID) ?: throw IllegalArgumentException("EXTRA_SERVICE_ID required")) } - override fun onRequestPermissionsResult(requestCode: Int, permissions: Array, grantResults: IntArray) = - webcalModel.calendarPermission.check() - override fun onCreateOptionsMenu(menu: Menu, inflater: MenuInflater) = inflater.inflate(R.menu.caldav_actions, menu) override fun onPrepareOptionsMenu(menu: Menu) { super.onPrepareOptionsMenu(menu) - menu.findItem(R.id.create).isVisible = false + menu.findItem(R.id.create_calendar).isVisible = false } - override fun createAdapter(): CollectionAdapter = WebcalAdapter(accountModel, webcalModel) + override fun checkPermissions() { + if (PermissionUtils.havePermissions(requireActivity(), PermissionUtils.CALENDAR_PERMISSIONS)) + binding.permissionsCard.visibility = View.GONE + else { + binding.permissionsText.setText(R.string.account_webcal_missing_calendar_permissions) + binding.permissionsCard.visibility = View.VISIBLE + } + } + + override fun createAdapter(): CollectionAdapter = WebcalAdapter(accountModel, webcalModel, this) class CalendarViewHolder( - private val parent: ViewGroup, - accountModel: AccountActivity.Model, - private val webcalModel: WebcalModel - ): CollectionViewHolder(parent, R.layout.account_caldav_item, accountModel) { + private val parent: ViewGroup, + accountModel: AccountActivity.Model, + private val webcalModel: WebcalModel, + private val webcalFragment: WebcalFragment + ): CollectionViewHolder(parent, AccountCaldavItemBinding.inflate(LayoutInflater.from(parent.context), parent, false), accountModel) { override fun bindTo(item: Collection) { - val v = itemView - v.color.setBackgroundColor(item.color ?: Constants.DAVDROID_GREEN_RGBA) + binding.color.setBackgroundColor(item.color ?: Constants.DAVDROID_GREEN_RGBA) - v.sync.isChecked = item.sync - v.title.text = item.title() + binding.sync.isChecked = item.sync + binding.title.text = item.title() if (item.description.isNullOrBlank()) - v.description.visibility = View.GONE + binding.description.visibility = View.GONE else { - v.description.text = item.description - v.description.visibility = View.VISIBLE + binding.description.text = item.description + binding.description.visibility = View.VISIBLE } - v.read_only.visibility = View.VISIBLE - v.events.visibility = if (item.supportsVEVENT == true) View.VISIBLE else View.GONE - v.tasks.visibility = if (item.supportsVTODO == true) View.VISIBLE else View.GONE + binding.readOnly.visibility = View.VISIBLE + binding.events.visibility = if (item.supportsVEVENT == true) View.VISIBLE else View.GONE + binding.tasks.visibility = if (item.supportsVTODO == true) View.VISIBLE else View.GONE itemView.setOnClickListener { if (item.sync) @@ -100,7 +120,7 @@ class WebcalFragment: CollectionsFragment() { else subscribe(item) } - v.action_overflow.setOnClickListener(CollectionPopupListener(accountModel, item)) + binding.actionOverflow.setOnClickListener(CollectionPopupListener(accountModel, item, webcalFragment.parentFragmentManager)) } private fun subscribe(item: Collection) { @@ -116,7 +136,7 @@ class WebcalFragment: CollectionsFragment() { Logger.log.info("Intent: ${intent.extras}") - val activity = parent.context as Activity + val activity = webcalFragment.requireActivity() if (activity.packageManager.resolveActivity(intent, 0) != null) activity.startActivity(intent) else { @@ -136,40 +156,36 @@ class WebcalFragment: CollectionsFragment() { } class WebcalAdapter( - accountModel: AccountActivity.Model, - private val webcalModel: WebcalModel + accountModel: AccountActivity.Model, + private val webcalModel: WebcalModel, + val webcalFragment: WebcalFragment ): CollectionAdapter(accountModel) { override fun onCreateViewHolder(parent: ViewGroup, viewType: Int) = - CalendarViewHolder(parent, accountModel, webcalModel) + CalendarViewHolder(parent, accountModel, webcalModel, webcalFragment) } - class WebcalModel(application: Application): AndroidViewModel(application) { + class WebcalModel @AssistedInject constructor( + @ApplicationContext context: Context, + val db: AppDatabase, + @Assisted val serviceId: Long + ): ViewModel() { - private var initialized = false - private var serviceId: Long = 0 - - private val workerThread = HandlerThread(javaClass.simpleName) - init { workerThread.start() } - val workerHandler = Handler(workerThread.looper) + @AssistedFactory + interface Factory { + fun create(serviceId: Long): WebcalModel + } - private val db = AppDatabase.getInstance(application) - private val resolver = application.contentResolver + private val resolver = context.contentResolver - val calendarPermission = CalendarPermission(application) + private var calendarPermission = false private val calendarProvider = object: MediatorLiveData() { - var havePermission = false - init { - addSource(calendarPermission) { granted -> - havePermission = granted - if (granted) - connect() - else - disconnect() - } + calendarPermission = ContextCompat.checkSelfPermission(context, Manifest.permission.READ_CALENDAR) == PackageManager.PERMISSION_GRANTED + if (calendarPermission) + connect() } override fun onActive() { @@ -178,7 +194,7 @@ class WebcalFragment: CollectionsFragment() { } fun connect() { - if (havePermission && value == null) + if (calendarPermission && value == null) value = resolver.acquireContentProviderClient(CalendarContract.AUTHORITY) } @@ -192,7 +208,7 @@ class WebcalFragment: CollectionsFragment() { value = null } } - val subscribedUrls = object: MediatorLiveData>() { + val subscribedUrls = object: MediatorLiveData>() { var provider: ContentProviderClient? = null var observer: ContentObserver? = null @@ -214,15 +230,17 @@ class WebcalFragment: CollectionsFragment() { private fun connect() { unregisterObserver() provider?.let { provider -> - val newObserver = object: ContentObserver(workerHandler) { + val newObserver = object: ContentObserver(null) { override fun onChange(selfChange: Boolean) { - queryCalendars(provider) + viewModelScope.launch(Dispatchers.IO) { + queryCalendars(provider) + } } } - getApplication().contentResolver.registerContentObserver(Calendars.CONTENT_URI, false, newObserver) + context.contentResolver.registerContentObserver(Calendars.CONTENT_URI, false, newObserver) observer = newObserver - workerHandler.post { + viewModelScope.launch(Dispatchers.IO) { queryCalendars(provider) } } @@ -235,7 +253,7 @@ class WebcalFragment: CollectionsFragment() { private fun unregisterObserver() { observer?.let { - application.contentResolver.unregisterContentObserver(it) + context.contentResolver.unregisterContentObserver(it) observer = null } } @@ -244,19 +262,19 @@ class WebcalFragment: CollectionsFragment() { @Transaction private fun queryCalendars(provider: ContentProviderClient) { // query subscribed URLs from Android calendar list - val subscriptions = mutableMapOf() + val subscriptions = mutableMapOf() provider.query(Calendars.CONTENT_URI, arrayOf(Calendars._ID, Calendars.NAME),null, null, null)?.use { cursor -> while (cursor.moveToNext()) cursor.getString(1)?.let { rawName -> - HttpUrl.parse(rawName)?.let { url -> - subscriptions[url] = cursor.getLong(0) + rawName.toHttpUrlOrNull()?.let { url -> + subscriptions[cursor.getLong(0)] = url } } } // update "sync" field in database accordingly (will update UI) db.collectionDao().getByServiceAndType(serviceId, Collection.TYPE_WEBCAL).forEach { webcal -> - val newSync = subscriptions.keys + val newSync = subscriptions.values .any { webcal.source?.let { source -> UrlUtils.equals(source, it) } ?: false } if (newSync != webcal.sync) db.collectionDao().update(webcal.copy(sync = newSync)) @@ -267,19 +285,13 @@ class WebcalFragment: CollectionsFragment() { } - fun initialize(dbServiceId: Long) { - if (initialized) - return - initialized = true - - serviceId = dbServiceId - calendarPermission.check() - } - fun unsubscribe(webcal: Collection) { - workerHandler.post { - subscribedUrls.value?.get(webcal.source)?.let { id -> - // delete subscription from Android calendar list + viewModelScope.launch(Dispatchers.IO) { + // find first matching source (Webcal) URL + subscribedUrls.value?.entries?.firstOrNull { (_, source) -> + UrlUtils.equals(source, webcal.source!!) + }?.key?.let { id -> + // delete first matching subscription from Android calendar list calendarProvider.value?.delete(Calendars.CONTENT_URI, "${Calendars._ID}=?", arrayOf(id.toString())) } @@ -288,14 +300,4 @@ class WebcalFragment: CollectionsFragment() { } - class CalendarPermission(val context: Context): LiveData() { - init { - check() - } - - fun check() { - value = ContextCompat.checkSelfPermission(context, Manifest.permission.READ_CALENDAR) == PackageManager.PERMISSION_GRANTED - } - } - } \ No newline at end of file diff --git a/app/src/main/java/at/bitfire/davdroid/ui/account/WifiPermissionsActivity.kt b/app/src/main/java/at/bitfire/davdroid/ui/account/WifiPermissionsActivity.kt new file mode 100644 index 0000000000000000000000000000000000000000..679ce8fb3eb7f5f2a4fac45eed1d3a7ab5e5f24e --- /dev/null +++ b/app/src/main/java/at/bitfire/davdroid/ui/account/WifiPermissionsActivity.kt @@ -0,0 +1,168 @@ +/*************************************************************************************************** + * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. + **************************************************************************************************/ + +package at.bitfire.davdroid.ui.account + +import android.Manifest +import android.accounts.Account +import android.app.Application +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import android.content.IntentFilter +import android.content.pm.PackageManager +import android.location.LocationManager +import android.os.Build +import android.os.Bundle +import android.provider.Settings +import androidx.activity.viewModels +import androidx.appcompat.app.AppCompatActivity +import androidx.core.app.ActivityCompat +import androidx.core.app.TaskStackBuilder +import androidx.core.content.ContextCompat +import androidx.core.content.ContextCompat.getSystemService +import androidx.core.location.LocationManagerCompat +import androidx.core.text.HtmlCompat +import androidx.lifecycle.AndroidViewModel +import androidx.lifecycle.MutableLiveData +import at.bitfire.davdroid.PermissionUtils +import at.bitfire.davdroid.R +import at.bitfire.davdroid.databinding.ActivityWifiPermissionsBinding +import at.bitfire.davdroid.log.Logger + +class WifiPermissionsActivity: AppCompatActivity() { + + companion object { + const val EXTRA_ACCOUNT = "account" + } + + private val account by lazy { intent.getParcelableExtra(EXTRA_ACCOUNT) ?: throw IllegalArgumentException("EXTRA_ACCOUNT must be set") } + private val model by viewModels() + + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + if (!PermissionUtils.WIFI_SSID_PERMISSIONS.all { perm -> PermissionUtils.declaresPermission(packageManager, perm) }) + throw IllegalArgumentException("WiFi SSID restriction requires location permissions") + + supportActionBar?.setDisplayHomeAsUpEnabled(true) + + val binding = ActivityWifiPermissionsBinding.inflate(layoutInflater) + binding.lifecycleOwner = this + binding.model = model + setContentView(binding.root) + + model.needLocation.observe(this) { needPermission -> + if (needPermission && model.haveLocation.value == false) + ActivityCompat.requestPermissions(this, arrayOf(model.PERMISSION_LOCATION), 0) + } + + model.haveBackgroundLocation.observe(this) { status -> + val label = if (Build.VERSION.SDK_INT >= 30) + packageManager.backgroundPermissionOptionLabel + else + getString(R.string.wifi_permissions_background_location_permission_label) + binding.backgroundLocationStatus.text = HtmlCompat.fromHtml(getString( + if (status) R.string.wifi_permissions_background_location_permission_on else R.string.wifi_permissions_background_location_permission_off, + label + ), HtmlCompat.FROM_HTML_MODE_LEGACY) + } + model.needBackgroundLocation.observe(this) { needPermission -> + if (needPermission && model.haveBackgroundLocation.value == false) + ActivityCompat.requestPermissions(this, arrayOf(Manifest.permission.ACCESS_BACKGROUND_LOCATION), 0) + } + binding.backgroundLocationDisclaimer.text = getString(R.string.wifi_permissions_background_location_disclaimer, getString(R.string.app_name)) + + binding.settingsBtn.setOnClickListener { + PermissionUtils.showAppSettings(this) + } + + model.needLocationEnabled.observe(this) { needLocation -> + if (needLocation != null && needLocation != model.isLocationEnabled.value) { + val intent = Intent(Settings.ACTION_LOCATION_SOURCE_SETTINGS) + if (intent.resolveActivity(packageManager) != null) + startActivity(intent) + else + Logger.log.warning("Couldn't resolve Location settings Intent") + } + } + } + + override fun supportShouldUpRecreateTask(targetIntent: Intent) = true + + override fun onPrepareSupportNavigateUpTaskStack(builder: TaskStackBuilder) { + builder.editIntentAt(builder.intentCount - 1)?.putExtra(SettingsActivity.EXTRA_ACCOUNT, account) + } + + override fun onResume() { + super.onResume() + model.checkPermissions() + } + + override fun onRequestPermissionsResult(requestCode: Int, permissions: Array, grantResults: IntArray) { + super.onRequestPermissionsResult(requestCode, permissions, grantResults) + model.checkPermissions() + } + + + class Model(app: Application): AndroidViewModel(app) { + + val PERMISSION_LOCATION = + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) + Manifest.permission.ACCESS_FINE_LOCATION // since Android 10, fine location is required + else + Manifest.permission.ACCESS_COARSE_LOCATION // Android 8+: coarse location is enough + + val haveLocation = MutableLiveData() + val needLocation = MutableLiveData() + + val haveBackgroundLocation = MutableLiveData() + val needBackgroundLocation = MutableLiveData() + + val isLocationEnabled = MutableLiveData() + val needLocationEnabled = MutableLiveData() + val locationModeWatcher = object: BroadcastReceiver() { + override fun onReceive(context: Context, intent: Intent) { + checkPermissions() + } + } + + init { + app.registerReceiver(locationModeWatcher, IntentFilter(LocationManager.MODE_CHANGED_ACTION)) + checkPermissions() + } + + override fun onCleared() { + getApplication().unregisterReceiver(locationModeWatcher) + } + + fun checkPermissions() { + // Android 8.1+: location permission + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O_MR1) { + val location = ContextCompat.checkSelfPermission(getApplication(), PERMISSION_LOCATION) == PackageManager.PERMISSION_GRANTED + haveLocation.value = location + needLocation.value = location + } + + // Android 9+: location service + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { + getSystemService(getApplication(), LocationManager::class.java)?.let { locationManager -> + val locationEnabled = LocationManagerCompat.isLocationEnabled(locationManager) + isLocationEnabled.value = locationEnabled + needLocationEnabled.value = locationEnabled + } + } + + // Android 10+: background location permission + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + val backgroundLocation = ContextCompat.checkSelfPermission(getApplication(), Manifest.permission.ACCESS_BACKGROUND_LOCATION) == PackageManager.PERMISSION_GRANTED + haveBackgroundLocation.value = backgroundLocation + needBackgroundLocation.value = backgroundLocation + } + } + + } + +} \ No newline at end of file diff --git a/app/src/main/java/at/bitfire/davdroid/ui/intro/BatteryOptimizationsFragment.kt b/app/src/main/java/at/bitfire/davdroid/ui/intro/BatteryOptimizationsFragment.kt new file mode 100644 index 0000000000000000000000000000000000000000..c578b2c6f380fce44375d73cdc479e92c487d9f0 --- /dev/null +++ b/app/src/main/java/at/bitfire/davdroid/ui/intro/BatteryOptimizationsFragment.kt @@ -0,0 +1,209 @@ +/*************************************************************************************************** + * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. + **************************************************************************************************/ + +package at.bitfire.davdroid.ui.intro + +import android.annotation.SuppressLint +import android.content.Context +import android.content.Intent +import android.net.Uri +import android.os.Build +import android.os.Bundle +import android.os.PowerManager +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.core.content.getSystemService +import androidx.databinding.ObservableBoolean +import androidx.fragment.app.Fragment +import androidx.fragment.app.viewModels +import androidx.lifecycle.MutableLiveData +import androidx.lifecycle.ViewModel +import at.bitfire.davdroid.App +import at.bitfire.davdroid.BuildConfig +import at.bitfire.davdroid.R +import at.bitfire.davdroid.databinding.IntroBatteryOptimizationsBinding +import at.bitfire.davdroid.settings.SettingsManager +import at.bitfire.davdroid.ui.UiUtils +import at.bitfire.davdroid.ui.intro.BatteryOptimizationsFragment.Model.Companion.HINT_AUTOSTART_PERMISSION +import at.bitfire.davdroid.ui.intro.BatteryOptimizationsFragment.Model.Companion.HINT_BATTERY_OPTIMIZATIONS +import dagger.Binds +import dagger.Module +import dagger.hilt.InstallIn +import dagger.hilt.android.AndroidEntryPoint +import dagger.hilt.android.components.ActivityComponent +import dagger.hilt.android.lifecycle.HiltViewModel +import dagger.hilt.android.qualifiers.ApplicationContext +import dagger.multibindings.IntoSet +import org.apache.commons.text.WordUtils +import java.util.* +import javax.inject.Inject + +@AndroidEntryPoint +class BatteryOptimizationsFragment: Fragment() { + + companion object { + const val REQUEST_IGNORE_BATTERY_OPTIMIZATIONS = 0 + } + + val model by viewModels() + + + override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View { + val binding = IntroBatteryOptimizationsBinding.inflate(inflater, container, false) + binding.lifecycleOwner = viewLifecycleOwner + binding.model = model + + model.shouldBeWhitelisted.observe(viewLifecycleOwner, { shouldBeWhitelisted -> + @SuppressLint("BatteryLife") + if (shouldBeWhitelisted && !model.isWhitelisted.value!! && Build.VERSION.SDK_INT >= 23) + startActivityForResult(Intent( + android.provider.Settings.ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS, + Uri.parse("package:" + BuildConfig.APPLICATION_ID) + ), REQUEST_IGNORE_BATTERY_OPTIMIZATIONS) + }) + binding.batteryText.text = getString(R.string.intro_battery_text, getString(R.string.app_name)) + + binding.autostartHeading.text = getString(R.string.intro_autostart_title, WordUtils.capitalize(Build.MANUFACTURER)) + binding.autostartText.setText(R.string.intro_autostart_text) + binding.autostartMoreInfo.setOnClickListener { + UiUtils.launchUri(requireActivity(), App.homepageUrl(requireActivity()).buildUpon() + .appendPath("faq").appendPath("synchronization-is-not-run-as-expected") + .appendQueryParameter("manufacturer", Build.MANUFACTURER.lowercase(Locale.ROOT)).build()) + } + + binding.infoLeaveUnchecked.text = getString(R.string.intro_leave_unchecked, getString(R.string.app_settings_reset_hints)) + + return binding.root + } + + override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) { + super.onActivityResult(requestCode, resultCode, data) + if (requestCode == REQUEST_IGNORE_BATTERY_OPTIMIZATIONS) + model.checkWhitelisted() + } + + override fun onResume() { + super.onResume() + model.checkWhitelisted() + } + + + @HiltViewModel + class Model @Inject constructor( + @ApplicationContext val context: Context, + val settings: SettingsManager + ): ViewModel() { + + companion object { + + /** + * Whether the request for whitelisting from battery optimizations shall be shown. + * If this setting is true or null/not set, the notice shall be shown. Only if this + * setting is false, the notice shall not be shown. + */ + const val HINT_BATTERY_OPTIMIZATIONS = "hint_BatteryOptimizations" + + /** + * Whether the autostart permission notice shall be shown. If this setting is true + * or null/not set, the notice shall be shown. Only if this setting is false, the notice + * shall not be shown. + * + * Type: Boolean + */ + const val HINT_AUTOSTART_PERMISSION = "hint_AutostartPermissions" + + /** + * List of manufacturers which are known to restrict background processes or otherwise + * block synchronization. + * + * See https://www.davx5.com/faq/synchronization-is-not-run-as-expected for why this is evil. + * See https://github.com/jaredrummler/AndroidDeviceNames/blob/master/json/ for manufacturer values. + */ + private val evilManufacturers = arrayOf("asus", "huawei", "lenovo", "letv", "meizu", "nokia", + "oneplus", "oppo", "samsung", "sony", "vivo", "wiko", "xiaomi", "zte") + + /** + * Whether the device has been produced by an evil manufacturer. + * + * Always true for debug builds (to test the UI). + * + * @see evilManufacturers + */ + val manufacturerWarning = + (evilManufacturers.contains(Build.MANUFACTURER.lowercase(Locale.ROOT)) || BuildConfig.DEBUG) + + fun isWhitelisted(context: Context) = + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { + val powerManager = context.getSystemService()!! + powerManager.isIgnoringBatteryOptimizations(BuildConfig.APPLICATION_ID) + } else + true + } + + val shouldBeWhitelisted = MutableLiveData() + val isWhitelisted = MutableLiveData() + val dontShowBattery = object: ObservableBoolean() { + override fun get() = settings.getBooleanOrNull(HINT_BATTERY_OPTIMIZATIONS) == false + override fun set(dontShowAgain: Boolean) { + if (dontShowAgain) + settings.putBoolean(HINT_BATTERY_OPTIMIZATIONS, false) + else + settings.remove(HINT_BATTERY_OPTIMIZATIONS) + notifyChange() + } + } + + val dontShowAutostart = object: ObservableBoolean() { + override fun get() = settings.getBooleanOrNull(HINT_AUTOSTART_PERMISSION) == false + override fun set(dontShowAgain: Boolean) { + if (dontShowAgain) + settings.putBoolean(HINT_AUTOSTART_PERMISSION, false) + else + settings.remove(HINT_AUTOSTART_PERMISSION) + notifyChange() + } + } + + fun checkWhitelisted() { + val whitelisted = isWhitelisted(context) + isWhitelisted.value = whitelisted + shouldBeWhitelisted.value = whitelisted + + // if DAVx5 is whitelisted, always show a reminder as soon as it's not whitelisted anymore + if (whitelisted) + settings.remove(HINT_BATTERY_OPTIMIZATIONS) + } + + } + + + @Module + @InstallIn(ActivityComponent::class) + abstract class BatteryOptimizationsFragmentModule { + @Binds @IntoSet + abstract fun getFactory(factory: Factory): IntroFragmentFactory + } + + class Factory @Inject constructor( + val settingsManager: SettingsManager + ): IntroFragmentFactory { + + override fun getOrder(context: Context) = + // show fragment when: + // 1. DAVx5 is not whitelisted yet and "don't show anymore" has not been clicked, and/or + // 2a. evil manufacturer AND + // 2b. "don't show anymore" has not been clicked + if ( + (!Model.isWhitelisted(context) && settingsManager.getBooleanOrNull(HINT_BATTERY_OPTIMIZATIONS) != false) || + (Model.manufacturerWarning && settingsManager.getBooleanOrNull(HINT_AUTOSTART_PERMISSION) != false) + ) + 100 + else + IntroFragmentFactory.DONT_SHOW + + override fun create() = BatteryOptimizationsFragment() + } + +} diff --git a/app/src/main/java/at/bitfire/davdroid/ui/intro/IntroActivity.kt b/app/src/main/java/at/bitfire/davdroid/ui/intro/IntroActivity.kt new file mode 100644 index 0000000000000000000000000000000000000000..bb259812c3208ba854274c2ced492bc86f5ff98f --- /dev/null +++ b/app/src/main/java/at/bitfire/davdroid/ui/intro/IntroActivity.kt @@ -0,0 +1,82 @@ +/*************************************************************************************************** + * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. + **************************************************************************************************/ + +package at.bitfire.davdroid.ui.intro + +import android.app.Activity +import android.os.Bundle +import androidx.core.content.res.ResourcesCompat +import androidx.fragment.app.Fragment +import at.bitfire.davdroid.R +import at.bitfire.davdroid.log.Logger +import com.github.appintro.AppIntro2 +import dagger.hilt.EntryPoint +import dagger.hilt.InstallIn +import dagger.hilt.android.AndroidEntryPoint +import dagger.hilt.android.EntryPointAccessors +import dagger.hilt.android.components.ActivityComponent +import javax.inject.Inject + +@AndroidEntryPoint +class IntroActivity: AppIntro2() { + + @EntryPoint + @InstallIn(ActivityComponent::class) + interface IntroActivityEntryPoint { + fun introFragmentFactories(): Set<@JvmSuppressWildcards IntroFragmentFactory> + } + + companion object { + + fun shouldShowIntroActivity(activity: Activity): Boolean { + val factories = EntryPointAccessors.fromActivity(activity, IntroActivityEntryPoint::class.java).introFragmentFactories() + return factories.any { + val order = it.getOrder(activity) + Logger.log.fine("Found intro fragment factory ${it::class.java} with order $order") + order > 0 + } + } + + } + + private var currentSlide = 0 + + @Inject lateinit var introFragmentFactories: Set<@JvmSuppressWildcards IntroFragmentFactory> + + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + val factoriesWithOrder = introFragmentFactories + .associateBy { it.getOrder(this) } + .filterKeys { it != IntroFragmentFactory.DONT_SHOW } + + val anyPositiveOrder = factoriesWithOrder.keys.any { it > 0 } + if (anyPositiveOrder) { + for ((_, factory) in factoriesWithOrder.toSortedMap()) + addSlide(factory.create()) + } + + setBarColor(ResourcesCompat.getColor(resources, R.color.primaryDarkColor, null)) + isSkipButtonEnabled = false + } + + override fun onPageSelected(position: Int) { + super.onPageSelected(position) + currentSlide = position + } + + override fun onBackPressed() { + if (currentSlide == 0) + setResult(Activity.RESULT_CANCELED) + super.onBackPressed() + } + + override fun onDonePressed(currentFragment: Fragment?) { + super.onDonePressed(currentFragment) + setResult(Activity.RESULT_OK) + finish() + } + +} \ No newline at end of file diff --git a/app/src/main/java/at/bitfire/davdroid/ui/intro/IntroFragmentFactory.kt b/app/src/main/java/at/bitfire/davdroid/ui/intro/IntroFragmentFactory.kt new file mode 100644 index 0000000000000000000000000000000000000000..3e6493ff3881448730cd91e96c8b404a0435272c --- /dev/null +++ b/app/src/main/java/at/bitfire/davdroid/ui/intro/IntroFragmentFactory.kt @@ -0,0 +1,38 @@ +/*************************************************************************************************** + * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. + **************************************************************************************************/ + +package at.bitfire.davdroid.ui.intro + +import android.content.Context +import androidx.fragment.app.Fragment + +interface IntroFragmentFactory { + + companion object { + const val DONT_SHOW = 0 + } + + /** + * Used to determine whether an intro fragment of this type (for instance, + * the [BatteryOptimizationsFragment]) should be shown. + * + * @param context used to determine whether the fragment shall be shown + * + * @return Order with which an instance of this fragment type shall be created and shown. Possible values: + * + * * <0: only show the fragment when there is at least one other fragment with positive order (lower numbers are shown first) + * * 0: don't show the fragment + * * ≥0: show the fragment (lower numbers are shown first) + */ + fun getOrder(context: Context): Int + + /** + * Creates an instance of this intro fragment type. Will only be called when + * [getOrder] is true. + * + * @return the fragment (for instance, a [BatteryOptimizationsFragment]]) + */ + fun create(): Fragment + +} \ No newline at end of file diff --git a/app/src/main/java/at/bitfire/davdroid/ui/intro/OpenSourceFragment.kt b/app/src/main/java/at/bitfire/davdroid/ui/intro/OpenSourceFragment.kt new file mode 100644 index 0000000000000000000000000000000000000000..30f01de01d61b5a1546ce6b44f2d1e14be88fc04 --- /dev/null +++ b/app/src/main/java/at/bitfire/davdroid/ui/intro/OpenSourceFragment.kt @@ -0,0 +1,87 @@ +/*************************************************************************************************** + * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. + **************************************************************************************************/ + +package at.bitfire.davdroid.ui.intro + +import android.content.Context +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.databinding.ObservableBoolean +import androidx.fragment.app.Fragment +import androidx.fragment.app.viewModels +import androidx.lifecycle.ViewModel +import at.bitfire.davdroid.App +import at.bitfire.davdroid.R +import at.bitfire.davdroid.databinding.IntroOpenSourceBinding +import at.bitfire.davdroid.settings.SettingsManager +import at.bitfire.davdroid.ui.UiUtils +import at.bitfire.davdroid.ui.intro.OpenSourceFragment.Model.Companion.SETTING_NEXT_DONATION_POPUP +import dagger.hilt.android.AndroidEntryPoint +import dagger.hilt.android.lifecycle.HiltViewModel +import dagger.hilt.android.qualifiers.ApplicationContext +import javax.inject.Inject + +@AndroidEntryPoint +class OpenSourceFragment: Fragment() { + + val model by viewModels() + + + override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View { + val binding = IntroOpenSourceBinding.inflate(inflater, container, false) + binding.lifecycleOwner = viewLifecycleOwner + binding.model = model + + binding.text.text = getString(R.string.intro_open_source_text, getString(R.string.app_name)) + binding.moreInfo.setOnClickListener { + UiUtils.launchUri(requireActivity(), App.homepageUrl(requireActivity()).buildUpon() + .appendPath("donate") + .build()) + } + + return binding.root + } + + + @HiltViewModel + class Model @Inject constructor( + @ApplicationContext val context: Context, + val settings: SettingsManager + ): ViewModel() { + + companion object { + const val SETTING_NEXT_DONATION_POPUP = "time_nextDonationPopup" + } + + val dontShow = object: ObservableBoolean() { + override fun set(dontShowAgain: Boolean) { + if (dontShowAgain) { + val nextReminder = System.currentTimeMillis() + 90*86400000L // 90 days (~ 3 months) + settings.putLong(SETTING_NEXT_DONATION_POPUP, nextReminder) + } else + settings.remove(SETTING_NEXT_DONATION_POPUP) + super.set(dontShowAgain) + } + } + + } + + + class Factory @Inject constructor( + val settingsManager: SettingsManager + ): IntroFragmentFactory { + + override fun getOrder(context: Context) = + if (System.currentTimeMillis() > (settingsManager.getLongOrNull(SETTING_NEXT_DONATION_POPUP) ?: 0)) + 100 + else + 0 + + override fun create() = OpenSourceFragment() + + } + +} \ No newline at end of file diff --git a/app/src/main/java/at/bitfire/davdroid/ui/intro/PermissionsIntroFragment.kt b/app/src/main/java/at/bitfire/davdroid/ui/intro/PermissionsIntroFragment.kt new file mode 100644 index 0000000000000000000000000000000000000000..a3ec5d2faeff886e329a4cb7adbedf3894f2b01a --- /dev/null +++ b/app/src/main/java/at/bitfire/davdroid/ui/intro/PermissionsIntroFragment.kt @@ -0,0 +1,46 @@ +/*************************************************************************************************** + * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. + **************************************************************************************************/ + +package at.bitfire.davdroid.ui.intro + +import android.content.Context +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.fragment.app.Fragment +import at.bitfire.davdroid.PermissionUtils +import at.bitfire.davdroid.PermissionUtils.CALENDAR_PERMISSIONS +import at.bitfire.davdroid.PermissionUtils.CONTACT_PERMISSIONS +import at.bitfire.davdroid.R +import at.bitfire.ical4android.TaskProvider +import dagger.Binds +import dagger.Module +import dagger.hilt.InstallIn +import dagger.hilt.android.components.ActivityComponent +import dagger.multibindings.IntoSet +import javax.inject.Inject + +class PermissionsIntroFragment : Fragment() { + + override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View = + inflater.inflate(R.layout.intro_permissions, container, false) + + + class Factory @Inject constructor(): IntroFragmentFactory { + + override fun getOrder(context: Context): Int { + // show PermissionsFragment as intro fragment when no permissions are granted + val permissions = CONTACT_PERMISSIONS + CALENDAR_PERMISSIONS + TaskProvider.PERMISSIONS_OPENTASKS + return if (PermissionUtils.haveAnyPermission(context, permissions)) + IntroFragmentFactory.DONT_SHOW + else + 50 + } + + override fun create() = PermissionsIntroFragment() + + } + +} \ No newline at end of file diff --git a/app/src/main/java/at/bitfire/davdroid/ui/intro/TasksIntroFragment.kt b/app/src/main/java/at/bitfire/davdroid/ui/intro/TasksIntroFragment.kt new file mode 100644 index 0000000000000000000000000000000000000000..c61697fb9ea50a54248128da274569e9333b9036 --- /dev/null +++ b/app/src/main/java/at/bitfire/davdroid/ui/intro/TasksIntroFragment.kt @@ -0,0 +1,52 @@ +/*************************************************************************************************** + * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. + **************************************************************************************************/ + +package at.bitfire.davdroid.ui.intro + +import android.content.Context +import android.os.Build +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.fragment.app.Fragment +import at.bitfire.davdroid.App +import at.bitfire.davdroid.BuildConfig +import at.bitfire.davdroid.R +import at.bitfire.davdroid.resource.TaskUtils +import at.bitfire.davdroid.settings.SettingsManager +import at.bitfire.davdroid.ui.TasksFragment +import dagger.Binds +import dagger.Module +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import dagger.multibindings.IntoSet +import javax.inject.Inject + +class TasksIntroFragment : Fragment() { + + override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View = + inflater.inflate(R.layout.intro_tasks, container, false) + + + class Factory @Inject constructor( + val settingsManager: SettingsManager + ): IntroFragmentFactory { + + override fun getOrder(context: Context): Int { + // On Android <6, OpenTasks must be installed before DAVx5, so this fragment is not useful. + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M) + return IntroFragmentFactory.DONT_SHOW + + return if (!TaskUtils.isAvailable(context) && settingsManager.getBooleanOrNull(TasksFragment.Model.HINT_OPENTASKS_NOT_INSTALLED) != false) + 10 + else + IntroFragmentFactory.DONT_SHOW + } + + override fun create() = TasksIntroFragment() + + } + +} \ No newline at end of file diff --git a/app/src/main/java/at/bitfire/davdroid/ui/intro/WelcomeFragment.kt b/app/src/main/java/at/bitfire/davdroid/ui/intro/WelcomeFragment.kt new file mode 100644 index 0000000000000000000000000000000000000000..6203fe8ace7188ee2e182168aa9a9fdfcfac100d --- /dev/null +++ b/app/src/main/java/at/bitfire/davdroid/ui/intro/WelcomeFragment.kt @@ -0,0 +1,76 @@ +/*************************************************************************************************** + * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. + **************************************************************************************************/ + +package at.bitfire.davdroid.ui.intro + +import android.content.Context +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.fragment.app.Fragment +import at.bitfire.davdroid.App +import at.bitfire.davdroid.BuildConfig +import at.bitfire.davdroid.databinding.IntroWelcomeBinding +import dagger.Binds +import dagger.Module +import dagger.hilt.InstallIn +import dagger.hilt.android.components.ActivityComponent +import dagger.multibindings.IntoSet +import javax.inject.Inject + +class WelcomeFragment: Fragment() { + + private var _binding: IntroWelcomeBinding? = null + private val binding get() = _binding!! + + override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View { + _binding = IntroWelcomeBinding.inflate(inflater, container, false) + + if (true /* ose build */) { + binding.logo.apply { + alpha = 0f + animate() + .alpha(1f) + .setDuration(300) + } + binding.yourDataYourChoice.apply { + translationX = -1000f + animate() + .translationX(0f) + .setDuration(300) + } + binding.takeControl.apply { + translationX = 1000f + animate() + .translationX(0f) + .setDuration(300) + } + } + + return binding.root + } + + override fun onDestroyView() { + super.onDestroyView() + _binding = null + } + + + @Module + @InstallIn(ActivityComponent::class) + abstract class WelcomeFragmentModule { + @Binds @IntoSet + abstract fun getFactory(factory: WelcomeFragment.Factory): IntroFragmentFactory + } + + class Factory @Inject constructor() : IntroFragmentFactory { + + override fun getOrder(context: Context) = -1000 + + override fun create() = WelcomeFragment() + + } + +} 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 1abc29a37543e1d44ae1e6fb78337868095df45c..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 @@ -1,54 +1,63 @@ -/* - * Copyright © Ricki Hirner (bitfire web engineering). - * All rights reserved. This program and the accompanying materials - * are made available under the terms of the GNU Public License v3.0 - * which accompanies this distribution, and is available at - * http://www.gnu.org/licenses/gpl.html - */ +/*************************************************************************************************** + * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. + **************************************************************************************************/ package at.bitfire.davdroid.ui.setup import android.accounts.Account +import android.accounts.AccountAuthenticatorResponse import android.accounts.AccountManager -import android.app.Application +import android.app.Activity import android.content.ContentResolver +import android.content.Context import android.content.Intent import android.os.Bundle import android.provider.CalendarContract +import android.text.Editable 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 +import at.bitfire.davdroid.db.Credentials +import at.bitfire.davdroid.db.HomeSet +import at.bitfire.davdroid.db.Service import at.bitfire.davdroid.log.Logger -import at.bitfire.davdroid.model.AppDatabase -import at.bitfire.davdroid.model.Credentials -import at.bitfire.davdroid.model.HomeSet -import at.bitfire.davdroid.model.Service -import at.bitfire.davdroid.resource.LocalTaskList +import at.bitfire.davdroid.resource.TaskUtils import at.bitfire.davdroid.settings.AccountSettings -import at.bitfire.davdroid.settings.Settings -import at.bitfire.ical4android.TaskProvider +import at.bitfire.davdroid.settings.SettingsManager +import at.bitfire.davdroid.syncadapter.AccountUtils +import at.bitfire.davdroid.ui.account.AccountActivity import at.bitfire.vcard4android.GroupMethod import com.google.android.material.snackbar.Snackbar +import dagger.hilt.android.AndroidEntryPoint +import dagger.hilt.android.lifecycle.HiltViewModel +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.NonCancellable +import kotlinx.coroutines.launch import java.util.logging.Level -import kotlin.concurrent.thread +import javax.inject.Inject -class AccountDetailsFragment: Fragment() { +@AndroidEntryPoint +class AccountDetailsFragment : Fragment() { - private lateinit var loginModel: LoginModel - private lateinit var model: AccountDetailsModel + @Inject lateinit var settings: SettingsManager + + val loginModel by activityViewModels() + val model by viewModels() - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - loginModel = ViewModelProviders.of(requireActivity()).get(LoginModel::class.java) - model = ViewModelProviders.of(this).get(AccountDetailsModel::class.java) - } override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View { val v = LoginAccountDetailsBinding.inflate(inflater, container, false) @@ -57,16 +66,26 @@ class AccountDetailsFragment: Fragment() { val config = loginModel.configuration ?: throw IllegalStateException() - model.name.value = config.calDAV?.email ?: - loginModel.credentials?.userName ?: - loginModel.credentials?.certificateAlias + // default account name + model.name.value = + loginModel.credentials?.userName + ?: config.calDAV?.emails?.firstOrNull() + ?: loginModel.credentials?.certificateAlias + ?: loginModel.baseURI?.host // CardDAV-specific - val settings = Settings.getInstance(requireActivity()) v.carddav.visibility = if (config.cardDAV != null) View.VISIBLE else View.GONE - if (settings.has(AccountSettings.KEY_CONTACT_GROUP_METHOD)) + 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) + v.accountName.setAdapter(accountNameAdapter) + } + v.createAccount.setOnClickListener { val name = model.name.value if (name.isNullOrBlank()) @@ -86,14 +105,16 @@ class AccountDetailsFragment: Fragment() { v.createAccount.visibility = View.GONE model.createAccount( + requireActivity(), name, - loginModel.credentials!!, + loginModel.credentials, config, GroupMethod.valueOf(groupMethodName) - ).observe(this, Observer { success -> - if (success) + ).observe(viewLifecycleOwner, Observer { success -> + if (success) { + // close Create account activity requireActivity().finish() - else { + } else { Snackbar.make(requireActivity().findViewById(android.R.id.content), R.string.login_account_not_created, Snackbar.LENGTH_LONG).show() v.createAccountProgress.visibility = View.GONE @@ -115,46 +136,151 @@ 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 } - class AccountDetailsModel( - application: Application - ): AndroidViewModel(application) { + @HiltViewModel + class AccountDetailsModel @Inject constructor( + @ApplicationContext val context: Context, + val db: AppDatabase, + val settingsManager: SettingsManager + ) : ViewModel() { val name = MutableLiveData() val nameError = MutableLiveData() + val showApostropheWarning = MutableLiveData(false) - fun createAccount(name: String, credentials: Credentials, config: DavResourceFinder.Configuration, groupMethod: GroupMethod): LiveData { + fun validateAccountName(s: Editable) { + showApostropheWarning.value = s.toString().contains('\'') + nameError.value = null + } + + fun createAccount(activity: Activity, name: String, credentials: Credentials?, config: DavResourceFinder.Configuration, groupMethod: GroupMethod): LiveData { val result = MutableLiveData() - val context = getApplication() - thread { - val account = Account(name, context.getString(R.string.account_type)) + viewModelScope.launch(Dispatchers.Default + NonCancellable) { + 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 (!accountManager.addAccountExplicitly(account, credentials.password, userData)) { - result.postValue(false) - return@thread + + if (!AccountUtils.createAccount(context, account, userData, credentials?.password)) { + 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) - val db = AppDatabase.getInstance(context) try { val accountSettings = AccountSettings(context, account) + val defaultSyncInterval = Constants.DEFAULT_CALENDAR_SYNC_INTERVAL val refreshIntent = Intent(context, DavService::class.java) refreshIntent.action = DavService.ACTION_REFRESH_COLLECTIONS + val addrBookAuthority = context.getString(R.string.address_books_authority) if (config.cardDAV != null) { // insert CardDAV service - - val id = insertService(db, 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) @@ -163,52 +289,66 @@ class AccountDetailsFragment: Fragment() { refreshIntent.putExtra(DavService.EXTRA_DAV_SERVICE_ID, id) context.startService(refreshIntent) - // contact sync is automatically enabled by isAlwaysSyncable="true" in res/xml/sync_address_books.xml - accountSettings.setSyncInterval(context.getString(R.string.address_books_authority), Constants.DEFAULT_SYNC_INTERVAL) + // set default sync interval and enable sync regardless of permissions + ContentResolver.setIsSyncable(account, addrBookAuthority, 1) + accountSettings.setSyncInterval(addrBookAuthority, defaultSyncInterval) } else - ContentResolver.setIsSyncable(account, context.getString(R.string.address_books_authority), 0) + ContentResolver.setIsSyncable(account, addrBookAuthority, 0) if (config.calDAV != null) { // insert CalDAV service - val id = insertService(db, 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) context.startService(refreshIntent) - // calendar sync is automatically enabled by isAlwaysSyncable="true" in res/xml/sync_calendars.xml - accountSettings.setSyncInterval(CalendarContract.AUTHORITY, Constants.DEFAULT_SYNC_INTERVAL) + // set default sync interval and enable sync regardless of permissions + ContentResolver.setIsSyncable(account, CalendarContract.AUTHORITY, 1) + accountSettings.setSyncInterval(CalendarContract.AUTHORITY, defaultSyncInterval) - // enable task sync if OpenTasks is installed - // further changes will be handled by PackageChangedReceiver - if (LocalTaskList.tasksProviderAvailable(context)) { - ContentResolver.setIsSyncable(account, TaskProvider.ProviderName.OpenTasks.authority, 1) - accountSettings.setSyncInterval(TaskProvider.ProviderName.OpenTasks.authority, Constants.DEFAULT_SYNC_INTERVAL) + val taskProvider = TaskUtils.currentProvider(context) + if (taskProvider != null) { + ContentResolver.setIsSyncable(account, taskProvider.authority, 1) + accountSettings.setSyncInterval(taskProvider.authority, defaultSyncInterval) + // further changes will be handled by TasksWatcher on app start or when tasks app is (un)installed } - } else { + } else ContentResolver.setIsSyncable(account, CalendarContract.AUTHORITY, 0) - ContentResolver.setIsSyncable(account, TaskProvider.ProviderName.OpenTasks.authority, 0) - } } catch(e: InvalidAccountException) { Logger.log.log(Level.SEVERE, "Couldn't access account settings", e) result.postValue(false) - return@thread + return@launch } result.postValue(true) } return result } - private fun insertService(db: AppDatabase, 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 val homeSetDao = db.homeSetDao() for (homeSet in info.homeSets) { - homeSetDao.insertOrReplace(HomeSet(0, serviceId, homeSet)) + homeSetDao.insertOrReplace(HomeSet(0, serviceId, true, homeSet)) } // insert collections 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 2805068884b183be4d6c9c6c014ec6885e3ab7a2..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 @@ -1,10 +1,6 @@ -/* - * Copyright © Ricki Hirner (bitfire web engineering). - * All rights reserved. This program and the accompanying materials - * are made available under the terms of the GNU Public License v3.0 - * which accompanies this distribution, and is available at - * http://www.gnu.org/licenses/gpl.html - */ +/*************************************************************************************************** + * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. + **************************************************************************************************/ package at.bitfire.davdroid.ui.setup import android.content.Context @@ -13,12 +9,14 @@ import at.bitfire.dav4jvm.Response import at.bitfire.dav4jvm.UrlUtils import at.bitfire.dav4jvm.exception.DavException import at.bitfire.dav4jvm.exception.HttpException +import at.bitfire.dav4jvm.exception.UnauthorizedException import at.bitfire.dav4jvm.property.* import at.bitfire.davdroid.DavUtils import at.bitfire.davdroid.HttpClient +import at.bitfire.davdroid.db.Collection import at.bitfire.davdroid.log.StringHandler -import at.bitfire.davdroid.model.Collection import okhttp3.HttpUrl +import okhttp3.HttpUrl.Companion.toHttpUrlOrNull import org.apache.commons.lang3.builder.ReflectionToStringBuilder import org.xbill.DNS.Lookup import org.xbill.DNS.Type @@ -43,17 +41,22 @@ class DavResourceFinder( override fun toString() = wellKnownName } - val log = Logger.getLogger("davdroid.DavResourceFinder") + val log: Logger = Logger.getLogger("davx5.DavResourceFinder") private val logBuffer = StringHandler() init { log.level = Level.FINEST log.addHandler(logBuffer) } - private val httpClient: HttpClient = HttpClient.Builder(context, logger = log) - .addAuthentication(null, loginModel.credentials!!) - .setForeground(true) - .build() + var encountered401 = false + + private val httpClient: HttpClient = HttpClient.Builder(context, logger = log).let { + loginModel.credentials?.let { credentials -> + it.addAuthentication(null, credentials) + } + it.setForeground(true) + it.build() + } override fun close() { httpClient.close() @@ -73,14 +76,14 @@ class DavResourceFinder( cardDavConfig = findInitialConfiguration(Service.CARDDAV) } catch (e: Exception) { log.log(Level.INFO, "CardDAV service detection failed", e) - rethrowIfInterrupted(e) + processException(e) } try { calDavConfig = findInitialConfiguration(Service.CALDAV) } catch (e: Exception) { log.log(Level.INFO, "CalDAV service detection failed", e) - rethrowIfInterrupted(e) + processException(e) } } catch(e: Exception) { // we have been interrupted; reset results so that an error message will be shown @@ -90,6 +93,7 @@ class DavResourceFinder( return Configuration( cardDavConfig, calDavConfig, + encountered401, logBuffer.toString() ) } @@ -106,11 +110,11 @@ class DavResourceFinder( log.info("Finding initial ${service.wellKnownName} service configuration") if (baseURI.scheme.equals("http", true) || baseURI.scheme.equals("https", true)) { - HttpUrl.get(baseURI)?.let { baseURL -> + baseURI.toHttpUrlOrNull()?.let { baseURL -> // remember domain for service discovery // try service discovery only for https:// URLs because only secure service discovery is implemented - if (baseURL.scheme().equals("https", true)) - discoveryFQDN = baseURL.host() + if (baseURL.scheme.equals("https", true)) + discoveryFQDN = baseURL.host checkUserGivenURL(baseURL, service, config) @@ -119,7 +123,7 @@ class DavResourceFinder( config.principal = getCurrentUserPrincipal(baseURL.resolve("/.well-known/" + service.wellKnownName)!!, service) } catch(e: Exception) { log.log(Level.FINE, "Well-known URL detection failed", e) - rethrowIfInterrupted(e) + processException(e) } } } else if (baseURI.scheme.equals("mailto", true)) { @@ -138,28 +142,14 @@ class DavResourceFinder( config.principal = discoverPrincipalUrl(it, service) } catch(e: Exception) { log.log(Level.FINE, "$service service discovery failed", e) - rethrowIfInterrupted(e) + processException(e) } } - if (config.principal != null && service == Service.CALDAV) - // query email address (CalDAV scheduling: calendar-user-address-set) - try { - DavResource(httpClient.okHttpClient, config.principal!!, log).propfind(0, CalendarUserAddressSet.NAME) { response, _ -> - response[CalendarUserAddressSet::class.java]?.let { addressSet -> - for (href in addressSet.hrefs) - try { - val uri = URI(href) - if (uri.scheme.equals("mailto", true)) - config.email = uri.schemeSpecificPart - } catch(e: URISyntaxException) { - log.log(Level.WARNING, "Couldn't parse user address", e) - } - } - } - } catch(e: Exception) { - log.log(Level.WARNING, "Couldn't query user email address", e) - rethrowIfInterrupted(e) + // detect email address + if (service == Service.CALDAV) + config.principal?.let { + config.emails.addAll(queryEmailAddress(it)) } // return config or null if config doesn't contain useful information @@ -173,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 -> { @@ -186,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 @@ -197,8 +187,37 @@ class DavResourceFinder( } } catch(e: Exception) { log.log(Level.FINE, "PROPFIND/OPTIONS on user-given URL failed", e) - rethrowIfInterrupted(e) + processException(e) + } + } + + /** + * Queries a user's email address using CalDAV scheduling: calendar-user-address-set. + * @param principal principal URL of the user + * @return list of found email addresses (empty if none) + */ + fun queryEmailAddress(principal: HttpUrl): List { + val mailboxes = LinkedList() + try { + 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)) { + log.info("myenail: ${uri.schemeSpecificPart}") + mailboxes.add(uri.schemeSpecificPart) + } + } catch(e: URISyntaxException) { + log.log(Level.WARNING, "Couldn't parse user address", e) + } + } + } + } catch(e: Exception) { + log.log(Level.WARNING, "Couldn't query user email address", e) + processException(e) } + return mailboxes } /** @@ -262,7 +281,7 @@ class DavResourceFinder( principal = dav.requestedUrl.resolve(it) } - // Is it a calendar book and/or principal? + // Is it a calendar and/or principal? dav[ResourceType::class.java]?.let { if (it.types.contains(ResourceType.CALENDAR)) { val info = Collection.fromDavResponse(dav)!! @@ -279,7 +298,7 @@ class DavResourceFinder( for (href in homeSet.hrefs) { dav.requestedUrl.resolve(href)?.let { val location = UrlUtils.withTrailingSlash(it) - log.info("Found calendar book home-set at $location") + log.info("Found calendar home-set at $location") config.homeSets += location } } @@ -296,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 @@ -318,7 +337,7 @@ class DavResourceFinder( * @return principal URL, or null if none found */ @Throws(IOException::class, HttpException::class, DavException::class) - private fun discoverPrincipalUrl(domain: String, service: Service): HttpUrl? { + fun discoverPrincipalUrl(domain: String, service: Service): HttpUrl? { val scheme: String val fqdn: String var port = 443 @@ -326,9 +345,11 @@ class DavResourceFinder( val query = "_${service.wellKnownName}s._tcp.$domain" log.fine("Looking up SRV records for $query") + val srvLookup = Lookup(query, Type.SRV) DavUtils.prepareLookup(context, srvLookup) - val srv = DavUtils.selectSRVRecord(srvLookup.run()) + val srv = DavUtils.selectSRVRecord(srvLookup.run().orEmpty()) + if (srv != null) { // choose SRV record to use (query may return multiple SRV records) scheme = "https" @@ -367,7 +388,7 @@ class DavResourceFinder( principal?.let { return it } } catch(e: Exception) { log.log(Level.WARNING, "No resource found", e) - rethrowIfInterrupted(e) + processException(e) } return null } @@ -382,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") @@ -399,11 +420,15 @@ class DavResourceFinder( } /** - * Re-throws the exception if it signals that the current thread was interrupted - * to stop the current operation. + * Processes a thrown exception likes this: + * + * - If the Exception is an [UnauthorizedException] (HTTP 401), [encountered401] is set to *true*. + * - Re-throws the exception if it signals that the current thread was interrupted to stop the current operation. */ - private fun rethrowIfInterrupted(e: Exception) { - if ((e is InterruptedIOException && e !is SocketTimeoutException) || e is InterruptedException) + private fun processException(e: Exception) { + if (e is UnauthorizedException) + encountered401 = true + else if ((e is InterruptedIOException && e !is SocketTimeoutException) || e is InterruptedException) throw e } @@ -414,6 +439,7 @@ class DavResourceFinder( val cardDAV: ServiceInfo?, val calDAV: ServiceInfo?, + val encountered401: Boolean, val logs: String ) { @@ -422,7 +448,7 @@ class DavResourceFinder( val homeSets: MutableSet = HashSet(), val collections: MutableMap = HashMap(), - var email: String? = null + val emails: MutableList = LinkedList() ) override fun toString(): String { 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 bb0e56769d0c4761bc287b1087de853cc6a863ff..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 @@ -1,10 +1,6 @@ -/* - * Copyright © Ricki Hirner (bitfire web engineering). - * All rights reserved. This program and the accompanying materials - * are made available under the terms of the GNU Public License v3.0 - * which accompanies this distribution, and is available at - * http://www.gnu.org/licenses/gpl.html - */ +/*************************************************************************************************** + * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. + **************************************************************************************************/ package at.bitfire.davdroid.ui.setup @@ -18,22 +14,29 @@ import android.view.LayoutInflater import android.view.View import android.view.ViewGroup import androidx.fragment.app.Fragment -import androidx.lifecycle.ViewModelProviders +import androidx.fragment.app.activityViewModels +import androidx.fragment.app.viewModels import at.bitfire.davdroid.R import at.bitfire.davdroid.databinding.LoginCredentialsFragmentBinding -import at.bitfire.davdroid.model.Credentials +import at.bitfire.davdroid.db.Credentials +import com.google.android.material.snackbar.Snackbar +import dagger.Binds +import dagger.Module +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import dagger.multibindings.IntKey +import dagger.multibindings.IntoMap import java.net.URI import java.net.URISyntaxException +import javax.inject.Inject -class DefaultLoginCredentialsFragment: Fragment() { +class DefaultLoginCredentialsFragment : Fragment() { - private lateinit var model: DefaultLoginCredentialsModel - private lateinit var loginModel: LoginModel + val loginModel by activityViewModels() + val model by viewModels() - override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View { - model = ViewModelProviders.of(this).get(DefaultLoginCredentialsModel::class.java) - loginModel = ViewModelProviders.of(requireActivity()).get(LoginModel::class.java) + override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View { val v = LoginCredentialsFragmentBinding.inflate(inflater, container, false) v.lifecycleOwner = viewLifecycleOwner v.model = model @@ -42,17 +45,30 @@ class DefaultLoginCredentialsFragment: Fragment() { if (savedInstanceState == null) activity?.intent?.let { model.initialize(it) } + v.loginUrlBaseUrlEdittext.setAdapter(DefaultLoginCredentialsModel.LoginUrlAdapter(requireActivity())) + v.selectCertificate.setOnClickListener { KeyChain.choosePrivateKeyAlias(requireActivity(), { alias -> Handler(Looper.getMainLooper()).post { - model.certificateAlias.value = alias + + // Show a Snackbar to add a certificate if no certificate was found + // API Versions < 29 still handle this automatically + if (alias == null && android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.Q) { + Snackbar.make(v.root, R.string.login_no_certificate_found, Snackbar.LENGTH_LONG) + .setAction(R.string.login_install_certificate) { + startActivity(KeyChain.createInstallIntent()) + } + .show() + } + else + model.certificateAlias.value = alias } }, null, null, null, -1, model.certificateAlias.value) } v.login.setOnClickListener { if (validate()) - requireFragmentManager().beginTransaction() + parentFragmentManager.beginTransaction() .replace(android.R.id.content, DetectConfigurationFragment(), null) .addToBackStack(null) .commit() @@ -134,18 +150,49 @@ class DefaultLoginCredentialsFragment: Fragment() { loginModel.credentials = Credentials(username, password, null) } - model.loginWithUrlAndCertificate.value == true -> { + model.loginAdvanced.value == true -> { validateUrl() model.certificateAliasError.value = null val alias = model.certificateAlias.value - if (alias.isNullOrBlank()) { + if (model.loginUseClientCertificate.value == true && alias.isNullOrBlank()) { valid = false model.certificateAliasError.value = "" // error icon without text } + model.usernameError.value = null + val username = model.username.value + + model.passwordError.value = null + val password = model.password.value + + if (model.loginUseUsernamePassword.value == true) { + if (username.isNullOrEmpty()) { + valid = false + model.usernameError.value = getString(R.string.login_user_name_required) + } + validatePassword() + } + + // loginModel.credentials stays null if login is tried with Base URL only if (valid) - loginModel.credentials = Credentials(null, null, alias) + loginModel.credentials = when { + // username/password and client certificate + model.loginUseUsernamePassword.value == true && model.loginUseClientCertificate.value == true -> + Credentials(username, password, null, alias) + + // user/name password only + model.loginUseUsernamePassword.value == true && model.loginUseClientCertificate.value == false -> + Credentials(username, password) + + // client certificate only + model.loginUseUsernamePassword.value == false && model.loginUseClientCertificate.value == true -> + Credentials(certificateAlias = alias) + + // anonymous (neither username/password nor client certificate) + else -> + null + } } } @@ -153,10 +200,19 @@ class DefaultLoginCredentialsFragment: Fragment() { } - class Factory: ILoginCredentialsFragment { + class Factory @Inject constructor() : LoginCredentialsFragmentFactory { override fun getFragment(intent: Intent) = DefaultLoginCredentialsFragment() } -} + @Module + @InstallIn(SingletonComponent::class) + abstract class DefaultLoginCredentialsFragmentModule { + @Binds + @IntoMap + @IntKey(/* priority */ 10) + abstract fun factory(impl: Factory): LoginCredentialsFragmentFactory + } + +} \ No newline at end of file diff --git a/app/src/main/java/at/bitfire/davdroid/ui/setup/DefaultLoginCredentialsModel.kt b/app/src/main/java/at/bitfire/davdroid/ui/setup/DefaultLoginCredentialsModel.kt index ba32312d8c3c9991609881b0bf3d0b57bea21f82..633310e9bd1c412192853467e801f9b59b9c2077 100644 --- a/app/src/main/java/at/bitfire/davdroid/ui/setup/DefaultLoginCredentialsModel.kt +++ b/app/src/main/java/at/bitfire/davdroid/ui/setup/DefaultLoginCredentialsModel.kt @@ -1,17 +1,33 @@ +/*************************************************************************************************** + * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. + **************************************************************************************************/ + package at.bitfire.davdroid.ui.setup +import android.app.Application +import android.content.Context import android.content.Intent +import android.net.Uri +import android.text.Editable +import android.view.View +import android.view.ViewGroup +import android.widget.ArrayAdapter +import android.widget.Filter +import android.widget.RadioGroup import androidx.annotation.MainThread +import androidx.lifecycle.AndroidViewModel import androidx.lifecycle.MutableLiveData -import androidx.lifecycle.ViewModel +import at.bitfire.davdroid.R +import java.io.InputStreamReader +import java.util.regex.Pattern -class DefaultLoginCredentialsModel: ViewModel() { +class DefaultLoginCredentialsModel(app: Application): AndroidViewModel(app) { private var initialized = false val loginWithEmailAddress = MutableLiveData() val loginWithUrlAndUsername = MutableLiveData() - val loginWithUrlAndCertificate = MutableLiveData() + val loginAdvanced = MutableLiveData() val baseUrl = MutableLiveData() val baseUrlError = MutableLiveData() @@ -26,8 +42,33 @@ class DefaultLoginCredentialsModel: ViewModel() { val certificateAlias = MutableLiveData() val certificateAliasError = MutableLiveData() + val loginUseUsernamePassword = MutableLiveData() + val loginUseClientCertificate = MutableLiveData() + init { loginWithEmailAddress.value = true + loginUseClientCertificate.value = false + loginUseUsernamePassword.value = false + } + + fun clearUrlError(s: Editable) { + if (s.toString() != "https://") { + baseUrlError.value = null + } + } + + fun clearUsernameError(s: Editable) { + usernameError.value = null + } + + fun clearPasswordError(s: Editable) { + passwordError.value = null + } + + fun clearErrors(group: RadioGroup, checkedId: Int) { + usernameError.value = null + passwordError.value = null + baseUrlError.value = null } @MainThread @@ -36,10 +77,43 @@ class DefaultLoginCredentialsModel: ViewModel() { return initialized = true - // we've got initial login data - val givenUrl = intent.getStringExtra(LoginActivity.EXTRA_URL) - val givenUsername = intent.getStringExtra(LoginActivity.EXTRA_USERNAME) - val givenPassword = intent.getStringExtra(LoginActivity.EXTRA_PASSWORD) + var givenUrl: String? = null + var givenUsername: String? = null + var givenPassword: String? = null + + intent.data?.normalizeScheme()?.let { uri -> + // We've got initial login data from the Intent. + // We can't use uri.buildUpon() because this keeps the user info (it's readable, but not writable). + val realScheme = when (uri.scheme) { + "caldav", "carddav" -> "http" + "caldavs", "carddavs", "davx5" -> "https" + "http", "https" -> uri.scheme + else -> null + } + if (realScheme != null) { + val realUri = Uri.Builder() + .scheme(realScheme) + .authority(uri.host) + .path(uri.path) + .query(uri.query) + givenUrl = realUri.build().toString() + + // extract user info + uri.userInfo?.split(':')?.let { userInfo -> + givenUsername = userInfo.getOrNull(0) + givenPassword = userInfo.getOrNull(1) + } + } + } + + // no login data from the Intent, let's look up the extras + givenUrl ?: intent.getStringExtra(LoginActivity.EXTRA_URL) + + // always prefer username/password from the extras + if (intent.hasExtra(LoginActivity.EXTRA_USERNAME)) + givenUsername = intent.getStringExtra(LoginActivity.EXTRA_USERNAME) + if (intent.hasExtra(LoginActivity.EXTRA_PASSWORD)) + givenPassword = intent.getStringExtra(LoginActivity.EXTRA_PASSWORD) if (givenUrl != null) { loginWithUrlAndUsername.value = true @@ -50,4 +124,50 @@ class DefaultLoginCredentialsModel: ViewModel() { password.value = givenPassword } + + class LoginUrlAdapter(context: Context): ArrayAdapter(context, R.layout.text_list_item, android.R.id.text1) { + + /** + * list of known host names/domains (without https://), like "example.com" or "carddav.example.com" + */ + val knownUrls = mutableListOf() + + init { + InputStreamReader(context.assets.open("known-base-urls.txt")).use { reader -> + knownUrls.addAll(reader.readLines()) + } + } + + override fun getView(position: Int, convertView: View?, parent: ViewGroup): View { + val v = super.getView(position, convertView, parent) + v.findViewById(android.R.id.text2).visibility = View.GONE + return v + } + + override fun getFilter(): Filter = object: Filter() { + override fun performFiltering(constraint: CharSequence): FilterResults { + val str = constraint.removePrefix("https://").toString() + val results = if (str.isEmpty()) + knownUrls + else { + val regex = Pattern.compile("(\\.|\\b)" + Pattern.quote(str)) + knownUrls.filter { url -> + regex.matcher(url).find() + }.map { url -> "https://$url" } + } + return FilterResults().apply { + values = results + count = results.size + } + } + override fun publishResults(constraint: CharSequence?, results: FilterResults) { + clear() + (results.values as List?)?.let { suggestions -> + addAll(suggestions) + } + } + } + + } + } 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 04793df9d0191376b52832d7cfa4874d066b8dd9..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 @@ -1,55 +1,59 @@ -/* - * Copyright © Ricki Hirner (bitfire web engineering). - * All rights reserved. This program and the accompanying materials - * are made available under the terms of the GNU Public License v3.0 - * which accompanies this distribution, and is available at - * http://www.gnu.org/licenses/gpl.html - */ +/*************************************************************************************************** + * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. + **************************************************************************************************/ package at.bitfire.davdroid.ui.setup import android.app.Application import android.app.Dialog -import android.content.Intent import android.os.Bundle import android.view.LayoutInflater import android.view.View import android.view.ViewGroup import androidx.fragment.app.DialogFragment import androidx.fragment.app.Fragment -import androidx.lifecycle.* +import androidx.fragment.app.activityViewModels +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 class DetectConfigurationFragment: Fragment() { - private lateinit var loginModel: LoginModel - private lateinit var model: DetectConfigurationModel + val loginModel by activityViewModels() + val model by viewModels() override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - loginModel = ViewModelProviders.of(requireActivity()).get(LoginModel::class.java) - model = ViewModelProviders.of(this).get(DetectConfigurationModel::class.java) - model.detectConfiguration(loginModel).observe(this, Observer { result -> + if (model.blockProceedWithLogin(loginModel)) { + ECloudAccountHelper.showMultipleECloudAccountNotAcceptedDialog(requireActivity()) + return + } + + model.detectConfiguration(loginModel).observe(this, { result -> // save result for next step loginModel.configuration = result // remove "Detecting configuration" fragment, it shouldn't come back - requireFragmentManager().popBackStack() + parentFragmentManager.popBackStack() if (result.calDAV != null || result.cardDAV != null) - requireFragmentManager().beginTransaction() + parentFragmentManager.beginTransaction() .replace(android.R.id.content, AccountDetailsFragment()) .addToBackStack(null) .commit() else - requireFragmentManager().beginTransaction() + parentFragmentManager.beginTransaction() .add(NothingDetectedFragment(), null) .commit() }) @@ -66,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) @@ -104,21 +118,28 @@ class DetectConfigurationFragment: Fragment() { class NothingDetectedFragment: DialogFragment() { + val model by activityViewModels() + override fun onCreateDialog(savedInstanceState: Bundle?): Dialog { - val model = ViewModelProviders.of(requireActivity()).get(LoginModel::class.java) - return MaterialAlertDialogBuilder(requireActivity()) + var message = getString(R.string.login_no_caldav_carddav) + if (model.configuration?.encountered401 == true) + message += "\n\n" + getString(R.string.login_username_password_wrong) + + return MaterialAlertDialogBuilder(requireActivity(), R.style.CustomAlertDialogStyle) .setTitle(R.string.login_configuration_detection) - .setIcon(R.drawable.ic_error_dark) - .setMessage(R.string.login_no_caldav_carddav) + .setIcon(R.drawable.ic_error) + .setMessage(message) + .setCancelable(false) .setNeutralButton(R.string.login_view_logs) { _, _ -> - val intent = Intent(activity, DebugInfoActivity::class.java) - intent.putExtra(DebugInfoActivity.KEY_LOGS, model.configuration?.logs) + val intent = DebugInfoActivity.IntentBuilder(requireActivity()) + .withLogs(model.configuration?.logs) + .build() startActivity(intent) } .setPositiveButton(android.R.string.ok) { _, _ -> // just dismiss } - .create()!! + .create() } } 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/ILoginCredentialsFragment.kt b/app/src/main/java/at/bitfire/davdroid/ui/setup/ILoginCredentialsFragment.kt deleted file mode 100644 index 91fcc0ce2b2541dd9d24d7fa32cc7675a77d64bb..0000000000000000000000000000000000000000 --- a/app/src/main/java/at/bitfire/davdroid/ui/setup/ILoginCredentialsFragment.kt +++ /dev/null @@ -1,18 +0,0 @@ -/* - * Copyright © Ricki Hirner (bitfire web engineering). - * All rights reserved. This program and the accompanying materials - * are made available under the terms of the GNU Public License v3.0 - * which accompanies this distribution, and is available at - * http://www.gnu.org/licenses/gpl.html - */ - -package at.bitfire.davdroid.ui.setup - -import android.content.Intent -import androidx.fragment.app.Fragment - -interface ILoginCredentialsFragment { - - fun getFragment(intent: Intent): Fragment? - -} \ No newline at end of file diff --git a/app/src/main/java/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