mirror of
https://github.com/bitfireAT/davx5-ose.git
synced 2026-01-10 07:47:50 -05:00
Compare commits
87 Commits
debug-buil
...
migrate-in
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
958be01a79 | ||
|
|
597a3d293e | ||
|
|
0e59334e68 | ||
|
|
d8aad544ff | ||
|
|
5b935263d3 | ||
|
|
f3333b7b54 | ||
|
|
013cb915fd | ||
|
|
226560230d | ||
|
|
6a08497b3a | ||
|
|
356183084f | ||
|
|
5ea7273c94 | ||
|
|
843013a0f0 | ||
|
|
ac8de37b6f | ||
|
|
62dc374774 | ||
|
|
1f83e1bf12 | ||
|
|
4c9b67a9e5 | ||
|
|
754c971fb9 | ||
|
|
282f1d1db6 | ||
|
|
4cbe03b351 | ||
|
|
52fde0c9f7 | ||
|
|
97478fb7a3 | ||
|
|
bdae74189b | ||
|
|
b2785bc296 | ||
|
|
0d9be98547 | ||
|
|
b8b38b600a | ||
|
|
a544e53267 | ||
|
|
a02559ca9a | ||
|
|
ddf881a504 | ||
|
|
777b419a60 | ||
|
|
365f87991a | ||
|
|
77a795dfe5 | ||
|
|
794007fa38 | ||
|
|
1e17e1883b | ||
|
|
48ecb5e008 | ||
|
|
f503ce5ff6 | ||
|
|
98578feeb2 | ||
|
|
0762cc6c27 | ||
|
|
b267291e93 | ||
|
|
eb8db47cea | ||
|
|
7384feeafb | ||
|
|
d10add8367 | ||
|
|
51bd163069 | ||
|
|
90280066ee | ||
|
|
03a52e96ad | ||
|
|
5890b3cc5e | ||
|
|
a02bc56b44 | ||
|
|
4939c9fc4d | ||
|
|
c2524b085e | ||
|
|
d892dd2b9c | ||
|
|
95ebce5722 | ||
|
|
4b2f032a57 | ||
|
|
bc596edfb3 | ||
|
|
e18534ab9f | ||
|
|
9ae03dbc6f | ||
|
|
042dd3fba2 | ||
|
|
5d6959c47e | ||
|
|
239038ab77 | ||
|
|
7097bf9523 | ||
|
|
53bc5a6641 | ||
|
|
9e060f6651 | ||
|
|
cc8fc4734f | ||
|
|
0733fef213 | ||
|
|
f977cc01eb | ||
|
|
30dc2cb221 | ||
|
|
fc6b605693 | ||
|
|
4f1176fd99 | ||
|
|
4ff7ff8746 | ||
|
|
2f26c6c365 | ||
|
|
d8bff41bc4 | ||
|
|
878e2bb3ad | ||
|
|
dc1c72cdd3 | ||
|
|
1dc7f3de64 | ||
|
|
d20c613044 | ||
|
|
fe8eabce1b | ||
|
|
b5790bfd09 | ||
|
|
9e7de1c8ca | ||
|
|
15d2072f16 | ||
|
|
0f4e48ad4d | ||
|
|
41075e442c | ||
|
|
3a16b5ca3f | ||
|
|
32925dc18b | ||
|
|
cf15dd3e0e | ||
|
|
3317a8d355 | ||
|
|
154d1e6bc8 | ||
|
|
1a19d5cd17 | ||
|
|
b721e83377 | ||
|
|
f69533b049 |
2
.github/workflows/codeql.yml
vendored
2
.github/workflows/codeql.yml
vendored
@@ -33,7 +33,7 @@ jobs:
|
||||
with:
|
||||
distribution: temurin
|
||||
java-version: 21
|
||||
- uses: gradle/actions/setup-gradle@v3
|
||||
- uses: gradle/actions/setup-gradle@v4
|
||||
with:
|
||||
cache-encryption-key: ${{ secrets.gradle_encryption_key }}
|
||||
cache-read-only: true # gradle user home cache is generated by test jobs
|
||||
|
||||
2
.github/workflows/release.yml
vendored
2
.github/workflows/release.yml
vendored
@@ -24,7 +24,7 @@ jobs:
|
||||
with:
|
||||
distribution: temurin
|
||||
java-version: 21
|
||||
- uses: gradle/actions/setup-gradle@v3
|
||||
- uses: gradle/actions/setup-gradle@v4
|
||||
|
||||
- name: Prepare keystore
|
||||
run: echo ${{ secrets.android_keystore_base64 }} | base64 -d >$GITHUB_WORKSPACE/keystore.jks
|
||||
|
||||
6
.github/workflows/test-dev.yml
vendored
6
.github/workflows/test-dev.yml
vendored
@@ -21,7 +21,7 @@ jobs:
|
||||
java-version: 21
|
||||
|
||||
# See https://community.gradle.org/github-actions/docs/setup-gradle/ for more information
|
||||
- uses: gradle/actions/setup-gradle@v3 # creates build cache when on main branch
|
||||
- uses: gradle/actions/setup-gradle@v4 # creates build cache when on main branch
|
||||
with:
|
||||
cache-encryption-key: ${{ secrets.gradle_encryption_key }}
|
||||
dependency-graph: generate-and-submit # submit Github Dependency Graph info
|
||||
@@ -39,7 +39,7 @@ jobs:
|
||||
with:
|
||||
distribution: temurin
|
||||
java-version: 21
|
||||
- uses: gradle/actions/setup-gradle@v3
|
||||
- uses: gradle/actions/setup-gradle@v4
|
||||
with:
|
||||
cache-encryption-key: ${{ secrets.gradle_encryption_key }}
|
||||
cache-read-only: true
|
||||
@@ -60,7 +60,7 @@ jobs:
|
||||
with:
|
||||
distribution: temurin
|
||||
java-version: 21
|
||||
- uses: gradle/actions/setup-gradle@v3
|
||||
- uses: gradle/actions/setup-gradle@v4
|
||||
with:
|
||||
cache-encryption-key: ${{ secrets.gradle_encryption_key }}
|
||||
cache-read-only: true
|
||||
|
||||
6
.idea/copyright/LICENSE.xml
generated
Normal file
6
.idea/copyright/LICENSE.xml
generated
Normal file
@@ -0,0 +1,6 @@
|
||||
<component name="CopyrightManager">
|
||||
<copyright>
|
||||
<option name="notice" value="Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details." />
|
||||
<option name="myName" value="LICENSE" />
|
||||
</copyright>
|
||||
</component>
|
||||
3
.idea/copyright/profiles_settings.xml
generated
Normal file
3
.idea/copyright/profiles_settings.xml
generated
Normal file
@@ -0,0 +1,3 @@
|
||||
<component name="CopyrightManager">
|
||||
<settings default="LICENSE" />
|
||||
</component>
|
||||
14
AUTHORS
14
AUTHORS
@@ -1,11 +1,7 @@
|
||||
# This is the list of significant contributors to DAVx5.
|
||||
#
|
||||
# This does not necessarily list everyone who has contributed work.
|
||||
# To see the full list of contributors, see the revision history in
|
||||
# source control.
|
||||
You can view the list of people who have contributed to the code base in the version control history:
|
||||
https://github.com/bitfireAT/davx5-ose/graphs/contributors
|
||||
|
||||
Ricki Hirner (bitfire.at)
|
||||
Bernhard Stockmann (bitfire.at)
|
||||
Translators are not mentioned in the history explicitly.
|
||||
The list of translators can be found in the About screen.
|
||||
|
||||
Sunik Kupfer (bitfire.at)
|
||||
Patrick Lang (techbee.at)
|
||||
Every contribution is welcome. There are many other forms of contributing besides writing code!
|
||||
|
||||
@@ -8,6 +8,7 @@ plugins {
|
||||
alias(libs.plugins.compose.compiler)
|
||||
alias(libs.plugins.hilt)
|
||||
alias(libs.plugins.kotlin.android)
|
||||
alias(libs.plugins.kotlin.serialization)
|
||||
alias(libs.plugins.ksp)
|
||||
}
|
||||
|
||||
@@ -18,8 +19,8 @@ android {
|
||||
defaultConfig {
|
||||
applicationId = "at.bitfire.davdroid"
|
||||
|
||||
versionCode = 404030200
|
||||
versionName = "4.4.3.2"
|
||||
versionCode = 404060001
|
||||
versionName = "4.4.6-beta.1"
|
||||
|
||||
setProperty("archivesBaseName", "davx5-ose-$versionName")
|
||||
|
||||
@@ -82,9 +83,6 @@ android {
|
||||
|
||||
signingConfig = signingConfigs.findByName("bitfire")
|
||||
}
|
||||
getByName("debug") {
|
||||
applicationIdSuffix = ".debug"
|
||||
}
|
||||
}
|
||||
|
||||
lint {
|
||||
@@ -166,6 +164,7 @@ dependencies {
|
||||
implementation(platform(libs.compose.bom))
|
||||
implementation(libs.compose.material3)
|
||||
implementation(libs.compose.materialIconsExtended)
|
||||
implementation(libs.compose.navigation)
|
||||
implementation(libs.compose.runtime.livedata)
|
||||
debugImplementation(libs.compose.ui.tooling)
|
||||
implementation(libs.compose.ui.toolingPreview)
|
||||
@@ -192,6 +191,7 @@ dependencies {
|
||||
@Suppress("RedundantSuppression")
|
||||
implementation(libs.dnsjava)
|
||||
implementation(libs.guava)
|
||||
implementation(libs.kotlinx.serialization)
|
||||
implementation(libs.mikepenz.aboutLibraries)
|
||||
implementation(libs.nsk90.kstatemachine)
|
||||
implementation(libs.okhttp.base)
|
||||
|
||||
675
app/schemas/at.bitfire.davdroid.db.AppDatabase/15.json
Normal file
675
app/schemas/at.bitfire.davdroid.db.AppDatabase/15.json
Normal file
@@ -0,0 +1,675 @@
|
||||
{
|
||||
"formatVersion": 1,
|
||||
"database": {
|
||||
"version": 15,
|
||||
"identityHash": "ab1cb6057d8e050f6648bea46ae0943d",
|
||||
"entities": [
|
||||
{
|
||||
"tableName": "service",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `accountName` TEXT NOT NULL, `type` TEXT NOT NULL, `principal` TEXT)",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "id",
|
||||
"columnName": "id",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "accountName",
|
||||
"columnName": "accountName",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "type",
|
||||
"columnName": "type",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "principal",
|
||||
"columnName": "principal",
|
||||
"affinity": "TEXT",
|
||||
"notNull": false
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"autoGenerate": true,
|
||||
"columnNames": [
|
||||
"id"
|
||||
]
|
||||
},
|
||||
"indices": [
|
||||
{
|
||||
"name": "index_service_accountName_type",
|
||||
"unique": true,
|
||||
"columnNames": [
|
||||
"accountName",
|
||||
"type"
|
||||
],
|
||||
"orders": [],
|
||||
"createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_service_accountName_type` ON `${TABLE_NAME}` (`accountName`, `type`)"
|
||||
}
|
||||
],
|
||||
"foreignKeys": []
|
||||
},
|
||||
{
|
||||
"tableName": "homeset",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `serviceId` INTEGER NOT NULL, `personal` INTEGER NOT NULL, `url` TEXT NOT NULL, `privBind` INTEGER NOT NULL, `displayName` TEXT, FOREIGN KEY(`serviceId`) REFERENCES `service`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "id",
|
||||
"columnName": "id",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "serviceId",
|
||||
"columnName": "serviceId",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "personal",
|
||||
"columnName": "personal",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "url",
|
||||
"columnName": "url",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "privBind",
|
||||
"columnName": "privBind",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "displayName",
|
||||
"columnName": "displayName",
|
||||
"affinity": "TEXT",
|
||||
"notNull": false
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"autoGenerate": true,
|
||||
"columnNames": [
|
||||
"id"
|
||||
]
|
||||
},
|
||||
"indices": [
|
||||
{
|
||||
"name": "index_homeset_serviceId_url",
|
||||
"unique": true,
|
||||
"columnNames": [
|
||||
"serviceId",
|
||||
"url"
|
||||
],
|
||||
"orders": [],
|
||||
"createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_homeset_serviceId_url` ON `${TABLE_NAME}` (`serviceId`, `url`)"
|
||||
}
|
||||
],
|
||||
"foreignKeys": [
|
||||
{
|
||||
"table": "service",
|
||||
"onDelete": "CASCADE",
|
||||
"onUpdate": "NO ACTION",
|
||||
"columns": [
|
||||
"serviceId"
|
||||
],
|
||||
"referencedColumns": [
|
||||
"id"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"tableName": "collection",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `serviceId` INTEGER NOT NULL, `homeSetId` INTEGER, `ownerId` INTEGER, `type` TEXT NOT NULL, `url` TEXT NOT NULL, `privWriteContent` INTEGER NOT NULL, `privUnbind` INTEGER NOT NULL, `forceReadOnly` INTEGER NOT NULL, `displayName` TEXT, `description` TEXT, `color` INTEGER, `timezone` TEXT, `supportsVEVENT` INTEGER, `supportsVTODO` INTEGER, `supportsVJOURNAL` INTEGER, `source` TEXT, `sync` INTEGER NOT NULL, `pushTopic` TEXT, `supportsWebPush` INTEGER NOT NULL DEFAULT 0, `pushSubscription` TEXT, `pushSubscriptionExpires` INTEGER, `pushSubscriptionCreated` INTEGER, FOREIGN KEY(`serviceId`) REFERENCES `service`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`homeSetId`) REFERENCES `homeset`(`id`) ON UPDATE NO ACTION ON DELETE SET NULL , FOREIGN KEY(`ownerId`) REFERENCES `principal`(`id`) ON UPDATE NO ACTION ON DELETE SET NULL )",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "id",
|
||||
"columnName": "id",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "serviceId",
|
||||
"columnName": "serviceId",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "homeSetId",
|
||||
"columnName": "homeSetId",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "ownerId",
|
||||
"columnName": "ownerId",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "type",
|
||||
"columnName": "type",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "url",
|
||||
"columnName": "url",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "privWriteContent",
|
||||
"columnName": "privWriteContent",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "privUnbind",
|
||||
"columnName": "privUnbind",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "forceReadOnly",
|
||||
"columnName": "forceReadOnly",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "displayName",
|
||||
"columnName": "displayName",
|
||||
"affinity": "TEXT",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "description",
|
||||
"columnName": "description",
|
||||
"affinity": "TEXT",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "color",
|
||||
"columnName": "color",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "timezone",
|
||||
"columnName": "timezone",
|
||||
"affinity": "TEXT",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "supportsVEVENT",
|
||||
"columnName": "supportsVEVENT",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "supportsVTODO",
|
||||
"columnName": "supportsVTODO",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "supportsVJOURNAL",
|
||||
"columnName": "supportsVJOURNAL",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "source",
|
||||
"columnName": "source",
|
||||
"affinity": "TEXT",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "sync",
|
||||
"columnName": "sync",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "pushTopic",
|
||||
"columnName": "pushTopic",
|
||||
"affinity": "TEXT",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "supportsWebPush",
|
||||
"columnName": "supportsWebPush",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true,
|
||||
"defaultValue": "0"
|
||||
},
|
||||
{
|
||||
"fieldPath": "pushSubscription",
|
||||
"columnName": "pushSubscription",
|
||||
"affinity": "TEXT",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "pushSubscriptionExpires",
|
||||
"columnName": "pushSubscriptionExpires",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "pushSubscriptionCreated",
|
||||
"columnName": "pushSubscriptionCreated",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": false
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"autoGenerate": true,
|
||||
"columnNames": [
|
||||
"id"
|
||||
]
|
||||
},
|
||||
"indices": [
|
||||
{
|
||||
"name": "index_collection_serviceId_type",
|
||||
"unique": false,
|
||||
"columnNames": [
|
||||
"serviceId",
|
||||
"type"
|
||||
],
|
||||
"orders": [],
|
||||
"createSql": "CREATE INDEX IF NOT EXISTS `index_collection_serviceId_type` ON `${TABLE_NAME}` (`serviceId`, `type`)"
|
||||
},
|
||||
{
|
||||
"name": "index_collection_homeSetId_type",
|
||||
"unique": false,
|
||||
"columnNames": [
|
||||
"homeSetId",
|
||||
"type"
|
||||
],
|
||||
"orders": [],
|
||||
"createSql": "CREATE INDEX IF NOT EXISTS `index_collection_homeSetId_type` ON `${TABLE_NAME}` (`homeSetId`, `type`)"
|
||||
},
|
||||
{
|
||||
"name": "index_collection_ownerId_type",
|
||||
"unique": false,
|
||||
"columnNames": [
|
||||
"ownerId",
|
||||
"type"
|
||||
],
|
||||
"orders": [],
|
||||
"createSql": "CREATE INDEX IF NOT EXISTS `index_collection_ownerId_type` ON `${TABLE_NAME}` (`ownerId`, `type`)"
|
||||
},
|
||||
{
|
||||
"name": "index_collection_pushTopic_type",
|
||||
"unique": false,
|
||||
"columnNames": [
|
||||
"pushTopic",
|
||||
"type"
|
||||
],
|
||||
"orders": [],
|
||||
"createSql": "CREATE INDEX IF NOT EXISTS `index_collection_pushTopic_type` ON `${TABLE_NAME}` (`pushTopic`, `type`)"
|
||||
},
|
||||
{
|
||||
"name": "index_collection_url",
|
||||
"unique": false,
|
||||
"columnNames": [
|
||||
"url"
|
||||
],
|
||||
"orders": [],
|
||||
"createSql": "CREATE INDEX IF NOT EXISTS `index_collection_url` ON `${TABLE_NAME}` (`url`)"
|
||||
}
|
||||
],
|
||||
"foreignKeys": [
|
||||
{
|
||||
"table": "service",
|
||||
"onDelete": "CASCADE",
|
||||
"onUpdate": "NO ACTION",
|
||||
"columns": [
|
||||
"serviceId"
|
||||
],
|
||||
"referencedColumns": [
|
||||
"id"
|
||||
]
|
||||
},
|
||||
{
|
||||
"table": "homeset",
|
||||
"onDelete": "SET NULL",
|
||||
"onUpdate": "NO ACTION",
|
||||
"columns": [
|
||||
"homeSetId"
|
||||
],
|
||||
"referencedColumns": [
|
||||
"id"
|
||||
]
|
||||
},
|
||||
{
|
||||
"table": "principal",
|
||||
"onDelete": "SET NULL",
|
||||
"onUpdate": "NO ACTION",
|
||||
"columns": [
|
||||
"ownerId"
|
||||
],
|
||||
"referencedColumns": [
|
||||
"id"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"tableName": "principal",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `serviceId` INTEGER NOT NULL, `url` TEXT NOT NULL, `displayName` TEXT, FOREIGN KEY(`serviceId`) REFERENCES `service`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "id",
|
||||
"columnName": "id",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "serviceId",
|
||||
"columnName": "serviceId",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "url",
|
||||
"columnName": "url",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "displayName",
|
||||
"columnName": "displayName",
|
||||
"affinity": "TEXT",
|
||||
"notNull": false
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"autoGenerate": true,
|
||||
"columnNames": [
|
||||
"id"
|
||||
]
|
||||
},
|
||||
"indices": [
|
||||
{
|
||||
"name": "index_principal_serviceId_url",
|
||||
"unique": true,
|
||||
"columnNames": [
|
||||
"serviceId",
|
||||
"url"
|
||||
],
|
||||
"orders": [],
|
||||
"createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_principal_serviceId_url` ON `${TABLE_NAME}` (`serviceId`, `url`)"
|
||||
}
|
||||
],
|
||||
"foreignKeys": [
|
||||
{
|
||||
"table": "service",
|
||||
"onDelete": "CASCADE",
|
||||
"onUpdate": "NO ACTION",
|
||||
"columns": [
|
||||
"serviceId"
|
||||
],
|
||||
"referencedColumns": [
|
||||
"id"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"tableName": "syncstats",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `collectionId` INTEGER NOT NULL, `authority` TEXT NOT NULL, `lastSync` INTEGER NOT NULL, FOREIGN KEY(`collectionId`) REFERENCES `collection`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "id",
|
||||
"columnName": "id",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "collectionId",
|
||||
"columnName": "collectionId",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "authority",
|
||||
"columnName": "authority",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "lastSync",
|
||||
"columnName": "lastSync",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"autoGenerate": true,
|
||||
"columnNames": [
|
||||
"id"
|
||||
]
|
||||
},
|
||||
"indices": [
|
||||
{
|
||||
"name": "index_syncstats_collectionId_authority",
|
||||
"unique": true,
|
||||
"columnNames": [
|
||||
"collectionId",
|
||||
"authority"
|
||||
],
|
||||
"orders": [],
|
||||
"createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_syncstats_collectionId_authority` ON `${TABLE_NAME}` (`collectionId`, `authority`)"
|
||||
}
|
||||
],
|
||||
"foreignKeys": [
|
||||
{
|
||||
"table": "collection",
|
||||
"onDelete": "CASCADE",
|
||||
"onUpdate": "NO ACTION",
|
||||
"columns": [
|
||||
"collectionId"
|
||||
],
|
||||
"referencedColumns": [
|
||||
"id"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"tableName": "webdav_document",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `mountId` INTEGER NOT NULL, `parentId` INTEGER, `name` TEXT NOT NULL, `isDirectory` INTEGER NOT NULL, `displayName` TEXT, `mimeType` TEXT, `eTag` TEXT, `lastModified` INTEGER, `size` INTEGER, `mayBind` INTEGER, `mayUnbind` INTEGER, `mayWriteContent` INTEGER, `quotaAvailable` INTEGER, `quotaUsed` INTEGER, FOREIGN KEY(`mountId`) REFERENCES `webdav_mount`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`parentId`) REFERENCES `webdav_document`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "id",
|
||||
"columnName": "id",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "mountId",
|
||||
"columnName": "mountId",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "parentId",
|
||||
"columnName": "parentId",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "name",
|
||||
"columnName": "name",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "isDirectory",
|
||||
"columnName": "isDirectory",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "displayName",
|
||||
"columnName": "displayName",
|
||||
"affinity": "TEXT",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "mimeType",
|
||||
"columnName": "mimeType",
|
||||
"affinity": "TEXT",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "eTag",
|
||||
"columnName": "eTag",
|
||||
"affinity": "TEXT",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "lastModified",
|
||||
"columnName": "lastModified",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "size",
|
||||
"columnName": "size",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "mayBind",
|
||||
"columnName": "mayBind",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "mayUnbind",
|
||||
"columnName": "mayUnbind",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "mayWriteContent",
|
||||
"columnName": "mayWriteContent",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "quotaAvailable",
|
||||
"columnName": "quotaAvailable",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "quotaUsed",
|
||||
"columnName": "quotaUsed",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": false
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"autoGenerate": true,
|
||||
"columnNames": [
|
||||
"id"
|
||||
]
|
||||
},
|
||||
"indices": [
|
||||
{
|
||||
"name": "index_webdav_document_mountId_parentId_name",
|
||||
"unique": true,
|
||||
"columnNames": [
|
||||
"mountId",
|
||||
"parentId",
|
||||
"name"
|
||||
],
|
||||
"orders": [],
|
||||
"createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_webdav_document_mountId_parentId_name` ON `${TABLE_NAME}` (`mountId`, `parentId`, `name`)"
|
||||
},
|
||||
{
|
||||
"name": "index_webdav_document_parentId",
|
||||
"unique": false,
|
||||
"columnNames": [
|
||||
"parentId"
|
||||
],
|
||||
"orders": [],
|
||||
"createSql": "CREATE INDEX IF NOT EXISTS `index_webdav_document_parentId` ON `${TABLE_NAME}` (`parentId`)"
|
||||
}
|
||||
],
|
||||
"foreignKeys": [
|
||||
{
|
||||
"table": "webdav_mount",
|
||||
"onDelete": "CASCADE",
|
||||
"onUpdate": "NO ACTION",
|
||||
"columns": [
|
||||
"mountId"
|
||||
],
|
||||
"referencedColumns": [
|
||||
"id"
|
||||
]
|
||||
},
|
||||
{
|
||||
"table": "webdav_document",
|
||||
"onDelete": "CASCADE",
|
||||
"onUpdate": "NO ACTION",
|
||||
"columns": [
|
||||
"parentId"
|
||||
],
|
||||
"referencedColumns": [
|
||||
"id"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"tableName": "webdav_mount",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL, `url` TEXT NOT NULL)",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "id",
|
||||
"columnName": "id",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "name",
|
||||
"columnName": "name",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "url",
|
||||
"columnName": "url",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"autoGenerate": true,
|
||||
"columnNames": [
|
||||
"id"
|
||||
]
|
||||
},
|
||||
"indices": [],
|
||||
"foreignKeys": []
|
||||
}
|
||||
],
|
||||
"views": [],
|
||||
"setupQueries": [
|
||||
"CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
|
||||
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'ab1cb6057d8e050f6648bea46ae0943d')"
|
||||
]
|
||||
}
|
||||
}
|
||||
675
app/schemas/at.bitfire.davdroid.db.AppDatabase/16.json
Normal file
675
app/schemas/at.bitfire.davdroid.db.AppDatabase/16.json
Normal file
@@ -0,0 +1,675 @@
|
||||
{
|
||||
"formatVersion": 1,
|
||||
"database": {
|
||||
"version": 16,
|
||||
"identityHash": "2ff7560d957e03a78b4b7de88aa9593b",
|
||||
"entities": [
|
||||
{
|
||||
"tableName": "service",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `accountName` TEXT NOT NULL, `type` TEXT NOT NULL, `principal` TEXT)",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "id",
|
||||
"columnName": "id",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "accountName",
|
||||
"columnName": "accountName",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "type",
|
||||
"columnName": "type",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "principal",
|
||||
"columnName": "principal",
|
||||
"affinity": "TEXT",
|
||||
"notNull": false
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"autoGenerate": true,
|
||||
"columnNames": [
|
||||
"id"
|
||||
]
|
||||
},
|
||||
"indices": [
|
||||
{
|
||||
"name": "index_service_accountName_type",
|
||||
"unique": true,
|
||||
"columnNames": [
|
||||
"accountName",
|
||||
"type"
|
||||
],
|
||||
"orders": [],
|
||||
"createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_service_accountName_type` ON `${TABLE_NAME}` (`accountName`, `type`)"
|
||||
}
|
||||
],
|
||||
"foreignKeys": []
|
||||
},
|
||||
{
|
||||
"tableName": "homeset",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `serviceId` INTEGER NOT NULL, `personal` INTEGER NOT NULL, `url` TEXT NOT NULL, `privBind` INTEGER NOT NULL, `displayName` TEXT, FOREIGN KEY(`serviceId`) REFERENCES `service`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "id",
|
||||
"columnName": "id",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "serviceId",
|
||||
"columnName": "serviceId",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "personal",
|
||||
"columnName": "personal",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "url",
|
||||
"columnName": "url",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "privBind",
|
||||
"columnName": "privBind",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "displayName",
|
||||
"columnName": "displayName",
|
||||
"affinity": "TEXT",
|
||||
"notNull": false
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"autoGenerate": true,
|
||||
"columnNames": [
|
||||
"id"
|
||||
]
|
||||
},
|
||||
"indices": [
|
||||
{
|
||||
"name": "index_homeset_serviceId_url",
|
||||
"unique": true,
|
||||
"columnNames": [
|
||||
"serviceId",
|
||||
"url"
|
||||
],
|
||||
"orders": [],
|
||||
"createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_homeset_serviceId_url` ON `${TABLE_NAME}` (`serviceId`, `url`)"
|
||||
}
|
||||
],
|
||||
"foreignKeys": [
|
||||
{
|
||||
"table": "service",
|
||||
"onDelete": "CASCADE",
|
||||
"onUpdate": "NO ACTION",
|
||||
"columns": [
|
||||
"serviceId"
|
||||
],
|
||||
"referencedColumns": [
|
||||
"id"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"tableName": "collection",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `serviceId` INTEGER NOT NULL, `homeSetId` INTEGER, `ownerId` INTEGER, `type` TEXT NOT NULL, `url` TEXT NOT NULL, `privWriteContent` INTEGER NOT NULL, `privUnbind` INTEGER NOT NULL, `forceReadOnly` INTEGER NOT NULL, `displayName` TEXT, `description` TEXT, `color` INTEGER, `timezoneId` TEXT, `supportsVEVENT` INTEGER, `supportsVTODO` INTEGER, `supportsVJOURNAL` INTEGER, `source` TEXT, `sync` INTEGER NOT NULL, `pushTopic` TEXT, `supportsWebPush` INTEGER NOT NULL DEFAULT 0, `pushSubscription` TEXT, `pushSubscriptionExpires` INTEGER, `pushSubscriptionCreated` INTEGER, FOREIGN KEY(`serviceId`) REFERENCES `service`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`homeSetId`) REFERENCES `homeset`(`id`) ON UPDATE NO ACTION ON DELETE SET NULL , FOREIGN KEY(`ownerId`) REFERENCES `principal`(`id`) ON UPDATE NO ACTION ON DELETE SET NULL )",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "id",
|
||||
"columnName": "id",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "serviceId",
|
||||
"columnName": "serviceId",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "homeSetId",
|
||||
"columnName": "homeSetId",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "ownerId",
|
||||
"columnName": "ownerId",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "type",
|
||||
"columnName": "type",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "url",
|
||||
"columnName": "url",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "privWriteContent",
|
||||
"columnName": "privWriteContent",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "privUnbind",
|
||||
"columnName": "privUnbind",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "forceReadOnly",
|
||||
"columnName": "forceReadOnly",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "displayName",
|
||||
"columnName": "displayName",
|
||||
"affinity": "TEXT",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "description",
|
||||
"columnName": "description",
|
||||
"affinity": "TEXT",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "color",
|
||||
"columnName": "color",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "timezoneId",
|
||||
"columnName": "timezoneId",
|
||||
"affinity": "TEXT",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "supportsVEVENT",
|
||||
"columnName": "supportsVEVENT",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "supportsVTODO",
|
||||
"columnName": "supportsVTODO",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "supportsVJOURNAL",
|
||||
"columnName": "supportsVJOURNAL",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "source",
|
||||
"columnName": "source",
|
||||
"affinity": "TEXT",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "sync",
|
||||
"columnName": "sync",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "pushTopic",
|
||||
"columnName": "pushTopic",
|
||||
"affinity": "TEXT",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "supportsWebPush",
|
||||
"columnName": "supportsWebPush",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true,
|
||||
"defaultValue": "0"
|
||||
},
|
||||
{
|
||||
"fieldPath": "pushSubscription",
|
||||
"columnName": "pushSubscription",
|
||||
"affinity": "TEXT",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "pushSubscriptionExpires",
|
||||
"columnName": "pushSubscriptionExpires",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "pushSubscriptionCreated",
|
||||
"columnName": "pushSubscriptionCreated",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": false
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"autoGenerate": true,
|
||||
"columnNames": [
|
||||
"id"
|
||||
]
|
||||
},
|
||||
"indices": [
|
||||
{
|
||||
"name": "index_collection_serviceId_type",
|
||||
"unique": false,
|
||||
"columnNames": [
|
||||
"serviceId",
|
||||
"type"
|
||||
],
|
||||
"orders": [],
|
||||
"createSql": "CREATE INDEX IF NOT EXISTS `index_collection_serviceId_type` ON `${TABLE_NAME}` (`serviceId`, `type`)"
|
||||
},
|
||||
{
|
||||
"name": "index_collection_homeSetId_type",
|
||||
"unique": false,
|
||||
"columnNames": [
|
||||
"homeSetId",
|
||||
"type"
|
||||
],
|
||||
"orders": [],
|
||||
"createSql": "CREATE INDEX IF NOT EXISTS `index_collection_homeSetId_type` ON `${TABLE_NAME}` (`homeSetId`, `type`)"
|
||||
},
|
||||
{
|
||||
"name": "index_collection_ownerId_type",
|
||||
"unique": false,
|
||||
"columnNames": [
|
||||
"ownerId",
|
||||
"type"
|
||||
],
|
||||
"orders": [],
|
||||
"createSql": "CREATE INDEX IF NOT EXISTS `index_collection_ownerId_type` ON `${TABLE_NAME}` (`ownerId`, `type`)"
|
||||
},
|
||||
{
|
||||
"name": "index_collection_pushTopic_type",
|
||||
"unique": false,
|
||||
"columnNames": [
|
||||
"pushTopic",
|
||||
"type"
|
||||
],
|
||||
"orders": [],
|
||||
"createSql": "CREATE INDEX IF NOT EXISTS `index_collection_pushTopic_type` ON `${TABLE_NAME}` (`pushTopic`, `type`)"
|
||||
},
|
||||
{
|
||||
"name": "index_collection_url",
|
||||
"unique": false,
|
||||
"columnNames": [
|
||||
"url"
|
||||
],
|
||||
"orders": [],
|
||||
"createSql": "CREATE INDEX IF NOT EXISTS `index_collection_url` ON `${TABLE_NAME}` (`url`)"
|
||||
}
|
||||
],
|
||||
"foreignKeys": [
|
||||
{
|
||||
"table": "service",
|
||||
"onDelete": "CASCADE",
|
||||
"onUpdate": "NO ACTION",
|
||||
"columns": [
|
||||
"serviceId"
|
||||
],
|
||||
"referencedColumns": [
|
||||
"id"
|
||||
]
|
||||
},
|
||||
{
|
||||
"table": "homeset",
|
||||
"onDelete": "SET NULL",
|
||||
"onUpdate": "NO ACTION",
|
||||
"columns": [
|
||||
"homeSetId"
|
||||
],
|
||||
"referencedColumns": [
|
||||
"id"
|
||||
]
|
||||
},
|
||||
{
|
||||
"table": "principal",
|
||||
"onDelete": "SET NULL",
|
||||
"onUpdate": "NO ACTION",
|
||||
"columns": [
|
||||
"ownerId"
|
||||
],
|
||||
"referencedColumns": [
|
||||
"id"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"tableName": "principal",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `serviceId` INTEGER NOT NULL, `url` TEXT NOT NULL, `displayName` TEXT, FOREIGN KEY(`serviceId`) REFERENCES `service`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "id",
|
||||
"columnName": "id",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "serviceId",
|
||||
"columnName": "serviceId",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "url",
|
||||
"columnName": "url",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "displayName",
|
||||
"columnName": "displayName",
|
||||
"affinity": "TEXT",
|
||||
"notNull": false
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"autoGenerate": true,
|
||||
"columnNames": [
|
||||
"id"
|
||||
]
|
||||
},
|
||||
"indices": [
|
||||
{
|
||||
"name": "index_principal_serviceId_url",
|
||||
"unique": true,
|
||||
"columnNames": [
|
||||
"serviceId",
|
||||
"url"
|
||||
],
|
||||
"orders": [],
|
||||
"createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_principal_serviceId_url` ON `${TABLE_NAME}` (`serviceId`, `url`)"
|
||||
}
|
||||
],
|
||||
"foreignKeys": [
|
||||
{
|
||||
"table": "service",
|
||||
"onDelete": "CASCADE",
|
||||
"onUpdate": "NO ACTION",
|
||||
"columns": [
|
||||
"serviceId"
|
||||
],
|
||||
"referencedColumns": [
|
||||
"id"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"tableName": "syncstats",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `collectionId` INTEGER NOT NULL, `authority` TEXT NOT NULL, `lastSync` INTEGER NOT NULL, FOREIGN KEY(`collectionId`) REFERENCES `collection`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "id",
|
||||
"columnName": "id",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "collectionId",
|
||||
"columnName": "collectionId",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "authority",
|
||||
"columnName": "authority",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "lastSync",
|
||||
"columnName": "lastSync",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"autoGenerate": true,
|
||||
"columnNames": [
|
||||
"id"
|
||||
]
|
||||
},
|
||||
"indices": [
|
||||
{
|
||||
"name": "index_syncstats_collectionId_authority",
|
||||
"unique": true,
|
||||
"columnNames": [
|
||||
"collectionId",
|
||||
"authority"
|
||||
],
|
||||
"orders": [],
|
||||
"createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_syncstats_collectionId_authority` ON `${TABLE_NAME}` (`collectionId`, `authority`)"
|
||||
}
|
||||
],
|
||||
"foreignKeys": [
|
||||
{
|
||||
"table": "collection",
|
||||
"onDelete": "CASCADE",
|
||||
"onUpdate": "NO ACTION",
|
||||
"columns": [
|
||||
"collectionId"
|
||||
],
|
||||
"referencedColumns": [
|
||||
"id"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"tableName": "webdav_document",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `mountId` INTEGER NOT NULL, `parentId` INTEGER, `name` TEXT NOT NULL, `isDirectory` INTEGER NOT NULL, `displayName` TEXT, `mimeType` TEXT, `eTag` TEXT, `lastModified` INTEGER, `size` INTEGER, `mayBind` INTEGER, `mayUnbind` INTEGER, `mayWriteContent` INTEGER, `quotaAvailable` INTEGER, `quotaUsed` INTEGER, FOREIGN KEY(`mountId`) REFERENCES `webdav_mount`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`parentId`) REFERENCES `webdav_document`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "id",
|
||||
"columnName": "id",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "mountId",
|
||||
"columnName": "mountId",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "parentId",
|
||||
"columnName": "parentId",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "name",
|
||||
"columnName": "name",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "isDirectory",
|
||||
"columnName": "isDirectory",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "displayName",
|
||||
"columnName": "displayName",
|
||||
"affinity": "TEXT",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "mimeType",
|
||||
"columnName": "mimeType",
|
||||
"affinity": "TEXT",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "eTag",
|
||||
"columnName": "eTag",
|
||||
"affinity": "TEXT",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "lastModified",
|
||||
"columnName": "lastModified",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "size",
|
||||
"columnName": "size",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "mayBind",
|
||||
"columnName": "mayBind",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "mayUnbind",
|
||||
"columnName": "mayUnbind",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "mayWriteContent",
|
||||
"columnName": "mayWriteContent",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "quotaAvailable",
|
||||
"columnName": "quotaAvailable",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "quotaUsed",
|
||||
"columnName": "quotaUsed",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": false
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"autoGenerate": true,
|
||||
"columnNames": [
|
||||
"id"
|
||||
]
|
||||
},
|
||||
"indices": [
|
||||
{
|
||||
"name": "index_webdav_document_mountId_parentId_name",
|
||||
"unique": true,
|
||||
"columnNames": [
|
||||
"mountId",
|
||||
"parentId",
|
||||
"name"
|
||||
],
|
||||
"orders": [],
|
||||
"createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_webdav_document_mountId_parentId_name` ON `${TABLE_NAME}` (`mountId`, `parentId`, `name`)"
|
||||
},
|
||||
{
|
||||
"name": "index_webdav_document_parentId",
|
||||
"unique": false,
|
||||
"columnNames": [
|
||||
"parentId"
|
||||
],
|
||||
"orders": [],
|
||||
"createSql": "CREATE INDEX IF NOT EXISTS `index_webdav_document_parentId` ON `${TABLE_NAME}` (`parentId`)"
|
||||
}
|
||||
],
|
||||
"foreignKeys": [
|
||||
{
|
||||
"table": "webdav_mount",
|
||||
"onDelete": "CASCADE",
|
||||
"onUpdate": "NO ACTION",
|
||||
"columns": [
|
||||
"mountId"
|
||||
],
|
||||
"referencedColumns": [
|
||||
"id"
|
||||
]
|
||||
},
|
||||
{
|
||||
"table": "webdav_document",
|
||||
"onDelete": "CASCADE",
|
||||
"onUpdate": "NO ACTION",
|
||||
"columns": [
|
||||
"parentId"
|
||||
],
|
||||
"referencedColumns": [
|
||||
"id"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"tableName": "webdav_mount",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL, `url` TEXT NOT NULL)",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "id",
|
||||
"columnName": "id",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "name",
|
||||
"columnName": "name",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "url",
|
||||
"columnName": "url",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"autoGenerate": true,
|
||||
"columnNames": [
|
||||
"id"
|
||||
]
|
||||
},
|
||||
"indices": [],
|
||||
"foreignKeys": []
|
||||
}
|
||||
],
|
||||
"views": [],
|
||||
"setupQueries": [
|
||||
"CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
|
||||
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '2ff7560d957e03a78b4b7de88aa9593b')"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -7,21 +7,4 @@
|
||||
<uses-permission android:name="android.permission.GET_ACCOUNTS" android:maxSdkVersion="22"/>
|
||||
<uses-permission android:name="android.permission.MANAGE_ACCOUNTS" android:maxSdkVersion="22"/>
|
||||
|
||||
<application>
|
||||
|
||||
<!-- test account type (without associated sync adapters) -->
|
||||
<service
|
||||
android:name="at.bitfire.davdroid.sync.account.TestAccountAuthenticator"
|
||||
android:exported="false">
|
||||
<intent-filter>
|
||||
<action android:name="android.accounts.AccountAuthenticator"/>
|
||||
</intent-filter>
|
||||
|
||||
<meta-data
|
||||
android:name="android.accounts.AccountAuthenticator"
|
||||
android:resource="@xml/test_account_authenticator"/>
|
||||
</service>
|
||||
|
||||
</application>
|
||||
|
||||
</manifest>
|
||||
@@ -1,6 +1,6 @@
|
||||
/***************************************************************************************************
|
||||
/*
|
||||
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
|
||||
**************************************************************************************************/
|
||||
*/
|
||||
|
||||
package at.bitfire.davdroid
|
||||
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
/*
|
||||
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
|
||||
*/
|
||||
|
||||
package at.bitfire.davdroid
|
||||
|
||||
import at.bitfire.davdroid.push.PushRegistrationWorker
|
||||
import at.bitfire.davdroid.push.PushRegistrationWorkerManager
|
||||
import at.bitfire.davdroid.repository.DavCollectionRepository
|
||||
import at.bitfire.davdroid.startup.StartupPlugin
|
||||
import at.bitfire.davdroid.startup.TasksAppWatcher
|
||||
@@ -9,30 +13,26 @@ import dagger.hilt.components.SingletonComponent
|
||||
import dagger.hilt.testing.TestInstallIn
|
||||
import dagger.multibindings.Multibinds
|
||||
|
||||
interface TestModules {
|
||||
|
||||
// remove PushRegistrationWorkerModule from Android tests
|
||||
@Module
|
||||
@TestInstallIn(
|
||||
components = [SingletonComponent::class],
|
||||
replaces = [PushRegistrationWorker.PushRegistrationWorkerModule::class]
|
||||
)
|
||||
abstract class TestPushRegistrationWorkerModule {
|
||||
// provides empty set of listeners
|
||||
@Multibinds
|
||||
abstract fun empty(): Set<DavCollectionRepository.OnChangeListener>
|
||||
}
|
||||
|
||||
// remove TasksAppWatcherModule from Android tests
|
||||
@Module
|
||||
@TestInstallIn(
|
||||
components = [SingletonComponent::class],
|
||||
replaces = [TasksAppWatcher.TasksAppWatcherModule::class]
|
||||
)
|
||||
abstract class TestTasksAppWatcherModuleModule {
|
||||
// provides empty set of plugins
|
||||
@Multibinds
|
||||
abstract fun empty(): Set<StartupPlugin>
|
||||
}
|
||||
// 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 {
|
||||
// provides empty set of plugins
|
||||
@Multibinds
|
||||
abstract fun empty(): Set<StartupPlugin>
|
||||
}
|
||||
@@ -5,16 +5,14 @@
|
||||
package at.bitfire.davdroid
|
||||
|
||||
import android.content.Context
|
||||
import androidx.lifecycle.LiveData
|
||||
import androidx.lifecycle.Observer
|
||||
import android.util.Log
|
||||
import androidx.work.Configuration
|
||||
import androidx.work.WorkInfo
|
||||
import androidx.work.WorkManager
|
||||
import androidx.work.WorkQuery
|
||||
import org.jetbrains.annotations.TestOnly
|
||||
import androidx.work.WorkerFactory
|
||||
import androidx.work.testing.WorkManagerTestInitHelper
|
||||
import org.junit.Assert.assertTrue
|
||||
import java.util.concurrent.CountDownLatch
|
||||
import java.util.concurrent.TimeUnit
|
||||
import java.util.concurrent.TimeoutException
|
||||
import kotlin.math.abs
|
||||
|
||||
object TestUtils {
|
||||
@@ -27,22 +25,16 @@ object TestUtils {
|
||||
)
|
||||
}
|
||||
|
||||
@TestOnly
|
||||
fun workScheduledOrRunning(context: Context, workerName: String): Boolean =
|
||||
workInStates(context, workerName, listOf(
|
||||
WorkInfo.State.ENQUEUED,
|
||||
WorkInfo.State.RUNNING
|
||||
))
|
||||
/**
|
||||
* Initializes WorkManager for instrumentation tests.
|
||||
*/
|
||||
fun setUpWorkManager(context: Context, workerFactory: WorkerFactory? = null) {
|
||||
val config = Configuration.Builder().setMinimumLoggingLevel(Log.DEBUG)
|
||||
if (workerFactory != null)
|
||||
config.setWorkerFactory(workerFactory)
|
||||
WorkManagerTestInitHelper.initializeTestWorkManager(context, config.build())
|
||||
}
|
||||
|
||||
@TestOnly
|
||||
fun workScheduledOrRunningOrSuccessful(context: Context, workerName: String): Boolean =
|
||||
workInStates(context, workerName, listOf(
|
||||
WorkInfo.State.ENQUEUED,
|
||||
WorkInfo.State.RUNNING,
|
||||
WorkInfo.State.SUCCEEDED
|
||||
))
|
||||
|
||||
@TestOnly
|
||||
fun workInStates(context: Context, workerName: String, states: List<WorkInfo.State>): Boolean =
|
||||
WorkManager.getInstance(context).getWorkInfos(WorkQuery.Builder
|
||||
.fromUniqueWorkNames(listOf(workerName))
|
||||
@@ -50,33 +42,17 @@ object TestUtils {
|
||||
.build()
|
||||
).get().isNotEmpty()
|
||||
|
||||
fun workScheduledOrRunning(context: Context, workerName: String): Boolean =
|
||||
workInStates(context, workerName, listOf(
|
||||
WorkInfo.State.ENQUEUED,
|
||||
WorkInfo.State.RUNNING
|
||||
))
|
||||
|
||||
/* Copyright 2019 Google LLC.
|
||||
SPDX-License-Identifier: Apache-2.0 */
|
||||
@TestOnly
|
||||
fun <T> LiveData<T>.getOrAwaitValue(
|
||||
time: Long = 2,
|
||||
timeUnit: TimeUnit = TimeUnit.SECONDS
|
||||
): T {
|
||||
var data: T? = null
|
||||
val latch = CountDownLatch(1)
|
||||
val observer = object : Observer<T> {
|
||||
override fun onChanged(value: T) {
|
||||
data = value
|
||||
latch.countDown()
|
||||
this@getOrAwaitValue.removeObserver(this)
|
||||
}
|
||||
}
|
||||
|
||||
this.observeForever(observer)
|
||||
|
||||
// Don't wait indefinitely if the LiveData is not set.
|
||||
if (!latch.await(time, timeUnit)) {
|
||||
throw TimeoutException("LiveData value was never set.")
|
||||
}
|
||||
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
return data as T
|
||||
}
|
||||
fun workScheduledOrRunningOrSuccessful(context: Context, workerName: String): Boolean =
|
||||
workInStates(context, workerName, listOf(
|
||||
WorkInfo.State.ENQUEUED,
|
||||
WorkInfo.State.RUNNING,
|
||||
WorkInfo.State.SUCCEEDED
|
||||
))
|
||||
|
||||
}
|
||||
@@ -6,45 +6,74 @@ package at.bitfire.davdroid.db
|
||||
|
||||
import android.content.Context
|
||||
import androidx.room.Room
|
||||
import androidx.room.migration.AutoMigrationSpec
|
||||
import androidx.room.migration.Migration
|
||||
import androidx.room.testing.MigrationTestHelper
|
||||
import androidx.sqlite.db.framework.FrameworkSQLiteOpenHelperFactory
|
||||
import androidx.test.platform.app.InstrumentationRegistry
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import dagger.hilt.android.testing.HiltAndroidRule
|
||||
import dagger.hilt.android.testing.HiltAndroidTest
|
||||
import org.junit.Before
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import java.util.logging.Logger
|
||||
import javax.inject.Inject
|
||||
|
||||
@HiltAndroidTest
|
||||
class AppDatabaseTest {
|
||||
|
||||
val TEST_DB = "test"
|
||||
@get:Rule
|
||||
val hiltRule = HiltAndroidRule(this)
|
||||
|
||||
val context: Context = InstrumentationRegistry.getInstrumentation().targetContext
|
||||
@Inject @ApplicationContext
|
||||
lateinit var context: Context
|
||||
|
||||
@Rule
|
||||
@JvmField
|
||||
val helper = MigrationTestHelper(
|
||||
InstrumentationRegistry.getInstrumentation(),
|
||||
AppDatabase::class.java,
|
||||
listOf(), // no auto migrations until v8
|
||||
FrameworkSQLiteOpenHelperFactory()
|
||||
)
|
||||
@Inject
|
||||
lateinit var autoMigrations: Set<@JvmSuppressWildcards AutoMigrationSpec>
|
||||
|
||||
@Inject
|
||||
lateinit var logger: Logger
|
||||
|
||||
@Inject
|
||||
lateinit var manualMigrations: Set<@JvmSuppressWildcards Migration>
|
||||
|
||||
@Before
|
||||
fun setup() {
|
||||
hiltRule.inject()
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Creates a database with schema version 8 (the first exported one) and then migrates it to the latest version.
|
||||
*/
|
||||
@Test
|
||||
fun testAllMigrations() {
|
||||
// DB schema is available since version 8, so create DB with v8
|
||||
helper.createDatabase(TEST_DB, 8).close()
|
||||
// Create DB with v8
|
||||
MigrationTestHelper(
|
||||
InstrumentationRegistry.getInstrumentation(),
|
||||
AppDatabase::class.java,
|
||||
listOf(), // no auto migrations until v8
|
||||
FrameworkSQLiteOpenHelperFactory()
|
||||
).createDatabase(TEST_DB, 8).close()
|
||||
|
||||
val db = Room.databaseBuilder(context, AppDatabase::class.java, TEST_DB)
|
||||
// open and migrate (to current version) database
|
||||
Room.databaseBuilder(context, AppDatabase::class.java, TEST_DB)
|
||||
// manual migrations
|
||||
.addMigrations(*AppDatabase.migrations)
|
||||
.addMigrations(*manualMigrations.toTypedArray())
|
||||
// auto-migrations that need to be specified explicitly
|
||||
.addAutoMigrationSpec(AppDatabase.AutoMigration11_12(context))
|
||||
.apply {
|
||||
for (spec in autoMigrations)
|
||||
addAutoMigrationSpec(spec)
|
||||
}
|
||||
.build()
|
||||
try {
|
||||
// open (with version 8) + migrate (to current version) database
|
||||
db.openHelper.writableDatabase
|
||||
} finally {
|
||||
db.close()
|
||||
}
|
||||
.openHelper.writableDatabase // this will run all migrations
|
||||
.close()
|
||||
}
|
||||
|
||||
|
||||
companion object {
|
||||
const val TEST_DB = "test"
|
||||
}
|
||||
|
||||
}
|
||||
@@ -92,35 +92,93 @@ class CollectionTest {
|
||||
|
||||
@Test
|
||||
@SmallTest
|
||||
fun testFromDavResponseCalendar() {
|
||||
fun testFromDavResponseCalendar_FullTimezone() {
|
||||
// read-only calendar, no display name
|
||||
server.enqueue(MockResponse()
|
||||
.setResponseCode(207)
|
||||
.setBody("<multistatus xmlns='DAV:' xmlns:CAL='urn:ietf:params:xml:ns:caldav' xmlns:ICAL='http://apple.com/ns/ical/'>" +
|
||||
"<response>" +
|
||||
" <href>/</href>" +
|
||||
" <propstat><prop>" +
|
||||
" <resourcetype><collection/><CAL:calendar/></resourcetype>" +
|
||||
" <current-user-privilege-set><privilege><read/></privilege></current-user-privilege-set>" +
|
||||
" <CAL:calendar-description>My Calendar</CAL:calendar-description>" +
|
||||
" <CAL:calendar-timezone>tzdata</CAL:calendar-timezone>" +
|
||||
" <ICAL:calendar-color>#ff0000</ICAL:calendar-color>" +
|
||||
" </prop></propstat>" +
|
||||
"</response>" +
|
||||
"</multistatus>"))
|
||||
.setResponseCode(207)
|
||||
.setBody("<multistatus xmlns='DAV:' xmlns:CAL='urn:ietf:params:xml:ns:caldav' xmlns:ICAL='http://apple.com/ns/ical/'>" +
|
||||
"<response>" +
|
||||
" <href>/</href>" +
|
||||
" <propstat><prop>" +
|
||||
" <resourcetype><collection/><CAL:calendar/></resourcetype>" +
|
||||
" <current-user-privilege-set><privilege><read/></privilege></current-user-privilege-set>" +
|
||||
" <CAL:calendar-description>My Calendar</CAL:calendar-description>" +
|
||||
" <CAL:calendar-timezone>BEGIN:VCALENDAR\n" +
|
||||
"PRODID:-//Example Corp.//CalDAV Client//EN\n" +
|
||||
"VERSION:2.0\n" +
|
||||
"BEGIN:VTIMEZONE\n" +
|
||||
"TZID:US-Eastern\n" +
|
||||
"LAST-MODIFIED:19870101T000000Z\n" +
|
||||
"BEGIN:STANDARD\n" +
|
||||
"DTSTART:19671029T020000\n" +
|
||||
"RRULE:FREQ=YEARLY;BYDAY=-1SU;BYMONTH=10\n" +
|
||||
"TZOFFSETFROM:-0400\n" +
|
||||
"TZOFFSETTO:-0500\n" +
|
||||
"TZNAME:Eastern Standard Time (US & Canada)\n" +
|
||||
"END:STANDARD\n" +
|
||||
"BEGIN:DAYLIGHT\n" +
|
||||
"DTSTART:19870405T020000\n" +
|
||||
"RRULE:FREQ=YEARLY;BYDAY=1SU;BYMONTH=4\n" +
|
||||
"TZOFFSETFROM:-0500\n" +
|
||||
"TZOFFSETTO:-0400\n" +
|
||||
"TZNAME:Eastern Daylight Time (US & Canada)\n" +
|
||||
"END:DAYLIGHT\n" +
|
||||
"END:VTIMEZONE\n" +
|
||||
"END:VCALENDAR\n" +
|
||||
"</CAL:calendar-timezone>" +
|
||||
" <ICAL:calendar-color>#ff0000</ICAL:calendar-color>" +
|
||||
" </prop></propstat>" +
|
||||
"</response>" +
|
||||
"</multistatus>"))
|
||||
|
||||
lateinit var info: Collection
|
||||
DavResource(httpClient.okHttpClient, server.url("/"))
|
||||
.propfind(0, ResourceType.NAME) { response, _ ->
|
||||
info = Collection.fromDavResponse(response) ?: throw IllegalArgumentException()
|
||||
}
|
||||
.propfind(0, ResourceType.NAME) { response, _ ->
|
||||
info = Collection.fromDavResponse(response)!!
|
||||
}
|
||||
assertEquals(Collection.TYPE_CALENDAR, info.type)
|
||||
assertFalse(info.privWriteContent)
|
||||
assertFalse(info.privUnbind)
|
||||
assertNull(info.displayName)
|
||||
assertEquals("My Calendar", info.description)
|
||||
assertEquals(0xFFFF0000.toInt(), info.color)
|
||||
assertEquals("tzdata", info.timezone)
|
||||
assertEquals("US-Eastern", info.timezoneId)
|
||||
assertTrue(info.supportsVEVENT!!)
|
||||
assertTrue(info.supportsVTODO!!)
|
||||
assertTrue(info.supportsVJOURNAL!!)
|
||||
}
|
||||
|
||||
@Test
|
||||
@SmallTest
|
||||
fun testFromDavResponseCalendar_OnlyTzId() {
|
||||
// read-only calendar, no display name
|
||||
server.enqueue(MockResponse()
|
||||
.setResponseCode(207)
|
||||
.setBody("<multistatus xmlns='DAV:' xmlns:CAL='urn:ietf:params:xml:ns:caldav' xmlns:ICAL='http://apple.com/ns/ical/'>" +
|
||||
"<response>" +
|
||||
" <href>/</href>" +
|
||||
" <propstat><prop>" +
|
||||
" <resourcetype><collection/><CAL:calendar/></resourcetype>" +
|
||||
" <current-user-privilege-set><privilege><read/></privilege></current-user-privilege-set>" +
|
||||
" <CAL:calendar-description>My Calendar</CAL:calendar-description>" +
|
||||
" <CAL:calendar-timezone-id>US-Eastern</CAL:calendar-timezone-id>" +
|
||||
" <ICAL:calendar-color>#ff0000</ICAL:calendar-color>" +
|
||||
" </prop></propstat>" +
|
||||
"</response>" +
|
||||
"</multistatus>"))
|
||||
|
||||
lateinit var info: Collection
|
||||
DavResource(httpClient.okHttpClient, server.url("/"))
|
||||
.propfind(0, ResourceType.NAME) { response, _ ->
|
||||
info = Collection.fromDavResponse(response)!!
|
||||
}
|
||||
assertEquals(Collection.TYPE_CALENDAR, info.type)
|
||||
assertFalse(info.privWriteContent)
|
||||
assertFalse(info.privUnbind)
|
||||
assertNull(info.displayName)
|
||||
assertEquals("My Calendar", info.description)
|
||||
assertEquals(0xFFFF0000.toInt(), info.color)
|
||||
assertEquals("US-Eastern", info.timezoneId)
|
||||
assertTrue(info.supportsVEVENT!!)
|
||||
assertTrue(info.supportsVTODO!!)
|
||||
assertTrue(info.supportsVJOURNAL!!)
|
||||
|
||||
@@ -6,6 +6,7 @@ package at.bitfire.davdroid.db
|
||||
|
||||
import android.content.Context
|
||||
import androidx.room.Room
|
||||
import androidx.room.migration.AutoMigrationSpec
|
||||
import dagger.Module
|
||||
import dagger.Provides
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
@@ -24,10 +25,17 @@ class MemoryDbModule {
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun inMemoryDatabase(@ApplicationContext context: Context): AppDatabase =
|
||||
fun inMemoryDatabase(
|
||||
autoMigrations: Set<@JvmSuppressWildcards AutoMigrationSpec>,
|
||||
@ApplicationContext context: Context
|
||||
): AppDatabase =
|
||||
Room.inMemoryDatabaseBuilder(context, AppDatabase::class.java)
|
||||
// auto-migrations that need to be specified explicitly
|
||||
.addAutoMigrationSpec(AppDatabase.AutoMigration11_12(context))
|
||||
// auto-migration specs that need to be specified explicitly
|
||||
.apply {
|
||||
for (spec in autoMigrations) {
|
||||
addAutoMigrationSpec(spec)
|
||||
}
|
||||
}
|
||||
.build()
|
||||
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
/*
|
||||
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
|
||||
*/
|
||||
|
||||
package at.bitfire.davdroid.db.migration
|
||||
|
||||
import at.bitfire.davdroid.db.Collection.Companion.TYPE_CALENDAR
|
||||
import at.bitfire.davdroid.db.Service
|
||||
import dagger.hilt.android.testing.HiltAndroidTest
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Test
|
||||
|
||||
@HiltAndroidTest
|
||||
class AutoMigration16Test: DatabaseMigrationTest(toVersion = 16) {
|
||||
|
||||
@Test
|
||||
fun testMigrate_WithTimeZone() = testMigration(
|
||||
prepare = { db ->
|
||||
val minimalVTimezone = """
|
||||
BEGIN:VCALENDAR
|
||||
VERSION:2.0
|
||||
PRODID:DAVx5
|
||||
BEGIN:VTIMEZONE
|
||||
TZID:America/New_York
|
||||
END:VTIMEZONE
|
||||
END:VCALENDAR
|
||||
""".trimIndent()
|
||||
db.execSQL(
|
||||
"INSERT INTO service (id, accountName, type) VALUES (?, ?, ?)",
|
||||
arrayOf(1, "test", Service.Companion.TYPE_CALDAV)
|
||||
)
|
||||
db.execSQL(
|
||||
"INSERT INTO collection (id, serviceId, type, url, privWriteContent, privUnbind, forceReadOnly, sync, timezone) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
||||
arrayOf(1, 1, TYPE_CALENDAR, "https://example.com", true, true, false, false, minimalVTimezone)
|
||||
)
|
||||
}
|
||||
) { db ->
|
||||
db.query("SELECT timezoneId FROM collection WHERE id=1").use { cursor ->
|
||||
cursor.moveToFirst()
|
||||
assertEquals("America/New_York", cursor.getString(0))
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testMigrate_WithTimeZone_Unparseable() = testMigration(
|
||||
prepare = { db ->
|
||||
db.execSQL(
|
||||
"INSERT INTO service (id, accountName, type) VALUES (?, ?, ?)",
|
||||
arrayOf(1, "test", Service.Companion.TYPE_CALDAV)
|
||||
)
|
||||
db.execSQL(
|
||||
"INSERT INTO collection (id, serviceId, type, url, privWriteContent, privUnbind, forceReadOnly, sync, timezone) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
||||
arrayOf(1, 1, TYPE_CALENDAR, "https://example.com", true, true, false, false, "Some Garbage Content")
|
||||
)
|
||||
}
|
||||
) { db ->
|
||||
db.query("SELECT timezoneId FROM collection WHERE id=1").use { cursor ->
|
||||
cursor.moveToFirst()
|
||||
assertNull(cursor.getString(0))
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testMigrate_WithoutTimezone() = testMigration(
|
||||
prepare = { db ->
|
||||
db.execSQL(
|
||||
"INSERT INTO service (id, accountName, type) VALUES (?, ?, ?)",
|
||||
arrayOf(1, "test", Service.Companion.TYPE_CALDAV)
|
||||
)
|
||||
db.execSQL(
|
||||
"INSERT INTO collection (id, serviceId, type, url, privWriteContent, privUnbind, forceReadOnly, sync) VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
|
||||
arrayOf(1, 1, TYPE_CALENDAR, "https://example.com", true, true, false, false)
|
||||
)
|
||||
}
|
||||
) { db ->
|
||||
db.query("SELECT timezoneId FROM collection WHERE id=1").use { cursor ->
|
||||
cursor.moveToFirst()
|
||||
assertNull(cursor.getString(0))
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
/*
|
||||
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
|
||||
*/
|
||||
|
||||
package at.bitfire.davdroid.db.migration
|
||||
|
||||
import androidx.room.migration.AutoMigrationSpec
|
||||
import androidx.room.migration.Migration
|
||||
import androidx.room.testing.MigrationTestHelper
|
||||
import androidx.sqlite.db.SupportSQLiteDatabase
|
||||
import androidx.sqlite.db.framework.FrameworkSQLiteOpenHelperFactory
|
||||
import androidx.test.platform.app.InstrumentationRegistry
|
||||
import at.bitfire.davdroid.db.AppDatabase
|
||||
import dagger.hilt.android.testing.HiltAndroidRule
|
||||
import org.junit.Before
|
||||
import org.junit.Rule
|
||||
import javax.inject.Inject
|
||||
|
||||
/**
|
||||
* Helper for testing the database migration from [toVersion] - 1 to [toVersion].
|
||||
*
|
||||
* @param toVersion The target version to migrate to.
|
||||
*/
|
||||
abstract class DatabaseMigrationTest(
|
||||
private val toVersion: Int
|
||||
) {
|
||||
|
||||
@Inject
|
||||
lateinit var autoMigrations: Set<@JvmSuppressWildcards AutoMigrationSpec>
|
||||
|
||||
@Inject
|
||||
lateinit var manualMigrations: Set<@JvmSuppressWildcards Migration>
|
||||
|
||||
@get:Rule
|
||||
val hiltRule = HiltAndroidRule(this)
|
||||
|
||||
|
||||
@Before
|
||||
fun setup() {
|
||||
hiltRule.inject()
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Used for testing the migration process from [toVersion]-1 to [toVersion].
|
||||
*
|
||||
* @param prepare Callback to prepare the database. Will be run with database schema in version [toVersion] - 1.
|
||||
* @param validate Callback to validate the migration result. Will be run with database schema in version [toVersion].
|
||||
*/
|
||||
protected fun testMigration(
|
||||
prepare: (SupportSQLiteDatabase) -> Unit,
|
||||
validate: (SupportSQLiteDatabase) -> Unit
|
||||
) {
|
||||
val helper = MigrationTestHelper(
|
||||
InstrumentationRegistry.getInstrumentation(),
|
||||
AppDatabase::class.java,
|
||||
autoMigrations.toList(),
|
||||
FrameworkSQLiteOpenHelperFactory()
|
||||
)
|
||||
|
||||
// Prepare the database with the initial version.
|
||||
val dbName = "test"
|
||||
helper.createDatabase(dbName, version = toVersion - 1).apply {
|
||||
prepare(this)
|
||||
close()
|
||||
}
|
||||
|
||||
// Re-open the database with the new version and provide all the migrations.
|
||||
val db = helper.runMigrationsAndValidate(
|
||||
name = dbName,
|
||||
version = toVersion,
|
||||
validateDroppedTables = true,
|
||||
migrations = manualMigrations.toTypedArray()
|
||||
)
|
||||
|
||||
validate(db)
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,3 +1,7 @@
|
||||
/*
|
||||
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
|
||||
*/
|
||||
|
||||
package at.bitfire.davdroid.repository
|
||||
|
||||
import android.content.Context
|
||||
@@ -5,6 +9,7 @@ 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
|
||||
@@ -63,7 +68,17 @@ class DavCollectionRepositoryTest {
|
||||
)
|
||||
)
|
||||
val testObserver = mockk<DavCollectionRepository.OnChangeListener>(relaxed = true)
|
||||
val collectionRepository = DavCollectionRepository(accountSettingsFactory, context, db, mutableSetOf(testObserver), serviceRepository)
|
||||
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) {
|
||||
|
||||
@@ -41,17 +41,17 @@ class DavHomeSetRepositoryTest {
|
||||
val entry1 = HomeSet(id=0, serviceId=serviceId, personal=true, url="https://example.com/1".toHttpUrl())
|
||||
val insertId1 = repository.insertOrUpdateByUrl(entry1)
|
||||
assertEquals(1L, insertId1)
|
||||
assertEquals(entry1.apply { id = 1L }, repository.getById(1L))
|
||||
assertEquals(entry1.copy(id = 1L), repository.getById(1L))
|
||||
|
||||
val updatedEntry1 = HomeSet(id=0, serviceId=serviceId, personal=true, url="https://example.com/1".toHttpUrl(), displayName="Updated Entry")
|
||||
val updateId1 = repository.insertOrUpdateByUrl(updatedEntry1)
|
||||
assertEquals(1L, updateId1)
|
||||
assertEquals(updatedEntry1.apply { id = 1L }, repository.getById(1L))
|
||||
assertEquals(updatedEntry1.copy(id = 1L), repository.getById(1L))
|
||||
|
||||
val entry2 = HomeSet(id=0, serviceId=serviceId, personal=true, url= "https://example.com/2".toHttpUrl())
|
||||
val insertId2 = repository.insertOrUpdateByUrl(entry2)
|
||||
assertEquals(2L, insertId2)
|
||||
assertEquals(entry2.apply { id = 2L }, repository.getById(2L))
|
||||
assertEquals(entry2.copy(id = 2L), repository.getById(2L))
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
@@ -0,0 +1,226 @@
|
||||
/*
|
||||
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
|
||||
*/
|
||||
|
||||
package at.bitfire.davdroid.resource
|
||||
|
||||
import android.accounts.Account
|
||||
import android.accounts.AccountManager
|
||||
import android.content.ContentProviderClient
|
||||
import android.content.Context
|
||||
import at.bitfire.davdroid.R
|
||||
import at.bitfire.davdroid.db.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 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.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
|
||||
import org.junit.Assert.assertFalse
|
||||
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 {
|
||||
|
||||
@get:Rule
|
||||
val hiltRule = HiltAndroidRule(this)
|
||||
|
||||
@Inject
|
||||
@ApplicationContext
|
||||
@SpyK
|
||||
lateinit var context: Context
|
||||
|
||||
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
|
||||
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
hiltRule.inject()
|
||||
|
||||
// initialize global mocks
|
||||
MockKAnnotations.init(this)
|
||||
}
|
||||
|
||||
@After
|
||||
fun tearDown() {
|
||||
unmockkAll()
|
||||
}
|
||||
|
||||
|
||||
@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
|
||||
}
|
||||
assertEquals("funnyfriends #42", localAddressBookStore.accountName(collection))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun test_accountName_missingDisplayName() {
|
||||
val collection = mockk<Collection> {
|
||||
every { id } returns 42
|
||||
every { url } returns "https://example.com/addressbook/funnyfriends".toHttpUrl()
|
||||
every { displayName } returns null
|
||||
every { serviceId } returns 200
|
||||
}
|
||||
val accountName = localAddressBookStore.accountName(collection)
|
||||
assertEquals("funnyfriends (MrRobert@example.com) #42", accountName)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun test_accountName_missingDisplayNameAndService() {
|
||||
val collection = mockk<Collection>(relaxed = true) {
|
||||
every { id } returns 1
|
||||
every { url } returns "https://example.com/addressbook/funnyfriends".toHttpUrl()
|
||||
every { displayName } returns null
|
||||
every { serviceId } returns 404 // missing service
|
||||
}
|
||||
assertEquals("funnyfriends #1", localAddressBookStore.accountName(collection))
|
||||
}
|
||||
|
||||
|
||||
@Test
|
||||
fun test_create_createAccountReturnsNull() {
|
||||
val collection = mockk<Collection>(relaxed = true) {
|
||||
every { serviceId } returns 200
|
||||
every { id } returns 1
|
||||
every { url } returns "https://example.com/addressbook/funnyfriends".toHttpUrl()
|
||||
}
|
||||
every { localAddressBookStore.createAddressBookAccount(any(), any(), any(), any()) } returns null
|
||||
assertEquals(null, localAddressBookStore.create(provider, collection))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun test_create_createAccountReturnsAccount() {
|
||||
val collection = mockk<Collection>(relaxed = true) {
|
||||
every { serviceId } returns 200
|
||||
every { id } returns 1
|
||||
every { url } returns "https://example.com/addressbook/funnyfriends".toHttpUrl()
|
||||
}
|
||||
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)
|
||||
}
|
||||
|
||||
@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)
|
||||
}
|
||||
|
||||
|
||||
@Test
|
||||
fun test_getAll_differentAccount() {
|
||||
val accountManager = AccountManager.get(context)
|
||||
mockkObject(accountManager)
|
||||
every { accountManager.getAccountsByType(any()) } returns arrayOf(addressBookAccount)
|
||||
every { accountManager.getUserData(addressBookAccount, LocalAddressBook.USER_DATA_ACCOUNT_NAME) } returns "Another Unrelated Account"
|
||||
every { accountManager.getUserData(addressBookAccount, LocalAddressBook.USER_DATA_ACCOUNT_TYPE) } returns account.type
|
||||
val result = localAddressBookStore.getAll(account, provider)
|
||||
assertTrue(result.isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun test_getAll_sameAccount() {
|
||||
val accountManager = AccountManager.get(context)
|
||||
mockkObject(accountManager)
|
||||
every { accountManager.getAccountsByType(any()) } returns arrayOf(addressBookAccount)
|
||||
every { accountManager.getUserData(addressBookAccount, LocalAddressBook.USER_DATA_ACCOUNT_NAME) } returns account.name
|
||||
every { accountManager.getUserData(addressBookAccount, LocalAddressBook.USER_DATA_ACCOUNT_TYPE) } returns account.type
|
||||
val result = localAddressBookStore.getAll(account, provider)
|
||||
assertEquals(1, result.size)
|
||||
assertEquals(addressBookAccount, result.first().addressBookAccount)
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Tests the calculation of read only state is correct
|
||||
*/
|
||||
@Test
|
||||
fun test_shouldBeReadOnly() {
|
||||
val collectionReadOnly = mockk<Collection> { every { readOnly() } returns true }
|
||||
assertTrue(LocalAddressBookStore.shouldBeReadOnly(collectionReadOnly, false))
|
||||
assertTrue(LocalAddressBookStore.shouldBeReadOnly(collectionReadOnly, true))
|
||||
|
||||
val collectionNotReadOnly = mockk<Collection> { every { readOnly() } returns false }
|
||||
assertFalse(LocalAddressBookStore.shouldBeReadOnly(collectionNotReadOnly, false))
|
||||
assertTrue(LocalAddressBookStore.shouldBeReadOnly(collectionNotReadOnly, true))
|
||||
}
|
||||
|
||||
}
|
||||
@@ -13,38 +13,34 @@ import android.provider.ContactsContract
|
||||
import androidx.test.platform.app.InstrumentationRegistry
|
||||
import androidx.test.rule.GrantPermissionRule
|
||||
import at.bitfire.vcard4android.Contact
|
||||
import at.bitfire.vcard4android.GroupMethod
|
||||
import at.bitfire.vcard4android.LabeledProperty
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import dagger.hilt.android.testing.HiltAndroidRule
|
||||
import dagger.hilt.android.testing.HiltAndroidTest
|
||||
import ezvcard.property.Telephone
|
||||
import java.util.LinkedList
|
||||
import javax.inject.Inject
|
||||
import org.junit.After
|
||||
import org.junit.AfterClass
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertNotNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Before
|
||||
import org.junit.BeforeClass
|
||||
import org.junit.ClassRule
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import java.util.LinkedList
|
||||
import javax.inject.Inject
|
||||
|
||||
@HiltAndroidTest
|
||||
class LocalAddressBookTest {
|
||||
|
||||
@Inject @ApplicationContext
|
||||
lateinit var context: Context
|
||||
|
||||
@get:Rule
|
||||
val hiltRule = HiltAndroidRule(this)
|
||||
|
||||
@Inject
|
||||
lateinit var addressbookFactory: LocalTestAddressBook.Factory
|
||||
|
||||
@Inject
|
||||
@ApplicationContext
|
||||
lateinit var context: Context
|
||||
|
||||
val account = Account("Test Account", "Test Account Type")
|
||||
lateinit var addressBook: LocalTestAddressBook
|
||||
|
||||
|
||||
@@ -52,14 +48,13 @@ class LocalAddressBookTest {
|
||||
fun setUp() {
|
||||
hiltRule.inject()
|
||||
|
||||
addressBook = addressbookFactory.create(provider, GroupMethod.CATEGORIES)
|
||||
LocalTestAddressBook.createAccount(context)
|
||||
addressBook = LocalTestAddressBook.create(context, account, provider)
|
||||
}
|
||||
|
||||
@After
|
||||
fun tearDown() {
|
||||
// remove address book
|
||||
addressBook.deleteCollection()
|
||||
addressBook.remove()
|
||||
}
|
||||
|
||||
|
||||
@@ -84,7 +79,7 @@ class LocalAddressBookTest {
|
||||
// rename address book
|
||||
val newName = "New Name"
|
||||
addressBook.renameAccount(newName)
|
||||
assertEquals(Account(newName, LocalTestAddressBook.ACCOUNT.type), addressBook.addressBookAccount)
|
||||
assertEquals(newName, addressBook.addressBookAccount.name)
|
||||
|
||||
// check whether contact is still here (including data rows) and not dirty
|
||||
val result = addressBook.findContactById(id)
|
||||
@@ -112,8 +107,8 @@ class LocalAddressBookTest {
|
||||
|
||||
// rename address book
|
||||
val newName = "New Name"
|
||||
addressBook.renameAccount(newName)
|
||||
assertEquals(Account(newName, LocalTestAddressBook.ACCOUNT.type), addressBook.addressBookAccount)
|
||||
assertTrue(addressBook.renameAccount(newName))
|
||||
assertEquals(newName, addressBook.addressBookAccount.name)
|
||||
|
||||
// check whether group is still here and not dirty
|
||||
val result = addressBook.findGroupById(id)
|
||||
@@ -124,7 +119,6 @@ class LocalAddressBookTest {
|
||||
}
|
||||
|
||||
|
||||
|
||||
companion object {
|
||||
|
||||
@JvmField
|
||||
@@ -136,9 +130,8 @@ class LocalAddressBookTest {
|
||||
@BeforeClass
|
||||
@JvmStatic
|
||||
fun connect() {
|
||||
val context = InstrumentationRegistry.getInstrumentation().targetContext
|
||||
val context = InstrumentationRegistry.getInstrumentation().context
|
||||
provider = context.contentResolver.acquireContentProviderClient(ContactsContract.AUTHORITY)!!
|
||||
assertNotNull(provider)
|
||||
}
|
||||
|
||||
@AfterClass
|
||||
|
||||
@@ -66,7 +66,7 @@ class LocalCalendarTest {
|
||||
|
||||
@After
|
||||
fun tearDown() {
|
||||
calendar.deleteCollection()
|
||||
calendar.delete()
|
||||
}
|
||||
|
||||
|
||||
@@ -119,7 +119,7 @@ class LocalCalendarTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
// Flaky, Needs single or rec init of CalendarProvider (InitCalendarProviderRule)
|
||||
// Needs InitCalendarProviderRule
|
||||
fun testDeleteDirtyEventsWithoutInstances_Recurring_Instances() {
|
||||
val event = Event().apply {
|
||||
dtStart = DtStart("20220120T010203Z")
|
||||
|
||||
@@ -40,29 +40,6 @@ import java.util.UUID
|
||||
|
||||
class LocalEventTest {
|
||||
|
||||
companion object {
|
||||
|
||||
@JvmField
|
||||
@ClassRule
|
||||
val initCalendarProviderRule: TestRule = InitCalendarProviderRule.getInstance()
|
||||
|
||||
private lateinit var provider: ContentProviderClient
|
||||
|
||||
@BeforeClass
|
||||
@JvmStatic
|
||||
fun setUpClass() {
|
||||
val context = InstrumentationRegistry.getInstrumentation().targetContext
|
||||
provider = context.contentResolver.acquireContentProviderClient(CalendarContract.AUTHORITY)!!
|
||||
}
|
||||
|
||||
@AfterClass
|
||||
@JvmStatic
|
||||
fun tearDownClass() {
|
||||
provider.closeCompat()
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
private val account = Account("LocalCalendarTest", ACCOUNT_TYPE_LOCAL)
|
||||
private lateinit var calendar: LocalCalendar
|
||||
|
||||
@@ -74,7 +51,7 @@ class LocalEventTest {
|
||||
|
||||
@After
|
||||
fun removeCalendar() {
|
||||
calendar.deleteCollection()
|
||||
calendar.delete()
|
||||
}
|
||||
|
||||
|
||||
@@ -282,7 +259,7 @@ class LocalEventTest {
|
||||
})
|
||||
}
|
||||
val localEvent = LocalEvent(calendar, event, "filename.ics", null, null, 0)
|
||||
val uri = localEvent.add()
|
||||
localEvent.add()
|
||||
|
||||
calendar.findById(localEvent.id!!)
|
||||
|
||||
@@ -481,4 +458,28 @@ class LocalEventTest {
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
companion object {
|
||||
|
||||
@JvmField
|
||||
@ClassRule
|
||||
val initCalendarProviderRule: TestRule = InitCalendarProviderRule.getInstance()
|
||||
|
||||
private lateinit var provider: ContentProviderClient
|
||||
|
||||
@BeforeClass
|
||||
@JvmStatic
|
||||
fun setUpClass() {
|
||||
val context = InstrumentationRegistry.getInstrumentation().targetContext
|
||||
provider = context.contentResolver.acquireContentProviderClient(CalendarContract.AUTHORITY)!!
|
||||
}
|
||||
|
||||
@AfterClass
|
||||
@JvmStatic
|
||||
fun tearDownClass() {
|
||||
provider.closeCompat()
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
@@ -5,6 +5,7 @@
|
||||
package at.bitfire.davdroid.resource
|
||||
|
||||
import android.Manifest
|
||||
import android.accounts.Account
|
||||
import android.content.ContentProviderClient
|
||||
import android.content.ContentUris
|
||||
import android.content.ContentValues
|
||||
@@ -20,6 +21,7 @@ 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
|
||||
@@ -36,40 +38,13 @@ import javax.inject.Inject
|
||||
@HiltAndroidTest
|
||||
class LocalGroupTest {
|
||||
|
||||
companion object {
|
||||
|
||||
@JvmField
|
||||
@ClassRule
|
||||
val permissionRule = GrantPermissionRule.grant(Manifest.permission.READ_CONTACTS, Manifest.permission.WRITE_CONTACTS)!!
|
||||
|
||||
private lateinit var provider: ContentProviderClient
|
||||
|
||||
@BeforeClass
|
||||
@JvmStatic
|
||||
fun connect() {
|
||||
val context = InstrumentationRegistry.getInstrumentation().targetContext
|
||||
provider = context.contentResolver.acquireContentProviderClient(ContactsContract.AUTHORITY)!!
|
||||
assertNotNull(provider)
|
||||
}
|
||||
|
||||
@AfterClass
|
||||
@JvmStatic
|
||||
fun disconnect() {
|
||||
provider.close()
|
||||
}
|
||||
}
|
||||
@Inject @ApplicationContext
|
||||
lateinit var context: Context
|
||||
|
||||
@get:Rule
|
||||
val hiltRule = HiltAndroidRule(this)
|
||||
|
||||
@Inject
|
||||
@ApplicationContext
|
||||
lateinit var context: Context
|
||||
|
||||
@Inject
|
||||
lateinit var addressbookFactory: LocalTestAddressBook.Factory
|
||||
|
||||
|
||||
val account = Account("Test Account", "Test Account Type")
|
||||
private lateinit var addressBookGroupsAsCategories: LocalTestAddressBook
|
||||
private lateinit var addressBookGroupsAsVCards: LocalTestAddressBook
|
||||
|
||||
@@ -77,14 +52,20 @@ class LocalGroupTest {
|
||||
fun setup() {
|
||||
hiltRule.inject()
|
||||
|
||||
addressBookGroupsAsCategories = addressbookFactory.create(provider, GroupMethod.CATEGORIES)
|
||||
addressBookGroupsAsVCards = addressbookFactory.create(provider, GroupMethod.GROUP_VCARDS)
|
||||
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() {
|
||||
@@ -278,4 +259,27 @@ class LocalGroupTest {
|
||||
add()
|
||||
}
|
||||
|
||||
|
||||
companion object {
|
||||
|
||||
@JvmField
|
||||
@ClassRule
|
||||
val permissionRule = GrantPermissionRule.grant(Manifest.permission.READ_CONTACTS, Manifest.permission.WRITE_CONTACTS)!!
|
||||
|
||||
private lateinit var provider: ContentProviderClient
|
||||
|
||||
@BeforeClass
|
||||
@JvmStatic
|
||||
fun connect() {
|
||||
val context = InstrumentationRegistry.getInstrumentation().context
|
||||
provider = context.contentResolver.acquireContentProviderClient(ContactsContract.AUTHORITY)!!
|
||||
}
|
||||
|
||||
@AfterClass
|
||||
@JvmStatic
|
||||
fun disconnect() {
|
||||
provider.close()
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -10,31 +10,52 @@ 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
|
||||
import at.bitfire.davdroid.sync.SyncFrameworkIntegration
|
||||
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
|
||||
|
||||
class LocalTestAddressBook @AssistedInject constructor(
|
||||
@Assisted account: Account,
|
||||
@Assisted("addressBook") addressBookAccount: Account,
|
||||
@Assisted provider: ContentProviderClient,
|
||||
@Assisted override val groupMethod: GroupMethod,
|
||||
accountSettingsFactory: AccountSettings.Factory,
|
||||
collectionRepository: DavCollectionRepository,
|
||||
@ApplicationContext context: Context,
|
||||
@ApplicationContext private val context: Context,
|
||||
logger: Logger,
|
||||
serviceRepository: DavServiceRepository
|
||||
): LocalAddressBook(ACCOUNT, provider, accountSettingsFactory, collectionRepository, context, logger, serviceRepository) {
|
||||
serviceRepository: DavServiceRepository,
|
||||
syncFramework: SyncFrameworkIntegration
|
||||
): LocalAddressBook(
|
||||
account = account,
|
||||
_addressBookAccount = addressBookAccount,
|
||||
provider = provider,
|
||||
accountSettingsFactory = accountSettingsFactory,
|
||||
collectionRepository = collectionRepository,
|
||||
context = context,
|
||||
dirtyVerifier = Optional.empty(),
|
||||
logger = logger,
|
||||
serviceRepository = serviceRepository,
|
||||
syncFramework = syncFramework
|
||||
) {
|
||||
|
||||
@AssistedFactory
|
||||
interface Factory {
|
||||
fun create(provider: ContentProviderClient, groupMethod: GroupMethod): LocalTestAddressBook
|
||||
fun create(account: Account, @Assisted("addressBook") addressBookAccount: Account, provider: ContentProviderClient, groupMethod: GroupMethod): LocalTestAddressBook
|
||||
}
|
||||
|
||||
override var readOnly: Boolean
|
||||
@@ -82,14 +103,43 @@ class LocalTestAddressBook @AssistedInject constructor(
|
||||
throw FileNotFoundException()
|
||||
}
|
||||
|
||||
fun remove() {
|
||||
val accountManager = AccountManager.get(context)
|
||||
assertTrue(accountManager.removeAccountExplicitly(addressBookAccount))
|
||||
}
|
||||
|
||||
|
||||
companion object {
|
||||
|
||||
val ACCOUNT = Account("LocalTestAddressBook", "at.bitfire.davdroid.test")
|
||||
@dagger.hilt.EntryPoint
|
||||
@InstallIn(SingletonComponent::class)
|
||||
interface EntryPoint {
|
||||
fun localTestAddressBookFactory(): Factory
|
||||
}
|
||||
|
||||
fun createAccount(context: Context) {
|
||||
val am = AccountManager.get(context)
|
||||
assertTrue("Couldn't create account for local test address-book", am.addAccountExplicitly(ACCOUNT, null, null))
|
||||
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)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
package at.bitfire.davdroid.resource.contactrow
|
||||
|
||||
import android.Manifest
|
||||
import android.accounts.Account
|
||||
import android.content.ContentProviderClient
|
||||
import android.content.ContentValues
|
||||
import android.content.Context
|
||||
@@ -31,6 +32,38 @@ import javax.inject.Inject
|
||||
@HiltAndroidTest
|
||||
class CachedGroupMembershipHandlerTest {
|
||||
|
||||
@Inject
|
||||
@ApplicationContext
|
||||
lateinit var context: Context
|
||||
|
||||
@get:Rule
|
||||
val hiltRule = HiltAndroidRule(this)
|
||||
|
||||
val account = Account("Test Account", "Test Account Type")
|
||||
|
||||
@Before
|
||||
fun inject() {
|
||||
hiltRule.inject()
|
||||
}
|
||||
|
||||
|
||||
@Test
|
||||
fun testMembership() {
|
||||
val addressBook = LocalTestAddressBook.create(context, account, provider, GroupMethod.GROUP_VCARDS)
|
||||
try {
|
||||
val contact = Contact()
|
||||
val localContact = LocalContact(addressBook, contact, null, null, 0)
|
||||
CachedGroupMembershipHandler(localContact).handle(ContentValues().apply {
|
||||
put(CachedGroupMembership.GROUP_ID, 123456)
|
||||
put(CachedGroupMembership.RAW_CONTACT_ID, 789)
|
||||
}, contact)
|
||||
assertArrayEquals(arrayOf(123456L), localContact.cachedGroupMemberships.toArray())
|
||||
} finally {
|
||||
addressBook.remove()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
companion object {
|
||||
|
||||
@JvmField
|
||||
@@ -54,34 +87,4 @@ class CachedGroupMembershipHandlerTest {
|
||||
|
||||
}
|
||||
|
||||
|
||||
@Inject
|
||||
lateinit var addressbookFactory: LocalTestAddressBook.Factory
|
||||
|
||||
@Inject
|
||||
@ApplicationContext
|
||||
lateinit var context: Context
|
||||
|
||||
@get:Rule
|
||||
val hiltRule = HiltAndroidRule(this)
|
||||
|
||||
@Before
|
||||
fun inject() {
|
||||
hiltRule.inject()
|
||||
}
|
||||
|
||||
|
||||
@Test
|
||||
fun testMembership() {
|
||||
val addressBook = addressbookFactory.create(provider, GroupMethod.GROUP_VCARDS)
|
||||
|
||||
val contact = Contact()
|
||||
val localContact = LocalContact(addressBook, contact, null, null, 0)
|
||||
CachedGroupMembershipHandler(localContact).handle(ContentValues().apply {
|
||||
put(CachedGroupMembership.GROUP_ID, 123456)
|
||||
put(CachedGroupMembership.RAW_CONTACT_ID, 789)
|
||||
}, contact)
|
||||
assertArrayEquals(arrayOf(123456L), localContact.cachedGroupMemberships.toArray())
|
||||
}
|
||||
|
||||
}
|
||||
@@ -5,6 +5,7 @@
|
||||
package at.bitfire.davdroid.resource.contactrow
|
||||
|
||||
import android.Manifest
|
||||
import android.accounts.Account
|
||||
import android.content.ContentProviderClient
|
||||
import android.content.Context
|
||||
import android.net.Uri
|
||||
@@ -30,38 +31,14 @@ import javax.inject.Inject
|
||||
@HiltAndroidTest
|
||||
class GroupMembershipBuilderTest {
|
||||
|
||||
companion object {
|
||||
|
||||
@JvmField
|
||||
@ClassRule
|
||||
val permissionRule = GrantPermissionRule.grant(Manifest.permission.READ_CONTACTS, Manifest.permission.WRITE_CONTACTS)!!
|
||||
|
||||
private lateinit var provider: ContentProviderClient
|
||||
|
||||
@BeforeClass
|
||||
@JvmStatic
|
||||
fun connect() {
|
||||
val context: Context = InstrumentationRegistry.getInstrumentation().targetContext
|
||||
provider = context.contentResolver.acquireContentProviderClient(ContactsContract.AUTHORITY)!!
|
||||
}
|
||||
|
||||
@AfterClass
|
||||
@JvmStatic
|
||||
fun disconnect() {
|
||||
provider.close()
|
||||
}
|
||||
|
||||
}
|
||||
@Inject @ApplicationContext
|
||||
lateinit var context: Context
|
||||
|
||||
@get:Rule
|
||||
val hiltRule = HiltAndroidRule(this)
|
||||
|
||||
@Inject
|
||||
lateinit var addressbookFactory: LocalTestAddressBook.Factory
|
||||
val account = Account("Test Account", "Test Account Type")
|
||||
|
||||
@Inject
|
||||
@ApplicationContext
|
||||
lateinit var context: Context
|
||||
|
||||
@Before
|
||||
fun inject() {
|
||||
@@ -74,11 +51,15 @@ class GroupMembershipBuilderTest {
|
||||
val contact = Contact().apply {
|
||||
categories += "TEST GROUP"
|
||||
}
|
||||
val addressBookGroupsAsCategories = addressbookFactory.create(provider, GroupMethod.CATEGORIES)
|
||||
GroupMembershipBuilder(Uri.EMPTY, null, contact, addressBookGroupsAsCategories, false).build().also { result ->
|
||||
assertEquals(1, result.size)
|
||||
assertEquals(GroupMembership.CONTENT_ITEM_TYPE, result[0].values[GroupMembership.MIMETYPE])
|
||||
assertEquals(addressBookGroupsAsCategories.findOrCreateGroup("TEST GROUP"), result[0].values[GroupMembership.GROUP_ROW_ID])
|
||||
val addressBookGroupsAsCategories = LocalTestAddressBook.create(context, account, provider, GroupMethod.CATEGORIES)
|
||||
try {
|
||||
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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -87,11 +68,39 @@ class GroupMembershipBuilderTest {
|
||||
val contact = Contact().apply {
|
||||
categories += "TEST GROUP"
|
||||
}
|
||||
val addressBookGroupsAsVCards = addressbookFactory.create(provider, GroupMethod.GROUP_VCARDS)
|
||||
GroupMembershipBuilder(Uri.EMPTY, null, contact, addressBookGroupsAsVCards, false).build().also { result ->
|
||||
// group membership is constructed during post-processing
|
||||
assertEquals(0, result.size)
|
||||
val addressBookGroupsAsVCards = LocalTestAddressBook.create(context, account, provider, GroupMethod.GROUP_VCARDS)
|
||||
try {
|
||||
GroupMembershipBuilder(Uri.EMPTY, null, contact, addressBookGroupsAsVCards, false).build().also { result ->
|
||||
// group membership is constructed during post-processing
|
||||
assertEquals(0, result.size)
|
||||
}
|
||||
} finally {
|
||||
addressBookGroupsAsVCards.remove()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
companion object {
|
||||
|
||||
@JvmField
|
||||
@ClassRule
|
||||
val permissionRule = GrantPermissionRule.grant(Manifest.permission.READ_CONTACTS, Manifest.permission.WRITE_CONTACTS)!!
|
||||
|
||||
private lateinit var provider: ContentProviderClient
|
||||
|
||||
@BeforeClass
|
||||
@JvmStatic
|
||||
fun connect() {
|
||||
val context: Context = InstrumentationRegistry.getInstrumentation().context
|
||||
provider = context.contentResolver.acquireContentProviderClient(ContactsContract.AUTHORITY)!!
|
||||
}
|
||||
|
||||
@AfterClass
|
||||
@JvmStatic
|
||||
fun disconnect() {
|
||||
provider.close()
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
@@ -5,6 +5,7 @@
|
||||
package at.bitfire.davdroid.resource.contactrow
|
||||
|
||||
import android.Manifest
|
||||
import android.accounts.Account
|
||||
import android.content.ContentProviderClient
|
||||
import android.content.ContentValues
|
||||
import android.content.Context
|
||||
@@ -33,6 +34,58 @@ import javax.inject.Inject
|
||||
@HiltAndroidTest
|
||||
class GroupMembershipHandlerTest {
|
||||
|
||||
@Inject @ApplicationContext
|
||||
lateinit var context: Context
|
||||
|
||||
@get:Rule
|
||||
var hiltRule = HiltAndroidRule(this)
|
||||
|
||||
val account = Account("Test Account", "Test Account Type")
|
||||
|
||||
@Before
|
||||
fun inject() {
|
||||
hiltRule.inject()
|
||||
}
|
||||
|
||||
|
||||
@Test
|
||||
fun testMembership_GroupsAsCategories() {
|
||||
val addressBookGroupsAsCategories = LocalTestAddressBook.create(context, account, provider, GroupMethod.CATEGORIES)
|
||||
try {
|
||||
val addressBookGroupsAsCategoriesGroup = addressBookGroupsAsCategories.findOrCreateGroup("TEST GROUP")
|
||||
|
||||
val contact = Contact()
|
||||
val localContact = LocalContact(addressBookGroupsAsCategories, contact, null, null, 0)
|
||||
GroupMembershipHandler(localContact).handle(ContentValues().apply {
|
||||
put(CachedGroupMembership.GROUP_ID, addressBookGroupsAsCategoriesGroup)
|
||||
put(CachedGroupMembership.RAW_CONTACT_ID, -1)
|
||||
}, contact)
|
||||
assertArrayEquals(arrayOf(addressBookGroupsAsCategoriesGroup), localContact.groupMemberships.toArray())
|
||||
assertArrayEquals(arrayOf("TEST GROUP"), contact.categories.toArray())
|
||||
} finally {
|
||||
addressBookGroupsAsCategories.remove()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@Test
|
||||
fun testMembership_GroupsAsVCards() {
|
||||
val addressBookGroupsAsVCards = LocalTestAddressBook.create(context, account, provider, GroupMethod.GROUP_VCARDS)
|
||||
try {
|
||||
val contact = Contact()
|
||||
val localContact = LocalContact(addressBookGroupsAsVCards, contact, null, null, 0)
|
||||
GroupMembershipHandler(localContact).handle(ContentValues().apply {
|
||||
put(CachedGroupMembership.GROUP_ID, 12345) // because the group name is not queried and put into CATEGORIES, the group doesn't have to really exist
|
||||
put(CachedGroupMembership.RAW_CONTACT_ID, -1)
|
||||
}, contact)
|
||||
assertArrayEquals(arrayOf(12345L), localContact.groupMemberships.toArray())
|
||||
assertTrue(contact.categories.isEmpty())
|
||||
} finally {
|
||||
addressBookGroupsAsVCards.remove()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
companion object {
|
||||
|
||||
@JvmField
|
||||
@@ -57,49 +110,4 @@ class GroupMembershipHandlerTest {
|
||||
|
||||
}
|
||||
|
||||
@Inject
|
||||
lateinit var addressbookFactory: LocalTestAddressBook.Factory
|
||||
|
||||
@Inject @ApplicationContext
|
||||
lateinit var context: Context
|
||||
|
||||
@get:Rule
|
||||
var hiltRule = HiltAndroidRule(this)
|
||||
|
||||
@Before
|
||||
fun inject() {
|
||||
hiltRule.inject()
|
||||
}
|
||||
|
||||
|
||||
@Test
|
||||
fun testMembership_GroupsAsCategories() {
|
||||
val addressBookGroupsAsCategories = addressbookFactory.create(provider, GroupMethod.CATEGORIES)
|
||||
val addressBookGroupsAsCategoriesGroup = addressBookGroupsAsCategories.findOrCreateGroup("TEST GROUP")
|
||||
|
||||
val contact = Contact()
|
||||
val localContact = LocalContact(addressBookGroupsAsCategories, contact, null, null, 0)
|
||||
GroupMembershipHandler(localContact).handle(ContentValues().apply {
|
||||
put(CachedGroupMembership.GROUP_ID, addressBookGroupsAsCategoriesGroup)
|
||||
put(CachedGroupMembership.RAW_CONTACT_ID, -1)
|
||||
}, contact)
|
||||
assertArrayEquals(arrayOf(addressBookGroupsAsCategoriesGroup), localContact.groupMemberships.toArray())
|
||||
assertArrayEquals(arrayOf("TEST GROUP"), contact.categories.toArray())
|
||||
}
|
||||
|
||||
|
||||
@Test
|
||||
fun testMembership_GroupsAsVCards() {
|
||||
val addressBookGroupsAsVCards = addressbookFactory.create(provider, GroupMethod.GROUP_VCARDS)
|
||||
|
||||
val contact = Contact()
|
||||
val localContact = LocalContact(addressBookGroupsAsVCards, contact, null, null, 0)
|
||||
GroupMembershipHandler(localContact).handle(ContentValues().apply {
|
||||
put(CachedGroupMembership.GROUP_ID, 12345) // because the group name is not queried and put into CATEGORIES, the group doesn't have to really exist
|
||||
put(CachedGroupMembership.RAW_CONTACT_ID, -1)
|
||||
}, contact)
|
||||
assertArrayEquals(arrayOf(12345L), localContact.groupMemberships.toArray())
|
||||
assertTrue(contact.categories.isEmpty())
|
||||
}
|
||||
|
||||
}
|
||||
@@ -37,24 +37,7 @@ import javax.inject.Inject
|
||||
@HiltAndroidTest
|
||||
class CollectionListRefresherTest {
|
||||
|
||||
companion object {
|
||||
private const val PATH_CALDAV = "/caldav"
|
||||
private const val PATH_CARDDAV = "/carddav"
|
||||
|
||||
private const val SUBPATH_PRINCIPAL = "/principal"
|
||||
private const val SUBPATH_PRINCIPAL_INACCESSIBLE = "/inaccessible-principal"
|
||||
private const val SUBPATH_PRINCIPAL_WITHOUT_COLLECTIONS = "/principal2"
|
||||
private const val SUBPATH_ADDRESSBOOK_HOMESET = "/addressbooks-homeset"
|
||||
private const val SUBPATH_ADDRESSBOOK_HOMESET_EMPTY = "/addressbooks-homeset-empty"
|
||||
private const val SUBPATH_ADDRESSBOOK = "/addressbooks/my-contacts"
|
||||
private const val SUBPATH_ADDRESSBOOK_INACCESSIBLE = "/addressbooks/inaccessible-contacts"
|
||||
}
|
||||
|
||||
@get:Rule
|
||||
var hiltRule = HiltAndroidRule(this)
|
||||
|
||||
@Inject
|
||||
@ApplicationContext
|
||||
@Inject @ApplicationContext
|
||||
lateinit var context: Context
|
||||
|
||||
@Inject
|
||||
@@ -69,6 +52,9 @@ class CollectionListRefresherTest {
|
||||
@Inject
|
||||
lateinit var settings: SettingsManager
|
||||
|
||||
@get:Rule
|
||||
var hiltRule = HiltAndroidRule(this)
|
||||
|
||||
private val mockServer = MockWebServer()
|
||||
private lateinit var client: HttpClient
|
||||
|
||||
@@ -100,9 +86,23 @@ class CollectionListRefresherTest {
|
||||
// Query home sets
|
||||
refresherFactory.create(service, client.okHttpClient).discoverHomesets(baseUrl)
|
||||
|
||||
// Check home sets have been saved to database
|
||||
assertEquals(mockServer.url("$PATH_CARDDAV$SUBPATH_ADDRESSBOOK_HOMESET/"), db.homeSetDao().getByService(service.id).first().url)
|
||||
assertEquals(1, db.homeSetDao().getByService(service.id).size)
|
||||
// Check home set has been saved correctly to database
|
||||
val savedHomesets = db.homeSetDao().getByService(service.id)
|
||||
assertEquals(2, savedHomesets.size)
|
||||
|
||||
// Home set from current-user-principal
|
||||
val personalHomeset = savedHomesets[1]
|
||||
assertEquals(mockServer.url("$PATH_CARDDAV$SUBPATH_ADDRESSBOOK_HOMESET_PERSONAL/"), personalHomeset.url)
|
||||
assertEquals(service.id, personalHomeset.serviceId)
|
||||
// personal should be true for homesets detected at first query of current-user-principal (Even if they occur in a group principal as well!!!)
|
||||
assertEquals(true, personalHomeset.personal)
|
||||
|
||||
// Home set found in a group principal
|
||||
val groupHomeset = savedHomesets[0]
|
||||
assertEquals(mockServer.url("$PATH_CARDDAV$SUBPATH_ADDRESSBOOK_HOMESET_NON_PERSONAL/"), groupHomeset.url)
|
||||
assertEquals(service.id, groupHomeset.serviceId)
|
||||
// personal should be false for homesets not detected at the first query of current-user-principal (IE. in groups)
|
||||
assertEquals(false, groupHomeset.personal)
|
||||
}
|
||||
|
||||
|
||||
@@ -114,7 +114,7 @@ class CollectionListRefresherTest {
|
||||
|
||||
// save homeset in DB
|
||||
val homesetId = db.homeSetDao().insert(
|
||||
HomeSet(id=0, service.id, true, mockServer.url("$PATH_CARDDAV$SUBPATH_ADDRESSBOOK_HOMESET"))
|
||||
HomeSet(id=0, service.id, true, mockServer.url("$PATH_CARDDAV$SUBPATH_ADDRESSBOOK_HOMESET_PERSONAL"))
|
||||
)
|
||||
|
||||
// Refresh
|
||||
@@ -248,7 +248,7 @@ class CollectionListRefresherTest {
|
||||
|
||||
// save a homeset in DB
|
||||
val homesetId = db.homeSetDao().insert(
|
||||
HomeSet(id=0, service.id, true, mockServer.url("$PATH_CARDDAV$SUBPATH_ADDRESSBOOK_HOMESET"))
|
||||
HomeSet(id=0, service.id, true, mockServer.url("$PATH_CARDDAV$SUBPATH_ADDRESSBOOK_HOMESET_PERSONAL"))
|
||||
)
|
||||
|
||||
// place collection in DB - as part of the homeset
|
||||
@@ -482,7 +482,7 @@ class CollectionListRefresherTest {
|
||||
url = mockServer.url("$PATH_CARDDAV$SUBPATH_ADDRESSBOOK/")
|
||||
)
|
||||
val homesets = listOf(
|
||||
HomeSet(0, service.id, true, mockServer.url("$PATH_CARDDAV$SUBPATH_ADDRESSBOOK_HOMESET"))
|
||||
HomeSet(0, service.id, true, mockServer.url("$PATH_CARDDAV$SUBPATH_ADDRESSBOOK_HOMESET_PERSONAL"))
|
||||
)
|
||||
|
||||
val refresher = refresherFactory.create(service, client.okHttpClient)
|
||||
@@ -506,7 +506,7 @@ class CollectionListRefresherTest {
|
||||
url = mockServer.url("$PATH_CARDDAV$SUBPATH_ADDRESSBOOK/")
|
||||
)
|
||||
val homesets = listOf(
|
||||
HomeSet(0, service.id, false, mockServer.url("$PATH_CARDDAV$SUBPATH_ADDRESSBOOK_HOMESET"))
|
||||
HomeSet(0, service.id, false, mockServer.url("$PATH_CARDDAV$SUBPATH_ADDRESSBOOK_HOMESET_PERSONAL"))
|
||||
)
|
||||
|
||||
val refresher = refresherFactory.create(service, client.okHttpClient)
|
||||
@@ -531,7 +531,7 @@ class CollectionListRefresherTest {
|
||||
url = url
|
||||
)
|
||||
val homesets = listOf(
|
||||
HomeSet(0, service.id, false, mockServer.url("$PATH_CARDDAV$SUBPATH_ADDRESSBOOK_HOMESET"))
|
||||
HomeSet(0, service.id, false, mockServer.url("$PATH_CARDDAV$SUBPATH_ADDRESSBOOK_HOMESET_PERSONAL"))
|
||||
)
|
||||
|
||||
val refresher = refresherFactory.create(service, client.okHttpClient)
|
||||
@@ -555,7 +555,7 @@ class CollectionListRefresherTest {
|
||||
url = mockServer.url("$PATH_CARDDAV$SUBPATH_ADDRESSBOOK/")
|
||||
)
|
||||
val homesets = listOf(
|
||||
HomeSet(0, service.id, false, mockServer.url("$PATH_CARDDAV$SUBPATH_ADDRESSBOOK_HOMESET"))
|
||||
HomeSet(0, service.id, false, mockServer.url("$PATH_CARDDAV$SUBPATH_ADDRESSBOOK_HOMESET_PERSONAL"))
|
||||
)
|
||||
|
||||
val refresher = refresherFactory.create(service, client.okHttpClient)
|
||||
@@ -579,7 +579,7 @@ class CollectionListRefresherTest {
|
||||
url = mockServer.url("$PATH_CARDDAV$SUBPATH_ADDRESSBOOK/")
|
||||
)
|
||||
val homesets = listOf(
|
||||
HomeSet(0, service.id, true, mockServer.url("$PATH_CARDDAV$SUBPATH_ADDRESSBOOK_HOMESET"))
|
||||
HomeSet(0, service.id, true, mockServer.url("$PATH_CARDDAV$SUBPATH_ADDRESSBOOK_HOMESET_PERSONAL"))
|
||||
)
|
||||
|
||||
val refresher = refresherFactory.create(service, client.okHttpClient)
|
||||
@@ -604,7 +604,7 @@ class CollectionListRefresherTest {
|
||||
url = collectionUrl
|
||||
)
|
||||
val homesets = listOf(
|
||||
HomeSet(0, service.id, true, mockServer.url("$PATH_CARDDAV$SUBPATH_ADDRESSBOOK_HOMESET"))
|
||||
HomeSet(0, service.id, true, mockServer.url("$PATH_CARDDAV$SUBPATH_ADDRESSBOOK_HOMESET_PERSONAL"))
|
||||
)
|
||||
|
||||
val refresher = refresherFactory.create(service, client.okHttpClient)
|
||||
@@ -620,6 +620,20 @@ class CollectionListRefresherTest {
|
||||
return db.serviceDao().get(serviceId)
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val PATH_CALDAV = "/caldav"
|
||||
private const val PATH_CARDDAV = "/carddav"
|
||||
|
||||
private const val SUBPATH_PRINCIPAL = "/principal"
|
||||
private const val SUBPATH_PRINCIPAL_INACCESSIBLE = "/inaccessible-principal"
|
||||
private const val SUBPATH_PRINCIPAL_WITHOUT_COLLECTIONS = "/principal2"
|
||||
private const val SUBPATH_GROUPPRINCIPAL_0 = "/groups/0"
|
||||
private const val SUBPATH_ADDRESSBOOK_HOMESET_PERSONAL = "/addressbooks-homeset"
|
||||
private const val SUBPATH_ADDRESSBOOK_HOMESET_NON_PERSONAL = "/addressbooks-homeset-non-personal"
|
||||
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(
|
||||
private val logger: Logger
|
||||
@@ -640,8 +654,11 @@ class CollectionListRefresherTest {
|
||||
"<resourcetype><principal/></resourcetype>" +
|
||||
"<displayname>Mr. Wobbles</displayname>" +
|
||||
"<CARD:addressbook-home-set>" +
|
||||
" <href>${PATH_CARDDAV}${SUBPATH_ADDRESSBOOK_HOMESET}</href>" +
|
||||
"</CARD:addressbook-home-set>"
|
||||
" <href>${PATH_CARDDAV}${SUBPATH_ADDRESSBOOK_HOMESET_PERSONAL}</href>" +
|
||||
"</CARD:addressbook-home-set>" +
|
||||
"<group-membership>" +
|
||||
" <href>${PATH_CARDDAV}${SUBPATH_GROUPPRINCIPAL_0}</href>" +
|
||||
"</group-membership>"
|
||||
|
||||
PATH_CARDDAV + SUBPATH_PRINCIPAL_WITHOUT_COLLECTIONS ->
|
||||
"<CARD:addressbook-home-set>" +
|
||||
@@ -649,8 +666,16 @@ class CollectionListRefresherTest {
|
||||
"</CARD:addressbook-home-set>" +
|
||||
"<displayname>Mr. Wobbles Jr.</displayname>"
|
||||
|
||||
PATH_CARDDAV + SUBPATH_GROUPPRINCIPAL_0 ->
|
||||
"<resourcetype><principal/></resourcetype>" +
|
||||
"<displayname>All address books</displayname>" +
|
||||
"<CARD:addressbook-home-set>" +
|
||||
" <href>${PATH_CARDDAV}${SUBPATH_ADDRESSBOOK_HOMESET_PERSONAL}</href>" +
|
||||
" <href>${PATH_CARDDAV}${SUBPATH_ADDRESSBOOK_HOMESET_NON_PERSONAL}</href>" +
|
||||
"</CARD:addressbook-home-set>"
|
||||
|
||||
PATH_CARDDAV + SUBPATH_ADDRESSBOOK,
|
||||
PATH_CARDDAV + SUBPATH_ADDRESSBOOK_HOMESET ->
|
||||
PATH_CARDDAV + SUBPATH_ADDRESSBOOK_HOMESET_PERSONAL ->
|
||||
"<resourcetype>" +
|
||||
" <collection/>" +
|
||||
" <CARD:addressbook/>" +
|
||||
@@ -661,6 +686,17 @@ class CollectionListRefresherTest {
|
||||
" <href>${PATH_CARDDAV + SUBPATH_PRINCIPAL}</href>" +
|
||||
"</owner>"
|
||||
|
||||
PATH_CARDDAV + SUBPATH_ADDRESSBOOK_HOMESET_NON_PERSONAL ->
|
||||
"<resourcetype>" +
|
||||
" <collection/>" +
|
||||
" <CARD:addressbook/>" +
|
||||
"</resourcetype>" +
|
||||
"<displayname>Freds Contacts (not mine)</displayname>" +
|
||||
"<CARD:addressbook-description>Not personal contacts</CARD:addressbook-description>" +
|
||||
"<owner>" +
|
||||
" <href>${PATH_CARDDAV + SUBPATH_PRINCIPAL}</href>" + // OK, user is allowed to own non-personal contacts
|
||||
"</owner>"
|
||||
|
||||
PATH_CALDAV + SUBPATH_PRINCIPAL ->
|
||||
"<CAL:calendar-user-address-set>" +
|
||||
" <href>urn:unknown-entry</href>" +
|
||||
@@ -676,7 +712,7 @@ class CollectionListRefresherTest {
|
||||
var responseBody = ""
|
||||
var responseCode = 207
|
||||
when (path) {
|
||||
PATH_CARDDAV + SUBPATH_ADDRESSBOOK_HOMESET ->
|
||||
PATH_CARDDAV + SUBPATH_ADDRESSBOOK_HOMESET_PERSONAL ->
|
||||
responseBody =
|
||||
"<multistatus xmlns='DAV:' xmlns:CARD='urn:ietf:params:xml:ns:carddav' xmlns:CAL='urn:ietf:params:xml:ns:caldav'>" +
|
||||
"<response>" +
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
/*
|
||||
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
|
||||
*/
|
||||
|
||||
package at.bitfire.davdroid.settings
|
||||
|
||||
import android.accounts.AccountManager
|
||||
import android.content.Context
|
||||
import at.bitfire.davdroid.TestUtils
|
||||
import at.bitfire.davdroid.sync.account.TestAccount
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import dagger.hilt.android.testing.HiltAndroidRule
|
||||
import dagger.hilt.android.testing.HiltAndroidTest
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Before
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import javax.inject.Inject
|
||||
|
||||
@HiltAndroidTest
|
||||
class AccountSettingsTest {
|
||||
|
||||
@Inject @ApplicationContext
|
||||
lateinit var context: Context
|
||||
|
||||
@Inject
|
||||
lateinit var accountSettingsFactory: AccountSettings.Factory
|
||||
|
||||
@get:Rule
|
||||
val hiltRule = HiltAndroidRule(this)
|
||||
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
hiltRule.inject()
|
||||
TestUtils.setUpWorkManager(context)
|
||||
}
|
||||
|
||||
|
||||
@Test(expected = IllegalArgumentException::class)
|
||||
fun testUpdate_MissingMigrations() {
|
||||
TestAccount.provide(version = 1) { account ->
|
||||
// will run AccountSettings.update
|
||||
accountSettingsFactory.create(account, abortOnMissingMigration = true)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testUpdate_RunAllMigrations() {
|
||||
TestAccount.provide(version = 6) { account ->
|
||||
// will run AccountSettings.update
|
||||
accountSettingsFactory.create(account, abortOnMissingMigration = true)
|
||||
|
||||
val accountManager = AccountManager.get(context)
|
||||
val version = accountManager.getUserData(account, AccountSettings.KEY_SETTINGS_VERSION).toInt()
|
||||
assertEquals(AccountSettings.CURRENT_VERSION, version)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
/*
|
||||
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
|
||||
*/
|
||||
|
||||
package at.bitfire.davdroid.settings.migration
|
||||
|
||||
import android.accounts.Account
|
||||
import android.accounts.AccountManager
|
||||
import android.content.Context
|
||||
import androidx.test.rule.GrantPermissionRule
|
||||
import at.bitfire.davdroid.R
|
||||
import at.bitfire.davdroid.db.AppDatabase
|
||||
import at.bitfire.davdroid.db.Collection
|
||||
import at.bitfire.davdroid.db.Service
|
||||
import at.bitfire.davdroid.resource.LocalAddressBook
|
||||
import at.bitfire.davdroid.sync.account.TestAccount
|
||||
import at.bitfire.davdroid.sync.account.setAndVerifyUserData
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import dagger.hilt.android.testing.HiltAndroidRule
|
||||
import dagger.hilt.android.testing.HiltAndroidTest
|
||||
import okhttp3.HttpUrl.Companion.toHttpUrl
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Before
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import javax.inject.Inject
|
||||
|
||||
@HiltAndroidTest
|
||||
class AccountSettingsMigration17Test {
|
||||
|
||||
@Inject @ApplicationContext
|
||||
lateinit var context: Context
|
||||
|
||||
@Inject
|
||||
lateinit var db: AppDatabase
|
||||
|
||||
@Inject
|
||||
lateinit var migration: AccountSettingsMigration17
|
||||
|
||||
@get:Rule
|
||||
val hiltRule = HiltAndroidRule(this)
|
||||
|
||||
@get:Rule
|
||||
val permissionRule = GrantPermissionRule.grant(android.Manifest.permission.READ_CONTACTS, android.Manifest.permission.WRITE_CONTACTS)
|
||||
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
hiltRule.inject()
|
||||
}
|
||||
|
||||
|
||||
@Test
|
||||
fun testMigrate_OldAddressBook_CollectionInDB() {
|
||||
TestAccount.provide(version = 16) { account ->
|
||||
val accountManager = AccountManager.get(context)
|
||||
val addressBookAccountType = context.getString(R.string.account_type_address_book)
|
||||
var addressBookAccount = Account("Address Book", addressBookAccountType)
|
||||
assertTrue(accountManager.addAccountExplicitly(addressBookAccount, null, null))
|
||||
|
||||
try {
|
||||
// address book has account + URL
|
||||
val url = "https://example.com/address-book"
|
||||
accountManager.setAndVerifyUserData(addressBookAccount, "real_account_name", account.name)
|
||||
accountManager.setAndVerifyUserData(addressBookAccount, LocalAddressBook.USER_DATA_URL, url)
|
||||
|
||||
// and is known in database
|
||||
db.serviceDao().insertOrReplace(
|
||||
Service(
|
||||
id = 1, accountName = account.name, type = Service.TYPE_CARDDAV, principal = null
|
||||
)
|
||||
)
|
||||
db.collectionDao().insert(
|
||||
Collection(
|
||||
id = 100,
|
||||
serviceId = 1,
|
||||
url = url.toHttpUrl(),
|
||||
type = Collection.TYPE_ADDRESSBOOK,
|
||||
displayName = "Some Address Book"
|
||||
)
|
||||
)
|
||||
|
||||
// run migration
|
||||
migration.migrate(account)
|
||||
|
||||
// migration renames address book, update account
|
||||
addressBookAccount = accountManager.getAccountsByType(addressBookAccountType).filter {
|
||||
accountManager.getUserData(it, LocalAddressBook.USER_DATA_URL) == url
|
||||
}.first()
|
||||
assertEquals("Some Address Book (${account.name}) #100", addressBookAccount.name)
|
||||
|
||||
// ID is now assigned
|
||||
assertEquals(100L, accountManager.getUserData(addressBookAccount, LocalAddressBook.USER_DATA_COLLECTION_ID)?.toLong())
|
||||
} finally {
|
||||
accountManager.removeAccountExplicitly(addressBookAccount)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
/*
|
||||
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
|
||||
*/
|
||||
|
||||
package at.bitfire.davdroid.settings.migration
|
||||
|
||||
import android.accounts.Account
|
||||
import android.accounts.AccountManager
|
||||
import android.content.Context
|
||||
import at.bitfire.davdroid.R
|
||||
import at.bitfire.davdroid.db.AppDatabase
|
||||
import at.bitfire.davdroid.db.Collection
|
||||
import at.bitfire.davdroid.db.Service
|
||||
import at.bitfire.davdroid.resource.LocalAddressBook
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import dagger.hilt.android.testing.HiltAndroidRule
|
||||
import dagger.hilt.android.testing.HiltAndroidTest
|
||||
import io.mockk.MockKAnnotations
|
||||
import io.mockk.every
|
||||
import io.mockk.impl.annotations.InjectMockKs
|
||||
import io.mockk.impl.annotations.MockK
|
||||
import io.mockk.mockk
|
||||
import io.mockk.mockkObject
|
||||
import io.mockk.verify
|
||||
import okhttp3.HttpUrl.Companion.toHttpUrl
|
||||
import org.junit.Before
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import javax.inject.Inject
|
||||
|
||||
@HiltAndroidTest
|
||||
class AccountSettingsMigration18Test {
|
||||
|
||||
@Inject @ApplicationContext
|
||||
lateinit var context: Context
|
||||
|
||||
@MockK
|
||||
lateinit var db: AppDatabase
|
||||
|
||||
@InjectMockKs
|
||||
lateinit var migration: AccountSettingsMigration18
|
||||
|
||||
@get:Rule
|
||||
val hiltRule = HiltAndroidRule(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)
|
||||
|
||||
val accountManager = AccountManager.get(context)
|
||||
mockkObject(accountManager)
|
||||
every { accountManager.getAccountsByType(addressBookAccountType) } returns arrayOf(addressBookAccount)
|
||||
every { accountManager.getUserData(addressBookAccount, LocalAddressBook.USER_DATA_COLLECTION_ID) } returns "123"
|
||||
|
||||
val account = Account("test", "test")
|
||||
migration.migrate(account)
|
||||
|
||||
verify(exactly = 0) {
|
||||
accountManager.setUserData(addressBookAccount, any(), any())
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testMigrate_AddressBook_NoCollection() {
|
||||
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)
|
||||
|
||||
val accountManager = AccountManager.get(context)
|
||||
mockkObject(accountManager)
|
||||
every { accountManager.getAccountsByType(addressBookAccountType) } returns arrayOf(addressBookAccount)
|
||||
every { accountManager.getUserData(addressBookAccount, LocalAddressBook.USER_DATA_COLLECTION_ID) } returns "123"
|
||||
|
||||
val account = Account("test", "test")
|
||||
migration.migrate(account)
|
||||
|
||||
verify(exactly = 0) {
|
||||
accountManager.setUserData(addressBookAccount, any(), any())
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testMigrate_AddressBook_ValidCollection() {
|
||||
val account = Account("test", "test")
|
||||
|
||||
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
|
||||
))
|
||||
}
|
||||
|
||||
val addressBookAccountType = context.getString(R.string.account_type_address_book)
|
||||
var addressBookAccount = Account("Address Book", addressBookAccountType)
|
||||
|
||||
val accountManager = AccountManager.get(context)
|
||||
mockkObject(accountManager)
|
||||
every { accountManager.getAccountsByType(addressBookAccountType) } returns arrayOf(addressBookAccount)
|
||||
every { accountManager.getUserData(addressBookAccount, LocalAddressBook.USER_DATA_COLLECTION_ID) } returns "100"
|
||||
|
||||
migration.migrate(account)
|
||||
|
||||
verify {
|
||||
accountManager.setUserData(addressBookAccount, LocalAddressBook.USER_DATA_ACCOUNT_NAME, account.name)
|
||||
accountManager.setUserData(addressBookAccount, LocalAddressBook.USER_DATA_ACCOUNT_TYPE, account.type)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
/*
|
||||
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
|
||||
*/
|
||||
|
||||
package at.bitfire.davdroid.settings.migration
|
||||
|
||||
import android.accounts.Account
|
||||
import android.content.Context
|
||||
import android.util.Log
|
||||
import androidx.hilt.work.HiltWorkerFactory
|
||||
import androidx.work.Configuration
|
||||
import androidx.work.WorkManager
|
||||
import androidx.work.testing.WorkManagerTestInitHelper
|
||||
import at.bitfire.davdroid.sync.AutomaticSyncManager
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import dagger.hilt.android.testing.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.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
|
||||
import javax.inject.Inject
|
||||
|
||||
@HiltAndroidTest
|
||||
class AccountSettingsMigration19Test {
|
||||
|
||||
@Inject @ApplicationContext
|
||||
@SpyK
|
||||
lateinit var context: Context
|
||||
|
||||
@MockK(relaxed = true)
|
||||
lateinit var automaticSyncManager: AutomaticSyncManager
|
||||
|
||||
@InjectMockKs
|
||||
lateinit var migration: AccountSettingsMigration19
|
||||
|
||||
@Inject
|
||||
lateinit var workerFactory: HiltWorkerFactory
|
||||
|
||||
@get:Rule
|
||||
val hiltRule = HiltAndroidRule(this)
|
||||
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
hiltRule.inject()
|
||||
|
||||
// Initialize WorkManager for instrumentation tests.
|
||||
val config = Configuration.Builder()
|
||||
.setMinimumLoggingLevel(Log.DEBUG)
|
||||
.setWorkerFactory(workerFactory)
|
||||
.build()
|
||||
WorkManagerTestInitHelper.initializeTestWorkManager(context, config)
|
||||
|
||||
MockKAnnotations.init(this)
|
||||
}
|
||||
|
||||
@After
|
||||
fun tearDown() {
|
||||
unmockkAll()
|
||||
}
|
||||
|
||||
|
||||
@Test
|
||||
fun testMigrate_CancelsOldWorkersAndUpdatesAutomaticSync() {
|
||||
val workManager = WorkManager.getInstance(context)
|
||||
mockkObject(workManager)
|
||||
|
||||
val account = Account("Some", "Test")
|
||||
migration.migrate(account)
|
||||
|
||||
verify {
|
||||
workManager.cancelUniqueWork("periodic-sync at.bitfire.davdroid.addressbooks Test/Some")
|
||||
workManager.cancelUniqueWork("periodic-sync com.android.calendar Test/Some")
|
||||
workManager.cancelUniqueWork("periodic-sync at.techbee.jtx.provider Test/Some")
|
||||
workManager.cancelUniqueWork("periodic-sync org.dmfs.tasks Test/Some")
|
||||
workManager.cancelUniqueWork("periodic-sync org.tasks.opentasks Test/Some")
|
||||
|
||||
automaticSyncManager.updateAutomaticSync(account)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -21,8 +21,6 @@ class LocalTestCollection(
|
||||
override val readOnly: Boolean
|
||||
get() = throw NotImplementedError()
|
||||
|
||||
override fun deleteCollection(): Boolean = true
|
||||
|
||||
override fun findDeleted() = entries.filter { it.deleted }
|
||||
override fun findDirty() = entries.filter { it.dirty }
|
||||
|
||||
|
||||
@@ -9,16 +9,14 @@ import android.content.Context
|
||||
import android.content.SyncResult
|
||||
import android.os.Bundle
|
||||
import android.provider.CalendarContract
|
||||
import android.util.Log
|
||||
import androidx.hilt.work.HiltWorkerFactory
|
||||
import androidx.work.Configuration
|
||||
import androidx.work.WorkInfo
|
||||
import androidx.work.WorkManager
|
||||
import androidx.work.testing.WorkManagerTestInitHelper
|
||||
import at.bitfire.davdroid.TestUtils
|
||||
import at.bitfire.davdroid.repository.DavCollectionRepository
|
||||
import at.bitfire.davdroid.repository.DavServiceRepository
|
||||
import at.bitfire.davdroid.settings.AccountSettings
|
||||
import at.bitfire.davdroid.sync.account.TestAccountAuthenticator
|
||||
import at.bitfire.davdroid.sync.account.TestAccount
|
||||
import at.bitfire.davdroid.sync.worker.SyncWorkerManager
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import dagger.hilt.android.testing.HiltAndroidRule
|
||||
@@ -42,7 +40,6 @@ import org.junit.Before
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.rules.Timeout
|
||||
import java.util.concurrent.Executors
|
||||
import java.util.logging.Logger
|
||||
import javax.inject.Inject
|
||||
import kotlin.coroutines.cancellation.CancellationException
|
||||
@@ -85,20 +82,14 @@ class SyncAdapterServicesTest {
|
||||
@Before
|
||||
fun setUp() {
|
||||
hiltRule.inject()
|
||||
TestUtils.setUpWorkManager(context, workerFactory)
|
||||
|
||||
account = TestAccountAuthenticator.create()
|
||||
|
||||
// Initialize WorkManager for instrumentation tests.
|
||||
val config = Configuration.Builder()
|
||||
.setMinimumLoggingLevel(Log.DEBUG)
|
||||
.setWorkerFactory(workerFactory)
|
||||
.build()
|
||||
WorkManagerTestInitHelper.initializeTestWorkManager(context, config)
|
||||
account = TestAccount.create()
|
||||
}
|
||||
|
||||
@After
|
||||
fun tearDown() {
|
||||
TestAccountAuthenticator.remove(account)
|
||||
TestAccount.remove(account)
|
||||
unmockkAll()
|
||||
}
|
||||
|
||||
|
||||
@@ -6,22 +6,20 @@ package at.bitfire.davdroid.sync
|
||||
|
||||
import android.accounts.Account
|
||||
import android.content.Context
|
||||
import android.util.Log
|
||||
import androidx.core.app.NotificationManagerCompat
|
||||
import androidx.hilt.work.HiltWorkerFactory
|
||||
import androidx.work.Configuration
|
||||
import androidx.work.testing.WorkManagerTestInitHelper
|
||||
import at.bitfire.dav4jvm.PropStat
|
||||
import at.bitfire.dav4jvm.Response
|
||||
import at.bitfire.dav4jvm.Response.HrefRelation
|
||||
import at.bitfire.dav4jvm.property.webdav.GetETag
|
||||
import at.bitfire.davdroid.TestUtils
|
||||
import at.bitfire.davdroid.TestUtils.assertWithin
|
||||
import at.bitfire.davdroid.db.Collection
|
||||
import at.bitfire.davdroid.db.SyncState
|
||||
import at.bitfire.davdroid.network.HttpClient
|
||||
import at.bitfire.davdroid.repository.DavSyncStatsRepository
|
||||
import at.bitfire.davdroid.settings.AccountSettings
|
||||
import at.bitfire.davdroid.sync.account.TestAccountAuthenticator
|
||||
import at.bitfire.davdroid.sync.account.TestAccount
|
||||
import dagger.Module
|
||||
import dagger.Provides
|
||||
import dagger.hilt.InstallIn
|
||||
@@ -78,22 +76,16 @@ class SyncManagerTest {
|
||||
@Before
|
||||
fun setUp() {
|
||||
hiltRule.inject()
|
||||
TestUtils.setUpWorkManager(context, workerFactory)
|
||||
|
||||
// Initialize WorkManager for instrumentation tests.
|
||||
val config = Configuration.Builder()
|
||||
.setMinimumLoggingLevel(Log.DEBUG)
|
||||
.setWorkerFactory(workerFactory)
|
||||
.build()
|
||||
WorkManagerTestInitHelper.initializeTestWorkManager(context, config)
|
||||
|
||||
account = TestAccountAuthenticator.create()
|
||||
account = TestAccount.create()
|
||||
|
||||
server.start()
|
||||
}
|
||||
|
||||
@After
|
||||
fun tearDown() {
|
||||
TestAccountAuthenticator.remove(account)
|
||||
TestAccount.remove(account)
|
||||
|
||||
// clear annoying syncError notifications
|
||||
NotificationManagerCompat.from(context).cancelAll()
|
||||
|
||||
@@ -7,56 +7,42 @@ package at.bitfire.davdroid.sync
|
||||
import android.accounts.Account
|
||||
import android.content.ContentProviderClient
|
||||
import at.bitfire.davdroid.db.Collection
|
||||
import at.bitfire.davdroid.sync.account.TestAccountAuthenticator
|
||||
import dagger.assisted.Assisted
|
||||
import dagger.assisted.AssistedFactory
|
||||
import dagger.assisted.AssistedInject
|
||||
import dagger.hilt.android.testing.HiltAndroidRule
|
||||
import dagger.hilt.android.testing.HiltAndroidTest
|
||||
import at.bitfire.davdroid.resource.LocalDataStore
|
||||
import io.mockk.every
|
||||
import io.mockk.impl.annotations.InjectMockKs
|
||||
import io.mockk.impl.annotations.RelaxedMockK
|
||||
import io.mockk.impl.annotations.SpyK
|
||||
import io.mockk.junit4.MockKRule
|
||||
import io.mockk.just
|
||||
import io.mockk.mockk
|
||||
import io.mockk.spyk
|
||||
import io.mockk.runs
|
||||
import io.mockk.verify
|
||||
import okhttp3.HttpUrl.Companion.toHttpUrl
|
||||
import org.junit.After
|
||||
import org.junit.Assert.assertArrayEquals
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Before
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import javax.inject.Inject
|
||||
import java.util.logging.Logger
|
||||
|
||||
@HiltAndroidTest
|
||||
class SyncerTest {
|
||||
|
||||
@get:Rule
|
||||
val hiltRule = HiltAndroidRule(this)
|
||||
val mockkRule = MockKRule(this)
|
||||
|
||||
@Inject
|
||||
lateinit var testSyncer: TestSyncer.Factory
|
||||
@RelaxedMockK
|
||||
lateinit var logger: Logger
|
||||
|
||||
lateinit var account: Account
|
||||
val dataStore: LocalTestStore = mockk(relaxed = true)
|
||||
val provider: ContentProviderClient = mockk(relaxed = true)
|
||||
|
||||
private lateinit var syncer: TestSyncer
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
hiltRule.inject()
|
||||
|
||||
account = TestAccountAuthenticator.create()
|
||||
syncer = spyk(testSyncer.create(account, emptyArray(), SyncResult()))
|
||||
}
|
||||
|
||||
@After
|
||||
fun tearDown() {
|
||||
TestAccountAuthenticator.remove(account)
|
||||
}
|
||||
@SpyK
|
||||
@InjectMockKs
|
||||
var syncer = TestSyncer(mockk(relaxed = true), emptyArray(), SyncResult(), dataStore)
|
||||
|
||||
|
||||
@Test
|
||||
fun testSync_prepare_fails() {
|
||||
val provider = mockk<ContentProviderClient>()
|
||||
every { syncer.prepare(provider) } returns false
|
||||
every { syncer.getSyncEnabledCollections() } returns emptyMap()
|
||||
|
||||
@@ -68,7 +54,6 @@ class SyncerTest {
|
||||
|
||||
@Test
|
||||
fun testSync_prepare_succeeds() {
|
||||
val provider = mockk<ContentProviderClient>()
|
||||
every { syncer.prepare(provider) } returns true
|
||||
every { syncer.getSyncEnabledCollections() } returns emptyMap()
|
||||
|
||||
@@ -83,13 +68,12 @@ class SyncerTest {
|
||||
fun testUpdateCollections_deletesCollection() {
|
||||
val localCollection = mockk<LocalTestCollection>()
|
||||
every { localCollection.collectionUrl } returns "http://delete.the/collection"
|
||||
every { localCollection.deleteCollection() } returns true
|
||||
every { localCollection.title } returns "Collection to be deleted locally"
|
||||
|
||||
// Should delete the localCollection if dbCollection (remote) does not exist
|
||||
val localCollections = mutableListOf(localCollection)
|
||||
val result = syncer.updateCollections(mockk(), localCollections, emptyMap())
|
||||
verify(exactly = 1) { localCollection.deleteCollection() }
|
||||
verify(exactly = 1) { dataStore.delete(localCollection) }
|
||||
|
||||
// Updated local collection list should be empty
|
||||
assertTrue(result.isEmpty())
|
||||
@@ -105,8 +89,8 @@ class SyncerTest {
|
||||
every { localCollection.title } returns "The Local Collection"
|
||||
|
||||
// Should update the localCollection if it exists
|
||||
val result = syncer.updateCollections(mockk(), listOf(localCollection), dbCollections)
|
||||
verify(exactly = 1) { syncer.update(localCollection, dbCollection) }
|
||||
val result = syncer.updateCollections(provider, listOf(localCollection), dbCollections)
|
||||
verify(exactly = 1) { dataStore.update(provider, localCollection, dbCollection) }
|
||||
|
||||
// Updated local collection list should be same as input
|
||||
assertArrayEquals(arrayOf(localCollection), result.toTypedArray())
|
||||
@@ -114,12 +98,18 @@ class SyncerTest {
|
||||
|
||||
@Test
|
||||
fun testUpdateCollections_findsNewCollection() {
|
||||
val dbCollection = mockk<Collection>()
|
||||
every { dbCollection.url } returns "http://newly.found/collection".toHttpUrl()
|
||||
val dbCollections = mapOf(dbCollection.url to dbCollection)
|
||||
val dbCollection = mockk<Collection> {
|
||||
every { url } returns "http://newly.found/collection".toHttpUrl()
|
||||
}
|
||||
val localCollections = listOf(mockk<LocalTestCollection> {
|
||||
every { collectionUrl } returns "http://newly.found/collection"
|
||||
})
|
||||
val dbCollections = listOf(dbCollection)
|
||||
val dbCollectionsMap = mapOf(dbCollection.url to dbCollection)
|
||||
every { syncer.createLocalCollections(provider, dbCollections) } returns localCollections
|
||||
|
||||
// Should return the new collection, because it was not updated
|
||||
val result = syncer.updateCollections(mockk(), emptyList(), dbCollections)
|
||||
val result = syncer.updateCollections(provider, emptyList(), dbCollectionsMap)
|
||||
|
||||
// Updated local collection list contain new entry
|
||||
assertEquals(1, result.size)
|
||||
@@ -129,21 +119,18 @@ class SyncerTest {
|
||||
|
||||
@Test
|
||||
fun testCreateLocalCollections() {
|
||||
val provider = mockk<ContentProviderClient>()
|
||||
val localCollection = mockk<LocalTestCollection>()
|
||||
val dbCollection = mockk<Collection>()
|
||||
every { syncer.create(provider, dbCollection) } returns localCollection
|
||||
every { dbCollection.url } returns "http://newly.found/collection".toHttpUrl()
|
||||
every { dataStore.create(provider, dbCollection) } returns localCollection
|
||||
|
||||
// Should return list of newly created local collections
|
||||
val result = syncer.createLocalCollections(provider, listOf(dbCollection))
|
||||
assertEquals(listOf(localCollection), result)
|
||||
}
|
||||
|
||||
|
||||
|
||||
@Test
|
||||
fun testSyncCollectionContents() {
|
||||
val provider = mockk<ContentProviderClient>()
|
||||
val dbCollection1 = mockk<Collection>()
|
||||
val dbCollection2 = mockk<Collection>()
|
||||
val dbCollections = mapOf(
|
||||
@@ -155,6 +142,7 @@ class SyncerTest {
|
||||
val localCollections = listOf(localCollection1, localCollection2)
|
||||
every { localCollection1.collectionUrl } returns "http://newly.found/collection1"
|
||||
every { localCollection2.collectionUrl } returns "http://newly.found/collection2"
|
||||
every { syncer.syncCollection(provider, any(), any()) } just runs
|
||||
|
||||
// Should call the collection content sync on both collections
|
||||
syncer.syncCollectionContents(provider, localCollections, dbCollections)
|
||||
@@ -165,41 +153,69 @@ class SyncerTest {
|
||||
|
||||
// Test helpers
|
||||
|
||||
class TestSyncer @AssistedInject constructor(
|
||||
@Assisted account: Account,
|
||||
@Assisted extras: Array<String>,
|
||||
@Assisted syncResult: SyncResult
|
||||
) : Syncer<LocalTestCollection>(account, extras, syncResult) {
|
||||
class TestSyncer (
|
||||
account: Account,
|
||||
extras: Array<String>,
|
||||
syncResult: SyncResult,
|
||||
theDataStore: LocalTestStore
|
||||
) : Syncer<LocalTestStore, LocalTestCollection>(account, extras, syncResult) {
|
||||
|
||||
@AssistedFactory
|
||||
interface Factory {
|
||||
fun create(account: Account, extras: Array<String>, syncResult: SyncResult): TestSyncer
|
||||
}
|
||||
override val dataStore: LocalTestStore =
|
||||
theDataStore
|
||||
|
||||
override val authority: String
|
||||
get() = ""
|
||||
get() = throw NotImplementedError()
|
||||
|
||||
override val serviceType: String
|
||||
get() = ""
|
||||
get() = throw NotImplementedError()
|
||||
|
||||
override fun prepare(provider: ContentProviderClient): Boolean =
|
||||
true
|
||||
|
||||
override fun getLocalCollections(provider: ContentProviderClient): List<LocalTestCollection> =
|
||||
emptyList()
|
||||
throw NotImplementedError()
|
||||
|
||||
override fun getDbSyncCollections(serviceId: Long): List<Collection> =
|
||||
emptyList()
|
||||
|
||||
override fun create(provider: ContentProviderClient, remoteCollection: Collection): LocalTestCollection =
|
||||
LocalTestCollection(remoteCollection.url.toString())
|
||||
throw NotImplementedError()
|
||||
|
||||
override fun syncCollection(
|
||||
provider: ContentProviderClient,
|
||||
localCollection: LocalTestCollection,
|
||||
remoteCollection: Collection
|
||||
) {}
|
||||
) {
|
||||
throw NotImplementedError()
|
||||
}
|
||||
|
||||
override fun update(localCollection: LocalTestCollection, remoteCollection: Collection) {}
|
||||
}
|
||||
|
||||
class LocalTestStore : LocalDataStore<LocalTestCollection> {
|
||||
|
||||
override fun create(
|
||||
provider: ContentProviderClient,
|
||||
fromCollection: Collection
|
||||
): LocalTestCollection? {
|
||||
throw NotImplementedError()
|
||||
}
|
||||
|
||||
override fun getAll(
|
||||
account: Account,
|
||||
provider: ContentProviderClient
|
||||
): List<LocalTestCollection> {
|
||||
throw NotImplementedError()
|
||||
}
|
||||
|
||||
override fun update(
|
||||
provider: ContentProviderClient,
|
||||
localCollection: LocalTestCollection,
|
||||
fromCollection: Collection
|
||||
) {
|
||||
throw NotImplementedError()
|
||||
}
|
||||
|
||||
override fun delete(localCollection: LocalTestCollection) {
|
||||
throw NotImplementedError()
|
||||
}
|
||||
|
||||
override fun updateAccount(oldAccount: Account, newAccount: Account) {
|
||||
throw NotImplementedError()
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
@@ -1,28 +1,29 @@
|
||||
/*
|
||||
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
|
||||
*/
|
||||
|
||||
package at.bitfire.davdroid.sync.account
|
||||
|
||||
import android.accounts.Account
|
||||
import android.accounts.AccountManager
|
||||
import android.content.Context
|
||||
import android.os.Bundle
|
||||
import android.util.Log
|
||||
import androidx.hilt.work.HiltWorkerFactory
|
||||
import androidx.work.Configuration
|
||||
import androidx.work.WorkerFactory
|
||||
import androidx.work.WorkerParameters
|
||||
import androidx.work.testing.TestListenableWorkerBuilder
|
||||
import androidx.work.testing.WorkManagerTestInitHelper
|
||||
import at.bitfire.davdroid.R
|
||||
import at.bitfire.davdroid.TestUtils
|
||||
import at.bitfire.davdroid.db.AppDatabase
|
||||
import at.bitfire.davdroid.db.Collection
|
||||
import at.bitfire.davdroid.db.Service
|
||||
import at.bitfire.davdroid.resource.LocalAddressBook.Companion.USER_DATA_COLLECTION_ID
|
||||
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
|
||||
import dagger.hilt.android.testing.HiltAndroidTest
|
||||
import okhttp3.HttpUrl.Companion.toHttpUrl
|
||||
import org.junit.After
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNotNull
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Before
|
||||
import org.junit.Rule
|
||||
@@ -38,8 +39,7 @@ class AccountsCleanupWorkerTest {
|
||||
@Inject
|
||||
lateinit var accountsCleanupWorkerFactory: AccountsCleanupWorker.Factory
|
||||
|
||||
@Inject
|
||||
@ApplicationContext
|
||||
@Inject @ApplicationContext
|
||||
lateinit var context: Context
|
||||
|
||||
@Inject
|
||||
@@ -59,23 +59,16 @@ class AccountsCleanupWorkerTest {
|
||||
@Before
|
||||
fun setUp() {
|
||||
hiltRule.inject()
|
||||
TestUtils.setUpWorkManager(context, workerFactory)
|
||||
|
||||
service = createTestService(Service.TYPE_CARDDAV)
|
||||
|
||||
// Prepare test account
|
||||
accountManager = AccountManager.get(context)
|
||||
addressBookAccountType = context.getString(R.string.account_type_address_book)
|
||||
addressBookAccount = Account(
|
||||
"Fancy address book account",
|
||||
addressBookAccountType
|
||||
)
|
||||
service = createTestService()
|
||||
|
||||
// Initialize WorkManager for instrumentation tests.
|
||||
val config = Configuration.Builder()
|
||||
.setMinimumLoggingLevel(Log.DEBUG)
|
||||
.setWorkerFactory(workerFactory)
|
||||
.build()
|
||||
WorkManagerTestInitHelper.initializeTestWorkManager(context, config)
|
||||
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
|
||||
@@ -86,65 +79,87 @@ class AccountsCleanupWorkerTest {
|
||||
|
||||
|
||||
@Test
|
||||
fun testDeleteOrphanedAddressBookAccounts_deletesAddressBookAccountWithoutCollection() {
|
||||
// Create address book account without corresponding collection
|
||||
assertTrue(accountManager.addAccountExplicitly(addressBookAccount, null, null))
|
||||
|
||||
val addressBookAccounts = accountManager.getAccountsByType(addressBookAccountType)
|
||||
assertEquals(addressBookAccount, addressBookAccounts.firstOrNull())
|
||||
fun testCleanUpServices_noAccount() {
|
||||
// Insert service that reference to invalid account
|
||||
db.serviceDao().insertOrReplace(Service(id = 1, accountName = "test", type = Service.TYPE_CALDAV, principal = null))
|
||||
assertNotNull(db.serviceDao().get(1))
|
||||
|
||||
// Create worker and run the method
|
||||
val worker = TestListenableWorkerBuilder<AccountsCleanupWorker>(context)
|
||||
.setWorkerFactory(object: WorkerFactory() {
|
||||
override fun createWorker(appContext: Context, workerClassName: String, workerParameters: WorkerParameters) =
|
||||
accountsCleanupWorkerFactory.create(appContext, workerParameters)
|
||||
})
|
||||
.setWorkerFactory(workerFactory)
|
||||
.build()
|
||||
worker.deleteOrphanedAddressBookAccounts(addressBookAccounts)
|
||||
worker.cleanUpServices()
|
||||
|
||||
// Verify that service is deleted
|
||||
assertNull(db.serviceDao().get(1))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testCleanUpServices_oneAccount() {
|
||||
TestAccount.provide { existingAccount ->
|
||||
// Insert services, one that reference the existing account and one that references an invalid account
|
||||
db.serviceDao().insertOrReplace(Service(id = 1, accountName = existingAccount.name, type = Service.TYPE_CALDAV, principal = null))
|
||||
assertNotNull(db.serviceDao().get(1))
|
||||
|
||||
db.serviceDao().insertOrReplace(Service(id = 2, accountName = "not existing", type = Service.TYPE_CARDDAV, principal = null))
|
||||
assertNotNull(db.serviceDao().get(2))
|
||||
|
||||
// Create worker and run the method
|
||||
val worker = TestListenableWorkerBuilder<AccountsCleanupWorker>(context)
|
||||
.setWorkerFactory(workerFactory)
|
||||
.build()
|
||||
worker.cleanUpServices()
|
||||
|
||||
// Verify that one service is deleted and the other one is kept
|
||||
assertNotNull(db.serviceDao().get(1))
|
||||
assertNull(db.serviceDao().get(2))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@Test
|
||||
fun testCleanUpAddressBooks_deletesAddressBookWithoutAccount() {
|
||||
// Create address book account without corresponding account
|
||||
assertTrue(accountManager.addAccountExplicitly(addressBookAccount, null, null))
|
||||
assertEquals(listOf(addressBookAccount), accountManager.getAccountsByType(addressBookAccountType).toList())
|
||||
|
||||
// Create worker and run the method
|
||||
val worker = TestListenableWorkerBuilder<AccountsCleanupWorker>(context)
|
||||
.setWorkerFactory(workerFactory)
|
||||
.build()
|
||||
worker.cleanUpAddressBooks()
|
||||
|
||||
// Verify account was deleted
|
||||
assertTrue(accountManager.getAccountsByType(addressBookAccountType).isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testDeleteOrphanedAddressBookAccounts_leavesAddressBookAccountWithCollection() {
|
||||
// Create address book account _with_ corresponding collection and verify
|
||||
val randomCollectionId = 12345L
|
||||
val userData = Bundle(1).apply {
|
||||
putString(USER_DATA_COLLECTION_ID, "$randomCollectionId")
|
||||
fun testCleanUpAddressBooks_keepsAddressBookWithAccount() {
|
||||
TestAccount.provide { existingAccount ->
|
||||
// Create address book account _with_ corresponding account and verify
|
||||
val userData = Bundle(2).apply {
|
||||
putString(LocalAddressBook.USER_DATA_ACCOUNT_NAME, existingAccount.name)
|
||||
putString(LocalAddressBook.USER_DATA_ACCOUNT_TYPE, existingAccount.type)
|
||||
}
|
||||
assertTrue(accountManager.addAccountExplicitly(addressBookAccount, null, userData))
|
||||
assertEquals(listOf(addressBookAccount), accountManager.getAccountsByType(addressBookAccountType).toList())
|
||||
|
||||
// Create worker and run the method
|
||||
val worker = TestListenableWorkerBuilder<AccountsCleanupWorker>(context)
|
||||
.setWorkerFactory(workerFactory)
|
||||
.build()
|
||||
worker.cleanUpAddressBooks()
|
||||
|
||||
// Verify account was _not_ deleted
|
||||
assertEquals(listOf(addressBookAccount), accountManager.getAccountsByType(addressBookAccountType).toList())
|
||||
}
|
||||
assertTrue(accountManager.addAccountExplicitly(addressBookAccount, null, userData))
|
||||
|
||||
val addressBookAccounts = accountManager.getAccountsByType(addressBookAccountType)
|
||||
assertEquals(randomCollectionId, accountManager.getUserData(addressBookAccount, USER_DATA_COLLECTION_ID).toLong())
|
||||
|
||||
// Create the collection
|
||||
val collectionDao = db.collectionDao()
|
||||
collectionDao.insert(Collection(
|
||||
randomCollectionId,
|
||||
serviceId = service.id,
|
||||
type = Collection.TYPE_ADDRESSBOOK,
|
||||
url = "http://www.example.com/yay.php".toHttpUrl()
|
||||
))
|
||||
|
||||
// Create worker and run the method
|
||||
val worker = TestListenableWorkerBuilder<AccountsCleanupWorker>(context)
|
||||
.setWorkerFactory(object: WorkerFactory() {
|
||||
override fun createWorker(appContext: Context, workerClassName: String, workerParameters: WorkerParameters) =
|
||||
accountsCleanupWorkerFactory.create(appContext, workerParameters)
|
||||
})
|
||||
.build()
|
||||
worker.deleteOrphanedAddressBookAccounts(addressBookAccounts)
|
||||
|
||||
// Verify account was _not_ deleted
|
||||
assertEquals(addressBookAccount, addressBookAccounts.firstOrNull())
|
||||
}
|
||||
|
||||
|
||||
// helpers
|
||||
|
||||
private fun createTestService(serviceType: String): Service {
|
||||
val service = Service(id=0, accountName="test", type=serviceType, principal = null)
|
||||
private fun createTestService(): Service {
|
||||
val service = Service(id=0, accountName="test", type=Service.TYPE_CARDDAV, principal = null)
|
||||
val serviceId = db.serviceDao().insertOrReplace(service)
|
||||
return db.serviceDao().get(serviceId)!!
|
||||
}
|
||||
|
||||
@@ -8,9 +8,8 @@ import android.accounts.Account
|
||||
import android.accounts.AccountManager
|
||||
import android.content.Context
|
||||
import android.os.Bundle
|
||||
import androidx.test.platform.app.InstrumentationRegistry
|
||||
import at.bitfire.davdroid.R
|
||||
import at.bitfire.davdroid.settings.SettingsManager
|
||||
import at.bitfire.davdroid.test.R
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import dagger.hilt.android.testing.HiltAndroidRule
|
||||
import dagger.hilt.android.testing.HiltAndroidTest
|
||||
@@ -22,24 +21,17 @@ import org.junit.Test
|
||||
import javax.inject.Inject
|
||||
|
||||
@HiltAndroidTest
|
||||
class AccountUtilsTest {
|
||||
class SystemAccountUtilsTest {
|
||||
|
||||
@get:Rule
|
||||
val hiltRule = HiltAndroidRule(this)
|
||||
|
||||
@Inject
|
||||
@ApplicationContext
|
||||
@Inject @ApplicationContext
|
||||
lateinit var context: Context
|
||||
val testContext = InstrumentationRegistry.getInstrumentation().context
|
||||
|
||||
@Inject
|
||||
lateinit var settingsManager: SettingsManager
|
||||
|
||||
val account = Account(
|
||||
"AccountUtilsTest",
|
||||
testContext.getString(R.string.account_type_test)
|
||||
)
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
hiltRule.inject()
|
||||
@@ -52,6 +44,7 @@ class AccountUtilsTest {
|
||||
userData.putString("int", "1")
|
||||
userData.putString("string", "abc/\"-")
|
||||
|
||||
val account = Account("AccountUtilsTest", context.getString(R.string.account_type))
|
||||
val manager = AccountManager.get(context)
|
||||
try {
|
||||
assertTrue(SystemAccountUtils.createAccount(context, account, userData))
|
||||
@@ -0,0 +1,53 @@
|
||||
/*
|
||||
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
|
||||
*/
|
||||
package at.bitfire.davdroid.sync.account
|
||||
|
||||
import android.accounts.Account
|
||||
import android.accounts.AccountManager
|
||||
import androidx.test.platform.app.InstrumentationRegistry
|
||||
import at.bitfire.davdroid.R
|
||||
import at.bitfire.davdroid.settings.AccountSettings
|
||||
import org.junit.Assert.assertTrue
|
||||
|
||||
object TestAccount {
|
||||
|
||||
private val targetContext by lazy { InstrumentationRegistry.getInstrumentation().targetContext }
|
||||
|
||||
/**
|
||||
* Creates a test account, usually in the `Before` setUp of a test.
|
||||
*
|
||||
* Remove it with [remove].
|
||||
*/
|
||||
fun create(version: Int = AccountSettings.CURRENT_VERSION): Account {
|
||||
val accountType = targetContext.getString(R.string.account_type)
|
||||
val account = Account("Test Account", accountType)
|
||||
|
||||
val initialData = AccountSettings.initialUserData(null)
|
||||
initialData.putString(AccountSettings.KEY_SETTINGS_VERSION, version.toString())
|
||||
assertTrue(SystemAccountUtils.createAccount(targetContext, account, initialData))
|
||||
|
||||
return account
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes a test account, usually in the `@After` tearDown of a test.
|
||||
*/
|
||||
fun remove(account: Account) {
|
||||
val am = AccountManager.get(targetContext)
|
||||
assertTrue(am.removeAccountExplicitly(account))
|
||||
}
|
||||
|
||||
/**
|
||||
* Convenience method to create a test account and remove it after executing the block.
|
||||
*/
|
||||
fun provide(version: Int = AccountSettings.CURRENT_VERSION, block: (Account) -> Unit) {
|
||||
val account = create(version)
|
||||
try {
|
||||
block(account)
|
||||
} finally {
|
||||
remove(account)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,96 +0,0 @@
|
||||
/***************************************************************************************************
|
||||
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
|
||||
**************************************************************************************************/
|
||||
package at.bitfire.davdroid.sync.account
|
||||
|
||||
import android.accounts.AbstractAccountAuthenticator
|
||||
import android.accounts.Account
|
||||
import android.accounts.AccountAuthenticatorResponse
|
||||
import android.accounts.AccountManager
|
||||
import android.app.Service
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.os.Bundle
|
||||
import androidx.test.platform.app.InstrumentationRegistry
|
||||
import at.bitfire.davdroid.settings.AccountSettings
|
||||
import at.bitfire.davdroid.test.R
|
||||
import org.junit.Assert.assertTrue
|
||||
|
||||
/**
|
||||
* Handles the test account type, which has no sync adapters and side effects that run unintentionally.
|
||||
*
|
||||
* Usually used like this:
|
||||
*
|
||||
* ```
|
||||
* lateinit var account: Account
|
||||
*
|
||||
* @Before
|
||||
* fun setUp() {
|
||||
* account = TestAccountAuthenticator.create()
|
||||
*
|
||||
* // You can now use the test account.
|
||||
* }
|
||||
*
|
||||
* @After
|
||||
* fun tearDown() {
|
||||
* TestAccountAuthenticator.remove(account)
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
class TestAccountAuthenticator: Service() {
|
||||
|
||||
companion object {
|
||||
|
||||
val context by lazy { InstrumentationRegistry.getInstrumentation().context }
|
||||
|
||||
/**
|
||||
* Creates a test account, usually in the `Before` setUp of a test.
|
||||
*
|
||||
* Remove it with [remove].
|
||||
*/
|
||||
fun create(): Account {
|
||||
val accountType = context.getString(R.string.account_type_test)
|
||||
val account = Account("Test Account", accountType)
|
||||
|
||||
assertTrue(SystemAccountUtils.createAccount(context, account, AccountSettings.initialUserData(null)))
|
||||
|
||||
return account
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes a test account, usually in the `@After` tearDown of a test.
|
||||
*/
|
||||
fun remove(account: Account) {
|
||||
val am = AccountManager.get(context)
|
||||
am.removeAccountExplicitly(account)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
private lateinit var accountAuthenticator: AccountAuthenticator
|
||||
|
||||
|
||||
override fun onCreate() {
|
||||
accountAuthenticator = AccountAuthenticator(this)
|
||||
}
|
||||
|
||||
override fun onBind(intent: Intent?) =
|
||||
accountAuthenticator.iBinder.takeIf { intent?.action == AccountManager.ACTION_AUTHENTICATOR_INTENT }
|
||||
|
||||
|
||||
private class AccountAuthenticator(
|
||||
val context: Context
|
||||
): AbstractAccountAuthenticator(context) {
|
||||
|
||||
override fun addAccount(response: AccountAuthenticatorResponse?, accountType: String?, authTokenType: String?, requiredFeatures: Array<String>?, options: Bundle?) = null
|
||||
override fun editProperties(response: AccountAuthenticatorResponse?, accountType: String?) = null
|
||||
override fun getAuthTokenLabel(p0: String?) = null
|
||||
override fun confirmCredentials(p0: AccountAuthenticatorResponse?, p1: Account?, p2: Bundle?) = null
|
||||
override fun updateCredentials(p0: AccountAuthenticatorResponse?, p1: Account?, p2: String?, p3: Bundle?) = null
|
||||
override fun getAuthToken(p0: AccountAuthenticatorResponse?, p1: Account?, p2: String?, p3: Bundle?) = null
|
||||
override fun hasFeatures(p0: AccountAuthenticatorResponse?, p1: Account?, p2: Array<out String>?) = null
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,24 +1,21 @@
|
||||
/***************************************************************************************************
|
||||
/*
|
||||
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
|
||||
**************************************************************************************************/
|
||||
*/
|
||||
|
||||
package at.bitfire.davdroid.sync.worker
|
||||
|
||||
import android.accounts.Account
|
||||
import android.content.Context
|
||||
import android.provider.CalendarContract
|
||||
import android.util.Log
|
||||
import androidx.test.platform.app.InstrumentationRegistry
|
||||
import androidx.work.Configuration
|
||||
import androidx.work.ListenableWorker
|
||||
import androidx.work.WorkManager
|
||||
import androidx.work.WorkerFactory
|
||||
import androidx.work.WorkerParameters
|
||||
import androidx.work.testing.TestListenableWorkerBuilder
|
||||
import androidx.work.testing.WorkManagerTestInitHelper
|
||||
import androidx.work.workDataOf
|
||||
import at.bitfire.davdroid.sync.account.TestAccountAuthenticator
|
||||
import at.bitfire.davdroid.test.R
|
||||
import at.bitfire.davdroid.R
|
||||
import at.bitfire.davdroid.TestUtils
|
||||
import at.bitfire.davdroid.sync.SyncDataType
|
||||
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
|
||||
@@ -35,10 +32,8 @@ import javax.inject.Inject
|
||||
@HiltAndroidTest
|
||||
class PeriodicSyncWorkerTest {
|
||||
|
||||
@Inject
|
||||
@ApplicationContext
|
||||
@Inject @ApplicationContext
|
||||
lateinit var context: Context
|
||||
val testContext = InstrumentationRegistry.getInstrumentation().context
|
||||
|
||||
@Inject
|
||||
lateinit var syncWorkerFactory: PeriodicSyncWorker.Factory
|
||||
@@ -51,29 +46,24 @@ class PeriodicSyncWorkerTest {
|
||||
@Before
|
||||
fun setUp() {
|
||||
hiltRule.inject()
|
||||
TestUtils.setUpWorkManager(context)
|
||||
|
||||
// Initialize WorkManager for instrumentation tests.
|
||||
val config = Configuration.Builder()
|
||||
.setMinimumLoggingLevel(Log.DEBUG)
|
||||
.build()
|
||||
WorkManagerTestInitHelper.initializeTestWorkManager(context, config)
|
||||
|
||||
account = TestAccountAuthenticator.create()
|
||||
account = TestAccount.create()
|
||||
}
|
||||
|
||||
@After
|
||||
fun tearDown() {
|
||||
TestAccountAuthenticator.remove(account)
|
||||
TestAccount.remove(account)
|
||||
}
|
||||
|
||||
|
||||
@Test
|
||||
fun doWork_cancelsItselfOnInvalidAccount() {
|
||||
val invalidAccount = Account("invalid", testContext.getString(R.string.account_type_test))
|
||||
val invalidAccount = Account("invalid", context.getString(R.string.account_type))
|
||||
|
||||
// Run PeriodicSyncWorker as TestWorker
|
||||
val inputData = workDataOf(
|
||||
BaseSyncWorker.INPUT_AUTHORITY to CalendarContract.AUTHORITY,
|
||||
BaseSyncWorker.INPUT_DATA_TYPE to SyncDataType.EVENTS.toString(),
|
||||
BaseSyncWorker.INPUT_ACCOUNT_NAME to invalidAccount.name,
|
||||
BaseSyncWorker.INPUT_ACCOUNT_TYPE to invalidAccount.type
|
||||
)
|
||||
|
||||
@@ -6,14 +6,11 @@ package at.bitfire.davdroid.sync.worker
|
||||
|
||||
import android.accounts.Account
|
||||
import android.content.Context
|
||||
import android.provider.CalendarContract
|
||||
import android.util.Log
|
||||
import androidx.hilt.work.HiltWorkerFactory
|
||||
import androidx.work.Configuration
|
||||
import androidx.work.testing.WorkManagerTestInitHelper
|
||||
import at.bitfire.davdroid.TestUtils
|
||||
import at.bitfire.davdroid.TestUtils.workScheduledOrRunning
|
||||
import at.bitfire.davdroid.sync.account.TestAccountAuthenticator
|
||||
import at.bitfire.davdroid.sync.SyncDataType
|
||||
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
|
||||
@@ -47,20 +44,14 @@ class SyncWorkerManagerTest {
|
||||
@Before
|
||||
fun setUp() {
|
||||
hiltRule.inject()
|
||||
TestUtils.setUpWorkManager(context, workerFactory)
|
||||
|
||||
// Initialize WorkManager for instrumentation tests.
|
||||
val config = Configuration.Builder()
|
||||
.setMinimumLoggingLevel(Log.DEBUG)
|
||||
.setWorkerFactory(workerFactory)
|
||||
.build()
|
||||
WorkManagerTestInitHelper.initializeTestWorkManager(context, config)
|
||||
|
||||
account = TestAccountAuthenticator.create()
|
||||
account = TestAccount.create()
|
||||
}
|
||||
|
||||
@After
|
||||
fun tearDown() {
|
||||
TestAccountAuthenticator.remove(account)
|
||||
TestAccount.remove(account)
|
||||
}
|
||||
|
||||
|
||||
@@ -68,10 +59,10 @@ class SyncWorkerManagerTest {
|
||||
|
||||
@Test
|
||||
fun testEnqueueOneTime() {
|
||||
val workerName = OneTimeSyncWorker.workerName(account, CalendarContract.AUTHORITY)
|
||||
val workerName = OneTimeSyncWorker.workerName(account, SyncDataType.EVENTS)
|
||||
assertFalse(TestUtils.workScheduledOrRunningOrSuccessful(context, workerName))
|
||||
|
||||
val returnedName = syncWorkerManager.enqueueOneTime(account, CalendarContract.AUTHORITY)
|
||||
val returnedName = syncWorkerManager.enqueueOneTime(account, SyncDataType.EVENTS)
|
||||
assertEquals(workerName, returnedName)
|
||||
assertTrue(TestUtils.workScheduledOrRunningOrSuccessful(context, workerName))
|
||||
}
|
||||
@@ -81,18 +72,18 @@ class SyncWorkerManagerTest {
|
||||
|
||||
@Test
|
||||
fun enablePeriodic() {
|
||||
syncWorkerManager.enablePeriodic(account, CalendarContract.AUTHORITY, 60, false).result.get()
|
||||
syncWorkerManager.enablePeriodic(account, SyncDataType.EVENTS, 60, false).result.get()
|
||||
|
||||
val workerName = PeriodicSyncWorker.workerName(account, CalendarContract.AUTHORITY)
|
||||
val workerName = PeriodicSyncWorker.workerName(account, SyncDataType.EVENTS)
|
||||
assertTrue(workScheduledOrRunning(context, workerName))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun disablePeriodic() {
|
||||
syncWorkerManager.enablePeriodic(account, CalendarContract.AUTHORITY, 60, false).result.get()
|
||||
syncWorkerManager.disablePeriodic(account, CalendarContract.AUTHORITY).result.get()
|
||||
syncWorkerManager.enablePeriodic(account, SyncDataType.EVENTS, 60, false).result.get()
|
||||
syncWorkerManager.disablePeriodic(account, SyncDataType.EVENTS).result.get()
|
||||
|
||||
val workerName = PeriodicSyncWorker.workerName(account, CalendarContract.AUTHORITY)
|
||||
val workerName = PeriodicSyncWorker.workerName(account, SyncDataType.EVENTS)
|
||||
assertFalse(workScheduledOrRunning(context, workerName))
|
||||
}
|
||||
|
||||
|
||||
@@ -1,15 +1,6 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!--
|
||||
~ Copyright (c) Ricki Hirner (bitfire web engineering).
|
||||
~ All rights reserved. This program and the accompanying materials
|
||||
~ are made available under the terms of the GNU Public License v3.0
|
||||
~ which accompanies this distribution, and is available at
|
||||
~ http://www.gnu.org/licenses/gpl.html
|
||||
-->
|
||||
|
||||
<resources>
|
||||
|
||||
<string name="app_name">Davx5Test</string>
|
||||
<string name="account_type_test">at.bitfire.davdroid.test</string>
|
||||
|
||||
</resources>
|
||||
@@ -1,5 +0,0 @@
|
||||
<account-authenticator xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:accountType="@string/account_type_test"
|
||||
android:icon="@android:drawable/star_on"
|
||||
android:smallIcon="@android:drawable/star_on"
|
||||
android:label="Test Account" />
|
||||
@@ -1,6 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<color name="primaryColor">#E07C25</color>
|
||||
<color name="primaryLightColor">#E5A371</color>
|
||||
<color name="primaryDarkColor">#7C3E07</color>
|
||||
</resources>
|
||||
@@ -1,4 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<string name="app_name" translatable="false">DAVx⁵ Debug</string>
|
||||
</resources>
|
||||
@@ -14,11 +14,6 @@
|
||||
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED"/>
|
||||
<uses-permission android:name="android.permission.REQUEST_IGNORE_BATTERY_OPTIMIZATIONS"/>
|
||||
|
||||
<!-- account management permissions not required for own accounts since API level 22 -->
|
||||
<uses-permission android:name="android.permission.AUTHENTICATE_ACCOUNTS" android:maxSdkVersion="22"/>
|
||||
<uses-permission android:name="android.permission.GET_ACCOUNTS" android:maxSdkVersion="22"/>
|
||||
<uses-permission android:name="android.permission.MANAGE_ACCOUNTS" android:maxSdkVersion="22"/>
|
||||
|
||||
<!-- other permissions -->
|
||||
<!-- android.permission-group.CONTACTS -->
|
||||
<uses-permission android:name="android.permission.READ_CONTACTS"/>
|
||||
@@ -65,9 +60,8 @@
|
||||
<activity android:name="net.openid.appauth.RedirectUriReceiverActivity"
|
||||
tools:node="remove" tools:selector="net.openid.appauth"/>
|
||||
|
||||
<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"/>
|
||||
@@ -75,15 +69,18 @@
|
||||
</intent-filter>
|
||||
</activity>
|
||||
|
||||
<activity
|
||||
android:name=".ui.AboutActivity"
|
||||
android:label="@string/navigation_drawer_about"
|
||||
android:parentActivityName=".ui.AccountsActivity"/>
|
||||
<!--
|
||||
Left here for external apps.
|
||||
Automatically redirects to MainActivity.
|
||||
Should be removed in the future.
|
||||
-->
|
||||
<!--suppress DeprecatedClassUsageInspection -->
|
||||
<activity android:name=".ui.AccountsActivity" android:exported="true" />
|
||||
|
||||
<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"/>
|
||||
@@ -111,7 +108,7 @@
|
||||
|
||||
<activity
|
||||
android:name=".ui.setup.LoginActivity"
|
||||
android:parentActivityName=".ui.AccountsActivity"
|
||||
android:parentActivityName=".ui.MainActivity"
|
||||
android:windowSoftInputMode="adjustResize"
|
||||
android:exported="true">
|
||||
<intent-filter>
|
||||
@@ -139,7 +136,7 @@
|
||||
|
||||
<activity
|
||||
android:name=".ui.account.AccountActivity"
|
||||
android:parentActivityName=".ui.AccountsActivity"
|
||||
android:parentActivityName=".ui.MainActivity"
|
||||
android:exported="true">
|
||||
</activity>
|
||||
<activity
|
||||
@@ -161,7 +158,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"
|
||||
|
||||
@@ -13,8 +13,6 @@ import androidx.core.app.NotificationCompat
|
||||
import androidx.core.database.getStringOrNull
|
||||
import androidx.room.AutoMigration
|
||||
import androidx.room.Database
|
||||
import androidx.room.DeleteColumn
|
||||
import androidx.room.ProvidedAutoMigrationSpec
|
||||
import androidx.room.Room
|
||||
import androidx.room.RoomDatabase
|
||||
import androidx.room.TypeConverters
|
||||
@@ -23,8 +21,9 @@ import androidx.room.migration.Migration
|
||||
import androidx.sqlite.db.SupportSQLiteDatabase
|
||||
import at.bitfire.davdroid.R
|
||||
import at.bitfire.davdroid.TextTable
|
||||
import at.bitfire.davdroid.servicedetection.RefreshCollectionsWorker
|
||||
import at.bitfire.davdroid.ui.AccountsActivity
|
||||
import at.bitfire.davdroid.db.migration.AutoMigration12
|
||||
import at.bitfire.davdroid.db.migration.AutoMigration16
|
||||
import at.bitfire.davdroid.ui.MainActivity
|
||||
import at.bitfire.davdroid.ui.NotificationRegistry
|
||||
import dagger.Module
|
||||
import dagger.Provides
|
||||
@@ -32,10 +31,8 @@ import dagger.hilt.InstallIn
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import dagger.hilt.components.SingletonComponent
|
||||
import java.io.Writer
|
||||
import java.util.logging.Logger
|
||||
import javax.inject.Singleton
|
||||
|
||||
@Suppress("ClassName")
|
||||
@Database(entities = [
|
||||
Service::class,
|
||||
HomeSet::class,
|
||||
@@ -44,12 +41,14 @@ import javax.inject.Singleton
|
||||
SyncStats::class,
|
||||
WebDavDocument::class,
|
||||
WebDavMount::class
|
||||
], exportSchema = true, version = 14, autoMigrations = [
|
||||
AutoMigration(from = 9, to = 10),
|
||||
AutoMigration(from = 10, to = 11),
|
||||
AutoMigration(from = 11, to = 12, spec = AppDatabase.AutoMigration11_12::class),
|
||||
], exportSchema = true, version = 16, autoMigrations = [
|
||||
AutoMigration(from = 15, to = 16, spec = AutoMigration16::class),
|
||||
AutoMigration(from = 14, to = 15),
|
||||
AutoMigration(from = 13, to = 14),
|
||||
AutoMigration(from = 12, to = 13),
|
||||
AutoMigration(from = 13, to = 14)
|
||||
AutoMigration(from = 11, to = 12, spec = AutoMigration12::class),
|
||||
AutoMigration(from = 10, to = 11),
|
||||
AutoMigration(from = 9, to = 10)
|
||||
])
|
||||
@TypeConverters(Converters::class)
|
||||
abstract class AppDatabase: RoomDatabase() {
|
||||
@@ -57,204 +56,43 @@ abstract class AppDatabase: RoomDatabase() {
|
||||
@Module
|
||||
@InstallIn(SingletonComponent::class)
|
||||
object AppDatabaseModule {
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun appDatabase(
|
||||
autoMigrations: Set<@JvmSuppressWildcards AutoMigrationSpec>,
|
||||
@ApplicationContext context: Context,
|
||||
manualMigrations: Set<@JvmSuppressWildcards Migration>,
|
||||
notificationRegistry: NotificationRegistry
|
||||
): AppDatabase =
|
||||
Room.databaseBuilder(context.applicationContext, AppDatabase::class.java, "services.db")
|
||||
.addMigrations(*migrations)
|
||||
.addAutoMigrationSpec(AutoMigration11_12(context))
|
||||
.fallbackToDestructiveMigration() // as a last fallback, recreate database instead of crashing
|
||||
.addCallback(object: Callback() {
|
||||
override fun onDestructiveMigration(db: SupportSQLiteDatabase) {
|
||||
notificationRegistry.notifyIfPossible(NotificationRegistry.NOTIFY_DATABASE_CORRUPTED) {
|
||||
val launcherIntent = Intent(context, AccountsActivity::class.java)
|
||||
NotificationCompat.Builder(context, notificationRegistry.CHANNEL_GENERAL)
|
||||
.setSmallIcon(R.drawable.ic_warning_notify)
|
||||
.setContentTitle(context.getString(R.string.database_destructive_migration_title))
|
||||
.setContentText(context.getString(R.string.database_destructive_migration_text))
|
||||
.setCategory(NotificationCompat.CATEGORY_ERROR)
|
||||
.setContentIntent(PendingIntent.getActivity(context, 0, launcherIntent, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE))
|
||||
.setAutoCancel(true)
|
||||
.build()
|
||||
}
|
||||
|
||||
// remove all accounts because they're unfortunately useless without database
|
||||
val am = AccountManager.get(context)
|
||||
for (account in am.getAccountsByType(context.getString(R.string.account_type)))
|
||||
am.removeAccountExplicitly(account)
|
||||
): AppDatabase = Room
|
||||
.databaseBuilder(context, AppDatabase::class.java, "services.db")
|
||||
.addMigrations(*manualMigrations.toTypedArray())
|
||||
.apply {
|
||||
for (spec in autoMigrations)
|
||||
addAutoMigrationSpec(spec)
|
||||
}
|
||||
.fallbackToDestructiveMigration() // as a last fallback, recreate database instead of crashing
|
||||
.addCallback(object: Callback() {
|
||||
override fun onDestructiveMigration(db: SupportSQLiteDatabase) {
|
||||
notificationRegistry.notifyIfPossible(NotificationRegistry.NOTIFY_DATABASE_CORRUPTED) {
|
||||
val launcherIntent = Intent(context, 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))
|
||||
.setAutoCancel(true)
|
||||
.build()
|
||||
}
|
||||
})
|
||||
.build()
|
||||
}
|
||||
|
||||
// auto migrations
|
||||
|
||||
@ProvidedAutoMigrationSpec
|
||||
@DeleteColumn(tableName = "collection", columnName = "owner")
|
||||
class AutoMigration11_12(val context: Context): AutoMigrationSpec {
|
||||
override fun onPostMigrate(db: SupportSQLiteDatabase) {
|
||||
Logger.getGlobal().info("Database update to v12, refreshing services to get display names of owners")
|
||||
db.query("SELECT id FROM service", arrayOf()).use { cursor ->
|
||||
while (cursor.moveToNext()) {
|
||||
val serviceId = cursor.getLong(0)
|
||||
RefreshCollectionsWorker.enqueue(context, serviceId)
|
||||
// remove all accounts because they're unfortunately useless without database
|
||||
val am = AccountManager.get(context)
|
||||
for (account in am.getAccountsByType(context.getString(R.string.account_type)))
|
||||
am.removeAccountExplicitly(account)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
companion object {
|
||||
|
||||
// manual migrations
|
||||
|
||||
val migrations: Array<Migration> = arrayOf(
|
||||
object : Migration(8, 9) {
|
||||
override fun migrate(db: SupportSQLiteDatabase) {
|
||||
db.execSQL("CREATE TABLE syncstats (" +
|
||||
"id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT," +
|
||||
"collectionId INTEGER NOT NULL REFERENCES collection(id) ON DELETE CASCADE," +
|
||||
"authority TEXT NOT NULL," +
|
||||
"lastSync INTEGER NOT NULL)")
|
||||
db.execSQL("CREATE UNIQUE INDEX index_syncstats_collectionId_authority ON syncstats(collectionId, authority)")
|
||||
|
||||
db.execSQL("CREATE INDEX index_collection_url ON collection(url)")
|
||||
}
|
||||
},
|
||||
|
||||
object : Migration(7, 8) {
|
||||
override fun migrate(db: SupportSQLiteDatabase) {
|
||||
db.execSQL("ALTER TABLE homeset ADD COLUMN personal INTEGER NOT NULL DEFAULT 1")
|
||||
db.execSQL("ALTER TABLE collection ADD COLUMN homeSetId INTEGER DEFAULT NULL REFERENCES homeset(id) ON DELETE SET NULL")
|
||||
db.execSQL("ALTER TABLE collection ADD COLUMN owner TEXT DEFAULT NULL")
|
||||
db.execSQL("CREATE INDEX index_collection_homeSetId_type ON collection(homeSetId, type)")
|
||||
}
|
||||
},
|
||||
|
||||
object : Migration(6, 7) {
|
||||
override fun migrate(db: SupportSQLiteDatabase) {
|
||||
db.execSQL("ALTER TABLE homeset ADD COLUMN privBind INTEGER NOT NULL DEFAULT 1")
|
||||
db.execSQL("ALTER TABLE homeset ADD COLUMN displayName TEXT DEFAULT NULL")
|
||||
}
|
||||
},
|
||||
|
||||
object : Migration(5, 6) {
|
||||
override fun migrate(db: SupportSQLiteDatabase) {
|
||||
val sql = arrayOf(
|
||||
// migrate "services" to "service": rename columns, make id NOT NULL
|
||||
"CREATE TABLE service(" +
|
||||
"id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT," +
|
||||
"accountName TEXT NOT NULL," +
|
||||
"type TEXT NOT NULL," +
|
||||
"principal TEXT DEFAULT NULL" +
|
||||
")",
|
||||
"CREATE UNIQUE INDEX index_service_accountName_type ON service(accountName, type)",
|
||||
"INSERT INTO service(id, accountName, type, principal) SELECT _id, accountName, service, principal FROM services",
|
||||
"DROP TABLE services",
|
||||
|
||||
// migrate "homesets" to "homeset": rename columns, make id NOT NULL
|
||||
"CREATE TABLE homeset(" +
|
||||
"id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT," +
|
||||
"serviceId INTEGER NOT NULL," +
|
||||
"url TEXT NOT NULL," +
|
||||
"FOREIGN KEY (serviceId) REFERENCES service(id) ON DELETE CASCADE" +
|
||||
")",
|
||||
"CREATE UNIQUE INDEX index_homeset_serviceId_url ON homeset(serviceId, url)",
|
||||
"INSERT INTO homeset(id, serviceId, url) SELECT _id, serviceID, url FROM homesets",
|
||||
"DROP TABLE homesets",
|
||||
|
||||
// migrate "collections" to "collection": rename columns, make id NOT NULL
|
||||
"CREATE TABLE collection(" +
|
||||
"id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT," +
|
||||
"serviceId INTEGER NOT NULL," +
|
||||
"type TEXT NOT NULL," +
|
||||
"url TEXT NOT NULL," +
|
||||
"privWriteContent INTEGER NOT NULL DEFAULT 1," +
|
||||
"privUnbind INTEGER NOT NULL DEFAULT 1," +
|
||||
"forceReadOnly INTEGER NOT NULL DEFAULT 0," +
|
||||
"displayName TEXT DEFAULT NULL," +
|
||||
"description TEXT DEFAULT NULL," +
|
||||
"color INTEGER DEFAULT NULL," +
|
||||
"timezone TEXT DEFAULT NULL," +
|
||||
"supportsVEVENT INTEGER DEFAULT NULL," +
|
||||
"supportsVTODO INTEGER DEFAULT NULL," +
|
||||
"supportsVJOURNAL INTEGER DEFAULT NULL," +
|
||||
"source TEXT DEFAULT NULL," +
|
||||
"sync INTEGER NOT NULL DEFAULT 0," +
|
||||
"FOREIGN KEY (serviceId) REFERENCES service(id) ON DELETE CASCADE" +
|
||||
")",
|
||||
"CREATE INDEX index_collection_serviceId_type ON collection(serviceId,type)",
|
||||
"INSERT INTO collection(id, serviceId, type, url, privWriteContent, privUnbind, forceReadOnly, displayName, description, color, timezone, supportsVEVENT, supportsVTODO, source, sync) " +
|
||||
"SELECT _id, serviceID, type, url, privWriteContent, privUnbind, forceReadOnly, displayName, description, color, timezone, supportsVEVENT, supportsVTODO, source, sync FROM collections",
|
||||
"DROP TABLE collections"
|
||||
)
|
||||
sql.forEach { db.execSQL(it) }
|
||||
}
|
||||
},
|
||||
|
||||
object : Migration(4, 5) {
|
||||
override fun migrate(db: SupportSQLiteDatabase) {
|
||||
db.execSQL("ALTER TABLE collections ADD COLUMN privWriteContent INTEGER DEFAULT 0 NOT NULL")
|
||||
db.execSQL("UPDATE collections SET privWriteContent=NOT readOnly")
|
||||
|
||||
db.execSQL("ALTER TABLE collections ADD COLUMN privUnbind INTEGER DEFAULT 0 NOT NULL")
|
||||
db.execSQL("UPDATE collections SET privUnbind=NOT readOnly")
|
||||
|
||||
// there's no DROP COLUMN in SQLite, so just keep the "readOnly" column
|
||||
}
|
||||
},
|
||||
|
||||
object : Migration(3, 4) {
|
||||
override fun migrate(db: SupportSQLiteDatabase) {
|
||||
db.execSQL("ALTER TABLE collections ADD COLUMN forceReadOnly INTEGER DEFAULT 0 NOT NULL")
|
||||
}
|
||||
},
|
||||
|
||||
object : Migration(2, 3) {
|
||||
override fun migrate(db: SupportSQLiteDatabase) {
|
||||
// We don't have access to the context in a Room migration now, so
|
||||
// we will just drop those settings from old DAVx5 versions.
|
||||
Logger.getGlobal().warning("Dropping settings distrustSystemCerts and overrideProxy*")
|
||||
|
||||
/*val edit = PreferenceManager.getDefaultSharedPreferences(context).edit()
|
||||
try {
|
||||
db.query("settings", arrayOf("setting", "value"), null, null, null, null, null).use { cursor ->
|
||||
while (cursor.moveToNext()) {
|
||||
when (cursor.getString(0)) {
|
||||
"distrustSystemCerts" -> edit.putBoolean(App.DISTRUST_SYSTEM_CERTIFICATES, cursor.getInt(1) != 0)
|
||||
"overrideProxy" -> edit.putBoolean(App.OVERRIDE_PROXY, cursor.getInt(1) != 0)
|
||||
"overrideProxyHost" -> edit.putString(App.OVERRIDE_PROXY_HOST, cursor.getString(1))
|
||||
"overrideProxyPort" -> edit.putInt(App.OVERRIDE_PROXY_PORT, cursor.getInt(1))
|
||||
|
||||
StartupDialogFragment.HINT_GOOGLE_PLAY_ACCOUNTS_REMOVED ->
|
||||
edit.putBoolean(StartupDialogFragment.HINT_GOOGLE_PLAY_ACCOUNTS_REMOVED, cursor.getInt(1) != 0)
|
||||
StartupDialogFragment.HINT_OPENTASKS_NOT_INSTALLED ->
|
||||
edit.putBoolean(StartupDialogFragment.HINT_OPENTASKS_NOT_INSTALLED, cursor.getInt(1) != 0)
|
||||
}
|
||||
}
|
||||
}
|
||||
db.execSQL("DROP TABLE settings")
|
||||
} finally {
|
||||
edit.apply()
|
||||
}*/
|
||||
}
|
||||
},
|
||||
|
||||
object : Migration(1, 2) {
|
||||
override fun migrate(db: SupportSQLiteDatabase) {
|
||||
db.execSQL("ALTER TABLE collections ADD COLUMN type TEXT NOT NULL DEFAULT ''")
|
||||
db.execSQL("ALTER TABLE collections ADD COLUMN source TEXT DEFAULT NULL")
|
||||
db.execSQL("UPDATE collections SET type=(" +
|
||||
"SELECT CASE service WHEN ? THEN ? ELSE ? END " +
|
||||
"FROM services WHERE _id=collections.serviceID" +
|
||||
")",
|
||||
arrayOf("caldav", "CALENDAR", "ADDRESS_BOOK"))
|
||||
}
|
||||
}
|
||||
)
|
||||
})
|
||||
.build()
|
||||
|
||||
}
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
|
||||
package at.bitfire.davdroid.db
|
||||
|
||||
import androidx.annotation.StringDef
|
||||
import androidx.room.ColumnInfo
|
||||
import androidx.room.Entity
|
||||
import androidx.room.ForeignKey
|
||||
@@ -14,6 +15,7 @@ import at.bitfire.dav4jvm.UrlUtils
|
||||
import at.bitfire.dav4jvm.property.caldav.CalendarColor
|
||||
import at.bitfire.dav4jvm.property.caldav.CalendarDescription
|
||||
import at.bitfire.dav4jvm.property.caldav.CalendarTimezone
|
||||
import at.bitfire.dav4jvm.property.caldav.CalendarTimezoneId
|
||||
import at.bitfire.dav4jvm.property.caldav.Source
|
||||
import at.bitfire.dav4jvm.property.caldav.SupportedCalendarComponentSet
|
||||
import at.bitfire.dav4jvm.property.carddav.AddressbookDescription
|
||||
@@ -24,9 +26,18 @@ import at.bitfire.dav4jvm.property.webdav.DisplayName
|
||||
import at.bitfire.dav4jvm.property.webdav.ResourceType
|
||||
import at.bitfire.davdroid.util.DavUtils.lastSegment
|
||||
import at.bitfire.davdroid.util.trimToNull
|
||||
import at.bitfire.ical4android.util.DateUtils
|
||||
import okhttp3.HttpUrl
|
||||
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
|
||||
|
||||
@Retention(AnnotationRetention.SOURCE)
|
||||
@StringDef(
|
||||
Collection.TYPE_ADDRESSBOOK,
|
||||
Collection.TYPE_CALENDAR,
|
||||
Collection.TYPE_WEBCAL
|
||||
)
|
||||
annotation class CollectionType
|
||||
|
||||
@Entity(tableName = "collection",
|
||||
foreignKeys = [
|
||||
ForeignKey(entity = Service::class, parentColumns = arrayOf("id"), childColumns = arrayOf("serviceId"), onDelete = ForeignKey.CASCADE),
|
||||
@@ -43,92 +54,96 @@ import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
|
||||
)
|
||||
data class Collection(
|
||||
@PrimaryKey(autoGenerate = true)
|
||||
var id: Long = 0,
|
||||
val id: Long = 0,
|
||||
|
||||
/**
|
||||
* Service, which this collection belongs to. Services are unique, so a [Collection] is uniquely
|
||||
* identifiable via its [serviceId] and [url].
|
||||
*/
|
||||
var serviceId: Long = 0,
|
||||
val serviceId: Long = 0,
|
||||
|
||||
/**
|
||||
* A home set this collection belongs to. Multiple homesets are not supported.
|
||||
* If *null* the collection is considered homeless.
|
||||
*/
|
||||
var homeSetId: Long? = null,
|
||||
val homeSetId: Long? = null,
|
||||
|
||||
/**
|
||||
* Principal who is owner of this collection.
|
||||
*/
|
||||
var ownerId: Long? = null,
|
||||
val ownerId: Long? = null,
|
||||
|
||||
/**
|
||||
* Type of service. CalDAV or CardDAV
|
||||
*/
|
||||
var type: String,
|
||||
@CollectionType
|
||||
val type: String,
|
||||
|
||||
/**
|
||||
* Address where this collection lives - with trailing slash
|
||||
*/
|
||||
var url: HttpUrl,
|
||||
val url: HttpUrl,
|
||||
|
||||
/**
|
||||
* Whether we have the permission to change contents of the collection on the server.
|
||||
* Even if this flag is set, there may still be other reasons why a collection is effectively read-only.
|
||||
*/
|
||||
var privWriteContent: Boolean = true,
|
||||
val privWriteContent: Boolean = true,
|
||||
/**
|
||||
* Whether we have the permission to delete the collection on the server
|
||||
*/
|
||||
var privUnbind: Boolean = true,
|
||||
val privUnbind: Boolean = true,
|
||||
/**
|
||||
* Whether the user has manually set the "force read-only" flag.
|
||||
* Even if this flag is not set, there may still be other reasons why a collection is effectively read-only.
|
||||
*/
|
||||
var forceReadOnly: Boolean = false,
|
||||
val forceReadOnly: Boolean = false,
|
||||
|
||||
/**
|
||||
* Human-readable name of the collection
|
||||
*/
|
||||
var displayName: String? = null,
|
||||
val displayName: String? = null,
|
||||
/**
|
||||
* Human-readable description of the collection
|
||||
*/
|
||||
var description: String? = null,
|
||||
val description: String? = null,
|
||||
|
||||
// CalDAV only
|
||||
var color: Int? = null,
|
||||
val color: Int? = null,
|
||||
|
||||
/** timezone definition (full VTIMEZONE) - not a TZID! **/
|
||||
var timezone: String? = null,
|
||||
/** default timezone (only timezone ID, like `Europe/Vienna`) */
|
||||
val timezoneId: String? = null,
|
||||
|
||||
/** whether the collection supports VEVENT; in case of calendars: null means true */
|
||||
var supportsVEVENT: Boolean? = null,
|
||||
val supportsVEVENT: Boolean? = null,
|
||||
|
||||
/** whether the collection supports VTODO; in case of calendars: null means true */
|
||||
var supportsVTODO: Boolean? = null,
|
||||
val supportsVTODO: Boolean? = null,
|
||||
|
||||
/** whether the collection supports VJOURNAL; in case of calendars: null means true */
|
||||
var supportsVJOURNAL: Boolean? = null,
|
||||
val supportsVJOURNAL: Boolean? = null,
|
||||
|
||||
/** Webcal subscription source URL */
|
||||
var source: HttpUrl? = null,
|
||||
val source: HttpUrl? = null,
|
||||
|
||||
/** whether this collection has been selected for synchronization */
|
||||
var sync: Boolean = false,
|
||||
val sync: Boolean = false,
|
||||
|
||||
/** WebDAV-Push topic */
|
||||
var pushTopic: String? = null,
|
||||
val pushTopic: String? = null,
|
||||
|
||||
/** WebDAV-Push: whether this collection supports the Web Push Transport */
|
||||
@ColumnInfo(defaultValue = "0")
|
||||
var supportsWebPush: Boolean = false,
|
||||
val supportsWebPush: Boolean = false,
|
||||
|
||||
/** WebDAV-Push subscription URL */
|
||||
var pushSubscription: String? = null,
|
||||
val pushSubscription: String? = null,
|
||||
|
||||
/** when the [pushSubscription] was created/updated (used to determine whether we need to re-subscribe) */
|
||||
var pushSubscriptionCreated: Long? = null
|
||||
/** when the [pushSubscription] expires (timestamp, used to determine whether we need to re-subscribe) */
|
||||
val pushSubscriptionExpires: Long? = null,
|
||||
|
||||
/** when the [pushSubscription] was created/updated (timestamp) */
|
||||
val pushSubscriptionCreated: Long? = null
|
||||
|
||||
) {
|
||||
|
||||
@@ -165,7 +180,7 @@ data class Collection(
|
||||
|
||||
var description: String? = null
|
||||
var color: Int? = null
|
||||
var timezone: String? = null
|
||||
var timezoneId: String? = null
|
||||
var supportsVEVENT: Boolean? = null
|
||||
var supportsVTODO: Boolean? = null
|
||||
var supportsVJOURNAL: Boolean? = null
|
||||
@@ -177,7 +192,11 @@ data class Collection(
|
||||
TYPE_CALENDAR, TYPE_WEBCAL -> {
|
||||
dav[CalendarDescription::class.java]?.let { description = it.description }
|
||||
dav[CalendarColor::class.java]?.let { color = it.color }
|
||||
dav[CalendarTimezone::class.java]?.let { timezone = it.vTimeZone }
|
||||
dav[CalendarTimezoneId::class.java]?.let { timezoneId = it.identifier }
|
||||
if (timezoneId == null)
|
||||
dav[CalendarTimezone::class.java]?.vTimeZone?.let {
|
||||
timezoneId = DateUtils.parseVTimeZone(it)?.timeZoneId?.value
|
||||
}
|
||||
|
||||
if (type == TYPE_CALENDAR) {
|
||||
supportsVEVENT = true
|
||||
@@ -217,7 +236,7 @@ data class Collection(
|
||||
displayName = displayName,
|
||||
description = description,
|
||||
color = color,
|
||||
timezone = timezone,
|
||||
timezoneId = timezoneId,
|
||||
supportsVEVENT = supportsVEVENT,
|
||||
supportsVTODO = supportsVTODO,
|
||||
supportsVJOURNAL = supportsVJOURNAL,
|
||||
|
||||
@@ -38,6 +38,9 @@ interface CollectionDao {
|
||||
@Query("SELECT COUNT(*) FROM collection WHERE serviceId=:serviceId AND type=:type")
|
||||
suspend fun anyOfType(serviceId: Long, type: String): Boolean
|
||||
|
||||
@Query("SELECT COUNT(*) FROM collection WHERE supportsWebPush AND pushTopic IS NOT NULL")
|
||||
suspend fun anyPushCapable(): Boolean
|
||||
|
||||
/**
|
||||
* Returns collections which
|
||||
* - support VEVENT and/or VTODO (= supported calendar collections), or
|
||||
@@ -87,8 +90,8 @@ interface CollectionDao {
|
||||
@Query("UPDATE collection SET forceReadOnly=:forceReadOnly WHERE id=:id")
|
||||
suspend fun updateForceReadOnly(id: Long, forceReadOnly: Boolean)
|
||||
|
||||
@Query("UPDATE collection SET pushSubscription=:pushSubscription, pushSubscriptionCreated=:updatedAt WHERE id=:id")
|
||||
fun updatePushSubscription(id: Long, pushSubscription: String?, updatedAt: Long = System.currentTimeMillis())
|
||||
@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)
|
||||
|
||||
@Query("UPDATE collection SET sync=:sync WHERE id=:id")
|
||||
suspend fun updateSync(id: Long, sync: Boolean)
|
||||
|
||||
@@ -22,20 +22,20 @@ import okhttp3.HttpUrl
|
||||
)
|
||||
data class HomeSet(
|
||||
@PrimaryKey(autoGenerate = true)
|
||||
var id: Long,
|
||||
val id: Long,
|
||||
|
||||
var serviceId: Long,
|
||||
val serviceId: Long,
|
||||
|
||||
/**
|
||||
* Whether this homeset belongs to the [Service.principal] given by [serviceId].
|
||||
*/
|
||||
var personal: Boolean,
|
||||
val personal: Boolean,
|
||||
|
||||
var url: HttpUrl,
|
||||
val url: HttpUrl,
|
||||
|
||||
var privBind: Boolean = true,
|
||||
val privBind: Boolean = true,
|
||||
|
||||
var displayName: String? = null
|
||||
val displayName: String? = null
|
||||
) {
|
||||
|
||||
fun title() = displayName ?: url.lastSegment
|
||||
|
||||
@@ -15,6 +15,9 @@ import at.bitfire.dav4jvm.property.webdav.ResourceType
|
||||
import at.bitfire.davdroid.util.trimToNull
|
||||
import okhttp3.HttpUrl
|
||||
|
||||
/**
|
||||
* A principal entity representing a WebDAV principal (rfc3744).
|
||||
*/
|
||||
@Entity(tableName = "principal",
|
||||
foreignKeys = [
|
||||
ForeignKey(entity = Service::class, parentColumns = arrayOf("id"), childColumns = arrayOf("serviceId"), onDelete = ForeignKey.CASCADE)
|
||||
@@ -26,11 +29,11 @@ import okhttp3.HttpUrl
|
||||
)
|
||||
data class Principal(
|
||||
@PrimaryKey(autoGenerate = true)
|
||||
var id: Long = 0,
|
||||
var serviceId: Long,
|
||||
val id: Long = 0,
|
||||
val serviceId: Long,
|
||||
/** URL of the principal, always without trailing slash */
|
||||
var url: HttpUrl,
|
||||
var displayName: String? = null
|
||||
val url: HttpUrl,
|
||||
val displayName: String? = null
|
||||
) {
|
||||
|
||||
companion object {
|
||||
|
||||
@@ -4,11 +4,16 @@
|
||||
|
||||
package at.bitfire.davdroid.db
|
||||
|
||||
import androidx.annotation.StringDef
|
||||
import androidx.room.Entity
|
||||
import androidx.room.Index
|
||||
import androidx.room.PrimaryKey
|
||||
import okhttp3.HttpUrl
|
||||
|
||||
@Retention(AnnotationRetention.SOURCE)
|
||||
@StringDef(Service.TYPE_CALDAV, Service.TYPE_CARDDAV)
|
||||
annotation class ServiceType
|
||||
|
||||
/**
|
||||
* A service entity.
|
||||
*
|
||||
@@ -21,12 +26,14 @@ import okhttp3.HttpUrl
|
||||
])
|
||||
data class Service(
|
||||
@PrimaryKey(autoGenerate = true)
|
||||
var id: Long,
|
||||
val id: Long,
|
||||
|
||||
var accountName: String,
|
||||
var type: String,
|
||||
val accountName: String,
|
||||
|
||||
var principal: HttpUrl?
|
||||
@ServiceType
|
||||
val type: String,
|
||||
|
||||
val principal: HttpUrl?
|
||||
) {
|
||||
|
||||
companion object {
|
||||
|
||||
@@ -24,5 +24,5 @@ data class SyncStats(
|
||||
val collectionId: Long,
|
||||
val authority: String,
|
||||
|
||||
var lastSync: Long
|
||||
val lastSync: Long
|
||||
)
|
||||
@@ -8,17 +8,18 @@ import android.annotation.SuppressLint
|
||||
import android.os.Bundle
|
||||
import android.provider.DocumentsContract.Document
|
||||
import android.webkit.MimeTypeMap
|
||||
import androidx.core.os.bundleOf
|
||||
import androidx.room.Entity
|
||||
import androidx.room.ForeignKey
|
||||
import androidx.room.Index
|
||||
import androidx.room.PrimaryKey
|
||||
import at.bitfire.davdroid.util.DavUtils.MEDIA_TYPE_OCTET_STREAM
|
||||
import at.bitfire.davdroid.webdav.DocumentState
|
||||
import java.io.FileNotFoundException
|
||||
import java.time.Instant
|
||||
import okhttp3.HttpUrl
|
||||
import okhttp3.MediaType
|
||||
import okhttp3.MediaType.Companion.toMediaTypeOrNull
|
||||
import java.io.FileNotFoundException
|
||||
import java.time.Instant
|
||||
|
||||
@Entity(
|
||||
tableName = "webdav_document",
|
||||
@@ -34,30 +35,30 @@ import okhttp3.MediaType.Companion.toMediaTypeOrNull
|
||||
data class WebDavDocument(
|
||||
|
||||
@PrimaryKey(autoGenerate = true)
|
||||
var id: Long = 0,
|
||||
val id: Long = 0,
|
||||
|
||||
/** refers to the [WebDavMount] the document belongs to */
|
||||
val mountId: Long,
|
||||
|
||||
/** refers to parent document (*null* when this document is a root document) */
|
||||
var parentId: Long?,
|
||||
val parentId: Long?,
|
||||
|
||||
/** file name (without any slashes) */
|
||||
var name: String,
|
||||
var isDirectory: Boolean = false,
|
||||
val name: String,
|
||||
val isDirectory: Boolean = false,
|
||||
|
||||
var displayName: String? = null,
|
||||
var mimeType: MediaType? = null,
|
||||
var eTag: String? = null,
|
||||
var lastModified: Long? = null,
|
||||
var size: Long? = null,
|
||||
val displayName: String? = null,
|
||||
val mimeType: MediaType? = null,
|
||||
val eTag: String? = null,
|
||||
val lastModified: Long? = null,
|
||||
val size: Long? = null,
|
||||
|
||||
var mayBind: Boolean? = null,
|
||||
var mayUnbind: Boolean? = null,
|
||||
var mayWriteContent: Boolean? = null,
|
||||
val mayBind: Boolean? = null,
|
||||
val mayUnbind: Boolean? = null,
|
||||
val mayWriteContent: Boolean? = null,
|
||||
|
||||
var quotaAvailable: Long? = null,
|
||||
var quotaUsed: Long? = null
|
||||
val quotaAvailable: Long? = null,
|
||||
val quotaUsed: Long? = null
|
||||
|
||||
) {
|
||||
|
||||
@@ -72,9 +73,10 @@ data class WebDavDocument(
|
||||
if (parent?.isDirectory == false)
|
||||
throw IllegalArgumentException("Parent must be a directory")
|
||||
|
||||
val bundle = Bundle()
|
||||
bundle.putString(Document.COLUMN_DOCUMENT_ID, id.toString())
|
||||
bundle.putString(Document.COLUMN_DISPLAY_NAME, name)
|
||||
val bundle = bundleOf(
|
||||
Document.COLUMN_DOCUMENT_ID to id.toString(),
|
||||
Document.COLUMN_DISPLAY_NAME to name
|
||||
)
|
||||
|
||||
displayName?.let { bundle.putString(Document.COLUMN_SUMMARY, it) }
|
||||
size?.let { bundle.putLong(Document.COLUMN_SIZE, it) }
|
||||
|
||||
@@ -77,8 +77,7 @@ interface WebDavDocumentDao {
|
||||
displayName = mount.name
|
||||
)
|
||||
val id = insertOrReplace(newDoc)
|
||||
newDoc.id = id
|
||||
return newDoc
|
||||
return newDoc.copy(id = id)
|
||||
}
|
||||
|
||||
}
|
||||
@@ -11,13 +11,13 @@ import okhttp3.HttpUrl
|
||||
@Entity(tableName = "webdav_mount")
|
||||
data class WebDavMount(
|
||||
@PrimaryKey(autoGenerate = true)
|
||||
var id: Long = 0,
|
||||
val id: Long = 0,
|
||||
|
||||
/** display name of the WebDAV mount */
|
||||
var name: String,
|
||||
val name: String,
|
||||
|
||||
/** URL of the WebDAV service, including trailing slash */
|
||||
var url: HttpUrl
|
||||
val url: HttpUrl
|
||||
|
||||
// credentials are stored using CredentialsStore
|
||||
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
/*
|
||||
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
|
||||
*/
|
||||
|
||||
package at.bitfire.davdroid.db.migration
|
||||
|
||||
import android.content.Context
|
||||
import androidx.room.DeleteColumn
|
||||
import androidx.room.ProvidedAutoMigrationSpec
|
||||
import androidx.room.migration.AutoMigrationSpec
|
||||
import androidx.sqlite.db.SupportSQLiteDatabase
|
||||
import at.bitfire.davdroid.servicedetection.RefreshCollectionsWorker
|
||||
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 java.util.logging.Logger
|
||||
import javax.inject.Inject
|
||||
|
||||
@ProvidedAutoMigrationSpec
|
||||
@DeleteColumn(tableName = "collection", columnName = "owner")
|
||||
class AutoMigration12 @Inject constructor(
|
||||
@ApplicationContext val context: Context,
|
||||
val logger: Logger
|
||||
): AutoMigrationSpec {
|
||||
|
||||
override fun onPostMigrate(db: SupportSQLiteDatabase) {
|
||||
logger.info("Database update to v12, refreshing services to get display names of owners")
|
||||
db.query("SELECT id FROM service", arrayOf()).use { cursor ->
|
||||
while (cursor.moveToNext()) {
|
||||
val serviceId = cursor.getLong(0)
|
||||
RefreshCollectionsWorker.enqueue(context, serviceId)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@Module
|
||||
@InstallIn(SingletonComponent::class)
|
||||
abstract class AutoMigrationModule {
|
||||
@Binds @IntoSet
|
||||
abstract fun provide(impl: AutoMigration12): AutoMigrationSpec
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
/*
|
||||
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
|
||||
*/
|
||||
|
||||
package at.bitfire.davdroid.db.migration
|
||||
|
||||
import androidx.room.ProvidedAutoMigrationSpec
|
||||
import androidx.room.RenameColumn
|
||||
import androidx.room.migration.AutoMigrationSpec
|
||||
import androidx.sqlite.db.SupportSQLiteDatabase
|
||||
import at.bitfire.ical4android.util.DateUtils
|
||||
import dagger.Binds
|
||||
import dagger.Module
|
||||
import dagger.hilt.InstallIn
|
||||
import dagger.hilt.components.SingletonComponent
|
||||
import dagger.multibindings.IntoSet
|
||||
import javax.inject.Inject
|
||||
|
||||
/**
|
||||
* The timezone column has been renamed to timezoneId, but still contains the VTIMEZONE.
|
||||
* So we need to parse the VTIMEZONE, extract the timezone ID and save it back.
|
||||
*/
|
||||
@ProvidedAutoMigrationSpec
|
||||
@RenameColumn(tableName = "collection", fromColumnName = "timezone", toColumnName = "timezoneId")
|
||||
class AutoMigration16 @Inject constructor(): AutoMigrationSpec {
|
||||
|
||||
override fun onPostMigrate(db: SupportSQLiteDatabase) {
|
||||
db.query("SELECT id, timezoneId FROM collection").use { cursor ->
|
||||
while (cursor.moveToNext()) {
|
||||
val id: Long = cursor.getLong(0)
|
||||
val timezoneDef: String = cursor.getString(1) ?: continue
|
||||
val vTimeZone = DateUtils.parseVTimeZone(timezoneDef)
|
||||
val timezoneId = vTimeZone?.timeZoneId?.value
|
||||
db.execSQL("UPDATE collection SET timezoneId=? WHERE id=?", arrayOf(timezoneId, id))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@Module
|
||||
@InstallIn(SingletonComponent::class)
|
||||
abstract class AutoMigrationModule {
|
||||
@Binds @IntoSet
|
||||
abstract fun provide(impl: AutoMigration16): AutoMigrationSpec
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
/*
|
||||
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
|
||||
*/
|
||||
|
||||
package at.bitfire.davdroid.db.migration
|
||||
|
||||
import androidx.room.migration.Migration
|
||||
import dagger.Module
|
||||
import dagger.Provides
|
||||
import dagger.hilt.InstallIn
|
||||
import dagger.hilt.components.SingletonComponent
|
||||
import dagger.multibindings.IntoSet
|
||||
|
||||
val Migration2 = Migration(1, 2) { db ->
|
||||
db.execSQL("ALTER TABLE collections ADD COLUMN type TEXT NOT NULL DEFAULT ''")
|
||||
db.execSQL("ALTER TABLE collections ADD COLUMN source TEXT DEFAULT NULL")
|
||||
db.execSQL("UPDATE collections SET type=(" +
|
||||
"SELECT CASE service WHEN ? THEN ? ELSE ? END " +
|
||||
"FROM services WHERE _id=collections.serviceID" +
|
||||
")",
|
||||
arrayOf("caldav", "CALENDAR", "ADDRESS_BOOK"))
|
||||
}
|
||||
|
||||
@Module
|
||||
@InstallIn(SingletonComponent::class)
|
||||
internal object Migration2Module {
|
||||
@Provides @IntoSet
|
||||
fun provide(): Migration = Migration2
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
/*
|
||||
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
|
||||
*/
|
||||
|
||||
package at.bitfire.davdroid.db.migration
|
||||
|
||||
import androidx.room.migration.Migration
|
||||
import dagger.Module
|
||||
import dagger.Provides
|
||||
import dagger.hilt.InstallIn
|
||||
import dagger.hilt.components.SingletonComponent
|
||||
import dagger.multibindings.IntoSet
|
||||
import java.util.logging.Logger
|
||||
|
||||
val Migration3 = Migration(2, 3) { db ->
|
||||
// We don't have access to the context in a Room migration now, so
|
||||
// we will just drop those settings from old DAVx5 versions.
|
||||
Logger.getGlobal().warning("Dropping settings distrustSystemCerts and overrideProxy*")
|
||||
}
|
||||
|
||||
@Module
|
||||
@InstallIn(SingletonComponent::class)
|
||||
internal object Migration3Module {
|
||||
@Provides @IntoSet
|
||||
fun provide(): Migration = Migration3
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
/*
|
||||
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
|
||||
*/
|
||||
|
||||
package at.bitfire.davdroid.db.migration
|
||||
|
||||
import androidx.room.migration.Migration
|
||||
import dagger.Module
|
||||
import dagger.Provides
|
||||
import dagger.hilt.InstallIn
|
||||
import dagger.hilt.components.SingletonComponent
|
||||
import dagger.multibindings.IntoSet
|
||||
|
||||
val Migration4 = Migration(3, 4) { db ->
|
||||
db.execSQL("ALTER TABLE collections ADD COLUMN forceReadOnly INTEGER DEFAULT 0 NOT NULL")
|
||||
}
|
||||
|
||||
@Module
|
||||
@InstallIn(SingletonComponent::class)
|
||||
internal object Migration4Module {
|
||||
@Provides @IntoSet
|
||||
fun provide(): Migration = Migration4
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
/*
|
||||
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
|
||||
*/
|
||||
|
||||
package at.bitfire.davdroid.db.migration
|
||||
|
||||
import androidx.room.migration.Migration
|
||||
import dagger.Module
|
||||
import dagger.Provides
|
||||
import dagger.hilt.InstallIn
|
||||
import dagger.hilt.components.SingletonComponent
|
||||
import dagger.multibindings.IntoSet
|
||||
|
||||
val Migration5 = Migration(4, 5) { db ->
|
||||
db.execSQL("ALTER TABLE collections ADD COLUMN privWriteContent INTEGER DEFAULT 0 NOT NULL")
|
||||
db.execSQL("UPDATE collections SET privWriteContent=NOT readOnly")
|
||||
|
||||
db.execSQL("ALTER TABLE collections ADD COLUMN privUnbind INTEGER DEFAULT 0 NOT NULL")
|
||||
db.execSQL("UPDATE collections SET privUnbind=NOT readOnly")
|
||||
|
||||
// there's no DROP COLUMN in SQLite, so just keep the "readOnly" column
|
||||
}
|
||||
|
||||
@Module
|
||||
@InstallIn(SingletonComponent::class)
|
||||
internal object Migration5Module {
|
||||
@Provides @IntoSet
|
||||
fun provide(): Migration = Migration5
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
/*
|
||||
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
|
||||
*/
|
||||
|
||||
package at.bitfire.davdroid.db.migration
|
||||
|
||||
import androidx.room.migration.Migration
|
||||
import dagger.Module
|
||||
import dagger.Provides
|
||||
import dagger.hilt.InstallIn
|
||||
import dagger.hilt.components.SingletonComponent
|
||||
import dagger.multibindings.IntoSet
|
||||
|
||||
val Migration6 = Migration(5, 6) { db ->
|
||||
val sql = arrayOf(
|
||||
// migrate "services" to "service": rename columns, make id NOT NULL
|
||||
"CREATE TABLE service(" +
|
||||
"id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT," +
|
||||
"accountName TEXT NOT NULL," +
|
||||
"type TEXT NOT NULL," +
|
||||
"principal TEXT DEFAULT NULL" +
|
||||
")",
|
||||
"CREATE UNIQUE INDEX index_service_accountName_type ON service(accountName, type)",
|
||||
"INSERT INTO service(id, accountName, type, principal) SELECT _id, accountName, service, principal FROM services",
|
||||
"DROP TABLE services",
|
||||
|
||||
// migrate "homesets" to "homeset": rename columns, make id NOT NULL
|
||||
"CREATE TABLE homeset(" +
|
||||
"id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT," +
|
||||
"serviceId INTEGER NOT NULL," +
|
||||
"url TEXT NOT NULL," +
|
||||
"FOREIGN KEY (serviceId) REFERENCES service(id) ON DELETE CASCADE" +
|
||||
")",
|
||||
"CREATE UNIQUE INDEX index_homeset_serviceId_url ON homeset(serviceId, url)",
|
||||
"INSERT INTO homeset(id, serviceId, url) SELECT _id, serviceID, url FROM homesets",
|
||||
"DROP TABLE homesets",
|
||||
|
||||
// migrate "collections" to "collection": rename columns, make id NOT NULL
|
||||
"CREATE TABLE collection(" +
|
||||
"id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT," +
|
||||
"serviceId INTEGER NOT NULL," +
|
||||
"type TEXT NOT NULL," +
|
||||
"url TEXT NOT NULL," +
|
||||
"privWriteContent INTEGER NOT NULL DEFAULT 1," +
|
||||
"privUnbind INTEGER NOT NULL DEFAULT 1," +
|
||||
"forceReadOnly INTEGER NOT NULL DEFAULT 0," +
|
||||
"displayName TEXT DEFAULT NULL," +
|
||||
"description TEXT DEFAULT NULL," +
|
||||
"color INTEGER DEFAULT NULL," +
|
||||
"timezone TEXT DEFAULT NULL," +
|
||||
"supportsVEVENT INTEGER DEFAULT NULL," +
|
||||
"supportsVTODO INTEGER DEFAULT NULL," +
|
||||
"supportsVJOURNAL INTEGER DEFAULT NULL," +
|
||||
"source TEXT DEFAULT NULL," +
|
||||
"sync INTEGER NOT NULL DEFAULT 0," +
|
||||
"FOREIGN KEY (serviceId) REFERENCES service(id) ON DELETE CASCADE" +
|
||||
")",
|
||||
"CREATE INDEX index_collection_serviceId_type ON collection(serviceId,type)",
|
||||
"INSERT INTO collection(id, serviceId, type, url, privWriteContent, privUnbind, forceReadOnly, displayName, description, color, timezone, supportsVEVENT, supportsVTODO, source, sync) " +
|
||||
"SELECT _id, serviceID, type, url, privWriteContent, privUnbind, forceReadOnly, displayName, description, color, timezone, supportsVEVENT, supportsVTODO, source, sync FROM collections",
|
||||
"DROP TABLE collections"
|
||||
)
|
||||
sql.forEach { db.execSQL(it) }
|
||||
}
|
||||
|
||||
@Module
|
||||
@InstallIn(SingletonComponent::class)
|
||||
internal object Migration6Module {
|
||||
@Provides @IntoSet
|
||||
fun provide(): Migration = Migration6
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
/*
|
||||
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
|
||||
*/
|
||||
|
||||
package at.bitfire.davdroid.db.migration
|
||||
|
||||
import androidx.room.migration.Migration
|
||||
import dagger.Module
|
||||
import dagger.Provides
|
||||
import dagger.hilt.InstallIn
|
||||
import dagger.hilt.components.SingletonComponent
|
||||
import dagger.multibindings.IntoSet
|
||||
|
||||
val Migration7 = Migration(6, 7) { db ->
|
||||
db.execSQL("ALTER TABLE homeset ADD COLUMN privBind INTEGER NOT NULL DEFAULT 1")
|
||||
db.execSQL("ALTER TABLE homeset ADD COLUMN displayName TEXT DEFAULT NULL")
|
||||
}
|
||||
|
||||
@Module
|
||||
@InstallIn(SingletonComponent::class)
|
||||
internal object Migration7Module {
|
||||
@Provides @IntoSet
|
||||
fun provide(): Migration = Migration7
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
/*
|
||||
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
|
||||
*/
|
||||
|
||||
package at.bitfire.davdroid.db.migration
|
||||
|
||||
import androidx.room.migration.Migration
|
||||
import dagger.Module
|
||||
import dagger.Provides
|
||||
import dagger.hilt.InstallIn
|
||||
import dagger.hilt.components.SingletonComponent
|
||||
import dagger.multibindings.IntoSet
|
||||
|
||||
val Migration8 = Migration(7, 8) { db ->
|
||||
db.execSQL("ALTER TABLE homeset ADD COLUMN personal INTEGER NOT NULL DEFAULT 1")
|
||||
db.execSQL("ALTER TABLE collection ADD COLUMN homeSetId INTEGER DEFAULT NULL REFERENCES homeset(id) ON DELETE SET NULL")
|
||||
db.execSQL("ALTER TABLE collection ADD COLUMN owner TEXT DEFAULT NULL")
|
||||
db.execSQL("CREATE INDEX index_collection_homeSetId_type ON collection(homeSetId, type)")
|
||||
}
|
||||
|
||||
@Module
|
||||
@InstallIn(SingletonComponent::class)
|
||||
internal object Migration8Module {
|
||||
@Provides @IntoSet
|
||||
fun provide(): Migration = Migration8
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
/*
|
||||
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
|
||||
*/
|
||||
|
||||
package at.bitfire.davdroid.db.migration
|
||||
|
||||
import androidx.room.migration.Migration
|
||||
import dagger.Module
|
||||
import dagger.Provides
|
||||
import dagger.hilt.InstallIn
|
||||
import dagger.hilt.components.SingletonComponent
|
||||
import dagger.multibindings.IntoSet
|
||||
|
||||
val Migration9 = Migration(8, 9) { db ->
|
||||
db.execSQL("CREATE TABLE syncstats (" +
|
||||
"id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT," +
|
||||
"collectionId INTEGER NOT NULL REFERENCES collection(id) ON DELETE CASCADE," +
|
||||
"authority TEXT NOT NULL," +
|
||||
"lastSync INTEGER NOT NULL)")
|
||||
db.execSQL("CREATE UNIQUE INDEX index_syncstats_collectionId_authority ON syncstats(collectionId, authority)")
|
||||
|
||||
db.execSQL("CREATE INDEX index_collection_url ON collection(url)")
|
||||
}
|
||||
|
||||
@Module
|
||||
@InstallIn(SingletonComponent::class)
|
||||
internal object Migration9Module {
|
||||
@Provides @IntoSet
|
||||
fun provide(): Migration = Migration9
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
/***************************************************************************************************
|
||||
/*
|
||||
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
|
||||
**************************************************************************************************/
|
||||
*/
|
||||
|
||||
package at.bitfire.davdroid.network
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/***************************************************************************************************
|
||||
/*
|
||||
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
|
||||
**************************************************************************************************/
|
||||
*/
|
||||
|
||||
package at.bitfire.davdroid.network
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ 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
|
||||
@@ -31,7 +32,9 @@ class PushMessageParser @Inject constructor(
|
||||
|
||||
XmlReader(parser).processTag(PushMessage.NAME) {
|
||||
val pushMessage = PushMessage.Factory.create(parser)
|
||||
topic = pushMessage.topic
|
||||
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)
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
/*
|
||||
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
|
||||
*/
|
||||
|
||||
package at.bitfire.davdroid.push
|
||||
|
||||
import android.accounts.Account
|
||||
@@ -7,6 +11,7 @@ import android.content.Intent
|
||||
import androidx.core.app.NotificationCompat
|
||||
import androidx.core.app.NotificationManagerCompat
|
||||
import at.bitfire.davdroid.R
|
||||
import at.bitfire.davdroid.sync.SyncDataType
|
||||
import at.bitfire.davdroid.ui.NotificationRegistry
|
||||
import at.bitfire.davdroid.ui.account.AccountActivity
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
@@ -20,16 +25,16 @@ class PushNotificationManager @Inject constructor(
|
||||
/**
|
||||
* Generates the notification ID for a push notification.
|
||||
*/
|
||||
private fun notificationId(account: Account, authority: String): Int {
|
||||
return account.name.hashCode() + account.type.hashCode() + authority.hashCode()
|
||||
private fun notificationId(account: Account, dataType: SyncDataType): Int {
|
||||
return account.name.hashCode() + account.type.hashCode() + dataType.hashCode()
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends a notification to inform the user that a push notification has been received, the
|
||||
* sync has been scheduled, but it still has not run.
|
||||
*/
|
||||
fun notify(account: Account, authority: String) {
|
||||
notificationRegistry.notifyIfPossible(notificationId(account, authority)) {
|
||||
fun notify(account: Account, dataType: SyncDataType) {
|
||||
notificationRegistry.notifyIfPossible(notificationId(account, dataType)) {
|
||||
NotificationCompat.Builder(context, notificationRegistry.CHANNEL_STATUS)
|
||||
.setSmallIcon(R.drawable.ic_sync)
|
||||
.setContentTitle(context.getString(R.string.sync_notification_pending_push_title))
|
||||
@@ -57,9 +62,9 @@ class PushNotificationManager @Inject constructor(
|
||||
* Once the sync has been started, the notification is no longer needed and can be dismissed.
|
||||
* It's safe to call this method even if the notification has not been shown.
|
||||
*/
|
||||
fun dismiss(account: Account, authority: String) {
|
||||
fun dismiss(account: Account, dataType: SyncDataType) {
|
||||
NotificationManagerCompat.from(context)
|
||||
.cancel(notificationId(account, authority))
|
||||
.cancel(notificationId(account, dataType))
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -7,15 +7,11 @@ package at.bitfire.davdroid.push
|
||||
import android.accounts.Account
|
||||
import android.content.Context
|
||||
import androidx.hilt.work.HiltWorker
|
||||
import androidx.work.Constraints
|
||||
import androidx.work.CoroutineWorker
|
||||
import androidx.work.ExistingWorkPolicy
|
||||
import androidx.work.NetworkType
|
||||
import androidx.work.OneTimeWorkRequestBuilder
|
||||
import androidx.work.WorkManager
|
||||
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
|
||||
@@ -28,31 +24,23 @@ 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.Binds
|
||||
import dagger.Module
|
||||
import dagger.assisted.Assisted
|
||||
import dagger.assisted.AssistedInject
|
||||
import dagger.hilt.InstallIn
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import dagger.hilt.components.SingletonComponent
|
||||
import dagger.multibindings.IntoSet
|
||||
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.util.concurrent.TimeUnit
|
||||
import java.time.Duration
|
||||
import java.time.Instant
|
||||
import java.util.logging.Level
|
||||
import java.util.logging.Logger
|
||||
import javax.inject.Inject
|
||||
|
||||
/**
|
||||
* 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).
|
||||
*
|
||||
* TODO Should run periodically, too (to refresh registrations that are about to expire).
|
||||
* Not required for a first demonstration version.
|
||||
*/
|
||||
@Suppress("unused")
|
||||
@HiltWorker
|
||||
@@ -66,34 +54,15 @@ class PushRegistrationWorker @AssistedInject constructor(
|
||||
private val serviceRepository: DavServiceRepository
|
||||
) : CoroutineWorker(context, workerParameters) {
|
||||
|
||||
companion object {
|
||||
|
||||
private const val UNIQUE_WORK_NAME = "push-registration"
|
||||
|
||||
/**
|
||||
* Enqueues a push registration worker with a minimum delay of 5 seconds.
|
||||
*/
|
||||
fun enqueue(context: Context) {
|
||||
val constraints = Constraints.Builder()
|
||||
.setRequiredNetworkType(NetworkType.CONNECTED) // require a network connection
|
||||
.build()
|
||||
val workRequest = OneTimeWorkRequestBuilder<PushRegistrationWorker>()
|
||||
.setInitialDelay(5, TimeUnit.SECONDS)
|
||||
.setConstraints(constraints)
|
||||
.build()
|
||||
Logger.getGlobal().info("Enqueueing push registration worker")
|
||||
WorkManager.getInstance(context)
|
||||
.enqueueUniqueWork(UNIQUE_WORK_NAME, ExistingWorkPolicy.REPLACE, workRequest)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
override suspend fun doWork(): Result {
|
||||
logger.info("Running push registration worker")
|
||||
|
||||
registerSyncable()
|
||||
unregisterNotSyncable()
|
||||
try {
|
||||
registerSyncable()
|
||||
unregisterNotSyncable()
|
||||
} catch (_: IOException) {
|
||||
return Result.retry() // retry on I/O errors
|
||||
}
|
||||
|
||||
return Result.success()
|
||||
}
|
||||
@@ -108,27 +77,41 @@ class PushRegistrationWorker @AssistedInject constructor(
|
||||
.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) {
|
||||
response.header("Location")?.let { subscriptionUrl ->
|
||||
collectionRepository.updatePushSubscription(collection.id, subscriptionUrl)
|
||||
}
|
||||
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")
|
||||
}
|
||||
@@ -142,6 +125,15 @@ class PushRegistrationWorker @AssistedInject constructor(
|
||||
// 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))
|
||||
@@ -176,7 +168,11 @@ class PushRegistrationWorker @AssistedInject constructor(
|
||||
}
|
||||
|
||||
// remove registration URL from DB in any case
|
||||
collectionRepository.updatePushSubscription(collection.id, null)
|
||||
collectionRepository.updatePushSubscription(
|
||||
id = collection.id,
|
||||
subscriptionUrl = null,
|
||||
expires = null
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -193,25 +189,4 @@ class PushRegistrationWorker @AssistedInject constructor(
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Listener that enqueues a push registration worker when the collection list changes.
|
||||
*/
|
||||
class CollectionsListener @Inject constructor(
|
||||
@ApplicationContext val context: Context
|
||||
): DavCollectionRepository.OnChangeListener {
|
||||
override fun onCollectionsChanged() = enqueue(context)
|
||||
}
|
||||
|
||||
/**
|
||||
* Hilt module that registers [CollectionsListener] in [DavCollectionRepository].
|
||||
*/
|
||||
@Module
|
||||
@InstallIn(SingletonComponent::class)
|
||||
interface PushRegistrationWorkerModule {
|
||||
@Binds
|
||||
@IntoSet
|
||||
fun listener(impl: CollectionsListener): DavCollectionRepository.OnChangeListener
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
/*
|
||||
* 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
|
||||
}
|
||||
}
|
||||
@@ -5,19 +5,23 @@
|
||||
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 java.util.logging.Level
|
||||
import java.util.logging.Logger
|
||||
import javax.inject.Inject
|
||||
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() {
|
||||
@@ -40,6 +44,12 @@ class UnifiedPushReceiver: MessagingReceiver() {
|
||||
@Inject
|
||||
lateinit var parsePushMessage: PushMessageParser
|
||||
|
||||
@Inject
|
||||
lateinit var pushRegistrationWorkerManager: PushRegistrationWorkerManager
|
||||
|
||||
@Inject
|
||||
lateinit var tasksAppManager: Lazy<TasksAppManager>
|
||||
|
||||
@Inject
|
||||
lateinit var syncWorkerManager: SyncWorkerManager
|
||||
|
||||
@@ -49,7 +59,7 @@ class UnifiedPushReceiver: MessagingReceiver() {
|
||||
preferenceRepository.unifiedPushEndpoint(endpoint)
|
||||
|
||||
// register new endpoint at CalDAV/CardDAV servers
|
||||
PushRegistrationWorker.enqueue(context)
|
||||
pushRegistrationWorkerManager.updatePeriodicWorker()
|
||||
}
|
||||
|
||||
override fun onUnregistered(context: Context, instance: String) {
|
||||
@@ -73,8 +83,25 @@ class UnifiedPushReceiver: MessagingReceiver() {
|
||||
// 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)
|
||||
syncWorkerManager.enqueueOneTimeAllAuthorities(account, fromPush = true)
|
||||
for (syncDataType in syncDataTypes)
|
||||
syncWorkerManager.enqueueOneTime(account, syncDataType, fromPush = true)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -7,25 +7,22 @@ package at.bitfire.davdroid.repository
|
||||
import android.accounts.Account
|
||||
import android.accounts.AccountManager
|
||||
import android.accounts.OnAccountsUpdateListener
|
||||
import android.content.ContentResolver
|
||||
import android.content.Context
|
||||
import android.provider.CalendarContract
|
||||
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.resource.LocalAddressBook
|
||||
import at.bitfire.davdroid.resource.LocalTaskList
|
||||
import at.bitfire.davdroid.resource.LocalAddressBookStore
|
||||
import at.bitfire.davdroid.resource.LocalCalendarStore
|
||||
import at.bitfire.davdroid.servicedetection.DavResourceFinder
|
||||
import at.bitfire.davdroid.servicedetection.RefreshCollectionsWorker
|
||||
import at.bitfire.davdroid.settings.AccountSettings
|
||||
import at.bitfire.davdroid.settings.Settings
|
||||
import at.bitfire.davdroid.settings.SettingsManager
|
||||
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.SystemAccountUtils
|
||||
import at.bitfire.davdroid.sync.worker.BaseSyncWorker
|
||||
import at.bitfire.davdroid.sync.worker.SyncWorkerManager
|
||||
import at.bitfire.vcard4android.GroupMethod
|
||||
import dagger.Lazy
|
||||
@@ -46,11 +43,13 @@ import javax.inject.Inject
|
||||
*/
|
||||
class AccountRepository @Inject constructor(
|
||||
private val accountSettingsFactory: AccountSettings.Factory,
|
||||
@ApplicationContext val context: Context,
|
||||
private val automaticSyncManager: AutomaticSyncManager,
|
||||
@ApplicationContext private val context: Context,
|
||||
private val collectionRepository: DavCollectionRepository,
|
||||
private val homeSetRepository: DavHomeSetRepository,
|
||||
private val localCalendarStore: Lazy<LocalCalendarStore>,
|
||||
private val localAddressBookStore: Lazy<LocalAddressBookStore>,
|
||||
private val logger: Logger,
|
||||
private val settingsManager: SettingsManager,
|
||||
private val serviceRepository: DavServiceRepository,
|
||||
private val syncWorkerManager: SyncWorkerManager,
|
||||
private val tasksAppManager: Lazy<TasksAppManager>
|
||||
@@ -80,49 +79,31 @@ class AccountRepository @Inject constructor(
|
||||
if (!SystemAccountUtils.createAccount(context, account, userData, credentials?.password))
|
||||
return null
|
||||
|
||||
// add entries for account to service DB
|
||||
// add entries for account to database
|
||||
logger.log(Level.INFO, "Writing account configuration to database", config)
|
||||
try {
|
||||
val accountSettings = accountSettingsFactory.create(account)
|
||||
val defaultSyncInterval = settingsManager.getLong(Settings.DEFAULT_SYNC_INTERVAL)
|
||||
|
||||
// Configure CardDAV service
|
||||
val addrBookAuthority = context.getString(R.string.address_books_authority)
|
||||
if (config.cardDAV != null) {
|
||||
// insert CardDAV service
|
||||
val id = insertService(accountName, Service.TYPE_CARDDAV, config.cardDAV)
|
||||
|
||||
// initial CardDAV account settings and sync intervals
|
||||
// set initial CardDAV account settings and set sync intervals (enables automatic sync)
|
||||
val accountSettings = accountSettingsFactory.create(account)
|
||||
accountSettings.setGroupMethod(groupMethod)
|
||||
accountSettings.setSyncInterval(addrBookAuthority, defaultSyncInterval)
|
||||
|
||||
// start CardDAV service detection (refresh collections)
|
||||
RefreshCollectionsWorker.enqueue(context, id)
|
||||
}
|
||||
|
||||
// Configure CalDAV service
|
||||
if (config.calDAV != null) {
|
||||
// insert CalDAV service
|
||||
val id = insertService(accountName, Service.TYPE_CALDAV, config.calDAV)
|
||||
|
||||
// set default sync interval and enable sync regardless of permissions
|
||||
ContentResolver.setIsSyncable(account, CalendarContract.AUTHORITY, 1)
|
||||
accountSettings.setSyncInterval(CalendarContract.AUTHORITY, defaultSyncInterval)
|
||||
|
||||
// if task provider present, set task sync interval and enable sync
|
||||
val taskProvider = tasksAppManager.get().currentProvider()
|
||||
if (taskProvider != null) {
|
||||
ContentResolver.setIsSyncable(account, taskProvider.authority, 1)
|
||||
accountSettings.setSyncInterval(taskProvider.authority, defaultSyncInterval)
|
||||
// further changes will be handled by TasksWatcher on app start or when tasks app is (un)installed
|
||||
logger.info("Tasks provider ${taskProvider.authority} found. Tasks sync enabled.")
|
||||
} else
|
||||
logger.info("No tasks provider found. Did not enable tasks sync.")
|
||||
|
||||
// start CalDAV service detection (refresh collections)
|
||||
RefreshCollectionsWorker.enqueue(context, id)
|
||||
} else
|
||||
ContentResolver.setIsSyncable(account, CalendarContract.AUTHORITY, 0)
|
||||
}
|
||||
|
||||
// set up automatic sync (processes inserted services)
|
||||
automaticSyncManager.updateAutomaticSync(account)
|
||||
|
||||
} catch(e: InvalidAccountException) {
|
||||
logger.log(Level.SEVERE, "Couldn't access account settings", e)
|
||||
@@ -140,7 +121,7 @@ class AccountRepository @Inject constructor(
|
||||
// delete address books (= address book accounts)
|
||||
serviceRepository.getByAccountAndType(accountName, Service.TYPE_CARDDAV)?.let { service ->
|
||||
collectionRepository.getByService(service.id).forEach { collection ->
|
||||
LocalAddressBook.deleteByCollection(context, collection.id)
|
||||
localAddressBookStore.get().deleteByCollectionId(collection.id)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -201,16 +182,6 @@ class AccountRepository @Inject constructor(
|
||||
if (accountManager.getAccountsByType(context.getString(R.string.account_type)).contains(newAccount))
|
||||
throw IllegalArgumentException("Account with name \"$newName\" already exists")
|
||||
|
||||
// remember sync intervals
|
||||
val oldSettings = accountSettingsFactory.create(oldAccount)
|
||||
val authorities = mutableListOf(
|
||||
context.getString(R.string.address_books_authority),
|
||||
CalendarContract.AUTHORITY
|
||||
)
|
||||
val tasksProvider = tasksAppManager.get().currentProvider()
|
||||
tasksProvider?.authority?.let { authorities.add(it) }
|
||||
val syncIntervals = authorities.map { Pair(it, oldSettings.getSyncInterval(it)) }
|
||||
|
||||
// rename account
|
||||
try {
|
||||
/* https://github.com/bitfireAT/davx5/issues/135
|
||||
@@ -222,7 +193,7 @@ class AccountRepository @Inject constructor(
|
||||
3. Now the services would be renamed, but they're not here anymore. */
|
||||
AccountsCleanupWorker.lockAccountsCleanup()
|
||||
|
||||
// rename account
|
||||
// rename account (also moves AccountSettings)
|
||||
val future = accountManager.renameAccount(oldAccount, newName, null, null)
|
||||
|
||||
// wait for operation to complete
|
||||
@@ -234,37 +205,39 @@ class AccountRepository @Inject constructor(
|
||||
}
|
||||
|
||||
// account renamed, cancel maybe running synchronization of old account
|
||||
BaseSyncWorker.cancelAllWork(context, oldAccount)
|
||||
syncWorkerManager.cancelAllWork(oldAccount)
|
||||
|
||||
// disable periodic syncs for old account
|
||||
syncIntervals.forEach { (authority, _) ->
|
||||
syncWorkerManager.disablePeriodic(oldAccount, authority)
|
||||
}
|
||||
for (dataType in SyncDataType.entries)
|
||||
syncWorkerManager.disablePeriodic(oldAccount, dataType)
|
||||
|
||||
// update account name references in database
|
||||
serviceRepository.renameAccount(oldName, newName)
|
||||
|
||||
// calendar provider doesn't allow changing account_name of Events
|
||||
// (all events will have to be downloaded again at next sync)
|
||||
|
||||
// update account_name of local tasks
|
||||
try {
|
||||
LocalTaskList.onRenameAccount(context, oldAccount.name, newName)
|
||||
// update address books
|
||||
localAddressBookStore.get().updateAccount(oldAccount, newAccount)
|
||||
} catch (e: Exception) {
|
||||
logger.log(Level.WARNING, "Couldn't propagate new account name to tasks provider", e)
|
||||
// Couldn't update task lists, but this is not a fatal error (will be fixed at next sync)
|
||||
logger.log(Level.WARNING, "Couldn't change address books to renamed account", e)
|
||||
}
|
||||
|
||||
// restore sync intervals
|
||||
val newSettings = accountSettingsFactory.create(newAccount)
|
||||
for ((authority, interval) in syncIntervals) {
|
||||
if (interval == null)
|
||||
ContentResolver.setIsSyncable(newAccount, authority, 0)
|
||||
else {
|
||||
ContentResolver.setIsSyncable(newAccount, authority, 1)
|
||||
newSettings.setSyncInterval(authority, interval)
|
||||
}
|
||||
try {
|
||||
// update calendar events
|
||||
localCalendarStore.get().updateAccount(oldAccount, newAccount)
|
||||
} catch (e: Exception) {
|
||||
logger.log(Level.WARNING, "Couldn't change calendars to renamed account", e)
|
||||
}
|
||||
|
||||
try {
|
||||
// update account_name of local tasks
|
||||
val dataStore = tasksAppManager.get().getDataStore()
|
||||
dataStore?.updateAccount(oldAccount, newAccount)
|
||||
} catch (e: Exception) {
|
||||
logger.log(Level.WARNING, "Couldn't change task lists to renamed account", e)
|
||||
}
|
||||
|
||||
// update automatic sync
|
||||
automaticSyncManager.updateAutomaticSync(newAccount)
|
||||
} finally {
|
||||
// release AccountsCleanupWorker mutex at the end of this async coroutine
|
||||
AccountsCleanupWorker.unlockAccountsCleanup()
|
||||
@@ -285,8 +258,7 @@ class AccountRepository @Inject constructor(
|
||||
|
||||
// insert collections
|
||||
for (collection in info.collections.values) {
|
||||
collection.serviceId = serviceId
|
||||
collectionRepository.insertOrUpdateByUrl(collection)
|
||||
collectionRepository.insertOrUpdateByUrl(collection.copy(serviceId = serviceId))
|
||||
}
|
||||
|
||||
return serviceId
|
||||
|
||||
@@ -29,15 +29,12 @@ import at.bitfire.davdroid.servicedetection.RefreshCollectionsWorker
|
||||
import at.bitfire.davdroid.settings.AccountSettings
|
||||
import at.bitfire.davdroid.util.DavUtils
|
||||
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 java.io.StringWriter
|
||||
import java.util.Collections
|
||||
import java.util.UUID
|
||||
import javax.inject.Inject
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.runInterruptible
|
||||
import kotlinx.coroutines.withContext
|
||||
@@ -46,6 +43,10 @@ import net.fortuna.ical4j.model.Component
|
||||
import net.fortuna.ical4j.model.ComponentList
|
||||
import net.fortuna.ical4j.model.component.VTimeZone
|
||||
import okhttp3.HttpUrl
|
||||
import java.io.StringWriter
|
||||
import java.util.Collections
|
||||
import java.util.UUID
|
||||
import javax.inject.Inject
|
||||
|
||||
/**
|
||||
* Repository for managing collections.
|
||||
@@ -54,16 +55,21 @@ import okhttp3.HttpUrl
|
||||
*/
|
||||
class DavCollectionRepository @Inject constructor(
|
||||
private val accountSettingsFactory: AccountSettings.Factory,
|
||||
@ApplicationContext val context: Context,
|
||||
db: AppDatabase,
|
||||
defaultListeners: Set<@JvmSuppressWildcards OnChangeListener>,
|
||||
@ApplicationContext private val context: Context,
|
||||
private val db: AppDatabase,
|
||||
defaultListeners: Lazy<Set<@JvmSuppressWildcards OnChangeListener>>,
|
||||
private val serviceRepository: DavServiceRepository
|
||||
) {
|
||||
|
||||
private val listeners = Collections.synchronizedSet(defaultListeners.toMutableSet())
|
||||
private val listeners by lazy { Collections.synchronizedSet(defaultListeners.get().toMutableSet()) }
|
||||
|
||||
private val dao = db.collectionDao()
|
||||
|
||||
/**
|
||||
* Whether there are any collections that are registered for push.
|
||||
*/
|
||||
suspend fun anyPushCapable() = dao.anyPushCapable()
|
||||
|
||||
/**
|
||||
* Creates address book collection on server and locally
|
||||
*/
|
||||
@@ -151,7 +157,7 @@ class DavCollectionRepository @Inject constructor(
|
||||
displayName = displayName,
|
||||
description = description,
|
||||
color = color,
|
||||
timezone = timeZoneId?.let { getVTimeZone(it)?.toString() },
|
||||
timezoneId = timeZoneId,
|
||||
supportsVEVENT = supportVEVENT,
|
||||
supportsVTODO = supportVTODO,
|
||||
supportsVJOURNAL = supportVJOURNAL
|
||||
@@ -210,20 +216,28 @@ class DavCollectionRepository @Inject constructor(
|
||||
dao.getPushRegisteredAndNotSyncable()
|
||||
|
||||
/**
|
||||
* Inserts or updates the collection. On update it will not update flag values ([Collection.sync],
|
||||
* [Collection.forceReadOnly]), but use the values of the already existing collection.
|
||||
* Inserts or updates the collection.
|
||||
*
|
||||
* On update, it will _not_ update the flags
|
||||
* - [Collection.sync] and
|
||||
* - [Collection.forceReadOnly],
|
||||
* but use the values of the already existing collection.
|
||||
*
|
||||
* @param newCollection Collection to be inserted or updated
|
||||
*/
|
||||
fun insertOrUpdateByUrlAndRememberFlags(newCollection: Collection) {
|
||||
// remember locally set flags
|
||||
dao.getByServiceAndUrl(newCollection.serviceId, newCollection.url.toString())?.let { oldCollection ->
|
||||
newCollection.sync = oldCollection.sync
|
||||
newCollection.forceReadOnly = oldCollection.forceReadOnly
|
||||
}
|
||||
db.runInTransaction {
|
||||
// remember locally set flags
|
||||
val oldCollection = dao.getByServiceAndUrl(newCollection.serviceId, newCollection.url.toString())
|
||||
val newCollectionWithFlags =
|
||||
if (oldCollection != null)
|
||||
newCollection.copy(sync = oldCollection.sync, forceReadOnly = oldCollection.forceReadOnly)
|
||||
else
|
||||
newCollection
|
||||
|
||||
// commit to database
|
||||
insertOrUpdateByUrl(newCollection)
|
||||
// commit new collection to database
|
||||
insertOrUpdateByUrl(newCollectionWithFlags)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -256,8 +270,12 @@ class DavCollectionRepository @Inject constructor(
|
||||
notifyOnChangeListeners()
|
||||
}
|
||||
|
||||
fun updatePushSubscription(id: Long, subscriptionUrl: String?) {
|
||||
dao.updatePushSubscription(id, subscriptionUrl)
|
||||
fun updatePushSubscription(id: Long, subscriptionUrl: String?, expires: Long?) {
|
||||
dao.updatePushSubscription(
|
||||
id = id,
|
||||
pushSubscription = subscriptionUrl,
|
||||
pushSubscriptionExpires = expires
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -4,9 +4,10 @@
|
||||
|
||||
package at.bitfire.davdroid.repository
|
||||
|
||||
import android.app.Application
|
||||
import android.content.Context
|
||||
import android.content.SharedPreferences.OnSharedPreferenceChangeListener
|
||||
import androidx.preference.PreferenceManager
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import kotlinx.coroutines.channels.awaitClose
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.callbackFlow
|
||||
@@ -18,7 +19,7 @@ import javax.inject.Inject
|
||||
* [at.bitfire.davdroid.settings.SettingsManager].
|
||||
*/
|
||||
class PreferenceRepository @Inject constructor(
|
||||
context: Application
|
||||
@ApplicationContext context: Context
|
||||
) {
|
||||
|
||||
companion object {
|
||||
|
||||
@@ -6,11 +6,8 @@ package at.bitfire.davdroid.resource
|
||||
import android.accounts.Account
|
||||
import android.accounts.AccountManager
|
||||
import android.content.ContentProviderClient
|
||||
import android.content.ContentResolver
|
||||
import android.content.ContentUris
|
||||
import android.content.ContentValues
|
||||
import android.content.Context
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import android.os.RemoteException
|
||||
import android.provider.ContactsContract
|
||||
@@ -18,16 +15,16 @@ import android.provider.ContactsContract.CommonDataKinds.GroupMembership
|
||||
import android.provider.ContactsContract.Groups
|
||||
import android.provider.ContactsContract.RawContacts
|
||||
import androidx.annotation.OpenForTesting
|
||||
import androidx.annotation.VisibleForTesting
|
||||
import androidx.core.content.contentValuesOf
|
||||
import at.bitfire.davdroid.R
|
||||
import at.bitfire.davdroid.db.Collection
|
||||
import at.bitfire.davdroid.db.SyncState
|
||||
import at.bitfire.davdroid.repository.DavCollectionRepository
|
||||
import at.bitfire.davdroid.repository.DavServiceRepository
|
||||
import at.bitfire.davdroid.resource.workaround.ContactDirtyVerifier
|
||||
import at.bitfire.davdroid.settings.AccountSettings
|
||||
import at.bitfire.davdroid.sync.SyncFrameworkIntegration
|
||||
import at.bitfire.davdroid.sync.account.SystemAccountUtils
|
||||
import at.bitfire.davdroid.util.DavUtils.lastSegment
|
||||
import at.bitfire.davdroid.util.setAndVerifyUserData
|
||||
import at.bitfire.davdroid.sync.account.setAndVerifyUserData
|
||||
import at.bitfire.vcard4android.AndroidAddressBook
|
||||
import at.bitfire.vcard4android.AndroidContact
|
||||
import at.bitfire.vcard4android.AndroidGroup
|
||||
@@ -36,12 +33,9 @@ import at.bitfire.vcard4android.GroupMethod
|
||||
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.android.qualifiers.ApplicationContext
|
||||
import dagger.hilt.components.SingletonComponent
|
||||
import java.util.LinkedList
|
||||
import java.util.Optional
|
||||
import java.util.logging.Level
|
||||
import java.util.logging.Logger
|
||||
|
||||
@@ -50,29 +44,35 @@ import java.util.logging.Logger
|
||||
* 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 _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.
|
||||
*
|
||||
* @param provider Content provider needed to access and modify the address book
|
||||
* @param provider Content provider needed to access and modify the address book
|
||||
*/
|
||||
@OpenForTesting
|
||||
open class LocalAddressBook @AssistedInject constructor(
|
||||
@Assisted _addressBookAccount: Account,
|
||||
@Assisted("account") val account: Account,
|
||||
@Assisted("addressBookAccount") _addressBookAccount: Account,
|
||||
@Assisted provider: ContentProviderClient,
|
||||
private val accountSettingsFactory: AccountSettings.Factory,
|
||||
private val collectionRepository: DavCollectionRepository,
|
||||
@ApplicationContext val context: Context,
|
||||
@ApplicationContext private val context: Context,
|
||||
internal val dirtyVerifier: Optional<ContactDirtyVerifier>,
|
||||
private val logger: Logger,
|
||||
private val serviceRepository: DavServiceRepository
|
||||
private val serviceRepository: DavServiceRepository,
|
||||
private val syncFramework: SyncFrameworkIntegration
|
||||
): AndroidAddressBook<LocalContact, LocalGroup>(_addressBookAccount, provider, LocalContact.Factory, LocalGroup.Factory), LocalCollection<LocalAddress> {
|
||||
|
||||
@AssistedFactory
|
||||
interface Factory {
|
||||
fun create(addressBookAccount: Account, provider: ContentProviderClient): LocalAddressBook
|
||||
fun create(
|
||||
@Assisted("account") account: Account,
|
||||
@Assisted("addressBookAccount") addressBookAccount: Account,
|
||||
provider: ContentProviderClient
|
||||
): LocalAddressBook
|
||||
}
|
||||
|
||||
|
||||
override val tag: String
|
||||
get() = "contacts-${addressBookAccount.name}"
|
||||
|
||||
@@ -100,7 +100,7 @@ open class LocalAddressBook @AssistedInject constructor(
|
||||
val accountSettings = accountSettingsFactory.create(account)
|
||||
accountSettings.getGroupMethod()
|
||||
}
|
||||
private val includeGroups
|
||||
val includeGroups
|
||||
get() = groupMethod == GroupMethod.GROUP_VCARDS
|
||||
|
||||
@Deprecated("Local collection should be identified by ID, not by URL")
|
||||
@@ -109,9 +109,36 @@ open class LocalAddressBook @AssistedInject constructor(
|
||||
?: throw IllegalStateException("Address book has no URL")
|
||||
set(url) = AccountManager.get(context).setAndVerifyUserData(addressBookAccount, USER_DATA_URL, url)
|
||||
|
||||
/**
|
||||
* Read-only flag for the address book itself.
|
||||
*
|
||||
* Setting this flag:
|
||||
*
|
||||
* - stores the new value in [USER_DATA_READ_ONLY] and
|
||||
* - sets the read-only flag for all contacts and groups in the address book in the content provider, which will
|
||||
* prevent non-sync-adapter apps from modifying them. However new entries can still be created, so the address book
|
||||
* is not really read-only.
|
||||
*
|
||||
* 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
|
||||
set(readOnly) = AccountManager.get(context).setAndVerifyUserData(addressBookAccount, USER_DATA_READ_ONLY, if (readOnly) "1" else 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)
|
||||
|
||||
// update raw contacts
|
||||
val rawContactValues = contentValuesOf(RawContacts.RAW_CONTACT_IS_READ_ONLY to if (readOnly) 1 else 0)
|
||||
provider!!.update(rawContactsSyncUri(), rawContactValues, null, null)
|
||||
|
||||
// update data rows
|
||||
val dataValues = contentValuesOf(ContactsContract.Data.IS_READ_ONLY to if (readOnly) 1 else 0)
|
||||
provider!!.update(syncAdapterURI(ContactsContract.Data.CONTENT_URI), dataValues, null, null)
|
||||
|
||||
// update group rows
|
||||
val groupValues = contentValuesOf(Groups.GROUP_IS_READ_ONLY to if (readOnly) 1 else 0)
|
||||
provider!!.update(groupsSyncUri(), groupValues, null, null)
|
||||
}
|
||||
|
||||
override var lastSyncState: SyncState?
|
||||
get() = syncState?.let { SyncState.fromString(String(it)) }
|
||||
@@ -123,8 +150,7 @@ open class LocalAddressBook @AssistedInject constructor(
|
||||
/* operations on the collection (address book) itself */
|
||||
|
||||
override fun markNotDirty(flags: Int): Int {
|
||||
val values = ContentValues(1)
|
||||
values.put(LocalContact.COLUMN_FLAGS, flags)
|
||||
val values = contentValuesOf(LocalContact.COLUMN_FLAGS to flags)
|
||||
var number = provider!!.update(rawContactsSyncUri(), values, "${RawContacts.DIRTY}=0", null)
|
||||
|
||||
if (includeGroups) {
|
||||
@@ -147,58 +173,6 @@ open class LocalAddressBook @AssistedInject constructor(
|
||||
return number
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates the address book settings.
|
||||
*
|
||||
* @param info collection where to take the settings from
|
||||
* @param forceReadOnly `true`: set the address book to "force read-only";
|
||||
* `false`: determine read-only flag from [info];
|
||||
* `null`: don't change the existing value
|
||||
*/
|
||||
fun update(info: Collection, forceReadOnly: Boolean? = null) {
|
||||
logger.log(Level.INFO, "Updating local address book $addressBookAccount with collection $info")
|
||||
val accountManager = AccountManager.get(context)
|
||||
|
||||
// Update the account name
|
||||
val newAccountName = accountName(context, info)
|
||||
if (addressBookAccount.name != newAccountName)
|
||||
// rename, move contacts/groups and update [AndroidAddressBook.]account
|
||||
renameAccount(newAccountName)
|
||||
|
||||
// Update the account user data
|
||||
accountManager.setAndVerifyUserData(addressBookAccount, USER_DATA_COLLECTION_ID, info.id.toString())
|
||||
accountManager.setAndVerifyUserData(addressBookAccount, USER_DATA_URL, info.url.toString())
|
||||
|
||||
// Update force read only
|
||||
if (forceReadOnly != null) {
|
||||
val nowReadOnly = forceReadOnly || !info.privWriteContent || info.forceReadOnly
|
||||
if (nowReadOnly != readOnly) {
|
||||
logger.info("Address book now read-only = $nowReadOnly, updating contacts")
|
||||
|
||||
// update address book itself
|
||||
readOnly = nowReadOnly
|
||||
|
||||
// update raw contacts
|
||||
val rawContactValues = ContentValues(1)
|
||||
rawContactValues.put(RawContacts.RAW_CONTACT_IS_READ_ONLY, if (nowReadOnly) 1 else 0)
|
||||
provider!!.update(rawContactsSyncUri(), rawContactValues, null, null)
|
||||
|
||||
// update data rows
|
||||
val dataValues = ContentValues(1)
|
||||
dataValues.put(ContactsContract.Data.IS_READ_ONLY, if (nowReadOnly) 1 else 0)
|
||||
provider!!.update(syncAdapterURI(ContactsContract.Data.CONTENT_URI), dataValues, null, null)
|
||||
|
||||
// update group rows
|
||||
val groupValues = ContentValues(1)
|
||||
groupValues.put(Groups.GROUP_IS_READ_ONLY, if (nowReadOnly) 1 else 0)
|
||||
provider!!.update(groupsSyncUri(), groupValues, null, null)
|
||||
}
|
||||
}
|
||||
|
||||
// make sure it will still be synchronized when contacts are updated
|
||||
updateSyncFrameworkSettings()
|
||||
}
|
||||
|
||||
/**
|
||||
* Renames an address book account and moves the contacts and groups (without making them dirty).
|
||||
* Does not keep user data of the old account, so these have to be set again.
|
||||
@@ -212,7 +186,6 @@ open class LocalAddressBook @AssistedInject constructor(
|
||||
*
|
||||
* @return whether the account was renamed successfully
|
||||
*/
|
||||
@VisibleForTesting
|
||||
internal fun renameAccount(newName: String): Boolean {
|
||||
val oldAccount = addressBookAccount
|
||||
logger.info("Renaming address book from \"${oldAccount.name}\" to \"$newName\"")
|
||||
@@ -246,32 +219,17 @@ open class LocalAddressBook @AssistedInject constructor(
|
||||
return true
|
||||
}
|
||||
|
||||
override fun deleteCollection(): Boolean {
|
||||
val accountManager = AccountManager.get(context)
|
||||
return accountManager.removeAccountExplicitly(addressBookAccount)
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Updates the sync framework settings for this address book:
|
||||
*
|
||||
* - Contacts sync of this address book account shall be possible -> isSyncable = 1
|
||||
* - When a contact is changed, a sync shall be initiated -> syncAutomatically = true
|
||||
* - Remove unwanted sync framework periodic syncs created by setSyncAutomatically, as
|
||||
* we use PeriodicSyncWorker for scheduled syncs
|
||||
* Makes contacts of this address book available to be synced and activates synchronization upon
|
||||
* contact data changes.
|
||||
*/
|
||||
fun updateSyncFrameworkSettings() {
|
||||
// Enable sync-ability
|
||||
if (ContentResolver.getIsSyncable(addressBookAccount, ContactsContract.AUTHORITY) != 1)
|
||||
ContentResolver.setIsSyncable(addressBookAccount, ContactsContract.AUTHORITY, 1)
|
||||
// Enable sync-ability of contacts
|
||||
syncFramework.enableSyncAbility(addressBookAccount, ContactsContract.AUTHORITY)
|
||||
|
||||
// Enable content trigger
|
||||
if (!ContentResolver.getSyncAutomatically(addressBookAccount, ContactsContract.AUTHORITY))
|
||||
ContentResolver.setSyncAutomatically(addressBookAccount, ContactsContract.AUTHORITY, true)
|
||||
|
||||
// Remove periodic syncs (setSyncAutomatically also creates periodic syncs, which we don't want)
|
||||
for (periodicSync in ContentResolver.getPeriodicSyncs(addressBookAccount, ContactsContract.AUTHORITY))
|
||||
ContentResolver.removePeriodicSync(periodicSync.account, periodicSync.authority, periodicSync.extras)
|
||||
// Changes in contact data should trigger syncs
|
||||
syncFramework.enableSyncOnContentChange(addressBookAccount, ContactsContract.AUTHORITY)
|
||||
}
|
||||
|
||||
|
||||
@@ -313,12 +271,10 @@ open class LocalAddressBook @AssistedInject constructor(
|
||||
|
||||
override fun forgetETags() {
|
||||
if (includeGroups) {
|
||||
val values = ContentValues(1)
|
||||
values.putNull(AndroidGroup.COLUMN_ETAG)
|
||||
val values = contentValuesOf(AndroidGroup.COLUMN_ETAG to null)
|
||||
provider!!.update(groupsSyncUri(), values, null, null)
|
||||
}
|
||||
val values = ContentValues(1)
|
||||
values.putNull(AndroidContact.COLUMN_ETAG)
|
||||
val values = contentValuesOf(AndroidContact.COLUMN_ETAG to null)
|
||||
provider!!.update(rawContactsSyncUri(), values, null, null)
|
||||
}
|
||||
|
||||
@@ -344,39 +300,6 @@ open class LocalAddressBook @AssistedInject constructor(
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Queries all contacts with DIRTY flag and checks whether their data checksum has changed, i.e.
|
||||
* if they're "really dirty" (= data has changed, not only metadata, which is not hashed).
|
||||
* The DIRTY flag is removed from contacts which are not "really dirty", i.e. from contacts
|
||||
* whose contact data checksum has not changed.
|
||||
* @return number of "really dirty" contacts
|
||||
* @throws RemoteException on content provider errors
|
||||
*/
|
||||
fun verifyDirty(): Int {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O)
|
||||
throw IllegalStateException("verifyDirty() should not be called on Android != 7.0")
|
||||
|
||||
var reallyDirty = 0
|
||||
for (contact in findDirtyContacts()) {
|
||||
val lastHash = contact.getLastHashCode()
|
||||
val currentHash = contact.dataHashCode()
|
||||
if (lastHash == currentHash) {
|
||||
// hash is code still the same, contact is not "really dirty" (only metadata been have changed)
|
||||
logger.log(Level.FINE, "Contact data hash has not changed, resetting dirty flag", contact)
|
||||
contact.resetDirty()
|
||||
} else {
|
||||
logger.log(Level.FINE, "Contact data has changed from hash $lastHash to $currentHash", contact)
|
||||
reallyDirty++
|
||||
}
|
||||
}
|
||||
|
||||
if (includeGroups)
|
||||
reallyDirty += findDirtyGroups().size
|
||||
|
||||
return reallyDirty
|
||||
}
|
||||
|
||||
|
||||
/* special group operations */
|
||||
|
||||
/**
|
||||
@@ -393,8 +316,7 @@ open class LocalAddressBook @AssistedInject constructor(
|
||||
return cursor.getLong(0)
|
||||
}
|
||||
|
||||
val values = ContentValues(1)
|
||||
values.put(Groups.TITLE, title)
|
||||
val values = contentValuesOf(Groups.TITLE to title)
|
||||
val uri = provider!!.insert(syncAdapterURI(Groups.CONTENT_URI), values) ?: throw RemoteException("Couldn't create contact group")
|
||||
return ContentUris.parseId(uri)
|
||||
}
|
||||
@@ -411,126 +333,31 @@ open class LocalAddressBook @AssistedInject constructor(
|
||||
|
||||
companion object {
|
||||
|
||||
@EntryPoint
|
||||
@InstallIn(SingletonComponent::class)
|
||||
interface LocalAddressBookCompanionEntryPoint {
|
||||
fun localAddressBookFactory(): Factory
|
||||
fun serviceRepository(): DavServiceRepository
|
||||
fun logger(): Logger
|
||||
}
|
||||
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].
|
||||
*
|
||||
* User data of the address book account (Long).
|
||||
*/
|
||||
const val USER_DATA_COLLECTION_ID = "collection_id"
|
||||
|
||||
/**
|
||||
* Indicates whether the address book is currently set to read-only (i.e. its contacts and groups have the read-only flag).
|
||||
*
|
||||
* User data of the address book account (Boolean).
|
||||
*/
|
||||
const val USER_DATA_READ_ONLY = "read_only"
|
||||
|
||||
// create/query/delete
|
||||
|
||||
/**
|
||||
* Creates a new local address book.
|
||||
*
|
||||
* @param context app context to resolve string resources
|
||||
* @param provider contacts provider client
|
||||
* @param info collection where to take the name and settings from
|
||||
* @param forceReadOnly `true`: set the address book to "force read-only"; `false`: determine read-only flag from [info]
|
||||
*/
|
||||
fun create(context: Context, provider: ContentProviderClient, info: Collection, forceReadOnly: Boolean): LocalAddressBook {
|
||||
val entryPoint = EntryPointAccessors.fromApplication<LocalAddressBookCompanionEntryPoint>(context)
|
||||
val logger = entryPoint.logger()
|
||||
|
||||
val account = Account(accountName(context, info), context.getString(R.string.account_type_address_book))
|
||||
val userData = initialUserData(info.url.toString(), info.id.toString())
|
||||
logger.log(Level.INFO, "Creating local address book $account", userData)
|
||||
if (!SystemAccountUtils.createAccount(context, account, userData))
|
||||
throw IllegalStateException("Couldn't create address book account")
|
||||
|
||||
val factory = entryPoint.localAddressBookFactory()
|
||||
val addressBook = factory.create(account, provider)
|
||||
addressBook.updateSyncFrameworkSettings()
|
||||
|
||||
// initialize Contacts Provider Settings
|
||||
val values = ContentValues(2)
|
||||
values.put(ContactsContract.Settings.SHOULD_SYNC, 1)
|
||||
values.put(ContactsContract.Settings.UNGROUPED_VISIBLE, 1)
|
||||
addressBook.settings = values
|
||||
addressBook.readOnly = forceReadOnly || !info.privWriteContent || info.forceReadOnly
|
||||
|
||||
return addressBook
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds a [LocalAddressBook] based on its corresponding collection.
|
||||
*
|
||||
* @param id collection ID to look for
|
||||
*
|
||||
* @return The [LocalAddressBook] for the given collection or *null* if not found
|
||||
*/
|
||||
fun findByCollection(context: Context, provider: ContentProviderClient, id: Long): LocalAddressBook? {
|
||||
val entryPoint = EntryPointAccessors.fromApplication<LocalAddressBookCompanionEntryPoint>(context)
|
||||
val factory = entryPoint.localAddressBookFactory()
|
||||
|
||||
val accountManager = AccountManager.get(context)
|
||||
return accountManager
|
||||
.getAccountsByType(context.getString(R.string.account_type_address_book))
|
||||
.filter { account ->
|
||||
accountManager.getUserData(account, USER_DATA_COLLECTION_ID)?.toLongOrNull() == id
|
||||
}
|
||||
.map { account -> factory.create(account, provider) }
|
||||
.firstOrNull()
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes a [LocalAddressBook] based on its corresponding database collection.
|
||||
*
|
||||
* @param id collection ID to look for
|
||||
*/
|
||||
fun deleteByCollection(context: Context, id: Long) {
|
||||
val accountManager = AccountManager.get(context)
|
||||
val addressBookAccount = accountManager.getAccountsByType(context.getString(R.string.account_type_address_book)).firstOrNull { account ->
|
||||
accountManager.getUserData(account, USER_DATA_COLLECTION_ID)?.toLongOrNull() == id
|
||||
}
|
||||
if (addressBookAccount != null)
|
||||
accountManager.removeAccountExplicitly(addressBookAccount)
|
||||
}
|
||||
|
||||
|
||||
// helpers
|
||||
|
||||
/**
|
||||
* Creates a name for the address book account from its corresponding db collection info.
|
||||
*
|
||||
* The address book account name contains
|
||||
* - the collection display name or last URL path segment
|
||||
* - the actual account name
|
||||
* - the collection ID, to make it unique.
|
||||
*
|
||||
* @param info The corresponding collection
|
||||
*/
|
||||
fun accountName(context: Context, 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
|
||||
})
|
||||
// Add the actual account name to the address book account name
|
||||
val entryPoint = EntryPointAccessors.fromApplication<LocalAddressBookCompanionEntryPoint>(context)
|
||||
val serviceRepository = entryPoint.serviceRepository()
|
||||
serviceRepository.get(info.serviceId)?.let { service ->
|
||||
sb.append(" (${service.accountName})")
|
||||
}
|
||||
// Add the collection ID for uniqueness
|
||||
sb.append(" #${info.id}")
|
||||
return sb.toString()
|
||||
}
|
||||
|
||||
private fun initialUserData(url: String, collectionId: String): Bundle {
|
||||
val bundle = Bundle(3)
|
||||
bundle.putString(USER_DATA_COLLECTION_ID, collectionId)
|
||||
bundle.putString(USER_DATA_URL, url)
|
||||
return bundle
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,224 @@
|
||||
/*
|
||||
* 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 android.provider.ContactsContract
|
||||
import androidx.annotation.OpenForTesting
|
||||
import androidx.annotation.VisibleForTesting
|
||||
import androidx.core.content.contentValuesOf
|
||||
import androidx.core.os.bundleOf
|
||||
import at.bitfire.davdroid.R
|
||||
import at.bitfire.davdroid.db.Collection
|
||||
import at.bitfire.davdroid.repository.DavServiceRepository
|
||||
import at.bitfire.davdroid.settings.Settings
|
||||
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 dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import java.util.logging.Level
|
||||
import java.util.logging.Logger
|
||||
import javax.inject.Inject
|
||||
|
||||
class LocalAddressBookStore @Inject constructor(
|
||||
@ApplicationContext private val context: Context,
|
||||
private val localAddressBookFactory: LocalAddressBook.Factory,
|
||||
private val logger: Logger,
|
||||
private val serviceRepository: DavServiceRepository,
|
||||
private val settings: SettingsManager
|
||||
): LocalDataStore<LocalAddressBook> {
|
||||
|
||||
/** whether a (usually managed) setting wants all address-books to be read-only **/
|
||||
val forceAllReadOnly: Boolean
|
||||
get() = settings.getBoolean(Settings.FORCE_READ_ONLY_ADDRESSBOOKS)
|
||||
|
||||
|
||||
/**
|
||||
* Assembles a name for the address book (account) from its corresponding database [Collection].
|
||||
*
|
||||
* The address book account name contains
|
||||
*
|
||||
* - the collection display name or last URL path segment
|
||||
* - 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
|
||||
})
|
||||
// Add the actual account name to the address book account name
|
||||
serviceRepository.get(info.serviceId)?.let { service ->
|
||||
sb.append(" (${service.accountName})")
|
||||
}
|
||||
// Add the collection ID for uniqueness
|
||||
sb.append(" #${info.id}")
|
||||
return sb.toString()
|
||||
}
|
||||
|
||||
|
||||
override fun create(provider: ContentProviderClient, fromCollection: Collection): LocalAddressBook? {
|
||||
val service = serviceRepository.get(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()
|
||||
) ?: return null
|
||||
|
||||
val addressBook = localAddressBookFactory.create(account, addressBookAccount, provider)
|
||||
|
||||
// update settings
|
||||
addressBook.updateSyncFrameworkSettings()
|
||||
addressBook.settings = contactsProviderSettings
|
||||
addressBook.readOnly = shouldBeReadOnly(fromCollection, forceAllReadOnly)
|
||||
|
||||
return addressBook
|
||||
}
|
||||
|
||||
@OpenForTesting
|
||||
internal fun createAddressBookAccount(account: Account, name: String, id: Long, url: String): 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
|
||||
)
|
||||
if (!SystemAccountUtils.createAccount(context, addressBookAccount, userData)) {
|
||||
logger.warning("Couldn't create address book account: $addressBookAccount")
|
||||
return null
|
||||
}
|
||||
|
||||
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))
|
||||
.filter { addressBookAccount ->
|
||||
accountManager.getUserData(addressBookAccount, LocalAddressBook.USER_DATA_ACCOUNT_NAME) == account.name &&
|
||||
accountManager.getUserData(addressBookAccount, LocalAddressBook.USER_DATA_ACCOUNT_TYPE) == account.type
|
||||
}
|
||||
.map { addressBookAccount ->
|
||||
localAddressBookFactory.create(account, addressBookAccount, provider)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
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")
|
||||
|
||||
// Update the account name
|
||||
val newAccountName = accountName(fromCollection)
|
||||
if (currentAccount.name != newAccountName) {
|
||||
// rename, move contacts/groups and update [AndroidAddressBook.]account
|
||||
localCollection.renameAccount(newAccountName)
|
||||
currentAccount = Account(newAccountName, currentAccount.type)
|
||||
}
|
||||
|
||||
// Update the account user data
|
||||
val accountManager = AccountManager.get(context)
|
||||
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
|
||||
|
||||
// Update force read only
|
||||
val nowReadOnly = shouldBeReadOnly(fromCollection, forceAllReadOnly)
|
||||
if (nowReadOnly != localCollection.readOnly) {
|
||||
logger.info("Address book has changed to read-only = $nowReadOnly")
|
||||
localCollection.readOnly = nowReadOnly
|
||||
}
|
||||
|
||||
// make sure it will still be synchronized when contacts are updated
|
||||
localCollection.updateSyncFrameworkSettings()
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates address books which are assigned to [oldAccount] so that they're assigned to [newAccount] instead.
|
||||
*
|
||||
* @param oldAccount The old account
|
||||
* @param newAccount The new account
|
||||
*/
|
||||
override fun updateAccount(oldAccount: Account, newAccount: Account) {
|
||||
val accountManager = AccountManager.get(context)
|
||||
accountManager.getAccountsByType(context.getString(R.string.account_type_address_book))
|
||||
.filter { addressBookAccount ->
|
||||
accountManager.getUserData(addressBookAccount, LocalAddressBook.USER_DATA_ACCOUNT_NAME) == oldAccount.name &&
|
||||
accountManager.getUserData(addressBookAccount, LocalAddressBook.USER_DATA_ACCOUNT_TYPE) == oldAccount.type
|
||||
}
|
||||
.forEach { addressBookAccount ->
|
||||
accountManager.setAndVerifyUserData(addressBookAccount, LocalAddressBook.USER_DATA_ACCOUNT_NAME, newAccount.name)
|
||||
accountManager.setAndVerifyUserData(addressBookAccount, LocalAddressBook.USER_DATA_ACCOUNT_TYPE, newAccount.type)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
override fun delete(localCollection: LocalAddressBook) {
|
||||
val accountManager = AccountManager.get(context)
|
||||
accountManager.removeAccountExplicitly(localCollection.addressBookAccount)
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes a [LocalAddressBook] based on its corresponding database collection.
|
||||
*
|
||||
* @param id [Collection.id] to look for
|
||||
*/
|
||||
fun deleteByCollectionId(id: Long) {
|
||||
val accountManager = AccountManager.get(context)
|
||||
val addressBookAccount = accountManager.getAccountsByType(context.getString(R.string.account_type_address_book)).firstOrNull { account ->
|
||||
accountManager.getUserData(account, LocalAddressBook.USER_DATA_COLLECTION_ID)?.toLongOrNull() == id
|
||||
}
|
||||
if (addressBookAccount != null)
|
||||
accountManager.removeAccountExplicitly(addressBookAccount)
|
||||
}
|
||||
|
||||
|
||||
companion object {
|
||||
|
||||
/**
|
||||
* Contacts Provider Settings (equal for every address book)
|
||||
*/
|
||||
val contactsProviderSettings
|
||||
get() = contentValuesOf(
|
||||
// SHOULD_SYNC is just a hint that an account's contacts (the contacts of this local address book) are syncable.
|
||||
ContactsContract.Settings.SHOULD_SYNC to 1,
|
||||
|
||||
// UNGROUPED_VISIBLE is required for making contacts work over Bluetooth (especially with some car systems).
|
||||
ContactsContract.Settings.UNGROUPED_VISIBLE to 1
|
||||
)
|
||||
|
||||
/**
|
||||
* Determines whether the address book should be set to read-only.
|
||||
*
|
||||
* @param forceAllReadOnly Whether (usually managed, app-wide) setting should overwrite local read-only information
|
||||
* @param info Collection data to determine read-only status from (either user-set read-only flag or missing write privilege)
|
||||
*/
|
||||
@VisibleForTesting
|
||||
internal fun shouldBeReadOnly(info: Collection, forceAllReadOnly: Boolean): Boolean =
|
||||
info.readOnly() || forceAllReadOnly
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
@@ -8,17 +8,13 @@ import android.accounts.Account
|
||||
import android.content.ContentProviderClient
|
||||
import android.content.ContentUris
|
||||
import android.content.ContentValues
|
||||
import android.net.Uri
|
||||
import android.provider.CalendarContract.Calendars
|
||||
import android.provider.CalendarContract.Events
|
||||
import at.bitfire.davdroid.Constants
|
||||
import at.bitfire.davdroid.db.Collection
|
||||
import androidx.core.content.contentValuesOf
|
||||
import at.bitfire.davdroid.db.SyncState
|
||||
import at.bitfire.davdroid.util.DavUtils.lastSegment
|
||||
import at.bitfire.ical4android.AndroidCalendar
|
||||
import at.bitfire.ical4android.AndroidCalendarFactory
|
||||
import at.bitfire.ical4android.BatchOperation
|
||||
import at.bitfire.ical4android.util.DateUtils
|
||||
import at.bitfire.ical4android.util.MiscUtils.asSyncAdapter
|
||||
import java.util.LinkedList
|
||||
import java.util.logging.Level
|
||||
@@ -42,60 +38,6 @@ class LocalCalendar private constructor(
|
||||
private val logger: Logger
|
||||
get() = Logger.getGlobal()
|
||||
|
||||
fun create(account: Account, provider: ContentProviderClient, info: Collection): Uri {
|
||||
// If the collection doesn't have a color, use a default color.
|
||||
if (info.color != null)
|
||||
info.color = Constants.DAVDROID_GREEN_RGBA
|
||||
|
||||
val values = valuesFromCollectionInfo(info, withColor = true)
|
||||
|
||||
// ACCOUNT_NAME and ACCOUNT_TYPE are required (see docs)! If it's missing, other apps will crash.
|
||||
values.put(Calendars.ACCOUNT_NAME, account.name)
|
||||
values.put(Calendars.ACCOUNT_TYPE, account.type)
|
||||
|
||||
// Email address for scheduling. Used by the calendar provider to determine whether the
|
||||
// user is ORGANIZER/ATTENDEE for a certain event.
|
||||
values.put(Calendars.OWNER_ACCOUNT, account.name)
|
||||
|
||||
// flag as visible & synchronizable at creation, might be changed by user at any time
|
||||
values.put(Calendars.VISIBLE, 1)
|
||||
values.put(Calendars.SYNC_EVENTS, 1)
|
||||
return create(account, provider, values)
|
||||
}
|
||||
|
||||
private fun valuesFromCollectionInfo(info: Collection, withColor: Boolean): ContentValues {
|
||||
val values = ContentValues()
|
||||
values.put(Calendars.NAME, info.url.toString())
|
||||
values.put(Calendars.CALENDAR_DISPLAY_NAME,
|
||||
if (info.displayName.isNullOrBlank()) info.url.lastSegment else info.displayName)
|
||||
|
||||
if (withColor && info.color != null)
|
||||
values.put(Calendars.CALENDAR_COLOR, info.color)
|
||||
|
||||
if (info.privWriteContent && !info.forceReadOnly) {
|
||||
values.put(Calendars.CALENDAR_ACCESS_LEVEL, Calendars.CAL_ACCESS_OWNER)
|
||||
values.put(Calendars.CAN_MODIFY_TIME_ZONE, 1)
|
||||
values.put(Calendars.CAN_ORGANIZER_RESPOND, 1)
|
||||
} else
|
||||
values.put(Calendars.CALENDAR_ACCESS_LEVEL, Calendars.CAL_ACCESS_READ)
|
||||
|
||||
info.timezone?.let { tzData ->
|
||||
try {
|
||||
val timeZone = DateUtils.parseVTimeZone(tzData)
|
||||
timeZone.timeZoneId?.let { tzId ->
|
||||
values.put(Calendars.CALENDAR_TIME_ZONE, DateUtils.findAndroidTimezoneID(tzId.value))
|
||||
}
|
||||
} catch(e: IllegalArgumentException) {
|
||||
logger.log(Level.WARNING, "Couldn't parse calendar default time zone", e)
|
||||
}
|
||||
}
|
||||
|
||||
// add base values for Calendars
|
||||
values.putAll(calendarBaseValues)
|
||||
|
||||
return values
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
override val collectionUrl: String?
|
||||
@@ -111,8 +53,6 @@ class LocalCalendar private constructor(
|
||||
override val readOnly
|
||||
get() = accessLevel <= Calendars.CAL_ACCESS_READ
|
||||
|
||||
override fun deleteCollection(): Boolean = delete()
|
||||
|
||||
override var lastSyncState: SyncState?
|
||||
get() = provider.query(calendarSyncURI(), arrayOf(COLUMN_SYNC_STATE), null, null, null)?.use { cursor ->
|
||||
if (cursor.moveToNext())
|
||||
@@ -121,8 +61,7 @@ class LocalCalendar private constructor(
|
||||
null
|
||||
}
|
||||
set(state) {
|
||||
val values = ContentValues(1)
|
||||
values.put(COLUMN_SYNC_STATE, state.toString())
|
||||
val values = contentValuesOf(COLUMN_SYNC_STATE to state.toString())
|
||||
provider.update(calendarSyncURI(), values, null, null)
|
||||
}
|
||||
|
||||
@@ -132,9 +71,6 @@ class LocalCalendar private constructor(
|
||||
accessLevel = info.getAsInteger(Calendars.CALENDAR_ACCESS_LEVEL) ?: Calendars.CAL_ACCESS_OWNER
|
||||
}
|
||||
|
||||
fun update(info: Collection, updateColor: Boolean) =
|
||||
update(valuesFromCollectionInfo(info, updateColor))
|
||||
|
||||
|
||||
override fun findDeleted() =
|
||||
queryEvents("${Events.DELETED} AND ${Events.ORIGINAL_ID} IS NULL", null)
|
||||
@@ -175,8 +111,7 @@ class LocalCalendar private constructor(
|
||||
|
||||
|
||||
override fun markNotDirty(flags: Int): Int {
|
||||
val values = ContentValues(1)
|
||||
values.put(LocalEvent.COLUMN_FLAGS, flags)
|
||||
val values = contentValuesOf(LocalEvent.COLUMN_FLAGS to flags)
|
||||
return provider.update(Events.CONTENT_URI.asSyncAdapter(account), values,
|
||||
"${Events.CALENDAR_ID}=? AND NOT ${Events.DIRTY} AND ${Events.ORIGINAL_ID} IS NULL",
|
||||
arrayOf(id.toString()))
|
||||
@@ -202,8 +137,7 @@ class LocalCalendar private constructor(
|
||||
}
|
||||
|
||||
override fun forgetETags() {
|
||||
val values = ContentValues(1)
|
||||
values.putNull(LocalEvent.COLUMN_ETAG)
|
||||
val values = contentValuesOf(LocalEvent.COLUMN_ETAG to null)
|
||||
provider.update(Events.CONTENT_URI.asSyncAdapter(account), values, "${Events.CALENDAR_ID}=?",
|
||||
arrayOf(id.toString()))
|
||||
}
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
/*
|
||||
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
|
||||
*/
|
||||
|
||||
package at.bitfire.davdroid.resource
|
||||
|
||||
import android.accounts.Account
|
||||
import android.content.ContentProviderClient
|
||||
import android.content.ContentUris
|
||||
import android.content.ContentValues
|
||||
import android.content.Context
|
||||
import android.provider.CalendarContract
|
||||
import android.provider.CalendarContract.Calendars
|
||||
import androidx.core.content.contentValuesOf
|
||||
import at.bitfire.davdroid.Constants
|
||||
import at.bitfire.davdroid.R
|
||||
import at.bitfire.davdroid.db.Collection
|
||||
import at.bitfire.davdroid.repository.DavServiceRepository
|
||||
import at.bitfire.davdroid.settings.AccountSettings
|
||||
import at.bitfire.davdroid.util.DavUtils.lastSegment
|
||||
import at.bitfire.ical4android.AndroidCalendar
|
||||
import at.bitfire.ical4android.AndroidCalendar.Companion.calendarBaseValues
|
||||
import at.bitfire.ical4android.util.DateUtils
|
||||
import at.bitfire.ical4android.util.MiscUtils.asSyncAdapter
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import java.util.logging.Level
|
||||
import java.util.logging.Logger
|
||||
import javax.inject.Inject
|
||||
|
||||
class LocalCalendarStore @Inject constructor(
|
||||
@ApplicationContext private val context: Context,
|
||||
private val accountSettingsFactory: AccountSettings.Factory,
|
||||
private val logger: Logger,
|
||||
private val serviceRepository: DavServiceRepository
|
||||
): LocalDataStore<LocalCalendar> {
|
||||
|
||||
override fun create(provider: ContentProviderClient, fromCollection: Collection): LocalCalendar? {
|
||||
val service = serviceRepository.get(fromCollection.serviceId) ?: throw IllegalArgumentException("Couldn't fetch DB service from collection")
|
||||
val account = Account(service.accountName, context.getString(R.string.account_type))
|
||||
|
||||
// If the collection doesn't have a color, use a default color.
|
||||
val collectionWithColor =
|
||||
if (fromCollection.color != null)
|
||||
fromCollection
|
||||
else
|
||||
fromCollection.copy(color = Constants.DAVDROID_GREEN_RGBA)
|
||||
|
||||
val values = valuesFromCollectionInfo(
|
||||
info = collectionWithColor,
|
||||
withColor = true
|
||||
).apply {
|
||||
// ACCOUNT_NAME and ACCOUNT_TYPE are required (see docs)! If it's missing, other apps will crash.
|
||||
put(Calendars.ACCOUNT_NAME, account.name)
|
||||
put(Calendars.ACCOUNT_TYPE, account.type)
|
||||
|
||||
// Email address for scheduling. Used by the calendar provider to determine whether the
|
||||
// user is ORGANIZER/ATTENDEE for a certain event.
|
||||
put(Calendars.OWNER_ACCOUNT, account.name)
|
||||
|
||||
// flag as visible & syncable at creation, might be changed by user at any time
|
||||
put(Calendars.VISIBLE, 1)
|
||||
put(Calendars.SYNC_EVENTS, 1)
|
||||
}
|
||||
|
||||
logger.log(Level.INFO, "Adding local calendar", values)
|
||||
val uri = AndroidCalendar.create(account, provider, values)
|
||||
return AndroidCalendar.findByID(account, provider, LocalCalendar.Factory, ContentUris.parseId(uri))
|
||||
}
|
||||
|
||||
|
||||
override fun getAll(account: Account, provider: ContentProviderClient) =
|
||||
AndroidCalendar.find(account, provider, LocalCalendar.Factory, "${Calendars.SYNC_EVENTS}!=0", null)
|
||||
|
||||
|
||||
override fun update(provider: ContentProviderClient, localCollection: LocalCalendar, fromCollection: Collection) {
|
||||
val accountSettings = accountSettingsFactory.create(localCollection.account)
|
||||
val values = valuesFromCollectionInfo(fromCollection, withColor = accountSettings.getManageCalendarColors())
|
||||
|
||||
logger.log(Level.FINE, "Updating local calendar ${fromCollection.url}", values)
|
||||
localCollection.update(values)
|
||||
}
|
||||
|
||||
private fun valuesFromCollectionInfo(info: Collection, withColor: Boolean): ContentValues {
|
||||
val values = ContentValues()
|
||||
values.put(Calendars.NAME, info.url.toString())
|
||||
values.put(Calendars.CALENDAR_DISPLAY_NAME,
|
||||
if (info.displayName.isNullOrBlank()) info.url.lastSegment else info.displayName)
|
||||
|
||||
if (withColor && info.color != null)
|
||||
values.put(Calendars.CALENDAR_COLOR, info.color)
|
||||
|
||||
if (info.privWriteContent && !info.forceReadOnly) {
|
||||
values.put(Calendars.CALENDAR_ACCESS_LEVEL, Calendars.CAL_ACCESS_OWNER)
|
||||
values.put(Calendars.CAN_MODIFY_TIME_ZONE, 1)
|
||||
values.put(Calendars.CAN_ORGANIZER_RESPOND, 1)
|
||||
} else
|
||||
values.put(Calendars.CALENDAR_ACCESS_LEVEL, Calendars.CAL_ACCESS_READ)
|
||||
|
||||
info.timezoneId?.let { tzId ->
|
||||
values.put(Calendars.CALENDAR_TIME_ZONE, DateUtils.findAndroidTimezoneID(tzId))
|
||||
}
|
||||
|
||||
// add base values for Calendars
|
||||
values.putAll(calendarBaseValues)
|
||||
|
||||
return values
|
||||
}
|
||||
|
||||
override fun updateAccount(oldAccount: Account, newAccount: Account) {
|
||||
val values = contentValuesOf(Calendars.ACCOUNT_NAME to newAccount.name)
|
||||
val uri = Calendars.CONTENT_URI.asSyncAdapter(oldAccount)
|
||||
context.contentResolver.acquireContentProviderClient(CalendarContract.AUTHORITY)?.use {
|
||||
it.update(uri, values, "${Calendars.ACCOUNT_NAME}=?", arrayOf(oldAccount.name))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
override fun delete(localCollection: LocalCalendar) {
|
||||
logger.log(Level.INFO, "Deleting local calendar", localCollection)
|
||||
localCollection.delete()
|
||||
}
|
||||
|
||||
}
|
||||
@@ -4,7 +4,6 @@
|
||||
|
||||
package at.bitfire.davdroid.resource
|
||||
|
||||
import android.provider.CalendarContract.Events
|
||||
import at.bitfire.davdroid.db.SyncState
|
||||
|
||||
interface LocalCollection<out T: LocalResource<*>> {
|
||||
@@ -27,13 +26,6 @@ interface LocalCollection<out T: LocalResource<*>> {
|
||||
*/
|
||||
val readOnly: Boolean
|
||||
|
||||
/**
|
||||
* Deletes the local collection.
|
||||
*
|
||||
* @return true if the collection was deleted, false otherwise
|
||||
*/
|
||||
fun deleteCollection(): Boolean
|
||||
|
||||
/**
|
||||
* Finds local resources of this collection which have been marked as *deleted* by the user
|
||||
* or an app acting on their behalf.
|
||||
|
||||
@@ -5,11 +5,11 @@
|
||||
package at.bitfire.davdroid.resource
|
||||
|
||||
import android.content.ContentValues
|
||||
import android.os.Build
|
||||
import android.os.RemoteException
|
||||
import android.provider.ContactsContract
|
||||
import android.provider.ContactsContract.CommonDataKinds.GroupMembership
|
||||
import android.provider.ContactsContract.RawContacts.Data
|
||||
import androidx.core.content.contentValuesOf
|
||||
import at.bitfire.davdroid.BuildConfig
|
||||
import at.bitfire.davdroid.resource.contactrow.CachedGroupMembershipHandler
|
||||
import at.bitfire.davdroid.resource.contactrow.GroupMembershipBuilder
|
||||
@@ -25,7 +25,7 @@ import at.bitfire.vcard4android.Contact
|
||||
import ezvcard.Ezvcard
|
||||
import java.io.FileNotFoundException
|
||||
import java.util.UUID
|
||||
import java.util.logging.Logger
|
||||
import kotlin.jvm.optionals.getOrNull
|
||||
|
||||
class LocalContact: AndroidContact, LocalAddress {
|
||||
|
||||
@@ -39,8 +39,6 @@ class LocalContact: AndroidContact, LocalAddress {
|
||||
const val COLUMN_HASHCODE = ContactsContract.RawContacts.SYNC3
|
||||
}
|
||||
|
||||
private val logger: Logger = Logger.getGlobal()
|
||||
|
||||
override val addressBook: LocalAddressBook
|
||||
get() = super.addressBook as LocalAddressBook
|
||||
|
||||
@@ -78,8 +76,7 @@ class LocalContact: AndroidContact, LocalAddress {
|
||||
val newUid = UUID.randomUUID().toString()
|
||||
|
||||
// update in contacts provider
|
||||
val values = ContentValues(1)
|
||||
values.put(COLUMN_UID, newUid)
|
||||
val values = contentValuesOf(COLUMN_UID to newUid)
|
||||
addressBook.provider!!.update(rawContactSyncURI(), values, null, null)
|
||||
|
||||
// update this event
|
||||
@@ -91,6 +88,13 @@ class LocalContact: AndroidContact, LocalAddress {
|
||||
return "$uid.vcf"
|
||||
}
|
||||
|
||||
/**
|
||||
* Clears cached [contact] so that the next read of [contact] will query the content provider again.
|
||||
*/
|
||||
fun clearCachedContact() {
|
||||
_contact = null
|
||||
}
|
||||
|
||||
override fun clearDirty(fileName: String?, eTag: String?, scheduleTag: String?) {
|
||||
if (scheduleTag != null)
|
||||
throw IllegalArgumentException("Contacts must not have a Schedule-Tag")
|
||||
@@ -101,12 +105,8 @@ class LocalContact: AndroidContact, LocalAddress {
|
||||
values.put(COLUMN_ETAG, eTag)
|
||||
values.put(ContactsContract.RawContacts.DIRTY, 0)
|
||||
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) {
|
||||
// workaround for Android 7 which sets DIRTY flag when only meta-data is changed
|
||||
val hashCode = dataHashCode()
|
||||
values.put(COLUMN_HASHCODE, hashCode)
|
||||
logger.finer("Clearing dirty flag with eTag = $eTag, contact hash = $hashCode")
|
||||
}
|
||||
// Android 7 workaround
|
||||
addressBook.dirtyVerifier.getOrNull()?.setHashCodeColumn(this, values)
|
||||
|
||||
addressBook.provider!!.update(rawContactSyncURI(), values, null, null)
|
||||
|
||||
@@ -116,74 +116,23 @@ class LocalContact: AndroidContact, LocalAddress {
|
||||
}
|
||||
|
||||
override fun resetDeleted() {
|
||||
val values = ContentValues(1)
|
||||
values.put(ContactsContract.Groups.DELETED, 0)
|
||||
val values = contentValuesOf(ContactsContract.Groups.DELETED to 0)
|
||||
addressBook.provider!!.update(rawContactSyncURI(), values, null, null)
|
||||
}
|
||||
|
||||
fun resetDirty() {
|
||||
val values = ContentValues(1)
|
||||
values.put(ContactsContract.RawContacts.DIRTY, 0)
|
||||
val values = contentValuesOf(ContactsContract.RawContacts.DIRTY to 0)
|
||||
addressBook.provider!!.update(rawContactSyncURI(), values, null, null)
|
||||
}
|
||||
|
||||
override fun updateFlags(flags: Int) {
|
||||
val values = ContentValues(1)
|
||||
values.put(COLUMN_FLAGS, flags)
|
||||
val values = contentValuesOf(COLUMN_FLAGS to flags)
|
||||
addressBook.provider!!.update(rawContactSyncURI(), values, null, null)
|
||||
|
||||
this.flags = flags
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Calculates a hash code from the contact's data (VCard) and group memberships.
|
||||
* Attention: re-reads {@link #contact} from the database, discarding all changes in memory
|
||||
* @return hash code of contact data (including group memberships)
|
||||
*/
|
||||
internal fun dataHashCode(): Int {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O)
|
||||
throw IllegalStateException("dataHashCode() should not be called on Android != 7")
|
||||
|
||||
// reset contact so that getContact() reads from database
|
||||
_contact = null
|
||||
|
||||
// groupMemberships is filled by getContact()
|
||||
val dataHash = getContact().hashCode()
|
||||
val groupHash = groupMemberships.hashCode()
|
||||
logger.finest("Calculated data hash = $dataHash, group memberships hash = $groupHash")
|
||||
return dataHash xor groupHash
|
||||
}
|
||||
|
||||
fun updateHashCode(batch: BatchOperation?) {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O)
|
||||
throw IllegalStateException("updateHashCode() should not be called on Android != 7")
|
||||
|
||||
val hashCode = dataHashCode()
|
||||
logger.fine("Storing contact hash = $hashCode")
|
||||
|
||||
if (batch == null) {
|
||||
val values = ContentValues(1)
|
||||
values.put(COLUMN_HASHCODE, hashCode)
|
||||
addressBook.provider!!.update(rawContactSyncURI(), values, null, null)
|
||||
} else
|
||||
batch.enqueue(BatchOperation.CpoBuilder
|
||||
.newUpdate(rawContactSyncURI())
|
||||
.withValue(COLUMN_HASHCODE, hashCode))
|
||||
}
|
||||
|
||||
fun getLastHashCode(): Int {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O)
|
||||
throw IllegalStateException("getLastHashCode() should not be called on Android != 7")
|
||||
|
||||
addressBook.provider!!.query(rawContactSyncURI(), arrayOf(COLUMN_HASHCODE), null, null, null)?.use { c ->
|
||||
if (c.moveToNext() && !c.isNull(0))
|
||||
return c.getInt(0)
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
|
||||
fun addToGroup(batch: BatchOperation, groupID: Long) {
|
||||
batch.enqueue(BatchOperation.CpoBuilder
|
||||
.newInsert(dataSyncURI())
|
||||
@@ -238,6 +187,7 @@ class LocalContact: AndroidContact, LocalAddress {
|
||||
|
||||
|
||||
// data rows
|
||||
|
||||
override fun buildContact(builder: BatchOperation.CpoBuilder, update: Boolean) {
|
||||
builder.withValue(COLUMN_FLAGS, flags)
|
||||
super.buildContact(builder, update)
|
||||
@@ -250,4 +200,4 @@ class LocalContact: AndroidContact, LocalAddress {
|
||||
LocalContact(addressBook as LocalAddressBook, values)
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
/*
|
||||
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
|
||||
*/
|
||||
|
||||
package at.bitfire.davdroid.resource
|
||||
|
||||
import android.accounts.Account
|
||||
import android.content.ContentProviderClient
|
||||
import at.bitfire.davdroid.db.Collection
|
||||
|
||||
/**
|
||||
* Represents a local data store for a specific collection type.
|
||||
* Manages creation, update, and deletion of collections of the given type.
|
||||
*/
|
||||
interface LocalDataStore<T: LocalCollection<*>> {
|
||||
|
||||
/**
|
||||
* Creates a new local collection from the given (remote) collection info.
|
||||
*
|
||||
* @param provider the content provider client
|
||||
* @param fromCollection collection info
|
||||
*
|
||||
* @return the new local collection, or `null` if creation failed
|
||||
*/
|
||||
fun create(provider: ContentProviderClient, fromCollection: Collection): T?
|
||||
|
||||
/**
|
||||
* Returns all local collections of the data store, including those which don't have a corresponding remote
|
||||
* [Collection] entry.
|
||||
*
|
||||
* @param account the account that the data store is associated with
|
||||
* @param provider the content provider client
|
||||
*
|
||||
* @return a list of all local collections
|
||||
*/
|
||||
fun getAll(account: Account, provider: ContentProviderClient): List<T>
|
||||
|
||||
/**
|
||||
* Updates the local collection with the data from the given (remote) collection info.
|
||||
*
|
||||
* @param provider the content provider client
|
||||
* @param localCollection the local collection to update
|
||||
* @param fromCollection collection info
|
||||
*/
|
||||
fun update(provider: ContentProviderClient, localCollection: T, fromCollection: Collection)
|
||||
|
||||
/**
|
||||
* Deletes the local collection.
|
||||
*
|
||||
* @param localCollection the local collection to delete
|
||||
*/
|
||||
fun delete(localCollection: T)
|
||||
|
||||
/**
|
||||
* Changes the account assigned to the containing data to another one.
|
||||
*
|
||||
* @param oldAccount The old account.
|
||||
* @param newAccount The new account.
|
||||
*/
|
||||
fun updateAccount(oldAccount: Account, newAccount: Account)
|
||||
|
||||
}
|
||||
@@ -10,6 +10,7 @@ import android.content.ContentUris
|
||||
import android.content.ContentValues
|
||||
import android.provider.CalendarContract
|
||||
import android.provider.CalendarContract.Events
|
||||
import androidx.core.content.contentValuesOf
|
||||
import at.bitfire.davdroid.BuildConfig
|
||||
import at.bitfire.ical4android.AndroidCalendar
|
||||
import at.bitfire.ical4android.AndroidEvent
|
||||
@@ -43,10 +44,8 @@ class LocalEvent: AndroidEvent, LocalResource<Event> {
|
||||
Events.CONTENT_URI,
|
||||
eventID
|
||||
).asSyncAdapter(account),
|
||||
ContentValues(1).apply {
|
||||
put(Events.DELETED, 1)
|
||||
},
|
||||
null,null
|
||||
contentValuesOf(Events.DELETED to 1),
|
||||
null, null
|
||||
)
|
||||
}
|
||||
|
||||
@@ -208,8 +207,7 @@ class LocalEvent: AndroidEvent, LocalResource<Event> {
|
||||
val newUid = UUID.randomUUID().toString()
|
||||
|
||||
// update in calendar provider
|
||||
val values = ContentValues(1)
|
||||
values.put(Events.UID_2445, newUid)
|
||||
val values = contentValuesOf(Events.UID_2445 to newUid)
|
||||
calendar.provider.update(eventSyncURI(), values, null, null)
|
||||
|
||||
// update this event
|
||||
@@ -249,15 +247,14 @@ class LocalEvent: AndroidEvent, LocalResource<Event> {
|
||||
}
|
||||
|
||||
override fun updateFlags(flags: Int) {
|
||||
val values = ContentValues(1)
|
||||
values.put(COLUMN_FLAGS, flags)
|
||||
val values = contentValuesOf(COLUMN_FLAGS to flags)
|
||||
calendar.provider.update(eventSyncURI(), values, null, null)
|
||||
|
||||
this.flags = flags
|
||||
}
|
||||
|
||||
override fun resetDeleted() {
|
||||
val values = ContentValues(1).apply { put(Events.DELETED, 0) }
|
||||
val values = contentValuesOf(Events.DELETED to 0)
|
||||
calendar.provider.update(eventSyncURI(), values, null, null)
|
||||
}
|
||||
|
||||
|
||||
@@ -7,13 +7,13 @@ package at.bitfire.davdroid.resource
|
||||
import android.content.ContentUris
|
||||
import android.content.ContentValues
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import android.os.RemoteException
|
||||
import android.provider.ContactsContract
|
||||
import android.provider.ContactsContract.CommonDataKinds.GroupMembership
|
||||
import android.provider.ContactsContract.Groups
|
||||
import android.provider.ContactsContract.RawContacts
|
||||
import android.provider.ContactsContract.RawContacts.Data
|
||||
import androidx.core.content.contentValuesOf
|
||||
import at.bitfire.davdroid.util.trimToNull
|
||||
import at.bitfire.vcard4android.AndroidAddressBook
|
||||
import at.bitfire.vcard4android.AndroidContact
|
||||
@@ -25,6 +25,7 @@ import at.bitfire.vcard4android.Contact
|
||||
import java.util.LinkedList
|
||||
import java.util.UUID
|
||||
import java.util.logging.Logger
|
||||
import kotlin.jvm.optionals.getOrNull
|
||||
|
||||
class LocalGroup: AndroidGroup, LocalAddress {
|
||||
|
||||
@@ -90,11 +91,14 @@ class LocalGroup: AndroidGroup, LocalAddress {
|
||||
changeContactIDs += missingMember.id!!
|
||||
}
|
||||
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O)
|
||||
addressBook.dirtyVerifier.getOrNull()?.let { verifier ->
|
||||
// workaround for Android 7 which sets DIRTY flag when only meta-data is changed
|
||||
changeContactIDs
|
||||
.map { addressBook.findContactById(it) }
|
||||
.forEach { it.updateHashCode(batch) }
|
||||
.map { id -> addressBook.findContactById(id) }
|
||||
.forEach { contact ->
|
||||
verifier.updateHashCode(contact, batch)
|
||||
}
|
||||
}
|
||||
|
||||
batch.commit()
|
||||
}
|
||||
@@ -144,8 +148,7 @@ class LocalGroup: AndroidGroup, LocalAddress {
|
||||
// generate new UID
|
||||
uid = UUID.randomUUID().toString()
|
||||
|
||||
val values = ContentValues(1)
|
||||
values.put(AndroidContact.COLUMN_UID, uid)
|
||||
val values = contentValuesOf(AndroidContact.COLUMN_UID to uid)
|
||||
addressBook.provider!!.update(groupSyncUri(), values, null, null)
|
||||
|
||||
_contact?.uid = uid
|
||||
@@ -207,14 +210,12 @@ class LocalGroup: AndroidGroup, LocalAddress {
|
||||
}
|
||||
|
||||
override fun resetDeleted() {
|
||||
val values = ContentValues(1)
|
||||
values.put(Groups.DELETED, 0)
|
||||
val values = contentValuesOf(Groups.DELETED to 0)
|
||||
addressBook.provider!!.update(groupSyncUri(), values, null, null)
|
||||
}
|
||||
|
||||
override fun updateFlags(flags: Int) {
|
||||
val values = ContentValues(1)
|
||||
values.put(COLUMN_FLAGS, flags)
|
||||
val values = contentValuesOf(COLUMN_FLAGS to flags)
|
||||
addressBook.provider!!.update(groupSyncUri(), values, null, null)
|
||||
|
||||
this.flags = flags
|
||||
|
||||
@@ -6,64 +6,18 @@ package at.bitfire.davdroid.resource
|
||||
|
||||
import android.accounts.Account
|
||||
import android.content.ContentProviderClient
|
||||
import android.content.ContentValues
|
||||
import android.net.Uri
|
||||
import at.bitfire.davdroid.Constants
|
||||
import at.bitfire.davdroid.db.Collection
|
||||
import at.bitfire.davdroid.db.Principal
|
||||
import at.bitfire.davdroid.db.SyncState
|
||||
import at.bitfire.davdroid.util.DavUtils.lastSegment
|
||||
import at.bitfire.ical4android.JtxCollection
|
||||
import at.bitfire.ical4android.JtxCollectionFactory
|
||||
import at.bitfire.ical4android.JtxICalObject
|
||||
import at.techbee.jtx.JtxContract
|
||||
import java.util.logging.Logger
|
||||
|
||||
class LocalJtxCollection(account: Account, client: ContentProviderClient, id: Long):
|
||||
JtxCollection<JtxICalObject>(account, client, LocalJtxICalObject.Factory, id),
|
||||
LocalCollection<LocalJtxICalObject>{
|
||||
|
||||
companion object {
|
||||
|
||||
fun create(account: Account, client: ContentProviderClient, info: Collection, owner: Principal?): Uri {
|
||||
// If the collection doesn't have a color, use a default color.
|
||||
if (info.color != null)
|
||||
info.color = Constants.DAVDROID_GREEN_RGBA
|
||||
|
||||
val values = valuesFromCollection(info, account, owner, withColor = true)
|
||||
|
||||
return create(account, client, values)
|
||||
}
|
||||
|
||||
fun valuesFromCollection(info: Collection, account: Account, owner: Principal?, withColor: Boolean) =
|
||||
ContentValues().apply {
|
||||
put(JtxContract.JtxCollection.URL, info.url.toString())
|
||||
put(
|
||||
JtxContract.JtxCollection.DISPLAYNAME,
|
||||
info.displayName ?: info.url.lastSegment
|
||||
)
|
||||
put(JtxContract.JtxCollection.DESCRIPTION, info.description)
|
||||
if (owner != null)
|
||||
put(JtxContract.JtxCollection.OWNER, owner.url.toString())
|
||||
else
|
||||
Logger.getGlobal().warning("No collection owner given. Will create jtx collection without owner")
|
||||
put(JtxContract.JtxCollection.OWNER_DISPLAYNAME, owner?.displayName)
|
||||
if (withColor && info.color != null)
|
||||
put(JtxContract.JtxCollection.COLOR, info.color)
|
||||
put(JtxContract.JtxCollection.SUPPORTSVEVENT, info.supportsVEVENT)
|
||||
put(JtxContract.JtxCollection.SUPPORTSVJOURNAL, info.supportsVJOURNAL)
|
||||
put(JtxContract.JtxCollection.SUPPORTSVTODO, info.supportsVTODO)
|
||||
put(JtxContract.JtxCollection.ACCOUNT_NAME, account.name)
|
||||
put(JtxContract.JtxCollection.ACCOUNT_TYPE, account.type)
|
||||
put(JtxContract.JtxCollection.READONLY, info.forceReadOnly || !info.privWriteContent)
|
||||
}
|
||||
}
|
||||
|
||||
override val readOnly: Boolean
|
||||
get() = throw NotImplementedError()
|
||||
|
||||
override fun deleteCollection(): Boolean = delete()
|
||||
|
||||
override val tag: String
|
||||
get() = "jtx-${account.name}-$id"
|
||||
override val collectionUrl: String?
|
||||
@@ -74,10 +28,6 @@ class LocalJtxCollection(account: Account, client: ContentProviderClient, id: Lo
|
||||
get() = SyncState.fromString(syncstate)
|
||||
set(value) { syncstate = value.toString() }
|
||||
|
||||
fun updateCollection(info: Collection, owner: Principal?, updateColor: Boolean) {
|
||||
val values = valuesFromCollection(info, account, owner, updateColor)
|
||||
update(values)
|
||||
}
|
||||
|
||||
override fun findDeleted(): List<LocalJtxICalObject> {
|
||||
val values = queryDeletedICalObjects()
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
/*
|
||||
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
|
||||
*/
|
||||
|
||||
package at.bitfire.davdroid.resource
|
||||
|
||||
import android.accounts.Account
|
||||
import android.content.ContentProviderClient
|
||||
import android.content.ContentUris
|
||||
import android.content.ContentValues
|
||||
import android.content.Context
|
||||
import androidx.core.content.contentValuesOf
|
||||
import at.bitfire.davdroid.Constants
|
||||
import at.bitfire.davdroid.R
|
||||
import at.bitfire.davdroid.db.AppDatabase
|
||||
import at.bitfire.davdroid.db.Collection
|
||||
import at.bitfire.davdroid.repository.PrincipalRepository
|
||||
import at.bitfire.davdroid.settings.AccountSettings
|
||||
import at.bitfire.davdroid.util.DavUtils.lastSegment
|
||||
import at.bitfire.ical4android.JtxCollection
|
||||
import at.bitfire.ical4android.TaskProvider
|
||||
import at.techbee.jtx.JtxContract
|
||||
import at.techbee.jtx.JtxContract.asSyncAdapter
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import java.util.logging.Logger
|
||||
import javax.inject.Inject
|
||||
|
||||
class LocalJtxCollectionStore @Inject constructor(
|
||||
@ApplicationContext val context: Context,
|
||||
val accountSettingsFactory: AccountSettings.Factory,
|
||||
db: AppDatabase,
|
||||
val principalRepository: PrincipalRepository
|
||||
): LocalDataStore<LocalJtxCollection> {
|
||||
|
||||
private val serviceDao = db.serviceDao()
|
||||
|
||||
override fun create(provider: ContentProviderClient, fromCollection: Collection): LocalJtxCollection? {
|
||||
val service = serviceDao.get(fromCollection.serviceId) ?: throw IllegalArgumentException("Couldn't fetch DB service from collection")
|
||||
val account = Account(service.accountName, context.getString(R.string.account_type))
|
||||
|
||||
// If the collection doesn't have a color, use a default color.
|
||||
val collectionWithColor =
|
||||
if (fromCollection.color != null)
|
||||
fromCollection
|
||||
else
|
||||
fromCollection.copy(color = Constants.DAVDROID_GREEN_RGBA)
|
||||
|
||||
val values = valuesFromCollection(
|
||||
info = collectionWithColor,
|
||||
account = account,
|
||||
withColor = true
|
||||
)
|
||||
|
||||
val uri = JtxCollection.create(account, provider, values)
|
||||
return LocalJtxCollection(account, provider, ContentUris.parseId(uri))
|
||||
}
|
||||
|
||||
private fun valuesFromCollection(info: Collection, account: Account, withColor: Boolean): ContentValues {
|
||||
val owner = info.ownerId?.let { principalRepository.get(it) }
|
||||
|
||||
return ContentValues().apply {
|
||||
put(JtxContract.JtxCollection.URL, info.url.toString())
|
||||
put(
|
||||
JtxContract.JtxCollection.DISPLAYNAME,
|
||||
info.displayName ?: info.url.lastSegment
|
||||
)
|
||||
put(JtxContract.JtxCollection.DESCRIPTION, info.description)
|
||||
if (owner != null)
|
||||
put(JtxContract.JtxCollection.OWNER, owner.url.toString())
|
||||
else
|
||||
Logger.getGlobal().warning("No collection owner given. Will create jtx collection without owner")
|
||||
put(JtxContract.JtxCollection.OWNER_DISPLAYNAME, owner?.displayName)
|
||||
if (withColor && info.color != null)
|
||||
put(JtxContract.JtxCollection.COLOR, info.color)
|
||||
put(JtxContract.JtxCollection.SUPPORTSVEVENT, info.supportsVEVENT)
|
||||
put(JtxContract.JtxCollection.SUPPORTSVJOURNAL, info.supportsVJOURNAL)
|
||||
put(JtxContract.JtxCollection.SUPPORTSVTODO, info.supportsVTODO)
|
||||
put(JtxContract.JtxCollection.ACCOUNT_NAME, account.name)
|
||||
put(JtxContract.JtxCollection.ACCOUNT_TYPE, account.type)
|
||||
put(JtxContract.JtxCollection.READONLY, info.forceReadOnly || !info.privWriteContent)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
override fun getAll(account: Account, provider: ContentProviderClient): List<LocalJtxCollection> =
|
||||
JtxCollection.find(account, provider, context, LocalJtxCollection.Factory, null, null)
|
||||
|
||||
|
||||
override fun update(provider: ContentProviderClient, localCollection: LocalJtxCollection, fromCollection: Collection) {
|
||||
val accountSettings = accountSettingsFactory.create(localCollection.account)
|
||||
val values = valuesFromCollection(fromCollection, account = localCollection.account, withColor = accountSettings.getManageCalendarColors())
|
||||
localCollection.update(values)
|
||||
}
|
||||
|
||||
|
||||
override fun delete(localCollection: LocalJtxCollection) {
|
||||
localCollection.delete()
|
||||
}
|
||||
|
||||
override fun updateAccount(oldAccount: Account, newAccount: Account) {
|
||||
TaskProvider.acquire(context, TaskProvider.ProviderName.JtxBoard)?.use { provider ->
|
||||
val values = contentValuesOf(JtxContract.JtxCollection.ACCOUNT_NAME to newAccount.name)
|
||||
val uri = JtxContract.JtxCollection.CONTENT_URI.asSyncAdapter(oldAccount)
|
||||
provider.client.update(uri, values, "${JtxContract.JtxCollection.ACCOUNT_NAME}=?", arrayOf(oldAccount.name))
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -5,6 +5,7 @@
|
||||
package at.bitfire.davdroid.resource
|
||||
|
||||
import android.content.ContentValues
|
||||
import androidx.core.content.contentValuesOf
|
||||
import at.bitfire.ical4android.BatchOperation
|
||||
import at.bitfire.ical4android.DmfsTask
|
||||
import at.bitfire.ical4android.DmfsTaskFactory
|
||||
@@ -63,8 +64,7 @@ class LocalTask: DmfsTask, LocalResource<Task> {
|
||||
val newUid = UUID.randomUUID().toString()
|
||||
|
||||
// update in tasks provider
|
||||
val values = ContentValues(1)
|
||||
values.put(Tasks._UID, newUid)
|
||||
val values = contentValuesOf(Tasks._UID to newUid)
|
||||
taskList.provider.update(taskSyncURI(), values, null, null)
|
||||
|
||||
// update this task
|
||||
@@ -95,8 +95,7 @@ class LocalTask: DmfsTask, LocalResource<Task> {
|
||||
|
||||
override fun updateFlags(flags: Int) {
|
||||
if (id != null) {
|
||||
val values = ContentValues(1)
|
||||
values.put(COLUMN_FLAGS, flags)
|
||||
val values = contentValuesOf(COLUMN_FLAGS to flags)
|
||||
taskList.provider.update(taskSyncURI(), values, null, null)
|
||||
}
|
||||
|
||||
|
||||
@@ -5,19 +5,16 @@
|
||||
package at.bitfire.davdroid.resource
|
||||
|
||||
import android.accounts.Account
|
||||
import android.annotation.SuppressLint
|
||||
import android.content.ContentProviderClient
|
||||
import android.content.ContentValues
|
||||
import android.content.Context
|
||||
import android.net.Uri
|
||||
import at.bitfire.davdroid.Constants
|
||||
import at.bitfire.davdroid.db.Collection
|
||||
import androidx.core.content.contentValuesOf
|
||||
import at.bitfire.davdroid.db.SyncState
|
||||
import at.bitfire.davdroid.util.DavUtils.lastSegment
|
||||
import at.bitfire.ical4android.DmfsTaskList
|
||||
import at.bitfire.ical4android.DmfsTaskListFactory
|
||||
import at.bitfire.ical4android.TaskProvider
|
||||
import org.dmfs.tasks.contract.TaskContract.*
|
||||
import org.dmfs.tasks.contract.TaskContract.TaskListColumns
|
||||
import org.dmfs.tasks.contract.TaskContract.TaskLists
|
||||
import org.dmfs.tasks.contract.TaskContract.Tasks
|
||||
import java.util.logging.Level
|
||||
import java.util.logging.Logger
|
||||
|
||||
@@ -33,53 +30,6 @@ class LocalTaskList private constructor(
|
||||
id: Long
|
||||
): DmfsTaskList<LocalTask>(account, provider, providerName, LocalTask.Factory, id), LocalCollection<LocalTask> {
|
||||
|
||||
companion object {
|
||||
|
||||
fun create(account: Account, provider: ContentProviderClient, providerName: TaskProvider.ProviderName, info: Collection): Uri {
|
||||
// If the collection doesn't have a color, use a default color.
|
||||
if (info.color != null)
|
||||
info.color = Constants.DAVDROID_GREEN_RGBA
|
||||
|
||||
val values = valuesFromCollectionInfo(info, withColor = true)
|
||||
values.put(TaskLists.OWNER, account.name)
|
||||
values.put(TaskLists.SYNC_ENABLED, 1)
|
||||
values.put(TaskLists.VISIBLE, 1)
|
||||
return create(account, provider, providerName, values)
|
||||
}
|
||||
|
||||
@SuppressLint("Recycle")
|
||||
@Throws(Exception::class)
|
||||
fun onRenameAccount(context: Context, oldName: String, newName: String) {
|
||||
TaskProvider.acquire(context)?.use { provider ->
|
||||
val values = ContentValues(1)
|
||||
values.put(Tasks.ACCOUNT_NAME, newName)
|
||||
provider.client.update(
|
||||
Tasks.getContentUri(provider.name.authority),
|
||||
values,
|
||||
"${Tasks.ACCOUNT_NAME}=?", arrayOf(oldName)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun valuesFromCollectionInfo(info: Collection, withColor: Boolean): ContentValues {
|
||||
val values = ContentValues(3)
|
||||
values.put(TaskLists._SYNC_ID, info.url.toString())
|
||||
values.put(TaskLists.LIST_NAME,
|
||||
if (info.displayName.isNullOrBlank()) info.url.lastSegment else info.displayName)
|
||||
|
||||
if (withColor && info.color != null)
|
||||
values.put(TaskLists.LIST_COLOR, info.color)
|
||||
|
||||
if (info.privWriteContent && !info.forceReadOnly)
|
||||
values.put(TaskListColumns.ACCESS_LEVEL, TaskListColumns.ACCESS_LEVEL_OWNER)
|
||||
else
|
||||
values.put(TaskListColumns.ACCESS_LEVEL, TaskListColumns.ACCESS_LEVEL_READ)
|
||||
|
||||
return values
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
private val logger = Logger.getGlobal()
|
||||
|
||||
private var accessLevel: Int = TaskListColumns.ACCESS_LEVEL_UNDEFINED
|
||||
@@ -88,8 +38,6 @@ class LocalTaskList private constructor(
|
||||
accessLevel != TaskListColumns.ACCESS_LEVEL_UNDEFINED &&
|
||||
accessLevel <= TaskListColumns.ACCESS_LEVEL_READ
|
||||
|
||||
override fun deleteCollection(): Boolean = delete()
|
||||
|
||||
override val collectionUrl: String?
|
||||
get() = syncId
|
||||
|
||||
@@ -115,8 +63,7 @@ class LocalTaskList private constructor(
|
||||
return null
|
||||
}
|
||||
set(state) {
|
||||
val values = ContentValues(1)
|
||||
values.put(TaskLists.SYNC_VERSION, state?.toString())
|
||||
val values = contentValuesOf(TaskLists.SYNC_VERSION to state?.toString())
|
||||
provider.update(taskListSyncUri(), values, null, null)
|
||||
}
|
||||
|
||||
@@ -126,9 +73,6 @@ class LocalTaskList private constructor(
|
||||
accessLevel = values.getAsInteger(TaskListColumns.ACCESS_LEVEL)
|
||||
}
|
||||
|
||||
fun update(info: Collection, updateColor: Boolean) =
|
||||
update(valuesFromCollectionInfo(info, updateColor))
|
||||
|
||||
|
||||
override fun findDeleted() = queryTasks(Tasks._DELETED, null)
|
||||
|
||||
@@ -154,8 +98,7 @@ class LocalTaskList private constructor(
|
||||
|
||||
|
||||
override fun markNotDirty(flags: Int): Int {
|
||||
val values = ContentValues(1)
|
||||
values.put(LocalTask.COLUMN_FLAGS, flags)
|
||||
val values = contentValuesOf(LocalTask.COLUMN_FLAGS to flags)
|
||||
return provider.update(tasksSyncUri(), values,
|
||||
"${Tasks.LIST_ID}=? AND ${Tasks._DIRTY}=0",
|
||||
arrayOf(id.toString()))
|
||||
@@ -167,8 +110,7 @@ class LocalTaskList private constructor(
|
||||
arrayOf(id.toString(), flags.toString()))
|
||||
|
||||
override fun forgetETags() {
|
||||
val values = ContentValues(1)
|
||||
values.putNull(LocalEvent.COLUMN_ETAG)
|
||||
val values = contentValuesOf(LocalEvent.COLUMN_ETAG to null)
|
||||
provider.update(tasksSyncUri(), values, "${Tasks.LIST_ID}=?",
|
||||
arrayOf(id.toString()))
|
||||
}
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
/*
|
||||
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
|
||||
*/
|
||||
|
||||
package at.bitfire.davdroid.resource
|
||||
|
||||
import android.accounts.Account
|
||||
import android.content.ContentProviderClient
|
||||
import android.content.ContentUris
|
||||
import android.content.ContentValues
|
||||
import android.content.Context
|
||||
import android.net.Uri
|
||||
import androidx.core.content.contentValuesOf
|
||||
import at.bitfire.davdroid.Constants
|
||||
import at.bitfire.davdroid.R
|
||||
import at.bitfire.davdroid.db.AppDatabase
|
||||
import at.bitfire.davdroid.db.Collection
|
||||
import at.bitfire.davdroid.settings.AccountSettings
|
||||
import at.bitfire.davdroid.util.DavUtils.lastSegment
|
||||
import at.bitfire.ical4android.DmfsTaskList
|
||||
import at.bitfire.ical4android.TaskProvider
|
||||
import dagger.assisted.Assisted
|
||||
import dagger.assisted.AssistedFactory
|
||||
import dagger.assisted.AssistedInject
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import org.dmfs.tasks.contract.TaskContract.TaskListColumns
|
||||
import org.dmfs.tasks.contract.TaskContract.TaskLists
|
||||
import org.dmfs.tasks.contract.TaskContract.Tasks
|
||||
import java.util.logging.Level
|
||||
import java.util.logging.Logger
|
||||
|
||||
class LocalTaskListStore @AssistedInject constructor(
|
||||
@Assisted private val providerName: TaskProvider.ProviderName,
|
||||
val accountSettingsFactory: AccountSettings.Factory,
|
||||
@ApplicationContext val context: Context,
|
||||
val db: AppDatabase,
|
||||
val logger: Logger
|
||||
): LocalDataStore<LocalTaskList> {
|
||||
|
||||
@AssistedFactory
|
||||
interface Factory {
|
||||
fun create(providerName: TaskProvider.ProviderName): LocalTaskListStore
|
||||
}
|
||||
|
||||
private val serviceDao = db.serviceDao()
|
||||
|
||||
|
||||
override fun create(provider: ContentProviderClient, fromCollection: Collection): LocalTaskList? {
|
||||
val service = serviceDao.get(fromCollection.serviceId) ?: throw IllegalArgumentException("Couldn't fetch DB service from collection")
|
||||
val account = Account(service.accountName, context.getString(R.string.account_type))
|
||||
|
||||
logger.log(Level.INFO, "Adding local task list", fromCollection)
|
||||
val uri = create(account, provider, providerName, fromCollection)
|
||||
return DmfsTaskList.findByID(account, provider, providerName, LocalTaskList.Factory, ContentUris.parseId(uri))
|
||||
}
|
||||
|
||||
private fun create(account: Account, provider: ContentProviderClient, providerName: TaskProvider.ProviderName, fromCollection: Collection): Uri {
|
||||
// If the collection doesn't have a color, use a default color.
|
||||
val collectionWithColor = if (fromCollection.color != null)
|
||||
fromCollection
|
||||
else
|
||||
fromCollection.copy(color = Constants.DAVDROID_GREEN_RGBA)
|
||||
|
||||
val values = valuesFromCollectionInfo(
|
||||
info = collectionWithColor,
|
||||
withColor = true
|
||||
).apply {
|
||||
put(TaskLists.OWNER, account.name)
|
||||
put(TaskLists.SYNC_ENABLED, 1)
|
||||
put(TaskLists.VISIBLE, 1)
|
||||
}
|
||||
return DmfsTaskList.Companion.create(account, provider, providerName, values)
|
||||
}
|
||||
|
||||
private fun valuesFromCollectionInfo(info: Collection, withColor: Boolean): ContentValues {
|
||||
val values = ContentValues(3)
|
||||
values.put(TaskLists._SYNC_ID, info.url.toString())
|
||||
values.put(TaskLists.LIST_NAME,
|
||||
if (info.displayName.isNullOrBlank()) info.url.lastSegment else info.displayName)
|
||||
|
||||
if (withColor && info.color != null)
|
||||
values.put(TaskLists.LIST_COLOR, info.color)
|
||||
|
||||
if (info.privWriteContent && !info.forceReadOnly)
|
||||
values.put(TaskListColumns.ACCESS_LEVEL, TaskListColumns.ACCESS_LEVEL_OWNER)
|
||||
else
|
||||
values.put(TaskListColumns.ACCESS_LEVEL, TaskListColumns.ACCESS_LEVEL_READ)
|
||||
|
||||
return values
|
||||
}
|
||||
|
||||
|
||||
override fun getAll(account: Account, provider: ContentProviderClient) =
|
||||
DmfsTaskList.find(account, LocalTaskList.Factory, provider, providerName, null, null)
|
||||
|
||||
|
||||
override fun update(provider: ContentProviderClient, localCollection: LocalTaskList, fromCollection: Collection) {
|
||||
logger.log(Level.FINE, "Updating local task list ${fromCollection.url}", fromCollection)
|
||||
val accountSettings = accountSettingsFactory.create(localCollection.account)
|
||||
localCollection.update(valuesFromCollectionInfo(fromCollection, withColor = accountSettings.getManageCalendarColors()))
|
||||
}
|
||||
|
||||
|
||||
override fun delete(localCollection: LocalTaskList) {
|
||||
localCollection.delete()
|
||||
}
|
||||
|
||||
override fun updateAccount(oldAccount: Account, newAccount: Account) {
|
||||
TaskProvider.acquire(context, providerName)?.use { provider ->
|
||||
val values = contentValuesOf(Tasks.ACCOUNT_NAME to newAccount.name)
|
||||
val uri = Tasks.getContentUri(providerName.authority)
|
||||
provider.client.update(uri, values, "${Tasks.ACCOUNT_NAME}=?", arrayOf(oldAccount.name))
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -12,7 +12,6 @@ import at.bitfire.vcard4android.Contact
|
||||
import at.bitfire.vcard4android.GroupMethod
|
||||
import at.bitfire.vcard4android.contactrow.DataRowHandler
|
||||
import java.io.FileNotFoundException
|
||||
import java.util.logging.Logger
|
||||
|
||||
class GroupMembershipHandler(val localContact: LocalContact): DataRowHandler() {
|
||||
|
||||
|
||||
@@ -0,0 +1,160 @@
|
||||
/*
|
||||
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
|
||||
*/
|
||||
|
||||
package at.bitfire.davdroid.resource.workaround
|
||||
|
||||
import android.content.ContentValues
|
||||
import android.os.Build
|
||||
import at.bitfire.davdroid.resource.LocalAddressBook
|
||||
import at.bitfire.davdroid.resource.LocalContact
|
||||
import at.bitfire.davdroid.resource.LocalContact.Companion.COLUMN_HASHCODE
|
||||
import at.bitfire.vcard4android.BatchOperation
|
||||
import dagger.Module
|
||||
import dagger.Provides
|
||||
import dagger.hilt.InstallIn
|
||||
import dagger.hilt.components.SingletonComponent
|
||||
import java.util.Optional
|
||||
import java.util.logging.Level
|
||||
import java.util.logging.Logger
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Provider
|
||||
|
||||
/**
|
||||
* Android 7.x introduced a new behavior in the Contacts provider: when metadata of a contact (like the "last contacted" time)
|
||||
* changes, the contact is marked as "dirty" (i.e. the [android.provider.ContactsContract.RawContacts.DIRTY] flag is set).
|
||||
* So, under Android 7.x, every time a user calls a contact or writes an SMS to a contact, the contact is marked as dirty.
|
||||
*
|
||||
* **This behavior is not present in Android ≤ 6.x nor in ≥ Android 8.x, where a contact is only marked as dirty
|
||||
* when its data actually change.**
|
||||
*
|
||||
* So, as a dirty workaround for Android 7.x, we need to calculate a hash code from the contact data and group memberships every
|
||||
* time we change the contact. When then a contact is marked as dirty, we compare the hash code of the current contact data with
|
||||
* the previous hash code. If the hash code has changed, the contact is "really dirty" and we need to upload it. Otherwise,
|
||||
* we reset the dirty flag to ignore the meta-data change.
|
||||
*
|
||||
* @constructor May only be called on Android 7.x, otherwise an [IllegalStateException] is thrown.
|
||||
*/
|
||||
class Android7DirtyVerifier @Inject constructor(
|
||||
val logger: Logger
|
||||
): ContactDirtyVerifier {
|
||||
|
||||
init {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O)
|
||||
throw IllegalStateException("Android7DirtyVerifier must not be used on Android != 7.x")
|
||||
}
|
||||
|
||||
|
||||
// address-book level functions
|
||||
|
||||
override fun prepareAddressBook(addressBook: LocalAddressBook, isUpload: Boolean): Boolean {
|
||||
val reallyDirty = verifyDirtyContacts(addressBook)
|
||||
|
||||
val deleted = addressBook.findDeleted().size
|
||||
if (isUpload && reallyDirty == 0 && deleted == 0) {
|
||||
logger.info("This sync was called to up-sync dirty/deleted contacts, but no contacts have been changed")
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* Queries all contacts with the [android.provider.ContactsContract.RawContacts.DIRTY] flag and checks whether their data
|
||||
* checksum has changed, i.e. if they're "really dirty" (= data has changed, not only metadata, which is not hashed).
|
||||
*
|
||||
* The dirty flag is removed from contacts which are not "really dirty", i.e. from contacts whose contact data
|
||||
* checksum has not changed.
|
||||
*
|
||||
* @return number of "really dirty" contacts
|
||||
*/
|
||||
private fun verifyDirtyContacts(addressBook: LocalAddressBook): Int {
|
||||
var reallyDirty = 0
|
||||
for (contact in addressBook.findDirtyContacts()) {
|
||||
val lastHash = getLastHashCode(addressBook, contact)
|
||||
val currentHash = contactDataHashCode(contact)
|
||||
if (lastHash == currentHash) {
|
||||
// hash is code still the same, contact is not "really dirty" (only metadata been have changed)
|
||||
logger.log(Level.FINE, "Contact data hash has not changed, resetting dirty flag", contact)
|
||||
contact.resetDirty()
|
||||
} else {
|
||||
logger.log(Level.FINE, "Contact data has changed from hash $lastHash to $currentHash", contact)
|
||||
reallyDirty++
|
||||
}
|
||||
}
|
||||
|
||||
if (addressBook.includeGroups)
|
||||
reallyDirty += addressBook.findDirtyGroups().size
|
||||
|
||||
return reallyDirty
|
||||
}
|
||||
|
||||
private fun getLastHashCode(addressBook: LocalAddressBook, contact: LocalContact): Int {
|
||||
addressBook.provider!!.query(contact.rawContactSyncURI(), arrayOf(COLUMN_HASHCODE), null, null, null)?.use { c ->
|
||||
if (c.moveToNext() && !c.isNull(0))
|
||||
return c.getInt(0)
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
|
||||
// contact level functions
|
||||
|
||||
/**
|
||||
* Calculates a hash code from the [at.bitfire.vcard4android.Contact] data and group memberships.
|
||||
* Attention: re-reads {@link #contact} from the database, discarding all changes in memory!
|
||||
*
|
||||
* @return hash code of contact data (including group memberships)
|
||||
*/
|
||||
private fun contactDataHashCode(contact: LocalContact): Int {
|
||||
contact.clearCachedContact()
|
||||
|
||||
// groupMemberships is filled by getContact()
|
||||
val dataHash = contact.getContact().hashCode()
|
||||
val groupHash = contact.groupMemberships.hashCode()
|
||||
val combinedHash = dataHash xor groupHash
|
||||
logger.log(Level.FINE, "Calculated data hash = $dataHash, group memberships hash = $groupHash → combined hash = $combinedHash", contact)
|
||||
return combinedHash
|
||||
}
|
||||
|
||||
override fun setHashCodeColumn(contact: LocalContact, toValues: ContentValues) {
|
||||
val hashCode = contactDataHashCode(contact)
|
||||
toValues.put(COLUMN_HASHCODE, hashCode)
|
||||
}
|
||||
|
||||
override fun updateHashCode(addressBook: LocalAddressBook, contact: LocalContact) {
|
||||
val values = ContentValues(1)
|
||||
setHashCodeColumn(contact, values)
|
||||
|
||||
addressBook.provider!!.update(contact.rawContactSyncURI(), values, null, null)
|
||||
}
|
||||
|
||||
override fun updateHashCode(contact: LocalContact, batch: BatchOperation) {
|
||||
val hashCode = contactDataHashCode(contact)
|
||||
|
||||
batch.enqueue(BatchOperation.CpoBuilder
|
||||
.newUpdate(contact.rawContactSyncURI())
|
||||
.withValue(COLUMN_HASHCODE, hashCode))
|
||||
}
|
||||
|
||||
|
||||
|
||||
// factory
|
||||
|
||||
@Module
|
||||
@InstallIn(SingletonComponent::class)
|
||||
object Android7DirtyVerifierModule {
|
||||
|
||||
/**
|
||||
* Provides an [Android7DirtyVerifier] on Android 7.x, or an empty [Optional] on other versions.
|
||||
*/
|
||||
@Provides
|
||||
fun provide(android7DirtyVerifier: Provider<Android7DirtyVerifier>): Optional<ContactDirtyVerifier> =
|
||||
if (/* Build.VERSION.SDK_INT >= Build.VERSION_CODES.N && */ Build.VERSION.SDK_INT < Build.VERSION_CODES.O)
|
||||
Optional.of(android7DirtyVerifier.get())
|
||||
else
|
||||
Optional.empty()
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
/*
|
||||
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
|
||||
*/
|
||||
|
||||
package at.bitfire.davdroid.resource.workaround
|
||||
|
||||
import android.content.ContentValues
|
||||
import at.bitfire.davdroid.resource.LocalAddressBook
|
||||
import at.bitfire.davdroid.resource.LocalContact
|
||||
import at.bitfire.vcard4android.BatchOperation
|
||||
|
||||
/**
|
||||
* Only required for [Android7DirtyVerifier]. If that class is removed because the minimum SDK is raised to Android 8,
|
||||
* this interface and all calls to it can be removed as well.
|
||||
*/
|
||||
interface ContactDirtyVerifier {
|
||||
|
||||
// address-book level functions
|
||||
|
||||
/**
|
||||
* Checks whether contacts which are marked as "dirty" are really dirty, i.e. their data has changed.
|
||||
* If contacts are not really dirty (because only the metadata like "last contacted" changed), the "dirty" flag is removed.
|
||||
*
|
||||
* Intended to be called by [at.bitfire.davdroid.sync.ContactsSyncManager.prepare].
|
||||
*
|
||||
* @param addressBook the address book
|
||||
* @param isUpload whether this sync is an upload
|
||||
*
|
||||
* @return `true` if the address book should be synced, `false` if the sync is an upload and no contacts have been changed
|
||||
*/
|
||||
fun prepareAddressBook(addressBook: LocalAddressBook, isUpload: Boolean): Boolean
|
||||
|
||||
|
||||
// contact level functions
|
||||
|
||||
/**
|
||||
* Sets the [LocalContact.COLUMN_HASHCODE] column in the given [ContentValues] to the hash code of the contact data.
|
||||
*
|
||||
* @param contact the contact to calculate the hash code for
|
||||
* @param toValues set the hash code into these values
|
||||
*/
|
||||
fun setHashCodeColumn(contact: LocalContact, toValues: ContentValues)
|
||||
|
||||
/**
|
||||
* Sets the [LocalContact.COLUMN_HASHCODE] field of the contact to the hash code of the contact data directly in the content provider.
|
||||
*/
|
||||
fun updateHashCode(addressBook: LocalAddressBook, contact: LocalContact)
|
||||
|
||||
/**
|
||||
Sets the [LocalContact.COLUMN_HASHCODE] field of the contact to the hash code of the contact data in a content provider batch operation.
|
||||
*/
|
||||
fun updateHashCode(contact: LocalContact, batch: BatchOperation)
|
||||
|
||||
}
|
||||
@@ -9,10 +9,19 @@ import at.bitfire.dav4jvm.Property
|
||||
import at.bitfire.dav4jvm.Response
|
||||
import at.bitfire.dav4jvm.UrlUtils
|
||||
import at.bitfire.dav4jvm.exception.HttpException
|
||||
import at.bitfire.dav4jvm.property.caldav.CalendarColor
|
||||
import at.bitfire.dav4jvm.property.caldav.CalendarDescription
|
||||
import at.bitfire.dav4jvm.property.caldav.CalendarHomeSet
|
||||
import at.bitfire.dav4jvm.property.caldav.CalendarProxyReadFor
|
||||
import at.bitfire.dav4jvm.property.caldav.CalendarProxyWriteFor
|
||||
import at.bitfire.dav4jvm.property.caldav.CalendarTimezone
|
||||
import at.bitfire.dav4jvm.property.caldav.CalendarTimezoneId
|
||||
import at.bitfire.dav4jvm.property.caldav.Source
|
||||
import at.bitfire.dav4jvm.property.caldav.SupportedCalendarComponentSet
|
||||
import at.bitfire.dav4jvm.property.carddav.AddressbookDescription
|
||||
import at.bitfire.dav4jvm.property.carddav.AddressbookHomeSet
|
||||
import at.bitfire.dav4jvm.property.push.PushTransports
|
||||
import at.bitfire.dav4jvm.property.push.Topic
|
||||
import at.bitfire.dav4jvm.property.webdav.CurrentUserPrivilegeSet
|
||||
import at.bitfire.dav4jvm.property.webdav.DisplayName
|
||||
import at.bitfire.dav4jvm.property.webdav.GroupMembership
|
||||
@@ -41,8 +50,8 @@ import java.util.logging.Logger
|
||||
* Logic for refreshing the list of collections and home-sets and related information.
|
||||
*/
|
||||
class CollectionListRefresher @AssistedInject constructor(
|
||||
@Assisted val service: Service,
|
||||
@Assisted val httpClient: OkHttpClient,
|
||||
@Assisted private val service: Service,
|
||||
@Assisted private val httpClient: OkHttpClient,
|
||||
private val db: AppDatabase,
|
||||
private val collectionRepository: DavCollectionRepository,
|
||||
private val homeSetRepository: DavHomeSetRepository,
|
||||
@@ -55,68 +64,120 @@ class CollectionListRefresher @AssistedInject constructor(
|
||||
fun create(service: Service, httpClient: OkHttpClient): CollectionListRefresher
|
||||
}
|
||||
|
||||
|
||||
private val alreadyQueried = mutableSetOf<HttpUrl>()
|
||||
/**
|
||||
* Principal properties to ask the server for.
|
||||
*/
|
||||
private val principalProperties = arrayOf(
|
||||
DisplayName.NAME,
|
||||
ResourceType.NAME
|
||||
)
|
||||
|
||||
/**
|
||||
* Starting at current-user-principal URL, tries to recursively find and save all user relevant home sets.
|
||||
*
|
||||
*
|
||||
* @param principalUrl URL of principal to query (user-provided principal or current-user-principal)
|
||||
* @param level Current recursion level (limited to 0, 1 or 2):
|
||||
*
|
||||
* - 0: We assume found home sets belong to the current-user-principal
|
||||
* - 1 or 2: We assume found home sets don't directly belong to the current-user-principal
|
||||
*
|
||||
* @throws java.io.IOException
|
||||
* @throws HttpException
|
||||
* @throws at.bitfire.dav4jvm.exception.DavException
|
||||
* Home-set class to use depending on the given service type.
|
||||
*/
|
||||
internal fun discoverHomesets(principalUrl: HttpUrl, level: Int = 0) {
|
||||
logger.fine("Discovering homesets of $principalUrl")
|
||||
val relatedResources = mutableSetOf<HttpUrl>()
|
||||
|
||||
// Define homeset class and properties to look for
|
||||
val homeSetClass: Class<out HrefListProperty>
|
||||
val properties: Array<Property.Name>
|
||||
private val homeSetClass: Class<out HrefListProperty> =
|
||||
when (service.type) {
|
||||
Service.TYPE_CARDDAV -> {
|
||||
homeSetClass = AddressbookHomeSet::class.java
|
||||
properties = arrayOf(DisplayName.NAME, AddressbookHomeSet.NAME, GroupMembership.NAME, ResourceType.NAME)
|
||||
}
|
||||
Service.TYPE_CALDAV -> {
|
||||
homeSetClass = CalendarHomeSet::class.java
|
||||
properties = arrayOf(
|
||||
DisplayName.NAME,
|
||||
CalendarHomeSet.NAME,
|
||||
CalendarProxyReadFor.NAME,
|
||||
CalendarProxyWriteFor.NAME,
|
||||
GroupMembership.NAME,
|
||||
ResourceType.NAME
|
||||
)
|
||||
}
|
||||
Service.TYPE_CARDDAV -> AddressbookHomeSet::class.java
|
||||
Service.TYPE_CALDAV -> CalendarHomeSet::class.java
|
||||
else -> throw IllegalArgumentException()
|
||||
}
|
||||
|
||||
/**
|
||||
* Home-set properties to ask for in a PROPFIND request to the principal URL,
|
||||
* depending on the given service type.
|
||||
*/
|
||||
private val homeSetProperties: Array<Property.Name> =
|
||||
arrayOf( // generic WebDAV properties
|
||||
DisplayName.NAME,
|
||||
GroupMembership.NAME,
|
||||
ResourceType.NAME
|
||||
) + when (service.type) { // service-specific CalDAV/CardDAV properties
|
||||
Service.TYPE_CARDDAV -> arrayOf(
|
||||
AddressbookHomeSet.NAME,
|
||||
)
|
||||
Service.TYPE_CALDAV -> arrayOf(
|
||||
CalendarHomeSet.NAME,
|
||||
CalendarProxyReadFor.NAME,
|
||||
CalendarProxyWriteFor.NAME
|
||||
)
|
||||
else -> throw IllegalArgumentException()
|
||||
}
|
||||
|
||||
/**
|
||||
* Collection properties to ask for in a PROPFIND request on a collection.
|
||||
*/
|
||||
private val collectionProperties: Array<Property.Name> =
|
||||
arrayOf( // generic WebDAV properties
|
||||
CurrentUserPrivilegeSet.NAME,
|
||||
DisplayName.NAME,
|
||||
Owner.NAME,
|
||||
ResourceType.NAME,
|
||||
PushTransports.NAME, // WebDAV-Push
|
||||
Topic.NAME
|
||||
) + when (service.type) { // service-specific CalDAV/CardDAV properties
|
||||
Service.TYPE_CARDDAV -> arrayOf(
|
||||
AddressbookDescription.NAME
|
||||
)
|
||||
Service.TYPE_CALDAV -> arrayOf(
|
||||
CalendarColor.NAME,
|
||||
CalendarDescription.NAME,
|
||||
CalendarTimezone.NAME,
|
||||
CalendarTimezoneId.NAME,
|
||||
SupportedCalendarComponentSet.NAME,
|
||||
Source.NAME
|
||||
)
|
||||
else -> throw IllegalArgumentException()
|
||||
}
|
||||
|
||||
|
||||
|
||||
/**
|
||||
* Starting at given principal URL, tries to recursively find and save all user relevant home sets.
|
||||
*
|
||||
* @param principalUrl URL of principal to query (user-provided principal or current-user-principal)
|
||||
* @param level Current recursion level (limited to 0, 1 or 2):
|
||||
* - 0: We assume found home sets belong to the current-user-principal
|
||||
* - 1 or 2: We assume found home sets don't directly belong to the current-user-principal
|
||||
* @param alreadyQueriedPrincipals The HttpUrls of principals which have been queried already, to avoid querying principals more than once.
|
||||
* @param alreadySavedHomeSets The HttpUrls of home sets which have been saved to database already, to avoid saving home sets
|
||||
* more than once, which could overwrite the already set "personal" flag with `false`.
|
||||
*
|
||||
* @throws java.io.IOException on I/O errors
|
||||
* @throws HttpException on HTTP errors
|
||||
* @throws at.bitfire.dav4jvm.exception.DavException on application-level or logical errors
|
||||
*/
|
||||
internal fun discoverHomesets(
|
||||
principalUrl: HttpUrl,
|
||||
level: Int = 0,
|
||||
alreadyQueriedPrincipals: MutableSet<HttpUrl> = mutableSetOf(),
|
||||
alreadySavedHomeSets: MutableSet<HttpUrl> = mutableSetOf()
|
||||
) {
|
||||
logger.fine("Discovering homesets of $principalUrl")
|
||||
val relatedResources = mutableSetOf<HttpUrl>()
|
||||
|
||||
// Query the URL
|
||||
val principal = DavResource(httpClient, principalUrl)
|
||||
val personal = level == 0
|
||||
try {
|
||||
principal.propfind(0, *properties) { davResponse, _ ->
|
||||
alreadyQueried += davResponse.href
|
||||
principal.propfind(0, *homeSetProperties) { davResponse, _ ->
|
||||
alreadyQueriedPrincipals += davResponse.href
|
||||
|
||||
// If response holds home sets, save them
|
||||
davResponse[homeSetClass]?.let { homeSets ->
|
||||
for (homeSetHref in homeSets.hrefs)
|
||||
principal.location.resolve(homeSetHref)?.let { homesetUrl ->
|
||||
val resolvedHomeSetUrl = UrlUtils.withTrailingSlash(homesetUrl)
|
||||
// Homeset is considered personal if this is the outer recursion call,
|
||||
// This is because we assume the first call to query the current-user-principal
|
||||
// Note: This is not be be confused with the DAV:owner attribute. Home sets can be owned by
|
||||
// other principals and still be considered "personal" (belonging to the current-user-principal).
|
||||
homeSetRepository.insertOrUpdateByUrl(
|
||||
HomeSet(0, service.id, personal, resolvedHomeSetUrl)
|
||||
)
|
||||
if (!alreadySavedHomeSets.contains(resolvedHomeSetUrl)) {
|
||||
homeSetRepository.insertOrUpdateByUrl(
|
||||
// HomeSet is considered personal if this is the outer recursion call,
|
||||
// This is because we assume the first call to query the current-user-principal
|
||||
// Note: This is not be be confused with the DAV:owner attribute. Home sets can be owned by
|
||||
// other principals while still being considered "personal" (belonging to the current-user-principal)
|
||||
// and an owned home set need not always be personal either.
|
||||
HomeSet(0, service.id, personal, resolvedHomeSetUrl)
|
||||
)
|
||||
alreadySavedHomeSets += resolvedHomeSetUrl
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -158,10 +219,15 @@ class CollectionListRefresher @AssistedInject constructor(
|
||||
// query related resources
|
||||
if (level <= 1)
|
||||
for (resource in relatedResources)
|
||||
if (alreadyQueried.contains(resource))
|
||||
if (alreadyQueriedPrincipals.contains(resource))
|
||||
logger.warning("$resource already queried, skipping")
|
||||
else
|
||||
discoverHomesets(resource, level + 1)
|
||||
discoverHomesets(
|
||||
principalUrl = resource,
|
||||
level = level + 1,
|
||||
alreadyQueriedPrincipals = alreadyQueriedPrincipals,
|
||||
alreadySavedHomeSets = alreadySavedHomeSets
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -186,34 +252,29 @@ class CollectionListRefresher @AssistedInject constructor(
|
||||
.toMutableMap()
|
||||
|
||||
try {
|
||||
DavResource(httpClient, homeSetUrl).propfind(1, *RefreshCollectionsWorker.DAV_COLLECTION_PROPERTIES) { response, relation ->
|
||||
DavResource(httpClient, homeSetUrl).propfind(1, *collectionProperties) { response, relation ->
|
||||
// Note: This callback may be called multiple times ([MultiResponseCallback])
|
||||
if (!response.isSuccess())
|
||||
return@propfind
|
||||
|
||||
if (relation == Response.HrefRelation.SELF) {
|
||||
// this response is about the homeset itself
|
||||
localHomeset.displayName = response[DisplayName::class.java]?.displayName
|
||||
localHomeset.privBind = response[CurrentUserPrivilegeSet::class.java]?.mayBind ?: true
|
||||
homeSetRepository.insertOrUpdateByUrl(localHomeset)
|
||||
}
|
||||
if (relation == Response.HrefRelation.SELF)
|
||||
// this response is about the home set itself
|
||||
homeSetRepository.insertOrUpdateByUrl(localHomeset.copy(
|
||||
displayName = response[DisplayName::class.java]?.displayName,
|
||||
privBind = response[CurrentUserPrivilegeSet::class.java]?.mayBind != false
|
||||
))
|
||||
|
||||
// in any case, check whether the response is about a usable collection
|
||||
val collection = Collection.fromDavResponse(response) ?: return@propfind
|
||||
|
||||
collection.serviceId = service.id
|
||||
collection.homeSetId = localHomeset.id
|
||||
collection.sync = shouldPreselect(collection, homesets.values)
|
||||
|
||||
// .. and save the principal url (collection owner)
|
||||
response[Owner::class.java]?.href
|
||||
?.let { response.href.resolve(it) }
|
||||
?.let { principalUrl ->
|
||||
val principal = Principal.fromServiceAndUrl(service, principalUrl)
|
||||
val id = db.principalDao().insertOrUpdate(service.id, principal)
|
||||
collection.ownerId = id
|
||||
}
|
||||
|
||||
var collection = Collection.fromDavResponse(response) ?: return@propfind
|
||||
collection = collection.copy(
|
||||
serviceId = service.id,
|
||||
homeSetId = localHomeset.id,
|
||||
sync = shouldPreselect(collection, homesets.values),
|
||||
ownerId = response[Owner::class.java]?.href // save the principal id (collection owner)
|
||||
?.let { response.href.resolve(it) }
|
||||
?.let { principalUrl -> Principal.fromServiceAndUrl(service, principalUrl) }
|
||||
?.let { principal -> db.principalDao().insertOrUpdate(service.id, principal) }
|
||||
)
|
||||
logger.log(Level.FINE, "Found collection", collection)
|
||||
|
||||
// save or update collection if usable (ignore it otherwise)
|
||||
@@ -230,10 +291,10 @@ class CollectionListRefresher @AssistedInject constructor(
|
||||
}
|
||||
|
||||
// Mark leftover (not rediscovered) collections from queue as homeless (remove association)
|
||||
for ((_, homelessCollection) in localHomesetCollections) {
|
||||
homelessCollection.homeSetId = null
|
||||
collectionRepository.insertOrUpdateByUrlAndRememberFlags(homelessCollection)
|
||||
}
|
||||
for ((_, homelessCollection) in localHomesetCollections)
|
||||
collectionRepository.insertOrUpdateByUrlAndRememberFlags(
|
||||
homelessCollection.copy(homeSetId = null)
|
||||
)
|
||||
|
||||
}
|
||||
}
|
||||
@@ -246,7 +307,7 @@ class CollectionListRefresher @AssistedInject constructor(
|
||||
internal fun refreshHomelessCollections() {
|
||||
val homelessCollections = db.collectionDao().getByServiceAndHomeset(service.id, null).associateBy { it.url }.toMutableMap()
|
||||
for((url, localCollection) in homelessCollections) try {
|
||||
DavResource(httpClient, url).propfind(0, *RefreshCollectionsWorker.DAV_COLLECTION_PROPERTIES) { response, _ ->
|
||||
DavResource(httpClient, url).propfind(0, *collectionProperties) { response, _ ->
|
||||
if (!response.isSuccess()) {
|
||||
collectionRepository.delete(localCollection)
|
||||
return@propfind
|
||||
@@ -256,18 +317,13 @@ class CollectionListRefresher @AssistedInject constructor(
|
||||
Collection.fromDavResponse(response)?.let { collection ->
|
||||
if (!isUsableCollection(collection))
|
||||
return@let
|
||||
collection.serviceId = localCollection.serviceId // use same service ID as previous entry
|
||||
|
||||
// .. and save the principal url (collection owner)
|
||||
response[Owner::class.java]?.href
|
||||
?.let { response.href.resolve(it) }
|
||||
?.let { principalUrl ->
|
||||
val principal = Principal.fromServiceAndUrl(service, principalUrl)
|
||||
val principalId = db.principalDao().insertOrUpdate(service.id, principal)
|
||||
collection.ownerId = principalId
|
||||
}
|
||||
|
||||
collectionRepository.insertOrUpdateByUrlAndRememberFlags(collection)
|
||||
collectionRepository.insertOrUpdateByUrlAndRememberFlags(collection.copy(
|
||||
serviceId = localCollection.serviceId, // use same service ID as previous entry
|
||||
ownerId = response[Owner::class.java]?.href // save the principal id (collection owner)
|
||||
?.let { response.href.resolve(it) }
|
||||
?.let { principalUrl -> Principal.fromServiceAndUrl(service, principalUrl) }
|
||||
?.let { principal -> db.principalDao().insertOrUpdate(service.id, principal) }
|
||||
))
|
||||
} ?: collectionRepository.delete(localCollection)
|
||||
}
|
||||
} catch (e: HttpException) {
|
||||
@@ -291,7 +347,7 @@ class CollectionListRefresher @AssistedInject constructor(
|
||||
val principalUrl = oldPrincipal.url
|
||||
logger.fine("Querying principal $principalUrl")
|
||||
try {
|
||||
DavResource(httpClient, principalUrl).propfind(0, *RefreshCollectionsWorker.DAV_PRINCIPAL_PROPERTIES) { response, _ ->
|
||||
DavResource(httpClient, principalUrl).propfind(0, *principalProperties) { response, _ ->
|
||||
if (!response.isSuccess())
|
||||
return@propfind
|
||||
Principal.fromDavResponse(service.id, response)?.let { principal ->
|
||||
@@ -331,10 +387,10 @@ class CollectionListRefresher @AssistedInject constructor(
|
||||
* [Settings.PRESELECT_COLLECTIONS_EXCLUDED], in which case *false* is returned.
|
||||
*
|
||||
* @param collection the collection to check
|
||||
* @param homesets list of home-sets (to check whether collection is in a personal home-set)
|
||||
* @param homeSets list of home-sets (to check whether collection is in a personal home-set)
|
||||
* @return *true* if the collection should be preselected for synchronization; *false* otherwise
|
||||
*/
|
||||
internal fun shouldPreselect(collection: Collection, homesets: Iterable<HomeSet>): Boolean {
|
||||
internal fun shouldPreselect(collection: Collection, homeSets: Iterable<HomeSet>): Boolean {
|
||||
val shouldPreselect = settings.getIntOrNull(Settings.PRESELECT_COLLECTIONS)
|
||||
|
||||
val excluded by lazy {
|
||||
@@ -352,7 +408,7 @@ class CollectionListRefresher @AssistedInject constructor(
|
||||
|
||||
Settings.PRESELECT_COLLECTIONS_PERSONAL ->
|
||||
// preselect if is personal (in a personal home-set), but not excluded
|
||||
homesets
|
||||
homeSets
|
||||
.filter { homeset -> homeset.personal }
|
||||
.map { homeset -> homeset.id }
|
||||
.contains(collection.homeSetId)
|
||||
|
||||
@@ -22,23 +22,10 @@ import androidx.work.WorkInfo
|
||||
import androidx.work.WorkManager
|
||||
import androidx.work.WorkerParameters
|
||||
import at.bitfire.dav4jvm.exception.UnauthorizedException
|
||||
import at.bitfire.dav4jvm.property.caldav.CalendarColor
|
||||
import at.bitfire.dav4jvm.property.caldav.CalendarDescription
|
||||
import at.bitfire.dav4jvm.property.caldav.Source
|
||||
import at.bitfire.dav4jvm.property.caldav.SupportedCalendarComponentSet
|
||||
import at.bitfire.dav4jvm.property.carddav.AddressbookDescription
|
||||
import at.bitfire.dav4jvm.property.carddav.SupportedAddressData
|
||||
import at.bitfire.dav4jvm.property.push.PushTransports
|
||||
import at.bitfire.dav4jvm.property.push.Topic
|
||||
import at.bitfire.dav4jvm.property.webdav.CurrentUserPrivilegeSet
|
||||
import at.bitfire.dav4jvm.property.webdav.DisplayName
|
||||
import at.bitfire.dav4jvm.property.webdav.Owner
|
||||
import at.bitfire.dav4jvm.property.webdav.ResourceType
|
||||
import at.bitfire.davdroid.InvalidAccountException
|
||||
import at.bitfire.davdroid.R
|
||||
import at.bitfire.davdroid.network.HttpClient
|
||||
import at.bitfire.davdroid.repository.DavServiceRepository
|
||||
import at.bitfire.davdroid.servicedetection.RefreshCollectionsWorker.Companion.ARG_SERVICE_ID
|
||||
import at.bitfire.davdroid.settings.AccountSettings
|
||||
import at.bitfire.davdroid.ui.DebugInfoActivity
|
||||
import at.bitfire.davdroid.ui.NotificationRegistry
|
||||
@@ -85,26 +72,6 @@ class RefreshCollectionsWorker @AssistedInject constructor(
|
||||
const val ARG_SERVICE_ID = "serviceId"
|
||||
const val WORKER_TAG = "refreshCollectionsWorker"
|
||||
|
||||
// Collection properties to ask for in a propfind request to the Cal- or CardDAV server
|
||||
val DAV_COLLECTION_PROPERTIES = arrayOf(
|
||||
ResourceType.NAME,
|
||||
CurrentUserPrivilegeSet.NAME,
|
||||
DisplayName.NAME,
|
||||
Owner.NAME,
|
||||
AddressbookDescription.NAME, SupportedAddressData.NAME,
|
||||
CalendarDescription.NAME, CalendarColor.NAME, SupportedCalendarComponentSet.NAME,
|
||||
Source.NAME,
|
||||
// WebDAV Push
|
||||
PushTransports.NAME,
|
||||
Topic.NAME
|
||||
)
|
||||
|
||||
// Principal properties to ask the server
|
||||
val DAV_PRINCIPAL_PROPERTIES = arrayOf(
|
||||
DisplayName.NAME,
|
||||
ResourceType.NAME
|
||||
)
|
||||
|
||||
/**
|
||||
* Uniquely identifies a refresh worker. Useful for stopping work, or querying its state.
|
||||
*
|
||||
|
||||
@@ -5,48 +5,51 @@ package at.bitfire.davdroid.settings
|
||||
|
||||
import android.accounts.Account
|
||||
import android.accounts.AccountManager
|
||||
import android.content.ContentResolver
|
||||
import android.content.Context
|
||||
import android.os.Bundle
|
||||
import android.os.Looper
|
||||
import android.provider.CalendarContract
|
||||
import androidx.annotation.WorkerThread
|
||||
import androidx.core.os.bundleOf
|
||||
import at.bitfire.davdroid.InvalidAccountException
|
||||
import at.bitfire.davdroid.R
|
||||
import at.bitfire.davdroid.db.Credentials
|
||||
import at.bitfire.davdroid.sync.SyncUtils
|
||||
import at.bitfire.davdroid.sync.worker.SyncWorkerManager
|
||||
import at.bitfire.davdroid.util.setAndVerifyUserData
|
||||
import at.bitfire.davdroid.settings.migration.AccountSettingsMigration
|
||||
import at.bitfire.davdroid.sync.AutomaticSyncManager
|
||||
import at.bitfire.davdroid.sync.SyncDataType
|
||||
import at.bitfire.davdroid.sync.account.setAndVerifyUserData
|
||||
import at.bitfire.davdroid.util.trimToNull
|
||||
import at.bitfire.ical4android.TaskProvider
|
||||
import at.bitfire.vcard4android.GroupMethod
|
||||
import dagger.assisted.Assisted
|
||||
import dagger.assisted.AssistedFactory
|
||||
import dagger.assisted.AssistedInject
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import net.openid.appauth.AuthState
|
||||
import java.util.Collections
|
||||
import java.util.logging.Level
|
||||
import java.util.logging.Logger
|
||||
import javax.inject.Provider
|
||||
import kotlin.collections.mutableSetOf
|
||||
|
||||
/**
|
||||
* Manages settings of an account.
|
||||
*
|
||||
* Must not be called from main thread as it uses blocking I/O
|
||||
* and may run migrations.
|
||||
* Must not be called from main thread as it uses blocking I/O and may run migrations.
|
||||
*
|
||||
* @param account account to take settings from
|
||||
* @param account account to take settings from
|
||||
* @param abortOnMissingMigration whether to throw an [IllegalArgumentException] when migrations are missing (useful for testing)
|
||||
*
|
||||
* @throws InvalidAccountException on construction when the account doesn't exist (anymore)
|
||||
* @throws IllegalArgumentException when the account is not a DAVx5 account
|
||||
* @throws InvalidAccountException on construction when the account doesn't exist (anymore)
|
||||
* @throws IllegalArgumentException when the account is not a DAVx5 account or migrations are missing and [abortOnMissingMigration] is set
|
||||
*/
|
||||
@WorkerThread
|
||||
class AccountSettings @AssistedInject constructor(
|
||||
@Assisted val account: Account,
|
||||
@ApplicationContext val context: Context,
|
||||
@Assisted val abortOnMissingMigration: Boolean,
|
||||
private val automaticSyncManager: AutomaticSyncManager,
|
||||
@ApplicationContext private val context: Context,
|
||||
private val logger: Logger,
|
||||
private val migrationsFactory: AccountSettingsMigrations.Factory,
|
||||
private val settingsManager: SettingsManager,
|
||||
private val syncWorkerManager: SyncWorkerManager
|
||||
private val migrations: Map<Int, @JvmSuppressWildcards Provider<AccountSettingsMigration>>,
|
||||
private val settingsManager: SettingsManager
|
||||
) {
|
||||
|
||||
@AssistedFactory
|
||||
@@ -56,7 +59,7 @@ class AccountSettings @AssistedInject constructor(
|
||||
* migrations.
|
||||
*/
|
||||
@WorkerThread
|
||||
fun create(account: Account): AccountSettings
|
||||
fun create(account: Account, abortOnMissingMigration: Boolean = false): AccountSettings
|
||||
}
|
||||
|
||||
init {
|
||||
@@ -71,27 +74,29 @@ class AccountSettings @AssistedInject constructor(
|
||||
"at.bitfire.davdroid.test" // R.strings.account_type_test in androidTest
|
||||
)
|
||||
if (!allowedAccountTypes.contains(account.type))
|
||||
throw IllegalArgumentException("Invalid account type: ${account.type}")
|
||||
throw IllegalArgumentException("Invalid account type for AccountSettings(): ${account.type}")
|
||||
|
||||
// synchronize because account migration must only be run one time
|
||||
synchronized(AccountSettings::class.java) {
|
||||
val versionStr = accountManager.getUserData(account, KEY_SETTINGS_VERSION) ?: throw InvalidAccountException(account)
|
||||
var version = 0
|
||||
try {
|
||||
version = Integer.parseInt(versionStr)
|
||||
} catch (e: NumberFormatException) {
|
||||
logger.log(Level.SEVERE, "Invalid account version: $versionStr", e)
|
||||
}
|
||||
logger.fine("Account ${account.name} has version $version, current version: $CURRENT_VERSION")
|
||||
synchronized(currentlyUpdating) {
|
||||
if (currentlyUpdating.contains(account))
|
||||
logger.warning("AccountSettings created during migration of $account – not running update()")
|
||||
else {
|
||||
val versionStr = accountManager.getUserData(account, KEY_SETTINGS_VERSION) ?: throw InvalidAccountException(account)
|
||||
var version = 0
|
||||
try {
|
||||
version = Integer.parseInt(versionStr)
|
||||
} catch (e: NumberFormatException) {
|
||||
logger.log(Level.SEVERE, "Invalid account version: $versionStr", e)
|
||||
}
|
||||
logger.fine("Account ${account.name} has version $version, current version: $CURRENT_VERSION")
|
||||
|
||||
if (version < CURRENT_VERSION) {
|
||||
if (currentlyUpdating) {
|
||||
logger.severe("Redundant call: migration created AccountSettings(). This must never happen.")
|
||||
throw IllegalStateException("Redundant call: migration created AccountSettings()")
|
||||
} else {
|
||||
currentlyUpdating = true
|
||||
update(version)
|
||||
currentlyUpdating = false
|
||||
if (version < CURRENT_VERSION) {
|
||||
currentlyUpdating += account
|
||||
try {
|
||||
update(version, abortOnMissingMigration)
|
||||
} finally {
|
||||
currentlyUpdating -= account
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -127,114 +132,41 @@ class AccountSettings @AssistedInject constructor(
|
||||
// sync. settings
|
||||
|
||||
/**
|
||||
* Gets the currently set sync interval for this account in seconds.
|
||||
* Gets the currently set sync interval for this account and data type in seconds.
|
||||
*
|
||||
* @param authority authority to check (for instance: [CalendarContract.AUTHORITY]])
|
||||
* @return sync interval in seconds; *[SYNC_INTERVAL_MANUALLY]* if manual sync; *null* if not set
|
||||
* @param dataType data type of desired sync interval
|
||||
* @return sync interval in seconds, or `null` if not set (not applicable or only manual sync)
|
||||
*/
|
||||
fun getSyncInterval(authority: String): Long? {
|
||||
val addrBookAuthority = context.getString(R.string.address_books_authority)
|
||||
|
||||
if (ContentResolver.getIsSyncable(account, authority) <= 0 && authority != addrBookAuthority)
|
||||
return null
|
||||
|
||||
val key = when {
|
||||
authority == addrBookAuthority ->
|
||||
KEY_SYNC_INTERVAL_ADDRESSBOOKS
|
||||
authority == CalendarContract.AUTHORITY ->
|
||||
KEY_SYNC_INTERVAL_CALENDARS
|
||||
TaskProvider.ProviderName.entries.any { it.authority == authority } ->
|
||||
KEY_SYNC_INTERVAL_TASKS
|
||||
else -> return null
|
||||
fun getSyncInterval(dataType: SyncDataType): Long? {
|
||||
val key = when (dataType) {
|
||||
SyncDataType.CONTACTS -> KEY_SYNC_INTERVAL_ADDRESSBOOKS
|
||||
SyncDataType.EVENTS -> KEY_SYNC_INTERVAL_CALENDARS
|
||||
SyncDataType.TASKS -> KEY_SYNC_INTERVAL_TASKS
|
||||
}
|
||||
return accountManager.getUserData(account, key)?.toLong()
|
||||
}
|
||||
|
||||
fun getTasksSyncInterval() = accountManager.getUserData(account, KEY_SYNC_INTERVAL_TASKS)?.toLong()
|
||||
|
||||
/**
|
||||
* Sets the sync interval and en- or disables periodic sync for the given account and authority.
|
||||
* Does *not* call [ContentResolver.setIsSyncable].
|
||||
*
|
||||
* This method blocks until a worker as been created and enqueued (sync active) or removed
|
||||
* (sync disabled), so it should not be called from the UI thread.
|
||||
*
|
||||
* @param authority sync authority (like [CalendarContract.AUTHORITY])
|
||||
* @param _seconds if [SYNC_INTERVAL_MANUALLY]: automatic sync will be disabled;
|
||||
* otherwise (must be ≥ 15 min): automatic sync will be enabled and set to the given number of seconds
|
||||
*/
|
||||
@WorkerThread
|
||||
fun setSyncInterval(authority: String, _seconds: Long) {
|
||||
val seconds =
|
||||
if (_seconds != SYNC_INTERVAL_MANUALLY && _seconds < 60*15)
|
||||
60*15
|
||||
else
|
||||
_seconds
|
||||
|
||||
// Store (user defined) sync interval in account settings
|
||||
val key = when {
|
||||
authority == context.getString(R.string.address_books_authority) ->
|
||||
KEY_SYNC_INTERVAL_ADDRESSBOOKS
|
||||
authority == CalendarContract.AUTHORITY ->
|
||||
KEY_SYNC_INTERVAL_CALENDARS
|
||||
TaskProvider.ProviderName.entries.any { it.authority == authority } ->
|
||||
KEY_SYNC_INTERVAL_TASKS
|
||||
else -> {
|
||||
logger.warning("Sync interval not applicable to authority $authority")
|
||||
return
|
||||
}
|
||||
val seconds = accountManager.getUserData(account, key)?.toLong()
|
||||
return when (seconds) {
|
||||
null -> settingsManager.getLongOrNull(Settings.DEFAULT_SYNC_INTERVAL) // no setting → default value
|
||||
SYNC_INTERVAL_MANUALLY -> null // manual sync
|
||||
else -> seconds
|
||||
}
|
||||
accountManager.setAndVerifyUserData(account, key, seconds.toString())
|
||||
|
||||
// update sync workers (needs already updated sync interval in AccountSettings)
|
||||
updatePeriodicSyncWorker(authority, seconds, getSyncWifiOnly())
|
||||
|
||||
// Also enable/disable content change triggered syncs (SyncFramework automatic sync).
|
||||
// We could make this a separate user adjustable setting later on.
|
||||
setSyncOnContentChange(authority, seconds != SYNC_INTERVAL_MANUALLY)
|
||||
}
|
||||
|
||||
/**
|
||||
* Enables/disables sync adapter automatic sync (content triggered sync) for the given
|
||||
* account and authority. Does *not* call [ContentResolver.setIsSyncable].
|
||||
* Sets the sync interval for the given data type and updates the automatic sync.
|
||||
*
|
||||
* We use the sync adapter framework only for the trigger, actual syncing is implemented
|
||||
* with WorkManager. The trigger comes in through SyncAdapterService.
|
||||
*
|
||||
* This method blocks until the sync-on-content-change has been enabled or disabled, so it
|
||||
* should not be called from the UI thread.
|
||||
*
|
||||
* @param enable *true* enables automatic sync; *false* disables it
|
||||
* @param authority sync authority (like [CalendarContract.AUTHORITY])
|
||||
* @return whether the content triggered sync was enabled successfully
|
||||
* @param dataType data type of the sync interval to set
|
||||
* @param seconds sync interval in seconds; _null_ for no periodic sync
|
||||
*/
|
||||
@WorkerThread
|
||||
fun setSyncOnContentChange(authority: String, enable: Boolean): Boolean {
|
||||
// Enable content change triggers (sync adapter framework)
|
||||
val setContentTrigger: () -> Boolean =
|
||||
/* Ugly hack: because there is no callback for when the sync status/interval has been
|
||||
updated, we need to make this call blocking. */
|
||||
if (enable) {{
|
||||
logger.fine("Enabling content-triggered sync of $account/$authority")
|
||||
ContentResolver.setSyncAutomatically(account, authority, true) // enables content triggers
|
||||
// Remove unwanted sync framework periodic syncs created by setSyncAutomatically
|
||||
for (periodicSync in ContentResolver.getPeriodicSyncs(account, authority))
|
||||
ContentResolver.removePeriodicSync(periodicSync.account, periodicSync.authority, periodicSync.extras)
|
||||
/* return */ ContentResolver.getSyncAutomatically(account, authority)
|
||||
}} else {{
|
||||
logger.fine("Disabling content-triggered sync of $account/$authority")
|
||||
ContentResolver.setSyncAutomatically(account, authority, false) // disables content triggers
|
||||
/* return */ !ContentResolver.getSyncAutomatically(account, authority)
|
||||
}}
|
||||
|
||||
// try up to 10 times with 100 ms pause
|
||||
for (idxTry in 0 until 10) {
|
||||
if (setContentTrigger())
|
||||
// successfully set
|
||||
return true
|
||||
Thread.sleep(100)
|
||||
fun setSyncInterval(dataType: SyncDataType, seconds: Long?) {
|
||||
val key = when (dataType) {
|
||||
SyncDataType.CONTACTS -> KEY_SYNC_INTERVAL_ADDRESSBOOKS
|
||||
SyncDataType.EVENTS -> KEY_SYNC_INTERVAL_CALENDARS
|
||||
SyncDataType.TASKS -> KEY_SYNC_INTERVAL_TASKS
|
||||
}
|
||||
return false
|
||||
val newValue = if (seconds == null) SYNC_INTERVAL_MANUALLY else seconds
|
||||
accountManager.setAndVerifyUserData(account, key, newValue.toString())
|
||||
|
||||
automaticSyncManager.updateAutomaticSync(account, dataType)
|
||||
}
|
||||
|
||||
fun getSyncWifiOnly() =
|
||||
@@ -245,10 +177,7 @@ class AccountSettings @AssistedInject constructor(
|
||||
|
||||
fun setSyncWiFiOnly(wiFiOnly: Boolean) {
|
||||
accountManager.setAndVerifyUserData(account, KEY_WIFI_ONLY, if (wiFiOnly) "1" else null)
|
||||
|
||||
// update sync workers (needs already updated wifi-only flag in AccountSettings)
|
||||
for (authority in SyncUtils.syncAuthorities(context))
|
||||
updatePeriodicSyncWorker(authority, getSyncInterval(authority), wiFiOnly)
|
||||
automaticSyncManager.updateAutomaticSync(account)
|
||||
}
|
||||
|
||||
fun getSyncWifiOnlySSIDs(): List<String>? =
|
||||
@@ -273,30 +202,6 @@ class AccountSettings @AssistedInject constructor(
|
||||
fun setIgnoreVpns(ignoreVpns: Boolean) =
|
||||
accountManager.setAndVerifyUserData(account, KEY_IGNORE_VPNS, if (ignoreVpns) "1" else "0")
|
||||
|
||||
/**
|
||||
* Updates the periodic sync worker of an authority according to
|
||||
*
|
||||
* - the sync interval and
|
||||
* - the _Sync WiFi only_ flag.
|
||||
*
|
||||
* @param authority periodic sync workers for this authority will be updated
|
||||
* @param seconds sync interval in seconds (`null` or [SYNC_INTERVAL_MANUALLY] disables periodic sync)
|
||||
* @param wiFiOnly sync Wifi only flag
|
||||
*/
|
||||
fun updatePeriodicSyncWorker(authority: String, seconds: Long?, wiFiOnly: Boolean) {
|
||||
try {
|
||||
if (seconds == null || seconds == SYNC_INTERVAL_MANUALLY) {
|
||||
logger.fine("Disabling periodic sync of $account/$authority")
|
||||
syncWorkerManager.disablePeriodic(account, authority)
|
||||
} else {
|
||||
logger.fine("Setting periodic sync of $account/$authority to $seconds seconds (wifiOnly=$wiFiOnly)")
|
||||
syncWorkerManager.enablePeriodic(account, authority, seconds, wiFiOnly)
|
||||
}.result.get() // On operation (enable/disable) failure exception is thrown
|
||||
} catch (e: Exception) {
|
||||
logger.log(Level.SEVERE, "Failed to set sync interval of $account/$authority to $seconds seconds", e)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// CalDAV settings
|
||||
|
||||
@@ -313,7 +218,7 @@ class AccountSettings @AssistedInject constructor(
|
||||
}
|
||||
|
||||
fun setTimeRangePastDays(days: Int?) =
|
||||
accountManager.setAndVerifyUserData(account, KEY_TIME_RANGE_PAST_DAYS, (days ?: -1).toString())
|
||||
accountManager.setAndVerifyUserData(account, KEY_TIME_RANGE_PAST_DAYS, (days ?: -1).toString())
|
||||
|
||||
/**
|
||||
* Takes the default alarm setting (in this order) from
|
||||
@@ -325,8 +230,8 @@ class AccountSettings @AssistedInject constructor(
|
||||
* non-full-day event without reminder. *null*: No default reminders shall be created.
|
||||
*/
|
||||
fun getDefaultAlarm() =
|
||||
accountManager.getUserData(account, KEY_DEFAULT_ALARM)?.toInt() ?:
|
||||
settingsManager.getIntOrNull(KEY_DEFAULT_ALARM)?.takeIf { it != -1 }
|
||||
accountManager.getUserData(account, KEY_DEFAULT_ALARM)?.toInt() ?:
|
||||
settingsManager.getIntOrNull(KEY_DEFAULT_ALARM)?.takeIf { it != -1 }
|
||||
|
||||
/**
|
||||
* Sets the default alarm value in the local account settings, if the new value differs
|
||||
@@ -338,11 +243,11 @@ class AccountSettings @AssistedInject constructor(
|
||||
* start of every non-full-day event without reminder. *null*: No default reminders shall be created.
|
||||
*/
|
||||
fun setDefaultAlarm(minBefore: Int?) =
|
||||
accountManager.setAndVerifyUserData(account, KEY_DEFAULT_ALARM,
|
||||
if (minBefore == settingsManager.getIntOrNull(KEY_DEFAULT_ALARM)?.takeIf { it != -1 })
|
||||
null
|
||||
else
|
||||
minBefore?.toString())
|
||||
accountManager.setAndVerifyUserData(account, KEY_DEFAULT_ALARM,
|
||||
if (minBefore == settingsManager.getIntOrNull(KEY_DEFAULT_ALARM)?.takeIf { it != -1 })
|
||||
null
|
||||
else
|
||||
minBefore?.toString())
|
||||
|
||||
fun getManageCalendarColors() =
|
||||
if (settingsManager.containsKey(KEY_MANAGE_CALENDAR_COLORS))
|
||||
@@ -415,29 +320,33 @@ class AccountSettings @AssistedInject constructor(
|
||||
|
||||
// update from previous account settings
|
||||
|
||||
private fun update(baseVersion: Int) {
|
||||
private fun update(baseVersion: Int, abortOnMissingMigration: Boolean) {
|
||||
for (toVersion in baseVersion+1 ..CURRENT_VERSION) {
|
||||
val fromVersion = toVersion-1
|
||||
logger.info("Updating account ${account.name} from version $fromVersion to $toVersion")
|
||||
try {
|
||||
val migrations = migrationsFactory.create(
|
||||
account = account,
|
||||
accountSettings = this
|
||||
)
|
||||
val updateProc = AccountSettingsMigrations::class.java.getDeclaredMethod("update_${fromVersion}_$toVersion")
|
||||
updateProc.invoke(migrations)
|
||||
val fromVersion = toVersion - 1
|
||||
logger.info("Updating account ${account.name} settings version $fromVersion → $toVersion")
|
||||
|
||||
logger.info("Account version update successful")
|
||||
accountManager.setAndVerifyUserData(account, KEY_SETTINGS_VERSION, toVersion.toString())
|
||||
} catch (e: Exception) {
|
||||
logger.log(Level.SEVERE, "Couldn't update account settings", e)
|
||||
val migration = migrations[toVersion]
|
||||
if (migration == null) {
|
||||
logger.severe("No AccountSettings migration $fromVersion → $toVersion")
|
||||
if (abortOnMissingMigration)
|
||||
throw IllegalArgumentException("Missing AccountSettings migration $fromVersion → $toVersion")
|
||||
} else {
|
||||
try {
|
||||
migration.get().migrate(account)
|
||||
|
||||
logger.info("Account settings version update to $toVersion successful")
|
||||
accountManager.setAndVerifyUserData(account, KEY_SETTINGS_VERSION, toVersion.toString())
|
||||
} catch (e: Exception) {
|
||||
logger.log(Level.SEVERE, "Couldn't run AccountSettings migration $fromVersion → $toVersion", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
companion object {
|
||||
|
||||
const val CURRENT_VERSION = 17
|
||||
const val CURRENT_VERSION = 19
|
||||
const val KEY_SETTINGS_VERSION = "version"
|
||||
|
||||
const val KEY_SYNC_INTERVAL_ADDRESSBOOKS = "sync_interval_addressbooks"
|
||||
@@ -493,16 +402,13 @@ class AccountSettings @AssistedInject constructor(
|
||||
"1" show only personal collections */
|
||||
const val KEY_SHOW_ONLY_PERSONAL = "show_only_personal"
|
||||
|
||||
const val SYNC_INTERVAL_MANUALLY = -1L
|
||||
internal const val SYNC_INTERVAL_MANUALLY = -1L
|
||||
|
||||
/** Static property to indicate whether AccountSettings migration is currently running.
|
||||
* **Access must be `synchronized` with `AccountSettings::class.java`.** */
|
||||
@Volatile
|
||||
var currentlyUpdating = false
|
||||
/** Static property to remember which AccountSettings updates/migrations are currently running */
|
||||
val currentlyUpdating = Collections.synchronizedSet(mutableSetOf<Account>())
|
||||
|
||||
fun initialUserData(credentials: Credentials?): Bundle {
|
||||
val bundle = Bundle()
|
||||
bundle.putString(KEY_SETTINGS_VERSION, CURRENT_VERSION.toString())
|
||||
val bundle = bundleOf(KEY_SETTINGS_VERSION to CURRENT_VERSION.toString())
|
||||
|
||||
if (credentials != null) {
|
||||
if (credentials.username != null)
|
||||
|
||||
@@ -1,423 +0,0 @@
|
||||
/*
|
||||
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
|
||||
*/
|
||||
|
||||
package at.bitfire.davdroid.settings
|
||||
|
||||
import android.accounts.Account
|
||||
import android.accounts.AccountManager
|
||||
import android.annotation.SuppressLint
|
||||
import android.content.ContentResolver
|
||||
import android.content.ContentUris
|
||||
import android.content.ContentValues
|
||||
import android.content.Context
|
||||
import android.content.pm.PackageManager
|
||||
import android.provider.CalendarContract
|
||||
import android.provider.ContactsContract
|
||||
import android.util.Base64
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.preference.PreferenceManager
|
||||
import androidx.work.WorkManager
|
||||
import at.bitfire.davdroid.R
|
||||
import at.bitfire.davdroid.db.AppDatabase
|
||||
import at.bitfire.davdroid.db.Service
|
||||
import at.bitfire.davdroid.repository.AccountRepository
|
||||
import at.bitfire.davdroid.repository.DavCollectionRepository
|
||||
import at.bitfire.davdroid.repository.DavServiceRepository
|
||||
import at.bitfire.davdroid.resource.LocalAddressBook
|
||||
import at.bitfire.davdroid.resource.LocalTask
|
||||
import at.bitfire.davdroid.sync.SyncUtils
|
||||
import at.bitfire.davdroid.sync.TasksAppManager
|
||||
import at.bitfire.davdroid.sync.worker.SyncWorkerManager
|
||||
import at.bitfire.davdroid.util.setAndVerifyUserData
|
||||
import at.bitfire.ical4android.AndroidCalendar
|
||||
import at.bitfire.ical4android.AndroidEvent
|
||||
import at.bitfire.ical4android.TaskProvider
|
||||
import at.bitfire.ical4android.UnknownProperty
|
||||
import at.techbee.jtx.JtxContract.asSyncAdapter
|
||||
import dagger.Lazy
|
||||
import dagger.assisted.Assisted
|
||||
import dagger.assisted.AssistedFactory
|
||||
import dagger.assisted.AssistedInject
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import net.fortuna.ical4j.model.Property
|
||||
import net.fortuna.ical4j.model.property.Url
|
||||
import org.dmfs.tasks.contract.TaskContract
|
||||
import java.io.ByteArrayInputStream
|
||||
import java.io.ObjectInputStream
|
||||
import java.util.logging.Level
|
||||
import java.util.logging.Logger
|
||||
|
||||
class AccountSettingsMigrations @AssistedInject constructor(
|
||||
@Assisted val account: Account,
|
||||
@Assisted val accountSettings: AccountSettings,
|
||||
@ApplicationContext val context: Context,
|
||||
private val accountRepository: AccountRepository,
|
||||
private val collectionRepository: DavCollectionRepository,
|
||||
private val db: AppDatabase,
|
||||
private val localAddressBookFactory: LocalAddressBook.Factory,
|
||||
private val logger: Logger,
|
||||
private val serviceRepository: DavServiceRepository,
|
||||
private val syncWorkerManager: SyncWorkerManager,
|
||||
private val tasksAppManager: Lazy<TasksAppManager>
|
||||
) {
|
||||
|
||||
@AssistedFactory
|
||||
interface Factory {
|
||||
fun create(account: Account, accountSettings: AccountSettings): AccountSettingsMigrations
|
||||
}
|
||||
|
||||
val accountManager: AccountManager = AccountManager.get(context)
|
||||
|
||||
/**
|
||||
* With DAVx5 4.3.3 address book account names now contain the collection ID as a unique
|
||||
* identifier. We need to update the address book account names.
|
||||
*/
|
||||
@Suppress("unused","FunctionName")
|
||||
fun update_16_17() {
|
||||
val addressBookAccountType = context.getString(R.string.account_type_address_book)
|
||||
try {
|
||||
context.contentResolver.acquireContentProviderClient(ContactsContract.AUTHORITY)
|
||||
} catch (e: SecurityException) {
|
||||
// Not setting the collection ID will cause the address books to removed and fully re-synced as soon as there are permissions.
|
||||
logger.log(Level.WARNING, "Missing permissions for contacts authority, won't set collection ID for address books", e)
|
||||
null
|
||||
}?.use { provider ->
|
||||
val service = serviceRepository.getByAccountAndType(account.name, Service.TYPE_CARDDAV) ?: return
|
||||
|
||||
// Get all old address books of this account, i.e. the ones which have a "real_account_name" of this account.
|
||||
// After this migration is run, address books won't be associated to accounts anymore but only to their respective collection/URL.
|
||||
val oldAddressBookAccounts = accountManager.getAccountsByType(addressBookAccountType)
|
||||
.filter { addressBookAccount ->
|
||||
account.name == accountManager.getUserData(addressBookAccount, "real_account_name")
|
||||
}
|
||||
|
||||
for (oldAddressBookAccount in oldAddressBookAccounts) {
|
||||
// Old address books only have a URL, so use it to determine the collection ID
|
||||
logger.info("Migrating address book ${oldAddressBookAccount.name}")
|
||||
val url = accountManager.getUserData(oldAddressBookAccount, LocalAddressBook.USER_DATA_URL)
|
||||
collectionRepository.getByServiceAndUrl(service.id, url)?.let { collection ->
|
||||
// Set collection ID and rename the account
|
||||
val localAddressBook = localAddressBookFactory.create(oldAddressBookAccount, provider)
|
||||
localAddressBook.update(collection)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Between DAVx5 4.4.1-beta.1 and 4.4.1-rc.1 (both v15), the periodic sync workers were renamed (moved to another
|
||||
* package) and thus automatic synchronization stopped (because the enqueued workers rely on the full class
|
||||
* name and no new workers were enqueued). Here we enqueue all periodic sync workers again with the correct class name.
|
||||
*/
|
||||
@Suppress("unused","FunctionName")
|
||||
fun update_15_16() {
|
||||
for (authority in SyncUtils.syncAuthorities(context)) {
|
||||
logger.info("Re-enqueuing periodic sync workers for $account/$authority, if necessary")
|
||||
|
||||
/* A maybe existing periodic worker references the old class name (even if it failed and/or is not active). So
|
||||
we need to explicitly disable and prune all workers. Just updating the worker is not enough – WorkManager will update
|
||||
the work details, but not the class name. */
|
||||
val disableOp = syncWorkerManager.disablePeriodic(account, authority)
|
||||
disableOp.result.get() // block until worker with old name is disabled
|
||||
|
||||
val pruneOp = WorkManager.getInstance(context).pruneWork()
|
||||
pruneOp.result.get() // block until worker with old name is removed from DB
|
||||
|
||||
val interval = accountSettings.getSyncInterval(authority)
|
||||
if (interval != null && interval != AccountSettings.SYNC_INTERVAL_MANUALLY) {
|
||||
// There's a sync interval for this account/authority; a periodic sync worker should be there, too.
|
||||
val onlyWifi = accountSettings.getSyncWifiOnly()
|
||||
syncWorkerManager.enablePeriodic(account, authority, interval, onlyWifi)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Updates the periodic sync workers by re-setting the same sync interval.
|
||||
*
|
||||
* The goal is to add the [BaseSyncWorker.commonTag] to all existing periodic sync workers so that they can be detected by
|
||||
* the new [BaseSyncWorker.exists] and [at.bitfire.davdroid.ui.AccountsActivity.Model].
|
||||
*/
|
||||
@Suppress("unused","FunctionName")
|
||||
fun update_14_15() {
|
||||
for (authority in SyncUtils.syncAuthorities(context)) {
|
||||
val interval = accountSettings.getSyncInterval(authority)
|
||||
accountSettings.setSyncInterval(authority, interval ?: AccountSettings.SYNC_INTERVAL_MANUALLY)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Disables all sync adapter periodic syncs for every authority. Then enables
|
||||
* corresponding PeriodicSyncWorkers
|
||||
*/
|
||||
@Suppress("unused","FunctionName")
|
||||
fun update_13_14() {
|
||||
// Cancel any potentially running syncs for this account (sync framework)
|
||||
ContentResolver.cancelSync(account, null)
|
||||
|
||||
val authorities = listOf(
|
||||
context.getString(R.string.address_books_authority),
|
||||
CalendarContract.AUTHORITY,
|
||||
TaskProvider.ProviderName.JtxBoard.authority,
|
||||
TaskProvider.ProviderName.OpenTasks.authority,
|
||||
TaskProvider.ProviderName.TasksOrg.authority
|
||||
)
|
||||
|
||||
for (authority in authorities) {
|
||||
// Enable PeriodicSyncWorker (WorkManager), with known intervals
|
||||
v14_enableWorkManager(authority)
|
||||
// Disable periodic syncs (sync adapter framework)
|
||||
v14_disableSyncFramework(authority)
|
||||
}
|
||||
}
|
||||
private fun v14_enableWorkManager(authority: String) {
|
||||
val enabled = accountSettings.getSyncInterval(authority)?.let { syncInterval ->
|
||||
accountSettings.setSyncInterval(authority, syncInterval)
|
||||
} ?: false
|
||||
logger.info("PeriodicSyncWorker for $account/$authority enabled=$enabled")
|
||||
}
|
||||
private fun v14_disableSyncFramework(authority: String) {
|
||||
// Disable periodic syncs (sync adapter framework)
|
||||
val disable: () -> Boolean = {
|
||||
/* Ugly hack: because there is no callback for when the sync status/interval has been
|
||||
updated, we need to make this call blocking. */
|
||||
for (sync in ContentResolver.getPeriodicSyncs(account, authority))
|
||||
ContentResolver.removePeriodicSync(sync.account, sync.authority, sync.extras)
|
||||
|
||||
// check whether syncs are really disabled
|
||||
var result = true
|
||||
for (sync in ContentResolver.getPeriodicSyncs(account, authority)) {
|
||||
logger.info("Sync framework still has a periodic sync for $account/$authority: $sync")
|
||||
result = false
|
||||
}
|
||||
result
|
||||
}
|
||||
// try up to 10 times with 100 ms pause
|
||||
var success = false
|
||||
for (idxTry in 0 until 10) {
|
||||
success = disable()
|
||||
if (success)
|
||||
break
|
||||
Thread.sleep(200)
|
||||
}
|
||||
logger.info("Sync framework periodic syncs for $account/$authority disabled=$success")
|
||||
}
|
||||
|
||||
@Suppress("unused","FunctionName")
|
||||
/**
|
||||
* Not a per-account migration, but not a database migration, too, so it fits best there.
|
||||
* Best future solution would be that SettingsManager manages versions and migrations.
|
||||
*
|
||||
* Updates proxy settings from override_proxy_* to proxy_type, proxy_host, proxy_port.
|
||||
*/
|
||||
private fun update_12_13() {
|
||||
// proxy settings are managed by SharedPreferencesProvider
|
||||
val preferences = PreferenceManager.getDefaultSharedPreferences(context)
|
||||
|
||||
// old setting names
|
||||
val overrideProxy = "override_proxy"
|
||||
val overrideProxyHost = "override_proxy_host"
|
||||
val overrideProxyPort = "override_proxy_port"
|
||||
|
||||
val edit = preferences.edit()
|
||||
if (preferences.contains(overrideProxy)) {
|
||||
if (preferences.getBoolean(overrideProxy, false))
|
||||
// override_proxy set, migrate to proxy_type = HTTP
|
||||
edit.putInt(Settings.PROXY_TYPE, Settings.PROXY_TYPE_HTTP)
|
||||
edit.remove(overrideProxy)
|
||||
}
|
||||
if (preferences.contains(overrideProxyHost)) {
|
||||
preferences.getString(overrideProxyHost, null)?.let { host ->
|
||||
edit.putString(Settings.PROXY_HOST, host)
|
||||
}
|
||||
edit.remove(overrideProxyHost)
|
||||
}
|
||||
if (preferences.contains(overrideProxyPort)) {
|
||||
val port = preferences.getInt(overrideProxyPort, 0)
|
||||
if (port != 0)
|
||||
edit.putInt(Settings.PROXY_PORT, port)
|
||||
edit.remove(overrideProxyPort)
|
||||
}
|
||||
edit.apply()
|
||||
}
|
||||
|
||||
|
||||
@Suppress("unused","FunctionName")
|
||||
/**
|
||||
* Store event URLs as URL (extended property) instead of unknown property. At the same time,
|
||||
* convert legacy unknown properties to the current format.
|
||||
*/
|
||||
private fun update_11_12() {
|
||||
if (ContextCompat.checkSelfPermission(context, android.Manifest.permission.WRITE_CALENDAR) == PackageManager.PERMISSION_GRANTED) {
|
||||
context.contentResolver.acquireContentProviderClient(CalendarContract.AUTHORITY)?.use { provider ->
|
||||
// Attention: CalendarProvider does NOT limit the results of the ExtendedProperties query
|
||||
// to the given account! So all extended properties will be processed number-of-accounts times.
|
||||
val extUri = CalendarContract.ExtendedProperties.CONTENT_URI.asSyncAdapter(account)
|
||||
|
||||
provider.query(
|
||||
extUri, arrayOf(
|
||||
CalendarContract.ExtendedProperties._ID, // idx 0
|
||||
CalendarContract.ExtendedProperties.NAME, // idx 1
|
||||
CalendarContract.ExtendedProperties.VALUE // idx 2
|
||||
), null, null, null
|
||||
)?.use { cursor ->
|
||||
while (cursor.moveToNext()) {
|
||||
val id = cursor.getLong(0)
|
||||
val rawValue = cursor.getString(2)
|
||||
|
||||
val uri by lazy {
|
||||
ContentUris.withAppendedId(CalendarContract.ExtendedProperties.CONTENT_URI, id).asSyncAdapter(account)
|
||||
}
|
||||
|
||||
when (cursor.getString(1)) {
|
||||
UnknownProperty.CONTENT_ITEM_TYPE -> {
|
||||
// unknown property; check whether it's a URL
|
||||
try {
|
||||
val property = UnknownProperty.fromJsonString(rawValue)
|
||||
if (property is Url) { // rewrite to MIMETYPE_URL
|
||||
val newValues = ContentValues(2)
|
||||
newValues.put(CalendarContract.ExtendedProperties.NAME, AndroidEvent.EXTNAME_URL)
|
||||
newValues.put(CalendarContract.ExtendedProperties.VALUE, property.value)
|
||||
provider.update(uri, newValues, null, null)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
logger.log(
|
||||
Level.WARNING,
|
||||
"Couldn't rewrite URL from unknown property to ${AndroidEvent.EXTNAME_URL}",
|
||||
e
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
"unknown-property" -> {
|
||||
// unknown property (deprecated format); convert to current format
|
||||
try {
|
||||
val stream = ByteArrayInputStream(Base64.decode(rawValue, Base64.NO_WRAP))
|
||||
ObjectInputStream(stream).use {
|
||||
(it.readObject() as? Property)?.let { property ->
|
||||
// rewrite to current format
|
||||
val newValues = ContentValues(2)
|
||||
newValues.put(CalendarContract.ExtendedProperties.NAME, UnknownProperty.CONTENT_ITEM_TYPE)
|
||||
newValues.put(CalendarContract.ExtendedProperties.VALUE, UnknownProperty.toJsonString(property))
|
||||
provider.update(uri, newValues, null, null)
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
logger.log(Level.WARNING, "Couldn't rewrite deprecated unknown property to current format", e)
|
||||
}
|
||||
}
|
||||
|
||||
"unknown-property.v2" -> {
|
||||
// unknown property (deprecated MIME type); rewrite to current MIME type
|
||||
val newValues = ContentValues(1)
|
||||
newValues.put(CalendarContract.ExtendedProperties.NAME, UnknownProperty.CONTENT_ITEM_TYPE)
|
||||
provider.update(uri, newValues, null, null)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Suppress("unused","FunctionName")
|
||||
/**
|
||||
* The tasks sync interval should be stored in account settings. It's used to set the sync interval
|
||||
* again when the tasks provider is switched.
|
||||
*/
|
||||
private fun update_10_11() {
|
||||
tasksAppManager.get().currentProvider()?.let { provider ->
|
||||
val interval = accountSettings.getSyncInterval(provider.authority)
|
||||
if (interval != null)
|
||||
accountManager.setAndVerifyUserData(account,
|
||||
AccountSettings.KEY_SYNC_INTERVAL_TASKS, interval.toString())
|
||||
}
|
||||
}
|
||||
|
||||
@Suppress("unused","FunctionName")
|
||||
/**
|
||||
* Task synchronization now handles alarms, categories, relations and unknown properties.
|
||||
* Setting task ETags to null will cause them to be downloaded (and parsed) again.
|
||||
*
|
||||
* Also update the allowed reminder types for calendars.
|
||||
**/
|
||||
private fun update_9_10() {
|
||||
TaskProvider.acquire(context, TaskProvider.ProviderName.OpenTasks)?.use { provider ->
|
||||
val tasksUri = provider.tasksUri().asSyncAdapter(account)
|
||||
val emptyETag = ContentValues(1)
|
||||
emptyETag.putNull(LocalTask.COLUMN_ETAG)
|
||||
provider.client.update(tasksUri, emptyETag, "${TaskContract.Tasks._DIRTY}=0 AND ${TaskContract.Tasks._DELETED}=0", null)
|
||||
}
|
||||
|
||||
if (ContextCompat.checkSelfPermission(context, android.Manifest.permission.WRITE_CALENDAR) == PackageManager.PERMISSION_GRANTED)
|
||||
context.contentResolver.acquireContentProviderClient(CalendarContract.AUTHORITY)?.use { provider ->
|
||||
provider.update(
|
||||
CalendarContract.Calendars.CONTENT_URI.asSyncAdapter(account),
|
||||
AndroidCalendar.calendarBaseValues, null, null)
|
||||
}
|
||||
}
|
||||
|
||||
@Suppress("unused","FunctionName")
|
||||
/**
|
||||
* It seems that somehow some non-CalDAV accounts got OpenTasks syncable, which caused battery problems.
|
||||
* Disable it on those accounts for the future.
|
||||
*/
|
||||
private fun update_8_9() {
|
||||
val hasCalDAV = db.serviceDao().getByAccountAndType(account.name, Service.TYPE_CALDAV) != null
|
||||
if (!hasCalDAV && ContentResolver.getIsSyncable(account, TaskProvider.ProviderName.OpenTasks.authority) != 0) {
|
||||
logger.info("Disabling OpenTasks sync for $account")
|
||||
ContentResolver.setIsSyncable(account, TaskProvider.ProviderName.OpenTasks.authority, 0)
|
||||
}
|
||||
}
|
||||
|
||||
@Suppress("unused","FunctionName")
|
||||
@SuppressLint("Recycle")
|
||||
/**
|
||||
* There is a mistake in this method. [TaskContract.Tasks.SYNC_VERSION] is used to store the
|
||||
* SEQUENCE and should not be used for the eTag.
|
||||
*/
|
||||
private fun update_7_8() {
|
||||
TaskProvider.acquire(context, TaskProvider.ProviderName.OpenTasks)?.use { provider ->
|
||||
// ETag is now in sync_version instead of sync1
|
||||
// UID is now in _uid instead of sync2
|
||||
provider.client.query(provider.tasksUri().asSyncAdapter(account),
|
||||
arrayOf(TaskContract.Tasks._ID, TaskContract.Tasks.SYNC1, TaskContract.Tasks.SYNC2),
|
||||
"${TaskContract.Tasks.ACCOUNT_TYPE}=? AND ${TaskContract.Tasks.ACCOUNT_NAME}=?",
|
||||
arrayOf(account.type, account.name), null)!!.use { cursor ->
|
||||
while (cursor.moveToNext()) {
|
||||
val id = cursor.getLong(0)
|
||||
val eTag = cursor.getString(1)
|
||||
val uid = cursor.getString(2)
|
||||
val values = ContentValues(4)
|
||||
values.put(TaskContract.Tasks._UID, uid)
|
||||
values.put(TaskContract.Tasks.SYNC_VERSION, eTag)
|
||||
values.putNull(TaskContract.Tasks.SYNC1)
|
||||
values.putNull(TaskContract.Tasks.SYNC2)
|
||||
logger.log(Level.FINER, "Updating task $id", values)
|
||||
provider.client.update(
|
||||
ContentUris.withAppendedId(provider.tasksUri(), id).asSyncAdapter(account),
|
||||
values, null, null)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Suppress("unused")
|
||||
@SuppressLint("Recycle")
|
||||
private fun update_6_7() {
|
||||
// add calendar colors
|
||||
context.contentResolver.acquireContentProviderClient(CalendarContract.AUTHORITY)?.use { provider ->
|
||||
AndroidCalendar.insertColors(provider, account)
|
||||
}
|
||||
|
||||
// update allowed WiFi settings key
|
||||
val onlySSID = accountManager.getUserData(account, "wifi_only_ssid")
|
||||
accountManager.setAndVerifyUserData(account, AccountSettings.KEY_WIFI_ONLY_SSIDS, onlySSID)
|
||||
accountManager.setAndVerifyUserData(account, "wifi_only_ssid", null)
|
||||
}
|
||||
|
||||
// updates from AccountSettings version 5 and below are not supported anymore
|
||||
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user