mirror of
https://github.com/bitfireAT/davx5-ose.git
synced 2025-12-23 23:17:50 -05:00
Compare commits
156 Commits
v4.5.2-ose
...
main-ose
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9754770238 | ||
|
|
6be15fd366 | ||
|
|
0cb27f0c2f | ||
|
|
776305bd12 | ||
|
|
01f54df3c0 | ||
|
|
4944ce59b1 | ||
|
|
0e455d8371 | ||
|
|
a938b511cd | ||
|
|
d32b86789b | ||
|
|
84d58f73db | ||
|
|
cc43998148 | ||
|
|
b354bfebc2 | ||
|
|
29240ea16f | ||
|
|
e7b88f9aa8 | ||
|
|
4d71517cde | ||
|
|
0d4f154baf | ||
|
|
9eb70a5564 | ||
|
|
24e0a864bd | ||
|
|
10ec0c3b6d | ||
|
|
bd3349cc38 | ||
|
|
c7bc2b317b | ||
|
|
2d10cbb07d | ||
|
|
88928792af | ||
|
|
b5e8c80db1 | ||
|
|
6f09f55e1a | ||
|
|
b08f10a98f | ||
|
|
a3a952d875 | ||
|
|
e9fc570895 | ||
|
|
098b7d5b12 | ||
|
|
a38dc29cca | ||
|
|
84b9a14ba1 | ||
|
|
cda95dc789 | ||
|
|
f64882ca2a | ||
|
|
7c2dcf3d70 | ||
|
|
66a34ebd9f | ||
|
|
365364aa89 | ||
|
|
114543f4c5 | ||
|
|
3bd3f56e1b | ||
|
|
5263172376 | ||
|
|
babd52cfb1 | ||
|
|
3d4d533b92 | ||
|
|
76fc024ef6 | ||
|
|
794b4c1c7f | ||
|
|
aac6356722 | ||
|
|
9bc46d4194 | ||
|
|
a3aac44775 | ||
|
|
084ba3b630 | ||
|
|
28dcf90775 | ||
|
|
70766affd9 | ||
|
|
d00292f421 | ||
|
|
6b5c4f191a | ||
|
|
5c7b792e7f | ||
|
|
c9da496142 | ||
|
|
ee098c4a83 | ||
|
|
a8bd296520 | ||
|
|
0959624dee | ||
|
|
bd13d27e38 | ||
|
|
85548163ca | ||
|
|
026750eca3 | ||
|
|
d365a504e8 | ||
|
|
c64cb1e7ec | ||
|
|
837b5e5d50 | ||
|
|
98aefc4fee | ||
|
|
a8c8a8d2e0 | ||
|
|
b839cbfe7f | ||
|
|
f0f9f58e49 | ||
|
|
66f6e48e3b | ||
|
|
d6feda1142 | ||
|
|
05f058ab3f | ||
|
|
4e4c0f5e31 | ||
|
|
0304d7168a | ||
|
|
19458aa95c | ||
|
|
4412617079 | ||
|
|
76277dbfd5 | ||
|
|
b0b99de56b | ||
|
|
b33c4750bb | ||
|
|
25b749dd1b | ||
|
|
39a0fe3f98 | ||
|
|
dd798f8380 | ||
|
|
47d380de62 | ||
|
|
019d32a9b7 | ||
|
|
0acabd9c80 | ||
|
|
aa23980a59 | ||
|
|
5674d6b954 | ||
|
|
df56c8628a | ||
|
|
af4ecd3a1d | ||
|
|
701e292ab5 | ||
|
|
5da7d9e292 | ||
|
|
e6256764ac | ||
|
|
e63f815416 | ||
|
|
374dadfaaa | ||
|
|
f6ef13f9fe | ||
|
|
a88cfd2acf | ||
|
|
98fc946594 | ||
|
|
00523d9bc8 | ||
|
|
53d338d03e | ||
|
|
0424999225 | ||
|
|
fa09a0560f | ||
|
|
f4aa55d482 | ||
|
|
1bffd5efe1 | ||
|
|
4850f2a5a5 | ||
|
|
53f38ce2ec | ||
|
|
7cf6e30577 | ||
|
|
cec77c33cb | ||
|
|
6c98e6d501 | ||
|
|
a7cd1cd49f | ||
|
|
0cc84dfd01 | ||
|
|
87239daaf6 | ||
|
|
81ceb57842 | ||
|
|
cd0b0c0804 | ||
|
|
48cbd4a05d | ||
|
|
beccc7a0d4 | ||
|
|
2b629c8b18 | ||
|
|
cd725479cd | ||
|
|
44666d2138 | ||
|
|
8e67db7d54 | ||
|
|
a58e3b9036 | ||
|
|
d63918ff42 | ||
|
|
f21c3de94a | ||
|
|
24d4ba65e5 | ||
|
|
ae96f1ffbb | ||
|
|
a08ecae635 | ||
|
|
eb4224780a | ||
|
|
0240e67dab | ||
|
|
0ccd9d5eb3 | ||
|
|
438f967152 | ||
|
|
a093238864 | ||
|
|
293daf1e82 | ||
|
|
3b50747ce9 | ||
|
|
51d6ed279a | ||
|
|
2c6842ac0c | ||
|
|
0e6644305a | ||
|
|
10e3b0a723 | ||
|
|
2f45b705b3 | ||
|
|
be6c3311d7 | ||
|
|
755863778b | ||
|
|
0e81866d3a | ||
|
|
93a256ee75 | ||
|
|
61e9d60b7c | ||
|
|
dc9fb7b608 | ||
|
|
44b52f65a2 | ||
|
|
e13c140554 | ||
|
|
cdb50205f4 | ||
|
|
2ba4a2a510 | ||
|
|
38b2377760 | ||
|
|
10f6356a6e | ||
|
|
df4b6d3fbc | ||
|
|
dab948730e | ||
|
|
288583bfad | ||
|
|
98c0b0c36a | ||
|
|
ed7a477d3f | ||
|
|
b0609fafb2 | ||
|
|
94a85833bc | ||
|
|
4c5c8c3ed0 | ||
|
|
4685ab6d0c | ||
|
|
62a0ba3520 |
4
.github/CODEOWNERS
vendored
Normal file
4
.github/CODEOWNERS
vendored
Normal file
@@ -0,0 +1,4 @@
|
||||
# See https://docs.github.com/de/repositories/managing-your-repositorys-settings-and-features/customizing-your-repository/about-code-owners
|
||||
|
||||
# For combination with "Require review from code owners" for main-ose branch:
|
||||
* @bitfireAT/app-dev
|
||||
18
.github/dependabot.yml
vendored
18
.github/dependabot.yml
vendored
@@ -8,4 +8,20 @@ updates:
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
commit-message:
|
||||
prefix: "[CI] "
|
||||
prefix: "[CI] "
|
||||
groups:
|
||||
ci-actions:
|
||||
patterns: ["*"]
|
||||
|
||||
- package-ecosystem: "gradle"
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
groups:
|
||||
app-dependencies:
|
||||
patterns: ["*"]
|
||||
ignore:
|
||||
# dependencies without semantic versioning
|
||||
- dependency-name: "com.github.bitfireat:cert4android"
|
||||
- dependency-name: "com.github.bitfireat:dav4jvm"
|
||||
- dependency-name: "com.github.bitfireat:synctools"
|
||||
17
.github/workflows/codeql.yml
vendored
17
.github/workflows/codeql.yml
vendored
@@ -3,11 +3,12 @@ name: "CodeQL"
|
||||
on:
|
||||
push:
|
||||
branches: [ main-ose ]
|
||||
pull_request:
|
||||
# pull_request:
|
||||
# The branches below must be a subset of the branches above
|
||||
branches: [ main-ose ]
|
||||
# branches: [ main-ose ]
|
||||
schedule:
|
||||
- cron: '22 10 * * 1'
|
||||
|
||||
concurrency:
|
||||
group: codeql-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
@@ -28,19 +29,19 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
- uses: actions/setup-java@v4
|
||||
uses: actions/checkout@v6
|
||||
- uses: actions/setup-java@v5
|
||||
with:
|
||||
distribution: temurin
|
||||
java-version: 21
|
||||
- uses: gradle/actions/setup-gradle@v4
|
||||
- uses: gradle/actions/setup-gradle@v5
|
||||
with:
|
||||
cache-encryption-key: ${{ secrets.gradle_encryption_key }}
|
||||
cache-read-only: true # gradle user home cache is generated by test jobs
|
||||
|
||||
# Initializes the CodeQL tools for scanning.
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@v3
|
||||
uses: github/codeql-action/init@v4
|
||||
with:
|
||||
languages: ${{ matrix.language }}
|
||||
|
||||
@@ -50,9 +51,9 @@ jobs:
|
||||
# uses: github/codeql-action/autobuild@v2
|
||||
|
||||
- name: Build
|
||||
run: ./gradlew --build-cache --configuration-cache --no-daemon app:assembleOseDebug
|
||||
run: ./gradlew --no-daemon app:compileOseDebugSource
|
||||
|
||||
- name: Perform CodeQL Analysis
|
||||
uses: github/codeql-action/analyze@v3
|
||||
uses: github/codeql-action/analyze@v4
|
||||
with:
|
||||
category: "/language:${{matrix.language}}"
|
||||
|
||||
24
.github/workflows/dependency-submission.yml
vendored
Normal file
24
.github/workflows/dependency-submission.yml
vendored
Normal file
@@ -0,0 +1,24 @@
|
||||
name: Dependency Submission
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [ 'main-ose' ]
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
jobs:
|
||||
dependency-submission:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/setup-java@v5
|
||||
with:
|
||||
distribution: temurin
|
||||
java-version: 21
|
||||
|
||||
- name: Generate and submit dependency graph
|
||||
uses: gradle/actions/dependency-submission@v5
|
||||
with:
|
||||
cache-encryption-key: ${{ secrets.gradle_encryption_key }}
|
||||
dependency-graph-exclude-configurations: '.*[Tt]est.* .*[cC]heck.*'
|
||||
55
.github/workflows/dependent-issues.yml
vendored
55
.github/workflows/dependent-issues.yml
vendored
@@ -1,55 +0,0 @@
|
||||
name: Dependent Issues
|
||||
|
||||
on:
|
||||
issues:
|
||||
types:
|
||||
- opened
|
||||
- edited
|
||||
- closed
|
||||
- reopened
|
||||
pull_request_target:
|
||||
types:
|
||||
- opened
|
||||
- edited
|
||||
- closed
|
||||
- reopened
|
||||
# Makes sure we always add status check for PRs. Useful only if
|
||||
# this action is required to pass before merging. Otherwise, it
|
||||
# can be removed.
|
||||
- synchronize
|
||||
|
||||
# Schedule a daily check. Useful if you reference cross-repository
|
||||
# issues or pull requests. Otherwise, it can be removed.
|
||||
schedule:
|
||||
- cron: '19 9 * * *'
|
||||
|
||||
permissions: write-all
|
||||
|
||||
jobs:
|
||||
check:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: z0al/dependent-issues@v1
|
||||
env:
|
||||
# (Required) The token to use to make API calls to GitHub.
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
# (Optional) The token to use to make API calls to GitHub for remote repos.
|
||||
GITHUB_READ_TOKEN: ${{ secrets.DEPENDENT_ISSUES_READ_TOKEN }}
|
||||
|
||||
with:
|
||||
# (Optional) The label to use to mark dependent issues
|
||||
# label: dependent
|
||||
|
||||
# (Optional) Enable checking for dependencies in issues.
|
||||
# Enable by setting the value to "on". Default "off"
|
||||
check_issues: on
|
||||
|
||||
# (Optional) A comma-separated list of keywords. Default
|
||||
# "depends on, blocked by"
|
||||
keywords: depends on, blocked by
|
||||
|
||||
# (Optional) A custom comment body. It supports `{{ dependencies }}` token.
|
||||
comment: >
|
||||
This PR/issue depends on:
|
||||
|
||||
{{ dependencies }}
|
||||
6
.github/workflows/release.yml
vendored
6
.github/workflows/release.yml
vendored
@@ -19,12 +19,12 @@ jobs:
|
||||
discussions: write
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-java@v4
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/setup-java@v5
|
||||
with:
|
||||
distribution: temurin
|
||||
java-version: 21
|
||||
- uses: gradle/actions/setup-gradle@v4
|
||||
- uses: gradle/actions/setup-gradle@v5
|
||||
|
||||
- name: Prepare keystore
|
||||
run: echo ${{ secrets.android_keystore_base64 }} | base64 -d >$GITHUB_WORKSPACE/keystore.jks
|
||||
|
||||
107
.github/workflows/test-dev.yml
vendored
107
.github/workflows/test-dev.yml
vendored
@@ -9,74 +9,125 @@ concurrency:
|
||||
group: test-dev-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
# We provide a remote gradle build cache. Take the settings from the secrets and enable
|
||||
# configuration and build cache for all gradle jobs.
|
||||
env:
|
||||
GRADLE_BUILDCACHE_URL: ${{ secrets.gradle_buildcache_url }}
|
||||
GRADLE_BUILDCACHE_USERNAME: ${{ secrets.gradle_buildcache_username }}
|
||||
GRADLE_BUILDCACHE_PASSWORD: ${{ secrets.gradle_buildcache_password }}
|
||||
GRADLE_OPTS: -Dorg.gradle.caching=true -Dorg.gradle.configuration-cache=true
|
||||
|
||||
jobs:
|
||||
compile:
|
||||
name: Compile for build cache
|
||||
if: ${{ github.ref == 'refs/heads/main-ose' }}
|
||||
name: Compile
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-java@v4
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/setup-java@v5
|
||||
with:
|
||||
distribution: temurin
|
||||
java-version: 21
|
||||
|
||||
# See https://community.gradle.org/github-actions/docs/setup-gradle/ for more information
|
||||
- uses: gradle/actions/setup-gradle@v4 # creates build cache when on main branch
|
||||
- uses: gradle/actions/setup-gradle@v5
|
||||
with:
|
||||
cache-encryption-key: ${{ secrets.gradle_encryption_key }}
|
||||
dependency-graph: generate-and-submit # submit Github Dependency Graph info
|
||||
cache-read-only: false # allow branches to update their configuration cache
|
||||
gradle-home-cache-excludes: caches/build-cache-1 # don't cache local build cache because we use a remote cache
|
||||
|
||||
- run: ./gradlew --build-cache --configuration-cache app:compileOseDebugSource
|
||||
- name: Cache Android environment
|
||||
uses: actions/cache@v5
|
||||
with:
|
||||
path: ~/.config/.android # needs to be cached so that configuration cache can work
|
||||
key: android-${{ hashFiles('app/build.gradle.kts') }}
|
||||
|
||||
test:
|
||||
- name: Compile
|
||||
run: ./gradlew app:compileOseDebugSource
|
||||
|
||||
# Cache configurations for the other jobs
|
||||
- name: Populate configuration cache
|
||||
run: |
|
||||
./gradlew --dry-run app:lintOseDebug
|
||||
./gradlew --dry-run app:testOseDebugUnitTest
|
||||
./gradlew --dry-run app:virtualOseDebugAndroidTest
|
||||
|
||||
unit_tests:
|
||||
needs: compile
|
||||
if: ${{ !cancelled() }} # even if compile didn't run (because not on main branch)
|
||||
name: Lint and unit tests
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-java@v4
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/setup-java@v5
|
||||
with:
|
||||
distribution: temurin
|
||||
java-version: 21
|
||||
- uses: gradle/actions/setup-gradle@v4
|
||||
- uses: gradle/actions/setup-gradle@v5
|
||||
with:
|
||||
cache-encryption-key: ${{ secrets.gradle_encryption_key }}
|
||||
cache-read-only: true
|
||||
|
||||
- name: Run lint
|
||||
run: ./gradlew --build-cache --configuration-cache app:lintOseDebug
|
||||
- name: Run unit tests
|
||||
run: ./gradlew --build-cache --configuration-cache app:testOseDebugUnitTest
|
||||
- name: Restore Android environment
|
||||
uses: actions/cache/restore@v5
|
||||
with:
|
||||
path: ~/.config/.android
|
||||
key: android-${{ hashFiles('app/build.gradle.kts') }}
|
||||
|
||||
test_on_emulator:
|
||||
- name: Lint checks
|
||||
run: ./gradlew app:lintOseDebug
|
||||
|
||||
- name: Unit tests
|
||||
run: ./gradlew app:testOseDebugUnitTest
|
||||
|
||||
instrumented_tests:
|
||||
needs: compile
|
||||
if: ${{ !cancelled() }} # even if compile didn't run (because not on main branch)
|
||||
name: Instrumented tests
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-java@v4
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/setup-java@v5
|
||||
with:
|
||||
distribution: temurin
|
||||
java-version: 21
|
||||
- uses: gradle/actions/setup-gradle@v4
|
||||
- uses: gradle/actions/setup-gradle@v5
|
||||
with:
|
||||
cache-encryption-key: ${{ secrets.gradle_encryption_key }}
|
||||
cache-read-only: true
|
||||
|
||||
- name: Restore Android environment
|
||||
uses: actions/cache/restore@v5
|
||||
with:
|
||||
path: ~/.config/.android
|
||||
key: android-${{ hashFiles('app/build.gradle.kts') }}
|
||||
|
||||
# gradle and Android SDK often take more space than what is available on the default runner.
|
||||
# We try to free a few GB here to make gradle-managed devices more reliable.
|
||||
- name: Free some disk space
|
||||
uses: jlumbroso/free-disk-space@main
|
||||
with:
|
||||
android: false # we need the Android SDK
|
||||
large-packages: false # apt takes too long
|
||||
swap-storage: false # gradle needs much memory
|
||||
|
||||
- name: Restore AVD
|
||||
id: restore-avd
|
||||
uses: actions/cache/restore@v5
|
||||
with:
|
||||
path: ~/.config/.android/avd # where AVD is stored
|
||||
key: avd-${{ hashFiles('app/build.gradle.kts') }} # gradle-managed devices are defined there
|
||||
|
||||
# Enable virtualization for Android emulator
|
||||
- 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
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: ~/.config/.android/avd
|
||||
key: avd-${{ hashFiles('app/build.gradle.kts') }} # gradle-managed devices are defined there
|
||||
- name: Instrumented tests
|
||||
run: ./gradlew app:virtualOseDebugAndroidTest
|
||||
|
||||
- name: Run device tests
|
||||
run: ./gradlew --build-cache --configuration-cache app:virtualCheck
|
||||
- name: Cache AVD
|
||||
uses: actions/cache/save@v5
|
||||
if: steps.restore-avd.outputs.cache-hit != 'true'
|
||||
with:
|
||||
path: ~/.config/.android/avd # where AVD is stored
|
||||
key: avd-${{ hashFiles('app/build.gradle.kts') }} # gradle-managed devices are defined there
|
||||
|
||||
4
.gitignore
vendored
4
.gitignore
vendored
@@ -16,10 +16,6 @@
|
||||
bin/
|
||||
gen/
|
||||
|
||||
# Gradle files
|
||||
.gradle/
|
||||
build/
|
||||
|
||||
# Local configuration file (sdk path, etc)
|
||||
local.properties
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[main]
|
||||
host = https://www.transifex.com
|
||||
lang_map = ar_SA: ar, en_GB: en-rGB, fa_IR: fa-rIR, fi_FI: fi, nb_NO: nb, sk_SK: sk, sl_SI: sl, tr_TR: tr, zh_CN: zh, zh_TW: zh-rTW
|
||||
lang_map = ar_SA: ar, en_GB: en-rGB, fa_IR: fa-rIR, fi_FI: fi, nb_NO: nb, pt_BR: pt-rBR, sk_SK: sk, sl_SI: sl, tr_TR: tr, zh_CN: zh, zh_TW: zh-rTW
|
||||
|
||||
[o:bitfireAT:p:davx5:r:app]
|
||||
file_filter = app/src/main/res/values-<lang>/strings.xml
|
||||
|
||||
@@ -7,9 +7,9 @@ plugins {
|
||||
alias(libs.plugins.compose.compiler)
|
||||
alias(libs.plugins.hilt)
|
||||
alias(libs.plugins.kotlin.android)
|
||||
alias(libs.plugins.kotlin.serialization)
|
||||
alias(libs.plugins.ksp)
|
||||
|
||||
alias(libs.plugins.mikepenz.aboutLibraries)
|
||||
alias(libs.plugins.mikepenz.aboutLibraries.android)
|
||||
}
|
||||
|
||||
// Android configuration
|
||||
@@ -19,15 +19,16 @@ android {
|
||||
defaultConfig {
|
||||
applicationId = "at.bitfire.davdroid"
|
||||
|
||||
versionCode = 405020002
|
||||
versionName = "4.5.2"
|
||||
versionCode = 405080000
|
||||
versionName = "4.5.8-alpha.1"
|
||||
|
||||
setProperty("archivesBaseName", "davx5-ose-$versionName")
|
||||
base.archivesName = "davx5-ose-$versionName"
|
||||
|
||||
minSdk = 24 // Android 7.0
|
||||
targetSdk = 36 // Android 16
|
||||
|
||||
buildConfigField("boolean", "customCertsUI", "true")
|
||||
// whether the build supports and allows to use custom certificates
|
||||
buildConfigField("boolean", "allowCustomCerts", "true")
|
||||
|
||||
testInstrumentationRunner = "at.bitfire.davdroid.HiltTestRunner"
|
||||
}
|
||||
@@ -123,8 +124,10 @@ ksp {
|
||||
}
|
||||
|
||||
aboutLibraries {
|
||||
// exclude timestamps for reproducible builds [https://github.com/bitfireAT/davx5-ose/issues/994]
|
||||
excludeFields = arrayOf("generated")
|
||||
export {
|
||||
// exclude timestamps for reproducible builds [https://github.com/bitfireAT/davx5-ose/issues/994]
|
||||
excludeFields.add("generated")
|
||||
}
|
||||
}
|
||||
|
||||
dependencies {
|
||||
@@ -184,11 +187,14 @@ dependencies {
|
||||
}
|
||||
|
||||
// third-party libs
|
||||
@Suppress("RedundantSuppression")
|
||||
implementation(libs.conscrypt)
|
||||
implementation(libs.dnsjava)
|
||||
implementation(libs.guava)
|
||||
implementation(libs.mikepenz.aboutLibraries)
|
||||
implementation(libs.nsk90.kstatemachine)
|
||||
implementation(libs.ktor.client.content.negotiation)
|
||||
implementation(libs.ktor.client.core)
|
||||
implementation(libs.ktor.client.okhttp)
|
||||
implementation(libs.ktor.serialization.kotlinx.json)
|
||||
implementation(libs.mikepenz.aboutLibraries.m3)
|
||||
implementation(libs.okhttp.base)
|
||||
implementation(libs.okhttp.brotli)
|
||||
implementation(libs.okhttp.logging)
|
||||
@@ -223,4 +229,5 @@ dependencies {
|
||||
testImplementation(libs.junit)
|
||||
testImplementation(libs.mockk)
|
||||
testImplementation(libs.okhttp.mockwebserver)
|
||||
testImplementation(libs.robolectric)
|
||||
}
|
||||
|
||||
@@ -24,3 +24,8 @@
|
||||
-dontwarn sun.net.spi.nameservice.NameService
|
||||
-dontwarn sun.net.spi.nameservice.NameServiceDescriptor
|
||||
-dontwarn org.xbill.DNS.spi.DnsjavaInetAddressResolverProvider
|
||||
|
||||
# okhttp
|
||||
# https://github.com/bitfireAT/davx5/issues/711 / https://github.com/square/okhttp/issues/8574
|
||||
-keep class okhttp3.internal.idn.IdnaMappingTable { *; }
|
||||
-keep class okhttp3.internal.idn.IdnaMappingTableInstanceKt{ *; }
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:tools="http://schemas.android.com/tools"
|
||||
android:installLocation="internalOnly">
|
||||
|
||||
<!-- account management permissions not required for own accounts since API level 22 -->
|
||||
@@ -7,4 +8,9 @@
|
||||
<uses-permission android:name="android.permission.GET_ACCOUNTS" android:maxSdkVersion="22"/>
|
||||
<uses-permission android:name="android.permission.MANAGE_ACCOUNTS" android:maxSdkVersion="22"/>
|
||||
|
||||
<!--
|
||||
Since Mockk 1.14.7 it's required to use minSdk 26. We use 24, so override for tests.
|
||||
-->
|
||||
<uses-sdk tools:overrideLibrary="io.mockk.android,io.mockk.proxy.android" />
|
||||
|
||||
</manifest>
|
||||
@@ -1,20 +0,0 @@
|
||||
/*
|
||||
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
|
||||
*/
|
||||
|
||||
package at.bitfire.davdroid
|
||||
|
||||
import android.util.Xml
|
||||
import at.bitfire.dav4jvm.XmlUtils
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class Dav4jvmTest {
|
||||
|
||||
@Test
|
||||
fun test_Dav4jvm_XmlUtils_NewPullParser_RelaxedParsing() {
|
||||
val parser = XmlUtils.newPullParser()
|
||||
assertTrue(parser.getFeature(Xml.FEATURE_RELAXED))
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
/*
|
||||
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
|
||||
*/
|
||||
|
||||
package at.bitfire.davdroid
|
||||
|
||||
import android.util.Xml
|
||||
import at.bitfire.dav4jvm.XmlUtils
|
||||
import okhttp3.HttpUrl.Companion.toHttpUrl
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class ExternalLibrariesTest {
|
||||
|
||||
@Test
|
||||
fun test_Dav4jvm_XmlUtils_NewPullParser_RelaxedParsing() {
|
||||
val parser = XmlUtils.newPullParser()
|
||||
assertTrue(parser.getFeature(Xml.FEATURE_RELAXED))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testOkhttpHttpUrl_PublicSuffixList() {
|
||||
// HttpUrl.topPrivateDomain() requires okhttp's internal PublicSuffixList.
|
||||
// In Android, loading the PublicSuffixList is done over AndroidX startup.
|
||||
// This test verifies that everything is working.
|
||||
assertEquals("example.com", "http://example.com".toHttpUrl().topPrivateDomain())
|
||||
}
|
||||
|
||||
}
|
||||
@@ -10,7 +10,6 @@ import android.os.Build
|
||||
import android.os.Bundle
|
||||
import androidx.test.runner.AndroidJUnitRunner
|
||||
import at.bitfire.davdroid.di.TestCoroutineDispatchersModule
|
||||
import at.bitfire.davdroid.sync.SyncAdapterService
|
||||
import at.bitfire.davdroid.test.BuildConfig
|
||||
import at.bitfire.synctools.log.LogcatHandler
|
||||
import dagger.hilt.android.testing.HiltTestApplication
|
||||
@@ -36,9 +35,6 @@ class HiltTestRunner : AndroidJUnitRunner() {
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.P)
|
||||
throw AssertionError("MockK requires Android P [https://mockk.io/ANDROID.html]")
|
||||
|
||||
// disable sync adapters
|
||||
SyncAdapterService.syncActive.set(false)
|
||||
|
||||
// set main dispatcher for tests (especially runTest)
|
||||
TestCoroutineDispatchersModule.initMainDispatcher()
|
||||
}
|
||||
|
||||
@@ -6,15 +6,15 @@ package at.bitfire.davdroid.db
|
||||
|
||||
import android.security.NetworkSecurityPolicy
|
||||
import androidx.test.filters.SmallTest
|
||||
import at.bitfire.dav4jvm.DavResource
|
||||
import at.bitfire.dav4jvm.property.webdav.ResourceType
|
||||
import at.bitfire.davdroid.network.HttpClient
|
||||
import at.bitfire.dav4jvm.okhttp.DavResource
|
||||
import at.bitfire.dav4jvm.property.webdav.WebDAV
|
||||
import at.bitfire.davdroid.network.HttpClientBuilder
|
||||
import dagger.hilt.android.testing.HiltAndroidRule
|
||||
import dagger.hilt.android.testing.HiltAndroidTest
|
||||
import okhttp3.HttpUrl.Companion.toHttpUrl
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.mockwebserver.MockResponse
|
||||
import okhttp3.mockwebserver.MockWebServer
|
||||
import org.junit.After
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertNull
|
||||
@@ -29,12 +29,12 @@ import javax.inject.Inject
|
||||
class CollectionTest {
|
||||
|
||||
@Inject
|
||||
lateinit var httpClientBuilder: HttpClient.Builder
|
||||
lateinit var httpClientBuilder: HttpClientBuilder
|
||||
|
||||
@get:Rule
|
||||
val hiltRule = HiltAndroidRule(this)
|
||||
|
||||
private lateinit var httpClient: HttpClient
|
||||
private lateinit var httpClient: OkHttpClient
|
||||
private val server = MockWebServer()
|
||||
|
||||
@Before
|
||||
@@ -45,11 +45,6 @@ class CollectionTest {
|
||||
Assume.assumeTrue(NetworkSecurityPolicy.getInstance().isCleartextTrafficPermitted)
|
||||
}
|
||||
|
||||
@After
|
||||
fun teardown() {
|
||||
httpClient.close()
|
||||
}
|
||||
|
||||
|
||||
@Test
|
||||
@SmallTest
|
||||
@@ -69,8 +64,8 @@ class CollectionTest {
|
||||
"</multistatus>"))
|
||||
|
||||
lateinit var info: Collection
|
||||
DavResource(httpClient.okHttpClient, server.url("/"))
|
||||
.propfind(0, ResourceType.NAME) { response, _ ->
|
||||
DavResource(httpClient, server.url("/"))
|
||||
.propfind(0, WebDAV.ResourceType) { response, _ ->
|
||||
info = Collection.fromDavResponse(response) ?: throw IllegalArgumentException()
|
||||
}
|
||||
assertEquals(Collection.TYPE_ADDRESSBOOK, info.type)
|
||||
@@ -125,8 +120,8 @@ class CollectionTest {
|
||||
"</multistatus>"))
|
||||
|
||||
lateinit var info: Collection
|
||||
DavResource(httpClient.okHttpClient, server.url("/"))
|
||||
.propfind(0, ResourceType.NAME) { response, _ ->
|
||||
DavResource(httpClient, server.url("/"))
|
||||
.propfind(0, WebDAV.ResourceType) { response, _ ->
|
||||
info = Collection.fromDavResponse(response)!!
|
||||
}
|
||||
assertEquals(Collection.TYPE_CALENDAR, info.type)
|
||||
@@ -161,8 +156,8 @@ class CollectionTest {
|
||||
"</multistatus>"))
|
||||
|
||||
lateinit var info: Collection
|
||||
DavResource(httpClient.okHttpClient, server.url("/"))
|
||||
.propfind(0, ResourceType.NAME) { response, _ ->
|
||||
DavResource(httpClient, server.url("/"))
|
||||
.propfind(0, WebDAV.ResourceType) { response, _ ->
|
||||
info = Collection.fromDavResponse(response)!!
|
||||
}
|
||||
assertEquals(Collection.TYPE_CALENDAR, info.type)
|
||||
@@ -195,8 +190,8 @@ class CollectionTest {
|
||||
"</multistatus>"))
|
||||
|
||||
lateinit var info: Collection
|
||||
DavResource(httpClient.okHttpClient, server.url("/"))
|
||||
.propfind(0, ResourceType.NAME) { response, _ ->
|
||||
DavResource(httpClient, server.url("/"))
|
||||
.propfind(0, WebDAV.ResourceType) { response, _ ->
|
||||
info = Collection.fromDavResponse(response) ?: throw IllegalArgumentException()
|
||||
}
|
||||
assertEquals(Collection.TYPE_WEBCAL, info.type)
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
/*
|
||||
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
|
||||
*/
|
||||
|
||||
package at.bitfire.davdroid.db
|
||||
|
||||
import android.database.sqlite.SQLiteConstraintException
|
||||
import dagger.hilt.android.testing.HiltAndroidRule
|
||||
import dagger.hilt.android.testing.HiltAndroidTest
|
||||
import io.mockk.junit4.MockKRule
|
||||
import io.mockk.spyk
|
||||
import io.mockk.verify
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import okhttp3.HttpUrl.Companion.toHttpUrl
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Before
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import javax.inject.Inject
|
||||
|
||||
|
||||
@HiltAndroidTest
|
||||
class PrincipalDaoTest {
|
||||
|
||||
@get:Rule
|
||||
val hiltRule = HiltAndroidRule(this)
|
||||
|
||||
@get:Rule
|
||||
val mockKRule = MockKRule(this)
|
||||
|
||||
@Inject
|
||||
lateinit var db: AppDatabase
|
||||
|
||||
private lateinit var principalDao: PrincipalDao
|
||||
private lateinit var service: Service
|
||||
private val url = "https://example.com/dav/principal".toHttpUrl()
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
hiltRule.inject()
|
||||
principalDao = spyk(db.principalDao())
|
||||
|
||||
service = Service(id = 1, accountName = "account", type = "webdav")
|
||||
db.serviceDao().insertOrReplace(service)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun insertOrUpdate_insertsIfNotExisting() = runTest {
|
||||
val principal = Principal(serviceId = service.id, url = url, displayName = "principal")
|
||||
val id = principalDao.insertOrUpdate(service.id, principal)
|
||||
assertTrue(id > 0)
|
||||
|
||||
val stored = principalDao.get(id)
|
||||
assertEquals("principal", stored.displayName)
|
||||
verify(exactly = 0) { principalDao.update(any()) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun insertOrUpdate_doesNotUpdateIfDisplayNameIsEqual() = runTest {
|
||||
val principalOld = Principal(serviceId = service.id, url = url, displayName = "principalOld")
|
||||
val idOld = principalDao.insertOrUpdate(service.id, principalOld)
|
||||
|
||||
val principalNew = Principal(serviceId = service.id, url = url, displayName = "principalOld")
|
||||
val idNew = principalDao.insertOrUpdate(service.id, principalNew)
|
||||
|
||||
assertEquals(idOld, idNew)
|
||||
val stored = principalDao.get(idOld)
|
||||
assertEquals("principalOld", stored.displayName)
|
||||
verify(exactly = 0) { principalDao.update(any()) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun insertOrUpdate_updatesIfDisplayNameIsDifferent() = runTest {
|
||||
val principalOld = Principal(serviceId = service.id, url = url, displayName = "principalOld")
|
||||
val idOld = principalDao.insertOrUpdate(service.id, principalOld)
|
||||
|
||||
val principalNew = Principal(serviceId = service.id, url = url, displayName = "principalNew")
|
||||
val idNew = principalDao.insertOrUpdate(service.id, principalNew)
|
||||
|
||||
assertEquals(idOld, idNew)
|
||||
|
||||
val updated = principalDao.get(idOld)
|
||||
assertEquals("principalNew", updated.displayName)
|
||||
verify(exactly = 1) { principalDao.update(any()) }
|
||||
}
|
||||
|
||||
@Test(expected = SQLiteConstraintException::class)
|
||||
fun insertOrUpdate_throwsForeignKeyConstraintViolationException() = runTest {
|
||||
// throws on non-existing service
|
||||
val url = "https://example.com/dav/principal".toHttpUrl()
|
||||
val principal1 = Principal(serviceId = 999, url = url, displayName = "p1")
|
||||
principalDao.insertOrUpdate(999, principal1)
|
||||
}
|
||||
|
||||
}
|
||||
@@ -44,6 +44,11 @@ abstract class DatabaseMigrationTest(
|
||||
/**
|
||||
* Used for testing the migration process from [toVersion]-1 to [toVersion].
|
||||
*
|
||||
* Note: SQLite's foreign key constraint enforcement is not enabled in tests. We need
|
||||
* to enable it ourselves using setting "PRAGMA foreign_keys=ON" directly after opening
|
||||
* a new database connection (works per connection). In tests it's usually more practical
|
||||
* not to do so, however. In production database connections room enables it for us.
|
||||
*
|
||||
* @param prepare Callback to prepare the database. Will be run with database schema in version [toVersion] - 1.
|
||||
* @param validate Callback to validate the migration result. Will be run with database schema in version [toVersion].
|
||||
*/
|
||||
@@ -61,6 +66,8 @@ abstract class DatabaseMigrationTest(
|
||||
// Prepare the database with the initial version.
|
||||
val dbName = "test"
|
||||
helper.createDatabase(dbName, version = toVersion - 1).apply {
|
||||
// We could enable foreign key constraint enforcement here
|
||||
// by setting "PRAGMA foreign_keys=ON".
|
||||
prepare(this)
|
||||
close()
|
||||
}
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
/*
|
||||
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
|
||||
*/
|
||||
|
||||
package at.bitfire.davdroid.di
|
||||
|
||||
import at.bitfire.davdroid.sync.FakeSyncAdapter
|
||||
import at.bitfire.davdroid.sync.adapter.SyncAdapter
|
||||
import at.bitfire.davdroid.sync.adapter.SyncAdapterImpl
|
||||
import dagger.Binds
|
||||
import dagger.Module
|
||||
import dagger.hilt.components.SingletonComponent
|
||||
import dagger.hilt.testing.TestInstallIn
|
||||
|
||||
@Module
|
||||
@TestInstallIn(components = [SingletonComponent::class], replaces = [SyncAdapterImpl.RealSyncAdapterModule::class])
|
||||
abstract class FakeSyncAdapterModule {
|
||||
@Binds
|
||||
abstract fun provide(impl: FakeSyncAdapter): SyncAdapter
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
/*
|
||||
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
|
||||
*/
|
||||
|
||||
package at.bitfire.davdroid.network
|
||||
|
||||
import org.conscrypt.Conscrypt
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
import java.security.Security
|
||||
|
||||
class ConscryptIntegrationTest {
|
||||
|
||||
val integration = ConscryptIntegration()
|
||||
|
||||
@Test
|
||||
fun testInitialize_InstallsConscrypt() {
|
||||
uninstallConscrypt()
|
||||
assertFalse(integration.conscryptInstalled())
|
||||
|
||||
integration.initialize()
|
||||
assertTrue(integration.conscryptInstalled())
|
||||
}
|
||||
|
||||
private fun uninstallConscrypt() {
|
||||
for (conscrypt in Security.getProviders().filter { Conscrypt.isConscrypt(it) })
|
||||
Security.removeProvider(conscrypt.name)
|
||||
}
|
||||
|
||||
}
|
||||
@@ -7,6 +7,9 @@ package at.bitfire.davdroid.network
|
||||
import android.security.NetworkSecurityPolicy
|
||||
import dagger.hilt.android.testing.HiltAndroidRule
|
||||
import dagger.hilt.android.testing.HiltAndroidTest
|
||||
import io.ktor.client.request.get
|
||||
import io.ktor.client.statement.bodyAsText
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import okhttp3.Request
|
||||
import okhttp3.mockwebserver.MockResponse
|
||||
import okhttp3.mockwebserver.MockWebServer
|
||||
@@ -19,25 +22,23 @@ import org.junit.Before
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Provider
|
||||
|
||||
@HiltAndroidTest
|
||||
class HttpClientTest {
|
||||
class HttpClientBuilderTest {
|
||||
|
||||
@get:Rule
|
||||
var hiltRule = HiltAndroidRule(this)
|
||||
|
||||
@Inject
|
||||
lateinit var httpClientBuilder: HttpClient.Builder
|
||||
lateinit var httpClientBuilder: Provider<HttpClientBuilder>
|
||||
|
||||
lateinit var httpClient: HttpClient
|
||||
lateinit var server: MockWebServer
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
hiltRule.inject()
|
||||
|
||||
httpClient = httpClientBuilder.build()
|
||||
|
||||
server = MockWebServer()
|
||||
server.start(30000)
|
||||
}
|
||||
@@ -45,10 +46,22 @@ class HttpClientTest {
|
||||
@After
|
||||
fun tearDown() {
|
||||
server.shutdown()
|
||||
httpClient.close()
|
||||
}
|
||||
|
||||
|
||||
@Test
|
||||
fun testBuildKtor_CreatesWorkingClient() = runTest {
|
||||
server.enqueue(MockResponse()
|
||||
.setResponseCode(200)
|
||||
.setBody("Some Content"))
|
||||
|
||||
httpClientBuilder.get().buildKtor().use { client ->
|
||||
val response = client.get(server.url("/").toString())
|
||||
assertEquals(200, response.status.value)
|
||||
assertEquals("Some Content", response.bodyAsText())
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testCookies() {
|
||||
Assume.assumeTrue(NetworkSecurityPolicy.getInstance().isCleartextTrafficPermitted)
|
||||
@@ -60,7 +73,9 @@ class HttpClientTest {
|
||||
.addHeader("Set-Cookie", "cookie1=1; path=/")
|
||||
.addHeader("Set-Cookie", "cookie2=2")
|
||||
.setBody("Cookie set"))
|
||||
httpClient.okHttpClient.newCall(Request.Builder()
|
||||
|
||||
val httpClient = httpClientBuilder.get().build()
|
||||
httpClient.newCall(Request.Builder()
|
||||
.get().url(url)
|
||||
.build()).execute()
|
||||
assertNull(server.takeRequest().getHeader("Cookie"))
|
||||
@@ -71,7 +86,7 @@ class HttpClientTest {
|
||||
.addHeader("Set-Cookie", "cookie1=1a; path=/; Max-Age=0")
|
||||
.addHeader("Set-Cookie", "cookie2=2a")
|
||||
.setResponseCode(200))
|
||||
httpClient.okHttpClient.newCall(Request.Builder()
|
||||
httpClient.newCall(Request.Builder()
|
||||
.get().url(url)
|
||||
.build()).execute()
|
||||
val header = server.takeRequest().getHeader("Cookie")
|
||||
@@ -79,7 +94,7 @@ class HttpClientTest {
|
||||
|
||||
server.enqueue(MockResponse()
|
||||
.setResponseCode(200))
|
||||
httpClient.okHttpClient.newCall(Request.Builder()
|
||||
httpClient.newCall(Request.Builder()
|
||||
.get().url(url)
|
||||
.build()).execute()
|
||||
assertEquals("cookie2=2a", server.takeRequest().getHeader("Cookie"))
|
||||
@@ -17,7 +17,7 @@ import javax.inject.Inject
|
||||
class OkhttpClientTest {
|
||||
|
||||
@Inject
|
||||
lateinit var httpClientBuilder: HttpClient.Builder
|
||||
lateinit var httpClientBuilder: HttpClientBuilder
|
||||
|
||||
@get:Rule
|
||||
val hiltRule = HiltAndroidRule(this)
|
||||
@@ -31,16 +31,15 @@ class OkhttpClientTest {
|
||||
@Test
|
||||
@SdkSuppress(maxSdkVersion = 34)
|
||||
fun testIcloudWithSettings() {
|
||||
httpClientBuilder.build().use { client ->
|
||||
client.okHttpClient
|
||||
.newCall(
|
||||
Request.Builder()
|
||||
.get()
|
||||
.url("https://icloud.com")
|
||||
.build()
|
||||
)
|
||||
.execute()
|
||||
}
|
||||
val client = httpClientBuilder.build()
|
||||
client
|
||||
.newCall(
|
||||
Request.Builder()
|
||||
.get()
|
||||
.url("https://icloud.com")
|
||||
.build()
|
||||
)
|
||||
.execute()
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,214 @@
|
||||
/*
|
||||
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
|
||||
*/
|
||||
|
||||
package at.bitfire.davdroid.repository
|
||||
|
||||
import android.accounts.Account
|
||||
import android.accounts.AccountManager
|
||||
import android.content.Context
|
||||
import androidx.hilt.work.HiltWorkerFactory
|
||||
import at.bitfire.davdroid.R
|
||||
import at.bitfire.davdroid.TestUtils
|
||||
import at.bitfire.davdroid.resource.LocalAddressBookStore
|
||||
import at.bitfire.davdroid.resource.LocalCalendarStore
|
||||
import at.bitfire.davdroid.resource.LocalDataStore
|
||||
import at.bitfire.davdroid.settings.AccountSettings
|
||||
import at.bitfire.davdroid.sync.AutomaticSyncManager
|
||||
import at.bitfire.davdroid.sync.SyncDataType
|
||||
import at.bitfire.davdroid.sync.TasksAppManager
|
||||
import at.bitfire.davdroid.sync.account.AccountsCleanupWorker
|
||||
import at.bitfire.davdroid.sync.account.TestAccount
|
||||
import at.bitfire.davdroid.sync.worker.SyncWorkerManager
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import dagger.hilt.android.testing.BindValue
|
||||
import dagger.hilt.android.testing.HiltAndroidRule
|
||||
import dagger.hilt.android.testing.HiltAndroidTest
|
||||
import io.mockk.clearAllMocks
|
||||
import io.mockk.coVerify
|
||||
import io.mockk.every
|
||||
import io.mockk.impl.annotations.MockK
|
||||
import io.mockk.junit4.MockKRule
|
||||
import io.mockk.mockk
|
||||
import io.mockk.mockkObject
|
||||
import io.mockk.unmockkObject
|
||||
import io.mockk.verify
|
||||
import junit.framework.TestCase.assertTrue
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.After
|
||||
import org.junit.Before
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import javax.inject.Inject
|
||||
|
||||
@HiltAndroidTest
|
||||
class AccountRepositoryTest {
|
||||
|
||||
@get:Rule
|
||||
val hiltRule = HiltAndroidRule(this)
|
||||
|
||||
@get:Rule
|
||||
val mockKRule = MockKRule(this)
|
||||
|
||||
// System under test
|
||||
|
||||
@Inject
|
||||
lateinit var accountRepository: AccountRepository
|
||||
|
||||
// Real injections
|
||||
|
||||
@Inject
|
||||
@ApplicationContext
|
||||
lateinit var context: Context
|
||||
|
||||
@Inject
|
||||
lateinit var accountSettingsFactory: AccountSettings.Factory
|
||||
|
||||
@Inject
|
||||
lateinit var workerFactory: HiltWorkerFactory
|
||||
|
||||
// Dependency overrides
|
||||
|
||||
@BindValue @MockK(relaxed = true)
|
||||
lateinit var automaticSyncManager: AutomaticSyncManager
|
||||
|
||||
@BindValue @MockK(relaxed = true)
|
||||
lateinit var localAddressBookStore: LocalAddressBookStore
|
||||
|
||||
@BindValue @MockK(relaxed = true)
|
||||
lateinit var localCalendarStore: LocalCalendarStore
|
||||
|
||||
@BindValue @MockK(relaxed = true)
|
||||
lateinit var serviceRepository: DavServiceRepository
|
||||
|
||||
@BindValue @MockK(relaxed = true)
|
||||
lateinit var syncWorkerManager: SyncWorkerManager
|
||||
|
||||
@BindValue @MockK(relaxed = true)
|
||||
lateinit var tasksAppManager: TasksAppManager
|
||||
|
||||
|
||||
// Account setup
|
||||
private val newName = "Renamed Account"
|
||||
lateinit var am: AccountManager
|
||||
lateinit var accountType: String
|
||||
lateinit var account: Account
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
hiltRule.inject()
|
||||
TestUtils.setUpWorkManager(context, workerFactory)
|
||||
|
||||
// Account setup
|
||||
am = AccountManager.get(context)
|
||||
accountType = context.getString(R.string.account_type)
|
||||
account = TestAccount.create()
|
||||
|
||||
// AccountsCleanupWorker static mocking
|
||||
mockkObject(AccountsCleanupWorker)
|
||||
every { AccountsCleanupWorker.lockAccountsCleanup() } returns Unit
|
||||
}
|
||||
|
||||
@After
|
||||
fun tearDown() {
|
||||
am.getAccountsByType(accountType).forEach { account ->
|
||||
am.removeAccountExplicitly(account)
|
||||
}
|
||||
|
||||
unmockkObject(AccountsCleanupWorker)
|
||||
clearAllMocks()
|
||||
}
|
||||
|
||||
|
||||
// testRename
|
||||
|
||||
@Test(expected = IllegalArgumentException::class)
|
||||
fun testRename_checksForAlreadyExisting() = runTest {
|
||||
val existing = Account("Existing Account", accountType)
|
||||
am.addAccountExplicitly(existing, null, null)
|
||||
|
||||
accountRepository.rename(account.name, existing.name)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testRename_locksAccountsCleanup() = runTest {
|
||||
accountRepository.rename(account.name, newName)
|
||||
|
||||
verify { AccountsCleanupWorker.lockAccountsCleanup() }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testRename_renamesAccountInAndroid() = runTest {
|
||||
accountRepository.rename(account.name, newName)
|
||||
|
||||
val accountsAfter = am.getAccountsByType(accountType)
|
||||
assertTrue(accountsAfter.any { it.name == newName })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testRename_cancelsRunningSynchronizationOfOldAccount() = runTest {
|
||||
accountRepository.rename(account.name, newName)
|
||||
|
||||
coVerify { syncWorkerManager.cancelAllWork(account) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testRename_disablesPeriodicSyncsForOldAccount() = runTest {
|
||||
accountRepository.rename(account.name, newName)
|
||||
|
||||
for (dataType in SyncDataType.entries)
|
||||
coVerify(exactly = 1) {
|
||||
syncWorkerManager.disablePeriodic(account, dataType)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testRename_updatesAccountNameReferencesInDatabase() = runTest {
|
||||
accountRepository.rename(account.name, newName)
|
||||
|
||||
coVerify { serviceRepository.renameAccount(account.name, newName) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testRename_updatesAddressBooks() = runTest {
|
||||
accountRepository.rename(account.name, newName)
|
||||
|
||||
val newAccount = accountRepository.fromName(newName)
|
||||
coVerify { localAddressBookStore.updateAccount(account, newAccount, any()) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testRename_updatesCalendarEvents() = runTest {
|
||||
accountRepository.rename(account.name, newName)
|
||||
|
||||
val newAccount = accountRepository.fromName(newName)
|
||||
coVerify { localCalendarStore.updateAccount(account, newAccount, any()) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testRename_updatesAccountNameOfLocalTasks() = runTest {
|
||||
val mockDataStore = mockk<LocalDataStore<*>>(relaxed = true)
|
||||
every { tasksAppManager.getDataStore() } returns mockDataStore
|
||||
accountRepository.rename(account.name, newName)
|
||||
|
||||
val newAccount = accountRepository.fromName(newName)
|
||||
coVerify { mockDataStore.updateAccount(account, newAccount, any()) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testRename_updatesAutomaticSync() = runTest {
|
||||
accountRepository.rename(account.name, newName)
|
||||
|
||||
val newAccount = accountRepository.fromName(newName)
|
||||
coVerify { automaticSyncManager.updateAutomaticSync(newAccount) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testRename_releasesAccountsCleanupWorkerMutex() = runTest {
|
||||
accountRepository.rename(account.name, newName)
|
||||
|
||||
verify { AccountsCleanupWorker.lockAccountsCleanup() }
|
||||
coVerify { serviceRepository.renameAccount(account.name, newName) }
|
||||
}
|
||||
|
||||
}
|
||||
@@ -29,6 +29,7 @@ import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import java.io.FileNotFoundException
|
||||
import java.util.LinkedList
|
||||
import java.util.Optional
|
||||
import javax.inject.Inject
|
||||
|
||||
@HiltAndroidTest
|
||||
@@ -98,7 +99,7 @@ class LocalAddressBookTest {
|
||||
val id = ContentUris.parseId(uri)
|
||||
|
||||
// make sure it's not dirty
|
||||
localGroup.clearDirty(null, null, null)
|
||||
localGroup.clearDirty(Optional.empty(), null, null)
|
||||
assertFalse("Group is dirty before moving", isGroupDirty(addressBook, id))
|
||||
|
||||
// rename address book
|
||||
@@ -127,7 +128,7 @@ class LocalAddressBookTest {
|
||||
*/
|
||||
fun isContactDirty(adddressBook: LocalAddressBook, id: Long): Boolean {
|
||||
val uri = ContentUris.withAppendedId(adddressBook.rawContactsSyncUri(), id)
|
||||
provider!!.query(uri, arrayOf(ContactsContract.RawContacts.DIRTY), null, null, null)?.use { cursor ->
|
||||
provider.query(uri, arrayOf(ContactsContract.RawContacts.DIRTY), null, null, null)?.use { cursor ->
|
||||
if (cursor.moveToFirst())
|
||||
return cursor.getInt(0) != 0
|
||||
}
|
||||
@@ -143,7 +144,7 @@ class LocalAddressBookTest {
|
||||
*/
|
||||
fun isGroupDirty(adddressBook: LocalAddressBook, id: Long): Boolean {
|
||||
val uri = ContentUris.withAppendedId(adddressBook.groupsSyncUri(), id)
|
||||
provider!!.query(uri, arrayOf(ContactsContract.Groups.DIRTY), null, null, null)?.use { cursor ->
|
||||
provider.query(uri, arrayOf(ContactsContract.Groups.DIRTY), null, null, null)?.use { cursor ->
|
||||
if (cursor.moveToFirst())
|
||||
return cursor.getInt(0) != 0
|
||||
}
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
/*
|
||||
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
|
||||
*/
|
||||
|
||||
package at.bitfire.davdroid.resource
|
||||
|
||||
import android.accounts.Account
|
||||
import android.content.ContentProviderClient
|
||||
import android.content.Context
|
||||
import android.net.Uri
|
||||
import android.provider.CalendarContract
|
||||
import android.provider.CalendarContract.Calendars
|
||||
import androidx.core.content.contentValuesOf
|
||||
import at.bitfire.davdroid.sync.account.TestAccount
|
||||
import at.bitfire.ical4android.util.MiscUtils.asSyncAdapter
|
||||
import at.bitfire.ical4android.util.MiscUtils.closeCompat
|
||||
import at.bitfire.synctools.test.InitCalendarProviderRule
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import dagger.hilt.android.testing.HiltAndroidRule
|
||||
import dagger.hilt.android.testing.HiltAndroidTest
|
||||
import org.junit.After
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assume
|
||||
import org.junit.Before
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.rules.TestRule
|
||||
import javax.inject.Inject
|
||||
|
||||
@HiltAndroidTest
|
||||
class LocalCalendarStoreTest {
|
||||
|
||||
@get:Rule
|
||||
val hiltRule = HiltAndroidRule(this)
|
||||
|
||||
@get:Rule
|
||||
val initCalendarProviderRule: TestRule = InitCalendarProviderRule.initialize()
|
||||
|
||||
@Inject @ApplicationContext
|
||||
lateinit var context: Context
|
||||
|
||||
@Inject
|
||||
lateinit var localCalendarStore: LocalCalendarStore
|
||||
|
||||
private lateinit var provider: ContentProviderClient
|
||||
private lateinit var account: Account
|
||||
private lateinit var calendarUri: Uri
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
hiltRule.inject()
|
||||
provider = context.contentResolver.acquireContentProviderClient(CalendarContract.AUTHORITY)!!
|
||||
account = TestAccount.create(accountName = "InitialAccountName")
|
||||
calendarUri = createCalendarForAccount(account)
|
||||
}
|
||||
|
||||
@After
|
||||
fun tearDown() {
|
||||
provider.delete(calendarUri, null, null)
|
||||
TestAccount.remove(account)
|
||||
provider.closeCompat()
|
||||
}
|
||||
|
||||
|
||||
@Test
|
||||
fun testUpdateAccount_updatesOwnerAccount() {
|
||||
// Verify initial state (assume to skip and prevent flaky test failures)
|
||||
Assume.assumeTrue("InitialAccountName" == getOwnerAccount())
|
||||
|
||||
// Rename account
|
||||
val oldAccount = account
|
||||
account = TestAccount.rename(account, "ChangedAccountName")
|
||||
|
||||
// Update account name in local calendar
|
||||
localCalendarStore.updateAccount(oldAccount, account, provider)
|
||||
|
||||
// Verify [Calendar.OWNER_ACCOUNT] of local calendar was updated
|
||||
assertEquals("ChangedAccountName", getOwnerAccount())
|
||||
|
||||
}
|
||||
|
||||
|
||||
// helpers
|
||||
|
||||
private fun createCalendarForAccount(account: Account): Uri =
|
||||
provider.insert(
|
||||
Calendars.CONTENT_URI.asSyncAdapter(account),
|
||||
contentValuesOf(
|
||||
Calendars.ACCOUNT_NAME to account.name,
|
||||
Calendars.ACCOUNT_TYPE to account.type,
|
||||
Calendars.OWNER_ACCOUNT to account.name,
|
||||
Calendars.VISIBLE to 1,
|
||||
Calendars.SYNC_EVENTS to 1,
|
||||
Calendars._SYNC_ID to 999,
|
||||
Calendars.CALENDAR_DISPLAY_NAME to "displayName",
|
||||
)
|
||||
)!!.asSyncAdapter(account)
|
||||
|
||||
private fun getOwnerAccount(): String {
|
||||
provider.query(
|
||||
calendarUri,
|
||||
arrayOf(Calendars.OWNER_ACCOUNT),
|
||||
"${Calendars.ACCOUNT_NAME}=?",
|
||||
arrayOf(account.name),
|
||||
null
|
||||
)!!.use { cursor ->
|
||||
cursor.moveToNext()
|
||||
return cursor.getString(0)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -6,26 +6,23 @@ package at.bitfire.davdroid.resource
|
||||
|
||||
import android.accounts.Account
|
||||
import android.content.ContentProviderClient
|
||||
import android.content.ContentUris
|
||||
import android.content.ContentValues
|
||||
import android.content.Entity
|
||||
import android.provider.CalendarContract
|
||||
import android.provider.CalendarContract.ACCOUNT_TYPE_LOCAL
|
||||
import android.provider.CalendarContract.Events
|
||||
import androidx.core.content.contentValuesOf
|
||||
import androidx.test.platform.app.InstrumentationRegistry
|
||||
import at.bitfire.ical4android.AndroidEvent
|
||||
import at.bitfire.ical4android.Event
|
||||
import at.bitfire.ical4android.util.MiscUtils.asSyncAdapter
|
||||
import at.bitfire.ical4android.util.MiscUtils.closeCompat
|
||||
import at.bitfire.synctools.storage.calendar.AndroidCalendar
|
||||
import at.bitfire.synctools.storage.calendar.AndroidCalendarProvider
|
||||
import at.bitfire.synctools.storage.calendar.EventsContract
|
||||
import at.bitfire.synctools.test.InitCalendarProviderRule
|
||||
import dagger.hilt.android.testing.HiltAndroidRule
|
||||
import dagger.hilt.android.testing.HiltAndroidTest
|
||||
import net.fortuna.ical4j.model.property.DtStart
|
||||
import net.fortuna.ical4j.model.property.RRule
|
||||
import net.fortuna.ical4j.model.property.RecurrenceId
|
||||
import net.fortuna.ical4j.model.property.Status
|
||||
import org.junit.After
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Before
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
@@ -45,6 +42,7 @@ class LocalCalendarTest {
|
||||
lateinit var localCalendarFactory: LocalCalendar.Factory
|
||||
|
||||
private val account = Account("LocalCalendarTest", ACCOUNT_TYPE_LOCAL)
|
||||
private lateinit var androidCalendar: AndroidCalendar
|
||||
private lateinit var client: ContentProviderClient
|
||||
private lateinit var calendar: LocalCalendar
|
||||
|
||||
@@ -56,92 +54,89 @@ class LocalCalendarTest {
|
||||
client = context.contentResolver.acquireContentProviderClient(CalendarContract.AUTHORITY)!!
|
||||
|
||||
val provider = AndroidCalendarProvider(account, client)
|
||||
calendar = localCalendarFactory.create(provider.createAndGetCalendar(ContentValues()))
|
||||
androidCalendar = provider.createAndGetCalendar(ContentValues())
|
||||
calendar = localCalendarFactory.create(androidCalendar)
|
||||
}
|
||||
|
||||
@After
|
||||
fun tearDown() {
|
||||
calendar.androidCalendar.delete()
|
||||
androidCalendar.delete()
|
||||
client.closeCompat()
|
||||
}
|
||||
|
||||
|
||||
@Test
|
||||
fun testDeleteDirtyEventsWithoutInstances_NoInstances_CancelledExceptions() {
|
||||
// create recurring event with only deleted/cancelled instances
|
||||
val event = Event().apply {
|
||||
dtStart = DtStart("20220120T010203Z")
|
||||
summary = "Event with 3 instances"
|
||||
rRules.add(RRule("FREQ=DAILY;COUNT=3"))
|
||||
exceptions.add(Event().apply {
|
||||
recurrenceId = RecurrenceId("20220120T010203Z")
|
||||
dtStart = DtStart("20220120T010203Z")
|
||||
summary = "Cancelled exception on 1st day"
|
||||
status = Status.VEVENT_CANCELLED
|
||||
})
|
||||
exceptions.add(Event().apply {
|
||||
recurrenceId = RecurrenceId("20220121T010203Z")
|
||||
dtStart = DtStart("20220121T010203Z")
|
||||
summary = "Cancelled exception on 2nd day"
|
||||
status = Status.VEVENT_CANCELLED
|
||||
})
|
||||
exceptions.add(Event().apply {
|
||||
recurrenceId = RecurrenceId("20220122T010203Z")
|
||||
dtStart = DtStart("20220122T010203Z")
|
||||
summary = "Cancelled exception on 3rd day"
|
||||
status = Status.VEVENT_CANCELLED
|
||||
})
|
||||
}
|
||||
val localEvent = AndroidEvent(calendar.androidCalendar, event, "filename.ics", null, null, LocalResource.FLAG_REMOTELY_PRESENT)
|
||||
localEvent.add()
|
||||
val eventId = localEvent.id!!
|
||||
/**
|
||||
* Verifies that [LocalCalendar.removeNotDirtyMarked] works as expected.
|
||||
* @param contentValues values to set on the event. Required:
|
||||
* - [Events._ID]
|
||||
* - [Events.DIRTY]
|
||||
*/
|
||||
private fun testRemoveNotDirtyMarked(contentValues: ContentValues) {
|
||||
val entity = Entity(
|
||||
contentValuesOf(
|
||||
Events.CALENDAR_ID to androidCalendar.id,
|
||||
Events.DTSTART to System.currentTimeMillis(),
|
||||
Events.DTEND to System.currentTimeMillis(),
|
||||
Events.TITLE to "Some Event",
|
||||
EventsContract.COLUMN_FLAGS to 123
|
||||
).apply { putAll(contentValues) }
|
||||
)
|
||||
val id = androidCalendar.addEvent(entity)
|
||||
|
||||
// set event as dirty
|
||||
client.update(ContentUris.withAppendedId(Events.CONTENT_URI.asSyncAdapter(account), eventId), ContentValues(1).apply {
|
||||
put(Events.DIRTY, 1)
|
||||
}, null, null)
|
||||
calendar.removeNotDirtyMarked(123)
|
||||
|
||||
// this method should mark the event as deleted
|
||||
calendar.deleteDirtyEventsWithoutInstances()
|
||||
|
||||
// verify that event is now marked as deleted
|
||||
client.query(
|
||||
ContentUris.withAppendedId(Events.CONTENT_URI.asSyncAdapter(account), eventId),
|
||||
arrayOf(Events.DELETED), null, null, null
|
||||
)!!.use { cursor ->
|
||||
cursor.moveToNext()
|
||||
assertEquals(1, cursor.getInt(0))
|
||||
}
|
||||
assertNull(androidCalendar.getEvent(id))
|
||||
}
|
||||
|
||||
@Test
|
||||
// Needs InitCalendarProviderRule
|
||||
fun testDeleteDirtyEventsWithoutInstances_Recurring_Instances() {
|
||||
val event = Event().apply {
|
||||
dtStart = DtStart("20220120T010203Z")
|
||||
summary = "Event with 3 instances"
|
||||
rRules.add(RRule("FREQ=DAILY;COUNT=3"))
|
||||
}
|
||||
val localEvent = AndroidEvent(calendar.androidCalendar, event, "filename.ics", null, null, LocalResource.FLAG_REMOTELY_PRESENT)
|
||||
localEvent.add()
|
||||
val eventId = localEvent.id!!
|
||||
fun testRemoveNotDirtyMarked_IdLargerThanIntMaxValue() = testRemoveNotDirtyMarked(
|
||||
contentValuesOf(Events._ID to Int.MAX_VALUE.toLong() + 10, Events.DIRTY to 0)
|
||||
)
|
||||
|
||||
// set event as dirty
|
||||
client.update(ContentUris.withAppendedId(Events.CONTENT_URI.asSyncAdapter(account), eventId), ContentValues(1).apply {
|
||||
put(Events.DIRTY, 1)
|
||||
}, null, null)
|
||||
@Test
|
||||
fun testRemoveNotDirtyMarked_DirtyIs0() = testRemoveNotDirtyMarked(
|
||||
contentValuesOf(Events._ID to 1, Events.DIRTY to 0)
|
||||
)
|
||||
|
||||
// this method should mark the event as deleted
|
||||
calendar.deleteDirtyEventsWithoutInstances()
|
||||
@Test
|
||||
fun testRemoveNotDirtyMarked_DirtyNull() = testRemoveNotDirtyMarked(
|
||||
contentValuesOf(Events._ID to 1, Events.DIRTY to null)
|
||||
)
|
||||
|
||||
// verify that event is not marked as deleted
|
||||
client.query(
|
||||
ContentUris.withAppendedId(Events.CONTENT_URI.asSyncAdapter(account), eventId),
|
||||
arrayOf(Events.DELETED), null, null, null
|
||||
)!!.use { cursor ->
|
||||
cursor.moveToNext()
|
||||
assertEquals(0, cursor.getInt(0))
|
||||
}
|
||||
/**
|
||||
* Verifies that [LocalCalendar.markNotDirty] works as expected.
|
||||
* @param contentValues values to set on the event. Required:
|
||||
* - [Events.DIRTY]
|
||||
*/
|
||||
private fun testMarkNotDirty(contentValues: ContentValues) {
|
||||
val id = androidCalendar.addEvent(Entity(
|
||||
contentValuesOf(
|
||||
Events.CALENDAR_ID to androidCalendar.id,
|
||||
Events._ID to 1,
|
||||
Events.DTSTART to System.currentTimeMillis(),
|
||||
Events.DTEND to System.currentTimeMillis(),
|
||||
Events.TITLE to "Some Event",
|
||||
EventsContract.COLUMN_FLAGS to 123
|
||||
).apply { putAll(contentValues) }
|
||||
))
|
||||
|
||||
val updated = calendar.markNotDirty(321)
|
||||
assertEquals(1, updated)
|
||||
assertEquals(321, androidCalendar.getEvent(id)?.entityValues?.getAsInteger(EventsContract.COLUMN_FLAGS))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun test_markNotDirty_DirtyIs0() = testMarkNotDirty(
|
||||
contentValuesOf(
|
||||
Events.DIRTY to 0
|
||||
)
|
||||
)
|
||||
|
||||
@Test
|
||||
fun test_markNotDirty_DirtyIsNull() = testMarkNotDirty(
|
||||
contentValuesOf(
|
||||
Events.DIRTY to null
|
||||
)
|
||||
)
|
||||
|
||||
}
|
||||
@@ -1,235 +0,0 @@
|
||||
/*
|
||||
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
|
||||
*/
|
||||
|
||||
package at.bitfire.davdroid.resource
|
||||
|
||||
import android.Manifest
|
||||
import android.accounts.Account
|
||||
import android.content.ContentProviderClient
|
||||
import android.content.ContentUris
|
||||
import android.content.ContentValues
|
||||
import android.provider.CalendarContract
|
||||
import android.provider.CalendarContract.ACCOUNT_TYPE_LOCAL
|
||||
import android.provider.CalendarContract.Events
|
||||
import androidx.test.platform.app.InstrumentationRegistry
|
||||
import androidx.test.rule.GrantPermissionRule
|
||||
import at.bitfire.ical4android.AndroidEvent
|
||||
import at.bitfire.ical4android.Event
|
||||
import at.bitfire.ical4android.util.MiscUtils.closeCompat
|
||||
import at.bitfire.synctools.storage.calendar.AndroidCalendarProvider
|
||||
import at.techbee.jtx.JtxContract.asSyncAdapter
|
||||
import dagger.hilt.android.testing.HiltAndroidRule
|
||||
import dagger.hilt.android.testing.HiltAndroidTest
|
||||
import net.fortuna.ical4j.model.property.DtStart
|
||||
import net.fortuna.ical4j.model.property.RRule
|
||||
import net.fortuna.ical4j.model.property.RecurrenceId
|
||||
import net.fortuna.ical4j.model.property.Status
|
||||
import org.junit.After
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Before
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import java.util.UUID
|
||||
import javax.inject.Inject
|
||||
|
||||
@HiltAndroidTest
|
||||
class LocalEventTest {
|
||||
|
||||
@get:Rule
|
||||
val hiltRule = HiltAndroidRule(this)
|
||||
|
||||
@get:Rule
|
||||
val permissionRule = GrantPermissionRule.grant(Manifest.permission.READ_CALENDAR, Manifest.permission.WRITE_CALENDAR)
|
||||
|
||||
@Inject
|
||||
lateinit var localCalendarFactory: LocalCalendar.Factory
|
||||
|
||||
private val account = Account("LocalCalendarTest", ACCOUNT_TYPE_LOCAL)
|
||||
private lateinit var client: ContentProviderClient
|
||||
private lateinit var calendar: LocalCalendar
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
hiltRule.inject()
|
||||
|
||||
val context = InstrumentationRegistry.getInstrumentation().targetContext
|
||||
client = context.contentResolver.acquireContentProviderClient(CalendarContract.AUTHORITY)!!
|
||||
|
||||
val provider = AndroidCalendarProvider(account, client)
|
||||
calendar = localCalendarFactory.create(provider.createAndGetCalendar(ContentValues()))
|
||||
}
|
||||
|
||||
@After
|
||||
fun tearDown() {
|
||||
calendar.androidCalendar.delete()
|
||||
client.closeCompat()
|
||||
}
|
||||
|
||||
|
||||
@Test
|
||||
fun testPrepareForUpload_NoUid() {
|
||||
// create event
|
||||
val event = Event().apply {
|
||||
dtStart = DtStart("20220120T010203Z")
|
||||
summary = "Event without uid"
|
||||
}
|
||||
val localEvent = LocalEvent(AndroidEvent(calendar.androidCalendar, event, null))
|
||||
localEvent.add() // save it to calendar storage
|
||||
|
||||
// prepare for upload - this should generate a new random uuid, returned as filename
|
||||
val fileNameWithSuffix = localEvent.prepareForUpload()
|
||||
val fileName = fileNameWithSuffix.removeSuffix(".ics")
|
||||
|
||||
// throws an exception if fileName is not an UUID
|
||||
UUID.fromString(fileName)
|
||||
|
||||
// UID in calendar storage should be the same as file name
|
||||
client.query(
|
||||
ContentUris.withAppendedId(Events.CONTENT_URI, localEvent.id!!).asSyncAdapter(account),
|
||||
arrayOf(Events.UID_2445), null, null, null
|
||||
)!!.use { cursor ->
|
||||
cursor.moveToFirst()
|
||||
assertEquals(fileName, cursor.getString(0))
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testPrepareForUpload_NormalUid() {
|
||||
// create event
|
||||
val event = Event().apply {
|
||||
dtStart = DtStart("20220120T010203Z")
|
||||
summary = "Event with normal uid"
|
||||
uid = "some-event@hostname.tld" // old UID format, UUID would be new format
|
||||
}
|
||||
val localEvent = LocalEvent(AndroidEvent(calendar.androidCalendar, event, null))
|
||||
localEvent.add() // save it to calendar storage
|
||||
|
||||
// prepare for upload - this should use the UID for the file name
|
||||
val fileNameWithSuffix = localEvent.prepareForUpload()
|
||||
val fileName = fileNameWithSuffix.removeSuffix(".ics")
|
||||
|
||||
assertEquals(event.uid, fileName)
|
||||
|
||||
// UID in calendar storage should still be set, too
|
||||
client.query(
|
||||
ContentUris.withAppendedId(Events.CONTENT_URI, localEvent.id!!).asSyncAdapter(account),
|
||||
arrayOf(Events.UID_2445), null, null, null
|
||||
)!!.use { cursor ->
|
||||
cursor.moveToFirst()
|
||||
assertEquals(fileName, cursor.getString(0))
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testPrepareForUpload_UidHasDangerousChars() {
|
||||
// create event
|
||||
val event = Event().apply {
|
||||
dtStart = DtStart("20220120T010203Z")
|
||||
summary = "Event with funny uid"
|
||||
uid = "https://www.example.com/events/asdfewfe-cxyb-ewrws-sadfrwerxyvser-asdfxye-"
|
||||
}
|
||||
val localEvent = LocalEvent(AndroidEvent(calendar.androidCalendar, event, null))
|
||||
localEvent.add() // save it to calendar storage
|
||||
|
||||
// prepare for upload - this should generate a new random uuid, returned as filename
|
||||
val fileNameWithSuffix = localEvent.prepareForUpload()
|
||||
val fileName = fileNameWithSuffix.removeSuffix(".ics")
|
||||
|
||||
// throws an exception if fileName is not an UUID
|
||||
UUID.fromString(fileName)
|
||||
|
||||
// UID in calendar storage shouldn't have been changed
|
||||
client.query(
|
||||
ContentUris.withAppendedId(Events.CONTENT_URI, localEvent.id!!).asSyncAdapter(account),
|
||||
arrayOf(Events.UID_2445), null, null, null
|
||||
)!!.use { cursor ->
|
||||
cursor.moveToFirst()
|
||||
assertEquals(event.uid, cursor.getString(0))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@Test
|
||||
fun testDeleteDirtyEventsWithoutInstances_NoInstances_Exdate() {
|
||||
// TODO
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testDeleteDirtyEventsWithoutInstances_NoInstances_CancelledExceptions() {
|
||||
// create recurring event with only deleted/cancelled instances
|
||||
val event = Event().apply {
|
||||
dtStart = DtStart("20220120T010203Z")
|
||||
summary = "Event with 3 instances"
|
||||
rRules.add(RRule("FREQ=DAILY;COUNT=3"))
|
||||
exceptions.add(Event().apply {
|
||||
recurrenceId = RecurrenceId("20220120T010203Z")
|
||||
dtStart = DtStart("20220120T010203Z")
|
||||
summary = "Cancelled exception on 1st day"
|
||||
status = Status.VEVENT_CANCELLED
|
||||
})
|
||||
exceptions.add(Event().apply {
|
||||
recurrenceId = RecurrenceId("20220121T010203Z")
|
||||
dtStart = DtStart("20220121T010203Z")
|
||||
summary = "Cancelled exception on 2nd day"
|
||||
status = Status.VEVENT_CANCELLED
|
||||
})
|
||||
exceptions.add(Event().apply {
|
||||
recurrenceId = RecurrenceId("20220122T010203Z")
|
||||
dtStart = DtStart("20220122T010203Z")
|
||||
summary = "Cancelled exception on 3rd day"
|
||||
status = Status.VEVENT_CANCELLED
|
||||
})
|
||||
}
|
||||
val localEvent = LocalEvent(AndroidEvent(calendar.androidCalendar, event, "filename.ics", null, null, LocalResource.FLAG_REMOTELY_PRESENT))
|
||||
localEvent.add()
|
||||
val eventId = localEvent.id!!
|
||||
|
||||
// set event as dirty
|
||||
client.update(ContentUris.withAppendedId(Events.CONTENT_URI.asSyncAdapter(account), eventId), ContentValues(1).apply {
|
||||
put(Events.DIRTY, 1)
|
||||
}, null, null)
|
||||
|
||||
// this method should mark the event as deleted
|
||||
calendar.deleteDirtyEventsWithoutInstances()
|
||||
|
||||
// verify that event is now marked as deleted
|
||||
client.query(
|
||||
ContentUris.withAppendedId(Events.CONTENT_URI.asSyncAdapter(account), eventId),
|
||||
arrayOf(Events.DELETED), null, null, null
|
||||
)!!.use { cursor ->
|
||||
cursor.moveToNext()
|
||||
assertEquals(1, cursor.getInt(0))
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testDeleteDirtyEventsWithoutInstances_Recurring_Instances() {
|
||||
val event = Event().apply {
|
||||
dtStart = DtStart("20220120T010203Z")
|
||||
summary = "Event with 3 instances"
|
||||
rRules.add(RRule("FREQ=DAILY;COUNT=3"))
|
||||
}
|
||||
val localEvent = LocalEvent(AndroidEvent(calendar.androidCalendar, event, "filename.ics", null, null, LocalResource.FLAG_REMOTELY_PRESENT))
|
||||
localEvent.add()
|
||||
val eventId = localEvent.id!!
|
||||
|
||||
// set event as dirty
|
||||
client.update(ContentUris.withAppendedId(Events.CONTENT_URI.asSyncAdapter(account), eventId), ContentValues(1).apply {
|
||||
put(Events.DIRTY, 1)
|
||||
}, null, null)
|
||||
|
||||
// this method should mark the event as deleted
|
||||
calendar.deleteDirtyEventsWithoutInstances()
|
||||
|
||||
// verify that event is not marked as deleted
|
||||
client.query(
|
||||
ContentUris.withAppendedId(Events.CONTENT_URI.asSyncAdapter(account), eventId),
|
||||
arrayOf(Events.DELETED), null, null, null
|
||||
)!!.use { cursor ->
|
||||
cursor.moveToNext()
|
||||
assertEquals(0, cursor.getInt(0))
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -21,38 +21,47 @@ import at.bitfire.vcard4android.GroupMethod
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import dagger.hilt.android.testing.HiltAndroidRule
|
||||
import dagger.hilt.android.testing.HiltAndroidTest
|
||||
import org.junit.AfterClass
|
||||
import org.junit.After
|
||||
import org.junit.Assert.assertEquals
|
||||
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.BeforeClass
|
||||
import org.junit.ClassRule
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import java.util.Optional
|
||||
import javax.inject.Inject
|
||||
|
||||
@HiltAndroidTest
|
||||
class LocalGroupTest {
|
||||
|
||||
@get:Rule
|
||||
val hiltRule = HiltAndroidRule(this)
|
||||
|
||||
@get:Rule
|
||||
val permissionRule = GrantPermissionRule.grant(Manifest.permission.READ_CONTACTS, Manifest.permission.WRITE_CONTACTS)!!
|
||||
|
||||
@Inject @ApplicationContext
|
||||
lateinit var context: Context
|
||||
|
||||
@Inject
|
||||
lateinit var localTestAddressBookProvider: LocalTestAddressBookProvider
|
||||
|
||||
@get:Rule
|
||||
val hiltRule = HiltAndroidRule(this)
|
||||
lateinit var provider: ContentProviderClient
|
||||
|
||||
val account = Account("Test Account", "Test Account Type")
|
||||
|
||||
@Before
|
||||
fun setup() {
|
||||
fun setUp() {
|
||||
hiltRule.inject()
|
||||
|
||||
val context = InstrumentationRegistry.getInstrumentation().context
|
||||
provider = context.contentResolver.acquireContentProviderClient(ContactsContract.AUTHORITY)!!
|
||||
}
|
||||
|
||||
@After
|
||||
fun tearDown() {
|
||||
provider.close()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testApplyPendingMemberships_addPendingMembership() {
|
||||
@@ -145,7 +154,6 @@ class LocalGroupTest {
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@Test
|
||||
fun testClearDirty_addCachedGroupMembership() {
|
||||
localTestAddressBookProvider.provide(account, provider, GroupMethod.CATEGORIES) { ab ->
|
||||
@@ -164,7 +172,7 @@ class LocalGroupTest {
|
||||
}
|
||||
)
|
||||
|
||||
group.clearDirty(null, null)
|
||||
group.clearDirty(Optional.empty(), null)
|
||||
|
||||
// check cached group membership
|
||||
ab.provider!!.query(
|
||||
@@ -200,7 +208,7 @@ class LocalGroupTest {
|
||||
}
|
||||
)
|
||||
|
||||
group.clearDirty(null, null)
|
||||
group.clearDirty(Optional.empty(), null)
|
||||
|
||||
// cached group membership should be gone
|
||||
ab.provider!!.query(
|
||||
@@ -213,7 +221,6 @@ class LocalGroupTest {
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@Test
|
||||
fun testMarkMembersDirty() {
|
||||
localTestAddressBookProvider.provide(account, provider, GroupMethod.CATEGORIES) { ab ->
|
||||
@@ -233,17 +240,11 @@ class LocalGroupTest {
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@Test
|
||||
fun testPrepareForUpload() {
|
||||
localTestAddressBookProvider.provide(account, provider, GroupMethod.CATEGORIES) { ab ->
|
||||
fun testUpdate() {
|
||||
localTestAddressBookProvider.provide(account, provider) { ab ->
|
||||
val group = newGroup(ab)
|
||||
assertNull(group.getContact().uid)
|
||||
|
||||
val fileName = group.prepareForUpload()
|
||||
val newUid = group.getContact().uid
|
||||
assertNotNull(newUid)
|
||||
assertEquals("$newUid.vcf", fileName)
|
||||
group.update(Contact(displayName = "New Group Name"), null, null, null, 0)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -259,27 +260,4 @@ class LocalGroupTest {
|
||||
add()
|
||||
}
|
||||
|
||||
|
||||
companion object {
|
||||
|
||||
@JvmField
|
||||
@ClassRule
|
||||
val permissionRule = GrantPermissionRule.grant(Manifest.permission.READ_CONTACTS, Manifest.permission.WRITE_CONTACTS)!!
|
||||
|
||||
private lateinit var provider: ContentProviderClient
|
||||
|
||||
@BeforeClass
|
||||
@JvmStatic
|
||||
fun connect() {
|
||||
val context = InstrumentationRegistry.getInstrumentation().context
|
||||
provider = context.contentResolver.acquireContentProviderClient(ContactsContract.AUTHORITY)!!
|
||||
}
|
||||
|
||||
@AfterClass
|
||||
@JvmStatic
|
||||
fun disconnect() {
|
||||
provider.close()
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -10,7 +10,7 @@ import android.content.Context
|
||||
import at.bitfire.davdroid.repository.DavCollectionRepository
|
||||
import at.bitfire.davdroid.repository.DavServiceRepository
|
||||
import at.bitfire.davdroid.settings.AccountSettings
|
||||
import at.bitfire.davdroid.sync.SyncFrameworkIntegration
|
||||
import at.bitfire.davdroid.sync.adapter.SyncFrameworkIntegration
|
||||
import at.bitfire.vcard4android.GroupMethod
|
||||
import dagger.assisted.Assisted
|
||||
import dagger.assisted.AssistedFactory
|
||||
|
||||
@@ -1,749 +0,0 @@
|
||||
/*
|
||||
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
|
||||
*/
|
||||
|
||||
package at.bitfire.davdroid.servicedetection
|
||||
|
||||
import android.security.NetworkSecurityPolicy
|
||||
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.network.HttpClient
|
||||
import at.bitfire.davdroid.settings.Settings
|
||||
import at.bitfire.davdroid.settings.SettingsManager
|
||||
import dagger.hilt.android.testing.BindValue
|
||||
import dagger.hilt.android.testing.HiltAndroidRule
|
||||
import dagger.hilt.android.testing.HiltAndroidTest
|
||||
import io.mockk.every
|
||||
import io.mockk.impl.annotations.MockK
|
||||
import io.mockk.junit4.MockKRule
|
||||
import kotlinx.coroutines.test.runTest
|
||||
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.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Assume
|
||||
import org.junit.Before
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import java.util.logging.Logger
|
||||
import javax.inject.Inject
|
||||
|
||||
@HiltAndroidTest
|
||||
class CollectionListRefresherTest {
|
||||
|
||||
@Inject
|
||||
lateinit var db: AppDatabase
|
||||
|
||||
@Inject
|
||||
lateinit var httpClientBuilder: HttpClient.Builder
|
||||
|
||||
@Inject
|
||||
lateinit var logger: Logger
|
||||
|
||||
@Inject
|
||||
lateinit var refresherFactory: CollectionListRefresher.Factory
|
||||
|
||||
@BindValue
|
||||
@MockK(relaxed = true)
|
||||
lateinit var settings: SettingsManager
|
||||
|
||||
@get:Rule
|
||||
val hiltRule = HiltAndroidRule(this)
|
||||
|
||||
@get:Rule
|
||||
val mockKRule = MockKRule(this)
|
||||
|
||||
private lateinit var client: HttpClient
|
||||
private lateinit var mockServer: MockWebServer
|
||||
private lateinit var service: Service
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
hiltRule.inject()
|
||||
|
||||
// Start mock web server
|
||||
mockServer = MockWebServer().apply {
|
||||
dispatcher = TestDispatcher(logger)
|
||||
start()
|
||||
}
|
||||
|
||||
// build HTTP client
|
||||
client = httpClientBuilder.build()
|
||||
Assume.assumeTrue(NetworkSecurityPolicy.getInstance().isCleartextTrafficPermitted)
|
||||
|
||||
// insert test service
|
||||
val serviceId = db.serviceDao().insertOrReplace(
|
||||
Service(id = 0, accountName = "test", type = Service.TYPE_CARDDAV, principal = null)
|
||||
)
|
||||
service = db.serviceDao().get(serviceId)!!
|
||||
}
|
||||
|
||||
@After
|
||||
fun tearDown() {
|
||||
client.close()
|
||||
mockServer.shutdown()
|
||||
}
|
||||
|
||||
|
||||
@Test
|
||||
fun testDiscoverHomesets() {
|
||||
val baseUrl = mockServer.url(PATH_CARDDAV + SUBPATH_PRINCIPAL)
|
||||
|
||||
// Query home sets
|
||||
refresherFactory.create(service, client.okHttpClient).discoverHomesets(baseUrl)
|
||||
|
||||
// Check home set has been saved correctly to database
|
||||
val savedHomesets = db.homeSetDao().getByService(service.id)
|
||||
assertEquals(2, savedHomesets.size)
|
||||
|
||||
// Home set from current-user-principal
|
||||
val personalHomeset = savedHomesets[1]
|
||||
assertEquals(mockServer.url("$PATH_CARDDAV$SUBPATH_ADDRESSBOOK_HOMESET_PERSONAL/"), personalHomeset.url)
|
||||
assertEquals(service.id, personalHomeset.serviceId)
|
||||
// personal should be true for homesets detected at first query of current-user-principal (Even if they occur in a group principal as well!!!)
|
||||
assertEquals(true, personalHomeset.personal)
|
||||
|
||||
// Home set found in a group principal
|
||||
val groupHomeset = savedHomesets[0]
|
||||
assertEquals(mockServer.url("$PATH_CARDDAV$SUBPATH_ADDRESSBOOK_HOMESET_NON_PERSONAL/"), groupHomeset.url)
|
||||
assertEquals(service.id, groupHomeset.serviceId)
|
||||
// personal should be false for homesets not detected at the first query of current-user-principal (IE. in groups)
|
||||
assertEquals(false, groupHomeset.personal)
|
||||
}
|
||||
|
||||
|
||||
// refreshHomesetsAndTheirCollections
|
||||
|
||||
@Test
|
||||
fun refreshHomesetsAndTheirCollections_addsNewCollection() = runTest {
|
||||
// save homeset in DB
|
||||
val homesetId = db.homeSetDao().insert(
|
||||
HomeSet(id=0, service.id, true, mockServer.url("$PATH_CARDDAV$SUBPATH_ADDRESSBOOK_HOMESET_PERSONAL"))
|
||||
)
|
||||
|
||||
// Refresh
|
||||
refresherFactory.create(service, 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() {
|
||||
// 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
|
||||
refresherFactory.create(service, 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() {
|
||||
// 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
|
||||
refresherFactory.create(service, 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() {
|
||||
// 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.
|
||||
refresherFactory.create(service, client.okHttpClient).refreshHomesetsAndTheirCollections()
|
||||
|
||||
// Check the collection, is now marked as homeless
|
||||
assertEquals(null, db.collectionDao().get(collectionId)!!.homeSetId)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun refreshHomesetsAndTheirCollections_addsOwnerUrls() {
|
||||
// save a homeset in DB
|
||||
val homesetId = db.homeSetDao().insert(
|
||||
HomeSet(id=0, service.id, true, mockServer.url("$PATH_CARDDAV$SUBPATH_ADDRESSBOOK_HOMESET_PERSONAL"))
|
||||
)
|
||||
|
||||
// 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)
|
||||
refresherFactory.create(service, 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() {
|
||||
// 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
|
||||
refresherFactory.create(service, 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() {
|
||||
// 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
|
||||
refresherFactory.create(service, client.okHttpClient).refreshHomelessCollections()
|
||||
|
||||
// Check the collection got deleted
|
||||
assertEquals(null, db.collectionDao().get(collectionId))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun refreshHomelessCollections_addsOwnerUrls() {
|
||||
// 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)
|
||||
refresherFactory.create(service, 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() {
|
||||
// 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
|
||||
refresherFactory.create(service, 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() {
|
||||
// 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
|
||||
refresherFactory.create(service, 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() {
|
||||
// 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
|
||||
refresherFactory.create(service, client.okHttpClient).refreshPrincipals()
|
||||
|
||||
// Check principal was deleted
|
||||
val principals = db.principalDao().getByService(service.id)
|
||||
assertEquals(0, principals.size)
|
||||
}
|
||||
|
||||
// Others
|
||||
|
||||
@Test
|
||||
fun shouldPreselect_none() {
|
||||
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("/addressbook-homeset/addressbook/")
|
||||
)
|
||||
val homesets = listOf(
|
||||
HomeSet(
|
||||
id = 0,
|
||||
serviceId = service.id,
|
||||
personal = true,
|
||||
url = mockServer.url("/addressbook-homeset/")
|
||||
)
|
||||
)
|
||||
|
||||
val refresher = refresherFactory.create(service, client.okHttpClient)
|
||||
assertFalse(refresher.shouldPreselect(collection, homesets))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun shouldPreselect_all() {
|
||||
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("/addressbook-homeset/addressbook/")
|
||||
)
|
||||
val homesets = listOf(
|
||||
HomeSet(
|
||||
id = 0,
|
||||
serviceId = service.id,
|
||||
personal = false,
|
||||
url = mockServer.url("/addressbook-homeset/")
|
||||
)
|
||||
)
|
||||
|
||||
val refresher = refresherFactory.create(service, client.okHttpClient)
|
||||
assertTrue(refresher.shouldPreselect(collection, homesets))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun shouldPreselect_all_blacklisted() {
|
||||
val url = mockServer.url("/addressbook-homeset/addressbook/")
|
||||
|
||||
every { settings.getIntOrNull(Settings.PRESELECT_COLLECTIONS) } returns Settings.PRESELECT_COLLECTIONS_ALL
|
||||
every { settings.getString(Settings.PRESELECT_COLLECTIONS_EXCLUDED) } returns url.toString()
|
||||
|
||||
val collection = Collection(
|
||||
id = 0,
|
||||
serviceId = service.id,
|
||||
homeSetId = 0,
|
||||
type = Collection.TYPE_ADDRESSBOOK,
|
||||
url = url
|
||||
)
|
||||
val homesets = listOf(
|
||||
HomeSet(
|
||||
id = 0,
|
||||
serviceId = service.id,
|
||||
personal = false,
|
||||
url = mockServer.url("/addressbook-homeset/")
|
||||
)
|
||||
)
|
||||
|
||||
val refresher = refresherFactory.create(service, client.okHttpClient)
|
||||
assertFalse(refresher.shouldPreselect(collection, homesets))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun shouldPreselect_personal_notPersonal() {
|
||||
every { settings.getIntOrNull(Settings.PRESELECT_COLLECTIONS) } returns Settings.PRESELECT_COLLECTIONS_PERSONAL
|
||||
every { settings.getString(Settings.PRESELECT_COLLECTIONS_EXCLUDED) } returns ""
|
||||
|
||||
val collection = Collection(
|
||||
id = 0,
|
||||
serviceId = service.id,
|
||||
homeSetId = 0,
|
||||
type = Collection.TYPE_ADDRESSBOOK,
|
||||
url = mockServer.url("/addressbook-homeset/addressbook/")
|
||||
)
|
||||
val homesets = listOf(
|
||||
HomeSet(
|
||||
id = 0,
|
||||
serviceId = service.id,
|
||||
personal = false,
|
||||
url = mockServer.url("/addressbook-homeset/")
|
||||
)
|
||||
)
|
||||
|
||||
val refresher = refresherFactory.create(service, client.okHttpClient)
|
||||
assertFalse(refresher.shouldPreselect(collection, homesets))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun shouldPreselect_personal_isPersonal() {
|
||||
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("/addressbook-homeset/addressbook/")
|
||||
)
|
||||
val homesets = listOf(
|
||||
HomeSet(
|
||||
id = 0,
|
||||
serviceId = service.id,
|
||||
personal = true,
|
||||
url = mockServer.url("/addressbook-homeset/")
|
||||
)
|
||||
)
|
||||
|
||||
val refresher = refresherFactory.create(service, client.okHttpClient)
|
||||
assertTrue(refresher.shouldPreselect(collection, homesets))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun shouldPreselect_personal_isPersonalButBlacklisted() {
|
||||
val collectionUrl = mockServer.url("/addressbook-homeset/addressbook/")
|
||||
|
||||
every { settings.getIntOrNull(Settings.PRESELECT_COLLECTIONS) } returns Settings.PRESELECT_COLLECTIONS_PERSONAL
|
||||
every { settings.getString(Settings.PRESELECT_COLLECTIONS_EXCLUDED) } returns collectionUrl.toString()
|
||||
|
||||
val collection = Collection(
|
||||
id = 0,
|
||||
serviceId = service.id,
|
||||
homeSetId = 0,
|
||||
type = Collection.TYPE_ADDRESSBOOK,
|
||||
url = collectionUrl
|
||||
)
|
||||
val homesets = listOf(
|
||||
HomeSet(
|
||||
id = 0,
|
||||
serviceId = service.id,
|
||||
personal = true,
|
||||
url = mockServer.url("/addressbook-homeset/")
|
||||
)
|
||||
)
|
||||
|
||||
val refresher = refresherFactory.create(service, client.okHttpClient)
|
||||
assertFalse(refresher.shouldPreselect(collection, homesets))
|
||||
}
|
||||
|
||||
|
||||
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_GROUPPRINCIPAL_0 = "/groups/0"
|
||||
private const val SUBPATH_ADDRESSBOOK_HOMESET_PERSONAL = "/addressbooks-homeset"
|
||||
private const val SUBPATH_ADDRESSBOOK_HOMESET_NON_PERSONAL = "/addressbooks-homeset-non-personal"
|
||||
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"
|
||||
|
||||
}
|
||||
|
||||
class TestDispatcher(
|
||||
private val logger: Logger
|
||||
): Dispatcher() {
|
||||
|
||||
override fun dispatch(request: RecordedRequest): MockResponse {
|
||||
val path = request.path!!.trimEnd('/')
|
||||
|
||||
if (request.method.equals("PROPFIND", true)) {
|
||||
val properties = when (path) {
|
||||
PATH_CALDAV,
|
||||
PATH_CARDDAV ->
|
||||
"<current-user-principal>" +
|
||||
" <href>$path${SUBPATH_PRINCIPAL}</href>" +
|
||||
"</current-user-principal>"
|
||||
|
||||
PATH_CARDDAV + SUBPATH_PRINCIPAL ->
|
||||
"<resourcetype><principal/></resourcetype>" +
|
||||
"<displayname>Mr. Wobbles</displayname>" +
|
||||
"<CARD:addressbook-home-set>" +
|
||||
" <href>${PATH_CARDDAV}${SUBPATH_ADDRESSBOOK_HOMESET_PERSONAL}</href>" +
|
||||
"</CARD:addressbook-home-set>" +
|
||||
"<group-membership>" +
|
||||
" <href>${PATH_CARDDAV}${SUBPATH_GROUPPRINCIPAL_0}</href>" +
|
||||
"</group-membership>"
|
||||
|
||||
PATH_CARDDAV + SUBPATH_PRINCIPAL_WITHOUT_COLLECTIONS ->
|
||||
"<CARD:addressbook-home-set>" +
|
||||
" <href>${PATH_CARDDAV}${SUBPATH_ADDRESSBOOK_HOMESET_EMPTY}</href>" +
|
||||
"</CARD:addressbook-home-set>" +
|
||||
"<displayname>Mr. Wobbles Jr.</displayname>"
|
||||
|
||||
PATH_CARDDAV + SUBPATH_GROUPPRINCIPAL_0 ->
|
||||
"<resourcetype><principal/></resourcetype>" +
|
||||
"<displayname>All address books</displayname>" +
|
||||
"<CARD:addressbook-home-set>" +
|
||||
" <href>${PATH_CARDDAV}${SUBPATH_ADDRESSBOOK_HOMESET_PERSONAL}</href>" +
|
||||
" <href>${PATH_CARDDAV}${SUBPATH_ADDRESSBOOK_HOMESET_NON_PERSONAL}</href>" +
|
||||
"</CARD:addressbook-home-set>"
|
||||
|
||||
PATH_CARDDAV + SUBPATH_ADDRESSBOOK,
|
||||
PATH_CARDDAV + SUBPATH_ADDRESSBOOK_HOMESET_PERSONAL ->
|
||||
"<resourcetype>" +
|
||||
" <collection/>" +
|
||||
" <CARD:addressbook/>" +
|
||||
"</resourcetype>" +
|
||||
"<displayname>My Contacts</displayname>" +
|
||||
"<CARD:addressbook-description>My Contacts Description</CARD:addressbook-description>" +
|
||||
"<owner>" +
|
||||
" <href>${PATH_CARDDAV + SUBPATH_PRINCIPAL}</href>" +
|
||||
"</owner>"
|
||||
|
||||
PATH_CARDDAV + SUBPATH_ADDRESSBOOK_HOMESET_NON_PERSONAL ->
|
||||
"<resourcetype>" +
|
||||
" <collection/>" +
|
||||
" <CARD:addressbook/>" +
|
||||
"</resourcetype>" +
|
||||
"<displayname>Freds Contacts (not mine)</displayname>" +
|
||||
"<CARD:addressbook-description>Not personal contacts</CARD:addressbook-description>" +
|
||||
"<owner>" +
|
||||
" <href>${PATH_CARDDAV + SUBPATH_PRINCIPAL}</href>" + // OK, user is allowed to own non-personal contacts
|
||||
"</owner>"
|
||||
|
||||
PATH_CALDAV + SUBPATH_PRINCIPAL ->
|
||||
"<CAL:calendar-user-address-set>" +
|
||||
" <href>urn:unknown-entry</href>" +
|
||||
" <href>mailto:email1@example.com</href>" +
|
||||
" <href>mailto:email2@example.com</href>" +
|
||||
"</CAL:calendar-user-address-set>"
|
||||
|
||||
SUBPATH_ADDRESSBOOK_HOMESET_EMPTY -> ""
|
||||
|
||||
else -> ""
|
||||
}
|
||||
|
||||
var responseBody = ""
|
||||
var responseCode = 207
|
||||
when (path) {
|
||||
PATH_CARDDAV + SUBPATH_ADDRESSBOOK_HOMESET_PERSONAL ->
|
||||
responseBody =
|
||||
"<multistatus xmlns='DAV:' xmlns:CARD='urn:ietf:params:xml:ns:carddav' xmlns:CAL='urn:ietf:params:xml:ns:caldav'>" +
|
||||
"<response>" +
|
||||
" <href>${PATH_CARDDAV + SUBPATH_ADDRESSBOOK}</href>" +
|
||||
" <propstat><prop>" +
|
||||
properties +
|
||||
" </prop></propstat>" +
|
||||
" <status>HTTP/1.1 200 OK</status>" +
|
||||
"</response>" +
|
||||
"</multistatus>"
|
||||
|
||||
PATH_CARDDAV + SUBPATH_PRINCIPAL_INACCESSIBLE,
|
||||
PATH_CARDDAV + SUBPATH_ADDRESSBOOK_INACCESSIBLE ->
|
||||
responseCode = 404
|
||||
|
||||
else ->
|
||||
responseBody =
|
||||
"<multistatus xmlns='DAV:' xmlns:CARD='urn:ietf:params:xml:ns:carddav' xmlns:CAL='urn:ietf:params:xml:ns:caldav'>" +
|
||||
"<response>" +
|
||||
" <href>$path</href>" +
|
||||
" <propstat><prop>"+
|
||||
properties +
|
||||
" </prop></propstat>" +
|
||||
"</response>" +
|
||||
"</multistatus>"
|
||||
}
|
||||
|
||||
logger.info("Queried: $path")
|
||||
logger.info("Response: $responseBody")
|
||||
return MockResponse()
|
||||
.setResponseCode(responseCode)
|
||||
.setBody(responseBody)
|
||||
}
|
||||
|
||||
return MockResponse().setResponseCode(404)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,222 @@
|
||||
/*
|
||||
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
|
||||
*/
|
||||
|
||||
package at.bitfire.davdroid.servicedetection
|
||||
|
||||
import android.security.NetworkSecurityPolicy
|
||||
import at.bitfire.davdroid.db.AppDatabase
|
||||
import at.bitfire.davdroid.db.Collection
|
||||
import at.bitfire.davdroid.db.Service
|
||||
import at.bitfire.davdroid.network.HttpClientBuilder
|
||||
import at.bitfire.davdroid.settings.SettingsManager
|
||||
import dagger.hilt.android.testing.BindValue
|
||||
import dagger.hilt.android.testing.HiltAndroidRule
|
||||
import dagger.hilt.android.testing.HiltAndroidTest
|
||||
import io.mockk.impl.annotations.MockK
|
||||
import io.mockk.junit4.MockKRule
|
||||
import okhttp3.OkHttpClient
|
||||
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.Assume
|
||||
import org.junit.Before
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import java.util.logging.Logger
|
||||
import javax.inject.Inject
|
||||
|
||||
@HiltAndroidTest
|
||||
class CollectionsWithoutHomeSetRefresherTest {
|
||||
|
||||
@get:Rule
|
||||
val hiltRule = HiltAndroidRule(this)
|
||||
|
||||
@get:Rule
|
||||
val mockKRule = MockKRule(this)
|
||||
|
||||
@Inject
|
||||
lateinit var db: AppDatabase
|
||||
|
||||
@Inject
|
||||
lateinit var httpClientBuilder: HttpClientBuilder
|
||||
|
||||
@Inject
|
||||
lateinit var logger: Logger
|
||||
|
||||
@Inject
|
||||
lateinit var refresherFactory: CollectionsWithoutHomeSetRefresher.Factory
|
||||
|
||||
@BindValue
|
||||
@MockK(relaxed = true)
|
||||
lateinit var settings: SettingsManager
|
||||
|
||||
private lateinit var client: OkHttpClient
|
||||
private lateinit var mockServer: MockWebServer
|
||||
private lateinit var service: Service
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
hiltRule.inject()
|
||||
|
||||
// Start mock web server
|
||||
mockServer = MockWebServer().apply {
|
||||
dispatcher = TestDispatcher(logger)
|
||||
start()
|
||||
}
|
||||
|
||||
// build HTTP client
|
||||
client = httpClientBuilder.build()
|
||||
Assume.assumeTrue(NetworkSecurityPolicy.getInstance().isCleartextTrafficPermitted)
|
||||
|
||||
// insert test service
|
||||
val serviceId = db.serviceDao().insertOrReplace(
|
||||
Service(id = 0, accountName = "test", type = Service.TYPE_CARDDAV, principal = null)
|
||||
)
|
||||
service = db.serviceDao().get(serviceId)!!
|
||||
}
|
||||
|
||||
@After
|
||||
fun tearDown() {
|
||||
mockServer.shutdown()
|
||||
}
|
||||
|
||||
|
||||
// refreshCollectionsWithoutHomeSet
|
||||
|
||||
@Test
|
||||
fun refreshCollectionsWithoutHomeSet_updatesExistingCollection() {
|
||||
// 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
|
||||
refresherFactory.create(service, client).refreshCollectionsWithoutHomeSet()
|
||||
|
||||
// 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 refreshCollectionsWithoutHomeSet_deletesInaccessibleCollectionsWithoutHomeSet() {
|
||||
// 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
|
||||
refresherFactory.create(service, client).refreshCollectionsWithoutHomeSet()
|
||||
|
||||
// Check the collection got deleted
|
||||
assertEquals(null, db.collectionDao().get(collectionId))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun refreshCollectionsWithoutHomeSet_addsOwnerUrls() {
|
||||
// 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)
|
||||
refresherFactory.create(service, client).refreshCollectionsWithoutHomeSet()
|
||||
|
||||
// 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
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
companion object {
|
||||
private const val PATH_CARDDAV = "/carddav"
|
||||
private const val SUBPATH_PRINCIPAL = "/principal"
|
||||
private const val SUBPATH_ADDRESSBOOK = "/addressbooks/my-contacts"
|
||||
private const val SUBPATH_ADDRESSBOOK_INACCESSIBLE = "/addressbooks/inaccessible-contacts"
|
||||
}
|
||||
|
||||
class TestDispatcher(
|
||||
private val logger: Logger
|
||||
): Dispatcher() {
|
||||
|
||||
override fun dispatch(request: RecordedRequest): MockResponse {
|
||||
val path = request.path!!.trimEnd('/')
|
||||
logger.info("${request.method} on $path")
|
||||
|
||||
if (request.method.equals("PROPFIND", true)) {
|
||||
val properties = when (path) {
|
||||
PATH_CARDDAV + SUBPATH_ADDRESSBOOK ->
|
||||
"<resourcetype>" +
|
||||
" <collection/>" +
|
||||
" <CARD:addressbook/>" +
|
||||
"</resourcetype>" +
|
||||
"<displayname>My Contacts</displayname>" +
|
||||
"<CARD:addressbook-description>My Contacts Description</CARD:addressbook-description>" +
|
||||
"<owner>" +
|
||||
" <href>${PATH_CARDDAV + SUBPATH_PRINCIPAL}</href>" +
|
||||
"</owner>"
|
||||
|
||||
else -> ""
|
||||
}
|
||||
|
||||
return MockResponse()
|
||||
.setResponseCode(207)
|
||||
.setBody("<multistatus xmlns='DAV:' xmlns:CARD='urn:ietf:params:xml:ns:carddav' xmlns:CAL='urn:ietf:params:xml:ns:caldav'>" +
|
||||
"<response>" +
|
||||
" <href>$path</href>" +
|
||||
" <propstat><prop>"+
|
||||
properties +
|
||||
" </prop></propstat>" +
|
||||
"</response>" +
|
||||
"</multistatus>")
|
||||
}
|
||||
|
||||
return MockResponse().setResponseCode(404)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
@@ -5,14 +5,16 @@
|
||||
package at.bitfire.davdroid.servicedetection
|
||||
|
||||
import android.security.NetworkSecurityPolicy
|
||||
import at.bitfire.dav4jvm.DavResource
|
||||
import at.bitfire.dav4jvm.property.carddav.AddressbookHomeSet
|
||||
import at.bitfire.dav4jvm.property.webdav.ResourceType
|
||||
import at.bitfire.davdroid.network.HttpClient
|
||||
import at.bitfire.dav4jvm.okhttp.DavResource
|
||||
import at.bitfire.dav4jvm.property.carddav.CardDAV
|
||||
import at.bitfire.dav4jvm.property.webdav.WebDAV
|
||||
import at.bitfire.davdroid.network.HttpClientBuilder
|
||||
import at.bitfire.davdroid.servicedetection.DavResourceFinder.Configuration.ServiceInfo
|
||||
import at.bitfire.davdroid.settings.Credentials
|
||||
import at.bitfire.davdroid.util.SensitiveString.Companion.toSensitiveString
|
||||
import dagger.hilt.android.testing.HiltAndroidRule
|
||||
import dagger.hilt.android.testing.HiltAndroidTest
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.mockwebserver.Dispatcher
|
||||
import okhttp3.mockwebserver.MockResponse
|
||||
import okhttp3.mockwebserver.MockWebServer
|
||||
@@ -49,7 +51,7 @@ class DavResourceFinderTest {
|
||||
val hiltRule = HiltAndroidRule(this)
|
||||
|
||||
@Inject
|
||||
lateinit var httpClientBuilder: HttpClient.Builder
|
||||
lateinit var httpClientBuilder: HttpClientBuilder
|
||||
|
||||
@Inject
|
||||
lateinit var logger: Logger
|
||||
@@ -58,7 +60,7 @@ class DavResourceFinderTest {
|
||||
lateinit var resourceFinderFactory: DavResourceFinder.Factory
|
||||
|
||||
private lateinit var server: MockWebServer
|
||||
private lateinit var client: HttpClient
|
||||
private lateinit var client: OkHttpClient
|
||||
private lateinit var finder: DavResourceFinder
|
||||
|
||||
@Before
|
||||
@@ -70,9 +72,9 @@ class DavResourceFinderTest {
|
||||
start()
|
||||
}
|
||||
|
||||
val credentials = Credentials(username = "mock", password = "12345".toCharArray())
|
||||
val credentials = Credentials(username = "mock", password = "12345".toSensitiveString())
|
||||
client = httpClientBuilder
|
||||
.authenticate(host = null, getCredentials = { credentials })
|
||||
.authenticate(domain = null, getCredentials = { credentials })
|
||||
.build()
|
||||
Assume.assumeTrue(NetworkSecurityPolicy.getInstance().isCleartextTrafficPermitted)
|
||||
|
||||
@@ -82,7 +84,6 @@ class DavResourceFinderTest {
|
||||
|
||||
@After
|
||||
fun tearDown() {
|
||||
client.close()
|
||||
server.shutdown()
|
||||
}
|
||||
|
||||
@@ -91,9 +92,9 @@ class DavResourceFinderTest {
|
||||
fun testRememberIfAddressBookOrHomeset() {
|
||||
// recognize home set
|
||||
var info = ServiceInfo()
|
||||
DavResource(client.okHttpClient, server.url(PATH_CARDDAV + SUBPATH_PRINCIPAL))
|
||||
.propfind(0, AddressbookHomeSet.NAME) { response, _ ->
|
||||
finder.scanResponse(ResourceType.ADDRESSBOOK, response, info)
|
||||
DavResource(client, server.url(PATH_CARDDAV + SUBPATH_PRINCIPAL))
|
||||
.propfind(0, CardDAV.AddressbookHomeSet) { response, _ ->
|
||||
finder.scanResponse(CardDAV.Addressbook, response, info)
|
||||
}
|
||||
assertEquals(0, info.collections.size)
|
||||
assertEquals(1, info.homeSets.size)
|
||||
@@ -101,9 +102,9 @@ class DavResourceFinderTest {
|
||||
|
||||
// recognize address book
|
||||
info = ServiceInfo()
|
||||
DavResource(client.okHttpClient, server.url(PATH_CARDDAV + SUBPATH_ADDRESSBOOK))
|
||||
.propfind(0, ResourceType.NAME) { response, _ ->
|
||||
finder.scanResponse(ResourceType.ADDRESSBOOK, response, info)
|
||||
DavResource(client, server.url(PATH_CARDDAV + SUBPATH_ADDRESSBOOK))
|
||||
.propfind(0, WebDAV.ResourceType) { response, _ ->
|
||||
finder.scanResponse(CardDAV.Addressbook, response, info)
|
||||
}
|
||||
assertEquals(1, info.collections.size)
|
||||
assertEquals(server.url("$PATH_CARDDAV$SUBPATH_ADDRESSBOOK/"), info.collections.keys.first())
|
||||
|
||||
@@ -0,0 +1,473 @@
|
||||
/*
|
||||
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
|
||||
*/
|
||||
|
||||
package at.bitfire.davdroid.servicedetection
|
||||
|
||||
import android.security.NetworkSecurityPolicy
|
||||
import at.bitfire.davdroid.db.AppDatabase
|
||||
import at.bitfire.davdroid.db.Collection
|
||||
import at.bitfire.davdroid.db.HomeSet
|
||||
import at.bitfire.davdroid.db.Service
|
||||
import at.bitfire.davdroid.network.HttpClientBuilder
|
||||
import at.bitfire.davdroid.settings.Settings
|
||||
import at.bitfire.davdroid.settings.SettingsManager
|
||||
import dagger.hilt.android.testing.BindValue
|
||||
import dagger.hilt.android.testing.HiltAndroidRule
|
||||
import dagger.hilt.android.testing.HiltAndroidTest
|
||||
import io.mockk.every
|
||||
import io.mockk.impl.annotations.MockK
|
||||
import io.mockk.junit4.MockKRule
|
||||
import junit.framework.TestCase.assertFalse
|
||||
import junit.framework.TestCase.assertTrue
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import okhttp3.OkHttpClient
|
||||
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.Assume
|
||||
import org.junit.Before
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import java.util.logging.Logger
|
||||
import javax.inject.Inject
|
||||
|
||||
@HiltAndroidTest
|
||||
class HomeSetRefresherTest {
|
||||
|
||||
@get:Rule
|
||||
val hiltRule = HiltAndroidRule(this)
|
||||
|
||||
@get:Rule
|
||||
val mockKRule = MockKRule(this)
|
||||
|
||||
@Inject
|
||||
lateinit var db: AppDatabase
|
||||
|
||||
@Inject
|
||||
lateinit var httpClientBuilder: HttpClientBuilder
|
||||
|
||||
@Inject
|
||||
lateinit var logger: Logger
|
||||
|
||||
@Inject
|
||||
lateinit var homeSetRefresherFactory: HomeSetRefresher.Factory
|
||||
|
||||
@BindValue
|
||||
@MockK(relaxed = true)
|
||||
lateinit var settings: SettingsManager
|
||||
|
||||
private lateinit var client: OkHttpClient
|
||||
private lateinit var mockServer: MockWebServer
|
||||
private lateinit var service: Service
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
hiltRule.inject()
|
||||
|
||||
// Start mock web server
|
||||
mockServer = MockWebServer().apply {
|
||||
dispatcher = TestDispatcher(logger)
|
||||
start()
|
||||
}
|
||||
|
||||
// build HTTP client
|
||||
client = httpClientBuilder.build()
|
||||
Assume.assumeTrue(NetworkSecurityPolicy.getInstance().isCleartextTrafficPermitted)
|
||||
|
||||
// insert test service
|
||||
val serviceId = db.serviceDao().insertOrReplace(
|
||||
Service(id = 0, accountName = "test", type = Service.TYPE_CARDDAV, principal = null)
|
||||
)
|
||||
service = db.serviceDao().get(serviceId)!!
|
||||
}
|
||||
|
||||
@After
|
||||
fun tearDown() {
|
||||
mockServer.shutdown()
|
||||
}
|
||||
|
||||
|
||||
// refreshHomesetsAndTheirCollections
|
||||
|
||||
@Test
|
||||
fun refreshHomesetsAndTheirCollections_addsNewCollection() = runTest {
|
||||
// save homeset in DB
|
||||
val homesetId = db.homeSetDao().insert(
|
||||
HomeSet(id = 0, service.id, true, mockServer.url("$PATH_CARDDAV$SUBPATH_ADDRESSBOOK_HOMESET_PERSONAL"))
|
||||
)
|
||||
|
||||
// Refresh
|
||||
homeSetRefresherFactory.create(service, client)
|
||||
.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() {
|
||||
// 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
|
||||
homeSetRefresherFactory.create(service, client).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() {
|
||||
// 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
|
||||
homeSetRefresherFactory.create(service, client).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() {
|
||||
// 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.
|
||||
homeSetRefresherFactory.create(service, client).refreshHomesetsAndTheirCollections()
|
||||
|
||||
// Check the collection, is now marked as homeless
|
||||
assertEquals(null, db.collectionDao().get(collectionId)!!.homeSetId)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun refreshHomesetsAndTheirCollections_addsOwnerUrls() {
|
||||
// save a homeset in DB
|
||||
val homesetId = db.homeSetDao().insert(
|
||||
HomeSet(id = 0, service.id, true, mockServer.url("$PATH_CARDDAV$SUBPATH_ADDRESSBOOK_HOMESET_PERSONAL"))
|
||||
)
|
||||
|
||||
// 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)
|
||||
homeSetRefresherFactory.create(service, client).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
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
// other
|
||||
|
||||
@Test
|
||||
fun shouldPreselect_none() {
|
||||
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("/addressbook-homeset/addressbook/")
|
||||
)
|
||||
val homesets = listOf(
|
||||
HomeSet(
|
||||
id = 0,
|
||||
serviceId = service.id,
|
||||
personal = true,
|
||||
url = mockServer.url("/addressbook-homeset/")
|
||||
)
|
||||
)
|
||||
|
||||
val refresher = homeSetRefresherFactory.create(service, client)
|
||||
assertFalse(refresher.shouldPreselect(collection, homesets))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun shouldPreselect_all() {
|
||||
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("/addressbook-homeset/addressbook/")
|
||||
)
|
||||
val homesets = listOf(
|
||||
HomeSet(
|
||||
id = 0,
|
||||
serviceId = service.id,
|
||||
personal = false,
|
||||
url = mockServer.url("/addressbook-homeset/")
|
||||
)
|
||||
)
|
||||
|
||||
val refresher = homeSetRefresherFactory.create(service, client)
|
||||
assertTrue(refresher.shouldPreselect(collection, homesets))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun shouldPreselect_all_blacklisted() {
|
||||
val url = mockServer.url("/addressbook-homeset/addressbook/")
|
||||
|
||||
every { settings.getIntOrNull(Settings.PRESELECT_COLLECTIONS) } returns Settings.PRESELECT_COLLECTIONS_ALL
|
||||
every { settings.getString(Settings.PRESELECT_COLLECTIONS_EXCLUDED) } returns url.toString()
|
||||
|
||||
val collection = Collection(
|
||||
id = 0,
|
||||
serviceId = service.id,
|
||||
homeSetId = 0,
|
||||
type = Collection.TYPE_ADDRESSBOOK,
|
||||
url = url
|
||||
)
|
||||
val homesets = listOf(
|
||||
HomeSet(
|
||||
id = 0,
|
||||
serviceId = service.id,
|
||||
personal = false,
|
||||
url = mockServer.url("/addressbook-homeset/")
|
||||
)
|
||||
)
|
||||
|
||||
val refresher = homeSetRefresherFactory.create(service, client)
|
||||
assertFalse(refresher.shouldPreselect(collection, homesets))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun shouldPreselect_personal_notPersonal() {
|
||||
every { settings.getIntOrNull(Settings.PRESELECT_COLLECTIONS) } returns Settings.PRESELECT_COLLECTIONS_PERSONAL
|
||||
every { settings.getString(Settings.PRESELECT_COLLECTIONS_EXCLUDED) } returns ""
|
||||
|
||||
val collection = Collection(
|
||||
id = 0,
|
||||
serviceId = service.id,
|
||||
homeSetId = 0,
|
||||
type = Collection.TYPE_ADDRESSBOOK,
|
||||
url = mockServer.url("/addressbook-homeset/addressbook/")
|
||||
)
|
||||
val homesets = listOf(
|
||||
HomeSet(
|
||||
id = 0,
|
||||
serviceId = service.id,
|
||||
personal = false,
|
||||
url = mockServer.url("/addressbook-homeset/")
|
||||
)
|
||||
)
|
||||
|
||||
val refresher = homeSetRefresherFactory.create(service, client)
|
||||
assertFalse(refresher.shouldPreselect(collection, homesets))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun shouldPreselect_personal_isPersonal() {
|
||||
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("/addressbook-homeset/addressbook/")
|
||||
)
|
||||
val homesets = listOf(
|
||||
HomeSet(
|
||||
id = 0,
|
||||
serviceId = service.id,
|
||||
personal = true,
|
||||
url = mockServer.url("/addressbook-homeset/")
|
||||
)
|
||||
)
|
||||
|
||||
val refresher = homeSetRefresherFactory.create(service, client)
|
||||
assertTrue(refresher.shouldPreselect(collection, homesets))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun shouldPreselect_personal_isPersonalButBlacklisted() {
|
||||
val collectionUrl = mockServer.url("/addressbook-homeset/addressbook/")
|
||||
|
||||
every { settings.getIntOrNull(Settings.PRESELECT_COLLECTIONS) } returns Settings.PRESELECT_COLLECTIONS_PERSONAL
|
||||
every { settings.getString(Settings.PRESELECT_COLLECTIONS_EXCLUDED) } returns collectionUrl.toString()
|
||||
|
||||
val collection = Collection(
|
||||
id = 0,
|
||||
serviceId = service.id,
|
||||
homeSetId = 0,
|
||||
type = Collection.TYPE_ADDRESSBOOK,
|
||||
url = collectionUrl
|
||||
)
|
||||
val homesets = listOf(
|
||||
HomeSet(
|
||||
id = 0,
|
||||
serviceId = service.id,
|
||||
personal = true,
|
||||
url = mockServer.url("/addressbook-homeset/")
|
||||
)
|
||||
)
|
||||
|
||||
val refresher = homeSetRefresherFactory.create(service, client)
|
||||
assertFalse(refresher.shouldPreselect(collection, homesets))
|
||||
}
|
||||
|
||||
|
||||
companion object {
|
||||
|
||||
private const val PATH_CARDDAV = "/carddav"
|
||||
|
||||
private const val SUBPATH_PRINCIPAL = "/principal"
|
||||
private const val SUBPATH_ADDRESSBOOK_HOMESET_PERSONAL = "/addressbooks-homeset"
|
||||
private const val SUBPATH_ADDRESSBOOK_HOMESET_EMPTY = "/addressbooks-homeset-empty"
|
||||
private const val SUBPATH_ADDRESSBOOK = "/addressbooks/my-contacts"
|
||||
|
||||
}
|
||||
|
||||
class TestDispatcher(
|
||||
private val logger: Logger
|
||||
) : Dispatcher() {
|
||||
|
||||
override fun dispatch(request: RecordedRequest): MockResponse {
|
||||
val path = request.path!!.trimEnd('/')
|
||||
|
||||
if (request.method.equals("PROPFIND", true)) {
|
||||
val properties = when (path) {
|
||||
|
||||
PATH_CARDDAV + SUBPATH_ADDRESSBOOK_HOMESET_PERSONAL ->
|
||||
"<resourcetype>" +
|
||||
" <collection/>" +
|
||||
" <CARD:addressbook/>" +
|
||||
"</resourcetype>" +
|
||||
"<displayname>My Contacts</displayname>" +
|
||||
"<CARD:addressbook-description>My Contacts Description</CARD:addressbook-description>" +
|
||||
"<owner>" +
|
||||
" <href>${PATH_CARDDAV + SUBPATH_PRINCIPAL}</href>" +
|
||||
"</owner>"
|
||||
|
||||
SUBPATH_ADDRESSBOOK_HOMESET_EMPTY -> ""
|
||||
|
||||
else -> ""
|
||||
}
|
||||
|
||||
logger.info("Queried: $path")
|
||||
return MockResponse()
|
||||
.setResponseCode(207)
|
||||
.setBody(
|
||||
"<multistatus xmlns='DAV:' xmlns:CARD='urn:ietf:params:xml:ns:carddav' xmlns:CAL='urn:ietf:params:xml:ns:caldav'>" +
|
||||
"<response>" +
|
||||
" <href>${PATH_CARDDAV + SUBPATH_ADDRESSBOOK}</href>" +
|
||||
" <propstat><prop>" +
|
||||
properties +
|
||||
" </prop></propstat>" +
|
||||
" <status>HTTP/1.1 200 OK</status>" +
|
||||
"</response>" +
|
||||
"</multistatus>"
|
||||
)
|
||||
}
|
||||
|
||||
return MockResponse().setResponseCode(404)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,236 @@
|
||||
/*
|
||||
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
|
||||
*/
|
||||
|
||||
package at.bitfire.davdroid.servicedetection
|
||||
|
||||
import android.security.NetworkSecurityPolicy
|
||||
import at.bitfire.davdroid.db.AppDatabase
|
||||
import at.bitfire.davdroid.db.Collection
|
||||
import at.bitfire.davdroid.db.Principal
|
||||
import at.bitfire.davdroid.db.Service
|
||||
import at.bitfire.davdroid.network.HttpClientBuilder
|
||||
import at.bitfire.davdroid.settings.SettingsManager
|
||||
import dagger.hilt.android.testing.BindValue
|
||||
import dagger.hilt.android.testing.HiltAndroidRule
|
||||
import dagger.hilt.android.testing.HiltAndroidTest
|
||||
import io.mockk.impl.annotations.MockK
|
||||
import io.mockk.junit4.MockKRule
|
||||
import junit.framework.TestCase.assertEquals
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.mockwebserver.Dispatcher
|
||||
import okhttp3.mockwebserver.MockResponse
|
||||
import okhttp3.mockwebserver.MockWebServer
|
||||
import okhttp3.mockwebserver.RecordedRequest
|
||||
import org.junit.After
|
||||
import org.junit.Assume
|
||||
import org.junit.Before
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import java.util.logging.Logger
|
||||
import javax.inject.Inject
|
||||
|
||||
@HiltAndroidTest
|
||||
class PrincipalsRefresherTest {
|
||||
|
||||
@Inject
|
||||
lateinit var db: AppDatabase
|
||||
|
||||
@Inject
|
||||
lateinit var httpClientBuilder: HttpClientBuilder
|
||||
|
||||
@Inject
|
||||
lateinit var logger: Logger
|
||||
|
||||
@Inject
|
||||
lateinit var principalsRefresher: PrincipalsRefresher.Factory
|
||||
|
||||
@BindValue
|
||||
@MockK(relaxed = true)
|
||||
lateinit var settings: SettingsManager
|
||||
|
||||
@get:Rule
|
||||
val hiltRule = HiltAndroidRule(this)
|
||||
|
||||
@get:Rule
|
||||
val mockKRule = MockKRule(this)
|
||||
|
||||
private lateinit var client: OkHttpClient
|
||||
private lateinit var mockServer: MockWebServer
|
||||
private lateinit var service: Service
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
hiltRule.inject()
|
||||
|
||||
// Start mock web server
|
||||
mockServer = MockWebServer().apply {
|
||||
dispatcher = TestDispatcher(logger)
|
||||
start()
|
||||
}
|
||||
|
||||
// build HTTP client
|
||||
client = httpClientBuilder.build()
|
||||
Assume.assumeTrue(NetworkSecurityPolicy.getInstance().isCleartextTrafficPermitted)
|
||||
|
||||
// insert test service
|
||||
val serviceId = db.serviceDao().insertOrReplace(
|
||||
Service(id = 0, accountName = "test", type = Service.TYPE_CARDDAV, principal = null)
|
||||
)
|
||||
service = db.serviceDao().get(serviceId)!!
|
||||
}
|
||||
|
||||
@After
|
||||
fun tearDown() {
|
||||
mockServer.shutdown()
|
||||
}
|
||||
|
||||
|
||||
@Test
|
||||
fun refreshPrincipals_inaccessiblePrincipal() {
|
||||
// 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
|
||||
principalsRefresher.create(service, client).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() {
|
||||
// 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
|
||||
principalsRefresher.create(service, client).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() {
|
||||
// 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
|
||||
principalsRefresher.create(service, client).refreshPrincipals()
|
||||
|
||||
// Check principal was deleted
|
||||
val principals = db.principalDao().getByService(service.id)
|
||||
assertEquals(0, principals.size)
|
||||
}
|
||||
|
||||
|
||||
companion object {
|
||||
|
||||
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_GROUPPRINCIPAL_0 = "/groups/0"
|
||||
private const val SUBPATH_ADDRESSBOOK_HOMESET_PERSONAL = "/addressbooks-homeset"
|
||||
private const val SUBPATH_ADDRESSBOOK_HOMESET_EMPTY = "/addressbooks-homeset-empty"
|
||||
private const val SUBPATH_ADDRESSBOOK = "/addressbooks/my-contacts"
|
||||
|
||||
}
|
||||
|
||||
class TestDispatcher(
|
||||
private val logger: Logger
|
||||
) : Dispatcher() {
|
||||
|
||||
override fun dispatch(request: RecordedRequest): MockResponse {
|
||||
val path = request.path!!.trimEnd('/')
|
||||
|
||||
if (request.method.equals("PROPFIND", true)) {
|
||||
val properties = when (path) {
|
||||
|
||||
PATH_CARDDAV + SUBPATH_PRINCIPAL ->
|
||||
"<resourcetype><principal/></resourcetype>" +
|
||||
"<displayname>Mr. Wobbles</displayname>" + "<CARD:addressbook-home-set>" + " <href>${PATH_CARDDAV}${SUBPATH_ADDRESSBOOK_HOMESET_PERSONAL}</href>" + "</CARD:addressbook-home-set>" + "<group-membership>" + " <href>${PATH_CARDDAV}${SUBPATH_GROUPPRINCIPAL_0}</href>" +
|
||||
"</group-membership>"
|
||||
|
||||
PATH_CARDDAV + SUBPATH_PRINCIPAL_WITHOUT_COLLECTIONS ->
|
||||
"<CARD:addressbook-home-set>" +
|
||||
" <href>${PATH_CARDDAV}${SUBPATH_ADDRESSBOOK_HOMESET_EMPTY}</href>" +
|
||||
"</CARD:addressbook-home-set>" +
|
||||
"<displayname>Mr. Wobbles Jr.</displayname>"
|
||||
|
||||
|
||||
SUBPATH_ADDRESSBOOK_HOMESET_EMPTY -> ""
|
||||
|
||||
else -> ""
|
||||
}
|
||||
|
||||
logger.info("Queried: $path")
|
||||
return MockResponse()
|
||||
.setResponseCode(207)
|
||||
.setBody(
|
||||
"<multistatus xmlns='DAV:' xmlns:CARD='urn:ietf:params:xml:ns:carddav' xmlns:CAL='urn:ietf:params:xml:ns:caldav'>" +
|
||||
"<response>" +
|
||||
" <href>$path</href>" +
|
||||
" <propstat><prop>" +
|
||||
properties +
|
||||
" </prop></propstat>" +
|
||||
"</response>" +
|
||||
"</multistatus>"
|
||||
)
|
||||
}
|
||||
|
||||
return MockResponse().setResponseCode(404)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
/*
|
||||
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
|
||||
*/
|
||||
|
||||
package at.bitfire.davdroid.servicedetection
|
||||
|
||||
import android.security.NetworkSecurityPolicy
|
||||
import at.bitfire.davdroid.db.AppDatabase
|
||||
import at.bitfire.davdroid.db.Service
|
||||
import at.bitfire.davdroid.network.HttpClientBuilder
|
||||
import dagger.hilt.android.testing.HiltAndroidRule
|
||||
import dagger.hilt.android.testing.HiltAndroidTest
|
||||
import okhttp3.OkHttpClient
|
||||
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.Assume
|
||||
import org.junit.Before
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import java.util.logging.Logger
|
||||
import javax.inject.Inject
|
||||
|
||||
@HiltAndroidTest
|
||||
class ServiceRefresherTest {
|
||||
|
||||
@get:Rule
|
||||
val hiltRule = HiltAndroidRule(this)
|
||||
|
||||
@Inject
|
||||
lateinit var db: AppDatabase
|
||||
|
||||
@Inject
|
||||
lateinit var httpClientBuilder: HttpClientBuilder
|
||||
|
||||
@Inject
|
||||
lateinit var logger: Logger
|
||||
|
||||
@Inject
|
||||
lateinit var serviceRefresherFactory: ServiceRefresher.Factory
|
||||
|
||||
private lateinit var client: OkHttpClient
|
||||
private lateinit var mockServer: MockWebServer
|
||||
private lateinit var service: Service
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
hiltRule.inject()
|
||||
|
||||
// Start mock web server
|
||||
mockServer = MockWebServer().apply {
|
||||
dispatcher = TestDispatcher(logger)
|
||||
start()
|
||||
}
|
||||
|
||||
// build HTTP client
|
||||
client = httpClientBuilder.build()
|
||||
Assume.assumeTrue(NetworkSecurityPolicy.getInstance().isCleartextTrafficPermitted)
|
||||
|
||||
// insert test service
|
||||
val serviceId = db.serviceDao().insertOrReplace(
|
||||
Service(id = 0, accountName = "test", type = Service.TYPE_CARDDAV, principal = null)
|
||||
)
|
||||
service = db.serviceDao().get(serviceId)!!
|
||||
}
|
||||
|
||||
@After
|
||||
fun tearDown() {
|
||||
mockServer.shutdown()
|
||||
}
|
||||
|
||||
|
||||
@Test
|
||||
fun testDiscoverHomesets() {
|
||||
val baseUrl = mockServer.url(PATH_CARDDAV + SUBPATH_PRINCIPAL)
|
||||
|
||||
// Query home sets
|
||||
serviceRefresherFactory.create(service, client)
|
||||
.discoverHomesets(baseUrl)
|
||||
|
||||
// Check home set has been saved correctly to database
|
||||
val savedHomesets = db.homeSetDao().getByService(service.id)
|
||||
assertEquals(2, savedHomesets.size)
|
||||
|
||||
// Home set from current-user-principal
|
||||
val personalHomeset = savedHomesets[1]
|
||||
assertEquals(mockServer.url("$PATH_CARDDAV$SUBPATH_ADDRESSBOOK_HOMESET_PERSONAL/"), personalHomeset.url)
|
||||
assertEquals(service.id, personalHomeset.serviceId)
|
||||
// personal should be true for homesets detected at first query of current-user-principal (Even if they occur in a group principal as well!!!)
|
||||
assertEquals(true, personalHomeset.personal)
|
||||
|
||||
// Home set found in a group principal
|
||||
val groupHomeset = savedHomesets[0]
|
||||
assertEquals(mockServer.url("$PATH_CARDDAV$SUBPATH_ADDRESSBOOK_HOMESET_NON_PERSONAL/"), groupHomeset.url)
|
||||
assertEquals(service.id, groupHomeset.serviceId)
|
||||
// personal should be false for homesets not detected at the first query of current-user-principal (IE. in groups)
|
||||
assertEquals(false, groupHomeset.personal)
|
||||
}
|
||||
|
||||
|
||||
companion object {
|
||||
private const val PATH_CARDDAV = "/carddav"
|
||||
|
||||
private const val SUBPATH_PRINCIPAL = "/principal"
|
||||
private const val SUBPATH_GROUPPRINCIPAL_0 = "/groups/0"
|
||||
private const val SUBPATH_ADDRESSBOOK_HOMESET_PERSONAL = "/addressbooks-homeset"
|
||||
private const val SUBPATH_ADDRESSBOOK_HOMESET_NON_PERSONAL = "/addressbooks-homeset-non-personal"
|
||||
|
||||
}
|
||||
|
||||
class TestDispatcher(
|
||||
private val logger: Logger
|
||||
) : Dispatcher() {
|
||||
|
||||
override fun dispatch(request: RecordedRequest): MockResponse {
|
||||
val path = request.path!!.trimEnd('/')
|
||||
logger.info("Query: ${request.method} on $path ")
|
||||
|
||||
if (request.method.equals("PROPFIND", true)) {
|
||||
val properties = when (path) {
|
||||
PATH_CARDDAV + SUBPATH_PRINCIPAL ->
|
||||
"<resourcetype><principal/></resourcetype>" +
|
||||
"<displayname>Mr. Wobbles</displayname>" +
|
||||
"<CARD:addressbook-home-set>" +
|
||||
" <href>${PATH_CARDDAV}${SUBPATH_ADDRESSBOOK_HOMESET_PERSONAL}</href>" +
|
||||
"</CARD:addressbook-home-set>" +
|
||||
"<group-membership>" +
|
||||
" <href>${PATH_CARDDAV}${SUBPATH_GROUPPRINCIPAL_0}</href>" +
|
||||
"</group-membership>"
|
||||
|
||||
PATH_CARDDAV + SUBPATH_GROUPPRINCIPAL_0 ->
|
||||
"<resourcetype><principal/></resourcetype>" +
|
||||
"<displayname>All address books</displayname>" +
|
||||
"<CARD:addressbook-home-set>" +
|
||||
" <href>${PATH_CARDDAV}${SUBPATH_ADDRESSBOOK_HOMESET_PERSONAL}</href>" +
|
||||
" <href>${PATH_CARDDAV}${SUBPATH_ADDRESSBOOK_HOMESET_NON_PERSONAL}</href>" +
|
||||
"</CARD:addressbook-home-set>"
|
||||
|
||||
else -> ""
|
||||
}
|
||||
return MockResponse()
|
||||
.setResponseCode(207)
|
||||
.setBody(
|
||||
"<multistatus xmlns='DAV:' xmlns:CARD='urn:ietf:params:xml:ns:carddav' xmlns:CAL='urn:ietf:params:xml:ns:caldav'>" +
|
||||
"<response>" +
|
||||
" <href>$path</href>" +
|
||||
" <propstat><prop>" +
|
||||
properties +
|
||||
" </prop></propstat>" +
|
||||
"</response>" +
|
||||
"</multistatus>"
|
||||
)
|
||||
}
|
||||
|
||||
return MockResponse().setResponseCode(404)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
/*
|
||||
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
|
||||
*/
|
||||
|
||||
package at.bitfire.davdroid.settings.migration
|
||||
|
||||
import android.accounts.Account
|
||||
import android.content.ContentResolver
|
||||
import android.content.Context
|
||||
import android.content.SyncRequest
|
||||
import android.os.Bundle
|
||||
import android.provider.CalendarContract
|
||||
import androidx.test.filters.SdkSuppress
|
||||
import at.bitfire.davdroid.sync.account.TestAccount
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import dagger.hilt.android.testing.HiltAndroidRule
|
||||
import dagger.hilt.android.testing.HiltAndroidTest
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import kotlinx.coroutines.withTimeout
|
||||
import org.junit.After
|
||||
import org.junit.AfterClass
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assume
|
||||
import org.junit.Before
|
||||
import org.junit.BeforeClass
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import java.util.logging.Logger
|
||||
import javax.inject.Inject
|
||||
|
||||
@HiltAndroidTest
|
||||
class AccountSettingsMigration21Test {
|
||||
|
||||
@get:Rule
|
||||
val hiltRule = HiltAndroidRule(this)
|
||||
|
||||
@Inject
|
||||
lateinit var migration: AccountSettingsMigration21
|
||||
|
||||
@Inject
|
||||
@ApplicationContext
|
||||
lateinit var context: Context
|
||||
|
||||
@Inject
|
||||
lateinit var logger: Logger
|
||||
|
||||
lateinit var account: Account
|
||||
val authority = CalendarContract.AUTHORITY
|
||||
|
||||
private val inPendingState = MutableStateFlow(false)
|
||||
private var statusChangeListener: Any? = null
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
hiltRule.inject()
|
||||
|
||||
account = TestAccount.create()
|
||||
|
||||
// Enable sync globally and for the test account
|
||||
ContentResolver.setIsSyncable(account, authority, 1)
|
||||
|
||||
// Start hot flow
|
||||
registerSyncStateObserver()
|
||||
}
|
||||
|
||||
@After
|
||||
fun tearDown() {
|
||||
unregisterSyncStateObserver()
|
||||
TestAccount.remove(account)
|
||||
}
|
||||
|
||||
|
||||
@SdkSuppress(minSdkVersion = 34)
|
||||
@Test
|
||||
fun testCancelsSyncAndClearsPendingState() = runBlocking {
|
||||
// Move into forever pending state
|
||||
ContentResolver.requestSync(syncRequest())
|
||||
|
||||
// Wait until we are in forever pending state (with timeout)
|
||||
withTimeout(10_000) {
|
||||
inPendingState.first { it }
|
||||
}
|
||||
|
||||
// Assume that we are now in the forever pending state (Skips test otherwise)
|
||||
Assume.assumeTrue(ContentResolver.isSyncPending(account, authority))
|
||||
|
||||
// Run the migration which should cancel the forever pending sync for all accounts
|
||||
migration.migrate(account)
|
||||
|
||||
// Wait for the state to change (with timeout)
|
||||
withTimeout(10_000) {
|
||||
inPendingState.first { !it }
|
||||
}
|
||||
|
||||
// Check the sync is now not pending anymore
|
||||
assertFalse(ContentResolver.isSyncPending(account, authority))
|
||||
}
|
||||
|
||||
|
||||
// helpers
|
||||
|
||||
private fun syncRequest() = SyncRequest.Builder()
|
||||
.setSyncAdapter(account, authority)
|
||||
.syncOnce()
|
||||
.setExtras(Bundle()) // needed for Android 9
|
||||
.setExpedited(true) // sync request will be scheduled at the front of the sync request queue
|
||||
.setManual(true) // equivalent of setting both SYNC_EXTRAS_IGNORE_SETTINGS and SYNC_EXTRAS_IGNORE_BACKOFF
|
||||
.build()
|
||||
|
||||
private fun registerSyncStateObserver() {
|
||||
// listener pushes updates immediately when sync status changes
|
||||
statusChangeListener = ContentResolver.addStatusChangeListener(
|
||||
ContentResolver.SYNC_OBSERVER_TYPE_PENDING or ContentResolver.SYNC_OBSERVER_TYPE_ACTIVE
|
||||
) {
|
||||
inPendingState.tryEmit(ContentResolver.isSyncPending(account, authority))
|
||||
}
|
||||
|
||||
// Emit initial state
|
||||
inPendingState.tryEmit(ContentResolver.isSyncPending(account, authority))
|
||||
}
|
||||
|
||||
private fun unregisterSyncStateObserver() {
|
||||
statusChangeListener?.let { ContentResolver.removeStatusChangeListener(it) }
|
||||
}
|
||||
|
||||
companion object {
|
||||
|
||||
var globalAutoSyncBeforeTest = false
|
||||
|
||||
@BeforeClass
|
||||
@JvmStatic
|
||||
fun before() {
|
||||
globalAutoSyncBeforeTest = ContentResolver.getMasterSyncAutomatically()
|
||||
|
||||
// We'll request syncs explicitly and with SYNC_EXTRAS_IGNORE_SETTINGS
|
||||
ContentResolver.setMasterSyncAutomatically(false)
|
||||
}
|
||||
|
||||
@AfterClass
|
||||
@JvmStatic
|
||||
fun after() {
|
||||
ContentResolver.setMasterSyncAutomatically(globalAutoSyncBeforeTest)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,238 @@
|
||||
/*
|
||||
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
|
||||
*/
|
||||
|
||||
package at.bitfire.davdroid.sync
|
||||
|
||||
import android.accounts.Account
|
||||
import android.content.ContentResolver
|
||||
import android.content.SyncRequest
|
||||
import android.content.SyncStatusObserver
|
||||
import android.os.Bundle
|
||||
import android.provider.CalendarContract
|
||||
import androidx.test.filters.SdkSuppress
|
||||
import at.bitfire.davdroid.sync.account.TestAccount
|
||||
import dagger.hilt.android.testing.HiltAndroidRule
|
||||
import dagger.hilt.android.testing.HiltAndroidTest
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import kotlinx.coroutines.withTimeout
|
||||
import org.junit.After
|
||||
import org.junit.AfterClass
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Before
|
||||
import org.junit.BeforeClass
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import java.util.Collections
|
||||
import java.util.LinkedList
|
||||
import java.util.logging.Logger
|
||||
import javax.inject.Inject
|
||||
import kotlin.time.Duration.Companion.seconds
|
||||
|
||||
@HiltAndroidTest
|
||||
class AndroidSyncFrameworkTest: SyncStatusObserver {
|
||||
|
||||
@get:Rule
|
||||
val hiltRule = HiltAndroidRule(this)
|
||||
|
||||
@Inject
|
||||
lateinit var logger: Logger
|
||||
|
||||
lateinit var account: Account
|
||||
val authority = CalendarContract.AUTHORITY
|
||||
|
||||
private lateinit var stateChangeListener: Any
|
||||
private val recordedStates = Collections.synchronizedList(LinkedList<State>())
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
hiltRule.inject()
|
||||
|
||||
account = TestAccount.create()
|
||||
|
||||
// Enable sync globally and for the test account
|
||||
ContentResolver.setIsSyncable(account, authority, 1)
|
||||
|
||||
// Remember states the sync framework reports as pairs of (sync pending, sync active).
|
||||
recordedStates.clear()
|
||||
onStatusChanged(0) // record first entry (pending = false, active = false)
|
||||
stateChangeListener = ContentResolver.addStatusChangeListener(
|
||||
ContentResolver.SYNC_OBSERVER_TYPE_PENDING or ContentResolver.SYNC_OBSERVER_TYPE_ACTIVE,
|
||||
this
|
||||
)
|
||||
}
|
||||
|
||||
@After
|
||||
fun tearDown() {
|
||||
ContentResolver.removeStatusChangeListener(stateChangeListener)
|
||||
TestAccount.remove(account)
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Correct behaviour of the sync framework on Android 13 and below.
|
||||
* Pending state is correctly reflected
|
||||
*/
|
||||
@SdkSuppress(maxSdkVersion = 33)
|
||||
@Test
|
||||
fun testVerifySyncAlwaysPending_correctBehaviour_android13() {
|
||||
verifySyncStates(
|
||||
listOf(
|
||||
State(pending = false, active = false), // no sync pending or active
|
||||
State(pending = true, active = false, optional = true), // sync becomes pending
|
||||
State(pending = true, active = true), // ... and pending and active at the same time
|
||||
State(pending = false, active = true), // ... and then only active
|
||||
State(pending = false, active = false) // sync finished
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
/* SHOULD BE FIXED WITH https://github.com/bitfireAT/davx5-ose/issues/1748
|
||||
* Wrong behaviour of the sync framework on Android 14+.
|
||||
* Pending state stays true forever (after initial run), active state behaves correctly
|
||||
*/
|
||||
/*@SdkSuppress(minSdkVersion = 34 /*, maxSdkVersion = 36 */)
|
||||
@Test
|
||||
fun testVerifySyncAlwaysPending_wrongBehaviour_android14() {
|
||||
verifySyncStates(
|
||||
listOf(
|
||||
State(pending = false, active = false), // no sync pending or active
|
||||
State(pending = true, active = false, optional = true), // sync becomes pending
|
||||
State(pending = true, active = true), // ... and pending and active at the same time
|
||||
State(pending = true, active = false) // ... and finishes, but stays pending
|
||||
)
|
||||
)
|
||||
}*/
|
||||
|
||||
|
||||
// helpers
|
||||
|
||||
private fun syncRequest() = SyncRequest.Builder()
|
||||
.setSyncAdapter(account, authority)
|
||||
.syncOnce()
|
||||
.setExtras(Bundle()) // needed for Android 9
|
||||
.setExpedited(true) // sync request will be scheduled at the front of the sync request queue
|
||||
.setManual(true) // equivalent of setting both SYNC_EXTRAS_IGNORE_SETTINGS and SYNC_EXTRAS_IGNORE_BACKOFF
|
||||
.build()
|
||||
|
||||
/**
|
||||
* Verifies that the given expected states match the recorded states.
|
||||
*/
|
||||
private fun verifySyncStates(expectedStates: List<State>) = runBlocking {
|
||||
// Verify that last state is non-optional.
|
||||
if (expectedStates.last().optional)
|
||||
throw IllegalArgumentException("Last expected state must not be optional")
|
||||
|
||||
// We use runBlocking for these tests because it uses the default dispatcher
|
||||
// which does not auto-advance virtual time and we need real system time to
|
||||
// test the sync framework behavior.
|
||||
|
||||
ContentResolver.requestSync(syncRequest())
|
||||
|
||||
// Even though the always-pending-bug is present on Android 14+, the sync active
|
||||
// state behaves correctly, so we can record the state changes as pairs (pending,
|
||||
// active) and expect a certain sequence of state pairs to verify the presence or
|
||||
// absence of the bug on different Android versions.
|
||||
withTimeout(60.seconds) { // Usually takes less than 30 seconds
|
||||
while (recordedStates.size < expectedStates.size) {
|
||||
// verify already known states
|
||||
if (recordedStates.isNotEmpty())
|
||||
assertStatesEqual(expectedStates, recordedStates, fullMatch = false)
|
||||
|
||||
delay(500) // avoid busy-waiting
|
||||
}
|
||||
|
||||
assertStatesEqual(expectedStates, recordedStates, fullMatch = true)
|
||||
}
|
||||
}
|
||||
|
||||
private fun assertStatesEqual(expectedStates: List<State>, actualStates: List<State>, fullMatch: Boolean) {
|
||||
assertTrue("Expected states=$expectedStates, actual=$actualStates", statesMatch(expectedStates, actualStates, fullMatch))
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks whether [actualStates] have matching [expectedStates], under the condition
|
||||
* that expected states with the [State.optional] flag can be skipped.
|
||||
*
|
||||
* Note: When [fullMatch] is not set, this method can return _true_ even if not all expected states are used.
|
||||
*
|
||||
* @param expectedStates expected states (can include optional states which don't have to be present in actual states)
|
||||
* @param actualStates actual states
|
||||
* @param fullMatch whether all non-optional expected states must be present in actual states
|
||||
*/
|
||||
private fun statesMatch(expectedStates: List<State>, actualStates: List<State>, fullMatch: Boolean): Boolean {
|
||||
// iterate through entries
|
||||
val expectedIterator = expectedStates.iterator()
|
||||
for (actual in actualStates) {
|
||||
if (!expectedIterator.hasNext())
|
||||
return false
|
||||
var expected = expectedIterator.next()
|
||||
|
||||
// skip optional expected entries if they don't match the actual entry
|
||||
while (!actual.stateEquals(expected) && expected.optional) {
|
||||
if (!expectedIterator.hasNext())
|
||||
return false
|
||||
expected = expectedIterator.next()
|
||||
}
|
||||
|
||||
// we now have a non-optional expected state and it must match
|
||||
if (!actual.stateEquals(expected))
|
||||
return false
|
||||
}
|
||||
|
||||
// full match: all expected states must have been used
|
||||
if (fullMatch && expectedIterator.hasNext())
|
||||
return false
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
|
||||
// SyncStatusObserver implementation and data class
|
||||
|
||||
override fun onStatusChanged(which: Int) {
|
||||
val state = State(
|
||||
pending = ContentResolver.isSyncPending(account, authority),
|
||||
active = ContentResolver.isSyncActive(account, authority)
|
||||
)
|
||||
synchronized(recordedStates) {
|
||||
if (recordedStates.lastOrNull() != state) {
|
||||
logger.info("$account syncState = $state")
|
||||
recordedStates += state
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
data class State(
|
||||
val pending: Boolean,
|
||||
val active: Boolean,
|
||||
val optional: Boolean = false
|
||||
) {
|
||||
fun stateEquals(other: State) =
|
||||
pending == other.pending && active == other.active
|
||||
}
|
||||
|
||||
|
||||
companion object {
|
||||
|
||||
var globalAutoSyncBeforeTest = false
|
||||
|
||||
@BeforeClass
|
||||
@JvmStatic
|
||||
fun before() {
|
||||
globalAutoSyncBeforeTest = ContentResolver.getMasterSyncAutomatically()
|
||||
|
||||
// We'll request syncs explicitly and with SYNC_EXTRAS_IGNORE_SETTINGS
|
||||
ContentResolver.setMasterSyncAutomatically(false)
|
||||
}
|
||||
|
||||
@AfterClass
|
||||
@JvmStatic
|
||||
fun after() {
|
||||
ContentResolver.setMasterSyncAutomatically(globalAutoSyncBeforeTest)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
/*
|
||||
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
|
||||
*/
|
||||
|
||||
package at.bitfire.davdroid.sync
|
||||
|
||||
import android.Manifest
|
||||
import android.accounts.Account
|
||||
import android.content.ContentProviderClient
|
||||
import android.content.Context
|
||||
import android.content.Entity
|
||||
import android.provider.CalendarContract
|
||||
import android.provider.CalendarContract.Calendars
|
||||
import android.provider.CalendarContract.Events
|
||||
import androidx.core.content.contentValuesOf
|
||||
import androidx.test.rule.GrantPermissionRule
|
||||
import at.bitfire.davdroid.resource.LocalCalendar
|
||||
import at.bitfire.davdroid.resource.LocalEvent
|
||||
import at.bitfire.davdroid.sync.account.TestAccount
|
||||
import at.bitfire.ical4android.util.MiscUtils.closeCompat
|
||||
import at.bitfire.synctools.storage.calendar.AndroidCalendar
|
||||
import at.bitfire.synctools.storage.calendar.AndroidCalendarProvider
|
||||
import at.bitfire.synctools.storage.calendar.EventAndExceptions
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import dagger.hilt.android.testing.HiltAndroidRule
|
||||
import dagger.hilt.android.testing.HiltAndroidTest
|
||||
import io.mockk.mockk
|
||||
import okio.Buffer
|
||||
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 javax.inject.Inject
|
||||
|
||||
@HiltAndroidTest
|
||||
class CalendarSyncManagerTest {
|
||||
|
||||
@get:Rule
|
||||
val hiltRule = HiltAndroidRule(this)
|
||||
|
||||
@get:Rule
|
||||
val permissionsRule = GrantPermissionRule.grant(
|
||||
Manifest.permission.READ_CALENDAR, Manifest.permission.WRITE_CALENDAR
|
||||
)
|
||||
|
||||
@Inject @ApplicationContext
|
||||
lateinit var context: Context
|
||||
|
||||
@Inject
|
||||
lateinit var localCalendarFactory: LocalCalendar.Factory
|
||||
|
||||
@Inject
|
||||
lateinit var syncManagerFactory: CalendarSyncManager.Factory
|
||||
|
||||
lateinit var account: Account
|
||||
lateinit var providerClient: ContentProviderClient
|
||||
lateinit var androidCalendar: AndroidCalendar
|
||||
lateinit var localCalendar: LocalCalendar
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
hiltRule.inject()
|
||||
|
||||
account = TestAccount.create()
|
||||
providerClient = context.contentResolver.acquireContentProviderClient(CalendarContract.AUTHORITY)!!
|
||||
|
||||
// create LocalCalendar
|
||||
val androidCalendarProvider = AndroidCalendarProvider(account, providerClient)
|
||||
androidCalendar = androidCalendarProvider.createAndGetCalendar(contentValuesOf(
|
||||
Calendars.NAME to "Sample Calendar"
|
||||
))
|
||||
localCalendar = localCalendarFactory.create(androidCalendar)
|
||||
}
|
||||
|
||||
@After
|
||||
fun tearDown() {
|
||||
localCalendar.androidCalendar.delete()
|
||||
providerClient.closeCompat()
|
||||
TestAccount.remove(account)
|
||||
}
|
||||
|
||||
|
||||
@Test
|
||||
fun test_generateUpload_existingUid() {
|
||||
val result = syncManager().generateUpload(LocalEvent(
|
||||
localCalendar.recurringCalendar,
|
||||
EventAndExceptions(
|
||||
main = Entity(contentValuesOf(
|
||||
Events._ID to 1,
|
||||
Events.CALENDAR_ID to androidCalendar.id,
|
||||
Events.DTSTART to System.currentTimeMillis(),
|
||||
Events.UID_2445 to "existing-uid"
|
||||
)),
|
||||
exceptions = emptyList()
|
||||
)
|
||||
))
|
||||
|
||||
assertEquals("existing-uid.ics", result.suggestedFileName)
|
||||
|
||||
val iCal = Buffer().also {
|
||||
result.requestBody.writeTo(it)
|
||||
}.readString(Charsets.UTF_8)
|
||||
assertTrue(iCal.contains("UID:existing-uid\r\n"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun generateUpload_noUid() {
|
||||
val result = syncManager().generateUpload(LocalEvent(
|
||||
localCalendar.recurringCalendar,
|
||||
EventAndExceptions(
|
||||
main = Entity(contentValuesOf(
|
||||
Events._ID to 2,
|
||||
Events.CALENDAR_ID to androidCalendar.id,
|
||||
Events.DTSTART to System.currentTimeMillis()
|
||||
)),
|
||||
exceptions = emptyList()
|
||||
)
|
||||
))
|
||||
|
||||
assertTrue(result.suggestedFileName.matches(UUID_FILENAME_REGEX))
|
||||
val uuid = result.suggestedFileName.removeSuffix(".ics")
|
||||
|
||||
val iCal = Buffer().also {
|
||||
result.requestBody.writeTo(it)
|
||||
}.readString(Charsets.UTF_8)
|
||||
assertTrue(iCal.contains("UID:$uuid\r\n"))
|
||||
|
||||
}
|
||||
|
||||
|
||||
// helpers
|
||||
|
||||
private fun syncManager() = syncManagerFactory.calendarSyncManager(
|
||||
account = account,
|
||||
httpClient = mockk(),
|
||||
syncResult = mockk(),
|
||||
localCalendar = mockk(),
|
||||
collection = mockk(),
|
||||
resync = mockk()
|
||||
)
|
||||
|
||||
|
||||
companion object {
|
||||
|
||||
val UUID_FILENAME_REGEX = "^[0-9a-fA-F]{8}\\b-[0-9a-fA-F]{4}\\b-[0-9a-fA-F]{4}\\b-[0-9a-fA-F]{4}\\b-[0-9a-fA-F]{12}\\.ics$".toRegex()
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
/*
|
||||
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
|
||||
*/
|
||||
|
||||
package at.bitfire.davdroid.sync
|
||||
|
||||
import android.accounts.Account
|
||||
import android.content.AbstractThreadedSyncAdapter
|
||||
import android.content.ContentProviderClient
|
||||
import android.content.Context
|
||||
import android.content.SyncResult
|
||||
import android.os.Bundle
|
||||
import android.os.IBinder
|
||||
import at.bitfire.davdroid.sync.adapter.SyncAdapter
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import java.util.logging.Level
|
||||
import java.util.logging.Logger
|
||||
import javax.inject.Inject
|
||||
|
||||
class FakeSyncAdapter @Inject constructor(
|
||||
@ApplicationContext context: Context,
|
||||
private val logger: Logger
|
||||
): AbstractThreadedSyncAdapter(context, true), SyncAdapter {
|
||||
|
||||
init {
|
||||
logger.info("FakeSyncAdapter created")
|
||||
}
|
||||
|
||||
override fun onPerformSync(account: Account, extras: Bundle, authority: String, provider: ContentProviderClient, syncResult: SyncResult) {
|
||||
logger.log(
|
||||
Level.INFO,
|
||||
"onPerformSync(account=$account, extras=$extras, authority=$authority, syncResult=$syncResult)",
|
||||
extras.keySet().map { key -> "extras[$key] = ${extras[key]}" }
|
||||
)
|
||||
|
||||
// fake 5 sec sync
|
||||
try {
|
||||
Thread.sleep(5000)
|
||||
} catch (_: InterruptedException) {
|
||||
logger.info("onPerformSync($account) cancelled")
|
||||
}
|
||||
|
||||
logger.info("onPerformSync($account) finished")
|
||||
}
|
||||
|
||||
|
||||
// SyncAdapter implementation and Hilt module
|
||||
|
||||
override fun getBinder(): IBinder = syncAdapterBinder
|
||||
|
||||
}
|
||||
@@ -9,7 +9,7 @@ import android.content.ContentProviderClient
|
||||
import android.content.Context
|
||||
import at.bitfire.davdroid.db.Collection
|
||||
import at.bitfire.davdroid.db.Service
|
||||
import at.bitfire.davdroid.network.HttpClient
|
||||
import at.bitfire.davdroid.network.HttpClientBuilder
|
||||
import at.bitfire.davdroid.repository.DavServiceRepository
|
||||
import at.bitfire.davdroid.resource.LocalJtxCollection
|
||||
import at.bitfire.davdroid.resource.LocalJtxCollectionStore
|
||||
@@ -46,7 +46,7 @@ class JtxSyncManagerTest {
|
||||
lateinit var context: Context
|
||||
|
||||
@Inject
|
||||
lateinit var httpClientBuilder: HttpClient.Builder
|
||||
lateinit var httpClientBuilder: HttpClientBuilder
|
||||
|
||||
@Inject
|
||||
lateinit var serviceRepository: DavServiceRepository
|
||||
|
||||
@@ -4,9 +4,11 @@
|
||||
|
||||
package at.bitfire.davdroid.sync
|
||||
|
||||
import android.content.Context
|
||||
import at.bitfire.davdroid.resource.LocalResource
|
||||
import java.util.Optional
|
||||
|
||||
class LocalTestResource: LocalResource<Any> {
|
||||
class LocalTestResource: LocalResource {
|
||||
|
||||
override val id: Long? = null
|
||||
override var fileName: String? = null
|
||||
@@ -17,12 +19,10 @@ class LocalTestResource: LocalResource<Any> {
|
||||
var deleted = false
|
||||
var dirty = false
|
||||
|
||||
override fun prepareForUpload() = "generated-file.txt"
|
||||
|
||||
override fun clearDirty(fileName: String?, eTag: String?, scheduleTag: String?) {
|
||||
override fun clearDirty(fileName: Optional<String>, eTag: String?, scheduleTag: String?) {
|
||||
dirty = false
|
||||
if (fileName != null)
|
||||
this.fileName = fileName
|
||||
if (fileName.isPresent)
|
||||
this.fileName = fileName.get()
|
||||
this.eTag = eTag
|
||||
this.scheduleTag = scheduleTag
|
||||
}
|
||||
@@ -31,9 +31,14 @@ class LocalTestResource: LocalResource<Any> {
|
||||
this.flags = flags
|
||||
}
|
||||
|
||||
override fun add() = throw NotImplementedError()
|
||||
override fun update(data: Any) = throw NotImplementedError()
|
||||
override fun delete() = throw NotImplementedError()
|
||||
override fun updateUid(uid: String) { /* no-op */ }
|
||||
override fun updateSequence(sequence: Int) = throw NotImplementedError()
|
||||
|
||||
override fun deleteLocal() = throw NotImplementedError()
|
||||
override fun resetDeleted() = throw NotImplementedError()
|
||||
|
||||
override fun getDebugSummary() = "Test Resource"
|
||||
|
||||
override fun getViewUri(context: Context) = null
|
||||
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
/*
|
||||
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
|
||||
*/
|
||||
|
||||
package at.bitfire.davdroid.sync
|
||||
|
||||
import android.accounts.Account
|
||||
import at.bitfire.dav4jvm.HttpUtils.toKtorUrl
|
||||
import at.bitfire.davdroid.settings.AccountSettings
|
||||
import at.bitfire.davdroid.settings.Credentials
|
||||
import at.bitfire.davdroid.sync.account.TestAccount
|
||||
import at.bitfire.davdroid.util.SensitiveString.Companion.toSensitiveString
|
||||
import dagger.hilt.android.testing.HiltAndroidRule
|
||||
import dagger.hilt.android.testing.HiltAndroidTest
|
||||
import io.ktor.http.HttpHeaders
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import okhttp3.mockwebserver.MockResponse
|
||||
import okhttp3.mockwebserver.MockWebServer
|
||||
import org.junit.After
|
||||
import org.junit.Assert.assertArrayEquals
|
||||
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
|
||||
import java.net.InetAddress
|
||||
import javax.inject.Inject
|
||||
|
||||
@HiltAndroidTest
|
||||
class ResourceDownloaderTest {
|
||||
|
||||
@get:Rule
|
||||
val hiltRule = HiltAndroidRule(this)
|
||||
|
||||
@Inject
|
||||
lateinit var accountSettingsFactory: AccountSettings.Factory
|
||||
|
||||
@Inject
|
||||
lateinit var resourceDownloaderFactory: ResourceDownloader.Factory
|
||||
|
||||
lateinit var account: Account
|
||||
lateinit var server: MockWebServer
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
hiltRule.inject()
|
||||
server = MockWebServer().apply {
|
||||
start()
|
||||
}
|
||||
|
||||
account = TestAccount.create()
|
||||
|
||||
// add credentials to test account so that we can check whether they have been sent
|
||||
val settings = accountSettingsFactory.create(account)
|
||||
settings.credentials(Credentials("test", "test".toSensitiveString()))
|
||||
}
|
||||
|
||||
@After
|
||||
fun tearDown() {
|
||||
TestAccount.remove(account)
|
||||
server.close()
|
||||
}
|
||||
|
||||
|
||||
@Test
|
||||
fun testDownload_ExternalDomain() = runTest {
|
||||
val baseUrl = server.url("/")
|
||||
|
||||
// URL should be http://localhost, replace with http://127.0.0.1 to have other domain
|
||||
Assume.assumeTrue(baseUrl.host == "localhost")
|
||||
val baseUrlIp = baseUrl.newBuilder()
|
||||
.host(InetAddress.getByName(baseUrl.host).hostAddress!!)
|
||||
.build()
|
||||
|
||||
server.enqueue(MockResponse()
|
||||
.setResponseCode(200)
|
||||
.setBody("TEST"))
|
||||
|
||||
val downloader = resourceDownloaderFactory.create(account, baseUrl.host)
|
||||
val result = downloader.download(baseUrlIp.toKtorUrl())
|
||||
|
||||
// authentication was NOT sent because request is not for original domain
|
||||
val sentAuth = server.takeRequest().getHeader(HttpHeaders.Authorization)
|
||||
assertNull(sentAuth)
|
||||
|
||||
// and result is OK
|
||||
assertArrayEquals("TEST".toByteArray(), result)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testDownload_SameDomain() = runTest {
|
||||
server.enqueue(MockResponse()
|
||||
.setResponseCode(200)
|
||||
.setBody("TEST"))
|
||||
|
||||
val baseUrl = server.url("/")
|
||||
val downloader = resourceDownloaderFactory.create(account, baseUrl.host)
|
||||
val result = downloader.download(baseUrl.toKtorUrl())
|
||||
|
||||
// authentication was sent
|
||||
val sentAuth = server.takeRequest().getHeader(HttpHeaders.Authorization)
|
||||
assertEquals("Basic dGVzdDp0ZXN0", sentAuth)
|
||||
|
||||
// and result is OK
|
||||
assertArrayEquals("TEST".toByteArray(), result)
|
||||
}
|
||||
|
||||
}
|
||||
@@ -5,6 +5,7 @@
|
||||
package at.bitfire.davdroid.sync
|
||||
|
||||
import android.accounts.Account
|
||||
import android.content.ContentResolver
|
||||
import android.content.Context
|
||||
import android.content.SyncResult
|
||||
import android.os.Bundle
|
||||
@@ -13,17 +14,17 @@ import androidx.hilt.work.HiltWorkerFactory
|
||||
import androidx.work.WorkInfo
|
||||
import androidx.work.WorkManager
|
||||
import at.bitfire.davdroid.TestUtils
|
||||
import at.bitfire.davdroid.repository.DavCollectionRepository
|
||||
import at.bitfire.davdroid.repository.DavServiceRepository
|
||||
import at.bitfire.davdroid.settings.AccountSettings
|
||||
import at.bitfire.davdroid.sync.account.TestAccount
|
||||
import at.bitfire.davdroid.sync.adapter.SyncAdapterImpl
|
||||
import at.bitfire.davdroid.sync.worker.SyncWorkerManager
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import dagger.hilt.android.testing.BindValue
|
||||
import dagger.hilt.android.testing.HiltAndroidRule
|
||||
import dagger.hilt.android.testing.HiltAndroidTest
|
||||
import io.mockk.Awaits
|
||||
import io.mockk.coEvery
|
||||
import io.mockk.every
|
||||
import io.mockk.impl.annotations.MockK
|
||||
import io.mockk.junit4.MockKRule
|
||||
import io.mockk.just
|
||||
import io.mockk.mockk
|
||||
@@ -39,36 +40,12 @@ import org.junit.After
|
||||
import org.junit.Before
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.rules.Timeout
|
||||
import java.util.logging.Logger
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Provider
|
||||
import kotlin.coroutines.cancellation.CancellationException
|
||||
|
||||
@HiltAndroidTest
|
||||
class SyncAdapterServicesTest {
|
||||
|
||||
lateinit var account: Account
|
||||
|
||||
@Inject
|
||||
lateinit var accountSettingsFactory: AccountSettings.Factory
|
||||
|
||||
@Inject
|
||||
lateinit var collectionRepository: DavCollectionRepository
|
||||
|
||||
@Inject @ApplicationContext
|
||||
lateinit var context: Context
|
||||
|
||||
@Inject
|
||||
lateinit var logger: Logger
|
||||
|
||||
@Inject
|
||||
lateinit var serviceRepository: DavServiceRepository
|
||||
|
||||
@Inject
|
||||
lateinit var syncConditionsFactory: SyncConditions.Factory
|
||||
|
||||
@Inject
|
||||
lateinit var workerFactory: HiltWorkerFactory
|
||||
class SyncAdapterImplTest {
|
||||
|
||||
@get:Rule
|
||||
val hiltRule = HiltAndroidRule(this)
|
||||
@@ -76,10 +53,21 @@ class SyncAdapterServicesTest {
|
||||
@get:Rule
|
||||
val mockkRule = MockKRule(this)
|
||||
|
||||
// test methods should run quickly and not wait 60 seconds for a sync timeout or something like that
|
||||
@get:Rule
|
||||
val timeoutRule: Timeout = Timeout.seconds(5)
|
||||
@Inject @ApplicationContext
|
||||
lateinit var context: Context
|
||||
|
||||
@Inject
|
||||
lateinit var syncAdapterImplProvider: Provider<SyncAdapterImpl>
|
||||
|
||||
@BindValue @MockK
|
||||
lateinit var syncWorkerManager: SyncWorkerManager
|
||||
|
||||
@Inject
|
||||
lateinit var workerFactory: HiltWorkerFactory
|
||||
|
||||
lateinit var account: Account
|
||||
|
||||
private var masterSyncStateBeforeTest = ContentResolver.getMasterSyncAutomatically()
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
@@ -87,33 +75,23 @@ class SyncAdapterServicesTest {
|
||||
TestUtils.setUpWorkManager(context, workerFactory)
|
||||
|
||||
account = TestAccount.create()
|
||||
|
||||
ContentResolver.setMasterSyncAutomatically(true)
|
||||
ContentResolver.setSyncAutomatically(account, CalendarContract.AUTHORITY, true)
|
||||
ContentResolver.setIsSyncable(account, CalendarContract.AUTHORITY, 1)
|
||||
}
|
||||
|
||||
@After
|
||||
fun tearDown() {
|
||||
ContentResolver.setMasterSyncAutomatically(masterSyncStateBeforeTest)
|
||||
TestAccount.remove(account)
|
||||
}
|
||||
|
||||
|
||||
private fun syncAdapter(
|
||||
syncWorkerManager: SyncWorkerManager
|
||||
): SyncAdapterService.SyncAdapter =
|
||||
SyncAdapterService.SyncAdapter(
|
||||
accountSettingsFactory = accountSettingsFactory,
|
||||
collectionRepository = collectionRepository,
|
||||
serviceRepository = serviceRepository,
|
||||
context = context,
|
||||
logger = logger,
|
||||
syncConditionsFactory = syncConditionsFactory,
|
||||
syncWorkerManager = syncWorkerManager
|
||||
)
|
||||
|
||||
|
||||
@Test
|
||||
fun testSyncAdapter_onPerformSync_cancellation() = runTest {
|
||||
val syncWorkerManager = mockk<SyncWorkerManager>()
|
||||
val syncAdapter = syncAdapter(syncWorkerManager = syncWorkerManager)
|
||||
val workManager = WorkManager.getInstance(context)
|
||||
val syncAdapter = syncAdapterImplProvider.get()
|
||||
|
||||
mockkObject(workManager) {
|
||||
// don't actually create a worker
|
||||
@@ -136,9 +114,8 @@ class SyncAdapterServicesTest {
|
||||
|
||||
@Test
|
||||
fun testSyncAdapter_onPerformSync_returnsAfterTimeout() {
|
||||
val syncWorkerManager = mockk<SyncWorkerManager>()
|
||||
val syncAdapter = syncAdapter(syncWorkerManager = syncWorkerManager)
|
||||
val workManager = WorkManager.getInstance(context)
|
||||
val syncAdapter = syncAdapterImplProvider.get()
|
||||
|
||||
mockkObject(workManager) {
|
||||
// don't actually create a worker
|
||||
@@ -158,9 +135,8 @@ class SyncAdapterServicesTest {
|
||||
|
||||
@Test
|
||||
fun testSyncAdapter_onPerformSync_runsInTime() {
|
||||
val syncWorkerManager = mockk<SyncWorkerManager>()
|
||||
val syncAdapter = syncAdapter(syncWorkerManager = syncWorkerManager)
|
||||
val workManager = WorkManager.getInstance(context)
|
||||
val syncAdapter = syncAdapterImplProvider.get()
|
||||
|
||||
mockkObject(workManager) {
|
||||
// don't actually create a worker
|
||||
@@ -179,4 +155,4 @@ class SyncAdapterServicesTest {
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
@@ -8,14 +8,14 @@ import android.accounts.Account
|
||||
import android.content.Context
|
||||
import androidx.core.app.NotificationManagerCompat
|
||||
import androidx.hilt.work.HiltWorkerFactory
|
||||
import at.bitfire.dav4jvm.PropStat
|
||||
import at.bitfire.dav4jvm.Response
|
||||
import at.bitfire.dav4jvm.Response.HrefRelation
|
||||
import at.bitfire.dav4jvm.okhttp.PropStat
|
||||
import at.bitfire.dav4jvm.okhttp.Response
|
||||
import at.bitfire.dav4jvm.okhttp.Response.HrefRelation
|
||||
import at.bitfire.dav4jvm.property.webdav.GetETag
|
||||
import at.bitfire.davdroid.TestUtils
|
||||
import at.bitfire.davdroid.TestUtils.assertWithin
|
||||
import at.bitfire.davdroid.db.Collection
|
||||
import at.bitfire.davdroid.network.HttpClient
|
||||
import at.bitfire.davdroid.network.HttpClientBuilder
|
||||
import at.bitfire.davdroid.repository.DavSyncStatsRepository
|
||||
import at.bitfire.davdroid.resource.SyncState
|
||||
import at.bitfire.davdroid.settings.AccountSettings
|
||||
@@ -59,7 +59,7 @@ class SyncManagerTest {
|
||||
lateinit var context: Context
|
||||
|
||||
@Inject
|
||||
lateinit var httpClientBuilder: HttpClient.Builder
|
||||
lateinit var httpClientBuilder: HttpClientBuilder
|
||||
|
||||
@Inject
|
||||
lateinit var syncManagerFactory: TestSyncManager.Factory
|
||||
|
||||
@@ -189,12 +189,12 @@ class SyncerTest {
|
||||
override val authority: String
|
||||
get() = throw NotImplementedError()
|
||||
|
||||
override fun acquireContentProvider(): ContentProviderClient? {
|
||||
override fun acquireContentProvider(throwOnMissingPermissions: Boolean): ContentProviderClient? {
|
||||
throw NotImplementedError()
|
||||
}
|
||||
|
||||
override fun create(
|
||||
provider: ContentProviderClient,
|
||||
client: ContentProviderClient,
|
||||
fromCollection: Collection
|
||||
): LocalTestCollection? {
|
||||
throw NotImplementedError()
|
||||
@@ -202,13 +202,13 @@ class SyncerTest {
|
||||
|
||||
override fun getAll(
|
||||
account: Account,
|
||||
provider: ContentProviderClient
|
||||
client: ContentProviderClient
|
||||
): List<LocalTestCollection> {
|
||||
throw NotImplementedError()
|
||||
}
|
||||
|
||||
override fun update(
|
||||
provider: ContentProviderClient,
|
||||
client: ContentProviderClient,
|
||||
localCollection: LocalTestCollection,
|
||||
fromCollection: Collection
|
||||
) {
|
||||
@@ -219,7 +219,7 @@ class SyncerTest {
|
||||
throw NotImplementedError()
|
||||
}
|
||||
|
||||
override fun updateAccount(oldAccount: Account, newAccount: Account) {
|
||||
override fun updateAccount(oldAccount: Account, newAccount: Account, client: ContentProviderClient?) {
|
||||
throw NotImplementedError()
|
||||
}
|
||||
|
||||
|
||||
@@ -5,13 +5,13 @@
|
||||
package at.bitfire.davdroid.sync
|
||||
|
||||
import android.accounts.Account
|
||||
import at.bitfire.dav4jvm.DavCollection
|
||||
import at.bitfire.dav4jvm.MultiResponseCallback
|
||||
import at.bitfire.dav4jvm.Response
|
||||
import at.bitfire.dav4jvm.okhttp.DavCollection
|
||||
import at.bitfire.dav4jvm.okhttp.MultiResponseCallback
|
||||
import at.bitfire.dav4jvm.okhttp.Response
|
||||
import at.bitfire.dav4jvm.property.caldav.CalDAV
|
||||
import at.bitfire.dav4jvm.property.caldav.GetCTag
|
||||
import at.bitfire.davdroid.db.Collection
|
||||
import at.bitfire.davdroid.di.SyncDispatcher
|
||||
import at.bitfire.davdroid.network.HttpClient
|
||||
import at.bitfire.davdroid.resource.LocalResource
|
||||
import at.bitfire.davdroid.resource.SyncState
|
||||
import at.bitfire.davdroid.util.DavUtils.lastSegment
|
||||
@@ -20,13 +20,13 @@ import dagger.assisted.AssistedFactory
|
||||
import dagger.assisted.AssistedInject
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
import okhttp3.HttpUrl
|
||||
import okhttp3.RequestBody
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.RequestBody.Companion.toRequestBody
|
||||
import org.junit.Assert.assertEquals
|
||||
|
||||
class TestSyncManager @AssistedInject constructor(
|
||||
@Assisted account: Account,
|
||||
@Assisted httpClient: HttpClient,
|
||||
@Assisted httpClient: OkHttpClient,
|
||||
@Assisted syncResult: SyncResult,
|
||||
@Assisted localCollection: LocalTestCollection,
|
||||
@Assisted collection: Collection,
|
||||
@@ -46,7 +46,7 @@ class TestSyncManager @AssistedInject constructor(
|
||||
interface Factory {
|
||||
fun create(
|
||||
account: Account,
|
||||
httpClient: HttpClient,
|
||||
httpClient: OkHttpClient,
|
||||
syncResult: SyncResult,
|
||||
localCollection: LocalTestCollection,
|
||||
collection: Collection
|
||||
@@ -54,7 +54,7 @@ class TestSyncManager @AssistedInject constructor(
|
||||
}
|
||||
|
||||
override fun prepare(): Boolean {
|
||||
davCollection = DavCollection(httpClient.okHttpClient, collection.url)
|
||||
davCollection = DavCollection(httpClient, collection.url)
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -65,7 +65,7 @@ class TestSyncManager @AssistedInject constructor(
|
||||
didQueryCapabilities = true
|
||||
|
||||
var cTag: SyncState? = null
|
||||
davCollection.propfind(0, GetCTag.NAME) { response, rel ->
|
||||
davCollection.propfind(0, CalDAV.GetCTag) { response, rel ->
|
||||
if (rel == Response.HrefRelation.SELF)
|
||||
response[GetCTag::class.java]?.cTag?.let {
|
||||
cTag = SyncState(SyncState.Type.CTAG, it)
|
||||
@@ -76,9 +76,13 @@ class TestSyncManager @AssistedInject constructor(
|
||||
}
|
||||
|
||||
var didGenerateUpload = false
|
||||
override fun generateUpload(resource: LocalTestResource): RequestBody {
|
||||
override fun generateUpload(resource: LocalTestResource): GeneratedResource {
|
||||
didGenerateUpload = true
|
||||
return resource.toString().toRequestBody()
|
||||
return GeneratedResource(
|
||||
suggestedFileName = resource.fileName ?: "generated-file.txt",
|
||||
requestBody = resource.toString().toRequestBody(),
|
||||
onSuccessContext = GeneratedResource.OnSuccessContext()
|
||||
)
|
||||
}
|
||||
|
||||
override fun syncAlgorithm() = SyncAlgorithm.PROPFIND_REPORT
|
||||
|
||||
@@ -8,6 +8,8 @@ import android.accounts.AccountManager
|
||||
import androidx.test.platform.app.InstrumentationRegistry
|
||||
import at.bitfire.davdroid.R
|
||||
import at.bitfire.davdroid.settings.AccountSettings
|
||||
import at.bitfire.davdroid.sync.account.TestAccount.remove
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
|
||||
object TestAccount {
|
||||
@@ -19,9 +21,9 @@ object TestAccount {
|
||||
*
|
||||
* Remove it with [remove].
|
||||
*/
|
||||
fun create(version: Int = AccountSettings.CURRENT_VERSION): Account {
|
||||
fun create(version: Int = AccountSettings.CURRENT_VERSION, accountName: String = "Test Account"): Account {
|
||||
val accountType = targetContext.getString(R.string.account_type)
|
||||
val account = Account("Test Account", accountType)
|
||||
val account = Account(accountName, accountType)
|
||||
|
||||
val initialData = AccountSettings.initialUserData(null)
|
||||
initialData.putString(AccountSettings.KEY_SETTINGS_VERSION, version.toString())
|
||||
@@ -30,6 +32,16 @@ object TestAccount {
|
||||
return account
|
||||
}
|
||||
|
||||
/**
|
||||
* Renames a test account in a blocking way (usually what you want in tests)
|
||||
*/
|
||||
fun rename(account: Account, newName: String): Account {
|
||||
val am = AccountManager.get(targetContext)
|
||||
val newAccount = am.renameAccount(account, newName, null, null).result
|
||||
assertEquals(newName, newAccount.name)
|
||||
return newAccount
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes a test account, usually in the `@After` tearDown of a test.
|
||||
*/
|
||||
|
||||
@@ -19,7 +19,7 @@ class DebugInfoActivityTest {
|
||||
val expected = StringBuilder(DebugInfoActivity.IntentBuilder.MAX_ELEMENT_SIZE)
|
||||
expected.append(String(ByteArray(DebugInfoActivity.IntentBuilder.MAX_ELEMENT_SIZE - 3) { a }))
|
||||
expected.append("...")
|
||||
assertEquals(expected.toString(), intent.getStringExtra("localResource"))
|
||||
assertEquals(expected.toString(), intent.getStringExtra(DebugInfoActivity.EXTRA_LOCAL_RESOURCE_SUMMARY))
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
@@ -21,7 +21,7 @@ class LoginActivityTest {
|
||||
val loginInfo = LoginActivity.loginInfoFromIntent(intent)
|
||||
assertEquals("https://example.com/nextcloud", loginInfo.baseUri.toString())
|
||||
assertEquals("user", loginInfo.credentials!!.username)
|
||||
assertEquals("password", loginInfo.credentials.password?.concatToString())
|
||||
assertEquals("password", loginInfo.credentials.password?.asString())
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -34,7 +34,7 @@ class LoginActivityTest {
|
||||
val loginInfo = LoginActivity.loginInfoFromIntent(intent)
|
||||
assertEquals("https://example.com:444/nextcloud", loginInfo.baseUri.toString())
|
||||
assertEquals("user", loginInfo.credentials!!.username)
|
||||
assertEquals("password", loginInfo.credentials.password?.concatToString())
|
||||
assertEquals("password", loginInfo.credentials.password?.asString())
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -43,7 +43,7 @@ class LoginActivityTest {
|
||||
val loginInfo = LoginActivity.loginInfoFromIntent(intent)
|
||||
assertEquals("https://example.com/path", loginInfo.baseUri.toString())
|
||||
assertEquals("user", loginInfo.credentials!!.username)
|
||||
assertEquals("password", loginInfo.credentials.password?.concatToString())
|
||||
assertEquals("password", loginInfo.credentials.password?.asString())
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -52,7 +52,7 @@ class LoginActivityTest {
|
||||
val loginInfo = LoginActivity.loginInfoFromIntent(intent)
|
||||
assertEquals("https://example.com:0/path", loginInfo.baseUri.toString())
|
||||
assertEquals("user", loginInfo.credentials!!.username)
|
||||
assertEquals("password", loginInfo.credentials.password?.concatToString())
|
||||
assertEquals("password", loginInfo.credentials.password?.asString())
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -61,7 +61,7 @@ class LoginActivityTest {
|
||||
val loginInfo = LoginActivity.loginInfoFromIntent(intent)
|
||||
assertEquals(null, loginInfo.baseUri)
|
||||
assertEquals("user@example.com", loginInfo.credentials!!.username)
|
||||
assertEquals(null, loginInfo.credentials.password?.concatToString())
|
||||
assertEquals(null, loginInfo.credentials.password?.asString())
|
||||
}
|
||||
|
||||
}
|
||||
@@ -5,6 +5,7 @@
|
||||
package at.bitfire.davdroid.webdav
|
||||
|
||||
import at.bitfire.davdroid.settings.Credentials
|
||||
import at.bitfire.davdroid.util.SensitiveString.Companion.toSensitiveString
|
||||
import dagger.hilt.android.testing.HiltAndroidRule
|
||||
import dagger.hilt.android.testing.HiltAndroidTest
|
||||
import org.junit.Assert.assertEquals
|
||||
@@ -30,8 +31,8 @@ class CredentialsStoreTest {
|
||||
|
||||
@Test
|
||||
fun testSetGetDelete() {
|
||||
store.setCredentials(0, Credentials(username = "myname", password = "12345".toCharArray()))
|
||||
assertEquals(Credentials(username = "myname", password = "12345".toCharArray()), store.getCredentials(0))
|
||||
store.setCredentials(0, Credentials(username = "myname", password = "12345".toSensitiveString()))
|
||||
assertEquals(Credentials(username = "myname", password = "12345".toSensitiveString()), store.getCredentials(0))
|
||||
|
||||
store.setCredentials(0, null)
|
||||
assertNull(store.getCredentials(0))
|
||||
|
||||
@@ -6,11 +6,11 @@ package at.bitfire.davdroid.webdav
|
||||
|
||||
import dagger.hilt.android.testing.HiltAndroidRule
|
||||
import dagger.hilt.android.testing.HiltAndroidTest
|
||||
import junit.framework.TestCase.assertEquals
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import okhttp3.mockwebserver.MockResponse
|
||||
import okhttp3.mockwebserver.MockWebServer
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Before
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
@@ -36,7 +36,7 @@ class WebDavMountRepositoryTest {
|
||||
@Test
|
||||
fun testHasWebDav_NoDavHeader() = runTest {
|
||||
web.enqueue(MockResponse().setResponseCode(200))
|
||||
assertFalse(repository.hasWebDav(url, null))
|
||||
assertNull(repository.hasWebDav(url, null))
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -44,7 +44,7 @@ class WebDavMountRepositoryTest {
|
||||
web.enqueue(MockResponse()
|
||||
.setResponseCode(200)
|
||||
.addHeader("DAV: 1"))
|
||||
assertTrue(repository.hasWebDav(url, null))
|
||||
assertEquals(url, repository.hasWebDav(url, null))
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -52,7 +52,7 @@ class WebDavMountRepositoryTest {
|
||||
web.enqueue(MockResponse()
|
||||
.setResponseCode(200)
|
||||
.addHeader("DAV: 1, 2"))
|
||||
assertTrue(repository.hasWebDav(url, null))
|
||||
assertEquals(url,repository.hasWebDav(url, null))
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -60,7 +60,7 @@ class WebDavMountRepositoryTest {
|
||||
web.enqueue(MockResponse()
|
||||
.setResponseCode(200)
|
||||
.addHeader("DAV: 1, 3"))
|
||||
assertTrue(repository.hasWebDav(url, null))
|
||||
assertEquals(url,repository.hasWebDav(url, null))
|
||||
}
|
||||
|
||||
}
|
||||
@@ -2,19 +2,21 @@
|
||||
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
|
||||
*/
|
||||
|
||||
package at.bitfire.davdroid.webdav
|
||||
package at.bitfire.davdroid.webdav.operation
|
||||
|
||||
import android.content.Context
|
||||
import android.security.NetworkSecurityPolicy
|
||||
import at.bitfire.davdroid.db.AppDatabase
|
||||
import at.bitfire.davdroid.db.WebDavDocument
|
||||
import at.bitfire.davdroid.db.WebDavMount
|
||||
import at.bitfire.davdroid.network.HttpClient
|
||||
import at.bitfire.davdroid.network.HttpClientBuilder
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import dagger.hilt.android.testing.HiltAndroidRule
|
||||
import dagger.hilt.android.testing.HiltAndroidTest
|
||||
import io.mockk.junit4.MockKRule
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.mockwebserver.Dispatcher
|
||||
import okhttp3.mockwebserver.MockResponse
|
||||
import okhttp3.mockwebserver.MockWebServer
|
||||
@@ -29,29 +31,7 @@ import java.util.logging.Logger
|
||||
import javax.inject.Inject
|
||||
|
||||
@HiltAndroidTest
|
||||
class DavDocumentsProviderTest {
|
||||
|
||||
companion object {
|
||||
private const val PATH_WEBDAV_ROOT = "/webdav"
|
||||
}
|
||||
|
||||
@Inject @ApplicationContext
|
||||
lateinit var context: Context
|
||||
|
||||
@Inject
|
||||
lateinit var credentialsStore: CredentialsStore
|
||||
|
||||
@Inject
|
||||
lateinit var davDocumentsActorFactory: DavDocumentsProvider.DavDocumentsActor.Factory
|
||||
|
||||
@Inject
|
||||
lateinit var httpClientBuilder: HttpClient.Builder
|
||||
|
||||
@Inject
|
||||
lateinit var db: AppDatabase
|
||||
|
||||
@Inject
|
||||
lateinit var testDispatcher: TestDispatcher
|
||||
class QueryChildDocumentsOperationTest {
|
||||
|
||||
@get:Rule
|
||||
val hiltRule = HiltAndroidRule(this)
|
||||
@@ -59,13 +39,32 @@ class DavDocumentsProviderTest {
|
||||
@get:Rule
|
||||
val mockkRule = MockKRule(this)
|
||||
|
||||
@Inject @ApplicationContext
|
||||
lateinit var context: Context
|
||||
|
||||
@Inject
|
||||
lateinit var db: AppDatabase
|
||||
|
||||
@Inject
|
||||
lateinit var operation: QueryChildDocumentsOperation
|
||||
|
||||
@Inject
|
||||
lateinit var httpClientBuilder: HttpClientBuilder
|
||||
|
||||
@Inject
|
||||
lateinit var testDispatcher: TestDispatcher
|
||||
|
||||
private lateinit var server: MockWebServer
|
||||
private lateinit var client: HttpClient
|
||||
private lateinit var client: OkHttpClient
|
||||
|
||||
private lateinit var mount: WebDavMount
|
||||
private lateinit var rootDocument: WebDavDocument
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
hiltRule.inject()
|
||||
|
||||
// create server and client
|
||||
server = MockWebServer().apply {
|
||||
dispatcher = testDispatcher
|
||||
start()
|
||||
@@ -75,50 +74,48 @@ class DavDocumentsProviderTest {
|
||||
|
||||
// mock server delivers HTTP without encryption
|
||||
assertTrue(NetworkSecurityPolicy.getInstance().isCleartextTrafficPermitted)
|
||||
|
||||
// create WebDAV mount and root document in DB
|
||||
runBlocking {
|
||||
val mountId = db.webDavMountDao().insert(WebDavMount(0, "Cat food storage", server.url(PATH_WEBDAV_ROOT)))
|
||||
mount = db.webDavMountDao().getById(mountId)
|
||||
rootDocument = db.webDavDocumentDao().getOrCreateRoot(mount)
|
||||
}
|
||||
}
|
||||
|
||||
@After
|
||||
fun tearDown() {
|
||||
client.close()
|
||||
server.shutdown()
|
||||
|
||||
runBlocking {
|
||||
db.webDavMountDao().deleteAsync(mount)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@Test
|
||||
fun testDoQueryChildren_insert() = runTest {
|
||||
// Create parent and root in database
|
||||
val id = db.webDavMountDao().insert(WebDavMount(0, "Cat food storage", server.url(PATH_WEBDAV_ROOT)))
|
||||
val webDavMount = db.webDavMountDao().getById(id)
|
||||
val parent = db.webDavDocumentDao().getOrCreateRoot(webDavMount)
|
||||
|
||||
// Query
|
||||
val actor = davDocumentsActorFactory.create(
|
||||
cookieStore = mutableMapOf(),
|
||||
credentialsStore = credentialsStore
|
||||
)
|
||||
actor.queryChildren(parent)
|
||||
operation.queryChildren(rootDocument)
|
||||
|
||||
// Assert new children were inserted into db
|
||||
assertEquals(3, db.webDavDocumentDao().getChildren(parent.id).size)
|
||||
assertEquals("Library", db.webDavDocumentDao().getChildren(parent.id)[0].displayName)
|
||||
assertEquals("MeowMeow_Cats.docx", db.webDavDocumentDao().getChildren(parent.id)[1].displayName)
|
||||
assertEquals("Secret_Document.pages", db.webDavDocumentDao().getChildren(parent.id)[2].displayName)
|
||||
assertEquals(3, db.webDavDocumentDao().getChildren(rootDocument.id).size)
|
||||
assertEquals("Library", db.webDavDocumentDao().getChildren(rootDocument.id)[0].displayName)
|
||||
assertEquals("MeowMeow_Cats.docx", db.webDavDocumentDao().getChildren(rootDocument.id)[1].displayName)
|
||||
assertEquals("Secret_Document.pages", db.webDavDocumentDao().getChildren(rootDocument.id)[2].displayName)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testDoQueryChildren_update() = runTest {
|
||||
// Create parent and root in database
|
||||
val mountId = db.webDavMountDao().insert(WebDavMount(0, "Cat food storage", server.url(PATH_WEBDAV_ROOT)))
|
||||
val webDavMount = db.webDavMountDao().getById(mountId)
|
||||
val parent = db.webDavDocumentDao().getOrCreateRoot(webDavMount)
|
||||
assertEquals("Cat food storage", db.webDavDocumentDao().get(parent.id)!!.displayName)
|
||||
assertEquals("Cat food storage", db.webDavDocumentDao().get(rootDocument.id)!!.displayName)
|
||||
|
||||
// Create a folder
|
||||
val folderId = db.webDavDocumentDao().insert(
|
||||
WebDavDocument(
|
||||
0,
|
||||
mountId,
|
||||
parent.id,
|
||||
mount.id,
|
||||
rootDocument.id,
|
||||
"My_Books",
|
||||
true,
|
||||
"My Books",
|
||||
@@ -128,38 +125,25 @@ class DavDocumentsProviderTest {
|
||||
assertEquals("My Books", db.webDavDocumentDao().get(folderId)!!.displayName)
|
||||
|
||||
// Query - should update the parent displayname and folder name
|
||||
val actor = davDocumentsActorFactory.create(
|
||||
cookieStore = mutableMapOf(),
|
||||
credentialsStore = credentialsStore
|
||||
)
|
||||
actor.queryChildren(parent)
|
||||
operation.queryChildren(rootDocument)
|
||||
|
||||
// Assert parent and children were updated in database
|
||||
assertEquals("Cats WebDAV", db.webDavDocumentDao().get(parent.id)!!.displayName)
|
||||
assertEquals("Library", db.webDavDocumentDao().getChildren(parent.id)[0].name)
|
||||
assertEquals("Library", db.webDavDocumentDao().getChildren(parent.id)[0].displayName)
|
||||
assertEquals("Cats WebDAV", db.webDavDocumentDao().get(rootDocument.id)!!.displayName)
|
||||
assertEquals("Library", db.webDavDocumentDao().getChildren(rootDocument.id)[0].name)
|
||||
assertEquals("Library", db.webDavDocumentDao().getChildren(rootDocument.id)[0].displayName)
|
||||
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testDoQueryChildren_delete() = runTest {
|
||||
// Create parent and root in database
|
||||
val mountId = db.webDavMountDao().insert(WebDavMount(0, "Cat food storage", server.url(PATH_WEBDAV_ROOT)))
|
||||
val webDavMount = db.webDavMountDao().getById(mountId)
|
||||
val parent = db.webDavDocumentDao().getOrCreateRoot(webDavMount)
|
||||
|
||||
// Create a folder
|
||||
val folderId = db.webDavDocumentDao().insert(
|
||||
WebDavDocument(0, mountId, parent.id, "deleteme", true, "Should be deleted")
|
||||
WebDavDocument(0, mount.id, rootDocument.id, "deleteme", true, "Should be deleted")
|
||||
)
|
||||
assertEquals("deleteme", db.webDavDocumentDao().get(folderId)!!.name)
|
||||
|
||||
// Query - discovers serverside deletion
|
||||
val actor = davDocumentsActorFactory.create(
|
||||
cookieStore = mutableMapOf(),
|
||||
credentialsStore = credentialsStore
|
||||
)
|
||||
actor.queryChildren(parent)
|
||||
operation.queryChildren(rootDocument)
|
||||
|
||||
// Assert folder got deleted
|
||||
assertEquals(null, db.webDavDocumentDao().get(folderId))
|
||||
@@ -167,26 +151,17 @@ class DavDocumentsProviderTest {
|
||||
|
||||
@Test
|
||||
fun testDoQueryChildren_updateTwoDirectoriesSimultaneously() = runTest {
|
||||
// Create root in database
|
||||
val mountId = db.webDavMountDao().insert(WebDavMount(0, "Cat food storage", server.url(PATH_WEBDAV_ROOT)))
|
||||
val webDavMount = db.webDavMountDao().getById(mountId)
|
||||
val root = db.webDavDocumentDao().getOrCreateRoot(webDavMount)
|
||||
|
||||
// Create two directories
|
||||
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 parent1Id = db.webDavDocumentDao().insert(WebDavDocument(0, mount.id, rootDocument.id, "parent1", true))
|
||||
val parent2Id = db.webDavDocumentDao().insert(WebDavDocument(0, mount.id, rootDocument.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
|
||||
val actor = davDocumentsActorFactory.create(
|
||||
cookieStore = mutableMapOf(),
|
||||
credentialsStore = credentialsStore
|
||||
)
|
||||
actor.queryChildren(parent1)
|
||||
actor.queryChildren(parent2)
|
||||
operation.queryChildren(parent1)
|
||||
operation.queryChildren(parent2)
|
||||
|
||||
// Assert the two folders names have changed
|
||||
assertEquals("childOne.txt", db.webDavDocumentDao().getChildren(parent1Id)[0].name)
|
||||
@@ -214,7 +189,7 @@ class DavDocumentsProviderTest {
|
||||
PATH_WEBDAV_ROOT to arrayOf(
|
||||
Resource("",
|
||||
"<resourcetype><collection/></resourcetype>" +
|
||||
"<displayname>Cats WebDAV</displayname>"
|
||||
"<displayname>Cats WebDAV</displayname>"
|
||||
),
|
||||
Resource("Secret_Document.pages",
|
||||
"<displayname>Secret_Document.pages</displayname>",
|
||||
@@ -224,7 +199,7 @@ class DavDocumentsProviderTest {
|
||||
),
|
||||
Resource("Library",
|
||||
"<resourcetype><collection/></resourcetype>" +
|
||||
"<displayname>Library</displayname>"
|
||||
"<displayname>Library</displayname>"
|
||||
)
|
||||
),
|
||||
|
||||
@@ -243,15 +218,15 @@ class DavDocumentsProviderTest {
|
||||
val responses = propsMap[requestPath]?.joinToString { resource ->
|
||||
"<response><href>$requestPath/${resource.name}</href><propstat><prop>" +
|
||||
resource.props +
|
||||
"</prop></propstat></response>"
|
||||
"</prop></propstat></response>"
|
||||
}
|
||||
|
||||
val multistatus =
|
||||
"<multistatus xmlns='DAV:' " +
|
||||
"xmlns:CARD='urn:ietf:params:xml:ns:carddav' " +
|
||||
"xmlns:CAL='urn:ietf:params:xml:ns:caldav'>" +
|
||||
responses +
|
||||
"</multistatus>"
|
||||
"xmlns:CARD='urn:ietf:params:xml:ns:carddav' " +
|
||||
"xmlns:CAL='urn:ietf:params:xml:ns:caldav'>" +
|
||||
responses +
|
||||
"</multistatus>"
|
||||
|
||||
logger.info("Response: $multistatus")
|
||||
return MockResponse()
|
||||
@@ -264,4 +239,9 @@ class DavDocumentsProviderTest {
|
||||
|
||||
}
|
||||
|
||||
|
||||
companion object {
|
||||
private const val PATH_WEBDAV_ROOT = "/webdav"
|
||||
}
|
||||
|
||||
}
|
||||
@@ -53,11 +53,19 @@
|
||||
tools:ignore="UnusedAttribute"
|
||||
android:supportsRtl="true">
|
||||
|
||||
<!-- required for Hilt/WorkManager integration -->
|
||||
<!-- Required for Hilt/WorkManager integration. See
|
||||
- https://developer.android.com/develop/background-work/background-tasks/persistent/configuration/custom-configuration#remove-default
|
||||
- https://developer.android.com/training/dependency-injection/hilt-jetpack#workmanager
|
||||
However, we must not disable AndroidX startup completely, as it's needed by other libraries like okhttp. -->
|
||||
<provider
|
||||
android:name="androidx.startup.InitializationProvider"
|
||||
android:authorities="${applicationId}.androidx-startup"
|
||||
tools:node="remove">
|
||||
android:exported="false"
|
||||
tools:node="merge">
|
||||
<meta-data
|
||||
android:name="androidx.work.WorkManagerInitializer"
|
||||
android:value="androidx.startup"
|
||||
tools:node="remove" />
|
||||
</provider>
|
||||
|
||||
<!-- Remove the node added by AppAuth (remove only from net.openid.appauth library, not from our flavor manifest files) -->
|
||||
@@ -92,7 +100,7 @@
|
||||
<activity
|
||||
android:name=".ui.DebugInfoActivity"
|
||||
android:parentActivityName=".ui.AppSettingsActivity"
|
||||
android:exported="true"
|
||||
android:exported="false"
|
||||
android:label="@string/debug_info_title">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.BUG_REPORT"/>
|
||||
@@ -178,7 +186,7 @@
|
||||
android:resource="@xml/account_authenticator"/>
|
||||
</service>
|
||||
<service
|
||||
android:name=".sync.CalendarsSyncAdapterService"
|
||||
android:name=".sync.adapter.CalendarsSyncAdapterService"
|
||||
android:exported="true"
|
||||
tools:ignore="ExportedService">
|
||||
<intent-filter>
|
||||
@@ -189,7 +197,7 @@
|
||||
android:resource="@xml/sync_calendars"/>
|
||||
</service>
|
||||
<service
|
||||
android:name=".sync.JtxSyncAdapterService"
|
||||
android:name=".sync.adapter.JtxSyncAdapterService"
|
||||
android:exported="true"
|
||||
tools:ignore="ExportedService">
|
||||
<intent-filter>
|
||||
@@ -200,7 +208,7 @@
|
||||
android:resource="@xml/sync_notes"/>
|
||||
</service>
|
||||
<service
|
||||
android:name=".sync.OpenTasksSyncAdapterService"
|
||||
android:name=".sync.adapter.OpenTasksSyncAdapterService"
|
||||
android:exported="true"
|
||||
tools:ignore="ExportedService">
|
||||
<intent-filter>
|
||||
@@ -211,7 +219,7 @@
|
||||
android:resource="@xml/sync_opentasks"/>
|
||||
</service>
|
||||
<service
|
||||
android:name=".sync.TasksOrgSyncAdapterService"
|
||||
android:name=".sync.adapter.TasksOrgSyncAdapterService"
|
||||
android:exported="true"
|
||||
tools:ignore="ExportedService">
|
||||
<intent-filter>
|
||||
@@ -246,7 +254,7 @@
|
||||
android:resource="@xml/account_authenticator_address_book"/>
|
||||
</service>
|
||||
<service
|
||||
android:name=".sync.ContactsSyncAdapterService"
|
||||
android:name=".sync.adapter.ContactsSyncAdapterService"
|
||||
android:exported="true"
|
||||
tools:ignore="ExportedService">
|
||||
<intent-filter>
|
||||
|
||||
@@ -7,13 +7,14 @@ package at.bitfire.davdroid
|
||||
import android.app.Application
|
||||
import androidx.hilt.work.HiltWorkerFactory
|
||||
import androidx.work.Configuration
|
||||
import at.bitfire.davdroid.di.DefaultDispatcher
|
||||
import at.bitfire.davdroid.log.LogManager
|
||||
import at.bitfire.davdroid.startup.StartupPlugin
|
||||
import at.bitfire.davdroid.sync.account.AccountsCleanupWorker
|
||||
import at.bitfire.davdroid.ui.UiUtils
|
||||
import dagger.hilt.android.HiltAndroidApp
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
import kotlinx.coroutines.DelicateCoroutinesApi
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.GlobalScope
|
||||
import kotlinx.coroutines.launch
|
||||
import java.util.logging.Logger
|
||||
@@ -31,6 +32,10 @@ class App: Application(), Configuration.Provider {
|
||||
@Inject
|
||||
lateinit var logManager: LogManager
|
||||
|
||||
@Inject
|
||||
@DefaultDispatcher
|
||||
lateinit var defaultDispatcher: CoroutineDispatcher
|
||||
|
||||
@Inject
|
||||
lateinit var plugins: Set<@JvmSuppressWildcards StartupPlugin>
|
||||
|
||||
@@ -60,7 +65,7 @@ class App: Application(), Configuration.Provider {
|
||||
|
||||
// don't block UI for some background checks
|
||||
@OptIn(DelicateCoroutinesApi::class)
|
||||
GlobalScope.launch(Dispatchers.Default) {
|
||||
GlobalScope.launch(defaultDispatcher) {
|
||||
// clean up orphaned accounts in DB from time to time
|
||||
AccountsCleanupWorker.enable(this@App)
|
||||
|
||||
|
||||
@@ -3,9 +3,8 @@
|
||||
*/
|
||||
package at.bitfire.davdroid
|
||||
|
||||
import at.bitfire.ical4android.ical4jVersion
|
||||
import at.bitfire.synctools.icalendar.ical4jVersion
|
||||
import ezvcard.Ezvcard
|
||||
import net.fortuna.ical4j.model.property.ProdId
|
||||
|
||||
/**
|
||||
* Brand-specific constants like (non-theme) colors, homepage URLs etc.
|
||||
@@ -17,7 +16,7 @@ object Constants {
|
||||
|
||||
// product IDs for iCalendar/vCard
|
||||
|
||||
val iCalProdId = ProdId("DAVx5/${BuildConfig.VERSION_NAME} ical4j/$ical4jVersion")
|
||||
val iCalProdId = "DAVx5/${BuildConfig.VERSION_NAME} ical4j/$ical4jVersion"
|
||||
const val vCardProdId = "+//IDN bitfire.at//DAVx5/${BuildConfig.VERSION_NAME} ez-vcard/${Ezvcard.VERSION}"
|
||||
|
||||
}
|
||||
@@ -35,6 +35,13 @@ import dagger.hilt.components.SingletonComponent
|
||||
import java.io.Writer
|
||||
import javax.inject.Singleton
|
||||
|
||||
/**
|
||||
* The app database. Managed via android jetpack room. Room provides an abstraction
|
||||
* layer over SQLite.
|
||||
*
|
||||
* Note: In SQLite PRAGMA foreign_keys is off by default. Room activates it for
|
||||
* production (non-test) databases.
|
||||
*/
|
||||
@Database(entities = [
|
||||
Service::class,
|
||||
HomeSet::class,
|
||||
|
||||
@@ -10,8 +10,9 @@ 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.okhttp.Response
|
||||
import at.bitfire.dav4jvm.okhttp.UrlUtils
|
||||
import at.bitfire.dav4jvm.property.caldav.CalDAV
|
||||
import at.bitfire.dav4jvm.property.caldav.CalendarColor
|
||||
import at.bitfire.dav4jvm.property.caldav.CalendarDescription
|
||||
import at.bitfire.dav4jvm.property.caldav.CalendarTimezone
|
||||
@@ -19,6 +20,7 @@ import at.bitfire.dav4jvm.property.caldav.CalendarTimezoneId
|
||||
import at.bitfire.dav4jvm.property.caldav.Source
|
||||
import at.bitfire.dav4jvm.property.caldav.SupportedCalendarComponentSet
|
||||
import at.bitfire.dav4jvm.property.carddav.AddressbookDescription
|
||||
import at.bitfire.dav4jvm.property.carddav.CardDAV
|
||||
import at.bitfire.dav4jvm.property.push.PushTransports
|
||||
import at.bitfire.dav4jvm.property.push.Topic
|
||||
import at.bitfire.dav4jvm.property.push.WebPush
|
||||
@@ -166,9 +168,9 @@ data class Collection(
|
||||
val url = UrlUtils.withTrailingSlash(dav.href)
|
||||
val type: String = dav[ResourceType::class.java]?.let { resourceType ->
|
||||
when {
|
||||
resourceType.types.contains(ResourceType.ADDRESSBOOK) -> TYPE_ADDRESSBOOK
|
||||
resourceType.types.contains(ResourceType.CALENDAR) -> TYPE_CALENDAR
|
||||
resourceType.types.contains(ResourceType.SUBSCRIBED) -> TYPE_WEBCAL
|
||||
resourceType.types.contains(CardDAV.Addressbook) -> TYPE_ADDRESSBOOK
|
||||
resourceType.types.contains(CalDAV.Calendar) -> TYPE_CALENDAR
|
||||
resourceType.types.contains(CalDAV.Subscribed) -> TYPE_WEBCAL
|
||||
else -> null
|
||||
}
|
||||
} ?: return null
|
||||
|
||||
@@ -8,10 +8,11 @@ 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.okhttp.Response
|
||||
import at.bitfire.dav4jvm.okhttp.UrlUtils
|
||||
import at.bitfire.dav4jvm.property.webdav.DisplayName
|
||||
import at.bitfire.dav4jvm.property.webdav.ResourceType
|
||||
import at.bitfire.dav4jvm.property.webdav.WebDAV
|
||||
import at.bitfire.davdroid.util.trimToNull
|
||||
import okhttp3.HttpUrl
|
||||
|
||||
@@ -46,7 +47,7 @@ data class 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))
|
||||
if (!resourceType.types.contains(WebDAV.Principal))
|
||||
return null
|
||||
|
||||
// Try getting the display name of the principal
|
||||
|
||||
@@ -48,11 +48,20 @@ interface PrincipalDao {
|
||||
* @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)
|
||||
fun insertOrUpdate(serviceId: Long, principal: Principal): Long {
|
||||
// Try to get existing principal by URL
|
||||
val oldPrincipal = getByUrl(serviceId, principal.url)
|
||||
|
||||
// Insert new principal if not existing
|
||||
if (oldPrincipal == null)
|
||||
return insert(principal)
|
||||
|
||||
// Otherwise update the existing principal
|
||||
if (principal.displayName != oldPrincipal.displayName)
|
||||
update(principal.copy(id = oldPrincipal.id))
|
||||
|
||||
// In any case return the id of the principal
|
||||
return oldPrincipal.id
|
||||
}
|
||||
|
||||
}
|
||||
@@ -39,6 +39,10 @@ import javax.inject.Singleton
|
||||
*
|
||||
* When using the global logger, the class name of the logging calls will still be logged, so there's
|
||||
* no need to always get a separate logger for each class (only if the class wants to customize it).
|
||||
*
|
||||
* Note about choosing log levels: records with [Level.FINE] or higher will always be printed to adb logs
|
||||
* (regardless of whether verbose logging is active). Records with a lower level will only be
|
||||
* printed to adb logs when verbose logging is active.
|
||||
*/
|
||||
@Singleton
|
||||
class LogManager @Inject constructor(
|
||||
@@ -79,7 +83,10 @@ class LogManager @Inject constructor(
|
||||
|
||||
// root logger: set default log level and always log to logcat
|
||||
val rootLogger = Logger.getLogger("")
|
||||
rootLogger.level = if (logVerbose) Level.ALL else Level.INFO
|
||||
rootLogger.level = if (logVerbose)
|
||||
Level.ALL // include everything (including HTTP interceptor logs) in verbose logs
|
||||
else
|
||||
Level.FINE // include detailed information like content provider operations in non-verbose logs
|
||||
rootLogger.addHandler(LogcatHandler(BuildConfig.APPLICATION_ID))
|
||||
|
||||
// log to file, if requested
|
||||
|
||||
@@ -6,22 +6,31 @@ package at.bitfire.davdroid.network
|
||||
|
||||
import android.content.Context
|
||||
import android.security.KeyChain
|
||||
import android.security.KeyChainException
|
||||
import dagger.assisted.Assisted
|
||||
import dagger.assisted.AssistedFactory
|
||||
import dagger.assisted.AssistedInject
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import java.net.Socket
|
||||
import java.security.Principal
|
||||
import java.security.PrivateKey
|
||||
import java.security.cert.X509Certificate
|
||||
import java.util.logging.Level
|
||||
import java.util.logging.Logger
|
||||
import javax.net.ssl.X509ExtendedKeyManager
|
||||
|
||||
/**
|
||||
* KeyManager that provides a client certificate and private key from the Android KeyChain.
|
||||
* KeyManager that provides a client certificate and private key from the Android [KeyChain].
|
||||
*
|
||||
* @throws IllegalArgumentException if the alias doesn't exist or is not accessible
|
||||
* Requests for certificates / private keys for other aliases than the specified one
|
||||
* will be ignored.
|
||||
*
|
||||
* @param alias alias of the desired certificate / private key
|
||||
*/
|
||||
class ClientCertKeyManager @AssistedInject constructor(
|
||||
@Assisted private val alias: String,
|
||||
@ApplicationContext private val context: Context
|
||||
@ApplicationContext private val context: Context,
|
||||
private val logger: Logger
|
||||
): X509ExtendedKeyManager() {
|
||||
|
||||
@AssistedFactory
|
||||
@@ -29,19 +38,42 @@ class ClientCertKeyManager @AssistedInject constructor(
|
||||
fun create(alias: String): ClientCertKeyManager
|
||||
}
|
||||
|
||||
val certs = KeyChain.getCertificateChain(context, alias) ?: throw IllegalArgumentException("Alias doesn't exist or not accessible: $alias")
|
||||
val key = KeyChain.getPrivateKey(context, alias) ?: throw IllegalArgumentException("Alias doesn't exist or not accessible: $alias")
|
||||
|
||||
override fun getServerAliases(p0: String?, p1: Array<out Principal>?): Array<String>? = null
|
||||
override fun chooseServerAlias(p0: String?, p1: Array<out Principal>?, p2: Socket?) = null
|
||||
|
||||
override fun getClientAliases(p0: String?, p1: Array<out Principal>?) = arrayOf(alias)
|
||||
override fun chooseClientAlias(p0: Array<out String>?, p1: Array<out Principal>?, p2: Socket?) = alias
|
||||
|
||||
override fun getCertificateChain(forAlias: String?) =
|
||||
certs.takeIf { forAlias == alias }
|
||||
override fun getCertificateChain(forAlias: String): Array<X509Certificate>? {
|
||||
if (forAlias != alias)
|
||||
return null
|
||||
|
||||
override fun getPrivateKey(forAlias: String?) =
|
||||
key.takeIf { forAlias == alias }
|
||||
return try {
|
||||
KeyChain.getCertificateChain(context, alias).also { result ->
|
||||
if (result == null)
|
||||
logger.warning("Couldn't obtain certificate chain for alias $alias")
|
||||
}
|
||||
} catch (e: KeyChainException) {
|
||||
// Android <Q throws an exception instead of returning null
|
||||
logger.log(Level.WARNING, "Couldn't obtain certificate chain for alias $alias", e)
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
override fun getPrivateKey(forAlias: String): PrivateKey? {
|
||||
if (forAlias != alias)
|
||||
return null
|
||||
|
||||
return try {
|
||||
KeyChain.getPrivateKey(context, alias).also { result ->
|
||||
if (result == null)
|
||||
logger.log(Level.WARNING, "Couldn't obtain private key for alias $alias")
|
||||
}
|
||||
} catch (e: KeyChainException) {
|
||||
// Android <Q throws an exception instead of returning null
|
||||
logger.log(Level.WARNING, "Couldn't obtain private key for alias $alias", e)
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
/*
|
||||
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
|
||||
*/
|
||||
|
||||
package at.bitfire.davdroid.network
|
||||
|
||||
import androidx.annotation.VisibleForTesting
|
||||
import org.conscrypt.Conscrypt
|
||||
import java.security.Security
|
||||
import java.util.logging.Logger
|
||||
import javax.net.ssl.SSLContext
|
||||
|
||||
/**
|
||||
* Integration with the Conscrypt library that provides recent TLS versions and ciphers,
|
||||
* regardless of the device Android version.
|
||||
*/
|
||||
class ConscryptIntegration {
|
||||
|
||||
private val logger
|
||||
get() = Logger.getLogger(javaClass.name)
|
||||
|
||||
private var initialized = false
|
||||
|
||||
/**
|
||||
* Loads and initializes Conscrypt (if not already done). Safe to be called multiple times.
|
||||
*/
|
||||
fun initialize() {
|
||||
synchronized(ConscryptIntegration::javaClass) {
|
||||
if (initialized)
|
||||
return
|
||||
|
||||
if (Conscrypt.isAvailable() && !conscryptInstalled()) {
|
||||
// install Conscrypt as most preferred provider
|
||||
Security.insertProviderAt(Conscrypt.newProvider(), 1)
|
||||
|
||||
val version = Conscrypt.version()
|
||||
logger.info("Using Conscrypt/${version.major()}.${version.minor()}.${version.patch()} for TLS")
|
||||
|
||||
val engine = SSLContext.getDefault().createSSLEngine()
|
||||
logger.info("Enabled protocols: ${engine.enabledProtocols.joinToString(", ")}")
|
||||
logger.info("Enabled ciphers: ${engine.enabledCipherSuites.joinToString(", ")}")
|
||||
}
|
||||
|
||||
initialized = true
|
||||
}
|
||||
}
|
||||
|
||||
@VisibleForTesting
|
||||
internal fun conscryptInstalled() =
|
||||
Security.getProviders().any { Conscrypt.isConscrypt(it) }
|
||||
|
||||
}
|
||||
@@ -1,315 +0,0 @@
|
||||
/*
|
||||
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
|
||||
*/
|
||||
|
||||
package at.bitfire.davdroid.network
|
||||
|
||||
import android.accounts.Account
|
||||
import android.content.Context
|
||||
import androidx.annotation.WorkerThread
|
||||
import at.bitfire.cert4android.CustomCertManager
|
||||
import at.bitfire.dav4jvm.BasicDigestAuthHandler
|
||||
import at.bitfire.dav4jvm.UrlUtils
|
||||
import at.bitfire.davdroid.BuildConfig
|
||||
import at.bitfire.davdroid.di.IoDispatcher
|
||||
import at.bitfire.davdroid.settings.AccountSettings
|
||||
import at.bitfire.davdroid.settings.Credentials
|
||||
import at.bitfire.davdroid.settings.Settings
|
||||
import at.bitfire.davdroid.settings.SettingsManager
|
||||
import at.bitfire.davdroid.ui.ForegroundTracker
|
||||
import com.google.common.net.HttpHeaders
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
import kotlinx.coroutines.withContext
|
||||
import net.openid.appauth.AuthState
|
||||
import okhttp3.Authenticator
|
||||
import okhttp3.Cache
|
||||
import okhttp3.ConnectionSpec
|
||||
import okhttp3.CookieJar
|
||||
import okhttp3.Interceptor
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Protocol
|
||||
import okhttp3.brotli.BrotliInterceptor
|
||||
import okhttp3.internal.tls.OkHostnameVerifier
|
||||
import okhttp3.logging.HttpLoggingInterceptor
|
||||
import java.io.File
|
||||
import java.net.InetSocketAddress
|
||||
import java.net.Proxy
|
||||
import java.util.concurrent.TimeUnit
|
||||
import java.util.logging.Level
|
||||
import java.util.logging.Logger
|
||||
import javax.inject.Inject
|
||||
import javax.net.ssl.KeyManager
|
||||
import javax.net.ssl.SSLContext
|
||||
|
||||
class HttpClient(
|
||||
val okHttpClient: OkHttpClient
|
||||
): AutoCloseable {
|
||||
|
||||
override fun close() {
|
||||
okHttpClient.cache?.close()
|
||||
}
|
||||
|
||||
|
||||
// builder
|
||||
|
||||
/**
|
||||
* Builder for the [HttpClient].
|
||||
*
|
||||
* **Attention:** If the builder is injected, it shouldn't be used from multiple locations to generate different clients because then
|
||||
* there's only one [Builder] object and setting properties from one location would influence the others.
|
||||
*
|
||||
* To generate multiple clients, inject and use `Provider<HttpClient.Builder>` instead.
|
||||
*/
|
||||
class Builder @Inject constructor(
|
||||
private val accountSettingsFactory: AccountSettings.Factory,
|
||||
@ApplicationContext private val context: Context,
|
||||
defaultLogger: Logger,
|
||||
@IoDispatcher private val ioDispatcher: CoroutineDispatcher,
|
||||
private val keyManagerFactory: ClientCertKeyManager.Factory,
|
||||
private val oAuthInterceptorFactory: OAuthInterceptor.Factory,
|
||||
private val settingsManager: SettingsManager
|
||||
) {
|
||||
|
||||
// property setters/getters
|
||||
|
||||
private var logger: Logger = defaultLogger
|
||||
fun setLogger(logger: Logger): Builder {
|
||||
this.logger = logger
|
||||
return this
|
||||
}
|
||||
|
||||
private var loggerInterceptorLevel: HttpLoggingInterceptor.Level = HttpLoggingInterceptor.Level.BODY
|
||||
fun loggerInterceptorLevel(level: HttpLoggingInterceptor.Level): Builder {
|
||||
loggerInterceptorLevel = level
|
||||
return this
|
||||
}
|
||||
|
||||
// default cookie store for non-persistent cookies (some services like Horde use cookies for session tracking)
|
||||
private var cookieStore: CookieJar = MemoryCookieStore()
|
||||
fun setCookieStore(cookieStore: CookieJar): Builder {
|
||||
this.cookieStore = cookieStore
|
||||
return this
|
||||
}
|
||||
|
||||
private var authenticationInterceptor: Interceptor? = null
|
||||
private var authenticator: Authenticator? = null
|
||||
private var certificateAlias: String? = null
|
||||
fun authenticate(host: String?, getCredentials: () -> Credentials, updateAuthState: ((AuthState) -> Unit)? = null): Builder {
|
||||
val credentials = getCredentials()
|
||||
if (credentials.authState != null) {
|
||||
// OAuth
|
||||
authenticationInterceptor = oAuthInterceptorFactory.create(
|
||||
readAuthState = {
|
||||
// We don't use the "credentials" object from above because it may contain an outdated access token
|
||||
// when readAuthState is called. Instead, we fetch the up-to-date auth-state.
|
||||
getCredentials().authState
|
||||
},
|
||||
writeAuthState = { authState ->
|
||||
updateAuthState?.invoke(authState)
|
||||
}
|
||||
|
||||
)
|
||||
|
||||
} else if (credentials.username != null && credentials.password != null) {
|
||||
// basic/digest auth
|
||||
val authHandler = BasicDigestAuthHandler(
|
||||
domain = UrlUtils.hostToDomain(host),
|
||||
username = credentials.username,
|
||||
password = credentials.password,
|
||||
insecurePreemptive = true
|
||||
)
|
||||
authenticationInterceptor = authHandler
|
||||
authenticator = authHandler
|
||||
}
|
||||
|
||||
// client certificate
|
||||
if (credentials.certificateAlias != null)
|
||||
certificateAlias = credentials.certificateAlias
|
||||
|
||||
return this
|
||||
}
|
||||
|
||||
private var followRedirects = false
|
||||
fun followRedirects(follow: Boolean): Builder {
|
||||
followRedirects = follow
|
||||
return this
|
||||
}
|
||||
|
||||
private var cache: Cache? = null
|
||||
@Suppress("unused")
|
||||
fun withDiskCache(maxSize: Long = 10*1024*1024): Builder {
|
||||
for (dir in arrayOf(context.externalCacheDir, context.cacheDir).filterNotNull()) {
|
||||
if (dir.exists() && dir.canWrite()) {
|
||||
val cacheDir = File(dir, "HttpClient")
|
||||
cacheDir.mkdir()
|
||||
logger.fine("Using disk cache: $cacheDir")
|
||||
cache = Cache(cacheDir, maxSize)
|
||||
break
|
||||
}
|
||||
}
|
||||
return this
|
||||
}
|
||||
|
||||
|
||||
// convenience builders from other classes
|
||||
|
||||
/**
|
||||
* Takes authentication (basic/digest or OAuth and client certificate) from a given account.
|
||||
*
|
||||
* **Must not be run on main thread, because it creates [AccountSettings]!** Use [fromAccountAsync] if possible.
|
||||
*
|
||||
* @param account the account to take authentication from
|
||||
* @param onlyHost if set: only authenticate for this host name
|
||||
*
|
||||
* @throws at.bitfire.davdroid.sync.account.InvalidAccountException when the account doesn't exist
|
||||
*/
|
||||
@WorkerThread
|
||||
fun fromAccount(account: Account, onlyHost: String? = null): Builder {
|
||||
val accountSettings = accountSettingsFactory.create(account)
|
||||
authenticate(
|
||||
host = onlyHost,
|
||||
getCredentials = {
|
||||
accountSettings.credentials()
|
||||
},
|
||||
updateAuthState = { authState ->
|
||||
accountSettings.updateAuthState(authState)
|
||||
}
|
||||
)
|
||||
return this
|
||||
}
|
||||
|
||||
/**
|
||||
* Same as [fromAccount], but can be called on any thread.
|
||||
*
|
||||
* @throws at.bitfire.davdroid.sync.account.InvalidAccountException when the account doesn't exist
|
||||
*/
|
||||
suspend fun fromAccountAsync(account: Account, onlyHost: String? = null): Builder = withContext(ioDispatcher) {
|
||||
fromAccount(account, onlyHost)
|
||||
}
|
||||
|
||||
|
||||
// actual builder
|
||||
|
||||
fun build(): HttpClient {
|
||||
val okBuilder = OkHttpClient.Builder()
|
||||
// Set timeouts. According to [AbstractThreadedSyncAdapter], when there is no network
|
||||
// traffic within a minute, a sync will be cancelled.
|
||||
.connectTimeout(15, TimeUnit.SECONDS)
|
||||
.writeTimeout(30, TimeUnit.SECONDS)
|
||||
.readTimeout(120, TimeUnit.SECONDS)
|
||||
.pingInterval(45, TimeUnit.SECONDS) // avoid cancellation because of missing traffic; only works for HTTP/2
|
||||
|
||||
// don't allow redirects by default because it would break PROPFIND handling
|
||||
.followRedirects(followRedirects)
|
||||
|
||||
// add User-Agent to every request
|
||||
.addInterceptor(UserAgentInterceptor)
|
||||
|
||||
// connection-private cookie store
|
||||
.cookieJar(cookieStore)
|
||||
|
||||
// allow cleartext and TLS 1.2+
|
||||
.connectionSpecs(listOf(
|
||||
ConnectionSpec.CLEARTEXT,
|
||||
ConnectionSpec.MODERN_TLS
|
||||
))
|
||||
|
||||
// offer Brotli and gzip compression (can be disabled per request with `Accept-Encoding: identity`)
|
||||
.addInterceptor(BrotliInterceptor)
|
||||
|
||||
// add cache, if requested
|
||||
.cache(cache)
|
||||
|
||||
// app-wide custom proxy support
|
||||
buildProxy(okBuilder)
|
||||
|
||||
// add authentication
|
||||
buildAuthentication(okBuilder)
|
||||
|
||||
// add network logging, if requested
|
||||
if (logger.isLoggable(Level.FINEST)) {
|
||||
val loggingInterceptor = HttpLoggingInterceptor { message -> logger.finest(message) }
|
||||
loggingInterceptor.redactHeader(HttpHeaders.AUTHORIZATION)
|
||||
loggingInterceptor.redactHeader(HttpHeaders.COOKIE)
|
||||
loggingInterceptor.redactHeader(HttpHeaders.SET_COOKIE)
|
||||
loggingInterceptor.redactHeader(HttpHeaders.SET_COOKIE2)
|
||||
loggingInterceptor.level = loggerInterceptorLevel
|
||||
okBuilder.addNetworkInterceptor(loggingInterceptor)
|
||||
}
|
||||
|
||||
return HttpClient(okBuilder.build())
|
||||
}
|
||||
|
||||
private fun buildAuthentication(okBuilder: OkHttpClient.Builder) {
|
||||
// basic/digest auth and OAuth
|
||||
authenticationInterceptor?.let { okBuilder.addInterceptor(it) }
|
||||
authenticator?.let { okBuilder.authenticator(it) }
|
||||
|
||||
// client certificate
|
||||
val keyManager: KeyManager? = certificateAlias?.let { alias ->
|
||||
try {
|
||||
val manager = keyManagerFactory.create(alias)
|
||||
logger.fine("Using certificate $alias for authentication")
|
||||
|
||||
// HTTP/2 doesn't support client certificates (yet)
|
||||
// see https://tools.ietf.org/html/draft-ietf-httpbis-http2-secondary-certs-04
|
||||
okBuilder.protocols(listOf(Protocol.HTTP_1_1))
|
||||
|
||||
manager
|
||||
} catch (e: IllegalArgumentException) {
|
||||
logger.log(Level.SEVERE, "Couldn't create KeyManager for certificate $alias", e)
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
// cert4android integration
|
||||
val certManager = CustomCertManager(
|
||||
context = context,
|
||||
trustSystemCerts = !settingsManager.getBoolean(Settings.DISTRUST_SYSTEM_CERTIFICATES),
|
||||
appInForeground = if (BuildConfig.customCertsUI)
|
||||
ForegroundTracker.inForeground // interactive mode
|
||||
else
|
||||
null // non-interactive mode
|
||||
)
|
||||
|
||||
val sslContext = SSLContext.getInstance("TLS")
|
||||
sslContext.init(
|
||||
/* km = */ if (keyManager != null) arrayOf(keyManager) else null,
|
||||
/* tm = */ arrayOf(certManager),
|
||||
/* random = */ null
|
||||
)
|
||||
okBuilder
|
||||
.sslSocketFactory(sslContext.socketFactory, certManager)
|
||||
.hostnameVerifier(certManager.HostnameVerifier(OkHostnameVerifier))
|
||||
}
|
||||
|
||||
private fun buildProxy(okBuilder: OkHttpClient.Builder) {
|
||||
try {
|
||||
val proxyTypeValue = settingsManager.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(
|
||||
settingsManager.getString(Settings.PROXY_HOST),
|
||||
settingsManager.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")
|
||||
}
|
||||
okBuilder.proxy(proxy)
|
||||
logger.log(Level.INFO, "Using proxy setting", proxy)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
logger.log(Level.SEVERE, "Can't set proxy, ignoring", e)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,415 @@
|
||||
/*
|
||||
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
|
||||
*/
|
||||
|
||||
package at.bitfire.davdroid.network
|
||||
|
||||
import android.accounts.Account
|
||||
import android.content.Context
|
||||
import androidx.annotation.WorkerThread
|
||||
import at.bitfire.cert4android.CustomCertManager
|
||||
import at.bitfire.cert4android.CustomCertStore
|
||||
import at.bitfire.dav4jvm.okhttp.BasicDigestAuthHandler
|
||||
import at.bitfire.dav4jvm.okhttp.UrlUtils
|
||||
import at.bitfire.davdroid.BuildConfig
|
||||
import at.bitfire.davdroid.di.IoDispatcher
|
||||
import at.bitfire.davdroid.settings.AccountSettings
|
||||
import at.bitfire.davdroid.settings.Credentials
|
||||
import at.bitfire.davdroid.settings.Settings
|
||||
import at.bitfire.davdroid.settings.SettingsManager
|
||||
import at.bitfire.davdroid.ui.ForegroundTracker
|
||||
import com.google.common.net.HttpHeaders
|
||||
import com.google.errorprone.annotations.MustBeClosed
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import io.ktor.client.HttpClient
|
||||
import io.ktor.client.engine.okhttp.OkHttp
|
||||
import io.ktor.client.plugins.contentnegotiation.ContentNegotiation
|
||||
import io.ktor.serialization.kotlinx.json.json
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
import kotlinx.coroutines.withContext
|
||||
import net.openid.appauth.AuthState
|
||||
import okhttp3.Authenticator
|
||||
import okhttp3.ConnectionSpec
|
||||
import okhttp3.CookieJar
|
||||
import okhttp3.Interceptor
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Protocol
|
||||
import okhttp3.brotli.BrotliInterceptor
|
||||
import okhttp3.internal.tls.OkHostnameVerifier
|
||||
import okhttp3.logging.HttpLoggingInterceptor
|
||||
import java.net.InetSocketAddress
|
||||
import java.net.Proxy
|
||||
import java.security.KeyStore
|
||||
import java.util.concurrent.TimeUnit
|
||||
import java.util.logging.Level
|
||||
import java.util.logging.Logger
|
||||
import javax.inject.Inject
|
||||
import javax.net.ssl.HostnameVerifier
|
||||
import javax.net.ssl.KeyManager
|
||||
import javax.net.ssl.SSLContext
|
||||
import javax.net.ssl.TrustManagerFactory
|
||||
import javax.net.ssl.X509TrustManager
|
||||
|
||||
/**
|
||||
* Builder for the HTTP client.
|
||||
*
|
||||
* **Attention:** If the builder is injected, it shouldn't be used from multiple locations to generate different clients because then
|
||||
* there's only one [HttpClientBuilder] object and setting properties from one location would influence the others.
|
||||
*
|
||||
* To generate multiple clients, inject and use `Provider<HttpClientBuilder>` instead.
|
||||
*/
|
||||
class HttpClientBuilder @Inject constructor(
|
||||
private val accountSettingsFactory: AccountSettings.Factory,
|
||||
@ApplicationContext private val context: Context,
|
||||
defaultLogger: Logger,
|
||||
@IoDispatcher private val ioDispatcher: CoroutineDispatcher,
|
||||
private val keyManagerFactory: ClientCertKeyManager.Factory,
|
||||
private val oAuthInterceptorFactory: OAuthInterceptor.Factory,
|
||||
private val settingsManager: SettingsManager
|
||||
) {
|
||||
|
||||
companion object {
|
||||
init {
|
||||
// make sure Conscrypt is available when the HttpClientBuilder class is loaded the first time
|
||||
ConscryptIntegration().initialize()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Flag to prevent multiple [build] calls
|
||||
*/
|
||||
var alreadyBuilt = false
|
||||
|
||||
// property setters/getters
|
||||
|
||||
private var logger: Logger = defaultLogger
|
||||
fun setLogger(logger: Logger): HttpClientBuilder {
|
||||
this.logger = logger
|
||||
return this
|
||||
}
|
||||
|
||||
private var loggerInterceptorLevel: HttpLoggingInterceptor.Level = HttpLoggingInterceptor.Level.BODY
|
||||
|
||||
fun loggerInterceptorLevel(level: HttpLoggingInterceptor.Level): HttpClientBuilder {
|
||||
loggerInterceptorLevel = level
|
||||
return this
|
||||
}
|
||||
|
||||
// default cookie store for non-persistent cookies (some services like Horde use cookies for session tracking)
|
||||
private var cookieStore: CookieJar = MemoryCookieStore()
|
||||
|
||||
fun setCookieStore(cookieStore: CookieJar): HttpClientBuilder {
|
||||
this.cookieStore = cookieStore
|
||||
return this
|
||||
}
|
||||
|
||||
private var authenticationInterceptor: Interceptor? = null
|
||||
private var authenticator: Authenticator? = null
|
||||
private var certificateAlias: String? = null
|
||||
|
||||
fun authenticate(domain: String?, getCredentials: () -> Credentials, updateAuthState: ((AuthState) -> Unit)? = null): HttpClientBuilder {
|
||||
val credentials = getCredentials()
|
||||
if (credentials.authState != null) {
|
||||
// OAuth
|
||||
authenticationInterceptor = oAuthInterceptorFactory.create(
|
||||
readAuthState = {
|
||||
// We don't use the "credentials" object from above because it may contain an outdated access token
|
||||
// when readAuthState is called. Instead, we fetch the up-to-date auth-state.
|
||||
getCredentials().authState
|
||||
},
|
||||
writeAuthState = { authState ->
|
||||
updateAuthState?.invoke(authState)
|
||||
}
|
||||
|
||||
)
|
||||
|
||||
} else if (credentials.username != null && credentials.password != null) {
|
||||
// basic/digest auth
|
||||
val authHandler = BasicDigestAuthHandler(
|
||||
domain = domain,
|
||||
username = credentials.username,
|
||||
password = credentials.password.asCharArray(),
|
||||
insecurePreemptive = true
|
||||
)
|
||||
authenticationInterceptor = authHandler
|
||||
authenticator = authHandler
|
||||
}
|
||||
|
||||
// client certificate
|
||||
if (credentials.certificateAlias != null)
|
||||
certificateAlias = credentials.certificateAlias
|
||||
|
||||
return this
|
||||
}
|
||||
|
||||
private var followRedirects = false
|
||||
|
||||
fun followRedirects(follow: Boolean): HttpClientBuilder {
|
||||
followRedirects = follow
|
||||
return this
|
||||
}
|
||||
|
||||
|
||||
// convenience builders from other classes
|
||||
|
||||
/**
|
||||
* Takes authentication (basic/digest or OAuth and client certificate) from a given account.
|
||||
*
|
||||
* **Must not be run on main thread, because it creates [AccountSettings]!** Use [fromAccountAsync] if possible.
|
||||
*
|
||||
* @param account the account to take authentication from
|
||||
* @param authDomain (optional) Send credentials only for the hosts of the given domain. Can be:
|
||||
*
|
||||
* - a full host name (`caldav.example.com`): then credentials are only sent for the domain of that host name (`example.com`), or
|
||||
* - a domain name (`example.com`): then credentials are only sent for the given domain, or
|
||||
* - or _null_: then credentials are always sent, regardless of the resource host name.
|
||||
*
|
||||
* @throws at.bitfire.davdroid.sync.account.InvalidAccountException when the account doesn't exist
|
||||
*/
|
||||
@WorkerThread
|
||||
fun fromAccount(account: Account, authDomain: String? = null): HttpClientBuilder {
|
||||
val accountSettings = accountSettingsFactory.create(account)
|
||||
authenticate(
|
||||
domain = UrlUtils.hostToDomain(authDomain),
|
||||
getCredentials = {
|
||||
accountSettings.credentials()
|
||||
},
|
||||
updateAuthState = { authState ->
|
||||
accountSettings.updateAuthState(authState)
|
||||
}
|
||||
)
|
||||
return this
|
||||
}
|
||||
|
||||
/**
|
||||
* Same as [fromAccount], but can be called on any thread.
|
||||
*
|
||||
* @throws at.bitfire.davdroid.sync.account.InvalidAccountException when the account doesn't exist
|
||||
*/
|
||||
suspend fun fromAccountAsync(account: Account, onlyHost: String? = null): HttpClientBuilder = withContext(ioDispatcher) {
|
||||
fromAccount(account, onlyHost)
|
||||
}
|
||||
|
||||
|
||||
// okhttp builder
|
||||
|
||||
/**
|
||||
* Builds an [OkHttpClient] with the configured settings.
|
||||
*
|
||||
* [build] or [buildKtor] is usually called only once because multiple calls indicate this wrong usage pattern:
|
||||
*
|
||||
* ```
|
||||
* val builder = HttpClientBuilder(/*injected*/)
|
||||
* val client1 = builder.configure().build()
|
||||
* val client2 = builder.configureOtherwise().build()
|
||||
* ```
|
||||
*
|
||||
* However in this case the configuration of `client1` is still in `builder` and would be reused for `client2`,
|
||||
* which is usually not desired.
|
||||
*/
|
||||
fun build(): OkHttpClient {
|
||||
if (alreadyBuilt)
|
||||
logger.warning("build() should only be called once; use Provider<HttpClientBuilder> instead")
|
||||
|
||||
val builder = OkHttpClient.Builder()
|
||||
configureOkHttp(builder)
|
||||
|
||||
alreadyBuilt = true
|
||||
return builder.build()
|
||||
}
|
||||
|
||||
private fun configureOkHttp(builder: OkHttpClient.Builder) {
|
||||
buildTimeouts(builder)
|
||||
|
||||
// don't allow redirects by default because it would break PROPFIND handling
|
||||
builder.followRedirects(followRedirects)
|
||||
|
||||
// add User-Agent to every request
|
||||
builder.addInterceptor(UserAgentInterceptor)
|
||||
|
||||
// connection-private cookie store
|
||||
builder.cookieJar(cookieStore)
|
||||
|
||||
// offer Brotli and gzip compression (can be disabled per request with `Accept-Encoding: identity`)
|
||||
builder.addInterceptor(BrotliInterceptor)
|
||||
|
||||
// app-wide custom proxy support
|
||||
buildProxy(builder)
|
||||
|
||||
// add connection security (including client certificates) and authentication
|
||||
buildConnectionSecurity(builder)
|
||||
buildAuthentication(builder)
|
||||
|
||||
// add network logging, if requested
|
||||
if (logger.isLoggable(Level.FINEST)) {
|
||||
val loggingInterceptor = HttpLoggingInterceptor { message -> logger.finest(message) }
|
||||
loggingInterceptor.redactHeader(HttpHeaders.AUTHORIZATION)
|
||||
loggingInterceptor.redactHeader(HttpHeaders.COOKIE)
|
||||
loggingInterceptor.redactHeader(HttpHeaders.SET_COOKIE)
|
||||
loggingInterceptor.redactHeader(HttpHeaders.SET_COOKIE2)
|
||||
loggingInterceptor.level = loggerInterceptorLevel
|
||||
builder.addNetworkInterceptor(loggingInterceptor)
|
||||
}
|
||||
}
|
||||
|
||||
private fun buildAuthentication(okBuilder: OkHttpClient.Builder) {
|
||||
// basic/digest auth and OAuth
|
||||
authenticationInterceptor?.let { okBuilder.addInterceptor(it) }
|
||||
authenticator?.let { okBuilder.authenticator(it) }
|
||||
}
|
||||
|
||||
private fun buildConnectionSecurity(okBuilder: OkHttpClient.Builder) {
|
||||
// allow cleartext and TLS 1.2+
|
||||
okBuilder.connectionSpecs(listOf(
|
||||
ConnectionSpec.CLEARTEXT,
|
||||
ConnectionSpec.MODERN_TLS
|
||||
))
|
||||
|
||||
// client certificate
|
||||
val clientKeyManager: KeyManager? = certificateAlias?.let { alias ->
|
||||
try {
|
||||
val manager = keyManagerFactory.create(alias)
|
||||
logger.fine("Using certificate $alias for authentication")
|
||||
|
||||
// HTTP/2 doesn't support client certificates (yet)
|
||||
// see https://datatracker.ietf.org/doc/draft-ietf-httpbis-secondary-server-certs/
|
||||
okBuilder.protocols(listOf(Protocol.HTTP_1_1))
|
||||
|
||||
manager
|
||||
} catch (e: IllegalArgumentException) {
|
||||
logger.log(Level.SEVERE, "Couldn't create KeyManager for certificate $alias", e)
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
// select trust manager and hostname verifier depending on whether custom certificates are allowed
|
||||
val customTrustManager: X509TrustManager?
|
||||
val customHostnameVerifier: HostnameVerifier?
|
||||
|
||||
if (BuildConfig.allowCustomCerts) {
|
||||
// use cert4android for custom certificate handling
|
||||
customTrustManager = CustomCertManager(
|
||||
certStore = CustomCertStore.getInstance(context),
|
||||
trustSystemCerts = !settingsManager.getBoolean(Settings.DISTRUST_SYSTEM_CERTIFICATES),
|
||||
appInForeground = ForegroundTracker.inForeground
|
||||
)
|
||||
// allow users to accept certificates with wrong host names
|
||||
customHostnameVerifier = customTrustManager.HostnameVerifier(OkHostnameVerifier)
|
||||
|
||||
} else {
|
||||
// no custom certificates, use default trust manager and hostname verifier
|
||||
customTrustManager = null
|
||||
customHostnameVerifier = null
|
||||
}
|
||||
|
||||
// change settings only if we have at least only one custom component
|
||||
if (clientKeyManager != null || customTrustManager != null) {
|
||||
val trustManager = customTrustManager ?: defaultTrustManager()
|
||||
|
||||
// use trust manager and client key manager (if defined) for TLS connections
|
||||
val sslContext = SSLContext.getInstance("TLS")
|
||||
sslContext.init(
|
||||
/* km = */ if (clientKeyManager != null) arrayOf(clientKeyManager) else null,
|
||||
/* tm = */ arrayOf(trustManager),
|
||||
/* random = */ null
|
||||
)
|
||||
okBuilder.sslSocketFactory(sslContext.socketFactory, trustManager)
|
||||
}
|
||||
|
||||
// also add the custom hostname verifier (if defined)
|
||||
if (customHostnameVerifier != null)
|
||||
okBuilder.hostnameVerifier(customHostnameVerifier)
|
||||
}
|
||||
|
||||
private fun defaultTrustManager(): X509TrustManager {
|
||||
val factory = TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm())
|
||||
factory.init(null as KeyStore?)
|
||||
return factory.trustManagers.filterIsInstance<X509TrustManager>().first()
|
||||
}
|
||||
|
||||
private fun buildProxy(okBuilder: OkHttpClient.Builder) {
|
||||
try {
|
||||
val proxyTypeValue = settingsManager.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(
|
||||
settingsManager.getString(Settings.PROXY_HOST),
|
||||
settingsManager.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")
|
||||
}
|
||||
okBuilder.proxy(proxy)
|
||||
logger.log(Level.INFO, "Using proxy setting", proxy)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
logger.log(Level.SEVERE, "Can't set proxy, ignoring", e)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Set timeouts for the connection.
|
||||
*
|
||||
* **Note:** According to [android.content.AbstractThreadedSyncAdapter], when there is no network
|
||||
* traffic within a minute, a sync will be cancelled.
|
||||
*/
|
||||
private fun buildTimeouts(builder: OkHttpClient.Builder) {
|
||||
builder.connectTimeout(15, TimeUnit.SECONDS)
|
||||
.writeTimeout(30, TimeUnit.SECONDS)
|
||||
.readTimeout(120, TimeUnit.SECONDS)
|
||||
.pingInterval(45, TimeUnit.SECONDS) // avoid cancellation because of missing traffic; only works for HTTP/2
|
||||
}
|
||||
|
||||
|
||||
// Ktor builder
|
||||
|
||||
/**
|
||||
* Builds a Ktor [HttpClient] with the configured settings.
|
||||
*
|
||||
* [buildKtor] or [build] must be called only once because multiple calls indicate this wrong usage pattern:
|
||||
*
|
||||
* ```
|
||||
* val builder = HttpClientBuilder(/*injected*/)
|
||||
* val client1 = builder.configure().buildKtor()
|
||||
* val client2 = builder.configureOtherwise().buildKtor()
|
||||
* ```
|
||||
*
|
||||
* However in this case the configuration of `client1` is still in `builder` and would be reused for `client2`,
|
||||
* which is usually not desired.
|
||||
*
|
||||
* @return the new HttpClient (with [OkHttp] engine) which **must be closed by the caller**
|
||||
*/
|
||||
@MustBeClosed
|
||||
fun buildKtor(): HttpClient {
|
||||
if (alreadyBuilt)
|
||||
logger.warning("buildKtor() should only be called once; use Provider<HttpClientBuilder> instead")
|
||||
|
||||
val client = HttpClient(OkHttp) {
|
||||
// Ktor-level configuration here
|
||||
|
||||
// automatically convert JSON from/into data classes (if requested in respective code)
|
||||
install(ContentNegotiation) {
|
||||
json()
|
||||
}
|
||||
|
||||
engine {
|
||||
// okhttp engine configuration here
|
||||
|
||||
config {
|
||||
// OkHttpClient.Builder configuration here
|
||||
configureOkHttp(this)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
alreadyBuilt = true
|
||||
return client
|
||||
}
|
||||
|
||||
}
|
||||
@@ -4,26 +4,28 @@
|
||||
|
||||
package at.bitfire.davdroid.network
|
||||
|
||||
import at.bitfire.dav4jvm.exception.DavException
|
||||
import at.bitfire.dav4jvm.exception.HttpException
|
||||
import androidx.annotation.VisibleForTesting
|
||||
import at.bitfire.dav4jvm.ktor.exception.HttpException
|
||||
import at.bitfire.davdroid.settings.Credentials
|
||||
import at.bitfire.davdroid.ui.setup.LoginInfo
|
||||
import at.bitfire.davdroid.util.SensitiveString.Companion.toSensitiveString
|
||||
import at.bitfire.davdroid.util.withTrailingSlash
|
||||
import at.bitfire.vcard4android.GroupMethod
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.runInterruptible
|
||||
import kotlinx.coroutines.withContext
|
||||
import okhttp3.HttpUrl
|
||||
import okhttp3.HttpUrl.Companion.toHttpUrl
|
||||
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
|
||||
import okhttp3.MediaType.Companion.toMediaType
|
||||
import okhttp3.Request
|
||||
import okhttp3.RequestBody
|
||||
import okhttp3.RequestBody.Companion.toRequestBody
|
||||
import org.json.JSONObject
|
||||
import java.net.HttpURLConnection
|
||||
import io.ktor.client.HttpClient
|
||||
import io.ktor.client.call.body
|
||||
import io.ktor.client.request.post
|
||||
import io.ktor.client.request.setBody
|
||||
import io.ktor.http.ContentType
|
||||
import io.ktor.http.URLBuilder
|
||||
import io.ktor.http.Url
|
||||
import io.ktor.http.appendPathSegments
|
||||
import io.ktor.http.contentType
|
||||
import io.ktor.http.isSuccess
|
||||
import io.ktor.http.path
|
||||
import kotlinx.serialization.Serializable
|
||||
import java.net.URI
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Provider
|
||||
|
||||
/**
|
||||
* Implements Nextcloud Login Flow v2.
|
||||
@@ -31,8 +33,133 @@ import javax.inject.Inject
|
||||
* See https://docs.nextcloud.com/server/latest/developer_manual/client_apis/LoginFlow/index.html#login-flow-v2
|
||||
*/
|
||||
class NextcloudLoginFlow @Inject constructor(
|
||||
httpClientBuilder: HttpClient.Builder
|
||||
): AutoCloseable {
|
||||
private val httpClientBuilder: Provider<HttpClientBuilder>
|
||||
) {
|
||||
|
||||
// Login flow state
|
||||
var pollUrl: Url? = null
|
||||
var token: String? = null
|
||||
|
||||
/**
|
||||
* Starts Nextcloud Login Flow v2.
|
||||
*
|
||||
* @param baseUrl Nextcloud login flow or base URL
|
||||
*
|
||||
* @return URL that should be opened in the browser (login screen)
|
||||
*
|
||||
* @throws HttpException on non-successful HTTP status
|
||||
*/
|
||||
suspend fun start(baseUrl: Url): Url {
|
||||
// reset fields in case something goes wrong
|
||||
pollUrl = null
|
||||
token = null
|
||||
|
||||
// POST to login flow URL in order to receive endpoint data
|
||||
createClient().use { client ->
|
||||
val result = client.post(loginFlowUrl(baseUrl))
|
||||
if (!result.status.isSuccess())
|
||||
throw HttpException.fromResponse(result)
|
||||
|
||||
// save endpoint data for polling
|
||||
val endpointData: EndpointData = result.body()
|
||||
pollUrl = Url(endpointData.poll.endpoint)
|
||||
token = endpointData.poll.token
|
||||
|
||||
return Url(endpointData.login)
|
||||
}
|
||||
}
|
||||
|
||||
@VisibleForTesting
|
||||
internal fun loginFlowUrl(baseUrl: Url): Url {
|
||||
return when {
|
||||
// already a Login Flow v2 URL
|
||||
baseUrl.encodedPath.endsWith(FLOW_V2_PATH) ->
|
||||
baseUrl
|
||||
|
||||
// Login Flow v1 URL, rewrite to v2
|
||||
baseUrl.encodedPath.endsWith(FLOW_V1_PATH) -> {
|
||||
// drop "[index.php/login]/flow" from the end and append "/v2"
|
||||
val v2Segments = baseUrl.segments.dropLast(1) + "v2"
|
||||
val builder = URLBuilder(baseUrl)
|
||||
builder.path(*v2Segments.toTypedArray())
|
||||
builder.build()
|
||||
}
|
||||
|
||||
// other URL, make it a Login Flow v2 URL
|
||||
else ->
|
||||
URLBuilder(baseUrl)
|
||||
.appendPathSegments(FLOW_V2_PATH.split('/'))
|
||||
.build()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves login info from the polling endpoint using [pollUrl]/[token].
|
||||
*
|
||||
* @throws HttpException on non-successful HTTP status
|
||||
*/
|
||||
suspend fun fetchLoginInfo(): LoginInfo {
|
||||
val pollUrl = pollUrl ?: throw IllegalArgumentException("Missing pollUrl")
|
||||
val token = token ?: throw IllegalArgumentException("Missing token")
|
||||
|
||||
// send HTTP request to request server, login name and app password
|
||||
createClient().use { client ->
|
||||
val result = client.post(pollUrl) {
|
||||
contentType(ContentType.Application.FormUrlEncoded)
|
||||
setBody("token=$token")
|
||||
}
|
||||
if (!result.status.isSuccess())
|
||||
throw HttpException.fromResponse(result)
|
||||
|
||||
// make sure server URL ends with a slash so that DAV_PATH can be appended
|
||||
val loginData: LoginData = result.body()
|
||||
val serverUrl = loginData.server.withTrailingSlash()
|
||||
|
||||
return LoginInfo(
|
||||
baseUri = URI(serverUrl).resolve(DAV_PATH),
|
||||
credentials = Credentials(
|
||||
username = loginData.loginName,
|
||||
password = loginData.appPassword.toSensitiveString()
|
||||
),
|
||||
suggestedGroupMethod = GroupMethod.CATEGORIES
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a Ktor HTTP client that follows redirects.
|
||||
*/
|
||||
private fun createClient(): HttpClient =
|
||||
httpClientBuilder.get()
|
||||
.followRedirects(true)
|
||||
.buildKtor()
|
||||
|
||||
|
||||
/**
|
||||
* Represents the JSON response that is returned on the first call to `/login/v2`.
|
||||
*/
|
||||
@Serializable
|
||||
private data class EndpointData(
|
||||
val poll: Poll,
|
||||
val login: String
|
||||
) {
|
||||
@Serializable
|
||||
data class Poll(
|
||||
val token: String,
|
||||
val endpoint: String
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Represents the JSON response that is returned by the polling endpoint.
|
||||
*/
|
||||
@Serializable
|
||||
private data class LoginData(
|
||||
val server: String,
|
||||
val loginName: String,
|
||||
val appPassword: String
|
||||
)
|
||||
|
||||
|
||||
companion object {
|
||||
const val FLOW_V1_PATH = "index.php/login/flow"
|
||||
@@ -42,97 +169,4 @@ class NextcloudLoginFlow @Inject constructor(
|
||||
const val DAV_PATH = "remote.php/dav"
|
||||
}
|
||||
|
||||
val httpClient = httpClientBuilder
|
||||
.build()
|
||||
|
||||
override fun close() {
|
||||
httpClient.close()
|
||||
}
|
||||
|
||||
|
||||
// Login flow state
|
||||
var loginUrl: HttpUrl? = null
|
||||
var pollUrl: HttpUrl? = null
|
||||
var token: String? = null
|
||||
|
||||
|
||||
suspend fun initiate(baseUrl: HttpUrl): HttpUrl? {
|
||||
loginUrl = null
|
||||
pollUrl = null
|
||||
token = null
|
||||
|
||||
val json = postForJson(initiateUrl(baseUrl), "".toRequestBody())
|
||||
|
||||
loginUrl = json.getString("login").toHttpUrlOrNull()
|
||||
json.getJSONObject("poll").let { poll ->
|
||||
pollUrl = poll.getString("endpoint").toHttpUrl()
|
||||
token = poll.getString("token")
|
||||
}
|
||||
|
||||
return loginUrl
|
||||
}
|
||||
|
||||
fun initiateUrl(baseUrl: HttpUrl): HttpUrl {
|
||||
val path = baseUrl.encodedPath
|
||||
|
||||
if (path.endsWith(FLOW_V2_PATH))
|
||||
// already a Login Flow v2 URL
|
||||
return baseUrl
|
||||
|
||||
if (path.endsWith(FLOW_V1_PATH))
|
||||
// Login Flow v1 URL, rewrite to v2
|
||||
return baseUrl.newBuilder()
|
||||
.encodedPath(path.replace(FLOW_V1_PATH, FLOW_V2_PATH))
|
||||
.build()
|
||||
|
||||
// other URL, make it a Login Flow v2 URL
|
||||
return baseUrl.newBuilder()
|
||||
.addPathSegments(FLOW_V2_PATH)
|
||||
.build()
|
||||
}
|
||||
|
||||
|
||||
suspend fun fetchLoginInfo(): LoginInfo {
|
||||
val pollUrl = pollUrl ?: throw IllegalArgumentException("Missing pollUrl")
|
||||
val token = token ?: throw IllegalArgumentException("Missing token")
|
||||
|
||||
// send HTTP request to request server, login name and app password
|
||||
val json = postForJson(pollUrl, "token=$token".toRequestBody("application/x-www-form-urlencoded".toMediaType()))
|
||||
|
||||
// make sure server URL ends with a slash so that DAV_PATH can be appended
|
||||
val serverUrl = json.getString("server").withTrailingSlash()
|
||||
|
||||
return LoginInfo(
|
||||
baseUri = URI(serverUrl).resolve(DAV_PATH),
|
||||
credentials = Credentials(
|
||||
username = json.getString("loginName"),
|
||||
password = json.getString("appPassword").toCharArray()
|
||||
),
|
||||
suggestedGroupMethod = GroupMethod.CATEGORIES
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
private suspend fun postForJson(url: HttpUrl, requestBody: RequestBody): JSONObject = withContext(Dispatchers.IO) {
|
||||
val postRq = Request.Builder()
|
||||
.url(url)
|
||||
.post(requestBody)
|
||||
.build()
|
||||
val response = runInterruptible {
|
||||
httpClient.okHttpClient.newCall(postRq).execute()
|
||||
}
|
||||
|
||||
if (response.code != HttpURLConnection.HTTP_OK)
|
||||
throw HttpException(response)
|
||||
|
||||
response.body.use { body ->
|
||||
val mimeType = body.contentType() ?: throw DavException("Login Flow response without MIME type")
|
||||
if (mimeType.type != "application" || mimeType.subtype != "json")
|
||||
throw DavException("Invalid Login Flow response (not JSON)")
|
||||
|
||||
// decode JSON
|
||||
return@withContext JSONObject(body.string())
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -7,6 +7,7 @@ package at.bitfire.davdroid.push
|
||||
import androidx.annotation.VisibleForTesting
|
||||
import at.bitfire.dav4jvm.XmlReader
|
||||
import at.bitfire.dav4jvm.XmlUtils
|
||||
import at.bitfire.dav4jvm.property.push.WebDAVPush
|
||||
import at.bitfire.davdroid.db.Collection.Companion.TYPE_ADDRESSBOOK
|
||||
import at.bitfire.davdroid.repository.AccountRepository
|
||||
import at.bitfire.davdroid.repository.DavCollectionRepository
|
||||
@@ -105,7 +106,7 @@ class PushMessageHandler @Inject constructor(
|
||||
try {
|
||||
parser.setInput(StringReader(message))
|
||||
|
||||
XmlReader(parser).processTag(DavPushMessage.NAME) {
|
||||
XmlReader(parser).processTag(WebDAVPush.PushMessage) {
|
||||
val pushMessage = DavPushMessage.Factory.create(parser)
|
||||
topic = pushMessage.topic?.topic
|
||||
}
|
||||
|
||||
@@ -11,22 +11,17 @@ import androidx.work.ExistingPeriodicWorkPolicy
|
||||
import androidx.work.NetworkType
|
||||
import androidx.work.PeriodicWorkRequest
|
||||
import androidx.work.WorkManager
|
||||
import at.bitfire.dav4jvm.DavCollection
|
||||
import at.bitfire.dav4jvm.DavResource
|
||||
import at.bitfire.dav4jvm.HttpUtils
|
||||
import at.bitfire.dav4jvm.XmlUtils
|
||||
import at.bitfire.dav4jvm.XmlUtils.insertTag
|
||||
import at.bitfire.dav4jvm.exception.DavException
|
||||
import at.bitfire.dav4jvm.property.push.AuthSecret
|
||||
import at.bitfire.dav4jvm.property.push.PushRegister
|
||||
import at.bitfire.dav4jvm.property.push.PushResource
|
||||
import at.bitfire.dav4jvm.property.push.Subscription
|
||||
import at.bitfire.dav4jvm.property.push.SubscriptionPublicKey
|
||||
import at.bitfire.dav4jvm.property.push.WebPushSubscription
|
||||
import at.bitfire.dav4jvm.okhttp.DavCollection
|
||||
import at.bitfire.dav4jvm.okhttp.DavResource
|
||||
import at.bitfire.dav4jvm.okhttp.exception.DavException
|
||||
import at.bitfire.dav4jvm.property.push.WebDAVPush
|
||||
import at.bitfire.davdroid.db.Collection
|
||||
import at.bitfire.davdroid.db.Service
|
||||
import at.bitfire.davdroid.di.IoDispatcher
|
||||
import at.bitfire.davdroid.network.HttpClient
|
||||
import at.bitfire.davdroid.network.HttpClientBuilder
|
||||
import at.bitfire.davdroid.push.PushRegistrationManager.Companion.mutex
|
||||
import at.bitfire.davdroid.repository.AccountRepository
|
||||
import at.bitfire.davdroid.repository.DavCollectionRepository
|
||||
@@ -41,6 +36,7 @@ import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import okhttp3.HttpUrl
|
||||
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.RequestBody.Companion.toRequestBody
|
||||
import org.unifiedpush.android.connector.UnifiedPush
|
||||
import org.unifiedpush.android.connector.data.PushEndpoint
|
||||
@@ -65,7 +61,7 @@ class PushRegistrationManager @Inject constructor(
|
||||
private val accountRepository: Lazy<AccountRepository>,
|
||||
private val collectionRepository: DavCollectionRepository,
|
||||
@ApplicationContext private val context: Context,
|
||||
private val httpClientBuilder: Provider<HttpClient.Builder>,
|
||||
private val httpClientBuilder: Provider<HttpClientBuilder>,
|
||||
@IoDispatcher private val ioDispatcher: CoroutineDispatcher,
|
||||
private val logger: Logger,
|
||||
private val serviceRepository: DavServiceRepository
|
||||
@@ -180,25 +176,23 @@ class PushRegistrationManager @Inject constructor(
|
||||
return
|
||||
|
||||
val account = accountRepository.get().fromName(service.accountName)
|
||||
httpClientBuilder.get()
|
||||
val httpClient = httpClientBuilder.get()
|
||||
.fromAccountAsync(account)
|
||||
.build()
|
||||
.use { httpClient ->
|
||||
for (collection in subscribeTo)
|
||||
try {
|
||||
val expires = collection.pushSubscriptionExpires
|
||||
// calculate next run time, but use the duplicate interval for safety (times are not exact)
|
||||
val nextRun = Instant.now() + Duration.ofDays(2 * WORKER_INTERVAL_DAYS)
|
||||
if (expires != null && expires >= nextRun.epochSecond)
|
||||
logger.fine("Push subscription for ${collection.url} is still valid until ${collection.pushSubscriptionExpires}")
|
||||
else {
|
||||
// no existing subscription or expiring soon
|
||||
logger.fine("Registering push subscription for ${collection.url}")
|
||||
subscribe(httpClient, collection, endpoint)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
logger.log(Level.WARNING, "Couldn't register subscription at CalDAV/CardDAV server", e)
|
||||
}
|
||||
for (collection in subscribeTo)
|
||||
try {
|
||||
val expires = collection.pushSubscriptionExpires
|
||||
// calculate next run time, but use the duplicate interval for safety (times are not exact)
|
||||
val nextRun = Instant.now() + Duration.ofDays(2 * WORKER_INTERVAL_DAYS)
|
||||
if (expires != null && expires >= nextRun.epochSecond)
|
||||
logger.fine("Push subscription for ${collection.url} is still valid until ${collection.pushSubscriptionExpires}")
|
||||
else {
|
||||
// no existing subscription or expiring soon
|
||||
logger.fine("Registering push subscription for ${collection.url}")
|
||||
subscribe(httpClient, collection, endpoint)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
logger.log(Level.WARNING, "Couldn't register subscription at CalDAV/CardDAV server", e)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -230,7 +224,7 @@ class PushRegistrationManager @Inject constructor(
|
||||
* @param collection collection to subscribe to
|
||||
* @param endpoint subscription to register
|
||||
*/
|
||||
private suspend fun subscribe(httpClient: HttpClient, collection: Collection, endpoint: PushEndpoint) {
|
||||
private suspend fun subscribe(httpClient: OkHttpClient, collection: Collection, endpoint: PushEndpoint) {
|
||||
// requested expiration time: 3 days
|
||||
val requestedExpiration = Instant.now() + Duration.ofDays(3)
|
||||
|
||||
@@ -238,26 +232,26 @@ class PushRegistrationManager @Inject constructor(
|
||||
val writer = StringWriter()
|
||||
serializer.setOutput(writer)
|
||||
serializer.startDocument("UTF-8", true)
|
||||
serializer.insertTag(PushRegister.NAME) {
|
||||
serializer.insertTag(Subscription.NAME) {
|
||||
serializer.insertTag(WebDAVPush.PushRegister) {
|
||||
serializer.insertTag(WebDAVPush.Subscription) {
|
||||
// subscription URL
|
||||
serializer.insertTag(WebPushSubscription.NAME) {
|
||||
serializer.insertTag(PushResource.NAME) {
|
||||
serializer.insertTag(WebDAVPush.WebPushSubscription) {
|
||||
serializer.insertTag(WebDAVPush.PushResource) {
|
||||
text(endpoint.url)
|
||||
}
|
||||
endpoint.pubKeySet?.let { pubKeySet ->
|
||||
serializer.insertTag(SubscriptionPublicKey.NAME) {
|
||||
serializer.insertTag(WebDAVPush.SubscriptionPublicKey) {
|
||||
attribute(null, "type", "p256dh")
|
||||
text(pubKeySet.pubKey)
|
||||
}
|
||||
serializer.insertTag(AuthSecret.NAME) {
|
||||
serializer.insertTag(WebDAVPush.AuthSecret) {
|
||||
text(pubKeySet.auth)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// requested expiration
|
||||
serializer.insertTag(PushRegister.EXPIRES) {
|
||||
serializer.insertTag(WebDAVPush.Expires) {
|
||||
text(HttpUtils.formatDate(requestedExpiration))
|
||||
}
|
||||
}
|
||||
@@ -265,7 +259,7 @@ class PushRegistrationManager @Inject constructor(
|
||||
|
||||
runInterruptible(ioDispatcher) {
|
||||
val xml = writer.toString().toRequestBody(DavResource.MIME_XML)
|
||||
DavCollection(httpClient.okHttpClient, collection.url).post(xml) { response ->
|
||||
DavCollection(httpClient, collection.url).post(xml) { response ->
|
||||
if (response.isSuccessful) {
|
||||
// update subscription URL and expiration in DB
|
||||
val subscriptionUrl = response.header("Location")
|
||||
@@ -294,22 +288,20 @@ class PushRegistrationManager @Inject constructor(
|
||||
return
|
||||
|
||||
val account = accountRepository.get().fromName(service.accountName)
|
||||
httpClientBuilder.get()
|
||||
val httpClient = httpClientBuilder.get()
|
||||
.fromAccountAsync(account)
|
||||
.build()
|
||||
.use { httpClient ->
|
||||
for (collection in from)
|
||||
collection.pushSubscription?.toHttpUrlOrNull()?.let { url ->
|
||||
logger.info("Unsubscribing Push from ${collection.url}")
|
||||
unsubscribe(httpClient, collection, url)
|
||||
}
|
||||
for (collection in from)
|
||||
collection.pushSubscription?.toHttpUrlOrNull()?.let { url ->
|
||||
logger.info("Unsubscribing Push from ${collection.url}")
|
||||
unsubscribe(httpClient, collection, url)
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun unsubscribe(httpClient: HttpClient, collection: Collection, url: HttpUrl) {
|
||||
private suspend fun unsubscribe(httpClient: OkHttpClient, collection: Collection, url: HttpUrl) {
|
||||
try {
|
||||
runInterruptible(ioDispatcher) {
|
||||
DavResource(httpClient.okHttpClient, url).delete {
|
||||
DavResource(httpClient, url).delete {
|
||||
// deleted
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,10 +8,12 @@ import android.accounts.Account
|
||||
import android.accounts.AccountManager
|
||||
import android.accounts.OnAccountsUpdateListener
|
||||
import android.content.Context
|
||||
import androidx.annotation.WorkerThread
|
||||
import at.bitfire.davdroid.R
|
||||
import at.bitfire.davdroid.db.HomeSet
|
||||
import at.bitfire.davdroid.db.Service
|
||||
import at.bitfire.davdroid.db.ServiceType
|
||||
import at.bitfire.davdroid.di.DefaultDispatcher
|
||||
import at.bitfire.davdroid.resource.LocalAddressBookStore
|
||||
import at.bitfire.davdroid.resource.LocalCalendarStore
|
||||
import at.bitfire.davdroid.servicedetection.DavResourceFinder
|
||||
@@ -28,7 +30,7 @@ import at.bitfire.davdroid.sync.worker.SyncWorkerManager
|
||||
import at.bitfire.vcard4android.GroupMethod
|
||||
import dagger.Lazy
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
import kotlinx.coroutines.channels.awaitClose
|
||||
import kotlinx.coroutines.flow.callbackFlow
|
||||
import kotlinx.coroutines.withContext
|
||||
@@ -47,6 +49,7 @@ class AccountRepository @Inject constructor(
|
||||
private val automaticSyncManager: Lazy<AutomaticSyncManager>,
|
||||
@ApplicationContext private val context: Context,
|
||||
private val collectionRepository: DavCollectionRepository,
|
||||
@DefaultDispatcher private val defaultDispatcher: CoroutineDispatcher,
|
||||
private val homeSetRepository: DavHomeSetRepository,
|
||||
private val localCalendarStore: Lazy<LocalCalendarStore>,
|
||||
private val localAddressBookStore: Lazy<LocalAddressBookStore>,
|
||||
@@ -70,6 +73,7 @@ class AccountRepository @Inject constructor(
|
||||
*
|
||||
* @return account if account creation was successful; null otherwise (for instance because an account with this name already exists)
|
||||
*/
|
||||
@WorkerThread
|
||||
fun createBlocking(accountName: String, credentials: Credentials?, config: DavResourceFinder.Configuration, groupMethod: GroupMethod): Account? {
|
||||
val account = fromName(accountName)
|
||||
|
||||
@@ -153,7 +157,7 @@ class AccountRepository @Inject constructor(
|
||||
val listener = OnAccountsUpdateListener { accounts ->
|
||||
trySend(accounts.filter { it.type == accountType }.toSet())
|
||||
}
|
||||
withContext(Dispatchers.Default) { // causes disk I/O
|
||||
withContext(defaultDispatcher) { // causes disk I/O
|
||||
accountManager.addOnAccountsUpdatedListener(listener, null, true)
|
||||
}
|
||||
|
||||
@@ -165,7 +169,7 @@ class AccountRepository @Inject constructor(
|
||||
/**
|
||||
* Renames an account.
|
||||
*
|
||||
* **Not**: It is highly advised to re-sync the account after renaming in order to restore
|
||||
* **Note**: It is highly advised to re-sync the account after renaming in order to restore
|
||||
* a consistent state.
|
||||
*
|
||||
* @param oldName current name of the account
|
||||
@@ -175,7 +179,7 @@ class AccountRepository @Inject constructor(
|
||||
* @throws IllegalArgumentException if the new account name already exists
|
||||
* @throws Exception (or sub-classes) on other errors
|
||||
*/
|
||||
suspend fun rename(oldName: String, newName: String) {
|
||||
suspend fun rename(oldName: String, newName: String): Unit = withContext(defaultDispatcher) {
|
||||
val oldAccount = fromName(oldName)
|
||||
val newAccount = fromName(newName)
|
||||
|
||||
@@ -197,13 +201,10 @@ class AccountRepository @Inject constructor(
|
||||
// rename account (also moves AccountSettings)
|
||||
val future = accountManager.renameAccount(oldAccount, newName, null, null)
|
||||
|
||||
// wait for operation to complete
|
||||
withContext(Dispatchers.Default) {
|
||||
// blocks calling thread
|
||||
val newNameFromApi: Account = future.result
|
||||
if (newNameFromApi.name != newName)
|
||||
throw IllegalStateException("renameAccount returned ${newNameFromApi.name} instead of $newName")
|
||||
}
|
||||
// wait for operation to complete (blocks calling thread)
|
||||
val newNameFromApi: Account = future.result
|
||||
if (newNameFromApi.name != newName)
|
||||
throw IllegalStateException("renameAccount returned ${newNameFromApi.name} instead of $newName")
|
||||
|
||||
// account renamed, cancel maybe running synchronization of old account
|
||||
syncWorkerManager.get().cancelAllWork(oldAccount)
|
||||
@@ -217,22 +218,27 @@ class AccountRepository @Inject constructor(
|
||||
|
||||
try {
|
||||
// update address books
|
||||
localAddressBookStore.get().updateAccount(oldAccount, newAccount)
|
||||
localAddressBookStore.get().updateAccount(oldAccount, newAccount, null)
|
||||
} catch (e: Exception) {
|
||||
logger.log(Level.WARNING, "Couldn't change address books to renamed account", e)
|
||||
}
|
||||
|
||||
try {
|
||||
// update calendar events
|
||||
localCalendarStore.get().updateAccount(oldAccount, newAccount)
|
||||
val store = localCalendarStore.get()
|
||||
store.acquireContentProvider(true)?.use { client ->
|
||||
store.updateAccount(oldAccount, newAccount, client)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
logger.log(Level.WARNING, "Couldn't change calendars to renamed account", e)
|
||||
}
|
||||
|
||||
try {
|
||||
// update account_name of local tasks
|
||||
val dataStore = tasksAppManager.get().getDataStore()
|
||||
dataStore?.updateAccount(oldAccount, newAccount)
|
||||
val store = tasksAppManager.get().getDataStore()
|
||||
store?.acquireContentProvider(true)?.use { client ->
|
||||
store.updateAccount(oldAccount, newAccount, client)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
logger.log(Level.WARNING, "Couldn't change task lists to renamed account", e)
|
||||
}
|
||||
|
||||
@@ -6,23 +6,15 @@ package at.bitfire.davdroid.repository
|
||||
|
||||
import android.accounts.Account
|
||||
import android.content.Context
|
||||
import at.bitfire.dav4jvm.DavResource
|
||||
import at.bitfire.dav4jvm.XmlUtils
|
||||
import at.bitfire.dav4jvm.XmlUtils.insertTag
|
||||
import at.bitfire.dav4jvm.exception.GoneException
|
||||
import at.bitfire.dav4jvm.exception.HttpException
|
||||
import at.bitfire.dav4jvm.exception.NotFoundException
|
||||
import at.bitfire.dav4jvm.property.caldav.CalendarColor
|
||||
import at.bitfire.dav4jvm.property.caldav.CalendarDescription
|
||||
import at.bitfire.dav4jvm.property.caldav.CalendarTimezone
|
||||
import at.bitfire.dav4jvm.property.caldav.CalendarTimezoneId
|
||||
import at.bitfire.dav4jvm.property.caldav.NS_CALDAV
|
||||
import at.bitfire.dav4jvm.property.caldav.SupportedCalendarComponentSet
|
||||
import at.bitfire.dav4jvm.property.carddav.AddressbookDescription
|
||||
import at.bitfire.dav4jvm.property.carddav.NS_CARDDAV
|
||||
import at.bitfire.dav4jvm.property.webdav.DisplayName
|
||||
import at.bitfire.dav4jvm.property.webdav.NS_WEBDAV
|
||||
import at.bitfire.dav4jvm.property.webdav.ResourceType
|
||||
import at.bitfire.dav4jvm.okhttp.DavResource
|
||||
import at.bitfire.dav4jvm.okhttp.exception.GoneException
|
||||
import at.bitfire.dav4jvm.okhttp.exception.HttpException
|
||||
import at.bitfire.dav4jvm.okhttp.exception.NotFoundException
|
||||
import at.bitfire.dav4jvm.property.caldav.CalDAV
|
||||
import at.bitfire.dav4jvm.property.carddav.CardDAV
|
||||
import at.bitfire.dav4jvm.property.webdav.WebDAV
|
||||
import at.bitfire.davdroid.Constants
|
||||
import at.bitfire.davdroid.R
|
||||
import at.bitfire.davdroid.db.AppDatabase
|
||||
@@ -30,7 +22,7 @@ import at.bitfire.davdroid.db.Collection
|
||||
import at.bitfire.davdroid.db.CollectionType
|
||||
import at.bitfire.davdroid.db.HomeSet
|
||||
import at.bitfire.davdroid.di.IoDispatcher
|
||||
import at.bitfire.davdroid.network.HttpClient
|
||||
import at.bitfire.davdroid.network.HttpClientBuilder
|
||||
import at.bitfire.davdroid.servicedetection.RefreshCollectionsWorker
|
||||
import at.bitfire.davdroid.util.DavUtils
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
@@ -43,6 +35,7 @@ import net.fortuna.ical4j.model.Property
|
||||
import net.fortuna.ical4j.model.PropertyList
|
||||
import net.fortuna.ical4j.model.TimeZoneRegistryFactory
|
||||
import net.fortuna.ical4j.model.component.VTimeZone
|
||||
import net.fortuna.ical4j.model.property.ProdId
|
||||
import net.fortuna.ical4j.model.property.Version
|
||||
import okhttp3.HttpUrl
|
||||
import java.io.StringWriter
|
||||
@@ -58,7 +51,7 @@ class DavCollectionRepository @Inject constructor(
|
||||
@ApplicationContext private val context: Context,
|
||||
private val db: AppDatabase,
|
||||
private val logger: Logger,
|
||||
private val httpClientBuilder: Provider<HttpClient.Builder>,
|
||||
private val httpClientBuilder: Provider<HttpClientBuilder>,
|
||||
@IoDispatcher private val ioDispatcher: CoroutineDispatcher,
|
||||
private val serviceRepository: DavServiceRepository
|
||||
) {
|
||||
@@ -172,21 +165,20 @@ class DavCollectionRepository @Inject constructor(
|
||||
val service = serviceRepository.getBlocking(collection.serviceId) ?: throw IllegalArgumentException("Service not found")
|
||||
val account = Account(service.accountName, context.getString(R.string.account_type))
|
||||
|
||||
httpClientBuilder.get().fromAccount(account).build().use { httpClient ->
|
||||
runInterruptible(ioDispatcher) {
|
||||
try {
|
||||
DavResource(httpClient.okHttpClient, collection.url).delete {
|
||||
// success, otherwise an exception would have been thrown → delete locally, too
|
||||
delete(collection)
|
||||
}
|
||||
} catch (e: HttpException) {
|
||||
if (e is NotFoundException || e is GoneException) {
|
||||
// HTTP 404 Not Found or 410 Gone (collection is not there anymore) -> delete locally, too
|
||||
logger.info("Collection ${collection.url} not found on server, deleting locally")
|
||||
delete(collection)
|
||||
} else
|
||||
throw e
|
||||
val httpClient = httpClientBuilder.get().fromAccount(account).build()
|
||||
runInterruptible(ioDispatcher) {
|
||||
try {
|
||||
DavResource(httpClient, collection.url).delete {
|
||||
// success, otherwise an exception would have been thrown → delete locally, too
|
||||
delete(collection)
|
||||
}
|
||||
} catch (e: HttpException) {
|
||||
if (e is NotFoundException || e is GoneException) {
|
||||
// HTTP 404 Not Found or 410 Gone (collection is not there anymore) -> delete locally, too
|
||||
logger.info("Collection ${collection.url} not found on server, deleting locally")
|
||||
delete(collection)
|
||||
} else
|
||||
throw e
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -289,19 +281,17 @@ class DavCollectionRepository @Inject constructor(
|
||||
// helpers
|
||||
|
||||
private suspend fun createOnServer(account: Account, url: HttpUrl, method: String, xmlBody: String) {
|
||||
httpClientBuilder.get()
|
||||
val httpClient = httpClientBuilder.get()
|
||||
.fromAccount(account)
|
||||
.build()
|
||||
.use { httpClient ->
|
||||
runInterruptible(ioDispatcher) {
|
||||
DavResource(httpClient.okHttpClient, url).mkCol(
|
||||
xmlBody = xmlBody,
|
||||
method = method
|
||||
) {
|
||||
// success, otherwise an exception would have been thrown
|
||||
}
|
||||
}
|
||||
runInterruptible(ioDispatcher) {
|
||||
DavResource(httpClient, url).mkCol(
|
||||
xmlBody = xmlBody,
|
||||
method = method
|
||||
) {
|
||||
// success, otherwise an exception would have been thrown
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun generateMkColXml(
|
||||
@@ -320,27 +310,27 @@ class DavCollectionRepository @Inject constructor(
|
||||
setOutput(writer)
|
||||
|
||||
startDocument("UTF-8", null)
|
||||
setPrefix("", NS_WEBDAV)
|
||||
setPrefix("CAL", NS_CALDAV)
|
||||
setPrefix("CARD", NS_CARDDAV)
|
||||
setPrefix("", WebDAV.NS_WEBDAV)
|
||||
setPrefix("CAL", CalDAV.NS_CALDAV)
|
||||
setPrefix("CARD", CardDAV.NS_CARDDAV)
|
||||
|
||||
if (addressBook)
|
||||
startTag(NS_WEBDAV, "mkcol")
|
||||
startTag(WebDAV.NS_WEBDAV, "mkcol")
|
||||
else
|
||||
startTag(NS_CALDAV, "mkcalendar")
|
||||
startTag(CalDAV.NS_CALDAV, "mkcalendar")
|
||||
|
||||
insertTag(DavResource.SET) {
|
||||
insertTag(DavResource.PROP) {
|
||||
insertTag(ResourceType.NAME) {
|
||||
insertTag(ResourceType.COLLECTION)
|
||||
insertTag(WebDAV.Set) {
|
||||
insertTag(WebDAV.Prop) {
|
||||
insertTag(WebDAV.ResourceType) {
|
||||
insertTag(WebDAV.Collection)
|
||||
if (addressBook)
|
||||
insertTag(ResourceType.ADDRESSBOOK)
|
||||
insertTag(CardDAV.Addressbook)
|
||||
else
|
||||
insertTag(ResourceType.CALENDAR)
|
||||
insertTag(CalDAV.Calendar)
|
||||
}
|
||||
|
||||
displayName?.let {
|
||||
insertTag(DisplayName.NAME) {
|
||||
insertTag(WebDAV.DisplayName) {
|
||||
text(it)
|
||||
}
|
||||
}
|
||||
@@ -348,7 +338,7 @@ class DavCollectionRepository @Inject constructor(
|
||||
if (addressBook) {
|
||||
// addressbook-specific properties
|
||||
description?.let {
|
||||
insertTag(AddressbookDescription.NAME) {
|
||||
insertTag(CardDAV.AddressbookDescription) {
|
||||
text(it)
|
||||
}
|
||||
}
|
||||
@@ -356,27 +346,27 @@ class DavCollectionRepository @Inject constructor(
|
||||
} else {
|
||||
// calendar-specific properties
|
||||
description?.let {
|
||||
insertTag(CalendarDescription.NAME) {
|
||||
insertTag(CalDAV.CalendarDescription) {
|
||||
text(it)
|
||||
}
|
||||
}
|
||||
color?.let {
|
||||
insertTag(CalendarColor.NAME) {
|
||||
insertTag(CalDAV.CalendarColor) {
|
||||
text(DavUtils.ARGBtoCalDAVColor(it))
|
||||
}
|
||||
}
|
||||
timezoneId?.let { id ->
|
||||
insertTag(CalendarTimezoneId.NAME) {
|
||||
insertTag(CalDAV.CalendarTimezoneId) {
|
||||
text(id)
|
||||
}
|
||||
getVTimeZone(id)?.let { vTimezone ->
|
||||
insertTag(CalendarTimezone.NAME) {
|
||||
insertTag(CalDAV.CalendarTimezone) {
|
||||
text(
|
||||
// spec requires "an iCalendar object with exactly one VTIMEZONE component"
|
||||
Calendar(
|
||||
PropertyList<Property>().apply {
|
||||
add(Version.VERSION_2_0)
|
||||
add(Constants.iCalProdId)
|
||||
add(ProdId(Constants.iCalProdId))
|
||||
},
|
||||
ComponentList(
|
||||
listOf(vTimezone)
|
||||
@@ -388,19 +378,19 @@ class DavCollectionRepository @Inject constructor(
|
||||
}
|
||||
|
||||
if (!supportsVEVENT || !supportsVTODO || !supportsVJOURNAL) {
|
||||
insertTag(SupportedCalendarComponentSet.NAME) {
|
||||
insertTag(CalDAV.SupportedCalendarComponentSet) {
|
||||
// Only if there's at least one not explicitly supported calendar component set,
|
||||
// otherwise don't include the property, which means "supports everything".
|
||||
if (supportsVEVENT)
|
||||
insertTag(SupportedCalendarComponentSet.COMP) {
|
||||
insertTag(CalDAV.Comp) {
|
||||
attribute(null, "name", Component.VEVENT)
|
||||
}
|
||||
if (supportsVTODO)
|
||||
insertTag(SupportedCalendarComponentSet.COMP) {
|
||||
insertTag(CalDAV.Comp) {
|
||||
attribute(null, "name", Component.VTODO)
|
||||
}
|
||||
if (supportsVJOURNAL)
|
||||
insertTag(SupportedCalendarComponentSet.COMP) {
|
||||
insertTag(CalDAV.Comp) {
|
||||
attribute(null, "name", Component.VJOURNAL)
|
||||
}
|
||||
}
|
||||
@@ -409,9 +399,9 @@ class DavCollectionRepository @Inject constructor(
|
||||
}
|
||||
}
|
||||
if (addressBook)
|
||||
endTag(NS_WEBDAV, "mkcol")
|
||||
endTag(WebDAV.NS_WEBDAV, "mkcol")
|
||||
else
|
||||
endTag(NS_CALDAV, "mkcalendar")
|
||||
endTag(CalDAV.NS_CALDAV, "mkcalendar")
|
||||
endDocument()
|
||||
}
|
||||
return writer.toString()
|
||||
|
||||
@@ -6,4 +6,8 @@ package at.bitfire.davdroid.resource
|
||||
|
||||
import at.bitfire.vcard4android.Contact
|
||||
|
||||
interface LocalAddress: LocalResource<Contact>
|
||||
interface LocalAddress: LocalResource {
|
||||
|
||||
fun update(data: Contact, fileName: String?, eTag: String?, scheduleTag: String?, flags: Int)
|
||||
|
||||
}
|
||||
@@ -23,9 +23,9 @@ import at.bitfire.davdroid.resource.LocalAddressBook.Companion.USER_DATA_READ_ON
|
||||
import at.bitfire.davdroid.resource.workaround.ContactDirtyVerifier
|
||||
import at.bitfire.davdroid.settings.AccountSettings
|
||||
import at.bitfire.davdroid.sync.SyncDataType
|
||||
import at.bitfire.davdroid.sync.SyncFrameworkIntegration
|
||||
import at.bitfire.davdroid.sync.account.SystemAccountUtils
|
||||
import at.bitfire.davdroid.sync.account.setAndVerifyUserData
|
||||
import at.bitfire.davdroid.sync.adapter.SyncFrameworkIntegration
|
||||
import at.bitfire.synctools.storage.BatchOperation
|
||||
import at.bitfire.synctools.storage.ContactsBatchOperation
|
||||
import at.bitfire.vcard4android.AndroidAddressBook
|
||||
@@ -234,7 +234,7 @@ open class LocalAddressBook @AssistedInject constructor(
|
||||
if (syncInterval != null)
|
||||
syncFramework.enableSyncOnContentChange(addressBookAccount, ContactsContract.AUTHORITY)
|
||||
else
|
||||
syncFramework.disableSyncAbility(addressBookAccount, ContactsContract.AUTHORITY)
|
||||
syncFramework.disableSyncOnContentChange(addressBookAccount, ContactsContract.AUTHORITY)
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -78,10 +78,16 @@ class LocalAddressBookStore @Inject constructor(
|
||||
return sb.toString()
|
||||
}
|
||||
|
||||
override fun acquireContentProvider() =
|
||||
override fun acquireContentProvider(throwOnMissingPermissions: Boolean) = try {
|
||||
context.contentResolver.acquireContentProviderClient(authority)
|
||||
} catch (e: SecurityException) {
|
||||
if (throwOnMissingPermissions)
|
||||
throw e
|
||||
else
|
||||
/* return */ null
|
||||
}
|
||||
|
||||
override fun create(provider: ContentProviderClient, fromCollection: Collection): LocalAddressBook? {
|
||||
override fun create(client: ContentProviderClient, fromCollection: Collection): LocalAddressBook? {
|
||||
val service = serviceRepository.getBlocking(fromCollection.serviceId) ?: throw IllegalArgumentException("Couldn't fetch DB service from collection")
|
||||
val account = Account(service.accountName, context.getString(R.string.account_type))
|
||||
|
||||
@@ -92,7 +98,7 @@ class LocalAddressBookStore @Inject constructor(
|
||||
id = fromCollection.id
|
||||
) ?: return null
|
||||
|
||||
val addressBook = localAddressBookFactory.create(account, addressBookAccount, provider)
|
||||
val addressBook = localAddressBookFactory.create(account, addressBookAccount, client)
|
||||
|
||||
// update settings
|
||||
addressBook.updateSyncFrameworkSettings()
|
||||
@@ -119,12 +125,12 @@ class LocalAddressBookStore @Inject constructor(
|
||||
return addressBookAccount
|
||||
}
|
||||
|
||||
override fun getAll(account: Account, provider: ContentProviderClient): List<LocalAddressBook> =
|
||||
override fun getAll(account: Account, client: ContentProviderClient): List<LocalAddressBook> =
|
||||
getAddressBookAccounts(account).map { addressBookAccount ->
|
||||
localAddressBookFactory.create(account, addressBookAccount, provider)
|
||||
localAddressBookFactory.create(account, addressBookAccount, client)
|
||||
}
|
||||
|
||||
override fun update(provider: ContentProviderClient, localCollection: LocalAddressBook, fromCollection: Collection) {
|
||||
override fun update(client: ContentProviderClient, localCollection: LocalAddressBook, fromCollection: Collection) {
|
||||
var currentAccount = localCollection.addressBookAccount
|
||||
logger.log(Level.INFO, "Updating local address book $currentAccount from collection $fromCollection")
|
||||
|
||||
@@ -161,8 +167,9 @@ class LocalAddressBookStore @Inject constructor(
|
||||
*
|
||||
* @param oldAccount The old account
|
||||
* @param newAccount The new account
|
||||
* @param client content provider client (not needed/does not exist for address books)
|
||||
*/
|
||||
override fun updateAccount(oldAccount: Account, newAccount: Account) {
|
||||
override fun updateAccount(oldAccount: Account, newAccount: Account, client: ContentProviderClient?) {
|
||||
val accountManager = AccountManager.get(context)
|
||||
accountManager.getAccountsByType(context.getString(R.string.account_type_address_book))
|
||||
.filter { addressBookAccount ->
|
||||
|
||||
@@ -4,20 +4,20 @@
|
||||
|
||||
package at.bitfire.davdroid.resource
|
||||
|
||||
import android.content.ContentUris
|
||||
import android.provider.CalendarContract.Calendars
|
||||
import android.provider.CalendarContract.Events
|
||||
import androidx.annotation.VisibleForTesting
|
||||
import androidx.core.content.contentValuesOf
|
||||
import at.bitfire.ical4android.AndroidEvent
|
||||
import at.bitfire.ical4android.util.MiscUtils.asSyncAdapter
|
||||
import at.bitfire.synctools.storage.BatchOperation
|
||||
import at.bitfire.synctools.storage.calendar.AndroidCalendar
|
||||
import at.bitfire.synctools.storage.calendar.AndroidRecurringCalendar
|
||||
import at.bitfire.synctools.storage.calendar.CalendarBatchOperation
|
||||
import at.bitfire.synctools.storage.calendar.EventAndExceptions
|
||||
import at.bitfire.synctools.storage.calendar.EventsContract
|
||||
import dagger.assisted.Assisted
|
||||
import dagger.assisted.AssistedFactory
|
||||
import dagger.assisted.AssistedInject
|
||||
import java.util.LinkedList
|
||||
import java.util.logging.Level
|
||||
import java.util.logging.Logger
|
||||
|
||||
/**
|
||||
@@ -26,16 +26,18 @@ import java.util.logging.Logger
|
||||
* [Calendars._SYNC_ID] corresponds to the database collection ID ([at.bitfire.davdroid.db.Collection.id]).
|
||||
*/
|
||||
class LocalCalendar @AssistedInject constructor(
|
||||
@Assisted val androidCalendar: AndroidCalendar,
|
||||
@Assisted internal val androidCalendar: AndroidCalendar,
|
||||
private val logger: Logger
|
||||
) : LocalCollection<LocalEvent> {
|
||||
|
||||
@AssistedFactory
|
||||
interface Factory {
|
||||
fun create(androidCalendar: AndroidCalendar): LocalCalendar
|
||||
fun create(calendar: AndroidCalendar): LocalCalendar
|
||||
}
|
||||
|
||||
|
||||
// properties
|
||||
|
||||
override val dbCollectionId: Long?
|
||||
get() = androidCalendar.syncId?.toLongOrNull()
|
||||
|
||||
@@ -56,164 +58,80 @@ class LocalCalendar @AssistedInject constructor(
|
||||
androidCalendar.writeSyncState(state.toString())
|
||||
}
|
||||
|
||||
@VisibleForTesting
|
||||
internal val recurringCalendar = AndroidRecurringCalendar(androidCalendar)
|
||||
|
||||
override fun findDeleted() =
|
||||
androidCalendar
|
||||
.findEvents("${Events.DELETED} AND ${Events.ORIGINAL_ID} IS NULL", null)
|
||||
.map { LocalEvent(it) }
|
||||
|
||||
fun add(event: EventAndExceptions): Long {
|
||||
return recurringCalendar.addEventAndExceptions(event)
|
||||
}
|
||||
|
||||
override fun findDeleted(): List<LocalEvent> {
|
||||
val result = LinkedList<LocalEvent>()
|
||||
recurringCalendar.iterateEventAndExceptions(
|
||||
"${Events.DELETED} AND ${Events.ORIGINAL_ID} IS NULL", null
|
||||
) { eventAndExceptions ->
|
||||
result += LocalEvent(recurringCalendar, eventAndExceptions)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
override fun findDirty(): List<LocalEvent> {
|
||||
val dirty = LinkedList<LocalEvent>()
|
||||
|
||||
/*
|
||||
* RFC 5545 3.8.7.4. Sequence Number
|
||||
* When a calendar component is created, its sequence number is 0. It is monotonically incremented by the "Organizer's"
|
||||
* CUA each time the "Organizer" makes a significant revision to the calendar component.
|
||||
*/
|
||||
for (androidEvent in androidCalendar.findEvents("${Events.DIRTY} AND ${Events.ORIGINAL_ID} IS NULL", null)) {
|
||||
val localEvent = LocalEvent(androidEvent)
|
||||
try {
|
||||
val event = requireNotNull(androidEvent.event)
|
||||
|
||||
val nonGroupScheduled = event.attendees.isEmpty()
|
||||
val weAreOrganizer = localEvent.weAreOrganizer
|
||||
|
||||
val sequence = event.sequence
|
||||
if (sequence == null)
|
||||
// sequence has not been assigned yet (i.e. this event was just locally created)
|
||||
event.sequence = 0
|
||||
else if (nonGroupScheduled || weAreOrganizer) // increase sequence
|
||||
event.sequence = sequence + 1
|
||||
|
||||
} catch(e: Exception) {
|
||||
logger.log(Level.WARNING, "Couldn't check/increase sequence", e)
|
||||
}
|
||||
dirty += localEvent
|
||||
recurringCalendar.iterateEventAndExceptions(
|
||||
"${Events.DIRTY} AND ${Events.ORIGINAL_ID} IS NULL", null
|
||||
) { eventAndExceptions ->
|
||||
dirty += LocalEvent(recurringCalendar, eventAndExceptions)
|
||||
}
|
||||
|
||||
return dirty
|
||||
}
|
||||
|
||||
override fun findByName(name: String) =
|
||||
androidCalendar.findEvents("${Events._SYNC_ID}=?", arrayOf(name)).firstOrNull()?.let { LocalEvent(it) }
|
||||
|
||||
recurringCalendar.findEventAndExceptions("${Events._SYNC_ID}=? AND ${Events.ORIGINAL_SYNC_ID} IS null", arrayOf(name))?.let {
|
||||
LocalEvent(recurringCalendar, it)
|
||||
}
|
||||
|
||||
override fun markNotDirty(flags: Int) =
|
||||
androidCalendar.updateEvents(
|
||||
contentValuesOf(AndroidEvent.COLUMN_FLAGS to flags),
|
||||
"${Events.CALENDAR_ID}=? AND NOT ${Events.DIRTY} AND ${Events.ORIGINAL_ID} IS NULL",
|
||||
androidCalendar.updateEventRows(
|
||||
contentValuesOf(EventsContract.COLUMN_FLAGS to flags),
|
||||
// `dirty` can be 0, 1, or null. "NOT dirty" is not enough.
|
||||
"""
|
||||
${Events.CALENDAR_ID}=?
|
||||
AND (${Events.DIRTY} IS NULL OR ${Events.DIRTY}=0)
|
||||
AND ${Events.ORIGINAL_ID} IS NULL
|
||||
""".trimIndent(),
|
||||
arrayOf(androidCalendar.id.toString())
|
||||
)
|
||||
|
||||
override fun removeNotDirtyMarked(flags: Int): Int {
|
||||
// list all non-dirty events with the given flags and delete every row + its exceptions
|
||||
val batch = CalendarBatchOperation(androidCalendar.client)
|
||||
androidCalendar.iterateEvents(
|
||||
androidCalendar.iterateEventRows(
|
||||
arrayOf(Events._ID),
|
||||
"${Events.CALENDAR_ID}=? AND NOT ${Events.DIRTY} AND ${Events.ORIGINAL_ID} IS NULL AND ${AndroidEvent.COLUMN_FLAGS}=?",
|
||||
// `dirty` can be 0, 1, or null. "NOT dirty" is not enough.
|
||||
"""
|
||||
${Events.CALENDAR_ID}=?
|
||||
AND (${Events.DIRTY} IS NULL OR ${Events.DIRTY}=0)
|
||||
AND ${Events.ORIGINAL_ID} IS NULL
|
||||
AND ${EventsContract.COLUMN_FLAGS}=?
|
||||
""".trimIndent(),
|
||||
arrayOf(androidCalendar.id.toString(), flags.toString())
|
||||
) { values ->
|
||||
val id = values.getAsInteger(Events._ID)
|
||||
val id = values.getAsLong(Events._ID)
|
||||
|
||||
// delete event and possible exceptions (content provider doesn't delete exceptions itself)
|
||||
batch += BatchOperation.CpoBuilder
|
||||
.newDelete(Events.CONTENT_URI.asSyncAdapter(androidCalendar.account))
|
||||
.newDelete(androidCalendar.eventsUri)
|
||||
.withSelection("${Events._ID}=? OR ${Events.ORIGINAL_ID}=?", arrayOf(id.toString(), id.toString()))
|
||||
}
|
||||
return batch.commit()
|
||||
}
|
||||
|
||||
override fun forgetETags() {
|
||||
androidCalendar.updateEvents(
|
||||
contentValuesOf(AndroidEvent.COLUMN_ETAG to null),
|
||||
androidCalendar.updateEventRows(
|
||||
contentValuesOf(EventsContract.COLUMN_ETAG to null),
|
||||
"${Events.CALENDAR_ID}=?", arrayOf(androidCalendar.id.toString())
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
fun processDirtyExceptions() {
|
||||
// process deleted exceptions
|
||||
logger.info("Processing deleted exceptions")
|
||||
|
||||
androidCalendar.iterateEvents(
|
||||
arrayOf(Events._ID, Events.ORIGINAL_ID, AndroidEvent.COLUMN_SEQUENCE),
|
||||
"${Events.CALENDAR_ID}=? AND ${Events.DELETED} AND ${Events.ORIGINAL_ID} IS NOT NULL",
|
||||
arrayOf(androidCalendar.id.toString())
|
||||
) { values ->
|
||||
logger.fine("Found deleted exception, removing and re-scheduling original event (if available)")
|
||||
|
||||
val id = values.getAsLong(Events._ID) // can't be null (by definition)
|
||||
val originalID = values.getAsLong(Events.ORIGINAL_ID) // can't be null (by query)
|
||||
|
||||
val batch = CalendarBatchOperation(androidCalendar.client)
|
||||
|
||||
// enqueue: increase sequence of main event
|
||||
val originalEventValues = androidCalendar.getEventValues(originalID, arrayOf(AndroidEvent.COLUMN_SEQUENCE))
|
||||
val originalSequence = originalEventValues?.getAsInteger(AndroidEvent.COLUMN_SEQUENCE) ?: 0
|
||||
|
||||
batch += BatchOperation.CpoBuilder
|
||||
.newUpdate(ContentUris.withAppendedId(Events.CONTENT_URI, originalID).asSyncAdapter(androidCalendar.account))
|
||||
.withValue(AndroidEvent.COLUMN_SEQUENCE, originalSequence + 1)
|
||||
.withValue(Events.DIRTY, 1)
|
||||
|
||||
// completely remove deleted exception
|
||||
batch += BatchOperation.CpoBuilder.newDelete(ContentUris.withAppendedId(Events.CONTENT_URI, id).asSyncAdapter(androidCalendar.account))
|
||||
batch.commit()
|
||||
}
|
||||
|
||||
// process dirty exceptions
|
||||
logger.info("Processing dirty exceptions")
|
||||
androidCalendar.iterateEvents(
|
||||
arrayOf(Events._ID, Events.ORIGINAL_ID, AndroidEvent.COLUMN_SEQUENCE),
|
||||
"${Events.CALENDAR_ID}=? AND ${Events.DIRTY} AND ${Events.ORIGINAL_ID} IS NOT NULL",
|
||||
arrayOf(androidCalendar.id.toString())
|
||||
) { values ->
|
||||
logger.fine("Found dirty exception, increasing SEQUENCE to re-schedule")
|
||||
|
||||
val id = values.getAsLong(Events._ID) // can't be null (by definition)
|
||||
val originalID = values.getAsLong(Events.ORIGINAL_ID) // can't be null (by query)
|
||||
val sequence = values.getAsInteger(AndroidEvent.COLUMN_SEQUENCE) ?: 0
|
||||
|
||||
val batch = CalendarBatchOperation(androidCalendar.client)
|
||||
|
||||
// enqueue: set original event to DIRTY
|
||||
batch += BatchOperation.CpoBuilder
|
||||
.newUpdate(androidCalendar.eventUri(originalID))
|
||||
.withValue(Events.DIRTY, 1)
|
||||
|
||||
// enqueue: increase exception SEQUENCE and set DIRTY to 0
|
||||
batch += BatchOperation.CpoBuilder
|
||||
.newUpdate(androidCalendar.eventUri(id))
|
||||
.withValue(AndroidEvent.COLUMN_SEQUENCE, sequence + 1)
|
||||
.withValue(Events.DIRTY, 0)
|
||||
|
||||
batch.commit()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Marks dirty events (which are not already marked as deleted) which got no valid instances as "deleted"
|
||||
*
|
||||
* @return number of affected events
|
||||
*/
|
||||
fun deleteDirtyEventsWithoutInstances() {
|
||||
// Iterate dirty main events without exceptions
|
||||
androidCalendar.iterateEvents(
|
||||
arrayOf(Events._ID),
|
||||
"${Events.DIRTY} AND NOT ${Events.DELETED} AND ${Events.ORIGINAL_ID} IS NULL",
|
||||
null
|
||||
) { values ->
|
||||
val eventId = values.getAsLong(Events._ID)
|
||||
|
||||
// get number of instances
|
||||
val numEventInstances = androidCalendar.numInstances(eventId)
|
||||
|
||||
// delete event if there are no instances
|
||||
if (numEventInstances == 0) {
|
||||
logger.fine("Marking event #$eventId without instances as deleted")
|
||||
androidCalendar.updateEvent(eventId, contentValuesOf(Events.DELETED to 1))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -26,6 +26,7 @@ import at.bitfire.synctools.storage.calendar.AndroidCalendarProvider
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import java.util.logging.Level
|
||||
import java.util.logging.Logger
|
||||
import javax.annotation.WillNotClose
|
||||
import javax.inject.Inject
|
||||
|
||||
class LocalCalendarStore @Inject constructor(
|
||||
@@ -39,8 +40,14 @@ class LocalCalendarStore @Inject constructor(
|
||||
override val authority: String
|
||||
get() = CalendarContract.AUTHORITY
|
||||
|
||||
override fun acquireContentProvider() =
|
||||
override fun acquireContentProvider(throwOnMissingPermissions: Boolean) = try {
|
||||
context.contentResolver.acquireContentProviderClient(authority)
|
||||
} catch (e: SecurityException) {
|
||||
if (throwOnMissingPermissions)
|
||||
throw e
|
||||
else
|
||||
/* return */ null
|
||||
}
|
||||
|
||||
override fun create(client: ContentProviderClient, fromCollection: Collection): LocalCalendar? {
|
||||
val service = serviceRepository.getBlocking(fromCollection.serviceId) ?: throw IllegalArgumentException("Couldn't fetch DB service from collection")
|
||||
@@ -132,12 +139,18 @@ class LocalCalendarStore @Inject constructor(
|
||||
return values
|
||||
}
|
||||
|
||||
override fun updateAccount(oldAccount: Account, newAccount: Account) {
|
||||
val values = contentValuesOf(Calendars.ACCOUNT_NAME to newAccount.name)
|
||||
override fun updateAccount(oldAccount: Account, newAccount: Account, @WillNotClose client: ContentProviderClient?) {
|
||||
if (client == null)
|
||||
return
|
||||
val values = contentValuesOf(
|
||||
// Account name to be changed
|
||||
Calendars.ACCOUNT_NAME to newAccount.name,
|
||||
// Owner account of this calendar to be changed. Used by the calendar
|
||||
// provider to determine whether the user is ORGANIZER/ATTENDEE (usually an email address) for a certain event.
|
||||
Calendars.OWNER_ACCOUNT to newAccount.name
|
||||
)
|
||||
val uri = Calendars.CONTENT_URI.asSyncAdapter(oldAccount)
|
||||
context.contentResolver.acquireContentProviderClient(CalendarContract.AUTHORITY)?.use {
|
||||
it.update(uri, values, "${Calendars.ACCOUNT_NAME}=?", arrayOf(oldAccount.name))
|
||||
}
|
||||
client.update(uri, values, "${Calendars.ACCOUNT_NAME}=?", arrayOf(oldAccount.name))
|
||||
}
|
||||
|
||||
override fun delete(localCollection: LocalCalendar) {
|
||||
|
||||
@@ -4,7 +4,12 @@
|
||||
|
||||
package at.bitfire.davdroid.resource
|
||||
|
||||
interface LocalCollection<out T: LocalResource<*>> {
|
||||
/**
|
||||
* This is an interface between the Syncer/SyncManager and a collection in the local storage.
|
||||
*
|
||||
* It defines operations that are used during sync for all sync data types.
|
||||
*/
|
||||
interface LocalCollection<out T: LocalResource> {
|
||||
|
||||
/** a tag that uniquely identifies the collection (DAVx5-wide) */
|
||||
val tag: String
|
||||
@@ -48,10 +53,8 @@ interface LocalCollection<out T: LocalResource<*>> {
|
||||
*/
|
||||
fun findByName(name: String): T?
|
||||
|
||||
|
||||
/**
|
||||
* Sets the [LocalEvent.COLUMN_FLAGS] value for entries which are not dirty ([Events.DIRTY] is 0)
|
||||
* and have an [Events.ORIGINAL_ID] of null.
|
||||
* Updates the flags value for entries which are not dirty.
|
||||
*
|
||||
* @param flags value of flags to set (for instance, [LocalResource.FLAG_REMOTELY_PRESENT]])
|
||||
*
|
||||
@@ -60,8 +63,7 @@ interface LocalCollection<out T: LocalResource<*>> {
|
||||
fun markNotDirty(flags: Int): Int
|
||||
|
||||
/**
|
||||
* Removes entries which are not dirty ([Events.DIRTY] is 0 and an [Events.ORIGINAL_ID] is null) with
|
||||
* a given flag combination.
|
||||
* Removes entries which are not dirty with a given flag combination.
|
||||
*
|
||||
* @param flags exact flags value to remove entries with (for instance, if this is [LocalResource.FLAG_REMOTELY_PRESENT]],
|
||||
* all entries with exactly this flag will be removed)
|
||||
@@ -76,4 +78,4 @@ interface LocalCollection<out T: LocalResource<*>> {
|
||||
*/
|
||||
fun forgetETags()
|
||||
|
||||
}
|
||||
}
|
||||
@@ -4,11 +4,16 @@
|
||||
|
||||
package at.bitfire.davdroid.resource
|
||||
|
||||
import android.content.ContentUris
|
||||
import android.content.ContentValues
|
||||
import android.content.Context
|
||||
import android.net.Uri
|
||||
import android.os.RemoteException
|
||||
import android.provider.ContactsContract
|
||||
import android.provider.ContactsContract.CommonDataKinds.GroupMembership
|
||||
import android.provider.ContactsContract.RawContacts
|
||||
import android.provider.ContactsContract.RawContacts.Data
|
||||
import android.provider.ContactsContract.RawContacts.getContactLookupUri
|
||||
import androidx.core.content.contentValuesOf
|
||||
import at.bitfire.davdroid.resource.contactrow.CachedGroupMembershipHandler
|
||||
import at.bitfire.davdroid.resource.contactrow.GroupMembershipBuilder
|
||||
@@ -22,16 +27,16 @@ import at.bitfire.vcard4android.AndroidContact
|
||||
import at.bitfire.vcard4android.AndroidContactFactory
|
||||
import at.bitfire.vcard4android.CachedGroupMembership
|
||||
import at.bitfire.vcard4android.Contact
|
||||
import com.google.common.base.MoreObjects
|
||||
import java.io.FileNotFoundException
|
||||
import java.util.UUID
|
||||
import java.util.Optional
|
||||
import kotlin.jvm.optionals.getOrNull
|
||||
|
||||
class LocalContact: AndroidContact, LocalAddress {
|
||||
|
||||
companion object {
|
||||
|
||||
const val COLUMN_FLAGS = ContactsContract.RawContacts.SYNC4
|
||||
const val COLUMN_HASHCODE = ContactsContract.RawContacts.SYNC3
|
||||
const val COLUMN_FLAGS = RawContacts.SYNC4
|
||||
const val COLUMN_HASHCODE = RawContacts.SYNC3
|
||||
}
|
||||
|
||||
override val addressBook: LocalAddressBook
|
||||
@@ -40,9 +45,8 @@ class LocalContact: AndroidContact, LocalAddress {
|
||||
internal val cachedGroupMemberships = HashSet<Long>()
|
||||
internal val groupMemberships = HashSet<Long>()
|
||||
|
||||
override var scheduleTag: String?
|
||||
override val scheduleTag: String?
|
||||
get() = null
|
||||
set(_) = throw NotImplementedError()
|
||||
|
||||
override var flags: Int = 0
|
||||
|
||||
@@ -64,25 +68,6 @@ class LocalContact: AndroidContact, LocalAddress {
|
||||
}
|
||||
|
||||
|
||||
override fun prepareForUpload(): String {
|
||||
val contact = getContact()
|
||||
val uid: String = contact.uid ?: run {
|
||||
// generate new UID
|
||||
val newUid = UUID.randomUUID().toString()
|
||||
|
||||
// update in contacts provider
|
||||
val values = contentValuesOf(COLUMN_UID to newUid)
|
||||
addressBook.provider!!.update(rawContactSyncURI(), values, null, null)
|
||||
|
||||
// update this event
|
||||
contact.uid = newUid
|
||||
|
||||
newUid
|
||||
}
|
||||
|
||||
return "$uid.vcf"
|
||||
}
|
||||
|
||||
/**
|
||||
* Clears cached [contact] so that the next read of [contact] will query the content provider again.
|
||||
*/
|
||||
@@ -90,34 +75,38 @@ class LocalContact: AndroidContact, LocalAddress {
|
||||
_contact = null
|
||||
}
|
||||
|
||||
override fun clearDirty(fileName: String?, eTag: String?, scheduleTag: String?) {
|
||||
override fun clearDirty(fileName: Optional<String>, eTag: String?, scheduleTag: String?) {
|
||||
if (scheduleTag != null)
|
||||
throw IllegalArgumentException("Contacts must not have a Schedule-Tag")
|
||||
|
||||
val values = ContentValues(4)
|
||||
if (fileName != null)
|
||||
values.put(COLUMN_FILENAME, fileName)
|
||||
if (fileName.isPresent)
|
||||
values.put(COLUMN_FILENAME, fileName.get())
|
||||
values.put(COLUMN_ETAG, eTag)
|
||||
values.put(ContactsContract.RawContacts.DIRTY, 0)
|
||||
values.put(RawContacts.DIRTY, 0)
|
||||
|
||||
// Android 7 workaround
|
||||
addressBook.dirtyVerifier.getOrNull()?.setHashCodeColumn(this, values)
|
||||
|
||||
addressBook.provider!!.update(rawContactSyncURI(), values, null, null)
|
||||
|
||||
if (fileName != null)
|
||||
this.fileName = fileName
|
||||
if (fileName.isPresent)
|
||||
this.fileName = fileName.get()
|
||||
this.eTag = eTag
|
||||
}
|
||||
|
||||
override fun resetDeleted() {
|
||||
val values = contentValuesOf(ContactsContract.Groups.DELETED to 0)
|
||||
fun resetDirty() {
|
||||
val values = contentValuesOf(RawContacts.DIRTY to 0)
|
||||
addressBook.provider!!.update(rawContactSyncURI(), values, null, null)
|
||||
}
|
||||
|
||||
fun resetDirty() {
|
||||
val values = contentValuesOf(ContactsContract.RawContacts.DIRTY to 0)
|
||||
addressBook.provider!!.update(rawContactSyncURI(), values, null, null)
|
||||
override fun update(data: Contact, fileName: String?, eTag: String?, scheduleTag: String?, flags: Int) {
|
||||
this.fileName = fileName
|
||||
this.eTag = eTag
|
||||
this.flags = flags
|
||||
|
||||
// processes this.{fileName, eTag, flags} and resets DIRTY flag
|
||||
update(data)
|
||||
}
|
||||
|
||||
override fun updateFlags(flags: Int) {
|
||||
@@ -127,6 +116,46 @@ class LocalContact: AndroidContact, LocalAddress {
|
||||
this.flags = flags
|
||||
}
|
||||
|
||||
override fun updateSequence(sequence: Int) = throw NotImplementedError()
|
||||
|
||||
override fun updateUid(uid: String) {
|
||||
val values = contentValuesOf(COLUMN_UID to uid)
|
||||
addressBook.provider!!.update(rawContactSyncURI(), values, null, null)
|
||||
}
|
||||
|
||||
override fun deleteLocal() {
|
||||
delete()
|
||||
}
|
||||
|
||||
override fun resetDeleted() {
|
||||
val values = contentValuesOf(ContactsContract.Groups.DELETED to 0)
|
||||
addressBook.provider!!.update(rawContactSyncURI(), values, null, null)
|
||||
}
|
||||
|
||||
override fun getDebugSummary() =
|
||||
MoreObjects.toStringHelper(this)
|
||||
.add("id", id)
|
||||
.add("fileName", fileName)
|
||||
.add("eTag", eTag)
|
||||
.add("flags", flags)
|
||||
/*.add("contact",
|
||||
try {
|
||||
// too dangerous, may contain unknown properties and cause another OOM
|
||||
Ascii.truncate(getContact().toString(), 1000, "…")
|
||||
} catch (e: Exception) {
|
||||
e
|
||||
}
|
||||
)*/
|
||||
.toString()
|
||||
|
||||
override fun getViewUri(context: Context): Uri? =
|
||||
id?.let { idNotNull ->
|
||||
getContactLookupUri(
|
||||
context.contentResolver,
|
||||
ContentUris.withAppendedId(RawContacts.CONTENT_URI, idNotNull)
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
fun addToGroup(batch: ContactsBatchOperation, groupID: Long) {
|
||||
batch += BatchOperation.CpoBuilder
|
||||
@@ -187,6 +216,7 @@ class LocalContact: AndroidContact, LocalAddress {
|
||||
super.buildContact(builder, update)
|
||||
}
|
||||
|
||||
|
||||
// factory
|
||||
|
||||
object Factory: AndroidContactFactory<LocalContact> {
|
||||
|
||||
@@ -7,6 +7,7 @@ package at.bitfire.davdroid.resource
|
||||
import android.accounts.Account
|
||||
import android.content.ContentProviderClient
|
||||
import at.bitfire.davdroid.db.Collection
|
||||
import javax.annotation.WillNotClose
|
||||
|
||||
/**
|
||||
* Represents a local data store for a specific collection type.
|
||||
@@ -25,16 +26,19 @@ interface LocalDataStore<T: LocalCollection<*>> {
|
||||
*
|
||||
* **The caller is responsible for closing the content provider client!**
|
||||
*
|
||||
* @return the content provider client, or `null` if the content provider could not be acquired
|
||||
* @param throwOnMissingPermissions If `true`, the function will throw [SecurityException] if permissions are not granted.
|
||||
*
|
||||
* @return the content provider client, or `null` if the content provider could not be acquired (or permissions are not
|
||||
* granted and [throwOnMissingPermissions] is `false`)
|
||||
*
|
||||
* @throws SecurityException on missing permissions
|
||||
*/
|
||||
fun acquireContentProvider(): ContentProviderClient?
|
||||
fun acquireContentProvider(throwOnMissingPermissions: Boolean = false): ContentProviderClient?
|
||||
|
||||
/**
|
||||
* Creates a new local collection from the given (remote) collection info.
|
||||
*
|
||||
* @param provider the content provider client
|
||||
* @param client the content provider client
|
||||
* @param fromCollection collection info
|
||||
*
|
||||
* @return the new local collection, or `null` if creation failed
|
||||
@@ -46,7 +50,7 @@ interface LocalDataStore<T: LocalCollection<*>> {
|
||||
* [Collection] entry.
|
||||
*
|
||||
* @param account the account that the data store is associated with
|
||||
* @param provider the content provider client
|
||||
* @param client the content provider client
|
||||
*
|
||||
* @return a list of all local collections
|
||||
*/
|
||||
@@ -55,7 +59,7 @@ interface LocalDataStore<T: LocalCollection<*>> {
|
||||
/**
|
||||
* Updates the local collection with the data from the given (remote) collection info.
|
||||
*
|
||||
* @param provider the content provider client
|
||||
* @param client the content provider client
|
||||
* @param localCollection the local collection to update
|
||||
* @param fromCollection collection info
|
||||
*/
|
||||
@@ -73,7 +77,8 @@ interface LocalDataStore<T: LocalCollection<*>> {
|
||||
*
|
||||
* @param oldAccount The old account.
|
||||
* @param newAccount The new account.
|
||||
* @param client Content provider client for the local data store type or *null* when not needed for that data type.
|
||||
*/
|
||||
fun updateAccount(oldAccount: Account, newAccount: Account)
|
||||
fun updateAccount(oldAccount: Account, newAccount: Account, @WillNotClose client: ContentProviderClient?)
|
||||
|
||||
}
|
||||
@@ -4,114 +4,105 @@
|
||||
|
||||
package at.bitfire.davdroid.resource
|
||||
|
||||
import android.content.ContentValues
|
||||
import android.content.ContentUris
|
||||
import android.content.Context
|
||||
import android.provider.CalendarContract
|
||||
import android.provider.CalendarContract.Events
|
||||
import androidx.core.content.contentValuesOf
|
||||
import at.bitfire.ical4android.AndroidEvent
|
||||
import at.bitfire.ical4android.Event
|
||||
import java.util.UUID
|
||||
import at.bitfire.synctools.storage.calendar.AndroidCalendar
|
||||
import at.bitfire.synctools.storage.calendar.AndroidRecurringCalendar
|
||||
import at.bitfire.synctools.storage.calendar.EventAndExceptions
|
||||
import at.bitfire.synctools.storage.calendar.EventsContract
|
||||
import com.google.common.base.MoreObjects
|
||||
import java.util.Optional
|
||||
|
||||
class LocalEvent(
|
||||
val androidEvent: AndroidEvent
|
||||
) : LocalResource<Event> {
|
||||
val recurringCalendar: AndroidRecurringCalendar,
|
||||
val androidEvent: EventAndExceptions
|
||||
) : LocalResource {
|
||||
|
||||
// LocalResource implementation
|
||||
val calendar: AndroidCalendar
|
||||
get() = recurringCalendar.calendar
|
||||
|
||||
private val mainValues = androidEvent.main.entityValues
|
||||
|
||||
override val id: Long?
|
||||
get() = androidEvent.id
|
||||
override val id: Long
|
||||
get() = mainValues.getAsLong(Events._ID)
|
||||
|
||||
override var fileName: String?
|
||||
get() = androidEvent.syncId
|
||||
private set(value) {
|
||||
androidEvent.syncId = value
|
||||
}
|
||||
override val fileName: String?
|
||||
get() = mainValues.getAsString(Events._SYNC_ID)
|
||||
|
||||
override var eTag: String?
|
||||
get() = androidEvent.eTag
|
||||
set(value) { androidEvent.eTag = value }
|
||||
override val eTag: String?
|
||||
get() = mainValues.getAsString(EventsContract.COLUMN_ETAG)
|
||||
|
||||
override var scheduleTag: String?
|
||||
get() = androidEvent.scheduleTag
|
||||
set(value) { androidEvent.scheduleTag = value }
|
||||
override val scheduleTag: String?
|
||||
get() = mainValues.getAsString(EventsContract.COLUMN_SCHEDULE_TAG)
|
||||
|
||||
override val flags: Int
|
||||
get() = androidEvent.flags
|
||||
|
||||
override fun add() = androidEvent.add()
|
||||
|
||||
override fun update(data: Event) = androidEvent.update(data)
|
||||
|
||||
override fun delete() = androidEvent.delete()
|
||||
get() = mainValues.getAsInteger(EventsContract.COLUMN_FLAGS) ?: 0
|
||||
|
||||
|
||||
// other methods
|
||||
|
||||
val weAreOrganizer
|
||||
get() = androidEvent.event!!.isOrganizer == true
|
||||
|
||||
|
||||
/**
|
||||
* Creates and sets a new UID in the calendar provider, if no UID is already set.
|
||||
* It also returns the desired file name for the event for further processing in the sync algorithm.
|
||||
*
|
||||
* @return file name to use at upload
|
||||
*/
|
||||
override fun prepareForUpload(): String {
|
||||
// make sure that UID is set
|
||||
val uid: String = androidEvent.event!!.uid ?: run {
|
||||
// generate new UID
|
||||
val newUid = UUID.randomUUID().toString()
|
||||
|
||||
// update in calendar provider
|
||||
val values = contentValuesOf(Events.UID_2445 to newUid)
|
||||
androidEvent.update(values)
|
||||
|
||||
// update this event
|
||||
androidEvent.event?.uid = newUid
|
||||
|
||||
newUid
|
||||
}
|
||||
|
||||
val uidIsGoodFilename = uid.all { char ->
|
||||
// see RFC 2396 2.2
|
||||
char.isLetterOrDigit() || arrayOf( // allow letters and digits
|
||||
';', ':', '@', '&', '=', '+', '$', ',', // allow reserved characters except '/' and '?'
|
||||
'-', '_', '.', '!', '~', '*', '\'', '(', ')' // allow unreserved characters
|
||||
).contains(char)
|
||||
}
|
||||
return if (uidIsGoodFilename)
|
||||
"$uid.ics" // use UID as file name
|
||||
else
|
||||
"${UUID.randomUUID()}.ics" // UID would be dangerous as file name, use random UUID instead
|
||||
fun update(data: EventAndExceptions) {
|
||||
recurringCalendar.updateEventAndExceptions(id, data)
|
||||
}
|
||||
|
||||
|
||||
override fun clearDirty(fileName: String?, eTag: String?, scheduleTag: String?) {
|
||||
val values = ContentValues(5)
|
||||
if (fileName != null)
|
||||
values.put(Events._SYNC_ID, fileName)
|
||||
values.put(AndroidEvent.COLUMN_ETAG, eTag)
|
||||
values.put(AndroidEvent.COLUMN_SCHEDULE_TAG, scheduleTag)
|
||||
values.put(AndroidEvent.COLUMN_SEQUENCE, androidEvent.event!!.sequence)
|
||||
values.put(Events.DIRTY, 0)
|
||||
androidEvent.update(values)
|
||||
|
||||
if (fileName != null)
|
||||
this.fileName = fileName
|
||||
this.eTag = eTag
|
||||
this.scheduleTag = scheduleTag
|
||||
override fun clearDirty(fileName: Optional<String>, eTag: String?, scheduleTag: String?) {
|
||||
val values = contentValuesOf(
|
||||
Events.DIRTY to 0,
|
||||
EventsContract.COLUMN_ETAG to eTag,
|
||||
EventsContract.COLUMN_SCHEDULE_TAG to scheduleTag
|
||||
)
|
||||
if (fileName.isPresent)
|
||||
values.put(Events._SYNC_ID, fileName.get())
|
||||
calendar.updateEventRow(id, values)
|
||||
}
|
||||
|
||||
override fun updateFlags(flags: Int) {
|
||||
val values = contentValuesOf(AndroidEvent.COLUMN_FLAGS to flags)
|
||||
androidEvent.update(values)
|
||||
calendar.updateEventRow(id, contentValuesOf(
|
||||
EventsContract.COLUMN_FLAGS to flags
|
||||
))
|
||||
}
|
||||
|
||||
androidEvent.flags = flags
|
||||
override fun updateSequence(sequence: Int) {
|
||||
calendar.updateEventRow(id, contentValuesOf(
|
||||
EventsContract.COLUMN_SEQUENCE to sequence
|
||||
))
|
||||
}
|
||||
|
||||
override fun updateUid(uid: String) {
|
||||
calendar.updateEventRow(id, contentValuesOf(
|
||||
Events.UID_2445 to uid
|
||||
))
|
||||
}
|
||||
|
||||
override fun deleteLocal() {
|
||||
recurringCalendar.deleteEventAndExceptions(id)
|
||||
}
|
||||
|
||||
override fun resetDeleted() {
|
||||
val values = contentValuesOf(Events.DELETED to 0)
|
||||
androidEvent.update(values)
|
||||
calendar.updateEventRow(id, contentValuesOf(
|
||||
Events.DELETED to 0
|
||||
))
|
||||
}
|
||||
|
||||
override fun getDebugSummary() =
|
||||
MoreObjects.toStringHelper(this)
|
||||
.add("id", id)
|
||||
.add("fileName", fileName)
|
||||
.add("eTag", eTag)
|
||||
.add("scheduleTag", scheduleTag)
|
||||
.add("flags", flags)
|
||||
.add("event",
|
||||
try {
|
||||
// only include truncated main event row (won't contain attachments, unknown properties etc.)
|
||||
androidEvent.main.entityValues.toString().take(1000)
|
||||
} catch (e: Exception) {
|
||||
e
|
||||
}
|
||||
).toString()
|
||||
|
||||
override fun getViewUri(context: Context) =
|
||||
ContentUris.withAppendedId(CalendarContract.Events.CONTENT_URI, id)
|
||||
|
||||
}
|
||||
@@ -6,6 +6,7 @@ package at.bitfire.davdroid.resource
|
||||
|
||||
import android.content.ContentUris
|
||||
import android.content.ContentValues
|
||||
import android.content.Context
|
||||
import android.net.Uri
|
||||
import android.os.RemoteException
|
||||
import android.provider.ContactsContract
|
||||
@@ -15,7 +16,6 @@ import android.provider.ContactsContract.RawContacts
|
||||
import android.provider.ContactsContract.RawContacts.Data
|
||||
import androidx.core.content.contentValuesOf
|
||||
import at.bitfire.davdroid.resource.LocalGroup.Companion.COLUMN_PENDING_MEMBERS
|
||||
import at.bitfire.davdroid.util.trimToNull
|
||||
import at.bitfire.synctools.storage.BatchOperation
|
||||
import at.bitfire.synctools.storage.ContactsBatchOperation
|
||||
import at.bitfire.vcard4android.AndroidAddressBook
|
||||
@@ -24,8 +24,9 @@ import at.bitfire.vcard4android.AndroidGroup
|
||||
import at.bitfire.vcard4android.AndroidGroupFactory
|
||||
import at.bitfire.vcard4android.CachedGroupMembership
|
||||
import at.bitfire.vcard4android.Contact
|
||||
import com.google.common.base.MoreObjects
|
||||
import java.util.LinkedList
|
||||
import java.util.UUID
|
||||
import java.util.Optional
|
||||
import java.util.logging.Logger
|
||||
import kotlin.jvm.optionals.getOrNull
|
||||
|
||||
@@ -111,7 +112,7 @@ class LocalGroup: AndroidGroup, LocalAddress {
|
||||
|
||||
override var scheduleTag: String?
|
||||
get() = null
|
||||
set(value) = throw NotImplementedError()
|
||||
set(_) = throw NotImplementedError()
|
||||
|
||||
override var flags: Int = 0
|
||||
|
||||
@@ -139,40 +140,20 @@ class LocalGroup: AndroidGroup, LocalAddress {
|
||||
}
|
||||
|
||||
|
||||
override fun prepareForUpload(): String {
|
||||
var uid: String? = null
|
||||
addressBook.provider!!.query(groupSyncUri(), arrayOf(AndroidContact.COLUMN_UID), null, null, null)?.use { cursor ->
|
||||
if (cursor.moveToNext())
|
||||
uid = cursor.getString(0).trimToNull()
|
||||
}
|
||||
|
||||
if (uid == null) {
|
||||
// generate new UID
|
||||
uid = UUID.randomUUID().toString()
|
||||
|
||||
val values = contentValuesOf(AndroidContact.COLUMN_UID to uid)
|
||||
addressBook.provider!!.update(groupSyncUri(), values, null, null)
|
||||
|
||||
_contact?.uid = uid
|
||||
}
|
||||
|
||||
return "$uid.vcf"
|
||||
}
|
||||
|
||||
override fun clearDirty(fileName: String?, eTag: String?, scheduleTag: String?) {
|
||||
override fun clearDirty(fileName: Optional<String>, eTag: String?, scheduleTag: String?) {
|
||||
if (scheduleTag != null)
|
||||
throw IllegalArgumentException("Contact groups must not have a Schedule-Tag")
|
||||
val id = requireNotNull(id)
|
||||
|
||||
val values = ContentValues(3)
|
||||
if (fileName != null)
|
||||
values.put(COLUMN_FILENAME, fileName)
|
||||
if (fileName.isPresent)
|
||||
values.put(COLUMN_FILENAME, fileName.get())
|
||||
values.putNull(COLUMN_ETAG) // don't save changed ETag but null, so that the group is downloaded again, so that pendingMembers is updated
|
||||
values.put(Groups.DIRTY, 0)
|
||||
update(values)
|
||||
|
||||
if (fileName != null)
|
||||
this.fileName = fileName
|
||||
if (fileName.isPresent)
|
||||
this.fileName = fileName.get()
|
||||
this.eTag = null
|
||||
|
||||
// update cached group memberships
|
||||
@@ -211,9 +192,12 @@ class LocalGroup: AndroidGroup, LocalAddress {
|
||||
batch.commit()
|
||||
}
|
||||
|
||||
override fun resetDeleted() {
|
||||
val values = contentValuesOf(Groups.DELETED to 0)
|
||||
addressBook.provider!!.update(groupSyncUri(), values, null, null)
|
||||
override fun update(data: Contact, fileName: String?, eTag: String?, scheduleTag: String?, flags: Int) {
|
||||
this.fileName = fileName
|
||||
this.eTag = eTag
|
||||
|
||||
// processes this.{fileName, eTag, flags} and resets DIRTY flag
|
||||
update(data)
|
||||
}
|
||||
|
||||
override fun updateFlags(flags: Int) {
|
||||
@@ -223,6 +207,38 @@ class LocalGroup: AndroidGroup, LocalAddress {
|
||||
this.flags = flags
|
||||
}
|
||||
|
||||
override fun updateSequence(sequence: Int) = throw NotImplementedError()
|
||||
|
||||
override fun updateUid(uid: String) {
|
||||
val values = contentValuesOf(AndroidContact.COLUMN_UID to uid)
|
||||
addressBook.provider!!.update(groupSyncUri(), values, null, null)
|
||||
}
|
||||
|
||||
override fun deleteLocal() {
|
||||
delete()
|
||||
}
|
||||
|
||||
override fun resetDeleted() {
|
||||
val values = contentValuesOf(Groups.DELETED to 0)
|
||||
addressBook.provider!!.update(groupSyncUri(), values, null, null)
|
||||
}
|
||||
|
||||
override fun getDebugSummary() =
|
||||
MoreObjects.toStringHelper(this)
|
||||
.add("id", id)
|
||||
.add("fileName", fileName)
|
||||
.add("eTag", eTag)
|
||||
.add("flags", flags)
|
||||
.add("contact",
|
||||
try {
|
||||
getContact().toString()
|
||||
} catch (e: Exception) {
|
||||
e
|
||||
}
|
||||
).toString()
|
||||
|
||||
override fun getViewUri(context: Context) = null
|
||||
|
||||
|
||||
// helpers
|
||||
|
||||
|
||||
@@ -18,11 +18,11 @@ import at.bitfire.davdroid.repository.PrincipalRepository
|
||||
import at.bitfire.davdroid.settings.AccountSettings
|
||||
import at.bitfire.davdroid.util.DavUtils.lastSegment
|
||||
import at.bitfire.ical4android.JtxCollection
|
||||
import at.bitfire.ical4android.TaskProvider
|
||||
import at.techbee.jtx.JtxContract
|
||||
import at.techbee.jtx.JtxContract.asSyncAdapter
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import java.util.logging.Logger
|
||||
import javax.annotation.WillNotClose
|
||||
import javax.inject.Inject
|
||||
|
||||
class LocalJtxCollectionStore @Inject constructor(
|
||||
@@ -37,10 +37,16 @@ class LocalJtxCollectionStore @Inject constructor(
|
||||
override val authority: String
|
||||
get() = JtxContract.AUTHORITY
|
||||
|
||||
override fun acquireContentProvider() =
|
||||
context.contentResolver.acquireContentProviderClient(JtxContract.AUTHORITY)
|
||||
override fun acquireContentProvider(throwOnMissingPermissions: Boolean) = try {
|
||||
context.contentResolver.acquireContentProviderClient(authority)
|
||||
} catch (e: SecurityException) {
|
||||
if (throwOnMissingPermissions)
|
||||
throw e
|
||||
else
|
||||
/* return */ null
|
||||
}
|
||||
|
||||
override fun create(provider: ContentProviderClient, fromCollection: Collection): LocalJtxCollection? {
|
||||
override fun create(client: ContentProviderClient, fromCollection: Collection): LocalJtxCollection {
|
||||
val service = serviceDao.get(fromCollection.serviceId) ?: throw IllegalArgumentException("Couldn't fetch DB service from collection")
|
||||
val account = Account(service.accountName, context.getString(R.string.account_type))
|
||||
|
||||
@@ -57,8 +63,8 @@ class LocalJtxCollectionStore @Inject constructor(
|
||||
withColor = true
|
||||
)
|
||||
|
||||
val uri = JtxCollection.create(account, provider, values)
|
||||
return LocalJtxCollection(account, provider, ContentUris.parseId(uri))
|
||||
val uri = JtxCollection.create(account, client, values)
|
||||
return LocalJtxCollection(account, client, ContentUris.parseId(uri))
|
||||
}
|
||||
|
||||
private fun valuesFromCollection(info: Collection, account: Account, withColor: Boolean): ContentValues {
|
||||
@@ -88,21 +94,21 @@ class LocalJtxCollectionStore @Inject constructor(
|
||||
}
|
||||
}
|
||||
|
||||
override fun getAll(account: Account, provider: ContentProviderClient): List<LocalJtxCollection> =
|
||||
JtxCollection.find(account, provider, context, LocalJtxCollection.Factory, null, null)
|
||||
override fun getAll(account: Account, client: ContentProviderClient): List<LocalJtxCollection> =
|
||||
JtxCollection.find(account, client, context, LocalJtxCollection.Factory, null, null)
|
||||
|
||||
override fun update(provider: ContentProviderClient, localCollection: LocalJtxCollection, fromCollection: Collection) {
|
||||
override fun update(client: ContentProviderClient, localCollection: LocalJtxCollection, fromCollection: Collection) {
|
||||
val accountSettings = accountSettingsFactory.create(localCollection.account)
|
||||
val values = valuesFromCollection(fromCollection, account = localCollection.account, withColor = accountSettings.getManageCalendarColors())
|
||||
localCollection.update(values)
|
||||
}
|
||||
|
||||
override fun updateAccount(oldAccount: Account, newAccount: Account) {
|
||||
TaskProvider.acquire(context, TaskProvider.ProviderName.JtxBoard)?.use { provider ->
|
||||
val values = contentValuesOf(JtxContract.JtxCollection.ACCOUNT_NAME to newAccount.name)
|
||||
val uri = JtxContract.JtxCollection.CONTENT_URI.asSyncAdapter(oldAccount)
|
||||
provider.client.update(uri, values, "${JtxContract.JtxCollection.ACCOUNT_NAME}=?", arrayOf(oldAccount.name))
|
||||
}
|
||||
override fun updateAccount(oldAccount: Account, newAccount: Account, @WillNotClose client: ContentProviderClient?) {
|
||||
if (client == null)
|
||||
return
|
||||
val values = contentValuesOf(JtxContract.JtxCollection.ACCOUNT_NAME to newAccount.name)
|
||||
val uri = JtxContract.JtxCollection.CONTENT_URI.asSyncAdapter(oldAccount)
|
||||
client.update(uri, values, "${JtxContract.JtxCollection.ACCOUNT_NAME}=?", arrayOf(oldAccount.name))
|
||||
}
|
||||
|
||||
override fun delete(localCollection: LocalJtxCollection) {
|
||||
|
||||
@@ -5,21 +5,26 @@
|
||||
package at.bitfire.davdroid.resource
|
||||
|
||||
import android.content.ContentValues
|
||||
import android.content.Context
|
||||
import at.bitfire.ical4android.JtxCollection
|
||||
import at.bitfire.ical4android.JtxICalObject
|
||||
import at.bitfire.ical4android.JtxICalObjectFactory
|
||||
import at.techbee.jtx.JtxContract
|
||||
import at.techbee.jtx.JtxContract.JtxICalObject.getViewIntentUriFor
|
||||
import com.google.common.base.MoreObjects
|
||||
import java.util.Optional
|
||||
import kotlin.jvm.optionals.getOrNull
|
||||
|
||||
/**
|
||||
* Represents a Journal, Note or Task entry
|
||||
*/
|
||||
class LocalJtxICalObject(
|
||||
collection: JtxCollection<*>,
|
||||
fileName: String?,
|
||||
eTag: String?,
|
||||
scheduleTag: String?,
|
||||
flags: Int
|
||||
) :
|
||||
JtxICalObject(collection),
|
||||
LocalResource<JtxICalObject> {
|
||||
|
||||
) : JtxICalObject(collection), LocalResource {
|
||||
|
||||
init {
|
||||
this.fileName = fileName
|
||||
@@ -48,8 +53,41 @@ class LocalJtxICalObject(
|
||||
|
||||
}
|
||||
|
||||
fun update(data: JtxICalObject, fileName: String?, eTag: String?, scheduleTag: String?, flags: Int) {
|
||||
this.fileName = fileName
|
||||
this.eTag = eTag
|
||||
this.scheduleTag = scheduleTag
|
||||
this.flags = flags
|
||||
|
||||
// processes this.{fileName, eTag, scheduleTag, flags} and resets DIRTY flag
|
||||
update(data)
|
||||
}
|
||||
|
||||
override fun updateSequence(sequence: Int) = throw NotImplementedError()
|
||||
|
||||
override fun updateUid(uid: String) = throw NotImplementedError()
|
||||
|
||||
override fun clearDirty(fileName: Optional<String>, eTag: String?, scheduleTag: String?) {
|
||||
clearDirty(fileName.getOrNull(), eTag, scheduleTag)
|
||||
}
|
||||
|
||||
override fun deleteLocal() {
|
||||
delete()
|
||||
}
|
||||
|
||||
override fun resetDeleted() {
|
||||
throw NotImplementedError()
|
||||
}
|
||||
|
||||
override fun getDebugSummary() =
|
||||
MoreObjects.toStringHelper(this)
|
||||
.add("id", id)
|
||||
.add("fileName", fileName)
|
||||
.add("eTag", eTag)
|
||||
.add("scheduleTag", scheduleTag)
|
||||
.add("flags", flags)
|
||||
.toString()
|
||||
|
||||
override fun getViewUri(context: Context) = getViewIntentUriFor(id)
|
||||
|
||||
}
|
||||
@@ -4,9 +4,18 @@
|
||||
|
||||
package at.bitfire.davdroid.resource
|
||||
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import at.bitfire.davdroid.resource.LocalResource.Companion.FLAG_REMOTELY_PRESENT
|
||||
import java.util.Optional
|
||||
|
||||
interface LocalResource<in TData: Any> {
|
||||
/**
|
||||
* This is an interface between the SyncManager and a resource in the local storage.
|
||||
*
|
||||
* It defines operations that are used by SyncManager for all sync data types.
|
||||
*/
|
||||
interface LocalResource {
|
||||
|
||||
companion object {
|
||||
/**
|
||||
@@ -32,67 +41,70 @@ interface LocalResource<in TData: Any> {
|
||||
val fileName: String?
|
||||
|
||||
/** remote ETag for the resource */
|
||||
var eTag: String?
|
||||
val eTag: String?
|
||||
|
||||
/** remote Schedule-Tag for the resource */
|
||||
var scheduleTag: String?
|
||||
val scheduleTag: String?
|
||||
|
||||
/** bitfield of flags; currently either [FLAG_REMOTELY_PRESENT] or 0 */
|
||||
val flags: Int
|
||||
|
||||
/**
|
||||
* Prepares the resource for uploading:
|
||||
* Unsets the _dirty_ field of the resource and updates other sync-related fields in the content provider.
|
||||
* Does not affect `this` object itself (which is immutable).
|
||||
*
|
||||
* 1. If the resource doesn't have an UID yet, this method generates one and writes it to the content provider.
|
||||
* 2. The new file name which can be used for the upload is derived from the UID and returned, but not
|
||||
* saved to the content provider. The sync manager is responsible for saving the file name that
|
||||
* was actually used.
|
||||
*
|
||||
* @return new file name of the resource (like "<uid>.vcf")
|
||||
* @param fileName If this optional argument is present, [LocalResource.fileName] will be set to its value.
|
||||
* @param eTag ETag of the uploaded resource as returned by the server (null if the server didn't return one)
|
||||
* @param scheduleTag CalDAV only: `Schedule-Tag` of the uploaded resource as returned by the server
|
||||
* (null if not applicable or if the server didn't return one)
|
||||
*/
|
||||
fun prepareForUpload(): String
|
||||
fun clearDirty(fileName: Optional<String>, eTag: String?, scheduleTag: String? = null)
|
||||
|
||||
/**
|
||||
* Unsets the /dirty/ field of the resource. Typically used after successfully uploading a
|
||||
* locally modified resource.
|
||||
* Sets (local) flags of the resource in the content provider.
|
||||
* Does not affect `this` object itself (which is immutable).
|
||||
*
|
||||
* @param fileName If this argument is not *null*, [LocalResource.fileName] will be set to its value.
|
||||
* @param eTag ETag of the uploaded resource as returned by the server (null if the server didn't return one)
|
||||
* @param scheduleTag CalDAV Schedule-Tag of the uploaded resource as returned by the server (null if not applicable or if the server didn't return one)
|
||||
*/
|
||||
fun clearDirty(fileName: String?, eTag: String?, scheduleTag: String? = null)
|
||||
|
||||
/**
|
||||
* Sets (local) flags of the resource. At the moment, the only allowed values are
|
||||
* 0 and [FLAG_REMOTELY_PRESENT].
|
||||
* At the moment, the only allowed values are 0 and [FLAG_REMOTELY_PRESENT].
|
||||
*/
|
||||
fun updateFlags(flags: Int)
|
||||
|
||||
/**
|
||||
* Updates the local UID of the resource in the content provider.
|
||||
* Usually used to persist a UID that has been created during an upload of a locally created resource.
|
||||
*/
|
||||
fun updateUid(uid: String)
|
||||
|
||||
/**
|
||||
* Adds the data object to the content provider and ensures that the dirty flag is clear.
|
||||
* Updates the local SEQUENCE of the resource in the content provider.
|
||||
*
|
||||
* @return content URI of the created row (e.g. event URI)
|
||||
* @throws NotImplementedError if SEQUENCE update is not supported
|
||||
*/
|
||||
fun add(): Uri
|
||||
|
||||
/**
|
||||
* Updates the data object in the content provider and ensures that the dirty flag is clear.
|
||||
*
|
||||
* @return content URI of the updated row (e.g. event URI)
|
||||
*/
|
||||
fun update(data: TData): Uri
|
||||
fun updateSequence(sequence: Int)
|
||||
|
||||
/**
|
||||
* Deletes the data object from the content provider.
|
||||
*
|
||||
* @return number of affected rows
|
||||
*/
|
||||
fun delete(): Int
|
||||
fun deleteLocal()
|
||||
|
||||
/**
|
||||
* Undoes deletion of the data object from the content provider.
|
||||
*/
|
||||
fun resetDeleted()
|
||||
|
||||
/**
|
||||
* User-readable debug summary of this local resource (used in debug info)
|
||||
*/
|
||||
fun getDebugSummary(): String
|
||||
|
||||
/**
|
||||
* Returns the content provider URI that opens the local resource for viewing ([Intent.ACTION_VIEW])
|
||||
* in its respective app.
|
||||
*
|
||||
* For instance, in case of a local raw contact, this method could return the content provider URI
|
||||
* that identifies the corresponding contact.
|
||||
*
|
||||
* @return content provider URI, or `null` if not available
|
||||
*/
|
||||
fun getViewUri(context: Context): Uri?
|
||||
|
||||
}
|
||||
@@ -4,111 +4,122 @@
|
||||
|
||||
package at.bitfire.davdroid.resource
|
||||
|
||||
import android.content.ContentUris
|
||||
import android.content.ContentValues
|
||||
import android.content.Context
|
||||
import android.net.Uri
|
||||
import androidx.core.content.contentValuesOf
|
||||
import at.bitfire.ical4android.DmfsTask
|
||||
import at.bitfire.ical4android.DmfsTaskFactory
|
||||
import at.bitfire.ical4android.DmfsTaskList
|
||||
import at.bitfire.ical4android.Task
|
||||
import at.bitfire.synctools.storage.BatchOperation
|
||||
import at.bitfire.ical4android.TaskProvider
|
||||
import com.google.common.base.MoreObjects
|
||||
import org.dmfs.tasks.contract.TaskContract.Tasks
|
||||
import java.util.UUID
|
||||
import java.util.Optional
|
||||
import java.util.logging.Logger
|
||||
|
||||
class LocalTask: DmfsTask, LocalResource<Task> {
|
||||
/**
|
||||
* Represents a Dmfs Task (OpenTasks and Tasks.org) entry
|
||||
*/
|
||||
class LocalTask(
|
||||
val dmfsTask: DmfsTask
|
||||
): LocalResource {
|
||||
|
||||
companion object {
|
||||
const val COLUMN_ETAG = Tasks.SYNC1
|
||||
const val COLUMN_FLAGS = Tasks.SYNC2
|
||||
}
|
||||
val logger: Logger = Logger.getLogger(javaClass.name)
|
||||
|
||||
override var fileName: String? = null
|
||||
|
||||
// LocalResource implementation
|
||||
|
||||
override val id: Long?
|
||||
get() = dmfsTask.id
|
||||
|
||||
override var fileName: String?
|
||||
get() = dmfsTask.syncId
|
||||
set(value) { dmfsTask.syncId = value }
|
||||
|
||||
override var eTag: String?
|
||||
get() = dmfsTask.eTag
|
||||
set(value) { dmfsTask.eTag = value }
|
||||
|
||||
/**
|
||||
* Note: Schedule-Tag for tasks is not supported
|
||||
*/
|
||||
override var scheduleTag: String? = null
|
||||
override var eTag: String? = null
|
||||
|
||||
override var flags = 0
|
||||
private set
|
||||
override val flags: Int
|
||||
get() = dmfsTask.flags
|
||||
|
||||
fun add() = dmfsTask.add()
|
||||
|
||||
constructor(taskList: DmfsTaskList<*>, task: Task, fileName: String?, eTag: String?, flags: Int)
|
||||
: super(taskList, task) {
|
||||
this.fileName = fileName
|
||||
this.eTag = eTag
|
||||
this.flags = flags
|
||||
}
|
||||
fun update(data: Task) = dmfsTask.update(data)
|
||||
|
||||
private constructor(taskList: DmfsTaskList<*>, values: ContentValues): super(taskList) {
|
||||
id = values.getAsLong(Tasks._ID)
|
||||
fileName = values.getAsString(Tasks._SYNC_ID)
|
||||
eTag = values.getAsString(COLUMN_ETAG)
|
||||
flags = values.getAsInteger(COLUMN_FLAGS) ?: 0
|
||||
}
|
||||
fun delete() = dmfsTask.delete()
|
||||
|
||||
|
||||
/* process LocalTask-specific fields */
|
||||
|
||||
override fun buildTask(builder: BatchOperation.CpoBuilder, update: Boolean) {
|
||||
super.buildTask(builder, update)
|
||||
|
||||
builder .withValue(Tasks._SYNC_ID, fileName)
|
||||
.withValue(COLUMN_ETAG, eTag)
|
||||
.withValue(COLUMN_FLAGS, flags)
|
||||
}
|
||||
|
||||
|
||||
/* custom queries */
|
||||
|
||||
override fun prepareForUpload(): String {
|
||||
val uid: String = task!!.uid ?: run {
|
||||
// generate new UID
|
||||
val newUid = UUID.randomUUID().toString()
|
||||
|
||||
// update in tasks provider
|
||||
val values = contentValuesOf(Tasks._UID to newUid)
|
||||
taskList.provider.update(taskSyncURI(), values, null, null)
|
||||
|
||||
// update this task
|
||||
task!!.uid = newUid
|
||||
|
||||
newUid
|
||||
}
|
||||
|
||||
return "$uid.ics"
|
||||
}
|
||||
|
||||
override fun clearDirty(fileName: String?, eTag: String?, scheduleTag: String?) {
|
||||
override fun clearDirty(fileName: Optional<String>, eTag: String?, scheduleTag: String?) {
|
||||
if (scheduleTag != null)
|
||||
logger.fine("Schedule-Tag for tasks not supported yet, won't save")
|
||||
logger.fine("Schedule-Tag for tasks not supported, won't save")
|
||||
|
||||
val values = ContentValues(4)
|
||||
if (fileName != null)
|
||||
values.put(Tasks._SYNC_ID, fileName)
|
||||
values.put(COLUMN_ETAG, eTag)
|
||||
values.put(Tasks.SYNC_VERSION, task!!.sequence)
|
||||
if (fileName.isPresent)
|
||||
values.put(Tasks._SYNC_ID, fileName.get())
|
||||
values.put(DmfsTask.COLUMN_ETAG, eTag)
|
||||
values.put(Tasks.SYNC_VERSION, dmfsTask.task!!.sequence)
|
||||
values.put(Tasks._DIRTY, 0)
|
||||
taskList.provider.update(taskSyncURI(), values, null, null)
|
||||
dmfsTask.update(values)
|
||||
|
||||
if (fileName != null)
|
||||
this.fileName = fileName
|
||||
if (fileName.isPresent)
|
||||
this.fileName = fileName.get()
|
||||
this.eTag = eTag
|
||||
}
|
||||
|
||||
override fun updateFlags(flags: Int) {
|
||||
if (id != null) {
|
||||
val values = contentValuesOf(COLUMN_FLAGS to flags)
|
||||
taskList.provider.update(taskSyncURI(), values, null, null)
|
||||
val values = contentValuesOf(DmfsTask.COLUMN_FLAGS to flags)
|
||||
dmfsTask.update(values)
|
||||
}
|
||||
dmfsTask.flags = flags
|
||||
}
|
||||
|
||||
this.flags = flags
|
||||
override fun updateSequence(sequence: Int) = throw NotImplementedError()
|
||||
|
||||
override fun updateUid(uid: String) {
|
||||
val values = contentValuesOf(Tasks._UID to uid)
|
||||
dmfsTask.update(values)
|
||||
}
|
||||
|
||||
override fun deleteLocal() {
|
||||
dmfsTask.delete()
|
||||
}
|
||||
|
||||
override fun resetDeleted() {
|
||||
throw NotImplementedError()
|
||||
}
|
||||
|
||||
override fun getDebugSummary() =
|
||||
MoreObjects.toStringHelper(this)
|
||||
.add("id", id)
|
||||
.add("fileName", fileName)
|
||||
.add("eTag", eTag)
|
||||
.add("flags", flags)
|
||||
/*.add("task",
|
||||
try {
|
||||
// too dangerous, may contain unknown properties and cause another OOM
|
||||
Ascii.truncate(task.toString(), 1000, "…")
|
||||
} catch (e: Exception) {
|
||||
e
|
||||
}
|
||||
)*/
|
||||
.toString()
|
||||
|
||||
object Factory: DmfsTaskFactory<LocalTask> {
|
||||
override fun fromProvider(taskList: DmfsTaskList<*>, values: ContentValues) =
|
||||
LocalTask(taskList, values)
|
||||
override fun getViewUri(context: Context): Uri? = id?.let { id ->
|
||||
when (dmfsTask.taskList.providerName) {
|
||||
TaskProvider.ProviderName.OpenTasks -> {
|
||||
val contentUri = Tasks.getContentUri(dmfsTask.taskList.providerName.authority)
|
||||
ContentUris.withAppendedId(contentUri, id)
|
||||
}
|
||||
// Tasks.org can't handle view content URIs (missing intent-filter)
|
||||
// Jtx Board tasks are [LocalJtxICalObject]s
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -4,15 +4,10 @@
|
||||
|
||||
package at.bitfire.davdroid.resource
|
||||
|
||||
import android.accounts.Account
|
||||
import android.content.ContentProviderClient
|
||||
import android.content.ContentValues
|
||||
import androidx.core.content.contentValuesOf
|
||||
import at.bitfire.ical4android.DmfsTask
|
||||
import at.bitfire.ical4android.DmfsTaskList
|
||||
import at.bitfire.ical4android.DmfsTaskListFactory
|
||||
import at.bitfire.ical4android.TaskProvider
|
||||
import org.dmfs.tasks.contract.TaskContract.TaskListColumns
|
||||
import org.dmfs.tasks.contract.TaskContract.TaskLists
|
||||
import org.dmfs.tasks.contract.TaskContract.Tasks
|
||||
import java.util.logging.Level
|
||||
import java.util.logging.Logger
|
||||
@@ -22,62 +17,38 @@ import java.util.logging.Logger
|
||||
*
|
||||
* [TaskLists._SYNC_ID] corresponds to the database collection ID ([at.bitfire.davdroid.db.Collection.id]).
|
||||
*/
|
||||
class LocalTaskList private constructor(
|
||||
account: Account,
|
||||
provider: ContentProviderClient,
|
||||
providerName: TaskProvider.ProviderName,
|
||||
id: Long
|
||||
): DmfsTaskList<LocalTask>(account, provider, providerName, LocalTask.Factory, id), LocalCollection<LocalTask> {
|
||||
class LocalTaskList (
|
||||
val dmfsTaskList: DmfsTaskList
|
||||
): LocalCollection<LocalTask> {
|
||||
|
||||
private val logger = Logger.getGlobal()
|
||||
|
||||
private var accessLevel: Int = TaskListColumns.ACCESS_LEVEL_UNDEFINED
|
||||
override val readOnly
|
||||
get() =
|
||||
accessLevel != TaskListColumns.ACCESS_LEVEL_UNDEFINED &&
|
||||
accessLevel <= TaskListColumns.ACCESS_LEVEL_READ
|
||||
get() = dmfsTaskList.accessLevel?.let {
|
||||
it != TaskListColumns.ACCESS_LEVEL_UNDEFINED && it <= TaskListColumns.ACCESS_LEVEL_READ
|
||||
} ?: false
|
||||
|
||||
override val dbCollectionId: Long?
|
||||
get() = syncId?.toLongOrNull()
|
||||
get() = dmfsTaskList.syncId?.toLongOrNull()
|
||||
|
||||
override val tag: String
|
||||
get() = "tasks-${account.name}-$id"
|
||||
get() = "tasks-${dmfsTaskList.account.name}-${dmfsTaskList.id}"
|
||||
|
||||
override val title: String
|
||||
get() = name ?: id.toString()
|
||||
get() = dmfsTaskList.name ?: dmfsTaskList.id.toString()
|
||||
|
||||
override var lastSyncState: SyncState?
|
||||
get() {
|
||||
try {
|
||||
provider.query(taskListSyncUri(), arrayOf(TaskLists.SYNC_VERSION),
|
||||
null, null, null)?.use { cursor ->
|
||||
if (cursor.moveToNext())
|
||||
cursor.getString(0)?.let {
|
||||
return SyncState.fromString(it)
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
logger.log(Level.WARNING, "Couldn't read sync state", e)
|
||||
}
|
||||
return null
|
||||
}
|
||||
get() = dmfsTaskList.readSyncState()?.let { SyncState.fromString(it) }
|
||||
set(state) {
|
||||
val values = contentValuesOf(TaskLists.SYNC_VERSION to state?.toString())
|
||||
provider.update(taskListSyncUri(), values, null, null)
|
||||
dmfsTaskList.writeSyncState(state.toString())
|
||||
}
|
||||
|
||||
|
||||
override fun populate(values: ContentValues) {
|
||||
super.populate(values)
|
||||
accessLevel = values.getAsInteger(TaskListColumns.ACCESS_LEVEL)
|
||||
}
|
||||
|
||||
|
||||
override fun findDeleted() = queryTasks(Tasks._DELETED, null)
|
||||
override fun findDeleted() = dmfsTaskList.queryTasks(Tasks._DELETED, null)
|
||||
.map { LocalTask(it) }
|
||||
|
||||
override fun findDirty(): List<LocalTask> {
|
||||
val tasks = queryTasks(Tasks._DIRTY, null)
|
||||
for (localTask in tasks) {
|
||||
val dmfsTasks = dmfsTaskList.queryTasks(Tasks._DIRTY, null)
|
||||
for (localTask in dmfsTasks) {
|
||||
try {
|
||||
val task = requireNotNull(localTask.task)
|
||||
val sequence = task.sequence
|
||||
@@ -89,41 +60,32 @@ class LocalTaskList private constructor(
|
||||
logger.log(Level.WARNING, "Couldn't check/increase sequence", e)
|
||||
}
|
||||
}
|
||||
return tasks
|
||||
return dmfsTasks.map { LocalTask(it) }
|
||||
}
|
||||
|
||||
override fun findByName(name: String) =
|
||||
queryTasks("${Tasks._SYNC_ID}=?", arrayOf(name)).firstOrNull()
|
||||
dmfsTaskList.queryTasks("${Tasks._SYNC_ID}=?", arrayOf(name))
|
||||
.firstOrNull()?.let {
|
||||
LocalTask(it)
|
||||
}
|
||||
|
||||
|
||||
override fun markNotDirty(flags: Int): Int {
|
||||
val values = contentValuesOf(LocalTask.COLUMN_FLAGS to flags)
|
||||
return provider.update(tasksSyncUri(), values,
|
||||
val values = contentValuesOf(DmfsTask.COLUMN_FLAGS to flags)
|
||||
return dmfsTaskList.provider.update(dmfsTaskList.tasksSyncUri(), values,
|
||||
"${Tasks.LIST_ID}=? AND ${Tasks._DIRTY}=0",
|
||||
arrayOf(id.toString()))
|
||||
arrayOf(dmfsTaskList.id.toString()))
|
||||
}
|
||||
|
||||
override fun removeNotDirtyMarked(flags: Int) =
|
||||
provider.delete(tasksSyncUri(),
|
||||
"${Tasks.LIST_ID}=? AND NOT ${Tasks._DIRTY} AND ${LocalTask.COLUMN_FLAGS}=?",
|
||||
arrayOf(id.toString(), flags.toString()))
|
||||
dmfsTaskList.provider.delete(dmfsTaskList.tasksSyncUri(),
|
||||
"${Tasks.LIST_ID}=? AND NOT ${Tasks._DIRTY} AND ${DmfsTask.COLUMN_FLAGS}=?",
|
||||
arrayOf(dmfsTaskList.id.toString(), flags.toString()))
|
||||
|
||||
override fun forgetETags() {
|
||||
val values = contentValuesOf(LocalTask.COLUMN_ETAG to null)
|
||||
provider.update(tasksSyncUri(), values, "${Tasks.LIST_ID}=?",
|
||||
arrayOf(id.toString()))
|
||||
}
|
||||
|
||||
|
||||
object Factory: DmfsTaskListFactory<LocalTaskList> {
|
||||
|
||||
override fun newInstance(
|
||||
account: Account,
|
||||
provider: ContentProviderClient,
|
||||
providerName: TaskProvider.ProviderName,
|
||||
id: Long
|
||||
) = LocalTaskList(account, provider, providerName, id)
|
||||
|
||||
val values = contentValuesOf(DmfsTask.COLUMN_ETAG to null)
|
||||
dmfsTaskList.provider.update(dmfsTaskList.tasksSyncUri(), values, "${Tasks.LIST_ID}=?",
|
||||
arrayOf(dmfsTaskList.id.toString()))
|
||||
}
|
||||
|
||||
}
|
||||
@@ -28,6 +28,7 @@ import org.dmfs.tasks.contract.TaskContract.TaskLists
|
||||
import org.dmfs.tasks.contract.TaskContract.Tasks
|
||||
import java.util.logging.Level
|
||||
import java.util.logging.Logger
|
||||
import javax.annotation.WillNotClose
|
||||
|
||||
class LocalTaskListStore @AssistedInject constructor(
|
||||
@Assisted private val providerName: TaskProvider.ProviderName,
|
||||
@@ -47,16 +48,22 @@ class LocalTaskListStore @AssistedInject constructor(
|
||||
override val authority: String
|
||||
get() = providerName.authority
|
||||
|
||||
override fun acquireContentProvider() =
|
||||
override fun acquireContentProvider(throwOnMissingPermissions: Boolean) = try {
|
||||
context.contentResolver.acquireContentProviderClient(authority)
|
||||
} catch (e: SecurityException) {
|
||||
if (throwOnMissingPermissions)
|
||||
throw e
|
||||
else
|
||||
/* return */ null
|
||||
}
|
||||
|
||||
override fun create(provider: ContentProviderClient, fromCollection: Collection): LocalTaskList? {
|
||||
override fun create(client: ContentProviderClient, fromCollection: Collection): LocalTaskList? {
|
||||
val service = serviceDao.get(fromCollection.serviceId) ?: throw IllegalArgumentException("Couldn't fetch DB service from collection")
|
||||
val account = Account(service.accountName, context.getString(R.string.account_type))
|
||||
|
||||
logger.log(Level.INFO, "Adding local task list", fromCollection)
|
||||
val uri = create(account, provider, providerName, fromCollection)
|
||||
return DmfsTaskList.findByID(account, provider, providerName, LocalTaskList.Factory, ContentUris.parseId(uri))
|
||||
val uri = create(account, client, providerName, fromCollection)
|
||||
return LocalTaskList(DmfsTaskList.findByID(account, client, providerName, ContentUris.parseId(uri)))
|
||||
}
|
||||
|
||||
private fun create(account: Account, provider: ContentProviderClient, providerName: TaskProvider.ProviderName, fromCollection: Collection): Uri {
|
||||
@@ -74,7 +81,7 @@ class LocalTaskListStore @AssistedInject constructor(
|
||||
put(TaskLists.SYNC_ENABLED, 1)
|
||||
put(TaskLists.VISIBLE, 1)
|
||||
}
|
||||
return DmfsTaskList.Companion.create(account, provider, providerName, values)
|
||||
return DmfsTaskList.create(account, provider, providerName, values)
|
||||
}
|
||||
|
||||
private fun valuesFromCollectionInfo(info: Collection, withColor: Boolean): ContentValues {
|
||||
@@ -94,25 +101,26 @@ class LocalTaskListStore @AssistedInject constructor(
|
||||
return values
|
||||
}
|
||||
|
||||
override fun getAll(account: Account, provider: ContentProviderClient) =
|
||||
DmfsTaskList.find(account, LocalTaskList.Factory, provider, providerName, null, null)
|
||||
override fun getAll(account: Account, client: ContentProviderClient) =
|
||||
DmfsTaskList.find(account, client, providerName, null, null)
|
||||
.map { LocalTaskList(it) }
|
||||
|
||||
override fun update(provider: ContentProviderClient, localCollection: LocalTaskList, fromCollection: Collection) {
|
||||
override fun update(client: ContentProviderClient, localCollection: LocalTaskList, fromCollection: Collection) {
|
||||
logger.log(Level.FINE, "Updating local task list ${fromCollection.url}", fromCollection)
|
||||
val accountSettings = accountSettingsFactory.create(localCollection.account)
|
||||
localCollection.update(valuesFromCollectionInfo(fromCollection, withColor = accountSettings.getManageCalendarColors()))
|
||||
val accountSettings = accountSettingsFactory.create(localCollection.dmfsTaskList.account)
|
||||
localCollection.dmfsTaskList.update(valuesFromCollectionInfo(fromCollection, withColor = accountSettings.getManageCalendarColors()))
|
||||
}
|
||||
|
||||
override fun updateAccount(oldAccount: Account, newAccount: Account) {
|
||||
TaskProvider.acquire(context, providerName)?.use { provider ->
|
||||
val values = contentValuesOf(Tasks.ACCOUNT_NAME to newAccount.name)
|
||||
val uri = Tasks.getContentUri(providerName.authority)
|
||||
provider.client.update(uri, values, "${Tasks.ACCOUNT_NAME}=?", arrayOf(oldAccount.name))
|
||||
}
|
||||
override fun updateAccount(oldAccount: Account, newAccount: Account, @WillNotClose client: ContentProviderClient?) {
|
||||
if (client == null)
|
||||
return
|
||||
val values = contentValuesOf(Tasks.ACCOUNT_NAME to newAccount.name)
|
||||
val uri = Tasks.getContentUri(providerName.authority)
|
||||
client.update(uri, values, "${Tasks.ACCOUNT_NAME}=?", arrayOf(oldAccount.name))
|
||||
}
|
||||
|
||||
override fun delete(localCollection: LocalTaskList) {
|
||||
localCollection.delete()
|
||||
localCollection.dmfsTaskList.delete()
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,421 +0,0 @@
|
||||
/*
|
||||
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
|
||||
*/
|
||||
|
||||
package at.bitfire.davdroid.servicedetection
|
||||
|
||||
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.HttpException
|
||||
import at.bitfire.dav4jvm.property.caldav.CalendarColor
|
||||
import at.bitfire.dav4jvm.property.caldav.CalendarDescription
|
||||
import at.bitfire.dav4jvm.property.caldav.CalendarHomeSet
|
||||
import at.bitfire.dav4jvm.property.caldav.CalendarProxyReadFor
|
||||
import at.bitfire.dav4jvm.property.caldav.CalendarProxyWriteFor
|
||||
import at.bitfire.dav4jvm.property.caldav.CalendarTimezone
|
||||
import at.bitfire.dav4jvm.property.caldav.CalendarTimezoneId
|
||||
import at.bitfire.dav4jvm.property.caldav.Source
|
||||
import at.bitfire.dav4jvm.property.caldav.SupportedCalendarComponentSet
|
||||
import at.bitfire.dav4jvm.property.carddav.AddressbookDescription
|
||||
import at.bitfire.dav4jvm.property.carddav.AddressbookHomeSet
|
||||
import at.bitfire.dav4jvm.property.common.HrefListProperty
|
||||
import at.bitfire.dav4jvm.property.push.PushTransports
|
||||
import at.bitfire.dav4jvm.property.push.Topic
|
||||
import at.bitfire.dav4jvm.property.webdav.CurrentUserPrivilegeSet
|
||||
import at.bitfire.dav4jvm.property.webdav.DisplayName
|
||||
import at.bitfire.dav4jvm.property.webdav.GroupMembership
|
||||
import at.bitfire.dav4jvm.property.webdav.Owner
|
||||
import at.bitfire.dav4jvm.property.webdav.ResourceType
|
||||
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.repository.DavCollectionRepository
|
||||
import at.bitfire.davdroid.repository.DavHomeSetRepository
|
||||
import at.bitfire.davdroid.settings.Settings
|
||||
import at.bitfire.davdroid.settings.SettingsManager
|
||||
import at.bitfire.davdroid.util.DavUtils.parent
|
||||
import dagger.assisted.Assisted
|
||||
import dagger.assisted.AssistedFactory
|
||||
import dagger.assisted.AssistedInject
|
||||
import okhttp3.HttpUrl
|
||||
import okhttp3.OkHttpClient
|
||||
import java.util.logging.Level
|
||||
import java.util.logging.Logger
|
||||
|
||||
/**
|
||||
* Logic for refreshing the list of collections and home-sets and related information.
|
||||
*/
|
||||
class CollectionListRefresher @AssistedInject constructor(
|
||||
@Assisted private val service: Service,
|
||||
@Assisted private val httpClient: OkHttpClient,
|
||||
private val db: AppDatabase,
|
||||
private val collectionRepository: DavCollectionRepository,
|
||||
private val homeSetRepository: DavHomeSetRepository,
|
||||
private val logger: Logger,
|
||||
private val settings: SettingsManager
|
||||
) {
|
||||
|
||||
@AssistedFactory
|
||||
interface Factory {
|
||||
fun create(service: Service, httpClient: OkHttpClient): CollectionListRefresher
|
||||
}
|
||||
|
||||
/**
|
||||
* Principal properties to ask the server for.
|
||||
*/
|
||||
private val principalProperties = arrayOf(
|
||||
DisplayName.NAME,
|
||||
ResourceType.NAME
|
||||
)
|
||||
|
||||
/**
|
||||
* Home-set class to use depending on the given service type.
|
||||
*/
|
||||
private val homeSetClass: Class<out HrefListProperty> =
|
||||
when (service.type) {
|
||||
Service.TYPE_CARDDAV -> AddressbookHomeSet::class.java
|
||||
Service.TYPE_CALDAV -> CalendarHomeSet::class.java
|
||||
else -> throw IllegalArgumentException()
|
||||
}
|
||||
|
||||
/**
|
||||
* Home-set properties to ask for in a PROPFIND request to the principal URL,
|
||||
* depending on the given service type.
|
||||
*/
|
||||
private val homeSetProperties: Array<Property.Name> =
|
||||
arrayOf( // generic WebDAV properties
|
||||
DisplayName.NAME,
|
||||
GroupMembership.NAME,
|
||||
ResourceType.NAME
|
||||
) + when (service.type) { // service-specific CalDAV/CardDAV properties
|
||||
Service.TYPE_CARDDAV -> arrayOf(
|
||||
AddressbookHomeSet.NAME,
|
||||
)
|
||||
Service.TYPE_CALDAV -> arrayOf(
|
||||
CalendarHomeSet.NAME,
|
||||
CalendarProxyReadFor.NAME,
|
||||
CalendarProxyWriteFor.NAME
|
||||
)
|
||||
else -> throw IllegalArgumentException()
|
||||
}
|
||||
|
||||
/**
|
||||
* Collection properties to ask for in a PROPFIND request on a collection.
|
||||
*/
|
||||
private val collectionProperties: Array<Property.Name> =
|
||||
arrayOf( // generic WebDAV properties
|
||||
CurrentUserPrivilegeSet.NAME,
|
||||
DisplayName.NAME,
|
||||
Owner.NAME,
|
||||
ResourceType.NAME,
|
||||
PushTransports.NAME, // WebDAV-Push
|
||||
Topic.NAME
|
||||
) + when (service.type) { // service-specific CalDAV/CardDAV properties
|
||||
Service.TYPE_CARDDAV -> arrayOf(
|
||||
AddressbookDescription.NAME
|
||||
)
|
||||
Service.TYPE_CALDAV -> arrayOf(
|
||||
CalendarColor.NAME,
|
||||
CalendarDescription.NAME,
|
||||
CalendarTimezone.NAME,
|
||||
CalendarTimezoneId.NAME,
|
||||
SupportedCalendarComponentSet.NAME,
|
||||
Source.NAME
|
||||
)
|
||||
else -> throw IllegalArgumentException()
|
||||
}
|
||||
|
||||
|
||||
|
||||
/**
|
||||
* Starting at given principal URL, tries to recursively find and save all user relevant home sets.
|
||||
*
|
||||
* @param principalUrl URL of principal to query (user-provided principal or current-user-principal)
|
||||
* @param level Current recursion level (limited to 0, 1 or 2):
|
||||
* - 0: We assume found home sets belong to the current-user-principal
|
||||
* - 1 or 2: We assume found home sets don't directly belong to the current-user-principal
|
||||
* @param alreadyQueriedPrincipals The HttpUrls of principals which have been queried already, to avoid querying principals more than once.
|
||||
* @param alreadySavedHomeSets The HttpUrls of home sets which have been saved to database already, to avoid saving home sets
|
||||
* more than once, which could overwrite the already set "personal" flag with `false`.
|
||||
*
|
||||
* @throws java.io.IOException on I/O errors
|
||||
* @throws HttpException on HTTP errors
|
||||
* @throws at.bitfire.dav4jvm.exception.DavException on application-level or logical errors
|
||||
*/
|
||||
internal fun discoverHomesets(
|
||||
principalUrl: HttpUrl,
|
||||
level: Int = 0,
|
||||
alreadyQueriedPrincipals: MutableSet<HttpUrl> = mutableSetOf(),
|
||||
alreadySavedHomeSets: MutableSet<HttpUrl> = mutableSetOf()
|
||||
) {
|
||||
logger.fine("Discovering homesets of $principalUrl")
|
||||
val relatedResources = mutableSetOf<HttpUrl>()
|
||||
|
||||
// Query the URL
|
||||
val principal = DavResource(httpClient, principalUrl)
|
||||
val personal = level == 0
|
||||
try {
|
||||
principal.propfind(0, *homeSetProperties) { davResponse, _ ->
|
||||
alreadyQueriedPrincipals += davResponse.href
|
||||
|
||||
// If response holds home sets, save them
|
||||
davResponse[homeSetClass]?.let { homeSets ->
|
||||
for (homeSetHref in homeSets.hrefs)
|
||||
principal.location.resolve(homeSetHref)?.let { homesetUrl ->
|
||||
val resolvedHomeSetUrl = UrlUtils.withTrailingSlash(homesetUrl)
|
||||
if (!alreadySavedHomeSets.contains(resolvedHomeSetUrl)) {
|
||||
homeSetRepository.insertOrUpdateByUrlBlocking(
|
||||
// HomeSet is considered personal if this is the outer recursion call,
|
||||
// This is because we assume the first call to query the current-user-principal
|
||||
// Note: This is not be be confused with the DAV:owner attribute. Home sets can be owned by
|
||||
// other principals while still being considered "personal" (belonging to the current-user-principal)
|
||||
// and an owned home set need not always be personal either.
|
||||
HomeSet(0, service.id, personal, resolvedHomeSetUrl)
|
||||
)
|
||||
alreadySavedHomeSets += resolvedHomeSetUrl
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Add related principals to be queried afterwards
|
||||
if (personal) {
|
||||
val relatedResourcesTypes = listOf(
|
||||
// current resource is a read/write-proxy for other principals
|
||||
CalendarProxyReadFor::class.java,
|
||||
CalendarProxyWriteFor::class.java,
|
||||
// current resource is a member of a group (principal that can also have proxies)
|
||||
GroupMembership::class.java
|
||||
)
|
||||
for (type in relatedResourcesTypes)
|
||||
davResponse[type]?.let {
|
||||
for (href in it.hrefs)
|
||||
principal.location.resolve(href)?.let { url ->
|
||||
relatedResources += url
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// If current resource is a calendar-proxy-read/write, it's likely that its parent is a principal, too.
|
||||
davResponse[ResourceType::class.java]?.let { resourceType ->
|
||||
val proxyProperties = arrayOf(
|
||||
ResourceType.CALENDAR_PROXY_READ,
|
||||
ResourceType.CALENDAR_PROXY_WRITE,
|
||||
)
|
||||
if (proxyProperties.any { resourceType.types.contains(it) })
|
||||
relatedResources += davResponse.href.parent()
|
||||
}
|
||||
}
|
||||
} catch (e: HttpException) {
|
||||
if (e.code/100 == 4)
|
||||
logger.log(Level.INFO, "Ignoring Client Error 4xx while looking for ${service.type} home sets", e)
|
||||
else
|
||||
throw e
|
||||
}
|
||||
|
||||
// query related resources
|
||||
if (level <= 1)
|
||||
for (resource in relatedResources)
|
||||
if (alreadyQueriedPrincipals.contains(resource))
|
||||
logger.warning("$resource already queried, skipping")
|
||||
else
|
||||
discoverHomesets(
|
||||
principalUrl = resource,
|
||||
level = level + 1,
|
||||
alreadyQueriedPrincipals = alreadyQueriedPrincipals,
|
||||
alreadySavedHomeSets = alreadySavedHomeSets
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Refreshes home-sets and their collections.
|
||||
*
|
||||
* Each stored home-set URL is queried (`PROPFIND`) and its collections are either saved, updated
|
||||
* or marked as homeless - in case a collection was removed from its home-set.
|
||||
*
|
||||
* If a home-set URL in fact points to a collection directly, the collection will be saved with this URL,
|
||||
* and a null value for it's home-set. Refreshing of collections without home-sets is then handled by [refreshHomelessCollections].
|
||||
*/
|
||||
internal fun refreshHomesetsAndTheirCollections() {
|
||||
val homesets = homeSetRepository.getByServiceBlocking(service.id).associateBy { it.url }.toMutableMap()
|
||||
for((homeSetUrl, localHomeset) in homesets) {
|
||||
logger.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, *collectionProperties) { 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 home set itself
|
||||
homeSetRepository.insertOrUpdateByUrlBlocking(localHomeset.copy(
|
||||
displayName = response[DisplayName::class.java]?.displayName,
|
||||
privBind = response[CurrentUserPrivilegeSet::class.java]?.mayBind != false
|
||||
))
|
||||
|
||||
// in any case, check whether the response is about a usable collection
|
||||
var collection = Collection.fromDavResponse(response) ?: return@propfind
|
||||
collection = collection.copy(
|
||||
serviceId = service.id,
|
||||
homeSetId = localHomeset.id,
|
||||
sync = shouldPreselect(collection, homesets.values),
|
||||
ownerId = response[Owner::class.java]?.href // save the principal id (collection owner)
|
||||
?.let { response.href.resolve(it) }
|
||||
?.let { principalUrl -> Principal.fromServiceAndUrl(service, principalUrl) }
|
||||
?.let { principal -> db.principalDao().insertOrUpdate(service.id, principal) }
|
||||
)
|
||||
logger.log(Level.FINE, "Found collection", collection)
|
||||
|
||||
// save or update collection if usable (ignore it otherwise)
|
||||
if (isUsableCollection(collection))
|
||||
collectionRepository.insertOrUpdateByUrlRememberSync(collection)
|
||||
|
||||
// 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))
|
||||
homeSetRepository.deleteBlocking(localHomeset)
|
||||
}
|
||||
|
||||
// Mark leftover (not rediscovered) collections from queue as homeless (remove association)
|
||||
for ((_, homelessCollection) in localHomesetCollections)
|
||||
collectionRepository.insertOrUpdateByUrlRememberSync(
|
||||
homelessCollection.copy(homeSetId = null)
|
||||
)
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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, *collectionProperties) { response, _ ->
|
||||
if (!response.isSuccess()) {
|
||||
collectionRepository.delete(localCollection)
|
||||
return@propfind
|
||||
}
|
||||
|
||||
// Save or update the collection, if usable, otherwise delete it
|
||||
Collection.fromDavResponse(response)?.let { collection ->
|
||||
if (!isUsableCollection(collection))
|
||||
return@let
|
||||
collectionRepository.insertOrUpdateByUrlRememberSync(collection.copy(
|
||||
serviceId = localCollection.serviceId, // use same service ID as previous entry
|
||||
ownerId = response[Owner::class.java]?.href // save the principal id (collection owner)
|
||||
?.let { response.href.resolve(it) }
|
||||
?.let { principalUrl -> Principal.fromServiceAndUrl(service, principalUrl) }
|
||||
?.let { principal -> db.principalDao().insertOrUpdate(service.id, principal) }
|
||||
))
|
||||
} ?: collectionRepository.delete(localCollection)
|
||||
}
|
||||
} catch (e: HttpException) {
|
||||
// delete collection locally if it was not accessible (40x)
|
||||
if (e.code in arrayOf(403, 404, 410))
|
||||
collectionRepository.delete(localCollection)
|
||||
else
|
||||
throw e
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.fine("Querying principal $principalUrl")
|
||||
try {
|
||||
DavResource(httpClient, principalUrl).propfind(0, *principalProperties) { response, _ ->
|
||||
if (!response.isSuccess())
|
||||
return@propfind
|
||||
Principal.fromDavResponse(service.id, response)?.let { principal ->
|
||||
logger.fine("Got principal: $principal")
|
||||
db.principalDao().insertOrUpdate(service.id, principal)
|
||||
}
|
||||
}
|
||||
} catch (e: HttpException) {
|
||||
logger.info("Principal update failed with response code ${e.code}. principalUrl=$principalUrl")
|
||||
}
|
||||
}
|
||||
|
||||
// Delete principals which don't own any collections
|
||||
db.principalDao().getAllWithoutCollections().forEach {principal ->
|
||||
db.principalDao().delete(principal)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 personal home-sets
|
||||
* @return *true* if the collection should be preselected for synchronization; *false* otherwise
|
||||
*/
|
||||
internal fun shouldPreselect(collection: Collection, homeSets: Iterable<HomeSet>): 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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
/*
|
||||
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
|
||||
*/
|
||||
|
||||
package at.bitfire.davdroid.servicedetection
|
||||
|
||||
import at.bitfire.dav4jvm.okhttp.DavResource
|
||||
import at.bitfire.dav4jvm.okhttp.exception.HttpException
|
||||
import at.bitfire.dav4jvm.property.webdav.Owner
|
||||
import at.bitfire.davdroid.db.AppDatabase
|
||||
import at.bitfire.davdroid.db.Collection
|
||||
import at.bitfire.davdroid.db.Principal
|
||||
import at.bitfire.davdroid.db.Service
|
||||
import at.bitfire.davdroid.repository.DavCollectionRepository
|
||||
import dagger.assisted.Assisted
|
||||
import dagger.assisted.AssistedFactory
|
||||
import dagger.assisted.AssistedInject
|
||||
import okhttp3.OkHttpClient
|
||||
|
||||
/**
|
||||
* Logic for refreshing the list of collections (and their related information)
|
||||
* which do not belong to a home set.
|
||||
*/
|
||||
class CollectionsWithoutHomeSetRefresher @AssistedInject constructor(
|
||||
@Assisted private val service: Service,
|
||||
@Assisted private val httpClient: OkHttpClient,
|
||||
private val db: AppDatabase,
|
||||
private val collectionRepository: DavCollectionRepository,
|
||||
) {
|
||||
|
||||
@AssistedFactory
|
||||
interface Factory {
|
||||
fun create(service: Service, httpClient: OkHttpClient): CollectionsWithoutHomeSetRefresher
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 refreshCollectionsWithoutHomeSet() {
|
||||
val withoutHomeSet = db.collectionDao().getByServiceAndHomeset(service.id, null).associateBy { it.url }.toMutableMap()
|
||||
for ((url, localCollection) in withoutHomeSet) try {
|
||||
val collectionProperties = ServiceDetectionUtils.collectionQueryProperties(service.type)
|
||||
DavResource(httpClient, url).propfind(0, *collectionProperties) { response, _ ->
|
||||
if (!response.isSuccess()) {
|
||||
collectionRepository.delete(localCollection)
|
||||
return@propfind
|
||||
}
|
||||
|
||||
// Save or update the collection, if usable, otherwise delete it
|
||||
Collection.fromDavResponse(response)?.let { collection ->
|
||||
if (!ServiceDetectionUtils.isUsableCollection(service, collection))
|
||||
return@let
|
||||
collectionRepository.insertOrUpdateByUrlRememberSync(collection.copy(
|
||||
serviceId = localCollection.serviceId, // use same service ID as previous entry
|
||||
ownerId = response[Owner::class.java]?.href // save the principal id (collection owner)
|
||||
?.let { response.href.resolve(it) }
|
||||
?.let { principalUrl -> Principal.fromServiceAndUrl(service, principalUrl) }
|
||||
?.let { principal -> db.principalDao().insertOrUpdate(service.id, principal) }
|
||||
))
|
||||
} ?: collectionRepository.delete(localCollection)
|
||||
}
|
||||
} catch (e: HttpException) {
|
||||
// delete collection locally if it was not accessible (40x)
|
||||
if (e.statusCode in arrayOf(403, 404, 410))
|
||||
collectionRepository.delete(localCollection)
|
||||
else
|
||||
throw e
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -6,30 +6,26 @@ package at.bitfire.davdroid.servicedetection
|
||||
import android.app.ActivityManager
|
||||
import android.content.Context
|
||||
import androidx.core.content.getSystemService
|
||||
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.caldav.CalendarColor
|
||||
import at.bitfire.dav4jvm.property.caldav.CalendarDescription
|
||||
import at.bitfire.dav4jvm.okhttp.DavResource
|
||||
import at.bitfire.dav4jvm.okhttp.Response
|
||||
import at.bitfire.dav4jvm.okhttp.UrlUtils
|
||||
import at.bitfire.dav4jvm.okhttp.exception.DavException
|
||||
import at.bitfire.dav4jvm.okhttp.exception.HttpException
|
||||
import at.bitfire.dav4jvm.okhttp.exception.UnauthorizedException
|
||||
import at.bitfire.dav4jvm.property.caldav.CalDAV
|
||||
import at.bitfire.dav4jvm.property.caldav.CalendarHomeSet
|
||||
import at.bitfire.dav4jvm.property.caldav.CalendarTimezone
|
||||
import at.bitfire.dav4jvm.property.caldav.CalendarUserAddressSet
|
||||
import at.bitfire.dav4jvm.property.caldav.SupportedCalendarComponentSet
|
||||
import at.bitfire.dav4jvm.property.carddav.AddressbookDescription
|
||||
import at.bitfire.dav4jvm.property.carddav.AddressbookHomeSet
|
||||
import at.bitfire.dav4jvm.property.carddav.CardDAV
|
||||
import at.bitfire.dav4jvm.property.common.HrefListProperty
|
||||
import at.bitfire.dav4jvm.property.webdav.CurrentUserPrincipal
|
||||
import at.bitfire.dav4jvm.property.webdav.CurrentUserPrivilegeSet
|
||||
import at.bitfire.dav4jvm.property.webdav.DisplayName
|
||||
import at.bitfire.dav4jvm.property.webdav.ResourceType
|
||||
import at.bitfire.dav4jvm.property.webdav.WebDAV
|
||||
import at.bitfire.davdroid.db.Collection
|
||||
import at.bitfire.davdroid.log.StringHandler
|
||||
import at.bitfire.davdroid.network.DnsRecordResolver
|
||||
import at.bitfire.davdroid.network.HttpClient
|
||||
import at.bitfire.davdroid.network.HttpClientBuilder
|
||||
import at.bitfire.davdroid.settings.Credentials
|
||||
import dagger.assisted.Assisted
|
||||
import dagger.assisted.AssistedFactory
|
||||
@@ -62,8 +58,8 @@ class DavResourceFinder @AssistedInject constructor(
|
||||
@Assisted private val credentials: Credentials? = null,
|
||||
@ApplicationContext val context: Context,
|
||||
private val dnsRecordResolver: DnsRecordResolver,
|
||||
httpClientBuilder: HttpClient.Builder
|
||||
): AutoCloseable {
|
||||
httpClientBuilder: HttpClientBuilder
|
||||
) {
|
||||
|
||||
@AssistedFactory
|
||||
interface Factory {
|
||||
@@ -87,16 +83,12 @@ class DavResourceFinder @AssistedInject constructor(
|
||||
.apply {
|
||||
if (credentials != null)
|
||||
authenticate(
|
||||
host = null,
|
||||
domain = null,
|
||||
getCredentials = { credentials }
|
||||
)
|
||||
}
|
||||
.build()
|
||||
|
||||
override fun close() {
|
||||
httpClient.close()
|
||||
}
|
||||
|
||||
private fun initLogging(): StringHandler {
|
||||
// don't use more than 1/4 of the available memory for a log string
|
||||
val activityManager = context.getSystemService<ActivityManager>()!!
|
||||
@@ -228,27 +220,29 @@ class DavResourceFinder @AssistedInject constructor(
|
||||
private fun checkBaseURL(baseURL: HttpUrl, service: Service, config: Configuration.ServiceInfo) {
|
||||
log.info("Checking user-given URL: $baseURL")
|
||||
|
||||
val davBaseURL = DavResource(httpClient.okHttpClient, baseURL, log)
|
||||
val davBaseURL = DavResource(httpClient, baseURL, log)
|
||||
try {
|
||||
when (service) {
|
||||
Service.CARDDAV -> {
|
||||
davBaseURL.propfind(
|
||||
0,
|
||||
ResourceType.NAME, DisplayName.NAME, AddressbookDescription.NAME,
|
||||
AddressbookHomeSet.NAME,
|
||||
CurrentUserPrincipal.NAME
|
||||
WebDAV.ResourceType, WebDAV.DisplayName,
|
||||
WebDAV.CurrentUserPrincipal,
|
||||
CardDAV.AddressbookHomeSet,
|
||||
CardDAV.AddressbookDescription
|
||||
) { response, _ ->
|
||||
scanResponse(ResourceType.ADDRESSBOOK, response, config)
|
||||
scanResponse(CardDAV.Addressbook, response, config)
|
||||
}
|
||||
}
|
||||
Service.CALDAV -> {
|
||||
davBaseURL.propfind(
|
||||
0,
|
||||
ResourceType.NAME, DisplayName.NAME, CalendarColor.NAME, CalendarDescription.NAME, CalendarTimezone.NAME, CurrentUserPrivilegeSet.NAME, SupportedCalendarComponentSet.NAME,
|
||||
CalendarHomeSet.NAME,
|
||||
CurrentUserPrincipal.NAME
|
||||
WebDAV.ResourceType, WebDAV.DisplayName,
|
||||
WebDAV.CurrentUserPrincipal, WebDAV.CurrentUserPrivilegeSet,
|
||||
CalDAV.CalendarHomeSet,
|
||||
CalDAV.SupportedCalendarComponentSet, CalDAV.CalendarColor, CalDAV.CalendarDescription, CalDAV.CalendarTimezone
|
||||
) { response, _ ->
|
||||
scanResponse(ResourceType.CALENDAR, response, config)
|
||||
scanResponse(CalDAV.Calendar, response, config)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -266,7 +260,7 @@ class DavResourceFinder @AssistedInject constructor(
|
||||
fun queryEmailAddress(principal: HttpUrl): List<String> {
|
||||
val mailboxes = LinkedList<String>()
|
||||
try {
|
||||
DavResource(httpClient.okHttpClient, principal, log).propfind(0, CalendarUserAddressSet.NAME) { response, _ ->
|
||||
DavResource(httpClient, principal, log).propfind(0, CalDAV.CalendarUserAddressSet) { response, _ ->
|
||||
response[CalendarUserAddressSet::class.java]?.let { addressSet ->
|
||||
for (href in addressSet.hrefs)
|
||||
try {
|
||||
@@ -305,11 +299,11 @@ class DavResourceFinder @AssistedInject constructor(
|
||||
val homeSetClass: Class<out HrefListProperty>
|
||||
val serviceType: Service
|
||||
when (resourceType) {
|
||||
ResourceType.ADDRESSBOOK -> {
|
||||
CardDAV.Addressbook -> {
|
||||
homeSetClass = AddressbookHomeSet::class.java
|
||||
serviceType = Service.CARDDAV
|
||||
}
|
||||
ResourceType.CALENDAR -> {
|
||||
CalDAV.Calendar -> {
|
||||
homeSetClass = CalendarHomeSet::class.java
|
||||
serviceType = Service.CALDAV
|
||||
}
|
||||
@@ -330,7 +324,7 @@ class DavResourceFinder @AssistedInject constructor(
|
||||
}
|
||||
|
||||
// ... and/or a principal?
|
||||
if (it.types.contains(ResourceType.PRINCIPAL))
|
||||
if (it.types.contains(WebDAV.Principal))
|
||||
principal = davResponse.href
|
||||
}
|
||||
|
||||
@@ -365,7 +359,7 @@ class DavResourceFinder @AssistedInject constructor(
|
||||
fun providesService(url: HttpUrl, service: Service): Boolean {
|
||||
var provided = false
|
||||
try {
|
||||
DavResource(httpClient.okHttpClient, url, log).options { capabilities, _ ->
|
||||
DavResource(httpClient, url, log).options { capabilities, _ ->
|
||||
if ((service == Service.CARDDAV && capabilities.contains("addressbook")) ||
|
||||
(service == Service.CALDAV && capabilities.contains("calendar-access")))
|
||||
provided = true
|
||||
@@ -450,7 +444,7 @@ class DavResourceFinder @AssistedInject constructor(
|
||||
*/
|
||||
fun getCurrentUserPrincipal(url: HttpUrl, service: Service?): HttpUrl? {
|
||||
var principal: HttpUrl? = null
|
||||
DavResource(httpClient.okHttpClient, url, log).propfind(0, CurrentUserPrincipal.NAME) { response, _ ->
|
||||
DavResource(httpClient, url, log).propfind(0, WebDAV.CurrentUserPrincipal) { response, _ ->
|
||||
response[CurrentUserPrincipal::class.java]?.href?.let { href ->
|
||||
response.requestedUrl.resolve(href)?.let {
|
||||
log.info("Found current-user-principal: $it")
|
||||
|
||||
@@ -0,0 +1,162 @@
|
||||
/*
|
||||
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
|
||||
*/
|
||||
|
||||
package at.bitfire.davdroid.servicedetection
|
||||
|
||||
import at.bitfire.dav4jvm.okhttp.DavResource
|
||||
import at.bitfire.dav4jvm.okhttp.Response
|
||||
import at.bitfire.dav4jvm.okhttp.exception.HttpException
|
||||
import at.bitfire.dav4jvm.property.webdav.CurrentUserPrivilegeSet
|
||||
import at.bitfire.dav4jvm.property.webdav.DisplayName
|
||||
import at.bitfire.dav4jvm.property.webdav.Owner
|
||||
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.repository.DavCollectionRepository
|
||||
import at.bitfire.davdroid.repository.DavHomeSetRepository
|
||||
import at.bitfire.davdroid.settings.Settings
|
||||
import at.bitfire.davdroid.settings.SettingsManager
|
||||
import dagger.assisted.Assisted
|
||||
import dagger.assisted.AssistedFactory
|
||||
import dagger.assisted.AssistedInject
|
||||
import okhttp3.OkHttpClient
|
||||
import java.util.logging.Level
|
||||
import java.util.logging.Logger
|
||||
|
||||
/**
|
||||
* Used to update the list of synchronizable collections
|
||||
*/
|
||||
class HomeSetRefresher @AssistedInject constructor(
|
||||
@Assisted private val service: Service,
|
||||
@Assisted private val httpClient: OkHttpClient,
|
||||
private val db: AppDatabase,
|
||||
private val logger: Logger,
|
||||
private val collectionRepository: DavCollectionRepository,
|
||||
private val homeSetRepository: DavHomeSetRepository,
|
||||
private val settings: SettingsManager
|
||||
) {
|
||||
|
||||
@AssistedFactory
|
||||
interface Factory {
|
||||
fun create(service: Service, httpClient: OkHttpClient): HomeSetRefresher
|
||||
}
|
||||
|
||||
/**
|
||||
* Refreshes home-sets and their collections.
|
||||
*
|
||||
* Each stored home-set URL is queried (`PROPFIND`) and its collections are either saved, updated
|
||||
* or marked as "without home-set" - in case a collection was removed from its home-set.
|
||||
*
|
||||
* If a home-set URL in fact points to a collection directly, the collection will be saved with this URL,
|
||||
* and a null value for it's home-set. Refreshing of collections without home-sets is then handled by [CollectionsWithoutHomeSetRefresher.refreshCollectionsWithoutHomeSet].
|
||||
*/
|
||||
internal fun refreshHomesetsAndTheirCollections() {
|
||||
val homesets = homeSetRepository.getByServiceBlocking(service.id).associateBy { it.url }.toMutableMap()
|
||||
for ((homeSetUrl, localHomeset) in homesets) {
|
||||
logger.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 as "without home-set".
|
||||
val localHomesetCollections = db.collectionDao()
|
||||
.getByServiceAndHomeset(service.id, localHomeset.id)
|
||||
.associateBy { it.url }
|
||||
.toMutableMap()
|
||||
|
||||
try {
|
||||
val collectionProperties = ServiceDetectionUtils.collectionQueryProperties(service.type)
|
||||
DavResource(httpClient, homeSetUrl).propfind(1, *collectionProperties) { 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 home set itself
|
||||
homeSetRepository.insertOrUpdateByUrlBlocking(
|
||||
localHomeset.copy(
|
||||
displayName = response[DisplayName::class.java]?.displayName,
|
||||
privBind = response[CurrentUserPrivilegeSet::class.java]?.mayBind != false
|
||||
)
|
||||
)
|
||||
|
||||
// in any case, check whether the response is about a usable collection
|
||||
var collection = Collection.fromDavResponse(response) ?: return@propfind
|
||||
collection = collection.copy(
|
||||
serviceId = service.id,
|
||||
homeSetId = localHomeset.id,
|
||||
sync = shouldPreselect(collection, homesets.values),
|
||||
ownerId = response[Owner::class.java]?.href // save the principal id (collection owner)
|
||||
?.let { response.href.resolve(it) }
|
||||
?.let { principalUrl -> Principal.fromServiceAndUrl(service, principalUrl) }
|
||||
?.let { principal -> db.principalDao().insertOrUpdate(service.id, principal) }
|
||||
)
|
||||
logger.log(Level.FINE, "Found collection", collection)
|
||||
|
||||
// save or update collection if usable (ignore it otherwise)
|
||||
if (ServiceDetectionUtils.isUsableCollection(service, collection))
|
||||
collectionRepository.insertOrUpdateByUrlRememberSync(collection)
|
||||
|
||||
// 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.statusCode in arrayOf(403, 404, 410))
|
||||
homeSetRepository.deleteBlocking(localHomeset)
|
||||
}
|
||||
|
||||
// Mark leftover (not rediscovered) collections from queue as "without home-set" (remove association)
|
||||
for ((_, collection) in localHomesetCollections)
|
||||
collectionRepository.insertOrUpdateByUrlRememberSync(
|
||||
collection.copy(homeSetId = 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 personal home-sets
|
||||
* @return *true* if the collection should be preselected for synchronization; *false* otherwise
|
||||
*/
|
||||
internal fun shouldPreselect(collection: Collection, homeSets: Iterable<HomeSet>): 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
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
/*
|
||||
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
|
||||
*/
|
||||
|
||||
package at.bitfire.davdroid.servicedetection
|
||||
|
||||
import at.bitfire.dav4jvm.okhttp.DavResource
|
||||
import at.bitfire.dav4jvm.okhttp.exception.HttpException
|
||||
import at.bitfire.dav4jvm.property.webdav.WebDAV
|
||||
import at.bitfire.davdroid.db.AppDatabase
|
||||
import at.bitfire.davdroid.db.Principal
|
||||
import at.bitfire.davdroid.db.Service
|
||||
import dagger.assisted.Assisted
|
||||
import dagger.assisted.AssistedFactory
|
||||
import dagger.assisted.AssistedInject
|
||||
import okhttp3.OkHttpClient
|
||||
import java.util.logging.Logger
|
||||
|
||||
/**
|
||||
* Used to update the principals (their current display names) and delete those without collections.
|
||||
*/
|
||||
class PrincipalsRefresher @AssistedInject constructor(
|
||||
@Assisted private val service: Service,
|
||||
@Assisted private val httpClient: OkHttpClient,
|
||||
private val db: AppDatabase,
|
||||
private val logger: Logger
|
||||
) {
|
||||
|
||||
@AssistedFactory
|
||||
interface Factory {
|
||||
fun create(service: Service, httpClient: OkHttpClient): PrincipalsRefresher
|
||||
}
|
||||
|
||||
/**
|
||||
* Principal properties to ask the server for.
|
||||
*/
|
||||
private val principalProperties = arrayOf(
|
||||
WebDAV.DisplayName,
|
||||
WebDAV.ResourceType
|
||||
)
|
||||
|
||||
/**
|
||||
* Refreshes the principals (get their current display names).
|
||||
* Also removes principals which do not own any collections anymore.
|
||||
*/
|
||||
fun refreshPrincipals() {
|
||||
// Refresh principals (collection owner urls)
|
||||
val principals = db.principalDao().getByService(service.id)
|
||||
for (oldPrincipal in principals) {
|
||||
val principalUrl = oldPrincipal.url
|
||||
logger.fine("Querying principal $principalUrl")
|
||||
try {
|
||||
DavResource(httpClient, principalUrl).propfind(0, *principalProperties) { response, _ ->
|
||||
if (!response.isSuccess())
|
||||
return@propfind
|
||||
Principal.fromDavResponse(service.id, response)?.let { principal ->
|
||||
logger.fine("Got principal: $principal")
|
||||
db.principalDao().insertOrUpdate(service.id, principal)
|
||||
}
|
||||
}
|
||||
} catch (e: HttpException) {
|
||||
logger.info("Principal update failed with response code ${e.statusCode}. principalUrl=$principalUrl")
|
||||
}
|
||||
}
|
||||
|
||||
// Delete principals which don't own any collections
|
||||
db.principalDao().getAllWithoutCollections().forEach { principal ->
|
||||
db.principalDao().delete(principal)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -22,9 +22,9 @@ import androidx.work.OutOfQuotaPolicy
|
||||
import androidx.work.WorkInfo
|
||||
import androidx.work.WorkManager
|
||||
import androidx.work.WorkerParameters
|
||||
import at.bitfire.dav4jvm.exception.UnauthorizedException
|
||||
import at.bitfire.dav4jvm.okhttp.exception.UnauthorizedException
|
||||
import at.bitfire.davdroid.R
|
||||
import at.bitfire.davdroid.network.HttpClient
|
||||
import at.bitfire.davdroid.network.HttpClientBuilder
|
||||
import at.bitfire.davdroid.push.PushRegistrationManager
|
||||
import at.bitfire.davdroid.repository.DavServiceRepository
|
||||
import at.bitfire.davdroid.servicedetection.RefreshCollectionsWorker.Companion.ARG_SERVICE_ID
|
||||
@@ -62,11 +62,14 @@ import java.util.logging.Logger
|
||||
class RefreshCollectionsWorker @AssistedInject constructor(
|
||||
@Assisted appContext: Context,
|
||||
@Assisted workerParams: WorkerParameters,
|
||||
private val collectionListRefresherFactory: CollectionListRefresher.Factory,
|
||||
private val httpClientBuilder: HttpClient.Builder,
|
||||
private val collectionsWithoutHomeSetRefresherFactory: CollectionsWithoutHomeSetRefresher.Factory,
|
||||
private val homeSetRefresherFactory: HomeSetRefresher.Factory,
|
||||
private val httpClientBuilder: HttpClientBuilder,
|
||||
private val logger: Logger,
|
||||
private val notificationRegistry: NotificationRegistry,
|
||||
private val principalsRefresherFactory: PrincipalsRefresher.Factory,
|
||||
private val pushRegistrationManager: PushRegistrationManager,
|
||||
private val serviceRefresherFactory: ServiceRefresher.Factory,
|
||||
serviceRepository: DavServiceRepository
|
||||
): CoroutineWorker(appContext, workerParams) {
|
||||
|
||||
@@ -150,31 +153,31 @@ class RefreshCollectionsWorker @AssistedInject constructor(
|
||||
.cancel(serviceId.toString(), NotificationRegistry.NOTIFY_REFRESH_COLLECTIONS)
|
||||
|
||||
// create authenticating OkHttpClient (credentials taken from account settings)
|
||||
httpClientBuilder
|
||||
val httpClient = httpClientBuilder
|
||||
.fromAccount(account)
|
||||
.build()
|
||||
.use { httpClient ->
|
||||
runInterruptible {
|
||||
val httpClient = httpClient.okHttpClient
|
||||
val refresher = collectionListRefresherFactory.create(service, httpClient)
|
||||
runInterruptible {
|
||||
val refresher = collectionsWithoutHomeSetRefresherFactory.create(service, httpClient)
|
||||
|
||||
// refresh home set list (from principal url)
|
||||
service.principal?.let { principalUrl ->
|
||||
logger.fine("Querying principal $principalUrl for home sets")
|
||||
refresher.discoverHomesets(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()
|
||||
}
|
||||
// refresh home set list (from principal url)
|
||||
service.principal?.let { principalUrl ->
|
||||
logger.fine("Querying principal $principalUrl for home sets")
|
||||
val serviceRefresher = serviceRefresherFactory.create(service, httpClient)
|
||||
serviceRefresher.discoverHomesets(principalUrl)
|
||||
}
|
||||
|
||||
// refresh home sets and their member collections
|
||||
homeSetRefresherFactory.create(service, httpClient)
|
||||
.refreshHomesetsAndTheirCollections()
|
||||
|
||||
// also refresh collections without a home set
|
||||
refresher.refreshCollectionsWithoutHomeSet()
|
||||
|
||||
// Lastly, refresh the principals (collection owners)
|
||||
val principalsRefresher = principalsRefresherFactory.create(service, httpClient)
|
||||
principalsRefresher.refreshPrincipals()
|
||||
}
|
||||
|
||||
} catch(e: InvalidAccountException) {
|
||||
logger.log(Level.SEVERE, "Invalid account", e)
|
||||
return Result.failure()
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
/*
|
||||
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
|
||||
*/
|
||||
|
||||
package at.bitfire.davdroid.servicedetection
|
||||
|
||||
import at.bitfire.dav4jvm.Property
|
||||
import at.bitfire.dav4jvm.property.caldav.CalDAV
|
||||
import at.bitfire.dav4jvm.property.carddav.CardDAV
|
||||
import at.bitfire.dav4jvm.property.push.WebDAVPush
|
||||
import at.bitfire.dav4jvm.property.webdav.WebDAV
|
||||
import at.bitfire.davdroid.db.Collection
|
||||
import at.bitfire.davdroid.db.Service
|
||||
import at.bitfire.davdroid.db.ServiceType
|
||||
|
||||
object ServiceDetectionUtils {
|
||||
|
||||
/**
|
||||
* WebDAV properties to ask for in a PROPFIND request on a collection.
|
||||
*/
|
||||
fun collectionQueryProperties(@ServiceType serviceType: String): Array<Property.Name> =
|
||||
arrayOf( // generic WebDAV properties
|
||||
WebDAV.CurrentUserPrivilegeSet,
|
||||
WebDAV.DisplayName,
|
||||
WebDAV.Owner,
|
||||
WebDAV.ResourceType,
|
||||
WebDAVPush.Transports,
|
||||
WebDAVPush.Topic
|
||||
) + when (serviceType) { // service-specific CalDAV/CardDAV properties
|
||||
Service.TYPE_CARDDAV -> arrayOf(
|
||||
CardDAV.AddressbookDescription
|
||||
)
|
||||
|
||||
Service.TYPE_CALDAV -> arrayOf(
|
||||
CalDAV.CalendarColor,
|
||||
CalDAV.CalendarDescription,
|
||||
CalDAV.CalendarTimezone,
|
||||
CalDAV.CalendarTimezoneId,
|
||||
CalDAV.SupportedCalendarComponentSet,
|
||||
CalDAV.Source
|
||||
)
|
||||
|
||||
else -> throw IllegalArgumentException()
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds out whether given collection is usable for synchronization, by checking that either
|
||||
*
|
||||
* - CalDAV/CardDAV: service and collection type match, or
|
||||
* - WebCal: subscription source URL is not empty.
|
||||
*/
|
||||
fun isUsableCollection(service: Service, 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)
|
||||
|
||||
}
|
||||
@@ -0,0 +1,180 @@
|
||||
/*
|
||||
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
|
||||
*/
|
||||
|
||||
package at.bitfire.davdroid.servicedetection
|
||||
|
||||
import at.bitfire.dav4jvm.Property
|
||||
import at.bitfire.dav4jvm.okhttp.DavResource
|
||||
import at.bitfire.dav4jvm.okhttp.UrlUtils
|
||||
import at.bitfire.dav4jvm.okhttp.exception.HttpException
|
||||
import at.bitfire.dav4jvm.property.caldav.CalDAV
|
||||
import at.bitfire.dav4jvm.property.caldav.CalendarHomeSet
|
||||
import at.bitfire.dav4jvm.property.caldav.CalendarProxyReadFor
|
||||
import at.bitfire.dav4jvm.property.caldav.CalendarProxyWriteFor
|
||||
import at.bitfire.dav4jvm.property.carddav.AddressbookHomeSet
|
||||
import at.bitfire.dav4jvm.property.carddav.CardDAV
|
||||
import at.bitfire.dav4jvm.property.common.HrefListProperty
|
||||
import at.bitfire.dav4jvm.property.webdav.GroupMembership
|
||||
import at.bitfire.dav4jvm.property.webdav.ResourceType
|
||||
import at.bitfire.dav4jvm.property.webdav.WebDAV
|
||||
import at.bitfire.davdroid.db.HomeSet
|
||||
import at.bitfire.davdroid.db.Service
|
||||
import at.bitfire.davdroid.repository.DavHomeSetRepository
|
||||
import at.bitfire.davdroid.util.DavUtils.parent
|
||||
import dagger.assisted.Assisted
|
||||
import dagger.assisted.AssistedFactory
|
||||
import dagger.assisted.AssistedInject
|
||||
import okhttp3.HttpUrl
|
||||
import okhttp3.OkHttpClient
|
||||
import java.util.logging.Level
|
||||
import java.util.logging.Logger
|
||||
|
||||
/**
|
||||
* ServiceRefresher is used to discover and save home sets of a given service.
|
||||
*/
|
||||
class ServiceRefresher @AssistedInject constructor(
|
||||
@Assisted private val service: Service,
|
||||
@Assisted private val httpClient: OkHttpClient,
|
||||
private val logger: Logger,
|
||||
private val homeSetRepository: DavHomeSetRepository
|
||||
) {
|
||||
|
||||
@AssistedFactory
|
||||
interface Factory {
|
||||
fun create(service: Service, httpClient: OkHttpClient): ServiceRefresher
|
||||
}
|
||||
|
||||
/**
|
||||
* Home-set class to use depending on the given service type.
|
||||
*/
|
||||
private val homeSetClass: Class<out HrefListProperty> =
|
||||
when (service.type) {
|
||||
Service.TYPE_CARDDAV -> AddressbookHomeSet::class.java
|
||||
Service.TYPE_CALDAV -> CalendarHomeSet::class.java
|
||||
else -> throw IllegalArgumentException()
|
||||
}
|
||||
|
||||
/**
|
||||
* Home-set properties to ask for in a PROPFIND request to the principal URL,
|
||||
* depending on the given service type.
|
||||
*/
|
||||
private val homeSetProperties: Array<Property.Name> =
|
||||
arrayOf( // generic WebDAV properties
|
||||
WebDAV.DisplayName,
|
||||
WebDAV.GroupMembership,
|
||||
WebDAV.ResourceType
|
||||
) + when (service.type) { // service-specific CalDAV/CardDAV properties
|
||||
Service.TYPE_CARDDAV -> arrayOf(
|
||||
CardDAV.AddressbookHomeSet,
|
||||
)
|
||||
|
||||
Service.TYPE_CALDAV -> arrayOf(
|
||||
CalDAV.CalendarHomeSet,
|
||||
CalDAV.CalendarProxyReadFor,
|
||||
CalDAV.CalendarProxyWriteFor
|
||||
)
|
||||
|
||||
else -> throw IllegalArgumentException()
|
||||
}
|
||||
|
||||
/**
|
||||
* Starting at given principal URL, tries to recursively find and save all user relevant home sets.
|
||||
*
|
||||
* @param principalUrl URL of principal to query (user-provided principal or current-user-principal)
|
||||
* @param level Current recursion level (limited to 0, 1 or 2):
|
||||
* - 0: We assume found home sets belong to the current-user-principal
|
||||
* - 1 or 2: We assume found home sets don't directly belong to the current-user-principal
|
||||
* @param alreadyQueriedPrincipals The HttpUrls of principals which have been queried already, to avoid querying principals more than once.
|
||||
* @param alreadySavedHomeSets The HttpUrls of home sets which have been saved to database already, to avoid saving home sets
|
||||
* more than once, which could overwrite the already set "personal" flag with `false`.
|
||||
*
|
||||
* @throws java.io.IOException on I/O errors
|
||||
* @throws HttpException on HTTP errors
|
||||
* @throws at.bitfire.dav4jvm.exception.DavException on application-level or logical errors
|
||||
*/
|
||||
internal fun discoverHomesets(
|
||||
principalUrl: HttpUrl,
|
||||
level: Int = 0,
|
||||
alreadyQueriedPrincipals: MutableSet<HttpUrl> = mutableSetOf(),
|
||||
alreadySavedHomeSets: MutableSet<HttpUrl> = mutableSetOf()
|
||||
) {
|
||||
logger.fine("Discovering homesets of $principalUrl")
|
||||
val relatedResources = mutableSetOf<HttpUrl>()
|
||||
|
||||
// Query the URL
|
||||
val principal = DavResource(httpClient, principalUrl)
|
||||
val personal = level == 0
|
||||
try {
|
||||
principal.propfind(0, *homeSetProperties) { davResponse, _ ->
|
||||
alreadyQueriedPrincipals += davResponse.href
|
||||
|
||||
// If response holds home sets, save them
|
||||
davResponse[homeSetClass]?.let { homeSets ->
|
||||
for (homeSetHref in homeSets.hrefs)
|
||||
principal.location.resolve(homeSetHref)?.let { homesetUrl ->
|
||||
val resolvedHomeSetUrl = UrlUtils.withTrailingSlash(homesetUrl)
|
||||
if (!alreadySavedHomeSets.contains(resolvedHomeSetUrl)) {
|
||||
homeSetRepository.insertOrUpdateByUrlBlocking(
|
||||
// HomeSet is considered personal if this is the outer recursion call,
|
||||
// This is because we assume the first call to query the current-user-principal
|
||||
// Note: This is not be be confused with the DAV:owner attribute. Home sets can be owned by
|
||||
// other principals while still being considered "personal" (belonging to the current-user-principal)
|
||||
// and an owned home set need not always be personal either.
|
||||
HomeSet(0, service.id, personal, resolvedHomeSetUrl)
|
||||
)
|
||||
alreadySavedHomeSets += resolvedHomeSetUrl
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Add related principals to be queried afterwards
|
||||
if (personal) {
|
||||
val relatedResourcesTypes = listOf(
|
||||
// current resource is a read/write-proxy for other principals
|
||||
CalendarProxyReadFor::class.java,
|
||||
CalendarProxyWriteFor::class.java,
|
||||
// current resource is a member of a group (principal that can also have proxies)
|
||||
GroupMembership::class.java
|
||||
)
|
||||
for (type in relatedResourcesTypes)
|
||||
davResponse[type]?.let {
|
||||
for (href in it.hrefs)
|
||||
principal.location.resolve(href)?.let { url ->
|
||||
relatedResources += url
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// If current resource is a calendar-proxy-read/write, it's likely that its parent is a principal, too.
|
||||
davResponse[ResourceType::class.java]?.let { resourceType ->
|
||||
val proxyProperties = arrayOf(
|
||||
CalDAV.CalendarProxyRead,
|
||||
CalDAV.CalendarProxyWrite
|
||||
)
|
||||
if (proxyProperties.any { resourceType.types.contains(it) })
|
||||
relatedResources += davResponse.href.parent()
|
||||
}
|
||||
}
|
||||
} catch (e: HttpException) {
|
||||
if (e.isClientError)
|
||||
logger.log(Level.INFO, "Ignoring Client Error 4xx while looking for ${service.type} home sets", e)
|
||||
else
|
||||
throw e
|
||||
}
|
||||
|
||||
// query related resources
|
||||
if (level <= 1)
|
||||
for (resource in relatedResources)
|
||||
if (alreadyQueriedPrincipals.contains(resource))
|
||||
logger.warning("$resource already queried, skipping")
|
||||
else
|
||||
discoverHomesets(
|
||||
principalUrl = resource,
|
||||
level = level + 1,
|
||||
alreadyQueriedPrincipals = alreadyQueriedPrincipals,
|
||||
alreadySavedHomeSets = alreadySavedHomeSets
|
||||
)
|
||||
}
|
||||
|
||||
}
|
||||
@@ -18,6 +18,7 @@ import at.bitfire.davdroid.sync.AutomaticSyncManager
|
||||
import at.bitfire.davdroid.sync.SyncDataType
|
||||
import at.bitfire.davdroid.sync.account.InvalidAccountException
|
||||
import at.bitfire.davdroid.sync.account.setAndVerifyUserData
|
||||
import at.bitfire.davdroid.util.SensitiveString.Companion.toSensitiveString
|
||||
import at.bitfire.davdroid.util.trimToNull
|
||||
import at.bitfire.vcard4android.GroupMethod
|
||||
import dagger.assisted.Assisted
|
||||
@@ -106,7 +107,7 @@ class AccountSettings @AssistedInject constructor(
|
||||
|
||||
fun credentials() = Credentials(
|
||||
accountManager.getUserData(account, KEY_USERNAME),
|
||||
accountManager.getPassword(account)?.toCharArray(),
|
||||
accountManager.getPassword(account)?.toSensitiveString(),
|
||||
|
||||
accountManager.getUserData(account, KEY_CERTIFICATE_ALIAS),
|
||||
|
||||
@@ -118,7 +119,7 @@ class AccountSettings @AssistedInject constructor(
|
||||
fun credentials(credentials: Credentials) {
|
||||
// Basic/Digest auth
|
||||
accountManager.setAndVerifyUserData(account, KEY_USERNAME, credentials.username)
|
||||
accountManager.setPassword(account, credentials.password?.concatToString())
|
||||
accountManager.setPassword(account, credentials.password?.asString())
|
||||
|
||||
// client certificate
|
||||
accountManager.setAndVerifyUserData(account, KEY_CERTIFICATE_ALIAS, credentials.certificateAlias)
|
||||
@@ -354,7 +355,12 @@ class AccountSettings @AssistedInject constructor(
|
||||
|
||||
companion object {
|
||||
|
||||
const val CURRENT_VERSION = 20
|
||||
/**
|
||||
* Current (usually the newest) account settings version. It's used to
|
||||
* determine whether a migration ([AccountSettingsMigration])
|
||||
* should be performed.
|
||||
*/
|
||||
const val CURRENT_VERSION = 21
|
||||
const val KEY_SETTINGS_VERSION = "version"
|
||||
|
||||
const val KEY_SYNC_INTERVAL_ADDRESSBOOKS = "sync_interval_addressbooks"
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
|
||||
package at.bitfire.davdroid.settings
|
||||
|
||||
import at.bitfire.davdroid.util.SensitiveString
|
||||
import net.openid.appauth.AuthState
|
||||
|
||||
/**
|
||||
@@ -16,7 +17,7 @@ data class Credentials(
|
||||
/** username for Basic / Digest auth */
|
||||
val username: String? = null,
|
||||
/** password for Basic / Digest auth */
|
||||
val password: CharArray? = null,
|
||||
val password: SensitiveString? = null,
|
||||
|
||||
/** alias of an client certificate that is present on the system */
|
||||
val certificateAlias: String? = null,
|
||||
@@ -42,27 +43,4 @@ data class Credentials(
|
||||
return "Credentials(" + s.joinToString(", ") + ")"
|
||||
}
|
||||
|
||||
|
||||
override fun equals(other: Any?): Boolean {
|
||||
if (this === other) return true
|
||||
if (javaClass != other?.javaClass) return false
|
||||
|
||||
other as Credentials
|
||||
|
||||
if (username != other.username) return false
|
||||
if (!password.contentEquals(other.password)) return false
|
||||
if (certificateAlias != other.certificateAlias) return false
|
||||
if (authState != other.authState) return false
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
override fun hashCode(): Int {
|
||||
var result = username?.hashCode() ?: 0
|
||||
result = 31 * result + (password?.contentHashCode() ?: 0)
|
||||
result = 31 * result + (certificateAlias?.hashCode() ?: 0)
|
||||
result = 31 * result + (authState?.hashCode() ?: 0)
|
||||
return result
|
||||
}
|
||||
|
||||
}
|
||||
@@ -10,7 +10,8 @@ import at.bitfire.davdroid.settings.AccountSettings
|
||||
interface AccountSettingsMigration {
|
||||
|
||||
/**
|
||||
* Migrate the account settings from the old version to the new version.
|
||||
* Migrate the account settings from the old version to the new version which
|
||||
* is set in [AccountSettings.CURRENT_VERSION].
|
||||
*
|
||||
* **The new (target) version number is registered in the Hilt module as [Int] key of the multi-binding of [AccountSettings].**
|
||||
*
|
||||
|
||||
@@ -12,7 +12,7 @@ import android.provider.CalendarContract.Calendars
|
||||
import android.provider.CalendarContract.Reminders
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.core.content.contentValuesOf
|
||||
import at.bitfire.davdroid.resource.LocalTask
|
||||
import at.bitfire.ical4android.DmfsTask
|
||||
import at.bitfire.ical4android.TaskProvider
|
||||
import at.techbee.jtx.JtxContract.asSyncAdapter
|
||||
import dagger.Binds
|
||||
@@ -39,7 +39,7 @@ class AccountSettingsMigration10 @Inject constructor(
|
||||
override fun migrate(account: Account) {
|
||||
TaskProvider.acquire(context, TaskProvider.ProviderName.OpenTasks)?.use { provider ->
|
||||
val tasksUri = provider.tasksUri().asSyncAdapter(account)
|
||||
val emptyETag = contentValuesOf(LocalTask.COLUMN_ETAG to null)
|
||||
val emptyETag = contentValuesOf(DmfsTask.COLUMN_ETAG to null)
|
||||
provider.client.update(tasksUri, emptyETag, "${TaskContract.Tasks._DIRTY}=0 AND ${TaskContract.Tasks._DELETED}=0", null)
|
||||
}
|
||||
|
||||
|
||||
@@ -12,8 +12,8 @@ import android.provider.CalendarContract
|
||||
import android.util.Base64
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.core.content.contentValuesOf
|
||||
import at.bitfire.ical4android.AndroidEvent
|
||||
import at.bitfire.ical4android.UnknownProperty
|
||||
import at.bitfire.synctools.storage.calendar.EventsContract
|
||||
import at.techbee.jtx.JtxContract.asSyncAdapter
|
||||
import dagger.Binds
|
||||
import dagger.Module
|
||||
@@ -69,7 +69,7 @@ class AccountSettingsMigration12 @Inject constructor(
|
||||
val property = UnknownProperty.fromJsonString(rawValue)
|
||||
if (property is Url) { // rewrite to MIMETYPE_URL
|
||||
val newValues = contentValuesOf(
|
||||
CalendarContract.ExtendedProperties.NAME to AndroidEvent.EXTNAME_URL,
|
||||
CalendarContract.ExtendedProperties.NAME to EventsContract.EXTNAME_URL,
|
||||
CalendarContract.ExtendedProperties.VALUE to property.value
|
||||
)
|
||||
provider.update(uri, newValues, null, null)
|
||||
@@ -77,7 +77,7 @@ class AccountSettingsMigration12 @Inject constructor(
|
||||
} catch (e: Exception) {
|
||||
logger.log(
|
||||
Level.WARNING,
|
||||
"Couldn't rewrite URL from unknown property to ${AndroidEvent.EXTNAME_URL}",
|
||||
"Couldn't rewrite URL from unknown property to ${EventsContract.EXTNAME_URL}",
|
||||
e
|
||||
)
|
||||
}
|
||||
|
||||
@@ -65,12 +65,7 @@ class AccountSettingsMigration20 @Inject constructor(
|
||||
|
||||
@OpenForTesting
|
||||
internal fun migrateAddressBooks(account: Account, cardDavServiceId: Long) {
|
||||
try {
|
||||
addressBookStore.acquireContentProvider()
|
||||
} catch (_: SecurityException) {
|
||||
// no contacts permission
|
||||
null
|
||||
}?.use { provider ->
|
||||
addressBookStore.acquireContentProvider()?.use { provider ->
|
||||
for (addressBook in addressBookStore.getAll(account, provider)) {
|
||||
val url = accountManager.getUserData(addressBook.addressBookAccount, ADDRESS_BOOK_USER_DATA_URL) ?: continue
|
||||
val collection = collectionRepository.getByServiceAndUrl(cardDavServiceId, url) ?: continue
|
||||
@@ -81,12 +76,7 @@ class AccountSettingsMigration20 @Inject constructor(
|
||||
|
||||
@OpenForTesting
|
||||
internal fun migrateCalendars(account: Account, calDavServiceId: Long) {
|
||||
try {
|
||||
calendarStore.acquireContentProvider()
|
||||
} catch (_: SecurityException) {
|
||||
// no contacts permission
|
||||
null
|
||||
}?.use { client ->
|
||||
calendarStore.acquireContentProvider()?.use { client ->
|
||||
val calendarProvider = AndroidCalendarProvider(account, client)
|
||||
// for each calendar, assign _SYNC_ID := ID if collection (identified by NAME field = URL)
|
||||
for (calendar in calendarProvider.findCalendars()) {
|
||||
@@ -104,18 +94,13 @@ class AccountSettingsMigration20 @Inject constructor(
|
||||
@OpenForTesting
|
||||
internal fun migrateTaskLists(account: Account, calDavServiceId: Long) {
|
||||
val taskListStore = tasksAppManager.getDataStore() ?: /* no tasks app */ return
|
||||
try {
|
||||
taskListStore.acquireContentProvider()
|
||||
} catch (_: SecurityException) {
|
||||
// no tasks permission
|
||||
null
|
||||
}?.use { provider ->
|
||||
taskListStore.acquireContentProvider()?.use { provider ->
|
||||
for (taskList in taskListStore.getAll(account, provider)) {
|
||||
when (taskList) {
|
||||
is LocalTaskList -> { // tasks.org, OpenTasks
|
||||
val url = taskList.syncId ?: continue
|
||||
val url = taskList.dmfsTaskList.syncId ?: continue
|
||||
collectionRepository.getByServiceAndUrl(calDavServiceId, url)?.let { collection ->
|
||||
taskList.update(contentValuesOf(
|
||||
taskList.dmfsTaskList.update(contentValuesOf(
|
||||
TaskLists._SYNC_ID to collection.id.toString()
|
||||
))
|
||||
}
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
/*
|
||||
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
|
||||
*/
|
||||
|
||||
package at.bitfire.davdroid.settings.migration
|
||||
|
||||
import android.accounts.Account
|
||||
import android.content.ContentResolver
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import android.provider.ContactsContract
|
||||
import at.bitfire.davdroid.resource.LocalAddressBookStore
|
||||
import at.bitfire.davdroid.sync.SyncDataType
|
||||
import dagger.Binds
|
||||
import dagger.Module
|
||||
import dagger.hilt.InstallIn
|
||||
import dagger.hilt.components.SingletonComponent
|
||||
import dagger.multibindings.IntKey
|
||||
import dagger.multibindings.IntoMap
|
||||
import java.util.logging.Logger
|
||||
import javax.inject.Inject
|
||||
|
||||
/**
|
||||
* On Android 14+ the pending sync state of the Sync Adapter Framework is not handled correctly.
|
||||
* As a workaround we cancel incoming sync requests (clears pending flag) after enqueuing our own
|
||||
* sync worker (work manager). With version 4.5.3 we started cancelling pending syncs for DAVx5
|
||||
* accounts, but forgot to do that for address book accounts. With version 4.5.4 we also cancel
|
||||
* those, but only when contact data of an address book has been edited.
|
||||
*
|
||||
* This migration cancels (once only) any possibly still wrongly pending address book and calendar
|
||||
* (+tasks) account syncs.
|
||||
*/
|
||||
class AccountSettingsMigration21 @Inject constructor(
|
||||
private val localAddressBookStore: LocalAddressBookStore,
|
||||
private val logger: Logger
|
||||
): AccountSettingsMigration {
|
||||
|
||||
/**
|
||||
* Cancel any possibly forever pending account syncs of the different authorities
|
||||
*/
|
||||
override fun migrate(account: Account) {
|
||||
if (Build.VERSION.SDK_INT >= 34) {
|
||||
// Request new dummy syncs (yes, seems like this is needed)
|
||||
val extras = Bundle().apply {
|
||||
putBoolean(ContentResolver.SYNC_EXTRAS_MANUAL, true)
|
||||
putBoolean(ContentResolver.SYNC_EXTRAS_EXPEDITED, true)
|
||||
}
|
||||
|
||||
// Request calendar and tasks syncs and cancel all syncs account wide
|
||||
val possibleAuthorities = SyncDataType.EVENTS.possibleAuthorities() +
|
||||
SyncDataType.TASKS.possibleAuthorities()
|
||||
for (authority in possibleAuthorities) {
|
||||
ContentResolver.requestSync(account, authority, extras)
|
||||
logger.info("Android 14+: Canceling all (possibly forever pending) sync adapter syncs for $authority and $account")
|
||||
// Ensure the sync framework processes the request right away
|
||||
ContentResolver.isSyncPending(account, authority)
|
||||
// Cancel the sync
|
||||
ContentResolver.cancelSync(account, null) // Ignores possibly set sync extras
|
||||
}
|
||||
|
||||
// Request contacts sync (per address book account) and cancel all syncs address book account wide
|
||||
val addressBookAccounts = localAddressBookStore.getAddressBookAccounts(account) + account
|
||||
for (addressBookAccount in addressBookAccounts) {
|
||||
ContentResolver.requestSync(addressBookAccount, ContactsContract.AUTHORITY, extras)
|
||||
logger.info("Android 14+: Canceling all (possibly forever pending) sync adapter syncs for $addressBookAccount")
|
||||
// Ensure the sync framework processes the request right away
|
||||
ContentResolver.isSyncPending(account, ContactsContract.AUTHORITY)
|
||||
// Cancel the sync
|
||||
ContentResolver.cancelSync(addressBookAccount, null) // Ignores possibly set sync extras
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@Module
|
||||
@InstallIn(SingletonComponent::class)
|
||||
abstract class AccountSettingsMigrationModule {
|
||||
@Binds @IntoMap
|
||||
@IntKey(21)
|
||||
abstract fun provide(impl: AccountSettingsMigration21): AccountSettingsMigration
|
||||
}
|
||||
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user