mirror of
https://github.com/bitfireAT/davx5-ose.git
synced 2026-02-26 03:47:15 -05:00
Compare commits
151 Commits
migrate-in
...
nav3-migra
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e9fb031d0a | ||
|
|
d1c3548ccc | ||
|
|
762095c7ce | ||
|
|
fa50fe4c30 | ||
|
|
ba4d3b2fd1 | ||
|
|
0fed85fdc3 | ||
|
|
6fbaea9487 | ||
|
|
fc2bc8aa47 | ||
|
|
0321e4ab8f | ||
|
|
711543c5f1 | ||
|
|
5c485834e9 | ||
|
|
f349f1fec8 | ||
|
|
e6413506cb | ||
|
|
d9b36a0e34 | ||
|
|
514623c0f2 | ||
|
|
9978850594 | ||
|
|
e1f5b2e3c1 | ||
|
|
ad0cdb5c0c | ||
|
|
de9d58bc20 | ||
|
|
a6238a4131 | ||
|
|
bbc7fbfa1e | ||
|
|
3ba4dfb157 | ||
|
|
4544cd9b5c | ||
|
|
24026edad0 | ||
|
|
d4b5039297 | ||
|
|
979f2257de | ||
|
|
3efb8d5c62 | ||
|
|
ec657519a9 | ||
|
|
a835557b35 | ||
|
|
19f86670bf | ||
|
|
f74d14e2a2 | ||
|
|
57ef059099 | ||
|
|
f157a819b7 | ||
|
|
9bbc4c096d | ||
|
|
b306219015 | ||
|
|
469c30b511 | ||
|
|
05f6c7ab0b | ||
|
|
eeb94d4039 | ||
|
|
7bf9172bdc | ||
|
|
dec5be5690 | ||
|
|
d8e8129d7b | ||
|
|
c9fb7dc7a2 | ||
|
|
ededcb98e1 | ||
|
|
fc10a315d5 | ||
|
|
cfeb6b3974 | ||
|
|
a15902e586 | ||
|
|
47afddbd08 | ||
|
|
5f647b7403 | ||
|
|
d460e4ca7b | ||
|
|
827a1b954f | ||
|
|
54bcda1bb4 | ||
|
|
7003c5f730 | ||
|
|
4ccf99ce23 | ||
|
|
3fca4d60f1 | ||
|
|
2099f47d22 | ||
|
|
c12a723a52 | ||
|
|
eb0b75a9a7 | ||
|
|
94ca9cd871 | ||
|
|
9f697f06be | ||
|
|
ba7f95aad5 | ||
|
|
993fffaa15 | ||
|
|
4b7f7ed45e | ||
|
|
7e80607a34 | ||
|
|
d0389f13fc | ||
|
|
993d0f66ec | ||
|
|
4c9ad959dd | ||
|
|
044a28138a | ||
|
|
e8ec98c257 | ||
|
|
fe0c1e67e7 | ||
|
|
7261a8137d | ||
|
|
b1493f3f6a | ||
|
|
d679dc5e97 | ||
|
|
b0f7196f2b | ||
|
|
77a6e5c5ab | ||
|
|
845d979046 | ||
|
|
f62509ed80 | ||
|
|
e79c362f46 | ||
|
|
5c35741226 | ||
|
|
b90b8ce6a2 | ||
|
|
321aeedd8f | ||
|
|
09f68a237b | ||
|
|
93d715bb99 | ||
|
|
04fe8e1aca | ||
|
|
1f02f3cc27 | ||
|
|
a0acd4e929 | ||
|
|
3901e6ebe4 | ||
|
|
f229226521 | ||
|
|
6644e4acd7 | ||
|
|
d46f8056a5 | ||
|
|
c3731ace88 | ||
|
|
038c2df524 | ||
|
|
400318b390 | ||
|
|
0d2e5a1f07 | ||
|
|
9835cd0d53 | ||
|
|
f6d8efcd26 | ||
|
|
955de83b35 | ||
|
|
29a09f2038 | ||
|
|
fff332f31f | ||
|
|
b4d4a2fddd | ||
|
|
566a539a85 | ||
|
|
e588ada891 | ||
|
|
0c89e3ba3b | ||
|
|
7b0e134c20 | ||
|
|
8f7c285cb7 | ||
|
|
a2cddfc012 | ||
|
|
54eaecc6b5 | ||
|
|
0012dec482 | ||
|
|
ced6abea3f | ||
|
|
4a82baeaea | ||
|
|
f41b4fd59d | ||
|
|
f6bd4b0fc2 | ||
|
|
50879b6a0c | ||
|
|
6be42d4ec3 | ||
|
|
a56d42d9a5 | ||
|
|
e34952bca9 | ||
|
|
9d293a00e7 | ||
|
|
1ee41f8027 | ||
|
|
d3c1dbb5da | ||
|
|
cd554d885b | ||
|
|
d9b4149d41 | ||
|
|
4af6165094 | ||
|
|
fb2023762d | ||
|
|
946c450036 | ||
|
|
969d92d037 | ||
|
|
6998f009c4 | ||
|
|
cba1f01bdb | ||
|
|
5f80c8e779 | ||
|
|
5ece438b3f | ||
|
|
acd4e41f8b | ||
|
|
ae08093906 | ||
|
|
dd456b41f1 | ||
|
|
30283f36a4 | ||
|
|
4858dd9229 | ||
|
|
b910ba25ae | ||
|
|
2a542210ca | ||
|
|
eef85f1f7a | ||
|
|
4f2d4e3a49 | ||
|
|
feccb76ce8 | ||
|
|
835689a4a6 | ||
|
|
50cbac147e | ||
|
|
d1dd2f016e | ||
|
|
bd5e26a9a9 | ||
|
|
2d686bee01 | ||
|
|
2438f1a8d4 | ||
|
|
331f8d5743 | ||
|
|
7e43524ff5 | ||
|
|
708d94b69b | ||
|
|
8b3c36f702 | ||
|
|
ca2d57cf61 | ||
|
|
1cc9b4bdd1 | ||
|
|
226583f19e |
2
.github/ISSUE_TEMPLATE/config.yml
vendored
2
.github/ISSUE_TEMPLATE/config.yml
vendored
@@ -1,4 +1,4 @@
|
||||
blank_issues_enabled: false
|
||||
blank_issues_enabled: true
|
||||
contact_links:
|
||||
- name: DAVx⁵ Community Support
|
||||
url: https://github.com/bitfireAT/davx5-ose/discussions
|
||||
|
||||
3
.github/ISSUE_TEMPLATE/qualified-bug.yml
vendored
3
.github/ISSUE_TEMPLATE/qualified-bug.yml
vendored
@@ -1,5 +1,6 @@
|
||||
name: Qualified Bug Report
|
||||
description: "[Developers only] For qualified bug reports. (Use Discussions if unsure.)"
|
||||
description: "For qualified bug reports. (Use Discussions if unsure.)"
|
||||
type: bug
|
||||
labels: ["bug"]
|
||||
body:
|
||||
- type: checkboxes
|
||||
|
||||
3
.github/ISSUE_TEMPLATE/qualified-feature.yml
vendored
3
.github/ISSUE_TEMPLATE/qualified-feature.yml
vendored
@@ -1,5 +1,6 @@
|
||||
name: Qualified Feature Request
|
||||
description: "[Developers only] For qualified feature requests. (Use Discussions if unsure.)"
|
||||
description: "For qualified feature requests. (Use Discussions if unsure.)"
|
||||
type: feature
|
||||
labels: ["enhancement"]
|
||||
body:
|
||||
- type: checkboxes
|
||||
|
||||
11
.github/dependabot.yml
vendored
Normal file
11
.github/dependabot.yml
vendored
Normal file
@@ -0,0 +1,11 @@
|
||||
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] "
|
||||
7
.github/workflows/test-dev.yml
vendored
7
.github/workflows/test-dev.yml
vendored
@@ -2,7 +2,8 @@ name: Development tests
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- '*'
|
||||
- 'main-ose'
|
||||
pull_request:
|
||||
|
||||
concurrency:
|
||||
group: test-dev-${{ github.ref }}
|
||||
@@ -30,7 +31,7 @@ jobs:
|
||||
|
||||
test:
|
||||
needs: compile
|
||||
if: ${{ always() }} # even if compile didn't run (because not on main branch)
|
||||
if: ${{ !cancelled() }} # even if compile didn't run (because not on main branch)
|
||||
name: Lint and unit tests
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
@@ -51,7 +52,7 @@ jobs:
|
||||
|
||||
test_on_emulator:
|
||||
needs: compile
|
||||
if: ${{ always() }} # even if compile didn't run (because not on main branch)
|
||||
if: ${{ !cancelled() }} # even if compile didn't run (because not on main branch)
|
||||
name: Instrumented tests
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
|
||||
9
.idea/codeStyles/Project.xml
generated
Normal file
9
.idea/codeStyles/Project.xml
generated
Normal file
@@ -0,0 +1,9 @@
|
||||
<component name="ProjectCodeStyleConfiguration">
|
||||
<code_scheme name="Project" version="173">
|
||||
<option name="LINE_SEPARATOR" value=" " />
|
||||
<option name="RIGHT_MARGIN" value="180" />
|
||||
<codeStyleSettings language="kotlin">
|
||||
<option name="CODE_STYLE_DEFAULTS" value="KOTLIN_OFFICIAL" />
|
||||
</codeStyleSettings>
|
||||
</code_scheme>
|
||||
</component>
|
||||
5
.idea/codeStyles/codeStyleConfig.xml
generated
Normal file
5
.idea/codeStyles/codeStyleConfig.xml
generated
Normal file
@@ -0,0 +1,5 @@
|
||||
<component name="ProjectCodeStyleConfiguration">
|
||||
<state>
|
||||
<option name="USE_PER_PROJECT_SETTINGS" value="true" />
|
||||
</state>
|
||||
</component>
|
||||
@@ -1,30 +1,32 @@
|
||||
/***************************************************************************************************
|
||||
/*
|
||||
* 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.kotlin.serialization)
|
||||
alias(libs.plugins.ksp)
|
||||
|
||||
alias(libs.plugins.mikepenz.aboutLibraries)
|
||||
}
|
||||
|
||||
// Android configuration
|
||||
android {
|
||||
compileSdk = 35
|
||||
compileSdk = 36
|
||||
|
||||
defaultConfig {
|
||||
applicationId = "at.bitfire.davdroid"
|
||||
|
||||
versionCode = 404060001
|
||||
versionName = "4.4.6-beta.1"
|
||||
versionCode = 404110004
|
||||
versionName = "4.4.11"
|
||||
|
||||
setProperty("archivesBaseName", "davx5-ose-$versionName")
|
||||
|
||||
minSdk = 24 // Android 7.0
|
||||
targetSdk = 35 // Android 15
|
||||
targetSdk = 36 // Android 16
|
||||
|
||||
testInstrumentationRunner = "at.bitfire.davdroid.HiltTestRunner"
|
||||
}
|
||||
@@ -85,26 +87,27 @@ android {
|
||||
}
|
||||
|
||||
lint {
|
||||
disable += arrayOf("GoogleAppIndexingWarning", "ImpliedQuantity", "MissingQuantity", "MissingTranslation", "ExtraTranslation", "RtlEnabled", "RtlHardcoded", "Typos", "NullSafeMutableLiveData")
|
||||
}
|
||||
|
||||
packaging {
|
||||
resources {
|
||||
excludes += arrayOf("META-INF/*.md")
|
||||
}
|
||||
disable += arrayOf("GoogleAppIndexingWarning", "ImpliedQuantity", "MissingQuantity", "MissingTranslation", "ExtraTranslation", "RtlEnabled", "RtlHardcoded", "Typos")
|
||||
}
|
||||
|
||||
androidResources {
|
||||
generateLocaleConfig = true
|
||||
}
|
||||
|
||||
packaging {
|
||||
resources {
|
||||
// multiple (test) dependencies have LICENSE files at same location
|
||||
merges += arrayOf("META-INF/LICENSE*")
|
||||
}
|
||||
}
|
||||
|
||||
@Suppress("UnstableApiUsage")
|
||||
testOptions {
|
||||
managedDevices {
|
||||
localDevices {
|
||||
create("virtual") {
|
||||
device = "Pixel 3"
|
||||
apiLevel = 34
|
||||
apiLevel = 35
|
||||
systemImageSource = "aosp-atd"
|
||||
}
|
||||
}
|
||||
@@ -117,20 +120,10 @@ 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)
|
||||
@@ -152,6 +145,9 @@ dependencies {
|
||||
implementation(libs.androidx.lifecycle.runtime.compose)
|
||||
implementation(libs.androidx.lifecycle.viewmodel.base)
|
||||
implementation(libs.androidx.lifecycle.viewmodel.compose)
|
||||
implementation(libs.androidx.lifecycle.viewmodel.navigation3)
|
||||
implementation(libs.androidx.navigation3.runtime)
|
||||
implementation(libs.androidx.navigation3.ui)
|
||||
implementation(libs.androidx.paging)
|
||||
implementation(libs.androidx.paging.compose)
|
||||
implementation(libs.androidx.preference)
|
||||
@@ -162,8 +158,8 @@ dependencies {
|
||||
implementation(libs.compose.accompanist.permissions)
|
||||
implementation(platform(libs.compose.bom))
|
||||
implementation(libs.compose.material3)
|
||||
implementation(libs.compose.material3.navigation3)
|
||||
implementation(libs.compose.materialIconsExtended)
|
||||
implementation(libs.compose.runtime.livedata)
|
||||
debugImplementation(libs.compose.ui.tooling)
|
||||
implementation(libs.compose.ui.toolingPreview)
|
||||
|
||||
@@ -181,10 +177,15 @@ 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.ical4android)
|
||||
implementation(libs.bitfire.vcard4android)
|
||||
|
||||
// Serialization (for navigation)
|
||||
implementation(libs.kotlinx.serialization.core)
|
||||
implementation(libs.kotlinx.serialization.json)
|
||||
|
||||
// third-party libs
|
||||
@Suppress("RedundantSuppression")
|
||||
implementation(libs.dnsjava)
|
||||
@@ -195,7 +196,17 @@ dependencies {
|
||||
implementation(libs.okhttp.brotli)
|
||||
implementation(libs.okhttp.logging)
|
||||
implementation(libs.openid.appauth)
|
||||
implementation(libs.unifiedpush)
|
||||
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)
|
||||
|
||||
// for tests
|
||||
androidTestImplementation(libs.androidx.arch.core.testing)
|
||||
@@ -206,10 +217,12 @@ 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)
|
||||
|
||||
@@ -8,53 +8,19 @@
|
||||
-dontobfuscate
|
||||
-printusage build/reports/r8-usage.txt
|
||||
|
||||
# ez-vcard: keep all vCard properties/parameters (used via reflection)
|
||||
-keep class ezvcard.io.scribe.** { *; }
|
||||
-keep class ezvcard.property.** { *; }
|
||||
-keep class ezvcard.parameter.** { *; }
|
||||
|
||||
# ical4j: keep all iCalendar properties/parameters (used via reflection)
|
||||
-keep class net.fortuna.ical4j.** { *; }
|
||||
|
||||
# XmlPullParser
|
||||
# keep rules
|
||||
-keep class at.bitfire.** { *; } # all DAVx5 code is required
|
||||
-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
|
||||
|
||||
648
app/schemas/at.bitfire.davdroid.db.AppDatabase/17.json
Normal file
648
app/schemas/at.bitfire.davdroid.db.AppDatabase/17.json
Normal file
@@ -0,0 +1,648 @@
|
||||
{
|
||||
"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')"
|
||||
]
|
||||
}
|
||||
}
|
||||
648
app/schemas/at.bitfire.davdroid.db.AppDatabase/18.json
Normal file
648
app/schemas/at.bitfire.davdroid.db.AppDatabase/18.json
Normal file
@@ -0,0 +1,648 @@
|
||||
{
|
||||
"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')"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
/*
|
||||
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
|
||||
*/
|
||||
|
||||
package at.bitfire.davdroid
|
||||
|
||||
import org.junit.rules.TestRule
|
||||
import org.junit.runner.Description
|
||||
import org.junit.runners.model.Statement
|
||||
import kotlin.reflect.KClass
|
||||
|
||||
/**
|
||||
* Use this custom rule to ignore exceptions thrown by another rule.
|
||||
*
|
||||
* @param innerRule The rule to wrap.
|
||||
* @param exceptionsToIgnore The exceptions to ignore.
|
||||
*/
|
||||
class CatchExceptionsRule(
|
||||
private val innerRule: TestRule,
|
||||
private vararg val exceptionsToIgnore: KClass<out Throwable>
|
||||
) : TestRule {
|
||||
override fun apply(base: Statement, description: Description): Statement {
|
||||
return object : Statement() {
|
||||
override fun evaluate() {
|
||||
try {
|
||||
innerRule.apply(base, description).evaluate()
|
||||
} catch (e: Throwable) {
|
||||
val shouldIgnore = exceptionsToIgnore.any { it.isInstance(e) }
|
||||
if (shouldIgnore)
|
||||
base.evaluate()
|
||||
else
|
||||
throw e
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -6,12 +6,31 @@ 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.sync.SyncAdapterService
|
||||
import dagger.hilt.android.testing.HiltTestApplication
|
||||
|
||||
@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)
|
||||
|
||||
// MockK requirements
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.P)
|
||||
throw AssertionError("MockK requires Android P [https://mockk.io/ANDROID.html]")
|
||||
|
||||
// disable sync adapters
|
||||
SyncAdapterService.syncActive.set(false)
|
||||
|
||||
// set main dispatcher for tests (especially runTest)
|
||||
TestCoroutineDispatchersModule.initMainDispatcher()
|
||||
}
|
||||
|
||||
}
|
||||
@@ -4,10 +4,7 @@
|
||||
|
||||
package at.bitfire.davdroid
|
||||
|
||||
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
|
||||
@@ -19,16 +16,12 @@ 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()
|
||||
@@ -37,12 +30,14 @@ class OkhttpClientTest {
|
||||
|
||||
@Test
|
||||
fun testIcloudWithSettings() {
|
||||
val client = HttpClient.Builder(context).build()
|
||||
client.okHttpClient.newCall(Request.Builder()
|
||||
httpClientBuilder.build().use { client ->
|
||||
client.okHttpClient
|
||||
.newCall(Request.Builder()
|
||||
.get()
|
||||
.url("https://icloud.com")
|
||||
.build())
|
||||
.execute()
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -4,14 +4,11 @@
|
||||
|
||||
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
|
||||
@@ -31,16 +28,12 @@ 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()
|
||||
|
||||
@@ -48,7 +41,7 @@ class CollectionTest {
|
||||
fun setup() {
|
||||
hiltRule.inject()
|
||||
|
||||
httpClient = HttpClient.Builder(context).build()
|
||||
httpClient = httpClientBuilder.build()
|
||||
Assume.assumeTrue(NetworkSecurityPolicy.getInstance().isCleartextTrafficPermitted)
|
||||
}
|
||||
|
||||
@@ -211,4 +204,4 @@ class CollectionTest {
|
||||
assertEquals("https://example.com/1.ics".toHttpUrl(), info.source)
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
/*
|
||||
* 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)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
/*
|
||||
* 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
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
/*
|
||||
* 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)
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
/*
|
||||
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
|
||||
*/
|
||||
|
||||
package at.bitfire.davdroid.di
|
||||
|
||||
import at.bitfire.davdroid.log.LogcatHandler
|
||||
import dagger.Module
|
||||
import dagger.Provides
|
||||
import dagger.hilt.components.SingletonComponent
|
||||
import dagger.hilt.testing.TestInstallIn
|
||||
import java.util.logging.Level
|
||||
import java.util.logging.Logger
|
||||
import javax.inject.Singleton
|
||||
|
||||
/**
|
||||
* Module that provides verbose logging for tests.
|
||||
*/
|
||||
@TestInstallIn(
|
||||
components = [SingletonComponent::class],
|
||||
replaces = [LoggerModule::class]
|
||||
)
|
||||
@Module
|
||||
class TestLoggerModule {
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun logger(): Logger = Logger.getGlobal().apply {
|
||||
level = Level.ALL
|
||||
addHandler(LogcatHandler())
|
||||
}
|
||||
|
||||
}
|
||||
@@ -2,10 +2,8 @@
|
||||
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
|
||||
*/
|
||||
|
||||
package at.bitfire.davdroid
|
||||
package at.bitfire.davdroid.di
|
||||
|
||||
import at.bitfire.davdroid.push.PushRegistrationWorkerManager
|
||||
import at.bitfire.davdroid.repository.DavCollectionRepository
|
||||
import at.bitfire.davdroid.startup.StartupPlugin
|
||||
import at.bitfire.davdroid.startup.TasksAppWatcher
|
||||
import dagger.Module
|
||||
@@ -13,25 +11,13 @@ import dagger.hilt.components.SingletonComponent
|
||||
import dagger.hilt.testing.TestInstallIn
|
||||
import dagger.multibindings.Multibinds
|
||||
|
||||
// remove PushRegistrationWorkerModule from Android tests
|
||||
@Module
|
||||
@TestInstallIn(
|
||||
components = [SingletonComponent::class],
|
||||
replaces = [PushRegistrationWorkerManager.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 {
|
||||
abstract class TestTasksAppWatcherModule {
|
||||
// provides empty set of plugins
|
||||
@Multibinds
|
||||
abstract fun empty(): Set<StartupPlugin>
|
||||
@@ -6,6 +6,8 @@ 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
|
||||
@@ -18,6 +20,7 @@ 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 {
|
||||
@@ -65,25 +68,22 @@ 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)
|
||||
|
||||
// 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]++
|
||||
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
|
||||
}
|
||||
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
|
||||
|
||||
@@ -4,9 +4,7 @@
|
||||
|
||||
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
|
||||
@@ -25,21 +23,20 @@ import javax.inject.Inject
|
||||
@HiltAndroidTest
|
||||
class HttpClientTest {
|
||||
|
||||
lateinit var server: MockWebServer
|
||||
lateinit var httpClient: HttpClient
|
||||
|
||||
@get:Rule
|
||||
var hiltRule = HiltAndroidRule(this)
|
||||
|
||||
@Inject
|
||||
@ApplicationContext
|
||||
lateinit var context: Context
|
||||
lateinit var httpClientBuilder: HttpClient.Builder
|
||||
|
||||
lateinit var httpClient: HttpClient
|
||||
lateinit var server: MockWebServer
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
hiltRule.inject()
|
||||
|
||||
httpClient = HttpClient.Builder(context).build()
|
||||
httpClient = httpClientBuilder.build()
|
||||
|
||||
server = MockWebServer()
|
||||
server.start(30000)
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
/*
|
||||
* 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)
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
/*
|
||||
* 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)
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,103 +0,0 @@
|
||||
/*
|
||||
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
|
||||
*/
|
||||
|
||||
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.Lazy
|
||||
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,
|
||||
object : Lazy<Set<DavCollectionRepository.OnChangeListener>> {
|
||||
override fun get(): Set<DavCollectionRepository.OnChangeListener> {
|
||||
return 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)
|
||||
}
|
||||
|
||||
}
|
||||
@@ -18,61 +18,57 @@ import javax.inject.Inject
|
||||
@HiltAndroidTest
|
||||
class DavHomeSetRepositoryTest {
|
||||
|
||||
@get:Rule
|
||||
var hiltRule = HiltAndroidRule(this)
|
||||
|
||||
@Inject
|
||||
lateinit var repository: DavHomeSetRepository
|
||||
|
||||
@Inject
|
||||
lateinit var serviceRepository: DavServiceRepository
|
||||
|
||||
@get:Rule
|
||||
var hiltRule = HiltAndroidRule(this)
|
||||
|
||||
var serviceId: Long = 0
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
hiltRule.inject()
|
||||
|
||||
serviceId = serviceRepository.insertOrReplaceBlocking(
|
||||
Service(id=0, accountName="test", type= Service.TYPE_CALDAV, principal = null)
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@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)
|
||||
val insertId1 = repository.insertOrUpdateByUrlBlocking(entry1)
|
||||
assertEquals(1L, insertId1)
|
||||
assertEquals(entry1.copy(id = 1L), repository.getById(1L))
|
||||
assertEquals(entry1.copy(id = 1L), repository.getByIdBlocking(1L))
|
||||
|
||||
val updatedEntry1 = HomeSet(id=0, serviceId=serviceId, personal=true, url="https://example.com/1".toHttpUrl(), displayName="Updated Entry")
|
||||
val updateId1 = repository.insertOrUpdateByUrl(updatedEntry1)
|
||||
val updateId1 = repository.insertOrUpdateByUrlBlocking(updatedEntry1)
|
||||
assertEquals(1L, updateId1)
|
||||
assertEquals(updatedEntry1.copy(id = 1L), repository.getById(1L))
|
||||
assertEquals(updatedEntry1.copy(id = 1L), repository.getByIdBlocking(1L))
|
||||
|
||||
val entry2 = HomeSet(id=0, serviceId=serviceId, personal=true, url= "https://example.com/2".toHttpUrl())
|
||||
val insertId2 = repository.insertOrUpdateByUrl(entry2)
|
||||
val insertId2 = repository.insertOrUpdateByUrlBlocking(entry2)
|
||||
assertEquals(2L, insertId2)
|
||||
assertEquals(entry2.copy(id = 2L), repository.getById(2L))
|
||||
assertEquals(entry2.copy(id = 2L), repository.getByIdBlocking(2L))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testDelete() {
|
||||
fun testDeleteBlocking() {
|
||||
// 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)
|
||||
val insertId1 = repository.insertOrUpdateByUrlBlocking(entry1)
|
||||
assertEquals(1L, insertId1)
|
||||
assertEquals(entry1, repository.getById(1L))
|
||||
assertEquals(entry1, repository.getByIdBlocking(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)
|
||||
repository.deleteBlocking(entry1)
|
||||
assertEquals(null, repository.getByIdBlocking(1L))
|
||||
}
|
||||
|
||||
}
|
||||
@@ -9,26 +9,18 @@ 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.repository.DavCollectionRepository
|
||||
import at.bitfire.davdroid.repository.DavServiceRepository
|
||||
import at.bitfire.davdroid.settings.SettingsManager
|
||||
import at.bitfire.davdroid.sync.account.SystemAccountUtils
|
||||
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.MockKAnnotations
|
||||
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.just
|
||||
import io.mockk.junit4.MockKRule
|
||||
import io.mockk.mockk
|
||||
import io.mockk.mockkObject
|
||||
import io.mockk.runs
|
||||
import io.mockk.unmockkAll
|
||||
import io.mockk.verify
|
||||
import okhttp3.HttpUrl.Companion.toHttpUrl
|
||||
import org.junit.After
|
||||
import org.junit.Assert.assertEquals
|
||||
@@ -37,81 +29,81 @@ 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 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)
|
||||
|
||||
@Inject
|
||||
@ApplicationContext
|
||||
@SpyK
|
||||
lateinit var context: Context
|
||||
@get:Rule
|
||||
val mockkRule = MockKRule(this)
|
||||
|
||||
val account by lazy { Account("Test Account", context.getString(R.string.account_type)) }
|
||||
val addressBookAccount by lazy { Account("MrRobert@example.com", context.getString(R.string.account_type_address_book)) }
|
||||
|
||||
val provider = mockk<ContentProviderClient>(relaxed = true)
|
||||
val addressBook: LocalAddressBook = mockk(relaxed = true) {
|
||||
every { account } answers { this@LocalAddressBookStoreTest.account }
|
||||
every { updateSyncFrameworkSettings() } just runs
|
||||
every { addressBookAccount } answers { this@LocalAddressBookStoreTest.addressBookAccount }
|
||||
every { settings } returns LocalAddressBookStore.contactsProviderSettings
|
||||
}
|
||||
|
||||
@Suppress("unused") // used by @InjectMockKs LocalAddressBookStore
|
||||
@RelaxedMockK
|
||||
lateinit var collectionRepository: DavCollectionRepository
|
||||
|
||||
@Suppress("unused") // used by @InjectMockKs LocalAddressBookStore
|
||||
val localAddressBookFactory = mockk<LocalAddressBook.Factory> {
|
||||
every { create(any(), any(), provider) } returns addressBook
|
||||
}
|
||||
|
||||
@Inject
|
||||
@SpyK
|
||||
lateinit var logger: Logger
|
||||
|
||||
@RelaxedMockK
|
||||
lateinit var settingsManager: SettingsManager
|
||||
|
||||
@Suppress("unused") // used by @InjectMockKs LocalAddressBookStore
|
||||
val serviceRepository = mockk<DavServiceRepository>(relaxed = true) {
|
||||
every { get(any<Long>()) } returns null
|
||||
every { get(200) } returns mockk<Service> {
|
||||
every { accountName } returns "MrRobert@example.com"
|
||||
}
|
||||
}
|
||||
|
||||
@InjectMockKs
|
||||
@SpyK
|
||||
lateinit var localAddressBookStore: LocalAddressBookStore
|
||||
lateinit var addressBookAccountType: String
|
||||
|
||||
lateinit var addressBookAccount: Account
|
||||
lateinit var account: Account
|
||||
lateinit var service: Service
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
hiltRule.inject()
|
||||
|
||||
// initialize global mocks
|
||||
MockKAnnotations.init(this)
|
||||
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() {
|
||||
unmockkAll()
|
||||
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
|
||||
every { serviceId } returns 404 // missing service
|
||||
}
|
||||
assertEquals("funnyfriends #42", localAddressBookStore.accountName(collection))
|
||||
}
|
||||
@@ -122,19 +114,19 @@ class LocalAddressBookStoreTest {
|
||||
every { id } returns 42
|
||||
every { url } returns "https://example.com/addressbook/funnyfriends".toHttpUrl()
|
||||
every { displayName } returns null
|
||||
every { serviceId } returns 200
|
||||
every { serviceId } returns service.id
|
||||
}
|
||||
val accountName = localAddressBookStore.accountName(collection)
|
||||
assertEquals("funnyfriends (MrRobert@example.com) #42", accountName)
|
||||
assertEquals("funnyfriends (${account.name}) #42", accountName)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun test_accountName_missingDisplayNameAndService() {
|
||||
val collection = mockk<Collection>(relaxed = true) {
|
||||
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
|
||||
every { serviceId } returns 404 // missing service
|
||||
}
|
||||
assertEquals("funnyfriends #1", localAddressBookStore.accountName(collection))
|
||||
}
|
||||
@@ -143,45 +135,42 @@ class LocalAddressBookStoreTest {
|
||||
@Test
|
||||
fun test_create_createAccountReturnsNull() {
|
||||
val collection = mockk<Collection>(relaxed = true) {
|
||||
every { serviceId } returns 200
|
||||
every { serviceId } returns service.id
|
||||
every { id } returns 1
|
||||
every { url } returns "https://example.com/addressbook/funnyfriends".toHttpUrl()
|
||||
}
|
||||
every { localAddressBookStore.createAddressBookAccount(any(), any(), any(), any()) } returns null
|
||||
|
||||
mockkObject(localAddressBookStore)
|
||||
every { localAddressBookStore.createAddressBookAccount(any(), any(), any()) } returns null
|
||||
|
||||
assertEquals(null, localAddressBookStore.create(provider, collection))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun test_create_createAccountReturnsAccount() {
|
||||
fun test_create_ReadOnly() {
|
||||
val collection = mockk<Collection>(relaxed = true) {
|
||||
every { serviceId } returns 200
|
||||
every { serviceId } returns service.id
|
||||
every { id } returns 1
|
||||
every { url } returns "https://example.com/addressbook/funnyfriends".toHttpUrl()
|
||||
every { readOnly() } returns true
|
||||
}
|
||||
every { localAddressBookStore.createAddressBookAccount(any(), any(), any(), any()) } returns addressBookAccount
|
||||
every { addressBook.readOnly } returns true
|
||||
val addrBook = localAddressBookStore.create(provider, collection)!!
|
||||
|
||||
verify(exactly = 1) { addressBook.updateSyncFrameworkSettings() }
|
||||
assertEquals(addressBookAccount, addrBook.addressBookAccount)
|
||||
assertEquals(LocalAddressBookStore.contactsProviderSettings, addrBook.settings)
|
||||
assertEquals(true, addrBook.readOnly)
|
||||
|
||||
every { addressBook.readOnly } returns false
|
||||
val addrBook2 = localAddressBookStore.create(provider, collection)!!
|
||||
assertEquals(false, addrBook2.readOnly)
|
||||
assertEquals(Account("funnyfriends (Test Account) #1", addressBookAccountType), addrBook.addressBookAccount)
|
||||
assertTrue(addrBook.readOnly)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun test_createAccount_succeeds() {
|
||||
mockkObject(SystemAccountUtils)
|
||||
every { SystemAccountUtils.createAccount(any(), any(), any()) } returns true
|
||||
val result: Account = localAddressBookStore.createAddressBookAccount(
|
||||
account, "MrRobert@example.com", 42, "https://example.com/addressbook/funnyfriends"
|
||||
)!!
|
||||
verify(exactly = 1) { SystemAccountUtils.createAccount(any(), any(), any()) }
|
||||
assertEquals("MrRobert@example.com", result.name)
|
||||
assertEquals(context.getString(R.string.account_type_address_book), result.type)
|
||||
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)
|
||||
}
|
||||
|
||||
|
||||
@@ -223,4 +212,14 @@ class LocalAddressBookStoreTest {
|
||||
assertTrue(LocalAddressBookStore.shouldBeReadOnly(collectionNotReadOnly, true))
|
||||
}
|
||||
|
||||
|
||||
// helpers
|
||||
|
||||
private fun removeAddressBooks() {
|
||||
val accountManager = AccountManager.get(context)
|
||||
accountManager.getAccountsByType(addressBookAccountType).forEach {
|
||||
accountManager.removeAccountExplicitly(it)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -18,7 +18,6 @@ import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import dagger.hilt.android.testing.HiltAndroidRule
|
||||
import dagger.hilt.android.testing.HiltAndroidTest
|
||||
import ezvcard.property.Telephone
|
||||
import org.junit.After
|
||||
import org.junit.AfterClass
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
@@ -28,6 +27,7 @@ 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 javax.inject.Inject
|
||||
|
||||
@@ -37,24 +37,17 @@ 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")
|
||||
lateinit var addressBook: LocalTestAddressBook
|
||||
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
hiltRule.inject()
|
||||
|
||||
addressBook = LocalTestAddressBook.create(context, account, provider)
|
||||
}
|
||||
|
||||
@After
|
||||
fun tearDown() {
|
||||
// remove address book
|
||||
addressBook.remove()
|
||||
}
|
||||
|
||||
|
||||
@@ -63,32 +56,34 @@ class LocalAddressBookTest {
|
||||
*/
|
||||
@Test
|
||||
fun test_renameAccount_retainsContacts() {
|
||||
// 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))
|
||||
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))
|
||||
|
||||
// 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(newName, addressBook.addressBookAccount.name)
|
||||
|
||||
// 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))
|
||||
// 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))
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -96,26 +91,63 @@ class LocalAddressBookTest {
|
||||
*/
|
||||
@Test
|
||||
fun test_renameAccount_retainsGroups() {
|
||||
// insert group
|
||||
val localGroup = LocalGroup(addressBook, Contact(displayName = "Test Group"), null, null, 0)
|
||||
val uri = localGroup.add()
|
||||
val id = ContentUris.parseId(uri)
|
||||
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)
|
||||
|
||||
// make sure it's not dirty
|
||||
localGroup.clearDirty(null, null, null)
|
||||
assertFalse("Group is dirty before moving", addressBook.isGroupDirty(id))
|
||||
// make sure it's not dirty
|
||||
localGroup.clearDirty(null, null, null)
|
||||
assertFalse("Group is dirty before moving", isGroupDirty(addressBook, id))
|
||||
|
||||
// rename address book
|
||||
val newName = "New Name"
|
||||
assertTrue(addressBook.renameAccount(newName))
|
||||
assertEquals(newName, addressBook.addressBookAccount.name)
|
||||
// rename address book
|
||||
val newName = "New Name"
|
||||
assertTrue(addressBook.renameAccount(newName))
|
||||
assertEquals(newName, addressBook.addressBookAccount.name)
|
||||
|
||||
// check whether group is still here and not dirty
|
||||
val result = addressBook.findGroupById(id)
|
||||
assertFalse("Group is dirty after moving", addressBook.isGroupDirty(id))
|
||||
// check whether group is still here and not dirty
|
||||
val result = addressBook.findGroupById(id)
|
||||
assertFalse("Group is dirty after moving", isGroupDirty(addressBook, 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()
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -21,7 +21,6 @@ import at.bitfire.vcard4android.GroupMethod
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import dagger.hilt.android.testing.HiltAndroidRule
|
||||
import dagger.hilt.android.testing.HiltAndroidTest
|
||||
import org.junit.After
|
||||
import org.junit.AfterClass
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
@@ -41,216 +40,217 @@ 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")
|
||||
private lateinit var addressBookGroupsAsCategories: LocalTestAddressBook
|
||||
private lateinit var addressBookGroupsAsVCards: LocalTestAddressBook
|
||||
|
||||
@Before
|
||||
fun setup() {
|
||||
hiltRule.inject()
|
||||
|
||||
addressBookGroupsAsCategories = LocalTestAddressBook.create(context, account, provider, GroupMethod.CATEGORIES)
|
||||
addressBookGroupsAsVCards = LocalTestAddressBook.create(context, account, provider, GroupMethod.GROUP_VCARDS)
|
||||
|
||||
// clear contacts
|
||||
addressBookGroupsAsCategories.clear()
|
||||
addressBookGroupsAsVCards.clear()
|
||||
}
|
||||
|
||||
@After
|
||||
fun tearDown() {
|
||||
addressBookGroupsAsCategories.remove()
|
||||
addressBookGroupsAsVCards.remove()
|
||||
}
|
||||
|
||||
|
||||
@Test
|
||||
fun testApplyPendingMemberships_addPendingMembership() {
|
||||
val ab = addressBookGroupsAsVCards
|
||||
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 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
|
||||
)
|
||||
|
||||
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)
|
||||
|
||||
// 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))
|
||||
|
||||
// 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())
|
||||
}
|
||||
// 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())
|
||||
assertFalse(cursor.moveToNext())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testApplyPendingMemberships_removeMembership() {
|
||||
val ab = addressBookGroupsAsVCards
|
||||
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 contact1 = LocalContact(ab, Contact().apply {
|
||||
uid = "test1"
|
||||
displayName = "Test"
|
||||
}, "test1.vcf", null, 0)
|
||||
contact1.add()
|
||||
val group = newGroup(ab)
|
||||
|
||||
val group = newGroup(ab)
|
||||
// add contact1 to group
|
||||
val batch = BatchOperation(ab.provider!!)
|
||||
contact1.addToGroup(batch, group.id!!)
|
||||
batch.commit()
|
||||
|
||||
// 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)
|
||||
|
||||
// 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())
|
||||
// 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)
|
||||
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 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)
|
||||
// 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())
|
||||
}
|
||||
)
|
||||
|
||||
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)
|
||||
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 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)
|
||||
// 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())
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
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)
|
||||
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 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()
|
||||
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)
|
||||
assertEquals(0, ab.findDirty().size)
|
||||
group.markMembersDirty()
|
||||
assertEquals(contact1.id, ab.findDirty().first().id)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@Test
|
||||
fun testPrepareForUpload() {
|
||||
val group = newGroup()
|
||||
assertNull(group.getContact().uid)
|
||||
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)
|
||||
val fileName = group.prepareForUpload()
|
||||
val newUid = group.getContact().uid
|
||||
assertNotNull(newUid)
|
||||
assertEquals("$newUid.vcf", fileName)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// helpers
|
||||
|
||||
private fun newGroup(addressBook: LocalAddressBook = addressBookGroupsAsCategories): LocalGroup =
|
||||
private fun newGroup(addressBook: LocalAddressBook): LocalGroup =
|
||||
LocalGroup(addressBook,
|
||||
Contact().apply {
|
||||
displayName = "Test Group"
|
||||
|
||||
@@ -5,12 +5,8 @@
|
||||
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.R
|
||||
import at.bitfire.davdroid.repository.DavCollectionRepository
|
||||
import at.bitfire.davdroid.repository.DavServiceRepository
|
||||
import at.bitfire.davdroid.settings.AccountSettings
|
||||
@@ -19,16 +15,13 @@ import at.bitfire.vcard4android.GroupMethod
|
||||
import dagger.assisted.Assisted
|
||||
import dagger.assisted.AssistedFactory
|
||||
import dagger.assisted.AssistedInject
|
||||
import dagger.hilt.InstallIn
|
||||
import dagger.hilt.android.EntryPointAccessors
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import dagger.hilt.components.SingletonComponent
|
||||
import org.junit.Assert.assertTrue
|
||||
import java.io.FileNotFoundException
|
||||
import java.util.Optional
|
||||
import java.util.concurrent.atomic.AtomicInteger
|
||||
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,
|
||||
@@ -36,7 +29,7 @@ class LocalTestAddressBook @AssistedInject constructor(
|
||||
@Assisted override val groupMethod: GroupMethod,
|
||||
accountSettingsFactory: AccountSettings.Factory,
|
||||
collectionRepository: DavCollectionRepository,
|
||||
@ApplicationContext private val context: Context,
|
||||
@ApplicationContext context: Context,
|
||||
logger: Logger,
|
||||
serviceRepository: DavServiceRepository,
|
||||
syncFramework: SyncFrameworkIntegration
|
||||
@@ -55,93 +48,12 @@ class LocalTestAddressBook @AssistedInject constructor(
|
||||
|
||||
@AssistedFactory
|
||||
interface Factory {
|
||||
fun create(account: Account, @Assisted("addressBook") addressBookAccount: Account, 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()
|
||||
}
|
||||
|
||||
fun remove() {
|
||||
val accountManager = AccountManager.get(context)
|
||||
assertTrue(accountManager.removeAccountExplicitly(addressBookAccount))
|
||||
}
|
||||
|
||||
|
||||
companion object {
|
||||
|
||||
@dagger.hilt.EntryPoint
|
||||
@InstallIn(SingletonComponent::class)
|
||||
interface EntryPoint {
|
||||
fun localTestAddressBookFactory(): Factory
|
||||
}
|
||||
|
||||
val counter = AtomicInteger()
|
||||
|
||||
/**
|
||||
* Creates a [at.bitfire.davdroid.resource.LocalTestAddressBook].
|
||||
*
|
||||
* Make sure to delete it with [at.bitfire.davdroid.resource.LocalTestAddressBook.remove] or [removeAll] after use.
|
||||
*/
|
||||
fun create(context: Context, account: Account, provider: ContentProviderClient, groupMethod: GroupMethod = GroupMethod.GROUP_VCARDS): LocalTestAddressBook {
|
||||
// create new address book account
|
||||
val addressBookAccount = Account("Test Address Book ${counter.incrementAndGet()}", context.getString(R.string.account_type_address_book))
|
||||
val accountManager = AccountManager.get(context)
|
||||
assertTrue(accountManager.addAccountExplicitly(addressBookAccount, null, null))
|
||||
|
||||
// return address book with this account
|
||||
val entryPoint = EntryPointAccessors.fromApplication<EntryPoint>(context)
|
||||
val factory = entryPoint.localTestAddressBookFactory()
|
||||
return factory.create(account, addressBookAccount, provider, groupMethod)
|
||||
}
|
||||
|
||||
fun removeAll(context: Context) {
|
||||
val accountManager = AccountManager.get(context)
|
||||
for (account in accountManager.getAccountsByType(context.getString(R.string.account_type_address_book)))
|
||||
accountManager.removeAccountExplicitly(account)
|
||||
}
|
||||
|
||||
fun create(
|
||||
account: Account,
|
||||
@Assisted("addressBook") addressBookAccount: Account,
|
||||
provider: ContentProviderClient,
|
||||
groupMethod: GroupMethod
|
||||
): LocalTestAddressBook
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
/*
|
||||
* 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))
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -13,7 +13,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.LocalTestAddressBook
|
||||
import at.bitfire.davdroid.resource.LocalTestAddressBookProvider
|
||||
import at.bitfire.vcard4android.CachedGroupMembership
|
||||
import at.bitfire.vcard4android.Contact
|
||||
import at.bitfire.vcard4android.GroupMethod
|
||||
@@ -36,6 +36,9 @@ class CachedGroupMembershipHandlerTest {
|
||||
@ApplicationContext
|
||||
lateinit var context: Context
|
||||
|
||||
@Inject
|
||||
lateinit var localTestAddressBookProvider: LocalTestAddressBookProvider
|
||||
|
||||
@get:Rule
|
||||
val hiltRule = HiltAndroidRule(this)
|
||||
|
||||
@@ -49,8 +52,7 @@ class CachedGroupMembershipHandlerTest {
|
||||
|
||||
@Test
|
||||
fun testMembership() {
|
||||
val addressBook = LocalTestAddressBook.create(context, account, provider, GroupMethod.GROUP_VCARDS)
|
||||
try {
|
||||
localTestAddressBookProvider.provide(account, provider, GroupMethod.GROUP_VCARDS) { addressBook ->
|
||||
val contact = Contact()
|
||||
val localContact = LocalContact(addressBook, contact, null, null, 0)
|
||||
CachedGroupMembershipHandler(localContact).handle(ContentValues().apply {
|
||||
@@ -58,8 +60,6 @@ class CachedGroupMembershipHandlerTest {
|
||||
put(CachedGroupMembership.RAW_CONTACT_ID, 789)
|
||||
}, contact)
|
||||
assertArrayEquals(arrayOf(123456L), localContact.cachedGroupMemberships.toArray())
|
||||
} finally {
|
||||
addressBook.remove()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -13,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.davdroid.resource.LocalTestAddressBook
|
||||
import at.bitfire.davdroid.resource.LocalTestAddressBookProvider
|
||||
import at.bitfire.vcard4android.Contact
|
||||
import at.bitfire.vcard4android.GroupMethod
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
@@ -34,6 +34,9 @@ class GroupMembershipBuilderTest {
|
||||
@Inject @ApplicationContext
|
||||
lateinit var context: Context
|
||||
|
||||
@Inject
|
||||
lateinit var localTestAddressBookProvider: LocalTestAddressBookProvider
|
||||
|
||||
@get:Rule
|
||||
val hiltRule = HiltAndroidRule(this)
|
||||
|
||||
@@ -51,15 +54,12 @@ class GroupMembershipBuilderTest {
|
||||
val contact = Contact().apply {
|
||||
categories += "TEST GROUP"
|
||||
}
|
||||
val addressBookGroupsAsCategories = LocalTestAddressBook.create(context, account, provider, GroupMethod.CATEGORIES)
|
||||
try {
|
||||
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])
|
||||
}
|
||||
} finally {
|
||||
addressBookGroupsAsCategories.remove()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -68,14 +68,11 @@ class GroupMembershipBuilderTest {
|
||||
val contact = Contact().apply {
|
||||
categories += "TEST GROUP"
|
||||
}
|
||||
val addressBookGroupsAsVCards = LocalTestAddressBook.create(context, account, provider, GroupMethod.GROUP_VCARDS)
|
||||
try {
|
||||
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)
|
||||
}
|
||||
} finally {
|
||||
addressBookGroupsAsVCards.remove()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -13,7 +13,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.LocalTestAddressBook
|
||||
import at.bitfire.davdroid.resource.LocalTestAddressBookProvider
|
||||
import at.bitfire.vcard4android.CachedGroupMembership
|
||||
import at.bitfire.vcard4android.Contact
|
||||
import at.bitfire.vcard4android.GroupMethod
|
||||
@@ -37,6 +37,9 @@ class GroupMembershipHandlerTest {
|
||||
@Inject @ApplicationContext
|
||||
lateinit var context: Context
|
||||
|
||||
@Inject
|
||||
lateinit var localTestAddressBookProvider: LocalTestAddressBookProvider
|
||||
|
||||
@get:Rule
|
||||
var hiltRule = HiltAndroidRule(this)
|
||||
|
||||
@@ -50,8 +53,7 @@ class GroupMembershipHandlerTest {
|
||||
|
||||
@Test
|
||||
fun testMembership_GroupsAsCategories() {
|
||||
val addressBookGroupsAsCategories = LocalTestAddressBook.create(context, account, provider, GroupMethod.CATEGORIES)
|
||||
try {
|
||||
localTestAddressBookProvider.provide(account, provider, GroupMethod.CATEGORIES) { addressBookGroupsAsCategories ->
|
||||
val addressBookGroupsAsCategoriesGroup = addressBookGroupsAsCategories.findOrCreateGroup("TEST GROUP")
|
||||
|
||||
val contact = Contact()
|
||||
@@ -62,16 +64,13 @@ class GroupMembershipHandlerTest {
|
||||
}, contact)
|
||||
assertArrayEquals(arrayOf(addressBookGroupsAsCategoriesGroup), localContact.groupMemberships.toArray())
|
||||
assertArrayEquals(arrayOf("TEST GROUP"), contact.categories.toArray())
|
||||
} finally {
|
||||
addressBookGroupsAsCategories.remove()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@Test
|
||||
fun testMembership_GroupsAsVCards() {
|
||||
val addressBookGroupsAsVCards = LocalTestAddressBook.create(context, account, provider, GroupMethod.GROUP_VCARDS)
|
||||
try {
|
||||
localTestAddressBookProvider.provide(account, provider, GroupMethod.GROUP_VCARDS) { addressBookGroupsAsVCards ->
|
||||
val contact = Contact()
|
||||
val localContact = LocalContact(addressBookGroupsAsVCards, contact, null, null, 0)
|
||||
GroupMembershipHandler(localContact).handle(ContentValues().apply {
|
||||
@@ -80,8 +79,6 @@ class GroupMembershipHandlerTest {
|
||||
}, contact)
|
||||
assertArrayEquals(arrayOf(12345L), localContact.groupMemberships.toArray())
|
||||
assertTrue(contact.categories.isEmpty())
|
||||
} finally {
|
||||
addressBookGroupsAsVCards.remove()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -4,7 +4,6 @@
|
||||
|
||||
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
|
||||
@@ -14,11 +13,13 @@ 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.BindValue
|
||||
import dagger.hilt.android.testing.HiltAndroidRule
|
||||
import dagger.hilt.android.testing.HiltAndroidTest
|
||||
import io.mockk.every
|
||||
import io.mockk.mockkObject
|
||||
import io.mockk.impl.annotations.MockK
|
||||
import io.mockk.junit4.MockKRule
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import okhttp3.mockwebserver.Dispatcher
|
||||
import okhttp3.mockwebserver.MockResponse
|
||||
import okhttp3.mockwebserver.MockWebServer
|
||||
@@ -37,50 +38,62 @@ import javax.inject.Inject
|
||||
@HiltAndroidTest
|
||||
class CollectionListRefresherTest {
|
||||
|
||||
@Inject @ApplicationContext
|
||||
lateinit var context: Context
|
||||
|
||||
@Inject
|
||||
lateinit var db: AppDatabase
|
||||
|
||||
@Inject
|
||||
lateinit var httpClientBuilder: HttpClient.Builder
|
||||
|
||||
@Inject
|
||||
lateinit var logger: Logger
|
||||
|
||||
@Inject
|
||||
lateinit var refresherFactory: CollectionListRefresher.Factory
|
||||
|
||||
@Inject
|
||||
@BindValue
|
||||
@MockK(relaxed = true)
|
||||
lateinit var settings: SettingsManager
|
||||
|
||||
@get:Rule
|
||||
var hiltRule = HiltAndroidRule(this)
|
||||
val hiltRule = HiltAndroidRule(this)
|
||||
|
||||
@get:Rule
|
||||
val mockKRule = MockKRule(this)
|
||||
|
||||
private val mockServer = MockWebServer()
|
||||
private lateinit var client: HttpClient
|
||||
private lateinit var mockServer: MockWebServer
|
||||
private lateinit var service: Service
|
||||
|
||||
@Before
|
||||
fun setup() {
|
||||
fun setUp() {
|
||||
hiltRule.inject()
|
||||
|
||||
// Start mock web server
|
||||
mockServer.dispatcher = TestDispatcher(logger)
|
||||
mockServer.start()
|
||||
|
||||
client = HttpClient.Builder(context).build()
|
||||
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() {
|
||||
fun tearDown() {
|
||||
client.close()
|
||||
mockServer.shutdown()
|
||||
db.close()
|
||||
}
|
||||
|
||||
|
||||
@Test
|
||||
fun testDiscoverHomesets() {
|
||||
val service = createTestService(Service.TYPE_CARDDAV)!!
|
||||
val baseUrl = mockServer.url(PATH_CARDDAV + SUBPATH_PRINCIPAL)
|
||||
|
||||
// Query home sets
|
||||
@@ -109,9 +122,7 @@ class CollectionListRefresherTest {
|
||||
// refreshHomesetsAndTheirCollections
|
||||
|
||||
@Test
|
||||
fun refreshHomesetsAndTheirCollections_addsNewCollection() {
|
||||
val service = createTestService(Service.TYPE_CARDDAV)!!
|
||||
|
||||
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"))
|
||||
@@ -138,8 +149,6 @@ class CollectionListRefresherTest {
|
||||
|
||||
@Test
|
||||
fun refreshHomesetsAndTheirCollections_updatesExistingCollection() {
|
||||
val service = createTestService(Service.TYPE_CARDDAV)!!
|
||||
|
||||
// save "old" collection in DB
|
||||
val collectionId = db.collectionDao().insertOrUpdateByUrl(
|
||||
Collection(
|
||||
@@ -175,8 +184,6 @@ class CollectionListRefresherTest {
|
||||
|
||||
@Test
|
||||
fun refreshHomesetsAndTheirCollections_preservesCollectionFlags() {
|
||||
val service = createTestService(Service.TYPE_CARDDAV)!!
|
||||
|
||||
// save "old" collection in DB - with set flags
|
||||
val collectionId = db.collectionDao().insertOrUpdateByUrl(
|
||||
Collection(
|
||||
@@ -216,8 +223,6 @@ class CollectionListRefresherTest {
|
||||
|
||||
@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"))
|
||||
@@ -244,8 +249,6 @@ class CollectionListRefresherTest {
|
||||
|
||||
@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_PERSONAL"))
|
||||
@@ -283,8 +286,6 @@ class CollectionListRefresherTest {
|
||||
|
||||
@Test
|
||||
fun refreshHomelessCollections_updatesExistingCollection() {
|
||||
val service = createTestService(Service.TYPE_CARDDAV)!!
|
||||
|
||||
// place homeless collection in DB
|
||||
val collectionId = db.collectionDao().insertOrUpdateByUrl(
|
||||
Collection(
|
||||
@@ -318,8 +319,6 @@ class CollectionListRefresherTest {
|
||||
|
||||
@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(
|
||||
@@ -341,8 +340,6 @@ class CollectionListRefresherTest {
|
||||
|
||||
@Test
|
||||
fun refreshHomelessCollections_addsOwnerUrls() {
|
||||
val service = createTestService(Service.TYPE_CARDDAV)!!
|
||||
|
||||
// place homeless collection in DB
|
||||
val collectionId = db.collectionDao().insertOrUpdateByUrl(
|
||||
Collection(
|
||||
@@ -375,8 +372,6 @@ class CollectionListRefresherTest {
|
||||
|
||||
@Test
|
||||
fun refreshPrincipals_inaccessiblePrincipal() {
|
||||
val service = createTestService(Service.TYPE_CARDDAV)!!
|
||||
|
||||
// place principal without display name in db
|
||||
val principalId = db.principalDao().insert(
|
||||
Principal(
|
||||
@@ -410,8 +405,6 @@ class CollectionListRefresherTest {
|
||||
|
||||
@Test
|
||||
fun refreshPrincipals_updatesPrincipal() {
|
||||
val service = createTestService(Service.TYPE_CARDDAV)!!
|
||||
|
||||
// place principal without display name in db
|
||||
val principalId = db.principalDao().insert(
|
||||
Principal(
|
||||
@@ -445,8 +438,6 @@ class CollectionListRefresherTest {
|
||||
|
||||
@Test
|
||||
fun refreshPrincipals_deletesPrincipalsWithoutCollections() {
|
||||
val service = createTestService(Service.TYPE_CARDDAV)!!
|
||||
|
||||
// place principal without collections in DB
|
||||
db.principalDao().insert(
|
||||
Principal(
|
||||
@@ -468,159 +459,161 @@ class CollectionListRefresherTest {
|
||||
|
||||
@Test
|
||||
fun shouldPreselect_none() {
|
||||
val service = createTestService(Service.TYPE_CARDDAV)!!
|
||||
every { settings.getIntOrNull(Settings.PRESELECT_COLLECTIONS) } returns Settings.PRESELECT_COLLECTIONS_NONE
|
||||
every { settings.getString(Settings.PRESELECT_COLLECTIONS_EXCLUDED) } returns ""
|
||||
|
||||
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_PERSONAL"))
|
||||
val collection = Collection(
|
||||
0,
|
||||
service.id,
|
||||
0,
|
||||
type = Collection.TYPE_ADDRESSBOOK,
|
||||
url = mockServer.url("/addressbook-homeset/addressbook/")
|
||||
)
|
||||
val homesets = listOf(
|
||||
HomeSet(
|
||||
id = 0,
|
||||
serviceId = service.id,
|
||||
personal = true,
|
||||
url = mockServer.url("/addressbook-homeset/")
|
||||
)
|
||||
)
|
||||
|
||||
val refresher = refresherFactory.create(service, client.okHttpClient)
|
||||
assertFalse(refresher.shouldPreselect(collection, homesets))
|
||||
}
|
||||
val refresher = refresherFactory.create(service, client.okHttpClient)
|
||||
assertFalse(refresher.shouldPreselect(collection, homesets))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun shouldPreselect_all() {
|
||||
val service = createTestService(Service.TYPE_CARDDAV)!!
|
||||
every { settings.getIntOrNull(Settings.PRESELECT_COLLECTIONS) } returns Settings.PRESELECT_COLLECTIONS_ALL
|
||||
every { settings.getString(Settings.PRESELECT_COLLECTIONS_EXCLUDED) } returns ""
|
||||
|
||||
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_PERSONAL"))
|
||||
val collection = Collection(
|
||||
0,
|
||||
service.id,
|
||||
0,
|
||||
type = Collection.TYPE_ADDRESSBOOK,
|
||||
url = mockServer.url("/addressbook-homeset/addressbook/")
|
||||
)
|
||||
val homesets = listOf(
|
||||
HomeSet(
|
||||
id = 0,
|
||||
serviceId = service.id,
|
||||
personal = false,
|
||||
url = mockServer.url("/addressbook-homeset/")
|
||||
)
|
||||
)
|
||||
|
||||
val refresher = refresherFactory.create(service, client.okHttpClient)
|
||||
assertTrue(refresher.shouldPreselect(collection, homesets))
|
||||
}
|
||||
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/")
|
||||
val url = mockServer.url("/addressbook-homeset/addressbook/")
|
||||
|
||||
mockkObject(settings) {
|
||||
every { settings.getIntOrNull(Settings.PRESELECT_COLLECTIONS) } returns Settings.PRESELECT_COLLECTIONS_ALL
|
||||
every { settings.getString(Settings.PRESELECT_COLLECTIONS_EXCLUDED) } returns url.toString()
|
||||
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_PERSONAL"))
|
||||
val collection = Collection(
|
||||
id = 0,
|
||||
serviceId = service.id,
|
||||
homeSetId = 0,
|
||||
type = Collection.TYPE_ADDRESSBOOK,
|
||||
url = url
|
||||
)
|
||||
val homesets = listOf(
|
||||
HomeSet(
|
||||
id = 0,
|
||||
serviceId = service.id,
|
||||
personal = false,
|
||||
url = mockServer.url("/addressbook-homeset/")
|
||||
)
|
||||
)
|
||||
|
||||
val refresher = refresherFactory.create(service, client.okHttpClient)
|
||||
assertFalse(refresher.shouldPreselect(collection, homesets))
|
||||
}
|
||||
val refresher = refresherFactory.create(service, client.okHttpClient)
|
||||
assertFalse(refresher.shouldPreselect(collection, homesets))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun shouldPreselect_personal_notPersonal() {
|
||||
val service = createTestService(Service.TYPE_CARDDAV)!!
|
||||
every { settings.getIntOrNull(Settings.PRESELECT_COLLECTIONS) } returns Settings.PRESELECT_COLLECTIONS_PERSONAL
|
||||
every { settings.getString(Settings.PRESELECT_COLLECTIONS_EXCLUDED) } returns ""
|
||||
|
||||
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_PERSONAL"))
|
||||
val collection = Collection(
|
||||
id = 0,
|
||||
serviceId = service.id,
|
||||
homeSetId = 0,
|
||||
type = Collection.TYPE_ADDRESSBOOK,
|
||||
url = mockServer.url("/addressbook-homeset/addressbook/")
|
||||
)
|
||||
val homesets = listOf(
|
||||
HomeSet(
|
||||
id = 0,
|
||||
serviceId = service.id,
|
||||
personal = false,
|
||||
url = mockServer.url("/addressbook-homeset/")
|
||||
)
|
||||
)
|
||||
|
||||
val refresher = refresherFactory.create(service, client.okHttpClient)
|
||||
assertFalse(refresher.shouldPreselect(collection, homesets))
|
||||
}
|
||||
val refresher = refresherFactory.create(service, client.okHttpClient)
|
||||
assertFalse(refresher.shouldPreselect(collection, homesets))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun shouldPreselect_personal_isPersonal() {
|
||||
val service = createTestService(Service.TYPE_CARDDAV)!!
|
||||
every { settings.getIntOrNull(Settings.PRESELECT_COLLECTIONS) } returns Settings.PRESELECT_COLLECTIONS_PERSONAL
|
||||
every { settings.getString(Settings.PRESELECT_COLLECTIONS_EXCLUDED) } returns ""
|
||||
|
||||
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_PERSONAL"))
|
||||
val collection = Collection(
|
||||
0,
|
||||
service.id,
|
||||
0,
|
||||
type = Collection.TYPE_ADDRESSBOOK,
|
||||
url = mockServer.url("/addressbook-homeset/addressbook/")
|
||||
)
|
||||
val homesets = listOf(
|
||||
HomeSet(
|
||||
id = 0,
|
||||
serviceId = service.id,
|
||||
personal = true,
|
||||
url = mockServer.url("/addressbook-homeset/")
|
||||
)
|
||||
)
|
||||
|
||||
val refresher = refresherFactory.create(service, client.okHttpClient)
|
||||
assertTrue(refresher.shouldPreselect(collection, homesets))
|
||||
}
|
||||
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/")
|
||||
val collectionUrl = mockServer.url("/addressbook-homeset/addressbook/")
|
||||
|
||||
mockkObject(settings) {
|
||||
every { settings.getIntOrNull(Settings.PRESELECT_COLLECTIONS) } returns Settings.PRESELECT_COLLECTIONS_PERSONAL
|
||||
every { settings.getString(Settings.PRESELECT_COLLECTIONS_EXCLUDED) } returns collectionUrl.toString()
|
||||
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_PERSONAL"))
|
||||
val collection = Collection(
|
||||
id = 0,
|
||||
serviceId = service.id,
|
||||
homeSetId = 0,
|
||||
type = Collection.TYPE_ADDRESSBOOK,
|
||||
url = collectionUrl
|
||||
)
|
||||
val homesets = listOf(
|
||||
HomeSet(
|
||||
id = 0,
|
||||
serviceId = service.id,
|
||||
personal = true,
|
||||
url = mockServer.url("/addressbook-homeset/")
|
||||
)
|
||||
)
|
||||
|
||||
val refresher = refresherFactory.create(service, client.okHttpClient)
|
||||
assertFalse(refresher.shouldPreselect(collection, homesets))
|
||||
}
|
||||
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)
|
||||
}
|
||||
|
||||
companion object {
|
||||
|
||||
private const val PATH_CALDAV = "/caldav"
|
||||
private const val PATH_CARDDAV = "/carddav"
|
||||
|
||||
@@ -633,6 +626,7 @@ class CollectionListRefresherTest {
|
||||
private const val SUBPATH_ADDRESSBOOK_HOMESET_EMPTY = "/addressbooks-homeset-empty"
|
||||
private const val SUBPATH_ADDRESSBOOK = "/addressbooks/my-contacts"
|
||||
private const val SUBPATH_ADDRESSBOOK_INACCESSIBLE = "/addressbooks/inaccessible-contacts"
|
||||
|
||||
}
|
||||
|
||||
class TestDispatcher(
|
||||
@@ -751,4 +745,5 @@ class CollectionListRefresherTest {
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -4,17 +4,13 @@
|
||||
|
||||
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.SettingsManager
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import dagger.hilt.android.testing.HiltAndroidRule
|
||||
import dagger.hilt.android.testing.HiltAndroidTest
|
||||
import okhttp3.mockwebserver.Dispatcher
|
||||
@@ -53,8 +49,7 @@ class DavResourceFinderTest {
|
||||
val hiltRule = HiltAndroidRule(this)
|
||||
|
||||
@Inject
|
||||
@ApplicationContext
|
||||
lateinit var context: Context
|
||||
lateinit var httpClientBuilder: HttpClient.Builder
|
||||
|
||||
@Inject
|
||||
lateinit var logger: Logger
|
||||
@@ -62,40 +57,37 @@ class DavResourceFinderTest {
|
||||
@Inject
|
||||
lateinit var resourceFinderFactory: DavResourceFinder.Factory
|
||||
|
||||
@Inject
|
||||
lateinit var settingsManager: SettingsManager
|
||||
|
||||
private val server = MockWebServer()
|
||||
|
||||
private lateinit var finder: DavResourceFinder
|
||||
private lateinit var server: MockWebServer
|
||||
private lateinit var client: HttpClient
|
||||
private lateinit var finder: DavResourceFinder
|
||||
|
||||
@Before
|
||||
fun setup() {
|
||||
fun setUp() {
|
||||
hiltRule.inject()
|
||||
|
||||
server.dispatcher = TestDispatcher(logger)
|
||||
server.start()
|
||||
server = MockWebServer().apply {
|
||||
dispatcher = TestDispatcher(logger)
|
||||
start()
|
||||
}
|
||||
|
||||
val credentials = Credentials(username = "mock", password = "12345".toCharArray())
|
||||
client = httpClientBuilder
|
||||
.authenticate(host = null, credentials = credentials)
|
||||
.build()
|
||||
Assume.assumeTrue(NetworkSecurityPolicy.getInstance().isCleartextTrafficPermitted)
|
||||
|
||||
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() {
|
||||
fun tearDown() {
|
||||
client.close()
|
||||
server.shutdown()
|
||||
}
|
||||
|
||||
|
||||
@Test
|
||||
@SmallTest
|
||||
fun testRememberIfAddressBookOrHomeset() {
|
||||
// recognize home set
|
||||
var info = ServiceInfo()
|
||||
|
||||
@@ -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.runBlocking
|
||||
import kotlinx.coroutines.test.runTest
|
||||
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() = runBlocking {
|
||||
fun test_observerFlow_initialValue() = runTest {
|
||||
var counter = 0
|
||||
val live = settingsManager.observerFlow {
|
||||
if (counter++ == 0)
|
||||
@@ -71,7 +71,7 @@ class SettingsManagerTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
fun test_observerFlow_updatedValue() = runBlocking {
|
||||
fun test_observerFlow_updatedValue() = runTest {
|
||||
var counter = 0
|
||||
val live = settingsManager.observerFlow {
|
||||
when (counter++) {
|
||||
|
||||
@@ -53,6 +53,7 @@ class AccountSettingsMigration17Test {
|
||||
|
||||
@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)
|
||||
@@ -63,7 +64,7 @@ class AccountSettingsMigration17Test {
|
||||
// address book has account + URL
|
||||
val url = "https://example.com/address-book"
|
||||
accountManager.setAndVerifyUserData(addressBookAccount, "real_account_name", account.name)
|
||||
accountManager.setAndVerifyUserData(addressBookAccount, LocalAddressBook.USER_DATA_URL, url)
|
||||
accountManager.setAndVerifyUserData(addressBookAccount, localAddressBookUserDataUrl, url)
|
||||
|
||||
// and is known in database
|
||||
db.serviceDao().insertOrReplace(
|
||||
@@ -86,7 +87,7 @@ class AccountSettingsMigration17Test {
|
||||
|
||||
// migration renames address book, update account
|
||||
addressBookAccount = accountManager.getAccountsByType(addressBookAccountType).filter {
|
||||
accountManager.getUserData(it, LocalAddressBook.USER_DATA_URL) == url
|
||||
accountManager.getUserData(it, localAddressBookUserDataUrl) == url
|
||||
}.first()
|
||||
assertEquals("Some Address Book (${account.name}) #100", addressBookAccount.name)
|
||||
|
||||
|
||||
@@ -15,11 +15,8 @@ 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.MockKAnnotations
|
||||
import io.mockk.every
|
||||
import io.mockk.impl.annotations.InjectMockKs
|
||||
import io.mockk.impl.annotations.MockK
|
||||
import io.mockk.mockk
|
||||
import io.mockk.junit4.MockKRule
|
||||
import io.mockk.mockkObject
|
||||
import io.mockk.verify
|
||||
import okhttp3.HttpUrl.Companion.toHttpUrl
|
||||
@@ -34,29 +31,27 @@ class AccountSettingsMigration18Test {
|
||||
@Inject @ApplicationContext
|
||||
lateinit var context: Context
|
||||
|
||||
@MockK
|
||||
@Inject
|
||||
lateinit var db: AppDatabase
|
||||
|
||||
@InjectMockKs
|
||||
@Inject
|
||||
lateinit var migration: AccountSettingsMigration18
|
||||
|
||||
@get:Rule
|
||||
val hiltRule = HiltAndroidRule(this)
|
||||
|
||||
@get:Rule
|
||||
val mockkRule = MockKRule(this)
|
||||
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
hiltRule.inject()
|
||||
MockKAnnotations.init(this)
|
||||
}
|
||||
|
||||
|
||||
@Test
|
||||
fun testMigrate_AddressBook_InvalidCollection() {
|
||||
every { db.serviceDao() } returns mockk {
|
||||
every { getByAccountAndType(any(), any()) } returns null
|
||||
}
|
||||
|
||||
val addressBookAccountType = context.getString(R.string.account_type_address_book)
|
||||
var addressBookAccount = Account("Address Book", addressBookAccountType)
|
||||
|
||||
@@ -75,10 +70,6 @@ class AccountSettingsMigration18Test {
|
||||
|
||||
@Test
|
||||
fun testMigrate_AddressBook_NoCollection() {
|
||||
every { db.serviceDao() } returns mockk {
|
||||
every { getByAccountAndType(any(), any()) } returns null
|
||||
}
|
||||
|
||||
val addressBookAccountType = context.getString(R.string.account_type_address_book)
|
||||
var addressBookAccount = Account("Address Book", addressBookAccountType)
|
||||
|
||||
@@ -99,22 +90,18 @@ class AccountSettingsMigration18Test {
|
||||
fun testMigrate_AddressBook_ValidCollection() {
|
||||
val account = Account("test", "test")
|
||||
|
||||
every { db.serviceDao() } returns mockk {
|
||||
every { getByAccountAndType(any(), any()) } returns Service(
|
||||
id = 10,
|
||||
accountName = account.name,
|
||||
type = Service.TYPE_CARDDAV,
|
||||
principal = null
|
||||
)
|
||||
}
|
||||
every { db.collectionDao() } returns mockk {
|
||||
every { getByService(10) } returns listOf(Collection(
|
||||
id = 100,
|
||||
serviceId = 10,
|
||||
url = "http://example.com".toHttpUrl(),
|
||||
type = Collection.TYPE_ADDRESSBOOK
|
||||
))
|
||||
}
|
||||
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)
|
||||
|
||||
@@ -13,16 +13,13 @@ 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.MockKAnnotations
|
||||
import io.mockk.impl.annotations.InjectMockKs
|
||||
import io.mockk.impl.annotations.MockK
|
||||
import io.mockk.impl.annotations.SpyK
|
||||
import io.mockk.impl.annotations.RelaxedMockK
|
||||
import io.mockk.junit4.MockKRule
|
||||
import io.mockk.mockkObject
|
||||
import io.mockk.unmockkAll
|
||||
import io.mockk.verify
|
||||
import org.junit.After
|
||||
import org.junit.Before
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
@@ -32,13 +29,13 @@ import javax.inject.Inject
|
||||
class AccountSettingsMigration19Test {
|
||||
|
||||
@Inject @ApplicationContext
|
||||
@SpyK
|
||||
lateinit var context: Context
|
||||
|
||||
@MockK(relaxed = true)
|
||||
@BindValue
|
||||
@RelaxedMockK
|
||||
lateinit var automaticSyncManager: AutomaticSyncManager
|
||||
|
||||
@InjectMockKs
|
||||
@Inject
|
||||
lateinit var migration: AccountSettingsMigration19
|
||||
|
||||
@Inject
|
||||
@@ -47,6 +44,9 @@ class AccountSettingsMigration19Test {
|
||||
@get:Rule
|
||||
val hiltRule = HiltAndroidRule(this)
|
||||
|
||||
@get:Rule
|
||||
val mockkRule = MockKRule(this)
|
||||
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
@@ -58,13 +58,6 @@ class AccountSettingsMigration19Test {
|
||||
.setWorkerFactory(workerFactory)
|
||||
.build()
|
||||
WorkManagerTestInitHelper.initializeTestWorkManager(context, config)
|
||||
|
||||
MockKAnnotations.init(this)
|
||||
}
|
||||
|
||||
@After
|
||||
fun tearDown() {
|
||||
unmockkAll()
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,144 @@
|
||||
/*
|
||||
* 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
|
||||
)
|
||||
)!!
|
||||
try {
|
||||
migration.migrateCalendars(account, calDavServiceId = 1)
|
||||
|
||||
provider.query(uri.asSyncAdapter(account), arrayOf(Calendars._SYNC_ID), null, null, null)!!.use { cursor ->
|
||||
cursor.moveToNext()
|
||||
assertEquals(collectionId, cursor.getLongOrNull(0))
|
||||
}
|
||||
} finally {
|
||||
provider.delete(uri.asSyncAdapter(account), null, null)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,182 @@
|
||||
/*
|
||||
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
|
||||
*/
|
||||
|
||||
package at.bitfire.davdroid.sync
|
||||
|
||||
import android.content.ContentProviderClient
|
||||
import android.content.Context
|
||||
import androidx.test.rule.GrantPermissionRule
|
||||
import at.bitfire.davdroid.CatchExceptionsRule
|
||||
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.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.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 = CatchExceptionsRule(
|
||||
GrantPermissionRule.grant(*TaskProvider.PERMISSIONS_JTX),
|
||||
SecurityException::class
|
||||
)
|
||||
|
||||
private val account = TestAccount.create()
|
||||
|
||||
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
|
||||
provider = context.contentResolver.acquireContentProviderClient(JtxContract.AUTHORITY)!!
|
||||
|
||||
// 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()
|
||||
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)
|
||||
}
|
||||
|
||||
}
|
||||
@@ -8,7 +8,7 @@ import at.bitfire.davdroid.db.SyncState
|
||||
import at.bitfire.davdroid.resource.LocalCollection
|
||||
|
||||
class LocalTestCollection(
|
||||
override val collectionUrl: String = "http://example.com/test/"
|
||||
override val dbCollectionId: Long = 0L
|
||||
): LocalCollection<LocalTestResource> {
|
||||
|
||||
override val tag = "LocalTestCollection"
|
||||
|
||||
@@ -24,16 +24,16 @@ import dagger.hilt.android.testing.HiltAndroidTest
|
||||
import io.mockk.Awaits
|
||||
import io.mockk.coEvery
|
||||
import io.mockk.every
|
||||
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.runBlocking
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import kotlinx.coroutines.withTimeout
|
||||
import org.junit.After
|
||||
import org.junit.Before
|
||||
@@ -55,8 +55,7 @@ class SyncAdapterServicesTest {
|
||||
@Inject
|
||||
lateinit var collectionRepository: DavCollectionRepository
|
||||
|
||||
@Inject
|
||||
@ApplicationContext
|
||||
@Inject @ApplicationContext
|
||||
lateinit var context: Context
|
||||
|
||||
@Inject
|
||||
@@ -74,6 +73,9 @@ class SyncAdapterServicesTest {
|
||||
@get:Rule
|
||||
val hiltRule = HiltAndroidRule(this)
|
||||
|
||||
@get:Rule
|
||||
val mockkRule = MockKRule(this)
|
||||
|
||||
// test methods should run quickly and not wait 60 seconds for a sync timeout or something like that
|
||||
@get:Rule
|
||||
val timeoutRule: Timeout = Timeout.seconds(5)
|
||||
@@ -90,7 +92,6 @@ class SyncAdapterServicesTest {
|
||||
@After
|
||||
fun tearDown() {
|
||||
TestAccount.remove(account)
|
||||
unmockkAll()
|
||||
}
|
||||
|
||||
|
||||
@@ -109,7 +110,7 @@ class SyncAdapterServicesTest {
|
||||
|
||||
|
||||
@Test
|
||||
fun testSyncAdapter_onPerformSync_cancellation() {
|
||||
fun testSyncAdapter_onPerformSync_cancellation() = runTest {
|
||||
val syncWorkerManager = mockk<SyncWorkerManager>()
|
||||
val syncAdapter = syncAdapter(syncWorkerManager = syncWorkerManager)
|
||||
val workManager = WorkManager.getInstance(context)
|
||||
@@ -121,17 +122,15 @@ class SyncAdapterServicesTest {
|
||||
// assume worker takes a long time
|
||||
every { workManager.getWorkInfosForUniqueWorkFlow("TheSyncWorker") } just Awaits
|
||||
|
||||
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()
|
||||
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()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -20,15 +20,15 @@ import at.bitfire.davdroid.network.HttpClient
|
||||
import at.bitfire.davdroid.repository.DavSyncStatsRepository
|
||||
import at.bitfire.davdroid.settings.AccountSettings
|
||||
import at.bitfire.davdroid.sync.account.TestAccount
|
||||
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,41 +46,45 @@ import javax.inject.Inject
|
||||
@HiltAndroidTest
|
||||
class SyncManagerTest {
|
||||
|
||||
@Module
|
||||
@InstallIn(SingletonComponent::class)
|
||||
object SyncManagerTestModule {
|
||||
@Provides
|
||||
fun davSyncStatsRepository(): DavSyncStatsRepository = mockk<DavSyncStatsRepository>(relaxed = true)
|
||||
}
|
||||
|
||||
|
||||
@get:Rule
|
||||
val hiltRule = HiltAndroidRule(this)
|
||||
|
||||
@get:Rule
|
||||
val mockKRule = MockKRule(this)
|
||||
|
||||
@Inject
|
||||
lateinit var accountSettingsFactory: AccountSettings.Factory
|
||||
|
||||
@Inject
|
||||
@ApplicationContext
|
||||
@Inject @ApplicationContext
|
||||
lateinit var context: Context
|
||||
|
||||
@Inject
|
||||
lateinit var httpClientBuilder: HttpClient.Builder
|
||||
|
||||
@Inject
|
||||
lateinit var syncManagerFactory: TestSyncManager.Factory
|
||||
|
||||
@BindValue
|
||||
@RelaxedMockK
|
||||
lateinit var syncStatsRepository: DavSyncStatsRepository
|
||||
|
||||
@Inject
|
||||
lateinit var workerFactory: HiltWorkerFactory
|
||||
|
||||
lateinit var account: Account
|
||||
private val server = MockWebServer()
|
||||
private lateinit var account: Account
|
||||
private lateinit var server: MockWebServer
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
hiltRule.inject()
|
||||
|
||||
TestUtils.setUpWorkManager(context, workerFactory)
|
||||
|
||||
account = TestAccount.create()
|
||||
|
||||
server.start()
|
||||
server = MockWebServer().apply {
|
||||
start()
|
||||
}
|
||||
}
|
||||
|
||||
@After
|
||||
@@ -94,30 +98,6 @@ 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(
|
||||
@@ -142,8 +122,9 @@ class SyncManagerTest {
|
||||
.setBody(body.toString())
|
||||
}
|
||||
|
||||
|
||||
@Test
|
||||
fun testPerformSync_503RetryAfter_DelaySeconds() {
|
||||
fun testPerformSync_503RetryAfter_DelaySeconds() = runTest {
|
||||
server.enqueue(MockResponse()
|
||||
.setResponseCode(503)
|
||||
.setHeader("Retry-After", "60")) // 60 seconds
|
||||
@@ -160,7 +141,7 @@ class SyncManagerTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testPerformSync_FirstSync_Empty() {
|
||||
fun testPerformSync_FirstSync_Empty() = runTest {
|
||||
val collection = LocalTestCollection() /* no last known ctag */
|
||||
server.enqueue(queryCapabilitiesResponse())
|
||||
|
||||
@@ -175,7 +156,7 @@ class SyncManagerTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testPerformSync_UploadNewMember_ETagOnPut() {
|
||||
fun testPerformSync_UploadNewMember_ETagOnPut() = runTest {
|
||||
val collection = LocalTestCollection().apply {
|
||||
lastSyncState = SyncState(SyncState.Type.CTAG, "old-ctag")
|
||||
entries += LocalTestResource().apply {
|
||||
@@ -218,7 +199,7 @@ class SyncManagerTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testPerformSync_UploadModifiedMember_ETagOnPut() {
|
||||
fun testPerformSync_UploadModifiedMember_ETagOnPut() = runTest {
|
||||
val collection = LocalTestCollection().apply {
|
||||
lastSyncState = SyncState(SyncState.Type.CTAG, "old-ctag")
|
||||
entries += LocalTestResource().apply {
|
||||
@@ -265,7 +246,7 @@ class SyncManagerTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testPerformSync_UploadModifiedMember_NoETagOnPut() {
|
||||
fun testPerformSync_UploadModifiedMember_NoETagOnPut() = runTest {
|
||||
val collection = LocalTestCollection().apply {
|
||||
lastSyncState = SyncState(SyncState.Type.CTAG, "old-ctag")
|
||||
entries += LocalTestResource().apply {
|
||||
@@ -310,7 +291,7 @@ class SyncManagerTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testPerformSync_UploadModifiedMember_412PreconditionFailed() {
|
||||
fun testPerformSync_UploadModifiedMember_412PreconditionFailed() = runTest {
|
||||
val collection = LocalTestCollection().apply {
|
||||
lastSyncState = SyncState(SyncState.Type.CTAG, "old-ctag")
|
||||
entries += LocalTestResource().apply {
|
||||
@@ -356,7 +337,7 @@ class SyncManagerTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testPerformSync_NoopOnMemberWithSameETag() {
|
||||
fun testPerformSync_NoopOnMemberWithSameETag() = runTest {
|
||||
val collection = LocalTestCollection().apply {
|
||||
lastSyncState = SyncState(SyncState.Type.CTAG, "ctag1")
|
||||
entries += LocalTestResource().apply {
|
||||
@@ -393,7 +374,7 @@ class SyncManagerTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testPerformSync_DownloadNewMember() {
|
||||
fun testPerformSync_DownloadNewMember() = runTest {
|
||||
val collection = LocalTestCollection().apply {
|
||||
lastSyncState = SyncState(SyncState.Type.CTAG, "old-ctag")
|
||||
}
|
||||
@@ -427,7 +408,7 @@ class SyncManagerTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testPerformSync_DownloadUpdatedMember() {
|
||||
fun testPerformSync_DownloadUpdatedMember() = runTest {
|
||||
val collection = LocalTestCollection().apply {
|
||||
lastSyncState = SyncState(SyncState.Type.CTAG, "old-ctag")
|
||||
entries += LocalTestResource().apply {
|
||||
@@ -465,7 +446,7 @@ class SyncManagerTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testPerformSync_RemoveVanishedMember() {
|
||||
fun testPerformSync_RemoveVanishedMember() = runTest {
|
||||
val collection = LocalTestCollection().apply {
|
||||
lastSyncState = SyncState(SyncState.Type.CTAG, "old-ctag")
|
||||
entries += LocalTestResource().apply {
|
||||
@@ -485,7 +466,7 @@ class SyncManagerTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testPerformSync_CTagDidntChange() {
|
||||
fun testPerformSync_CTagDidntChange() = runTest {
|
||||
val collection = LocalTestCollection().apply {
|
||||
lastSyncState = SyncState(SyncState.Type.CTAG, "ctag1")
|
||||
}
|
||||
@@ -507,16 +488,13 @@ class SyncManagerTest {
|
||||
private fun syncManager(
|
||||
localCollection: LocalTestCollection,
|
||||
syncResult: SyncResult = SyncResult(),
|
||||
collection: Collection = mockk<Collection>() {
|
||||
collection: Collection = mockk<Collection>(relaxed = true) {
|
||||
every { id } returns 1
|
||||
every { url } returns server.url("/")
|
||||
}
|
||||
) = syncManagerFactory.create(
|
||||
account,
|
||||
accountSettingsFactory.create(account),
|
||||
arrayOf(),
|
||||
"TestAuthority",
|
||||
HttpClient.Builder(context).build(),
|
||||
httpClientBuilder.build(),
|
||||
syncResult,
|
||||
localCollection,
|
||||
collection
|
||||
|
||||
@@ -17,7 +17,6 @@ import io.mockk.just
|
||||
import io.mockk.mockk
|
||||
import io.mockk.runs
|
||||
import io.mockk.verify
|
||||
import okhttp3.HttpUrl.Companion.toHttpUrl
|
||||
import org.junit.Assert.assertArrayEquals
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
@@ -38,7 +37,7 @@ class SyncerTest {
|
||||
|
||||
@SpyK
|
||||
@InjectMockKs
|
||||
var syncer = TestSyncer(mockk(relaxed = true), emptyArray(), SyncResult(), dataStore)
|
||||
var syncer = TestSyncer(mockk(relaxed = true), null, SyncResult(), dataStore)
|
||||
|
||||
|
||||
@Test
|
||||
@@ -66,9 +65,10 @@ class SyncerTest {
|
||||
|
||||
@Test
|
||||
fun testUpdateCollections_deletesCollection() {
|
||||
val localCollection = mockk<LocalTestCollection>()
|
||||
every { localCollection.collectionUrl } returns "http://delete.the/collection"
|
||||
every { localCollection.title } returns "Collection to be deleted locally"
|
||||
val localCollection = mockk<LocalTestCollection> {
|
||||
every { dbCollectionId } returns 0L
|
||||
every { title } returns "Collection to be deleted locally"
|
||||
}
|
||||
|
||||
// Should delete the localCollection if dbCollection (remote) does not exist
|
||||
val localCollections = mutableListOf(localCollection)
|
||||
@@ -81,12 +81,14 @@ class SyncerTest {
|
||||
|
||||
@Test
|
||||
fun testUpdateCollections_updatesCollection() {
|
||||
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"
|
||||
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)
|
||||
|
||||
// Should update the localCollection if it exists
|
||||
val result = syncer.updateCollections(provider, listOf(localCollection), dbCollections)
|
||||
@@ -99,13 +101,13 @@ class SyncerTest {
|
||||
@Test
|
||||
fun testUpdateCollections_findsNewCollection() {
|
||||
val dbCollection = mockk<Collection> {
|
||||
every { url } returns "http://newly.found/collection".toHttpUrl()
|
||||
every { id } returns 0L
|
||||
}
|
||||
val localCollections = listOf(mockk<LocalTestCollection> {
|
||||
every { collectionUrl } returns "http://newly.found/collection"
|
||||
every { dbCollectionId } returns 0L
|
||||
})
|
||||
val dbCollections = listOf(dbCollection)
|
||||
val dbCollectionsMap = mapOf(dbCollection.url to dbCollection)
|
||||
val dbCollectionsMap = mapOf(dbCollection.id to dbCollection)
|
||||
every { syncer.createLocalCollections(provider, dbCollections) } returns localCollections
|
||||
|
||||
// Should return the new collection, because it was not updated
|
||||
@@ -113,7 +115,7 @@ class SyncerTest {
|
||||
|
||||
// Updated local collection list contain new entry
|
||||
assertEquals(1, result.size)
|
||||
assertEquals(dbCollection.url.toString(), result[0].collectionUrl)
|
||||
assertEquals(dbCollection.id, result[0].dbCollectionId)
|
||||
}
|
||||
|
||||
|
||||
@@ -134,14 +136,14 @@ class SyncerTest {
|
||||
val dbCollection1 = mockk<Collection>()
|
||||
val dbCollection2 = mockk<Collection>()
|
||||
val dbCollections = mapOf(
|
||||
"http://newly.found/collection1".toHttpUrl() to dbCollection1,
|
||||
"http://newly.found/collection2".toHttpUrl() to dbCollection2
|
||||
0L to dbCollection1,
|
||||
1L to dbCollection2
|
||||
)
|
||||
val localCollection1 = mockk<LocalTestCollection>()
|
||||
val localCollection2 = mockk<LocalTestCollection>()
|
||||
val localCollection1 = mockk<LocalTestCollection> { every { dbCollectionId } returns 0L }
|
||||
val localCollection2 = mockk<LocalTestCollection> { every { dbCollectionId } returns 1L }
|
||||
val localCollections = listOf(localCollection1, localCollection2)
|
||||
every { localCollection1.collectionUrl } returns "http://newly.found/collection1"
|
||||
every { localCollection2.collectionUrl } returns "http://newly.found/collection2"
|
||||
every { localCollection1.dbCollectionId } returns 0L
|
||||
every { localCollection2.dbCollectionId } returns 1L
|
||||
every { syncer.syncCollection(provider, any(), any()) } just runs
|
||||
|
||||
// Should call the collection content sync on both collections
|
||||
@@ -153,19 +155,16 @@ class SyncerTest {
|
||||
|
||||
// Test helpers
|
||||
|
||||
class TestSyncer (
|
||||
class TestSyncer(
|
||||
account: Account,
|
||||
extras: Array<String>,
|
||||
resyncType: ResyncType?,
|
||||
syncResult: SyncResult,
|
||||
theDataStore: LocalTestStore
|
||||
) : Syncer<LocalTestStore, LocalTestCollection>(account, extras, syncResult) {
|
||||
) : Syncer<LocalTestStore, LocalTestCollection>(account, resyncType, syncResult) {
|
||||
|
||||
override val dataStore: LocalTestStore =
|
||||
theDataStore
|
||||
|
||||
override val authority: String
|
||||
get() = throw NotImplementedError()
|
||||
|
||||
override val serviceType: String
|
||||
get() = throw NotImplementedError()
|
||||
|
||||
@@ -187,6 +186,13 @@ class SyncerTest {
|
||||
|
||||
class LocalTestStore : LocalDataStore<LocalTestCollection> {
|
||||
|
||||
override val authority: String
|
||||
get() = throw NotImplementedError()
|
||||
|
||||
override fun acquireContentProvider(): ContentProviderClient? {
|
||||
throw NotImplementedError()
|
||||
}
|
||||
|
||||
override fun create(
|
||||
provider: ContentProviderClient,
|
||||
fromCollection: Collection
|
||||
|
||||
@@ -11,13 +11,14 @@ import at.bitfire.dav4jvm.Response
|
||||
import at.bitfire.dav4jvm.property.caldav.GetCTag
|
||||
import at.bitfire.davdroid.db.Collection
|
||||
import at.bitfire.davdroid.db.SyncState
|
||||
import at.bitfire.davdroid.di.SyncDispatcher
|
||||
import at.bitfire.davdroid.network.HttpClient
|
||||
import at.bitfire.davdroid.resource.LocalResource
|
||||
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
|
||||
@@ -25,31 +26,26 @@ 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
|
||||
@Assisted collection: Collection,
|
||||
@SyncDispatcher syncDispatcher: CoroutineDispatcher
|
||||
): SyncManager<LocalTestResource, LocalTestCollection, DavCollection>(
|
||||
account,
|
||||
accountSettings,
|
||||
httpClient,
|
||||
extras,
|
||||
authority,
|
||||
SyncDataType.EVENTS,
|
||||
syncResult,
|
||||
localCollection,
|
||||
collection
|
||||
collection,
|
||||
resync = null,
|
||||
syncDispatcher
|
||||
) {
|
||||
|
||||
@AssistedFactory
|
||||
interface Factory {
|
||||
fun create(
|
||||
account: Account,
|
||||
accountSettings: AccountSettings,
|
||||
extras: Array<String>,
|
||||
authority: String,
|
||||
httpClient: HttpClient,
|
||||
syncResult: SyncResult,
|
||||
localCollection: LocalTestCollection,
|
||||
@@ -63,7 +59,7 @@ class TestSyncManager @AssistedInject constructor(
|
||||
}
|
||||
|
||||
var didQueryCapabilities = false
|
||||
override fun queryCapabilities(): SyncState? {
|
||||
override suspend fun queryCapabilities(): SyncState? {
|
||||
if (didQueryCapabilities)
|
||||
throw IllegalStateException("queryCapabilities() must not be called twice")
|
||||
didQueryCapabilities = true
|
||||
@@ -89,7 +85,7 @@ class TestSyncManager @AssistedInject constructor(
|
||||
|
||||
var listAllRemoteResult = emptyList<Pair<Response, Response.HrefRelation>>()
|
||||
var didListAllRemote = false
|
||||
override fun listAllRemote(callback: MultiResponseCallback) {
|
||||
override suspend fun listAllRemote(callback: MultiResponseCallback) {
|
||||
if (didListAllRemote)
|
||||
throw IllegalStateException("listAllRemote() must not be called twice")
|
||||
didListAllRemote = true
|
||||
@@ -99,7 +95,7 @@ class TestSyncManager @AssistedInject constructor(
|
||||
|
||||
var assertDownloadRemote = emptyMap<HttpUrl, String>()
|
||||
var didDownloadRemote = false
|
||||
override fun downloadRemote(bunch: List<HttpUrl>) {
|
||||
override suspend fun downloadRemote(bunch: List<HttpUrl>) {
|
||||
didDownloadRemote = true
|
||||
assertEquals(assertDownloadRemote.keys.toList(), bunch)
|
||||
|
||||
|
||||
@@ -15,7 +15,6 @@ import at.bitfire.davdroid.TestUtils
|
||||
import at.bitfire.davdroid.db.AppDatabase
|
||||
import at.bitfire.davdroid.db.Service
|
||||
import at.bitfire.davdroid.resource.LocalAddressBook
|
||||
import at.bitfire.davdroid.resource.LocalTestAddressBook
|
||||
import at.bitfire.davdroid.settings.SettingsManager
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import dagger.hilt.android.testing.HiltAndroidRule
|
||||
@@ -66,9 +65,6 @@ class AccountsCleanupWorkerTest {
|
||||
|
||||
addressBookAccountType = context.getString(R.string.account_type_address_book)
|
||||
addressBookAccount = Account("Fancy address book account", addressBookAccountType)
|
||||
|
||||
// Make sure there are no address books
|
||||
LocalTestAddressBook.removeAll(context)
|
||||
}
|
||||
|
||||
@After
|
||||
|
||||
@@ -19,9 +19,10 @@ 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 io.mockk.mockkObject
|
||||
import io.mockk.verify
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.After
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Before
|
||||
@@ -41,6 +42,9 @@ class PeriodicSyncWorkerTest {
|
||||
@get:Rule
|
||||
val hiltRule = HiltAndroidRule(this)
|
||||
|
||||
@get:Rule
|
||||
val mockkRule = MockKRule(this)
|
||||
|
||||
lateinit var account: Account
|
||||
|
||||
@Before
|
||||
@@ -58,7 +62,7 @@ class PeriodicSyncWorkerTest {
|
||||
|
||||
|
||||
@Test
|
||||
fun doWork_cancelsItselfOnInvalidAccount() {
|
||||
fun doWork_cancelsItselfOnInvalidAccount() = runTest {
|
||||
val invalidAccount = Account("invalid", context.getString(R.string.account_type))
|
||||
|
||||
// Run PeriodicSyncWorker as TestWorker
|
||||
@@ -68,7 +72,7 @@ class PeriodicSyncWorkerTest {
|
||||
BaseSyncWorker.INPUT_ACCOUNT_TYPE to invalidAccount.type
|
||||
)
|
||||
|
||||
// mock WorkManager to observe cancellation call
|
||||
// observe WorkManager cancellation call
|
||||
val workManager = WorkManager.getInstance(context)
|
||||
mockkObject(workManager)
|
||||
|
||||
@@ -79,9 +83,7 @@ class PeriodicSyncWorkerTest {
|
||||
syncWorkerFactory.create(appContext, workerParameters)
|
||||
})
|
||||
.build()
|
||||
val result = runBlocking {
|
||||
testWorker.doWork()
|
||||
}
|
||||
val result = testWorker.doWork()
|
||||
assertTrue(result is ListenableWorker.Result.Failure)
|
||||
|
||||
// verify that worker called WorkManager.cancelWorkById(<its ID>)
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
/*
|
||||
* 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)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
/*
|
||||
* 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())
|
||||
}
|
||||
|
||||
}
|
||||
@@ -30,8 +30,8 @@ class CredentialsStoreTest {
|
||||
|
||||
@Test
|
||||
fun testSetGetDelete() {
|
||||
store.setCredentials(0, Credentials(username = "myname", password = "12345"))
|
||||
assertEquals(Credentials(username = "myname", password = "12345"), store.getCredentials(0))
|
||||
store.setCredentials(0, Credentials(username = "myname", password = "12345".toCharArray()))
|
||||
assertEquals(Credentials(username = "myname", password = "12345".toCharArray()), store.getCredentials(0))
|
||||
|
||||
store.setCredentials(0, null)
|
||||
assertNull(store.getCredentials(0))
|
||||
|
||||
@@ -6,7 +6,6 @@ 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
|
||||
@@ -14,6 +13,8 @@ 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.test.runTest
|
||||
import okhttp3.CookieJar
|
||||
import okhttp3.mockwebserver.Dispatcher
|
||||
import okhttp3.mockwebserver.MockResponse
|
||||
@@ -25,6 +26,7 @@ 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
|
||||
@@ -34,75 +36,82 @@ class DavDocumentsProviderTest {
|
||||
private const val PATH_WEBDAV_ROOT = "/webdav"
|
||||
}
|
||||
|
||||
@get:Rule
|
||||
val hiltRule = HiltAndroidRule(this)
|
||||
|
||||
@Inject
|
||||
@ApplicationContext
|
||||
@Inject @ApplicationContext
|
||||
lateinit var context: Context
|
||||
|
||||
@Inject
|
||||
lateinit var credentialsStore: CredentialsStore
|
||||
|
||||
@Inject
|
||||
lateinit var davDocumentsActorFactory: DavDocumentsProvider.DavDocumentsActor.Factory
|
||||
|
||||
@Inject
|
||||
lateinit var httpClientBuilder: HttpClient.Builder
|
||||
|
||||
@Inject
|
||||
lateinit var db: AppDatabase
|
||||
|
||||
@Inject
|
||||
lateinit var logger: java.util.logging.Logger
|
||||
lateinit var testDispatcher: TestDispatcher
|
||||
|
||||
@get:Rule
|
||||
val hiltRule = HiltAndroidRule(this)
|
||||
|
||||
@get:Rule
|
||||
val mockkRule = MockKRule(this)
|
||||
|
||||
private lateinit var server: MockWebServer
|
||||
private lateinit var client: HttpClient
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
hiltRule.inject()
|
||||
}
|
||||
|
||||
server = MockWebServer().apply {
|
||||
dispatcher = testDispatcher
|
||||
start()
|
||||
}
|
||||
|
||||
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()
|
||||
client = httpClientBuilder.build()
|
||||
|
||||
// mock server delivers HTTP without encryption
|
||||
assertTrue(NetworkSecurityPolicy.getInstance().isCleartextTrafficPermitted)
|
||||
}
|
||||
|
||||
@After
|
||||
fun cleanUp() {
|
||||
mockServer.shutdown()
|
||||
db.close()
|
||||
fun tearDown() {
|
||||
client.close()
|
||||
server.shutdown()
|
||||
}
|
||||
|
||||
|
||||
@Test
|
||||
fun testDoQueryChildren_insert() {
|
||||
fun testDoQueryChildren_insert() = runTest {
|
||||
// Create parent and root in database
|
||||
val id = db.webDavMountDao().insert(WebDavMount(0, "Cat food storage", mockServer.url(PATH_WEBDAV_ROOT)))
|
||||
val id = db.webDavMountDao().insert(WebDavMount(0, "Cat food storage", server.url(PATH_WEBDAV_ROOT)))
|
||||
val webDavMount = db.webDavMountDao().getById(id)
|
||||
val parent = db.webDavDocumentDao().getOrCreateRoot(webDavMount)
|
||||
val cookieStore = mutableMapOf<Long, CookieJar>()
|
||||
|
||||
// Query
|
||||
DavDocumentsProvider.DavDocumentsActor(context, db, logger, cookieStore, CredentialsStore(context), context.getString(R.string.webdav_authority))
|
||||
.queryChildren(parent)
|
||||
val actor = davDocumentsActorFactory.create(
|
||||
cookieStore = mutableMapOf<Long, CookieJar>(),
|
||||
credentialsStore = credentialsStore
|
||||
)
|
||||
actor.queryChildren(parent)
|
||||
|
||||
// Assert new children were inserted into db
|
||||
assertEquals(3, db.webDavDocumentDao().getChildren(parent.id).size)
|
||||
assertEquals("Secret_Document.pages", db.webDavDocumentDao().getChildren(parent.id)[0].displayName)
|
||||
assertEquals("Library", 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)
|
||||
assertEquals("Secret_Document.pages", db.webDavDocumentDao().getChildren(parent.id)[2].displayName)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testDoQueryChildren_update() {
|
||||
fun testDoQueryChildren_update() = runTest {
|
||||
// Create parent and root in database
|
||||
val mountId = db.webDavMountDao().insert(WebDavMount(0, "Cat food storage", mockServer.url(PATH_WEBDAV_ROOT)))
|
||||
val mountId = db.webDavMountDao().insert(WebDavMount(0, "Cat food storage", server.url(PATH_WEBDAV_ROOT)))
|
||||
val webDavMount = db.webDavMountDao().getById(mountId)
|
||||
val parent = db.webDavDocumentDao().getOrCreateRoot(webDavMount)
|
||||
val cookieStore = mutableMapOf<Long, CookieJar>()
|
||||
assertEquals("Cat food storage", db.webDavDocumentDao().get(parent.id)!!.displayName)
|
||||
|
||||
// Create a folder
|
||||
@@ -120,23 +129,25 @@ class DavDocumentsProviderTest {
|
||||
assertEquals("My Books", db.webDavDocumentDao().get(folderId)!!.displayName)
|
||||
|
||||
// Query - should update the parent displayname and folder name
|
||||
DavDocumentsProvider.DavDocumentsActor(context, db, logger, cookieStore, CredentialsStore(context), context.getString(R.string.webdav_authority))
|
||||
.queryChildren(parent)
|
||||
val actor = davDocumentsActorFactory.create(
|
||||
cookieStore = mutableMapOf<Long, CookieJar>(),
|
||||
credentialsStore = credentialsStore
|
||||
)
|
||||
actor.queryChildren(parent)
|
||||
|
||||
// Assert parent and children were updated in database
|
||||
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)
|
||||
assertEquals("Library", db.webDavDocumentDao().getChildren(parent.id)[0].name)
|
||||
assertEquals("Library", db.webDavDocumentDao().getChildren(parent.id)[0].displayName)
|
||||
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testDoQueryChildren_delete() {
|
||||
fun testDoQueryChildren_delete() = runTest {
|
||||
// Create parent and root in database
|
||||
val mountId = db.webDavMountDao().insert(WebDavMount(0, "Cat food storage", mockServer.url(PATH_WEBDAV_ROOT)))
|
||||
val mountId = db.webDavMountDao().insert(WebDavMount(0, "Cat food storage", server.url(PATH_WEBDAV_ROOT)))
|
||||
val webDavMount = db.webDavMountDao().getById(mountId)
|
||||
val parent = db.webDavDocumentDao().getOrCreateRoot(webDavMount)
|
||||
val cookieStore = mutableMapOf<Long, CookieJar>()
|
||||
|
||||
// Create a folder
|
||||
val folderId = db.webDavDocumentDao().insert(
|
||||
@@ -145,22 +156,24 @@ class DavDocumentsProviderTest {
|
||||
assertEquals("deleteme", db.webDavDocumentDao().get(folderId)!!.name)
|
||||
|
||||
// Query - discovers serverside deletion
|
||||
DavDocumentsProvider.DavDocumentsActor(context, db, logger, cookieStore, CredentialsStore(context), context.getString(R.string.webdav_authority))
|
||||
.queryChildren(parent)
|
||||
val actor = davDocumentsActorFactory.create(
|
||||
cookieStore = mutableMapOf<Long, CookieJar>(),
|
||||
credentialsStore = credentialsStore
|
||||
)
|
||||
actor.queryChildren(parent)
|
||||
|
||||
// Assert folder got deleted
|
||||
assertEquals(null, db.webDavDocumentDao().get(folderId))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testDoQueryChildren_updateTwoParentsSimultaneous() {
|
||||
fun testDoQueryChildren_updateTwoDirectoriesSimultaneously() = runTest {
|
||||
// Create root in database
|
||||
val mountId = db.webDavMountDao().insert(WebDavMount(0, "Cat food storage", mockServer.url(PATH_WEBDAV_ROOT)))
|
||||
val mountId = db.webDavMountDao().insert(WebDavMount(0, "Cat food storage", server.url(PATH_WEBDAV_ROOT)))
|
||||
val webDavMount = db.webDavMountDao().getById(mountId)
|
||||
val root = db.webDavDocumentDao().getOrCreateRoot(webDavMount)
|
||||
val cookieStore = mutableMapOf<Long, CookieJar>()
|
||||
|
||||
// Create two parents
|
||||
// Create two directories
|
||||
val parent1Id = db.webDavDocumentDao().insert(WebDavDocument(0, mountId, root.id, "parent1", true))
|
||||
val parent2Id = db.webDavDocumentDao().insert(WebDavDocument(0, mountId, root.id, "parent2", true))
|
||||
val parent1 = db.webDavDocumentDao().get(parent1Id)!!
|
||||
@@ -169,10 +182,12 @@ class DavDocumentsProviderTest {
|
||||
assertEquals("parent2", parent2.name)
|
||||
|
||||
// Query - find children of two nodes simultaneously
|
||||
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)
|
||||
val actor = davDocumentsActorFactory.create(
|
||||
cookieStore = mutableMapOf<Long, CookieJar>(),
|
||||
credentialsStore = credentialsStore
|
||||
)
|
||||
actor.queryChildren(parent1)
|
||||
actor.queryChildren(parent2)
|
||||
|
||||
// Assert the two folders names have changed
|
||||
assertEquals("childOne.txt", db.webDavDocumentDao().getChildren(parent1Id)[0].name)
|
||||
@@ -182,8 +197,8 @@ class DavDocumentsProviderTest {
|
||||
|
||||
// mock server
|
||||
|
||||
class TestDispatcher(
|
||||
private val logger: java.util.logging.Logger
|
||||
class TestDispatcher @Inject constructor(
|
||||
private val logger: Logger
|
||||
): Dispatcher() {
|
||||
|
||||
data class Resource(
|
||||
@@ -192,10 +207,10 @@ class DavDocumentsProviderTest {
|
||||
)
|
||||
|
||||
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("",
|
||||
@@ -239,7 +254,6 @@ class DavDocumentsProviderTest {
|
||||
responses +
|
||||
"</multistatus>"
|
||||
|
||||
logger.info("Query path: $requestPath")
|
||||
logger.info("Response: $multistatus")
|
||||
return MockResponse()
|
||||
.setResponseCode(207)
|
||||
|
||||
@@ -6,7 +6,7 @@ package at.bitfire.davdroid.webdav
|
||||
|
||||
import dagger.hilt.android.testing.HiltAndroidRule
|
||||
import dagger.hilt.android.testing.HiltAndroidTest
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import okhttp3.mockwebserver.MockResponse
|
||||
import okhttp3.mockwebserver.MockWebServer
|
||||
import org.junit.Assert.assertFalse
|
||||
@@ -34,13 +34,13 @@ class WebDavMountRepositoryTest {
|
||||
val url = web.url("/")
|
||||
|
||||
@Test
|
||||
fun testHasWebDav_NoDavHeader() = runBlocking {
|
||||
fun testHasWebDav_NoDavHeader() = runTest {
|
||||
web.enqueue(MockResponse().setResponseCode(200))
|
||||
assertFalse(repository.hasWebDav(url, null))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testHasWebDav_DavClass1() = runBlocking {
|
||||
fun testHasWebDav_DavClass1() = runTest {
|
||||
web.enqueue(MockResponse()
|
||||
.setResponseCode(200)
|
||||
.addHeader("DAV: 1"))
|
||||
@@ -48,7 +48,7 @@ class WebDavMountRepositoryTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testHasWebDav_DavClass2() = runBlocking {
|
||||
fun testHasWebDav_DavClass2() = runTest {
|
||||
web.enqueue(MockResponse()
|
||||
.setResponseCode(200)
|
||||
.addHeader("DAV: 1, 2"))
|
||||
@@ -56,7 +56,7 @@ class WebDavMountRepositoryTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testHasWebDav_DavClass3() = runBlocking {
|
||||
fun testHasWebDav_DavClass3() = runTest {
|
||||
web.enqueue(MockResponse()
|
||||
.setResponseCode(200)
|
||||
.addHeader("DAV: 1, 3"))
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
<?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">
|
||||
@@ -41,6 +45,7 @@
|
||||
<application
|
||||
android:name=".App"
|
||||
android:allowBackup="false"
|
||||
android:enableOnBackInvokedCallback="true"
|
||||
android:networkSecurityConfig="@xml/network_security_config"
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
android:label="@string/app_name"
|
||||
@@ -62,7 +67,7 @@
|
||||
|
||||
<activity android:name=".ui.intro.IntroActivity" />
|
||||
<activity
|
||||
android:name=".ui.AccountsActivity"
|
||||
android:name=".ui.MainActivity"
|
||||
android:exported="true">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN"/>
|
||||
@@ -73,12 +78,12 @@
|
||||
<activity
|
||||
android:name=".ui.AboutActivity"
|
||||
android:label="@string/navigation_drawer_about"
|
||||
android:parentActivityName=".ui.AccountsActivity"/>
|
||||
android:parentActivityName=".ui.MainActivity"/>
|
||||
|
||||
<activity
|
||||
android:name=".ui.AppSettingsActivity"
|
||||
android:label="@string/app_settings"
|
||||
android:parentActivityName=".ui.AccountsActivity"
|
||||
android:parentActivityName=".ui.MainActivity"
|
||||
android:exported="true">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.APPLICATION_PREFERENCES"/>
|
||||
@@ -106,7 +111,7 @@
|
||||
|
||||
<activity
|
||||
android:name=".ui.setup.LoginActivity"
|
||||
android:parentActivityName=".ui.AccountsActivity"
|
||||
android:parentActivityName=".ui.MainActivity"
|
||||
android:windowSoftInputMode="adjustResize"
|
||||
android:exported="true">
|
||||
<intent-filter>
|
||||
@@ -134,7 +139,7 @@
|
||||
|
||||
<activity
|
||||
android:name=".ui.account.AccountActivity"
|
||||
android:parentActivityName=".ui.AccountsActivity"
|
||||
android:parentActivityName=".ui.MainActivity"
|
||||
android:exported="true">
|
||||
</activity>
|
||||
<activity
|
||||
@@ -156,7 +161,7 @@
|
||||
<activity
|
||||
android:name=".ui.webdav.WebdavMountsActivity"
|
||||
android:exported="true"
|
||||
android:parentActivityName=".ui.AccountsActivity" />
|
||||
android:parentActivityName=".ui.MainActivity" />
|
||||
<activity
|
||||
android:name=".ui.webdav.AddWebdavMountActivity"
|
||||
android:parentActivityName=".ui.webdav.WebdavMountsActivity"
|
||||
@@ -268,18 +273,16 @@
|
||||
android:resource="@xml/debug_paths" />
|
||||
</provider>
|
||||
|
||||
<!-- UnifiedPush receiver -->
|
||||
<receiver android:exported="true" android:enabled="true" android:name=".push.UnifiedPushReceiver" tools:ignore="ExportedReceiver">
|
||||
<!-- UnifiedPush -->
|
||||
<service android:exported="false" android:name=".push.UnifiedPushService">
|
||||
<intent-filter>
|
||||
<action android:name="org.unifiedpush.android.connector.MESSAGE"/>
|
||||
<action android:name="org.unifiedpush.android.connector.UNREGISTERED"/>
|
||||
<action android:name="org.unifiedpush.android.connector.NEW_ENDPOINT"/>
|
||||
<action android:name="org.unifiedpush.android.connector.REGISTRATION_FAILED"/>
|
||||
<action android:name="org.unifiedpush.android.connector.PUSH_EVENT"/>
|
||||
</intent-filter>
|
||||
</receiver>
|
||||
</service>
|
||||
|
||||
<!-- Widgets -->
|
||||
<receiver android:name=".ui.widget.SyncButtonWidgetReceiver"
|
||||
<receiver android:name=".ui.widget.LabeledSyncButtonWidgetReceiver"
|
||||
android:label="@string/widget_labeled_sync_label"
|
||||
android:exported="true">
|
||||
<intent-filter>
|
||||
<action android:name="android.appwidget.action.APPWIDGET_UPDATE" />
|
||||
@@ -287,7 +290,18 @@
|
||||
|
||||
<meta-data
|
||||
android:name="android.appwidget.provider"
|
||||
android:resource="@xml/widget_info_sync_button" />
|
||||
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" />
|
||||
</receiver>
|
||||
|
||||
</application>
|
||||
|
||||
@@ -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","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"]}
|
||||
{"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"]}
|
||||
|
||||
@@ -22,11 +22,18 @@ object Constants {
|
||||
const val HOMEPAGE_PATH_TESTED_SERVICES = "tested-with"
|
||||
|
||||
val MANUAL_URL = "https://manual.davx5.com".toUri()
|
||||
|
||||
const val MANUAL_PATH_ACCOUNTS_COLLECTIONS = "accounts_collections.html"
|
||||
const val MANUAL_FRAGMENT_SERVICE_DISCOVERY = "how-does-service-discovery-work"
|
||||
|
||||
const val MANUAL_PATH_INTRODUCTION = "introduction.html"
|
||||
const val MANUAL_FRAGMENT_AUTHENTICATION_METHODS = "authentication-methods"
|
||||
|
||||
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_PUSH = "webdav_push.html"
|
||||
const val MANUAL_PATH_WEBDAV_MOUNTS = "webdav_mounts.html"
|
||||
|
||||
val COMMUNITY_URL = "https://github.com/bitfireAT/davx5-ose/discussions".toUri()
|
||||
|
||||
@@ -7,7 +7,7 @@ package at.bitfire.davdroid
|
||||
import java.util.Collections
|
||||
|
||||
class TextTable(
|
||||
vararg val headers: String
|
||||
val headers: List<String>
|
||||
) {
|
||||
|
||||
companion object {
|
||||
@@ -18,10 +18,12 @@ class TextTable(
|
||||
|
||||
}
|
||||
|
||||
constructor(vararg headers: String): this(headers.toList())
|
||||
|
||||
|
||||
private val lines = mutableListOf<Array<String>>()
|
||||
|
||||
fun addLine(vararg values: Any?) {
|
||||
fun addLine(values: List<Any?>) {
|
||||
if (values.size != headers.size)
|
||||
throw IllegalArgumentException("Table line must have ${headers.size} column(s)")
|
||||
lines += values.map {
|
||||
@@ -29,6 +31,8 @@ class TextTable(
|
||||
}.toTypedArray()
|
||||
}
|
||||
|
||||
fun addLine(vararg values: Any?) = addLine(values.toList())
|
||||
|
||||
override fun toString(): String {
|
||||
val sb = StringBuilder()
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ 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
|
||||
@@ -21,8 +22,10 @@ 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.*
|
||||
import at.bitfire.davdroid.ui.AccountsActivity
|
||||
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.ui.MainActivity
|
||||
import at.bitfire.davdroid.ui.NotificationRegistry
|
||||
import dagger.Module
|
||||
import dagger.Provides
|
||||
@@ -40,7 +43,9 @@ import javax.inject.Singleton
|
||||
SyncStats::class,
|
||||
WebDavDocument::class,
|
||||
WebDavMount::class
|
||||
], exportSchema = true, version = 16, autoMigrations = [
|
||||
], 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),
|
||||
@@ -74,13 +79,17 @@ abstract class AppDatabase: RoomDatabase() {
|
||||
.addCallback(object: Callback() {
|
||||
override fun onDestructiveMigration(db: SupportSQLiteDatabase) {
|
||||
notificationRegistry.notifyIfPossible(NotificationRegistry.NOTIFY_DATABASE_CORRUPTED) {
|
||||
val launcherIntent = Intent(context, AccountsActivity::class.java)
|
||||
val launcherIntent = Intent(context, MainActivity::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))
|
||||
.setContentIntent(
|
||||
TaskStackBuilder.create(context)
|
||||
.addNextIntent(launcherIntent)
|
||||
.getPendingIntent(0, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE)
|
||||
)
|
||||
.setAutoCancel(true)
|
||||
.build()
|
||||
}
|
||||
|
||||
@@ -21,6 +21,7 @@ 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
|
||||
@@ -136,6 +137,9 @@ data class Collection(
|
||||
@ColumnInfo(defaultValue = "0")
|
||||
val supportsWebPush: Boolean = false,
|
||||
|
||||
/** WebDAV-Push: VAPID public key */
|
||||
val pushVapidKey: String? = null,
|
||||
|
||||
/** WebDAV-Push subscription URL */
|
||||
val pushSubscription: String? = null,
|
||||
|
||||
@@ -223,8 +227,13 @@ data class Collection(
|
||||
|
||||
// WebDAV-Push
|
||||
var supportsWebPush = false
|
||||
var vapidPublicKey: String? = null
|
||||
dav[PushTransports::class.java]?.let { pushTransports ->
|
||||
supportsWebPush = pushTransports.hasWebPush()
|
||||
for (transport in pushTransports.transports)
|
||||
if (transport is WebPush) {
|
||||
supportsWebPush = true
|
||||
vapidPublicKey = transport.vapidPublicKey?.key
|
||||
}
|
||||
}
|
||||
val pushTopic = dav[Topic::class.java]?.topic
|
||||
|
||||
@@ -242,6 +251,7 @@ data class Collection(
|
||||
supportsVJOURNAL = supportsVJOURNAL,
|
||||
source = source,
|
||||
supportsWebPush = supportsWebPush,
|
||||
pushVapidKey = vapidPublicKey,
|
||||
pushTopic = pushTopic
|
||||
)
|
||||
}
|
||||
@@ -249,6 +259,7 @@ data class Collection(
|
||||
}
|
||||
|
||||
// calculated properties
|
||||
|
||||
fun title() = displayName ?: url.lastSegment
|
||||
fun readOnly() = forceReadOnly || !privWriteContent
|
||||
|
||||
|
||||
@@ -20,23 +20,29 @@ 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")
|
||||
fun getByService(serviceId: Long): List<Collection>
|
||||
suspend 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, type: String): List<Collection>
|
||||
fun getByServiceAndType(serviceId: Long, @CollectionType type: String): List<Collection>
|
||||
|
||||
@Query("SELECT * FROM collection WHERE pushTopic=:topic AND sync")
|
||||
fun getSyncableByPushTopic(topic: String): Collection?
|
||||
suspend fun getSyncableByPushTopic(topic: String): Collection?
|
||||
|
||||
@Query("SELECT pushVapidKey FROM collection WHERE serviceId=:serviceId AND pushVapidKey IS NOT NULL LIMIT 1")
|
||||
suspend fun getFirstVapidKey(serviceId: Long): String?
|
||||
|
||||
@Query("SELECT COUNT(*) FROM collection WHERE serviceId=:serviceId AND type=:type")
|
||||
suspend fun anyOfType(serviceId: Long, type: String): Boolean
|
||||
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
|
||||
@@ -48,13 +54,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, type: String): PagingSource<Int, Collection>
|
||||
fun pageByServiceAndType(serviceId: Long, @CollectionType 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, type: String): PagingSource<Int, Collection>
|
||||
fun pagePersonalByServiceAndType(serviceId: Long, @CollectionType type: String): PagingSource<Int, Collection>
|
||||
|
||||
@Query("SELECT * FROM collection WHERE serviceId=:serviceId AND url=:url")
|
||||
fun getByServiceAndUrl(serviceId: Long, url: String): Collection?
|
||||
@@ -72,11 +78,14 @@ 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 sync AND supportsWebPush AND pushTopic IS NOT NULL")
|
||||
suspend fun getPushCapableSyncCollections(): List<Collection>
|
||||
@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 pushSubscription IS NOT NULL AND NOT sync")
|
||||
suspend fun getPushRegisteredAndNotSyncable(): 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>
|
||||
|
||||
@Insert(onConflict = OnConflictStrategy.IGNORE)
|
||||
fun insert(collection: Collection): Long
|
||||
@@ -91,7 +100,7 @@ interface CollectionDao {
|
||||
suspend fun updateForceReadOnly(id: Long, forceReadOnly: Boolean)
|
||||
|
||||
@Query("UPDATE collection SET pushSubscription=:pushSubscription, pushSubscriptionExpires=:pushSubscriptionExpires, pushSubscriptionCreated=:updatedAt WHERE id=:id")
|
||||
fun updatePushSubscription(id: Long, pushSubscription: String?, pushSubscriptionExpires: Long?, updatedAt: Long = System.currentTimeMillis()/1000)
|
||||
suspend fun updatePushSubscription(id: Long, pushSubscription: String?, pushSubscriptionExpires: Long?, updatedAt: Long = System.currentTimeMillis()/1000)
|
||||
|
||||
@Query("UPDATE collection SET sync=:sync WHERE id=:id")
|
||||
suspend fun updateSync(id: Long, sync: Boolean)
|
||||
@@ -116,4 +125,4 @@ interface CollectionDao {
|
||||
@Delete
|
||||
fun delete(collection: Collection)
|
||||
|
||||
}
|
||||
}
|
||||
@@ -8,7 +8,7 @@ import net.openid.appauth.AuthState
|
||||
|
||||
data class Credentials(
|
||||
val username: String? = null,
|
||||
val password: String? = null,
|
||||
val password: CharArray? = null,
|
||||
|
||||
val certificateAlias: String? = null,
|
||||
|
||||
@@ -32,4 +32,27 @@ data class Credentials(
|
||||
return "Credentials(" + s.joinToString(", ") + ")"
|
||||
}
|
||||
|
||||
|
||||
override fun equals(other: Any?): Boolean {
|
||||
if (this === other) return true
|
||||
if (javaClass != other?.javaClass) return false
|
||||
|
||||
other as Credentials
|
||||
|
||||
if (username != other.username) return false
|
||||
if (!password.contentEquals(other.password)) return false
|
||||
if (certificateAlias != other.certificateAlias) return false
|
||||
if (authState != other.authState) return false
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
override fun hashCode(): Int {
|
||||
var result = username?.hashCode() ?: 0
|
||||
result = 31 * result + (password?.contentHashCode() ?: 0)
|
||||
result = 31 * result + (certificateAlias?.hashCode() ?: 0)
|
||||
result = 31 * result + (authState?.hashCode() ?: 0)
|
||||
return result
|
||||
}
|
||||
|
||||
}
|
||||
@@ -15,7 +15,7 @@ import kotlinx.coroutines.flow.Flow
|
||||
interface HomeSetDao {
|
||||
|
||||
@Query("SELECT * FROM homeset WHERE id=:homesetId")
|
||||
fun getById(homesetId: Long): HomeSet
|
||||
fun getById(homesetId: Long): HomeSet?
|
||||
|
||||
@Query("SELECT * FROM homeset WHERE serviceId=:serviceId AND url=:url")
|
||||
fun getByUrl(serviceId: Long, url: String): HomeSet?
|
||||
@@ -24,7 +24,7 @@ interface HomeSetDao {
|
||||
fun getByService(serviceId: Long): List<HomeSet>
|
||||
|
||||
@Query("SELECT * FROM homeset WHERE serviceId=(SELECT id FROM service WHERE accountName=:accountName AND type=:serviceType) AND privBind ORDER BY displayName, url COLLATE NOCASE")
|
||||
fun getBindableByAccountAndServiceTypeFlow(accountName: String, serviceType: String): Flow<List<HomeSet>>
|
||||
fun getBindableByAccountAndServiceTypeFlow(accountName: String, @ServiceType serviceType: String): Flow<List<HomeSet>>
|
||||
|
||||
@Query("SELECT * FROM homeset WHERE serviceId=:serviceId AND privBind")
|
||||
fun getBindableByServiceFlow(serviceId: Long): Flow<List<HomeSet>>
|
||||
|
||||
@@ -33,7 +33,7 @@ data class Service(
|
||||
@ServiceType
|
||||
val type: String,
|
||||
|
||||
val principal: HttpUrl?
|
||||
val principal: HttpUrl? = null
|
||||
) {
|
||||
|
||||
companion object {
|
||||
|
||||
@@ -14,10 +14,10 @@ import kotlinx.coroutines.flow.Flow
|
||||
interface ServiceDao {
|
||||
|
||||
@Query("SELECT * FROM service WHERE accountName=:accountName AND type=:type")
|
||||
fun getByAccountAndType(accountName: String, type: String): Service?
|
||||
suspend fun getByAccountAndType(accountName: String, @ServiceType type: String): Service?
|
||||
|
||||
@Query("SELECT * FROM service WHERE accountName=:accountName AND type=:type")
|
||||
fun getByAccountAndTypeFlow(accountName: String, type: String): Flow<Service?>
|
||||
fun getByAccountAndTypeFlow(accountName: String, @ServiceType type: String): Flow<Service?>
|
||||
|
||||
@Query("SELECT id FROM service WHERE accountName=:accountName")
|
||||
suspend fun getIdsByAccountAsync(accountName: String): List<Long>
|
||||
@@ -25,6 +25,12 @@ interface ServiceDao {
|
||||
@Query("SELECT * FROM service WHERE id=:id")
|
||||
fun get(id: Long): Service?
|
||||
|
||||
@Query("SELECT * FROM service WHERE id=:id")
|
||||
suspend fun getAsync(id: Long): Service?
|
||||
|
||||
@Query("SELECT * FROM service")
|
||||
suspend fun getAll(): List<Service>
|
||||
|
||||
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||
fun insertOrReplace(service: Service): Long
|
||||
|
||||
|
||||
@@ -14,7 +14,7 @@ import androidx.room.PrimaryKey
|
||||
ForeignKey(childColumns = arrayOf("collectionId"), entity = Collection::class, parentColumns = arrayOf("id"), onDelete = ForeignKey.CASCADE)
|
||||
],
|
||||
indices = [
|
||||
Index("collectionId", "authority", unique = true),
|
||||
Index(value = ["collectionId", "dataType"], unique = true)
|
||||
]
|
||||
)
|
||||
data class SyncStats(
|
||||
@@ -22,7 +22,7 @@ data class SyncStats(
|
||||
val id: Long,
|
||||
|
||||
val collectionId: Long,
|
||||
val authority: String,
|
||||
val dataType: String,
|
||||
|
||||
val lastSync: Long
|
||||
)
|
||||
@@ -14,7 +14,7 @@ import kotlinx.coroutines.flow.Flow
|
||||
interface SyncStatsDao {
|
||||
|
||||
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||
fun insertOrReplace(syncStats: SyncStats)
|
||||
suspend fun insertOrReplace(syncStats: SyncStats)
|
||||
|
||||
@Query("SELECT * FROM syncstats WHERE collectionId=:id")
|
||||
fun getByCollectionIdFlow(id: Long): Flow<List<SyncStats>>
|
||||
|
||||
@@ -32,6 +32,7 @@ import java.time.Instant
|
||||
Index("parentId")
|
||||
]
|
||||
)
|
||||
// If any column name is modified, also change it in [DavDocumentsProvider$queryChildDocuments]
|
||||
data class WebDavDocument(
|
||||
|
||||
@PrimaryKey(autoGenerate = true)
|
||||
@@ -110,7 +111,7 @@ data class WebDavDocument(
|
||||
return bundle
|
||||
}
|
||||
|
||||
fun toHttpUrl(db: AppDatabase): HttpUrl {
|
||||
suspend fun toHttpUrl(db: AppDatabase): HttpUrl {
|
||||
val mount = db.webDavMountDao().getById(mountId)
|
||||
|
||||
val segments = mutableListOf(name)
|
||||
|
||||
@@ -4,12 +4,13 @@
|
||||
|
||||
package at.bitfire.davdroid.db
|
||||
|
||||
import androidx.lifecycle.LiveData
|
||||
import androidx.room.Dao
|
||||
import androidx.room.Delete
|
||||
import androidx.room.Insert
|
||||
import androidx.room.OnConflictStrategy
|
||||
import androidx.room.Query
|
||||
import androidx.room.RawQuery
|
||||
import androidx.room.RoomRawQuery
|
||||
import androidx.room.Transaction
|
||||
import androidx.room.Update
|
||||
|
||||
@@ -22,11 +23,23 @@ interface WebDavDocumentDao {
|
||||
@Query("SELECT * FROM webdav_document WHERE mountId=:mountId AND (parentId=:parentId OR (parentId IS NULL AND :parentId IS NULL)) AND name=:name")
|
||||
fun getByParentAndName(mountId: Long, parentId: Long?, name: String): WebDavDocument?
|
||||
|
||||
@Query("SELECT * FROM webdav_document WHERE parentId=:parentId")
|
||||
fun getChildren(parentId: Long): List<WebDavDocument>
|
||||
@RawQuery
|
||||
fun query(query: RoomRawQuery): List<WebDavDocument>
|
||||
|
||||
@Query("SELECT * FROM webdav_document WHERE parentId IS NULL")
|
||||
fun getRootsLive(): LiveData<List<WebDavDocument>>
|
||||
/**
|
||||
* Gets all the child documents from a given parent id.
|
||||
*
|
||||
* @param parentId The id of the parent document to get the documents from.
|
||||
* @param orderBy If desired, a SQL clause to specify how to order the results.
|
||||
* **The caller is responsible for the correct formatting of this argument. Syntax won't be validated!**
|
||||
*/
|
||||
fun getChildren(parentId: Long, orderBy: String = DEFAULT_ORDER): List<WebDavDocument> {
|
||||
return query(
|
||||
RoomRawQuery("SELECT * FROM webdav_document WHERE parentId = ? ORDER BY $orderBy") {
|
||||
it.bindLong(1, parentId)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||
fun insertOrReplace(document: WebDavDocument): Long
|
||||
@@ -39,7 +52,7 @@ interface WebDavDocumentDao {
|
||||
|
||||
@Update
|
||||
fun update(document: WebDavDocument)
|
||||
|
||||
|
||||
@Delete
|
||||
fun delete(document: WebDavDocument)
|
||||
|
||||
@@ -80,4 +93,15 @@ interface WebDavDocumentDao {
|
||||
return newDoc.copy(id = id)
|
||||
}
|
||||
|
||||
|
||||
companion object {
|
||||
|
||||
/**
|
||||
* Default ORDER BY value to use when content provider doesn't specify a sort order:
|
||||
* _sort by name (directories first)_
|
||||
*/
|
||||
const val DEFAULT_ORDER = "isDirectory DESC, name ASC"
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
@@ -17,16 +17,16 @@ interface WebDavMountDao {
|
||||
suspend fun deleteAsync(mount: WebDavMount)
|
||||
|
||||
@Query("SELECT * FROM webdav_mount ORDER BY name, url")
|
||||
fun getAll(): List<WebDavMount>
|
||||
suspend fun getAll(): List<WebDavMount>
|
||||
|
||||
@Query("SELECT * FROM webdav_mount ORDER BY name, url")
|
||||
fun getAllFlow(): Flow<List<WebDavMount>>
|
||||
|
||||
@Query("SELECT * FROM webdav_mount WHERE id=:id")
|
||||
fun getById(id: Long): WebDavMount
|
||||
suspend fun getById(id: Long): WebDavMount
|
||||
|
||||
@Insert
|
||||
fun insert(mount: WebDavMount): Long
|
||||
suspend fun insert(mount: WebDavMount): Long
|
||||
|
||||
|
||||
// complex queries
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
/*
|
||||
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
|
||||
*/
|
||||
|
||||
package at.bitfire.davdroid.db.migration
|
||||
|
||||
import android.provider.CalendarContract
|
||||
import android.provider.ContactsContract
|
||||
import androidx.room.ProvidedAutoMigrationSpec
|
||||
import androidx.room.RenameColumn
|
||||
import androidx.room.migration.AutoMigrationSpec
|
||||
import androidx.sqlite.db.SupportSQLiteDatabase
|
||||
import at.bitfire.davdroid.sync.SyncDataType
|
||||
import at.bitfire.ical4android.TaskProvider
|
||||
import dagger.Binds
|
||||
import dagger.Module
|
||||
import dagger.hilt.InstallIn
|
||||
import dagger.hilt.components.SingletonComponent
|
||||
import dagger.multibindings.IntoSet
|
||||
import javax.inject.Inject
|
||||
|
||||
/**
|
||||
* Renames syncstats.authority to dataType, and maps values to SyncDataType enum names.
|
||||
*/
|
||||
@ProvidedAutoMigrationSpec
|
||||
@RenameColumn(tableName = "syncstats", fromColumnName = "authority", toColumnName = "dataType")
|
||||
class AutoMigration18 @Inject constructor() : AutoMigrationSpec {
|
||||
|
||||
override fun onPostMigrate(db: SupportSQLiteDatabase) {
|
||||
// Drop old unique index
|
||||
db.execSQL("DROP INDEX IF EXISTS index_syncstats_collectionId_authority")
|
||||
|
||||
val seen = mutableSetOf<Pair<Long, String>>() // (collectionId, dataType)
|
||||
db.query(
|
||||
"SELECT id, collectionId, dataType, lastSync FROM syncstats ORDER BY lastSync DESC"
|
||||
).use { cursor ->
|
||||
val idIndex = cursor.getColumnIndex("id")
|
||||
val collectionIdIndex = cursor.getColumnIndex("collectionId")
|
||||
val authorityIndex = cursor.getColumnIndex("dataType")
|
||||
|
||||
while (cursor.moveToNext()) {
|
||||
val id = cursor.getLong(idIndex)
|
||||
val collectionId = cursor.getLong(collectionIdIndex)
|
||||
val authority = cursor.getString(authorityIndex)
|
||||
|
||||
val dataType = when (authority) {
|
||||
ContactsContract.AUTHORITY -> SyncDataType.CONTACTS.name
|
||||
CalendarContract.AUTHORITY -> SyncDataType.EVENTS.name
|
||||
TaskProvider.ProviderName.JtxBoard.authority,
|
||||
TaskProvider.ProviderName.TasksOrg.authority,
|
||||
TaskProvider.ProviderName.OpenTasks.authority -> SyncDataType.TASKS.name
|
||||
else -> {
|
||||
db.execSQL("DELETE FROM syncstats WHERE id = ?", arrayOf(id))
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
val keyValue = collectionId to dataType
|
||||
if (seen.contains(keyValue)) {
|
||||
db.execSQL("DELETE FROM syncstats WHERE id = ?", arrayOf(id))
|
||||
} else {
|
||||
db.execSQL("UPDATE syncstats SET dataType = ? WHERE id = ?", arrayOf<Any>(dataType, id))
|
||||
seen.add(keyValue)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Create new unique index
|
||||
db.execSQL("CREATE UNIQUE INDEX IF NOT EXISTS index_syncstats_collectionId_dataType ON syncstats (collectionId, dataType)")
|
||||
}
|
||||
|
||||
|
||||
@Module
|
||||
@InstallIn(SingletonComponent::class)
|
||||
abstract class AutoMigrationModule {
|
||||
@Binds
|
||||
@IntoSet
|
||||
abstract fun provide(impl: AutoMigration18): AutoMigrationSpec
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
/*
|
||||
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
|
||||
*/
|
||||
|
||||
package at.bitfire.davdroid.di
|
||||
|
||||
import dagger.Module
|
||||
import dagger.Provides
|
||||
import dagger.hilt.InstallIn
|
||||
import dagger.hilt.components.SingletonComponent
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import javax.inject.Qualifier
|
||||
import javax.inject.Singleton
|
||||
|
||||
@Retention(AnnotationRetention.RUNTIME)
|
||||
@Qualifier
|
||||
annotation class DefaultDispatcher
|
||||
|
||||
@Retention(AnnotationRetention.RUNTIME)
|
||||
@Qualifier
|
||||
annotation class IoDispatcher
|
||||
|
||||
@Retention(AnnotationRetention.RUNTIME)
|
||||
@Qualifier
|
||||
annotation class MainDispatcher
|
||||
|
||||
@Retention(AnnotationRetention.RUNTIME)
|
||||
@Qualifier
|
||||
annotation class SyncDispatcher
|
||||
|
||||
@Module
|
||||
@InstallIn(SingletonComponent::class)
|
||||
class CoroutineDispatchersModule {
|
||||
|
||||
@Provides
|
||||
@DefaultDispatcher
|
||||
fun defaultDispatcher(): CoroutineDispatcher = Dispatchers.Default
|
||||
|
||||
@Provides
|
||||
@IoDispatcher
|
||||
fun ioDispatcher(): CoroutineDispatcher = Dispatchers.IO
|
||||
|
||||
@Provides
|
||||
@MainDispatcher
|
||||
fun mainDispatcher(): CoroutineDispatcher = Dispatchers.Main
|
||||
|
||||
/**
|
||||
* A dispatcher for background sync operations. They're not run on [ioDispatcher] because there can
|
||||
* be many long-blocking operations at the same time which shouldn't never block other I/O operations
|
||||
* like database access for the UI.
|
||||
*
|
||||
* It uses the I/O dispatcher and limits the number of parallel operations to the number of available processors.
|
||||
*/
|
||||
@Provides
|
||||
@SyncDispatcher
|
||||
@Singleton
|
||||
fun syncDispatcher(): CoroutineDispatcher =
|
||||
Dispatchers.IO.limitedParallelism(Runtime.getRuntime().availableProcessors())
|
||||
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
/*
|
||||
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
|
||||
*/
|
||||
|
||||
package at.bitfire.davdroid.di
|
||||
|
||||
import dagger.Module
|
||||
import dagger.Provides
|
||||
import dagger.hilt.InstallIn
|
||||
import dagger.hilt.components.SingletonComponent
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import javax.inject.Qualifier
|
||||
import javax.inject.Singleton
|
||||
|
||||
@Retention(AnnotationRetention.RUNTIME)
|
||||
@Qualifier
|
||||
annotation class ApplicationScope
|
||||
|
||||
@Module
|
||||
@InstallIn(SingletonComponent::class)
|
||||
class CoroutineScopesModule {
|
||||
|
||||
@Singleton
|
||||
@Provides
|
||||
@ApplicationScope
|
||||
fun applicationScope(@MainDispatcher mainDispatcher: CoroutineDispatcher): CoroutineScope = CoroutineScope(SupervisorJob() + mainDispatcher)
|
||||
|
||||
}
|
||||
@@ -2,7 +2,7 @@
|
||||
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
|
||||
*/
|
||||
|
||||
package at.bitfire.davdroid.log
|
||||
package at.bitfire.davdroid.di
|
||||
|
||||
import dagger.Module
|
||||
import dagger.Provides
|
||||
@@ -10,6 +10,7 @@ import android.content.Intent
|
||||
import android.os.Process
|
||||
import androidx.core.app.NotificationCompat
|
||||
import androidx.core.app.NotificationManagerCompat
|
||||
import androidx.core.app.TaskStackBuilder
|
||||
import at.bitfire.davdroid.R
|
||||
import at.bitfire.davdroid.ui.AppSettingsActivity
|
||||
import at.bitfire.davdroid.ui.DebugInfoActivity
|
||||
@@ -138,8 +139,9 @@ class LogFileHandler @Inject constructor(
|
||||
val shareIntent = DebugInfoActivity.IntentBuilder(context)
|
||||
.newTask()
|
||||
.share()
|
||||
val pendingShare =
|
||||
PendingIntent.getActivity(context, 0, shareIntent, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE)
|
||||
val pendingShare = TaskStackBuilder.create(context)
|
||||
.addNextIntentWithParentStack(shareIntent)
|
||||
.getPendingIntent(0, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE)
|
||||
builder.addAction(
|
||||
NotificationCompat.Action.Builder(
|
||||
R.drawable.ic_share,
|
||||
@@ -151,8 +153,9 @@ class LogFileHandler @Inject constructor(
|
||||
// add action to disable verbose logging
|
||||
val prefIntent = Intent(context, AppSettingsActivity::class.java)
|
||||
prefIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||
val pendingPref =
|
||||
PendingIntent.getActivity(context, 0, prefIntent, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE)
|
||||
val pendingPref = TaskStackBuilder.create(context)
|
||||
.addNextIntentWithParentStack(prefIntent)
|
||||
.getPendingIntent(0, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE)
|
||||
builder.addAction(
|
||||
NotificationCompat.Action.Builder(
|
||||
R.drawable.ic_settings,
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
/*
|
||||
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
|
||||
*/
|
||||
|
||||
package at.bitfire.davdroid.network
|
||||
|
||||
import android.content.Context
|
||||
import android.security.KeyChain
|
||||
import dagger.assisted.Assisted
|
||||
import dagger.assisted.AssistedFactory
|
||||
import dagger.assisted.AssistedInject
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import java.net.Socket
|
||||
import java.security.Principal
|
||||
import javax.net.ssl.X509ExtendedKeyManager
|
||||
|
||||
/**
|
||||
* KeyManager that provides a client certificate and private key from the Android KeyChain.
|
||||
*
|
||||
* @throws IllegalArgumentException if the alias doesn't exist or is not accessible
|
||||
*/
|
||||
class ClientCertKeyManager @AssistedInject constructor(
|
||||
@Assisted private val alias: String,
|
||||
@ApplicationContext private val context: Context
|
||||
): X509ExtendedKeyManager() {
|
||||
|
||||
@AssistedFactory
|
||||
interface Factory {
|
||||
fun create(alias: String): ClientCertKeyManager
|
||||
}
|
||||
|
||||
val certs = KeyChain.getCertificateChain(context, alias) ?: throw IllegalArgumentException("Alias doesn't exist or not accessible: $alias")
|
||||
val key = KeyChain.getPrivateKey(context, alias) ?: throw IllegalArgumentException("Alias doesn't exist or not accessible: $alias")
|
||||
|
||||
override fun getServerAliases(p0: String?, p1: Array<out Principal>?): Array<String>? = null
|
||||
override fun chooseServerAlias(p0: String?, p1: Array<out Principal>?, p2: Socket?) = null
|
||||
|
||||
override fun getClientAliases(p0: String?, p1: Array<out Principal>?) = arrayOf(alias)
|
||||
override fun chooseClientAlias(p0: Array<out String>?, p1: Array<out Principal>?, p2: Socket?) = alias
|
||||
|
||||
override fun getCertificateChain(forAlias: String?) =
|
||||
certs.takeIf { forAlias == alias }
|
||||
|
||||
override fun getPrivateKey(forAlias: String?) =
|
||||
key.takeIf { forAlias == alias }
|
||||
|
||||
}
|
||||
@@ -22,6 +22,7 @@ import java.util.LinkedList
|
||||
import java.util.TreeMap
|
||||
import java.util.logging.Logger
|
||||
import javax.inject.Inject
|
||||
import kotlin.random.Random
|
||||
|
||||
/**
|
||||
* Allows to resolve SRV/TXT records. Chooses the correct resolver, DNS servers etc.
|
||||
@@ -102,7 +103,14 @@ class DnsRecordResolver @Inject constructor(
|
||||
|
||||
// record selection
|
||||
|
||||
fun bestSRVRecord(records: Array<out Record>): SRVRecord? {
|
||||
/**
|
||||
* Selects the best SRV record from a list of records, based on algorithm from RFC 2782.
|
||||
*
|
||||
* @param records the records to choose from
|
||||
* @param randomGenerator a random number generator to use for random selection
|
||||
* @return the best SRV record, or `null` if no SRV record is available
|
||||
*/
|
||||
fun bestSRVRecord(records: Array<out Record>, randomGenerator: Random = Random.Default): SRVRecord? {
|
||||
val srvRecords = records.filterIsInstance<SRVRecord>()
|
||||
if (srvRecords.size <= 1)
|
||||
return srvRecords.firstOrNull()
|
||||
@@ -141,7 +149,7 @@ class DnsRecordResolver @Inject constructor(
|
||||
map[runningWeight] = record
|
||||
}
|
||||
|
||||
val selector = (0..runningWeight).random()
|
||||
val selector = (0..runningWeight).random(randomGenerator)
|
||||
return map.ceilingEntry(selector)!!.value
|
||||
}
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
package at.bitfire.davdroid.network
|
||||
|
||||
import android.net.Uri
|
||||
import androidx.core.net.toUri
|
||||
import at.bitfire.davdroid.BuildConfig
|
||||
import at.bitfire.davdroid.db.Credentials
|
||||
import kotlinx.coroutines.CompletableDeferred
|
||||
@@ -49,18 +49,18 @@ class GoogleLogin(
|
||||
URI("https", "apidata.googleusercontent.com", "/caldav/v2/$googleAccount/user", null)
|
||||
|
||||
private val serviceConfig = AuthorizationServiceConfiguration(
|
||||
Uri.parse("https://accounts.google.com/o/oauth2/v2/auth"),
|
||||
Uri.parse("https://oauth2.googleapis.com/token")
|
||||
"https://accounts.google.com/o/oauth2/v2/auth".toUri(),
|
||||
"https://oauth2.googleapis.com/token".toUri()
|
||||
)
|
||||
|
||||
}
|
||||
|
||||
fun signIn(email: String, customClientId: String?, locale: String?): AuthorizationRequest {
|
||||
val builder = AuthorizationRequest.Builder(
|
||||
GoogleLogin.serviceConfig,
|
||||
serviceConfig,
|
||||
customClientId ?: CLIENT_ID,
|
||||
ResponseTypeValues.CODE,
|
||||
Uri.parse(BuildConfig.APPLICATION_ID + ":/oauth2/redirect")
|
||||
(BuildConfig.APPLICATION_ID + ":/oauth2/redirect").toUri()
|
||||
)
|
||||
return builder
|
||||
.setScopes(*SCOPES)
|
||||
|
||||
@@ -4,157 +4,286 @@
|
||||
|
||||
package at.bitfire.davdroid.network
|
||||
|
||||
import android.accounts.Account
|
||||
import android.content.Context
|
||||
import android.os.Build
|
||||
import android.security.KeyChain
|
||||
import androidx.annotation.WorkerThread
|
||||
import at.bitfire.cert4android.CustomCertManager
|
||||
import at.bitfire.dav4jvm.BasicDigestAuthHandler
|
||||
import at.bitfire.dav4jvm.UrlUtils
|
||||
import at.bitfire.davdroid.BuildConfig
|
||||
import at.bitfire.davdroid.db.Credentials
|
||||
import at.bitfire.davdroid.di.IoDispatcher
|
||||
import at.bitfire.davdroid.settings.AccountSettings
|
||||
import at.bitfire.davdroid.settings.Settings
|
||||
import at.bitfire.davdroid.settings.SettingsManager
|
||||
import dagger.assisted.Assisted
|
||||
import dagger.assisted.AssistedFactory
|
||||
import dagger.assisted.AssistedInject
|
||||
import dagger.hilt.EntryPoint
|
||||
import dagger.hilt.InstallIn
|
||||
import dagger.hilt.android.EntryPointAccessors
|
||||
import dagger.hilt.components.SingletonComponent
|
||||
import java.io.File
|
||||
import java.net.InetSocketAddress
|
||||
import java.net.Proxy
|
||||
import java.net.Socket
|
||||
import java.security.KeyStore
|
||||
import java.security.Principal
|
||||
import java.util.Locale
|
||||
import java.util.concurrent.TimeUnit
|
||||
import java.util.logging.Level
|
||||
import java.util.logging.Logger
|
||||
import javax.net.ssl.KeyManager
|
||||
import javax.net.ssl.SSLContext
|
||||
import javax.net.ssl.TrustManagerFactory
|
||||
import javax.net.ssl.X509ExtendedKeyManager
|
||||
import javax.net.ssl.X509TrustManager
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import at.bitfire.davdroid.ui.ForegroundTracker
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
import kotlinx.coroutines.withContext
|
||||
import net.openid.appauth.AuthState
|
||||
import net.openid.appauth.AuthorizationService
|
||||
import okhttp3.Authenticator
|
||||
import okhttp3.Cache
|
||||
import okhttp3.ConnectionSpec
|
||||
import okhttp3.CookieJar
|
||||
import okhttp3.Interceptor
|
||||
import okhttp3.OkHttp
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Protocol
|
||||
import okhttp3.Response
|
||||
import okhttp3.brotli.BrotliInterceptor
|
||||
import okhttp3.internal.tls.OkHostnameVerifier
|
||||
import okhttp3.logging.HttpLoggingInterceptor
|
||||
import java.io.File
|
||||
import java.net.InetSocketAddress
|
||||
import java.net.Proxy
|
||||
import java.util.concurrent.TimeUnit
|
||||
import java.util.logging.Level
|
||||
import java.util.logging.Logger
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Provider
|
||||
import javax.net.ssl.KeyManager
|
||||
import javax.net.ssl.SSLContext
|
||||
|
||||
class HttpClient @AssistedInject constructor(
|
||||
@Assisted val okHttpClient: OkHttpClient,
|
||||
@Assisted private var authService: AuthorizationService? = null,
|
||||
val settingsManager: SettingsManager
|
||||
class HttpClient(
|
||||
val okHttpClient: OkHttpClient,
|
||||
private val authorizationService: AuthorizationService? = null
|
||||
): AutoCloseable {
|
||||
|
||||
companion object {
|
||||
/** max. size of disk cache (10 MB) */
|
||||
const val DISK_CACHE_MAX_SIZE: Long = 10*1024*1024
|
||||
override fun close() {
|
||||
authorizationService?.dispose()
|
||||
okHttpClient.cache?.close()
|
||||
}
|
||||
|
||||
/** Base Builder to build all clients from. Use rarely; [OkHttpClient]s should
|
||||
* be reused as much as possible. */
|
||||
fun baseBuilder() =
|
||||
OkHttpClient.Builder()
|
||||
|
||||
// builder
|
||||
|
||||
/**
|
||||
* Builder for the [HttpClient].
|
||||
*
|
||||
* **Attention:** If the builder is injected, it shouldn't be used from multiple locations to generate different clients because then
|
||||
* there's only one [Builder] object and setting properties from one location would influence the others.
|
||||
*
|
||||
* To generate multiple clients, inject and use `Provider<HttpClient.Builder>` instead.
|
||||
*/
|
||||
class Builder @Inject constructor(
|
||||
private val accountSettingsFactory: AccountSettings.Factory,
|
||||
private val authorizationServiceProvider: Provider<AuthorizationService>,
|
||||
@ApplicationContext private val context: Context,
|
||||
defaultLogger: Logger,
|
||||
@IoDispatcher private val ioDispatcher: CoroutineDispatcher,
|
||||
private val keyManagerFactory: ClientCertKeyManager.Factory,
|
||||
private val settingsManager: SettingsManager
|
||||
) {
|
||||
|
||||
// property setters/getters
|
||||
|
||||
private var logger: Logger = defaultLogger
|
||||
fun setLogger(logger: Logger): Builder {
|
||||
this.logger = logger
|
||||
return this
|
||||
}
|
||||
|
||||
private var loggerInterceptorLevel: HttpLoggingInterceptor.Level = HttpLoggingInterceptor.Level.BODY
|
||||
fun loggerInterceptorLevel(level: HttpLoggingInterceptor.Level): Builder {
|
||||
loggerInterceptorLevel = level
|
||||
return this
|
||||
}
|
||||
|
||||
// default cookie store for non-persistent cookies (some services like Horde use cookies for session tracking)
|
||||
private var cookieStore: CookieJar = MemoryCookieStore()
|
||||
fun setCookieStore(cookieStore: CookieJar): Builder {
|
||||
this.cookieStore = cookieStore
|
||||
return this
|
||||
}
|
||||
|
||||
private var authenticationInterceptor: Interceptor? = null
|
||||
private var authenticator: Authenticator? = null
|
||||
private var authorizationService: AuthorizationService? = null
|
||||
private var certificateAlias: String? = null
|
||||
fun authenticate(host: String?, credentials: Credentials, authStateCallback: BearerAuthInterceptor.AuthStateUpdateCallback? = null): Builder {
|
||||
if (credentials.authState != null) {
|
||||
// OAuth
|
||||
val authService = authorizationServiceProvider.get()
|
||||
authenticationInterceptor = BearerAuthInterceptor.fromAuthState(authService, credentials.authState, authStateCallback)
|
||||
authorizationService = authService
|
||||
|
||||
} else if (credentials.username != null && credentials.password != null) {
|
||||
// basic/digest auth
|
||||
val authHandler = BasicDigestAuthHandler(
|
||||
domain = UrlUtils.hostToDomain(host),
|
||||
username = credentials.username,
|
||||
password = credentials.password,
|
||||
insecurePreemptive = true
|
||||
)
|
||||
authenticationInterceptor = authHandler
|
||||
authenticator = authHandler
|
||||
}
|
||||
|
||||
// client certificate
|
||||
if (credentials.certificateAlias != null)
|
||||
certificateAlias = credentials.certificateAlias
|
||||
|
||||
return this
|
||||
}
|
||||
|
||||
private var followRedirects = false
|
||||
fun followRedirects(follow: Boolean): Builder {
|
||||
followRedirects = follow
|
||||
return this
|
||||
}
|
||||
|
||||
private var cache: Cache? = null
|
||||
@Suppress("unused")
|
||||
fun withDiskCache(maxSize: Long = 10*1024*1024): Builder {
|
||||
for (dir in arrayOf(context.externalCacheDir, context.cacheDir).filterNotNull()) {
|
||||
if (dir.exists() && dir.canWrite()) {
|
||||
val cacheDir = File(dir, "HttpClient")
|
||||
cacheDir.mkdir()
|
||||
logger.fine("Using disk cache: $cacheDir")
|
||||
cache = Cache(cacheDir, maxSize)
|
||||
break
|
||||
}
|
||||
}
|
||||
return this
|
||||
}
|
||||
|
||||
|
||||
// convenience builders from other classes
|
||||
|
||||
/**
|
||||
* Takes authentication (basic/digest or OAuth and client certificate) from a given account.
|
||||
*
|
||||
* **Must not be run on main thread, because it creates [AccountSettings]!** Use [fromAccountAsync] if possible.
|
||||
*
|
||||
* @param account the account to take authentication from
|
||||
* @param onlyHost if set: only authenticate for this host name
|
||||
*
|
||||
* @throws at.bitfire.davdroid.sync.account.InvalidAccountException when the account doesn't exist
|
||||
*/
|
||||
@WorkerThread
|
||||
fun fromAccount(account: Account, onlyHost: String? = null): Builder {
|
||||
val accountSettings = accountSettingsFactory.create(account)
|
||||
authenticate(
|
||||
host = onlyHost,
|
||||
credentials = accountSettings.credentials(),
|
||||
authStateCallback = { authState: AuthState ->
|
||||
accountSettings.credentials(Credentials(authState = authState))
|
||||
}
|
||||
)
|
||||
return this
|
||||
}
|
||||
|
||||
/**
|
||||
* Same as [fromAccount], but can be called on any thread.
|
||||
*
|
||||
* @throws at.bitfire.davdroid.sync.account.InvalidAccountException when the account doesn't exist
|
||||
*/
|
||||
suspend fun fromAccountAsync(account: Account, onlyHost: String? = null): Builder = withContext(ioDispatcher) {
|
||||
fromAccount(account, onlyHost)
|
||||
}
|
||||
|
||||
|
||||
// actual builder
|
||||
|
||||
fun build(): HttpClient {
|
||||
val okBuilder = OkHttpClient.Builder()
|
||||
// Set timeouts. According to [AbstractThreadedSyncAdapter], when there is no network
|
||||
// traffic within a minute, a sync will be cancelled.
|
||||
.connectTimeout(15, TimeUnit.SECONDS)
|
||||
.writeTimeout(30, TimeUnit.SECONDS)
|
||||
.readTimeout(120, TimeUnit.SECONDS)
|
||||
.pingInterval(
|
||||
45,
|
||||
TimeUnit.SECONDS
|
||||
) // avoid cancellation because of missing traffic; only works for HTTP/2
|
||||
.pingInterval(45, TimeUnit.SECONDS) // avoid cancellation because of missing traffic; only works for HTTP/2
|
||||
|
||||
// keep TLS 1.0 and 1.1 for now; remove when major browsers have dropped it (probably 2020)
|
||||
.connectionSpecs(
|
||||
listOf(
|
||||
ConnectionSpec.CLEARTEXT,
|
||||
ConnectionSpec.COMPATIBLE_TLS
|
||||
)
|
||||
)
|
||||
|
||||
// don't allow redirects by default, because it would break PROPFIND handling
|
||||
.followRedirects(false)
|
||||
// don't allow redirects by default because it would break PROPFIND handling
|
||||
.followRedirects(followRedirects)
|
||||
|
||||
// add User-Agent to every request
|
||||
.addInterceptor(UserAgentInterceptor)
|
||||
}
|
||||
|
||||
@AssistedFactory
|
||||
interface Factory {
|
||||
fun create(okHttpClient: OkHttpClient, authService: AuthorizationService?): HttpClient
|
||||
}
|
||||
// connection-private cookie store
|
||||
.cookieJar(cookieStore)
|
||||
|
||||
// allow cleartext and TLS 1.2+
|
||||
.connectionSpecs(listOf(
|
||||
ConnectionSpec.CLEARTEXT,
|
||||
ConnectionSpec.MODERN_TLS
|
||||
))
|
||||
|
||||
override fun close() {
|
||||
authService?.dispose()
|
||||
okHttpClient.cache?.close()
|
||||
}
|
||||
// offer Brotli and gzip compression (can be disabled per request with `Accept-Encoding: identity`)
|
||||
.addInterceptor(BrotliInterceptor)
|
||||
|
||||
// add cache, if requested
|
||||
.cache(cache)
|
||||
|
||||
class Builder(
|
||||
val context: Context,
|
||||
accountSettings: AccountSettings? = null,
|
||||
val logger: Logger = Logger.getGlobal(),
|
||||
val loggerLevel: HttpLoggingInterceptor.Level = HttpLoggingInterceptor.Level.BODY
|
||||
) {
|
||||
// app-wide custom proxy support
|
||||
buildProxy(okBuilder)
|
||||
|
||||
@EntryPoint
|
||||
@InstallIn(SingletonComponent::class)
|
||||
interface HttpClientBuilderEntryPoint {
|
||||
fun authorizationService(): AuthorizationService
|
||||
fun httpClientFactory(): Factory
|
||||
fun settingsManager(): SettingsManager
|
||||
}
|
||||
// add authentication
|
||||
buildAuthentication(okBuilder)
|
||||
|
||||
private val entryPoint = EntryPointAccessors.fromApplication<HttpClientBuilderEntryPoint>(context)
|
||||
|
||||
fun interface CertManagerProducer {
|
||||
fun certManager(): CustomCertManager
|
||||
}
|
||||
|
||||
private var appInForeground: MutableStateFlow<Boolean>? =
|
||||
MutableStateFlow(false)
|
||||
private var authService: AuthorizationService? = null
|
||||
private var certManagerProducer: CertManagerProducer? = null
|
||||
private var certificateAlias: String? = null
|
||||
private var offerCompression: Boolean = false
|
||||
|
||||
// default cookie store for non-persistent cookies (some services like Horde use cookies for session tracking)
|
||||
private var cookieStore: CookieJar? = MemoryCookieStore()
|
||||
|
||||
private val orig = baseBuilder()
|
||||
|
||||
init {
|
||||
// add network logging, if requested
|
||||
if (logger.isLoggable(Level.FINEST)) {
|
||||
val loggingInterceptor = HttpLoggingInterceptor { message -> logger.finest(message) }
|
||||
loggingInterceptor.level = loggerLevel
|
||||
orig.addNetworkInterceptor(loggingInterceptor)
|
||||
loggingInterceptor.level = loggerInterceptorLevel
|
||||
okBuilder.addNetworkInterceptor(loggingInterceptor)
|
||||
}
|
||||
|
||||
val settings = entryPoint.settingsManager()
|
||||
return HttpClient(
|
||||
okHttpClient = okBuilder.build(),
|
||||
authorizationService = authorizationService
|
||||
)
|
||||
}
|
||||
|
||||
// custom proxy support
|
||||
private fun buildAuthentication(okBuilder: OkHttpClient.Builder) {
|
||||
// basic/digest auth and OAuth
|
||||
authenticationInterceptor?.let { okBuilder.addInterceptor(it) }
|
||||
authenticator?.let { okBuilder.authenticator(it) }
|
||||
|
||||
// client certificate
|
||||
val keyManager: KeyManager? = certificateAlias?.let { alias ->
|
||||
try {
|
||||
val manager = keyManagerFactory.create(alias)
|
||||
logger.fine("Using certificate $alias for authentication")
|
||||
|
||||
// HTTP/2 doesn't support client certificates (yet)
|
||||
// see https://tools.ietf.org/html/draft-ietf-httpbis-http2-secondary-certs-04
|
||||
okBuilder.protocols(listOf(Protocol.HTTP_1_1))
|
||||
|
||||
manager
|
||||
} catch (e: IllegalArgumentException) {
|
||||
logger.log(Level.SEVERE, "Couldn't create KeyManager for certificate $alias", e)
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
// cert4android integration
|
||||
val certManager = CustomCertManager(
|
||||
context = context,
|
||||
trustSystemCerts = !settingsManager.getBoolean(Settings.DISTRUST_SYSTEM_CERTIFICATES),
|
||||
appInForeground = if (/* davx5-ose */ true)
|
||||
ForegroundTracker.inForeground // interactive mode
|
||||
else
|
||||
null // non-interactive mode
|
||||
)
|
||||
|
||||
val sslContext = SSLContext.getInstance("TLS")
|
||||
sslContext.init(
|
||||
/* km = */ if (keyManager != null) arrayOf(keyManager) else null,
|
||||
/* tm = */ arrayOf(certManager),
|
||||
/* random = */ null
|
||||
)
|
||||
okBuilder
|
||||
.sslSocketFactory(sslContext.socketFactory, certManager)
|
||||
.hostnameVerifier(certManager.HostnameVerifier(OkHostnameVerifier))
|
||||
}
|
||||
|
||||
private fun buildProxy(okBuilder: OkHttpClient.Builder) {
|
||||
try {
|
||||
val proxyTypeValue = settings.getInt(Settings.PROXY_TYPE)
|
||||
val proxyTypeValue = settingsManager.getInt(Settings.PROXY_TYPE)
|
||||
if (proxyTypeValue != Settings.PROXY_TYPE_SYSTEM) {
|
||||
// we set our own proxy
|
||||
val address by lazy { // lazy because not required for PROXY_TYPE_NONE
|
||||
InetSocketAddress(
|
||||
settings.getString(Settings.PROXY_HOST),
|
||||
settings.getInt(Settings.PROXY_PORT)
|
||||
settingsManager.getString(Settings.PROXY_HOST),
|
||||
settingsManager.getInt(Settings.PROXY_PORT)
|
||||
)
|
||||
}
|
||||
val proxy =
|
||||
@@ -164,173 +293,12 @@ class HttpClient @AssistedInject constructor(
|
||||
Settings.PROXY_TYPE_SOCKS -> Proxy(Proxy.Type.SOCKS, address)
|
||||
else -> throw IllegalArgumentException("Invalid proxy type")
|
||||
}
|
||||
orig.proxy(proxy)
|
||||
okBuilder.proxy(proxy)
|
||||
logger.log(Level.INFO, "Using proxy setting", proxy)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
logger.log(Level.SEVERE, "Can't set proxy, ignoring", e)
|
||||
}
|
||||
|
||||
customCertManager {
|
||||
// by default, use a CustomCertManager that respects the "distrust system certificates" setting
|
||||
val trustSystemCerts = !settings.getBoolean(Settings.DISTRUST_SYSTEM_CERTIFICATES)
|
||||
CustomCertManager(context, trustSystemCerts, appInForeground)
|
||||
}
|
||||
|
||||
// use account settings for authentication and cookies
|
||||
if (accountSettings != null)
|
||||
addAuthentication(null, accountSettings.credentials(), authStateCallback = { authState: AuthState ->
|
||||
accountSettings.credentials(Credentials(authState = authState))
|
||||
})
|
||||
}
|
||||
|
||||
constructor(context: Context, host: String?, credentials: Credentials?) : this(context) {
|
||||
if (credentials != null)
|
||||
addAuthentication(host, credentials)
|
||||
}
|
||||
|
||||
fun addAuthentication(host: String?, credentials: Credentials, insecurePreemptive: Boolean = false, authStateCallback: BearerAuthInterceptor.AuthStateUpdateCallback? = null): Builder {
|
||||
if (credentials.username != null && credentials.password != null) {
|
||||
val authHandler = BasicDigestAuthHandler(UrlUtils.hostToDomain(host), credentials.username, credentials.password, insecurePreemptive)
|
||||
orig.addNetworkInterceptor(authHandler)
|
||||
.authenticator(authHandler)
|
||||
}
|
||||
|
||||
if (credentials.certificateAlias != null)
|
||||
certificateAlias = credentials.certificateAlias
|
||||
|
||||
credentials.authState?.let { authState ->
|
||||
val newAuthService = entryPoint.authorizationService()
|
||||
authService = newAuthService
|
||||
BearerAuthInterceptor.fromAuthState(newAuthService, authState, authStateCallback)?.let { bearerAuthInterceptor ->
|
||||
orig.addNetworkInterceptor(bearerAuthInterceptor)
|
||||
}
|
||||
}
|
||||
return this
|
||||
}
|
||||
|
||||
fun allowCompression(allow: Boolean): Builder {
|
||||
offerCompression = allow
|
||||
return this
|
||||
}
|
||||
|
||||
fun cookieStore(store: CookieJar?): Builder {
|
||||
cookieStore = store
|
||||
return this
|
||||
}
|
||||
|
||||
fun followRedirects(follow: Boolean): Builder {
|
||||
orig.followRedirects(follow)
|
||||
return this
|
||||
}
|
||||
|
||||
fun customCertManager(producer: CertManagerProducer) {
|
||||
certManagerProducer = producer
|
||||
}
|
||||
fun setForeground(foreground: Boolean): Builder {
|
||||
appInForeground?.value = foreground
|
||||
return this
|
||||
}
|
||||
|
||||
fun withDiskCache(): Builder {
|
||||
for (dir in arrayOf(context.externalCacheDir, context.cacheDir).filterNotNull()) {
|
||||
if (dir.exists() && dir.canWrite()) {
|
||||
val cacheDir = File(dir, "HttpClient")
|
||||
cacheDir.mkdir()
|
||||
logger.fine("Using disk cache: $cacheDir")
|
||||
orig.cache(Cache(cacheDir, DISK_CACHE_MAX_SIZE))
|
||||
break
|
||||
}
|
||||
}
|
||||
return this
|
||||
}
|
||||
|
||||
fun build(): HttpClient {
|
||||
cookieStore?.let {
|
||||
orig.cookieJar(it)
|
||||
}
|
||||
|
||||
if (offerCompression)
|
||||
// offer Brotli and gzip compression
|
||||
orig.addInterceptor(BrotliInterceptor)
|
||||
|
||||
var keyManager: KeyManager? = null
|
||||
certificateAlias?.let { alias ->
|
||||
// get provider certificate and private key
|
||||
val certs = KeyChain.getCertificateChain(context, alias) ?: return@let
|
||||
val key = KeyChain.getPrivateKey(context, alias) ?: return@let
|
||||
logger?.fine("Using provider certificate $alias for authentication (chain length: ${certs.size})")
|
||||
|
||||
// create KeyManager
|
||||
keyManager = object : X509ExtendedKeyManager() {
|
||||
override fun getServerAliases(p0: String?, p1: Array<out Principal>?): Array<String>? = null
|
||||
override fun chooseServerAlias(p0: String?, p1: Array<out Principal>?, p2: Socket?) = null
|
||||
|
||||
override fun getClientAliases(p0: String?, p1: Array<out Principal>?) =
|
||||
arrayOf(alias)
|
||||
|
||||
override fun chooseClientAlias(p0: Array<out String>?, p1: Array<out Principal>?, p2: Socket?) =
|
||||
alias
|
||||
|
||||
override fun getCertificateChain(forAlias: String?) =
|
||||
certs.takeIf { forAlias == alias }
|
||||
|
||||
override fun getPrivateKey(forAlias: String?) =
|
||||
key.takeIf { forAlias == alias }
|
||||
}
|
||||
|
||||
// HTTP/2 doesn't support client certificates (yet)
|
||||
// see https://tools.ietf.org/html/draft-ietf-httpbis-http2-secondary-certs-04
|
||||
orig.protocols(listOf(Protocol.HTTP_1_1))
|
||||
}
|
||||
|
||||
if (certManagerProducer != null || keyManager != null) {
|
||||
val manager = certManagerProducer?.certManager()
|
||||
|
||||
val trustManager = manager ?: /* fall back to system default trust manager */
|
||||
TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm())
|
||||
.let { factory ->
|
||||
factory.init(null as KeyStore?)
|
||||
factory.trustManagers.first() as X509TrustManager
|
||||
}
|
||||
|
||||
val hostnameVerifier =
|
||||
if (manager != null)
|
||||
manager.HostnameVerifier(OkHostnameVerifier)
|
||||
else
|
||||
OkHostnameVerifier
|
||||
|
||||
val sslContext = SSLContext.getInstance("TLS")
|
||||
sslContext.init(
|
||||
if (keyManager != null) arrayOf(keyManager) else null,
|
||||
arrayOf(trustManager),
|
||||
null)
|
||||
orig.sslSocketFactory(sslContext.socketFactory, trustManager)
|
||||
orig.hostnameVerifier(hostnameVerifier)
|
||||
}
|
||||
|
||||
return entryPoint.httpClientFactory().create(orig.build(), authService = authService)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
object UserAgentInterceptor: Interceptor {
|
||||
|
||||
val userAgent = "DAVx5/${BuildConfig.VERSION_NAME} (dav4jvm; " +
|
||||
"okhttp/${OkHttp.VERSION}) Android/${Build.VERSION.RELEASE}"
|
||||
|
||||
init {
|
||||
Logger.getGlobal().info("Will set User-Agent: $userAgent")
|
||||
}
|
||||
|
||||
override fun intercept(chain: Interceptor.Chain): Response {
|
||||
val locale = Locale.getDefault()
|
||||
val request = chain.request().newBuilder()
|
||||
.header("User-Agent", userAgent)
|
||||
.header("Accept-Language", "${locale.language}-${locale.country}, ${locale.language};q=0.7, *;q=0.5")
|
||||
.build()
|
||||
return chain.proceed(request)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -4,7 +4,6 @@
|
||||
|
||||
package at.bitfire.davdroid.network
|
||||
|
||||
import android.content.Context
|
||||
import at.bitfire.dav4jvm.exception.DavException
|
||||
import at.bitfire.dav4jvm.exception.HttpException
|
||||
import at.bitfire.davdroid.db.Credentials
|
||||
@@ -24,14 +23,15 @@ import okhttp3.RequestBody.Companion.toRequestBody
|
||||
import org.json.JSONObject
|
||||
import java.net.HttpURLConnection
|
||||
import java.net.URI
|
||||
import javax.inject.Inject
|
||||
|
||||
/**
|
||||
* Implements Nextcloud Login Flow v2.
|
||||
*
|
||||
* See https://docs.nextcloud.com/server/latest/developer_manual/client_apis/LoginFlow/index.html#login-flow-v2
|
||||
*/
|
||||
class NextcloudLoginFlow(
|
||||
context: Context
|
||||
class NextcloudLoginFlow @Inject constructor(
|
||||
httpClientBuilder: HttpClient.Builder
|
||||
): AutoCloseable {
|
||||
|
||||
companion object {
|
||||
@@ -42,8 +42,7 @@ class NextcloudLoginFlow(
|
||||
const val DAV_PATH = "remote.php/dav"
|
||||
}
|
||||
|
||||
val httpClient = HttpClient.Builder(context)
|
||||
.setForeground(true)
|
||||
val httpClient = httpClientBuilder
|
||||
.build()
|
||||
|
||||
override fun close() {
|
||||
@@ -107,7 +106,7 @@ class NextcloudLoginFlow(
|
||||
baseUri = URI(serverUrl).resolve(DAV_PATH),
|
||||
credentials = Credentials(
|
||||
username = json.getString("loginName"),
|
||||
password = json.getString("appPassword")
|
||||
password = json.getString("appPassword").toCharArray()
|
||||
),
|
||||
suggestedGroupMethod = GroupMethod.CATEGORIES
|
||||
)
|
||||
|
||||
@@ -26,8 +26,9 @@ object OAuthModule {
|
||||
.setConnectionBuilder { uri ->
|
||||
val url = URL(uri.toString())
|
||||
(url.openConnection() as HttpURLConnection).apply {
|
||||
setRequestProperty("User-Agent", HttpClient.UserAgentInterceptor.userAgent)
|
||||
setRequestProperty("User-Agent", UserAgentInterceptor.userAgent)
|
||||
}
|
||||
}.build()
|
||||
)
|
||||
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
/*
|
||||
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
|
||||
*/
|
||||
|
||||
package at.bitfire.davdroid.network
|
||||
|
||||
import android.os.Build
|
||||
import at.bitfire.davdroid.BuildConfig
|
||||
import okhttp3.Interceptor
|
||||
import okhttp3.OkHttp
|
||||
import okhttp3.Response
|
||||
import java.util.Locale
|
||||
import java.util.logging.Logger
|
||||
|
||||
object UserAgentInterceptor: Interceptor {
|
||||
|
||||
val userAgent = "DAVx5/${BuildConfig.VERSION_NAME} (dav4jvm; " +
|
||||
"okhttp/${OkHttp.VERSION}) Android/${Build.VERSION.RELEASE}"
|
||||
|
||||
init {
|
||||
Logger.getGlobal().info("Will set User-Agent: $userAgent")
|
||||
}
|
||||
|
||||
override fun intercept(chain: Interceptor.Chain): Response {
|
||||
val locale = Locale.getDefault()
|
||||
val request = chain.request().newBuilder()
|
||||
.header("User-Agent", userAgent)
|
||||
.header("Accept-Language", "${locale.language}-${locale.country}, ${locale.language};q=0.7, *;q=0.5")
|
||||
.build()
|
||||
return chain.proceed(request)
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
/*
|
||||
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
|
||||
*/
|
||||
|
||||
package at.bitfire.davdroid.push
|
||||
|
||||
import androidx.annotation.VisibleForTesting
|
||||
import at.bitfire.dav4jvm.XmlReader
|
||||
import at.bitfire.dav4jvm.XmlUtils
|
||||
import at.bitfire.davdroid.db.Collection.Companion.TYPE_ADDRESSBOOK
|
||||
import at.bitfire.davdroid.repository.AccountRepository
|
||||
import at.bitfire.davdroid.repository.DavCollectionRepository
|
||||
import at.bitfire.davdroid.repository.DavServiceRepository
|
||||
import at.bitfire.davdroid.sync.SyncDataType
|
||||
import at.bitfire.davdroid.sync.TasksAppManager
|
||||
import at.bitfire.davdroid.sync.worker.SyncWorkerManager
|
||||
import dagger.Lazy
|
||||
import org.unifiedpush.android.connector.data.PushMessage
|
||||
import org.xmlpull.v1.XmlPullParserException
|
||||
import java.io.StringReader
|
||||
import java.util.logging.Level
|
||||
import java.util.logging.Logger
|
||||
import javax.inject.Inject
|
||||
import at.bitfire.dav4jvm.property.push.PushMessage as DavPushMessage
|
||||
|
||||
/**
|
||||
* Handles incoming WebDAV-Push messages.
|
||||
*/
|
||||
class PushMessageHandler @Inject constructor(
|
||||
private val accountRepository: AccountRepository,
|
||||
private val collectionRepository: DavCollectionRepository,
|
||||
private val logger: Logger,
|
||||
private val serviceRepository: DavServiceRepository,
|
||||
private val syncWorkerManager: SyncWorkerManager,
|
||||
private val tasksAppManager: Lazy<TasksAppManager>
|
||||
) {
|
||||
|
||||
suspend fun processMessage(message: PushMessage, instance: String) {
|
||||
if (!message.decrypted) {
|
||||
logger.severe("Received a push message that could not be decrypted.")
|
||||
return
|
||||
}
|
||||
val messageXml = message.content.toString(Charsets.UTF_8)
|
||||
logger.log(Level.INFO, "Received push message", messageXml)
|
||||
|
||||
// parse push notification
|
||||
val topic = parse(messageXml)
|
||||
|
||||
// sync affected collection
|
||||
if (topic != null) {
|
||||
logger.info("Got push notification for topic $topic")
|
||||
|
||||
// Sync all authorities of account that the collection belongs to
|
||||
// Later: only sync affected collection and authorities
|
||||
collectionRepository.getSyncableByTopic(topic)?.let { collection ->
|
||||
serviceRepository.get(collection.serviceId)?.let { service ->
|
||||
val syncDataTypes = mutableSetOf<SyncDataType>()
|
||||
// If the type is an address book, add the contacts type
|
||||
if (collection.type == TYPE_ADDRESSBOOK)
|
||||
syncDataTypes += SyncDataType.CONTACTS
|
||||
|
||||
// If the collection supports events, add the events type
|
||||
if (collection.supportsVEVENT != false)
|
||||
syncDataTypes += SyncDataType.EVENTS
|
||||
|
||||
// If the collection supports tasks, make sure there's a provider installed,
|
||||
// and add the tasks type
|
||||
if (collection.supportsVJOURNAL != false || collection.supportsVTODO != false)
|
||||
if (tasksAppManager.get().currentProvider() != null)
|
||||
syncDataTypes += SyncDataType.TASKS
|
||||
|
||||
// Schedule sync for all the types identified
|
||||
val account = accountRepository.fromName(service.accountName)
|
||||
for (syncDataType in syncDataTypes)
|
||||
syncWorkerManager.enqueueOneTime(account, syncDataType, fromPush = true)
|
||||
}
|
||||
}
|
||||
|
||||
} else {
|
||||
// fallback when no known topic is present (shouldn't happen)
|
||||
val service = instance.toLongOrNull()?.let { serviceRepository.getBlocking(it) }
|
||||
if (service != null) {
|
||||
logger.warning("Got push message without topic and service, syncing all accounts")
|
||||
val account = accountRepository.fromName(service.accountName)
|
||||
syncWorkerManager.enqueueOneTimeAllAuthorities(account, fromPush = true)
|
||||
|
||||
} else {
|
||||
logger.warning("Got push message without topic, syncing all accounts")
|
||||
for (account in accountRepository.getAll())
|
||||
syncWorkerManager.enqueueOneTimeAllAuthorities(account, fromPush = true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses a WebDAV-Push message and returns the `topic` that the message is about.
|
||||
*
|
||||
* @return topic of the modified collection, or `null` if the topic couldn't be determined
|
||||
*/
|
||||
@VisibleForTesting
|
||||
internal fun parse(message: String): String? {
|
||||
var topic: String? = null
|
||||
|
||||
val parser = XmlUtils.newPullParser()
|
||||
try {
|
||||
parser.setInput(StringReader(message))
|
||||
|
||||
XmlReader(parser).processTag(DavPushMessage.NAME) {
|
||||
val pushMessage = DavPushMessage.Factory.create(parser)
|
||||
topic = pushMessage.topic?.topic
|
||||
}
|
||||
} catch (e: XmlPullParserException) {
|
||||
logger.log(Level.WARNING, "Couldn't parse push message", e)
|
||||
}
|
||||
|
||||
return topic
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,46 +0,0 @@
|
||||
/*
|
||||
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
|
||||
*/
|
||||
|
||||
package at.bitfire.davdroid.push
|
||||
|
||||
import at.bitfire.dav4jvm.XmlReader
|
||||
import at.bitfire.dav4jvm.XmlUtils
|
||||
import at.bitfire.dav4jvm.property.push.PushMessage
|
||||
import at.bitfire.dav4jvm.property.push.Topic
|
||||
import org.xmlpull.v1.XmlPullParserException
|
||||
import java.io.StringReader
|
||||
import java.util.logging.Level
|
||||
import java.util.logging.Logger
|
||||
import javax.inject.Inject
|
||||
|
||||
class PushMessageParser @Inject constructor(
|
||||
private val logger: Logger
|
||||
) {
|
||||
|
||||
/**
|
||||
* Parses a WebDAV-Push message and returns the `topic` that the message is about.
|
||||
*
|
||||
* @return topic of the modified collection, or `null` if the topic couldn't be determined
|
||||
*/
|
||||
operator fun invoke(message: String): String? {
|
||||
var topic: String? = null
|
||||
|
||||
val parser = XmlUtils.newPullParser()
|
||||
try {
|
||||
parser.setInput(StringReader(message))
|
||||
|
||||
XmlReader(parser).processTag(PushMessage.NAME) {
|
||||
val pushMessage = PushMessage.Factory.create(parser)
|
||||
val properties = pushMessage.propStat?.properties ?: return@processTag
|
||||
val pushTopic = properties.filterIsInstance<Topic>().firstOrNull()
|
||||
topic = pushTopic?.topic
|
||||
}
|
||||
} catch (e: XmlPullParserException) {
|
||||
logger.log(Level.WARNING, "Couldn't parse push message", e)
|
||||
}
|
||||
|
||||
return topic
|
||||
}
|
||||
|
||||
}
|
||||
@@ -10,6 +10,7 @@ import android.content.Context
|
||||
import android.content.Intent
|
||||
import androidx.core.app.NotificationCompat
|
||||
import androidx.core.app.NotificationManagerCompat
|
||||
import androidx.core.app.TaskStackBuilder
|
||||
import at.bitfire.davdroid.R
|
||||
import at.bitfire.davdroid.sync.SyncDataType
|
||||
import at.bitfire.davdroid.ui.NotificationRegistry
|
||||
@@ -45,14 +46,13 @@ class PushNotificationManager @Inject constructor(
|
||||
.setAutoCancel(true)
|
||||
.setOnlyAlertOnce(true)
|
||||
.setContentIntent(
|
||||
PendingIntent.getActivity(
|
||||
context,
|
||||
0,
|
||||
Intent(context, AccountActivity::class.java).apply {
|
||||
putExtra(AccountActivity.EXTRA_ACCOUNT, account)
|
||||
},
|
||||
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT
|
||||
)
|
||||
TaskStackBuilder.create(context)
|
||||
.addNextIntentWithParentStack(
|
||||
Intent(context, AccountActivity::class.java).apply {
|
||||
putExtra(AccountActivity.EXTRA_ACCOUNT, account)
|
||||
}
|
||||
)
|
||||
.getPendingIntent(0, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE)
|
||||
)
|
||||
.build()
|
||||
}
|
||||
|
||||
@@ -0,0 +1,375 @@
|
||||
/*
|
||||
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
|
||||
*/
|
||||
|
||||
package at.bitfire.davdroid.push
|
||||
|
||||
import android.content.Context
|
||||
import androidx.work.BackoffPolicy
|
||||
import androidx.work.Constraints
|
||||
import androidx.work.ExistingPeriodicWorkPolicy
|
||||
import androidx.work.NetworkType
|
||||
import androidx.work.PeriodicWorkRequest
|
||||
import androidx.work.WorkManager
|
||||
import at.bitfire.dav4jvm.DavCollection
|
||||
import at.bitfire.dav4jvm.DavResource
|
||||
import at.bitfire.dav4jvm.HttpUtils
|
||||
import at.bitfire.dav4jvm.XmlUtils
|
||||
import at.bitfire.dav4jvm.XmlUtils.insertTag
|
||||
import at.bitfire.dav4jvm.exception.DavException
|
||||
import at.bitfire.dav4jvm.property.push.AuthSecret
|
||||
import at.bitfire.dav4jvm.property.push.PushRegister
|
||||
import at.bitfire.dav4jvm.property.push.PushResource
|
||||
import at.bitfire.dav4jvm.property.push.Subscription
|
||||
import at.bitfire.dav4jvm.property.push.SubscriptionPublicKey
|
||||
import at.bitfire.dav4jvm.property.push.WebPushSubscription
|
||||
import at.bitfire.davdroid.db.Collection
|
||||
import at.bitfire.davdroid.db.Service
|
||||
import at.bitfire.davdroid.di.IoDispatcher
|
||||
import at.bitfire.davdroid.network.HttpClient
|
||||
import at.bitfire.davdroid.push.PushRegistrationManager.Companion.mutex
|
||||
import at.bitfire.davdroid.repository.AccountRepository
|
||||
import at.bitfire.davdroid.repository.DavCollectionRepository
|
||||
import at.bitfire.davdroid.repository.DavServiceRepository
|
||||
import at.bitfire.davdroid.sync.account.InvalidAccountException
|
||||
import dagger.Lazy
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import kotlinx.coroutines.runInterruptible
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import okhttp3.HttpUrl
|
||||
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
|
||||
import okhttp3.RequestBody.Companion.toRequestBody
|
||||
import org.unifiedpush.android.connector.UnifiedPush
|
||||
import org.unifiedpush.android.connector.data.PushEndpoint
|
||||
import java.io.StringWriter
|
||||
import java.time.Duration
|
||||
import java.time.Instant
|
||||
import java.util.concurrent.TimeUnit
|
||||
import java.util.logging.Level
|
||||
import java.util.logging.Logger
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Provider
|
||||
|
||||
/**
|
||||
* Manages push registrations and subscriptions.
|
||||
*
|
||||
* To update push registrations and subscriptions (for instance after collections have been changed), call [update].
|
||||
*
|
||||
* Public API calls are protected by [mutex] so that there won't be multiple subscribe/unsubscribe operations at the same time.
|
||||
* If you call other methods than [update], make sure that they don't interfere with other operations.
|
||||
*/
|
||||
class PushRegistrationManager @Inject constructor(
|
||||
private val accountRepository: Lazy<AccountRepository>,
|
||||
private val collectionRepository: DavCollectionRepository,
|
||||
@ApplicationContext private val context: Context,
|
||||
private val httpClientBuilder: Provider<HttpClient.Builder>,
|
||||
@IoDispatcher private val ioDispatcher: CoroutineDispatcher,
|
||||
private val logger: Logger,
|
||||
private val serviceRepository: DavServiceRepository
|
||||
) {
|
||||
|
||||
/**
|
||||
* Sets or removes (disable push) the distributor and updates the subscriptions + worker.
|
||||
*
|
||||
* Uses [update] which is protected by [mutex] so creating/deleting subscriptions doesn't
|
||||
* interfere with other operations.
|
||||
*
|
||||
* @param pushDistributor new distributor or `null` to disable Push
|
||||
*/
|
||||
suspend fun setPushDistributor(pushDistributor: String?) {
|
||||
// Disable UnifiedPush and remove all subscriptions
|
||||
UnifiedPush.removeDistributor(context)
|
||||
update()
|
||||
|
||||
if (pushDistributor != null) {
|
||||
// If a distributor was passed, store it and create/register subscriptions
|
||||
UnifiedPush.saveDistributor(context, pushDistributor)
|
||||
update()
|
||||
}
|
||||
}
|
||||
|
||||
fun getCurrentDistributor() = UnifiedPush.getSavedDistributor(context)
|
||||
|
||||
fun getDistributors() = UnifiedPush.getDistributors(context)
|
||||
|
||||
|
||||
/**
|
||||
* Updates all push registrations and subscriptions so that if Push is available, it's up-to-date and
|
||||
* working for all database services. If Push is not available, existing subscriptions are unregistered.
|
||||
*
|
||||
* Also makes sure that the [PushRegistrationWorker] is enabled if there's a Push-enabled collection.
|
||||
*
|
||||
* Acquires [mutex] so that this method can't be called twice at the same time, or at the same time
|
||||
* with [update(serviceId)].
|
||||
*/
|
||||
suspend fun update() = mutex.withLock {
|
||||
for (service in serviceRepository.getAll())
|
||||
updateService(service.id)
|
||||
|
||||
updatePeriodicWorker()
|
||||
}
|
||||
|
||||
/**
|
||||
* Same as [update], but for a specific database service.
|
||||
*
|
||||
* Acquires [mutex] so that this method can't be called twice at the same time, or at the same time
|
||||
* as [update()].
|
||||
*/
|
||||
suspend fun update(serviceId: Long) = mutex.withLock {
|
||||
updateService(serviceId)
|
||||
updatePeriodicWorker()
|
||||
}
|
||||
|
||||
/**
|
||||
* Registers or unregisters subscriptions depending on whether there is a distributor available.
|
||||
*/
|
||||
private suspend fun updateService(serviceId: Long) {
|
||||
val service = serviceRepository.get(serviceId) ?: return
|
||||
|
||||
// use service ID from database as UnifiedPush instance name
|
||||
val instance = serviceId.toString()
|
||||
|
||||
val distributorAvailable = getCurrentDistributor() != null
|
||||
if (distributorAvailable)
|
||||
try {
|
||||
val vapid = collectionRepository.getVapidKey(serviceId)
|
||||
logger.fine("Registering UnifiedPush instance $serviceId (${service.accountName})")
|
||||
|
||||
// message for distributor
|
||||
val message = "${service.accountName} (${service.type})"
|
||||
|
||||
UnifiedPush.register(context, instance, message, vapid)
|
||||
} catch (e: UnifiedPush.VapidNotValidException) {
|
||||
logger.log(Level.WARNING, "Couldn't register invalid VAPID key for service $serviceId", e)
|
||||
}
|
||||
else {
|
||||
logger.fine("Unregistering UnifiedPush instance $serviceId (${service.accountName})")
|
||||
UnifiedPush.unregister(context, instance) // doesn't call UnifiedPushService.onUnregistered
|
||||
unsubscribeAll(service)
|
||||
}
|
||||
|
||||
// UnifiedPush has now been called. It will do its work and then asynchronously call back to UnifiedPushService, which
|
||||
// will then call processSubscription or removeSubscription.
|
||||
}
|
||||
|
||||
/**
|
||||
* Called by [UnifiedPushService] when a subscription (endpoint) is available for the given service.
|
||||
*
|
||||
* Uses the subscription to subscribe to syncable collections, and then unsubscribes from non-syncable collections.
|
||||
*/
|
||||
suspend fun processSubscription(serviceId: Long, endpoint: PushEndpoint) = mutex.withLock {
|
||||
val service = serviceRepository.get(serviceId) ?: return
|
||||
|
||||
try {
|
||||
// subscribe to collections which are selected for synchronization
|
||||
subscribeSyncable(service, endpoint)
|
||||
|
||||
// unsubscribe from collections which are not selected for synchronization
|
||||
unsubscribeCollections(service, collectionRepository.getPushRegisteredAndNotSyncable(service.id))
|
||||
} catch (_: InvalidAccountException) {
|
||||
// couldn't create authenticating HTTP client because account is not available
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun subscribeSyncable(service: Service, endpoint: PushEndpoint) {
|
||||
val subscribeTo = collectionRepository.getPushCapableAndSyncable(service.id)
|
||||
if (subscribeTo.isEmpty())
|
||||
return
|
||||
|
||||
val account = accountRepository.get().fromName(service.accountName)
|
||||
httpClientBuilder.get()
|
||||
.fromAccountAsync(account)
|
||||
.build()
|
||||
.use { httpClient ->
|
||||
for (collection in subscribeTo)
|
||||
try {
|
||||
val expires = collection.pushSubscriptionExpires
|
||||
// calculate next run time, but use the duplicate interval for safety (times are not exact)
|
||||
val nextRun = Instant.now() + Duration.ofDays(2 * WORKER_INTERVAL_DAYS)
|
||||
if (expires != null && expires >= nextRun.epochSecond)
|
||||
logger.fine("Push subscription for ${collection.url} is still valid until ${collection.pushSubscriptionExpires}")
|
||||
else {
|
||||
// no existing subscription or expiring soon
|
||||
logger.fine("Registering push subscription for ${collection.url}")
|
||||
subscribe(httpClient, collection, endpoint)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
logger.log(Level.WARNING, "Couldn't register subscription at CalDAV/CardDAV server", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Called when no subscription is available (anymore) for the given service.
|
||||
*
|
||||
* Unsubscribes from all subscribed collections.
|
||||
*/
|
||||
suspend fun removeSubscription(serviceId: Long) = mutex.withLock {
|
||||
val service = serviceRepository.get(serviceId) ?: return
|
||||
unsubscribeAll(service)
|
||||
}
|
||||
|
||||
private suspend fun unsubscribeAll(service: Service) {
|
||||
val unsubscribeFrom = collectionRepository.getPushRegistered(service.id)
|
||||
|
||||
try {
|
||||
unsubscribeCollections(service, unsubscribeFrom)
|
||||
} catch (_: InvalidAccountException) {
|
||||
// couldn't create authenticating HTTP client because account is not available
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Registers the subscription to a given collection ("subscribe to a collection").
|
||||
*
|
||||
* @param httpClient HTTP client to use
|
||||
* @param collection collection to subscribe to
|
||||
* @param endpoint subscription to register
|
||||
*/
|
||||
private suspend fun subscribe(httpClient: HttpClient, collection: Collection, endpoint: PushEndpoint) {
|
||||
// requested expiration time: 3 days
|
||||
val requestedExpiration = Instant.now() + Duration.ofDays(3)
|
||||
|
||||
val serializer = XmlUtils.newSerializer()
|
||||
val writer = StringWriter()
|
||||
serializer.setOutput(writer)
|
||||
serializer.startDocument("UTF-8", true)
|
||||
serializer.insertTag(PushRegister.NAME) {
|
||||
serializer.insertTag(Subscription.NAME) {
|
||||
// subscription URL
|
||||
serializer.insertTag(WebPushSubscription.NAME) {
|
||||
serializer.insertTag(PushResource.NAME) {
|
||||
text(endpoint.url)
|
||||
}
|
||||
endpoint.pubKeySet?.let { pubKeySet ->
|
||||
serializer.insertTag(SubscriptionPublicKey.NAME) {
|
||||
attribute(null, "type", "p256dh")
|
||||
text(pubKeySet.pubKey)
|
||||
}
|
||||
serializer.insertTag(AuthSecret.NAME) {
|
||||
text(pubKeySet.auth)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// requested expiration
|
||||
serializer.insertTag(PushRegister.EXPIRES) {
|
||||
text(HttpUtils.formatDate(requestedExpiration))
|
||||
}
|
||||
}
|
||||
serializer.endDocument()
|
||||
|
||||
runInterruptible(ioDispatcher) {
|
||||
val xml = writer.toString().toRequestBody(DavResource.MIME_XML)
|
||||
DavCollection(httpClient.okHttpClient, collection.url).post(xml) { response ->
|
||||
if (response.isSuccessful) {
|
||||
// update subscription URL and expiration in DB
|
||||
val subscriptionUrl = response.header("Location")
|
||||
val expires = response.header("Expires")?.let { expiresDate ->
|
||||
HttpUtils.parseDate(expiresDate)
|
||||
} ?: requestedExpiration
|
||||
|
||||
runBlocking {
|
||||
collectionRepository.updatePushSubscription(
|
||||
id = collection.id,
|
||||
subscriptionUrl = subscriptionUrl,
|
||||
expires = expires?.epochSecond
|
||||
)
|
||||
}
|
||||
} else
|
||||
logger.warning("Couldn't register push for ${collection.url}: $response")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Unsubscribe from the given collections.
|
||||
*/
|
||||
private suspend fun unsubscribeCollections(service: Service, from: List<Collection>) {
|
||||
if (from.isEmpty())
|
||||
return
|
||||
|
||||
val account = accountRepository.get().fromName(service.accountName)
|
||||
httpClientBuilder.get()
|
||||
.fromAccountAsync(account)
|
||||
.build()
|
||||
.use { httpClient ->
|
||||
for (collection in from)
|
||||
collection.pushSubscription?.toHttpUrlOrNull()?.let { url ->
|
||||
logger.info("Unsubscribing Push from ${collection.url}")
|
||||
unsubscribe(httpClient, collection, url)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun unsubscribe(httpClient: HttpClient, collection: Collection, url: HttpUrl) {
|
||||
try {
|
||||
runInterruptible(ioDispatcher) {
|
||||
DavResource(httpClient.okHttpClient, url).delete {
|
||||
// deleted
|
||||
}
|
||||
}
|
||||
} catch (e: DavException) {
|
||||
logger.log(Level.WARNING, "Couldn't unregister push for ${collection.url}", e)
|
||||
}
|
||||
|
||||
// remove registration URL from DB in any case
|
||||
collectionRepository.updatePushSubscription(
|
||||
id = collection.id,
|
||||
subscriptionUrl = null,
|
||||
expires = null
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Determines whether there are any push-capable collections and updates the periodic worker accordingly.
|
||||
*
|
||||
* If there are push-capable collections, a unique periodic worker with an initial delay of 5 seconds is enqueued.
|
||||
* A potentially existing worker is replaced, so that the first run should be soon.
|
||||
*
|
||||
* Otherwise, a potentially existing worker is cancelled.
|
||||
*/
|
||||
private suspend fun updatePeriodicWorker() {
|
||||
val workerNeeded = collectionRepository.anyPushCapable()
|
||||
|
||||
val workManager = WorkManager.getInstance(context)
|
||||
if (workerNeeded) {
|
||||
logger.info("Enqueuing periodic PushRegistrationWorker")
|
||||
workManager.enqueueUniquePeriodicWork(
|
||||
WORKER_UNIQUE_NAME,
|
||||
ExistingPeriodicWorkPolicy.UPDATE,
|
||||
PeriodicWorkRequest.Builder(PushRegistrationWorker::class, WORKER_INTERVAL_DAYS, TimeUnit.DAYS)
|
||||
.setInitialDelay(5, TimeUnit.SECONDS)
|
||||
.setConstraints(
|
||||
Constraints.Builder()
|
||||
.setRequiredNetworkType(NetworkType.CONNECTED)
|
||||
.build()
|
||||
)
|
||||
.setBackoffCriteria(BackoffPolicy.EXPONENTIAL, 1, TimeUnit.MINUTES)
|
||||
.build()
|
||||
)
|
||||
} else {
|
||||
logger.info("Cancelling periodic PushRegistrationWorker")
|
||||
workManager.cancelUniqueWork(WORKER_UNIQUE_NAME)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
companion object {
|
||||
|
||||
private const val WORKER_UNIQUE_NAME = "push-registration"
|
||||
const val WORKER_INTERVAL_DAYS = 1L
|
||||
|
||||
/**
|
||||
* Mutex to synchronize (un)subscription.
|
||||
*/
|
||||
val mutex = Mutex()
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
@@ -4,189 +4,35 @@
|
||||
|
||||
package at.bitfire.davdroid.push
|
||||
|
||||
import android.accounts.Account
|
||||
import android.content.Context
|
||||
import androidx.hilt.work.HiltWorker
|
||||
import androidx.work.CoroutineWorker
|
||||
import androidx.work.WorkerParameters
|
||||
import at.bitfire.dav4jvm.DavCollection
|
||||
import at.bitfire.dav4jvm.DavResource
|
||||
import at.bitfire.dav4jvm.HttpUtils
|
||||
import at.bitfire.dav4jvm.Property
|
||||
import at.bitfire.dav4jvm.XmlUtils
|
||||
import at.bitfire.dav4jvm.XmlUtils.insertTag
|
||||
import at.bitfire.dav4jvm.exception.DavException
|
||||
import at.bitfire.dav4jvm.property.push.NS_WEBDAV_PUSH
|
||||
import at.bitfire.davdroid.R
|
||||
import at.bitfire.davdroid.db.Collection
|
||||
import at.bitfire.davdroid.network.HttpClient
|
||||
import at.bitfire.davdroid.repository.DavCollectionRepository
|
||||
import at.bitfire.davdroid.repository.DavServiceRepository
|
||||
import at.bitfire.davdroid.repository.PreferenceRepository
|
||||
import at.bitfire.davdroid.settings.AccountSettings
|
||||
import dagger.assisted.Assisted
|
||||
import dagger.assisted.AssistedInject
|
||||
import kotlinx.coroutines.runInterruptible
|
||||
import okhttp3.HttpUrl
|
||||
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
|
||||
import okhttp3.RequestBody.Companion.toRequestBody
|
||||
import java.io.IOException
|
||||
import java.io.StringWriter
|
||||
import java.time.Duration
|
||||
import java.time.Instant
|
||||
import java.util.logging.Level
|
||||
import java.util.logging.Logger
|
||||
|
||||
/**
|
||||
* Worker that registers push for all collections that support it.
|
||||
* To be run as soon as a collection that supports push is changed (selected for sync status
|
||||
* changes, or collection is created, deleted, etc).
|
||||
* Worker that runs regularly and initiates push registration updates for all collections.
|
||||
*
|
||||
* Managed by [PushRegistrationManager].
|
||||
*/
|
||||
@Suppress("unused")
|
||||
@HiltWorker
|
||||
class PushRegistrationWorker @AssistedInject constructor(
|
||||
@Assisted context: Context,
|
||||
@Assisted workerParameters: WorkerParameters,
|
||||
private val accountSettingsFactory: AccountSettings.Factory,
|
||||
private val collectionRepository: DavCollectionRepository,
|
||||
private val logger: Logger,
|
||||
private val preferenceRepository: PreferenceRepository,
|
||||
private val serviceRepository: DavServiceRepository
|
||||
private val pushRegistrationManager: PushRegistrationManager
|
||||
) : CoroutineWorker(context, workerParameters) {
|
||||
|
||||
override suspend fun doWork(): Result {
|
||||
logger.info("Running push registration worker")
|
||||
|
||||
try {
|
||||
registerSyncable()
|
||||
unregisterNotSyncable()
|
||||
} catch (_: IOException) {
|
||||
return Result.retry() // retry on I/O errors
|
||||
}
|
||||
// update registrations for all services
|
||||
pushRegistrationManager.update()
|
||||
|
||||
return Result.success()
|
||||
}
|
||||
|
||||
private suspend fun registerPushSubscription(collection: Collection, account: Account, endpoint: String) {
|
||||
val settings = accountSettingsFactory.create(account)
|
||||
|
||||
runInterruptible {
|
||||
HttpClient.Builder(applicationContext, settings)
|
||||
.setForeground(true)
|
||||
.build()
|
||||
.use { client ->
|
||||
val httpClient = client.okHttpClient
|
||||
|
||||
// requested expiration time: 3 days
|
||||
val requestedExpiration = Instant.now() + Duration.ofDays(3)
|
||||
|
||||
val serializer = XmlUtils.newSerializer()
|
||||
val writer = StringWriter()
|
||||
serializer.setOutput(writer)
|
||||
serializer.startDocument("UTF-8", true)
|
||||
serializer.insertTag(Property.Name(NS_WEBDAV_PUSH, "push-register")) {
|
||||
serializer.insertTag(Property.Name(NS_WEBDAV_PUSH, "subscription")) {
|
||||
// subscription URL
|
||||
serializer.insertTag(Property.Name(NS_WEBDAV_PUSH, "web-push-subscription")) {
|
||||
serializer.insertTag(Property.Name(NS_WEBDAV_PUSH, "push-resource")) {
|
||||
text(endpoint)
|
||||
}
|
||||
}
|
||||
}
|
||||
// requested expiration
|
||||
serializer.insertTag(Property.Name(NS_WEBDAV_PUSH, "expires")) {
|
||||
text(HttpUtils.formatDate(requestedExpiration))
|
||||
}
|
||||
}
|
||||
serializer.endDocument()
|
||||
|
||||
val xml = writer.toString().toRequestBody(DavResource.MIME_XML)
|
||||
DavCollection(httpClient, collection.url).post(xml) { response ->
|
||||
if (response.isSuccessful) {
|
||||
val subscriptionUrl = response.header("Location")
|
||||
val expires = response.header("Expires")?.let { expiresDate ->
|
||||
HttpUtils.parseDate(expiresDate)
|
||||
} ?: requestedExpiration
|
||||
collectionRepository.updatePushSubscription(
|
||||
id = collection.id,
|
||||
subscriptionUrl = subscriptionUrl,
|
||||
expires = expires?.epochSecond
|
||||
)
|
||||
} else
|
||||
logger.warning("Couldn't register push for ${collection.url}: $response")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun registerSyncable() {
|
||||
val endpoint = preferenceRepository.unifiedPushEndpoint()
|
||||
|
||||
// register push subscription for syncable collections
|
||||
if (endpoint != null)
|
||||
for (collection in collectionRepository.getPushCapableAndSyncable()) {
|
||||
val expires = collection.pushSubscriptionExpires
|
||||
// calculate next run time, but use the duplicate interval for safety (times are not exact)
|
||||
val nextRun = Instant.now() + Duration.ofDays(2*PushRegistrationWorkerManager.INTERVAL_DAYS)
|
||||
if (expires != null && expires >= nextRun.epochSecond) {
|
||||
logger.fine("Push subscription for ${collection.url} is still valid until ${collection.pushSubscriptionExpires}")
|
||||
continue
|
||||
}
|
||||
|
||||
// no existing subscription or expiring soon
|
||||
logger.info("Registering push for ${collection.url}")
|
||||
serviceRepository.get(collection.serviceId)?.let { service ->
|
||||
val account = Account(service.accountName, applicationContext.getString(R.string.account_type))
|
||||
try {
|
||||
registerPushSubscription(collection, account, endpoint)
|
||||
} catch (e: DavException) {
|
||||
// catch possible per-collection exception so that all collections can be processed
|
||||
logger.log(Level.WARNING, "Couldn't register push for ${collection.url}", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
else
|
||||
logger.info("No UnifiedPush endpoint configured")
|
||||
}
|
||||
|
||||
private suspend fun unregisterPushSubscription(collection: Collection, account: Account, url: HttpUrl) {
|
||||
val settings = accountSettingsFactory.create(account)
|
||||
|
||||
runInterruptible {
|
||||
HttpClient.Builder(applicationContext, settings)
|
||||
.setForeground(true)
|
||||
.build()
|
||||
.use { client ->
|
||||
val httpClient = client.okHttpClient
|
||||
|
||||
try {
|
||||
DavResource(httpClient, url).delete {
|
||||
// deleted
|
||||
}
|
||||
} catch (e: DavException) {
|
||||
logger.log(Level.WARNING, "Couldn't unregister push for ${collection.url}", e)
|
||||
}
|
||||
|
||||
// remove registration URL from DB in any case
|
||||
collectionRepository.updatePushSubscription(
|
||||
id = collection.id,
|
||||
subscriptionUrl = null,
|
||||
expires = null
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun unregisterNotSyncable() {
|
||||
for (collection in collectionRepository.getPushRegisteredAndNotSyncable()) {
|
||||
logger.info("Unregistering push for ${collection.url}")
|
||||
collection.pushSubscription?.toHttpUrlOrNull()?.let { url ->
|
||||
serviceRepository.get(collection.serviceId)?.let { service ->
|
||||
val account = Account(service.accountName, applicationContext.getString(R.string.account_type))
|
||||
unregisterPushSubscription(collection, account, url)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,98 +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 androidx.work.BackoffPolicy
|
||||
import androidx.work.Constraints
|
||||
import androidx.work.ExistingPeriodicWorkPolicy
|
||||
import androidx.work.NetworkType
|
||||
import androidx.work.PeriodicWorkRequest
|
||||
import androidx.work.WorkManager
|
||||
import at.bitfire.davdroid.repository.DavCollectionRepository
|
||||
import dagger.Binds
|
||||
import dagger.Module
|
||||
import dagger.hilt.InstallIn
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import dagger.hilt.components.SingletonComponent
|
||||
import dagger.multibindings.IntoSet
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import java.util.concurrent.TimeUnit
|
||||
import java.util.logging.Logger
|
||||
import javax.inject.Inject
|
||||
|
||||
class PushRegistrationWorkerManager @Inject constructor(
|
||||
@ApplicationContext val context: Context,
|
||||
val collectionRepository: DavCollectionRepository,
|
||||
val logger: Logger
|
||||
) {
|
||||
|
||||
/**
|
||||
* Determines whether there are any push-capable collections and updates the periodic worker accordingly.
|
||||
*
|
||||
* If there are push-capable collections, a unique periodic worker with an initial delay of 5 seconds is enqueued.
|
||||
* A potentially existing worker is replaced, so that the first run should be soon.
|
||||
*
|
||||
* Otherwise, a potentially existing worker is cancelled.
|
||||
*/
|
||||
fun updatePeriodicWorker() {
|
||||
val workerNeeded = runBlocking {
|
||||
collectionRepository.anyPushCapable()
|
||||
}
|
||||
|
||||
val workManager = WorkManager.getInstance(context)
|
||||
if (workerNeeded) {
|
||||
logger.info("Enqueuing periodic PushRegistrationWorker")
|
||||
workManager.enqueueUniquePeriodicWork(
|
||||
UNIQUE_WORK_NAME,
|
||||
ExistingPeriodicWorkPolicy.CANCEL_AND_REENQUEUE,
|
||||
PeriodicWorkRequest.Builder(PushRegistrationWorker::class, INTERVAL_DAYS, TimeUnit.DAYS)
|
||||
.setInitialDelay(5, TimeUnit.SECONDS)
|
||||
.setConstraints(
|
||||
Constraints.Builder()
|
||||
.setRequiredNetworkType(NetworkType.CONNECTED)
|
||||
.build()
|
||||
)
|
||||
.setBackoffCriteria(BackoffPolicy.EXPONENTIAL, 1, TimeUnit.MINUTES)
|
||||
.build()
|
||||
)
|
||||
} else {
|
||||
logger.info("Cancelling periodic PushRegistrationWorker")
|
||||
workManager.cancelUniqueWork(UNIQUE_WORK_NAME)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
companion object {
|
||||
private const val UNIQUE_WORK_NAME = "push-registration"
|
||||
const val INTERVAL_DAYS = 1L
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Listener that enqueues a push registration worker when the collection list changes.
|
||||
*/
|
||||
class CollectionsListener @Inject constructor(
|
||||
@ApplicationContext val context: Context,
|
||||
val workerManager: PushRegistrationWorkerManager
|
||||
): DavCollectionRepository.OnChangeListener {
|
||||
|
||||
override fun onCollectionsChanged() {
|
||||
workerManager.updatePeriodicWorker()
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* Hilt module that registers [CollectionsListener] in [DavCollectionRepository].
|
||||
*/
|
||||
@Module
|
||||
@InstallIn(SingletonComponent::class)
|
||||
interface PushRegistrationWorkerModule {
|
||||
@Binds
|
||||
@IntoSet
|
||||
fun listener(impl: CollectionsListener): DavCollectionRepository.OnChangeListener
|
||||
}
|
||||
}
|
||||
@@ -1,117 +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 at.bitfire.davdroid.db.Collection.Companion.TYPE_ADDRESSBOOK
|
||||
import at.bitfire.davdroid.repository.AccountRepository
|
||||
import at.bitfire.davdroid.repository.DavCollectionRepository
|
||||
import at.bitfire.davdroid.repository.DavServiceRepository
|
||||
import at.bitfire.davdroid.repository.PreferenceRepository
|
||||
import at.bitfire.davdroid.sync.SyncDataType
|
||||
import at.bitfire.davdroid.sync.TasksAppManager
|
||||
import at.bitfire.davdroid.sync.worker.SyncWorkerManager
|
||||
import dagger.Lazy
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import org.unifiedpush.android.connector.MessagingReceiver
|
||||
import java.util.logging.Level
|
||||
import java.util.logging.Logger
|
||||
import javax.inject.Inject
|
||||
|
||||
@AndroidEntryPoint
|
||||
class UnifiedPushReceiver: MessagingReceiver() {
|
||||
|
||||
@Inject
|
||||
lateinit var accountRepository: AccountRepository
|
||||
|
||||
@Inject
|
||||
lateinit var collectionRepository: DavCollectionRepository
|
||||
|
||||
@Inject
|
||||
lateinit var logger: Logger
|
||||
|
||||
@Inject
|
||||
lateinit var serviceRepository: DavServiceRepository
|
||||
|
||||
@Inject
|
||||
lateinit var preferenceRepository: PreferenceRepository
|
||||
|
||||
@Inject
|
||||
lateinit var parsePushMessage: PushMessageParser
|
||||
|
||||
@Inject
|
||||
lateinit var pushRegistrationWorkerManager: PushRegistrationWorkerManager
|
||||
|
||||
@Inject
|
||||
lateinit var tasksAppManager: Lazy<TasksAppManager>
|
||||
|
||||
@Inject
|
||||
lateinit var syncWorkerManager: SyncWorkerManager
|
||||
|
||||
|
||||
override fun onNewEndpoint(context: Context, endpoint: String, instance: String) {
|
||||
// remember new endpoint
|
||||
preferenceRepository.unifiedPushEndpoint(endpoint)
|
||||
|
||||
// register new endpoint at CalDAV/CardDAV servers
|
||||
pushRegistrationWorkerManager.updatePeriodicWorker()
|
||||
}
|
||||
|
||||
override fun onUnregistered(context: Context, instance: String) {
|
||||
// reset known endpoint
|
||||
preferenceRepository.unifiedPushEndpoint(null)
|
||||
}
|
||||
|
||||
override fun onMessage(context: Context, message: ByteArray, instance: String) {
|
||||
CoroutineScope(Dispatchers.Default).launch {
|
||||
val messageXml = message.toString(Charsets.UTF_8)
|
||||
logger.log(Level.INFO, "Received push message", messageXml)
|
||||
|
||||
// parse push notification
|
||||
val topic = parsePushMessage(messageXml)
|
||||
|
||||
// sync affected collection
|
||||
if (topic != null) {
|
||||
logger.info("Got push notification for topic $topic")
|
||||
|
||||
// Sync all authorities of account that the collection belongs to
|
||||
// Later: only sync affected collection and authorities
|
||||
collectionRepository.getSyncableByTopic(topic)?.let { collection ->
|
||||
serviceRepository.get(collection.serviceId)?.let { service ->
|
||||
val syncDataTypes = mutableSetOf<SyncDataType>()
|
||||
// If the type is an address book, add the contacts type
|
||||
if (collection.type == TYPE_ADDRESSBOOK)
|
||||
syncDataTypes += SyncDataType.CONTACTS
|
||||
|
||||
// If the collection supports events, add the events type
|
||||
if (collection.supportsVEVENT != false)
|
||||
syncDataTypes += SyncDataType.EVENTS
|
||||
|
||||
// If the collection supports tasks, make sure there's a provider installed,
|
||||
// and add the tasks type
|
||||
if (collection.supportsVJOURNAL != false || collection.supportsVTODO != false)
|
||||
if (tasksAppManager.get().currentProvider() != null)
|
||||
syncDataTypes += SyncDataType.TASKS
|
||||
|
||||
// Schedule sync for all the types identified
|
||||
val account = accountRepository.fromName(service.accountName)
|
||||
for (syncDataType in syncDataTypes)
|
||||
syncWorkerManager.enqueueOneTime(account, syncDataType, fromPush = true)
|
||||
}
|
||||
}
|
||||
|
||||
} else {
|
||||
logger.warning("Got push message without topic, syncing all accounts")
|
||||
for (account in accountRepository.getAll())
|
||||
syncWorkerManager.enqueueOneTimeAllAuthorities(account, fromPush = true)
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
/*
|
||||
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
|
||||
*/
|
||||
|
||||
package at.bitfire.davdroid.push
|
||||
|
||||
import at.bitfire.davdroid.di.ApplicationScope
|
||||
import dagger.Lazy
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.launch
|
||||
import org.unifiedpush.android.connector.FailedReason
|
||||
import org.unifiedpush.android.connector.PushService
|
||||
import org.unifiedpush.android.connector.data.PushEndpoint
|
||||
import org.unifiedpush.android.connector.data.PushMessage
|
||||
import java.util.logging.Logger
|
||||
import javax.inject.Inject
|
||||
|
||||
/**
|
||||
* Entry point for UnifiedPush.
|
||||
*
|
||||
* Calls [PushRegistrationManager] for most tasks, except incoming push messages,
|
||||
* which are handled directly.
|
||||
*/
|
||||
@AndroidEntryPoint
|
||||
class UnifiedPushService : PushService() {
|
||||
|
||||
/* Scope to run the requests asynchronously. UnifiedPush binds the service,
|
||||
* sends the message and unbinds one second later. Our operations may take longer,
|
||||
* so the scope should not be bound to the service lifecycle. */
|
||||
@Inject
|
||||
@ApplicationScope
|
||||
lateinit var applicationScope: CoroutineScope
|
||||
|
||||
@Inject
|
||||
lateinit var logger: Logger
|
||||
|
||||
@Inject
|
||||
lateinit var pushMessageHandler: Lazy<PushMessageHandler>
|
||||
|
||||
@Inject
|
||||
lateinit var pushRegistrationManager: Lazy<PushRegistrationManager>
|
||||
|
||||
|
||||
override fun onNewEndpoint(endpoint: PushEndpoint, instance: String) {
|
||||
val serviceId = instance.toLongOrNull() ?: return
|
||||
logger.warning("Got UnifiedPush endpoint for service $serviceId: ${endpoint.url}")
|
||||
|
||||
// register new endpoint at CalDAV/CardDAV servers
|
||||
applicationScope.launch {
|
||||
pushRegistrationManager.get().processSubscription(serviceId, endpoint)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onRegistrationFailed(reason: FailedReason, instance: String) {
|
||||
val serviceId = instance.toLongOrNull() ?: return
|
||||
logger.warning("UnifiedPush registration failed for service $serviceId: $reason")
|
||||
|
||||
// unregister subscriptions
|
||||
applicationScope.launch {
|
||||
pushRegistrationManager.get().removeSubscription(serviceId)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onUnregistered(instance: String) {
|
||||
val serviceId = instance.toLongOrNull() ?: return
|
||||
logger.warning("UnifiedPush unregistered for service $serviceId")
|
||||
|
||||
applicationScope.launch {
|
||||
pushRegistrationManager.get().removeSubscription(serviceId)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onMessage(message: PushMessage, instance: String) {
|
||||
applicationScope.launch {
|
||||
pushMessageHandler.get().processMessage(message, instance)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -8,11 +8,11 @@ import android.accounts.Account
|
||||
import android.accounts.AccountManager
|
||||
import android.accounts.OnAccountsUpdateListener
|
||||
import android.content.Context
|
||||
import at.bitfire.davdroid.InvalidAccountException
|
||||
import at.bitfire.davdroid.R
|
||||
import at.bitfire.davdroid.db.Credentials
|
||||
import at.bitfire.davdroid.db.HomeSet
|
||||
import at.bitfire.davdroid.db.Service
|
||||
import at.bitfire.davdroid.db.ServiceType
|
||||
import at.bitfire.davdroid.resource.LocalAddressBookStore
|
||||
import at.bitfire.davdroid.resource.LocalCalendarStore
|
||||
import at.bitfire.davdroid.servicedetection.DavResourceFinder
|
||||
@@ -22,6 +22,7 @@ import at.bitfire.davdroid.sync.AutomaticSyncManager
|
||||
import at.bitfire.davdroid.sync.SyncDataType
|
||||
import at.bitfire.davdroid.sync.TasksAppManager
|
||||
import at.bitfire.davdroid.sync.account.AccountsCleanupWorker
|
||||
import at.bitfire.davdroid.sync.account.InvalidAccountException
|
||||
import at.bitfire.davdroid.sync.account.SystemAccountUtils
|
||||
import at.bitfire.davdroid.sync.worker.SyncWorkerManager
|
||||
import at.bitfire.vcard4android.GroupMethod
|
||||
@@ -43,7 +44,7 @@ import javax.inject.Inject
|
||||
*/
|
||||
class AccountRepository @Inject constructor(
|
||||
private val accountSettingsFactory: AccountSettings.Factory,
|
||||
private val automaticSyncManager: AutomaticSyncManager,
|
||||
private val automaticSyncManager: Lazy<AutomaticSyncManager>,
|
||||
@ApplicationContext private val context: Context,
|
||||
private val collectionRepository: DavCollectionRepository,
|
||||
private val homeSetRepository: DavHomeSetRepository,
|
||||
@@ -51,7 +52,7 @@ class AccountRepository @Inject constructor(
|
||||
private val localAddressBookStore: Lazy<LocalAddressBookStore>,
|
||||
private val logger: Logger,
|
||||
private val serviceRepository: DavServiceRepository,
|
||||
private val syncWorkerManager: SyncWorkerManager,
|
||||
private val syncWorkerManager: Lazy<SyncWorkerManager>,
|
||||
private val tasksAppManager: Lazy<TasksAppManager>
|
||||
) {
|
||||
|
||||
@@ -69,7 +70,7 @@ class AccountRepository @Inject constructor(
|
||||
*
|
||||
* @return account if account creation was successful; null otherwise (for instance because an account with this name already exists)
|
||||
*/
|
||||
fun create(accountName: String, credentials: Credentials?, config: DavResourceFinder.Configuration, groupMethod: GroupMethod): Account? {
|
||||
fun createBlocking(accountName: String, credentials: Credentials?, config: DavResourceFinder.Configuration, groupMethod: GroupMethod): Account? {
|
||||
val account = fromName(accountName)
|
||||
|
||||
// create Android account
|
||||
@@ -103,7 +104,7 @@ class AccountRepository @Inject constructor(
|
||||
}
|
||||
|
||||
// set up automatic sync (processes inserted services)
|
||||
automaticSyncManager.updateAutomaticSync(account)
|
||||
automaticSyncManager.get().updateAutomaticSync(account)
|
||||
|
||||
} catch(e: InvalidAccountException) {
|
||||
logger.log(Level.SEVERE, "Couldn't access account settings", e)
|
||||
@@ -205,11 +206,11 @@ class AccountRepository @Inject constructor(
|
||||
}
|
||||
|
||||
// account renamed, cancel maybe running synchronization of old account
|
||||
syncWorkerManager.cancelAllWork(oldAccount)
|
||||
syncWorkerManager.get().cancelAllWork(oldAccount)
|
||||
|
||||
// disable periodic syncs for old account
|
||||
for (dataType in SyncDataType.entries)
|
||||
syncWorkerManager.disablePeriodic(oldAccount, dataType)
|
||||
syncWorkerManager.get().disablePeriodic(oldAccount, dataType)
|
||||
|
||||
// update account name references in database
|
||||
serviceRepository.renameAccount(oldName, newName)
|
||||
@@ -237,7 +238,7 @@ class AccountRepository @Inject constructor(
|
||||
}
|
||||
|
||||
// update automatic sync
|
||||
automaticSyncManager.updateAutomaticSync(newAccount)
|
||||
automaticSyncManager.get().updateAutomaticSync(newAccount)
|
||||
} finally {
|
||||
// release AccountsCleanupWorker mutex at the end of this async coroutine
|
||||
AccountsCleanupWorker.unlockAccountsCleanup()
|
||||
@@ -247,14 +248,14 @@ class AccountRepository @Inject constructor(
|
||||
|
||||
// helpers
|
||||
|
||||
private fun insertService(accountName: String, type: String, info: DavResourceFinder.Configuration.ServiceInfo): Long {
|
||||
private fun insertService(accountName: String, @ServiceType type: String, info: DavResourceFinder.Configuration.ServiceInfo): Long {
|
||||
// insert service
|
||||
val service = Service(0, accountName, type, info.principal)
|
||||
val serviceId = serviceRepository.insertOrReplace(service)
|
||||
val serviceId = serviceRepository.insertOrReplaceBlocking(service)
|
||||
|
||||
// insert home sets
|
||||
for (homeSet in info.homeSets)
|
||||
homeSetRepository.insertOrUpdateByUrl(HomeSet(0, serviceId, true, homeSet))
|
||||
homeSetRepository.insertOrUpdateByUrlBlocking(HomeSet(0, serviceId, true, homeSet))
|
||||
|
||||
// insert collections
|
||||
for (collection in info.collections.values) {
|
||||
|
||||
@@ -23,46 +23,39 @@ import at.bitfire.dav4jvm.property.webdav.ResourceType
|
||||
import at.bitfire.davdroid.R
|
||||
import at.bitfire.davdroid.db.AppDatabase
|
||||
import at.bitfire.davdroid.db.Collection
|
||||
import at.bitfire.davdroid.db.CollectionType
|
||||
import at.bitfire.davdroid.db.HomeSet
|
||||
import at.bitfire.davdroid.network.HttpClient
|
||||
import at.bitfire.davdroid.servicedetection.RefreshCollectionsWorker
|
||||
import at.bitfire.davdroid.settings.AccountSettings
|
||||
import at.bitfire.davdroid.util.DavUtils
|
||||
import at.bitfire.ical4android.ICalendar
|
||||
import at.bitfire.ical4android.util.DateUtils
|
||||
import dagger.Lazy
|
||||
import dagger.Module
|
||||
import dagger.hilt.InstallIn
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import dagger.hilt.components.SingletonComponent
|
||||
import dagger.multibindings.Multibinds
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.runInterruptible
|
||||
import kotlinx.coroutines.withContext
|
||||
import net.fortuna.ical4j.model.Calendar
|
||||
import net.fortuna.ical4j.model.Component
|
||||
import net.fortuna.ical4j.model.ComponentList
|
||||
import net.fortuna.ical4j.model.Property
|
||||
import net.fortuna.ical4j.model.PropertyList
|
||||
import net.fortuna.ical4j.model.component.VTimeZone
|
||||
import net.fortuna.ical4j.model.property.Version
|
||||
import okhttp3.HttpUrl
|
||||
import java.io.StringWriter
|
||||
import java.util.Collections
|
||||
import java.util.UUID
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Provider
|
||||
|
||||
/**
|
||||
* Repository for managing collections.
|
||||
*
|
||||
* Implements an observer pattern that can be used to listen for changes of collections.
|
||||
*/
|
||||
class DavCollectionRepository @Inject constructor(
|
||||
private val accountSettingsFactory: AccountSettings.Factory,
|
||||
@ApplicationContext private val context: Context,
|
||||
private val db: AppDatabase,
|
||||
defaultListeners: Lazy<Set<@JvmSuppressWildcards OnChangeListener>>,
|
||||
private val httpClientBuilder: Provider<HttpClient.Builder>,
|
||||
private val serviceRepository: DavServiceRepository
|
||||
) {
|
||||
|
||||
private val listeners by lazy { Collections.synchronizedSet(defaultListeners.get().toMutableSet()) }
|
||||
|
||||
private val dao = db.collectionDao()
|
||||
|
||||
/**
|
||||
@@ -107,8 +100,6 @@ class DavCollectionRepository @Inject constructor(
|
||||
description = description
|
||||
)
|
||||
dao.insertAsync(collection)
|
||||
|
||||
notifyOnChangeListeners()
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -167,36 +158,34 @@ class DavCollectionRepository @Inject constructor(
|
||||
// Trigger service detection (because the collection may actually have other properties than the ones we have inserted).
|
||||
// Some servers are known to change the supported components (VEVENT, …) after creation.
|
||||
RefreshCollectionsWorker.enqueue(context, homeSet.serviceId)
|
||||
|
||||
notifyOnChangeListeners()
|
||||
}
|
||||
|
||||
/** Deletes the given collection from the server and the database. */
|
||||
suspend fun deleteRemote(collection: Collection) {
|
||||
val service = serviceRepository.get(collection.serviceId) ?: throw IllegalArgumentException("Service not found")
|
||||
val service = serviceRepository.getBlocking(collection.serviceId) ?: throw IllegalArgumentException("Service not found")
|
||||
val account = Account(service.accountName, context.getString(R.string.account_type))
|
||||
|
||||
HttpClient.Builder(context, accountSettingsFactory.create(account))
|
||||
.setForeground(true)
|
||||
.build().use { httpClient ->
|
||||
withContext(Dispatchers.IO) {
|
||||
runInterruptible {
|
||||
DavResource(httpClient.okHttpClient, collection.url).delete() {
|
||||
// success, otherwise an exception would have been thrown → delete locally, too
|
||||
delete(collection)
|
||||
}
|
||||
httpClientBuilder.get()
|
||||
.fromAccount(account)
|
||||
.build()
|
||||
.use { httpClient ->
|
||||
runInterruptible(Dispatchers.IO) {
|
||||
DavResource(httpClient.okHttpClient, collection.url).delete {
|
||||
// success, otherwise an exception would have been thrown → delete locally, too
|
||||
delete(collection)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun getSyncableByTopic(topic: String) = dao.getSyncableByPushTopic(topic)
|
||||
suspend fun getSyncableByTopic(topic: String) = dao.getSyncableByPushTopic(topic)
|
||||
|
||||
fun get(id: Long) = dao.get(id)
|
||||
suspend fun getAsync(id: Long) = dao.getAsync(id)
|
||||
|
||||
fun getFlow(id: Long) = dao.getFlow(id)
|
||||
|
||||
fun getByService(serviceId: Long) = dao.getByService(serviceId)
|
||||
suspend fun getByService(serviceId: Long) = dao.getByService(serviceId)
|
||||
|
||||
fun getByServiceAndUrl(serviceId: Long, url: String) = dao.getByServiceAndUrl(serviceId, url)
|
||||
|
||||
@@ -209,11 +198,12 @@ class DavCollectionRepository @Inject constructor(
|
||||
fun getSyncTaskLists(serviceId: Long) = dao.getSyncTaskLists(serviceId)
|
||||
|
||||
/** Returns all collections that are both selected for synchronization and push-capable. */
|
||||
suspend fun getPushCapableAndSyncable(): List<Collection> =
|
||||
dao.getPushCapableSyncCollections()
|
||||
suspend fun getPushCapableAndSyncable(serviceId: Long) = dao.getPushCapableSyncCollections(serviceId)
|
||||
|
||||
suspend fun getPushRegisteredAndNotSyncable(): List<Collection> =
|
||||
dao.getPushRegisteredAndNotSyncable()
|
||||
suspend fun getPushRegistered(serviceId: Long) = dao.getPushRegistered(serviceId)
|
||||
suspend fun getPushRegisteredAndNotSyncable(serviceId: Long) = dao.getPushRegisteredAndNotSyncable(serviceId)
|
||||
|
||||
suspend fun getVapidKey(serviceId: Long) = dao.getFirstVapidKey(serviceId)
|
||||
|
||||
/**
|
||||
* Inserts or updates the collection.
|
||||
@@ -245,13 +235,12 @@ class DavCollectionRepository @Inject constructor(
|
||||
*/
|
||||
fun insertOrUpdateByUrl(collection: Collection) {
|
||||
dao.insertOrUpdateByUrl(collection)
|
||||
notifyOnChangeListeners()
|
||||
}
|
||||
|
||||
fun pageByServiceAndType(serviceId: Long, type: String) =
|
||||
fun pageByServiceAndType(serviceId: Long, @CollectionType type: String) =
|
||||
dao.pageByServiceAndType(serviceId, type)
|
||||
|
||||
fun pagePersonalByServiceAndType(serviceId: Long, type: String) =
|
||||
fun pagePersonalByServiceAndType(serviceId: Long, @CollectionType type: String) =
|
||||
dao.pagePersonalByServiceAndType(serviceId, type)
|
||||
|
||||
/**
|
||||
@@ -259,7 +248,6 @@ class DavCollectionRepository @Inject constructor(
|
||||
*/
|
||||
suspend fun setForceReadOnly(id: Long, forceReadOnly: Boolean) {
|
||||
dao.updateForceReadOnly(id, forceReadOnly)
|
||||
notifyOnChangeListeners()
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -267,10 +255,9 @@ class DavCollectionRepository @Inject constructor(
|
||||
*/
|
||||
suspend fun setSync(id: Long, forceReadOnly: Boolean) {
|
||||
dao.updateSync(id, forceReadOnly)
|
||||
notifyOnChangeListeners()
|
||||
}
|
||||
|
||||
fun updatePushSubscription(id: Long, subscriptionUrl: String?, expires: Long?) {
|
||||
suspend fun updatePushSubscription(id: Long, subscriptionUrl: String?, expires: Long?) {
|
||||
dao.updatePushSubscription(
|
||||
id = id,
|
||||
pushSubscription = subscriptionUrl,
|
||||
@@ -283,24 +270,22 @@ class DavCollectionRepository @Inject constructor(
|
||||
*/
|
||||
fun delete(collection: Collection) {
|
||||
dao.delete(collection)
|
||||
notifyOnChangeListeners()
|
||||
}
|
||||
|
||||
|
||||
// helpers
|
||||
|
||||
private suspend fun createOnServer(account: Account, url: HttpUrl, method: String, xmlBody: String) {
|
||||
HttpClient.Builder(context, accountSettingsFactory.create(account))
|
||||
.setForeground(true)
|
||||
.build().use { httpClient ->
|
||||
withContext(Dispatchers.IO) {
|
||||
runInterruptible {
|
||||
DavResource(httpClient.okHttpClient, url).mkCol(
|
||||
xmlBody = xmlBody,
|
||||
method = method
|
||||
) {
|
||||
// success, otherwise an exception would have been thrown
|
||||
}
|
||||
httpClientBuilder.get()
|
||||
.fromAccount(account)
|
||||
.build()
|
||||
.use { httpClient ->
|
||||
runInterruptible(Dispatchers.IO) {
|
||||
DavResource(httpClient.okHttpClient, url).mkCol(
|
||||
xmlBody = xmlBody,
|
||||
method = method
|
||||
) {
|
||||
// success, otherwise an exception would have been thrown
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -375,7 +360,15 @@ class DavCollectionRepository @Inject constructor(
|
||||
insertTag(CalendarTimezone.NAME) {
|
||||
text(
|
||||
// spec requires "an iCalendar object with exactly one VTIMEZONE component"
|
||||
Calendar(ComponentList(listOf(vTimezone))).toString()
|
||||
Calendar(
|
||||
PropertyList<Property>().apply {
|
||||
add(ICalendar.prodId)
|
||||
add(Version.VERSION_2_0)
|
||||
},
|
||||
ComponentList(
|
||||
listOf(vTimezone)
|
||||
)
|
||||
).toString()
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -413,32 +406,4 @@ class DavCollectionRepository @Inject constructor(
|
||||
|
||||
private fun getVTimeZone(tzId: String): VTimeZone? = DateUtils.ical4jTimeZone(tzId)?.vTimeZone
|
||||
|
||||
|
||||
/*** OBSERVERS ***/
|
||||
|
||||
/**
|
||||
* Notifies registered listeners about changes in the collections.
|
||||
*/
|
||||
private fun notifyOnChangeListeners() = synchronized(listeners) {
|
||||
listeners.forEach { listener ->
|
||||
listener.onCollectionsChanged()
|
||||
}
|
||||
}
|
||||
|
||||
fun interface OnChangeListener {
|
||||
/**
|
||||
* Will be called when collections have changed. Will run in the coroutine context/thread
|
||||
* of the data-modifying method. For instance, if [delete] is called, [onCollectionsChanged]
|
||||
* will be called in the context/thread that called [delete].
|
||||
*/
|
||||
fun onCollectionsChanged()
|
||||
}
|
||||
|
||||
@Module
|
||||
@InstallIn(SingletonComponent::class)
|
||||
abstract class DavCollectionRepositoryModule {
|
||||
// Provides empty set of listeners
|
||||
@Multibinds abstract fun defaultOnChangeListeners(): Set<OnChangeListener>
|
||||
}
|
||||
|
||||
}
|
||||
@@ -15,16 +15,16 @@ class DavHomeSetRepository @Inject constructor(
|
||||
db: AppDatabase
|
||||
) {
|
||||
|
||||
val dao = db.homeSetDao()
|
||||
private val dao = db.homeSetDao()
|
||||
|
||||
fun getAddressBookHomeSetsFlow(account: Account) =
|
||||
dao.getBindableByAccountAndServiceTypeFlow(account.name, Service.TYPE_CARDDAV)
|
||||
|
||||
fun getBindableByServiceFlow(serviceId: Long) = dao.getBindableByServiceFlow(serviceId)
|
||||
|
||||
fun getById(id: Long) = dao.getById(id)
|
||||
fun getByIdBlocking(id: Long) = dao.getById(id)
|
||||
|
||||
fun getByService(serviceId: Long) = dao.getByService(serviceId)
|
||||
fun getByServiceBlocking(serviceId: Long) = dao.getByService(serviceId)
|
||||
|
||||
fun getCalendarHomeSetsFlow(account: Account) =
|
||||
dao.getBindableByAccountAndServiceTypeFlow(account.name, Service.TYPE_CALDAV)
|
||||
@@ -38,13 +38,13 @@ class DavHomeSetRepository @Inject constructor(
|
||||
* @return ID of the row, that has been inserted or updated. -1 If the insert fails due to other reasons.
|
||||
*/
|
||||
@Transaction
|
||||
fun insertOrUpdateByUrl(homeset: HomeSet): Long =
|
||||
fun insertOrUpdateByUrlBlocking(homeset: HomeSet): Long =
|
||||
dao.getByUrl(homeset.serviceId, homeset.url.toString())?.let { existingHomeset ->
|
||||
dao.update(homeset.copy(id = existingHomeset.id))
|
||||
existingHomeset.id
|
||||
} ?: dao.insert(homeset)
|
||||
|
||||
|
||||
fun delete(homeSet: HomeSet) = dao.delete(homeSet)
|
||||
fun deleteBlocking(homeSet: HomeSet) = dao.delete(homeSet)
|
||||
|
||||
}
|
||||
@@ -6,6 +6,7 @@ package at.bitfire.davdroid.repository
|
||||
|
||||
import at.bitfire.davdroid.db.AppDatabase
|
||||
import at.bitfire.davdroid.db.Service
|
||||
import at.bitfire.davdroid.db.ServiceType
|
||||
import javax.inject.Inject
|
||||
|
||||
class DavServiceRepository @Inject constructor(
|
||||
@@ -17,9 +18,12 @@ class DavServiceRepository @Inject constructor(
|
||||
|
||||
// Read
|
||||
|
||||
fun get(id: Long): Service? = dao.get(id)
|
||||
fun getBlocking(id: Long): Service? = dao.get(id)
|
||||
suspend fun get(id: Long): Service? = dao.getAsync(id)
|
||||
|
||||
fun getByAccountAndType(name: String, serviceType: String): Service? =
|
||||
suspend fun getAll(): List<Service> = dao.getAll()
|
||||
|
||||
suspend fun getByAccountAndType(name: String, @ServiceType serviceType: String): Service? =
|
||||
dao.getByAccountAndType(name, serviceType)
|
||||
|
||||
fun getCalDavServiceFlow(accountName: String) =
|
||||
@@ -31,7 +35,7 @@ class DavServiceRepository @Inject constructor(
|
||||
|
||||
// Create & update
|
||||
|
||||
fun insertOrReplace(service: Service) =
|
||||
fun insertOrReplaceBlocking(service: Service) =
|
||||
dao.insertOrReplace(service)
|
||||
|
||||
suspend fun renameAccount(oldName: String, newName: String) =
|
||||
@@ -40,7 +44,7 @@ class DavServiceRepository @Inject constructor(
|
||||
|
||||
// Delete
|
||||
|
||||
fun deleteAll() = dao.deleteAll()
|
||||
fun deleteAllBlocking() = dao.deleteAll()
|
||||
|
||||
suspend fun deleteByAccount(accountName: String) =
|
||||
dao.deleteByAccount(accountName)
|
||||
|
||||
@@ -5,26 +5,24 @@
|
||||
package at.bitfire.davdroid.repository
|
||||
|
||||
import android.content.Context
|
||||
import android.content.pm.PackageManager
|
||||
import at.bitfire.davdroid.db.AppDatabase
|
||||
import at.bitfire.davdroid.db.SyncStats
|
||||
import at.bitfire.davdroid.sync.SyncDataType
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import java.text.Collator
|
||||
import java.util.logging.Logger
|
||||
import javax.inject.Inject
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.map
|
||||
import java.text.Collator
|
||||
import javax.inject.Inject
|
||||
|
||||
class DavSyncStatsRepository @Inject constructor(
|
||||
@ApplicationContext val context: Context,
|
||||
db: AppDatabase,
|
||||
private val logger: Logger
|
||||
db: AppDatabase
|
||||
) {
|
||||
|
||||
private val dao = db.syncStatsDao()
|
||||
|
||||
data class LastSynced(
|
||||
val appName: String,
|
||||
val dataType: String,
|
||||
val lastSynced: Long
|
||||
)
|
||||
fun getLastSyncedFlow(collectionId: Long): Flow<List<LastSynced>> =
|
||||
@@ -32,46 +30,21 @@ class DavSyncStatsRepository @Inject constructor(
|
||||
val collator = Collator.getInstance()
|
||||
list.map { stats ->
|
||||
LastSynced(
|
||||
appName = appNameFromAuthority(stats.authority),
|
||||
dataType = stats.dataType,
|
||||
lastSynced = stats.lastSync
|
||||
)
|
||||
}.sortedWith { a, b ->
|
||||
collator.compare(a.appName, b.appName)
|
||||
collator.compare(a.dataType, b.dataType)
|
||||
}
|
||||
}
|
||||
|
||||
fun logSyncTime(collectionId: Long, authority: String, lastSync: Long = System.currentTimeMillis()) {
|
||||
suspend fun logSyncTime(collectionId: Long, dataType: SyncDataType, lastSync: Long = System.currentTimeMillis()) {
|
||||
dao.insertOrReplace(SyncStats(
|
||||
id = 0,
|
||||
collectionId = collectionId,
|
||||
authority = authority,
|
||||
dataType = dataType.name,
|
||||
lastSync = lastSync
|
||||
))
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Tries to find the application name for given authority. Returns the authority if not
|
||||
* found.
|
||||
*
|
||||
* @param authority authority to find the application name for (ie "at.techbee.jtx")
|
||||
* @return the application name of authority (ie "jtx Board")
|
||||
*/
|
||||
private fun appNameFromAuthority(authority: String): String {
|
||||
val packageManager = context.packageManager
|
||||
val packageName = packageManager.resolveContentProvider(authority, 0)?.packageName ?: authority
|
||||
return try {
|
||||
val appInfo = packageManager.getPackageInfo(packageName, 0).applicationInfo
|
||||
if (appInfo != null) {
|
||||
packageManager.getApplicationLabel(appInfo).toString()
|
||||
} else {
|
||||
logger.warning("Package name ($packageName) not found for authority: $authority")
|
||||
authority
|
||||
}
|
||||
} catch (e: PackageManager.NameNotFoundException) {
|
||||
logger.warning("Application name not found for authority: $authority")
|
||||
authority
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -6,6 +6,7 @@ package at.bitfire.davdroid.repository
|
||||
|
||||
import android.content.Context
|
||||
import android.content.SharedPreferences.OnSharedPreferenceChangeListener
|
||||
import androidx.core.content.edit
|
||||
import androidx.preference.PreferenceManager
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import kotlinx.coroutines.channels.awaitClose
|
||||
@@ -24,7 +25,6 @@ class PreferenceRepository @Inject constructor(
|
||||
|
||||
companion object {
|
||||
const val LOG_TO_FILE = "log_to_file"
|
||||
const val UNIFIED_PUSH_ENDPOINT = "unified_push_endpoint"
|
||||
}
|
||||
|
||||
private val preferences = PreferenceManager.getDefaultSharedPreferences(context)
|
||||
@@ -34,10 +34,9 @@ class PreferenceRepository @Inject constructor(
|
||||
* Updates the "log to file" (verbose logging") preference.
|
||||
*/
|
||||
fun logToFile(logToFile: Boolean) {
|
||||
preferences
|
||||
.edit()
|
||||
.putBoolean(LOG_TO_FILE, logToFile)
|
||||
.apply()
|
||||
preferences.edit {
|
||||
putBoolean(LOG_TO_FILE, logToFile)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -54,21 +53,6 @@ class PreferenceRepository @Inject constructor(
|
||||
}
|
||||
|
||||
|
||||
fun unifiedPushEndpoint() =
|
||||
preferences.getString(UNIFIED_PUSH_ENDPOINT, null)
|
||||
|
||||
fun unifiedPushEndpointFlow() = observeAsFlow(UNIFIED_PUSH_ENDPOINT) {
|
||||
unifiedPushEndpoint()
|
||||
}
|
||||
|
||||
fun unifiedPushEndpoint(endpoint: String?) {
|
||||
preferences
|
||||
.edit()
|
||||
.putString(UNIFIED_PUSH_ENDPOINT, endpoint)
|
||||
.apply()
|
||||
}
|
||||
|
||||
|
||||
// helpers
|
||||
|
||||
private fun<T> observeAsFlow(keyToObserve: String, getValue: () -> T): Flow<T> =
|
||||
|
||||
@@ -14,6 +14,6 @@ class PrincipalRepository @Inject constructor(
|
||||
|
||||
private val dao = db.principalDao()
|
||||
|
||||
fun get(id: Long): Principal = dao.get(id)
|
||||
fun getBlocking(id: Long): Principal = dao.get(id)
|
||||
|
||||
}
|
||||
@@ -20,6 +20,7 @@ import at.bitfire.davdroid.R
|
||||
import at.bitfire.davdroid.db.SyncState
|
||||
import at.bitfire.davdroid.repository.DavCollectionRepository
|
||||
import at.bitfire.davdroid.repository.DavServiceRepository
|
||||
import at.bitfire.davdroid.resource.LocalAddressBook.Companion.USER_DATA_READ_ONLY
|
||||
import at.bitfire.davdroid.resource.workaround.ContactDirtyVerifier
|
||||
import at.bitfire.davdroid.settings.AccountSettings
|
||||
import at.bitfire.davdroid.sync.SyncFrameworkIntegration
|
||||
@@ -40,11 +41,11 @@ import java.util.logging.Level
|
||||
import java.util.logging.Logger
|
||||
|
||||
/**
|
||||
* A local address book. Requires an own Android account, because Android manages contacts per
|
||||
* A local address book. Requires its own Android account, because Android manages contacts per
|
||||
* account and there is no such thing as "address books". So, DAVx5 creates a "DAVx5
|
||||
* address book" account for every CardDAV address book.
|
||||
*
|
||||
* @param account TODO
|
||||
* @param account DAVx5 account which "owns" this address book
|
||||
* @param _addressBookAccount Address book account (not: DAVx5 account) storing the actual Android
|
||||
* contacts. This is the initial value of [addressBookAccount]. However when the address book is renamed,
|
||||
* the new name will only be available in [addressBookAccount], so usually that one should be used.
|
||||
@@ -79,6 +80,8 @@ open class LocalAddressBook @AssistedInject constructor(
|
||||
override val title
|
||||
get() = addressBookAccount.name
|
||||
|
||||
private val accountManager by lazy { AccountManager.get(context) }
|
||||
|
||||
/**
|
||||
* Whether contact groups ([LocalGroup]) are included in query results
|
||||
* and are affected by updates/deletes on generic members.
|
||||
@@ -87,10 +90,9 @@ open class LocalAddressBook @AssistedInject constructor(
|
||||
* but if it is enabled, [findDirty] will find dirty [LocalContact]s and [LocalGroup]s.
|
||||
*/
|
||||
open val groupMethod: GroupMethod by lazy {
|
||||
val manager = AccountManager.get(context)
|
||||
val account = manager.getUserData(addressBookAccount, USER_DATA_COLLECTION_ID)?.toLongOrNull()?.let { collectionId ->
|
||||
val account = accountManager.getUserData(addressBookAccount, USER_DATA_COLLECTION_ID)?.toLongOrNull()?.let { collectionId ->
|
||||
collectionRepository.get(collectionId)?.let { collection ->
|
||||
serviceRepository.get(collection.serviceId)?.let { service ->
|
||||
serviceRepository.getBlocking(collection.serviceId)?.let { service ->
|
||||
Account(service.accountName, context.getString(R.string.account_type))
|
||||
}
|
||||
}
|
||||
@@ -103,11 +105,11 @@ open class LocalAddressBook @AssistedInject constructor(
|
||||
val includeGroups
|
||||
get() = groupMethod == GroupMethod.GROUP_VCARDS
|
||||
|
||||
@Deprecated("Local collection should be identified by ID, not by URL")
|
||||
override var collectionUrl: String
|
||||
get() = AccountManager.get(context).getUserData(addressBookAccount, USER_DATA_URL)
|
||||
?: throw IllegalStateException("Address book has no URL")
|
||||
set(url) = AccountManager.get(context).setAndVerifyUserData(addressBookAccount, USER_DATA_URL, url)
|
||||
override var dbCollectionId: Long?
|
||||
get() = accountManager.getUserData(addressBookAccount, USER_DATA_COLLECTION_ID)?.toLongOrNull()
|
||||
set(id) {
|
||||
accountManager.setAndVerifyUserData(addressBookAccount, USER_DATA_COLLECTION_ID, id.toString())
|
||||
}
|
||||
|
||||
/**
|
||||
* Read-only flag for the address book itself.
|
||||
@@ -122,10 +124,10 @@ open class LocalAddressBook @AssistedInject constructor(
|
||||
* Reading this flag returns the stored value from [USER_DATA_READ_ONLY].
|
||||
*/
|
||||
override var readOnly: Boolean
|
||||
get() = AccountManager.get(context).getUserData(addressBookAccount, USER_DATA_READ_ONLY) != null
|
||||
get() = accountManager.getUserData(addressBookAccount, USER_DATA_READ_ONLY) != null
|
||||
set(readOnly) {
|
||||
// set read-only flag for address book itself
|
||||
AccountManager.get(context).setAndVerifyUserData(addressBookAccount, USER_DATA_READ_ONLY, if (readOnly) "1" else null)
|
||||
accountManager.setAndVerifyUserData(addressBookAccount, USER_DATA_READ_ONLY, if (readOnly) "1" else null)
|
||||
|
||||
// update raw contacts
|
||||
val rawContactValues = contentValuesOf(RawContacts.RAW_CONTACT_IS_READ_ONLY to if (readOnly) 1 else 0)
|
||||
@@ -213,7 +215,6 @@ open class LocalAddressBook @AssistedInject constructor(
|
||||
addressBookAccount = newAccount
|
||||
|
||||
// delete old account
|
||||
val accountManager = AccountManager.get(context)
|
||||
accountManager.removeAccountExplicitly(oldAccount)
|
||||
|
||||
return true
|
||||
@@ -336,14 +337,6 @@ open class LocalAddressBook @AssistedInject constructor(
|
||||
const val USER_DATA_ACCOUNT_NAME = "account_name"
|
||||
const val USER_DATA_ACCOUNT_TYPE = "account_type"
|
||||
|
||||
/**
|
||||
* URL of the corresponding CardDAV address book.
|
||||
*
|
||||
* User data of the address book account (String).
|
||||
*/
|
||||
@Deprecated("Use the URL of the DB collection instead")
|
||||
const val USER_DATA_URL = "url"
|
||||
|
||||
/**
|
||||
* ID of the corresponding database [at.bitfire.davdroid.db.Collection].
|
||||
*
|
||||
|
||||
@@ -21,6 +21,7 @@ import at.bitfire.davdroid.settings.SettingsManager
|
||||
import at.bitfire.davdroid.sync.account.SystemAccountUtils
|
||||
import at.bitfire.davdroid.sync.account.setAndVerifyUserData
|
||||
import at.bitfire.davdroid.util.DavUtils.lastSegment
|
||||
import com.google.common.base.CharMatcher
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import java.util.logging.Level
|
||||
import java.util.logging.Logger
|
||||
@@ -34,6 +35,9 @@ class LocalAddressBookStore @Inject constructor(
|
||||
private val settings: SettingsManager
|
||||
): LocalDataStore<LocalAddressBook> {
|
||||
|
||||
override val authority: String
|
||||
get() = ContactsContract.AUTHORITY
|
||||
|
||||
/** whether a (usually managed) setting wants all address-books to be read-only **/
|
||||
val forceAllReadOnly: Boolean
|
||||
get() = settings.getBoolean(Settings.FORCE_READ_ONLY_ADDRESSBOOKS)
|
||||
@@ -44,22 +48,25 @@ class LocalAddressBookStore @Inject constructor(
|
||||
*
|
||||
* The address book account name contains
|
||||
*
|
||||
* - the collection display name or last URL path segment
|
||||
* - the collection display name or last URL path segment (filtered for dangerous special characters)
|
||||
* - the actual account name
|
||||
* - the collection ID, to make it unique.
|
||||
*
|
||||
* @param info Collection to take info from
|
||||
*/
|
||||
fun accountName(info: Collection): String {
|
||||
// Name the address book after given collection display name, otherwise use last URL path segment
|
||||
val sb = StringBuilder(info.displayName.let {
|
||||
if (it.isNullOrEmpty())
|
||||
info.url.lastSegment
|
||||
else
|
||||
it
|
||||
})
|
||||
// Name of address book is given collection display name, otherwise the last URL path segment
|
||||
var name = info.displayName.takeIf { !it.isNullOrEmpty() } ?: info.url.lastSegment
|
||||
|
||||
// Remove ISO control characters + SQL problematic characters
|
||||
name = CharMatcher
|
||||
.javaIsoControl()
|
||||
.or(CharMatcher.anyOf("`'\""))
|
||||
.removeFrom(name)
|
||||
|
||||
// Add the actual account name to the address book account name
|
||||
serviceRepository.get(info.serviceId)?.let { service ->
|
||||
val sb = StringBuilder(name)
|
||||
serviceRepository.getBlocking(info.serviceId)?.let { service ->
|
||||
sb.append(" (${service.accountName})")
|
||||
}
|
||||
// Add the collection ID for uniqueness
|
||||
@@ -67,17 +74,18 @@ class LocalAddressBookStore @Inject constructor(
|
||||
return sb.toString()
|
||||
}
|
||||
|
||||
override fun acquireContentProvider() =
|
||||
context.contentResolver.acquireContentProviderClient(authority)
|
||||
|
||||
override fun create(provider: ContentProviderClient, fromCollection: Collection): LocalAddressBook? {
|
||||
val service = serviceRepository.get(fromCollection.serviceId) ?: throw IllegalArgumentException("Couldn't fetch DB service from collection")
|
||||
val service = serviceRepository.getBlocking(fromCollection.serviceId) ?: throw IllegalArgumentException("Couldn't fetch DB service from collection")
|
||||
val account = Account(service.accountName, context.getString(R.string.account_type))
|
||||
|
||||
val name = accountName(fromCollection)
|
||||
val addressBookAccount = createAddressBookAccount(
|
||||
account = account,
|
||||
name = name,
|
||||
id = fromCollection.id,
|
||||
url = fromCollection.url.toString()
|
||||
id = fromCollection.id
|
||||
) ?: return null
|
||||
|
||||
val addressBook = localAddressBookFactory.create(account, addressBookAccount, provider)
|
||||
@@ -91,14 +99,13 @@ class LocalAddressBookStore @Inject constructor(
|
||||
}
|
||||
|
||||
@OpenForTesting
|
||||
internal fun createAddressBookAccount(account: Account, name: String, id: Long, url: String): Account? {
|
||||
internal fun createAddressBookAccount(account: Account, name: String, id: Long): Account? {
|
||||
// create address book account with reference to account, collection ID and URL
|
||||
val addressBookAccount = Account(name, context.getString(R.string.account_type_address_book))
|
||||
val userData = bundleOf(
|
||||
LocalAddressBook.USER_DATA_ACCOUNT_NAME to account.name,
|
||||
LocalAddressBook.USER_DATA_ACCOUNT_TYPE to account.type,
|
||||
LocalAddressBook.USER_DATA_COLLECTION_ID to id.toString(),
|
||||
LocalAddressBook.USER_DATA_URL to url
|
||||
LocalAddressBook.USER_DATA_COLLECTION_ID to id.toString()
|
||||
)
|
||||
if (!SystemAccountUtils.createAccount(context, addressBookAccount, userData)) {
|
||||
logger.warning("Couldn't create address book account: $addressBookAccount")
|
||||
@@ -108,7 +115,6 @@ class LocalAddressBookStore @Inject constructor(
|
||||
return addressBookAccount
|
||||
}
|
||||
|
||||
|
||||
override fun getAll(account: Account, provider: ContentProviderClient): List<LocalAddressBook> {
|
||||
val accountManager = AccountManager.get(context)
|
||||
return accountManager.getAccountsByType(context.getString(R.string.account_type_address_book))
|
||||
@@ -121,7 +127,6 @@ class LocalAddressBookStore @Inject constructor(
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
override fun update(provider: ContentProviderClient, localCollection: LocalAddressBook, fromCollection: Collection) {
|
||||
var currentAccount = localCollection.addressBookAccount
|
||||
logger.log(Level.INFO, "Updating local address book $currentAccount from collection $fromCollection")
|
||||
@@ -139,7 +144,6 @@ class LocalAddressBookStore @Inject constructor(
|
||||
accountManager.setAndVerifyUserData(currentAccount, LocalAddressBook.USER_DATA_ACCOUNT_NAME, localCollection.account.name)
|
||||
accountManager.setAndVerifyUserData(currentAccount, LocalAddressBook.USER_DATA_ACCOUNT_TYPE, localCollection.account.type)
|
||||
accountManager.setAndVerifyUserData(currentAccount, LocalAddressBook.USER_DATA_COLLECTION_ID, fromCollection.id.toString())
|
||||
accountManager.setAndVerifyUserData(currentAccount, LocalAddressBook.USER_DATA_URL, fromCollection.url.toString())
|
||||
|
||||
// Set contacts provider settings
|
||||
localCollection.settings = contactsProviderSettings
|
||||
@@ -174,7 +178,6 @@ class LocalAddressBookStore @Inject constructor(
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
override fun delete(localCollection: LocalAddressBook) {
|
||||
val accountManager = AccountManager.get(context)
|
||||
accountManager.removeAccountExplicitly(localCollection.addressBookAccount)
|
||||
|
||||
@@ -23,7 +23,7 @@ import java.util.logging.Logger
|
||||
/**
|
||||
* Application-specific subclass of [AndroidCalendar] for local calendars.
|
||||
*
|
||||
* [Calendars.NAME] is used to store the calendar URL.
|
||||
* [Calendars._SYNC_ID] corresponds to the database collection ID ([at.bitfire.davdroid.db.Collection.id]).
|
||||
*/
|
||||
class LocalCalendar private constructor(
|
||||
account: Account,
|
||||
@@ -40,8 +40,8 @@ class LocalCalendar private constructor(
|
||||
|
||||
}
|
||||
|
||||
override val collectionUrl: String?
|
||||
get() = name
|
||||
override val dbCollectionId: Long?
|
||||
get() = syncId?.toLongOrNull()
|
||||
|
||||
override val tag: String
|
||||
get() = "events-${account.name}-$id"
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user