mirror of
https://github.com/bitfireAT/davx5-ose.git
synced 2026-01-27 16:18:51 -05:00
Compare commits
20 Commits
reuse-http
...
testing-sy
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7f36e826d8 | ||
|
|
3737d69397 | ||
|
|
2dbd5c02b6 | ||
|
|
c12e9311f7 | ||
|
|
b663912feb | ||
|
|
3c484f253f | ||
|
|
de7f8d2964 | ||
|
|
a79a39c25d | ||
|
|
20675ed71b | ||
|
|
881588f8e8 | ||
|
|
0c31758880 | ||
|
|
9ffd59cd00 | ||
|
|
c40b2b38bc | ||
|
|
3025ea7491 | ||
|
|
b84a812d7a | ||
|
|
562afc5666 | ||
|
|
8992859b63 | ||
|
|
03013b5576 | ||
|
|
0028fc8722 | ||
|
|
1b4ebde896 |
4
.github/CODEOWNERS
vendored
4
.github/CODEOWNERS
vendored
@@ -1,4 +0,0 @@
|
|||||||
# 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
@@ -9,24 +9,6 @@ updates:
|
|||||||
interval: "weekly"
|
interval: "weekly"
|
||||||
commit-message:
|
commit-message:
|
||||||
prefix: "[CI] "
|
prefix: "[CI] "
|
||||||
labels:
|
|
||||||
- "github_actions"
|
|
||||||
- "dependencies"
|
|
||||||
groups:
|
groups:
|
||||||
ci-actions:
|
ci-actions:
|
||||||
patterns: ["*"]
|
patterns: ["*"]
|
||||||
|
|
||||||
- package-ecosystem: "gradle"
|
|
||||||
directory: "/"
|
|
||||||
schedule:
|
|
||||||
interval: "weekly"
|
|
||||||
labels: # don't create "java" label (default for gradle ecosystem)
|
|
||||||
- "dependencies"
|
|
||||||
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"
|
|
||||||
26
.github/workflows/codeql.yml
vendored
26
.github/workflows/codeql.yml
vendored
@@ -8,7 +8,6 @@ on:
|
|||||||
branches: [ main-ose ]
|
branches: [ main-ose ]
|
||||||
schedule:
|
schedule:
|
||||||
- cron: '22 10 * * 1'
|
- cron: '22 10 * * 1'
|
||||||
|
|
||||||
concurrency:
|
concurrency:
|
||||||
group: codeql-${{ github.ref }}
|
group: codeql-${{ github.ref }}
|
||||||
cancel-in-progress: true
|
cancel-in-progress: true
|
||||||
@@ -22,29 +21,38 @@ jobs:
|
|||||||
contents: read
|
contents: read
|
||||||
security-events: write
|
security-events: write
|
||||||
|
|
||||||
|
strategy:
|
||||||
|
fail-fast: false
|
||||||
|
matrix:
|
||||||
|
language: [ 'java' ]
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@v6
|
uses: actions/checkout@v5
|
||||||
- uses: actions/setup-java@v5
|
- uses: actions/setup-java@v5
|
||||||
with:
|
with:
|
||||||
distribution: temurin
|
distribution: temurin
|
||||||
java-version: 21
|
java-version: 21
|
||||||
- uses: gradle/actions/setup-gradle@v5
|
- uses: gradle/actions/setup-gradle@v4
|
||||||
with:
|
with:
|
||||||
cache-encryption-key: ${{ secrets.gradle_encryption_key }}
|
cache-encryption-key: ${{ secrets.gradle_encryption_key }}
|
||||||
cache-read-only: true # gradle user home cache is generated by test jobs
|
cache-read-only: true # gradle user home cache is generated by test jobs
|
||||||
|
|
||||||
# Initializes the CodeQL tools for scanning.
|
# Initializes the CodeQL tools for scanning.
|
||||||
- name: Initialize CodeQL
|
- name: Initialize CodeQL
|
||||||
uses: github/codeql-action/init@v4
|
uses: github/codeql-action/init@v3
|
||||||
with:
|
with:
|
||||||
languages: java-kotlin
|
languages: ${{ matrix.language }}
|
||||||
build-mode: manual # autobuild uses older JDK
|
|
||||||
|
|
||||||
- name: Build # we must not use build cache here
|
# Autobuild attempts to build any compiled languages (C/C++, C#, Go, or Java).
|
||||||
run: ./gradlew --no-daemon --configuration-cache app:assembleDebug
|
# If this step fails, then you should remove it and run the build manually (see below)
|
||||||
|
#- name: Autobuild
|
||||||
|
# uses: github/codeql-action/autobuild@v2
|
||||||
|
|
||||||
|
- name: Build
|
||||||
|
run: ./gradlew --build-cache --configuration-cache --no-daemon app:assembleOseDebug
|
||||||
|
|
||||||
- name: Perform CodeQL Analysis
|
- name: Perform CodeQL Analysis
|
||||||
uses: github/codeql-action/analyze@v4
|
uses: github/codeql-action/analyze@v3
|
||||||
with:
|
with:
|
||||||
category: "/language:${{matrix.language}}"
|
category: "/language:${{matrix.language}}"
|
||||||
|
|||||||
24
.github/workflows/dependency-submission.yml
vendored
24
.github/workflows/dependency-submission.yml
vendored
@@ -1,24 +0,0 @@
|
|||||||
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
Normal file
55
.github/workflows/dependent-issues.yml
vendored
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
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 }}
|
||||||
4
.github/workflows/release.yml
vendored
4
.github/workflows/release.yml
vendored
@@ -19,12 +19,12 @@ jobs:
|
|||||||
discussions: write
|
discussions: write
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v6
|
- uses: actions/checkout@v5
|
||||||
- uses: actions/setup-java@v5
|
- uses: actions/setup-java@v5
|
||||||
with:
|
with:
|
||||||
distribution: temurin
|
distribution: temurin
|
||||||
java-version: 21
|
java-version: 21
|
||||||
- uses: gradle/actions/setup-gradle@v5
|
- uses: gradle/actions/setup-gradle@v4
|
||||||
|
|
||||||
- name: Prepare keystore
|
- name: Prepare keystore
|
||||||
run: echo ${{ secrets.android_keystore_base64 }} | base64 -d >$GITHUB_WORKSPACE/keystore.jks
|
run: echo ${{ secrets.android_keystore_base64 }} | base64 -d >$GITHUB_WORKSPACE/keystore.jks
|
||||||
|
|||||||
100
.github/workflows/test-dev.yml
vendored
100
.github/workflows/test-dev.yml
vendored
@@ -9,128 +9,74 @@ concurrency:
|
|||||||
group: test-dev-${{ github.ref }}
|
group: test-dev-${{ github.ref }}
|
||||||
cancel-in-progress: true
|
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.
|
|
||||||
#
|
|
||||||
# Note: The secrets are not available for forks and Dependabot PRs.
|
|
||||||
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:
|
jobs:
|
||||||
compile:
|
compile:
|
||||||
name: Compile
|
name: Compile for build cache
|
||||||
|
if: ${{ github.ref == 'refs/heads/main-ose' }}
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v6
|
- uses: actions/checkout@v5
|
||||||
- uses: actions/setup-java@v5
|
- uses: actions/setup-java@v5
|
||||||
with:
|
with:
|
||||||
distribution: temurin
|
distribution: temurin
|
||||||
java-version: 21
|
java-version: 21
|
||||||
|
|
||||||
# See https://community.gradle.org/github-actions/docs/setup-gradle/ for more information
|
# See https://community.gradle.org/github-actions/docs/setup-gradle/ for more information
|
||||||
- uses: gradle/actions/setup-gradle@v5
|
- uses: gradle/actions/setup-gradle@v4 # creates build cache when on main branch
|
||||||
with:
|
with:
|
||||||
cache-encryption-key: ${{ secrets.gradle_encryption_key }}
|
cache-encryption-key: ${{ secrets.gradle_encryption_key }}
|
||||||
cache-read-only: false # allow branches to update their configuration cache
|
dependency-graph: generate-and-submit # submit Github Dependency Graph info
|
||||||
gradle-home-cache-excludes: caches/build-cache-1 # don't cache local build cache because we use a remote cache
|
|
||||||
|
|
||||||
- name: Cache Android environment
|
- run: ./gradlew --build-cache --configuration-cache app:compileOseDebugSource
|
||||||
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') }}
|
|
||||||
|
|
||||||
- name: Compile
|
test:
|
||||||
run: ./gradlew app:compileOseDebugSource
|
|
||||||
|
|
||||||
# Cache configurations for the other jobs (including assemble for CodeQL)
|
|
||||||
- name: Populate configuration cache
|
|
||||||
run: |
|
|
||||||
./gradlew --dry-run app:assembleDebug
|
|
||||||
./gradlew --dry-run app:lintOseDebug
|
|
||||||
./gradlew --dry-run app:testOseDebugUnitTest
|
|
||||||
./gradlew --dry-run app:virtualOseDebugAndroidTest
|
|
||||||
|
|
||||||
unit_tests:
|
|
||||||
needs: compile
|
needs: compile
|
||||||
|
if: ${{ !cancelled() }} # even if compile didn't run (because not on main branch)
|
||||||
name: Lint and unit tests
|
name: Lint and unit tests
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v6
|
- uses: actions/checkout@v5
|
||||||
- uses: actions/setup-java@v5
|
- uses: actions/setup-java@v5
|
||||||
with:
|
with:
|
||||||
distribution: temurin
|
distribution: temurin
|
||||||
java-version: 21
|
java-version: 21
|
||||||
- uses: gradle/actions/setup-gradle@v5
|
- uses: gradle/actions/setup-gradle@v4
|
||||||
with:
|
with:
|
||||||
cache-encryption-key: ${{ secrets.gradle_encryption_key }}
|
cache-encryption-key: ${{ secrets.gradle_encryption_key }}
|
||||||
cache-read-only: true
|
cache-read-only: true
|
||||||
|
|
||||||
- name: Restore Android environment
|
- name: Run lint
|
||||||
uses: actions/cache/restore@v5
|
run: ./gradlew --build-cache --configuration-cache app:lintOseDebug
|
||||||
with:
|
- name: Run unit tests
|
||||||
path: ~/.config/.android
|
run: ./gradlew --build-cache --configuration-cache app:testOseDebugUnitTest
|
||||||
key: android-${{ hashFiles('app/build.gradle.kts') }}
|
|
||||||
|
|
||||||
- name: Lint checks
|
test_on_emulator:
|
||||||
run: ./gradlew app:lintOseDebug
|
|
||||||
|
|
||||||
- name: Unit tests
|
|
||||||
run: ./gradlew app:testOseDebugUnitTest
|
|
||||||
|
|
||||||
instrumented_tests:
|
|
||||||
needs: compile
|
needs: compile
|
||||||
|
if: ${{ !cancelled() }} # even if compile didn't run (because not on main branch)
|
||||||
name: Instrumented tests
|
name: Instrumented tests
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v6
|
- uses: actions/checkout@v5
|
||||||
- uses: actions/setup-java@v5
|
- uses: actions/setup-java@v5
|
||||||
with:
|
with:
|
||||||
distribution: temurin
|
distribution: temurin
|
||||||
java-version: 21
|
java-version: 21
|
||||||
- uses: gradle/actions/setup-gradle@v5
|
- uses: gradle/actions/setup-gradle@v4
|
||||||
with:
|
with:
|
||||||
cache-encryption-key: ${{ secrets.gradle_encryption_key }}
|
cache-encryption-key: ${{ secrets.gradle_encryption_key }}
|
||||||
cache-read-only: true
|
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
|
- name: Enable KVM group perms
|
||||||
run: |
|
run: |
|
||||||
echo 'KERNEL=="kvm", GROUP="kvm", MODE="0666", OPTIONS+="static_node=kvm"' | sudo tee /etc/udev/rules.d/99-kvm4all.rules
|
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 control --reload-rules
|
||||||
sudo udevadm trigger --name-match=kvm
|
sudo udevadm trigger --name-match=kvm
|
||||||
|
|
||||||
- name: Instrumented tests
|
|
||||||
run: ./gradlew app:virtualOseDebugAndroidTest
|
|
||||||
|
|
||||||
- name: Cache AVD
|
- name: Cache AVD
|
||||||
uses: actions/cache/save@v5
|
uses: actions/cache@v4
|
||||||
if: steps.restore-avd.outputs.cache-hit != 'true'
|
|
||||||
with:
|
with:
|
||||||
path: ~/.config/.android/avd # where AVD is stored
|
path: ~/.config/.android/avd
|
||||||
key: avd-${{ hashFiles('app/build.gradle.kts') }} # gradle-managed devices are defined there
|
key: avd-${{ hashFiles('app/build.gradle.kts') }} # gradle-managed devices are defined there
|
||||||
|
|
||||||
|
- name: Run device tests
|
||||||
|
run: ./gradlew --build-cache --configuration-cache app:virtualCheck
|
||||||
|
|||||||
4
.gitignore
vendored
4
.gitignore
vendored
@@ -16,6 +16,10 @@
|
|||||||
bin/
|
bin/
|
||||||
gen/
|
gen/
|
||||||
|
|
||||||
|
# Gradle files
|
||||||
|
.gradle/
|
||||||
|
build/
|
||||||
|
|
||||||
# Local configuration file (sdk path, etc)
|
# Local configuration file (sdk path, etc)
|
||||||
local.properties
|
local.properties
|
||||||
|
|
||||||
|
|||||||
30
.tx/config
Normal file
30
.tx/config
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
[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
|
||||||
|
|
||||||
|
[o:bitfireAT:p:davx5:r:app]
|
||||||
|
file_filter = app/src/main/res/values-<lang>/strings.xml
|
||||||
|
source_file = app/src/main/res/values/strings.xml
|
||||||
|
source_lang = en
|
||||||
|
type = ANDROID
|
||||||
|
minimum_perc = 20
|
||||||
|
resource_name = App strings (all flavors)
|
||||||
|
|
||||||
|
|
||||||
|
# Attention: fastlane directories are like "en-us", not "en-rUS"!
|
||||||
|
|
||||||
|
[o:bitfireAT:p:davx5:r:metadata-short-description]
|
||||||
|
file_filter = fastlane/metadata/android/<lang>/short_description.txt
|
||||||
|
source_file = fastlane/metadata/android/en-US/short_description.txt
|
||||||
|
source_lang = en
|
||||||
|
type = TXT
|
||||||
|
minimum_perc = 100
|
||||||
|
resource_name = Metadata: short description
|
||||||
|
|
||||||
|
[o:bitfireAT:p:davx5:r:metadata-full-description]
|
||||||
|
file_filter = fastlane/metadata/android/<lang>/full_description.txt
|
||||||
|
source_file = fastlane/metadata/android/en-US/full_description.txt
|
||||||
|
source_lang = en
|
||||||
|
type = TXT
|
||||||
|
minimum_perc = 100
|
||||||
|
resource_name = Metadata: full description
|
||||||
@@ -14,11 +14,24 @@ If you send us a pull request, our CLA bot will ask you to sign the
|
|||||||
Contributor's License Agreement so that we can use your contribution.
|
Contributor's License Agreement so that we can use your contribution.
|
||||||
|
|
||||||
|
|
||||||
# Copyright notice
|
# Copyright
|
||||||
|
|
||||||
Make sure that every file that contains significant work (at least every code file)
|
Make sure that every file that contains significant work (at least every code file)
|
||||||
starts with the copyright header. Android Studio should do so automatically because the
|
starts with the copyright header:
|
||||||
configuration is stored in the repository (`.idea/copyright`).
|
|
||||||
|
```
|
||||||
|
/*
|
||||||
|
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
|
||||||
|
*/
|
||||||
|
```
|
||||||
|
|
||||||
|
You can set this in Android Studio:
|
||||||
|
|
||||||
|
1. Settings / Editor / Copyright / Copyright Profiles
|
||||||
|
2. Paste the text above (without the stars).
|
||||||
|
3. Set Formatting so that the preview exactly looks like above; one blank line after the block.
|
||||||
|
4. Set this copyright profile as the default profile for the project.
|
||||||
|
5. Apply copyright: right-click in file tree / Update copyright.
|
||||||
|
|
||||||
|
|
||||||
# Style guide
|
# Style guide
|
||||||
@@ -97,3 +110,8 @@ Test classes should be in the appropriate directory (see existing tests) and in
|
|||||||
tested class. Tests are usually be named like `methodToBeTested_Condition()`, see
|
tested class. Tests are usually be named like `methodToBeTested_Condition()`, see
|
||||||
[Test apps on Android](https://developer.android.com/training/testing/).
|
[Test apps on Android](https://developer.android.com/training/testing/).
|
||||||
|
|
||||||
|
|
||||||
|
# Authors
|
||||||
|
|
||||||
|
If you make significant contributions, feel free to add yourself to the [AUTHORS file](AUTHORS).
|
||||||
|
|
||||||
|
|||||||
12
README.md
12
README.md
@@ -1,9 +1,9 @@
|
|||||||
|
|
||||||
[](https://fosstodon.org/@davx5app)
|
|
||||||
[](https://www.davx5.com/)
|
[](https://www.davx5.com/)
|
||||||
[](https://github.com/bitfireAT/davx5-ose/blob/main/LICENSE)
|
|
||||||
[](https://f-droid.org/packages/at.bitfire.davdroid/)
|
[](https://f-droid.org/packages/at.bitfire.davdroid/)
|
||||||

|
[](https://github.com/bitfireAT/davx5-ose/blob/main/LICENSE)
|
||||||
|
[](https://fosstodon.org/@davx5app)
|
||||||
|
[](https://github.com/bitfireAT/davx5-ose/actions/workflows/test-dev.yml)
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
@@ -11,10 +11,8 @@
|
|||||||
DAVx⁵
|
DAVx⁵
|
||||||
========
|
========
|
||||||
|
|
||||||
> [!IMPORTANT]
|
Please see the [DAVx⁵ Web site](https://www.davx5.com) for
|
||||||
> Please see the [DAVx⁵ Web site](https://www.davx5.com) for
|
comprehensive information about DAVx⁵, including a list of services it has been tested with.
|
||||||
> comprehensive information about DAVx⁵, including a list of services it has been tested with,
|
|
||||||
> a manual and FAQ.
|
|
||||||
|
|
||||||
DAVx⁵ is licensed under the [GPLv3 License](LICENSE).
|
DAVx⁵ is licensed under the [GPLv3 License](LICENSE).
|
||||||
|
|
||||||
|
|||||||
@@ -6,9 +6,10 @@ plugins {
|
|||||||
alias(libs.plugins.android.application)
|
alias(libs.plugins.android.application)
|
||||||
alias(libs.plugins.compose.compiler)
|
alias(libs.plugins.compose.compiler)
|
||||||
alias(libs.plugins.hilt)
|
alias(libs.plugins.hilt)
|
||||||
alias(libs.plugins.kotlin.serialization)
|
alias(libs.plugins.kotlin.android)
|
||||||
alias(libs.plugins.ksp)
|
alias(libs.plugins.ksp)
|
||||||
alias(libs.plugins.mikepenz.aboutLibraries.android)
|
|
||||||
|
alias(libs.plugins.mikepenz.aboutLibraries)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Android configuration
|
// Android configuration
|
||||||
@@ -18,16 +19,15 @@ android {
|
|||||||
defaultConfig {
|
defaultConfig {
|
||||||
applicationId = "at.bitfire.davdroid"
|
applicationId = "at.bitfire.davdroid"
|
||||||
|
|
||||||
versionCode = 405080003
|
versionCode = 405040002
|
||||||
versionName = "4.5.8"
|
versionName = "4.5.4-rc.1"
|
||||||
|
|
||||||
base.archivesName = "davx5-ose-$versionName"
|
base.archivesName = "davx5-ose-$versionName"
|
||||||
|
|
||||||
minSdk = 24 // Android 7.0
|
minSdk = 24 // Android 7.0
|
||||||
targetSdk = 36 // Android 16
|
targetSdk = 36 // Android 16
|
||||||
|
|
||||||
// whether the build supports and allows to use custom certificates
|
buildConfigField("boolean", "customCertsUI", "true")
|
||||||
buildConfigField("boolean", "allowCustomCerts", "true")
|
|
||||||
|
|
||||||
testInstrumentationRunner = "at.bitfire.davdroid.HiltTestRunner"
|
testInstrumentationRunner = "at.bitfire.davdroid.HiltTestRunner"
|
||||||
}
|
}
|
||||||
@@ -123,10 +123,8 @@ ksp {
|
|||||||
}
|
}
|
||||||
|
|
||||||
aboutLibraries {
|
aboutLibraries {
|
||||||
export {
|
// exclude timestamps for reproducible builds [https://github.com/bitfireAT/davx5-ose/issues/994]
|
||||||
// exclude timestamps for reproducible builds [https://github.com/bitfireAT/davx5-ose/issues/994]
|
excludeFields = arrayOf("generated")
|
||||||
excludeFields.add("generated")
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
dependencies {
|
dependencies {
|
||||||
@@ -158,21 +156,21 @@ dependencies {
|
|||||||
|
|
||||||
// Jetpack Compose
|
// Jetpack Compose
|
||||||
implementation(libs.compose.accompanist.permissions)
|
implementation(libs.compose.accompanist.permissions)
|
||||||
implementation(platform(libs.androidx.compose.bom))
|
implementation(platform(libs.compose.bom))
|
||||||
implementation(libs.androidx.compose.material3)
|
implementation(libs.compose.material3)
|
||||||
implementation(libs.androidx.compose.materialIconsExtended)
|
implementation(libs.compose.materialIconsExtended)
|
||||||
debugImplementation(libs.androidx.compose.ui.tooling)
|
debugImplementation(libs.compose.ui.tooling)
|
||||||
implementation(libs.androidx.compose.ui.toolingPreview)
|
implementation(libs.compose.ui.toolingPreview)
|
||||||
|
|
||||||
// Glance Widgets
|
// Glance Widgets
|
||||||
implementation(libs.androidx.glance.base)
|
implementation(libs.glance.base)
|
||||||
implementation(libs.androidx.glance.material)
|
implementation(libs.glance.material)
|
||||||
|
|
||||||
// Jetpack Room
|
// Jetpack Room
|
||||||
implementation(libs.androidx.room.runtime)
|
implementation(libs.room.runtime)
|
||||||
implementation(libs.androidx.room.base)
|
implementation(libs.room.base)
|
||||||
implementation(libs.androidx.room.paging)
|
implementation(libs.room.paging)
|
||||||
ksp(libs.androidx.room.compiler)
|
ksp(libs.room.compiler)
|
||||||
|
|
||||||
// own libraries
|
// own libraries
|
||||||
implementation(libs.bitfire.cert4android)
|
implementation(libs.bitfire.cert4android)
|
||||||
@@ -186,14 +184,10 @@ dependencies {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// third-party libs
|
// third-party libs
|
||||||
implementation(libs.conscrypt)
|
@Suppress("RedundantSuppression")
|
||||||
implementation(libs.dnsjava)
|
implementation(libs.dnsjava)
|
||||||
implementation(libs.guava)
|
implementation(libs.guava)
|
||||||
implementation(libs.ktor.client.content.negotiation)
|
implementation(libs.mikepenz.aboutLibraries)
|
||||||
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.base)
|
||||||
implementation(libs.okhttp.brotli)
|
implementation(libs.okhttp.brotli)
|
||||||
implementation(libs.okhttp.logging)
|
implementation(libs.okhttp.logging)
|
||||||
@@ -212,7 +206,6 @@ dependencies {
|
|||||||
|
|
||||||
// for tests
|
// for tests
|
||||||
androidTestImplementation(libs.androidx.arch.core.testing)
|
androidTestImplementation(libs.androidx.arch.core.testing)
|
||||||
androidTestImplementation(libs.androidx.room.testing)
|
|
||||||
androidTestImplementation(libs.androidx.test.core)
|
androidTestImplementation(libs.androidx.test.core)
|
||||||
androidTestImplementation(libs.androidx.test.junit)
|
androidTestImplementation(libs.androidx.test.junit)
|
||||||
androidTestImplementation(libs.androidx.test.rules)
|
androidTestImplementation(libs.androidx.test.rules)
|
||||||
@@ -223,10 +216,10 @@ dependencies {
|
|||||||
androidTestImplementation(libs.kotlinx.coroutines.test)
|
androidTestImplementation(libs.kotlinx.coroutines.test)
|
||||||
androidTestImplementation(libs.mockk.android)
|
androidTestImplementation(libs.mockk.android)
|
||||||
androidTestImplementation(libs.okhttp.mockwebserver)
|
androidTestImplementation(libs.okhttp.mockwebserver)
|
||||||
|
androidTestImplementation(libs.room.testing)
|
||||||
|
|
||||||
testImplementation(libs.bitfire.dav4jvm)
|
testImplementation(libs.bitfire.dav4jvm)
|
||||||
testImplementation(libs.junit)
|
testImplementation(libs.junit)
|
||||||
testImplementation(libs.mockk)
|
testImplementation(libs.mockk)
|
||||||
testImplementation(libs.okhttp.mockwebserver)
|
testImplementation(libs.okhttp.mockwebserver)
|
||||||
testImplementation(libs.robolectric)
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,8 +24,3 @@
|
|||||||
-dontwarn sun.net.spi.nameservice.NameService
|
-dontwarn sun.net.spi.nameservice.NameService
|
||||||
-dontwarn sun.net.spi.nameservice.NameServiceDescriptor
|
-dontwarn sun.net.spi.nameservice.NameServiceDescriptor
|
||||||
-dontwarn org.xbill.DNS.spi.DnsjavaInetAddressResolverProvider
|
-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,6 +1,5 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
xmlns:tools="http://schemas.android.com/tools"
|
|
||||||
android:installLocation="internalOnly">
|
android:installLocation="internalOnly">
|
||||||
|
|
||||||
<!-- account management permissions not required for own accounts since API level 22 -->
|
<!-- account management permissions not required for own accounts since API level 22 -->
|
||||||
@@ -8,9 +7,4 @@
|
|||||||
<uses-permission android:name="android.permission.GET_ACCOUNTS" android:maxSdkVersion="22"/>
|
<uses-permission android:name="android.permission.GET_ACCOUNTS" android:maxSdkVersion="22"/>
|
||||||
<uses-permission android:name="android.permission.MANAGE_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>
|
</manifest>
|
||||||
@@ -4,20 +4,22 @@
|
|||||||
|
|
||||||
package at.bitfire.davdroid.db
|
package at.bitfire.davdroid.db
|
||||||
|
|
||||||
|
import android.security.NetworkSecurityPolicy
|
||||||
import androidx.test.filters.SmallTest
|
import androidx.test.filters.SmallTest
|
||||||
import at.bitfire.dav4jvm.okhttp.DavResource
|
import at.bitfire.dav4jvm.DavResource
|
||||||
import at.bitfire.dav4jvm.property.webdav.WebDAV
|
import at.bitfire.dav4jvm.property.webdav.ResourceType
|
||||||
import at.bitfire.davdroid.network.HttpClientBuilder
|
import at.bitfire.davdroid.network.HttpClient
|
||||||
import dagger.hilt.android.testing.HiltAndroidRule
|
import dagger.hilt.android.testing.HiltAndroidRule
|
||||||
import dagger.hilt.android.testing.HiltAndroidTest
|
import dagger.hilt.android.testing.HiltAndroidTest
|
||||||
import okhttp3.HttpUrl.Companion.toHttpUrl
|
import okhttp3.HttpUrl.Companion.toHttpUrl
|
||||||
import okhttp3.OkHttpClient
|
|
||||||
import okhttp3.mockwebserver.MockResponse
|
import okhttp3.mockwebserver.MockResponse
|
||||||
import okhttp3.mockwebserver.MockWebServer
|
import okhttp3.mockwebserver.MockWebServer
|
||||||
|
import org.junit.After
|
||||||
import org.junit.Assert.assertEquals
|
import org.junit.Assert.assertEquals
|
||||||
import org.junit.Assert.assertFalse
|
import org.junit.Assert.assertFalse
|
||||||
import org.junit.Assert.assertNull
|
import org.junit.Assert.assertNull
|
||||||
import org.junit.Assert.assertTrue
|
import org.junit.Assert.assertTrue
|
||||||
|
import org.junit.Assume
|
||||||
import org.junit.Before
|
import org.junit.Before
|
||||||
import org.junit.Rule
|
import org.junit.Rule
|
||||||
import org.junit.Test
|
import org.junit.Test
|
||||||
@@ -27,12 +29,12 @@ import javax.inject.Inject
|
|||||||
class CollectionTest {
|
class CollectionTest {
|
||||||
|
|
||||||
@Inject
|
@Inject
|
||||||
lateinit var httpClientBuilder: HttpClientBuilder
|
lateinit var httpClientBuilder: HttpClient.Builder
|
||||||
|
|
||||||
@get:Rule
|
@get:Rule
|
||||||
val hiltRule = HiltAndroidRule(this)
|
val hiltRule = HiltAndroidRule(this)
|
||||||
|
|
||||||
private lateinit var httpClient: OkHttpClient
|
private lateinit var httpClient: HttpClient
|
||||||
private val server = MockWebServer()
|
private val server = MockWebServer()
|
||||||
|
|
||||||
@Before
|
@Before
|
||||||
@@ -40,6 +42,12 @@ class CollectionTest {
|
|||||||
hiltRule.inject()
|
hiltRule.inject()
|
||||||
|
|
||||||
httpClient = httpClientBuilder.build()
|
httpClient = httpClientBuilder.build()
|
||||||
|
Assume.assumeTrue(NetworkSecurityPolicy.getInstance().isCleartextTrafficPermitted)
|
||||||
|
}
|
||||||
|
|
||||||
|
@After
|
||||||
|
fun teardown() {
|
||||||
|
httpClient.close()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -61,8 +69,8 @@ class CollectionTest {
|
|||||||
"</multistatus>"))
|
"</multistatus>"))
|
||||||
|
|
||||||
lateinit var info: Collection
|
lateinit var info: Collection
|
||||||
DavResource(httpClient, server.url("/"))
|
DavResource(httpClient.okHttpClient, server.url("/"))
|
||||||
.propfind(0, WebDAV.ResourceType) { response, _ ->
|
.propfind(0, ResourceType.NAME) { response, _ ->
|
||||||
info = Collection.fromDavResponse(response) ?: throw IllegalArgumentException()
|
info = Collection.fromDavResponse(response) ?: throw IllegalArgumentException()
|
||||||
}
|
}
|
||||||
assertEquals(Collection.TYPE_ADDRESSBOOK, info.type)
|
assertEquals(Collection.TYPE_ADDRESSBOOK, info.type)
|
||||||
@@ -117,8 +125,8 @@ class CollectionTest {
|
|||||||
"</multistatus>"))
|
"</multistatus>"))
|
||||||
|
|
||||||
lateinit var info: Collection
|
lateinit var info: Collection
|
||||||
DavResource(httpClient, server.url("/"))
|
DavResource(httpClient.okHttpClient, server.url("/"))
|
||||||
.propfind(0, WebDAV.ResourceType) { response, _ ->
|
.propfind(0, ResourceType.NAME) { response, _ ->
|
||||||
info = Collection.fromDavResponse(response)!!
|
info = Collection.fromDavResponse(response)!!
|
||||||
}
|
}
|
||||||
assertEquals(Collection.TYPE_CALENDAR, info.type)
|
assertEquals(Collection.TYPE_CALENDAR, info.type)
|
||||||
@@ -153,8 +161,8 @@ class CollectionTest {
|
|||||||
"</multistatus>"))
|
"</multistatus>"))
|
||||||
|
|
||||||
lateinit var info: Collection
|
lateinit var info: Collection
|
||||||
DavResource(httpClient, server.url("/"))
|
DavResource(httpClient.okHttpClient, server.url("/"))
|
||||||
.propfind(0, WebDAV.ResourceType) { response, _ ->
|
.propfind(0, ResourceType.NAME) { response, _ ->
|
||||||
info = Collection.fromDavResponse(response)!!
|
info = Collection.fromDavResponse(response)!!
|
||||||
}
|
}
|
||||||
assertEquals(Collection.TYPE_CALENDAR, info.type)
|
assertEquals(Collection.TYPE_CALENDAR, info.type)
|
||||||
@@ -187,8 +195,8 @@ class CollectionTest {
|
|||||||
"</multistatus>"))
|
"</multistatus>"))
|
||||||
|
|
||||||
lateinit var info: Collection
|
lateinit var info: Collection
|
||||||
DavResource(httpClient, server.url("/"))
|
DavResource(httpClient.okHttpClient, server.url("/"))
|
||||||
.propfind(0, WebDAV.ResourceType) { response, _ ->
|
.propfind(0, ResourceType.NAME) { response, _ ->
|
||||||
info = Collection.fromDavResponse(response) ?: throw IllegalArgumentException()
|
info = Collection.fromDavResponse(response) ?: throw IllegalArgumentException()
|
||||||
}
|
}
|
||||||
assertEquals(Collection.TYPE_WEBCAL, info.type)
|
assertEquals(Collection.TYPE_WEBCAL, info.type)
|
||||||
|
|||||||
@@ -1,96 +0,0 @@
|
|||||||
/*
|
|
||||||
* 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)
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
@@ -1,31 +0,0 @@
|
|||||||
/*
|
|
||||||
* 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)
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
@@ -4,11 +4,9 @@
|
|||||||
|
|
||||||
package at.bitfire.davdroid.network
|
package at.bitfire.davdroid.network
|
||||||
|
|
||||||
|
import android.security.NetworkSecurityPolicy
|
||||||
import dagger.hilt.android.testing.HiltAndroidRule
|
import dagger.hilt.android.testing.HiltAndroidRule
|
||||||
import dagger.hilt.android.testing.HiltAndroidTest
|
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.Request
|
||||||
import okhttp3.mockwebserver.MockResponse
|
import okhttp3.mockwebserver.MockResponse
|
||||||
import okhttp3.mockwebserver.MockWebServer
|
import okhttp3.mockwebserver.MockWebServer
|
||||||
@@ -16,27 +14,30 @@ import org.junit.After
|
|||||||
import org.junit.Assert.assertEquals
|
import org.junit.Assert.assertEquals
|
||||||
import org.junit.Assert.assertNull
|
import org.junit.Assert.assertNull
|
||||||
import org.junit.Assert.assertTrue
|
import org.junit.Assert.assertTrue
|
||||||
|
import org.junit.Assume
|
||||||
import org.junit.Before
|
import org.junit.Before
|
||||||
import org.junit.Rule
|
import org.junit.Rule
|
||||||
import org.junit.Test
|
import org.junit.Test
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
import javax.inject.Provider
|
|
||||||
|
|
||||||
@HiltAndroidTest
|
@HiltAndroidTest
|
||||||
class HttpClientBuilderTest {
|
class HttpClientTest {
|
||||||
|
|
||||||
@get:Rule
|
@get:Rule
|
||||||
var hiltRule = HiltAndroidRule(this)
|
var hiltRule = HiltAndroidRule(this)
|
||||||
|
|
||||||
@Inject
|
@Inject
|
||||||
lateinit var httpClientBuilder: Provider<HttpClientBuilder>
|
lateinit var httpClientBuilder: HttpClient.Builder
|
||||||
|
|
||||||
|
lateinit var httpClient: HttpClient
|
||||||
lateinit var server: MockWebServer
|
lateinit var server: MockWebServer
|
||||||
|
|
||||||
@Before
|
@Before
|
||||||
fun setUp() {
|
fun setUp() {
|
||||||
hiltRule.inject()
|
hiltRule.inject()
|
||||||
|
|
||||||
|
httpClient = httpClientBuilder.build()
|
||||||
|
|
||||||
server = MockWebServer()
|
server = MockWebServer()
|
||||||
server.start(30000)
|
server.start(30000)
|
||||||
}
|
}
|
||||||
@@ -44,32 +45,13 @@ class HttpClientBuilderTest {
|
|||||||
@After
|
@After
|
||||||
fun tearDown() {
|
fun tearDown() {
|
||||||
server.shutdown()
|
server.shutdown()
|
||||||
|
httpClient.close()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun testBuild_SharesConnectionPoolAndDispatcher() {
|
|
||||||
val client1 = httpClientBuilder.get().build()
|
|
||||||
val client2 = httpClientBuilder.get().build()
|
|
||||||
assertEquals(client1.connectionPool, client2.connectionPool)
|
|
||||||
assertEquals(client1.dispatcher, client2.dispatcher)
|
|
||||||
}
|
|
||||||
|
|
||||||
@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
|
@Test
|
||||||
fun testCookies() {
|
fun testCookies() {
|
||||||
|
Assume.assumeTrue(NetworkSecurityPolicy.getInstance().isCleartextTrafficPermitted)
|
||||||
val url = server.url("/test")
|
val url = server.url("/test")
|
||||||
|
|
||||||
// set cookie for root path (/) and /test path in first response
|
// set cookie for root path (/) and /test path in first response
|
||||||
@@ -78,9 +60,7 @@ class HttpClientBuilderTest {
|
|||||||
.addHeader("Set-Cookie", "cookie1=1; path=/")
|
.addHeader("Set-Cookie", "cookie1=1; path=/")
|
||||||
.addHeader("Set-Cookie", "cookie2=2")
|
.addHeader("Set-Cookie", "cookie2=2")
|
||||||
.setBody("Cookie set"))
|
.setBody("Cookie set"))
|
||||||
|
httpClient.okHttpClient.newCall(Request.Builder()
|
||||||
val httpClient = httpClientBuilder.get().build()
|
|
||||||
httpClient.newCall(Request.Builder()
|
|
||||||
.get().url(url)
|
.get().url(url)
|
||||||
.build()).execute()
|
.build()).execute()
|
||||||
assertNull(server.takeRequest().getHeader("Cookie"))
|
assertNull(server.takeRequest().getHeader("Cookie"))
|
||||||
@@ -91,7 +71,7 @@ class HttpClientBuilderTest {
|
|||||||
.addHeader("Set-Cookie", "cookie1=1a; path=/; Max-Age=0")
|
.addHeader("Set-Cookie", "cookie1=1a; path=/; Max-Age=0")
|
||||||
.addHeader("Set-Cookie", "cookie2=2a")
|
.addHeader("Set-Cookie", "cookie2=2a")
|
||||||
.setResponseCode(200))
|
.setResponseCode(200))
|
||||||
httpClient.newCall(Request.Builder()
|
httpClient.okHttpClient.newCall(Request.Builder()
|
||||||
.get().url(url)
|
.get().url(url)
|
||||||
.build()).execute()
|
.build()).execute()
|
||||||
val header = server.takeRequest().getHeader("Cookie")
|
val header = server.takeRequest().getHeader("Cookie")
|
||||||
@@ -99,7 +79,7 @@ class HttpClientBuilderTest {
|
|||||||
|
|
||||||
server.enqueue(MockResponse()
|
server.enqueue(MockResponse()
|
||||||
.setResponseCode(200))
|
.setResponseCode(200))
|
||||||
httpClient.newCall(Request.Builder()
|
httpClient.okHttpClient.newCall(Request.Builder()
|
||||||
.get().url(url)
|
.get().url(url)
|
||||||
.build()).execute()
|
.build()).execute()
|
||||||
assertEquals("cookie2=2a", server.takeRequest().getHeader("Cookie"))
|
assertEquals("cookie2=2a", server.takeRequest().getHeader("Cookie"))
|
||||||
@@ -17,7 +17,7 @@ import javax.inject.Inject
|
|||||||
class OkhttpClientTest {
|
class OkhttpClientTest {
|
||||||
|
|
||||||
@Inject
|
@Inject
|
||||||
lateinit var httpClientBuilder: HttpClientBuilder
|
lateinit var httpClientBuilder: HttpClient.Builder
|
||||||
|
|
||||||
@get:Rule
|
@get:Rule
|
||||||
val hiltRule = HiltAndroidRule(this)
|
val hiltRule = HiltAndroidRule(this)
|
||||||
@@ -31,15 +31,16 @@ class OkhttpClientTest {
|
|||||||
@Test
|
@Test
|
||||||
@SdkSuppress(maxSdkVersion = 34)
|
@SdkSuppress(maxSdkVersion = 34)
|
||||||
fun testIcloudWithSettings() {
|
fun testIcloudWithSettings() {
|
||||||
val client = httpClientBuilder.build()
|
httpClientBuilder.build().use { client ->
|
||||||
client
|
client.okHttpClient
|
||||||
.newCall(
|
.newCall(
|
||||||
Request.Builder()
|
Request.Builder()
|
||||||
.get()
|
.get()
|
||||||
.url("https://icloud.com")
|
.url("https://icloud.com")
|
||||||
.build()
|
.build()
|
||||||
)
|
)
|
||||||
.execute()
|
.execute()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
@@ -1,214 +0,0 @@
|
|||||||
/*
|
|
||||||
* 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) }
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
@@ -1,114 +0,0 @@
|
|||||||
/*
|
|
||||||
* 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.Ignore
|
|
||||||
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()
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
@Ignore("Flaky in CI")
|
|
||||||
@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 ->
|
|
||||||
if (!cursor.moveToNext())
|
|
||||||
return null
|
|
||||||
return cursor.getString(0)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
@@ -6,6 +6,7 @@ package at.bitfire.davdroid.resource
|
|||||||
|
|
||||||
import android.accounts.Account
|
import android.accounts.Account
|
||||||
import android.content.ContentProviderClient
|
import android.content.ContentProviderClient
|
||||||
|
import android.content.ContentUris
|
||||||
import android.content.ContentValues
|
import android.content.ContentValues
|
||||||
import android.content.Entity
|
import android.content.Entity
|
||||||
import android.provider.CalendarContract
|
import android.provider.CalendarContract
|
||||||
@@ -13,15 +14,22 @@ import android.provider.CalendarContract.ACCOUNT_TYPE_LOCAL
|
|||||||
import android.provider.CalendarContract.Events
|
import android.provider.CalendarContract.Events
|
||||||
import androidx.core.content.contentValuesOf
|
import androidx.core.content.contentValuesOf
|
||||||
import androidx.test.platform.app.InstrumentationRegistry
|
import androidx.test.platform.app.InstrumentationRegistry
|
||||||
|
import at.bitfire.ical4android.Event
|
||||||
|
import at.bitfire.ical4android.util.MiscUtils.asSyncAdapter
|
||||||
import at.bitfire.ical4android.util.MiscUtils.closeCompat
|
import at.bitfire.ical4android.util.MiscUtils.closeCompat
|
||||||
import at.bitfire.synctools.storage.calendar.AndroidCalendar
|
import at.bitfire.synctools.storage.calendar.AndroidCalendar
|
||||||
import at.bitfire.synctools.storage.calendar.AndroidCalendarProvider
|
import at.bitfire.synctools.storage.calendar.AndroidCalendarProvider
|
||||||
import at.bitfire.synctools.storage.calendar.EventsContract
|
import at.bitfire.synctools.storage.calendar.AndroidEvent2
|
||||||
import at.bitfire.synctools.test.InitCalendarProviderRule
|
import at.bitfire.synctools.test.InitCalendarProviderRule
|
||||||
import dagger.hilt.android.testing.HiltAndroidRule
|
import dagger.hilt.android.testing.HiltAndroidRule
|
||||||
import dagger.hilt.android.testing.HiltAndroidTest
|
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.After
|
||||||
import org.junit.Assert.assertEquals
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Assert.assertNotNull
|
||||||
import org.junit.Assert.assertNull
|
import org.junit.Assert.assertNull
|
||||||
import org.junit.Before
|
import org.junit.Before
|
||||||
import org.junit.Rule
|
import org.junit.Rule
|
||||||
@@ -65,6 +73,93 @@ class LocalCalendarTest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@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
|
||||||
|
})
|
||||||
|
}
|
||||||
|
calendar.add(
|
||||||
|
event = event,
|
||||||
|
fileName = "filename.ics",
|
||||||
|
eTag = null,
|
||||||
|
scheduleTag = null,
|
||||||
|
flags = LocalResource.FLAG_REMOTELY_PRESENT
|
||||||
|
)
|
||||||
|
val localEvent = calendar.findByName("filename.ics")!!
|
||||||
|
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
|
||||||
|
// 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"))
|
||||||
|
}
|
||||||
|
calendar.add(
|
||||||
|
event = event,
|
||||||
|
fileName = "filename.ics",
|
||||||
|
eTag = null,
|
||||||
|
scheduleTag = null,
|
||||||
|
flags = LocalResource.FLAG_REMOTELY_PRESENT
|
||||||
|
)
|
||||||
|
val localEvent = calendar.findByName("filename.ics")!!
|
||||||
|
val eventUrl = androidCalendar.eventUri(localEvent.id)
|
||||||
|
|
||||||
|
// set event as dirty
|
||||||
|
client.update(eventUrl, contentValuesOf(
|
||||||
|
Events.DIRTY to 1
|
||||||
|
), null, null)
|
||||||
|
|
||||||
|
// this method should mark the event as deleted
|
||||||
|
calendar.deleteDirtyEventsWithoutInstances()
|
||||||
|
|
||||||
|
// verify that event is not marked as deleted
|
||||||
|
client.query(eventUrl, arrayOf(Events.DELETED), null, null, null)!!.use { cursor ->
|
||||||
|
cursor.moveToNext()
|
||||||
|
assertEquals(0, cursor.getInt(0))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Verifies that [LocalCalendar.removeNotDirtyMarked] works as expected.
|
* Verifies that [LocalCalendar.removeNotDirtyMarked] works as expected.
|
||||||
* @param contentValues values to set on the event. Required:
|
* @param contentValues values to set on the event. Required:
|
||||||
@@ -72,16 +167,15 @@ class LocalCalendarTest {
|
|||||||
* - [Events.DIRTY]
|
* - [Events.DIRTY]
|
||||||
*/
|
*/
|
||||||
private fun testRemoveNotDirtyMarked(contentValues: ContentValues) {
|
private fun testRemoveNotDirtyMarked(contentValues: ContentValues) {
|
||||||
val entity = Entity(
|
val id = androidCalendar.addEvent(Entity(
|
||||||
contentValuesOf(
|
contentValuesOf(
|
||||||
Events.CALENDAR_ID to androidCalendar.id,
|
Events.CALENDAR_ID to androidCalendar.id,
|
||||||
Events.DTSTART to System.currentTimeMillis(),
|
Events.DTSTART to System.currentTimeMillis(),
|
||||||
Events.DTEND to System.currentTimeMillis(),
|
Events.DTEND to System.currentTimeMillis(),
|
||||||
Events.TITLE to "Some Event",
|
Events.TITLE to "Some Event",
|
||||||
EventsContract.COLUMN_FLAGS to 123
|
AndroidEvent2.COLUMN_FLAGS to 123
|
||||||
).apply { putAll(contentValues) }
|
).apply { putAll(contentValues) }
|
||||||
)
|
))
|
||||||
val id = androidCalendar.addEvent(entity)
|
|
||||||
|
|
||||||
calendar.removeNotDirtyMarked(123)
|
calendar.removeNotDirtyMarked(123)
|
||||||
|
|
||||||
@@ -116,13 +210,13 @@ class LocalCalendarTest {
|
|||||||
Events.DTSTART to System.currentTimeMillis(),
|
Events.DTSTART to System.currentTimeMillis(),
|
||||||
Events.DTEND to System.currentTimeMillis(),
|
Events.DTEND to System.currentTimeMillis(),
|
||||||
Events.TITLE to "Some Event",
|
Events.TITLE to "Some Event",
|
||||||
EventsContract.COLUMN_FLAGS to 123
|
AndroidEvent2.COLUMN_FLAGS to 123
|
||||||
).apply { putAll(contentValues) }
|
).apply { putAll(contentValues) }
|
||||||
))
|
))
|
||||||
|
|
||||||
val updated = calendar.markNotDirty(321)
|
val updated = calendar.markNotDirty(321)
|
||||||
assertEquals(1, updated)
|
assertEquals(1, updated)
|
||||||
assertEquals(321, androidCalendar.getEvent(id)?.entityValues?.getAsInteger(EventsContract.COLUMN_FLAGS))
|
assertEquals(321, androidCalendar.getEvent(id)?.flags)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
|
|||||||
@@ -0,0 +1,265 @@
|
|||||||
|
/*
|
||||||
|
* 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.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"
|
||||||
|
}
|
||||||
|
|
||||||
|
calendar.add(
|
||||||
|
event = event,
|
||||||
|
fileName = "filename.ics",
|
||||||
|
eTag = null,
|
||||||
|
scheduleTag = null,
|
||||||
|
flags = LocalResource.FLAG_REMOTELY_PRESENT
|
||||||
|
)
|
||||||
|
val localEvent = calendar.findByName("filename.ics")!!
|
||||||
|
|
||||||
|
// 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
|
||||||
|
}
|
||||||
|
calendar.add(
|
||||||
|
event = event,
|
||||||
|
fileName = "filename.ics",
|
||||||
|
eTag = null,
|
||||||
|
scheduleTag = null,
|
||||||
|
flags = LocalResource.FLAG_REMOTELY_PRESENT
|
||||||
|
)
|
||||||
|
val localEvent = calendar.findByName("filename.ics")!!
|
||||||
|
|
||||||
|
// 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-"
|
||||||
|
}
|
||||||
|
calendar.add(
|
||||||
|
event = event,
|
||||||
|
fileName = "filename.ics",
|
||||||
|
eTag = null,
|
||||||
|
scheduleTag = null,
|
||||||
|
flags = LocalResource.FLAG_REMOTELY_PRESENT
|
||||||
|
)
|
||||||
|
val localEvent = calendar.findByName("filename.ics")!!
|
||||||
|
|
||||||
|
// 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
|
||||||
|
})
|
||||||
|
}
|
||||||
|
calendar.add(
|
||||||
|
event = event,
|
||||||
|
fileName = "filename.ics",
|
||||||
|
eTag = null,
|
||||||
|
scheduleTag = null,
|
||||||
|
flags = LocalResource.FLAG_REMOTELY_PRESENT
|
||||||
|
)
|
||||||
|
val localEvent = calendar.findByName("filename.ics")!!
|
||||||
|
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"))
|
||||||
|
}
|
||||||
|
calendar.add(
|
||||||
|
event = event,
|
||||||
|
fileName = "filename.ics",
|
||||||
|
eTag = null,
|
||||||
|
scheduleTag = null,
|
||||||
|
flags = LocalResource.FLAG_REMOTELY_PRESENT
|
||||||
|
)
|
||||||
|
val localEvent = calendar.findByName("filename.ics")!!
|
||||||
|
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,11 +21,15 @@ import at.bitfire.vcard4android.GroupMethod
|
|||||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||||
import dagger.hilt.android.testing.HiltAndroidRule
|
import dagger.hilt.android.testing.HiltAndroidRule
|
||||||
import dagger.hilt.android.testing.HiltAndroidTest
|
import dagger.hilt.android.testing.HiltAndroidTest
|
||||||
import org.junit.After
|
import org.junit.AfterClass
|
||||||
import org.junit.Assert.assertEquals
|
import org.junit.Assert.assertEquals
|
||||||
import org.junit.Assert.assertFalse
|
import org.junit.Assert.assertFalse
|
||||||
|
import org.junit.Assert.assertNotNull
|
||||||
|
import org.junit.Assert.assertNull
|
||||||
import org.junit.Assert.assertTrue
|
import org.junit.Assert.assertTrue
|
||||||
import org.junit.Before
|
import org.junit.Before
|
||||||
|
import org.junit.BeforeClass
|
||||||
|
import org.junit.ClassRule
|
||||||
import org.junit.Rule
|
import org.junit.Rule
|
||||||
import org.junit.Test
|
import org.junit.Test
|
||||||
import java.util.Optional
|
import java.util.Optional
|
||||||
@@ -34,34 +38,22 @@ import javax.inject.Inject
|
|||||||
@HiltAndroidTest
|
@HiltAndroidTest
|
||||||
class LocalGroupTest {
|
class LocalGroupTest {
|
||||||
|
|
||||||
@get:Rule
|
|
||||||
val hiltRule = HiltAndroidRule(this)
|
|
||||||
|
|
||||||
@get:Rule
|
|
||||||
val permissionRule = GrantPermissionRule.grant(Manifest.permission.READ_CONTACTS, Manifest.permission.WRITE_CONTACTS)!!
|
|
||||||
|
|
||||||
@Inject @ApplicationContext
|
@Inject @ApplicationContext
|
||||||
lateinit var context: Context
|
lateinit var context: Context
|
||||||
|
|
||||||
@Inject
|
@Inject
|
||||||
lateinit var localTestAddressBookProvider: LocalTestAddressBookProvider
|
lateinit var localTestAddressBookProvider: LocalTestAddressBookProvider
|
||||||
|
|
||||||
lateinit var provider: ContentProviderClient
|
@get:Rule
|
||||||
|
val hiltRule = HiltAndroidRule(this)
|
||||||
|
|
||||||
val account = Account("Test Account", "Test Account Type")
|
val account = Account("Test Account", "Test Account Type")
|
||||||
|
|
||||||
@Before
|
@Before
|
||||||
fun setUp() {
|
fun setup() {
|
||||||
hiltRule.inject()
|
hiltRule.inject()
|
||||||
|
|
||||||
val context = InstrumentationRegistry.getInstrumentation().context
|
|
||||||
provider = context.contentResolver.acquireContentProviderClient(ContactsContract.AUTHORITY)!!
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@After
|
|
||||||
fun tearDown() {
|
|
||||||
provider.close()
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun testApplyPendingMemberships_addPendingMembership() {
|
fun testApplyPendingMemberships_addPendingMembership() {
|
||||||
@@ -154,6 +146,7 @@ class LocalGroupTest {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun testClearDirty_addCachedGroupMembership() {
|
fun testClearDirty_addCachedGroupMembership() {
|
||||||
localTestAddressBookProvider.provide(account, provider, GroupMethod.CATEGORIES) { ab ->
|
localTestAddressBookProvider.provide(account, provider, GroupMethod.CATEGORIES) { ab ->
|
||||||
@@ -221,6 +214,7 @@ class LocalGroupTest {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun testMarkMembersDirty() {
|
fun testMarkMembersDirty() {
|
||||||
localTestAddressBookProvider.provide(account, provider, GroupMethod.CATEGORIES) { ab ->
|
localTestAddressBookProvider.provide(account, provider, GroupMethod.CATEGORIES) { ab ->
|
||||||
@@ -240,11 +234,17 @@ class LocalGroupTest {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun testUpdate() {
|
fun testPrepareForUpload() {
|
||||||
localTestAddressBookProvider.provide(account, provider) { ab ->
|
localTestAddressBookProvider.provide(account, provider, GroupMethod.CATEGORIES) { ab ->
|
||||||
val group = newGroup(ab)
|
val group = newGroup(ab)
|
||||||
group.update(Contact(displayName = "New Group Name"), null, null, null, 0)
|
assertNull(group.getContact().uid)
|
||||||
|
|
||||||
|
val fileName = group.prepareForUpload()
|
||||||
|
val newUid = group.getContact().uid
|
||||||
|
assertNotNull(newUid)
|
||||||
|
assertEquals("$newUid.vcf", fileName)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -260,4 +260,27 @@ class LocalGroupTest {
|
|||||||
add()
|
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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
@@ -4,23 +4,24 @@
|
|||||||
|
|
||||||
package at.bitfire.davdroid.servicedetection
|
package at.bitfire.davdroid.servicedetection
|
||||||
|
|
||||||
|
import android.security.NetworkSecurityPolicy
|
||||||
import at.bitfire.davdroid.db.AppDatabase
|
import at.bitfire.davdroid.db.AppDatabase
|
||||||
import at.bitfire.davdroid.db.Collection
|
import at.bitfire.davdroid.db.Collection
|
||||||
import at.bitfire.davdroid.db.Service
|
import at.bitfire.davdroid.db.Service
|
||||||
import at.bitfire.davdroid.network.HttpClientBuilder
|
import at.bitfire.davdroid.network.HttpClient
|
||||||
import at.bitfire.davdroid.settings.SettingsManager
|
import at.bitfire.davdroid.settings.SettingsManager
|
||||||
import dagger.hilt.android.testing.BindValue
|
import dagger.hilt.android.testing.BindValue
|
||||||
import dagger.hilt.android.testing.HiltAndroidRule
|
import dagger.hilt.android.testing.HiltAndroidRule
|
||||||
import dagger.hilt.android.testing.HiltAndroidTest
|
import dagger.hilt.android.testing.HiltAndroidTest
|
||||||
import io.mockk.impl.annotations.MockK
|
import io.mockk.impl.annotations.MockK
|
||||||
import io.mockk.junit4.MockKRule
|
import io.mockk.junit4.MockKRule
|
||||||
import okhttp3.OkHttpClient
|
|
||||||
import okhttp3.mockwebserver.Dispatcher
|
import okhttp3.mockwebserver.Dispatcher
|
||||||
import okhttp3.mockwebserver.MockResponse
|
import okhttp3.mockwebserver.MockResponse
|
||||||
import okhttp3.mockwebserver.MockWebServer
|
import okhttp3.mockwebserver.MockWebServer
|
||||||
import okhttp3.mockwebserver.RecordedRequest
|
import okhttp3.mockwebserver.RecordedRequest
|
||||||
import org.junit.After
|
import org.junit.After
|
||||||
import org.junit.Assert.assertEquals
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Assume
|
||||||
import org.junit.Before
|
import org.junit.Before
|
||||||
import org.junit.Rule
|
import org.junit.Rule
|
||||||
import org.junit.Test
|
import org.junit.Test
|
||||||
@@ -40,7 +41,7 @@ class CollectionsWithoutHomeSetRefresherTest {
|
|||||||
lateinit var db: AppDatabase
|
lateinit var db: AppDatabase
|
||||||
|
|
||||||
@Inject
|
@Inject
|
||||||
lateinit var httpClientBuilder: HttpClientBuilder
|
lateinit var httpClientBuilder: HttpClient.Builder
|
||||||
|
|
||||||
@Inject
|
@Inject
|
||||||
lateinit var logger: Logger
|
lateinit var logger: Logger
|
||||||
@@ -52,7 +53,7 @@ class CollectionsWithoutHomeSetRefresherTest {
|
|||||||
@MockK(relaxed = true)
|
@MockK(relaxed = true)
|
||||||
lateinit var settings: SettingsManager
|
lateinit var settings: SettingsManager
|
||||||
|
|
||||||
private lateinit var client: OkHttpClient
|
private lateinit var client: HttpClient
|
||||||
private lateinit var mockServer: MockWebServer
|
private lateinit var mockServer: MockWebServer
|
||||||
private lateinit var service: Service
|
private lateinit var service: Service
|
||||||
|
|
||||||
@@ -68,6 +69,7 @@ class CollectionsWithoutHomeSetRefresherTest {
|
|||||||
|
|
||||||
// build HTTP client
|
// build HTTP client
|
||||||
client = httpClientBuilder.build()
|
client = httpClientBuilder.build()
|
||||||
|
Assume.assumeTrue(NetworkSecurityPolicy.getInstance().isCleartextTrafficPermitted)
|
||||||
|
|
||||||
// insert test service
|
// insert test service
|
||||||
val serviceId = db.serviceDao().insertOrReplace(
|
val serviceId = db.serviceDao().insertOrReplace(
|
||||||
@@ -78,6 +80,7 @@ class CollectionsWithoutHomeSetRefresherTest {
|
|||||||
|
|
||||||
@After
|
@After
|
||||||
fun tearDown() {
|
fun tearDown() {
|
||||||
|
client.close()
|
||||||
mockServer.shutdown()
|
mockServer.shutdown()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -99,7 +102,7 @@ class CollectionsWithoutHomeSetRefresherTest {
|
|||||||
)
|
)
|
||||||
|
|
||||||
// Refresh
|
// Refresh
|
||||||
refresherFactory.create(service, client).refreshCollectionsWithoutHomeSet()
|
refresherFactory.create(service, client.okHttpClient).refreshCollectionsWithoutHomeSet()
|
||||||
|
|
||||||
// Check the collection got updated - with display name and description
|
// Check the collection got updated - with display name and description
|
||||||
assertEquals(
|
assertEquals(
|
||||||
@@ -132,7 +135,7 @@ class CollectionsWithoutHomeSetRefresherTest {
|
|||||||
)
|
)
|
||||||
|
|
||||||
// Refresh - should delete collection
|
// Refresh - should delete collection
|
||||||
refresherFactory.create(service, client).refreshCollectionsWithoutHomeSet()
|
refresherFactory.create(service, client.okHttpClient).refreshCollectionsWithoutHomeSet()
|
||||||
|
|
||||||
// Check the collection got deleted
|
// Check the collection got deleted
|
||||||
assertEquals(null, db.collectionDao().get(collectionId))
|
assertEquals(null, db.collectionDao().get(collectionId))
|
||||||
@@ -154,7 +157,7 @@ class CollectionsWithoutHomeSetRefresherTest {
|
|||||||
|
|
||||||
// Refresh homeless collections
|
// Refresh homeless collections
|
||||||
assertEquals(0, db.principalDao().getByService(service.id).size)
|
assertEquals(0, db.principalDao().getByService(service.id).size)
|
||||||
refresherFactory.create(service, client).refreshCollectionsWithoutHomeSet()
|
refresherFactory.create(service, client.okHttpClient).refreshCollectionsWithoutHomeSet()
|
||||||
|
|
||||||
// Check principal saved and the collection was updated with its reference
|
// Check principal saved and the collection was updated with its reference
|
||||||
val principals = db.principalDao().getByService(service.id)
|
val principals = db.principalDao().getByService(service.id)
|
||||||
|
|||||||
@@ -4,16 +4,15 @@
|
|||||||
|
|
||||||
package at.bitfire.davdroid.servicedetection
|
package at.bitfire.davdroid.servicedetection
|
||||||
|
|
||||||
import at.bitfire.dav4jvm.okhttp.DavResource
|
import android.security.NetworkSecurityPolicy
|
||||||
import at.bitfire.dav4jvm.property.carddav.CardDAV
|
import at.bitfire.dav4jvm.DavResource
|
||||||
import at.bitfire.dav4jvm.property.webdav.WebDAV
|
import at.bitfire.dav4jvm.property.carddav.AddressbookHomeSet
|
||||||
import at.bitfire.davdroid.network.HttpClientBuilder
|
import at.bitfire.dav4jvm.property.webdav.ResourceType
|
||||||
|
import at.bitfire.davdroid.network.HttpClient
|
||||||
import at.bitfire.davdroid.servicedetection.DavResourceFinder.Configuration.ServiceInfo
|
import at.bitfire.davdroid.servicedetection.DavResourceFinder.Configuration.ServiceInfo
|
||||||
import at.bitfire.davdroid.settings.Credentials
|
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.HiltAndroidRule
|
||||||
import dagger.hilt.android.testing.HiltAndroidTest
|
import dagger.hilt.android.testing.HiltAndroidTest
|
||||||
import okhttp3.OkHttpClient
|
|
||||||
import okhttp3.mockwebserver.Dispatcher
|
import okhttp3.mockwebserver.Dispatcher
|
||||||
import okhttp3.mockwebserver.MockResponse
|
import okhttp3.mockwebserver.MockResponse
|
||||||
import okhttp3.mockwebserver.MockWebServer
|
import okhttp3.mockwebserver.MockWebServer
|
||||||
@@ -24,6 +23,7 @@ import org.junit.Assert.assertEquals
|
|||||||
import org.junit.Assert.assertFalse
|
import org.junit.Assert.assertFalse
|
||||||
import org.junit.Assert.assertNull
|
import org.junit.Assert.assertNull
|
||||||
import org.junit.Assert.assertTrue
|
import org.junit.Assert.assertTrue
|
||||||
|
import org.junit.Assume
|
||||||
import org.junit.Before
|
import org.junit.Before
|
||||||
import org.junit.Rule
|
import org.junit.Rule
|
||||||
import org.junit.Test
|
import org.junit.Test
|
||||||
@@ -49,7 +49,7 @@ class DavResourceFinderTest {
|
|||||||
val hiltRule = HiltAndroidRule(this)
|
val hiltRule = HiltAndroidRule(this)
|
||||||
|
|
||||||
@Inject
|
@Inject
|
||||||
lateinit var httpClientBuilder: HttpClientBuilder
|
lateinit var httpClientBuilder: HttpClient.Builder
|
||||||
|
|
||||||
@Inject
|
@Inject
|
||||||
lateinit var logger: Logger
|
lateinit var logger: Logger
|
||||||
@@ -58,7 +58,7 @@ class DavResourceFinderTest {
|
|||||||
lateinit var resourceFinderFactory: DavResourceFinder.Factory
|
lateinit var resourceFinderFactory: DavResourceFinder.Factory
|
||||||
|
|
||||||
private lateinit var server: MockWebServer
|
private lateinit var server: MockWebServer
|
||||||
private lateinit var client: OkHttpClient
|
private lateinit var client: HttpClient
|
||||||
private lateinit var finder: DavResourceFinder
|
private lateinit var finder: DavResourceFinder
|
||||||
|
|
||||||
@Before
|
@Before
|
||||||
@@ -70,10 +70,11 @@ class DavResourceFinderTest {
|
|||||||
start()
|
start()
|
||||||
}
|
}
|
||||||
|
|
||||||
val credentials = Credentials(username = "mock", password = "12345".toSensitiveString())
|
val credentials = Credentials(username = "mock", password = "12345".toCharArray())
|
||||||
client = httpClientBuilder
|
client = httpClientBuilder
|
||||||
.authenticate(domain = null, getCredentials = { credentials })
|
.authenticate(host = null, getCredentials = { credentials })
|
||||||
.build()
|
.build()
|
||||||
|
Assume.assumeTrue(NetworkSecurityPolicy.getInstance().isCleartextTrafficPermitted)
|
||||||
|
|
||||||
val baseURI = URI.create("/")
|
val baseURI = URI.create("/")
|
||||||
finder = resourceFinderFactory.create(baseURI, credentials)
|
finder = resourceFinderFactory.create(baseURI, credentials)
|
||||||
@@ -81,6 +82,7 @@ class DavResourceFinderTest {
|
|||||||
|
|
||||||
@After
|
@After
|
||||||
fun tearDown() {
|
fun tearDown() {
|
||||||
|
client.close()
|
||||||
server.shutdown()
|
server.shutdown()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -89,9 +91,9 @@ class DavResourceFinderTest {
|
|||||||
fun testRememberIfAddressBookOrHomeset() {
|
fun testRememberIfAddressBookOrHomeset() {
|
||||||
// recognize home set
|
// recognize home set
|
||||||
var info = ServiceInfo()
|
var info = ServiceInfo()
|
||||||
DavResource(client, server.url(PATH_CARDDAV + SUBPATH_PRINCIPAL))
|
DavResource(client.okHttpClient, server.url(PATH_CARDDAV + SUBPATH_PRINCIPAL))
|
||||||
.propfind(0, CardDAV.AddressbookHomeSet) { response, _ ->
|
.propfind(0, AddressbookHomeSet.NAME) { response, _ ->
|
||||||
finder.scanResponse(CardDAV.Addressbook, response, info)
|
finder.scanResponse(ResourceType.ADDRESSBOOK, response, info)
|
||||||
}
|
}
|
||||||
assertEquals(0, info.collections.size)
|
assertEquals(0, info.collections.size)
|
||||||
assertEquals(1, info.homeSets.size)
|
assertEquals(1, info.homeSets.size)
|
||||||
@@ -99,9 +101,9 @@ class DavResourceFinderTest {
|
|||||||
|
|
||||||
// recognize address book
|
// recognize address book
|
||||||
info = ServiceInfo()
|
info = ServiceInfo()
|
||||||
DavResource(client, server.url(PATH_CARDDAV + SUBPATH_ADDRESSBOOK))
|
DavResource(client.okHttpClient, server.url(PATH_CARDDAV + SUBPATH_ADDRESSBOOK))
|
||||||
.propfind(0, WebDAV.ResourceType) { response, _ ->
|
.propfind(0, ResourceType.NAME) { response, _ ->
|
||||||
finder.scanResponse(CardDAV.Addressbook, response, info)
|
finder.scanResponse(ResourceType.ADDRESSBOOK, response, info)
|
||||||
}
|
}
|
||||||
assertEquals(1, info.collections.size)
|
assertEquals(1, info.collections.size)
|
||||||
assertEquals(server.url("$PATH_CARDDAV$SUBPATH_ADDRESSBOOK/"), info.collections.keys.first())
|
assertEquals(server.url("$PATH_CARDDAV$SUBPATH_ADDRESSBOOK/"), info.collections.keys.first())
|
||||||
|
|||||||
@@ -4,11 +4,12 @@
|
|||||||
|
|
||||||
package at.bitfire.davdroid.servicedetection
|
package at.bitfire.davdroid.servicedetection
|
||||||
|
|
||||||
|
import android.security.NetworkSecurityPolicy
|
||||||
import at.bitfire.davdroid.db.AppDatabase
|
import at.bitfire.davdroid.db.AppDatabase
|
||||||
import at.bitfire.davdroid.db.Collection
|
import at.bitfire.davdroid.db.Collection
|
||||||
import at.bitfire.davdroid.db.HomeSet
|
import at.bitfire.davdroid.db.HomeSet
|
||||||
import at.bitfire.davdroid.db.Service
|
import at.bitfire.davdroid.db.Service
|
||||||
import at.bitfire.davdroid.network.HttpClientBuilder
|
import at.bitfire.davdroid.network.HttpClient
|
||||||
import at.bitfire.davdroid.settings.Settings
|
import at.bitfire.davdroid.settings.Settings
|
||||||
import at.bitfire.davdroid.settings.SettingsManager
|
import at.bitfire.davdroid.settings.SettingsManager
|
||||||
import dagger.hilt.android.testing.BindValue
|
import dagger.hilt.android.testing.BindValue
|
||||||
@@ -20,13 +21,13 @@ import io.mockk.junit4.MockKRule
|
|||||||
import junit.framework.TestCase.assertFalse
|
import junit.framework.TestCase.assertFalse
|
||||||
import junit.framework.TestCase.assertTrue
|
import junit.framework.TestCase.assertTrue
|
||||||
import kotlinx.coroutines.test.runTest
|
import kotlinx.coroutines.test.runTest
|
||||||
import okhttp3.OkHttpClient
|
|
||||||
import okhttp3.mockwebserver.Dispatcher
|
import okhttp3.mockwebserver.Dispatcher
|
||||||
import okhttp3.mockwebserver.MockResponse
|
import okhttp3.mockwebserver.MockResponse
|
||||||
import okhttp3.mockwebserver.MockWebServer
|
import okhttp3.mockwebserver.MockWebServer
|
||||||
import okhttp3.mockwebserver.RecordedRequest
|
import okhttp3.mockwebserver.RecordedRequest
|
||||||
import org.junit.After
|
import org.junit.After
|
||||||
import org.junit.Assert.assertEquals
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Assume
|
||||||
import org.junit.Before
|
import org.junit.Before
|
||||||
import org.junit.Rule
|
import org.junit.Rule
|
||||||
import org.junit.Test
|
import org.junit.Test
|
||||||
@@ -46,7 +47,7 @@ class HomeSetRefresherTest {
|
|||||||
lateinit var db: AppDatabase
|
lateinit var db: AppDatabase
|
||||||
|
|
||||||
@Inject
|
@Inject
|
||||||
lateinit var httpClientBuilder: HttpClientBuilder
|
lateinit var httpClientBuilder: HttpClient.Builder
|
||||||
|
|
||||||
@Inject
|
@Inject
|
||||||
lateinit var logger: Logger
|
lateinit var logger: Logger
|
||||||
@@ -58,7 +59,7 @@ class HomeSetRefresherTest {
|
|||||||
@MockK(relaxed = true)
|
@MockK(relaxed = true)
|
||||||
lateinit var settings: SettingsManager
|
lateinit var settings: SettingsManager
|
||||||
|
|
||||||
private lateinit var client: OkHttpClient
|
private lateinit var client: HttpClient
|
||||||
private lateinit var mockServer: MockWebServer
|
private lateinit var mockServer: MockWebServer
|
||||||
private lateinit var service: Service
|
private lateinit var service: Service
|
||||||
|
|
||||||
@@ -74,6 +75,7 @@ class HomeSetRefresherTest {
|
|||||||
|
|
||||||
// build HTTP client
|
// build HTTP client
|
||||||
client = httpClientBuilder.build()
|
client = httpClientBuilder.build()
|
||||||
|
Assume.assumeTrue(NetworkSecurityPolicy.getInstance().isCleartextTrafficPermitted)
|
||||||
|
|
||||||
// insert test service
|
// insert test service
|
||||||
val serviceId = db.serviceDao().insertOrReplace(
|
val serviceId = db.serviceDao().insertOrReplace(
|
||||||
@@ -84,6 +86,7 @@ class HomeSetRefresherTest {
|
|||||||
|
|
||||||
@After
|
@After
|
||||||
fun tearDown() {
|
fun tearDown() {
|
||||||
|
client.close()
|
||||||
mockServer.shutdown()
|
mockServer.shutdown()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -98,7 +101,7 @@ class HomeSetRefresherTest {
|
|||||||
)
|
)
|
||||||
|
|
||||||
// Refresh
|
// Refresh
|
||||||
homeSetRefresherFactory.create(service, client)
|
homeSetRefresherFactory.create(service, client.okHttpClient)
|
||||||
.refreshHomesetsAndTheirCollections()
|
.refreshHomesetsAndTheirCollections()
|
||||||
|
|
||||||
// Check the collection defined in homeset is now in the database
|
// Check the collection defined in homeset is now in the database
|
||||||
@@ -134,7 +137,7 @@ class HomeSetRefresherTest {
|
|||||||
)
|
)
|
||||||
|
|
||||||
// Refresh
|
// Refresh
|
||||||
homeSetRefresherFactory.create(service, client).refreshHomesetsAndTheirCollections()
|
homeSetRefresherFactory.create(service, client.okHttpClient).refreshHomesetsAndTheirCollections()
|
||||||
|
|
||||||
// Check the collection got updated
|
// Check the collection got updated
|
||||||
assertEquals(
|
assertEquals(
|
||||||
@@ -171,7 +174,7 @@ class HomeSetRefresherTest {
|
|||||||
)
|
)
|
||||||
|
|
||||||
// Refresh
|
// Refresh
|
||||||
homeSetRefresherFactory.create(service, client).refreshHomesetsAndTheirCollections()
|
homeSetRefresherFactory.create(service, client.okHttpClient).refreshHomesetsAndTheirCollections()
|
||||||
|
|
||||||
// Check the collection got updated
|
// Check the collection got updated
|
||||||
assertEquals(
|
assertEquals(
|
||||||
@@ -211,7 +214,7 @@ class HomeSetRefresherTest {
|
|||||||
)
|
)
|
||||||
|
|
||||||
// Refresh - should mark collection as homeless, because serverside homeset is empty.
|
// Refresh - should mark collection as homeless, because serverside homeset is empty.
|
||||||
homeSetRefresherFactory.create(service, client).refreshHomesetsAndTheirCollections()
|
homeSetRefresherFactory.create(service, client.okHttpClient).refreshHomesetsAndTheirCollections()
|
||||||
|
|
||||||
// Check the collection, is now marked as homeless
|
// Check the collection, is now marked as homeless
|
||||||
assertEquals(null, db.collectionDao().get(collectionId)!!.homeSetId)
|
assertEquals(null, db.collectionDao().get(collectionId)!!.homeSetId)
|
||||||
@@ -238,7 +241,7 @@ class HomeSetRefresherTest {
|
|||||||
|
|
||||||
// Refresh - homesets and their collections
|
// Refresh - homesets and their collections
|
||||||
assertEquals(0, db.principalDao().getByService(service.id).size)
|
assertEquals(0, db.principalDao().getByService(service.id).size)
|
||||||
homeSetRefresherFactory.create(service, client).refreshHomesetsAndTheirCollections()
|
homeSetRefresherFactory.create(service, client.okHttpClient).refreshHomesetsAndTheirCollections()
|
||||||
|
|
||||||
// Check principal saved and the collection was updated with its reference
|
// Check principal saved and the collection was updated with its reference
|
||||||
val principals = db.principalDao().getByService(service.id)
|
val principals = db.principalDao().getByService(service.id)
|
||||||
@@ -275,7 +278,7 @@ class HomeSetRefresherTest {
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
val refresher = homeSetRefresherFactory.create(service, client)
|
val refresher = homeSetRefresherFactory.create(service, client.okHttpClient)
|
||||||
assertFalse(refresher.shouldPreselect(collection, homesets))
|
assertFalse(refresher.shouldPreselect(collection, homesets))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -300,7 +303,7 @@ class HomeSetRefresherTest {
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
val refresher = homeSetRefresherFactory.create(service, client)
|
val refresher = homeSetRefresherFactory.create(service, client.okHttpClient)
|
||||||
assertTrue(refresher.shouldPreselect(collection, homesets))
|
assertTrue(refresher.shouldPreselect(collection, homesets))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -327,7 +330,7 @@ class HomeSetRefresherTest {
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
val refresher = homeSetRefresherFactory.create(service, client)
|
val refresher = homeSetRefresherFactory.create(service, client.okHttpClient)
|
||||||
assertFalse(refresher.shouldPreselect(collection, homesets))
|
assertFalse(refresher.shouldPreselect(collection, homesets))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -352,7 +355,7 @@ class HomeSetRefresherTest {
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
val refresher = homeSetRefresherFactory.create(service, client)
|
val refresher = homeSetRefresherFactory.create(service, client.okHttpClient)
|
||||||
assertFalse(refresher.shouldPreselect(collection, homesets))
|
assertFalse(refresher.shouldPreselect(collection, homesets))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -377,7 +380,7 @@ class HomeSetRefresherTest {
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
val refresher = homeSetRefresherFactory.create(service, client)
|
val refresher = homeSetRefresherFactory.create(service, client.okHttpClient)
|
||||||
assertTrue(refresher.shouldPreselect(collection, homesets))
|
assertTrue(refresher.shouldPreselect(collection, homesets))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -404,7 +407,7 @@ class HomeSetRefresherTest {
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
val refresher = homeSetRefresherFactory.create(service, client)
|
val refresher = homeSetRefresherFactory.create(service, client.okHttpClient)
|
||||||
assertFalse(refresher.shouldPreselect(collection, homesets))
|
assertFalse(refresher.shouldPreselect(collection, homesets))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4,11 +4,12 @@
|
|||||||
|
|
||||||
package at.bitfire.davdroid.servicedetection
|
package at.bitfire.davdroid.servicedetection
|
||||||
|
|
||||||
|
import android.security.NetworkSecurityPolicy
|
||||||
import at.bitfire.davdroid.db.AppDatabase
|
import at.bitfire.davdroid.db.AppDatabase
|
||||||
import at.bitfire.davdroid.db.Collection
|
import at.bitfire.davdroid.db.Collection
|
||||||
import at.bitfire.davdroid.db.Principal
|
import at.bitfire.davdroid.db.Principal
|
||||||
import at.bitfire.davdroid.db.Service
|
import at.bitfire.davdroid.db.Service
|
||||||
import at.bitfire.davdroid.network.HttpClientBuilder
|
import at.bitfire.davdroid.network.HttpClient
|
||||||
import at.bitfire.davdroid.settings.SettingsManager
|
import at.bitfire.davdroid.settings.SettingsManager
|
||||||
import dagger.hilt.android.testing.BindValue
|
import dagger.hilt.android.testing.BindValue
|
||||||
import dagger.hilt.android.testing.HiltAndroidRule
|
import dagger.hilt.android.testing.HiltAndroidRule
|
||||||
@@ -16,12 +17,12 @@ import dagger.hilt.android.testing.HiltAndroidTest
|
|||||||
import io.mockk.impl.annotations.MockK
|
import io.mockk.impl.annotations.MockK
|
||||||
import io.mockk.junit4.MockKRule
|
import io.mockk.junit4.MockKRule
|
||||||
import junit.framework.TestCase.assertEquals
|
import junit.framework.TestCase.assertEquals
|
||||||
import okhttp3.OkHttpClient
|
|
||||||
import okhttp3.mockwebserver.Dispatcher
|
import okhttp3.mockwebserver.Dispatcher
|
||||||
import okhttp3.mockwebserver.MockResponse
|
import okhttp3.mockwebserver.MockResponse
|
||||||
import okhttp3.mockwebserver.MockWebServer
|
import okhttp3.mockwebserver.MockWebServer
|
||||||
import okhttp3.mockwebserver.RecordedRequest
|
import okhttp3.mockwebserver.RecordedRequest
|
||||||
import org.junit.After
|
import org.junit.After
|
||||||
|
import org.junit.Assume
|
||||||
import org.junit.Before
|
import org.junit.Before
|
||||||
import org.junit.Rule
|
import org.junit.Rule
|
||||||
import org.junit.Test
|
import org.junit.Test
|
||||||
@@ -35,7 +36,7 @@ class PrincipalsRefresherTest {
|
|||||||
lateinit var db: AppDatabase
|
lateinit var db: AppDatabase
|
||||||
|
|
||||||
@Inject
|
@Inject
|
||||||
lateinit var httpClientBuilder: HttpClientBuilder
|
lateinit var httpClientBuilder: HttpClient.Builder
|
||||||
|
|
||||||
@Inject
|
@Inject
|
||||||
lateinit var logger: Logger
|
lateinit var logger: Logger
|
||||||
@@ -53,7 +54,7 @@ class PrincipalsRefresherTest {
|
|||||||
@get:Rule
|
@get:Rule
|
||||||
val mockKRule = MockKRule(this)
|
val mockKRule = MockKRule(this)
|
||||||
|
|
||||||
private lateinit var client: OkHttpClient
|
private lateinit var client: HttpClient
|
||||||
private lateinit var mockServer: MockWebServer
|
private lateinit var mockServer: MockWebServer
|
||||||
private lateinit var service: Service
|
private lateinit var service: Service
|
||||||
|
|
||||||
@@ -69,6 +70,7 @@ class PrincipalsRefresherTest {
|
|||||||
|
|
||||||
// build HTTP client
|
// build HTTP client
|
||||||
client = httpClientBuilder.build()
|
client = httpClientBuilder.build()
|
||||||
|
Assume.assumeTrue(NetworkSecurityPolicy.getInstance().isCleartextTrafficPermitted)
|
||||||
|
|
||||||
// insert test service
|
// insert test service
|
||||||
val serviceId = db.serviceDao().insertOrReplace(
|
val serviceId = db.serviceDao().insertOrReplace(
|
||||||
@@ -79,6 +81,7 @@ class PrincipalsRefresherTest {
|
|||||||
|
|
||||||
@After
|
@After
|
||||||
fun tearDown() {
|
fun tearDown() {
|
||||||
|
client.close()
|
||||||
mockServer.shutdown()
|
mockServer.shutdown()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -107,7 +110,7 @@ class PrincipalsRefresherTest {
|
|||||||
)
|
)
|
||||||
|
|
||||||
// Refresh principals
|
// Refresh principals
|
||||||
principalsRefresher.create(service, client).refreshPrincipals()
|
principalsRefresher.create(service, client.okHttpClient).refreshPrincipals()
|
||||||
|
|
||||||
// Check principal was not updated
|
// Check principal was not updated
|
||||||
val principals = db.principalDao().getByService(service.id)
|
val principals = db.principalDao().getByService(service.id)
|
||||||
@@ -140,7 +143,7 @@ class PrincipalsRefresherTest {
|
|||||||
)
|
)
|
||||||
|
|
||||||
// Refresh principals
|
// Refresh principals
|
||||||
principalsRefresher.create(service, client).refreshPrincipals()
|
principalsRefresher.create(service, client.okHttpClient).refreshPrincipals()
|
||||||
|
|
||||||
// Check principal now got a display name
|
// Check principal now got a display name
|
||||||
val principals = db.principalDao().getByService(service.id)
|
val principals = db.principalDao().getByService(service.id)
|
||||||
@@ -161,7 +164,7 @@ class PrincipalsRefresherTest {
|
|||||||
)
|
)
|
||||||
|
|
||||||
// Refresh principals - detecting it does not own collections
|
// Refresh principals - detecting it does not own collections
|
||||||
principalsRefresher.create(service, client).refreshPrincipals()
|
principalsRefresher.create(service, client.okHttpClient).refreshPrincipals()
|
||||||
|
|
||||||
// Check principal was deleted
|
// Check principal was deleted
|
||||||
val principals = db.principalDao().getByService(service.id)
|
val principals = db.principalDao().getByService(service.id)
|
||||||
|
|||||||
@@ -4,18 +4,19 @@
|
|||||||
|
|
||||||
package at.bitfire.davdroid.servicedetection
|
package at.bitfire.davdroid.servicedetection
|
||||||
|
|
||||||
|
import android.security.NetworkSecurityPolicy
|
||||||
import at.bitfire.davdroid.db.AppDatabase
|
import at.bitfire.davdroid.db.AppDatabase
|
||||||
import at.bitfire.davdroid.db.Service
|
import at.bitfire.davdroid.db.Service
|
||||||
import at.bitfire.davdroid.network.HttpClientBuilder
|
import at.bitfire.davdroid.network.HttpClient
|
||||||
import dagger.hilt.android.testing.HiltAndroidRule
|
import dagger.hilt.android.testing.HiltAndroidRule
|
||||||
import dagger.hilt.android.testing.HiltAndroidTest
|
import dagger.hilt.android.testing.HiltAndroidTest
|
||||||
import okhttp3.OkHttpClient
|
|
||||||
import okhttp3.mockwebserver.Dispatcher
|
import okhttp3.mockwebserver.Dispatcher
|
||||||
import okhttp3.mockwebserver.MockResponse
|
import okhttp3.mockwebserver.MockResponse
|
||||||
import okhttp3.mockwebserver.MockWebServer
|
import okhttp3.mockwebserver.MockWebServer
|
||||||
import okhttp3.mockwebserver.RecordedRequest
|
import okhttp3.mockwebserver.RecordedRequest
|
||||||
import org.junit.After
|
import org.junit.After
|
||||||
import org.junit.Assert.assertEquals
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Assume
|
||||||
import org.junit.Before
|
import org.junit.Before
|
||||||
import org.junit.Rule
|
import org.junit.Rule
|
||||||
import org.junit.Test
|
import org.junit.Test
|
||||||
@@ -32,7 +33,7 @@ class ServiceRefresherTest {
|
|||||||
lateinit var db: AppDatabase
|
lateinit var db: AppDatabase
|
||||||
|
|
||||||
@Inject
|
@Inject
|
||||||
lateinit var httpClientBuilder: HttpClientBuilder
|
lateinit var httpClientBuilder: HttpClient.Builder
|
||||||
|
|
||||||
@Inject
|
@Inject
|
||||||
lateinit var logger: Logger
|
lateinit var logger: Logger
|
||||||
@@ -40,7 +41,7 @@ class ServiceRefresherTest {
|
|||||||
@Inject
|
@Inject
|
||||||
lateinit var serviceRefresherFactory: ServiceRefresher.Factory
|
lateinit var serviceRefresherFactory: ServiceRefresher.Factory
|
||||||
|
|
||||||
private lateinit var client: OkHttpClient
|
private lateinit var client: HttpClient
|
||||||
private lateinit var mockServer: MockWebServer
|
private lateinit var mockServer: MockWebServer
|
||||||
private lateinit var service: Service
|
private lateinit var service: Service
|
||||||
|
|
||||||
@@ -56,6 +57,7 @@ class ServiceRefresherTest {
|
|||||||
|
|
||||||
// build HTTP client
|
// build HTTP client
|
||||||
client = httpClientBuilder.build()
|
client = httpClientBuilder.build()
|
||||||
|
Assume.assumeTrue(NetworkSecurityPolicy.getInstance().isCleartextTrafficPermitted)
|
||||||
|
|
||||||
// insert test service
|
// insert test service
|
||||||
val serviceId = db.serviceDao().insertOrReplace(
|
val serviceId = db.serviceDao().insertOrReplace(
|
||||||
@@ -66,6 +68,7 @@ class ServiceRefresherTest {
|
|||||||
|
|
||||||
@After
|
@After
|
||||||
fun tearDown() {
|
fun tearDown() {
|
||||||
|
client.close()
|
||||||
mockServer.shutdown()
|
mockServer.shutdown()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -75,7 +78,7 @@ class ServiceRefresherTest {
|
|||||||
val baseUrl = mockServer.url(PATH_CARDDAV + SUBPATH_PRINCIPAL)
|
val baseUrl = mockServer.url(PATH_CARDDAV + SUBPATH_PRINCIPAL)
|
||||||
|
|
||||||
// Query home sets
|
// Query home sets
|
||||||
serviceRefresherFactory.create(service, client)
|
serviceRefresherFactory.create(service, client.okHttpClient)
|
||||||
.discoverHomesets(baseUrl)
|
.discoverHomesets(baseUrl)
|
||||||
|
|
||||||
// Check home set has been saved correctly to database
|
// Check home set has been saved correctly to database
|
||||||
|
|||||||
@@ -1,149 +0,0 @@
|
|||||||
/*
|
|
||||||
* 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)
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
@@ -6,20 +6,22 @@ package at.bitfire.davdroid.sync
|
|||||||
|
|
||||||
import android.accounts.Account
|
import android.accounts.Account
|
||||||
import android.content.ContentResolver
|
import android.content.ContentResolver
|
||||||
|
import android.content.Context
|
||||||
import android.content.SyncRequest
|
import android.content.SyncRequest
|
||||||
import android.content.SyncStatusObserver
|
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
import android.provider.CalendarContract
|
import android.provider.CalendarContract
|
||||||
import androidx.test.filters.SdkSuppress
|
import androidx.test.filters.SdkSuppress
|
||||||
import at.bitfire.davdroid.sync.account.TestAccount
|
import at.bitfire.davdroid.sync.account.TestAccount
|
||||||
|
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||||
import dagger.hilt.android.testing.HiltAndroidRule
|
import dagger.hilt.android.testing.HiltAndroidRule
|
||||||
import dagger.hilt.android.testing.HiltAndroidTest
|
import dagger.hilt.android.testing.HiltAndroidTest
|
||||||
|
import io.mockk.junit4.MockKRule
|
||||||
|
import junit.framework.AssertionFailedError
|
||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
import kotlinx.coroutines.runBlocking
|
import kotlinx.coroutines.runBlocking
|
||||||
import kotlinx.coroutines.withTimeout
|
import kotlinx.coroutines.withTimeout
|
||||||
import org.junit.After
|
import org.junit.After
|
||||||
import org.junit.AfterClass
|
import org.junit.AfterClass
|
||||||
import org.junit.Assert.assertTrue
|
|
||||||
import org.junit.Before
|
import org.junit.Before
|
||||||
import org.junit.BeforeClass
|
import org.junit.BeforeClass
|
||||||
import org.junit.Rule
|
import org.junit.Rule
|
||||||
@@ -31,11 +33,18 @@ import javax.inject.Inject
|
|||||||
import kotlin.time.Duration.Companion.seconds
|
import kotlin.time.Duration.Companion.seconds
|
||||||
|
|
||||||
@HiltAndroidTest
|
@HiltAndroidTest
|
||||||
class AndroidSyncFrameworkTest: SyncStatusObserver {
|
class AndroidSyncFrameworkTest {
|
||||||
|
|
||||||
@get:Rule
|
@get:Rule
|
||||||
val hiltRule = HiltAndroidRule(this)
|
val hiltRule = HiltAndroidRule(this)
|
||||||
|
|
||||||
|
@get:Rule
|
||||||
|
val mockkRule = MockKRule(this)
|
||||||
|
|
||||||
|
@Inject
|
||||||
|
@ApplicationContext
|
||||||
|
lateinit var context: Context
|
||||||
|
|
||||||
@Inject
|
@Inject
|
||||||
lateinit var logger: Logger
|
lateinit var logger: Logger
|
||||||
|
|
||||||
@@ -59,7 +68,7 @@ class AndroidSyncFrameworkTest: SyncStatusObserver {
|
|||||||
onStatusChanged(0) // record first entry (pending = false, active = false)
|
onStatusChanged(0) // record first entry (pending = false, active = false)
|
||||||
stateChangeListener = ContentResolver.addStatusChangeListener(
|
stateChangeListener = ContentResolver.addStatusChangeListener(
|
||||||
ContentResolver.SYNC_OBSERVER_TYPE_PENDING or ContentResolver.SYNC_OBSERVER_TYPE_ACTIVE,
|
ContentResolver.SYNC_OBSERVER_TYPE_PENDING or ContentResolver.SYNC_OBSERVER_TYPE_ACTIVE,
|
||||||
this
|
::onStatusChanged
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -88,11 +97,11 @@ class AndroidSyncFrameworkTest: SyncStatusObserver {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/* SHOULD BE FIXED WITH https://github.com/bitfireAT/davx5-ose/issues/1748
|
/**
|
||||||
* Wrong behaviour of the sync framework on Android 14+.
|
* Wrong behaviour of the sync framework on Android 14+.
|
||||||
* Pending state stays true forever (after initial run), active state behaves correctly
|
* Pending state stays true forever (after initial run), active state behaves correctly
|
||||||
*/
|
*/
|
||||||
/*@SdkSuppress(minSdkVersion = 34 /*, maxSdkVersion = 36 */)
|
@SdkSuppress(minSdkVersion = 34 /*, maxSdkVersion = 36 */)
|
||||||
@Test
|
@Test
|
||||||
fun testVerifySyncAlwaysPending_wrongBehaviour_android14() {
|
fun testVerifySyncAlwaysPending_wrongBehaviour_android14() {
|
||||||
verifySyncStates(
|
verifySyncStates(
|
||||||
@@ -103,7 +112,7 @@ class AndroidSyncFrameworkTest: SyncStatusObserver {
|
|||||||
State(pending = true, active = false) // ... and finishes, but stays pending
|
State(pending = true, active = false) // ... and finishes, but stays pending
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
}*/
|
}
|
||||||
|
|
||||||
|
|
||||||
// helpers
|
// helpers
|
||||||
@@ -120,10 +129,6 @@ class AndroidSyncFrameworkTest: SyncStatusObserver {
|
|||||||
* Verifies that the given expected states match the recorded states.
|
* Verifies that the given expected states match the recorded states.
|
||||||
*/
|
*/
|
||||||
private fun verifySyncStates(expectedStates: List<State>) = runBlocking {
|
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
|
// 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
|
// which does not auto-advance virtual time and we need real system time to
|
||||||
// test the sync framework behavior.
|
// test the sync framework behavior.
|
||||||
@@ -138,60 +143,47 @@ class AndroidSyncFrameworkTest: SyncStatusObserver {
|
|||||||
while (recordedStates.size < expectedStates.size) {
|
while (recordedStates.size < expectedStates.size) {
|
||||||
// verify already known states
|
// verify already known states
|
||||||
if (recordedStates.isNotEmpty())
|
if (recordedStates.isNotEmpty())
|
||||||
assertStatesEqual(expectedStates, recordedStates, fullMatch = false)
|
assertStatesEqual(expectedStates.subList(0, recordedStates.size), recordedStates)
|
||||||
|
|
||||||
delay(500) // avoid busy-waiting
|
delay(500) // avoid busy-waiting
|
||||||
}
|
}
|
||||||
|
|
||||||
assertStatesEqual(expectedStates, recordedStates, fullMatch = true)
|
assertStatesEqual(expectedStates, recordedStates)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
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
|
* Asserts whether [actualStates] and [expectedStates] are the same, under the condition
|
||||||
* that expected states with the [State.optional] flag can be skipped.
|
* 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 {
|
private fun assertStatesEqual(expectedStates: List<State>, actualStates: List<State>) {
|
||||||
|
fun fail() {
|
||||||
|
throw AssertionFailedError("Expected states=$expectedStates, actual=$actualStates")
|
||||||
|
}
|
||||||
|
|
||||||
// iterate through entries
|
// iterate through entries
|
||||||
val expectedIterator = expectedStates.iterator()
|
val expectedIterator = expectedStates.iterator()
|
||||||
for (actual in actualStates) {
|
for (actual in actualStates) {
|
||||||
if (!expectedIterator.hasNext())
|
if (!expectedIterator.hasNext())
|
||||||
return false
|
fail()
|
||||||
var expected = expectedIterator.next()
|
var expected = expectedIterator.next()
|
||||||
|
|
||||||
// skip optional expected entries if they don't match the actual entry
|
// skip optional expected entries if they don't match the actual entry
|
||||||
while (!actual.stateEquals(expected) && expected.optional) {
|
while (!actual.stateEquals(expected) && expected.optional) {
|
||||||
if (!expectedIterator.hasNext())
|
if (!expectedIterator.hasNext())
|
||||||
return false
|
fail()
|
||||||
expected = expectedIterator.next()
|
expected = expectedIterator.next()
|
||||||
}
|
}
|
||||||
|
|
||||||
// we now have a non-optional expected state and it must match
|
|
||||||
if (!actual.stateEquals(expected))
|
if (!actual.stateEquals(expected))
|
||||||
return false
|
fail()
|
||||||
}
|
}
|
||||||
|
|
||||||
// full match: all expected states must have been used
|
|
||||||
if (fullMatch && expectedIterator.hasNext())
|
|
||||||
return false
|
|
||||||
|
|
||||||
return true
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
// SyncStatusObserver implementation and data class
|
// SyncStatusObserver implementation and data class
|
||||||
|
|
||||||
override fun onStatusChanged(which: Int) {
|
fun onStatusChanged(which: Int) {
|
||||||
val state = State(
|
val state = State(
|
||||||
pending = ContentResolver.isSyncPending(account, authority),
|
pending = ContentResolver.isSyncPending(account, authority),
|
||||||
active = ContentResolver.isSyncActive(account, authority)
|
active = ContentResolver.isSyncActive(account, authority)
|
||||||
|
|||||||
@@ -1,151 +0,0 @@
|
|||||||
/*
|
|
||||||
* 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()
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
@@ -9,7 +9,7 @@ import android.content.ContentProviderClient
|
|||||||
import android.content.Context
|
import android.content.Context
|
||||||
import at.bitfire.davdroid.db.Collection
|
import at.bitfire.davdroid.db.Collection
|
||||||
import at.bitfire.davdroid.db.Service
|
import at.bitfire.davdroid.db.Service
|
||||||
import at.bitfire.davdroid.network.HttpClientBuilder
|
import at.bitfire.davdroid.network.HttpClient
|
||||||
import at.bitfire.davdroid.repository.DavServiceRepository
|
import at.bitfire.davdroid.repository.DavServiceRepository
|
||||||
import at.bitfire.davdroid.resource.LocalJtxCollection
|
import at.bitfire.davdroid.resource.LocalJtxCollection
|
||||||
import at.bitfire.davdroid.resource.LocalJtxCollectionStore
|
import at.bitfire.davdroid.resource.LocalJtxCollectionStore
|
||||||
@@ -46,7 +46,7 @@ class JtxSyncManagerTest {
|
|||||||
lateinit var context: Context
|
lateinit var context: Context
|
||||||
|
|
||||||
@Inject
|
@Inject
|
||||||
lateinit var httpClientBuilder: HttpClientBuilder
|
lateinit var httpClientBuilder: HttpClient.Builder
|
||||||
|
|
||||||
@Inject
|
@Inject
|
||||||
lateinit var serviceRepository: DavServiceRepository
|
lateinit var serviceRepository: DavServiceRepository
|
||||||
|
|||||||
@@ -4,11 +4,10 @@
|
|||||||
|
|
||||||
package at.bitfire.davdroid.sync
|
package at.bitfire.davdroid.sync
|
||||||
|
|
||||||
import android.content.Context
|
|
||||||
import at.bitfire.davdroid.resource.LocalResource
|
import at.bitfire.davdroid.resource.LocalResource
|
||||||
import java.util.Optional
|
import java.util.Optional
|
||||||
|
|
||||||
class LocalTestResource: LocalResource {
|
class LocalTestResource: LocalResource<Any> {
|
||||||
|
|
||||||
override val id: Long? = null
|
override val id: Long? = null
|
||||||
override var fileName: String? = null
|
override var fileName: String? = null
|
||||||
@@ -19,6 +18,8 @@ class LocalTestResource: LocalResource {
|
|||||||
var deleted = false
|
var deleted = false
|
||||||
var dirty = false
|
var dirty = false
|
||||||
|
|
||||||
|
override fun prepareForUpload() = "generated-file.txt"
|
||||||
|
|
||||||
override fun clearDirty(fileName: Optional<String>, eTag: String?, scheduleTag: String?) {
|
override fun clearDirty(fileName: Optional<String>, eTag: String?, scheduleTag: String?) {
|
||||||
dirty = false
|
dirty = false
|
||||||
if (fileName.isPresent)
|
if (fileName.isPresent)
|
||||||
@@ -31,14 +32,8 @@ class LocalTestResource: LocalResource {
|
|||||||
this.flags = flags
|
this.flags = flags
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun updateUid(uid: String) { /* no-op */ }
|
override fun update(data: Any, fileName: String?, eTag: String?, scheduleTag: String?, flags: Int) = throw NotImplementedError()
|
||||||
override fun updateSequence(sequence: Int) = throw NotImplementedError()
|
|
||||||
|
|
||||||
override fun deleteLocal() = throw NotImplementedError()
|
override fun deleteLocal() = throw NotImplementedError()
|
||||||
override fun resetDeleted() = throw NotImplementedError()
|
override fun resetDeleted() = throw NotImplementedError()
|
||||||
|
|
||||||
override fun getDebugSummary() = "Test Resource"
|
|
||||||
|
|
||||||
override fun getViewUri(context: Context) = null
|
|
||||||
|
|
||||||
}
|
}
|
||||||
@@ -1,135 +0,0 @@
|
|||||||
/*
|
|
||||||
* 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.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.Before
|
|
||||||
import org.junit.Rule
|
|
||||||
import org.junit.Test
|
|
||||||
import java.net.InetAddress
|
|
||||||
import javax.inject.Inject
|
|
||||||
|
|
||||||
@HiltAndroidTest
|
|
||||||
class ResourceRetrieverTest {
|
|
||||||
|
|
||||||
@get:Rule
|
|
||||||
val hiltRule = HiltAndroidRule(this)
|
|
||||||
|
|
||||||
@Inject
|
|
||||||
lateinit var accountSettingsFactory: AccountSettings.Factory
|
|
||||||
|
|
||||||
@Inject
|
|
||||||
lateinit var resourceRetrieverFactory: ResourceRetriever.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 testRetrieve_DataUri() = runTest {
|
|
||||||
val downloader = resourceRetrieverFactory.create(account, "example.com")
|
|
||||||
val result = downloader.retrieve("data:image/png;base64,dGVzdA==")
|
|
||||||
assertArrayEquals("test".toByteArray(), result)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun testRetrieve_DataUri_Invalid() = runTest {
|
|
||||||
val downloader = resourceRetrieverFactory.create(account, "example.com")
|
|
||||||
val result = downloader.retrieve("data:;INVALID,INVALID")
|
|
||||||
assertNull(result)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun testRetrieve_ExternalDomain() = runTest {
|
|
||||||
val baseUrl = server.url("/")
|
|
||||||
val localhostIp = InetAddress.getByName(baseUrl.host).hostAddress!!
|
|
||||||
|
|
||||||
// URL should be http://localhost, replace with http://127.0.0.1 to have other domain
|
|
||||||
val baseUrlIp = baseUrl.newBuilder()
|
|
||||||
.host(localhostIp)
|
|
||||||
.build()
|
|
||||||
|
|
||||||
server.enqueue(MockResponse()
|
|
||||||
.setResponseCode(200)
|
|
||||||
.setBody("TEST"))
|
|
||||||
|
|
||||||
val downloader = resourceRetrieverFactory.create(account, baseUrl.host)
|
|
||||||
val result = downloader.retrieve(baseUrlIp.toString())
|
|
||||||
|
|
||||||
// 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 testRetrieve_FtpUrl() = runTest {
|
|
||||||
val downloader = resourceRetrieverFactory.create(account, "example.com")
|
|
||||||
val result = downloader.retrieve("ftp://example.com/photo.jpg")
|
|
||||||
assertNull(result)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun testRetrieve_RelativeHttpsUrl() = runTest {
|
|
||||||
val downloader = resourceRetrieverFactory.create(account, "example.com")
|
|
||||||
val result = downloader.retrieve("https:photo.jpg")
|
|
||||||
assertNull(result)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun testRetrieve_SameDomain() = runTest {
|
|
||||||
server.enqueue(MockResponse()
|
|
||||||
.setResponseCode(200)
|
|
||||||
.setBody("TEST"))
|
|
||||||
|
|
||||||
val baseUrl = server.url("/")
|
|
||||||
val downloader = resourceRetrieverFactory.create(account, baseUrl.host)
|
|
||||||
val result = downloader.retrieve(baseUrl.toString())
|
|
||||||
|
|
||||||
// authentication was sent
|
|
||||||
val sentAuth = server.takeRequest().getHeader(HttpHeaders.Authorization)
|
|
||||||
assertEquals("Basic dGVzdDp0ZXN0", sentAuth)
|
|
||||||
|
|
||||||
// and result is OK
|
|
||||||
assertArrayEquals("TEST".toByteArray(), result)
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
@@ -8,14 +8,14 @@ import android.accounts.Account
|
|||||||
import android.content.Context
|
import android.content.Context
|
||||||
import androidx.core.app.NotificationManagerCompat
|
import androidx.core.app.NotificationManagerCompat
|
||||||
import androidx.hilt.work.HiltWorkerFactory
|
import androidx.hilt.work.HiltWorkerFactory
|
||||||
import at.bitfire.dav4jvm.okhttp.PropStat
|
import at.bitfire.dav4jvm.PropStat
|
||||||
import at.bitfire.dav4jvm.okhttp.Response
|
import at.bitfire.dav4jvm.Response
|
||||||
import at.bitfire.dav4jvm.okhttp.Response.HrefRelation
|
import at.bitfire.dav4jvm.Response.HrefRelation
|
||||||
import at.bitfire.dav4jvm.property.webdav.GetETag
|
import at.bitfire.dav4jvm.property.webdav.GetETag
|
||||||
import at.bitfire.davdroid.TestUtils
|
import at.bitfire.davdroid.TestUtils
|
||||||
import at.bitfire.davdroid.TestUtils.assertWithin
|
import at.bitfire.davdroid.TestUtils.assertWithin
|
||||||
import at.bitfire.davdroid.db.Collection
|
import at.bitfire.davdroid.db.Collection
|
||||||
import at.bitfire.davdroid.network.HttpClientBuilder
|
import at.bitfire.davdroid.network.HttpClient
|
||||||
import at.bitfire.davdroid.repository.DavSyncStatsRepository
|
import at.bitfire.davdroid.repository.DavSyncStatsRepository
|
||||||
import at.bitfire.davdroid.resource.SyncState
|
import at.bitfire.davdroid.resource.SyncState
|
||||||
import at.bitfire.davdroid.settings.AccountSettings
|
import at.bitfire.davdroid.settings.AccountSettings
|
||||||
@@ -59,7 +59,7 @@ class SyncManagerTest {
|
|||||||
lateinit var context: Context
|
lateinit var context: Context
|
||||||
|
|
||||||
@Inject
|
@Inject
|
||||||
lateinit var httpClientBuilder: HttpClientBuilder
|
lateinit var httpClientBuilder: HttpClient.Builder
|
||||||
|
|
||||||
@Inject
|
@Inject
|
||||||
lateinit var syncManagerFactory: TestSyncManager.Factory
|
lateinit var syncManagerFactory: TestSyncManager.Factory
|
||||||
|
|||||||
@@ -194,7 +194,7 @@ class SyncerTest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
override fun create(
|
override fun create(
|
||||||
client: ContentProviderClient,
|
provider: ContentProviderClient,
|
||||||
fromCollection: Collection
|
fromCollection: Collection
|
||||||
): LocalTestCollection? {
|
): LocalTestCollection? {
|
||||||
throw NotImplementedError()
|
throw NotImplementedError()
|
||||||
@@ -202,13 +202,13 @@ class SyncerTest {
|
|||||||
|
|
||||||
override fun getAll(
|
override fun getAll(
|
||||||
account: Account,
|
account: Account,
|
||||||
client: ContentProviderClient
|
provider: ContentProviderClient
|
||||||
): List<LocalTestCollection> {
|
): List<LocalTestCollection> {
|
||||||
throw NotImplementedError()
|
throw NotImplementedError()
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun update(
|
override fun update(
|
||||||
client: ContentProviderClient,
|
provider: ContentProviderClient,
|
||||||
localCollection: LocalTestCollection,
|
localCollection: LocalTestCollection,
|
||||||
fromCollection: Collection
|
fromCollection: Collection
|
||||||
) {
|
) {
|
||||||
@@ -219,7 +219,7 @@ class SyncerTest {
|
|||||||
throw NotImplementedError()
|
throw NotImplementedError()
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun updateAccount(oldAccount: Account, newAccount: Account, client: ContentProviderClient?) {
|
override fun updateAccount(oldAccount: Account, newAccount: Account) {
|
||||||
throw NotImplementedError()
|
throw NotImplementedError()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,13 +5,13 @@
|
|||||||
package at.bitfire.davdroid.sync
|
package at.bitfire.davdroid.sync
|
||||||
|
|
||||||
import android.accounts.Account
|
import android.accounts.Account
|
||||||
import at.bitfire.dav4jvm.okhttp.DavCollection
|
import at.bitfire.dav4jvm.DavCollection
|
||||||
import at.bitfire.dav4jvm.okhttp.MultiResponseCallback
|
import at.bitfire.dav4jvm.MultiResponseCallback
|
||||||
import at.bitfire.dav4jvm.okhttp.Response
|
import at.bitfire.dav4jvm.Response
|
||||||
import at.bitfire.dav4jvm.property.caldav.CalDAV
|
|
||||||
import at.bitfire.dav4jvm.property.caldav.GetCTag
|
import at.bitfire.dav4jvm.property.caldav.GetCTag
|
||||||
import at.bitfire.davdroid.db.Collection
|
import at.bitfire.davdroid.db.Collection
|
||||||
import at.bitfire.davdroid.di.SyncDispatcher
|
import at.bitfire.davdroid.di.SyncDispatcher
|
||||||
|
import at.bitfire.davdroid.network.HttpClient
|
||||||
import at.bitfire.davdroid.resource.LocalResource
|
import at.bitfire.davdroid.resource.LocalResource
|
||||||
import at.bitfire.davdroid.resource.SyncState
|
import at.bitfire.davdroid.resource.SyncState
|
||||||
import at.bitfire.davdroid.util.DavUtils.lastSegment
|
import at.bitfire.davdroid.util.DavUtils.lastSegment
|
||||||
@@ -20,13 +20,13 @@ import dagger.assisted.AssistedFactory
|
|||||||
import dagger.assisted.AssistedInject
|
import dagger.assisted.AssistedInject
|
||||||
import kotlinx.coroutines.CoroutineDispatcher
|
import kotlinx.coroutines.CoroutineDispatcher
|
||||||
import okhttp3.HttpUrl
|
import okhttp3.HttpUrl
|
||||||
import okhttp3.OkHttpClient
|
import okhttp3.RequestBody
|
||||||
import okhttp3.RequestBody.Companion.toRequestBody
|
import okhttp3.RequestBody.Companion.toRequestBody
|
||||||
import org.junit.Assert.assertEquals
|
import org.junit.Assert.assertEquals
|
||||||
|
|
||||||
class TestSyncManager @AssistedInject constructor(
|
class TestSyncManager @AssistedInject constructor(
|
||||||
@Assisted account: Account,
|
@Assisted account: Account,
|
||||||
@Assisted httpClient: OkHttpClient,
|
@Assisted httpClient: HttpClient,
|
||||||
@Assisted syncResult: SyncResult,
|
@Assisted syncResult: SyncResult,
|
||||||
@Assisted localCollection: LocalTestCollection,
|
@Assisted localCollection: LocalTestCollection,
|
||||||
@Assisted collection: Collection,
|
@Assisted collection: Collection,
|
||||||
@@ -46,7 +46,7 @@ class TestSyncManager @AssistedInject constructor(
|
|||||||
interface Factory {
|
interface Factory {
|
||||||
fun create(
|
fun create(
|
||||||
account: Account,
|
account: Account,
|
||||||
httpClient: OkHttpClient,
|
httpClient: HttpClient,
|
||||||
syncResult: SyncResult,
|
syncResult: SyncResult,
|
||||||
localCollection: LocalTestCollection,
|
localCollection: LocalTestCollection,
|
||||||
collection: Collection
|
collection: Collection
|
||||||
@@ -54,7 +54,7 @@ class TestSyncManager @AssistedInject constructor(
|
|||||||
}
|
}
|
||||||
|
|
||||||
override fun prepare(): Boolean {
|
override fun prepare(): Boolean {
|
||||||
davCollection = DavCollection(httpClient, collection.url)
|
davCollection = DavCollection(httpClient.okHttpClient, collection.url)
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -65,7 +65,7 @@ class TestSyncManager @AssistedInject constructor(
|
|||||||
didQueryCapabilities = true
|
didQueryCapabilities = true
|
||||||
|
|
||||||
var cTag: SyncState? = null
|
var cTag: SyncState? = null
|
||||||
davCollection.propfind(0, CalDAV.GetCTag) { response, rel ->
|
davCollection.propfind(0, GetCTag.NAME) { response, rel ->
|
||||||
if (rel == Response.HrefRelation.SELF)
|
if (rel == Response.HrefRelation.SELF)
|
||||||
response[GetCTag::class.java]?.cTag?.let {
|
response[GetCTag::class.java]?.cTag?.let {
|
||||||
cTag = SyncState(SyncState.Type.CTAG, it)
|
cTag = SyncState(SyncState.Type.CTAG, it)
|
||||||
@@ -76,13 +76,9 @@ class TestSyncManager @AssistedInject constructor(
|
|||||||
}
|
}
|
||||||
|
|
||||||
var didGenerateUpload = false
|
var didGenerateUpload = false
|
||||||
override fun generateUpload(resource: LocalTestResource): GeneratedResource {
|
override fun generateUpload(resource: LocalTestResource): RequestBody {
|
||||||
didGenerateUpload = true
|
didGenerateUpload = true
|
||||||
return GeneratedResource(
|
return resource.toString().toRequestBody()
|
||||||
suggestedFileName = resource.fileName ?: "generated-file.txt",
|
|
||||||
requestBody = resource.toString().toRequestBody(),
|
|
||||||
onSuccessContext = GeneratedResource.OnSuccessContext()
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun syncAlgorithm() = SyncAlgorithm.PROPFIND_REPORT
|
override fun syncAlgorithm() = SyncAlgorithm.PROPFIND_REPORT
|
||||||
|
|||||||
@@ -8,8 +8,6 @@ import android.accounts.AccountManager
|
|||||||
import androidx.test.platform.app.InstrumentationRegistry
|
import androidx.test.platform.app.InstrumentationRegistry
|
||||||
import at.bitfire.davdroid.R
|
import at.bitfire.davdroid.R
|
||||||
import at.bitfire.davdroid.settings.AccountSettings
|
import at.bitfire.davdroid.settings.AccountSettings
|
||||||
import at.bitfire.davdroid.sync.account.TestAccount.remove
|
|
||||||
import org.junit.Assert.assertEquals
|
|
||||||
import org.junit.Assert.assertTrue
|
import org.junit.Assert.assertTrue
|
||||||
|
|
||||||
object TestAccount {
|
object TestAccount {
|
||||||
@@ -21,9 +19,9 @@ object TestAccount {
|
|||||||
*
|
*
|
||||||
* Remove it with [remove].
|
* Remove it with [remove].
|
||||||
*/
|
*/
|
||||||
fun create(version: Int = AccountSettings.CURRENT_VERSION, accountName: String = "Test Account"): Account {
|
fun create(version: Int = AccountSettings.CURRENT_VERSION): Account {
|
||||||
val accountType = targetContext.getString(R.string.account_type)
|
val accountType = targetContext.getString(R.string.account_type)
|
||||||
val account = Account(accountName, accountType)
|
val account = Account("Test Account", accountType)
|
||||||
|
|
||||||
val initialData = AccountSettings.initialUserData(null)
|
val initialData = AccountSettings.initialUserData(null)
|
||||||
initialData.putString(AccountSettings.KEY_SETTINGS_VERSION, version.toString())
|
initialData.putString(AccountSettings.KEY_SETTINGS_VERSION, version.toString())
|
||||||
@@ -32,16 +30,6 @@ object TestAccount {
|
|||||||
return account
|
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.
|
* 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)
|
val expected = StringBuilder(DebugInfoActivity.IntentBuilder.MAX_ELEMENT_SIZE)
|
||||||
expected.append(String(ByteArray(DebugInfoActivity.IntentBuilder.MAX_ELEMENT_SIZE - 3) { a }))
|
expected.append(String(ByteArray(DebugInfoActivity.IntentBuilder.MAX_ELEMENT_SIZE - 3) { a }))
|
||||||
expected.append("...")
|
expected.append("...")
|
||||||
assertEquals(expected.toString(), intent.getStringExtra(DebugInfoActivity.EXTRA_LOCAL_RESOURCE_SUMMARY))
|
assertEquals(expected.toString(), intent.getStringExtra("localResource"))
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ class LoginActivityTest {
|
|||||||
val loginInfo = LoginActivity.loginInfoFromIntent(intent)
|
val loginInfo = LoginActivity.loginInfoFromIntent(intent)
|
||||||
assertEquals("https://example.com/nextcloud", loginInfo.baseUri.toString())
|
assertEquals("https://example.com/nextcloud", loginInfo.baseUri.toString())
|
||||||
assertEquals("user", loginInfo.credentials!!.username)
|
assertEquals("user", loginInfo.credentials!!.username)
|
||||||
assertEquals("password", loginInfo.credentials.password?.asString())
|
assertEquals("password", loginInfo.credentials.password?.concatToString())
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -34,7 +34,7 @@ class LoginActivityTest {
|
|||||||
val loginInfo = LoginActivity.loginInfoFromIntent(intent)
|
val loginInfo = LoginActivity.loginInfoFromIntent(intent)
|
||||||
assertEquals("https://example.com:444/nextcloud", loginInfo.baseUri.toString())
|
assertEquals("https://example.com:444/nextcloud", loginInfo.baseUri.toString())
|
||||||
assertEquals("user", loginInfo.credentials!!.username)
|
assertEquals("user", loginInfo.credentials!!.username)
|
||||||
assertEquals("password", loginInfo.credentials.password?.asString())
|
assertEquals("password", loginInfo.credentials.password?.concatToString())
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -43,7 +43,7 @@ class LoginActivityTest {
|
|||||||
val loginInfo = LoginActivity.loginInfoFromIntent(intent)
|
val loginInfo = LoginActivity.loginInfoFromIntent(intent)
|
||||||
assertEquals("https://example.com/path", loginInfo.baseUri.toString())
|
assertEquals("https://example.com/path", loginInfo.baseUri.toString())
|
||||||
assertEquals("user", loginInfo.credentials!!.username)
|
assertEquals("user", loginInfo.credentials!!.username)
|
||||||
assertEquals("password", loginInfo.credentials.password?.asString())
|
assertEquals("password", loginInfo.credentials.password?.concatToString())
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -52,7 +52,7 @@ class LoginActivityTest {
|
|||||||
val loginInfo = LoginActivity.loginInfoFromIntent(intent)
|
val loginInfo = LoginActivity.loginInfoFromIntent(intent)
|
||||||
assertEquals("https://example.com:0/path", loginInfo.baseUri.toString())
|
assertEquals("https://example.com:0/path", loginInfo.baseUri.toString())
|
||||||
assertEquals("user", loginInfo.credentials!!.username)
|
assertEquals("user", loginInfo.credentials!!.username)
|
||||||
assertEquals("password", loginInfo.credentials.password?.asString())
|
assertEquals("password", loginInfo.credentials.password?.concatToString())
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -61,7 +61,7 @@ class LoginActivityTest {
|
|||||||
val loginInfo = LoginActivity.loginInfoFromIntent(intent)
|
val loginInfo = LoginActivity.loginInfoFromIntent(intent)
|
||||||
assertEquals(null, loginInfo.baseUri)
|
assertEquals(null, loginInfo.baseUri)
|
||||||
assertEquals("user@example.com", loginInfo.credentials!!.username)
|
assertEquals("user@example.com", loginInfo.credentials!!.username)
|
||||||
assertEquals(null, loginInfo.credentials.password?.asString())
|
assertEquals(null, loginInfo.credentials.password?.concatToString())
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
@@ -5,7 +5,6 @@
|
|||||||
package at.bitfire.davdroid.webdav
|
package at.bitfire.davdroid.webdav
|
||||||
|
|
||||||
import at.bitfire.davdroid.settings.Credentials
|
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.HiltAndroidRule
|
||||||
import dagger.hilt.android.testing.HiltAndroidTest
|
import dagger.hilt.android.testing.HiltAndroidTest
|
||||||
import org.junit.Assert.assertEquals
|
import org.junit.Assert.assertEquals
|
||||||
@@ -31,8 +30,8 @@ class CredentialsStoreTest {
|
|||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun testSetGetDelete() {
|
fun testSetGetDelete() {
|
||||||
store.setCredentials(0, Credentials(username = "myname", password = "12345".toSensitiveString()))
|
store.setCredentials(0, Credentials(username = "myname", password = "12345".toCharArray()))
|
||||||
assertEquals(Credentials(username = "myname", password = "12345".toSensitiveString()), store.getCredentials(0))
|
assertEquals(Credentials(username = "myname", password = "12345".toCharArray()), store.getCredentials(0))
|
||||||
|
|
||||||
store.setCredentials(0, null)
|
store.setCredentials(0, null)
|
||||||
assertNull(store.getCredentials(0))
|
assertNull(store.getCredentials(0))
|
||||||
|
|||||||
@@ -9,14 +9,13 @@ import android.security.NetworkSecurityPolicy
|
|||||||
import at.bitfire.davdroid.db.AppDatabase
|
import at.bitfire.davdroid.db.AppDatabase
|
||||||
import at.bitfire.davdroid.db.WebDavDocument
|
import at.bitfire.davdroid.db.WebDavDocument
|
||||||
import at.bitfire.davdroid.db.WebDavMount
|
import at.bitfire.davdroid.db.WebDavMount
|
||||||
import at.bitfire.davdroid.network.HttpClientBuilder
|
import at.bitfire.davdroid.network.HttpClient
|
||||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||||
import dagger.hilt.android.testing.HiltAndroidRule
|
import dagger.hilt.android.testing.HiltAndroidRule
|
||||||
import dagger.hilt.android.testing.HiltAndroidTest
|
import dagger.hilt.android.testing.HiltAndroidTest
|
||||||
import io.mockk.junit4.MockKRule
|
import io.mockk.junit4.MockKRule
|
||||||
import kotlinx.coroutines.runBlocking
|
import kotlinx.coroutines.runBlocking
|
||||||
import kotlinx.coroutines.test.runTest
|
import kotlinx.coroutines.test.runTest
|
||||||
import okhttp3.OkHttpClient
|
|
||||||
import okhttp3.mockwebserver.Dispatcher
|
import okhttp3.mockwebserver.Dispatcher
|
||||||
import okhttp3.mockwebserver.MockResponse
|
import okhttp3.mockwebserver.MockResponse
|
||||||
import okhttp3.mockwebserver.MockWebServer
|
import okhttp3.mockwebserver.MockWebServer
|
||||||
@@ -49,13 +48,13 @@ class QueryChildDocumentsOperationTest {
|
|||||||
lateinit var operation: QueryChildDocumentsOperation
|
lateinit var operation: QueryChildDocumentsOperation
|
||||||
|
|
||||||
@Inject
|
@Inject
|
||||||
lateinit var httpClientBuilder: HttpClientBuilder
|
lateinit var httpClientBuilder: HttpClient.Builder
|
||||||
|
|
||||||
@Inject
|
@Inject
|
||||||
lateinit var testDispatcher: TestDispatcher
|
lateinit var testDispatcher: TestDispatcher
|
||||||
|
|
||||||
private lateinit var server: MockWebServer
|
private lateinit var server: MockWebServer
|
||||||
private lateinit var client: OkHttpClient
|
private lateinit var client: HttpClient
|
||||||
|
|
||||||
private lateinit var mount: WebDavMount
|
private lateinit var mount: WebDavMount
|
||||||
private lateinit var rootDocument: WebDavDocument
|
private lateinit var rootDocument: WebDavDocument
|
||||||
@@ -85,6 +84,7 @@ class QueryChildDocumentsOperationTest {
|
|||||||
|
|
||||||
@After
|
@After
|
||||||
fun tearDown() {
|
fun tearDown() {
|
||||||
|
client.close()
|
||||||
server.shutdown()
|
server.shutdown()
|
||||||
|
|
||||||
runBlocking {
|
runBlocking {
|
||||||
|
|||||||
@@ -100,7 +100,7 @@
|
|||||||
<activity
|
<activity
|
||||||
android:name=".ui.DebugInfoActivity"
|
android:name=".ui.DebugInfoActivity"
|
||||||
android:parentActivityName=".ui.AppSettingsActivity"
|
android:parentActivityName=".ui.AppSettingsActivity"
|
||||||
android:exported="false"
|
android:exported="true"
|
||||||
android:label="@string/debug_info_title">
|
android:label="@string/debug_info_title">
|
||||||
<intent-filter>
|
<intent-filter>
|
||||||
<action android:name="android.intent.action.BUG_REPORT"/>
|
<action android:name="android.intent.action.BUG_REPORT"/>
|
||||||
|
|||||||
@@ -7,14 +7,13 @@ package at.bitfire.davdroid
|
|||||||
import android.app.Application
|
import android.app.Application
|
||||||
import androidx.hilt.work.HiltWorkerFactory
|
import androidx.hilt.work.HiltWorkerFactory
|
||||||
import androidx.work.Configuration
|
import androidx.work.Configuration
|
||||||
import at.bitfire.davdroid.di.DefaultDispatcher
|
|
||||||
import at.bitfire.davdroid.log.LogManager
|
import at.bitfire.davdroid.log.LogManager
|
||||||
import at.bitfire.davdroid.startup.StartupPlugin
|
import at.bitfire.davdroid.startup.StartupPlugin
|
||||||
import at.bitfire.davdroid.sync.account.AccountsCleanupWorker
|
import at.bitfire.davdroid.sync.account.AccountsCleanupWorker
|
||||||
import at.bitfire.davdroid.ui.UiUtils
|
import at.bitfire.davdroid.ui.UiUtils
|
||||||
import dagger.hilt.android.HiltAndroidApp
|
import dagger.hilt.android.HiltAndroidApp
|
||||||
import kotlinx.coroutines.CoroutineDispatcher
|
|
||||||
import kotlinx.coroutines.DelicateCoroutinesApi
|
import kotlinx.coroutines.DelicateCoroutinesApi
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.GlobalScope
|
import kotlinx.coroutines.GlobalScope
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import java.util.logging.Logger
|
import java.util.logging.Logger
|
||||||
@@ -32,10 +31,6 @@ class App: Application(), Configuration.Provider {
|
|||||||
@Inject
|
@Inject
|
||||||
lateinit var logManager: LogManager
|
lateinit var logManager: LogManager
|
||||||
|
|
||||||
@Inject
|
|
||||||
@DefaultDispatcher
|
|
||||||
lateinit var defaultDispatcher: CoroutineDispatcher
|
|
||||||
|
|
||||||
@Inject
|
@Inject
|
||||||
lateinit var plugins: Set<@JvmSuppressWildcards StartupPlugin>
|
lateinit var plugins: Set<@JvmSuppressWildcards StartupPlugin>
|
||||||
|
|
||||||
@@ -65,7 +60,7 @@ class App: Application(), Configuration.Provider {
|
|||||||
|
|
||||||
// don't block UI for some background checks
|
// don't block UI for some background checks
|
||||||
@OptIn(DelicateCoroutinesApi::class)
|
@OptIn(DelicateCoroutinesApi::class)
|
||||||
GlobalScope.launch(defaultDispatcher) {
|
GlobalScope.launch(Dispatchers.Default) {
|
||||||
// clean up orphaned accounts in DB from time to time
|
// clean up orphaned accounts in DB from time to time
|
||||||
AccountsCleanupWorker.enable(this@App)
|
AccountsCleanupWorker.enable(this@App)
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ package at.bitfire.davdroid
|
|||||||
|
|
||||||
import at.bitfire.synctools.icalendar.ical4jVersion
|
import at.bitfire.synctools.icalendar.ical4jVersion
|
||||||
import ezvcard.Ezvcard
|
import ezvcard.Ezvcard
|
||||||
|
import net.fortuna.ical4j.model.property.ProdId
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Brand-specific constants like (non-theme) colors, homepage URLs etc.
|
* Brand-specific constants like (non-theme) colors, homepage URLs etc.
|
||||||
@@ -16,7 +17,7 @@ object Constants {
|
|||||||
|
|
||||||
// product IDs for iCalendar/vCard
|
// product IDs for iCalendar/vCard
|
||||||
|
|
||||||
val iCalProdId = "DAVx5/${BuildConfig.VERSION_NAME} ical4j/$ical4jVersion"
|
val iCalProdId = ProdId("DAVx5/${BuildConfig.VERSION_NAME} ical4j/$ical4jVersion")
|
||||||
const val vCardProdId = "+//IDN bitfire.at//DAVx5/${BuildConfig.VERSION_NAME} ez-vcard/${Ezvcard.VERSION}"
|
const val vCardProdId = "+//IDN bitfire.at//DAVx5/${BuildConfig.VERSION_NAME} ez-vcard/${Ezvcard.VERSION}"
|
||||||
|
|
||||||
}
|
}
|
||||||
@@ -10,9 +10,8 @@ import androidx.room.Entity
|
|||||||
import androidx.room.ForeignKey
|
import androidx.room.ForeignKey
|
||||||
import androidx.room.Index
|
import androidx.room.Index
|
||||||
import androidx.room.PrimaryKey
|
import androidx.room.PrimaryKey
|
||||||
import at.bitfire.dav4jvm.okhttp.Response
|
import at.bitfire.dav4jvm.Response
|
||||||
import at.bitfire.dav4jvm.okhttp.UrlUtils
|
import at.bitfire.dav4jvm.UrlUtils
|
||||||
import at.bitfire.dav4jvm.property.caldav.CalDAV
|
|
||||||
import at.bitfire.dav4jvm.property.caldav.CalendarColor
|
import at.bitfire.dav4jvm.property.caldav.CalendarColor
|
||||||
import at.bitfire.dav4jvm.property.caldav.CalendarDescription
|
import at.bitfire.dav4jvm.property.caldav.CalendarDescription
|
||||||
import at.bitfire.dav4jvm.property.caldav.CalendarTimezone
|
import at.bitfire.dav4jvm.property.caldav.CalendarTimezone
|
||||||
@@ -20,7 +19,6 @@ import at.bitfire.dav4jvm.property.caldav.CalendarTimezoneId
|
|||||||
import at.bitfire.dav4jvm.property.caldav.Source
|
import at.bitfire.dav4jvm.property.caldav.Source
|
||||||
import at.bitfire.dav4jvm.property.caldav.SupportedCalendarComponentSet
|
import at.bitfire.dav4jvm.property.caldav.SupportedCalendarComponentSet
|
||||||
import at.bitfire.dav4jvm.property.carddav.AddressbookDescription
|
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.PushTransports
|
||||||
import at.bitfire.dav4jvm.property.push.Topic
|
import at.bitfire.dav4jvm.property.push.Topic
|
||||||
import at.bitfire.dav4jvm.property.push.WebPush
|
import at.bitfire.dav4jvm.property.push.WebPush
|
||||||
@@ -168,9 +166,9 @@ data class Collection(
|
|||||||
val url = UrlUtils.withTrailingSlash(dav.href)
|
val url = UrlUtils.withTrailingSlash(dav.href)
|
||||||
val type: String = dav[ResourceType::class.java]?.let { resourceType ->
|
val type: String = dav[ResourceType::class.java]?.let { resourceType ->
|
||||||
when {
|
when {
|
||||||
resourceType.types.contains(CardDAV.Addressbook) -> TYPE_ADDRESSBOOK
|
resourceType.types.contains(ResourceType.ADDRESSBOOK) -> TYPE_ADDRESSBOOK
|
||||||
resourceType.types.contains(CalDAV.Calendar) -> TYPE_CALENDAR
|
resourceType.types.contains(ResourceType.CALENDAR) -> TYPE_CALENDAR
|
||||||
resourceType.types.contains(CalDAV.Subscribed) -> TYPE_WEBCAL
|
resourceType.types.contains(ResourceType.SUBSCRIBED) -> TYPE_WEBCAL
|
||||||
else -> null
|
else -> null
|
||||||
}
|
}
|
||||||
} ?: return null
|
} ?: return null
|
||||||
|
|||||||
@@ -8,11 +8,10 @@ import androidx.room.Entity
|
|||||||
import androidx.room.ForeignKey
|
import androidx.room.ForeignKey
|
||||||
import androidx.room.Index
|
import androidx.room.Index
|
||||||
import androidx.room.PrimaryKey
|
import androidx.room.PrimaryKey
|
||||||
import at.bitfire.dav4jvm.okhttp.Response
|
import at.bitfire.dav4jvm.Response
|
||||||
import at.bitfire.dav4jvm.okhttp.UrlUtils
|
import at.bitfire.dav4jvm.UrlUtils
|
||||||
import at.bitfire.dav4jvm.property.webdav.DisplayName
|
import at.bitfire.dav4jvm.property.webdav.DisplayName
|
||||||
import at.bitfire.dav4jvm.property.webdav.ResourceType
|
import at.bitfire.dav4jvm.property.webdav.ResourceType
|
||||||
import at.bitfire.dav4jvm.property.webdav.WebDAV
|
|
||||||
import at.bitfire.davdroid.util.trimToNull
|
import at.bitfire.davdroid.util.trimToNull
|
||||||
import okhttp3.HttpUrl
|
import okhttp3.HttpUrl
|
||||||
|
|
||||||
@@ -47,7 +46,7 @@ data class Principal(
|
|||||||
fun fromDavResponse(serviceId: Long, dav: Response): Principal? {
|
fun fromDavResponse(serviceId: Long, dav: Response): Principal? {
|
||||||
// Check if response is a principal
|
// Check if response is a principal
|
||||||
val resourceType = dav[ResourceType::class.java] ?: return null
|
val resourceType = dav[ResourceType::class.java] ?: return null
|
||||||
if (!resourceType.types.contains(WebDAV.Principal))
|
if (!resourceType.types.contains(ResourceType.PRINCIPAL))
|
||||||
return null
|
return null
|
||||||
|
|
||||||
// Try getting the display name of the principal
|
// Try getting the display name of the principal
|
||||||
|
|||||||
@@ -48,20 +48,11 @@ interface PrincipalDao {
|
|||||||
* @param principal Principal to be inserted or updated
|
* @param principal Principal to be inserted or updated
|
||||||
* @return ID of the newly inserted or already existing principal
|
* @return ID of the newly inserted or already existing principal
|
||||||
*/
|
*/
|
||||||
fun insertOrUpdate(serviceId: Long, principal: Principal): Long {
|
fun insertOrUpdate(serviceId: Long, principal: Principal): Long =
|
||||||
// Try to get existing principal by URL
|
getByUrl(serviceId, principal.url)?.let { oldPrincipal ->
|
||||||
val oldPrincipal = getByUrl(serviceId, principal.url)
|
if (principal.displayName != oldPrincipal.displayName)
|
||||||
|
update(principal.copy(id = oldPrincipal.id))
|
||||||
// Insert new principal if not existing
|
return oldPrincipal.id
|
||||||
if (oldPrincipal == null)
|
} ?: insert(principal)
|
||||||
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
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
}
|
||||||
@@ -13,10 +13,8 @@ import androidx.room.Entity
|
|||||||
import androidx.room.ForeignKey
|
import androidx.room.ForeignKey
|
||||||
import androidx.room.Index
|
import androidx.room.Index
|
||||||
import androidx.room.PrimaryKey
|
import androidx.room.PrimaryKey
|
||||||
import at.bitfire.dav4jvm.HttpUtils.toKtorUrl
|
|
||||||
import at.bitfire.davdroid.util.DavUtils.MEDIA_TYPE_OCTET_STREAM
|
import at.bitfire.davdroid.util.DavUtils.MEDIA_TYPE_OCTET_STREAM
|
||||||
import at.bitfire.davdroid.webdav.DocumentState
|
import at.bitfire.davdroid.webdav.DocumentState
|
||||||
import io.ktor.http.Url
|
|
||||||
import okhttp3.HttpUrl
|
import okhttp3.HttpUrl
|
||||||
import okhttp3.MediaType
|
import okhttp3.MediaType
|
||||||
import okhttp3.MediaType.Companion.toMediaTypeOrNull
|
import okhttp3.MediaType.Companion.toMediaTypeOrNull
|
||||||
@@ -130,9 +128,6 @@ data class WebDavDocument(
|
|||||||
return builder.build()
|
return builder.build()
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun toKtorUrl(db: AppDatabase): Url =
|
|
||||||
toHttpUrl(db).toKtorUrl()
|
|
||||||
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Represents a WebDAV document in a given state (with a given ETag/Last-Modified).
|
* Represents a WebDAV document in a given state (with a given ETag/Last-Modified).
|
||||||
|
|||||||
@@ -1,65 +0,0 @@
|
|||||||
/*
|
|
||||||
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
|
|
||||||
*/
|
|
||||||
|
|
||||||
package at.bitfire.davdroid.di
|
|
||||||
|
|
||||||
import android.content.Context
|
|
||||||
import at.bitfire.cert4android.CustomCertManager
|
|
||||||
import at.bitfire.cert4android.CustomCertStore
|
|
||||||
import at.bitfire.cert4android.SettingsProvider
|
|
||||||
import at.bitfire.davdroid.BuildConfig
|
|
||||||
import at.bitfire.davdroid.settings.Settings
|
|
||||||
import at.bitfire.davdroid.settings.SettingsManager
|
|
||||||
import at.bitfire.davdroid.ui.ForegroundTracker
|
|
||||||
import dagger.Module
|
|
||||||
import dagger.Provides
|
|
||||||
import dagger.hilt.InstallIn
|
|
||||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
|
||||||
import dagger.hilt.components.SingletonComponent
|
|
||||||
import okhttp3.internal.tls.OkHostnameVerifier
|
|
||||||
import java.util.Optional
|
|
||||||
import javax.inject.Singleton
|
|
||||||
|
|
||||||
@Module
|
|
||||||
@InstallIn(SingletonComponent::class)
|
|
||||||
/**
|
|
||||||
* cert4android integration module
|
|
||||||
*/
|
|
||||||
class CustomCertManagerModule {
|
|
||||||
|
|
||||||
@Provides
|
|
||||||
@Singleton
|
|
||||||
fun customCertManager(
|
|
||||||
@ApplicationContext context: Context,
|
|
||||||
settings: SettingsManager
|
|
||||||
): Optional<CustomCertManager> =
|
|
||||||
if (BuildConfig.allowCustomCerts)
|
|
||||||
Optional.of(
|
|
||||||
CustomCertManager(
|
|
||||||
certStore = CustomCertStore.getInstance(context),
|
|
||||||
settings = object : SettingsProvider {
|
|
||||||
|
|
||||||
override val appInForeground: Boolean
|
|
||||||
get() = ForegroundTracker.inForeground.value
|
|
||||||
|
|
||||||
override val trustSystemCerts: Boolean
|
|
||||||
get() = !settings.getBoolean(Settings.DISTRUST_SYSTEM_CERTIFICATES)
|
|
||||||
|
|
||||||
}
|
|
||||||
))
|
|
||||||
else
|
|
||||||
Optional.empty()
|
|
||||||
|
|
||||||
@Provides
|
|
||||||
@Singleton
|
|
||||||
fun customHostnameVerifier(
|
|
||||||
customCertManager: Optional<CustomCertManager>
|
|
||||||
): Optional<CustomCertManager.HostnameVerifier> =
|
|
||||||
if (BuildConfig.allowCustomCerts && customCertManager.isPresent) {
|
|
||||||
val hostnameVerifier = customCertManager.get().HostnameVerifier(OkHostnameVerifier)
|
|
||||||
Optional.of(hostnameVerifier)
|
|
||||||
} else
|
|
||||||
Optional.empty()
|
|
||||||
|
|
||||||
}
|
|
||||||
@@ -39,10 +39,6 @@ import javax.inject.Singleton
|
|||||||
*
|
*
|
||||||
* When using the global logger, the class name of the logging calls will still be logged, so there's
|
* 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).
|
* 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
|
@Singleton
|
||||||
class LogManager @Inject constructor(
|
class LogManager @Inject constructor(
|
||||||
@@ -83,10 +79,7 @@ class LogManager @Inject constructor(
|
|||||||
|
|
||||||
// root logger: set default log level and always log to logcat
|
// root logger: set default log level and always log to logcat
|
||||||
val rootLogger = Logger.getLogger("")
|
val rootLogger = Logger.getLogger("")
|
||||||
rootLogger.level = if (logVerbose)
|
rootLogger.level = if (logVerbose) Level.ALL else Level.INFO
|
||||||
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))
|
rootLogger.addHandler(LogcatHandler(BuildConfig.APPLICATION_ID))
|
||||||
|
|
||||||
// log to file, if requested
|
// log to file, if requested
|
||||||
|
|||||||
@@ -6,31 +6,22 @@ package at.bitfire.davdroid.network
|
|||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.security.KeyChain
|
import android.security.KeyChain
|
||||||
import android.security.KeyChainException
|
|
||||||
import dagger.assisted.Assisted
|
import dagger.assisted.Assisted
|
||||||
import dagger.assisted.AssistedFactory
|
import dagger.assisted.AssistedFactory
|
||||||
import dagger.assisted.AssistedInject
|
import dagger.assisted.AssistedInject
|
||||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||||
import java.net.Socket
|
import java.net.Socket
|
||||||
import java.security.Principal
|
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
|
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.
|
||||||
*
|
*
|
||||||
* Requests for certificates / private keys for other aliases than the specified one
|
* @throws IllegalArgumentException if the alias doesn't exist or is not accessible
|
||||||
* will be ignored.
|
|
||||||
*
|
|
||||||
* @param alias alias of the desired certificate / private key
|
|
||||||
*/
|
*/
|
||||||
class ClientCertKeyManager @AssistedInject constructor(
|
class ClientCertKeyManager @AssistedInject constructor(
|
||||||
@Assisted private val alias: String,
|
@Assisted private val alias: String,
|
||||||
@ApplicationContext private val context: Context,
|
@ApplicationContext private val context: Context
|
||||||
private val logger: Logger
|
|
||||||
): X509ExtendedKeyManager() {
|
): X509ExtendedKeyManager() {
|
||||||
|
|
||||||
@AssistedFactory
|
@AssistedFactory
|
||||||
@@ -38,42 +29,19 @@ class ClientCertKeyManager @AssistedInject constructor(
|
|||||||
fun create(alias: String): ClientCertKeyManager
|
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 getServerAliases(p0: String?, p1: Array<out Principal>?): Array<String>? = null
|
||||||
override fun chooseServerAlias(p0: String?, p1: Array<out Principal>?, p2: Socket?) = 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 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 chooseClientAlias(p0: Array<out String>?, p1: Array<out Principal>?, p2: Socket?) = alias
|
||||||
|
|
||||||
override fun getCertificateChain(forAlias: String): Array<X509Certificate>? {
|
override fun getCertificateChain(forAlias: String?) =
|
||||||
if (forAlias != alias)
|
certs.takeIf { forAlias == alias }
|
||||||
return null
|
|
||||||
|
|
||||||
return try {
|
override fun getPrivateKey(forAlias: String?) =
|
||||||
KeyChain.getCertificateChain(context, alias).also { result ->
|
key.takeIf { forAlias == alias }
|
||||||
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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
}
|
||||||
@@ -1,24 +0,0 @@
|
|||||||
/*
|
|
||||||
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
|
|
||||||
*/
|
|
||||||
|
|
||||||
package at.bitfire.davdroid.network
|
|
||||||
|
|
||||||
import javax.net.ssl.HostnameVerifier
|
|
||||||
import javax.net.ssl.SSLSocketFactory
|
|
||||||
import javax.net.ssl.X509TrustManager
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Holds information that shall be used to create TLS connections.
|
|
||||||
*
|
|
||||||
* @param sslSocketFactory the socket factory that shall be used
|
|
||||||
* @param trustManager the trust manager that shall be used
|
|
||||||
* @param hostnameVerifier the hostname verifier that shall be used
|
|
||||||
* @param disableHttp2 whether HTTP/2 shall be disabled
|
|
||||||
*/
|
|
||||||
class ConnectionSecurityContext(
|
|
||||||
val sslSocketFactory: SSLSocketFactory?,
|
|
||||||
val trustManager: X509TrustManager?,
|
|
||||||
val hostnameVerifier: HostnameVerifier?,
|
|
||||||
val disableHttp2: Boolean
|
|
||||||
)
|
|
||||||
@@ -1,103 +0,0 @@
|
|||||||
/*
|
|
||||||
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
|
|
||||||
*/
|
|
||||||
|
|
||||||
package at.bitfire.davdroid.network
|
|
||||||
|
|
||||||
import androidx.annotation.VisibleForTesting
|
|
||||||
import at.bitfire.cert4android.CustomCertManager
|
|
||||||
import java.lang.ref.SoftReference
|
|
||||||
import java.security.KeyStore
|
|
||||||
import java.util.Optional
|
|
||||||
import javax.inject.Inject
|
|
||||||
import javax.inject.Singleton
|
|
||||||
import javax.net.ssl.SSLContext
|
|
||||||
import javax.net.ssl.SSLSocketFactory
|
|
||||||
import javax.net.ssl.TrustManagerFactory
|
|
||||||
import javax.net.ssl.X509TrustManager
|
|
||||||
import kotlin.jvm.optionals.getOrNull
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Caching provider for [ConnectionSecurityContext].
|
|
||||||
*/
|
|
||||||
@Singleton
|
|
||||||
class ConnectionSecurityManager @Inject constructor(
|
|
||||||
private val customHostnameVerifier: Optional<CustomCertManager.HostnameVerifier>,
|
|
||||||
private val customTrustManager: Optional<CustomCertManager>,
|
|
||||||
private val keyManagerFactory: ClientCertKeyManager.Factory
|
|
||||||
) {
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Maps client certificate aliases (or `null` if no client authentication is used) to their SSLSocketFactory.
|
|
||||||
* Uses soft references for the values so that they can be garbage-collected when not used anymore.
|
|
||||||
*
|
|
||||||
* Not thread-safe, access must be synchronized by caller.
|
|
||||||
*/
|
|
||||||
private val socketFactoryCache: MutableMap<String?, SoftReference<SSLSocketFactory>> =
|
|
||||||
LinkedHashMap(2) // usually not more than: one for no client certificates + one for a certain certificate alias
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The default TrustManager to use for connections. If [customTrustManager] provides a value, that value is
|
|
||||||
* used. Otherwise, the platform's default trust manager is used.
|
|
||||||
*/
|
|
||||||
private val trustManager by lazy { customTrustManager.getOrNull() ?: defaultTrustManager() }
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Provides the [ConnectionSecurityContext] for a given [certificateAlias].
|
|
||||||
*
|
|
||||||
* Uses [socketFactoryCache] to cache the entries (per [certificateAlias]).
|
|
||||||
*
|
|
||||||
* @param certificateAlias alias of the client certificate that shall be used for authentication (`null` for none)
|
|
||||||
* @return the connection security context
|
|
||||||
*/
|
|
||||||
fun getContext(certificateAlias: String?): ConnectionSecurityContext {
|
|
||||||
/* We only need a custom socket factory for
|
|
||||||
- client certificates and/or
|
|
||||||
- when cert4android is active (= there's a custom trustManager). */
|
|
||||||
val socketFactory = if (certificateAlias != null || customTrustManager.isPresent)
|
|
||||||
getSocketFactory(certificateAlias)
|
|
||||||
else
|
|
||||||
null
|
|
||||||
|
|
||||||
return ConnectionSecurityContext(
|
|
||||||
sslSocketFactory = socketFactory,
|
|
||||||
trustManager = if (socketFactory != null) trustManager else null, // when there's a customTrustManager, there's always a socketFactory, too
|
|
||||||
hostnameVerifier = customHostnameVerifier.getOrNull(),
|
|
||||||
disableHttp2 = certificateAlias != null
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
@VisibleForTesting
|
|
||||||
internal fun getSocketFactory(certificateAlias: String?): SSLSocketFactory = synchronized(socketFactoryCache) {
|
|
||||||
// look up cache first
|
|
||||||
val cachedFactory = socketFactoryCache[certificateAlias]?.get()
|
|
||||||
if (cachedFactory != null)
|
|
||||||
return cachedFactory
|
|
||||||
// no cached value, calculate and store into cache
|
|
||||||
|
|
||||||
// when a client certificate alias is given, create and use the respective ClientKeyManager
|
|
||||||
val clientKeyManager = certificateAlias?.let { keyManagerFactory.create(it) }
|
|
||||||
|
|
||||||
// create SSLContext that provides the SSLSocketFactory
|
|
||||||
val sslContext = SSLContext.getInstance("TLS").apply {
|
|
||||||
init(
|
|
||||||
/* km = */ if (clientKeyManager != null) arrayOf(clientKeyManager) else null,
|
|
||||||
/* tm = */ arrayOf(trustManager),
|
|
||||||
/* random = */ null /* default RNG */
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// cache reference and return socket factory
|
|
||||||
return sslContext.socketFactory.also { socketFactory ->
|
|
||||||
socketFactoryCache[certificateAlias] = SoftReference(socketFactory)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@VisibleForTesting
|
|
||||||
internal fun defaultTrustManager(): X509TrustManager {
|
|
||||||
val factory = TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm())
|
|
||||||
factory.init(null as KeyStore?)
|
|
||||||
return factory.trustManagers.filterIsInstance<X509TrustManager>().first()
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
@@ -1,52 +0,0 @@
|
|||||||
/*
|
|
||||||
* 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) }
|
|
||||||
|
|
||||||
}
|
|
||||||
315
app/src/main/kotlin/at/bitfire/davdroid/network/HttpClient.kt
Normal file
315
app/src/main/kotlin/at/bitfire/davdroid/network/HttpClient.kt
Normal file
@@ -0,0 +1,315 @@
|
|||||||
|
/*
|
||||||
|
* 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -1,374 +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 androidx.annotation.WorkerThread
|
|
||||||
import at.bitfire.dav4jvm.okhttp.BasicDigestAuthHandler
|
|
||||||
import at.bitfire.dav4jvm.okhttp.UrlUtils
|
|
||||||
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 com.google.common.net.HttpHeaders
|
|
||||||
import com.google.errorprone.annotations.MustBeClosed
|
|
||||||
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.logging.HttpLoggingInterceptor
|
|
||||||
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
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 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,
|
|
||||||
private val connectionSecurityManager: ConnectionSecurityManager,
|
|
||||||
defaultLogger: Logger,
|
|
||||||
@IoDispatcher private val ioDispatcher: CoroutineDispatcher,
|
|
||||||
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()
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* According to [OkHttpClient] documentation, [OkHttpClient]s should be shared, which allows it to use a
|
|
||||||
* shared connection and thread pool.
|
|
||||||
*
|
|
||||||
* We need custom settings for each actual client, but we can use a shared client as a base. This also
|
|
||||||
* enables sharing resources like connection and thread pool.
|
|
||||||
*
|
|
||||||
* The shared client is available for the lifetime of the application and must not be shut down or
|
|
||||||
* closed (which is not necessary, according to its documentation).
|
|
||||||
*/
|
|
||||||
val sharedOkHttpClient = OkHttpClient.Builder().apply {
|
|
||||||
configureTimeouts(this)
|
|
||||||
}.build()
|
|
||||||
|
|
||||||
private fun configureTimeouts(okBuilder: OkHttpClient.Builder) {
|
|
||||||
okBuilder
|
|
||||||
.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
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 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.
|
|
||||||
*
|
|
||||||
* Closing/shutting down the client is not necessary.
|
|
||||||
*/
|
|
||||||
fun build(): OkHttpClient {
|
|
||||||
if (alreadyBuilt)
|
|
||||||
logger.warning("build() should only be called once; use Provider<HttpClientBuilder> instead")
|
|
||||||
|
|
||||||
val builder = sharedOkHttpClient.newBuilder()
|
|
||||||
configureOkHttp(builder)
|
|
||||||
|
|
||||||
alreadyBuilt = true
|
|
||||||
return builder.build()
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun configureOkHttp(builder: OkHttpClient.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
|
|
||||||
))
|
|
||||||
|
|
||||||
/* Set SSLSocketFactory, TrustManager and HostnameVerifier (if needed).
|
|
||||||
* We shouldn't create these things here, because
|
|
||||||
*
|
|
||||||
* a. it involves complex logic that should be the responsibility of a dedicated class, and
|
|
||||||
* b. we need to cache the instances because otherwise, HTTPS connection are not used
|
|
||||||
* correctly. okhttp checks the SSLSocketFactory/TrustManager of a connection in the pool
|
|
||||||
* and creates a new connection when they have changed. */
|
|
||||||
val securityContext = connectionSecurityManager.getContext(certificateAlias)
|
|
||||||
|
|
||||||
if (securityContext.disableHttp2)
|
|
||||||
okBuilder.protocols(listOf(Protocol.HTTP_1_1))
|
|
||||||
|
|
||||||
if (securityContext.sslSocketFactory != null && securityContext.trustManager != null)
|
|
||||||
okBuilder.sslSocketFactory(securityContext.sslSocketFactory, securityContext.trustManager)
|
|
||||||
|
|
||||||
if (securityContext.hostnameVerifier != null)
|
|
||||||
okBuilder.hostnameVerifier(securityContext.hostnameVerifier)
|
|
||||||
}
|
|
||||||
|
|
||||||
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
// 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
|
|
||||||
|
|
||||||
// we don't use the sharedOkHttpClient, so we have to apply timeouts again
|
|
||||||
configureTimeouts(this)
|
|
||||||
|
|
||||||
// build most config on okhttp level
|
|
||||||
configureOkHttp(this)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
alreadyBuilt = true
|
|
||||||
return client
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
@@ -4,28 +4,26 @@
|
|||||||
|
|
||||||
package at.bitfire.davdroid.network
|
package at.bitfire.davdroid.network
|
||||||
|
|
||||||
import androidx.annotation.VisibleForTesting
|
import at.bitfire.dav4jvm.exception.DavException
|
||||||
import at.bitfire.dav4jvm.ktor.exception.HttpException
|
import at.bitfire.dav4jvm.exception.HttpException
|
||||||
import at.bitfire.davdroid.settings.Credentials
|
import at.bitfire.davdroid.settings.Credentials
|
||||||
import at.bitfire.davdroid.ui.setup.LoginInfo
|
import at.bitfire.davdroid.ui.setup.LoginInfo
|
||||||
import at.bitfire.davdroid.util.SensitiveString.Companion.toSensitiveString
|
|
||||||
import at.bitfire.davdroid.util.withTrailingSlash
|
import at.bitfire.davdroid.util.withTrailingSlash
|
||||||
import at.bitfire.vcard4android.GroupMethod
|
import at.bitfire.vcard4android.GroupMethod
|
||||||
import io.ktor.client.HttpClient
|
import kotlinx.coroutines.Dispatchers
|
||||||
import io.ktor.client.call.body
|
import kotlinx.coroutines.runInterruptible
|
||||||
import io.ktor.client.request.post
|
import kotlinx.coroutines.withContext
|
||||||
import io.ktor.client.request.setBody
|
import okhttp3.HttpUrl
|
||||||
import io.ktor.http.ContentType
|
import okhttp3.HttpUrl.Companion.toHttpUrl
|
||||||
import io.ktor.http.URLBuilder
|
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
|
||||||
import io.ktor.http.Url
|
import okhttp3.MediaType.Companion.toMediaType
|
||||||
import io.ktor.http.appendPathSegments
|
import okhttp3.Request
|
||||||
import io.ktor.http.contentType
|
import okhttp3.RequestBody
|
||||||
import io.ktor.http.isSuccess
|
import okhttp3.RequestBody.Companion.toRequestBody
|
||||||
import io.ktor.http.path
|
import org.json.JSONObject
|
||||||
import kotlinx.serialization.Serializable
|
import java.net.HttpURLConnection
|
||||||
import java.net.URI
|
import java.net.URI
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
import javax.inject.Provider
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Implements Nextcloud Login Flow v2.
|
* Implements Nextcloud Login Flow v2.
|
||||||
@@ -33,133 +31,8 @@ import javax.inject.Provider
|
|||||||
* See https://docs.nextcloud.com/server/latest/developer_manual/client_apis/LoginFlow/index.html#login-flow-v2
|
* See https://docs.nextcloud.com/server/latest/developer_manual/client_apis/LoginFlow/index.html#login-flow-v2
|
||||||
*/
|
*/
|
||||||
class NextcloudLoginFlow @Inject constructor(
|
class NextcloudLoginFlow @Inject constructor(
|
||||||
private val httpClientBuilder: Provider<HttpClientBuilder>
|
httpClientBuilder: HttpClient.Builder
|
||||||
) {
|
): AutoCloseable {
|
||||||
|
|
||||||
// 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 {
|
companion object {
|
||||||
const val FLOW_V1_PATH = "index.php/login/flow"
|
const val FLOW_V1_PATH = "index.php/login/flow"
|
||||||
@@ -169,4 +42,97 @@ class NextcloudLoginFlow @Inject constructor(
|
|||||||
const val DAV_PATH = "remote.php/dav"
|
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,7 +7,6 @@ package at.bitfire.davdroid.push
|
|||||||
import androidx.annotation.VisibleForTesting
|
import androidx.annotation.VisibleForTesting
|
||||||
import at.bitfire.dav4jvm.XmlReader
|
import at.bitfire.dav4jvm.XmlReader
|
||||||
import at.bitfire.dav4jvm.XmlUtils
|
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.db.Collection.Companion.TYPE_ADDRESSBOOK
|
||||||
import at.bitfire.davdroid.repository.AccountRepository
|
import at.bitfire.davdroid.repository.AccountRepository
|
||||||
import at.bitfire.davdroid.repository.DavCollectionRepository
|
import at.bitfire.davdroid.repository.DavCollectionRepository
|
||||||
@@ -106,7 +105,7 @@ class PushMessageHandler @Inject constructor(
|
|||||||
try {
|
try {
|
||||||
parser.setInput(StringReader(message))
|
parser.setInput(StringReader(message))
|
||||||
|
|
||||||
XmlReader(parser).processTag(WebDAVPush.PushMessage) {
|
XmlReader(parser).processTag(DavPushMessage.NAME) {
|
||||||
val pushMessage = DavPushMessage.Factory.create(parser)
|
val pushMessage = DavPushMessage.Factory.create(parser)
|
||||||
topic = pushMessage.topic?.topic
|
topic = pushMessage.topic?.topic
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,19 +11,22 @@ import androidx.work.ExistingPeriodicWorkPolicy
|
|||||||
import androidx.work.NetworkType
|
import androidx.work.NetworkType
|
||||||
import androidx.work.PeriodicWorkRequest
|
import androidx.work.PeriodicWorkRequest
|
||||||
import androidx.work.WorkManager
|
import androidx.work.WorkManager
|
||||||
|
import at.bitfire.dav4jvm.DavCollection
|
||||||
|
import at.bitfire.dav4jvm.DavResource
|
||||||
import at.bitfire.dav4jvm.HttpUtils
|
import at.bitfire.dav4jvm.HttpUtils
|
||||||
import at.bitfire.dav4jvm.HttpUtils.toKtorUrl
|
|
||||||
import at.bitfire.dav4jvm.XmlUtils
|
import at.bitfire.dav4jvm.XmlUtils
|
||||||
import at.bitfire.dav4jvm.XmlUtils.insertTag
|
import at.bitfire.dav4jvm.XmlUtils.insertTag
|
||||||
import at.bitfire.dav4jvm.ktor.DavCollection
|
import at.bitfire.dav4jvm.exception.DavException
|
||||||
import at.bitfire.dav4jvm.ktor.DavResource
|
import at.bitfire.dav4jvm.property.push.AuthSecret
|
||||||
import at.bitfire.dav4jvm.ktor.exception.DavException
|
import at.bitfire.dav4jvm.property.push.PushRegister
|
||||||
import at.bitfire.dav4jvm.ktor.toUrlOrNull
|
import at.bitfire.dav4jvm.property.push.PushResource
|
||||||
import at.bitfire.dav4jvm.property.push.WebDAVPush
|
import at.bitfire.dav4jvm.property.push.Subscription
|
||||||
|
import at.bitfire.dav4jvm.property.push.SubscriptionPublicKey
|
||||||
|
import at.bitfire.dav4jvm.property.push.WebPushSubscription
|
||||||
import at.bitfire.davdroid.db.Collection
|
import at.bitfire.davdroid.db.Collection
|
||||||
import at.bitfire.davdroid.db.Service
|
import at.bitfire.davdroid.db.Service
|
||||||
import at.bitfire.davdroid.di.IoDispatcher
|
import at.bitfire.davdroid.di.IoDispatcher
|
||||||
import at.bitfire.davdroid.network.HttpClientBuilder
|
import at.bitfire.davdroid.network.HttpClient
|
||||||
import at.bitfire.davdroid.push.PushRegistrationManager.Companion.mutex
|
import at.bitfire.davdroid.push.PushRegistrationManager.Companion.mutex
|
||||||
import at.bitfire.davdroid.repository.AccountRepository
|
import at.bitfire.davdroid.repository.AccountRepository
|
||||||
import at.bitfire.davdroid.repository.DavCollectionRepository
|
import at.bitfire.davdroid.repository.DavCollectionRepository
|
||||||
@@ -31,14 +34,14 @@ import at.bitfire.davdroid.repository.DavServiceRepository
|
|||||||
import at.bitfire.davdroid.sync.account.InvalidAccountException
|
import at.bitfire.davdroid.sync.account.InvalidAccountException
|
||||||
import dagger.Lazy
|
import dagger.Lazy
|
||||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||||
import io.ktor.client.HttpClient
|
|
||||||
import io.ktor.http.HttpHeaders
|
|
||||||
import io.ktor.http.Url
|
|
||||||
import io.ktor.http.isSuccess
|
|
||||||
import io.ktor.utils.io.ByteReadChannel
|
|
||||||
import kotlinx.coroutines.CoroutineDispatcher
|
import kotlinx.coroutines.CoroutineDispatcher
|
||||||
|
import kotlinx.coroutines.runBlocking
|
||||||
|
import kotlinx.coroutines.runInterruptible
|
||||||
import kotlinx.coroutines.sync.Mutex
|
import kotlinx.coroutines.sync.Mutex
|
||||||
import kotlinx.coroutines.sync.withLock
|
import kotlinx.coroutines.sync.withLock
|
||||||
|
import okhttp3.HttpUrl
|
||||||
|
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
|
||||||
|
import okhttp3.RequestBody.Companion.toRequestBody
|
||||||
import org.unifiedpush.android.connector.UnifiedPush
|
import org.unifiedpush.android.connector.UnifiedPush
|
||||||
import org.unifiedpush.android.connector.data.PushEndpoint
|
import org.unifiedpush.android.connector.data.PushEndpoint
|
||||||
import java.io.StringWriter
|
import java.io.StringWriter
|
||||||
@@ -62,7 +65,7 @@ class PushRegistrationManager @Inject constructor(
|
|||||||
private val accountRepository: Lazy<AccountRepository>,
|
private val accountRepository: Lazy<AccountRepository>,
|
||||||
private val collectionRepository: DavCollectionRepository,
|
private val collectionRepository: DavCollectionRepository,
|
||||||
@ApplicationContext private val context: Context,
|
@ApplicationContext private val context: Context,
|
||||||
private val httpClientBuilder: Provider<HttpClientBuilder>,
|
private val httpClientBuilder: Provider<HttpClient.Builder>,
|
||||||
@IoDispatcher private val ioDispatcher: CoroutineDispatcher,
|
@IoDispatcher private val ioDispatcher: CoroutineDispatcher,
|
||||||
private val logger: Logger,
|
private val logger: Logger,
|
||||||
private val serviceRepository: DavServiceRepository
|
private val serviceRepository: DavServiceRepository
|
||||||
@@ -179,24 +182,24 @@ class PushRegistrationManager @Inject constructor(
|
|||||||
val account = accountRepository.get().fromName(service.accountName)
|
val account = accountRepository.get().fromName(service.accountName)
|
||||||
httpClientBuilder.get()
|
httpClientBuilder.get()
|
||||||
.fromAccountAsync(account)
|
.fromAccountAsync(account)
|
||||||
.buildKtor()
|
.build()
|
||||||
.use { httpClient ->
|
.use { httpClient ->
|
||||||
for (collection in subscribeTo)
|
for (collection in subscribeTo)
|
||||||
try {
|
try {
|
||||||
val expires = collection.pushSubscriptionExpires
|
val expires = collection.pushSubscriptionExpires
|
||||||
// calculate next run time, but use the duplicate interval for safety (times are not exact)
|
// 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)
|
val nextRun = Instant.now() + Duration.ofDays(2 * WORKER_INTERVAL_DAYS)
|
||||||
if (expires != null && expires >= nextRun.epochSecond)
|
if (expires != null && expires >= nextRun.epochSecond)
|
||||||
logger.fine("Push subscription for ${collection.url} is still valid until ${collection.pushSubscriptionExpires}")
|
logger.fine("Push subscription for ${collection.url} is still valid until ${collection.pushSubscriptionExpires}")
|
||||||
else {
|
else {
|
||||||
// no existing subscription or expiring soon
|
// no existing subscription or expiring soon
|
||||||
logger.fine("Registering push subscription for ${collection.url}")
|
logger.fine("Registering push subscription for ${collection.url}")
|
||||||
subscribe(httpClient, collection, endpoint)
|
subscribe(httpClient, collection, endpoint)
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
logger.log(Level.WARNING, "Couldn't register subscription at CalDAV/CardDAV server", e)
|
||||||
}
|
}
|
||||||
} catch (e: Exception) {
|
}
|
||||||
logger.log(Level.WARNING, "Couldn't register subscription at CalDAV/CardDAV server", e)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -235,49 +238,51 @@ class PushRegistrationManager @Inject constructor(
|
|||||||
val writer = StringWriter()
|
val writer = StringWriter()
|
||||||
serializer.setOutput(writer)
|
serializer.setOutput(writer)
|
||||||
serializer.startDocument("UTF-8", true)
|
serializer.startDocument("UTF-8", true)
|
||||||
serializer.insertTag(WebDAVPush.PushRegister) {
|
serializer.insertTag(PushRegister.NAME) {
|
||||||
serializer.insertTag(WebDAVPush.Subscription) {
|
serializer.insertTag(Subscription.NAME) {
|
||||||
// subscription URL
|
// subscription URL
|
||||||
serializer.insertTag(WebDAVPush.WebPushSubscription) {
|
serializer.insertTag(WebPushSubscription.NAME) {
|
||||||
serializer.insertTag(WebDAVPush.PushResource) {
|
serializer.insertTag(PushResource.NAME) {
|
||||||
text(endpoint.url)
|
text(endpoint.url)
|
||||||
}
|
}
|
||||||
endpoint.pubKeySet?.let { pubKeySet ->
|
endpoint.pubKeySet?.let { pubKeySet ->
|
||||||
serializer.insertTag(WebDAVPush.SubscriptionPublicKey) {
|
serializer.insertTag(SubscriptionPublicKey.NAME) {
|
||||||
attribute(null, "type", "p256dh")
|
attribute(null, "type", "p256dh")
|
||||||
text(pubKeySet.pubKey)
|
text(pubKeySet.pubKey)
|
||||||
}
|
}
|
||||||
serializer.insertTag(WebDAVPush.AuthSecret) {
|
serializer.insertTag(AuthSecret.NAME) {
|
||||||
text(pubKeySet.auth)
|
text(pubKeySet.auth)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// requested expiration
|
// requested expiration
|
||||||
serializer.insertTag(WebDAVPush.Expires) {
|
serializer.insertTag(PushRegister.EXPIRES) {
|
||||||
text(HttpUtils.formatDate(requestedExpiration))
|
text(HttpUtils.formatDate(requestedExpiration))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
serializer.endDocument()
|
serializer.endDocument()
|
||||||
|
|
||||||
DavCollection(httpClient, collection.url.toKtorUrl()).post(
|
runInterruptible(ioDispatcher) {
|
||||||
{ ByteReadChannel(writer.toString()) },
|
val xml = writer.toString().toRequestBody(DavResource.MIME_XML)
|
||||||
DavResource.MIME_XML_UTF8
|
DavCollection(httpClient.okHttpClient, collection.url).post(xml) { response ->
|
||||||
) { response ->
|
if (response.isSuccessful) {
|
||||||
if (response.status.isSuccess()) {
|
// update subscription URL and expiration in DB
|
||||||
// update subscription URL and expiration in DB
|
val subscriptionUrl = response.header("Location")
|
||||||
val subscriptionUrl = response.headers[HttpHeaders.Location]
|
val expires = response.header("Expires")?.let { expiresDate ->
|
||||||
val expires = response.headers[HttpHeaders.Expires]?.let { expiresDate ->
|
HttpUtils.parseDate(expiresDate)
|
||||||
HttpUtils.parseDate(expiresDate)
|
} ?: requestedExpiration
|
||||||
} ?: requestedExpiration
|
|
||||||
|
|
||||||
collectionRepository.updatePushSubscription(
|
runBlocking {
|
||||||
id = collection.id,
|
collectionRepository.updatePushSubscription(
|
||||||
subscriptionUrl = subscriptionUrl,
|
id = collection.id,
|
||||||
expires = expires?.epochSecond
|
subscriptionUrl = subscriptionUrl,
|
||||||
)
|
expires = expires?.epochSecond
|
||||||
} else
|
)
|
||||||
logger.warning("Couldn't register push for ${collection.url}: $response")
|
}
|
||||||
|
} else
|
||||||
|
logger.warning("Couldn't register push for ${collection.url}: $response")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -291,20 +296,22 @@ class PushRegistrationManager @Inject constructor(
|
|||||||
val account = accountRepository.get().fromName(service.accountName)
|
val account = accountRepository.get().fromName(service.accountName)
|
||||||
httpClientBuilder.get()
|
httpClientBuilder.get()
|
||||||
.fromAccountAsync(account)
|
.fromAccountAsync(account)
|
||||||
.buildKtor()
|
.build()
|
||||||
.use { httpClient ->
|
.use { httpClient ->
|
||||||
for (collection in from)
|
for (collection in from)
|
||||||
collection.pushSubscription?.toUrlOrNull()?.let { url ->
|
collection.pushSubscription?.toHttpUrlOrNull()?.let { url ->
|
||||||
logger.info("Unsubscribing Push from ${collection.url}")
|
logger.info("Unsubscribing Push from ${collection.url}")
|
||||||
unsubscribe(httpClient, collection, url)
|
unsubscribe(httpClient, collection, url)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun unsubscribe(httpClient: HttpClient, collection: Collection, url: Url) {
|
private suspend fun unsubscribe(httpClient: HttpClient, collection: Collection, url: HttpUrl) {
|
||||||
try {
|
try {
|
||||||
DavResource(httpClient, url).delete {
|
runInterruptible(ioDispatcher) {
|
||||||
// deleted
|
DavResource(httpClient.okHttpClient, url).delete {
|
||||||
|
// deleted
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} catch (e: DavException) {
|
} catch (e: DavException) {
|
||||||
logger.log(Level.WARNING, "Couldn't unregister push for ${collection.url}", e)
|
logger.log(Level.WARNING, "Couldn't unregister push for ${collection.url}", e)
|
||||||
|
|||||||
@@ -8,12 +8,10 @@ import android.accounts.Account
|
|||||||
import android.accounts.AccountManager
|
import android.accounts.AccountManager
|
||||||
import android.accounts.OnAccountsUpdateListener
|
import android.accounts.OnAccountsUpdateListener
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import androidx.annotation.WorkerThread
|
|
||||||
import at.bitfire.davdroid.R
|
import at.bitfire.davdroid.R
|
||||||
import at.bitfire.davdroid.db.HomeSet
|
import at.bitfire.davdroid.db.HomeSet
|
||||||
import at.bitfire.davdroid.db.Service
|
import at.bitfire.davdroid.db.Service
|
||||||
import at.bitfire.davdroid.db.ServiceType
|
import at.bitfire.davdroid.db.ServiceType
|
||||||
import at.bitfire.davdroid.di.DefaultDispatcher
|
|
||||||
import at.bitfire.davdroid.resource.LocalAddressBookStore
|
import at.bitfire.davdroid.resource.LocalAddressBookStore
|
||||||
import at.bitfire.davdroid.resource.LocalCalendarStore
|
import at.bitfire.davdroid.resource.LocalCalendarStore
|
||||||
import at.bitfire.davdroid.servicedetection.DavResourceFinder
|
import at.bitfire.davdroid.servicedetection.DavResourceFinder
|
||||||
@@ -30,7 +28,7 @@ import at.bitfire.davdroid.sync.worker.SyncWorkerManager
|
|||||||
import at.bitfire.vcard4android.GroupMethod
|
import at.bitfire.vcard4android.GroupMethod
|
||||||
import dagger.Lazy
|
import dagger.Lazy
|
||||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||||
import kotlinx.coroutines.CoroutineDispatcher
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.channels.awaitClose
|
import kotlinx.coroutines.channels.awaitClose
|
||||||
import kotlinx.coroutines.flow.callbackFlow
|
import kotlinx.coroutines.flow.callbackFlow
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
@@ -49,7 +47,6 @@ class AccountRepository @Inject constructor(
|
|||||||
private val automaticSyncManager: Lazy<AutomaticSyncManager>,
|
private val automaticSyncManager: Lazy<AutomaticSyncManager>,
|
||||||
@ApplicationContext private val context: Context,
|
@ApplicationContext private val context: Context,
|
||||||
private val collectionRepository: DavCollectionRepository,
|
private val collectionRepository: DavCollectionRepository,
|
||||||
@DefaultDispatcher private val defaultDispatcher: CoroutineDispatcher,
|
|
||||||
private val homeSetRepository: DavHomeSetRepository,
|
private val homeSetRepository: DavHomeSetRepository,
|
||||||
private val localCalendarStore: Lazy<LocalCalendarStore>,
|
private val localCalendarStore: Lazy<LocalCalendarStore>,
|
||||||
private val localAddressBookStore: Lazy<LocalAddressBookStore>,
|
private val localAddressBookStore: Lazy<LocalAddressBookStore>,
|
||||||
@@ -73,7 +70,6 @@ class AccountRepository @Inject constructor(
|
|||||||
*
|
*
|
||||||
* @return account if account creation was successful; null otherwise (for instance because an account with this name already exists)
|
* @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? {
|
fun createBlocking(accountName: String, credentials: Credentials?, config: DavResourceFinder.Configuration, groupMethod: GroupMethod): Account? {
|
||||||
val account = fromName(accountName)
|
val account = fromName(accountName)
|
||||||
|
|
||||||
@@ -157,7 +153,7 @@ class AccountRepository @Inject constructor(
|
|||||||
val listener = OnAccountsUpdateListener { accounts ->
|
val listener = OnAccountsUpdateListener { accounts ->
|
||||||
trySend(accounts.filter { it.type == accountType }.toSet())
|
trySend(accounts.filter { it.type == accountType }.toSet())
|
||||||
}
|
}
|
||||||
withContext(defaultDispatcher) { // causes disk I/O
|
withContext(Dispatchers.Default) { // causes disk I/O
|
||||||
accountManager.addOnAccountsUpdatedListener(listener, null, true)
|
accountManager.addOnAccountsUpdatedListener(listener, null, true)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -169,7 +165,7 @@ class AccountRepository @Inject constructor(
|
|||||||
/**
|
/**
|
||||||
* Renames an account.
|
* Renames an account.
|
||||||
*
|
*
|
||||||
* **Note**: It is highly advised to re-sync the account after renaming in order to restore
|
* **Not**: It is highly advised to re-sync the account after renaming in order to restore
|
||||||
* a consistent state.
|
* a consistent state.
|
||||||
*
|
*
|
||||||
* @param oldName current name of the account
|
* @param oldName current name of the account
|
||||||
@@ -179,7 +175,7 @@ class AccountRepository @Inject constructor(
|
|||||||
* @throws IllegalArgumentException if the new account name already exists
|
* @throws IllegalArgumentException if the new account name already exists
|
||||||
* @throws Exception (or sub-classes) on other errors
|
* @throws Exception (or sub-classes) on other errors
|
||||||
*/
|
*/
|
||||||
suspend fun rename(oldName: String, newName: String): Unit = withContext(defaultDispatcher) {
|
suspend fun rename(oldName: String, newName: String) {
|
||||||
val oldAccount = fromName(oldName)
|
val oldAccount = fromName(oldName)
|
||||||
val newAccount = fromName(newName)
|
val newAccount = fromName(newName)
|
||||||
|
|
||||||
@@ -201,10 +197,13 @@ class AccountRepository @Inject constructor(
|
|||||||
// rename account (also moves AccountSettings)
|
// rename account (also moves AccountSettings)
|
||||||
val future = accountManager.renameAccount(oldAccount, newName, null, null)
|
val future = accountManager.renameAccount(oldAccount, newName, null, null)
|
||||||
|
|
||||||
// wait for operation to complete (blocks calling thread)
|
// wait for operation to complete
|
||||||
val newNameFromApi: Account = future.result
|
withContext(Dispatchers.Default) {
|
||||||
if (newNameFromApi.name != newName)
|
// blocks calling thread
|
||||||
throw IllegalStateException("renameAccount returned ${newNameFromApi.name} instead of $newName")
|
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
|
// account renamed, cancel maybe running synchronization of old account
|
||||||
syncWorkerManager.get().cancelAllWork(oldAccount)
|
syncWorkerManager.get().cancelAllWork(oldAccount)
|
||||||
@@ -218,27 +217,22 @@ class AccountRepository @Inject constructor(
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
// update address books
|
// update address books
|
||||||
localAddressBookStore.get().updateAccount(oldAccount, newAccount, null)
|
localAddressBookStore.get().updateAccount(oldAccount, newAccount)
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
logger.log(Level.WARNING, "Couldn't change address books to renamed account", e)
|
logger.log(Level.WARNING, "Couldn't change address books to renamed account", e)
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// update calendar events
|
// update calendar events
|
||||||
val store = localCalendarStore.get()
|
localCalendarStore.get().updateAccount(oldAccount, newAccount)
|
||||||
store.acquireContentProvider(true)?.use { client ->
|
|
||||||
store.updateAccount(oldAccount, newAccount, client)
|
|
||||||
}
|
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
logger.log(Level.WARNING, "Couldn't change calendars to renamed account", e)
|
logger.log(Level.WARNING, "Couldn't change calendars to renamed account", e)
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// update account_name of local tasks
|
// update account_name of local tasks
|
||||||
val store = tasksAppManager.get().getDataStore()
|
val dataStore = tasksAppManager.get().getDataStore()
|
||||||
store?.acquireContentProvider(true)?.use { client ->
|
dataStore?.updateAccount(oldAccount, newAccount)
|
||||||
store.updateAccount(oldAccount, newAccount, client)
|
|
||||||
}
|
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
logger.log(Level.WARNING, "Couldn't change task lists to renamed account", e)
|
logger.log(Level.WARNING, "Couldn't change task lists to renamed account", e)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,15 +6,23 @@ package at.bitfire.davdroid.repository
|
|||||||
|
|
||||||
import android.accounts.Account
|
import android.accounts.Account
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
|
import at.bitfire.dav4jvm.DavResource
|
||||||
import at.bitfire.dav4jvm.XmlUtils
|
import at.bitfire.dav4jvm.XmlUtils
|
||||||
import at.bitfire.dav4jvm.XmlUtils.insertTag
|
import at.bitfire.dav4jvm.XmlUtils.insertTag
|
||||||
import at.bitfire.dav4jvm.okhttp.DavResource
|
import at.bitfire.dav4jvm.exception.GoneException
|
||||||
import at.bitfire.dav4jvm.okhttp.exception.GoneException
|
import at.bitfire.dav4jvm.exception.HttpException
|
||||||
import at.bitfire.dav4jvm.okhttp.exception.HttpException
|
import at.bitfire.dav4jvm.exception.NotFoundException
|
||||||
import at.bitfire.dav4jvm.okhttp.exception.NotFoundException
|
import at.bitfire.dav4jvm.property.caldav.CalendarColor
|
||||||
import at.bitfire.dav4jvm.property.caldav.CalDAV
|
import at.bitfire.dav4jvm.property.caldav.CalendarDescription
|
||||||
import at.bitfire.dav4jvm.property.carddav.CardDAV
|
import at.bitfire.dav4jvm.property.caldav.CalendarTimezone
|
||||||
import at.bitfire.dav4jvm.property.webdav.WebDAV
|
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.davdroid.Constants
|
import at.bitfire.davdroid.Constants
|
||||||
import at.bitfire.davdroid.R
|
import at.bitfire.davdroid.R
|
||||||
import at.bitfire.davdroid.db.AppDatabase
|
import at.bitfire.davdroid.db.AppDatabase
|
||||||
@@ -22,7 +30,7 @@ import at.bitfire.davdroid.db.Collection
|
|||||||
import at.bitfire.davdroid.db.CollectionType
|
import at.bitfire.davdroid.db.CollectionType
|
||||||
import at.bitfire.davdroid.db.HomeSet
|
import at.bitfire.davdroid.db.HomeSet
|
||||||
import at.bitfire.davdroid.di.IoDispatcher
|
import at.bitfire.davdroid.di.IoDispatcher
|
||||||
import at.bitfire.davdroid.network.HttpClientBuilder
|
import at.bitfire.davdroid.network.HttpClient
|
||||||
import at.bitfire.davdroid.servicedetection.RefreshCollectionsWorker
|
import at.bitfire.davdroid.servicedetection.RefreshCollectionsWorker
|
||||||
import at.bitfire.davdroid.util.DavUtils
|
import at.bitfire.davdroid.util.DavUtils
|
||||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||||
@@ -35,7 +43,6 @@ import net.fortuna.ical4j.model.Property
|
|||||||
import net.fortuna.ical4j.model.PropertyList
|
import net.fortuna.ical4j.model.PropertyList
|
||||||
import net.fortuna.ical4j.model.TimeZoneRegistryFactory
|
import net.fortuna.ical4j.model.TimeZoneRegistryFactory
|
||||||
import net.fortuna.ical4j.model.component.VTimeZone
|
import net.fortuna.ical4j.model.component.VTimeZone
|
||||||
import net.fortuna.ical4j.model.property.ProdId
|
|
||||||
import net.fortuna.ical4j.model.property.Version
|
import net.fortuna.ical4j.model.property.Version
|
||||||
import okhttp3.HttpUrl
|
import okhttp3.HttpUrl
|
||||||
import java.io.StringWriter
|
import java.io.StringWriter
|
||||||
@@ -51,7 +58,7 @@ class DavCollectionRepository @Inject constructor(
|
|||||||
@ApplicationContext private val context: Context,
|
@ApplicationContext private val context: Context,
|
||||||
private val db: AppDatabase,
|
private val db: AppDatabase,
|
||||||
private val logger: Logger,
|
private val logger: Logger,
|
||||||
private val httpClientBuilder: Provider<HttpClientBuilder>,
|
private val httpClientBuilder: Provider<HttpClient.Builder>,
|
||||||
@IoDispatcher private val ioDispatcher: CoroutineDispatcher,
|
@IoDispatcher private val ioDispatcher: CoroutineDispatcher,
|
||||||
private val serviceRepository: DavServiceRepository
|
private val serviceRepository: DavServiceRepository
|
||||||
) {
|
) {
|
||||||
@@ -165,20 +172,21 @@ class DavCollectionRepository @Inject constructor(
|
|||||||
val service = serviceRepository.getBlocking(collection.serviceId) ?: throw IllegalArgumentException("Service not found")
|
val service = serviceRepository.getBlocking(collection.serviceId) ?: throw IllegalArgumentException("Service not found")
|
||||||
val account = Account(service.accountName, context.getString(R.string.account_type))
|
val account = Account(service.accountName, context.getString(R.string.account_type))
|
||||||
|
|
||||||
val httpClient = httpClientBuilder.get().fromAccount(account).build()
|
httpClientBuilder.get().fromAccount(account).build().use { httpClient ->
|
||||||
runInterruptible(ioDispatcher) {
|
runInterruptible(ioDispatcher) {
|
||||||
try {
|
try {
|
||||||
DavResource(httpClient, collection.url).delete {
|
DavResource(httpClient.okHttpClient, collection.url).delete {
|
||||||
// success, otherwise an exception would have been thrown → delete locally, too
|
// success, otherwise an exception would have been thrown → delete locally, too
|
||||||
delete(collection)
|
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
|
||||||
}
|
}
|
||||||
} 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
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -242,15 +250,11 @@ class DavCollectionRepository @Inject constructor(
|
|||||||
dao.insertOrUpdateByUrl(collection)
|
dao.insertOrUpdateByUrl(collection)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
fun pageByServiceAndType(serviceId: Long, @CollectionType type: String) =
|
||||||
* Returns paging source to retrieve collections for given service, of given collection type and
|
dao.pageByServiceAndType(serviceId, type)
|
||||||
* depending on whether they are considered personal or not (see [HomeSet.personal]).
|
|
||||||
*/
|
fun pagePersonalByServiceAndType(serviceId: Long, @CollectionType type: String) =
|
||||||
fun pageByServiceAndType(serviceId: Long, @CollectionType type: String, onlyPersonal: Boolean) =
|
dao.pagePersonalByServiceAndType(serviceId, type)
|
||||||
if (onlyPersonal)
|
|
||||||
dao.pagePersonalByServiceAndType(serviceId, type)
|
|
||||||
else
|
|
||||||
dao.pageByServiceAndType(serviceId, type)
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Sets the flag for whether read-only should be enforced on the local collection
|
* Sets the flag for whether read-only should be enforced on the local collection
|
||||||
@@ -285,17 +289,19 @@ class DavCollectionRepository @Inject constructor(
|
|||||||
// helpers
|
// helpers
|
||||||
|
|
||||||
private suspend fun createOnServer(account: Account, url: HttpUrl, method: String, xmlBody: String) {
|
private suspend fun createOnServer(account: Account, url: HttpUrl, method: String, xmlBody: String) {
|
||||||
val httpClient = httpClientBuilder.get()
|
httpClientBuilder.get()
|
||||||
.fromAccount(account)
|
.fromAccount(account)
|
||||||
.build()
|
.build()
|
||||||
runInterruptible(ioDispatcher) {
|
.use { httpClient ->
|
||||||
DavResource(httpClient, url).mkCol(
|
runInterruptible(ioDispatcher) {
|
||||||
xmlBody = xmlBody,
|
DavResource(httpClient.okHttpClient, url).mkCol(
|
||||||
method = method
|
xmlBody = xmlBody,
|
||||||
) {
|
method = method
|
||||||
// success, otherwise an exception would have been thrown
|
) {
|
||||||
|
// success, otherwise an exception would have been thrown
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun generateMkColXml(
|
private fun generateMkColXml(
|
||||||
@@ -314,27 +320,27 @@ class DavCollectionRepository @Inject constructor(
|
|||||||
setOutput(writer)
|
setOutput(writer)
|
||||||
|
|
||||||
startDocument("UTF-8", null)
|
startDocument("UTF-8", null)
|
||||||
setPrefix("", WebDAV.NS_WEBDAV)
|
setPrefix("", NS_WEBDAV)
|
||||||
setPrefix("CAL", CalDAV.NS_CALDAV)
|
setPrefix("CAL", NS_CALDAV)
|
||||||
setPrefix("CARD", CardDAV.NS_CARDDAV)
|
setPrefix("CARD", NS_CARDDAV)
|
||||||
|
|
||||||
if (addressBook)
|
if (addressBook)
|
||||||
startTag(WebDAV.NS_WEBDAV, "mkcol")
|
startTag(NS_WEBDAV, "mkcol")
|
||||||
else
|
else
|
||||||
startTag(CalDAV.NS_CALDAV, "mkcalendar")
|
startTag(NS_CALDAV, "mkcalendar")
|
||||||
|
|
||||||
insertTag(WebDAV.Set) {
|
insertTag(DavResource.SET) {
|
||||||
insertTag(WebDAV.Prop) {
|
insertTag(DavResource.PROP) {
|
||||||
insertTag(WebDAV.ResourceType) {
|
insertTag(ResourceType.NAME) {
|
||||||
insertTag(WebDAV.Collection)
|
insertTag(ResourceType.COLLECTION)
|
||||||
if (addressBook)
|
if (addressBook)
|
||||||
insertTag(CardDAV.Addressbook)
|
insertTag(ResourceType.ADDRESSBOOK)
|
||||||
else
|
else
|
||||||
insertTag(CalDAV.Calendar)
|
insertTag(ResourceType.CALENDAR)
|
||||||
}
|
}
|
||||||
|
|
||||||
displayName?.let {
|
displayName?.let {
|
||||||
insertTag(WebDAV.DisplayName) {
|
insertTag(DisplayName.NAME) {
|
||||||
text(it)
|
text(it)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -342,7 +348,7 @@ class DavCollectionRepository @Inject constructor(
|
|||||||
if (addressBook) {
|
if (addressBook) {
|
||||||
// addressbook-specific properties
|
// addressbook-specific properties
|
||||||
description?.let {
|
description?.let {
|
||||||
insertTag(CardDAV.AddressbookDescription) {
|
insertTag(AddressbookDescription.NAME) {
|
||||||
text(it)
|
text(it)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -350,27 +356,27 @@ class DavCollectionRepository @Inject constructor(
|
|||||||
} else {
|
} else {
|
||||||
// calendar-specific properties
|
// calendar-specific properties
|
||||||
description?.let {
|
description?.let {
|
||||||
insertTag(CalDAV.CalendarDescription) {
|
insertTag(CalendarDescription.NAME) {
|
||||||
text(it)
|
text(it)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
color?.let {
|
color?.let {
|
||||||
insertTag(CalDAV.CalendarColor) {
|
insertTag(CalendarColor.NAME) {
|
||||||
text(DavUtils.ARGBtoCalDAVColor(it))
|
text(DavUtils.ARGBtoCalDAVColor(it))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
timezoneId?.let { id ->
|
timezoneId?.let { id ->
|
||||||
insertTag(CalDAV.CalendarTimezoneId) {
|
insertTag(CalendarTimezoneId.NAME) {
|
||||||
text(id)
|
text(id)
|
||||||
}
|
}
|
||||||
getVTimeZone(id)?.let { vTimezone ->
|
getVTimeZone(id)?.let { vTimezone ->
|
||||||
insertTag(CalDAV.CalendarTimezone) {
|
insertTag(CalendarTimezone.NAME) {
|
||||||
text(
|
text(
|
||||||
// spec requires "an iCalendar object with exactly one VTIMEZONE component"
|
// spec requires "an iCalendar object with exactly one VTIMEZONE component"
|
||||||
Calendar(
|
Calendar(
|
||||||
PropertyList<Property>().apply {
|
PropertyList<Property>().apply {
|
||||||
add(Version.VERSION_2_0)
|
add(Version.VERSION_2_0)
|
||||||
add(ProdId(Constants.iCalProdId))
|
add(Constants.iCalProdId)
|
||||||
},
|
},
|
||||||
ComponentList(
|
ComponentList(
|
||||||
listOf(vTimezone)
|
listOf(vTimezone)
|
||||||
@@ -382,19 +388,19 @@ class DavCollectionRepository @Inject constructor(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!supportsVEVENT || !supportsVTODO || !supportsVJOURNAL) {
|
if (!supportsVEVENT || !supportsVTODO || !supportsVJOURNAL) {
|
||||||
insertTag(CalDAV.SupportedCalendarComponentSet) {
|
insertTag(SupportedCalendarComponentSet.NAME) {
|
||||||
// Only if there's at least one not explicitly supported calendar component set,
|
// Only if there's at least one not explicitly supported calendar component set,
|
||||||
// otherwise don't include the property, which means "supports everything".
|
// otherwise don't include the property, which means "supports everything".
|
||||||
if (supportsVEVENT)
|
if (supportsVEVENT)
|
||||||
insertTag(CalDAV.Comp) {
|
insertTag(SupportedCalendarComponentSet.COMP) {
|
||||||
attribute(null, "name", Component.VEVENT)
|
attribute(null, "name", Component.VEVENT)
|
||||||
}
|
}
|
||||||
if (supportsVTODO)
|
if (supportsVTODO)
|
||||||
insertTag(CalDAV.Comp) {
|
insertTag(SupportedCalendarComponentSet.COMP) {
|
||||||
attribute(null, "name", Component.VTODO)
|
attribute(null, "name", Component.VTODO)
|
||||||
}
|
}
|
||||||
if (supportsVJOURNAL)
|
if (supportsVJOURNAL)
|
||||||
insertTag(CalDAV.Comp) {
|
insertTag(SupportedCalendarComponentSet.COMP) {
|
||||||
attribute(null, "name", Component.VJOURNAL)
|
attribute(null, "name", Component.VJOURNAL)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -403,9 +409,9 @@ class DavCollectionRepository @Inject constructor(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (addressBook)
|
if (addressBook)
|
||||||
endTag(WebDAV.NS_WEBDAV, "mkcol")
|
endTag(NS_WEBDAV, "mkcol")
|
||||||
else
|
else
|
||||||
endTag(CalDAV.NS_CALDAV, "mkcalendar")
|
endTag(NS_CALDAV, "mkcalendar")
|
||||||
endDocument()
|
endDocument()
|
||||||
}
|
}
|
||||||
return writer.toString()
|
return writer.toString()
|
||||||
|
|||||||
@@ -6,8 +6,4 @@ package at.bitfire.davdroid.resource
|
|||||||
|
|
||||||
import at.bitfire.vcard4android.Contact
|
import at.bitfire.vcard4android.Contact
|
||||||
|
|
||||||
interface LocalAddress: LocalResource {
|
interface LocalAddress: LocalResource<Contact>
|
||||||
|
|
||||||
fun update(data: Contact, fileName: String?, eTag: String?, scheduleTag: String?, flags: Int)
|
|
||||||
|
|
||||||
}
|
|
||||||
@@ -87,7 +87,7 @@ class LocalAddressBookStore @Inject constructor(
|
|||||||
/* return */ null
|
/* return */ null
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun create(client: ContentProviderClient, fromCollection: Collection): LocalAddressBook? {
|
override fun create(provider: ContentProviderClient, fromCollection: Collection): LocalAddressBook? {
|
||||||
val service = serviceRepository.getBlocking(fromCollection.serviceId) ?: throw IllegalArgumentException("Couldn't fetch DB service from collection")
|
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))
|
val account = Account(service.accountName, context.getString(R.string.account_type))
|
||||||
|
|
||||||
@@ -98,7 +98,7 @@ class LocalAddressBookStore @Inject constructor(
|
|||||||
id = fromCollection.id
|
id = fromCollection.id
|
||||||
) ?: return null
|
) ?: return null
|
||||||
|
|
||||||
val addressBook = localAddressBookFactory.create(account, addressBookAccount, client)
|
val addressBook = localAddressBookFactory.create(account, addressBookAccount, provider)
|
||||||
|
|
||||||
// update settings
|
// update settings
|
||||||
addressBook.updateSyncFrameworkSettings()
|
addressBook.updateSyncFrameworkSettings()
|
||||||
@@ -125,12 +125,12 @@ class LocalAddressBookStore @Inject constructor(
|
|||||||
return addressBookAccount
|
return addressBookAccount
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun getAll(account: Account, client: ContentProviderClient): List<LocalAddressBook> =
|
override fun getAll(account: Account, provider: ContentProviderClient): List<LocalAddressBook> =
|
||||||
getAddressBookAccounts(account).map { addressBookAccount ->
|
getAddressBookAccounts(account).map { addressBookAccount ->
|
||||||
localAddressBookFactory.create(account, addressBookAccount, client)
|
localAddressBookFactory.create(account, addressBookAccount, provider)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun update(client: ContentProviderClient, localCollection: LocalAddressBook, fromCollection: Collection) {
|
override fun update(provider: ContentProviderClient, localCollection: LocalAddressBook, fromCollection: Collection) {
|
||||||
var currentAccount = localCollection.addressBookAccount
|
var currentAccount = localCollection.addressBookAccount
|
||||||
logger.log(Level.INFO, "Updating local address book $currentAccount from collection $fromCollection")
|
logger.log(Level.INFO, "Updating local address book $currentAccount from collection $fromCollection")
|
||||||
|
|
||||||
@@ -167,9 +167,8 @@ class LocalAddressBookStore @Inject constructor(
|
|||||||
*
|
*
|
||||||
* @param oldAccount The old account
|
* @param oldAccount The old account
|
||||||
* @param newAccount The new 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, client: ContentProviderClient?) {
|
override fun updateAccount(oldAccount: Account, newAccount: Account) {
|
||||||
val accountManager = AccountManager.get(context)
|
val accountManager = AccountManager.get(context)
|
||||||
accountManager.getAccountsByType(context.getString(R.string.account_type_address_book))
|
accountManager.getAccountsByType(context.getString(R.string.account_type_address_book))
|
||||||
.filter { addressBookAccount ->
|
.filter { addressBookAccount ->
|
||||||
|
|||||||
@@ -4,16 +4,18 @@
|
|||||||
|
|
||||||
package at.bitfire.davdroid.resource
|
package at.bitfire.davdroid.resource
|
||||||
|
|
||||||
|
import android.content.ContentUris
|
||||||
import android.provider.CalendarContract.Calendars
|
import android.provider.CalendarContract.Calendars
|
||||||
import android.provider.CalendarContract.Events
|
import android.provider.CalendarContract.Events
|
||||||
import androidx.annotation.VisibleForTesting
|
|
||||||
import androidx.core.content.contentValuesOf
|
import androidx.core.content.contentValuesOf
|
||||||
|
import at.bitfire.ical4android.Event
|
||||||
|
import at.bitfire.ical4android.util.MiscUtils.asSyncAdapter
|
||||||
|
import at.bitfire.synctools.mapping.calendar.LegacyAndroidEventBuilder2
|
||||||
import at.bitfire.synctools.storage.BatchOperation
|
import at.bitfire.synctools.storage.BatchOperation
|
||||||
import at.bitfire.synctools.storage.calendar.AndroidCalendar
|
import at.bitfire.synctools.storage.calendar.AndroidCalendar
|
||||||
|
import at.bitfire.synctools.storage.calendar.AndroidEvent2
|
||||||
import at.bitfire.synctools.storage.calendar.AndroidRecurringCalendar
|
import at.bitfire.synctools.storage.calendar.AndroidRecurringCalendar
|
||||||
import at.bitfire.synctools.storage.calendar.CalendarBatchOperation
|
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.Assisted
|
||||||
import dagger.assisted.AssistedFactory
|
import dagger.assisted.AssistedFactory
|
||||||
import dagger.assisted.AssistedInject
|
import dagger.assisted.AssistedInject
|
||||||
@@ -58,42 +60,53 @@ class LocalCalendar @AssistedInject constructor(
|
|||||||
androidCalendar.writeSyncState(state.toString())
|
androidCalendar.writeSyncState(state.toString())
|
||||||
}
|
}
|
||||||
|
|
||||||
@VisibleForTesting
|
private val recurringCalendar = AndroidRecurringCalendar(androidCalendar)
|
||||||
internal val recurringCalendar = AndroidRecurringCalendar(androidCalendar)
|
|
||||||
|
|
||||||
|
|
||||||
fun add(event: EventAndExceptions): Long {
|
fun add(event: Event, fileName: String, eTag: String?, scheduleTag: String?, flags: Int) {
|
||||||
return recurringCalendar.addEventAndExceptions(event)
|
val mapped = LegacyAndroidEventBuilder2(
|
||||||
|
calendar = androidCalendar,
|
||||||
|
event = event,
|
||||||
|
id = null,
|
||||||
|
syncId = fileName,
|
||||||
|
eTag = eTag,
|
||||||
|
scheduleTag = scheduleTag,
|
||||||
|
flags = flags
|
||||||
|
).build()
|
||||||
|
recurringCalendar.addEventAndExceptions(mapped)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun findDeleted(): List<LocalEvent> {
|
override fun findDeleted(): List<LocalEvent> {
|
||||||
val result = LinkedList<LocalEvent>()
|
val result = LinkedList<LocalEvent>()
|
||||||
recurringCalendar.iterateEventAndExceptions(
|
androidCalendar.iterateEvents( "${Events.DELETED} AND ${Events.ORIGINAL_ID} IS NULL", null) { entity ->
|
||||||
"${Events.DELETED} AND ${Events.ORIGINAL_ID} IS NULL", null
|
result += LocalEvent(recurringCalendar, AndroidEvent2(androidCalendar, entity))
|
||||||
) { eventAndExceptions ->
|
|
||||||
result += LocalEvent(recurringCalendar, eventAndExceptions)
|
|
||||||
}
|
}
|
||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun findDirty(): List<LocalEvent> {
|
override fun findDirty(): List<LocalEvent> {
|
||||||
val dirty = LinkedList<LocalEvent>()
|
val dirty = LinkedList<LocalEvent>()
|
||||||
recurringCalendar.iterateEventAndExceptions(
|
|
||||||
"${Events.DIRTY} AND ${Events.ORIGINAL_ID} IS NULL", null
|
/*
|
||||||
) { eventAndExceptions ->
|
* RFC 5545 3.8.7.4. Sequence Number
|
||||||
dirty += LocalEvent(recurringCalendar, eventAndExceptions)
|
* 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.
|
||||||
|
*/
|
||||||
|
androidCalendar.iterateEvents("${Events.DIRTY} AND ${Events.ORIGINAL_ID} IS NULL", null) { values ->
|
||||||
|
dirty += LocalEvent(recurringCalendar, AndroidEvent2(androidCalendar, values))
|
||||||
}
|
}
|
||||||
|
|
||||||
return dirty
|
return dirty
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun findByName(name: String) =
|
override fun findByName(name: String) =
|
||||||
recurringCalendar.findEventAndExceptions("${Events._SYNC_ID}=? AND ${Events.ORIGINAL_SYNC_ID} IS null", arrayOf(name))?.let {
|
androidCalendar.findEvent("${Events._SYNC_ID}=? AND ${Events.ORIGINAL_SYNC_ID} IS null", arrayOf(name))?.let {
|
||||||
LocalEvent(recurringCalendar, it)
|
LocalEvent(recurringCalendar, it)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun markNotDirty(flags: Int) =
|
override fun markNotDirty(flags: Int) =
|
||||||
androidCalendar.updateEventRows(
|
androidCalendar.updateEventRows(
|
||||||
contentValuesOf(EventsContract.COLUMN_FLAGS to flags),
|
contentValuesOf(AndroidEvent2.COLUMN_FLAGS to flags),
|
||||||
// `dirty` can be 0, 1, or null. "NOT dirty" is not enough.
|
// `dirty` can be 0, 1, or null. "NOT dirty" is not enough.
|
||||||
"""
|
"""
|
||||||
${Events.CALENDAR_ID}=?
|
${Events.CALENDAR_ID}=?
|
||||||
@@ -113,7 +126,7 @@ class LocalCalendar @AssistedInject constructor(
|
|||||||
${Events.CALENDAR_ID}=?
|
${Events.CALENDAR_ID}=?
|
||||||
AND (${Events.DIRTY} IS NULL OR ${Events.DIRTY}=0)
|
AND (${Events.DIRTY} IS NULL OR ${Events.DIRTY}=0)
|
||||||
AND ${Events.ORIGINAL_ID} IS NULL
|
AND ${Events.ORIGINAL_ID} IS NULL
|
||||||
AND ${EventsContract.COLUMN_FLAGS}=?
|
AND ${AndroidEvent2.COLUMN_FLAGS}=?
|
||||||
""".trimIndent(),
|
""".trimIndent(),
|
||||||
arrayOf(androidCalendar.id.toString(), flags.toString())
|
arrayOf(androidCalendar.id.toString(), flags.toString())
|
||||||
) { values ->
|
) { values ->
|
||||||
@@ -129,9 +142,95 @@ class LocalCalendar @AssistedInject constructor(
|
|||||||
|
|
||||||
override fun forgetETags() {
|
override fun forgetETags() {
|
||||||
androidCalendar.updateEventRows(
|
androidCalendar.updateEventRows(
|
||||||
contentValuesOf(EventsContract.COLUMN_ETAG to null),
|
contentValuesOf(AndroidEvent2.COLUMN_ETAG to null),
|
||||||
"${Events.CALENDAR_ID}=?", arrayOf(androidCalendar.id.toString())
|
"${Events.CALENDAR_ID}=?", arrayOf(androidCalendar.id.toString())
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
fun processDirtyExceptions() {
|
||||||
|
// process deleted exceptions
|
||||||
|
logger.info("Processing deleted exceptions")
|
||||||
|
|
||||||
|
androidCalendar.iterateEventRows(
|
||||||
|
arrayOf(Events._ID, Events.ORIGINAL_ID, AndroidEvent2.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.getEventRow(originalID, arrayOf(AndroidEvent2.COLUMN_SEQUENCE))
|
||||||
|
val originalSequence = originalEventValues?.getAsInteger(AndroidEvent2.COLUMN_SEQUENCE) ?: 0
|
||||||
|
|
||||||
|
batch += BatchOperation.CpoBuilder
|
||||||
|
.newUpdate(ContentUris.withAppendedId(Events.CONTENT_URI, originalID).asSyncAdapter(androidCalendar.account))
|
||||||
|
.withValue(AndroidEvent2.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.iterateEventRows(
|
||||||
|
arrayOf(Events._ID, Events.ORIGINAL_ID, AndroidEvent2.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(AndroidEvent2.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(AndroidEvent2.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.iterateEventRows(
|
||||||
|
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.updateEventRow(eventId, contentValuesOf(Events.DELETED to 1))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
@@ -26,7 +26,6 @@ import at.bitfire.synctools.storage.calendar.AndroidCalendarProvider
|
|||||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||||
import java.util.logging.Level
|
import java.util.logging.Level
|
||||||
import java.util.logging.Logger
|
import java.util.logging.Logger
|
||||||
import javax.annotation.WillNotClose
|
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
|
|
||||||
class LocalCalendarStore @Inject constructor(
|
class LocalCalendarStore @Inject constructor(
|
||||||
@@ -139,18 +138,12 @@ class LocalCalendarStore @Inject constructor(
|
|||||||
return values
|
return values
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun updateAccount(oldAccount: Account, newAccount: Account, @WillNotClose client: ContentProviderClient?) {
|
override fun updateAccount(oldAccount: Account, newAccount: Account) {
|
||||||
if (client == null)
|
val values = contentValuesOf(Calendars.ACCOUNT_NAME to newAccount.name)
|
||||||
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)
|
val uri = Calendars.CONTENT_URI.asSyncAdapter(oldAccount)
|
||||||
client.update(uri, values, "${Calendars.ACCOUNT_NAME}=?", arrayOf(oldAccount.name))
|
context.contentResolver.acquireContentProviderClient(CalendarContract.AUTHORITY)?.use {
|
||||||
|
it.update(uri, values, "${Calendars.ACCOUNT_NAME}=?", arrayOf(oldAccount.name))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun delete(localCollection: LocalCalendar) {
|
override fun delete(localCollection: LocalCalendar) {
|
||||||
|
|||||||
@@ -4,12 +4,7 @@
|
|||||||
|
|
||||||
package at.bitfire.davdroid.resource
|
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) */
|
/** a tag that uniquely identifies the collection (DAVx5-wide) */
|
||||||
val tag: String
|
val tag: String
|
||||||
|
|||||||
@@ -4,16 +4,11 @@
|
|||||||
|
|
||||||
package at.bitfire.davdroid.resource
|
package at.bitfire.davdroid.resource
|
||||||
|
|
||||||
import android.content.ContentUris
|
|
||||||
import android.content.ContentValues
|
import android.content.ContentValues
|
||||||
import android.content.Context
|
|
||||||
import android.net.Uri
|
|
||||||
import android.os.RemoteException
|
import android.os.RemoteException
|
||||||
import android.provider.ContactsContract
|
import android.provider.ContactsContract
|
||||||
import android.provider.ContactsContract.CommonDataKinds.GroupMembership
|
import android.provider.ContactsContract.CommonDataKinds.GroupMembership
|
||||||
import android.provider.ContactsContract.RawContacts
|
|
||||||
import android.provider.ContactsContract.RawContacts.Data
|
import android.provider.ContactsContract.RawContacts.Data
|
||||||
import android.provider.ContactsContract.RawContacts.getContactLookupUri
|
|
||||||
import androidx.core.content.contentValuesOf
|
import androidx.core.content.contentValuesOf
|
||||||
import at.bitfire.davdroid.resource.contactrow.CachedGroupMembershipHandler
|
import at.bitfire.davdroid.resource.contactrow.CachedGroupMembershipHandler
|
||||||
import at.bitfire.davdroid.resource.contactrow.GroupMembershipBuilder
|
import at.bitfire.davdroid.resource.contactrow.GroupMembershipBuilder
|
||||||
@@ -27,16 +22,16 @@ import at.bitfire.vcard4android.AndroidContact
|
|||||||
import at.bitfire.vcard4android.AndroidContactFactory
|
import at.bitfire.vcard4android.AndroidContactFactory
|
||||||
import at.bitfire.vcard4android.CachedGroupMembership
|
import at.bitfire.vcard4android.CachedGroupMembership
|
||||||
import at.bitfire.vcard4android.Contact
|
import at.bitfire.vcard4android.Contact
|
||||||
import com.google.common.base.MoreObjects
|
|
||||||
import java.io.FileNotFoundException
|
import java.io.FileNotFoundException
|
||||||
import java.util.Optional
|
import java.util.Optional
|
||||||
|
import java.util.UUID
|
||||||
import kotlin.jvm.optionals.getOrNull
|
import kotlin.jvm.optionals.getOrNull
|
||||||
|
|
||||||
class LocalContact: AndroidContact, LocalAddress {
|
class LocalContact: AndroidContact, LocalAddress {
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
const val COLUMN_FLAGS = RawContacts.SYNC4
|
const val COLUMN_FLAGS = ContactsContract.RawContacts.SYNC4
|
||||||
const val COLUMN_HASHCODE = RawContacts.SYNC3
|
const val COLUMN_HASHCODE = ContactsContract.RawContacts.SYNC3
|
||||||
}
|
}
|
||||||
|
|
||||||
override val addressBook: LocalAddressBook
|
override val addressBook: LocalAddressBook
|
||||||
@@ -68,6 +63,25 @@ 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.
|
* Clears cached [contact] so that the next read of [contact] will query the content provider again.
|
||||||
*/
|
*/
|
||||||
@@ -83,7 +97,7 @@ class LocalContact: AndroidContact, LocalAddress {
|
|||||||
if (fileName.isPresent)
|
if (fileName.isPresent)
|
||||||
values.put(COLUMN_FILENAME, fileName.get())
|
values.put(COLUMN_FILENAME, fileName.get())
|
||||||
values.put(COLUMN_ETAG, eTag)
|
values.put(COLUMN_ETAG, eTag)
|
||||||
values.put(RawContacts.DIRTY, 0)
|
values.put(ContactsContract.RawContacts.DIRTY, 0)
|
||||||
|
|
||||||
// Android 7 workaround
|
// Android 7 workaround
|
||||||
addressBook.dirtyVerifier.getOrNull()?.setHashCodeColumn(this, values)
|
addressBook.dirtyVerifier.getOrNull()?.setHashCodeColumn(this, values)
|
||||||
@@ -96,7 +110,7 @@ class LocalContact: AndroidContact, LocalAddress {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fun resetDirty() {
|
fun resetDirty() {
|
||||||
val values = contentValuesOf(RawContacts.DIRTY to 0)
|
val values = contentValuesOf(ContactsContract.RawContacts.DIRTY to 0)
|
||||||
addressBook.provider!!.update(rawContactSyncURI(), values, null, null)
|
addressBook.provider!!.update(rawContactSyncURI(), values, null, null)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -116,13 +130,6 @@ class LocalContact: AndroidContact, LocalAddress {
|
|||||||
this.flags = flags
|
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() {
|
override fun deleteLocal() {
|
||||||
delete()
|
delete()
|
||||||
}
|
}
|
||||||
@@ -132,30 +139,6 @@ class LocalContact: AndroidContact, LocalAddress {
|
|||||||
addressBook.provider!!.update(rawContactSyncURI(), values, null, null)
|
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) {
|
fun addToGroup(batch: ContactsBatchOperation, groupID: Long) {
|
||||||
batch += BatchOperation.CpoBuilder
|
batch += BatchOperation.CpoBuilder
|
||||||
@@ -216,7 +199,6 @@ class LocalContact: AndroidContact, LocalAddress {
|
|||||||
super.buildContact(builder, update)
|
super.buildContact(builder, update)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
// factory
|
// factory
|
||||||
|
|
||||||
object Factory: AndroidContactFactory<LocalContact> {
|
object Factory: AndroidContactFactory<LocalContact> {
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ package at.bitfire.davdroid.resource
|
|||||||
import android.accounts.Account
|
import android.accounts.Account
|
||||||
import android.content.ContentProviderClient
|
import android.content.ContentProviderClient
|
||||||
import at.bitfire.davdroid.db.Collection
|
import at.bitfire.davdroid.db.Collection
|
||||||
import javax.annotation.WillNotClose
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Represents a local data store for a specific collection type.
|
* Represents a local data store for a specific collection type.
|
||||||
@@ -77,8 +76,7 @@ interface LocalDataStore<T: LocalCollection<*>> {
|
|||||||
*
|
*
|
||||||
* @param oldAccount The old account.
|
* @param oldAccount The old account.
|
||||||
* @param newAccount The new 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, @WillNotClose client: ContentProviderClient?)
|
fun updateAccount(oldAccount: Account, newAccount: Account)
|
||||||
|
|
||||||
}
|
}
|
||||||
@@ -4,75 +4,161 @@
|
|||||||
|
|
||||||
package at.bitfire.davdroid.resource
|
package at.bitfire.davdroid.resource
|
||||||
|
|
||||||
import android.content.ContentUris
|
|
||||||
import android.content.Context
|
|
||||||
import android.provider.CalendarContract
|
|
||||||
import android.provider.CalendarContract.Events
|
import android.provider.CalendarContract.Events
|
||||||
import androidx.core.content.contentValuesOf
|
import androidx.core.content.contentValuesOf
|
||||||
import at.bitfire.synctools.storage.calendar.AndroidCalendar
|
import at.bitfire.ical4android.Event
|
||||||
|
import at.bitfire.ical4android.LegacyAndroidCalendar
|
||||||
|
import at.bitfire.synctools.mapping.calendar.LegacyAndroidEventBuilder2
|
||||||
|
import at.bitfire.synctools.storage.LocalStorageException
|
||||||
|
import at.bitfire.synctools.storage.calendar.AndroidEvent2
|
||||||
import at.bitfire.synctools.storage.calendar.AndroidRecurringCalendar
|
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
|
import java.util.Optional
|
||||||
|
import java.util.UUID
|
||||||
|
|
||||||
class LocalEvent(
|
class LocalEvent(
|
||||||
val recurringCalendar: AndroidRecurringCalendar,
|
val recurringCalendar: AndroidRecurringCalendar,
|
||||||
val androidEvent: EventAndExceptions
|
val androidEvent: AndroidEvent2
|
||||||
) : LocalResource {
|
) : LocalResource<Event> {
|
||||||
|
|
||||||
val calendar: AndroidCalendar
|
|
||||||
get() = recurringCalendar.calendar
|
|
||||||
|
|
||||||
private val mainValues = androidEvent.main.entityValues
|
|
||||||
|
|
||||||
override val id: Long
|
override val id: Long
|
||||||
get() = mainValues.getAsLong(Events._ID)
|
get() = androidEvent.id
|
||||||
|
|
||||||
override val fileName: String?
|
override val fileName: String?
|
||||||
get() = mainValues.getAsString(Events._SYNC_ID)
|
get() = androidEvent.syncId
|
||||||
|
|
||||||
override val eTag: String?
|
override val eTag: String?
|
||||||
get() = mainValues.getAsString(EventsContract.COLUMN_ETAG)
|
get() = androidEvent.eTag
|
||||||
|
|
||||||
override val scheduleTag: String?
|
override val scheduleTag: String?
|
||||||
get() = mainValues.getAsString(EventsContract.COLUMN_SCHEDULE_TAG)
|
get() = androidEvent.scheduleTag
|
||||||
|
|
||||||
override val flags: Int
|
override val flags: Int
|
||||||
get() = mainValues.getAsInteger(EventsContract.COLUMN_FLAGS) ?: 0
|
get() = androidEvent.flags
|
||||||
|
|
||||||
|
|
||||||
fun update(data: EventAndExceptions) {
|
override fun update(data: Event, fileName: String?, eTag: String?, scheduleTag: String?, flags: Int) {
|
||||||
recurringCalendar.updateEventAndExceptions(id, data)
|
val eventAndExceptions = LegacyAndroidEventBuilder2(
|
||||||
|
calendar = androidEvent.calendar,
|
||||||
|
event = data,
|
||||||
|
id = id,
|
||||||
|
syncId = fileName,
|
||||||
|
eTag = eTag,
|
||||||
|
scheduleTag = scheduleTag,
|
||||||
|
flags = flags
|
||||||
|
).build()
|
||||||
|
recurringCalendar.updateEventAndExceptions(id, eventAndExceptions)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
private var _event: Event? = null
|
||||||
|
/**
|
||||||
|
* Retrieves the event from the content provider and converts it to a legacy data object.
|
||||||
|
*
|
||||||
|
* Caches the result: the content provider is only queried at the first call and then
|
||||||
|
* this method always returns the same object.
|
||||||
|
*
|
||||||
|
* @throws LocalStorageException if there is no local event with the ID from [androidEvent]
|
||||||
|
*/
|
||||||
|
@Synchronized
|
||||||
|
fun getCachedEvent(): Event {
|
||||||
|
_event?.let { return it }
|
||||||
|
|
||||||
|
val legacyCalendar = LegacyAndroidCalendar(androidEvent.calendar)
|
||||||
|
val event = legacyCalendar.getEvent(androidEvent.id)
|
||||||
|
?: throw LocalStorageException("Event ${androidEvent.id} not found")
|
||||||
|
|
||||||
|
_event = event
|
||||||
|
return event
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generates the [Event] that should actually be uploaded:
|
||||||
|
*
|
||||||
|
* 1. Takes the [getCachedEvent].
|
||||||
|
* 2. Calculates the new SEQUENCE.
|
||||||
|
*
|
||||||
|
* _Note: This method currently modifies the object returned by [getCachedEvent], but
|
||||||
|
* this may change in the future._
|
||||||
|
*
|
||||||
|
* @return data object that should be used for uploading
|
||||||
|
*/
|
||||||
|
fun eventToUpload(): Event {
|
||||||
|
val event = getCachedEvent()
|
||||||
|
|
||||||
|
val nonGroupScheduled = event.attendees.isEmpty()
|
||||||
|
val weAreOrganizer = event.isOrganizer == true
|
||||||
|
|
||||||
|
// Increase sequence (event.sequence null/non-null behavior is defined by the Event, see KDoc of event.sequence):
|
||||||
|
// - If it's null, the event has just been created in the database, so we can start with SEQUENCE:0 (default).
|
||||||
|
// - If it's non-null, the event already exists on the server, so increase by one.
|
||||||
|
val sequence = event.sequence
|
||||||
|
if (sequence != null && (nonGroupScheduled || weAreOrganizer))
|
||||||
|
event.sequence = sequence + 1
|
||||||
|
|
||||||
|
return event
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Updates the SEQUENCE of the event in the content provider.
|
||||||
|
*
|
||||||
|
* @param sequence new sequence value
|
||||||
|
*/
|
||||||
|
fun updateSequence(sequence: Int?) {
|
||||||
|
androidEvent.update(contentValuesOf(
|
||||||
|
AndroidEvent2.COLUMN_SEQUENCE to sequence
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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 = getCachedEvent().uid ?: run {
|
||||||
|
// generate new UID
|
||||||
|
val newUid = UUID.randomUUID().toString()
|
||||||
|
|
||||||
|
// persist to calendar provider
|
||||||
|
val values = contentValuesOf(Events.UID_2445 to newUid)
|
||||||
|
androidEvent.update(values)
|
||||||
|
|
||||||
|
// update in cached event data object
|
||||||
|
getCachedEvent().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
|
||||||
|
}
|
||||||
|
|
||||||
override fun clearDirty(fileName: Optional<String>, eTag: String?, scheduleTag: String?) {
|
override fun clearDirty(fileName: Optional<String>, eTag: String?, scheduleTag: String?) {
|
||||||
val values = contentValuesOf(
|
val values = contentValuesOf(
|
||||||
Events.DIRTY to 0,
|
Events.DIRTY to 0,
|
||||||
EventsContract.COLUMN_ETAG to eTag,
|
AndroidEvent2.COLUMN_ETAG to eTag,
|
||||||
EventsContract.COLUMN_SCHEDULE_TAG to scheduleTag
|
AndroidEvent2.COLUMN_SCHEDULE_TAG to scheduleTag
|
||||||
)
|
)
|
||||||
if (fileName.isPresent)
|
if (fileName.isPresent)
|
||||||
values.put(Events._SYNC_ID, fileName.get())
|
values.put(Events._SYNC_ID, fileName.get())
|
||||||
calendar.updateEventRow(id, values)
|
androidEvent.update(values)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun updateFlags(flags: Int) {
|
override fun updateFlags(flags: Int) {
|
||||||
calendar.updateEventRow(id, contentValuesOf(
|
androidEvent.update(contentValuesOf(
|
||||||
EventsContract.COLUMN_FLAGS to flags
|
AndroidEvent2.COLUMN_FLAGS to 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
|
|
||||||
))
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -81,28 +167,9 @@ class LocalEvent(
|
|||||||
}
|
}
|
||||||
|
|
||||||
override fun resetDeleted() {
|
override fun resetDeleted() {
|
||||||
calendar.updateEventRow(id, contentValuesOf(
|
androidEvent.update(contentValuesOf(
|
||||||
Events.DELETED to 0
|
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,7 +6,6 @@ package at.bitfire.davdroid.resource
|
|||||||
|
|
||||||
import android.content.ContentUris
|
import android.content.ContentUris
|
||||||
import android.content.ContentValues
|
import android.content.ContentValues
|
||||||
import android.content.Context
|
|
||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
import android.os.RemoteException
|
import android.os.RemoteException
|
||||||
import android.provider.ContactsContract
|
import android.provider.ContactsContract
|
||||||
@@ -16,6 +15,7 @@ import android.provider.ContactsContract.RawContacts
|
|||||||
import android.provider.ContactsContract.RawContacts.Data
|
import android.provider.ContactsContract.RawContacts.Data
|
||||||
import androidx.core.content.contentValuesOf
|
import androidx.core.content.contentValuesOf
|
||||||
import at.bitfire.davdroid.resource.LocalGroup.Companion.COLUMN_PENDING_MEMBERS
|
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.BatchOperation
|
||||||
import at.bitfire.synctools.storage.ContactsBatchOperation
|
import at.bitfire.synctools.storage.ContactsBatchOperation
|
||||||
import at.bitfire.vcard4android.AndroidAddressBook
|
import at.bitfire.vcard4android.AndroidAddressBook
|
||||||
@@ -24,9 +24,9 @@ import at.bitfire.vcard4android.AndroidGroup
|
|||||||
import at.bitfire.vcard4android.AndroidGroupFactory
|
import at.bitfire.vcard4android.AndroidGroupFactory
|
||||||
import at.bitfire.vcard4android.CachedGroupMembership
|
import at.bitfire.vcard4android.CachedGroupMembership
|
||||||
import at.bitfire.vcard4android.Contact
|
import at.bitfire.vcard4android.Contact
|
||||||
import com.google.common.base.MoreObjects
|
|
||||||
import java.util.LinkedList
|
import java.util.LinkedList
|
||||||
import java.util.Optional
|
import java.util.Optional
|
||||||
|
import java.util.UUID
|
||||||
import java.util.logging.Logger
|
import java.util.logging.Logger
|
||||||
import kotlin.jvm.optionals.getOrNull
|
import kotlin.jvm.optionals.getOrNull
|
||||||
|
|
||||||
@@ -140,6 +140,26 @@ 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: Optional<String>, eTag: String?, scheduleTag: String?) {
|
override fun clearDirty(fileName: Optional<String>, eTag: String?, scheduleTag: String?) {
|
||||||
if (scheduleTag != null)
|
if (scheduleTag != null)
|
||||||
throw IllegalArgumentException("Contact groups must not have a Schedule-Tag")
|
throw IllegalArgumentException("Contact groups must not have a Schedule-Tag")
|
||||||
@@ -195,6 +215,7 @@ class LocalGroup: AndroidGroup, LocalAddress {
|
|||||||
override fun update(data: Contact, fileName: String?, eTag: String?, scheduleTag: String?, flags: Int) {
|
override fun update(data: Contact, fileName: String?, eTag: String?, scheduleTag: String?, flags: Int) {
|
||||||
this.fileName = fileName
|
this.fileName = fileName
|
||||||
this.eTag = eTag
|
this.eTag = eTag
|
||||||
|
this.scheduleTag = scheduleTag
|
||||||
|
|
||||||
// processes this.{fileName, eTag, flags} and resets DIRTY flag
|
// processes this.{fileName, eTag, flags} and resets DIRTY flag
|
||||||
update(data)
|
update(data)
|
||||||
@@ -207,13 +228,6 @@ class LocalGroup: AndroidGroup, LocalAddress {
|
|||||||
this.flags = flags
|
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() {
|
override fun deleteLocal() {
|
||||||
delete()
|
delete()
|
||||||
}
|
}
|
||||||
@@ -223,22 +237,6 @@ class LocalGroup: AndroidGroup, LocalAddress {
|
|||||||
addressBook.provider!!.update(groupSyncUri(), values, null, null)
|
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
|
// helpers
|
||||||
|
|
||||||
|
|||||||
@@ -18,11 +18,11 @@ import at.bitfire.davdroid.repository.PrincipalRepository
|
|||||||
import at.bitfire.davdroid.settings.AccountSettings
|
import at.bitfire.davdroid.settings.AccountSettings
|
||||||
import at.bitfire.davdroid.util.DavUtils.lastSegment
|
import at.bitfire.davdroid.util.DavUtils.lastSegment
|
||||||
import at.bitfire.ical4android.JtxCollection
|
import at.bitfire.ical4android.JtxCollection
|
||||||
|
import at.bitfire.ical4android.TaskProvider
|
||||||
import at.techbee.jtx.JtxContract
|
import at.techbee.jtx.JtxContract
|
||||||
import at.techbee.jtx.JtxContract.asSyncAdapter
|
import at.techbee.jtx.JtxContract.asSyncAdapter
|
||||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||||
import java.util.logging.Logger
|
import java.util.logging.Logger
|
||||||
import javax.annotation.WillNotClose
|
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
|
|
||||||
class LocalJtxCollectionStore @Inject constructor(
|
class LocalJtxCollectionStore @Inject constructor(
|
||||||
@@ -46,7 +46,7 @@ class LocalJtxCollectionStore @Inject constructor(
|
|||||||
/* return */ null
|
/* return */ null
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun create(client: ContentProviderClient, fromCollection: Collection): LocalJtxCollection {
|
override fun create(provider: ContentProviderClient, fromCollection: Collection): LocalJtxCollection? {
|
||||||
val service = serviceDao.get(fromCollection.serviceId) ?: throw IllegalArgumentException("Couldn't fetch DB service from collection")
|
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))
|
val account = Account(service.accountName, context.getString(R.string.account_type))
|
||||||
|
|
||||||
@@ -63,8 +63,8 @@ class LocalJtxCollectionStore @Inject constructor(
|
|||||||
withColor = true
|
withColor = true
|
||||||
)
|
)
|
||||||
|
|
||||||
val uri = JtxCollection.create(account, client, values)
|
val uri = JtxCollection.create(account, provider, values)
|
||||||
return LocalJtxCollection(account, client, ContentUris.parseId(uri))
|
return LocalJtxCollection(account, provider, ContentUris.parseId(uri))
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun valuesFromCollection(info: Collection, account: Account, withColor: Boolean): ContentValues {
|
private fun valuesFromCollection(info: Collection, account: Account, withColor: Boolean): ContentValues {
|
||||||
@@ -94,21 +94,21 @@ class LocalJtxCollectionStore @Inject constructor(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun getAll(account: Account, client: ContentProviderClient): List<LocalJtxCollection> =
|
override fun getAll(account: Account, provider: ContentProviderClient): List<LocalJtxCollection> =
|
||||||
JtxCollection.find(account, client, context, LocalJtxCollection.Factory, null, null)
|
JtxCollection.find(account, provider, context, LocalJtxCollection.Factory, null, null)
|
||||||
|
|
||||||
override fun update(client: ContentProviderClient, localCollection: LocalJtxCollection, fromCollection: Collection) {
|
override fun update(provider: ContentProviderClient, localCollection: LocalJtxCollection, fromCollection: Collection) {
|
||||||
val accountSettings = accountSettingsFactory.create(localCollection.account)
|
val accountSettings = accountSettingsFactory.create(localCollection.account)
|
||||||
val values = valuesFromCollection(fromCollection, account = localCollection.account, withColor = accountSettings.getManageCalendarColors())
|
val values = valuesFromCollection(fromCollection, account = localCollection.account, withColor = accountSettings.getManageCalendarColors())
|
||||||
localCollection.update(values)
|
localCollection.update(values)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun updateAccount(oldAccount: Account, newAccount: Account, @WillNotClose client: ContentProviderClient?) {
|
override fun updateAccount(oldAccount: Account, newAccount: Account) {
|
||||||
if (client == null)
|
TaskProvider.acquire(context, TaskProvider.ProviderName.JtxBoard)?.use { provider ->
|
||||||
return
|
val values = contentValuesOf(JtxContract.JtxCollection.ACCOUNT_NAME to newAccount.name)
|
||||||
val values = contentValuesOf(JtxContract.JtxCollection.ACCOUNT_NAME to newAccount.name)
|
val uri = JtxContract.JtxCollection.CONTENT_URI.asSyncAdapter(oldAccount)
|
||||||
val uri = JtxContract.JtxCollection.CONTENT_URI.asSyncAdapter(oldAccount)
|
provider.client.update(uri, values, "${JtxContract.JtxCollection.ACCOUNT_NAME}=?", arrayOf(oldAccount.name))
|
||||||
client.update(uri, values, "${JtxContract.JtxCollection.ACCOUNT_NAME}=?", arrayOf(oldAccount.name))
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun delete(localCollection: LocalJtxCollection) {
|
override fun delete(localCollection: LocalJtxCollection) {
|
||||||
|
|||||||
@@ -5,26 +5,23 @@
|
|||||||
package at.bitfire.davdroid.resource
|
package at.bitfire.davdroid.resource
|
||||||
|
|
||||||
import android.content.ContentValues
|
import android.content.ContentValues
|
||||||
import android.content.Context
|
|
||||||
import at.bitfire.ical4android.JtxCollection
|
import at.bitfire.ical4android.JtxCollection
|
||||||
import at.bitfire.ical4android.JtxICalObject
|
import at.bitfire.ical4android.JtxICalObject
|
||||||
import at.bitfire.ical4android.JtxICalObjectFactory
|
import at.bitfire.ical4android.JtxICalObjectFactory
|
||||||
import at.techbee.jtx.JtxContract
|
import at.techbee.jtx.JtxContract
|
||||||
import at.techbee.jtx.JtxContract.JtxICalObject.getViewIntentUriFor
|
|
||||||
import com.google.common.base.MoreObjects
|
|
||||||
import java.util.Optional
|
import java.util.Optional
|
||||||
import kotlin.jvm.optionals.getOrNull
|
import kotlin.jvm.optionals.getOrNull
|
||||||
|
|
||||||
/**
|
|
||||||
* Represents a Journal, Note or Task entry
|
|
||||||
*/
|
|
||||||
class LocalJtxICalObject(
|
class LocalJtxICalObject(
|
||||||
collection: JtxCollection<*>,
|
collection: JtxCollection<*>,
|
||||||
fileName: String?,
|
fileName: String?,
|
||||||
eTag: String?,
|
eTag: String?,
|
||||||
scheduleTag: String?,
|
scheduleTag: String?,
|
||||||
flags: Int
|
flags: Int
|
||||||
) : JtxICalObject(collection), LocalResource {
|
) :
|
||||||
|
JtxICalObject(collection),
|
||||||
|
LocalResource<JtxICalObject> {
|
||||||
|
|
||||||
|
|
||||||
init {
|
init {
|
||||||
this.fileName = fileName
|
this.fileName = fileName
|
||||||
@@ -53,7 +50,7 @@ class LocalJtxICalObject(
|
|||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fun update(data: JtxICalObject, fileName: String?, eTag: String?, scheduleTag: String?, flags: Int) {
|
override fun update(data: JtxICalObject, fileName: String?, eTag: String?, scheduleTag: String?, flags: Int) {
|
||||||
this.fileName = fileName
|
this.fileName = fileName
|
||||||
this.eTag = eTag
|
this.eTag = eTag
|
||||||
this.scheduleTag = scheduleTag
|
this.scheduleTag = scheduleTag
|
||||||
@@ -63,10 +60,6 @@ class LocalJtxICalObject(
|
|||||||
update(data)
|
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?) {
|
override fun clearDirty(fileName: Optional<String>, eTag: String?, scheduleTag: String?) {
|
||||||
clearDirty(fileName.getOrNull(), eTag, scheduleTag)
|
clearDirty(fileName.getOrNull(), eTag, scheduleTag)
|
||||||
}
|
}
|
||||||
@@ -79,15 +72,4 @@ class LocalJtxICalObject(
|
|||||||
throw NotImplementedError()
|
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,18 +4,13 @@
|
|||||||
|
|
||||||
package at.bitfire.davdroid.resource
|
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 at.bitfire.davdroid.resource.LocalResource.Companion.FLAG_REMOTELY_PRESENT
|
||||||
import java.util.Optional
|
import java.util.Optional
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* This is an interface between the SyncManager and a resource in the local storage.
|
* Defines operations that are used by SyncManager for all sync data types.
|
||||||
*
|
|
||||||
* It defines operations that are used by SyncManager for all sync data types.
|
|
||||||
*/
|
*/
|
||||||
interface LocalResource {
|
interface LocalResource<in TData: Any> {
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
/**
|
/**
|
||||||
@@ -49,6 +44,18 @@ interface LocalResource {
|
|||||||
/** bitfield of flags; currently either [FLAG_REMOTELY_PRESENT] or 0 */
|
/** bitfield of flags; currently either [FLAG_REMOTELY_PRESENT] or 0 */
|
||||||
val flags: Int
|
val flags: Int
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Prepares the resource for uploading:
|
||||||
|
*
|
||||||
|
* 1. If the resource doesn't have an UID yet, this method generates one and writes it to the content provider.
|
||||||
|
* 2. The new file name which can be used for the upload is derived from the UID and returned, but not
|
||||||
|
* saved to the content provider. The sync manager is responsible for saving the file name that
|
||||||
|
* was actually used.
|
||||||
|
*
|
||||||
|
* @return suggestion for new file name of the resource (like "<uid>.vcf")
|
||||||
|
*/
|
||||||
|
fun prepareForUpload(): String
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Unsets the _dirty_ field of the resource and updates other sync-related fields in the content provider.
|
* 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).
|
* Does not affect `this` object itself (which is immutable).
|
||||||
@@ -69,17 +76,12 @@ interface LocalResource {
|
|||||||
fun updateFlags(flags: Int)
|
fun updateFlags(flags: Int)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Updates the local UID of the resource in the content provider.
|
* Updates the data object in the content provider and ensures that the dirty flag is clear.
|
||||||
* Usually used to persist a UID that has been created during an upload of a locally created resource.
|
* Does not affect `this` or the [data] object (which are both immutable).
|
||||||
*/
|
|
||||||
fun updateUid(uid: String)
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Updates the local SEQUENCE of the resource in the content provider.
|
|
||||||
*
|
*
|
||||||
* @throws NotImplementedError if SEQUENCE update is not supported
|
* @return content URI of the updated row (e.g. event URI)
|
||||||
*/
|
*/
|
||||||
fun updateSequence(sequence: Int)
|
fun update(data: TData, fileName: String?, eTag: String?, scheduleTag: String?, flags: Int)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Deletes the data object from the content provider.
|
* Deletes the data object from the content provider.
|
||||||
@@ -91,20 +93,4 @@ interface LocalResource {
|
|||||||
*/
|
*/
|
||||||
fun resetDeleted()
|
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,122 +4,126 @@
|
|||||||
|
|
||||||
package at.bitfire.davdroid.resource
|
package at.bitfire.davdroid.resource
|
||||||
|
|
||||||
import android.content.ContentUris
|
|
||||||
import android.content.ContentValues
|
import android.content.ContentValues
|
||||||
import android.content.Context
|
|
||||||
import android.net.Uri
|
|
||||||
import androidx.core.content.contentValuesOf
|
import androidx.core.content.contentValuesOf
|
||||||
import at.bitfire.ical4android.DmfsTask
|
import at.bitfire.ical4android.DmfsTask
|
||||||
|
import at.bitfire.ical4android.DmfsTaskFactory
|
||||||
|
import at.bitfire.ical4android.DmfsTaskList
|
||||||
import at.bitfire.ical4android.Task
|
import at.bitfire.ical4android.Task
|
||||||
import at.bitfire.ical4android.TaskProvider
|
import at.bitfire.synctools.storage.BatchOperation
|
||||||
import com.google.common.base.MoreObjects
|
|
||||||
import org.dmfs.tasks.contract.TaskContract.Tasks
|
import org.dmfs.tasks.contract.TaskContract.Tasks
|
||||||
import java.util.Optional
|
import java.util.Optional
|
||||||
import java.util.logging.Logger
|
import java.util.UUID
|
||||||
|
|
||||||
/**
|
class LocalTask: DmfsTask, LocalResource<Task> {
|
||||||
* Represents a Dmfs Task (OpenTasks and Tasks.org) entry
|
|
||||||
*/
|
|
||||||
class LocalTask(
|
|
||||||
val dmfsTask: DmfsTask
|
|
||||||
): LocalResource {
|
|
||||||
|
|
||||||
val logger: Logger = Logger.getLogger(javaClass.name)
|
companion object {
|
||||||
|
const val COLUMN_ETAG = Tasks.SYNC1
|
||||||
|
const val COLUMN_FLAGS = Tasks.SYNC2
|
||||||
|
}
|
||||||
|
|
||||||
|
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 scheduleTag: String? = null
|
||||||
|
override var eTag: String? = null
|
||||||
|
|
||||||
override val flags: Int
|
override var flags = 0
|
||||||
get() = dmfsTask.flags
|
private set
|
||||||
|
|
||||||
fun add() = dmfsTask.add()
|
|
||||||
|
|
||||||
fun update(data: Task) = dmfsTask.update(data)
|
constructor(taskList: DmfsTaskList<*>, task: Task, fileName: String?, eTag: String?, flags: Int)
|
||||||
|
: super(taskList, task) {
|
||||||
|
this.fileName = fileName
|
||||||
|
this.eTag = eTag
|
||||||
|
this.flags = flags
|
||||||
|
}
|
||||||
|
|
||||||
fun delete() = dmfsTask.delete()
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/* 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: Optional<String>, eTag: String?, scheduleTag: String?) {
|
override fun clearDirty(fileName: Optional<String>, eTag: String?, scheduleTag: String?) {
|
||||||
if (scheduleTag != null)
|
if (scheduleTag != null)
|
||||||
logger.fine("Schedule-Tag for tasks not supported, won't save")
|
logger.fine("Schedule-Tag for tasks not supported yet, won't save")
|
||||||
|
|
||||||
val values = ContentValues(4)
|
val values = ContentValues(4)
|
||||||
if (fileName.isPresent)
|
if (fileName.isPresent)
|
||||||
values.put(Tasks._SYNC_ID, fileName.get())
|
values.put(Tasks._SYNC_ID, fileName.get())
|
||||||
values.put(DmfsTask.COLUMN_ETAG, eTag)
|
values.put(COLUMN_ETAG, eTag)
|
||||||
values.put(Tasks.SYNC_VERSION, dmfsTask.task!!.sequence)
|
values.put(Tasks.SYNC_VERSION, task!!.sequence)
|
||||||
values.put(Tasks._DIRTY, 0)
|
values.put(Tasks._DIRTY, 0)
|
||||||
dmfsTask.update(values)
|
taskList.provider.update(taskSyncURI(), values, null, null)
|
||||||
|
|
||||||
if (fileName.isPresent)
|
if (fileName.isPresent)
|
||||||
this.fileName = fileName.get()
|
this.fileName = fileName.get()
|
||||||
this.eTag = eTag
|
this.eTag = eTag
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun updateFlags(flags: Int) {
|
override fun update(data: Task, fileName: String?, eTag: String?, scheduleTag: String?, flags: Int) {
|
||||||
if (id != null) {
|
this.fileName = fileName
|
||||||
val values = contentValuesOf(DmfsTask.COLUMN_FLAGS to flags)
|
this.eTag = eTag
|
||||||
dmfsTask.update(values)
|
this.scheduleTag = scheduleTag
|
||||||
}
|
this.flags = flags
|
||||||
dmfsTask.flags = flags
|
|
||||||
|
// processes this.{fileName, eTag, scheduleTag, flags} and resets DIRTY flag
|
||||||
|
update(data)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun updateSequence(sequence: Int) = throw NotImplementedError()
|
override fun updateFlags(flags: Int) {
|
||||||
|
if (id != null) {
|
||||||
|
val values = contentValuesOf(COLUMN_FLAGS to flags)
|
||||||
|
taskList.provider.update(taskSyncURI(), values, null, null)
|
||||||
|
}
|
||||||
|
|
||||||
override fun updateUid(uid: String) {
|
this.flags = flags
|
||||||
val values = contentValuesOf(Tasks._UID to uid)
|
|
||||||
dmfsTask.update(values)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun deleteLocal() {
|
override fun deleteLocal() {
|
||||||
dmfsTask.delete()
|
delete()
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun resetDeleted() {
|
override fun resetDeleted() {
|
||||||
throw NotImplementedError()
|
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()
|
|
||||||
|
|
||||||
override fun getViewUri(context: Context): Uri? = id?.let { id ->
|
object Factory: DmfsTaskFactory<LocalTask> {
|
||||||
when (dmfsTask.taskList.providerName) {
|
override fun fromProvider(taskList: DmfsTaskList<*>, values: ContentValues) =
|
||||||
TaskProvider.ProviderName.OpenTasks -> {
|
LocalTask(taskList, values)
|
||||||
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,10 +4,15 @@
|
|||||||
|
|
||||||
package at.bitfire.davdroid.resource
|
package at.bitfire.davdroid.resource
|
||||||
|
|
||||||
|
import android.accounts.Account
|
||||||
|
import android.content.ContentProviderClient
|
||||||
|
import android.content.ContentValues
|
||||||
import androidx.core.content.contentValuesOf
|
import androidx.core.content.contentValuesOf
|
||||||
import at.bitfire.ical4android.DmfsTask
|
import at.bitfire.ical4android.DmfsTaskList
|
||||||
import at.bitfire.synctools.storage.tasks.DmfsTaskList
|
import at.bitfire.ical4android.DmfsTaskListFactory
|
||||||
|
import at.bitfire.ical4android.TaskProvider
|
||||||
import org.dmfs.tasks.contract.TaskContract.TaskListColumns
|
import org.dmfs.tasks.contract.TaskContract.TaskListColumns
|
||||||
|
import org.dmfs.tasks.contract.TaskContract.TaskLists
|
||||||
import org.dmfs.tasks.contract.TaskContract.Tasks
|
import org.dmfs.tasks.contract.TaskContract.Tasks
|
||||||
import java.util.logging.Level
|
import java.util.logging.Level
|
||||||
import java.util.logging.Logger
|
import java.util.logging.Logger
|
||||||
@@ -17,38 +22,62 @@ import java.util.logging.Logger
|
|||||||
*
|
*
|
||||||
* [TaskLists._SYNC_ID] corresponds to the database collection ID ([at.bitfire.davdroid.db.Collection.id]).
|
* [TaskLists._SYNC_ID] corresponds to the database collection ID ([at.bitfire.davdroid.db.Collection.id]).
|
||||||
*/
|
*/
|
||||||
class LocalTaskList (
|
class LocalTaskList private constructor(
|
||||||
val dmfsTaskList: DmfsTaskList
|
account: Account,
|
||||||
): LocalCollection<LocalTask> {
|
provider: ContentProviderClient,
|
||||||
|
providerName: TaskProvider.ProviderName,
|
||||||
|
id: Long
|
||||||
|
): DmfsTaskList<LocalTask>(account, provider, providerName, LocalTask.Factory, id), LocalCollection<LocalTask> {
|
||||||
|
|
||||||
private val logger = Logger.getGlobal()
|
private val logger = Logger.getGlobal()
|
||||||
|
|
||||||
|
private var accessLevel: Int = TaskListColumns.ACCESS_LEVEL_UNDEFINED
|
||||||
override val readOnly
|
override val readOnly
|
||||||
get() = dmfsTaskList.accessLevel?.let {
|
get() =
|
||||||
it != TaskListColumns.ACCESS_LEVEL_UNDEFINED && it <= TaskListColumns.ACCESS_LEVEL_READ
|
accessLevel != TaskListColumns.ACCESS_LEVEL_UNDEFINED &&
|
||||||
} ?: false
|
accessLevel <= TaskListColumns.ACCESS_LEVEL_READ
|
||||||
|
|
||||||
override val dbCollectionId: Long?
|
override val dbCollectionId: Long?
|
||||||
get() = dmfsTaskList.syncId?.toLongOrNull()
|
get() = syncId?.toLongOrNull()
|
||||||
|
|
||||||
override val tag: String
|
override val tag: String
|
||||||
get() = "tasks-${dmfsTaskList.account.name}-${dmfsTaskList.id}"
|
get() = "tasks-${account.name}-$id"
|
||||||
|
|
||||||
override val title: String
|
override val title: String
|
||||||
get() = dmfsTaskList.name ?: dmfsTaskList.id.toString()
|
get() = name ?: id.toString()
|
||||||
|
|
||||||
override var lastSyncState: SyncState?
|
override var lastSyncState: SyncState?
|
||||||
get() = dmfsTaskList.readSyncState()?.let { SyncState.fromString(it) }
|
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
|
||||||
|
}
|
||||||
set(state) {
|
set(state) {
|
||||||
dmfsTaskList.writeSyncState(state.toString())
|
val values = contentValuesOf(TaskLists.SYNC_VERSION to state?.toString())
|
||||||
|
provider.update(taskListSyncUri(), values, null, null)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun findDeleted() = dmfsTaskList.findTasks(Tasks._DELETED, null)
|
|
||||||
.map { LocalTask(it) }
|
override fun populate(values: ContentValues) {
|
||||||
|
super.populate(values)
|
||||||
|
accessLevel = values.getAsInteger(TaskListColumns.ACCESS_LEVEL)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
override fun findDeleted() = queryTasks(Tasks._DELETED, null)
|
||||||
|
|
||||||
override fun findDirty(): List<LocalTask> {
|
override fun findDirty(): List<LocalTask> {
|
||||||
val dmfsTasks = dmfsTaskList.findTasks(Tasks._DIRTY, null)
|
val tasks = queryTasks(Tasks._DIRTY, null)
|
||||||
for (localTask in dmfsTasks) {
|
for (localTask in tasks) {
|
||||||
try {
|
try {
|
||||||
val task = requireNotNull(localTask.task)
|
val task = requireNotNull(localTask.task)
|
||||||
val sequence = task.sequence
|
val sequence = task.sequence
|
||||||
@@ -60,35 +89,41 @@ class LocalTaskList (
|
|||||||
logger.log(Level.WARNING, "Couldn't check/increase sequence", e)
|
logger.log(Level.WARNING, "Couldn't check/increase sequence", e)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return dmfsTasks.map { LocalTask(it) }
|
return tasks
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun findByName(name: String) =
|
override fun findByName(name: String) =
|
||||||
dmfsTaskList.findTasks("${Tasks._SYNC_ID}=?", arrayOf(name))
|
queryTasks("${Tasks._SYNC_ID}=?", arrayOf(name)).firstOrNull()
|
||||||
.firstOrNull()?.let {
|
|
||||||
LocalTask(it)
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
override fun markNotDirty(flags: Int): Int =
|
override fun markNotDirty(flags: Int): Int {
|
||||||
dmfsTaskList.updateTasks(
|
val values = contentValuesOf(LocalTask.COLUMN_FLAGS to flags)
|
||||||
contentValuesOf(DmfsTask.COLUMN_FLAGS to flags),
|
return provider.update(tasksSyncUri(), values,
|
||||||
"${Tasks.LIST_ID}=? AND ${Tasks._DIRTY}=0",
|
"${Tasks.LIST_ID}=? AND ${Tasks._DIRTY}=0",
|
||||||
arrayOf(dmfsTaskList.id.toString())
|
arrayOf(id.toString()))
|
||||||
)
|
}
|
||||||
|
|
||||||
override fun removeNotDirtyMarked(flags: Int) =
|
override fun removeNotDirtyMarked(flags: Int) =
|
||||||
dmfsTaskList.deleteTasks(
|
provider.delete(tasksSyncUri(),
|
||||||
"${Tasks.LIST_ID}=? AND NOT ${Tasks._DIRTY} AND ${DmfsTask.COLUMN_FLAGS}=?",
|
"${Tasks.LIST_ID}=? AND NOT ${Tasks._DIRTY} AND ${LocalTask.COLUMN_FLAGS}=?",
|
||||||
arrayOf(dmfsTaskList.id.toString(), flags.toString())
|
arrayOf(id.toString(), flags.toString()))
|
||||||
)
|
|
||||||
|
|
||||||
override fun forgetETags() {
|
override fun forgetETags() {
|
||||||
dmfsTaskList.updateTasks(
|
val values = contentValuesOf(LocalTask.COLUMN_ETAG to null)
|
||||||
contentValuesOf(DmfsTask.COLUMN_ETAG to null),
|
provider.update(tasksSyncUri(), values, "${Tasks.LIST_ID}=?",
|
||||||
"${Tasks.LIST_ID}=?",
|
arrayOf(id.toString()))
|
||||||
arrayOf(dmfsTaskList.id.toString())
|
}
|
||||||
)
|
|
||||||
|
|
||||||
|
object Factory: DmfsTaskListFactory<LocalTaskList> {
|
||||||
|
|
||||||
|
override fun newInstance(
|
||||||
|
account: Account,
|
||||||
|
provider: ContentProviderClient,
|
||||||
|
providerName: TaskProvider.ProviderName,
|
||||||
|
id: Long
|
||||||
|
) = LocalTaskList(account, provider, providerName, id)
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
@@ -6,8 +6,10 @@ package at.bitfire.davdroid.resource
|
|||||||
|
|
||||||
import android.accounts.Account
|
import android.accounts.Account
|
||||||
import android.content.ContentProviderClient
|
import android.content.ContentProviderClient
|
||||||
|
import android.content.ContentUris
|
||||||
import android.content.ContentValues
|
import android.content.ContentValues
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
|
import android.net.Uri
|
||||||
import androidx.core.content.contentValuesOf
|
import androidx.core.content.contentValuesOf
|
||||||
import at.bitfire.davdroid.Constants
|
import at.bitfire.davdroid.Constants
|
||||||
import at.bitfire.davdroid.R
|
import at.bitfire.davdroid.R
|
||||||
@@ -15,9 +17,8 @@ import at.bitfire.davdroid.db.AppDatabase
|
|||||||
import at.bitfire.davdroid.db.Collection
|
import at.bitfire.davdroid.db.Collection
|
||||||
import at.bitfire.davdroid.settings.AccountSettings
|
import at.bitfire.davdroid.settings.AccountSettings
|
||||||
import at.bitfire.davdroid.util.DavUtils.lastSegment
|
import at.bitfire.davdroid.util.DavUtils.lastSegment
|
||||||
|
import at.bitfire.ical4android.DmfsTaskList
|
||||||
import at.bitfire.ical4android.TaskProvider
|
import at.bitfire.ical4android.TaskProvider
|
||||||
import at.bitfire.synctools.storage.tasks.DmfsTaskList
|
|
||||||
import at.bitfire.synctools.storage.tasks.DmfsTaskListProvider
|
|
||||||
import dagger.assisted.Assisted
|
import dagger.assisted.Assisted
|
||||||
import dagger.assisted.AssistedFactory
|
import dagger.assisted.AssistedFactory
|
||||||
import dagger.assisted.AssistedInject
|
import dagger.assisted.AssistedInject
|
||||||
@@ -27,7 +28,6 @@ import org.dmfs.tasks.contract.TaskContract.TaskLists
|
|||||||
import org.dmfs.tasks.contract.TaskContract.Tasks
|
import org.dmfs.tasks.contract.TaskContract.Tasks
|
||||||
import java.util.logging.Level
|
import java.util.logging.Level
|
||||||
import java.util.logging.Logger
|
import java.util.logging.Logger
|
||||||
import javax.annotation.WillNotClose
|
|
||||||
|
|
||||||
class LocalTaskListStore @AssistedInject constructor(
|
class LocalTaskListStore @AssistedInject constructor(
|
||||||
@Assisted private val providerName: TaskProvider.ProviderName,
|
@Assisted private val providerName: TaskProvider.ProviderName,
|
||||||
@@ -56,16 +56,16 @@ class LocalTaskListStore @AssistedInject constructor(
|
|||||||
/* return */ null
|
/* return */ null
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun create(client: ContentProviderClient, fromCollection: Collection): LocalTaskList? {
|
override fun create(provider: ContentProviderClient, fromCollection: Collection): LocalTaskList? {
|
||||||
val service = serviceDao.get(fromCollection.serviceId) ?: throw IllegalArgumentException("Couldn't fetch DB service from collection")
|
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))
|
val account = Account(service.accountName, context.getString(R.string.account_type))
|
||||||
|
|
||||||
logger.log(Level.INFO, "Adding local task list", fromCollection)
|
logger.log(Level.INFO, "Adding local task list", fromCollection)
|
||||||
val dmfsTaskList = create(account, client, providerName, fromCollection)
|
val uri = create(account, provider, providerName, fromCollection)
|
||||||
return LocalTaskList(dmfsTaskList)
|
return DmfsTaskList.findByID(account, provider, providerName, LocalTaskList.Factory, ContentUris.parseId(uri))
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun create(account: Account, client: ContentProviderClient, providerName: TaskProvider.ProviderName, fromCollection: Collection): DmfsTaskList {
|
private fun create(account: Account, provider: ContentProviderClient, providerName: TaskProvider.ProviderName, fromCollection: Collection): Uri {
|
||||||
// If the collection doesn't have a color, use a default color.
|
// If the collection doesn't have a color, use a default color.
|
||||||
val collectionWithColor = if (fromCollection.color != null)
|
val collectionWithColor = if (fromCollection.color != null)
|
||||||
fromCollection
|
fromCollection
|
||||||
@@ -80,8 +80,7 @@ class LocalTaskListStore @AssistedInject constructor(
|
|||||||
put(TaskLists.SYNC_ENABLED, 1)
|
put(TaskLists.SYNC_ENABLED, 1)
|
||||||
put(TaskLists.VISIBLE, 1)
|
put(TaskLists.VISIBLE, 1)
|
||||||
}
|
}
|
||||||
val dmfsTaskListProvider = DmfsTaskListProvider(account, client, providerName)
|
return DmfsTaskList.Companion.create(account, provider, providerName, values)
|
||||||
return dmfsTaskListProvider.createAndGetTaskList(values)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun valuesFromCollectionInfo(info: Collection, withColor: Boolean): ContentValues {
|
private fun valuesFromCollectionInfo(info: Collection, withColor: Boolean): ContentValues {
|
||||||
@@ -101,26 +100,25 @@ class LocalTaskListStore @AssistedInject constructor(
|
|||||||
return values
|
return values
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun getAll(account: Account, client: ContentProviderClient) =
|
override fun getAll(account: Account, provider: ContentProviderClient) =
|
||||||
DmfsTaskListProvider(account, client, providerName).findTaskLists()
|
DmfsTaskList.find(account, LocalTaskList.Factory, provider, providerName, null, null)
|
||||||
.map { LocalTaskList(it) }
|
|
||||||
|
|
||||||
override fun update(client: ContentProviderClient, localCollection: LocalTaskList, fromCollection: Collection) {
|
override fun update(provider: ContentProviderClient, localCollection: LocalTaskList, fromCollection: Collection) {
|
||||||
logger.log(Level.FINE, "Updating local task list ${fromCollection.url}", fromCollection)
|
logger.log(Level.FINE, "Updating local task list ${fromCollection.url}", fromCollection)
|
||||||
val accountSettings = accountSettingsFactory.create(localCollection.dmfsTaskList.account)
|
val accountSettings = accountSettingsFactory.create(localCollection.account)
|
||||||
localCollection.dmfsTaskList.update(valuesFromCollectionInfo(fromCollection, withColor = accountSettings.getManageCalendarColors()))
|
localCollection.update(valuesFromCollectionInfo(fromCollection, withColor = accountSettings.getManageCalendarColors()))
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun updateAccount(oldAccount: Account, newAccount: Account, @WillNotClose client: ContentProviderClient?) {
|
override fun updateAccount(oldAccount: Account, newAccount: Account) {
|
||||||
if (client == null)
|
TaskProvider.acquire(context, providerName)?.use { provider ->
|
||||||
return
|
val values = contentValuesOf(Tasks.ACCOUNT_NAME to newAccount.name)
|
||||||
val values = contentValuesOf(Tasks.ACCOUNT_NAME to newAccount.name)
|
val uri = Tasks.getContentUri(providerName.authority)
|
||||||
val uri = Tasks.getContentUri(providerName.authority)
|
provider.client.update(uri, values, "${Tasks.ACCOUNT_NAME}=?", arrayOf(oldAccount.name))
|
||||||
client.update(uri, values, "${Tasks.ACCOUNT_NAME}=?", arrayOf(oldAccount.name))
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun delete(localCollection: LocalTaskList) {
|
override fun delete(localCollection: LocalTaskList) {
|
||||||
localCollection.dmfsTaskList.delete()
|
localCollection.delete()
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
@@ -4,8 +4,8 @@
|
|||||||
|
|
||||||
package at.bitfire.davdroid.servicedetection
|
package at.bitfire.davdroid.servicedetection
|
||||||
|
|
||||||
import at.bitfire.dav4jvm.okhttp.DavResource
|
import at.bitfire.dav4jvm.DavResource
|
||||||
import at.bitfire.dav4jvm.okhttp.exception.HttpException
|
import at.bitfire.dav4jvm.exception.HttpException
|
||||||
import at.bitfire.dav4jvm.property.webdav.Owner
|
import at.bitfire.dav4jvm.property.webdav.Owner
|
||||||
import at.bitfire.davdroid.db.AppDatabase
|
import at.bitfire.davdroid.db.AppDatabase
|
||||||
import at.bitfire.davdroid.db.Collection
|
import at.bitfire.davdroid.db.Collection
|
||||||
@@ -63,7 +63,7 @@ class CollectionsWithoutHomeSetRefresher @AssistedInject constructor(
|
|||||||
}
|
}
|
||||||
} catch (e: HttpException) {
|
} catch (e: HttpException) {
|
||||||
// delete collection locally if it was not accessible (40x)
|
// delete collection locally if it was not accessible (40x)
|
||||||
if (e.statusCode in arrayOf(403, 404, 410))
|
if (e.code in arrayOf(403, 404, 410))
|
||||||
collectionRepository.delete(localCollection)
|
collectionRepository.delete(localCollection)
|
||||||
else
|
else
|
||||||
throw e
|
throw e
|
||||||
|
|||||||
@@ -6,26 +6,30 @@ package at.bitfire.davdroid.servicedetection
|
|||||||
import android.app.ActivityManager
|
import android.app.ActivityManager
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import androidx.core.content.getSystemService
|
import androidx.core.content.getSystemService
|
||||||
|
import at.bitfire.dav4jvm.DavResource
|
||||||
import at.bitfire.dav4jvm.Property
|
import at.bitfire.dav4jvm.Property
|
||||||
import at.bitfire.dav4jvm.okhttp.DavResource
|
import at.bitfire.dav4jvm.Response
|
||||||
import at.bitfire.dav4jvm.okhttp.Response
|
import at.bitfire.dav4jvm.UrlUtils
|
||||||
import at.bitfire.dav4jvm.okhttp.UrlUtils
|
import at.bitfire.dav4jvm.exception.DavException
|
||||||
import at.bitfire.dav4jvm.okhttp.exception.DavException
|
import at.bitfire.dav4jvm.exception.HttpException
|
||||||
import at.bitfire.dav4jvm.okhttp.exception.HttpException
|
import at.bitfire.dav4jvm.exception.UnauthorizedException
|
||||||
import at.bitfire.dav4jvm.okhttp.exception.UnauthorizedException
|
import at.bitfire.dav4jvm.property.caldav.CalendarColor
|
||||||
import at.bitfire.dav4jvm.property.caldav.CalDAV
|
import at.bitfire.dav4jvm.property.caldav.CalendarDescription
|
||||||
import at.bitfire.dav4jvm.property.caldav.CalendarHomeSet
|
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.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.AddressbookHomeSet
|
||||||
import at.bitfire.dav4jvm.property.carddav.CardDAV
|
|
||||||
import at.bitfire.dav4jvm.property.common.HrefListProperty
|
import at.bitfire.dav4jvm.property.common.HrefListProperty
|
||||||
import at.bitfire.dav4jvm.property.webdav.CurrentUserPrincipal
|
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.ResourceType
|
||||||
import at.bitfire.dav4jvm.property.webdav.WebDAV
|
|
||||||
import at.bitfire.davdroid.db.Collection
|
import at.bitfire.davdroid.db.Collection
|
||||||
import at.bitfire.davdroid.log.StringHandler
|
import at.bitfire.davdroid.log.StringHandler
|
||||||
import at.bitfire.davdroid.network.DnsRecordResolver
|
import at.bitfire.davdroid.network.DnsRecordResolver
|
||||||
import at.bitfire.davdroid.network.HttpClientBuilder
|
import at.bitfire.davdroid.network.HttpClient
|
||||||
import at.bitfire.davdroid.settings.Credentials
|
import at.bitfire.davdroid.settings.Credentials
|
||||||
import dagger.assisted.Assisted
|
import dagger.assisted.Assisted
|
||||||
import dagger.assisted.AssistedFactory
|
import dagger.assisted.AssistedFactory
|
||||||
@@ -58,8 +62,8 @@ class DavResourceFinder @AssistedInject constructor(
|
|||||||
@Assisted private val credentials: Credentials? = null,
|
@Assisted private val credentials: Credentials? = null,
|
||||||
@ApplicationContext val context: Context,
|
@ApplicationContext val context: Context,
|
||||||
private val dnsRecordResolver: DnsRecordResolver,
|
private val dnsRecordResolver: DnsRecordResolver,
|
||||||
httpClientBuilder: HttpClientBuilder
|
httpClientBuilder: HttpClient.Builder
|
||||||
) {
|
): AutoCloseable {
|
||||||
|
|
||||||
@AssistedFactory
|
@AssistedFactory
|
||||||
interface Factory {
|
interface Factory {
|
||||||
@@ -83,12 +87,16 @@ class DavResourceFinder @AssistedInject constructor(
|
|||||||
.apply {
|
.apply {
|
||||||
if (credentials != null)
|
if (credentials != null)
|
||||||
authenticate(
|
authenticate(
|
||||||
domain = null,
|
host = null,
|
||||||
getCredentials = { credentials }
|
getCredentials = { credentials }
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
.build()
|
.build()
|
||||||
|
|
||||||
|
override fun close() {
|
||||||
|
httpClient.close()
|
||||||
|
}
|
||||||
|
|
||||||
private fun initLogging(): StringHandler {
|
private fun initLogging(): StringHandler {
|
||||||
// don't use more than 1/4 of the available memory for a log string
|
// don't use more than 1/4 of the available memory for a log string
|
||||||
val activityManager = context.getSystemService<ActivityManager>()!!
|
val activityManager = context.getSystemService<ActivityManager>()!!
|
||||||
@@ -220,29 +228,27 @@ class DavResourceFinder @AssistedInject constructor(
|
|||||||
private fun checkBaseURL(baseURL: HttpUrl, service: Service, config: Configuration.ServiceInfo) {
|
private fun checkBaseURL(baseURL: HttpUrl, service: Service, config: Configuration.ServiceInfo) {
|
||||||
log.info("Checking user-given URL: $baseURL")
|
log.info("Checking user-given URL: $baseURL")
|
||||||
|
|
||||||
val davBaseURL = DavResource(httpClient, baseURL, log)
|
val davBaseURL = DavResource(httpClient.okHttpClient, baseURL, log)
|
||||||
try {
|
try {
|
||||||
when (service) {
|
when (service) {
|
||||||
Service.CARDDAV -> {
|
Service.CARDDAV -> {
|
||||||
davBaseURL.propfind(
|
davBaseURL.propfind(
|
||||||
0,
|
0,
|
||||||
WebDAV.ResourceType, WebDAV.DisplayName,
|
ResourceType.NAME, DisplayName.NAME, AddressbookDescription.NAME,
|
||||||
WebDAV.CurrentUserPrincipal,
|
AddressbookHomeSet.NAME,
|
||||||
CardDAV.AddressbookHomeSet,
|
CurrentUserPrincipal.NAME
|
||||||
CardDAV.AddressbookDescription
|
|
||||||
) { response, _ ->
|
) { response, _ ->
|
||||||
scanResponse(CardDAV.Addressbook, response, config)
|
scanResponse(ResourceType.ADDRESSBOOK, response, config)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Service.CALDAV -> {
|
Service.CALDAV -> {
|
||||||
davBaseURL.propfind(
|
davBaseURL.propfind(
|
||||||
0,
|
0,
|
||||||
WebDAV.ResourceType, WebDAV.DisplayName,
|
ResourceType.NAME, DisplayName.NAME, CalendarColor.NAME, CalendarDescription.NAME, CalendarTimezone.NAME, CurrentUserPrivilegeSet.NAME, SupportedCalendarComponentSet.NAME,
|
||||||
WebDAV.CurrentUserPrincipal, WebDAV.CurrentUserPrivilegeSet,
|
CalendarHomeSet.NAME,
|
||||||
CalDAV.CalendarHomeSet,
|
CurrentUserPrincipal.NAME
|
||||||
CalDAV.SupportedCalendarComponentSet, CalDAV.CalendarColor, CalDAV.CalendarDescription, CalDAV.CalendarTimezone
|
|
||||||
) { response, _ ->
|
) { response, _ ->
|
||||||
scanResponse(CalDAV.Calendar, response, config)
|
scanResponse(ResourceType.CALENDAR, response, config)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -260,7 +266,7 @@ class DavResourceFinder @AssistedInject constructor(
|
|||||||
fun queryEmailAddress(principal: HttpUrl): List<String> {
|
fun queryEmailAddress(principal: HttpUrl): List<String> {
|
||||||
val mailboxes = LinkedList<String>()
|
val mailboxes = LinkedList<String>()
|
||||||
try {
|
try {
|
||||||
DavResource(httpClient, principal, log).propfind(0, CalDAV.CalendarUserAddressSet) { response, _ ->
|
DavResource(httpClient.okHttpClient, principal, log).propfind(0, CalendarUserAddressSet.NAME) { response, _ ->
|
||||||
response[CalendarUserAddressSet::class.java]?.let { addressSet ->
|
response[CalendarUserAddressSet::class.java]?.let { addressSet ->
|
||||||
for (href in addressSet.hrefs)
|
for (href in addressSet.hrefs)
|
||||||
try {
|
try {
|
||||||
@@ -299,11 +305,11 @@ class DavResourceFinder @AssistedInject constructor(
|
|||||||
val homeSetClass: Class<out HrefListProperty>
|
val homeSetClass: Class<out HrefListProperty>
|
||||||
val serviceType: Service
|
val serviceType: Service
|
||||||
when (resourceType) {
|
when (resourceType) {
|
||||||
CardDAV.Addressbook -> {
|
ResourceType.ADDRESSBOOK -> {
|
||||||
homeSetClass = AddressbookHomeSet::class.java
|
homeSetClass = AddressbookHomeSet::class.java
|
||||||
serviceType = Service.CARDDAV
|
serviceType = Service.CARDDAV
|
||||||
}
|
}
|
||||||
CalDAV.Calendar -> {
|
ResourceType.CALENDAR -> {
|
||||||
homeSetClass = CalendarHomeSet::class.java
|
homeSetClass = CalendarHomeSet::class.java
|
||||||
serviceType = Service.CALDAV
|
serviceType = Service.CALDAV
|
||||||
}
|
}
|
||||||
@@ -324,7 +330,7 @@ class DavResourceFinder @AssistedInject constructor(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ... and/or a principal?
|
// ... and/or a principal?
|
||||||
if (it.types.contains(WebDAV.Principal))
|
if (it.types.contains(ResourceType.PRINCIPAL))
|
||||||
principal = davResponse.href
|
principal = davResponse.href
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -359,7 +365,7 @@ class DavResourceFinder @AssistedInject constructor(
|
|||||||
fun providesService(url: HttpUrl, service: Service): Boolean {
|
fun providesService(url: HttpUrl, service: Service): Boolean {
|
||||||
var provided = false
|
var provided = false
|
||||||
try {
|
try {
|
||||||
DavResource(httpClient, url, log).options { capabilities, _ ->
|
DavResource(httpClient.okHttpClient, url, log).options { capabilities, _ ->
|
||||||
if ((service == Service.CARDDAV && capabilities.contains("addressbook")) ||
|
if ((service == Service.CARDDAV && capabilities.contains("addressbook")) ||
|
||||||
(service == Service.CALDAV && capabilities.contains("calendar-access")))
|
(service == Service.CALDAV && capabilities.contains("calendar-access")))
|
||||||
provided = true
|
provided = true
|
||||||
@@ -444,7 +450,7 @@ class DavResourceFinder @AssistedInject constructor(
|
|||||||
*/
|
*/
|
||||||
fun getCurrentUserPrincipal(url: HttpUrl, service: Service?): HttpUrl? {
|
fun getCurrentUserPrincipal(url: HttpUrl, service: Service?): HttpUrl? {
|
||||||
var principal: HttpUrl? = null
|
var principal: HttpUrl? = null
|
||||||
DavResource(httpClient, url, log).propfind(0, WebDAV.CurrentUserPrincipal) { response, _ ->
|
DavResource(httpClient.okHttpClient, url, log).propfind(0, CurrentUserPrincipal.NAME) { response, _ ->
|
||||||
response[CurrentUserPrincipal::class.java]?.href?.let { href ->
|
response[CurrentUserPrincipal::class.java]?.href?.let { href ->
|
||||||
response.requestedUrl.resolve(href)?.let {
|
response.requestedUrl.resolve(href)?.let {
|
||||||
log.info("Found current-user-principal: $it")
|
log.info("Found current-user-principal: $it")
|
||||||
|
|||||||
@@ -4,9 +4,9 @@
|
|||||||
|
|
||||||
package at.bitfire.davdroid.servicedetection
|
package at.bitfire.davdroid.servicedetection
|
||||||
|
|
||||||
import at.bitfire.dav4jvm.okhttp.DavResource
|
import at.bitfire.dav4jvm.DavResource
|
||||||
import at.bitfire.dav4jvm.okhttp.Response
|
import at.bitfire.dav4jvm.Response
|
||||||
import at.bitfire.dav4jvm.okhttp.exception.HttpException
|
import at.bitfire.dav4jvm.exception.HttpException
|
||||||
import at.bitfire.dav4jvm.property.webdav.CurrentUserPrivilegeSet
|
import at.bitfire.dav4jvm.property.webdav.CurrentUserPrivilegeSet
|
||||||
import at.bitfire.dav4jvm.property.webdav.DisplayName
|
import at.bitfire.dav4jvm.property.webdav.DisplayName
|
||||||
import at.bitfire.dav4jvm.property.webdav.Owner
|
import at.bitfire.dav4jvm.property.webdav.Owner
|
||||||
@@ -103,7 +103,7 @@ class HomeSetRefresher @AssistedInject constructor(
|
|||||||
}
|
}
|
||||||
} catch (e: HttpException) {
|
} catch (e: HttpException) {
|
||||||
// delete home set locally if it was not accessible (40x)
|
// delete home set locally if it was not accessible (40x)
|
||||||
if (e.statusCode in arrayOf(403, 404, 410))
|
if (e.code in arrayOf(403, 404, 410))
|
||||||
homeSetRepository.deleteBlocking(localHomeset)
|
homeSetRepository.deleteBlocking(localHomeset)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4,9 +4,10 @@
|
|||||||
|
|
||||||
package at.bitfire.davdroid.servicedetection
|
package at.bitfire.davdroid.servicedetection
|
||||||
|
|
||||||
import at.bitfire.dav4jvm.okhttp.DavResource
|
import at.bitfire.dav4jvm.DavResource
|
||||||
import at.bitfire.dav4jvm.okhttp.exception.HttpException
|
import at.bitfire.dav4jvm.exception.HttpException
|
||||||
import at.bitfire.dav4jvm.property.webdav.WebDAV
|
import at.bitfire.dav4jvm.property.webdav.DisplayName
|
||||||
|
import at.bitfire.dav4jvm.property.webdav.ResourceType
|
||||||
import at.bitfire.davdroid.db.AppDatabase
|
import at.bitfire.davdroid.db.AppDatabase
|
||||||
import at.bitfire.davdroid.db.Principal
|
import at.bitfire.davdroid.db.Principal
|
||||||
import at.bitfire.davdroid.db.Service
|
import at.bitfire.davdroid.db.Service
|
||||||
@@ -35,8 +36,8 @@ class PrincipalsRefresher @AssistedInject constructor(
|
|||||||
* Principal properties to ask the server for.
|
* Principal properties to ask the server for.
|
||||||
*/
|
*/
|
||||||
private val principalProperties = arrayOf(
|
private val principalProperties = arrayOf(
|
||||||
WebDAV.DisplayName,
|
DisplayName.NAME,
|
||||||
WebDAV.ResourceType
|
ResourceType.NAME
|
||||||
)
|
)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -59,7 +60,7 @@ class PrincipalsRefresher @AssistedInject constructor(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (e: HttpException) {
|
} catch (e: HttpException) {
|
||||||
logger.info("Principal update failed with response code ${e.statusCode}. principalUrl=$principalUrl")
|
logger.info("Principal update failed with response code ${e.code}. principalUrl=$principalUrl")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -22,9 +22,9 @@ import androidx.work.OutOfQuotaPolicy
|
|||||||
import androidx.work.WorkInfo
|
import androidx.work.WorkInfo
|
||||||
import androidx.work.WorkManager
|
import androidx.work.WorkManager
|
||||||
import androidx.work.WorkerParameters
|
import androidx.work.WorkerParameters
|
||||||
import at.bitfire.dav4jvm.okhttp.exception.UnauthorizedException
|
import at.bitfire.dav4jvm.exception.UnauthorizedException
|
||||||
import at.bitfire.davdroid.R
|
import at.bitfire.davdroid.R
|
||||||
import at.bitfire.davdroid.network.HttpClientBuilder
|
import at.bitfire.davdroid.network.HttpClient
|
||||||
import at.bitfire.davdroid.push.PushRegistrationManager
|
import at.bitfire.davdroid.push.PushRegistrationManager
|
||||||
import at.bitfire.davdroid.repository.DavServiceRepository
|
import at.bitfire.davdroid.repository.DavServiceRepository
|
||||||
import at.bitfire.davdroid.servicedetection.RefreshCollectionsWorker.Companion.ARG_SERVICE_ID
|
import at.bitfire.davdroid.servicedetection.RefreshCollectionsWorker.Companion.ARG_SERVICE_ID
|
||||||
@@ -64,7 +64,7 @@ class RefreshCollectionsWorker @AssistedInject constructor(
|
|||||||
@Assisted workerParams: WorkerParameters,
|
@Assisted workerParams: WorkerParameters,
|
||||||
private val collectionsWithoutHomeSetRefresherFactory: CollectionsWithoutHomeSetRefresher.Factory,
|
private val collectionsWithoutHomeSetRefresherFactory: CollectionsWithoutHomeSetRefresher.Factory,
|
||||||
private val homeSetRefresherFactory: HomeSetRefresher.Factory,
|
private val homeSetRefresherFactory: HomeSetRefresher.Factory,
|
||||||
private val httpClientBuilder: HttpClientBuilder,
|
private val httpClientBuilder: HttpClient.Builder,
|
||||||
private val logger: Logger,
|
private val logger: Logger,
|
||||||
private val notificationRegistry: NotificationRegistry,
|
private val notificationRegistry: NotificationRegistry,
|
||||||
private val principalsRefresherFactory: PrincipalsRefresher.Factory,
|
private val principalsRefresherFactory: PrincipalsRefresher.Factory,
|
||||||
@@ -153,31 +153,34 @@ class RefreshCollectionsWorker @AssistedInject constructor(
|
|||||||
.cancel(serviceId.toString(), NotificationRegistry.NOTIFY_REFRESH_COLLECTIONS)
|
.cancel(serviceId.toString(), NotificationRegistry.NOTIFY_REFRESH_COLLECTIONS)
|
||||||
|
|
||||||
// create authenticating OkHttpClient (credentials taken from account settings)
|
// create authenticating OkHttpClient (credentials taken from account settings)
|
||||||
val httpClient = httpClientBuilder
|
httpClientBuilder
|
||||||
.fromAccount(account)
|
.fromAccount(account)
|
||||||
.build()
|
.build()
|
||||||
runInterruptible {
|
.use { httpClient ->
|
||||||
val refresher = collectionsWithoutHomeSetRefresherFactory.create(service, httpClient)
|
runInterruptible {
|
||||||
|
val httpClient = httpClient.okHttpClient
|
||||||
|
val refresher = collectionsWithoutHomeSetRefresherFactory.create(service, httpClient)
|
||||||
|
|
||||||
// refresh home set list (from principal url)
|
// refresh home set list (from principal url)
|
||||||
service.principal?.let { principalUrl ->
|
service.principal?.let { principalUrl ->
|
||||||
logger.fine("Querying principal $principalUrl for home sets")
|
logger.fine("Querying principal $principalUrl for home sets")
|
||||||
val serviceRefresher = serviceRefresherFactory.create(service, httpClient)
|
val serviceRefresher = serviceRefresherFactory.create(service, httpClient)
|
||||||
serviceRefresher.discoverHomesets(principalUrl)
|
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()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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) {
|
} catch(e: InvalidAccountException) {
|
||||||
logger.log(Level.SEVERE, "Invalid account", e)
|
logger.log(Level.SEVERE, "Invalid account", e)
|
||||||
return Result.failure()
|
return Result.failure()
|
||||||
|
|||||||
@@ -5,10 +5,19 @@
|
|||||||
package at.bitfire.davdroid.servicedetection
|
package at.bitfire.davdroid.servicedetection
|
||||||
|
|
||||||
import at.bitfire.dav4jvm.Property
|
import at.bitfire.dav4jvm.Property
|
||||||
import at.bitfire.dav4jvm.property.caldav.CalDAV
|
import at.bitfire.dav4jvm.property.caldav.CalendarColor
|
||||||
import at.bitfire.dav4jvm.property.carddav.CardDAV
|
import at.bitfire.dav4jvm.property.caldav.CalendarDescription
|
||||||
import at.bitfire.dav4jvm.property.push.WebDAVPush
|
import at.bitfire.dav4jvm.property.caldav.CalendarTimezone
|
||||||
import at.bitfire.dav4jvm.property.webdav.WebDAV
|
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.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.Owner
|
||||||
|
import at.bitfire.dav4jvm.property.webdav.ResourceType
|
||||||
import at.bitfire.davdroid.db.Collection
|
import at.bitfire.davdroid.db.Collection
|
||||||
import at.bitfire.davdroid.db.Service
|
import at.bitfire.davdroid.db.Service
|
||||||
import at.bitfire.davdroid.db.ServiceType
|
import at.bitfire.davdroid.db.ServiceType
|
||||||
@@ -20,24 +29,24 @@ object ServiceDetectionUtils {
|
|||||||
*/
|
*/
|
||||||
fun collectionQueryProperties(@ServiceType serviceType: String): Array<Property.Name> =
|
fun collectionQueryProperties(@ServiceType serviceType: String): Array<Property.Name> =
|
||||||
arrayOf( // generic WebDAV properties
|
arrayOf( // generic WebDAV properties
|
||||||
WebDAV.CurrentUserPrivilegeSet,
|
CurrentUserPrivilegeSet.NAME,
|
||||||
WebDAV.DisplayName,
|
DisplayName.NAME,
|
||||||
WebDAV.Owner,
|
Owner.NAME,
|
||||||
WebDAV.ResourceType,
|
ResourceType.NAME,
|
||||||
WebDAVPush.Transports,
|
PushTransports.NAME, // WebDAV-Push
|
||||||
WebDAVPush.Topic
|
Topic.NAME
|
||||||
) + when (serviceType) { // service-specific CalDAV/CardDAV properties
|
) + when (serviceType) { // service-specific CalDAV/CardDAV properties
|
||||||
Service.TYPE_CARDDAV -> arrayOf(
|
Service.TYPE_CARDDAV -> arrayOf(
|
||||||
CardDAV.AddressbookDescription
|
AddressbookDescription.NAME
|
||||||
)
|
)
|
||||||
|
|
||||||
Service.TYPE_CALDAV -> arrayOf(
|
Service.TYPE_CALDAV -> arrayOf(
|
||||||
CalDAV.CalendarColor,
|
CalendarColor.NAME,
|
||||||
CalDAV.CalendarDescription,
|
CalendarDescription.NAME,
|
||||||
CalDAV.CalendarTimezone,
|
CalendarTimezone.NAME,
|
||||||
CalDAV.CalendarTimezoneId,
|
CalendarTimezoneId.NAME,
|
||||||
CalDAV.SupportedCalendarComponentSet,
|
SupportedCalendarComponentSet.NAME,
|
||||||
CalDAV.Source
|
Source.NAME
|
||||||
)
|
)
|
||||||
|
|
||||||
else -> throw IllegalArgumentException()
|
else -> throw IllegalArgumentException()
|
||||||
|
|||||||
@@ -4,20 +4,18 @@
|
|||||||
|
|
||||||
package at.bitfire.davdroid.servicedetection
|
package at.bitfire.davdroid.servicedetection
|
||||||
|
|
||||||
|
import at.bitfire.dav4jvm.DavResource
|
||||||
import at.bitfire.dav4jvm.Property
|
import at.bitfire.dav4jvm.Property
|
||||||
import at.bitfire.dav4jvm.okhttp.DavResource
|
import at.bitfire.dav4jvm.UrlUtils
|
||||||
import at.bitfire.dav4jvm.okhttp.UrlUtils
|
import at.bitfire.dav4jvm.exception.HttpException
|
||||||
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.CalendarHomeSet
|
||||||
import at.bitfire.dav4jvm.property.caldav.CalendarProxyReadFor
|
import at.bitfire.dav4jvm.property.caldav.CalendarProxyReadFor
|
||||||
import at.bitfire.dav4jvm.property.caldav.CalendarProxyWriteFor
|
import at.bitfire.dav4jvm.property.caldav.CalendarProxyWriteFor
|
||||||
import at.bitfire.dav4jvm.property.carddav.AddressbookHomeSet
|
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.common.HrefListProperty
|
||||||
|
import at.bitfire.dav4jvm.property.webdav.DisplayName
|
||||||
import at.bitfire.dav4jvm.property.webdav.GroupMembership
|
import at.bitfire.dav4jvm.property.webdav.GroupMembership
|
||||||
import at.bitfire.dav4jvm.property.webdav.ResourceType
|
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.HomeSet
|
||||||
import at.bitfire.davdroid.db.Service
|
import at.bitfire.davdroid.db.Service
|
||||||
import at.bitfire.davdroid.repository.DavHomeSetRepository
|
import at.bitfire.davdroid.repository.DavHomeSetRepository
|
||||||
@@ -61,18 +59,18 @@ class ServiceRefresher @AssistedInject constructor(
|
|||||||
*/
|
*/
|
||||||
private val homeSetProperties: Array<Property.Name> =
|
private val homeSetProperties: Array<Property.Name> =
|
||||||
arrayOf( // generic WebDAV properties
|
arrayOf( // generic WebDAV properties
|
||||||
WebDAV.DisplayName,
|
DisplayName.NAME,
|
||||||
WebDAV.GroupMembership,
|
GroupMembership.NAME,
|
||||||
WebDAV.ResourceType
|
ResourceType.NAME
|
||||||
) + when (service.type) { // service-specific CalDAV/CardDAV properties
|
) + when (service.type) { // service-specific CalDAV/CardDAV properties
|
||||||
Service.TYPE_CARDDAV -> arrayOf(
|
Service.TYPE_CARDDAV -> arrayOf(
|
||||||
CardDAV.AddressbookHomeSet,
|
AddressbookHomeSet.NAME,
|
||||||
)
|
)
|
||||||
|
|
||||||
Service.TYPE_CALDAV -> arrayOf(
|
Service.TYPE_CALDAV -> arrayOf(
|
||||||
CalDAV.CalendarHomeSet,
|
CalendarHomeSet.NAME,
|
||||||
CalDAV.CalendarProxyReadFor,
|
CalendarProxyReadFor.NAME,
|
||||||
CalDAV.CalendarProxyWriteFor
|
CalendarProxyWriteFor.NAME
|
||||||
)
|
)
|
||||||
|
|
||||||
else -> throw IllegalArgumentException()
|
else -> throw IllegalArgumentException()
|
||||||
@@ -149,15 +147,15 @@ class ServiceRefresher @AssistedInject constructor(
|
|||||||
// If current resource is a calendar-proxy-read/write, it's likely that its parent is a principal, too.
|
// 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 ->
|
davResponse[ResourceType::class.java]?.let { resourceType ->
|
||||||
val proxyProperties = arrayOf(
|
val proxyProperties = arrayOf(
|
||||||
CalDAV.CalendarProxyRead,
|
ResourceType.CALENDAR_PROXY_READ,
|
||||||
CalDAV.CalendarProxyWrite
|
ResourceType.CALENDAR_PROXY_WRITE,
|
||||||
)
|
)
|
||||||
if (proxyProperties.any { resourceType.types.contains(it) })
|
if (proxyProperties.any { resourceType.types.contains(it) })
|
||||||
relatedResources += davResponse.href.parent()
|
relatedResources += davResponse.href.parent()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (e: HttpException) {
|
} catch (e: HttpException) {
|
||||||
if (e.isClientError)
|
if (e.code / 100 == 4)
|
||||||
logger.log(Level.INFO, "Ignoring Client Error 4xx while looking for ${service.type} home sets", e)
|
logger.log(Level.INFO, "Ignoring Client Error 4xx while looking for ${service.type} home sets", e)
|
||||||
else
|
else
|
||||||
throw e
|
throw e
|
||||||
|
|||||||
@@ -18,7 +18,6 @@ import at.bitfire.davdroid.sync.AutomaticSyncManager
|
|||||||
import at.bitfire.davdroid.sync.SyncDataType
|
import at.bitfire.davdroid.sync.SyncDataType
|
||||||
import at.bitfire.davdroid.sync.account.InvalidAccountException
|
import at.bitfire.davdroid.sync.account.InvalidAccountException
|
||||||
import at.bitfire.davdroid.sync.account.setAndVerifyUserData
|
import at.bitfire.davdroid.sync.account.setAndVerifyUserData
|
||||||
import at.bitfire.davdroid.util.SensitiveString.Companion.toSensitiveString
|
|
||||||
import at.bitfire.davdroid.util.trimToNull
|
import at.bitfire.davdroid.util.trimToNull
|
||||||
import at.bitfire.vcard4android.GroupMethod
|
import at.bitfire.vcard4android.GroupMethod
|
||||||
import dagger.assisted.Assisted
|
import dagger.assisted.Assisted
|
||||||
@@ -107,7 +106,7 @@ class AccountSettings @AssistedInject constructor(
|
|||||||
|
|
||||||
fun credentials() = Credentials(
|
fun credentials() = Credentials(
|
||||||
accountManager.getUserData(account, KEY_USERNAME),
|
accountManager.getUserData(account, KEY_USERNAME),
|
||||||
accountManager.getPassword(account)?.toSensitiveString(),
|
accountManager.getPassword(account)?.toCharArray(),
|
||||||
|
|
||||||
accountManager.getUserData(account, KEY_CERTIFICATE_ALIAS),
|
accountManager.getUserData(account, KEY_CERTIFICATE_ALIAS),
|
||||||
|
|
||||||
@@ -119,7 +118,7 @@ class AccountSettings @AssistedInject constructor(
|
|||||||
fun credentials(credentials: Credentials) {
|
fun credentials(credentials: Credentials) {
|
||||||
// Basic/Digest auth
|
// Basic/Digest auth
|
||||||
accountManager.setAndVerifyUserData(account, KEY_USERNAME, credentials.username)
|
accountManager.setAndVerifyUserData(account, KEY_USERNAME, credentials.username)
|
||||||
accountManager.setPassword(account, credentials.password?.asString())
|
accountManager.setPassword(account, credentials.password?.concatToString())
|
||||||
|
|
||||||
// client certificate
|
// client certificate
|
||||||
accountManager.setAndVerifyUserData(account, KEY_CERTIFICATE_ALIAS, credentials.certificateAlias)
|
accountManager.setAndVerifyUserData(account, KEY_CERTIFICATE_ALIAS, credentials.certificateAlias)
|
||||||
|
|||||||
@@ -4,7 +4,6 @@
|
|||||||
|
|
||||||
package at.bitfire.davdroid.settings
|
package at.bitfire.davdroid.settings
|
||||||
|
|
||||||
import at.bitfire.davdroid.util.SensitiveString
|
|
||||||
import net.openid.appauth.AuthState
|
import net.openid.appauth.AuthState
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -17,7 +16,7 @@ data class Credentials(
|
|||||||
/** username for Basic / Digest auth */
|
/** username for Basic / Digest auth */
|
||||||
val username: String? = null,
|
val username: String? = null,
|
||||||
/** password for Basic / Digest auth */
|
/** password for Basic / Digest auth */
|
||||||
val password: SensitiveString? = null,
|
val password: CharArray? = null,
|
||||||
|
|
||||||
/** alias of an client certificate that is present on the system */
|
/** alias of an client certificate that is present on the system */
|
||||||
val certificateAlias: String? = null,
|
val certificateAlias: String? = null,
|
||||||
@@ -43,4 +42,27 @@ data class Credentials(
|
|||||||
return "Credentials(" + s.joinToString(", ") + ")"
|
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
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
@@ -12,7 +12,7 @@ import android.provider.CalendarContract.Calendars
|
|||||||
import android.provider.CalendarContract.Reminders
|
import android.provider.CalendarContract.Reminders
|
||||||
import androidx.core.content.ContextCompat
|
import androidx.core.content.ContextCompat
|
||||||
import androidx.core.content.contentValuesOf
|
import androidx.core.content.contentValuesOf
|
||||||
import at.bitfire.ical4android.DmfsTask
|
import at.bitfire.davdroid.resource.LocalTask
|
||||||
import at.bitfire.ical4android.TaskProvider
|
import at.bitfire.ical4android.TaskProvider
|
||||||
import at.techbee.jtx.JtxContract.asSyncAdapter
|
import at.techbee.jtx.JtxContract.asSyncAdapter
|
||||||
import dagger.Binds
|
import dagger.Binds
|
||||||
@@ -39,7 +39,7 @@ class AccountSettingsMigration10 @Inject constructor(
|
|||||||
override fun migrate(account: Account) {
|
override fun migrate(account: Account) {
|
||||||
TaskProvider.acquire(context, TaskProvider.ProviderName.OpenTasks)?.use { provider ->
|
TaskProvider.acquire(context, TaskProvider.ProviderName.OpenTasks)?.use { provider ->
|
||||||
val tasksUri = provider.tasksUri().asSyncAdapter(account)
|
val tasksUri = provider.tasksUri().asSyncAdapter(account)
|
||||||
val emptyETag = contentValuesOf(DmfsTask.COLUMN_ETAG to null)
|
val emptyETag = contentValuesOf(LocalTask.COLUMN_ETAG to null)
|
||||||
provider.client.update(tasksUri, emptyETag, "${TaskContract.Tasks._DIRTY}=0 AND ${TaskContract.Tasks._DELETED}=0", null)
|
provider.client.update(tasksUri, emptyETag, "${TaskContract.Tasks._DIRTY}=0 AND ${TaskContract.Tasks._DELETED}=0", null)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ import android.util.Base64
|
|||||||
import androidx.core.content.ContextCompat
|
import androidx.core.content.ContextCompat
|
||||||
import androidx.core.content.contentValuesOf
|
import androidx.core.content.contentValuesOf
|
||||||
import at.bitfire.ical4android.UnknownProperty
|
import at.bitfire.ical4android.UnknownProperty
|
||||||
import at.bitfire.synctools.storage.calendar.EventsContract
|
import at.bitfire.synctools.storage.calendar.AndroidEvent2
|
||||||
import at.techbee.jtx.JtxContract.asSyncAdapter
|
import at.techbee.jtx.JtxContract.asSyncAdapter
|
||||||
import dagger.Binds
|
import dagger.Binds
|
||||||
import dagger.Module
|
import dagger.Module
|
||||||
@@ -69,7 +69,7 @@ class AccountSettingsMigration12 @Inject constructor(
|
|||||||
val property = UnknownProperty.fromJsonString(rawValue)
|
val property = UnknownProperty.fromJsonString(rawValue)
|
||||||
if (property is Url) { // rewrite to MIMETYPE_URL
|
if (property is Url) { // rewrite to MIMETYPE_URL
|
||||||
val newValues = contentValuesOf(
|
val newValues = contentValuesOf(
|
||||||
CalendarContract.ExtendedProperties.NAME to EventsContract.EXTNAME_URL,
|
CalendarContract.ExtendedProperties.NAME to AndroidEvent2.EXTNAME_URL,
|
||||||
CalendarContract.ExtendedProperties.VALUE to property.value
|
CalendarContract.ExtendedProperties.VALUE to property.value
|
||||||
)
|
)
|
||||||
provider.update(uri, newValues, null, null)
|
provider.update(uri, newValues, null, null)
|
||||||
@@ -77,7 +77,7 @@ class AccountSettingsMigration12 @Inject constructor(
|
|||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
logger.log(
|
logger.log(
|
||||||
Level.WARNING,
|
Level.WARNING,
|
||||||
"Couldn't rewrite URL from unknown property to ${EventsContract.EXTNAME_URL}",
|
"Couldn't rewrite URL from unknown property to ${AndroidEvent2.EXTNAME_URL}",
|
||||||
e
|
e
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -98,9 +98,9 @@ class AccountSettingsMigration20 @Inject constructor(
|
|||||||
for (taskList in taskListStore.getAll(account, provider)) {
|
for (taskList in taskListStore.getAll(account, provider)) {
|
||||||
when (taskList) {
|
when (taskList) {
|
||||||
is LocalTaskList -> { // tasks.org, OpenTasks
|
is LocalTaskList -> { // tasks.org, OpenTasks
|
||||||
val url = taskList.dmfsTaskList.syncId ?: continue
|
val url = taskList.syncId ?: continue
|
||||||
collectionRepository.getByServiceAndUrl(calDavServiceId, url)?.let { collection ->
|
collectionRepository.getByServiceAndUrl(calDavServiceId, url)?.let { collection ->
|
||||||
taskList.dmfsTaskList.update(contentValuesOf(
|
taskList.update(contentValuesOf(
|
||||||
TaskLists._SYNC_ID to collection.id.toString()
|
TaskLists._SYNC_ID to collection.id.toString()
|
||||||
))
|
))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,15 +5,18 @@
|
|||||||
package at.bitfire.davdroid.settings.migration
|
package at.bitfire.davdroid.settings.migration
|
||||||
|
|
||||||
import android.accounts.Account
|
import android.accounts.Account
|
||||||
import android.content.ContentResolver
|
import android.accounts.AccountManager
|
||||||
|
import android.content.Context
|
||||||
import android.os.Build
|
import android.os.Build
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
|
import android.provider.CalendarContract
|
||||||
import android.provider.ContactsContract
|
import android.provider.ContactsContract
|
||||||
import at.bitfire.davdroid.resource.LocalAddressBookStore
|
import at.bitfire.davdroid.R
|
||||||
import at.bitfire.davdroid.sync.SyncDataType
|
import at.bitfire.davdroid.sync.adapter.SyncFrameworkIntegration
|
||||||
import dagger.Binds
|
import dagger.Binds
|
||||||
import dagger.Module
|
import dagger.Module
|
||||||
import dagger.hilt.InstallIn
|
import dagger.hilt.InstallIn
|
||||||
|
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||||
import dagger.hilt.components.SingletonComponent
|
import dagger.hilt.components.SingletonComponent
|
||||||
import dagger.multibindings.IntKey
|
import dagger.multibindings.IntKey
|
||||||
import dagger.multibindings.IntoMap
|
import dagger.multibindings.IntoMap
|
||||||
@@ -31,46 +34,36 @@ import javax.inject.Inject
|
|||||||
* (+tasks) account syncs.
|
* (+tasks) account syncs.
|
||||||
*/
|
*/
|
||||||
class AccountSettingsMigration21 @Inject constructor(
|
class AccountSettingsMigration21 @Inject constructor(
|
||||||
private val localAddressBookStore: LocalAddressBookStore,
|
@ApplicationContext private val context: Context,
|
||||||
|
private val syncFrameworkIntegration: SyncFrameworkIntegration,
|
||||||
private val logger: Logger
|
private val logger: Logger
|
||||||
): AccountSettingsMigration {
|
): AccountSettingsMigration {
|
||||||
|
|
||||||
/**
|
private val accountManager = AccountManager.get(context)
|
||||||
* Cancel any possibly forever pending account syncs of the different authorities
|
|
||||||
*/
|
private val calendarAccountType = context.getString(R.string.account_type)
|
||||||
|
private val addressBookAccountType = context.getString(R.string.account_type_address_book)
|
||||||
|
|
||||||
override fun migrate(account: Account) {
|
override fun migrate(account: Account) {
|
||||||
if (Build.VERSION.SDK_INT >= 34) {
|
if (Build.VERSION.SDK_INT >= 34) {
|
||||||
// Request new dummy syncs (yes, seems like this is needed)
|
// Cancel any (after an update) possibly forever pending calendar (+tasks) account syncs
|
||||||
val extras = Bundle().apply {
|
cancelSyncs(calendarAccountType, CalendarContract.AUTHORITY)
|
||||||
putBoolean(ContentResolver.SYNC_EXTRAS_MANUAL, true)
|
|
||||||
putBoolean(ContentResolver.SYNC_EXTRAS_EXPEDITED, true)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Request calendar and tasks syncs and cancel all syncs account wide
|
// Cancel any (after an update) possibly forever pending address book account syncs
|
||||||
val possibleAuthorities = SyncDataType.EVENTS.possibleAuthorities() +
|
cancelSyncs(addressBookAccountType, ContactsContract.AUTHORITY)
|
||||||
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
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cancels any (possibly forever pending) syncs for the accounts of given account type for all
|
||||||
|
* authorities.
|
||||||
|
*/
|
||||||
|
private fun cancelSyncs(accountType: String, authority: String) {
|
||||||
|
accountManager.getAccountsByType(accountType).forEach { account ->
|
||||||
|
logger.info("Android 14+: Canceling all (possibly forever pending) syncs for $account")
|
||||||
|
syncFrameworkIntegration.cancelSync(account, authority, Bundle())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Module
|
@Module
|
||||||
@InstallIn(SingletonComponent::class)
|
@InstallIn(SingletonComponent::class)
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import dagger.hilt.components.SingletonComponent
|
|||||||
import dagger.multibindings.IntKey
|
import dagger.multibindings.IntKey
|
||||||
import dagger.multibindings.IntoMap
|
import dagger.multibindings.IntoMap
|
||||||
import org.dmfs.tasks.contract.TaskContract
|
import org.dmfs.tasks.contract.TaskContract
|
||||||
|
import org.dmfs.tasks.contract.TaskContract.CommonSyncColumns
|
||||||
import java.util.logging.Level
|
import java.util.logging.Level
|
||||||
import java.util.logging.Logger
|
import java.util.logging.Logger
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
@@ -49,7 +50,7 @@ class AccountSettingsMigration8 @Inject constructor(
|
|||||||
TaskContract.Tasks.SYNC1 to null,
|
TaskContract.Tasks.SYNC1 to null,
|
||||||
TaskContract.Tasks.SYNC2 to null
|
TaskContract.Tasks.SYNC2 to null
|
||||||
)
|
)
|
||||||
logger.log(Level.FINE, "Updating task $id", values)
|
logger.log(Level.FINER, "Updating task $id", values)
|
||||||
provider.client.update(
|
provider.client.update(
|
||||||
ContentUris.withAppendedId(provider.tasksUri(), id).asSyncAdapter(account),
|
ContentUris.withAppendedId(provider.tasksUri(), id).asSyncAdapter(account),
|
||||||
values, null, null)
|
values, null, null)
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import android.content.ContentProviderClient
|
|||||||
import android.provider.ContactsContract
|
import android.provider.ContactsContract
|
||||||
import at.bitfire.davdroid.db.Collection
|
import at.bitfire.davdroid.db.Collection
|
||||||
import at.bitfire.davdroid.db.Service
|
import at.bitfire.davdroid.db.Service
|
||||||
|
import at.bitfire.davdroid.network.HttpClient
|
||||||
import at.bitfire.davdroid.resource.LocalAddressBook
|
import at.bitfire.davdroid.resource.LocalAddressBook
|
||||||
import at.bitfire.davdroid.resource.LocalAddressBookStore
|
import at.bitfire.davdroid.resource.LocalAddressBookStore
|
||||||
import at.bitfire.davdroid.settings.AccountSettings
|
import at.bitfire.davdroid.settings.AccountSettings
|
||||||
@@ -18,7 +19,6 @@ import dagger.assisted.Assisted
|
|||||||
import dagger.assisted.AssistedFactory
|
import dagger.assisted.AssistedFactory
|
||||||
import dagger.assisted.AssistedInject
|
import dagger.assisted.AssistedInject
|
||||||
import kotlinx.coroutines.runBlocking
|
import kotlinx.coroutines.runBlocking
|
||||||
import okhttp3.OkHttpClient
|
|
||||||
import java.util.logging.Level
|
import java.util.logging.Level
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -58,7 +58,7 @@ class AddressBookSyncer @AssistedInject constructor(
|
|||||||
syncAddressBook(
|
syncAddressBook(
|
||||||
account = account,
|
account = account,
|
||||||
addressBook = localCollection,
|
addressBook = localCollection,
|
||||||
provideHttpClient = { httpClient },
|
httpClient = httpClient,
|
||||||
provider = provider,
|
provider = provider,
|
||||||
syncResult = syncResult,
|
syncResult = syncResult,
|
||||||
collection = remoteCollection
|
collection = remoteCollection
|
||||||
@@ -68,16 +68,15 @@ class AddressBookSyncer @AssistedInject constructor(
|
|||||||
/**
|
/**
|
||||||
* Synchronizes an address book
|
* Synchronizes an address book
|
||||||
*
|
*
|
||||||
* @param addressBook local address book
|
* @param addressBook local address book
|
||||||
* @param provideHttpClient returns HTTP client on demand
|
* @param provider Content provider to access android contacts
|
||||||
* @param provider content provider to access android contacts
|
* @param syncResult Stores hard and soft sync errors
|
||||||
* @param syncResult stores hard and soft sync errors
|
* @param collection The database collection associated with this address book
|
||||||
* @param collection the database collection associated with this address book
|
|
||||||
*/
|
*/
|
||||||
private fun syncAddressBook(
|
private fun syncAddressBook(
|
||||||
account: Account,
|
account: Account,
|
||||||
addressBook: LocalAddressBook,
|
addressBook: LocalAddressBook,
|
||||||
provideHttpClient: () -> OkHttpClient,
|
httpClient: Lazy<HttpClient>,
|
||||||
provider: ContentProviderClient,
|
provider: ContentProviderClient,
|
||||||
syncResult: SyncResult,
|
syncResult: SyncResult,
|
||||||
collection: Collection
|
collection: Collection
|
||||||
@@ -104,7 +103,7 @@ class AddressBookSyncer @AssistedInject constructor(
|
|||||||
|
|
||||||
val syncManager = contactsSyncManagerFactory.contactsSyncManager(
|
val syncManager = contactsSyncManagerFactory.contactsSyncManager(
|
||||||
account,
|
account,
|
||||||
provideHttpClient(),
|
httpClient.value,
|
||||||
syncResult,
|
syncResult,
|
||||||
provider,
|
provider,
|
||||||
addressBook,
|
addressBook,
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ package at.bitfire.davdroid.sync
|
|||||||
import android.accounts.Account
|
import android.accounts.Account
|
||||||
import android.provider.CalendarContract
|
import android.provider.CalendarContract
|
||||||
import android.provider.ContactsContract
|
import android.provider.ContactsContract
|
||||||
import androidx.annotation.WorkerThread
|
|
||||||
import at.bitfire.davdroid.db.Service
|
import at.bitfire.davdroid.db.Service
|
||||||
import at.bitfire.davdroid.repository.DavServiceRepository
|
import at.bitfire.davdroid.repository.DavServiceRepository
|
||||||
import at.bitfire.davdroid.resource.LocalAddressBookStore
|
import at.bitfire.davdroid.resource.LocalAddressBookStore
|
||||||
@@ -60,7 +59,6 @@ class AutomaticSyncManager @Inject constructor(
|
|||||||
* @param account the account to synchronize
|
* @param account the account to synchronize
|
||||||
* @param dataType the data type to synchronize
|
* @param dataType the data type to synchronize
|
||||||
*/
|
*/
|
||||||
@WorkerThread
|
|
||||||
private fun enableAutomaticSync(
|
private fun enableAutomaticSync(
|
||||||
account: Account,
|
account: Account,
|
||||||
dataType: SyncDataType
|
dataType: SyncDataType
|
||||||
@@ -115,7 +113,6 @@ class AutomaticSyncManager @Inject constructor(
|
|||||||
*
|
*
|
||||||
* @param account account for which automatic synchronization shall be updated
|
* @param account account for which automatic synchronization shall be updated
|
||||||
*/
|
*/
|
||||||
@WorkerThread
|
|
||||||
fun updateAutomaticSync(account: Account) {
|
fun updateAutomaticSync(account: Account) {
|
||||||
for (dataType in SyncDataType.entries)
|
for (dataType in SyncDataType.entries)
|
||||||
updateAutomaticSync(account, dataType)
|
updateAutomaticSync(account, dataType)
|
||||||
@@ -131,7 +128,6 @@ class AutomaticSyncManager @Inject constructor(
|
|||||||
* @param account account for which automatic synchronization shall be updated
|
* @param account account for which automatic synchronization shall be updated
|
||||||
* @param dataType sync data type for which automatic synchronization shall be updated
|
* @param dataType sync data type for which automatic synchronization shall be updated
|
||||||
*/
|
*/
|
||||||
@WorkerThread
|
|
||||||
fun updateAutomaticSync(account: Account, dataType: SyncDataType) {
|
fun updateAutomaticSync(account: Account, dataType: SyncDataType) {
|
||||||
val serviceType = when (dataType) {
|
val serviceType = when (dataType) {
|
||||||
SyncDataType.CONTACTS -> Service.TYPE_CARDDAV
|
SyncDataType.CONTACTS -> Service.TYPE_CARDDAV
|
||||||
|
|||||||
@@ -6,49 +6,48 @@ package at.bitfire.davdroid.sync
|
|||||||
|
|
||||||
import android.accounts.Account
|
import android.accounts.Account
|
||||||
import android.text.format.Formatter
|
import android.text.format.Formatter
|
||||||
import at.bitfire.dav4jvm.okhttp.DavCalendar
|
import at.bitfire.dav4jvm.DavCalendar
|
||||||
import at.bitfire.dav4jvm.okhttp.MultiResponseCallback
|
import at.bitfire.dav4jvm.MultiResponseCallback
|
||||||
import at.bitfire.dav4jvm.okhttp.Response
|
import at.bitfire.dav4jvm.Response
|
||||||
import at.bitfire.dav4jvm.okhttp.exception.DavException
|
import at.bitfire.dav4jvm.exception.DavException
|
||||||
import at.bitfire.dav4jvm.property.caldav.CalDAV
|
|
||||||
import at.bitfire.dav4jvm.property.caldav.CalendarData
|
import at.bitfire.dav4jvm.property.caldav.CalendarData
|
||||||
|
import at.bitfire.dav4jvm.property.caldav.GetCTag
|
||||||
import at.bitfire.dav4jvm.property.caldav.MaxResourceSize
|
import at.bitfire.dav4jvm.property.caldav.MaxResourceSize
|
||||||
import at.bitfire.dav4jvm.property.caldav.ScheduleTag
|
import at.bitfire.dav4jvm.property.caldav.ScheduleTag
|
||||||
import at.bitfire.dav4jvm.property.webdav.GetETag
|
import at.bitfire.dav4jvm.property.webdav.GetETag
|
||||||
import at.bitfire.dav4jvm.property.webdav.SupportedReportSet
|
import at.bitfire.dav4jvm.property.webdav.SupportedReportSet
|
||||||
import at.bitfire.dav4jvm.property.webdav.WebDAV
|
import at.bitfire.dav4jvm.property.webdav.SyncToken
|
||||||
import at.bitfire.davdroid.Constants
|
import at.bitfire.davdroid.Constants
|
||||||
import at.bitfire.davdroid.R
|
import at.bitfire.davdroid.R
|
||||||
import at.bitfire.davdroid.db.Collection
|
import at.bitfire.davdroid.db.Collection
|
||||||
import at.bitfire.davdroid.di.SyncDispatcher
|
import at.bitfire.davdroid.di.SyncDispatcher
|
||||||
|
import at.bitfire.davdroid.network.HttpClient
|
||||||
import at.bitfire.davdroid.resource.LocalCalendar
|
import at.bitfire.davdroid.resource.LocalCalendar
|
||||||
import at.bitfire.davdroid.resource.LocalEvent
|
import at.bitfire.davdroid.resource.LocalEvent
|
||||||
import at.bitfire.davdroid.resource.LocalResource
|
import at.bitfire.davdroid.resource.LocalResource
|
||||||
import at.bitfire.davdroid.resource.SyncState
|
import at.bitfire.davdroid.resource.SyncState
|
||||||
import at.bitfire.davdroid.settings.AccountSettings
|
import at.bitfire.davdroid.settings.AccountSettings
|
||||||
import at.bitfire.davdroid.util.DavUtils
|
|
||||||
import at.bitfire.davdroid.util.DavUtils.lastSegment
|
import at.bitfire.davdroid.util.DavUtils.lastSegment
|
||||||
import at.bitfire.synctools.exception.InvalidICalendarException
|
import at.bitfire.ical4android.Event
|
||||||
import at.bitfire.synctools.icalendar.CalendarUidSplitter
|
import at.bitfire.ical4android.EventReader
|
||||||
import at.bitfire.synctools.icalendar.ICalendarGenerator
|
import at.bitfire.ical4android.EventWriter
|
||||||
import at.bitfire.synctools.icalendar.ICalendarParser
|
import at.bitfire.ical4android.util.DateUtils
|
||||||
import at.bitfire.synctools.mapping.calendar.AndroidEventBuilder
|
import at.bitfire.synctools.exception.InvalidRemoteResourceException
|
||||||
import at.bitfire.synctools.mapping.calendar.AndroidEventHandler
|
|
||||||
import at.bitfire.synctools.mapping.calendar.DefaultProdIdGenerator
|
|
||||||
import at.bitfire.synctools.mapping.calendar.SequenceUpdater
|
|
||||||
import dagger.assisted.Assisted
|
import dagger.assisted.Assisted
|
||||||
import dagger.assisted.AssistedFactory
|
import dagger.assisted.AssistedFactory
|
||||||
import dagger.assisted.AssistedInject
|
import dagger.assisted.AssistedInject
|
||||||
import kotlinx.coroutines.CoroutineDispatcher
|
import kotlinx.coroutines.CoroutineDispatcher
|
||||||
import kotlinx.coroutines.runInterruptible
|
import kotlinx.coroutines.runInterruptible
|
||||||
import net.fortuna.ical4j.model.Component
|
import net.fortuna.ical4j.model.Component
|
||||||
import net.fortuna.ical4j.model.component.VEvent
|
import net.fortuna.ical4j.model.component.VAlarm
|
||||||
|
import net.fortuna.ical4j.model.property.Action
|
||||||
import okhttp3.HttpUrl
|
import okhttp3.HttpUrl
|
||||||
import okhttp3.OkHttpClient
|
import okhttp3.RequestBody
|
||||||
import okhttp3.RequestBody.Companion.toRequestBody
|
import okhttp3.RequestBody.Companion.toRequestBody
|
||||||
import java.io.Reader
|
import java.io.Reader
|
||||||
import java.io.StringReader
|
import java.io.StringReader
|
||||||
import java.io.StringWriter
|
import java.io.StringWriter
|
||||||
|
import java.time.Duration
|
||||||
import java.time.ZonedDateTime
|
import java.time.ZonedDateTime
|
||||||
import java.util.Optional
|
import java.util.Optional
|
||||||
import java.util.logging.Level
|
import java.util.logging.Level
|
||||||
@@ -58,7 +57,7 @@ import java.util.logging.Level
|
|||||||
*/
|
*/
|
||||||
class CalendarSyncManager @AssistedInject constructor(
|
class CalendarSyncManager @AssistedInject constructor(
|
||||||
@Assisted account: Account,
|
@Assisted account: Account,
|
||||||
@Assisted httpClient: OkHttpClient,
|
@Assisted httpClient: HttpClient,
|
||||||
@Assisted syncResult: SyncResult,
|
@Assisted syncResult: SyncResult,
|
||||||
@Assisted localCalendar: LocalCalendar,
|
@Assisted localCalendar: LocalCalendar,
|
||||||
@Assisted collection: Collection,
|
@Assisted collection: Collection,
|
||||||
@@ -80,7 +79,7 @@ class CalendarSyncManager @AssistedInject constructor(
|
|||||||
interface Factory {
|
interface Factory {
|
||||||
fun calendarSyncManager(
|
fun calendarSyncManager(
|
||||||
account: Account,
|
account: Account,
|
||||||
httpClient: OkHttpClient,
|
httpClient: HttpClient,
|
||||||
syncResult: SyncResult,
|
syncResult: SyncResult,
|
||||||
localCalendar: LocalCalendar,
|
localCalendar: LocalCalendar,
|
||||||
collection: Collection,
|
collection: Collection,
|
||||||
@@ -92,15 +91,13 @@ class CalendarSyncManager @AssistedInject constructor(
|
|||||||
|
|
||||||
|
|
||||||
override fun prepare(): Boolean {
|
override fun prepare(): Boolean {
|
||||||
davCollection = DavCalendar(httpClient, collection.url)
|
davCollection = DavCalendar(httpClient.okHttpClient, collection.url)
|
||||||
|
|
||||||
// if there are dirty exceptions for events, mark their master events as dirty, too
|
// if there are dirty exceptions for events, mark their master events as dirty, too
|
||||||
val recurringCalendar = localCollection.recurringCalendar
|
localCollection.processDirtyExceptions()
|
||||||
recurringCalendar.processDeletedExceptions()
|
|
||||||
recurringCalendar.processDirtyExceptions()
|
|
||||||
|
|
||||||
// now find dirty events that have no instances and set them to deleted
|
// now find dirty events that have no instances and set them to deleted
|
||||||
localCollection.androidCalendar.deleteDirtyEventsWithoutInstances()
|
localCollection.deleteDirtyEventsWithoutInstances()
|
||||||
|
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
@@ -109,20 +106,14 @@ class CalendarSyncManager @AssistedInject constructor(
|
|||||||
SyncException.wrapWithRemoteResourceSuspending(collection.url) {
|
SyncException.wrapWithRemoteResourceSuspending(collection.url) {
|
||||||
var syncState: SyncState? = null
|
var syncState: SyncState? = null
|
||||||
runInterruptible {
|
runInterruptible {
|
||||||
davCollection.propfind(
|
davCollection.propfind(0, MaxResourceSize.NAME, SupportedReportSet.NAME, GetCTag.NAME, SyncToken.NAME) { response, relation ->
|
||||||
0,
|
|
||||||
CalDAV.MaxResourceSize,
|
|
||||||
WebDAV.SupportedReportSet,
|
|
||||||
CalDAV.GetCTag,
|
|
||||||
WebDAV.SyncToken
|
|
||||||
) { response, relation ->
|
|
||||||
if (relation == Response.HrefRelation.SELF) {
|
if (relation == Response.HrefRelation.SELF) {
|
||||||
response[MaxResourceSize::class.java]?.maxSize?.let { maxSize ->
|
response[MaxResourceSize::class.java]?.maxSize?.let { maxSize ->
|
||||||
logger.info("Calendar accepts events up to ${Formatter.formatFileSize(context, maxSize)}")
|
logger.info("Calendar accepts events up to ${Formatter.formatFileSize(context, maxSize)}")
|
||||||
}
|
}
|
||||||
|
|
||||||
response[SupportedReportSet::class.java]?.let { supported ->
|
response[SupportedReportSet::class.java]?.let { supported ->
|
||||||
hasCollectionSync = supported.reports.contains(WebDAV.SyncCollection)
|
hasCollectionSync = supported.reports.contains(SupportedReportSet.SYNC_COLLECTION)
|
||||||
}
|
}
|
||||||
syncState = syncState(response)
|
syncState = syncState(response)
|
||||||
}
|
}
|
||||||
@@ -187,38 +178,24 @@ class CalendarSyncManager @AssistedInject constructor(
|
|||||||
return modified or superModified
|
return modified or superModified
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun generateUpload(resource: LocalEvent): GeneratedResource {
|
override fun onSuccessfulUpload(local: LocalEvent, newFileName: String, eTag: String?, scheduleTag: String?) {
|
||||||
val localEvent = resource.androidEvent
|
super.onSuccessfulUpload(local, newFileName, eTag, scheduleTag)
|
||||||
logger.log(Level.FINE, "Preparing upload of event #${resource.id}", localEvent)
|
|
||||||
|
|
||||||
// increase SEQUENCE of main event and remember value
|
// update local SEQUENCE to new value after successful upload
|
||||||
val updatedSequence = SequenceUpdater().increaseSequence(localEvent.main)
|
local.updateSequence(local.getCachedEvent().sequence)
|
||||||
|
|
||||||
// map Android event to iCalendar (also generates UID, if necessary)
|
|
||||||
val handler = AndroidEventHandler(
|
|
||||||
accountName = resource.recurringCalendar.calendar.account.name,
|
|
||||||
prodIdGenerator = DefaultProdIdGenerator(Constants.iCalProdId)
|
|
||||||
)
|
|
||||||
val mappedEvents = handler.mapToVEvents(localEvent)
|
|
||||||
|
|
||||||
// persist UID if it was generated
|
|
||||||
if (mappedEvents.generatedUid)
|
|
||||||
resource.updateUid(mappedEvents.uid)
|
|
||||||
|
|
||||||
// generate iCalendar and convert to request body
|
|
||||||
val iCalWriter = StringWriter()
|
|
||||||
ICalendarGenerator().write(mappedEvents.associatedEvents, iCalWriter)
|
|
||||||
val requestBody = iCalWriter.toString().toRequestBody(DavCalendar.MIME_ICALENDAR_UTF8)
|
|
||||||
|
|
||||||
return GeneratedResource(
|
|
||||||
suggestedFileName = DavUtils.fileNameFromUid(mappedEvents.uid, "ics"),
|
|
||||||
requestBody = requestBody,
|
|
||||||
onSuccessContext = GeneratedResource.OnSuccessContext(
|
|
||||||
sequence = updatedSequence
|
|
||||||
)
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override fun generateUpload(resource: LocalEvent): RequestBody =
|
||||||
|
SyncException.wrapWithLocalResource(resource) {
|
||||||
|
val event = resource.eventToUpload()
|
||||||
|
logger.log(Level.FINE, "Preparing upload of event ${resource.fileName}", event)
|
||||||
|
|
||||||
|
// write iCalendar to string and convert to request body
|
||||||
|
val iCalWriter = StringWriter()
|
||||||
|
EventWriter(Constants.iCalProdId).write(event, iCalWriter)
|
||||||
|
iCalWriter.toString().toRequestBody(DavCalendar.MIME_ICALENDAR_UTF8)
|
||||||
|
}
|
||||||
|
|
||||||
override suspend fun listAllRemote(callback: MultiResponseCallback) {
|
override suspend fun listAllRemote(callback: MultiResponseCallback) {
|
||||||
// calculate time range limits
|
// calculate time range limits
|
||||||
val limitStart = accountSettings.getTimeRangePastDays()?.let { pastDays ->
|
val limitStart = accountSettings.getTimeRangePastDays()?.let { pastDays ->
|
||||||
@@ -267,11 +244,11 @@ class CalendarSyncManager @AssistedInject constructor(
|
|||||||
?: throw DavException("Received multi-get response without ETag")
|
?: throw DavException("Received multi-get response without ETag")
|
||||||
val scheduleTag = response[ScheduleTag::class.java]?.scheduleTag
|
val scheduleTag = response[ScheduleTag::class.java]?.scheduleTag
|
||||||
|
|
||||||
processICalendar(
|
processVEvent(
|
||||||
fileName = response.href.lastSegment,
|
response.href.lastSegment,
|
||||||
eTag = eTag,
|
eTag,
|
||||||
scheduleTag = scheduleTag,
|
scheduleTag,
|
||||||
reader = StringReader(iCal)
|
StringReader(iCal)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -284,50 +261,56 @@ class CalendarSyncManager @AssistedInject constructor(
|
|||||||
|
|
||||||
// helpers
|
// helpers
|
||||||
|
|
||||||
private fun processICalendar(fileName: String, eTag: String, scheduleTag: String?, reader: Reader) {
|
private fun processVEvent(fileName: String, eTag: String, scheduleTag: String?, reader: Reader) {
|
||||||
val calendar =
|
val events: List<Event>
|
||||||
try {
|
try {
|
||||||
ICalendarParser().parse(reader)
|
events = EventReader().readEvents(reader)
|
||||||
} catch (e: InvalidICalendarException) {
|
} catch (e: InvalidRemoteResourceException) {
|
||||||
logger.log(Level.WARNING, "Received invalid iCalendar, ignoring", e)
|
logger.log(Level.SEVERE, "Received invalid iCalendar, ignoring", e)
|
||||||
notifyInvalidResource(e, fileName)
|
notifyInvalidResource(e, fileName)
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
val uidsAndEvents = CalendarUidSplitter<VEvent>().associateByUid(calendar, Component.VEVENT)
|
|
||||||
if (uidsAndEvents.size != 1) {
|
|
||||||
logger.warning("Received iCalendar with not exactly one UID; ignoring $fileName")
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
// Event: main VEVENT and potentially attached exceptions (further VEVENTs with RECURRENCE-ID)
|
|
||||||
val event = uidsAndEvents.values.first()
|
|
||||||
|
|
||||||
// map AssociatedEvents (VEVENTs) to EventAndExceptions (Android events)
|
if (events.size == 1) {
|
||||||
val androidEvent = AndroidEventBuilder(
|
val event = events.first()
|
||||||
calendar = localCollection.androidCalendar,
|
|
||||||
syncId = fileName,
|
|
||||||
eTag = eTag,
|
|
||||||
scheduleTag = scheduleTag,
|
|
||||||
flags = LocalResource.FLAG_REMOTELY_PRESENT
|
|
||||||
).build(event)
|
|
||||||
|
|
||||||
// add default reminder (if desired)
|
// set default reminder for non-full-day events, if requested
|
||||||
accountSettings.getDefaultAlarm()?.let { minBefore ->
|
val defaultAlarmMinBefore = accountSettings.getDefaultAlarm()
|
||||||
logger.log(Level.INFO, "Adding default alarm ($minBefore min before)", event)
|
if (defaultAlarmMinBefore != null && DateUtils.isDateTime(event.dtStart) && event.alarms.isEmpty()) {
|
||||||
DefaultReminderBuilder(minBefore = minBefore).add(to = androidEvent)
|
val alarm = VAlarm(Duration.ofMinutes(-defaultAlarmMinBefore.toLong())).apply {
|
||||||
}
|
// Sets METHOD_ALERT instead of METHOD_DEFAULT in the calendar provider.
|
||||||
|
// Needed for calendars to actually show a notification.
|
||||||
// create/update local event in calendar provider
|
properties += Action.DISPLAY
|
||||||
val local = localCollection.findByName(fileName)
|
}
|
||||||
if (local != null) {
|
logger.log(Level.FINE, "${event.uid}: Adding default alarm", alarm)
|
||||||
SyncException.wrapWithLocalResource(local) {
|
event.alarms += alarm
|
||||||
logger.log(Level.INFO, "Updating $fileName in local calendar", event)
|
|
||||||
local.update(androidEvent)
|
|
||||||
}
|
}
|
||||||
} else {
|
|
||||||
logger.log(Level.INFO, "Adding $fileName to local calendar", event)
|
// update local event, if it exists
|
||||||
localCollection.add(androidEvent)
|
val local = localCollection.findByName(fileName)
|
||||||
}
|
SyncException.wrapWithLocalResource(local) {
|
||||||
|
if (local != null) {
|
||||||
|
logger.log(Level.INFO, "Updating $fileName in local calendar", event)
|
||||||
|
local.update(
|
||||||
|
data = event,
|
||||||
|
fileName = fileName,
|
||||||
|
eTag = eTag,
|
||||||
|
scheduleTag = scheduleTag,
|
||||||
|
flags = LocalResource.FLAG_REMOTELY_PRESENT
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
logger.log(Level.INFO, "Adding $fileName to local calendar", event)
|
||||||
|
localCollection.add(
|
||||||
|
event = event,
|
||||||
|
fileName = fileName,
|
||||||
|
eTag = eTag,
|
||||||
|
scheduleTag = scheduleTag,
|
||||||
|
flags = LocalResource.FLAG_REMOTELY_PRESENT
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else
|
||||||
|
logger.info("Received VCALENDAR with not exactly one VEVENT with UID and without RECURRENCE-ID; ignoring $fileName")
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun notifyInvalidResourceTitle(): String =
|
override fun notifyInvalidResourceTitle(): String =
|
||||||
|
|||||||
@@ -60,7 +60,7 @@ class CalendarSyncer @AssistedInject constructor(
|
|||||||
|
|
||||||
val syncManager = calendarSyncManagerFactory.calendarSyncManager(
|
val syncManager = calendarSyncManagerFactory.calendarSyncManager(
|
||||||
account,
|
account,
|
||||||
httpClient,
|
httpClient.value,
|
||||||
syncResult,
|
syncResult,
|
||||||
localCollection,
|
localCollection,
|
||||||
remoteCollection,
|
remoteCollection,
|
||||||
|
|||||||
@@ -7,23 +7,24 @@ package at.bitfire.davdroid.sync
|
|||||||
import android.accounts.Account
|
import android.accounts.Account
|
||||||
import android.content.ContentProviderClient
|
import android.content.ContentProviderClient
|
||||||
import android.text.format.Formatter
|
import android.text.format.Formatter
|
||||||
import at.bitfire.dav4jvm.okhttp.DavAddressBook
|
import at.bitfire.dav4jvm.DavAddressBook
|
||||||
import at.bitfire.dav4jvm.okhttp.MultiResponseCallback
|
import at.bitfire.dav4jvm.MultiResponseCallback
|
||||||
import at.bitfire.dav4jvm.okhttp.Response
|
import at.bitfire.dav4jvm.Response
|
||||||
import at.bitfire.dav4jvm.okhttp.exception.DavException
|
import at.bitfire.dav4jvm.exception.DavException
|
||||||
import at.bitfire.dav4jvm.property.caldav.CalDAV
|
import at.bitfire.dav4jvm.property.caldav.GetCTag
|
||||||
import at.bitfire.dav4jvm.property.carddav.AddressData
|
import at.bitfire.dav4jvm.property.carddav.AddressData
|
||||||
import at.bitfire.dav4jvm.property.carddav.CardDAV
|
|
||||||
import at.bitfire.dav4jvm.property.carddav.MaxResourceSize
|
import at.bitfire.dav4jvm.property.carddav.MaxResourceSize
|
||||||
import at.bitfire.dav4jvm.property.carddav.SupportedAddressData
|
import at.bitfire.dav4jvm.property.carddav.SupportedAddressData
|
||||||
import at.bitfire.dav4jvm.property.webdav.GetContentType
|
import at.bitfire.dav4jvm.property.webdav.GetContentType
|
||||||
import at.bitfire.dav4jvm.property.webdav.GetETag
|
import at.bitfire.dav4jvm.property.webdav.GetETag
|
||||||
|
import at.bitfire.dav4jvm.property.webdav.ResourceType
|
||||||
import at.bitfire.dav4jvm.property.webdav.SupportedReportSet
|
import at.bitfire.dav4jvm.property.webdav.SupportedReportSet
|
||||||
import at.bitfire.dav4jvm.property.webdav.WebDAV
|
import at.bitfire.dav4jvm.property.webdav.SyncToken
|
||||||
import at.bitfire.davdroid.Constants
|
import at.bitfire.davdroid.Constants
|
||||||
import at.bitfire.davdroid.R
|
import at.bitfire.davdroid.R
|
||||||
import at.bitfire.davdroid.db.Collection
|
import at.bitfire.davdroid.db.Collection
|
||||||
import at.bitfire.davdroid.di.SyncDispatcher
|
import at.bitfire.davdroid.di.SyncDispatcher
|
||||||
|
import at.bitfire.davdroid.network.HttpClient
|
||||||
import at.bitfire.davdroid.resource.LocalAddress
|
import at.bitfire.davdroid.resource.LocalAddress
|
||||||
import at.bitfire.davdroid.resource.LocalAddressBook
|
import at.bitfire.davdroid.resource.LocalAddressBook
|
||||||
import at.bitfire.davdroid.resource.LocalContact
|
import at.bitfire.davdroid.resource.LocalContact
|
||||||
@@ -45,14 +46,15 @@ import dagger.assisted.AssistedInject
|
|||||||
import ezvcard.VCardVersion
|
import ezvcard.VCardVersion
|
||||||
import ezvcard.io.CannotParseException
|
import ezvcard.io.CannotParseException
|
||||||
import kotlinx.coroutines.CoroutineDispatcher
|
import kotlinx.coroutines.CoroutineDispatcher
|
||||||
import kotlinx.coroutines.runBlocking
|
|
||||||
import kotlinx.coroutines.runInterruptible
|
import kotlinx.coroutines.runInterruptible
|
||||||
import okhttp3.HttpUrl
|
import okhttp3.HttpUrl
|
||||||
|
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
|
||||||
import okhttp3.MediaType
|
import okhttp3.MediaType
|
||||||
import okhttp3.MediaType.Companion.toMediaTypeOrNull
|
import okhttp3.Request
|
||||||
import okhttp3.OkHttpClient
|
import okhttp3.RequestBody
|
||||||
import okhttp3.RequestBody.Companion.toRequestBody
|
import okhttp3.RequestBody.Companion.toRequestBody
|
||||||
import java.io.ByteArrayOutputStream
|
import java.io.ByteArrayOutputStream
|
||||||
|
import java.io.IOException
|
||||||
import java.io.Reader
|
import java.io.Reader
|
||||||
import java.io.StringReader
|
import java.io.StringReader
|
||||||
import java.util.Optional
|
import java.util.Optional
|
||||||
@@ -98,7 +100,7 @@ import kotlin.jvm.optionals.getOrNull
|
|||||||
*/
|
*/
|
||||||
class ContactsSyncManager @AssistedInject constructor(
|
class ContactsSyncManager @AssistedInject constructor(
|
||||||
@Assisted account: Account,
|
@Assisted account: Account,
|
||||||
@Assisted httpClient: OkHttpClient,
|
@Assisted httpClient: HttpClient,
|
||||||
@Assisted syncResult: SyncResult,
|
@Assisted syncResult: SyncResult,
|
||||||
@Assisted val provider: ContentProviderClient,
|
@Assisted val provider: ContentProviderClient,
|
||||||
@Assisted localAddressBook: LocalAddressBook,
|
@Assisted localAddressBook: LocalAddressBook,
|
||||||
@@ -107,7 +109,7 @@ class ContactsSyncManager @AssistedInject constructor(
|
|||||||
@Assisted val syncFrameworkUpload: Boolean,
|
@Assisted val syncFrameworkUpload: Boolean,
|
||||||
val dirtyVerifier: Optional<ContactDirtyVerifier>,
|
val dirtyVerifier: Optional<ContactDirtyVerifier>,
|
||||||
accountSettingsFactory: AccountSettings.Factory,
|
accountSettingsFactory: AccountSettings.Factory,
|
||||||
private val resourceRetrieverFactory: ResourceRetriever.Factory,
|
private val httpClientBuilder: HttpClient.Builder,
|
||||||
@SyncDispatcher syncDispatcher: CoroutineDispatcher
|
@SyncDispatcher syncDispatcher: CoroutineDispatcher
|
||||||
): SyncManager<LocalAddress, LocalAddressBook, DavAddressBook>(
|
): SyncManager<LocalAddress, LocalAddressBook, DavAddressBook>(
|
||||||
account,
|
account,
|
||||||
@@ -124,7 +126,7 @@ class ContactsSyncManager @AssistedInject constructor(
|
|||||||
interface Factory {
|
interface Factory {
|
||||||
fun contactsSyncManager(
|
fun contactsSyncManager(
|
||||||
account: Account,
|
account: Account,
|
||||||
httpClient: OkHttpClient,
|
httpClient: HttpClient,
|
||||||
syncResult: SyncResult,
|
syncResult: SyncResult,
|
||||||
provider: ContentProviderClient,
|
provider: ContentProviderClient,
|
||||||
localAddressBook: LocalAddressBook,
|
localAddressBook: LocalAddressBook,
|
||||||
@@ -147,6 +149,11 @@ class ContactsSyncManager @AssistedInject constructor(
|
|||||||
GroupMethod.CATEGORIES -> CategoriesStrategy(localAddressBook)
|
GroupMethod.CATEGORIES -> CategoriesStrategy(localAddressBook)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Used to download images which are referenced by URL
|
||||||
|
*/
|
||||||
|
private lateinit var resourceDownloader: ResourceDownloader
|
||||||
|
|
||||||
|
|
||||||
override fun prepare(): Boolean {
|
override fun prepare(): Boolean {
|
||||||
if (dirtyVerifier.isPresent) {
|
if (dirtyVerifier.isPresent) {
|
||||||
@@ -155,7 +162,8 @@ class ContactsSyncManager @AssistedInject constructor(
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
davCollection = DavAddressBook(httpClient, collection.url)
|
davCollection = DavAddressBook(httpClient.okHttpClient, collection.url)
|
||||||
|
resourceDownloader = ResourceDownloader(davCollection.location)
|
||||||
|
|
||||||
logger.info("Contact group strategy: ${groupStrategy::class.java.simpleName}")
|
logger.info("Contact group strategy: ${groupStrategy::class.java.simpleName}")
|
||||||
return true
|
return true
|
||||||
@@ -165,14 +173,7 @@ class ContactsSyncManager @AssistedInject constructor(
|
|||||||
return SyncException.wrapWithRemoteResourceSuspending(collection.url) {
|
return SyncException.wrapWithRemoteResourceSuspending(collection.url) {
|
||||||
var syncState: SyncState? = null
|
var syncState: SyncState? = null
|
||||||
runInterruptible {
|
runInterruptible {
|
||||||
davCollection.propfind(
|
davCollection.propfind(0, MaxResourceSize.NAME, SupportedAddressData.NAME, SupportedReportSet.NAME, GetCTag.NAME, SyncToken.NAME) { response, relation ->
|
||||||
0,
|
|
||||||
CardDAV.MaxResourceSize,
|
|
||||||
CardDAV.SupportedAddressData,
|
|
||||||
WebDAV.SupportedReportSet,
|
|
||||||
CalDAV.GetCTag,
|
|
||||||
WebDAV.SyncToken
|
|
||||||
) { response, relation ->
|
|
||||||
if (relation == Response.HrefRelation.SELF) {
|
if (relation == Response.HrefRelation.SELF) {
|
||||||
response[MaxResourceSize::class.java]?.maxSize?.let { maxSize ->
|
response[MaxResourceSize::class.java]?.maxSize?.let { maxSize ->
|
||||||
logger.info("Address book accepts vCards up to ${Formatter.formatFileSize(context, maxSize)}")
|
logger.info("Address book accepts vCards up to ${Formatter.formatFileSize(context, maxSize)}")
|
||||||
@@ -185,7 +186,7 @@ class ContactsSyncManager @AssistedInject constructor(
|
|||||||
// hasJCard = supported.hasJCard()
|
// hasJCard = supported.hasJCard()
|
||||||
}
|
}
|
||||||
response[SupportedReportSet::class.java]?.let { supported ->
|
response[SupportedReportSet::class.java]?.let { supported ->
|
||||||
hasCollectionSync = supported.reports.contains(WebDAV.SyncCollection)
|
hasCollectionSync = supported.reports.contains(SupportedReportSet.SYNC_COLLECTION)
|
||||||
}
|
}
|
||||||
syncState = syncState(response)
|
syncState = syncState(response)
|
||||||
}
|
}
|
||||||
@@ -271,50 +272,40 @@ class ContactsSyncManager @AssistedInject constructor(
|
|||||||
return modified or superModified
|
return modified or superModified
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun generateUpload(resource: LocalAddress): GeneratedResource {
|
override fun generateUpload(resource: LocalAddress): RequestBody =
|
||||||
val contact: Contact = when (resource) {
|
SyncException.wrapWithLocalResource(resource) {
|
||||||
is LocalContact -> resource.getContact()
|
val contact: Contact = when (resource) {
|
||||||
is LocalGroup -> resource.getContact()
|
is LocalContact -> resource.getContact()
|
||||||
else -> throw IllegalArgumentException("resource must be LocalContact or LocalGroup")
|
is LocalGroup -> resource.getContact()
|
||||||
}
|
else -> throw IllegalArgumentException("resource must be LocalContact or LocalGroup")
|
||||||
logger.log(Level.FINE, "Preparing upload of vCard #${resource.id}", contact)
|
|
||||||
|
|
||||||
// get/create UID
|
|
||||||
val (uid, uidIsGenerated) = DavUtils.generateUidIfNecessary(contact.uid)
|
|
||||||
if (uidIsGenerated) {
|
|
||||||
// modify in Contact and persist to contacts provider
|
|
||||||
contact.uid = uid
|
|
||||||
resource.updateUid(uid)
|
|
||||||
}
|
|
||||||
|
|
||||||
// generate vCard and convert to request body
|
|
||||||
val os = ByteArrayOutputStream()
|
|
||||||
val mimeType: MediaType
|
|
||||||
when {
|
|
||||||
hasJCard -> {
|
|
||||||
mimeType = DavAddressBook.MIME_JCARD
|
|
||||||
contact.writeJCard(os, Constants.vCardProdId)
|
|
||||||
}
|
}
|
||||||
hasVCard4 -> {
|
|
||||||
mimeType = DavAddressBook.MIME_VCARD4
|
|
||||||
contact.writeVCard(VCardVersion.V4_0, os, Constants.vCardProdId)
|
|
||||||
}
|
|
||||||
else -> {
|
|
||||||
mimeType = DavAddressBook.MIME_VCARD3_UTF8
|
|
||||||
contact.writeVCard(VCardVersion.V3_0, os, Constants.vCardProdId)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return GeneratedResource(
|
logger.log(Level.FINE, "Preparing upload of vCard ${resource.fileName}", contact)
|
||||||
suggestedFileName = DavUtils.fileNameFromUid(uid, "vcf"),
|
|
||||||
requestBody = os.toByteArray().toRequestBody(mimeType)
|
val os = ByteArrayOutputStream()
|
||||||
)
|
val mimeType: MediaType
|
||||||
}
|
when {
|
||||||
|
hasJCard -> {
|
||||||
|
mimeType = DavAddressBook.MIME_JCARD
|
||||||
|
contact.writeJCard(os, Constants.vCardProdId)
|
||||||
|
}
|
||||||
|
hasVCard4 -> {
|
||||||
|
mimeType = DavAddressBook.MIME_VCARD4
|
||||||
|
contact.writeVCard(VCardVersion.V4_0, os, Constants.vCardProdId)
|
||||||
|
}
|
||||||
|
else -> {
|
||||||
|
mimeType = DavAddressBook.MIME_VCARD3_UTF8
|
||||||
|
contact.writeVCard(VCardVersion.V3_0, os, Constants.vCardProdId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return@wrapWithLocalResource os.toByteArray().toRequestBody(mimeType)
|
||||||
|
}
|
||||||
|
|
||||||
override suspend fun listAllRemote(callback: MultiResponseCallback) =
|
override suspend fun listAllRemote(callback: MultiResponseCallback) =
|
||||||
SyncException.wrapWithRemoteResourceSuspending(collection.url) {
|
SyncException.wrapWithRemoteResourceSuspending(collection.url) {
|
||||||
runInterruptible {
|
runInterruptible {
|
||||||
davCollection.propfind(1, WebDAV.ResourceType, WebDAV.GetETag, callback = callback)
|
davCollection.propfind(1, ResourceType.NAME, GetETag.NAME, callback = callback)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -356,24 +347,16 @@ class ContactsSyncManager @AssistedInject constructor(
|
|||||||
?: throw DavException("Received multi-get response without ETag")
|
?: throw DavException("Received multi-get response without ETag")
|
||||||
|
|
||||||
var isJCard = hasJCard // assume that server has sent what we have requested (we ask for jCard only when the server advertises it)
|
var isJCard = hasJCard // assume that server has sent what we have requested (we ask for jCard only when the server advertises it)
|
||||||
response[GetContentType::class.java]?.type?.toMediaTypeOrNull()?.let { type ->
|
response[GetContentType::class.java]?.type?.let { type ->
|
||||||
isJCard = type.sameTypeAs(DavUtils.MEDIA_TYPE_JCARD)
|
isJCard = type.sameTypeAs(DavUtils.MEDIA_TYPE_JCARD)
|
||||||
}
|
}
|
||||||
|
|
||||||
processCard(
|
processCard(
|
||||||
fileName = response.href.lastSegment,
|
response.href.lastSegment,
|
||||||
eTag = eTag,
|
eTag,
|
||||||
reader = StringReader(card),
|
StringReader(card),
|
||||||
jCard = isJCard,
|
isJCard,
|
||||||
downloader = object : Contact.Downloader {
|
resourceDownloader
|
||||||
override fun download(url: String, accepts: String): ByteArray? {
|
|
||||||
// retrieve external resource (like a photo) from an URL (not necessarily HTTP[S])
|
|
||||||
return runBlocking(syncDispatcher) {
|
|
||||||
val retriever = resourceRetrieverFactory.create(account, davCollection.location.host)
|
|
||||||
retriever.retrieve(url)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -479,6 +462,44 @@ class ContactsSyncManager @AssistedInject constructor(
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// downloader helper class
|
||||||
|
|
||||||
|
private inner class ResourceDownloader(
|
||||||
|
val baseUrl: HttpUrl
|
||||||
|
): Contact.Downloader {
|
||||||
|
|
||||||
|
override fun download(url: String, accepts: String): ByteArray? {
|
||||||
|
val httpUrl = url.toHttpUrlOrNull()
|
||||||
|
if (httpUrl == null) {
|
||||||
|
logger.log(Level.SEVERE, "Invalid external resource URL", url)
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
// authenticate only against a certain host, and only upon request
|
||||||
|
httpClientBuilder
|
||||||
|
.fromAccount(account, onlyHost = baseUrl.host)
|
||||||
|
.followRedirects(true) // allow redirects
|
||||||
|
.build()
|
||||||
|
.use { httpClient ->
|
||||||
|
try {
|
||||||
|
val response = httpClient.okHttpClient.newCall(Request.Builder()
|
||||||
|
.get()
|
||||||
|
.url(httpUrl)
|
||||||
|
.build()).execute()
|
||||||
|
|
||||||
|
if (response.isSuccessful)
|
||||||
|
return response.body.bytes()
|
||||||
|
else
|
||||||
|
logger.warning("Couldn't download external resource")
|
||||||
|
} catch(e: IOException) {
|
||||||
|
logger.log(Level.SEVERE, "Couldn't download external resource", e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
override fun notifyInvalidResourceTitle(): String =
|
override fun notifyInvalidResourceTitle(): String =
|
||||||
context.getString(R.string.sync_invalid_contact)
|
context.getString(R.string.sync_invalid_contact)
|
||||||
|
|
||||||
|
|||||||
@@ -1,60 +0,0 @@
|
|||||||
/*
|
|
||||||
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
|
|
||||||
*/
|
|
||||||
|
|
||||||
package at.bitfire.davdroid.sync
|
|
||||||
|
|
||||||
import android.content.Entity
|
|
||||||
import android.provider.CalendarContract.Events
|
|
||||||
import android.provider.CalendarContract.Reminders
|
|
||||||
import androidx.annotation.VisibleForTesting
|
|
||||||
import androidx.core.content.contentValuesOf
|
|
||||||
import at.bitfire.synctools.storage.calendar.EventAndExceptions
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Builder for default reminders / alarms that can be added to events
|
|
||||||
* if this is enabled in app settings.
|
|
||||||
*
|
|
||||||
* @param minBefore how many minutes before the entry the alarm should be added (usually taken from app settings)
|
|
||||||
*/
|
|
||||||
class DefaultReminderBuilder(
|
|
||||||
private val minBefore: Int
|
|
||||||
) {
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Adds a default alarm ([minBefore] minutes before) to
|
|
||||||
*
|
|
||||||
* - the main event and
|
|
||||||
* - each exception event,
|
|
||||||
*
|
|
||||||
* except for those events which
|
|
||||||
*
|
|
||||||
* - are all-day, or
|
|
||||||
* - already have another reminder.
|
|
||||||
*/
|
|
||||||
fun add(to: EventAndExceptions) {
|
|
||||||
// add default reminder to main event and exceptions
|
|
||||||
val events = mutableListOf(to.main)
|
|
||||||
events += to.exceptions
|
|
||||||
|
|
||||||
for (event in events)
|
|
||||||
addToEvent(to = event)
|
|
||||||
}
|
|
||||||
|
|
||||||
@VisibleForTesting
|
|
||||||
internal fun addToEvent(to: Entity) {
|
|
||||||
// don't add default reminder if there's already another reminder
|
|
||||||
if (to.subValues.any { it.uri == Reminders.CONTENT_URI })
|
|
||||||
return
|
|
||||||
|
|
||||||
// don't add default reminder to all-day events
|
|
||||||
if (to.entityValues.getAsInteger(Events.ALL_DAY) == 1)
|
|
||||||
return
|
|
||||||
|
|
||||||
to.addSubValue(Reminders.CONTENT_URI, contentValuesOf(
|
|
||||||
Reminders.MINUTES to minBefore,
|
|
||||||
Reminders.METHOD to Reminders.METHOD_ALERT // will trigger an alarm on the Android device
|
|
||||||
))
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
@@ -1,32 +0,0 @@
|
|||||||
/*
|
|
||||||
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
|
|
||||||
*/
|
|
||||||
|
|
||||||
package at.bitfire.davdroid.sync
|
|
||||||
|
|
||||||
import okhttp3.RequestBody
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Represents a resource that has been generated for the purpose of being uploaded.
|
|
||||||
*
|
|
||||||
* @param suggestedFileName file name that can be used for uploading if there's no existing name
|
|
||||||
* @param requestBody resource body (including MIME type)
|
|
||||||
* @param onSuccessContext context that must be passed to [SyncManager.onSuccessfulUpload]
|
|
||||||
* on successful upload in order to persist the changes made during mapping
|
|
||||||
*/
|
|
||||||
class GeneratedResource(
|
|
||||||
val suggestedFileName: String,
|
|
||||||
val requestBody: RequestBody,
|
|
||||||
val onSuccessContext: OnSuccessContext? = null
|
|
||||||
) {
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Contains information that has been created for a [GeneratedResource], but has not been saved yet.
|
|
||||||
*
|
|
||||||
* @param sequence new SEQUENCE to persist on successful upload (*null*: SEQUENCE not modified)
|
|
||||||
*/
|
|
||||||
data class OnSuccessContext(
|
|
||||||
val sequence: Int? = null
|
|
||||||
)
|
|
||||||
|
|
||||||
}
|
|
||||||
@@ -7,35 +7,34 @@ package at.bitfire.davdroid.sync
|
|||||||
import android.accounts.Account
|
import android.accounts.Account
|
||||||
import android.text.format.Formatter
|
import android.text.format.Formatter
|
||||||
import androidx.annotation.OpenForTesting
|
import androidx.annotation.OpenForTesting
|
||||||
import at.bitfire.dav4jvm.okhttp.DavCalendar
|
import at.bitfire.dav4jvm.DavCalendar
|
||||||
import at.bitfire.dav4jvm.okhttp.MultiResponseCallback
|
import at.bitfire.dav4jvm.MultiResponseCallback
|
||||||
import at.bitfire.dav4jvm.okhttp.Response
|
import at.bitfire.dav4jvm.Response
|
||||||
import at.bitfire.dav4jvm.okhttp.exception.DavException
|
import at.bitfire.dav4jvm.exception.DavException
|
||||||
import at.bitfire.dav4jvm.property.caldav.CalDAV
|
|
||||||
import at.bitfire.dav4jvm.property.caldav.CalendarData
|
import at.bitfire.dav4jvm.property.caldav.CalendarData
|
||||||
|
import at.bitfire.dav4jvm.property.caldav.GetCTag
|
||||||
import at.bitfire.dav4jvm.property.caldav.MaxResourceSize
|
import at.bitfire.dav4jvm.property.caldav.MaxResourceSize
|
||||||
import at.bitfire.dav4jvm.property.webdav.GetETag
|
import at.bitfire.dav4jvm.property.webdav.GetETag
|
||||||
import at.bitfire.dav4jvm.property.webdav.WebDAV
|
import at.bitfire.dav4jvm.property.webdav.SyncToken
|
||||||
import at.bitfire.davdroid.Constants
|
import at.bitfire.davdroid.Constants
|
||||||
import at.bitfire.davdroid.R
|
import at.bitfire.davdroid.R
|
||||||
import at.bitfire.davdroid.db.Collection
|
import at.bitfire.davdroid.db.Collection
|
||||||
import at.bitfire.davdroid.di.SyncDispatcher
|
import at.bitfire.davdroid.di.SyncDispatcher
|
||||||
|
import at.bitfire.davdroid.network.HttpClient
|
||||||
import at.bitfire.davdroid.resource.LocalJtxCollection
|
import at.bitfire.davdroid.resource.LocalJtxCollection
|
||||||
import at.bitfire.davdroid.resource.LocalJtxICalObject
|
import at.bitfire.davdroid.resource.LocalJtxICalObject
|
||||||
import at.bitfire.davdroid.resource.LocalResource
|
import at.bitfire.davdroid.resource.LocalResource
|
||||||
import at.bitfire.davdroid.resource.SyncState
|
import at.bitfire.davdroid.resource.SyncState
|
||||||
import at.bitfire.davdroid.util.DavUtils
|
|
||||||
import at.bitfire.davdroid.util.DavUtils.lastSegment
|
import at.bitfire.davdroid.util.DavUtils.lastSegment
|
||||||
import at.bitfire.ical4android.JtxICalObject
|
import at.bitfire.ical4android.JtxICalObject
|
||||||
import at.bitfire.synctools.exception.InvalidICalendarException
|
import at.bitfire.synctools.exception.InvalidRemoteResourceException
|
||||||
import dagger.assisted.Assisted
|
import dagger.assisted.Assisted
|
||||||
import dagger.assisted.AssistedFactory
|
import dagger.assisted.AssistedFactory
|
||||||
import dagger.assisted.AssistedInject
|
import dagger.assisted.AssistedInject
|
||||||
import kotlinx.coroutines.CoroutineDispatcher
|
import kotlinx.coroutines.CoroutineDispatcher
|
||||||
import kotlinx.coroutines.runInterruptible
|
import kotlinx.coroutines.runInterruptible
|
||||||
import net.fortuna.ical4j.model.property.ProdId
|
|
||||||
import okhttp3.HttpUrl
|
import okhttp3.HttpUrl
|
||||||
import okhttp3.OkHttpClient
|
import okhttp3.RequestBody
|
||||||
import okhttp3.RequestBody.Companion.toRequestBody
|
import okhttp3.RequestBody.Companion.toRequestBody
|
||||||
import java.io.ByteArrayOutputStream
|
import java.io.ByteArrayOutputStream
|
||||||
import java.io.Reader
|
import java.io.Reader
|
||||||
@@ -44,7 +43,7 @@ import java.util.logging.Level
|
|||||||
|
|
||||||
class JtxSyncManager @AssistedInject constructor(
|
class JtxSyncManager @AssistedInject constructor(
|
||||||
@Assisted account: Account,
|
@Assisted account: Account,
|
||||||
@Assisted httpClient: OkHttpClient,
|
@Assisted httpClient: HttpClient,
|
||||||
@Assisted syncResult: SyncResult,
|
@Assisted syncResult: SyncResult,
|
||||||
@Assisted localCollection: LocalJtxCollection,
|
@Assisted localCollection: LocalJtxCollection,
|
||||||
@Assisted collection: Collection,
|
@Assisted collection: Collection,
|
||||||
@@ -65,7 +64,7 @@ class JtxSyncManager @AssistedInject constructor(
|
|||||||
interface Factory {
|
interface Factory {
|
||||||
fun jtxSyncManager(
|
fun jtxSyncManager(
|
||||||
account: Account,
|
account: Account,
|
||||||
httpClient: OkHttpClient,
|
httpClient: HttpClient,
|
||||||
syncResult: SyncResult,
|
syncResult: SyncResult,
|
||||||
localCollection: LocalJtxCollection,
|
localCollection: LocalJtxCollection,
|
||||||
collection: Collection,
|
collection: Collection,
|
||||||
@@ -75,7 +74,7 @@ class JtxSyncManager @AssistedInject constructor(
|
|||||||
|
|
||||||
|
|
||||||
override fun prepare(): Boolean {
|
override fun prepare(): Boolean {
|
||||||
davCollection = DavCalendar(httpClient, collection.url)
|
davCollection = DavCalendar(httpClient.okHttpClient, collection.url)
|
||||||
|
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
@@ -84,7 +83,7 @@ class JtxSyncManager @AssistedInject constructor(
|
|||||||
SyncException.wrapWithRemoteResourceSuspending(collection.url) {
|
SyncException.wrapWithRemoteResourceSuspending(collection.url) {
|
||||||
var syncState: SyncState? = null
|
var syncState: SyncState? = null
|
||||||
runInterruptible {
|
runInterruptible {
|
||||||
davCollection.propfind(0, CalDAV.GetCTag, CalDAV.MaxResourceSize, WebDAV.SyncToken) { response, relation ->
|
davCollection.propfind(0, GetCTag.NAME, MaxResourceSize.NAME, SyncToken.NAME) { response, relation ->
|
||||||
if (relation == Response.HrefRelation.SELF) {
|
if (relation == Response.HrefRelation.SELF) {
|
||||||
response[MaxResourceSize::class.java]?.maxSize?.let { maxSize ->
|
response[MaxResourceSize::class.java]?.maxSize?.let { maxSize ->
|
||||||
logger.info("Collection accepts resources up to ${Formatter.formatFileSize(context, maxSize)}")
|
logger.info("Collection accepts resources up to ${Formatter.formatFileSize(context, maxSize)}")
|
||||||
@@ -97,17 +96,13 @@ class JtxSyncManager @AssistedInject constructor(
|
|||||||
syncState
|
syncState
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun generateUpload(resource: LocalJtxICalObject): GeneratedResource {
|
override fun generateUpload(resource: LocalJtxICalObject): RequestBody =
|
||||||
logger.log(Level.FINE, "Preparing upload of icalobject #${resource.id}")
|
SyncException.wrapWithLocalResource(resource) {
|
||||||
|
logger.log(Level.FINE, "Preparing upload of icalobject ${resource.fileName}", resource)
|
||||||
val os = ByteArrayOutputStream()
|
val os = ByteArrayOutputStream()
|
||||||
resource.write(os, ProdId(Constants.iCalProdId))
|
resource.write(os, Constants.iCalProdId)
|
||||||
|
os.toByteArray().toRequestBody(DavCalendar.MIME_ICALENDAR_UTF8)
|
||||||
return GeneratedResource(
|
}
|
||||||
suggestedFileName = DavUtils.fileNameFromUid(resource.uid, "ics"),
|
|
||||||
requestBody = os.toByteArray().toRequestBody(DavCalendar.MIME_ICALENDAR_UTF8)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun syncAlgorithm() = SyncAlgorithm.PROPFIND_REPORT
|
override fun syncAlgorithm() = SyncAlgorithm.PROPFIND_REPORT
|
||||||
|
|
||||||
@@ -172,7 +167,7 @@ class JtxSyncManager @AssistedInject constructor(
|
|||||||
try {
|
try {
|
||||||
// parse the reader content and return the list of ICalObjects
|
// parse the reader content and return the list of ICalObjects
|
||||||
icalobjects.addAll(JtxICalObject.fromReader(reader, localCollection))
|
icalobjects.addAll(JtxICalObject.fromReader(reader, localCollection))
|
||||||
} catch (e: InvalidICalendarException) {
|
} catch (e: InvalidRemoteResourceException) {
|
||||||
logger.log(Level.SEVERE, "Received invalid iCalendar, ignoring", e)
|
logger.log(Level.SEVERE, "Received invalid iCalendar, ignoring", e)
|
||||||
notifyInvalidResource(e, fileName)
|
notifyInvalidResource(e, fileName)
|
||||||
return
|
return
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user