Compare commits

..

2 Commits

Author SHA1 Message Date
Ricki Hirner
60219aafc7 Version bump to 4.4.1.1 2024-07-12 02:25:42 +02:00
Ricki Hirner
209fdf3e7c AccountSettings v16: re-enqueue periodic sync workers with correct class name (bitfireAT/davx5#593) 2024-07-12 02:25:25 +02:00
700 changed files with 23167 additions and 41321 deletions

8
.github/CODEOWNERS vendored
View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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"

View File

@@ -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
View File

@@ -12,9 +12,6 @@ changelog:
- title: Refactoring
labels:
- refactoring
- title: Dependencies
labels:
- dependencies
- title: Other changes
labels:
- "*"

View File

@@ -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}}"

View File

@@ -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
View 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 }}

View File

@@ -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

View File

@@ -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 app:assembleDebug
./gradlew --dry-run app:lintOseDebug
./gradlew --dry-run app:testOseDebugUnitTest
./gradlew --dry-run 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 app:lintOseDebug
- name: Unit tests
run: ./gradlew 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 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
View File

@@ -16,6 +16,10 @@
bin/
gen/
# Gradle files
.gradle/
build/
# Local configuration file (sdk path, etc)
local.properties

View File

@@ -1,9 +0,0 @@
<component name="ProjectCodeStyleConfiguration">
<code_scheme name="Project" version="173">
<option name="LINE_SEPARATOR" value="&#10;" />
<option name="RIGHT_MARGIN" value="180" />
<codeStyleSettings language="kotlin">
<option name="CODE_STYLE_DEFAULTS" value="KOTLIN_OFFICIAL" />
</codeStyleSettings>
</code_scheme>
</component>

View File

@@ -1,5 +0,0 @@
<component name="ProjectCodeStyleConfiguration">
<state>
<option name="USE_PER_PROJECT_SETTINGS" value="true" />
</state>
</component>

View File

@@ -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>

View File

@@ -1,3 +0,0 @@
<component name="CopyrightManager">
<settings default="LICENSE" />
</component>

30
.tx/config Normal file
View 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
View File

@@ -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)

View File

@@ -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).

View File

@@ -1,9 +1,9 @@
[![Follow @davx5app@fosstodon.org](https://img.shields.io/mastodon/follow/109598783742737223?domain=https%3A%2F%2Ffosstodon.org&style=flat-square)](https://fosstodon.org/@davx5app)
[![Website](https://img.shields.io/website?style=flat-square&up_color=%237cb342&url=https%3A%2F%2Fwww.davx5.com)](https://www.davx5.com/)
[![License](https://img.shields.io/github/license/bitfireAT/davx5-ose?style=flat-square)](https://github.com/bitfireAT/davx5-ose/blob/main/LICENSE)
[![F-Droid](https://img.shields.io/f-droid/v/at.bitfire.davdroid?style=flat-square)](https://f-droid.org/packages/at.bitfire.davdroid/)
![GitHub Downloads (all assets, all releases)](https://img.shields.io/github/downloads/bitfireAT/davx5-ose/total?label=GitHub%20downloads)
[![License](https://img.shields.io/github/license/bitfireAT/davx5-ose?style=flat-square)](https://github.com/bitfireAT/davx5-ose/blob/main/LICENSE)
[![Follow @davx5app@fosstodon.org](https://img.shields.io/mastodon/follow/109598783742737223?domain=https%3A%2F%2Ffosstodon.org&style=flat-square)](https://fosstodon.org/@davx5app)
[![Development tests](https://github.com/bitfireAT/davx5-ose/actions/workflows/test-dev.yml/badge.svg)](https://github.com/bitfireAT/davx5-ose/actions/workflows/test-dev.yml)
![DAVx⁵ logo](app/src/main/res/mipmap-xxxhdpi/ic_launcher.png)
@@ -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).**

View File

View File

@@ -1,44 +1,46 @@
/*
/***************************************************************************************************
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
*/
**************************************************************************************************/
plugins {
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 {
applicationId = "at.bitfire.davdroid"
versionCode = 405090005
versionName = "4.5.9"
versionCode = 404010101
versionName = "4.4.1.1"
base.archivesName = "davx5-$versionCode-$versionName"
buildConfigField("long", "buildTime", "${System.currentTimeMillis()}L")
setProperty("archivesBaseName", "davx5-ose-$versionName")
minSdk = 24 // Android 7.0
targetSdk = 36 // Android 16
targetSdk = 34 // Android 14
testInstrumentationRunner = "at.bitfire.davdroid.HiltTestRunner"
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
}
@@ -48,7 +50,7 @@ android {
}
// Java namespace for our classes (not to be confused with Android package ID)
namespace = "com.davx5.ose"
namespace = "at.bitfire.davdroid"
flavorDimensions += "distribution"
productFlavors {
@@ -85,28 +87,25 @@ android {
}
lint {
disable += arrayOf("GoogleAppIndexingWarning", "ImpliedQuantity", "MissingQuantity", "MissingTranslation", "ExtraTranslation", "RtlEnabled", "RtlHardcoded", "Typos")
}
androidResources {
generateLocaleConfig = true
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"
}
@@ -119,18 +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 {
// app core
implementation(project(":core"))
// Kotlin / Android
// core
implementation(libs.kotlin.stdlib)
implementation(libs.kotlinx.coroutines)
coreLibraryDesugaring(libs.android.desugaring)
@@ -158,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)
@@ -221,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)
}
}

View File

View 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
View File

@@ -0,0 +1 @@
espressoTest

View File

@@ -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>

View File

@@ -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)
}

View 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))
}
}

View File

@@ -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()
}
}
}
}

View File

@@ -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>
}
}

View File

@@ -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
}
}

View File

@@ -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()
}
}
}

View File

@@ -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)
}
}

View File

@@ -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()
}

View File

@@ -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)

View File

@@ -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))
}
}

View File

@@ -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"))

View File

@@ -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)
}
}

View File

@@ -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)
}
}

View File

@@ -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"))
}
}

View File

@@ -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))
}
}
}

View File

@@ -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))
}
}
}

View File

@@ -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)
}
}

View File

@@ -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()
}
}

View File

@@ -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())
}
}

View File

@@ -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)
}
}
}

View File

@@ -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())
}
}

View File

@@ -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)
}
}
}

View File

@@ -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'>" +

View File

@@ -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++) {

View File

@@ -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

View File

@@ -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))

View File

@@ -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"
}
}

View File

@@ -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))
}
}

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.2 KiB

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.1 KiB

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

View 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>

View File

@@ -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" />

View File

@@ -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()
}
}

View File

@@ -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"

View File

@@ -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
}

View File

@@ -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
)
}

View File

@@ -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()
}
}
}

View File

@@ -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)

View File

@@ -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))
}
}

View File

@@ -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))
}
}

View File

@@ -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))
}
}

View File

@@ -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)
}
}
}

View File

@@ -1,18 +1,352 @@
<?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"
android:installLocation="internalOnly">
xmlns:tools="http://schemas.android.com/tools"
android:installLocation="internalOnly">
<application android:name=".App">
<!-- normal permissions -->
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE"/>
<uses-permission android:name="android.permission.ACCESS_WIFI_STATE"/>
<uses-permission android:name="android.permission.INTERNET"/>
<uses-permission android:name="android.permission.POST_NOTIFICATIONS"/>
<uses-permission android:name="android.permission.READ_SYNC_SETTINGS"/>
<uses-permission android:name="android.permission.READ_SYNC_STATS"/>
<uses-permission android:name="android.permission.WRITE_SYNC_SETTINGS"/>
<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"/>
<uses-permission android:name="android.permission.WRITE_CONTACTS"/>
<!-- android.permission-group.CALENDAR -->
<uses-permission android:name="android.permission.READ_CALENDAR"/>
<uses-permission android:name="android.permission.WRITE_CALENDAR"/>
<!-- android.permission-group.LOCATION -->
<!-- getting the WiFi name (for "sync in Wifi only") requires
- coarse location (Android 8.1)
- fine location (Android 10) -->
<uses-permission-sdk-23 android:name="android.permission.ACCESS_COARSE_LOCATION"/>
<uses-permission-sdk-23 android:name="android.permission.ACCESS_FINE_LOCATION"/>
<!-- required since Android 10 to get the WiFi name while in background (= while syncing) -->
<uses-permission-sdk-23 android:name="android.permission.ACCESS_BACKGROUND_LOCATION"/>
<!-- ical4android declares task access permissions -->
<!-- Disable GPS capability requirement, which is implicitly derived from ACCESS_FINE_LOCATION
permission and makes app unusable on some devices without GPS. We need location permissions only
to get the current WiFi SSID, and we don't need GPS for that. -->
<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"
android:label="@string/app_name"
android:theme="@style/Theme.AppCompat.DayNight.NoActionBar"
android:resizeableActivity="true"
tools:ignore="UnusedAttribute"
android:supportsRtl="true">
<!-- required for Hilt/WorkManager integration -->
<provider
android:name="androidx.startup.InitializationProvider"
android:authorities="${applicationId}.androidx-startup"
tools:node="remove">
</provider>
<!-- Remove the node added by AppAuth (remove only from net.openid.appauth library, not from our flavor manifest files) -->
<activity android:name="net.openid.appauth.RedirectUriReceiverActivity"
tools:node="remove" tools:selector="net.openid.appauth"/>
tools:node="remove" tools:selector="net.openid.appauth"/>
<activity android:name=".ui.intro.IntroActivity" />
<activity
android:name=".ui.AccountsActivity"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.MAIN"/>
<category android:name="android.intent.category.LAUNCHER"/>
</intent-filter>
</activity>
<activity
android:name=".ui.AboutActivity"
android:label="@string/navigation_drawer_about"
android:parentActivityName=".ui.AccountsActivity"/>
<activity
android:name=".ui.AppSettingsActivity"
android:label="@string/app_settings"
android:parentActivityName=".ui.AccountsActivity"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.APPLICATION_PREFERENCES"/>
<category android:name="android.intent.category.DEFAULT" />
</intent-filter>
</activity>
<activity
android:name=".ui.DebugInfoActivity"
android:parentActivityName=".ui.AppSettingsActivity"
android:exported="true"
android:label="@string/debug_info_title">
<intent-filter>
<action android:name="android.intent.action.BUG_REPORT"/>
<category android:name="android.intent.category.DEFAULT" />
</intent-filter>
</activity>
<activity
android:name=".ui.PermissionsActivity"
android:label="@string/app_settings_security_app_permissions"
android:parentActivityName=".ui.AppSettingsActivity" />
<activity
android:name=".ui.TasksActivity"
android:label="@string/intro_tasks_title"
android:parentActivityName=".ui.AppSettingsActivity" />
<activity
android:name=".ui.setup.LoginActivity"
android:parentActivityName=".ui.AccountsActivity"
android:windowSoftInputMode="adjustResize"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.MAIN"/>
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.VIEW"/>
<category android:name="android.intent.category.BROWSABLE"/>
<category android:name="android.intent.category.DEFAULT"/>
<data android:scheme="caldav"/>
<data android:scheme="caldavs"/>
<data android:scheme="carddav"/>
<data android:scheme="carddavs"/>
<data android:scheme="davx5"/>
</intent-filter>
<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 android:scheme="http" />
<data android:scheme="https" />
</intent-filter>
</activity>
<activity
android:name=".ui.account.AccountActivity"
android:parentActivityName=".ui.AccountsActivity"
android:exported="true">
</activity>
<activity
android:name=".ui.account.CollectionActivity"
android:parentActivityName=".ui.account.AccountActivity" />
<activity
android:name=".ui.account.CreateAddressBookActivity"
android:parentActivityName=".ui.account.AccountActivity" />
<activity
android:name=".ui.account.CreateCalendarActivity"
android:parentActivityName=".ui.account.AccountActivity" />
<activity
android:name=".ui.account.AccountSettingsActivity"
android:parentActivityName=".ui.account.AccountActivity" />
<activity
android:name=".ui.account.WifiPermissionsActivity"
android:parentActivityName=".ui.account.AccountSettingsActivity" />
<activity
android:name=".ui.webdav.WebdavMountsActivity"
android:exported="true"
android:parentActivityName=".ui.AccountsActivity" />
<activity
android:name=".ui.webdav.AddWebdavMountActivity"
android:parentActivityName=".ui.webdav.WebdavMountsActivity"
android:windowSoftInputMode="adjustResize" />
<!-- account type "DAVx⁵" -->
<service
android:name=".sync.account.AccountAuthenticatorService"
android:exported="false">
<intent-filter>
<action android:name="android.accounts.AccountAuthenticator"/>
</intent-filter>
<meta-data
android:name="android.accounts.AccountAuthenticator"
android:resource="@xml/account_authenticator"/>
</service>
<service
android:name=".sync.adapter.CalendarsSyncAdapterService"
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_calendars"/>
</service>
<service
android:name=".sync.adapter.JtxSyncAdapterService"
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_notes"/>
</service>
<service
android:name=".sync.adapter.OpenTasksSyncAdapterService"
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_opentasks"/>
</service>
<service
android:name=".sync.adapter.TasksOrgSyncAdapterService"
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_tasks_org"/>
</service>
<provider
android:authorities="@string/webdav_authority"
android:name=".webdav.DavDocumentsProvider"
android:exported="true"
android:grantUriPermissions="true"
android:permission="android.permission.MANAGE_DOCUMENTS">
<intent-filter>
<action android:name="android.content.action.DOCUMENTS_PROVIDER" />
</intent-filter>
</provider>
<!-- account type "DAVx⁵ Address book" -->
<service
android:name=".sync.account.AddressBookAuthenticatorService"
android:exported="true"
tools:ignore="ExportedService"> <!-- Since Android 11, this must be true so that Google Contacts shows the address book accounts -->
<intent-filter>
<action android:name="android.accounts.AccountAuthenticator"/>
</intent-filter>
<meta-data
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"
tools:ignore="ExportedService">
<intent-filter>
<action android:name="android.content.SyncAdapter"/>
</intent-filter>
<meta-data
android:name="android.content.SyncAdapter"
android:resource="@xml/sync_contacts"/>
<meta-data
android:name="android.provider.CONTACTS_STRUCTURE"
android:resource="@xml/contacts"/>
</service>
<!-- provider to share debug info/logs -->
<provider
android:name="androidx.core.content.FileProvider"
android:authorities="@string/authority_debug_provider"
android:grantUriPermissions="true"
android:exported="false">
<meta-data
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/debug_paths" />
</provider>
<!-- 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.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>
</receiver>
<!-- Widgets -->
<receiver android:name=".ui.widget.SyncButtonWidgetReceiver"
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_sync_button" />
</receiver>
</application>
<!-- package visiblity which apps do we need to see? -->
<queries>
<!-- system providers (listing them is technically not required, but some apps like the
Huawei calendar take this as indication of whether these providers are accessed) -->
<provider android:authorities="com.android.calendar"/>
<provider android:authorities="com.android.contacts"/>
<!-- task providers -->
<package android:name="at.techbee.jtx" />
<package android:name="org.dmfs.tasks" />
<package android:name="org.tasks" />
<!-- ICSx5 for Webcal feeds -->
<package android:name="at.bitfire.icsdroid"/>
<!-- apps that interact with contact, calendar, task data (for debug info) -->
<intent>
<action android:name="*" />
<data android:scheme="content" android:host="com.android.contacts" />
</intent>
<intent>
<action android:name="*" />
<data android:scheme="content" android:host="com.android.calendar" />
</intent>
<!-- Open URLs in a browser or other app [https://developer.android.com/training/package-visibility/use-cases#open-urls-browser-or-other-app] -->
<intent>
<action android:name="android.intent.action.VIEW" />
<data android:scheme="https" />
</intent>
<!-- Custom Tabs support (e.g. Nextcloud Login Flow) -->
<intent>
<action android:name="android.support.customtabs.action.CustomTabsService" />
</intent>
</queries>
</manifest>

View 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"]}

View File

Before

Width:  |  Height:  |  Size: 23 KiB

After

Width:  |  Height:  |  Size: 23 KiB

View 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)
}
}

View 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
}
}

View File

@@ -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")

View 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)
}
}
}

View File

@@ -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")
}

View 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()
}
}
}

View File

@@ -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

View File

@@ -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)
}
}

View File

@@ -14,7 +14,7 @@ class Converters {
@TypeConverter
fun httpUrlToString(url: HttpUrl?) =
url?.toString()
url?.toString()
@TypeConverter
fun mediaTypeToString(mediaType: MediaType?) =

View File

@@ -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(", ") + ")"

View File

@@ -8,7 +8,7 @@ import androidx.room.Entity
import androidx.room.ForeignKey
import androidx.room.Index
import androidx.room.PrimaryKey
import at.bitfire.davdroid.util.DavUtils.lastSegment
import at.bitfire.davdroid.util.lastSegment
import okhttp3.HttpUrl
@Entity(tableName = "homeset",
@@ -22,20 +22,20 @@ import okhttp3.HttpUrl
)
data class HomeSet(
@PrimaryKey(autoGenerate = true)
val id: Long,
var id: Long,
val serviceId: Long,
var serviceId: Long,
/**
* Whether this homeset belongs to the [Service.principal] given by [serviceId].
*/
val personal: Boolean,
var personal: Boolean,
val url: HttpUrl,
var url: HttpUrl,
val privBind: Boolean = true,
var privBind: Boolean = true,
val displayName: String? = null
var displayName: String? = null
) {
fun title() = displayName ?: url.lastSegment

View File

@@ -8,7 +8,6 @@ import androidx.room.Dao
import androidx.room.Delete
import androidx.room.Insert
import androidx.room.Query
import androidx.room.Transaction
import androidx.room.Update
import kotlinx.coroutines.flow.Flow
@@ -16,7 +15,7 @@ import kotlinx.coroutines.flow.Flow
interface HomeSetDao {
@Query("SELECT * FROM homeset WHERE id=:homesetId")
fun getById(homesetId: Long): HomeSet?
fun getById(homesetId: Long): HomeSet
@Query("SELECT * FROM homeset WHERE serviceId=:serviceId AND url=:url")
fun getByUrl(serviceId: Long, url: String): HomeSet?
@@ -25,7 +24,7 @@ interface HomeSetDao {
fun getByService(serviceId: Long): List<HomeSet>
@Query("SELECT * FROM homeset WHERE serviceId=(SELECT id FROM service WHERE accountName=:accountName AND type=:serviceType) AND privBind ORDER BY displayName, url COLLATE NOCASE")
fun getBindableByAccountAndServiceTypeFlow(accountName: String, @ServiceType serviceType: String): Flow<List<HomeSet>>
fun getBindableByAccountAndServiceTypeFlow(accountName: String, serviceType: String): Flow<List<HomeSet>>
@Query("SELECT * FROM homeset WHERE serviceId=:serviceId AND privBind")
fun getBindableByServiceFlow(serviceId: Long): Flow<List<HomeSet>>
@@ -36,24 +35,6 @@ interface HomeSetDao {
@Update
fun update(homeset: HomeSet)
/**
* If a homeset with the given service ID and URL already exists, it is updated with the other fields.
* Otherwise, a new homeset is inserted.
*
* This method preserves the primary key, as opposed to using "@Insert(onConflict = OnConflictStrategy.REPLACE)"
* which will create a new row with incremented ID and thus breaks entity relationships!
*
* @param homeSet home set to insert/update
*
* @return ID of the row that has been inserted or updated. -1 If the insert fails due to other reasons.
*/
@Transaction
fun insertOrUpdateByUrlBlocking(homeSet: HomeSet): Long =
getByUrl(homeSet.serviceId, homeSet.url.toString())?.let { existingHomeset ->
update(homeSet.copy(id = existingHomeset.id))
existingHomeset.id
} ?: insert(homeSet)
@Delete
fun delete(homeset: HomeSet)

View File

@@ -8,17 +8,13 @@ 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.Response
import at.bitfire.dav4jvm.UrlUtils
import at.bitfire.dav4jvm.property.webdav.DisplayName
import at.bitfire.dav4jvm.property.webdav.ResourceType
import at.bitfire.dav4jvm.property.webdav.WebDAV
import at.bitfire.davdroid.util.trimToNull
import okhttp3.HttpUrl
import org.apache.commons.lang3.StringUtils
/**
* A principal entity representing a WebDAV principal (rfc3744).
*/
@Entity(tableName = "principal",
foreignKeys = [
ForeignKey(entity = Service::class, parentColumns = arrayOf("id"), childColumns = arrayOf("serviceId"), onDelete = ForeignKey.CASCADE)
@@ -30,11 +26,11 @@ import okhttp3.HttpUrl
)
data class Principal(
@PrimaryKey(autoGenerate = true)
val id: Long = 0,
val serviceId: Long,
var id: Long = 0,
var serviceId: Long,
/** URL of the principal, always without trailing slash */
val url: HttpUrl,
val displayName: String? = null
var url: HttpUrl,
var displayName: String? = null
) {
companion object {
@@ -47,11 +43,13 @@ data class Principal(
fun fromDavResponse(serviceId: Long, dav: Response): Principal? {
// Check if response is a principal
val resourceType = dav[ResourceType::class.java] ?: return null
if (!resourceType.types.contains(WebDAV.Principal))
if (!resourceType.types.contains(ResourceType.PRINCIPAL))
return null
// Try getting the display name of the principal
val displayName: String? = dav[DisplayName::class.java]?.displayName.trimToNull()
val displayName: String? = StringUtils.trimToNull(
dav[DisplayName::class.java]?.displayName
)
// Create and return principal - even without it's display name
return Principal(

View File

@@ -48,20 +48,11 @@ interface PrincipalDao {
* @param principal Principal to be inserted or updated
* @return ID of the newly inserted or already existing principal
*/
fun insertOrUpdate(serviceId: Long, principal: Principal): Long {
// Try to get existing principal by URL
val oldPrincipal = getByUrl(serviceId, principal.url)
// Insert new principal if not existing
if (oldPrincipal == null)
return insert(principal)
// Otherwise update the existing principal
if (principal.displayName != oldPrincipal.displayName)
update(principal.copy(id = oldPrincipal.id))
// In any case return the id of the principal
return oldPrincipal.id
}
fun insertOrUpdate(serviceId: Long, principal: Principal): Long =
getByUrl(serviceId, principal.url)?.let { oldPrincipal ->
if (principal.displayName != oldPrincipal.displayName)
update(principal.copy(id = oldPrincipal.id))
return oldPrincipal.id
} ?: insert(principal)
}

View File

@@ -4,16 +4,11 @@
package at.bitfire.davdroid.db
import androidx.annotation.StringDef
import androidx.room.Entity
import androidx.room.Index
import androidx.room.PrimaryKey
import okhttp3.HttpUrl
@Retention(AnnotationRetention.SOURCE)
@StringDef(Service.TYPE_CALDAV, Service.TYPE_CARDDAV)
annotation class ServiceType
/**
* A service entity.
*
@@ -26,14 +21,12 @@ annotation class ServiceType
])
data class Service(
@PrimaryKey(autoGenerate = true)
val id: Long,
var id: Long,
val accountName: String,
var accountName: String,
var type: String,
@ServiceType
val type: String,
val principal: HttpUrl? = null
var principal: HttpUrl?
) {
companion object {

View File

@@ -14,10 +14,10 @@ import kotlinx.coroutines.flow.Flow
interface ServiceDao {
@Query("SELECT * FROM service WHERE accountName=:accountName AND type=:type")
suspend fun getByAccountAndType(accountName: String, @ServiceType type: String): Service?
fun getByAccountAndType(accountName: String, type: String): Service?
@Query("SELECT * FROM service WHERE accountName=:accountName AND type=:type")
fun getByAccountAndTypeFlow(accountName: String, @ServiceType type: String): Flow<Service?>
fun getByAccountAndTypeFlow(accountName: String, type: String): Flow<Service?>
@Query("SELECT id FROM service WHERE accountName=:accountName")
suspend fun getIdsByAccountAsync(accountName: String): List<Long>
@@ -25,12 +25,6 @@ interface ServiceDao {
@Query("SELECT * FROM service WHERE id=:id")
fun get(id: Long): Service?
@Query("SELECT * FROM service WHERE id=:id")
suspend fun getAsync(id: Long): Service?
@Query("SELECT * FROM service")
suspend fun getAll(): List<Service>
@Insert(onConflict = OnConflictStrategy.REPLACE)
fun insertOrReplace(service: Service): Long

View File

@@ -2,21 +2,21 @@
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
*/
package at.bitfire.davdroid.resource
package at.bitfire.davdroid.db
import at.bitfire.dav4jvm.property.webdav.SyncToken
import org.json.JSONException
import org.json.JSONObject
data class SyncState(
val type: Type,
val value: String,
val type: Type,
val value: String,
/**
* Whether this sync state occurred during an initial sync as described
* in RFC 6578, which means the initial sync is not complete yet.
*/
var initialSync: Boolean? = null
/**
* Whether this sync state occurred during an initial sync as described
* in RFC 6578, which means the initial sync is not complete yet.
*/
var initialSync: Boolean? = null
) {
companion object {

Some files were not shown because too many files have changed in this diff Show More