Compare commits

..

1 Commits

Author SHA1 Message Date
Arnau Mora
46e8c4522b Updated name, package and color
Signed-off-by: Arnau Mora Gras <arnyminerz@proton.me>
2024-10-25 17:47:23 +02:00
438 changed files with 12127 additions and 22998 deletions

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,14 +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] "
groups:
ci-actions:
patterns: ["*"]

View File

@@ -28,12 +28,12 @@ jobs:
steps:
- name: Checkout repository
uses: actions/checkout@v5
- 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@v4
- 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
@@ -50,7 +50,7 @@ jobs:
# uses: github/codeql-action/autobuild@v2
- name: Build
run: ./gradlew --build-cache --configuration-cache --no-daemon app:assembleOseDebug
run: ./gradlew --build-cache --configuration-cache --configuration-cache-problems=warn --no-daemon app:assembleOseDebug
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@v3

View File

@@ -19,19 +19,19 @@ jobs:
discussions: write
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
- 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@v4
- 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,8 +2,7 @@ name: Development tests
on:
push:
branches:
- 'main-ose'
pull_request:
- '*'
concurrency:
group: test-dev-${{ github.ref }}
@@ -15,53 +14,53 @@ jobs:
if: ${{ github.ref == 'refs/heads/main-ose' }}
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
- uses: actions/setup-java@v5
- uses: actions/checkout@v4
- uses: actions/setup-java@v4
with:
distribution: temurin
java-version: 21
# See https://community.gradle.org/github-actions/docs/setup-gradle/ for more information
- uses: gradle/actions/setup-gradle@v4 # creates build cache when on main branch
- uses: gradle/actions/setup-gradle@v3 # creates build cache when on main branch
with:
cache-encryption-key: ${{ secrets.gradle_encryption_key }}
dependency-graph: generate-and-submit # submit Github Dependency Graph info
- run: ./gradlew --build-cache --configuration-cache app:compileOseDebugSource
- run: ./gradlew --build-cache --configuration-cache --configuration-cache-problems=warn app:compileOseDebugSource
test:
needs: compile
if: ${{ !cancelled() }} # even if compile didn't run (because not on main branch)
if: ${{ always() }} # even if compile didn't run (because not on main branch)
name: Lint and unit tests
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
- 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@v4
- uses: gradle/actions/setup-gradle@v3
with:
cache-encryption-key: ${{ secrets.gradle_encryption_key }}
cache-read-only: true
- name: Run lint
run: ./gradlew --build-cache --configuration-cache app:lintOseDebug
run: ./gradlew --build-cache --configuration-cache --configuration-cache-problems=warn app:lintOseDebug
- name: Run unit tests
run: ./gradlew --build-cache --configuration-cache app:testOseDebugUnitTest
run: ./gradlew --build-cache --configuration-cache --configuration-cache-problems=warn app:testOseDebugUnitTest
test_on_emulator:
needs: compile
if: ${{ !cancelled() }} # even if compile didn't run (because not on main branch)
if: ${{ always() }} # even if compile didn't run (because not on main branch)
name: Instrumented tests
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
- 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@v4
- uses: gradle/actions/setup-gradle@v3
with:
cache-encryption-key: ${{ secrets.gradle_encryption_key }}
cache-read-only: true
@@ -79,4 +78,4 @@ jobs:
key: avd-${{ hashFiles('app/build.gradle.kts') }} # gradle-managed devices are defined there
- name: Run device tests
run: ./gradlew --build-cache --configuration-cache app:virtualCheck
run: ./gradlew --build-cache --configuration-cache --configuration-cache-problems=warn app:virtualCheck

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>

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

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

@@ -1,33 +1,30 @@
/*
/***************************************************************************************************
* 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.android)
alias(libs.plugins.ksp)
alias(libs.plugins.mikepenz.aboutLibraries)
}
// Android configuration
android {
compileSdk = 36
compileSdk = 35
defaultConfig {
applicationId = "at.bitfire.davdroid"
versionCode = 405040002
versionName = "4.5.4-rc.1"
versionCode = 404030200
versionName = "4.4.3.2"
base.archivesName = "davx5-ose-$versionName"
setProperty("archivesBaseName", "davx5-ose-$versionName")
minSdk = 24 // Android 7.0
targetSdk = 36 // Android 16
buildConfigField("boolean", "customCertsUI", "true")
targetSdk = 35 // Android 15
testInstrumentationRunner = "at.bitfire.davdroid.HiltTestRunner"
}
@@ -85,31 +82,31 @@ android {
signingConfig = signingConfigs.findByName("bitfire")
}
getByName("debug") {
applicationIdSuffix = ".debug"
}
}
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"
}
@@ -123,10 +120,20 @@ ksp {
}
aboutLibraries {
// exclude timestamps for reproducible builds [https://github.com/bitfireAT/davx5-ose/issues/994]
excludeFields = arrayOf("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 {
// core
implementation(libs.kotlin.stdlib)
@@ -159,6 +166,7 @@ dependencies {
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)
@@ -176,33 +184,21 @@ dependencies {
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
@Suppress("RedundantSuppression")
implementation(libs.dnsjava)
implementation(libs.guava)
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)
@@ -213,12 +209,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)

View File

@@ -8,19 +8,53 @@
-dontobfuscate
-printusage build/reports/r8-usage.txt
# keep rules
-keep class at.bitfire.** { *; } # all DAVx5 code is required
# 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.xbill.DNS.spi.DnsjavaInetAddressResolverProvider # dnsjava
-dontwarn org.xmlpull.**
# dnsjava
-dontwarn com.sun.jna.**
-dontwarn lombok.**
-dontwarn javax.naming.NamingException
-dontwarn javax.naming.directory.**
-dontwarn sun.net.spi.nameservice.NameService
-dontwarn sun.net.spi.nameservice.NameServiceDescriptor
-dontwarn org.xbill.DNS.spi.DnsjavaInetAddressResolverProvider

View File

@@ -1,675 +0,0 @@
{
"formatVersion": 1,
"database": {
"version": 15,
"identityHash": "ab1cb6057d8e050f6648bea46ae0943d",
"entities": [
{
"tableName": "service",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `accountName` TEXT NOT NULL, `type` TEXT NOT NULL, `principal` TEXT)",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "accountName",
"columnName": "accountName",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "type",
"columnName": "type",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "principal",
"columnName": "principal",
"affinity": "TEXT",
"notNull": false
}
],
"primaryKey": {
"autoGenerate": true,
"columnNames": [
"id"
]
},
"indices": [
{
"name": "index_service_accountName_type",
"unique": true,
"columnNames": [
"accountName",
"type"
],
"orders": [],
"createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_service_accountName_type` ON `${TABLE_NAME}` (`accountName`, `type`)"
}
],
"foreignKeys": []
},
{
"tableName": "homeset",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `serviceId` INTEGER NOT NULL, `personal` INTEGER NOT NULL, `url` TEXT NOT NULL, `privBind` INTEGER NOT NULL, `displayName` TEXT, FOREIGN KEY(`serviceId`) REFERENCES `service`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "serviceId",
"columnName": "serviceId",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "personal",
"columnName": "personal",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "url",
"columnName": "url",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "privBind",
"columnName": "privBind",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "displayName",
"columnName": "displayName",
"affinity": "TEXT",
"notNull": false
}
],
"primaryKey": {
"autoGenerate": true,
"columnNames": [
"id"
]
},
"indices": [
{
"name": "index_homeset_serviceId_url",
"unique": true,
"columnNames": [
"serviceId",
"url"
],
"orders": [],
"createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_homeset_serviceId_url` ON `${TABLE_NAME}` (`serviceId`, `url`)"
}
],
"foreignKeys": [
{
"table": "service",
"onDelete": "CASCADE",
"onUpdate": "NO ACTION",
"columns": [
"serviceId"
],
"referencedColumns": [
"id"
]
}
]
},
{
"tableName": "collection",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `serviceId` INTEGER NOT NULL, `homeSetId` INTEGER, `ownerId` INTEGER, `type` TEXT NOT NULL, `url` TEXT NOT NULL, `privWriteContent` INTEGER NOT NULL, `privUnbind` INTEGER NOT NULL, `forceReadOnly` INTEGER NOT NULL, `displayName` TEXT, `description` TEXT, `color` INTEGER, `timezone` TEXT, `supportsVEVENT` INTEGER, `supportsVTODO` INTEGER, `supportsVJOURNAL` INTEGER, `source` TEXT, `sync` INTEGER NOT NULL, `pushTopic` TEXT, `supportsWebPush` INTEGER NOT NULL DEFAULT 0, `pushSubscription` TEXT, `pushSubscriptionExpires` INTEGER, `pushSubscriptionCreated` INTEGER, FOREIGN KEY(`serviceId`) REFERENCES `service`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`homeSetId`) REFERENCES `homeset`(`id`) ON UPDATE NO ACTION ON DELETE SET NULL , FOREIGN KEY(`ownerId`) REFERENCES `principal`(`id`) ON UPDATE NO ACTION ON DELETE SET NULL )",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "serviceId",
"columnName": "serviceId",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "homeSetId",
"columnName": "homeSetId",
"affinity": "INTEGER",
"notNull": false
},
{
"fieldPath": "ownerId",
"columnName": "ownerId",
"affinity": "INTEGER",
"notNull": false
},
{
"fieldPath": "type",
"columnName": "type",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "url",
"columnName": "url",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "privWriteContent",
"columnName": "privWriteContent",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "privUnbind",
"columnName": "privUnbind",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "forceReadOnly",
"columnName": "forceReadOnly",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "displayName",
"columnName": "displayName",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "description",
"columnName": "description",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "color",
"columnName": "color",
"affinity": "INTEGER",
"notNull": false
},
{
"fieldPath": "timezone",
"columnName": "timezone",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "supportsVEVENT",
"columnName": "supportsVEVENT",
"affinity": "INTEGER",
"notNull": false
},
{
"fieldPath": "supportsVTODO",
"columnName": "supportsVTODO",
"affinity": "INTEGER",
"notNull": false
},
{
"fieldPath": "supportsVJOURNAL",
"columnName": "supportsVJOURNAL",
"affinity": "INTEGER",
"notNull": false
},
{
"fieldPath": "source",
"columnName": "source",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "sync",
"columnName": "sync",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "pushTopic",
"columnName": "pushTopic",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "supportsWebPush",
"columnName": "supportsWebPush",
"affinity": "INTEGER",
"notNull": true,
"defaultValue": "0"
},
{
"fieldPath": "pushSubscription",
"columnName": "pushSubscription",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "pushSubscriptionExpires",
"columnName": "pushSubscriptionExpires",
"affinity": "INTEGER",
"notNull": false
},
{
"fieldPath": "pushSubscriptionCreated",
"columnName": "pushSubscriptionCreated",
"affinity": "INTEGER",
"notNull": false
}
],
"primaryKey": {
"autoGenerate": true,
"columnNames": [
"id"
]
},
"indices": [
{
"name": "index_collection_serviceId_type",
"unique": false,
"columnNames": [
"serviceId",
"type"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_collection_serviceId_type` ON `${TABLE_NAME}` (`serviceId`, `type`)"
},
{
"name": "index_collection_homeSetId_type",
"unique": false,
"columnNames": [
"homeSetId",
"type"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_collection_homeSetId_type` ON `${TABLE_NAME}` (`homeSetId`, `type`)"
},
{
"name": "index_collection_ownerId_type",
"unique": false,
"columnNames": [
"ownerId",
"type"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_collection_ownerId_type` ON `${TABLE_NAME}` (`ownerId`, `type`)"
},
{
"name": "index_collection_pushTopic_type",
"unique": false,
"columnNames": [
"pushTopic",
"type"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_collection_pushTopic_type` ON `${TABLE_NAME}` (`pushTopic`, `type`)"
},
{
"name": "index_collection_url",
"unique": false,
"columnNames": [
"url"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_collection_url` ON `${TABLE_NAME}` (`url`)"
}
],
"foreignKeys": [
{
"table": "service",
"onDelete": "CASCADE",
"onUpdate": "NO ACTION",
"columns": [
"serviceId"
],
"referencedColumns": [
"id"
]
},
{
"table": "homeset",
"onDelete": "SET NULL",
"onUpdate": "NO ACTION",
"columns": [
"homeSetId"
],
"referencedColumns": [
"id"
]
},
{
"table": "principal",
"onDelete": "SET NULL",
"onUpdate": "NO ACTION",
"columns": [
"ownerId"
],
"referencedColumns": [
"id"
]
}
]
},
{
"tableName": "principal",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `serviceId` INTEGER NOT NULL, `url` TEXT NOT NULL, `displayName` TEXT, FOREIGN KEY(`serviceId`) REFERENCES `service`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "serviceId",
"columnName": "serviceId",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "url",
"columnName": "url",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "displayName",
"columnName": "displayName",
"affinity": "TEXT",
"notNull": false
}
],
"primaryKey": {
"autoGenerate": true,
"columnNames": [
"id"
]
},
"indices": [
{
"name": "index_principal_serviceId_url",
"unique": true,
"columnNames": [
"serviceId",
"url"
],
"orders": [],
"createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_principal_serviceId_url` ON `${TABLE_NAME}` (`serviceId`, `url`)"
}
],
"foreignKeys": [
{
"table": "service",
"onDelete": "CASCADE",
"onUpdate": "NO ACTION",
"columns": [
"serviceId"
],
"referencedColumns": [
"id"
]
}
]
},
{
"tableName": "syncstats",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `collectionId` INTEGER NOT NULL, `authority` TEXT NOT NULL, `lastSync` INTEGER NOT NULL, FOREIGN KEY(`collectionId`) REFERENCES `collection`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "collectionId",
"columnName": "collectionId",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "authority",
"columnName": "authority",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "lastSync",
"columnName": "lastSync",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": true,
"columnNames": [
"id"
]
},
"indices": [
{
"name": "index_syncstats_collectionId_authority",
"unique": true,
"columnNames": [
"collectionId",
"authority"
],
"orders": [],
"createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_syncstats_collectionId_authority` ON `${TABLE_NAME}` (`collectionId`, `authority`)"
}
],
"foreignKeys": [
{
"table": "collection",
"onDelete": "CASCADE",
"onUpdate": "NO ACTION",
"columns": [
"collectionId"
],
"referencedColumns": [
"id"
]
}
]
},
{
"tableName": "webdav_document",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `mountId` INTEGER NOT NULL, `parentId` INTEGER, `name` TEXT NOT NULL, `isDirectory` INTEGER NOT NULL, `displayName` TEXT, `mimeType` TEXT, `eTag` TEXT, `lastModified` INTEGER, `size` INTEGER, `mayBind` INTEGER, `mayUnbind` INTEGER, `mayWriteContent` INTEGER, `quotaAvailable` INTEGER, `quotaUsed` INTEGER, FOREIGN KEY(`mountId`) REFERENCES `webdav_mount`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`parentId`) REFERENCES `webdav_document`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "mountId",
"columnName": "mountId",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "parentId",
"columnName": "parentId",
"affinity": "INTEGER",
"notNull": false
},
{
"fieldPath": "name",
"columnName": "name",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "isDirectory",
"columnName": "isDirectory",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "displayName",
"columnName": "displayName",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "mimeType",
"columnName": "mimeType",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "eTag",
"columnName": "eTag",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "lastModified",
"columnName": "lastModified",
"affinity": "INTEGER",
"notNull": false
},
{
"fieldPath": "size",
"columnName": "size",
"affinity": "INTEGER",
"notNull": false
},
{
"fieldPath": "mayBind",
"columnName": "mayBind",
"affinity": "INTEGER",
"notNull": false
},
{
"fieldPath": "mayUnbind",
"columnName": "mayUnbind",
"affinity": "INTEGER",
"notNull": false
},
{
"fieldPath": "mayWriteContent",
"columnName": "mayWriteContent",
"affinity": "INTEGER",
"notNull": false
},
{
"fieldPath": "quotaAvailable",
"columnName": "quotaAvailable",
"affinity": "INTEGER",
"notNull": false
},
{
"fieldPath": "quotaUsed",
"columnName": "quotaUsed",
"affinity": "INTEGER",
"notNull": false
}
],
"primaryKey": {
"autoGenerate": true,
"columnNames": [
"id"
]
},
"indices": [
{
"name": "index_webdav_document_mountId_parentId_name",
"unique": true,
"columnNames": [
"mountId",
"parentId",
"name"
],
"orders": [],
"createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_webdav_document_mountId_parentId_name` ON `${TABLE_NAME}` (`mountId`, `parentId`, `name`)"
},
{
"name": "index_webdav_document_parentId",
"unique": false,
"columnNames": [
"parentId"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_webdav_document_parentId` ON `${TABLE_NAME}` (`parentId`)"
}
],
"foreignKeys": [
{
"table": "webdav_mount",
"onDelete": "CASCADE",
"onUpdate": "NO ACTION",
"columns": [
"mountId"
],
"referencedColumns": [
"id"
]
},
{
"table": "webdav_document",
"onDelete": "CASCADE",
"onUpdate": "NO ACTION",
"columns": [
"parentId"
],
"referencedColumns": [
"id"
]
}
]
},
{
"tableName": "webdav_mount",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL, `url` TEXT NOT NULL)",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "name",
"columnName": "name",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "url",
"columnName": "url",
"affinity": "TEXT",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": true,
"columnNames": [
"id"
]
},
"indices": [],
"foreignKeys": []
}
],
"views": [],
"setupQueries": [
"CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'ab1cb6057d8e050f6648bea46ae0943d')"
]
}
}

View File

@@ -1,675 +0,0 @@
{
"formatVersion": 1,
"database": {
"version": 16,
"identityHash": "2ff7560d957e03a78b4b7de88aa9593b",
"entities": [
{
"tableName": "service",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `accountName` TEXT NOT NULL, `type` TEXT NOT NULL, `principal` TEXT)",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "accountName",
"columnName": "accountName",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "type",
"columnName": "type",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "principal",
"columnName": "principal",
"affinity": "TEXT",
"notNull": false
}
],
"primaryKey": {
"autoGenerate": true,
"columnNames": [
"id"
]
},
"indices": [
{
"name": "index_service_accountName_type",
"unique": true,
"columnNames": [
"accountName",
"type"
],
"orders": [],
"createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_service_accountName_type` ON `${TABLE_NAME}` (`accountName`, `type`)"
}
],
"foreignKeys": []
},
{
"tableName": "homeset",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `serviceId` INTEGER NOT NULL, `personal` INTEGER NOT NULL, `url` TEXT NOT NULL, `privBind` INTEGER NOT NULL, `displayName` TEXT, FOREIGN KEY(`serviceId`) REFERENCES `service`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "serviceId",
"columnName": "serviceId",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "personal",
"columnName": "personal",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "url",
"columnName": "url",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "privBind",
"columnName": "privBind",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "displayName",
"columnName": "displayName",
"affinity": "TEXT",
"notNull": false
}
],
"primaryKey": {
"autoGenerate": true,
"columnNames": [
"id"
]
},
"indices": [
{
"name": "index_homeset_serviceId_url",
"unique": true,
"columnNames": [
"serviceId",
"url"
],
"orders": [],
"createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_homeset_serviceId_url` ON `${TABLE_NAME}` (`serviceId`, `url`)"
}
],
"foreignKeys": [
{
"table": "service",
"onDelete": "CASCADE",
"onUpdate": "NO ACTION",
"columns": [
"serviceId"
],
"referencedColumns": [
"id"
]
}
]
},
{
"tableName": "collection",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `serviceId` INTEGER NOT NULL, `homeSetId` INTEGER, `ownerId` INTEGER, `type` TEXT NOT NULL, `url` TEXT NOT NULL, `privWriteContent` INTEGER NOT NULL, `privUnbind` INTEGER NOT NULL, `forceReadOnly` INTEGER NOT NULL, `displayName` TEXT, `description` TEXT, `color` INTEGER, `timezoneId` TEXT, `supportsVEVENT` INTEGER, `supportsVTODO` INTEGER, `supportsVJOURNAL` INTEGER, `source` TEXT, `sync` INTEGER NOT NULL, `pushTopic` TEXT, `supportsWebPush` INTEGER NOT NULL DEFAULT 0, `pushSubscription` TEXT, `pushSubscriptionExpires` INTEGER, `pushSubscriptionCreated` INTEGER, FOREIGN KEY(`serviceId`) REFERENCES `service`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`homeSetId`) REFERENCES `homeset`(`id`) ON UPDATE NO ACTION ON DELETE SET NULL , FOREIGN KEY(`ownerId`) REFERENCES `principal`(`id`) ON UPDATE NO ACTION ON DELETE SET NULL )",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "serviceId",
"columnName": "serviceId",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "homeSetId",
"columnName": "homeSetId",
"affinity": "INTEGER",
"notNull": false
},
{
"fieldPath": "ownerId",
"columnName": "ownerId",
"affinity": "INTEGER",
"notNull": false
},
{
"fieldPath": "type",
"columnName": "type",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "url",
"columnName": "url",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "privWriteContent",
"columnName": "privWriteContent",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "privUnbind",
"columnName": "privUnbind",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "forceReadOnly",
"columnName": "forceReadOnly",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "displayName",
"columnName": "displayName",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "description",
"columnName": "description",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "color",
"columnName": "color",
"affinity": "INTEGER",
"notNull": false
},
{
"fieldPath": "timezoneId",
"columnName": "timezoneId",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "supportsVEVENT",
"columnName": "supportsVEVENT",
"affinity": "INTEGER",
"notNull": false
},
{
"fieldPath": "supportsVTODO",
"columnName": "supportsVTODO",
"affinity": "INTEGER",
"notNull": false
},
{
"fieldPath": "supportsVJOURNAL",
"columnName": "supportsVJOURNAL",
"affinity": "INTEGER",
"notNull": false
},
{
"fieldPath": "source",
"columnName": "source",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "sync",
"columnName": "sync",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "pushTopic",
"columnName": "pushTopic",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "supportsWebPush",
"columnName": "supportsWebPush",
"affinity": "INTEGER",
"notNull": true,
"defaultValue": "0"
},
{
"fieldPath": "pushSubscription",
"columnName": "pushSubscription",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "pushSubscriptionExpires",
"columnName": "pushSubscriptionExpires",
"affinity": "INTEGER",
"notNull": false
},
{
"fieldPath": "pushSubscriptionCreated",
"columnName": "pushSubscriptionCreated",
"affinity": "INTEGER",
"notNull": false
}
],
"primaryKey": {
"autoGenerate": true,
"columnNames": [
"id"
]
},
"indices": [
{
"name": "index_collection_serviceId_type",
"unique": false,
"columnNames": [
"serviceId",
"type"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_collection_serviceId_type` ON `${TABLE_NAME}` (`serviceId`, `type`)"
},
{
"name": "index_collection_homeSetId_type",
"unique": false,
"columnNames": [
"homeSetId",
"type"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_collection_homeSetId_type` ON `${TABLE_NAME}` (`homeSetId`, `type`)"
},
{
"name": "index_collection_ownerId_type",
"unique": false,
"columnNames": [
"ownerId",
"type"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_collection_ownerId_type` ON `${TABLE_NAME}` (`ownerId`, `type`)"
},
{
"name": "index_collection_pushTopic_type",
"unique": false,
"columnNames": [
"pushTopic",
"type"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_collection_pushTopic_type` ON `${TABLE_NAME}` (`pushTopic`, `type`)"
},
{
"name": "index_collection_url",
"unique": false,
"columnNames": [
"url"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_collection_url` ON `${TABLE_NAME}` (`url`)"
}
],
"foreignKeys": [
{
"table": "service",
"onDelete": "CASCADE",
"onUpdate": "NO ACTION",
"columns": [
"serviceId"
],
"referencedColumns": [
"id"
]
},
{
"table": "homeset",
"onDelete": "SET NULL",
"onUpdate": "NO ACTION",
"columns": [
"homeSetId"
],
"referencedColumns": [
"id"
]
},
{
"table": "principal",
"onDelete": "SET NULL",
"onUpdate": "NO ACTION",
"columns": [
"ownerId"
],
"referencedColumns": [
"id"
]
}
]
},
{
"tableName": "principal",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `serviceId` INTEGER NOT NULL, `url` TEXT NOT NULL, `displayName` TEXT, FOREIGN KEY(`serviceId`) REFERENCES `service`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "serviceId",
"columnName": "serviceId",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "url",
"columnName": "url",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "displayName",
"columnName": "displayName",
"affinity": "TEXT",
"notNull": false
}
],
"primaryKey": {
"autoGenerate": true,
"columnNames": [
"id"
]
},
"indices": [
{
"name": "index_principal_serviceId_url",
"unique": true,
"columnNames": [
"serviceId",
"url"
],
"orders": [],
"createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_principal_serviceId_url` ON `${TABLE_NAME}` (`serviceId`, `url`)"
}
],
"foreignKeys": [
{
"table": "service",
"onDelete": "CASCADE",
"onUpdate": "NO ACTION",
"columns": [
"serviceId"
],
"referencedColumns": [
"id"
]
}
]
},
{
"tableName": "syncstats",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `collectionId` INTEGER NOT NULL, `authority` TEXT NOT NULL, `lastSync` INTEGER NOT NULL, FOREIGN KEY(`collectionId`) REFERENCES `collection`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "collectionId",
"columnName": "collectionId",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "authority",
"columnName": "authority",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "lastSync",
"columnName": "lastSync",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": true,
"columnNames": [
"id"
]
},
"indices": [
{
"name": "index_syncstats_collectionId_authority",
"unique": true,
"columnNames": [
"collectionId",
"authority"
],
"orders": [],
"createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_syncstats_collectionId_authority` ON `${TABLE_NAME}` (`collectionId`, `authority`)"
}
],
"foreignKeys": [
{
"table": "collection",
"onDelete": "CASCADE",
"onUpdate": "NO ACTION",
"columns": [
"collectionId"
],
"referencedColumns": [
"id"
]
}
]
},
{
"tableName": "webdav_document",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `mountId` INTEGER NOT NULL, `parentId` INTEGER, `name` TEXT NOT NULL, `isDirectory` INTEGER NOT NULL, `displayName` TEXT, `mimeType` TEXT, `eTag` TEXT, `lastModified` INTEGER, `size` INTEGER, `mayBind` INTEGER, `mayUnbind` INTEGER, `mayWriteContent` INTEGER, `quotaAvailable` INTEGER, `quotaUsed` INTEGER, FOREIGN KEY(`mountId`) REFERENCES `webdav_mount`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`parentId`) REFERENCES `webdav_document`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "mountId",
"columnName": "mountId",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "parentId",
"columnName": "parentId",
"affinity": "INTEGER",
"notNull": false
},
{
"fieldPath": "name",
"columnName": "name",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "isDirectory",
"columnName": "isDirectory",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "displayName",
"columnName": "displayName",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "mimeType",
"columnName": "mimeType",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "eTag",
"columnName": "eTag",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "lastModified",
"columnName": "lastModified",
"affinity": "INTEGER",
"notNull": false
},
{
"fieldPath": "size",
"columnName": "size",
"affinity": "INTEGER",
"notNull": false
},
{
"fieldPath": "mayBind",
"columnName": "mayBind",
"affinity": "INTEGER",
"notNull": false
},
{
"fieldPath": "mayUnbind",
"columnName": "mayUnbind",
"affinity": "INTEGER",
"notNull": false
},
{
"fieldPath": "mayWriteContent",
"columnName": "mayWriteContent",
"affinity": "INTEGER",
"notNull": false
},
{
"fieldPath": "quotaAvailable",
"columnName": "quotaAvailable",
"affinity": "INTEGER",
"notNull": false
},
{
"fieldPath": "quotaUsed",
"columnName": "quotaUsed",
"affinity": "INTEGER",
"notNull": false
}
],
"primaryKey": {
"autoGenerate": true,
"columnNames": [
"id"
]
},
"indices": [
{
"name": "index_webdav_document_mountId_parentId_name",
"unique": true,
"columnNames": [
"mountId",
"parentId",
"name"
],
"orders": [],
"createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_webdav_document_mountId_parentId_name` ON `${TABLE_NAME}` (`mountId`, `parentId`, `name`)"
},
{
"name": "index_webdav_document_parentId",
"unique": false,
"columnNames": [
"parentId"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_webdav_document_parentId` ON `${TABLE_NAME}` (`parentId`)"
}
],
"foreignKeys": [
{
"table": "webdav_mount",
"onDelete": "CASCADE",
"onUpdate": "NO ACTION",
"columns": [
"mountId"
],
"referencedColumns": [
"id"
]
},
{
"table": "webdav_document",
"onDelete": "CASCADE",
"onUpdate": "NO ACTION",
"columns": [
"parentId"
],
"referencedColumns": [
"id"
]
}
]
},
{
"tableName": "webdav_mount",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL, `url` TEXT NOT NULL)",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "name",
"columnName": "name",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "url",
"columnName": "url",
"affinity": "TEXT",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": true,
"columnNames": [
"id"
]
},
"indices": [],
"foreignKeys": []
}
],
"views": [],
"setupQueries": [
"CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '2ff7560d957e03a78b4b7de88aa9593b')"
]
}
}

View File

@@ -1,648 +0,0 @@
{
"formatVersion": 1,
"database": {
"version": 17,
"identityHash": "cd15d368408570cc2e57252816869de2",
"entities": [
{
"tableName": "service",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `accountName` TEXT NOT NULL, `type` TEXT NOT NULL, `principal` TEXT)",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "accountName",
"columnName": "accountName",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "type",
"columnName": "type",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "principal",
"columnName": "principal",
"affinity": "TEXT"
}
],
"primaryKey": {
"autoGenerate": true,
"columnNames": [
"id"
]
},
"indices": [
{
"name": "index_service_accountName_type",
"unique": true,
"columnNames": [
"accountName",
"type"
],
"orders": [],
"createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_service_accountName_type` ON `${TABLE_NAME}` (`accountName`, `type`)"
}
]
},
{
"tableName": "homeset",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `serviceId` INTEGER NOT NULL, `personal` INTEGER NOT NULL, `url` TEXT NOT NULL, `privBind` INTEGER NOT NULL, `displayName` TEXT, FOREIGN KEY(`serviceId`) REFERENCES `service`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "serviceId",
"columnName": "serviceId",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "personal",
"columnName": "personal",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "url",
"columnName": "url",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "privBind",
"columnName": "privBind",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "displayName",
"columnName": "displayName",
"affinity": "TEXT"
}
],
"primaryKey": {
"autoGenerate": true,
"columnNames": [
"id"
]
},
"indices": [
{
"name": "index_homeset_serviceId_url",
"unique": true,
"columnNames": [
"serviceId",
"url"
],
"orders": [],
"createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_homeset_serviceId_url` ON `${TABLE_NAME}` (`serviceId`, `url`)"
}
],
"foreignKeys": [
{
"table": "service",
"onDelete": "CASCADE",
"onUpdate": "NO ACTION",
"columns": [
"serviceId"
],
"referencedColumns": [
"id"
]
}
]
},
{
"tableName": "collection",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `serviceId` INTEGER NOT NULL, `homeSetId` INTEGER, `ownerId` INTEGER, `type` TEXT NOT NULL, `url` TEXT NOT NULL, `privWriteContent` INTEGER NOT NULL, `privUnbind` INTEGER NOT NULL, `forceReadOnly` INTEGER NOT NULL, `displayName` TEXT, `description` TEXT, `color` INTEGER, `timezoneId` TEXT, `supportsVEVENT` INTEGER, `supportsVTODO` INTEGER, `supportsVJOURNAL` INTEGER, `source` TEXT, `sync` INTEGER NOT NULL, `pushTopic` TEXT, `supportsWebPush` INTEGER NOT NULL DEFAULT 0, `pushVapidKey` TEXT, `pushSubscription` TEXT, `pushSubscriptionExpires` INTEGER, `pushSubscriptionCreated` INTEGER, FOREIGN KEY(`serviceId`) REFERENCES `service`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`homeSetId`) REFERENCES `homeset`(`id`) ON UPDATE NO ACTION ON DELETE SET NULL , FOREIGN KEY(`ownerId`) REFERENCES `principal`(`id`) ON UPDATE NO ACTION ON DELETE SET NULL )",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "serviceId",
"columnName": "serviceId",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "homeSetId",
"columnName": "homeSetId",
"affinity": "INTEGER"
},
{
"fieldPath": "ownerId",
"columnName": "ownerId",
"affinity": "INTEGER"
},
{
"fieldPath": "type",
"columnName": "type",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "url",
"columnName": "url",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "privWriteContent",
"columnName": "privWriteContent",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "privUnbind",
"columnName": "privUnbind",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "forceReadOnly",
"columnName": "forceReadOnly",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "displayName",
"columnName": "displayName",
"affinity": "TEXT"
},
{
"fieldPath": "description",
"columnName": "description",
"affinity": "TEXT"
},
{
"fieldPath": "color",
"columnName": "color",
"affinity": "INTEGER"
},
{
"fieldPath": "timezoneId",
"columnName": "timezoneId",
"affinity": "TEXT"
},
{
"fieldPath": "supportsVEVENT",
"columnName": "supportsVEVENT",
"affinity": "INTEGER"
},
{
"fieldPath": "supportsVTODO",
"columnName": "supportsVTODO",
"affinity": "INTEGER"
},
{
"fieldPath": "supportsVJOURNAL",
"columnName": "supportsVJOURNAL",
"affinity": "INTEGER"
},
{
"fieldPath": "source",
"columnName": "source",
"affinity": "TEXT"
},
{
"fieldPath": "sync",
"columnName": "sync",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "pushTopic",
"columnName": "pushTopic",
"affinity": "TEXT"
},
{
"fieldPath": "supportsWebPush",
"columnName": "supportsWebPush",
"affinity": "INTEGER",
"notNull": true,
"defaultValue": "0"
},
{
"fieldPath": "pushVapidKey",
"columnName": "pushVapidKey",
"affinity": "TEXT"
},
{
"fieldPath": "pushSubscription",
"columnName": "pushSubscription",
"affinity": "TEXT"
},
{
"fieldPath": "pushSubscriptionExpires",
"columnName": "pushSubscriptionExpires",
"affinity": "INTEGER"
},
{
"fieldPath": "pushSubscriptionCreated",
"columnName": "pushSubscriptionCreated",
"affinity": "INTEGER"
}
],
"primaryKey": {
"autoGenerate": true,
"columnNames": [
"id"
]
},
"indices": [
{
"name": "index_collection_serviceId_type",
"unique": false,
"columnNames": [
"serviceId",
"type"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_collection_serviceId_type` ON `${TABLE_NAME}` (`serviceId`, `type`)"
},
{
"name": "index_collection_homeSetId_type",
"unique": false,
"columnNames": [
"homeSetId",
"type"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_collection_homeSetId_type` ON `${TABLE_NAME}` (`homeSetId`, `type`)"
},
{
"name": "index_collection_ownerId_type",
"unique": false,
"columnNames": [
"ownerId",
"type"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_collection_ownerId_type` ON `${TABLE_NAME}` (`ownerId`, `type`)"
},
{
"name": "index_collection_pushTopic_type",
"unique": false,
"columnNames": [
"pushTopic",
"type"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_collection_pushTopic_type` ON `${TABLE_NAME}` (`pushTopic`, `type`)"
},
{
"name": "index_collection_url",
"unique": false,
"columnNames": [
"url"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_collection_url` ON `${TABLE_NAME}` (`url`)"
}
],
"foreignKeys": [
{
"table": "service",
"onDelete": "CASCADE",
"onUpdate": "NO ACTION",
"columns": [
"serviceId"
],
"referencedColumns": [
"id"
]
},
{
"table": "homeset",
"onDelete": "SET NULL",
"onUpdate": "NO ACTION",
"columns": [
"homeSetId"
],
"referencedColumns": [
"id"
]
},
{
"table": "principal",
"onDelete": "SET NULL",
"onUpdate": "NO ACTION",
"columns": [
"ownerId"
],
"referencedColumns": [
"id"
]
}
]
},
{
"tableName": "principal",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `serviceId` INTEGER NOT NULL, `url` TEXT NOT NULL, `displayName` TEXT, FOREIGN KEY(`serviceId`) REFERENCES `service`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "serviceId",
"columnName": "serviceId",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "url",
"columnName": "url",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "displayName",
"columnName": "displayName",
"affinity": "TEXT"
}
],
"primaryKey": {
"autoGenerate": true,
"columnNames": [
"id"
]
},
"indices": [
{
"name": "index_principal_serviceId_url",
"unique": true,
"columnNames": [
"serviceId",
"url"
],
"orders": [],
"createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_principal_serviceId_url` ON `${TABLE_NAME}` (`serviceId`, `url`)"
}
],
"foreignKeys": [
{
"table": "service",
"onDelete": "CASCADE",
"onUpdate": "NO ACTION",
"columns": [
"serviceId"
],
"referencedColumns": [
"id"
]
}
]
},
{
"tableName": "syncstats",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `collectionId` INTEGER NOT NULL, `authority` TEXT NOT NULL, `lastSync` INTEGER NOT NULL, FOREIGN KEY(`collectionId`) REFERENCES `collection`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "collectionId",
"columnName": "collectionId",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "authority",
"columnName": "authority",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "lastSync",
"columnName": "lastSync",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": true,
"columnNames": [
"id"
]
},
"indices": [
{
"name": "index_syncstats_collectionId_authority",
"unique": true,
"columnNames": [
"collectionId",
"authority"
],
"orders": [],
"createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_syncstats_collectionId_authority` ON `${TABLE_NAME}` (`collectionId`, `authority`)"
}
],
"foreignKeys": [
{
"table": "collection",
"onDelete": "CASCADE",
"onUpdate": "NO ACTION",
"columns": [
"collectionId"
],
"referencedColumns": [
"id"
]
}
]
},
{
"tableName": "webdav_document",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `mountId` INTEGER NOT NULL, `parentId` INTEGER, `name` TEXT NOT NULL, `isDirectory` INTEGER NOT NULL, `displayName` TEXT, `mimeType` TEXT, `eTag` TEXT, `lastModified` INTEGER, `size` INTEGER, `mayBind` INTEGER, `mayUnbind` INTEGER, `mayWriteContent` INTEGER, `quotaAvailable` INTEGER, `quotaUsed` INTEGER, FOREIGN KEY(`mountId`) REFERENCES `webdav_mount`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`parentId`) REFERENCES `webdav_document`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "mountId",
"columnName": "mountId",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "parentId",
"columnName": "parentId",
"affinity": "INTEGER"
},
{
"fieldPath": "name",
"columnName": "name",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "isDirectory",
"columnName": "isDirectory",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "displayName",
"columnName": "displayName",
"affinity": "TEXT"
},
{
"fieldPath": "mimeType",
"columnName": "mimeType",
"affinity": "TEXT"
},
{
"fieldPath": "eTag",
"columnName": "eTag",
"affinity": "TEXT"
},
{
"fieldPath": "lastModified",
"columnName": "lastModified",
"affinity": "INTEGER"
},
{
"fieldPath": "size",
"columnName": "size",
"affinity": "INTEGER"
},
{
"fieldPath": "mayBind",
"columnName": "mayBind",
"affinity": "INTEGER"
},
{
"fieldPath": "mayUnbind",
"columnName": "mayUnbind",
"affinity": "INTEGER"
},
{
"fieldPath": "mayWriteContent",
"columnName": "mayWriteContent",
"affinity": "INTEGER"
},
{
"fieldPath": "quotaAvailable",
"columnName": "quotaAvailable",
"affinity": "INTEGER"
},
{
"fieldPath": "quotaUsed",
"columnName": "quotaUsed",
"affinity": "INTEGER"
}
],
"primaryKey": {
"autoGenerate": true,
"columnNames": [
"id"
]
},
"indices": [
{
"name": "index_webdav_document_mountId_parentId_name",
"unique": true,
"columnNames": [
"mountId",
"parentId",
"name"
],
"orders": [],
"createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_webdav_document_mountId_parentId_name` ON `${TABLE_NAME}` (`mountId`, `parentId`, `name`)"
},
{
"name": "index_webdav_document_parentId",
"unique": false,
"columnNames": [
"parentId"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_webdav_document_parentId` ON `${TABLE_NAME}` (`parentId`)"
}
],
"foreignKeys": [
{
"table": "webdav_mount",
"onDelete": "CASCADE",
"onUpdate": "NO ACTION",
"columns": [
"mountId"
],
"referencedColumns": [
"id"
]
},
{
"table": "webdav_document",
"onDelete": "CASCADE",
"onUpdate": "NO ACTION",
"columns": [
"parentId"
],
"referencedColumns": [
"id"
]
}
]
},
{
"tableName": "webdav_mount",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL, `url` TEXT NOT NULL)",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "name",
"columnName": "name",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "url",
"columnName": "url",
"affinity": "TEXT",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": true,
"columnNames": [
"id"
]
}
}
],
"setupQueries": [
"CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'cd15d368408570cc2e57252816869de2')"
]
}
}

View File

@@ -1,648 +0,0 @@
{
"formatVersion": 1,
"database": {
"version": 18,
"identityHash": "6a0f7e1553e1f621ae7913ea14370fd0",
"entities": [
{
"tableName": "service",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `accountName` TEXT NOT NULL, `type` TEXT NOT NULL, `principal` TEXT)",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "accountName",
"columnName": "accountName",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "type",
"columnName": "type",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "principal",
"columnName": "principal",
"affinity": "TEXT"
}
],
"primaryKey": {
"autoGenerate": true,
"columnNames": [
"id"
]
},
"indices": [
{
"name": "index_service_accountName_type",
"unique": true,
"columnNames": [
"accountName",
"type"
],
"orders": [],
"createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_service_accountName_type` ON `${TABLE_NAME}` (`accountName`, `type`)"
}
]
},
{
"tableName": "homeset",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `serviceId` INTEGER NOT NULL, `personal` INTEGER NOT NULL, `url` TEXT NOT NULL, `privBind` INTEGER NOT NULL, `displayName` TEXT, FOREIGN KEY(`serviceId`) REFERENCES `service`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "serviceId",
"columnName": "serviceId",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "personal",
"columnName": "personal",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "url",
"columnName": "url",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "privBind",
"columnName": "privBind",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "displayName",
"columnName": "displayName",
"affinity": "TEXT"
}
],
"primaryKey": {
"autoGenerate": true,
"columnNames": [
"id"
]
},
"indices": [
{
"name": "index_homeset_serviceId_url",
"unique": true,
"columnNames": [
"serviceId",
"url"
],
"orders": [],
"createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_homeset_serviceId_url` ON `${TABLE_NAME}` (`serviceId`, `url`)"
}
],
"foreignKeys": [
{
"table": "service",
"onDelete": "CASCADE",
"onUpdate": "NO ACTION",
"columns": [
"serviceId"
],
"referencedColumns": [
"id"
]
}
]
},
{
"tableName": "collection",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `serviceId` INTEGER NOT NULL, `homeSetId` INTEGER, `ownerId` INTEGER, `type` TEXT NOT NULL, `url` TEXT NOT NULL, `privWriteContent` INTEGER NOT NULL, `privUnbind` INTEGER NOT NULL, `forceReadOnly` INTEGER NOT NULL, `displayName` TEXT, `description` TEXT, `color` INTEGER, `timezoneId` TEXT, `supportsVEVENT` INTEGER, `supportsVTODO` INTEGER, `supportsVJOURNAL` INTEGER, `source` TEXT, `sync` INTEGER NOT NULL, `pushTopic` TEXT, `supportsWebPush` INTEGER NOT NULL DEFAULT 0, `pushVapidKey` TEXT, `pushSubscription` TEXT, `pushSubscriptionExpires` INTEGER, `pushSubscriptionCreated` INTEGER, FOREIGN KEY(`serviceId`) REFERENCES `service`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`homeSetId`) REFERENCES `homeset`(`id`) ON UPDATE NO ACTION ON DELETE SET NULL , FOREIGN KEY(`ownerId`) REFERENCES `principal`(`id`) ON UPDATE NO ACTION ON DELETE SET NULL )",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "serviceId",
"columnName": "serviceId",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "homeSetId",
"columnName": "homeSetId",
"affinity": "INTEGER"
},
{
"fieldPath": "ownerId",
"columnName": "ownerId",
"affinity": "INTEGER"
},
{
"fieldPath": "type",
"columnName": "type",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "url",
"columnName": "url",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "privWriteContent",
"columnName": "privWriteContent",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "privUnbind",
"columnName": "privUnbind",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "forceReadOnly",
"columnName": "forceReadOnly",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "displayName",
"columnName": "displayName",
"affinity": "TEXT"
},
{
"fieldPath": "description",
"columnName": "description",
"affinity": "TEXT"
},
{
"fieldPath": "color",
"columnName": "color",
"affinity": "INTEGER"
},
{
"fieldPath": "timezoneId",
"columnName": "timezoneId",
"affinity": "TEXT"
},
{
"fieldPath": "supportsVEVENT",
"columnName": "supportsVEVENT",
"affinity": "INTEGER"
},
{
"fieldPath": "supportsVTODO",
"columnName": "supportsVTODO",
"affinity": "INTEGER"
},
{
"fieldPath": "supportsVJOURNAL",
"columnName": "supportsVJOURNAL",
"affinity": "INTEGER"
},
{
"fieldPath": "source",
"columnName": "source",
"affinity": "TEXT"
},
{
"fieldPath": "sync",
"columnName": "sync",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "pushTopic",
"columnName": "pushTopic",
"affinity": "TEXT"
},
{
"fieldPath": "supportsWebPush",
"columnName": "supportsWebPush",
"affinity": "INTEGER",
"notNull": true,
"defaultValue": "0"
},
{
"fieldPath": "pushVapidKey",
"columnName": "pushVapidKey",
"affinity": "TEXT"
},
{
"fieldPath": "pushSubscription",
"columnName": "pushSubscription",
"affinity": "TEXT"
},
{
"fieldPath": "pushSubscriptionExpires",
"columnName": "pushSubscriptionExpires",
"affinity": "INTEGER"
},
{
"fieldPath": "pushSubscriptionCreated",
"columnName": "pushSubscriptionCreated",
"affinity": "INTEGER"
}
],
"primaryKey": {
"autoGenerate": true,
"columnNames": [
"id"
]
},
"indices": [
{
"name": "index_collection_serviceId_type",
"unique": false,
"columnNames": [
"serviceId",
"type"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_collection_serviceId_type` ON `${TABLE_NAME}` (`serviceId`, `type`)"
},
{
"name": "index_collection_homeSetId_type",
"unique": false,
"columnNames": [
"homeSetId",
"type"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_collection_homeSetId_type` ON `${TABLE_NAME}` (`homeSetId`, `type`)"
},
{
"name": "index_collection_ownerId_type",
"unique": false,
"columnNames": [
"ownerId",
"type"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_collection_ownerId_type` ON `${TABLE_NAME}` (`ownerId`, `type`)"
},
{
"name": "index_collection_pushTopic_type",
"unique": false,
"columnNames": [
"pushTopic",
"type"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_collection_pushTopic_type` ON `${TABLE_NAME}` (`pushTopic`, `type`)"
},
{
"name": "index_collection_url",
"unique": false,
"columnNames": [
"url"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_collection_url` ON `${TABLE_NAME}` (`url`)"
}
],
"foreignKeys": [
{
"table": "service",
"onDelete": "CASCADE",
"onUpdate": "NO ACTION",
"columns": [
"serviceId"
],
"referencedColumns": [
"id"
]
},
{
"table": "homeset",
"onDelete": "SET NULL",
"onUpdate": "NO ACTION",
"columns": [
"homeSetId"
],
"referencedColumns": [
"id"
]
},
{
"table": "principal",
"onDelete": "SET NULL",
"onUpdate": "NO ACTION",
"columns": [
"ownerId"
],
"referencedColumns": [
"id"
]
}
]
},
{
"tableName": "principal",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `serviceId` INTEGER NOT NULL, `url` TEXT NOT NULL, `displayName` TEXT, FOREIGN KEY(`serviceId`) REFERENCES `service`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "serviceId",
"columnName": "serviceId",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "url",
"columnName": "url",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "displayName",
"columnName": "displayName",
"affinity": "TEXT"
}
],
"primaryKey": {
"autoGenerate": true,
"columnNames": [
"id"
]
},
"indices": [
{
"name": "index_principal_serviceId_url",
"unique": true,
"columnNames": [
"serviceId",
"url"
],
"orders": [],
"createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_principal_serviceId_url` ON `${TABLE_NAME}` (`serviceId`, `url`)"
}
],
"foreignKeys": [
{
"table": "service",
"onDelete": "CASCADE",
"onUpdate": "NO ACTION",
"columns": [
"serviceId"
],
"referencedColumns": [
"id"
]
}
]
},
{
"tableName": "syncstats",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `collectionId` INTEGER NOT NULL, `dataType` TEXT NOT NULL, `lastSync` INTEGER NOT NULL, FOREIGN KEY(`collectionId`) REFERENCES `collection`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "collectionId",
"columnName": "collectionId",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "dataType",
"columnName": "dataType",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "lastSync",
"columnName": "lastSync",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": true,
"columnNames": [
"id"
]
},
"indices": [
{
"name": "index_syncstats_collectionId_dataType",
"unique": true,
"columnNames": [
"collectionId",
"dataType"
],
"orders": [],
"createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_syncstats_collectionId_dataType` ON `${TABLE_NAME}` (`collectionId`, `dataType`)"
}
],
"foreignKeys": [
{
"table": "collection",
"onDelete": "CASCADE",
"onUpdate": "NO ACTION",
"columns": [
"collectionId"
],
"referencedColumns": [
"id"
]
}
]
},
{
"tableName": "webdav_document",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `mountId` INTEGER NOT NULL, `parentId` INTEGER, `name` TEXT NOT NULL, `isDirectory` INTEGER NOT NULL, `displayName` TEXT, `mimeType` TEXT, `eTag` TEXT, `lastModified` INTEGER, `size` INTEGER, `mayBind` INTEGER, `mayUnbind` INTEGER, `mayWriteContent` INTEGER, `quotaAvailable` INTEGER, `quotaUsed` INTEGER, FOREIGN KEY(`mountId`) REFERENCES `webdav_mount`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`parentId`) REFERENCES `webdav_document`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "mountId",
"columnName": "mountId",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "parentId",
"columnName": "parentId",
"affinity": "INTEGER"
},
{
"fieldPath": "name",
"columnName": "name",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "isDirectory",
"columnName": "isDirectory",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "displayName",
"columnName": "displayName",
"affinity": "TEXT"
},
{
"fieldPath": "mimeType",
"columnName": "mimeType",
"affinity": "TEXT"
},
{
"fieldPath": "eTag",
"columnName": "eTag",
"affinity": "TEXT"
},
{
"fieldPath": "lastModified",
"columnName": "lastModified",
"affinity": "INTEGER"
},
{
"fieldPath": "size",
"columnName": "size",
"affinity": "INTEGER"
},
{
"fieldPath": "mayBind",
"columnName": "mayBind",
"affinity": "INTEGER"
},
{
"fieldPath": "mayUnbind",
"columnName": "mayUnbind",
"affinity": "INTEGER"
},
{
"fieldPath": "mayWriteContent",
"columnName": "mayWriteContent",
"affinity": "INTEGER"
},
{
"fieldPath": "quotaAvailable",
"columnName": "quotaAvailable",
"affinity": "INTEGER"
},
{
"fieldPath": "quotaUsed",
"columnName": "quotaUsed",
"affinity": "INTEGER"
}
],
"primaryKey": {
"autoGenerate": true,
"columnNames": [
"id"
]
},
"indices": [
{
"name": "index_webdav_document_mountId_parentId_name",
"unique": true,
"columnNames": [
"mountId",
"parentId",
"name"
],
"orders": [],
"createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_webdav_document_mountId_parentId_name` ON `${TABLE_NAME}` (`mountId`, `parentId`, `name`)"
},
{
"name": "index_webdav_document_parentId",
"unique": false,
"columnNames": [
"parentId"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_webdav_document_parentId` ON `${TABLE_NAME}` (`parentId`)"
}
],
"foreignKeys": [
{
"table": "webdav_mount",
"onDelete": "CASCADE",
"onUpdate": "NO ACTION",
"columns": [
"mountId"
],
"referencedColumns": [
"id"
]
},
{
"table": "webdav_document",
"onDelete": "CASCADE",
"onUpdate": "NO ACTION",
"columns": [
"parentId"
],
"referencedColumns": [
"id"
]
}
]
},
{
"tableName": "webdav_mount",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL, `url` TEXT NOT NULL)",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "name",
"columnName": "name",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "url",
"columnName": "url",
"affinity": "TEXT",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": true,
"columnNames": [
"id"
]
}
}
],
"setupQueries": [
"CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '6a0f7e1553e1f621ae7913ea14370fd0')"
]
}
}

View File

@@ -7,4 +7,21 @@
<uses-permission android:name="android.permission.GET_ACCOUNTS" android:maxSdkVersion="22"/>
<uses-permission android:name="android.permission.MANAGE_ACCOUNTS" android:maxSdkVersion="22"/>
<application>
<!-- test account type (without associated sync adapters) -->
<service
android:name="at.bitfire.davdroid.sync.account.TestAccountAuthenticator"
android:exported="false">
<intent-filter>
<action android:name="android.accounts.AccountAuthenticator"/>
</intent-filter>
<meta-data
android:name="android.accounts.AccountAuthenticator"
android:resource="@xml/test_account_authenticator"/>
</service>
</application>
</manifest>

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

@@ -1,30 +0,0 @@
/*
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
*/
package at.bitfire.davdroid
import android.util.Xml
import at.bitfire.dav4jvm.XmlUtils
import okhttp3.HttpUrl.Companion.toHttpUrl
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
class ExternalLibrariesTest {
@Test
fun test_Dav4jvm_XmlUtils_NewPullParser_RelaxedParsing() {
val parser = XmlUtils.newPullParser()
assertTrue(parser.getFeature(Xml.FEATURE_RELAXED))
}
@Test
fun testOkhttpHttpUrl_PublicSuffixList() {
// HttpUrl.topPrivateDomain() requires okhttp's internal PublicSuffixList.
// In Android, loading the PublicSuffixList is done over AndroidX startup.
// This test verifies that everything is working.
assertEquals("example.com", "http://example.com".toHttpUrl().topPrivateDomain())
}
}

View File

@@ -6,37 +6,12 @@ package at.bitfire.davdroid
import android.app.Application
import android.content.Context
import android.os.Build
import android.os.Bundle
import androidx.test.runner.AndroidJUnitRunner
import at.bitfire.davdroid.di.TestCoroutineDispatchersModule
import at.bitfire.davdroid.test.BuildConfig
import at.bitfire.synctools.log.LogcatHandler
import dagger.hilt.android.testing.HiltTestApplication
import java.util.logging.Level
import java.util.logging.Logger
@Suppress("unused")
class HiltTestRunner : AndroidJUnitRunner() {
override fun newApplication(cl: ClassLoader, name: String, context: Context): Application =
super.newApplication(cl, HiltTestApplication::class.java.name, context)
override fun onCreate(arguments: Bundle?) {
super.onCreate(arguments)
// set root logger to adb Logcat
val rootLogger = Logger.getLogger("")
rootLogger.level = Level.ALL
rootLogger.handlers.forEach { rootLogger.removeHandler(it) }
rootLogger.addHandler(LogcatHandler(BuildConfig.APPLICATION_ID))
// MockK requirements
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.P)
throw AssertionError("MockK requires Android P [https://mockk.io/ANDROID.html]")
// set main dispatcher for tests (especially runTest)
TestCoroutineDispatchersModule.initMainDispatcher()
}
}

View File

@@ -0,0 +1,123 @@
/*
* 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.ContentProviderClient
import android.content.ContentUris
import android.content.ContentValues
import android.os.Build
import android.provider.CalendarContract
import androidx.test.platform.app.InstrumentationRegistry
import androidx.test.rule.GrantPermissionRule
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.Assert.assertNotNull
import org.junit.rules.ExternalResource
import org.junit.rules.RuleChain
import java.util.logging.Logger
/**
* JUnit ClassRule which initializes the AOSP CalendarProvider.
*
* It seems that the calendar provider unfortunately forgets the very first requests when it is used the very first time,
* maybe by some wrongly synchronized database initialization. So things like querying the instances
* fails in this case.
*
* So this rule is needed to allow tests which need the calendar provider to succeed even when the calendar provider
* is used the very first time (especially in CI tests / a fresh emulator).
*
* See [at.bitfire.davdroid.resource.LocalCalendarTest] for an example of how to use this rule.
*/
class InitCalendarProviderRule private constructor(): ExternalResource() {
companion object {
private var isInitialized = false
private val logger = Logger.getLogger(InitCalendarProviderRule::javaClass.name)
fun getInstance(): RuleChain = RuleChain
.outerRule(GrantPermissionRule.grant(Manifest.permission.READ_CALENDAR, Manifest.permission.WRITE_CALENDAR))
.around(InitCalendarProviderRule())
}
override fun before() {
if (!isInitialized) {
logger.info("Initializing calendar provider")
if (Build.VERSION.SDK_INT < 31)
logger.warning("Calendar provider initialization may or may not work. See InitCalendarProviderRule")
val context = InstrumentationRegistry.getInstrumentation().targetContext
val client = context.contentResolver.acquireContentProviderClient(CalendarContract.AUTHORITY)
assertNotNull("Couldn't acquire calendar provider", client)
client!!.use {
initCalendarProvider(client)
isInitialized = true
}
}
}
private fun initCalendarProvider(provider: ContentProviderClient) {
val account = Account("LocalCalendarTest", CalendarContract.ACCOUNT_TYPE_LOCAL)
// Sometimes, the calendar provider returns an ID for the created calendar, but then fails to find it.
var calendarOrNull: LocalCalendar? = null
for (i in 0..50) {
calendarOrNull = createAndVerifyCalendar(account, provider)
if (calendarOrNull != null)
break
else
Thread.sleep(100)
}
val calendar = calendarOrNull ?: throw IllegalStateException("Couldn't create calendar")
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()
}
}
private fun createAndVerifyCalendar(account: Account, provider: ContentProviderClient): LocalCalendar? {
val uri = AndroidCalendar.create(account, provider, ContentValues())
return try {
AndroidCalendar.findByID(
account,
provider,
LocalCalendar.Factory,
ContentUris.parseId(uri)
)
} catch (e: Exception) {
logger.warning("Couldn't find calendar after creation: $e")
null
}
}
}

View File

@@ -2,9 +2,12 @@
* 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 android.content.Context
import at.bitfire.davdroid.network.HttpClient
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 okhttp3.Request
@@ -16,12 +19,16 @@ import javax.inject.Inject
@HiltAndroidTest
class OkhttpClientTest {
@Inject
lateinit var httpClientBuilder: HttpClient.Builder
@get:Rule
val hiltRule = HiltAndroidRule(this)
@Inject
@ApplicationContext
lateinit var context: Context
@Inject
lateinit var settingsManager: SettingsManager
@Before
fun inject() {
hiltRule.inject()
@@ -29,18 +36,13 @@ class OkhttpClientTest {
@Test
@SdkSuppress(maxSdkVersion = 34)
fun testIcloudWithSettings() {
httpClientBuilder.build().use { client ->
client.okHttpClient
.newCall(
Request.Builder()
.get()
.url("https://icloud.com")
.build()
)
val client = HttpClient.Builder(context).build()
client.okHttpClient.newCall(Request.Builder()
.get()
.url("https://icloud.com")
.build())
.execute()
}
}
}

View File

@@ -0,0 +1,38 @@
package at.bitfire.davdroid
import at.bitfire.davdroid.push.PushRegistrationWorker
import at.bitfire.davdroid.repository.DavCollectionRepository
import at.bitfire.davdroid.startup.StartupPlugin
import at.bitfire.davdroid.startup.TasksAppWatcher
import dagger.Module
import dagger.hilt.components.SingletonComponent
import dagger.hilt.testing.TestInstallIn
import dagger.multibindings.Multibinds
interface TestModules {
// remove PushRegistrationWorkerModule from Android tests
@Module
@TestInstallIn(
components = [SingletonComponent::class],
replaces = [PushRegistrationWorker.PushRegistrationWorkerModule::class]
)
abstract class TestPushRegistrationWorkerModule {
// provides empty set of listeners
@Multibinds
abstract fun empty(): Set<DavCollectionRepository.OnChangeListener>
}
// remove TasksAppWatcherModule from Android tests
@Module
@TestInstallIn(
components = [SingletonComponent::class],
replaces = [TasksAppWatcher.TasksAppWatcherModule::class]
)
abstract class TestTasksAppWatcherModuleModule {
// provides empty set of plugins
@Multibinds
abstract fun empty(): Set<StartupPlugin>
}
}

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

@@ -6,74 +6,45 @@ package at.bitfire.davdroid.db
import android.content.Context
import androidx.room.Room
import androidx.room.migration.AutoMigrationSpec
import androidx.room.migration.Migration
import androidx.room.testing.MigrationTestHelper
import androidx.sqlite.db.framework.FrameworkSQLiteOpenHelperFactory
import androidx.test.platform.app.InstrumentationRegistry
import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.android.testing.HiltAndroidRule
import dagger.hilt.android.testing.HiltAndroidTest
import org.junit.Before
import org.junit.Rule
import org.junit.Test
import java.util.logging.Logger
import javax.inject.Inject
@HiltAndroidTest
class AppDatabaseTest {
@get:Rule
val hiltRule = HiltAndroidRule(this)
val TEST_DB = "test"
@Inject @ApplicationContext
lateinit var context: Context
val context: Context = InstrumentationRegistry.getInstrumentation().targetContext
@Inject
lateinit var autoMigrations: Set<@JvmSuppressWildcards AutoMigrationSpec>
@Inject
lateinit var logger: Logger
@Inject
lateinit var manualMigrations: Set<@JvmSuppressWildcards Migration>
@Before
fun setup() {
hiltRule.inject()
}
@Rule
@JvmField
val helper = MigrationTestHelper(
InstrumentationRegistry.getInstrumentation(),
AppDatabase::class.java,
listOf(), // no auto migrations until v8
FrameworkSQLiteOpenHelperFactory()
)
/**
* Creates a database with schema version 8 (the first exported one) and then migrates it to the latest version.
*/
@Test
fun testAllMigrations() {
// Create DB with v8
MigrationTestHelper(
InstrumentationRegistry.getInstrumentation(),
AppDatabase::class.java,
listOf(), // no auto migrations until v8
FrameworkSQLiteOpenHelperFactory()
).createDatabase(TEST_DB, 8).close()
// DB schema is available since version 8, so create DB with v8
helper.createDatabase(TEST_DB, 8).close()
// open and migrate (to current version) database
Room.databaseBuilder(context, AppDatabase::class.java, TEST_DB)
val db = Room.databaseBuilder(context, AppDatabase::class.java, TEST_DB)
// manual migrations
.addMigrations(*manualMigrations.toTypedArray())
.addMigrations(*AppDatabase.migrations)
// auto-migrations that need to be specified explicitly
.apply {
for (spec in autoMigrations)
addAutoMigrationSpec(spec)
}
.addAutoMigrationSpec(AppDatabase.AutoMigration11_12(context))
.build()
.openHelper.writableDatabase // this will run all migrations
.close()
}
companion object {
const val TEST_DB = "test"
try {
// open (with version 8) + migrate (to current version) database
db.openHelper.writableDatabase
} finally {
db.close()
}
}
}

View File

@@ -4,11 +4,14 @@
package at.bitfire.davdroid.db
import android.content.Context
import android.security.NetworkSecurityPolicy
import androidx.test.filters.SmallTest
import at.bitfire.dav4jvm.DavResource
import at.bitfire.dav4jvm.property.webdav.ResourceType
import at.bitfire.davdroid.network.HttpClient
import at.bitfire.davdroid.settings.SettingsManager
import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.android.testing.HiltAndroidRule
import dagger.hilt.android.testing.HiltAndroidTest
import okhttp3.HttpUrl.Companion.toHttpUrl
@@ -28,12 +31,16 @@ import javax.inject.Inject
@HiltAndroidTest
class CollectionTest {
@Inject
lateinit var httpClientBuilder: HttpClient.Builder
@get:Rule
val hiltRule = HiltAndroidRule(this)
@Inject
@ApplicationContext
lateinit var context: Context
@Inject
lateinit var settingsManager: SettingsManager
private lateinit var httpClient: HttpClient
private val server = MockWebServer()
@@ -41,7 +48,7 @@ class CollectionTest {
fun setup() {
hiltRule.inject()
httpClient = httpClientBuilder.build()
httpClient = HttpClient.Builder(context).build()
Assume.assumeTrue(NetworkSecurityPolicy.getInstance().isCleartextTrafficPermitted)
}
@@ -85,93 +92,35 @@ class CollectionTest {
@Test
@SmallTest
fun testFromDavResponseCalendar_FullTimezone() {
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>BEGIN:VCALENDAR\n" +
"PRODID:-//Example Corp.//CalDAV Client//EN\n" +
"VERSION:2.0\n" +
"BEGIN:VTIMEZONE\n" +
"TZID:US-Eastern\n" +
"LAST-MODIFIED:19870101T000000Z\n" +
"BEGIN:STANDARD\n" +
"DTSTART:19671029T020000\n" +
"RRULE:FREQ=YEARLY;BYDAY=-1SU;BYMONTH=10\n" +
"TZOFFSETFROM:-0400\n" +
"TZOFFSETTO:-0500\n" +
"TZNAME:Eastern Standard Time (US & Canada)\n" +
"END:STANDARD\n" +
"BEGIN:DAYLIGHT\n" +
"DTSTART:19870405T020000\n" +
"RRULE:FREQ=YEARLY;BYDAY=1SU;BYMONTH=4\n" +
"TZOFFSETFROM:-0500\n" +
"TZOFFSETTO:-0400\n" +
"TZNAME:Eastern Daylight Time (US & Canada)\n" +
"END:DAYLIGHT\n" +
"END:VTIMEZONE\n" +
"END:VCALENDAR\n" +
"</CAL:calendar-timezone>" +
" <ICAL:calendar-color>#ff0000</ICAL:calendar-color>" +
" </prop></propstat>" +
"</response>" +
"</multistatus>"))
.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)!!
}
.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("US-Eastern", info.timezoneId)
assertTrue(info.supportsVEVENT!!)
assertTrue(info.supportsVTODO!!)
assertTrue(info.supportsVJOURNAL!!)
}
@Test
@SmallTest
fun testFromDavResponseCalendar_OnlyTzId() {
// 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-id>US-Eastern</CAL:calendar-timezone-id>" +
" <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)!!
}
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("US-Eastern", info.timezoneId)
assertEquals("tzdata", info.timezone)
assertTrue(info.supportsVEVENT!!)
assertTrue(info.supportsVTODO!!)
assertTrue(info.supportsVJOURNAL!!)
@@ -204,4 +153,4 @@ class CollectionTest {
assertEquals("https://example.com/1.ics".toHttpUrl(), info.source)
}
}
}

View File

@@ -1,97 +0,0 @@
/*
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
*/
package at.bitfire.davdroid.db
import dagger.hilt.android.testing.HiltAndroidRule
import dagger.hilt.android.testing.HiltAndroidTest
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
import okhttp3.HttpUrl.Companion.toHttpUrl
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Before
import org.junit.Rule
import org.junit.Test
import javax.inject.Inject
@HiltAndroidTest
class HomeSetDaoTest {
@get:Rule
var hiltRule = HiltAndroidRule(this)
@Inject
lateinit var db: AppDatabase
lateinit var dao: HomeSetDao
var serviceId: Long = 0
@Before
fun setUp() {
hiltRule.inject()
dao = db.homeSetDao()
serviceId = db.serviceDao().insertOrReplace(
Service(id=0, accountName="test", type= Service.TYPE_CALDAV, principal = null)
)
}
@After
fun tearDown() {
db.serviceDao().deleteAll()
}
@Test
fun testInsertOrUpdate() {
// should insert new row or update (upsert) existing row - without changing its key!
val entry1 = HomeSet(id=0, serviceId=serviceId, personal=true, url="https://example.com/1".toHttpUrl())
val insertId1 = dao.insertOrUpdateByUrlBlocking(entry1)
assertEquals(1L, insertId1)
assertEquals(entry1.copy(id = 1L), dao.getById(1))
val updatedEntry1 = HomeSet(id=0, serviceId=serviceId, personal=true, url="https://example.com/1".toHttpUrl(), displayName="Updated Entry")
val updateId1 = dao.insertOrUpdateByUrlBlocking(updatedEntry1)
assertEquals(1L, updateId1)
assertEquals(updatedEntry1.copy(id = 1L), dao.getById(1))
val entry2 = HomeSet(id=0, serviceId=serviceId, personal=true, url= "https://example.com/2".toHttpUrl())
val insertId2 = dao.insertOrUpdateByUrlBlocking(entry2)
assertEquals(2L, insertId2)
assertEquals(entry2.copy(id = 2L), dao.getById(2))
}
@Test
fun testInsertOrUpdate_TransactionSafe() {
runBlocking(Dispatchers.IO) {
for (i in 0..9999)
launch {
dao.insertOrUpdateByUrlBlocking(
HomeSet(
id = 0,
serviceId = serviceId,
url = "https://example.com/".toHttpUrl(),
personal = true
)
)
}
}
assertEquals(1, dao.getByService(serviceId).size)
}
@Test
fun testDelete() {
// should delete row with given primary key (id)
val entry1 = HomeSet(id=1, serviceId=serviceId, personal=true, url= "https://example.com/1".toHttpUrl())
val insertId1 = dao.insertOrUpdateByUrlBlocking(entry1)
assertEquals(1L, insertId1)
assertEquals(entry1, dao.getById(1L))
dao.delete(entry1)
assertEquals(null, dao.getById(1L))
}
}

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

@@ -1,77 +0,0 @@
/*
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
*/
package at.bitfire.davdroid.db
import androidx.sqlite.SQLiteException
import at.bitfire.davdroid.sync.SyncDataType
import dagger.hilt.android.testing.HiltAndroidRule
import dagger.hilt.android.testing.HiltAndroidTest
import kotlinx.coroutines.test.runTest
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 SyncStatsDaoTest {
@get:Rule
val hiltRule = HiltAndroidRule(this)
@Inject
lateinit var db: AppDatabase
var collectionId: Long = 0
@Before
fun setUp() {
hiltRule.inject()
val serviceId = db.serviceDao().insertOrReplace(Service(
id = 0,
accountName = "test@example.com",
type = Service.TYPE_CALDAV
))
collectionId = db.collectionDao().insert(Collection(
id = 0,
serviceId = serviceId,
type = Collection.TYPE_CALENDAR,
url = "https://example.com".toHttpUrl()
))
}
@After
fun tearDown() {
db.serviceDao().deleteAll()
}
@Test
fun testInsertOrReplace_ExistingForeignKey() = runTest {
val dao = db.syncStatsDao()
dao.insertOrReplace(
SyncStats(
id = 0,
collectionId = collectionId,
dataType = SyncDataType.CONTACTS.toString(),
lastSync = System.currentTimeMillis()
)
)
}
@Test(expected = SQLiteException::class)
fun testInsertOrReplace_MissingForeignKey() = runTest {
val dao = db.syncStatsDao()
dao.insertOrReplace(
SyncStats(
id = 0,
collectionId = 12345,
dataType = SyncDataType.CONTACTS.toString(),
lastSync = System.currentTimeMillis()
)
)
}
}

View File

@@ -1,80 +0,0 @@
/*
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
*/
package at.bitfire.davdroid.db
import dagger.hilt.android.testing.HiltAndroidRule
import dagger.hilt.android.testing.HiltAndroidTest
import kotlinx.coroutines.test.runTest
import okhttp3.HttpUrl.Companion.toHttpUrl
import org.junit.Assert.assertEquals
import org.junit.Before
import org.junit.Rule
import org.junit.Test
import java.util.logging.Level
import java.util.logging.Logger
import javax.inject.Inject
@HiltAndroidTest
class WebDavDocumentDaoTest {
@get:Rule
val hiltRule = HiltAndroidRule(this)
@Inject
lateinit var db: AppDatabase
@Inject
lateinit var logger: Logger
@Before
fun setUp() {
hiltRule.inject()
}
@Test
fun testGetChildren() = runTest {
val mountDao = db.webDavMountDao()
val dao = db.webDavDocumentDao()
val mount = WebDavMount(id = 1, name = "Test", url = "https://example.com/".toHttpUrl())
db.webDavMountDao().insert(mount)
val root = WebDavDocument(
id = 1,
mountId = mount.id,
parentId = null,
name = "Root Document"
)
dao.insertOrReplace(root)
dao.insertOrReplace(WebDavDocument(id = 0, mountId = mount.id, parentId = root.id, name = "Name 1", displayName = "DisplayName 2"))
dao.insertOrReplace(WebDavDocument(id = 0, mountId = mount.id, parentId = root.id, name = "Name 2", displayName = "DisplayName 1"))
dao.insertOrReplace(WebDavDocument(id = 0, mountId = mount.id, parentId = root.id, name = "Name 3", displayName = "Directory 1", isDirectory = true))
try {
dao.getChildren(root.id, orderBy = "name DESC").let { result ->
logger.log(Level.INFO, "getChildren single sort Result", result)
assertEquals(listOf(
"Name 3",
"Name 2",
"Name 1"
), result.map { it.name })
}
dao.getChildren(root.id, orderBy = "isDirectory DESC, name ASC").let { result ->
logger.log(Level.INFO, "getChildren multiple sort Result", result)
assertEquals(listOf(
"Name 3",
"Name 1",
"Name 2"
), result.map { it.name })
}
} finally {
mountDao.deleteAsync(mount)
}
}
}

View File

@@ -1,83 +0,0 @@
/*
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
*/
package at.bitfire.davdroid.db.migration
import at.bitfire.davdroid.db.Collection.Companion.TYPE_CALENDAR
import at.bitfire.davdroid.db.Service
import dagger.hilt.android.testing.HiltAndroidTest
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Test
@HiltAndroidTest
class AutoMigration16Test: DatabaseMigrationTest(toVersion = 16) {
@Test
fun testMigrate_WithTimeZone() = testMigration(
prepare = { db ->
val minimalVTimezone = """
BEGIN:VCALENDAR
VERSION:2.0
PRODID:DAVx5
BEGIN:VTIMEZONE
TZID:America/New_York
END:VTIMEZONE
END:VCALENDAR
""".trimIndent()
db.execSQL(
"INSERT INTO service (id, accountName, type) VALUES (?, ?, ?)",
arrayOf(1, "test", Service.Companion.TYPE_CALDAV)
)
db.execSQL(
"INSERT INTO collection (id, serviceId, type, url, privWriteContent, privUnbind, forceReadOnly, sync, timezone) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)",
arrayOf(1, 1, TYPE_CALENDAR, "https://example.com", true, true, false, false, minimalVTimezone)
)
}
) { db ->
db.query("SELECT timezoneId FROM collection WHERE id=1").use { cursor ->
cursor.moveToFirst()
assertEquals("America/New_York", cursor.getString(0))
}
}
@Test
fun testMigrate_WithTimeZone_Unparseable() = testMigration(
prepare = { db ->
db.execSQL(
"INSERT INTO service (id, accountName, type) VALUES (?, ?, ?)",
arrayOf(1, "test", Service.Companion.TYPE_CALDAV)
)
db.execSQL(
"INSERT INTO collection (id, serviceId, type, url, privWriteContent, privUnbind, forceReadOnly, sync, timezone) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)",
arrayOf(1, 1, TYPE_CALENDAR, "https://example.com", true, true, false, false, "Some Garbage Content")
)
}
) { db ->
db.query("SELECT timezoneId FROM collection WHERE id=1").use { cursor ->
cursor.moveToFirst()
assertNull(cursor.getString(0))
}
}
@Test
fun testMigrate_WithoutTimezone() = testMigration(
prepare = { db ->
db.execSQL(
"INSERT INTO service (id, accountName, type) VALUES (?, ?, ?)",
arrayOf(1, "test", Service.Companion.TYPE_CALDAV)
)
db.execSQL(
"INSERT INTO collection (id, serviceId, type, url, privWriteContent, privUnbind, forceReadOnly, sync) VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
arrayOf(1, 1, TYPE_CALENDAR, "https://example.com", true, true, false, false)
)
}
) { db ->
db.query("SELECT timezoneId FROM collection WHERE id=1").use { cursor ->
cursor.moveToFirst()
assertNull(cursor.getString(0))
}
}
}

View File

@@ -1,79 +0,0 @@
/*
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
*/
package at.bitfire.davdroid.db.migration
import dagger.hilt.android.testing.HiltAndroidTest
import org.junit.Assert.assertEquals
import org.junit.Test
@HiltAndroidTest
class AutoMigration18Test : DatabaseMigrationTest(toVersion = 18) {
@Test
fun testMigration_AllAuthorities() = testMigration(
prepare = { db ->
// Insert service and collection to respect relation constraints
db.execSQL("INSERT INTO service (id, accountName, type) VALUES (?, ?, ?)", arrayOf<Any?>(1, "test", 1))
listOf(1L, 2L, 3L).forEach { id ->
db.execSQL(
"INSERT INTO collection (id, serviceId, url, type, privWriteContent, privUnbind, forceReadOnly, sync) VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
arrayOf<Any?>(id, 1, "https://example.com/$id", 1, 1, 1, 0, 1)
)
}
// Insert some syncstats with authorities and lastSync times
val syncstats = listOf(
Entry(1, 1, "com.android.contacts", 1000),
Entry(2, 1, "com.android.calendar", 1000),
Entry(3, 1, "org.dmfs.tasks", 1000),
Entry(4, 1, "org.tasks.opentasks", 2000),
Entry(5, 1, "at.techbee.jtx.provider", 3000), // highest lastSync for collection 1
Entry(6, 1, "unknown.authority", 1000), // ignored
Entry(7, 2, "org.dmfs.tasks", 1000),
Entry(8, 2, "org.tasks.opentasks", 2000), // highest lastSync for collection 2
Entry(9, 3, "org.tasks.opentasks", 1000),
)
syncstats.forEach { (id, collectionId, authority, lastSync) ->
db.execSQL(
"INSERT INTO syncstats (id, collectionId, authority, lastSync) VALUES (?, ?, ?, ?)",
arrayOf<Any?>(id, collectionId, authority, lastSync)
)
}
},
validate = { db ->
db.query("SELECT id, collectionId, dataType FROM syncstats ORDER BY id").use { cursor ->
val found = mutableListOf<Entry>()
db.query("SELECT id, collectionId, dataType FROM syncstats ORDER BY id").use { cursor ->
val idIdx = cursor.getColumnIndex("id")
val colIdx = cursor.getColumnIndex("collectionId")
val typeIdx = cursor.getColumnIndex("dataType")
while (cursor.moveToNext())
found.add(
Entry(cursor.getInt(idIdx), cursor.getLong(colIdx), cursor.getString(typeIdx))
)
}
// Expect one TASKS row per collection (collections 1, 2, 3)
assertEquals(
listOf(
Entry(1, 1, "CONTACTS"),
Entry(2, 1, "EVENTS"),
Entry(5, 1, "TASKS"), // highest lastSync TASK for collection 1 is JTX Board
Entry(8, 2, "TASKS"), // highest lastSync TASK for collection 2
Entry(9, 3, "TASKS"), // only TASK for collection 3
), found
)
}
}
)
data class Entry(
val id: Int,
val collectionId: Long,
val dataType: String? = null,
val lastSync: Long? = null
)
}

View File

@@ -1,86 +0,0 @@
/*
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
*/
package at.bitfire.davdroid.db.migration
import androidx.room.migration.AutoMigrationSpec
import androidx.room.migration.Migration
import androidx.room.testing.MigrationTestHelper
import androidx.sqlite.db.SupportSQLiteDatabase
import androidx.sqlite.db.framework.FrameworkSQLiteOpenHelperFactory
import androidx.test.platform.app.InstrumentationRegistry
import at.bitfire.davdroid.db.AppDatabase
import dagger.hilt.android.testing.HiltAndroidRule
import org.junit.Before
import org.junit.Rule
import javax.inject.Inject
/**
* Helper for testing the database migration from [toVersion] - 1 to [toVersion].
*
* @param toVersion The target version to migrate to.
*/
abstract class DatabaseMigrationTest(
private val toVersion: Int
) {
@Inject
lateinit var autoMigrations: Set<@JvmSuppressWildcards AutoMigrationSpec>
@Inject
lateinit var manualMigrations: Set<@JvmSuppressWildcards Migration>
@get:Rule
val hiltRule = HiltAndroidRule(this)
@Before
fun setup() {
hiltRule.inject()
}
/**
* Used for testing the migration process from [toVersion]-1 to [toVersion].
*
* Note: SQLite's foreign key constraint enforcement is not enabled in tests. We need
* to enable it ourselves using setting "PRAGMA foreign_keys=ON" directly after opening
* a new database connection (works per connection). In tests it's usually more practical
* not to do so, however. In production database connections room enables it for us.
*
* @param prepare Callback to prepare the database. Will be run with database schema in version [toVersion] - 1.
* @param validate Callback to validate the migration result. Will be run with database schema in version [toVersion].
*/
protected fun testMigration(
prepare: (SupportSQLiteDatabase) -> Unit,
validate: (SupportSQLiteDatabase) -> Unit
) {
val helper = MigrationTestHelper(
InstrumentationRegistry.getInstrumentation(),
AppDatabase::class.java,
autoMigrations.toList(),
FrameworkSQLiteOpenHelperFactory()
)
// Prepare the database with the initial version.
val dbName = "test"
helper.createDatabase(dbName, version = toVersion - 1).apply {
// We could enable foreign key constraint enforcement here
// by setting "PRAGMA foreign_keys=ON".
prepare(this)
close()
}
// Re-open the database with the new version and provide all the migrations.
val db = helper.runMigrationsAndValidate(
name = dbName,
version = toVersion,
validateDroppedTables = true,
migrations = manualMigrations.toTypedArray()
)
validate(db)
}
}

View File

@@ -1,20 +0,0 @@
/*
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
*/
package at.bitfire.davdroid.di
import at.bitfire.davdroid.sync.FakeSyncAdapter
import at.bitfire.davdroid.sync.adapter.SyncAdapter
import at.bitfire.davdroid.sync.adapter.SyncAdapterImpl
import dagger.Binds
import dagger.Module
import dagger.hilt.components.SingletonComponent
import dagger.hilt.testing.TestInstallIn
@Module
@TestInstallIn(components = [SingletonComponent::class], replaces = [SyncAdapterImpl.RealSyncAdapterModule::class])
abstract class FakeSyncAdapterModule {
@Binds
abstract fun provide(impl: FakeSyncAdapter): SyncAdapter
}

View File

@@ -1,59 +0,0 @@
/*
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
*/
package at.bitfire.davdroid.di
import at.bitfire.davdroid.di.TestCoroutineDispatchersModule.standardTestDispatcher
import dagger.Module
import dagger.Provides
import dagger.hilt.components.SingletonComponent
import dagger.hilt.testing.TestInstallIn
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.StandardTestDispatcher
import kotlinx.coroutines.test.setMain
/**
* Provides test dispatchers to be injected instead of the normal ones.
*
* The [standardTestDispatcher] is set as main dispatcher in [at.bitfire.davdroid.HiltTestRunner],
* so that tests can just use [kotlinx.coroutines.test.runTest] without providing [standardTestDispatcher].
*/
@Module
@TestInstallIn(
components = [SingletonComponent::class],
replaces = [CoroutineDispatchersModule::class]
)
object TestCoroutineDispatchersModule {
private val standardTestDispatcher = StandardTestDispatcher()
@Provides
@DefaultDispatcher
fun defaultDispatcher(): CoroutineDispatcher = standardTestDispatcher
@Provides
@IoDispatcher
fun ioDispatcher(): CoroutineDispatcher = standardTestDispatcher
@Provides
@MainDispatcher
fun mainDispatcher(): CoroutineDispatcher = standardTestDispatcher
@Provides
@SyncDispatcher
fun syncDispatcher(): CoroutineDispatcher = standardTestDispatcher
/**
* Sets the [standardTestDispatcher] as [Dispatchers.Main] so that test dispatchers
* created in the future use the same scheduler. See [StandardTestDispatcher] docs
* for more information.
*/
@OptIn(ExperimentalCoroutinesApi::class)
fun initMainDispatcher() {
Dispatchers.setMain(standardTestDispatcher)
}
}

View File

@@ -1,24 +0,0 @@
/*
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
*/
package at.bitfire.davdroid.di
import at.bitfire.davdroid.startup.StartupPlugin
import at.bitfire.davdroid.startup.TasksAppWatcher
import dagger.Module
import dagger.hilt.components.SingletonComponent
import dagger.hilt.testing.TestInstallIn
import dagger.multibindings.Multibinds
// remove TasksAppWatcherModule from Android tests
@Module
@TestInstallIn(
components = [SingletonComponent::class],
replaces = [TasksAppWatcher.TasksAppWatcherModule::class]
)
abstract class TestTasksAppWatcherModule {
// provides empty set of plugins
@Multibinds
abstract fun empty(): Set<StartupPlugin>
}

View File

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

View File

@@ -6,8 +6,6 @@ package at.bitfire.davdroid.network
import dagger.hilt.android.testing.HiltAndroidRule
import dagger.hilt.android.testing.HiltAndroidTest
import io.mockk.every
import io.mockk.mockk
import org.junit.Assert.assertArrayEquals
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
@@ -20,7 +18,6 @@ import org.xbill.DNS.Name
import org.xbill.DNS.SRVRecord
import org.xbill.DNS.TXTRecord
import javax.inject.Inject
import kotlin.random.Random
@HiltAndroidTest
class DnsRecordResolverTest {
@@ -68,22 +65,25 @@ class DnsRecordResolverTest {
Name.fromString("_caldavs._tcp.example.com."),
DClass.IN, 3600, 10, 20, 8443, Name.fromString("dav1020.example.com.")
)
val dns1030 = SRVRecord(
Name.fromString("_caldavs._tcp.example.com."),
DClass.IN, 3600, 10, 30, 8443, Name.fromString("dav1030.example.com.")
)
val records = arrayOf(dns1010, dns1020, dns1030)
val randomNumberGenerator = mockk<Random>()
for (i in 0..60) {
every { randomNumberGenerator.nextInt(0, 61) } returns i
val expected = when (i) {
in 0..10 -> dns1010
in 11..30 -> dns1020
else -> dns1030
// entries are selected randomly (for load balancing)
// run 1000 times to get a good distribution
val counts = IntArray(2)
for (i in 0 until 1000) {
val result = dnsRecordResolver.bestSRVRecord(arrayOf(dns1010, dns1020))
when (result) {
dns1010 -> counts[0]++
dns1020 -> counts[1]++
}
assertEquals(expected, dnsRecordResolver.bestSRVRecord(records, randomNumberGenerator))
}
/* We had weights 10 and 20, so the distribution of 1000 tries should be roughly
weight 10 fraction 1/3 expected count 333 binomial distribution (p=1/3) with 99.99% in [275..393]
weight 20 fraction 2/3 expected count 667 binomial distribution (p=2/3) with 99.99% in [607..725]
*/
assertTrue(counts[0] in 275..393)
assertTrue(counts[1] in 607..725)
}
@Test

View File

@@ -4,7 +4,9 @@
package at.bitfire.davdroid.network
import android.content.Context
import android.security.NetworkSecurityPolicy
import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.android.testing.HiltAndroidRule
import dagger.hilt.android.testing.HiltAndroidTest
import okhttp3.Request
@@ -23,20 +25,21 @@ import javax.inject.Inject
@HiltAndroidTest
class HttpClientTest {
lateinit var server: MockWebServer
lateinit var httpClient: HttpClient
@get:Rule
var hiltRule = HiltAndroidRule(this)
@Inject
lateinit var httpClientBuilder: HttpClient.Builder
lateinit var httpClient: HttpClient
lateinit var server: MockWebServer
@ApplicationContext
lateinit var context: Context
@Before
fun setUp() {
hiltRule.inject()
httpClient = httpClientBuilder.build()
httpClient = HttpClient.Builder(context).build()
server = MockWebServer()
server.start(30000)

View File

@@ -1,46 +0,0 @@
/*
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
*/
package at.bitfire.davdroid.push
import dagger.hilt.android.testing.HiltAndroidRule
import dagger.hilt.android.testing.HiltAndroidTest
import org.junit.Assert
import org.junit.Before
import org.junit.Rule
import org.junit.Test
import javax.inject.Inject
@HiltAndroidTest
class PushMessageHandlerTest {
@get:Rule
val hiltRule = HiltAndroidRule(this)
@Inject
lateinit var handler: PushMessageHandler
@Before
fun setUp() {
hiltRule.inject()
}
@Test
fun testParse_InvalidXml() {
Assert.assertNull(handler.parse("Non-XML content"))
}
@Test
fun testParse_WithXmlDeclAndTopic() {
val topic = handler.parse(
"<?xml version=\"1.0\" encoding=\"utf-8\" ?>" +
"<P:push-message xmlns:D=\"DAV:\" xmlns:P=\"https://bitfire.at/webdav-push\">" +
" <P:topic>O7M1nQ7cKkKTKsoS_j6Z3w</P:topic>" +
"</P:push-message>"
)
Assert.assertEquals("O7M1nQ7cKkKTKsoS_j6Z3w", topic)
}
}

View File

@@ -1,102 +0,0 @@
/*
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
*/
package at.bitfire.davdroid.push
import android.content.Context
import android.content.Intent
import android.os.IBinder
import androidx.test.rule.ServiceTestRule
import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.android.testing.BindValue
import dagger.hilt.android.testing.HiltAndroidRule
import dagger.hilt.android.testing.HiltAndroidTest
import io.mockk.coVerify
import io.mockk.confirmVerified
import io.mockk.every
import io.mockk.impl.annotations.RelaxedMockK
import io.mockk.junit4.MockKRule
import io.mockk.mockk
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.advanceUntilIdle
import kotlinx.coroutines.test.runTest
import org.junit.Before
import org.junit.Rule
import org.junit.Test
import org.unifiedpush.android.connector.FailedReason
import org.unifiedpush.android.connector.PushService
import org.unifiedpush.android.connector.data.PushEndpoint
import javax.inject.Inject
@OptIn(ExperimentalCoroutinesApi::class)
@HiltAndroidTest
class UnifiedPushServiceTest {
@get:Rule
val hiltRule = HiltAndroidRule(this)
@get:Rule
val mockKRule = MockKRule(this)
@get:Rule
val serviceTestRule = ServiceTestRule()
@Inject
@ApplicationContext
lateinit var context: Context
@RelaxedMockK
@BindValue
lateinit var pushRegistrationManager: PushRegistrationManager
lateinit var binder: IBinder
lateinit var unifiedPushService: UnifiedPushService
@Before
fun setUp() {
hiltRule.inject()
binder = serviceTestRule.bindService(Intent(context, UnifiedPushService::class.java))!!
unifiedPushService = (binder as PushService.PushBinder).getService() as UnifiedPushService
}
@Test
fun testOnNewEndpoint() = runTest {
val endpoint = mockk<PushEndpoint> {
every { url } returns "https://example.com/12"
}
unifiedPushService.onNewEndpoint(endpoint, "12")
advanceUntilIdle()
coVerify {
pushRegistrationManager.processSubscription(12, endpoint)
}
confirmVerified(pushRegistrationManager)
}
@Test
fun testOnRegistrationFailed() = runTest {
unifiedPushService.onRegistrationFailed(FailedReason.INTERNAL_ERROR, "34")
advanceUntilIdle()
coVerify {
pushRegistrationManager.removeSubscription(34)
}
confirmVerified(pushRegistrationManager)
}
@Test
fun testOnUnregistered() = runTest {
unifiedPushService.onUnregistered("45")
advanceUntilIdle()
coVerify {
pushRegistrationManager.removeSubscription(45)
}
confirmVerified(pushRegistrationManager)
}
}

View File

@@ -0,0 +1,88 @@
package at.bitfire.davdroid.repository
import android.content.Context
import at.bitfire.davdroid.db.AppDatabase
import at.bitfire.davdroid.db.Collection
import at.bitfire.davdroid.db.Service
import at.bitfire.davdroid.settings.AccountSettings
import dagger.hilt.android.qualifiers.ApplicationContext
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 accountSettingsFactory: AccountSettings.Factory
@Inject
@ApplicationContext
lateinit var context: Context
@Inject
lateinit var db: AppDatabase
@Inject
lateinit var serviceRepository: DavServiceRepository
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(accountSettingsFactory, context, db, mutableSetOf(testObserver), serviceRepository)
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

@@ -1,225 +0,0 @@
/*
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
*/
package at.bitfire.davdroid.resource
import android.accounts.Account
import android.accounts.AccountManager
import android.content.ContentProviderClient
import android.content.Context
import at.bitfire.davdroid.R
import at.bitfire.davdroid.db.AppDatabase
import at.bitfire.davdroid.db.Collection
import at.bitfire.davdroid.db.Service
import at.bitfire.davdroid.sync.account.TestAccount
import dagger.hilt.android.qualifiers.ApplicationContext
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 io.mockk.mockkObject
import okhttp3.HttpUrl.Companion.toHttpUrl
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Rule
import org.junit.Test
import javax.inject.Inject
@HiltAndroidTest
class LocalAddressBookStoreTest {
@Inject @ApplicationContext
lateinit var context: Context
@Inject
lateinit var db: AppDatabase
@Inject
lateinit var localAddressBookStore: LocalAddressBookStore
@RelaxedMockK
lateinit var provider: ContentProviderClient
@get:Rule
val hiltRule = HiltAndroidRule(this)
@get:Rule
val mockkRule = MockKRule(this)
lateinit var addressBookAccountType: String
lateinit var addressBookAccount: Account
lateinit var account: Account
lateinit var service: Service
@Before
fun setUp() {
hiltRule.inject()
addressBookAccountType = context.getString(R.string.account_type_address_book)
account = TestAccount.create()
service = Service(
id = 200,
accountName = account.name,
type = Service.Companion.TYPE_CARDDAV,
principal = null
)
db.serviceDao().insertOrReplace(service)
addressBookAccount = Account(
"MrRobert@example.com",
addressBookAccountType
)
}
@After
fun tearDown() {
TestAccount.remove(account)
removeAddressBooks()
}
@Test
fun test_accountName_removesSpecialChars() {
// Should remove iso control characters and `, ", ',
val collection = mockk<Collection> {
every { id } returns 1
every { url } returns "https://example.com/addressbook/funnyfriends".toHttpUrl()
every { displayName } returns "手 M's_\"F-e\"\\(´д`)/;æøå% äöü #42"
every { serviceId } returns service.id
}
assertEquals("手 Ms_F-e\\(´д)/;æøå% äöü #42 (Test Account) #1", localAddressBookStore.accountName(collection))
}
@Test
fun test_accountName_missingService() {
val collection = mockk<Collection> {
every { id } returns 42
every { url } returns "https://example.com/addressbook/funnyfriends".toHttpUrl()
every { displayName } returns null
every { serviceId } returns 404 // missing service
}
assertEquals("funnyfriends #42", localAddressBookStore.accountName(collection))
}
@Test
fun test_accountName_missingDisplayName() {
val collection = mockk<Collection> {
every { id } returns 42
every { url } returns "https://example.com/addressbook/funnyfriends".toHttpUrl()
every { displayName } returns null
every { serviceId } returns service.id
}
val accountName = localAddressBookStore.accountName(collection)
assertEquals("funnyfriends (${account.name}) #42", accountName)
}
@Test
fun test_accountName_missingDisplayNameAndService() {
val collection = mockk<Collection> {
every { id } returns 1
every { url } returns "https://example.com/addressbook/funnyfriends".toHttpUrl()
every { displayName } returns null
every { serviceId } returns 404 // missing service
}
assertEquals("funnyfriends #1", localAddressBookStore.accountName(collection))
}
@Test
fun test_create_createAccountReturnsNull() {
val collection = mockk<Collection>(relaxed = true) {
every { serviceId } returns service.id
every { id } returns 1
every { url } returns "https://example.com/addressbook/funnyfriends".toHttpUrl()
}
mockkObject(localAddressBookStore)
every { localAddressBookStore.createAddressBookAccount(any(), any(), any()) } returns null
assertEquals(null, localAddressBookStore.create(provider, collection))
}
@Test
fun test_create_ReadOnly() {
val collection = mockk<Collection>(relaxed = true) {
every { serviceId } returns service.id
every { id } returns 1
every { url } returns "https://example.com/addressbook/funnyfriends".toHttpUrl()
every { readOnly() } returns true
}
val addrBook = localAddressBookStore.create(provider, collection)!!
assertEquals(Account("funnyfriends (Test Account) #1", addressBookAccountType), addrBook.addressBookAccount)
assertTrue(addrBook.readOnly)
}
@Test
fun test_create_ReadWrite() {
val collection = mockk<Collection>(relaxed = true) {
every { serviceId } returns service.id
every { id } returns 1
every { url } returns "https://example.com/addressbook/funnyfriends".toHttpUrl()
every { readOnly() } returns false
}
val addrBook = localAddressBookStore.create(provider, collection)!!
assertEquals(Account("funnyfriends (Test Account) #1", addressBookAccountType), addrBook.addressBookAccount)
assertFalse(addrBook.readOnly)
}
@Test
fun test_getAll_differentAccount() {
val accountManager = AccountManager.get(context)
mockkObject(accountManager)
every { accountManager.getAccountsByType(any()) } returns arrayOf(addressBookAccount)
every { accountManager.getUserData(addressBookAccount, LocalAddressBook.USER_DATA_ACCOUNT_NAME) } returns "Another Unrelated Account"
every { accountManager.getUserData(addressBookAccount, LocalAddressBook.USER_DATA_ACCOUNT_TYPE) } returns account.type
val result = localAddressBookStore.getAll(account, provider)
assertTrue(result.isEmpty())
}
@Test
fun test_getAll_sameAccount() {
val accountManager = AccountManager.get(context)
mockkObject(accountManager)
every { accountManager.getAccountsByType(any()) } returns arrayOf(addressBookAccount)
every { accountManager.getUserData(addressBookAccount, LocalAddressBook.USER_DATA_ACCOUNT_NAME) } returns account.name
every { accountManager.getUserData(addressBookAccount, LocalAddressBook.USER_DATA_ACCOUNT_TYPE) } returns account.type
val result = localAddressBookStore.getAll(account, provider)
assertEquals(1, result.size)
assertEquals(addressBookAccount, result.first().addressBookAccount)
}
/**
* Tests the calculation of read only state is correct
*/
@Test
fun test_shouldBeReadOnly() {
val collectionReadOnly = mockk<Collection> { every { readOnly() } returns true }
assertTrue(LocalAddressBookStore.shouldBeReadOnly(collectionReadOnly, false))
assertTrue(LocalAddressBookStore.shouldBeReadOnly(collectionReadOnly, true))
val collectionNotReadOnly = mockk<Collection> { every { readOnly() } returns false }
assertFalse(LocalAddressBookStore.shouldBeReadOnly(collectionNotReadOnly, false))
assertTrue(LocalAddressBookStore.shouldBeReadOnly(collectionNotReadOnly, true))
}
// helpers
private fun removeAddressBooks() {
val accountManager = AccountManager.get(context)
accountManager.getAccountsByType(addressBookAccountType).forEach {
accountManager.removeAccountExplicitly(it)
}
}
}

View File

@@ -13,42 +13,53 @@ import android.provider.ContactsContract
import androidx.test.platform.app.InstrumentationRegistry
import androidx.test.rule.GrantPermissionRule
import at.bitfire.vcard4android.Contact
import at.bitfire.vcard4android.GroupMethod
import at.bitfire.vcard4android.LabeledProperty
import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.android.testing.HiltAndroidRule
import dagger.hilt.android.testing.HiltAndroidTest
import ezvcard.property.Telephone
import java.util.LinkedList
import javax.inject.Inject
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.Assert.assertNotNull
import org.junit.Before
import org.junit.BeforeClass
import org.junit.ClassRule
import org.junit.Rule
import org.junit.Test
import java.io.FileNotFoundException
import java.util.LinkedList
import java.util.Optional
import javax.inject.Inject
@HiltAndroidTest
class LocalAddressBookTest {
@Inject @ApplicationContext
lateinit var context: Context
@Inject
lateinit var localTestAddressBookProvider: LocalTestAddressBookProvider
@get:Rule
val hiltRule = HiltAndroidRule(this)
val account = Account("Test Account", "Test Account Type")
@Inject
lateinit var addressbookFactory: LocalTestAddressBook.Factory
@Inject
@ApplicationContext
lateinit var context: Context
lateinit var addressBook: LocalTestAddressBook
@Before
fun setUp() {
hiltRule.inject()
addressBook = addressbookFactory.create(provider, GroupMethod.CATEGORIES)
LocalTestAddressBook.createAccount(context)
}
@After
fun tearDown() {
// remove address book
addressBook.deleteCollection()
}
@@ -57,34 +68,32 @@ class LocalAddressBookTest {
*/
@Test
fun test_renameAccount_retainsContacts() {
localTestAddressBookProvider.provide(account, provider) { addressBook ->
// insert contact with data row
val uid = "12345"
val contact = Contact(
uid = uid,
displayName = "Test Contact",
phoneNumbers = LinkedList(listOf(LabeledProperty(Telephone("1234567890"))))
)
val uri = LocalContact(addressBook, contact, null, null, 0).add()
val id = ContentUris.parseId(uri)
val localContact = addressBook.findContactById(id)
localContact.resetDirty()
assertFalse("Contact is dirty before moving", isContactDirty(addressBook, id))
// insert contact with data row
val uid = "12345"
val contact = Contact(
uid = uid,
displayName = "Test Contact",
phoneNumbers = LinkedList(listOf(LabeledProperty(Telephone("1234567890"))))
)
val uri = LocalContact(addressBook, contact, null, null, 0).add()
val id = ContentUris.parseId(uri)
val localContact = addressBook.findContactById(id)
localContact.resetDirty()
assertFalse("Contact is dirty before moving", addressBook.isContactDirty(id))
// rename address book
val newName = "New Name"
addressBook.renameAccount(newName)
assertEquals(newName, addressBook.addressBookAccount.name)
// rename address book
val newName = "New Name"
addressBook.renameAccount(newName)
assertEquals(Account(newName, LocalTestAddressBook.ACCOUNT.type), addressBook.addressBookAccount)
// check whether contact is still here (including data rows) and not dirty
val result = addressBook.findContactById(id)
assertFalse("Contact is dirty after moving", isContactDirty(addressBook, id))
// check whether contact is still here (including data rows) and not dirty
val result = addressBook.findContactById(id)
assertFalse("Contact is dirty after moving", addressBook.isContactDirty(id))
val contact2 = result.getContact()
assertEquals(uid, contact2.uid)
assertEquals("Test Contact", contact2.displayName)
assertEquals("1234567890", contact2.phoneNumbers.first().component1().text)
}
val contact2 = result.getContact()
assertEquals(uid, contact2.uid)
assertEquals("Test Contact", contact2.displayName)
assertEquals("1234567890", contact2.phoneNumbers.first().component1().text)
}
/**
@@ -92,65 +101,29 @@ class LocalAddressBookTest {
*/
@Test
fun test_renameAccount_retainsGroups() {
localTestAddressBookProvider.provide(account, provider) { addressBook ->
// insert group
val localGroup = LocalGroup(addressBook, Contact(displayName = "Test Group"), null, null, 0)
val uri = localGroup.add()
val id = ContentUris.parseId(uri)
// insert group
val localGroup = LocalGroup(addressBook, Contact(displayName = "Test Group"), null, null, 0)
val uri = localGroup.add()
val id = ContentUris.parseId(uri)
// make sure it's not dirty
localGroup.clearDirty(Optional.empty(), null, null)
assertFalse("Group is dirty before moving", isGroupDirty(addressBook, id))
// make sure it's not dirty
localGroup.clearDirty(null, null, null)
assertFalse("Group is dirty before moving", addressBook.isGroupDirty(id))
// rename address book
val newName = "New Name"
assertTrue(addressBook.renameAccount(newName))
assertEquals(newName, addressBook.addressBookAccount.name)
// rename address book
val newName = "New Name"
addressBook.renameAccount(newName)
assertEquals(Account(newName, LocalTestAddressBook.ACCOUNT.type), addressBook.addressBookAccount)
// check whether group is still here and not dirty
val result = addressBook.findGroupById(id)
assertFalse("Group is dirty after moving", isGroupDirty(addressBook, id))
// check whether group is still here and not dirty
val result = addressBook.findGroupById(id)
assertFalse("Group is dirty after moving", addressBook.isGroupDirty(id))
val group = result.getContact()
assertEquals("Test Group", group.displayName)
}
val group = result.getContact()
assertEquals("Test Group", group.displayName)
}
// helpers
/**
* Returns the dirty flag of the given contact.
*
* @return true if the contact is dirty, false otherwise
*
* @throws FileNotFoundException if the contact can't be found
*/
fun isContactDirty(adddressBook: LocalAddressBook, id: Long): Boolean {
val uri = ContentUris.withAppendedId(adddressBook.rawContactsSyncUri(), id)
provider.query(uri, arrayOf(ContactsContract.RawContacts.DIRTY), null, null, null)?.use { cursor ->
if (cursor.moveToFirst())
return cursor.getInt(0) != 0
}
throw FileNotFoundException()
}
/**
* Returns the dirty flag of the given contact group.
*
* @return true if the group is dirty, false otherwise
*
* @throws FileNotFoundException if the group can't be found
*/
fun isGroupDirty(adddressBook: LocalAddressBook, id: Long): Boolean {
val uri = ContentUris.withAppendedId(adddressBook.groupsSyncUri(), id)
provider.query(uri, arrayOf(ContactsContract.Groups.DIRTY), null, null, null)?.use { cursor ->
if (cursor.moveToFirst())
return cursor.getInt(0) != 0
}
throw FileNotFoundException()
}
companion object {
@@ -163,8 +136,9 @@ class LocalAddressBookTest {
@BeforeClass
@JvmStatic
fun connect() {
val context = InstrumentationRegistry.getInstrumentation().context
val context = InstrumentationRegistry.getInstrumentation().targetContext
provider = context.contentResolver.acquireContentProviderClient(ContactsContract.AUTHORITY)!!
assertNotNull(provider)
}
@AfterClass

View File

@@ -8,68 +8,65 @@ import android.accounts.Account
import android.content.ContentProviderClient
import android.content.ContentUris
import android.content.ContentValues
import android.content.Entity
import android.provider.CalendarContract
import android.provider.CalendarContract.ACCOUNT_TYPE_LOCAL
import android.provider.CalendarContract.Events
import androidx.core.content.contentValuesOf
import androidx.test.platform.app.InstrumentationRegistry
import at.bitfire.davdroid.InitCalendarProviderRule
import at.bitfire.ical4android.AndroidCalendar
import at.bitfire.ical4android.Event
import at.bitfire.ical4android.util.MiscUtils.asSyncAdapter
import at.bitfire.ical4android.util.MiscUtils.closeCompat
import at.bitfire.synctools.storage.calendar.AndroidCalendar
import at.bitfire.synctools.storage.calendar.AndroidCalendarProvider
import at.bitfire.synctools.storage.calendar.AndroidEvent2
import at.bitfire.synctools.test.InitCalendarProviderRule
import dagger.hilt.android.testing.HiltAndroidRule
import dagger.hilt.android.testing.HiltAndroidTest
import net.fortuna.ical4j.model.property.DtStart
import net.fortuna.ical4j.model.property.RRule
import net.fortuna.ical4j.model.property.RecurrenceId
import net.fortuna.ical4j.model.property.Status
import org.junit.After
import org.junit.AfterClass
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertNull
import org.junit.Before
import org.junit.Rule
import org.junit.BeforeClass
import org.junit.ClassRule
import org.junit.Test
import org.junit.rules.TestRule
import javax.inject.Inject
@HiltAndroidTest
class LocalCalendarTest {
@get:Rule
val hiltRule = HiltAndroidRule(this)
companion object {
@get:Rule
val initCalendarProviderRule: TestRule = InitCalendarProviderRule.initialize()
@JvmField
@ClassRule
val initCalendarProviderRule: TestRule = InitCalendarProviderRule.getInstance()
@Inject
lateinit var localCalendarFactory: LocalCalendar.Factory
private lateinit var provider: ContentProviderClient
@BeforeClass
@JvmStatic
fun setUpClass() {
val context = InstrumentationRegistry.getInstrumentation().targetContext
provider = context.contentResolver.acquireContentProviderClient(CalendarContract.AUTHORITY)!!
}
@AfterClass
@JvmStatic
fun tearDownClass() {
provider.closeCompat()
}
}
private val account = Account("LocalCalendarTest", ACCOUNT_TYPE_LOCAL)
private lateinit var androidCalendar: AndroidCalendar
private lateinit var client: ContentProviderClient
private lateinit var calendar: LocalCalendar
@Before
fun setUp() {
hiltRule.inject()
val context = InstrumentationRegistry.getInstrumentation().targetContext
client = context.contentResolver.acquireContentProviderClient(CalendarContract.AUTHORITY)!!
val provider = AndroidCalendarProvider(account, client)
androidCalendar = provider.createAndGetCalendar(ContentValues())
calendar = localCalendarFactory.create(androidCalendar)
val uri = AndroidCalendar.create(account, provider, ContentValues())
calendar = AndroidCalendar.findByID(account, provider, LocalCalendar.Factory, ContentUris.parseId(uri))
}
@After
fun tearDown() {
androidCalendar.delete()
client.closeCompat()
calendar.deleteCollection()
}
@@ -99,18 +96,12 @@ class LocalCalendarTest {
status = Status.VEVENT_CANCELLED
})
}
calendar.add(
event = event,
fileName = "filename.ics",
eTag = null,
scheduleTag = null,
flags = LocalResource.FLAG_REMOTELY_PRESENT
)
val localEvent = calendar.findByName("filename.ics")!!
val eventId = localEvent.id
val localEvent = LocalEvent(calendar, event, "filename.ics", null, null, LocalResource.FLAG_REMOTELY_PRESENT)
localEvent.add()
val eventId = localEvent.id!!
// set event as dirty
client.update(ContentUris.withAppendedId(Events.CONTENT_URI.asSyncAdapter(account), eventId), ContentValues(1).apply {
provider.update(ContentUris.withAppendedId(Events.CONTENT_URI.asSyncAdapter(account), eventId), ContentValues(1).apply {
put(Events.DIRTY, 1)
}, null, null)
@@ -118,7 +109,7 @@ class LocalCalendarTest {
calendar.deleteDirtyEventsWithoutInstances()
// verify that event is now marked as deleted
client.query(
provider.query(
ContentUris.withAppendedId(Events.CONTENT_URI.asSyncAdapter(account), eventId),
arrayOf(Events.DELETED), null, null, null
)!!.use { cursor ->
@@ -128,109 +119,33 @@ class LocalCalendarTest {
}
@Test
// Needs InitCalendarProviderRule
// 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"))
}
calendar.add(
event = event,
fileName = "filename.ics",
eTag = null,
scheduleTag = null,
flags = LocalResource.FLAG_REMOTELY_PRESENT
)
val localEvent = calendar.findByName("filename.ics")!!
val eventUrl = androidCalendar.eventUri(localEvent.id)
val localEvent = LocalEvent(calendar, event, "filename.ics", null, null, LocalResource.FLAG_REMOTELY_PRESENT)
localEvent.add()
val eventId = localEvent.id!!
// set event as dirty
client.update(eventUrl, contentValuesOf(
Events.DIRTY to 1
), null, null)
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
client.query(eventUrl, arrayOf(Events.DELETED), null, null, null)!!.use { cursor ->
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))
}
}
/**
* Verifies that [LocalCalendar.removeNotDirtyMarked] works as expected.
* @param contentValues values to set on the event. Required:
* - [Events._ID]
* - [Events.DIRTY]
*/
private fun testRemoveNotDirtyMarked(contentValues: ContentValues) {
val id = androidCalendar.addEvent(Entity(
contentValuesOf(
Events.CALENDAR_ID to androidCalendar.id,
Events.DTSTART to System.currentTimeMillis(),
Events.DTEND to System.currentTimeMillis(),
Events.TITLE to "Some Event",
AndroidEvent2.COLUMN_FLAGS to 123
).apply { putAll(contentValues) }
))
calendar.removeNotDirtyMarked(123)
assertNull(androidCalendar.getEvent(id))
}
@Test
fun testRemoveNotDirtyMarked_IdLargerThanIntMaxValue() = testRemoveNotDirtyMarked(
contentValuesOf(Events._ID to Int.MAX_VALUE.toLong() + 10, Events.DIRTY to 0)
)
@Test
fun testRemoveNotDirtyMarked_DirtyIs0() = testRemoveNotDirtyMarked(
contentValuesOf(Events._ID to 1, Events.DIRTY to 0)
)
@Test
fun testRemoveNotDirtyMarked_DirtyNull() = testRemoveNotDirtyMarked(
contentValuesOf(Events._ID to 1, Events.DIRTY to null)
)
/**
* Verifies that [LocalCalendar.markNotDirty] works as expected.
* @param contentValues values to set on the event. Required:
* - [Events.DIRTY]
*/
private fun testMarkNotDirty(contentValues: ContentValues) {
val id = androidCalendar.addEvent(Entity(
contentValuesOf(
Events.CALENDAR_ID to androidCalendar.id,
Events._ID to 1,
Events.DTSTART to System.currentTimeMillis(),
Events.DTEND to System.currentTimeMillis(),
Events.TITLE to "Some Event",
AndroidEvent2.COLUMN_FLAGS to 123
).apply { putAll(contentValues) }
))
val updated = calendar.markNotDirty(321)
assertEquals(1, updated)
assertEquals(321, androidCalendar.getEvent(id)?.flags)
}
@Test
fun test_markNotDirty_DirtyIs0() = testMarkNotDirty(
contentValuesOf(
Events.DIRTY to 0
)
)
@Test
fun test_markNotDirty_DirtyIsNull() = testMarkNotDirty(
contentValuesOf(
Events.DIRTY to null
)
)
}

View File

@@ -4,65 +4,315 @@
package at.bitfire.davdroid.resource
import android.Manifest
import android.accounts.Account
import android.content.ContentProviderClient
import android.content.ContentUris
import android.content.ContentValues
import android.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 androidx.test.rule.GrantPermissionRule
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.synctools.storage.calendar.AndroidCalendarProvider
import at.techbee.jtx.JtxContract.asSyncAdapter
import dagger.hilt.android.testing.HiltAndroidRule
import dagger.hilt.android.testing.HiltAndroidTest
import net.fortuna.ical4j.model.Date
import net.fortuna.ical4j.model.DateList
import net.fortuna.ical4j.model.parameter.Value
import net.fortuna.ical4j.model.property.DtStart
import net.fortuna.ical4j.model.property.ExDate
import net.fortuna.ical4j.model.property.RRule
import net.fortuna.ical4j.model.property.RecurrenceId
import net.fortuna.ical4j.model.property.Status
import org.junit.After
import org.junit.AfterClass
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Rule
import org.junit.BeforeClass
import org.junit.ClassRule
import org.junit.Test
import org.junit.rules.TestRule
import java.util.UUID
import javax.inject.Inject
@HiltAndroidTest
class LocalEventTest {
@get:Rule
val hiltRule = HiltAndroidRule(this)
companion object {
@get:Rule
val permissionRule = GrantPermissionRule.grant(Manifest.permission.READ_CALENDAR, Manifest.permission.WRITE_CALENDAR)
@JvmField
@ClassRule
val initCalendarProviderRule: TestRule = InitCalendarProviderRule.getInstance()
@Inject
lateinit var localCalendarFactory: LocalCalendar.Factory
private lateinit var provider: ContentProviderClient
@BeforeClass
@JvmStatic
fun setUpClass() {
val context = InstrumentationRegistry.getInstrumentation().targetContext
provider = context.contentResolver.acquireContentProviderClient(CalendarContract.AUTHORITY)!!
}
@AfterClass
@JvmStatic
fun tearDownClass() {
provider.closeCompat()
}
}
private val account = Account("LocalCalendarTest", ACCOUNT_TYPE_LOCAL)
private lateinit var client: ContentProviderClient
private lateinit var calendar: LocalCalendar
@Before
fun setUp() {
hiltRule.inject()
val context = InstrumentationRegistry.getInstrumentation().targetContext
client = context.contentResolver.acquireContentProviderClient(CalendarContract.AUTHORITY)!!
val provider = AndroidCalendarProvider(account, client)
calendar = localCalendarFactory.create(provider.createAndGetCalendar(ContentValues()))
val uri = AndroidCalendar.create(account, provider, ContentValues())
calendar = AndroidCalendar.findByID(account, provider, LocalCalendar.Factory, ContentUris.parseId(uri))
}
@After
fun tearDown() {
calendar.androidCalendar.delete()
client.closeCompat()
fun removeCalendar() {
calendar.deleteCollection()
}
@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))
}
}
@@ -73,15 +323,8 @@ class LocalEventTest {
dtStart = DtStart("20220120T010203Z")
summary = "Event without uid"
}
calendar.add(
event = event,
fileName = "filename.ics",
eTag = null,
scheduleTag = null,
flags = LocalResource.FLAG_REMOTELY_PRESENT
)
val localEvent = calendar.findByName("filename.ics")!!
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()
@@ -91,7 +334,7 @@ class LocalEventTest {
UUID.fromString(fileName)
// UID in calendar storage should be the same as file name
client.query(
provider.query(
ContentUris.withAppendedId(Events.CONTENT_URI, localEvent.id!!).asSyncAdapter(account),
arrayOf(Events.UID_2445), null, null, null
)!!.use { cursor ->
@@ -108,14 +351,8 @@ class LocalEventTest {
summary = "Event with normal uid"
uid = "some-event@hostname.tld" // old UID format, UUID would be new format
}
calendar.add(
event = event,
fileName = "filename.ics",
eTag = null,
scheduleTag = null,
flags = LocalResource.FLAG_REMOTELY_PRESENT
)
val localEvent = calendar.findByName("filename.ics")!!
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()
@@ -124,7 +361,7 @@ class LocalEventTest {
assertEquals(event.uid, fileName)
// UID in calendar storage should still be set, too
client.query(
provider.query(
ContentUris.withAppendedId(Events.CONTENT_URI, localEvent.id!!).asSyncAdapter(account),
arrayOf(Events.UID_2445), null, null, null
)!!.use { cursor ->
@@ -141,14 +378,8 @@ class LocalEventTest {
summary = "Event with funny uid"
uid = "https://www.example.com/events/asdfewfe-cxyb-ewrws-sadfrwerxyvser-asdfxye-"
}
calendar.add(
event = event,
fileName = "filename.ics",
eTag = null,
scheduleTag = null,
flags = LocalResource.FLAG_REMOTELY_PRESENT
)
val localEvent = calendar.findByName("filename.ics")!!
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()
@@ -158,7 +389,7 @@ class LocalEventTest {
UUID.fromString(fileName)
// UID in calendar storage shouldn't have been changed
client.query(
provider.query(
ContentUris.withAppendedId(Events.CONTENT_URI, localEvent.id!!).asSyncAdapter(account),
arrayOf(Events.UID_2445), null, null, null
)!!.use { cursor ->
@@ -199,18 +430,12 @@ class LocalEventTest {
status = Status.VEVENT_CANCELLED
})
}
calendar.add(
event = event,
fileName = "filename.ics",
eTag = null,
scheduleTag = null,
flags = LocalResource.FLAG_REMOTELY_PRESENT
)
val localEvent = calendar.findByName("filename.ics")!!
val localEvent = LocalEvent(calendar, event, "filename.ics", null, null, LocalResource.FLAG_REMOTELY_PRESENT)
localEvent.add()
val eventId = localEvent.id!!
// set event as dirty
client.update(ContentUris.withAppendedId(Events.CONTENT_URI.asSyncAdapter(account), eventId), ContentValues(1).apply {
provider.update(ContentUris.withAppendedId(Events.CONTENT_URI.asSyncAdapter(account), eventId), ContentValues(1).apply {
put(Events.DIRTY, 1)
}, null, null)
@@ -218,7 +443,7 @@ class LocalEventTest {
calendar.deleteDirtyEventsWithoutInstances()
// verify that event is now marked as deleted
client.query(
provider.query(
ContentUris.withAppendedId(Events.CONTENT_URI.asSyncAdapter(account), eventId),
arrayOf(Events.DELETED), null, null, null
)!!.use { cursor ->
@@ -234,18 +459,12 @@ class LocalEventTest {
summary = "Event with 3 instances"
rRules.add(RRule("FREQ=DAILY;COUNT=3"))
}
calendar.add(
event = event,
fileName = "filename.ics",
eTag = null,
scheduleTag = null,
flags = LocalResource.FLAG_REMOTELY_PRESENT
)
val localEvent = calendar.findByName("filename.ics")!!
val localEvent = LocalEvent(calendar, event, "filename.ics", null, null, LocalResource.FLAG_REMOTELY_PRESENT)
localEvent.add()
val eventId = localEvent.id!!
// set event as dirty
client.update(ContentUris.withAppendedId(Events.CONTENT_URI.asSyncAdapter(account), eventId), ContentValues(1).apply {
provider.update(ContentUris.withAppendedId(Events.CONTENT_URI.asSyncAdapter(account), eventId), ContentValues(1).apply {
put(Events.DIRTY, 1)
}, null, null)
@@ -253,7 +472,7 @@ class LocalEventTest {
calendar.deleteDirtyEventsWithoutInstances()
// verify that event is not marked as deleted
client.query(
provider.query(
ContentUris.withAppendedId(Events.CONTENT_URI.asSyncAdapter(account), eventId),
arrayOf(Events.DELETED), null, null, null
)!!.use { cursor ->
@@ -262,4 +481,4 @@ class LocalEventTest {
}
}
}
}

View File

@@ -5,7 +5,6 @@
package at.bitfire.davdroid.resource
import android.Manifest
import android.accounts.Account
import android.content.ContentProviderClient
import android.content.ContentUris
import android.content.ContentValues
@@ -14,7 +13,7 @@ import android.provider.ContactsContract
import android.provider.ContactsContract.CommonDataKinds.GroupMembership
import androidx.test.platform.app.InstrumentationRegistry
import androidx.test.rule.GrantPermissionRule
import at.bitfire.synctools.storage.ContactsBatchOperation
import at.bitfire.vcard4android.BatchOperation
import at.bitfire.vcard4android.CachedGroupMembership
import at.bitfire.vcard4android.Contact
import at.bitfire.vcard4android.GroupMethod
@@ -32,235 +31,11 @@ import org.junit.BeforeClass
import org.junit.ClassRule
import org.junit.Rule
import org.junit.Test
import java.util.Optional
import javax.inject.Inject
@HiltAndroidTest
class LocalGroupTest {
@Inject @ApplicationContext
lateinit var context: Context
@Inject
lateinit var localTestAddressBookProvider: LocalTestAddressBookProvider
@get:Rule
val hiltRule = HiltAndroidRule(this)
val account = Account("Test Account", "Test Account Type")
@Before
fun setup() {
hiltRule.inject()
}
@Test
fun testApplyPendingMemberships_addPendingMembership() {
localTestAddressBookProvider.provide(account, provider, GroupMethod.GROUP_VCARDS) { ab ->
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() {
localTestAddressBookProvider.provide(account, provider, GroupMethod.GROUP_VCARDS) { ab ->
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 = ContactsBatchOperation(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() {
localTestAddressBookProvider.provide(account, provider, GroupMethod.CATEGORIES) { ab ->
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(Optional.empty(), 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() {
localTestAddressBookProvider.provide(account, provider, GroupMethod.CATEGORIES) { ab ->
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(Optional.empty(), 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() {
localTestAddressBookProvider.provide(account, provider, GroupMethod.CATEGORIES) { ab ->
val group = newGroup(ab)
val contact1 =
LocalContact(ab, Contact().apply { displayName = "Test" }, "fn.vcf", null, 0)
contact1.add()
val batch = ContactsBatchOperation(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() {
localTestAddressBookProvider.provide(account, provider, GroupMethod.CATEGORIES) { ab ->
val group = newGroup(ab)
assertNull(group.getContact().uid)
val fileName = group.prepareForUpload()
val newUid = group.getContact().uid
assertNotNull(newUid)
assertEquals("$newUid.vcf", fileName)
}
}
// helpers
private fun newGroup(addressBook: LocalAddressBook): LocalGroup =
LocalGroup(addressBook,
Contact().apply {
displayName = "Test Group"
}, null, null, 0
).apply {
add()
}
companion object {
@JvmField
@@ -272,8 +47,9 @@ class LocalGroupTest {
@BeforeClass
@JvmStatic
fun connect() {
val context = InstrumentationRegistry.getInstrumentation().context
val context = InstrumentationRegistry.getInstrumentation().targetContext
provider = context.contentResolver.acquireContentProviderClient(ContactsContract.AUTHORITY)!!
assertNotNull(provider)
}
@AfterClass
@@ -283,4 +59,223 @@ class LocalGroupTest {
}
}
@get:Rule
val hiltRule = HiltAndroidRule(this)
@Inject
@ApplicationContext
lateinit var context: Context
@Inject
lateinit var addressbookFactory: LocalTestAddressBook.Factory
private lateinit var addressBookGroupsAsCategories: LocalTestAddressBook
private lateinit var addressBookGroupsAsVCards: LocalTestAddressBook
@Before
fun setup() {
hiltRule.inject()
addressBookGroupsAsCategories = addressbookFactory.create(provider, GroupMethod.CATEGORIES)
addressBookGroupsAsVCards = addressbookFactory.create(provider, GroupMethod.GROUP_VCARDS)
// clear contacts
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)
}
// helpers
private fun newGroup(addressBook: LocalAddressBook = addressBookGroupsAsCategories): LocalGroup =
LocalGroup(addressBook,
Contact().apply {
displayName = "Test Group"
}, null, null, 0
).apply {
add()
}
}

View File

@@ -5,55 +5,93 @@
package at.bitfire.davdroid.resource
import android.accounts.Account
import android.accounts.AccountManager
import android.content.ContentProviderClient
import android.content.ContentUris
import android.content.Context
import android.provider.ContactsContract
import at.bitfire.davdroid.repository.DavCollectionRepository
import at.bitfire.davdroid.repository.DavServiceRepository
import at.bitfire.davdroid.settings.AccountSettings
import at.bitfire.davdroid.sync.adapter.SyncFrameworkIntegration
import at.bitfire.vcard4android.GroupMethod
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
import dagger.hilt.android.qualifiers.ApplicationContext
import java.util.Optional
import org.junit.Assert.assertTrue
import java.io.FileNotFoundException
import java.util.logging.Logger
/**
* A local address book that provides an easy way to set the group method in tests.
*/
class LocalTestAddressBook @AssistedInject constructor(
@Assisted account: Account,
@Assisted("addressBook") addressBookAccount: Account,
@Assisted provider: ContentProviderClient,
@Assisted override val groupMethod: GroupMethod,
accountSettingsFactory: AccountSettings.Factory,
collectionRepository: DavCollectionRepository,
@ApplicationContext context: Context,
logger: Logger,
serviceRepository: DavServiceRepository,
syncFramework: SyncFrameworkIntegration
): LocalAddressBook(
account = account,
_addressBookAccount = addressBookAccount,
provider = provider,
accountSettingsFactory = accountSettingsFactory,
collectionRepository = collectionRepository,
context = context,
dirtyVerifier = Optional.empty(),
logger = logger,
serviceRepository = serviceRepository,
syncFramework = syncFramework
) {
serviceRepository: DavServiceRepository
): LocalAddressBook(ACCOUNT, provider, accountSettingsFactory, collectionRepository, context, logger, serviceRepository) {
@AssistedFactory
interface Factory {
fun create(
account: Account,
@Assisted("addressBook") addressBookAccount: Account,
provider: ContentProviderClient,
groupMethod: GroupMethod
): LocalTestAddressBook
fun create(provider: ContentProviderClient, groupMethod: GroupMethod): LocalTestAddressBook
}
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()
}
/**
* Returns the dirty flag of the given contact.
*
* @return true if the contact is dirty, false otherwise
*
* @throws FileNotFoundException if the contact can't be found
*/
fun isContactDirty(id: Long): Boolean {
val uri = ContentUris.withAppendedId(rawContactsSyncUri(), id)
provider!!.query(uri, arrayOf(ContactsContract.RawContacts.DIRTY), null, null, null)?.use { cursor ->
if (cursor.moveToFirst())
return cursor.getInt(0) != 0
}
throw FileNotFoundException()
}
/**
* Returns the dirty flag of the given contact group.
*
* @return true if the group is dirty, false otherwise
*
* @throws FileNotFoundException if the group can't be found
*/
fun isGroupDirty(id: Long): Boolean {
val uri = ContentUris.withAppendedId(groupsSyncUri(), id)
provider!!.query(uri, arrayOf(ContactsContract.Groups.DIRTY), null, null, null)?.use { cursor ->
if (cursor.moveToFirst())
return cursor.getInt(0) != 0
}
throw FileNotFoundException()
}
companion object {
val ACCOUNT = Account("LocalTestAddressBook", "at.bitfire.davdroid.test")
fun createAccount(context: Context) {
val am = AccountManager.get(context)
assertTrue("Couldn't create account for local test address-book", am.addAccountExplicitly(ACCOUNT, null, null))
}
}
}

View File

@@ -1,72 +0,0 @@
/*
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
*/
package at.bitfire.davdroid.resource
import android.accounts.Account
import android.accounts.AccountManager
import android.content.ContentProviderClient
import android.content.Context
import at.bitfire.davdroid.R
import at.bitfire.vcard4android.GroupMethod
import dagger.hilt.android.qualifiers.ApplicationContext
import org.junit.Assert.assertTrue
import java.util.concurrent.atomic.AtomicInteger
import javax.inject.Inject
/**
* Provides [LocalTestAddressBook]s in tests.
*/
class LocalTestAddressBookProvider @Inject constructor(
@ApplicationContext context: Context,
private val localTestAddressBookFactory: LocalTestAddressBook.Factory
) {
/**
* Counter for creating unique address book names.
*/
val counter = AtomicInteger()
val accountManager = AccountManager.get(context)
val accountType = context.getString(R.string.account_type_address_book)
/**
* Creates and provides a new temporary [LocalTestAddressBook] for the given [account] and
* removes it again.
*
* @param account The DAVx5 account to use for the address book
* @param provider Content provider needed to access and modify the address book
* @param groupMethod The group method the address book should use
* @param block Function to execute with the temporary available address book
*/
fun provide(
account: Account,
provider: ContentProviderClient,
groupMethod: GroupMethod = GroupMethod.GROUP_VCARDS,
block: (LocalTestAddressBook) -> Unit
) {
// create new address book account
val addressBookAccount = Account("Test Address Book ${counter.incrementAndGet()}", accountType)
assertTrue(accountManager.addAccountExplicitly(addressBookAccount, null, null))
val addressBook = localTestAddressBookFactory.create(account, addressBookAccount, provider, groupMethod)
// Empty the address book (Needed by LocalGroupTest)
for (contact in addressBook.queryContacts(null, null))
contact.delete()
for (group in addressBook.queryGroups(null, null))
group.delete()
try {
// provide address book
block(addressBook)
} finally {
// recreate account of provided address book, since the account might have been renamed
val renamedAccount = Account(addressBook.addressBookAccount.name, addressBook.addressBookAccount.type)
// remove address book account / address book
assertTrue(accountManager.removeAccountExplicitly(renamedAccount))
}
}
}

View File

@@ -5,7 +5,6 @@
package at.bitfire.davdroid.resource.contactrow
import android.Manifest
import android.accounts.Account
import android.content.ContentProviderClient
import android.content.ContentValues
import android.content.Context
@@ -13,7 +12,7 @@ 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.LocalTestAddressBookProvider
import at.bitfire.davdroid.resource.LocalTestAddressBook
import at.bitfire.vcard4android.CachedGroupMembership
import at.bitfire.vcard4android.Contact
import at.bitfire.vcard4android.GroupMethod
@@ -32,38 +31,6 @@ import javax.inject.Inject
@HiltAndroidTest
class CachedGroupMembershipHandlerTest {
@Inject
@ApplicationContext
lateinit var context: Context
@Inject
lateinit var localTestAddressBookProvider: LocalTestAddressBookProvider
@get:Rule
val hiltRule = HiltAndroidRule(this)
val account = Account("Test Account", "Test Account Type")
@Before
fun inject() {
hiltRule.inject()
}
@Test
fun testMembership() {
localTestAddressBookProvider.provide(account, provider, GroupMethod.GROUP_VCARDS) { addressBook ->
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())
}
}
companion object {
@JvmField
@@ -87,4 +54,34 @@ class CachedGroupMembershipHandlerTest {
}
@Inject
lateinit var addressbookFactory: LocalTestAddressBook.Factory
@Inject
@ApplicationContext
lateinit var context: Context
@get:Rule
val hiltRule = HiltAndroidRule(this)
@Before
fun inject() {
hiltRule.inject()
}
@Test
fun testMembership() {
val addressBook = addressbookFactory.create(provider, GroupMethod.GROUP_VCARDS)
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

@@ -5,7 +5,6 @@
package at.bitfire.davdroid.resource.contactrow
import android.Manifest
import android.accounts.Account
import android.content.ContentProviderClient
import android.content.Context
import android.net.Uri
@@ -13,7 +12,7 @@ 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.LocalTestAddressBookProvider
import at.bitfire.davdroid.resource.LocalTestAddressBook
import at.bitfire.vcard4android.Contact
import at.bitfire.vcard4android.GroupMethod
import dagger.hilt.android.qualifiers.ApplicationContext
@@ -31,17 +30,38 @@ import javax.inject.Inject
@HiltAndroidTest
class GroupMembershipBuilderTest {
@Inject @ApplicationContext
lateinit var context: Context
companion object {
@JvmField
@ClassRule
val permissionRule = GrantPermissionRule.grant(Manifest.permission.READ_CONTACTS, Manifest.permission.WRITE_CONTACTS)!!
private lateinit var provider: ContentProviderClient
@BeforeClass
@JvmStatic
fun connect() {
val context: Context = InstrumentationRegistry.getInstrumentation().targetContext
provider = context.contentResolver.acquireContentProviderClient(ContactsContract.AUTHORITY)!!
}
@AfterClass
@JvmStatic
fun disconnect() {
provider.close()
}
}
@Inject
lateinit var localTestAddressBookProvider: LocalTestAddressBookProvider
@get:Rule
val hiltRule = HiltAndroidRule(this)
val account = Account("Test Account", "Test Account Type")
@Inject
lateinit var addressbookFactory: LocalTestAddressBook.Factory
@Inject
@ApplicationContext
lateinit var context: Context
@Before
fun inject() {
@@ -54,12 +74,11 @@ class GroupMembershipBuilderTest {
val contact = Contact().apply {
categories += "TEST GROUP"
}
localTestAddressBookProvider.provide(account, provider, GroupMethod.CATEGORIES) { addressBookGroupsAsCategories ->
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])
}
val addressBookGroupsAsCategories = addressbookFactory.create(provider, GroupMethod.CATEGORIES)
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])
}
}
@@ -68,36 +87,11 @@ class GroupMembershipBuilderTest {
val contact = Contact().apply {
categories += "TEST GROUP"
}
localTestAddressBookProvider.provide(account, provider, GroupMethod.GROUP_VCARDS) { addressBookGroupsAsVCards ->
GroupMembershipBuilder(Uri.EMPTY, null, contact, addressBookGroupsAsVCards, false).build().also { result ->
// group membership is constructed during post-processing
assertEquals(0, result.size)
}
val addressBookGroupsAsVCards = addressbookFactory.create(provider, GroupMethod.GROUP_VCARDS)
GroupMembershipBuilder(Uri.EMPTY, null, contact, addressBookGroupsAsVCards, false).build().also { result ->
// group membership is constructed during post-processing
assertEquals(0, result.size)
}
}
companion object {
@JvmField
@ClassRule
val permissionRule = GrantPermissionRule.grant(Manifest.permission.READ_CONTACTS, Manifest.permission.WRITE_CONTACTS)!!
private lateinit var provider: ContentProviderClient
@BeforeClass
@JvmStatic
fun connect() {
val context: Context = InstrumentationRegistry.getInstrumentation().context
provider = context.contentResolver.acquireContentProviderClient(ContactsContract.AUTHORITY)!!
}
@AfterClass
@JvmStatic
fun disconnect() {
provider.close()
}
}
}

View File

@@ -5,7 +5,6 @@
package at.bitfire.davdroid.resource.contactrow
import android.Manifest
import android.accounts.Account
import android.content.ContentProviderClient
import android.content.ContentValues
import android.content.Context
@@ -13,7 +12,7 @@ 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.LocalTestAddressBookProvider
import at.bitfire.davdroid.resource.LocalTestAddressBook
import at.bitfire.vcard4android.CachedGroupMembership
import at.bitfire.vcard4android.Contact
import at.bitfire.vcard4android.GroupMethod
@@ -34,55 +33,6 @@ import javax.inject.Inject
@HiltAndroidTest
class GroupMembershipHandlerTest {
@Inject @ApplicationContext
lateinit var context: Context
@Inject
lateinit var localTestAddressBookProvider: LocalTestAddressBookProvider
@get:Rule
var hiltRule = HiltAndroidRule(this)
val account = Account("Test Account", "Test Account Type")
@Before
fun inject() {
hiltRule.inject()
}
@Test
fun testMembership_GroupsAsCategories() {
localTestAddressBookProvider.provide(account, provider, GroupMethod.CATEGORIES) { addressBookGroupsAsCategories ->
val addressBookGroupsAsCategoriesGroup = addressBookGroupsAsCategories.findOrCreateGroup("TEST GROUP")
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() {
localTestAddressBookProvider.provide(account, provider, GroupMethod.GROUP_VCARDS) { addressBookGroupsAsVCards ->
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())
}
}
companion object {
@JvmField
@@ -107,4 +57,49 @@ class GroupMembershipHandlerTest {
}
@Inject
lateinit var addressbookFactory: LocalTestAddressBook.Factory
@Inject @ApplicationContext
lateinit var context: Context
@get:Rule
var hiltRule = HiltAndroidRule(this)
@Before
fun inject() {
hiltRule.inject()
}
@Test
fun testMembership_GroupsAsCategories() {
val addressBookGroupsAsCategories = addressbookFactory.create(provider, GroupMethod.CATEGORIES)
val addressBookGroupsAsCategoriesGroup = addressBookGroupsAsCategories.findOrCreateGroup("TEST GROUP")
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 addressBookGroupsAsVCards = addressbookFactory.create(provider, GroupMethod.GROUP_VCARDS)
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,718 @@
/*
* 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 at.bitfire.davdroid.db.AppDatabase
import at.bitfire.davdroid.db.Collection
import at.bitfire.davdroid.db.HomeSet
import at.bitfire.davdroid.db.Principal
import at.bitfire.davdroid.db.Service
import at.bitfire.davdroid.network.HttpClient
import at.bitfire.davdroid.settings.Settings
import at.bitfire.davdroid.settings.SettingsManager
import dagger.hilt.android.qualifiers.ApplicationContext
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.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Assume
import org.junit.Before
import org.junit.Rule
import org.junit.Test
import java.util.logging.Logger
import javax.inject.Inject
@HiltAndroidTest
class CollectionListRefresherTest {
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"
}
@get:Rule
var hiltRule = HiltAndroidRule(this)
@Inject
@ApplicationContext
lateinit var context: Context
@Inject
lateinit var db: AppDatabase
@Inject
lateinit var logger: Logger
@Inject
lateinit var refresherFactory: CollectionListRefresher.Factory
@Inject
lateinit var settings: SettingsManager
private val mockServer = MockWebServer()
private lateinit var client: HttpClient
@Before
fun setup() {
hiltRule.inject()
// Start mock web server
mockServer.dispatcher = TestDispatcher(logger)
mockServer.start()
client = HttpClient.Builder(context).build()
Assume.assumeTrue(NetworkSecurityPolicy.getInstance().isCleartextTrafficPermitted)
}
@After
fun teardown() {
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(
private val logger: Logger
): Dispatcher() {
override fun dispatch(request: RecordedRequest): MockResponse {
val path = request.path!!.trimEnd('/')
if (request.method.equals("PROPFIND", true)) {
val properties = when (path) {
PATH_CALDAV,
PATH_CARDDAV ->
"<current-user-principal>" +
" <href>$path${SUBPATH_PRINCIPAL}</href>" +
"</current-user-principal>"
PATH_CARDDAV + SUBPATH_PRINCIPAL ->
"<resourcetype><principal/></resourcetype>" +
"<displayname>Mr. Wobbles</displayname>" +
"<CARD:addressbook-home-set>" +
" <href>${PATH_CARDDAV}${SUBPATH_ADDRESSBOOK_HOMESET}</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.info("Queried: $path")
logger.info("Response: $responseBody")
return MockResponse()
.setResponseCode(responseCode)
.setBody(responseBody)
}
return MockResponse().setResponseCode(404)
}
}
}

View File

@@ -1,222 +0,0 @@
/*
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
*/
package at.bitfire.davdroid.servicedetection
import android.security.NetworkSecurityPolicy
import at.bitfire.davdroid.db.AppDatabase
import at.bitfire.davdroid.db.Collection
import at.bitfire.davdroid.db.Service
import at.bitfire.davdroid.network.HttpClient
import at.bitfire.davdroid.settings.SettingsManager
import dagger.hilt.android.testing.BindValue
import dagger.hilt.android.testing.HiltAndroidRule
import dagger.hilt.android.testing.HiltAndroidTest
import io.mockk.impl.annotations.MockK
import io.mockk.junit4.MockKRule
import okhttp3.mockwebserver.Dispatcher
import okhttp3.mockwebserver.MockResponse
import okhttp3.mockwebserver.MockWebServer
import okhttp3.mockwebserver.RecordedRequest
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assume
import org.junit.Before
import org.junit.Rule
import org.junit.Test
import java.util.logging.Logger
import javax.inject.Inject
@HiltAndroidTest
class CollectionsWithoutHomeSetRefresherTest {
@get:Rule
val hiltRule = HiltAndroidRule(this)
@get:Rule
val mockKRule = MockKRule(this)
@Inject
lateinit var db: AppDatabase
@Inject
lateinit var httpClientBuilder: HttpClient.Builder
@Inject
lateinit var logger: Logger
@Inject
lateinit var refresherFactory: CollectionsWithoutHomeSetRefresher.Factory
@BindValue
@MockK(relaxed = true)
lateinit var settings: SettingsManager
private lateinit var client: HttpClient
private lateinit var mockServer: MockWebServer
private lateinit var service: Service
@Before
fun setUp() {
hiltRule.inject()
// Start mock web server
mockServer = MockWebServer().apply {
dispatcher = TestDispatcher(logger)
start()
}
// build HTTP client
client = httpClientBuilder.build()
Assume.assumeTrue(NetworkSecurityPolicy.getInstance().isCleartextTrafficPermitted)
// insert test service
val serviceId = db.serviceDao().insertOrReplace(
Service(id = 0, accountName = "test", type = Service.TYPE_CARDDAV, principal = null)
)
service = db.serviceDao().get(serviceId)!!
}
@After
fun tearDown() {
client.close()
mockServer.shutdown()
}
// refreshCollectionsWithoutHomeSet
@Test
fun refreshCollectionsWithoutHomeSet_updatesExistingCollection() {
// place homeless collection in DB
val collectionId = db.collectionDao().insertOrUpdateByUrl(
Collection(
0,
service.id,
null,
null,
Collection.TYPE_ADDRESSBOOK,
mockServer.url("$PATH_CARDDAV$SUBPATH_ADDRESSBOOK/"),
)
)
// Refresh
refresherFactory.create(service, client.okHttpClient).refreshCollectionsWithoutHomeSet()
// Check the collection got updated - with display name and description
assertEquals(
Collection(
collectionId,
service.id,
null,
1, // will have gotten an owner too
Collection.TYPE_ADDRESSBOOK,
mockServer.url("$PATH_CARDDAV$SUBPATH_ADDRESSBOOK/"),
displayName = "My Contacts",
description = "My Contacts Description"
),
db.collectionDao().get(collectionId)
)
}
@Test
fun refreshCollectionsWithoutHomeSet_deletesInaccessibleCollectionsWithoutHomeSet() {
// place homeless collection in DB - it is also inaccessible
val collectionId = db.collectionDao().insertOrUpdateByUrl(
Collection(
0,
service.id,
null,
null,
Collection.TYPE_ADDRESSBOOK,
mockServer.url("$PATH_CARDDAV$SUBPATH_ADDRESSBOOK_INACCESSIBLE")
)
)
// Refresh - should delete collection
refresherFactory.create(service, client.okHttpClient).refreshCollectionsWithoutHomeSet()
// Check the collection got deleted
assertEquals(null, db.collectionDao().get(collectionId))
}
@Test
fun refreshCollectionsWithoutHomeSet_addsOwnerUrls() {
// place homeless collection in DB
val collectionId = db.collectionDao().insertOrUpdateByUrl(
Collection(
0,
service.id,
null,
null,
Collection.TYPE_ADDRESSBOOK,
mockServer.url("$PATH_CARDDAV$SUBPATH_ADDRESSBOOK/"),
)
)
// Refresh homeless collections
assertEquals(0, db.principalDao().getByService(service.id).size)
refresherFactory.create(service, client.okHttpClient).refreshCollectionsWithoutHomeSet()
// Check principal saved and the collection was updated with its reference
val principals = db.principalDao().getByService(service.id)
assertEquals(1, principals.size)
assertEquals(mockServer.url("$PATH_CARDDAV$SUBPATH_PRINCIPAL"), principals[0].url)
assertEquals(null, principals[0].displayName)
assertEquals(
principals[0].id,
db.collectionDao().get(collectionId)!!.ownerId
)
}
companion object {
private const val PATH_CARDDAV = "/carddav"
private const val SUBPATH_PRINCIPAL = "/principal"
private const val SUBPATH_ADDRESSBOOK = "/addressbooks/my-contacts"
private const val SUBPATH_ADDRESSBOOK_INACCESSIBLE = "/addressbooks/inaccessible-contacts"
}
class TestDispatcher(
private val logger: Logger
): Dispatcher() {
override fun dispatch(request: RecordedRequest): MockResponse {
val path = request.path!!.trimEnd('/')
logger.info("${request.method} on $path")
if (request.method.equals("PROPFIND", true)) {
val properties = when (path) {
PATH_CARDDAV + SUBPATH_ADDRESSBOOK ->
"<resourcetype>" +
" <collection/>" +
" <CARD:addressbook/>" +
"</resourcetype>" +
"<displayname>My Contacts</displayname>" +
"<CARD:addressbook-description>My Contacts Description</CARD:addressbook-description>" +
"<owner>" +
" <href>${PATH_CARDDAV + SUBPATH_PRINCIPAL}</href>" +
"</owner>"
else -> ""
}
return MockResponse()
.setResponseCode(207)
.setBody("<multistatus xmlns='DAV:' xmlns:CARD='urn:ietf:params:xml:ns:carddav' xmlns:CAL='urn:ietf:params:xml:ns:caldav'>" +
"<response>" +
" <href>$path</href>" +
" <propstat><prop>"+
properties +
" </prop></propstat>" +
"</response>" +
"</multistatus>")
}
return MockResponse().setResponseCode(404)
}
}
}

View File

@@ -4,13 +4,17 @@
package at.bitfire.davdroid.servicedetection
import android.content.Context
import android.security.NetworkSecurityPolicy
import androidx.test.filters.SmallTest
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.network.HttpClient
import at.bitfire.davdroid.servicedetection.DavResourceFinder.Configuration.ServiceInfo
import at.bitfire.davdroid.settings.Credentials
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 okhttp3.mockwebserver.Dispatcher
@@ -49,7 +53,8 @@ class DavResourceFinderTest {
val hiltRule = HiltAndroidRule(this)
@Inject
lateinit var httpClientBuilder: HttpClient.Builder
@ApplicationContext
lateinit var context: Context
@Inject
lateinit var logger: Logger
@@ -57,37 +62,40 @@ class DavResourceFinderTest {
@Inject
lateinit var resourceFinderFactory: DavResourceFinder.Factory
private lateinit var server: MockWebServer
private lateinit var client: HttpClient
@Inject
lateinit var settingsManager: SettingsManager
private val server = MockWebServer()
private lateinit var finder: DavResourceFinder
private lateinit var client: HttpClient
@Before
fun setUp() {
fun setup() {
hiltRule.inject()
server = MockWebServer().apply {
dispatcher = TestDispatcher(logger)
start()
}
val credentials = Credentials(username = "mock", password = "12345".toCharArray())
client = httpClientBuilder
.authenticate(host = null, getCredentials = { credentials })
.build()
Assume.assumeTrue(NetworkSecurityPolicy.getInstance().isCleartextTrafficPermitted)
server.dispatcher = TestDispatcher(logger)
server.start()
val baseURI = URI.create("/")
val credentials = Credentials("mock", "12345")
finder = resourceFinderFactory.create(baseURI, credentials)
client = HttpClient.Builder(context)
.addAuthentication(null, credentials)
.build()
Assume.assumeTrue(NetworkSecurityPolicy.getInstance().isCleartextTrafficPermitted)
}
@After
fun tearDown() {
client.close()
fun teardown() {
server.shutdown()
}
@Test
@SmallTest
fun testRememberIfAddressBookOrHomeset() {
// recognize home set
var info = ServiceInfo()

View File

@@ -1,473 +0,0 @@
/*
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
*/
package at.bitfire.davdroid.servicedetection
import android.security.NetworkSecurityPolicy
import at.bitfire.davdroid.db.AppDatabase
import at.bitfire.davdroid.db.Collection
import at.bitfire.davdroid.db.HomeSet
import at.bitfire.davdroid.db.Service
import at.bitfire.davdroid.network.HttpClient
import at.bitfire.davdroid.settings.Settings
import at.bitfire.davdroid.settings.SettingsManager
import dagger.hilt.android.testing.BindValue
import dagger.hilt.android.testing.HiltAndroidRule
import dagger.hilt.android.testing.HiltAndroidTest
import io.mockk.every
import io.mockk.impl.annotations.MockK
import io.mockk.junit4.MockKRule
import junit.framework.TestCase.assertFalse
import junit.framework.TestCase.assertTrue
import kotlinx.coroutines.test.runTest
import okhttp3.mockwebserver.Dispatcher
import okhttp3.mockwebserver.MockResponse
import okhttp3.mockwebserver.MockWebServer
import okhttp3.mockwebserver.RecordedRequest
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assume
import org.junit.Before
import org.junit.Rule
import org.junit.Test
import java.util.logging.Logger
import javax.inject.Inject
@HiltAndroidTest
class HomeSetRefresherTest {
@get:Rule
val hiltRule = HiltAndroidRule(this)
@get:Rule
val mockKRule = MockKRule(this)
@Inject
lateinit var db: AppDatabase
@Inject
lateinit var httpClientBuilder: HttpClient.Builder
@Inject
lateinit var logger: Logger
@Inject
lateinit var homeSetRefresherFactory: HomeSetRefresher.Factory
@BindValue
@MockK(relaxed = true)
lateinit var settings: SettingsManager
private lateinit var client: HttpClient
private lateinit var mockServer: MockWebServer
private lateinit var service: Service
@Before
fun setUp() {
hiltRule.inject()
// Start mock web server
mockServer = MockWebServer().apply {
dispatcher = TestDispatcher(logger)
start()
}
// build HTTP client
client = httpClientBuilder.build()
Assume.assumeTrue(NetworkSecurityPolicy.getInstance().isCleartextTrafficPermitted)
// insert test service
val serviceId = db.serviceDao().insertOrReplace(
Service(id = 0, accountName = "test", type = Service.TYPE_CARDDAV, principal = null)
)
service = db.serviceDao().get(serviceId)!!
}
@After
fun tearDown() {
client.close()
mockServer.shutdown()
}
// refreshHomesetsAndTheirCollections
@Test
fun refreshHomesetsAndTheirCollections_addsNewCollection() = runTest {
// save homeset in DB
val homesetId = db.homeSetDao().insert(
HomeSet(id = 0, service.id, true, mockServer.url("$PATH_CARDDAV$SUBPATH_ADDRESSBOOK_HOMESET_PERSONAL"))
)
// Refresh
homeSetRefresherFactory.create(service, client.okHttpClient)
.refreshHomesetsAndTheirCollections()
// Check the collection defined in homeset is now in the database
assertEquals(
Collection(
1,
service.id,
homesetId,
1, // will have gotten an owner too
Collection.TYPE_ADDRESSBOOK,
mockServer.url("$PATH_CARDDAV$SUBPATH_ADDRESSBOOK/"),
displayName = "My Contacts",
description = "My Contacts Description"
),
db.collectionDao().getByService(service.id).first()
)
}
@Test
fun refreshHomesetsAndTheirCollections_updatesExistingCollection() {
// save "old" collection in DB
val collectionId = db.collectionDao().insertOrUpdateByUrl(
Collection(
0,
service.id,
null,
null,
Collection.TYPE_ADDRESSBOOK,
mockServer.url("$PATH_CARDDAV$SUBPATH_ADDRESSBOOK/"),
displayName = "My Contacts",
description = "My Contacts Description"
)
)
// Refresh
homeSetRefresherFactory.create(service, client.okHttpClient).refreshHomesetsAndTheirCollections()
// Check the collection got updated
assertEquals(
Collection(
collectionId,
service.id,
null,
null,
Collection.TYPE_ADDRESSBOOK,
mockServer.url("$PATH_CARDDAV$SUBPATH_ADDRESSBOOK/"),
displayName = "My Contacts",
description = "My Contacts Description"
),
db.collectionDao().get(collectionId)
)
}
@Test
fun refreshHomesetsAndTheirCollections_preservesCollectionFlags() {
// save "old" collection in DB - with set flags
val collectionId = db.collectionDao().insertOrUpdateByUrl(
Collection(
0,
service.id,
null,
null,
Collection.TYPE_ADDRESSBOOK,
mockServer.url("$PATH_CARDDAV$SUBPATH_ADDRESSBOOK/"),
displayName = "My Contacts",
description = "My Contacts Description",
forceReadOnly = true,
sync = true
)
)
// Refresh
homeSetRefresherFactory.create(service, client.okHttpClient).refreshHomesetsAndTheirCollections()
// Check the collection got updated
assertEquals(
Collection(
collectionId,
service.id,
null,
null,
Collection.TYPE_ADDRESSBOOK,
mockServer.url("$PATH_CARDDAV$SUBPATH_ADDRESSBOOK/"),
displayName = "My Contacts",
description = "My Contacts Description",
forceReadOnly = true,
sync = true
),
db.collectionDao().get(collectionId)
)
}
@Test
fun refreshHomesetsAndTheirCollections_marksRemovedCollectionsAsHomeless() {
// save homeset in DB - which is empty (zero address books) on the serverside
val homesetId = db.homeSetDao().insert(
HomeSet(id = 0, service.id, true, mockServer.url("$PATH_CARDDAV$SUBPATH_ADDRESSBOOK_HOMESET_EMPTY"))
)
// place collection in DB - as part of the homeset
val collectionId = db.collectionDao().insertOrUpdateByUrl(
Collection(
0,
service.id,
homesetId,
null,
Collection.TYPE_ADDRESSBOOK,
mockServer.url("$PATH_CARDDAV$SUBPATH_ADDRESSBOOK/")
)
)
// Refresh - should mark collection as homeless, because serverside homeset is empty.
homeSetRefresherFactory.create(service, client.okHttpClient).refreshHomesetsAndTheirCollections()
// Check the collection, is now marked as homeless
assertEquals(null, db.collectionDao().get(collectionId)!!.homeSetId)
}
@Test
fun refreshHomesetsAndTheirCollections_addsOwnerUrls() {
// save a homeset in DB
val homesetId = db.homeSetDao().insert(
HomeSet(id = 0, service.id, true, mockServer.url("$PATH_CARDDAV$SUBPATH_ADDRESSBOOK_HOMESET_PERSONAL"))
)
// place collection in DB - as part of the homeset
val collectionId = db.collectionDao().insertOrUpdateByUrl(
Collection(
0,
service.id,
homesetId, // part of above home set
null,
Collection.TYPE_ADDRESSBOOK,
mockServer.url("$PATH_CARDDAV$SUBPATH_ADDRESSBOOK/")
)
)
// Refresh - homesets and their collections
assertEquals(0, db.principalDao().getByService(service.id).size)
homeSetRefresherFactory.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
)
}
// other
@Test
fun shouldPreselect_none() {
every { settings.getIntOrNull(Settings.PRESELECT_COLLECTIONS) } returns Settings.PRESELECT_COLLECTIONS_NONE
every { settings.getString(Settings.PRESELECT_COLLECTIONS_EXCLUDED) } returns ""
val collection = Collection(
0,
service.id,
0,
type = Collection.TYPE_ADDRESSBOOK,
url = mockServer.url("/addressbook-homeset/addressbook/")
)
val homesets = listOf(
HomeSet(
id = 0,
serviceId = service.id,
personal = true,
url = mockServer.url("/addressbook-homeset/")
)
)
val refresher = homeSetRefresherFactory.create(service, client.okHttpClient)
assertFalse(refresher.shouldPreselect(collection, homesets))
}
@Test
fun shouldPreselect_all() {
every { settings.getIntOrNull(Settings.PRESELECT_COLLECTIONS) } returns Settings.PRESELECT_COLLECTIONS_ALL
every { settings.getString(Settings.PRESELECT_COLLECTIONS_EXCLUDED) } returns ""
val collection = Collection(
0,
service.id,
0,
type = Collection.TYPE_ADDRESSBOOK,
url = mockServer.url("/addressbook-homeset/addressbook/")
)
val homesets = listOf(
HomeSet(
id = 0,
serviceId = service.id,
personal = false,
url = mockServer.url("/addressbook-homeset/")
)
)
val refresher = homeSetRefresherFactory.create(service, client.okHttpClient)
assertTrue(refresher.shouldPreselect(collection, homesets))
}
@Test
fun shouldPreselect_all_blacklisted() {
val url = mockServer.url("/addressbook-homeset/addressbook/")
every { settings.getIntOrNull(Settings.PRESELECT_COLLECTIONS) } returns Settings.PRESELECT_COLLECTIONS_ALL
every { settings.getString(Settings.PRESELECT_COLLECTIONS_EXCLUDED) } returns url.toString()
val collection = Collection(
id = 0,
serviceId = service.id,
homeSetId = 0,
type = Collection.TYPE_ADDRESSBOOK,
url = url
)
val homesets = listOf(
HomeSet(
id = 0,
serviceId = service.id,
personal = false,
url = mockServer.url("/addressbook-homeset/")
)
)
val refresher = homeSetRefresherFactory.create(service, client.okHttpClient)
assertFalse(refresher.shouldPreselect(collection, homesets))
}
@Test
fun shouldPreselect_personal_notPersonal() {
every { settings.getIntOrNull(Settings.PRESELECT_COLLECTIONS) } returns Settings.PRESELECT_COLLECTIONS_PERSONAL
every { settings.getString(Settings.PRESELECT_COLLECTIONS_EXCLUDED) } returns ""
val collection = Collection(
id = 0,
serviceId = service.id,
homeSetId = 0,
type = Collection.TYPE_ADDRESSBOOK,
url = mockServer.url("/addressbook-homeset/addressbook/")
)
val homesets = listOf(
HomeSet(
id = 0,
serviceId = service.id,
personal = false,
url = mockServer.url("/addressbook-homeset/")
)
)
val refresher = homeSetRefresherFactory.create(service, client.okHttpClient)
assertFalse(refresher.shouldPreselect(collection, homesets))
}
@Test
fun shouldPreselect_personal_isPersonal() {
every { settings.getIntOrNull(Settings.PRESELECT_COLLECTIONS) } returns Settings.PRESELECT_COLLECTIONS_PERSONAL
every { settings.getString(Settings.PRESELECT_COLLECTIONS_EXCLUDED) } returns ""
val collection = Collection(
0,
service.id,
0,
type = Collection.TYPE_ADDRESSBOOK,
url = mockServer.url("/addressbook-homeset/addressbook/")
)
val homesets = listOf(
HomeSet(
id = 0,
serviceId = service.id,
personal = true,
url = mockServer.url("/addressbook-homeset/")
)
)
val refresher = homeSetRefresherFactory.create(service, client.okHttpClient)
assertTrue(refresher.shouldPreselect(collection, homesets))
}
@Test
fun shouldPreselect_personal_isPersonalButBlacklisted() {
val collectionUrl = mockServer.url("/addressbook-homeset/addressbook/")
every { settings.getIntOrNull(Settings.PRESELECT_COLLECTIONS) } returns Settings.PRESELECT_COLLECTIONS_PERSONAL
every { settings.getString(Settings.PRESELECT_COLLECTIONS_EXCLUDED) } returns collectionUrl.toString()
val collection = Collection(
id = 0,
serviceId = service.id,
homeSetId = 0,
type = Collection.TYPE_ADDRESSBOOK,
url = collectionUrl
)
val homesets = listOf(
HomeSet(
id = 0,
serviceId = service.id,
personal = true,
url = mockServer.url("/addressbook-homeset/")
)
)
val refresher = homeSetRefresherFactory.create(service, client.okHttpClient)
assertFalse(refresher.shouldPreselect(collection, homesets))
}
companion object {
private const val PATH_CARDDAV = "/carddav"
private const val SUBPATH_PRINCIPAL = "/principal"
private const val SUBPATH_ADDRESSBOOK_HOMESET_PERSONAL = "/addressbooks-homeset"
private const val SUBPATH_ADDRESSBOOK_HOMESET_EMPTY = "/addressbooks-homeset-empty"
private const val SUBPATH_ADDRESSBOOK = "/addressbooks/my-contacts"
}
class TestDispatcher(
private val logger: Logger
) : Dispatcher() {
override fun dispatch(request: RecordedRequest): MockResponse {
val path = request.path!!.trimEnd('/')
if (request.method.equals("PROPFIND", true)) {
val properties = when (path) {
PATH_CARDDAV + SUBPATH_ADDRESSBOOK_HOMESET_PERSONAL ->
"<resourcetype>" +
" <collection/>" +
" <CARD:addressbook/>" +
"</resourcetype>" +
"<displayname>My Contacts</displayname>" +
"<CARD:addressbook-description>My Contacts Description</CARD:addressbook-description>" +
"<owner>" +
" <href>${PATH_CARDDAV + SUBPATH_PRINCIPAL}</href>" +
"</owner>"
SUBPATH_ADDRESSBOOK_HOMESET_EMPTY -> ""
else -> ""
}
logger.info("Queried: $path")
return MockResponse()
.setResponseCode(207)
.setBody(
"<multistatus xmlns='DAV:' xmlns:CARD='urn:ietf:params:xml:ns:carddav' xmlns:CAL='urn:ietf:params:xml:ns:caldav'>" +
"<response>" +
" <href>${PATH_CARDDAV + SUBPATH_ADDRESSBOOK}</href>" +
" <propstat><prop>" +
properties +
" </prop></propstat>" +
" <status>HTTP/1.1 200 OK</status>" +
"</response>" +
"</multistatus>"
)
}
return MockResponse().setResponseCode(404)
}
}
}

View File

@@ -1,236 +0,0 @@
/*
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
*/
package at.bitfire.davdroid.servicedetection
import android.security.NetworkSecurityPolicy
import at.bitfire.davdroid.db.AppDatabase
import at.bitfire.davdroid.db.Collection
import at.bitfire.davdroid.db.Principal
import at.bitfire.davdroid.db.Service
import at.bitfire.davdroid.network.HttpClient
import at.bitfire.davdroid.settings.SettingsManager
import dagger.hilt.android.testing.BindValue
import dagger.hilt.android.testing.HiltAndroidRule
import dagger.hilt.android.testing.HiltAndroidTest
import io.mockk.impl.annotations.MockK
import io.mockk.junit4.MockKRule
import junit.framework.TestCase.assertEquals
import okhttp3.mockwebserver.Dispatcher
import okhttp3.mockwebserver.MockResponse
import okhttp3.mockwebserver.MockWebServer
import okhttp3.mockwebserver.RecordedRequest
import org.junit.After
import org.junit.Assume
import org.junit.Before
import org.junit.Rule
import org.junit.Test
import java.util.logging.Logger
import javax.inject.Inject
@HiltAndroidTest
class PrincipalsRefresherTest {
@Inject
lateinit var db: AppDatabase
@Inject
lateinit var httpClientBuilder: HttpClient.Builder
@Inject
lateinit var logger: Logger
@Inject
lateinit var principalsRefresher: PrincipalsRefresher.Factory
@BindValue
@MockK(relaxed = true)
lateinit var settings: SettingsManager
@get:Rule
val hiltRule = HiltAndroidRule(this)
@get:Rule
val mockKRule = MockKRule(this)
private lateinit var client: HttpClient
private lateinit var mockServer: MockWebServer
private lateinit var service: Service
@Before
fun setUp() {
hiltRule.inject()
// Start mock web server
mockServer = MockWebServer().apply {
dispatcher = TestDispatcher(logger)
start()
}
// build HTTP client
client = httpClientBuilder.build()
Assume.assumeTrue(NetworkSecurityPolicy.getInstance().isCleartextTrafficPermitted)
// insert test service
val serviceId = db.serviceDao().insertOrReplace(
Service(id = 0, accountName = "test", type = Service.TYPE_CARDDAV, principal = null)
)
service = db.serviceDao().get(serviceId)!!
}
@After
fun tearDown() {
client.close()
mockServer.shutdown()
}
@Test
fun refreshPrincipals_inaccessiblePrincipal() {
// place principal without display name in db
val principalId = db.principalDao().insert(
Principal(
0,
service.id,
mockServer.url("$PATH_CARDDAV$SUBPATH_PRINCIPAL_INACCESSIBLE"), // no trailing slash
null // no display name for now
)
)
// add an associated collection - as the principal is rightfully removed otherwise
db.collectionDao().insertOrUpdateByUrl(
Collection(
0,
service.id,
null,
principalId, // create association with principal
Collection.TYPE_ADDRESSBOOK,
mockServer.url("$PATH_CARDDAV$SUBPATH_ADDRESSBOOK/"), // with trailing slash
)
)
// Refresh principals
principalsRefresher.create(service, client.okHttpClient).refreshPrincipals()
// Check principal was not updated
val principals = db.principalDao().getByService(service.id)
assertEquals(1, principals.size)
assertEquals(mockServer.url("$PATH_CARDDAV$SUBPATH_PRINCIPAL_INACCESSIBLE"), principals[0].url)
assertEquals(null, principals[0].displayName)
}
@Test
fun refreshPrincipals_updatesPrincipal() {
// place principal without display name in db
val principalId = db.principalDao().insert(
Principal(
0,
service.id,
mockServer.url("$PATH_CARDDAV$SUBPATH_PRINCIPAL"), // no trailing slash
null // no display name for now
)
)
// add an associated collection - as the principal is rightfully removed otherwise
db.collectionDao().insertOrUpdateByUrl(
Collection(
0,
service.id,
null,
principalId, // create association with principal
Collection.TYPE_ADDRESSBOOK,
mockServer.url("$PATH_CARDDAV$SUBPATH_ADDRESSBOOK/"), // with trailing slash
)
)
// Refresh principals
principalsRefresher.create(service, client.okHttpClient).refreshPrincipals()
// Check principal now got a display name
val principals = db.principalDao().getByService(service.id)
assertEquals(1, principals.size)
assertEquals(mockServer.url("$PATH_CARDDAV$SUBPATH_PRINCIPAL"), principals[0].url)
assertEquals("Mr. Wobbles", principals[0].displayName)
}
@Test
fun refreshPrincipals_deletesPrincipalsWithoutCollections() {
// place principal without collections in DB
db.principalDao().insert(
Principal(
0,
service.id,
mockServer.url("$PATH_CARDDAV$SUBPATH_PRINCIPAL_WITHOUT_COLLECTIONS/")
)
)
// Refresh principals - detecting it does not own collections
principalsRefresher.create(service, client.okHttpClient).refreshPrincipals()
// Check principal was deleted
val principals = db.principalDao().getByService(service.id)
assertEquals(0, principals.size)
}
companion object {
private const val PATH_CARDDAV = "/carddav"
private const val SUBPATH_PRINCIPAL = "/principal"
private const val SUBPATH_PRINCIPAL_INACCESSIBLE = "/inaccessible-principal"
private const val SUBPATH_PRINCIPAL_WITHOUT_COLLECTIONS = "/principal2"
private const val SUBPATH_GROUPPRINCIPAL_0 = "/groups/0"
private const val SUBPATH_ADDRESSBOOK_HOMESET_PERSONAL = "/addressbooks-homeset"
private const val SUBPATH_ADDRESSBOOK_HOMESET_EMPTY = "/addressbooks-homeset-empty"
private const val SUBPATH_ADDRESSBOOK = "/addressbooks/my-contacts"
}
class TestDispatcher(
private val logger: Logger
) : Dispatcher() {
override fun dispatch(request: RecordedRequest): MockResponse {
val path = request.path!!.trimEnd('/')
if (request.method.equals("PROPFIND", true)) {
val properties = when (path) {
PATH_CARDDAV + SUBPATH_PRINCIPAL ->
"<resourcetype><principal/></resourcetype>" +
"<displayname>Mr. Wobbles</displayname>" + "<CARD:addressbook-home-set>" + " <href>${PATH_CARDDAV}${SUBPATH_ADDRESSBOOK_HOMESET_PERSONAL}</href>" + "</CARD:addressbook-home-set>" + "<group-membership>" + " <href>${PATH_CARDDAV}${SUBPATH_GROUPPRINCIPAL_0}</href>" +
"</group-membership>"
PATH_CARDDAV + SUBPATH_PRINCIPAL_WITHOUT_COLLECTIONS ->
"<CARD:addressbook-home-set>" +
" <href>${PATH_CARDDAV}${SUBPATH_ADDRESSBOOK_HOMESET_EMPTY}</href>" +
"</CARD:addressbook-home-set>" +
"<displayname>Mr. Wobbles Jr.</displayname>"
SUBPATH_ADDRESSBOOK_HOMESET_EMPTY -> ""
else -> ""
}
logger.info("Queried: $path")
return MockResponse()
.setResponseCode(207)
.setBody(
"<multistatus xmlns='DAV:' xmlns:CARD='urn:ietf:params:xml:ns:carddav' xmlns:CAL='urn:ietf:params:xml:ns:caldav'>" +
"<response>" +
" <href>$path</href>" +
" <propstat><prop>" +
properties +
" </prop></propstat>" +
"</response>" +
"</multistatus>"
)
}
return MockResponse().setResponseCode(404)
}
}
}

View File

@@ -1,163 +0,0 @@
/*
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
*/
package at.bitfire.davdroid.servicedetection
import android.security.NetworkSecurityPolicy
import at.bitfire.davdroid.db.AppDatabase
import at.bitfire.davdroid.db.Service
import at.bitfire.davdroid.network.HttpClient
import dagger.hilt.android.testing.HiltAndroidRule
import dagger.hilt.android.testing.HiltAndroidTest
import okhttp3.mockwebserver.Dispatcher
import okhttp3.mockwebserver.MockResponse
import okhttp3.mockwebserver.MockWebServer
import okhttp3.mockwebserver.RecordedRequest
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assume
import org.junit.Before
import org.junit.Rule
import org.junit.Test
import java.util.logging.Logger
import javax.inject.Inject
@HiltAndroidTest
class ServiceRefresherTest {
@get:Rule
val hiltRule = HiltAndroidRule(this)
@Inject
lateinit var db: AppDatabase
@Inject
lateinit var httpClientBuilder: HttpClient.Builder
@Inject
lateinit var logger: Logger
@Inject
lateinit var serviceRefresherFactory: ServiceRefresher.Factory
private lateinit var client: HttpClient
private lateinit var mockServer: MockWebServer
private lateinit var service: Service
@Before
fun setUp() {
hiltRule.inject()
// Start mock web server
mockServer = MockWebServer().apply {
dispatcher = TestDispatcher(logger)
start()
}
// build HTTP client
client = httpClientBuilder.build()
Assume.assumeTrue(NetworkSecurityPolicy.getInstance().isCleartextTrafficPermitted)
// insert test service
val serviceId = db.serviceDao().insertOrReplace(
Service(id = 0, accountName = "test", type = Service.TYPE_CARDDAV, principal = null)
)
service = db.serviceDao().get(serviceId)!!
}
@After
fun tearDown() {
client.close()
mockServer.shutdown()
}
@Test
fun testDiscoverHomesets() {
val baseUrl = mockServer.url(PATH_CARDDAV + SUBPATH_PRINCIPAL)
// Query home sets
serviceRefresherFactory.create(service, client.okHttpClient)
.discoverHomesets(baseUrl)
// Check home set has been saved correctly to database
val savedHomesets = db.homeSetDao().getByService(service.id)
assertEquals(2, savedHomesets.size)
// Home set from current-user-principal
val personalHomeset = savedHomesets[1]
assertEquals(mockServer.url("$PATH_CARDDAV$SUBPATH_ADDRESSBOOK_HOMESET_PERSONAL/"), personalHomeset.url)
assertEquals(service.id, personalHomeset.serviceId)
// personal should be true for homesets detected at first query of current-user-principal (Even if they occur in a group principal as well!!!)
assertEquals(true, personalHomeset.personal)
// Home set found in a group principal
val groupHomeset = savedHomesets[0]
assertEquals(mockServer.url("$PATH_CARDDAV$SUBPATH_ADDRESSBOOK_HOMESET_NON_PERSONAL/"), groupHomeset.url)
assertEquals(service.id, groupHomeset.serviceId)
// personal should be false for homesets not detected at the first query of current-user-principal (IE. in groups)
assertEquals(false, groupHomeset.personal)
}
companion object {
private const val PATH_CARDDAV = "/carddav"
private const val SUBPATH_PRINCIPAL = "/principal"
private const val SUBPATH_GROUPPRINCIPAL_0 = "/groups/0"
private const val SUBPATH_ADDRESSBOOK_HOMESET_PERSONAL = "/addressbooks-homeset"
private const val SUBPATH_ADDRESSBOOK_HOMESET_NON_PERSONAL = "/addressbooks-homeset-non-personal"
}
class TestDispatcher(
private val logger: Logger
) : Dispatcher() {
override fun dispatch(request: RecordedRequest): MockResponse {
val path = request.path!!.trimEnd('/')
logger.info("Query: ${request.method} on $path ")
if (request.method.equals("PROPFIND", true)) {
val properties = when (path) {
PATH_CARDDAV + SUBPATH_PRINCIPAL ->
"<resourcetype><principal/></resourcetype>" +
"<displayname>Mr. Wobbles</displayname>" +
"<CARD:addressbook-home-set>" +
" <href>${PATH_CARDDAV}${SUBPATH_ADDRESSBOOK_HOMESET_PERSONAL}</href>" +
"</CARD:addressbook-home-set>" +
"<group-membership>" +
" <href>${PATH_CARDDAV}${SUBPATH_GROUPPRINCIPAL_0}</href>" +
"</group-membership>"
PATH_CARDDAV + SUBPATH_GROUPPRINCIPAL_0 ->
"<resourcetype><principal/></resourcetype>" +
"<displayname>All address books</displayname>" +
"<CARD:addressbook-home-set>" +
" <href>${PATH_CARDDAV}${SUBPATH_ADDRESSBOOK_HOMESET_PERSONAL}</href>" +
" <href>${PATH_CARDDAV}${SUBPATH_ADDRESSBOOK_HOMESET_NON_PERSONAL}</href>" +
"</CARD:addressbook-home-set>"
else -> ""
}
return MockResponse()
.setResponseCode(207)
.setBody(
"<multistatus xmlns='DAV:' xmlns:CARD='urn:ietf:params:xml:ns:carddav' xmlns:CAL='urn:ietf:params:xml:ns:caldav'>" +
"<response>" +
" <href>$path</href>" +
" <propstat><prop>" +
properties +
" </prop></propstat>" +
"</response>" +
"</multistatus>"
)
}
return MockResponse().setResponseCode(404)
}
}
}

View File

@@ -1,60 +0,0 @@
/*
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
*/
package at.bitfire.davdroid.settings
import android.accounts.AccountManager
import android.content.Context
import at.bitfire.davdroid.TestUtils
import at.bitfire.davdroid.sync.account.TestAccount
import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.android.testing.HiltAndroidRule
import dagger.hilt.android.testing.HiltAndroidTest
import org.junit.Assert.assertEquals
import org.junit.Before
import org.junit.Rule
import org.junit.Test
import javax.inject.Inject
@HiltAndroidTest
class AccountSettingsTest {
@Inject @ApplicationContext
lateinit var context: Context
@Inject
lateinit var accountSettingsFactory: AccountSettings.Factory
@get:Rule
val hiltRule = HiltAndroidRule(this)
@Before
fun setUp() {
hiltRule.inject()
TestUtils.setUpWorkManager(context)
}
@Test(expected = IllegalArgumentException::class)
fun testUpdate_MissingMigrations() {
TestAccount.provide(version = 1) { account ->
// will run AccountSettings.update
accountSettingsFactory.create(account, abortOnMissingMigration = true)
}
}
@Test
fun testUpdate_RunAllMigrations() {
TestAccount.provide(version = 6) { account ->
// will run AccountSettings.update
accountSettingsFactory.create(account, abortOnMissingMigration = true)
val accountManager = AccountManager.get(context)
val version = accountManager.getUserData(account, AccountSettings.KEY_SETTINGS_VERSION).toInt()
assertEquals(AccountSettings.CURRENT_VERSION, version)
}
}
}

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

@@ -1,102 +0,0 @@
/*
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
*/
package at.bitfire.davdroid.settings.migration
import android.accounts.Account
import android.accounts.AccountManager
import android.content.Context
import androidx.test.rule.GrantPermissionRule
import at.bitfire.davdroid.R
import at.bitfire.davdroid.db.AppDatabase
import at.bitfire.davdroid.db.Collection
import at.bitfire.davdroid.db.Service
import at.bitfire.davdroid.resource.LocalAddressBook
import at.bitfire.davdroid.sync.account.TestAccount
import at.bitfire.davdroid.sync.account.setAndVerifyUserData
import dagger.hilt.android.qualifiers.ApplicationContext
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.Assert.assertTrue
import org.junit.Before
import org.junit.Rule
import org.junit.Test
import javax.inject.Inject
@HiltAndroidTest
class AccountSettingsMigration17Test {
@Inject @ApplicationContext
lateinit var context: Context
@Inject
lateinit var db: AppDatabase
@Inject
lateinit var migration: AccountSettingsMigration17
@get:Rule
val hiltRule = HiltAndroidRule(this)
@get:Rule
val permissionRule = GrantPermissionRule.grant(android.Manifest.permission.READ_CONTACTS, android.Manifest.permission.WRITE_CONTACTS)
@Before
fun setUp() {
hiltRule.inject()
}
@Test
fun testMigrate_OldAddressBook_CollectionInDB() {
val localAddressBookUserDataUrl = "url"
TestAccount.provide(version = 16) { account ->
val accountManager = AccountManager.get(context)
val addressBookAccountType = context.getString(R.string.account_type_address_book)
var addressBookAccount = Account("Address Book", addressBookAccountType)
assertTrue(accountManager.addAccountExplicitly(addressBookAccount, null, null))
try {
// address book has account + URL
val url = "https://example.com/address-book"
accountManager.setAndVerifyUserData(addressBookAccount, "real_account_name", account.name)
accountManager.setAndVerifyUserData(addressBookAccount, localAddressBookUserDataUrl, url)
// and is known in database
db.serviceDao().insertOrReplace(
Service(
id = 1, accountName = account.name, type = Service.TYPE_CARDDAV, principal = null
)
)
db.collectionDao().insert(
Collection(
id = 100,
serviceId = 1,
url = url.toHttpUrl(),
type = Collection.TYPE_ADDRESSBOOK,
displayName = "Some Address Book"
)
)
// run migration
migration.migrate(account)
// migration renames address book, update account
addressBookAccount = accountManager.getAccountsByType(addressBookAccountType).filter {
accountManager.getUserData(it, localAddressBookUserDataUrl) == url
}.first()
assertEquals("Some Address Book (${account.name}) #100", addressBookAccount.name)
// ID is now assigned
assertEquals(100L, accountManager.getUserData(addressBookAccount, LocalAddressBook.USER_DATA_COLLECTION_ID)?.toLong())
} finally {
accountManager.removeAccountExplicitly(addressBookAccount)
}
}
}
}

View File

@@ -1,122 +0,0 @@
/*
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
*/
package at.bitfire.davdroid.settings.migration
import android.accounts.Account
import android.accounts.AccountManager
import android.content.Context
import at.bitfire.davdroid.R
import at.bitfire.davdroid.db.AppDatabase
import at.bitfire.davdroid.db.Collection
import at.bitfire.davdroid.db.Service
import at.bitfire.davdroid.resource.LocalAddressBook
import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.android.testing.HiltAndroidRule
import dagger.hilt.android.testing.HiltAndroidTest
import io.mockk.every
import io.mockk.junit4.MockKRule
import io.mockk.mockkObject
import io.mockk.verify
import okhttp3.HttpUrl.Companion.toHttpUrl
import org.junit.Before
import org.junit.Rule
import org.junit.Test
import javax.inject.Inject
@HiltAndroidTest
class AccountSettingsMigration18Test {
@Inject @ApplicationContext
lateinit var context: Context
@Inject
lateinit var db: AppDatabase
@Inject
lateinit var migration: AccountSettingsMigration18
@get:Rule
val hiltRule = HiltAndroidRule(this)
@get:Rule
val mockkRule = MockKRule(this)
@Before
fun setUp() {
hiltRule.inject()
}
@Test
fun testMigrate_AddressBook_InvalidCollection() {
val addressBookAccountType = context.getString(R.string.account_type_address_book)
var addressBookAccount = Account("Address Book", addressBookAccountType)
val accountManager = AccountManager.get(context)
mockkObject(accountManager)
every { accountManager.getAccountsByType(addressBookAccountType) } returns arrayOf(addressBookAccount)
every { accountManager.getUserData(addressBookAccount, LocalAddressBook.USER_DATA_COLLECTION_ID) } returns "123"
val account = Account("test", "test")
migration.migrate(account)
verify(exactly = 0) {
accountManager.setUserData(addressBookAccount, any(), any())
}
}
@Test
fun testMigrate_AddressBook_NoCollection() {
val addressBookAccountType = context.getString(R.string.account_type_address_book)
var addressBookAccount = Account("Address Book", addressBookAccountType)
val accountManager = AccountManager.get(context)
mockkObject(accountManager)
every { accountManager.getAccountsByType(addressBookAccountType) } returns arrayOf(addressBookAccount)
every { accountManager.getUserData(addressBookAccount, LocalAddressBook.USER_DATA_COLLECTION_ID) } returns "123"
val account = Account("test", "test")
migration.migrate(account)
verify(exactly = 0) {
accountManager.setUserData(addressBookAccount, any(), any())
}
}
@Test
fun testMigrate_AddressBook_ValidCollection() {
val account = Account("test", "test")
db.serviceDao().insertOrReplace(Service(
id = 10,
accountName = account.name,
type = Service.TYPE_CARDDAV,
principal = null
))
db.collectionDao().insertOrUpdateByUrl(Collection(
id = 100,
serviceId = 10,
url = "http://example.com".toHttpUrl(),
type = Collection.TYPE_ADDRESSBOOK
))
val addressBookAccountType = context.getString(R.string.account_type_address_book)
var addressBookAccount = Account("Address Book", addressBookAccountType)
val accountManager = AccountManager.get(context)
mockkObject(accountManager)
every { accountManager.getAccountsByType(addressBookAccountType) } returns arrayOf(addressBookAccount)
every { accountManager.getUserData(addressBookAccount, LocalAddressBook.USER_DATA_COLLECTION_ID) } returns "100"
migration.migrate(account)
verify {
accountManager.setUserData(addressBookAccount, LocalAddressBook.USER_DATA_ACCOUNT_NAME, account.name)
accountManager.setUserData(addressBookAccount, LocalAddressBook.USER_DATA_ACCOUNT_TYPE, account.type)
}
}
}

View File

@@ -1,83 +0,0 @@
/*
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
*/
package at.bitfire.davdroid.settings.migration
import android.accounts.Account
import android.content.Context
import android.util.Log
import androidx.hilt.work.HiltWorkerFactory
import androidx.work.Configuration
import androidx.work.WorkManager
import androidx.work.testing.WorkManagerTestInitHelper
import at.bitfire.davdroid.sync.AutomaticSyncManager
import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.android.testing.BindValue
import dagger.hilt.android.testing.HiltAndroidRule
import dagger.hilt.android.testing.HiltAndroidTest
import io.mockk.impl.annotations.RelaxedMockK
import io.mockk.junit4.MockKRule
import io.mockk.mockkObject
import io.mockk.verify
import org.junit.Before
import org.junit.Rule
import org.junit.Test
import javax.inject.Inject
@HiltAndroidTest
class AccountSettingsMigration19Test {
@Inject @ApplicationContext
lateinit var context: Context
@BindValue
@RelaxedMockK
lateinit var automaticSyncManager: AutomaticSyncManager
@Inject
lateinit var migration: AccountSettingsMigration19
@Inject
lateinit var workerFactory: HiltWorkerFactory
@get:Rule
val hiltRule = HiltAndroidRule(this)
@get:Rule
val mockkRule = MockKRule(this)
@Before
fun setUp() {
hiltRule.inject()
// Initialize WorkManager for instrumentation tests.
val config = Configuration.Builder()
.setMinimumLoggingLevel(Log.DEBUG)
.setWorkerFactory(workerFactory)
.build()
WorkManagerTestInitHelper.initializeTestWorkManager(context, config)
}
@Test
fun testMigrate_CancelsOldWorkersAndUpdatesAutomaticSync() {
val workManager = WorkManager.getInstance(context)
mockkObject(workManager)
val account = Account("Some", "Test")
migration.migrate(account)
verify {
workManager.cancelUniqueWork("periodic-sync at.bitfire.davdroid.addressbooks Test/Some")
workManager.cancelUniqueWork("periodic-sync com.android.calendar Test/Some")
workManager.cancelUniqueWork("periodic-sync at.techbee.jtx.provider Test/Some")
workManager.cancelUniqueWork("periodic-sync org.dmfs.tasks Test/Some")
workManager.cancelUniqueWork("periodic-sync org.tasks.opentasks Test/Some")
automaticSyncManager.updateAutomaticSync(account)
}
}
}

View File

@@ -1,144 +0,0 @@
/*
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
*/
package at.bitfire.davdroid.settings.migration
import android.Manifest
import android.accounts.Account
import android.accounts.AccountManager
import android.content.Context
import android.provider.CalendarContract
import android.provider.CalendarContract.Calendars
import androidx.core.content.contentValuesOf
import androidx.core.database.getLongOrNull
import androidx.test.rule.GrantPermissionRule
import at.bitfire.davdroid.db.AppDatabase
import at.bitfire.davdroid.db.Collection
import at.bitfire.davdroid.db.Service
import at.bitfire.davdroid.resource.LocalAddressBook
import at.bitfire.davdroid.resource.LocalCalendarStore
import at.bitfire.davdroid.resource.LocalTestAddressBookProvider
import at.bitfire.davdroid.sync.account.setAndVerifyUserData
import at.bitfire.ical4android.util.MiscUtils.asSyncAdapter
import at.bitfire.vcard4android.GroupMethod
import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.android.testing.HiltAndroidRule
import dagger.hilt.android.testing.HiltAndroidTest
import io.mockk.junit4.MockKRule
import io.mockk.mockk
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 AccountSettingsMigration20Test {
@Inject
lateinit var calendarStore: LocalCalendarStore
@Inject @ApplicationContext
lateinit var context: Context
@Inject
lateinit var db: AppDatabase
@Inject
lateinit var migration: AccountSettingsMigration20
@Inject
lateinit var localTestAddressBookProvider: LocalTestAddressBookProvider
@get:Rule
val hiltRule = HiltAndroidRule(this)
@get:Rule
val mockkRule = MockKRule(this)
@get:Rule
val permissionsRule = GrantPermissionRule.grant(
Manifest.permission.READ_CONTACTS, Manifest.permission.WRITE_CONTACTS,
Manifest.permission.READ_CALENDAR, Manifest.permission.WRITE_CALENDAR
)
val accountManager by lazy { AccountManager.get(context) }
@Before
fun setUp() {
hiltRule.inject()
}
@Test
fun testMigrateAddressBooks_UrlMatchesCollection() {
// set up legacy address-book with URL, but without collection ID
val account = Account("test", "test")
val url = "https://example.com/"
db.serviceDao().insertOrReplace(Service(id = 1, accountName = account.name, type = Service.TYPE_CARDDAV, principal = null))
val collectionId = db.collectionDao().insert(Collection(
serviceId = 1,
type = Collection.Companion.TYPE_ADDRESSBOOK,
url = url.toHttpUrl()
))
localTestAddressBookProvider.provide(account, mockk(relaxed = true), GroupMethod.GROUP_VCARDS) { addressBook ->
accountManager.setAndVerifyUserData(addressBook.addressBookAccount, LocalAddressBook.USER_DATA_ACCOUNT_NAME, account.name)
accountManager.setAndVerifyUserData(addressBook.addressBookAccount, LocalAddressBook.USER_DATA_ACCOUNT_TYPE, account.type)
accountManager.setAndVerifyUserData(addressBook.addressBookAccount, AccountSettingsMigration20.ADDRESS_BOOK_USER_DATA_URL, url)
accountManager.setAndVerifyUserData(addressBook.addressBookAccount, LocalAddressBook.USER_DATA_COLLECTION_ID, null)
migration.migrateAddressBooks(account, cardDavServiceId = 1)
assertEquals(
collectionId,
accountManager.getUserData(addressBook.addressBookAccount, LocalAddressBook.USER_DATA_COLLECTION_ID).toLongOrNull()
)
}
}
@Test
fun testMigrateCalendars_UrlMatchesCollection() {
// set up legacy calendar with URL, but without collection ID
val account = Account("test", CalendarContract.ACCOUNT_TYPE_LOCAL)
val url = "https://example.com/"
db.serviceDao().insertOrReplace(Service(id = 1, accountName = account.name, type = Service.TYPE_CALDAV, principal = null))
val collectionId = db.collectionDao().insert(
Collection(
serviceId = 1,
type = Collection.Companion.TYPE_CALENDAR,
url = url.toHttpUrl()
)
)
context.contentResolver.acquireContentProviderClient(CalendarContract.AUTHORITY)!!.use { provider ->
val uri = provider.insert(
Calendars.CONTENT_URI.asSyncAdapter(account),
contentValuesOf(
Calendars.ACCOUNT_NAME to account.name,
Calendars.ACCOUNT_TYPE to account.type,
Calendars.CALENDAR_DISPLAY_NAME to "Test",
Calendars.NAME to url,
Calendars.SYNC_EVENTS to 1
)
)!!.asSyncAdapter(account)
try {
migration.migrateCalendars(account, 1)
provider.query(uri, arrayOf(Calendars._SYNC_ID), null, null, null)!!.use { cursor ->
cursor.moveToNext()
assertEquals(collectionId, cursor.getLongOrNull(0))
}
} finally {
provider.delete(uri, null, null)
}
}
}
}

View File

@@ -1,230 +0,0 @@
/*
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
*/
package at.bitfire.davdroid.sync
import android.accounts.Account
import android.content.ContentResolver
import android.content.Context
import android.content.SyncRequest
import android.os.Bundle
import android.provider.CalendarContract
import androidx.test.filters.SdkSuppress
import at.bitfire.davdroid.sync.account.TestAccount
import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.android.testing.HiltAndroidRule
import dagger.hilt.android.testing.HiltAndroidTest
import io.mockk.junit4.MockKRule
import junit.framework.AssertionFailedError
import kotlinx.coroutines.delay
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.withTimeout
import org.junit.After
import org.junit.AfterClass
import org.junit.Before
import org.junit.BeforeClass
import org.junit.Rule
import org.junit.Test
import java.util.Collections
import java.util.LinkedList
import java.util.logging.Logger
import javax.inject.Inject
import kotlin.time.Duration.Companion.seconds
@HiltAndroidTest
class AndroidSyncFrameworkTest {
@get:Rule
val hiltRule = HiltAndroidRule(this)
@get:Rule
val mockkRule = MockKRule(this)
@Inject
@ApplicationContext
lateinit var context: Context
@Inject
lateinit var logger: Logger
lateinit var account: Account
val authority = CalendarContract.AUTHORITY
private lateinit var stateChangeListener: Any
private val recordedStates = Collections.synchronizedList(LinkedList<State>())
@Before
fun setUp() {
hiltRule.inject()
account = TestAccount.create()
// Enable sync globally and for the test account
ContentResolver.setIsSyncable(account, authority, 1)
// Remember states the sync framework reports as pairs of (sync pending, sync active).
recordedStates.clear()
onStatusChanged(0) // record first entry (pending = false, active = false)
stateChangeListener = ContentResolver.addStatusChangeListener(
ContentResolver.SYNC_OBSERVER_TYPE_PENDING or ContentResolver.SYNC_OBSERVER_TYPE_ACTIVE,
::onStatusChanged
)
}
@After
fun tearDown() {
ContentResolver.removeStatusChangeListener(stateChangeListener)
TestAccount.remove(account)
}
/**
* Correct behaviour of the sync framework on Android 13 and below.
* Pending state is correctly reflected
*/
@SdkSuppress(maxSdkVersion = 33)
@Test
fun testVerifySyncAlwaysPending_correctBehaviour_android13() {
verifySyncStates(
listOf(
State(pending = false, active = false), // no sync pending or active
State(pending = true, active = false, optional = true), // sync becomes pending
State(pending = true, active = true), // ... and pending and active at the same time
State(pending = false, active = true), // ... and then only active
State(pending = false, active = false) // sync finished
)
)
}
/**
* Wrong behaviour of the sync framework on Android 14+.
* Pending state stays true forever (after initial run), active state behaves correctly
*/
@SdkSuppress(minSdkVersion = 34 /*, maxSdkVersion = 36 */)
@Test
fun testVerifySyncAlwaysPending_wrongBehaviour_android14() {
verifySyncStates(
listOf(
State(pending = false, active = false), // no sync pending or active
State(pending = true, active = false, optional = true), // sync becomes pending
State(pending = true, active = true), // ... and pending and active at the same time
State(pending = true, active = false) // ... and finishes, but stays pending
)
)
}
// helpers
private fun syncRequest() = SyncRequest.Builder()
.setSyncAdapter(account, authority)
.syncOnce()
.setExtras(Bundle()) // needed for Android 9
.setExpedited(true) // sync request will be scheduled at the front of the sync request queue
.setManual(true) // equivalent of setting both SYNC_EXTRAS_IGNORE_SETTINGS and SYNC_EXTRAS_IGNORE_BACKOFF
.build()
/**
* Verifies that the given expected states match the recorded states.
*/
private fun verifySyncStates(expectedStates: List<State>) = runBlocking {
// We use runBlocking for these tests because it uses the default dispatcher
// which does not auto-advance virtual time and we need real system time to
// test the sync framework behavior.
ContentResolver.requestSync(syncRequest())
// Even though the always-pending-bug is present on Android 14+, the sync active
// state behaves correctly, so we can record the state changes as pairs (pending,
// active) and expect a certain sequence of state pairs to verify the presence or
// absence of the bug on different Android versions.
withTimeout(60.seconds) { // Usually takes less than 30 seconds
while (recordedStates.size < expectedStates.size) {
// verify already known states
if (recordedStates.isNotEmpty())
assertStatesEqual(expectedStates.subList(0, recordedStates.size), recordedStates)
delay(500) // avoid busy-waiting
}
assertStatesEqual(expectedStates, recordedStates)
}
}
/**
* Asserts whether [actualStates] and [expectedStates] are the same, under the condition
* that expected states with the [State.optional] flag can be skipped.
*/
private fun assertStatesEqual(expectedStates: List<State>, actualStates: List<State>) {
fun fail() {
throw AssertionFailedError("Expected states=$expectedStates, actual=$actualStates")
}
// iterate through entries
val expectedIterator = expectedStates.iterator()
for (actual in actualStates) {
if (!expectedIterator.hasNext())
fail()
var expected = expectedIterator.next()
// skip optional expected entries if they don't match the actual entry
while (!actual.stateEquals(expected) && expected.optional) {
if (!expectedIterator.hasNext())
fail()
expected = expectedIterator.next()
}
if (!actual.stateEquals(expected))
fail()
}
}
// SyncStatusObserver implementation and data class
fun onStatusChanged(which: Int) {
val state = State(
pending = ContentResolver.isSyncPending(account, authority),
active = ContentResolver.isSyncActive(account, authority)
)
synchronized(recordedStates) {
if (recordedStates.lastOrNull() != state) {
logger.info("$account syncState = $state")
recordedStates += state
}
}
}
data class State(
val pending: Boolean,
val active: Boolean,
val optional: Boolean = false
) {
fun stateEquals(other: State) =
pending == other.pending && active == other.active
}
companion object {
var globalAutoSyncBeforeTest = false
@BeforeClass
@JvmStatic
fun before() {
globalAutoSyncBeforeTest = ContentResolver.getMasterSyncAutomatically()
// We'll request syncs explicitly and with SYNC_EXTRAS_IGNORE_SETTINGS
ContentResolver.setMasterSyncAutomatically(false)
}
@AfterClass
@JvmStatic
fun after() {
ContentResolver.setMasterSyncAutomatically(globalAutoSyncBeforeTest)
}
}
}

View File

@@ -1,51 +0,0 @@
/*
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
*/
package at.bitfire.davdroid.sync
import android.accounts.Account
import android.content.AbstractThreadedSyncAdapter
import android.content.ContentProviderClient
import android.content.Context
import android.content.SyncResult
import android.os.Bundle
import android.os.IBinder
import at.bitfire.davdroid.sync.adapter.SyncAdapter
import dagger.hilt.android.qualifiers.ApplicationContext
import java.util.logging.Level
import java.util.logging.Logger
import javax.inject.Inject
class FakeSyncAdapter @Inject constructor(
@ApplicationContext context: Context,
private val logger: Logger
): AbstractThreadedSyncAdapter(context, true), SyncAdapter {
init {
logger.info("FakeSyncAdapter created")
}
override fun onPerformSync(account: Account, extras: Bundle, authority: String, provider: ContentProviderClient, syncResult: SyncResult) {
logger.log(
Level.INFO,
"onPerformSync(account=$account, extras=$extras, authority=$authority, syncResult=$syncResult)",
extras.keySet().map { key -> "extras[$key] = ${extras[key]}" }
)
// fake 5 sec sync
try {
Thread.sleep(5000)
} catch (_: InterruptedException) {
logger.info("onPerformSync($account) cancelled")
}
logger.info("onPerformSync($account) finished")
}
// SyncAdapter implementation and Hilt module
override fun getBinder(): IBinder = syncAdapterBinder
}

View File

@@ -1,187 +0,0 @@
/*
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
*/
package at.bitfire.davdroid.sync
import android.accounts.Account
import android.content.ContentProviderClient
import android.content.Context
import at.bitfire.davdroid.db.Collection
import at.bitfire.davdroid.db.Service
import at.bitfire.davdroid.network.HttpClient
import at.bitfire.davdroid.repository.DavServiceRepository
import at.bitfire.davdroid.resource.LocalJtxCollection
import at.bitfire.davdroid.resource.LocalJtxCollectionStore
import at.bitfire.davdroid.sync.account.TestAccount
import at.bitfire.davdroid.util.PermissionUtils
import at.bitfire.ical4android.TaskProvider
import at.bitfire.ical4android.util.MiscUtils.closeCompat
import at.bitfire.synctools.test.GrantPermissionOrSkipRule
import at.techbee.jtx.JtxContract
import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.android.testing.HiltAndroidRule
import dagger.hilt.android.testing.HiltAndroidTest
import okhttp3.HttpUrl.Companion.toHttpUrl
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assume.assumeNotNull
import org.junit.Assume.assumeTrue
import org.junit.Before
import org.junit.Rule
import org.junit.Test
import java.io.StringReader
import javax.inject.Inject
/**
* Ensure you have jtxBoard installed on the emulator, before running these tests. Otherwise they
* will be skipped.
*/
@HiltAndroidTest
class JtxSyncManagerTest {
@Inject
@ApplicationContext
lateinit var context: Context
@Inject
lateinit var httpClientBuilder: HttpClient.Builder
@Inject
lateinit var serviceRepository: DavServiceRepository
@Inject
lateinit var localJtxCollectionStore: LocalJtxCollectionStore
@Inject
lateinit var jtxSyncManagerFactory: JtxSyncManager.Factory
@get:Rule
val hiltRule = HiltAndroidRule(this)
@get:Rule
val permissionRule = GrantPermissionOrSkipRule(TaskProvider.PERMISSIONS_JTX.toSet())
lateinit var account: Account
private lateinit var provider: ContentProviderClient
private lateinit var syncManager: JtxSyncManager
private lateinit var localJtxCollection: LocalJtxCollection
@Before
fun setUp() {
hiltRule.inject()
// Check jtxBoard permissions were granted (+jtxBoard is installed); skip test otherwise
assumeTrue(PermissionUtils.havePermissions(context, TaskProvider.PERMISSIONS_JTX))
// Acquire the jtx content provider
val providerOrNull = context.contentResolver.acquireContentProviderClient(JtxContract.AUTHORITY)
assumeNotNull(providerOrNull)
provider = providerOrNull!!
account = TestAccount.create()
// Create dummy dependencies
val service = Service(0, account.name, Service.TYPE_CALDAV, null)
val serviceId = serviceRepository.insertOrReplaceBlocking(service)
val dbCollection = Collection(
0,
serviceId,
type = Collection.TYPE_CALENDAR,
url = "https://example.com".toHttpUrl()
)
localJtxCollection = localJtxCollectionStore.create(provider, dbCollection)!!
syncManager = jtxSyncManagerFactory.jtxSyncManager(
account = account,
httpClient = httpClientBuilder.build(),
syncResult = SyncResult(),
localCollection = localJtxCollection,
collection = dbCollection,
resync = null
)
}
@After
fun tearDown() {
if (this::localJtxCollection.isInitialized)
localJtxCollectionStore.delete(localJtxCollection)
serviceRepository.deleteAllBlocking()
if (this::provider.isInitialized)
provider.closeCompat()
if (this::account.isInitialized)
TestAccount.remove(account)
}
@Test
fun testProcessICalObject_addsVtodo() {
val calendar = "BEGIN:VCALENDAR\n" +
"PRODID:-Vivaldi Calendar V1.0//EN\n" +
"VERSION:2.0\n" +
"BEGIN:VTODO\n" +
"SUMMARY:Test Task (Main VTODO)\n" +
"DTSTAMP;VALUE=DATE-TIME:20250228T032800Z\n" +
"UID:47a23c66-8c1a-4b44-bbe8-ebf33f8cf80f\n" +
"END:VTODO\n" +
"END:VCALENDAR"
// Should create "demo-calendar"
syncManager.processICalObject("demo-calendar", "abc123", StringReader(calendar))
// Verify main VTODO is created
val localJtxIcalObject = localJtxCollection.findByName("demo-calendar")!!
assertEquals("47a23c66-8c1a-4b44-bbe8-ebf33f8cf80f", localJtxIcalObject.uid)
assertEquals("abc123", localJtxIcalObject.eTag)
assertEquals("Test Task (Main VTODO)", localJtxIcalObject.summary)
}
@Test
fun testProcessICalObject_addsRecurringVtodo_withoutDtStart() {
// Valid calendar example (See bitfireAT/davx5-ose#1265)
// Note: We don't support starting a recurrence from DUE (RFC 5545 leaves it open to interpretation)
val calendar = "BEGIN:VCALENDAR\n" +
"PRODID:-Vivaldi Calendar V1.0//EN\n" +
"VERSION:2.0\n" +
"BEGIN:VTODO\n" +
"SUMMARY:Test Task (Exception)\n" +
"DTSTAMP;VALUE=DATE-TIME:20250228T032800Z\n" +
"DUE;TZID=America/New_York:20250228T130000\n" +
"RECURRENCE-ID;TZID=America/New_York:20250228T130000\n" +
"UID:47a23c66-8c1a-4b44-bbe8-ebf33f8cf80f\n" +
"END:VTODO\n" +
"BEGIN:VTODO\n" +
"SUMMARY:Test Task (Main VTODO)\n" +
"DTSTAMP;VALUE=DATE-TIME:20250228T032800Z\n" +
"DUE;TZID=America/New_York:20250228T130000\n" + // Due date will NOT be assumed as start for recurrence
"SEQUENCE:1\n" +
"UID:47a23c66-8c1a-4b44-bbe8-ebf33f8cf80f\n" +
"RRULE:FREQ=WEEKLY;INTERVAL=1;BYDAY=FR;UNTIL=20250505T235959Z\n" +
"END:VTODO\n" +
"END:VCALENDAR"
// Create and store calendar
syncManager.processICalObject("demo-calendar", "abc123", StringReader(calendar))
// Verify main VTODO was created with RRULE present
val mainVtodo = localJtxCollection.findByName("demo-calendar")!!
assertEquals("Test Task (Main VTODO)", mainVtodo.summary)
assertEquals("FREQ=WEEKLY;UNTIL=20250505T235959Z;INTERVAL=1;BYDAY=FR", mainVtodo.rrule)
// Verify the RRULE exception instance was created with correct recurrence-id timezone
val vtodoException = localJtxCollection.findRecurInstance(
uid = "47a23c66-8c1a-4b44-bbe8-ebf33f8cf80f",
recurid = "20250228T130000"
)!!
assertEquals("Test Task (Exception)", vtodoException.summary)
assertEquals("America/New_York", vtodoException.recuridTimezone)
}
}

View File

@@ -4,11 +4,11 @@
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
override val collectionUrl: String = "http://example.com/test/"
): LocalCollection<LocalTestResource> {
override val tag = "LocalTestCollection"
@@ -21,6 +21,8 @@ class LocalTestCollection(
override val readOnly: Boolean
get() = throw NotImplementedError()
override fun deleteCollection(): Boolean = true
override fun findDeleted() = entries.filter { it.deleted }
override fun findDirty() = entries.filter { it.dirty }

View File

@@ -5,7 +5,6 @@
package at.bitfire.davdroid.sync
import at.bitfire.davdroid.resource.LocalResource
import java.util.Optional
class LocalTestResource: LocalResource<Any> {
@@ -20,10 +19,10 @@ class LocalTestResource: LocalResource<Any> {
override fun prepareForUpload() = "generated-file.txt"
override fun clearDirty(fileName: Optional<String>, eTag: String?, scheduleTag: String?) {
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
}
@@ -32,8 +31,9 @@ class LocalTestResource: LocalResource<Any> {
this.flags = flags
}
override fun update(data: Any, fileName: String?, eTag: String?, scheduleTag: String?, flags: 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()
}

View File

@@ -5,93 +5,123 @@
package at.bitfire.davdroid.sync
import android.accounts.Account
import android.content.ContentResolver
import android.content.Context
import android.content.SyncResult
import android.os.Bundle
import android.provider.CalendarContract
import android.util.Log
import androidx.hilt.work.HiltWorkerFactory
import androidx.work.Configuration
import androidx.work.WorkInfo
import androidx.work.WorkManager
import at.bitfire.davdroid.TestUtils
import at.bitfire.davdroid.sync.account.TestAccount
import at.bitfire.davdroid.sync.adapter.SyncAdapterImpl
import androidx.work.testing.WorkManagerTestInitHelper
import at.bitfire.davdroid.repository.DavCollectionRepository
import at.bitfire.davdroid.repository.DavServiceRepository
import at.bitfire.davdroid.settings.AccountSettings
import at.bitfire.davdroid.sync.account.TestAccountAuthenticator
import at.bitfire.davdroid.sync.worker.SyncWorkerManager
import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.android.testing.BindValue
import dagger.hilt.android.testing.HiltAndroidRule
import dagger.hilt.android.testing.HiltAndroidTest
import io.mockk.Awaits
import io.mockk.coEvery
import io.mockk.every
import io.mockk.impl.annotations.MockK
import io.mockk.junit4.MockKRule
import io.mockk.just
import io.mockk.mockk
import io.mockk.mockkObject
import io.mockk.mockkStatic
import io.mockk.unmockkAll
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.launch
import kotlinx.coroutines.test.runTest
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.withTimeout
import org.junit.After
import org.junit.Before
import org.junit.Rule
import org.junit.Test
import org.junit.rules.Timeout
import java.util.concurrent.Executors
import java.util.logging.Logger
import javax.inject.Inject
import javax.inject.Provider
import kotlin.coroutines.cancellation.CancellationException
@HiltAndroidTest
class SyncAdapterImplTest {
class SyncAdapterServicesTest {
@get:Rule
val hiltRule = HiltAndroidRule(this)
lateinit var account: Account
@get:Rule
val mockkRule = MockKRule(this)
@Inject
lateinit var accountSettingsFactory: AccountSettings.Factory
@Inject @ApplicationContext
@Inject
lateinit var collectionRepository: DavCollectionRepository
@Inject
@ApplicationContext
lateinit var context: Context
@Inject
lateinit var syncAdapterImplProvider: Provider<SyncAdapterImpl>
lateinit var logger: Logger
@BindValue @MockK
lateinit var syncWorkerManager: SyncWorkerManager
@Inject
lateinit var serviceRepository: DavServiceRepository
@Inject
lateinit var syncConditionsFactory: SyncConditions.Factory
@Inject
lateinit var workerFactory: HiltWorkerFactory
lateinit var account: Account
@get:Rule
val hiltRule = HiltAndroidRule(this)
// test methods should run quickly and not wait 60 seconds for a sync timeout or something like that
@get:Rule
val timeoutRule: Timeout = Timeout.seconds(5)
private var masterSyncStateBeforeTest = ContentResolver.getMasterSyncAutomatically()
@Before
fun setUp() {
hiltRule.inject()
TestUtils.setUpWorkManager(context, workerFactory)
account = TestAccount.create()
account = TestAccountAuthenticator.create()
ContentResolver.setMasterSyncAutomatically(true)
ContentResolver.setSyncAutomatically(account, CalendarContract.AUTHORITY, true)
ContentResolver.setIsSyncable(account, CalendarContract.AUTHORITY, 1)
// Initialize WorkManager for instrumentation tests.
val config = Configuration.Builder()
.setMinimumLoggingLevel(Log.DEBUG)
.setWorkerFactory(workerFactory)
.build()
WorkManagerTestInitHelper.initializeTestWorkManager(context, config)
}
@After
fun tearDown() {
ContentResolver.setMasterSyncAutomatically(masterSyncStateBeforeTest)
TestAccount.remove(account)
TestAccountAuthenticator.remove(account)
unmockkAll()
}
private fun syncAdapter(
syncWorkerManager: SyncWorkerManager
): SyncAdapterService.SyncAdapter =
SyncAdapterService.SyncAdapter(
accountSettingsFactory = accountSettingsFactory,
collectionRepository = collectionRepository,
serviceRepository = serviceRepository,
context = context,
logger = logger,
syncConditionsFactory = syncConditionsFactory,
syncWorkerManager = syncWorkerManager
)
@Test
fun testSyncAdapter_onPerformSync_cancellation() = runTest {
fun testSyncAdapter_onPerformSync_cancellation() {
val syncWorkerManager = mockk<SyncWorkerManager>()
val syncAdapter = syncAdapter(syncWorkerManager = syncWorkerManager)
val workManager = WorkManager.getInstance(context)
val syncAdapter = syncAdapterImplProvider.get()
mockkObject(workManager) {
// don't actually create a worker
@@ -100,22 +130,25 @@ class SyncAdapterImplTest {
// assume worker takes a long time
every { workManager.getWorkInfosForUniqueWorkFlow("TheSyncWorker") } just Awaits
val sync = launch {
syncAdapter.onPerformSync(account, Bundle(), CalendarContract.AUTHORITY, mockk(), SyncResult())
runBlocking {
val sync = launch {
syncAdapter.onPerformSync(account, Bundle(), CalendarContract.AUTHORITY, mockk(), SyncResult())
}
// simulate incoming cancellation from sync framework
syncAdapter.onSyncCanceled()
// wait for sync to finish (should happen immediately)
sync.join()
}
// simulate incoming cancellation from sync framework
syncAdapter.onSyncCanceled()
// wait for sync to finish (should happen immediately)
sync.join()
}
}
@Test
fun testSyncAdapter_onPerformSync_returnsAfterTimeout() {
val syncWorkerManager = mockk<SyncWorkerManager>()
val syncAdapter = syncAdapter(syncWorkerManager = syncWorkerManager)
val workManager = WorkManager.getInstance(context)
val syncAdapter = syncAdapterImplProvider.get()
mockkObject(workManager) {
// don't actually create a worker
@@ -135,8 +168,9 @@ class SyncAdapterImplTest {
@Test
fun testSyncAdapter_onPerformSync_runsInTime() {
val syncWorkerManager = mockk<SyncWorkerManager>()
val syncAdapter = syncAdapter(syncWorkerManager = syncWorkerManager)
val workManager = WorkManager.getInstance(context)
val syncAdapter = syncAdapterImplProvider.get()
mockkObject(workManager) {
// don't actually create a worker
@@ -155,4 +189,4 @@ class SyncAdapterImplTest {
}
}
}
}

View File

@@ -6,29 +6,31 @@ package at.bitfire.davdroid.sync
import android.accounts.Account
import android.content.Context
import android.util.Log
import androidx.core.app.NotificationManagerCompat
import androidx.hilt.work.HiltWorkerFactory
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.TestUtils.assertWithin
import at.bitfire.davdroid.db.Collection
import at.bitfire.davdroid.db.SyncState
import at.bitfire.davdroid.network.HttpClient
import at.bitfire.davdroid.repository.DavSyncStatsRepository
import at.bitfire.davdroid.resource.SyncState
import at.bitfire.davdroid.settings.AccountSettings
import at.bitfire.davdroid.sync.account.TestAccount
import at.bitfire.davdroid.sync.account.TestAccountAuthenticator
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.android.testing.BindValue
import dagger.hilt.android.testing.HiltAndroidRule
import dagger.hilt.android.testing.HiltAndroidTest
import dagger.hilt.components.SingletonComponent
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
@@ -46,50 +48,52 @@ import javax.inject.Inject
@HiltAndroidTest
class SyncManagerTest {
@get:Rule
val hiltRule = HiltAndroidRule(this)
@Module
@InstallIn(SingletonComponent::class)
object SyncManagerTestModule {
@Provides
fun davSyncStatsRepository(): DavSyncStatsRepository = mockk<DavSyncStatsRepository>(relaxed = true)
}
@get:Rule
val mockKRule = MockKRule(this)
val hiltRule = HiltAndroidRule(this)
@Inject
lateinit var accountSettingsFactory: AccountSettings.Factory
@Inject @ApplicationContext
lateinit var context: Context
@Inject
lateinit var httpClientBuilder: HttpClient.Builder
@ApplicationContext
lateinit var context: Context
@Inject
lateinit var syncManagerFactory: TestSyncManager.Factory
@BindValue
@RelaxedMockK
lateinit var syncStatsRepository: DavSyncStatsRepository
@Inject
lateinit var workerFactory: HiltWorkerFactory
private lateinit var account: Account
private lateinit var server: MockWebServer
lateinit var account: Account
private val server = MockWebServer()
@Before
fun setUp() {
hiltRule.inject()
TestUtils.setUpWorkManager(context, workerFactory)
// Initialize WorkManager for instrumentation tests.
val config = Configuration.Builder()
.setMinimumLoggingLevel(Log.DEBUG)
.setWorkerFactory(workerFactory)
.build()
WorkManagerTestInitHelper.initializeTestWorkManager(context, config)
account = TestAccount.create()
account = TestAccountAuthenticator.create()
server = MockWebServer().apply {
start()
}
server.start()
}
@After
fun tearDown() {
TestAccount.remove(account)
TestAccountAuthenticator.remove(account)
// clear annoying syncError notifications
NotificationManagerCompat.from(context).cancelAll()
@@ -98,6 +102,30 @@ class SyncManagerTest {
}
@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 +150,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 +168,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 +183,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 +226,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 +273,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 +318,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 +364,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 +401,7 @@ class SyncManagerTest {
}
@Test
fun testPerformSync_DownloadNewMember() = runTest {
fun testPerformSync_DownloadNewMember() {
val collection = LocalTestCollection().apply {
lastSyncState = SyncState(SyncState.Type.CTAG, "old-ctag")
}
@@ -408,7 +435,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 +473,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 +493,7 @@ class SyncManagerTest {
}
@Test
fun testPerformSync_CTagDidntChange() = runTest {
fun testPerformSync_CTagDidntChange() {
val collection = LocalTestCollection().apply {
lastSyncState = SyncState(SyncState.Type.CTAG, "ctag1")
}
@@ -488,13 +515,16 @@ class SyncManagerTest {
private fun syncManager(
localCollection: LocalTestCollection,
syncResult: SyncResult = SyncResult(),
collection: Collection = mockk<Collection>(relaxed = true) {
collection: Collection = mockk<Collection>() {
every { id } returns 1
every { url } returns server.url("/")
}
) = syncManagerFactory.create(
account,
httpClientBuilder.build(),
accountSettingsFactory.create(account),
arrayOf(),
"TestAuthority",
HttpClient.Builder(context).build(),
syncResult,
localCollection,
collection

View File

@@ -7,41 +7,56 @@ package at.bitfire.davdroid.sync
import android.accounts.Account
import android.content.ContentProviderClient
import at.bitfire.davdroid.db.Collection
import at.bitfire.davdroid.resource.LocalDataStore
import at.bitfire.davdroid.sync.account.TestAccountAuthenticator
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
import dagger.hilt.android.testing.HiltAndroidRule
import dagger.hilt.android.testing.HiltAndroidTest
import io.mockk.every
import io.mockk.impl.annotations.InjectMockKs
import io.mockk.impl.annotations.RelaxedMockK
import io.mockk.impl.annotations.SpyK
import io.mockk.junit4.MockKRule
import io.mockk.just
import io.mockk.mockk
import io.mockk.runs
import io.mockk.spyk
import io.mockk.verify
import okhttp3.HttpUrl.Companion.toHttpUrl
import org.junit.After
import org.junit.Assert.assertArrayEquals
import org.junit.Assert.assertEquals
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 SyncerTest {
@get:Rule
val mockkRule = MockKRule(this)
val hiltRule = HiltAndroidRule(this)
@RelaxedMockK
lateinit var logger: Logger
@Inject
lateinit var testSyncer: TestSyncer.Factory
val dataStore: LocalTestStore = mockk(relaxed = true)
val provider: ContentProviderClient = mockk(relaxed = true)
lateinit var account: Account
@SpyK
@InjectMockKs
var syncer = TestSyncer(mockk(relaxed = true), null, SyncResult(), dataStore)
private lateinit var syncer: TestSyncer
@Before
fun setUp() {
hiltRule.inject()
account = TestAccountAuthenticator.create()
syncer = spyk(testSyncer.create(account, emptyArray(), SyncResult()))
}
@After
fun tearDown() {
TestAccountAuthenticator.remove(account)
}
@Test
fun testSync_prepare_fails() {
val provider = mockk<ContentProviderClient>()
every { syncer.prepare(provider) } returns false
every { syncer.getSyncEnabledCollections() } returns emptyMap()
@@ -53,6 +68,7 @@ class SyncerTest {
@Test
fun testSync_prepare_succeeds() {
val provider = mockk<ContentProviderClient>()
every { syncer.prepare(provider) } returns true
every { syncer.getSyncEnabledCollections() } returns emptyMap()
@@ -65,15 +81,15 @@ class SyncerTest {
@Test
fun testUpdateCollections_deletesCollection() {
val localCollection = mockk<LocalTestCollection> {
every { dbCollectionId } returns 0L
every { title } returns "Collection to be deleted locally"
}
val localCollection = mockk<LocalTestCollection>()
every { localCollection.collectionUrl } returns "http://delete.the/collection"
every { localCollection.deleteCollection() } returns true
every { localCollection.title } returns "Collection to be deleted locally"
// Should delete the localCollection if dbCollection (remote) does not exist
val localCollections = mutableListOf(localCollection)
val result = syncer.updateCollections(mockk(), localCollections, emptyMap())
verify(exactly = 1) { dataStore.delete(localCollection) }
verify(exactly = 1) { localCollection.deleteCollection() }
// Updated local collection list should be empty
assertTrue(result.isEmpty())
@@ -81,18 +97,16 @@ class SyncerTest {
@Test
fun testUpdateCollections_updatesCollection() {
val localCollection = mockk<LocalTestCollection> {
every { dbCollectionId } returns 0L
every { title } returns "The Local Collection"
}
val dbCollection = mockk<Collection> {
every { id } returns 0L
}
val dbCollections = mapOf(0L to dbCollection)
val localCollection = mockk<LocalTestCollection>()
val dbCollection = mockk<Collection>()
val dbCollections = mapOf("http://update.the/collection".toHttpUrl() to dbCollection)
every { dbCollection.url } returns "http://update.the/collection".toHttpUrl()
every { localCollection.collectionUrl } returns "http://update.the/collection"
every { localCollection.title } returns "The Local Collection"
// Should update the localCollection if it exists
val result = syncer.updateCollections(provider, listOf(localCollection), dbCollections)
verify(exactly = 1) { dataStore.update(provider, localCollection, dbCollection) }
val result = syncer.updateCollections(mockk(), listOf(localCollection), dbCollections)
verify(exactly = 1) { syncer.update(localCollection, dbCollection) }
// Updated local collection list should be same as input
assertArrayEquals(arrayOf(localCollection), result.toTypedArray())
@@ -100,51 +114,47 @@ class SyncerTest {
@Test
fun testUpdateCollections_findsNewCollection() {
val dbCollection = mockk<Collection> {
every { id } returns 0L
}
val localCollections = listOf(mockk<LocalTestCollection> {
every { dbCollectionId } returns 0L
})
val dbCollections = listOf(dbCollection)
val dbCollectionsMap = mapOf(dbCollection.id to dbCollection)
every { syncer.createLocalCollections(provider, dbCollections) } returns localCollections
val dbCollection = mockk<Collection>()
every { dbCollection.url } returns "http://newly.found/collection".toHttpUrl()
val dbCollections = mapOf(dbCollection.url to dbCollection)
// Should return the new collection, because it was not updated
val result = syncer.updateCollections(provider, emptyList(), dbCollectionsMap)
val result = syncer.updateCollections(mockk(), emptyList(), dbCollections)
// Updated local collection list contain new entry
assertEquals(1, result.size)
assertEquals(dbCollection.id, result[0].dbCollectionId)
assertEquals(dbCollection.url.toString(), result[0].collectionUrl)
}
@Test
fun testCreateLocalCollections() {
val provider = mockk<ContentProviderClient>()
val localCollection = mockk<LocalTestCollection>()
val dbCollection = mockk<Collection>()
every { dataStore.create(provider, dbCollection) } returns localCollection
every { syncer.create(provider, dbCollection) } returns localCollection
every { dbCollection.url } returns "http://newly.found/collection".toHttpUrl()
// Should return list of newly created local collections
val result = syncer.createLocalCollections(provider, listOf(dbCollection))
assertEquals(listOf(localCollection), result)
}
@Test
fun testSyncCollectionContents() {
val provider = mockk<ContentProviderClient>()
val dbCollection1 = mockk<Collection>()
val dbCollection2 = mockk<Collection>()
val dbCollections = mapOf(
0L to dbCollection1,
1L to dbCollection2
"http://newly.found/collection1".toHttpUrl() to dbCollection1,
"http://newly.found/collection2".toHttpUrl() to dbCollection2
)
val localCollection1 = mockk<LocalTestCollection> { every { dbCollectionId } returns 0L }
val localCollection2 = mockk<LocalTestCollection> { every { dbCollectionId } returns 1L }
val localCollection1 = mockk<LocalTestCollection>()
val localCollection2 = mockk<LocalTestCollection>()
val localCollections = listOf(localCollection1, localCollection2)
every { localCollection1.dbCollectionId } returns 0L
every { localCollection2.dbCollectionId } returns 1L
every { syncer.syncCollection(provider, any(), any()) } just runs
every { localCollection1.collectionUrl } returns "http://newly.found/collection1"
every { localCollection2.collectionUrl } returns "http://newly.found/collection2"
// Should call the collection content sync on both collections
syncer.syncCollectionContents(provider, localCollections, dbCollections)
@@ -155,73 +165,41 @@ class SyncerTest {
// Test helpers
class TestSyncer(
account: Account,
resyncType: ResyncType?,
syncResult: SyncResult,
theDataStore: LocalTestStore
) : Syncer<LocalTestStore, LocalTestCollection>(account, resyncType, syncResult) {
class TestSyncer @AssistedInject constructor(
@Assisted account: Account,
@Assisted extras: Array<String>,
@Assisted syncResult: SyncResult
) : Syncer<LocalTestCollection>(account, extras, syncResult) {
override val dataStore: LocalTestStore =
theDataStore
@AssistedFactory
interface Factory {
fun create(account: Account, extras: Array<String>, syncResult: SyncResult): TestSyncer
}
override val authority: String
get() = ""
override val serviceType: String
get() = throw NotImplementedError()
get() = ""
override fun prepare(provider: ContentProviderClient): Boolean =
throw NotImplementedError()
true
override fun getLocalCollections(provider: ContentProviderClient): List<LocalTestCollection> =
emptyList()
override fun getDbSyncCollections(serviceId: Long): List<Collection> =
throw NotImplementedError()
emptyList()
override fun create(provider: ContentProviderClient, remoteCollection: Collection): LocalTestCollection =
LocalTestCollection(remoteCollection.url.toString())
override fun syncCollection(
provider: ContentProviderClient,
localCollection: LocalTestCollection,
remoteCollection: Collection
) {
throw NotImplementedError()
}
) {}
}
class LocalTestStore : LocalDataStore<LocalTestCollection> {
override val authority: String
get() = throw NotImplementedError()
override fun acquireContentProvider(throwOnMissingPermissions: Boolean): ContentProviderClient? {
throw NotImplementedError()
}
override fun create(
provider: ContentProviderClient,
fromCollection: Collection
): LocalTestCollection? {
throw NotImplementedError()
}
override fun getAll(
account: Account,
provider: ContentProviderClient
): List<LocalTestCollection> {
throw NotImplementedError()
}
override fun update(
provider: ContentProviderClient,
localCollection: LocalTestCollection,
fromCollection: Collection
) {
throw NotImplementedError()
}
override fun delete(localCollection: LocalTestCollection) {
throw NotImplementedError()
}
override fun updateAccount(oldAccount: Account, newAccount: Account) {
throw NotImplementedError()
}
override fun update(localCollection: LocalTestCollection, remoteCollection: Collection) {}
}

View File

@@ -10,15 +10,14 @@ 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.SyncDispatcher
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.settings.AccountSettings
import at.bitfire.davdroid.util.DavUtils.lastSegment
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
import kotlinx.coroutines.CoroutineDispatcher
import okhttp3.HttpUrl
import okhttp3.RequestBody
import okhttp3.RequestBody.Companion.toRequestBody
@@ -26,26 +25,31 @@ import org.junit.Assert.assertEquals
class TestSyncManager @AssistedInject constructor(
@Assisted account: Account,
@Assisted accountSettings: AccountSettings,
@Assisted extras: Array<String>,
@Assisted authority: String,
@Assisted httpClient: HttpClient,
@Assisted syncResult: SyncResult,
@Assisted localCollection: LocalTestCollection,
@Assisted collection: Collection,
@SyncDispatcher syncDispatcher: CoroutineDispatcher
@Assisted collection: Collection
): SyncManager<LocalTestResource, LocalTestCollection, DavCollection>(
account,
accountSettings,
httpClient,
SyncDataType.EVENTS,
extras,
authority,
syncResult,
localCollection,
collection,
resync = null,
syncDispatcher
collection
) {
@AssistedFactory
interface Factory {
fun create(
account: Account,
accountSettings: AccountSettings,
extras: Array<String>,
authority: String,
httpClient: HttpClient,
syncResult: SyncResult,
localCollection: LocalTestCollection,
@@ -59,7 +63,7 @@ class TestSyncManager @AssistedInject constructor(
}
var didQueryCapabilities = false
override suspend fun queryCapabilities(): SyncState? {
override fun queryCapabilities(): SyncState? {
if (didQueryCapabilities)
throw IllegalStateException("queryCapabilities() must not be called twice")
didQueryCapabilities = true
@@ -85,7 +89,7 @@ class TestSyncManager @AssistedInject constructor(
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
@@ -95,7 +99,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

@@ -8,8 +8,9 @@ import android.accounts.Account
import android.accounts.AccountManager
import android.content.Context
import android.os.Bundle
import at.bitfire.davdroid.R
import androidx.test.platform.app.InstrumentationRegistry
import at.bitfire.davdroid.settings.SettingsManager
import at.bitfire.davdroid.test.R
import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.android.testing.HiltAndroidRule
import dagger.hilt.android.testing.HiltAndroidTest
@@ -21,17 +22,24 @@ import org.junit.Test
import javax.inject.Inject
@HiltAndroidTest
class SystemAccountUtilsTest {
class AccountUtilsTest {
@get:Rule
val hiltRule = HiltAndroidRule(this)
@Inject @ApplicationContext
@Inject
@ApplicationContext
lateinit var context: Context
val testContext = InstrumentationRegistry.getInstrumentation().context
@Inject
lateinit var settingsManager: SettingsManager
val account = Account(
"AccountUtilsTest",
testContext.getString(R.string.account_type_test)
)
@Before
fun setUp() {
hiltRule.inject()
@@ -44,7 +52,6 @@ class SystemAccountUtilsTest {
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))

View File

@@ -1,28 +1,28 @@
/*
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
*/
package at.bitfire.davdroid.sync.account
import android.accounts.Account
import android.accounts.AccountManager
import android.content.Context
import android.os.Bundle
import android.util.Log
import androidx.hilt.work.HiltWorkerFactory
import androidx.work.Configuration
import androidx.work.WorkerFactory
import androidx.work.WorkerParameters
import androidx.work.testing.TestListenableWorkerBuilder
import androidx.work.testing.WorkManagerTestInitHelper
import at.bitfire.davdroid.R
import at.bitfire.davdroid.TestUtils
import at.bitfire.davdroid.db.AppDatabase
import at.bitfire.davdroid.db.Collection
import at.bitfire.davdroid.db.Service
import at.bitfire.davdroid.resource.LocalAddressBook
import at.bitfire.davdroid.resource.LocalAddressBook.Companion.USER_DATA_COLLECTION_ID
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 okhttp3.HttpUrl.Companion.toHttpUrl
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Rule
@@ -38,7 +38,8 @@ class AccountsCleanupWorkerTest {
@Inject
lateinit var accountsCleanupWorkerFactory: AccountsCleanupWorker.Factory
@Inject @ApplicationContext
@Inject
@ApplicationContext
lateinit var context: Context
@Inject
@@ -58,13 +59,23 @@ class AccountsCleanupWorkerTest {
@Before
fun setUp() {
hiltRule.inject()
TestUtils.setUpWorkManager(context, workerFactory)
service = createTestService(Service.TYPE_CARDDAV)
// Prepare test account
accountManager = AccountManager.get(context)
service = createTestService()
addressBookAccountType = context.getString(R.string.account_type_address_book)
addressBookAccount = Account("Fancy address book account", addressBookAccountType)
addressBookAccount = Account(
"Fancy address book account",
addressBookAccountType
)
// Initialize WorkManager for instrumentation tests.
val config = Configuration.Builder()
.setMinimumLoggingLevel(Log.DEBUG)
.setWorkerFactory(workerFactory)
.build()
WorkManagerTestInitHelper.initializeTestWorkManager(context, config)
}
@After
@@ -75,87 +86,65 @@ class AccountsCleanupWorkerTest {
@Test
fun testCleanUpServices_noAccount() {
// Insert service that reference to invalid account
db.serviceDao().insertOrReplace(Service(id = 1, accountName = "test", type = Service.TYPE_CALDAV, principal = null))
assertNotNull(db.serviceDao().get(1))
// Create worker and run the method
val worker = TestListenableWorkerBuilder<AccountsCleanupWorker>(context)
.setWorkerFactory(workerFactory)
.build()
worker.cleanUpServices()
// Verify that service is deleted
assertNull(db.serviceDao().get(1))
}
@Test
fun testCleanUpServices_oneAccount() {
TestAccount.provide { existingAccount ->
// Insert services, one that reference the existing account and one that references an invalid account
db.serviceDao().insertOrReplace(Service(id = 1, accountName = existingAccount.name, type = Service.TYPE_CALDAV, principal = null))
assertNotNull(db.serviceDao().get(1))
db.serviceDao().insertOrReplace(Service(id = 2, accountName = "not existing", type = Service.TYPE_CARDDAV, principal = null))
assertNotNull(db.serviceDao().get(2))
// Create worker and run the method
val worker = TestListenableWorkerBuilder<AccountsCleanupWorker>(context)
.setWorkerFactory(workerFactory)
.build()
worker.cleanUpServices()
// Verify that one service is deleted and the other one is kept
assertNotNull(db.serviceDao().get(1))
assertNull(db.serviceDao().get(2))
}
}
@Test
fun testCleanUpAddressBooks_deletesAddressBookWithoutAccount() {
// Create address book account without corresponding account
fun testDeleteOrphanedAddressBookAccounts_deletesAddressBookAccountWithoutCollection() {
// Create address book account without corresponding collection
assertTrue(accountManager.addAccountExplicitly(addressBookAccount, null, null))
assertEquals(listOf(addressBookAccount), accountManager.getAccountsByType(addressBookAccountType).toList())
val addressBookAccounts = accountManager.getAccountsByType(addressBookAccountType)
assertEquals(addressBookAccount, addressBookAccounts.firstOrNull())
// Create worker and run the method
val worker = TestListenableWorkerBuilder<AccountsCleanupWorker>(context)
.setWorkerFactory(workerFactory)
.setWorkerFactory(object: WorkerFactory() {
override fun createWorker(appContext: Context, workerClassName: String, workerParameters: WorkerParameters) =
accountsCleanupWorkerFactory.create(appContext, workerParameters)
})
.build()
worker.cleanUpAddressBooks()
worker.deleteOrphanedAddressBookAccounts(addressBookAccounts)
// Verify account was deleted
assertTrue(accountManager.getAccountsByType(addressBookAccountType).isEmpty())
}
@Test
fun testCleanUpAddressBooks_keepsAddressBookWithAccount() {
TestAccount.provide { existingAccount ->
// Create address book account _with_ corresponding account and verify
val userData = Bundle(2).apply {
putString(LocalAddressBook.USER_DATA_ACCOUNT_NAME, existingAccount.name)
putString(LocalAddressBook.USER_DATA_ACCOUNT_TYPE, existingAccount.type)
}
assertTrue(accountManager.addAccountExplicitly(addressBookAccount, null, userData))
assertEquals(listOf(addressBookAccount), accountManager.getAccountsByType(addressBookAccountType).toList())
// Create worker and run the method
val worker = TestListenableWorkerBuilder<AccountsCleanupWorker>(context)
.setWorkerFactory(workerFactory)
.build()
worker.cleanUpAddressBooks()
// Verify account was _not_ deleted
assertEquals(listOf(addressBookAccount), accountManager.getAccountsByType(addressBookAccountType).toList())
fun testDeleteOrphanedAddressBookAccounts_leavesAddressBookAccountWithCollection() {
// Create address book account _with_ corresponding collection and verify
val randomCollectionId = 12345L
val userData = Bundle(1).apply {
putString(USER_DATA_COLLECTION_ID, "$randomCollectionId")
}
assertTrue(accountManager.addAccountExplicitly(addressBookAccount, null, userData))
val addressBookAccounts = accountManager.getAccountsByType(addressBookAccountType)
assertEquals(randomCollectionId, accountManager.getUserData(addressBookAccount, USER_DATA_COLLECTION_ID).toLong())
// Create the collection
val collectionDao = db.collectionDao()
collectionDao.insert(Collection(
randomCollectionId,
serviceId = service.id,
type = Collection.TYPE_ADDRESSBOOK,
url = "http://www.example.com/yay.php".toHttpUrl()
))
// Create worker and run the method
val worker = TestListenableWorkerBuilder<AccountsCleanupWorker>(context)
.setWorkerFactory(object: WorkerFactory() {
override fun createWorker(appContext: Context, workerClassName: String, workerParameters: WorkerParameters) =
accountsCleanupWorkerFactory.create(appContext, workerParameters)
})
.build()
worker.deleteOrphanedAddressBookAccounts(addressBookAccounts)
// Verify account was _not_ deleted
assertEquals(addressBookAccount, addressBookAccounts.firstOrNull())
}
// helpers
private fun createTestService(): Service {
val service = Service(id=0, accountName="test", type=Service.TYPE_CARDDAV, principal = null)
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)!!
}

View File

@@ -1,53 +0,0 @@
/*
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
*/
package at.bitfire.davdroid.sync.account
import android.accounts.Account
import android.accounts.AccountManager
import androidx.test.platform.app.InstrumentationRegistry
import at.bitfire.davdroid.R
import at.bitfire.davdroid.settings.AccountSettings
import org.junit.Assert.assertTrue
object TestAccount {
private val targetContext by lazy { InstrumentationRegistry.getInstrumentation().targetContext }
/**
* Creates a test account, usually in the `Before` setUp of a test.
*
* Remove it with [remove].
*/
fun create(version: Int = AccountSettings.CURRENT_VERSION): Account {
val accountType = targetContext.getString(R.string.account_type)
val account = Account("Test Account", accountType)
val initialData = AccountSettings.initialUserData(null)
initialData.putString(AccountSettings.KEY_SETTINGS_VERSION, version.toString())
assertTrue(SystemAccountUtils.createAccount(targetContext, account, initialData))
return account
}
/**
* Removes a test account, usually in the `@After` tearDown of a test.
*/
fun remove(account: Account) {
val am = AccountManager.get(targetContext)
assertTrue(am.removeAccountExplicitly(account))
}
/**
* Convenience method to create a test account and remove it after executing the block.
*/
fun provide(version: Int = AccountSettings.CURRENT_VERSION, block: (Account) -> Unit) {
val account = create(version)
try {
block(account)
} finally {
remove(account)
}
}
}

View File

@@ -0,0 +1,96 @@
/***************************************************************************************************
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
**************************************************************************************************/
package at.bitfire.davdroid.sync.account
import android.accounts.AbstractAccountAuthenticator
import android.accounts.Account
import android.accounts.AccountAuthenticatorResponse
import android.accounts.AccountManager
import android.app.Service
import android.content.Context
import android.content.Intent
import android.os.Bundle
import androidx.test.platform.app.InstrumentationRegistry
import at.bitfire.davdroid.settings.AccountSettings
import at.bitfire.davdroid.test.R
import org.junit.Assert.assertTrue
/**
* Handles the test account type, which has no sync adapters and side effects that run unintentionally.
*
* Usually used like this:
*
* ```
* lateinit var account: Account
*
* @Before
* fun setUp() {
* account = TestAccountAuthenticator.create()
*
* // You can now use the test account.
* }
*
* @After
* fun tearDown() {
* TestAccountAuthenticator.remove(account)
* }
* ```
*/
class TestAccountAuthenticator: Service() {
companion object {
val context by lazy { InstrumentationRegistry.getInstrumentation().context }
/**
* Creates a test account, usually in the `Before` setUp of a test.
*
* Remove it with [remove].
*/
fun create(): Account {
val accountType = context.getString(R.string.account_type_test)
val account = Account("Test Account", accountType)
assertTrue(SystemAccountUtils.createAccount(context, account, AccountSettings.initialUserData(null)))
return account
}
/**
* Removes a test account, usually in the `@After` tearDown of a test.
*/
fun remove(account: Account) {
val am = AccountManager.get(context)
am.removeAccountExplicitly(account)
}
}
private lateinit var accountAuthenticator: AccountAuthenticator
override fun onCreate() {
accountAuthenticator = AccountAuthenticator(this)
}
override fun onBind(intent: Intent?) =
accountAuthenticator.iBinder.takeIf { intent?.action == AccountManager.ACTION_AUTHENTICATOR_INTENT }
private class AccountAuthenticator(
val context: Context
): AbstractAccountAuthenticator(context) {
override fun addAccount(response: AccountAuthenticatorResponse?, accountType: String?, authTokenType: String?, requiredFeatures: Array<String>?, options: Bundle?) = null
override fun editProperties(response: AccountAuthenticatorResponse?, accountType: String?) = null
override fun getAuthTokenLabel(p0: String?) = null
override fun confirmCredentials(p0: AccountAuthenticatorResponse?, p1: Account?, p2: Bundle?) = null
override fun updateCredentials(p0: AccountAuthenticatorResponse?, p1: Account?, p2: String?, p3: Bundle?) = null
override fun getAuthToken(p0: AccountAuthenticatorResponse?, p1: Account?, p2: String?, p3: Bundle?) = null
override fun hasFeatures(p0: AccountAuthenticatorResponse?, p1: Account?, p2: Array<out String>?) = null
}
}

View File

@@ -1,28 +1,30 @@
/*
/***************************************************************************************************
* 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.content.Context
import android.provider.CalendarContract
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
import at.bitfire.davdroid.sync.SyncDataType
import at.bitfire.davdroid.sync.account.TestAccount
import at.bitfire.davdroid.sync.account.TestAccountAuthenticator
import at.bitfire.davdroid.test.R
import dagger.hilt.android.qualifiers.ApplicationContext
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.test.runTest
import kotlinx.coroutines.runBlocking
import org.junit.After
import org.junit.Assert.assertTrue
import org.junit.Before
@@ -33,8 +35,10 @@ import javax.inject.Inject
@HiltAndroidTest
class PeriodicSyncWorkerTest {
@Inject @ApplicationContext
@Inject
@ApplicationContext
lateinit var context: Context
val testContext = InstrumentationRegistry.getInstrumentation().context
@Inject
lateinit var syncWorkerFactory: PeriodicSyncWorker.Factory
@@ -42,37 +46,39 @@ class PeriodicSyncWorkerTest {
@get:Rule
val hiltRule = HiltAndroidRule(this)
@get:Rule
val mockkRule = MockKRule(this)
lateinit var account: Account
@Before
fun setUp() {
hiltRule.inject()
TestUtils.setUpWorkManager(context)
account = TestAccount.create()
// Initialize WorkManager for instrumentation tests.
val config = Configuration.Builder()
.setMinimumLoggingLevel(Log.DEBUG)
.build()
WorkManagerTestInitHelper.initializeTestWorkManager(context, config)
account = TestAccountAuthenticator.create()
}
@After
fun tearDown() {
TestAccount.remove(account)
TestAccountAuthenticator.remove(account)
}
@Test
fun doWork_cancelsItselfOnInvalidAccount() = runTest {
val invalidAccount = Account("invalid", context.getString(R.string.account_type))
fun doWork_cancelsItselfOnInvalidAccount() {
val invalidAccount = Account("invalid", testContext.getString(R.string.account_type_test))
// Run PeriodicSyncWorker as TestWorker
val inputData = workDataOf(
BaseSyncWorker.INPUT_DATA_TYPE to SyncDataType.EVENTS.toString(),
BaseSyncWorker.INPUT_AUTHORITY to CalendarContract.AUTHORITY,
BaseSyncWorker.INPUT_ACCOUNT_NAME to invalidAccount.name,
BaseSyncWorker.INPUT_ACCOUNT_TYPE to invalidAccount.type
)
// observe WorkManager cancellation call
// mock WorkManager to observe cancellation call
val workManager = WorkManager.getInstance(context)
mockkObject(workManager)
@@ -83,7 +89,9 @@ class PeriodicSyncWorkerTest {
syncWorkerFactory.create(appContext, workerParameters)
})
.build()
val result = testWorker.doWork()
val result = runBlocking {
testWorker.doWork()
}
assertTrue(result is ListenableWorker.Result.Failure)
// verify that worker called WorkManager.cancelWorkById(<its ID>)

View File

@@ -6,11 +6,14 @@ package at.bitfire.davdroid.sync.worker
import android.accounts.Account
import android.content.Context
import android.provider.CalendarContract
import android.util.Log
import androidx.hilt.work.HiltWorkerFactory
import androidx.work.Configuration
import androidx.work.testing.WorkManagerTestInitHelper
import at.bitfire.davdroid.TestUtils
import at.bitfire.davdroid.TestUtils.workScheduledOrRunning
import at.bitfire.davdroid.sync.SyncDataType
import at.bitfire.davdroid.sync.account.TestAccount
import at.bitfire.davdroid.sync.account.TestAccountAuthenticator
import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.android.testing.HiltAndroidRule
import dagger.hilt.android.testing.HiltAndroidTest
@@ -44,14 +47,20 @@ class SyncWorkerManagerTest {
@Before
fun setUp() {
hiltRule.inject()
TestUtils.setUpWorkManager(context, workerFactory)
account = TestAccount.create()
// Initialize WorkManager for instrumentation tests.
val config = Configuration.Builder()
.setMinimumLoggingLevel(Log.DEBUG)
.setWorkerFactory(workerFactory)
.build()
WorkManagerTestInitHelper.initializeTestWorkManager(context, config)
account = TestAccountAuthenticator.create()
}
@After
fun tearDown() {
TestAccount.remove(account)
TestAccountAuthenticator.remove(account)
}
@@ -59,10 +68,10 @@ class SyncWorkerManagerTest {
@Test
fun testEnqueueOneTime() {
val workerName = OneTimeSyncWorker.workerName(account, SyncDataType.EVENTS)
val workerName = OneTimeSyncWorker.workerName(account, CalendarContract.AUTHORITY)
assertFalse(TestUtils.workScheduledOrRunningOrSuccessful(context, workerName))
val returnedName = syncWorkerManager.enqueueOneTime(account, SyncDataType.EVENTS)
val returnedName = syncWorkerManager.enqueueOneTime(account, CalendarContract.AUTHORITY)
assertEquals(workerName, returnedName)
assertTrue(TestUtils.workScheduledOrRunningOrSuccessful(context, workerName))
}
@@ -72,18 +81,18 @@ class SyncWorkerManagerTest {
@Test
fun enablePeriodic() {
syncWorkerManager.enablePeriodic(account, SyncDataType.EVENTS, 60, false).result.get()
syncWorkerManager.enablePeriodic(account, CalendarContract.AUTHORITY, 60, false).result.get()
val workerName = PeriodicSyncWorker.workerName(account, SyncDataType.EVENTS)
val workerName = PeriodicSyncWorker.workerName(account, CalendarContract.AUTHORITY)
assertTrue(workScheduledOrRunning(context, workerName))
}
@Test
fun disablePeriodic() {
syncWorkerManager.enablePeriodic(account, SyncDataType.EVENTS, 60, false).result.get()
syncWorkerManager.disablePeriodic(account, SyncDataType.EVENTS).result.get()
syncWorkerManager.enablePeriodic(account, CalendarContract.AUTHORITY, 60, false).result.get()
syncWorkerManager.disablePeriodic(account, CalendarContract.AUTHORITY).result.get()
val workerName = PeriodicSyncWorker.workerName(account, SyncDataType.EVENTS)
val workerName = PeriodicSyncWorker.workerName(account, CalendarContract.AUTHORITY)
assertFalse(workScheduledOrRunning(context, workerName))
}

View File

@@ -1,94 +0,0 @@
/*
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
*/
package at.bitfire.davdroid.ui
import at.bitfire.davdroid.db.Collection
import at.bitfire.davdroid.db.Service
import at.bitfire.davdroid.push.PushRegistrationManager
import at.bitfire.davdroid.repository.DavCollectionRepository
import at.bitfire.davdroid.repository.DavServiceRepository
import at.bitfire.davdroid.sync.worker.SyncWorkerManager
import dagger.hilt.android.testing.BindValue
import dagger.hilt.android.testing.HiltAndroidRule
import dagger.hilt.android.testing.HiltAndroidTest
import io.mockk.coVerify
import io.mockk.impl.annotations.RelaxedMockK
import io.mockk.junit4.MockKRule
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.advanceUntilIdle
import kotlinx.coroutines.test.runTest
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
@OptIn(ExperimentalCoroutinesApi::class)
@HiltAndroidTest
class CollectionSelectedUseCaseTest {
@get:Rule
val hiltRule = HiltAndroidRule(this)
@get:Rule
val mockkRule = MockKRule(this)
val collection = Collection(
id = 2,
serviceId = 1,
type = Collection.Companion.TYPE_CALENDAR,
url = "https://example.com".toHttpUrl()
)
@Inject
lateinit var collectionRepository: DavCollectionRepository
val service = Service(
id = 1,
type = Service.Companion.TYPE_CALDAV,
accountName = "test@example.com"
)
@BindValue
@RelaxedMockK
lateinit var pushRegistrationManager: PushRegistrationManager
@Inject
lateinit var serviceRepository: DavServiceRepository
@BindValue
@RelaxedMockK
lateinit var syncWorkerManager: SyncWorkerManager
@Inject
lateinit var useCase: CollectionSelectedUseCase
@Before
fun setUp() {
hiltRule.inject()
serviceRepository.insertOrReplaceBlocking(service)
collectionRepository.insertOrUpdateByUrl(collection)
}
@After
fun tearDown() {
serviceRepository.deleteAllBlocking()
}
@Test
fun testHandleWithDelay() = runTest {
useCase.handleWithDelay(collectionId = collection.id)
advanceUntilIdle()
coVerify {
syncWorkerManager.enqueueOneTimeAllAuthorities(any())
pushRegistrationManager.update(service.id)
}
}
}

View File

@@ -1,67 +0,0 @@
/*
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
*/
package at.bitfire.davdroid.ui.setup
import android.content.Intent
import android.net.Uri
import org.junit.Assert.assertEquals
import org.junit.Test
class LoginActivityTest {
@Test
fun loginInfoFromIntent() {
val intent = Intent().apply {
data = Uri.parse("https://example.com/nextcloud")
putExtra(LoginActivity.EXTRA_USERNAME, "user")
putExtra(LoginActivity.EXTRA_PASSWORD, "password")
}
val loginInfo = LoginActivity.loginInfoFromIntent(intent)
assertEquals("https://example.com/nextcloud", loginInfo.baseUri.toString())
assertEquals("user", loginInfo.credentials!!.username)
assertEquals("password", loginInfo.credentials.password?.concatToString())
}
@Test
fun loginInfoFromIntent_withPort() {
val intent = Intent().apply {
data = Uri.parse("https://example.com:444/nextcloud")
putExtra(LoginActivity.EXTRA_USERNAME, "user")
putExtra(LoginActivity.EXTRA_PASSWORD, "password")
}
val loginInfo = LoginActivity.loginInfoFromIntent(intent)
assertEquals("https://example.com:444/nextcloud", loginInfo.baseUri.toString())
assertEquals("user", loginInfo.credentials!!.username)
assertEquals("password", loginInfo.credentials.password?.concatToString())
}
@Test
fun loginInfoFromIntent_implicit() {
val intent = Intent(Intent.ACTION_VIEW, Uri.parse("davx5://user:password@example.com/path"))
val loginInfo = LoginActivity.loginInfoFromIntent(intent)
assertEquals("https://example.com/path", loginInfo.baseUri.toString())
assertEquals("user", loginInfo.credentials!!.username)
assertEquals("password", loginInfo.credentials.password?.concatToString())
}
@Test
fun loginInfoFromIntent_implicit_withPort() {
val intent = Intent(Intent.ACTION_VIEW, Uri.parse("davx5://user:password@example.com:0/path"))
val loginInfo = LoginActivity.loginInfoFromIntent(intent)
assertEquals("https://example.com:0/path", loginInfo.baseUri.toString())
assertEquals("user", loginInfo.credentials!!.username)
assertEquals("password", loginInfo.credentials.password?.concatToString())
}
@Test
fun loginInfoFromIntent_implicit_email() {
val intent = Intent(Intent.ACTION_VIEW, Uri.parse("mailto:user@example.com"))
val loginInfo = LoginActivity.loginInfoFromIntent(intent)
assertEquals(null, loginInfo.baseUri)
assertEquals("user@example.com", loginInfo.credentials!!.username)
assertEquals(null, loginInfo.credentials.password?.concatToString())
}
}

View File

@@ -4,7 +4,7 @@
package at.bitfire.davdroid.webdav
import at.bitfire.davdroid.settings.Credentials
import at.bitfire.davdroid.db.Credentials
import dagger.hilt.android.testing.HiltAndroidRule
import dagger.hilt.android.testing.HiltAndroidTest
import org.junit.Assert.assertEquals
@@ -30,8 +30,8 @@ class CredentialsStoreTest {
@Test
fun testSetGetDelete() {
store.setCredentials(0, Credentials(username = "myname", password = "12345".toCharArray()))
assertEquals(Credentials(username = "myname", password = "12345".toCharArray()), store.getCredentials(0))
store.setCredentials(0, Credentials(username = "myname", password = "12345"))
assertEquals(Credentials(username = "myname", password = "12345"), store.getCredentials(0))
store.setCredentials(0, null)
assertNull(store.getCredentials(0))

View File

@@ -2,10 +2,11 @@
* 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 at.bitfire.davdroid.R
import at.bitfire.davdroid.db.AppDatabase
import at.bitfire.davdroid.db.WebDavDocument
import at.bitfire.davdroid.db.WebDavMount
@@ -13,9 +14,7 @@ import at.bitfire.davdroid.network.HttpClient
import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.android.testing.HiltAndroidRule
import dagger.hilt.android.testing.HiltAndroidTest
import io.mockk.junit4.MockKRule
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.test.runTest
import okhttp3.CookieJar
import okhttp3.mockwebserver.Dispatcher
import okhttp3.mockwebserver.MockResponse
import okhttp3.mockwebserver.MockWebServer
@@ -26,96 +25,92 @@ 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 {
companion object {
private const val PATH_WEBDAV_ROOT = "/webdav"
}
@get:Rule
val hiltRule = HiltAndroidRule(this)
@get:Rule
val mockkRule = MockKRule(this)
@Inject @ApplicationContext
@Inject
@ApplicationContext
lateinit var context: Context
@Inject
lateinit var db: AppDatabase
@Inject
lateinit var operation: QueryChildDocumentsOperation
@Inject
lateinit var httpClientBuilder: HttpClient.Builder
@Inject
lateinit var testDispatcher: TestDispatcher
private lateinit var server: MockWebServer
private lateinit var client: HttpClient
private lateinit var mount: WebDavMount
private lateinit var rootDocument: WebDavDocument
lateinit var logger: java.util.logging.Logger
@Before
fun setUp() {
hiltRule.inject()
}
// create server and client
server = MockWebServer().apply {
dispatcher = testDispatcher
start()
}
client = httpClientBuilder.build()
private var mockServer = MockWebServer()
private lateinit var client: HttpClient
@Before
fun mockServerSetup() {
// Start mock web server
mockServer.dispatcher = TestDispatcher(logger)
mockServer.start()
client = HttpClient.Builder(context).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() {
client.close()
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, logger, 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 +120,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, logger, 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, logger, 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, logger, cookieStore, CredentialsStore(context), context.getString(R.string.webdav_authority))
.queryChildren(parent1)
DavDocumentsProvider.DavDocumentsActor(context, db, logger, 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,8 +182,8 @@ class QueryChildDocumentsOperationTest {
// mock server
class TestDispatcher @Inject constructor(
private val logger: Logger
class TestDispatcher(
private val logger: java.util.logging.Logger
): Dispatcher() {
data class Resource(
@@ -181,15 +192,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 +210,7 @@ class QueryChildDocumentsOperationTest {
),
Resource("Library",
"<resourcetype><collection/></resourcetype>" +
"<displayname>Library</displayname>"
"<displayname>Library</displayname>"
)
),
@@ -218,16 +229,17 @@ 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("Query path: $requestPath")
logger.info("Response: $multistatus")
return MockResponse()
.setResponseCode(207)
@@ -239,9 +251,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

@@ -1,6 +1,15 @@
<?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>
<string name="account_type_test">at.bitfire.davdroid.test</string>
</resources>

View File

@@ -0,0 +1,5 @@
<account-authenticator xmlns:android="http://schemas.android.com/apk/res/android"
android:accountType="@string/account_type_test"
android:icon="@android:drawable/star_on"
android:smallIcon="@android:drawable/star_on"
android:label="Test Account" />

View File

@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="primaryColor">#E07C25</color>
<color name="primaryLightColor">#E5A371</color>
<color name="primaryDarkColor">#7C3E07</color>
</resources>

View File

@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="app_name" translatable="false">DAVx⁵ Debug</string>
</resources>

View File

@@ -1,8 +1,4 @@
<?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">
@@ -18,6 +14,11 @@
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED"/>
<uses-permission android:name="android.permission.REQUEST_IGNORE_BATTERY_OPTIMIZATIONS"/>
<!-- account management permissions not required for own accounts since API level 22 -->
<uses-permission android:name="android.permission.AUTHENTICATE_ACCOUNTS" android:maxSdkVersion="22"/>
<uses-permission android:name="android.permission.GET_ACCOUNTS" android:maxSdkVersion="22"/>
<uses-permission android:name="android.permission.MANAGE_ACCOUNTS" android:maxSdkVersion="22"/>
<!-- other permissions -->
<!-- android.permission-group.CONTACTS -->
<uses-permission android:name="android.permission.READ_CONTACTS"/>
@@ -53,19 +54,11 @@
tools:ignore="UnusedAttribute"
android:supportsRtl="true">
<!-- Required for Hilt/WorkManager integration. See
- https://developer.android.com/develop/background-work/background-tasks/persistent/configuration/custom-configuration#remove-default
- https://developer.android.com/training/dependency-injection/hilt-jetpack#workmanager
However, we must not disable AndroidX startup completely, as it's needed by other libraries like okhttp. -->
<!-- required for Hilt/WorkManager integration -->
<provider
android:name="androidx.startup.InitializationProvider"
android:authorities="${applicationId}.androidx-startup"
android:exported="false"
tools:node="merge">
<meta-data
android:name="androidx.work.WorkManagerInitializer"
android:value="androidx.startup"
tools:node="remove" />
tools:node="remove">
</provider>
<!-- Remove the node added by AppAuth (remove only from net.openid.appauth library, not from our flavor manifest files) -->
@@ -186,7 +179,7 @@
android:resource="@xml/account_authenticator"/>
</service>
<service
android:name=".sync.adapter.CalendarsSyncAdapterService"
android:name=".sync.CalendarsSyncAdapterService"
android:exported="true"
tools:ignore="ExportedService">
<intent-filter>
@@ -197,7 +190,7 @@
android:resource="@xml/sync_calendars"/>
</service>
<service
android:name=".sync.adapter.JtxSyncAdapterService"
android:name=".sync.JtxSyncAdapterService"
android:exported="true"
tools:ignore="ExportedService">
<intent-filter>
@@ -208,7 +201,7 @@
android:resource="@xml/sync_notes"/>
</service>
<service
android:name=".sync.adapter.OpenTasksSyncAdapterService"
android:name=".sync.OpenTasksSyncAdapterService"
android:exported="true"
tools:ignore="ExportedService">
<intent-filter>
@@ -219,7 +212,7 @@
android:resource="@xml/sync_opentasks"/>
</service>
<service
android:name=".sync.adapter.TasksOrgSyncAdapterService"
android:name=".sync.TasksOrgSyncAdapterService"
android:exported="true"
tools:ignore="ExportedService">
<intent-filter>
@@ -254,7 +247,7 @@
android:resource="@xml/account_authenticator_address_book"/>
</service>
<service
android:name=".sync.adapter.ContactsSyncAdapterService"
android:name=".sync.ContactsSyncAdapterService"
android:exported="true"
tools:ignore="ExportedService">
<intent-filter>
@@ -280,16 +273,18 @@
android:resource="@xml/debug_paths" />
</provider>
<!-- UnifiedPush -->
<service android:exported="false" android:name=".push.UnifiedPushService">
<!-- UnifiedPush receiver -->
<receiver android:exported="true" android:enabled="true" android:name=".push.UnifiedPushReceiver" tools:ignore="ExportedReceiver">
<intent-filter>
<action android:name="org.unifiedpush.android.connector.PUSH_EVENT"/>
<action android:name="org.unifiedpush.android.connector.MESSAGE"/>
<action android:name="org.unifiedpush.android.connector.UNREGISTERED"/>
<action android:name="org.unifiedpush.android.connector.NEW_ENDPOINT"/>
<action android:name="org.unifiedpush.android.connector.REGISTRATION_FAILED"/>
</intent-filter>
</service>
</receiver>
<!-- Widgets -->
<receiver android:name=".ui.widget.LabeledSyncButtonWidgetReceiver"
android:label="@string/widget_labeled_sync_label"
<receiver android:name=".ui.widget.SyncButtonWidgetReceiver"
android:exported="true">
<intent-filter>
<action android:name="android.appwidget.action.APPWIDGET_UPDATE" />
@@ -297,18 +292,7 @@
<meta-data
android:name="android.appwidget.provider"
android:resource="@xml/widget_info_labeled_sync_button" />
</receiver>
<receiver android:name=".ui.widget.IconSyncButtonWidgetReceiver"
android:label="@string/widget_icon_sync_label"
android:exported="true">
<intent-filter>
<action android:name="android.appwidget.action.APPWIDGET_UPDATE" />
</intent-filter>
<meta-data
android:name="android.appwidget.provider"
android:resource="@xml/widget_info_icon_sync_button" />
android:resource="@xml/widget_info_sync_button" />
</receiver>
</application>

View File

@@ -1 +1 @@
{"ar_SA":["abdunnasir"],"bg":["dpa_transifex"],"ca":["Kintu","jordibrus","zagur"],"cs":["pavelb","svetlemodry","tomas.odehnal"],"da":["Tntdruid_","knutztar","mjjzf","twikedk"],"de":["Atalanttore","TheName","Waldmeisda","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"],"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"]}
{"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

@@ -3,9 +3,8 @@
*/
package at.bitfire.davdroid
import at.bitfire.synctools.icalendar.ical4jVersion
import ezvcard.Ezvcard
import net.fortuna.ical4j.model.property.ProdId
import android.net.Uri
import androidx.core.net.toUri
/**
* Brand-specific constants like (non-theme) colors, homepage URLs etc.
@@ -14,10 +13,41 @@ 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"
// product IDs for iCalendar/vCard
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 iCalProdId = ProdId("DAVx5/${BuildConfig.VERSION_NAME} ical4j/$ical4jVersion")
const val vCardProdId = "+//IDN bitfire.at//DAVx5/${BuildConfig.VERSION_NAME} ez-vcard/${Ezvcard.VERSION}"
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

@@ -7,7 +7,7 @@ package at.bitfire.davdroid
import java.util.Collections
class TextTable(
val headers: List<String>
vararg val headers: String
) {
companion object {
@@ -18,12 +18,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 +29,6 @@ class TextTable(
}.toTypedArray()
}
fun addLine(vararg values: Any?) = addLine(values.toList())
override fun toString(): String {
val sb = StringBuilder()

View File

@@ -10,10 +10,11 @@ import android.content.Context
import android.content.Intent
import android.database.sqlite.SQLiteQueryBuilder
import androidx.core.app.NotificationCompat
import androidx.core.app.TaskStackBuilder
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
@@ -22,9 +23,7 @@ import androidx.room.migration.Migration
import androidx.sqlite.db.SupportSQLiteDatabase
import at.bitfire.davdroid.R
import at.bitfire.davdroid.TextTable
import at.bitfire.davdroid.db.migration.AutoMigration12
import at.bitfire.davdroid.db.migration.AutoMigration16
import at.bitfire.davdroid.db.migration.AutoMigration18
import at.bitfire.davdroid.servicedetection.RefreshCollectionsWorker
import at.bitfire.davdroid.ui.AccountsActivity
import at.bitfire.davdroid.ui.NotificationRegistry
import dagger.Module
@@ -33,15 +32,10 @@ import dagger.hilt.InstallIn
import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.components.SingletonComponent
import java.io.Writer
import java.util.logging.Logger
import javax.inject.Singleton
/**
* The app database. Managed via android jetpack room. Room provides an abstraction
* layer over SQLite.
*
* Note: In SQLite PRAGMA foreign_keys is off by default. Room activates it for
* production (non-test) databases.
*/
@Suppress("ClassName")
@Database(entities = [
Service::class,
HomeSet::class,
@@ -50,16 +44,12 @@ import javax.inject.Singleton
SyncStats::class,
WebDavDocument::class,
WebDavMount::class
], exportSchema = true, version = 18, autoMigrations = [
AutoMigration(from = 17, to = 18, spec = AutoMigration18::class),
AutoMigration(from = 16, to = 17), // collection: add VAPID key
AutoMigration(from = 15, to = 16, spec = AutoMigration16::class),
AutoMigration(from = 14, to = 15),
AutoMigration(from = 13, to = 14),
AutoMigration(from = 12, to = 13),
AutoMigration(from = 11, to = 12, spec = AutoMigration12::class),
], exportSchema = true, version = 14, autoMigrations = [
AutoMigration(from = 9, to = 10),
AutoMigration(from = 10, to = 11),
AutoMigration(from = 9, to = 10)
AutoMigration(from = 11, to = 12, spec = AppDatabase.AutoMigration11_12::class),
AutoMigration(from = 12, to = 13),
AutoMigration(from = 13, to = 14)
])
@TypeConverters(Converters::class)
abstract class AppDatabase: RoomDatabase() {
@@ -67,47 +57,204 @@ abstract class AppDatabase: RoomDatabase() {
@Module
@InstallIn(SingletonComponent::class)
object AppDatabaseModule {
@Provides
@Singleton
fun appDatabase(
autoMigrations: Set<@JvmSuppressWildcards AutoMigrationSpec>,
@ApplicationContext context: Context,
manualMigrations: Set<@JvmSuppressWildcards Migration>,
notificationRegistry: NotificationRegistry
): AppDatabase = Room
.databaseBuilder(context, AppDatabase::class.java, "services.db")
.addMigrations(*manualMigrations.toTypedArray())
.apply {
for (spec in autoMigrations)
addAutoMigrationSpec(spec)
}
.fallbackToDestructiveMigration() // as a last fallback, recreate database instead of crashing
.addCallback(object: Callback() {
override fun onDestructiveMigration(db: SupportSQLiteDatabase) {
notificationRegistry.notifyIfPossible(NotificationRegistry.NOTIFY_DATABASE_CORRUPTED) {
val launcherIntent = Intent(context, AccountsActivity::class.java)
NotificationCompat.Builder(context, notificationRegistry.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(
TaskStackBuilder.create(context)
.addNextIntent(launcherIntent)
.getPendingIntent(0, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE)
)
.setAutoCancel(true)
.build()
}
): 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) {
notificationRegistry.notifyIfPossible(NotificationRegistry.NOTIFY_DATABASE_CORRUPTED) {
val launcherIntent = Intent(context, AccountsActivity::class.java)
NotificationCompat.Builder(context, notificationRegistry.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()
}
// 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.removeAccountExplicitly(account)
// 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.removeAccountExplicitly(account)
}
})
.build()
}
// auto migrations
@ProvidedAutoMigrationSpec
@DeleteColumn(tableName = "collection", columnName = "owner")
class AutoMigration11_12(val context: Context): AutoMigrationSpec {
override fun onPostMigrate(db: SupportSQLiteDatabase) {
Logger.getGlobal().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)
}
})
.build()
}
}
}
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.getGlobal().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"))
}
}
)
}

View File

@@ -4,7 +4,6 @@
package at.bitfire.davdroid.db
import androidx.annotation.StringDef
import androidx.room.ColumnInfo
import androidx.room.Entity
import androidx.room.ForeignKey
@@ -15,30 +14,19 @@ 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.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 okhttp3.HttpUrl
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
@Retention(AnnotationRetention.SOURCE)
@StringDef(
Collection.TYPE_ADDRESSBOOK,
Collection.TYPE_CALENDAR,
Collection.TYPE_WEBCAL
)
annotation class CollectionType
@Entity(tableName = "collection",
foreignKeys = [
ForeignKey(entity = Service::class, parentColumns = arrayOf("id"), childColumns = arrayOf("serviceId"), onDelete = ForeignKey.CASCADE),
@@ -55,99 +43,92 @@ annotation class CollectionType
)
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,
var privWriteContent: Boolean = true,
/**
* Whether we have the permission to delete the collection on the server
*/
val privUnbind: Boolean = true,
var 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 forceReadOnly: Boolean = false,
/**
* Human-readable name of the collection
*/
val displayName: String? = null,
var displayName: String? = null,
/**
* Human-readable description of the collection
*/
val description: 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
) {
@@ -184,7 +165,7 @@ data class Collection(
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
@@ -196,11 +177,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
@@ -227,13 +204,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
@@ -245,13 +217,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
)
}
@@ -259,7 +230,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,11 @@ 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 sync AND supportsWebPush AND pushTopic IS NOT NULL")
suspend fun getPushCapableSyncCollections(): 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 pushSubscription IS NOT NULL AND NOT sync")
suspend fun getPushRegisteredAndNotSyncable(): List<Collection>
@Insert(onConflict = OnConflictStrategy.IGNORE)
fun insert(collection: Collection): Long
@@ -103,8 +87,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")
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 +113,4 @@ interface CollectionDao {
@Delete
fun delete(collection: Collection)
}
}

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