mirror of
https://github.com/bitfireAT/davx5-ose.git
synced 2026-02-05 20:51:21 -05:00
Compare commits
2 Commits
split-core
...
v4.4.1.1-o
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
60219aafc7 | ||
|
|
209fdf3e7c |
8
.github/CODEOWNERS
vendored
8
.github/CODEOWNERS
vendored
@@ -1,8 +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.
|
||||
|
||||
# Dependabot
|
||||
gradle/** @bitfireAT/app-dev
|
||||
|
||||
# everything else
|
||||
* @rfc2822
|
||||
2
.github/ISSUE_TEMPLATE/config.yml
vendored
2
.github/ISSUE_TEMPLATE/config.yml
vendored
@@ -1,4 +1,4 @@
|
||||
blank_issues_enabled: true
|
||||
blank_issues_enabled: false
|
||||
contact_links:
|
||||
- name: DAVx⁵ Community Support
|
||||
url: https://github.com/bitfireAT/davx5-ose/discussions
|
||||
|
||||
3
.github/ISSUE_TEMPLATE/qualified-bug.yml
vendored
3
.github/ISSUE_TEMPLATE/qualified-bug.yml
vendored
@@ -1,6 +1,5 @@
|
||||
name: Qualified Bug Report
|
||||
description: "For qualified bug reports. (Use Discussions if unsure.)"
|
||||
type: bug
|
||||
description: "[Developers only] For qualified bug reports. (Use Discussions if unsure.)"
|
||||
labels: ["bug"]
|
||||
body:
|
||||
- type: checkboxes
|
||||
|
||||
3
.github/ISSUE_TEMPLATE/qualified-feature.yml
vendored
3
.github/ISSUE_TEMPLATE/qualified-feature.yml
vendored
@@ -1,6 +1,5 @@
|
||||
name: Qualified Feature Request
|
||||
description: "For qualified feature requests. (Use Discussions if unsure.)"
|
||||
type: feature
|
||||
description: "[Developers only] For qualified feature requests. (Use Discussions if unsure.)"
|
||||
labels: ["enhancement"]
|
||||
body:
|
||||
- type: checkboxes
|
||||
|
||||
32
.github/dependabot.yml
vendored
32
.github/dependabot.yml
vendored
@@ -1,32 +0,0 @@
|
||||
version: 2
|
||||
updates:
|
||||
# Enable version updates for GitHub Actions
|
||||
- package-ecosystem: "github-actions"
|
||||
# Workflow files stored in the default location of `.github/workflows`
|
||||
# You don't need to specify `/.github/workflows` for `directory`. You can use `directory: "/"`.
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
commit-message:
|
||||
prefix: "[CI] "
|
||||
labels:
|
||||
- "github_actions"
|
||||
- "dependencies"
|
||||
groups:
|
||||
ci-actions:
|
||||
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"
|
||||
2
.github/pull_request_template.md
vendored
2
.github/pull_request_template.md
vendored
@@ -1,6 +1,4 @@
|
||||
|
||||
Please delete this paragraph and other repeating text (like the examples) after reading and before submitting the PR.
|
||||
|
||||
The PR should be in _Draft_ state during development. As soon as it's finished, it should be marked as _Ready for review_ and a reviewer should be chosen.
|
||||
|
||||
See also: [Writing A Great Pull Request Description](https://www.pullrequest.com/blog/writing-a-great-pull-request-description/)
|
||||
|
||||
3
.github/release.yml
vendored
3
.github/release.yml
vendored
@@ -12,9 +12,6 @@ changelog:
|
||||
- title: Refactoring
|
||||
labels:
|
||||
- refactoring
|
||||
- title: Dependencies
|
||||
labels:
|
||||
- dependencies
|
||||
- title: Other changes
|
||||
labels:
|
||||
- "*"
|
||||
|
||||
30
.github/workflows/codeql.yml
vendored
30
.github/workflows/codeql.yml
vendored
@@ -8,7 +8,6 @@ on:
|
||||
branches: [ main-ose ]
|
||||
schedule:
|
||||
- cron: '22 10 * * 1'
|
||||
|
||||
concurrency:
|
||||
group: codeql-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
@@ -22,29 +21,38 @@ jobs:
|
||||
contents: read
|
||||
security-events: write
|
||||
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
language: [ 'java' ]
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
- uses: actions/setup-java@v5
|
||||
uses: actions/checkout@v4
|
||||
- uses: actions/setup-java@v4
|
||||
with:
|
||||
distribution: temurin
|
||||
java-version: 21
|
||||
- uses: gradle/actions/setup-gradle@v5
|
||||
java-version: 17
|
||||
- uses: gradle/actions/setup-gradle@v3
|
||||
with:
|
||||
cache-encryption-key: ${{ secrets.gradle_encryption_key }}
|
||||
cache-read-only: true # gradle user home cache is generated by test jobs
|
||||
|
||||
# Initializes the CodeQL tools for scanning.
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@v4
|
||||
uses: github/codeql-action/init@v3
|
||||
with:
|
||||
languages: java-kotlin
|
||||
build-mode: manual # autobuild uses older JDK
|
||||
languages: ${{ matrix.language }}
|
||||
|
||||
- name: Build # we must not use build cache here
|
||||
run: ./gradlew --no-daemon --configuration-cache app:assembleDebug
|
||||
# Autobuild attempts to build any compiled languages (C/C++, C#, Go, or Java).
|
||||
# If this step fails, then you should remove it and run the build manually (see below)
|
||||
#- name: Autobuild
|
||||
# uses: github/codeql-action/autobuild@v2
|
||||
|
||||
- name: Build
|
||||
run: ./gradlew --build-cache --configuration-cache --configuration-cache-problems=warn --no-daemon app:assembleOseDebug
|
||||
|
||||
- name: Perform CodeQL Analysis
|
||||
uses: github/codeql-action/analyze@v4
|
||||
uses: github/codeql-action/analyze@v3
|
||||
with:
|
||||
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 }}
|
||||
13
.github/workflows/release.yml
vendored
13
.github/workflows/release.yml
vendored
@@ -19,19 +19,19 @@ jobs:
|
||||
discussions: write
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/setup-java@v5
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-java@v4
|
||||
with:
|
||||
distribution: temurin
|
||||
java-version: 21
|
||||
- uses: gradle/actions/setup-gradle@v5
|
||||
java-version: 17
|
||||
- uses: gradle/actions/setup-gradle@v3
|
||||
|
||||
- name: Prepare keystore
|
||||
run: echo ${{ secrets.android_keystore_base64 }} | base64 -d >$GITHUB_WORKSPACE/keystore.jks
|
||||
|
||||
- name: Build signed package
|
||||
# Use build cache to speed up building of build variants, but clean caches from previous tests before
|
||||
run: ./gradlew --build-cache --configuration-cache --no-daemon app:clean app:assembleRelease
|
||||
# Make sure that caches are disabled to generate reproducible release builds
|
||||
run: ./gradlew --no-build-cache --no-configuration-cache --no-daemon app:assembleRelease
|
||||
env:
|
||||
ANDROID_KEYSTORE: ${{ github.workspace }}/keystore.jks
|
||||
ANDROID_KEYSTORE_PASSWORD: ${{ secrets.android_keystore_password }}
|
||||
@@ -45,3 +45,4 @@ jobs:
|
||||
files: app/build/outputs/apk/ose/release/*.apk
|
||||
fail_on_unmatched_files: true
|
||||
generate_release_notes: true
|
||||
discussion_category_name: Announcements
|
||||
|
||||
119
.github/workflows/test-dev.yml
vendored
119
.github/workflows/test-dev.yml
vendored
@@ -2,135 +2,80 @@ name: Development tests
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- 'main-ose'
|
||||
pull_request:
|
||||
- '*'
|
||||
|
||||
concurrency:
|
||||
group: test-dev-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
# We provide a remote gradle build cache. Take the settings from the secrets and enable
|
||||
# configuration and build cache for all gradle jobs.
|
||||
#
|
||||
# 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:
|
||||
compile:
|
||||
name: Compile
|
||||
name: Compile and cache
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/setup-java@v5
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-java@v4
|
||||
with:
|
||||
distribution: temurin
|
||||
java-version: 21
|
||||
java-version: 17
|
||||
|
||||
# See https://community.gradle.org/github-actions/docs/setup-gradle/ for more information
|
||||
- uses: gradle/actions/setup-gradle@v5
|
||||
- uses: gradle/actions/setup-gradle@v3 # creates build cache when on main branch
|
||||
with:
|
||||
cache-encryption-key: ${{ secrets.gradle_encryption_key }}
|
||||
cache-read-only: false # allow branches to update their configuration cache
|
||||
gradle-home-cache-excludes: caches/build-cache-1 # don't cache local build cache because we use a remote cache
|
||||
gradle-home-cache-cleanup: true # clean up unused files
|
||||
dependency-graph: generate-and-submit # submit Github Dependency Graph info
|
||||
dependency-graph-continue-on-failure: false
|
||||
|
||||
- name: Cache Android environment
|
||||
uses: actions/cache@v5
|
||||
with:
|
||||
path: ~/.config/.android # needs to be cached so that configuration cache can work
|
||||
key: android-${{ hashFiles('app/build.gradle.kts') }}
|
||||
- run: ./gradlew --build-cache --configuration-cache --configuration-cache-problems=warn app:assembleDebug
|
||||
|
||||
- name: Compile
|
||||
run: ./gradlew app:compileOseDebugSource
|
||||
|
||||
# Cache configurations for the other jobs (including assemble for CodeQL)
|
||||
- name: Populate configuration cache
|
||||
run: |
|
||||
./gradlew --dry-run core:assembleDebug app:assembleDebug
|
||||
./gradlew --dry-run core:lintDebug app:lintOseDebug
|
||||
./gradlew --dry-run core:testDebugUnitTest app:testOseDebugUnitTest
|
||||
./gradlew --dry-run core:virtualDebugAndroidTest app:virtualOseDebugAndroidTest
|
||||
|
||||
unit_tests:
|
||||
test:
|
||||
needs: compile
|
||||
name: Lint and unit tests
|
||||
name: Tests without emulator
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/setup-java@v5
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-java@v4
|
||||
with:
|
||||
distribution: temurin
|
||||
java-version: 21
|
||||
- uses: gradle/actions/setup-gradle@v5
|
||||
java-version: 17
|
||||
- uses: gradle/actions/setup-gradle@v3
|
||||
with:
|
||||
cache-encryption-key: ${{ secrets.gradle_encryption_key }}
|
||||
cache-read-only: true
|
||||
|
||||
- name: Restore Android environment
|
||||
uses: actions/cache/restore@v5
|
||||
with:
|
||||
path: ~/.config/.android
|
||||
key: android-${{ hashFiles('app/build.gradle.kts') }}
|
||||
- name: Run lint
|
||||
run: ./gradlew --build-cache --configuration-cache --configuration-cache-problems=warn app:lintOseDebug
|
||||
- name: Run unit tests
|
||||
run: ./gradlew --build-cache --configuration-cache --configuration-cache-problems=warn app:testOseDebugUnitTest
|
||||
|
||||
- name: Lint checks
|
||||
run: ./gradlew core:lintDebug app:lintOseDebug
|
||||
|
||||
- name: Unit tests
|
||||
run: ./gradlew core:testDebugUnitTest app:testOseDebugUnitTest
|
||||
|
||||
instrumented_tests:
|
||||
# generates the build caches because it uses more gradle dependencies
|
||||
test_on_emulator:
|
||||
needs: compile
|
||||
name: Instrumented tests
|
||||
name: Tests with emulator
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/setup-java@v5
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-java@v4
|
||||
with:
|
||||
distribution: temurin
|
||||
java-version: 21
|
||||
- uses: gradle/actions/setup-gradle@v5
|
||||
java-version: 17
|
||||
- uses: gradle/actions/setup-gradle@v3
|
||||
with:
|
||||
cache-encryption-key: ${{ secrets.gradle_encryption_key }}
|
||||
cache-read-only: true
|
||||
|
||||
- name: Restore Android environment
|
||||
uses: actions/cache/restore@v5
|
||||
with:
|
||||
path: ~/.config/.android
|
||||
key: android-${{ hashFiles('app/build.gradle.kts') }}
|
||||
|
||||
# gradle and Android SDK often take more space than what is available on the default runner.
|
||||
# We try to free a few GB here to make gradle-managed devices more reliable.
|
||||
- name: Free some disk space
|
||||
uses: jlumbroso/free-disk-space@main
|
||||
with:
|
||||
android: false # we need the Android SDK
|
||||
large-packages: false # apt takes too long
|
||||
swap-storage: false # gradle needs much memory
|
||||
|
||||
- name: Restore AVD
|
||||
id: restore-avd
|
||||
uses: actions/cache/restore@v5
|
||||
with:
|
||||
path: ~/.config/.android/avd # where AVD is stored
|
||||
key: avd-${{ hashFiles('app/build.gradle.kts') }} # gradle-managed devices are defined there
|
||||
|
||||
# Enable virtualization for Android emulator
|
||||
- name: Enable KVM group perms
|
||||
run: |
|
||||
echo 'KERNEL=="kvm", GROUP="kvm", MODE="0666", OPTIONS+="static_node=kvm"' | sudo tee /etc/udev/rules.d/99-kvm4all.rules
|
||||
sudo udevadm control --reload-rules
|
||||
sudo udevadm trigger --name-match=kvm
|
||||
|
||||
- name: Instrumented tests
|
||||
run: ./gradlew core:virtualDebugAndroidTest app:virtualOseDebugAndroidTest
|
||||
|
||||
- name: Cache AVD
|
||||
uses: actions/cache/save@v5
|
||||
if: steps.restore-avd.outputs.cache-hit != 'true'
|
||||
uses: actions/cache@v4
|
||||
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
|
||||
|
||||
- name: Run device tests
|
||||
run: ./gradlew --build-cache --configuration-cache --configuration-cache-problems=warn app:virtualCheck
|
||||
|
||||
4
.gitignore
vendored
4
.gitignore
vendored
@@ -16,6 +16,10 @@
|
||||
bin/
|
||||
gen/
|
||||
|
||||
# Gradle files
|
||||
.gradle/
|
||||
build/
|
||||
|
||||
# Local configuration file (sdk path, etc)
|
||||
local.properties
|
||||
|
||||
|
||||
9
.idea/codeStyles/Project.xml
generated
9
.idea/codeStyles/Project.xml
generated
@@ -1,9 +0,0 @@
|
||||
<component name="ProjectCodeStyleConfiguration">
|
||||
<code_scheme name="Project" version="173">
|
||||
<option name="LINE_SEPARATOR" value=" " />
|
||||
<option name="RIGHT_MARGIN" value="180" />
|
||||
<codeStyleSettings language="kotlin">
|
||||
<option name="CODE_STYLE_DEFAULTS" value="KOTLIN_OFFICIAL" />
|
||||
</codeStyleSettings>
|
||||
</code_scheme>
|
||||
</component>
|
||||
5
.idea/codeStyles/codeStyleConfig.xml
generated
5
.idea/codeStyles/codeStyleConfig.xml
generated
@@ -1,5 +0,0 @@
|
||||
<component name="ProjectCodeStyleConfiguration">
|
||||
<state>
|
||||
<option name="USE_PER_PROJECT_SETTINGS" value="true" />
|
||||
</state>
|
||||
</component>
|
||||
6
.idea/copyright/LICENSE.xml
generated
6
.idea/copyright/LICENSE.xml
generated
@@ -1,6 +0,0 @@
|
||||
<component name="CopyrightManager">
|
||||
<copyright>
|
||||
<option name="notice" value="Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details." />
|
||||
<option name="myName" value="LICENSE" />
|
||||
</copyright>
|
||||
</component>
|
||||
3
.idea/copyright/profiles_settings.xml
generated
3
.idea/copyright/profiles_settings.xml
generated
@@ -1,3 +0,0 @@
|
||||
<component name="CopyrightManager">
|
||||
<settings default="LICENSE" />
|
||||
</component>
|
||||
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
AUTHORS
14
AUTHORS
@@ -1,7 +1,11 @@
|
||||
You can view the list of people who have contributed to the code base in the version control history:
|
||||
https://github.com/bitfireAT/davx5-ose/graphs/contributors
|
||||
# This is the list of significant contributors to DAVx5.
|
||||
#
|
||||
# This does not necessarily list everyone who has contributed work.
|
||||
# To see the full list of contributors, see the revision history in
|
||||
# source control.
|
||||
|
||||
Translators are not mentioned in the history explicitly.
|
||||
The list of translators can be found in the About screen.
|
||||
Ricki Hirner (bitfire.at)
|
||||
Bernhard Stockmann (bitfire.at)
|
||||
|
||||
Every contribution is welcome. There are many other forms of contributing besides writing code!
|
||||
Sunik Kupfer (bitfire.at)
|
||||
Patrick Lang (techbee.at)
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
# Copyright notice
|
||||
# Copyright
|
||||
|
||||
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
|
||||
configuration is stored in the repository (`.idea/copyright`).
|
||||
starts with the copyright header:
|
||||
|
||||
```
|
||||
/*
|
||||
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
|
||||
*/
|
||||
```
|
||||
|
||||
You can set this in Android Studio:
|
||||
|
||||
1. Settings / Editor / Copyright / Copyright Profiles
|
||||
2. Paste the text above (without the stars).
|
||||
3. Set Formatting 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
|
||||
@@ -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
|
||||
[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).
|
||||
|
||||
|
||||
15
README.md
15
README.md
@@ -1,9 +1,9 @@
|
||||
|
||||
[](https://fosstodon.org/@davx5app)
|
||||
[](https://www.davx5.com/)
|
||||
[](https://github.com/bitfireAT/davx5-ose/blob/main/LICENSE)
|
||||
[](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⁵
|
||||
========
|
||||
|
||||
> [!IMPORTANT]
|
||||
> 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,
|
||||
> a manual and FAQ.
|
||||
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.
|
||||
|
||||
DAVx⁵ is licensed under the [GPLv3 License](LICENSE).
|
||||
|
||||
@@ -28,7 +26,8 @@ Parts of DAVx⁵ have been outsourced into these libraries:
|
||||
|
||||
* [cert4android](https://github.com/bitfireAT/cert4android) – custom certificate management
|
||||
* [dav4jvm](https://github.com/bitfireAT/dav4jvm) – WebDAV/CalDav/CardDAV framework
|
||||
* [synctools](https://github.com/bitfireAT/synctools) – iCalendar/vCard/Tasks processing and content provider access
|
||||
* [ical4android](https://github.com/bitfireAT/ical4android) – iCalendar processing and Calendar Provider access
|
||||
* [vcard4android](https://github.com/bitfireAT/vcard4android) – vCard processing and Contacts Provider access
|
||||
|
||||
**If you want to support DAVx⁵, please consider [donating to DAVx⁵](https://www.davx5.com/donate)
|
||||
or [purchasing it](https://www.davx5.com/download).**
|
||||
|
||||
@@ -1,132 +0,0 @@
|
||||
/*
|
||||
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
|
||||
*/
|
||||
|
||||
plugins {
|
||||
alias(libs.plugins.android.application)
|
||||
alias(libs.plugins.compose.compiler)
|
||||
alias(libs.plugins.hilt)
|
||||
alias(libs.plugins.ksp)
|
||||
}
|
||||
|
||||
android {
|
||||
compileSdk = 36
|
||||
|
||||
defaultConfig {
|
||||
minSdk = 24 // Android 7.0
|
||||
targetSdk = 36 // Android 16
|
||||
|
||||
applicationId = "at.bitfire.davdroid"
|
||||
|
||||
versionCode = 405090005
|
||||
versionName = "4.5.9"
|
||||
|
||||
//base.archivesName = "davx5-$versionCode-$versionName"
|
||||
}
|
||||
|
||||
java {
|
||||
toolchain {
|
||||
languageVersion = JavaLanguageVersion.of(21)
|
||||
}
|
||||
}
|
||||
|
||||
compileOptions {
|
||||
isCoreLibraryDesugaringEnabled = true
|
||||
}
|
||||
|
||||
buildFeatures {
|
||||
compose = true
|
||||
}
|
||||
|
||||
// Java namespace for our classes (not to be confused with Android package ID)
|
||||
namespace = "com.davx5.ose"
|
||||
|
||||
flavorDimensions += "distribution"
|
||||
productFlavors {
|
||||
create("ose") {
|
||||
dimension = "distribution"
|
||||
versionNameSuffix = "-ose"
|
||||
}
|
||||
}
|
||||
|
||||
sourceSets {
|
||||
getByName("androidTest") {
|
||||
assets.srcDir("$projectDir/schemas")
|
||||
}
|
||||
}
|
||||
|
||||
androidResources {
|
||||
generateLocaleConfig = true
|
||||
}
|
||||
|
||||
buildTypes {
|
||||
getByName("release") {
|
||||
isMinifyEnabled = true
|
||||
proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules-release.pro")
|
||||
|
||||
isShrinkResources = true
|
||||
|
||||
signingConfig = signingConfigs.findByName("bitfire")
|
||||
}
|
||||
}
|
||||
|
||||
signingConfigs {
|
||||
create("bitfire") {
|
||||
storeFile = file(System.getenv("ANDROID_KEYSTORE") ?: "/dev/null")
|
||||
storePassword = System.getenv("ANDROID_KEYSTORE_PASSWORD")
|
||||
keyAlias = System.getenv("ANDROID_KEY_ALIAS")
|
||||
keyPassword = System.getenv("ANDROID_KEY_PASSWORD")
|
||||
}
|
||||
}
|
||||
|
||||
@Suppress("UnstableApiUsage")
|
||||
testOptions {
|
||||
managedDevices {
|
||||
localDevices {
|
||||
create("virtual") {
|
||||
device = "Pixel 3"
|
||||
// TBD: API level 35 and higher causes network tests to fail sometimes, see https://github.com/bitfireAT/davx5-ose/issues/1525
|
||||
// Suspected reason: https://developer.android.com/about/versions/15/behavior-changes-all#background-network-access
|
||||
apiLevel = 34
|
||||
systemImageSource = "aosp-atd"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
dependencies {
|
||||
// include core module
|
||||
implementation(project(":core"))
|
||||
|
||||
// Kotlin / Android
|
||||
implementation(libs.kotlin.stdlib)
|
||||
implementation(libs.kotlinx.coroutines)
|
||||
coreLibraryDesugaring(libs.android.desugaring)
|
||||
|
||||
// Hilt
|
||||
implementation(libs.hilt.android.base)
|
||||
ksp(libs.androidx.hilt.compiler)
|
||||
ksp(libs.hilt.android.compiler)
|
||||
|
||||
// support libs
|
||||
implementation(libs.androidx.core)
|
||||
implementation(libs.androidx.hilt.work)
|
||||
implementation(libs.androidx.lifecycle.viewmodel.base)
|
||||
implementation(libs.androidx.lifecycle.viewmodel.compose)
|
||||
implementation(libs.androidx.work.base)
|
||||
|
||||
// Jetpack Compose
|
||||
implementation(platform(libs.androidx.compose.bom))
|
||||
implementation(libs.androidx.compose.material3)
|
||||
debugImplementation(libs.androidx.compose.ui.tooling)
|
||||
implementation(libs.androidx.compose.ui.toolingPreview)
|
||||
|
||||
// own libraries
|
||||
implementation(libs.bitfire.cert4android)
|
||||
|
||||
// third-party libs
|
||||
implementation(libs.guava)
|
||||
implementation(libs.okhttp.base)
|
||||
implementation(libs.openid.appauth)
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!--
|
||||
~ Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
|
||||
-->
|
||||
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:installLocation="internalOnly">
|
||||
|
||||
<application android:name=".App"/>
|
||||
|
||||
</manifest>
|
||||
@@ -1,83 +0,0 @@
|
||||
/*
|
||||
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
|
||||
*/
|
||||
|
||||
package com.davx5.ose
|
||||
|
||||
import android.app.Application
|
||||
import androidx.hilt.work.HiltWorkerFactory
|
||||
import androidx.work.Configuration
|
||||
import at.bitfire.davdroid.di.scope.DefaultDispatcher
|
||||
import at.bitfire.davdroid.log.LogManager
|
||||
import at.bitfire.davdroid.startup.StartupPlugin
|
||||
import at.bitfire.davdroid.sync.account.AccountsCleanupWorker
|
||||
import at.bitfire.davdroid.ui.UiUtils
|
||||
import dagger.hilt.android.HiltAndroidApp
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
import kotlinx.coroutines.DelicateCoroutinesApi
|
||||
import kotlinx.coroutines.GlobalScope
|
||||
import kotlinx.coroutines.launch
|
||||
import java.util.logging.Logger
|
||||
import javax.inject.Inject
|
||||
|
||||
@HiltAndroidApp
|
||||
class App: Application(), Configuration.Provider {
|
||||
|
||||
@Inject
|
||||
lateinit var logger: Logger
|
||||
|
||||
/**
|
||||
* Creates the [at.bitfire.davdroid.log.LogManager] singleton and thus initializes logging.
|
||||
*/
|
||||
@Inject
|
||||
lateinit var logManager: LogManager
|
||||
|
||||
@Inject
|
||||
@DefaultDispatcher
|
||||
lateinit var defaultDispatcher: CoroutineDispatcher
|
||||
|
||||
@Inject
|
||||
lateinit var plugins: Set<@JvmSuppressWildcards StartupPlugin>
|
||||
|
||||
@Inject
|
||||
lateinit var workerFactory: HiltWorkerFactory
|
||||
|
||||
override val workManagerConfiguration: Configuration
|
||||
get() = Configuration.Builder()
|
||||
.setWorkerFactory(workerFactory)
|
||||
.build()
|
||||
|
||||
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
|
||||
logger.fine("Logging using LogManager $logManager")
|
||||
|
||||
// set light/dark mode
|
||||
UiUtils.updateTheme(this) // when this is called in the asynchronous thread below, it recreates
|
||||
// some current activity and causes an IllegalStateException in rare cases
|
||||
|
||||
// run startup plugins (sync)
|
||||
for (plugin in plugins.sortedBy { it.priority() }) {
|
||||
logger.fine("Running startup plugin: $plugin (onAppCreate)")
|
||||
plugin.onAppCreate()
|
||||
}
|
||||
|
||||
// don't block UI for some background checks
|
||||
@OptIn(DelicateCoroutinesApi::class)
|
||||
GlobalScope.launch(defaultDispatcher) {
|
||||
// clean up orphaned accounts in DB from time to time
|
||||
AccountsCleanupWorker.Companion.enable(this@App)
|
||||
|
||||
// create/update app shortcuts
|
||||
UiUtils.updateShortcuts(this@App)
|
||||
|
||||
// run startup plugins (async)
|
||||
for (plugin in plugins.sortedBy { it.priorityAsync() }) {
|
||||
logger.fine("Running startup plugin: $plugin (onAppCreateAsync)")
|
||||
plugin.onAppCreateAsync()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,50 +0,0 @@
|
||||
/*
|
||||
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
|
||||
*/
|
||||
|
||||
package com.davx5.ose
|
||||
|
||||
import android.content.Context
|
||||
import at.bitfire.davdroid.ui.DebugInfoActivity
|
||||
import dagger.Binds
|
||||
import dagger.Module
|
||||
import dagger.hilt.InstallIn
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import dagger.hilt.components.SingletonComponent
|
||||
import java.util.logging.Level
|
||||
import java.util.logging.Logger
|
||||
import javax.inject.Inject
|
||||
|
||||
class DebugInfoCrashHandler @Inject constructor(
|
||||
@ApplicationContext private val context: Context,
|
||||
private val logger: Logger
|
||||
): Thread.UncaughtExceptionHandler {
|
||||
|
||||
@Module
|
||||
@InstallIn(SingletonComponent::class)
|
||||
interface DebugInfoCrashHandlerModule {
|
||||
@Binds
|
||||
fun debugInfoCrashHandler(
|
||||
debugInfoCrashHandler: DebugInfoCrashHandler
|
||||
): Thread.UncaughtExceptionHandler
|
||||
}
|
||||
|
||||
// See https://developer.android.com/about/versions/oreo/android-8.0-changes#loue
|
||||
val originalCrashHandler = Thread.getDefaultUncaughtExceptionHandler()
|
||||
|
||||
|
||||
override fun uncaughtException(t: Thread, e: Throwable) {
|
||||
logger.log(Level.SEVERE, "Unhandled exception in thread ${t.id}!", e)
|
||||
|
||||
// start debug info activity with exception (will be started in a new process)
|
||||
val intent = DebugInfoActivity.IntentBuilder(context)
|
||||
.withCause(e)
|
||||
.newTask()
|
||||
.build()
|
||||
context.startActivity(intent)
|
||||
|
||||
// pass through to default handler to kill the process
|
||||
originalCrashHandler?.uncaughtException(t, e)
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,61 +0,0 @@
|
||||
/*
|
||||
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
|
||||
*/
|
||||
|
||||
package com.davx5.ose.di
|
||||
|
||||
import android.content.Context
|
||||
import at.bitfire.cert4android.CustomCertManager
|
||||
import at.bitfire.cert4android.CustomCertStore
|
||||
import at.bitfire.cert4android.SettingsProvider
|
||||
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.Reusable
|
||||
import dagger.hilt.InstallIn
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import dagger.hilt.components.SingletonComponent
|
||||
import okhttp3.internal.tls.OkHostnameVerifier
|
||||
import java.util.Optional
|
||||
|
||||
/**
|
||||
* cert4android integration module
|
||||
*/
|
||||
@Module
|
||||
@InstallIn(SingletonComponent::class)
|
||||
class CustomCertManagerModule {
|
||||
|
||||
@Provides
|
||||
fun customCertStore(@ApplicationContext context: Context): Optional<CustomCertStore> =
|
||||
Optional.of(CustomCertStore.getInstance(context))
|
||||
|
||||
@Provides
|
||||
@Reusable
|
||||
fun customCertManager(
|
||||
customCertStore: Optional<CustomCertStore>,
|
||||
settings: SettingsManager
|
||||
): Optional<CustomCertManager> =
|
||||
Optional.of(
|
||||
CustomCertManager(
|
||||
certStore = customCertStore.get(),
|
||||
settings = object : SettingsProvider {
|
||||
|
||||
override val appInForeground: Boolean
|
||||
get() = ForegroundTracker.inForeground.value
|
||||
|
||||
override val trustSystemCerts: Boolean
|
||||
get() = !settings.getBoolean(Settings.DISTRUST_SYSTEM_CERTIFICATES)
|
||||
|
||||
}
|
||||
))
|
||||
|
||||
@Provides
|
||||
@Reusable
|
||||
fun customHostnameVerifier(
|
||||
customCertManager: Optional<CustomCertManager>
|
||||
): Optional<CustomCertManager.HostnameVerifier> =
|
||||
Optional.of(customCertManager.get().HostnameVerifier(OkHostnameVerifier))
|
||||
|
||||
}
|
||||
@@ -1,28 +0,0 @@
|
||||
/*
|
||||
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
|
||||
*/
|
||||
|
||||
package com.davx5.ose.di
|
||||
|
||||
import androidx.compose.material3.ColorScheme
|
||||
import at.bitfire.davdroid.di.scope.DarkColorScheme
|
||||
import at.bitfire.davdroid.di.scope.LightColorScheme
|
||||
import com.davx5.ose.ui.OseTheme
|
||||
import dagger.Module
|
||||
import dagger.Provides
|
||||
import dagger.hilt.InstallIn
|
||||
import dagger.hilt.components.SingletonComponent
|
||||
|
||||
@Module
|
||||
@InstallIn(SingletonComponent::class)
|
||||
class OseColorSchemesModule {
|
||||
|
||||
@Provides
|
||||
@LightColorScheme
|
||||
fun lightColorScheme(): ColorScheme = OseTheme.lightScheme
|
||||
|
||||
@Provides
|
||||
@DarkColorScheme
|
||||
fun darkColorScheme(): ColorScheme = OseTheme.darkScheme
|
||||
|
||||
}
|
||||
@@ -1,76 +0,0 @@
|
||||
/*
|
||||
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
|
||||
*/
|
||||
|
||||
package com.davx5.ose.ui.setup
|
||||
|
||||
import android.content.Intent
|
||||
import androidx.compose.material3.SnackbarHostState
|
||||
import androidx.compose.runtime.Composable
|
||||
import at.bitfire.davdroid.ui.setup.AdvancedLogin
|
||||
import at.bitfire.davdroid.ui.setup.EmailLogin
|
||||
import at.bitfire.davdroid.ui.setup.FastmailLogin
|
||||
import at.bitfire.davdroid.ui.setup.GoogleLogin
|
||||
import at.bitfire.davdroid.ui.setup.LoginActivity
|
||||
import at.bitfire.davdroid.ui.setup.LoginInfo
|
||||
import at.bitfire.davdroid.ui.setup.LoginType
|
||||
import at.bitfire.davdroid.ui.setup.LoginTypesProvider
|
||||
import at.bitfire.davdroid.ui.setup.LoginTypesProvider.LoginAction
|
||||
import at.bitfire.davdroid.ui.setup.NextcloudLogin
|
||||
import at.bitfire.davdroid.ui.setup.UrlLogin
|
||||
import java.util.logging.Logger
|
||||
import javax.inject.Inject
|
||||
|
||||
class StandardLoginTypesProvider @Inject constructor(
|
||||
private val logger: Logger
|
||||
) : LoginTypesProvider {
|
||||
|
||||
companion object {
|
||||
val genericLoginTypes = listOf(
|
||||
UrlLogin,
|
||||
EmailLogin,
|
||||
AdvancedLogin
|
||||
)
|
||||
|
||||
val specificLoginTypes = listOf(
|
||||
FastmailLogin,
|
||||
GoogleLogin,
|
||||
NextcloudLogin
|
||||
)
|
||||
}
|
||||
|
||||
override val defaultLoginType = UrlLogin
|
||||
|
||||
override fun intentToInitialLoginType(intent: Intent): LoginAction =
|
||||
intent.data?.normalizeScheme().let { uri ->
|
||||
when {
|
||||
intent.hasExtra(LoginActivity.EXTRA_LOGIN_FLOW) ->
|
||||
LoginAction(NextcloudLogin, true)
|
||||
uri?.scheme == "mailto" ->
|
||||
LoginAction(EmailLogin, true)
|
||||
listOf("caldavs", "carddavs", "davx5", "http", "https").any { uri?.scheme == it } ->
|
||||
LoginAction(UrlLogin, true)
|
||||
else -> {
|
||||
logger.warning("Did not understand login intent: $intent")
|
||||
LoginAction(defaultLoginType, false) // Don't skip login type page if intent is unclear
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
override fun LoginTypePage(
|
||||
snackbarHostState: SnackbarHostState,
|
||||
selectedLoginType: LoginType,
|
||||
onSelectLoginType: (LoginType) -> Unit,
|
||||
setInitialLoginInfo: (LoginInfo) -> Unit,
|
||||
onContinue: () -> Unit
|
||||
) {
|
||||
StandardLoginTypePage(
|
||||
selectedLoginType = selectedLoginType,
|
||||
onSelectLoginType = onSelectLoginType,
|
||||
setInitialLoginInfo = setInitialLoginInfo,
|
||||
onContinue = onContinue
|
||||
)
|
||||
}
|
||||
|
||||
}
|
||||
0
core/.gitignore → app/.gitignore
vendored
0
core/.gitignore → app/.gitignore
vendored
@@ -1,36 +1,46 @@
|
||||
/*
|
||||
/***************************************************************************************************
|
||||
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
|
||||
*/
|
||||
**************************************************************************************************/
|
||||
|
||||
plugins {
|
||||
alias(libs.plugins.android.library)
|
||||
alias(libs.plugins.mikepenz.aboutLibraries)
|
||||
alias(libs.plugins.android.application)
|
||||
alias(libs.plugins.compose.compiler)
|
||||
alias(libs.plugins.hilt)
|
||||
alias(libs.plugins.kotlin.serialization)
|
||||
alias(libs.plugins.kotlin.android)
|
||||
alias(libs.plugins.ksp)
|
||||
alias(libs.plugins.mikepenz.aboutLibraries.android)
|
||||
}
|
||||
|
||||
// Android configuration
|
||||
android {
|
||||
compileSdk = 36
|
||||
compileSdk = 34
|
||||
|
||||
defaultConfig {
|
||||
minSdk = 24 // Android 7.0
|
||||
applicationId = "at.bitfire.davdroid"
|
||||
|
||||
testInstrumentationRunner = "at.bitfire.davdroid.HiltTestRunner"
|
||||
versionCode = 404010101
|
||||
versionName = "4.4.1.1"
|
||||
|
||||
buildConfigField("long", "buildTime", "${System.currentTimeMillis()}L")
|
||||
|
||||
setProperty("archivesBaseName", "davx5-ose-$versionName")
|
||||
|
||||
minSdk = 24 // Android 7.0
|
||||
targetSdk = 34 // Android 14
|
||||
|
||||
buildConfigField("String", "userAgent", "\"DAVx5\"")
|
||||
|
||||
testInstrumentationRunner = "at.bitfire.davdroid.CustomTestRunner"
|
||||
}
|
||||
|
||||
java {
|
||||
toolchain {
|
||||
languageVersion = JavaLanguageVersion.of(21)
|
||||
languageVersion = JavaLanguageVersion.of(17)
|
||||
}
|
||||
}
|
||||
|
||||
compileOptions {
|
||||
// required for
|
||||
// - dnsjava 3.x: java.nio.file.Path
|
||||
// - ical4android: time API
|
||||
// enable because ical4android requires desugaring
|
||||
isCoreLibraryDesugaringEnabled = true
|
||||
}
|
||||
|
||||
@@ -42,31 +52,60 @@ android {
|
||||
// Java namespace for our classes (not to be confused with Android package ID)
|
||||
namespace = "at.bitfire.davdroid"
|
||||
|
||||
flavorDimensions += "distribution"
|
||||
productFlavors {
|
||||
create("ose") {
|
||||
dimension = "distribution"
|
||||
versionNameSuffix = "-ose"
|
||||
}
|
||||
}
|
||||
|
||||
sourceSets {
|
||||
getByName("androidTest") {
|
||||
assets.srcDir("$projectDir/schemas")
|
||||
}
|
||||
}
|
||||
|
||||
signingConfigs {
|
||||
create("bitfire") {
|
||||
storeFile = file(System.getenv("ANDROID_KEYSTORE") ?: "/dev/null")
|
||||
storePassword = System.getenv("ANDROID_KEYSTORE_PASSWORD")
|
||||
keyAlias = System.getenv("ANDROID_KEY_ALIAS")
|
||||
keyPassword = System.getenv("ANDROID_KEY_PASSWORD")
|
||||
}
|
||||
}
|
||||
|
||||
buildTypes {
|
||||
getByName("release") {
|
||||
isMinifyEnabled = false
|
||||
isMinifyEnabled = true
|
||||
proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules-release.pro")
|
||||
|
||||
isShrinkResources = true
|
||||
|
||||
signingConfig = signingConfigs.findByName("bitfire")
|
||||
}
|
||||
}
|
||||
|
||||
lint {
|
||||
disable += arrayOf("GoogleAppIndexingWarning", "ImpliedQuantity", "MissingQuantity", "MissingTranslation", "ExtraTranslation", "RtlEnabled", "RtlHardcoded", "Typos")
|
||||
disable += arrayOf("GoogleAppIndexingWarning", "ImpliedQuantity", "MissingQuantity", "MissingTranslation", "ExtraTranslation", "RtlEnabled", "RtlHardcoded", "Typos", "NullSafeMutableLiveData")
|
||||
}
|
||||
|
||||
packaging {
|
||||
resources {
|
||||
// multiple (test) dependencies have LICENSE files at same location
|
||||
merges += arrayOf("META-INF/LICENSE*")
|
||||
excludes += arrayOf("META-INF/*.md")
|
||||
}
|
||||
}
|
||||
|
||||
androidResources {
|
||||
generateLocaleConfig = true
|
||||
}
|
||||
|
||||
@Suppress("UnstableApiUsage")
|
||||
testOptions {
|
||||
managedDevices {
|
||||
localDevices {
|
||||
create("virtual") {
|
||||
device = "Pixel 3"
|
||||
// TBD: API level 35 and higher causes network tests to fail sometimes, see https://github.com/bitfireAT/davx5-ose/issues/1525
|
||||
// Suspected reason: https://developer.android.com/about/versions/15/behavior-changes-all#background-network-access
|
||||
apiLevel = 34
|
||||
systemImageSource = "aosp-atd"
|
||||
}
|
||||
@@ -79,15 +118,19 @@ ksp {
|
||||
arg("room.schemaLocation", "$projectDir/schemas")
|
||||
}
|
||||
|
||||
aboutLibraries {
|
||||
export {
|
||||
// exclude timestamps for reproducible builds [https://github.com/bitfireAT/davx5-ose/issues/994]
|
||||
excludeFields.add("generated")
|
||||
configurations {
|
||||
configureEach {
|
||||
// exclude modules which are in conflict with system libraries
|
||||
exclude(module="commons-logging")
|
||||
exclude(group="org.json", module="json")
|
||||
|
||||
// Groovy requires SDK 26+, and it's not required, so exclude it
|
||||
exclude(group="org.codehaus.groovy")
|
||||
}
|
||||
}
|
||||
|
||||
dependencies {
|
||||
// Kotlin / Android
|
||||
// core
|
||||
implementation(libs.kotlin.stdlib)
|
||||
implementation(libs.kotlinx.coroutines)
|
||||
coreLibraryDesugaring(libs.android.desugaring)
|
||||
@@ -115,62 +158,49 @@ dependencies {
|
||||
|
||||
// Jetpack Compose
|
||||
implementation(libs.compose.accompanist.permissions)
|
||||
implementation(platform(libs.androidx.compose.bom))
|
||||
implementation(libs.androidx.compose.material3)
|
||||
implementation(libs.androidx.compose.material3.adaptive)
|
||||
implementation(libs.androidx.compose.materialIconsExtended)
|
||||
debugImplementation(libs.androidx.compose.ui.tooling)
|
||||
implementation(libs.androidx.compose.ui.toolingPreview)
|
||||
implementation(platform(libs.compose.bom))
|
||||
implementation(libs.compose.material3)
|
||||
implementation(libs.compose.materialIconsExtended)
|
||||
implementation(libs.compose.runtime.livedata)
|
||||
debugImplementation(libs.compose.ui.tooling)
|
||||
implementation(libs.compose.ui.toolingPreview)
|
||||
|
||||
// Glance Widgets
|
||||
implementation(libs.androidx.glance.base)
|
||||
implementation(libs.androidx.glance.material3)
|
||||
implementation(libs.glance.base)
|
||||
implementation(libs.glance.material)
|
||||
|
||||
// Jetpack Room
|
||||
implementation(libs.androidx.room.runtime)
|
||||
implementation(libs.androidx.room.base)
|
||||
implementation(libs.androidx.room.paging)
|
||||
ksp(libs.androidx.room.compiler)
|
||||
implementation(libs.room.runtime)
|
||||
implementation(libs.room.base)
|
||||
implementation(libs.room.paging)
|
||||
ksp(libs.room.compiler)
|
||||
|
||||
// own libraries
|
||||
implementation(libs.bitfire.cert4android)
|
||||
implementation(libs.bitfire.dav4jvm) {
|
||||
exclude(group="junit")
|
||||
exclude(group="org.ogce", module="xpp3") // Android has its own XmlPullParser implementation
|
||||
}
|
||||
implementation(libs.bitfire.synctools) {
|
||||
exclude(group="androidx.test") // synctools declares test rules, but we don't want them in non-test code
|
||||
exclude(group = "junit")
|
||||
}
|
||||
implementation(libs.bitfire.ical4android)
|
||||
implementation(libs.bitfire.vcard4android)
|
||||
|
||||
// third-party libs
|
||||
implementation(libs.conscrypt)
|
||||
implementation(libs.commons.collections)
|
||||
@Suppress("RedundantSuppression")
|
||||
implementation(libs.commons.io)
|
||||
implementation(libs.commons.lang)
|
||||
implementation(libs.commons.text)
|
||||
@Suppress("RedundantSuppression")
|
||||
implementation(libs.dnsjava)
|
||||
implementation(libs.guava)
|
||||
implementation(libs.ktor.client.content.negotiation)
|
||||
implementation(libs.ktor.client.core)
|
||||
implementation(libs.ktor.client.okhttp)
|
||||
implementation(libs.ktor.serialization.kotlinx.json)
|
||||
implementation(libs.mikepenz.aboutLibraries.m3)
|
||||
implementation(libs.mikepenz.aboutLibraries)
|
||||
implementation(libs.nsk90.kstatemachine)
|
||||
implementation(libs.okhttp.base)
|
||||
implementation(libs.okhttp.brotli)
|
||||
implementation(libs.okhttp.logging)
|
||||
implementation(libs.openid.appauth)
|
||||
implementation(libs.unifiedpush) {
|
||||
// UnifiedPush connector seems to be using a workaround by importing this library.
|
||||
// Will be removed after https://github.com/tink-crypto/tink-java-apps/pull/5 is merged.
|
||||
// See: https://codeberg.org/UnifiedPush/android-connector/src/commit/28cb0d622ed0a972996041ab9cc85b701abc48c6/connector/build.gradle#L56-L59
|
||||
exclude(group = "com.google.crypto.tink", module = "tink")
|
||||
}
|
||||
implementation(libs.unifiedpush.fcm)
|
||||
|
||||
// force some versions for compatibility with our minSdk level (see version catalog for details)
|
||||
implementation(libs.commons.codec)
|
||||
implementation(libs.commons.lang)
|
||||
implementation(libs.unifiedpush)
|
||||
|
||||
// for tests
|
||||
androidTestImplementation(libs.androidx.arch.core.testing)
|
||||
androidTestImplementation(libs.androidx.room.testing)
|
||||
androidTestImplementation(libs.androidx.test.core)
|
||||
androidTestImplementation(libs.androidx.test.junit)
|
||||
androidTestImplementation(libs.androidx.test.rules)
|
||||
@@ -178,13 +208,10 @@ dependencies {
|
||||
androidTestImplementation(libs.androidx.work.testing)
|
||||
androidTestImplementation(libs.hilt.android.testing)
|
||||
androidTestImplementation(libs.junit)
|
||||
androidTestImplementation(libs.kotlinx.coroutines.test)
|
||||
androidTestImplementation(libs.mockk.android)
|
||||
androidTestImplementation(libs.okhttp.mockwebserver)
|
||||
androidTestImplementation(libs.room.testing)
|
||||
|
||||
testImplementation(libs.bitfire.dav4jvm)
|
||||
testImplementation(libs.junit)
|
||||
testImplementation(libs.mockk)
|
||||
testImplementation(libs.okhttp.mockwebserver)
|
||||
testImplementation(libs.robolectric)
|
||||
}
|
||||
}
|
||||
59
app/proguard-rules-release.pro
Normal file
59
app/proguard-rules-release.pro
Normal file
@@ -0,0 +1,59 @@
|
||||
|
||||
# R8 usage for DAVx⁵:
|
||||
# shrinking yes (only in release builds)
|
||||
# optimization yes (on by R8 defaults)
|
||||
# full-mode no (see gradle.properties)
|
||||
# obfuscation no (open-source)
|
||||
|
||||
-dontobfuscate
|
||||
-printusage build/reports/r8-usage.txt
|
||||
|
||||
# ez-vcard: keep all vCard properties/parameters (used via reflection)
|
||||
-keep class ezvcard.io.scribe.** { *; }
|
||||
-keep class ezvcard.property.** { *; }
|
||||
-keep class ezvcard.parameter.** { *; }
|
||||
|
||||
# ical4j: keep all iCalendar properties/parameters (used via reflection)
|
||||
-keep class net.fortuna.ical4j.** { *; }
|
||||
|
||||
# XmlPullParser
|
||||
-keep class org.xmlpull.** { *; }
|
||||
|
||||
# DAVx⁵ + libs
|
||||
-keep class at.bitfire.** { *; } # all DAVx⁵ code is required
|
||||
|
||||
# AGP 8.2 and 8.3 seem to remove this class, but ezvcard.io uses it. See https://github.com/bitfireAT/davx5/issues/499
|
||||
-keep class javax.xml.namespace.QName { *; }
|
||||
|
||||
# we use enum classes (https://www.guardsquare.com/en/products/proguard/manual/examples#enumerations)
|
||||
-keepclassmembers,allowoptimization enum * {
|
||||
public static **[] values();
|
||||
public static ** valueOf(java.lang.String);
|
||||
}
|
||||
|
||||
# Additional rules which are now required since missing classes can't be ignored in R8 anymore.
|
||||
# [https://developer.android.com/build/releases/past-releases/agp-7-0-0-release-notes#r8-missing-class-warning]
|
||||
-dontwarn com.android.org.conscrypt.SSLParametersImpl
|
||||
-dontwarn com.github.erosb.jsonsKema.** # ical4j
|
||||
-dontwarn com.google.errorprone.annotations.**
|
||||
-dontwarn com.sun.jna.** # dnsjava
|
||||
-dontwarn groovy.**
|
||||
-dontwarn java.beans.Transient
|
||||
-dontwarn javax.cache.** # ical4j
|
||||
-dontwarn javax.naming.NamingException # dnsjava
|
||||
-dontwarn javax.naming.directory.** # dnsjava
|
||||
-dontwarn junit.textui.TestRunner
|
||||
-dontwarn lombok.** # dnsjava
|
||||
-dontwarn org.apache.harmony.xnet.provider.jsse.SSLParametersImpl
|
||||
-dontwarn org.bouncycastle.jsse.**
|
||||
-dontwarn org.codehaus.groovy.**
|
||||
-dontwarn org.joda.**
|
||||
-dontwarn org.jparsec.** # ical4j
|
||||
-dontwarn org.json.*
|
||||
-dontwarn org.jsoup.**
|
||||
-dontwarn org.openjsse.javax.net.ssl.SSLParameters
|
||||
-dontwarn org.openjsse.javax.net.ssl.SSLSocket
|
||||
-dontwarn org.openjsse.net.ssl.OpenJSSE
|
||||
-dontwarn org.xmlpull.**
|
||||
-dontwarn sun.net.spi.nameservice.NameService
|
||||
-dontwarn sun.net.spi.nameservice.NameServiceDescriptor
|
||||
1
app/src/.gitignore
vendored
Normal file
1
app/src/.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
||||
espressoTest
|
||||
@@ -1,6 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:tools="http://schemas.android.com/tools"
|
||||
android:installLocation="internalOnly">
|
||||
|
||||
<!-- account management permissions not required for own accounts since API level 22 -->
|
||||
@@ -8,9 +7,4 @@
|
||||
<uses-permission android:name="android.permission.GET_ACCOUNTS" android:maxSdkVersion="22"/>
|
||||
<uses-permission android:name="android.permission.MANAGE_ACCOUNTS" android:maxSdkVersion="22"/>
|
||||
|
||||
<!--
|
||||
Since Mockk 1.14.7 it's required to use minSdk 26. We use 24, so override for tests.
|
||||
-->
|
||||
<uses-sdk tools:overrideLibrary="io.mockk.android,io.mockk.proxy.android" />
|
||||
|
||||
</manifest>
|
||||
</manifest>
|
||||
@@ -0,0 +1,17 @@
|
||||
/*
|
||||
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
|
||||
*/
|
||||
|
||||
package at.bitfire.davdroid
|
||||
|
||||
import android.content.Context
|
||||
import androidx.test.runner.AndroidJUnitRunner
|
||||
import dagger.hilt.android.HiltAndroidApp
|
||||
import dagger.hilt.android.testing.HiltTestApplication
|
||||
|
||||
class CustomTestRunner : AndroidJUnitRunner() {
|
||||
|
||||
override fun newApplication(cl: ClassLoader, name: String, context: Context) =
|
||||
super.newApplication(cl, HiltTestApplication::class.java.name, context)
|
||||
|
||||
}
|
||||
20
app/src/androidTest/kotlin/at/bitfire/davdroid/Dav4jvm.kt
Normal file
20
app/src/androidTest/kotlin/at/bitfire/davdroid/Dav4jvm.kt
Normal file
@@ -0,0 +1,20 @@
|
||||
/***************************************************************************************************
|
||||
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
|
||||
**************************************************************************************************/
|
||||
|
||||
package at.bitfire.davdroid
|
||||
|
||||
import android.util.Xml
|
||||
import at.bitfire.dav4jvm.XmlUtils
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class Dav4jvm {
|
||||
|
||||
@Test
|
||||
fun test_Dav4jvm_XmlUtils_NewPullParser_RelaxedParsing() {
|
||||
val parser = XmlUtils.newPullParser()
|
||||
assertTrue(parser.getFeature(Xml.FEATURE_RELAXED))
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
/*
|
||||
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
|
||||
*/
|
||||
|
||||
package at.bitfire.davdroid
|
||||
|
||||
import android.Manifest
|
||||
import android.accounts.Account
|
||||
import android.content.ContentUris
|
||||
import android.content.ContentValues
|
||||
import android.os.Build
|
||||
import android.provider.CalendarContract
|
||||
import androidx.annotation.RequiresApi
|
||||
import androidx.test.platform.app.InstrumentationRegistry
|
||||
import androidx.test.rule.GrantPermissionRule
|
||||
import at.bitfire.davdroid.log.Logger
|
||||
import at.bitfire.davdroid.resource.LocalCalendar
|
||||
import at.bitfire.davdroid.resource.LocalEvent
|
||||
import at.bitfire.ical4android.AndroidCalendar
|
||||
import at.bitfire.ical4android.Event
|
||||
import net.fortuna.ical4j.model.property.DtStart
|
||||
import net.fortuna.ical4j.model.property.RRule
|
||||
import org.junit.rules.RuleChain
|
||||
import org.junit.rules.TestRule
|
||||
import org.junit.runner.Description
|
||||
import org.junit.runners.model.Statement
|
||||
|
||||
/**
|
||||
* JUnit ClassRule which initializes the AOSP CalendarProvider.
|
||||
* Needed for some "flaky" tests which would otherwise only succeed on second run.
|
||||
*
|
||||
* Currently tested on development machine (Ryzen) with Android 12 images (with/without Google Play).
|
||||
* Calendar provider behaves quite randomly, so it may or may not work. If you (the reader
|
||||
* if this comment) can find out on how to initialize the calendar provider so that the
|
||||
* tests are reliably run after `adb shell pm clear com.android.providers.calendar`,
|
||||
* please let us know!
|
||||
*
|
||||
* If you run tests manually, just make sure to ignore the first run after the calendar
|
||||
* provider has been accessed the first time.
|
||||
*/
|
||||
class InitCalendarProviderRule private constructor(): TestRule {
|
||||
|
||||
companion object {
|
||||
fun getInstance() = RuleChain
|
||||
.outerRule(GrantPermissionRule.grant(Manifest.permission.READ_CALENDAR, Manifest.permission.WRITE_CALENDAR))
|
||||
.around(InitCalendarProviderRule())
|
||||
}
|
||||
|
||||
override fun apply(base: Statement, description: Description): Statement {
|
||||
Logger.log.info("Initializing calendar provider before running ${description.displayName}")
|
||||
return InitCalendarProviderStatement(base)
|
||||
}
|
||||
|
||||
|
||||
class InitCalendarProviderStatement(val base: Statement): Statement() {
|
||||
|
||||
override fun evaluate() {
|
||||
if (Build.VERSION.SDK_INT < 31)
|
||||
Logger.log.warning("Calendar provider initialization may or may not work. See InitCalendarProviderRule")
|
||||
initCalendarProvider()
|
||||
|
||||
base.evaluate()
|
||||
}
|
||||
|
||||
private fun initCalendarProvider() {
|
||||
val account = Account("LocalCalendarTest", CalendarContract.ACCOUNT_TYPE_LOCAL)
|
||||
val context = InstrumentationRegistry.getInstrumentation().targetContext
|
||||
val provider = context.contentResolver.acquireContentProviderClient(CalendarContract.AUTHORITY)!!
|
||||
val uri = AndroidCalendar.create(account, provider, ContentValues())
|
||||
val calendar = AndroidCalendar.findByID(account, provider, LocalCalendar.Factory, ContentUris.parseId(uri))
|
||||
try {
|
||||
// single event init
|
||||
val normalEvent = Event().apply {
|
||||
dtStart = DtStart("20220120T010203Z")
|
||||
summary = "Event with 1 instance"
|
||||
}
|
||||
val normalLocalEvent = LocalEvent(calendar, normalEvent, null, null, null, 0)
|
||||
normalLocalEvent.add()
|
||||
LocalEvent.numInstances(provider, account, normalLocalEvent.id!!)
|
||||
|
||||
// recurring event init
|
||||
val recurringEvent = Event().apply {
|
||||
dtStart = DtStart("20220120T010203Z")
|
||||
summary = "Event over 22 years"
|
||||
rRules.add(RRule("FREQ=YEARLY;UNTIL=20740119T010203Z")) // year needs to be >2074 (not supported by Android <11 Calendar Storage)
|
||||
}
|
||||
val localRecurringEvent = LocalEvent(calendar, recurringEvent, null, null, null, 0)
|
||||
localRecurringEvent.add()
|
||||
LocalEvent.numInstances(provider, account, localRecurringEvent.id!!)
|
||||
} finally {
|
||||
calendar.delete()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
package at.bitfire.davdroid
|
||||
|
||||
import at.bitfire.davdroid.repository.DavCollectionRepository
|
||||
import at.bitfire.davdroid.push.PushRegistrationWorker
|
||||
import dagger.Module
|
||||
import dagger.hilt.components.SingletonComponent
|
||||
import dagger.multibindings.Multibinds
|
||||
import dagger.hilt.testing.TestInstallIn
|
||||
|
||||
interface TestModules {
|
||||
|
||||
@Module
|
||||
@TestInstallIn(
|
||||
components = [SingletonComponent::class],
|
||||
replaces = [PushRegistrationWorker.PushRegistrationWorkerModule::class]
|
||||
)
|
||||
abstract class FakePushRegistrationWorkerModule {
|
||||
// Provides empty set of listeners
|
||||
@Multibinds
|
||||
abstract fun defaultOnChangeListeners(): Set<DavCollectionRepository.OnChangeListener>
|
||||
}
|
||||
}
|
||||
@@ -5,14 +5,16 @@
|
||||
package at.bitfire.davdroid
|
||||
|
||||
import android.content.Context
|
||||
import android.util.Log
|
||||
import androidx.work.Configuration
|
||||
import androidx.lifecycle.LiveData
|
||||
import androidx.lifecycle.Observer
|
||||
import androidx.work.WorkInfo
|
||||
import androidx.work.WorkManager
|
||||
import androidx.work.WorkQuery
|
||||
import androidx.work.WorkerFactory
|
||||
import androidx.work.testing.WorkManagerTestInitHelper
|
||||
import org.jetbrains.annotations.TestOnly
|
||||
import org.junit.Assert.assertTrue
|
||||
import java.util.concurrent.CountDownLatch
|
||||
import java.util.concurrent.TimeUnit
|
||||
import java.util.concurrent.TimeoutException
|
||||
import kotlin.math.abs
|
||||
|
||||
object TestUtils {
|
||||
@@ -25,29 +27,14 @@ object TestUtils {
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Initializes WorkManager for instrumentation tests.
|
||||
*/
|
||||
fun setUpWorkManager(context: Context, workerFactory: WorkerFactory? = null) {
|
||||
val config = Configuration.Builder().setMinimumLoggingLevel(Log.DEBUG)
|
||||
if (workerFactory != null)
|
||||
config.setWorkerFactory(workerFactory)
|
||||
WorkManagerTestInitHelper.initializeTestWorkManager(context, config.build())
|
||||
}
|
||||
|
||||
fun workInStates(context: Context, workerName: String, states: List<WorkInfo.State>): Boolean =
|
||||
WorkManager.getInstance(context).getWorkInfos(WorkQuery.Builder
|
||||
.fromUniqueWorkNames(listOf(workerName))
|
||||
.addStates(states)
|
||||
.build()
|
||||
).get().isNotEmpty()
|
||||
|
||||
@TestOnly
|
||||
fun workScheduledOrRunning(context: Context, workerName: String): Boolean =
|
||||
workInStates(context, workerName, listOf(
|
||||
WorkInfo.State.ENQUEUED,
|
||||
WorkInfo.State.RUNNING
|
||||
))
|
||||
|
||||
@TestOnly
|
||||
fun workScheduledOrRunningOrSuccessful(context: Context, workerName: String): Boolean =
|
||||
workInStates(context, workerName, listOf(
|
||||
WorkInfo.State.ENQUEUED,
|
||||
@@ -55,4 +42,41 @@ object TestUtils {
|
||||
WorkInfo.State.SUCCEEDED
|
||||
))
|
||||
|
||||
@TestOnly
|
||||
fun workInStates(context: Context, workerName: String, states: List<WorkInfo.State>): Boolean =
|
||||
WorkManager.getInstance(context).getWorkInfos(WorkQuery.Builder
|
||||
.fromUniqueWorkNames(listOf(workerName))
|
||||
.addStates(states)
|
||||
.build()
|
||||
).get().isNotEmpty()
|
||||
|
||||
|
||||
/* Copyright 2019 Google LLC.
|
||||
SPDX-License-Identifier: Apache-2.0 */
|
||||
@TestOnly
|
||||
fun <T> LiveData<T>.getOrAwaitValue(
|
||||
time: Long = 2,
|
||||
timeUnit: TimeUnit = TimeUnit.SECONDS
|
||||
): T {
|
||||
var data: T? = null
|
||||
val latch = CountDownLatch(1)
|
||||
val observer = object : Observer<T> {
|
||||
override fun onChanged(value: T) {
|
||||
data = value
|
||||
latch.countDown()
|
||||
this@getOrAwaitValue.removeObserver(this)
|
||||
}
|
||||
}
|
||||
|
||||
this.observeForever(observer)
|
||||
|
||||
// Don't wait indefinitely if the LiveData is not set.
|
||||
if (!latch.await(time, timeUnit)) {
|
||||
throw TimeoutException("LiveData value was never set.")
|
||||
}
|
||||
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
return data as T
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
/*
|
||||
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
|
||||
*/
|
||||
|
||||
package at.bitfire.davdroid.db
|
||||
|
||||
import android.content.Context
|
||||
import androidx.room.Room
|
||||
import androidx.room.testing.MigrationTestHelper
|
||||
import androidx.sqlite.db.framework.FrameworkSQLiteOpenHelperFactory
|
||||
import androidx.test.platform.app.InstrumentationRegistry
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
|
||||
class AppDatabaseTest {
|
||||
|
||||
val TEST_DB = "test"
|
||||
|
||||
val context: Context = InstrumentationRegistry.getInstrumentation().targetContext
|
||||
|
||||
@Rule
|
||||
@JvmField
|
||||
val helper = MigrationTestHelper(
|
||||
InstrumentationRegistry.getInstrumentation(),
|
||||
AppDatabase::class.java,
|
||||
listOf(), // no auto migrations until v8
|
||||
FrameworkSQLiteOpenHelperFactory()
|
||||
)
|
||||
|
||||
|
||||
@Test
|
||||
fun testAllMigrations() {
|
||||
// DB schema is available since version 8, so create DB with v8
|
||||
helper.createDatabase(TEST_DB, 8).close()
|
||||
|
||||
val db = Room.databaseBuilder(context, AppDatabase::class.java, TEST_DB)
|
||||
// manual migrations
|
||||
.addMigrations(*AppDatabase.migrations)
|
||||
// auto-migrations that need to be specified explicitly
|
||||
.addAutoMigrationSpec(AppDatabase.AutoMigration11_12(context))
|
||||
.build()
|
||||
try {
|
||||
// open (with version 8) + migrate (to current version) database
|
||||
db.openHelper.writableDatabase
|
||||
} finally {
|
||||
db.close()
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
/*
|
||||
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
|
||||
*/
|
||||
|
||||
package at.bitfire.davdroid.db
|
||||
|
||||
import android.security.NetworkSecurityPolicy
|
||||
import androidx.test.filters.SmallTest
|
||||
import androidx.test.platform.app.InstrumentationRegistry
|
||||
import at.bitfire.dav4jvm.DavResource
|
||||
import at.bitfire.dav4jvm.property.webdav.ResourceType
|
||||
import at.bitfire.davdroid.network.HttpClient
|
||||
import at.bitfire.davdroid.settings.SettingsManager
|
||||
import dagger.hilt.android.testing.HiltAndroidRule
|
||||
import dagger.hilt.android.testing.HiltAndroidTest
|
||||
import okhttp3.HttpUrl.Companion.toHttpUrl
|
||||
import okhttp3.mockwebserver.MockResponse
|
||||
import okhttp3.mockwebserver.MockWebServer
|
||||
import org.junit.After
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Assume
|
||||
import org.junit.Before
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import javax.inject.Inject
|
||||
|
||||
@HiltAndroidTest
|
||||
class CollectionTest {
|
||||
|
||||
@get:Rule
|
||||
val hiltRule = HiltAndroidRule(this)
|
||||
|
||||
@Inject
|
||||
lateinit var settingsManager: SettingsManager
|
||||
|
||||
@Before
|
||||
fun inject() {
|
||||
hiltRule.inject()
|
||||
}
|
||||
|
||||
|
||||
private lateinit var httpClient: HttpClient
|
||||
private val server = MockWebServer()
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
httpClient = HttpClient.Builder(InstrumentationRegistry.getInstrumentation().targetContext).build()
|
||||
Assume.assumeTrue(NetworkSecurityPolicy.getInstance().isCleartextTrafficPermitted)
|
||||
}
|
||||
|
||||
@After
|
||||
fun shutDown() {
|
||||
httpClient.close()
|
||||
}
|
||||
|
||||
|
||||
@Test
|
||||
@SmallTest
|
||||
fun testFromDavResponseAddressBook() {
|
||||
// r/w address book
|
||||
server.enqueue(MockResponse()
|
||||
.setResponseCode(207)
|
||||
.setBody("<multistatus xmlns='DAV:' xmlns:CARD='urn:ietf:params:xml:ns:carddav'>" +
|
||||
"<response>" +
|
||||
" <href>/</href>" +
|
||||
" <propstat><prop>" +
|
||||
" <resourcetype><collection/><CARD:addressbook/></resourcetype>" +
|
||||
" <displayname>My Contacts</displayname>" +
|
||||
" <CARD:addressbook-description>My Contacts Description</CARD:addressbook-description>" +
|
||||
" </prop></propstat>" +
|
||||
"</response>" +
|
||||
"</multistatus>"))
|
||||
|
||||
lateinit var info: Collection
|
||||
DavResource(httpClient.okHttpClient, server.url("/"))
|
||||
.propfind(0, ResourceType.NAME) { response, _ ->
|
||||
info = Collection.fromDavResponse(response) ?: throw IllegalArgumentException()
|
||||
}
|
||||
assertEquals(Collection.TYPE_ADDRESSBOOK, info.type)
|
||||
assertTrue(info.privWriteContent)
|
||||
assertTrue(info.privUnbind)
|
||||
assertNull(info.supportsVEVENT)
|
||||
assertNull(info.supportsVTODO)
|
||||
assertNull(info.supportsVJOURNAL)
|
||||
assertEquals("My Contacts", info.displayName)
|
||||
assertEquals("My Contacts Description", info.description)
|
||||
}
|
||||
|
||||
@Test
|
||||
@SmallTest
|
||||
fun testFromDavResponseCalendar() {
|
||||
// read-only calendar, no display name
|
||||
server.enqueue(MockResponse()
|
||||
.setResponseCode(207)
|
||||
.setBody("<multistatus xmlns='DAV:' xmlns:CAL='urn:ietf:params:xml:ns:caldav' xmlns:ICAL='http://apple.com/ns/ical/'>" +
|
||||
"<response>" +
|
||||
" <href>/</href>" +
|
||||
" <propstat><prop>" +
|
||||
" <resourcetype><collection/><CAL:calendar/></resourcetype>" +
|
||||
" <current-user-privilege-set><privilege><read/></privilege></current-user-privilege-set>" +
|
||||
" <CAL:calendar-description>My Calendar</CAL:calendar-description>" +
|
||||
" <CAL:calendar-timezone>tzdata</CAL:calendar-timezone>" +
|
||||
" <ICAL:calendar-color>#ff0000</ICAL:calendar-color>" +
|
||||
" </prop></propstat>" +
|
||||
"</response>" +
|
||||
"</multistatus>"))
|
||||
|
||||
lateinit var info: Collection
|
||||
DavResource(httpClient.okHttpClient, server.url("/"))
|
||||
.propfind(0, ResourceType.NAME) { response, _ ->
|
||||
info = Collection.fromDavResponse(response) ?: throw IllegalArgumentException()
|
||||
}
|
||||
assertEquals(Collection.TYPE_CALENDAR, info.type)
|
||||
assertFalse(info.privWriteContent)
|
||||
assertFalse(info.privUnbind)
|
||||
assertNull(info.displayName)
|
||||
assertEquals("My Calendar", info.description)
|
||||
assertEquals(0xFFFF0000.toInt(), info.color)
|
||||
assertEquals("tzdata", info.timezone)
|
||||
assertTrue(info.supportsVEVENT!!)
|
||||
assertTrue(info.supportsVTODO!!)
|
||||
assertTrue(info.supportsVJOURNAL!!)
|
||||
}
|
||||
|
||||
@Test
|
||||
@SmallTest
|
||||
fun testFromDavResponseWebcal() {
|
||||
// Webcal subscription
|
||||
server.enqueue(MockResponse()
|
||||
.setResponseCode(207)
|
||||
.setBody("<multistatus xmlns='DAV:' xmlns:CS='http://calendarserver.org/ns/'>" +
|
||||
"<response>" +
|
||||
" <href>/webcal1</href>" +
|
||||
" <propstat><prop>" +
|
||||
" <displayname>Sample Subscription</displayname>" +
|
||||
" <resourcetype><collection/><CS:subscribed/></resourcetype>" +
|
||||
" <CS:source><href>webcals://example.com/1.ics</href></CS:source>" +
|
||||
" </prop></propstat>" +
|
||||
"</response>" +
|
||||
"</multistatus>"))
|
||||
|
||||
lateinit var info: Collection
|
||||
DavResource(httpClient.okHttpClient, server.url("/"))
|
||||
.propfind(0, ResourceType.NAME) { response, _ ->
|
||||
info = Collection.fromDavResponse(response) ?: throw IllegalArgumentException()
|
||||
}
|
||||
assertEquals(Collection.TYPE_WEBCAL, info.type)
|
||||
assertEquals("Sample Subscription", info.displayName)
|
||||
assertEquals("https://example.com/1.ics".toHttpUrl(), info.source)
|
||||
}
|
||||
|
||||
}
|
||||
@@ -6,7 +6,6 @@ package at.bitfire.davdroid.db
|
||||
|
||||
import android.content.Context
|
||||
import androidx.room.Room
|
||||
import androidx.room.migration.AutoMigrationSpec
|
||||
import dagger.Module
|
||||
import dagger.Provides
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
@@ -25,17 +24,10 @@ class MemoryDbModule {
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun inMemoryDatabase(
|
||||
autoMigrations: Set<@JvmSuppressWildcards AutoMigrationSpec>,
|
||||
@ApplicationContext context: Context
|
||||
): AppDatabase =
|
||||
fun inMemoryDatabase(@ApplicationContext context: Context): AppDatabase =
|
||||
Room.inMemoryDatabaseBuilder(context, AppDatabase::class.java)
|
||||
// auto-migration specs that need to be specified explicitly
|
||||
.apply {
|
||||
for (spec in autoMigrations) {
|
||||
addAutoMigrationSpec(spec)
|
||||
}
|
||||
}
|
||||
// auto-migrations that need to be specified explicitly
|
||||
.addAutoMigrationSpec(AppDatabase.AutoMigration11_12(context))
|
||||
.build()
|
||||
|
||||
}
|
||||
@@ -19,12 +19,12 @@ class Android10ResolverTest {
|
||||
val FQDN_DAVX5 = "www.davx5.com"
|
||||
|
||||
@Test
|
||||
@SdkSuppress(minSdkVersion = Build.VERSION_CODES.Q, maxSdkVersion = 34)
|
||||
@SdkSuppress(minSdkVersion = Build.VERSION_CODES.Q)
|
||||
fun testResolveA() {
|
||||
val www = InetAddress.getAllByName(FQDN_DAVX5).filterIsInstance<Inet4Address>().first()
|
||||
val www = InetAddress.getAllByName(FQDN_DAVX5).filterIsInstance(Inet4Address::class.java).first()
|
||||
|
||||
val srvLookup = Lookup(FQDN_DAVX5, Type.A)
|
||||
srvLookup.setResolver(Android10Resolver())
|
||||
srvLookup.setResolver(Android10Resolver)
|
||||
val resultGeneric = srvLookup.run()
|
||||
assertEquals(1, resultGeneric.size)
|
||||
|
||||
@@ -0,0 +1,154 @@
|
||||
/*
|
||||
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
|
||||
*/
|
||||
|
||||
package at.bitfire.davdroid.network
|
||||
|
||||
import android.net.ConnectivityManager
|
||||
import android.net.Network
|
||||
import android.net.NetworkCapabilities
|
||||
import android.net.NetworkCapabilities.NET_CAPABILITY_INTERNET
|
||||
import android.net.NetworkCapabilities.NET_CAPABILITY_NOT_VPN
|
||||
import android.net.NetworkCapabilities.NET_CAPABILITY_VALIDATED
|
||||
import android.net.NetworkCapabilities.TRANSPORT_WIFI
|
||||
import dagger.hilt.android.testing.HiltAndroidTest
|
||||
import io.mockk.every
|
||||
import io.mockk.junit4.MockKRule
|
||||
import io.mockk.mockk
|
||||
import junit.framework.TestCase.assertFalse
|
||||
import junit.framework.TestCase.assertTrue
|
||||
import org.junit.Before
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
|
||||
@HiltAndroidTest
|
||||
class ConnectionUtilsTest {
|
||||
|
||||
@get:Rule
|
||||
val mockkRule = MockKRule(this)
|
||||
|
||||
private val connectivityManager = mockk<ConnectivityManager>()
|
||||
private val network1 = mockk<Network>()
|
||||
private val network2 = mockk<Network>()
|
||||
private val capabilities = mockk<NetworkCapabilities>()
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
every { connectivityManager.allNetworks } returns arrayOf(network1, network2)
|
||||
every { connectivityManager.getNetworkInfo(network1) } returns mockk()
|
||||
every { connectivityManager.getNetworkInfo(network2) } returns mockk()
|
||||
every { connectivityManager.getNetworkCapabilities(network1) } returns capabilities
|
||||
every { connectivityManager.getNetworkCapabilities(network2) } returns capabilities
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testWifiAvailable_capabilitiesNull() {
|
||||
every { connectivityManager.getNetworkCapabilities(network1) } returns null
|
||||
every { connectivityManager.getNetworkCapabilities(network2) } returns null
|
||||
assertFalse(ConnectionUtils.wifiAvailable(connectivityManager))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testWifiAvailable() {
|
||||
every { capabilities.hasTransport(TRANSPORT_WIFI) } returns false
|
||||
every { capabilities.hasCapability(NET_CAPABILITY_VALIDATED) } returns false
|
||||
assertFalse(ConnectionUtils.wifiAvailable(connectivityManager))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testWifiAvailable_wifi() {
|
||||
every { capabilities.hasTransport(TRANSPORT_WIFI) } returns true
|
||||
every { capabilities.hasCapability(NET_CAPABILITY_VALIDATED) } returns false
|
||||
assertFalse(ConnectionUtils.wifiAvailable(connectivityManager))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testWifiAvailable_validated() {
|
||||
every { capabilities.hasTransport(TRANSPORT_WIFI) } returns false
|
||||
every { capabilities.hasCapability(NET_CAPABILITY_VALIDATED) } returns true
|
||||
assertFalse(ConnectionUtils.wifiAvailable(connectivityManager))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testWifiAvailable_wifiValidated() {
|
||||
every { capabilities.hasTransport(TRANSPORT_WIFI) } returns true
|
||||
every { capabilities.hasCapability(NET_CAPABILITY_VALIDATED) } returns true
|
||||
assertTrue(ConnectionUtils.wifiAvailable(connectivityManager))
|
||||
}
|
||||
|
||||
|
||||
@Test
|
||||
fun testInternetAvailable_capabilitiesNull() {
|
||||
every { connectivityManager.getNetworkCapabilities(network1) } returns null
|
||||
every { connectivityManager.getNetworkCapabilities(network2) } returns null
|
||||
assertFalse(ConnectionUtils.internetAvailable(connectivityManager, false))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testInternetAvailable_Internet() {
|
||||
every { capabilities.hasCapability(NET_CAPABILITY_INTERNET) } returns true
|
||||
every { capabilities.hasCapability(NET_CAPABILITY_VALIDATED) } returns false
|
||||
assertFalse(ConnectionUtils.internetAvailable(connectivityManager, false))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testInternetAvailable_Validated() {
|
||||
every { capabilities.hasCapability(NET_CAPABILITY_INTERNET) } returns false
|
||||
every { capabilities.hasCapability(NET_CAPABILITY_VALIDATED) } returns true
|
||||
assertFalse(ConnectionUtils.internetAvailable(connectivityManager, false))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testInternetAvailable_InternetValidated() {
|
||||
every { capabilities.hasCapability(NET_CAPABILITY_INTERNET) } returns true
|
||||
every { capabilities.hasCapability(NET_CAPABILITY_VALIDATED) } returns true
|
||||
assertTrue(ConnectionUtils.internetAvailable(connectivityManager, false))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testInternetAvailable_ignoreVpns() {
|
||||
every { capabilities.hasCapability(NET_CAPABILITY_INTERNET) } returns true
|
||||
every { capabilities.hasCapability(NET_CAPABILITY_VALIDATED) } returns true
|
||||
every { capabilities.hasCapability(NET_CAPABILITY_NOT_VPN) } returns false
|
||||
assertFalse(ConnectionUtils.internetAvailable(connectivityManager, true))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testInternetAvailable_ignoreVpns_Notvpn() {
|
||||
every { capabilities.hasCapability(NET_CAPABILITY_INTERNET) } returns true
|
||||
every { capabilities.hasCapability(NET_CAPABILITY_VALIDATED) } returns true
|
||||
every { capabilities.hasCapability(NET_CAPABILITY_NOT_VPN) } returns true
|
||||
assertTrue(ConnectionUtils.internetAvailable(connectivityManager, true))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testInternetAvailable_twoConnectionsFirstOneWithoutInternet() {
|
||||
// The real case that failed in davx5-ose#395 is that the connection list contains (in this order)
|
||||
// 1. a mobile network without INTERNET, but with VALIDATED
|
||||
// 2. a WiFi network with INTERNET and VALIDATED
|
||||
|
||||
// The "return false" of hasINTERNET will trigger at the first connection, the
|
||||
// "andThen true" will trigger for the second connection
|
||||
every { capabilities.hasCapability(NET_CAPABILITY_INTERNET) } returns false andThen true
|
||||
every { capabilities.hasCapability(NET_CAPABILITY_VALIDATED) } returns true
|
||||
|
||||
// There is an internet connection if any(!) connection has both INTERNET and VALIDATED.
|
||||
assertTrue(ConnectionUtils.internetAvailable(connectivityManager, false))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testInternetAvailable_twoConnectionsFirstOneWithoutValidated() {
|
||||
every { capabilities.hasCapability(NET_CAPABILITY_INTERNET) } returns true
|
||||
every { capabilities.hasCapability(NET_CAPABILITY_VALIDATED) } returns false andThen true
|
||||
assertTrue(ConnectionUtils.internetAvailable(connectivityManager, false))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testInternetAvailable_twoConnectionsFirstOneWithoutNotvpn() {
|
||||
every { capabilities.hasCapability(NET_CAPABILITY_INTERNET) } returns true
|
||||
every { capabilities.hasCapability(NET_CAPABILITY_VALIDATED) } returns true
|
||||
every { capabilities.hasCapability(NET_CAPABILITY_NOT_VPN) } returns false andThen true
|
||||
assertTrue(ConnectionUtils.internetAvailable(connectivityManager, true))
|
||||
}
|
||||
|
||||
}
|
||||
@@ -4,39 +4,36 @@
|
||||
|
||||
package at.bitfire.davdroid.network
|
||||
|
||||
import android.security.NetworkSecurityPolicy
|
||||
import androidx.test.platform.app.InstrumentationRegistry
|
||||
import dagger.hilt.android.testing.HiltAndroidRule
|
||||
import dagger.hilt.android.testing.HiltAndroidTest
|
||||
import io.ktor.client.request.get
|
||||
import io.ktor.client.statement.bodyAsText
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import okhttp3.Request
|
||||
import okhttp3.mockwebserver.MockResponse
|
||||
import okhttp3.mockwebserver.MockWebServer
|
||||
import org.junit.After
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Assume
|
||||
import org.junit.Before
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Provider
|
||||
|
||||
@HiltAndroidTest
|
||||
class HttpClientBuilderTest {
|
||||
class HttpClientTest {
|
||||
|
||||
lateinit var server: MockWebServer
|
||||
lateinit var httpClient: HttpClient
|
||||
|
||||
@get:Rule
|
||||
var hiltRule = HiltAndroidRule(this)
|
||||
|
||||
@Inject
|
||||
lateinit var httpClientBuilder: Provider<HttpClientBuilder>
|
||||
|
||||
lateinit var server: MockWebServer
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
hiltRule.inject()
|
||||
|
||||
httpClient = HttpClient.Builder(InstrumentationRegistry.getInstrumentation().targetContext).build()
|
||||
|
||||
server = MockWebServer()
|
||||
server.start(30000)
|
||||
}
|
||||
@@ -44,32 +41,13 @@ class HttpClientBuilderTest {
|
||||
@After
|
||||
fun tearDown() {
|
||||
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
|
||||
fun testCookies() {
|
||||
Assume.assumeTrue(NetworkSecurityPolicy.getInstance().isCleartextTrafficPermitted)
|
||||
val url = server.url("/test")
|
||||
|
||||
// set cookie for root path (/) and /test path in first response
|
||||
@@ -78,9 +56,7 @@ class HttpClientBuilderTest {
|
||||
.addHeader("Set-Cookie", "cookie1=1; path=/")
|
||||
.addHeader("Set-Cookie", "cookie2=2")
|
||||
.setBody("Cookie set"))
|
||||
|
||||
val httpClient = httpClientBuilder.get().build()
|
||||
httpClient.newCall(Request.Builder()
|
||||
httpClient.okHttpClient.newCall(Request.Builder()
|
||||
.get().url(url)
|
||||
.build()).execute()
|
||||
assertNull(server.takeRequest().getHeader("Cookie"))
|
||||
@@ -91,15 +67,14 @@ class HttpClientBuilderTest {
|
||||
.addHeader("Set-Cookie", "cookie1=1a; path=/; Max-Age=0")
|
||||
.addHeader("Set-Cookie", "cookie2=2a")
|
||||
.setResponseCode(200))
|
||||
httpClient.newCall(Request.Builder()
|
||||
httpClient.okHttpClient.newCall(Request.Builder()
|
||||
.get().url(url)
|
||||
.build()).execute()
|
||||
val header = server.takeRequest().getHeader("Cookie")
|
||||
assertTrue(header == "cookie1=1; cookie2=2" || header == "cookie2=2; cookie1=1")
|
||||
assertEquals("cookie2=2; cookie1=1", server.takeRequest().getHeader("Cookie"))
|
||||
|
||||
server.enqueue(MockResponse()
|
||||
.setResponseCode(200))
|
||||
httpClient.newCall(Request.Builder()
|
||||
httpClient.okHttpClient.newCall(Request.Builder()
|
||||
.get().url(url)
|
||||
.build()).execute()
|
||||
assertEquals("cookie2=2a", server.takeRequest().getHeader("Cookie"))
|
||||
@@ -0,0 +1,79 @@
|
||||
package at.bitfire.davdroid.repository
|
||||
|
||||
import androidx.test.platform.app.InstrumentationRegistry
|
||||
import at.bitfire.davdroid.db.AppDatabase
|
||||
import at.bitfire.davdroid.db.Collection
|
||||
import at.bitfire.davdroid.db.Service
|
||||
import dagger.hilt.android.testing.HiltAndroidRule
|
||||
import dagger.hilt.android.testing.HiltAndroidTest
|
||||
import io.mockk.mockk
|
||||
import io.mockk.verify
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import okhttp3.HttpUrl.Companion.toHttpUrl
|
||||
import org.junit.After
|
||||
import org.junit.Before
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import javax.inject.Inject
|
||||
|
||||
@HiltAndroidTest
|
||||
class DavCollectionRepositoryTest {
|
||||
|
||||
@get:Rule
|
||||
var hiltRule = HiltAndroidRule(this)
|
||||
|
||||
@Inject
|
||||
lateinit var serviceRepository: DavServiceRepository
|
||||
|
||||
@Inject
|
||||
lateinit var db: AppDatabase
|
||||
|
||||
val context = InstrumentationRegistry.getInstrumentation().targetContext
|
||||
|
||||
var service: Service? = null
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
hiltRule.inject()
|
||||
service = createTestService(Service.TYPE_CARDDAV)!!
|
||||
}
|
||||
|
||||
@After
|
||||
fun cleanUp() {
|
||||
db.close()
|
||||
serviceRepository.deleteAll()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testOnChangeListener_setForceReadOnly() = runBlocking {
|
||||
val collectionId = db.collectionDao().insertOrUpdateByUrl(
|
||||
Collection(
|
||||
serviceId = service!!.id,
|
||||
type = Collection.TYPE_ADDRESSBOOK,
|
||||
url = "https://example.com".toHttpUrl(),
|
||||
forceReadOnly = false,
|
||||
)
|
||||
)
|
||||
val testObserver = mockk<DavCollectionRepository.OnChangeListener>(relaxed = true)
|
||||
val collectionRepository = DavCollectionRepository(context, mutableSetOf(testObserver), serviceRepository, db)
|
||||
|
||||
assert(db.collectionDao().get(collectionId)?.forceReadOnly == false)
|
||||
verify(exactly = 0) {
|
||||
testObserver.onCollectionsChanged()
|
||||
}
|
||||
collectionRepository.setForceReadOnly(collectionId, true)
|
||||
assert(db.collectionDao().get(collectionId)?.forceReadOnly == true)
|
||||
verify(exactly = 1) {
|
||||
testObserver.onCollectionsChanged()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Test helpers and dependencies
|
||||
|
||||
private fun createTestService(serviceType: String) : Service? {
|
||||
val service = Service(id=0, accountName="test", type=serviceType, principal = null)
|
||||
val serviceId = serviceRepository.insertOrReplace(service)
|
||||
return serviceRepository.get(serviceId)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
/*
|
||||
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
|
||||
*/
|
||||
|
||||
package at.bitfire.davdroid.repository
|
||||
|
||||
import at.bitfire.davdroid.db.HomeSet
|
||||
import at.bitfire.davdroid.db.Service
|
||||
import dagger.hilt.android.testing.HiltAndroidRule
|
||||
import dagger.hilt.android.testing.HiltAndroidTest
|
||||
import okhttp3.HttpUrl.Companion.toHttpUrl
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Before
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import javax.inject.Inject
|
||||
|
||||
@HiltAndroidTest
|
||||
class DavHomeSetRepositoryTest {
|
||||
|
||||
@get:Rule
|
||||
var hiltRule = HiltAndroidRule(this)
|
||||
|
||||
@Inject
|
||||
lateinit var repository: DavHomeSetRepository
|
||||
|
||||
@Inject
|
||||
lateinit var serviceRepository: DavServiceRepository
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
hiltRule.inject()
|
||||
}
|
||||
|
||||
|
||||
@Test
|
||||
fun testInsertOrUpdate() {
|
||||
// should insert new row or update (upsert) existing row - without changing its key!
|
||||
val serviceId = createTestService()
|
||||
|
||||
val entry1 = HomeSet(id=0, serviceId=serviceId, personal=true, url="https://example.com/1".toHttpUrl())
|
||||
val insertId1 = repository.insertOrUpdateByUrl(entry1)
|
||||
assertEquals(1L, insertId1)
|
||||
assertEquals(entry1.apply { id = 1L }, repository.getById(1L))
|
||||
|
||||
val updatedEntry1 = HomeSet(id=0, serviceId=serviceId, personal=true, url="https://example.com/1".toHttpUrl(), displayName="Updated Entry")
|
||||
val updateId1 = repository.insertOrUpdateByUrl(updatedEntry1)
|
||||
assertEquals(1L, updateId1)
|
||||
assertEquals(updatedEntry1.apply { id = 1L }, repository.getById(1L))
|
||||
|
||||
val entry2 = HomeSet(id=0, serviceId=serviceId, personal=true, url= "https://example.com/2".toHttpUrl())
|
||||
val insertId2 = repository.insertOrUpdateByUrl(entry2)
|
||||
assertEquals(2L, insertId2)
|
||||
assertEquals(entry2.apply { id = 2L }, repository.getById(2L))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testDelete() {
|
||||
// should delete row with given primary key (id)
|
||||
val serviceId = createTestService()
|
||||
|
||||
val entry1 = HomeSet(id=1, serviceId=serviceId, personal=true, url= "https://example.com/1".toHttpUrl())
|
||||
|
||||
val insertId1 = repository.insertOrUpdateByUrl(entry1)
|
||||
assertEquals(1L, insertId1)
|
||||
assertEquals(entry1, repository.getById(1L))
|
||||
|
||||
repository.delete(entry1)
|
||||
assertEquals(null, repository.getById(1L))
|
||||
}
|
||||
|
||||
|
||||
private fun createTestService() : Long {
|
||||
val service = Service(id=0, accountName="test", type= Service.TYPE_CALDAV, principal = null)
|
||||
return serviceRepository.insertOrReplace(service)
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
/*
|
||||
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
|
||||
*/
|
||||
|
||||
package at.bitfire.davdroid.resource
|
||||
|
||||
import android.accounts.Account
|
||||
import android.accounts.AccountManager
|
||||
import androidx.arch.core.executor.testing.InstantTaskExecutorRule
|
||||
import androidx.test.platform.app.InstrumentationRegistry
|
||||
import at.bitfire.davdroid.R
|
||||
import dagger.hilt.android.testing.HiltAndroidRule
|
||||
import dagger.hilt.android.testing.HiltAndroidTest
|
||||
import org.junit.After
|
||||
import org.junit.Before
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
|
||||
@HiltAndroidTest
|
||||
class LocalAddressBookTest {
|
||||
|
||||
@get:Rule
|
||||
val hiltRule = HiltAndroidRule(this)
|
||||
|
||||
val context = InstrumentationRegistry.getInstrumentation().targetContext
|
||||
|
||||
val mainAccountType = context.getString(R.string.account_type)
|
||||
val mainAccount = Account("main", mainAccountType)
|
||||
|
||||
val addressBookAccountType = context.getString(R.string.account_type_address_book)
|
||||
val addressBookAccount = Account("sub", addressBookAccountType)
|
||||
|
||||
val accountManager = AccountManager.get(context)
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
hiltRule.inject()
|
||||
|
||||
// TODO DOES NOT WORK: the account immediately starts to sync, which creates the sync adapter services.
|
||||
// The services however can't be created because Hilt is "not ready" (although it has been initialized in the line above).
|
||||
// assertTrue(AccountUtils.createAccount(context, mainAccount, AccountSettings.initialUserData(null)))
|
||||
}
|
||||
|
||||
@After
|
||||
fun cleanup() {
|
||||
accountManager.removeAccount(addressBookAccount, null, null)
|
||||
accountManager.removeAccount(mainAccount, null, null)
|
||||
}
|
||||
|
||||
|
||||
// TODO see above
|
||||
/*@Test
|
||||
fun testMainAccount_AddressBookAccount_WithMainAccount() {
|
||||
// create address book account
|
||||
assertTrue(accountManager.addAccountExplicitly(addressBookAccount, null, Bundle().apply {
|
||||
putString(LocalAddressBook.USER_DATA_MAIN_ACCOUNT_NAME, mainAccount.name)
|
||||
putString(LocalAddressBook.USER_DATA_MAIN_ACCOUNT_TYPE, mainAccount.type)
|
||||
}))
|
||||
|
||||
// check mainAccount()
|
||||
assertEquals(mainAccount, LocalAddressBook.mainAccount(context, addressBookAccount))
|
||||
}
|
||||
|
||||
@Test(expected = IllegalArgumentException::class)
|
||||
fun testMainAccount_AddressBookAccount_WithoutMainAccount() {
|
||||
// create address book account
|
||||
assertTrue(accountManager.addAccountExplicitly(addressBookAccount, null, Bundle()))
|
||||
|
||||
// check mainAccount(); should fail because there's no main account
|
||||
LocalAddressBook.mainAccount(context, addressBookAccount)
|
||||
}*/
|
||||
|
||||
@Test(expected = IllegalArgumentException::class)
|
||||
fun testMainAccount_OtherAccount() {
|
||||
LocalAddressBook.mainAccount(context, Account("Other Account", "com.example"))
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
/*
|
||||
* 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.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 at.bitfire.davdroid.InitCalendarProviderRule
|
||||
import at.bitfire.ical4android.AndroidCalendar
|
||||
import at.bitfire.ical4android.Event
|
||||
import at.bitfire.ical4android.util.MiscUtils.closeCompat
|
||||
import at.bitfire.ical4android.util.MiscUtils.asSyncAdapter
|
||||
import net.fortuna.ical4j.model.property.DtStart
|
||||
import net.fortuna.ical4j.model.property.RRule
|
||||
import net.fortuna.ical4j.model.property.RecurrenceId
|
||||
import net.fortuna.ical4j.model.property.Status
|
||||
import org.junit.*
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.rules.TestRule
|
||||
|
||||
class LocalCalendarTest {
|
||||
|
||||
companion object {
|
||||
@JvmField
|
||||
@ClassRule
|
||||
val initCalendarProviderRule: TestRule = InitCalendarProviderRule.getInstance()
|
||||
|
||||
private lateinit var provider: ContentProviderClient
|
||||
|
||||
@BeforeClass
|
||||
@JvmStatic
|
||||
fun setUpProvider() {
|
||||
val context = InstrumentationRegistry.getInstrumentation().targetContext
|
||||
provider = context.contentResolver.acquireContentProviderClient(CalendarContract.AUTHORITY)!!
|
||||
}
|
||||
|
||||
@AfterClass
|
||||
@JvmStatic
|
||||
fun closeProvider() {
|
||||
provider.closeCompat()
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
private val account = Account("LocalCalendarTest", ACCOUNT_TYPE_LOCAL)
|
||||
private lateinit var calendar: LocalCalendar
|
||||
|
||||
@Before
|
||||
fun prepare() {
|
||||
val uri = AndroidCalendar.create(account, provider, ContentValues())
|
||||
calendar = AndroidCalendar.findByID(account, provider, LocalCalendar.Factory, ContentUris.parseId(uri))
|
||||
}
|
||||
|
||||
@After
|
||||
fun shutdown() {
|
||||
calendar.delete()
|
||||
}
|
||||
|
||||
|
||||
@Test
|
||||
fun testDeleteDirtyEventsWithoutInstances_NoInstances_CancelledExceptions() {
|
||||
// create recurring event with only deleted/cancelled instances
|
||||
val event = Event().apply {
|
||||
dtStart = DtStart("20220120T010203Z")
|
||||
summary = "Event with 3 instances"
|
||||
rRules.add(RRule("FREQ=DAILY;COUNT=3"))
|
||||
exceptions.add(Event().apply {
|
||||
recurrenceId = RecurrenceId("20220120T010203Z")
|
||||
dtStart = DtStart("20220120T010203Z")
|
||||
summary = "Cancelled exception on 1st day"
|
||||
status = Status.VEVENT_CANCELLED
|
||||
})
|
||||
exceptions.add(Event().apply {
|
||||
recurrenceId = RecurrenceId("20220121T010203Z")
|
||||
dtStart = DtStart("20220121T010203Z")
|
||||
summary = "Cancelled exception on 2nd day"
|
||||
status = Status.VEVENT_CANCELLED
|
||||
})
|
||||
exceptions.add(Event().apply {
|
||||
recurrenceId = RecurrenceId("20220122T010203Z")
|
||||
dtStart = DtStart("20220122T010203Z")
|
||||
summary = "Cancelled exception on 3rd day"
|
||||
status = Status.VEVENT_CANCELLED
|
||||
})
|
||||
}
|
||||
val localEvent = LocalEvent(calendar, event, "filename.ics", null, null, LocalResource.FLAG_REMOTELY_PRESENT)
|
||||
localEvent.add()
|
||||
val eventId = localEvent.id!!
|
||||
|
||||
// set event as dirty
|
||||
provider.update(ContentUris.withAppendedId(Events.CONTENT_URI.asSyncAdapter(account), eventId), ContentValues(1).apply {
|
||||
put(Events.DIRTY, 1)
|
||||
}, null, null)
|
||||
|
||||
// this method should mark the event as deleted
|
||||
calendar.deleteDirtyEventsWithoutInstances()
|
||||
|
||||
// verify that event is now marked as deleted
|
||||
provider.query(
|
||||
ContentUris.withAppendedId(Events.CONTENT_URI.asSyncAdapter(account), eventId),
|
||||
arrayOf(Events.DELETED), null, null, null
|
||||
)!!.use { cursor ->
|
||||
cursor.moveToNext()
|
||||
assertEquals(1, cursor.getInt(0))
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
// Flaky, Needs single or rec init of CalendarProvider (InitCalendarProviderRule)
|
||||
fun testDeleteDirtyEventsWithoutInstances_Recurring_Instances() {
|
||||
val event = Event().apply {
|
||||
dtStart = DtStart("20220120T010203Z")
|
||||
summary = "Event with 3 instances"
|
||||
rRules.add(RRule("FREQ=DAILY;COUNT=3"))
|
||||
}
|
||||
val localEvent = LocalEvent(calendar, event, "filename.ics", null, null, LocalResource.FLAG_REMOTELY_PRESENT)
|
||||
localEvent.add()
|
||||
val eventId = localEvent.id!!
|
||||
|
||||
// set event as dirty
|
||||
provider.update(ContentUris.withAppendedId(Events.CONTENT_URI.asSyncAdapter(account), eventId), ContentValues(1).apply {
|
||||
put(Events.DIRTY, 1)
|
||||
}, null, null)
|
||||
|
||||
// this method should mark the event as deleted
|
||||
calendar.deleteDirtyEventsWithoutInstances()
|
||||
|
||||
// verify that event is not marked as deleted
|
||||
provider.query(
|
||||
ContentUris.withAppendedId(Events.CONTENT_URI.asSyncAdapter(account), eventId),
|
||||
arrayOf(Events.DELETED), null, null, null
|
||||
)!!.use { cursor ->
|
||||
cursor.moveToNext()
|
||||
assertEquals(0, cursor.getInt(0))
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,472 @@
|
||||
/*
|
||||
* 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.ContentUris
|
||||
import android.content.ContentValues
|
||||
import android.os.Build
|
||||
import android.provider.CalendarContract
|
||||
import android.provider.CalendarContract.ACCOUNT_TYPE_LOCAL
|
||||
import android.provider.CalendarContract.Events
|
||||
import androidx.test.platform.app.InstrumentationRegistry
|
||||
import at.bitfire.davdroid.InitCalendarProviderRule
|
||||
import at.bitfire.ical4android.AndroidCalendar
|
||||
import at.bitfire.ical4android.Event
|
||||
import at.bitfire.ical4android.util.MiscUtils.closeCompat
|
||||
import at.techbee.jtx.JtxContract.asSyncAdapter
|
||||
import net.fortuna.ical4j.model.Date
|
||||
import net.fortuna.ical4j.model.DateList
|
||||
import net.fortuna.ical4j.model.parameter.Value
|
||||
import net.fortuna.ical4j.model.property.*
|
||||
import org.junit.*
|
||||
import org.junit.Assert.*
|
||||
import org.junit.rules.TestRule
|
||||
import java.util.*
|
||||
|
||||
class LocalEventTest {
|
||||
|
||||
companion object {
|
||||
|
||||
@JvmField
|
||||
@ClassRule
|
||||
val initCalendarProviderRule: TestRule = InitCalendarProviderRule.getInstance()
|
||||
|
||||
private val account = Account("LocalCalendarTest", ACCOUNT_TYPE_LOCAL)
|
||||
|
||||
private lateinit var provider: ContentProviderClient
|
||||
private lateinit var calendar: LocalCalendar
|
||||
|
||||
@BeforeClass
|
||||
@JvmStatic
|
||||
fun connect() {
|
||||
val context = InstrumentationRegistry.getInstrumentation().targetContext
|
||||
provider = context.contentResolver.acquireContentProviderClient(CalendarContract.AUTHORITY)!!
|
||||
}
|
||||
|
||||
@AfterClass
|
||||
@JvmStatic
|
||||
fun disconnect() {
|
||||
provider.closeCompat()
|
||||
}
|
||||
}
|
||||
|
||||
@Before
|
||||
fun createCalendar() {
|
||||
val uri = AndroidCalendar.create(account, provider, ContentValues())
|
||||
calendar = AndroidCalendar.findByID(account, provider, LocalCalendar.Factory, ContentUris.parseId(uri))
|
||||
}
|
||||
|
||||
@After
|
||||
fun removeCalendar() {
|
||||
calendar.delete()
|
||||
}
|
||||
|
||||
|
||||
@Test
|
||||
fun testNumDirectInstances_SingleInstance() {
|
||||
val event = Event().apply {
|
||||
dtStart = DtStart("20220120T010203Z")
|
||||
summary = "Event with 1 instance"
|
||||
}
|
||||
val localEvent = LocalEvent(calendar, event, null, null, null, 0)
|
||||
localEvent.add()
|
||||
|
||||
assertEquals(1, LocalEvent.numDirectInstances(provider, account, localEvent.id!!))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testNumDirectInstances_Recurring() {
|
||||
val event = Event().apply {
|
||||
dtStart = DtStart("20220120T010203Z")
|
||||
summary = "Event with 5 instances"
|
||||
rRules.add(RRule("FREQ=DAILY;COUNT=5"))
|
||||
}
|
||||
val localEvent = LocalEvent(calendar, event, null, null, null, 0)
|
||||
localEvent.add()
|
||||
|
||||
assertEquals(5, LocalEvent.numDirectInstances(provider, account, localEvent.id!!))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testNumDirectInstances_Recurring_Endless() {
|
||||
val event = Event().apply {
|
||||
dtStart = DtStart("20220120T010203Z")
|
||||
summary = "Event without end"
|
||||
rRules.add(RRule("FREQ=DAILY"))
|
||||
}
|
||||
val localEvent = LocalEvent(calendar, event, null, null, null, 0)
|
||||
localEvent.add()
|
||||
|
||||
assertNull(LocalEvent.numDirectInstances(provider, account, localEvent.id!!))
|
||||
}
|
||||
|
||||
@Test
|
||||
// flaky, needs InitCalendarProviderRule
|
||||
fun testNumDirectInstances_Recurring_LateEnd() {
|
||||
val event = Event().apply {
|
||||
dtStart = DtStart("20220120T010203Z")
|
||||
summary = "Event with 53 years"
|
||||
rRules.add(RRule("FREQ=YEARLY;UNTIL=20740119T010203Z")) // year 2074 is not supported by Android <11 Calendar Storage
|
||||
}
|
||||
val localEvent = LocalEvent(calendar, event, null, null, null, 0)
|
||||
localEvent.add()
|
||||
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R)
|
||||
assertEquals(52, LocalEvent.numDirectInstances(provider, account, localEvent.id!!))
|
||||
else
|
||||
assertNull(LocalEvent.numDirectInstances(provider, account, localEvent.id!!))
|
||||
}
|
||||
|
||||
@Test
|
||||
// flaky, needs InitCalendarProviderRule
|
||||
fun testNumDirectInstances_Recurring_ManyInstances() {
|
||||
val event = Event().apply {
|
||||
dtStart = DtStart("20220120T010203Z")
|
||||
summary = "Event with 2 years"
|
||||
rRules.add(RRule("FREQ=DAILY;UNTIL=20240120T010203Z"))
|
||||
}
|
||||
val localEvent = LocalEvent(calendar, event, null, null, null, 0)
|
||||
localEvent.add()
|
||||
val number = LocalEvent.numDirectInstances(provider, account, localEvent.id!!)
|
||||
|
||||
// Some android versions (i.e. <=Q and S) return 365*2 instances (wrong, 365*2+1 => correct),
|
||||
// but we are satisfied with either result for now
|
||||
assertTrue(number == 365*2 || number == 365*2+1)
|
||||
}
|
||||
|
||||
@Test
|
||||
// flaky, needs InitCalendarProviderRule
|
||||
fun testNumDirectInstances_RecurringWithExdate() {
|
||||
val event = Event().apply {
|
||||
dtStart = DtStart(Date("20220120T010203Z"))
|
||||
summary = "Event with 5 instances"
|
||||
rRules.add(RRule("FREQ=DAILY;COUNT=5"))
|
||||
exDates.add(ExDate(DateList("20220121T010203Z", Value.DATE_TIME)))
|
||||
}
|
||||
val localEvent = LocalEvent(calendar, event, null, null, null, 0)
|
||||
localEvent.add()
|
||||
|
||||
assertEquals(4, LocalEvent.numDirectInstances(provider, account, localEvent.id!!))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testNumDirectInstances_RecurringWithExceptions() {
|
||||
val event = Event().apply {
|
||||
dtStart = DtStart("20220120T010203Z")
|
||||
summary = "Event with 5 instances"
|
||||
rRules.add(RRule("FREQ=DAILY;COUNT=5"))
|
||||
exceptions.add(Event().apply {
|
||||
recurrenceId = RecurrenceId("20220122T010203Z")
|
||||
dtStart = DtStart("20220122T130203Z")
|
||||
summary = "Exception on 3rd day"
|
||||
})
|
||||
exceptions.add(Event().apply {
|
||||
recurrenceId = RecurrenceId("20220124T010203Z")
|
||||
dtStart = DtStart("20220122T160203Z")
|
||||
summary = "Exception on 5th day"
|
||||
})
|
||||
}
|
||||
val localEvent = LocalEvent(calendar, event, "filename.ics", null, null, 0)
|
||||
localEvent.add()
|
||||
|
||||
assertEquals(5-2, LocalEvent.numDirectInstances(provider, account, localEvent.id!!))
|
||||
}
|
||||
|
||||
|
||||
@Test
|
||||
// flaky, needs InitCalendarProviderRule
|
||||
fun testNumInstances_SingleInstance() {
|
||||
val event = Event().apply {
|
||||
dtStart = DtStart("20220120T010203Z")
|
||||
summary = "Event with 1 instance"
|
||||
}
|
||||
val localEvent = LocalEvent(calendar, event, null, null, null, 0)
|
||||
localEvent.add()
|
||||
|
||||
assertEquals(1, LocalEvent.numInstances(provider, account, localEvent.id!!))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testNumInstances_Recurring() {
|
||||
val event = Event().apply {
|
||||
dtStart = DtStart("20220120T010203Z")
|
||||
summary = "Event with 5 instances"
|
||||
rRules.add(RRule("FREQ=DAILY;COUNT=5"))
|
||||
}
|
||||
val localEvent = LocalEvent(calendar, event, null, null, null, 0)
|
||||
localEvent.add()
|
||||
|
||||
assertEquals(5, LocalEvent.numInstances(provider, account, localEvent.id!!))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testNumInstances_Recurring_Endless() {
|
||||
val event = Event().apply {
|
||||
dtStart = DtStart("20220120T010203Z")
|
||||
summary = "Event with infinite instances"
|
||||
rRules.add(RRule("FREQ=YEARLY"))
|
||||
}
|
||||
val localEvent = LocalEvent(calendar, event, null, null, null, 0)
|
||||
localEvent.add()
|
||||
|
||||
assertNull(LocalEvent.numInstances(provider, account, localEvent.id!!))
|
||||
}
|
||||
|
||||
@Test
|
||||
// flaky, needs InitCalendarProviderRule
|
||||
fun testNumInstances_Recurring_LateEnd() {
|
||||
val event = Event().apply {
|
||||
dtStart = DtStart("20220120T010203Z")
|
||||
summary = "Event over 22 years"
|
||||
rRules.add(RRule("FREQ=YEARLY;UNTIL=20740119T010203Z")) // year 2074 not supported by Android <11 Calendar Storage
|
||||
}
|
||||
val localEvent = LocalEvent(calendar, event, null, null, null, 0)
|
||||
localEvent.add()
|
||||
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R)
|
||||
assertEquals(52, LocalEvent.numInstances(provider, account, localEvent.id!!))
|
||||
else
|
||||
assertNull(LocalEvent.numInstances(provider, account, localEvent.id!!))
|
||||
}
|
||||
|
||||
@Test
|
||||
// flaky, needs InitCalendarProviderRule
|
||||
fun testNumInstances_Recurring_ManyInstances() {
|
||||
val event = Event().apply {
|
||||
dtStart = DtStart("20220120T010203Z")
|
||||
summary = "Event over two years"
|
||||
rRules.add(RRule("FREQ=DAILY;UNTIL=20240120T010203Z"))
|
||||
}
|
||||
val localEvent = LocalEvent(calendar, event, null, null, null, 0)
|
||||
localEvent.add()
|
||||
|
||||
assertEquals(
|
||||
if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.Q)
|
||||
365*2 // Android <10: does not include UNTIL (incorrect!)
|
||||
else
|
||||
365*2 + 1, // Android ≥10: includes UNTIL (correct)
|
||||
LocalEvent.numInstances(provider, account, localEvent.id!!))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testNumInstances_RecurringWithExceptions() {
|
||||
val event = Event().apply {
|
||||
dtStart = DtStart("20220120T010203Z")
|
||||
summary = "Event with 6 instances"
|
||||
rRules.add(RRule("FREQ=DAILY;COUNT=6"))
|
||||
exceptions.add(Event().apply {
|
||||
recurrenceId = RecurrenceId("20220122T010203Z")
|
||||
dtStart = DtStart("20220122T130203Z")
|
||||
summary = "Exception on 3rd day"
|
||||
})
|
||||
exceptions.add(Event().apply {
|
||||
recurrenceId = RecurrenceId("20220124T010203Z")
|
||||
dtStart = DtStart("20220122T160203Z")
|
||||
summary = "Exception on 5th day"
|
||||
})
|
||||
}
|
||||
val localEvent = LocalEvent(calendar, event, "filename.ics", null, null, 0)
|
||||
val uri = localEvent.add()
|
||||
|
||||
calendar.findById(localEvent.id!!)
|
||||
|
||||
assertEquals(6, LocalEvent.numInstances(provider, account, localEvent.id!!))
|
||||
}
|
||||
|
||||
|
||||
@Test
|
||||
fun testMarkEventAsDeleted() {
|
||||
// Create event
|
||||
val event = Event().apply {
|
||||
dtStart = DtStart("20220120T010203Z")
|
||||
summary = "A fine event"
|
||||
}
|
||||
val localEvent = LocalEvent(calendar, event, null, null, null, 0)
|
||||
localEvent.add()
|
||||
|
||||
// Delete event
|
||||
LocalEvent.markAsDeleted(provider, account, localEvent.id!!)
|
||||
|
||||
// Get the status of whether the event is deleted
|
||||
provider.query(
|
||||
ContentUris.withAppendedId(Events.CONTENT_URI, localEvent.id!!).asSyncAdapter(account),
|
||||
arrayOf(Events.DELETED),
|
||||
null,
|
||||
null, null
|
||||
)!!.use { cursor ->
|
||||
cursor.moveToFirst()
|
||||
assertEquals(1, cursor.getInt(0))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@Test
|
||||
fun testPrepareForUpload_NoUid() {
|
||||
// create event
|
||||
val event = Event().apply {
|
||||
dtStart = DtStart("20220120T010203Z")
|
||||
summary = "Event without uid"
|
||||
}
|
||||
val localEvent = LocalEvent(calendar, event, null, null, null, 0)
|
||||
localEvent.add() // save it to calendar storage
|
||||
|
||||
// prepare for upload - this should generate a new random uuid, returned as filename
|
||||
val fileNameWithSuffix = localEvent.prepareForUpload()
|
||||
val fileName = fileNameWithSuffix.removeSuffix(".ics")
|
||||
|
||||
// throws an exception if fileName is not an UUID
|
||||
UUID.fromString(fileName)
|
||||
|
||||
// UID in calendar storage should be the same as file name
|
||||
provider.query(
|
||||
ContentUris.withAppendedId(Events.CONTENT_URI, localEvent.id!!).asSyncAdapter(account),
|
||||
arrayOf(Events.UID_2445), null, null, null
|
||||
)!!.use { cursor ->
|
||||
cursor.moveToFirst()
|
||||
assertEquals(fileName, cursor.getString(0))
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testPrepareForUpload_NormalUid() {
|
||||
// create event
|
||||
val event = Event().apply {
|
||||
dtStart = DtStart("20220120T010203Z")
|
||||
summary = "Event with normal uid"
|
||||
uid = "some-event@hostname.tld" // old UID format, UUID would be new format
|
||||
}
|
||||
val localEvent = LocalEvent(calendar, event, null, null, null, 0)
|
||||
localEvent.add() // save it to calendar storage
|
||||
|
||||
// prepare for upload - this should use the UID for the file name
|
||||
val fileNameWithSuffix = localEvent.prepareForUpload()
|
||||
val fileName = fileNameWithSuffix.removeSuffix(".ics")
|
||||
|
||||
assertEquals(event.uid, fileName)
|
||||
|
||||
// UID in calendar storage should still be set, too
|
||||
provider.query(
|
||||
ContentUris.withAppendedId(Events.CONTENT_URI, localEvent.id!!).asSyncAdapter(account),
|
||||
arrayOf(Events.UID_2445), null, null, null
|
||||
)!!.use { cursor ->
|
||||
cursor.moveToFirst()
|
||||
assertEquals(fileName, cursor.getString(0))
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testPrepareForUpload_UidHasDangerousChars() {
|
||||
// create event
|
||||
val event = Event().apply {
|
||||
dtStart = DtStart("20220120T010203Z")
|
||||
summary = "Event with funny uid"
|
||||
uid = "https://www.example.com/events/asdfewfe-cxyb-ewrws-sadfrwerxyvser-asdfxye-"
|
||||
}
|
||||
val localEvent = LocalEvent(calendar, event, null, null, null, 0)
|
||||
localEvent.add() // save it to calendar storage
|
||||
|
||||
// prepare for upload - this should generate a new random uuid, returned as filename
|
||||
val fileNameWithSuffix = localEvent.prepareForUpload()
|
||||
val fileName = fileNameWithSuffix.removeSuffix(".ics")
|
||||
|
||||
// throws an exception if fileName is not an UUID
|
||||
UUID.fromString(fileName)
|
||||
|
||||
// UID in calendar storage shouldn't have been changed
|
||||
provider.query(
|
||||
ContentUris.withAppendedId(Events.CONTENT_URI, localEvent.id!!).asSyncAdapter(account),
|
||||
arrayOf(Events.UID_2445), null, null, null
|
||||
)!!.use { cursor ->
|
||||
cursor.moveToFirst()
|
||||
assertEquals(event.uid, cursor.getString(0))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@Test
|
||||
fun testDeleteDirtyEventsWithoutInstances_NoInstances_Exdate() {
|
||||
// TODO
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testDeleteDirtyEventsWithoutInstances_NoInstances_CancelledExceptions() {
|
||||
// create recurring event with only deleted/cancelled instances
|
||||
val event = Event().apply {
|
||||
dtStart = DtStart("20220120T010203Z")
|
||||
summary = "Event with 3 instances"
|
||||
rRules.add(RRule("FREQ=DAILY;COUNT=3"))
|
||||
exceptions.add(Event().apply {
|
||||
recurrenceId = RecurrenceId("20220120T010203Z")
|
||||
dtStart = DtStart("20220120T010203Z")
|
||||
summary = "Cancelled exception on 1st day"
|
||||
status = Status.VEVENT_CANCELLED
|
||||
})
|
||||
exceptions.add(Event().apply {
|
||||
recurrenceId = RecurrenceId("20220121T010203Z")
|
||||
dtStart = DtStart("20220121T010203Z")
|
||||
summary = "Cancelled exception on 2nd day"
|
||||
status = Status.VEVENT_CANCELLED
|
||||
})
|
||||
exceptions.add(Event().apply {
|
||||
recurrenceId = RecurrenceId("20220122T010203Z")
|
||||
dtStart = DtStart("20220122T010203Z")
|
||||
summary = "Cancelled exception on 3rd day"
|
||||
status = Status.VEVENT_CANCELLED
|
||||
})
|
||||
}
|
||||
val localEvent = LocalEvent(calendar, event, "filename.ics", null, null, LocalResource.FLAG_REMOTELY_PRESENT)
|
||||
localEvent.add()
|
||||
val eventId = localEvent.id!!
|
||||
|
||||
// set event as dirty
|
||||
provider.update(ContentUris.withAppendedId(Events.CONTENT_URI.asSyncAdapter(account), eventId), ContentValues(1).apply {
|
||||
put(Events.DIRTY, 1)
|
||||
}, null, null)
|
||||
|
||||
// this method should mark the event as deleted
|
||||
calendar.deleteDirtyEventsWithoutInstances()
|
||||
|
||||
// verify that event is now marked as deleted
|
||||
provider.query(
|
||||
ContentUris.withAppendedId(Events.CONTENT_URI.asSyncAdapter(account), eventId),
|
||||
arrayOf(Events.DELETED), null, null, null
|
||||
)!!.use { cursor ->
|
||||
cursor.moveToNext()
|
||||
assertEquals(1, cursor.getInt(0))
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testDeleteDirtyEventsWithoutInstances_Recurring_Instances() {
|
||||
val event = Event().apply {
|
||||
dtStart = DtStart("20220120T010203Z")
|
||||
summary = "Event with 3 instances"
|
||||
rRules.add(RRule("FREQ=DAILY;COUNT=3"))
|
||||
}
|
||||
val localEvent = LocalEvent(calendar, event, "filename.ics", null, null, LocalResource.FLAG_REMOTELY_PRESENT)
|
||||
localEvent.add()
|
||||
val eventId = localEvent.id!!
|
||||
|
||||
// set event as dirty
|
||||
provider.update(ContentUris.withAppendedId(Events.CONTENT_URI.asSyncAdapter(account), eventId), ContentValues(1).apply {
|
||||
put(Events.DIRTY, 1)
|
||||
}, null, null)
|
||||
|
||||
// this method should mark the event as deleted
|
||||
calendar.deleteDirtyEventsWithoutInstances()
|
||||
|
||||
// verify that event is not marked as deleted
|
||||
provider.query(
|
||||
ContentUris.withAppendedId(Events.CONTENT_URI.asSyncAdapter(account), eventId),
|
||||
arrayOf(Events.DELETED), null, null, null
|
||||
)!!.use { cursor ->
|
||||
cursor.moveToNext()
|
||||
assertEquals(0, cursor.getInt(0))
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,267 @@
|
||||
/*
|
||||
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
|
||||
*/
|
||||
|
||||
package at.bitfire.davdroid.resource
|
||||
|
||||
import android.Manifest
|
||||
import android.content.ContentProviderClient
|
||||
import android.content.ContentUris
|
||||
import android.content.ContentValues
|
||||
import android.provider.ContactsContract
|
||||
import android.provider.ContactsContract.CommonDataKinds.GroupMembership
|
||||
import androidx.test.platform.app.InstrumentationRegistry
|
||||
import androidx.test.rule.GrantPermissionRule
|
||||
import at.bitfire.davdroid.settings.SettingsManager
|
||||
import at.bitfire.vcard4android.BatchOperation
|
||||
import at.bitfire.vcard4android.CachedGroupMembership
|
||||
import at.bitfire.vcard4android.Contact
|
||||
import at.bitfire.vcard4android.GroupMethod
|
||||
import dagger.hilt.android.testing.HiltAndroidRule
|
||||
import dagger.hilt.android.testing.HiltAndroidTest
|
||||
import org.junit.*
|
||||
import org.junit.Assert.*
|
||||
import javax.inject.Inject
|
||||
|
||||
@HiltAndroidTest
|
||||
class LocalGroupTest {
|
||||
|
||||
@get:Rule
|
||||
val hiltRule = HiltAndroidRule(this)
|
||||
|
||||
@Inject
|
||||
lateinit var settingsManager: SettingsManager
|
||||
|
||||
@Before
|
||||
fun inject() {
|
||||
hiltRule.inject()
|
||||
}
|
||||
|
||||
companion object {
|
||||
@JvmField
|
||||
@ClassRule
|
||||
val permissionRule = GrantPermissionRule.grant(Manifest.permission.READ_CONTACTS, Manifest.permission.WRITE_CONTACTS)!!
|
||||
|
||||
private lateinit var provider: ContentProviderClient
|
||||
|
||||
private lateinit var addressBookGroupsAsCategories: LocalTestAddressBook
|
||||
private lateinit var addressBookGroupsAsVCards: LocalTestAddressBook
|
||||
|
||||
@BeforeClass
|
||||
@JvmStatic
|
||||
fun connect() {
|
||||
val context = InstrumentationRegistry.getInstrumentation().targetContext
|
||||
provider = context.contentResolver.acquireContentProviderClient(ContactsContract.AUTHORITY)!!
|
||||
assertNotNull(provider)
|
||||
|
||||
addressBookGroupsAsCategories = LocalTestAddressBook(context, provider, GroupMethod.CATEGORIES)
|
||||
addressBookGroupsAsVCards = LocalTestAddressBook(context, provider, GroupMethod.GROUP_VCARDS)
|
||||
}
|
||||
|
||||
@AfterClass
|
||||
@JvmStatic
|
||||
fun disconnect() {
|
||||
@Suppress("DEPRECATION")
|
||||
provider.release()
|
||||
}
|
||||
}
|
||||
|
||||
private fun newGroup(addressBook: LocalAddressBook = addressBookGroupsAsCategories): LocalGroup =
|
||||
LocalGroup(addressBook,
|
||||
Contact().apply {
|
||||
displayName = "Test Group"
|
||||
}, null, null, 0
|
||||
).apply {
|
||||
add()
|
||||
}
|
||||
|
||||
|
||||
|
||||
@Before
|
||||
fun clearContacts() {
|
||||
addressBookGroupsAsCategories.clear()
|
||||
addressBookGroupsAsVCards.clear()
|
||||
}
|
||||
|
||||
|
||||
@Test
|
||||
fun testApplyPendingMemberships_addPendingMembership() {
|
||||
val ab = addressBookGroupsAsVCards
|
||||
|
||||
val contact1 = LocalContact(ab, Contact().apply {
|
||||
uid = "test1"
|
||||
displayName = "Test"
|
||||
}, "test1.vcf", null, 0)
|
||||
contact1.add()
|
||||
|
||||
val group = newGroup(ab)
|
||||
// set pending membership of contact1
|
||||
ab.provider!!.update(
|
||||
ContentUris.withAppendedId(ab.groupsSyncUri(), group.id!!),
|
||||
ContentValues().apply {
|
||||
put(LocalGroup.COLUMN_PENDING_MEMBERS, LocalGroup.PendingMemberships(setOf("test1")).toString())
|
||||
},
|
||||
null, null
|
||||
)
|
||||
|
||||
// pending membership -> contact1 should be added to group
|
||||
LocalGroup.applyPendingMemberships(ab)
|
||||
|
||||
// check group membership
|
||||
ab.provider!!.query(
|
||||
ab.syncAdapterURI(ContactsContract.Data.CONTENT_URI), arrayOf(GroupMembership.GROUP_ROW_ID, GroupMembership.RAW_CONTACT_ID),
|
||||
"${GroupMembership.MIMETYPE}=?", arrayOf(GroupMembership.CONTENT_ITEM_TYPE),
|
||||
null
|
||||
)!!.use { cursor ->
|
||||
assertTrue(cursor.moveToNext())
|
||||
assertEquals(group.id, cursor.getLong(0))
|
||||
assertEquals(contact1.id, cursor.getLong(1))
|
||||
|
||||
assertFalse(cursor.moveToNext())
|
||||
}
|
||||
// check cached group membership
|
||||
ab.provider!!.query(
|
||||
ab.syncAdapterURI(ContactsContract.Data.CONTENT_URI), arrayOf(CachedGroupMembership.GROUP_ID, CachedGroupMembership.RAW_CONTACT_ID),
|
||||
"${CachedGroupMembership.MIMETYPE}=?", arrayOf(CachedGroupMembership.CONTENT_ITEM_TYPE),
|
||||
null
|
||||
)!!.use { cursor ->
|
||||
assertTrue(cursor.moveToNext())
|
||||
assertEquals(group.id, cursor.getLong(0))
|
||||
assertEquals(contact1.id, cursor.getLong(1))
|
||||
|
||||
assertFalse(cursor.moveToNext())
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testApplyPendingMemberships_removeMembership() {
|
||||
val ab = addressBookGroupsAsVCards
|
||||
|
||||
val contact1 = LocalContact(ab, Contact().apply {
|
||||
uid = "test1"
|
||||
displayName = "Test"
|
||||
}, "test1.vcf", null, 0)
|
||||
contact1.add()
|
||||
|
||||
val group = newGroup(ab)
|
||||
|
||||
// add contact1 to group
|
||||
val batch = BatchOperation(ab.provider!!)
|
||||
contact1.addToGroup(batch, group.id!!)
|
||||
batch.commit()
|
||||
|
||||
// no pending memberships -> membership should be removed
|
||||
LocalGroup.applyPendingMemberships(ab)
|
||||
|
||||
// check group membership
|
||||
ab.provider!!.query(
|
||||
ab.syncAdapterURI(ContactsContract.Data.CONTENT_URI), arrayOf(GroupMembership.GROUP_ROW_ID, GroupMembership.RAW_CONTACT_ID),
|
||||
"${GroupMembership.MIMETYPE}=?", arrayOf(GroupMembership.CONTENT_ITEM_TYPE),
|
||||
null
|
||||
)!!.use { cursor ->
|
||||
assertFalse(cursor.moveToNext())
|
||||
}
|
||||
// check cached group membership
|
||||
ab.provider!!.query(
|
||||
ab.syncAdapterURI(ContactsContract.Data.CONTENT_URI), arrayOf(CachedGroupMembership.GROUP_ID, CachedGroupMembership.RAW_CONTACT_ID),
|
||||
"${CachedGroupMembership.MIMETYPE}=?", arrayOf(CachedGroupMembership.CONTENT_ITEM_TYPE),
|
||||
null
|
||||
)!!.use { cursor ->
|
||||
assertFalse(cursor.moveToNext())
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@Test
|
||||
fun testClearDirty_addCachedGroupMembership() {
|
||||
val ab = addressBookGroupsAsCategories
|
||||
val group = newGroup(ab)
|
||||
|
||||
val contact1 = LocalContact(ab, Contact().apply { displayName = "Test" }, "fn.vcf", null, 0)
|
||||
contact1.add()
|
||||
|
||||
// insert group membership, but no cached group membership
|
||||
ab.provider!!.insert(
|
||||
ab.syncAdapterURI(ContactsContract.Data.CONTENT_URI), ContentValues().apply {
|
||||
put(GroupMembership.MIMETYPE, GroupMembership.CONTENT_ITEM_TYPE)
|
||||
put(GroupMembership.RAW_CONTACT_ID, contact1.id)
|
||||
put(GroupMembership.GROUP_ROW_ID, group.id)
|
||||
}
|
||||
)
|
||||
|
||||
group.clearDirty(null, null)
|
||||
|
||||
// check cached group membership
|
||||
ab.provider!!.query(
|
||||
ab.syncAdapterURI(ContactsContract.Data.CONTENT_URI), arrayOf(CachedGroupMembership.GROUP_ID, CachedGroupMembership.RAW_CONTACT_ID),
|
||||
"${CachedGroupMembership.MIMETYPE}=?", arrayOf(CachedGroupMembership.CONTENT_ITEM_TYPE),
|
||||
null
|
||||
)!!.use { cursor ->
|
||||
assertTrue(cursor.moveToNext())
|
||||
assertEquals(group.id, cursor.getLong(0))
|
||||
assertEquals(contact1.id, cursor.getLong(1))
|
||||
|
||||
assertFalse(cursor.moveToNext())
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testClearDirty_removeCachedGroupMembership() {
|
||||
val ab = addressBookGroupsAsCategories
|
||||
val group = newGroup(ab)
|
||||
|
||||
val contact1 = LocalContact(ab, Contact().apply { displayName = "Test" }, "fn.vcf", null, 0)
|
||||
contact1.add()
|
||||
|
||||
// insert cached group membership, but no group membership
|
||||
ab.provider!!.insert(
|
||||
ab.syncAdapterURI(ContactsContract.Data.CONTENT_URI), ContentValues().apply {
|
||||
put(CachedGroupMembership.MIMETYPE, CachedGroupMembership.CONTENT_ITEM_TYPE)
|
||||
put(CachedGroupMembership.RAW_CONTACT_ID, contact1.id)
|
||||
put(CachedGroupMembership.GROUP_ID, group.id)
|
||||
}
|
||||
)
|
||||
|
||||
group.clearDirty(null, null)
|
||||
|
||||
// cached group membership should be gone
|
||||
ab.provider!!.query(
|
||||
ab.syncAdapterURI(ContactsContract.Data.CONTENT_URI), arrayOf(CachedGroupMembership.GROUP_ID, CachedGroupMembership.RAW_CONTACT_ID),
|
||||
"${CachedGroupMembership.MIMETYPE}=?", arrayOf(CachedGroupMembership.CONTENT_ITEM_TYPE),
|
||||
null
|
||||
)!!.use { cursor ->
|
||||
assertFalse(cursor.moveToNext())
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@Test
|
||||
fun testMarkMembersDirty() {
|
||||
val ab = addressBookGroupsAsCategories
|
||||
val group = newGroup(ab)
|
||||
|
||||
val contact1 = LocalContact(ab, Contact().apply { displayName = "Test" }, "fn.vcf", null, 0)
|
||||
contact1.add()
|
||||
|
||||
val batch = BatchOperation(ab.provider!!)
|
||||
contact1.addToGroup(batch, group.id!!)
|
||||
batch.commit()
|
||||
|
||||
assertEquals(0, ab.findDirty().size)
|
||||
group.markMembersDirty()
|
||||
assertEquals(contact1.id, ab.findDirty().first().id)
|
||||
}
|
||||
|
||||
|
||||
@Test
|
||||
fun testPrepareForUpload() {
|
||||
val group = newGroup()
|
||||
assertNull(group.getContact().uid)
|
||||
|
||||
val fileName = group.prepareForUpload()
|
||||
val newUid = group.getContact().uid
|
||||
assertNotNull(newUid)
|
||||
assertEquals("$newUid.vcf", fileName)
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
/*
|
||||
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
|
||||
*/
|
||||
|
||||
package at.bitfire.davdroid.resource
|
||||
|
||||
import android.accounts.Account
|
||||
import android.content.ContentProviderClient
|
||||
import android.content.Context
|
||||
import at.bitfire.vcard4android.GroupMethod
|
||||
|
||||
class LocalTestAddressBook(
|
||||
context: Context,
|
||||
provider: ContentProviderClient,
|
||||
override val groupMethod: GroupMethod
|
||||
): LocalAddressBook(context, ACCOUNT, provider) {
|
||||
|
||||
companion object {
|
||||
val ACCOUNT = Account("LocalTestAddressBook", "at.bitfire.davdroid.test")
|
||||
}
|
||||
|
||||
override var mainAccount: Account?
|
||||
get() = throw NotImplementedError()
|
||||
set(_) = throw NotImplementedError()
|
||||
|
||||
override var readOnly: Boolean
|
||||
get() = false
|
||||
set(_) = throw NotImplementedError()
|
||||
|
||||
|
||||
fun clear() {
|
||||
for (contact in queryContacts(null, null))
|
||||
contact.delete()
|
||||
for (group in queryGroups(null, null))
|
||||
group.delete()
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
/*
|
||||
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
|
||||
*/
|
||||
|
||||
package at.bitfire.davdroid.resource.contactrow
|
||||
|
||||
import android.Manifest
|
||||
import android.content.ContentProviderClient
|
||||
import android.content.ContentValues
|
||||
import android.provider.ContactsContract
|
||||
import androidx.test.platform.app.InstrumentationRegistry
|
||||
import androidx.test.rule.GrantPermissionRule
|
||||
import at.bitfire.davdroid.resource.LocalContact
|
||||
import at.bitfire.davdroid.resource.LocalTestAddressBook
|
||||
import at.bitfire.vcard4android.CachedGroupMembership
|
||||
import at.bitfire.vcard4android.Contact
|
||||
import at.bitfire.vcard4android.GroupMethod
|
||||
import org.junit.*
|
||||
import org.junit.Assert.assertArrayEquals
|
||||
|
||||
class CachedGroupMembershipHandlerTest {
|
||||
|
||||
companion object {
|
||||
|
||||
@JvmField
|
||||
@ClassRule
|
||||
val permissionRule = GrantPermissionRule.grant(Manifest.permission.READ_CONTACTS, Manifest.permission.WRITE_CONTACTS)!!
|
||||
|
||||
private lateinit var provider: ContentProviderClient
|
||||
private lateinit var addressBook: LocalTestAddressBook
|
||||
|
||||
@BeforeClass
|
||||
@JvmStatic
|
||||
fun connect() {
|
||||
val context = InstrumentationRegistry.getInstrumentation().context
|
||||
provider = context.contentResolver.acquireContentProviderClient(ContactsContract.AUTHORITY)!!
|
||||
Assert.assertNotNull(provider)
|
||||
|
||||
addressBook = LocalTestAddressBook(context, provider, GroupMethod.GROUP_VCARDS)
|
||||
}
|
||||
|
||||
@AfterClass
|
||||
@JvmStatic
|
||||
fun disconnect() {
|
||||
@Suppress("DEPRECATION")
|
||||
provider.release()
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
@Test
|
||||
fun testMembership() {
|
||||
val contact = Contact()
|
||||
val localContact = LocalContact(addressBook, contact, null, null, 0)
|
||||
CachedGroupMembershipHandler(localContact).handle(ContentValues().apply {
|
||||
put(CachedGroupMembership.GROUP_ID, 123456)
|
||||
put(CachedGroupMembership.RAW_CONTACT_ID, 789)
|
||||
}, contact)
|
||||
assertArrayEquals(arrayOf(123456L), localContact.cachedGroupMemberships.toArray())
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
/*
|
||||
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
|
||||
*/
|
||||
|
||||
package at.bitfire.davdroid.resource.contactrow
|
||||
|
||||
import android.Manifest
|
||||
import android.content.ContentProviderClient
|
||||
import android.net.Uri
|
||||
import android.provider.ContactsContract
|
||||
import android.provider.ContactsContract.CommonDataKinds.GroupMembership
|
||||
import androidx.test.platform.app.InstrumentationRegistry
|
||||
import androidx.test.rule.GrantPermissionRule
|
||||
import at.bitfire.davdroid.resource.LocalTestAddressBook
|
||||
import at.bitfire.vcard4android.Contact
|
||||
import at.bitfire.vcard4android.GroupMethod
|
||||
import org.junit.*
|
||||
import org.junit.Assert.assertEquals
|
||||
|
||||
class GroupMembershipBuilderTest {
|
||||
|
||||
companion object {
|
||||
@JvmField
|
||||
@ClassRule
|
||||
val permissionRule = GrantPermissionRule.grant(Manifest.permission.READ_CONTACTS, Manifest.permission.WRITE_CONTACTS)!!
|
||||
|
||||
private lateinit var provider: ContentProviderClient
|
||||
|
||||
private lateinit var addressBookGroupsAsCategories: LocalTestAddressBook
|
||||
private lateinit var addressBookGroupsAsVCards: LocalTestAddressBook
|
||||
|
||||
@BeforeClass
|
||||
@JvmStatic
|
||||
fun connect() {
|
||||
val context = InstrumentationRegistry.getInstrumentation().targetContext
|
||||
provider = context.contentResolver.acquireContentProviderClient(ContactsContract.AUTHORITY)!!
|
||||
Assert.assertNotNull(provider)
|
||||
|
||||
addressBookGroupsAsCategories = LocalTestAddressBook(context, provider, GroupMethod.CATEGORIES)
|
||||
addressBookGroupsAsVCards = LocalTestAddressBook(context, provider, GroupMethod.GROUP_VCARDS)
|
||||
}
|
||||
|
||||
@AfterClass
|
||||
@JvmStatic
|
||||
fun disconnect() {
|
||||
@Suppress("DEPRECATION")
|
||||
provider.release()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@Test
|
||||
fun testCategories_GroupsAsCategories() {
|
||||
val contact = Contact().apply {
|
||||
categories += "TEST GROUP"
|
||||
}
|
||||
GroupMembershipBuilder(Uri.EMPTY, null, contact, addressBookGroupsAsCategories, false).build().also { result ->
|
||||
assertEquals(1, result.size)
|
||||
assertEquals(GroupMembership.CONTENT_ITEM_TYPE, result[0].values[GroupMembership.MIMETYPE])
|
||||
assertEquals(addressBookGroupsAsCategories.findOrCreateGroup("TEST GROUP"), result[0].values[GroupMembership.GROUP_ROW_ID])
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testCategories_GroupsAsVCards() {
|
||||
val contact = Contact().apply {
|
||||
categories += "TEST GROUP"
|
||||
}
|
||||
GroupMembershipBuilder(Uri.EMPTY, null, contact, addressBookGroupsAsVCards, false).build().also { result ->
|
||||
// group membership is constructed during post-processing
|
||||
assertEquals(0, result.size)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
/*
|
||||
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
|
||||
*/
|
||||
|
||||
package at.bitfire.davdroid.resource.contactrow
|
||||
|
||||
import android.Manifest
|
||||
import android.content.ContentProviderClient
|
||||
import android.content.ContentValues
|
||||
import android.provider.ContactsContract
|
||||
import androidx.test.platform.app.InstrumentationRegistry
|
||||
import androidx.test.rule.GrantPermissionRule
|
||||
import at.bitfire.davdroid.resource.LocalContact
|
||||
import at.bitfire.davdroid.resource.LocalTestAddressBook
|
||||
import at.bitfire.vcard4android.CachedGroupMembership
|
||||
import at.bitfire.vcard4android.Contact
|
||||
import at.bitfire.vcard4android.GroupMethod
|
||||
import org.junit.*
|
||||
import org.junit.Assert.assertArrayEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
|
||||
class GroupMembershipHandlerTest {
|
||||
|
||||
@JvmField
|
||||
@Rule
|
||||
val permissionRule = GrantPermissionRule.grant(Manifest.permission.READ_CONTACTS, Manifest.permission.WRITE_CONTACTS)!!
|
||||
|
||||
private lateinit var provider: ContentProviderClient
|
||||
|
||||
private lateinit var addressBookGroupsAsCategories: LocalTestAddressBook
|
||||
private var addressBookGroupsAsCategoriesGroup: Long = -1
|
||||
|
||||
private lateinit var addressBookGroupsAsVCards: LocalTestAddressBook
|
||||
|
||||
@Before
|
||||
fun connect() {
|
||||
val context = InstrumentationRegistry.getInstrumentation().targetContext
|
||||
provider = context.contentResolver.acquireContentProviderClient(ContactsContract.AUTHORITY)!!
|
||||
Assert.assertNotNull(provider)
|
||||
|
||||
addressBookGroupsAsCategories = LocalTestAddressBook(context, provider, GroupMethod.CATEGORIES)
|
||||
addressBookGroupsAsCategoriesGroup = addressBookGroupsAsCategories.findOrCreateGroup("TEST GROUP")
|
||||
|
||||
addressBookGroupsAsVCards = LocalTestAddressBook(context, provider, GroupMethod.GROUP_VCARDS)
|
||||
}
|
||||
|
||||
@After
|
||||
fun disconnect() {
|
||||
@Suppress("DEPRECATION")
|
||||
provider.release()
|
||||
}
|
||||
|
||||
|
||||
@Test
|
||||
fun testMembership_GroupsAsCategories() {
|
||||
val contact = Contact()
|
||||
val localContact = LocalContact(addressBookGroupsAsCategories, contact, null, null, 0)
|
||||
GroupMembershipHandler(localContact).handle(ContentValues().apply {
|
||||
put(CachedGroupMembership.GROUP_ID, addressBookGroupsAsCategoriesGroup)
|
||||
put(CachedGroupMembership.RAW_CONTACT_ID, -1)
|
||||
}, contact)
|
||||
assertArrayEquals(arrayOf(addressBookGroupsAsCategoriesGroup), localContact.groupMemberships.toArray())
|
||||
assertArrayEquals(arrayOf("TEST GROUP"), contact.categories.toArray())
|
||||
}
|
||||
|
||||
|
||||
@Test
|
||||
fun testMembership_GroupsAsVCards() {
|
||||
val contact = Contact()
|
||||
val localContact = LocalContact(addressBookGroupsAsVCards, contact, null, null, 0)
|
||||
GroupMembershipHandler(localContact).handle(ContentValues().apply {
|
||||
put(CachedGroupMembership.GROUP_ID, 12345) // because the group name is not queried and put into CATEGORIES, the group doesn't have to really exist
|
||||
put(CachedGroupMembership.RAW_CONTACT_ID, -1)
|
||||
}, contact)
|
||||
assertArrayEquals(arrayOf(12345L), localContact.groupMemberships.toArray())
|
||||
assertTrue(contact.categories.isEmpty())
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,717 @@
|
||||
/*
|
||||
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
|
||||
*/
|
||||
|
||||
package at.bitfire.davdroid.servicedetection
|
||||
|
||||
import android.content.Context
|
||||
import android.security.NetworkSecurityPolicy
|
||||
import androidx.test.platform.app.InstrumentationRegistry
|
||||
import at.bitfire.davdroid.db.AppDatabase
|
||||
import at.bitfire.davdroid.db.Collection
|
||||
import at.bitfire.davdroid.db.HomeSet
|
||||
import at.bitfire.davdroid.db.Principal
|
||||
import at.bitfire.davdroid.db.Service
|
||||
import at.bitfire.davdroid.log.Logger
|
||||
import at.bitfire.davdroid.network.HttpClient
|
||||
import at.bitfire.davdroid.settings.Settings
|
||||
import at.bitfire.davdroid.settings.SettingsManager
|
||||
import dagger.hilt.android.testing.HiltAndroidRule
|
||||
import dagger.hilt.android.testing.HiltAndroidTest
|
||||
import io.mockk.every
|
||||
import io.mockk.mockkObject
|
||||
import okhttp3.mockwebserver.Dispatcher
|
||||
import okhttp3.mockwebserver.MockResponse
|
||||
import okhttp3.mockwebserver.MockWebServer
|
||||
import okhttp3.mockwebserver.RecordedRequest
|
||||
import org.apache.commons.lang3.StringUtils
|
||||
import org.junit.After
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Assume
|
||||
import org.junit.Before
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import javax.inject.Inject
|
||||
|
||||
@HiltAndroidTest
|
||||
class CollectionListRefresherTest {
|
||||
|
||||
@get:Rule
|
||||
var hiltRule = HiltAndroidRule(this)
|
||||
|
||||
val context: Context = InstrumentationRegistry.getInstrumentation().targetContext
|
||||
|
||||
@Inject
|
||||
lateinit var settings: SettingsManager
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
hiltRule.inject()
|
||||
}
|
||||
|
||||
|
||||
// Test dependencies
|
||||
|
||||
companion object {
|
||||
private const val PATH_CALDAV = "/caldav"
|
||||
private const val PATH_CARDDAV = "/carddav"
|
||||
|
||||
private const val SUBPATH_PRINCIPAL = "/principal"
|
||||
private const val SUBPATH_PRINCIPAL_INACCESSIBLE = "/inaccessible-principal"
|
||||
private const val SUBPATH_PRINCIPAL_WITHOUT_COLLECTIONS = "/principal2"
|
||||
private const val SUBPATH_ADDRESSBOOK_HOMESET = "/addressbooks-homeset"
|
||||
private const val SUBPATH_ADDRESSBOOK_HOMESET_EMPTY = "/addressbooks-homeset-empty"
|
||||
private const val SUBPATH_ADDRESSBOOK = "/addressbooks/my-contacts"
|
||||
private const val SUBPATH_ADDRESSBOOK_INACCESSIBLE = "/addressbooks/inaccessible-contacts"
|
||||
}
|
||||
|
||||
@Inject
|
||||
lateinit var db: AppDatabase
|
||||
|
||||
@Inject
|
||||
lateinit var refresherFactory: CollectionListRefresher.Factory
|
||||
|
||||
private val mockServer = MockWebServer()
|
||||
private lateinit var client: HttpClient
|
||||
|
||||
@Before
|
||||
fun mockServerSetup() {
|
||||
// Start mock web server
|
||||
mockServer.dispatcher = TestDispatcher()
|
||||
mockServer.start()
|
||||
|
||||
client = HttpClient.Builder(InstrumentationRegistry.getInstrumentation().targetContext).build()
|
||||
|
||||
Assume.assumeTrue(NetworkSecurityPolicy.getInstance().isCleartextTrafficPermitted)
|
||||
}
|
||||
|
||||
@After
|
||||
fun cleanUp() {
|
||||
mockServer.shutdown()
|
||||
db.close()
|
||||
}
|
||||
|
||||
|
||||
@Test
|
||||
fun testDiscoverHomesets() {
|
||||
val service = createTestService(Service.TYPE_CARDDAV)!!
|
||||
val baseUrl = mockServer.url(PATH_CARDDAV + SUBPATH_PRINCIPAL)
|
||||
|
||||
// Query home sets
|
||||
refresherFactory.create(service, client.okHttpClient).discoverHomesets(baseUrl)
|
||||
|
||||
// Check home sets have been saved to database
|
||||
assertEquals(mockServer.url("$PATH_CARDDAV$SUBPATH_ADDRESSBOOK_HOMESET/"), db.homeSetDao().getByService(service.id).first().url)
|
||||
assertEquals(1, db.homeSetDao().getByService(service.id).size)
|
||||
}
|
||||
|
||||
|
||||
// refreshHomesetsAndTheirCollections
|
||||
|
||||
@Test
|
||||
fun refreshHomesetsAndTheirCollections_addsNewCollection() {
|
||||
val service = createTestService(Service.TYPE_CARDDAV)!!
|
||||
|
||||
// save homeset in DB
|
||||
val homesetId = db.homeSetDao().insert(
|
||||
HomeSet(id=0, service.id, true, mockServer.url("$PATH_CARDDAV$SUBPATH_ADDRESSBOOK_HOMESET"))
|
||||
)
|
||||
|
||||
// Refresh
|
||||
refresherFactory.create(service, client.okHttpClient).refreshHomesetsAndTheirCollections()
|
||||
|
||||
// Check the collection defined in homeset is now in the database
|
||||
assertEquals(
|
||||
Collection(
|
||||
1,
|
||||
service.id,
|
||||
homesetId,
|
||||
1, // will have gotten an owner too
|
||||
Collection.TYPE_ADDRESSBOOK,
|
||||
mockServer.url("$PATH_CARDDAV$SUBPATH_ADDRESSBOOK/"),
|
||||
displayName = "My Contacts",
|
||||
description = "My Contacts Description"
|
||||
),
|
||||
db.collectionDao().getByService(service.id).first()
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun refreshHomesetsAndTheirCollections_updatesExistingCollection() {
|
||||
val service = createTestService(Service.TYPE_CARDDAV)!!
|
||||
|
||||
// save "old" collection in DB
|
||||
val collectionId = db.collectionDao().insertOrUpdateByUrl(
|
||||
Collection(
|
||||
0,
|
||||
service.id,
|
||||
null,
|
||||
null,
|
||||
Collection.TYPE_ADDRESSBOOK,
|
||||
mockServer.url("$PATH_CARDDAV$SUBPATH_ADDRESSBOOK/"),
|
||||
displayName = "My Contacts",
|
||||
description = "My Contacts Description"
|
||||
)
|
||||
)
|
||||
|
||||
// Refresh
|
||||
refresherFactory.create(service, client.okHttpClient).refreshHomesetsAndTheirCollections()
|
||||
|
||||
// Check the collection got updated
|
||||
assertEquals(
|
||||
Collection(
|
||||
collectionId,
|
||||
service.id,
|
||||
null,
|
||||
null,
|
||||
Collection.TYPE_ADDRESSBOOK,
|
||||
mockServer.url("$PATH_CARDDAV$SUBPATH_ADDRESSBOOK/"),
|
||||
displayName = "My Contacts",
|
||||
description = "My Contacts Description"
|
||||
),
|
||||
db.collectionDao().get(collectionId)
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun refreshHomesetsAndTheirCollections_preservesCollectionFlags() {
|
||||
val service = createTestService(Service.TYPE_CARDDAV)!!
|
||||
|
||||
// save "old" collection in DB - with set flags
|
||||
val collectionId = db.collectionDao().insertOrUpdateByUrl(
|
||||
Collection(
|
||||
0,
|
||||
service.id,
|
||||
null,
|
||||
null,
|
||||
Collection.TYPE_ADDRESSBOOK,
|
||||
mockServer.url("$PATH_CARDDAV$SUBPATH_ADDRESSBOOK/"),
|
||||
displayName = "My Contacts",
|
||||
description = "My Contacts Description",
|
||||
forceReadOnly = true,
|
||||
sync = true
|
||||
)
|
||||
)
|
||||
|
||||
// Refresh
|
||||
refresherFactory.create(service, client.okHttpClient).refreshHomesetsAndTheirCollections()
|
||||
|
||||
// Check the collection got updated
|
||||
assertEquals(
|
||||
Collection(
|
||||
collectionId,
|
||||
service.id,
|
||||
null,
|
||||
null,
|
||||
Collection.TYPE_ADDRESSBOOK,
|
||||
mockServer.url("$PATH_CARDDAV$SUBPATH_ADDRESSBOOK/"),
|
||||
displayName = "My Contacts",
|
||||
description = "My Contacts Description",
|
||||
forceReadOnly = true,
|
||||
sync = true
|
||||
),
|
||||
db.collectionDao().get(collectionId)
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun refreshHomesetsAndTheirCollections_marksRemovedCollectionsAsHomeless() {
|
||||
val service = createTestService(Service.TYPE_CARDDAV)!!
|
||||
|
||||
// save homeset in DB - which is empty (zero address books) on the serverside
|
||||
val homesetId = db.homeSetDao().insert(
|
||||
HomeSet(id=0, service.id, true, mockServer.url("$PATH_CARDDAV$SUBPATH_ADDRESSBOOK_HOMESET_EMPTY"))
|
||||
)
|
||||
|
||||
// place collection in DB - as part of the homeset
|
||||
val collectionId = db.collectionDao().insertOrUpdateByUrl(
|
||||
Collection(
|
||||
0,
|
||||
service.id,
|
||||
homesetId,
|
||||
null,
|
||||
Collection.TYPE_ADDRESSBOOK,
|
||||
mockServer.url("$PATH_CARDDAV$SUBPATH_ADDRESSBOOK/")
|
||||
)
|
||||
)
|
||||
|
||||
// Refresh - should mark collection as homeless, because serverside homeset is empty.
|
||||
refresherFactory.create(service, client.okHttpClient).refreshHomesetsAndTheirCollections()
|
||||
|
||||
// Check the collection, is now marked as homeless
|
||||
assertEquals(null, db.collectionDao().get(collectionId)!!.homeSetId)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun refreshHomesetsAndTheirCollections_addsOwnerUrls() {
|
||||
val service = createTestService(Service.TYPE_CARDDAV)!!
|
||||
|
||||
// save a homeset in DB
|
||||
val homesetId = db.homeSetDao().insert(
|
||||
HomeSet(id=0, service.id, true, mockServer.url("$PATH_CARDDAV$SUBPATH_ADDRESSBOOK_HOMESET"))
|
||||
)
|
||||
|
||||
// place collection in DB - as part of the homeset
|
||||
val collectionId = db.collectionDao().insertOrUpdateByUrl(
|
||||
Collection(
|
||||
0,
|
||||
service.id,
|
||||
homesetId, // part of above home set
|
||||
null,
|
||||
Collection.TYPE_ADDRESSBOOK,
|
||||
mockServer.url("$PATH_CARDDAV$SUBPATH_ADDRESSBOOK/")
|
||||
)
|
||||
)
|
||||
|
||||
// Refresh - homesets and their collections
|
||||
assertEquals(0, db.principalDao().getByService(service.id).size)
|
||||
refresherFactory.create(service, client.okHttpClient).refreshHomesetsAndTheirCollections()
|
||||
|
||||
// Check principal saved and the collection was updated with its reference
|
||||
val principals = db.principalDao().getByService(service.id)
|
||||
assertEquals(1, principals.size)
|
||||
assertEquals(mockServer.url("$PATH_CARDDAV$SUBPATH_PRINCIPAL"), principals[0].url)
|
||||
assertEquals(null, principals[0].displayName)
|
||||
assertEquals(
|
||||
principals[0].id,
|
||||
db.collectionDao().get(collectionId)!!.ownerId
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
// refreshHomelessCollections
|
||||
|
||||
@Test
|
||||
fun refreshHomelessCollections_updatesExistingCollection() {
|
||||
val service = createTestService(Service.TYPE_CARDDAV)!!
|
||||
|
||||
// place homeless collection in DB
|
||||
val collectionId = db.collectionDao().insertOrUpdateByUrl(
|
||||
Collection(
|
||||
0,
|
||||
service.id,
|
||||
null,
|
||||
null,
|
||||
Collection.TYPE_ADDRESSBOOK,
|
||||
mockServer.url("$PATH_CARDDAV$SUBPATH_ADDRESSBOOK/"),
|
||||
)
|
||||
)
|
||||
|
||||
// Refresh
|
||||
refresherFactory.create(service, client.okHttpClient).refreshHomelessCollections()
|
||||
|
||||
// Check the collection got updated - with display name and description
|
||||
assertEquals(
|
||||
Collection(
|
||||
collectionId,
|
||||
service.id,
|
||||
null,
|
||||
1, // will have gotten an owner too
|
||||
Collection.TYPE_ADDRESSBOOK,
|
||||
mockServer.url("$PATH_CARDDAV$SUBPATH_ADDRESSBOOK/"),
|
||||
displayName = "My Contacts",
|
||||
description = "My Contacts Description"
|
||||
),
|
||||
db.collectionDao().get(collectionId)
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun refreshHomelessCollections_deletesInaccessibleCollections() {
|
||||
val service = createTestService(Service.TYPE_CARDDAV)!!
|
||||
|
||||
// place homeless collection in DB - it is also inaccessible
|
||||
val collectionId = db.collectionDao().insertOrUpdateByUrl(
|
||||
Collection(
|
||||
0,
|
||||
service.id,
|
||||
null,
|
||||
null,
|
||||
Collection.TYPE_ADDRESSBOOK,
|
||||
mockServer.url("$PATH_CARDDAV$SUBPATH_ADDRESSBOOK_INACCESSIBLE")
|
||||
)
|
||||
)
|
||||
|
||||
// Refresh - should delete collection
|
||||
refresherFactory.create(service, client.okHttpClient).refreshHomelessCollections()
|
||||
|
||||
// Check the collection got deleted
|
||||
assertEquals(null, db.collectionDao().get(collectionId))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun refreshHomelessCollections_addsOwnerUrls() {
|
||||
val service = createTestService(Service.TYPE_CARDDAV)!!
|
||||
|
||||
// place homeless collection in DB
|
||||
val collectionId = db.collectionDao().insertOrUpdateByUrl(
|
||||
Collection(
|
||||
0,
|
||||
service.id,
|
||||
null,
|
||||
null,
|
||||
Collection.TYPE_ADDRESSBOOK,
|
||||
mockServer.url("$PATH_CARDDAV$SUBPATH_ADDRESSBOOK/"),
|
||||
)
|
||||
)
|
||||
|
||||
// Refresh homeless collections
|
||||
assertEquals(0, db.principalDao().getByService(service.id).size)
|
||||
refresherFactory.create(service, client.okHttpClient).refreshHomelessCollections()
|
||||
|
||||
// Check principal saved and the collection was updated with its reference
|
||||
val principals = db.principalDao().getByService(service.id)
|
||||
assertEquals(1, principals.size)
|
||||
assertEquals(mockServer.url("$PATH_CARDDAV$SUBPATH_PRINCIPAL"), principals[0].url)
|
||||
assertEquals(null, principals[0].displayName)
|
||||
assertEquals(
|
||||
principals[0].id,
|
||||
db.collectionDao().get(collectionId)!!.ownerId
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
// refreshPrincipals
|
||||
|
||||
@Test
|
||||
fun refreshPrincipals_inaccessiblePrincipal() {
|
||||
val service = createTestService(Service.TYPE_CARDDAV)!!
|
||||
|
||||
// place principal without display name in db
|
||||
val principalId = db.principalDao().insert(
|
||||
Principal(
|
||||
0,
|
||||
service.id,
|
||||
mockServer.url("$PATH_CARDDAV$SUBPATH_PRINCIPAL_INACCESSIBLE"), // no trailing slash
|
||||
null // no display name for now
|
||||
)
|
||||
)
|
||||
// add an associated collection - as the principal is rightfully removed otherwise
|
||||
db.collectionDao().insertOrUpdateByUrl(
|
||||
Collection(
|
||||
0,
|
||||
service.id,
|
||||
null,
|
||||
principalId, // create association with principal
|
||||
Collection.TYPE_ADDRESSBOOK,
|
||||
mockServer.url("$PATH_CARDDAV$SUBPATH_ADDRESSBOOK/"), // with trailing slash
|
||||
)
|
||||
)
|
||||
|
||||
// Refresh principals
|
||||
refresherFactory.create(service, client.okHttpClient).refreshPrincipals()
|
||||
|
||||
// Check principal was not updated
|
||||
val principals = db.principalDao().getByService(service.id)
|
||||
assertEquals(1, principals.size)
|
||||
assertEquals(mockServer.url("$PATH_CARDDAV$SUBPATH_PRINCIPAL_INACCESSIBLE"), principals[0].url)
|
||||
assertEquals(null, principals[0].displayName)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun refreshPrincipals_updatesPrincipal() {
|
||||
val service = createTestService(Service.TYPE_CARDDAV)!!
|
||||
|
||||
// place principal without display name in db
|
||||
val principalId = db.principalDao().insert(
|
||||
Principal(
|
||||
0,
|
||||
service.id,
|
||||
mockServer.url("$PATH_CARDDAV$SUBPATH_PRINCIPAL"), // no trailing slash
|
||||
null // no display name for now
|
||||
)
|
||||
)
|
||||
// add an associated collection - as the principal is rightfully removed otherwise
|
||||
db.collectionDao().insertOrUpdateByUrl(
|
||||
Collection(
|
||||
0,
|
||||
service.id,
|
||||
null,
|
||||
principalId, // create association with principal
|
||||
Collection.TYPE_ADDRESSBOOK,
|
||||
mockServer.url("$PATH_CARDDAV$SUBPATH_ADDRESSBOOK/"), // with trailing slash
|
||||
)
|
||||
)
|
||||
|
||||
// Refresh principals
|
||||
refresherFactory.create(service, client.okHttpClient).refreshPrincipals()
|
||||
|
||||
// Check principal now got a display name
|
||||
val principals = db.principalDao().getByService(service.id)
|
||||
assertEquals(1, principals.size)
|
||||
assertEquals(mockServer.url("$PATH_CARDDAV$SUBPATH_PRINCIPAL"), principals[0].url)
|
||||
assertEquals("Mr. Wobbles", principals[0].displayName)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun refreshPrincipals_deletesPrincipalsWithoutCollections() {
|
||||
val service = createTestService(Service.TYPE_CARDDAV)!!
|
||||
|
||||
// place principal without collections in DB
|
||||
db.principalDao().insert(
|
||||
Principal(
|
||||
0,
|
||||
service.id,
|
||||
mockServer.url("$PATH_CARDDAV$SUBPATH_PRINCIPAL_WITHOUT_COLLECTIONS/")
|
||||
)
|
||||
)
|
||||
|
||||
// Refresh principals - detecting it does not own collections
|
||||
refresherFactory.create(service, client.okHttpClient).refreshPrincipals()
|
||||
|
||||
// Check principal was deleted
|
||||
val principals = db.principalDao().getByService(service.id)
|
||||
assertEquals(0, principals.size)
|
||||
}
|
||||
|
||||
// Others
|
||||
|
||||
@Test
|
||||
fun shouldPreselect_none() {
|
||||
val service = createTestService(Service.TYPE_CARDDAV)!!
|
||||
|
||||
mockkObject(settings) {
|
||||
every { settings.getIntOrNull(Settings.PRESELECT_COLLECTIONS) } returns Settings.PRESELECT_COLLECTIONS_NONE
|
||||
every { settings.getString(Settings.PRESELECT_COLLECTIONS_EXCLUDED) } returns ""
|
||||
|
||||
val collection = Collection(
|
||||
0,
|
||||
service.id,
|
||||
0,
|
||||
type = Collection.TYPE_ADDRESSBOOK,
|
||||
url = mockServer.url("$PATH_CARDDAV$SUBPATH_ADDRESSBOOK/")
|
||||
)
|
||||
val homesets = listOf(
|
||||
HomeSet(0, service.id, true, mockServer.url("$PATH_CARDDAV$SUBPATH_ADDRESSBOOK_HOMESET"))
|
||||
)
|
||||
|
||||
val refresher = refresherFactory.create(service, client.okHttpClient)
|
||||
assertFalse(refresher.shouldPreselect(collection, homesets))
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun shouldPreselect_all() {
|
||||
val service = createTestService(Service.TYPE_CARDDAV)!!
|
||||
|
||||
mockkObject(settings) {
|
||||
every { settings.getIntOrNull(Settings.PRESELECT_COLLECTIONS) } returns Settings.PRESELECT_COLLECTIONS_ALL
|
||||
every { settings.getString(Settings.PRESELECT_COLLECTIONS_EXCLUDED) } returns ""
|
||||
|
||||
val collection = Collection(
|
||||
0,
|
||||
service.id,
|
||||
0,
|
||||
type = Collection.TYPE_ADDRESSBOOK,
|
||||
url = mockServer.url("$PATH_CARDDAV$SUBPATH_ADDRESSBOOK/")
|
||||
)
|
||||
val homesets = listOf(
|
||||
HomeSet(0, service.id, false, mockServer.url("$PATH_CARDDAV$SUBPATH_ADDRESSBOOK_HOMESET"))
|
||||
)
|
||||
|
||||
val refresher = refresherFactory.create(service, client.okHttpClient)
|
||||
assertTrue(refresher.shouldPreselect(collection, homesets))
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun shouldPreselect_all_blacklisted() {
|
||||
val service = createTestService(Service.TYPE_CARDDAV)!!
|
||||
val url = mockServer.url("$PATH_CARDDAV$SUBPATH_ADDRESSBOOK/")
|
||||
|
||||
mockkObject(settings) {
|
||||
every { settings.getIntOrNull(Settings.PRESELECT_COLLECTIONS) } returns Settings.PRESELECT_COLLECTIONS_ALL
|
||||
every { settings.getString(Settings.PRESELECT_COLLECTIONS_EXCLUDED) } returns url.toString()
|
||||
|
||||
val collection = Collection(
|
||||
0,
|
||||
service.id,
|
||||
0,
|
||||
type = Collection.TYPE_ADDRESSBOOK,
|
||||
url = url
|
||||
)
|
||||
val homesets = listOf(
|
||||
HomeSet(0, service.id, false, mockServer.url("$PATH_CARDDAV$SUBPATH_ADDRESSBOOK_HOMESET"))
|
||||
)
|
||||
|
||||
val refresher = refresherFactory.create(service, client.okHttpClient)
|
||||
assertFalse(refresher.shouldPreselect(collection, homesets))
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun shouldPreselect_personal_notPersonal() {
|
||||
val service = createTestService(Service.TYPE_CARDDAV)!!
|
||||
|
||||
mockkObject(settings) {
|
||||
every { settings.getIntOrNull(Settings.PRESELECT_COLLECTIONS) } returns Settings.PRESELECT_COLLECTIONS_PERSONAL
|
||||
every { settings.getString(Settings.PRESELECT_COLLECTIONS_EXCLUDED) } returns ""
|
||||
|
||||
val collection = Collection(
|
||||
0,
|
||||
service.id,
|
||||
0,
|
||||
type = Collection.TYPE_ADDRESSBOOK,
|
||||
url = mockServer.url("$PATH_CARDDAV$SUBPATH_ADDRESSBOOK/")
|
||||
)
|
||||
val homesets = listOf(
|
||||
HomeSet(0, service.id, false, mockServer.url("$PATH_CARDDAV$SUBPATH_ADDRESSBOOK_HOMESET"))
|
||||
)
|
||||
|
||||
val refresher = refresherFactory.create(service, client.okHttpClient)
|
||||
assertFalse(refresher.shouldPreselect(collection, homesets))
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun shouldPreselect_personal_isPersonal() {
|
||||
val service = createTestService(Service.TYPE_CARDDAV)!!
|
||||
|
||||
mockkObject(settings) {
|
||||
every { settings.getIntOrNull(Settings.PRESELECT_COLLECTIONS) } returns Settings.PRESELECT_COLLECTIONS_PERSONAL
|
||||
every { settings.getString(Settings.PRESELECT_COLLECTIONS_EXCLUDED) } returns ""
|
||||
|
||||
val collection = Collection(
|
||||
0,
|
||||
service.id,
|
||||
0,
|
||||
type = Collection.TYPE_ADDRESSBOOK,
|
||||
url = mockServer.url("$PATH_CARDDAV$SUBPATH_ADDRESSBOOK/")
|
||||
)
|
||||
val homesets = listOf(
|
||||
HomeSet(0, service.id, true, mockServer.url("$PATH_CARDDAV$SUBPATH_ADDRESSBOOK_HOMESET"))
|
||||
)
|
||||
|
||||
val refresher = refresherFactory.create(service, client.okHttpClient)
|
||||
assertTrue(refresher.shouldPreselect(collection, homesets))
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun shouldPreselect_personal_isPersonalButBlacklisted() {
|
||||
val service = createTestService(Service.TYPE_CARDDAV)!!
|
||||
val collectionUrl = mockServer.url("$PATH_CARDDAV$SUBPATH_ADDRESSBOOK/")
|
||||
|
||||
mockkObject(settings) {
|
||||
every { settings.getIntOrNull(Settings.PRESELECT_COLLECTIONS) } returns Settings.PRESELECT_COLLECTIONS_PERSONAL
|
||||
every { settings.getString(Settings.PRESELECT_COLLECTIONS_EXCLUDED) } returns collectionUrl.toString()
|
||||
|
||||
val collection = Collection(
|
||||
0,
|
||||
service.id,
|
||||
0,
|
||||
type = Collection.TYPE_ADDRESSBOOK,
|
||||
url = collectionUrl
|
||||
)
|
||||
val homesets = listOf(
|
||||
HomeSet(0, service.id, true, mockServer.url("$PATH_CARDDAV$SUBPATH_ADDRESSBOOK_HOMESET"))
|
||||
)
|
||||
|
||||
val refresher = refresherFactory.create(service, client.okHttpClient)
|
||||
assertFalse(refresher.shouldPreselect(collection, homesets))
|
||||
}
|
||||
}
|
||||
|
||||
// Test helpers and dependencies
|
||||
|
||||
private fun createTestService(serviceType: String) : Service? {
|
||||
val service = Service(id=0, accountName="test", type=serviceType, principal = null)
|
||||
val serviceId = db.serviceDao().insertOrReplace(service)
|
||||
return db.serviceDao().get(serviceId)
|
||||
}
|
||||
|
||||
class TestDispatcher: Dispatcher() {
|
||||
|
||||
override fun dispatch(request: RecordedRequest): MockResponse {
|
||||
val path = StringUtils.removeEnd(request.path!!, "/")
|
||||
|
||||
if (request.method.equals("PROPFIND", true)) {
|
||||
val properties = when (path) {
|
||||
PATH_CALDAV,
|
||||
PATH_CARDDAV ->
|
||||
"<current-user-principal>" +
|
||||
" <href>$path${SUBPATH_PRINCIPAL}</href>" +
|
||||
"</current-user-principal>"
|
||||
|
||||
PATH_CARDDAV + SUBPATH_PRINCIPAL ->
|
||||
"<resourcetype><principal/></resourcetype>" +
|
||||
"<displayname>Mr. Wobbles</displayname>" +
|
||||
"<CARD:addressbook-home-set>" +
|
||||
" <href>${PATH_CARDDAV}${SUBPATH_ADDRESSBOOK_HOMESET}</href>" +
|
||||
"</CARD:addressbook-home-set>"
|
||||
|
||||
PATH_CARDDAV + SUBPATH_PRINCIPAL_WITHOUT_COLLECTIONS ->
|
||||
"<CARD:addressbook-home-set>" +
|
||||
" <href>${PATH_CARDDAV}${SUBPATH_ADDRESSBOOK_HOMESET_EMPTY}</href>" +
|
||||
"</CARD:addressbook-home-set>" +
|
||||
"<displayname>Mr. Wobbles Jr.</displayname>"
|
||||
|
||||
PATH_CARDDAV + SUBPATH_ADDRESSBOOK,
|
||||
PATH_CARDDAV + SUBPATH_ADDRESSBOOK_HOMESET ->
|
||||
"<resourcetype>" +
|
||||
" <collection/>" +
|
||||
" <CARD:addressbook/>" +
|
||||
"</resourcetype>" +
|
||||
"<displayname>My Contacts</displayname>" +
|
||||
"<CARD:addressbook-description>My Contacts Description</CARD:addressbook-description>" +
|
||||
"<owner>" +
|
||||
" <href>${PATH_CARDDAV + SUBPATH_PRINCIPAL}</href>" +
|
||||
"</owner>"
|
||||
|
||||
PATH_CALDAV + SUBPATH_PRINCIPAL ->
|
||||
"<CAL:calendar-user-address-set>" +
|
||||
" <href>urn:unknown-entry</href>" +
|
||||
" <href>mailto:email1@example.com</href>" +
|
||||
" <href>mailto:email2@example.com</href>" +
|
||||
"</CAL:calendar-user-address-set>"
|
||||
|
||||
SUBPATH_ADDRESSBOOK_HOMESET_EMPTY -> ""
|
||||
|
||||
else -> ""
|
||||
}
|
||||
|
||||
var responseBody = ""
|
||||
var responseCode = 207
|
||||
when (path) {
|
||||
PATH_CARDDAV + SUBPATH_ADDRESSBOOK_HOMESET ->
|
||||
responseBody =
|
||||
"<multistatus xmlns='DAV:' xmlns:CARD='urn:ietf:params:xml:ns:carddav' xmlns:CAL='urn:ietf:params:xml:ns:caldav'>" +
|
||||
"<response>" +
|
||||
" <href>${PATH_CARDDAV + SUBPATH_ADDRESSBOOK}</href>" +
|
||||
" <propstat><prop>" +
|
||||
properties +
|
||||
" </prop></propstat>" +
|
||||
" <status>HTTP/1.1 200 OK</status>" +
|
||||
"</response>" +
|
||||
"</multistatus>"
|
||||
|
||||
PATH_CARDDAV + SUBPATH_PRINCIPAL_INACCESSIBLE,
|
||||
PATH_CARDDAV + SUBPATH_ADDRESSBOOK_INACCESSIBLE ->
|
||||
responseCode = 404
|
||||
|
||||
else ->
|
||||
responseBody =
|
||||
"<multistatus xmlns='DAV:' xmlns:CARD='urn:ietf:params:xml:ns:carddav' xmlns:CAL='urn:ietf:params:xml:ns:caldav'>" +
|
||||
"<response>" +
|
||||
" <href>$path</href>" +
|
||||
" <propstat><prop>"+
|
||||
properties +
|
||||
" </prop></propstat>" +
|
||||
"</response>" +
|
||||
"</multistatus>"
|
||||
}
|
||||
|
||||
Logger.log.info("Queried: $path")
|
||||
Logger.log.info("Response: $responseBody")
|
||||
return MockResponse()
|
||||
.setResponseCode(responseCode)
|
||||
.setBody(responseBody)
|
||||
}
|
||||
|
||||
return MockResponse().setResponseCode(404)
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
@@ -4,16 +4,19 @@
|
||||
|
||||
package at.bitfire.davdroid.servicedetection
|
||||
|
||||
import at.bitfire.dav4jvm.okhttp.DavResource
|
||||
import at.bitfire.dav4jvm.property.carddav.CardDAV
|
||||
import at.bitfire.dav4jvm.property.webdav.WebDAV
|
||||
import at.bitfire.davdroid.network.HttpClientBuilder
|
||||
import android.security.NetworkSecurityPolicy
|
||||
import androidx.test.filters.SmallTest
|
||||
import androidx.test.platform.app.InstrumentationRegistry
|
||||
import at.bitfire.dav4jvm.DavResource
|
||||
import at.bitfire.dav4jvm.property.carddav.AddressbookHomeSet
|
||||
import at.bitfire.dav4jvm.property.webdav.ResourceType
|
||||
import at.bitfire.davdroid.db.Credentials
|
||||
import at.bitfire.davdroid.log.Logger
|
||||
import at.bitfire.davdroid.network.HttpClient
|
||||
import at.bitfire.davdroid.servicedetection.DavResourceFinder.Configuration.ServiceInfo
|
||||
import at.bitfire.davdroid.settings.Credentials
|
||||
import at.bitfire.davdroid.util.SensitiveString.Companion.toSensitiveString
|
||||
import at.bitfire.davdroid.settings.SettingsManager
|
||||
import dagger.hilt.android.testing.HiltAndroidRule
|
||||
import dagger.hilt.android.testing.HiltAndroidTest
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.mockwebserver.Dispatcher
|
||||
import okhttp3.mockwebserver.MockResponse
|
||||
import okhttp3.mockwebserver.MockWebServer
|
||||
@@ -24,16 +27,27 @@ import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Assume
|
||||
import org.junit.Before
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import java.net.URI
|
||||
import java.util.logging.Logger
|
||||
import javax.inject.Inject
|
||||
|
||||
@HiltAndroidTest
|
||||
class DavResourceFinderTest {
|
||||
|
||||
@get:Rule
|
||||
val hiltRule = HiltAndroidRule(this)
|
||||
|
||||
@Inject
|
||||
lateinit var settingsManager: SettingsManager
|
||||
|
||||
@Before
|
||||
fun inject() {
|
||||
hiltRule.inject()
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val PATH_NO_DAV = "/nodav"
|
||||
private const val PATH_CALDAV = "/caldav"
|
||||
@@ -45,53 +59,41 @@ class DavResourceFinderTest {
|
||||
private const val SUBPATH_ADDRESSBOOK = "/addressbooks/private-contacts"
|
||||
}
|
||||
|
||||
@get:Rule
|
||||
val hiltRule = HiltAndroidRule(this)
|
||||
val server = MockWebServer()
|
||||
|
||||
@Inject
|
||||
lateinit var httpClientBuilder: HttpClientBuilder
|
||||
|
||||
@Inject
|
||||
lateinit var logger: Logger
|
||||
|
||||
@Inject
|
||||
lateinit var resourceFinderFactory: DavResourceFinder.Factory
|
||||
|
||||
private lateinit var server: MockWebServer
|
||||
private lateinit var client: OkHttpClient
|
||||
private lateinit var finder: DavResourceFinder
|
||||
lateinit var finder: DavResourceFinder
|
||||
lateinit var client: HttpClient
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
hiltRule.inject()
|
||||
|
||||
server = MockWebServer().apply {
|
||||
dispatcher = TestDispatcher(logger)
|
||||
start()
|
||||
}
|
||||
|
||||
val credentials = Credentials(username = "mock", password = "12345".toSensitiveString())
|
||||
client = httpClientBuilder
|
||||
.authenticate(domain = null, getCredentials = { credentials })
|
||||
.build()
|
||||
fun initServerAndClient() {
|
||||
server.dispatcher = TestDispatcher()
|
||||
server.start()
|
||||
|
||||
val baseURI = URI.create("/")
|
||||
finder = resourceFinderFactory.create(baseURI, credentials)
|
||||
val credentials = Credentials("mock", "12345")
|
||||
|
||||
finder = DavResourceFinder(InstrumentationRegistry.getInstrumentation().targetContext, baseURI, credentials)
|
||||
client = HttpClient.Builder(InstrumentationRegistry.getInstrumentation().targetContext)
|
||||
.addAuthentication(null, credentials)
|
||||
.build()
|
||||
|
||||
Assume.assumeTrue(NetworkSecurityPolicy.getInstance().isCleartextTrafficPermitted)
|
||||
}
|
||||
|
||||
@After
|
||||
fun tearDown() {
|
||||
fun stopServer() {
|
||||
server.shutdown()
|
||||
}
|
||||
|
||||
|
||||
@Test
|
||||
@SmallTest
|
||||
fun testRememberIfAddressBookOrHomeset() {
|
||||
// recognize home set
|
||||
var info = ServiceInfo()
|
||||
DavResource(client, server.url(PATH_CARDDAV + SUBPATH_PRINCIPAL))
|
||||
.propfind(0, CardDAV.AddressbookHomeSet) { response, _ ->
|
||||
finder.scanResponse(CardDAV.Addressbook, response, info)
|
||||
DavResource(client.okHttpClient, server.url(PATH_CARDDAV + SUBPATH_PRINCIPAL))
|
||||
.propfind(0, AddressbookHomeSet.NAME) { response, _ ->
|
||||
finder.scanResponse(ResourceType.ADDRESSBOOK, response, info)
|
||||
}
|
||||
assertEquals(0, info.collections.size)
|
||||
assertEquals(1, info.homeSets.size)
|
||||
@@ -99,9 +101,9 @@ class DavResourceFinderTest {
|
||||
|
||||
// recognize address book
|
||||
info = ServiceInfo()
|
||||
DavResource(client, server.url(PATH_CARDDAV + SUBPATH_ADDRESSBOOK))
|
||||
.propfind(0, WebDAV.ResourceType) { response, _ ->
|
||||
finder.scanResponse(CardDAV.Addressbook, response, info)
|
||||
DavResource(client.okHttpClient, server.url(PATH_CARDDAV + SUBPATH_ADDRESSBOOK))
|
||||
.propfind(0, ResourceType.NAME) { response, _ ->
|
||||
finder.scanResponse(ResourceType.ADDRESSBOOK, response, info)
|
||||
}
|
||||
assertEquals(1, info.collections.size)
|
||||
assertEquals(server.url("$PATH_CARDDAV$SUBPATH_ADDRESSBOOK/"), info.collections.keys.first())
|
||||
@@ -154,9 +156,7 @@ class DavResourceFinderTest {
|
||||
|
||||
// mock server
|
||||
|
||||
class TestDispatcher(
|
||||
private val logger: Logger
|
||||
): Dispatcher() {
|
||||
class TestDispatcher: Dispatcher() {
|
||||
|
||||
override fun dispatch(request: RecordedRequest): MockResponse {
|
||||
if (!checkAuth(request)) {
|
||||
@@ -205,7 +205,7 @@ class DavResourceFinderTest {
|
||||
|
||||
else -> props = null
|
||||
}
|
||||
logger.info("Sending props: $props")
|
||||
Logger.log.info("Sending props: $props")
|
||||
return MockResponse()
|
||||
.setResponseCode(207)
|
||||
.setBody("<multistatus xmlns='DAV:' xmlns:CARD='urn:ietf:params:xml:ns:carddav' xmlns:CAL='urn:ietf:params:xml:ns:caldav'>" +
|
||||
@@ -10,7 +10,7 @@ import dagger.hilt.android.testing.HiltAndroidTest
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.flow.take
|
||||
import kotlinx.coroutines.flow.toSet
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import org.junit.After
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
@@ -59,7 +59,7 @@ class SettingsManagerTest {
|
||||
|
||||
|
||||
@Test
|
||||
fun test_observerFlow_initialValue() = runTest {
|
||||
fun test_observerFlow_initialValue() = runBlocking {
|
||||
var counter = 0
|
||||
val live = settingsManager.observerFlow {
|
||||
if (counter++ == 0)
|
||||
@@ -71,7 +71,7 @@ class SettingsManagerTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
fun test_observerFlow_updatedValue() = runTest {
|
||||
fun test_observerFlow_updatedValue() = runBlocking {
|
||||
var counter = 0
|
||||
val live = settingsManager.observerFlow {
|
||||
when (counter++) {
|
||||
@@ -19,7 +19,7 @@ class DebugInfoActivityTest {
|
||||
val expected = StringBuilder(DebugInfoActivity.IntentBuilder.MAX_ELEMENT_SIZE)
|
||||
expected.append(String(ByteArray(DebugInfoActivity.IntentBuilder.MAX_ELEMENT_SIZE - 3) { a }))
|
||||
expected.append("...")
|
||||
assertEquals(expected.toString(), intent.getStringExtra(DebugInfoActivity.EXTRA_LOCAL_RESOURCE_SUMMARY))
|
||||
assertEquals(expected.toString(), intent.getStringExtra("localResource"))
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -4,8 +4,7 @@
|
||||
|
||||
package at.bitfire.davdroid.webdav
|
||||
|
||||
import at.bitfire.davdroid.settings.Credentials
|
||||
import at.bitfire.davdroid.util.SensitiveString.Companion.toSensitiveString
|
||||
import at.bitfire.davdroid.db.Credentials
|
||||
import dagger.hilt.android.testing.HiltAndroidRule
|
||||
import dagger.hilt.android.testing.HiltAndroidTest
|
||||
import org.junit.Assert.assertEquals
|
||||
@@ -31,8 +30,8 @@ class CredentialsStoreTest {
|
||||
|
||||
@Test
|
||||
fun testSetGetDelete() {
|
||||
store.setCredentials(0, Credentials(username = "myname", password = "12345".toSensitiveString()))
|
||||
assertEquals(Credentials(username = "myname", password = "12345".toSensitiveString()), store.getCredentials(0))
|
||||
store.setCredentials(0, Credentials(username = "myname", password = "12345"))
|
||||
assertEquals(Credentials(username = "myname", password = "12345"), store.getCredentials(0))
|
||||
|
||||
store.setCredentials(0, null)
|
||||
assertNull(store.getCredentials(0))
|
||||
@@ -2,21 +2,20 @@
|
||||
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
|
||||
*/
|
||||
|
||||
package at.bitfire.davdroid.webdav.operation
|
||||
package at.bitfire.davdroid.webdav
|
||||
|
||||
import android.content.Context
|
||||
import android.security.NetworkSecurityPolicy
|
||||
import androidx.test.platform.app.InstrumentationRegistry
|
||||
import at.bitfire.davdroid.R
|
||||
import at.bitfire.davdroid.db.AppDatabase
|
||||
import at.bitfire.davdroid.db.WebDavDocument
|
||||
import at.bitfire.davdroid.db.WebDavMount
|
||||
import at.bitfire.davdroid.network.HttpClientBuilder
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import at.bitfire.davdroid.log.Logger
|
||||
import at.bitfire.davdroid.network.HttpClient
|
||||
import dagger.hilt.android.testing.HiltAndroidRule
|
||||
import dagger.hilt.android.testing.HiltAndroidTest
|
||||
import io.mockk.junit4.MockKRule
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.CookieJar
|
||||
import okhttp3.mockwebserver.Dispatcher
|
||||
import okhttp3.mockwebserver.MockResponse
|
||||
import okhttp3.mockwebserver.MockWebServer
|
||||
@@ -27,95 +26,84 @@ import org.junit.Assert.assertTrue
|
||||
import org.junit.Before
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import java.util.logging.Logger
|
||||
import javax.inject.Inject
|
||||
|
||||
@HiltAndroidTest
|
||||
class QueryChildDocumentsOperationTest {
|
||||
class DavDocumentsProviderTest {
|
||||
|
||||
@get:Rule
|
||||
val hiltRule = HiltAndroidRule(this)
|
||||
|
||||
@get:Rule
|
||||
val mockkRule = MockKRule(this)
|
||||
val context: Context = InstrumentationRegistry.getInstrumentation().targetContext
|
||||
|
||||
@Inject @ApplicationContext
|
||||
lateinit var context: Context
|
||||
|
||||
@Inject
|
||||
lateinit var db: AppDatabase
|
||||
|
||||
@Inject
|
||||
lateinit var operation: QueryChildDocumentsOperation
|
||||
|
||||
@Inject
|
||||
lateinit var httpClientBuilder: HttpClientBuilder
|
||||
|
||||
@Inject
|
||||
lateinit var testDispatcher: TestDispatcher
|
||||
|
||||
private lateinit var server: MockWebServer
|
||||
private lateinit var client: OkHttpClient
|
||||
|
||||
private lateinit var mount: WebDavMount
|
||||
private lateinit var rootDocument: WebDavDocument
|
||||
@Inject lateinit var db: AppDatabase
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
hiltRule.inject()
|
||||
}
|
||||
|
||||
// create server and client
|
||||
server = MockWebServer().apply {
|
||||
dispatcher = testDispatcher
|
||||
start()
|
||||
}
|
||||
private var mockServer = MockWebServer()
|
||||
|
||||
client = httpClientBuilder.build()
|
||||
private lateinit var client: HttpClient
|
||||
|
||||
companion object {
|
||||
private const val PATH_WEBDAV_ROOT = "/webdav"
|
||||
}
|
||||
|
||||
@Before
|
||||
fun mockServerSetup() {
|
||||
// Start mock web server
|
||||
mockServer.dispatcher = TestDispatcher()
|
||||
mockServer.start()
|
||||
|
||||
client = HttpClient.Builder(InstrumentationRegistry.getInstrumentation().targetContext).build()
|
||||
|
||||
// mock server delivers HTTP without encryption
|
||||
assertTrue(NetworkSecurityPolicy.getInstance().isCleartextTrafficPermitted)
|
||||
|
||||
// create WebDAV mount and root document in DB
|
||||
runBlocking {
|
||||
val mountId = db.webDavMountDao().insert(WebDavMount(0, "Cat food storage", server.url(PATH_WEBDAV_ROOT)))
|
||||
mount = db.webDavMountDao().getById(mountId)
|
||||
rootDocument = db.webDavDocumentDao().getOrCreateRoot(mount)
|
||||
}
|
||||
}
|
||||
|
||||
@After
|
||||
fun tearDown() {
|
||||
server.shutdown()
|
||||
|
||||
runBlocking {
|
||||
db.webDavMountDao().deleteAsync(mount)
|
||||
}
|
||||
fun cleanUp() {
|
||||
mockServer.shutdown()
|
||||
db.close()
|
||||
}
|
||||
|
||||
|
||||
@Test
|
||||
fun testDoQueryChildren_insert() = runTest {
|
||||
fun testDoQueryChildren_insert() {
|
||||
// Create parent and root in database
|
||||
val id = db.webDavMountDao().insert(WebDavMount(0, "Cat food storage", mockServer.url(PATH_WEBDAV_ROOT)))
|
||||
val webDavMount = db.webDavMountDao().getById(id)
|
||||
val parent = db.webDavDocumentDao().getOrCreateRoot(webDavMount)
|
||||
val cookieStore = mutableMapOf<Long, CookieJar>()
|
||||
|
||||
// Query
|
||||
operation.queryChildren(rootDocument)
|
||||
DavDocumentsProvider.DavDocumentsActor(context, db, cookieStore, CredentialsStore(context), context.getString(R.string.webdav_authority))
|
||||
.queryChildren(parent)
|
||||
|
||||
// Assert new children were inserted into db
|
||||
assertEquals(3, db.webDavDocumentDao().getChildren(rootDocument.id).size)
|
||||
assertEquals("Library", db.webDavDocumentDao().getChildren(rootDocument.id)[0].displayName)
|
||||
assertEquals("MeowMeow_Cats.docx", db.webDavDocumentDao().getChildren(rootDocument.id)[1].displayName)
|
||||
assertEquals("Secret_Document.pages", db.webDavDocumentDao().getChildren(rootDocument.id)[2].displayName)
|
||||
assertEquals(3, db.webDavDocumentDao().getChildren(parent.id).size)
|
||||
assertEquals("Secret_Document.pages", db.webDavDocumentDao().getChildren(parent.id)[0].displayName)
|
||||
assertEquals("MeowMeow_Cats.docx", db.webDavDocumentDao().getChildren(parent.id)[1].displayName)
|
||||
assertEquals("Library", db.webDavDocumentDao().getChildren(parent.id)[2].displayName)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testDoQueryChildren_update() = runTest {
|
||||
fun testDoQueryChildren_update() {
|
||||
// Create parent and root in database
|
||||
assertEquals("Cat food storage", db.webDavDocumentDao().get(rootDocument.id)!!.displayName)
|
||||
val mountId = db.webDavMountDao().insert(WebDavMount(0, "Cat food storage", mockServer.url(PATH_WEBDAV_ROOT)))
|
||||
val webDavMount = db.webDavMountDao().getById(mountId)
|
||||
val parent = db.webDavDocumentDao().getOrCreateRoot(webDavMount)
|
||||
val cookieStore = mutableMapOf<Long, CookieJar>()
|
||||
assertEquals("Cat food storage", db.webDavDocumentDao().get(parent.id)!!.displayName)
|
||||
|
||||
// Create a folder
|
||||
val folderId = db.webDavDocumentDao().insert(
|
||||
WebDavDocument(
|
||||
0,
|
||||
mount.id,
|
||||
rootDocument.id,
|
||||
mountId,
|
||||
parent.id,
|
||||
"My_Books",
|
||||
true,
|
||||
"My Books",
|
||||
@@ -125,43 +113,59 @@ class QueryChildDocumentsOperationTest {
|
||||
assertEquals("My Books", db.webDavDocumentDao().get(folderId)!!.displayName)
|
||||
|
||||
// Query - should update the parent displayname and folder name
|
||||
operation.queryChildren(rootDocument)
|
||||
DavDocumentsProvider.DavDocumentsActor(context, db, cookieStore, CredentialsStore(context), context.getString(R.string.webdav_authority))
|
||||
.queryChildren(parent)
|
||||
|
||||
// Assert parent and children were updated in database
|
||||
assertEquals("Cats WebDAV", db.webDavDocumentDao().get(rootDocument.id)!!.displayName)
|
||||
assertEquals("Library", db.webDavDocumentDao().getChildren(rootDocument.id)[0].name)
|
||||
assertEquals("Library", db.webDavDocumentDao().getChildren(rootDocument.id)[0].displayName)
|
||||
assertEquals("Cats WebDAV", db.webDavDocumentDao().get(parent.id)!!.displayName)
|
||||
assertEquals("Library", db.webDavDocumentDao().getChildren(parent.id)[2].name)
|
||||
assertEquals("Library", db.webDavDocumentDao().getChildren(parent.id)[2].displayName)
|
||||
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testDoQueryChildren_delete() = runTest {
|
||||
fun testDoQueryChildren_delete() {
|
||||
// Create parent and root in database
|
||||
val mountId = db.webDavMountDao().insert(WebDavMount(0, "Cat food storage", mockServer.url(PATH_WEBDAV_ROOT)))
|
||||
val webDavMount = db.webDavMountDao().getById(mountId)
|
||||
val parent = db.webDavDocumentDao().getOrCreateRoot(webDavMount)
|
||||
val cookieStore = mutableMapOf<Long, CookieJar>()
|
||||
|
||||
// Create a folder
|
||||
val folderId = db.webDavDocumentDao().insert(
|
||||
WebDavDocument(0, mount.id, rootDocument.id, "deleteme", true, "Should be deleted")
|
||||
WebDavDocument(0, mountId, parent.id, "deleteme", true, "Should be deleted")
|
||||
)
|
||||
assertEquals("deleteme", db.webDavDocumentDao().get(folderId)!!.name)
|
||||
|
||||
// Query - discovers serverside deletion
|
||||
operation.queryChildren(rootDocument)
|
||||
DavDocumentsProvider.DavDocumentsActor(context, db, cookieStore, CredentialsStore(context), context.getString(R.string.webdav_authority))
|
||||
.queryChildren(parent)
|
||||
|
||||
// Assert folder got deleted
|
||||
assertEquals(null, db.webDavDocumentDao().get(folderId))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testDoQueryChildren_updateTwoDirectoriesSimultaneously() = runTest {
|
||||
// Create two directories
|
||||
val parent1Id = db.webDavDocumentDao().insert(WebDavDocument(0, mount.id, rootDocument.id, "parent1", true))
|
||||
val parent2Id = db.webDavDocumentDao().insert(WebDavDocument(0, mount.id, rootDocument.id, "parent2", true))
|
||||
fun testDoQueryChildren_updateTwoParentsSimultaneous() {
|
||||
// Create root in database
|
||||
val mountId = db.webDavMountDao().insert(WebDavMount(0, "Cat food storage", mockServer.url(PATH_WEBDAV_ROOT)))
|
||||
val webDavMount = db.webDavMountDao().getById(mountId)
|
||||
val root = db.webDavDocumentDao().getOrCreateRoot(webDavMount)
|
||||
val cookieStore = mutableMapOf<Long, CookieJar>()
|
||||
|
||||
// Create two parents
|
||||
val parent1Id = db.webDavDocumentDao().insert(WebDavDocument(0, mountId, root.id, "parent1", true))
|
||||
val parent2Id = db.webDavDocumentDao().insert(WebDavDocument(0, mountId, root.id, "parent2", true))
|
||||
val parent1 = db.webDavDocumentDao().get(parent1Id)!!
|
||||
val parent2 = db.webDavDocumentDao().get(parent2Id)!!
|
||||
assertEquals("parent1", parent1.name)
|
||||
assertEquals("parent2", parent2.name)
|
||||
|
||||
// Query - find children of two nodes simultaneously
|
||||
operation.queryChildren(parent1)
|
||||
operation.queryChildren(parent2)
|
||||
DavDocumentsProvider.DavDocumentsActor(context, db, cookieStore, CredentialsStore(context), context.getString(R.string.webdav_authority))
|
||||
.queryChildren(parent1)
|
||||
DavDocumentsProvider.DavDocumentsActor(context, db, cookieStore, CredentialsStore(context), context.getString(R.string.webdav_authority))
|
||||
.queryChildren(parent2)
|
||||
|
||||
// Assert the two folders names have changed
|
||||
assertEquals("childOne.txt", db.webDavDocumentDao().getChildren(parent1Id)[0].name)
|
||||
@@ -171,9 +175,7 @@ class QueryChildDocumentsOperationTest {
|
||||
|
||||
// mock server
|
||||
|
||||
class TestDispatcher @Inject constructor(
|
||||
private val logger: Logger
|
||||
): Dispatcher() {
|
||||
class TestDispatcher: Dispatcher() {
|
||||
|
||||
data class Resource(
|
||||
val name: String,
|
||||
@@ -181,15 +183,15 @@ class QueryChildDocumentsOperationTest {
|
||||
)
|
||||
|
||||
override fun dispatch(request: RecordedRequest): MockResponse {
|
||||
logger.info("Request: $request")
|
||||
val requestPath = request.path!!.trimEnd('/')
|
||||
|
||||
if (request.method.equals("PROPFIND", true)) {
|
||||
|
||||
val propsMap = mutableMapOf(
|
||||
PATH_WEBDAV_ROOT to arrayOf(
|
||||
Resource("",
|
||||
"<resourcetype><collection/></resourcetype>" +
|
||||
"<displayname>Cats WebDAV</displayname>"
|
||||
"<displayname>Cats WebDAV</displayname>"
|
||||
),
|
||||
Resource("Secret_Document.pages",
|
||||
"<displayname>Secret_Document.pages</displayname>",
|
||||
@@ -199,7 +201,7 @@ class QueryChildDocumentsOperationTest {
|
||||
),
|
||||
Resource("Library",
|
||||
"<resourcetype><collection/></resourcetype>" +
|
||||
"<displayname>Library</displayname>"
|
||||
"<displayname>Library</displayname>"
|
||||
)
|
||||
),
|
||||
|
||||
@@ -218,17 +220,18 @@ class QueryChildDocumentsOperationTest {
|
||||
val responses = propsMap[requestPath]?.joinToString { resource ->
|
||||
"<response><href>$requestPath/${resource.name}</href><propstat><prop>" +
|
||||
resource.props +
|
||||
"</prop></propstat></response>"
|
||||
"</prop></propstat></response>"
|
||||
}
|
||||
|
||||
val multistatus =
|
||||
"<multistatus xmlns='DAV:' " +
|
||||
"xmlns:CARD='urn:ietf:params:xml:ns:carddav' " +
|
||||
"xmlns:CAL='urn:ietf:params:xml:ns:caldav'>" +
|
||||
responses +
|
||||
"</multistatus>"
|
||||
"xmlns:CARD='urn:ietf:params:xml:ns:carddav' " +
|
||||
"xmlns:CAL='urn:ietf:params:xml:ns:caldav'>" +
|
||||
responses +
|
||||
"</multistatus>"
|
||||
|
||||
logger.info("Response: $multistatus")
|
||||
Logger.log.info("Query path: $requestPath")
|
||||
Logger.log.info("Response: $multistatus")
|
||||
return MockResponse()
|
||||
.setResponseCode(207)
|
||||
.setBody(multistatus)
|
||||
@@ -239,9 +242,4 @@ class QueryChildDocumentsOperationTest {
|
||||
|
||||
}
|
||||
|
||||
|
||||
companion object {
|
||||
private const val PATH_WEBDAV_ROOT = "/webdav"
|
||||
}
|
||||
|
||||
}
|
||||
@@ -6,11 +6,11 @@ package at.bitfire.davdroid.webdav
|
||||
|
||||
import dagger.hilt.android.testing.HiltAndroidRule
|
||||
import dagger.hilt.android.testing.HiltAndroidTest
|
||||
import junit.framework.TestCase.assertEquals
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import okhttp3.mockwebserver.MockResponse
|
||||
import okhttp3.mockwebserver.MockWebServer
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Before
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
@@ -34,33 +34,33 @@ class WebDavMountRepositoryTest {
|
||||
val url = web.url("/")
|
||||
|
||||
@Test
|
||||
fun testHasWebDav_NoDavHeader() = runTest {
|
||||
fun testHasWebDav_NoDavHeader() = runBlocking {
|
||||
web.enqueue(MockResponse().setResponseCode(200))
|
||||
assertNull(repository.hasWebDav(url, null))
|
||||
assertFalse(repository.hasWebDav(url, null))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testHasWebDav_DavClass1() = runTest {
|
||||
fun testHasWebDav_DavClass1() = runBlocking {
|
||||
web.enqueue(MockResponse()
|
||||
.setResponseCode(200)
|
||||
.addHeader("DAV: 1"))
|
||||
assertEquals(url, repository.hasWebDav(url, null))
|
||||
assertTrue(repository.hasWebDav(url, null))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testHasWebDav_DavClass2() = runTest {
|
||||
fun testHasWebDav_DavClass2() = runBlocking {
|
||||
web.enqueue(MockResponse()
|
||||
.setResponseCode(200)
|
||||
.addHeader("DAV: 1, 2"))
|
||||
assertEquals(url,repository.hasWebDav(url, null))
|
||||
assertTrue(repository.hasWebDav(url, null))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testHasWebDav_DavClass3() = runTest {
|
||||
fun testHasWebDav_DavClass3() = runBlocking {
|
||||
web.enqueue(MockResponse()
|
||||
.setResponseCode(200)
|
||||
.addHeader("DAV: 1, 3"))
|
||||
assertEquals(url,repository.hasWebDav(url, null))
|
||||
assertTrue(repository.hasWebDav(url, null))
|
||||
}
|
||||
|
||||
}
|
||||
BIN
app/src/androidTest/res/drawable-hdpi/ic_launcher.png
Normal file
BIN
app/src/androidTest/res/drawable-hdpi/ic_launcher.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 9.2 KiB |
BIN
app/src/androidTest/res/drawable-ldpi/ic_launcher.png
Normal file
BIN
app/src/androidTest/res/drawable-ldpi/ic_launcher.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 2.7 KiB |
BIN
app/src/androidTest/res/drawable-mdpi/ic_launcher.png
Normal file
BIN
app/src/androidTest/res/drawable-mdpi/ic_launcher.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 5.1 KiB |
BIN
app/src/androidTest/res/drawable-xhdpi/ic_launcher.png
Normal file
BIN
app/src/androidTest/res/drawable-xhdpi/ic_launcher.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 14 KiB |
14
app/src/androidTest/res/values/strings.xml
Normal file
14
app/src/androidTest/res/values/strings.xml
Normal file
@@ -0,0 +1,14 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!--
|
||||
~ Copyright (c) Ricki Hirner (bitfire web engineering).
|
||||
~ All rights reserved. This program and the accompanying materials
|
||||
~ are made available under the terms of the GNU Public License v3.0
|
||||
~ which accompanies this distribution, and is available at
|
||||
~ http://www.gnu.org/licenses/gpl.html
|
||||
-->
|
||||
|
||||
<resources>
|
||||
|
||||
<string name="app_name">Davx5Test</string>
|
||||
|
||||
</resources>
|
||||
@@ -0,0 +1,3 @@
|
||||
<account-authenticator xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:accountType="at.bitfire.davdroid.SyncManagerTest"
|
||||
android:label="Test Account" />
|
||||
@@ -2,9 +2,11 @@
|
||||
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
|
||||
*/
|
||||
|
||||
package at.bitfire.davdroid.network
|
||||
package at.bitfire.davdroid
|
||||
|
||||
import androidx.test.filters.SdkSuppress
|
||||
import androidx.test.platform.app.InstrumentationRegistry
|
||||
import at.bitfire.davdroid.network.HttpClient
|
||||
import at.bitfire.davdroid.settings.SettingsManager
|
||||
import dagger.hilt.android.testing.HiltAndroidRule
|
||||
import dagger.hilt.android.testing.HiltAndroidTest
|
||||
import okhttp3.Request
|
||||
@@ -16,12 +18,12 @@ import javax.inject.Inject
|
||||
@HiltAndroidTest
|
||||
class OkhttpClientTest {
|
||||
|
||||
@Inject
|
||||
lateinit var httpClientBuilder: HttpClientBuilder
|
||||
|
||||
@get:Rule
|
||||
val hiltRule = HiltAndroidRule(this)
|
||||
|
||||
@Inject
|
||||
lateinit var settingsManager: SettingsManager
|
||||
|
||||
@Before
|
||||
fun inject() {
|
||||
hiltRule.inject()
|
||||
@@ -29,17 +31,14 @@ class OkhttpClientTest {
|
||||
|
||||
|
||||
@Test
|
||||
@SdkSuppress(maxSdkVersion = 34)
|
||||
fun testIcloudWithSettings() {
|
||||
val client = httpClientBuilder.build()
|
||||
client
|
||||
.newCall(
|
||||
Request.Builder()
|
||||
.get()
|
||||
.url("https://icloud.com")
|
||||
.build()
|
||||
)
|
||||
.execute()
|
||||
val client = HttpClient.Builder(InstrumentationRegistry.getInstrumentation().targetContext)
|
||||
.build()
|
||||
client.okHttpClient.newCall(Request.Builder()
|
||||
.get()
|
||||
.url("https://icloud.com")
|
||||
.build())
|
||||
.execute()
|
||||
}
|
||||
|
||||
}
|
||||
@@ -4,12 +4,10 @@
|
||||
|
||||
package at.bitfire.davdroid.sync
|
||||
|
||||
import at.bitfire.davdroid.db.SyncState
|
||||
import at.bitfire.davdroid.resource.LocalCollection
|
||||
import at.bitfire.davdroid.resource.SyncState
|
||||
|
||||
class LocalTestCollection(
|
||||
override val dbCollectionId: Long = 0L
|
||||
): LocalCollection<LocalTestResource> {
|
||||
class LocalTestCollection: LocalCollection<LocalTestResource> {
|
||||
|
||||
override val tag = "LocalTestCollection"
|
||||
override val title = "Local Test Collection"
|
||||
@@ -4,11 +4,9 @@
|
||||
|
||||
package at.bitfire.davdroid.sync
|
||||
|
||||
import android.content.Context
|
||||
import at.bitfire.davdroid.resource.LocalResource
|
||||
import java.util.Optional
|
||||
|
||||
class LocalTestResource: LocalResource {
|
||||
class LocalTestResource: LocalResource<Any> {
|
||||
|
||||
override val id: Long? = null
|
||||
override var fileName: String? = null
|
||||
@@ -19,10 +17,12 @@ class LocalTestResource: LocalResource {
|
||||
var deleted = false
|
||||
var dirty = false
|
||||
|
||||
override fun clearDirty(fileName: Optional<String>, eTag: String?, scheduleTag: String?) {
|
||||
override fun prepareForUpload() = "generated-file.txt"
|
||||
|
||||
override fun clearDirty(fileName: String?, eTag: String?, scheduleTag: String?) {
|
||||
dirty = false
|
||||
if (fileName.isPresent)
|
||||
this.fileName = fileName.get()
|
||||
if (fileName != null)
|
||||
this.fileName = fileName
|
||||
this.eTag = eTag
|
||||
this.scheduleTag = scheduleTag
|
||||
}
|
||||
@@ -31,14 +31,9 @@ class LocalTestResource: LocalResource {
|
||||
this.flags = flags
|
||||
}
|
||||
|
||||
override fun updateUid(uid: String) { /* no-op */ }
|
||||
override fun updateSequence(sequence: Int) = throw NotImplementedError()
|
||||
|
||||
override fun deleteLocal() = throw NotImplementedError()
|
||||
override fun add() = throw NotImplementedError()
|
||||
override fun update(data: Any) = throw NotImplementedError()
|
||||
override fun delete() = throw NotImplementedError()
|
||||
override fun resetDeleted() = throw NotImplementedError()
|
||||
|
||||
override fun getDebugSummary() = "Test Resource"
|
||||
|
||||
override fun getViewUri(context: Context) = null
|
||||
|
||||
}
|
||||
@@ -5,99 +5,146 @@
|
||||
package at.bitfire.davdroid.sync
|
||||
|
||||
import android.accounts.Account
|
||||
import android.content.Context
|
||||
import android.accounts.AccountManager
|
||||
import android.content.SyncResult
|
||||
import android.util.Log
|
||||
import androidx.core.app.NotificationManagerCompat
|
||||
import androidx.hilt.work.HiltWorkerFactory
|
||||
import at.bitfire.dav4jvm.okhttp.PropStat
|
||||
import at.bitfire.dav4jvm.okhttp.Response
|
||||
import at.bitfire.dav4jvm.okhttp.Response.HrefRelation
|
||||
import androidx.test.platform.app.InstrumentationRegistry
|
||||
import androidx.work.Configuration
|
||||
import androidx.work.testing.WorkManagerTestInitHelper
|
||||
import at.bitfire.dav4jvm.PropStat
|
||||
import at.bitfire.dav4jvm.Response
|
||||
import at.bitfire.dav4jvm.Response.HrefRelation
|
||||
import at.bitfire.dav4jvm.property.webdav.GetETag
|
||||
import at.bitfire.davdroid.TestUtils
|
||||
import at.bitfire.davdroid.R
|
||||
import at.bitfire.davdroid.TestUtils.assertWithin
|
||||
import at.bitfire.davdroid.db.Collection
|
||||
import at.bitfire.davdroid.network.HttpClientBuilder
|
||||
import at.bitfire.davdroid.repository.DavSyncStatsRepository
|
||||
import at.bitfire.davdroid.resource.SyncState
|
||||
import at.bitfire.davdroid.db.AppDatabase
|
||||
import at.bitfire.davdroid.db.Credentials
|
||||
import at.bitfire.davdroid.db.SyncState
|
||||
import at.bitfire.davdroid.network.HttpClient
|
||||
import at.bitfire.davdroid.settings.AccountSettings
|
||||
import at.bitfire.davdroid.sync.account.TestAccount
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import dagger.hilt.android.testing.BindValue
|
||||
import at.bitfire.davdroid.ui.NotificationUtils
|
||||
import dagger.hilt.android.testing.HiltAndroidRule
|
||||
import dagger.hilt.android.testing.HiltAndroidTest
|
||||
import io.mockk.every
|
||||
import io.mockk.impl.annotations.RelaxedMockK
|
||||
import io.mockk.junit4.MockKRule
|
||||
import io.mockk.mockk
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import okhttp3.Protocol
|
||||
import okhttp3.internal.http.StatusLine
|
||||
import okhttp3.mockwebserver.MockResponse
|
||||
import okhttp3.mockwebserver.MockWebServer
|
||||
import org.junit.After
|
||||
import org.junit.AfterClass
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Before
|
||||
import org.junit.BeforeClass
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import java.time.Instant
|
||||
import java.util.concurrent.TimeUnit
|
||||
import javax.inject.Inject
|
||||
|
||||
@HiltAndroidTest
|
||||
class SyncManagerTest {
|
||||
|
||||
companion object {
|
||||
|
||||
private val context = InstrumentationRegistry.getInstrumentation().targetContext
|
||||
val account = Account("SyncManagerTest", context.getString(R.string.account_type))
|
||||
|
||||
@BeforeClass
|
||||
@JvmStatic
|
||||
fun createAccount() {
|
||||
assertTrue(AccountManager.get(context).addAccountExplicitly(account, "test", AccountSettings.initialUserData(Credentials("test", "test"))))
|
||||
}
|
||||
|
||||
@AfterClass
|
||||
@JvmStatic
|
||||
fun removeAccount() {
|
||||
assertTrue(AccountManager.get(context).removeAccount(account, null, null).getResult(10, TimeUnit.SECONDS))
|
||||
|
||||
// clear annoying syncError notifications
|
||||
NotificationManagerCompat.from(context).cancelAll()
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
@get:Rule
|
||||
val hiltRule = HiltAndroidRule(this)
|
||||
|
||||
@get:Rule
|
||||
val mockKRule = MockKRule(this)
|
||||
|
||||
@Inject
|
||||
lateinit var accountSettingsFactory: AccountSettings.Factory
|
||||
|
||||
@Inject @ApplicationContext
|
||||
lateinit var context: Context
|
||||
|
||||
@Inject
|
||||
lateinit var httpClientBuilder: HttpClientBuilder
|
||||
|
||||
@Inject
|
||||
lateinit var syncManagerFactory: TestSyncManager.Factory
|
||||
|
||||
@BindValue
|
||||
@RelaxedMockK
|
||||
lateinit var syncStatsRepository: DavSyncStatsRepository
|
||||
lateinit var db: AppDatabase
|
||||
|
||||
@Inject
|
||||
lateinit var workerFactory: HiltWorkerFactory
|
||||
|
||||
private lateinit var account: Account
|
||||
private lateinit var server: MockWebServer
|
||||
private val server = MockWebServer()
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
fun inject() {
|
||||
hiltRule.inject()
|
||||
|
||||
TestUtils.setUpWorkManager(context, workerFactory)
|
||||
// The test application is an instance of HiltTestApplication, which doesn't initialize notification channels.
|
||||
// However, we need notification channels for the ongoing work notifications.
|
||||
NotificationUtils.createChannels(context)
|
||||
|
||||
account = TestAccount.create()
|
||||
// Initialize WorkManager for instrumentation tests.
|
||||
val config = Configuration.Builder()
|
||||
.setMinimumLoggingLevel(Log.DEBUG)
|
||||
.setWorkerFactory(workerFactory)
|
||||
.build()
|
||||
WorkManagerTestInitHelper.initializeTestWorkManager(context, config)
|
||||
}
|
||||
|
||||
server = MockWebServer().apply {
|
||||
start()
|
||||
}
|
||||
|
||||
private fun syncManager(collection: LocalTestCollection, syncResult: SyncResult = SyncResult()) =
|
||||
TestSyncManager(
|
||||
account,
|
||||
arrayOf(),
|
||||
"TestAuthority",
|
||||
HttpClient.Builder(InstrumentationRegistry.getInstrumentation().targetContext).build(),
|
||||
syncResult,
|
||||
collection,
|
||||
server,
|
||||
context, db
|
||||
)
|
||||
|
||||
@Before
|
||||
fun startServer() {
|
||||
server.start()
|
||||
}
|
||||
|
||||
@After
|
||||
fun tearDown() {
|
||||
TestAccount.remove(account)
|
||||
|
||||
// clear annoying syncError notifications
|
||||
NotificationManagerCompat.from(context).cancelAll()
|
||||
|
||||
fun stopServer() {
|
||||
server.close()
|
||||
}
|
||||
|
||||
|
||||
@Test
|
||||
fun testGetDelayUntil_defaultOnNull() {
|
||||
val now = Instant.now()
|
||||
val delayUntil = SyncManager.getDelayUntil(null).epochSecond
|
||||
val default = now.plusSeconds(SyncManager.DELAY_UNTIL_DEFAULT).epochSecond
|
||||
assertWithin(default, delayUntil, 5)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testGetDelayUntil_reducesToMax() {
|
||||
val now = Instant.now()
|
||||
val delayUntil = SyncManager.getDelayUntil(now.plusSeconds(10*24*60*60)).epochSecond
|
||||
val max = now.plusSeconds(SyncManager.DELAY_UNTIL_MAX).epochSecond
|
||||
assertWithin(max, delayUntil, 5)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testGetDelayUntil_increasesToMin() {
|
||||
val delayUntil = SyncManager.getDelayUntil(Instant.EPOCH).epochSecond
|
||||
val min = Instant.now().plusSeconds(SyncManager.DELAY_UNTIL_MIN).epochSecond
|
||||
assertWithin(min, delayUntil, 5)
|
||||
}
|
||||
|
||||
|
||||
private fun queryCapabilitiesResponse(cTag: String? = null): MockResponse {
|
||||
val body = StringBuilder()
|
||||
body.append(
|
||||
@@ -122,9 +169,8 @@ class SyncManagerTest {
|
||||
.setBody(body.toString())
|
||||
}
|
||||
|
||||
|
||||
@Test
|
||||
fun testPerformSync_503RetryAfter_DelaySeconds() = runTest {
|
||||
fun testPerformSync_503RetryAfter_DelaySeconds() {
|
||||
server.enqueue(MockResponse()
|
||||
.setResponseCode(503)
|
||||
.setHeader("Retry-After", "60")) // 60 seconds
|
||||
@@ -141,7 +187,7 @@ class SyncManagerTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testPerformSync_FirstSync_Empty() = runTest {
|
||||
fun testPerformSync_FirstSync_Empty() {
|
||||
val collection = LocalTestCollection() /* no last known ctag */
|
||||
server.enqueue(queryCapabilitiesResponse())
|
||||
|
||||
@@ -156,7 +202,7 @@ class SyncManagerTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testPerformSync_UploadNewMember_ETagOnPut() = runTest {
|
||||
fun testPerformSync_UploadNewMember_ETagOnPut() {
|
||||
val collection = LocalTestCollection().apply {
|
||||
lastSyncState = SyncState(SyncState.Type.CTAG, "old-ctag")
|
||||
entries += LocalTestResource().apply {
|
||||
@@ -199,7 +245,7 @@ class SyncManagerTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testPerformSync_UploadModifiedMember_ETagOnPut() = runTest {
|
||||
fun testPerformSync_UploadModifiedMember_ETagOnPut() {
|
||||
val collection = LocalTestCollection().apply {
|
||||
lastSyncState = SyncState(SyncState.Type.CTAG, "old-ctag")
|
||||
entries += LocalTestResource().apply {
|
||||
@@ -246,7 +292,7 @@ class SyncManagerTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testPerformSync_UploadModifiedMember_NoETagOnPut() = runTest {
|
||||
fun testPerformSync_UploadModifiedMember_NoETagOnPut() {
|
||||
val collection = LocalTestCollection().apply {
|
||||
lastSyncState = SyncState(SyncState.Type.CTAG, "old-ctag")
|
||||
entries += LocalTestResource().apply {
|
||||
@@ -291,7 +337,7 @@ class SyncManagerTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testPerformSync_UploadModifiedMember_412PreconditionFailed() = runTest {
|
||||
fun testPerformSync_UploadModifiedMember_412PreconditionFailed() {
|
||||
val collection = LocalTestCollection().apply {
|
||||
lastSyncState = SyncState(SyncState.Type.CTAG, "old-ctag")
|
||||
entries += LocalTestResource().apply {
|
||||
@@ -337,7 +383,7 @@ class SyncManagerTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testPerformSync_NoopOnMemberWithSameETag() = runTest {
|
||||
fun testPerformSync_NoopOnMemberWithSameETag() {
|
||||
val collection = LocalTestCollection().apply {
|
||||
lastSyncState = SyncState(SyncState.Type.CTAG, "ctag1")
|
||||
entries += LocalTestResource().apply {
|
||||
@@ -374,7 +420,7 @@ class SyncManagerTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testPerformSync_DownloadNewMember() = runTest {
|
||||
fun testPerformSync_DownloadNewMember() {
|
||||
val collection = LocalTestCollection().apply {
|
||||
lastSyncState = SyncState(SyncState.Type.CTAG, "old-ctag")
|
||||
}
|
||||
@@ -408,7 +454,7 @@ class SyncManagerTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testPerformSync_DownloadUpdatedMember() = runTest {
|
||||
fun testPerformSync_DownloadUpdatedMember() {
|
||||
val collection = LocalTestCollection().apply {
|
||||
lastSyncState = SyncState(SyncState.Type.CTAG, "old-ctag")
|
||||
entries += LocalTestResource().apply {
|
||||
@@ -446,7 +492,7 @@ class SyncManagerTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testPerformSync_RemoveVanishedMember() = runTest {
|
||||
fun testPerformSync_RemoveVanishedMember() {
|
||||
val collection = LocalTestCollection().apply {
|
||||
lastSyncState = SyncState(SyncState.Type.CTAG, "old-ctag")
|
||||
entries += LocalTestResource().apply {
|
||||
@@ -466,7 +512,7 @@ class SyncManagerTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testPerformSync_CTagDidntChange() = runTest {
|
||||
fun testPerformSync_CTagDidntChange() {
|
||||
val collection = LocalTestCollection().apply {
|
||||
lastSyncState = SyncState(SyncState.Type.CTAG, "ctag1")
|
||||
}
|
||||
@@ -482,22 +528,4 @@ class SyncManagerTest {
|
||||
assertTrue(collection.entries.isEmpty())
|
||||
}
|
||||
|
||||
|
||||
// helpers
|
||||
|
||||
private fun syncManager(
|
||||
localCollection: LocalTestCollection,
|
||||
syncResult: SyncResult = SyncResult(),
|
||||
collection: Collection = mockk<Collection>(relaxed = true) {
|
||||
every { id } returns 1
|
||||
every { url } returns server.url("/")
|
||||
}
|
||||
) = syncManagerFactory.create(
|
||||
account,
|
||||
httpClientBuilder.build(),
|
||||
syncResult,
|
||||
localCollection,
|
||||
collection
|
||||
)
|
||||
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
/*
|
||||
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
|
||||
*/
|
||||
|
||||
package at.bitfire.davdroid.sync
|
||||
|
||||
import android.accounts.Account
|
||||
import android.content.ContentProviderClient
|
||||
import android.content.Context
|
||||
import android.content.SyncResult
|
||||
import androidx.test.platform.app.InstrumentationRegistry
|
||||
import at.bitfire.davdroid.network.HttpClient
|
||||
import at.bitfire.davdroid.R
|
||||
import dagger.hilt.android.testing.HiltAndroidRule
|
||||
import dagger.hilt.android.testing.HiltAndroidTest
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Before
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import java.util.concurrent.atomic.AtomicInteger
|
||||
|
||||
@HiltAndroidTest
|
||||
class SyncerTest {
|
||||
|
||||
@get:Rule
|
||||
val hiltRule = HiltAndroidRule(this)
|
||||
|
||||
val context = InstrumentationRegistry.getInstrumentation().targetContext
|
||||
|
||||
/** use our WebDAV provider as a mock provider because it's our own and we don't need any permissions for it */
|
||||
val mockAuthority = context.getString(R.string.webdav_authority)
|
||||
val mockProvider = context.contentResolver!!.acquireContentProviderClient(mockAuthority)!!
|
||||
|
||||
val account = Account(javaClass.canonicalName, context.getString(R.string.account_type))
|
||||
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
hiltRule.inject()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testOnPerformSync_runsSyncAndSetsClassLoader() {
|
||||
val syncer = TestSyncer(context)
|
||||
syncer.onPerformSync(account, arrayOf(), mockAuthority, mockProvider, SyncResult())
|
||||
|
||||
// check whether onPerformSync() actually calls sync()
|
||||
assertEquals(1, syncer.syncCalled.get())
|
||||
|
||||
// check whether contextClassLoader is set
|
||||
assertEquals(context.classLoader, Thread.currentThread().contextClassLoader)
|
||||
}
|
||||
|
||||
|
||||
class TestSyncer(context: Context) : Syncer(context) {
|
||||
|
||||
val syncCalled = AtomicInteger()
|
||||
|
||||
override fun sync(
|
||||
account: Account,
|
||||
extras: Array<String>,
|
||||
authority: String,
|
||||
httpClient: Lazy<HttpClient>,
|
||||
provider: ContentProviderClient,
|
||||
syncResult: SyncResult
|
||||
) {
|
||||
Thread.sleep(1000)
|
||||
syncCalled.incrementAndGet()
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
@@ -5,67 +5,50 @@
|
||||
package at.bitfire.davdroid.sync
|
||||
|
||||
import android.accounts.Account
|
||||
import at.bitfire.dav4jvm.okhttp.DavCollection
|
||||
import at.bitfire.dav4jvm.okhttp.MultiResponseCallback
|
||||
import at.bitfire.dav4jvm.okhttp.Response
|
||||
import at.bitfire.dav4jvm.property.caldav.CalDAV
|
||||
import android.content.Context
|
||||
import android.content.SyncResult
|
||||
import at.bitfire.dav4jvm.DavCollection
|
||||
import at.bitfire.dav4jvm.MultiResponseCallback
|
||||
import at.bitfire.dav4jvm.Response
|
||||
import at.bitfire.dav4jvm.property.caldav.GetCTag
|
||||
import at.bitfire.davdroid.db.Collection
|
||||
import at.bitfire.davdroid.di.scope.SyncDispatcher
|
||||
import at.bitfire.davdroid.db.AppDatabase
|
||||
import at.bitfire.davdroid.db.SyncState
|
||||
import at.bitfire.davdroid.network.HttpClient
|
||||
import at.bitfire.davdroid.resource.LocalResource
|
||||
import at.bitfire.davdroid.resource.SyncState
|
||||
import at.bitfire.davdroid.util.DavUtils.lastSegment
|
||||
import dagger.assisted.Assisted
|
||||
import dagger.assisted.AssistedFactory
|
||||
import dagger.assisted.AssistedInject
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
import at.bitfire.davdroid.settings.AccountSettings
|
||||
import at.bitfire.davdroid.util.lastSegment
|
||||
import okhttp3.HttpUrl
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.RequestBody
|
||||
import okhttp3.RequestBody.Companion.toRequestBody
|
||||
import okhttp3.mockwebserver.MockWebServer
|
||||
import org.junit.Assert.assertEquals
|
||||
|
||||
class TestSyncManager @AssistedInject constructor(
|
||||
@Assisted account: Account,
|
||||
@Assisted httpClient: OkHttpClient,
|
||||
@Assisted syncResult: SyncResult,
|
||||
@Assisted localCollection: LocalTestCollection,
|
||||
@Assisted collection: Collection,
|
||||
@SyncDispatcher syncDispatcher: CoroutineDispatcher
|
||||
): SyncManager<LocalTestResource, LocalTestCollection, DavCollection>(
|
||||
account,
|
||||
httpClient,
|
||||
SyncDataType.EVENTS,
|
||||
syncResult,
|
||||
localCollection,
|
||||
collection,
|
||||
resync = null,
|
||||
syncDispatcher
|
||||
) {
|
||||
|
||||
@AssistedFactory
|
||||
interface Factory {
|
||||
fun create(
|
||||
account: Account,
|
||||
httpClient: OkHttpClient,
|
||||
syncResult: SyncResult,
|
||||
localCollection: LocalTestCollection,
|
||||
collection: Collection
|
||||
): TestSyncManager
|
||||
}
|
||||
class TestSyncManager(
|
||||
account: Account,
|
||||
extras: Array<String>,
|
||||
authority: String,
|
||||
httpClient: HttpClient,
|
||||
syncResult: SyncResult,
|
||||
localCollection: LocalTestCollection,
|
||||
val mockWebServer: MockWebServer,
|
||||
context: Context,
|
||||
db: AppDatabase
|
||||
): SyncManager<LocalTestResource, LocalTestCollection, DavCollection>(account, AccountSettings(context, account), httpClient, extras, authority, syncResult, localCollection, context, db) {
|
||||
|
||||
override fun prepare(): Boolean {
|
||||
davCollection = DavCollection(httpClient, collection.url)
|
||||
collectionURL = mockWebServer.url("/")
|
||||
davCollection = DavCollection(httpClient.okHttpClient, collectionURL)
|
||||
return true
|
||||
}
|
||||
|
||||
var didQueryCapabilities = false
|
||||
override suspend fun queryCapabilities(): SyncState? {
|
||||
override fun queryCapabilities(): SyncState? {
|
||||
if (didQueryCapabilities)
|
||||
throw IllegalStateException("queryCapabilities() must not be called twice")
|
||||
didQueryCapabilities = true
|
||||
|
||||
var cTag: SyncState? = null
|
||||
davCollection.propfind(0, CalDAV.GetCTag) { response, rel ->
|
||||
davCollection.propfind(0, GetCTag.NAME) { response, rel ->
|
||||
if (rel == Response.HrefRelation.SELF)
|
||||
response[GetCTag::class.java]?.cTag?.let {
|
||||
cTag = SyncState(SyncState.Type.CTAG, it)
|
||||
@@ -76,20 +59,16 @@ class TestSyncManager @AssistedInject constructor(
|
||||
}
|
||||
|
||||
var didGenerateUpload = false
|
||||
override fun generateUpload(resource: LocalTestResource): GeneratedResource {
|
||||
override fun generateUpload(resource: LocalTestResource): RequestBody {
|
||||
didGenerateUpload = true
|
||||
return GeneratedResource(
|
||||
suggestedFileName = resource.fileName ?: "generated-file.txt",
|
||||
requestBody = resource.toString().toRequestBody(),
|
||||
onSuccessContext = GeneratedResource.OnSuccessContext()
|
||||
)
|
||||
return resource.toString().toRequestBody()
|
||||
}
|
||||
|
||||
override fun syncAlgorithm() = SyncAlgorithm.PROPFIND_REPORT
|
||||
|
||||
var listAllRemoteResult = emptyList<Pair<Response, Response.HrefRelation>>()
|
||||
var didListAllRemote = false
|
||||
override suspend fun listAllRemote(callback: MultiResponseCallback) {
|
||||
override fun listAllRemote(callback: MultiResponseCallback) {
|
||||
if (didListAllRemote)
|
||||
throw IllegalStateException("listAllRemote() must not be called twice")
|
||||
didListAllRemote = true
|
||||
@@ -99,7 +78,7 @@ class TestSyncManager @AssistedInject constructor(
|
||||
|
||||
var assertDownloadRemote = emptyMap<HttpUrl, String>()
|
||||
var didDownloadRemote = false
|
||||
override suspend fun downloadRemote(bunch: List<HttpUrl>) {
|
||||
override fun downloadRemote(bunch: List<HttpUrl>) {
|
||||
didDownloadRemote = true
|
||||
assertEquals(assertDownloadRemote.keys.toList(), bunch)
|
||||
|
||||
@@ -6,11 +6,10 @@ package at.bitfire.davdroid.sync.account
|
||||
|
||||
import android.accounts.Account
|
||||
import android.accounts.AccountManager
|
||||
import android.content.Context
|
||||
import android.os.Bundle
|
||||
import androidx.test.platform.app.InstrumentationRegistry
|
||||
import at.bitfire.davdroid.R
|
||||
import at.bitfire.davdroid.settings.SettingsManager
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import dagger.hilt.android.testing.HiltAndroidRule
|
||||
import dagger.hilt.android.testing.HiltAndroidTest
|
||||
import org.junit.Assert.assertEquals
|
||||
@@ -18,42 +17,42 @@ import org.junit.Assert.assertTrue
|
||||
import org.junit.Before
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import java.util.concurrent.TimeUnit
|
||||
import javax.inject.Inject
|
||||
|
||||
@HiltAndroidTest
|
||||
class SystemAccountUtilsTest {
|
||||
class AccountUtilsTest {
|
||||
|
||||
@get:Rule
|
||||
val hiltRule = HiltAndroidRule(this)
|
||||
|
||||
@Inject @ApplicationContext
|
||||
lateinit var context: Context
|
||||
|
||||
@Inject
|
||||
lateinit var settingsManager: SettingsManager
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
fun inject() {
|
||||
hiltRule.inject()
|
||||
}
|
||||
|
||||
|
||||
val context by lazy { InstrumentationRegistry.getInstrumentation().targetContext }
|
||||
val account by lazy { Account("Test Account", context.getString(R.string.account_type)) }
|
||||
|
||||
@Test
|
||||
fun testCreateAccount() {
|
||||
val userData = Bundle(2)
|
||||
userData.putString("int", "1")
|
||||
userData.putString("string", "abc/\"-")
|
||||
|
||||
val account = Account("AccountUtilsTest", context.getString(R.string.account_type))
|
||||
val manager = AccountManager.get(context)
|
||||
try {
|
||||
assertTrue(SystemAccountUtils.createAccount(context, account, userData))
|
||||
assertTrue(AccountUtils.createAccount(context, account, userData))
|
||||
|
||||
// validate user data
|
||||
val manager = AccountManager.get(context)
|
||||
assertEquals("1", manager.getUserData(account, "int"))
|
||||
assertEquals("abc/\"-", manager.getUserData(account, "string"))
|
||||
} finally {
|
||||
assertTrue(manager.removeAccountExplicitly(account))
|
||||
val futureResult = AccountManager.get(context).removeAccount(account, {}, null)
|
||||
assertTrue(futureResult.getResult(10, TimeUnit.SECONDS))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,151 @@
|
||||
/***************************************************************************************************
|
||||
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
|
||||
**************************************************************************************************/
|
||||
|
||||
package at.bitfire.davdroid.sync.worker
|
||||
|
||||
import android.accounts.Account
|
||||
import android.accounts.AccountManager
|
||||
import android.content.ContentResolver
|
||||
import android.net.wifi.WifiInfo
|
||||
import android.net.wifi.WifiManager
|
||||
import android.provider.CalendarContract
|
||||
import android.provider.ContactsContract
|
||||
import android.util.Log
|
||||
import androidx.core.content.getSystemService
|
||||
import androidx.test.platform.app.InstrumentationRegistry
|
||||
import androidx.work.Configuration
|
||||
import androidx.work.testing.WorkManagerTestInitHelper
|
||||
import at.bitfire.davdroid.R
|
||||
import at.bitfire.davdroid.db.Credentials
|
||||
import at.bitfire.davdroid.network.ConnectionUtils
|
||||
import at.bitfire.davdroid.settings.AccountSettings
|
||||
import at.bitfire.davdroid.sync.account.AccountUtils
|
||||
import at.bitfire.davdroid.ui.NotificationUtils
|
||||
import at.bitfire.davdroid.util.PermissionUtils
|
||||
import dagger.hilt.android.testing.HiltAndroidRule
|
||||
import dagger.hilt.android.testing.HiltAndroidTest
|
||||
import io.mockk.every
|
||||
import io.mockk.junit4.MockKRule
|
||||
import io.mockk.mockk
|
||||
import io.mockk.mockkObject
|
||||
import io.mockk.spyk
|
||||
import org.junit.After
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Before
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
|
||||
@HiltAndroidTest
|
||||
class BaseSyncWorkerTest {
|
||||
|
||||
val context = InstrumentationRegistry.getInstrumentation().targetContext
|
||||
|
||||
private val accountManager = AccountManager.get(context)
|
||||
private val account = Account("Test Account", context.getString(R.string.account_type))
|
||||
private val fakeCredentials = Credentials("test", "test")
|
||||
|
||||
@get:Rule
|
||||
val hiltRule = HiltAndroidRule(this)
|
||||
@get:Rule
|
||||
val mockkRule = MockKRule(this)
|
||||
|
||||
@Before
|
||||
fun inject() = hiltRule.inject()
|
||||
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
assertTrue(AccountUtils.createAccount(context, account, AccountSettings.initialUserData(fakeCredentials)))
|
||||
ContentResolver.setIsSyncable(account, CalendarContract.AUTHORITY, 1)
|
||||
ContentResolver.setIsSyncable(account, ContactsContract.AUTHORITY, 0)
|
||||
|
||||
// The test application is an instance of HiltTestApplication, which doesn't initialize notification channels.
|
||||
// However, we need notification channels for the ongoing work notifications.
|
||||
NotificationUtils.createChannels(context)
|
||||
|
||||
// Initialize WorkManager for instrumentation tests.
|
||||
val config = Configuration.Builder()
|
||||
.setMinimumLoggingLevel(Log.DEBUG)
|
||||
.build()
|
||||
WorkManagerTestInitHelper.initializeTestWorkManager(context, config)
|
||||
}
|
||||
|
||||
@After
|
||||
fun removeAccount() {
|
||||
accountManager.removeAccountExplicitly(account)
|
||||
}
|
||||
|
||||
|
||||
@Test
|
||||
fun testWifiConditionsMet_withoutWifi() {
|
||||
val accountSettings = mockk<AccountSettings>()
|
||||
every { accountSettings.getSyncWifiOnly() } returns false
|
||||
assertTrue(BaseSyncWorker.wifiConditionsMet(context, accountSettings))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testWifiConditionsMet_anyWifi_wifiEnabled() {
|
||||
val accountSettings = AccountSettings(context, account)
|
||||
accountSettings.setSyncWiFiOnly(true)
|
||||
|
||||
mockkObject(ConnectionUtils)
|
||||
every { ConnectionUtils.wifiAvailable(any()) } returns true
|
||||
mockkObject(BaseSyncWorker.Companion)
|
||||
every { BaseSyncWorker.correctWifiSsid(any(), any()) } returns true
|
||||
|
||||
assertTrue(BaseSyncWorker.wifiConditionsMet(context, accountSettings))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testWifiConditionsMet_anyWifi_wifiDisabled() {
|
||||
val accountSettings = AccountSettings(context, account)
|
||||
accountSettings.setSyncWiFiOnly(true)
|
||||
|
||||
mockkObject(ConnectionUtils)
|
||||
every { ConnectionUtils.wifiAvailable(any()) } returns false
|
||||
mockkObject(BaseSyncWorker.Companion)
|
||||
every { BaseSyncWorker.correctWifiSsid(any(), any()) } returns true
|
||||
|
||||
assertFalse(BaseSyncWorker.wifiConditionsMet(context, accountSettings))
|
||||
}
|
||||
|
||||
|
||||
@Test
|
||||
fun testCorrectWifiSsid_CorrectWiFiSsid() {
|
||||
val accountSettings = AccountSettings(context, account)
|
||||
mockkObject(accountSettings)
|
||||
every { accountSettings.getSyncWifiOnlySSIDs() } returns listOf("SampleWiFi1","ConnectedWiFi")
|
||||
|
||||
mockkObject(PermissionUtils)
|
||||
every { PermissionUtils.canAccessWifiSsid(any()) } returns true
|
||||
|
||||
val wifiManager = context.getSystemService<WifiManager>()!!
|
||||
mockkObject(wifiManager)
|
||||
every { wifiManager.connectionInfo } returns spyk<WifiInfo>().apply {
|
||||
every { ssid } returns "ConnectedWiFi"
|
||||
}
|
||||
|
||||
assertTrue(BaseSyncWorker.correctWifiSsid(context, accountSettings))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testCorrectWifiSsid_WrongWiFiSsid() {
|
||||
val accountSettings = AccountSettings(context, account)
|
||||
mockkObject(accountSettings)
|
||||
every { accountSettings.getSyncWifiOnlySSIDs() } returns listOf("SampleWiFi1","SampleWiFi2")
|
||||
|
||||
mockkObject(PermissionUtils)
|
||||
every { PermissionUtils.canAccessWifiSsid(any()) } returns true
|
||||
|
||||
val wifiManager = context.getSystemService<WifiManager>()!!
|
||||
mockkObject(wifiManager)
|
||||
every { wifiManager.connectionInfo } returns spyk<WifiInfo>().apply {
|
||||
every { ssid } returns "ConnectedWiFi"
|
||||
}
|
||||
|
||||
assertFalse(BaseSyncWorker.correctWifiSsid(context, accountSettings))
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
/*
|
||||
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
|
||||
*/
|
||||
|
||||
package at.bitfire.davdroid.sync.worker
|
||||
|
||||
import android.content.Context
|
||||
import android.provider.CalendarContract
|
||||
import androidx.test.platform.app.InstrumentationRegistry
|
||||
import at.bitfire.davdroid.TestUtils
|
||||
import at.bitfire.davdroid.sync.SyncManagerTest.Companion.account
|
||||
import dagger.hilt.android.testing.HiltAndroidTest
|
||||
import org.junit.Assert
|
||||
import org.junit.Test
|
||||
|
||||
@HiltAndroidTest
|
||||
class OneTimeSyncWorkerTest {
|
||||
|
||||
val context: Context = InstrumentationRegistry.getInstrumentation().targetContext
|
||||
|
||||
@Test
|
||||
fun testEnqueue_enqueuesWorker() {
|
||||
OneTimeSyncWorker.enqueue(context, account, CalendarContract.AUTHORITY)
|
||||
val workerName = OneTimeSyncWorker.workerName(account, CalendarContract.AUTHORITY)
|
||||
Assert.assertTrue(TestUtils.workScheduledOrRunningOrSuccessful(context, workerName))
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
/***************************************************************************************************
|
||||
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
|
||||
**************************************************************************************************/
|
||||
|
||||
package at.bitfire.davdroid.sync.worker
|
||||
|
||||
import android.accounts.Account
|
||||
import android.accounts.AccountManager
|
||||
import android.content.ContentResolver
|
||||
import android.content.Context
|
||||
import android.provider.CalendarContract
|
||||
import android.provider.ContactsContract
|
||||
import android.util.Log
|
||||
import androidx.test.platform.app.InstrumentationRegistry
|
||||
import androidx.work.Configuration
|
||||
import androidx.work.ListenableWorker
|
||||
import androidx.work.WorkManager
|
||||
import androidx.work.WorkerFactory
|
||||
import androidx.work.WorkerParameters
|
||||
import androidx.work.testing.TestListenableWorkerBuilder
|
||||
import androidx.work.testing.WorkManagerTestInitHelper
|
||||
import androidx.work.workDataOf
|
||||
import at.bitfire.davdroid.R
|
||||
import at.bitfire.davdroid.TestUtils.workScheduledOrRunning
|
||||
import at.bitfire.davdroid.db.Credentials
|
||||
import at.bitfire.davdroid.settings.AccountSettings
|
||||
import at.bitfire.davdroid.sync.account.AccountUtils
|
||||
import at.bitfire.davdroid.ui.NotificationUtils
|
||||
import dagger.assisted.AssistedFactory
|
||||
import dagger.hilt.android.testing.HiltAndroidRule
|
||||
import dagger.hilt.android.testing.HiltAndroidTest
|
||||
import io.mockk.junit4.MockKRule
|
||||
import io.mockk.mockkObject
|
||||
import io.mockk.verify
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import org.junit.AfterClass
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Before
|
||||
import org.junit.BeforeClass
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import javax.inject.Inject
|
||||
|
||||
@HiltAndroidTest
|
||||
class PeriodicSyncWorkerTest {
|
||||
|
||||
companion object {
|
||||
|
||||
val context: Context = InstrumentationRegistry.getInstrumentation().targetContext
|
||||
|
||||
private val accountManager = AccountManager.get(context)
|
||||
private val account = Account("Test Account", context.getString(R.string.account_type))
|
||||
private val fakeCredentials = Credentials("test", "test")
|
||||
|
||||
@BeforeClass
|
||||
@JvmStatic
|
||||
fun setUp() {
|
||||
// The test application is an instance of HiltTestApplication, which doesn't initialize notification channels.
|
||||
// However, we need notification channels for the ongoing work notifications.
|
||||
NotificationUtils.createChannels(context)
|
||||
|
||||
// Initialize WorkManager for instrumentation tests.
|
||||
val config = Configuration.Builder()
|
||||
.setMinimumLoggingLevel(Log.DEBUG)
|
||||
.build()
|
||||
WorkManagerTestInitHelper.initializeTestWorkManager(context, config)
|
||||
|
||||
assertTrue(AccountUtils.createAccount(context, account, AccountSettings.initialUserData(fakeCredentials)))
|
||||
ContentResolver.setIsSyncable(account, CalendarContract.AUTHORITY, 1)
|
||||
ContentResolver.setIsSyncable(account, ContactsContract.AUTHORITY, 1)
|
||||
}
|
||||
|
||||
@AfterClass
|
||||
@JvmStatic
|
||||
fun removeAccount() {
|
||||
accountManager.removeAccountExplicitly(account)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@AssistedFactory
|
||||
interface PeriodicSyncWorkerFactory {
|
||||
fun create(appContext: Context, workerParams: WorkerParameters): PeriodicSyncWorker
|
||||
}
|
||||
|
||||
@get:Rule
|
||||
val hiltRule = HiltAndroidRule(this)
|
||||
@get:Rule
|
||||
val mockkRule = MockKRule(this)
|
||||
|
||||
@Inject
|
||||
lateinit var syncWorkerFactory: PeriodicSyncWorkerFactory
|
||||
|
||||
@Before
|
||||
fun inject() {
|
||||
hiltRule.inject()
|
||||
}
|
||||
|
||||
|
||||
@Test
|
||||
fun enable_enqueuesPeriodicWorker() {
|
||||
PeriodicSyncWorker.enable(context, account, CalendarContract.AUTHORITY, 60, false)
|
||||
val workerName = PeriodicSyncWorker.workerName(account, CalendarContract.AUTHORITY)
|
||||
assertTrue(workScheduledOrRunning(context, workerName))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun disable_removesPeriodicWorker() {
|
||||
PeriodicSyncWorker.enable(context, account, CalendarContract.AUTHORITY, 60, false)
|
||||
PeriodicSyncWorker.disable(context, account, CalendarContract.AUTHORITY)
|
||||
val workerName = PeriodicSyncWorker.workerName(account, CalendarContract.AUTHORITY)
|
||||
assertFalse(workScheduledOrRunning(context, workerName))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun doWork_cancelsItselfOnInvalidAccount() {
|
||||
val invalidAccount = Account("invalid", context.getString(R.string.account_type))
|
||||
|
||||
// Run PeriodicSyncWorker as TestWorker
|
||||
val inputData = workDataOf(
|
||||
BaseSyncWorker.INPUT_AUTHORITY to CalendarContract.AUTHORITY,
|
||||
BaseSyncWorker.INPUT_ACCOUNT_NAME to invalidAccount.name,
|
||||
BaseSyncWorker.INPUT_ACCOUNT_TYPE to invalidAccount.type
|
||||
)
|
||||
|
||||
// mock WorkManager to observe cancellation call
|
||||
val workManager = WorkManager.getInstance(context)
|
||||
mockkObject(workManager)
|
||||
|
||||
// run test worker, expect failure
|
||||
val testWorker = TestListenableWorkerBuilder<PeriodicSyncWorker>(context, inputData)
|
||||
.setWorkerFactory(object: WorkerFactory() {
|
||||
override fun createWorker(appContext: Context, workerClassName: String, workerParameters: WorkerParameters) =
|
||||
syncWorkerFactory.create(appContext, workerParameters)
|
||||
})
|
||||
.build()
|
||||
val result = runBlocking {
|
||||
testWorker.doWork()
|
||||
}
|
||||
assertTrue(result is ListenableWorker.Result.Failure)
|
||||
|
||||
// verify that worker called WorkManager.cancelWorkById(<its ID>)
|
||||
verify {
|
||||
workManager.cancelWorkById(testWorker.id)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,10 +1,7 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!--
|
||||
~ Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
|
||||
-->
|
||||
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:tools="http://schemas.android.com/tools">
|
||||
xmlns:tools="http://schemas.android.com/tools"
|
||||
android:installLocation="internalOnly">
|
||||
|
||||
<!-- normal permissions -->
|
||||
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE"/>
|
||||
@@ -17,6 +14,11 @@
|
||||
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED"/>
|
||||
<uses-permission android:name="android.permission.REQUEST_IGNORE_BATTERY_OPTIMIZATIONS"/>
|
||||
|
||||
<!-- account management permissions not required for own accounts since API level 22 -->
|
||||
<uses-permission android:name="android.permission.AUTHENTICATE_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"/>
|
||||
|
||||
<!-- other permissions -->
|
||||
<!-- android.permission-group.CONTACTS -->
|
||||
<uses-permission android:name="android.permission.READ_CONTACTS"/>
|
||||
@@ -42,6 +44,7 @@
|
||||
<uses-feature android:name="android.hardware.location.gps" android:required="false" />
|
||||
|
||||
<application
|
||||
android:name=".App"
|
||||
android:allowBackup="false"
|
||||
android:networkSecurityConfig="@xml/network_security_config"
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
@@ -51,19 +54,11 @@
|
||||
tools:ignore="UnusedAttribute"
|
||||
android:supportsRtl="true">
|
||||
|
||||
<!-- Required for Hilt/WorkManager integration. See
|
||||
- https://developer.android.com/develop/background-work/background-tasks/persistent/configuration/custom-configuration#remove-default
|
||||
- https://developer.android.com/training/dependency-injection/hilt-jetpack#workmanager
|
||||
However, we must not disable AndroidX startup completely, as it's needed by other libraries like okhttp. -->
|
||||
<!-- required for Hilt/WorkManager integration -->
|
||||
<provider
|
||||
android:name="androidx.startup.InitializationProvider"
|
||||
android:authorities="${applicationId}.androidx-startup"
|
||||
android:exported="false"
|
||||
tools:node="merge">
|
||||
<meta-data
|
||||
android:name="androidx.work.WorkManagerInitializer"
|
||||
android:value="androidx.startup"
|
||||
tools:node="remove" />
|
||||
tools:node="remove">
|
||||
</provider>
|
||||
|
||||
<!-- Remove the node added by AppAuth (remove only from net.openid.appauth library, not from our flavor manifest files) -->
|
||||
@@ -81,7 +76,7 @@
|
||||
</activity>
|
||||
|
||||
<activity
|
||||
android:name=".ui.about.AboutActivity"
|
||||
android:name=".ui.AboutActivity"
|
||||
android:label="@string/navigation_drawer_about"
|
||||
android:parentActivityName=".ui.AccountsActivity"/>
|
||||
|
||||
@@ -98,7 +93,7 @@
|
||||
<activity
|
||||
android:name=".ui.DebugInfoActivity"
|
||||
android:parentActivityName=".ui.AppSettingsActivity"
|
||||
android:exported="false"
|
||||
android:exported="true"
|
||||
android:label="@string/debug_info_title">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.BUG_REPORT"/>
|
||||
@@ -135,9 +130,7 @@
|
||||
<intent-filter>
|
||||
<action android:name="loginFlow" /> <!-- Ensures this filter matches, even if the sending app is not defining an action -->
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
<data
|
||||
tools:ignore="AppLinkUrlError"
|
||||
android:scheme="http" />
|
||||
<data android:scheme="http" />
|
||||
<data android:scheme="https" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
@@ -251,6 +244,23 @@
|
||||
android:name="android.accounts.AccountAuthenticator"
|
||||
android:resource="@xml/account_authenticator_address_book"/>
|
||||
</service>
|
||||
<provider
|
||||
android:authorities="@string/address_books_authority"
|
||||
android:exported="false"
|
||||
android:label="@string/address_books_authority_title"
|
||||
android:name=".sync.adapter.AddressBookProvider" />
|
||||
<service
|
||||
android:name=".sync.adapter.AddressBooksSyncAdapterService"
|
||||
android:exported="true"
|
||||
tools:ignore="ExportedService">
|
||||
<intent-filter>
|
||||
<action android:name="android.content.SyncAdapter"/>
|
||||
</intent-filter>
|
||||
|
||||
<meta-data
|
||||
android:name="android.content.SyncAdapter"
|
||||
android:resource="@xml/sync_address_books"/>
|
||||
</service>
|
||||
<service
|
||||
android:name=".sync.adapter.ContactsSyncAdapterService"
|
||||
android:exported="true"
|
||||
@@ -278,16 +288,18 @@
|
||||
android:resource="@xml/debug_paths" />
|
||||
</provider>
|
||||
|
||||
<!-- UnifiedPush -->
|
||||
<service android:exported="false" android:name=".push.UnifiedPushService">
|
||||
<!-- UnifiedPush receiver -->
|
||||
<receiver android:exported="true" android:enabled="true" android:name=".push.UnifiedPushReceiver" tools:ignore="ExportedReceiver">
|
||||
<intent-filter>
|
||||
<action android:name="org.unifiedpush.android.connector.PUSH_EVENT"/>
|
||||
<action android:name="org.unifiedpush.android.connector.MESSAGE"/>
|
||||
<action android:name="org.unifiedpush.android.connector.UNREGISTERED"/>
|
||||
<action android:name="org.unifiedpush.android.connector.NEW_ENDPOINT"/>
|
||||
<action android:name="org.unifiedpush.android.connector.REGISTRATION_FAILED"/>
|
||||
</intent-filter>
|
||||
</service>
|
||||
</receiver>
|
||||
|
||||
<!-- Widgets -->
|
||||
<receiver android:name=".ui.widget.LabeledSyncButtonWidgetReceiver"
|
||||
android:label="@string/widget_labeled_sync_label"
|
||||
<receiver android:name=".ui.widget.SyncButtonWidgetReceiver"
|
||||
android:exported="true">
|
||||
<intent-filter>
|
||||
<action android:name="android.appwidget.action.APPWIDGET_UPDATE" />
|
||||
@@ -295,18 +307,7 @@
|
||||
|
||||
<meta-data
|
||||
android:name="android.appwidget.provider"
|
||||
android:resource="@xml/widget_info_labeled_sync_button" />
|
||||
</receiver>
|
||||
<receiver android:name=".ui.widget.IconSyncButtonWidgetReceiver"
|
||||
android:label="@string/widget_icon_sync_label"
|
||||
android:exported="true">
|
||||
<intent-filter>
|
||||
<action android:name="android.appwidget.action.APPWIDGET_UPDATE" />
|
||||
</intent-filter>
|
||||
|
||||
<meta-data
|
||||
android:name="android.appwidget.provider"
|
||||
android:resource="@xml/widget_info_icon_sync_button" />
|
||||
android:resource="@xml/widget_info_sync_button" />
|
||||
</receiver>
|
||||
|
||||
</application>
|
||||
1
app/src/main/assets/translators.json
Normal file
1
app/src/main/assets/translators.json
Normal file
@@ -0,0 +1 @@
|
||||
{"ar_SA":["abdunnasir"],"bg":["dpa_transifex"],"ca":["Kintu","jordibrus","zagur"],"cs":["pavelb","svetlemodry","tomas.odehnal"],"da":["Tntdruid_","knutztar","mjjzf","twikedk"],"de":["Atalanttore","TheName","Wyrrrd","YvanM","amandablue","anestiskaci","corppneq","crit12","hammaschlach","maxkl","nicolas_git","owncube"],"el":["KristinaQejvanaj","anestiskaci","diamond_gr"],"es":["Ark74","Elhea","GranPC","aluaces","jcvielma","plaguna","polkhas","xphnx"],"eu":["Osoitz","Thadah","cockeredradiation"],"fa":["Numb","ahangarha","amiraliakbari","joojoojoo","maryambehzi","mtashackori","taranehsaei"],"fr":["AlainR","Amadeen","Floflr","JorisBodin","Llorc","LoiX07","Novick","Poussinou","Thecross","YvanM","alkino2","boutil","callmemagnus","chrcha","grenatrad","jokx","mathieugfortin","paullbn","vincen","ÉricB."],"fr_FR":["Llorc","Poussinou","chrcha"],"gl":["aluaces","pikamoku"],"hu":["Roshek","infeeeee","jtg"],"it":["Damtux","FranzMari","ed0","malaerba","noccio","nwandy","rickyroo","technezio"],"it_IT":["malaerba"],"ja":["Naofumi","yanorei32"],"nb_NO":["elonus"],"nl":["XtremeNova","davtemp","dehart","erikhubers","frankyboy1963"],"pl":["TORminator","TheName","Valdnet","gsz","mg6","oskarjakiela"],"pt":["amalvarenga","wanderlei.huttel"],"pt_BR":["wanderlei.huttel"],"ru":["aigoshin","anm","ashed","astalavister","nick.savin","vaddd"],"sk_SK":["brango67","tiborepcek"],"sl_SI":["MrLaaky","uroszor"],"sr":["daimonion"],"sv":["Mikaelb","campbelldavid"],"szl":["chlodny"],"tr_TR":["ooguz","pultars"],"uk":["androsua","olexn","twixi007"],"uk_UA":["astalavister"],"zh_CN":["anolir","jxj2zzz79pfp9bpo","linuxbckp","mofitt2016","oksjd","phy","spice2wolf"],"zh_TW":["linuxbckp","mofitt2016","phy","waiabsfabuloushk"]}
|
||||
|
Before Width: | Height: | Size: 23 KiB After Width: | Height: | Size: 23 KiB |
85
app/src/main/kotlin/at/bitfire/davdroid/App.kt
Normal file
85
app/src/main/kotlin/at/bitfire/davdroid/App.kt
Normal file
@@ -0,0 +1,85 @@
|
||||
/*
|
||||
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
|
||||
*/
|
||||
|
||||
package at.bitfire.davdroid
|
||||
|
||||
import android.app.Application
|
||||
import android.os.StrictMode
|
||||
import androidx.hilt.work.HiltWorkerFactory
|
||||
import androidx.work.Configuration
|
||||
import at.bitfire.davdroid.log.Logger
|
||||
import at.bitfire.davdroid.sync.account.AccountsCleanupWorker
|
||||
import at.bitfire.davdroid.ui.DebugInfoActivity
|
||||
import at.bitfire.davdroid.ui.NotificationUtils
|
||||
import at.bitfire.davdroid.ui.UiUtils
|
||||
import dagger.hilt.android.HiltAndroidApp
|
||||
import kotlinx.coroutines.DelicateCoroutinesApi
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.GlobalScope
|
||||
import kotlinx.coroutines.launch
|
||||
import java.util.logging.Level
|
||||
import javax.inject.Inject
|
||||
import kotlin.system.exitProcess
|
||||
|
||||
@HiltAndroidApp
|
||||
class App: Application(), Thread.UncaughtExceptionHandler, Configuration.Provider {
|
||||
|
||||
@Inject lateinit var workerFactory: HiltWorkerFactory
|
||||
|
||||
override val workManagerConfiguration: Configuration
|
||||
get() = Configuration.Builder()
|
||||
.setWorkerFactory(workerFactory)
|
||||
.build()
|
||||
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
Logger.initialize(this)
|
||||
|
||||
if (BuildConfig.DEBUG)
|
||||
// debug builds
|
||||
StrictMode.setVmPolicy(StrictMode.VmPolicy.Builder()
|
||||
.detectActivityLeaks()
|
||||
.detectFileUriExposure()
|
||||
.detectLeakedClosableObjects()
|
||||
.detectLeakedRegistrationObjects()
|
||||
.detectLeakedSqlLiteObjects()
|
||||
.penaltyLog()
|
||||
.build())
|
||||
else // if (BuildConfig.FLAVOR == FLAVOR_STANDARD)
|
||||
// handle uncaught exceptions in non-debug standard flavor
|
||||
Thread.setDefaultUncaughtExceptionHandler(this)
|
||||
|
||||
NotificationUtils.createChannels(this)
|
||||
|
||||
// set light/dark mode
|
||||
UiUtils.updateTheme(this) // when this is called in the asynchronous thread below, it recreates
|
||||
// some current activity and causes an IllegalStateException in rare cases
|
||||
|
||||
// don't block UI for some background checks
|
||||
@OptIn(DelicateCoroutinesApi::class)
|
||||
GlobalScope.launch(Dispatchers.Default) {
|
||||
// clean up orphaned accounts in DB from time to time
|
||||
AccountsCleanupWorker.enqueue(this@App)
|
||||
|
||||
// watch installed/removed tasks apps over whole app lifetime and update sync settings accordingly
|
||||
TasksAppWatcher.watchInstalledTaskApps(this@App, this)
|
||||
|
||||
// create/update app shortcuts
|
||||
UiUtils.updateShortcuts(this@App)
|
||||
}
|
||||
}
|
||||
|
||||
override fun uncaughtException(t: Thread, e: Throwable) {
|
||||
Logger.log.log(Level.SEVERE, "Unhandled exception!", e)
|
||||
|
||||
val intent = DebugInfoActivity.IntentBuilder(this)
|
||||
.withCause(e)
|
||||
.newTask()
|
||||
.build()
|
||||
startActivity(intent)
|
||||
|
||||
exitProcess(1)
|
||||
}
|
||||
|
||||
}
|
||||
53
app/src/main/kotlin/at/bitfire/davdroid/Constants.kt
Normal file
53
app/src/main/kotlin/at/bitfire/davdroid/Constants.kt
Normal file
@@ -0,0 +1,53 @@
|
||||
/*
|
||||
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
|
||||
*/
|
||||
package at.bitfire.davdroid
|
||||
|
||||
import android.net.Uri
|
||||
import androidx.core.net.toUri
|
||||
|
||||
/**
|
||||
* Brand-specific constants like (non-theme) colors, homepage URLs etc.
|
||||
*/
|
||||
object Constants {
|
||||
|
||||
const val DAVDROID_GREEN_RGBA = 0xFF8bc34a.toInt()
|
||||
|
||||
val HOMEPAGE_URL = "https://www.davx5.com".toUri()
|
||||
const val HOMEPAGE_PATH_FAQ = "faq"
|
||||
const val HOMEPAGE_PATH_FAQ_SYNC_NOT_RUN = "synchronization-is-not-run-as-expected"
|
||||
const val HOMEPAGE_PATH_FAQ_LOCATION_PERMISSION = "wifi-ssid-restriction-location-permission"
|
||||
const val HOMEPAGE_PATH_OPEN_SOURCE = "donate"
|
||||
const val HOMEPAGE_PATH_PRIVACY = "privacy"
|
||||
const val HOMEPAGE_PATH_TESTED_SERVICES = "tested-with"
|
||||
|
||||
val MANUAL_URL = "https://manual.davx5.com".toUri()
|
||||
const val MANUAL_PATH_ACCOUNTS_COLLECTIONS = "accounts_collections.html"
|
||||
const val MANUAL_FRAGMENT_SERVICE_DISCOVERY = "how-does-service-discovery-work"
|
||||
const val MANUAL_PATH_SETTINGS = "settings.html"
|
||||
const val MANUAL_FRAGMENT_APP_SETTINGS = "app-wide-settings"
|
||||
const val MANUAL_FRAGMENT_ACCOUNT_SETTINGS = "account-settings"
|
||||
const val MANUAL_PATH_WEBDAV_MOUNTS = "webdav_mounts.html"
|
||||
|
||||
val COMMUNITY_URL = "https://github.com/bitfireAT/davx5-ose/discussions".toUri()
|
||||
|
||||
val FEDIVERSE_HANDLE = "@davx5app@fosstodon.org"
|
||||
val FEDIVERSE_URL = "https://fosstodon.org/@davx5app".toUri()
|
||||
|
||||
/**
|
||||
* Appends query parameters for anonymized usage statistics (app ID, version).
|
||||
* Can be used by the called Website to get an idea of which versions etc. are currently used.
|
||||
*
|
||||
* @param context optional info about from where the URL was opened (like a specific Activity)
|
||||
*/
|
||||
fun Uri.Builder.withStatParams(context: String? = null): Uri.Builder {
|
||||
appendQueryParameter("pk_campaign", BuildConfig.APPLICATION_ID)
|
||||
appendQueryParameter("app-version", BuildConfig.VERSION_NAME)
|
||||
|
||||
if (context != null)
|
||||
appendQueryParameter("pk_kwd", context)
|
||||
|
||||
return this
|
||||
}
|
||||
|
||||
}
|
||||
@@ -2,11 +2,8 @@
|
||||
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
|
||||
*/
|
||||
|
||||
package at.bitfire.davdroid.sync.account
|
||||
package at.bitfire.davdroid
|
||||
|
||||
import android.accounts.Account
|
||||
|
||||
/**
|
||||
* Thrown when an account is invalid (usually because it doesn't exist anymore).
|
||||
*/
|
||||
class InvalidAccountException(account: Account): Exception("Invalid account: $account")
|
||||
53
app/src/main/kotlin/at/bitfire/davdroid/TasksAppWatcher.kt
Normal file
53
app/src/main/kotlin/at/bitfire/davdroid/TasksAppWatcher.kt
Normal file
@@ -0,0 +1,53 @@
|
||||
/*
|
||||
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
|
||||
*/
|
||||
|
||||
package at.bitfire.davdroid
|
||||
|
||||
import android.content.Context
|
||||
import at.bitfire.davdroid.log.Logger
|
||||
import at.bitfire.davdroid.util.TaskUtils
|
||||
import at.bitfire.davdroid.util.packageChangedFlow
|
||||
import at.bitfire.ical4android.TaskProvider
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
/**
|
||||
* Watches whether a tasks app has been installed or uninstalled and updates
|
||||
* the selected tasks app and task sync settings accordingly.
|
||||
*/
|
||||
object TasksAppWatcher {
|
||||
|
||||
fun watchInstalledTaskApps(context: Context, externalScope: CoroutineScope) {
|
||||
externalScope.launch(Dispatchers.Default) {
|
||||
packageChangedFlow(context).collect {
|
||||
onPackageChanged(context)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun onPackageChanged(context: Context) {
|
||||
val currentProvider = TaskUtils.currentProvider(context)
|
||||
Logger.log.info("App launched or package (un)installed; current tasks provider = $currentProvider")
|
||||
|
||||
if (currentProvider == null) {
|
||||
// Iterate through all supported providers and select one, if available.
|
||||
var providerSelected = false
|
||||
for (provider in TaskProvider.ProviderName.entries) {
|
||||
val available = context.packageManager.resolveContentProvider(provider.authority, 0) != null
|
||||
if (available) {
|
||||
Logger.log.info("Selecting new tasks provider: $provider")
|
||||
TaskUtils.selectProvider(context, provider)
|
||||
providerSelected = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if (!providerSelected)
|
||||
// no provider available (anymore), also clear setting and sync
|
||||
TaskUtils.selectProvider(context, null)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -2,12 +2,13 @@
|
||||
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
|
||||
*/
|
||||
|
||||
package at.bitfire.davdroid.util
|
||||
package at.bitfire.davdroid
|
||||
|
||||
import org.apache.commons.lang3.StringUtils
|
||||
import java.util.Collections
|
||||
|
||||
class TextTable(
|
||||
val headers: List<String>
|
||||
vararg val headers: String
|
||||
) {
|
||||
|
||||
companion object {
|
||||
@@ -18,12 +19,10 @@ class TextTable(
|
||||
|
||||
}
|
||||
|
||||
constructor(vararg headers: String): this(headers.toList())
|
||||
|
||||
|
||||
private val lines = mutableListOf<Array<String>>()
|
||||
|
||||
fun addLine(values: List<Any?>) {
|
||||
fun addLine(vararg values: Any?) {
|
||||
if (values.size != headers.size)
|
||||
throw IllegalArgumentException("Table line must have ${headers.size} column(s)")
|
||||
lines += values.map {
|
||||
@@ -31,8 +30,6 @@ class TextTable(
|
||||
}.toTypedArray()
|
||||
}
|
||||
|
||||
fun addLine(vararg values: Any?) = addLine(values.toList())
|
||||
|
||||
override fun toString(): String {
|
||||
val sb = StringBuilder()
|
||||
|
||||
@@ -44,7 +41,7 @@ class TextTable(
|
||||
// first line
|
||||
sb.append("\n┌")
|
||||
for (colIdx in headers.indices)
|
||||
sb .append("─".repeat(colWidths[colIdx] + 2))
|
||||
sb .append(StringUtils.repeat('─', colWidths[colIdx] + 2))
|
||||
.append(if (colIdx == headers.size - 1) '┐' else '┬')
|
||||
sb.append('\n')
|
||||
|
||||
@@ -52,14 +49,14 @@ class TextTable(
|
||||
sb.append('│')
|
||||
for (colIdx in headers.indices)
|
||||
sb .append(' ')
|
||||
.append(headers[colIdx].padEnd(colWidths[colIdx] + 1))
|
||||
.append(StringUtils.rightPad(headers[colIdx], colWidths[colIdx] + 1))
|
||||
.append('│')
|
||||
sb.append('\n')
|
||||
|
||||
// separator between header and body
|
||||
sb.append('├')
|
||||
for (colIdx in headers.indices) {
|
||||
sb .append("─".repeat(colWidths[colIdx] + 2))
|
||||
sb .append(StringUtils.repeat('─', colWidths[colIdx] + 2))
|
||||
.append(if (colIdx == headers.size - 1) '┤' else '┼')
|
||||
}
|
||||
sb.append('\n')
|
||||
@@ -68,7 +65,7 @@ class TextTable(
|
||||
for (line in lines) {
|
||||
for (colIdx in headers.indices)
|
||||
sb .append("│ ")
|
||||
.append(line[colIdx].padEnd(colWidths[colIdx] + 1))
|
||||
.append(StringUtils.rightPad(line[colIdx], colWidths[colIdx] + 1))
|
||||
sb.append("│\n")
|
||||
}
|
||||
|
||||
305
app/src/main/kotlin/at/bitfire/davdroid/db/AppDatabase.kt
Normal file
305
app/src/main/kotlin/at/bitfire/davdroid/db/AppDatabase.kt
Normal file
@@ -0,0 +1,305 @@
|
||||
/*
|
||||
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
|
||||
*/
|
||||
|
||||
package at.bitfire.davdroid.db
|
||||
|
||||
import android.accounts.AccountManager
|
||||
import android.app.PendingIntent
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.database.sqlite.SQLiteQueryBuilder
|
||||
import androidx.core.app.NotificationCompat
|
||||
import androidx.core.app.NotificationManagerCompat
|
||||
import androidx.core.database.getStringOrNull
|
||||
import androidx.room.AutoMigration
|
||||
import androidx.room.Database
|
||||
import androidx.room.DeleteColumn
|
||||
import androidx.room.ProvidedAutoMigrationSpec
|
||||
import androidx.room.Room
|
||||
import androidx.room.RoomDatabase
|
||||
import androidx.room.TypeConverters
|
||||
import androidx.room.migration.AutoMigrationSpec
|
||||
import androidx.room.migration.Migration
|
||||
import androidx.sqlite.db.SupportSQLiteDatabase
|
||||
import at.bitfire.davdroid.R
|
||||
import at.bitfire.davdroid.TextTable
|
||||
import at.bitfire.davdroid.log.Logger
|
||||
import at.bitfire.davdroid.servicedetection.RefreshCollectionsWorker
|
||||
import at.bitfire.davdroid.ui.AccountsActivity
|
||||
import at.bitfire.davdroid.ui.NotificationUtils
|
||||
import at.bitfire.davdroid.ui.NotificationUtils.notifyIfPossible
|
||||
import dagger.Module
|
||||
import dagger.Provides
|
||||
import dagger.hilt.InstallIn
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import dagger.hilt.components.SingletonComponent
|
||||
import java.io.Writer
|
||||
import javax.inject.Singleton
|
||||
|
||||
@Suppress("ClassName")
|
||||
@Database(entities = [
|
||||
Service::class,
|
||||
HomeSet::class,
|
||||
Collection::class,
|
||||
Principal::class,
|
||||
SyncStats::class,
|
||||
WebDavDocument::class,
|
||||
WebDavMount::class
|
||||
], exportSchema = true, version = 13, autoMigrations = [
|
||||
AutoMigration(from = 9, to = 10),
|
||||
AutoMigration(from = 10, to = 11),
|
||||
AutoMigration(from = 11, to = 12, spec = AppDatabase.AutoMigration11_12::class),
|
||||
AutoMigration(from = 12, to = 13)
|
||||
])
|
||||
@TypeConverters(Converters::class)
|
||||
abstract class AppDatabase: RoomDatabase() {
|
||||
|
||||
@Module
|
||||
@InstallIn(SingletonComponent::class)
|
||||
object AppDatabaseModule {
|
||||
@Provides
|
||||
@Singleton
|
||||
fun appDatabase(@ApplicationContext context: Context): AppDatabase =
|
||||
Room.databaseBuilder(context.applicationContext, AppDatabase::class.java, "services.db")
|
||||
.addMigrations(*migrations)
|
||||
.addAutoMigrationSpec(AutoMigration11_12(context))
|
||||
.fallbackToDestructiveMigration() // as a last fallback, recreate database instead of crashing
|
||||
.addCallback(object: Callback() {
|
||||
override fun onDestructiveMigration(db: SupportSQLiteDatabase) {
|
||||
val nm = NotificationManagerCompat.from(context)
|
||||
val launcherIntent = Intent(context, AccountsActivity::class.java)
|
||||
val notify = NotificationUtils.newBuilder(context, NotificationUtils.CHANNEL_GENERAL)
|
||||
.setSmallIcon(R.drawable.ic_warning_notify)
|
||||
.setContentTitle(context.getString(R.string.database_destructive_migration_title))
|
||||
.setContentText(context.getString(R.string.database_destructive_migration_text))
|
||||
.setCategory(NotificationCompat.CATEGORY_ERROR)
|
||||
.setContentIntent(PendingIntent.getActivity(context, 0, launcherIntent, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE))
|
||||
.setAutoCancel(true)
|
||||
.build()
|
||||
nm.notifyIfPossible(NotificationUtils.NOTIFY_DATABASE_CORRUPTED, notify)
|
||||
|
||||
// remove all accounts because they're unfortunately useless without database
|
||||
val am = AccountManager.get(context)
|
||||
for (account in am.getAccountsByType(context.getString(R.string.account_type)))
|
||||
am.removeAccount(account, null, null)
|
||||
}
|
||||
})
|
||||
.build()
|
||||
}
|
||||
|
||||
// auto migrations
|
||||
|
||||
@ProvidedAutoMigrationSpec
|
||||
@DeleteColumn(tableName = "collection", columnName = "owner")
|
||||
class AutoMigration11_12(val context: Context): AutoMigrationSpec {
|
||||
override fun onPostMigrate(db: SupportSQLiteDatabase) {
|
||||
Logger.log.info("Database update to v12, refreshing services to get display names of owners")
|
||||
db.query("SELECT id FROM service", arrayOf()).use { cursor ->
|
||||
while (cursor.moveToNext()) {
|
||||
val serviceId = cursor.getLong(0)
|
||||
RefreshCollectionsWorker.enqueue(context, serviceId)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
companion object {
|
||||
|
||||
// manual migrations
|
||||
|
||||
val migrations: Array<Migration> = arrayOf(
|
||||
object : Migration(8, 9) {
|
||||
override fun migrate(db: SupportSQLiteDatabase) {
|
||||
db.execSQL("CREATE TABLE syncstats (" +
|
||||
"id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT," +
|
||||
"collectionId INTEGER NOT NULL REFERENCES collection(id) ON DELETE CASCADE," +
|
||||
"authority TEXT NOT NULL," +
|
||||
"lastSync INTEGER NOT NULL)")
|
||||
db.execSQL("CREATE UNIQUE INDEX index_syncstats_collectionId_authority ON syncstats(collectionId, authority)")
|
||||
|
||||
db.execSQL("CREATE INDEX index_collection_url ON collection(url)")
|
||||
}
|
||||
},
|
||||
|
||||
object : Migration(7, 8) {
|
||||
override fun migrate(db: SupportSQLiteDatabase) {
|
||||
db.execSQL("ALTER TABLE homeset ADD COLUMN personal INTEGER NOT NULL DEFAULT 1")
|
||||
db.execSQL("ALTER TABLE collection ADD COLUMN homeSetId INTEGER DEFAULT NULL REFERENCES homeset(id) ON DELETE SET NULL")
|
||||
db.execSQL("ALTER TABLE collection ADD COLUMN owner TEXT DEFAULT NULL")
|
||||
db.execSQL("CREATE INDEX index_collection_homeSetId_type ON collection(homeSetId, type)")
|
||||
}
|
||||
},
|
||||
|
||||
object : Migration(6, 7) {
|
||||
override fun migrate(db: SupportSQLiteDatabase) {
|
||||
db.execSQL("ALTER TABLE homeset ADD COLUMN privBind INTEGER NOT NULL DEFAULT 1")
|
||||
db.execSQL("ALTER TABLE homeset ADD COLUMN displayName TEXT DEFAULT NULL")
|
||||
}
|
||||
},
|
||||
|
||||
object : Migration(5, 6) {
|
||||
override fun migrate(db: SupportSQLiteDatabase) {
|
||||
val sql = arrayOf(
|
||||
// migrate "services" to "service": rename columns, make id NOT NULL
|
||||
"CREATE TABLE service(" +
|
||||
"id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT," +
|
||||
"accountName TEXT NOT NULL," +
|
||||
"type TEXT NOT NULL," +
|
||||
"principal TEXT DEFAULT NULL" +
|
||||
")",
|
||||
"CREATE UNIQUE INDEX index_service_accountName_type ON service(accountName, type)",
|
||||
"INSERT INTO service(id, accountName, type, principal) SELECT _id, accountName, service, principal FROM services",
|
||||
"DROP TABLE services",
|
||||
|
||||
// migrate "homesets" to "homeset": rename columns, make id NOT NULL
|
||||
"CREATE TABLE homeset(" +
|
||||
"id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT," +
|
||||
"serviceId INTEGER NOT NULL," +
|
||||
"url TEXT NOT NULL," +
|
||||
"FOREIGN KEY (serviceId) REFERENCES service(id) ON DELETE CASCADE" +
|
||||
")",
|
||||
"CREATE UNIQUE INDEX index_homeset_serviceId_url ON homeset(serviceId, url)",
|
||||
"INSERT INTO homeset(id, serviceId, url) SELECT _id, serviceID, url FROM homesets",
|
||||
"DROP TABLE homesets",
|
||||
|
||||
// migrate "collections" to "collection": rename columns, make id NOT NULL
|
||||
"CREATE TABLE collection(" +
|
||||
"id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT," +
|
||||
"serviceId INTEGER NOT NULL," +
|
||||
"type TEXT NOT NULL," +
|
||||
"url TEXT NOT NULL," +
|
||||
"privWriteContent INTEGER NOT NULL DEFAULT 1," +
|
||||
"privUnbind INTEGER NOT NULL DEFAULT 1," +
|
||||
"forceReadOnly INTEGER NOT NULL DEFAULT 0," +
|
||||
"displayName TEXT DEFAULT NULL," +
|
||||
"description TEXT DEFAULT NULL," +
|
||||
"color INTEGER DEFAULT NULL," +
|
||||
"timezone TEXT DEFAULT NULL," +
|
||||
"supportsVEVENT INTEGER DEFAULT NULL," +
|
||||
"supportsVTODO INTEGER DEFAULT NULL," +
|
||||
"supportsVJOURNAL INTEGER DEFAULT NULL," +
|
||||
"source TEXT DEFAULT NULL," +
|
||||
"sync INTEGER NOT NULL DEFAULT 0," +
|
||||
"FOREIGN KEY (serviceId) REFERENCES service(id) ON DELETE CASCADE" +
|
||||
")",
|
||||
"CREATE INDEX index_collection_serviceId_type ON collection(serviceId,type)",
|
||||
"INSERT INTO collection(id, serviceId, type, url, privWriteContent, privUnbind, forceReadOnly, displayName, description, color, timezone, supportsVEVENT, supportsVTODO, source, sync) " +
|
||||
"SELECT _id, serviceID, type, url, privWriteContent, privUnbind, forceReadOnly, displayName, description, color, timezone, supportsVEVENT, supportsVTODO, source, sync FROM collections",
|
||||
"DROP TABLE collections"
|
||||
)
|
||||
sql.forEach { db.execSQL(it) }
|
||||
}
|
||||
},
|
||||
|
||||
object : Migration(4, 5) {
|
||||
override fun migrate(db: SupportSQLiteDatabase) {
|
||||
db.execSQL("ALTER TABLE collections ADD COLUMN privWriteContent INTEGER DEFAULT 0 NOT NULL")
|
||||
db.execSQL("UPDATE collections SET privWriteContent=NOT readOnly")
|
||||
|
||||
db.execSQL("ALTER TABLE collections ADD COLUMN privUnbind INTEGER DEFAULT 0 NOT NULL")
|
||||
db.execSQL("UPDATE collections SET privUnbind=NOT readOnly")
|
||||
|
||||
// there's no DROP COLUMN in SQLite, so just keep the "readOnly" column
|
||||
}
|
||||
},
|
||||
|
||||
object : Migration(3, 4) {
|
||||
override fun migrate(db: SupportSQLiteDatabase) {
|
||||
db.execSQL("ALTER TABLE collections ADD COLUMN forceReadOnly INTEGER DEFAULT 0 NOT NULL")
|
||||
}
|
||||
},
|
||||
|
||||
object : Migration(2, 3) {
|
||||
override fun migrate(db: SupportSQLiteDatabase) {
|
||||
// We don't have access to the context in a Room migration now, so
|
||||
// we will just drop those settings from old DAVx5 versions.
|
||||
Logger.log.warning("Dropping settings distrustSystemCerts and overrideProxy*")
|
||||
|
||||
/*val edit = PreferenceManager.getDefaultSharedPreferences(context).edit()
|
||||
try {
|
||||
db.query("settings", arrayOf("setting", "value"), null, null, null, null, null).use { cursor ->
|
||||
while (cursor.moveToNext()) {
|
||||
when (cursor.getString(0)) {
|
||||
"distrustSystemCerts" -> edit.putBoolean(App.DISTRUST_SYSTEM_CERTIFICATES, cursor.getInt(1) != 0)
|
||||
"overrideProxy" -> edit.putBoolean(App.OVERRIDE_PROXY, cursor.getInt(1) != 0)
|
||||
"overrideProxyHost" -> edit.putString(App.OVERRIDE_PROXY_HOST, cursor.getString(1))
|
||||
"overrideProxyPort" -> edit.putInt(App.OVERRIDE_PROXY_PORT, cursor.getInt(1))
|
||||
|
||||
StartupDialogFragment.HINT_GOOGLE_PLAY_ACCOUNTS_REMOVED ->
|
||||
edit.putBoolean(StartupDialogFragment.HINT_GOOGLE_PLAY_ACCOUNTS_REMOVED, cursor.getInt(1) != 0)
|
||||
StartupDialogFragment.HINT_OPENTASKS_NOT_INSTALLED ->
|
||||
edit.putBoolean(StartupDialogFragment.HINT_OPENTASKS_NOT_INSTALLED, cursor.getInt(1) != 0)
|
||||
}
|
||||
}
|
||||
}
|
||||
db.execSQL("DROP TABLE settings")
|
||||
} finally {
|
||||
edit.apply()
|
||||
}*/
|
||||
}
|
||||
},
|
||||
|
||||
object : Migration(1, 2) {
|
||||
override fun migrate(db: SupportSQLiteDatabase) {
|
||||
db.execSQL("ALTER TABLE collections ADD COLUMN type TEXT NOT NULL DEFAULT ''")
|
||||
db.execSQL("ALTER TABLE collections ADD COLUMN source TEXT DEFAULT NULL")
|
||||
db.execSQL("UPDATE collections SET type=(" +
|
||||
"SELECT CASE service WHEN ? THEN ? ELSE ? END " +
|
||||
"FROM services WHERE _id=collections.serviceID" +
|
||||
")",
|
||||
arrayOf("caldav", "CALENDAR", "ADDRESS_BOOK"))
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
}
|
||||
|
||||
|
||||
// DAOs
|
||||
|
||||
abstract fun serviceDao(): ServiceDao
|
||||
abstract fun homeSetDao(): HomeSetDao
|
||||
abstract fun collectionDao(): CollectionDao
|
||||
abstract fun principalDao(): PrincipalDao
|
||||
abstract fun syncStatsDao(): SyncStatsDao
|
||||
abstract fun webDavDocumentDao(): WebDavDocumentDao
|
||||
abstract fun webDavMountDao(): WebDavMountDao
|
||||
|
||||
|
||||
// helpers
|
||||
|
||||
fun dump(writer: Writer, ignoreTables: Array<String>) {
|
||||
val db = openHelper.readableDatabase
|
||||
db.beginTransactionNonExclusive()
|
||||
|
||||
// iterate through all tables
|
||||
db.query(SQLiteQueryBuilder.buildQueryString(false, "sqlite_master", arrayOf("name"), "type='table'", null, null, null, null)).use { cursorTables ->
|
||||
while (cursorTables.moveToNext()) {
|
||||
val tableName = cursorTables.getString(0)
|
||||
if (ignoreTables.contains(tableName)) {
|
||||
writer.append("$tableName: ")
|
||||
db.query("SELECT COUNT(*) FROM $tableName").use { cursor ->
|
||||
if (cursor.moveToNext())
|
||||
writer.append("${cursor.getInt(0)} row(s), data not listed here\n\n")
|
||||
}
|
||||
} else {
|
||||
writer.append("$tableName\n")
|
||||
db.query("SELECT * FROM $tableName").use { cursor ->
|
||||
val table = TextTable(*cursor.columnNames)
|
||||
val cols = cursor.columnCount
|
||||
// print rows
|
||||
while (cursor.moveToNext()) {
|
||||
val values = Array(cols) { idx -> cursor.getStringOrNull(idx) }
|
||||
table.addLine(*values)
|
||||
}
|
||||
writer.append(table.toString())
|
||||
}
|
||||
}
|
||||
}
|
||||
db.endTransaction()
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -4,42 +4,28 @@
|
||||
|
||||
package at.bitfire.davdroid.db
|
||||
|
||||
import androidx.annotation.StringDef
|
||||
import androidx.room.ColumnInfo
|
||||
import androidx.room.Entity
|
||||
import androidx.room.ForeignKey
|
||||
import androidx.room.Index
|
||||
import androidx.room.PrimaryKey
|
||||
import at.bitfire.dav4jvm.okhttp.Response
|
||||
import at.bitfire.dav4jvm.okhttp.UrlUtils
|
||||
import at.bitfire.dav4jvm.property.caldav.CalDAV
|
||||
import at.bitfire.dav4jvm.Response
|
||||
import at.bitfire.dav4jvm.UrlUtils
|
||||
import at.bitfire.dav4jvm.property.caldav.CalendarColor
|
||||
import at.bitfire.dav4jvm.property.caldav.CalendarDescription
|
||||
import at.bitfire.dav4jvm.property.caldav.CalendarTimezone
|
||||
import at.bitfire.dav4jvm.property.caldav.CalendarTimezoneId
|
||||
import at.bitfire.dav4jvm.property.caldav.Source
|
||||
import at.bitfire.dav4jvm.property.caldav.SupportedCalendarComponentSet
|
||||
import at.bitfire.dav4jvm.property.carddav.AddressbookDescription
|
||||
import at.bitfire.dav4jvm.property.carddav.CardDAV
|
||||
import at.bitfire.dav4jvm.property.push.PushTransports
|
||||
import at.bitfire.dav4jvm.property.push.Topic
|
||||
import at.bitfire.dav4jvm.property.push.WebPush
|
||||
import at.bitfire.dav4jvm.property.webdav.CurrentUserPrivilegeSet
|
||||
import at.bitfire.dav4jvm.property.webdav.DisplayName
|
||||
import at.bitfire.dav4jvm.property.webdav.ResourceType
|
||||
import at.bitfire.davdroid.util.DavUtils.lastSegment
|
||||
import at.bitfire.davdroid.util.trimToNull
|
||||
import at.bitfire.ical4android.util.DateUtils
|
||||
import at.bitfire.davdroid.util.lastSegment
|
||||
import okhttp3.HttpUrl
|
||||
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
|
||||
|
||||
@Retention(AnnotationRetention.SOURCE)
|
||||
@StringDef(
|
||||
Collection.TYPE_ADDRESSBOOK,
|
||||
Collection.TYPE_CALENDAR,
|
||||
Collection.TYPE_WEBCAL
|
||||
)
|
||||
annotation class CollectionType
|
||||
import org.apache.commons.lang3.StringUtils
|
||||
|
||||
@Entity(tableName = "collection",
|
||||
foreignKeys = [
|
||||
@@ -50,106 +36,80 @@ annotation class CollectionType
|
||||
indices = [
|
||||
Index("serviceId","type"),
|
||||
Index("homeSetId","type"),
|
||||
Index("ownerId","type"),
|
||||
Index("pushTopic","type"),
|
||||
Index("url")
|
||||
]
|
||||
)
|
||||
data class Collection(
|
||||
@PrimaryKey(autoGenerate = true)
|
||||
val id: Long = 0,
|
||||
var id: Long = 0,
|
||||
|
||||
/**
|
||||
* Service, which this collection belongs to. Services are unique, so a [Collection] is uniquely
|
||||
* identifiable via its [serviceId] and [url].
|
||||
*/
|
||||
val serviceId: Long = 0,
|
||||
var serviceId: Long = 0,
|
||||
|
||||
/**
|
||||
* A home set this collection belongs to. Multiple homesets are not supported.
|
||||
* If *null* the collection is considered homeless.
|
||||
*/
|
||||
val homeSetId: Long? = null,
|
||||
var homeSetId: Long? = null,
|
||||
|
||||
/**
|
||||
* Principal who is owner of this collection.
|
||||
*/
|
||||
val ownerId: Long? = null,
|
||||
var ownerId: Long? = null,
|
||||
|
||||
/**
|
||||
* Type of service. CalDAV or CardDAV
|
||||
*/
|
||||
@CollectionType
|
||||
val type: String,
|
||||
var type: String,
|
||||
|
||||
/**
|
||||
* Address where this collection lives - with trailing slash
|
||||
*/
|
||||
val url: HttpUrl,
|
||||
var url: HttpUrl,
|
||||
|
||||
/**
|
||||
* Whether we have the permission to change contents of the collection on the server.
|
||||
* Even if this flag is set, there may still be other reasons why a collection is effectively read-only.
|
||||
*/
|
||||
val privWriteContent: Boolean = true,
|
||||
/**
|
||||
* Whether we have the permission to delete the collection on the server
|
||||
*/
|
||||
val privUnbind: Boolean = true,
|
||||
/**
|
||||
* Whether the user has manually set the "force read-only" flag.
|
||||
* Even if this flag is not set, there may still be other reasons why a collection is effectively read-only.
|
||||
*/
|
||||
val forceReadOnly: Boolean = false,
|
||||
var privWriteContent: Boolean = true,
|
||||
var privUnbind: Boolean = true,
|
||||
var forceReadOnly: Boolean = false,
|
||||
|
||||
/**
|
||||
* Human-readable name of the collection
|
||||
*/
|
||||
val displayName: String? = null,
|
||||
/**
|
||||
* Human-readable description of the collection
|
||||
*/
|
||||
val description: String? = null,
|
||||
var displayName: String? = null,
|
||||
var description: String? = null,
|
||||
|
||||
// CalDAV only
|
||||
val color: Int? = null,
|
||||
var color: Int? = null,
|
||||
|
||||
/** default timezone (only timezone ID, like `Europe/Vienna`) */
|
||||
val timezoneId: String? = null,
|
||||
/** timezone definition (full VTIMEZONE) - not a TZID! **/
|
||||
var timezone: String? = null,
|
||||
|
||||
/** whether the collection supports VEVENT; in case of calendars: null means true */
|
||||
val supportsVEVENT: Boolean? = null,
|
||||
var supportsVEVENT: Boolean? = null,
|
||||
|
||||
/** whether the collection supports VTODO; in case of calendars: null means true */
|
||||
val supportsVTODO: Boolean? = null,
|
||||
var supportsVTODO: Boolean? = null,
|
||||
|
||||
/** whether the collection supports VJOURNAL; in case of calendars: null means true */
|
||||
val supportsVJOURNAL: Boolean? = null,
|
||||
var supportsVJOURNAL: Boolean? = null,
|
||||
|
||||
/** Webcal subscription source URL */
|
||||
val source: HttpUrl? = null,
|
||||
var source: HttpUrl? = null,
|
||||
|
||||
/** whether this collection has been selected for synchronization */
|
||||
val sync: Boolean = false,
|
||||
var sync: Boolean = false,
|
||||
|
||||
/** WebDAV-Push topic */
|
||||
val pushTopic: String? = null,
|
||||
var pushTopic: String? = null,
|
||||
|
||||
/** WebDAV-Push: whether this collection supports the Web Push Transport */
|
||||
@ColumnInfo(defaultValue = "0")
|
||||
val supportsWebPush: Boolean = false,
|
||||
|
||||
/** WebDAV-Push: VAPID public key */
|
||||
val pushVapidKey: String? = null,
|
||||
var supportsWebPush: Boolean = false,
|
||||
|
||||
/** WebDAV-Push subscription URL */
|
||||
val pushSubscription: String? = null,
|
||||
var pushSubscription: String? = null,
|
||||
|
||||
/** when the [pushSubscription] expires (timestamp, used to determine whether we need to re-subscribe) */
|
||||
val pushSubscriptionExpires: Long? = null,
|
||||
|
||||
/** when the [pushSubscription] was created/updated (timestamp) */
|
||||
val pushSubscriptionCreated: Long? = null
|
||||
/** when the [pushSubscription] was created/updated (used to determine whether we need to re-subscribe) */
|
||||
var pushSubscriptionCreated: Long? = null
|
||||
|
||||
) {
|
||||
|
||||
@@ -168,9 +128,9 @@ data class Collection(
|
||||
val url = UrlUtils.withTrailingSlash(dav.href)
|
||||
val type: String = dav[ResourceType::class.java]?.let { resourceType ->
|
||||
when {
|
||||
resourceType.types.contains(CardDAV.Addressbook) -> TYPE_ADDRESSBOOK
|
||||
resourceType.types.contains(CalDAV.Calendar) -> TYPE_CALENDAR
|
||||
resourceType.types.contains(CalDAV.Subscribed) -> TYPE_WEBCAL
|
||||
resourceType.types.contains(ResourceType.ADDRESSBOOK) -> TYPE_ADDRESSBOOK
|
||||
resourceType.types.contains(ResourceType.CALENDAR) -> TYPE_CALENDAR
|
||||
resourceType.types.contains(ResourceType.SUBSCRIBED) -> TYPE_WEBCAL
|
||||
else -> null
|
||||
}
|
||||
} ?: return null
|
||||
@@ -182,11 +142,11 @@ data class Collection(
|
||||
privUnbind = privilegeSet.mayUnbind
|
||||
}
|
||||
|
||||
val displayName = dav[DisplayName::class.java]?.displayName.trimToNull()
|
||||
val displayName = StringUtils.trimToNull(dav[DisplayName::class.java]?.displayName)
|
||||
|
||||
var description: String? = null
|
||||
var color: Int? = null
|
||||
var timezoneId: String? = null
|
||||
var timezone: String? = null
|
||||
var supportsVEVENT: Boolean? = null
|
||||
var supportsVTODO: Boolean? = null
|
||||
var supportsVJOURNAL: Boolean? = null
|
||||
@@ -198,11 +158,7 @@ data class Collection(
|
||||
TYPE_CALENDAR, TYPE_WEBCAL -> {
|
||||
dav[CalendarDescription::class.java]?.let { description = it.description }
|
||||
dav[CalendarColor::class.java]?.let { color = it.color }
|
||||
dav[CalendarTimezoneId::class.java]?.let { timezoneId = it.identifier }
|
||||
if (timezoneId == null)
|
||||
dav[CalendarTimezone::class.java]?.vTimeZone?.let {
|
||||
timezoneId = DateUtils.parseVTimeZone(it)?.timeZoneId?.value
|
||||
}
|
||||
dav[CalendarTimezone::class.java]?.let { timezone = it.vTimeZone }
|
||||
|
||||
if (type == TYPE_CALENDAR) {
|
||||
supportsVEVENT = true
|
||||
@@ -229,13 +185,8 @@ data class Collection(
|
||||
|
||||
// WebDAV-Push
|
||||
var supportsWebPush = false
|
||||
var vapidPublicKey: String? = null
|
||||
dav[PushTransports::class.java]?.let { pushTransports ->
|
||||
for (transport in pushTransports.transports)
|
||||
if (transport is WebPush) {
|
||||
supportsWebPush = true
|
||||
vapidPublicKey = transport.vapidPublicKey?.key
|
||||
}
|
||||
supportsWebPush = pushTransports.hasWebPush()
|
||||
}
|
||||
val pushTopic = dav[Topic::class.java]?.topic
|
||||
|
||||
@@ -247,13 +198,12 @@ data class Collection(
|
||||
displayName = displayName,
|
||||
description = description,
|
||||
color = color,
|
||||
timezoneId = timezoneId,
|
||||
timezone = timezone,
|
||||
supportsVEVENT = supportsVEVENT,
|
||||
supportsVTODO = supportsVTODO,
|
||||
supportsVJOURNAL = supportsVJOURNAL,
|
||||
source = source,
|
||||
supportsWebPush = supportsWebPush,
|
||||
pushVapidKey = vapidPublicKey,
|
||||
pushTopic = pushTopic
|
||||
)
|
||||
}
|
||||
@@ -261,7 +211,6 @@ data class Collection(
|
||||
}
|
||||
|
||||
// calculated properties
|
||||
|
||||
fun title() = displayName ?: url.lastSegment
|
||||
fun readOnly() = forceReadOnly || !privWriteContent
|
||||
|
||||
@@ -20,36 +20,23 @@ interface CollectionDao {
|
||||
@Query("SELECT * FROM collection WHERE id=:id")
|
||||
fun get(id: Long): Collection?
|
||||
|
||||
@Query("SELECT * FROM collection WHERE id=:id")
|
||||
suspend fun getAsync(id: Long): Collection?
|
||||
|
||||
@Query("SELECT * FROM collection WHERE id=:id")
|
||||
fun getFlow(id: Long): Flow<Collection?>
|
||||
|
||||
@Query("SELECT * FROM collection WHERE serviceId=:serviceId")
|
||||
suspend fun getByService(serviceId: Long): List<Collection>
|
||||
fun getByService(serviceId: Long): List<Collection>
|
||||
|
||||
@Query("SELECT * FROM collection WHERE serviceId=:serviceId AND homeSetId IS :homeSetId")
|
||||
fun getByServiceAndHomeset(serviceId: Long, homeSetId: Long?): List<Collection>
|
||||
|
||||
@Query("SELECT * FROM collection WHERE serviceId=:serviceId AND type=:type ORDER BY displayName COLLATE NOCASE, url COLLATE NOCASE")
|
||||
fun getByServiceAndType(serviceId: Long, @CollectionType type: String): List<Collection>
|
||||
fun getByServiceAndType(serviceId: Long, type: String): List<Collection>
|
||||
|
||||
@Query("SELECT * FROM collection WHERE pushTopic=:topic AND sync")
|
||||
suspend fun getSyncableByPushTopic(topic: String): Collection?
|
||||
|
||||
@Suppress("unused") // for build variant
|
||||
@Query("SELECT * FROM collection WHERE sync")
|
||||
fun getSyncCollections(): List<Collection>
|
||||
|
||||
@Query("SELECT pushVapidKey FROM collection WHERE serviceId=:serviceId AND pushVapidKey IS NOT NULL LIMIT 1")
|
||||
suspend fun getFirstVapidKey(serviceId: Long): String?
|
||||
fun getSyncableByPushTopic(topic: String): Collection?
|
||||
|
||||
@Query("SELECT COUNT(*) FROM collection WHERE serviceId=:serviceId AND type=:type")
|
||||
suspend fun anyOfType(serviceId: Long, @CollectionType type: String): Boolean
|
||||
|
||||
@Query("SELECT COUNT(*) FROM collection WHERE supportsWebPush AND pushTopic IS NOT NULL")
|
||||
suspend fun anyPushCapable(): Boolean
|
||||
suspend fun anyOfType(serviceId: Long, type: String): Boolean
|
||||
|
||||
/**
|
||||
* Returns collections which
|
||||
@@ -58,13 +45,13 @@ interface CollectionDao {
|
||||
*/
|
||||
@Query("SELECT * FROM collection WHERE serviceId=:serviceId AND type=:type " +
|
||||
"AND (supportsVTODO OR supportsVEVENT OR supportsVJOURNAL OR (supportsVEVENT IS NULL AND supportsVTODO IS NULL AND supportsVJOURNAL IS NULL)) ORDER BY displayName COLLATE NOCASE, URL COLLATE NOCASE")
|
||||
fun pageByServiceAndType(serviceId: Long, @CollectionType type: String): PagingSource<Int, Collection>
|
||||
fun pageByServiceAndType(serviceId: Long, type: String): PagingSource<Int, Collection>
|
||||
|
||||
@Query("SELECT * FROM collection WHERE serviceId=:serviceId AND sync")
|
||||
fun getByServiceAndSync(serviceId: Long): List<Collection>
|
||||
|
||||
@Query("SELECT collection.* FROM collection, homeset WHERE collection.serviceId=:serviceId AND type=:type AND homeSetId=homeset.id AND homeset.personal ORDER BY collection.displayName COLLATE NOCASE, collection.url COLLATE NOCASE")
|
||||
fun pagePersonalByServiceAndType(serviceId: Long, @CollectionType type: String): PagingSource<Int, Collection>
|
||||
fun pagePersonalByServiceAndType(serviceId: Long, type: String): PagingSource<Int, Collection>
|
||||
|
||||
@Query("SELECT * FROM collection WHERE serviceId=:serviceId AND url=:url")
|
||||
fun getByServiceAndUrl(serviceId: Long, url: String): Collection?
|
||||
@@ -82,14 +69,8 @@ interface CollectionDao {
|
||||
* Get a list of collections that are both sync enabled and push capable (supportsWebPush and
|
||||
* pushTopic is available).
|
||||
*/
|
||||
@Query("SELECT * FROM collection WHERE serviceId=:serviceId AND sync AND supportsWebPush AND pushTopic IS NOT NULL")
|
||||
suspend fun getPushCapableSyncCollections(serviceId: Long): List<Collection>
|
||||
|
||||
@Query("SELECT * FROM collection WHERE serviceId=:serviceId AND pushSubscription IS NOT NULL")
|
||||
suspend fun getPushRegistered(serviceId: Long): List<Collection>
|
||||
|
||||
@Query("SELECT * FROM collection WHERE serviceId=:serviceId AND pushSubscription IS NOT NULL AND NOT sync")
|
||||
suspend fun getPushRegisteredAndNotSyncable(serviceId: Long): List<Collection>
|
||||
@Query("SELECT * FROM collection WHERE sync AND supportsWebPush AND pushTopic IS NOT NULL")
|
||||
suspend fun getPushCapableSyncCollections(): List<Collection>
|
||||
|
||||
@Insert(onConflict = OnConflictStrategy.IGNORE)
|
||||
fun insert(collection: Collection): Long
|
||||
@@ -103,8 +84,8 @@ interface CollectionDao {
|
||||
@Query("UPDATE collection SET forceReadOnly=:forceReadOnly WHERE id=:id")
|
||||
suspend fun updateForceReadOnly(id: Long, forceReadOnly: Boolean)
|
||||
|
||||
@Query("UPDATE collection SET pushSubscription=:pushSubscription, pushSubscriptionExpires=:pushSubscriptionExpires, pushSubscriptionCreated=:updatedAt WHERE id=:id")
|
||||
suspend fun updatePushSubscription(id: Long, pushSubscription: String?, pushSubscriptionExpires: Long?, updatedAt: Long = System.currentTimeMillis()/1000)
|
||||
@Query("UPDATE collection SET pushSubscription=:pushSubscription, pushSubscriptionCreated=:updatedAt WHERE id=:id")
|
||||
suspend fun updatePushSubscription(id: Long, pushSubscription: String, updatedAt: Long = System.currentTimeMillis())
|
||||
|
||||
@Query("UPDATE collection SET sync=:sync WHERE id=:id")
|
||||
suspend fun updateSync(id: Long, sync: Boolean)
|
||||
@@ -129,4 +110,4 @@ interface CollectionDao {
|
||||
@Delete
|
||||
fun delete(collection: Collection)
|
||||
|
||||
}
|
||||
}
|
||||
@@ -14,7 +14,7 @@ class Converters {
|
||||
|
||||
@TypeConverter
|
||||
fun httpUrlToString(url: HttpUrl?) =
|
||||
url?.toString()
|
||||
url?.toString()
|
||||
|
||||
@TypeConverter
|
||||
fun mediaTypeToString(mediaType: MediaType?) =
|
||||
@@ -2,27 +2,16 @@
|
||||
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
|
||||
*/
|
||||
|
||||
package at.bitfire.davdroid.settings
|
||||
package at.bitfire.davdroid.db
|
||||
|
||||
import at.bitfire.davdroid.util.SensitiveString
|
||||
import net.openid.appauth.AuthState
|
||||
|
||||
/**
|
||||
* Represents credentials that are used to authenticate against a CalDAV/CardDAV/WebDAV server.
|
||||
*
|
||||
* Note: [authState] can change from request to request, so make sure that you have an up-to-date
|
||||
* copy when using it.
|
||||
*/
|
||||
data class Credentials(
|
||||
/** username for Basic / Digest auth */
|
||||
val username: String? = null,
|
||||
/** password for Basic / Digest auth */
|
||||
val password: SensitiveString? = null,
|
||||
val password: String? = null,
|
||||
|
||||
/** alias of an client certificate that is present on the system */
|
||||
val certificateAlias: String? = null,
|
||||
|
||||
/** OAuth authorization state */
|
||||
val authState: AuthState? = null
|
||||
) {
|
||||
|
||||
@@ -37,7 +26,7 @@ data class Credentials(
|
||||
if (certificateAlias != null)
|
||||
s += "certificateAlias=$certificateAlias"
|
||||
|
||||
if (authState != null) // contains sensitive information (refresh token, access token)
|
||||
if (authState != null)
|
||||
s += "authState=${authState.jsonSerializeString()}"
|
||||
|
||||
return "Credentials(" + s.joinToString(", ") + ")"
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user