diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 0000000000000000000000000000000000000000..2d8a186f5f8e87fca4c3b25b9387e8aafa0bc439 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,5 @@ +blank_issues_enabled: false +contact_links: + - name: DAVx⁵ Community Support + url: https://github.com/bitfireAT/davx5-ose/discussions + about: Ask and answer questions (including feature requests and bug reports) here. diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml new file mode 100644 index 0000000000000000000000000000000000000000..8259442352a07cb766ff3b558b214e319ab2a5c8 --- /dev/null +++ b/.github/workflows/codeql.yml @@ -0,0 +1,55 @@ +name: "CodeQL" + +on: + push: + branches: [ "dev-ose", main-ose ] + pull_request: + # The branches below must be a subset of the branches above + branches: [ "dev-ose" ] + schedule: + - cron: '22 10 * * 1' +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + analyze: + name: Analyze + runs-on: ubuntu-latest + permissions: + actions: read + contents: read + security-events: write + + strategy: + fail-fast: false + matrix: + language: [ 'java' ] + + steps: + - name: Checkout repository + uses: actions/checkout@v3 + - uses: actions/setup-java@v3 + with: + distribution: 'temurin' + java-version: 17 + - uses: gradle/gradle-build-action@v2 + + # Initializes the CodeQL tools for scanning. + - name: Initialize CodeQL + uses: github/codeql-action/init@v2 + with: + languages: ${{ matrix.language }} + + # Autobuild attempts to build any compiled languages (C/C++, C#, Go, or Java). + # If this step fails, then you should remove it and run the build manually (see below) + #- name: Autobuild + # uses: github/codeql-action/autobuild@v2 + + - name: Build + run: ./gradlew --no-daemon app:assembleOseDebug + + - name: Perform CodeQL Analysis + uses: github/codeql-action/analyze@v2 + with: + category: "/language:${{matrix.language}}" diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 6138883c0aea252ee032b5ad6eaf435489dd57d6..60497467c36040d819d8140b56babf835b0e47b3 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -3,6 +3,9 @@ on: push: tags: - v* +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true jobs: build: name: Create release @@ -10,21 +13,19 @@ jobs: contents: write runs-on: ubuntu-latest steps: - - uses: actions/checkout@v2 + - uses: actions/checkout@v3 + - uses: actions/setup-java@v3 with: - submodules: true - - uses: actions/setup-java@v2 - with: - distribution: 'temurin' - java-version: 11 - cache: 'gradle' - - uses: gradle/wrapper-validation-action@v1 + distribution: temurin + java-version: 17 + - uses: gradle/gradle-build-action@v2 - name: Prepare keystore run: echo ${{ secrets.android_keystore_base64 }} | base64 -d >$GITHUB_WORKSPACE/keystore.jks - - name: Build signed package - run: ./gradlew app:assembleRelease + # --no-configuration-cache is only required for AboutLibraries (bitfireAT/davx5#263, mikepenz/AboutLibraries#857) + # Remove it as soon as AboutLibraries is compatbile with the gradle configuration cache. + run: ./gradlew --no-configuration-cache --no-daemon app:assembleRelease env: ANDROID_KEYSTORE: ${{ github.workspace }}/keystore.jks ANDROID_KEYSTORE_PASSWORD: ${{ secrets.android_keystore_password }} @@ -32,7 +33,7 @@ jobs: ANDROID_KEY_PASSWORD: ${{ secrets.android_key_password }} - name: Create Github release - uses: softprops/action-gh-release@v0.1.14 + uses: softprops/action-gh-release@v1 with: prerelease: ${{ contains(github.ref_name, '-alpha') || contains(github.ref_name, '-beta') || contains(github.ref_name, '-rc') }} files: app/build/outputs/apk/ose/release/*.apk diff --git a/.github/workflows/test-dev.yml b/.github/workflows/test-dev.yml index 503eeb7cb00238af7713cc6e2911c046ac1e0b1e..f7d635ecb94efe9ffa1b67580dc8db7802ba544e 100644 --- a/.github/workflows/test-dev.yml +++ b/.github/workflows/test-dev.yml @@ -1,19 +1,20 @@ name: Development tests on: [push, pull_request] +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true jobs: + test: name: Tests without emulator runs-on: ubuntu-latest steps: - - uses: actions/checkout@v2 - with: - submodules: true - - uses: actions/setup-java@v2 + - uses: actions/checkout@v3 + - uses: actions/setup-java@v3 with: - distribution: zulu - java-version: 11 - cache: gradle - - uses: gradle/wrapper-validation-action@v1 + distribution: temurin + java-version: 17 + - uses: gradle/gradle-build-action@v2 - name: Run lint and unit tests run: ./gradlew app:check @@ -27,31 +28,54 @@ jobs: 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 + runs-on: ubuntu-latest-4-cores + strategy: + matrix: + api-level: [ 31 ] steps: - - uses: actions/checkout@v2 + - uses: actions/checkout@v3 + - uses: actions/setup-java@v3 with: - submodules: true - - uses: gradle/wrapper-validation-action@v1 + distribution: temurin + java-version: 17 + - uses: gradle/gradle-build-action@v2 - - name: Cache gradle dependencies - uses: actions/cache@v2 + - name: Enable KVM group perms + run: | + echo 'KERNEL=="kvm", GROUP="kvm", MODE="0666", OPTIONS+="static_node=kvm"' | sudo tee /etc/udev/rules.d/99-kvm4all.rules + sudo udevadm control --reload-rules + sudo udevadm trigger --name-match=kvm + + - name: Cache AVD and APKs + uses: actions/cache@v3 + id: avd-cache with: - key: ${{ runner.os }} path: | - ~/.gradle/caches - ~/.gradle/wrapper + ~/.android/avd/* + ~/.android/adb* + key: avd-${{ matrix.api-level }} + + - name: Create AVD and generate snapshot for caching + if: steps.avd-cache.outputs.cache-hit != 'true' + uses: reactivecircus/android-emulator-runner@v2 + with: + api-level: ${{ matrix.api-level }} + arch: x86_64 + force-avd-creation: false + emulator-options: -no-window -gpu swiftshader_indirect -noaudio -no-boot-anim -camera-back none + disable-animations: false + script: echo "Generated AVD snapshot for caching." + + - name: Run tests + uses: reactivecircus/android-emulator-runner@v2 + with: + api-level: ${{ matrix.api-level }} + arch: x86_64 + force-avd-creation: false + emulator-options: -no-snapshot-save -no-window -gpu swiftshader_indirect -noaudio -no-boot-anim -camera-back none + disable-animations: true + script: ./gradlew app:connectedCheck - - 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 @@ -59,4 +83,3 @@ jobs: name: test-results path: | app/build/reports - diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 8fc973b0fe921a52fb5dce12ab3aaaac1ad7776e..7a3601b0e3b7dcc6881020733acfa8fa52ad594d 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -1,4 +1,4 @@ -image: "registry.gitlab.e.foundation/e/os/docker-android-apps-cicd:latest" +image: "registry.gitlab.e.foundation/e/os/docker-android-apps-cicd:1263-Add_java_17_support" stages: - update-from-upstream @@ -19,8 +19,6 @@ cache: build: stage: build - variables: - GIT_SUBMODULE_STRATEGY: recursive script: - ./gradlew build -x test artifacts: @@ -28,7 +26,6 @@ build: - app/build/outputs/apk/ose/ .update-from-upstream: - image: registry.gitlab.e.foundation/e/tools/docker-tools:latest stage: update-from-upstream rules: - if: '$CI_PIPELINE_SOURCE == "schedule" && $CI_COMMIT_REF_NAME == $LOCAL_BRANCH' @@ -65,8 +62,6 @@ build: - git checkout -b $TEMP_LATEST_TAG_BRANCH # merge $LOCAL_BRANCH with $TEMP_LATEST_TAG_BRANCH & push - git checkout $LOCAL_BRANCH - - git submodule sync - - git submodule update --init --recursive --force - git merge $TEMP_LATEST_TAG_BRANCH - git push origin $LOCAL_BRANCH # remove unwanted local branch & remote @@ -78,7 +73,7 @@ update-default-branch: variables: LOCAL_BRANCH: main UPSTREAM_BRANCH: upstream/master - UPSTREAM_DEFAULT_BRANCH: main-ose + UPSTREAM_DEFAULT_BRANCH: release-ose UPSTREAM_URL: https://github.com/bitfireAT/davx5-ose.git TEMP_LATEST_TAG_BRANCH: latest_upstream_tag_branch diff --git a/.gitmodules b/.gitmodules deleted file mode 100644 index b23c031a137895f2bdabe62599aa62cc69e61547..0000000000000000000000000000000000000000 --- a/.gitmodules +++ /dev/null @@ -1,14 +0,0 @@ -[submodule "ical4android"] - path = ical4android - url = https://gitlab.e.foundation/e/os/ical4android.git - branch = main -[submodule "vcard4android"] - path = vcard4android - url = https://github.com/bitfireAT/vcard4android.git -[submodule "cert4android"] - path = cert4android - url = https://github.com/bitfireAT/cert4android.git -[submodule "dav4android"] - path = dav4android - url = https://gitlab.e.foundation/e/apps/dav4android.git - branch = main diff --git a/.tx/config b/.tx/config index 8b2d7db5f9df968d9e86c9311ee56968db541121..5342e141a45d98f54bc31db5e81beb5486536f5f 100644 --- a/.tx/config +++ b/.tx/config @@ -1,26 +1,30 @@ [main] -host = https://www.transifex.com +host = https://www.transifex.com +lang_map = ar_SA: ar, en_GB: en-rGB, es_MX: es-rMX, fa_IR: fa-rIR, fi_FI: fi, fr_FR: fr-rFR, nb_NO: nb, pt_BR: pt-rBR, sk_SK: sk, sl_SI: sl, sv_SE: sv-rSE, tr_TR: tr, zh_CN: zh, zh_TW: zh-rTW -[davx5.app] -source_file = app/src/main/res/values/strings.xml -source_lang = en -minimum_perc = 20 -file_filter = app/src/main/res/values-/strings.xml -lang_map = ar_SA: ar, fi_FI: fi, fr_FR: fr-rFR, nb_NO: nb, sk_SK: sk, sl_SI: sl, tr_TR: tr, zh_CN: zh, zh_TW: zh-rTW -type = ANDROID +[o:bitfireAT:p:davx5:r:app] +file_filter = app/src/main/res/values-/strings.xml +source_file = app/src/main/res/values/strings.xml +source_lang = en +type = ANDROID +minimum_perc = 20 +resource_name = App strings (all flavors) -[davx5.metadata-short-description] -source_file = fastlane/metadata/android/en-US/short_description.txt -source_lang = en -minimum_perc = 100 -file_filter = fastlane/metadata/android//short_description.txt -lang_map = ar_SA: ar, fi_FI: fi, fr_FR: fr-FR, nb_NO: nb, sk_SK: sk, sl_SI: sl, tr_TR: tr, zh_CN: zh, zh_TW: zh-TW -type = TXT -[davx5.metadata-full-description] -source_file = fastlane/metadata/android/en-US/full_description.txt -source_lang = en -minimum_perc = 100 -file_filter = fastlane/metadata/android//full_description.txt -lang_map = ar_SA: ar, fi_FI: fi, fr_FR: fr-FR, nb_NO: nb, sk_SK: sk, sl_SI: sl, tr_TR: tr, zh_CN: zh, zh_TW: zh-TW -type = TXT +# Attention: fastlane directories are like "en-us", not "en-rUS"! + +[o:bitfireAT:p:davx5:r:metadata-short-description] +file_filter = fastlane/metadata/android//short_description.txt +source_file = fastlane/metadata/android/en-US/short_description.txt +source_lang = en +type = TXT +minimum_perc = 100 +resource_name = Metadata: short description + +[o:bitfireAT:p:davx5:r:metadata-full-description] +file_filter = fastlane/metadata/android//full_description.txt +source_file = fastlane/metadata/android/en-US/full_description.txt +source_lang = en +type = TXT +minimum_perc = 100 +resource_name = Metadata: full description diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 0ac7be253f85d6813a882acbe6086097d0977bd6..010673dc832e49e33b2d1ce27b371cd153e72d04 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -20,17 +20,95 @@ Make sure that every file that contains significant work (at least every code fi starts with the copyright header: ``` -/*************************************************************************************************** +/* * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. - **************************************************************************************************/ + */ ``` You can set this in Android Studio: 1. Settings / Editor / Copyright / Copyright Profiles 2. Paste the text above (without the stars). -3. Set Formatting: separator before and after, length: 100. +3. Set Formatting so that the preview exactly looks like above; one blank line after the block. 4. Set this copyright profile as the default profile for the project. +5. Apply copyright: right-click in file tree / Update copyright. + + +# Style guide + +Please adhere to the [Kotlin style guide](https://developer.android.com/kotlin/style-guide) and +the following hints to make the source code uniform. + +**Have a look at similar files and copy their style if you're not certain.** + +Sample file (pay attention to blank lines and other formatting): + +``` + + +class MyClass(int arg1) : SuperClass() { + + companion object { + + const val CONSTANT_STRING = "Constant String"; + + fun staticMethod() { // Use static methods when you don't need the object context. + // … + } + + } + + var someProperty: String = "12345" + var someRelatedProperty: Int = 12345 + + init { + // constructor + } + + + /** + * Use KDoc to document important methods. Don't use it dogmatically, but writing proper documentation + * (not just the method name with spaces) helps you to re-think what the method shall really do. + */ + fun aFun1() { // Group methods by some logic (for instance, the order in which they will be called) + } // and alphabetically within a group. + + fun anotherFun() { + // … + } + + + fun somethingCompletelyDifferent() { // two blank lines to separate groups + } + + fun helperForSomethingCompletelyDifferent() { + someCall(arg1, arg2, arg3, arg4) // function calls: stick to one line unless it becomes confusing + } + + + class Model( // two blank lines before inner classes + someArgument: SomeLongClass, // arguments in multiple lines when they're too long for one line + anotherArgument: AnotherLongType, + thirdArgument: AnotherLongTypeName + ) : ViewModel() { + + fun abc() { + } + + } + +} +``` + +In general, use one blank line to separate things within one group of things, and two blank lines +to separate groups. In rare cases, when methods are tightly coupled and are only helpers for another +method, they may follow the calling method without separating blank lines. + +## Tests + +Test classes should be in the appropriate directory (see existing tests) and in the same package as the +tested class. Tests are usually be named like `methodToBeTested_Condition()`, see +[Test apps on Android](https://developer.android.com/training/testing/). # Authors diff --git a/README.md b/README.md index 9a31faf446f76f7b8a7651e0d95cde1afc7ec893..ff1700d85f1e278d394cd2b856df17df21e29a4a 100644 --- a/README.md +++ b/README.md @@ -4,11 +4,13 @@ Account Manager Account Manager is a fork of DAVx⁵. Please see the [DAVx⁵ Web site](https://www.davx5.com) for -comprehensive information about DAVx⁵. +comprehensive information about DAVx⁵, including a list of services it has been tested with. DAVx⁵ is licensed under the [GPLv3 License](LICENSE). -News and updates: [@davx5app](https://twitter.com/davx5app) on Twitter +News and updates: + +* [@davx5app@fosstodon.org](https://fosstodon.org/@davx5app) on Mastodon **Help, feature requests, bug reports: [DAVx⁵ discussions](https://github.com/bitfireAT/davx5-ose/discussions)** @@ -32,3 +34,5 @@ The most important libraries which are used by DAVx⁵ (alphabetically): * [ez-vcard](https://github.com/mangstadt/ez-vcard) – [New BSD License](https://github.com/mangstadt/ez-vcard/blob/master/LICENSE) * [iCal4j](https://github.com/ical4j/ical4j) – [New BSD License](https://github.com/ical4j/ical4j/blob/develop/LICENSE.txt) * [okhttp](https://square.github.io/okhttp) – [Apache License, Version 2.0](https://square.github.io/okhttp/#license) + +See _About / Libraries_ in the app for all used libraries and their licenses. diff --git a/app/build.gradle b/app/build.gradle index 1e697ceeb310b3522e088b46e92d7fd32127f3c9..1a900b11a777a22ae344e2c2b1f650205091ed9e 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -2,22 +2,26 @@ * 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' +plugins { + id 'com.android.application' + id 'com.google.devtools.ksp' + id 'com.mikepenz.aboutlibraries.plugin' + id 'dagger.hilt.android.plugin' + id 'kotlin-android' + id 'kotlin-kapt' // remove as soon as Hilt supports KSP [https://issuetracker.google.com/179057202] +} +// Android configuration android { compileSdkVersion 33 - buildToolsVersion '33.0.0' + buildToolsVersion '33.0.2' defaultConfig { applicationId "foundation.e.accountmanager" - versionCode 402060000 - versionName '4.2.6' + versionCode 403050200 + versionName '4.3.5.2' + buildConfigField "long", "buildTime", System.currentTimeMillis() + "L" setProperty "archivesBaseName", "davx5-ose-" + getVersionName() @@ -28,42 +32,45 @@ android { buildConfigField "String", "userAgent", "\"DAVx5\"" testInstrumentationRunner "at.bitfire.davdroid.CustomTestRunner" - - 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" + sourceCompatibility JavaVersion.VERSION_17 + targetCompatibility JavaVersion.VERSION_17 } buildFeatures { + buildConfig = true + compose = true viewBinding = true dataBinding = true + aidl = true + } + + composeOptions { + // Keep this in sync with Kotlin version: + // https://developer.android.com/jetpack/androidx/releases/compose-kotlin + kotlinCompilerExtensionVersion = "1.4.8" } // Java namespace for our classes (not to be confused with Android package ID) namespace 'at.bitfire.davdroid' - flavorDimensions "distribution" + flavorDimensions = [ "distribution" ] productFlavors { ose { + dimension "distribution" versionNameSuffix "-ose" } } sourceSets { - androidTest.assets.srcDirs += files("$projectDir/schemas".toString()) + androidTest { + assets.srcDirs += files("$projectDir/schemas".toString()) + } } signingConfigs { @@ -111,54 +118,84 @@ android { "googleAuthRedirectScheme": retrieveKey("GOOGLE_REDIRECT_URI") ] } + + androidResources { + generateLocaleConfig true + } } -dependencies { +ksp { + arg("room.schemaLocation", "$projectDir/schemas") +} + +configurations { + all { + // exclude modules which are in conflict with system libraries + exclude module: "commons-logging" + exclude group: "org.json", module: "json" - implementation project(':cert4android') - implementation project(':ical4android') - implementation project(':vcard4android') - implementation project(':dav4android') + // Groovy requires SDK 26+, and it's not required, so exclude it + exclude group: 'org.codehaus.groovy' + } +} - implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:${versions.kotlin}" - implementation "org.jetbrains.kotlinx:kotlinx-coroutines-core:1.6.4" - coreLibraryDesugaring 'com.android.tools:desugar_jdk_libs:1.1.6' +dependencies { + implementation "org.jetbrains.kotlin:kotlin-stdlib:${versions.kotlin}" + implementation "org.jetbrains.kotlinx:kotlinx-coroutines-core:1.7.1" + coreLibraryDesugaring 'com.android.tools:desugar_jdk_libs:2.0.3' implementation "com.google.dagger:hilt-android:${versions.hilt}" - kapt "com.google.dagger:hilt-android-compiler:${versions.hilt}" + kapt "com.google.dagger:hilt-android-compiler:${versions.hilt}" // replace by KSP when ready [https://issuetracker.google.com/179057202] - implementation 'androidx.appcompat:appcompat:1.5.1' - implementation 'androidx.browser:browser:1.4.0' + // support libs + implementation 'androidx.appcompat:appcompat:1.6.1' + implementation 'androidx.browser:browser:1.5.0' implementation 'androidx.cardview:cardview:1.0.0' implementation 'androidx.concurrent:concurrent-futures-ktx:1.1.0' implementation 'androidx.constraintlayout:constraintlayout:2.1.4' - implementation 'androidx.core:core-ktx:1.9.0' - implementation 'androidx.fragment:fragment-ktx:1.5.4' + implementation 'androidx.core:core-ktx:1.10.1' + implementation 'androidx.fragment:fragment-ktx:1.6.0' implementation 'androidx.hilt:hilt-work:1.0.0' kapt 'androidx.hilt:hilt-compiler:1.0.0' implementation 'androidx.lifecycle:lifecycle-extensions:2.2.0' - implementation 'androidx.lifecycle:lifecycle-viewmodel-ktx:2.5.1' + implementation 'androidx.lifecycle:lifecycle-viewmodel-ktx:2.6.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-alpha04' + implementation 'androidx.security:security-crypto:1.1.0-alpha06' implementation 'androidx.swiperefreshlayout:swiperefreshlayout:1.1.0' - implementation 'androidx.work:work-runtime-ktx:2.7.1' + implementation 'androidx.work:work-runtime-ktx:2.8.1' 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' - implementation 'androidx.work:work-runtime-ktx:2.7.1' - implementation 'com.google.android.material:material:1.7.0' - - 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 'androidx.recyclerview:recyclerview:1.3.0' + implementation 'com.google.android.material:material:1.9.0' + + // Jetpack Compose + implementation platform("androidx.compose:compose-bom:${versions.composeBom}") + implementation 'androidx.compose.material:material' + implementation 'androidx.compose.runtime:runtime-livedata' + debugImplementation 'androidx.compose.ui:ui-tooling' + implementation 'androidx.compose.ui:ui-tooling-preview' + implementation 'com.google.accompanist:accompanist-themeadapter-material:0.30.1' + + // Jetpack Room + implementation "androidx.room:room-runtime:${versions.room}" + implementation "androidx.room:room-ktx:${versions.room}" + implementation "androidx.room:room-paging:${versions.room}" + ksp "androidx.room:room-compiler:${versions.room}" + androidTestImplementation "androidx.room:room-testing:${versions.room}" + + // own libraries + implementation "com.github.bitfireAT:cert4android:${versions.cert4android}" + implementation files('../libs/ical4android.aar') + implementation "com.github.bitfireAT:vcard4android:${versions.vcard4android}" + + // third-party libs + implementation 'org.mnode.ical4j:ical4j:3.2.11' implementation 'com.jaredrummler:colorpicker:1.1.0' implementation "com.github.AppIntro:AppIntro:${versions.appIntro}" - implementation "com.mikepenz:aboutlibraries:${versions.aboutLibraries}" + implementation("com.github.bitfireAT:dav4jvm:${versions.dav4jvm}") { + exclude group: 'junit' + } + implementation "com.mikepenz:aboutlibraries-compose:${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}" @@ -166,13 +203,13 @@ dependencies { implementation 'commons-io:commons-io:2.8.0' //noinspection GradleDependency - dnsjava 3+ needs Java 8/Android 7 implementation 'dnsjava:dnsjava:2.1.9' + implementation 'net.openid:appauth:0.11.1' //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' implementation 'foundation.e:elib:0.0.1-alpha11' compileOnly 'org.jbundle.util.osgi.wrapped:org.jbundle.util.osgi.wrapped.org.apache.http.client:4.1.2' // remove after entire switch to lib v2 @@ -189,13 +226,14 @@ dependencies { androidTestImplementation "com.google.dagger:hilt-android-testing:${versions.hilt}" kaptAndroidTest "com.google.dagger:hilt-android-compiler:${versions.hilt}" + androidTestImplementation "androidx.arch.core:core-testing:2.2.0" androidTestImplementation 'androidx.test:core-ktx:1.5.0' - androidTestImplementation 'androidx.test:runner:1.5.1' + androidTestImplementation 'androidx.test:runner:1.5.2' androidTestImplementation 'androidx.test:rules:1.5.0' - androidTestImplementation 'androidx.test.ext:junit-ktx:1.1.4' - androidTestImplementation 'androidx.work:work-testing:2.7.1' + androidTestImplementation 'androidx.test.ext:junit-ktx:1.1.5' + androidTestImplementation 'androidx.work:work-testing:2.8.1' androidTestImplementation "com.squareup.okhttp3:mockwebserver:${versions.okhttp}" - androidTestImplementation 'io.mockk:mockk-android:1.13.2' + androidTestImplementation 'io.mockk:mockk-android:1.13.4' androidTestImplementation 'junit:junit:4.13.2' testImplementation "com.squareup.okhttp3:mockwebserver:${versions.okhttp}" diff --git a/app/proguard-rules-release.pro b/app/proguard-rules-release.pro index 45a059defc9e1d2cc86ed1019a0a2aa349e4c5c5..36a561092a55f72865f131eab5f0d7f7d1dd662c 100644 --- a/app/proguard-rules-release.pro +++ b/app/proguard-rules-release.pro @@ -66,3 +66,25 @@ -keep interface com.nextcloud.android.** { *; } -keep class org.apache.commons.httpclient.** { *; } -keep interface org.apache.commons.httpclient.** { *; } + +# Additional rules which are now required since missing classes can't be ignored in R8 anymore. +# [https://developer.android.com/build/releases/past-releases/agp-7-0-0-release-notes#r8-missing-class-warning] +-dontwarn com.android.org.conscrypt.SSLParametersImpl +-dontwarn groovy.** +-dontwarn java.beans.Transient +-dontwarn junit.textui.TestRunner +-dontwarn org.apache.harmony.xnet.provider.jsse.SSLParametersImpl +-dontwarn org.bouncycastle.jsse.** +-dontwarn org.codehaus.groovy.** +-dontwarn org.joda.** +-dontwarn org.json.* +-dontwarn org.jsoup.** +-dontwarn org.openjsse.javax.net.ssl.SSLParameters +-dontwarn org.openjsse.javax.net.ssl.SSLSocket +-dontwarn org.openjsse.net.ssl.OpenJSSE +-dontwarn org.xmlpull.** +-dontwarn sun.net.spi.nameservice.NameService +-dontwarn sun.net.spi.nameservice.NameServiceDescriptor + +-dontwarn org.slf4j.impl.StaticLoggerBinder +-dontwarn edu.umd.cs.findbugs.annotations.SuppressFBWarnings \ No newline at end of file diff --git a/app/schemas/at.bitfire.davdroid.db.AppDatabase/12.json b/app/schemas/at.bitfire.davdroid.db.AppDatabase/12.json new file mode 100644 index 0000000000000000000000000000000000000000..21cbbd66af68175fbc852f39c3278ccedf27a5fc --- /dev/null +++ b/app/schemas/at.bitfire.davdroid.db.AppDatabase/12.json @@ -0,0 +1,633 @@ +{ + "formatVersion": 1, + "database": { + "version": 12, + "identityHash": "188191fc58f4554baa135c298f85cd2f", + "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": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "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": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "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, `ownerId` 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, `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 , FOREIGN KEY(`ownerId`) REFERENCES `principal`(`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": "ownerId", + "columnName": "ownerId", + "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": "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": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "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" + ] + }, + { + "table": "principal", + "onDelete": "SET NULL", + "onUpdate": "NO ACTION", + "columns": [ + "ownerId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "principal", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `serviceId` INTEGER NOT NULL, `url` TEXT 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": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "displayName", + "columnName": "displayName", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_principal_serviceId_url", + "unique": true, + "columnNames": [ + "serviceId", + "url" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_principal_serviceId_url` ON `${TABLE_NAME}` (`serviceId`, `url`)" + } + ], + "foreignKeys": [ + { + "table": "service", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "serviceId" + ], + "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": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "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": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "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": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "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, '188191fc58f4554baa135c298f85cd2f')" + ] + } +} \ No newline at end of file diff --git a/app/src/androidTest/AndroidManifest.xml b/app/src/androidTest/AndroidManifest.xml index 986f2f8ad0eac21198c8029b503c1da131ee8b41..0e48dbab1a110644651c2930b37f26054f0263f3 100644 --- a/app/src/androidTest/AndroidManifest.xml +++ b/app/src/androidTest/AndroidManifest.xml @@ -1,15 +1,10 @@ + android:installLocation="internalOnly"> - - - - diff --git a/app/src/androidTest/java/at/bitfire/davdroid/MockingModule.kt b/app/src/androidTest/java/at/bitfire/davdroid/MockingModule.kt index 41cda464bcb465e38d83c24c8a126efd43cd234b..53dd3215075332361ca0e5a26ac9f6957eeabdd4 100644 --- a/app/src/androidTest/java/at/bitfire/davdroid/MockingModule.kt +++ b/app/src/androidTest/java/at/bitfire/davdroid/MockingModule.kt @@ -5,8 +5,6 @@ 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 @@ -20,17 +18,11 @@ import javax.inject.Singleton @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 = diff --git a/app/src/androidTest/java/at/bitfire/davdroid/TestUtils.kt b/app/src/androidTest/java/at/bitfire/davdroid/TestUtils.kt index 2ae8acf8b7480cf55f026c639b75675117525b50..05b1e7e9698e5bd434591afc7c2a28fe9e70512f 100644 --- a/app/src/androidTest/java/at/bitfire/davdroid/TestUtils.kt +++ b/app/src/androidTest/java/at/bitfire/davdroid/TestUtils.kt @@ -4,11 +4,69 @@ package at.bitfire.davdroid -import android.app.Application -import androidx.test.platform.app.InstrumentationRegistry +import android.content.Context +import androidx.lifecycle.LiveData +import androidx.lifecycle.Observer +import androidx.work.WorkInfo +import androidx.work.WorkManager +import androidx.work.WorkQuery +import org.jetbrains.annotations.TestOnly +import java.util.concurrent.CountDownLatch +import java.util.concurrent.TimeUnit +import java.util.concurrent.TimeoutException object TestUtils { - val targetApplication by lazy { InstrumentationRegistry.getInstrumentation().targetContext.applicationContext as Application } + @TestOnly + fun workScheduledOrRunning(context: Context, workerName: String): Boolean = + workInStates(context, workerName, listOf( + WorkInfo.State.ENQUEUED, + WorkInfo.State.RUNNING + )) + + @TestOnly + fun workScheduledOrRunningOrSuccessful(context: Context, workerName: String): Boolean = + workInStates(context, workerName, listOf( + WorkInfo.State.ENQUEUED, + WorkInfo.State.RUNNING, + WorkInfo.State.SUCCEEDED + )) + + @TestOnly + fun workInStates(context: Context, workerName: String, states: List): Boolean = + WorkManager.getInstance(context).getWorkInfos(WorkQuery.Builder + .fromUniqueWorkNames(listOf(workerName)) + .addStates(states) + .build() + ).get().isNotEmpty() + + + /* Copyright 2019 Google LLC. + SPDX-License-Identifier: Apache-2.0 */ + @TestOnly + fun LiveData.getOrAwaitValue( + time: Long = 2, + timeUnit: TimeUnit = TimeUnit.SECONDS + ): T { + var data: T? = null + val latch = CountDownLatch(1) + val observer = object : Observer { + override fun onChanged(value: T) { + data = value + latch.countDown() + this@getOrAwaitValue.removeObserver(this) + } + } + + this.observeForever(observer) + + // Don't wait indefinitely if the LiveData is not set. + if (!latch.await(time, timeUnit)) { + throw TimeoutException("LiveData value was never set.") + } + + @Suppress("UNCHECKED_CAST") + return data as T + } } \ 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 index 2598c45255de18658f16c7b1642b5cdf7fd67b66..53cc2106648c8690320eaea65526a4255e9dc647 100644 --- a/app/src/androidTest/java/at/bitfire/davdroid/db/AppDatabaseTest.kt +++ b/app/src/androidTest/java/at/bitfire/davdroid/db/AppDatabaseTest.kt @@ -4,6 +4,7 @@ package at.bitfire.davdroid.db +import android.content.Context import androidx.room.Room import androidx.room.testing.MigrationTestHelper import androidx.sqlite.db.framework.FrameworkSQLiteOpenHelperFactory @@ -15,27 +16,34 @@ class AppDatabaseTest { val TEST_DB = "test" + val context: Context = InstrumentationRegistry.getInstrumentation().targetContext + @Rule @JvmField val helper = MigrationTestHelper( - InstrumentationRegistry.getInstrumentation(), - AppDatabase::class.java.canonicalName, - FrameworkSQLiteOpenHelperFactory() + InstrumentationRegistry.getInstrumentation(), + AppDatabase::class.java, + listOf(), // no auto migrations until v8 + FrameworkSQLiteOpenHelperFactory() ) @Test fun testAllMigrations() { - // DB schema is available since version 8 + // DB schema is available since version 8, so create DB with v8 helper.createDatabase(TEST_DB, 8).close() - Room.databaseBuilder( - InstrumentationRegistry.getInstrumentation().targetContext, - AppDatabase::class.java, - TEST_DB - ).addMigrations(*AppDatabase.migrations).build().apply { - openHelper.writableDatabase - close() + val db = Room.databaseBuilder(context, AppDatabase::class.java, TEST_DB) + // manual migrations + .addMigrations(*AppDatabase.migrations) + // auto-migrations that need to be specified explicitly + .addAutoMigrationSpec(AppDatabase.AutoMigration11_12(context)) + .build() + try { + // open (with version 8) + migrate (to current version) database + db.openHelper.writableDatabase + } finally { + db.close() } } diff --git a/app/src/androidTest/java/at/bitfire/davdroid/db/CollectionTest.kt b/app/src/androidTest/java/at/bitfire/davdroid/db/CollectionTest.kt index addf472574510bea3ec65ebe1f4d0789248cd398..589f35821b35b8fc351dbd21e9e3e9bff92b75b6 100644 --- a/app/src/androidTest/java/at/bitfire/davdroid/db/CollectionTest.kt +++ b/app/src/androidTest/java/at/bitfire/davdroid/db/CollectionTest.kt @@ -6,9 +6,10 @@ package at.bitfire.davdroid.db 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.ResourceType -import at.bitfire.davdroid.HttpClient +import at.bitfire.davdroid.network.HttpClient import at.bitfire.davdroid.settings.SettingsManager import dagger.hilt.android.testing.HiltAndroidRule import dagger.hilt.android.testing.HiltAndroidTest @@ -43,7 +44,7 @@ class CollectionTest { @Before fun setUp() { - httpClient = HttpClient.Builder().build() + httpClient = HttpClient.Builder(InstrumentationRegistry.getInstrumentation().targetContext).build() Assume.assumeTrue(NetworkSecurityPolicy.getInstance().isCleartextTrafficPermitted) } diff --git a/app/src/androidTest/java/at/bitfire/davdroid/db/DaoToolsTest.kt b/app/src/androidTest/java/at/bitfire/davdroid/db/DaoToolsTest.kt deleted file mode 100644 index 885e327ae9fff372d031a97d9d71b541a002d4a0..0000000000000000000000000000000000000000 --- a/app/src/androidTest/java/at/bitfire/davdroid/db/DaoToolsTest.kt +++ /dev/null @@ -1,68 +0,0 @@ -/*************************************************************************************************** - * 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 -import org.junit.Test - -class DaoToolsTest { - - private lateinit var db: AppDatabase - - @Before - fun createDb() { - val context = InstrumentationRegistry.getInstrumentation().context - db = Room.inMemoryDatabaseBuilder(context, AppDatabase::class.java).build() - } - - @After - fun closeDb() { - db.close() - } - - @Test - fun testSyncAll() { - val serviceDao = db.serviceDao() - val service = Service(id=0, accountName="test", type=Service.TYPE_CALDAV, principal = null) - service.id = serviceDao.insertOrReplace(service) - - val homeSetDao = db.homeSetDao() - val entry1 = HomeSet(id=1, serviceId=service.id, 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, personal=true, url= "https://example.com/2".toHttpUrl()), - entry3 - ) - homeSetDao.insert(oldItems) - - val newItems = mutableMapOf() - newItems[entry1.url] = entry1 - - // no id, because identity is given by the url - val updated = HomeSet(id=0, serviceId=service.id, personal=true, - url= "https://example.com/2".toHttpUrl(), displayName="Updated Entry") - newItems[updated.url] = updated - - 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 }) - - val afterSync = homeSetDao.getByService(service.id) - assertEquals(afterSync.size, 3) - assertFalse(afterSync.contains(entry3)) - assertTrue(afterSync.contains(entry1)) - assertTrue(afterSync.contains(updated)) - assertTrue(afterSync.contains(created)) - } - -} \ No newline at end of file diff --git a/app/src/androidTest/java/at/bitfire/davdroid/db/HomesetDaoTest.kt b/app/src/androidTest/java/at/bitfire/davdroid/db/HomesetDaoTest.kt new file mode 100644 index 0000000000000000000000000000000000000000..f262163132831e4e4cfc3dbaac193030e55cffe4 --- /dev/null +++ b/app/src/androidTest/java/at/bitfire/davdroid/db/HomesetDaoTest.kt @@ -0,0 +1,81 @@ +/*************************************************************************************************** + * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. + **************************************************************************************************/ + +package at.bitfire.davdroid.db + +import dagger.hilt.android.testing.HiltAndroidRule +import dagger.hilt.android.testing.HiltAndroidTest +import okhttp3.HttpUrl.Companion.toHttpUrl +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import javax.inject.Inject + +@HiltAndroidTest +class HomesetDaoTest { + + @get:Rule + var hiltRule = HiltAndroidRule(this) + + @Inject + lateinit var db: AppDatabase + + @Before + fun setUp() { + hiltRule.inject() + } + + @After + fun tearDown() { + db.close() + } + + @Test + fun testInsertOrUpdate() { + // should insert new row or update (upsert) existing row - without changing its key! + val serviceId = createTestService() + val homeSetDao = db.homeSetDao() + + val entry1 = HomeSet(id=0, serviceId=serviceId, personal=true, url="https://example.com/1".toHttpUrl()) + val insertId1 = homeSetDao.insertOrUpdateByUrl(entry1) + assertEquals(1L, insertId1) + assertEquals(entry1.apply { id = 1L }, homeSetDao.getById(1L)) + + val updatedEntry1 = HomeSet(id=0, serviceId=serviceId, personal=true, url="https://example.com/1".toHttpUrl(), displayName="Updated Entry") + val updateId1 = homeSetDao.insertOrUpdateByUrl(updatedEntry1) + assertEquals(1L, updateId1) + assertEquals(updatedEntry1.apply { id = 1L }, homeSetDao.getById(1L)) + + val entry2 = HomeSet(id=0, serviceId=serviceId, personal=true, url= "https://example.com/2".toHttpUrl()) + val insertId2 = homeSetDao.insertOrUpdateByUrl(entry2) + assertEquals(2L, insertId2) + assertEquals(entry2.apply { id = 2L }, homeSetDao.getById(2L)) + } + + @Test + fun testDelete() { + // should delete row with given primary key (id) + val serviceId = createTestService() + val homesetDao = db.homeSetDao() + + val entry1 = HomeSet(id=1, serviceId=serviceId, personal=true, url= "https://example.com/1".toHttpUrl()) + + val insertId1 = homesetDao.insertOrUpdateByUrl(entry1) + assertEquals(1L, insertId1) + assertEquals(entry1, homesetDao.getById(1L)) + + homesetDao.delete(entry1) + + assertEquals(null, homesetDao.getById(1L)) + } + + fun createTestService() : Long { + val serviceDao = db.serviceDao() + val service = Service(id=0, accountName="test", type=Service.TYPE_CALDAV, accountType = null, addressBookAccountType = null, authState = null, principal = null) + return serviceDao.insertOrReplace(service) + } + +} \ No newline at end of file diff --git a/app/src/androidTest/java/at/bitfire/davdroid/db/MemoryDbModule.kt b/app/src/androidTest/java/at/bitfire/davdroid/db/MemoryDbModule.kt new file mode 100644 index 0000000000000000000000000000000000000000..bb45ecf30bc6873c06268bbd37f66c4fb89a3439 --- /dev/null +++ b/app/src/androidTest/java/at/bitfire/davdroid/db/MemoryDbModule.kt @@ -0,0 +1,33 @@ +/*************************************************************************************************** + * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. + **************************************************************************************************/ + +package at.bitfire.davdroid.db + +import android.content.Context +import androidx.room.Room +import dagger.Module +import dagger.Provides +import dagger.hilt.android.qualifiers.ApplicationContext +import dagger.hilt.components.SingletonComponent +import dagger.hilt.testing.TestInstallIn +import javax.inject.Singleton + +@Module +@TestInstallIn( + components = [ SingletonComponent::class ], + replaces = [ + AppDatabase.AppDatabaseModule::class + ] +) +class MemoryDbModule { + + @Provides + @Singleton + fun inMemoryDatabase(@ApplicationContext context: Context): AppDatabase = + Room.inMemoryDatabaseBuilder(context, AppDatabase::class.java) + // auto-migrations that need to be specified explicitly + .addAutoMigrationSpec(AppDatabase.AutoMigration11_12(context)) + .build() + +} \ No newline at end of file diff --git a/app/src/androidTest/java/at/bitfire/davdroid/Android10ResolverTest.kt b/app/src/androidTest/java/at/bitfire/davdroid/network/Android10ResolverTest.kt similarity index 93% rename from app/src/androidTest/java/at/bitfire/davdroid/Android10ResolverTest.kt rename to app/src/androidTest/java/at/bitfire/davdroid/network/Android10ResolverTest.kt index 8ef157c28b573aa77a1e3e5e1fdf305374023a91..b9177144092bb7d7a1f4d6ec348178c92a7ed5f6 100644 --- a/app/src/androidTest/java/at/bitfire/davdroid/Android10ResolverTest.kt +++ b/app/src/androidTest/java/at/bitfire/davdroid/network/Android10ResolverTest.kt @@ -2,7 +2,7 @@ * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. **************************************************************************************************/ -package at.bitfire.davdroid +package at.bitfire.davdroid.network import android.os.Build import androidx.test.filters.SdkSuppress @@ -16,7 +16,7 @@ import java.net.InetAddress class Android10ResolverTest { - val FQDN_DAVX5 = "www.google.com" + val FQDN_DAVX5 = "www.davx5.com" @Test @SdkSuppress(minSdkVersion = Build.VERSION_CODES.Q) diff --git a/app/src/androidTest/java/at/bitfire/davdroid/HttpClientTest.kt b/app/src/androidTest/java/at/bitfire/davdroid/network/HttpClientTest.kt similarity index 84% rename from app/src/androidTest/java/at/bitfire/davdroid/HttpClientTest.kt rename to app/src/androidTest/java/at/bitfire/davdroid/network/HttpClientTest.kt index f7a979b6327cef9a3575c8ba0fc56ca50e016ae3..2e2cccef3e79369603c97e4363ca2223cf4fc587 100644 --- a/app/src/androidTest/java/at/bitfire/davdroid/HttpClientTest.kt +++ b/app/src/androidTest/java/at/bitfire/davdroid/network/HttpClientTest.kt @@ -2,9 +2,12 @@ * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. **************************************************************************************************/ -package at.bitfire.davdroid +package at.bitfire.davdroid.network import android.security.NetworkSecurityPolicy +import androidx.test.platform.app.InstrumentationRegistry +import dagger.hilt.android.testing.HiltAndroidRule +import dagger.hilt.android.testing.HiltAndroidTest import okhttp3.Request import okhttp3.mockwebserver.MockResponse import okhttp3.mockwebserver.MockWebServer @@ -13,16 +16,23 @@ import org.junit.Assert.assertEquals import org.junit.Assert.assertNull import org.junit.Assume import org.junit.Before +import org.junit.Rule import org.junit.Test +@HiltAndroidTest class HttpClientTest { lateinit var server: MockWebServer lateinit var httpClient: HttpClient + @get:Rule + var hiltRule = HiltAndroidRule(this) + @Before fun setUp() { - httpClient = HttpClient.Builder().build() + hiltRule.inject() + + httpClient = HttpClient.Builder(InstrumentationRegistry.getInstrumentation().targetContext).build() server = MockWebServer() server.start(30000) diff --git a/app/src/androidTest/java/at/bitfire/davdroid/resource/LocalAddressBookTest.kt b/app/src/androidTest/java/at/bitfire/davdroid/resource/LocalAddressBookTest.kt index d8281384dcbba6a3e466a4d86ad9d3365b9e5fd0..93e2de89e8def7d32188f3face8c29d3ad7210a8 100644 --- a/app/src/androidTest/java/at/bitfire/davdroid/resource/LocalAddressBookTest.kt +++ b/app/src/androidTest/java/at/bitfire/davdroid/resource/LocalAddressBookTest.kt @@ -6,6 +6,7 @@ package at.bitfire.davdroid.resource import android.accounts.Account import android.accounts.AccountManager +import androidx.arch.core.executor.testing.InstantTaskExecutorRule import androidx.test.platform.app.InstrumentationRegistry import at.bitfire.davdroid.R import dagger.hilt.android.testing.HiltAndroidRule @@ -18,7 +19,7 @@ import org.junit.Test @HiltAndroidTest class LocalAddressBookTest { - @get:Rule() + @get:Rule val hiltRule = HiltAndroidRule(this) val context = InstrumentationRegistry.getInstrumentation().targetContext diff --git a/app/src/androidTest/java/at/bitfire/davdroid/ui/setup/DavResourceFinderTest.kt b/app/src/androidTest/java/at/bitfire/davdroid/servicedetection/DavResourceFinderTest.kt similarity index 92% rename from app/src/androidTest/java/at/bitfire/davdroid/ui/setup/DavResourceFinderTest.kt rename to app/src/androidTest/java/at/bitfire/davdroid/servicedetection/DavResourceFinderTest.kt index 10f80b925f3ef3b010d3c266147a2222ec8cb707..1224abe058d2667a173a141a2f47870bd3831e84 100644 --- a/app/src/androidTest/java/at/bitfire/davdroid/ui/setup/DavResourceFinderTest.kt +++ b/app/src/androidTest/java/at/bitfire/davdroid/servicedetection/DavResourceFinderTest.kt @@ -2,7 +2,7 @@ * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. **************************************************************************************************/ -package at.bitfire.davdroid.ui.setup +package at.bitfire.davdroid.servicedetection import android.security.NetworkSecurityPolicy import androidx.test.filters.SmallTest @@ -10,12 +10,11 @@ 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.servicedetection.DavResourceFinder -import at.bitfire.davdroid.settings.SettingsManager +import at.bitfire.davdroid.network.HttpClient import at.bitfire.davdroid.servicedetection.DavResourceFinder.Configuration.ServiceInfo +import at.bitfire.davdroid.settings.SettingsManager import dagger.hilt.android.testing.HiltAndroidRule import dagger.hilt.android.testing.HiltAndroidTest import okhttp3.mockwebserver.Dispatcher @@ -23,7 +22,11 @@ import okhttp3.mockwebserver.MockResponse import okhttp3.mockwebserver.MockWebServer import okhttp3.mockwebserver.RecordedRequest import org.junit.After -import org.junit.Assert.* +import org.junit.Assert.assertArrayEquals +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue import org.junit.Assume import org.junit.Before import org.junit.Rule @@ -60,20 +63,18 @@ class DavResourceFinderTest { lateinit var finder: DavResourceFinder lateinit var client: HttpClient - lateinit var loginModel: LoginModel @Before fun initServerAndClient() { server.dispatcher = TestDispatcher() server.start() - loginModel = LoginModel() - loginModel.baseURI = URI.create("/") - loginModel.credentials = Credentials("mock", "12345") + val baseURI = URI.create("/") + val credentials = Credentials("mock", "12345") - finder = DavResourceFinder(InstrumentationRegistry.getInstrumentation().targetContext, loginModel) - client = HttpClient.Builder() - .addAuthentication(null, loginModel.credentials!!) + finder = DavResourceFinder(InstrumentationRegistry.getInstrumentation().targetContext, baseURI, credentials) + client = HttpClient.Builder(InstrumentationRegistry.getInstrumentation().targetContext) + .addAuthentication(null, credentials) .build() Assume.assumeTrue(NetworkSecurityPolicy.getInstance().isCleartextTrafficPermitted) @@ -92,7 +93,7 @@ class DavResourceFinderTest { var info = ServiceInfo() DavResource(client.okHttpClient, server.url(PATH_CARDDAV + SUBPATH_PRINCIPAL)) .propfind(0, AddressbookHomeSet.NAME) { response, _ -> - finder.scanCardDavResponse(response, info) + finder.scanResponse(ResourceType.ADDRESSBOOK, response, info) } assertEquals(0, info.collections.size) assertEquals(1, info.homeSets.size) @@ -102,7 +103,7 @@ class DavResourceFinderTest { info = ServiceInfo() DavResource(client.okHttpClient, server.url(PATH_CARDDAV + SUBPATH_ADDRESSBOOK)) .propfind(0, ResourceType.NAME) { response, _ -> - finder.scanCardDavResponse(response, info) + finder.scanResponse(ResourceType.ADDRESSBOOK, response, info) } assertEquals(1, info.collections.size) assertEquals(server.url("$PATH_CARDDAV$SUBPATH_ADDRESSBOOK/"), info.collections.keys.first()) diff --git a/app/src/androidTest/java/at/bitfire/davdroid/servicedetection/RefreshCollectionsWorkerTest.kt b/app/src/androidTest/java/at/bitfire/davdroid/servicedetection/RefreshCollectionsWorkerTest.kt new file mode 100644 index 0000000000000000000000000000000000000000..c2baac00b6949c417a55a6ec47988175fab017de --- /dev/null +++ b/app/src/androidTest/java/at/bitfire/davdroid/servicedetection/RefreshCollectionsWorkerTest.kt @@ -0,0 +1,771 @@ +/*************************************************************************************************** + * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. + **************************************************************************************************/ + +package at.bitfire.davdroid.servicedetection + +import android.content.Context +import android.security.NetworkSecurityPolicy +import android.util.Log +import androidx.hilt.work.HiltWorkerFactory +import androidx.test.platform.app.InstrumentationRegistry +import androidx.work.Configuration +import androidx.work.WorkManager +import androidx.work.testing.WorkManagerTestInitHelper +import at.bitfire.davdroid.network.HttpClient +import at.bitfire.davdroid.TestUtils.workScheduledOrRunning +import at.bitfire.davdroid.db.AppDatabase +import at.bitfire.davdroid.db.Collection +import at.bitfire.davdroid.db.Credentials +import at.bitfire.davdroid.db.HomeSet +import at.bitfire.davdroid.db.Principal +import at.bitfire.davdroid.db.Service +import at.bitfire.davdroid.log.Logger +import at.bitfire.davdroid.settings.Settings +import at.bitfire.davdroid.settings.SettingsManager +import at.bitfire.davdroid.ui.NotificationUtils +import at.bitfire.davdroid.ui.setup.LoginModel +import dagger.hilt.android.testing.HiltAndroidRule +import dagger.hilt.android.testing.HiltAndroidTest +import io.mockk.every +import io.mockk.mockk +import okhttp3.mockwebserver.Dispatcher +import okhttp3.mockwebserver.MockResponse +import okhttp3.mockwebserver.MockWebServer +import okhttp3.mockwebserver.RecordedRequest +import org.apache.commons.lang3.StringUtils +import org.junit.After +import org.junit.Assert.assertEquals +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 java.net.URI +import javax.inject.Inject + +@HiltAndroidTest +class RefreshCollectionsWorkerTest { + + @get:Rule + var hiltRule = HiltAndroidRule(this) + + val context: Context = InstrumentationRegistry.getInstrumentation().targetContext + + @Inject + lateinit var workerFactory: HiltWorkerFactory + + @Before + fun setUp() { + hiltRule.inject() + + // The test application is an instance of HiltTestApplication, which doesn't initialize notification channels. + // However, we need notification channels for the ongoing work notifications. + NotificationUtils.createChannels(context) + + // Initialize WorkManager for instrumentation tests. + val config = Configuration.Builder() + .setMinimumLoggingLevel(Log.DEBUG) + .setWorkerFactory(workerFactory) + .build() + WorkManagerTestInitHelper.initializeTestWorkManager(context, config) + } + + + // Test dependencies + + companion object { + private const val PATH_CALDAV = "/caldav" + private const val PATH_CARDDAV = "/carddav" + + private const val SUBPATH_PRINCIPAL = "/principal" + private const val SUBPATH_PRINCIPAL_INACCESSIBLE = "/inaccessible-principal" + private const val SUBPATH_PRINCIPAL_WITHOUT_COLLECTIONS = "/principal2" + private const val SUBPATH_ADDRESSBOOK_HOMESET = "/addressbooks-homeset" + private const val SUBPATH_ADDRESSBOOK_HOMESET_EMPTY = "/addressbooks-homeset-empty" + private const val SUBPATH_ADDRESSBOOK = "/addressbooks/my-contacts" + private const val SUBPATH_ADDRESSBOOK_INACCESSIBLE = "/addressbooks/inaccessible-contacts" + } + + @Inject + lateinit var db: AppDatabase + + @Inject + lateinit var settings: SettingsManager + + var mockServer = MockWebServer() + + lateinit var client: HttpClient + lateinit var loginModel: LoginModel + + @Before + fun mockServerSetup() { + // Start mock web server + mockServer.dispatcher = TestDispatcher() + mockServer.start() + + loginModel = LoginModel() + loginModel.baseURI = URI.create("/") + loginModel.credentials = Credentials("mock", "12345") + + client = HttpClient.Builder(InstrumentationRegistry.getInstrumentation().targetContext) + .addAuthentication(null, loginModel.credentials!!) + .build() + + Assume.assumeTrue(NetworkSecurityPolicy.getInstance().isCleartextTrafficPermitted) + } + + @After + fun cleanUp() { + mockServer.shutdown() + db.close() + } + + + // Actual tests + + @Test + fun testRefreshCollections_enqueuesWorker() { + val service = createTestService(Service.TYPE_CALDAV)!! + val workerName = RefreshCollectionsWorker.refreshCollections(context, service.id) + assertTrue(workScheduledOrRunning(context, workerName)) + } + + @Test + fun testOnStopped_stopsRefreshThread() { + val service = createTestService(Service.TYPE_CALDAV)!! + val workerName = RefreshCollectionsWorker.refreshCollections(context, service.id) + WorkManager.getInstance(context).cancelUniqueWork(workerName) + assertFalse(workScheduledOrRunning(context, workerName)) + + // here we should test whether stopping the work really interrupts the refresh thread + } + + @Test + fun testQueryHomesets() { + val service = createTestService(Service.TYPE_CARDDAV)!! + val baseUrl = mockServer.url(PATH_CARDDAV + SUBPATH_PRINCIPAL) + + // Query home sets + RefreshCollectionsWorker.Refresher(db, service, settings, client.okHttpClient) + .queryHomeSets(baseUrl) + + // Check home sets have been saved to database + assertEquals(mockServer.url("$PATH_CARDDAV$SUBPATH_ADDRESSBOOK_HOMESET/"), db.homeSetDao().getByService(service.id).first().url) + assertEquals(1, db.homeSetDao().getByService(service.id).size) + } + + + // refreshHomesetsAndTheirCollections + + @Test + fun refreshHomesetsAndTheirCollections_addsNewCollection() { + val service = createTestService(Service.TYPE_CARDDAV)!! + + // save homeset in DB + val homesetId = db.homeSetDao().insert( + HomeSet(id=0, service.id, true, mockServer.url("$PATH_CARDDAV$SUBPATH_ADDRESSBOOK_HOMESET")) + ) + + // Refresh + RefreshCollectionsWorker.Refresher(db, service, settings, client.okHttpClient) + .refreshHomesetsAndTheirCollections() + + // Check the collection defined in homeset is now in the database + assertEquals( + Collection( + 1, + service.id, + homesetId, + 1, // will have gotten an owner too + Collection.TYPE_ADDRESSBOOK, + mockServer.url("$PATH_CARDDAV$SUBPATH_ADDRESSBOOK/"), + displayName = "My Contacts", + description = "My Contacts Description" + ), + db.collectionDao().getByService(service.id).first() + ) + } + + @Test + fun refreshHomesetsAndTheirCollections_updatesExistingCollection() { + val service = createTestService(Service.TYPE_CARDDAV)!! + + // save "old" collection in DB + val collectionId = db.collectionDao().insertOrUpdateByUrl( + Collection( + 0, + service.id, + null, + null, + Collection.TYPE_ADDRESSBOOK, + mockServer.url("$PATH_CARDDAV$SUBPATH_ADDRESSBOOK/"), + displayName = "My Contacts", + description = "My Contacts Description" + ) + ) + + // Refresh + RefreshCollectionsWorker.Refresher(db, service, settings, client.okHttpClient) + .refreshHomesetsAndTheirCollections() + + // Check the collection got updated + assertEquals( + Collection( + collectionId, + service.id, + null, + null, + Collection.TYPE_ADDRESSBOOK, + mockServer.url("$PATH_CARDDAV$SUBPATH_ADDRESSBOOK/"), + displayName = "My Contacts", + description = "My Contacts Description" + ), + db.collectionDao().get(collectionId) + ) + } + + @Test + fun refreshHomesetsAndTheirCollections_preservesCollectionFlags() { + val service = createTestService(Service.TYPE_CARDDAV)!! + + // save "old" collection in DB - with set flags + val collectionId = db.collectionDao().insertOrUpdateByUrl( + Collection( + 0, + service.id, + null, + null, + Collection.TYPE_ADDRESSBOOK, + mockServer.url("$PATH_CARDDAV$SUBPATH_ADDRESSBOOK/"), + displayName = "My Contacts", + description = "My Contacts Description", + forceReadOnly = true, + sync = true + ) + ) + + // Refresh + RefreshCollectionsWorker.Refresher(db, service, settings, client.okHttpClient) + .refreshHomesetsAndTheirCollections() + + // Check the collection got updated + assertEquals( + Collection( + collectionId, + service.id, + null, + null, + Collection.TYPE_ADDRESSBOOK, + mockServer.url("$PATH_CARDDAV$SUBPATH_ADDRESSBOOK/"), + displayName = "My Contacts", + description = "My Contacts Description", + forceReadOnly = true, + sync = true + ), + db.collectionDao().get(collectionId) + ) + } + + @Test + fun refreshHomesetsAndTheirCollections_marksRemovedCollectionsAsHomeless() { + val service = createTestService(Service.TYPE_CARDDAV)!! + + // save homeset in DB - which is empty (zero address books) on the serverside + val homesetId = db.homeSetDao().insert( + HomeSet(id=0, service.id, true, mockServer.url("$PATH_CARDDAV$SUBPATH_ADDRESSBOOK_HOMESET_EMPTY")) + ) + + // place collection in DB - as part of the homeset + val collectionId = db.collectionDao().insertOrUpdateByUrl( + Collection( + 0, + service.id, + homesetId, + null, + Collection.TYPE_ADDRESSBOOK, + mockServer.url("$PATH_CARDDAV$SUBPATH_ADDRESSBOOK/") + ) + ) + + // Refresh - should mark collection as homeless, because serverside homeset is empty + RefreshCollectionsWorker.Refresher(db, service, settings, client.okHttpClient) + .refreshHomesetsAndTheirCollections() + + // Check the collection, is now marked as homeless + assertEquals(null, db.collectionDao().get(collectionId)!!.homeSetId) + } + + @Test + fun refreshHomesetsAndTheirCollections_addsOwnerUrls() { + val service = createTestService(Service.TYPE_CARDDAV)!! + + // save a homeset in DB + val homesetId = db.homeSetDao().insert( + HomeSet(id=0, service.id, true, mockServer.url("$PATH_CARDDAV$SUBPATH_ADDRESSBOOK_HOMESET")) + ) + + // place collection in DB - as part of the homeset + val collectionId = db.collectionDao().insertOrUpdateByUrl( + Collection( + 0, + service.id, + homesetId, // part of above home set + null, + Collection.TYPE_ADDRESSBOOK, + mockServer.url("$PATH_CARDDAV$SUBPATH_ADDRESSBOOK/") + ) + ) + + // Refresh - homesets and their collections + assertEquals(0, db.principalDao().getByService(service.id).size) + RefreshCollectionsWorker.Refresher(db, service, settings, client.okHttpClient) + .refreshHomesetsAndTheirCollections() + + // Check principal saved and the collection was updated with its reference + val principals = db.principalDao().getByService(service.id) + assertEquals(1, principals.size) + assertEquals(mockServer.url("$PATH_CARDDAV$SUBPATH_PRINCIPAL"), principals[0].url) + assertEquals(null, principals[0].displayName) + assertEquals( + principals[0].id, + db.collectionDao().get(collectionId)!!.ownerId + ) + } + + + // refreshHomelessCollections + + @Test + fun refreshHomelessCollections_updatesExistingCollection() { + val service = createTestService(Service.TYPE_CARDDAV)!! + + // place homeless collection in DB + val collectionId = db.collectionDao().insertOrUpdateByUrl( + Collection( + 0, + service.id, + null, + null, + Collection.TYPE_ADDRESSBOOK, + mockServer.url("$PATH_CARDDAV$SUBPATH_ADDRESSBOOK/"), + ) + ) + + // Refresh + RefreshCollectionsWorker.Refresher(db, service, settings, client.okHttpClient) + .refreshHomelessCollections() + + // Check the collection got updated - with display name and description + assertEquals( + Collection( + collectionId, + service.id, + null, + 1, // will have gotten an owner too + Collection.TYPE_ADDRESSBOOK, + mockServer.url("$PATH_CARDDAV$SUBPATH_ADDRESSBOOK/"), + displayName = "My Contacts", + description = "My Contacts Description" + ), + db.collectionDao().get(collectionId) + ) + } + + @Test + fun refreshHomelessCollections_deletesInaccessibleCollections() { + val service = createTestService(Service.TYPE_CARDDAV)!! + + // place homeless collection in DB - it is also inaccessible + val collectionId = db.collectionDao().insertOrUpdateByUrl( + Collection( + 0, + service.id, + null, + null, + Collection.TYPE_ADDRESSBOOK, + mockServer.url("$PATH_CARDDAV$SUBPATH_ADDRESSBOOK_INACCESSIBLE") + ) + ) + + // Refresh - should delete collection + RefreshCollectionsWorker.Refresher(db, service, settings, client.okHttpClient) + .refreshHomelessCollections() + + // Check the collection got deleted + assertEquals(null, db.collectionDao().get(collectionId)) + } + + @Test + fun refreshHomelessCollections_addsOwnerUrls() { + val service = createTestService(Service.TYPE_CARDDAV)!! + + // place homeless collection in DB + val collectionId = db.collectionDao().insertOrUpdateByUrl( + Collection( + 0, + service.id, + null, + null, + Collection.TYPE_ADDRESSBOOK, + mockServer.url("$PATH_CARDDAV$SUBPATH_ADDRESSBOOK/"), + ) + ) + + // Refresh homeless collections + assertEquals(0, db.principalDao().getByService(service.id).size) + RefreshCollectionsWorker.Refresher(db, service, settings, client.okHttpClient) + .refreshHomelessCollections() + + // Check principal saved and the collection was updated with its reference + val principals = db.principalDao().getByService(service.id) + assertEquals(1, principals.size) + assertEquals(mockServer.url("$PATH_CARDDAV$SUBPATH_PRINCIPAL"), principals[0].url) + assertEquals(null, principals[0].displayName) + assertEquals( + principals[0].id, + db.collectionDao().get(collectionId)!!.ownerId + ) + } + + + // refreshPrincipals + + @Test + fun refreshPrincipals_inaccessiblePrincipal() { + val service = createTestService(Service.TYPE_CARDDAV)!! + + // place principal without display name in db + val principalId = db.principalDao().insert( + Principal( + 0, + service.id, + mockServer.url("$PATH_CARDDAV$SUBPATH_PRINCIPAL_INACCESSIBLE"), // no trailing slash + null // no display name for now + ) + ) + // add an associated collection - as the principal is rightfully removed otherwise + db.collectionDao().insertOrUpdateByUrl( + Collection( + 0, + service.id, + null, + principalId, // create association with principal + Collection.TYPE_ADDRESSBOOK, + mockServer.url("$PATH_CARDDAV$SUBPATH_ADDRESSBOOK/"), // with trailing slash + ) + ) + + // Refresh principals + RefreshCollectionsWorker.Refresher(db, service, settings, client.okHttpClient) + .refreshPrincipals() + + // Check principal was not updated + val principals = db.principalDao().getByService(service.id) + assertEquals(1, principals.size) + assertEquals(mockServer.url("$PATH_CARDDAV$SUBPATH_PRINCIPAL_INACCESSIBLE"), principals[0].url) + assertEquals(null, principals[0].displayName) + } + + @Test + fun refreshPrincipals_updatesPrincipal() { + val service = createTestService(Service.TYPE_CARDDAV)!! + + // place principal without display name in db + val principalId = db.principalDao().insert( + Principal( + 0, + service.id, + mockServer.url("$PATH_CARDDAV$SUBPATH_PRINCIPAL"), // no trailing slash + null // no display name for now + ) + ) + // add an associated collection - as the principal is rightfully removed otherwise + db.collectionDao().insertOrUpdateByUrl( + Collection( + 0, + service.id, + null, + principalId, // create association with principal + Collection.TYPE_ADDRESSBOOK, + mockServer.url("$PATH_CARDDAV$SUBPATH_ADDRESSBOOK/"), // with trailing slash + ) + ) + + // Refresh principals + RefreshCollectionsWorker.Refresher(db, service, settings, client.okHttpClient) + .refreshPrincipals() + + // Check principal now got a display name + val principals = db.principalDao().getByService(service.id) + assertEquals(1, principals.size) + assertEquals(mockServer.url("$PATH_CARDDAV$SUBPATH_PRINCIPAL"), principals[0].url) + assertEquals("Mr. Wobbles", principals[0].displayName) + } + + @Test + fun refreshPrincipals_deletesPrincipalsWithoutCollections() { + val service = createTestService(Service.TYPE_CARDDAV)!! + + // place principal without collections in DB + db.principalDao().insert( + Principal( + 0, + service.id, + mockServer.url("$PATH_CARDDAV$SUBPATH_PRINCIPAL_WITHOUT_COLLECTIONS/") + ) + ) + + // Refresh principals - detecting it does not own collections + RefreshCollectionsWorker.Refresher(db, service, settings, client.okHttpClient) + .refreshPrincipals() + + // Check principal was deleted + val principals = db.principalDao().getByService(service.id) + assertEquals(0, principals.size) + } + + // Others + + @Test + fun shouldPreselect_none() { + val service = createTestService(Service.TYPE_CARDDAV)!! + + val settings = mockk() + every { settings.getIntOrNull(Settings.PRESELECT_COLLECTIONS) } returns Settings.PRESELECT_COLLECTIONS_NONE + every { settings.getString(Settings.PRESELECT_COLLECTIONS_EXCLUDED) } returns "" + + val collection = Collection( + 0, + service.id, + 0, + type = Collection.TYPE_ADDRESSBOOK, + url = mockServer.url("$PATH_CARDDAV$SUBPATH_ADDRESSBOOK/") + ) + val homesets = listOf( + HomeSet(0, service.id, true, mockServer.url("$PATH_CARDDAV$SUBPATH_ADDRESSBOOK_HOMESET")) + ) + + val refresher = RefreshCollectionsWorker.Refresher(db, service, settings, client.okHttpClient) + assertFalse(refresher.shouldPreselect(collection, homesets)) + } + + @Test + fun shouldPreselect_all() { + val service = createTestService(Service.TYPE_CARDDAV)!! + + val settings = mockk() + every { settings.getIntOrNull(Settings.PRESELECT_COLLECTIONS) } returns Settings.PRESELECT_COLLECTIONS_ALL + every { settings.getString(Settings.PRESELECT_COLLECTIONS_EXCLUDED) } returns "" + + val collection = Collection( + 0, + service.id, + 0, + type = Collection.TYPE_ADDRESSBOOK, + url = mockServer.url("$PATH_CARDDAV$SUBPATH_ADDRESSBOOK/") + ) + val homesets = listOf( + HomeSet(0, service.id, false, mockServer.url("$PATH_CARDDAV$SUBPATH_ADDRESSBOOK_HOMESET")) + ) + + val refresher = RefreshCollectionsWorker.Refresher(db, service, settings, client.okHttpClient) + assertTrue(refresher.shouldPreselect(collection, homesets)) + } + + @Test + fun shouldPreselect_all_blacklisted() { + val service = createTestService(Service.TYPE_CARDDAV)!! + val url = mockServer.url("$PATH_CARDDAV$SUBPATH_ADDRESSBOOK/") + + val settings = mockk() + every { settings.getIntOrNull(Settings.PRESELECT_COLLECTIONS) } returns Settings.PRESELECT_COLLECTIONS_ALL + every { settings.getString(Settings.PRESELECT_COLLECTIONS_EXCLUDED) } returns url.toString() + + val collection = Collection( + 0, + service.id, + 0, + type = Collection.TYPE_ADDRESSBOOK, + url = url + ) + val homesets = listOf( + HomeSet(0, service.id, false, mockServer.url("$PATH_CARDDAV$SUBPATH_ADDRESSBOOK_HOMESET")) + ) + + val refresher = RefreshCollectionsWorker.Refresher(db, service, settings, client.okHttpClient) + assertFalse(refresher.shouldPreselect(collection, homesets)) + } + + @Test + fun shouldPreselect_personal_notPersonal() { + val service = createTestService(Service.TYPE_CARDDAV)!! + + val settings = mockk() + every { settings.getIntOrNull(Settings.PRESELECT_COLLECTIONS) } returns Settings.PRESELECT_COLLECTIONS_PERSONAL + every { settings.getString(Settings.PRESELECT_COLLECTIONS_EXCLUDED) } returns "" + + val collection = Collection( + 0, + service.id, + 0, + type = Collection.TYPE_ADDRESSBOOK, + url = mockServer.url("$PATH_CARDDAV$SUBPATH_ADDRESSBOOK/") + ) + val homesets = listOf( + HomeSet(0, service.id, false, mockServer.url("$PATH_CARDDAV$SUBPATH_ADDRESSBOOK_HOMESET")) + ) + + val refresher = RefreshCollectionsWorker.Refresher(db, service, settings, client.okHttpClient) + assertFalse(refresher.shouldPreselect(collection, homesets)) + } + + @Test + fun shouldPreselect_personal_isPersonal() { + val service = createTestService(Service.TYPE_CARDDAV)!! + + val settings = mockk() + every { settings.getIntOrNull(Settings.PRESELECT_COLLECTIONS) } returns Settings.PRESELECT_COLLECTIONS_PERSONAL + every { settings.getString(Settings.PRESELECT_COLLECTIONS_EXCLUDED) } returns "" + + val collection = Collection( + 0, + service.id, + 0, + type = Collection.TYPE_ADDRESSBOOK, + url = mockServer.url("$PATH_CARDDAV$SUBPATH_ADDRESSBOOK/") + ) + val homesets = listOf( + HomeSet(0, service.id, true, mockServer.url("$PATH_CARDDAV$SUBPATH_ADDRESSBOOK_HOMESET")) + ) + + val refresher = RefreshCollectionsWorker.Refresher(db, service, settings, client.okHttpClient) + assertTrue(refresher.shouldPreselect(collection, homesets)) + } + + @Test + fun shouldPreselect_personal_isPersonalButBlacklisted() { + val service = createTestService(Service.TYPE_CARDDAV)!! + val collectionUrl = mockServer.url("$PATH_CARDDAV$SUBPATH_ADDRESSBOOK/") + + val settings = mockk() + every { settings.getIntOrNull(Settings.PRESELECT_COLLECTIONS) } returns Settings.PRESELECT_COLLECTIONS_PERSONAL + every { settings.getString(Settings.PRESELECT_COLLECTIONS_EXCLUDED) } returns collectionUrl.toString() + + val collection = Collection( + 0, + service.id, + 0, + type = Collection.TYPE_ADDRESSBOOK, + url = collectionUrl + ) + val homesets = listOf( + HomeSet(0, service.id, true, mockServer.url("$PATH_CARDDAV$SUBPATH_ADDRESSBOOK_HOMESET")) + ) + + val refresher = RefreshCollectionsWorker.Refresher(db, service, settings, client.okHttpClient) + assertFalse(refresher.shouldPreselect(collection, homesets)) + } + + // Test helpers and dependencies + + private fun createTestService(serviceType: String) : Service? { + val service = Service(id=0, accountName="test", type=serviceType, accountType = null, addressBookAccountType = null, authState = null, principal = null) + val serviceId = db.serviceDao().insertOrReplace(service) + return db.serviceDao().get(serviceId) + } + + class TestDispatcher: Dispatcher() { + + override fun dispatch(request: RecordedRequest): MockResponse { + val path = StringUtils.removeEnd(request.path!!, "/") + + if (request.method.equals("PROPFIND", true)) { + val properties = when (path) { + PATH_CALDAV, + PATH_CARDDAV -> + "" + + " $path${SUBPATH_PRINCIPAL}" + + "" + + PATH_CARDDAV + SUBPATH_PRINCIPAL -> + "" + + "Mr. Wobbles" + + "" + + " ${PATH_CARDDAV}${SUBPATH_ADDRESSBOOK_HOMESET}" + + "" + + PATH_CARDDAV + SUBPATH_PRINCIPAL_WITHOUT_COLLECTIONS -> + "" + + " ${PATH_CARDDAV}${SUBPATH_ADDRESSBOOK_HOMESET_EMPTY}" + + "" + + "Mr. Wobbles Jr." + + PATH_CARDDAV + SUBPATH_ADDRESSBOOK, + PATH_CARDDAV + SUBPATH_ADDRESSBOOK_HOMESET -> + "" + + " " + + " " + + "" + + "My Contacts" + + "My Contacts Description" + + "" + + " ${PATH_CARDDAV + SUBPATH_PRINCIPAL}" + + "" + + PATH_CALDAV + SUBPATH_PRINCIPAL -> + "" + + " urn:unknown-entry" + + " mailto:email1@example.com" + + " mailto:email2@example.com" + + "" + + SUBPATH_ADDRESSBOOK_HOMESET_EMPTY -> "" + + else -> "" + } + + var responseBody = "" + var responseCode = 207 + when (path) { + PATH_CARDDAV + SUBPATH_ADDRESSBOOK_HOMESET -> + responseBody = + "" + + "" + + " ${PATH_CARDDAV + SUBPATH_ADDRESSBOOK}" + + " " + + properties + + " " + + " HTTP/1.1 200 OK" + + "" + + "" + + PATH_CARDDAV + SUBPATH_PRINCIPAL_INACCESSIBLE, + PATH_CARDDAV + SUBPATH_ADDRESSBOOK_INACCESSIBLE -> + responseCode = 404 + + else -> + responseBody = + "" + + "" + + " $path" + + " "+ + properties + + " " + + "" + + "" + } + + Logger.log.info("Queried: $path") + Logger.log.info("Response: $responseBody") + return MockResponse() + .setResponseCode(responseCode) + .setBody(responseBody) + } + + return MockResponse().setResponseCode(404) + } + + } +} \ 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 index f2060cad3b55ca9cbf02e5b5af18fa62579b2a16..8bc6a27f90bb28f248999a4acfbb72fdb6f59f78 100644 --- a/app/src/androidTest/java/at/bitfire/davdroid/settings/AccountSettingsTest.kt +++ b/app/src/androidTest/java/at/bitfire/davdroid/settings/AccountSettingsTest.kt @@ -7,24 +7,32 @@ 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 android.util.Log +import androidx.annotation.RequiresApi import androidx.test.platform.app.InstrumentationRegistry +import androidx.work.Configuration +import androidx.work.testing.WorkManagerTestInitHelper import at.bitfire.davdroid.R +import at.bitfire.davdroid.TestUtils import at.bitfire.davdroid.db.Credentials import at.bitfire.davdroid.syncadapter.AccountUtils +import at.bitfire.davdroid.syncadapter.PeriodicSyncWorker +import at.bitfire.davdroid.ui.NotificationUtils +import at.bitfire.ical4android.TaskProvider import dagger.hilt.android.testing.HiltAndroidRule import dagger.hilt.android.testing.HiltAndroidTest import org.junit.After -import org.junit.Assert.* +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue import org.junit.Before import org.junit.Rule import org.junit.Test -import java.util.concurrent.TimeUnit import javax.inject.Inject -@HiltAndroidTest +/*@HiltAndroidTest class AccountSettingsTest { @get:Rule @@ -34,31 +42,54 @@ class AccountSettingsTest { lateinit var settingsManager: SettingsManager - val context = InstrumentationRegistry.getInstrumentation().targetContext + private val context = InstrumentationRegistry.getInstrumentation().targetContext - val account = Account("Test Account", context.getString(R.string.account_type)) + val account = Account(javaClass.canonicalName, context.getString(R.string.account_type)) val fakeCredentials = Credentials("test", "test") + val authorities = listOf( + context.getString(R.string.address_books_authority), + CalendarContract.AUTHORITY, + TaskProvider.ProviderName.JtxBoard.authority, + TaskProvider.ProviderName.OpenTasks.authority, + TaskProvider.ProviderName.TasksOrg.authority + ) + @Before fun setUp() { hiltRule.inject() - assertTrue(AccountUtils.createAccount(context, account, AccountSettings.initialUserData(fakeCredentials, null))) + assertTrue(AccountUtils.createAccount( + context, + account, + AccountSettings.initialUserData(fakeCredentials) + )) ContentResolver.setIsSyncable(account, CalendarContract.AUTHORITY, 1) ContentResolver.setIsSyncable(account, ContactsContract.AUTHORITY, 0) + + // The test application is an instance of HiltTestApplication, which doesn't initialize notification channels. + // However, we need notification channels for the ongoing work notifications. + NotificationUtils.createChannels(context) + + // Initialize WorkManager for instrumentation tests. + val config = Configuration.Builder() + .setMinimumLoggingLevel(Log.DEBUG) + .build() + WorkManagerTestInitHelper.initializeTestWorkManager(context, config) } @After + @RequiresApi(22) fun removeAccount() { - val futureResult = AccountManager.get(context).removeAccount(account, {}, null) - assertTrue(futureResult.getResult(10, TimeUnit.SECONDS)) + AccountManager.get(context).removeAccountExplicitly(account) } @Test fun testSyncIntervals() { val settings = AccountSettings(context, account) - val presetIntervals = context.resources.getStringArray(R.array.settings_sync_interval_seconds) + val presetIntervals = + context.resources.getStringArray(R.array.settings_sync_interval_seconds) .map { it.toLong() } .filter { it != AccountSettings.SYNC_INTERVAL_MANUALLY } for (interval in presetIntervals) { @@ -68,21 +99,42 @@ class AccountSettingsTest { } @Test - fun testSyncIntervals_IsNotSyncable() { + fun testSyncIntervals_Syncable() { 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) + val result = settings.setSyncInterval(CalendarContract.AUTHORITY, interval) + assertTrue(result) } - @Test + @Test(expected = IllegalArgumentException::class) 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) + settings.setSyncInterval(CalendarContract.AUTHORITY, interval) + } + + @Test + fun testSyncIntervals_activatesPeriodicSyncWorker() { + val settings = AccountSettings(context, account) + val interval = 15*60L + for (authority in authorities) { + ContentResolver.setIsSyncable(account, authority, 1) + assertTrue(settings.setSyncInterval(authority, interval)) + assertTrue(TestUtils.workScheduledOrRunningOrSuccessful(context, PeriodicSyncWorker.workerName(account, authority))) + assertEquals(interval, settings.getSyncInterval(authority)) + } + } + + @Test + fun testSyncIntervals_disablesPeriodicSyncWorker() { + val settings = AccountSettings(context, account) + val interval = AccountSettings.SYNC_INTERVAL_MANUALLY // -1 + for (authority in authorities) { + ContentResolver.setIsSyncable(account, authority, 1) + assertTrue(settings.setSyncInterval(authority, interval)) + assertFalse(TestUtils.workScheduledOrRunningOrSuccessful(context, PeriodicSyncWorker.workerName(account, authority))) + assertEquals(AccountSettings.SYNC_INTERVAL_MANUALLY, settings.getSyncInterval(authority)) + } } -} \ No newline at end of file +}*/ \ No newline at end of file diff --git a/app/src/androidTest/java/at/bitfire/davdroid/ui/AppSettingsActivityTest.kt b/app/src/androidTest/java/at/bitfire/davdroid/ui/AppSettingsActivityTest.kt new file mode 100644 index 0000000000000000000000000000000000000000..f326c7f466efb0f18af2d4e276027af99b45c658 --- /dev/null +++ b/app/src/androidTest/java/at/bitfire/davdroid/ui/AppSettingsActivityTest.kt @@ -0,0 +1,13 @@ +package at.bitfire.davdroid.ui + +import org.junit.Assert.assertEquals +import org.junit.Test + +class AppSettingsActivityTest { + @Test + fun testResourceQualifierToLanguageTag() { + assertEquals("en", AppSettingsActivity.resourceQualifierToLanguageTag("en")) + assertEquals("en-GB", AppSettingsActivity.resourceQualifierToLanguageTag("en-GB")) + assertEquals("en-GB", AppSettingsActivity.resourceQualifierToLanguageTag("en-rGB")) + } +} diff --git a/app/src/androidTest/java/at/bitfire/davdroid/ui/setup/AccountDetailsFragmentTest.kt b/app/src/androidTest/java/at/bitfire/davdroid/ui/setup/AccountDetailsFragmentTest.kt new file mode 100644 index 0000000000000000000000000000000000000000..20ee30434ca997d0f3a77986da9174be6e602c4f --- /dev/null +++ b/app/src/androidTest/java/at/bitfire/davdroid/ui/setup/AccountDetailsFragmentTest.kt @@ -0,0 +1,222 @@ +/*************************************************************************************************** + * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. + **************************************************************************************************/ + +package at.bitfire.davdroid.ui.setup + +import android.accounts.AccountManager +import android.content.ContentResolver +import android.content.Context +import android.provider.CalendarContract +import android.util.Log +import androidx.annotation.RequiresApi +import androidx.arch.core.executor.testing.InstantTaskExecutorRule +import androidx.test.platform.app.InstrumentationRegistry +import androidx.work.Configuration +import androidx.work.testing.WorkManagerTestInitHelper +import at.bitfire.davdroid.R +import at.bitfire.davdroid.TestUtils.getOrAwaitValue +import at.bitfire.davdroid.db.AppDatabase +import at.bitfire.davdroid.db.Credentials +import at.bitfire.davdroid.resource.TaskUtils +import at.bitfire.davdroid.servicedetection.DavResourceFinder +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 at.bitfire.vcard4android.GroupMethod +import dagger.hilt.android.testing.HiltAndroidRule +import dagger.hilt.android.testing.HiltAndroidTest +import io.mockk.* +import org.junit.* +import org.junit.Assert.* +import javax.inject.Inject + +// COMMENTED OUT because it doesn't run reliably [see https://github.com/bitfireAT/davx5/pull/320] +/*@HiltAndroidTest +class AccountDetailsFragmentTest { + + @get:Rule + var hiltRule = HiltAndroidRule(this) + + @get:Rule + var instantTaskExecutorRule = InstantTaskExecutorRule() // required for TestUtils: LiveData.getOrAwaitValue() + + + @Inject + lateinit var db: AppDatabase + @Inject + lateinit var settingsManager: SettingsManager + + private val targetContext: Context = InstrumentationRegistry.getInstrumentation().targetContext + private val fakeCredentials = Credentials("test", "test") + + @Before + fun setUp() { + hiltRule.inject() + + // The test application is an instance of HiltTestApplication, which doesn't initialize notification channels. + // However, we need notification channels for the ongoing work notifications. + NotificationUtils.createChannels(targetContext) + + // Initialize WorkManager for instrumentation tests. + val config = Configuration.Builder() + .setMinimumLoggingLevel(Log.DEBUG) + .build() + WorkManagerTestInitHelper.initializeTestWorkManager(targetContext, config) + } + + @After + fun tearDown() { + // Remove accounts created by tests + val am = AccountManager.get(targetContext) + val accounts = am.getAccountsByType(targetContext.getString(R.string.account_type)) + for (account in accounts) { + am.removeAccountExplicitly(account) + } + } + + + @Test + fun testModel_CreateAccount_configuresContactsAndCalendars() { + val accountName = "MyAccountName" + val emptyServiceInfo = DavResourceFinder.Configuration.ServiceInfo() + val config = DavResourceFinder.Configuration(emptyServiceInfo, emptyServiceInfo, false, "") + + // Create account -> should also set sync interval in settings + val accountCreated = AccountDetailsFragment.Model(targetContext, db, settingsManager) + .createAccount(accountName, fakeCredentials, config, GroupMethod.GROUP_VCARDS) + assertTrue(accountCreated.getOrAwaitValue(5)) + + // Get the created account + val account = AccountManager.get(targetContext) + .getAccountsByType(targetContext.getString(R.string.account_type)) + .first { account -> account.name == accountName } + + for (authority in listOf( + targetContext.getString(R.string.address_books_authority), + CalendarContract.AUTHORITY, + )) { + // Check isSyncable was set + assertEquals(1, ContentResolver.getIsSyncable(account, authority)) + + // Check default sync interval was set for + // [AccountSettings.KEY_SYNC_INTERVAL_ADDRESSBOOKS], + // [AccountSettings.KEY_SYNC_INTERVAL_CALENDARS] + assertEquals( + settingsManager.getLong(Settings.DEFAULT_SYNC_INTERVAL), + AccountSettings(targetContext, account).getSyncInterval(authority) + ) + } + } + + @Test + @RequiresApi(28) // for mockkObject + fun testModel_CreateAccount_configuresCalendarsWithTasks() { + for (provider in listOf( + TaskProvider.ProviderName.JtxBoard, + TaskProvider.ProviderName.OpenTasks, + TaskProvider.ProviderName.TasksOrg + )) { + val accountName = "testAccount-$provider" + val emptyServiceInfo = DavResourceFinder.Configuration.ServiceInfo() + val config = DavResourceFinder.Configuration(emptyServiceInfo, emptyServiceInfo, false, "") + + // Mock TaskUtils currentProvider method, pretending that one of the task apps is installed :) + mockkObject(TaskUtils) + every { TaskUtils.currentProvider(targetContext) } returns provider + assertEquals(provider, TaskUtils.currentProvider(targetContext)) + + // Create account -> should also set tasks sync interval in settings + val accountCreated = + AccountDetailsFragment.Model(targetContext, db, settingsManager) + .createAccount(accountName, fakeCredentials, config, GroupMethod.GROUP_VCARDS) + assertTrue(accountCreated.getOrAwaitValue(5)) + + // Get the created account + val account = AccountManager.get(targetContext) + .getAccountsByType(targetContext.getString(R.string.account_type)) + .first { account -> account.name == accountName } + + // Calendar: Check isSyncable and default interval are set correctly + assertEquals(1, ContentResolver.getIsSyncable(account, CalendarContract.AUTHORITY)) + assertEquals( + settingsManager.getLong(Settings.DEFAULT_SYNC_INTERVAL), + AccountSettings(targetContext, account).getSyncInterval(CalendarContract.AUTHORITY) + ) + + // Tasks: Check isSyncable and default sync interval were set + assertEquals(1, ContentResolver.getIsSyncable(account, provider.authority)) + assertEquals( + settingsManager.getLong(Settings.DEFAULT_SYNC_INTERVAL), + AccountSettings(targetContext, account).getSyncInterval(provider.authority) + ) + } + } + + @Test + @RequiresApi(28) + fun testModel_CreateAccount_configuresCalendarsWithoutTasks() { + try { + val accountName = "testAccount" + val emptyServiceInfo = DavResourceFinder.Configuration.ServiceInfo() + val config = DavResourceFinder.Configuration(emptyServiceInfo, emptyServiceInfo, false, "") + + // Mock TaskUtils currentProvider method, pretending that no task app is installed + mockkObject(TaskUtils) + every { TaskUtils.currentProvider(targetContext) } returns null + assertEquals(null, TaskUtils.currentProvider(targetContext)) + + // Mock static ContentResolver calls + // TODO: Should not be needed, see below + mockkStatic(ContentResolver::class) + every { ContentResolver.setIsSyncable(any(), any(), any()) } returns Unit + every { ContentResolver.getIsSyncable(any(), any()) } returns 1 + + // Create account will try to start an initial collection refresh, which we don't need, so we mockk it + mockkObject(RefreshCollectionsWorker.Companion) + every { RefreshCollectionsWorker.refreshCollections(any(), any()) } returns "" + + // Create account -> should also set tasks sync interval in settings + val accountCreated = AccountDetailsFragment.Model(targetContext, db, settingsManager) + .createAccount(accountName, fakeCredentials, config, GroupMethod.GROUP_VCARDS) + assertTrue(accountCreated.getOrAwaitValue(5)) + + // Get the created account + val account = AccountManager.get(targetContext) + .getAccountsByType(targetContext.getString(R.string.account_type)) + .first { account -> account.name == accountName } + val accountSettings = AccountSettings(targetContext, account) + + // Calendar: Check automatic sync is enabled and default interval are set correctly + assertEquals(1, ContentResolver.getIsSyncable(account, CalendarContract.AUTHORITY)) + assertEquals( + settingsManager.getLong(Settings.DEFAULT_SYNC_INTERVAL), + accountSettings.getSyncInterval(CalendarContract.AUTHORITY) + ) + + // Tasks: Check isSyncable state is unknown (=-1) and sync interval is "unset" (=null) + for (authority in listOf( + TaskProvider.ProviderName.JtxBoard.authority, + TaskProvider.ProviderName.OpenTasks.authority, + TaskProvider.ProviderName.TasksOrg.authority + )) { + // Until below is fixed, just verify the method for enabling sync did not get called + verify(exactly = 0) { ContentResolver.setIsSyncable(account, authority, 1) } + + // TODO: Flaky, returns 1, although it should not. It only returns -1 if the test is run + // alone, on a blank emulator and if it's the first test run. + // This seems to have to do with the previous calls to ContentResolver by other tests. + // Finding a a way of resetting the ContentResolver before each test is run should + // solve the issue. + //assertEquals(-1, ContentResolver.getIsSyncable(account, authority)) + //assertNull(accountSettings.getSyncInterval(authority)) // Depends on above + } + } catch (e: InterruptedException) { + // The sync adapter framework will start a sync, which can get interrupted. We don't care + // about being interrupted. If it happens the test is not too important. + } + } + +}*/ \ No newline at end of file diff --git a/app/src/androidTest/java/at/bitfire/davdroid/webdav/DavDocumentsProviderTest.kt b/app/src/androidTest/java/at/bitfire/davdroid/webdav/DavDocumentsProviderTest.kt new file mode 100644 index 0000000000000000000000000000000000000000..ba939dadc9bc9772673196c7719134628f202a42 --- /dev/null +++ b/app/src/androidTest/java/at/bitfire/davdroid/webdav/DavDocumentsProviderTest.kt @@ -0,0 +1,255 @@ +/*************************************************************************************************** + * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. + **************************************************************************************************/ + +package at.bitfire.davdroid.webdav + +import android.content.Context +import android.security.NetworkSecurityPolicy +import androidx.test.platform.app.InstrumentationRegistry +import at.bitfire.davdroid.network.HttpClient +import at.bitfire.davdroid.R +import at.bitfire.davdroid.db.AppDatabase +import at.bitfire.davdroid.db.Credentials +import at.bitfire.davdroid.db.WebDavDocument +import at.bitfire.davdroid.db.WebDavMount +import at.bitfire.davdroid.log.Logger +import at.bitfire.davdroid.ui.setup.LoginModel +import dagger.hilt.android.testing.HiltAndroidRule +import dagger.hilt.android.testing.HiltAndroidTest +import okhttp3.CookieJar +import okhttp3.mockwebserver.Dispatcher +import okhttp3.mockwebserver.MockResponse +import okhttp3.mockwebserver.MockWebServer +import okhttp3.mockwebserver.RecordedRequest +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import java.net.URI +import javax.inject.Inject + +@HiltAndroidTest +class DavDocumentsProviderTest { + + @get:Rule + val hiltRule = HiltAndroidRule(this) + + val context: Context = InstrumentationRegistry.getInstrumentation().targetContext + + @Inject lateinit var db: AppDatabase + + @Before + fun setUp() { + hiltRule.inject() + } + + private var mockServer = MockWebServer() + + private lateinit var client: HttpClient + private lateinit var loginModel: LoginModel + + companion object { + private const val PATH_WEBDAV_ROOT = "/webdav" + } + + @Before + fun mockServerSetup() { + // Start mock web server + mockServer.dispatcher = TestDispatcher() + mockServer.start() + + loginModel = LoginModel() + loginModel.baseURI = URI.create("/") + loginModel.credentials = Credentials("mock", "12345") + + client = HttpClient.Builder(InstrumentationRegistry.getInstrumentation().targetContext) + .addAuthentication(null, loginModel.credentials!!) + .build() + + // mock server delivers HTTP without encryption + assertTrue(NetworkSecurityPolicy.getInstance().isCleartextTrafficPermitted) + } + + @After + fun cleanUp() { + mockServer.shutdown() + db.close() + } + + + @Test + fun testDoQueryChildren_insert() { + // Create parent and root in database + val id = db.webDavMountDao().insert(WebDavMount(0, "Cat food storage", mockServer.url(PATH_WEBDAV_ROOT))) + val webDavMount = db.webDavMountDao().getById(id) + val parent = db.webDavDocumentDao().getOrCreateRoot(webDavMount) + val cookieStore = mutableMapOf() + + // Query + DavDocumentsProvider.DavDocumentsActor(context, db, cookieStore, CredentialsStore(context), context.getString(R.string.webdav_authority)) + .queryChildren(parent) + + // Assert new children were inserted into db + assertEquals(3, db.webDavDocumentDao().getChildren(parent.id).size) + assertEquals("Secret_Document.pages", db.webDavDocumentDao().getChildren(parent.id)[0].displayName) + assertEquals("MeowMeow_Cats.docx", db.webDavDocumentDao().getChildren(parent.id)[1].displayName) + assertEquals("Library", db.webDavDocumentDao().getChildren(parent.id)[2].displayName) + } + + @Test + fun testDoQueryChildren_update() { + // Create parent and root in database + val mountId = db.webDavMountDao().insert(WebDavMount(0, "Cat food storage", mockServer.url(PATH_WEBDAV_ROOT))) + val webDavMount = db.webDavMountDao().getById(mountId) + val parent = db.webDavDocumentDao().getOrCreateRoot(webDavMount) + val cookieStore = mutableMapOf() + assertEquals("Cat food storage", db.webDavDocumentDao().get(parent.id)!!.displayName) + + // Create a folder + val folderId = db.webDavDocumentDao().insert( + WebDavDocument( + 0, + mountId, + parent.id, + "My_Books", + true, + "My Books", + ) + ) + assertEquals("My_Books", db.webDavDocumentDao().get(folderId)!!.name) + assertEquals("My Books", db.webDavDocumentDao().get(folderId)!!.displayName) + + // Query - should update the parent displayname and folder name + DavDocumentsProvider.DavDocumentsActor(context, db, cookieStore, CredentialsStore(context), context.getString(R.string.webdav_authority)) + .queryChildren(parent) + + // Assert parent and children were updated in database + assertEquals("Cats WebDAV", db.webDavDocumentDao().get(parent.id)!!.displayName) + assertEquals("Library", db.webDavDocumentDao().getChildren(parent.id)[2].name) + assertEquals("Library", db.webDavDocumentDao().getChildren(parent.id)[2].displayName) + + } + + @Test + fun testDoQueryChildren_delete() { + // Create parent and root in database + val mountId = db.webDavMountDao().insert(WebDavMount(0, "Cat food storage", mockServer.url(PATH_WEBDAV_ROOT))) + val webDavMount = db.webDavMountDao().getById(mountId) + val parent = db.webDavDocumentDao().getOrCreateRoot(webDavMount) + val cookieStore = mutableMapOf() + + // Create a folder + val folderId = db.webDavDocumentDao().insert( + WebDavDocument(0, mountId, parent.id, "deleteme", true, "Should be deleted") + ) + assertEquals("deleteme", db.webDavDocumentDao().get(folderId)!!.name) + + // Query - discovers serverside deletion + DavDocumentsProvider.DavDocumentsActor(context, db, cookieStore, CredentialsStore(context), context.getString(R.string.webdav_authority)) + .queryChildren(parent) + + // Assert folder got deleted + assertEquals(null, db.webDavDocumentDao().get(folderId)) + } + + @Test + fun testDoQueryChildren_updateTwoParentsSimultaneous() { + // Create root in database + val mountId = db.webDavMountDao().insert(WebDavMount(0, "Cat food storage", mockServer.url(PATH_WEBDAV_ROOT))) + val webDavMount = db.webDavMountDao().getById(mountId) + val root = db.webDavDocumentDao().getOrCreateRoot(webDavMount) + val cookieStore = mutableMapOf() + + // Create two parents + val parent1Id = db.webDavDocumentDao().insert(WebDavDocument(0, mountId, root.id, "parent1", true)) + val parent2Id = db.webDavDocumentDao().insert(WebDavDocument(0, mountId, root.id, "parent2", true)) + val parent1 = db.webDavDocumentDao().get(parent1Id)!! + val parent2 = db.webDavDocumentDao().get(parent2Id)!! + assertEquals("parent1", parent1.name) + assertEquals("parent2", parent2.name) + + // Query - find children of two nodes simultaneously + DavDocumentsProvider.DavDocumentsActor(context, db, cookieStore, CredentialsStore(context), context.getString(R.string.webdav_authority)) + .queryChildren(parent1) + DavDocumentsProvider.DavDocumentsActor(context, db, cookieStore, CredentialsStore(context), context.getString(R.string.webdav_authority)) + .queryChildren(parent2) + + // Assert the two folders names have changed + assertEquals("childOne.txt", db.webDavDocumentDao().getChildren(parent1Id)[0].name) + assertEquals("childTwo.txt", db.webDavDocumentDao().getChildren(parent2Id)[0].name) + } + + + // mock server + + class TestDispatcher: Dispatcher() { + + data class Resource( + val name: String, + val props: String + ) + + override fun dispatch(request: RecordedRequest): MockResponse { + val requestPath = request.path!!.trimEnd('/') + + if (request.method.equals("PROPFIND", true)) { + + val propsMap = mutableMapOf( + PATH_WEBDAV_ROOT to arrayOf( + Resource("", + "" + + "Cats WebDAV" + ), + Resource("Secret_Document.pages", + "Secret_Document.pages", + ), + Resource("MeowMeow_Cats.docx", + "MeowMeow_Cats.docx" + ), + Resource("Library", + "" + + "Library" + ) + ), + + "$PATH_WEBDAV_ROOT/parent1" to arrayOf( + Resource("childOne.txt", + "childOne.txt" + ), + ), + "$PATH_WEBDAV_ROOT/parent2" to arrayOf( + Resource("childTwo.txt", + "childTwo.txt" + ) + ) + ) + + val responses = propsMap[requestPath]?.joinToString { resource -> + "$requestPath/${resource.name}" + + resource.props + + "" + } + + val multistatus = + "" + + responses + + "" + + Logger.log.info("Query path: $requestPath") + Logger.log.info("Response: $multistatus") + return MockResponse() + .setResponseCode(207) + .setBody(multistatus) + } + + return MockResponse().setResponseCode(404) + } + + } + +} \ No newline at end of file diff --git a/app/src/androidTestOse/java/at/bitfire/davdroid/OkhttpClientTest.kt b/app/src/androidTestOse/java/at/bitfire/davdroid/OkhttpClientTest.kt index cbcb69064b87794588d7821c5915916439b6c570..57ac64da658826ebb53257dd0cc68e995c6c8f87 100644 --- a/app/src/androidTestOse/java/at/bitfire/davdroid/OkhttpClientTest.kt +++ b/app/src/androidTestOse/java/at/bitfire/davdroid/OkhttpClientTest.kt @@ -5,6 +5,7 @@ package at.bitfire.davdroid import androidx.test.platform.app.InstrumentationRegistry +import at.bitfire.davdroid.network.HttpClient import at.bitfire.davdroid.settings.SettingsManager import dagger.hilt.android.testing.HiltAndroidRule import dagger.hilt.android.testing.HiltAndroidTest diff --git a/app/src/androidTestOse/java/at/bitfire/davdroid/syncadapter/PeriodicSyncWorkerTest.kt b/app/src/androidTestOse/java/at/bitfire/davdroid/syncadapter/PeriodicSyncWorkerTest.kt new file mode 100644 index 0000000000000000000000000000000000000000..c54e96695d42eb1a647a833e9acc4ce73ca81e8d --- /dev/null +++ b/app/src/androidTestOse/java/at/bitfire/davdroid/syncadapter/PeriodicSyncWorkerTest.kt @@ -0,0 +1,126 @@ +/*************************************************************************************************** + * 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.ContentResolver +import android.provider.CalendarContract +import android.provider.ContactsContract +import android.util.Log +import androidx.test.platform.app.InstrumentationRegistry +import androidx.work.Configuration +import androidx.work.testing.TestWorkerBuilder +import androidx.work.testing.WorkManagerTestInitHelper +import androidx.work.workDataOf +import at.bitfire.davdroid.R +import at.bitfire.davdroid.TestUtils +import at.bitfire.davdroid.TestUtils.workScheduledOrRunning +import at.bitfire.davdroid.db.Credentials +import at.bitfire.davdroid.settings.AccountSettings +import at.bitfire.davdroid.ui.NotificationUtils +import at.bitfire.ical4android.TaskProvider +import dagger.hilt.android.testing.HiltAndroidRule +import dagger.hilt.android.testing.HiltAndroidTest +import org.junit.After +import org.junit.AfterClass +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.BeforeClass +import org.junit.Rule +import org.junit.Test +import java.util.concurrent.Executors + +@HiltAndroidTest +class PeriodicSyncWorkerTest { + + companion object { + + val context = InstrumentationRegistry.getInstrumentation().targetContext + + private val accountManager = AccountManager.get(context) + private val account = Account("Test Account", context.getString(R.string.account_type)) + private val fakeCredentials = Credentials("test", "test") + + @BeforeClass + @JvmStatic + fun setUp() { + // The test application is an instance of HiltTestApplication, which doesn't initialize notification channels. + // However, we need notification channels for the ongoing work notifications. + NotificationUtils.createChannels(context) + + // Initialize WorkManager for instrumentation tests. + val config = Configuration.Builder() + .setMinimumLoggingLevel(Log.DEBUG) + .build() + WorkManagerTestInitHelper.initializeTestWorkManager(context, config) + + assertTrue(AccountUtils.createAccount(context, account, AccountSettings.initialUserData(fakeCredentials))) + ContentResolver.setIsSyncable(account, CalendarContract.AUTHORITY, 1) + ContentResolver.setIsSyncable(account, ContactsContract.AUTHORITY, 1) + } + + @AfterClass + @JvmStatic + fun removeAccount() { + accountManager.removeAccountExplicitly(account) + } + + } + + private val executor = Executors.newSingleThreadExecutor() + + @get:Rule + val hiltRule = HiltAndroidRule(this) + + @Before + fun inject() { + hiltRule.inject() + } + + + @Test + fun enable_enqueuesPeriodicWorker() { + PeriodicSyncWorker.enable(context, account, CalendarContract.AUTHORITY, 60, false) + val workerName = PeriodicSyncWorker.workerName(account, CalendarContract.AUTHORITY) + assertTrue(workScheduledOrRunning(context, workerName)) + } + + @Test + fun disable_removesPeriodicWorker() { + PeriodicSyncWorker.enable(context, account, CalendarContract.AUTHORITY, 60, false) + PeriodicSyncWorker.disable(context, account, CalendarContract.AUTHORITY) + val workerName = PeriodicSyncWorker.workerName(account, CalendarContract.AUTHORITY) + assertFalse(workScheduledOrRunning(context, workerName)) + } + + @Test + fun doWork_immediatelyEnqueuesSyncWorkerForGivenAuthority() { + val authorities = listOf( + context.getString(R.string.address_books_authority), + CalendarContract.AUTHORITY, + ContactsContract.AUTHORITY, + TaskProvider.ProviderName.JtxBoard.authority, + TaskProvider.ProviderName.OpenTasks.authority, + TaskProvider.ProviderName.TasksOrg.authority + ) + for (authority in authorities) { + val inputData = workDataOf( + PeriodicSyncWorker.ARG_AUTHORITY to authority, + PeriodicSyncWorker.ARG_ACCOUNT_NAME to account.name, + PeriodicSyncWorker.ARG_ACCOUNT_TYPE to account.type + ) + // Run PeriodicSyncWorker as TestWorker + TestWorkerBuilder(context, executor, inputData).build().doWork() + + // Check the PeriodicSyncWorker enqueued the right SyncWorker + assertTrue(TestUtils.workScheduledOrRunningOrSuccessful(context, + SyncWorker.workerName(account, authority) + )) + } + } + +} \ No newline at end of file diff --git a/app/src/androidTestOse/java/at/bitfire/davdroid/syncadapter/SyncAdapterServiceTest.kt b/app/src/androidTestOse/java/at/bitfire/davdroid/syncadapter/SyncAdapterServiceTest.kt deleted file mode 100644 index de1ebc171964eae0276930d723c77569d06c1d17..0000000000000000000000000000000000000000 --- a/app/src/androidTestOse/java/at/bitfire/davdroid/syncadapter/SyncAdapterServiceTest.kt +++ /dev/null @@ -1,35 +0,0 @@ -/*************************************************************************************************** - * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. - **************************************************************************************************/ - -package at.bitfire.davdroid.syncadapter - -import android.os.Bundle -import androidx.test.filters.SmallTest -import at.bitfire.davdroid.syncadapter.SyncAdapterService.SyncAdapter -import org.junit.Assert.assertArrayEquals -import org.junit.Assert.assertTrue -import org.junit.Test - -class SyncAdapterServiceTest { - - @Test - @SmallTest - fun testPriorityCollections() { - val extras = Bundle() - assertTrue(SyncAdapter.priorityCollections(extras).isEmpty()) - - extras.putString(SyncAdapterService.SYNC_EXTRAS_PRIORITY_COLLECTIONS, "") - assertTrue(SyncAdapter.priorityCollections(extras).isEmpty()) - - extras.putString(SyncAdapterService.SYNC_EXTRAS_PRIORITY_COLLECTIONS, "123") - assertArrayEquals(longArrayOf(123), SyncAdapter.priorityCollections(extras).toLongArray()) - - extras.putString(SyncAdapterService.SYNC_EXTRAS_PRIORITY_COLLECTIONS, ",x,") - assertTrue(SyncAdapter.priorityCollections(extras).isEmpty()) - - extras.putString(SyncAdapterService.SYNC_EXTRAS_PRIORITY_COLLECTIONS, "1,2,3") - assertArrayEquals(longArrayOf(1,2,3), SyncAdapter.priorityCollections(extras).toLongArray()) - } - -} \ No newline at end of file diff --git a/app/src/androidTestOse/java/at/bitfire/davdroid/syncadapter/SyncAdapterTest.kt b/app/src/androidTestOse/java/at/bitfire/davdroid/syncadapter/SyncAdapterTest.kt deleted file mode 100644 index a4a7f03b150393ace17be0dd40ca7085f55501e3..0000000000000000000000000000000000000000 --- a/app/src/androidTestOse/java/at/bitfire/davdroid/syncadapter/SyncAdapterTest.kt +++ /dev/null @@ -1,151 +0,0 @@ -/*************************************************************************************************** - * 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.ContentProviderClient -import android.content.Context -import android.content.SyncResult -import android.os.Bundle -import androidx.test.platform.app.InstrumentationRegistry -import at.bitfire.davdroid.HttpClient -import at.bitfire.davdroid.R -import at.bitfire.davdroid.db.AppDatabase -import at.bitfire.davdroid.settings.SettingsManager -import dagger.hilt.android.testing.HiltAndroidRule -import dagger.hilt.android.testing.HiltAndroidTest -import org.junit.Assert.* -import org.junit.Before -import org.junit.Rule -import org.junit.Test -import java.util.concurrent.atomic.AtomicInteger -import javax.inject.Inject - -@HiltAndroidTest -class SyncAdapterTest { - - @get:Rule - val hiltRule = HiltAndroidRule(this) - - @Inject - lateinit var settingsManager: SettingsManager - - val context by lazy { InstrumentationRegistry.getInstrumentation().context } - val targetContext by lazy { InstrumentationRegistry.getInstrumentation().targetContext } - - /** use our WebDAV provider as a mock provider because it's our own and we don't need any permissions for it */ - val mockAuthority = targetContext.getString(R.string.webdav_authority) - val mockProvider = context.contentResolver.acquireContentProviderClient(mockAuthority)!! - - val account = Account("test", "com.example.test") - - @Inject lateinit var db: AppDatabase - lateinit var syncAdapter: TestSyncAdapter - - - @Before - fun setUp() { - hiltRule.inject() - - syncAdapter = TestSyncAdapter(context, db) - } - - - @Test - fun testPriorityCollections() { - assertTrue(SyncAdapterService.SyncAdapter.priorityCollections(Bundle()).isEmpty()) - assertArrayEquals(arrayOf(1L,2L), SyncAdapterService.SyncAdapter.priorityCollections(Bundle(1).apply { - putString(SyncAdapterService.SYNC_EXTRAS_PRIORITY_COLLECTIONS, "1,error,2") - }).toTypedArray()) - } - - - @Test - fun testOnPerformSync_allowsSequentialSyncs() { - for (i in 0 until 5) - syncAdapter.onPerformSync(account, Bundle(), mockAuthority, mockProvider, SyncResult()) - assertEquals(5, syncAdapter.syncCalled.get()) - } - - @Test - fun testOnPerformSync_allowsSimultaneousSyncs() { - val extras = Bundle(1) - extras.putLong(TestSyncAdapter.EXTRA_WAIT, 100) // sync takes 100 ms - - val syncThreads = mutableListOf() - for (i in 0 until 100) { // within 100 ms, at least 2 threads should be created and run simultaneously - syncThreads += Thread({ - syncAdapter.onPerformSync(account, extras, "$mockAuthority-$i", mockProvider, SyncResult()) - }).apply { - start() - } - } - - // wait for all threads - syncThreads.forEach { it.join() } - - assertEquals(100, syncAdapter.syncCalled.get()) - } - - @Test - fun testOnPerformSync_preventsDuplicateSyncs() { - val extras = Bundle(1) - extras.putLong(TestSyncAdapter.EXTRA_WAIT, 500) // sync takes 500 ms - - val syncThreads = mutableListOf() - for (i in 0 until 100) { // creating 100 threads should be possible within in 500 ms - syncThreads += Thread({ - syncAdapter.onPerformSync(account, extras, mockAuthority, mockProvider, SyncResult()) - }).apply { - start() - } - } - - // wait for all threads - syncThreads.forEach { it.join() } - - assertEquals(1, syncAdapter.syncCalled.get()) - } - - @Test - fun testOnPerformSync_runsSyncAndSetsClassLoader() { - syncAdapter.onPerformSync(account, Bundle(), mockAuthority, mockProvider, SyncResult()) - - // check whether onPerformSync() actually calls sync() - assertEquals(1, syncAdapter.syncCalled.get()) - - // check whether contextClassLoader is set - assertEquals(context.classLoader, Thread.currentThread().contextClassLoader) - } - - - class TestSyncAdapter(context: Context, db: AppDatabase): SyncAdapterService.SyncAdapter(context, db) { - - companion object { - /** - * How long the sync() method shall wait - */ - val EXTRA_WAIT = "waitMillis" - } - - val syncCalled = AtomicInteger() - - override fun sync( - account: Account, - extras: Bundle, - authority: String, - httpClient: Lazy, - provider: ContentProviderClient, - syncResult: SyncResult - ) { - val wait = extras.getLong(EXTRA_WAIT) - Thread.sleep(wait) - - syncCalled.incrementAndGet() - } - - } - -} \ No newline at end of file diff --git a/app/src/androidTestOse/java/at/bitfire/davdroid/syncadapter/SyncManagerTest.kt b/app/src/androidTestOse/java/at/bitfire/davdroid/syncadapter/SyncManagerTest.kt index 963665489d1d06dc02bed6a4c6d5040945758106..1eaf2d534c28b181a894d2785aff1d84a85ded32 100644 --- a/app/src/androidTestOse/java/at/bitfire/davdroid/syncadapter/SyncManagerTest.kt +++ b/app/src/androidTestOse/java/at/bitfire/davdroid/syncadapter/SyncManagerTest.kt @@ -6,20 +6,24 @@ package at.bitfire.davdroid.syncadapter import android.accounts.Account import android.accounts.AccountManager +import android.content.Context import android.content.SyncResult -import android.os.Bundle +import android.util.Log import androidx.core.app.NotificationManagerCompat +import androidx.hilt.work.HiltWorkerFactory import androidx.test.platform.app.InstrumentationRegistry +import androidx.work.Configuration +import androidx.work.testing.WorkManagerTestInitHelper import at.bitfire.dav4jvm.PropStat import at.bitfire.dav4jvm.Response import at.bitfire.dav4jvm.Response.HrefRelation import at.bitfire.dav4jvm.property.GetETag -import at.bitfire.davdroid.HttpClient import at.bitfire.davdroid.R import at.bitfire.davdroid.db.Credentials import at.bitfire.davdroid.db.SyncState +import at.bitfire.davdroid.network.HttpClient import at.bitfire.davdroid.settings.AccountSettings -import at.bitfire.davdroid.settings.SettingsManager +import at.bitfire.davdroid.ui.NotificationUtils import dagger.hilt.android.testing.HiltAndroidRule import dagger.hilt.android.testing.HiltAndroidTest import okhttp3.Protocol @@ -28,23 +32,13 @@ import okhttp3.mockwebserver.MockResponse import okhttp3.mockwebserver.MockWebServer import org.junit.* import org.junit.Assert.* +import java.time.Instant import java.util.concurrent.TimeUnit import javax.inject.Inject @HiltAndroidTest class SyncManagerTest { - @get:Rule - val hiltRule = HiltAndroidRule(this) - - @Inject - lateinit var settingsManager: SettingsManager - - @Before - fun inject() { - hiltRule.inject() - } - companion object { val context = InstrumentationRegistry.getInstrumentation().targetContext @@ -67,16 +61,42 @@ class SyncManagerTest { } + + @get:Rule + val hiltRule = HiltAndroidRule(this) + + val context: Context = InstrumentationRegistry.getInstrumentation().targetContext + + @Inject + lateinit var workerFactory: HiltWorkerFactory + val server = MockWebServer() - private fun syncManager(collection: LocalTestCollection) = + @Before + fun inject() { + hiltRule.inject() + + // The test application is an instance of HiltTestApplication, which doesn't initialize notification channels. + // However, we need notification channels for the ongoing work notifications. + NotificationUtils.createChannels(context) + + // Initialize WorkManager for instrumentation tests. + val config = Configuration.Builder() + .setMinimumLoggingLevel(Log.DEBUG) + .setWorkerFactory(workerFactory) + .build() + WorkManagerTestInitHelper.initializeTestWorkManager(context, config) + } + + + private fun syncManager(collection: LocalTestCollection, syncResult: SyncResult = SyncResult()) = TestSyncManager( context, account, - Bundle(), + arrayOf(), "TestAuthority", - HttpClient.Builder().build(), - SyncResult(), + HttpClient.Builder(InstrumentationRegistry.getInstrumentation().targetContext).build(), + syncResult, collection, server ) @@ -114,6 +134,23 @@ class SyncManagerTest { return response } + @Test + fun testPerformSync_503RetryAfter_DelaySeconds() { + server.enqueue(MockResponse() + .setResponseCode(503) + .setHeader("Retry-After", "60")) // 60 seconds + + val result = SyncResult() + val syncManager = syncManager(LocalTestCollection(), result) + syncManager.performSync() + + val expected = Instant.now() + .plusSeconds(60) + .toEpochMilli() + // 5 sec tolerance for test + assertTrue(result.delayUntil > (expected - 5000) && result.delayUntil < (expected + 5000)) + } + @Test fun testPerformSync_FirstSync_Empty() { val collection = LocalTestCollection() /* no last known ctag */ diff --git a/app/src/androidTestOse/java/at/bitfire/davdroid/syncadapter/SyncWorkerTest.kt b/app/src/androidTestOse/java/at/bitfire/davdroid/syncadapter/SyncWorkerTest.kt new file mode 100644 index 0000000000000000000000000000000000000000..a0c9ce9a1ac566723fdf94a598c85208a6117067 --- /dev/null +++ b/app/src/androidTestOse/java/at/bitfire/davdroid/syncadapter/SyncWorkerTest.kt @@ -0,0 +1,156 @@ +/*************************************************************************************************** + * 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.ContentResolver +import android.provider.CalendarContract +import android.provider.ContactsContract +import android.util.Log +import androidx.test.platform.app.InstrumentationRegistry +import androidx.work.Configuration +import androidx.work.testing.TestWorkerBuilder +import androidx.work.testing.WorkManagerTestInitHelper +import androidx.work.workDataOf +import at.bitfire.davdroid.R +import at.bitfire.davdroid.TestUtils.workScheduledOrRunningOrSuccessful +import at.bitfire.davdroid.db.Credentials +import at.bitfire.davdroid.settings.AccountSettings +import at.bitfire.davdroid.ui.NotificationUtils +import dagger.hilt.android.testing.HiltAndroidRule +import dagger.hilt.android.testing.HiltAndroidTest +import io.mockk.every +import io.mockk.mockk +import io.mockk.mockkObject +import org.junit.After +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import java.util.concurrent.Executors + +@HiltAndroidTest +class SyncWorkerTest { + + val context = InstrumentationRegistry.getInstrumentation().targetContext + + private val accountManager = AccountManager.get(context) + private val account = Account("Test Account", context.getString(R.string.account_type)) + private val fakeCredentials = Credentials("test", "test") + + private val executor = Executors.newSingleThreadExecutor() + + @get:Rule + val hiltRule = HiltAndroidRule(this) + + @Before + fun inject() { + hiltRule.inject() + } + + @Before + fun setUp() { + assertTrue(AccountUtils.createAccount(context, account, AccountSettings.initialUserData(fakeCredentials))) + ContentResolver.setIsSyncable(account, CalendarContract.AUTHORITY, 1) + ContentResolver.setIsSyncable(account, ContactsContract.AUTHORITY, 0) + + // The test application is an instance of HiltTestApplication, which doesn't initialize notification channels. + // However, we need notification channels for the ongoing work notifications. + NotificationUtils.createChannels(context) + + // Initialize WorkManager for instrumentation tests. + val config = Configuration.Builder() + .setMinimumLoggingLevel(Log.DEBUG) + .build() + WorkManagerTestInitHelper.initializeTestWorkManager(context, config) + } + + @After + fun removeAccount() { + accountManager.removeAccountExplicitly(account) + } + + + @Test + fun testEnqueue_enqueuesWorker() { + SyncWorker.enqueue(context, account, CalendarContract.AUTHORITY) + val workerName = SyncWorker.workerName(account, CalendarContract.AUTHORITY) + assertTrue(workScheduledOrRunningOrSuccessful(context, workerName)) + } + + @Test + fun testWifiConditionsMet_withoutWifi() { + val accountSettings = mockk() + every { accountSettings.getSyncWifiOnly() } returns false + assertTrue(SyncWorker.wifiConditionsMet(context, accountSettings)) + } + + @Test + fun testWifiConditionsMet_anyWifi_wifiEnabled() { + val accountSettings = AccountSettings(context, account) + accountSettings.setSyncWiFiOnly(true) + + mockkObject(SyncWorker.Companion) + every { SyncWorker.Companion.wifiAvailable(any()) } returns true + every { SyncWorker.Companion.correctWifiSsid(any(), any()) } returns true + + assertTrue(SyncWorker.wifiConditionsMet(context, accountSettings)) + } + + @Test + fun testWifiConditionsMet_anyWifi_wifiDisabled() { + val accountSettings = AccountSettings(context, account) + accountSettings.setSyncWiFiOnly(true) + + mockkObject(SyncWorker.Companion) + every { SyncWorker.Companion.wifiAvailable(any()) } returns false + every { SyncWorker.Companion.correctWifiSsid(any(), any()) } returns true + + assertFalse(SyncWorker.wifiConditionsMet(context, accountSettings)) + } + + + @Test + fun testWifiConditionsMet_correctWifiSsid() { + // TODO: Write test + } + + @Test + fun testWifiConditionsMet_wrongWifiSsid() { + // TODO: Write test + } + + + @Test + fun testOnStopped_interruptsSyncThread() { + val authority = CalendarContract.AUTHORITY + val inputData = workDataOf( + SyncWorker.ARG_AUTHORITY to authority, + SyncWorker.ARG_ACCOUNT_NAME to account.name, + SyncWorker.ARG_ACCOUNT_TYPE to account.type + ) + + // Create SyncWorker as TestWorker + val testSyncWorker = TestWorkerBuilder(context, executor, inputData).build() + assertNull(testSyncWorker.syncThread) + + // Run SyncWorker and assert sync thread is alive + testSyncWorker.doWork() + assertNotNull(testSyncWorker.syncThread) + assertTrue(testSyncWorker.syncThread!!.isAlive) + assertFalse(testSyncWorker.syncThread!!.isInterrupted) // Sync running + + // Stop SyncWorker and assert sync thread was interrupted + testSyncWorker.onStopped() + assertNotNull(testSyncWorker.syncThread) + assertTrue(testSyncWorker.syncThread!!.isAlive) + assertTrue(testSyncWorker.syncThread!!.isInterrupted) // Sync thread interrupted + } + +} \ No newline at end of file diff --git a/app/src/androidTestOse/java/at/bitfire/davdroid/syncadapter/SyncerTest.kt b/app/src/androidTestOse/java/at/bitfire/davdroid/syncadapter/SyncerTest.kt new file mode 100644 index 0000000000000000000000000000000000000000..229153bd6c9d7c7db73cfcba90b765b5e000aff6 --- /dev/null +++ b/app/src/androidTestOse/java/at/bitfire/davdroid/syncadapter/SyncerTest.kt @@ -0,0 +1,73 @@ +/*************************************************************************************************** + * 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.ContentProviderClient +import android.content.Context +import android.content.SyncResult +import androidx.test.platform.app.InstrumentationRegistry +import at.bitfire.davdroid.network.HttpClient +import at.bitfire.davdroid.R +import dagger.hilt.android.testing.HiltAndroidRule +import dagger.hilt.android.testing.HiltAndroidTest +import org.junit.Assert.assertEquals +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import java.util.concurrent.atomic.AtomicInteger + +@HiltAndroidTest +class SyncerTest { + + @get:Rule + val hiltRule = HiltAndroidRule(this) + + val context = InstrumentationRegistry.getInstrumentation().targetContext + + /** use our WebDAV provider as a mock provider because it's our own and we don't need any permissions for it */ + val mockAuthority = context.getString(R.string.webdav_authority) + val mockProvider = context.contentResolver!!.acquireContentProviderClient(mockAuthority)!! + + val account = Account(javaClass.canonicalName, context.getString(R.string.account_type)) + + + @Before + fun setUp() { + hiltRule.inject() + } + + @Test + fun testOnPerformSync_runsSyncAndSetsClassLoader() { + val syncer = TestSyncer(context) + syncer.onPerformSync(account, arrayOf(), mockAuthority, mockProvider, SyncResult()) + + // check whether onPerformSync() actually calls sync() + assertEquals(1, syncer.syncCalled.get()) + + // check whether contextClassLoader is set + assertEquals(context.classLoader, Thread.currentThread().contextClassLoader) + } + + + class TestSyncer(context: Context) : Syncer(context) { + + val syncCalled = AtomicInteger() + + override fun sync( + account: Account, + extras: Array, + authority: String, + httpClient: Lazy, + provider: ContentProviderClient, + syncResult: SyncResult + ) { + Thread.sleep(1000) + syncCalled.incrementAndGet() + } + + } + +} \ No newline at end of file diff --git a/app/src/androidTestOse/java/at/bitfire/davdroid/syncadapter/TestSyncManager.kt b/app/src/androidTestOse/java/at/bitfire/davdroid/syncadapter/TestSyncManager.kt index 0c146b63b0999cdaac67e4f1b90f0573264d77ce..85b04d82d1f0e750da011bb7ec5bf8c3119c55cf 100644 --- a/app/src/androidTestOse/java/at/bitfire/davdroid/syncadapter/TestSyncManager.kt +++ b/app/src/androidTestOse/java/at/bitfire/davdroid/syncadapter/TestSyncManager.kt @@ -7,16 +7,15 @@ 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.DavCollection import at.bitfire.dav4jvm.MultiResponseCallback import at.bitfire.dav4jvm.Response import at.bitfire.dav4jvm.property.GetCTag -import at.bitfire.davdroid.DavUtils -import at.bitfire.davdroid.HttpClient +import at.bitfire.davdroid.network.HttpClient import at.bitfire.davdroid.db.SyncState import at.bitfire.davdroid.resource.LocalResource import at.bitfire.davdroid.settings.AccountSettings +import at.bitfire.davdroid.util.DavUtils import okhttp3.HttpUrl import okhttp3.RequestBody import okhttp3.RequestBody.Companion.toRequestBody @@ -24,19 +23,19 @@ import okhttp3.mockwebserver.MockWebServer import org.junit.Assert.assertEquals class TestSyncManager( - context: Context, - account: Account, - extras: Bundle, - authority: String, - httpClient: HttpClient, - syncResult: SyncResult, - localCollection: LocalTestCollection, - val mockWebServer: MockWebServer + context: Context, + account: Account, + extras: Array, + authority: String, + httpClient: HttpClient, + syncResult: SyncResult, + localCollection: LocalTestCollection, + val mockWebServer: MockWebServer ): SyncManager(context, account, AccountSettings(context, account), httpClient, extras, authority, syncResult, localCollection) { override fun prepare(): Boolean { collectionURL = mockWebServer.url("/") - davCollection = DavCollection(httpClient.okHttpClient, collectionURL) + davCollection = DavCollection(httpClient.okHttpClient, collectionURL, null) return true } diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 5d3f9f1d46c8e8baef0ecd264daf67262ce9f609..fd66593b2d4d009d1af3d7ecd79ddab4f8d08b82 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -39,6 +39,11 @@ + + + + android:theme="@style/AppTheme.NoActionBar" + android:exported="true"> + @@ -319,7 +326,7 @@ @@ -333,7 +340,7 @@ @@ -347,7 +354,7 @@ @@ -361,7 +368,7 @@ @@ -375,7 +382,7 @@ @@ -390,7 +397,7 @@ @@ -417,7 +424,7 @@ @@ -431,7 +438,7 @@ @@ -672,6 +679,7 @@ + @@ -690,7 +698,6 @@ android:name="android.support.FILE_PROVIDER_PATHS" android:resource="@xml/debug_paths" /> - @@ -732,4 +739,4 @@ - + \ 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 index 0acf480745a0dab72d39a88e13897389e6df9204..b47d8e3a656ff8e7a2ef5386389bd0b44eb69450 100644 --- a/app/src/main/assets/known-base-urls.txt +++ b/app/src/main/assets/known-base-urls.txt @@ -1,18 +1,31 @@ +murena.io carddav.a1.net aol.com calendar.dingtalk.com/dav/ cloud.disroot.org dav.edis.at dav.fruux.com +caldav.gmx.com +caldav.gmx.co.uk +caldav.gmx.de +caldav.gmx.es +caldav.gmx.fr caldav.gmx.net +carddav.gmx.com +carddav.gmx.co.uk +carddav.gmx.de +carddav.gmx.es +carddav.gmx.fr carddav.gmx.net framagenda.org/remote.php/dav/ icloud.com cloud.liberta.vip office.luckycloud.de calendar.mail.ru -mailbox.org +dav.mailbox.org mailfence.com +caldav.mailo.com +carddav.mailo.com posteo.de:8443 dav.runbox.com live.teambox.eu diff --git a/app/src/main/java/at/bitfire/davdroid/App.kt b/app/src/main/java/at/bitfire/davdroid/App.kt index 59d989f8d4535d28c292e7497582563b51348515..aa2b223071a8f4ff9a944dded394cecf13436fc7 100644 --- a/app/src/main/java/at/bitfire/davdroid/App.kt +++ b/app/src/main/java/at/bitfire/davdroid/App.kt @@ -13,7 +13,6 @@ import androidx.core.graphics.drawable.toBitmap import androidx.hilt.work.HiltWorkerFactory import androidx.work.Configuration 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 @@ -30,20 +29,38 @@ class App: Application(), Thread.UncaughtExceptionHandler, Configuration.Provide companion object { + const val HOMEPAGE_PRIVACY = "privacy" + 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() - .appendQueryParameter("pk_campaign", BuildConfig.APPLICATION_ID) - .appendQueryParameter("pk_kwd", context::class.java.simpleName) - .appendQueryParameter("app-version", BuildConfig.VERSION_NAME) - .build()!! + /** + * Gets the DAVx5 Web site URL that should be used to open in the user's browser. + * Package ID, version number and calling context name will be appended as arguments. + * + * @param context context name to use + * @param page optional page segment to append (for instance: [HOMEPAGE_PRIVACY]]) + * + * @return the Uri for the browser + */ + fun homepageUrl(context: Context, page: String? = null): Uri { + val builder = Uri.parse(context.getString(R.string.homepage_url)).buildUpon() + + if (page != null) + builder.appendPath(page) + + return builder + .appendQueryParameter("pk_campaign", BuildConfig.APPLICATION_ID) + .appendQueryParameter("pk_kwd", context::class.java.simpleName) + .appendQueryParameter("app-version", BuildConfig.VERSION_NAME) + .build() + } } @Inject lateinit var accountsUpdatedListener: AccountsUpdatedListener @Inject lateinit var storageLowReceiver: StorageLowReceiver + @Inject lateinit var workerFactory: HiltWorkerFactory override fun onCreate() { @@ -85,9 +102,6 @@ class App: Application(), Thread.UncaughtExceptionHandler, Configuration.Provide TasksWatcher.watch(this) // check whether a tasks app is currently installed SyncUtils.updateTaskSync(this) - - // check/repair sync intervals - AccountSettings.repairSyncIntervals(this) } } diff --git a/app/src/main/java/at/bitfire/davdroid/BootCompletedReceiver.kt b/app/src/main/java/at/bitfire/davdroid/BootCompletedReceiver.kt index e91a057ea391fef3858cb3b30656616f9e45b499..dbbcd89ad82f71880dfbc42ba7bc4db8f709f759 100644 --- a/app/src/main/java/at/bitfire/davdroid/BootCompletedReceiver.kt +++ b/app/src/main/java/at/bitfire/davdroid/BootCompletedReceiver.kt @@ -8,6 +8,8 @@ import android.content.BroadcastReceiver import android.content.Context import android.content.Intent import at.bitfire.davdroid.log.Logger +import at.bitfire.davdroid.settings.AccountSettings +import at.bitfire.davdroid.syncadapter.AccountUtils /** * There are circumstances when Android drops automatic sync of accounts and resets them @@ -21,6 +23,11 @@ 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() + AccountUtils.getMainAccounts(context) + .forEach { + val accountSettings = AccountSettings(context, it) + accountSettings.initSync() + } } } \ 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 ae49b7dcd97c094abcf157cff376c6b3e8a0d3fc..b231268dec4dd0504bb157455b0461b1be0d7bd5 100644 --- a/app/src/main/java/at/bitfire/davdroid/Constants.kt +++ b/app/src/main/java/at/bitfire/davdroid/Constants.kt @@ -11,7 +11,7 @@ object Constants { 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_CALENDAR_SYNC_INTERVAL = 15 * 60L // 15 minutes const val DEFAULT_CONTACTS_SYNC_INTERVAL = 15 * 60L // 15 minutes /** @@ -32,4 +32,6 @@ object Constants { const val EELO_SYNC_HOST = "murena.io" const val E_SYNC_URL = "e.email" + + const val MURENA_DAV_URL = "https://murena.io/remote.php/dav" } diff --git a/app/src/main/java/at/bitfire/davdroid/ForegroundService.kt b/app/src/main/java/at/bitfire/davdroid/ForegroundService.kt index b7460e95d801862eca66c8353fa96de99c07f899..d9c78b5626691bb14f422f95bc35325e9cc8ef3b 100644 --- a/app/src/main/java/at/bitfire/davdroid/ForegroundService.kt +++ b/app/src/main/java/at/bitfire/davdroid/ForegroundService.kt @@ -16,6 +16,7 @@ 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 at.bitfire.davdroid.ui.NotificationUtils.notifyIfPossible import dagger.hilt.EntryPoint import dagger.hilt.InstallIn import dagger.hilt.android.EntryPointAccessors @@ -88,7 +89,7 @@ class ForegroundService : Service() { .setCategory(NotificationCompat.CATEGORY_ERROR) val nm = NotificationManagerCompat.from(context) - nm.notify(NotificationUtils.NOTIFY_BATTERY_OPTIMIZATION, builder.build()) + nm.notifyIfPossible(NotificationUtils.NOTIFY_BATTERY_OPTIMIZATION, builder.build()) } } diff --git a/app/src/main/java/at/bitfire/davdroid/StorageLowReceiver.kt b/app/src/main/java/at/bitfire/davdroid/StorageLowReceiver.kt index aa56855e677dfc83c8e4341088b50946cd5e710e..c74ea4f958b4ef096f72ca2c5148019eb2083a0b 100644 --- a/app/src/main/java/at/bitfire/davdroid/StorageLowReceiver.kt +++ b/app/src/main/java/at/bitfire/davdroid/StorageLowReceiver.kt @@ -15,6 +15,7 @@ import androidx.core.app.NotificationManagerCompat import androidx.lifecycle.MutableLiveData import at.bitfire.davdroid.log.Logger import at.bitfire.davdroid.ui.NotificationUtils +import at.bitfire.davdroid.ui.NotificationUtils.notifyIfPossible import dagger.Module import dagger.Provides import dagger.hilt.InstallIn @@ -74,7 +75,7 @@ class StorageLowReceiver private constructor( 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()) + nm.notifyIfPossible(NotificationUtils.NOTIFY_LOW_STORAGE, notify.build()) } fun onStorageOk() { diff --git a/app/src/main/java/at/bitfire/davdroid/db/AppDatabase.kt b/app/src/main/java/at/bitfire/davdroid/db/AppDatabase.kt index 51478c40a54c434d195fdca872e02c3282219a26..fa4570bf26b0906c4e707deac25dab68cd0adbe2 100644 --- a/app/src/main/java/at/bitfire/davdroid/db/AppDatabase.kt +++ b/app/src/main/java/at/bitfire/davdroid/db/AppDatabase.kt @@ -13,14 +13,17 @@ import androidx.core.app.NotificationCompat import androidx.core.app.NotificationManagerCompat import androidx.core.database.getStringOrNull import androidx.room.* +import androidx.room.migration.AutoMigrationSpec 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.syncadapter.AccountUtils +import at.bitfire.davdroid.servicedetection.RefreshCollectionsWorker import at.bitfire.davdroid.ui.AccountsActivity import at.bitfire.davdroid.ui.NotificationUtils +import at.bitfire.davdroid.ui.NotificationUtils.notifyIfPossible import dagger.Module import dagger.Provides import dagger.hilt.InstallIn @@ -34,11 +37,14 @@ import javax.inject.Singleton Service::class, HomeSet::class, Collection::class, + Principal::class, SyncStats::class, WebDavDocument::class, WebDavMount::class -], exportSchema = true, version = 11, autoMigrations = [ - AutoMigration(from = 9, to = 10) +], exportSchema = true, version = 12, autoMigrations = [ + AutoMigration(from = 9, to = 10), + AutoMigration(from = 10, to = 11), + AutoMigration(from = 11, to = 12, spec = AppDatabase.AutoMigration11_12::class) ]) @TypeConverters(Converters::class) abstract class AppDatabase: RoomDatabase() { @@ -51,6 +57,7 @@ abstract class AppDatabase: RoomDatabase() { fun appDatabase(@ApplicationContext context: Context): AppDatabase = Room.databaseBuilder(context.applicationContext, AppDatabase::class.java, "services.db") .addMigrations(*migrations) + .addAutoMigrationSpec(AutoMigration11_12(context)) .fallbackToDestructiveMigration() // as a last fallback, recreate database instead of crashing .addCallback(object: Callback() { override fun onDestructiveMigration(db: SupportSQLiteDatabase) { @@ -64,7 +71,7 @@ abstract class AppDatabase: RoomDatabase() { .setContentIntent(PendingIntent.getActivity(context, 0, launcherIntent, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE)) .setAutoCancel(true) .build() - nm.notify(NotificationUtils.NOTIFY_DATABASE_CORRUPTED, notify) + nm.notifyIfPossible(NotificationUtils.NOTIFY_DATABASE_CORRUPTED, notify) // remove all accounts because they're unfortunately useless without database val am = AccountManager.get(context) @@ -75,9 +82,26 @@ abstract class AppDatabase: RoomDatabase() { .build() } + // auto migrations + + @ProvidedAutoMigrationSpec + @DeleteColumn(tableName = "collection", columnName = "owner") + class AutoMigration11_12(val context: Context): AutoMigrationSpec { + override fun onPostMigrate(db: SupportSQLiteDatabase) { + Logger.log.info("Database update to v12, refreshing services to get display names of owners") + db.query("SELECT id FROM service", arrayOf()).use { cursor -> + while (cursor.moveToNext()) { + val serviceId = cursor.getLong(0) + RefreshCollectionsWorker.refreshCollections(context, serviceId) + } + } + } + } + + companion object { - // migrations + // manual migrations val migrations: Array = arrayOf( object : Migration(10, 11) { @@ -242,6 +266,7 @@ abstract class AppDatabase: RoomDatabase() { abstract fun serviceDao(): ServiceDao abstract fun homeSetDao(): HomeSetDao abstract fun collectionDao(): CollectionDao + abstract fun principalDao(): PrincipalDao abstract fun syncStatsDao(): SyncStatsDao abstract fun webDavDocumentDao(): WebDavDocumentDao abstract fun webDavMountDao(): WebDavMountDao diff --git a/app/src/main/java/at/bitfire/davdroid/db/Collection.kt b/app/src/main/java/at/bitfire/davdroid/db/Collection.kt index 213f54849a32e9d9da506d21ea8c16892497d296..65e56f377f72a093ae7bdd2dfbfcd4aff617f6e0 100644 --- a/app/src/main/java/at/bitfire/davdroid/db/Collection.kt +++ b/app/src/main/java/at/bitfire/davdroid/db/Collection.kt @@ -8,7 +8,7 @@ import androidx.room.* import at.bitfire.dav4jvm.Response import at.bitfire.dav4jvm.UrlUtils import at.bitfire.dav4jvm.property.* -import at.bitfire.davdroid.DavUtils +import at.bitfire.davdroid.util.DavUtils import okhttp3.HttpUrl import okhttp3.HttpUrl.Companion.toHttpUrlOrNull import org.apache.commons.lang3.StringUtils @@ -16,7 +16,8 @@ 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 = HomeSet::class, parentColumns = arrayOf("id"), childColumns = arrayOf("homeSetId"), onDelete = ForeignKey.SET_NULL) + ForeignKey(entity = HomeSet::class, parentColumns = arrayOf("id"), childColumns = arrayOf("homeSetId"), onDelete = ForeignKey.SET_NULL), + ForeignKey(entity = Principal::class, parentColumns = arrayOf("id"), childColumns = arrayOf("ownerId"), onDelete = ForeignKey.SET_NULL) ], indices = [ Index("serviceId","type"), @@ -26,12 +27,33 @@ import org.apache.commons.lang3.StringUtils ) data class Collection( @PrimaryKey(autoGenerate = true) - override var id: Long = 0, + var id: Long = 0, + /** + * Service, which this collection belongs to. Services are unique, so a [Collection] is uniquely + * identifiable via its [serviceId] and [url]. + */ var serviceId: Long = 0, + + /** + * A home set this collection belongs to. Multiple homesets are not supported. + * If *null* the collection is considered homeless. + */ var homeSetId: Long? = null, + /** + * Principal who is owner of this collection. + */ + var ownerId: Long? = null, + + /** + * Type of service. CalDAV or CardDAV + */ var type: String, + + /** + * Address where this collection lives - with trailing slash + */ var url: HttpUrl, var privWriteContent: Boolean = true, @@ -40,7 +62,6 @@ data class Collection( var displayName: String? = null, var description: String? = null, - var owner: HttpUrl? = null, // CalDAV only var color: Int? = null, @@ -63,11 +84,7 @@ data class Collection( /** whether this collection has been selected for synchronization */ var sync: Boolean = true -): IdEntity { - - @Ignore - var refHomeSet: HomeSet? = null - +) { companion object { @@ -99,9 +116,6 @@ data class Collection( } 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 var color: Int? = null @@ -148,7 +162,6 @@ data class Collection( privWriteContent = privWriteContent, privUnbind = privUnbind, displayName = displayName, - owner = owner, description = description, color = color, timezone = timezone, @@ -161,12 +174,6 @@ data class Collection( } - - // non-persistent properties - @Ignore - var confirmed: Boolean = false - - // calculated properties fun title() = displayName ?: DavUtils.lastSegmentOfUrl(url) fun readOnly() = forceReadOnly || !privWriteContent diff --git a/app/src/main/java/at/bitfire/davdroid/db/CollectionDao.kt b/app/src/main/java/at/bitfire/davdroid/db/CollectionDao.kt index f210efde451994d05dae19f1a19514bbf2e407f5..b1de01742680882e08e8b414cef29e3e5f69bff3 100644 --- a/app/src/main/java/at/bitfire/davdroid/db/CollectionDao.kt +++ b/app/src/main/java/at/bitfire/davdroid/db/CollectionDao.kt @@ -6,13 +6,10 @@ 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 +import androidx.room.* @Dao -interface CollectionDao: SyncableDao { +interface CollectionDao { @Query("SELECT DISTINCT color FROM collection WHERE serviceId=:id") fun colorsByServiceLive(id: Long): LiveData> @@ -26,6 +23,9 @@ interface CollectionDao: SyncableDao { @Query("SELECT * FROM collection WHERE serviceId=:serviceId") fun getByService(serviceId: Long): List + @Query("SELECT * FROM collection WHERE serviceId=:serviceId AND homeSetId IS :homeSetId") + fun getByServiceAndHomeset(serviceId: Long, homeSetId: Long?): List + @Query("SELECT * FROM collection WHERE serviceId=:serviceId AND type=:type ORDER BY displayName, url") fun getByServiceAndType(serviceId: Long, type: String): List @@ -44,9 +44,13 @@ interface CollectionDao: SyncableDao { @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 + @Deprecated("Use getByServiceAndUrl instead") @Query("SELECT * FROM collection WHERE url=:url") fun getByUrl(url: String): Collection? + @Query("SELECT * FROM collection WHERE serviceId=:serviceId AND url=:url") + fun getByServiceAndUrl(serviceId: Long, 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 @@ -56,10 +60,47 @@ interface CollectionDao: SyncableDao { @Query("SELECT * FROM collection WHERE serviceId=:serviceId AND type='${Collection.TYPE_CALENDAR}' AND supportsVTODO AND sync AND NOT forceReadOnly AND privWriteContent ORDER BY displayName, url") fun getSyncTaskLists(serviceId: Long): List - @Insert(onConflict = OnConflictStrategy.REPLACE) - fun insertOrReplace(collection: Collection) + @Insert(onConflict = OnConflictStrategy.IGNORE) + fun insert(collection: Collection): Long + + @Update + fun update(collection: Collection) + + /** + * Tries to insert new row, but updates existing row if already present. + * This method preserves the primary key, as opposed to using "@Insert(onConflict = OnConflictStrategy.REPLACE)" + * which will create a new row with incremented ID and thus breaks entity relationships! + * + * @param collection Collection to be inserted or updated + * @return ID of the row, that has been inserted or updated. -1 If the insert fails due to other reasons. + */ + @Transaction + fun insertOrUpdateByUrl(collection: Collection): Long = getByServiceAndUrl( + collection.serviceId, + collection.url.toString() + )?.let { localCollection -> + update(collection.copy(id = localCollection.id)) + localCollection.id + } ?: insert(collection) - @Insert - fun insert(collection: Collection) + /** + * Inserts or updates the collection. On update it will not update flag values ([Collection.sync], + * [Collection.forceReadOnly]), but use the values of the already existing collection. + * + * @param newCollection Collection to be inserted or updated + */ + fun insertOrUpdateByUrlAndRememberFlags(newCollection: Collection) { + // remember locally set flags + getByServiceAndUrl(newCollection.serviceId, newCollection.url.toString())?.let { oldCollection -> + newCollection.sync = oldCollection.sync + newCollection.forceReadOnly = oldCollection.forceReadOnly + } + + // commit to database + insertOrUpdateByUrl(newCollection) + } + + @Delete + fun delete(collection: Collection) } diff --git a/app/src/main/java/at/bitfire/davdroid/db/Credentials.kt b/app/src/main/java/at/bitfire/davdroid/db/Credentials.kt index 6c9b34ae80c9f865c3d74ddcae11c8d8a20495e4..4e0469f4a715112b4675c2771fbbc1726e9faa28 100644 --- a/app/src/main/java/at/bitfire/davdroid/db/Credentials.kt +++ b/app/src/main/java/at/bitfire/davdroid/db/Credentials.kt @@ -17,8 +17,23 @@ data class Credentials( ) { override fun toString(): String { - val maskedPassword = "*****".takeIf { password != null } - return "Credentials(userName=$userName, password=$maskedPassword, certificateAlias=$certificateAlias)" + val s = mutableListOf() + + if (userName != null) + s += "userName=$userName" + if (password != null) + s += "password=******" + + if (certificateAlias != null) + s += "certificateAlias=$certificateAlias" + + if (authState != null) + s += "authState=******" + + if (clientSecret != null) + s += "clientSecret=******" + + return "Credentials(" + s.joinToString(", ") + ")" } } diff --git a/app/src/main/java/at/bitfire/davdroid/db/DaoTools.kt b/app/src/main/java/at/bitfire/davdroid/db/DaoTools.kt deleted file mode 100644 index 49460433eef34f9e4725ba0009df0bac57301b5c..0000000000000000000000000000000000000000 --- a/app/src/main/java/at/bitfire/davdroid/db/DaoTools.kt +++ /dev/null @@ -1,50 +0,0 @@ -/*************************************************************************************************** - * 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 - -class DaoTools(dao: SyncableDao): SyncableDao by dao { - - /** - * Synchronizes a list of "old" elements with a list of "new" elements so that the list - * only contain equal elements. - * - * @param allOld list of old elements - * @param allNew map of new elements (stored in key map) - * @param selectKey generates a unique key from the element (will be called on old elements) - * @param prepareNew prepares new elements (can be used to take over properties of old elements) - */ - fun syncAll(allOld: List, allNew: Map, selectKey: (T) -> K, prepareNew: (new: T, old: T) -> Unit = { _, _ -> }) { - Logger.log.log(Level.FINE, "Syncing tables", arrayOf(allOld, allNew)) - val remainingNew = allNew.toMutableMap() - allOld.forEach { old -> - val key = selectKey(old) - val matchingNew = remainingNew[key] - if (matchingNew != null) { - // keep this old item, but maybe update it - matchingNew.id = old.id // identity is proven by key - prepareNew(matchingNew, old) - - if (matchingNew != old) - update(matchingNew) - - // remove from remainingNew - remainingNew -= key - } else { - // this old item is not present anymore, delete it - delete(old) - } - } - - 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/db/HomeSet.kt b/app/src/main/java/at/bitfire/davdroid/db/HomeSet.kt index 9725fcd1ba79866df02b90d95dc3503b07c28eda..7cead82948ad87986ec6177a17d4f6df87bb0824 100644 --- a/app/src/main/java/at/bitfire/davdroid/db/HomeSet.kt +++ b/app/src/main/java/at/bitfire/davdroid/db/HomeSet.kt @@ -21,7 +21,7 @@ import okhttp3.HttpUrl ) data class HomeSet( @PrimaryKey(autoGenerate = true) - override var id: Long, + var id: Long, var serviceId: Long, @@ -35,4 +35,4 @@ data class HomeSet( var privBind: Boolean = true, var displayName: String? = null -): IdEntity \ No newline at end of file +) \ No newline at end of file diff --git a/app/src/main/java/at/bitfire/davdroid/db/HomeSetDao.kt b/app/src/main/java/at/bitfire/davdroid/db/HomeSetDao.kt index 9d8c2aa2950c81645ad3ec4608816aacf76d2589..986c45b76536276e414379ef6477f960c90c3398 100644 --- a/app/src/main/java/at/bitfire/davdroid/db/HomeSetDao.kt +++ b/app/src/main/java/at/bitfire/davdroid/db/HomeSetDao.kt @@ -5,13 +5,16 @@ package at.bitfire.davdroid.db import androidx.lifecycle.LiveData -import androidx.room.Dao -import androidx.room.Insert -import androidx.room.OnConflictStrategy -import androidx.room.Query +import androidx.room.* @Dao -interface HomeSetDao: SyncableDao { +interface HomeSetDao { + + @Query("SELECT * FROM homeset WHERE id=:homesetId") + fun getById(homesetId: Long): HomeSet + + @Query("SELECT * FROM homeset WHERE serviceId=:serviceId AND url=:url") + fun getByUrl(serviceId: Long, url: String): HomeSet? @Query("SELECT * FROM homeset WHERE serviceId=:serviceId") fun getByService(serviceId: Long): List @@ -22,7 +25,27 @@ interface HomeSetDao: SyncableDao { @Query("SELECT COUNT(*) FROM homeset WHERE serviceId=:serviceId AND privBind") fun hasBindableByServiceLive(serviceId: Long): LiveData - @Insert(onConflict = OnConflictStrategy.REPLACE) - fun insertOrReplace(homeSet: HomeSet): Long + @Insert + fun insert(homeSet: HomeSet): Long + + @Update + fun update(homeset: HomeSet) + + /** + * Tries to insert new row, but updates existing row if already present. + * This method preserves the primary key, as opposed to using "@Insert(onConflict = OnConflictStrategy.REPLACE)" + * which will create a new row with incremented ID and thus breaks entity relationships! + * + * @return ID of the row, that has been inserted or updated. -1 If the insert fails due to other reasons. + */ + @Transaction + fun insertOrUpdateByUrl(homeset: HomeSet): Long = + getByUrl(homeset.serviceId, homeset.url.toString())?.let { existingHomeset -> + update(homeset.copy(id = existingHomeset.id)) + existingHomeset.id + } ?: insert(homeset) + + @Delete + fun delete(homeset: HomeSet) } \ 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 deleted file mode 100644 index ff90c6a08b9e01f1bba1aa8f1a2a6258bcac1277..0000000000000000000000000000000000000000 --- a/app/src/main/java/at/bitfire/davdroid/db/IdEntity.kt +++ /dev/null @@ -1,13 +0,0 @@ -/*************************************************************************************************** - * 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/db/Principal.kt b/app/src/main/java/at/bitfire/davdroid/db/Principal.kt new file mode 100644 index 0000000000000000000000000000000000000000..ad79b1b8b6f4659556fcb90f2d63fb63bcf583a2 --- /dev/null +++ b/app/src/main/java/at/bitfire/davdroid/db/Principal.kt @@ -0,0 +1,69 @@ +/*************************************************************************************************** + * 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 +import at.bitfire.dav4jvm.Response +import at.bitfire.dav4jvm.UrlUtils +import at.bitfire.dav4jvm.property.DisplayName +import at.bitfire.dav4jvm.property.ResourceType +import okhttp3.HttpUrl +import org.apache.commons.lang3.StringUtils + +@Entity(tableName = "principal", + foreignKeys = [ + ForeignKey(entity = Service::class, parentColumns = arrayOf("id"), childColumns = arrayOf("serviceId"), onDelete = ForeignKey.CASCADE) + ], + indices = [ + // index by service, urls are unique + Index("serviceId", "url", unique = true) + ] +) +data class Principal( + @PrimaryKey(autoGenerate = true) + var id: Long = 0, + var serviceId: Long, + /** URL of the principal, always without trailing slash */ + var url: HttpUrl, + var displayName: String? = null +) { + + companion object { + + /** + * Generates a principal entity from a WebDAV response. + * @param dav WebDAV response (make sure that you have queried `DAV:resource-type` and `DAV:display-name`) + * @return generated principal data object (with `id`=0), `null` if the response doesn't represent a principal + */ + fun fromDavResponse(serviceId: Long, dav: Response): Principal? { + // Check if response is a principal + val resourceType = dav[ResourceType::class.java] ?: return null + if (!resourceType.types.contains(ResourceType.PRINCIPAL)) + return null + + // Try getting the display name of the principal + val displayName: String? = StringUtils.trimToNull( + dav[DisplayName::class.java]?.displayName + ) + + // Create and return principal - even without it's display name + return Principal( + serviceId = serviceId, + url = UrlUtils.omitTrailingSlash(dav.href), + displayName = displayName + ) + } + + fun fromServiceAndUrl(service: Service, url: HttpUrl) = Principal( + serviceId = service.id, + url = UrlUtils.omitTrailingSlash(url) + ) + + } + +} \ No newline at end of file diff --git a/app/src/main/java/at/bitfire/davdroid/db/PrincipalDao.kt b/app/src/main/java/at/bitfire/davdroid/db/PrincipalDao.kt new file mode 100644 index 0000000000000000000000000000000000000000..70dfe08e4ebdb3ea256bda852d0981a7a0aff681 --- /dev/null +++ b/app/src/main/java/at/bitfire/davdroid/db/PrincipalDao.kt @@ -0,0 +1,55 @@ +/*************************************************************************************************** + * 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.* +import okhttp3.HttpUrl + +@Dao +interface PrincipalDao { + + @Query("SELECT * FROM principal WHERE id=:id") + fun get(id: Long): Principal + + @Query("SELECT * FROM principal WHERE id=:id") + fun getLive(id: Long): LiveData + + @Query("SELECT * FROM principal WHERE serviceId=:serviceId") + fun getByService(serviceId: Long): List + + @Query("SELECT * FROM principal WHERE serviceId=:serviceId AND url=:url") + fun getByUrl(serviceId: Long, url: HttpUrl): Principal? + + /** + * Gets all principals who do not own any collections + */ + @Query("SELECT * FROM principal WHERE principal.id NOT IN (SELECT ownerId FROM collection WHERE ownerId IS NOT NULL)") + fun getAllWithoutCollections(): List + + @Insert + fun insert(principal: Principal): Long + + @Update + fun update(principal: Principal) + + @Delete + fun delete(principal: Principal) + + /** + * Inserts, updates or just gets existing principal if its display name has not + * changed (will not update/overwrite with null values). + * + * @param principal Principal to be inserted or updated + * @return ID of the newly inserted or already existing principal + */ + fun insertOrUpdate(serviceId: Long, principal: Principal): Long = + getByUrl(serviceId, principal.url)?.let { oldPrincipal -> + if (principal.displayName != oldPrincipal.displayName) + update(principal.copy(id = oldPrincipal.id)) + return oldPrincipal.id + } ?: insert(principal) + +} \ No newline at end of file diff --git a/app/src/main/java/at/bitfire/davdroid/db/Service.kt b/app/src/main/java/at/bitfire/davdroid/db/Service.kt index 1e757519a41ae48e23c4acaf27328429ca9a7ceb..6f4adc2dd0287a7cbbddaa6f26b4f56abe4fc047 100644 --- a/app/src/main/java/at/bitfire/davdroid/db/Service.kt +++ b/app/src/main/java/at/bitfire/davdroid/db/Service.kt @@ -9,6 +9,11 @@ import androidx.room.Index import androidx.room.PrimaryKey import okhttp3.HttpUrl +/** + * A service entity. + * + * Services represent accounts and are unique. They are of type CardDAV or CalDAV and may have an associated principal. + */ @Entity(tableName = "service", indices = [ // only one service per type and account @@ -16,7 +21,7 @@ import okhttp3.HttpUrl ]) data class Service( @PrimaryKey(autoGenerate = true) - override var id: Long, + var id: Long, var accountName: String, @@ -27,7 +32,7 @@ data class Service( var type: String, var principal: HttpUrl? -): IdEntity { +) { companion object { const val TYPE_CALDAV = "caldav" diff --git a/app/src/main/java/at/bitfire/davdroid/db/ServiceDao.kt b/app/src/main/java/at/bitfire/davdroid/db/ServiceDao.kt index d45bb779dc6348fa86a9795f08ffd75f3f117f4c..904a106f85cddbb05c9ea687f6eb4ac6b946a273 100644 --- a/app/src/main/java/at/bitfire/davdroid/db/ServiceDao.kt +++ b/app/src/main/java/at/bitfire/davdroid/db/ServiceDao.kt @@ -5,6 +5,7 @@ package at.bitfire.davdroid.db import androidx.lifecycle.LiveData +import androidx.room.ColumnInfo import androidx.room.Dao import androidx.room.Insert import androidx.room.OnConflictStrategy @@ -19,6 +20,9 @@ interface ServiceDao { @Query("SELECT id FROM service WHERE accountName=:accountName AND type=:type") fun getIdByAccountAndType(accountName: String, type: String): LiveData + @Query("SELECT type, id FROM service WHERE accountName=:accountName") + fun getServiceTypeAndIdsByAccount(accountName: String): LiveData> + @Query("SELECT * FROM service WHERE id=:id") fun get(id: Long): Service? @@ -38,4 +42,9 @@ interface ServiceDao { @Query("UPDATE service SET accountName=:newName WHERE accountName=:oldName") fun renameAccount(oldName: String, newName: String) -} \ No newline at end of file +} + +data class ServiceTypeAndId( + @ColumnInfo(name = "type") val type: String, + @ColumnInfo(name = "id") val id: 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 index da702d5f2d8907988dcee1d0ffee278ba5bfb014..9b7ef974b1c106b0be77c4a1a457d49606832e17 100644 --- a/app/src/main/java/at/bitfire/davdroid/db/SyncStatsDao.kt +++ b/app/src/main/java/at/bitfire/davdroid/db/SyncStatsDao.kt @@ -4,9 +4,11 @@ package at.bitfire.davdroid.db +import androidx.lifecycle.LiveData import androidx.room.Dao import androidx.room.Insert import androidx.room.OnConflictStrategy +import androidx.room.Query @Dao interface SyncStatsDao { @@ -14,4 +16,6 @@ interface SyncStatsDao { @Insert(onConflict = OnConflictStrategy.REPLACE) fun insertOrReplace(syncStats: SyncStats) + @Query("SELECT * FROM syncstats WHERE collectionId=:id") + fun getLiveByCollectionId(id: Long): LiveData> } \ 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 deleted file mode 100644 index 745e156fcd478f8f8801aa3e74d49cee2a597b1e..0000000000000000000000000000000000000000 --- a/app/src/main/java/at/bitfire/davdroid/db/SyncableDao.kt +++ /dev/null @@ -1,23 +0,0 @@ -/*************************************************************************************************** - * 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 index 8854ab5c93afe44bd082e35c2a0108fe72c9994a..404d4e1962aa5eec65797ad31d249f90cdbac490 100644 --- a/app/src/main/java/at/bitfire/davdroid/db/WebDavDocument.kt +++ b/app/src/main/java/at/bitfire/davdroid/db/WebDavDocument.kt @@ -12,7 +12,7 @@ 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 at.bitfire.davdroid.util.DavUtils.MEDIA_TYPE_OCTET_STREAM import okhttp3.HttpUrl import okhttp3.MediaType import okhttp3.MediaType.Companion.toMediaTypeOrNull @@ -31,7 +31,7 @@ import java.io.FileNotFoundException data class WebDavDocument( @PrimaryKey(autoGenerate = true) - override var id: Long = 0, + var id: Long = 0, /** refers to the [WebDavMount] the document belongs to */ val mountId: Long, @@ -56,7 +56,7 @@ data class WebDavDocument( var quotaAvailable: Long? = null, var quotaUsed: Long? = null -): IdEntity { +) { @SuppressLint("InlinedApi") fun toBundle(parent: WebDavDocument?): Bundle { diff --git a/app/src/main/java/at/bitfire/davdroid/db/WebDavDocumentDao.kt b/app/src/main/java/at/bitfire/davdroid/db/WebDavDocumentDao.kt index ec6556fc96e06edc1a2780e58c8a2483b86a15c7..40a6bd8101824c6499def509e1e9dfe5af3e2841 100644 --- a/app/src/main/java/at/bitfire/davdroid/db/WebDavDocumentDao.kt +++ b/app/src/main/java/at/bitfire/davdroid/db/WebDavDocumentDao.kt @@ -8,7 +8,7 @@ import androidx.lifecycle.LiveData import androidx.room.* @Dao -interface WebDavDocumentDao: SyncableDao { +interface WebDavDocumentDao { @Query("SELECT * FROM webdav_document WHERE id=:id") fun get(id: Long): WebDavDocument? @@ -28,9 +28,35 @@ interface WebDavDocumentDao: SyncableDao { @Query("DELETE FROM webdav_document WHERE parentId=:parentId") fun removeChildren(parentId: Long) + @Insert + fun insert(document: WebDavDocument): Long + + @Update + fun update(document: WebDavDocument) + + @Delete + fun delete(document: WebDavDocument) + // complex operations + /** + * Tries to insert new row, but updates existing row if already present. + * This method preserves the primary key, as opposed to using "@Insert(onConflict = OnConflictStrategy.REPLACE)" + * which will create a new row with incremented ID and thus breaks entity relationships! + * + * @return ID of the row, that has been inserted or updated. -1 If the insert fails due to other reasons. + */ + @Transaction + fun insertOrUpdate(document: WebDavDocument): Long { + val parentId = document.parentId + ?: return insert(document) + val existingDocument = getByParentAndName(document.mountId, parentId, document.name) + ?: return insert(document) + update(document.copy(id = existingDocument.id)) + return existingDocument.id + } + @Transaction fun getOrCreateRoot(mount: WebDavMount): WebDavDocument { getByParentAndName(mount.id, null, "")?.let { existing -> diff --git a/app/src/main/java/at/bitfire/davdroid/db/WebDavMount.kt b/app/src/main/java/at/bitfire/davdroid/db/WebDavMount.kt index 89cb8ddd93005f4c3b6937e15e83d3a8e0930fe6..91d81ba074ac9bb5544cff9e1fac62702807d130 100644 --- a/app/src/main/java/at/bitfire/davdroid/db/WebDavMount.kt +++ b/app/src/main/java/at/bitfire/davdroid/db/WebDavMount.kt @@ -11,7 +11,7 @@ import okhttp3.HttpUrl @Entity(tableName = "webdav_mount") data class WebDavMount( @PrimaryKey(autoGenerate = true) - override var id: Long = 0, + var id: Long = 0, /** display name of the WebDAV mount */ var name: String, @@ -21,4 +21,4 @@ data class WebDavMount( // credentials are stored using CredentialsStore -): IdEntity \ No newline at end of file +) \ No newline at end of file diff --git a/app/src/main/java/at/bitfire/davdroid/di/SyncComponent.kt b/app/src/main/java/at/bitfire/davdroid/di/SyncComponent.kt new file mode 100644 index 0000000000000000000000000000000000000000..7363eb2ce126ff2366d5f5c76edcf6f8785b4576 --- /dev/null +++ b/app/src/main/java/at/bitfire/davdroid/di/SyncComponent.kt @@ -0,0 +1,72 @@ +/*************************************************************************************************** + * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. + **************************************************************************************************/ + +package at.bitfire.davdroid.di + +import at.bitfire.davdroid.log.Logger +import dagger.hilt.DefineComponent +import dagger.hilt.components.SingletonComponent +import java.lang.ref.WeakReference +import javax.inject.Inject +import javax.inject.Provider +import javax.inject.Scope +import javax.inject.Singleton + +@Scope +@Retention(AnnotationRetention.BINARY) +annotation class SyncScoped + +/** + * Custom Hilt component for running syncs, lifetime managed by [SyncComponentManager]. + * Dependencies installed in this component and scoped with [SyncScoped] (like SyncValidators) + * will have a lifetime of all active syncs. + */ +@SyncScoped +@DefineComponent(parent = SingletonComponent::class) +interface SyncComponent + +@DefineComponent.Builder +interface SyncComponentBuilder { + fun build(): SyncComponent +} + +/** + * Manages the lifecycle of [SyncComponent] by using [WeakReference]. + * + * @sample at.bitfire.davdroid.syncadapter.LicenseValidator + * @sample at.bitfire.davdroid.syncadapter.PaymentValidator + */ +@Singleton +class SyncComponentManager @Inject constructor( + val provider: Provider +) { + + private var componentRef: WeakReference? = null + + /** + * Returns a [SyncComponent]. When there is already a known [SyncComponent], + * it will be used. Otherwise, a new one will be created and returned. + * + * It is then stored using a [WeakReference] – so as long as the component + * stays in memory, it will always be returned. When it's not used anymore + * by anyone, it can be removed by garbage collection. After this, it will be + * created again when [get] is called. + * + * @return singleton [SyncComponent] (will be garbage collected when not referenced anymore) + */ + @Synchronized + fun get(): SyncComponent { + val component = componentRef?.get() + + // check for cached component + if (component != null) + return component + + // cached component not available, build new one + val newComponent = provider.get().build() + componentRef = WeakReference(newComponent) + return newComponent + } + +} \ No newline at end of file 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 b366de799b3ed74932810a48f925758b1bfc8f4e..a4c3fdf401d006d57292513d1e6526e80703b846 100644 --- a/app/src/main/java/at/bitfire/davdroid/log/Logger.kt +++ b/app/src/main/java/at/bitfire/davdroid/log/Logger.kt @@ -19,9 +19,10 @@ import at.bitfire.davdroid.R import at.bitfire.davdroid.ui.AppSettingsActivity import at.bitfire.davdroid.ui.DebugInfoActivity import at.bitfire.davdroid.ui.NotificationUtils +import at.bitfire.davdroid.ui.NotificationUtils.notifyIfPossible import java.io.File import java.io.IOException -import java.util.* +import java.util.Date import java.util.logging.FileHandler import java.util.logging.Level @@ -74,8 +75,7 @@ object Logger : SharedPreferences.OnSharedPreferenceChangeListener { val nm = NotificationManagerCompat.from(context) // log to external file according to preferences if (logToFile) { - val logDir = debugDir() ?: return - val logFile = File(logDir, "davx5-log.txt") + val logFile = getDebugLogFile() ?: return log.warning("Log file could not be retrieved.") if (logFile.createNewFile()) logFile.writeText("Log file created at ${Date()}; PID ${Process.myPid()}; UID ${Process.myUid()}\n") @@ -95,7 +95,6 @@ object Logger : SharedPreferences.OnSharedPreferenceChangeListener { .setOngoing(true) val shareIntent = DebugInfoActivity.IntentBuilder(context) - .withLogFile(logFile) .newTask() .share() val pendingShare = PendingIntent.getActivity(context, 0, shareIntent, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE) @@ -115,7 +114,7 @@ object Logger : SharedPreferences.OnSharedPreferenceChangeListener { pendingPref ).build()) - nm.notify(NotificationUtils.NOTIFY_VERBOSE_LOGGING, builder.build()) + nm.notifyIfPossible(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() @@ -128,7 +127,13 @@ object Logger : SharedPreferences.OnSharedPreferenceChangeListener { } - private fun debugDir(): File? { + /** + * Creates (when necessary) and returns the directory where all the debug files (such as log files) are stored. + * Must match the contents of `res/xml/debug.paths.xml`. + * + * @return The directory where all debug info are stored, or `null` if the directory couldn't be created successfully. + */ + fun debugDir(): File? { val dir = File(context.filesDir, "debug") if (dir.exists() && dir.isDirectory) return dir @@ -140,4 +145,14 @@ object Logger : SharedPreferences.OnSharedPreferenceChangeListener { return null } + /** + * The file (in [debugDir]) where verbose logs are stored. + * + * @return The file where verbose logs are stored, or `null` if there's no [debugDir]. + */ + fun getDebugLogFile(): File? { + val logDir = debugDir() ?: return null + return File(logDir, "davx5-log.txt") + } + } \ No newline at end of file diff --git a/app/src/main/java/at/bitfire/davdroid/Android10Resolver.kt b/app/src/main/java/at/bitfire/davdroid/network/Android10Resolver.kt similarity index 90% rename from app/src/main/java/at/bitfire/davdroid/Android10Resolver.kt rename to app/src/main/java/at/bitfire/davdroid/network/Android10Resolver.kt index 614484231251f5e389d18358816186c38fb159d0..2479b7aa940d49113378c6890d44ff34ed267ca0 100644 --- a/app/src/main/java/at/bitfire/davdroid/Android10Resolver.kt +++ b/app/src/main/java/at/bitfire/davdroid/network/Android10Resolver.kt @@ -1,8 +1,8 @@ -/*************************************************************************************************** +/* * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. - **************************************************************************************************/ + */ -package at.bitfire.davdroid +package at.bitfire.davdroid.network import android.net.DnsResolver import android.os.Build diff --git a/app/src/main/java/at/bitfire/davdroid/network/BearerAuthInterceptor.kt b/app/src/main/java/at/bitfire/davdroid/network/BearerAuthInterceptor.kt new file mode 100644 index 0000000000000000000000000000000000000000..40773e86236b18c250ce6fd9ff4cb881d8e45918 --- /dev/null +++ b/app/src/main/java/at/bitfire/davdroid/network/BearerAuthInterceptor.kt @@ -0,0 +1,71 @@ +/* + * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. + */ + +package at.bitfire.davdroid.network + +import at.bitfire.davdroid.log.Logger +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.runBlocking +import net.openid.appauth.AuthState +import net.openid.appauth.AuthorizationException +import net.openid.appauth.AuthorizationService +import net.openid.appauth.ClientAuthentication +import okhttp3.Interceptor +import okhttp3.Response +import java.util.logging.Level + +/** + * Sends an OAuth Bearer token authorization as described in RFC 6750. + */ +class BearerAuthInterceptor( + private val accessToken: String +): Interceptor { + + companion object { + + fun fromAuthState(authService: AuthorizationService, authState: AuthState, clientAuth: ClientAuthentication, + callback: AuthStateUpdateCallback? = null): BearerAuthInterceptor? { + return runBlocking { + val accessTokenFuture = CompletableDeferred() + + authState.performActionWithFreshTokens(authService, clientAuth) { accessToken: String?, _: String?, ex: AuthorizationException? -> + if (accessToken != null) { + // persist updated AuthState + callback?.onUpdate(authState) + + // emit access token + accessTokenFuture.complete(accessToken) + } + else { + Logger.log.log(Level.WARNING, "Couldn't obtain access token", ex) + accessTokenFuture.cancel() + } + } + + // return value + try { + BearerAuthInterceptor(accessTokenFuture.await()) + } catch (ignored: CancellationException) { + null + } + } + } + + } + + override fun intercept(chain: Interceptor.Chain): Response { + Logger.log.finer("Authenticating request with access token") + val rq = chain.request().newBuilder() + .header("Authorization", "Bearer $accessToken") + .build() + return chain.proceed(rq) + } + + + fun interface AuthStateUpdateCallback { + fun onUpdate(authState: AuthState) + } + +} \ 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/network/HttpClient.kt similarity index 66% rename from app/src/main/java/at/bitfire/davdroid/HttpClient.kt rename to app/src/main/java/at/bitfire/davdroid/network/HttpClient.kt index ad255d1853ba4325bf4b95d793a1ee91fc24d5b0..4cd6b08e46b2c5b0e9872808a6868aa41cdd8b3f 100644 --- a/app/src/main/java/at/bitfire/davdroid/HttpClient.kt +++ b/app/src/main/java/at/bitfire/davdroid/network/HttpClient.kt @@ -1,8 +1,8 @@ -/*************************************************************************************************** +/* * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. - **************************************************************************************************/ + */ -package at.bitfire.davdroid +package at.bitfire.davdroid.network import android.content.Context import android.os.Build @@ -10,6 +10,8 @@ import android.security.KeyChain import at.bitfire.cert4android.CustomCertManager import at.bitfire.dav4jvm.BasicDigestAuthHandler import at.bitfire.dav4jvm.UrlUtils +import at.bitfire.davdroid.BuildConfig +import at.bitfire.davdroid.OpenIdUtils import at.bitfire.davdroid.db.Credentials import at.bitfire.davdroid.log.Logger import at.bitfire.davdroid.settings.AccountSettings @@ -19,6 +21,8 @@ import dagger.hilt.EntryPoint import dagger.hilt.InstallIn import dagger.hilt.android.EntryPointAccessors import dagger.hilt.components.SingletonComponent +import net.openid.appauth.AuthState +import net.openid.appauth.AuthorizationService import okhttp3.* import okhttp3.brotli.BrotliInterceptor import okhttp3.internal.tls.OkHostnameVerifier @@ -37,12 +41,14 @@ import javax.net.ssl.* class HttpClient private constructor( val okHttpClient: OkHttpClient, - private val certManager: CustomCertManager? + private val certManager: CustomCertManager? = null, + private var authService: AuthorizationService? = null ): AutoCloseable { @EntryPoint @InstallIn(SingletonComponent::class) interface HttpClientEntryPoint { + fun authorizationService(): AuthorizationService fun settingsManager(): SettingsManager } @@ -79,14 +85,15 @@ class HttpClient private constructor( .addInterceptor(UserAgentInterceptor) } - override fun close() { + authService?.dispose() okHttpClient.cache?.close() certManager?.close() } + class Builder( - val context: Context? = null, + val context: Context, accountSettings: AccountSettings? = null, val logger: java.util.logging.Logger? = Logger.log, val loggerLevel: HttpLoggingInterceptor.Level = HttpLoggingInterceptor.Level.BODY @@ -97,6 +104,7 @@ class HttpClient private constructor( } private var appInForeground = false + private var authService: AuthorizationService? = null private var certManagerProducer: CertManagerProducer? = null private var certificateAlias: String? = null private var offerCompression: Boolean = false @@ -114,60 +122,69 @@ class HttpClient private constructor( orig.addNetworkInterceptor(loggingInterceptor) } - if (context != null) { - val settings = EntryPointAccessors.fromApplication(context, HttpClientEntryPoint::class.java).settingsManager() - - // custom proxy support - try { - 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 setting", proxy) + val settings = EntryPointAccessors.fromApplication(context, HttpClientEntryPoint::class.java).settingsManager() + + // custom proxy support + try { + 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) + ) } - } catch (e: Exception) { - Logger.log.log(Level.SEVERE, "Can't set proxy, ignoring", e) + 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 setting", proxy) } + } catch (e: Exception) { + Logger.log.log(Level.SEVERE, "Can't set proxy, ignoring", e) + } - 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) - } + 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 and cookies - accountSettings?.let { - addAuthentication(null, it.credentials()) - } + if (accountSettings != null) + addAuthentication(null, accountSettings.credentials(), authStateCallback = { authState: AuthState -> + accountSettings.credentials(Credentials(authState = authState)) + }) } - constructor(context: Context, host: String?, credentials: Credentials?): this(context) { + constructor(context: Context, host: String?, credentials: Credentials?) : this(context) { if (credentials != null) addAuthentication(host, credentials) } - fun addAuthentication(host: String?, credentials: Credentials, insecurePreemptive: Boolean = false): Builder { + fun addAuthentication(host: String?, credentials: Credentials, insecurePreemptive: Boolean = false, authStateCallback: BearerAuthInterceptor.AuthStateUpdateCallback? = null): 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 + + credentials.authState?.let { authState -> + val newAuthService = EntryPointAccessors.fromApplication(context, HttpClientEntryPoint::class.java).authorizationService() + authService = newAuthService + val clientAuth = OpenIdUtils.getClientAuthentication(credentials.clientSecret) + BearerAuthInterceptor.fromAuthState(newAuthService, authState, clientAuth, authStateCallback)?.let { bearerAuthInterceptor -> + orig.addNetworkInterceptor(bearerAuthInterceptor) + } + } return this } @@ -194,7 +211,7 @@ class HttpClient private constructor( return this } - fun withDiskCache(context: Context): Builder { + fun withDiskCache(): Builder { for (dir in arrayOf(context.externalCacheDir, context.cacheDir).filterNotNull()) { if (dir.exists() && dir.canWrite()) { val cacheDir = File(dir, "HttpClient") @@ -218,8 +235,6 @@ class HttpClient private constructor( var keyManager: KeyManager? = null certificateAlias?.let { alias -> - 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 @@ -248,40 +263,44 @@ class HttpClient private constructor( orig.protocols(listOf(Protocol.HTTP_1_1)) } - 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) - - return HttpClient(orig.build(), certManager) - } else - return HttpClient(orig.build(), null) + val certManager = + if (certManagerProducer != null || keyManager != null) { + val manager = certManagerProducer?.certManager() + manager?.appInForeground = appInForeground + + val trustManager = manager ?: { // 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 = manager?.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) + + manager + } else + null + + return HttpClient(orig.build(), certManager = certManager, authService = authService) } } - private object UserAgentInterceptor: Interceptor { + object UserAgentInterceptor: Interceptor { + // 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; " + + val userAgent = "${BuildConfig.userAgent}/${BuildConfig.VERSION_NAME} ($userAgentDate; dav4jvm; " + "okhttp/${OkHttp.VERSION}) Android/${Build.VERSION.RELEASE}" init { diff --git a/app/src/main/java/at/bitfire/davdroid/MemoryCookieStore.kt b/app/src/main/java/at/bitfire/davdroid/network/MemoryCookieStore.kt similarity index 98% rename from app/src/main/java/at/bitfire/davdroid/MemoryCookieStore.kt rename to app/src/main/java/at/bitfire/davdroid/network/MemoryCookieStore.kt index 250913d0afe409f58e5c8c89d2ad0827e2b1eb27..05e7ea63f0e859024e1c8403426324b70d2748df 100644 --- a/app/src/main/java/at/bitfire/davdroid/MemoryCookieStore.kt +++ b/app/src/main/java/at/bitfire/davdroid/network/MemoryCookieStore.kt @@ -2,7 +2,7 @@ * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. **************************************************************************************************/ -package at.bitfire.davdroid +package at.bitfire.davdroid.network import okhttp3.Cookie import okhttp3.CookieJar diff --git a/app/src/main/java/at/bitfire/davdroid/network/OAuthModule.kt b/app/src/main/java/at/bitfire/davdroid/network/OAuthModule.kt new file mode 100644 index 0000000000000000000000000000000000000000..e8c10421afad2f1ec170c65427a5f5d056df6230 --- /dev/null +++ b/app/src/main/java/at/bitfire/davdroid/network/OAuthModule.kt @@ -0,0 +1,33 @@ +/* + * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. + */ + +package at.bitfire.davdroid.network + +import android.content.Context +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.android.qualifiers.ApplicationContext +import dagger.hilt.components.SingletonComponent +import net.openid.appauth.AppAuthConfiguration +import net.openid.appauth.AuthorizationService +import java.net.HttpURLConnection +import java.net.URL + +@Module +@InstallIn(SingletonComponent::class) +object OAuthModule { + + @Provides + fun authorizationService(@ApplicationContext context: Context): AuthorizationService = + AuthorizationService(context, + AppAuthConfiguration.Builder() + .setConnectionBuilder { uri -> + val url = URL(uri.toString()) + (url.openConnection() as HttpURLConnection).apply { + setRequestProperty("User-Agent", HttpClient.UserAgentInterceptor.userAgent) + } + }.build() + ) +} \ No newline at end of file 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 abada92d0750e31c61f3a52c980b0dcd3a3e819d..fc5645ceebcd40d7c6061cdec0fdfe592fbe0696 100644 --- a/app/src/main/java/at/bitfire/davdroid/resource/LocalAddressBook.kt +++ b/app/src/main/java/at/bitfire/davdroid/resource/LocalAddressBook.kt @@ -14,7 +14,6 @@ import android.provider.ContactsContract.CommonDataKinds.GroupMembership import android.provider.ContactsContract.Groups import android.provider.ContactsContract.RawContacts import android.util.Base64 -import at.bitfire.davdroid.DavUtils import at.bitfire.davdroid.R import at.bitfire.davdroid.db.AppDatabase import at.bitfire.davdroid.db.Collection @@ -22,7 +21,7 @@ import at.bitfire.davdroid.db.SyncState import at.bitfire.davdroid.log.Logger import at.bitfire.davdroid.settings.AccountSettings import at.bitfire.davdroid.syncadapter.AccountUtils -import at.bitfire.davdroid.syncadapter.SyncUtils +import at.bitfire.davdroid.util.DavUtils import at.bitfire.vcard4android.* import java.io.ByteArrayOutputStream @@ -67,7 +66,7 @@ open class LocalAddressBook( throw IllegalStateException("Couldn't create address book account") val addressBook = LocalAddressBook(context, account, provider) - addressBook.updateSyncSettings() + addressBook.updateSyncFrameworkSettings() // initialize Contacts Provider Settings val values = ContentValues(2) @@ -231,7 +230,7 @@ open class LocalAddressBook( fun update(info: Collection, forceReadOnly: Boolean) { val newAccountName = accountName(mainAccount, info) - if (account.name != newAccountName && Build.VERSION.SDK_INT >= 21) { + if (account.name != newAccountName) { // no need to re-assign contacts to new account, because they will be deleted by contacts provider in any case val accountManager = AccountManager.get(context) val future = accountManager.renameAccount(account, newAccountName, null, null) @@ -262,7 +261,7 @@ open class LocalAddressBook( } // make sure it will still be synchronized when contacts are updated - updateSyncSettings() + updateSyncFrameworkSettings() } fun delete() { @@ -289,17 +288,23 @@ open class LocalAddressBook( /** * 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() { + * - Contacts sync of this address book account shall be possible -> isSyncable = 1 + * - When a contact is changed, a sync shall be initiated -> syncAutomatically = true + * - Remove unwanted sync framework periodic syncs created by setSyncAutomatically, as + * we use PeriodicSyncWorker for scheduled syncs + */ + fun updateSyncFrameworkSettings() { + // Enable sync-ability if (ContentResolver.getIsSyncable(account, ContactsContract.AUTHORITY) != 1) ContentResolver.setIsSyncable(account, ContactsContract.AUTHORITY, 1) + + // Enable content trigger if (!ContentResolver.getSyncAutomatically(account, ContactsContract.AUTHORITY)) ContentResolver.setSyncAutomatically(account, ContactsContract.AUTHORITY, true) - SyncUtils.removePeriodicSyncs(account, ContactsContract.AUTHORITY) + + // Remove periodic syncs (setSyncAutomatically also creates periodic syncs, which we don't want) + for (periodicSync in ContentResolver.getPeriodicSyncs(account, ContactsContract.AUTHORITY)) + ContentResolver.removePeriodicSync(periodicSync.account, periodicSync.authority, periodicSync.extras) } 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 bf22df9551c2debf42bd68d0c733a9c052c1e220..fb15ebb051c0c2d27e2b39e1f7e80aec6449e509 100644 --- a/app/src/main/java/at/bitfire/davdroid/resource/LocalCalendar.kt +++ b/app/src/main/java/at/bitfire/davdroid/resource/LocalCalendar.kt @@ -12,7 +12,7 @@ import android.net.Uri import android.provider.CalendarContract.Calendars import android.provider.CalendarContract.Events import at.bitfire.davdroid.Constants -import at.bitfire.davdroid.DavUtils +import at.bitfire.davdroid.util.DavUtils import at.bitfire.davdroid.db.Collection import at.bitfire.davdroid.db.SyncState import at.bitfire.davdroid.log.Logger diff --git a/app/src/main/java/at/bitfire/davdroid/resource/LocalJtxCollection.kt b/app/src/main/java/at/bitfire/davdroid/resource/LocalJtxCollection.kt index 3a35f14b9c12ea7fec8d67d680c6f86dafc09f70..0132a3e283ca162e04b29cf88501b71ddaeb1444 100644 --- a/app/src/main/java/at/bitfire/davdroid/resource/LocalJtxCollection.kt +++ b/app/src/main/java/at/bitfire/davdroid/resource/LocalJtxCollection.kt @@ -7,13 +7,15 @@ 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.* import at.bitfire.davdroid.db.Collection -import at.bitfire.davdroid.db.SyncState +import at.bitfire.davdroid.log.Logger +import at.bitfire.davdroid.util.DavUtils import at.bitfire.ical4android.JtxCollection import at.bitfire.ical4android.JtxCollectionFactory import at.bitfire.ical4android.JtxICalObject import at.techbee.jtx.JtxContract +import java.util.logging.Level class LocalJtxCollection(account: Account, client: ContentProviderClient, id: Long): JtxCollection(account, client, LocalJtxICalObject.Factory, id), @@ -21,17 +23,20 @@ class LocalJtxCollection(account: Account, client: ContentProviderClient, id: Lo companion object { - fun create(account: Account, client: ContentProviderClient, info: Collection) { - val values = valuesFromCollection(info, account) + fun create(account: Account, client: ContentProviderClient, info: Collection, owner: Principal?) { + val values = valuesFromCollection(info, account, owner) create(account, client, values) } - fun valuesFromCollection(info: Collection, account: Account) = + fun valuesFromCollection(info: Collection, account: Account, owner: Principal?) = 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()) + if (owner != null) + put(JtxContract.JtxCollection.OWNER, owner.url.toString()) + else Logger.log.log(Level.SEVERE, "No collection owner given. Will create jtx collection without owner") + put(JtxContract.JtxCollection.OWNER_DISPLAYNAME, owner?.displayName) put(JtxContract.JtxCollection.COLOR, info.color) put(JtxContract.JtxCollection.SUPPORTSVEVENT, info.supportsVEVENT) put(JtxContract.JtxCollection.SUPPORTSVJOURNAL, info.supportsVJOURNAL) @@ -50,8 +55,8 @@ class LocalJtxCollection(account: Account, client: ContentProviderClient, id: Lo get() = SyncState.fromString(syncstate) set(value) { syncstate = value.toString() } - fun updateCollection(info: Collection) { - val values = valuesFromCollection(info, account) + fun updateCollection(info: Collection, owner: Principal?) { + val values = valuesFromCollection(info, account, owner) update(values) } @@ -78,6 +83,14 @@ class LocalJtxCollection(account: Account, client: ContentProviderClient, id: Lo return LocalJtxICalObject.Factory.fromProvider(this, values) } + /** + * Finds and returns a recurring instance of a [LocalJtxICalObject] + */ + fun findRecurring(uid: String, recurid: String, dtstart: Long): LocalJtxICalObject? { + val values = queryRecur(uid, recurid, dtstart) ?: return null + return LocalJtxICalObject.Factory.fromProvider(this, values) + } + override fun markNotDirty(flags: Int)= updateSetFlags(flags) override fun removeNotDirtyMarked(flags: Int) = deleteByFlags(flags) 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 79359d587d00d28aa379031eed221eb9c2825c6b..d307c9c14e099dcafcb12206b4709209d5f84d62 100644 --- a/app/src/main/java/at/bitfire/davdroid/resource/LocalTaskList.kt +++ b/app/src/main/java/at/bitfire/davdroid/resource/LocalTaskList.kt @@ -10,7 +10,7 @@ import android.content.ContentValues import android.content.Context import android.net.Uri import at.bitfire.davdroid.Constants -import at.bitfire.davdroid.DavUtils +import at.bitfire.davdroid.util.DavUtils import at.bitfire.davdroid.db.Collection import at.bitfire.davdroid.db.SyncState import at.bitfire.davdroid.log.Logger diff --git a/app/src/main/java/at/bitfire/davdroid/servicedetection/DavResourceFinder.kt b/app/src/main/java/at/bitfire/davdroid/servicedetection/DavResourceFinder.kt index 3160bac5da2915b241677bdd0960d7ad4d7ef6bb..72f8cba8b434b179cf640e6c2f37c87a38696a6d 100644 --- a/app/src/main/java/at/bitfire/davdroid/servicedetection/DavResourceFinder.kt +++ b/app/src/main/java/at/bitfire/davdroid/servicedetection/DavResourceFinder.kt @@ -1,7 +1,3 @@ -/*************************************************************************************************** - * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. - **************************************************************************************************/ - /*************************************************************************************************** * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. **************************************************************************************************/ @@ -9,23 +5,23 @@ package at.bitfire.davdroid.servicedetection import android.content.Context import at.bitfire.dav4jvm.DavResource +import at.bitfire.dav4jvm.Property 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.db.Credentials import at.bitfire.davdroid.log.StringHandler -import at.bitfire.davdroid.ui.setup.LoginModel +import at.bitfire.davdroid.network.HttpClient +import at.bitfire.davdroid.util.DavUtils 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 -import java.io.IOException import java.io.InterruptedIOException import java.net.SocketTimeoutException import java.net.URI @@ -34,11 +30,25 @@ import java.util.* import java.util.logging.Level import java.util.logging.Logger +/** + * Does initial resource detection (straight after app install). Called after user has supplied url in + * app setup process [at.bitfire.davdroid.ui.setup.DetectConfigurationFragment]. + * It uses the (user given) base URL to find + * - services (CalDAV and/or CardDAV), + * - principal, + * - homeset/collections (multistatus responses are handled through dav4jvm). + * + * @param context to build the HTTP client + * @param serverURI user-given base URI (either mailto: URI or http(s):// URL) + * @param credentials optional login credentials (username/password, client certificate, OAuth state) + */ class DavResourceFinder( - val context: Context, - private val loginModel: LoginModel, - private val blockOnUnauthorizedException: Boolean = false -): AutoCloseable { + val context: Context, + private val serverURI: URI, + private val credentials: Credentials? = null, + private val cardDavURI: URI? = null, + private val blockOnUnauthorizedException: Boolean = false +) : AutoCloseable { enum class Service(val wellKnownName: String) { CALDAV("caldav"), @@ -47,8 +57,9 @@ class DavResourceFinder( override fun toString() = wellKnownName } - val log: Logger = Logger.getLogger("davx5.DavResourceFinder") + val log: Logger = Logger.getLogger("accountManager.DavResourceFinder") private val logBuffer = StringHandler() + init { log.level = Level.FINEST log.addHandler(logBuffer) @@ -57,7 +68,7 @@ class DavResourceFinder( var encountered401 = false private val httpClient: HttpClient = HttpClient.Builder(context, logger = log).let { - loginModel.credentials?.let { credentials -> + credentials?.let { credentials -> it.addAuthentication(null, credentials) } it.setForeground(true) @@ -91,65 +102,73 @@ class DavResourceFinder( log.log(Level.INFO, "CalDAV service detection failed", e) processException(e) } - } catch(e: Exception) { + } catch (e: Exception) { // we have been interrupted; reset results so that an error message will be shown cardDavConfig = null calDavConfig = null } return Configuration( - cardDavConfig, calDavConfig, - encountered401, - logBuffer.toString() + cardDavConfig, calDavConfig, + encountered401, + logBuffer.toString() ) } private fun findInitialConfiguration(service: Service): Configuration.ServiceInfo? { // user-given base URI (either mailto: URI or http(s):// URL) - var baseURI = loginModel.baseURI!! - if (loginModel.cardDavURI != null && service == Service.CARDDAV) { - baseURI = loginModel.cardDavURI!! + var baseURI = serverURI + if (cardDavURI != null && service == Service.CARDDAV) { + baseURI = cardDavURI } // domain for service discovery var discoveryFQDN: String? = null - // put discovered information here + // discovered information goes into this config val config = Configuration.ServiceInfo() + + // Start discovering log.info("Finding initial ${service.wellKnownName} service configuration") + when (baseURI.scheme.lowercase()) { + "http", "https" -> + baseURI.toHttpUrlOrNull()?.let { baseURL -> + // remember domain for service discovery + if (baseURL.scheme.equals("https", true)) + // service discovery will only be tried for https URLs, because only secure service discovery is implemented + discoveryFQDN = baseURL.host + + // Actual discovery process + checkBaseURL(baseURL, service, config) + + // If principal was not found already, try well known URI + if (config.principal == null) + try { + config.principal = getCurrentUserPrincipal( + baseURL.resolve("/.well-known/" + service.wellKnownName)!!, + service + ) + } catch (e: Exception) { + log.log(Level.FINE, "Well-known URL detection failed", e) + processException(e) + } + } - if (baseURI.scheme.equals("http", true) || baseURI.scheme.equals("https", true)) { - 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 - - checkUserGivenURL(baseURL, service, config) - - if (config.principal == null) - try { - config.principal = getCurrentUserPrincipal(baseURL.resolve("/.well-known/" + service.wellKnownName)!!, service) - } catch(e: Exception) { - log.log(Level.FINE, "Well-known URL detection failed", e) - processException(e) - } + "mailto" -> { + val mailbox = baseURI.schemeSpecificPart + val posAt = mailbox.lastIndexOf("@") + if (posAt != -1) + discoveryFQDN = mailbox.substring(posAt + 1) } - } else if (baseURI.scheme.equals("mailto", true)) { - val mailbox = baseURI.schemeSpecificPart - - val posAt = mailbox.lastIndexOf("@") - if (posAt != -1) - discoveryFQDN = mailbox.substring(posAt + 1) } - // Step 2: If user-given URL didn't reveal a principal, search for it: SERVICE DISCOVERY + // Second try: If user-given URL didn't reveal a principal, search for it (SERVICE DISCOVERY) if (config.principal == null) - discoveryFQDN?.let { - log.info("No principal found at user-given URL, trying to discover") + discoveryFQDN?.let { fqdn -> + log.info("No principal found at user-given URL, trying to discover for domain $fqdn") try { - config.principal = discoverPrincipalUrl(it, service) - } catch(e: Exception) { + config.principal = discoverPrincipalUrl(fqdn, service) + } catch (e: Exception) { log.log(Level.FINE, "$service service discovery failed", e) processException(e) } @@ -157,44 +176,69 @@ class DavResourceFinder( // detect email address if (service == Service.CALDAV) - config.principal?.let { - config.emails.addAll(queryEmailAddress(it)) + config.principal?.let { principal -> + config.emails.addAll(queryEmailAddress(principal)) } // return config or null if config doesn't contain useful information - val serviceAvailable = config.principal != null || config.homeSets.isNotEmpty() || config.collections.isNotEmpty() + val serviceAvailable = + config.principal != null || config.homeSets.isNotEmpty() || config.collections.isNotEmpty() return if (serviceAvailable) config else null } - private fun checkUserGivenURL(baseURL: HttpUrl, service: Service, config: Configuration.ServiceInfo) { + /** + * Entry point of the actual discovery process. + * + * Queries the user-given URL (= base URL) to detect whether it contains a current-user-principal + * or whether it is a homeset or collection. + * + * @param baseURL base URL provided by the user + * @param service service to detect configuration for + * @param config found configuration will be written to this object + */ + private fun checkBaseURL( + baseURL: HttpUrl, + service: Service, + config: Configuration.ServiceInfo + ) { log.info("Checking user-given URL: $baseURL") - val davBase = DavResource(httpClient.okHttpClient, baseURL, loginModel.credentials?.authState?.accessToken, log) + val davBaseURL = + DavResource(httpClient.okHttpClient, baseURL, log) try { when (service) { Service.CARDDAV -> { - davBase.propfind(0, - ResourceType.NAME, DisplayName.NAME, AddressbookDescription.NAME, - AddressbookHomeSet.NAME, - CurrentUserPrincipal.NAME + davBaseURL.propfind( + 0, + ResourceType.NAME, DisplayName.NAME, AddressbookDescription.NAME, + AddressbookHomeSet.NAME, + CurrentUserPrincipal.NAME ) { response, _ -> - scanCardDavResponse(response, config) + scanResponse(ResourceType.ADDRESSBOOK, response, config) } } + Service.CALDAV -> { - davBase.propfind(1, - ResourceType.NAME, DisplayName.NAME, CalendarColor.NAME, CalendarDescription.NAME, CalendarTimezone.NAME, CurrentUserPrivilegeSet.NAME, SupportedCalendarComponentSet.NAME, - CalendarHomeSet.NAME, - CurrentUserPrincipal.NAME + davBaseURL.propfind( + 1, + ResourceType.NAME, + DisplayName.NAME, + CalendarColor.NAME, + CalendarDescription.NAME, + CalendarTimezone.NAME, + CurrentUserPrivilegeSet.NAME, + SupportedCalendarComponentSet.NAME, + CalendarHomeSet.NAME, + CurrentUserPrincipal.NAME ) { response, _ -> - scanCalDavResponse(response, config) + scanResponse(ResourceType.CALENDAR, response, config) } } } - } catch(e: Exception) { + } catch (e: Exception) { log.log(Level.FINE, "PROPFIND/OPTIONS on user-given URL failed", e) processException(e) } @@ -208,7 +252,11 @@ class DavResourceFinder( fun queryEmailAddress(principal: HttpUrl): List { val mailboxes = LinkedList() try { - DavResource(httpClient.okHttpClient, principal, loginModel.credentials?.authState?.accessToken, log).propfind(0, CalendarUserAddressSet.NAME) { response, _ -> + DavResource( + httpClient.okHttpClient, + principal, + log + ).propfind(0, CalendarUserAddressSet.NAME) { response, _ -> response[CalendarUserAddressSet::class.java]?.let { addressSet -> for (href in addressSet.hrefs) try { @@ -217,12 +265,12 @@ class DavResourceFinder( log.info("myenail: ${uri.schemeSpecificPart}") mailboxes.add(uri.schemeSpecificPart) } - } catch(e: URISyntaxException) { + } catch (e: URISyntaxException) { log.log(Level.WARNING, "Couldn't parse user address", e) } } } - } catch(e: Exception) { + } catch (e: Exception) { log.log(Level.WARNING, "Couldn't query user email address", e) processException(e) } @@ -230,106 +278,102 @@ class DavResourceFinder( } /** - * If [dav] references an address book, an address book home set, and/or a princiapl, - * it will added to, config.collections, config.homesets and/or config.principal. - * URLs will be stored with trailing "/". + * Depending on [resourceType] (CalDAV or CardDAV), this method checks whether [davResponse] references + * - an address book or calendar (actual resource), and/or + * - an "address book home set" or a "calendar home set", and/or + * - whether it's a principal. + * + * Respectively, this method will add the response to [config.collections], [config.homesets] and/or [config.principal]. + * Collection URLs will be stored with trailing "/". * - * @param dav response whose properties are evaluated - * @param config structure where the results are stored into + * @param resourceType type of service to search for in the response + * @param davResponse response whose properties are evaluated + * @param config structure storing the references */ - fun scanCardDavResponse(dav: Response, config: Configuration.ServiceInfo) { + fun scanResponse( + resourceType: Property.Name, + davResponse: Response, + config: Configuration.ServiceInfo + ) { var principal: HttpUrl? = null - // check for current-user-principal - dav[CurrentUserPrincipal::class.java]?.href?.let { - principal = dav.requestedUrl.resolve(it) - } - - // Is it an address book and/or principal? - dav[ResourceType::class.java]?.let { - if (it.types.contains(ResourceType.ADDRESSBOOK)) { - val info = Collection.fromDavResponse(dav)!! - log.info("Found address book at ${info.url}") - config.collections[info.url] = info + // Type mapping + val homeSetClass: Class + val serviceType: Service + when (resourceType) { + ResourceType.ADDRESSBOOK -> { + homeSetClass = AddressbookHomeSet::class.java + serviceType = Service.CARDDAV } - if (it.types.contains(ResourceType.PRINCIPAL)) - principal = dav.href - } - - // Is it an addressbook-home-set? - dav[AddressbookHomeSet::class.java]?.let { homeSet -> - for (href in homeSet.hrefs) { - dav.requestedUrl.resolve(href)?.let { - val location = UrlUtils.withTrailingSlash(it) - log.info("Found address book home-set at $location") - config.homeSets += location - } + ResourceType.CALENDAR -> { + homeSetClass = CalendarHomeSet::class.java + serviceType = Service.CALDAV } - } - principal?.let { - if (providesService(it, Service.CARDDAV)) - config.principal = principal + else -> throw IllegalArgumentException() } - } - - /** - * If [dav] references an address book, an address book home set, and/or a princiapl, - * it will added to, config.collections, config.homesets and/or config.principal. - * URLs will be stored with trailing "/". - * - * @param dav response whose properties are evaluated - * @param config structure where the results are stored into - */ - private fun scanCalDavResponse(dav: Response, config: Configuration.ServiceInfo) { - var principal: HttpUrl? = null // check for current-user-principal - dav[CurrentUserPrincipal::class.java]?.href?.let { - principal = dav.requestedUrl.resolve(it) + davResponse[CurrentUserPrincipal::class.java]?.href?.let { currentUserPrincipal -> + principal = davResponse.requestedUrl.resolve(currentUserPrincipal) } - // Is it a calendar and/or principal? - dav[ResourceType::class.java]?.let { - if (it.types.contains(ResourceType.CALENDAR)) { - val info = Collection.fromDavResponse(dav)!! - log.info("Found calendar at ${info.url}") - config.collections[info.url] = info - } + davResponse[ResourceType::class.java]?.let { + // Is it a calendar or an address book, ... + if (it.types.contains(resourceType)) + Collection.fromDavResponse(davResponse)?.let { info -> + log.info("Found resource of type $resourceType at ${info.url}") + config.collections[info.url] = info + } + // ... and/or a principal? if (it.types.contains(ResourceType.PRINCIPAL)) - principal = dav.href + principal = davResponse.href } - // Is it an calendar-home-set? - dav[CalendarHomeSet::class.java]?.let { homeSet -> + // Is it an addressbook-home-set or calendar-home-set? + davResponse[homeSetClass]?.let { homeSet -> for (href in homeSet.hrefs) { - dav.requestedUrl.resolve(href)?.let { + davResponse.requestedUrl.resolve(href)?.let { val location = UrlUtils.withTrailingSlash(it) - log.info("Found calendar home-set at $location") + log.info("Found home-set of type $resourceType at $location") config.homeSets += location } } } + // Is there a principal too? principal?.let { - if (providesService(it, Service.CALDAV)) + if (providesService(it, serviceType)) config.principal = principal + else + log.warning("Principal $principal doesn't provide $serviceType service") } } - - @Throws(IOException::class) + /** + * Sends an OPTIONS request to determine whether a URL provides a given service. + * + * @param url URL to check; often a principal URL + * @param service service to check for + * + * @return whether the URL provides the given service + */ fun providesService(url: HttpUrl, service: Service): Boolean { var provided = false try { - DavResource(httpClient.okHttpClient, url, loginModel.credentials?.authState?.accessToken, log).options { capabilities, _ -> + DavResource( + httpClient.okHttpClient, + url, + log + ).options { capabilities, _ -> if ((service == Service.CARDDAV && capabilities.contains("addressbook")) || - (service == Service.CALDAV && capabilities.contains("calendar-access"))) + (service == Service.CALDAV && capabilities.contains("calendar-access")) + ) provided = true } - } catch(e: Exception) { + } catch (e: Exception) { log.log(Level.SEVERE, "Couldn't detect services on $url", e) if (e !is HttpException && e !is DavException) throw e @@ -341,11 +385,11 @@ class DavResourceFinder( /** * Try to find the principal URL by performing service discovery on a given domain name. * Only secure services (caldavs, carddavs) will be discovered! + * * @param domain domain name, e.g. "icloud.com" * @param service service to discover (CALDAV or CARDDAV) * @return principal URL, or null if none found */ - @Throws(IOException::class, HttpException::class, DavException::class) fun discoverPrincipalUrl(domain: String, service: Service): HttpUrl? { val scheme: String val fqdn: String @@ -378,24 +422,24 @@ class DavResourceFinder( DavUtils.prepareLookup(context, txtLookup) paths.addAll(DavUtils.pathsFromTXTRecords(txtLookup.run())) - // if there's TXT record and if it it's wrong, try well-known + // in case there's a TXT record, but it's wrong, try well-known paths.add("/.well-known/" + service.wellKnownName) - // if this fails, too, try "/" + // if this fails too, try "/" paths.add("/") for (path in paths) try { val initialContextPath = HttpUrl.Builder() - .scheme(scheme) - .host(fqdn).port(port) - .encodedPath(path) - .build() + .scheme(scheme) + .host(fqdn).port(port) + .encodedPath(path) + .build() log.info("Trying to determine principal from initial context path=$initialContextPath") val principal = getCurrentUserPrincipal(initialContextPath, service) principal?.let { return it } - } catch(e: Exception) { + } catch (e: Exception) { log.log(Level.WARNING, "No resource found", e) processException(e) } @@ -409,17 +453,20 @@ class DavResourceFinder( * @param service required service (may be null, in which case no service check is done) * @return current-user-principal URL that provides required service, or null if none */ - @Throws(IOException::class, HttpException::class, DavException::class) fun getCurrentUserPrincipal(url: HttpUrl, service: Service?): HttpUrl? { var principal: HttpUrl? = null - DavResource(httpClient.okHttpClient, url, loginModel.credentials?.authState?.accessToken, log).propfind(0, CurrentUserPrincipal.NAME) { response, _ -> + DavResource( + httpClient.okHttpClient, + url, + 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") // service check if (service != null && !providesService(it, service)) - log.info("$it doesn't provide required $service service") + log.warning("Principal $it doesn't provide $service service") else principal = it } @@ -429,7 +476,7 @@ class DavResourceFinder( } /** - * Processes a thrown exception likes this: + * Processes a thrown exception like 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. @@ -457,11 +504,11 @@ class DavResourceFinder( ) { data class ServiceInfo( - var principal: HttpUrl? = null, - val homeSets: MutableSet = HashSet(), - val collections: MutableMap = HashMap(), + var principal: HttpUrl? = null, + val homeSets: MutableSet = HashSet(), + val collections: MutableMap = HashMap(), - val emails: MutableList = LinkedList() + val emails: MutableList = LinkedList() ) override fun toString(): String { diff --git a/app/src/main/java/at/bitfire/davdroid/servicedetection/RefreshCollectionsWorker.kt b/app/src/main/java/at/bitfire/davdroid/servicedetection/RefreshCollectionsWorker.kt index 613ec99c8830826df520397de8cca5eac61c50d9..a1edacb26d2f11affe6343c2be5389b5db17a185 100644 --- a/app/src/main/java/at/bitfire/davdroid/servicedetection/RefreshCollectionsWorker.kt +++ b/app/src/main/java/at/bitfire/davdroid/servicedetection/RefreshCollectionsWorker.kt @@ -7,28 +7,61 @@ package at.bitfire.davdroid.servicedetection import android.accounts.Account import android.app.PendingIntent import android.content.Context +import android.content.Intent import androidx.concurrent.futures.CallbackToFutureAdapter import androidx.core.app.NotificationCompat import androidx.core.app.NotificationManagerCompat import androidx.hilt.work.HiltWorker -import androidx.lifecycle.Transformations -import androidx.work.* +import androidx.lifecycle.map +import androidx.work.Data +import androidx.work.ExistingWorkPolicy +import androidx.work.ForegroundInfo +import androidx.work.OneTimeWorkRequestBuilder +import androidx.work.OutOfQuotaPolicy +import androidx.work.WorkInfo +import androidx.work.WorkManager +import androidx.work.Worker +import androidx.work.WorkerParameters import at.bitfire.dav4jvm.DavResource +import at.bitfire.dav4jvm.MultiResponseCallback +import at.bitfire.dav4jvm.Property 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.HttpClient +import at.bitfire.dav4jvm.exception.UnauthorizedException +import at.bitfire.dav4jvm.property.AddressbookDescription +import at.bitfire.dav4jvm.property.AddressbookHomeSet +import at.bitfire.dav4jvm.property.CalendarColor +import at.bitfire.dav4jvm.property.CalendarDescription +import at.bitfire.dav4jvm.property.CalendarHomeSet +import at.bitfire.dav4jvm.property.CalendarProxyReadFor +import at.bitfire.dav4jvm.property.CalendarProxyWriteFor +import at.bitfire.dav4jvm.property.CurrentUserPrivilegeSet +import at.bitfire.dav4jvm.property.DisplayName +import at.bitfire.dav4jvm.property.GroupMembership +import at.bitfire.dav4jvm.property.HrefListProperty +import at.bitfire.dav4jvm.property.Owner +import at.bitfire.dav4jvm.property.ResourceType +import at.bitfire.dav4jvm.property.Source +import at.bitfire.dav4jvm.property.SupportedAddressData +import at.bitfire.dav4jvm.property.SupportedCalendarComponentSet import at.bitfire.davdroid.InvalidAccountException import at.bitfire.davdroid.R -import at.bitfire.davdroid.db.* +import at.bitfire.davdroid.db.AppDatabase import at.bitfire.davdroid.db.Collection +import at.bitfire.davdroid.db.HomeSet +import at.bitfire.davdroid.db.Principal +import at.bitfire.davdroid.db.Service import at.bitfire.davdroid.log.Logger +import at.bitfire.davdroid.network.HttpClient +import at.bitfire.davdroid.servicedetection.RefreshCollectionsWorker.Companion.ARG_SERVICE_ID 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 at.bitfire.davdroid.ui.NotificationUtils.notifyIfPossible +import at.bitfire.davdroid.ui.account.SettingsActivity import com.google.common.util.concurrent.ListenableFuture import dagger.assisted.Assisted import dagger.assisted.AssistedInject @@ -36,40 +69,73 @@ import net.openid.appauth.AuthState import okhttp3.HttpUrl import okhttp3.OkHttpClient import java.util.logging.Level -import javax.inject.Inject import kotlin.collections.* +/** + * Refreshes list of home sets and their respective collections of a service type (CardDAV or CalDAV). + * Called from UI, when user wants to refresh all collections of a service ([at.bitfire.davdroid.ui.account.CollectionsFragment]). + * + * Input data: + * + * - [ARG_SERVICE_ID]: service ID + * + * It queries all existing homesets and/or collections and then: + * - updates resources with found properties (overwrites without comparing) + * - adds resources if new ones are detected + * - removes resources if not found 40x (delete locally) + * + * @throws IllegalArgumentException when there's no service with the given service ID + */ @HiltWorker class RefreshCollectionsWorker @AssistedInject constructor( @Assisted appContext: Context, - @Assisted workerParams: WorkerParameters + @Assisted workerParams: WorkerParameters, + var db: AppDatabase, + var settings: SettingsManager ): Worker(appContext, workerParams) { companion object { const val ARG_SERVICE_ID = "serviceId" - const val REFRESH_COLLECTION_WORKER_TAG = "refreshCollectionWorker" + const val REFRESH_COLLECTIONS_WORKER_TAG = "refreshCollectionsWorker" + // Collection properties to ask for in a propfind request to the Cal- or CardDAV server val DAV_COLLECTION_PROPERTIES = arrayOf( - ResourceType.NAME, - CurrentUserPrivilegeSet.NAME, - DisplayName.NAME, - Owner.NAME, - AddressbookDescription.NAME, SupportedAddressData.NAME, - CalendarDescription.NAME, CalendarColor.NAME, SupportedCalendarComponentSet.NAME, - Source.NAME + ResourceType.NAME, + CurrentUserPrivilegeSet.NAME, + DisplayName.NAME, + Owner.NAME, + AddressbookDescription.NAME, SupportedAddressData.NAME, + CalendarDescription.NAME, CalendarColor.NAME, SupportedCalendarComponentSet.NAME, + Source.NAME ) - fun workerName(serviceId: Long): String { - return "$REFRESH_COLLECTION_WORKER_TAG-$serviceId" - } + // Principal properties to ask the server + val DAV_PRINCIPAL_PROPERTIES = arrayOf( + DisplayName.NAME, + ResourceType.NAME + ) + + /** + * Uniquely identifies a refresh worker. Useful for stopping work, or querying its state. + * + * @param serviceId what service (CalDAV/CardDAV) the worker is running for + */ + fun workerName(serviceId: Long): String = "$REFRESH_COLLECTIONS_WORKER_TAG-$serviceId" /** - * Requests immediate refresh of a given service + * Requests immediate refresh of a given service. If not running already. this will enqueue + * a [RefreshCollectionsWorker]. * * @param serviceId serviceId which is to be refreshed + * @return workerName name of the worker started + * + * @throws IllegalArgumentException when there's no service with this ID */ - fun refreshCollections(context: Context, serviceId: Long) { + fun refreshCollections(context: Context, serviceId: Long): String { + if (serviceId == -1L) + throw IllegalArgumentException("Service with ID \"$serviceId\" does not exist") + val arguments = Data.Builder() .putLong(ARG_SERVICE_ID, serviceId) .build() @@ -80,26 +146,93 @@ class RefreshCollectionsWorker @AssistedInject constructor( WorkManager.getInstance(context).enqueueUniqueWork( workerName(serviceId), - ExistingWorkPolicy.KEEP, // if refresh is already running, just continue + ExistingWorkPolicy.KEEP, // if refresh is already running, just continue that one workRequest ) + return workerName(serviceId) } /** * Will tell whether a refresh worker with given service id and state exists * - * @param serviceId the service which the worker(s) belong to - * @param workState state of worker to match - * @return boolean true if worker with matching state was found + * @param workerName name of worker to find + * @param workState state of worker to match + * @return boolean true if worker with matching state was found */ - fun isWorkerInState(context: Context, serviceId: Long, workState: WorkInfo.State) = Transformations.map( - WorkManager.getInstance(context).getWorkInfosForUniqueWorkLiveData(workerName(serviceId)) - ) { workInfoList -> workInfoList.any { workInfo -> workInfo.state == workState } } + fun isWorkerInState(context: Context, workerName: String, workState: WorkInfo.State) = + WorkManager.getInstance(context).getWorkInfosForUniqueWorkLiveData(workerName).map { + workInfoList -> workInfoList.any { workInfo -> workInfo.state == workState } + } } - @Inject lateinit var db: AppDatabase - @Inject lateinit var settings: SettingsManager + val serviceId: Long = inputData.getLong(ARG_SERVICE_ID, -1) + val service = db.serviceDao().get(serviceId) ?: throw IllegalArgumentException("Service #$serviceId not found") + val account = Account(service.accountName, service.accountType) + + override fun doWork(): Result { + try { + Logger.log.info("Refreshing ${service.type} collections of service #$service") + + // cancel previous notification + NotificationManagerCompat.from(applicationContext) + .cancel(serviceId.toString(), NotificationUtils.NOTIFY_REFRESH_COLLECTIONS) + + // create authenticating OkHttpClient (credentials taken from account settings) + HttpClient.Builder(applicationContext, AccountSettings(applicationContext, account)) + .setForeground(true) + .build().use { client -> + val httpClient = client.okHttpClient + val refresher = Refresher(db, service, settings, httpClient) + + // refresh home set list (from principal url) + service.principal?.let { principalUrl -> + Logger.log.fine("Querying principal $principalUrl for home sets") + refresher.queryHomeSets(principalUrl) + } + + // refresh home sets and their member collections + refresher.refreshHomesetsAndTheirCollections() + + // also refresh collections without a home set + refresher.refreshHomelessCollections() + + // Lastly, refresh the principals (collection owners) + refresher.refreshPrincipals() + } + + } catch(e: InvalidAccountException) { + Logger.log.log(Level.SEVERE, "Invalid account", e) + return Result.failure() + } catch (e: UnauthorizedException) { + Logger.log.log(Level.SEVERE, "Not authorized (anymore)", e) + // notify that we need to re-authenticate in the account settings + val settingsIntent = Intent(applicationContext, SettingsActivity::class.java) + .putExtra(SettingsActivity.EXTRA_ACCOUNT, account) + notifyRefreshError( + applicationContext.getString(R.string.sync_error_authentication_failed), + settingsIntent + ) + return Result.failure() + } catch(e: Exception) { + Logger.log.log(Level.SEVERE, "Couldn't refresh collection list", e) + + val debugIntent = DebugInfoActivity.IntentBuilder(applicationContext) + .withCause(e) + .withAccount(account) + .build() + notifyRefreshError( + applicationContext.getString(R.string.refresh_collections_worker_refresh_couldnt_refresh), + debugIntent + ) + return Result.failure() + } + + + + // Success + return Result.success() + } override fun getForegroundInfoAsync(): ListenableFuture = CallbackToFutureAdapter.getFuture { completer -> @@ -115,27 +248,39 @@ class RefreshCollectionsWorker @AssistedInject constructor( completer.set(ForegroundInfo(NotificationUtils.NOTIFY_SYNC_EXPEDITED, notification)) } - override fun doWork(): Result { - val serviceId = inputData.getLong(ARG_SERVICE_ID, -1) - - if (serviceId == -1L) - return Result.failure() - - 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, service.accountType) + private fun notifyRefreshError(contentText: String, contentIntent: Intent) { + val notify = NotificationUtils.newBuilder(applicationContext, NotificationUtils.CHANNEL_GENERAL) + .setSmallIcon(R.drawable.ic_sync_problem_notify) + .setContentTitle(applicationContext.getString(R.string.refresh_collections_worker_refresh_failed)) + .setContentText(contentText) + .setContentIntent(PendingIntent.getActivity(applicationContext, 0, contentIntent, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE)) + .setSubText(account.name) + .setCategory(NotificationCompat.CATEGORY_ERROR) + .build() + NotificationManagerCompat.from(applicationContext) + .notifyIfPossible(serviceId.toString(), NotificationUtils.NOTIFY_REFRESH_COLLECTIONS, notify) + } - val homeSets = homeSetDao.getByService(serviceId).associateBy { it.url }.toMutableMap() - val collections = collectionDao.getByService(serviceId).associateBy { it.url }.toMutableMap() + /** + * Contains the methods, which do the actual refreshing work. Collected here for testability + */ + class Refresher( + val db: AppDatabase, + val service: Service, + val settings: SettingsManager, + val httpClient: OkHttpClient + ) { /** - * Checks if the given URL defines home sets and adds them to the home set list. + * Checks if the given URL defines home sets and adds them to given home set list. + * + * @param principalUrl Principal URL to query + * @param forPersonalHomeset Whether this is the first call of this recursive method. + * Indicates that these found home sets are considered "personal", as they belong to the + * current-user-principal. * - * @param personal Whether this is the "outer" call of the recursion. + * Note: This is not be be confused with the DAV:owner attribute. Home sets can be owned by + * other principals and still be considered "personal" (belonging to the current-user-principal). * * *true* = found home sets belong to the current-user-principal; recurse if * calendar proxies or group memberships are found @@ -146,285 +291,270 @@ class RefreshCollectionsWorker @AssistedInject constructor( * @throws HttpException * @throws at.bitfire.dav4jvm.exception.DavException */ - fun queryHomeSets(client: OkHttpClient, url: HttpUrl, accessToken: String?, personal: Boolean = true) { + internal fun queryHomeSets(principalUrl: HttpUrl, forPersonalHomeset: Boolean = true) { val related = mutableSetOf() - fun findRelated(root: HttpUrl, dav: Response) { - // refresh home sets: calendar-proxy-read/write-for - dav[CalendarProxyReadFor::class.java]?.let { - for (href in it.hrefs) { - Logger.log.fine("Principal is a read-only proxy for $href, checking for home sets") - root.resolve(href)?.let { proxyReadFor -> - related += proxyReadFor - } - } - } - dav[CalendarProxyWriteFor::class.java]?.let { - for (href in it.hrefs) { - Logger.log.fine("Principal is a read/write proxy for $href, checking for home sets") - root.resolve(href)?.let { proxyWriteFor -> - related += proxyWriteFor - } - } + // Define homeset class and properties to look for + val homeSetClass: Class + val properties: Array + when (service.type) { + Service.TYPE_CARDDAV -> { + homeSetClass = AddressbookHomeSet::class.java + properties = arrayOf(DisplayName.NAME, AddressbookHomeSet.NAME, GroupMembership.NAME) } - - // refresh home sets: direct group memberships - dav[GroupMembership::class.java]?.let { - for (href in it.hrefs) { - Logger.log.fine("Principal is member of group $href, checking for home sets") - root.resolve(href)?.let { groupMembership -> - related += groupMembership - } - } + Service.TYPE_CALDAV -> { + homeSetClass = CalendarHomeSet::class.java + properties = arrayOf(DisplayName.NAME, CalendarHomeSet.NAME, CalendarProxyReadFor.NAME, CalendarProxyWriteFor.NAME, GroupMembership.NAME) } + else -> throw IllegalArgumentException() } - val dav = DavResource(client, url, accessToken) - when (service.type) { - Service.TYPE_CARDDAV -> - try { - dav.propfind(0, DisplayName.NAME, AddressbookHomeSet.NAME, GroupMembership.NAME) { response, _ -> - response[AddressbookHomeSet::class.java]?.let { homeSet -> - for (href in homeSet.hrefs) - dav.location.resolve(href)?.let { - val foundUrl = UrlUtils.withTrailingSlash(it) - homeSets[foundUrl] = HomeSet(0, service.id, personal, foundUrl) - } + val dav = DavResource(httpClient, principalUrl) + try { + // Query for the given service with properties + dav.propfind(0, *properties) { davResponse, _ -> + + // Check we got back the right service and save it + davResponse[homeSetClass]?.let { homeSet -> + for (href in homeSet.hrefs) + dav.location.resolve(href)?.let { + val foundUrl = UrlUtils.withTrailingSlash(it) + db.homeSetDao().insertOrUpdateByUrl( + HomeSet(0, service.id, forPersonalHomeset, foundUrl) + ) } - - if (personal) - findRelated(dav.location, response) - } - } catch (e: HttpException) { - if (e.code/100 == 4) - Logger.log.log(Level.INFO, "Ignoring Client Error 4xx while looking for addressbook home sets", e) - else - throw e } - Service.TYPE_CALDAV -> { - try { - dav.propfind(0, DisplayName.NAME, CalendarHomeSet.NAME, CalendarProxyReadFor.NAME, CalendarProxyWriteFor.NAME, GroupMembership.NAME) { response, _ -> - response[CalendarHomeSet::class.java]?.let { homeSet -> - for (href in homeSet.hrefs) - dav.location.resolve(href)?.let { - val foundUrl = UrlUtils.withTrailingSlash(it) - homeSets[foundUrl] = HomeSet(0, service.id, personal, foundUrl) + + // If personal (outer call of recursion), find/refresh related resources + if (forPersonalHomeset) { + val relatedResourcesTypes = mapOf( + CalendarProxyReadFor::class.java to "read-only proxy for", // calendar-proxy-read-for + CalendarProxyWriteFor::class.java to "read/write proxy for ", // calendar-proxy-read/write-for + GroupMembership::class.java to "member of group") // direct group memberships + + for ((type, logString) in relatedResourcesTypes) { + davResponse[type]?.let { + for (href in it.hrefs) { + Logger.log.fine("Principal is a $logString for $href, checking for home sets") + dav.location.resolve(href)?.let { url -> + related += url } + } } - - if (personal) - findRelated(dav.location, response) } - } catch (e: HttpException) { - if (e.code/100 == 4) - Logger.log.log(Level.INFO, "Ignoring Client Error 4xx while looking for calendar home sets", e) - else - throw e } } + } catch (e: HttpException) { + if (e.code/100 == 4) + Logger.log.log(Level.INFO, "Ignoring Client Error 4xx while looking for ${service.type} home sets", e) + else + throw e } // query related homesets (those that do not belong to the current-user-principal) for (resource in related) - queryHomeSets(client, resource, accessToken, false) + queryHomeSets(resource, false) } - 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 }) - } + /** + * Refreshes homesets and their collections. + * + * Each stored homeset URL is queried (propfind) and it's collections ([MultiResponseCallback]) either saved, updated + * or marked as homeless - in case a collection was removed from its homeset. + * + * If a homeset URL in fact points to a collection directly, the collection will be saved with this URL, + * and a null value for it's homeset. Refreshing of collections without homesets is then handled by [refreshHomelessCollections]. + */ + internal fun refreshHomesetsAndTheirCollections() { + val homesets = db.homeSetDao().getByService(service.id).associateBy { it.url }.toMutableMap() + for((homeSetUrl, localHomeset) in homesets) { + Logger.log.fine("Listing home set $homeSetUrl") + + // To find removed collections in this homeset: create a queue from existing collections and remove every collection that + // is successfully rediscovered. If there are collections left, after processing is done, these are marked homeless. + val localHomesetCollections = db.collectionDao() + .getByServiceAndHomeset(service.id, localHomeset.id) + .associateBy { it.url } + .toMutableMap() + + try { + DavResource(httpClient, homeSetUrl).propfind(1, *DAV_COLLECTION_PROPERTIES) { response, relation -> + // Note: This callback may be called multiple times ([MultiResponseCallback]) + if (!response.isSuccess()) + return@propfind + + if (relation == Response.HrefRelation.SELF) { + // this response is about the homeset itself + localHomeset.displayName = response[DisplayName::class.java]?.displayName + localHomeset.privBind = response[CurrentUserPrivilegeSet::class.java]?.mayBind ?: true + db.homeSetDao().insertOrUpdateByUrl(localHomeset) + } - 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 - } - } + // in any case, check whether the response is about a usable collection + val collection = Collection.fromDavResponse(response) ?: return@propfind - try { - Logger.log.info("Refreshing ${service.type} collections of service #$service") + collection.serviceId = service.id + collection.homeSetId = localHomeset.id + collection.sync = shouldPreselect(collection, homesets.values) - // cancel previous notification - NotificationManagerCompat.from(applicationContext) - .cancel(serviceId.toString(), NotificationUtils.NOTIFY_REFRESH_COLLECTIONS) + // .. and save the principal url (collection owner) + response[Owner::class.java]?.href + ?.let { response.href.resolve(it) } + ?.let { principalUrl -> + val principal = Principal.fromServiceAndUrl(service, principalUrl) + val id = db.principalDao().insertOrUpdate(service.id, principal) + collection.ownerId = id + } - // create authenticating OkHttpClient (credentials taken from account settings) - HttpClient.Builder(applicationContext, AccountSettings(applicationContext, account)) - .setForeground(true) - .build().use { client -> - val httpClient = client.okHttpClient + Logger.log.log(Level.FINE, "Found collection", collection) - var accessToken : String? = null - service.authState?.let { - accessToken = AuthState.jsonDeserialize(it).accessToken - } + // save or update collection if usable (ignore it otherwise) + if (isUsableCollection(collection)) + db.collectionDao().insertOrUpdateByUrlAndRememberFlags(collection) - // refresh home set list (from principal) - service.principal?.let { principalUrl -> - Logger.log.fine("Querying principal $principalUrl for home sets") - queryHomeSets(httpClient, principalUrl, accessToken) + // Remove this collection from queue - because it was found in the home set + localHomesetCollections.remove(collection.url) } + } catch (e: HttpException) { + // delete home set locally if it was not accessible (40x) + if (e.code in arrayOf(403, 404, 410)) + db.homeSetDao().delete(localHomeset) + } - // now refresh homesets and their member collections - val itHomeSets = homeSets.iterator() - while (itHomeSets.hasNext()) { - val (homeSetUrl, homeSet) = itHomeSets.next() - Logger.log.fine("Listing home set $homeSetUrl") - - try { - DavResource(httpClient, homeSetUrl, accessToken).propfind(1, *DAV_COLLECTION_PROPERTIES) { response, relation -> - if (!response.isSuccess()) - return@propfind - - if (relation == Response.HrefRelation.SELF) { - // this response is about the homeset itself - homeSet.displayName = response[DisplayName::class.java]?.displayName - homeSet.privBind = response[CurrentUserPrivilegeSet::class.java]?.mayBind ?: true - } - - // in any case, check whether the response is about a useable collection - val info = Collection.fromDavResponse(response) ?: return@propfind - info.serviceId = serviceId - info.confirmed = true - Logger.log.log(Level.FINE, "Found collection", info) - - // remember usable collections - if ((service.type == Service.TYPE_CARDDAV && info.type == Collection.TYPE_ADDRESSBOOK) || - (service.type == Service.TYPE_CALDAV && arrayOf(Collection.TYPE_CALENDAR, Collection.TYPE_WEBCAL).contains(info.type))) { - // Ignore "recently contacted" accounts since it is buggy and causes error 501 - if (!info.url.toString().contains(AccountSettings.CONTACTS_APP_INTERACTION)) { - collections[response.href] = info - } - } - } - } catch(e: HttpException) { - if (e.code in arrayOf(403, 404, 410)) - // delete home set only if it was not accessible (40x) - itHomeSets.remove() - } - } + // Mark leftover (not rediscovered) collections from queue as homeless (remove association) + for ((_, homelessCollection) in localHomesetCollections) { + homelessCollection.homeSetId = null + db.collectionDao().insertOrUpdateByUrlAndRememberFlags(homelessCollection) + } - // 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 - - var accessToken : String? = null - service.authState?.let { - accessToken = AuthState.jsonDeserialize(it).accessToken - } + } + } - DavResource(httpClient, url, accessToken).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 - } + /** + * Refreshes collections which don't have a homeset. + * + * It queries each stored collection with a homeSetId of "null" and either updates or deletes (if inaccessible or unusable) them. + */ + internal fun refreshHomelessCollections() { + val homelessCollections = db.collectionDao().getByServiceAndHomeset(service.id, null).associateBy { it.url }.toMutableMap() + + for((url, localCollection) in homelessCollections) try { + DavResource(httpClient, url).propfind(0, *DAV_COLLECTION_PROPERTIES) { response, _ -> + if (!response.isSuccess()) { + db.collectionDao().delete(localCollection) + return@propfind } - // 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 + // Save or update the collection, if usable, otherwise delete it + Collection.fromDavResponse(response)?.let { collection -> + if (!isUsableCollection(collection)) + return@let + collection.serviceId = localCollection.serviceId // use same service ID as previous entry + + // .. and save the principal url (collection owner) + response[Owner::class.java]?.href + ?.let { response.href.resolve(it) } + ?.let { principalUrl -> + val principal = Principal.fromServiceAndUrl(service, principalUrl) + val principalId = db.principalDao().insertOrUpdate(service.id, principal) + collection.ownerId = principalId } - } + + db.collectionDao().insertOrUpdateByUrlAndRememberFlags(collection) + } ?: db.collectionDao().delete(localCollection) } + } catch (e: HttpException) { + // delete collection locally if it was not accessible (40x) + if (e.code in arrayOf(403, 404, 410)) + db.collectionDao().delete(localCollection) + else + throw e + } - db.runInTransaction { - saveHomesets() + } - // use refHomeSet (if available) to determine homeset ID - for (collection in collections.values) - collection.refHomeSet?.let { homeSet -> - collection.homeSetId = homeSet.id + /** + * Refreshes the principals (get their current display names). + * Also removes principals which do not own any collections anymore. + */ + internal fun refreshPrincipals() { + // Refresh principals (collection owner urls) + val principals = db.principalDao().getByService(service.id) + for (oldPrincipal in principals) { + val principalUrl = oldPrincipal.url + Logger.log.fine("Querying principal $principalUrl") + try { + DavResource(httpClient, principalUrl).propfind(0, *DAV_PRINCIPAL_PROPERTIES) { response, _ -> + if (!response.isSuccess()) + return@propfind + Principal.fromDavResponse(service.id, response)?.let { principal -> + Logger.log.fine("Got principal: $principal") + db.principalDao().insertOrUpdate(service.id, principal) + } } - saveCollections() + } catch (e: HttpException) { + Logger.log.info("Principal update failed with response code ${e.code}. principalUrl=$principalUrl") + } } - } catch(e: InvalidAccountException) { - Logger.log.log(Level.SEVERE, "Invalid account", e) - return Result.failure() - } catch(e: Exception) { - Logger.log.log(Level.SEVERE, "Couldn't refresh collection list", e) - - val debugIntent = DebugInfoActivity.IntentBuilder(applicationContext) - .withCause(e) - .withAccount(account) - .build() - val notify = NotificationUtils.newBuilder(applicationContext, NotificationUtils.CHANNEL_GENERAL) - .setSmallIcon(R.drawable.ic_sync_problem_notify) - .setContentTitle(applicationContext.getString(R.string.refresh_collections_worker_refresh_failed)) - .setContentText(applicationContext.getString(R.string.refresh_collections_worker_refresh_couldnt_refresh)) - .setContentIntent(PendingIntent.getActivity(applicationContext, 0, debugIntent, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE)) - .setSubText(account.name) - .setCategory(NotificationCompat.CATEGORY_ERROR) - .build() - NotificationManagerCompat.from(applicationContext) - .notify(serviceId.toString(), NotificationUtils.NOTIFY_REFRESH_COLLECTIONS, notify) - return Result.failure() + // Delete principals which don't own any collections + db.principalDao().getAllWithoutCollections().forEach {principal -> + db.principalDao().delete(principal) + } } - // Success - return Result.success() + /** + * Finds out whether given collection is usable, by checking that either + * - CalDAV/CardDAV: service and collection type match, or + * - WebCal: subscription source URL is not empty + */ + private fun isUsableCollection(collection: Collection) = + (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) + + /** + * Whether to preselect the given collection for synchronisation, according to the + * settings [Settings.PRESELECT_COLLECTIONS] (see there for allowed values) and + * [Settings.PRESELECT_COLLECTIONS_EXCLUDED]. + * + * A collection is considered _personal_ if it is found in one of the current-user-principal's home-sets. + * + * Before a collection is pre-selected, we check whether its URL matches the regexp in + * [Settings.PRESELECT_COLLECTIONS_EXCLUDED], in which case *false* is returned. + * + * @param collection the collection to check + * @param homesets list of home-sets (to check whether collection is in a personal home-set) + * @return *true* if the collection should be preselected for synchronization; *false* otherwise + */ + internal fun shouldPreselect(collection: Collection, homesets: Iterable): Boolean { + val shouldPreselect = settings.getIntOrNull(Settings.PRESELECT_COLLECTIONS) + + val excluded by lazy { + val excludedRegex = settings.getString(Settings.PRESELECT_COLLECTIONS_EXCLUDED) + if (!excludedRegex.isNullOrEmpty()) + Regex(excludedRegex).containsMatchIn(collection.url.toString()) + else + false + } + + return when (shouldPreselect) { + Settings.PRESELECT_COLLECTIONS_ALL -> + // preselect if collection url is not excluded + !excluded + + Settings.PRESELECT_COLLECTIONS_PERSONAL -> + // preselect if is personal (in a personal home-set), but not excluded + homesets + .filter { homeset -> homeset.personal } + .map { homeset -> homeset.id } + .contains(collection.homeSetId) + && !excluded + + else -> // don't preselect + false + } + } } -} \ 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 2221fea3e9808f5f204f1d5c9d54c94bfbdd9418..b3ebfe9d9eba69a25ee33e8adc7921fa7d358563 100644 --- a/app/src/main/java/at/bitfire/davdroid/settings/AccountSettings.kt +++ b/app/src/main/java/at/bitfire/davdroid/settings/AccountSettings.kt @@ -5,56 +5,28 @@ package at.bitfire.davdroid.settings import android.accounts.Account import android.accounts.AccountManager -import android.annotation.SuppressLint -import android.content.ContentResolver -import android.content.ContentUris -import android.content.ContentValues -import android.content.Context -import android.content.pm.PackageManager +import android.content.* 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 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.resource.LocalAddressBook -import at.bitfire.davdroid.resource.LocalTask -import at.bitfire.davdroid.resource.TaskUtils import at.bitfire.davdroid.syncadapter.AccountUtils +import at.bitfire.davdroid.syncadapter.PeriodicSyncWorker 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 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 /** @@ -82,7 +54,7 @@ class AccountSettings( companion object { - const val CURRENT_VERSION = 13 + const val CURRENT_VERSION = 14 const val KEY_SETTINGS_VERSION = "version" const val KEY_SYNC_INTERVAL_ADDRESSBOOKS = "sync_interval_addressbooks" @@ -141,19 +113,29 @@ class AccountSettings( const val CONTACTS_APP_INTERACTION = "z-app-generated--contactsinteraction--recent/" - fun initialUserData(credentials: Credentials?, baseURL: String?): Bundle { - val bundle = Bundle(2) + /** Static property to indicate whether AccountSettings migration is currently running. + * **Access must be `synchronized` with `AccountSettings::class.java`.** */ + @Volatile + var currentlyUpdating = false + + fun initialUserData(credentials: Credentials?, baseURL: String? = null): Bundle { + val bundle = Bundle() bundle.putString(KEY_SETTINGS_VERSION, CURRENT_VERSION.toString()) if (credentials != null) { if (credentials.userName != null) { bundle.putString(KEY_USERNAME, credentials.userName) - bundle.putString(KEY_EMAIL_ADDRESS, credentials.userName) bundle.putString("oc_display_name", credentials.userName) + + if (credentials.userName.contains("@")) { + 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()) } @@ -164,50 +146,18 @@ class AccountSettings( } if (!baseURL.isNullOrEmpty()) { - bundle.putString("oc_base_url", baseURL) + bundle.putString("oc_base_url", getOCBaseUrl(baseURL)) } return bundle } - fun repairSyncIntervals(context: Context) { - val addressBooksAuthority = context.getString(R.string.address_books_authority) - val taskAuthority = TaskUtils.currentProvider(context)?.authority - - for (account in AccountUtils.getMainAccounts(context)) - 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) - } - } + private fun getOCBaseUrl(baseURL: String): String { + if (baseURL.contains("remote.php")) { + return baseURL.split("/remote.php")[0] + } - 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) - } + return baseURL } } @@ -243,17 +193,25 @@ class AccountSettings( try { version = Integer.parseInt(versionStr) } catch (e: NumberFormatException) { + Logger.log.log(Level.SEVERE, "Invalid account version: $versionStr", e) } Logger.log.fine("Account ${account.name} has version $version, current version: $CURRENT_VERSION") - if (version < CURRENT_VERSION) - update(version) + if (version < CURRENT_VERSION) { + if (currentlyUpdating) { + Logger.log.severe("Redundant call: migration created AccountSettings(). This must never happen.") + throw IllegalStateException("Redundant call: migration created AccountSettings()") + } else { + currentlyUpdating = true + update(version) + currentlyUpdating = false + } + } } } // authentication settings - fun credentials(): Credentials { return if (accountManager.getUserData(account, KEY_AUTH_STATE).isNullOrEmpty()) { Credentials( @@ -286,96 +244,140 @@ class AccountSettings( accountManager.setUserData(account, KEY_CERTIFICATE_ALIAS, credentials.certificateAlias) accountManager.setUserData(account, KEY_CLIENT_SECRET, credentials.clientSecret) } - } // sync. settings + /** + * Gets the currently set sync interval for this account in seconds. + * + * @param authority authority to check (for instance: [CalendarContract.AUTHORITY]]) + * @return sync interval in seconds; *[SYNC_INTERVAL_MANUALLY]* if manual sync; *null* if not set + */ fun getSyncInterval(authority: String): Long? { if (ContentResolver.getIsSyncable(account, authority) <= 0) return null - return if (ContentResolver.getSyncAutomatically(account, authority)) - ContentResolver.getPeriodicSyncs(account, authority).firstOrNull()?.period ?: SYNC_INTERVAL_MANUALLY - else - SYNC_INTERVAL_MANUALLY + val key = when { + authority == context.getString(R.string.address_books_authority) -> + KEY_SYNC_INTERVAL_ADDRESSBOOKS + authority == CalendarContract.AUTHORITY -> + KEY_SYNC_INTERVAL_CALENDARS + authority == context.getString(R.string.task_authority) -> + KEY_SYNC_INTERVAL_TASKS + TaskProvider.ProviderName.values().any { it.authority == authority } -> + KEY_SYNC_INTERVAL_TASKS + else -> KEY_SYNC_INTERVAL_CALENDARS + } + return accountManager.getUserData(account, key)?.toLong() } + fun getTasksSyncInterval() = accountManager.getUserData(account, KEY_SYNC_INTERVAL_TASKS)?.toLong() + /** - * Sets the sync interval and enables/disables automatic sync for the given account and authority. + * Sets the sync interval and en- or disables periodic 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. + * This method blocks until a worker as been created and enqueued (sync active) or removed + * (sync disabled), 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 + * otherwise (≥ 15 min): automatic sync will be enabled and set to the given number of seconds * * @return whether the sync interval was successfully set + * @throws IllegalArgumentException when [seconds] is not [SYNC_INTERVAL_MANUALLY] but less than 15 min */ @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) + fun setSyncInterval(authority: String, argSeconds: Long): Boolean { + var seconds = argSeconds - /* 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 (seconds != SYNC_INTERVAL_MANUALLY && seconds < 60*15) { + seconds = 15*60 } - if (!success) - return false - - // store sync interval in account settings (used when the provider is switched) - when { + // Store (user defined) sync interval in account settings + val key = when { authority == context.getString(R.string.address_books_authority) -> - accountManager.setUserData(account, KEY_SYNC_INTERVAL_ADDRESSBOOKS, seconds.toString()) - + KEY_SYNC_INTERVAL_ADDRESSBOOKS authority == CalendarContract.AUTHORITY -> - accountManager.setUserData(account, KEY_SYNC_INTERVAL_CALENDARS, seconds.toString()) - + KEY_SYNC_INTERVAL_CALENDARS + authority == context.getString(R.string.task_authority) -> + KEY_SYNC_INTERVAL_TASKS TaskProvider.ProviderName.values().any { it.authority == authority } -> - accountManager.setUserData(account, KEY_SYNC_INTERVAL_TASKS, seconds.toString()) + KEY_SYNC_INTERVAL_TASKS + else -> + throw IllegalArgumentException("Sync interval not applicable to authority $authority") } + accountManager.setUserData(account, key, seconds.toString()) + + // update sync workers (needs already updated sync interval in AccountSettings) + updatePeriodicSyncWorker(authority, seconds, getSyncWifiOnly()) + + // Also enable/disable content change triggered syncs (SyncFramework automatic sync). + // We could make this a separate user adjustable setting later on. + setSyncOnContentChange(authority, seconds != SYNC_INTERVAL_MANUALLY) return true } - 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() + /** + * Enables/disables sync adapter automatic sync (content triggered sync) for the given + * account and authority. Does *not* call [ContentResolver.setIsSyncable]. + * + * We use the sync adapter framework only for the trigger, actual syncing is implemented + * with WorkManager. The trigger comes in through SyncAdapterService. + * + * This method blocks until the sync-on-content-change has been enabled or disabled, so it + * should not be called from the UI thread. + * + * @param enable *true* enables automatic sync; *false* disables it + * @param authority sync authority (like [CalendarContract.AUTHORITY]) + * @return whether the content triggered sync was enabled successfully + */ + @WorkerThread + fun setSyncOnContentChange(authority: String, enable: Boolean): Boolean { + // Enable content change triggers (sync adapter framework) + val setContentTrigger: () -> Boolean = + /* Ugly hack: because there is no callback for when the sync status/interval has been + updated, we need to make this call blocking. */ + if (enable) {{ + Logger.log.fine("Enabling content-triggered sync of $account/$authority") + ContentResolver.setSyncAutomatically(account, authority, true) // enables content triggers + // Remove unwanted sync framework periodic syncs created by setSyncAutomatically + for (periodicSync in ContentResolver.getPeriodicSyncs(account, authority)) + ContentResolver.removePeriodicSync(periodicSync.account, periodicSync.authority, periodicSync.extras) + /* return */ ContentResolver.getSyncAutomatically(account, authority) + }} else {{ + Logger.log.fine("Disabling content-triggered sync of $account/$authority") + ContentResolver.setSyncAutomatically(account, authority, false) // disables content triggers + /* return */ !ContentResolver.getSyncAutomatically(account, authority) + }} + + // try up to 10 times with 100 ms pause + for (idxTry in 0 until 10) { + if (setContentTrigger()) + // successfully set + return true + Thread.sleep(100) + } + return false + } 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 setSyncWiFiOnly(wiFiOnly: Boolean) { + accountManager.setUserData(account, KEY_WIFI_ONLY, if (wiFiOnly) "1" else null) + + // update sync workers (needs already updated wifi-only flag in AccountSettings) + for (authority in SyncUtils.syncAuthorities(context)) + updatePeriodicSyncWorker(authority, getSyncInterval(authority), wiFiOnly) + } fun getSyncWifiOnlySSIDs(): List? = if (getSyncWifiOnly()) { @@ -389,6 +391,30 @@ class AccountSettings( fun setSyncWifiOnlySSIDs(ssids: List?) = accountManager.setUserData(account, KEY_WIFI_ONLY_SSIDS, StringUtils.trimToNull(ssids?.joinToString(","))) + /** + * Updates the periodic sync worker of an authority according to + * + * - the sync interval and + * - the _Sync WiFi only_ flag. + * + * @param authority periodic sync workers for this authority will be updated + * @param seconds sync interval in seconds (`null` or [SYNC_INTERVAL_MANUALLY] disables periodic sync) + * @param wiFiOnly sync Wifi only flag + */ + fun updatePeriodicSyncWorker(authority: String, seconds: Long?, wiFiOnly: Boolean) { + try { + if (seconds == null || seconds == SYNC_INTERVAL_MANUALLY) { + Logger.log.fine("Disabling periodic sync of $account/$authority") + PeriodicSyncWorker.disable(context, account, authority) + } else { + Logger.log.fine("Setting periodic sync of $account/$authority to $seconds seconds (wifiOnly=$wiFiOnly)") + PeriodicSyncWorker.enable(context, account, authority, seconds, wiFiOnly) + }.result.get() // On operation (enable/disable) failure exception is thrown + } catch (e: Exception) { + Logger.log.log(Level.SEVERE, "Failed to set sync interval of $account/$authority to $seconds seconds", e) + } + } + // CalDAV settings @@ -498,8 +524,16 @@ class AccountSettings( val fromVersion = toVersion-1 Logger.log.info("Updating account ${account.name} from version $fromVersion to $toVersion") try { - val updateProc = this::class.java.getDeclaredMethod("update_${fromVersion}_$toVersion") - updateProc.invoke(this) + val migrations = AccountSettingsMigrations( + context = context, + db = db, + settings = settings, + account = account, + accountManager = accountManager, + accountSettings = this + ) + val updateProc = AccountSettingsMigrations::class.java.getDeclaredMethod("update_${fromVersion}_$toVersion") + updateProc.invoke(migrations) Logger.log.info("Account version update successful") accountManager.setUserData(account, KEY_SETTINGS_VERSION, toVersion.toString()) @@ -509,305 +543,39 @@ 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) - } - } + private fun getPeriodicSyncEnableAuthorities() = + listOf( + context.getString(R.string.address_books_authority), + CalendarContract.AUTHORITY, + context.getString(R.string.task_authority), + TaskProvider.ProviderName.JtxBoard.authority, + TaskProvider.ProviderName.OpenTasks.authority, + TaskProvider.ProviderName.TasksOrg.authority + ) + + fun initSync() { + getPeriodicSyncEnableAuthorities().forEach { + enablePeriodicWorkManager(it) } } - - @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) { - val provider = context.contentResolver.acquireContentProviderClient(CalendarContract.AUTHORITY) - if (provider != null) - try { - // 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) - } - } - } - } - } finally { - provider.closeCompat() - } + fun enablePeriodicWorkManager(authority: String) { + if (ContentResolver.getIsSyncable(account, authority) <= 0) { + return } - } - - @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. - * Setting task ETags to null will cause them to be downloaded (and parsed) again. - * - * Also update the allowed reminder types for calendars. - **/ - private fun update_9_10() { - TaskProvider.acquire(context, OpenTasks)?.use { provider -> - val tasksUri = 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) + if (authority !in getPeriodicSyncEnableAuthorities()) { + return } - @SuppressLint("Recycle") - if (ContextCompat.checkSelfPermission(context, android.Manifest.permission.WRITE_CALENDAR) == PackageManager.PERMISSION_GRANTED) - context.contentResolver.acquireContentProviderClient(CalendarContract.AUTHORITY)?.let { provider -> - provider.update(CalendarContract.Calendars.CONTENT_URI.asSyncAdapter(account), - AndroidCalendar.calendarBaseValues, null, null) - provider.closeCompat() - } + val interval = getSyncInterval(authority) ?: getDefaultSyncInterval(authority) + setSyncInterval(authority, interval) } - @Suppress("unused","FunctionName") - /** - * It seems that somehow some non-CalDAV accounts got OpenTasks syncable, which caused battery problems. - * Disable it on those accounts for the future. - */ - private fun update_8_9() { - val hasCalDAV = db.serviceDao().getByAccountAndType(account.name, Service.TYPE_CALDAV) != null - if (!hasCalDAV && ContentResolver.getIsSyncable(account, OpenTasks.authority) != 0) { - Logger.log.info("Disabling OpenTasks sync for $account") - ContentResolver.setIsSyncable(account, OpenTasks.authority, 0) + private fun getDefaultSyncInterval(authority: String) = + when (authority) { + context.getString(R.string.address_books_authority) -> + Constants.DEFAULT_CONTACTS_SYNC_INTERVAL + else -> Constants.DEFAULT_CALENDAR_SYNC_INTERVAL } - } - - @Suppress("unused","FunctionName") - @SuppressLint("Recycle") - /** - * There is a mistake in this method. [TaskContract.Tasks.SYNC_VERSION] is used to store the - * SEQUENCE and should not be used for the eTag. - */ - private fun update_7_8() { - TaskProvider.acquire(context, OpenTasks)?.use { provider -> - // ETag is now in sync_version instead of sync1 - // UID is now in _uid instead of sync2 - 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 -> - while (cursor.moveToNext()) { - val id = cursor.getLong(0) - val eTag = cursor.getString(1) - val uid = cursor.getString(2) - val values = ContentValues(4) - values.put(TaskContract.Tasks._UID, uid) - values.put(TaskContract.Tasks.SYNC_VERSION, eTag) - values.putNull(TaskContract.Tasks.SYNC1) - values.putNull(TaskContract.Tasks.SYNC2) - Logger.log.log(Level.FINER, "Updating task $id", values) - provider.client.update( - ContentUris.withAppendedId(provider.tasksUri(), id).asSyncAdapter(account), - values, null, null) - } - } - } - } - - @Suppress("unused") - @SuppressLint("Recycle") - private fun update_6_7() { - // add calendar colors - context.contentResolver.acquireContentProviderClient(CalendarContract.AUTHORITY)?.let { provider -> - try { - AndroidCalendar.insertColors(provider, account) - } finally { - provider.closeCompat() - } - } - - // update allowed WiFi settings key - val onlySSID = accountManager.getUserData(account, "wifi_only_ssid") - accountManager.setUserData(account, KEY_WIFI_ONLY_SSIDS, onlySSID) - accountManager.setUserData(account, "wifi_only_ssid", null) - } - - @Suppress("unused") - @SuppressLint("Recycle", "ParcelClassLoader") - private fun update_5_6() { - context.contentResolver.acquireContentProviderClient(ContactsContract.AUTHORITY)?.let { provider -> - val parcel = Parcel.obtain() - try { - // don't run syncs during the migration - ContentResolver.setIsSyncable(account, ContactsContract.AUTHORITY, 0) - ContentResolver.setIsSyncable(account, context.getString(R.string.address_books_authority), 0) - ContentResolver.cancelSync(account, null) - - // get previous address book settings (including URL) - val raw = ContactsContract.SyncState.get(provider, account) - if (raw == null) - Logger.log.info("No contacts sync state, ignoring account") - else { - parcel.unmarshall(raw, 0, raw.size) - parcel.setDataPosition(0) - val params = parcel.readBundle()!! - val url = params.getString("url")?.toHttpUrlOrNull() - if (url == null) - Logger.log.info("No address book URL, ignoring account") - else { - // create new address book - val info = Collection(url = url, type = Collection.TYPE_ADDRESSBOOK, displayName = account.name) - Logger.log.log(Level.INFO, "Creating new address book account", url) - val addressBookAccount = Account(LocalAddressBook.accountName(account, info), context.getString(R.string.account_type_address_book)) - if (!accountManager.addAccountExplicitly(addressBookAccount, null, LocalAddressBook.initialUserData(account, info.url.toString()))) - throw ContactsStorageException("Couldn't create address book account") - - // move contacts to new address book - Logger.log.info("Moving contacts from $account to $addressBookAccount") - val newAccount = ContentValues(2) - newAccount.put(ContactsContract.RawContacts.ACCOUNT_NAME, addressBookAccount.name) - newAccount.put(ContactsContract.RawContacts.ACCOUNT_TYPE, addressBookAccount.type) - val affected = provider.update(ContactsContract.RawContacts.CONTENT_URI.buildUpon() - .appendQueryParameter(ContactsContract.RawContacts.ACCOUNT_NAME, account.name) - .appendQueryParameter(ContactsContract.RawContacts.ACCOUNT_TYPE, account.type) - .appendQueryParameter(ContactsContract.CALLER_IS_SYNCADAPTER, "true").build(), - newAccount, - "${ContactsContract.RawContacts.ACCOUNT_NAME}=? AND ${ContactsContract.RawContacts.ACCOUNT_TYPE}=?", - arrayOf(account.name, account.type)) - Logger.log.info("$affected contacts moved to new address book") - } - - ContactsContract.SyncState.set(provider, account, null) - } - } catch(e: RemoteException) { - throw ContactsStorageException("Couldn't migrate contacts to new address book", e) - } finally { - parcel.recycle() - provider.closeCompat() - } - } - - // update version number so that further syncs don't repeat the migration - accountManager.setUserData(account, KEY_SETTINGS_VERSION, "6") - - // request sync of new address book account - ContentResolver.setIsSyncable(account, context.getString(R.string.address_books_authority), 1) - 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 - SyncUtils.updateTaskSync(context) - } - - @Suppress("unused") - private fun update_3_4() { - setGroupMethod(GroupMethod.CATEGORIES) - } - - // updates from AccountSettings version 2 and below are not supported anymore - } diff --git a/app/src/main/java/at/bitfire/davdroid/settings/AccountSettingsMigrations.kt b/app/src/main/java/at/bitfire/davdroid/settings/AccountSettingsMigrations.kt new file mode 100644 index 0000000000000000000000000000000000000000..70c491d2441e087ce0da7c02bd472ea7f9169269 --- /dev/null +++ b/app/src/main/java/at/bitfire/davdroid/settings/AccountSettingsMigrations.kt @@ -0,0 +1,408 @@ +package at.bitfire.davdroid.settings + +import android.accounts.Account +import android.accounts.AccountManager +import android.annotation.SuppressLint +import android.content.ContentResolver +import android.content.ContentUris +import android.content.ContentValues +import android.content.Context +import android.content.pm.PackageManager +import android.os.Parcel +import android.os.RemoteException +import android.provider.CalendarContract +import android.provider.ContactsContract +import android.util.Base64 +import androidx.core.content.ContextCompat +import androidx.preference.PreferenceManager +import at.bitfire.davdroid.Constants +import at.bitfire.davdroid.R +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.resource.LocalTask +import at.bitfire.davdroid.resource.TaskUtils +import at.bitfire.davdroid.syncadapter.SyncUtils +import at.bitfire.davdroid.util.closeCompat +import at.bitfire.ical4android.AndroidCalendar +import at.bitfire.ical4android.AndroidEvent +import at.bitfire.ical4android.TaskProvider +import at.bitfire.ical4android.UnknownProperty +import at.bitfire.vcard4android.ContactsStorageException +import at.bitfire.vcard4android.GroupMethod +import at.techbee.jtx.JtxContract.asSyncAdapter +import net.fortuna.ical4j.model.Property +import net.fortuna.ical4j.model.property.Url +import okhttp3.HttpUrl.Companion.toHttpUrlOrNull +import org.dmfs.tasks.contract.TaskContract +import java.io.ByteArrayInputStream +import java.io.ObjectInputStream +import java.util.logging.Level + +class AccountSettingsMigrations( + val context: Context, + val db: AppDatabase, + val settings: SettingsManager, + val account: Account, + val accountManager: AccountManager, + val accountSettings: AccountSettings +) { + + /** + * Disables all sync adapter periodic syncs for every authority. Then enables + * corresponding PeriodicSyncWorkers + */ + @Suppress("unused","FunctionName") + fun update_13_14() { + + // Cancel any potentially running syncs for this account (sync framework) + ContentResolver.cancelSync(account, null) + + val authorities = listOf( + context.getString(R.string.address_books_authority), + CalendarContract.AUTHORITY, + context.getString(R.string.task_authority), + TaskProvider.ProviderName.JtxBoard.authority, + TaskProvider.ProviderName.OpenTasks.authority, + TaskProvider.ProviderName.TasksOrg.authority + ) + + for (authority in authorities) { + // Enable PeriodicSyncWorker (WorkManager), with known intervals + v14_enableWorkManager(authority) + // Disable periodic syncs (sync adapter framework) + v14_disableSyncFramework(authority) + } + } + private fun v14_enableWorkManager(authority: String) { + val enabled = accountSettings.getSyncInterval(authority)?.let { syncInterval -> + accountSettings.setSyncInterval(authority, syncInterval) + } ?: false + Logger.log.info("PeriodicSyncWorker for $account/$authority enabled=$enabled") + } + private fun v14_disableSyncFramework(authority: String) { + // Disable periodic syncs (sync adapter framework) + val disable: () -> Boolean = { + /* Ugly hack: because there is no callback for when the sync status/interval has been + updated, we need to make this call blocking. */ + for (sync in ContentResolver.getPeriodicSyncs(account, authority)) + ContentResolver.removePeriodicSync(sync.account, sync.authority, sync.extras) + + // check whether syncs are really disabled + var result = true + for (sync in ContentResolver.getPeriodicSyncs(account, authority)) { + Logger.log.info("Sync framework still has a periodic sync for $account/$authority: $sync") + result = false + } + result + } + // try up to 10 times with 100 ms pause + var success = false + for (idxTry in 0 until 10) { + success = disable() + if (success) + break + Thread.sleep(200) + } + Logger.log.info("Sync framework periodic syncs for $account/$authority disabled=$success") + } + + @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() + } + + + @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) { + val provider = context.contentResolver.acquireContentProviderClient(CalendarContract.AUTHORITY) + if (provider != null) + try { + // 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 = CalendarContract.ExtendedProperties.CONTENT_URI.asSyncAdapter(account) + + provider.query(extUri, arrayOf( + CalendarContract.ExtendedProperties._ID, // idx 0 + CalendarContract.ExtendedProperties.NAME, // idx 1 + CalendarContract.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(CalendarContract.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(CalendarContract.ExtendedProperties.NAME, AndroidEvent.MIMETYPE_URL) + newValues.put(CalendarContract.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(CalendarContract.ExtendedProperties.NAME, UnknownProperty.CONTENT_ITEM_TYPE) + newValues.put(CalendarContract.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(CalendarContract.ExtendedProperties.NAME, UnknownProperty.CONTENT_ITEM_TYPE) + provider.update(uri, newValues, null, null) + } + } + } + } + } finally { + provider.closeCompat() + } + } + } + + @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 = accountSettings.getSyncInterval(provider.authority) + if (interval != null) + accountManager.setUserData(account, + AccountSettings.KEY_SYNC_INTERVAL_TASKS, interval.toString()) + } + } + + @Suppress("unused","FunctionName") + /** + * Task synchronization now handles alarms, categories, relations and unknown properties. + * Setting task ETags to null will cause them to be downloaded (and parsed) again. + * + * Also update the allowed reminder types for calendars. + **/ + private fun update_9_10() { + TaskProvider.acquire(context, TaskProvider.ProviderName.OpenTasks)?.use { provider -> + 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( + CalendarContract.Calendars.CONTENT_URI.asSyncAdapter(account), + AndroidCalendar.calendarBaseValues, null, null) + provider.closeCompat() + } + } + + @Suppress("unused","FunctionName") + /** + * It seems that somehow some non-CalDAV accounts got OpenTasks syncable, which caused battery problems. + * Disable it on those accounts for the future. + */ + private fun update_8_9() { + val hasCalDAV = db.serviceDao().getByAccountAndType(account.name, Service.TYPE_CALDAV) != null + if (!hasCalDAV && ContentResolver.getIsSyncable(account, TaskProvider.ProviderName.OpenTasks.authority) != 0) { + Logger.log.info("Disabling OpenTasks sync for $account") + ContentResolver.setIsSyncable(account, TaskProvider.ProviderName.OpenTasks.authority, 0) + } + } + + @Suppress("unused","FunctionName") + @SuppressLint("Recycle") + /** + * There is a mistake in this method. [TaskContract.Tasks.SYNC_VERSION] is used to store the + * SEQUENCE and should not be used for the eTag. + */ + private fun update_7_8() { + TaskProvider.acquire(context, TaskProvider.ProviderName.OpenTasks)?.use { provider -> + // ETag is now in sync_version instead of sync1 + // UID is now in _uid instead of sync2 + 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 -> + while (cursor.moveToNext()) { + val id = cursor.getLong(0) + val eTag = cursor.getString(1) + val uid = cursor.getString(2) + val values = ContentValues(4) + values.put(TaskContract.Tasks._UID, uid) + values.put(TaskContract.Tasks.SYNC_VERSION, eTag) + values.putNull(TaskContract.Tasks.SYNC1) + values.putNull(TaskContract.Tasks.SYNC2) + Logger.log.log(Level.FINER, "Updating task $id", values) + provider.client.update( + ContentUris.withAppendedId(provider.tasksUri(), id).asSyncAdapter(account), + values, null, null) + } + } + } + } + + @Suppress("unused") + @SuppressLint("Recycle") + private fun update_6_7() { + // add calendar colors + context.contentResolver.acquireContentProviderClient(CalendarContract.AUTHORITY)?.let { provider -> + try { + AndroidCalendar.insertColors(provider, account) + } finally { + provider.closeCompat() + } + } + + // update allowed WiFi settings key + val onlySSID = accountManager.getUserData(account, "wifi_only_ssid") + accountManager.setUserData(account, AccountSettings.KEY_WIFI_ONLY_SSIDS, onlySSID) + accountManager.setUserData(account, "wifi_only_ssid", null) + } + + @Suppress("unused") + @SuppressLint("Recycle", "ParcelClassLoader") + private fun update_5_6() { + context.contentResolver.acquireContentProviderClient(ContactsContract.AUTHORITY)?.let { provider -> + val parcel = Parcel.obtain() + try { + // don't run syncs during the migration + ContentResolver.setIsSyncable(account, ContactsContract.AUTHORITY, 0) + ContentResolver.setIsSyncable(account, context.getString(R.string.address_books_authority), 0) + ContentResolver.cancelSync(account, null) + + // get previous address book settings (including URL) + val raw = ContactsContract.SyncState.get(provider, account) + if (raw == null) + Logger.log.info("No contacts sync state, ignoring account") + else { + parcel.unmarshall(raw, 0, raw.size) + parcel.setDataPosition(0) + val params = parcel.readBundle()!! + val url = params.getString("url")?.toHttpUrlOrNull() + if (url == null) + Logger.log.info("No address book URL, ignoring account") + else { + // create new address book + val info = Collection(url = url, type = Collection.TYPE_ADDRESSBOOK, displayName = account.name) + Logger.log.log(Level.INFO, "Creating new address book account", url) + val addressBookAccount = Account( + LocalAddressBook.accountName(account, info), context.getString( + R.string.account_type_address_book)) + if (!accountManager.addAccountExplicitly(addressBookAccount, null, LocalAddressBook.initialUserData(account, info.url.toString()))) + throw ContactsStorageException("Couldn't create address book account") + + // move contacts to new address book + Logger.log.info("Moving contacts from $account to $addressBookAccount") + val newAccount = ContentValues(2) + newAccount.put(ContactsContract.RawContacts.ACCOUNT_NAME, addressBookAccount.name) + newAccount.put(ContactsContract.RawContacts.ACCOUNT_TYPE, addressBookAccount.type) + val affected = provider.update( + ContactsContract.RawContacts.CONTENT_URI.buildUpon() + .appendQueryParameter(ContactsContract.RawContacts.ACCOUNT_NAME, account.name) + .appendQueryParameter(ContactsContract.RawContacts.ACCOUNT_TYPE, account.type) + .appendQueryParameter(ContactsContract.CALLER_IS_SYNCADAPTER, "true").build(), + newAccount, + "${ContactsContract.RawContacts.ACCOUNT_NAME}=? AND ${ContactsContract.RawContacts.ACCOUNT_TYPE}=?", + arrayOf(account.name, account.type)) + Logger.log.info("$affected contacts moved to new address book") + } + + ContactsContract.SyncState.set(provider, account, null) + accountSettings.setSyncInterval(context.getString(R.string.address_books_authority), Constants.DEFAULT_CONTACTS_SYNC_INTERVAL) + } + } catch(e: RemoteException) { + throw ContactsStorageException("Couldn't migrate contacts to new address book", e) + } finally { + parcel.recycle() + provider.closeCompat() + } + } + + // update version number so that further syncs don't repeat the migration + accountManager.setUserData(account, AccountSettings.KEY_SETTINGS_VERSION, "6") + + // request sync of new address book account + ContentResolver.setIsSyncable(account, context.getString(R.string.address_books_authority), 1) + accountSettings.setSyncInterval(context.getString(R.string.address_books_authority), 4*3600) + } + + /* 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 + SyncUtils.updateTaskSync(context) + } + + @Suppress("unused") + private fun update_3_4() { + accountSettings.setGroupMethod(GroupMethod.CATEGORIES) + } + + // updates from AccountSettings version 2 and below are not supported anymore + +} \ 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 828fbf4eac2d7661b317ca9d1ac03d3bfce6c34b..276c9a8588dd3913dce9c6162278667224ba4f8b 100644 --- a/app/src/main/java/at/bitfire/davdroid/settings/DefaultsProvider.kt +++ b/app/src/main/java/at/bitfire/davdroid/settings/DefaultsProvider.kt @@ -4,13 +4,7 @@ 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 @@ -26,21 +20,22 @@ class DefaultsProvider( override val booleanDefaults = mutableMapOf( Pair(Settings.DISTRUST_SYSTEM_CERTIFICATES, false), - Pair(Settings.SYNC_ALL_COLLECTIONS, false), Pair(Settings.FORCE_READ_ONLY_ADDRESSBOOKS, false) ) override val intDefaults = mapOf( + Pair(Settings.PRESELECT_COLLECTIONS, Settings.PRESELECT_COLLECTIONS_ALL), Pair(Settings.PROXY_TYPE, Settings.PROXY_TYPE_SYSTEM), Pair(Settings.PROXY_PORT, 9050) // Orbot SOCKS ) override val longDefaults = mapOf( - Pair(Settings.DEFAULT_SYNC_INTERVAL, 4*3600) /* 4 hours */ + Pair(Settings.DEFAULT_SYNC_INTERVAL, 15*60) /* 15 minutes */ ) override val stringDefaults = mapOf( - Pair(Settings.PROXY_HOST, "localhost") + Pair(Settings.PROXY_HOST, "localhost"), + Pair(Settings.PRESELECT_COLLECTIONS_EXCLUDED, "/z-app-generated--contactsinteraction--recent/") // Nextcloud "Recently Contacted" address book ) class Factory @Inject constructor(): SettingsProviderFactory { 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 31d058fcffcfbbc3ddd3ec69951f5e5082477878..5e9734a0e9923bb8f600f7a02889e553a1cd4058 100644 --- a/app/src/main/java/at/bitfire/davdroid/settings/Settings.kt +++ b/app/src/main/java/at/bitfire/davdroid/settings/Settings.kt @@ -13,13 +13,13 @@ object Settings { const val DISTRUST_SYSTEM_CERTIFICATES = "distrust_system_certs" - const val PROXY_TYPE = "proxy_type" + const val PROXY_TYPE = "proxy_type" // Integer 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" + const val PROXY_HOST = "proxy_host" // String + const val PROXY_PORT = "proxy_port" // Integer /** * Default sync interval (long), in seconds. @@ -34,10 +34,23 @@ object Settings { const val PREFERRED_THEME = "preferred_theme" const val PREFERRED_THEME_DEFAULT = AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM + const val LANGUAGE = "language" + const val LANGUAGE_SYSTEM = "language_system" + 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" + /** whether collections are automatically selected for synchronization after their initial detection */ + const val PRESELECT_COLLECTIONS = "preselect_collections" + /** collections are not automatically selected for synchronization */ + const val PRESELECT_COLLECTIONS_NONE = 0 + /** all collections (except those matching [PRESELECT_COLLECTIONS_EXCLUDED]) are automatically selected for synchronization */ + const val PRESELECT_COLLECTIONS_ALL = 1 + /** personal collections (except those matching [PRESELECT_COLLECTIONS_EXCLUDED]) are automatically selected for synchronization */ + const val PRESELECT_COLLECTIONS_PERSONAL = 2 + + /** regular expression to match URLs of collections to be excluded from pre-selection */ + const val PRESELECT_COLLECTIONS_EXCLUDED = "preselect_collections_excluded" + /** whether all address books are forced to be read-only */ const val FORCE_READ_ONLY_ADDRESSBOOKS = "force_read_only_addressbooks" diff --git a/app/src/main/java/at/bitfire/davdroid/syncadapter/AccountsCleanupWorker.kt b/app/src/main/java/at/bitfire/davdroid/syncadapter/AccountsCleanupWorker.kt new file mode 100644 index 0000000000000000000000000000000000000000..59e827633aa4dfb4a57415879f7e4f67f9c398b3 --- /dev/null +++ b/app/src/main/java/at/bitfire/davdroid/syncadapter/AccountsCleanupWorker.kt @@ -0,0 +1,90 @@ +/*************************************************************************************************** + * 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 androidx.hilt.work.HiltWorker +import androidx.work.* +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.assisted.Assisted +import dagger.assisted.AssistedInject +import java.util.concurrent.Semaphore +import java.util.concurrent.TimeUnit +import java.util.logging.Level + +@HiltWorker +class AccountsCleanupWorker @AssistedInject constructor( + @Assisted appContext: Context, + @Assisted workerParameters: WorkerParameters, + val db: AppDatabase +): Worker(appContext, workerParameters) { + + companion object { + const val NAME = "accounts-cleanup" + + private val mutex = Semaphore(1) + /** + * Prevents account cleanup from being run until `unlockAccountsCleanup` is called. + * Can only be active once at the same time globally (blocking). + */ + fun lockAccountsCleanup() = mutex.acquire() + /** Must be called exactly one time after calling `lockAccountsCleanup`. */ + fun unlockAccountsCleanup() = mutex.release() + + fun enqueue(context: Context) { + WorkManager.getInstance(context).enqueueUniqueWork(NAME, ExistingWorkPolicy.KEEP, + OneTimeWorkRequestBuilder() + .setInitialDelay(15, TimeUnit.SECONDS) // wait some time before cleaning up accouts + .build()) + } + } + + override fun doWork(): Result { + lockAccountsCleanup() + try { + val accountManager = AccountManager.get(applicationContext) + cleanupAccounts(applicationContext, accountManager.accounts) + } finally { + unlockAccountsCleanup() + } + return Result.success() + } + + private fun cleanupAccounts(context: Context, accounts: Array) { + Logger.log.log(Level.INFO, "Cleaning up accounts. Current accounts") + + val mainAccountNames = HashSet() + val accountFromManager = AccountUtils.getMainAccounts(context) + + for (account in accountFromManager.toTypedArray()) { + mainAccountNames += account.name + } + + // delete orphaned address book accounts + val addressBookAccounts = AccountUtils.getAddressBookAccounts(context) + addressBookAccounts.map { LocalAddressBook(context, it, null) } + .forEach { + try { + if (!mainAccountNames.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 (mainAccountNames.isEmpty()) + serviceDao.deleteAll() + else + serviceDao.deleteExceptAccounts(mainAccountNames.toTypedArray()) + } + +} \ 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 index 778ee9bb30ae295151fb553368c9233b55bae87c..8d4425fec3c87de5ad7f622a4fb53b9ab636eb59 100644 --- a/app/src/main/java/at/bitfire/davdroid/syncadapter/AccountsUpdatedListener.kt +++ b/app/src/main/java/at/bitfire/davdroid/syncadapter/AccountsUpdatedListener.kt @@ -9,21 +9,11 @@ import android.accounts.AccountManager import android.accounts.OnAccountsUpdateListener import android.content.Context import androidx.annotation.AnyThread -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.concurrent.Semaphore -import java.util.logging.Level import javax.inject.Singleton class AccountsUpdatedListener private constructor( @@ -38,78 +28,18 @@ class AccountsUpdatedListener private constructor( fun accountsUpdatedListener(@ApplicationContext context: Context) = AccountsUpdatedListener(context) } - @EntryPoint - @InstallIn(SingletonComponent::class) - interface AccountsUpdatedListenerEntryPoint { - fun appDatabase(): AppDatabase - } - - /** - * This mutex (semaphore with 1 permit) will be acquired by [onAccountsUpdated]. So if you - * want to postpone [onAccountsUpdated] execution because you're modifying accounts non-transactionally, - * you can acquire the mutex by yourself. Don't forget to release it as soon as you're done. - */ - val mutex = Semaphore(1) - - 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]. - * - * Before the accounts are cleaned up, the [mutex] is acquired. - * After the accounts are cleaned up, the [mutex] is released. + * Called when the system accounts have been updated. The interesting case for us is when + * a DAVx5 account has been removed. Then we enqueue an [AccountsCleanupWorker] to remove + * the orphaned entries from the database. */ @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 { - try { - mutex.acquire() - cleanupAccounts(context, accounts) - } finally { - mutex.release() - } - } - } - - @Synchronized - private fun cleanupAccounts(context: Context, accounts: Array) { - Logger.log.log(Level.INFO, "Cleaning up accounts. Current accounts") - - val accountNames = HashSet() - val accountFromManager = AccountUtils.getMainAccounts(context) - - for (account in accountFromManager.toTypedArray()) { - accountNames += account.name - } - - // delete orphaned address book accounts - val addressBookAccounts = AccountUtils.getAddressBookAccounts(context) - 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()) + AccountsCleanupWorker.enqueue(context) } } \ No newline at end of file diff --git a/app/src/main/java/at/bitfire/davdroid/syncadapter/AddressBookSyncer.kt b/app/src/main/java/at/bitfire/davdroid/syncadapter/AddressBookSyncer.kt new file mode 100644 index 0000000000000000000000000000000000000000..c099cb55464d426de44d5852b6539de0b3e7f94d --- /dev/null +++ b/app/src/main/java/at/bitfire/davdroid/syncadapter/AddressBookSyncer.kt @@ -0,0 +1,124 @@ +/*************************************************************************************************** + * 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.ContentProviderClient +import android.content.Context +import android.content.SyncResult +import android.content.pm.PackageManager +import android.provider.ContactsContract +import androidx.core.content.ContextCompat +import at.bitfire.davdroid.network.HttpClient +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.Settings +import at.bitfire.davdroid.settings.SettingsManager +import at.bitfire.davdroid.util.closeCompat +import dagger.hilt.EntryPoint +import dagger.hilt.InstallIn +import dagger.hilt.android.EntryPointAccessors +import dagger.hilt.components.SingletonComponent +import okhttp3.HttpUrl +import okhttp3.HttpUrl.Companion.toHttpUrl +import java.util.logging.Level + +/** + * Sync logic for address books + */ +class AddressBookSyncer(context: Context): Syncer(context) { + + @EntryPoint + @InstallIn(SingletonComponent::class) + interface AddressBooksSyncAdapterEntryPoint { + fun settingsManager(): SettingsManager + } + + val entryPoint = EntryPointAccessors.fromApplication(context, AddressBooksSyncAdapterEntryPoint::class.java) + val settingsManager = entryPoint.settingsManager() + + override fun sync( + account: Account, + extras: Array, + authority: String, + httpClient: Lazy, + provider: ContentProviderClient, + syncResult: SyncResult + ) { + try { + 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) + SyncWorker.enqueue(context, addressBookAccount, ContactsContract.AUTHORITY) + } + } 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 + Logger.log.warning("No contacts permission, but address books are selected for synchronization") + 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 + } + + val forceAllReadOnly = settingsManager.getBoolean(Settings.FORCE_READ_ONLY_ADDRESSBOOKS) + + // delete/update local address books + for (addressBook in LocalAddressBook.findAll(context, contactsProvider, account)) { + val url = addressBook.url.toHttpUrl() + 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, forceAllReadOnly) + } 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, forceAllReadOnly) + } + } finally { + contactsProvider?.closeCompat() + } + + return true + } + +} \ No newline at end of file diff --git a/app/src/main/java/at/bitfire/davdroid/syncadapter/AddressBooksSyncAdapterService.kt b/app/src/main/java/at/bitfire/davdroid/syncadapter/AddressBooksSyncAdapterService.kt deleted file mode 100644 index 81ad168b10a783d656bebf9c8a9e6e90602042ae..0000000000000000000000000000000000000000 --- a/app/src/main/java/at/bitfire/davdroid/syncadapter/AddressBooksSyncAdapterService.kt +++ /dev/null @@ -1,143 +0,0 @@ -/*************************************************************************************************** - * 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.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.resource.LocalAddressBook -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.HttpUrl -import okhttp3.HttpUrl.Companion.toHttpUrl -import java.util.logging.Level - -open class AddressBooksSyncAdapterService : SyncAdapterService() { - - override fun syncAdapter() = AddressBooksSyncAdapter(this, appDatabase) - - - class AddressBooksSyncAdapter( - context: Context, - appDatabase: AppDatabase - ) : SyncAdapter(context, appDatabase) { - - @EntryPoint - @InstallIn(SingletonComponent::class) - interface AddressBooksSyncAdapterEntryPoint { - fun settingsManager(): SettingsManager - } - - val entryPoint = EntryPointAccessors.fromApplication(context, AddressBooksSyncAdapterEntryPoint::class.java) - val settingsManager = entryPoint.settingsManager() - - 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 - Logger.log.warning("No contacts permission, but address books are selected for synchronization") - 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 - } - - val forceAllReadOnly = settingsManager.getBoolean(Settings.FORCE_READ_ONLY_ADDRESSBOOKS) - - // delete/update local address books - for (addressBook in LocalAddressBook.findAll(context, contactsProvider, account)) { - val url = addressBook.url.toHttpUrl() - 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, forceAllReadOnly) - } 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, forceAllReadOnly) - } - } finally { - contactsProvider?.closeCompat() - } - - return true - } - - } - -} diff --git a/app/src/main/java/at/bitfire/davdroid/syncadapter/YahooContactsSyncAdapterService.kt b/app/src/main/java/at/bitfire/davdroid/syncadapter/AppDataSyncer.kt similarity index 59% rename from app/src/main/java/at/bitfire/davdroid/syncadapter/YahooContactsSyncAdapterService.kt rename to app/src/main/java/at/bitfire/davdroid/syncadapter/AppDataSyncer.kt index 9954da15ba7f46cc94a8aad9b6864d46cd465633..465b440738a6c76ae7ae48ad487cb6819b264086 100644 --- a/app/src/main/java/at/bitfire/davdroid/syncadapter/YahooContactsSyncAdapterService.kt +++ b/app/src/main/java/at/bitfire/davdroid/syncadapter/AppDataSyncer.kt @@ -16,4 +16,21 @@ package at.bitfire.davdroid.syncadapter -class YahooContactsSyncAdapterService : ContactsSyncAdapterService() \ No newline at end of file +import android.accounts.Account +import android.content.ContentProviderClient +import android.content.Context +import android.content.SyncResult +import at.bitfire.davdroid.network.HttpClient + +class AppDataSyncer (context: Context): Syncer(context) { + override fun sync( + account: Account, + extras: Array, + 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/CalendarSyncManager.kt b/app/src/main/java/at/bitfire/davdroid/syncadapter/CalendarSyncManager.kt index 36c2123d9ceca209965c27057e726e2e2597d1ef..b4fb4a5b410b6ede42971d7a68ed7100982114aa 100644 --- a/app/src/main/java/at/bitfire/davdroid/syncadapter/CalendarSyncManager.kt +++ b/app/src/main/java/at/bitfire/davdroid/syncadapter/CalendarSyncManager.kt @@ -7,14 +7,13 @@ 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.MultiResponseCallback 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.util.DavUtils +import at.bitfire.davdroid.network.HttpClient import at.bitfire.davdroid.R import at.bitfire.davdroid.db.SyncState import at.bitfire.davdroid.log.Logger @@ -36,6 +35,8 @@ import java.io.ByteArrayOutputStream import java.io.Reader import java.io.StringReader import java.time.Duration +import java.time.Instant +import java.time.ZonedDateTime import java.util.* import java.util.logging.Level @@ -46,7 +47,7 @@ class CalendarSyncManager( context: Context, account: Account, accountSettings: AccountSettings, - extras: Bundle, + extras: Array, httpClient: HttpClient, authority: String, syncResult: SyncResult, @@ -55,7 +56,7 @@ class CalendarSyncManager( override fun prepare(): Boolean { collectionURL = (localCollection.name ?: return false).toHttpUrlOrNull() ?: return false - davCollection = DavCalendar(httpClient.okHttpClient, collectionURL, accountSettings.credentials().authState?.accessToken) + davCollection = DavCalendar(httpClient.okHttpClient, collectionURL) // if there are dirty exceptions for events, mark their master events as dirty, too localCollection.processDirtyExceptions() @@ -103,11 +104,8 @@ class CalendarSyncManager( override fun listAllRemote(callback: MultiResponseCallback) { // calculate time range limits - var limitStart: Date? = null - accountSettings.getTimeRangePastDays()?.let { pastDays -> - val calendar = Calendar.getInstance() - calendar.add(Calendar.DAY_OF_MONTH, -pastDays) - limitStart = calendar.time + val limitStart = accountSettings.getTimeRangePastDays()?.let { pastDays -> + ZonedDateTime.now().minusDays(pastDays.toLong()).toInstant() } return remoteExceptionContext { remote -> @@ -132,7 +130,7 @@ class CalendarSyncManager( val calendarData = response[CalendarData::class.java] val iCal = calendarData?.iCalendar - ?: throw DavException("Received multi-get response without address data") + ?: throw DavException("Received multi-get response without calendar data") processVEvent(DavUtils.lastSegmentOfUrl(response.href), eTag, scheduleTag, StringReader(iCal)) } diff --git a/app/src/main/java/at/bitfire/davdroid/syncadapter/CalendarSyncer.kt b/app/src/main/java/at/bitfire/davdroid/syncadapter/CalendarSyncer.kt new file mode 100644 index 0000000000000000000000000000000000000000..a45508e7f6c495b42492e887aca522b40e8b0937 --- /dev/null +++ b/app/src/main/java/at/bitfire/davdroid/syncadapter/CalendarSyncer.kt @@ -0,0 +1,91 @@ +/*************************************************************************************************** + * 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.ContentProviderClient +import android.content.Context +import android.content.SyncResult +import android.provider.CalendarContract +import at.bitfire.davdroid.network.HttpClient +import at.bitfire.davdroid.db.Collection +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 okhttp3.HttpUrl +import okhttp3.HttpUrl.Companion.toHttpUrl +import java.util.logging.Level + +/** + * Sync logic for calendars + */ +class CalendarSyncer(context: Context): Syncer(context) { + + override fun sync( + account: Account, + extras: Array, + authority: String, + httpClient: Lazy, + provider: ContentProviderClient, + syncResult: SyncResult + ) { + try { + val accountSettings = AccountSettings(context, account) + + if (accountSettings.getEventColors()) + AndroidCalendar.insertColors(provider, account) + else + AndroidCalendar.removeColors(provider, account) + + updateLocalCalendars(provider, account, accountSettings) + + val calendars = AndroidCalendar + .find(account, provider, LocalCalendar.Factory, "${CalendarContract.Calendars.SYNC_EVENTS}!=0", null) + for (calendar in calendars) { + Logger.log.info("Synchronizing calendar #${calendar.id}, URL: ${calendar.name}") + CalendarSyncManager(context, account, accountSettings, extras, httpClient.value, authority, syncResult, calendar).performSync() + } + } catch(e: Exception) { + Logger.log.log(Level.SEVERE, "Couldn't sync calendars", e) + } + Logger.log.info("Calendar sync complete") + } + + private fun updateLocalCalendars(provider: ContentProviderClient, account: Account, settings: AccountSettings) { + 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.toHttpUrl() + 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) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/at/bitfire/davdroid/syncadapter/CalendarsSyncAdapterService.kt b/app/src/main/java/at/bitfire/davdroid/syncadapter/CalendarsSyncAdapterService.kt deleted file mode 100644 index 7bc61773dfe33ff6ec727b94558f6c0a8a7a79e1..0000000000000000000000000000000000000000 --- a/app/src/main/java/at/bitfire/davdroid/syncadapter/CalendarsSyncAdapterService.kt +++ /dev/null @@ -1,144 +0,0 @@ -/*************************************************************************************************** - * 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.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.OpenIdUtils -import at.bitfire.davdroid.db.AppDatabase -import at.bitfire.davdroid.db.Collection -import at.bitfire.davdroid.db.Credentials -import at.bitfire.davdroid.db.Service -import at.bitfire.davdroid.log.Logger -import at.bitfire.davdroid.resource.LocalCalendar -import at.bitfire.davdroid.settings.AccountSettings -import at.bitfire.ical4android.AndroidCalendar -import net.openid.appauth.AuthorizationService -import okhttp3.HttpUrl -import okhttp3.HttpUrl.Companion.toHttpUrl -import java.util.logging.Level -import kotlin.collections.component1 -import kotlin.collections.component2 -import kotlin.collections.set - -open class CalendarsSyncAdapterService : 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 - ).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.toHttpUrl() - 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/ContactSyncer.kt b/app/src/main/java/at/bitfire/davdroid/syncadapter/ContactSyncer.kt new file mode 100644 index 0000000000000000000000000000000000000000..cbb33887fdcaef4d2342ce6bda6aa66cc82f784f --- /dev/null +++ b/app/src/main/java/at/bitfire/davdroid/syncadapter/ContactSyncer.kt @@ -0,0 +1,64 @@ +/*************************************************************************************************** + * 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.ContentProviderClient +import android.content.Context +import android.content.SyncResult +import android.provider.ContactsContract +import at.bitfire.davdroid.network.HttpClient +import at.bitfire.davdroid.log.Logger +import at.bitfire.davdroid.resource.LocalAddressBook +import at.bitfire.davdroid.settings.AccountSettings +import java.util.logging.Level + +/** + * Sync logic for contacts + */ +class ContactSyncer(context: Context): Syncer(context) { + + companion object { + const val PREVIOUS_GROUP_METHOD = "previous_group_method" + } + + override fun sync( + account: Account, + extras: Array, + authority: String, + httpClient: Lazy, + provider: ContentProviderClient, + syncResult: SyncResult + ) { + try { + val accountSettings = AccountSettings(context, account) + val addressBook = LocalAddressBook(context, account, provider) + + // 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) + + 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") + } +} \ No newline at end of file diff --git a/app/src/main/java/at/bitfire/davdroid/syncadapter/ContactsSyncAdapterService.kt b/app/src/main/java/at/bitfire/davdroid/syncadapter/ContactsSyncAdapterService.kt deleted file mode 100644 index 407af4a97d2d3621274a93b089cd7dc8dcdc8f26..0000000000000000000000000000000000000000 --- a/app/src/main/java/at/bitfire/davdroid/syncadapter/ContactsSyncAdapterService.kt +++ /dev/null @@ -1,79 +0,0 @@ -/*************************************************************************************************** - * 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.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.OpenIdUtils -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 - -open class ContactsSyncAdapterService: SyncAdapterService() { - - companion object { - const val PREVIOUS_GROUP_METHOD = "previous_group_method" - } - - override fun syncAdapter() = ContactsSyncAdapter(this, appDatabase) - - - class ContactsSyncAdapter( - 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) - val addressBook = LocalAddressBook(context, account, provider) - - // 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/ContactsSyncManager.kt b/app/src/main/java/at/bitfire/davdroid/syncadapter/ContactsSyncManager.kt index bf1032a10c2ed9af46faa93ac9bc7074002773d3..e32d4708c5051b3f89d29b1e60ca6dd0378e3a79 100644 --- a/app/src/main/java/at/bitfire/davdroid/syncadapter/ContactsSyncManager.kt +++ b/app/src/main/java/at/bitfire/davdroid/syncadapter/ContactsSyncManager.kt @@ -10,15 +10,14 @@ import android.content.ContentResolver import android.content.Context import android.content.SyncResult import android.os.Build -import android.os.Bundle import at.bitfire.dav4jvm.DavAddressBook import at.bitfire.dav4jvm.MultiResponseCallback 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.util.DavUtils +import at.bitfire.davdroid.util.DavUtils.sameTypeAs +import at.bitfire.davdroid.network.HttpClient import at.bitfire.davdroid.R import at.bitfire.davdroid.db.SyncState import at.bitfire.davdroid.log.Logger @@ -83,7 +82,7 @@ class ContactsSyncManager( account: Account, accountSettings: AccountSettings, httpClient: HttpClient, - extras: Bundle, + extras: Array, authority: String, syncResult: SyncResult, val provider: ContentProviderClient, @@ -115,14 +114,14 @@ class ContactsSyncManager( // workaround for Android 7 which sets DIRTY flag when only meta-data is changed val reallyDirty = localCollection.verifyDirty() val deleted = localCollection.findDeleted().size - if (extras.containsKey(ContentResolver.SYNC_EXTRAS_UPLOAD) && reallyDirty == 0 && deleted == 0) { + if (extras.contains(ContentResolver.SYNC_EXTRAS_UPLOAD) && reallyDirty == 0 && deleted == 0) { Logger.log.info("This sync was called to up-sync dirty/deleted contacts, but no contacts have been changed") return false } } collectionURL = localCollection.url.toHttpUrlOrNull() ?: return false - davCollection = DavAddressBook(httpClient.okHttpClient, collectionURL, accountSettings.credentials().authState?.accessToken) + davCollection = DavAddressBook(httpClient.okHttpClient, collectionURL) resourceDownloader = ResourceDownloader(davCollection.location) @@ -217,7 +216,7 @@ class ContactsSyncManager( groupStrategy.beforeUploadDirty() // generate UID/file name for newly created contacts - var superModified = super.uploadDirty() + val superModified = super.uploadDirty() // return true when any operation returned true return modified or superModified diff --git a/app/src/main/java/at/bitfire/davdroid/syncadapter/DefaultAccountAuthenticatorService.kt b/app/src/main/java/at/bitfire/davdroid/syncadapter/DefaultAccountAuthenticatorService.kt index 709cc45746c5e546bd9d3c1cb853b709084f0263..e31e04345b60d1d6b58b4b57ba775bd5f1336115 100644 --- a/app/src/main/java/at/bitfire/davdroid/syncadapter/DefaultAccountAuthenticatorService.kt +++ b/app/src/main/java/at/bitfire/davdroid/syncadapter/DefaultAccountAuthenticatorService.kt @@ -32,7 +32,10 @@ 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.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 @@ -42,6 +45,12 @@ import java.util.logging.Level abstract class DefaultAccountAuthenticatorService : Service(), OnAccountsUpdateListener { + @EntryPoint + @InstallIn(SingletonComponent::class) + interface DefaultAccountAuthenticatorServiceEntryPoint { + fun appDatabase(): AppDatabase + } + companion object { fun cleanupAccounts(context: Context, db: AppDatabase) { Logger.log.info("Cleaning up orphaned accounts") @@ -100,7 +109,7 @@ abstract class DefaultAccountAuthenticatorService : Service(), OnAccountsUpdateL CoroutineScope(Dispatchers.IO).launch { val db = EntryPointAccessors.fromApplication( applicationContext, - AccountsUpdatedListener.AccountsUpdatedListenerEntryPoint::class.java + DefaultAccountAuthenticatorServiceEntryPoint::class.java ).appDatabase() cleanupAccounts(applicationContext, db) diff --git a/app/src/main/java/at/bitfire/davdroid/syncadapter/EeloAddressBooksSyncAdapterService.kt b/app/src/main/java/at/bitfire/davdroid/syncadapter/EeloAddressBooksSyncAdapterService.kt deleted file mode 100644 index 619cc154c956b363dbfbc87ae98930f58ddcdb0b..0000000000000000000000000000000000000000 --- a/app/src/main/java/at/bitfire/davdroid/syncadapter/EeloAddressBooksSyncAdapterService.kt +++ /dev/null @@ -1,19 +0,0 @@ -/* - * 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 - -class EeloAddressBooksSyncAdapterService : AddressBooksSyncAdapterService() diff --git a/app/src/main/java/at/bitfire/davdroid/syncadapter/EeloAppDataSyncAdapterService.kt b/app/src/main/java/at/bitfire/davdroid/syncadapter/EeloAppDataSyncAdapterService.kt deleted file mode 100644 index 703cb25bdab57eeba0c03955674170b40f7905a0..0000000000000000000000000000000000000000 --- a/app/src/main/java/at/bitfire/davdroid/syncadapter/EeloAppDataSyncAdapterService.kt +++ /dev/null @@ -1,48 +0,0 @@ -/* - * 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 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 deleted file mode 100644 index a9367e3632fa82cb37adf258404bb85507204daf..0000000000000000000000000000000000000000 --- a/app/src/main/java/at/bitfire/davdroid/syncadapter/EeloCalendarsSyncAdapterService.kt +++ /dev/null @@ -1,19 +0,0 @@ -/* - * 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 - -class EeloCalendarsSyncAdapterService : CalendarsSyncAdapterService() diff --git a/app/src/main/java/at/bitfire/davdroid/syncadapter/EeloContactsSyncAdapterService.kt b/app/src/main/java/at/bitfire/davdroid/syncadapter/EeloContactsSyncAdapterService.kt deleted file mode 100644 index 0034ab3d03da088f4b06c2f6e461fce9587efbea..0000000000000000000000000000000000000000 --- a/app/src/main/java/at/bitfire/davdroid/syncadapter/EeloContactsSyncAdapterService.kt +++ /dev/null @@ -1,19 +0,0 @@ -/* - * 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 - -class EeloContactsSyncAdapterService: ContactsSyncAdapterService() diff --git a/app/src/main/java/at/bitfire/davdroid/syncadapter/EeloEmailSyncAdapterService.kt b/app/src/main/java/at/bitfire/davdroid/syncadapter/EeloEmailSyncAdapterService.kt deleted file mode 100644 index 3575ab895f589633341f3e68f888d1da76c81cc0..0000000000000000000000000000000000000000 --- a/app/src/main/java/at/bitfire/davdroid/syncadapter/EeloEmailSyncAdapterService.kt +++ /dev/null @@ -1,19 +0,0 @@ -/* - * 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 - -class EeloEmailSyncAdapterService : EmailSyncAdapterService() diff --git a/app/src/main/java/at/bitfire/davdroid/syncadapter/EeloMediaSyncAdapterService.kt b/app/src/main/java/at/bitfire/davdroid/syncadapter/EeloMediaSyncAdapterService.kt deleted file mode 100644 index 88f0c77244bb1fc1c5d5665a7018493ab21cc487..0000000000000000000000000000000000000000 --- a/app/src/main/java/at/bitfire/davdroid/syncadapter/EeloMediaSyncAdapterService.kt +++ /dev/null @@ -1,48 +0,0 @@ -/* - * 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 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 deleted file mode 100644 index cb49d1acc680b18daf6e230251412d8780145cd0..0000000000000000000000000000000000000000 --- a/app/src/main/java/at/bitfire/davdroid/syncadapter/EeloMeteredEdriveSyncAdapterService.kt +++ /dev/null @@ -1,47 +0,0 @@ -/* - * 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 - } - } -} diff --git a/app/src/main/java/at/bitfire/davdroid/syncadapter/EeloNotesSyncAdapterService.kt b/app/src/main/java/at/bitfire/davdroid/syncadapter/EeloNotesSyncAdapterService.kt deleted file mode 100644 index 91fed22c83797c3d7df027a55be9dfb6b1ec7985..0000000000000000000000000000000000000000 --- a/app/src/main/java/at/bitfire/davdroid/syncadapter/EeloNotesSyncAdapterService.kt +++ /dev/null @@ -1,47 +0,0 @@ -/* - * 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 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/EeloTasksSyncAdapterService.kt b/app/src/main/java/at/bitfire/davdroid/syncadapter/EeloTasksSyncAdapterService.kt deleted file mode 100644 index acd197cd02cb608462cec8eb512f15057da5dd21..0000000000000000000000000000000000000000 --- a/app/src/main/java/at/bitfire/davdroid/syncadapter/EeloTasksSyncAdapterService.kt +++ /dev/null @@ -1,48 +0,0 @@ -/* - * 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 : TasksSyncAdapterService() \ No newline at end of file diff --git a/app/src/main/java/at/bitfire/davdroid/syncadapter/GoogleAddressBooksSyncAdapterService.kt b/app/src/main/java/at/bitfire/davdroid/syncadapter/GoogleAddressBooksSyncAdapterService.kt deleted file mode 100644 index a79e47b2d9645ba8f0ce42e025b761c5d124d636..0000000000000000000000000000000000000000 --- a/app/src/main/java/at/bitfire/davdroid/syncadapter/GoogleAddressBooksSyncAdapterService.kt +++ /dev/null @@ -1,19 +0,0 @@ -/* - * 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 - -class GoogleAddressBooksSyncAdapterService : AddressBooksSyncAdapterService() diff --git a/app/src/main/java/at/bitfire/davdroid/syncadapter/GoogleCalendarsSyncAdapterService.kt b/app/src/main/java/at/bitfire/davdroid/syncadapter/GoogleCalendarsSyncAdapterService.kt deleted file mode 100644 index 4c5745a64658b029e0a3677f64ea0ddc48a1db90..0000000000000000000000000000000000000000 --- a/app/src/main/java/at/bitfire/davdroid/syncadapter/GoogleCalendarsSyncAdapterService.kt +++ /dev/null @@ -1,19 +0,0 @@ -/* - * 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 - -class GoogleCalendarsSyncAdapterService : CalendarsSyncAdapterService() diff --git a/app/src/main/java/at/bitfire/davdroid/syncadapter/GoogleContactsSyncAdapterService.kt b/app/src/main/java/at/bitfire/davdroid/syncadapter/GoogleContactsSyncAdapterService.kt deleted file mode 100644 index 343a2ba3d9a5b78c9211f7009f4e78fad7c0af42..0000000000000000000000000000000000000000 --- a/app/src/main/java/at/bitfire/davdroid/syncadapter/GoogleContactsSyncAdapterService.kt +++ /dev/null @@ -1,19 +0,0 @@ -/* - * 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 - -class GoogleContactsSyncAdapterService : ContactsSyncAdapterService() diff --git a/app/src/main/java/at/bitfire/davdroid/syncadapter/GoogleEmailSyncAdapterService.kt b/app/src/main/java/at/bitfire/davdroid/syncadapter/GoogleEmailSyncAdapterService.kt deleted file mode 100644 index fae1bcdcdb637768e423fa051da4b62221c66d7e..0000000000000000000000000000000000000000 --- a/app/src/main/java/at/bitfire/davdroid/syncadapter/GoogleEmailSyncAdapterService.kt +++ /dev/null @@ -1,19 +0,0 @@ -/* - * 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 - -class GoogleEmailSyncAdapterService : EmailSyncAdapterService() \ No newline at end of file diff --git a/app/src/main/java/at/bitfire/davdroid/syncadapter/GoogleTasksSyncAdapterService.kt b/app/src/main/java/at/bitfire/davdroid/syncadapter/GoogleTasksSyncAdapterService.kt deleted file mode 100644 index d885bf8addc148656f7c258c89f380ab63e36fb4..0000000000000000000000000000000000000000 --- a/app/src/main/java/at/bitfire/davdroid/syncadapter/GoogleTasksSyncAdapterService.kt +++ /dev/null @@ -1,22 +0,0 @@ -/* - * 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 - -/** - * Synchronization manager for CalDAV collections; handles tasks ({@code VTODO}). - */ -class GoogleTasksSyncAdapterService : TasksSyncAdapterService() diff --git a/app/src/main/java/at/bitfire/davdroid/syncadapter/JtxSyncAdapterService.kt b/app/src/main/java/at/bitfire/davdroid/syncadapter/JtxSyncAdapterService.kt deleted file mode 100644 index fb73d94621d030282b55bd9fe9d6ccedfe3cf12c..0000000000000000000000000000000000000000 --- a/app/src/main/java/at/bitfire/davdroid/syncadapter/JtxSyncAdapterService.kt +++ /dev/null @@ -1,114 +0,0 @@ -/*************************************************************************************************** - * 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).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 index d78b3459e383ac3552561cb74d370769c4d029c4..fdd28ce652ae946b906e8cadced7aecdd5086c4f 100644 --- a/app/src/main/java/at/bitfire/davdroid/syncadapter/JtxSyncManager.kt +++ b/app/src/main/java/at/bitfire/davdroid/syncadapter/JtxSyncManager.kt @@ -7,14 +7,13 @@ 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.MultiResponseCallback 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.util.DavUtils +import at.bitfire.davdroid.network.HttpClient import at.bitfire.davdroid.R import at.bitfire.davdroid.db.SyncState import at.bitfire.davdroid.log.Logger @@ -38,7 +37,7 @@ class JtxSyncManager( context: Context, account: Account, accountSettings: AccountSettings, - extras: Bundle, + extras: Array, httpClient: HttpClient, authority: String, syncResult: SyncResult, @@ -47,7 +46,7 @@ class JtxSyncManager( override fun prepare(): Boolean { collectionURL = (localCollection.url ?: return false).toHttpUrlOrNull() ?: return false - davCollection = DavCalendar(httpClient.okHttpClient, collectionURL, accountSettings.credentials().authState?.accessToken) + davCollection = DavCalendar(httpClient.okHttpClient, collectionURL) return true } @@ -106,7 +105,7 @@ class JtxSyncManager( val calendarData = response[CalendarData::class.java] val iCal = calendarData?.iCalendar - ?: throw DavException("Received multi-get response without address data") + ?: throw DavException("Received multi-get response without task data") processICalObject(DavUtils.lastSegmentOfUrl(response.href), eTag, StringReader(iCal)) } @@ -131,28 +130,44 @@ class JtxSyncManager( 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() + Logger.log.log(Level.INFO, "Found ${icalobjects.size} entries in $fileName", icalobjects) + + icalobjects.forEach { jtxICalObject -> + // if the entry is a recurring entry (and therefore has a recurid) + // we udpate the existing (generated) entry + if(jtxICalObject.recurid != null) { + localExceptionContext(localCollection.findRecurring(jtxICalObject.uid, jtxICalObject.recurid!!, jtxICalObject.dtstart!!)) { local -> + Logger.log.log(Level.INFO, "Updating $fileName with recur instance ${jtxICalObject.recurid} in local list", jtxICalObject) + if(local != null) { + local.update(jtxICalObject) + syncResult.stats.numUpdates++ + } else { + localExceptionContext(LocalJtxICalObject(localCollection, fileName, eTag, null, LocalResource.FLAG_REMOTELY_PRESENT)) { + it.applyNewData(jtxICalObject) + it.add() + } + syncResult.stats.numInserts++ + } + } + } else { + // otherwise we insert or update the main entry + localExceptionContext(localCollection.findByName(fileName)) { local -> + if (local != null) { + Logger.log.log(Level.INFO, "Updating $fileName in local list", jtxICalObject) + local.eTag = eTag + local.update(jtxICalObject) + syncResult.stats.numUpdates++ + } else { + Logger.log.log(Level.INFO, "Adding $fileName to local list", jtxICalObject) + + localExceptionContext(LocalJtxICalObject(localCollection, fileName, eTag, null, LocalResource.FLAG_REMOTELY_PRESENT)) { + it.applyNewData(jtxICalObject) + it.add() + } + syncResult.stats.numInserts++ } - 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/JtxSyncer.kt b/app/src/main/java/at/bitfire/davdroid/syncadapter/JtxSyncer.kt new file mode 100644 index 0000000000000000000000000000000000000000..c0868608ef61dbfdc4a8935be491a455ac00d201 --- /dev/null +++ b/app/src/main/java/at/bitfire/davdroid/syncadapter/JtxSyncer.kt @@ -0,0 +1,104 @@ +/*************************************************************************************************** + * 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.Context +import android.content.SyncResult +import android.os.Build +import at.bitfire.davdroid.network.HttpClient +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 + +/** + * Sync logic for jtx board + */ +class JtxSyncer(context: Context): Syncer(context) { + + override fun sync( + account: Account, + extras: Array, + 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) + } + + val accountSettings = AccountSettings(context, account) + + // 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).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) + val owner = info.ownerId?.let { db.principalDao().get(it) } + jtxCollection.updateCollection(info, owner) + // 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) + val owner = info.ownerId?.let { db.principalDao().get(it) } + LocalJtxCollection.create(account, client, info, owner) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/at/bitfire/davdroid/syncadapter/EmailSyncAdapterService.kt b/app/src/main/java/at/bitfire/davdroid/syncadapter/MailSyncer.kt similarity index 57% rename from app/src/main/java/at/bitfire/davdroid/syncadapter/EmailSyncAdapterService.kt rename to app/src/main/java/at/bitfire/davdroid/syncadapter/MailSyncer.kt index 68ea74fb585e48c3555117e0ca2061e272232fd3..69df9f05dd769fc45ac5ea4781d7a708c343ec8b 100644 --- a/app/src/main/java/at/bitfire/davdroid/syncadapter/EmailSyncAdapterService.kt +++ b/app/src/main/java/at/bitfire/davdroid/syncadapter/MailSyncer.kt @@ -20,28 +20,18 @@ 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.MailAccountSyncHelper -import at.bitfire.davdroid.db.AppDatabase +import at.bitfire.davdroid.network.HttpClient -abstract class EmailSyncAdapterService : SyncAdapterService() { - - override fun syncAdapter() = EmailSyncAdapter(this, appDatabase) - - class EmailSyncAdapter( - context: Context, - db: AppDatabase - ) : SyncAdapter(context, db) { - override fun sync( - account: Account, - extras: Bundle, - authority: String, - httpClient: Lazy, - provider: ContentProviderClient, - syncResult: SyncResult - ) { - MailAccountSyncHelper.syncMailAccounts(context.applicationContext) - } +class MailSyncer (context: Context): Syncer(context) { + override fun sync( + account: Account, + extras: Array, + authority: String, + httpClient: Lazy, + provider: ContentProviderClient, + syncResult: SyncResult + ) { + MailAccountSyncHelper.syncMailAccounts(context.applicationContext) } } diff --git a/app/src/main/java/at/bitfire/davdroid/syncadapter/YahooAddressBooksSyncAdapterService.kt b/app/src/main/java/at/bitfire/davdroid/syncadapter/MediaSyncer.kt similarity index 59% rename from app/src/main/java/at/bitfire/davdroid/syncadapter/YahooAddressBooksSyncAdapterService.kt rename to app/src/main/java/at/bitfire/davdroid/syncadapter/MediaSyncer.kt index 623b893671730a884c0af875ef6d49169a3b7043..c64530c3b40173b23762800c3b40f559dd9e5a60 100644 --- a/app/src/main/java/at/bitfire/davdroid/syncadapter/YahooAddressBooksSyncAdapterService.kt +++ b/app/src/main/java/at/bitfire/davdroid/syncadapter/MediaSyncer.kt @@ -16,4 +16,21 @@ package at.bitfire.davdroid.syncadapter -class YahooAddressBooksSyncAdapterService : AddressBooksSyncAdapterService() \ No newline at end of file +import android.accounts.Account +import android.content.ContentProviderClient +import android.content.Context +import android.content.SyncResult +import at.bitfire.davdroid.network.HttpClient + +class MediaSyncer (context: Context): Syncer(context) { + override fun sync( + account: Account, + extras: Array, + 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/YahooEmailSyncAdapterService.kt b/app/src/main/java/at/bitfire/davdroid/syncadapter/MeteredEDriveSyncer.kt similarity index 59% rename from app/src/main/java/at/bitfire/davdroid/syncadapter/YahooEmailSyncAdapterService.kt rename to app/src/main/java/at/bitfire/davdroid/syncadapter/MeteredEDriveSyncer.kt index 0c59b72bcc5f9a5a914145c60c2c12da79a8acf8..c25975ecf641b4cf1590ac5fed7f62819665e194 100644 --- a/app/src/main/java/at/bitfire/davdroid/syncadapter/YahooEmailSyncAdapterService.kt +++ b/app/src/main/java/at/bitfire/davdroid/syncadapter/MeteredEDriveSyncer.kt @@ -16,4 +16,21 @@ package at.bitfire.davdroid.syncadapter -class YahooEmailSyncAdapterService : EmailSyncAdapterService() +import android.accounts.Account +import android.content.ContentProviderClient +import android.content.Context +import android.content.SyncResult +import at.bitfire.davdroid.network.HttpClient + +class MeteredEDriveSyncer (context: Context): Syncer(context) { + override fun sync( + account: Account, + extras: Array, + 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/YahooCalendarsSyncAdapterService.kt b/app/src/main/java/at/bitfire/davdroid/syncadapter/NotesSyncer.kt similarity index 59% rename from app/src/main/java/at/bitfire/davdroid/syncadapter/YahooCalendarsSyncAdapterService.kt rename to app/src/main/java/at/bitfire/davdroid/syncadapter/NotesSyncer.kt index ee69f8a76e8187f3d3ec4d73c6e173f44ac4c38a..656ce6ba7a805208c27f1d2157185b0556ce295e 100644 --- a/app/src/main/java/at/bitfire/davdroid/syncadapter/YahooCalendarsSyncAdapterService.kt +++ b/app/src/main/java/at/bitfire/davdroid/syncadapter/NotesSyncer.kt @@ -16,4 +16,21 @@ package at.bitfire.davdroid.syncadapter -class YahooCalendarsSyncAdapterService : CalendarsSyncAdapterService() +import android.accounts.Account +import android.content.ContentProviderClient +import android.content.Context +import android.content.SyncResult +import at.bitfire.davdroid.network.HttpClient + +class NotesSyncer (context: Context): Syncer(context) { + override fun sync( + account: Account, + extras: Array, + 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/OpenTasksSyncAdapterService.kt b/app/src/main/java/at/bitfire/davdroid/syncadapter/OpenTasksSyncAdapterService.kt deleted file mode 100644 index 29418ea37465f87dc318d03cc596d0b627a17f76..0000000000000000000000000000000000000000 --- a/app/src/main/java/at/bitfire/davdroid/syncadapter/OpenTasksSyncAdapterService.kt +++ /dev/null @@ -1,7 +0,0 @@ -/*************************************************************************************************** - * 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/PeriodicSyncWorker.kt b/app/src/main/java/at/bitfire/davdroid/syncadapter/PeriodicSyncWorker.kt new file mode 100644 index 0000000000000000000000000000000000000000..320fe98675fc642f6f548d18066c32f72722a50e --- /dev/null +++ b/app/src/main/java/at/bitfire/davdroid/syncadapter/PeriodicSyncWorker.kt @@ -0,0 +1,156 @@ +/*************************************************************************************************** + * 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.provider.CalendarContract +import androidx.hilt.work.HiltWorker +import androidx.work.* +import at.bitfire.davdroid.log.Logger +import at.bitfire.davdroid.settings.AccountSettings +import dagger.assisted.Assisted +import dagger.assisted.AssistedInject +import java.util.concurrent.TimeUnit + + +/** + * Handles scheduled sync requests. + * + * Enqueues immediate [SyncWorker] syncs at the appropriate moment. This will prevent the actual + * sync code from running twice simultaneously (for manual and scheduled sync). + * + * For each account there will be multiple dedicated workers running for each authority. + */ +@HiltWorker +class PeriodicSyncWorker @AssistedInject constructor( + @Assisted appContext: Context, + @Assisted workerParams: WorkerParameters +) : Worker(appContext, workerParams) { + + companion object { + + private const val WORKER_TAG = "periodic-sync" + + // Worker input parameters + internal const val ARG_ACCOUNT_NAME = "accountName" + internal const val ARG_ACCOUNT_TYPE = "accountType" + internal const val ARG_AUTHORITY = "authority" + + /** + * Name of this worker. + * Used to distinguish between other work processes. A worker names are unique. There can + * never be two running workers with the same name. + */ + fun workerName(account: Account, authority: String): String = + "$WORKER_TAG $authority ${account.type}/${account.name}" + + /** + * Activate scheduled synchronization of an account with a specific authority. + * + * @param account account to sync + * @param authority authority to sync (for instance: [CalendarContract.AUTHORITY]]) + * @param interval interval between recurring syncs in seconds + * @return operation object to check when and whether activation was successful + */ + fun enable( + context: Context, + account: Account, + authority: String, + interval: Long, + syncWifiOnly: Boolean + ): Operation { + val arguments = Data.Builder() + .putString(ARG_AUTHORITY, authority) + .putString(ARG_ACCOUNT_NAME, account.name) + .putString(ARG_ACCOUNT_TYPE, account.type) + .build() + val constraints = Constraints.Builder() + .setRequiredNetworkType( + if (syncWifiOnly) + NetworkType.UNMETERED + else + NetworkType.CONNECTED + ).build() + val workRequest = + PeriodicWorkRequestBuilder(interval, TimeUnit.SECONDS) + .addTag(WORKER_TAG) + .setInputData(arguments) + .setConstraints(constraints) + .build() + return WorkManager.getInstance(context).enqueueUniquePeriodicWork( + workerName(account, authority), + // if a periodic sync exists already, we want to update it with the new interval + // and/or new required network type (applies on next iteration of periodic worker) + ExistingPeriodicWorkPolicy.UPDATE, + workRequest + ) + } + + /** + * Disables scheduled synchronization of an account for a specific authority. + * + * @param account account to sync + * @param authority authority to sync (for instance: [CalendarContract.AUTHORITY]]) + * @return operation object to check process state of work cancellation + */ + fun disable(context: Context, account: Account, authority: String): Operation = + WorkManager.getInstance(context) + .cancelUniqueWork(workerName(account, authority)) + + /** + * Finds out whether the [PeriodicSyncWorker] is currently enqueued or running + * + * @param account account to check + * @param authority authority to check (for instance: [CalendarContract.AUTHORITY]]) + * @return boolean whether the [PeriodicSyncWorker] is running or enqueued + */ + fun isEnabled(context: Context, account: Account, authority: String): Boolean = + WorkManager.getInstance(context) + .getWorkInfos( + WorkQuery.Builder + .fromUniqueWorkNames(listOf(workerName(account, authority))) + .addStates(listOf(WorkInfo.State.ENQUEUED, WorkInfo.State.RUNNING)) + .build() + ).get() + .isNotEmpty() + + } + + override fun doWork(): Result { + val account = Account( + inputData.getString(ARG_ACCOUNT_NAME) + ?: throw IllegalArgumentException("$ARG_ACCOUNT_NAME required"), + inputData.getString(ARG_ACCOUNT_TYPE) + ?: throw IllegalArgumentException("$ARG_ACCOUNT_TYPE required") + ) + val authority = inputData.getString(ARG_AUTHORITY) + ?: throw IllegalArgumentException("$ARG_AUTHORITY required") + Logger.log.info("Running periodic sync worker: account=$account, authority=$authority") + + if (!isAccountPresent(account.name)) { + Logger.log.info("Account not present anymore. Won't run sync.") + return Result.failure() + } + + val accountSettings = AccountSettings(applicationContext, account) + if (!SyncWorker.wifiConditionsMet(applicationContext, accountSettings)) { + Logger.log.info("Sync conditions not met. Won't run sync.") + return Result.failure() + } + + // Just request immediate sync + Logger.log.info("Requesting immediate sync") + SyncWorker.enqueue(applicationContext, account, authority) + return Result.success() + } + + private fun isAccountPresent(accountName: String): Boolean { + return AccountUtils.getMainAccounts(applicationContext) + .any { account -> account.name == accountName } + } + +} \ 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 deleted file mode 100644 index 969cbd25afc8ab230ecd3301ed99e1f0cfee07c1..0000000000000000000000000000000000000000 --- a/app/src/main/java/at/bitfire/davdroid/syncadapter/SyncAdapterService.kt +++ /dev/null @@ -1,205 +0,0 @@ -/*************************************************************************************************** - * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. - **************************************************************************************************/ - -package at.bitfire.davdroid.syncadapter - -import android.accounts.Account -import android.app.Service -import android.content.* -import android.net.ConnectivityManager -import android.net.NetworkCapabilities -import android.net.wifi.WifiManager -import android.os.Bundle -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.WifiPermissionsActivity -import dagger.hilt.android.AndroidEntryPoint -import java.util.logging.Level -import javax.inject.Inject - -@AndroidEntryPoint -abstract class SyncAdapterService: Service() { - - companion object { - /** - * Specifies an list of IDs which are requested to be synchronized before - * the other collections. For instance, if some calendars of a CalDAV - * account are visible in the calendar app and others are hidden, the visible calendars can - * be synchronized first, so that the "Refresh" action in the calendar app is more responsive. - * - * Extra type: String (comma-separated list of IDs) - * - * In case of calendar sync, the extra value is a list of Android calendar IDs. - * In case of task sync, the extra value is an a list of OpenTask task list IDs. - */ - const val SYNC_EXTRAS_PRIORITY_COLLECTIONS = "priority_collections" - - /** - * Requests a re-synchronization of all entries. For instance, if this extra is - * set for a calendar sync, all remote events will be listed and checked for remote - * changes again. - * - * Useful if settings which modify the remote resource list (like the CalDAV setting - * "sync events n days in the past") have been changed. - */ - const val SYNC_EXTRAS_RESYNC = "resync" - - /** - * Requests a full re-synchronization of all entries. For instance, if this extra is - * set for an address book sync, all contacts will be downloaded again and updated in the - * local storage. - * - * Useful if settings which modify parsing/local behavior have been changed. - */ - const val SYNC_EXTRAS_FULL_RESYNC = "full_resync" - } - - @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, - 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 -> - for (rawId in rawIds.split(',')) - try { - ids += rawId.toLong() - } catch (e: NumberFormatException) { - Logger.log.log(Level.WARNING, "Couldn't parse SYNC_EXTRAS_PRIORITY_COLLECTIONS", e) - } - } - return ids - } - - } - - - abstract fun sync(account: Account, extras: Bundle, authority: String, 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 and account to run simultaneously - val currentSyncKey = Pair(authority, account) - if (ConcurrentUtils.runSingle(currentSyncKey) { - // required for ServiceLoader -> ical4j -> ical4android - Thread.currentThread().contextClassLoader = context.classLoader - - 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 - - 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") - } - - override fun onSyncCanceled() { - Logger.log.info("Sync thread cancelled! Interrupting sync") - super.onSyncCanceled() - } - - 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()!! - - // check for connected WiFi network - var wifiAvailable = false - connectivityManager.allNetworks.forEach { network -> - connectivityManager.getNetworkCapabilities(network)?.let { capabilities -> - if (capabilities.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) && - capabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_VALIDATED)) - wifiAvailable = true - } - } - if (!wifiAvailable) { - Logger.log.info("Not on connected WiFi, stopping") - return false - } - // if execution reaches this point, we're on a connected WiFi - - settings.getSyncWifiOnlySSIDs()?.let { onlySSIDs -> - // 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) - - Logger.log.warning("Can't access WiFi SSID, aborting sync") - return false - } - - 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}), aborting sync") - return false - } else - Logger.log.fine("Connected to WiFi network ${info.ssid}") - } - } - return true - } - - } - -} \ No newline at end of file diff --git a/app/src/main/java/at/bitfire/davdroid/syncadapter/SyncAdapterServices.kt b/app/src/main/java/at/bitfire/davdroid/syncadapter/SyncAdapterServices.kt new file mode 100644 index 0000000000000000000000000000000000000000..1017ba6391c75c61963302b8e04909444499f1e0 --- /dev/null +++ b/app/src/main/java/at/bitfire/davdroid/syncadapter/SyncAdapterServices.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.app.Service +import android.content.AbstractThreadedSyncAdapter +import android.content.ContentProviderClient +import android.content.ContentResolver +import android.content.Context +import android.content.Intent +import android.content.SyncResult +import android.os.Bundle +import androidx.work.WorkManager +import at.bitfire.davdroid.InvalidAccountException +import at.bitfire.davdroid.log.Logger +import at.bitfire.davdroid.settings.AccountSettings +import java.util.logging.Level + +abstract class SyncAdapterService: Service() { + + fun syncAdapter() = SyncAdapter(this) + + override fun onBind(intent: Intent?) = syncAdapter().syncAdapterBinder!! + + /** + * Entry point for the sync adapter framework. + * + * Handles incoming sync requests from the sync adapter framework. + * + * Although we do not use the sync adapter for syncing anymore, we keep this sole + * adapter to provide exported services, which allow android system components and calendar, + * contacts or task apps to sync via DAVx5. + */ + class SyncAdapter( + context: Context + ): 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. + ) { + + override fun onPerformSync(account: Account, extras: Bundle, authority: String, provider: ContentProviderClient, syncResult: SyncResult) { + // We seem to have to pass this old SyncFramework extra for an Android 7 workaround + val upload = extras.containsKey(ContentResolver.SYNC_EXTRAS_UPLOAD) + Logger.log.log(Level.INFO,"Sync request via sync framework (upload=$upload)") + + val accountSettings = try { + AccountSettings(context, account) + } catch (e: InvalidAccountException) { + Logger.log.log(Level.WARNING, "Account doesn't exist anymore", e) + return + } + + // Should we run the sync at all? + if (!SyncWorker.wifiConditionsMet(context, accountSettings)) { + Logger.log.log(Level.INFO, "Sync conditions not met. Aborting sync framework initiated sync") + return + } + + Logger.log.log(Level.FINE, "Sync framework now starting SyncWorker") + val workerName = SyncWorker.enqueue(context, account, authority, upload = upload) + + // Block the onPerformSync method to simulate an ongoing sync + Logger.log.log(Level.FINE,"Blocking sync framework until SyncWorker finishes") + + // Because we are not allowed to observe worker state on a background thread, we can not + // use it to block the sync adapter. Instead we check periodically whether the sync has + // finished, putting the thread to sleep in between checks. + val workManager = WorkManager.getInstance(context) + + while (isWorkerRunning(workManager, workerName)) { + try { + Thread.sleep(5*1000) + } catch (e: InterruptedException) { + Logger.log.log(Level.INFO, "Interrupted while blocking sync framework. Sync may still be running") + break + } + } + + accountSettings.enablePeriodicWorkManager(authority) + + Logger.log.log(Level.INFO,"Returning to sync framework") + } + + override fun onSecurityException(account: Account, extras: Bundle, authority: String, syncResult: SyncResult) { + Logger.log.log(Level.WARNING, "Security exception for $account/$authority") + } + + override fun onSyncCanceled() { + Logger.log.info("Ignoring sync adapter cancellation") + super.onSyncCanceled() + } + + override fun onSyncCanceled(thread: Thread) { + Logger.log.info("Ignoring sync adapter cancellation") + super.onSyncCanceled(thread) + } + + private fun isWorkerRunning(workManager: WorkManager, workName: String): Boolean { + val infoList = workManager.getWorkInfosForUniqueWork(workName).get() + + if (infoList.isNullOrEmpty()) { + return false + } + + for (workInfo in infoList) { + if (workInfo.state.isFinished) { + return false + } + } + + return true + } + } +} + +// exported sync adapter services; we need a separate class for each authority +class AddressBooksSyncAdapterService: SyncAdapterService() +class CalendarsSyncAdapterService: SyncAdapterService() +class ContactsSyncAdapterService: SyncAdapterService() +class JtxSyncAdapterService: SyncAdapterService() +class OpenTasksSyncAdapterService: SyncAdapterService() +class TasksOrgSyncAdapterService: SyncAdapterService() + +class MurenaAddressBooksSyncAdapterService: SyncAdapterService() +class MurenaCalendarsSyncAdapterService: SyncAdapterService() +class MurenaContactsSyncAdapterService: SyncAdapterService() +class MurenaEmailSyncAdapterService: SyncAdapterService() +class MurenaNotesSyncAdapterService: SyncAdapterService() +class MurenaTasksSyncAdapterService: SyncAdapterService() +class MurenaMediaSyncAdapterService: SyncAdapterService() +class MurenaAppDataSyncAdapterService:SyncAdapterService() +class MurenaMeteredEdriveSyncAdapterService: SyncAdapterService() + +class GoogleAddressBooksSyncAdapterService: SyncAdapterService() +class GoogleCalendarsSyncAdapterService: SyncAdapterService() +class GoogleContactsSyncAdapterService: SyncAdapterService() +class GoogleEmailSyncAdapterService: SyncAdapterService() +class GoogleTasksSyncAdapterService: SyncAdapterService() + +class YahooAddressBooksSyncAdapterService: SyncAdapterService() +class YahooCalendarsSyncAdapterService: SyncAdapterService() +class YahooContactsSyncAdapterService: SyncAdapterService() +class YahooEmailSyncAdapterService: SyncAdapterService() +class YahooTasksSyncAdapterService: SyncAdapterService() \ 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 89dc5c0a189609a302cbf9bdefa9d6a7059a4646..f76bd6c6d6f033b8c5090ac309827fc8c1c27141 100644 --- a/app/src/main/java/at/bitfire/davdroid/syncadapter/SyncManager.kt +++ b/app/src/main/java/at/bitfire/davdroid/syncadapter/SyncManager.kt @@ -11,7 +11,6 @@ import android.content.Context import android.content.Intent import android.content.SyncResult import android.net.Uri -import android.os.Bundle import android.os.RemoteException import android.provider.CalendarContract import android.provider.ContactsContract @@ -29,11 +28,13 @@ import at.bitfire.davdroid.db.Credentials import at.bitfire.davdroid.db.SyncState import at.bitfire.davdroid.db.SyncStats import at.bitfire.davdroid.log.Logger +import at.bitfire.davdroid.network.HttpClient import at.bitfire.davdroid.resource.* import at.bitfire.davdroid.settings.AccountSettings import at.bitfire.davdroid.ui.DebugInfoActivity import at.bitfire.davdroid.ui.NetworkUtils import at.bitfire.davdroid.ui.NotificationUtils +import at.bitfire.davdroid.ui.NotificationUtils.notifyIfPossible import at.bitfire.davdroid.ui.account.SettingsActivity import at.bitfire.ical4android.CalendarStorageException import at.bitfire.ical4android.Ical4Android @@ -73,7 +74,7 @@ abstract class SyncManager, out CollectionType: L val account: Account, val accountSettings: AccountSettings, val httpClient: HttpClient, - val extras: Bundle, + val extras: Array, val authority: String, val syncResult: SyncResult, val localCollection: CollectionType @@ -223,7 +224,7 @@ abstract class SyncManager, out CollectionType: L Logger.log.info("Processing local deletes/updates") val modificationsPresent = processLocallyDeleted() || uploadDirty() - if (extras.containsKey(SyncAdapterService.SYNC_EXTRAS_FULL_RESYNC)) { + if (extras.contains(Syncer.SYNC_EXTRAS_FULL_RESYNC)) { Logger.log.info("Forcing re-synchronization of all entries") // forget sync state of collection (→ initial sync in case of SyncAlgorithm.COLLECTION_SYNC) @@ -319,7 +320,7 @@ abstract class SyncManager, out CollectionType: L Logger.log.info("Remote collection didn't change, no reason to sync") }, { e, local, remote -> when (e) { - // sync was cancelled or account has been removed: re-throw to SyncAdapterService + // sync was cancelled or account has been removed: re-throw to SyncAdapterService (now BaseSyncer) is InterruptedException, is InterruptedIOException, is InvalidAccountException -> @@ -338,8 +339,7 @@ abstract class SyncManager, out CollectionType: L is ServiceUnavailableException -> { Logger.log.log(Level.WARNING, "Got 503 Service unavailable, trying again later", e) e.retryAfter?.let { retryAfter -> - // how many seconds to wait? getTime() returns ms, so divide by 1000 - syncResult.delayUntil = (retryAfter.time - Date().time) / 1000 + syncResult.delayUntil = retryAfter.toEpochMilli() } } @@ -427,7 +427,7 @@ abstract class SyncManager, out CollectionType: L val lastETag = if (lastScheduleTag == null) local.eTag else null Logger.log.info("$fileName has been deleted locally -> deleting from server (ETag $lastETag / schedule-tag $lastScheduleTag)") - remoteExceptionContext(DavResource(httpClient.okHttpClient, collectionURL.newBuilder().addPathSegment(fileName).build(), accountSettings.credentials().authState?.accessToken)) { remote -> + remoteExceptionContext(DavResource(httpClient.okHttpClient, collectionURL.newBuilder().addPathSegment(fileName).build())) { remote -> try { remote.delete(ifETag = lastETag, ifScheduleTag = lastScheduleTag) {} numDeleted++ @@ -487,7 +487,7 @@ abstract class SyncManager, out CollectionType: L newFileName = local.prepareForUpload() val uploadUrl = collectionURL.newBuilder().addPathSegment(newFileName).build() - remoteExceptionContext(DavResource(httpClient.okHttpClient, uploadUrl, accountSettings.credentials().authState?.accessToken)) { remote -> + remoteExceptionContext(DavResource(httpClient.okHttpClient, uploadUrl)) { remote -> Logger.log.info("Uploading new record ${local.id} -> $newFileName") remote.put(generateUpload(local), ifNoneMatch = true, callback = readTagsFromResponse) } @@ -496,7 +496,7 @@ abstract class SyncManager, out CollectionType: L local.prepareForUpload() val uploadUrl = collectionURL.newBuilder().addPathSegment(existingFileName).build() - remoteExceptionContext(DavResource(httpClient.okHttpClient, uploadUrl, accountSettings.credentials().authState?.accessToken)) { remote -> + 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)") @@ -566,8 +566,8 @@ abstract class SyncManager, out CollectionType: L * [uploadDirty] were true), a sync is always required and this method * should *not* be evaluated. * - * Will return _true_ if [SyncAdapterService.SYNC_EXTRAS_RESYNC] and/or - * [SyncAdapterService.SYNC_EXTRAS_FULL_RESYNC] is set in [extras]. + * Will return _true_ if [Syncer.SYNC_EXTRAS_RESYNC] and/or + * [Syncer.SYNC_EXTRAS_FULL_RESYNC] is set in [extras]. * * @param state remote sync state to compare local sync state with * @@ -575,8 +575,8 @@ abstract class SyncManager, out CollectionType: L * sync algorithm is required */ protected open fun syncRequired(state: SyncState?): Boolean { - if (extras.containsKey(SyncAdapterService.SYNC_EXTRAS_RESYNC) || - extras.containsKey(SyncAdapterService.SYNC_EXTRAS_FULL_RESYNC)) + if (extras.contains(Syncer.SYNC_EXTRAS_RESYNC) || + extras.contains(Syncer.SYNC_EXTRAS_FULL_RESYNC)) return true val localState = localCollection.lastSyncState @@ -887,7 +887,7 @@ abstract class SyncManager, out CollectionType: L .setCategory(NotificationCompat.CATEGORY_ERROR) viewItemAction?.let { builder.addAction(it) } - notificationManager.notify(notificationTag, NotificationUtils.NOTIFY_SYNC_ERROR, builder.build()) + notificationManager.notifyIfPossible(notificationTag, NotificationUtils.NOTIFY_SYNC_ERROR, builder.build()) } private fun buildDebugInfoIntent(e: Throwable, local: ResourceType?, remote: HttpUrl?) = @@ -939,7 +939,7 @@ abstract class SyncManager, out CollectionType: L .setAutoCancel(true) .setOnlyAlertOnce(true) .priority = NotificationCompat.PRIORITY_LOW - notificationManager.notify(notificationTag, NotificationUtils.NOTIFY_INVALID_RESOURCE, builder.build()) + notificationManager.notifyIfPossible(notificationTag, NotificationUtils.NOTIFY_INVALID_RESOURCE, builder.build()) } protected abstract fun notifyInvalidResourceTitle(): String diff --git a/app/src/main/java/at/bitfire/davdroid/syncadapter/SyncUtils.kt b/app/src/main/java/at/bitfire/davdroid/syncadapter/SyncUtils.kt index d797ef509cf8f6ba172d99ae32f4568d1ddeb61f..52a24aa9c41e1c8c1ad504401cf0763ae6c6c80d 100644 --- a/app/src/main/java/at/bitfire/davdroid/syncadapter/SyncUtils.kt +++ b/app/src/main/java/at/bitfire/davdroid/syncadapter/SyncUtils.kt @@ -14,24 +14,23 @@ import android.content.pm.PackageManager import android.graphics.drawable.BitmapDrawable import android.net.Uri import android.os.Build -import android.view.MenuItem -import androidx.annotation.StringRes +import android.os.Bundle +import android.provider.CalendarContract import androidx.annotation.WorkerThread import androidx.core.app.NotificationCompat import androidx.core.app.NotificationManagerCompat import at.bitfire.davdroid.Constants -import at.bitfire.davdroid.DavUtils 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.davdroid.ui.NotificationUtils.notifyIfPossible +import at.bitfire.davdroid.util.PermissionUtils import at.bitfire.ical4android.TaskProvider import dagger.hilt.EntryPoint import dagger.hilt.InstallIn @@ -39,6 +38,9 @@ import dagger.hilt.android.EntryPointAccessors import dagger.hilt.components.SingletonComponent import java.util.logging.Level +/** + * Utility methods related to synchronization management (authorities, workers etc.) + */ object SyncUtils { @EntryPoint @@ -85,15 +87,32 @@ object SyncUtils { if (intent.resolveActivity(pm) != null) notify.setContentIntent(PendingIntent.getActivity(context, 0, intent, flags)) - nm.notify(NotificationUtils.NOTIFY_TASKS_PROVIDER_TOO_OLD, notify.build()) + nm.notifyIfPossible(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) + /** + * 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 } - // task sync utils @WorkerThread @@ -148,7 +167,7 @@ object SyncUtils { ContentResolver.setIsSyncable(account, authority, 1) try { val settings = AccountSettings(context, account) - val interval = settings.getSavedTasksSyncInterval() ?: Constants.DEFAULT_CALENDAR_SYNC_INTERVAL + val interval = settings.getTasksSyncInterval() ?: Constants.DEFAULT_CALENDAR_SYNC_INTERVAL settings.setSyncInterval(authority, interval) } catch (e: InvalidAccountException) { // account has already been removed @@ -167,9 +186,10 @@ object SyncUtils { } accounts.forEach { - DavUtils.requestSync(context, it) + SyncWorker.enqueueAllAuthorities(context, it) } return true } + } diff --git a/app/src/main/java/at/bitfire/davdroid/syncadapter/SyncWorker.kt b/app/src/main/java/at/bitfire/davdroid/syncadapter/SyncWorker.kt new file mode 100644 index 0000000000000000000000000000000000000000..1d456f9b391b856789989139525e8e963855447d --- /dev/null +++ b/app/src/main/java/at/bitfire/davdroid/syncadapter/SyncWorker.kt @@ -0,0 +1,459 @@ +/*************************************************************************************************** + * 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.ContentProviderClient +import android.content.ContentResolver +import android.content.Context +import android.content.Intent +import android.content.SyncResult +import android.net.ConnectivityManager +import android.net.NetworkCapabilities +import android.net.wifi.WifiManager +import android.os.Build +import android.provider.CalendarContract +import android.provider.ContactsContract +import androidx.annotation.IntDef +import androidx.annotation.RequiresApi +import androidx.concurrent.futures.CallbackToFutureAdapter +import androidx.core.app.NotificationCompat +import androidx.core.app.NotificationManagerCompat +import androidx.core.content.getSystemService +import androidx.hilt.work.HiltWorker +import androidx.lifecycle.LiveData +import androidx.lifecycle.map +import androidx.work.BackoffPolicy +import androidx.work.Constraints +import androidx.work.Data +import androidx.work.ExistingWorkPolicy +import androidx.work.ForegroundInfo +import androidx.work.NetworkType +import androidx.work.OneTimeWorkRequestBuilder +import androidx.work.OutOfQuotaPolicy +import androidx.work.WorkInfo +import androidx.work.WorkManager +import androidx.work.WorkQuery +import androidx.work.WorkRequest +import androidx.work.Worker +import androidx.work.WorkerParameters +import at.bitfire.davdroid.R +import at.bitfire.davdroid.log.Logger +import at.bitfire.davdroid.settings.AccountSettings +import at.bitfire.davdroid.ui.NotificationUtils +import at.bitfire.davdroid.ui.NotificationUtils.notifyIfPossible +import at.bitfire.davdroid.ui.account.WifiPermissionsActivity +import at.bitfire.davdroid.util.PermissionUtils +import at.bitfire.davdroid.util.closeCompat +import at.bitfire.ical4android.TaskProvider +import com.google.common.util.concurrent.ListenableFuture +import dagger.assisted.Assisted +import dagger.assisted.AssistedInject +import java.util.concurrent.TimeUnit +import java.util.logging.Level + +/** + * Handles immediate sync requests, status queries and cancellation for one or multiple authorities + */ +@HiltWorker +class SyncWorker @AssistedInject constructor( + @Assisted appContext: Context, + @Assisted workerParams: WorkerParameters +) : Worker(appContext, workerParams) { + + companion object { + + // Worker input parameters + internal const val ARG_ACCOUNT_NAME = "accountName" + internal const val ARG_ACCOUNT_TYPE = "accountType" + internal const val ARG_AUTHORITY = "authority" + + private const val ARG_UPLOAD = "upload" + + private const val ARG_RESYNC = "resync" + @IntDef(NO_RESYNC, RESYNC, FULL_RESYNC) + annotation class ArgResync + const val NO_RESYNC = 0 + const val RESYNC = 1 + const val FULL_RESYNC = 2 + + // This SyncWorker's tag + const val TAG_SYNC = "sync" + + /** + * How often this work will be retried to run after soft (network) errors. + * + * Retry strategy is defined in work request ([enqueue]). + */ + internal const val MAX_RUN_ATTEMPTS = 5 + + /** + * Name of this worker. + * Used to distinguish between other work processes. There must only ever be one worker with the exact same name. + */ + fun workerName(account: Account, authority: String) = + "$TAG_SYNC $authority ${account.type}/${account.name}" + + /** + * Requests immediate synchronization of an account with all applicable + * authorities (contacts, calendars, …). + * + * @see enqueue + */ + fun enqueueAllAuthorities( + context: Context, + account: Account, + @ArgResync resync: Int = NO_RESYNC, + upload: Boolean = false + ) { + for (authority in SyncUtils.syncAuthorities(context)) + enqueue(context, account, authority, resync, upload) + } + + /** + * Requests immediate synchronization of an account with a specific authority. + * + * @param account account to sync + * @param authority authority to sync (for instance: [CalendarContract.AUTHORITY]) + * @param resync whether to request (full) re-synchronization or not + * @param upload see [ContentResolver.SYNC_EXTRAS_UPLOAD] used only for contacts sync + * and android 7 workaround + * @return existing or newly created worker name + */ + fun enqueue( + context: Context, + account: Account, + authority: String, + @ArgResync resync: Int = NO_RESYNC, + upload: Boolean = false + ): String { + // Worker arguments + val argumentsBuilder = Data.Builder() + .putString(ARG_AUTHORITY, authority) + .putString(ARG_ACCOUNT_NAME, account.name) + .putString(ARG_ACCOUNT_TYPE, account.type) + if (resync != NO_RESYNC) + argumentsBuilder.putInt(ARG_RESYNC, resync) + argumentsBuilder.putBoolean(ARG_UPLOAD, upload) + + // build work request + val constraints = Constraints.Builder() + .setRequiredNetworkType(NetworkType.CONNECTED) // require a network connection + .build() + val workRequest = OneTimeWorkRequestBuilder() + .addTag(TAG_SYNC) + .setInputData(argumentsBuilder.build()) + .setExpedited(OutOfQuotaPolicy.RUN_AS_NON_EXPEDITED_WORK_REQUEST) + .setBackoffCriteria( + BackoffPolicy.EXPONENTIAL, + WorkRequest.MIN_BACKOFF_MILLIS, + TimeUnit.MILLISECONDS + ) + .setConstraints(constraints) + .build() + + // enqueue and start syncing + val name = workerName(account, authority) + Logger.log.log(Level.INFO, "Enqueueing unique worker: $name") + WorkManager.getInstance(context).enqueueUniqueWork( + name, + ExistingWorkPolicy.KEEP, // If sync is already running, just continue. + // Existing retried work will not be replaced (for instance when + // PeriodicSyncWorker enqueues another scheduled sync). + workRequest + ) + return name + } + + /** + * Stops running sync worker or removes pending sync from queue, for all authorities. + */ + fun cancelSync(context: Context, account: Account) { + for (authority in SyncUtils.syncAuthorities(context)) + WorkManager.getInstance(context).cancelUniqueWork(workerName(account, authority)) + } + + /** + * Will tell whether >0 [SyncWorker] exists, belonging to given account and authorities, + * and which are/is in the given worker state. + * + * @param workStates list of states of workers to match + * @param account the account which the workers belong to + * @param authorities type of sync work, ie [CalendarContract.AUTHORITY] + * @return *true* if at least one worker with matching query was found; *false* otherwise + */ + fun exists( + context: Context, + workStates: List, + account: Account? = null, + authorities: List? = null + ): LiveData { + val workQuery = WorkQuery.Builder + .fromTags(listOf(TAG_SYNC)) + .addStates(workStates) + if (account != null && authorities != null) + workQuery.addUniqueWorkNames( + authorities.map { authority -> workerName(account, authority) } + ) + return WorkManager.getInstance(context) + .getWorkInfosLiveData(workQuery.build()).map { workInfoList -> + workInfoList.isNotEmpty() + } + } + + + /** + * Checks whether user imposed sync conditions from settings are met: + * - Sync only on WiFi? + * - Sync only on specific WiFi (SSID)? + * + * @param accountSettings Account settings of the account to check (and is to be synced) + * @return *true* if conditions are met; *false* if not + */ + internal fun wifiConditionsMet(context: Context, accountSettings: AccountSettings): Boolean { + // May we sync without WiFi? + if (!accountSettings.getSyncWifiOnly()) + return true // yes, continue + + // WiFi required, is it available? + if (!wifiAvailable(context)) { + Logger.log.info("Not on connected WiFi, stopping") + return false + } + // If execution reaches this point, we're on a connected WiFi + + // Check whether we are connected to the correct WiFi (in case SSID was provided) + return correctWifiSsid(context, accountSettings) + } + + /** + * Checks whether we are connected to working WiFi + */ + internal fun wifiAvailable(context: Context): Boolean { + val connectivityManager = context.getSystemService()!! + connectivityManager.allNetworks.forEach { network -> + connectivityManager.getNetworkCapabilities(network)?.let { capabilities -> + if (capabilities.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) && + capabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_VALIDATED)) + return true + } + } + return false + } + + /** + * Checks whether we are connected to the correct wifi (SSID) defined by user in the + * account settings. + * + * Note: Should be connected to some wifi before calling. + * + * @param accountSettings Settings of account to check + * @return *true* if connected to the correct wifi OR no wifi names were specified in + * account settings; *false* otherwise + */ + internal fun correctWifiSsid(context: Context, accountSettings: AccountSettings): Boolean { + accountSettings.getSyncWifiOnlySSIDs()?.let { onlySSIDs -> + // 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, accountSettings.account) + PermissionUtils.notifyPermissions(context, intent) + + Logger.log.warning("Can't access WiFi SSID, aborting sync") + return false + } + + 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}), aborting sync") + return false + } + Logger.log.fine("Connected to WiFi network ${info.ssid}") + } + return true + } + } + + private val notificationManager = NotificationManagerCompat.from(applicationContext) + + /** thread which runs the actual sync code (can be interrupted to stop synchronization) */ + var syncThread: Thread? = null + + override fun doWork(): Result { + + // Check internet connection. This is especially important on API 26+ where when a VPN is used, + // WorkManager may start the SyncWorker without a working underlying Internet connection. + if (Build.VERSION.SDK_INT >= 23 && !internetAvailable(applicationContext)) { + Logger.log.info("WorkManager started SyncWorker without Internet connection. Aborting.") + return Result.failure() + } + + // ensure we got the required arguments + val account = Account( + inputData.getString(ARG_ACCOUNT_NAME) ?: throw IllegalArgumentException("$ARG_ACCOUNT_NAME required"), + inputData.getString(ARG_ACCOUNT_TYPE) ?: throw IllegalArgumentException("$ARG_ACCOUNT_TYPE required") + ) + val authority = inputData.getString(ARG_AUTHORITY) ?: throw IllegalArgumentException("$ARG_AUTHORITY required") + Logger.log.info("Running sync worker: account=$account, authority=$authority") + + if (ContentResolver.getIsSyncable(account, authority) <= 0) { + return Result.success() + } + + // What are we going to sync? Select syncer based on authority + val syncer: Syncer = when (authority) { + applicationContext.getString(R.string.address_books_authority) -> + AddressBookSyncer(applicationContext) + CalendarContract.AUTHORITY -> + CalendarSyncer(applicationContext) + ContactsContract.AUTHORITY -> + ContactSyncer(applicationContext) + TaskProvider.ProviderName.JtxBoard.authority -> + JtxSyncer(applicationContext) + applicationContext.getString(R.string.task_authority), + TaskProvider.ProviderName.OpenTasks.authority, + TaskProvider.ProviderName.TasksOrg.authority -> + TaskSyncer(applicationContext) + applicationContext.getString(R.string.notes_authority) -> + NotesSyncer(applicationContext) + applicationContext.getString(R.string.email_authority) -> + MailSyncer(applicationContext) + applicationContext.getString(R.string.media_authority) -> + MediaSyncer(applicationContext) + applicationContext.getString(R.string.app_data_authority) -> + AppDataSyncer(applicationContext) + applicationContext.getString(R.string.metered_edrive_authority) -> + MeteredEDriveSyncer(applicationContext) + else -> + throw IllegalArgumentException("Invalid authority $authority") + } + + // pass possibly supplied flags to the selected syncer + val extras = mutableListOf() + when (inputData.getInt(ARG_RESYNC, NO_RESYNC)) { + RESYNC -> extras.add(Syncer.SYNC_EXTRAS_RESYNC) + FULL_RESYNC -> extras.add(Syncer.SYNC_EXTRAS_FULL_RESYNC) + } + if (inputData.getBoolean(ARG_UPLOAD, false)) + // Comes in through SyncAdapterService and is used only by ContactsSyncManager for an Android 7 workaround. + extras.add(ContentResolver.SYNC_EXTRAS_UPLOAD) + + // acquire ContentProviderClient of authority to be synced + val provider: ContentProviderClient? = + try { + applicationContext.contentResolver.acquireContentProviderClient(authority) + } catch (e: SecurityException) { + Logger.log.log(Level.WARNING, "Missing permissions to acquire ContentProviderClient for $authority", e) + null + } + if (provider == null) { + Logger.log.warning("Couldn't acquire ContentProviderClient for $authority") + return Result.failure() + } + + // Start syncing. We still use the sync adapter framework's SyncResult to pass the sync results, but this + // is only for legacy reasons and can be replaced by an own result class in the future. + val result = SyncResult() + try { + syncThread = Thread.currentThread() + syncer.onPerformSync(account, extras.toTypedArray(), authority, provider, result) + } catch (e: SecurityException) { + Logger.log.log(Level.WARNING, "Security exception when opening content provider for $authority") + } finally { + provider.closeCompat() + } + + // Check for errors + if (result.hasError()) { + val syncResult = Data.Builder() + .putString("syncresult", result.toString()) + .putString("syncResultStats", result.stats.toString()) + .build() + + // On soft errors the sync is retried a few times before considered failed + if (result.hasSoftError()) { + Logger.log.warning("Soft error while syncing: result=$result, stats=${result.stats}") + if (runAttemptCount < MAX_RUN_ATTEMPTS) { + Logger.log.warning("Retrying on soft error (attempt $runAttemptCount of $MAX_RUN_ATTEMPTS)") + return Result.retry() + } + + Logger.log.warning("Max retries on soft errors reached ($runAttemptCount of $MAX_RUN_ATTEMPTS). Treating as failed") + + notificationManager.notifyIfPossible( + NotificationUtils.NOTIFY_SYNC_ERROR, + NotificationUtils.newBuilder(applicationContext, NotificationUtils.CHANNEL_SYNC_IO_ERRORS) + .setSmallIcon(R.drawable.ic_sync_problem_notify) + .setContentTitle(account.name) + .setContentText(applicationContext.getString(R.string.sync_error_retry_limit_reached)) + .setSubText(account.name) + .setOnlyAlertOnce(true) + .setPriority(NotificationCompat.PRIORITY_DEFAULT) + .setCategory(NotificationCompat.CATEGORY_ERROR) + .build() + ) + + return Result.failure(syncResult) + } + + // On a hard error - fail with an error message + // Note: SyncManager should have notified the user + if (result.hasHardError()) { + Logger.log.warning("Hard error while syncing: result=$result, stats=${result.stats}") + return Result.failure(syncResult) + } + } + + return Result.success() + } + + /** + * Checks whether we are connected to the internet. + * + * On API 26+ devices, when a VPN is used, WorkManager might start the SyncWorker without an + * internet connection. To prevent this we do an extra check at the start of doWork() with this + * method. + * + * Every VPN connection also has an underlying non-vpn connection, which we find with + * [NetworkCapabilities.NET_CAPABILITY_NOT_VPN] and then check if that has validated internet + * access or not, using [NetworkCapabilities.NET_CAPABILITY_VALIDATED]. + * + * @return whether we are connected to the internet + */ + @RequiresApi(23) + private fun internetAvailable(context: Context): Boolean { + val connectivityManager = context.getSystemService()!! + return connectivityManager.allNetworks.any { network -> + connectivityManager.getNetworkCapabilities(network)?.let { capabilities -> + capabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_NOT_VPN) // filter out VPNs + && capabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_VALIDATED) + && capabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) + } ?: false + } + } + + override fun onStopped() { + Logger.log.info("Stopping sync thread") + syncThread?.interrupt() + } + + override fun getForegroundInfoAsync(): ListenableFuture = + CallbackToFutureAdapter.getFuture { completer -> + val notification = NotificationUtils.newBuilder(applicationContext, NotificationUtils.CHANNEL_STATUS) + .setSmallIcon(R.drawable.ic_foreground_notify) + .setContentTitle(applicationContext.getString(R.string.foreground_service_notify_title)) + .setContentText(applicationContext.getString(R.string.foreground_service_notify_text)) + .setStyle(NotificationCompat.BigTextStyle()) + .setCategory(NotificationCompat.CATEGORY_STATUS) + .setOngoing(true) + .setPriority(NotificationCompat.PRIORITY_LOW) + .build() + completer.set(ForegroundInfo(NotificationUtils.NOTIFY_SYNC_EXPEDITED, notification)) + } + +} \ No newline at end of file diff --git a/app/src/main/java/at/bitfire/davdroid/syncadapter/Syncer.kt b/app/src/main/java/at/bitfire/davdroid/syncadapter/Syncer.kt new file mode 100644 index 0000000000000000000000000000000000000000..82101cdc4e6b32f1c31e5dd23632c811529ca0ef --- /dev/null +++ b/app/src/main/java/at/bitfire/davdroid/syncadapter/Syncer.kt @@ -0,0 +1,96 @@ +/*************************************************************************************************** + * 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.ContentProviderClient +import android.content.Context +import android.content.SyncResult +import at.bitfire.davdroid.network.HttpClient +import at.bitfire.davdroid.InvalidAccountException +import at.bitfire.davdroid.db.AppDatabase +import at.bitfire.davdroid.log.Logger +import at.bitfire.davdroid.settings.AccountSettings +import dagger.hilt.EntryPoint +import dagger.hilt.InstallIn +import dagger.hilt.android.EntryPointAccessors +import dagger.hilt.components.SingletonComponent +import java.util.* +import java.util.logging.Level + +/** + * Base class for sync code. + * + * Contains generic sync code, equal for all sync authorities, checks sync conditions and does + * validation. + * + * Also provides useful methods that can be used by derived syncers ie [CalendarSyncer], etc. + */ +abstract class Syncer(val context: Context) { + + companion object { + + /** + * Requests a re-synchronization of all entries. For instance, if this extra is + * set for a calendar sync, all remote events will be listed and checked for remote + * changes again. + * + * Useful if settings which modify the remote resource list (like the CalDAV setting + * "sync events n days in the past") have been changed. + */ + const val SYNC_EXTRAS_RESYNC = "resync" + + /** + * Requests a full re-synchronization of all entries. For instance, if this extra is + * set for an address book sync, all contacts will be downloaded again and updated in the + * local storage. + * + * Useful if settings which modify parsing/local behavior have been changed. + */ + const val SYNC_EXTRAS_FULL_RESYNC = "full_resync" + + } + + @EntryPoint + @InstallIn(SingletonComponent::class) + interface SyncAdapterEntryPoint { + fun appDatabase(): AppDatabase + } + + private val syncAdapterEntryPoint = EntryPointAccessors.fromApplication(context, SyncAdapterEntryPoint::class.java) + internal val db = syncAdapterEntryPoint.appDatabase() + + + abstract fun sync(account: Account, extras: Array, authority: String, httpClient: Lazy, provider: ContentProviderClient, syncResult: SyncResult) + + fun onPerformSync( + account: Account, + extras: Array, + authority: String, + provider: ContentProviderClient, + syncResult: SyncResult + ) { + Logger.log.log(Level.INFO, "$authority sync of $account initiated", extras.joinToString(", ")) + + val accountSettings by lazy { AccountSettings(context, account) } + val httpClient = lazy { HttpClient.Builder(context, accountSettings).build() } + + try { + val runSync = true /* ose */ + 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, + "$authority sync of $account finished", + extras.joinToString(", ")) + } + } + +} diff --git a/app/src/main/java/at/bitfire/davdroid/syncadapter/TaskSyncer.kt b/app/src/main/java/at/bitfire/davdroid/syncadapter/TaskSyncer.kt new file mode 100644 index 0000000000000000000000000000000000000000..e1a40c22f964c4b55fb074295508a13442e96a0d --- /dev/null +++ b/app/src/main/java/at/bitfire/davdroid/syncadapter/TaskSyncer.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.content.ContentProviderClient +import android.content.Context +import android.content.SyncResult +import android.os.Build +import at.bitfire.davdroid.network.HttpClient +import at.bitfire.davdroid.db.Collection +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 okhttp3.HttpUrl +import okhttp3.HttpUrl.Companion.toHttpUrl +import org.dmfs.tasks.contract.TaskContract +import java.util.logging.Level + +/** + * Sync logic for tasks in CalDAV collections ({@code VTODO}). + */ +class TaskSyncer(context: Context): Syncer(context) { + + override fun sync( + account: Account, + extras: Array, + 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 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) + + updateLocalTaskLists(taskProvider, account, accountSettings) + + val taskLists = AndroidTaskList + .find(account, taskProvider, LocalTaskList.Factory, "${TaskContract.TaskLists.SYNC_ENABLED}!=0", null) + for (taskList in taskLists) { + Logger.log.info("Synchronizing task list #${taskList.id} [${taskList.syncId}]") + TasksSyncManager(context, account, accountSettings, httpClient.value, extras, authority, syncResult, taskList).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.toHttpUrl() + 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) + } + } +} \ 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 deleted file mode 100644 index 0a387ae938c594fb1ced68e68e042eb62d837ed6..0000000000000000000000000000000000000000 --- a/app/src/main/java/at/bitfire/davdroid/syncadapter/TasksOrgSyncAdapterService.kt +++ /dev/null @@ -1,7 +0,0 @@ -/*************************************************************************************************** - * 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 deleted file mode 100644 index c9d10c372916e4488c3592730f492f65935953ea..0000000000000000000000000000000000000000 --- a/app/src/main/java/at/bitfire/davdroid/syncadapter/TasksSyncAdapterService.kt +++ /dev/null @@ -1,126 +0,0 @@ -/*************************************************************************************************** - * 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.LocalTaskList -import at.bitfire.davdroid.settings.AccountSettings -import at.bitfire.ical4android.AndroidTaskList -import at.bitfire.ical4android.TaskProvider -import android.os.AsyncTask -import at.bitfire.davdroid.OpenIdUtils -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}). - */ -open class TasksSyncAdapterService: 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 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 - - 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).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.toHttpUrl() - 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) - } - } - - } - -} \ 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 b1bbe4e8efcb5728a3c7e9b92dd7c59856f1a77d..d35afeaa896a867a91b85585f9014c426be99c6b 100644 --- a/app/src/main/java/at/bitfire/davdroid/syncadapter/TasksSyncManager.kt +++ b/app/src/main/java/at/bitfire/davdroid/syncadapter/TasksSyncManager.kt @@ -7,14 +7,13 @@ 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.MultiResponseCallback 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.util.DavUtils +import at.bitfire.davdroid.network.HttpClient import at.bitfire.davdroid.R import at.bitfire.davdroid.db.SyncState import at.bitfire.davdroid.log.Logger @@ -42,7 +41,7 @@ class TasksSyncManager( account: Account, accountSettings: AccountSettings, httpClient: HttpClient, - extras: Bundle, + extras: Array, authority: String, syncResult: SyncResult, localCollection: LocalTaskList @@ -50,7 +49,7 @@ class TasksSyncManager( override fun prepare(): Boolean { collectionURL = (localCollection.syncId ?: return false).toHttpUrlOrNull() ?: return false - davCollection = DavCalendar(httpClient.okHttpClient, collectionURL, accountSettings.credentials().authState?.accessToken) + davCollection = DavCalendar(httpClient.okHttpClient, collectionURL) return true } @@ -106,7 +105,7 @@ class TasksSyncManager( val calendarData = response[CalendarData::class.java] val iCal = calendarData?.iCalendar - ?: throw DavException("Received multi-get response without address data") + ?: throw DavException("Received multi-get response without task data") processVTodo(DavUtils.lastSegmentOfUrl(response.href), eTag, StringReader(iCal)) } diff --git a/app/src/main/java/at/bitfire/davdroid/syncadapter/YahooTasksSyncAdapterService.kt b/app/src/main/java/at/bitfire/davdroid/syncadapter/YahooTasksSyncAdapterService.kt deleted file mode 100644 index f48a51507f11b311b4d34d62060c08249af0504b..0000000000000000000000000000000000000000 --- a/app/src/main/java/at/bitfire/davdroid/syncadapter/YahooTasksSyncAdapterService.kt +++ /dev/null @@ -1,19 +0,0 @@ -/* - * Copyright MURENA SAS 2023 - * 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 - -class YahooTasksSyncAdapterService : TasksSyncAdapterService() 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 036a648cdfdc7733e6d1f569a4ea722629639fd6..85b8ec30bc19565b863adac59388715b4e35b41e 100644 --- a/app/src/main/java/at/bitfire/davdroid/ui/AboutActivity.kt +++ b/app/src/main/java/at/bitfire/davdroid/ui/AboutActivity.kt @@ -14,6 +14,9 @@ import android.util.DisplayMetrics import android.view.* import androidx.annotation.UiThread import androidx.appcompat.app.AppCompatActivity +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.ComposeView import androidx.core.text.HtmlCompat import androidx.fragment.app.Fragment import androidx.fragment.app.FragmentManager @@ -32,8 +35,8 @@ 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 com.google.accompanist.themeadapter.material.MdcTheme +import com.mikepenz.aboutlibraries.ui.compose.LibrariesContainer import dagger.BindsOptionalOf import dagger.Module import dagger.hilt.InstallIn @@ -88,10 +91,10 @@ class AboutActivity: AppCompatActivity() { private inner class TabsAdapter( - fm: FragmentManager + fm: FragmentManager ): FragmentPagerAdapter(fm, BEHAVIOR_RESUME_ONLY_CURRENT_FRAGMENT) { - override fun getCount() = 3 + override fun getCount() = 2 override fun getPageTitle(position: Int): String = when (position) { @@ -104,18 +107,7 @@ class AboutActivity: AppCompatActivity() { when (position) { 0 -> AppFragment() 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() - } + else -> LibsFragment() } } @@ -133,7 +125,7 @@ class AboutActivity: AppCompatActivity() { } @AndroidEntryPoint - class AppFragment: Fragment() { + class AppFragment : Fragment() { private var _binding: AboutBinding? = null private val binding get() = _binding!! @@ -233,6 +225,21 @@ class AboutActivity: AppCompatActivity() { } + class LibsFragment : Fragment() { + + override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?) = + ComposeView(requireContext()).apply { + setContent { + MdcTheme { + LibrariesContainer( + Modifier.fillMaxSize() + ) + } + } + } + + } + open class TextFileModel( application: Application 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 58d26a2f355d17a55551dfa12f776e7f40861647..45a49472144c86fe4c586860206e95e5145c6aa4 100644 --- a/app/src/main/java/at/bitfire/davdroid/ui/AccountListFragment.kt +++ b/app/src/main/java/at/bitfire/davdroid/ui/AccountListFragment.kt @@ -11,8 +11,8 @@ import android.accounts.OnAccountsUpdateListener import android.app.Activity import android.app.Application import android.content.ContentResolver +import android.content.Context import android.content.Intent -import android.content.SyncStatusObserver import android.content.pm.PackageManager import android.net.Uri import android.os.Build @@ -23,21 +23,27 @@ import android.view.Menu import android.view.MenuInflater import android.view.View import android.view.ViewGroup +import androidx.annotation.AnyThread import androidx.core.content.ContextCompat import androidx.fragment.app.Fragment import androidx.fragment.app.viewModels import androidx.lifecycle.AndroidViewModel +import androidx.lifecycle.MediatorLiveData import androidx.lifecycle.MutableLiveData 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 androidx.work.WorkInfo +import androidx.work.WorkManager +import androidx.work.WorkQuery import at.bitfire.davdroid.R import at.bitfire.davdroid.databinding.AccountListBinding import at.bitfire.davdroid.databinding.AccountListItemBinding import at.bitfire.davdroid.syncadapter.AccountUtils +import at.bitfire.davdroid.syncadapter.SyncUtils +import at.bitfire.davdroid.syncadapter.SyncUtils.syncAuthorities +import at.bitfire.davdroid.syncadapter.SyncWorker import at.bitfire.davdroid.ui.account.AccountActivity import com.google.android.material.snackbar.Snackbar import dagger.hilt.android.AndroidEntryPoint @@ -46,7 +52,7 @@ import java.text.Collator import javax.inject.Inject @AndroidEntryPoint -class AccountListFragment: Fragment() { +class AccountListFragment : Fragment() { private var _binding: AccountListBinding? = null private val binding get() = _binding!! @@ -54,8 +60,11 @@ class AccountListFragment: Fragment() { private var syncStatusSnackbar: Snackbar? = null - - override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View { + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View { setHasOptionsMenu(true) _binding = AccountListBinding.inflate(inflater, container, false) @@ -105,16 +114,21 @@ class AccountListFragment: Fragment() { } model.dataSaverOn.observe(viewLifecycleOwner) { datasaverOn -> - binding.datasaverOnInfo.visibility = if (Build.VERSION.SDK_INT >= 24 && datasaverOn) View.VISIBLE else View.GONE + binding.datasaverOnInfo.visibility = + if (Build.VERSION.SDK_INT >= 24 && datasaverOn) View.VISIBLE else View.GONE } binding.manageDatasaver.setOnClickListener { if (Build.VERSION.SDK_INT >= 24) { - val intent = Intent(Settings.ACTION_IGNORE_BACKGROUND_DATA_RESTRICTIONS_SETTINGS, Uri.parse("package:" + requireActivity().packageName)) + val intent = Intent( + Settings.ACTION_IGNORE_BACKGROUND_DATA_RESTRICTIONS_SETTINGS, + Uri.parse("package:" + requireActivity().packageName) + ) if (intent.resolveActivity(requireActivity().packageManager) != null) startActivity(intent) } } + // Accounts adapter val accountAdapter = AccountAdapter(requireActivity()) binding.list.apply { layoutManager = LinearLayoutManager(requireActivity()) @@ -154,7 +168,11 @@ class AccountListFragment: Fragment() { } fun checkPermissions() { - if (ContextCompat.checkSelfPermission(requireContext(), Manifest.permission.POST_NOTIFICATIONS) == PackageManager.PERMISSION_GRANTED) + if (ContextCompat.checkSelfPermission( + requireContext(), + Manifest.permission.POST_NOTIFICATIONS + ) == PackageManager.PERMISSION_GRANTED + ) binding.noNotificationsInfo.visibility = View.GONE else binding.noNotificationsInfo.visibility = View.VISIBLE @@ -162,20 +180,26 @@ class AccountListFragment: Fragment() { 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 - } + 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) + class ViewHolder(val binding: AccountListItemBinding) : + RecyclerView.ViewHolder(binding.root) override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder { - val binding = AccountListItemBinding.inflate(LayoutInflater.from(parent.context), parent, false) + val binding = + AccountListItemBinding.inflate(LayoutInflater.from(parent.context), parent, false) return ViewHolder(binding) } @@ -196,6 +220,7 @@ class AccountListFragment: Fragment() { visibility = View.VISIBLE } } + SyncStatus.PENDING -> { holder.binding.progress.apply { alpha = 0.4f @@ -204,6 +229,7 @@ class AccountListFragment: Fragment() { visibility = View.VISIBLE } } + else -> holder.binding.progress.visibility = View.INVISIBLE } holder.binding.accountName.text = accountInfo.account.name @@ -215,7 +241,7 @@ class AccountListFragment: Fragment() { class Model @Inject constructor( application: Application, private val warnings: AppWarningsManager - ): AndroidViewModel(application), OnAccountsUpdateListener, SyncStatusObserver { + ) : AndroidViewModel(application), OnAccountsUpdateListener { data class AccountInfo( val account: Account, @@ -229,51 +255,102 @@ class AccountListFragment: Fragment() { val storageLow = warnings.storageLow // Accounts - val accounts = MutableLiveData>() + private val accountsUpdated = MutableLiveData() + private val syncWorkersActive = SyncWorker.exists( + application, + listOf( + WorkInfo.State.RUNNING, + WorkInfo.State.ENQUEUED + ) + ) + + val accounts = object : MediatorLiveData>() { + init { + addSource(accountsUpdated) { recalculate() } + addSource(syncWorkersActive) { recalculate() } + } + + fun recalculate() { + val context = getApplication() + val collator = Collator.getInstance() + + val accountsFromManager = AccountUtils.getMainAccounts(context) + + val sortedAccounts = accountsFromManager + .sortedWith { a, b -> + collator.compare(a.name, b.name) + } + val accountsWithInfo = sortedAccounts.map { account -> + AccountInfo( + account, + SyncStatus.fromAccount(context, account, syncAuthorities) + ) + } + value = accountsWithInfo + } + } + private val accountManager = AccountManager.get(application)!! - private val syncAuthorities by lazy { DavUtils.syncAuthorities(application) } + private val syncAuthorities by lazy { SyncUtils.syncAuthorities(application) } 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 - ) } + @AnyThread override fun onAccountsUpdated(newAccounts: Array) { - reloadAccounts() + accountsUpdated.postValue(true) } - override fun onStatusChanged(which: Int) { - reloadAccounts() + override fun onCleared() { + accountManager.removeOnAccountsUpdatedListener(this) + warnings.close() } - private fun reloadAccounts() { - val context = getApplication() - val collator = Collator.getInstance() + } - val accountsFromManager = AccountUtils.getMainAccounts(context) + enum class SyncStatus { + ACTIVE, PENDING, IDLE; + + companion object { + /** + * Returns the sync status of a given account. Checks the account itself and possible + * sub-accounts (address book accounts). + * + * @param account account to check + * @param authorities sync authorities to check (usually taken from [syncAuthorities]) + * + * @return sync status of the given account + */ + fun fromAccount( + context: Context, + account: Account, + authorities: List + ): SyncStatus { + val workerNames = authorities.map { authority -> + SyncWorker.workerName(account, authority) + } + val workQuery = WorkQuery.Builder + .fromTags(listOf(SyncWorker.TAG_SYNC)) + .addUniqueWorkNames(workerNames) + .addStates(listOf(WorkInfo.State.RUNNING, WorkInfo.State.ENQUEUED)) + .build() - 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) - } + val workInfos = WorkManager.getInstance(context).getWorkInfos(workQuery).get() - override fun onCleared() { - accountManager.removeOnAccountsUpdatedListener(this) - warnings.close() + return when { + workInfos.any { workInfo -> + workInfo.state == WorkInfo.State.RUNNING + } -> ACTIVE + + workInfos.any { workInfo -> + workInfo.state == WorkInfo.State.ENQUEUED + } -> PENDING + + else -> IDLE + } + } } } 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 1a51058d8d5150aa1fa356e17067ab4df65d8e3a..0c6eb4fa803dffd944d7ee860828a51b0b344b4d 100644 --- a/app/src/main/java/at/bitfire/davdroid/ui/AccountsActivity.kt +++ b/app/src/main/java/at/bitfire/davdroid/ui/AccountsActivity.kt @@ -5,18 +5,25 @@ package at.bitfire.davdroid.ui import android.app.Activity +import android.app.Application import android.content.Intent import android.os.Bundle import android.view.MenuItem +import androidx.activity.viewModels import androidx.appcompat.app.ActionBarDrawerToggle import androidx.appcompat.app.AppCompatActivity import androidx.core.view.GravityCompat +import androidx.lifecycle.AndroidViewModel import at.bitfire.davdroid.R import at.bitfire.davdroid.databinding.ActivityAccountsBinding -import at.bitfire.davdroid.syncadapter.SyncUtils +import at.bitfire.davdroid.settings.SettingsManager +import at.bitfire.davdroid.syncadapter.AccountUtils +import at.bitfire.davdroid.syncadapter.SyncWorker import at.bitfire.davdroid.ui.setup.LoginActivity import com.google.android.material.navigation.NavigationView +import com.google.android.material.snackbar.Snackbar import dagger.hilt.android.AndroidEntryPoint +import dagger.hilt.android.lifecycle.HiltViewModel import javax.inject.Inject @AndroidEntryPoint @@ -29,6 +36,7 @@ class AccountsActivity: AppCompatActivity(), NavigationView.OnNavigationItemSele @Inject lateinit var accountsDrawerHandler: AccountsDrawerHandler private lateinit var binding: ActivityAccountsBinding + val model by viewModels() override fun onCreate(savedInstanceState: Bundle?) { @@ -83,7 +91,30 @@ class AccountsActivity: AppCompatActivity(), NavigationView.OnNavigationItemSele return true } + private fun allAccounts() = AccountUtils.getMainAccounts(this) + fun syncAllAccounts(item: MenuItem? = null) { - SyncUtils.syncAllAccounts(this) + // Notify user that sync will get enqueued if we're not connected to the internet + model.networkAvailable.value?.let { networkAvailable -> + if (!networkAvailable) + Snackbar.make(binding.drawerLayout, R.string.no_internet_sync_scheduled, Snackbar.LENGTH_LONG).show() + } + + // Enqueue sync worker for all accounts and authorities. Will sync once internet is available + val accounts = allAccounts() + for (account in accounts) + SyncWorker.enqueueAllAuthorities(this, account) + } + + + @HiltViewModel + class Model @Inject constructor( + application: Application, + val settings: SettingsManager, + warnings: AppWarningsManager + ): AndroidViewModel(application) { + + val networkAvailable = warnings.networkAvailable + } } 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 78cdb1dda07cbb07ec4167f4b4aaebde78381923..9f1cd224e6e93f016bc780de72c495d838696387 100644 --- a/app/src/main/java/at/bitfire/davdroid/ui/AppSettingsActivity.kt +++ b/app/src/main/java/at/bitfire/davdroid/ui/AppSettingsActivity.kt @@ -15,6 +15,7 @@ import androidx.activity.result.contract.ActivityResultContracts import androidx.annotation.UiThread import androidx.appcompat.app.AppCompatActivity import androidx.appcompat.app.AppCompatDelegate +import androidx.core.os.LocaleListCompat import androidx.preference.* import at.bitfire.cert4android.CustomCertManager import at.bitfire.davdroid.BuildConfig @@ -32,6 +33,7 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import java.net.URI import java.net.URISyntaxException +import java.util.* import javax.inject.Inject import kotlin.math.roundToInt @@ -40,6 +42,27 @@ class AppSettingsActivity: AppCompatActivity() { companion object { const val EXTRA_SCROLL_TO = "scrollTo" + + /** + * Matches all language qualifiers with a region of three characters, which is not supported + * by Java's Locale. + * @see resourceQualifierToLanguageTag + */ + private val langRegex = Regex(".*-.{3}") + + /** + * Converts the language qualifier given from Android to Java Locale language tag. + * @param lang The qualifier to convert. Example: `en`, `zh-rTW`... + * @return A correct language code to be introduced into [java.util.Locale.forLanguageTag]. + */ + fun resourceQualifierToLanguageTag(lang: String): String { + // If the language qualifier is correct, return it + if (!lang.matches(langRegex)) return lang + // Otherwise, fix it + val hyphenIndex = lang.indexOf('-') + // Remove the first character of the 3 (rGB -> GB, rTW -> TW) + return lang.substring(0, hyphenIndex) + "-" + lang.substring(hyphenIndex + 2) + } } diff --git a/app/src/main/java/at/bitfire/davdroid/ui/AppWarningsManager.kt b/app/src/main/java/at/bitfire/davdroid/ui/AppWarningsManager.kt index 544fdbfec96f6419703a2cc19216b5aefadaaf79..f09a1bf861e4080cf948eba97e98b9de785d23e4 100644 --- a/app/src/main/java/at/bitfire/davdroid/ui/AppWarningsManager.kt +++ b/app/src/main/java/at/bitfire/davdroid/ui/AppWarningsManager.kt @@ -10,7 +10,6 @@ import android.net.Network import android.net.NetworkCapabilities import android.net.NetworkRequest import android.os.Build -import androidx.annotation.RequiresApi import androidx.core.content.getSystemService import androidx.lifecycle.MutableLiveData import at.bitfire.davdroid.StorageLowReceiver @@ -55,7 +54,7 @@ class AppWarningsManager @Inject constructor( init { Logger.log.fine("Watching for warning conditions") - // Automatic sync + // Automatic Sync syncStatusObserver = ContentResolver.addStatusChangeListener(ContentResolver.SYNC_OBSERVER_TYPE_SETTINGS, this) onStatusChanged(ContentResolver.SYNC_OBSERVER_TYPE_SETTINGS) @@ -73,9 +72,8 @@ class AppWarningsManager @Inject constructor( val dataSaverChangedFilter = IntentFilter(ConnectivityManager.ACTION_RESTRICT_BACKGROUND_CHANGED) context.registerReceiver(listener, dataSaverChangedFilter) dataSaverChangedListener = listener - - checkDataSaver() } + checkDataSaver() } override fun onStatusChanged(which: Int) { @@ -130,13 +128,15 @@ class AppWarningsManager @Inject constructor( } } - @RequiresApi(24) private fun checkDataSaver() { - context.getSystemService()?.let { connectivityManager -> - dataSaverEnabled.postValue( - connectivityManager.restrictBackgroundStatus == ConnectivityManager.RESTRICT_BACKGROUND_STATUS_ENABLED - ) - } + dataSaverEnabled.postValue( + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) + context.getSystemService()?.let { connectivityManager -> + connectivityManager.restrictBackgroundStatus == ConnectivityManager.RESTRICT_BACKGROUND_STATUS_ENABLED + } + else + false + ) } override fun close() { 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 bdeb3d480eb77708c2def396e7c7cdcb011dfbd7..fe2cb62b750b283771e2c0066ab32377bbbf0083 100644 --- a/app/src/main/java/at/bitfire/davdroid/ui/DebugInfoActivity.kt +++ b/app/src/main/java/at/bitfire/davdroid/ui/DebugInfoActivity.kt @@ -6,19 +6,27 @@ package at.bitfire.davdroid.ui import android.accounts.Account import android.accounts.AccountManager +import android.app.Application import android.app.usage.UsageStatsManager -import android.content.* +import android.content.ContentProviderClient +import android.content.ContentResolver +import android.content.ContentUris +import android.content.Context +import android.content.Intent import android.content.pm.ApplicationInfo import android.content.pm.PackageManager import android.net.ConnectivityManager import android.net.Uri -import android.os.* +import android.os.Build +import android.os.Bundle +import android.os.Environment +import android.os.LocaleList +import android.os.PowerManager +import android.os.StatFs import android.provider.CalendarContract import android.provider.ContactsContract 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 @@ -27,10 +35,13 @@ 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.Observer import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.viewModelScope +import androidx.work.WorkManager +import androidx.work.WorkQuery import at.bitfire.dav4jvm.exception.DavException import at.bitfire.dav4jvm.exception.HttpException import at.bitfire.davdroid.BuildConfig @@ -44,15 +55,19 @@ import at.bitfire.davdroid.resource.LocalAddressBook import at.bitfire.davdroid.settings.AccountSettings import at.bitfire.davdroid.settings.SettingsManager import at.bitfire.davdroid.syncadapter.AccountUtils +import at.bitfire.davdroid.syncadapter.PeriodicSyncWorker +import at.bitfire.davdroid.syncadapter.SyncWorker +import at.bitfire.ical4android.TaskProvider import at.bitfire.ical4android.TaskProvider.ProviderName import at.bitfire.ical4android.util.MiscUtils.ContentProviderClientHelper.closeCompat import at.techbee.jtx.JtxContract +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.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 @@ -60,16 +75,33 @@ 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.* -import java.util.* +import java.io.File +import java.io.IOError +import java.io.IOException +import java.io.StringReader +import java.io.Writer +import java.util.Locale +import java.util.TimeZone import java.util.logging.Level import java.util.zip.ZipEntry import java.util.zip.ZipOutputStream import javax.inject.Inject -import kotlin.collections.ArrayList - +import at.bitfire.ical4android.util.MiscUtils.UriHelper.asSyncAdapter as asCalendarSyncAdapter +import at.bitfire.vcard4android.Utils.asSyncAdapter as asContactsSyncAdapter +import at.techbee.jtx.JtxContract.asSyncAdapter as asJtxSyncAdapter + +/** + * Debug info activity. Provides verbose information for debugging and support. Should enable users + * to debug problems themselves, but also to send it to the support. + * + * Important use cases to test: + * + * - debug info from App settings / Debug info (should provide debug info) + * - login with some broken login URL (should provide debug info + logs; check logs, too) + * - enable App settings / Verbose logs, then open debug info activity (should provide debug info + logs; check logs, too) + */ @AndroidEntryPoint -class DebugInfoActivity: AppCompatActivity() { +class DebugInfoActivity : AppCompatActivity() { companion object { /** [android.accounts.Account] (as [android.os.Parcelable]) related to problem */ @@ -84,9 +116,6 @@ class DebugInfoActivity: AppCompatActivity() { /** 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" @@ -97,21 +126,26 @@ class DebugInfoActivity: AppCompatActivity() { const val FILE_LOGS = "logs.txt" } - private val model by viewModels() + @Inject lateinit var modelFactory: ReportModel.Factory + private val model by viewModels { + object: ViewModelProvider.Factory { + @Suppress("UNCHECKED_CAST") + override fun create(modelClass: Class): T = + modelFactory.create(intent.extras) as T + } + } override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - model.generate(intent.extras) - val binding = DataBindingUtil.setContentView(this, R.layout.activity_debug_info) binding.model = model binding.lifecycleOwner = this - model.cause.observe(this, Observer { cause -> + model.cause.observe(this) { cause -> if (cause == null) - return@Observer + return@observe 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) @@ -125,17 +159,21 @@ class DebugInfoActivity: AppCompatActivity() { 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 + 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 -> + model.debugInfo.observe(this) { debugInfo -> val showDebugInfo = View.OnClickListener { - val uri = FileProvider.getUriForFile(this, getString(R.string.authority_debug_provider), debugInfo) + 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) @@ -149,9 +187,9 @@ class DebugInfoActivity: AppCompatActivity() { isEnabled = true } binding.zipShare.setOnClickListener { shareArchive() } - }) + } - model.logFile.observe(this, Observer { logs -> + model.logFile.observe(this) { logs -> binding.logsView.setOnClickListener { val uri = FileProvider.getUriForFile(this, getString(R.string.authority_debug_provider), logs) val intent = Intent(Intent.ACTION_VIEW) @@ -159,31 +197,60 @@ class DebugInfoActivity: AppCompatActivity() { intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) startActivity(Intent.createChooser(intent, null)) } - }) - } + } - fun shareArchive() { - model.generateZip { zipFile -> - val builder = ShareCompat.IntentBuilder.from(this) + model.zipFile.observe(this) { zipFile -> + if (zipFile != null) { + // ZIP file is ready + val builder = ShareCompat.IntentBuilder(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() + .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() + + // Not beautiful, because it changes model data from the view. + // See https://github.com/android/architecture-components-samples/issues/63 + model.zipFile.value = null + } + } + + model.error.observe(this) { message -> + if (message != null) { + Snackbar.make(binding.fab, message, Snackbar.LENGTH_LONG).show() + + // Reset error message so that it won't be shown when activity is re-created + model.error.value = null + } } } + fun shareArchive() { + model.generateZip() + } + - @HiltViewModel - class ReportModel @Inject constructor( - @ApplicationContext val context: Context - ): ViewModel() { + class ReportModel @AssistedInject constructor ( + val context: Application, + @Assisted extras: Bundle? + ) : AndroidViewModel(context) { - @Inject lateinit var db: AppDatabase - @Inject lateinit var settings: SettingsManager + @AssistedFactory + interface Factory { + fun create(extras: Bundle?): ReportModel + } - private var initialized = false + @Inject + lateinit var db: AppDatabase + @Inject + lateinit var settings: SettingsManager val cause = MutableLiveData() var logFile = MutableLiveData() @@ -191,35 +258,20 @@ class DebugInfoActivity: AppCompatActivity() { val remoteResource = MutableLiveData() val debugInfo = MutableLiveData() + // feedback for UI val zipProgress = MutableLiveData(false) val zipFile = MutableLiveData() - - // private storage, not readable by others - private val debugInfoDir = File(context.filesDir, "debug") + val error = MutableLiveData() 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 - initialized = true + val debugDir = Logger.debugDir() ?: throw IOException("Couldn't create debug info directory") viewModelScope.launch(Dispatchers.Default) { - val logFileName = extras?.getString(EXTRA_LOG_FILE) + // create log file from EXTRA_LOGS or 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 (logsText != null) { + val file = File(debugDir, FILE_LOGS) if (!file.exists() || file.canWrite()) { file.writer().buffered().use { writer -> IOUtils.copy(StringReader(logsText), writer) @@ -227,6 +279,9 @@ class DebugInfoActivity: AppCompatActivity() { logFile.postValue(file) } else Logger.log.warning("Can't write logs to $file") + } else Logger.getDebugLogFile()?.let { debugLogFile -> + if (debugLogFile.isFile && debugLogFile.canRead()) + logFile.postValue(debugLogFile) } val throwable = extras?.getSerializable(EXTRA_CAUSE) as? Throwable @@ -239,17 +294,17 @@ class DebugInfoActivity: AppCompatActivity() { remoteResource.postValue(remote) generateDebugInfo( - extras?.getParcelable(EXTRA_ACCOUNT), - extras?.getString(EXTRA_AUTHORITY), - throwable, - local, - remote + 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) + val debugInfoFile = File(Logger.debugDir(), FILE_DEBUG_INFO) debugInfoFile.writer().buffered().use { writer -> writer.append(ByteOrderMark.UTF_BOM) writer.append("--- BEGIN DEBUG INFO ---\n\n") @@ -296,20 +351,20 @@ class DebugInfoActivity: AppCompatActivity() { val pm = context.packageManager 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 + BuildConfig.APPLICATION_ID, // DAVx5 + ProviderName.JtxBoard.packageName, // jtx Board + ProviderName.OpenTasks.packageName, // OpenTasks + ProviderName.TasksOrg.packageName // tasks.org ) // ... 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) + 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)) @@ -327,13 +382,13 @@ class DebugInfoActivity: AppCompatActivity() { 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(", ") + info.packageName, info.versionName, PackageInfoCompat.getLongVersionCode(info), + pm.getInstallerPackageName(info.packageName) ?: '—', notes.joinToString(", ") ) - } catch(e: PackageManager.NameNotFoundException) { + } catch (ignored: PackageManager.NameNotFoundException) { } writer.append(table.toString()) - } catch(e: Exception) { + } catch (e: Exception) { Logger.log.log(Level.SEVERE, "Couldn't get software information", e) } @@ -343,11 +398,11 @@ class DebugInfoActivity: AppCompatActivity() { 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" + "\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) @@ -364,15 +419,15 @@ class DebugInfoActivity: AppCompatActivity() { 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') + 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 }) + writer.append(" - DNS: ") + .append(properties.dnsServers.joinToString(", ") { it.hostAddress }) if (Build.VERSION.SDK_INT >= 28 && properties.isPrivateDnsActive) writer.append(" (private mode)") writer.append('\n') @@ -385,12 +440,14 @@ class DebugInfoActivity: AppCompatActivity() { 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("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') } @@ -400,20 +457,20 @@ class DebugInfoActivity: AppCompatActivity() { context.getSystemService()?.let { statsManager -> val bucket = statsManager.appStandbyBucket writer.append("App standby bucket: $bucket") - if (bucket > UsageStatsManager.STANDBY_BUCKET_ACTIVE) - writer.append(" (RESTRICTED!)") + if (bucket > UsageStatsManager.STANDBY_BUCKET_ACTIVE) + writer.append(" (RESTRICTED!)") writer.append('\n') } if (Build.VERSION.SDK_INT >= 23) context.getSystemService()?.let { powerManager -> writer.append("Power saving disabled: ") - .append(if (powerManager.isIgnoringBatteryOptimizations(BuildConfig.APPLICATION_ID)) "yes" else "no") - .append('\n') + .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') + writer.append("System-wide synchronization: ") + .append(if (ContentResolver.getMasterSyncAutomatically()) "automatically" else "manually") + .append('\n') // notifications val nm = NotificationManagerCompat.from(context) writer.append("\nNotifications") @@ -441,17 +498,19 @@ class DebugInfoActivity: AppCompatActivity() { 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) + writer.append(" - $shortPermission: ") + .append( + if (ContextCompat.checkSelfPermission(context, permission) == PackageManager.PERMISSION_GRANTED) "granted" else - "denied") - .append('\n') + "denied" + ) + .append('\n') } writer.append('\n') + // main accounts writer.append("\nACCOUNTS\n\n") - val accountManager = AccountManager.get(context) val mainAccounts = AccountUtils.getMainAccounts(context) val addressBookAccounts = AccountUtils.getAddressBookAccounts(context) @@ -465,8 +524,8 @@ class DebugInfoActivity: AppCompatActivity() { 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) + 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) @@ -496,72 +555,92 @@ class DebugInfoActivity: AppCompatActivity() { debugInfo.postValue(debugInfoFile) } - fun generateZip(onSuccess: (File) -> Unit) { - viewModelScope.launch(Dispatchers.IO) { - try { - 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() + fun generateZip() { + try { + zipProgress.postValue(true) + + val file = File(Logger.debugDir(), "davx5-debug.zip") + Logger.log.fine("Writing debug info to ${file.absolutePath}") + ZipOutputStream(file.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) + 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() } + + // success, show ZIP file + zipFile.postValue(file) + } catch (e: Exception) { + Logger.log.log(Level.SEVERE, "Couldn't generate debug info ZIP", e) + error.postValue(e.localizedMessage) + } finally { zipProgress.postValue(false) } } private fun dumpMainAccount(account: Account, writer: Writer) { - writer.append(" - Account: ${account.name}\n") - writer.append(dumpAccount(account, AccountDumpInfo.mainAccount(context))) + writer.append("\n\n - Account: ${account.name}\n") + writer.append(dumpAccount(account, AccountDumpInfo.mainAccount(context, account))) try { val accountSettings = AccountSettings(context, account) + + val credentials = accountSettings.credentials() + val authStr = mutableListOf() + if (credentials.userName != null) + authStr += "user name" + if (credentials.password != null) + authStr += "password" + if (credentials.certificateAlias != null) + authStr += "client certificate" + credentials.authState?.let { authState -> + authStr += "OAuth [${authState.authorizationServiceConfiguration?.authorizationEndpoint}]" + } + if (authStr.isNotEmpty()) + writer .append(" Authentication: ") + .append(authStr.joinToString(", ")) + .append("\n") + 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( + "\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" + ) + + writer.append("\nSync workers:\n") + .append(dumpSyncWorkersInfo(account)) + .append("\n") + } catch (e: InvalidAccountException) { writer.append("$e\n") } writer.append('\n') @@ -569,63 +648,102 @@ class DebugInfoActivity: AppCompatActivity() { 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") + val table = dumpAccount(account, AccountDumpInfo.addressBookAccount(account)) + 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") } private fun dumpAccount(account: Account, infos: Iterable): String { - val table = TextTable("Authority", "Syncable", "Auto-sync", "Interval", "Entries") + val table = TextTable("Authority", "getIsSyncable", "getSyncAutomatically", "PeriodicSyncWorker", "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 -> + if (client != null) + client.query(info.countUri, null, null, null, null)?.use { cursor -> nrEntries = "${cursor.count} ${info.countStr}" } - } - } catch(ignored: Exception) { + else + nrEntries = "n/a" + } catch (e: Exception) { + nrEntries = e.toString() } finally { client?.closeCompat() } + val accountSettings = AccountSettings(context, account) 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 + info.authority, + ContentResolver.getIsSyncable(account, info.authority), + ContentResolver.getSyncAutomatically(account, info.authority), // content-triggered sync + PeriodicSyncWorker.isEnabled(context, account, info.authority), // should always be false for address book accounts + accountSettings.getSyncInterval(info.authority)?.let {"${it/60} min"}, + nrEntries ) } return table.toString() } + /** + * Gets sync workers info + * Note: WorkManager does not return worker names when queried, so we create them and ask + * whether they exist one by one + */ + private fun dumpSyncWorkersInfo(account: Account): String { + val table = TextTable("Tags", "Authority", "State", "Retries", "Generation", "ID") + listOf( + context.getString(R.string.address_books_authority), + CalendarContract.AUTHORITY, + TaskProvider.ProviderName.JtxBoard.authority, + TaskProvider.ProviderName.OpenTasks.authority, + TaskProvider.ProviderName.TasksOrg.authority + ).forEach { authority -> + for (workerName in listOf( + SyncWorker.workerName(account, authority), + PeriodicSyncWorker.workerName(account, authority) + )) { + WorkManager.getInstance(context).getWorkInfos( + WorkQuery.Builder.fromUniqueWorkNames(listOf(workerName)).build() + ).get().forEach { workInfo -> + table.addLine( + workInfo.tags.map { StringUtils.removeStartIgnoreCase(it, SyncWorker::class.java.getPackage()!!.name + ".") }, + authority, + workInfo.state, + workInfo.runAttemptCount, + workInfo.generation, + workInfo.id + ) + } + } + } + return table.toString() + } + } data class AccountDumpInfo( - val authority: String, - val countUri: Uri?, - val countStr: String?) { + val account: Account, + 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 mainAccount(context: Context, account: Account) = listOf( + AccountDumpInfo(account, context.getString(R.string.address_books_authority), null, null), + AccountDumpInfo(account, CalendarContract.AUTHORITY, CalendarContract.Events.CONTENT_URI.asCalendarSyncAdapter(account), "event(s)"), + AccountDumpInfo(account, ProviderName.JtxBoard.authority, JtxContract.JtxICalObject.CONTENT_URI.asJtxSyncAdapter(account), "jtx Board ICalObject(s)"), + AccountDumpInfo(account, ProviderName.OpenTasks.authority, TaskContract.Tasks.getContentUri(ProviderName.OpenTasks.authority).asCalendarSyncAdapter(account), "OpenTasks task(s)"), + AccountDumpInfo(account, ProviderName.TasksOrg.authority, TaskContract.Tasks.getContentUri(ProviderName.TasksOrg.authority).asCalendarSyncAdapter(account), "tasks.org task(s)"), + AccountDumpInfo(account, ContactsContract.AUTHORITY, ContactsContract.RawContacts.CONTENT_URI.asContactsSyncAdapter(account), "wrongly assigned raw contact(s)") ) - fun addressBookAccount() = listOf( - AccountDumpInfo(ContactsContract.AUTHORITY, ContactsContract.RawContacts.CONTENT_URI, "raw contact(s)") + fun addressBookAccount(account: Account) = listOf( + AccountDumpInfo(account, ContactsContract.AUTHORITY, ContactsContract.RawContacts.CONTENT_URI.asContactsSyncAdapter(account), "raw contact(s)") ) } @@ -636,7 +754,7 @@ class DebugInfoActivity: AppCompatActivity() { class IntentBuilder(context: Context) { companion object { - val MAX_ELEMENT_SIZE = 800*FileUtils.ONE_KB.toInt() + const val MAX_ELEMENT_SIZE = 800 * FileUtils.ONE_KB.toInt() } val intent = Intent(context, DebugInfoActivity::class.java) @@ -670,12 +788,6 @@ class DebugInfoActivity: AppCompatActivity() { 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)) @@ -697,4 +809,4 @@ class DebugInfoActivity: AppCompatActivity() { } -} +} \ No newline at end of file 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 b8f50ca7374262883d0f55fe7b4cef2f853a7d73..a09f5d49d212686302919f5681ac99731b46e604 100644 --- a/app/src/main/java/at/bitfire/davdroid/ui/HomeSetAdapter.kt +++ b/app/src/main/java/at/bitfire/davdroid/ui/HomeSetAdapter.kt @@ -13,7 +13,7 @@ import android.view.ViewGroup import android.widget.ArrayAdapter import android.widget.Filter import android.widget.TextView -import at.bitfire.davdroid.DavUtils +import at.bitfire.davdroid.util.DavUtils import at.bitfire.davdroid.R import at.bitfire.davdroid.db.HomeSet 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 b1cfba9b464abfbf2ec223d7da558cf6faf5450f..8bc0e5e6aadaf9f57b29e0422799d2e04b147838 100644 --- a/app/src/main/java/at/bitfire/davdroid/ui/NotificationUtils.kt +++ b/app/src/main/java/at/bitfire/davdroid/ui/NotificationUtils.kt @@ -5,16 +5,20 @@ package at.bitfire.davdroid.ui import android.annotation.TargetApi +import android.app.Notification import android.app.NotificationChannel import android.app.NotificationChannelGroup import android.app.NotificationManager import android.content.Context import android.os.Build import androidx.core.app.NotificationCompat +import androidx.core.app.NotificationManagerCompat import androidx.core.content.getSystemService import androidx.core.content.res.ResourcesCompat import at.bitfire.davdroid.App import at.bitfire.davdroid.R +import at.bitfire.davdroid.log.Logger +import java.util.logging.Level object NotificationUtils { @@ -83,4 +87,16 @@ object NotificationUtils { return builder } + + fun NotificationManagerCompat.notifyIfPossible(tag: String?, id: Int, notification: Notification) { + try { + notify(tag, id, notification) + } catch (e: SecurityException) { + Logger.log.log(Level.WARNING, "Couldn't post notification (SecurityException)", notification) + } + } + + fun NotificationManagerCompat.notifyIfPossible(id: Int, notification: Notification) = + notifyIfPossible(null, id, notification) + } \ 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 index 5d3b3cc4096971a96e4b7866d8557cbc0e1ec92a..6a75b1e91d773e381dec11d7771d5ca9fa1ac687 100644 --- a/app/src/main/java/at/bitfire/davdroid/ui/PermissionsFragment.kt +++ b/app/src/main/java/at/bitfire/davdroid/ui/PermissionsFragment.kt @@ -25,10 +25,10 @@ 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.util.PermissionUtils +import at.bitfire.davdroid.util.PermissionUtils.CALENDAR_PERMISSIONS +import at.bitfire.davdroid.util.PermissionUtils.CONTACT_PERMISSIONS +import at.bitfire.davdroid.util.PermissionUtils.havePermissions import at.bitfire.davdroid.R import at.bitfire.davdroid.databinding.ActivityPermissionsBinding import at.bitfire.ical4android.TaskProvider @@ -50,37 +50,40 @@ class PermissionsFragment: Fragment() { model.checkPermissions() } - model.needAutoResetPermission.observe(viewLifecycleOwner, { keepPermissions -> + 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))) + startActivity(Intent( + Intent.ACTION_AUTO_REVOKE_PERMISSIONS, + Uri.fromParts("package", BuildConfig.APPLICATION_ID, null) + )) } - }) - model.needContactsPermissions.observe(viewLifecycleOwner, { needContacts -> + } + model.needContactsPermissions.observe(viewLifecycleOwner) { needContacts -> if (needContacts && model.haveContactsPermissions.value == false) requestPermission.launch(CONTACT_PERMISSIONS) - }) - model.needCalendarPermissions.observe(viewLifecycleOwner, { needCalendars -> + } + model.needCalendarPermissions.observe(viewLifecycleOwner) { needCalendars -> if (needCalendars && model.haveCalendarPermissions.value == false) requestPermission.launch(CALENDAR_PERMISSIONS) - }) - model.needNotificationPermissions.observe(viewLifecycleOwner, { needNotifications -> + } + model.needNotificationPermissions.observe(viewLifecycleOwner) { needNotifications -> if (needNotifications == true && model.haveNotificationPermissions.value == false) requestPermission.launch(arrayOf(Manifest.permission.POST_NOTIFICATIONS)) - }) - model.needOpenTasksPermissions.observe(viewLifecycleOwner, { needOpenTasks -> + } + model.needOpenTasksPermissions.observe(viewLifecycleOwner) { needOpenTasks -> if (needOpenTasks == true && model.haveOpenTasksPermissions.value == false) requestPermission.launch(TaskProvider.PERMISSIONS_OPENTASKS) - }) - model.needTasksOrgPermissions.observe(viewLifecycleOwner, { needTasksOrg -> + } + model.needTasksOrgPermissions.observe(viewLifecycleOwner) { needTasksOrg -> if (needTasksOrg == true && model.haveTasksOrgPermissions.value == false) requestPermission.launch(TaskProvider.PERMISSIONS_TASKS_ORG) - }) - model.needJtxPermissions.observe(viewLifecycleOwner, { needJtx -> + } + model.needJtxPermissions.observe(viewLifecycleOwner) { needJtx -> if (needJtx == true && model.haveJtxPermissions.value == false) requestPermission.launch(TaskProvider.PERMISSIONS_JTX) - }) - model.needAllPermissions.observe(viewLifecycleOwner, { needAll -> + } + model.needAllPermissions.observe(viewLifecycleOwner) { needAll -> if (needAll && model.haveAllPermissions.value == false) { val all = mutableSetOf(*CONTACT_PERMISSIONS, *CALENDAR_PERMISSIONS, Manifest.permission.POST_NOTIFICATIONS) if (model.haveOpenTasksPermissions.value != null) @@ -91,7 +94,7 @@ class PermissionsFragment: Fragment() { all.addAll(TaskProvider.PERMISSIONS_JTX) requestPermission.launch(all.toTypedArray()) } - }) + } binding.appSettings.setOnClickListener { PermissionUtils.showAppSettings(requireActivity()) 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 1695203027c9e7f16fef135b1a8403e6c66227d0..e0112c9d1789520f007a0c8a9eb1c52b7aee5899 100644 --- a/app/src/main/java/at/bitfire/davdroid/ui/UiUtils.kt +++ b/app/src/main/java/at/bitfire/davdroid/ui/UiUtils.kt @@ -14,6 +14,7 @@ import android.net.Uri import android.os.Build import android.widget.Toast import androidx.appcompat.app.AppCompatDelegate +import androidx.browser.customtabs.CustomTabsClient import androidx.core.content.getSystemService import at.bitfire.davdroid.R import at.bitfire.davdroid.log.Logger @@ -36,6 +37,8 @@ object UiUtils { const val SHORTCUT_SYNC_ALL = "syncAllAccounts" const val SNACKBAR_LENGTH_VERY_LONG = 5000 // 5s + fun haveCustomTabs(context: Context) = CustomTabsClient.getPackageName(context, null, false) != null + /** * 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 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 ac29735e73f57bdb709bf21822cc18e7c2bbb9fc..bb29c3471d2011389b51fc8739859b180c8a42a4 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 @@ -7,7 +7,7 @@ package at.bitfire.davdroid.ui.account import android.accounts.Account import android.accounts.AccountManager import android.accounts.OnAccountsUpdateListener -import android.content.Context +import android.app.Application import android.content.Intent import android.os.Build import android.os.Bundle @@ -17,10 +17,10 @@ import android.view.Menu import android.view.MenuItem import androidx.activity.viewModels import androidx.appcompat.app.AppCompatActivity -import androidx.fragment.app.Fragment -import androidx.fragment.app.FragmentStatePagerAdapter +import androidx.appcompat.widget.TooltipCompat +import androidx.fragment.app.FragmentActivity import androidx.lifecycle.* -import at.bitfire.davdroid.DavUtils +import androidx.viewpager2.adapter.FragmentStateAdapter import at.bitfire.davdroid.R import at.bitfire.davdroid.databinding.ActivityAccountBinding import at.bitfire.davdroid.db.AppDatabase @@ -28,13 +28,15 @@ import at.bitfire.davdroid.db.Collection import at.bitfire.davdroid.db.Service import at.bitfire.davdroid.log.Logger import at.bitfire.davdroid.settings.AccountSettings +import at.bitfire.davdroid.syncadapter.SyncWorker +import at.bitfire.davdroid.ui.AppWarningsManager import com.google.android.material.dialog.MaterialAlertDialogBuilder import com.google.android.material.snackbar.Snackbar +import com.google.android.material.tabs.TabLayoutMediator 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 @@ -73,24 +75,36 @@ class AccountActivity: AppCompatActivity() { setSupportActionBar(binding.toolbar) supportActionBar?.setDisplayHomeAsUpEnabled(true) - model.accountExists.observe(this, Observer { accountExists -> + model.accountExists.observe(this) { accountExists -> if (!accountExists) finish() - }) - - binding.tabLayout.setupWithViewPager(binding.viewPager) - val tabsAdapter = TabsAdapter(this) - binding.viewPager.adapter = tabsAdapter - model.cardDavService.observe(this, Observer { - tabsAdapter.cardDavSvcId = it - }) - model.calDavService.observe(this, Observer { - tabsAdapter.calDavSvcId = it - }) - - binding.sync.setOnClickListener { - DavUtils.requestSync(this, model.account) - Snackbar.make(binding.viewPager, R.string.account_synchronizing_now, Snackbar.LENGTH_LONG).show() + } + + model.services.observe(this) { services -> + val calDavServiceId = services.firstOrNull { it.type == Service.TYPE_CALDAV }?.id + val cardDavServiceId = services.firstOrNull { it.type == Service.TYPE_CARDDAV }?.id + + val viewPager = binding.viewPager + val adapter = FragmentsAdapter(this, cardDavServiceId, calDavServiceId) + viewPager.adapter = adapter + + // connect ViewPager with TabLayout (top bar with tabs) + TabLayoutMediator(binding.tabLayout, viewPager) { tab, position -> + tab.text = adapter.getHeading(position) + }.attach() + } + + // "Sync now" fab + model.networkAvailable.observe(this) { networkAvailable -> + binding.sync.setOnClickListener { + if (!networkAvailable) + Snackbar.make( + binding.sync, + R.string.no_internet_sync_scheduled, + Snackbar.LENGTH_LONG + ).show() + SyncWorker.enqueueAllAuthorities(this, model.account) + } } } @@ -161,28 +175,48 @@ class AccountActivity: AppCompatActivity() { } - // adapter + // public functions - class TabsAdapter( - val activity: AppCompatActivity - ): FragmentStatePagerAdapter(activity.supportFragmentManager, BEHAVIOR_RESUME_ONLY_CURRENT_FRAGMENT) { + /** + * Updates the click listener of the refresh collections list FAB, according to the given + * fragment. Should be called when the related fragment is resumed. + */ + fun updateRefreshCollectionsListAction(fragment: CollectionsFragment) { + val label = when (fragment) { + is AddressBooksFragment -> + getString(R.string.account_refresh_address_book_list) - var cardDavSvcId: Long? = null - set(value) { - field = value - recalculate() - } - var calDavSvcId: Long? = null - set(value) { - field = value - recalculate() - } + is CalendarsFragment, + is WebcalFragment -> + getString(R.string.account_refresh_calendar_list) - private var idxCardDav: Int? = null - private var idxCalDav: Int? = null - private var idxWebcal: Int? = null + else -> null + } + if (label != null) { + binding.refresh.contentDescription = label + TooltipCompat.setTooltipText(binding.refresh, label) + } - private fun recalculate() { + binding.refresh.setOnClickListener { + fragment.onRefresh() + } + } + + + + // adapter + + class FragmentsAdapter( + val activity: FragmentActivity, + private val cardDavSvcId: Long?, + private val calDavSvcId: Long? + ): FragmentStateAdapter(activity) { + + private val idxCardDav: Int? + private val idxCalDav: Int? + private val idxWebcal: Int? + + init { var currentIndex = 0 idxCardDav = if (cardDavSvcId != null) @@ -197,54 +231,46 @@ class AccountActivity: AppCompatActivity() { idxCalDav = null idxWebcal = null } - - // reflect changes in UI - notifyDataSetChanged() } - override fun getCount() = - (if (idxCardDav != null) 1 else 0) + - (if (idxCalDav != null) 1 else 0) + - (if (idxWebcal != null) 1 else 0) + override fun getItemCount() = + (if (idxCardDav != null) 1 else 0) + + (if (idxCalDav != null) 1 else 0) + + (if (idxWebcal != null) 1 else 0) - override fun getItem(position: Int): Fragment { - val args = Bundle(1) + override fun createFragment(position: Int) = when (position) { - idxCardDav -> { - val frag = AddressBooksFragment() - args.putLong(CollectionsFragment.EXTRA_SERVICE_ID, cardDavSvcId!!) - args.putString(CollectionsFragment.EXTRA_COLLECTION_TYPE, Collection.TYPE_ADDRESSBOOK) - frag.arguments = args - return frag - } - idxCalDav -> { - val frag = CalendarsFragment() - args.putLong(CollectionsFragment.EXTRA_SERVICE_ID, calDavSvcId!!) - args.putString(CollectionsFragment.EXTRA_COLLECTION_TYPE, Collection.TYPE_CALENDAR) - frag.arguments = args - return frag - } - idxWebcal -> { - val frag = WebcalFragment() - args.putLong(CollectionsFragment.EXTRA_SERVICE_ID, calDavSvcId!!) - args.putString(CollectionsFragment.EXTRA_COLLECTION_TYPE, Collection.TYPE_WEBCAL) - frag.arguments = args - return frag - } + idxCardDav -> + AddressBooksFragment().apply { + arguments = Bundle(2).apply { + putLong(CollectionsFragment.EXTRA_SERVICE_ID, cardDavSvcId!!) + putString(CollectionsFragment.EXTRA_COLLECTION_TYPE, Collection.TYPE_ADDRESSBOOK) + } + } + idxCalDav -> + CalendarsFragment().apply { + arguments = Bundle(2).apply { + putLong(CollectionsFragment.EXTRA_SERVICE_ID, calDavSvcId!!) + putString(CollectionsFragment.EXTRA_COLLECTION_TYPE, Collection.TYPE_CALENDAR) + } + } + idxWebcal -> + WebcalFragment().apply { + arguments = Bundle(2).apply { + putLong(CollectionsFragment.EXTRA_SERVICE_ID, calDavSvcId!!) + putString(CollectionsFragment.EXTRA_COLLECTION_TYPE, Collection.TYPE_WEBCAL) + } + } + else -> throw IllegalArgumentException() } - 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) - idxCalDav -> activity.getString(R.string.account_caldav) - idxWebcal -> activity.getString(R.string.account_webcal) - else -> throw IllegalArgumentException() - } + fun getHeading(position: Int) = + when (position) { + idxCardDav -> activity.getString(R.string.account_carddav) + idxCalDav -> activity.getString(R.string.account_caldav) + idxWebcal -> activity.getString(R.string.account_webcal) + else -> throw IllegalArgumentException() + } } @@ -252,25 +278,27 @@ class AccountActivity: AppCompatActivity() { // model class Model @AssistedInject constructor( - @ApplicationContext val context: Context, + application: Application, val db: AppDatabase, - @Assisted val account: Account - ): ViewModel(), OnAccountsUpdateListener { + @Assisted val account: Account, + warnings: AppWarningsManager + ): AndroidViewModel(application), OnAccountsUpdateListener { @AssistedFactory interface Factory { fun create(account: Account): Model } - val accountManager = AccountManager.get(context)!! - val accountSettings by lazy { AccountSettings(context, account) } + val accountManager: AccountManager = AccountManager.get(application) + val accountSettings by lazy { AccountSettings(application, account) } val accountExists = MutableLiveData() - val cardDavService = db.serviceDao().getIdByAccountAndType(account.name, Service.TYPE_CARDDAV) - val calDavService = db.serviceDao().getIdByAccountAndType(account.name, Service.TYPE_CALDAV) + val services = db.serviceDao().getServiceTypeAndIdsByAccount(account.name) val showOnlyPersonal = MutableLiveData() - val showOnlyPersonal_writable = MutableLiveData() + val showOnlyPersonalWritable = MutableLiveData() + + val networkAvailable = warnings.networkAvailable init { @@ -278,7 +306,7 @@ class AccountActivity: AppCompatActivity() { viewModelScope.launch(Dispatchers.IO) { accountSettings.getShowOnlyPersonal().let { (value, locked) -> showOnlyPersonal.postValue(value) - showOnlyPersonal_writable.postValue(locked) + showOnlyPersonalWritable.postValue(locked) } } } @@ -315,4 +343,4 @@ class AccountActivity: AppCompatActivity() { } -} +} \ No newline at end of file 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 ff5e8741d534f8655606f029c7f7a930695a1ac6..2a30b1304006b33ee452e4e1ada28e8afc45d008 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 @@ -7,7 +7,7 @@ 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.util.PermissionUtils import at.bitfire.davdroid.R import at.bitfire.davdroid.databinding.AccountCarddavItemBinding import at.bitfire.davdroid.db.Collection 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 a74d53a489b6496294708bca0865e908b34b93db..cbd7916dcb262e4f8f58310249656e77526f5bbb 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 @@ -8,7 +8,7 @@ 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.util.PermissionUtils import at.bitfire.davdroid.R import at.bitfire.davdroid.databinding.AccountCaldavItemBinding import at.bitfire.davdroid.db.Collection 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 cecdf8f349b75fc44ab60abc5a1202500c4e3800..dc079fc0ab0fef3cbeec658804502546b0911b91 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 @@ -4,16 +4,40 @@ package at.bitfire.davdroid.ui.account +import android.app.Application +import android.content.pm.PackageManager import android.os.Bundle +import android.provider.CalendarContract +import android.provider.ContactsContract +import android.text.format.DateUtils import android.view.LayoutInflater import android.view.View import android.view.ViewGroup +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.text.selection.SelectionContainer +import androidx.compose.material.MaterialTheme +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.livedata.observeAsState +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.ComposeView +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.unit.dp import androidx.fragment.app.DialogFragment import androidx.fragment.app.viewModels +import androidx.lifecycle.AndroidViewModel +import androidx.lifecycle.LiveData import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModelProvider -import at.bitfire.davdroid.databinding.CollectionPropertiesBinding +import androidx.lifecycle.map +import androidx.lifecycle.switchMap +import at.bitfire.davdroid.R import at.bitfire.davdroid.db.AppDatabase +import at.bitfire.davdroid.log.Logger +import at.bitfire.davdroid.resource.TaskUtils +import com.google.accompanist.themeadapter.material.MdcTheme import dagger.assisted.Assisted import dagger.assisted.AssistedFactory import dagger.assisted.AssistedInject @@ -38,7 +62,7 @@ class CollectionInfoFragment: DialogFragment() { } @Inject lateinit var modelFactory: Model.Factory - val model by viewModels() { + val model by viewModels { object : ViewModelProvider.Factory { @Suppress("UNCHECKED_CAST") override fun create(modelClass: Class) = @@ -47,25 +71,111 @@ class CollectionInfoFragment: DialogFragment() { } override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View { - val view = CollectionPropertiesBinding.inflate(inflater, container, false) - view.lifecycleOwner = this - view.model = model + return ComposeView(requireContext()).apply { + setContent { + MdcTheme { + CollectionInfoDialog() + } + } + } + } - return view.root + @Composable + fun CollectionInfoDialog() { + Column(Modifier.padding(16.dp)) { + // URL + val collectionState = model.collection.observeAsState() + collectionState.value?.let { collection -> + Text(stringResource(R.string.collection_properties_url), style = MaterialTheme.typography.h5) + SelectionContainer { + Text(collection.url.toString(), modifier = Modifier.padding(bottom = 16.dp), fontFamily = FontFamily.Monospace) + } + } + + // Owner + val owner = model.owner.observeAsState() + owner.value?.let { principal -> + Text(stringResource(R.string.collection_properties_owner), style = MaterialTheme.typography.h5) + Text(principal.displayName ?: principal.url.toString(), Modifier.padding(bottom = 16.dp)) + } + + // Last synced (for all applicable authorities) + val lastSyncedState = model.lastSynced.observeAsState() + lastSyncedState.value?.let { lastSynced -> + Text(stringResource(R.string.collection_properties_sync_time), style = MaterialTheme.typography.h5) + if (lastSynced.isEmpty()) + Text(stringResource(R.string.collection_properties_sync_time_never)) + else + for ((app, timestamp) in lastSynced.entries) { + Text(app) + val timeStr = DateUtils.getRelativeDateTimeString(requireContext(), timestamp, + DateUtils.SECOND_IN_MILLIS, DateUtils.WEEK_IN_MILLIS, 0).toString() + Text(timeStr, Modifier.padding(bottom = 8.dp)) + } + } + } } class Model @AssistedInject constructor( + application: Application, val db: AppDatabase, @Assisted collectionId: Long - ): ViewModel() { + ): AndroidViewModel(application) { @AssistedFactory interface Factory { fun create(collectionId: Long): Model } - var collection = db.collectionDao().getLive(collectionId) + val collection = db.collectionDao().getLive(collectionId) + val owner = collection.switchMap { collection -> + collection.ownerId?.let { ownerId -> + db.principalDao().getLive(ownerId) + } + } + + val lastSynced: LiveData> = // map: app name -> last sync timestamp + db.syncStatsDao().getLiveByCollectionId(collectionId).map { syncStatsList -> + // map: authority -> syncStats + val syncStatsMap = syncStatsList.associateBy { it.authority } + + val interestingAuthorities = listOfNotNull( + ContactsContract.AUTHORITY, + CalendarContract.AUTHORITY, + TaskUtils.currentProvider(getApplication())?.authority + ) + + val result = mutableMapOf() + // map (authority name) -> (app name, last sync timestamp) + for (authority in interestingAuthorities) { + val lastSync = syncStatsMap[authority]?.lastSync + if (lastSync != null) + result[getAppNameFromAuthority(authority)] = lastSync + } + result + } + + /** + * Tries to find the application name for given authority. Returns the authority if not + * found. + * + * @param authority authority to find the application name for (ie "at.techbee.jtx") + * @return the application name of authority (ie "jtx Board") + */ + private fun getAppNameFromAuthority(authority: String): String { + val packageManager = getApplication().packageManager + @Suppress("DEPRECATION") + val packageName = packageManager.resolveContentProvider(authority, 0)?.packageName ?: authority + return try { + @Suppress("DEPRECATION") + val appInfo = packageManager.getPackageInfo(packageName, 0).applicationInfo + packageManager.getApplicationLabel(appInfo).toString() + } catch (e: PackageManager.NameNotFoundException) { + Logger.log.warning("Application name not found for authority: $authority") + authority + } + } } 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 ca1de01df4a1225554a8d4a7eb62bcbf2b298bbb..4d85f443449007251358f4627499df1a218ccf93 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 @@ -4,6 +4,7 @@ package at.bitfire.davdroid.ui.account +import android.app.Application import android.content.* import android.os.Bundle import android.provider.CalendarContract @@ -24,23 +25,19 @@ import androidx.recyclerview.widget.RecyclerView import androidx.swiperefreshlayout.widget.SwipeRefreshLayout import androidx.viewbinding.ViewBinding import androidx.work.WorkInfo -import at.bitfire.davdroid.Constants import at.bitfire.davdroid.R 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.log.Logger import at.bitfire.davdroid.resource.TaskUtils import at.bitfire.davdroid.servicedetection.RefreshCollectionsWorker +import at.bitfire.davdroid.syncadapter.SyncWorker 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 @@ -57,7 +54,7 @@ abstract class CollectionsFragment: Fragment(), SwipeRefreshLayout.OnRefreshList val accountModel by activityViewModels() @Inject lateinit var modelFactory: Model.Factory - val model by viewModels { + protected val model by viewModels { object: ViewModelProvider.Factory { @Suppress("UNCHECKED_CAST") override fun create(modelClass: Class): T = @@ -95,7 +92,7 @@ abstract class CollectionsFragment: Fragment(), SwipeRefreshLayout.OnRefreshList model.hasWriteableCollections.observe(viewLifecycleOwner, Observer { requireActivity().invalidateOptionsMenu() }) - model.collectionsColors.observe(viewLifecycleOwner, Observer { colors: List -> + model.collectionColors.observe(viewLifecycleOwner, Observer { colors: List -> val realColors = colors.filterNotNull() if (realColors.isNotEmpty()) binding.swipeRefresh.setColorSchemeColors(*realColors.toIntArray()) @@ -123,16 +120,9 @@ abstract class CollectionsFragment: Fragment(), SwipeRefreshLayout.OnRefreshList val adapter = createAdapter() binding.list.layoutManager = LinearLayoutManager(requireActivity()) binding.list.adapter = adapter - model.collectionsPager.observe(viewLifecycleOwner, Observer { data -> + model.collections.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.submitData(data) } }) adapter.addLoadStateListener { loadStates -> @@ -155,7 +145,7 @@ abstract class CollectionsFragment: Fragment(), SwipeRefreshLayout.OnRefreshList accountModel.showOnlyPersonal.value?.let { value -> showOnlyPersonal.isChecked = value } - accountModel.showOnlyPersonal_writable.value?.let { writable -> + accountModel.showOnlyPersonalWritable.value?.let { writable -> showOnlyPersonal.isEnabled = writable } } @@ -182,6 +172,7 @@ abstract class CollectionsFragment: Fragment(), SwipeRefreshLayout.OnRefreshList override fun onResume() { super.onResume() checkPermissions() + (activity as? AccountActivity)?.updateRefreshCollectionsListAction(this) } override fun onDestroyView() { @@ -274,12 +265,12 @@ abstract class CollectionsFragment: Fragment(), SwipeRefreshLayout.OnRefreshList class Model @AssistedInject constructor( - @ApplicationContext val context: Context, + application: Application, val db: AppDatabase, @Assisted val accountModel: AccountActivity.Model, @Assisted val serviceId: Long, @Assisted val collectionType: String - ): ViewModel(), SyncStatusObserver { + ): AndroidViewModel(application) { @AssistedFactory interface Factory { @@ -287,77 +278,51 @@ abstract class CollectionsFragment: Fragment(), SwipeRefreshLayout.OnRefreshList } // cache task provider - val taskProvider by lazy { TaskUtils.currentProvider(context) } + val taskProvider by lazy { TaskUtils.currentProvider(getApplication()) } 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) - } + val collectionColors = db.collectionDao().colorsByServiceLive(serviceId) + val collections: LiveData> = + accountModel.showOnlyPersonal.switchMap { onlyPersonal -> + val pager = Pager( + PagingConfig(pageSize = 25), + pagingSourceFactory = { + Logger.log.info("Creating new pager onlyPersonal=$onlyPersonal") + if (onlyPersonal) + // show only personal collections + db.collectionDao().pagePersonalByServiceAndType(serviceId, collectionType) + else + // show all collections + db.collectionDao().pageByServiceAndType(serviceId, collectionType) + } + ) + return@switchMap pager + .liveData + .cachedIn(viewModelScope) } // observe RefreshCollectionsWorker status - val isRefreshing = RefreshCollectionsWorker.isWorkerInState(context, serviceId, WorkInfo.State.RUNNING) - - // observe whether sync is active - private var syncStatusHandle: Any? = null - val isSyncActive = MutableLiveData() - val isSyncPending = MutableLiveData() - - - init { - viewModelScope.launch(Dispatchers.Default) { - syncStatusHandle = ContentResolver.addStatusChangeListener(ContentResolver.SYNC_OBSERVER_TYPE_PENDING + ContentResolver.SYNC_OBSERVER_TYPE_ACTIVE, this@Model) - checkSyncStatus() - } - } - - override fun onCleared() { - syncStatusHandle?.let { ContentResolver.removeStatusChangeListener(it) } - } + val isRefreshing = RefreshCollectionsWorker.isWorkerInState(getApplication(), RefreshCollectionsWorker.workerName(serviceId), WorkInfo.State.RUNNING) + + // observe SyncWorker state + private val authorities = + if (collectionType == Collection.TYPE_ADDRESSBOOK) + listOf(getApplication().getString(R.string.address_books_authority), ContactsContract.AUTHORITY) + else + listOf(CalendarContract.AUTHORITY, taskProvider?.authority).filterNotNull() + val isSyncActive = SyncWorker.exists(getApplication(), + listOf(WorkInfo.State.RUNNING), + accountModel.account, + authorities) + val isSyncPending = SyncWorker.exists(getApplication(), + listOf(WorkInfo.State.ENQUEUED), + accountModel.account, + authorities) + + // actions fun refresh() { - RefreshCollectionsWorker.refreshCollections(context, serviceId) - } - - @AnyThread - override fun onStatusChanged(which: Int) { - checkSyncStatus() - } - - @AnyThread - @Synchronized - private fun checkSyncStatus() { - 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 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) - taskProvider?.let { - authorities += it.authority - } - isSyncActive.postValue(authorities.any { - ContentResolver.isSyncActive(accountModel.account, it) - }) - isSyncPending.postValue(authorities.any { - ContentResolver.isSyncPending(accountModel.account, it) - }) - } + RefreshCollectionsWorker.refreshCollections(getApplication(), serviceId) } } diff --git a/app/src/main/java/at/bitfire/davdroid/ui/account/CreateCollectionFragment.kt b/app/src/main/java/at/bitfire/davdroid/ui/account/CreateCollectionFragment.kt index 756f22b766e6841d7cf391b7cd36de7070b0c60c..bcb9b5d1c19846383eec7a2e31a2eca0fdcf5c4d 100644 --- a/app/src/main/java/at/bitfire/davdroid/ui/account/CreateCollectionFragment.kt +++ b/app/src/main/java/at/bitfire/davdroid/ui/account/CreateCollectionFragment.kt @@ -15,8 +15,8 @@ import androidx.fragment.app.viewModels import androidx.lifecycle.* import at.bitfire.dav4jvm.DavResource import at.bitfire.dav4jvm.XmlUtils -import at.bitfire.davdroid.DavUtils -import at.bitfire.davdroid.HttpClient +import at.bitfire.davdroid.util.DavUtils +import at.bitfire.davdroid.network.HttpClient import at.bitfire.davdroid.R import at.bitfire.davdroid.db.AppDatabase import at.bitfire.davdroid.db.Collection @@ -138,7 +138,7 @@ class CreateCollectionFragment: DialogFragment() { .setForeground(true) .build().use { httpClient -> try { - val dav = DavResource(httpClient.okHttpClient, collection.url, accountSettings.credentials().authState?.accessToken) + val dav = DavResource(httpClient.okHttpClient, collection.url) // create collection on remote server dav.mkCol(generateXml()) {} diff --git a/app/src/main/java/at/bitfire/davdroid/ui/account/DeleteCollectionFragment.kt b/app/src/main/java/at/bitfire/davdroid/ui/account/DeleteCollectionFragment.kt index c9245f315aa0d2d68e575d1b76bae1b192e94243..d37fe0abbf87309759aa7ac3ba938b4ec5f11ae5 100644 --- a/app/src/main/java/at/bitfire/davdroid/ui/account/DeleteCollectionFragment.kt +++ b/app/src/main/java/at/bitfire/davdroid/ui/account/DeleteCollectionFragment.kt @@ -14,7 +14,7 @@ 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.network.HttpClient import at.bitfire.davdroid.databinding.DeleteCollectionBinding import at.bitfire.davdroid.db.AppDatabase import at.bitfire.davdroid.db.Collection @@ -120,7 +120,7 @@ class DeleteCollectionFragment: DialogFragment() { .setForeground(true) .build().use { httpClient -> try { - val collection = DavResource(httpClient.okHttpClient, collectionInfo.url, accountSettings.credentials().authState?.accessToken) + val collection = DavResource(httpClient.okHttpClient, collectionInfo.url) // delete collection from server collection.delete(null) {} 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 ead1050ab2409bf11f9158c97e6a309205a374f9..297b27e28e3b18790e35a6156923dc8f5fb6aa22 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 @@ -8,9 +8,9 @@ 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.Bundle @@ -19,30 +19,28 @@ import android.provider.ContactsContract import android.widget.EditText import android.widget.LinearLayout import android.widget.Toast +import androidx.annotation.MainThread import androidx.annotation.WorkerThread import androidx.core.content.ContextCompat import androidx.fragment.app.DialogFragment import androidx.fragment.app.viewModels +import androidx.lifecycle.AndroidViewModel import androidx.lifecycle.MutableLiveData -import androidx.lifecycle.Observer -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.* import at.bitfire.davdroid.db.AppDatabase import at.bitfire.davdroid.log.Logger import at.bitfire.davdroid.resource.LocalAddressBook import at.bitfire.davdroid.resource.LocalTaskList import at.bitfire.davdroid.settings.AccountSettings import at.bitfire.davdroid.syncadapter.AccountUtils -import at.bitfire.davdroid.syncadapter.AccountsUpdatedListener +import at.bitfire.davdroid.syncadapter.AccountsCleanupWorker +import at.bitfire.davdroid.syncadapter.SyncWorker +import at.bitfire.davdroid.util.closeCompat 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 @@ -82,9 +80,14 @@ class RenameAccountFragment: DialogFragment() { layout.setPadding(8*density, 8*density, 8*density, 8*density) layout.addView(editText) - model.finished.observe(this, Observer { - this@RenameAccountFragment.requireActivity().finish() - }) + model.errorMessage.observe(this) { msg -> + // we use a Toast to show the error message because a Snackbar is not usable for the input dialog fragment + Toast.makeText(requireActivity(), msg, Toast.LENGTH_LONG).show() + } + + model.finishActivity.observe(this) { + requireActivity().finish() + } return MaterialAlertDialogBuilder(requireActivity(), R.style.CustomAlertDialogStyle) .setTitle(R.string.account_rename) @@ -96,8 +99,6 @@ class RenameAccountFragment: DialogFragment() { return@OnClickListener model.renameAccount(oldAccount, newName) - - requireActivity().finish() }) .setNegativeButton(android.R.string.cancel) { _, _ -> } .create() @@ -106,57 +107,76 @@ class RenameAccountFragment: DialogFragment() { @HiltViewModel class Model @Inject constructor( - @ApplicationContext val context: Context, - val accountsUpdatedListener: AccountsUpdatedListener, + application: Application, val db: AppDatabase - ): ViewModel() { + ): AndroidViewModel(application) { - val finished = MutableLiveData() + val errorMessage = MutableLiveData() + val finishActivity = MutableLiveData() + /** + * Will try to rename the given account to given string + * + * @param oldAccount the account to be renamed + * @param newName the new name + */ fun renameAccount(oldAccount: Account, newName: String) { + val context: Application = getApplication() + // 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 + errorMessage.postValue(context.getString(R.string.account_invalid)) + finishActivity.value = true return } val authorities = arrayOf( - context.getString(R.string.address_books_authority), - CalendarContract.AUTHORITY, - TaskProvider.ProviderName.OpenTasks.authority + 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) + // check whether name is already taken + if (AccountUtils.getMainAccounts(context).map { it.name }.contains(newName)) { + Logger.log.log(Level.WARNING, "Account with name \"$newName\" already exists") + errorMessage.postValue(context.getString(R.string.account_rename_exists_already)) + return + } + try { /* https://github.com/bitfireAT/davx5/issues/135 - Take AccountsUpdatedListenerLock so that the AccountsUpdateListener doesn't run while we rename the account + Lock accounts cleanup so that the AccountsCleanupWorker doesn't run while we rename the account because this can cause problems when: 1. The account is renamed. - 2. The AccountsUpdateListener is called BEFORE the services table is updated. - → AccountsUpdateListener removes the "orphaned" services because they belong to the old account which doesn't exist anymore + 2. The AccountsCleanupWorker is called BEFORE the services table is updated. + → AccountsCleanupWorker removes the "orphaned" services because they belong to the old account which doesn't exist anymore 3. Now the services would be renamed, but they're not here anymore. */ - accountsUpdatedListener.mutex.acquire() + AccountsCleanupWorker.lockAccountsCleanup() - accountManager.renameAccount(oldAccount, newName, { - if (it.result?.name == newName /* success */) + // Renaming account + accountManager.renameAccount(oldAccount, newName, @MainThread { + if (it.result?.name == newName /* account has new name -> success */) viewModelScope.launch(Dispatchers.Default + NonCancellable) { - onAccountRenamed(oldAccount, newName, syncIntervals) - - // release AccountsUpdatedListener mutex at the end of this async coroutine - accountsUpdatedListener.mutex.release() + try { + onAccountRenamed(oldAccount, newName, syncIntervals) + } finally { + // release AccountsCleanupWorker mutex at the end of this async coroutine + AccountsCleanupWorker.unlockAccountsCleanup() + } } else - // release AccountsUpdatedListener mutex now - accountsUpdatedListener.mutex.release() - + // release AccountsCleanupWorker mutex now + AccountsCleanupWorker.unlockAccountsCleanup() + // close AccountActivity with old name + finishActivity.postValue(true) }, 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() + errorMessage.postValue(context.getString(R.string.account_rename_couldnt_rename)) } } @@ -165,19 +185,19 @@ class RenameAccountFragment: DialogFragment() { fun onAccountRenamed(oldAccount: Account, newName: String, syncIntervals: List>) { // account has now been renamed Logger.log.info("Updating account name references") + val context: Application = getApplication() // cancel maybe running synchronization - ContentResolver.cancelSync(oldAccount, null) - + SyncWorker.cancelSync(context, oldAccount) for (addrBookAccount in AccountUtils.getAddressBookAccounts(context)) - ContentResolver.cancelSync(addrBookAccount, null) + SyncWorker.cancelSync(context, addrBookAccount) // update account name references in database 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) + errorMessage.postValue(context.getString(R.string.account_rename_couldnt_rename)) return } @@ -222,7 +242,7 @@ class RenameAccountFragment: DialogFragment() { } // synchronize again - DavUtils.requestSync(context, newAccount) + SyncWorker.enqueueAllAuthorities(context, newAccount) } } 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 56f097a9fac4a1349ea167c1817c781eea29eae0..0dc6ee59ed94d36f588ecc2ea55decceb5579186 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 @@ -7,10 +7,8 @@ package at.bitfire.davdroid.ui.account import android.accounts.Account import android.accounts.AccountManager import android.annotation.SuppressLint -import android.content.ContentResolver -import android.content.Context +import android.app.Application import android.content.Intent -import android.content.SyncStatusObserver import android.os.Build import android.os.Bundle import android.provider.CalendarContract @@ -28,15 +26,17 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModelProvider import androidx.preference.* import at.bitfire.davdroid.InvalidAccountException -import at.bitfire.davdroid.PermissionUtils import at.bitfire.davdroid.R 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.SettingsManager -import at.bitfire.davdroid.syncadapter.SyncAdapterService +import at.bitfire.davdroid.syncadapter.SyncWorker +import at.bitfire.davdroid.syncadapter.Syncer import at.bitfire.davdroid.ui.UiUtils +import at.bitfire.davdroid.ui.setup.GoogleLoginFragment +import at.bitfire.davdroid.util.PermissionUtils import at.bitfire.ical4android.TaskProvider import at.bitfire.vcard4android.GroupMethod import com.google.android.material.snackbar.Snackbar @@ -44,7 +44,6 @@ 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 @@ -130,7 +129,7 @@ class SettingsActivity: AppCompatActivity() { private fun initSettings() { // preference group: sync findPreference(getString(R.string.settings_sync_interval_contacts_key))!!.let { - model.syncIntervalContacts.observe(viewLifecycleOwner, { interval: Long? -> + model.syncIntervalContacts.observe(viewLifecycleOwner) { interval: Long? -> if (interval != null) { it.isEnabled = true it.isVisible = true @@ -146,10 +145,10 @@ class SettingsActivity: AppCompatActivity() { } } else it.isVisible = false - }) + } } findPreference(getString(R.string.settings_sync_interval_calendars_key))!!.let { - model.syncIntervalCalendars.observe(viewLifecycleOwner, { interval: Long? -> + model.syncIntervalCalendars.observe(viewLifecycleOwner) { interval: Long? -> if (interval != null) { it.isEnabled = true it.isVisible = true @@ -165,10 +164,10 @@ class SettingsActivity: AppCompatActivity() { } } else it.isVisible = false - }) + } } findPreference(getString(R.string.settings_sync_interval_tasks_key))!!.let { - model.syncIntervalTasks.observe(viewLifecycleOwner, { interval: Long? -> + model.syncIntervalTasks.observe(viewLifecycleOwner) { interval: Long? -> val provider = model.tasksProvider if (provider != null && interval != null) { it.isEnabled = true @@ -185,25 +184,25 @@ class SettingsActivity: AppCompatActivity() { } } else it.isVisible = false - }) + } } findPreference(getString(R.string.settings_sync_wifi_only_key))!!.let { - model.syncWifiOnly.observe(viewLifecycleOwner, { wifiOnly -> + 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) false } - }) + } } findPreference(getString(R.string.settings_sync_wifi_only_ssids_key))!!.let { - model.syncWifiOnly.observe(viewLifecycleOwner, { wifiOnly -> + model.syncWifiOnly.observe(viewLifecycleOwner) { wifiOnly -> it.isEnabled = wifiOnly && settings.isWritable(AccountSettings.KEY_WIFI_ONLY_SSIDS) - }) - model.syncWifiOnlySSIDs.observe(viewLifecycleOwner, { onlySSIDs -> + } + model.syncWifiOnlySSIDs.observe(viewLifecycleOwner) { onlySSIDs -> checkWifiPermissions() if (onlySSIDs != null) { @@ -223,60 +222,82 @@ class SettingsActivity: AppCompatActivity() { model.updateSyncWifiOnlySSIDs(newOnlySSIDs) false } - }) + } } // preference group: authentication - val prefCredentials = findPreference("credentials")!! - val prefUserName = findPreference("username")!! - val prefPassword = findPreference("password")!! - val prefCertAlias = findPreference("certificate_alias")!! - 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, clientSecret = credentials.clientSecret)) - false - } + val prefUserName = findPreference(getString(R.string.settings_username_key))!! + val prefPassword = findPreference(getString(R.string.settings_password_key))!! + val prefCertAlias = findPreference(getString(R.string.settings_certificate_alias_key))!! + val prefOAuth = findPreference(getString(R.string.settings_oauth_key))!! + + model.credentials.observe(viewLifecycleOwner) { credentials -> + if (credentials.authState != null) { + // using OAuth, hide other settings + prefOAuth.isVisible = true + prefUserName.isVisible = false + prefPassword.isVisible = false + prefCertAlias.isVisible = false - if (credentials.userName != null) { - if (credentials.authState != null) { - prefPassword.isVisible = false - prefCredentials.isVisible = true - prefCredentials.setOnPreferenceClickListener{launchSetup()} - } else { - prefPassword.isVisible = true - prefCredentials.isVisible = false - prefPassword.onPreferenceChangeListener = - Preference.OnPreferenceChangeListener { _, newPassword -> - model.updateCredentials( - Credentials( - credentials.userName, - newPassword as String, - credentials.authState, - credentials.certificateAlias, - clientSecret = credentials.clientSecret - ) - ) - false - } + prefOAuth.setOnPreferenceClickListener { + launchSetup() + } + } else { + // not using OAuth, hide OAuth setting, show the others + prefOAuth.isVisible = false + prefUserName.isVisible = true + prefPassword.isVisible = true + prefCertAlias.isVisible = true + + prefUserName.summary = credentials.userName + prefUserName.text = credentials.userName + prefUserName.onPreferenceChangeListener = Preference.OnPreferenceChangeListener { _, newUserName -> + val newUserNameOrNull = StringUtils.trimToNull(newUserName as String) + + model.updateCredentials(Credentials( + userName = newUserNameOrNull, + password = credentials.password, + certificateAlias = credentials.certificateAlias, + authState = credentials.authState, + clientSecret = credentials.clientSecret, + serverUri = credentials.serverUri) + ) + false } - } else - prefPassword.isVisible = false - prefCredentials.isVisible = false + if (credentials.userName != null) { + prefPassword.isVisible = true + prefPassword.onPreferenceChangeListener = Preference.OnPreferenceChangeListener { _, newPassword -> + model.updateCredentials(Credentials( + userName = credentials.userName, + password = newPassword as String, + certificateAlias = credentials.certificateAlias, + authState = credentials.authState, + clientSecret = credentials.clientSecret, + serverUri = credentials.serverUri)) + false + } + } else + prefPassword.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, clientSecret = credentials.clientSecret)) - }, null, null, null, -1, credentials.certificateAlias) - true + prefCertAlias.summary = credentials.certificateAlias ?: getString(R.string.settings_certificate_alias_empty) + prefCertAlias.setOnPreferenceClickListener { + KeyChain.choosePrivateKeyAlias(requireActivity(), { newAlias -> + model.updateCredentials(Credentials( + userName = credentials.userName, + password = credentials.password, + certificateAlias = newAlias, + authState = credentials.authState, + clientSecret = credentials.clientSecret, + serverUri = credentials.serverUri)) + }, null, null, null, -1, credentials.certificateAlias) + true + } } - }) + } // preference group: CalDAV - model.hasCalDav.observe(viewLifecycleOwner, { hasCalDav -> + model.hasCalDav.observe(viewLifecycleOwner) { hasCalDav -> if (!hasCalDav) findPreference(getString(R.string.settings_caldav_key))!!.isVisible = false else { @@ -288,7 +309,7 @@ class SettingsActivity: AppCompatActivity() { findPreference(getString(R.string.settings_sync_time_range_past_key))!!.let { pref -> if (hasCalendars) - model.timeRangePastDays.observe(viewLifecycleOwner, { pastDays -> + model.timeRangePastDays.observe(viewLifecycleOwner) { pastDays -> if (model.syncIntervalCalendars.value != null) { pref.isVisible = true if (pastDays != null) { @@ -309,14 +330,14 @@ class SettingsActivity: AppCompatActivity() { } } else pref.isVisible = false - }) + } else pref.isVisible = false } findPreference(getString(R.string.settings_key_default_alarm))!!.let { pref -> if (hasCalendars) - model.defaultAlarmMinBefore.observe(viewLifecycleOwner, { minBefore -> + model.defaultAlarmMinBefore.observe(viewLifecycleOwner) { minBefore -> pref.isVisible = true if (minBefore != null) { pref.text = minBefore.toString() @@ -334,25 +355,25 @@ class SettingsActivity: AppCompatActivity() { model.updateDefaultAlarm(minBefore) false } - }) + } else pref.isVisible = false } findPreference(getString(R.string.settings_manage_calendar_colors_key))!!.let { - model.manageCalendarColors.observe(viewLifecycleOwner, { manageCalendarColors -> + 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 { pref -> if (hasCalendars) - model.eventColors.observe(viewLifecycleOwner, { eventColors -> + model.eventColors.observe(viewLifecycleOwner) { eventColors -> pref.isVisible = true pref.isEnabled = !settings.containsKey(AccountSettings.KEY_EVENT_COLORS) pref.isChecked = eventColors @@ -360,22 +381,22 @@ class SettingsActivity: AppCompatActivity() { model.updateEventColors(newValue as Boolean) false } - }) + } else pref.isVisible = false } } - }) + } // preference group: CardDAV - model.syncIntervalContacts.observe(viewLifecycleOwner, { contactsSyncInterval -> + 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 -> + model.contactGroupMethod.observe(viewLifecycleOwner) { groupMethod -> if (model.syncIntervalContacts.value != null) { it.isVisible = true it.value = groupMethod.name @@ -391,10 +412,10 @@ class SettingsActivity: AppCompatActivity() { } } else it.isVisible = false - }) + } } } - }) + } } @SuppressLint("WrongConstant") @@ -413,10 +434,10 @@ class SettingsActivity: AppCompatActivity() { class Model @AssistedInject constructor( - @ApplicationContext val context: Context, + val application: Application, val settings: SettingsManager, @Assisted val account: Account - ): ViewModel(), SyncStatusObserver, SettingsManager.OnChangeListener { + ): ViewModel(), SettingsManager.OnChangeListener { @AssistedFactory interface Factory { @@ -425,12 +446,10 @@ class SettingsActivity: AppCompatActivity() { private var accountSettings: AccountSettings? = null - private var statusChangeListener: Any? = null - // settings val syncIntervalContacts = MutableLiveData() val syncIntervalCalendars = MutableLiveData() - val tasksProvider = TaskUtils.currentProvider(context) + val tasksProvider = TaskUtils.currentProvider(application) val syncIntervalTasks = MutableLiveData() val hasCalDav = object: MediatorLiveData() { init { @@ -456,29 +475,18 @@ class SettingsActivity: AppCompatActivity() { init { - accountSettings = AccountSettings(context, account) + accountSettings = AccountSettings(application, account) settings.addOnChangeListener(this) - statusChangeListener = ContentResolver.addStatusChangeListener(ContentResolver.SYNC_OBSERVER_TYPE_SETTINGS, this) reload() } override fun onCleared() { super.onCleared() - - 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() @@ -487,7 +495,7 @@ class SettingsActivity: AppCompatActivity() { fun reload() { val accountSettings = accountSettings ?: return - syncIntervalContacts.postValue(accountSettings.getSyncInterval(context.getString(R.string.address_books_authority))) + syncIntervalContacts.postValue(accountSettings.getSyncInterval(application.getString(R.string.address_books_authority))) syncIntervalCalendars.postValue(accountSettings.getSyncInterval(CalendarContract.AUTHORITY)) syncIntervalTasks.postValue(tasksProvider?.let { accountSettings.getSyncInterval(it.authority) }) @@ -564,15 +572,15 @@ class SettingsActivity: AppCompatActivity() { accountSettings?.setGroupMethod(groupMethod) reload() - resync(context.getString(R.string.address_books_authority), fullResync = true) + resync(application.getString(R.string.address_books_authority), fullResync = true) } /** * Initiates calendar re-synchronization. * * @param fullResync whether sync shall download all events again - * (_true_: sets [SyncAdapterService.SYNC_EXTRAS_FULL_RESYNC], - * _false_: sets [ContentResolver.SYNC_EXTRAS_MANUAL]) + * (_true_: sets [Syncer.SYNC_EXTRAS_FULL_RESYNC], + * _false_: sets [Syncer.SYNC_EXTRAS_RESYNC]) * @param tasks whether tasks shall be synchronized, too (false: only events, true: events and tasks) */ private fun resyncCalendars(fullResync: Boolean, tasks: Boolean) { @@ -581,14 +589,17 @@ class SettingsActivity: AppCompatActivity() { resync(TaskProvider.ProviderName.OpenTasks.authority, fullResync) } + /** + * Initiates re-synchronization for given authority. + * + * @param authority authority to re-sync + * @param fullResync whether sync shall download all events again + * (_true_: sets [Syncer.SYNC_EXTRAS_FULL_RESYNC], + * _false_: sets [Syncer.SYNC_EXTRAS_RESYNC]) + */ private fun resync(authority: String, fullResync: Boolean) { - val args = Bundle(1) - args.putBoolean(if (fullResync) - SyncAdapterService.SYNC_EXTRAS_FULL_RESYNC - else - SyncAdapterService.SYNC_EXTRAS_RESYNC, true) - - ContentResolver.requestSync(account, authority, args) + val resync = if (fullResync) SyncWorker.FULL_RESYNC else SyncWorker.RESYNC + SyncWorker.enqueue(application, account, authority, resync) } } 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 b89bc0ca35e94f4af87388db72245540461b11bf..107674389b206c9cfb2bb7b4a4cfde96f13e3d8e 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 @@ -22,9 +22,9 @@ 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.util.PermissionUtils import at.bitfire.davdroid.R -import at.bitfire.davdroid.closeCompat +import at.bitfire.davdroid.util.closeCompat import at.bitfire.davdroid.databinding.AccountCaldavItemBinding import at.bitfire.davdroid.db.AppDatabase import at.bitfire.davdroid.db.Collection 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 index 679ce8fb3eb7f5f2a4fac45eed1d3a7ab5e5f24e..b9d8bd3ba9c94479edbf8d4a0eff939f936fcea8 100644 --- a/app/src/main/java/at/bitfire/davdroid/ui/account/WifiPermissionsActivity.kt +++ b/app/src/main/java/at/bitfire/davdroid/ui/account/WifiPermissionsActivity.kt @@ -26,7 +26,7 @@ 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.util.PermissionUtils import at.bitfire.davdroid.R import at.bitfire.davdroid.databinding.ActivityWifiPermissionsBinding import at.bitfire.davdroid.log.Logger 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 index 399d19072ad70253549a2d65355cee5ee9e3f7b8..0588aefce700c6c1e80c34b90bf76177609d138a 100644 --- a/app/src/main/java/at/bitfire/davdroid/ui/intro/PermissionsIntroFragment.kt +++ b/app/src/main/java/at/bitfire/davdroid/ui/intro/PermissionsIntroFragment.kt @@ -10,9 +10,9 @@ 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.util.PermissionUtils +import at.bitfire.davdroid.util.PermissionUtils.CALENDAR_PERMISSIONS +import at.bitfire.davdroid.util.PermissionUtils.CONTACT_PERMISSIONS import at.bitfire.davdroid.R import at.bitfire.ical4android.TaskProvider import javax.inject.Inject 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 73167bbfd059c6b08d007274b6f61ec7bc932275..747b0310c1b18d5f6712960cdbf20075450389a0 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 @@ -30,7 +30,6 @@ import androidx.lifecycle.Observer import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import at.bitfire.davdroid.Constants -import at.bitfire.davdroid.DavUtils import at.bitfire.davdroid.InvalidAccountException import at.bitfire.davdroid.R import at.bitfire.davdroid.databinding.LoginAccountDetailsBinding @@ -46,6 +45,7 @@ import at.bitfire.davdroid.settings.AccountSettings import at.bitfire.davdroid.settings.SettingsManager import at.bitfire.davdroid.syncadapter.AccountUtils import at.bitfire.davdroid.syncadapter.SyncAllAccountWorker +import at.bitfire.davdroid.syncadapter.SyncWorker import at.bitfire.vcard4android.GroupMethod import com.google.android.material.snackbar.Snackbar import dagger.hilt.android.AndroidEntryPoint @@ -63,7 +63,7 @@ class AccountDetailsFragment : Fragment() { @Inject lateinit var settings: SettingsManager val loginModel by activityViewModels() - val model by viewModels() + val model by viewModels() override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View { @@ -77,6 +77,7 @@ class AccountDetailsFragment : Fragment() { model.name.value = loginModel.credentials?.userName ?: config.calDAV?.emails?.firstOrNull() + ?: loginModel.suggestedAccountName ?: loginModel.credentials?.certificateAlias ?: loginModel.baseURI?.host @@ -215,7 +216,7 @@ class AccountDetailsFragment : Fragment() { } @HiltViewModel - class AccountDetailsModel @Inject constructor( + class Model @Inject constructor( @ApplicationContext val context: Context, val db: AppDatabase, val settingsManager: SettingsManager @@ -230,6 +231,16 @@ class AccountDetailsFragment : Fragment() { nameError.value = null } + /** + * Creates a new main account with discovered services and enables periodic syncs with + * default sync interval times. + * + * @param name Name of the account + * @param credentials Server credentials + * @param config Discovered server capabilities for syncable authorities + * @param groupMethod Whether CardDAV contact groups are separate VCards or as contact categories + * @return *true* if account creation was succesful; *false* otherwise (for instance because an account with this name already exists) + */ fun createAccount(activity: Activity, name: String, credentials: Credentials?, config: DavResourceFinder.Configuration, groupMethod: GroupMethod): LiveData { val result = MutableLiveData() viewModelScope.launch(Dispatchers.Default + NonCancellable) { @@ -259,7 +270,7 @@ class AccountDetailsFragment : Fragment() { } } - val account = Account(credentials?.userName, accountType) + val account = Account(name, accountType) // create Android account val userData = AccountSettings.initialUserData(credentials, baseURL) @@ -302,6 +313,7 @@ class AccountDetailsFragment : Fragment() { val accountSettings = AccountSettings(context, account) val defaultSyncInterval = Constants.DEFAULT_CALENDAR_SYNC_INTERVAL + // Configure CardDAV service val addrBookAuthority = context.getString(R.string.address_books_authority) if (config.cardDAV != null) { // insert CardDAV service @@ -326,6 +338,7 @@ class AccountDetailsFragment : Fragment() { } else ContentResolver.setIsSyncable(account, addrBookAuthority, 0) + // Configure CalDAV service if (config.calDAV != null) { // insert CalDAV service val id = insertService( @@ -344,12 +357,15 @@ class AccountDetailsFragment : Fragment() { ContentResolver.setIsSyncable(account, CalendarContract.AUTHORITY, 1) accountSettings.setSyncInterval(CalendarContract.AUTHORITY, defaultSyncInterval) + // if task provider present, set task sync interval and enable sync 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 - } + Logger.log.info("Tasks provider ${taskProvider.authority} found. Tasks sync enabled.") + } else + Logger.log.info("No tasks provider found. Did not enable tasks sync.") } else ContentResolver.setIsSyncable(account, CalendarContract.AUTHORITY, 0) @@ -359,7 +375,7 @@ class AccountDetailsFragment : Fragment() { return@launch } - DavUtils.requestSync(activity, account) + SyncWorker.enqueueAllAuthorities(activity, account) SyncAllAccountWorker.enqueue(context, 2) result.postValue(true) @@ -381,15 +397,14 @@ class AccountDetailsFragment : Fragment() { // insert home sets val homeSetDao = db.homeSetDao() - for (homeSet in info.homeSets) { - homeSetDao.insertOrReplace(HomeSet(0, serviceId, true, homeSet)) - } + for (homeSet in info.homeSets) + homeSetDao.insertOrUpdateByUrl(HomeSet(0, serviceId, true, homeSet)) // insert collections val collectionDao = db.collectionDao() for (collection in info.collections.values) { collection.serviceId = serviceId - collectionDao.insertOrReplace(collection) + collectionDao.insertOrUpdateByUrl(collection) } return serviceId 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 24f939cf89bd6a9865d958f60ad7d6ad6d70e64a..af6530dbf1087ec065366817253ed3de91276344 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 @@ -6,6 +6,7 @@ package at.bitfire.davdroid.ui.setup import android.content.Intent import android.net.MailTo +import android.net.Uri import android.os.Bundle import android.os.Handler import android.os.Looper @@ -19,6 +20,7 @@ import androidx.fragment.app.viewModels import at.bitfire.davdroid.R import at.bitfire.davdroid.databinding.LoginCredentialsFragmentBinding import at.bitfire.davdroid.db.Credentials +import at.bitfire.davdroid.ui.UiUtils import com.google.android.material.snackbar.Snackbar import dagger.Binds import dagger.Module @@ -66,12 +68,19 @@ class DefaultLoginCredentialsFragment : Fragment() { }, null, null, null, -1, model.certificateAlias.value) } - v.login.setOnClickListener { - if (validate()) + v.login.setOnClickListener { _ -> + if (validate()) { + val nextFragment = + if (model.loginGoogle.value == true) + GoogleLoginFragment() + else + DetectConfigurationFragment() + parentFragmentManager.beginTransaction() - .replace(android.R.id.content, DetectConfigurationFragment(), null) - .addToBackStack(null) - .commit() + .replace(android.R.id.content, nextFragment, null) + .addToBackStack(null) + .commit() + } } return v.root @@ -194,6 +203,10 @@ class DefaultLoginCredentialsFragment : Fragment() { null } } + + model.loginGoogle.value == true -> { + valid = true + } } return valid 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 633310e9bd1c412192853467e801f9b59b9c2077..30c9ed2d169fe304e96c6921250b2648076b9b12 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 @@ -25,9 +25,10 @@ class DefaultLoginCredentialsModel(app: Application): AndroidViewModel(app) { private var initialized = false - val loginWithEmailAddress = MutableLiveData() - val loginWithUrlAndUsername = MutableLiveData() - val loginAdvanced = MutableLiveData() + val loginWithEmailAddress = MutableLiveData(true) + val loginWithUrlAndUsername = MutableLiveData(false) + val loginAdvanced = MutableLiveData(false) + val loginGoogle = MutableLiveData(false) val baseUrl = MutableLiveData() val baseUrlError = MutableLiveData() @@ -42,14 +43,9 @@ class DefaultLoginCredentialsModel(app: Application): AndroidViewModel(app) { val certificateAlias = MutableLiveData() val certificateAliasError = MutableLiveData() - val loginUseUsernamePassword = MutableLiveData() - val loginUseClientCertificate = MutableLiveData() + val loginUseUsernamePassword = MutableLiveData(false) + val loginUseClientCertificate = MutableLiveData(false) - init { - loginWithEmailAddress.value = true - loginUseClientCertificate.value = false - loginUseUsernamePassword.value = false - } fun clearUrlError(s: Editable) { if (s.toString() != "https://") { 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 b945da0a8cb81e7ea2405a7e2eb79621afb89c19..c5286e67cfc56dc6eb0db624c273feb5e0df0bf4 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 @@ -10,6 +10,7 @@ import android.os.Bundle import android.view.LayoutInflater import android.view.View import android.view.ViewGroup +import androidx.core.content.ContextCompat import androidx.fragment.app.DialogFragment import androidx.fragment.app.Fragment import androidx.fragment.app.activityViewModels @@ -19,32 +20,38 @@ import androidx.lifecycle.LiveData import androidx.lifecycle.MutableLiveData import at.bitfire.davdroid.Constants import at.bitfire.davdroid.R +import at.bitfire.davdroid.db.Credentials import at.bitfire.davdroid.log.Logger import at.bitfire.davdroid.servicedetection.DavResourceFinder 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.net.URI import java.util.logging.Level import kotlin.concurrent.thread class DetectConfigurationFragment: Fragment() { - val loginModel by activityViewModels() - val model by viewModels() + private val loginModel by activityViewModels() + private val model by viewModels() + override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - if (model.blockProceedWithLogin(loginModel)) { + val accountType = requireActivity().intent.getStringExtra(LoginActivity.ACCOUNT_TYPE) + + if (model.blockProceedWithLogin(accountType)) { ECloudAccountHelper.showMultipleECloudAccountNotAcceptedDialog(requireActivity()) return } - val accountType = requireActivity().intent.getStringExtra(LoginActivity.ACCOUNT_TYPE) val isMurenaAccountType = (accountType == getString(R.string.eelo_account_type)) - model.detectConfiguration(loginModel, isMurenaAccountType).observe(this, { result -> + val baseURI = loginModel.baseURI ?: return + + model.detectConfiguration(baseURI, loginModel.credentials, loginModel.cardDavURI, isMurenaAccountType).observe(this) { result -> // save result for next step loginModel.configuration = result @@ -69,31 +76,28 @@ class DetectConfigurationFragment: Fragment() { .add(NothingDetectedFragment(), null) .commit() } - }) + } } override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View = inflater.inflate(R.layout.detect_configuration, container, false)!! - class DetectConfigurationModel( - application: Application - ): AndroidViewModel(application) { + class DetectConfigurationModel(application: Application): AndroidViewModel(application) { 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. + * User can't login using multiple murena (type) accounts. */ - fun blockProceedWithLogin(loginModel: LoginModel) : Boolean { + fun blockProceedWithLogin(accountType: String?) : Boolean { val context = getApplication() - return (loginModel.baseURI?.host.equals(Constants.EELO_SYNC_HOST) && ECloudAccountHelper.alreadyHasECloudAccount(context)) + return accountType == context.getString(R.string.eelo_account_type) && ECloudAccountHelper.alreadyHasECloudAccount(context) + } - fun detectConfiguration(loginModel: LoginModel, blockOnUnauthorizedException: Boolean): LiveData { + fun detectConfiguration(baseURI: URI, credentials: Credentials?, cardDavURI: URI?, blockOnUnauthorizedException: Boolean): LiveData { synchronized(result) { if (detectionThread != null) // detection already running @@ -106,7 +110,7 @@ class DetectConfigurationFragment: Fragment() { } try { - DavResourceFinder(getApplication(), loginModel, blockOnUnauthorizedException).use { finder -> + DavResourceFinder(getApplication(), baseURI, credentials, cardDavURI, blockOnUnauthorizedException).use { finder -> result.postValue(finder.findInitialConfiguration()) } } catch(e: Exception) { @@ -157,4 +161,4 @@ class DetectConfigurationFragment: Fragment() { } -} +} \ No newline at end of file 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 index 5a88ea3ce4dba0effbe0d975315d9a210c4f09e3..50061c91beeb83e72fd7a2a373ae35eb55ec7c91 100644 --- a/app/src/main/java/at/bitfire/davdroid/ui/setup/EeloAuthenticatorFragment.kt +++ b/app/src/main/java/at/bitfire/davdroid/ui/setup/EeloAuthenticatorFragment.kt @@ -20,12 +20,13 @@ import android.annotation.SuppressLint import android.content.Context import android.content.Intent import android.net.ConnectivityManager -import android.net.Uri import android.os.Bundle import android.view.LayoutInflater import android.view.View import android.view.ViewGroup +import android.widget.Button import android.widget.Toast +import androidx.core.content.ContextCompat import androidx.core.widget.doOnTextChanged import androidx.fragment.app.Fragment import androidx.fragment.app.activityViewModels @@ -37,8 +38,8 @@ import at.bitfire.davdroid.databinding.FragmentEeloAuthenticatorBinding import at.bitfire.davdroid.db.Credentials import at.bitfire.davdroid.ui.ShowUrlActivity import com.google.android.material.dialog.MaterialAlertDialogBuilder -import kotlinx.android.synthetic.main.fragment_eelo_authenticator.* -import kotlinx.android.synthetic.main.fragment_eelo_authenticator.view.* +import com.google.android.material.textfield.TextInputEditText +import com.google.android.material.textfield.TextInputLayout import java.net.URI class EeloAuthenticatorFragment : Fragment() { @@ -46,9 +47,15 @@ class EeloAuthenticatorFragment : Fragment() { private val model by viewModels() private val loginModel by activityViewModels() - val TOGGLE_BUTTON_CHECKED_KEY = "toggle_button_checked" + private val toggleButtonCheckedKey = "toggle_button_checked" var toggleButtonState = false + private lateinit var serverToggleButton: Button + private lateinit var userIdEditText: TextInputEditText + private lateinit var serverUrlEditTextLayout: TextInputLayout + private lateinit var serverUrlEditText: TextInputEditText + private lateinit var passwordEditText: TextInputEditText + private fun isNetworkAvailable(): Boolean { val connectivityManager = requireActivity().getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager val activeNetworkInfo = connectivityManager.activeNetworkInfo @@ -56,7 +63,7 @@ class EeloAuthenticatorFragment : Fragment() { } override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, - savedInstanceState: Bundle?): View? { + savedInstanceState: Bundle?): View { if (!isNetworkAvailable()) { Toast.makeText(context, "Please check your internet connection", Toast.LENGTH_LONG).show() @@ -67,36 +74,42 @@ class EeloAuthenticatorFragment : Fragment() { v.lifecycleOwner = this v.model = model - v.root.server_toggle_button.setOnClickListener() { expandCollapse() } + serverToggleButton = v.root.findViewById(R.id.server_toggle_button) + userIdEditText = v.root.findViewById(R.id.urlpwd_user_name) + serverUrlEditTextLayout = v.root.findViewById(R.id.urlpwd_server_uri_layout) + serverUrlEditText = v.root.findViewById(R.id.urlpwd_server_uri) + passwordEditText = v.root.findViewById(R.id.urlpwd_password) + + serverToggleButton.setOnClickListener { expandCollapse() } - v.root.sign_in.setOnClickListener { login() } + v.root.findViewById(R.id.sign_in).setOnClickListener { login() } - v.root.twofa_info_button.setOnClickListener { show2FAInfoDialog() } + v.root.findViewById(R.id.twofa_info_button).setOnClickListener { show2FAInfoDialog() } - v.root.urlpwd_user_name.doOnTextChanged { text, _, _, _ -> + userIdEditText.doOnTextChanged { text, _, _, _ -> val domain = computeDomain(text) if (domain.isEmpty()) { - requireView().urlpwd_server_uri_layout.hint = getString(R.string.login_server_uri) + serverUrlEditTextLayout.hint = getString(R.string.login_server_uri) } else { - requireView().urlpwd_server_uri_layout.hint = getString(R.string.login_server_uri_custom, domain) + serverUrlEditTextLayout.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) + toggleButtonState = savedInstanceState.getBoolean(toggleButtonCheckedKey, 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) + serverToggleButton.setCompoundDrawablesWithIntrinsicBounds(null, null , ContextCompat.getDrawable(requireContext(), R.drawable.ic_expand_less), null) + serverUrlEditTextLayout.visibility = View.VISIBLE + serverUrlEditText.isEnabled = 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) + serverToggleButton.setCompoundDrawablesWithIntrinsicBounds(null, null , ContextCompat.getDrawable(requireContext(), R.drawable.ic_expand_more), null) + serverUrlEditTextLayout.visibility = View.GONE + serverUrlEditText.isEnabled = false } return v.root } @@ -126,17 +139,17 @@ class EeloAuthenticatorFragment : Fragment() { */ @SuppressLint("SetTextI18n") private fun switchUserName(): Boolean { - if (urlpwd_user_name.text.toString().contains("@")) { - val username = urlpwd_user_name.text.toString().substringBefore("@") - val dns = urlpwd_user_name.text.toString().substringAfter("@") + if (userIdEditText.text.toString().contains("@")) { + val username = userIdEditText.text.toString().substringBefore("@") + val dns = userIdEditText.text.toString().substringAfter("@") if (dns == Constants.E_SYNC_URL) { - urlpwd_user_name.setText(username + "@" + Constants.EELO_SYNC_HOST) + userIdEditText.setText(username + "@" + Constants.EELO_SYNC_HOST) return true } if (dns == Constants.EELO_SYNC_HOST) { - urlpwd_user_name.setText(username + "@" + Constants.E_SYNC_URL) + userIdEditText.setText(username + "@" + Constants.E_SYNC_URL) return true } } @@ -145,7 +158,7 @@ class EeloAuthenticatorFragment : Fragment() { } override fun onSaveInstanceState(outState: Bundle) { - outState.putBoolean(TOGGLE_BUTTON_CHECKED_KEY, toggleButtonState) + outState.putBoolean(toggleButtonCheckedKey, toggleButtonState) super.onSaveInstanceState(outState) } @@ -157,10 +170,10 @@ class EeloAuthenticatorFragment : Fragment() { */ @SuppressLint("SetTextI18n") private fun purifyUserName(serverUrl: String) { - val providedUserName = requireView().urlpwd_user_name.text.toString() + val providedUserName = userIdEditText.text.toString() if (!providedUserName.contains("@") && serverUrl == "https://${Constants.EELO_SYNC_HOST}") { - requireView().urlpwd_user_name.setText("$providedUserName@${Constants.EELO_SYNC_HOST}") + userIdEditText.setText("$providedUserName@${Constants.EELO_SYNC_HOST}") } } @@ -183,9 +196,9 @@ class EeloAuthenticatorFragment : Fragment() { requireActivity().finish() } - if ((urlpwd_user_name.text.toString() != "") && (urlpwd_password.text.toString() != "")) { + if ((userIdEditText.text.toString() != "") && (passwordEditText.text.toString() != "")) { if (validate()) - requireFragmentManager().beginTransaction() + parentFragmentManager.beginTransaction() .replace(android.R.id.content, DetectConfigurationFragment(), null) .addToBackStack(null) .commit() @@ -206,10 +219,10 @@ class EeloAuthenticatorFragment : Fragment() { private fun validate(): Boolean { var valid = false - var serverUrl = requireView().urlpwd_server_uri.text.toString() + var serverUrl = serverUrlEditText.text.toString() if (serverUrl.isEmpty()) { - serverUrl = computeDomain(requireView().urlpwd_user_name.text.toString()) + serverUrl = computeDomain(userIdEditText.text.toString()) addSupportRetryOn401IfPossible(serverUrl) } @@ -219,7 +232,7 @@ class EeloAuthenticatorFragment : Fragment() { model.baseUrlError.value = null try { - val uri = URI(serverUrl) + val uri = getServerURI(serverUrl) if (uri.scheme.equals("http", true) || uri.scheme.equals("https", true)) { valid = true loginModel.baseURI = uri @@ -235,12 +248,12 @@ class EeloAuthenticatorFragment : Fragment() { model.loginWithUrlAndTokens.value == true -> { validateUrl() - val userName = requireView().urlpwd_user_name.text.toString() - val password = requireView().urlpwd_password.text.toString() + val userName = userIdEditText.text.toString() + val password = passwordEditText.text.toString() if (loginModel.baseURI != null) { valid = true - loginModel.credentials = Credentials(userName.toLowerCase(), password, null, null, loginModel.baseURI) + loginModel.credentials = Credentials(userName.lowercase(), password, null, null, loginModel.baseURI) } } @@ -249,19 +262,27 @@ class EeloAuthenticatorFragment : Fragment() { return valid } + private fun getServerURI(serverUrl: String): URI { + if (serverUrl.startsWith("https://${Constants.EELO_SYNC_HOST}")) { + return URI(Constants.MURENA_DAV_URL) + } + + return URI(serverUrl) + } + /** * 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) + serverToggleButton.setCompoundDrawablesWithIntrinsicBounds(null, null , ContextCompat.getDrawable(requireContext(), R.drawable.ic_expand_less), null) + serverUrlEditTextLayout.visibility = View.VISIBLE + serverUrlEditText.isEnabled = 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) + serverToggleButton.setCompoundDrawablesWithIntrinsicBounds(null, null , ContextCompat.getDrawable(requireContext(), R.drawable.ic_expand_more), null) + serverUrlEditTextLayout.visibility = View.GONE + serverUrlEditText.isEnabled = false toggleButtonState = false } } diff --git a/app/src/main/java/at/bitfire/davdroid/ui/setup/GoogleLoginFragment.kt b/app/src/main/java/at/bitfire/davdroid/ui/setup/GoogleLoginFragment.kt new file mode 100644 index 0000000000000000000000000000000000000000..8ebe158545ce0cbe56268af9b265fb1d0e70da1d --- /dev/null +++ b/app/src/main/java/at/bitfire/davdroid/ui/setup/GoogleLoginFragment.kt @@ -0,0 +1,327 @@ +/* + * 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.ActivityNotFoundException +import android.content.Context +import android.content.Intent +import android.net.Uri +import android.os.Bundle +import android.text.method.LinkMovementMethod +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.TextView +import androidx.activity.result.contract.ActivityResultContract +import androidx.compose.foundation.Image +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.* +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Warning +import androidx.compose.runtime.Composable +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.ColorFilter +import androidx.compose.ui.platform.ComposeView +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.intl.Locale +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.compose.ui.viewinterop.AndroidView +import androidx.core.text.HtmlCompat +import androidx.fragment.app.Fragment +import androidx.fragment.app.activityViewModels +import androidx.fragment.app.viewModels +import androidx.lifecycle.AndroidViewModel +import androidx.lifecycle.MutableLiveData +import androidx.lifecycle.viewModelScope +import at.bitfire.davdroid.App +import at.bitfire.davdroid.BuildConfig +import at.bitfire.davdroid.R +import at.bitfire.davdroid.db.Credentials +import at.bitfire.davdroid.log.Logger +import at.bitfire.davdroid.ui.UiUtils +import com.google.accompanist.themeadapter.material.MdcTheme +import com.google.android.material.snackbar.Snackbar +import dagger.hilt.android.AndroidEntryPoint +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import net.openid.appauth.* +import org.apache.commons.lang3.StringUtils +import java.net.URI +import java.util.logging.Level +import javax.inject.Inject + +@AndroidEntryPoint +class GoogleLoginFragment(private val defaultEmail: String? = null): Fragment() { + + companion object { + + // Google API Services User Data Policy + const val GOOGLE_POLICY_URL = "https://developers.google.com/terms/api-services-user-data-policy#additional_requirements_for_specific_api_scopes" + + // Support site + val URI_TESTED_WITH_GOOGLE: Uri = Uri.parse("https://www.davx5.com/tested-with/google") + + // davx5integration@gmail.com (for davx5-ose) + private const val CLIENT_ID = "1069050168830-eg09u4tk1cmboobevhm4k3bj1m4fav9i.apps.googleusercontent.com" + + val SCOPES = arrayOf( + "https://www.googleapis.com/auth/calendar", // CalDAV + "https://www.googleapis.com/auth/carddav" // CardDAV + ) + + private val serviceConfig = AuthorizationServiceConfiguration( + Uri.parse("https://accounts.google.com/o/oauth2/v2/auth"), + Uri.parse("https://oauth2.googleapis.com/token") + ) + + fun authRequestBuilder(clientId: String?) = + AuthorizationRequest.Builder( + serviceConfig, + clientId ?: CLIENT_ID, + ResponseTypeValues.CODE, + Uri.parse(BuildConfig.APPLICATION_ID + ":/oauth2/redirect") + ) + + /** + * Gets the Google CalDAV/CardDAV base URI. See https://developers.google.com/calendar/caldav/v2/guide; + * _calid_ of the primary calendar is the account name. + * + * This URL allows CardDAV (over well-known URLs) and CalDAV detection including calendar-homesets and secondary + * calendars. + */ + fun googleBaseUri(googleAccount: String): URI = + URI("https", "apidata.googleusercontent.com", "/caldav/v2/$googleAccount/user", null) + + } + + private val loginModel by activityViewModels() + private val model by viewModels() + + private val authRequestContract = registerForActivityResult(object: ActivityResultContract() { + override fun createIntent(context: Context, input: AuthorizationRequest) = + model.authService.getAuthorizationRequestIntent(input) + + override fun parseResult(resultCode: Int, intent: Intent?): AuthorizationResponse? = + intent?.let { AuthorizationResponse.fromIntent(it) } + }) { authResponse -> + if (authResponse != null) + model.authenticate(authResponse) + else + Snackbar.make(requireView(), R.string.login_oauth_couldnt_obtain_auth_code, Snackbar.LENGTH_LONG).show() + } + + + override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View { + val view = ComposeView(requireActivity()).apply { + setContent { + GoogleLogin(defaultEmail = defaultEmail, onLogin = { accountEmail, clientId -> + loginModel.baseURI = googleBaseUri(accountEmail) + loginModel.suggestedAccountName = accountEmail + + val authRequest = authRequestBuilder(clientId) + .setScopes(*SCOPES) + .setLoginHint(accountEmail) + .setUiLocales(Locale.current.toLanguageTag()) + .build() + + try { + authRequestContract.launch(authRequest) + } catch (e: ActivityNotFoundException) { + Logger.log.log(Level.WARNING, "Couldn't start OAuth intent", e) + Snackbar.make(requireView(), getString(R.string.install_browser), Snackbar.LENGTH_LONG).show() + } + }) + } + } + + model.credentials.observe(viewLifecycleOwner) { credentials -> + if (credentials != null) { + // pass credentials to login model + loginModel.credentials = credentials + + // continue with service detection + parentFragmentManager.beginTransaction() + .replace(android.R.id.content, DetectConfigurationFragment(), null) + .addToBackStack(null) + .commit() + + // reset because setting credentials LiveData represents a one-shot action + model.credentials.value = null + } + } + + return view + } + + + @HiltViewModel + class Model @Inject constructor( + application: Application, + val authService: AuthorizationService + ): AndroidViewModel(application) { + + val credentials = MutableLiveData() + + fun authenticate(resp: AuthorizationResponse) = viewModelScope.launch(Dispatchers.IO) { + val authState = AuthState(resp, null) // authorization code must not be stored; exchange it to refresh token + + authService.performTokenRequest(resp.createTokenExchangeRequest()) { tokenResponse: TokenResponse?, refreshTokenException: AuthorizationException? -> + Logger.log.info("Refresh token response: ${tokenResponse?.jsonSerializeString()}") + if (tokenResponse != null) { + // success + authState.update(tokenResponse, refreshTokenException) + // save authState (= refresh token) + credentials.postValue(Credentials(authState = authState)) + } + } + } + + override fun onCleared() { + authService.dispose() + } + + } + +} + + +@Composable +fun GoogleLogin( + defaultEmail: String?, + onLogin: (accountEmail: String, clientId: String?) -> Unit +) { + val context = LocalContext.current + MdcTheme { + Column( + Modifier + .padding(8.dp) + .verticalScroll(rememberScrollState())) { + Text( + stringResource(R.string.login_type_google), + style = MaterialTheme.typography.h5, + modifier = Modifier.padding(vertical = 16.dp)) + + Card(Modifier.fillMaxWidth()) { + Column(Modifier.padding(8.dp)) { + Row { + Image(Icons.Default.Warning, colorFilter = ColorFilter.tint(MaterialTheme.colors.onSurface), contentDescription = "", + modifier = Modifier.padding(top = 8.dp, end = 8.dp, bottom = 8.dp)) + Text(stringResource(R.string.login_google_see_tested_with)) + } + Text(stringResource(R.string.login_google_unexpected_warnings), modifier = Modifier.padding(vertical = 8.dp)) + Button( + onClick = { + UiUtils.launchUri(context, GoogleLoginFragment.URI_TESTED_WITH_GOOGLE) + }, + colors = ButtonDefaults.outlinedButtonColors(), + modifier = Modifier.wrapContentSize() + ) { + Text(stringResource(R.string.intro_more_info)) + } + } + } + + val email = rememberSaveable { mutableStateOf(defaultEmail ?: "") } + val emailError = remember { mutableStateOf(false) } + OutlinedTextField( + email.value, + singleLine = true, + onValueChange = { email.value = it }, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Email), + label = { Text(stringResource(R.string.login_google_account)) }, + isError = emailError.value, + placeholder = { Text("example@gmail.com") }, + modifier = Modifier + .fillMaxWidth() + .padding(top = 8.dp) + ) + + val userClientId = rememberSaveable { mutableStateOf("") } + val userClientIdError = remember { mutableStateOf(false) } + OutlinedTextField( + userClientId.value, + singleLine = true, + onValueChange = { clientId -> + userClientId.value = clientId + }, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Text), + label = { Text(stringResource(R.string.login_google_client_id)) }, + isError = userClientIdError.value, + placeholder = { Text("[...].apps.googleusercontent.com") }, + modifier = Modifier + .fillMaxWidth() + .padding(top = 8.dp) + ) + + Button( + onClick = { + val validEmail = email.value.contains('@') + emailError.value = !validEmail + + if (validEmail) { + val clientId = StringUtils.trimToNull(userClientId.value.trim()) + onLogin(email.value, clientId) + } + }, + modifier = Modifier + .padding(top = 8.dp) + .wrapContentSize(), + colors = ButtonDefaults.buttonColors( + backgroundColor = MaterialTheme.colors.surface + ) + ) { + Image( + painter = painterResource(R.drawable.google_g_logo), + contentDescription = stringResource(R.string.login_google), + modifier = Modifier.size(18.dp) + ) + Text( + text = stringResource(R.string.login_google), + modifier = Modifier.padding(start = 12.dp) + ) + } + + AndroidView({ context -> + TextView(context, null, 0, com.google.accompanist.themeadapter.material.R.style.TextAppearance_MaterialComponents_Body2).apply { + text = HtmlCompat.fromHtml(context.getString(R.string.login_google_client_privacy_policy, + context.getString(R.string.app_name), + App.homepageUrl(context, App.HOMEPAGE_PRIVACY) + ), 0) + movementMethod = LinkMovementMethod.getInstance() + } + }, modifier = Modifier.padding(top = 12.dp)) + + AndroidView({ context -> + TextView(context, null, 0, com.google.accompanist.themeadapter.material.R.style.TextAppearance_MaterialComponents_Body2).apply { + text = HtmlCompat.fromHtml(context.getString(R.string.login_google_client_limited_use, + context.getString(R.string.app_name), + GoogleLoginFragment.GOOGLE_POLICY_URL + ), 0) + movementMethod = LinkMovementMethod.getInstance() + } + }, modifier = Modifier.padding(top = 12.dp)) + } + } +} + +@Composable +@Preview( + showBackground = true +) +fun PreviewGoogleLogin() { + GoogleLogin(null) { _, _ -> } +} diff --git a/app/src/main/java/at/bitfire/davdroid/ui/setup/LoginModel.kt b/app/src/main/java/at/bitfire/davdroid/ui/setup/LoginModel.kt index c3410489edb2e5e52bbf0fb5cfeaa95eda0c77a1..6a02e740a4bb8c86ef61ecd2d1bab862e7fc2d4d 100644 --- a/app/src/main/java/at/bitfire/davdroid/ui/setup/LoginModel.kt +++ b/app/src/main/java/at/bitfire/davdroid/ui/setup/LoginModel.kt @@ -17,4 +17,9 @@ class LoginModel: ViewModel() { var configuration: DavResourceFinder.Configuration? = null + /** + * Account name that should be used as default account name when no email addresses have been found. + */ + var suggestedAccountName: String? = null + } diff --git a/app/src/main/java/at/bitfire/davdroid/ui/setup/NextcloudLoginFlowFragment.kt b/app/src/main/java/at/bitfire/davdroid/ui/setup/NextcloudLoginFlowFragment.kt index 561d0d228dd635e9835c6ccc183492478b14cf63..3e9a25403d0bc810a10b9525027b466c8f26d997 100644 --- a/app/src/main/java/at/bitfire/davdroid/ui/setup/NextcloudLoginFlowFragment.kt +++ b/app/src/main/java/at/bitfire/davdroid/ui/setup/NextcloudLoginFlowFragment.kt @@ -9,14 +9,16 @@ import android.app.Application import android.content.Intent import android.net.Uri import android.os.Bundle +import android.provider.Browser import android.view.LayoutInflater import android.view.View import android.view.ViewGroup import androidx.annotation.UiThread import androidx.annotation.WorkerThread import androidx.browser.customtabs.CustomTabsIntent -import androidx.browser.customtabs.CustomTabsService.ACTION_CUSTOM_TABS_CONNECTION +import androidx.compose.ui.text.intl.Locale import androidx.core.net.toUri +import androidx.core.os.bundleOf import androidx.fragment.app.Fragment import androidx.fragment.app.activityViewModels import androidx.fragment.app.viewModels @@ -24,11 +26,12 @@ import androidx.lifecycle.AndroidViewModel import androidx.lifecycle.MutableLiveData import at.bitfire.dav4jvm.exception.DavException import at.bitfire.dav4jvm.exception.HttpException -import at.bitfire.davdroid.HttpClient import at.bitfire.davdroid.R import at.bitfire.davdroid.db.Credentials import at.bitfire.davdroid.log.Logger +import at.bitfire.davdroid.network.HttpClient import at.bitfire.davdroid.ui.DebugInfoActivity +import at.bitfire.davdroid.ui.UiUtils.haveCustomTabs import com.google.android.material.snackbar.Snackbar import dagger.Binds import dagger.Module @@ -50,7 +53,6 @@ import java.net.HttpURLConnection import java.net.URI import javax.inject.Inject - class NextcloudLoginFlowFragment: Fragment() { companion object { @@ -87,12 +89,16 @@ class NextcloudLoginFlowFragment: Fragment() { // reset URL so that the browser isn't shown another time loginFlowModel.loginUrl.value = null - if (haveCustomTabs(loginUri)) { + if (haveCustomTabs(requireActivity())) { // Custom Tabs are available val browser = CustomTabsIntent.Builder() - .setToolbarColor(resources.getColor(R.color.primaryColor)) - .build() + .setToolbarColor(resources.getColor(R.color.primaryColor)) + .build() browser.intent.data = loginUri + browser.intent.putExtra( + Browser.EXTRA_HEADERS, + bundleOf("Accept-Language" to Locale.current.toLanguageTag()) + ) startActivityForResult(browser.intent, REQUEST_BROWSER, browser.startAnimationBundle) } else { @@ -133,24 +139,6 @@ class NextcloudLoginFlowFragment: Fragment() { return view } - private fun haveCustomTabs(uri: Uri): Boolean { - val browserIntent = Intent() - .setAction(Intent.ACTION_VIEW) - .addCategory(Intent.CATEGORY_BROWSABLE) - .setData(uri) - val pm = requireActivity().packageManager - val appsSupportingCustomTabs = pm.queryIntentActivities(browserIntent, 0) - for (pkg in appsSupportingCustomTabs) { - // check whether app resolves Custom Tabs service, too - val serviceIntent = Intent(ACTION_CUSTOM_TABS_CONNECTION).apply { - setPackage(pkg.activityInfo.packageName) - } - if (pm.resolveService(serviceIntent, 0) != null) - return true - } - return false - } - override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) { if (requestCode != REQUEST_BROWSER) return @@ -271,10 +259,10 @@ class NextcloudLoginFlowFragment: Fragment() { class Factory @Inject constructor(): LoginCredentialsFragmentFactory { override fun getFragment(intent: Intent) = - if (intent.hasExtra(EXTRA_LOGIN_FLOW)) - NextcloudLoginFlowFragment() - else - null + if (intent.hasExtra(EXTRA_LOGIN_FLOW)) + NextcloudLoginFlowFragment() + else + null } diff --git a/app/src/main/java/at/bitfire/davdroid/ui/setup/OpenIdAuthenticationViewModel.kt b/app/src/main/java/at/bitfire/davdroid/ui/setup/OpenIdAuthenticationViewModel.kt index f740d8c20609d34cb4f09d312d8300d74a255d51..b5c23d460068d4dc97449455adefe283f26a2204 100644 --- a/app/src/main/java/at/bitfire/davdroid/ui/setup/OpenIdAuthenticationViewModel.kt +++ b/app/src/main/java/at/bitfire/davdroid/ui/setup/OpenIdAuthenticationViewModel.kt @@ -22,6 +22,7 @@ import android.content.Intent import android.os.Build import androidx.annotation.WorkerThread import androidx.lifecycle.AndroidViewModel +import at.bitfire.davdroid.OpenIdUtils import at.bitfire.davdroid.authorization.IdentityProvider import at.bitfire.davdroid.log.Logger import net.openid.appauth.AuthState @@ -177,12 +178,7 @@ class OpenIdAuthenticationViewModel(application: Application) : AndroidViewModel ) { authState?.update(response, exception) - if (identityProvider?.clientSecret != null) { - val clientAuth = ClientSecretBasic(identityProvider!!.clientSecret!!) - authState?.performActionWithFreshTokens(authorizationService, clientAuth, callback) - return - } - - authState?.performActionWithFreshTokens(authorizationService, callback) + val clientAuth = OpenIdUtils.getClientAuthentication(identityProvider?.clientSecret) + authState?.performActionWithFreshTokens(authorizationService, clientAuth, callback) } } diff --git a/app/src/main/java/at/bitfire/davdroid/ui/webdav/AddWebdavMountActivity.kt b/app/src/main/java/at/bitfire/davdroid/ui/webdav/AddWebdavMountActivity.kt index 6c1f00544f0e59c1841d08210934e726558648c9..2cd8a9f15e1528b151c2784cce4c442d150fa306 100644 --- a/app/src/main/java/at/bitfire/davdroid/ui/webdav/AddWebdavMountActivity.kt +++ b/app/src/main/java/at/bitfire/davdroid/ui/webdav/AddWebdavMountActivity.kt @@ -18,7 +18,7 @@ import androidx.lifecycle.lifecycleScope import at.bitfire.dav4jvm.DavResource import at.bitfire.dav4jvm.UrlUtils import at.bitfire.davdroid.App -import at.bitfire.davdroid.HttpClient +import at.bitfire.davdroid.network.HttpClient import at.bitfire.davdroid.R import at.bitfire.davdroid.databinding.ActivityAddWebdavMountBinding import at.bitfire.davdroid.db.AppDatabase @@ -173,13 +173,16 @@ class AddWebdavMountActivity: AppCompatActivity() { fun hasWebDav(mount: WebDavMount, credentials: Credentials?): Boolean { var supported = false - HttpClient.Builder(context, null, credentials).build().use { client -> - val dav = DavResource(client.okHttpClient, mount.url, credentials?.authState?.accessToken) - dav.options { davCapabilities, _ -> - if (CollectionUtils.containsAny(davCapabilities, "1", "2", "3")) - supported = true + HttpClient.Builder(context, null, credentials) + .setForeground(true) + .build() + .use { client -> + val dav = DavResource(client.okHttpClient, mount.url) + dav.options { davCapabilities, _ -> + if (CollectionUtils.containsAny(davCapabilities, "1", "2", "3")) + supported = true + } } - } return supported } diff --git a/app/src/main/java/at/bitfire/davdroid/CompatUtils.kt b/app/src/main/java/at/bitfire/davdroid/util/CompatUtils.kt similarity index 93% rename from app/src/main/java/at/bitfire/davdroid/CompatUtils.kt rename to app/src/main/java/at/bitfire/davdroid/util/CompatUtils.kt index d705c8974b889f8cebf67c1717385db9de127e81..639758afcf024f34b1a713e555f9a3ab67316e0c 100644 --- a/app/src/main/java/at/bitfire/davdroid/CompatUtils.kt +++ b/app/src/main/java/at/bitfire/davdroid/util/CompatUtils.kt @@ -2,7 +2,7 @@ * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. **************************************************************************************************/ -package at.bitfire.davdroid +package at.bitfire.davdroid.util import android.content.ContentProviderClient import android.os.Build @@ -13,4 +13,4 @@ fun ContentProviderClient.closeCompat() { close() else release() -} +} \ No newline at end of file diff --git a/app/src/main/java/at/bitfire/davdroid/ConcurrentUtils.kt b/app/src/main/java/at/bitfire/davdroid/util/ConcurrentUtils.kt similarity index 97% rename from app/src/main/java/at/bitfire/davdroid/ConcurrentUtils.kt rename to app/src/main/java/at/bitfire/davdroid/util/ConcurrentUtils.kt index 57d41ff71c7cc5e6dfa2d8bd78c10525a2a9aa20..2163e3783134baf1ca64e90b06aaa6e748a6c0ff 100644 --- a/app/src/main/java/at/bitfire/davdroid/ConcurrentUtils.kt +++ b/app/src/main/java/at/bitfire/davdroid/util/ConcurrentUtils.kt @@ -2,7 +2,7 @@ * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. **************************************************************************************************/ -package at.bitfire.davdroid +package at.bitfire.davdroid.util import java.util.* diff --git a/app/src/main/java/at/bitfire/davdroid/DavUtils.kt b/app/src/main/java/at/bitfire/davdroid/util/DavUtils.kt similarity index 68% rename from app/src/main/java/at/bitfire/davdroid/DavUtils.kt rename to app/src/main/java/at/bitfire/davdroid/util/DavUtils.kt index db9a195bc009e298cd598e8ff8244574e726d682..8b436e07e81b97b6fa62409625af5a59ce7e1744 100644 --- a/app/src/main/java/at/bitfire/davdroid/DavUtils.kt +++ b/app/src/main/java/at/bitfire/davdroid/util/DavUtils.kt @@ -2,20 +2,14 @@ * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. **************************************************************************************************/ -package at.bitfire.davdroid +package at.bitfire.davdroid.util -import android.accounts.Account -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.davdroid.resource.LocalAddressBook -import at.bitfire.davdroid.resource.TaskUtils +import at.bitfire.davdroid.network.Android10Resolver import okhttp3.HttpUrl import okhttp3.MediaType import okhttp3.MediaType.Companion.toMediaType @@ -24,14 +18,10 @@ import java.net.InetAddress import java.util.* /** - * Some WebDAV and related network utility methods - */ + * Some WebDAV and HTTP network utility methods. + */ object DavUtils { - enum class SyncStatus { - ACTIVE, PENDING, IDLE - } - val DNS_QUAD9 = InetAddress.getByAddress(byteArrayOf(9,9,9,9)) const val MIME_TYPE_ACCEPT_ALL = "*/*" @@ -48,7 +38,6 @@ object DavUtils { 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) @@ -57,7 +46,6 @@ object DavUtils { return segments.firstOrNull { it.isNotEmpty() } ?: "/" } - fun prepareLookup(context: Context, lookup: Lookup) { if (Build.VERSION.SDK_INT >= 29) { /* Since Android 10, there's a native DnsResolver API that allows to send SRV queries without @@ -161,71 +149,6 @@ object DavUtils { } - /** - * 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 - - // 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 /** diff --git a/app/src/main/java/at/bitfire/davdroid/PermissionUtils.kt b/app/src/main/java/at/bitfire/davdroid/util/PermissionUtils.kt similarity index 95% rename from app/src/main/java/at/bitfire/davdroid/PermissionUtils.kt rename to app/src/main/java/at/bitfire/davdroid/util/PermissionUtils.kt index cc1df66890040c37909bb84a8dbc4a7cce8828fa..433267260864bd7b3c7440fe0ee07dfd8d037bc3 100644 --- a/app/src/main/java/at/bitfire/davdroid/PermissionUtils.kt +++ b/app/src/main/java/at/bitfire/davdroid/util/PermissionUtils.kt @@ -2,7 +2,7 @@ * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. **************************************************************************************************/ -package at.bitfire.davdroid +package at.bitfire.davdroid.util import android.Manifest import android.app.PendingIntent @@ -16,8 +16,11 @@ import androidx.core.app.NotificationCompat import androidx.core.app.NotificationManagerCompat import androidx.core.content.ContextCompat import androidx.core.location.LocationManagerCompat +import at.bitfire.davdroid.BuildConfig +import at.bitfire.davdroid.R import at.bitfire.davdroid.log.Logger import at.bitfire.davdroid.ui.NotificationUtils +import at.bitfire.davdroid.ui.NotificationUtils.notifyIfPossible import at.bitfire.davdroid.ui.PermissionsActivity object PermissionUtils { @@ -117,7 +120,7 @@ object PermissionUtils { .setAutoCancel(true) .build() NotificationManagerCompat.from(context) - .notify(NotificationUtils.NOTIFY_PERMISSIONS, notify) + .notifyIfPossible(NotificationUtils.NOTIFY_PERMISSIONS, notify) } fun showAppSettings(context: Context) { diff --git a/app/src/main/java/at/bitfire/davdroid/webdav/DavDocumentsProvider.kt b/app/src/main/java/at/bitfire/davdroid/webdav/DavDocumentsProvider.kt index 6ca88fd78ed76508d48bdd9cc84e1e9556b4f073..905e2832853739e965aa1b5275f284a0d633c6a5 100644 --- a/app/src/main/java/at/bitfire/davdroid/webdav/DavDocumentsProvider.kt +++ b/app/src/main/java/at/bitfire/davdroid/webdav/DavDocumentsProvider.kt @@ -17,7 +17,6 @@ import android.graphics.Point import android.media.ThumbnailUtils import android.net.ConnectivityManager import android.os.Build -import android.os.Bundle import android.os.CancellationSignal import android.os.ParcelFileDescriptor import android.os.storage.StorageManager @@ -31,14 +30,14 @@ import at.bitfire.dav4jvm.DavResource import at.bitfire.dav4jvm.Response import at.bitfire.dav4jvm.exception.HttpException import at.bitfire.dav4jvm.property.* -import at.bitfire.davdroid.HttpClient -import at.bitfire.davdroid.MemoryCookieStore import at.bitfire.davdroid.R import at.bitfire.davdroid.db.AppDatabase -import at.bitfire.davdroid.db.DaoTools import at.bitfire.davdroid.db.WebDavDocument import at.bitfire.davdroid.log.Logger +import at.bitfire.davdroid.network.HttpClient +import at.bitfire.davdroid.network.MemoryCookieStore import at.bitfire.davdroid.ui.webdav.WebdavMountsActivity +import at.bitfire.davdroid.webdav.DavDocumentsProvider.DavDocumentsActor import at.bitfire.davdroid.webdav.cache.HeadResponseCache import dagger.hilt.EntryPoint import dagger.hilt.InstallIn @@ -55,6 +54,11 @@ import java.util.concurrent.* import java.util.logging.Level import kotlin.math.min +/** + * Provides functionality on WebDav documents. + * + * Actual implementation should go into [DavDocumentsActor]. + */ class DavDocumentsProvider: DocumentsProvider() { @EntryPoint @@ -68,11 +72,12 @@ class DavDocumentsProvider: DocumentsProvider() { ResourceType.NAME, CurrentUserPrivilegeSet.NAME, DisplayName.NAME, + GetETag.NAME, GetContentType.NAME, GetContentLength.NAME, GetLastModified.NAME, QuotaAvailableBytes.NAME, - QuotaUsedBytes.NAME + QuotaUsedBytes.NAME, ) const val MAX_NAME_ATTEMPTS = 5 @@ -91,9 +96,9 @@ class DavDocumentsProvider: DocumentsProvider() { private val documentDao by lazy { db.webDavDocumentDao() } private val credentialsStore by lazy { CredentialsStore(ourContext) } - val cookieStore by lazy { mutableMapOf() } - val headResponseCache by lazy { HeadResponseCache() } - val thumbnailCache by lazy { ThumbnailCache(ourContext) } + private val cookieStore by lazy { mutableMapOf() } + private val headResponseCache by lazy { HeadResponseCache() } + private val thumbnailCache by lazy { ThumbnailCache(ourContext) } private val connectivityManager by lazy { ourContext.getSystemService()!! } private val storageManager by lazy { ourContext.getSystemService()!! } @@ -101,7 +106,14 @@ class DavDocumentsProvider: DocumentsProvider() { private val executor by lazy { ThreadPoolExecutor(1, min(Runtime.getRuntime().availableProcessors(), 4), 30, TimeUnit.SECONDS, BlockingLifoQueue()) } - private val runningQueryChildren = HashMap>>() + /** List of currently active [queryChildDocuments] runners. + * + * Key: document ID (directory) for which children are listed. + * Value: whether the runner is still running (*true*) or has already finished (*false*). + */ + private val runningQueryChildren = ConcurrentHashMap() + + private val actor by lazy { DavDocumentsActor(ourContext, db, cookieStore, credentialsStore, authority) } override fun onCreate() = true @@ -179,6 +191,14 @@ class DavDocumentsProvider: DocumentsProvider() { } } + /** + * Gets old or new children of given parent. + * + * Dispatches a worker querying the server for new children of given parent, and instantly + * returns old children (or nothing, on initial call). + * Once the worker finishes its query, it notifies the [android.content.ContentResolver] about + * change, which calls this method again. The worker being done + */ @Synchronized override fun queryChildDocuments(parentDocumentId: String, projection: Array?, sortOrder: String?): Cursor { Logger.log.fine("WebDAV queryChildDocuments $parentDocumentId $projection $sortOrder") @@ -193,104 +213,35 @@ class DavDocumentsProvider: DocumentsProvider() { Document.COLUMN_SIZE, Document.COLUMN_LAST_MODIFIED ) + + // Register watcher val result = DocumentsCursor(columns) val notificationUri = buildChildDocumentsUri(authority, parentDocumentId) + result.setNotificationUri(ourContext.contentResolver, notificationUri) - val worker = runningQueryChildren.getOrPut(parentId) { - executor.submit(Callable { - val rows = doQueryChildren(parent) + // Dispatch worker querying for the children and keep track of it + val running = runningQueryChildren.getOrPut(parentId) { + executor.submit { + actor.queryChildren(parent) + // Once the query is done, set query as finished (not running) + runningQueryChildren.put(parentId, false) + // .. and notify - effectively calling this method again ourContext.contentResolver.notifyChange(notificationUri, null) - return@Callable rows - }) + } + true } - if (!worker.isDone) { - // still loading, populate from cache + if (running) // worker still running result.loading = true - for (child in documentDao.getChildren(parentId)) { - val bundle = child.toBundle(parent) - result.addRow(bundle) - } - - } else { - try { - for (row in worker.get()) - result.addRow(row) - } catch (e: Exception) { - Logger.log.log(Level.WARNING, "Couldn't query children", e) - if (e is ExecutionException) { - val cause = e.cause - if (cause is HttpException) { - result.error = "${cause.code} ${cause.message}" - } else - result.error = (cause ?: e).message - } - } + else // remove worker from list if done runningQueryChildren.remove(parentId) - } - - result.setNotificationUri(ourContext.contentResolver, notificationUri) - return result - } - @WorkerThread - private fun doQueryChildren(parent: WebDavDocument): List { - val oldChildren = documentDao.getChildren(parent.id) - val newChildren = hashMapOf() - - httpClient(parent.mountId).use { client -> - val parentUrl = parent.toHttpUrl(db) - val folder = DavCollection(client.okHttpClient, parentUrl, null) - - folder.propfind(1, *DAV_FILE_FIELDS) { response, relation -> - Logger.log.fine("$relation $response") - - val resource: WebDavDocument = - when (relation) { - Response.HrefRelation.SELF -> // it's about the parent - parent - Response.HrefRelation.MEMBER -> // it's about a member - WebDavDocument(mountId = parent.mountId, parentId = parent.id, name = response.hrefName()) - else -> - // we didn't request this; ignore it - return@propfind - } - - response[ResourceType::class.java]?.types?.let { types -> - resource.isDirectory = types.contains(ResourceType.COLLECTION) - } - - resource.displayName = response[DisplayName::class.java]?.displayName - resource.mimeType = response[GetContentType::class.java]?.type - resource.eTag = response[GetETag::class.java]?.eTag - resource.lastModified = response[GetLastModified::class.java]?.lastModified - resource.size = response[GetContentLength::class.java]?.contentLength - - val privs = response[CurrentUserPrivilegeSet::class.java] - resource.mayBind = privs?.mayBind - resource.mayUnbind = privs?.mayUnbind - resource.mayWriteContent = privs?.mayWriteContent - - resource.quotaAvailable = response[QuotaAvailableBytes::class.java]?.quotaAvailableBytes - resource.quotaUsed = response[QuotaUsedBytes::class.java]?.quotaUsedBytes - - if (resource == parent) - documentDao.update(resource) - else - newChildren[resource.name] = resource - } - } - - DaoTools(documentDao).syncAll(oldChildren, newChildren, selectKey = { - document -> document.name - }) - - val result = ArrayList(newChildren.size) - for (child in newChildren.values) { + // Regardless of whether the worker is done, return the children we already have + for (child in documentDao.getChildren(parentId)) { val bundle = child.toBundle(parent) - Logger.log.fine("Found child: $bundle") - result += bundle + result.addRow(bundle) } + return result } @@ -325,10 +276,8 @@ class DavDocumentsProvider: DocumentsProvider() { throw UnsupportedOperationException("Can't COPY between WebDAV servers") val dstDocId: String - httpClient(srcDoc.mountId).use { client -> - val accessToken = credentialsStore.getCredentials(srcDoc.mountId)?.authState?.accessToken - - val dav = DavResource(client.okHttpClient, srcDoc.toHttpUrl(db), accessToken) + actor.httpClient(srcDoc.mountId).use { client -> + val dav = DavResource(client.okHttpClient, srcDoc.toHttpUrl(db)) try { val dstUrl = dstFolder.toHttpUrl(db).newBuilder() .addPathSegment(name) @@ -347,7 +296,7 @@ class DavDocumentsProvider: DocumentsProvider() { size = srcDoc.size )).toString() - notifyFolderChanged(targetParentDocumentId) + actor.notifyFolderChanged(targetParentDocumentId) } catch (e: HttpException) { if (e.code == HttpURLConnection.HTTP_NOT_FOUND) throw FileNotFoundException() @@ -365,16 +314,14 @@ class DavDocumentsProvider: DocumentsProvider() { val createDirectory = mimeType == Document.MIME_TYPE_DIR var docId: Long? = null - httpClient(parent.mountId).use { client -> + actor.httpClient(parent.mountId).use { client -> for (attempt in 0..MAX_NAME_ATTEMPTS) { val newName = displayNameToMemberName(displayName, attempt) val newLocation = parentUrl.newBuilder() .addPathSegment(newName) .build() - val accessToken = credentialsStore.getCredentials(parent.mountId)?.authState?.accessToken - - val doc = DavResource(client.okHttpClient, newLocation, accessToken) + val doc = DavResource(client.okHttpClient, newLocation) try { if (createDirectory) doc.mkCol(null) { @@ -393,7 +340,7 @@ class DavDocumentsProvider: DocumentsProvider() { isDirectory = createDirectory )) - notifyFolderChanged(parentDocumentId) + actor.notifyFolderChanged(parentDocumentId) break } catch (e: HttpException) { e.throwForDocumentProvider(true) @@ -407,10 +354,8 @@ class DavDocumentsProvider: DocumentsProvider() { override fun deleteDocument(documentId: String) { Logger.log.fine("WebDAV removeDocument $documentId") val doc = documentDao.get(documentId.toLong()) ?: throw FileNotFoundException() - httpClient(doc.mountId).use { client -> - val accessToken = credentialsStore.getCredentials(doc.mountId)?.authState?.accessToken - - val dav = DavResource(client.okHttpClient, doc.toHttpUrl(db), accessToken) + actor.httpClient(doc.mountId).use { client -> + val dav = DavResource(client.okHttpClient, doc.toHttpUrl(db)) try { dav.delete { // successfully deleted @@ -418,7 +363,7 @@ class DavDocumentsProvider: DocumentsProvider() { Logger.log.fine("Successfully removed") documentDao.delete(doc) - notifyFolderChanged(doc.parentId) + actor.notifyFolderChanged(doc.parentId) } catch (e: HttpException) { e.throwForDocumentProvider() } @@ -437,10 +382,8 @@ class DavDocumentsProvider: DocumentsProvider() { .addPathSegment(doc.name) .build() - httpClient(doc.mountId).use { client -> - val accessToken = credentialsStore.getCredentials(doc.mountId)?.authState?.accessToken - - val dav = DavResource(client.okHttpClient, doc.toHttpUrl(db), accessToken) + actor.httpClient(doc.mountId).use { client -> + val dav = DavResource(client.okHttpClient, doc.toHttpUrl(db)) try { dav.move(newLocation, false) { // successfully moved @@ -449,8 +392,8 @@ class DavDocumentsProvider: DocumentsProvider() { doc.parentId = dstParent.id documentDao.update(doc) - notifyFolderChanged(sourceParentDocumentId) - notifyFolderChanged(targetParentDocumentId) + actor.notifyFolderChanged(sourceParentDocumentId) + actor.notifyFolderChanged(targetParentDocumentId) } catch (e: HttpException) { e.throwForDocumentProvider() } @@ -463,7 +406,7 @@ class DavDocumentsProvider: DocumentsProvider() { Logger.log.fine("WebDAV renameDocument $documentId $displayName") val doc = documentDao.get(documentId.toLong()) ?: throw FileNotFoundException() val oldUrl = doc.toHttpUrl(db) - httpClient(doc.mountId).use { client -> + actor.httpClient(doc.mountId).use { client -> for (attempt in 0..MAX_NAME_ATTEMPTS) { val newName = displayNameToMemberName(displayName, attempt) val newLocation = oldUrl.newBuilder() @@ -471,16 +414,14 @@ class DavDocumentsProvider: DocumentsProvider() { .addPathSegment(newName) .build() try { - val accessToken = credentialsStore.getCredentials(doc.mountId)?.authState?.accessToken - - val dav = DavResource(client.okHttpClient, oldUrl, accessToken) + val dav = DavResource(client.okHttpClient, oldUrl) dav.move(newLocation, false) { // successfully renamed } doc.name = newName documentDao.update(doc) - notifyFolderChanged(doc.parentId) + actor.notifyFolderChanged(doc.parentId) return doc.id.toString() } catch (e: HttpException) { e.throwForDocumentProvider(true) @@ -512,7 +453,7 @@ class DavDocumentsProvider: DocumentsProvider() { val doc = documentDao.get(documentId.toLong()) ?: throw FileNotFoundException() val url = doc.toHttpUrl(db) - val client = httpClient(doc.mountId) + val client = actor.httpClient(doc.mountId) val modeFlags = ParcelFileDescriptor.parseMode(mode) val readAccess = when (mode) { @@ -524,7 +465,7 @@ class DavDocumentsProvider: DocumentsProvider() { val fileInfo = headResponseCache.get(doc) { val accessToken = credentialsStore.getCredentials(doc.mountId)?.authState?.accessToken - val deferredFileInfo = executor.submit(HeadInfoDownloader(client, url, accessToken)) + val deferredFileInfo = executor.submit(HeadInfoDownloader(client, url)) signal?.setOnCancelListener { deferredFileInfo.cancel(true) } @@ -541,7 +482,7 @@ class DavDocumentsProvider: DocumentsProvider() { ) { val accessToken = credentialsStore.getCredentials(doc.mountId)?.authState?.accessToken - val accessor = RandomAccessCallback.Wrapper(ourContext, client, url, doc.mimeType, fileInfo, signal, accessToken) + val accessor = RandomAccessCallback.Wrapper(ourContext, client, url, doc.mimeType, fileInfo, signal) storageManager.openProxyFileDescriptor(modeFlags, accessor, accessor.callback!!.workerHandler) } else { val accessToken = credentialsStore.getCredentials(doc.mountId)?.authState?.accessToken @@ -557,7 +498,7 @@ class DavDocumentsProvider: DocumentsProvider() { documentDao.update(doc) } - notifyFolderChanged(doc.parentId) + actor.notifyFolderChanged(doc.parentId) } if (readAccess) @@ -584,12 +525,9 @@ class DavDocumentsProvider: DocumentsProvider() { val thumbFile = thumbnailCache.get(doc, sizeHint) { // create thumbnail val result = executor.submit(Callable { - - httpClient(doc.mountId).use { client -> - val accessToken = credentialsStore.getCredentials(doc.mountId)?.authState?.accessToken - + actor.httpClient(doc.mountId).use { client -> val url = doc.toHttpUrl(db) - val dav = DavResource(client.okHttpClient, url, accessToken) + val dav = DavResource(client.okHttpClient, url) var result: ByteArray? = null dav.get("image/*", null) { response -> response.body?.byteStream()?.use { data -> @@ -635,26 +573,122 @@ class DavDocumentsProvider: DocumentsProvider() { } - // helpers + /** + * Acts on behalf of [DavDocumentsProvider]. + * + * Encapsulates functionality to make it easily testable without generating lots of + * DocumentProviders during the tests. + * + * By containing the actual implementation logic of [DavDocumentsProvider], it adds a layer of separation + * to make the methods of [DavDocumentsProvider] more easily testable. + * [DavDocumentsProvider]s methods should do nothing more, but to call [DavDocumentsActor]s methods. + */ + class DavDocumentsActor( + private val context: Context, + private val db: AppDatabase, + private val cookieStore: MutableMap, + private val credentialsStore: CredentialsStore, + private val authority: String + ) { + private val documentDao = db.webDavDocumentDao() + + /** + * Finds children of given parent [WebDavDocument]. After querying, it + * updates existing children, adds new ones or removes deleted ones. + * + * There must never be more than one running instance per [parent]! + * + * @param parent folder to search for children + */ + @WorkerThread + internal fun queryChildren(parent: WebDavDocument) { + val oldChildren = documentDao.getChildren(parent.id).associateBy { it.name }.toMutableMap() // "name" of file/folder must be unique + val newChildrenList = hashMapOf() + + httpClient(parent.mountId).use { client -> + val parentUrl = parent.toHttpUrl(db) + val folder = DavCollection(client.okHttpClient, parentUrl) + + try { + folder.propfind(1, *DAV_FILE_FIELDS) { response, relation -> + Logger.log.fine("$relation $response") + + val resource: WebDavDocument = + when (relation) { + Response.HrefRelation.SELF -> // it's about the parent + parent + Response.HrefRelation.MEMBER -> // it's about a member + WebDavDocument(mountId = parent.mountId, parentId = parent.id, name = response.hrefName()) + else -> + // we didn't request this; ignore it + return@propfind + } + + response[ResourceType::class.java]?.types?.let { types -> + resource.isDirectory = types.contains(ResourceType.COLLECTION) + } + + resource.displayName = response[DisplayName::class.java]?.displayName + resource.mimeType = response[GetContentType::class.java]?.type + response[GetETag::class.java]?.let { getETag -> + if (!getETag.weak) + resource.eTag = resource.eTag + } + resource.lastModified = response[GetLastModified::class.java]?.lastModified?.toInstant()?.toEpochMilli() + resource.size = response[GetContentLength::class.java]?.contentLength + + val privs = response[CurrentUserPrivilegeSet::class.java] + resource.mayBind = privs?.mayBind + resource.mayUnbind = privs?.mayUnbind + resource.mayWriteContent = privs?.mayWriteContent + + resource.quotaAvailable = response[QuotaAvailableBytes::class.java]?.quotaAvailableBytes + resource.quotaUsed = response[QuotaUsedBytes::class.java]?.quotaUsedBytes + + if (resource == parent) + documentDao.update(resource) + else { + documentDao.insertOrUpdate(resource) + newChildrenList[resource.name] = resource + } + + // remove resource from known child nodes, because not found on server + oldChildren.remove(resource.name) + } + } catch (e: Exception) { + Logger.log.log(Level.WARNING, "Couldn't query children", e) + } + } + + // Delete child nodes which were not rediscovered (deleted serverside) + for ((_, oldChild) in oldChildren) + documentDao.delete(oldChild) + } + + + // helpers - private fun httpClient(mountId: Long): HttpClient { - val builder = HttpClient.Builder(ourContext, loggerLevel = HttpLoggingInterceptor.Level.HEADERS) - .cookieStore(cookieStore.getOrPut(mountId) { MemoryCookieStore() }) + internal fun httpClient(mountId: Long): HttpClient { + val builder = HttpClient.Builder(context, loggerLevel = HttpLoggingInterceptor.Level.HEADERS) + .cookieStore(cookieStore.getOrPut(mountId) { MemoryCookieStore() }) - credentialsStore.getCredentials(mountId)?.let { credentials -> - builder.addAuthentication(null, credentials, true) + credentialsStore.getCredentials(mountId)?.let { credentials -> + builder.addAuthentication(null, credentials, true) + } + + return builder.build() } - return builder.build() - } + internal fun notifyFolderChanged(parentDocumentId: Long?) { + if (parentDocumentId != null) + context.contentResolver.notifyChange(buildChildDocumentsUri(authority, parentDocumentId.toString()), null) + } + + internal fun notifyFolderChanged(parentDocumentId: String) { + context.contentResolver.notifyChange(buildChildDocumentsUri(authority, parentDocumentId), null) + } - private fun notifyFolderChanged(parentDocumentId: Long?) { - if (parentDocumentId != null) - ourContext.contentResolver.notifyChange(buildChildDocumentsUri(authority, parentDocumentId.toString()), null) - } - private fun notifyFolderChanged(parentDocumentId: String) { - ourContext.contentResolver.notifyChange(buildChildDocumentsUri(authority, parentDocumentId), null) } diff --git a/app/src/main/java/at/bitfire/davdroid/webdav/DocumentState.kt b/app/src/main/java/at/bitfire/davdroid/webdav/DocumentState.kt index a1de4679be67881f09288766a0155c993cc8f507..9a2923c4bbc0f05eb0cc0c58536d1a943add0872 100644 --- a/app/src/main/java/at/bitfire/davdroid/webdav/DocumentState.kt +++ b/app/src/main/java/at/bitfire/davdroid/webdav/DocumentState.kt @@ -5,11 +5,11 @@ package at.bitfire.davdroid.webdav import at.bitfire.davdroid.webdav.cache.CacheUtils -import java.util.* +import java.time.Instant data class DocumentState( val eTag: String? = null, - val lastModified: Date? = null + val lastModified: Instant? = null ) { init { diff --git a/app/src/main/java/at/bitfire/davdroid/webdav/HeadInfoDownloader.kt b/app/src/main/java/at/bitfire/davdroid/webdav/HeadInfoDownloader.kt index 7e499d4984444d6ffd4b8c4120cb7008f339d753..718432522f0a3748de49080951ec8208d099964c 100644 --- a/app/src/main/java/at/bitfire/davdroid/webdav/HeadInfoDownloader.kt +++ b/app/src/main/java/at/bitfire/davdroid/webdav/HeadInfoDownloader.kt @@ -6,30 +6,31 @@ package at.bitfire.davdroid.webdav import at.bitfire.dav4jvm.DavResource import at.bitfire.dav4jvm.HttpUtils -import at.bitfire.dav4jvm.QuotedStringUtils -import at.bitfire.davdroid.HttpClient +import at.bitfire.dav4jvm.property.GetETag +import at.bitfire.davdroid.network.HttpClient import okhttp3.HttpUrl -import java.util.* +import java.time.Instant import java.util.concurrent.Callable class HeadInfoDownloader( val client: HttpClient, val url: HttpUrl, - val accessToken: String? = null ): Callable { override fun call(): HeadResponse { var size: Long? = null var eTag: String? = null - var lastModified: Date? = null + var lastModified: Instant? = null var supportsPartial: Boolean? = null - DavResource(client.okHttpClient, url, accessToken).head { response -> + DavResource(client.okHttpClient, url).head { response -> response.header("ETag", null)?.let { - eTag = QuotedStringUtils.decodeQuotedString(it.removeSuffix("W/")) + val getETag = GetETag(it) + if (!getETag.weak) + eTag = getETag.eTag } response.header("Last-Modified", null)?.let { - lastModified = HttpUtils.parseDate(it) + lastModified = HttpUtils.parseDate(it)?.toInstant() } response.headers["Content-Length"]?.let { size = it.toLong() diff --git a/app/src/main/java/at/bitfire/davdroid/webdav/HeadResponse.kt b/app/src/main/java/at/bitfire/davdroid/webdav/HeadResponse.kt index 33736854f955c7e505d07329ec7ef861122a85a7..67bad5b7aa062c66be112550a17d2523916f06ed 100644 --- a/app/src/main/java/at/bitfire/davdroid/webdav/HeadResponse.kt +++ b/app/src/main/java/at/bitfire/davdroid/webdav/HeadResponse.kt @@ -4,7 +4,7 @@ package at.bitfire.davdroid.webdav -import java.util.* +import java.time.Instant /** * Represents the information that was retrieved via a HEAD request before @@ -13,7 +13,7 @@ import java.util.* data class HeadResponse( val size: Long? = null, val eTag: String? = null, - val lastModified: Date? = null, + val lastModified: Instant? = null, val supportsPartial: Boolean? = null ) { diff --git a/app/src/main/java/at/bitfire/davdroid/webdav/RandomAccessCallback.kt b/app/src/main/java/at/bitfire/davdroid/webdav/RandomAccessCallback.kt index 5cb33ee8583810699d86cf09dd2c2d673d680a22..dc6ee69e487010c5b7d1078d5d3a71183b901ff4 100644 --- a/app/src/main/java/at/bitfire/davdroid/webdav/RandomAccessCallback.kt +++ b/app/src/main/java/at/bitfire/davdroid/webdav/RandomAccessCallback.kt @@ -22,7 +22,10 @@ import at.bitfire.dav4jvm.exception.DavException import at.bitfire.dav4jvm.exception.HttpException import at.bitfire.davdroid.* import at.bitfire.davdroid.log.Logger +import at.bitfire.davdroid.network.HttpClient import at.bitfire.davdroid.ui.NotificationUtils +import at.bitfire.davdroid.ui.NotificationUtils.notifyIfPossible +import at.bitfire.davdroid.util.DavUtils import at.bitfire.davdroid.webdav.cache.MemoryCache import at.bitfire.davdroid.webdav.cache.SegmentedCache import okhttp3.Headers @@ -44,7 +47,6 @@ class RandomAccessCallback private constructor( val mimeType: MediaType?, val headResponse: HeadResponse, val cancellationSignal: CancellationSignal?, - val accessToken: String? = null ): ProxyFileDescriptorCallback(), SegmentedCache.PageLoader { companion object { @@ -71,7 +73,7 @@ class RandomAccessCallback private constructor( } - private val dav = DavResource(httpClient.okHttpClient, url, accessToken) + private val dav = DavResource(httpClient.okHttpClient, url) private val fileSize = headResponse.size ?: throw IllegalArgumentException("Can only be used with given file size") private val documentState = headResponse.toDocumentState() ?: throw IllegalArgumentException("Can only be used with ETag/Last-Modified") @@ -114,7 +116,7 @@ class RandomAccessCallback private constructor( 100 else (offset*100/fileSize).toInt() - notificationManager.notify( + notificationManager.notifyIfPossible( notificationTag, NotificationUtils.NOTIFY_WEBDAV_ACCESS, notification.setProgress(100, progress, false).build() @@ -214,7 +216,7 @@ class RandomAccessCallback private constructor( accessToken: String? = null ): ProxyFileDescriptorCallback() { - var callback: RandomAccessCallback? = RandomAccessCallback(context, httpClient, url, mimeType, headResponse, cancellationSignal, accessToken) + var callback: RandomAccessCallback? = RandomAccessCallback(context, httpClient, url, mimeType, headResponse, cancellationSignal) override fun onFsync() = callback?.onFsync() ?: throw IllegalStateException("Must not be called after onRelease()") diff --git a/app/src/main/java/at/bitfire/davdroid/webdav/StreamingFileDescriptor.kt b/app/src/main/java/at/bitfire/davdroid/webdav/StreamingFileDescriptor.kt index 2b9cb19ea5aca410213fad5c9a48c666dc0a1539..7b311a5c2cb2580e059ff4b37f098e0e0f7f5e26 100644 --- a/app/src/main/java/at/bitfire/davdroid/webdav/StreamingFileDescriptor.kt +++ b/app/src/main/java/at/bitfire/davdroid/webdav/StreamingFileDescriptor.kt @@ -12,11 +12,12 @@ import androidx.core.app.NotificationCompat import androidx.core.app.NotificationManagerCompat import at.bitfire.dav4jvm.DavResource import at.bitfire.dav4jvm.exception.HttpException -import at.bitfire.davdroid.DavUtils -import at.bitfire.davdroid.HttpClient +import at.bitfire.davdroid.network.HttpClient import at.bitfire.davdroid.R import at.bitfire.davdroid.log.Logger import at.bitfire.davdroid.ui.NotificationUtils +import at.bitfire.davdroid.ui.NotificationUtils.notifyIfPossible +import at.bitfire.davdroid.util.DavUtils import okhttp3.HttpUrl import okhttp3.MediaType import okhttp3.RequestBody @@ -42,7 +43,7 @@ class StreamingFileDescriptor( private const val BUFFER_SIZE = FileUtils.ONE_MB.toInt() } - val dav = DavResource(client.okHttpClient, url, accessToken) + val dav = DavResource(client.okHttpClient, url) var transferred: Long = 0 private val notificationManager = NotificationManagerCompat.from(context) @@ -106,7 +107,7 @@ class StreamingFileDescriptor( notification.setContentTitle(context.getString(R.string.webdav_notification_download)) if (length == -1L) // unknown file size, show notification now (no updates on progress) - notificationManager.notify( + notificationManager.notifyIfPossible( notificationTag, NotificationUtils.NOTIFY_WEBDAV_ACCESS, notification @@ -125,7 +126,7 @@ class StreamingFileDescriptor( while (bytes != -1) { // update notification (if file size is known) if (length != -1L) - notificationManager.notify( + notificationManager.notifyIfPossible( notificationTag, NotificationUtils.NOTIFY_WEBDAV_ACCESS, notification @@ -156,7 +157,7 @@ class StreamingFileDescriptor( override fun contentType(): MediaType? = mimeType override fun isOneShot() = true override fun writeTo(sink: BufferedSink) { - notificationManager.notify( + notificationManager.notifyIfPossible( notificationTag, NotificationUtils.NOTIFY_WEBDAV_ACCESS, notification @@ -181,7 +182,7 @@ class StreamingFileDescriptor( } } } - DavResource(client.okHttpClient, url, accessToken).put(body) { + DavResource(client.okHttpClient, url).put(body) { // upload successful } } diff --git a/app/src/main/java/at/bitfire/davdroid/webdav/cache/HeadResponseCache.kt b/app/src/main/java/at/bitfire/davdroid/webdav/cache/HeadResponseCache.kt index 41f8d0fe4a560d48d8dfd2d551eceaf02e9d0303..a78284eb1d4c0ddc2775b7f6a50e9198a34ffe14 100644 --- a/app/src/main/java/at/bitfire/davdroid/webdav/cache/HeadResponseCache.kt +++ b/app/src/main/java/at/bitfire/davdroid/webdav/cache/HeadResponseCache.kt @@ -8,6 +8,7 @@ import android.util.LruCache import at.bitfire.davdroid.db.WebDavDocument import at.bitfire.davdroid.webdav.DocumentState import at.bitfire.davdroid.webdav.HeadResponse +import java.time.Instant import java.util.* class HeadResponseCache { @@ -28,7 +29,7 @@ class HeadResponseCache { fun get(doc: WebDavDocument, generate: () -> HeadResponse): HeadResponse { var key: Key? = null if (doc.eTag != null || doc.lastModified != null) { - key = Key(doc.id, DocumentState(doc.eTag, doc.lastModified?.let { ts -> Date(ts) })) + key = Key(doc.id, DocumentState(doc.eTag, doc.lastModified?.let { ts -> Instant.ofEpochMilli(ts) })) cache.get(key)?.let { info -> return info } diff --git a/app/src/main/java/com/nextcloud/android/sso/InputStreamBinder.java b/app/src/main/java/com/nextcloud/android/sso/InputStreamBinder.java index e0d2016cb651278e7aeae156c379ebf70b80c5be..26d5522f054fff04a598bbdb68581354db337cfa 100644 --- a/app/src/main/java/com/nextcloud/android/sso/InputStreamBinder.java +++ b/app/src/main/java/com/nextcloud/android/sso/InputStreamBinder.java @@ -43,11 +43,8 @@ import com.nextcloud.android.sso.aidl.NextcloudRequest; import com.nextcloud.android.sso.aidl.ParcelFileDescriptorUtil; import com.nextcloud.android.utils.AccountManagerUtils; import com.nextcloud.android.utils.EncryptionUtils; -import com.owncloud.android.lib.common.OwnCloudAccount; import com.owncloud.android.lib.common.OwnCloudClient; import com.owncloud.android.lib.common.OwnCloudClientFactory; -import com.owncloud.android.lib.common.OwnCloudClientManager; -import com.owncloud.android.lib.common.OwnCloudClientManagerFactory; import com.owncloud.android.lib.common.OwnCloudCredentialsFactory; import com.owncloud.android.lib.common.accounts.AccountUtils; diff --git a/app/src/main/java/com/owncloud/android/services/AccountManagerService.java b/app/src/main/java/com/owncloud/android/services/AccountManagerService.java index 276f3686b67ff3041df196b5effb0c78197ed714..99af5616acfce97d2d2d591ca08538fadaca7cea 100644 --- a/app/src/main/java/com/owncloud/android/services/AccountManagerService.java +++ b/app/src/main/java/com/owncloud/android/services/AccountManagerService.java @@ -34,7 +34,7 @@ public class AccountManagerService extends Service { @Override public IBinder onBind(Intent intent) { - if(mBinder == null) { + if (mBinder == null) { mBinder = new InputStreamBinder(getApplicationContext()); } return mBinder; diff --git a/app/src/main/java/com/owncloud/android/ui/activity/SsoGrantPermissionActivity.java b/app/src/main/java/com/owncloud/android/ui/activity/SsoGrantPermissionActivity.java index c62ae46ee1676e08b9ce52d0567b78cf031fa723..5b0607f5310efac8dc4d852ef1ce294ac3540866 100644 --- a/app/src/main/java/com/owncloud/android/ui/activity/SsoGrantPermissionActivity.java +++ b/app/src/main/java/com/owncloud/android/ui/activity/SsoGrantPermissionActivity.java @@ -35,6 +35,7 @@ import android.content.Intent; import android.content.SharedPreferences; import android.os.Bundle; +import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.appcompat.app.AppCompatActivity; @@ -46,7 +47,6 @@ import java.util.Arrays; import java.util.UUID; import java.util.logging.Level; -import at.bitfire.davdroid.Constants; import at.bitfire.davdroid.R; import at.bitfire.davdroid.log.Logger; @@ -83,6 +83,7 @@ public class SsoGrantPermissionActivity extends AppCompatActivity { grantPermission(); } + private boolean isValidRequest() { if (packageName == null || account == null) { return false; @@ -100,7 +101,7 @@ public class SsoGrantPermissionActivity extends AppCompatActivity { } private String[] getAcceptedAccountTypeList() { - return new String[] { + return new String[]{ getString(R.string.eelo_account_type) }; } @@ -114,16 +115,16 @@ public class SsoGrantPermissionActivity extends AppCompatActivity { // create token String token = UUID.randomUUID().toString().replaceAll("-", ""); - String userId = account.name; + String userId = sanitizeUserId(account.name); - saveToken(token, userId); + saveToken(token, account.name); setResultData(token, userId, serverUrl); finish(); } private void setResultData(String token, String userId, String serverUrl) { final Bundle result = new Bundle(); - result.putString(AccountManager.KEY_ACCOUNT_NAME, userId); + result.putString(AccountManager.KEY_ACCOUNT_NAME, account.name); result.putString(AccountManager.KEY_ACCOUNT_TYPE, account.type); result.putString(AccountManager.KEY_AUTHTOKEN, NEXTCLOUD_SSO); result.putString(SSO_USER_ID, userId); @@ -135,6 +136,23 @@ public class SsoGrantPermissionActivity extends AppCompatActivity { setResult(RESULT_OK, data); } + /** + * Murena account's userId is set same as it's email address. + * For old accounts (@e.email) userId = email. + * For new accounts (@murena.io) userId is first part of email (ex: for email abc@murena.io, userId is abc). + * For api requests, we needed to pass the actual userId. This method remove the unwanted part (@murena.io) from the userId + */ + @NonNull + private static String sanitizeUserId(@NonNull String userId) { + final String murenaMailEndPart = "@murena.io"; + + if (!userId.endsWith(murenaMailEndPart)) { + return userId; + } + + return userId.split(murenaMailEndPart)[0]; + } + @Nullable private String getServerUrl() { try { @@ -148,11 +166,11 @@ public class SsoGrantPermissionActivity extends AppCompatActivity { return null; } - private void saveToken(String token, String userId) { + private void saveToken(String token, String accountName) { String hashedTokenWithSalt = EncryptionUtils.generateSHA512(token); SharedPreferences sharedPreferences = getSharedPreferences(SSO_SHARED_PREFERENCE, Context.MODE_PRIVATE); SharedPreferences.Editor editor = sharedPreferences.edit(); - editor.putString(packageName + DELIMITER + userId, hashedTokenWithSalt); + editor.putString(packageName + DELIMITER + accountName, hashedTokenWithSalt); editor.apply(); } diff --git a/app/src/main/res/drawable/google_g_logo.xml b/app/src/main/res/drawable/google_g_logo.xml new file mode 100644 index 0000000000000000000000000000000000000000..993a5e78acd3835346b75d5226d85b8e7fc009b8 --- /dev/null +++ b/app/src/main/res/drawable/google_g_logo.xml @@ -0,0 +1,18 @@ + + + + + + diff --git a/app/src/main/res/drawable/ic_folder_refresh_outline.xml b/app/src/main/res/drawable/ic_folder_refresh_outline.xml new file mode 100644 index 0000000000000000000000000000000000000000..b72d56709c8d218aa1fd75db199da0cbb99de539 --- /dev/null +++ b/app/src/main/res/drawable/ic_folder_refresh_outline.xml @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_login.xml b/app/src/main/res/drawable/ic_login.xml new file mode 100644 index 0000000000000000000000000000000000000000..57aef3dca1c37b4689c5b039fcb7cb1ed80be012 --- /dev/null +++ b/app/src/main/res/drawable/ic_login.xml @@ -0,0 +1,5 @@ + + + diff --git a/app/src/main/res/drawable/ic_share_notify.xml b/app/src/main/res/drawable/ic_share_notify.xml deleted file mode 100644 index 608e3bb1b634675b0c3800a40190d0e4cc7ec725..0000000000000000000000000000000000000000 --- a/app/src/main/res/drawable/ic_share_notify.xml +++ /dev/null @@ -1,12 +0,0 @@ - - - - - diff --git a/app/src/main/res/drawable/mastodon.xml b/app/src/main/res/drawable/mastodon.xml new file mode 100644 index 0000000000000000000000000000000000000000..4074145b84919c2b991c75225327bf8df5c1c78f --- /dev/null +++ b/app/src/main/res/drawable/mastodon.xml @@ -0,0 +1,8 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/activity_account.xml b/app/src/main/res/layout/activity_account.xml index 047ee8a63b9d856561ed2272a00ead5c8115fa5f..62c6e2cbec768d949d148a07652284859d186692 100644 --- a/app/src/main/res/layout/activity_account.xml +++ b/app/src/main/res/layout/activity_account.xml @@ -32,7 +32,7 @@ - + + \ No newline at end of file diff --git a/app/src/main/res/layout/activity_permissions.xml b/app/src/main/res/layout/activity_permissions.xml index 09ca34e95ccad79f1cd6ebc9f0e95da1079c9023..e25ce71321d816e68d09c2cacfcab8e6571a7b63 100644 --- a/app/src/main/res/layout/activity_permissions.xml +++ b/app/src/main/res/layout/activity_permissions.xml @@ -121,7 +121,7 @@ style="@style/TextAppearance.MaterialComponents.Body1" android:layout_width="0dp" android:layout_height="wrap_content" - android:layout_marginTop="16dp" + android:layout_marginTop="@dimen/card_margin_title_text" android:text="@string/permissions_all_title" android:textAlignment="viewStart" android:textStyle="bold" @@ -224,7 +224,7 @@ style="@style/TextAppearance.MaterialComponents.Body1" android:layout_width="0dp" android:layout_height="wrap_content" - android:layout_marginTop="16dp" + android:layout_marginTop="@dimen/card_margin_title_text" android:text="@string/permissions_contacts_title" android:textAlignment="viewStart" app:layout_constraintBottom_toTopOf="@id/contactsStatus" diff --git a/app/src/main/res/layout/collection_properties.xml b/app/src/main/res/layout/collection_properties.xml deleted file mode 100644 index 7aff67e9549bba2bf61720f88db0e37dfa1a5359..0000000000000000000000000000000000000000 --- a/app/src/main/res/layout/collection_properties.xml +++ /dev/null @@ -1,74 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/app/src/main/res/layout/delete_collection.xml b/app/src/main/res/layout/delete_collection.xml index 9bc97131022dce0b66ee12a3dc5508d2da335779..09efb6419c3bdb896676b6e5668fd236702f85e8 100644 --- a/app/src/main/res/layout/delete_collection.xml +++ b/app/src/main/res/layout/delete_collection.xml @@ -17,7 +17,7 @@ android:padding="@dimen/activity_margin"> diff --git a/app/src/main/res/layout/login_account_details.xml b/app/src/main/res/layout/login_account_details.xml index 5116753974e2dde58e61ddcde40175c2885ea5df..14fa9254137ef17da1a77ece5571ddb35510684b 100644 --- a/app/src/main/res/layout/login_account_details.xml +++ b/app/src/main/res/layout/login_account_details.xml @@ -9,7 +9,7 @@ + type="at.bitfire.davdroid.ui.setup.AccountDetailsFragment.Model"/> - + + diff --git a/app/src/main/res/menu/webcal_actions.xml b/app/src/main/res/menu/webcal_actions.xml deleted file mode 100644 index 5e732a794b48a9d96cf135a7c2886abb1145a370..0000000000000000000000000000000000000000 --- a/app/src/main/res/menu/webcal_actions.xml +++ /dev/null @@ -1,7 +0,0 @@ - - - - - - \ No newline at end of file diff --git a/app/src/main/res/resources.properties b/app/src/main/res/resources.properties new file mode 100644 index 0000000000000000000000000000000000000000..4c0e06b8df70dd0ca4376a74cfb0d66fa6faab2e --- /dev/null +++ b/app/src/main/res/resources.properties @@ -0,0 +1,2 @@ +# Set default locale +unqualifiedResLocale=en-US diff --git a/app/src/main/res/values-ar/strings.xml b/app/src/main/res/values-ar/strings.xml index a4a52dde873e710f41894012d06fb8b5c31fc074..2a2ca91506d95809cd99c8d0803a59632f5db84e 100644 --- a/app/src/main/res/values-ar/strings.xml +++ b/app/src/main/res/values-ar/strings.xml @@ -5,7 +5,6 @@ Account Manager دفتر عناوين دفاتر العناوين مساعدة - إرسال تصحيح العلل رسائل هامة أخرى مزامنة @@ -15,23 +14,6 @@ المشاكل غير الفادحة في المزامنة ، مثل بعض الملفات غير الصالحة أخطاء الشبكة و عمليات الإدخال/الإخراج ألمهلات، مشاكل الاتصال ، …الخ (مؤقتة في العادة) - - مزامنة تلقائية - %s البرنامج الثابت للجهاز قد يمنع المزامنة التلقائية. في هذه الحالة أعط الإذن بالمزامنة التلقائية في إعدادات النظام لديك. - مزامنة مجدولة - سيقوم جهازك بتقييد مزامنة Account Manager . قم بإطفاء \"تحسين البطارية\" لإجبار مدد منتظمة لمزامنة Account Manager . - ايقاف تشغيل مدير الحساب - لاتعرضه مرة أخرى - ليس الآن - معلومات المصدر المفتوح - يسعدنا كونك تستخدم Account Manager ، البرنامج ذو المصدر المفتوح (GPLv3). تطوير Account Manager عمل مضنٍ أخذ منا آلاف ساعات العمل ، هلّا تبرعت لنا ؟ - عرض صفحة التبرُّع - ربما لاحقاً - المزيد من المعلومات - OpenTasks غير مثبت - تطبيق OpenTasks المجاني مطلوب لمزامنة المهام. (غير مطلوب لمزامنة جهات الاتصال أو الأحداث) - بعد تثبيت OpenTasks، لابد لك من إعادة تثبيت Account Manager وإضافة حساباتك مجدداً (علة في آندرويد). - تثبيت OpenTasks المكتبات النسخة %1$s (%2$d) @@ -40,8 +22,6 @@ يقدَّم هذا البرنامج دون أدنى مسؤولية. إنه برنامج حر، وندعوك لإعادة توزيعه حسب أحكام محددة. لا يمكن إنشاء ملف سجل - سجلات Account Manager - إرسال السجل فتح درج التنقل إغلاق درج التنقل @@ -54,7 +34,6 @@ موقع الويب دليل الاستخدام الأسئلة الشائعة - المساعدة / المنتدى تبرَّع مرحباً بك في Account Manager !\n\n يمكنك إضافة حساب CalDAV أو CardDAV الآن. تم تعطيل المزامنة التلقائية على مستوى النظام @@ -71,11 +50,6 @@ التسجيل مفعَّل التسجيل معطَّل الاتصال - تجاوز إعدادات الوكيل - إستخدم إعدادات وكيل مخصَّصة - إستخدم إعدادات الوكيل الافتراضية في النظام - اسم مضيف وكيل HTTP - منفذ وكيل HTTP الأمن عدم الثقة في شهادات النظام هيئات توثيق النظام و تلك التي أضافها المستخدم لن تكون محل ثقة @@ -94,7 +68,6 @@ CalDAV Webcal زامن الآن - المزامنة جارية إعدادات الحساب إعادة تسمية الحساب البيانات المحلية غير المحفوظة قد يجري استبعادها. مطلوب إعادة المزامنة بعد إعادة التسمية. الاسم الجديد للحساب: @@ -121,15 +94,12 @@ كلمة المرور مطلوبة تسجيل الدخول بعنوان URL واسم مستخدم يجب أن يبدأ عنوان URL بـ http(s):// - يجب أن يبدأ عنوان URL بـ https:// - اسم المضيف مطلوب اسم المستخدم اسم المستخدم مطلوب URL الأساس تسجيل الدخول بعنوان URL وشهادة عميل اختيار الشهادة تسجيل الدخول - عودة إنشاء حساب اسم الحساب استخدم عنوان بريدك الإلكتروني اسماً للحساب لأن آندرويد يستخدم اسم الحساب في حقل المنظّم ORGANIZER للأحداث التي تنشئها. لايمكن أن تمتلك حسابين بالاسم نفسه. @@ -166,7 +136,6 @@ ستتم المزامنة عبر %sفقط (يتطلب تفعيل خدمات الموقع الجغرافي) سيتم استخدام جميع اتصالات WiFi أسماء (SSIDs) مفصولة بفواصل لشبكات WiFi المسموح الاتصال عبرها (اتركه فارغاً للسماح للكل) - الإذن لمعرفة الموقع الجغرافي والتفعيل الدائم لخدمات الموقع مطلوبان من أجل قراءة أسماء شبكات وايفاي المزيد من المعلومات (الأسئلة الشائعة) المصادقة اسم المستخدم @@ -201,7 +170,6 @@ دفتر عناويني إنشاء تقويم المنطقة الزمنية - المنطقة الزمنية مطلوبة المدخلات المحتملة للتقويم أحداث مهام @@ -231,11 +199,8 @@ عرض التفاصيل معلومات تصحيح العلل - دفتر عناوين للقراءة فقط أذونات Account Manager مطلوب أذونات إضافية - OpenTasks قديم للغاية - الإصدار المطلوب:%1$s (الموجود %2$s) فشلت المصادقة (تحقق من بيانات تسجيل الدخول) خطأ شبكة أو الإدخال/الإخراج - %s خطأ خادم HTTP - %s @@ -252,4 +217,19 @@ جوجل دليل عناوين جوجل دليل العناوين + إدارة الحسابات + + المزيد من المعلومات + + + + + + + فشل اكتشاف الخدمة + لم يتمكن التطبيق من تجديد قائمة المجموعة + اسم الحساب مأخوذ بالفعل + + اسم المستخدم + كلمة المرور diff --git a/app/src/main/res/values-bg/strings.xml b/app/src/main/res/values-bg/strings.xml index 6c940f02f8ed6354957c5fb475d9eac0dd6cb647..1eb67b49474e4d423945cf636b47aa1fb11f373d 100644 --- a/app/src/main/res/values-bg/strings.xml +++ b/app/src/main/res/values-bg/strings.xml @@ -8,7 +8,6 @@ Отказ Задължително поле Помощ - Изпрати Други важни съобщения Синхронизиране Грешки при синхронизирането @@ -16,22 +15,6 @@ Предупреждения от синхронизирането Грешки с входа/изхода и мрежата. Проточване на времето, проблеми с връзката, и т.н. (обикновенно временни) - - Автоматична синхронизация - Планувана синхронизация - Устройството ти ще възпрепятства Account Manager да синхронизира. За да се извършва редовна, периодична синхронизация, изключи “оптимизация на батерията”. - Изключи за Account Manager - Не показвай отново - Не сега - Информация за отворения код - Радваме се, че изполваш Account Manager – софтуер с отворен код (GPLv3). Тъй като разработването на Account Manager е много трудоемка работа, моля да направиш дарение. - Покажи страницата за дарения - Може би по-късно - Повече информация - OpenTasks не е инсталирана. - За да синхронизираш задачи е нужна програмата OpenTasks. Тя не е необходима за синхронизиране на контакти или събития. - След като инсталирате OpenTasks трябва да инсталирате отново Account Manager и да добавите своите регистрации (грешката е в Андроид). - Инсталирай OpenTasks Библиотеки Версия %1$s (%2$d) @@ -40,9 +23,7 @@ Тази програма няма абсолютно никаква гаранция. Тя е свободен софтуер и можеш да я разпространяваш при определени условия. Не можах да създада файл с протокола - Account Manager протоколиране Сега действията на %s се протоколират - Изпрати протокола Адаптор за CalDAV/CardDAV синхронизиране Относно / Лиценз @@ -52,7 +33,6 @@ Интернет страница Упътване Често задавани въпроси - Помощ и форум Дарения Добре дошъл при Account Manager!\n\nМоже да добавиш CalDAV/CardDAV регистрация сега. Включи @@ -66,11 +46,6 @@ Протоколирането е включено Протоколирането е изключено Връзка - Промени прокси настройките - Изполвай долупосочените прокси настройки - Изпозвай прокси настройките на системата - Име на HTTP прокси сървъра - Порт на HTTP прокси Защита Загуби доверието в системните сертификати Издатели на сертификати познати на системата или добавени от потребителя няма да бъдат доверени @@ -87,7 +62,6 @@ Няма абонаменти за календари (все още) Дръпни надолу за да освежиш списъка от сървъра. Синхронизирай сега - Сега се синхронизира Настройки на регистрацията Преименовай регистрацията Данните, който не са синхронизирани със сървъра, може да се изпарят. След преименоването е нужно ново синхронизаране. Ново име на регистрацията: @@ -113,14 +87,11 @@ Трябва парола Вход с URL и потребителско име Адресът трябва да започва с http(s):// - Адресът трябва да започва с https:// - Нужен е адрес на сървър Потребителско име Нужно е потребителско име Основен адрес (URL) Влез с URL и сертификат Избери сертификат - Назад Създай регистрация Име на регистрация Метод за съхранение на групи от контакти: @@ -156,7 +127,6 @@ Ще синхронизира само пред %s(изисква услуга за местоположението) Всички WiFi връзки ще бъдат използвани Списък на разделени със запетайка имена на мрежи (SSID WiFi) (остави празно за всички мрежи) - За да се четат имената на WiFi мрежите са необходими право да се чете местоположението и постоянно включена услуга за местоположението. Повече информация (често задавани въпроси) Потребителско име Въведи потребитеско име: @@ -185,7 +155,6 @@ Моят адресен указател Създай календар Часова зона - Изисква се часова зона Възможни компоненти в календара Събития Задачи @@ -213,12 +182,8 @@ Възникна входно-изходна грешка Покажи подробности - Протоколът е закачен към това съобщение (изисква от получаващото приложение поддръжка за прикачени файлове). - Адресен указател само за четене Account Manager права Необходими са допълнителни права - OpenTasks е прекалено стара - Изисква се версия: %1$s, а е налична %2$s. Регистрирането се провали, провери потребителското име и паролата. Грешка с входа/изхода или мрежата – %s Грешка с HTTP сървъра – %s @@ -250,4 +215,231 @@ Google WebDAV ВНИМАНИЕ + + Вашите данни. Вашият избор + Поемете контрол + Периодично синхронизиране + Изключено (не се препоръчва) + Включено (препоръчително) + За да синхронизира периодично, %s се нуждае от разрешение да работи на заден план. В противен случай Android може да спре синхронизацията по всяко време. + Не желая периодично синхронизиране.* + %s съвместимост + Устройството вероятно прекъсва синхронизацията. Единственият начин да резрешите проблема е ръчната промяна на настройките. + Необходимите промени са направени. Без повторно напомняне.* + * Оставете без отметка за повторно напомняне. Може да бъде нулирано от настройките на приложението / %s. + Допълнителна информация + jtx Board + + Поддръжка на задачи + Ако сървърът поддържа задачи, те могат да бъдат синхронизирани с приложение за задачи: + OpenTasks + Изглежда не се разработва вече, не се препоръчва. + Tasks.org + (още) не се поддържат.]]> + Няма достъпен магазин за приложения + Не се нуждая от поддръжка на задачи.* + Приложение с отворен код + Радваме се, че използвате %s. Това е приложение с отворен код. Разработката, издръжката и поддръжката му са тежка работа. Моля да допринесете (има много начини) или да дарите. Вашият жест ще бъде високо оценен. + Как да допринеса или даря + Без повторно показване в близко бъдеще + + Разрешения + За да работи нормално %s се нуждае от разрешения. + Всички от изброените по-долу + За да включите вички възможности, изберете това (препоръчано) + Всичко е разрешено + Разрешения за контактите + Контактите не могат да бъдат синхронизирани (не се препоръчва) + Контактите могат да бъдат синхронизирани + Разрешения за календара + Календарите не могат да бъдат синхронизирани (не се препоръчва) + Календарите могат да бъдат синхронизирани + Разрешение за известия + Известията са изключени (непрепоръчително) + Известията са еключени + Разрешения за jtx Board + Няма (инсталирана) синхронизация на задачи, дневници и бележки + Без синхронизиране на задачи, дневници и бележки + Възможно синхронизиране на задачи, дневници и бележки + Разрешения за OpenTasks + Разрешения за Tasks + Няма (инсталирана) синхронизация на задачи + Няма синхронизация на задачи + Задачите могат да бъдат синхронизирани + Предпазване от нулиране на разрешенията + Разрешенията могат да бъдат нулирани автоматично (не се препоръчва) + Разрешенията не могат да бъдат нулирани автоматично + Докоснете Разрешения и махнете отметката от „Премахване на разрешенията, ако приложението не се използва“ + Ако някой превключвател не работи, използвайте настройките на приложението / Разрешения. + Настройки на приложението + + Разрешения за SSID на Wi-Fi + За достъп до името на текущата мрежа на Wi-Fi (SSID) трябва да бъдат изпълнени следните условия: + Разрешение за достъп до точното месоположение + Има разрешение за местоположението + Липсва разрешение за местоположението + Достъп до местоположението във фонов режим + Разрешаване във всички случаи + %s]]> + %s]]> + %s използва разрешението за местоположение за достъп до името на мрежата, когато се изисква синхронизиране само през определена мрежа. Това се случва дори и когато приложението се изпълнява във фонов режим. Не се събират, съхраняват, обработват и разпространяват данни за местоположението. + Винаги включено местоположение + Услугата за местоположението е включена + Услугата за местоположението е изключена + + Преводи + © Ricki Hirner, Bernhard Stockmann (bitfire web engineering GmbH) и сътрудници + Благодарение на: %s]]> + + Преглед/споделяне + Изключване + + Отваря менюто + Затваря менюто + Обратна връзка към бета + Инсталиране приложение за електронна поща + Инсталиране на мрежов четец + Инструменти + Общност + Известията са изключени. Няма да бъдете известявани за грешки при синхронизиране. + Управление на връзките + Няма достатъчно свободно място. Android няма да извършва синхронизация. + Управление на хранилището + Включена е икономия на данни. Синхронизацията във фонов режим е ограничена. + Икономия на трафик + Синхронизиране на всички профили + + Откриването на услугите се провали + Грешка при презареждане + + Не може да работи на преден план + Необходимо е да спрете оптимизирането на батерията за приложението + + Работи на преден план + На някои устройства това е необходимо, за да работи автоматичното синхронизиране. + + Оптимизиране на батерията + Приложението е в белия списък (препоръчително) + Приложението не е в белия списък (непрепоръчително) + Поддържане на преден план + Може да помогне ако устройството възпрепятства автоматичното синхронизиране + Вид на сървъра на прокси + + Според системата + Без сървър на прокси + HTTP + SOCKS (за Orbot) + + Име на хоста на сървъра на прокси + Порт на сървъра на прокси + Разрешения за приложението + Преглед на необходимите за синхронизиране разрешения + Нулиране на (не)доверените сертификати + Нулиране на доверието към всички потребителски сертификати + Всички потребителски сертификати са премахнати + Избиране на тема + + Според системата + Светла + Тъмна + + Всички подсказки, които са били разкарани, ще бъдат показани отново + Интеграция + Приложението Tasks + Синхронизиране чрез %s + Не е инсталирана съвместимо приложение за задачи. + + Контактите не се синхронизират (липсват разрешения) + Календарите те не се синхронизират (липсват разрешения) + Задачите не се синхронизират (липсват разрешения) + Календари и задачи не се синхронизират (липсват разрешения) + Няма достъп до календарите (липсват разрешения) + Разрешения + Това име на регистрация вече се използва + Грешка при преименуване + дневник + Само лични + Няма инсталирана програма за Webcal абонамент. + + Използването на апострофи (\') води до проблеми при някои устройства. + Използвайте адрес за електронна поща вместо име, защото Android ще го използва като адрес на организатора на събитията, които създавате. Не може да има две регистрации с еднакво име. + Вход за напреднали потребители (специални случаи) + С потребителско име/парола + Със сертификат за удостоверяване + Не е намерен сертификат + Инсталиране на сертификат + Грешно потребителско име или парола? + + Ограниченията на Wi-Fi по SSID изискват допълнителни настройки + Управление + Псевдоним на клиентския сертификат + Няма избран сертификат + Подразбирано напомняне + + Подразбирано напомняне една минута преди събитието + Подразбирано напомняне %d минути преди събитието + + Няма създадени подразбирани напомняния + Към събитията без напомняне ще бъде добавяно подразбирано напомняне: желания брой минути преди събитието. Оставете празно, за да изключите подразбираните напомняния. + + Групите са отделни vCards + Групите са категории във всеки контакт + + + по желание + Местоположението на хранилището е задължително + Собственик + + Информация за отстраняване на дефекти + Архив на ZIP + Съдържа информацията за отстраняване на дефекти и дневниците + Споделете архива, за да го пренесете на компютър, да го изпратите по електронна поща или да го прикачите към билет за поддръжка. + Споделяне на архива + Информацията за отстраняване на дефекти е приложена към това съобщение (получаващото приложение трябва да поддръжа прикачени файлове). + Грешка на HTTP + Грешка на сървъра + Грешка на WebDAV + Грешка на входа/изхода + Заявката е отказана. За подробности проверете свързаните със завката ресурси и информацията за отстраняване на дефекти. + Заявеният ресурс не съществува (вече). За подробности проверете свързаните със завката ресурси и информацията за отстраняване на дефекти. + Възникнал е проблем от страна на сървъра. Свържете се с поддръжката му. + Възникнала е неочаквана грешка. За подробности проверете информацията за отстраняване на дефекти. + Подробности + Информацията за отстраняване на дефекта е събрана + Ресурси, имащи отношение + Свързани с проблема + Отдалечен ресурс: + Местен ресурс: + Дневници + Налични са подробни дневници + Преглед + + Синхронизирането е спряно + Почти няма свободно пространство + + Дялове на WebDAV + Използвана квота: %1$s / налична: %2$s + Споделяне на съдържание + Демонтиране + Монтиране на дял на WebDAV + Получете директен достъп до файловете си в облака като монтирате дял на WebDAV. + как работят дяловете на WebDAVв ръководството.]]> + Видимо име + Адрес на WebDAV + Недействителен адрес + Потребителско име + Парола + Монтиране + Няма услуга на WebDAV на адреса + Премахване на точката на монтиране + Ще бъдат изгубени подробности за връзката, но файлове няма да бъдат премахвани. + Достъпва се файл на WebDAV + Изтегля се файл на WebDAV + Изпраща се файл на WebDAV + Дял на WebDAV + + Приложението %s е твърде старо + Минимално необходимо издание: %1$s + Грешка (достигнат максимален брой опити) + Преглед diff --git a/app/src/main/res/values-ca/strings.xml b/app/src/main/res/values-ca/strings.xml index 114522583f52a4829643712bf92ddf0df63f3781..216409aa12ddd26450eaccdef044cfdcd85f53e2 100644 --- a/app/src/main/res/values-ca/strings.xml +++ b/app/src/main/res/values-ca/strings.xml @@ -10,6 +10,7 @@ Cal aquest camp Ajuda Comparteix + Sense Internet, es planifica la sincronització Base de dades malmesa S\'han eliminat localment tots els comptes. Depurador @@ -40,6 +41,8 @@ Suport de tasques Si el vostre servidor permet les tasques, es poden sincronitzar amb una aplicació que admeti tasques: OpenTasks + Sembla que ja no es desenvolupa, no es recomana. + Tasks.org no estan disponibles (encara).]]> No hi ha cap mercat d\'aplicacions disponible No necessito suport per les tasques.* @@ -128,6 +131,8 @@ Gestiona les connexions Espai d\'emmagatzematge baix. L\'Android no executarà la sincronització. Gestiona l\'emmagatzematge + S\'ha activat l\'estalviador de dades. La sincronització en segon pla està restringida. + Gestió de l\'estalviador de dades Us donem la benvinguda a DAVx⁵! \n\nAra podeu afegir un compte CalDAV / CardDAV. La sincronització automàtica de tot el sistema està inhabilitada Activa @@ -204,11 +209,11 @@ No hi ha subscripcions a calendaris (encara). Llisca avall per a refrescar la llista del servidor. Sincronitza ara - Sincronitzant ara Configuració del compte Canvia el nom del compte Les dades locals no desades es poden desestimar. Es requereix tornar a sincronitzar després del canvi de nom. Nom nou del compte: Canvia el nom + Nom de compte existent No s\'ha pogut canviar el nom del compte Suprimeix el compte Segur que vols suprimir el compte? @@ -237,12 +242,11 @@ Nom d\'usuari/a Es requereix nom d\'usuari/a URL base - Inici de sessió amb un URL i un certificat de client Selecciona el certificat Inici de sessió Afegir compte Nom del compte - S\'ha informat que l\'ús d\'apòstrofs (\'), causa problemes en alguns dispositius. + S\'ha informat que l\'ús d\'apòstrofs (\') causa problemes en alguns dispositius. Utilitzeu la vostra adreça de correu electrònic com a nom del compte perquè l\'Android utilitzarà el nom del compte com a camp ORGANITZADOR per als esdeveniments que creeu. No poden haver-hi dos comptes amb el mateix nom. Mètode dels grups de contactes: Nom del compte obligatori @@ -253,6 +257,14 @@ Utilitza un certificat de client No s\'ha trobat cap certificat Instal·la un certificat + Contactes / Calendari de Google + Per favor, llig la nostra guia \"Tested with Google\" per a obtenir informació actualitzada + És possible que sorgisca alguna advertència o error, en eixe cas hauràs de crear el teu propi ID de client. + Compte de Google + ID de Client (opcional) + política de privadesa per als detalls.]]> + Política de dades d\'usuari dels servis de l\'API de Google, incloent els requisits d\'ús limitat.]]> + No s\'ha pogut obtenir el codi d\'autorització Detecció de la configuració Espereu, s\'està consultant el servidor… No s\'ha pogut trobar cap servei CalDAV o CardDAV. @@ -285,8 +297,10 @@ Noms (SSID) separats per comes de les xarxes Wi-Fi permeses (deixeu-ho en blanc per a totes) La restricció per SSID de la Wi-Fi requereix una configuració addicional Gestió - Més informació (PMF) Autentificació + Re-Autenticar + Tornar a realitzar l\'inici de sessió amb OAuth + nom d\'usuari Nom d\'usuari/a Escriu el nom d\'usuari/a: Contrasenya @@ -321,17 +335,14 @@ Els grups són vCards separades Els grups són categories per contacte - Canvia el mètode dels grups Crea una llibreta d\'adreces - La meva llibreta d\'adreces Crea un calendari Fus horari Possibles entrades de calendari Esdeveniments Tasques Notes/diari - Combinat (esdeveniments i tasques) Color S\'està creant una col·lecció Títol @@ -345,11 +356,11 @@ Estàs segur? Aquesta col·lecció (%s) i totes les seves dades s’eliminaran definitivament. Aquestes dades se suprimiran del servidor. - S\'està suprimint la col·lecció Força que sigui de només lectura Propietats + Darrera sincronització: + No s\'ha sincronitzat mai Adreça (URL): - Copia l\'URL Propietari: Informació de depuració @@ -372,7 +383,6 @@ Relacionats amb el problema Recurs remot: Recurs local: - Visualitza amb l\'aplicació Registres Hi ha registres detallats disponibles Visualitza els registres @@ -405,7 +415,7 @@ S\'està baixant el fitxer WebDAV S\'està pujant el fitxer WebDAV Muntatge WebDAV - + Permisos del DAVx⁵ Es requereixen permisos addicionals %s és massa antiga @@ -414,7 +424,7 @@ Error de xarxa o d\'E/S: %s Error del servidor HTTP: %s Error d\'emmagatzematge local: %s - Reintenta + Error de programari (s\'ha arribat al màxim de reintents) Veure element S\'ha rebut un contacte no vàlid del servidor S\'ha rebut un esdeveniment no vàlid del servidor diff --git a/app/src/main/res/values-cs/strings.xml b/app/src/main/res/values-cs/strings.xml index b8cdaa7b3b6960b602a5f07fc7b3443da2d71887..894e02571c1ec08a0e4fa779df6bbb0d11d2954d 100644 --- a/app/src/main/res/values-cs/strings.xml +++ b/app/src/main/res/values-cs/strings.xml @@ -205,11 +205,11 @@ (Zatím) zde nejsou žádná přihlášení se k odebírání kalendáře. Seznam ze serveru znovu načtete přejetím prstem dolů. Synchronizovat nyní - Probíhá synchronizace Nastavení účtu Přejmenovat účet Neuložená místní data mohou být vynechána. Po přejmenování je vyžadována nová synchronizace. Nové jméno účtu: Přejmenovat + Tento název účtu už je používán někým jiným Účet se nedaří přejmenovat Smazat účet Opravdu smazat účet? @@ -238,7 +238,6 @@ Uživatelské jméno Vyžadováno uživatelské jméno Základ URL - Přihlásit pomocí URL a klientského certifikátu Vybrat certifikát Přihlášení Přidat účet @@ -286,7 +285,6 @@ Čárkou oddělovaný seznam názvů (SSID) WiFi sítí, přes které synchronizovat (pokud omezovat nechcete, nevyplňujte) Omezení na názvy (SSID) WiFi sítí vyžaduje další nastavení Spravovat - Další informace (často kladené dotazy) Ověření Uživatelské jméno Zadejte uživatelské jméno: @@ -326,17 +324,14 @@ Skupiny jsou zvlášt vCards vizitky Skupiny jsou kategorie u jednotlivých kontaktů - Změnit metodu seskupování Vytvořit adresář - Můj adresář Vytvořit kalendář Časová zóna Možné položky kalendáře Události Úkoly Poznámky / deník - Kombinovaná (události a úkoly) Barva Vytváření sady Nadpis @@ -350,11 +345,9 @@ Opravdu to chcete? Tato sada (%s) a všechna její data budou nadobro odebrána. Tato data by měla být smazána ze serveru. - Mazání sbírky Vynutit pouze pro čtení Vlastnosti Adresa (URL): - Zkopírovat URL adresu Vlastník: Ladící informace @@ -377,7 +370,6 @@ Související s problémem Prostředek na protějšku: Místní prostředek: - Zobrazit v aplikaci Záznamy událostí Jsou k dispozici podrobnější záznamy událostí Zobrazit záznamy událostí @@ -410,7 +402,7 @@ Stahuje se WebDAV soubor Nahrává se WebDAV soubor WebDAV připojení - + Správce účtu oprávnění Vyžadována dodatečná oprávnění Příliš stará verze %s @@ -419,7 +411,6 @@ Chyba sítě nebo vstupu/výstupu – %s Chyba HTTP serveru – %s Chyba místního úložiště – %s - Opakovat Zobrazit položku Ze serveru obdržen neplaný kontakt Ze serveru obdržena neplatná událost diff --git a/app/src/main/res/values-da/strings.xml b/app/src/main/res/values-da/strings.xml index 7766133038849dca8c5f331390eb37ccf723659b..28cd94a0f0ca7c2b50bc05c4fa269cbc879fa23b 100644 --- a/app/src/main/res/values-da/strings.xml +++ b/app/src/main/res/values-da/strings.xml @@ -9,7 +9,9 @@ Annullere Feltet er påkrævet Hjælp + Administrer konti Del + Ingen internet, planlægger synkronisering Database ødelagt Alle konti er fjernet lokalt. Fejlsøgning @@ -40,6 +42,8 @@ Opgaver understøttelse Hvis opgaver er server understøttet, kan de synkroniseres med et understøttet opgaver program: OpenTasks + Ser ikke ud til at blive udviklet længere - ej anbefalet. + Tasks.org er ikke understøttet.]]> ingen program-butik tilgængelig Ingen opgaver understøttelse.* @@ -59,6 +63,9 @@ Kalender rettigheder Ingen kalender synkronisering (ikke anbefalet) Kalender synkronisering mulig + Notifikations-tilladelse + Notifikationer slået fra (ej anbefalet) + Notifikationer slået til jtx Board rettigheder Ingen opgaver, journaler & noter synkronisering (ikke installeret) Ingen opgaver, journaler & noter synkronisering @@ -120,12 +127,17 @@ Fællesskab Donation Privatlivs politik + Notifikationer slået fra. Du vil ikke blive gjort opmærksom på synkroniserings-fejl. Ingen internetforbindelse. Android kører ikke synkronisering. + Aministrer forbindelser Lagerplds lav. Android kører ikke synkronisering. Velkommen til Kundechef!\n\nDu kan nu tilføje en CalDAV/CardDAV konto. + Administrer lagerplads + Gemning af data slået til. Synkronisering i baggrunden er begrænset. + Administrer gemning af data Automatisk synkronisering på tværs af systemet er deaktiveret Aktivere - Synkronisere alle konti + Synkroniser alle konti Registrering af tjeneste kunne ikke foretages Kunne ikke opdatere samling liste @@ -198,14 +210,14 @@ Der er endnu ingen kalender abonnementer. Træk ned for at genopfriske listen fra serveren. Synkronisere - Synkroniserer Opsætning af konti - Omdøbe konto - Lokaldata der ikke er gemt kan gå tabt. Eftersynkronisering er krævet efter omdøbning. Nyt kontonavn: + Omdøb konto + Lokale data der ikke er gemt kan gå tabt. Gen-synkronisering er krævet efter omdøbning. Nyt kontonavn: Omdøbe + Konto navn er allerede i brug Kunne ikke omdøbe konto - Slette konto - Slette konto? + Slet konto + Slet konto? Alle lokale kopier af addessebøger, kalendere og opgavelister vil blive slettet. synkronisere samling skrivebeskyttet @@ -213,8 +225,8 @@ opgave liste journal Vis kun personlig - Opdatere adressebogslister - Oprette ny adressebog + Opdater adressebogslister + Oprett ny adressebog Opdatere kalenderliste Oprette ny kalender Der er ikke fundet noget program der kan håndtere Webcal. @@ -231,16 +243,15 @@ Brugernavn Brugernavn påkrævet Basis URL - Log ind med URL og klientcertifikat Vælge certifikat Log ind Tilføj konto Kontonavn Du kan ikke bruge anførelsestegn (\') på alle mobiler. - Brug e-mail adresse som kontonavn da Android bruger kontonavn til ORGANIZER-felt for oprettede aktiviteter. Man kan ikke have to konti med samme navn. + Brug en e-mail adresse som kontonavn da Android bruger kontonavn til ORGANIZER-felt for oprettede aktiviteter. Man kan ikke have to konti med samme navn. Gruppering af kontakter: Kontonavn påkrævet - Konto navn er taget + Konto navn er allerede i brug Konto kunne ikke oprettes Avanceret logind (særlige tilfælde) Brug brugernavn/adgangskode @@ -262,7 +273,6 @@ Synkroniseringsinterval for opgaver Kun manuelt - Hvert andet minut Hvert 15. minut Hver halve time Hver time @@ -280,7 +290,6 @@ Kommaseparerede navne (SSID\'er) over tilladte WiFi-netværk (efterlad blank for at bruge alle) Trådløs SSID begrænsning kræver yderligere opsætning Håndtere - Mere information (OSS) Adgangsgodkendelse Brugernavn Indtaste brugernavn: @@ -316,17 +325,14 @@ Grupper er særskilte vCards Grupper er kategorier per kontakt - Skift grupperingsmetode Opret adressebog - Min adressebog Opret kalender Tid zone Mulige kalenderposte Begivenheder Opgaver Notater / journal - Kombineret (begivenheder og opgaver) Farve Opretter sæt Titel @@ -340,11 +346,11 @@ Er du sikker? Denne samling (%s) og alle dens data vil blive fjernet helt. Disse data vil blive slettet fra serveren. - Sletter CalDAV-sæt Sæt skrivebeskyttet Egenskaber + Sidst synkroniseret: + Aldrig synkroniseret Adresse (URL): - Kopiere URL Ejer: Debug-info @@ -367,7 +373,6 @@ Relateret til problemet Fjern ressource: Lokal ressource: - Vis med program Log Uddybende log er tilgængelig Vis logfiler @@ -400,7 +405,7 @@ Hent WebDAV fil Overfør WebDAV fil Montere WebDAV - + Kundechef-rettigheder Yderligere adgang påkrævet %s for gammel @@ -409,7 +414,7 @@ Netværks- eller I/O-fejl - %s HTTP-serverfejl - %s Lokal lagringsfejl - %s - Gentag + Blød fejl (maks forsøg nået) Vis element Modtaget ugyldig kontakt fra server Modtaget ugyldig begivenhed fra server diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index 15642b2389f8add495eaddd51f5eede67acc11c8..b501c5f88ec4e8afce37425705a68658de8f77ad 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -5,7 +5,6 @@ WebDAV-Adressbuch Adressbücher Hilfe - Senden Fehlersuche Andere wichtige Mitteilungen Synchronisierung @@ -15,23 +14,6 @@ Keine schwerwiegenden Synchronisierungsprobleme wie gewisse ungültige Dateien Netzwerk- und E/A-Fehler Zeitüberschreitungen, Verbindungsprobleme, usw. (oft vorübergehend) - - Automatische Synchronisierung - %s-Firmware verhindert oftmals das automatische Synchronisieren. In diesem Fall müssen Sie das automatische Synchronisieren in den Android-Einstellungen erlauben. - Geplante Synchronisierung - Ihr Gerät wird die Synchronisierung des Kontenverwalters einschränken. Damit regelmäßig synchronisiert werden kann, muss die „Akku-Leistungsoptimierung“ deaktiviert werden. - Für Kontenverwalter deaktivieren - Nicht mehr anzeigen - Später - Open-Source-Information - Es freut uns, dass Sie den Konten Manager und damit Open-Source-Software (GPLv3) verwenden. Da in dem Konten Manager tausende Stunden harter Arbeit stecken und er immer noch weiter entwickelt wird, gibt es die Möglichkeit, an uns zu spenden. - Spendenseite anzeigen - Vielleicht später - Weitere Informationen - OpenTasks ist nicht installiert - Für die Synchronisierung von Aufgaben wird die freie App OpenTasks benötigt. (Nicht benötigt für Kontakte/Termine.) - Nach der Installation von OpenTasks muss der Kontenverwalter NEU INSTALLIERT und die Konten neu hinzugefügt werden (Android-Bug). - OpenTasks installieren Bibliotheken Version %1$s (%2$d) @@ -40,9 +22,7 @@ Dieses Programm wird OHNE JEDE GEWÄHRLEISTUNG bereitgestellt. Es ist freie Software – Sie können es also unter bestimmten Bedingungen weiterverbreiten. Protokolldatei konnte nicht angelegt werden - Konten Manager-Protokollierung Alle %s-Aktivitäten werden nun protokolliert - Protokoll senden Menüleiste öffnen Menüleiste schließen @@ -57,7 +37,6 @@ Webseite Handbuch Häufig gestellte Fragen - Hilfe / Foren Spenden Datenschutzerklärung Keine Internet-Verbindung. Android wird die Synchronisierung daher nicht starten. @@ -78,11 +57,6 @@ Protokollierung läuft Keine Protokollierung Verbindung - Proxy-Einstellungen überschreiben - Eigene Proxy-Einstellungen werden verwendet - System-Proxy-Einstellungen werden verwendet - HTTP-Proxy-Rechnername - HTTP-Proxy-Port Sicherheit Systemzertifikaten nicht vertrauen System- und Benutzer-installierten Sicherheitszertifikaten wird nicht vertraut @@ -105,7 +79,6 @@ (Noch) keine Kalender-Abos vorhanden. Nach unten wischen, um neue Einträge auf dem Server zu suchen. Jetzt synchronisieren - Synchronisation gestartet Konto-Einstellungen Konto umbenennen Nicht gesicherte lokale Änderungen können verloren gehen. Nach dem Umbenennen muss neu synchronisiert werden. Neuer Kontoname: @@ -132,15 +105,12 @@ Passwort wird benötigt Mit URL und Benutzernamen anmelden URL muss mit http(s):// beginnen - URL muss mit https:// beginnen - Hostname wird benötigt Benutzername Benutzername wird benötigt Basis-URL Mit URL und Client-Zertifikat anmelden Zertifikat auswählen Anmelden - Zurück Konto hinzufügen Kontoname Verwenden Sie Ihre E-Mail-Adresse als Kontonamen, da Android den Kontonamen als Verwalter für angelegte Ereignisse benutzt. Es kann keine zwei Konten mit dem gleichen Namen geben. @@ -162,7 +132,6 @@ Häufigkeit der Aufgaben-Synchronisierung Nur manuell - alle 2 Minuten Alle 15 Minuten Alle 30 Minuten Jede Stunde @@ -178,7 +147,6 @@ Synchronisierung nur über %s (benötigt aktive Standortdienste) Alle WLAN-Verbindungen werden verwendet Erlaubte WLAN-Netzwerke (SSIDs), durch Kommas getrennt (leer lassen für alle) - Zum Auslesen von WLAN-Namen sind die Standort-Berechtigung und die ständige Aktivierung der Standortdienste notwendig. Mehr Informationen (FAQ) Authentifizierung Benutzername @@ -220,7 +188,6 @@ Meine Adressen Kalender anlegen Zeitzone - Zeitzone benötigt Mögliche Kalendereinträge Termine Aufgaben @@ -250,12 +217,8 @@ Details anzeigen Informationen zur Fehlersuche - Das Protokoll liegt als Anhang bei (benötigt Unterstützung für Anhänge in der empfangenden App). - Schreibgeschütztes Adressbuch Kontenverwalter-Berechtigungen Zusätzliche Berechtigungen benötigt - OpenTasks ist veraltet - Version %1$s benötigt (derzeit %2$s) Authentifizierungsfehler (Anmeldedaten überprüfen) Netzwerk- oder E/A-Fehler – %s HTTP-Serverfehler – %s @@ -508,4 +471,34 @@ Yahoo Adressbuch Murena wird ein vorgetäuschtes Gerätemodell an Yahoo weitergeben, um Ihre Privatsphäre zu schützen. \nDies kann auf der Yahhoo-Kontoseite überprüft werden. - \ No newline at end of file + Sprache wählen + Systemvorgabe + Kein Internet, Synchronisation wird geplant + + System-Standard + Kein Proxy + HTTP + SOCKS (für Orbot) + + + wie System + heller Stil + dunkler Stil + + Synchronisiere die Kollektionen + Google-Kontakte / -Kalender + Siehe unsere \"Getestet mit Google\"-Seite für neuere Informationen. + Sie werden unerwartete Warnungen erhalten und/oder müssen eine eigene Client-ID erstellen. + Google-Konto + Zuletzt synchronisiert: + Nie synchronisiert + Kontoname bereits verwendet + Mit Google anmelden + Client-ID (optional) + Datenschutzrichtliniefür mehr Informationen.]]> + Google API Services Nutzerdaten-Richtlinie, inklusive der eingeschränkten Nutzungsbedingungen.]]> + Authentifizierungscode konnte nicht abgerufen werden + Wieder anmelden + Erneut mit OAuth anmelden + Benutzername + diff --git a/app/src/main/res/values-el/strings.xml b/app/src/main/res/values-el/strings.xml index 143e225c0ae72e7c14194d639058e7e5e0298044..c25ceb4ccdd9a373f25d7bc30e5db2ab23ceed9f 100644 --- a/app/src/main/res/values-el/strings.xml +++ b/app/src/main/res/values-el/strings.xml @@ -173,11 +173,11 @@ Δεν υπάρχουν συνδρομές ημερολογίου (ακόμα). Σύρετε προς τα κάτω για να ανανεώσετε τη λίστα από το διακομιστή. Συγχρονισμός τώρα - Γίνεται συγχρονισμός τώρα Ρυθμίσεις λογαριασμού Μετονομασία λογαριασμού Τα μη αποθηκευμένα τοπικά δεδομένα ενδέχεται να απορριφθούν. Απαιτείται εκ νέου συγχρονισμός μετά τη μετονομασία. Νέο όνομα λογαριασμού: Μετονομασία + Το όνομα λογαριασμού έχει ήδη ληφθεί Αδυναμία μετονομασίας λογαριασμού Διαγραφή λογαριασμού Θέλετε να διαγράψετε τον λογαριασμό; @@ -205,7 +205,6 @@ Όνομα χρήστη Απαιτείται όνομα χρήστη Βασική URL - Σύνδεση με URL και πιστοποιητικό πελάτη Επιλογή πιστοποιητικού Είσοδος Δημιουργία λογαριασμού @@ -252,7 +251,6 @@ Ονόματα που χωρίζονται με κόμμα (SSIDs) των επιτρεπόμενων δικτύων WiFi (αφήστε κενό για όλους) Ο περιορισμός WiFi SSID χρειάζεται επιπλέον ρυθμίσεις Διαχείριση - Περισσότερες πληροφορίες (FAQ) Πιστοποίηση Όνομα χρήστη Εισάγετε όνομα χρήστη: @@ -288,17 +286,14 @@ Οι ομάδες είναι ξεχωριστές vCards Οι ομάδες είναι κατηγορίες ανά επαφή - Αλλαγή μεθόδου ομάδας Δημιουργία βιβλίου διευθύνσεων - Το βιβλίο διευθύνσεών μου Δημιουργία ημερολογίου Ζώνη ώρας Πιθανές καταχωρήσεις ημερολογίου Συμβάντα Εργασίες Σημειώσεις / ημερολόγιο - Συνδυασμός (συμβάντα και εργασίες) Χρώμα Δημιουργία συλλογής Τίτλος @@ -312,11 +307,9 @@ Είστε σίγουρος; Αυτή η συλλογή (%s) και όλα τα δεδομένα της θα καταργηθούν μόνιμα. Αυτά τα δεδομένα θα διαγραφούν από τον διακομιστή. - Διαγραφή συλλογής Εξαναγκασμός μόνο για ανάγνωση Ιδιότητες Διεύθυνση (URL): - Αντιγραφή URL Ιδιοκτήτης Πληροφορίες αποσφαλμάτωσης @@ -329,7 +322,6 @@ Σφάλμα I/O Προβολή λεπτομερειών Σχετικά με το πρόβλημα - Προβολή με την εφαρμογή Ιστορικό Προβολή ιστορικού @@ -356,7 +348,7 @@ Kαταφόρτωση αρχείου WebDAV Ανέβασμα αρχείου WebDAV Bάση WebDAV - + Δικαιώματα DAVx⁵ Απαιτούνται πρόσθετα δικαιώματα Παλιά έκδοση %s @@ -365,7 +357,6 @@ Σφάλμα δικτύου ή I/O – %s Σφάλμα διακομιστής HTTP – %s Σφάλμα τοπικού αποθηκευτικού χώρου – %s - Επανάληψη Προβολή αντικειμένου Ελήφθη μη έγκυρη επαφή από το διακομιστή Ελήφθη μη έγκυρο συμβάν από το διακομιστή diff --git a/app/src/main/res/values-en-rGB/strings.xml b/app/src/main/res/values-en-rGB/strings.xml new file mode 100644 index 0000000000000000000000000000000000000000..529368a37a9d44bebb67cd495ba3baa74e410113 --- /dev/null +++ b/app/src/main/res/values-en-rGB/strings.xml @@ -0,0 +1,422 @@ + + + + DAVx⁵ + Account does not exist (anymore) + DAVx⁵ Address book + Address books + Remove + Cancel + This field is required + Help + Manage accounts + Share + Database corrupted + All accounts have been removed locally. + Debugging + Other important messages + Low-priority status messages + Synchronisation + Synchronisation errors + Important errors which stop synchronisation like unexpected server replies + Synchronisation warnings + Non-fatal synchronisation problems like certain invalid files + Network and I/O errors + Timeouts, connection problems, etc. (often temporary) + + Your data. Your choice. + Take control. + Regular sync intervals + Disabled (not recommended) + Enabled (recommended) + For synchronisation at regular intervals, %s must be allowed to run in the background. Otherwise, Android may pause synchronisation at any time. + I don\'t need regular sync intervals.* + %s compatibility + This device probably blocks synchronisation. If you\'re affected, you can only resolve this manually. + I have done the required settings. Don\'t remind me anymore.* + * Leave unchecked to be reminded later. Can be reset in app settings / %s. + More information + jtx Board + + Tasks support + If tasks are supported by your server, they can be synchronised with a supported tasks app: + OpenTasks + Doesn\'t seem to be developed anymore – not recommended. + Tasks.org + are not supported (yet).]]> + No app store available + I don\'t need tasks support.* + Open-source software + We\'re happy that you use %s, which is open-source software. Development, maintenance and support are hard work. Please consider contributing (there are many ways) or a donation. It would be highly appreciated! + How to contribute/donate + Don\'t show in the near future + + Permissions + %s requires permissions to work properly. + All of the below + Use this to enable all features (recommended) + All permissions granted + Contacts permissions + No contact sync (not recommended) + Contact sync possible + Calendar permissions + No calendar sync (not recommended) + Calendar sync possible + Notification permission + Notifications disabled (not recommended) + Notifications enabled + jtx Board permissions + No task, journals & notes sync (not installed) + No tasks, journals, notes sync + Tasks, journals, notes sync possible + OpenTasks permissions + Tasks permissions + No task sync (not installed) + No task sync + Task sync possible + Keep permissions + Permissions may be reset automatically (not recommended) + Permissions won\'t be reset automatically + Click Permissions > uncheck \"Remove permissions if app isn\'t used\" + If a switch doesn\'t work, use app settings / Permissions. + App settings + + WiFi SSID permissions + To be able to access the current WiFi name (SSID), these conditions must be met: + Precise location permission + Location permission granted + Location permission denied + Background location permission + Allow all the time + %s]]> + %s]]> + %s uses the Location permission only to determine the current WiFi\'s SSID for SSID-restricted accounts. This will happen even when the app is in background. No location data are collected, stored, processed or sent anywhere. + Location always enabled + Location service is enabled + Location service is disabled + + Translations + Libraries + Version %1$s (%2$d) + Compiled on %s + © Ricki Hirner, Bernhard Stockmann (bitfire web engineering GmbH) and contributors + This version is only eligible for distribution over Google Play. + This program comes with ABSOLUTELY NO WARRANTY. It is free software, and you are welcome to redistribute it under certain conditions. + Thanks to: %s]]> + + Couldn\'t create log file + Now logging all %s activities + View/share + Disable + + Open navigation drawer + Close navigation drawer + CalDAV/CardDAV Sync Adapter + About / License + Beta feedback + Please install an email client + Please install a Web browser + Settings + News & updates + Tools + External links + Web site + Manual + FAQ + Community + Donate + Privacy policy + Notifications disabled. You won\'t be notified about sync errors. + No Internet connectivity. Android will not run synchronisation. + Manage connections + Storage space low. Android will not run synchronisation. + Manage storage + Data saver enabled. Background synchronisation is restricted. + Manage data saver + Welcome to DAVx⁵!\n\nYou can add a CalDAV/CardDAV account now. + System-wide automatic synchronisation is disabled + Enable + Sync all accounts + + Service detection failed + Couldn\'t refresh collection list + + Can not run in foreground + Battery optimisation whitelisting required + + Running in foreground + On some devices, this is necessary for automatic synchronisation. + + Settings + Debugging + Show debug info + View/share software and configuration details + Verbose logging + Logging is active + Logging is disabled + Battery optimisation + App is whitelisted (recommended) + App is not whitelisted (not recommended) + Keep in foreground + May help if your device prevents automatic synchronisation + Connection + Proxy type + + System default + No proxy + HTTP + SOCKS (for Orbot) + + Proxy host name + Proxy port + Security + App permissions + Review permissions required for synchronisation + Distrust system certificates + System and user-added CAs won\'t be trusted + System and user-added CAs will be trusted (recommended) + Reset (un)trusted certificates + Resets trust of all custom certificates + All custom certificates have been cleared + User interface + Notification settings + Manage notification channels and their settings + Select theme + + System default + Light + Dark + + Reset hints + Re-enables hints which have been dismissed previously + All hints will be shown again + Integration + Tasks app + Synchronising with %s + No compatible tasks app found + + CardDAV + CalDAV + Webcal + No contacts sync (missing permissions) + No calendar sync (missing permissions) + No tasks sync (missing permissions) + No calendar and tasks sync (missing permissions) + Can\'t access calendars (missing permissions) + Permissions + There are no address books (yet). + There are no calendars (yet). + There are no calendar subscriptions (yet). + Swipe down to refresh the list from the server. + Synchronise now + Account settings + Rename account + Unsaved local data may be dismissed. Re-synchronisation is required after renaming. New account name: + Rename + Account name already taken + Couldn\'t rename account + Delete account + Really delete account? + All local copies of address books, calendars and task lists will be deleted. + synchronise this collection + read-only + calendar + task list + journal + Show only personal + Refresh address book list + Create new address book + Refresh calendar list + Create new calendar + No Webcal-capable app found + Install ICSx⁵ + + Add account + Login with email address + Email address + Valid email address required + Password + Password required + Login with URL and user name + URL must begin with http(s):// + User name + User name required + Base URL + Select certificate + Login + Create account + Account name + Use of apostrophes (\') have been reported to cause problems on some devices. + Use your email address as account name because Android will use the account name as ORGANISER field for events you create. You can\'t have two accounts with the same name. + Contact group method: + Account name required + Account name already taken + Account could not be created + Advanced login (special use cases) + Use username/password + Use client certificate + No certificate found + Install certificate + Configuration detection + Please wait, querying server… + Couldn\'t find CalDAV or CardDAV service. + Username (email address) / password wrong? + Show details + + Settings: %s + Synchronisation + Contacts sync. interval + Only manually + Every %d minutes + immediately on local changes + Calendars sync. interval + Tasks sync. interval + + Only manually + Every 15 minutes + Every 30 minutes + Every hour + Every 2 hours + Every 4 hours + Once a day + + Sync over WiFi only + Synchronisation is restricted to WiFi connections + Connection type is not taken into consideration + WiFi SSID restriction + Will only sync over %s + Will only sync over %s (requires active location services) + All WiFi connections will be used + Comma-separated names (SSIDs) of allowed WiFi networks (leave blank for all) + WiFi SSID restriction requires further settings + Manage + Authentication + User name + Enter user name: + Password + Update the password according to your server. + Enter your password: + Client certificate alias + No certificate selected + CalDAV + Past event time limit + All events will be synchronised + + Events more than one day in the past will be ignored + Events more than %d days in the past will be ignored + + Events which are more than this number of days in the past will be ignored (may be 0). Leave blank to synchronise all events. + Default reminder + + Default reminder one minute before event + Default reminder %d minutes before event + + No default reminders are created + If default reminders shall be created for events without reminder: the desired number of minutes before the event. Leave blank to disable default reminders. + Manage calendar colours + Calendar colours are reset at each sync + Calendar colours can be set by other apps + Event colour support + Event colours are synced + Event colours are not synced + CardDAV + Contact group method + + Groups are separate vCards + Groups are per-contact categories + + + Create address book + Create calendar + Time zone + Possible calendar entries + Events + Tasks + Notes / journal + Colour + Creating collection + Title + Title is required + Description + optional + Storage location + Storage location is required + Create + Delete collection + Are you sure? + This collection (%s) and all its data will be removed permanently. + These data shall be deleted from the server. + Force read-only + Properties + Address (URL): + Owner: + + Debug info + ZIP archive + Contains debug info and logs + Share the archive to transfer it to a computer, to send it by email or to attach it to a support ticket. + Share archive + Debug info attached to this message (requires attachment support of the receiving app). + HTTP Error + Server Error + WebDAV Error + I/O Error + The request has been denied. Check involved resources and debug info for details. + The requested resource doesn\'t exist (anymore). Check involved resources and debug info for details. + A server-side problem occured. Please contact your server support. + An unexpected error has occured. View debug info for details. + View details + Debug info have been collected + Involved resources + Related to the problem + Remote resource: + Local resource: + Logs + Verbose logs are available + View logs + + An error has occurred. + An HTTP error has occurred. + An I/O error has occurred. + Show details + + Synchronisation paused + Almost no free space left + + WebDAV mounts + Quota used: %1$s / available: %2$s + Share content + Unmount + Add WebDAV mount + Directly access your cloud files by adding a WebDAV mount! + how WebDAV mounts work.]]> + Display name + WebDAV URL + Invalid URL + User name + Password + Add mount + No WebDAV service at this URL + Remove mount point + Connection details will be lost, but no files will be deleted. + Accessing WebDAV file + Downloading WebDAV file + Uploading WebDAV file + WebDAV mount + + DAVx⁵ permissions + Additional permissions required + %s too old + Minimum required version: %1$s + Authentication failed (check login credentials) + Network or I/O error – %s + HTTP server error – %s + Local storage error – %s + View item + Received invalid contact from server + Received invalid event from server + Received invalid task from server + Ignoring one or more invalid resources + + DAVx⁵: Connection security + DAVx⁵ has encountered an unknown certificate. Do you want to trust it? + diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml index a6382050323678359a56ff1a37c7bf0c7c036855..eb6ce9c33c887c1ac3cc9867875ff76f11fdfad3 100644 --- a/app/src/main/res/values-es/strings.xml +++ b/app/src/main/res/values-es/strings.xml @@ -5,7 +5,6 @@ Contactos WebDav Agendas Ayuda - Enviar Depuración Otros mensajes importantes Sincronización @@ -15,23 +14,6 @@ Problemas de sincronización no-fatales como ciertos archivos inválidos Errores de Red y E/S Tiempos de espera, problemas de conección, etc. (muchas veces temporal) - - Sincronización automática - El firmware de %s muchas veces bloquea la sincronización automática. En este caso, permita la sincronización automática en sus ajustes de Android. - Sincronización programada - Su dispositivo restringirá la sincronización de Gerente de cuentas. Para hacer cumplir los intervalos de sincronización regulares de Gerente de cuentas, apague la \"optimización de batería\". - Apagar para Gerente de cuentas - No mostrar de nuevo - Ahora no - Información de código abierto - Nos alegra que uses Gerente de cuentas, que es software de código abierto (GPLv3). Debido al duro trabajo que supone el desarrollo de Gerente de cuentas y los cientos de horas de trabajo, por favor, considera hacer una donación. - Mostrar página de donación - Quizás luego - Más información - OpenTasks no está instalado - Para sincronizar tareas, la aplicación libre OpenTasks es requerida. (No necesaria para contactos/eventos.) - Tras instalar OpenTasks, tendrás que re-instalar Gerente de cuentas y añadir tus cuentas de nuevo (por un error de Android). - Instalar OpenTasks Librerías Versión %1$s (%2$d) @@ -40,9 +22,7 @@ Este programa viene sin NINGÚN TIPO DE GARANTÍA. Es software libre, y cualquier contribución es bienvenida y redistribuida bajo ciertas condiciones. No se pudo crear el archivo de registro - Registro Gerente de cuentas Ahora registrando todas las actividades de %s - Enviar registro Abrir panel de navegación Cerrar panel de navegación @@ -55,7 +35,6 @@ Sitio web Manual Preguntas frequentes - Ayuda / Foros Donar Bienvenido al manager de cuentas! \n @@ -74,11 +53,6 @@ El registro está activo El registro está deshabilitado Conexión - Anular ajustes del proxy - Usar ajustes personalizados del proxy - Usar ajustes del proxy predefinidos por el sistema - Nombre del host del proxy HTTP - Puerto del proxy HTTP Seguridad Invalidar los certificados del sistema Los CA del sistema y los añadidos por el usuario no serán válidos @@ -101,7 +75,6 @@ No hay suscripciones a calendarios (todavía). Desliza hacia abajo para actualizar la lista desde el servidor. Sincronizar ahora - Sincronizando Ajustes de cuenta Renombrar cuenta Información local no guardada puede ser desechada. Se requiere resincronizar después de renombrar. Nuevo nombre de cuenta: @@ -128,15 +101,12 @@ Contraseña requerida Acceder con URL y nombre de usuario La URL debe comenzar con http(s):// - El URL debe comenzar con https:// - Nombre de servidor requerido Nombre de usuario Nombre de usuario requerido URL base Iniciar sesión con URL y certificado del cliente Seleccionar un certificado Registrar - Volver Añadir cuenta Nombre de cuenta Usa tu dirección de correo como nombre de cuenta puesto que Android usará el nombre de la cuenta como campo de \"organizador\" en los eventos que cree. No puedes tener dos cuentas con el mismo nombre. @@ -173,7 +143,6 @@ Solo sincronizará sobre %s (requiere servicios de ubicación activos) Todas las conexiones WiFi serán usadas Nombres separados por comas (SSIDs) de redes WiFi permitidas (deje vacío para todas) - Para leer nombres de WiFi, los permisos de Ubicación y servicios de ubicación permanente son necesarios. Más información (preguntas frecuentes) Autenticación Nombre de usuario @@ -204,7 +173,6 @@ Agendas Crear calendario Huso horario - El huso horario es necesario Posibles entradas de calendario Eventos Tareas @@ -234,12 +202,8 @@ Mostrar detalles Información de depuración - Los registros van adjuntos a este mensaje (requiere suporte de adjuntos en la app que los recibe). - Libro de direcciones solo-lectura Permisos de Gerente de cuentas Permisos adicionales requeridos - OpenTasks muy viejo - Versión requerida: %1$s (actual %2$s) Falló la autenticación (revise credenciales de inicio de sesión) Error de red o E/S – %s Error de servidor – %s @@ -497,4 +461,19 @@ No se puede actualizar la lista de colecciones Tablero jtx Soportes WebDAV - \ No newline at end of file + + Por defecto del sistema + Sin proxy + HTTP + SOCKS (para Orbot) + + + Por defecto del sistema + Claro + Oscuro + + + Los grupos son vCards separadas + Los grupos son categorías de cada contacto + + diff --git a/app/src/main/res/values-eu/strings.xml b/app/src/main/res/values-eu/strings.xml index a26a38cfaa31eacc62025037978f18e843c314fa..2d79997bf88f847defbb342ff247122c6464b08a 100644 --- a/app/src/main/res/values-eu/strings.xml +++ b/app/src/main/res/values-eu/strings.xml @@ -5,7 +5,6 @@ WebDav helbide liburua Helbide liburuak Laguntza - Bidali Arazten Beste mezu garrantzitsu batzuk Sinkronizazioa @@ -13,25 +12,12 @@ Sinkronizazioa gelditzen duten errore garrantzitsuak, esaterako ustekabeko zerbitzariaren erantzunak Sinkronizazio abisuak Sare eta S/I erroreak - - Sinkronizazio automatikoa - Programatutako sinkronizazioa - Desgaitu Kontu-kudeatzailearentzat - Ez erakutsi berriro - Orain ez - Kode-Ireki Informazioa - Erakutsi dohaintza orrialdea - Agian geroago - Informazio gehiago - OpenTask ez dago instalatuta - Instalatu OpenTasks Liburutegiak %1$s (%2$d) bertsioa %s(e)an konpilatuta Ezin izan da egunkari fitxategia sortu - Bidali egunkaria Honi buruz / Lizentzia Instalatu e-mail bezero bat @@ -42,7 +28,6 @@ Webgunea Eskuz FAQ - Laguntza / Foroak Dohaintza egin Pribatutasun politika Gaitu @@ -52,8 +37,6 @@ Arazketa Erakutsi arazte informazioa Konexioa - HTTP proxy hostalari izena - HTTP proxy portua Segurtasuna Erabiltzaile interfazea Jakinarazpen-ezarpenak @@ -62,7 +45,6 @@ Egutegia Webcal Sinkronizatu orain - Sinkronizatzen orain Kontuaren ezarpenak Berrizendatu kontua Berrizendatu @@ -82,14 +64,12 @@ Pasahitza Pasahitza beharrezkoa Saioa hasi URL eta erabiltzaile izenarekin - Hostalari izena beharrezkoa Erabiltzaile izena Erabiltzaile izena beharrezkoa Oinarri URL Saioa hasi URL eta bezero ziurtagiriarekin Aukeratu ziurtagiria Saioa hasi - Atzera Gehitu kontua Kontuaren izena Erakutsi xehetasunak @@ -127,9 +107,7 @@ Erakutsi xehetasunak Arazketa informazioa - Soilik irakurtzeko helbide liburua Kontu-kudeatzailearen baimenak - OpenTasks zaharregia Sare edo S/I errorea – %s HTTP zerbitzari-errorea – %s Saiatu berriro @@ -137,7 +115,6 @@ Kontu-kudeatzailea: Konexioaren segurtasuna Kontu-kudeatzaileak ziurtagiri ezezagun bat aurkitu du. Fidagarritzat jo nahi duzu\? - Zereginak sinkronizatzeko, OpenTasks doako aplikazioa beharrezkoa da. (Ez da beharrezkoa kontaktuetarako/gertaeretarako.) Denbora-mugak, konexio-arazoak, etab. (maiz aldi baterakoak) Kredentzialak, sinkronizazio-frekuentzia, etab. Google helbide-liburua @@ -155,15 +132,10 @@ Itxi nabigazio-panel lerrakorra Ireki nabigazio-panel lerrakorra ABISUA - Zure gailuak Kontu-kudeatzailearen sinkronizazioa kontrolatuko du. Kontu-kudeatzailearen sinkronizazio-tarte erregularrak betearazteko, itzali \"bateriaren optimizazioa\". Egutegi/Kontaktu sinkronizazio moldagailua Orain %s jarduera guztiak erregistratzen ari dira - Kontu-kudeatzailearen erregistroa Programa honek ez du INOLAKO BERMERIK. Hau software librea da, eta birbanatu dezakezu zenbait baldintzarekin. Bertsio hau soilik Google Play-n banatu daiteke. - OpenTasks instalatu ostean Kontu-kudeatzailea berrinstalatu eta kontaktu berriro gehitu behar dituzu (Androiden akatsa). - Pozik gaude zuk Kontu-kudeatzailea erabiltzeaz, software librea dena (GPLv3). Kontu-kudeatzailea garatzea lan gogorra denez eta milaka lanordu eman dizkigunez, mesedez, egin dohaintza bat. - %s firmwareak maiz sinkronizazio automatikoa blokeatzen du. Kasu horretan, baimendu sinkronizazio automatikoa Androiden ezarpenetan. Sinkronizazioa arazo ez larriak, baliogabeko fitxategi batzuk kasu Ikusi/Partekatu softwarearen eta konfigurazioaren xehetasunak Ezin izan da freskatu bilduma zerrenda @@ -185,7 +157,6 @@ Eguneratu erabiltzaile-izena eta pasahitza Kredentzialak Informazio gehiago (ohiko galderak) - WiFi izenak irakurtzeko, kokapenarentzako baimena eta kokapen-zerbitzuak etengabe aktibo egotea behar da. Onartutako WiFI sareen komaz separatutako izenak (SSIDak), hutsik utzi denak onartzeko WiFi konexio oro erabiliko da %s bidez soilik sinkronizatuko da (kokapen-zerbitzu aktiboak behar ditu) @@ -209,7 +180,6 @@ Kontuaren izena behar da Kontaktu-taldeen metodoa: Erabili zure eposta helbidea kontuaren izen gisa, Androidek kontuaren izena erabiliko baitu zuk sortutako gertaeretan \"antolatzaile\" atala betetzeko. Ezin dituzu eduki izen bereko bi kontu. - URLak https:// egiturarekin hasi behar du URLak http(s):// egiturarekin hasi behar du Baliozko eposta helbide bat behar da Hasi saioa Murena.io kontu batekin @@ -227,9 +197,6 @@ Berrezarri iradokizunak Kudeatu jakinarazpen kanalak eta euren ezarpenak Ez fidatu sistemako ziurtagiriez - Erabili sistemak lehenetsitako proxy ezarpenak - Erabili proxy ezarpen pertsonalizatuak - Ezikusi proxy ezarpenak Erregistratzea desgaituta dago Erregistratzea aktibo dago Erregistro xehatua @@ -241,9 +208,7 @@ Baliogabeko kontaktua jaso da zerbitzaritik Biltegiratze lokal errorea – %s Autentifikazioak huts egin du (begiratu saio-haste kredentzialak) - Beharrezko bertsioa: %1$s (unekoa %2$s da) Baimen gehiago behar dira - Erregistroak mezu honi erantsita daude (jasotzen duen aplikazioak eranskinentzako euskarria behar du). S/I errore bat gertatu da. Helbidea (URL): Behartu irakurri-soilik @@ -256,7 +221,6 @@ Konbinatua (gertaerak eta zereginak) Oharrak / Egunerokoa Egutegiko sarrera posibleak - Ordu-zona behar da Ordu-zona Aldatu taldeen metodoa Kontaktu taldeen metodoa @@ -271,4 +235,243 @@ Lehenetsitako abisua Iraganean egun kopuru hau (0 izan daiteke) baino lehenagoko gertaerak ezikusiko dira. Hutsik utzi gertaera guztiak sinkronizatzeko. + Kontua ez da existitzen (dagoeneko) + Kendu + Utzi + Eremu hau beharrezkoa da + Kudeatu kontuak + Partekatu + Ez dago internetik, sinkronizazioa programatuta + Datu-basea hondatua + Kontu lokal guztiak ezabatu dira. + Prioritate baxuko egoera mezuak + + Zure datuak. Zure aukera. + Har ezazu kontrola. + Sinkronizazio tarte erregularrak + Desgaituta (ez gomendatuta) + Gaituta (gomendatuta) + Sinkronizazioa tarte erregularretan ahalbidetzeko, %s atzeko planoan exekutatzen utzi behar da. Bestela, Androidek sinkronizazioa gelditu dezake edozein unean. + Ez ditut sinkronizazio tarte erregularrak behar.* + %s bateragarritasuna + Gailu honek ziurrenik sinkronizazioa blokeatzen du. Hau jasaten baduzu, eskuz konpondu dezakezu soilik. + Beharrezko ezarpenak bukatu ditut. Ez gogorarazi berriro.* + * Utzi aktibatu gabe gero gogorarazteko. Aplikazioaren ezarpenetan berrezarri daiteke / %s + Informazio gehiago + jtx taula + + Tasks bateragarritasuna + Zereginak zure zerbitzarian onartuta badaude, onartutako zeregin aplikazio batekin sinkronizatu daitezke: + OpenTasks + Badirudi ez dela garatzen – ez da gomendatzen. + Tasks.org + ez dira onartzen (oraindik).]]> + Ez dago denda aplikaziorik eskuragarri + Ez dut zereginen funtzionalitatea behar.* + Kode irekiko softwarea + %s erabiltzen duzula pozik gaude, software irekia delako. Garapen, mantentze eta laguntza lan gogorrak dira. Mesedez pentsatu kolaboratzen (modu asko daude) edo dohaintza bat. Asko eskertuko genuke! + Nola lagundu/dirua eman + Ez erakutsi etorkizunean + + Baimenak + %s baimenak behar ditu ondo funtzionatzeko. + Azpiko guztiak + Erabili hau ezaugarri guztiak gaitzeko (gomendatuta) + Baimen guztiak eman dira + Kontaktuen baimenak + Kontaktu sinkronizaziorik ez (ez gomendatuta) + Kontaktuen sinkronizazioa posible + Egutegiaren baimenak + Egutegi sinkronizaziorik ez (ez gomendatuta) + Egutegiaren sinkronizazioa posible + Jakinarazpen baimena + Jakinarazpenak desgaituta (ez gomendatuta) + Jakinarazpenak gaituta + jtx taularen baimenak + Zereginen, egunkarien eta noten sinkronizaziorik ez (instalatu gabe) + Zeregin, egunkari, noten sinkronizaziorik ez + Zeregin, egunkari, noten sinkronizazioa posible + OpenTasks baimenak + Tasks baimenak + Ez sinkronizatu zereginak (instalatu gabe) + Zeregin sink. ez + Zereginen sinkronizazioa posible + Mantendu baimenak + Baimenak automatikoki berezarri daitezke (ez gomendatuta) + Baimenak ez dira automatikoki berezarriko + Egin klik Baimenak atalean > kendu marka \"Kendu baimenak aplikazioa ez bada erabiltzen\" aukeratik + Interruptore batek funtzionatzen ez badu, erabili aplikazioaren ezarpenak / Baimenak. + Aplikazioaren ezarpenak + + WiFi SSID baimenak + Uneko WiFi izena (SSID) atzitzeko, baldintza hauek bete behar dira: + Kokapen zehatz baimena + Kokapen baimena emanda + Kokapen baimena ukatuta + Atzeko planoko kokapen baimena + Baimendu beti + %s(e)ra ezarri da]]> + %s(e)ra ezarri]]> + %s zure kokapen baimena erabiltzen du uneko WiFi SSIDa SSIDz murriztatutako kontuen kontra egiaztatzeko. Hau aplikazioa atzeko planoan badago ere gertatuko da. Ez da kokapen daturik biltzen, gordetzen, prozesatzen edo inora bidaltzen. + Kokapena beti gaituta + Kokapen zerbitzua gaituta dago + Kokapen zerbitzua desgaituta dago + + Itzulpenak + © Ricki Hirner, Bernhard Stockmann (bitfire web engineering GmbH) eta kolaboratzaileak + Eskerrik asko: %s]]> + + Ikusi/partekatu + Desgaitu + + Tresnak + Komunitatea + Jakinarazpenak desgaituta. Ez zaitugu sinkronizazio-erroreei buruz jakinaraziko. + Kudeatu konexioak + Biltegiratze-lekua baxua da. Androidek ez du sinkronizatuko. + Kudeatu biltegia + Datu-aurrezpena gaituta. Atzeko planoko sinkronizazioa murriztuko da. + Kudeatu datu-aurrezpena + Sinkronizatu kontu guztiak + + Zerbitzuaren detekzioak huts egin du + Ezin izan da bilduma zerrenda freskatu + + Ezin da aurrealdean exekutatu + Bateriaren optimizazioaren zerrenda zuria behar da + + Aurrealdean exekutatzen + Gailu batzuetan, hau beharrezkoa da sinkronizazio automatikorako. + + Bateria optimizazioa + Aplikazioa zerrenda zurian dago (gomendatuta) + Aplikazioa ez dago zerrenda zurian (ez gomendatuta) + Mantendu aurrealdean + Zure gailuak sinkronizazio automatikoa galarazten badu lagundu dezake + Proxy mota + + Sistemaren lehenetsia + Proxyrik ez + HTTP + SOCKS (Orbot-erako) + + Proxy ostalariaren izena + Proxy ataka + Aplikazioaren baimenak + Berrikusi sinkronizaziorako beharrezkoak diren baimenak + Hautatu gaia + + Sistemaren lehenetsia + Argia + Iluna + + Integrazioa + Tasks aplikazioa + %s(r)ekin sinkronizatzen + Ez da zeregin aplikazio bategarririk aurkitu + + Kontaktu sinkronizaziorik ez (baimenak falta dira) + Egutegi sinkronizaziorik ez (baimenak falta dira) + Zeregin sinkronizaziorik ez (baimenak falta dira) + Egutegi eta zeregin sinkronizaziorik ez (baimenak falta dira) + Ezin dira egutegiak atzitu (baimenak falta dira) + Baimenak + Sinkronizatu bildumak + Kontuaren izena hartuta dago + Ezin izan da kontua berrizendatu + egunkaria + Erakutsi pertsonala soilik + Apostrofeen erabilera (\'), gailu batzuetan arazoak sortzen ditu. + Saio-hasiera aurreratua (kasu bereziak) + Erabili erabiltzaile-izena/pasahitza + Erabili bezero ziurtagiria + Ez da ziurtagiririk aurkitu + Instalatu ziurtagiria + Google Kontaktuak / Egutegia + Mesedez, ikusi gure \"Google-ekin probatuta\" orria informazio eguneraturako. + Oharrak jaso edo zure bezero ID sortzea beharrezkoa izatea gertatu daiteke. + Google kontua + Hasi saioa Google-rekin + Bezeroaren ID (aukerazkoa) + Zure bezeroaren ID erabili dezakezu, gureak funtzionatzen ez badu. + Erakutsi nola! + Pribatutasun politika xehetasunetarako.]]> + <![CDATA[%1$s -k <a href="%2$s">Google API Zerbitzuen Erabiltzaileen Datuen Gidalerroak</a>, erabilera mugatuko eskakizunak barne.]]. + Ezin izan da baimen-kodea lortu + Erabiltzaile-izena (eposta) / pasahitza txarto dago? + + + Eskuz soilik + 15 minuturo + 30 minuturo + Ordu batero + 2 orduro + 4 orduro + Egunero + + WiFi SSID murriztapenak ezarpen gehiago behar ditu + Kudeatu + Berriro autentifikatu + Egin OAuth saioa berriro + erabiltzaile-izena + Ez da ziurtagiririk hautatu + + Taldeak vCard banatuak dira + Taldeak kontaktu bakoitzeko kategoriak dira + + Biltegiaren kokapena beharrezkoa da + Azkenengoz sinkronizatuta: + Ez da inoiz sinkronizatu + Jabea: + + ZIP artxiboa + Arazte informazioa eta erregistroak ditu + Partekatu artxiboa ordenagailu batera transferitzeko, e-posta edo ticket baten bidez bidaltzeko. + Partekatu artxiboa + Arazketa informazioa mezuan erantsiko da (hartuko dituen aplikazioak eranskinak onartu behar ditu). + HTTP errorea + Zerbitzari errorea + WebDAV errorea + S/I errorea + Eskaera ukatu egin da. Egiaztatu parte hartzen dituzten baliabideak eta arazketa informazioa xehetasun gehiagorako. + Eskatutako baliabidea ez dago (jada). Egiaztatu parte hartzen dituzten baliabideak eta arazketa informazioa xehetasun gehiagorako. + Zerbitzariak arazo bat izan du. Mesedez jarri harremanetan zure zerbitzariaren laguntzarekin. + Ustekabeko errore bat gertatu da. Ikusi arazketa informazioa xehetasunentzako. + Ikusi xehetasunak + Arazketa informazioa lortu da + Parte hartzen duten baliabideak + Arazoarekin erlazionatuta + Kanpoko baliabidea: + Baliabide lokala: + Egunkariak + Erregistro xehetuak eskuragarri daude + Ikusi egunkariak + + Sinkronizazioa geldituta + Ia ez dago leku librerik + + WebDAV muntaiak + Erabilitako kuota: %1$s / eskuragarri: %2$s + Partekatu edukia + Desmuntatu + Gehitu WebDAV muntaia + Atzitu zure cloud fitxategiak zuzenean WebDAV muntaia bat gehitzen! + WebDAV muntaiak nola funtzionatzen duten.]]> + Bistaratze-izena + WebDAV URL + URL baliogabea + Erabiltzaile izena + Pasahitza + Gehitu muntaia + Ez dago WebDAV zerbitzurik URL honetan + Kendu muntaia-puntua + Konexio xehetasunak galduko dira, baina ez da fitxategirik ezabatuko. + WebDAV fitxategia atzitzen + WebDAV fitxategia deskargatzen + WebDAV fitxategia kargatzen + WebDAV muntaia + + %s zaharregia + Beharrezko bertsio minimoa: %1$s + Errore leuna (saiakera maximora heldu da) diff --git a/app/src/main/res/values-fa/strings.xml b/app/src/main/res/values-fa/strings.xml index 3b64c4a566a00b71a0ed957b6cdae4575442d47a..b31e848e3b8bd2b04ce055856830b208f8ae3950 100644 --- a/app/src/main/res/values-fa/strings.xml +++ b/app/src/main/res/values-fa/strings.xml @@ -5,8 +5,13 @@ حساب کاربری موجود نیست (بیشتر از این) کتاب آدرس DAVx⁵ کتاب آدرس + حذف + لغو + این قسمت الزامیست راهنما اشتراک گذاری + پایگاه داده، دارای مشکل است + تمام حساب های کاربری حذف شدند. خطایابی پیام های مهم دیگر پیامهای وضعیت با اولویت پایین @@ -30,9 +35,14 @@ من تنظیمات مورد نیاز را انجام داده ام. دیگر به من یادآوری نکن. * * علامت را بردارید تا بعداً یادآوری شود. در تنظیمات برنامه %s قابل تنظیم مجدد است. اطلاعات بیشتر - وظایف پشتیبانی + jtx Board + + پشتیبانی فعالیت ها اگر وظایف توسط سرور شما پشتیبانی شود، می توان آنها را همگام سازی کرد: وظایف را باز کنید + عدم توسعه توسط برنامه نویسان - توصیه نمیشود. + Tasks.org + پشتیبانی نمیشود (فعلا).]]> فروشگاه در دسترس نیست من به پشتیبانی وظایف نیاز ندارم. * برنامه‌های متن باز @@ -51,9 +61,17 @@ مجوزهای تقویم همگام سازی تقویم انجام نشود (توصیه نمی شود) همگام سازی تقویم امکان پذیر است + مجوز اعلان + اعلان ها غیرفعال اند (توصیه نمی شود). + اعلان ها فعال اند. + مجوز برنامه jtx Board + تسک، ثبت وقایع و یادداشت ها همگام سازی نمی شوند (برنامه نصب نیست). + تسک، ثبت وقایع و یادداشت ها همگام سازی نمی شوند. + تسک، ثبت وقایع و یادداشت ها همگام سازی می شوند. مجوزهای OpenTasks - مجوز وظایف + مجوز فعالیت ها بدون همگام سازی (نصب نشده است) + عدم همگام سازی وظایف امکان همگام سازی وظایف وجود دارد نگاه داری مجوزها مجوزها ممکن است به طور خودکار تنظیم مجدد شوند (توصیه نمی شود) @@ -64,10 +82,13 @@ مجوزهای SSID WiFi برای دسترسی به نام WiFi فعلی (SSID) ، باید این شرایط را داشته باشید: + مجوز موقعیت دقیق مجوز مکان اعطا شده است مجوز مکان رد شد مجوز مکان پس زمینه همیشه اجازه دهید + %s تنظیم گردید.]]> + %sفقط از مجوز مکان برای تعیین SSID WiFi فعلی برای حساب‌های محدود شده با SSID استفاده می‌کند. حتی وقتی برنامه در پس زمینه باشد این اتفاق می افتد. هیچ کدام اطلاعات مکانی ، ذخیره ، پردازش و یا ارسال نمی شود. مکان همیشه فعال است سرویس مکان فعال است @@ -79,6 +100,7 @@ در %s وارد شده است این نسخه تنها برای پخش در Google Play واجد شرایط است. این برنامه کاملاً بدون ضمانت است. این یک نرم افزار رایگان است ، و شما می توانید تحت شرایط خاص توزیع مجدد آن را انجام دهید. + با تشکر از: %s]]> پرونده ثبت ایجاد نشد اکنون همه %s فعالیت ها را ثبت می کنید @@ -94,13 +116,21 @@ لطفاً یک مرورگر وب نصب کنید تنظیمات اخبار و amp؛ به روز رسانی + ابزارها لینک های خارجی سایت اینترنتی دستی FAQ + انجمن کمک سیاست حفظ حریم خصوصی + اعلان ها غیر فعال اند. از همگام سازی با خبر نخواهید شد. بدون اتصال به اینترنت. اندروید همگام سازی را اجرا نمی کند. + مدیریت ارتباطات + فضای خالی کم است. اندروید همگام سازی را اجرا نخواهد کرد. + مدیریت حافظه + محافظ داده فعال است. همگام سازی در پس زمینه محدود می شود. + مدیریت محافظ داده به همگام‌ساز DAVx⁵ خوش آمدید همگام سازی خودکار در کل سیستم غیرفعال است فعال @@ -109,6 +139,8 @@ تشخیص سرویس ناموفق بود لیست مجموعه به روز نشد + در پس زمینه اجرا نمی شود. + افزودن به لیست سفید بهینه ساز باتری الزامیست. در حال اجرا در پیش زمینه در برخی از دستگاه ها ، این مورد برای همگام سازی خودکار لازم است. @@ -120,9 +152,21 @@ ورود به سیستم ورود به سیستم فعال است ورود به سیستم غیرفعال است + بهینه ساز باتری + برنامه در لیست سفید قرار دارد (توصیه می شود). + برنامه در لیست سفید قرار ندارد (توصیه نمی شود). در پیش زمینه نگه دارید اگر دستگاه شما از همگام سازی خودکار جلوگیری کند ، ممکن است کمک کند ارتباط + نوع پروکسی + + پیش فرض سیستم + بدون پروکسی + HTTP + ساکس (برای Orbot) + + هاست پروکسی + پورت پروکسی امنیت مجوزهای برنامه مجوزهای لازم برای همگام سازی را مرور کنید @@ -135,11 +179,17 @@ رابط کاربر تنظیمات اعلان کانال های اعلان و تنظیمات آنها را مدیریت کنید + انتخاب تم + + پیش فرض سیستم + روشن + تیره + تنظیم مجدد نکات نکاتی را که قبلاً رد شده اند دوباره فعال می کند همه نکات دوباره نشان داده خواهد شد ادغام - وظایف برنامه + برنامه مدیریت فعالیت ها همگام سازی با %s برنامه سازگار یافت نشد @@ -148,8 +198,8 @@ Webcal بدون همگام سازی مخاطبین (مجوزهای از دست رفته) بدون همگام سازی تقویم (مجوزهای از دست رفته) - بدون همگام سازی کارها (مجوزهای از دست رفته) - بدون تقویم و وظایف همگام سازی (مجوزهای از دست رفته) + عدم همگام سازی فعالیت ها (مجوزها یافت نشد) + عدم همگام سازی تقویم و فعالیت ها (مجوزها یافت نشد) دسترسی به تقویم ها (مجوزهای از دست رفته) امکان پذیر نیست مجوزها هیچ کتاب آدرسی وجود ندارد (هنوز). @@ -157,11 +207,11 @@ هیچ اشتراک تقویمی وجود ندارد (هنوز). برای تازه کردن لیست از سرور ، صفحه را به پایین بکشید. اکنون همگام سازی کنید - در حال همگام سازی تنظیمات حساب تغییر نام حساب داده های محلی ذخیره نشده ممکن است از بین بروند. پس از تغییر نام مجدداً همگام سازی لازم است. نام حساب جدید: تغییر نام + نام حساب قبلاً گرفته شده است نمی‌توانید نام حساب را تغییر دهید حذف حساب واقعاً حساب حذف شود؟ @@ -170,7 +220,8 @@ فقط خواندنی تقویم لیست کار - فقط به صورت شخصی نشان داده شود + وقایع + فقط شخصی ها را نمایش بده تازه کردن لیست کتاب آدرس کتاب آدرس جدید ایجاد کنید تازه کردن لیست تقویم @@ -189,11 +240,11 @@ نام کاربری نام کاربری مورد نیاز است آدرس پایه - با URL و گواهی مشتری وارد شوید گواهی را انتخاب کنید وارد شدن ساخت حساب عنوان حساب + گزارش شده، استفاده از علامت (\') باعث بوجود آمدن مشکل در برخی دستگاه ها می شود. از آدرس ایمیل خود به عنوان نام حساب استفاده کنید زیرا Android از نام حساب به عنوان قسمت ORGANIZER برای رویدادهایی که ایجاد می کنید استفاده خواهد کرد. نمی توانید دو حساب با یک نام داشته باشید. روش گروه تماس: نام حساب لازم است @@ -215,8 +266,8 @@ همگام سازی مخاطبین فقط دستی هر %d دقیقه + بلافاصله با تغییرات محلی - تقویم ها همگام سازی می شوند. - کارها همگام سازی می شوند. + همگام سازی تقویم ها + همگام سازی فعالیت ها فقط به صورت دستی هر ۱۵ دقیقه @@ -236,7 +287,6 @@ نام های جدا شده با کاما (SSID) شبکه های WiFi مجاز (برای همه خالی بگذارید) محدودیت WiFi SSID به تنظیمات بیشتری نیاز دارد مدیریت - اطلاعات بیشتر (پرسش و پاسخ) احراز هویت نام کاربری نام کاربری را وارد کنید @@ -272,17 +322,14 @@ گروه‌ها کارت‌های مجازی جداگانه هستند گروه ها دسته های هر مخاطب هستند - تغییر روش گروه ایجاد دفترچه آدرس - کتاب آدرس من تقویم ایجاد کنید منطقه زمانی ورودی های احتمالی تقویم - مناسبت ها - وظایف + رویدادها + فعالیت ها یادداشت ها / ژورنال - ترکیبی (رویدادها و وظایف) رنگ ایجاد مجموعه عنوان @@ -290,19 +337,22 @@ شرح اختیاری محل ذخیره سازی + تعیین محل ذخیره سازی الزامیست ایجاد حذف مجموعه آیا مطمئن هستید؟ این مجموعه (%s) و تمام داده های آن برای همیشه حذف می شوند. این داده ها باید از سرور حذف شوند. - در حال حذف مجموعه اجبار به فقط خواندنی خواص آدرس(URL) - URL را کپی کنید مالک: اطلاعات اشکال زدایی + آرشیوسازی ZIP + حاوی اطلاعات و پیغام های دیباگ + اشتراک گذاری فایل آرشیو شده، جهت انتقال به کامپیوتر، ارسال از طریق ایمیل و افزودن به تیکت پشتیبانی + اشتراک گذاری آرشیو اطلاعات اشکال زدایی پیوست شده به این پیام (نیاز به پشتیبانی پیوست از برنامه دریافت کننده دارد). خطای HTTP خطای سرور @@ -318,7 +368,6 @@ مربوط به مشکل است منبع از راه دور: منبع محلی: - مشاهده با برنامه رویدادها رویدادهای مربوط به گفتار موجود است دیدن رویدادها @@ -328,10 +377,18 @@ خطای ورودی خروجی رخ داده است. نمایش جزئیات + تقریبا فضای خالی وجود ندارد. + اشتراک گذاری محتوا + نام نمایشی + URL نامعتبر نام کاربری گذرواژه - + اتصال قطع میگردد، اما هیچ فایلی حذف نخواهد شد. + دسترسی به فایل WebDAB + بارگیری فایل WebDAV + بارگذاری فایل WebDAV + مجوزهای همگام‌ساز DAVx⁵ مجوزهای اضافی لازم است %s خیلی قدیمی است @@ -340,7 +397,6 @@ شبکه یا خطای ورودی / خروجی – %s خطای سرور HTTP – %s خطای ذخیره سازی محلی – %s - سعی مجدد مشاهده مورد مخاطب نامعتبر از سرور دریافت شد رویداد نامعتبر از سرور دریافت شد diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml index f323e5f8d727e8ae65f4239034edba2cfb4b7d39..d8e030337ef2268da7ccf75cd8bd312f415e8f89 100644 --- a/app/src/main/res/values-fr/strings.xml +++ b/app/src/main/res/values-fr/strings.xml @@ -7,7 +7,6 @@ Retirer Annuler Aide - Envoyer Débogage Autres messages importants Synchronisation @@ -17,23 +16,6 @@ Problèmes de synchronisation non fatals tels que certains fichiers non valides Erreurs de réseau et d\'entrée / sortie Délais d\'attente, problèmes de connexion, etc. (souvent temporaires) - - Synchronisation automatique - %s le matériel bloque souvent la synchronisation automatique. Dans ce cas, autorisez la synchronisation automatique dans vos paramètres d\'Android. - Synchronisation planifiée - Votre appareil va restreindre la synchronisation de Gestionnaire de compte. Pour forcer des intervalles de synchronisation Gestionnaire de compte plus réguliers, enlever l\'option \"optimisation de batterie\". - Désactiver pour Gestionnaire de compte - Ne plus afficher - Pas maintenant - Open-Source Information - Nous sommes heureux que vous utilisiez Gestionnaire de compte, qui est un logiciel open-source (GPLv3). Parce que développer Gestionnaire de compte est un travail difficile, qui nous a pris de nombreuses heures, nous vous invitons à faire un don. - Faire un don - Plus tard - Plus d\'informations - L\'application OpenTasks n\'est pas installée - Pour synchroniser les tâches, l\'application gratuite OpenTasks est nécessaire. (Elle ne l\'est pas pour les contacts et les événements.) - Après l\'installation d\'OpenTasks, vous devez RE-INSTALLER Gestionnaire de compte et ajouter vos comptes à nouveau (bug Android). - Installer OpenTasks Librairies Version %1$s (%2$d) @@ -42,8 +24,6 @@ Ce programme est fourni sans AUCUNE GARANTIE. C\'est un logiciel libre, et vous êtes en droit de le redistribuer sous certaines conditions. Impossible de créer le fichier journal - Journalisation de Gestionnaire de compte - Envoyer le journal Ouvrir le volet de navigation Fermer le volet de navigation @@ -56,7 +36,6 @@ Site Web Manuel Foire aux questions - Aide/Forum Faire un don Pas de connectivité Internet. Android ne pourra pas exécuter la synchronisation. Bienvenue sur Gestionnaire de compte ! @@ -76,11 +55,6 @@ La journalisation est activée La journalisation est désactivée Connexion - Ignorer les paramètres proxy - Utiliser des paramètres proxy personnalisés - Utiliser les paramètres proxy du système - Nom de l\'hôte du proxy HTTP - Port du proxy HTTP Sécurité Révoquer les certificats du système Les certificats du système et ceux ajoutés par l\'utilisateur ne seront pas dignes de confiance @@ -103,7 +77,6 @@ Il n\'y a pas (encore) d\'abonnement à des calendriers. Glisser vers le bas pour rafraîchir la liste à partir du serveur. Synchroniser maintenant - Synchronisation en cours Paramètres du compte Renommer le compte Les données locales non enregistrées pourraient être perdues. Une re-synchronisation est nécessaire après avoir renommé le compte. Nouveau nom du compte : @@ -130,15 +103,12 @@ Mot de passe requis Connexion avec une URL et un nom d\'utilisateur L\'URL doit commencer par http(s):// - L\'URL doit commencer par https:// - Nom d\'hôte requis Nom d\'utilisateur Nom d\'utilisateur requis URL de base Se connecter avec l\'URL et le certificat client Choisir le certificat Se connecter - Retour Ajouter un compte Nom du compte Utilisez votre adresse e-mail comme nom de compte car Android utilisera ce nom en tant que champ ORGANISATEUR pour les événements que vous créerez. Vous ne pouvez pas avoir deux comptes avec le même nom. @@ -160,7 +130,6 @@ Intervalle de synchronisation des tâches Manuellement - Toutes les 2 minutes Tous les quarts d\'heure Toutes les demi-heures Toutes les heures @@ -176,7 +145,6 @@ Ne synchronise que sur %s (nécessite des services de localisation actifs) Toutes les connexions WiFi seront utilisées Liste des points d\'accès WiFi (SSID) autorisés, séparés par des virgules. (Laissez vide pour tous) - Pour lire les noms WiFi, l\'autorisation « Position » et les services de localisation activés en permanence sont nécessaires. Plus d\'informations (FAQ) Authentification Nom d\'utilisateur @@ -208,7 +176,6 @@ Mon carnet d\'adresses Créer un calendrier Fuseau horaire - Fuseau horaire requis Entrées possibles de calendrier Événements Tâches @@ -238,12 +205,8 @@ Voir détails Infos de débogage - Les logs seront joints à ce message (nécessite la prise en charge de pièce jointe par l\'application destinataire). - Carnet d\'adresse en lecture seulement Autorisations Gestionnaire de compte Autorisations supplémentaires demandées - Version d\'OpenTask trop ancienne - Version requise %1$s (actuellement %2$s) Echec d\'authentification (contrôler vos identifiants de connexion) Erreur de réseau ou d\'entrée / sortie - %s Erreur de serveur HTTP - %s @@ -446,4 +409,83 @@ OpenTasks jtx Board Gestionnaire de calendriers web - \ No newline at end of file + Gestion des comptes + + Ne peut pas fonctionner en premier plan + Il est nécessaire de désactiver l’option \"Optimisation de la batterie\" + + Fonctionne au premier plan + + L\'App est dans la liste blanche (recommandé) + L\'App n\'est pas dans la liste blanche (non recommandé) + Garder au premier plan + Peut aider si votre appareil empêche la synchronisation automatique + + Par défaut (système) + Pas de proxy + HTTP + SOCKS (pour Orbot) + + Autorisations de l\'application + Consulter les autorisations requises pour la synchronisation + Sélectionner un thème + + Par défaut + Clair + Sombre + + Intégration + Applications de gestion de taches + Aucune application de tâches compatibles trouvée + + Pas de synchronisation du carnet d\'adresses (autorisations manquantes) + Pas de synchronisation du calendrier (autorisations manquantes) + Pas de synchronisation des tâches (autorisations manquantes) + Pas de synchronisation du calendrier et des tâches (autorisations manquantes) + Le nom du compte est déjà pris + journal + N\'afficher que les comptes perso. + + L\'utilisation d\'apostrophes (\') est connue pour causer des problèmes sur certains appareils. + Nom d\'utilisateur (courriel) / mot de passe incorrect ? + + La restriction du SSID WiFi nécessite des réglages supplémentaires + Gérer + + Les groupes sont des VCards indépendantes + Les groupes sont des catégories pour chacun des contacts + + + Emplacement de stockage requis + + Partagez l’archive pour la transférer sur un ordinateur, pour l’envoyer par e-mail ou pour la joindre à un ticket de support. + Informations de débogage jointes à ce message (nécessite une application compatible). + Erreur I/O + La demande a été rejetée. Vérifiez les ressources impliquées et les informations de débogage pour plus de détails. + La ressource demandée n\'existe pas (plus). Vérifiez les ressources concernées et les informations de débogage pour plus de détails. + Un problème de serveur s\'est produit. Veuillez contacter le support de votre serveur. + Les informations de débogage ont été collectées + Ressources impliquées + En rapport avec le problème + Voir avec l\'application + Des logs verbeux sont disponibles + Voir les logs + + Synchronisation suspendue + L\'espace de stockage est presque plein + + Points de montage WebDAV + Partager le contenu + Démonter + Ajouter un point de montage WebDAV + Accédez directement à vos fichiers du cloud en ajoutant un point de montage WebDAV ! + comment fonctionnent les points de montage WebDAV.]]> + URL WebDAV + Ajouter un point de montage + Retirer le point de montage + Les détails de la connexion seront perdus, mais aucun fichier ne sera supprimé. + Téléversement du fichier WebDAV + Point de montage WebDAV + + %s trop vieux + diff --git a/app/src/main/res/values-gd/strings.xml b/app/src/main/res/values-gd/strings.xml index 823bfc94a8c20b5e1bb339b6530cd1e96440025d..2dd569bc0ba4ed63792e5755a8ad9cc452c54c54 100644 --- a/app/src/main/res/values-gd/strings.xml +++ b/app/src/main/res/values-gd/strings.xml @@ -9,7 +9,6 @@ Saothraichean Tachartasan Nithean a ghabhas cur ris a’ mhìosachan - Tha feum air roinn-tìde Roinn-tìde Cruthaich mìosachan Leabhar nan seòladh agam @@ -51,7 +50,6 @@ Ainm-cleachdaiche Dearbhadh Barrachd fiosrachaidh (CÀBHA) - Airson na h-ainmean WiFi a leughadh, bidh feum air cead ionaid agus air seirbheisean ionaid a bhios air fad an t-siubhail. Ainmean (SSIDs) sgaraichte le cromagan air na lìonraidhean WiFi a tha ceadaichte (fàg bàn airson ceangal sam bith a chleachdadh) Thèid ceangal WiFi sam bith a chleachdadh Cha dèid a shioncronachadh ach thar %s (bi feum air seirbheisean ionaid gnìomhach) @@ -82,15 +80,12 @@ Cleachd an seòladh puist-d agad mar ainm a’ chunntais on a chleachdas Android ainm a’ chunntais dan raon an EAGRAICHE air na tachartasan a chruthaicheas tu. Chan fhaod dà chunntas leis an aon ainm a bhith agad. Ainm a’ chunntais Cuir cunntas ris - Air ais Clàraich a-steach Tagh teisteanas Clàraich a-steach le URL is teisteanas cliant URL bunaiteach Tha feum air ainm-cleachdaiche Ainm-cleachdaiche - Tha feum air ainm òstair - Feumaidh an URL a thòiseachadh le https:// Feumaidh an URL a thòiseachadh le http(s):// Clàraich a-steach le URL is ainm-cleachdaiche Tha feum air facal-faire @@ -117,7 +112,6 @@ Dh’fhaoidte gun dèid dàta ionadail nach deach a shàbhaladh a leigeil seachad. Bidh feum air ath-shioncronachadh ’na dhèidh. Ainm ùr a’ chunntais: Thoir ainm ùr air a’ chunntas Roghainnean a’ chunntais - ’Ga shioncronachadh Sioncronaich an-dràsta Grad-shlaighd sìos a dh’ath-nuadhachadh na liosta on fhrithealaiche. Chan eil fo-sgrìobhadh air mìosachan ann (fhathast). @@ -139,11 +133,6 @@ Na cuir earbsa ann an ùghdarrasan theisteanasan an t-siostaim no a chaidh a chur ris le cleachdaiche Na cuir earbsa ann an teisteanasan an t-siostaim Tèarainteachd - Port progsaidh HTTP - Ainm òstair progsaidh HTTP - Cleachd roghainnean progsaidh bunaiteach an t-siostaim - Cleachd roghainnean progsaidh gnàthaichte - Tar-àithn roghainnean a’ phrogsaidh Ceangal Tha an logadh à comas Tha an logadh gnìomhach @@ -162,7 +151,6 @@ Chan eil ceangal ris an eadar-lìon. Cha ruith Android an sioncronachadh. Poileasaidh prìobhaideachd Thoir tabhartas dhuinn - Cobhair / Bòrd-brath CÀBHA Leabhar-mìneachaidh Làrach-lìn @@ -176,30 +164,12 @@ Adaptar sioncronachadh nam mìoachan/nan cunntasan Dùin drathair na seòladaireachd Fosgail drathair na seòladaireachd - Cuir loga - Logadh manaidsear nan cunntas Cha b’ urrainn dhuinn faidhle an loga a chruthachadh Fhuair thu am prògram seo GUN BHARANTAS SAM BITH. ’S e bathar-bog saor a th’ ann agus faodaidh tu ath-sgaoileadh ma thogras tu fo chumhan sònraichte. Chan fhaodar an tionndadh seo dheth a sgaoileadh ach le Google Play. Chaidh a thrusadh %s Tionndadh %1$s (%2$d) Leabhar-lannan - Stàlaich OpenTasks - Nuair a bhios tu air OpenTasks a stàladh, feumaidh tu manaidsear nan cunntas ATH-STÀLADH ’s na cunntasan agad a chur ris a-rithist (seo buga Android). - Airson saothraichean a shioncronachadh, feumaidh sinn aplacaid shaor OpenTasks. (Chan eil seo riatanach airson an luchd-aithne no na tachartasan.) - Cha deach OpenTasks a stàladh - Barrachd fiosrachaidh - Uaireigin eile ’s dòcha - Seall duilleag nan tabhartasan - Tha sinn toilichte gu bheil thu a’ cleachdadh manaidsear nan cunntas a tha ’na bhathar-bog le bun-tùs fosgailte (GPLv3). Air sgàth ’s gur e obair chruaidh a th’ ann an leasachadh manaidsear nan cunntas is gun dug e mìltean de dh’uairean-obrach, saoil an doir thu tabhartas dhuinn\? - Fiosrachadh mun bhun-tùs fhosgailte - Chan ann an-dràsta - Na seall seo a-rithist - Cuir dheth airson manaidsear nan cunntas - Cuingichidh an t-uidheam agad an sioncronachadh a nì manaidsear nan cunntas. Airson sioncronachadh cunbhalach le manaidsear nan cunntas a sparradh air, cuir dheth “Piseachadh a’ bhataraidh”. - Sioncronachadh sgeidealaichte - Bacaidh bathar-an-sàs %s an sioncronachadh fèin-obrachail u tric. Ma thachras seo, ceadaich sioncronachadh fèin-obrachail ann an roghainnean Android. - Sioncronachadh fèin-obrachail Crìochan-ùine, duilgheadasan leis a’ cheangal is msaa. (sealach mar as trice) Mearachdan lìonraidh is ion/às-chuir Duilgheadasan leis an t-sioncronachadh nach eil èiginneach, can corra faidhle mì-dhligheach @@ -209,7 +179,6 @@ Sioncronachadh Teachdaireachdan cudromach eile Dì-bhugachadh - Cuir Teisteas, tricead an t-sioncronachaidh is msaa. Leabhraichean nan seòladh Leabhar sheòlaidhean Google @@ -234,12 +203,8 @@ Mearachd an fhrithealaiche HTTP – %s Mearachdan lìonraidh no ion/às-chuir – %s Dh’fhàillig an dearbhadh (thoir sùil air an teisteas clàraidh a-steach) - An tionndadh riatanach: %1$s (tha %2$s agad) - Tha OpenTasks ro shean Tha feum air ceadan a bharrachd Ceadan manaidsear nan cunntas - Leabhar sheòlaidhean ri leughadh a-mhàin - Thèid logaichean a cheangal ris an teachdaireachd seo (bi feum air taic ri ceanglachain san aplacaid a gheibh iad). Fiosrachadh dì-bhugachaidh Seall mion-fhiosrachadh Thachair mearachd ion/às-chur. diff --git a/app/src/main/res/values-gl/strings.xml b/app/src/main/res/values-gl/strings.xml index b05d4a7137c33d92b8ff607e6598f44492e15303..affc24578e8222e64052d9149f5cf448ee33656e 100644 --- a/app/src/main/res/values-gl/strings.xml +++ b/app/src/main/res/values-gl/strings.xml @@ -5,7 +5,6 @@ Axenda de WebDav Lista de contactos Axuda - Enviar Depurando Outras mensaxes importantes Sincronización @@ -15,23 +14,6 @@ Problemas non-fatais de sincronización como certos ficheiros non válidos Fallos de rede e de I/O Tempos límite, problemas de conexión, etc. (adoitan ser temporais) - - Sincronización automática - O firmware %s adoita bloquear a sincronización automática. Neste caso, permita a sincronización automática na configuración de Android. - Sincronización programada - O seu dispositivo restrinxirá a sincronización do Xestor de Conta. Para forzar un intervalo regular de sincronización do Xestor de Conta, desactive a \"optimización de batería\". - Desactivar para o Xestor de Conta - Non mostrar de novo - Non agora - Información sobre o Código Aberto - Alegrámonos de que utilice o Xestor de Conta, que é software de código aberto (GPLv3). Desenvolver o Xestor de Conta require un importante esforzo e toma miles de horas de traballo, por favor considere facer unha doazón. - Mostrar páxina de doazóns - Máis tarde - Máis información - OpenTasks non está instalado - Para sincronizar tarefas precisa o aplicativo gratuíto OpenTasks. (Non requerido para contactos/eventos.) - Tras instalar OpenTasks, debe REINSTALAR o Xestor de Conta e engadir as súas contas novamente (erro de Android). - Instalar OpenTasks Bibliotecas Versión %1$s (%2$d) @@ -40,9 +22,7 @@ Este programa non proporciona NINGUNHA GARANTÍA. É software libre, e convidámolo a redistribuílo baixo certas condicións. Non se creou ficheiro de rexistro - Rexistro do Xestor de Conta Está a rexistrar todas as actividades do %s - Enviar rexistro Abrir barra de navegación Pechar barra de navegación @@ -55,7 +35,6 @@ Sitio web Manual PMF - Axuda / Foros Doar Sen conexión a internet, Android non sincronizará. Dámosche a benvida ao Xestor de Conta! @@ -75,11 +54,6 @@ O rexistro está activo O rexistro está desactivado Conexión - Sobrescribir a configuración da proxy - Utilizar axustes de proxy personalizados - Utilizar axustes proxy por omisión do sistema - Nome servidor proxy HTTP - Porto proxy HTTP Seguridade Non confiar en certificados do sistema Non se confiará nos CAs do sistema ou engadidos por usuario @@ -102,7 +76,6 @@ Non ten subscricións a calendario (aínda). Arrastre cara abaixo para actualizar a lista desde o servidor. Sincronizar agora - Sincronizando Configuración da conta Renomear conta Os datos locais non gardados perderanse. A resincronización é precisa tras renomear. Novo nome da conta: @@ -129,15 +102,12 @@ Require contrasinal Conectar con URL e nome de usuario A URL debe comezar con http(s):// - A URL debe comezar con https:// - Require nome do servidor Nome de usuario Require nome de usuario URL base Conectar con URL e certificado cliente Escoller certificado Acceder - Atrás Engadir conta Nome da conta Utilice o seu enderezo de correo electrónico como nome de conta xa que Android utilizará o nome da conta como campo ORGANIZADOR para os eventos que cre. Non poderá ter dúas contas co mesmo nome. @@ -174,7 +144,6 @@ Só se sincronizará baixo %s (require servizos de localización activos) Utilizaranse todas as conexións WiFi Nome das rede WiFi permitidas (SSIDs) separados por comas (en branco para todas) - Para ler os nomes WiFi, requírese o permiso de Localización e que os servizos de localización estean sempre activados. Máis información (PMF) Autenticación Nome de usuario @@ -205,7 +174,6 @@ A miña lista de contactos Crear calendario Fuso horario - Requírese o fuso horario Entradas posibles no calendario Eventos Tarefas @@ -235,12 +203,8 @@ Mostrar detalles Info depuración - Os rexistros están anexados a esta mensaxe (precisa soporte de anexos do aplicativo receptor). - Lista de contactos de só lectura Permisos do Xestor de Conta Precísanse permisos adicionais - OpenTask está demasiado antigo - Require versión: %1$s (actual %2$) Fallo na autenticación (verifique credenciais) Fallo de rede ou I/O – %s Fallo servidor HTTP – %s @@ -280,4 +244,233 @@ Murena.io Axenda Murena.io Google + A conta non existe (definitivamente) + Eliminar + Desbotar + Este campo é requerido + Xestionar contas + Compartir + Sen internet, programando a sincr. + Base de datos estragada + Foron eliminadas tódalas contas locais + Mensaxes de estado de baixa prioridade + + Os teus datos. Ti elixes. + Toma o control. + Intervalos regulares de sincronización + Desactivado (non recomendado) + Activado (recomendado) + Para sincronizar a intervalos regulares, %s debe ter permiso para executarse en segundo plano. Se non, Android podería deter a sincronización. + Non preciso sincr. con regularidade.* + Compatibilidade de %s + Este dispositivo probablemente está a bloquear a sincronización. Esto só se pode solucionar de xeito manual. + Xa fixen o que me pediades. Non mo lembres máis.* + * Deixar sen marcar para lembrar máis tarde. Pode restablecerse nos axustes da app / %s. + Máis información + jtx Board + + Soporte para Tasks + Se as tarefas están soportadas polo teu servidor, poden ser sincronizadas cunha app que soporte tarefas: + OpenTasks + Semella que xa non está en desenvolvemento – non recomendado. + Task.org + non están soportadas (aínda).]]> + Sen tenda de apps dispoñible + Non necesito soporte para tarefas.* + Software de código aberto + Encántanos que uses %s, que é software de código aberto. O desenvolvemento, mantemento e soporte son un traballo difícil. Considera colaborar (hai moitos xeitos) ou facer unha doazón. Sería de agradecer! + Como contribuír/doar + Non mostrar no futuro próximo + + Permisos + %s require permisos para funcionar axeitadamente. + Todos os de abaixo + Usa isto para habilitar tódalas características (recomendado) + Todos os permisos concedidos + Permisos de contactos + Sen sincronización de contactos (non recomendado) + É posible sincronizar os contactos + Permisos de calendario + Sen sincronización de calendario (non se recomenda) + É posible sincronizar o calendario + Permiso de notificacións + Notificacións desactivadas (non recomendado) + Notificacións activadas + permisos para jtx Board + Sen sincr. de tarefas, diarios e notas (non instalado) + Sen sincr. de tarefas, diarios, notas + Dispoñible sincr. de tarefas, diarios e notas + Permisos para OpenTasks + Permisos para Tasks + Sen sincronización de tarefas (non instalado) + Tarefas non sincr. + É posible sincronizar as tarefas + Manter permisos + Os permisos poden restablecerse automáticamente (non recomendado) + Os permisos non se restablecerán automáticamente + Preme en Permisos > desmarca \"Eliminar permisos se a app non se usa\" + Se unha opción non funciona, usa axustes da aplicación / Permisos. + Permisos da aplicación + + Permisos WiFi SSID + Para poder acceder á WiFi (SSID) actual, deben darse estas condicións: + Permiso para localización precisa + Permiso de localización outorgado + Permiso de localización denegado + Permiso de localización en segundo plano + Permitir en todo momento + %s]]> + %s]]> + %s utiliza o permiso de Localización só para determinar o SSID da WiFi para contas restrinxidas a uns SSIDs. Esto acontecerá incluso cando a app está en segundo plano. Non se recollen datos de localización, nin se gardan, procesan ou envían a ningún sitio. + Localización sempre activada + Servizo de localización activado + Servizo de localización desactivado + + Traducións + © Ricki Hirner, Bernhard Stockmann (bitfire web engineering GmbH) e colaboradoras + Grazas a: %s]]> + + Ver/compartir + Desactivar + + Ferramentas + Comunidade + Notificacións desactivadas. Non verás os avisos de erro na sincr. + Xestionar conexións + Queda pouco espazo, Android non executará a sincronización. + Xestionar almacenaxe + O aforro de datos está activado. A sincronización en segundo plano está restrinxida. + Xestionar aforro de datos + Sincroniza todas as contas + + Fallou a detección do servizo + Non se actualizou a lista da colección + + Non pode executarse en segundo plano + Require ter permiso para optimizacións de batería + + Executándose en primeiro plano + En algúns dispositivos esto é necesario para a sincronización automática. + + Optimización da batería + App está permitida (recomendado) + App sen permiso (non recomendado) + Manter en primeiro plano + Podería ser útil se o dispositivo dificulta a sincronización automática + Tipo de Proxy + + Por defecto no sistema + Sen proxy + HTTP + SOCKS (para Orbot) + + Servidor do proxy + Porto do proxy + Permisos da App + Revisa os permisos requeridos para a sincronización + Elixe decorado + + Por defecto no sistema + Claro + Escuro + + Integración + App Tarefas + Sincronizando con %s + Non se atopan app de tarefas compatible + + Sen sincronización de contactos (faltan permisos) + Sen sincronización de calendario (faltan permisos) + Sen sincronización de tarefas (faltan permisos) + Sen sincronización de calendario e tarefas (faltan permisos) + Non se pode acceder aos calendarios (faltan permisos) + Permisos + Sincronizar coleccións + O nome de conta xa está a ser utilizado + Non cambiou o nome da conta + diario + Mostrar só personal + + O uso de apóstrofes (\') pode causar problemas nalgúns dispositivos, según nos informan. + Conexión avanzada (casos especiais) + Usar nome de usuaria/contrasinal + Usar certificado cliente + Non se atopa certificado + Instalar certificado + Contactos / Calendario de Google + Le a nosa páxina \"Probado con Google\" para ter información actualizada. + Pode que recibas algún aviso e/ou teñas que crear o teu propio ID de cliente. + Conta de Google + Inicia sesión con Google + ID Cliente (optativo) + política de Privacidade para saber máis.]]> + Google API Services User Data Policy, incluíndo os requerimentos de Limited Use.]]> + Non se puido obter o código de autorización + ¿Usuaria (enderezo email) / contrasinal incorrectos? + + A restrición WiFi SSID precisa máis axustes + Xestionar + Volver a autenticar + Intentar acceder con OAuth outra vez + identificador + Non hai certificado seleccionado + + Grupos son vCards separados + Grupos son categorías por contacto + + Requírese a localización da almacenaxe + Última sincronización: + Nunca + Propietaria: + + Arquivo ZIP + Contén info de depuración e rexistros + Comparte o arquivo para pasalo á computadora, envialo por email ou anexalo a un tícket de axuda + Compartir arquivo + Info de depuración anexa a esta mensaxe (require soporte de anexos na app receptora). + Erro HTTP + Fallo no servidor + Fallo WebDAV + Fallo I/O + A solicitude foi denegada. Comproba os recursos implicados e o rexistro de depuración para máis info. + O recurso solicitado xa non existe. Comproba os recursos implicados e a información de depuración. + Hai un fallo no lado do servidor. Contacta co soporte do servidor. + Aconteceu un fallo non agardado. Mira a info de depuración para detalles. + Ver detalles + Recolleuse a info de depuración + Recursos implicados + Relacionado co problema + Recurso remoto: + Recurso local: + Rexistros + Están dispoñibles rexistros explicativos + Ver rexistros + + Sincronización en pausa + Case non queda espazo libre + + Montaxes WebDAV + Cota utilizada: %1$s / dispoñible: %2$s + Compartir contido + Desmontar + Engadir montaxe WebDAV + Accede aos teus ficheiros na nube engadindo unha montaxe WebDAV! + como funciona a montaxe WebDAV.]]> + Nome mostrado + URL WebDAV + URL inválido + Nome de usuaria + Contrasinal + Engadir montaxe + Neste URL non hai ningún servizo WebDAV + Eliminar punto de montaxe + Perderanse os detalles da conexión, mais non se eliminarán ficheiros. + Accedendo ao ficheiro WebDAV + Descargando ficheiro WebDAV + Subindo ficheiro WebDAV + Montaxe WebDAV + + %s demasiado antigo + Versión mínima requerida: %1$s + Erro (acadouse o máx. de reintentos) diff --git a/app/src/main/res/values-hr/strings.xml b/app/src/main/res/values-hr/strings.xml index 242081e55e588cb7845c7cc717d65e3f7960a086..a57aa0b602a2032358ca99f081ca9ea58a31fdfc 100644 --- a/app/src/main/res/values-hr/strings.xml +++ b/app/src/main/res/values-hr/strings.xml @@ -162,11 +162,11 @@ Ne postoje pretplate na kalendar (još). Povucite prstom prema dolje za osvježavanje popisa sa poslužitelja. Sinkroniziraj sada - Upravo sinkronizira Postavke računa Preimenuj račun Ne spremljeni lokalni podatci mogu biti odbačeni. Ponovna sinkronizacija je potrebna nakon preimenovanja. Novi naziv računa: Preimenuj + Naziv računa se već koristi Račun nije moguće preimenovati Obriši račun Stvarno izbrisati račun? @@ -194,7 +194,6 @@ Korisničko ime Korisničko ime je potrebno Osnovni URL - Prijava sa URL-om i klijentskim certifikatom Odaberi certifikat Prijava Kreiraj račun @@ -241,7 +240,6 @@ Zarezom odijeljeni nazivi (SSIDa) dozvoljenih WiFi mreža (ostaviti prazno za sve mreže) WiFi SSID ograničenje zahtijeva daljnje postavke Upravljaj - Više informacija (FAQ) Autentifkacija Korisničko ime Unesite korisničko ime: @@ -279,17 +277,14 @@ Grupe su odvojene vCards Grupe su kategorije po kontaktima - Promijeni metodu grupe Kreiraj adresar - Moj adresar Kreiraj kalendar Vremenska zona Mogući unosi u kalendar Događaji Zadatci Bilješke / dnevnik - Kombinirano (događaji i zadaci) Boja Izrada zbirke Naslov @@ -302,11 +297,9 @@ Jeste li sigurni? Ova zbirka (%s) i njeni podatci biti će trajno uklonjeni. Ovi podatci biti će obrisani sa poslužitelja. - Brisanje zbirke Prisilno samo za čitanje Svojstva Adresa (URL): - Kopiraj URL Vlasnik: Debug info @@ -325,7 +318,6 @@ Povezano s problemom Udaljeni resurs: Lokali resurs: - Pregledaj u aplikaciji Logovi Opsežniji logovi su dostupni Pregledaj logove @@ -338,7 +330,7 @@ Korisničko ime Lozinka - + DAVx⁵ dopuštenja Dodatna dopuštenja su potrebna %s prestar @@ -347,7 +339,6 @@ Mrežna ili I/O greška – %s HTTP poslužiteljska greška – %s Greška lokalne pohrane – %s - Pokušaj ponovno Pregledaj stavku Primljen je nevažeći kontakt sa poslužitelja Primljen je nevažeći dogđaj sa poslužitelja diff --git a/app/src/main/res/values-hu/strings.xml b/app/src/main/res/values-hu/strings.xml index 7207e8058d6d57dfd87c9a46e3f63c58ff6b936d..d0342dc0168dc557eb6bf6669077caf4a3bd5598 100644 --- a/app/src/main/res/values-hu/strings.xml +++ b/app/src/main/res/values-hu/strings.xml @@ -5,7 +5,6 @@ WebDav címjegyzék Címjegyzékek Súgó - Küldés Hibakeresés Egyéb fontos üzenetek Szinkronizáció @@ -15,23 +14,6 @@ Nem kritikus szinkronizációs hibák, például bizonyos fajta hibás fájlok Hálózati és I/O hibák Időtúllépésék, kapcsolódási problémák, stb. (gyakran átmeneti problémák) - - Automatikus szinkronizálás - A(z) %s gyári szoftvere gyakran blokkolja az automatikus szinkronizálást. Ebben az esetben engedélyezze a szinkronizálást az Android beállítások között. - Időzített szinkronizálás - Az eszköz korlátozni fogja a Fiókkezelő szinkronizálást. A szokásos DAVadroid szinkronizációs ciklus biztosítása érdekében vonja ki a Fiókkezelő-ot az „akkumulátorfigyelés” alól. - A fiókkezelőhöz kapcsoljuk ki - Ne jelenjen meg többet - Ne most - Nyílt forráskód információ - Örülünk, hogy használja a Fiókkezelőt. A Fiókkezelő nyílt forráskódú (GPLv3) szoftver, ennek fejlesztése több ezer kemény munkaórát jelentett eddig. Kérjük gondolja át és adományokkal támogassa munkánkat! - Adományozó oldal mutatása - Talán később - További információk - Az OpenTasks nincs telepítve - A feladatok szinkronizálásához az ingyenes OpenTasks alkalmazásra van szükség. A névjegyek és események szinkronizálásához erre nincs szükség. - Az OpenTasks telepítését követően újra kell telepíteni a DAVdroit alkalmazást és újra fel kell venni a fiókokat (Android hiba). - Az OpenTasks telepítése Könyvtárak Verziószám:%1$s (%2$d) @@ -40,9 +22,7 @@ Ehhez a programhoz SEMMIFÉLE GARANCIA NEM JÁR. Ez a program szabad szoftver, ami a bizonyos feltételek mellett szabadon terjeszthető. A naplófájl létrehozása nem sikerült - Fiókkezelő naplózás Mostantól %s minden művelete naplózásra kerül - Naplófájl küldése Navigációs fiók megnyitása Navigációs fiók bezárása @@ -55,7 +35,6 @@ Honlap Kézikönyv GYIK - Segítség / Fórumok Támogatás Üdvözlünk a fiókkezelőben! \n @@ -74,11 +53,6 @@ Naplózás bekapcsolva Naplózás kikapcsolva Kapcsolat - Proxybeállítások felülírása - Egyedi proxybeállítások - Az alapértelmezett proxybeállítás használata - HTTP proxyállomás neve - HTTP proxy port Biztonság A rendszertanúsítványok elfogadása A rendszer által kezelt, előre vagy felhasználó által telepített tanúsítványok figyelmen kívül lesznek hagyva @@ -97,7 +71,6 @@ Naptár Webcal Szinkronizálás most - Szinkronizálás most Fiókbeállítások Fiók átnevezése Az elmentetlen helyben tárolt adatok elvesznek. Az átnevezés után szinkronizálásra lesz szükség. Új fióknév: @@ -124,15 +97,12 @@ A jelszó szükséges Bejelentkezés URL és felhasználónév segítségével Az URL elején kötelezően szerepeljen http(s):// - Az URL elején kötelezően szerepeljen https:// - A szervernév megadása feltétlenül szükséges Felhasználónév A felhasználónév megadása feltétlenül szükséges URL-törzs Bejelentkezés URL és tanúsítvány segítségével Tanúsítvány kiválasztása Bejelentkezés - Vissza Fiók hozzáadása A fiók neve Használja az email címet fióknévként, mert később a létrehozandó események szervezőjeként (ORGANIZER mező) az Android ezt fogja használni. Két fiókot nem lehet azonos néven létrehozni. @@ -154,7 +124,6 @@ Feladatlisták szinkronizálásának sűrűsége Manális - 2 perc 15 percenként 30 percenként Óránként @@ -170,7 +139,6 @@ Szinkroniázálás csak a megadott hálózaton (%s) keresztül (bekapcsolt helymeghatározási szolgáltatást igényel) Minden WiFi hálózatot használjuk A használható WiFi hálózatok nevei (SSID), vesszővel elválasztva (hagyja üresen, ha nem akar szűrést beállítani) - A WiFi hálózatok nevének elérése, helyadatokat alkalmazásengedélyt és folyamatosan bekapcsolt helymeghatározási szolgáltatást igényel. További információk (GYIK) Hitelesítés Felhasználónév @@ -201,7 +169,6 @@ Az én címjegyzékem Naptár létrehozása Időzóna - Az időzóna megadása kötelező Lehetséges naptárbejegyzések Események Feladatok @@ -231,11 +198,8 @@ Részletek megjelenítése Hibakeresési információ - Csak olvasható címjegyzék Fiókkezelő engedélyek További engedélyek szükségesek - Az OpenTask túl régi - Szükséges verzió: %1$s (jelenlegi verzió: %2$s) A hitelesítés nem sikerült. Kérjük ellenőrizze a bejelenkezési adatait! Hálózati vagy I/O hiba – %s HTTP szerverhiba - %s @@ -274,8 +238,233 @@ Alapértelmezett emlékeztető %d perccel az esemény előtt Nem készült alapértelmezett értesítő - Naplókat csatoltak ehhez az üzenethez. A fogadó alkalmazás részéről szükséges a csatolmányok támogatása. FIGYELMEZTETÉS Az Murena egy hamis készülékmodellt for jelenteni a Google számára, az adatvédelmed érdekében. \nEllenőrizheted, hogy melyik modell van a Google készülékaktivitásban, miután beléptél. + A felhasználói fiók (már) nem létezik + Eltávolítás + Mégse + Ennek e mezőnek a megadása kötelező + Fiókok kezelése + Megosztás + Nincs hálózati kapcsolat. A szinkronizálás beütemezése + Az adatbázis megsérült + A fiókok törölve lettek az eszközön. + Alacsony prioritású státuszüzenetek + + Az Ön adatai. Az Ön döntése. + Vegye kézbe az irányítást. + Rendszeres ütemezett szinkronizálás + Kikapcsolva (nem javasolt) + Bekapcsolva (javasolt) + A rendszeres ütemezett szinkronizáláshoz a %s számára engedélyezni kell a háttérben futást, különben az Android rendszer a szinkronizálást bármikor leállíthatja. + Nincs szükségem rendszeres ütemezett szinkronizálásra.* + %s kompatibilitás + Ez az eszköz valószínűleg blokkolja a szinkronizálást. Ha valóban, ez manuálisan megoldható. + Elvégeztem a szükséges beállításokat, nincs szükségem további figyelmeztetésre.* + * Hagyja üresen, ha szeretné, ha legközelebb is kapjon emlékeztetést. Később felülírható az alkalmazásbeállításoknál (%s). + További információk + jtx Board + + Feladatok támogatása + Ha a szerver támogatja a feladatokat, akkor a következő feladat alkalmazásokkal lehet szinkronizálni őket: + OpenTasks + A fejlesztése leállt – nem ajánlott. + Tasks.org + nem támogatott (még).]]> + Nincs elérhető alkalmazás-áruház + Nincs szükségem a feladatok támogatására.* + Nyílt forráskódú szoftver + Nagyon örülünk, hogy a %s felhasználói közé tartozik, ami egy nyílt forráskódú szoftver. Ennek fejlesztése, karbantartása és támogatása ugyanakkor kemény munkát igényel. Kérjük, fontolja meg, hogy ezt pénzzel vagy valamilyen módon támogassa (több lehetőség közül is választhat). Nagyon megköszönnénk! + A hozzájárulás lehetőségei + Ne mutassa a közeljövőben + + Engedélyek + A %s megfelelő működése bizonyos engedélyeket igényel. + Az összes alább felsorolt + Ezt választva valamennyi funkció bekapcsolható (javasolt) + Az összes engedély megadva + Névjegy-engedélyek + A címtárak szinkronizálásának mellőzése (nem javasolt) + A névjegyek szinkronizálása (javasolt) + Naptár-engedélyek + A naptárak szinkronizálásának mellőzése (nem javasolt) + A naptárak szinkronizálása (javasolt) + Értesítések engedélyezése + Az értesítések tiltása (nem ajánlott) + Értesítések engedélyezése + jtx Board engedélyek + Nincs feladat, napló- és jegyzetszinkronizálás (nincs telepítve) + Nincsenek szinkronizálandó feladatok, naplóbejegyzések és jegyzetek + Van lehetőség a feladatok, naplóbejegyzések és jegyzetek szinkronizálására + OpenTasks engedélyek + Feladat engedélyek + A feladatlisták szinkronizálása nem lehetséges (nincs telepítve) + Nincs feladatszinkronizálás + A feladatlisták szinkronizálása lehetséges + Engedélyek megtartása + Az engedélyek automatikusan visszaállhatnak az alapértelmezettre (nem ajánlott) + Az engedélyek nem fognak visszaállni az alapértelmezettre automatikusan + Kattints az Engedélyekre > vedd ki a pipát az \"Engedélyek eltávolítása, ha nem használja az alkalmazást\" mellől + Ha a kapcsolók nem működnek, használja az alkalmazás-beállításokat + Alkalmazásbeállítások + + WiFi SSID engedélyek + A jelenlegi WiFi nevének (SSID) az eléréséhez a következő feltételeknek kell teljesülniük: + Pontos helyadatok használatának engedélyezése + Helyadat engedélyek megadva + Helyadat engedélyek megtagadva + Helyadatok engedélyezése a háttérben + Mindig engedélyezze + %sre]]> + %sre]]> + %s a Helyadat engedélyt csak arra használja, hogy a jelenlegi WiFi SSID-jét meghatározza SSID-korlátozott fiókokhoz. Ez a funkció akkor is működik, ha az alkalmazás háttérben van. Semmilyen helyadatot nem gyűjtünk, tárolunk, feldolgozunk vagy küldünk bárhová. + Helyadatok mindig engedélyezve + Hely szolgáltatás bekapcsolva + Hely szolgáltatás kikapcsolva + + Fordítások + © Ricki Hirner, Bernhard Stockmann (bitfire web engineering GmbH) és a közreműködők + Köszönet a következőknek: %s]]> + + Nézet/megosztás + Kikapcsol + + Eszközök + Közösség + Az értesítések tiltva vannak. A szinkronizálási hibákról nem fog értesítést kapni. + Kapcsolatok kezelése + Kevés a rendelkezésre álló tárhely. A rendszer nem fog szinkronizálást végezni. + Tárhely kezelése + Az adatcsökkentés bekapcsolva. A háttérben futó szinkronizálás korlátozva van. + Adatcsökkentés kezelése + Az összes fiók szinkronizálása + + Szolgáltatások felderítése nem sikerült + Gyűjteménylista frissítése nem sikerült + + Nem lehetséges az előtérben való futtatás + Kivétel az akkumulátorhasználat optimalizálása alól + + Futás előtérben + Egyes eszközökön erre szükség van az automatikus szinkronizáció működéséhez + + Akkumulátorhasználat optimalizálása + Az optimalizálás kikapcsolva (ajánlott) + Az optimalizálás bekapcsolva (nem ajánlott) + Maradjon előtérben + Segíthet, ha az eszköz akadályozza az automatikus szinkronizációt + A proxy típusa + + Alapértelmezett + Kikapcsolva + HTTP + SOCKS (Orbot számára) + + A proxy szerver neve + A proxy által használt port + Alkalmazásengedélyek + Tekintse át a szinkronizáláshoz szükséges engedélyeket + Stílus kiválasztása + + Rendszerbeállítás szerinti + Világos + Sötét + + Integráció + Feladatok alkalmazás + Szinkronizálás ezzel: %s + Nem található kompatibilis feladat alkalmazás + + A névjegyek szinkronizálása nem működik (nincs engedélyezve) + A naptárak szinkronizálása nem működik (nincs engedélyezve) + A feladatok szinkronizálása nem működik (nincs engedélyezve) + A naptárak és feladatok szinkronizálása nem működik (nincs engedélyezve) + A naptárak elérése nem működik (nincs engedélyezve) + Engedélyek + Nem iratkozott fel egyetlen naptárra sem (még). + A fióknév már használatban van + A fiók átnevezése nem sikerült. + napló + Csak a személyesek megjelenítése + + Az aposztróf (\') használata a visszajelzések szerinte egyes eszközökön problémát okoz. + Haladó bejelentkezés (speciális lehetőségek) + Felhasználónév/jelszó használata + Tanúsítvány használata + Nem található tanúsítvány + Tanúsítvány telepítése + Google Naptár / Névjegyek + Kérjük, olvassa el a \"Google Tesztelve\" oldalunkat friss információkért. + Nem zárhatók ki váratlan figyelmeztetések, és lehet, hogy szükség lesz saját Client ID létrehozására is. + Google fiók + Client ID (opcionális) + Saját Client ID is használható, amennyiben az alapértelmezett nem működne. + Mutasd, hogyan! + Felhasználónév (e-mail cím) vagy jelszó hibás? + + WiFi SSID szűréshez további beállítások szükségesek + Beálítások + Nincs tanúsítvány kiválasztva + Ha szeretné, hogy az emlékeztető nélküli eseményekhez egy alapértelmezett emlékeztető legyen beállítva, akkor adja meg, hogy az hány perccel az esemény előtt legyen. Ha nem akar ilyet, hagyja üresen. + + A csoportok különálló vCard objektumok + A csoportok névjegy-kategóriák + + + A tárhely megadása feltétlenül szükséges + Utolsó szinkronizálás: + Még soha + Tulajdonos: + + ZIP archívum + Hibakeresési információkat tartalmaz + Az archívum megosztásával lehetőség van egy másik számítógépre áthelyezni, e-mail formájában elküldeni vagy egy hibabejelentéshez becsatolni. + Archívum megosztása + Hibakeresési információ csatolása ehhez az üzenethez (ha a fogadó alkalmazás támogatja a csatolmányokat). + HTTP hiba + Szerverhiba + WebDAV hiba + I/O hiba + A kérés megtagadva. Ellenőrizze az érintett erőforrásokat és a hibakeresési információkat a további részletekért. + Az igényelt erőforrás nem létezik (már). Ellenőrizze az érintett erőforrásokat és a hibakeresési információkat a további részletekért. + Szerveroldali hiba történt. Vegye fel a kapcsolatot a szerver üzemeltetőjével. + Váratlan hiba történt. Hibakereséshez használja a hibakeresési információkat. + Részletek megtekintése + A hibakeresési információ összegyűjtése befejeződött + Érintett erőforrások + A probléma kapcsán érintett erőforrások + Távoli erőforrás: + Helyi erőforrás: + Naplóbejegyzések + Rendelkezésre állnak részletes naplóbejegyzések + Naplóbejegyzések megtekintése + + A szinkronizáció felfüggesztve + A tárhely majdnem teljesen betelt + + WebDAV kötetek + Kvóta: felhasználva %1$s, keret %2$s + Tartalom megosztása + Lecsatolás + WebDAV kötet hozzáadása + Közvetlenül hozzáférhet felhőben tárolt fájlokhoz WebDAV kötet hozzáadásával! + hogyan lehet WebDAV köteteket használni.]]> + Megjelenítendő név + WebDAV URL + Érvénytelen URL + Felhasználónév + Jelszó + WebDAV kötet hozzáadása + Ezen a címen nincs WebDAV szolgáltatás + WebDAV kötet lecsatolása + A kapcsolat beállításai elvesznek, de maguk a fájlok nem. + Hozzáférés WebDAV fájlhoz + WebDAV fájl letöltése + WebDAV fájl feltöltése + WebDAV kötetek + + %s túl régi + Legalacsonyabb szükséges verzió: %1$s + Nem végzetes hiba (az újrapróbálkozások száma elérte a maximumot) diff --git a/app/src/main/res/values-is/strings.xml b/app/src/main/res/values-is/strings.xml index d977b6cef04827b05f1c7744e5d65fe346a6ce25..2ca342a4094196971f7ead0976aa1527c5e4cdf6 100644 --- a/app/src/main/res/values-is/strings.xml +++ b/app/src/main/res/values-is/strings.xml @@ -194,7 +194,6 @@ CA-skilríkjum kerfis og viðbættum af notanda verður treyst (ráðlagt) Öll sérsniðin skilríki hafa verið hreinsuð Allar ábendingar verða sýndar aftur - Samstilli núna Stillingar aðgangs Endurnefna aðgang Endurnefna diff --git a/app/src/main/res/values-it/strings.xml b/app/src/main/res/values-it/strings.xml index 162b18a1ca5c48811904f260df258323903b1bb9..be2b019f8420d15c5490dcf658e3c5b040000ef4 100644 --- a/app/src/main/res/values-it/strings.xml +++ b/app/src/main/res/values-it/strings.xml @@ -5,7 +5,6 @@ Rubrica WebDav Rubriche Aiuto - Invia Debug Altri messaggi importanti Sincronizzazione @@ -15,23 +14,6 @@ Problemi di sincronizzazione non gravi come alcuni file non validi Errori di Rete e di I/O Timeout, problemi di connessione, ecc. (spesso temporanei) - - Sincronizzazione automatica - Il firmware %s spesso blocca la sincronizzazione automatica. Nel caso, attiva la sincronizzazione automatica nelle impostazioni Android. - Sincronizzazione programmata - Il dispositivo bloccherà la sincronizzazione del Gestore Account. Per forzare intervalli di sincronizzazione regolari del Gestore Account, disabilita \"ottimizzazione batteria\". - Disabilita per il Gestore Account - Non mostrare più - Non adesso - Informazioni Open-Source - Siamo lieti che abbia scelto di utilizzare Gestore Account, un programma open-source (GPLv3). Dato che sviluppare Gestore Account è un impegno pesante che comporta migliaia di ore di lavoro, ti invitiamo a fare una donazione. - Mostra la pagina per donare - Forse più tardi - Più informazioni - OpenTasks non installata - Per sincronizzare le attività, occorre l\'app gratuita OpenTasks. (Non richiesta per contatti/eventi.) - Dopo l\'installazione di OpenTasks è necessario INSTALLARE NUOVAMENTE Account Manager e aggiungere di nuovo gli account (bug di Android). - Installa OpenTasks Librerie Versione %1$s (%2$d) @@ -40,9 +22,7 @@ Il programma è distribuito SENZA ALCUNA GARANZIA. È software libero e può essere redistribuito a determinate condizioni. Non riesco a creare il file di log - Log Account Manager Ora sto registrando tutte e %s le attività - Invia log Apro la barra di navigazione Chiudo la barra di navigazione @@ -55,7 +35,6 @@ Sito web Manuale FAQ - Aiuto / Forum Dona Benvenuto su Account Manager! \n @@ -74,11 +53,6 @@ Log attivo Log disabilitato Connessione - Sovrascrivi le impostazioni del proxy - Utilizza impostazioni personalizzate del proxy - Utilizza le impostazioni predefinite di sistema del proxy - Nome host del proxy HTTP - Porta del proxy HTTP Sicurezza Non fidarti dei certificati di sistema I Certificati utente e di sistema non verranno ritenuti affidabili @@ -101,7 +75,6 @@ Nessuna sottoscrizione a calendari presente (per ora). Scorri verso il basso per aggiornare l\'elenco dal server. Sincronizza adesso - Sincronizzazione in corso Impostazioni account Rinomina account I dati locali non salvati potrebbero essere rimossi. È necessario ri-sincronizzare dopo la rinomina. Nuovo nome account: @@ -128,15 +101,12 @@ Password richiesta Accedi con URL e nome utente L\'URL deve iniziare con http(s):// - L\'URL deve iniziare con https:// - E\' richiesto un nome host Nome utente E\' richiesto un nome utente Base URL Accedi con URL e certificato client Seleziona certificato Accedi - Indietro Aggiungi account Nome account Inserisci l\'indirizzo email come nome account dato che Android utilizzerà il nome account per il campo ORGANIZER degli eventi che andrai a creare. Non è possibile avere due account con lo stesso nome. @@ -158,7 +128,6 @@ Intervallo sincronizzazione attività Solo manualmente - Ogni 2 minuti Ogni 15 minuti Ogni 30 minuti Ogni ora @@ -174,7 +143,6 @@ Sincronizzerò solo con %s (richiede che siano attivi i servizi di geolocalizzazione) Verranno utilizzate tutte le connessioni WIFI Nomi (SSID) separati da virgola delle reti WiFi autorizzate (lascia vuoto per autorizzarle tutte) - Per leggere i nomi WiFi, sono richiesti permessi di Localizzazione e servizi di Localizzazione permanentemente attivi. Ulteriori informazioni (FAQ) Autenticazione Nome utente @@ -219,11 +187,8 @@ Visualizza dettagli Informazioni di debug - Rubrica in sola lettura Autorizzazioni Account Manager Sono richiesti ulteriori permessi - OpenTasks troppo vecchio - Versione richiesta: %1$s (attualmente %2$s) Autenticazione fallita (controlla le credenziali di accesso) Account Manager: Sicurezza connessione @@ -236,13 +201,11 @@ Ricevuta attività non valida dal server Ricevuto evento non valido dal server Ricevuto contatto non valido dal server - I Log sono allegati al messaggio (è necessario che l\'app ricevente supporti gli allegati). Questi dati saranno eliminati dal server. facoltativo Note / vjournal Attività Eventi - E\' necessario un Fuso Orario Nel caso in cui vengano creati dei promemoria predefiniti per gli eventi che ne sono senza: il numero di minuti prima dell\'evento. Lascia vuoto per disabilitare i promemoria predefiniti. Non è stato creato alcun promemoria predefinito @@ -506,4 +469,15 @@ Rubrica di Yahoo Murena comunicherà a Yahoo un modello di dispositivo fittizio per proteggere la tua privacy. \nÈ possibile verificare quale sia su Attività dispositivo di Yahoo dopo l\'accesso. - \ No newline at end of file + Gestione account + + Sistema predefinito + Luce + Buio + + Nome account già usato + + I gruppi sono vCards separate + I gruppi sono categorie per ogni contatto + + diff --git a/app/src/main/res/values-ja/strings.xml b/app/src/main/res/values-ja/strings.xml index a02c8e36287efa542eeddf61e906346d1118907c..c2e7244942a9d6f17272fc29122836bcf3ebe291 100644 --- a/app/src/main/res/values-ja/strings.xml +++ b/app/src/main/res/values-ja/strings.xml @@ -11,6 +11,7 @@ ヘルプ アカウントの管理 共有 + インターネット接続がありません。同期を待機しています データベースが破損しています すべてのアカウントがローカルから削除されました デバッグ中 @@ -25,7 +26,7 @@ タイムアウト、接続の問題など (多くの場合、一時的なもの) あなたのデータはあなたの手で - コントロールを始めよう + コントロールを始める 一定の間隔で同期 無効 (非推奨) 有効 (推奨) @@ -37,7 +38,7 @@ * 未チェックのままにすると後でリマインドします。アプリの設定 / %s でリセットできます 追加情報 jtx Board - + タスクの同期に対応 お使いのサーバーがタスクに対応している場合、対応するアプリで同期できます: OpenTasks @@ -51,42 +52,42 @@ 貢献/寄付の方法 しばらく非表示にする - 権限の許可 + 許可 %s が正しく動作するには権限を許可する必要があります 以下のすべて すべての機能を使用する (推奨) すべての権限が許可されました - 連絡先へのアクセス許可 + 連絡先へのアクセス 連絡先を同期しない (非推奨) 連絡先を同期できます - カレンダーへのアクセス許可 + カレンダーへのアクセス カレンダーを同期しない (非推奨) カレンダーを同期できます - 通知の許可 + 通知の権限 通知が無効です (非推奨) 通知が有効です - jtx Board の許可 - 日誌、メモ、タスクを同期しない (未インストール) - 日誌、メモ、タスクを同期しない - 日誌、メモ、タスクを同期できます - OpenTasks の許可 - Tasks の許可 + jtx Board へのアクセス + ジャーナル、メモ、タスクを同期しない (未インストール) + ジャーナル、メモ、タスクを同期しない + ジャーナル、メモ、タスクを同期できます + OpenTasks へのアクセス + Tasks へのアクセス タスクを同期しない (未インストール) タスクを同期しない タスクを同期できます - 継続の許可 + 権限を維持 権限が自動的にリセットされることがあります (非推奨) 権限は自動でリセットされません 許可から「アプリが使用されていない場合に権限を削除」を無効化してください スイッチが機能しない場合、アプリ情報 / 許可 にアクセスしてください アプリ設定 - WiFi SSID の許可 + WiFi SSID へのアクセス 現在の WiFi 名 (SSID) にアクセスするため、これらの条件を満たす必要があります: - 正確な位置情報の許可 - 位置情報の権限が許可されています + 正確な位置情報へのアクセス + 位置情報へのアクセスが許可されています 位置情報の権限が拒否されています - バックグラウンドでの位置情報の許可 + バックグラウンドでの位置情報へのアクセス 常に許可 %s に設定されています]]> %s に設定されていません]]> @@ -132,6 +133,8 @@ 接続を管理 残りのストレージ容量が少なくなっています。Android は同期を実行しません。 ストレージを管理 + データサーバーが有効です。バックグラウンド同期が制限されています + データサーバーを管理 システム全体の自動同期が無効です 有効 すべてのアカウントを同期 @@ -201,17 +204,18 @@ タスクを同期しません (権限なし) カレンダーとタスクを同期しません (権限なし) カレンダーにアクセスできません (権限なし) - 権限の許可 + 許可 アドレス帳は (まだ) ありません。 カレンダーは (まだ) ありません。 カレンダーのサブスクリプションは (まだ) ありません。 下にスワイプすると、サーバーからリストを更新します。 今すぐ同期 - 同期しています + コレクションを同期 アカウント設定 アカウントの名前を変更 未保存のローカルデータが破棄されることがあります。名前の変更後に再同期が必要です。新しいアカウント名: 名前を変更 + アカウント名はすでに取得されています アカウントの名前を変更できません アカウントを削除 本当にアカウントを削除しますか? @@ -220,7 +224,7 @@ 読み取り専用 カレンダー タスクリスト - 日誌 + ジャーナル プライベートのみ表示 アドレス帳リストを更新 新しいアドレス帳を作成 @@ -240,7 +244,6 @@ ユーザー名 ユーザー名が必要です ベース URL - URL とクライアント証明書でログイン 証明書を選択 ログイン アカウントを追加する @@ -256,6 +259,17 @@ クライアント証明書を使用 証明書が見つかりませんでした 証明書をインストール + Google コンタクト / カレンダー + 最新情報は \"Tested with Google\" のページをご確認ください。 + 未知のエラーに遭遇したり、クライアント ID を作成するように求められたりする場合があります。 + Google アカウント + Google でログイン + クライアント ID (オプション) + うまく機能しない場合、ユニークなクライアント ID が必要かもしれません。 + 詳しく知りたい! + プライバシーポリシー をご確認ください。]]> + Google API サービスのユーザーデータに関するポリシー に準拠しています。]]> + 認証コードを取得できませんでした 設定の検出 しばらくお待ちください。サーバーに問い合わせ中… CalDAV または CardDAV サービスが見つかりませんでした。 @@ -271,7 +285,6 @@ タスクの同期間隔 手動のみ - 2 分ごと 15 分ごと 30 分ごと 1 時間ごと @@ -289,8 +302,10 @@ 利用可能な WiFi ネットワークのカンマ区切りの名前 (SSID) (空白にするとすべて) WiFi SSID 制限にはさらに設定が必要です 管理 - 詳細情報 (FAQ) 認証 + 再認証 + もう一度 OAuth ログインを実行 + ユーザー名 ユーザー名 ユーザー名を入力: パスワード @@ -299,18 +314,18 @@ クライアント証明書の別名 証明書が選択されていません CalDAV - 過去イベントの時間限度 - すべてのイベントが同期されます + 過去の予定の読み込み制限 + すべての予定が同期されます - %d 日より前のイベントは無視されます + %d 日より前の予定は無視されます - この日数より過去のイベントは無視されます (0 も可)。すべてのイベントを同期させるには、空白のままにしてください。 + この日数より過去の予定は無視されます (0 も可)。すべての予定を同期するには、空白のままにしてください。 デフォルトのリマインダー - デフォルトのリマインダーはイベントの %d 分前です + デフォルトのリマインダーは予定の %d 分前です デフォルトのリマインダーはありません - デフォルトのリマインダーは、リマインダーのないイベントに適用されます。希望する分数を入力してください。デフォルトのリマインダーを無効にするには、空白のままにしてください。 + デフォルトのリマインダーは、リマインダーのない予定に適用されます。希望する分数を入力してください。デフォルトのリマインダーを無効にするには、空白のままにしてください。 カレンダーの色を管理 カレンダーの色は アカウントマネージャー が管理します カレンダーの色を アカウントマネージャー が設定しません @@ -323,17 +338,14 @@ グループを個別の vCard に分割 グループを連絡先ごとのカテゴリーとして記録 - グループ方法を変更 アドレス帳を作成 - アドレス帳 カレンダーを作成 タイムゾーン 可能なカレンダーエントリー - イベント + 予定 タスク - メモ / 日誌 - 組み合わせ (イベントとタスク) + メモ / ジャーナル コレクションの作成中 タイトル @@ -347,11 +359,11 @@ よろしいですか? このコレクション (%s) とそのすべてのデータが完全に削除されます。 これらのデータがサーバーから削除されます。 - コレクションの削除中 読み取り専用にする プロパティ + 最終同期: + 同期されていません アドレス (URL): - URL をコピー 所有者: デバッグ情報 @@ -374,7 +386,6 @@ 関係する問題 リモートリソース: ローカルリソース: - アプリで表示 ログ 詳細なログが利用できます ログを表示 @@ -407,7 +418,7 @@ WebDAV ファイルをダウンロードしています WebDAV ファイルをアップロードしています WebDAV マウント - + アカウントマネージャー アクセス許可 追加のアクセス許可が必要です %s が古すぎます @@ -416,10 +427,10 @@ ネットワークまたは I/O エラー – %s HTTP サーバーエラー – %s 内蔵ストレージエラー – %s - 再試行 + ソフトエラー (再試行回数の上限に到達) アイテムを表示 サーバーから無効な連絡先を受信しました - サーバーから無効なイベントを受信しました + サーバーから無効な予定を受信しました サーバーから無効なタスクを受信しました 1 または複数の無効なリソースを無視します diff --git a/app/src/main/res/values-ko/strings.xml b/app/src/main/res/values-ko/strings.xml index 6b2e04f2d02a8afe9fa2f80a5c2ba0aed041fd20..a76e34b3ead700ba433a06bec5e0cff9691a4110 100644 --- a/app/src/main/res/values-ko/strings.xml +++ b/app/src/main/res/values-ko/strings.xml @@ -198,11 +198,11 @@ 구독하는 캘린더가 없습니다. (yet) 서버에서 목록을 새로 고치려면 아래로 스와이프하세요. 동기화 - 동기화 중 계정 설정 계정 이름 바꾸기 저장되지 않은 로컬 데이터는 삭제될 수 있습니다. 이름을 변경한 후 다시 동기화해야 합니다. 새 계정 이름: 이름 바꾸기 + 계정 이름이 이미 사용되었습니다. 계정 이름을 바꿀 수 없습니다. 계정 삭제 정말 계정을 삭제하시겠습니까? @@ -231,7 +231,6 @@ 사용자 이름 사용자 이름이 필요합니다. 기본 URL - URL 및 클라이언트 인증서로 로그인 인증서 선택 로그인 계정 생성 @@ -279,7 +278,6 @@ 쉼표로 구분된 허용되는 WIFI 네트워크의 이름(모두 빈칸으로 두세요) WiFi SSID 제한에 추가 설정이 필요함 관리 - 추가 정보(FAQ) 인증 사용자 이름 사용자 이름 입력: @@ -313,17 +311,14 @@ 별도의 전자 명함으로 분류 연락처 별 항목으로 분류 - 분류 방식 변경 주소록 생성 - 내 주소록 캘린더 생성 시간대 가능한 캘린더 항목 이벤트 할일 목록 메모 및 저널 - 통합 (이벤트 및 작업) 색상 collection을 만드는 중 제목 @@ -337,11 +332,9 @@ 확실합니까? 이 collection(%s) 및 모든 데이터가 영구적으로 제거됩니다. 이러한 데이터는 서버에서 삭제되어야 한다. - collection 삭제중 읽기-전용으로 만들기 Properties 주소 (URL): - URL 복사 소유자: Debug info @@ -364,7 +357,6 @@ 관련된 문제 원격 리소스: 로컬 리소스: - 앱으로 보기 Logs 상세 logs를 사용할 수 있습니다. logs 보기 @@ -395,7 +387,7 @@ Downloading WebDAV file Uploading WebDAV file WebDAV mount - + DAVx⁵ 권한 추가 권한 필요 %s는 너무 오래되었습니다. @@ -404,7 +396,6 @@ 네트워크 혹은 I/O 에러 – %s HTTP 서버 오류 – %s Local storage 오류 – %s - 다시하기 항목 보기 서버로부터 잘못된 연락처를 받았습니다. 서버에서 잘못된 이벤트를 수신했습니다. diff --git a/app/src/main/res/values-nb-rNO/strings.xml b/app/src/main/res/values-nb-rNO/strings.xml index 13d325d134e7351d824ef3ba57334af9142dfa34..209a1644eb1d39a79f934ca309b496a82464ea95 100644 --- a/app/src/main/res/values-nb-rNO/strings.xml +++ b/app/src/main/res/values-nb-rNO/strings.xml @@ -35,7 +35,6 @@ Nettside Manuell O-S-S - Hjelp / Forum Doner Velkommen til Kontoadministrator.\n\nDu kan legge til en CalDAV/CardDAV-konto nå. Systemomspennende automatisk synkronisering avskrudd @@ -53,11 +52,6 @@ Logging er aktivert Logging er skrudd av Tilkobling - Overstyr mellomtjenerinnstillinger - Bruk egendefinerte mellomtjenerinnstillinger - Bruk systemets forvalgte mellomtjenerinnstillinger - Vertsnavn for HTTP-mellomtjener - HTTP-mellomtjeningsport Sikkerhet Fjern tiltro til systemsertifikater System og brukertillagte sertifikatsmyntigheter vil ikke bli tiltrodd @@ -79,7 +73,6 @@ Du abonnerer ikke på noen kalendere (enda). Dra ned for å oppdatere listen fra serveren. Synkroniser nå - Synkroniserer nå Kontoinnstillinger Gi konto nytt navn Ulagret lokal data kan bli avslått. Ny synkronisering kreves etter ny navngivning. Nytt kontonavn: @@ -131,7 +124,6 @@ Gjøremålssynkroniseringsintervall Bare manuelt - Hvert 2 minutter Hvert kvarter Hver halvtime Hver time diff --git a/app/src/main/res/values-nb/strings.xml b/app/src/main/res/values-nb/strings.xml index b2e0ae4a6c32411f0bfb0baf61f7623ceab52ee9..7600e73b5dbaf354339e84b3a9fc7eef6ceaf5ee 100644 --- a/app/src/main/res/values-nb/strings.xml +++ b/app/src/main/res/values-nb/strings.xml @@ -79,7 +79,6 @@ Du abonnerer ikke på noen kalendere (enda). Dra ned for å oppdatere listen fra serveren. Synkroniser nå - Synkroniserer nå Kontoinnstillinger Gi konto nytt navn Ulagret lokal data kan bli avslått. Ny synkronisering kreves etter ny navngivning. Nytt kontonavn: @@ -235,4 +234,5 @@ Konto existerer ikke (lengre) %s-kompabilitet Jeg trenger ikke periodiske synkroniseringsintervaller.* - \ No newline at end of file + Brukernavnet er allerede i bruk + diff --git a/app/src/main/res/values-nl/strings.xml b/app/src/main/res/values-nl/strings.xml index d6207d158bb19dfdaa808df19a87048c92e1e915..ca3019c3f678cbcdadb6e0459389a459855652b4 100644 --- a/app/src/main/res/values-nl/strings.xml +++ b/app/src/main/res/values-nl/strings.xml @@ -5,7 +5,6 @@ WebDav Adresboek Adresboeken Help - Verzenden Debuggen Andere belangrijke berichten Synchronisatie @@ -15,23 +14,6 @@ Niet-fatale synchronisatie problemen zoals bepaalde ongeldige bestanden Netwerk en I/O fouten Time-outs, connectie problemen, etc. (vaak tijdelijk) - - Automatische synchronisatie - %s firmware blokkeert vaak automatische synchronisatie. In dit geval, sta automatische synchronisatie toe in jouw Android instellingen. - Geplande synchronisatie - Jouw apparaat zal Account Manager synchronisatie beperken. Om voor Account Manager een regelmatig sync interval af te dwingen, schakel \"batterij optimalisatie\" uit. - Zet deze optie uit voor Account Manager - Niet opnieuw weergeven - Niet nu - Open source informatie - We zijn blij dat je Account Manager gebruikt, wat open source software (GPLv3) is. Omdat de ontwikkeling van Account Manager hard werk is en duizenden uren in beslag neemt, overweeg alstublieft een donatie. - Toon donatie pagina - Misschien later - Meer informatie - OpenTasks niet geinstalleerd - De gratis app OpenTasks is vereist om taken te kunnen synchroniseren. (Niet nodig voor contacten/evenementen) - Na installatie van OpenTasks dient u Account Manager opnieuw te installeren en de accounts toe te voegen (Android bug). - OpenTasks installeren Bibliotheken Versie %1$s (%2$d) @@ -40,9 +22,7 @@ Dit programma kom met ABSOLUUT GEEN GARANTIE. Het is gratis software, en je bent welkom dit te her-distribueren onder bepaalde voorwaarden. Kon de log file niet creëren - Account Manager log Op dit moment worden alle %s activiteiten gelogd - Verstuur het log Open navigatie menu Sluit navigatie menu @@ -55,7 +35,6 @@ Website Manueel FAQ - Hulp / Forums Doneren Welkom bij Account Manager! \n @@ -74,11 +53,6 @@ Loggen is actief Loggen is geactiveerd Verbinding - Proxy instellingen overschrijven - Eigen proxy instellingen gebruiken - Systeem proxy instellingen gebruiken - HTTP proxy beheerder naam - HTTP proxy poort Beveiliging Systeem certificaten niet vertrouwen Systeem en CA\'s van toegevoegde gebruiker wordt niet vertrouwd @@ -101,7 +75,6 @@ Er zijn (nog) geen kalender inschrijvingen. Swipe naar beneden om de lijst van servers te vernieuwen. Nu synchroniseren - Aan het synchroniseren Account instellingen Account hernoemen Niet opgeslagen lokale informatie mag verloren gaan. Synchronisatie is noodzakelijk na hernoemen. Nieuw account naam: @@ -128,15 +101,12 @@ Wachtwoord vereist Inloggen met URL en gebruikersnaam URL moet met http(s):// beginnen - URL moet beginnen met https:// - Host naam vereist Gebruikersnaam Gebruikersnaam vereist Basis URL Inloggen met URL en cliënt certificaat Selecteer certificaat Inloggen - Terug Account toevoegen Accountnaam Gebruik je email adres als account naam want Android zal je account naam gebruiken als ORGANIZER veld voor gemaakte afspraken. Je kunt geen 2 accounts met dezelfde naam hebben. @@ -173,7 +143,6 @@ Sync alleen over %s (vereist actieve locatie services) Alle WiFI verbindingen zullen worden gebruikt komma gescheiden namen (SSID\'s) van toegestane WiFi netwerken (leeg laten voor alles) - Om WIFI namen te lezen, zijn de locatie toestemming en permanent toegestane locatie services vereist. Meer informatie (Veel gestelde vragen) Authenticatie Gebruikersnaam @@ -204,7 +173,6 @@ Mijn adresboek Creëer kalender Tijdzone - Tijdzone benodigd Mogelijke kalender items Evenementen Taken @@ -234,12 +202,8 @@ Toon details Debug informatie - Logs worden toegevoegd aan dit bericht (vereist bijlage support van de ontvangende app). - Alleen-lezen adresboek Account Manager machtigingen Aanvullende machtigingen vereist - OpenTasks te oud - Vereiste versie: %1$s (momenteel %2$s) Authenticatie mislukt (controleer Inloggegevens) Netwerk of I/O fout – %s HTTP server fout – %s @@ -326,4 +290,209 @@ Yahoo adresboek Alle accounts zijn lokaal verwijderd. Regelmatige synchronisatie intervallen - \ No newline at end of file + Accounts beheren + Geen internet, planning de sync + Om op gezette tijden te synchroniseren moet %s zonder beperking op de achtergrond kunnen draaien. Anders kan Android het synchroniseren op elk moment onderbreken. + Synchroniseren op gezette tijden is niet nodig.* + %s compatibiliteit + Waarschijnlijk blokkeert dit toestel het synchroniseren. In dat geval is dit alleen handmatig op te lossen. + De vereiste instellingen zijn verricht. Er aan herinneren is niet meer nodig.* + * Niet aanvinken om later herinnerd te worden. Kan teruggezet in app instellingen / %s. + Meer informatie + jtx Board + + Ondersteunt taken + Als de server taken ondersteunt, synchroniseert een geschikte taken-app ze: + OpenTasks + Schijnt niet meer ontwikkeld te worden - niet aanbevolen. + Tasks.org + worden (nog) niet ondersteund.]]> + Geen app-store beschikbaar + Ik hoef geen ondersteuning van taken.* + Open-source software + We zijn blij dat de keuze valt op open source software %s. Ontwikkelen, onderhouden en ondersteunen is veel werk. Overweeg daarom bij te dragen (kan op vele manieren) of een donatie. Wij waarderen het zeer! + Hoe bijdragen/doneren + In de nabije toekomst niet weergeven + + Rechten toestaan + %s heeft rechten nodig om goed te werken. + Alle onderstaande + Gebruik dit om alle functies in te schakelen (aanbevolen) + Alle rechten toegekend + Contacten toestaan + Geen contacten synchroniseren (niet aanbevolen) + Contacten synchroniseren mogelijk + Kalender machtigingen + Geen kalenders synchroniseren (niet aanbevolen) + Kalenders synchroniseren mogelijk + Toestemming voor meldingen + Meldingen uitgeschakeld (niet aanbevolen) + Meldingen ingeschakeld + jtx Board-rechten + Geen taak-, kalender- en notitiesync (niet geïnstalleerd) + Geen taak-, kalender- en notitiesync + Taak-, kalender- en notitiesync mogelijk + OpenTasks rechten + Rechten voor taken + Geen taak-sync (niet geïnstalleerd) + Geen taak-sync + Taak-sync mogelijk + Rechten behouden + Rechten kunnen automatisch worden teruggezet (niet aanbevolen) + Rechten worden niet automatisch teruggezet + Klik op App Rechten > vinkje uit bij \"Rechten intrekken\" + Als een schakeloptie niet werkt, gebruik dan App-info / Rechten. + App instellingen + + WiFi SSID rechten + Voor toegang tot de huidige WiFi-naam (SSID), moet aan deze voorwaarden worden voldaan: + Recht van toegang tot exacte locatie + Toegang tot locatie verleend + Toegang tot locatie geweigerd + Toegang tot locatie op de achtergrond + Onbeperkt toestaan + %s]]> + %s]]> + %sgebruikt het locatie recht alleen om de huidige WiFi-SSID voor SSID-beperkte accounts te bepalen. Dit gebeurt zelfs als de app zich op de achtergrond bevindt. Locatiegegevens worden niet verzameld, opgeslagen, verwerkt of verzonden. + Toegang tot locatie altijd ingeschakeld + Toegang tot locatie is ingeschakeld + Toegang tot locatie is uitgeschakeld + + Vertalingen + © Ricki Hirner, Bernhard Stockmann (bitfire web engineering GmbH) en bijdragers + Dank aan: %s]]> + + Bekijken/delen + Uitschakelen + + Gereedschap + Community + Meldingen uitgeschakeld. U krijgt geen meldingen over synchronisatiefouten. + Verbindingen beheren + Er is te weinig opslagruimte. Android zal niet synchroniseren. + Opslag beheren + Gegevensbesparing ingeschakeld. Synchronisatie op de achtergrond is beperkt. + Beheer van gegevensbesparing + Alle accounts synchroniseren + + Service herkenning is mislukt + De collectielijst is niet bijgewerkt + + Kan niet op de voorgrond draaien + Toestemming tot onbeperkt batterijgebruik is vereist + + Draait op de voorgrond + Op sommige toestellen is dit nodig voor automatische synchronisatie. + + Batterijoptimalisatie + Onbeperkt batterijgebruik toestaan (aanbevolen) + Beperkt batterijgebruik toestaan (niet aanbevolen) + Op de voorgrond houden + Kan helpen als automatische synchronisatie niet plaatsvindt + Proxy type + + Systeem standaard + Geen proxy + HTTP + SOCKS (voor Orbot) + + Proxy host naam + Proxy poort + App rechten + De vereiste rechten om te synchroniseren controleren + Thema selecteren + + Systeem standaard + Licht + Donker + + Integratie + Taken app + Synchroniseren met %s + Geen compatibele taken app gevonden + + Geen contacten synchronisatie (ontbrekende rechten) + Geen kalender synchronisatie (ontbrekende rechten) + Geen taken synchronisatie (ontbrekende rechten) + Geen synchronisatie van kalender en taken (ontbrekende rechten) + Geen toegang tot kalenders (ontbrekende rechten) + Rechten + Accountnaam is al in gebruik + Naam account is niet gewijzigd + logboek + Alleen persoonlijk tonen + + Het gebruik van apostrofs (\') heeft op sommige apparaten problemen veroorzaakt. + Geavanceerde login (speciale gevallen) + Gebruikersnaam/wachtwoord gebruiken + Cliëntcertificaat gebruiken + Geen certificaat gevonden + Certificaat installeren + Google Contacten / Kalender + Google account + Is gebruikersnaam (e-mailadres) / wachtwoord verkeerd? + + Beperking WiFi-SSID vereist verdere instellingen + Beheren + Geen certificaat geselecteerd + + Groepen zijn afzonderlijke vCards + Groepen zijn categorieën per contact + + + Opslaglocatie is verplicht + Laatst gesynchroniseerd: + Nooit gesynchroniseerd: + Eigenaar: + + ZIP archief + Bevat debuginformatie en logbestanden + Deel het archief om over te zetten naar een computer, per e-mail te verzenden of als bijlage bij een supportticket te voegen.. + Archief delen + Debug info als bijlage bij dit bericht (vereist ondersteuning voor bijlagen van de ontvangende app). + HTTP-fout + Serverfout + WebDAV fout + I/O-fout + Het verzoek is afgewezen. Controleer de betrokken bronnen en debug-info voor details. + De gevraagde bron bestaat niet (meer). Controleer de betrokken bronnen en debug-info voor details. + Er is bij de server een probleem opgetreden. Neem contact op met de server-ondersteuning. + Er is een onverwachte fout opgetreden. Bekijk debug-info voor details. + Details bekijken + Debug-info is verzameld + Betrokken bronnen + Gerelateerd aan het probleem + Externe bron: + Lokale bron: + Logboeken + Uitgebreide logboeken zijn beschikbaar + Details bekijken + + Synchroniseren is onderbroken + Bijna geen vrije ruimte meer + + WebDAV-koppelingen + Quotum gebruikt: %1$s / Beschikbaar: %2$s + Inhoud delen + Ontkoppelen + WebDAV-koppeling toevoegen + Verkrijg directe toegang tot cloudbestanden met een WebDAV-koppeling! + het koppelen van WebDAV.]]> + Weergavenaam + WebDAV-URL + Ongeldige URL + Gebruikersnaam + Wachtwoord + Koppeling toevoegen + Geen WebDAV-service op deze URL + Verwijder het koppelpunt + Verbindingsgegevens gaan verloren, maar er worden geen bestanden gewist. + WebDAV-bestand openen + WebDAV-bestand downloaden + WebDAV-bestand uploaden + WebDAV-koppeling + + %ste oud + Minimaal vereiste versie: %1$s + Soft error (max. aantal pogingen bereikt) + diff --git a/app/src/main/res/values-pl/strings.xml b/app/src/main/res/values-pl/strings.xml index f524b481d5e58aec394254d85267d976d528a33f..7e39bd22549e7ab024c6b9afad247e6cea07d8fd 100644 --- a/app/src/main/res/values-pl/strings.xml +++ b/app/src/main/res/values-pl/strings.xml @@ -10,6 +10,7 @@ To pole jest wymagane Pomoc Udostępnij + Brak internetu, planowanie synchronizacji Uszkodzona baza danych Wszystkie konta zostały usunięte lokalnie. Debugowanie @@ -208,11 +209,12 @@ Nie ma (jeszcze) subskrypcji kalendarzy. Przesuń palcem w dół, aby odświeżyć listę z serwera. Synchronizuj teraz - Trwa synchronizacja + Synchronizuj kolekcje Ustawienia konta Zmień nazwę konta Niezapisane dane lokalne mogą zostać usunięte. Po zmianie nazwy jest wymagana ponowna synchronizacja. Nowa nazwa konta: Zmień nazwę + Nazwa konta jest już zajęta Nie udało się zmienić nazwy konta Usuń konto Naprawdę chcesz usunąć konto? @@ -241,7 +243,6 @@ Nazwa użytkownika Wymagana nazwa użytkownika Podstawowy adres URL - Logowanie za pomocą adresu URL i certyfikatu klienta Wybierz certyfikat Zaloguj Dodaj konto @@ -257,6 +258,14 @@ Użyj certyfikatu klienta Nie znaleziono certyfikatu Zainstaluj certyfikat + Kontakty Google / Kalendarz + Aktualne informacje można znaleźć na naszej stronie \"Testowano z Google\". + Mogą wystąpić nieoczekiwane ostrzeżenia i/lub konieczność utworzenia własnego identyfikatora klienta. + Konto Google + ID klienta (opcjonalnie) + Polityce prywatności.]]> + Zasadami dotyczącymi danych użytkownika usług interfejsu API Google, w tym wymaganiami dotyczącymi Ograniczonego użytkowania.]]> + Nie udało się uzyskać kodu autoryzacyjnego Wykrywanie konfiguracji Proszę czekać, odpytywanie serwera… Nie można znaleźć usługi CalDAV lub CardDAV. @@ -272,7 +281,6 @@ Częstotliwość synchronizacji list zadań Tylko ręcznie - Co 2 minuty Co 15 minut Co 30 minut Co godzinę @@ -290,8 +298,10 @@ Oddzielone przecinkami nazwy (SSID) dozwolonych sieci Wi‑Fi (pozostaw puste dla wszystkich) Ograniczenie WiFi SSID wymaga dalszych ustawień Zarządzaj - Więcej informacji (Najczęściej zadawane pytania) Uwierzytelnianie + Uwierzytelnij ponownie + Wykonaj ponownie logowanie OAuth + nazwa użytkownika Nazwa użytkownika Wpisz nazwę użytkownika: Hasło @@ -330,17 +340,14 @@ Grupy są odrębnymi vCards Grupy są kategoriami dla pojedynczego kontaktu - Zmień metodę grupowania Stwórz książkę adresową - Moja książka adresowa Utwórz kalendarz Strefa czasowa Możliwe wpisy kalendarza Wydarzenia Zadania Notatki/dziennik - Połączone (wydarzenia i zadania) Kolor Tworzenie kolekcji Tytuł @@ -354,11 +361,11 @@ Czy jesteś pewien? Ta kolekcja (%s) i wszystkie jej dane zostaną trwale usunięte. Te dane zostaną usunięte z serwera. - Usuwanie kolekcji Wymuś tylko do odczytu Właściwości + Ostatnia synchronizacja: + Nigdy nie synchronizowane Adres (URL): - Kopiuj adres URL Właściciel: Informacje debugowania @@ -381,7 +388,6 @@ Powiązane z problemem Zasób zdalny: Zasób lokalny: - Przeglądaj z aplikacjią Logi Szczegółowe logi są dostępne Otwórz logi @@ -414,7 +420,7 @@ Pobieranie pliku WebDAV Wgrywanie pliku WebDAV Punkt linkowania WebDAV - + Uprawnienia Menadżer konta Wymagane dodatkowe uprawnienia %s zbyt stary/a @@ -423,7 +429,7 @@ Błąd sieci lub we/wy – %s Błąd serwera HTTP — %s Błąd lokalnego storage’u — %s - Ponów + Błąd programowy (osiągnięto maksymalną liczbę ponownych prób) Zobacz element Otrzymano błędny kontakt z serwera Otrzymano błędne wydarzenie z serwera diff --git a/app/src/main/res/values-pt-rBR/strings.xml b/app/src/main/res/values-pt-rBR/strings.xml index 712a1b25dfe960f593c9f40b22209f743d7e24c6..c99be9a7866c2dbd5f540707e2ea01d77a468390 100644 --- a/app/src/main/res/values-pt-rBR/strings.xml +++ b/app/src/main/res/values-pt-rBR/strings.xml @@ -5,7 +5,6 @@ Livro de endereços Gerente de contas Livros de endereços Ajuda - Enviar Depuração Outras mensagens importantes Sincronização @@ -15,23 +14,6 @@ Problemas de sincronização não graves, como determinados arquivos inválidos Erros de rede e E/S Tempos de espera, problemas de conexão, etc. (geralmente temporários) - - Sincronização automática - O firmware%s frequentemente bloqueia a sincronização automática. Nesse caso, ative a sincronização automática nas configurações do seu Android. - Sincronização agendada - Seu aparelho irá restringir a sincronização do Gerente de contas. Para forçar a sincronização do Gerente de contas em intervalos regulares, desligue a \"otimização da bateria\". - Desligar para o Gerente de contas - Não mostrar novamente - Não agora - Informação sobre Código Aberto - Estamos felizes que você usa o Gerente de contas, um software de código aberto (GPLv3). O desenvolvimento do Gerente de contas é trabalhoso e consome muitas horas de trabalho. Por esse motivo, considere fazer uma doação. - Mostrar a página de doações - Talvez depois - Mais informações - O OpenTasks não está instalado - Para sincronizar tarefas é necessário instalar o aplicativo livre OpenTasks. (Não é necessário para contatos/eventos) - Depois da instalação do OpenTasks, torna-se necessário REINSTALAR o Gerente de contas e adicionar suas contas novamente (erro do Android). - Instalar o OpenTasks Bibliotecas Versão %1$s (%2$d) @@ -40,8 +22,6 @@ Este programa é distribuído SEM NENHUMA GARANTIA. Ele é software livre e pode ser redistribuído sob algumas condições. Não foi possível criar o arquivo de log - Log do Gerente de contas - Enviar log Abrir a gaveta de navegação Fechar gaveta de navegação @@ -56,7 +36,6 @@ Site na Web Manual Perguntas fequentes - Ajuda / Fóruns Doações Política de privacidade Sem conexão com a Internet. O Android não executará a sincronização. @@ -75,11 +54,6 @@ Registro de atividades ativo Registro de atividades desativado Conexão - Substituir as configurações de proxy - Usar configurações de proxy personalizadas - Usar configurações de proxy padrão do sistema - Nome do servidor proxy HTTP - Porta do proxy HTTP Segurança Desconfiar dos certificados de sistema ACs adicionadas pelo usuário e pelo sistema não serão confiáveis @@ -102,7 +76,6 @@ Não há assinaturas de calendário (ainda). Deslize para baixo para atualizar a lista do servidor. Sincronizar agora - Sincronizando Configurações da conta Renomear conta Dados locais que não foram salvos podem ser descartados. É necessário efetuar uma nova sincronização após renomear. Novo nome da conta: @@ -129,15 +102,12 @@ É necessário uma senha Autenticação com usuário e URL A URL deve começar com http(s):// - A URL deve começar com https:// - É necessário um nome de máquina Usuário É necessário um nome de usuário URL base Autenticação com URL e certificado do cliente Selecionar certificado Autenticar - Voltar Adicionar conta Nome da conta Use seu endereço de e-mail como nome da conta porque o Android irá usar esse nome como campo AGENDA nos eventos que você criar. Não é possível ter duas contas com o mesmo nome. @@ -159,7 +129,6 @@ Intervalo sinc. de tarefas Apenas manualmente - A cada 2 minutos A cada 15 minutos A cada 30 minutos A cada hora @@ -175,7 +144,6 @@ Só será sincronizado %s (requer serviços de localização ativos) Todas as conexões WiFi serão usadas Nomes separados por vírgula (SSIDs) das redes WiFi (deixe em branco para todas) - Para ler nomes de Wi-Fi, a permissão de localização e os serviços de localização permanentemente ativados são obrigatórios. Mais informações Autenticação Nome do usuário @@ -217,7 +185,6 @@ Meu livro de endereços Criar calendário Fuso horário - Fuso horário necessário Possíveis itens de calendário Eventos Tarefas @@ -247,12 +214,8 @@ Mostrar detalhes Informações de depuração - Os registros são anexados a esta mensagem (requer que o aplicativo de recebimento tenha suporte a anexos). - Livro de endereços somente leitura Permissões do Gerente de contas É necessário permissões adicionais - A versão do OpenTasks é muito antiga - Versão necessária: %1$s (atual %2$s) Falha de autenticação (verifique as credenciais) Erro de rede ou E/S – %s Erro no servidor HTTP – %s diff --git a/app/src/main/res/values-pt/strings.xml b/app/src/main/res/values-pt/strings.xml index c466df667c65f7a043909b7eb6ceafba3e00e5cf..cb6e8bfbd702cfee8eed0ea0b74976c65ece6a71 100644 --- a/app/src/main/res/values-pt/strings.xml +++ b/app/src/main/res/values-pt/strings.xml @@ -144,11 +144,11 @@ Não há assinaturas de calendário (ainda). Deslize para baixo para atualizar a lista do servidor. Sincronizar agora - Sincronizando Configurações da conta Renomear conta Dados locais que não foram salvos podem ser descartados. É necessário efetuar uma nova sincronização após renomear. Novo nome da conta: Renomear + O nome da conta já foi utilizado Não foi possível renomear a conta Excluir conta Deseja excluir a conta? @@ -175,7 +175,6 @@ Usuário É necessário um nome de usuário URL base - Autenticação com URL e certificado do cliente Selecionar certificado Autenticar Adicionar Conta @@ -216,7 +215,6 @@ Só será sincronizado %s (requer serviços de localização ativos) Todas as conexões WiFi serão usadas Nomes separados por vírgula (SSIDs) das redes WiFi (deixe em branco para todas) - Mais informações Autenticação Nome do usuário Digite o nome do usuário: @@ -250,17 +248,14 @@ Grupos são vCards separados Grupos são categorias por contato - Alterar a forma de agrupamento Criar livro de endereços - Meu livro de endereços Criar calendário Fuso horário Possíveis itens de calendário Eventos Tarefas Notas / Diário - Combinado (eventos e tarefas) Cor Criando a coleção Título @@ -273,11 +268,9 @@ Tem certeza? Esta coleção (%s) e todos os dados dela serão removidos permanentemente. Estes dados devem ser excluídos do servidor. - Excluindo coleção Forçar somente leitura Propriedades Endereço (URL): - Copiar URL Proprietário: Informações de depuração @@ -298,7 +291,6 @@ Relacionado ao problema Recurso remoto: Recurso local: - Exibir com aplicativo Registros Registros descritivos disponíveis @@ -311,7 +303,7 @@ Partilhar conteúdo Nome do usuário Palavra passe - + Permissões do DAVx⁵ É necessário permissões adicionais Versão mínima exigida: %1$s @@ -319,7 +311,6 @@ Erro de rede ou E/S – %s Erro no servidor HTTP – %s Erro de armazenamento local – %s - Repetir Ver item Contato inválido recebido do servidor Evento inválido recebido do servidor diff --git a/app/src/main/res/values-ru/strings.xml b/app/src/main/res/values-ru/strings.xml index ff44437a469fbe85e567d3cdec527b037a89c4b9..8380c5851ad9b58210e0d838977c615e75670feb 100644 --- a/app/src/main/res/values-ru/strings.xml +++ b/app/src/main/res/values-ru/strings.xml @@ -7,7 +7,6 @@ Удалить Отмена Помощь - Отправить Отладка Другие важные сообщения Синхронизация @@ -17,23 +16,6 @@ Некритичные проблемы с синхронизацией, такие как некоторые неверные файлы Ошибки сети и ввода/вывода Таймауты, проблемы с подключением и т. д. (часто временные) - - Автоматическая синхронизация - %s ПО устройства часто блокирует автоматическую синхронизацию. В этом случае разрешите автоматическую синхронизацию в настройках Android. - Синхронизация по расписанию - Ваше устройство будет блокировать синхронизацию менеджера учётных записей. Чтобы обеспечить регулярные интервалы синхронизации менеджера учётных записей, отключите оптимизацию энергопотребления. - Отключить для менеджера учётных записей - Не показывать снова - Не сейчас - Open-Source информация - Мы гордимся, что вы используете менеджер учётных записей - приложение с открытым исходным кодом. Его разработка и поддержка отнимает очень много сил и времени. Мы будем счастливы, если вы поддержите наш проект. - Поддержать проект - Возможно, позже - Дополнительная информация - OpenTasks не установлен - Для синхронизации задач требуется бесплатное приложение OpenTasks. (Не требуется для контактов/событий.) - После установки OpenTasks необходимо ПЕРЕУСТАНОВИТЬ менеджер учётных записей и повторно добавить ваши аккаунты (проблема Android). - Установить OpenTasks Библиотеки Версия %1$s (%2$d) @@ -42,8 +24,6 @@ Эта программа поставляется БЕЗ КАКИХ-ЛИБО ГАРАНТИЙ. Это свободное программное обеспечение и вы можете распространять его при соблюдении определенных условий. Не удалось создать файл журнала - Журналирование менеджера учетных записей - Отправить журнал Открыть панель навигации Закрыть панель навигации @@ -58,7 +38,6 @@ Веб-сайт Руководство FAQ - Помощь / Форумы Пожертвовать Политика конфиденциальности Нет подключения к интернету. Android не будет запускать синхронизацию. @@ -79,11 +58,6 @@ Журналирование активно Журналирование отключено Подключение - Переопределить настройки прокси - Использовать пользовательские настройки прокси - Использовать системные настройки прокси - Имя хоста HTTP прокси - Порт HTTP прокси Безопасность Доверие системным сертификатам Не доверять системным и пользовательским CA @@ -106,7 +80,6 @@ Нет подписок на календарь (пока). Проведите вниз, чтобы обновить список с сервера. Синхронизировать - Синхронизация Настройки учётной записи Переименовать учётную запись Несохраненные локальные данные могут быть потеряны. Необходима повторная синхронизация после переименования. Новое имя учётной записи: @@ -133,15 +106,12 @@ Требуется пароль Войти по URL-адресу и имени пользователя URL должен начинаться с http(s):// - URL-адрес должен начинаться с https:// - Требуется имя хоста Имя пользователя Требуется Имя пользователя Базовый URL Войти по URL-адресу и клиентскому сертификату Выберите сертификат Войти - Назад Добавить аккаунт Имя аккаунта Используйте ваш адрес электронной почты в качестве имени учётной записи, поскольку Android будет использовать имя учётной записи в поле ORGANIZER для событий, которые вы создаете. У вас не может быть двух учётных записей с тем же именем. @@ -163,7 +133,6 @@ Интервал синхронизации задач Только вручную - Каждые 2 минуты Каждые 15 минут Каждые 30 минут Каждый час @@ -179,7 +148,6 @@ Будет синхронизироваться только более %s (требуются активированные службы определения местоположения) Будут использоваться все WiFi-соединения Названия (SSID), разделенные запятыми разрешенных WiFi-сетей(оставьте пустым для всех) - Для чтения имен WiFi требуются разрешение на геолокацию и постоянно активированные службы определения местоположения. Дополнительная информация (FAQ) Аутентификация Имя пользователя @@ -223,7 +191,6 @@ Моя адресная книга Создать календарь Часовой пояс - Требуется часовой пояс Возможные записи календаря События Задачи @@ -253,12 +220,8 @@ Показать детали Отладочная информация - К этому сообщению будут добавлены журналы (требуется поддержка вложений принимающего приложения). - Адресная книга только для чтения Разрешения менеджера учётных записей Требуются дополнительные разрешения - OpenTasks устарел - Требуется версия: %1$s (текущая %2$s) Ошибка аутентификации (проверьте учетные данные) Ошибка сети или ввода/вывода – %s Ошибка HTTP-сервера – %s @@ -419,4 +382,121 @@ © Рикки Хирнер, Бернхард Стокманн (bitfire web engineering GmbH) и авторы статьи Переводы Служба определения местоположения отключена - \ No newline at end of file + Управление аккаунтами + Нет интернета, синхронизация по расписанию + Может помочь, если ваше устройство препятствует автоматической синхронизации + Тип прокси + + Определен системой + Без прокси + HTTP + SOCKS (для Orbot) + + Имя хоста прокси + Порт прокси + Разрешения приложения + Проверка разрешений, необходимых для синхронизации + Выбор темы + + Определена системой + Светлая + Темная + + Интеграция + Приложение Tasks + Синхронизация с %s + Не найдено совместимое приложение для задач + + Контакты не синхронизируются (отсутствуют разрешения) + Календарь не синхронизируется (отсутствуют разрешения) + Задачи не синхронизируются (отсутствуют разрешения) + Календарь и задачи не синхронизируются (отсутствуют разрешения) + Нет доступа к календарям (отсутствуют разрешения) + Разрешения + Синхронизировать коллекции + Название аккаунта уже используется + Не удалось переименовать аккаунт + журнал + Показать только личные + + По имеющимся данным, использование апострофов (\') вызывает проблемы на некоторых устройствах. + Расширенная авторизация (особые случаи использования) + Использовать имя пользователя/пароль + Использовать сертификат клиента + Сертификат не найден + Установить сертификат + Google Контакты / Календарь + Актуальную информацию смотрите на нашей странице \" Протестировано с Google\". + Вы можете столкнуться с неожиданными предупреждениями и/или вам придется создать свой собственный ID клиента. + Google аккаунт + Войти через Google + ID клиента (необязательно) + Вы можете использовать свой собственный ID клиента, если наш не работает. + Покажите мне как! + Политику конфиденциальности.]]> + Политику в отношении пользовательских данных Google API Services, включая требования Ограниченного использования.]]> + Не удалось получить код авторизации + Имя пользователя (адрес email) / пароль неверны? + + Ограничение WiFi SSID требует дополнительных настроек + Управлять + Повторная аутентификация + Выполните вход через OAuth повторно + имя пользователя + Сертификат не выбран + Место хранения обязательно + Последняя синхронизация: + Никогда не синхронизировалось + Владелец: + + ZIP-архив + Содержит отладочную информацию и логи + Поделитесь архивом, чтобы перенести его на компьютер, отправить по электронной почте или прикрепить к запросу в службу поддержки. + Поделиться архивом + Отладочная информация, прикреплена к данному сообщению (требует поддержки вложений со стороны принимающего приложения). + Ошибка HTTP + Ошибка сервера + Ошибка WebDAV + Ошибка ввода/вывода + Запрос был отклонен. Для получения подробной информации проверьте задействованные ресурсы и отладочную информацию. + Запрошенного ресурса не существует (больше не существует). Проверьте задействованные ресурсы и отладочную информацию для получения подробной информации. + Возникла проблема на стороне сервера. Пожалуйста, свяжитесь со службой поддержки вашего сервера. + Произошла неожиданная ошибка. Просмотрите отладочную информацию для получения подробностей. + Просмотреть информацию + Собрана отладочная информация + Вовлеченные ресурсы + Связанная с этим проблема + Удаленный ресурс: + Локальный ресурс: + Логи + Доступны подробные логи + Просмотр логов + + Синхронизация приостановлена + Почти не осталось свободного места + + Точки монтирования WebDAV + Использованная квота: %1$s / доступно: %2$s + Поделиться контентом + Отмонтировать + Добавление точки монтирования WebDAV + Прямой доступ к вашим облачным файлам с помощью точки монтирования WebDAV! + как работают точки монтирования WebDAV.]]> + Отображаемое имя + WebDAV URL + Некорректный URL + Имя пользователя + Пароль + Добавить точку монтирования + Служба WebDAV отсутствует на данном URL + Удалить точку монтирования + Информация о подключении будет потеряна, но никакие файлы не будут удалены. + Доступ к файлу WebDAV + Загрузка файла WebDAV + Выгрузка файла WebDAV + Точка монтирования WebDAV + + Приложение %s устарело + Минимально необходимая версия: %1$s + Ошибка (достигнуто максимальное количество повторных попыток) + diff --git a/app/src/main/res/values-sk/strings.xml b/app/src/main/res/values-sk/strings.xml index ab53d698ffb32543dccb0fd6b76a071fcc9f5314..b0048b6036e1bacb1899542680a74a760298fb97 100644 --- a/app/src/main/res/values-sk/strings.xml +++ b/app/src/main/res/values-sk/strings.xml @@ -82,11 +82,11 @@ Neexistujú žiadne odbery pre kalendár (zatiaľ) Potiahnite nadol aby ste znovu načítali zoznam zo serveru Teraz synchronizovať - Práve sa synchronizuje Nastavenia používateľského účtu Premenovať používateľský účet Neuložené miestne dáta môžu byť zabudnuté. Po premenovaní je potrebná opätovná synchronizácia. Nové meno účtu: Premenovať + Meno účtu sa už používa Odstrániť účet Skutočne si želáte odstrániť účet? Všetky miestne kópie adresárov, kalendárov a zoznamov úloh budú vymazané. @@ -112,7 +112,6 @@ Používateľské meno Vyžaduje sa používateľské meno Základné URL - Prihlásiť sa s použitím URL a klientského certifikátu Zvoliť certifikát Prihlásiť sa Vytvoriť používateľský účet @@ -151,7 +150,6 @@ Synchronizuje sa iba cez %s (miestne lokalizačné služby musia byť aktívne) Použije sa akékoľvek WiFi pripojenie Čiarkou oddelený zoznam mien (SSID) povolených WiFi sietí (ponechať prázdne pre všetky) - Viac informácií (FAQ) Overenie Meno používateľa Zadajte meno používateľa: @@ -186,17 +184,14 @@ Skupiny sú osobitné vKarty Skupiny sú kategórie na kontakt - Zmena spôsobu práce so skupinami Vytvoriť adresár - Môj adresár Vytvoriť kalendár Časová zóna Možné kalendárové položky Udalosti Úlohy Poznámky / denník - Kombinované (udalosti a úlohy) Farba Vytvára sa kolekcia Názov @@ -209,11 +204,9 @@ Ste si istý? Kolekcia (%s) a všetky jej údaje budú natrvalo zmazané. Tieto data budú zmazané zo servera. - Maže sa kolekcia Vyžiadať len na čítanie Vlastnosti Adresa (URL): - Kopírovať URL Ladiace informácie @@ -225,14 +218,13 @@ Meno používateľa Heslo - + Oprávnenia DAVx⁵ Vyžadujú sa dodatočné oprávnenia Overenie zlyhalo (skontroluje prihlasovacie údaje) Sieťová alebo V/V chyba – %s Chyba HTTP servera – %s Chyba miestneho úložiska – %s - Opakovať Zobraziť položku Kontakt prijatý zo servera je neplatný Udalosť prijatá zo servera nie je platná diff --git a/app/src/main/res/values-sl-rSI/strings.xml b/app/src/main/res/values-sl-rSI/strings.xml index aa4f427ebfa528b4405e1c7a0d5d3c98bc017466..3947cfd805e74b2d785604a7d34519bf956d1636 100644 --- a/app/src/main/res/values-sl-rSI/strings.xml +++ b/app/src/main/res/values-sl-rSI/strings.xml @@ -37,7 +37,6 @@ Spletna stran Priročnik Pogosta vprašanja - Pomoč / forumi Prispevaj Dobrodošli v DAVx⁵!\n\nZdaj lahko dodate CalDAV/CardDAV račun. Sistemska avtomatska sinhronizacija je izklopljena @@ -55,11 +54,6 @@ Zapisovanje je aktivno Zapisovanje je onemogočeno Povezava - Ignoriraj proxy nastavitve - Uporabi poljubne proxy nastavitve - Uporabi privzete sistemske proxy nastavitve - HTTP proxy strežniško ime - HTTP proxy vhod Varnost Nezaupaj sistemskim cerfitikatom Sistemski in od uporabnika dodani certifikati ne bodo zaupani @@ -82,7 +76,6 @@ Trenutno ni nobene koledar naročnine (še). Potegni navzdol za osvežitev strežniškega seznama. Sinhroniziraj zdaj - Sinhronizacija v teku Nastavitve računa Preimenuj račun Neshranjeni lokalni podatki bodo izbrisani. Ponovna sinhronizacija je potrebna po preimenovanju. Novo ime računa: diff --git a/app/src/main/res/values-sl/strings.xml b/app/src/main/res/values-sl/strings.xml index 2bf9153fffc1f26e052060409e50a21fc1b1f79e..7cbb33064d1775e70061a9716eea92b9f2a29403 100644 --- a/app/src/main/res/values-sl/strings.xml +++ b/app/src/main/res/values-sl/strings.xml @@ -2,22 +2,55 @@ DAVx⁵ + Račun ne obstaja (več) DAVx⁵ imenik Seznami s stiki + Odstrani + Preklic + To polje je obvezno Pomoč Urejanje prijav + Deli + Okvarjena baza podatkov + Vsi računi so bili lokalno odstranjeni. Razhroščevalnik Ostale pomembne nastavitve + Statusno sporočilo nizke prioritete Sinhronizacija Napake v sinhronizaciji - Pomembne napake, ki zaustavijo sinhronizacijo(npr. nepričakovani odgovori strežnika) + Pomembne napake, ki zaustavijo sinhronizacijo (npr. nepričakovani odgovori strežnika) Opozorila med sinhronizacijo Neusodni problemi v sinhronizaciji npr. določene neveljavne datoteke Omrežje in I/O napake Pavze, povezave v povezavi z omrežjem, itd. (ponavadi začasno) + Tvoji podatki. Tvoja izbira. + Prevzemi nadzor + Onemogočeno (nepriporočljivo) + Omogočeno (priporočljivo) + Ne potrebujem enakomernih intervalov sinhroniziranja + %szdružljivost Več informacij + Tasks.org + Nobene trgovine z aplikacijami ni na voljo + Odprtokodna programska oprema + Kako prispevati ali donirati + Ne prikaži v bližnji prihodnosti + Dovoljenja + Vse spodaj + Uporabi to da omogočiš vse funkcije (priporočeno) + Vsa dovoljenja odobrena + Dovoljenje za dostop do imenika + Ne sinhroniziraj imenika (ni priporočeno) + Možnost sinhronizacije imenika + Dovoljenje za dostop do koledarja + Ne sihroniziraj koledarja (ni priporočeno) + Možnost sinhronizacije koledarja + Dovoljenje za prikaz obvestil + Obvestila onemogočena (ni priporočeno) + Obvestila omogočena + jtx Board dovoljenja Knjižnice @@ -74,16 +107,17 @@ CardDAV CalDAV Webcal + Dovoljenja Trenutno ni nobenega imenika (še). Trenutno ni nobenega koledarja (še). Trenutno ni nobene koledar naročnine (še). Potegni navzdol za osvežitev strežniškega seznama. Sinhroniziraj zdaj - Sinhronizacija v teku Nastavitve računa Preimenuj račun Neshranjeni lokalni podatki bodo izbrisani. Ponovna sinhronizacija je potrebna po preimenovanju. Novo ime računa: Preimenuj + Ima računa že obstaja Izbriši račun Ali res želite izbrisati račun? Vse lokalne kopije imenika, koledarjev in seznamov opravil bodo izbrisane. @@ -91,6 +125,8 @@ samo za branje koledar seznam opravil + dnevnik + Pokaži samo osebno Osveži seznam imenikov Ustvari nov imenik Osveži seznam koledarjev @@ -109,7 +145,6 @@ Uporabniško ime Zahtevano je uporabniško ime URL osnova - Prijava z URL in certifikatom uporabnika Izberi certifikat Prijava Ustvari račun @@ -148,7 +183,6 @@ Bo sinhroniziralo samo preko %s (potrebuje aktivirano določanje lokacije) Vse WiFi povezave bodo uporabljene Z vejico ločena imena (SSID) dovoljenih WiFi omrežij (pusti prazno za vse) - Več informacij (pogosta vprašanja) Avtentikacija Uporabniško ime Vnesi uporabniško ime: @@ -170,17 +204,14 @@ Podpora barva dogodka CardDAV Metoda skupine kontaktov - Spremeni metodo skupine Ustvari imenik - Moj imenik Ustvari koledar Časovna zona Mogoči koledarski vnosi Dogodki Naloge Beležnice / dnevnik - Združeno ( dogodki in naloge) Barva Ustvarjenje zbirke Naslov @@ -193,11 +224,9 @@ Si prepričan? Ta zbirka (%s) in njena vsebina bodo za stalno izbrisane. Ti podatki bodo izbrisani iz strežnika - Izbris zbirke Prisili samo za branje Lastnosti Naslov (URL): - Kopiraj URL Informacije razhroščevalnika @@ -209,14 +238,13 @@ Uporabniško ime Geslo - + DAVx⁵ dovoljenja Dodatna dovoljenja so zahtevana Avtentikacija ni uspela (preverite podatke prijave) Omrežna ali I/O napaka -- %s HTTP strežniška napaka -- %s Napaka lokalne shrambe -- %s - Poskusi ponovno Prikaži predmet S strežnika so bili prejeti neveljavni kontakti S strežnika so bili prejeti neveljavni dogodki diff --git a/app/src/main/res/values-sr/strings.xml b/app/src/main/res/values-sr/strings.xml index 61b6d1186720095e3d0026e4ec10a07689478eb1..68a045e917bc505326747c7c3e764328de4c9539 100644 --- a/app/src/main/res/values-sr/strings.xml +++ b/app/src/main/res/values-sr/strings.xml @@ -11,6 +11,7 @@ Помоћ Управљај налозима Подели + Нема интернета, заказујем синхронизацију База података је корумпирана Сви налози су уклоњени локално. Тражење грешака @@ -18,37 +19,73 @@ Статусне поруке ниског приоритета Синхронизација Грешке синхронизације + Важне грешке које заустављају синхронизацију попут неочекиваних одговора сервера Упозорења синхронизације + Не критични проблеми са синхронизацијом попут одређених неисправних датотека Мрежне и У/И грешке + Истекла времена, проблеми са повезивањем, итд. (често привремено) Ваши подаци. Ваш избор. Преузмите контролу. Регуларни интервали синхронизације Онемогућено (није препоручено) Омогућено (препоручено) + За синхронизацију у регуларним интервалима, %s мора бити дозвољено да се извршава у позадини. У супротном, Андроид може зауставити синхронизацију у било ком тренутку. Не требају ми регуларни интервали синхронизације.* + %s компатибилност Овај уређај вероватно блокира синхронизацију. Ако је тако, овај проблем можете решити једино ручно. Изменио сам потребна подешавања. Не подсећај ме више.* * Остави непотврђено да би био подсетнут касније. Може бити ресетовано у подешавањима / %s. Још информација + + Подршка за задатке + Ако су задаци подржани од стране вашег сервера, они могу бити синхронизовани са подржаном апликацијом: + OpenTasks + Изгледа да се више не развија - не препоручује се. + Tasks.org + нису подржане (за сада).]]> + Ниједна продавница апликација није доступна Не треба ми подршка за задатке.* Софтвер отвореног кода + Како допринети/донирати Не приказуј у блиској будућности Дозволе %s захтева дозволе да би исправно радила. Све испод + Користите ово да би сте омогућили све функционалности (пропоручено) Све дозволе су дате Дозволе за контакте Без синхронизације контаката (није препоручено) + Могућа је синхронизација контакта Дозволе за календар - Дозволе за задатке + Без синхронизације календара (није препоручено) + Могућа је синхронизација календара + Дозволе за обавештења + Обавештења су онемогућена (није препоручено) + Обавештења су омогућена + Без синхронизације задатака, вести, белешки + Могућа је синхронизација задатака, вести, белешки + Дозволе за OpenTasks Дозволе за задатке + Без синхронизације задатака (није инсталирано) + Без синхронизације задатака + Могућа је синхронизација задатака Задржи дозволе + Дозволе могу бити аутоматски поништене (није препоручено) + Дозволе неће бити аутоматски поништене + Изаберите Дозволе > искључите \"Уклони дозволе ако се апликација не користи\" + Ако опција не функционише, користите подешавања апликације / Дозволе. Подешавања апликације + WiFi SSID дозволе Дозвола прецизне локације + Дата је дозвола за локацију + Одбијена је дозвола за локацију + Дозволе за локацију у позадини Дозволи сво време + %s]]> + %s]]> Локација је увек омогућена Услуга локације је омогућена Услуга локације је онемогућена @@ -58,7 +95,10 @@ Издање %1$s (%2$d) Компилован %s Овај програм НЕМА НИКАКВЕ ГАРАНЦИЈЕ. Бесплатан је софтвер којег можете слободно да делите под одређеним условима. + Захвалница: %s]]> + Није се могла направити датотека записа + Сада се записују све %s активности Прегледај/подели Онемогући @@ -67,6 +107,8 @@ КалДАВ/КардДАВ адаптер синхронизације О програму/лиценца Повратне информације бета издања + Молим вас инсталирајте клијент е-поште + Молим вас инсталирајте прегледач интернета Поставке Новости и ажурирања Алати @@ -77,8 +119,10 @@ Заједница Донирај Политика приватности + Обавештења су онемогућена. Нећете бити обавештени о проблемима са синхронизацијом. Нема везе са интернетом. Андроид неће вршити синхронизацију. Недовољно слободног простора. Андроид неће вршити синхронизацију. + Управљајте складиштем Добро дошли у ДАВдроид!\n\nМожете сада да додате КалДАВ/КардДАВ налог. Синхронизација је системски искључена Укључи @@ -87,7 +131,9 @@ Откривање услуге није успело Не могох да освежим списак збирки + Неопходно стављање на белу листу за оптимизацију батерије + На неким уређајима је ово неопходно за аутоматску синхронизацију. Поставке Тражење грешака @@ -96,6 +142,15 @@ Исцрпна евиденција Оптимизација батерије Повезивање + Врста проксија + + Системски предефинисан + Без проксија + HTTP + SOCKS (за Orbot) + + Назив прокси домаћина + Порт проксија Безбедност Дозволе апликације Прегледај дозволе неопходне за синхронизацију @@ -118,20 +173,29 @@ Поновно приказивање претходно одбачених савета Сви савети ће поново бити приказани Интеграција + Апликација за задатке + Синхронизовање са %s КардДАВ КалДАВ Вебкал + Без синхронизације контакта (недостају дозволе) + Без синхронизације календара (недостају дозволе) + Без синхронизације задатака (недостају дозволе) + Без синхронизације календара и задатака (недостају дозволе) + Није могуће приступити календарима (недостају дозволе) Дозволе Нема именика (још увек). Нема календара (још увек). + Нема претплата на календаре (још увек). Повуците на доле да би сте освежили списак са сервера. Синхронизуј одмах - Синхронизујем Поставке налога Преименуј налог Несачувани локални подаци могу бити изгубљни. Потребна је ресинхронизација након преименовања. Нови назив налога: Преименуј + Назив налога је већ заузет + Није било могуће преименовати налог Обриши налог Заиста обрисати налог? Све локалне копије адресара, календара и листи задатака ће бити обрисане. @@ -146,6 +210,7 @@ Освежи списак календара Направи нови календар Нема апликације за Вебкал + Инсталирај ICSx⁵ Додај налог Пријавите се адресом е-поште @@ -158,11 +223,11 @@ Корисничко име Корисничко име је обавезно Корени УРЛ - Пријавите се УРЛ-ом и сертификатом клијента Изабери сертификат Пријава Додај налог Назив налога + Коришћење апострофа (\'), је регистровано да изазива проблеме на неким уређајима. Користите вашу е-адресу за назив налога јер Андроид користи назив налога за поље ОРГАНИЗАТОР за догађаје које направите. Не можете имати два налога истог назива. Режим група контаката: Назив налога је обавезан @@ -173,9 +238,13 @@ Користи сертификат клијента Сертификат није пронађен Инсталирај сертификат + Гугл контакти / календар + Гугл налог + ИД клијента (опционо) Откривање конфигурације Сачекајте, шаљем упит серверу… Не могох да нађем КалДАВ или КардДАВ услугу. + Корисничко име (адреса е-поште) / лозинка је погрешна? Прикажи детаље Поставке: %s @@ -199,11 +268,13 @@ Тип везе није узет у обзир Ограничења ССИД-а бежичних Синхронизовање само преко %s + Синхронизовање само преко %s (захтева сервисе активне локације) Коришћење свих бежичних мрежа Имена (ССИД) дозвољених мрежа. одвојена зарезом (оставите празно за све мреже) Управљај - Још информација (ЧПП) Аутентификација + Поновна аутентификација + корисничко име Корисничко име Унесите корисничко име: Лозинка @@ -227,15 +298,12 @@ Боје догађаја нису синхронизоване КардДАВ Режим група контаката - Измени режим група Направи адресар - Мој адресар Направи календар Временска зона Догађаји Задаци - Комбиновано (догађаји и задаци) Боја Правим збирку Наслов @@ -247,11 +315,9 @@ Направи Обриши збирку Да ли сте сигурни? - Бришем збирку Присили само-за-читање Својства Адреса (УРЛ): - Копирај УРЛ Власник: Подаци за исправљање грешака @@ -268,14 +334,13 @@ Име за приказ Корисничко име Лозинка - + ДАВдроид дозволе Потребне су додатне доволе Аутентификација није успела (проверите акредитиве за пријаву) Мрежна или У/И грешка – %s Грешка ХТТП сервера – %s Грешка локалног складишта – %s - Покушај поново Прикажи ставку ДАВдроид: Безбедност везе diff --git a/app/src/main/res/values-sv/strings.xml b/app/src/main/res/values-sv/strings.xml index 3f94fbbeed728f783a0b7f5650ae9925ec122942..895ee4e770d852e8a4a8f2f530241166000054c5 100644 --- a/app/src/main/res/values-sv/strings.xml +++ b/app/src/main/res/values-sv/strings.xml @@ -205,7 +205,6 @@ Det finns inga kalenderprenumerationer (ännu). Svep nedåt för att uppdatera listan från servern. Synkronisera nu - Synkroniserar nu Kontoinställningar Byt namn på kontot Lokala data som inte har sparats kan avvisas. Omsynkronisering krävs efter byte av namn. Nytt kontonamn: @@ -433,7 +432,6 @@ Använd en specifik server Senare Uppdatera användarnamn och lösenord - VARNING Mitt konto Instruktioner logga in på denna enhet @@ -491,4 +489,5 @@ Misslyckades att uppdatera samlingslistan Kalenderhanterare för webben Datasparare aktiverad. Bakgrundssynkronisering är begränsad. - \ No newline at end of file + Kontonamn är upptaget + diff --git a/app/src/main/res/values-szl/strings.xml b/app/src/main/res/values-szl/strings.xml index 4608640d53625c6248461ff6bdaf2f1bd267273c..6b4670ccca0e375f194e4c2cd544d5f67469a3f8 100644 --- a/app/src/main/res/values-szl/strings.xml +++ b/app/src/main/res/values-szl/strings.xml @@ -101,11 +101,11 @@ Niy ma (jeszcze) żodnych subskrypcyji kalyndorzōw. Przeciōng w dōł, żeby ôdświyżyć lista ze serwera. Synchrōnizuj teroz - Synchronizacyjo trwo Sztelōnki kōnta Przemianuj kōnto Niyspamiyntane dane lokalne mogōm być skasowane. Synchrōnizacyjo na nowo je wymogano po przemianowaniu. Nowe miano kōnta: Przemianuj + Miano kōnta je już zajynte Skasuj kōnto Naprowda chcesz skasować kōnto? Wszyjske lokalne kopije ksiōnżek adresowych, kalyndorzōw i list zadań bydōm skasowane. @@ -131,7 +131,6 @@ Miano używocza Wymogane miano używocza Bazowy URL - Logowanie ze pōmocōm adresy URL i certyfikatu klijynta Ôbier certyfikat Wloguj Stwōrz kōnto @@ -170,7 +169,6 @@ Bydzie synchrōnizować ino bez %s (wymogo aktywnych serwisōw lokalizacyje) Wszyjske połōnczynia WiFi bydōm używane Ôddzielōne kōmami miana (SSID) przizwolōnych necōw WiFi (ôstow prōzne dlo wszyjskich) - Wiyncyj informacyji (FAQ) Autoryzowanie Miano używocza Wpisz miano używocza: @@ -203,17 +201,14 @@ Grupy to sōm ôddzielne VCards Grupy to sōm kategoryje co kōntakt - Zmiyń spusōb grupowy Stwōrz ksiōnżka adresowo - Moja ksiōnżka adresowo Stwōrz kalyndorz Strefa czasowo Możliwe wpisy kalyndorza Zdarzynia Zadania Zopiski / dziynnik - Połōnczōne (zdarzynia i zadania) Farba Tworzynie kolekcyje Tytuł @@ -226,11 +221,9 @@ Je żeś zicher? Ta kolekcyjo (%s) i wszyjske jeji dane bydōm doimyntnie skasowane. Te dane bydōm skasowane ze serwera. - Kasowanie kolekcyje Wymuś ino do ôdczytu Włosności Adresa (URL): - Skopiuj URL Informacyje debugowe @@ -242,14 +235,13 @@ Miano używocza Hasło - + Uprawniynia DAVx⁵ Wymogane ekstra uprawniynia Autoryzacyjo sie niy podarziła (dej pozōr na dane logowanio) Feler necu abo I/O – %s Feler serwera HTTP – %s Feler lokalnego przechowowanio – %s - Sprōbuj zaś Pokoż elymynt Dostany kōntakt ze serwera je niynoleżny Dostane zdarzynie ze serwera je niynoleżne diff --git a/app/src/main/res/values-tr/strings.xml b/app/src/main/res/values-tr/strings.xml index c599faee948390a219c188c78378cff0394513f5..72b1b01d358c2d4f4a9cda98b3b5c9ff737b3fe2 100644 --- a/app/src/main/res/values-tr/strings.xml +++ b/app/src/main/res/values-tr/strings.xml @@ -50,7 +50,6 @@ CalDAV Şimdi senkronize et - Senkronize ediyor Hesap ayarları Hesabı sil Hesap gerçekten silinsin mi? @@ -113,14 +112,11 @@ Takvim renkleri Muhasebe Müdürü tarafından ayarlanmadı Rehber yarat - Benim Rehberim - Birleşik (olaylar ve işler) Koleksiyon yaratılıyor Başlık zorunlu Yarat Koleksiyonu sil Emin misin? - Koleksiyon siliniyor Hata ayıklama bilgisi Jurnallere bak @@ -133,7 +129,7 @@ Kullanıcı adı Parola - + Muhasebe Müdürü izinleri Ek izinler zorunludur diff --git a/app/src/main/res/values-uk/strings.xml b/app/src/main/res/values-uk/strings.xml index 4b437e938fdd2a709fc5ef59a145cfb63eeaa802..15a122991bd9cd3a7d8f0a683c8221573c1ddfb0 100644 --- a/app/src/main/res/values-uk/strings.xml +++ b/app/src/main/res/values-uk/strings.xml @@ -5,7 +5,6 @@ Адресна книга WebDAV Адресні книги Допомога - Відправити Зневадження Інші важливі повідомлення Синхронізація @@ -15,23 +14,6 @@ Не критичні проблеми синхронізації, наприклад деякі файли хибні Помилка мережі та вводу/виводу Спливання часу відклику, проблеми зв\'язку, і т.п. (часто тимчасові) - - Автоматична синхронізація - %s прошивка часто блокує автоматичну синхронізацію. У цьому випадку дозвольте автоматичну синхронізацію в налаштуваннях Android. - Запланована синхронізація - Ваш пристрій буде обмежувати синхронізацію Менеджер рахунків. Щоб забезпечити регулярні інтервали синхронізації Менеджер рахунків, вимкніть \"оптимізація батареї\". - Вимкнути для Менеджер рахунків - Не показувати знову - Не зараз - Інформація Open-Source - Ми раді, що Ви використовуєте Менеджер рахунків, який є програмним засобом з відкритим джерельним кодом (GPLv3). Розробка Менеджер рахунків є досить складним завданням і потребує від нас тисячі годин роботи. Будь ласка, розгляньте можливість підтримати проект. - Показати сторінку пожертви - Можливо пізніше - Додаткова інформація - OpenTasks не встановлено - Для синхронізації завдань необхідно встановити додаток OpenTasks. (Не має потреби для контактів/подій.) - Після встановлення OpenTasks, необхідно перевстановити Менеджер рахунків та додати облікові записи знову (Вада системи Android). - Встановити OpenTasks Бібліотеки Версія %1$s (%2$d) @@ -40,8 +22,6 @@ Цей програмний засіб постачається АБСОЛЮТНО БЕЗ БУДЬ-ЯКИХ ГАРАНТІЙ. Це вільне програмне забезпечення, і ви можете поширювати її, за деякими умовами. Не вдалося створити файл звіту - Звітування Менеджер рахунків - Відправити звіт Відкрити панель навігації Закрити панель навігації @@ -54,7 +34,6 @@ Веб сайт Посібник Питання/Відповіді - Допомога / Форуми Підтримка Відсутнє підключення до інтернету. Android не може розпочати синхронізацію. Вітаємо у Менеджер рахунків! @@ -74,11 +53,6 @@ Звітування активно Звітування призупинено З\'єднання - Перевизначити налаштування проксі - Власні налаштування проксі - Типові системні налаштування проксі - Ім\'я хосту HTTP проксі - Порт HTTP проксі Безпека Не довіряти системним сертифікатам Не довіряти системним та доданим користувачем сертифікатам @@ -101,7 +75,6 @@ Немає підписок на календар (поки що). Потягніть донизу, аби оновити список з серверу. Синхронізувати зараз - Синхронізація Налаштування облікового запису Перейменувати обліковий запис Незбережені локальні дані можуть бути втрачені. Необхідно виконати синхронізацію після перейменування. Нова назва облікового запису: @@ -128,15 +101,12 @@ Потребує пароль Увійти за допомогою URL та імені користувача URL адреса повинна починатися з http(s):// - Посилання повинно починатися з https:// - Потребує назву хосту Ім\'я користувача Потребує ім\'я користувача Базовий URL Вхід по посиланню та сертифікату клієнта Обрати сертифікат Увійти - Назад Додати обліковий Назва запису Використовуйте вашу електронну адресу як ім\'я облікового запису, так як Android буде використовувати ім\'я облікового запису в полі ORGANIZER для подій, які ви створюватимете. Ви не можете мати два облікових записи з однаковими іменами. @@ -158,7 +128,6 @@ Інтервал синхронізації завдань Вручну - Кожні 2 хвилини Кожні 15 хвилин Кожні 30 хвилин Щогодинно @@ -174,7 +143,6 @@ Синхронізація відбуватиметься тільки через %s (вимагає ввімкнені служби локації) Може використовуватись всі Wi-Fi з\'єднання Назви (SSID) дозволених Wi-Fi мереж, розділені комами (залиште порожнім для всіх) - Для читання назв WiFi, потрібен дозвіл до локації, та ввімкнені сервіси локації. Більше інформації (FAQ) Автентифікація Ім\'я користувача @@ -207,7 +175,6 @@ Моя адресна книга Створити календар Часовий пояс - Потребує часовий пояс Можливі записи календаря Події Завдання @@ -237,12 +204,8 @@ Показати подробиці Інформація зневадження - Журнали прикріплені до цього повідомлення (потрібна підтримка вкладеного додатка). - Адресна книга лише для читання Дозволи Менеджер рахунків Потребує додаткові дозволи - OpenTasks застарів - Необхідна версія: %1$s (поточна %2$s) Помилка аутентифікації (перевірте обліковий запис) Помилка мережі та вводу/виводу — %s Помилка сервера HTTP — %s @@ -272,4 +235,95 @@ Викоритсати свій сервер Потрібна коректна адреса URL серверу Облікові дані - \ No newline at end of file + Обліківки не існує (більше) + Це поле обовʼязкове + Керування обліковими записами + Поширити + База даних пошкоджена + Усі облікові записи видалено локально. + Повідомлення з низьким пріоритетом + + Ваша дата. Ваш вибір. + Візьміть під свій контроль. + Інтервали регулярної синхронізації + Вимкнено (не рекомендуєтсья) + Дозволено (рекомендується) + Мені не потрібні інтервали регулярної синхронізації.* + Детальніше + Підтримка завдань + OpenTasks + Мені не потрібна підтримка завдань.* + ПЗ з відкритим кодом + Як співпрацювати/пожертвувати + + Дозволи + Використовуйте це, щоб дозволити всі можливості (рекомендується) + Усі дозволи надано + Дозволи контактів + Дозволи календаря + Без синхронізації календаря (не рекомендується) + Можлива синхронізація календаря + Дозволи OpenTasks + Дозволи завдань + Без синхронізації завдань (не встановлено) + Можлива синхронізація завдань + Налаштування застосунку + + Дозволи SSID WiFi + + Переклади + + Вимкнути + + Синхронізувати всі обліківки + + Не вдалося виявити сервіси + Не вдалося оновити перелік колекції + + + + Дозволи застосунку + Вибрати тему + + Системна + Світла + Темна + + Застосунок завдань + Не знайдено сумісного застосунку завдань + + Дозволи + Ім\'я запису вже зайняте + Не вдалося перейменувати облікувку + Викор. імʼя користувача/пароль + Викор. сертифікат клієнта + Не знайдено сертифікат + Встановити сертифікат + Невірне імʼя користувача (електронна адреса)/пароль? + Керувати + Не вибрано сертифікат + Нагадування за замовчуванням + + Типово нагадувати за хвилину до події + Типово нагадувати за %d хвилини до події + Типово нагадувати за %d хвилин до події + Типово нагадувати за %d хвилин до події + + Нагадування за замовчуванням не створюються + Якщо нагадування за замовчуванням створюються для подій без нагадування: бажана кількість хвилин до події. Залиште поле порожнім, щоб вимкнути нагадування за замовчуванням. + + Групи-це окремі записи + Групи є в категоріями в контактах + + Власник: + + Архів ZIP + Помилка HTTP + Помилка сервера + Помилка WebDAV + Помилка I/O + Переглянути деталі + + Ім\'я користувача + Пароль + diff --git a/app/src/main/res/values-vi/strings.xml b/app/src/main/res/values-vi/strings.xml index 0acb922d27c6190b7994be80776a27078c09e7bc..95d89c7e10fc05c279a6f7372b1e74b093d290a7 100644 --- a/app/src/main/res/values-vi/strings.xml +++ b/app/src/main/res/values-vi/strings.xml @@ -198,11 +198,11 @@ Chưa có lượt đăng ký lịch nào. Vuốt xuống để làm mới danh sách từ máy chủ. Đồng bộ hoá ngay - Đang đồng bộ hoá ngay Cài đặt tài khoản Đổi tên tài khoản Dữ liệu cục bộ chưa được lưu có thể sẽ bị bỏ qua. Yêu cầu đồng bộ hoá lại sau khi đổi tên. Tên tài khoản mới: Đổi tên + Tên tài khoản đã được sử dụng Không thể đổi tên tài khoản Xoá tài khoản Thực sự xoá tài khoản? @@ -231,7 +231,6 @@ Tên người dùng Yêu cầu tên người dùng URL cơ sở - Đăng nhập bằng URL và chứng chỉ khách Chọn chứng chỉ Đăng nhập Tạo tài khoản @@ -279,7 +278,6 @@ Tên được chia tách bởi dấu phẩy (SSID) của các mạng WiFi được cho phép (để trống để cho phép tất cả) Giới hạn WiFi SSID yêu cầu cài đặt sâu hơn Quản lý - Thêm thông tin (câu hỏi thường gặp) Xác thực Tên người dùng Nhập tên người dùng: @@ -313,17 +311,14 @@ Các nhóm là các tệp vCard riêng Các nhóm là các hạng mục cho từng liên hệ - Thay đổi phương pháp nhóm Tạo sổ địa chỉ - Sổ địa chỉ của tôi Tạo lịch Múi giờ Các mục của lịch có thể có Sự kiện Công việc Ghi chú / nhật ký - Kết hợp (sự kiện và công việc) Màu Đang tạo bộ sưu tập Tiêu đề @@ -337,11 +332,9 @@ Bạn có chắc không? Bộ sưu tập này (%s) và tất cả dữ liệu của nó sẽ bị xoá vĩnh viễn. Những dữ liệu này sẽ bị xoá khỏi máy chủ. - Đang xoá bộ sưu tập Buộc chỉ đọc Thuộc tính Địa chỉ (URL): - Sao chép URL Chủ sở hữu: Thông tin gỡ lỗi @@ -364,7 +357,6 @@ Có liên quan đến vấn đề Tài nguyên trên mạng: Tài nguyên cục bộ: - Xem bằng ứng dụng Nhật ký Có nhật ký chi tiết Xem nhật ký @@ -397,7 +389,7 @@ Đang tải xuống tệp WebDAV Đang tải lên tệp WebDAV Nơi gắn WebDAV - + Quyền của DAVx⁵ Yêu cầu quyền bổ sung %s quá cũ @@ -406,7 +398,6 @@ Lỗi mạng hoặc I/O – %s Lỗi máy chủ HTTP – %s Lỗi kho lưu trữ cục bộ – %s - Thử lại Xem mục Đã nhận liên hệ không hợp lệ từ máy chủ Đã nhận sự kiện không hợp lệ từ máy chủ diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml index 221934a6180e966d55e7e236d48839e2ce073d08..c74fe8beec4918f18929c3d260e9a68245516140 100644 --- a/app/src/main/res/values-zh-rCN/strings.xml +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -5,7 +5,6 @@ 客户经理 通讯录 通讯录 帮助 - 发送 调试 其它重要消息 同步 @@ -15,23 +14,6 @@ 不重要的同步问题,如某文件无效 网络或 I/O 错误 超时、连接异常等问题(通常是临时错误) - - 自动同步 - %s 的系统通常会禁止自动同步。在 Android 的系统设置中手工打开自动同步即可恢复。 - 定时同步 - 你的设备会限制 客户经理 的同步频率,如需恢复正常同步频率,请关闭电池优化设置。 - 禁用电池优化 - 不再显示 - 暂不 - 开源信息 - 欢迎使用 客户经理,这是一款开源软件 (GPLv3)。开发 客户经理 的工作花费了数千小时,请您考虑捐助我们。 - 显示捐助页面 - 稍后提示 - 更多信息 - OpenTasks 未安装 - 同步任务需安装 OpenTasks 免费应用。(如只需同步通讯录、事件,则不用安装) - 安装 OpenTasks 后,由于 Android 的限制,请重新安装 客户经理 并重新创建账户。 - 安装 OpenTasks 程序库 版本 %1$s (%2$d) @@ -40,8 +22,6 @@ 本程序不附带任何担保。这是一款自由软件,你可以有条件地传播它。 无法创建日志文件 - 客户经理 日志 - 发送日志 打开导航抽屉 关闭导航抽屉 @@ -54,7 +34,6 @@ 应用网站 手动 常见问题 - 帮助 / 论坛 捐助 欢迎使用 客户经理!\n\n请开始增加 CalDAV/CardDAV 账户。 系统全局自动同步已禁用 @@ -71,11 +50,6 @@ 日志记录已开启 日志记录已禁用 连接 - 覆盖代理设置 - 使用自定义代理设置 - 使用系统默认代理设置 - HTTP 代理主机名 - HTTP 代理端口 安全 不信任系统证书 系统和用户增加的发布者不会被信任 @@ -98,7 +72,6 @@ 暂无日历订阅。 下拉可从服务器获取最新列表。 立即同步 - 正在同步 账户设置 重命名账户 重命名后,未上传的本地修改会被撤销,您需要重新执行同步。新账户名: @@ -125,15 +98,12 @@ 请输入密码 使用 URL 和用户名登录 URL 需以 http(s):// 为开头 - URL 需以 https:// 为开头 - 请输入主机名 用户名 请输入用户名 根地址 使用 URL 和客户端证书登录 选择证书 登录 - 返回 新增帐户 账户显示名 请使用你的邮箱地址作为帐户名,因为 Android 会将你创建的日历事件的创建者项设置为帐户名。你不能拥有多个帐户名相同的账户。 @@ -155,7 +125,6 @@ 任务自动同步间隔 手动同步 - 每 2 分钟 每 15 分钟 每 30 分钟 每小时 @@ -171,7 +140,6 @@ 将只在 %s 上同步(需要生效的位置服务) 任意 WiFi 网络均可同步 请用半角逗号分隔允许同步的 WiFi 网络名(SSID),留空则允许任意网络 - 读取 WiFi 网络名称需要位置权限,以及永久生效的位置服务。 更多信息(常见问题) 认证 用户名 @@ -201,7 +169,6 @@ 我的通讯录 创建日历 时区 - 请选择时区 可能使用的日历类型 事件 任务 @@ -231,12 +198,8 @@ 显示详情 调试信息 - 日志数据将作为附件与该信息一并发送(需要分享目标应用支持附件功能)。 - 只读通讯录 客户经理 权限 需要额外权限 - OpenTasks 版本太旧 - 最低版本 %1$s (当前 %2$s) 认证失败(请检查登录凭据,如用户名密码) 网络或 I/O 错误 – %s HTTP 服务器错误 – %s diff --git a/app/src/main/res/values-zh-rTW/strings.xml b/app/src/main/res/values-zh-rTW/strings.xml index fc416f288237fcbc14040cdb70c927f4893476f0..87baa4b06891658dc8a9ba20b0a6c7da7536b12d 100644 --- a/app/src/main/res/values-zh-rTW/strings.xml +++ b/app/src/main/res/values-zh-rTW/strings.xml @@ -109,11 +109,11 @@ (目前)沒有行事曆訂閲 下拉可從伺服器獲取最新清單 立即同步 - 同步中 帳號設定 重新命名帳號 尚未儲存的本地資料可能會消失。重新命名後必須再次執行同步。新的帳號名稱: 重新命名 + 這個賬號名稱已經被取過了 無法重新命名帳號 刪除帳號 確定要刪除帳號? @@ -141,7 +141,6 @@ 使用者帳號 必須填寫使用者帳號 根 URL - 用網址和客戶端鑒權登入 點選憑證 登入 新增帐户 @@ -181,7 +180,6 @@ 所有 WiFi 連線都可以使用 使用逗號分割的名稱 (SSIDs) 表示的 WiFi 連線(留空則代表全部) 管理 - 更多資訊(常見問題) 認證 使用者帳號 輸入帳號名稱: @@ -213,17 +211,14 @@ 群組存成額外的 VCard 檔案 群組存成每個聯絡人的分類屬性 - 改變聯絡人群組的儲存格式 建立通訊錄 - 我的通訊錄 建立行事曆 時區 可使用的行事曆項目 活動 事務 筆記/日誌 - 合併 (事件和任務) 顔色 建立新行事曆或工作清單 標題 @@ -236,11 +231,9 @@ 您確定嗎? 這個組合 (%s) 和它的所有資料將永久刪除。 這些數據會從伺服器上永久刪除。 - 正在刪除 强制只讀 權限 地址(URL): - 拷貝URL 除錯訊息 @@ -252,14 +245,13 @@ 使用者帳號 密碼 - + 客户经理 權限 需要額外的權限 鑒權失敗(你需要檢查登錄憑證) 網際網絡或者輸入輸出錯誤——%s HTTP伺服器錯誤——%s 資料庫錯誤——%s - 再試一次 查閲項目 收到了無效的聯絡人 收到了無效的事件 diff --git a/app/src/main/res/values-zh/strings.xml b/app/src/main/res/values-zh/strings.xml index 58bc87ff20f88852f4bb0aca07201f9eacb4c4e7..ddedd6115e5d68d62913817db38f82019a574755 100644 --- a/app/src/main/res/values-zh/strings.xml +++ b/app/src/main/res/values-zh/strings.xml @@ -2,7 +2,7 @@ DAVx⁵ - 帐户(已)不存在 + 账户(已)不存在 DAVx⁵ 通讯录 通讯录 删除 @@ -10,6 +10,7 @@ 此字段是必填项 帮助 分享 + 无互联网,正安排同步 数据库损坏 所有帐户已在本地删除。 调试 @@ -208,11 +209,12 @@ 暂无日历订阅。 下拉可从服务器获取最新列表。 立即同步 - 正在同步 + 同步收藏 账户设置 重命名账户 重命名后,未上传的本地修改会被撤销,您需要重新执行同步。新账户名: 重命名 + 账户名已被占用 无法重命名账户 删除账户 真的要删除账户吗? @@ -241,7 +243,6 @@ 用户名 请输入用户名 根地址 - 使用 URL 和客户端证书登录 选择证书 登录 创建账户 @@ -257,6 +258,17 @@ 使用客户端证书 没有找到证书 安装证书 + Google 联系人/日历 + 请参阅我们的“Tested with”页面的 Google 部分获得最新信息。 + 你可能遇到意外的警告和/或者不得不创建自己的 client ID。 + Google 账户 + 使用 Google 账户登录 + Client ID (可选) + 如果我们的 client ID 不起作用,你可以使用自己的 client ID + 现在就展示给我看! + 隐私政策 。]]> + Google API 服务用户数据政策,包括有限使用的要求。]]> + 无法获得身份验证码 正在配置 正在与服务器通信,请稍等… 找不到 CalDAV 或 CardDAV 服务。 @@ -289,8 +301,10 @@ 请用半角逗号分隔允许同步的 WiFi 网络名(SSID),留空则允许任意网络 WiFi SSID 限制需要进一步设置 管理 - 更多信息(常见问题) 认证 + 重新验证身份 + 再次进行 OAuth 登录 + 用户名 用户名 输入用户名 密码 @@ -323,17 +337,14 @@ 按 VCard 文件分组 按联系人分类分组 - 修改分组方式 创建通讯录 - 我的通讯录 创建日历 时区 可能使用的日历类型 事件 任务 笔记 / 日志 - 混合(日程和任务) 颜色 正在创建集合 标题 @@ -347,11 +358,11 @@ 你确定吗? 该集合 %s 及其所有数据将会被永久删除。 这些数据将会被从远程服务器删除。 - 正在删除集合 强制只读 属性 + 上次同步: + 从未同步 地址 (URL): - 复制 URL 所有者: 调试信息 @@ -374,7 +385,6 @@ 与此问题有关 远程资源: 本地资源: - 用其它应用查看 日志 详细日志可用 查看日志 @@ -407,7 +417,7 @@ 正在下载 WebDAV 文件 正在上传 WebDAV 文件 WebDAV 文件系统 - + DAVx⁵ 权限 需要额外权限 %s太旧 @@ -416,7 +426,7 @@ 网络或 I/O 错误 – %s HTTP 服务器错误 – %s 本地存储错误 – %s - 重试 + 软错误(达到最大重试次数) 显示项目 从服务器收到无效的通讯录 从服务器收到无效的日历事件 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 4b777fecf349a3a93c81b725aad44250c2cbc4e4..100c5ad075ae851dbb5b89cab4526cffb35f976b 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -3,7 +3,7 @@ Account Manager https://e.foundation/ - e_mydata + Account does not exist (anymore) e.foundation.webdav WebDAV @@ -37,6 +37,7 @@ Account extra settings Credentials, sync frequency, etc. Share + No internet, scheduling sync Database corrupted All accounts have been removed locally. @@ -260,11 +261,12 @@ There are no calendar subscriptions (yet). Swipe down to refresh the list from the server. Synchronize now - Synchronizing now + Synchronize collections Account settings Rename account Unsaved local data may be dismissed. Re-synchronization is required after renaming. New account name: Rename + Account name already taken Couldn\'t rename account Delete account Really delete account? @@ -296,7 +298,6 @@ User name User name required Base URL - Login with URL and client certificate Select certificate Login Add account @@ -312,6 +313,15 @@ Use client certificate No certificate found Install certificate + Google Contacts / Calendar + Please see our \"Tested with Google\" page for up-to-date information. + You may experience unexpected warnings and/or have to create your own client ID. + Google account + Sign in with Google + Client ID (optional) + Privacy policy for details.]]> + Google API Services User Data Policy, including the Limited Use requirements.]]> + Couldn\'t obtain authorization code Add account Please wait, adding account… @@ -344,8 +354,7 @@ sync_interval_tasks Tasks sync. interval - -1 - 120 + -1 900 1800 3600 @@ -354,8 +363,7 @@ 86400 - Only manually - Every 2 minutes + Only manually Every 15 minutes Every 30 minutes Every hour @@ -375,9 +383,11 @@ Comma-separated names (SSIDs) of allowed WiFi networks (leave blank for all) WiFi SSID restriction requires further settings Manage - More information (FAQ) Authentication - username + oauth + Re-authenticate + Perform OAuth login again + username User name Credentials Update username and password @@ -427,18 +437,15 @@ Groups are separate vCards Groups are per-contact categories - Change group method Create address book - My Address Book Create calendar Time zone Possible calendar entries Events Tasks Notes / journal - Combined (events and tasks) Color Creating collection Title @@ -452,11 +459,11 @@ Are you sure? This collection (%s) and all its data will be removed permanently. These data shall be deleted from the server. - Deleting collection Force read-only Properties + Last synced: + Never synced Address (URL): - Copy URL Owner: @@ -481,7 +488,6 @@ Related to the problem Remote resource: Local resource: - View with app Logs Verbose logs are available View logs @@ -519,7 +525,7 @@ Uploading WebDAV file WebDAV mount - + Account Manager permissions Additional permissions required %s too old @@ -528,7 +534,7 @@ Network or I/O error – %s HTTP server error – %s Local storage error – %s - Retry + Soft error (max retries reached) View item Received invalid contact from server Received invalid event from server diff --git a/app/src/main/res/values/styles.xml b/app/src/main/res/values/styles.xml index bd9d7c9439f203e08560d7e014212a03e8c4cd1d..5ff4c1e46920cb5c49d9fdd03b0ad2f3c7e733be 100644 --- a/app/src/main/res/values/styles.xml +++ b/app/src/main/res/values/styles.xml @@ -26,6 +26,7 @@ @color/e_action_bar @color/e_action_bar @color/e_accent + @color/e_icon_color @color/e_background @color/e_primary_text_color @color/e_secondary_text_color @@ -36,6 +37,8 @@ @color/accentColor @color/red700 + #6364FF + @@ -125,7 +130,7 @@ + + <!– AboutLibraries specific values –> @color/primaryDarkColor ?android:textColorPrimary ?android:textColorSecondary @@ -220,6 +226,7 @@ ?android:textColorPrimary @color/dividerColor +-->