mirror of
https://github.com/bitfireAT/davx5-ose.git
synced 2026-02-23 18:36:17 -05:00
Compare commits
1 Commits
v4.4.5-ose
...
debug-buil
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
46e8c4522b |
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@v4
|
||||
- uses: gradle/actions/setup-gradle@v3
|
||||
with:
|
||||
cache-encryption-key: ${{ secrets.gradle_encryption_key }}
|
||||
cache-read-only: true # gradle user home cache is generated by test jobs
|
||||
|
||||
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@v4
|
||||
- uses: gradle/actions/setup-gradle@v3
|
||||
|
||||
- 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@v4 # creates build cache when on main branch
|
||||
- uses: gradle/actions/setup-gradle@v3 # creates build cache when on main branch
|
||||
with:
|
||||
cache-encryption-key: ${{ secrets.gradle_encryption_key }}
|
||||
dependency-graph: generate-and-submit # submit Github Dependency Graph info
|
||||
@@ -39,7 +39,7 @@ jobs:
|
||||
with:
|
||||
distribution: temurin
|
||||
java-version: 21
|
||||
- uses: gradle/actions/setup-gradle@v4
|
||||
- uses: gradle/actions/setup-gradle@v3
|
||||
with:
|
||||
cache-encryption-key: ${{ secrets.gradle_encryption_key }}
|
||||
cache-read-only: true
|
||||
@@ -60,7 +60,7 @@ jobs:
|
||||
with:
|
||||
distribution: temurin
|
||||
java-version: 21
|
||||
- uses: gradle/actions/setup-gradle@v4
|
||||
- uses: gradle/actions/setup-gradle@v3
|
||||
with:
|
||||
cache-encryption-key: ${{ secrets.gradle_encryption_key }}
|
||||
cache-read-only: true
|
||||
|
||||
@@ -18,8 +18,8 @@ android {
|
||||
defaultConfig {
|
||||
applicationId = "at.bitfire.davdroid"
|
||||
|
||||
versionCode = 404050003
|
||||
versionName = "4.4.5"
|
||||
versionCode = 404030200
|
||||
versionName = "4.4.3.2"
|
||||
|
||||
setProperty("archivesBaseName", "davx5-ose-$versionName")
|
||||
|
||||
@@ -82,6 +82,9 @@ android {
|
||||
|
||||
signingConfig = signingConfigs.findByName("bitfire")
|
||||
}
|
||||
getByName("debug") {
|
||||
applicationIdSuffix = ".debug"
|
||||
}
|
||||
}
|
||||
|
||||
lint {
|
||||
|
||||
@@ -1,675 +0,0 @@
|
||||
{
|
||||
"formatVersion": 1,
|
||||
"database": {
|
||||
"version": 15,
|
||||
"identityHash": "ab1cb6057d8e050f6648bea46ae0943d",
|
||||
"entities": [
|
||||
{
|
||||
"tableName": "service",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `accountName` TEXT NOT NULL, `type` TEXT NOT NULL, `principal` TEXT)",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "id",
|
||||
"columnName": "id",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "accountName",
|
||||
"columnName": "accountName",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "type",
|
||||
"columnName": "type",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "principal",
|
||||
"columnName": "principal",
|
||||
"affinity": "TEXT",
|
||||
"notNull": false
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"autoGenerate": true,
|
||||
"columnNames": [
|
||||
"id"
|
||||
]
|
||||
},
|
||||
"indices": [
|
||||
{
|
||||
"name": "index_service_accountName_type",
|
||||
"unique": true,
|
||||
"columnNames": [
|
||||
"accountName",
|
||||
"type"
|
||||
],
|
||||
"orders": [],
|
||||
"createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_service_accountName_type` ON `${TABLE_NAME}` (`accountName`, `type`)"
|
||||
}
|
||||
],
|
||||
"foreignKeys": []
|
||||
},
|
||||
{
|
||||
"tableName": "homeset",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `serviceId` INTEGER NOT NULL, `personal` INTEGER NOT NULL, `url` TEXT NOT NULL, `privBind` INTEGER NOT NULL, `displayName` TEXT, FOREIGN KEY(`serviceId`) REFERENCES `service`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "id",
|
||||
"columnName": "id",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "serviceId",
|
||||
"columnName": "serviceId",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "personal",
|
||||
"columnName": "personal",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "url",
|
||||
"columnName": "url",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "privBind",
|
||||
"columnName": "privBind",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "displayName",
|
||||
"columnName": "displayName",
|
||||
"affinity": "TEXT",
|
||||
"notNull": false
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"autoGenerate": true,
|
||||
"columnNames": [
|
||||
"id"
|
||||
]
|
||||
},
|
||||
"indices": [
|
||||
{
|
||||
"name": "index_homeset_serviceId_url",
|
||||
"unique": true,
|
||||
"columnNames": [
|
||||
"serviceId",
|
||||
"url"
|
||||
],
|
||||
"orders": [],
|
||||
"createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_homeset_serviceId_url` ON `${TABLE_NAME}` (`serviceId`, `url`)"
|
||||
}
|
||||
],
|
||||
"foreignKeys": [
|
||||
{
|
||||
"table": "service",
|
||||
"onDelete": "CASCADE",
|
||||
"onUpdate": "NO ACTION",
|
||||
"columns": [
|
||||
"serviceId"
|
||||
],
|
||||
"referencedColumns": [
|
||||
"id"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"tableName": "collection",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `serviceId` INTEGER NOT NULL, `homeSetId` INTEGER, `ownerId` INTEGER, `type` TEXT NOT NULL, `url` TEXT NOT NULL, `privWriteContent` INTEGER NOT NULL, `privUnbind` INTEGER NOT NULL, `forceReadOnly` INTEGER NOT NULL, `displayName` TEXT, `description` TEXT, `color` INTEGER, `timezone` TEXT, `supportsVEVENT` INTEGER, `supportsVTODO` INTEGER, `supportsVJOURNAL` INTEGER, `source` TEXT, `sync` INTEGER NOT NULL, `pushTopic` TEXT, `supportsWebPush` INTEGER NOT NULL DEFAULT 0, `pushSubscription` TEXT, `pushSubscriptionExpires` INTEGER, `pushSubscriptionCreated` INTEGER, FOREIGN KEY(`serviceId`) REFERENCES `service`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`homeSetId`) REFERENCES `homeset`(`id`) ON UPDATE NO ACTION ON DELETE SET NULL , FOREIGN KEY(`ownerId`) REFERENCES `principal`(`id`) ON UPDATE NO ACTION ON DELETE SET NULL )",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "id",
|
||||
"columnName": "id",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "serviceId",
|
||||
"columnName": "serviceId",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "homeSetId",
|
||||
"columnName": "homeSetId",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "ownerId",
|
||||
"columnName": "ownerId",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "type",
|
||||
"columnName": "type",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "url",
|
||||
"columnName": "url",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "privWriteContent",
|
||||
"columnName": "privWriteContent",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "privUnbind",
|
||||
"columnName": "privUnbind",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "forceReadOnly",
|
||||
"columnName": "forceReadOnly",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "displayName",
|
||||
"columnName": "displayName",
|
||||
"affinity": "TEXT",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "description",
|
||||
"columnName": "description",
|
||||
"affinity": "TEXT",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "color",
|
||||
"columnName": "color",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "timezone",
|
||||
"columnName": "timezone",
|
||||
"affinity": "TEXT",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "supportsVEVENT",
|
||||
"columnName": "supportsVEVENT",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "supportsVTODO",
|
||||
"columnName": "supportsVTODO",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "supportsVJOURNAL",
|
||||
"columnName": "supportsVJOURNAL",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "source",
|
||||
"columnName": "source",
|
||||
"affinity": "TEXT",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "sync",
|
||||
"columnName": "sync",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "pushTopic",
|
||||
"columnName": "pushTopic",
|
||||
"affinity": "TEXT",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "supportsWebPush",
|
||||
"columnName": "supportsWebPush",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true,
|
||||
"defaultValue": "0"
|
||||
},
|
||||
{
|
||||
"fieldPath": "pushSubscription",
|
||||
"columnName": "pushSubscription",
|
||||
"affinity": "TEXT",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "pushSubscriptionExpires",
|
||||
"columnName": "pushSubscriptionExpires",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "pushSubscriptionCreated",
|
||||
"columnName": "pushSubscriptionCreated",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": false
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"autoGenerate": true,
|
||||
"columnNames": [
|
||||
"id"
|
||||
]
|
||||
},
|
||||
"indices": [
|
||||
{
|
||||
"name": "index_collection_serviceId_type",
|
||||
"unique": false,
|
||||
"columnNames": [
|
||||
"serviceId",
|
||||
"type"
|
||||
],
|
||||
"orders": [],
|
||||
"createSql": "CREATE INDEX IF NOT EXISTS `index_collection_serviceId_type` ON `${TABLE_NAME}` (`serviceId`, `type`)"
|
||||
},
|
||||
{
|
||||
"name": "index_collection_homeSetId_type",
|
||||
"unique": false,
|
||||
"columnNames": [
|
||||
"homeSetId",
|
||||
"type"
|
||||
],
|
||||
"orders": [],
|
||||
"createSql": "CREATE INDEX IF NOT EXISTS `index_collection_homeSetId_type` ON `${TABLE_NAME}` (`homeSetId`, `type`)"
|
||||
},
|
||||
{
|
||||
"name": "index_collection_ownerId_type",
|
||||
"unique": false,
|
||||
"columnNames": [
|
||||
"ownerId",
|
||||
"type"
|
||||
],
|
||||
"orders": [],
|
||||
"createSql": "CREATE INDEX IF NOT EXISTS `index_collection_ownerId_type` ON `${TABLE_NAME}` (`ownerId`, `type`)"
|
||||
},
|
||||
{
|
||||
"name": "index_collection_pushTopic_type",
|
||||
"unique": false,
|
||||
"columnNames": [
|
||||
"pushTopic",
|
||||
"type"
|
||||
],
|
||||
"orders": [],
|
||||
"createSql": "CREATE INDEX IF NOT EXISTS `index_collection_pushTopic_type` ON `${TABLE_NAME}` (`pushTopic`, `type`)"
|
||||
},
|
||||
{
|
||||
"name": "index_collection_url",
|
||||
"unique": false,
|
||||
"columnNames": [
|
||||
"url"
|
||||
],
|
||||
"orders": [],
|
||||
"createSql": "CREATE INDEX IF NOT EXISTS `index_collection_url` ON `${TABLE_NAME}` (`url`)"
|
||||
}
|
||||
],
|
||||
"foreignKeys": [
|
||||
{
|
||||
"table": "service",
|
||||
"onDelete": "CASCADE",
|
||||
"onUpdate": "NO ACTION",
|
||||
"columns": [
|
||||
"serviceId"
|
||||
],
|
||||
"referencedColumns": [
|
||||
"id"
|
||||
]
|
||||
},
|
||||
{
|
||||
"table": "homeset",
|
||||
"onDelete": "SET NULL",
|
||||
"onUpdate": "NO ACTION",
|
||||
"columns": [
|
||||
"homeSetId"
|
||||
],
|
||||
"referencedColumns": [
|
||||
"id"
|
||||
]
|
||||
},
|
||||
{
|
||||
"table": "principal",
|
||||
"onDelete": "SET NULL",
|
||||
"onUpdate": "NO ACTION",
|
||||
"columns": [
|
||||
"ownerId"
|
||||
],
|
||||
"referencedColumns": [
|
||||
"id"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"tableName": "principal",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `serviceId` INTEGER NOT NULL, `url` TEXT NOT NULL, `displayName` TEXT, FOREIGN KEY(`serviceId`) REFERENCES `service`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "id",
|
||||
"columnName": "id",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "serviceId",
|
||||
"columnName": "serviceId",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "url",
|
||||
"columnName": "url",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "displayName",
|
||||
"columnName": "displayName",
|
||||
"affinity": "TEXT",
|
||||
"notNull": false
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"autoGenerate": true,
|
||||
"columnNames": [
|
||||
"id"
|
||||
]
|
||||
},
|
||||
"indices": [
|
||||
{
|
||||
"name": "index_principal_serviceId_url",
|
||||
"unique": true,
|
||||
"columnNames": [
|
||||
"serviceId",
|
||||
"url"
|
||||
],
|
||||
"orders": [],
|
||||
"createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_principal_serviceId_url` ON `${TABLE_NAME}` (`serviceId`, `url`)"
|
||||
}
|
||||
],
|
||||
"foreignKeys": [
|
||||
{
|
||||
"table": "service",
|
||||
"onDelete": "CASCADE",
|
||||
"onUpdate": "NO ACTION",
|
||||
"columns": [
|
||||
"serviceId"
|
||||
],
|
||||
"referencedColumns": [
|
||||
"id"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"tableName": "syncstats",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `collectionId` INTEGER NOT NULL, `authority` TEXT NOT NULL, `lastSync` INTEGER NOT NULL, FOREIGN KEY(`collectionId`) REFERENCES `collection`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "id",
|
||||
"columnName": "id",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "collectionId",
|
||||
"columnName": "collectionId",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "authority",
|
||||
"columnName": "authority",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "lastSync",
|
||||
"columnName": "lastSync",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"autoGenerate": true,
|
||||
"columnNames": [
|
||||
"id"
|
||||
]
|
||||
},
|
||||
"indices": [
|
||||
{
|
||||
"name": "index_syncstats_collectionId_authority",
|
||||
"unique": true,
|
||||
"columnNames": [
|
||||
"collectionId",
|
||||
"authority"
|
||||
],
|
||||
"orders": [],
|
||||
"createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_syncstats_collectionId_authority` ON `${TABLE_NAME}` (`collectionId`, `authority`)"
|
||||
}
|
||||
],
|
||||
"foreignKeys": [
|
||||
{
|
||||
"table": "collection",
|
||||
"onDelete": "CASCADE",
|
||||
"onUpdate": "NO ACTION",
|
||||
"columns": [
|
||||
"collectionId"
|
||||
],
|
||||
"referencedColumns": [
|
||||
"id"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"tableName": "webdav_document",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `mountId` INTEGER NOT NULL, `parentId` INTEGER, `name` TEXT NOT NULL, `isDirectory` INTEGER NOT NULL, `displayName` TEXT, `mimeType` TEXT, `eTag` TEXT, `lastModified` INTEGER, `size` INTEGER, `mayBind` INTEGER, `mayUnbind` INTEGER, `mayWriteContent` INTEGER, `quotaAvailable` INTEGER, `quotaUsed` INTEGER, FOREIGN KEY(`mountId`) REFERENCES `webdav_mount`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`parentId`) REFERENCES `webdav_document`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "id",
|
||||
"columnName": "id",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "mountId",
|
||||
"columnName": "mountId",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "parentId",
|
||||
"columnName": "parentId",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "name",
|
||||
"columnName": "name",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "isDirectory",
|
||||
"columnName": "isDirectory",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "displayName",
|
||||
"columnName": "displayName",
|
||||
"affinity": "TEXT",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "mimeType",
|
||||
"columnName": "mimeType",
|
||||
"affinity": "TEXT",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "eTag",
|
||||
"columnName": "eTag",
|
||||
"affinity": "TEXT",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "lastModified",
|
||||
"columnName": "lastModified",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "size",
|
||||
"columnName": "size",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "mayBind",
|
||||
"columnName": "mayBind",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "mayUnbind",
|
||||
"columnName": "mayUnbind",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "mayWriteContent",
|
||||
"columnName": "mayWriteContent",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "quotaAvailable",
|
||||
"columnName": "quotaAvailable",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "quotaUsed",
|
||||
"columnName": "quotaUsed",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": false
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"autoGenerate": true,
|
||||
"columnNames": [
|
||||
"id"
|
||||
]
|
||||
},
|
||||
"indices": [
|
||||
{
|
||||
"name": "index_webdav_document_mountId_parentId_name",
|
||||
"unique": true,
|
||||
"columnNames": [
|
||||
"mountId",
|
||||
"parentId",
|
||||
"name"
|
||||
],
|
||||
"orders": [],
|
||||
"createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_webdav_document_mountId_parentId_name` ON `${TABLE_NAME}` (`mountId`, `parentId`, `name`)"
|
||||
},
|
||||
{
|
||||
"name": "index_webdav_document_parentId",
|
||||
"unique": false,
|
||||
"columnNames": [
|
||||
"parentId"
|
||||
],
|
||||
"orders": [],
|
||||
"createSql": "CREATE INDEX IF NOT EXISTS `index_webdav_document_parentId` ON `${TABLE_NAME}` (`parentId`)"
|
||||
}
|
||||
],
|
||||
"foreignKeys": [
|
||||
{
|
||||
"table": "webdav_mount",
|
||||
"onDelete": "CASCADE",
|
||||
"onUpdate": "NO ACTION",
|
||||
"columns": [
|
||||
"mountId"
|
||||
],
|
||||
"referencedColumns": [
|
||||
"id"
|
||||
]
|
||||
},
|
||||
{
|
||||
"table": "webdav_document",
|
||||
"onDelete": "CASCADE",
|
||||
"onUpdate": "NO ACTION",
|
||||
"columns": [
|
||||
"parentId"
|
||||
],
|
||||
"referencedColumns": [
|
||||
"id"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"tableName": "webdav_mount",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL, `url` TEXT NOT NULL)",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "id",
|
||||
"columnName": "id",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "name",
|
||||
"columnName": "name",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "url",
|
||||
"columnName": "url",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"autoGenerate": true,
|
||||
"columnNames": [
|
||||
"id"
|
||||
]
|
||||
},
|
||||
"indices": [],
|
||||
"foreignKeys": []
|
||||
}
|
||||
],
|
||||
"views": [],
|
||||
"setupQueries": [
|
||||
"CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
|
||||
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'ab1cb6057d8e050f6648bea46ae0943d')"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -1,675 +0,0 @@
|
||||
{
|
||||
"formatVersion": 1,
|
||||
"database": {
|
||||
"version": 16,
|
||||
"identityHash": "2ff7560d957e03a78b4b7de88aa9593b",
|
||||
"entities": [
|
||||
{
|
||||
"tableName": "service",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `accountName` TEXT NOT NULL, `type` TEXT NOT NULL, `principal` TEXT)",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "id",
|
||||
"columnName": "id",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "accountName",
|
||||
"columnName": "accountName",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "type",
|
||||
"columnName": "type",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "principal",
|
||||
"columnName": "principal",
|
||||
"affinity": "TEXT",
|
||||
"notNull": false
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"autoGenerate": true,
|
||||
"columnNames": [
|
||||
"id"
|
||||
]
|
||||
},
|
||||
"indices": [
|
||||
{
|
||||
"name": "index_service_accountName_type",
|
||||
"unique": true,
|
||||
"columnNames": [
|
||||
"accountName",
|
||||
"type"
|
||||
],
|
||||
"orders": [],
|
||||
"createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_service_accountName_type` ON `${TABLE_NAME}` (`accountName`, `type`)"
|
||||
}
|
||||
],
|
||||
"foreignKeys": []
|
||||
},
|
||||
{
|
||||
"tableName": "homeset",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `serviceId` INTEGER NOT NULL, `personal` INTEGER NOT NULL, `url` TEXT NOT NULL, `privBind` INTEGER NOT NULL, `displayName` TEXT, FOREIGN KEY(`serviceId`) REFERENCES `service`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "id",
|
||||
"columnName": "id",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "serviceId",
|
||||
"columnName": "serviceId",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "personal",
|
||||
"columnName": "personal",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "url",
|
||||
"columnName": "url",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "privBind",
|
||||
"columnName": "privBind",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "displayName",
|
||||
"columnName": "displayName",
|
||||
"affinity": "TEXT",
|
||||
"notNull": false
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"autoGenerate": true,
|
||||
"columnNames": [
|
||||
"id"
|
||||
]
|
||||
},
|
||||
"indices": [
|
||||
{
|
||||
"name": "index_homeset_serviceId_url",
|
||||
"unique": true,
|
||||
"columnNames": [
|
||||
"serviceId",
|
||||
"url"
|
||||
],
|
||||
"orders": [],
|
||||
"createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_homeset_serviceId_url` ON `${TABLE_NAME}` (`serviceId`, `url`)"
|
||||
}
|
||||
],
|
||||
"foreignKeys": [
|
||||
{
|
||||
"table": "service",
|
||||
"onDelete": "CASCADE",
|
||||
"onUpdate": "NO ACTION",
|
||||
"columns": [
|
||||
"serviceId"
|
||||
],
|
||||
"referencedColumns": [
|
||||
"id"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"tableName": "collection",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `serviceId` INTEGER NOT NULL, `homeSetId` INTEGER, `ownerId` INTEGER, `type` TEXT NOT NULL, `url` TEXT NOT NULL, `privWriteContent` INTEGER NOT NULL, `privUnbind` INTEGER NOT NULL, `forceReadOnly` INTEGER NOT NULL, `displayName` TEXT, `description` TEXT, `color` INTEGER, `timezoneId` TEXT, `supportsVEVENT` INTEGER, `supportsVTODO` INTEGER, `supportsVJOURNAL` INTEGER, `source` TEXT, `sync` INTEGER NOT NULL, `pushTopic` TEXT, `supportsWebPush` INTEGER NOT NULL DEFAULT 0, `pushSubscription` TEXT, `pushSubscriptionExpires` INTEGER, `pushSubscriptionCreated` INTEGER, FOREIGN KEY(`serviceId`) REFERENCES `service`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`homeSetId`) REFERENCES `homeset`(`id`) ON UPDATE NO ACTION ON DELETE SET NULL , FOREIGN KEY(`ownerId`) REFERENCES `principal`(`id`) ON UPDATE NO ACTION ON DELETE SET NULL )",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "id",
|
||||
"columnName": "id",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "serviceId",
|
||||
"columnName": "serviceId",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "homeSetId",
|
||||
"columnName": "homeSetId",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "ownerId",
|
||||
"columnName": "ownerId",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "type",
|
||||
"columnName": "type",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "url",
|
||||
"columnName": "url",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "privWriteContent",
|
||||
"columnName": "privWriteContent",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "privUnbind",
|
||||
"columnName": "privUnbind",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "forceReadOnly",
|
||||
"columnName": "forceReadOnly",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "displayName",
|
||||
"columnName": "displayName",
|
||||
"affinity": "TEXT",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "description",
|
||||
"columnName": "description",
|
||||
"affinity": "TEXT",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "color",
|
||||
"columnName": "color",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "timezoneId",
|
||||
"columnName": "timezoneId",
|
||||
"affinity": "TEXT",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "supportsVEVENT",
|
||||
"columnName": "supportsVEVENT",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "supportsVTODO",
|
||||
"columnName": "supportsVTODO",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "supportsVJOURNAL",
|
||||
"columnName": "supportsVJOURNAL",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "source",
|
||||
"columnName": "source",
|
||||
"affinity": "TEXT",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "sync",
|
||||
"columnName": "sync",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "pushTopic",
|
||||
"columnName": "pushTopic",
|
||||
"affinity": "TEXT",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "supportsWebPush",
|
||||
"columnName": "supportsWebPush",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true,
|
||||
"defaultValue": "0"
|
||||
},
|
||||
{
|
||||
"fieldPath": "pushSubscription",
|
||||
"columnName": "pushSubscription",
|
||||
"affinity": "TEXT",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "pushSubscriptionExpires",
|
||||
"columnName": "pushSubscriptionExpires",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "pushSubscriptionCreated",
|
||||
"columnName": "pushSubscriptionCreated",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": false
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"autoGenerate": true,
|
||||
"columnNames": [
|
||||
"id"
|
||||
]
|
||||
},
|
||||
"indices": [
|
||||
{
|
||||
"name": "index_collection_serviceId_type",
|
||||
"unique": false,
|
||||
"columnNames": [
|
||||
"serviceId",
|
||||
"type"
|
||||
],
|
||||
"orders": [],
|
||||
"createSql": "CREATE INDEX IF NOT EXISTS `index_collection_serviceId_type` ON `${TABLE_NAME}` (`serviceId`, `type`)"
|
||||
},
|
||||
{
|
||||
"name": "index_collection_homeSetId_type",
|
||||
"unique": false,
|
||||
"columnNames": [
|
||||
"homeSetId",
|
||||
"type"
|
||||
],
|
||||
"orders": [],
|
||||
"createSql": "CREATE INDEX IF NOT EXISTS `index_collection_homeSetId_type` ON `${TABLE_NAME}` (`homeSetId`, `type`)"
|
||||
},
|
||||
{
|
||||
"name": "index_collection_ownerId_type",
|
||||
"unique": false,
|
||||
"columnNames": [
|
||||
"ownerId",
|
||||
"type"
|
||||
],
|
||||
"orders": [],
|
||||
"createSql": "CREATE INDEX IF NOT EXISTS `index_collection_ownerId_type` ON `${TABLE_NAME}` (`ownerId`, `type`)"
|
||||
},
|
||||
{
|
||||
"name": "index_collection_pushTopic_type",
|
||||
"unique": false,
|
||||
"columnNames": [
|
||||
"pushTopic",
|
||||
"type"
|
||||
],
|
||||
"orders": [],
|
||||
"createSql": "CREATE INDEX IF NOT EXISTS `index_collection_pushTopic_type` ON `${TABLE_NAME}` (`pushTopic`, `type`)"
|
||||
},
|
||||
{
|
||||
"name": "index_collection_url",
|
||||
"unique": false,
|
||||
"columnNames": [
|
||||
"url"
|
||||
],
|
||||
"orders": [],
|
||||
"createSql": "CREATE INDEX IF NOT EXISTS `index_collection_url` ON `${TABLE_NAME}` (`url`)"
|
||||
}
|
||||
],
|
||||
"foreignKeys": [
|
||||
{
|
||||
"table": "service",
|
||||
"onDelete": "CASCADE",
|
||||
"onUpdate": "NO ACTION",
|
||||
"columns": [
|
||||
"serviceId"
|
||||
],
|
||||
"referencedColumns": [
|
||||
"id"
|
||||
]
|
||||
},
|
||||
{
|
||||
"table": "homeset",
|
||||
"onDelete": "SET NULL",
|
||||
"onUpdate": "NO ACTION",
|
||||
"columns": [
|
||||
"homeSetId"
|
||||
],
|
||||
"referencedColumns": [
|
||||
"id"
|
||||
]
|
||||
},
|
||||
{
|
||||
"table": "principal",
|
||||
"onDelete": "SET NULL",
|
||||
"onUpdate": "NO ACTION",
|
||||
"columns": [
|
||||
"ownerId"
|
||||
],
|
||||
"referencedColumns": [
|
||||
"id"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"tableName": "principal",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `serviceId` INTEGER NOT NULL, `url` TEXT NOT NULL, `displayName` TEXT, FOREIGN KEY(`serviceId`) REFERENCES `service`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "id",
|
||||
"columnName": "id",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "serviceId",
|
||||
"columnName": "serviceId",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "url",
|
||||
"columnName": "url",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "displayName",
|
||||
"columnName": "displayName",
|
||||
"affinity": "TEXT",
|
||||
"notNull": false
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"autoGenerate": true,
|
||||
"columnNames": [
|
||||
"id"
|
||||
]
|
||||
},
|
||||
"indices": [
|
||||
{
|
||||
"name": "index_principal_serviceId_url",
|
||||
"unique": true,
|
||||
"columnNames": [
|
||||
"serviceId",
|
||||
"url"
|
||||
],
|
||||
"orders": [],
|
||||
"createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_principal_serviceId_url` ON `${TABLE_NAME}` (`serviceId`, `url`)"
|
||||
}
|
||||
],
|
||||
"foreignKeys": [
|
||||
{
|
||||
"table": "service",
|
||||
"onDelete": "CASCADE",
|
||||
"onUpdate": "NO ACTION",
|
||||
"columns": [
|
||||
"serviceId"
|
||||
],
|
||||
"referencedColumns": [
|
||||
"id"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"tableName": "syncstats",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `collectionId` INTEGER NOT NULL, `authority` TEXT NOT NULL, `lastSync` INTEGER NOT NULL, FOREIGN KEY(`collectionId`) REFERENCES `collection`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "id",
|
||||
"columnName": "id",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "collectionId",
|
||||
"columnName": "collectionId",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "authority",
|
||||
"columnName": "authority",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "lastSync",
|
||||
"columnName": "lastSync",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"autoGenerate": true,
|
||||
"columnNames": [
|
||||
"id"
|
||||
]
|
||||
},
|
||||
"indices": [
|
||||
{
|
||||
"name": "index_syncstats_collectionId_authority",
|
||||
"unique": true,
|
||||
"columnNames": [
|
||||
"collectionId",
|
||||
"authority"
|
||||
],
|
||||
"orders": [],
|
||||
"createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_syncstats_collectionId_authority` ON `${TABLE_NAME}` (`collectionId`, `authority`)"
|
||||
}
|
||||
],
|
||||
"foreignKeys": [
|
||||
{
|
||||
"table": "collection",
|
||||
"onDelete": "CASCADE",
|
||||
"onUpdate": "NO ACTION",
|
||||
"columns": [
|
||||
"collectionId"
|
||||
],
|
||||
"referencedColumns": [
|
||||
"id"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"tableName": "webdav_document",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `mountId` INTEGER NOT NULL, `parentId` INTEGER, `name` TEXT NOT NULL, `isDirectory` INTEGER NOT NULL, `displayName` TEXT, `mimeType` TEXT, `eTag` TEXT, `lastModified` INTEGER, `size` INTEGER, `mayBind` INTEGER, `mayUnbind` INTEGER, `mayWriteContent` INTEGER, `quotaAvailable` INTEGER, `quotaUsed` INTEGER, FOREIGN KEY(`mountId`) REFERENCES `webdav_mount`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`parentId`) REFERENCES `webdav_document`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "id",
|
||||
"columnName": "id",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "mountId",
|
||||
"columnName": "mountId",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "parentId",
|
||||
"columnName": "parentId",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "name",
|
||||
"columnName": "name",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "isDirectory",
|
||||
"columnName": "isDirectory",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "displayName",
|
||||
"columnName": "displayName",
|
||||
"affinity": "TEXT",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "mimeType",
|
||||
"columnName": "mimeType",
|
||||
"affinity": "TEXT",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "eTag",
|
||||
"columnName": "eTag",
|
||||
"affinity": "TEXT",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "lastModified",
|
||||
"columnName": "lastModified",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "size",
|
||||
"columnName": "size",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "mayBind",
|
||||
"columnName": "mayBind",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "mayUnbind",
|
||||
"columnName": "mayUnbind",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "mayWriteContent",
|
||||
"columnName": "mayWriteContent",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "quotaAvailable",
|
||||
"columnName": "quotaAvailable",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "quotaUsed",
|
||||
"columnName": "quotaUsed",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": false
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"autoGenerate": true,
|
||||
"columnNames": [
|
||||
"id"
|
||||
]
|
||||
},
|
||||
"indices": [
|
||||
{
|
||||
"name": "index_webdav_document_mountId_parentId_name",
|
||||
"unique": true,
|
||||
"columnNames": [
|
||||
"mountId",
|
||||
"parentId",
|
||||
"name"
|
||||
],
|
||||
"orders": [],
|
||||
"createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_webdav_document_mountId_parentId_name` ON `${TABLE_NAME}` (`mountId`, `parentId`, `name`)"
|
||||
},
|
||||
{
|
||||
"name": "index_webdav_document_parentId",
|
||||
"unique": false,
|
||||
"columnNames": [
|
||||
"parentId"
|
||||
],
|
||||
"orders": [],
|
||||
"createSql": "CREATE INDEX IF NOT EXISTS `index_webdav_document_parentId` ON `${TABLE_NAME}` (`parentId`)"
|
||||
}
|
||||
],
|
||||
"foreignKeys": [
|
||||
{
|
||||
"table": "webdav_mount",
|
||||
"onDelete": "CASCADE",
|
||||
"onUpdate": "NO ACTION",
|
||||
"columns": [
|
||||
"mountId"
|
||||
],
|
||||
"referencedColumns": [
|
||||
"id"
|
||||
]
|
||||
},
|
||||
{
|
||||
"table": "webdav_document",
|
||||
"onDelete": "CASCADE",
|
||||
"onUpdate": "NO ACTION",
|
||||
"columns": [
|
||||
"parentId"
|
||||
],
|
||||
"referencedColumns": [
|
||||
"id"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"tableName": "webdav_mount",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL, `url` TEXT NOT NULL)",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "id",
|
||||
"columnName": "id",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "name",
|
||||
"columnName": "name",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "url",
|
||||
"columnName": "url",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"autoGenerate": true,
|
||||
"columnNames": [
|
||||
"id"
|
||||
]
|
||||
},
|
||||
"indices": [],
|
||||
"foreignKeys": []
|
||||
}
|
||||
],
|
||||
"views": [],
|
||||
"setupQueries": [
|
||||
"CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
|
||||
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '2ff7560d957e03a78b4b7de88aa9593b')"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
package at.bitfire.davdroid
|
||||
|
||||
import at.bitfire.davdroid.push.PushRegistrationWorkerManager
|
||||
import at.bitfire.davdroid.push.PushRegistrationWorker
|
||||
import at.bitfire.davdroid.repository.DavCollectionRepository
|
||||
import at.bitfire.davdroid.startup.StartupPlugin
|
||||
import at.bitfire.davdroid.startup.TasksAppWatcher
|
||||
@@ -9,26 +9,30 @@ import dagger.hilt.components.SingletonComponent
|
||||
import dagger.hilt.testing.TestInstallIn
|
||||
import dagger.multibindings.Multibinds
|
||||
|
||||
// remove PushRegistrationWorkerModule from Android tests
|
||||
@Module
|
||||
@TestInstallIn(
|
||||
components = [SingletonComponent::class],
|
||||
replaces = [PushRegistrationWorkerManager.PushRegistrationWorkerModule::class]
|
||||
)
|
||||
abstract class TestPushRegistrationWorkerModule {
|
||||
// provides empty set of listeners
|
||||
@Multibinds
|
||||
abstract fun empty(): Set<DavCollectionRepository.OnChangeListener>
|
||||
}
|
||||
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 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>
|
||||
}
|
||||
@@ -1,165 +0,0 @@
|
||||
/*
|
||||
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
|
||||
*/
|
||||
|
||||
package at.bitfire.davdroid.db
|
||||
|
||||
import android.content.Context
|
||||
import androidx.room.testing.MigrationTestHelper
|
||||
import androidx.sqlite.db.SupportSQLiteDatabase
|
||||
import androidx.sqlite.db.framework.FrameworkSQLiteOpenHelperFactory
|
||||
import androidx.test.filters.SdkSuppress
|
||||
import androidx.test.platform.app.InstrumentationRegistry
|
||||
import at.bitfire.davdroid.db.Collection.Companion.TYPE_CALENDAR
|
||||
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.Assert.assertNull
|
||||
import org.junit.Before
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import javax.inject.Inject
|
||||
|
||||
@HiltAndroidTest
|
||||
class AppDatabaseMigrationsTest {
|
||||
|
||||
@get:Rule
|
||||
val hiltRule = HiltAndroidRule(this)
|
||||
|
||||
@Inject
|
||||
@ApplicationContext
|
||||
lateinit var context: Context
|
||||
|
||||
@Before
|
||||
fun setup() {
|
||||
hiltRule.inject()
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Used for testing the migration process between two versions.
|
||||
*
|
||||
* @param fromVersion The version from which to start testing
|
||||
* @param toVersion The target version to test
|
||||
* @param prepare Callback to prepare the database. Will be run with database schema in version [fromVersion].
|
||||
* @param validate Callback to validate the migration result. Will be run with database schema in version [toVersion].
|
||||
*/
|
||||
private fun testMigration(
|
||||
fromVersion: Int,
|
||||
toVersion: Int,
|
||||
prepare: (SupportSQLiteDatabase) -> Unit,
|
||||
validate: (SupportSQLiteDatabase) -> Unit
|
||||
) {
|
||||
val helper = MigrationTestHelper(
|
||||
InstrumentationRegistry.getInstrumentation(),
|
||||
AppDatabase::class.java,
|
||||
AppDatabase.getAutoMigrationSpecs(context),
|
||||
FrameworkSQLiteOpenHelperFactory()
|
||||
)
|
||||
|
||||
// Prepare the database with the initial version.
|
||||
helper.createDatabase(TEST_DB, version = fromVersion).apply {
|
||||
prepare(this)
|
||||
close()
|
||||
}
|
||||
|
||||
// Re-open the database with the new version and provide all the migrations.
|
||||
val db = helper.runMigrationsAndValidate(
|
||||
name = TEST_DB,
|
||||
version = toVersion,
|
||||
validateDroppedTables = true,
|
||||
migrations = AppDatabase.manualMigrations
|
||||
)
|
||||
|
||||
validate(db)
|
||||
}
|
||||
|
||||
|
||||
@Test
|
||||
@SdkSuppress(minSdkVersion = 34)
|
||||
fun migrate15To16_WithTimeZone() {
|
||||
testMigration(
|
||||
fromVersion = 15,
|
||||
toVersion = 16,
|
||||
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.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
|
||||
@SdkSuppress(minSdkVersion = 34)
|
||||
fun migrate15To16_WithTimeZone_Unparseable() {
|
||||
testMigration(
|
||||
fromVersion = 15,
|
||||
toVersion = 16,
|
||||
prepare = { db ->
|
||||
db.execSQL(
|
||||
"INSERT INTO service (id, accountName, type) VALUES (?, ?, ?)",
|
||||
arrayOf(1, "test", Service.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
|
||||
@SdkSuppress(minSdkVersion = 34)
|
||||
fun migrate15To16_WithoutTimezone() {
|
||||
testMigration(
|
||||
fromVersion = 15,
|
||||
toVersion = 16,
|
||||
prepare = { db ->
|
||||
db.execSQL(
|
||||
"INSERT INTO service (id, accountName, type) VALUES (?, ?, ?)",
|
||||
arrayOf(1, "test", Service.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))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
companion object {
|
||||
const val TEST_DB = "test"
|
||||
}
|
||||
|
||||
}
|
||||
@@ -9,64 +9,42 @@ import androidx.room.Room
|
||||
import androidx.room.testing.MigrationTestHelper
|
||||
import androidx.sqlite.db.framework.FrameworkSQLiteOpenHelperFactory
|
||||
import androidx.test.platform.app.InstrumentationRegistry
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import dagger.hilt.android.testing.HiltAndroidRule
|
||||
import dagger.hilt.android.testing.HiltAndroidTest
|
||||
import org.junit.Before
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import java.util.logging.Logger
|
||||
import javax.inject.Inject
|
||||
|
||||
@HiltAndroidTest
|
||||
class AppDatabaseTest {
|
||||
|
||||
@get:Rule
|
||||
val hiltRule = HiltAndroidRule(this)
|
||||
val TEST_DB = "test"
|
||||
|
||||
@Inject
|
||||
@ApplicationContext
|
||||
lateinit var context: Context
|
||||
val context: Context = InstrumentationRegistry.getInstrumentation().targetContext
|
||||
|
||||
@Inject
|
||||
lateinit var logger: Logger
|
||||
|
||||
@Before
|
||||
fun setup() {
|
||||
hiltRule.inject()
|
||||
}
|
||||
@Rule
|
||||
@JvmField
|
||||
val helper = MigrationTestHelper(
|
||||
InstrumentationRegistry.getInstrumentation(),
|
||||
AppDatabase::class.java,
|
||||
listOf(), // no auto migrations until v8
|
||||
FrameworkSQLiteOpenHelperFactory()
|
||||
)
|
||||
|
||||
|
||||
/**
|
||||
* Creates a database with schema version 8 (the first exported one) and then migrates it to the latest version.
|
||||
*/
|
||||
@Test
|
||||
fun testAllMigrations() {
|
||||
// Create DB with v8
|
||||
MigrationTestHelper(
|
||||
InstrumentationRegistry.getInstrumentation(),
|
||||
AppDatabase::class.java,
|
||||
listOf(), // no auto migrations until v8
|
||||
FrameworkSQLiteOpenHelperFactory()
|
||||
).createDatabase(TEST_DB, 8).close()
|
||||
// DB schema is available since version 8, so create DB with v8
|
||||
helper.createDatabase(TEST_DB, 8).close()
|
||||
|
||||
// open and migrate (to current version) database
|
||||
Room.databaseBuilder(context, AppDatabase::class.java, TEST_DB)
|
||||
val db = Room.databaseBuilder(context, AppDatabase::class.java, TEST_DB)
|
||||
// manual migrations
|
||||
.addMigrations(*AppDatabase.manualMigrations)
|
||||
.addMigrations(*AppDatabase.migrations)
|
||||
// auto-migrations that need to be specified explicitly
|
||||
.apply {
|
||||
for (spec in AppDatabase.getAutoMigrationSpecs(context))
|
||||
addAutoMigrationSpec(spec)
|
||||
}
|
||||
.addAutoMigrationSpec(AppDatabase.AutoMigration11_12(context))
|
||||
.build()
|
||||
.openHelper.writableDatabase // this will run all migrations
|
||||
.close()
|
||||
}
|
||||
|
||||
|
||||
companion object {
|
||||
const val TEST_DB = "test"
|
||||
try {
|
||||
// open (with version 8) + migrate (to current version) database
|
||||
db.openHelper.writableDatabase
|
||||
} finally {
|
||||
db.close()
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -92,93 +92,35 @@ class CollectionTest {
|
||||
|
||||
@Test
|
||||
@SmallTest
|
||||
fun testFromDavResponseCalendar_FullTimezone() {
|
||||
fun testFromDavResponseCalendar() {
|
||||
// read-only calendar, no display name
|
||||
server.enqueue(MockResponse()
|
||||
.setResponseCode(207)
|
||||
.setBody("<multistatus xmlns='DAV:' xmlns:CAL='urn:ietf:params:xml:ns:caldav' xmlns:ICAL='http://apple.com/ns/ical/'>" +
|
||||
"<response>" +
|
||||
" <href>/</href>" +
|
||||
" <propstat><prop>" +
|
||||
" <resourcetype><collection/><CAL:calendar/></resourcetype>" +
|
||||
" <current-user-privilege-set><privilege><read/></privilege></current-user-privilege-set>" +
|
||||
" <CAL:calendar-description>My Calendar</CAL:calendar-description>" +
|
||||
" <CAL:calendar-timezone>BEGIN:VCALENDAR\n" +
|
||||
"PRODID:-//Example Corp.//CalDAV Client//EN\n" +
|
||||
"VERSION:2.0\n" +
|
||||
"BEGIN:VTIMEZONE\n" +
|
||||
"TZID:US-Eastern\n" +
|
||||
"LAST-MODIFIED:19870101T000000Z\n" +
|
||||
"BEGIN:STANDARD\n" +
|
||||
"DTSTART:19671029T020000\n" +
|
||||
"RRULE:FREQ=YEARLY;BYDAY=-1SU;BYMONTH=10\n" +
|
||||
"TZOFFSETFROM:-0400\n" +
|
||||
"TZOFFSETTO:-0500\n" +
|
||||
"TZNAME:Eastern Standard Time (US & Canada)\n" +
|
||||
"END:STANDARD\n" +
|
||||
"BEGIN:DAYLIGHT\n" +
|
||||
"DTSTART:19870405T020000\n" +
|
||||
"RRULE:FREQ=YEARLY;BYDAY=1SU;BYMONTH=4\n" +
|
||||
"TZOFFSETFROM:-0500\n" +
|
||||
"TZOFFSETTO:-0400\n" +
|
||||
"TZNAME:Eastern Daylight Time (US & Canada)\n" +
|
||||
"END:DAYLIGHT\n" +
|
||||
"END:VTIMEZONE\n" +
|
||||
"END:VCALENDAR\n" +
|
||||
"</CAL:calendar-timezone>" +
|
||||
" <ICAL:calendar-color>#ff0000</ICAL:calendar-color>" +
|
||||
" </prop></propstat>" +
|
||||
"</response>" +
|
||||
"</multistatus>"))
|
||||
.setResponseCode(207)
|
||||
.setBody("<multistatus xmlns='DAV:' xmlns:CAL='urn:ietf:params:xml:ns:caldav' xmlns:ICAL='http://apple.com/ns/ical/'>" +
|
||||
"<response>" +
|
||||
" <href>/</href>" +
|
||||
" <propstat><prop>" +
|
||||
" <resourcetype><collection/><CAL:calendar/></resourcetype>" +
|
||||
" <current-user-privilege-set><privilege><read/></privilege></current-user-privilege-set>" +
|
||||
" <CAL:calendar-description>My Calendar</CAL:calendar-description>" +
|
||||
" <CAL:calendar-timezone>tzdata</CAL:calendar-timezone>" +
|
||||
" <ICAL:calendar-color>#ff0000</ICAL:calendar-color>" +
|
||||
" </prop></propstat>" +
|
||||
"</response>" +
|
||||
"</multistatus>"))
|
||||
|
||||
lateinit var info: Collection
|
||||
DavResource(httpClient.okHttpClient, server.url("/"))
|
||||
.propfind(0, ResourceType.NAME) { response, _ ->
|
||||
info = Collection.fromDavResponse(response)!!
|
||||
}
|
||||
.propfind(0, ResourceType.NAME) { response, _ ->
|
||||
info = Collection.fromDavResponse(response) ?: throw IllegalArgumentException()
|
||||
}
|
||||
assertEquals(Collection.TYPE_CALENDAR, info.type)
|
||||
assertFalse(info.privWriteContent)
|
||||
assertFalse(info.privUnbind)
|
||||
assertNull(info.displayName)
|
||||
assertEquals("My Calendar", info.description)
|
||||
assertEquals(0xFFFF0000.toInt(), info.color)
|
||||
assertEquals("US-Eastern", info.timezoneId)
|
||||
assertTrue(info.supportsVEVENT!!)
|
||||
assertTrue(info.supportsVTODO!!)
|
||||
assertTrue(info.supportsVJOURNAL!!)
|
||||
}
|
||||
|
||||
@Test
|
||||
@SmallTest
|
||||
fun testFromDavResponseCalendar_OnlyTzId() {
|
||||
// read-only calendar, no display name
|
||||
server.enqueue(MockResponse()
|
||||
.setResponseCode(207)
|
||||
.setBody("<multistatus xmlns='DAV:' xmlns:CAL='urn:ietf:params:xml:ns:caldav' xmlns:ICAL='http://apple.com/ns/ical/'>" +
|
||||
"<response>" +
|
||||
" <href>/</href>" +
|
||||
" <propstat><prop>" +
|
||||
" <resourcetype><collection/><CAL:calendar/></resourcetype>" +
|
||||
" <current-user-privilege-set><privilege><read/></privilege></current-user-privilege-set>" +
|
||||
" <CAL:calendar-description>My Calendar</CAL:calendar-description>" +
|
||||
" <CAL:calendar-timezone-id>US-Eastern</CAL:calendar-timezone-id>" +
|
||||
" <ICAL:calendar-color>#ff0000</ICAL:calendar-color>" +
|
||||
" </prop></propstat>" +
|
||||
"</response>" +
|
||||
"</multistatus>"))
|
||||
|
||||
lateinit var info: Collection
|
||||
DavResource(httpClient.okHttpClient, server.url("/"))
|
||||
.propfind(0, ResourceType.NAME) { response, _ ->
|
||||
info = Collection.fromDavResponse(response)!!
|
||||
}
|
||||
assertEquals(Collection.TYPE_CALENDAR, info.type)
|
||||
assertFalse(info.privWriteContent)
|
||||
assertFalse(info.privUnbind)
|
||||
assertNull(info.displayName)
|
||||
assertEquals("My Calendar", info.description)
|
||||
assertEquals(0xFFFF0000.toInt(), info.color)
|
||||
assertEquals("US-Eastern", info.timezoneId)
|
||||
assertEquals("tzdata", info.timezone)
|
||||
assertTrue(info.supportsVEVENT!!)
|
||||
assertTrue(info.supportsVTODO!!)
|
||||
assertTrue(info.supportsVJOURNAL!!)
|
||||
|
||||
@@ -26,12 +26,8 @@ class MemoryDbModule {
|
||||
@Singleton
|
||||
fun inMemoryDatabase(@ApplicationContext context: Context): AppDatabase =
|
||||
Room.inMemoryDatabaseBuilder(context, AppDatabase::class.java)
|
||||
// auto-migration specs that need to be specified explicitly
|
||||
.apply {
|
||||
for (spec in AppDatabase.getAutoMigrationSpecs(context)) {
|
||||
addAutoMigrationSpec(spec)
|
||||
}
|
||||
}
|
||||
// auto-migrations that need to be specified explicitly
|
||||
.addAutoMigrationSpec(AppDatabase.AutoMigration11_12(context))
|
||||
.build()
|
||||
|
||||
}
|
||||
@@ -5,7 +5,6 @@ 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
|
||||
@@ -64,17 +63,7 @@ class DavCollectionRepositoryTest {
|
||||
)
|
||||
)
|
||||
val testObserver = mockk<DavCollectionRepository.OnChangeListener>(relaxed = true)
|
||||
val collectionRepository = DavCollectionRepository(
|
||||
accountSettingsFactory,
|
||||
context,
|
||||
db,
|
||||
object : Lazy<Set<DavCollectionRepository.OnChangeListener>> {
|
||||
override fun get(): Set<DavCollectionRepository.OnChangeListener> {
|
||||
return mutableSetOf(testObserver)
|
||||
}
|
||||
},
|
||||
serviceRepository
|
||||
)
|
||||
val collectionRepository = DavCollectionRepository(accountSettingsFactory, context, db, mutableSetOf(testObserver), serviceRepository)
|
||||
|
||||
assert(db.collectionDao().get(collectionId)?.forceReadOnly == false)
|
||||
verify(exactly = 0) {
|
||||
|
||||
@@ -1,222 +0,0 @@
|
||||
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))
|
||||
}
|
||||
|
||||
}
|
||||
@@ -6,7 +6,6 @@ package at.bitfire.davdroid.resource
|
||||
|
||||
import android.Manifest
|
||||
import android.accounts.Account
|
||||
import android.accounts.AccountManager
|
||||
import android.content.ContentProviderClient
|
||||
import android.content.ContentUris
|
||||
import android.content.Context
|
||||
@@ -20,6 +19,8 @@ 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
|
||||
@@ -30,8 +31,6 @@ 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 {
|
||||
@@ -46,7 +45,6 @@ class LocalAddressBookTest {
|
||||
@ApplicationContext
|
||||
lateinit var context: Context
|
||||
|
||||
val account = Account("Test Account", "Test Account Type")
|
||||
lateinit var addressBook: LocalTestAddressBook
|
||||
|
||||
|
||||
@@ -54,15 +52,14 @@ class LocalAddressBookTest {
|
||||
fun setUp() {
|
||||
hiltRule.inject()
|
||||
|
||||
addressBook = addressbookFactory.create(account, provider, GroupMethod.CATEGORIES)
|
||||
addressBook = addressbookFactory.create(provider, GroupMethod.CATEGORIES)
|
||||
LocalTestAddressBook.createAccount(context)
|
||||
}
|
||||
|
||||
@After
|
||||
fun tearDown() {
|
||||
// remove address book
|
||||
val accountManager = AccountManager.get(context)
|
||||
accountManager.removeAccountExplicitly(addressBook.addressBookAccount)
|
||||
addressBook.deleteCollection()
|
||||
}
|
||||
|
||||
|
||||
@@ -126,6 +123,8 @@ class LocalAddressBookTest {
|
||||
assertEquals("Test Group", group.displayName)
|
||||
}
|
||||
|
||||
|
||||
|
||||
companion object {
|
||||
|
||||
@JvmField
|
||||
|
||||
@@ -66,7 +66,7 @@ class LocalCalendarTest {
|
||||
|
||||
@After
|
||||
fun tearDown() {
|
||||
calendar.delete()
|
||||
calendar.deleteCollection()
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -12,7 +12,6 @@ import android.os.Build
|
||||
import android.provider.CalendarContract
|
||||
import android.provider.CalendarContract.ACCOUNT_TYPE_LOCAL
|
||||
import android.provider.CalendarContract.Events
|
||||
import androidx.datastore.dataStore
|
||||
import androidx.test.platform.app.InstrumentationRegistry
|
||||
import at.bitfire.davdroid.InitCalendarProviderRule
|
||||
import at.bitfire.ical4android.AndroidCalendar
|
||||
@@ -75,7 +74,7 @@ class LocalEventTest {
|
||||
|
||||
@After
|
||||
fun removeCalendar() {
|
||||
calendar.delete()
|
||||
calendar.deleteCollection()
|
||||
}
|
||||
|
||||
|
||||
@@ -283,7 +282,7 @@ class LocalEventTest {
|
||||
})
|
||||
}
|
||||
val localEvent = LocalEvent(calendar, event, "filename.ics", null, null, 0)
|
||||
localEvent.add()
|
||||
val uri = localEvent.add()
|
||||
|
||||
calendar.findById(localEvent.id!!)
|
||||
|
||||
|
||||
@@ -5,7 +5,6 @@
|
||||
package at.bitfire.davdroid.resource
|
||||
|
||||
import android.Manifest
|
||||
import android.accounts.Account
|
||||
import android.content.ContentProviderClient
|
||||
import android.content.ContentUris
|
||||
import android.content.ContentValues
|
||||
@@ -70,7 +69,7 @@ class LocalGroupTest {
|
||||
@Inject
|
||||
lateinit var addressbookFactory: LocalTestAddressBook.Factory
|
||||
|
||||
val account = Account("Test Account", "Test Account Type")
|
||||
|
||||
private lateinit var addressBookGroupsAsCategories: LocalTestAddressBook
|
||||
private lateinit var addressBookGroupsAsVCards: LocalTestAddressBook
|
||||
|
||||
@@ -78,8 +77,8 @@ class LocalGroupTest {
|
||||
fun setup() {
|
||||
hiltRule.inject()
|
||||
|
||||
addressBookGroupsAsCategories = addressbookFactory.create(account, provider, GroupMethod.CATEGORIES)
|
||||
addressBookGroupsAsVCards = addressbookFactory.create(account, provider, GroupMethod.GROUP_VCARDS)
|
||||
addressBookGroupsAsCategories = addressbookFactory.create(provider, GroupMethod.CATEGORIES)
|
||||
addressBookGroupsAsVCards = addressbookFactory.create(provider, GroupMethod.GROUP_VCARDS)
|
||||
|
||||
// clear contacts
|
||||
addressBookGroupsAsCategories.clear()
|
||||
|
||||
@@ -13,7 +13,6 @@ import android.provider.ContactsContract
|
||||
import at.bitfire.davdroid.repository.DavCollectionRepository
|
||||
import at.bitfire.davdroid.repository.DavServiceRepository
|
||||
import at.bitfire.davdroid.settings.AccountSettings
|
||||
import at.bitfire.davdroid.sync.SyncFrameworkIntegration
|
||||
import at.bitfire.vcard4android.GroupMethod
|
||||
import dagger.assisted.Assisted
|
||||
import dagger.assisted.AssistedFactory
|
||||
@@ -21,35 +20,21 @@ import dagger.assisted.AssistedInject
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import org.junit.Assert.assertTrue
|
||||
import java.io.FileNotFoundException
|
||||
import java.util.Optional
|
||||
import java.util.logging.Logger
|
||||
|
||||
class LocalTestAddressBook @AssistedInject constructor(
|
||||
@Assisted account: Account,
|
||||
@Assisted provider: ContentProviderClient,
|
||||
@Assisted override val groupMethod: GroupMethod,
|
||||
accountSettingsFactory: AccountSettings.Factory,
|
||||
collectionRepository: DavCollectionRepository,
|
||||
@ApplicationContext context: Context,
|
||||
logger: Logger,
|
||||
serviceRepository: DavServiceRepository,
|
||||
syncFramework: SyncFrameworkIntegration
|
||||
): LocalAddressBook(
|
||||
account = account,
|
||||
_addressBookAccount = ACCOUNT,
|
||||
provider = provider,
|
||||
accountSettingsFactory = accountSettingsFactory,
|
||||
collectionRepository = collectionRepository,
|
||||
context = context,
|
||||
dirtyVerifier = Optional.empty(),
|
||||
logger = logger,
|
||||
serviceRepository = serviceRepository,
|
||||
syncFramework = syncFramework
|
||||
) {
|
||||
serviceRepository: DavServiceRepository
|
||||
): LocalAddressBook(ACCOUNT, provider, accountSettingsFactory, collectionRepository, context, logger, serviceRepository) {
|
||||
|
||||
@AssistedFactory
|
||||
interface Factory {
|
||||
fun create(account: Account, provider: ContentProviderClient, groupMethod: GroupMethod): LocalTestAddressBook
|
||||
fun create(provider: ContentProviderClient, groupMethod: GroupMethod): LocalTestAddressBook
|
||||
}
|
||||
|
||||
override var readOnly: Boolean
|
||||
|
||||
@@ -5,7 +5,6 @@
|
||||
package at.bitfire.davdroid.resource.contactrow
|
||||
|
||||
import android.Manifest
|
||||
import android.accounts.Account
|
||||
import android.content.ContentProviderClient
|
||||
import android.content.ContentValues
|
||||
import android.content.Context
|
||||
@@ -66,8 +65,6 @@ class CachedGroupMembershipHandlerTest {
|
||||
@get:Rule
|
||||
val hiltRule = HiltAndroidRule(this)
|
||||
|
||||
val account = Account("Test Account", "Test Account Type")
|
||||
|
||||
@Before
|
||||
fun inject() {
|
||||
hiltRule.inject()
|
||||
@@ -76,7 +73,7 @@ class CachedGroupMembershipHandlerTest {
|
||||
|
||||
@Test
|
||||
fun testMembership() {
|
||||
val addressBook = addressbookFactory.create(account, provider, GroupMethod.GROUP_VCARDS)
|
||||
val addressBook = addressbookFactory.create(provider, GroupMethod.GROUP_VCARDS)
|
||||
|
||||
val contact = Contact()
|
||||
val localContact = LocalContact(addressBook, contact, null, null, 0)
|
||||
|
||||
@@ -5,7 +5,6 @@
|
||||
package at.bitfire.davdroid.resource.contactrow
|
||||
|
||||
import android.Manifest
|
||||
import android.accounts.Account
|
||||
import android.content.ContentProviderClient
|
||||
import android.content.Context
|
||||
import android.net.Uri
|
||||
@@ -64,8 +63,6 @@ class GroupMembershipBuilderTest {
|
||||
@ApplicationContext
|
||||
lateinit var context: Context
|
||||
|
||||
val account = Account("Test Account", "Test Account Type")
|
||||
|
||||
@Before
|
||||
fun inject() {
|
||||
hiltRule.inject()
|
||||
@@ -77,7 +74,7 @@ class GroupMembershipBuilderTest {
|
||||
val contact = Contact().apply {
|
||||
categories += "TEST GROUP"
|
||||
}
|
||||
val addressBookGroupsAsCategories = addressbookFactory.create(account, provider, GroupMethod.CATEGORIES)
|
||||
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])
|
||||
@@ -90,7 +87,7 @@ class GroupMembershipBuilderTest {
|
||||
val contact = Contact().apply {
|
||||
categories += "TEST GROUP"
|
||||
}
|
||||
val addressBookGroupsAsVCards = addressbookFactory.create(account, provider, GroupMethod.GROUP_VCARDS)
|
||||
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)
|
||||
|
||||
@@ -5,7 +5,6 @@
|
||||
package at.bitfire.davdroid.resource.contactrow
|
||||
|
||||
import android.Manifest
|
||||
import android.accounts.Account
|
||||
import android.content.ContentProviderClient
|
||||
import android.content.ContentValues
|
||||
import android.content.Context
|
||||
@@ -67,8 +66,6 @@ class GroupMembershipHandlerTest {
|
||||
@get:Rule
|
||||
var hiltRule = HiltAndroidRule(this)
|
||||
|
||||
val account = Account("Test Account", "Test Account Type")
|
||||
|
||||
@Before
|
||||
fun inject() {
|
||||
hiltRule.inject()
|
||||
@@ -77,7 +74,7 @@ class GroupMembershipHandlerTest {
|
||||
|
||||
@Test
|
||||
fun testMembership_GroupsAsCategories() {
|
||||
val addressBookGroupsAsCategories = addressbookFactory.create(account, provider, GroupMethod.CATEGORIES)
|
||||
val addressBookGroupsAsCategories = addressbookFactory.create(provider, GroupMethod.CATEGORIES)
|
||||
val addressBookGroupsAsCategoriesGroup = addressBookGroupsAsCategories.findOrCreateGroup("TEST GROUP")
|
||||
|
||||
val contact = Contact()
|
||||
@@ -93,7 +90,7 @@ class GroupMembershipHandlerTest {
|
||||
|
||||
@Test
|
||||
fun testMembership_GroupsAsVCards() {
|
||||
val addressBookGroupsAsVCards = addressbookFactory.create(account, provider, GroupMethod.GROUP_VCARDS)
|
||||
val addressBookGroupsAsVCards = addressbookFactory.create(provider, GroupMethod.GROUP_VCARDS)
|
||||
|
||||
val contact = Contact()
|
||||
val localContact = LocalContact(addressBookGroupsAsVCards, contact, null, null, 0)
|
||||
|
||||
@@ -1,58 +0,0 @@
|
||||
/*
|
||||
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
|
||||
*/
|
||||
|
||||
package at.bitfire.davdroid.settings
|
||||
|
||||
import android.accounts.AccountManager
|
||||
import android.content.Context
|
||||
import at.bitfire.davdroid.sync.account.TestAccountAuthenticator
|
||||
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()
|
||||
}
|
||||
|
||||
|
||||
@Test(expected = IllegalArgumentException::class)
|
||||
fun testUpdate_MissingMigrations() {
|
||||
TestAccountAuthenticator.provide(version = 1) { account ->
|
||||
// will run AccountSettings.update
|
||||
accountSettingsFactory.create(account, abortOnMissingMigration = true)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testUpdate_RunAllMigrations() {
|
||||
TestAccountAuthenticator.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).toIntOrNull()
|
||||
assertEquals(AccountSettings.CURRENT_VERSION, version)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,102 +0,0 @@
|
||||
/*
|
||||
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
|
||||
*/
|
||||
|
||||
package at.bitfire.davdroid.settings.migration
|
||||
|
||||
import android.accounts.Account
|
||||
import android.accounts.AccountManager
|
||||
import android.content.Context
|
||||
import androidx.test.rule.GrantPermissionRule
|
||||
import at.bitfire.davdroid.R
|
||||
import at.bitfire.davdroid.db.AppDatabase
|
||||
import at.bitfire.davdroid.db.Collection
|
||||
import at.bitfire.davdroid.db.Service
|
||||
import at.bitfire.davdroid.resource.LocalAddressBook
|
||||
import at.bitfire.davdroid.sync.account.TestAccountAuthenticator
|
||||
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 io.mockk.mockk
|
||||
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() {
|
||||
TestAccountAuthenticator.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, mockk())
|
||||
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,135 +0,0 @@
|
||||
/*
|
||||
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
|
||||
*/
|
||||
|
||||
package at.bitfire.davdroid.settings.migration
|
||||
|
||||
import android.accounts.Account
|
||||
import android.accounts.AccountManager
|
||||
import android.content.Context
|
||||
import at.bitfire.davdroid.R
|
||||
import at.bitfire.davdroid.db.AppDatabase
|
||||
import at.bitfire.davdroid.db.Collection
|
||||
import at.bitfire.davdroid.db.Service
|
||||
import at.bitfire.davdroid.resource.LocalAddressBook
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import dagger.hilt.android.testing.HiltAndroidRule
|
||||
import dagger.hilt.android.testing.HiltAndroidTest
|
||||
import io.mockk.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, mockk())
|
||||
|
||||
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, mockk())
|
||||
|
||||
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, mockk())
|
||||
|
||||
verify {
|
||||
accountManager.setUserData(addressBookAccount, LocalAddressBook.USER_DATA_ACCOUNT_NAME, account.name)
|
||||
accountManager.setUserData(addressBookAccount, LocalAddressBook.USER_DATA_ACCOUNT_TYPE, account.type)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -21,6 +21,8 @@ class LocalTestCollection(
|
||||
override val readOnly: Boolean
|
||||
get() = throw NotImplementedError()
|
||||
|
||||
override fun deleteCollection(): Boolean = true
|
||||
|
||||
override fun findDeleted() = entries.filter { it.deleted }
|
||||
override fun findDirty() = entries.filter { it.dirty }
|
||||
|
||||
|
||||
@@ -42,6 +42,7 @@ 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
|
||||
|
||||
@@ -7,42 +7,56 @@ package at.bitfire.davdroid.sync
|
||||
import android.accounts.Account
|
||||
import android.content.ContentProviderClient
|
||||
import at.bitfire.davdroid.db.Collection
|
||||
import at.bitfire.davdroid.resource.LocalDataStore
|
||||
import at.bitfire.davdroid.sync.account.TestAccountAuthenticator
|
||||
import dagger.assisted.Assisted
|
||||
import dagger.assisted.AssistedFactory
|
||||
import dagger.assisted.AssistedInject
|
||||
import dagger.hilt.android.testing.HiltAndroidRule
|
||||
import dagger.hilt.android.testing.HiltAndroidTest
|
||||
import io.mockk.every
|
||||
import io.mockk.impl.annotations.InjectMockKs
|
||||
import io.mockk.impl.annotations.RelaxedMockK
|
||||
import io.mockk.impl.annotations.SpyK
|
||||
import io.mockk.junit4.MockKRule
|
||||
import io.mockk.just
|
||||
import io.mockk.mockk
|
||||
import io.mockk.runs
|
||||
import io.mockk.spyk
|
||||
import io.mockk.verify
|
||||
import okhttp3.HttpUrl.Companion.toHttpUrl
|
||||
import org.junit.After
|
||||
import org.junit.Assert.assertArrayEquals
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Before
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import java.util.logging.Logger
|
||||
import javax.inject.Inject
|
||||
|
||||
@HiltAndroidTest
|
||||
class SyncerTest {
|
||||
|
||||
@get:Rule
|
||||
val mockkRule = MockKRule(this)
|
||||
val hiltRule = HiltAndroidRule(this)
|
||||
|
||||
@RelaxedMockK
|
||||
lateinit var logger: Logger
|
||||
@Inject
|
||||
lateinit var testSyncer: TestSyncer.Factory
|
||||
|
||||
val dataStore: LocalTestStore = mockk(relaxed = true)
|
||||
val provider: ContentProviderClient = mockk(relaxed = true)
|
||||
lateinit var account: Account
|
||||
|
||||
@SpyK
|
||||
@InjectMockKs
|
||||
var syncer = TestSyncer(mockk(relaxed = true), emptyArray(), SyncResult(), dataStore)
|
||||
private lateinit var syncer: TestSyncer
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
hiltRule.inject()
|
||||
|
||||
account = TestAccountAuthenticator.create()
|
||||
syncer = spyk(testSyncer.create(account, emptyArray(), SyncResult()))
|
||||
}
|
||||
|
||||
@After
|
||||
fun tearDown() {
|
||||
TestAccountAuthenticator.remove(account)
|
||||
}
|
||||
|
||||
|
||||
@Test
|
||||
fun testSync_prepare_fails() {
|
||||
val provider = mockk<ContentProviderClient>()
|
||||
every { syncer.prepare(provider) } returns false
|
||||
every { syncer.getSyncEnabledCollections() } returns emptyMap()
|
||||
|
||||
@@ -54,6 +68,7 @@ class SyncerTest {
|
||||
|
||||
@Test
|
||||
fun testSync_prepare_succeeds() {
|
||||
val provider = mockk<ContentProviderClient>()
|
||||
every { syncer.prepare(provider) } returns true
|
||||
every { syncer.getSyncEnabledCollections() } returns emptyMap()
|
||||
|
||||
@@ -68,12 +83,13 @@ 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) { dataStore.delete(localCollection) }
|
||||
verify(exactly = 1) { localCollection.deleteCollection() }
|
||||
|
||||
// Updated local collection list should be empty
|
||||
assertTrue(result.isEmpty())
|
||||
@@ -89,8 +105,8 @@ class SyncerTest {
|
||||
every { localCollection.title } returns "The Local Collection"
|
||||
|
||||
// Should update the localCollection if it exists
|
||||
val result = syncer.updateCollections(provider, listOf(localCollection), dbCollections)
|
||||
verify(exactly = 1) { dataStore.update(provider, localCollection, dbCollection) }
|
||||
val result = syncer.updateCollections(mockk(), listOf(localCollection), dbCollections)
|
||||
verify(exactly = 1) { syncer.update(localCollection, dbCollection) }
|
||||
|
||||
// Updated local collection list should be same as input
|
||||
assertArrayEquals(arrayOf(localCollection), result.toTypedArray())
|
||||
@@ -98,18 +114,12 @@ class SyncerTest {
|
||||
|
||||
@Test
|
||||
fun testUpdateCollections_findsNewCollection() {
|
||||
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
|
||||
val dbCollection = mockk<Collection>()
|
||||
every { dbCollection.url } returns "http://newly.found/collection".toHttpUrl()
|
||||
val dbCollections = mapOf(dbCollection.url to dbCollection)
|
||||
|
||||
// Should return the new collection, because it was not updated
|
||||
val result = syncer.updateCollections(provider, emptyList(), dbCollectionsMap)
|
||||
val result = syncer.updateCollections(mockk(), emptyList(), dbCollections)
|
||||
|
||||
// Updated local collection list contain new entry
|
||||
assertEquals(1, result.size)
|
||||
@@ -119,18 +129,21 @@ class SyncerTest {
|
||||
|
||||
@Test
|
||||
fun testCreateLocalCollections() {
|
||||
val provider = mockk<ContentProviderClient>()
|
||||
val localCollection = mockk<LocalTestCollection>()
|
||||
val dbCollection = mockk<Collection>()
|
||||
every { dataStore.create(provider, dbCollection) } returns localCollection
|
||||
every { syncer.create(provider, dbCollection) } returns localCollection
|
||||
every { dbCollection.url } returns "http://newly.found/collection".toHttpUrl()
|
||||
|
||||
// Should return list of newly created local collections
|
||||
val result = syncer.createLocalCollections(provider, listOf(dbCollection))
|
||||
assertEquals(listOf(localCollection), result)
|
||||
}
|
||||
|
||||
|
||||
|
||||
@Test
|
||||
fun testSyncCollectionContents() {
|
||||
val provider = mockk<ContentProviderClient>()
|
||||
val dbCollection1 = mockk<Collection>()
|
||||
val dbCollection2 = mockk<Collection>()
|
||||
val dbCollections = mapOf(
|
||||
@@ -142,7 +155,6 @@ 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)
|
||||
@@ -153,65 +165,41 @@ class SyncerTest {
|
||||
|
||||
// Test helpers
|
||||
|
||||
class TestSyncer (
|
||||
account: Account,
|
||||
extras: Array<String>,
|
||||
syncResult: SyncResult,
|
||||
theDataStore: LocalTestStore
|
||||
) : Syncer<LocalTestStore, LocalTestCollection>(account, extras, syncResult) {
|
||||
class TestSyncer @AssistedInject constructor(
|
||||
@Assisted account: Account,
|
||||
@Assisted extras: Array<String>,
|
||||
@Assisted syncResult: SyncResult
|
||||
) : Syncer<LocalTestCollection>(account, extras, syncResult) {
|
||||
|
||||
override val dataStore: LocalTestStore =
|
||||
theDataStore
|
||||
@AssistedFactory
|
||||
interface Factory {
|
||||
fun create(account: Account, extras: Array<String>, syncResult: SyncResult): TestSyncer
|
||||
}
|
||||
|
||||
override val authority: String
|
||||
get() = throw NotImplementedError()
|
||||
|
||||
get() = ""
|
||||
override val serviceType: String
|
||||
get() = throw NotImplementedError()
|
||||
get() = ""
|
||||
|
||||
override fun prepare(provider: ContentProviderClient): Boolean =
|
||||
throw NotImplementedError()
|
||||
true
|
||||
|
||||
override fun getLocalCollections(provider: ContentProviderClient): List<LocalTestCollection> =
|
||||
emptyList()
|
||||
|
||||
override fun getDbSyncCollections(serviceId: Long): List<Collection> =
|
||||
throw NotImplementedError()
|
||||
emptyList()
|
||||
|
||||
override fun create(provider: ContentProviderClient, remoteCollection: Collection): LocalTestCollection =
|
||||
LocalTestCollection(remoteCollection.url.toString())
|
||||
|
||||
override fun syncCollection(
|
||||
provider: ContentProviderClient,
|
||||
localCollection: LocalTestCollection,
|
||||
remoteCollection: Collection
|
||||
) {
|
||||
throw NotImplementedError()
|
||||
}
|
||||
) {}
|
||||
|
||||
}
|
||||
|
||||
class LocalTestStore : LocalDataStore<LocalTestCollection> {
|
||||
|
||||
override 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 update(localCollection: LocalTestCollection, remoteCollection: Collection) {}
|
||||
|
||||
}
|
||||
|
||||
|
||||
@@ -22,7 +22,7 @@ import org.junit.Test
|
||||
import javax.inject.Inject
|
||||
|
||||
@HiltAndroidTest
|
||||
class SystemAccountUtilsTest {
|
||||
class AccountUtilsTest {
|
||||
|
||||
@get:Rule
|
||||
val hiltRule = HiltAndroidRule(this)
|
||||
@@ -13,18 +13,16 @@ import androidx.work.testing.TestListenableWorkerBuilder
|
||||
import androidx.work.testing.WorkManagerTestInitHelper
|
||||
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.resource.LocalAddressBook.Companion.USER_DATA_COLLECTION_ID
|
||||
import at.bitfire.davdroid.settings.SettingsManager
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import dagger.hilt.android.testing.HiltAndroidRule
|
||||
import dagger.hilt.android.testing.HiltAndroidTest
|
||||
import io.mockk.every
|
||||
import io.mockk.mockkObject
|
||||
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
|
||||
@@ -88,50 +86,8 @@ class AccountsCleanupWorkerTest {
|
||||
|
||||
|
||||
@Test
|
||||
fun testCleanUpServices_noAccount() {
|
||||
// Insert service that reference to invalid account
|
||||
db.serviceDao().insertOrReplace(Service(id = 1, accountName = "test", type = Service.TYPE_CALDAV, principal = null))
|
||||
assertNotNull(db.serviceDao().get(1))
|
||||
|
||||
// Create worker and run the method
|
||||
val worker = TestListenableWorkerBuilder<AccountsCleanupWorker>(context)
|
||||
.setWorkerFactory(workerFactory)
|
||||
.build()
|
||||
worker.cleanUpServices()
|
||||
|
||||
// Verify that service is deleted
|
||||
assertNull(db.serviceDao().get(1))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testCleanUpServices_oneAccount() {
|
||||
val account = Account("test", "test")
|
||||
val accountManager = AccountManager.get(context)
|
||||
mockkObject(accountManager)
|
||||
every { accountManager.getAccountsByType(context.getString(R.string.account_type)) } returns arrayOf(account)
|
||||
|
||||
// Insert services, one that reference the existing account and one that references an invalid account
|
||||
db.serviceDao().insertOrReplace(Service(id = 1, accountName = account.name, type = Service.TYPE_CALDAV, principal = null))
|
||||
assertNotNull(db.serviceDao().get(1))
|
||||
|
||||
db.serviceDao().insertOrReplace(Service(id = 2, accountName = "not existing", type = Service.TYPE_CARDDAV, principal = null))
|
||||
assertNotNull(db.serviceDao().get(2))
|
||||
|
||||
// Create worker and run the method
|
||||
val worker = TestListenableWorkerBuilder<AccountsCleanupWorker>(context)
|
||||
.setWorkerFactory(workerFactory)
|
||||
.build()
|
||||
worker.cleanUpServices()
|
||||
|
||||
// Verify that one service is deleted and the other one is kept
|
||||
assertNotNull(db.serviceDao().get(1))
|
||||
assertNull(db.serviceDao().get(2))
|
||||
}
|
||||
|
||||
|
||||
@Test
|
||||
fun testCleanUpAddressBooks_deletesAddressBookWithoutAccount() {
|
||||
// Create address book account without corresponding account
|
||||
fun testDeleteOrphanedAddressBookAccounts_deletesAddressBookAccountWithoutCollection() {
|
||||
// Create address book account without corresponding collection
|
||||
assertTrue(accountManager.addAccountExplicitly(addressBookAccount, null, null))
|
||||
|
||||
val addressBookAccounts = accountManager.getAccountsByType(addressBookAccountType)
|
||||
@@ -139,36 +95,49 @@ class AccountsCleanupWorkerTest {
|
||||
|
||||
// Create worker and run the method
|
||||
val worker = TestListenableWorkerBuilder<AccountsCleanupWorker>(context)
|
||||
.setWorkerFactory(workerFactory)
|
||||
.setWorkerFactory(object: WorkerFactory() {
|
||||
override fun createWorker(appContext: Context, workerClassName: String, workerParameters: WorkerParameters) =
|
||||
accountsCleanupWorkerFactory.create(appContext, workerParameters)
|
||||
})
|
||||
.build()
|
||||
worker.cleanUpAddressBooks()
|
||||
worker.deleteOrphanedAddressBookAccounts(addressBookAccounts)
|
||||
|
||||
// Verify account was deleted
|
||||
assertTrue(accountManager.getAccountsByType(addressBookAccountType).isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testCleanUpAddressBooks_keepsAddressBookWithAccount() {
|
||||
TestAccountAuthenticator.provide() { account ->
|
||||
// Create address book account _with_ corresponding account and verify
|
||||
val userData = Bundle(2).apply {
|
||||
putString(LocalAddressBook.USER_DATA_ACCOUNT_NAME, account.name)
|
||||
putString(LocalAddressBook.USER_DATA_ACCOUNT_TYPE, account.type)
|
||||
}
|
||||
assertTrue(accountManager.addAccountExplicitly(addressBookAccount, null, userData))
|
||||
|
||||
val addressBookAccounts = accountManager.getAccountsByType(addressBookAccountType)
|
||||
assertEquals(addressBookAccount, addressBookAccounts.firstOrNull())
|
||||
|
||||
// Create worker and run the method
|
||||
val worker = TestListenableWorkerBuilder<AccountsCleanupWorker>(context)
|
||||
.setWorkerFactory(workerFactory)
|
||||
.build()
|
||||
worker.cleanUpAddressBooks()
|
||||
|
||||
// Verify account was _not_ deleted
|
||||
assertEquals(addressBookAccount, addressBookAccounts.firstOrNull())
|
||||
fun testDeleteOrphanedAddressBookAccounts_leavesAddressBookAccountWithCollection() {
|
||||
// Create address book account _with_ corresponding collection and verify
|
||||
val randomCollectionId = 12345L
|
||||
val userData = Bundle(1).apply {
|
||||
putString(USER_DATA_COLLECTION_ID, "$randomCollectionId")
|
||||
}
|
||||
assertTrue(accountManager.addAccountExplicitly(addressBookAccount, null, userData))
|
||||
|
||||
val addressBookAccounts = accountManager.getAccountsByType(addressBookAccountType)
|
||||
assertEquals(randomCollectionId, accountManager.getUserData(addressBookAccount, USER_DATA_COLLECTION_ID).toLong())
|
||||
|
||||
// Create the collection
|
||||
val collectionDao = db.collectionDao()
|
||||
collectionDao.insert(Collection(
|
||||
randomCollectionId,
|
||||
serviceId = service.id,
|
||||
type = Collection.TYPE_ADDRESSBOOK,
|
||||
url = "http://www.example.com/yay.php".toHttpUrl()
|
||||
))
|
||||
|
||||
// Create worker and run the method
|
||||
val worker = TestListenableWorkerBuilder<AccountsCleanupWorker>(context)
|
||||
.setWorkerFactory(object: WorkerFactory() {
|
||||
override fun createWorker(appContext: Context, workerClassName: String, workerParameters: WorkerParameters) =
|
||||
accountsCleanupWorkerFactory.create(appContext, workerParameters)
|
||||
})
|
||||
.build()
|
||||
worker.deleteOrphanedAddressBookAccounts(addressBookAccounts)
|
||||
|
||||
// Verify account was _not_ deleted
|
||||
assertEquals(addressBookAccount, addressBookAccounts.firstOrNull())
|
||||
}
|
||||
|
||||
|
||||
@@ -180,9 +149,4 @@ class AccountsCleanupWorkerTest {
|
||||
return db.serviceDao().get(serviceId)!!
|
||||
}
|
||||
|
||||
private fun workerFactory() = object : WorkerFactory() {
|
||||
override fun createWorker(appContext: Context, workerClassName: String, workerParameters: WorkerParameters) =
|
||||
accountsCleanupWorkerFactory.create(appContext, workerParameters)
|
||||
}
|
||||
|
||||
}
|
||||
@@ -15,7 +15,6 @@ import androidx.test.platform.app.InstrumentationRegistry
|
||||
import at.bitfire.davdroid.settings.AccountSettings
|
||||
import at.bitfire.davdroid.test.R
|
||||
import org.junit.Assert.assertTrue
|
||||
import java.util.concurrent.atomic.AtomicInteger
|
||||
|
||||
/**
|
||||
* Handles the test account type, which has no sync adapters and side effects that run unintentionally.
|
||||
@@ -43,20 +42,17 @@ class TestAccountAuthenticator: Service() {
|
||||
companion object {
|
||||
|
||||
val context by lazy { InstrumentationRegistry.getInstrumentation().context }
|
||||
val counter = AtomicInteger(0)
|
||||
|
||||
/**
|
||||
* Creates a test account, usually in the `Before` setUp of a test.
|
||||
*
|
||||
* Remove it with [remove].
|
||||
*/
|
||||
fun create(version: Int = AccountSettings.CURRENT_VERSION): Account {
|
||||
fun create(): Account {
|
||||
val accountType = context.getString(R.string.account_type_test)
|
||||
val account = Account("Test Account No. ${counter.incrementAndGet()}", accountType)
|
||||
val account = Account("Test Account", accountType)
|
||||
|
||||
val initialData = AccountSettings.initialUserData(null)
|
||||
initialData.putString(AccountSettings.KEY_SETTINGS_VERSION, version.toString())
|
||||
assertTrue(SystemAccountUtils.createAccount(context, account, initialData))
|
||||
assertTrue(SystemAccountUtils.createAccount(context, account, AccountSettings.initialUserData(null)))
|
||||
|
||||
return account
|
||||
}
|
||||
@@ -66,19 +62,7 @@ class TestAccountAuthenticator: Service() {
|
||||
*/
|
||||
fun remove(account: Account) {
|
||||
val am = AccountManager.get(context)
|
||||
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)
|
||||
}
|
||||
am.removeAccountExplicitly(account)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
6
app/src/debug/res/values/colors.xml
Normal file
6
app/src/debug/res/values/colors.xml
Normal file
@@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<color name="primaryColor">#E07C25</color>
|
||||
<color name="primaryLightColor">#E5A371</color>
|
||||
<color name="primaryDarkColor">#7C3E07</color>
|
||||
</resources>
|
||||
4
app/src/debug/res/values/strings.xml
Normal file
4
app/src/debug/res/values/strings.xml
Normal file
@@ -0,0 +1,4 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<string name="app_name" translatable="false">DAVx⁵ Debug</string>
|
||||
</resources>
|
||||
@@ -14,6 +14,11 @@
|
||||
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED"/>
|
||||
<uses-permission android:name="android.permission.REQUEST_IGNORE_BATTERY_OPTIMIZATIONS"/>
|
||||
|
||||
<!-- account management permissions not required for own accounts since API level 22 -->
|
||||
<uses-permission android:name="android.permission.AUTHENTICATE_ACCOUNTS" android:maxSdkVersion="22"/>
|
||||
<uses-permission android:name="android.permission.GET_ACCOUNTS" android:maxSdkVersion="22"/>
|
||||
<uses-permission android:name="android.permission.MANAGE_ACCOUNTS" android:maxSdkVersion="22"/>
|
||||
|
||||
<!-- other permissions -->
|
||||
<!-- android.permission-group.CONTACTS -->
|
||||
<uses-permission android:name="android.permission.READ_CONTACTS"/>
|
||||
|
||||
@@ -15,7 +15,6 @@ import androidx.room.AutoMigration
|
||||
import androidx.room.Database
|
||||
import androidx.room.DeleteColumn
|
||||
import androidx.room.ProvidedAutoMigrationSpec
|
||||
import androidx.room.RenameColumn
|
||||
import androidx.room.Room
|
||||
import androidx.room.RoomDatabase
|
||||
import androidx.room.TypeConverters
|
||||
@@ -27,7 +26,6 @@ import at.bitfire.davdroid.TextTable
|
||||
import at.bitfire.davdroid.servicedetection.RefreshCollectionsWorker
|
||||
import at.bitfire.davdroid.ui.AccountsActivity
|
||||
import at.bitfire.davdroid.ui.NotificationRegistry
|
||||
import at.bitfire.ical4android.util.DateUtils
|
||||
import dagger.Module
|
||||
import dagger.Provides
|
||||
import dagger.hilt.InstallIn
|
||||
@@ -46,14 +44,12 @@ import javax.inject.Singleton
|
||||
SyncStats::class,
|
||||
WebDavDocument::class,
|
||||
WebDavMount::class
|
||||
], exportSchema = true, version = 16, autoMigrations = [
|
||||
], 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),
|
||||
AutoMigration(from = 12, to = 13),
|
||||
AutoMigration(from = 13, to = 14),
|
||||
AutoMigration(from = 14, to = 15),
|
||||
AutoMigration(from = 15, to = 16, spec = AppDatabase.AutoMigration15_16::class)
|
||||
AutoMigration(from = 13, to = 14)
|
||||
])
|
||||
@TypeConverters(Converters::class)
|
||||
abstract class AppDatabase: RoomDatabase() {
|
||||
@@ -61,64 +57,41 @@ abstract class AppDatabase: RoomDatabase() {
|
||||
@Module
|
||||
@InstallIn(SingletonComponent::class)
|
||||
object AppDatabaseModule {
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun appDatabase(
|
||||
@ApplicationContext context: Context,
|
||||
notificationRegistry: NotificationRegistry
|
||||
): AppDatabase = Room
|
||||
.databaseBuilder(context, AppDatabase::class.java, "services.db")
|
||||
.addMigrations(*manualMigrations)
|
||||
.apply {
|
||||
for (spec in getAutoMigrationSpecs(context))
|
||||
addAutoMigrationSpec(spec)
|
||||
}
|
||||
.fallbackToDestructiveMigration() // as a last fallback, recreate database instead of crashing
|
||||
.addCallback(object: Callback() {
|
||||
override fun onDestructiveMigration(db: SupportSQLiteDatabase) {
|
||||
notificationRegistry.notifyIfPossible(NotificationRegistry.NOTIFY_DATABASE_CORRUPTED) {
|
||||
val launcherIntent = Intent(context, AccountsActivity::class.java)
|
||||
NotificationCompat.Builder(context, notificationRegistry.CHANNEL_GENERAL)
|
||||
.setSmallIcon(R.drawable.ic_warning_notify)
|
||||
.setContentTitle(context.getString(R.string.database_destructive_migration_title))
|
||||
.setContentText(context.getString(R.string.database_destructive_migration_text))
|
||||
.setCategory(NotificationCompat.CATEGORY_ERROR)
|
||||
.setContentIntent(PendingIntent.getActivity(context, 0, launcherIntent, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE))
|
||||
.setAutoCancel(true)
|
||||
.build()
|
||||
): AppDatabase =
|
||||
Room.databaseBuilder(context.applicationContext, AppDatabase::class.java, "services.db")
|
||||
.addMigrations(*migrations)
|
||||
.addAutoMigrationSpec(AutoMigration11_12(context))
|
||||
.fallbackToDestructiveMigration() // as a last fallback, recreate database instead of crashing
|
||||
.addCallback(object: Callback() {
|
||||
override fun onDestructiveMigration(db: SupportSQLiteDatabase) {
|
||||
notificationRegistry.notifyIfPossible(NotificationRegistry.NOTIFY_DATABASE_CORRUPTED) {
|
||||
val launcherIntent = Intent(context, AccountsActivity::class.java)
|
||||
NotificationCompat.Builder(context, notificationRegistry.CHANNEL_GENERAL)
|
||||
.setSmallIcon(R.drawable.ic_warning_notify)
|
||||
.setContentTitle(context.getString(R.string.database_destructive_migration_title))
|
||||
.setContentText(context.getString(R.string.database_destructive_migration_text))
|
||||
.setCategory(NotificationCompat.CATEGORY_ERROR)
|
||||
.setContentIntent(PendingIntent.getActivity(context, 0, launcherIntent, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE))
|
||||
.setAutoCancel(true)
|
||||
.build()
|
||||
}
|
||||
|
||||
// remove all accounts because they're unfortunately useless without database
|
||||
val am = AccountManager.get(context)
|
||||
for (account in am.getAccountsByType(context.getString(R.string.account_type)))
|
||||
am.removeAccountExplicitly(account)
|
||||
}
|
||||
|
||||
// remove all accounts because they're unfortunately useless without database
|
||||
val am = AccountManager.get(context)
|
||||
for (account in am.getAccountsByType(context.getString(R.string.account_type)))
|
||||
am.removeAccountExplicitly(account)
|
||||
}
|
||||
})
|
||||
.build()
|
||||
|
||||
})
|
||||
.build()
|
||||
}
|
||||
|
||||
// auto migrations
|
||||
|
||||
@ProvidedAutoMigrationSpec
|
||||
@RenameColumn(tableName = "collection", fromColumnName = "timezone", toColumnName = "timezoneId")
|
||||
class AutoMigration15_16: AutoMigrationSpec {
|
||||
override fun onPostMigrate(db: SupportSQLiteDatabase) {
|
||||
// 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.
|
||||
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))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ProvidedAutoMigrationSpec
|
||||
@DeleteColumn(tableName = "collection", columnName = "owner")
|
||||
class AutoMigration11_12(val context: Context): AutoMigrationSpec {
|
||||
@@ -136,119 +109,150 @@ abstract class AppDatabase: RoomDatabase() {
|
||||
|
||||
companion object {
|
||||
|
||||
// automatic migrations
|
||||
|
||||
fun getAutoMigrationSpecs(context: Context) = listOf(
|
||||
AutoMigration11_12(context),
|
||||
AutoMigration15_16()
|
||||
)
|
||||
|
||||
// manual migrations
|
||||
|
||||
val manualMigrations: Array<Migration> = arrayOf(
|
||||
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)")
|
||||
},
|
||||
|
||||
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)")
|
||||
},
|
||||
|
||||
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")
|
||||
},
|
||||
|
||||
Migration(5, 6) { db ->
|
||||
val sql = arrayOf(
|
||||
// migrate "services" to "service": rename columns, make id NOT NULL
|
||||
"CREATE TABLE service(" +
|
||||
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," +
|
||||
"accountName TEXT NOT NULL," +
|
||||
"type TEXT NOT NULL," +
|
||||
"principal TEXT DEFAULT NULL" +
|
||||
"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" +
|
||||
")",
|
||||
"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) }
|
||||
},
|
||||
|
||||
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
|
||||
},
|
||||
|
||||
Migration(3, 4) { db ->
|
||||
db.execSQL("ALTER TABLE collections ADD COLUMN forceReadOnly INTEGER DEFAULT 0 NOT NULL")
|
||||
},
|
||||
|
||||
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*")
|
||||
},
|
||||
|
||||
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"))
|
||||
arrayOf("caldav", "CALENDAR", "ADDRESS_BOOK"))
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@@ -14,7 +14,6 @@ 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
|
||||
@@ -25,7 +24,6 @@ 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
|
||||
|
||||
@@ -101,8 +99,8 @@ data class Collection(
|
||||
// CalDAV only
|
||||
var color: Int? = null,
|
||||
|
||||
/** default timezone (only timezone ID, like `Europe/Vienna`) */
|
||||
var timezoneId: String? = null,
|
||||
/** timezone definition (full VTIMEZONE) - not a TZID! **/
|
||||
var timezone: String? = null,
|
||||
|
||||
/** whether the collection supports VEVENT; in case of calendars: null means true */
|
||||
var supportsVEVENT: Boolean? = null,
|
||||
@@ -129,10 +127,7 @@ data class Collection(
|
||||
/** WebDAV-Push subscription URL */
|
||||
var pushSubscription: String? = null,
|
||||
|
||||
/** when the [pushSubscription] expires (timestamp, used to determine whether we need to re-subscribe) */
|
||||
var pushSubscriptionExpires: Long? = null,
|
||||
|
||||
/** when the [pushSubscription] was created/updated (timestamp) */
|
||||
/** when the [pushSubscription] was created/updated (used to determine whether we need to re-subscribe) */
|
||||
var pushSubscriptionCreated: Long? = null
|
||||
|
||||
) {
|
||||
@@ -170,7 +165,7 @@ data class Collection(
|
||||
|
||||
var description: String? = null
|
||||
var color: Int? = null
|
||||
var timezoneId: String? = null
|
||||
var timezone: String? = null
|
||||
var supportsVEVENT: Boolean? = null
|
||||
var supportsVTODO: Boolean? = null
|
||||
var supportsVJOURNAL: Boolean? = null
|
||||
@@ -182,11 +177,7 @@ data class Collection(
|
||||
TYPE_CALENDAR, TYPE_WEBCAL -> {
|
||||
dav[CalendarDescription::class.java]?.let { description = it.description }
|
||||
dav[CalendarColor::class.java]?.let { color = it.color }
|
||||
dav[CalendarTimezoneId::class.java]?.let { timezoneId = it.identifier }
|
||||
if (timezoneId == null)
|
||||
dav[CalendarTimezone::class.java]?.vTimeZone?.let {
|
||||
timezoneId = DateUtils.parseVTimeZone(it)?.timeZoneId?.value
|
||||
}
|
||||
dav[CalendarTimezone::class.java]?.let { timezone = it.vTimeZone }
|
||||
|
||||
if (type == TYPE_CALENDAR) {
|
||||
supportsVEVENT = true
|
||||
@@ -226,7 +217,7 @@ data class Collection(
|
||||
displayName = displayName,
|
||||
description = description,
|
||||
color = color,
|
||||
timezoneId = timezoneId,
|
||||
timezone = timezone,
|
||||
supportsVEVENT = supportsVEVENT,
|
||||
supportsVTODO = supportsVTODO,
|
||||
supportsVJOURNAL = supportsVJOURNAL,
|
||||
|
||||
@@ -38,9 +38,6 @@ 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
|
||||
@@ -90,8 +87,8 @@ interface CollectionDao {
|
||||
@Query("UPDATE collection SET forceReadOnly=:forceReadOnly WHERE id=:id")
|
||||
suspend fun updateForceReadOnly(id: Long, forceReadOnly: Boolean)
|
||||
|
||||
@Query("UPDATE collection SET pushSubscription=:pushSubscription, pushSubscriptionExpires=:pushSubscriptionExpires, pushSubscriptionCreated=:updatedAt WHERE id=:id")
|
||||
fun updatePushSubscription(id: Long, pushSubscription: String?, pushSubscriptionExpires: Long?, updatedAt: Long = System.currentTimeMillis()/1000)
|
||||
@Query("UPDATE collection SET pushSubscription=:pushSubscription, pushSubscriptionCreated=:updatedAt WHERE id=:id")
|
||||
fun updatePushSubscription(id: Long, pushSubscription: String?, updatedAt: Long = System.currentTimeMillis())
|
||||
|
||||
@Query("UPDATE collection SET sync=:sync WHERE id=:id")
|
||||
suspend fun updateSync(id: Long, sync: Boolean)
|
||||
|
||||
@@ -7,7 +7,6 @@ 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
|
||||
@@ -32,9 +31,7 @@ class PushMessageParser @Inject constructor(
|
||||
|
||||
XmlReader(parser).processTag(PushMessage.NAME) {
|
||||
val pushMessage = PushMessage.Factory.create(parser)
|
||||
val properties = pushMessage.propStat?.properties ?: return@processTag
|
||||
val pushTopic = properties.filterIsInstance<Topic>().firstOrNull()
|
||||
topic = pushTopic?.topic
|
||||
topic = pushMessage.topic
|
||||
}
|
||||
} catch (e: XmlPullParserException) {
|
||||
logger.log(Level.WARNING, "Couldn't parse push message", e)
|
||||
|
||||
@@ -7,11 +7,15 @@ 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
|
||||
@@ -24,23 +28,31 @@ 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.time.Duration
|
||||
import java.time.Instant
|
||||
import java.util.concurrent.TimeUnit
|
||||
import java.util.logging.Level
|
||||
import java.util.logging.Logger
|
||||
import javax.inject.Inject
|
||||
|
||||
/**
|
||||
* 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
|
||||
@@ -54,15 +66,34 @@ 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")
|
||||
|
||||
try {
|
||||
registerSyncable()
|
||||
unregisterNotSyncable()
|
||||
} catch (_: IOException) {
|
||||
return Result.retry() // retry on I/O errors
|
||||
}
|
||||
registerSyncable()
|
||||
unregisterNotSyncable()
|
||||
|
||||
return Result.success()
|
||||
}
|
||||
@@ -77,41 +108,27 @@ 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) {
|
||||
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
|
||||
)
|
||||
response.header("Location")?.let { subscriptionUrl ->
|
||||
collectionRepository.updatePushSubscription(collection.id, subscriptionUrl)
|
||||
}
|
||||
} else
|
||||
logger.warning("Couldn't register push for ${collection.url}: $response")
|
||||
}
|
||||
@@ -125,15 +142,6 @@ 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))
|
||||
@@ -168,11 +176,7 @@ class PushRegistrationWorker @AssistedInject constructor(
|
||||
}
|
||||
|
||||
// remove registration URL from DB in any case
|
||||
collectionRepository.updatePushSubscription(
|
||||
id = collection.id,
|
||||
subscriptionUrl = null,
|
||||
expires = null
|
||||
)
|
||||
collectionRepository.updatePushSubscription(collection.id, null)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -189,4 +193,25 @@ 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
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,98 +0,0 @@
|
||||
/*
|
||||
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
|
||||
*/
|
||||
|
||||
package at.bitfire.davdroid.push
|
||||
|
||||
import android.content.Context
|
||||
import androidx.work.BackoffPolicy
|
||||
import androidx.work.Constraints
|
||||
import androidx.work.ExistingPeriodicWorkPolicy
|
||||
import androidx.work.NetworkType
|
||||
import androidx.work.PeriodicWorkRequest
|
||||
import androidx.work.WorkManager
|
||||
import at.bitfire.davdroid.repository.DavCollectionRepository
|
||||
import dagger.Binds
|
||||
import dagger.Module
|
||||
import dagger.hilt.InstallIn
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import dagger.hilt.components.SingletonComponent
|
||||
import dagger.multibindings.IntoSet
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import java.util.concurrent.TimeUnit
|
||||
import java.util.logging.Logger
|
||||
import javax.inject.Inject
|
||||
|
||||
class PushRegistrationWorkerManager @Inject constructor(
|
||||
@ApplicationContext val context: Context,
|
||||
val collectionRepository: DavCollectionRepository,
|
||||
val logger: Logger
|
||||
) {
|
||||
|
||||
/**
|
||||
* Determines whether there are any push-capable collections and updates the periodic worker accordingly.
|
||||
*
|
||||
* If there are push-capable collections, a unique periodic worker with an initial delay of 5 seconds is enqueued.
|
||||
* A potentially existing worker is replaced, so that the first run should be soon.
|
||||
*
|
||||
* Otherwise, a potentially existing worker is cancelled.
|
||||
*/
|
||||
fun updatePeriodicWorker() {
|
||||
val workerNeeded = runBlocking {
|
||||
collectionRepository.anyPushCapable()
|
||||
}
|
||||
|
||||
val workManager = WorkManager.getInstance(context)
|
||||
if (workerNeeded) {
|
||||
logger.info("Enqueuing periodic PushRegistrationWorker")
|
||||
workManager.enqueueUniquePeriodicWork(
|
||||
UNIQUE_WORK_NAME,
|
||||
ExistingPeriodicWorkPolicy.CANCEL_AND_REENQUEUE,
|
||||
PeriodicWorkRequest.Builder(PushRegistrationWorker::class, INTERVAL_DAYS, TimeUnit.DAYS)
|
||||
.setInitialDelay(5, TimeUnit.SECONDS)
|
||||
.setConstraints(
|
||||
Constraints.Builder()
|
||||
.setRequiredNetworkType(NetworkType.CONNECTED)
|
||||
.build()
|
||||
)
|
||||
.setBackoffCriteria(BackoffPolicy.EXPONENTIAL, 1, TimeUnit.MINUTES)
|
||||
.build()
|
||||
)
|
||||
} else {
|
||||
logger.info("Cancelling periodic PushRegistrationWorker")
|
||||
workManager.cancelUniqueWork(UNIQUE_WORK_NAME)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
companion object {
|
||||
private const val UNIQUE_WORK_NAME = "push-registration"
|
||||
const val INTERVAL_DAYS = 1L
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Listener that enqueues a push registration worker when the collection list changes.
|
||||
*/
|
||||
class CollectionsListener @Inject constructor(
|
||||
@ApplicationContext val context: Context,
|
||||
val workerManager: PushRegistrationWorkerManager
|
||||
): DavCollectionRepository.OnChangeListener {
|
||||
|
||||
override fun onCollectionsChanged() {
|
||||
workerManager.updatePeriodicWorker()
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* Hilt module that registers [CollectionsListener] in [DavCollectionRepository].
|
||||
*/
|
||||
@Module
|
||||
@InstallIn(SingletonComponent::class)
|
||||
interface PushRegistrationWorkerModule {
|
||||
@Binds
|
||||
@IntoSet
|
||||
fun listener(impl: CollectionsListener): DavCollectionRepository.OnChangeListener
|
||||
}
|
||||
}
|
||||
@@ -11,13 +11,13 @@ import at.bitfire.davdroid.repository.DavServiceRepository
|
||||
import at.bitfire.davdroid.repository.PreferenceRepository
|
||||
import at.bitfire.davdroid.sync.worker.SyncWorkerManager
|
||||
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,9 +40,6 @@ class UnifiedPushReceiver: MessagingReceiver() {
|
||||
@Inject
|
||||
lateinit var parsePushMessage: PushMessageParser
|
||||
|
||||
@Inject
|
||||
lateinit var pushRegistrationWorkerManager: PushRegistrationWorkerManager
|
||||
|
||||
@Inject
|
||||
lateinit var syncWorkerManager: SyncWorkerManager
|
||||
|
||||
@@ -52,7 +49,7 @@ class UnifiedPushReceiver: MessagingReceiver() {
|
||||
preferenceRepository.unifiedPushEndpoint(endpoint)
|
||||
|
||||
// register new endpoint at CalDAV/CardDAV servers
|
||||
pushRegistrationWorkerManager.updatePeriodicWorker()
|
||||
PushRegistrationWorker.enqueue(context)
|
||||
}
|
||||
|
||||
override fun onUnregistered(context: Context, instance: String) {
|
||||
|
||||
@@ -7,6 +7,7 @@ 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
|
||||
@@ -14,19 +15,18 @@ 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.LocalAddressBookStore
|
||||
import at.bitfire.davdroid.resource.LocalAddressBook
|
||||
import at.bitfire.davdroid.resource.LocalTaskList
|
||||
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.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.ical4android.TaskProvider
|
||||
import at.bitfire.vcard4android.GroupMethod
|
||||
import dagger.Lazy
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
@@ -46,11 +46,9 @@ import javax.inject.Inject
|
||||
*/
|
||||
class AccountRepository @Inject constructor(
|
||||
private val accountSettingsFactory: AccountSettings.Factory,
|
||||
private val automaticSyncManager: AutomaticSyncManager,
|
||||
@ApplicationContext private val context: Context,
|
||||
@ApplicationContext val context: Context,
|
||||
private val collectionRepository: DavCollectionRepository,
|
||||
private val homeSetRepository: DavHomeSetRepository,
|
||||
private val localAddressBookStore: Lazy<LocalAddressBookStore>,
|
||||
private val logger: Logger,
|
||||
private val settingsManager: SettingsManager,
|
||||
private val serviceRepository: DavServiceRepository,
|
||||
@@ -94,14 +92,13 @@ class AccountRepository @Inject constructor(
|
||||
// insert CardDAV service
|
||||
val id = insertService(accountName, Service.TYPE_CARDDAV, config.cardDAV)
|
||||
|
||||
// set initial CardDAV account settings and set sync intervals (enables automatic sync)
|
||||
// initial CardDAV account settings and sync intervals
|
||||
accountSettings.setGroupMethod(groupMethod)
|
||||
accountSettings.setSyncInterval(addrBookAuthority, defaultSyncInterval)
|
||||
|
||||
// start CardDAV service detection (refresh collections)
|
||||
RefreshCollectionsWorker.enqueue(context, id)
|
||||
} else
|
||||
automaticSyncManager.disable(account, addrBookAuthority)
|
||||
}
|
||||
|
||||
// Configure CalDAV service
|
||||
if (config.calDAV != null) {
|
||||
@@ -109,11 +106,13 @@ class AccountRepository @Inject constructor(
|
||||
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.")
|
||||
@@ -122,11 +121,8 @@ class AccountRepository @Inject constructor(
|
||||
|
||||
// start CalDAV service detection (refresh collections)
|
||||
RefreshCollectionsWorker.enqueue(context, id)
|
||||
} else {
|
||||
automaticSyncManager.disable(account, CalendarContract.AUTHORITY)
|
||||
for (provider in TaskProvider.ProviderName.entries)
|
||||
automaticSyncManager.disable(account, provider.authority)
|
||||
}
|
||||
} else
|
||||
ContentResolver.setIsSyncable(account, CalendarContract.AUTHORITY, 0)
|
||||
|
||||
} catch(e: InvalidAccountException) {
|
||||
logger.log(Level.SEVERE, "Couldn't access account settings", e)
|
||||
@@ -144,7 +140,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 ->
|
||||
localAddressBookStore.get().deleteByCollectionId(collection.id)
|
||||
LocalAddressBook.deleteByCollection(context, collection.id)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -238,7 +234,7 @@ class AccountRepository @Inject constructor(
|
||||
}
|
||||
|
||||
// account renamed, cancel maybe running synchronization of old account
|
||||
syncWorkerManager.cancelAllWork(oldAccount)
|
||||
BaseSyncWorker.cancelAllWork(context, oldAccount)
|
||||
|
||||
// disable periodic syncs for old account
|
||||
syncIntervals.forEach { (authority, _) ->
|
||||
@@ -248,9 +244,6 @@ class AccountRepository @Inject constructor(
|
||||
// update account name references in database
|
||||
serviceRepository.renameAccount(oldName, newName)
|
||||
|
||||
// update address books
|
||||
localAddressBookStore.get().updateAccount(oldAccount, newAccount)
|
||||
|
||||
// calendar provider doesn't allow changing account_name of Events
|
||||
// (all events will have to be downloaded again at next sync)
|
||||
|
||||
@@ -266,9 +259,11 @@ class AccountRepository @Inject constructor(
|
||||
val newSettings = accountSettingsFactory.create(newAccount)
|
||||
for ((authority, interval) in syncIntervals) {
|
||||
if (interval == null)
|
||||
automaticSyncManager.disable(newAccount, authority)
|
||||
else
|
||||
ContentResolver.setIsSyncable(newAccount, authority, 0)
|
||||
else {
|
||||
ContentResolver.setIsSyncable(newAccount, authority, 1)
|
||||
newSettings.setSyncInterval(authority, interval)
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
// release AccountsCleanupWorker mutex at the end of this async coroutine
|
||||
|
||||
@@ -29,12 +29,15 @@ 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
|
||||
@@ -43,10 +46,6 @@ 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.
|
||||
@@ -57,20 +56,14 @@ class DavCollectionRepository @Inject constructor(
|
||||
private val accountSettingsFactory: AccountSettings.Factory,
|
||||
@ApplicationContext val context: Context,
|
||||
db: AppDatabase,
|
||||
defaultListeners: Lazy<Set<@JvmSuppressWildcards OnChangeListener>>,
|
||||
defaultListeners: Set<@JvmSuppressWildcards OnChangeListener>,
|
||||
private val serviceRepository: DavServiceRepository
|
||||
) {
|
||||
|
||||
private val listeners by lazy { Collections.synchronizedSet(defaultListeners.get().toMutableSet()) }
|
||||
private val listeners = Collections.synchronizedSet(defaultListeners.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
|
||||
*/
|
||||
@@ -158,7 +151,7 @@ class DavCollectionRepository @Inject constructor(
|
||||
displayName = displayName,
|
||||
description = description,
|
||||
color = color,
|
||||
timezoneId = timeZoneId,
|
||||
timezone = timeZoneId?.let { getVTimeZone(it)?.toString() },
|
||||
supportsVEVENT = supportVEVENT,
|
||||
supportsVTODO = supportVTODO,
|
||||
supportsVJOURNAL = supportVJOURNAL
|
||||
@@ -263,12 +256,8 @@ class DavCollectionRepository @Inject constructor(
|
||||
notifyOnChangeListeners()
|
||||
}
|
||||
|
||||
fun updatePushSubscription(id: Long, subscriptionUrl: String?, expires: Long?) {
|
||||
dao.updatePushSubscription(
|
||||
id = id,
|
||||
pushSubscription = subscriptionUrl,
|
||||
pushSubscriptionExpires = expires
|
||||
)
|
||||
fun updatePushSubscription(id: Long, subscriptionUrl: String?) {
|
||||
dao.updatePushSubscription(id, subscriptionUrl)
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -4,10 +4,9 @@
|
||||
|
||||
package at.bitfire.davdroid.repository
|
||||
|
||||
import android.content.Context
|
||||
import android.app.Application
|
||||
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
|
||||
@@ -19,7 +18,7 @@ import javax.inject.Inject
|
||||
* [at.bitfire.davdroid.settings.SettingsManager].
|
||||
*/
|
||||
class PreferenceRepository @Inject constructor(
|
||||
@ApplicationContext context: Context
|
||||
context: Application
|
||||
) {
|
||||
|
||||
companion object {
|
||||
|
||||
@@ -6,9 +6,11 @@ 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
|
||||
@@ -16,15 +18,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 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.sync.account.setAndVerifyUserData
|
||||
import at.bitfire.davdroid.util.DavUtils.lastSegment
|
||||
import at.bitfire.davdroid.util.setAndVerifyUserData
|
||||
import at.bitfire.vcard4android.AndroidAddressBook
|
||||
import at.bitfire.vcard4android.AndroidContact
|
||||
import at.bitfire.vcard4android.AndroidGroup
|
||||
@@ -33,9 +36,12 @@ 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
|
||||
|
||||
@@ -44,35 +50,29 @@ 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("account") val account: Account,
|
||||
@Assisted("addressBookAccount") _addressBookAccount: Account,
|
||||
@Assisted _addressBookAccount: Account,
|
||||
@Assisted provider: ContentProviderClient,
|
||||
private val accountSettingsFactory: AccountSettings.Factory,
|
||||
private val collectionRepository: DavCollectionRepository,
|
||||
@ApplicationContext private val context: Context,
|
||||
internal val dirtyVerifier: Optional<ContactDirtyVerifier>,
|
||||
@ApplicationContext val context: Context,
|
||||
private val logger: Logger,
|
||||
private val serviceRepository: DavServiceRepository,
|
||||
private val syncFramework: SyncFrameworkIntegration
|
||||
private val serviceRepository: DavServiceRepository
|
||||
): AndroidAddressBook<LocalContact, LocalGroup>(_addressBookAccount, provider, LocalContact.Factory, LocalGroup.Factory), LocalCollection<LocalAddress> {
|
||||
|
||||
@AssistedFactory
|
||||
interface Factory {
|
||||
fun create(
|
||||
@Assisted("account") account: Account,
|
||||
@Assisted("addressBookAccount") addressBookAccount: Account,
|
||||
provider: ContentProviderClient
|
||||
): LocalAddressBook
|
||||
fun create(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()
|
||||
}
|
||||
val includeGroups
|
||||
private val includeGroups
|
||||
get() = groupMethod == GroupMethod.GROUP_VCARDS
|
||||
|
||||
@Deprecated("Local collection should be identified by ID, not by URL")
|
||||
@@ -109,42 +109,9 @@ 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) {
|
||||
// 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 = ContentValues(1).apply {
|
||||
put(RawContacts.RAW_CONTACT_IS_READ_ONLY, if (readOnly) 1 else 0)
|
||||
}
|
||||
provider!!.update(rawContactsSyncUri(), rawContactValues, null, null)
|
||||
|
||||
// update data rows
|
||||
val dataValues = ContentValues(1).apply {
|
||||
put(ContactsContract.Data.IS_READ_ONLY, if (readOnly) 1 else 0)
|
||||
}
|
||||
provider!!.update(syncAdapterURI(ContactsContract.Data.CONTENT_URI), dataValues, null, null)
|
||||
|
||||
// update group rows
|
||||
val groupValues = ContentValues(1).apply {
|
||||
put(Groups.GROUP_IS_READ_ONLY, if (readOnly) 1 else 0)
|
||||
}
|
||||
provider!!.update(groupsSyncUri(), groupValues, null, null)
|
||||
}
|
||||
set(readOnly) = AccountManager.get(context).setAndVerifyUserData(addressBookAccount, USER_DATA_READ_ONLY, if (readOnly) "1" else null)
|
||||
|
||||
override var lastSyncState: SyncState?
|
||||
get() = syncState?.let { SyncState.fromString(String(it)) }
|
||||
@@ -180,6 +147,58 @@ 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.
|
||||
@@ -193,6 +212,7 @@ 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\"")
|
||||
@@ -226,17 +246,32 @@ open class LocalAddressBook @AssistedInject constructor(
|
||||
return true
|
||||
}
|
||||
|
||||
override fun deleteCollection(): Boolean {
|
||||
val accountManager = AccountManager.get(context)
|
||||
return accountManager.removeAccountExplicitly(addressBookAccount)
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Makes contacts of this address book available to be synced and activates synchronization upon
|
||||
* contact data changes.
|
||||
* 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
|
||||
*/
|
||||
fun updateSyncFrameworkSettings() {
|
||||
// Enable sync-ability of contacts
|
||||
syncFramework.enableSyncAbility(addressBookAccount, ContactsContract.AUTHORITY)
|
||||
// Enable sync-ability
|
||||
if (ContentResolver.getIsSyncable(addressBookAccount, ContactsContract.AUTHORITY) != 1)
|
||||
ContentResolver.setIsSyncable(addressBookAccount, ContactsContract.AUTHORITY, 1)
|
||||
|
||||
// Changes in contact data should trigger syncs
|
||||
syncFramework.enableSyncOnContentChange(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)
|
||||
}
|
||||
|
||||
|
||||
@@ -309,6 +344,39 @@ 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 */
|
||||
|
||||
/**
|
||||
@@ -343,30 +411,125 @@ open class LocalAddressBook @AssistedInject constructor(
|
||||
|
||||
companion object {
|
||||
|
||||
const val USER_DATA_ACCOUNT_NAME = "account_name"
|
||||
const val USER_DATA_ACCOUNT_TYPE = "account_type"
|
||||
@EntryPoint
|
||||
@InstallIn(SingletonComponent::class)
|
||||
interface LocalAddressBookCompanionEntryPoint {
|
||||
fun localAddressBookFactory(): Factory
|
||||
fun serviceRepository(): DavServiceRepository
|
||||
fun logger(): Logger
|
||||
}
|
||||
|
||||
/**
|
||||
* 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"
|
||||
const val USER_DATA_READ_ONLY = "read_only"
|
||||
|
||||
// create/query/delete
|
||||
|
||||
/**
|
||||
* Indicates whether the address book is currently set to read-only (i.e. its contacts and groups have the read-only flag).
|
||||
* Creates a new local address book.
|
||||
*
|
||||
* User data of the address book account (Boolean).
|
||||
* @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]
|
||||
*/
|
||||
const val USER_DATA_READ_ONLY = "read_only"
|
||||
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
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
@@ -1,224 +0,0 @@
|
||||
/*
|
||||
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
|
||||
*/
|
||||
|
||||
package at.bitfire.davdroid.resource
|
||||
|
||||
import android.accounts.Account
|
||||
import android.accounts.AccountManager
|
||||
import android.content.ContentProviderClient
|
||||
import android.content.ContentValues
|
||||
import android.content.Context
|
||||
import android.os.Bundle
|
||||
import android.provider.ContactsContract
|
||||
import androidx.annotation.OpenForTesting
|
||||
import androidx.annotation.VisibleForTesting
|
||||
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 = Bundle(4).apply {
|
||||
putString(LocalAddressBook.USER_DATA_ACCOUNT_NAME, account.name)
|
||||
putString(LocalAddressBook.USER_DATA_ACCOUNT_TYPE, account.type)
|
||||
putString(LocalAddressBook.USER_DATA_COLLECTION_ID, id.toString())
|
||||
putString(LocalAddressBook.USER_DATA_URL, 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
|
||||
*/
|
||||
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() = ContentValues(2).apply {
|
||||
// SHOULD_SYNC is just a hint that an account's contacts (the contacts of this local address book) are syncable.
|
||||
put(ContactsContract.Settings.SHOULD_SYNC, 1)
|
||||
|
||||
// UNGROUPED_VISIBLE is required for making contacts work over Bluetooth (especially with some car systems).
|
||||
put(ContactsContract.Settings.UNGROUPED_VISIBLE, 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,12 +8,17 @@ 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 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
|
||||
@@ -37,6 +42,60 @@ 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?
|
||||
@@ -52,6 +111,8 @@ 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())
|
||||
@@ -71,6 +132,9 @@ 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)
|
||||
|
||||
@@ -1,106 +0,0 @@
|
||||
/*
|
||||
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
|
||||
*/
|
||||
|
||||
package at.bitfire.davdroid.resource
|
||||
|
||||
import android.accounts.Account
|
||||
import android.content.ContentProviderClient
|
||||
import android.content.ContentUris
|
||||
import android.content.ContentValues
|
||||
import android.content.Context
|
||||
import android.provider.CalendarContract.Calendars
|
||||
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 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.
|
||||
if (fromCollection.color != null)
|
||||
fromCollection.color = Constants.DAVDROID_GREEN_RGBA
|
||||
|
||||
val values = valuesFromCollectionInfo(fromCollection, 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 & syncable at creation, might be changed by user at any time
|
||||
values.put(Calendars.VISIBLE, 1)
|
||||
values.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 delete(localCollection: LocalCalendar) {
|
||||
logger.log(Level.INFO, "Deleting local calendar", localCollection)
|
||||
localCollection.delete()
|
||||
}
|
||||
|
||||
}
|
||||
@@ -4,6 +4,7 @@
|
||||
|
||||
package at.bitfire.davdroid.resource
|
||||
|
||||
import android.provider.CalendarContract.Events
|
||||
import at.bitfire.davdroid.db.SyncState
|
||||
|
||||
interface LocalCollection<out T: LocalResource<*>> {
|
||||
@@ -26,6 +27,13 @@ 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,6 +5,7 @@
|
||||
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
|
||||
@@ -24,7 +25,7 @@ import at.bitfire.vcard4android.Contact
|
||||
import ezvcard.Ezvcard
|
||||
import java.io.FileNotFoundException
|
||||
import java.util.UUID
|
||||
import kotlin.jvm.optionals.getOrNull
|
||||
import java.util.logging.Logger
|
||||
|
||||
class LocalContact: AndroidContact, LocalAddress {
|
||||
|
||||
@@ -38,6 +39,8 @@ 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
|
||||
|
||||
@@ -88,13 +91,6 @@ 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")
|
||||
@@ -105,8 +101,12 @@ class LocalContact: AndroidContact, LocalAddress {
|
||||
values.put(COLUMN_ETAG, eTag)
|
||||
values.put(ContactsContract.RawContacts.DIRTY, 0)
|
||||
|
||||
// Android 7 workaround
|
||||
addressBook.dirtyVerifier.getOrNull()?.setHashCodeColumn(this, values)
|
||||
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")
|
||||
}
|
||||
|
||||
addressBook.provider!!.update(rawContactSyncURI(), values, null, null)
|
||||
|
||||
@@ -136,6 +136,54 @@ class LocalContact: AndroidContact, LocalAddress {
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* 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())
|
||||
@@ -190,7 +238,6 @@ class LocalContact: AndroidContact, LocalAddress {
|
||||
|
||||
|
||||
// data rows
|
||||
|
||||
override fun buildContact(builder: BatchOperation.CpoBuilder, update: Boolean) {
|
||||
builder.withValue(COLUMN_FLAGS, flags)
|
||||
super.buildContact(builder, update)
|
||||
@@ -203,4 +250,4 @@ class LocalContact: AndroidContact, LocalAddress {
|
||||
LocalContact(addressBook as LocalAddressBook, values)
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,54 +0,0 @@
|
||||
/*
|
||||
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
|
||||
*/
|
||||
|
||||
package at.bitfire.davdroid.resource
|
||||
|
||||
import android.accounts.Account
|
||||
import android.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)
|
||||
|
||||
}
|
||||
@@ -25,7 +25,6 @@ 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 {
|
||||
|
||||
@@ -91,14 +90,11 @@ class LocalGroup: AndroidGroup, LocalAddress {
|
||||
changeContactIDs += missingMember.id!!
|
||||
}
|
||||
|
||||
addressBook.dirtyVerifier.getOrNull()?.let { verifier ->
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O)
|
||||
// workaround for Android 7 which sets DIRTY flag when only meta-data is changed
|
||||
changeContactIDs
|
||||
.map { id -> addressBook.findContactById(id) }
|
||||
.forEach { contact ->
|
||||
verifier.updateHashCode(contact, batch)
|
||||
}
|
||||
}
|
||||
.map { addressBook.findContactById(it) }
|
||||
.forEach { it.updateHashCode(batch) }
|
||||
|
||||
batch.commit()
|
||||
}
|
||||
|
||||
@@ -6,20 +6,64 @@ 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?
|
||||
@@ -30,6 +74,10 @@ 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()
|
||||
|
||||
@@ -1,89 +0,0 @@
|
||||
/*
|
||||
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
|
||||
*/
|
||||
|
||||
package at.bitfire.davdroid.resource
|
||||
|
||||
import android.accounts.Account
|
||||
import android.content.ContentProviderClient
|
||||
import android.content.ContentUris
|
||||
import android.content.ContentValues
|
||||
import android.content.Context
|
||||
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.techbee.jtx.JtxContract
|
||||
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? {
|
||||
// If the collection doesn't have a color, use a default color.
|
||||
if (fromCollection.color != null)
|
||||
fromCollection.color = Constants.DAVDROID_GREEN_RGBA
|
||||
|
||||
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))
|
||||
val values = valuesFromCollection(fromCollection, 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()
|
||||
}
|
||||
|
||||
}
|
||||
@@ -9,7 +9,11 @@ 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 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
|
||||
@@ -31,6 +35,18 @@ class LocalTaskList private constructor(
|
||||
|
||||
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) {
|
||||
@@ -45,6 +61,23 @@ class LocalTaskList private constructor(
|
||||
}
|
||||
}
|
||||
|
||||
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()
|
||||
@@ -55,6 +88,8 @@ 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
|
||||
|
||||
@@ -91,6 +126,9 @@ 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)
|
||||
|
||||
|
||||
@@ -1,102 +0,0 @@
|
||||
/*
|
||||
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
|
||||
*/
|
||||
|
||||
package at.bitfire.davdroid.resource
|
||||
|
||||
import android.accounts.Account
|
||||
import android.content.ContentProviderClient
|
||||
import android.content.ContentUris
|
||||
import android.content.ContentValues
|
||||
import android.content.Context
|
||||
import android.net.Uri
|
||||
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 java.util.logging.Level
|
||||
import java.util.logging.Logger
|
||||
|
||||
class LocalTaskListStore @AssistedInject constructor(
|
||||
@Assisted authority: String,
|
||||
val accountSettingsFactory: AccountSettings.Factory,
|
||||
@ApplicationContext val context: Context,
|
||||
val db: AppDatabase,
|
||||
val logger: Logger
|
||||
): LocalDataStore<LocalTaskList> {
|
||||
|
||||
@AssistedFactory
|
||||
interface Factory {
|
||||
fun create(authority: String): LocalTaskListStore
|
||||
}
|
||||
|
||||
private val serviceDao = db.serviceDao()
|
||||
|
||||
private val providerName = TaskProvider.ProviderName.fromAuthority(authority)
|
||||
|
||||
|
||||
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, 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 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()
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,160 +0,0 @@
|
||||
/*
|
||||
* 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()
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,54 +0,0 @@
|
||||
/*
|
||||
* 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)
|
||||
|
||||
}
|
||||
@@ -24,8 +24,6 @@ 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.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
|
||||
@@ -87,14 +85,14 @@ 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 CalDAV/CardDAV server
|
||||
// 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, CalendarTimezone.NAME, CalendarTimezoneId.NAME, SupportedCalendarComponentSet.NAME,
|
||||
CalendarDescription.NAME, CalendarColor.NAME, SupportedCalendarComponentSet.NAME,
|
||||
Source.NAME,
|
||||
// WebDAV Push
|
||||
PushTransports.NAME,
|
||||
|
||||
@@ -5,6 +5,7 @@ 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
|
||||
@@ -13,11 +14,9 @@ import androidx.annotation.WorkerThread
|
||||
import at.bitfire.davdroid.InvalidAccountException
|
||||
import at.bitfire.davdroid.R
|
||||
import at.bitfire.davdroid.db.Credentials
|
||||
import at.bitfire.davdroid.settings.migration.AccountSettingsMigration
|
||||
import at.bitfire.davdroid.sync.AutomaticSyncManager
|
||||
import at.bitfire.davdroid.sync.SyncFrameworkIntegration
|
||||
import at.bitfire.davdroid.sync.account.setAndVerifyUserData
|
||||
import at.bitfire.davdroid.sync.SyncUtils
|
||||
import at.bitfire.davdroid.sync.worker.SyncWorkerManager
|
||||
import at.bitfire.davdroid.util.setAndVerifyUserData
|
||||
import at.bitfire.davdroid.util.trimToNull
|
||||
import at.bitfire.ical4android.TaskProvider
|
||||
import at.bitfire.vcard4android.GroupMethod
|
||||
@@ -28,29 +27,25 @@ import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import net.openid.appauth.AuthState
|
||||
import java.util.logging.Level
|
||||
import java.util.logging.Logger
|
||||
import javax.inject.Provider
|
||||
|
||||
/**
|
||||
* 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 abortOnMissingMigration whether to throw an [IllegalArgumentException] when migrations are missing (useful for testing)
|
||||
* @param account account to take settings from
|
||||
*
|
||||
* @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
|
||||
* @throws InvalidAccountException on construction when the account doesn't exist (anymore)
|
||||
* @throws IllegalArgumentException when the account is not a DAVx5 account
|
||||
*/
|
||||
@WorkerThread
|
||||
class AccountSettings @AssistedInject constructor(
|
||||
@Assisted val account: Account,
|
||||
@Assisted val abortOnMissingMigration: Boolean,
|
||||
private val automaticSyncManager: AutomaticSyncManager,
|
||||
@ApplicationContext private val context: Context,
|
||||
@ApplicationContext val context: Context,
|
||||
private val logger: Logger,
|
||||
private val migrations: Map<Int, @JvmSuppressWildcards Provider<AccountSettingsMigration>>,
|
||||
private val migrationsFactory: AccountSettingsMigrations.Factory,
|
||||
private val settingsManager: SettingsManager,
|
||||
private val syncFramework: SyncFrameworkIntegration,
|
||||
private val syncWorkerManager: SyncWorkerManager
|
||||
) {
|
||||
|
||||
@@ -61,7 +56,7 @@ class AccountSettings @AssistedInject constructor(
|
||||
* migrations.
|
||||
*/
|
||||
@WorkerThread
|
||||
fun create(account: Account, abortOnMissingMigration: Boolean = false): AccountSettings
|
||||
fun create(account: Account): AccountSettings
|
||||
}
|
||||
|
||||
init {
|
||||
@@ -76,7 +71,7 @@ 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 for AccountSettings(): ${account.type}")
|
||||
throw IllegalArgumentException("Invalid account type: ${account.type}")
|
||||
|
||||
// synchronize because account migration must only be run one time
|
||||
synchronized(AccountSettings::class.java) {
|
||||
@@ -95,11 +90,8 @@ class AccountSettings @AssistedInject constructor(
|
||||
throw IllegalStateException("Redundant call: migration created AccountSettings()")
|
||||
} else {
|
||||
currentlyUpdating = true
|
||||
try {
|
||||
update(version, abortOnMissingMigration)
|
||||
} finally {
|
||||
currentlyUpdating = false
|
||||
}
|
||||
update(version)
|
||||
currentlyUpdating = false
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -143,7 +135,7 @@ class AccountSettings @AssistedInject constructor(
|
||||
fun getSyncInterval(authority: String): Long? {
|
||||
val addrBookAuthority = context.getString(R.string.address_books_authority)
|
||||
|
||||
if (!syncFramework.isSyncable(account, authority) && authority != addrBookAuthority)
|
||||
if (ContentResolver.getIsSyncable(account, authority) <= 0 && authority != addrBookAuthority)
|
||||
return null
|
||||
|
||||
val key = when {
|
||||
@@ -162,6 +154,7 @@ class AccountSettings @AssistedInject constructor(
|
||||
|
||||
/**
|
||||
* 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.
|
||||
@@ -193,7 +186,55 @@ class AccountSettings @AssistedInject constructor(
|
||||
}
|
||||
accountManager.setAndVerifyUserData(account, key, seconds.toString())
|
||||
|
||||
automaticSyncManager.setSyncInterval(account, authority, seconds, getSyncWifiOnly())
|
||||
// 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].
|
||||
*
|
||||
* 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
|
||||
*/
|
||||
@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)
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
fun getSyncWifiOnly() =
|
||||
@@ -205,9 +246,9 @@ class AccountSettings @AssistedInject constructor(
|
||||
fun setSyncWiFiOnly(wiFiOnly: Boolean) {
|
||||
accountManager.setAndVerifyUserData(account, KEY_WIFI_ONLY, if (wiFiOnly) "1" else null)
|
||||
|
||||
// update automatic sync (needs already updated wifi-only flag in AccountSettings)
|
||||
for (authority in syncWorkerManager.syncAuthorities())
|
||||
automaticSyncManager.setSyncInterval(account, authority, getSyncInterval(authority), wiFiOnly)
|
||||
// update sync workers (needs already updated wifi-only flag in AccountSettings)
|
||||
for (authority in SyncUtils.syncAuthorities(context))
|
||||
updatePeriodicSyncWorker(authority, getSyncInterval(authority), wiFiOnly)
|
||||
}
|
||||
|
||||
fun getSyncWifiOnlySSIDs(): List<String>? =
|
||||
@@ -232,6 +273,30 @@ 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
|
||||
|
||||
@@ -350,32 +415,29 @@ class AccountSettings @AssistedInject constructor(
|
||||
|
||||
// update from previous account settings
|
||||
|
||||
private fun update(baseVersion: Int, abortOnMissingMigration: Boolean) {
|
||||
private fun update(baseVersion: Int) {
|
||||
for (toVersion in baseVersion+1 ..CURRENT_VERSION) {
|
||||
val fromVersion = toVersion - 1
|
||||
logger.info("Updating account ${account.name} settings version $fromVersion → $toVersion")
|
||||
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 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, this)
|
||||
|
||||
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)
|
||||
}
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
|
||||
const val CURRENT_VERSION = 18
|
||||
const val CURRENT_VERSION = 17
|
||||
const val KEY_SETTINGS_VERSION = "version"
|
||||
|
||||
const val KEY_SYNC_INTERVAL_ADDRESSBOOKS = "sync_interval_addressbooks"
|
||||
|
||||
@@ -0,0 +1,423 @@
|
||||
/*
|
||||
* 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
|
||||
|
||||
}
|
||||
@@ -1,28 +0,0 @@
|
||||
/*
|
||||
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
|
||||
*/
|
||||
|
||||
package at.bitfire.davdroid.settings.migration
|
||||
|
||||
import android.accounts.Account
|
||||
import at.bitfire.davdroid.settings.AccountSettings
|
||||
|
||||
interface AccountSettingsMigration {
|
||||
|
||||
/**
|
||||
* Migrate the account settings from the old version to the new version.
|
||||
*
|
||||
* The target version number is registered in the Hilt module as [Int] key of the multi-binding of [AccountSettings].
|
||||
*
|
||||
* @param account The account to migrate.
|
||||
* @param accountSettings The account settings object that initiated the migration.
|
||||
*
|
||||
* This method _must not_ create [AccountSettings] itself! This would cause an infinite loop. Use [accountSettings] instead.
|
||||
*
|
||||
* This method should depend on current architecture of [AccountSettings] as little as possible. Methods of [AccountSettings]
|
||||
* may change in future and it shouldn't be necessary to change migrations as well. So it's better to operate "low-level"
|
||||
* directly on the account user-data – which is also better testable.
|
||||
*/
|
||||
fun migrate(account: Account, accountSettings: AccountSettings)
|
||||
|
||||
}
|
||||
@@ -1,64 +0,0 @@
|
||||
/*
|
||||
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
|
||||
*/
|
||||
|
||||
package at.bitfire.davdroid.settings.migration
|
||||
|
||||
import android.accounts.Account
|
||||
import android.content.ContentValues
|
||||
import android.content.Context
|
||||
import android.content.pm.PackageManager
|
||||
import android.provider.CalendarContract
|
||||
import androidx.core.content.ContextCompat
|
||||
import at.bitfire.davdroid.resource.LocalTask
|
||||
import at.bitfire.davdroid.settings.AccountSettings
|
||||
import at.bitfire.ical4android.AndroidCalendar
|
||||
import at.bitfire.ical4android.TaskProvider
|
||||
import at.techbee.jtx.JtxContract.asSyncAdapter
|
||||
import dagger.Binds
|
||||
import dagger.Module
|
||||
import dagger.hilt.InstallIn
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import dagger.hilt.components.SingletonComponent
|
||||
import dagger.multibindings.IntKey
|
||||
import dagger.multibindings.IntoMap
|
||||
import org.dmfs.tasks.contract.TaskContract
|
||||
import javax.inject.Inject
|
||||
import kotlin.use
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
class AccountSettingsMigration10 @Inject constructor(
|
||||
@ApplicationContext private val context: Context
|
||||
): AccountSettingsMigration {
|
||||
|
||||
override fun migrate(account: Account, accountSettings: AccountSettings) {
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@Module
|
||||
@InstallIn(SingletonComponent::class)
|
||||
abstract class AccountSettingsMigrationModule {
|
||||
@Binds @IntoMap
|
||||
@IntKey(10)
|
||||
abstract fun provide(impl: AccountSettingsMigration10): AccountSettingsMigration
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,50 +0,0 @@
|
||||
/*
|
||||
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
|
||||
*/
|
||||
|
||||
package at.bitfire.davdroid.settings.migration
|
||||
|
||||
import android.accounts.Account
|
||||
import android.accounts.AccountManager
|
||||
import android.content.Context
|
||||
import at.bitfire.davdroid.settings.AccountSettings
|
||||
import at.bitfire.davdroid.sync.TasksAppManager
|
||||
import at.bitfire.davdroid.sync.account.setAndVerifyUserData
|
||||
import dagger.Binds
|
||||
import dagger.Module
|
||||
import dagger.hilt.InstallIn
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import dagger.hilt.components.SingletonComponent
|
||||
import dagger.multibindings.IntKey
|
||||
import dagger.multibindings.IntoMap
|
||||
import javax.inject.Inject
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
class AccountSettingsMigration11 @Inject constructor(
|
||||
@ApplicationContext private val context: Context,
|
||||
private val tasksAppManager: TasksAppManager
|
||||
): AccountSettingsMigration {
|
||||
|
||||
override fun migrate(account: Account, accountSettings: AccountSettings) {
|
||||
val accountManager: AccountManager = AccountManager.get(context)
|
||||
tasksAppManager.currentProvider()?.let { provider ->
|
||||
val interval = accountSettings.getSyncInterval(provider.authority)
|
||||
if (interval != null)
|
||||
accountManager.setAndVerifyUserData(account,
|
||||
AccountSettings.KEY_SYNC_INTERVAL_TASKS, interval.toString())
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@Module
|
||||
@InstallIn(SingletonComponent::class)
|
||||
abstract class AccountSettingsMigrationModule {
|
||||
@Binds @IntoMap
|
||||
@IntKey(11)
|
||||
abstract fun provide(impl: AccountSettingsMigration11): AccountSettingsMigration
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,126 +0,0 @@
|
||||
/*
|
||||
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
|
||||
*/
|
||||
|
||||
package at.bitfire.davdroid.settings.migration
|
||||
|
||||
import android.accounts.Account
|
||||
import android.content.ContentUris
|
||||
import android.content.ContentValues
|
||||
import android.content.Context
|
||||
import android.content.pm.PackageManager
|
||||
import android.provider.CalendarContract
|
||||
import android.util.Base64
|
||||
import androidx.core.content.ContextCompat
|
||||
import at.bitfire.davdroid.settings.AccountSettings
|
||||
import at.bitfire.ical4android.AndroidEvent
|
||||
import at.bitfire.ical4android.UnknownProperty
|
||||
import at.techbee.jtx.JtxContract.asSyncAdapter
|
||||
import dagger.Binds
|
||||
import dagger.Module
|
||||
import dagger.hilt.InstallIn
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import dagger.hilt.components.SingletonComponent
|
||||
import dagger.multibindings.IntKey
|
||||
import dagger.multibindings.IntoMap
|
||||
import net.fortuna.ical4j.model.Property
|
||||
import net.fortuna.ical4j.model.property.Url
|
||||
import java.io.ByteArrayInputStream
|
||||
import java.io.ObjectInputStream
|
||||
import java.util.logging.Level
|
||||
import java.util.logging.Logger
|
||||
import javax.inject.Inject
|
||||
import kotlin.use
|
||||
|
||||
/**
|
||||
* Store event URLs as URL (extended property) instead of unknown property. At the same time,
|
||||
* convert legacy unknown properties to the current format.
|
||||
*/
|
||||
class AccountSettingsMigration12 @Inject constructor(
|
||||
@ApplicationContext private val context: Context,
|
||||
private val logger: Logger
|
||||
): AccountSettingsMigration {
|
||||
|
||||
override fun migrate(account: Account, accountSettings: AccountSettings) {
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@Module
|
||||
@InstallIn(SingletonComponent::class)
|
||||
abstract class AccountSettingsMigrationModule {
|
||||
@Binds @IntoMap
|
||||
@IntKey(12)
|
||||
abstract fun provide(impl: AccountSettingsMigration12): AccountSettingsMigration
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,71 +0,0 @@
|
||||
/*
|
||||
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
|
||||
*/
|
||||
|
||||
package at.bitfire.davdroid.settings.migration
|
||||
|
||||
import android.accounts.Account
|
||||
import android.content.Context
|
||||
import androidx.preference.PreferenceManager
|
||||
import at.bitfire.davdroid.settings.AccountSettings
|
||||
import at.bitfire.davdroid.settings.Settings
|
||||
import dagger.Binds
|
||||
import dagger.Module
|
||||
import dagger.hilt.InstallIn
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import dagger.hilt.components.SingletonComponent
|
||||
import dagger.multibindings.IntKey
|
||||
import dagger.multibindings.IntoMap
|
||||
import javax.inject.Inject
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
class AccountSettingsMigration13 @Inject constructor(
|
||||
@ApplicationContext private val context: Context
|
||||
): AccountSettingsMigration {
|
||||
|
||||
override fun migrate(account: Account, accountSettings: AccountSettings) {
|
||||
// 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()
|
||||
}
|
||||
|
||||
|
||||
@Module
|
||||
@InstallIn(SingletonComponent::class)
|
||||
abstract class AccountSettingsMigrationModule {
|
||||
@Binds @IntoMap
|
||||
@IntKey(13)
|
||||
abstract fun provide(impl: AccountSettingsMigration13): AccountSettingsMigration
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,96 +0,0 @@
|
||||
/*
|
||||
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
|
||||
*/
|
||||
|
||||
package at.bitfire.davdroid.settings.migration
|
||||
|
||||
import android.accounts.Account
|
||||
import android.content.ContentResolver
|
||||
import android.content.Context
|
||||
import android.provider.CalendarContract
|
||||
import at.bitfire.davdroid.R
|
||||
import at.bitfire.davdroid.settings.AccountSettings
|
||||
import at.bitfire.ical4android.TaskProvider
|
||||
import dagger.Binds
|
||||
import dagger.Module
|
||||
import dagger.hilt.InstallIn
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import dagger.hilt.components.SingletonComponent
|
||||
import dagger.multibindings.IntKey
|
||||
import dagger.multibindings.IntoMap
|
||||
import java.util.logging.Logger
|
||||
import javax.inject.Inject
|
||||
|
||||
/**
|
||||
* Disables all sync adapter periodic syncs for every authority. Then enables
|
||||
* corresponding PeriodicSyncWorkers
|
||||
*/
|
||||
class AccountSettingsMigration14 @Inject constructor(
|
||||
@ApplicationContext private val context: Context,
|
||||
private val logger: Logger
|
||||
): AccountSettingsMigration {
|
||||
|
||||
override fun migrate(account: Account, accountSettings: AccountSettings) {
|
||||
// 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
|
||||
enableWorkManager(account, authority, accountSettings)
|
||||
// Disable periodic syncs (sync adapter framework)
|
||||
disableSyncFramework(account, authority)
|
||||
}
|
||||
}
|
||||
|
||||
private fun enableWorkManager(account: Account, authority: String, accountSettings: AccountSettings) {
|
||||
val enabled = accountSettings.getSyncInterval(authority)?.let { syncInterval ->
|
||||
accountSettings.setSyncInterval(authority, syncInterval)
|
||||
} ?: false
|
||||
logger.info("PeriodicSyncWorker for $account/$authority enabled=$enabled")
|
||||
}
|
||||
|
||||
private fun disableSyncFramework(account: Account, 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")
|
||||
}
|
||||
|
||||
|
||||
@Module
|
||||
@InstallIn(SingletonComponent::class)
|
||||
abstract class AccountSettingsMigrationModule {
|
||||
@Binds @IntoMap
|
||||
@IntKey(14)
|
||||
abstract fun provide(impl: AccountSettingsMigration14): AccountSettingsMigration
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,44 +0,0 @@
|
||||
/*
|
||||
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
|
||||
*/
|
||||
|
||||
package at.bitfire.davdroid.settings.migration
|
||||
|
||||
import android.accounts.Account
|
||||
import at.bitfire.davdroid.settings.AccountSettings
|
||||
import at.bitfire.davdroid.sync.worker.SyncWorkerManager
|
||||
import dagger.Binds
|
||||
import dagger.Module
|
||||
import dagger.hilt.InstallIn
|
||||
import dagger.hilt.components.SingletonComponent
|
||||
import dagger.multibindings.IntKey
|
||||
import dagger.multibindings.IntoMap
|
||||
import javax.inject.Inject
|
||||
|
||||
/**
|
||||
* 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].
|
||||
*/
|
||||
class AccountSettingsMigration15 @Inject constructor(
|
||||
private val syncWorkerManager: SyncWorkerManager
|
||||
): AccountSettingsMigration {
|
||||
|
||||
override fun migrate(account: Account, accountSettings: AccountSettings) {
|
||||
for (authority in syncWorkerManager.syncAuthorities()) {
|
||||
val interval = accountSettings.getSyncInterval(authority)
|
||||
accountSettings.setSyncInterval(authority, interval ?: AccountSettings.SYNC_INTERVAL_MANUALLY)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@Module
|
||||
@InstallIn(SingletonComponent::class)
|
||||
abstract class AccountSettingsMigrationModule {
|
||||
@Binds @IntoMap
|
||||
@IntKey(15)
|
||||
abstract fun provide(impl: AccountSettingsMigration15): AccountSettingsMigration
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,64 +0,0 @@
|
||||
/*
|
||||
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
|
||||
*/
|
||||
|
||||
package at.bitfire.davdroid.settings.migration
|
||||
|
||||
import android.accounts.Account
|
||||
import android.content.Context
|
||||
import androidx.work.WorkManager
|
||||
import at.bitfire.davdroid.settings.AccountSettings
|
||||
import at.bitfire.davdroid.sync.worker.SyncWorkerManager
|
||||
import dagger.Binds
|
||||
import dagger.Module
|
||||
import dagger.hilt.InstallIn
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import dagger.hilt.components.SingletonComponent
|
||||
import dagger.multibindings.IntKey
|
||||
import dagger.multibindings.IntoMap
|
||||
import java.util.logging.Logger
|
||||
import javax.inject.Inject
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
class AccountSettingsMigration16 @Inject constructor(
|
||||
@ApplicationContext private val context: Context,
|
||||
private val logger: Logger,
|
||||
private val syncWorkerManager: SyncWorkerManager
|
||||
): AccountSettingsMigration {
|
||||
|
||||
override fun migrate(account: Account, accountSettings: AccountSettings) {
|
||||
for (authority in syncWorkerManager.syncAuthorities()) {
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@Module
|
||||
@InstallIn(SingletonComponent::class)
|
||||
abstract class AccountSettingsMigrationModule {
|
||||
@Binds @IntoMap
|
||||
@IntKey(16)
|
||||
abstract fun provide(impl: AccountSettingsMigration16): AccountSettingsMigration
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,84 +0,0 @@
|
||||
/*
|
||||
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
|
||||
*/
|
||||
|
||||
package at.bitfire.davdroid.settings.migration
|
||||
|
||||
import android.accounts.Account
|
||||
import android.accounts.AccountManager
|
||||
import android.content.Context
|
||||
import android.provider.ContactsContract
|
||||
import at.bitfire.davdroid.R
|
||||
import at.bitfire.davdroid.db.Service
|
||||
import at.bitfire.davdroid.repository.DavCollectionRepository
|
||||
import at.bitfire.davdroid.repository.DavServiceRepository
|
||||
import at.bitfire.davdroid.resource.LocalAddressBook
|
||||
import at.bitfire.davdroid.resource.LocalAddressBookStore
|
||||
import at.bitfire.davdroid.settings.AccountSettings
|
||||
import dagger.Binds
|
||||
import dagger.Module
|
||||
import dagger.hilt.InstallIn
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import dagger.hilt.components.SingletonComponent
|
||||
import dagger.multibindings.IntKey
|
||||
import dagger.multibindings.IntoMap
|
||||
import java.util.logging.Level
|
||||
import java.util.logging.Logger
|
||||
import javax.inject.Inject
|
||||
import kotlin.use
|
||||
|
||||
/**
|
||||
* With DAVx5 4.4.3 address book account names now contain the collection ID as a unique
|
||||
* identifier. We need to update the address book account names.
|
||||
*/
|
||||
class AccountSettingsMigration17 @Inject constructor(
|
||||
private val collectionRepository: DavCollectionRepository,
|
||||
@ApplicationContext private val context: Context,
|
||||
private val localAddressBookFactory: LocalAddressBook.Factory,
|
||||
private val localAddressBookStore: LocalAddressBookStore,
|
||||
private val logger: Logger,
|
||||
private val serviceRepository: DavServiceRepository
|
||||
): AccountSettingsMigration {
|
||||
|
||||
override fun migrate(account: Account, accountSettings: AccountSettings) {
|
||||
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
|
||||
|
||||
val accountManager = AccountManager.get(context)
|
||||
// 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 oldAddressBook = localAddressBookFactory.create(account, oldAddressBookAccount, provider)
|
||||
val url = accountManager.getUserData(oldAddressBookAccount, LocalAddressBook.USER_DATA_URL)
|
||||
collectionRepository.getByServiceAndUrl(service.id, url)?.let { collection ->
|
||||
// Set collection ID and rename the account
|
||||
localAddressBookStore.update(provider, oldAddressBook, collection)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@Module
|
||||
@InstallIn(SingletonComponent::class)
|
||||
abstract class AccountSettingsMigrationModule {
|
||||
@Binds @IntoMap
|
||||
@IntKey(17)
|
||||
abstract fun provide(impl: AccountSettingsMigration17): AccountSettingsMigration
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,70 +0,0 @@
|
||||
/*
|
||||
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
|
||||
*/
|
||||
|
||||
package at.bitfire.davdroid.settings.migration
|
||||
|
||||
import android.accounts.Account
|
||||
import android.accounts.AccountManager
|
||||
import android.content.Context
|
||||
import at.bitfire.davdroid.R
|
||||
import at.bitfire.davdroid.db.AppDatabase
|
||||
import at.bitfire.davdroid.db.Service
|
||||
import at.bitfire.davdroid.resource.LocalAddressBook
|
||||
import at.bitfire.davdroid.settings.AccountSettings
|
||||
import at.bitfire.davdroid.sync.account.setAndVerifyUserData
|
||||
import dagger.Binds
|
||||
import dagger.Module
|
||||
import dagger.hilt.InstallIn
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import dagger.hilt.components.SingletonComponent
|
||||
import dagger.multibindings.IntKey
|
||||
import dagger.multibindings.IntoMap
|
||||
import javax.inject.Inject
|
||||
|
||||
/**
|
||||
* v17 had removed the binding between address book accounts and accounts and introduced
|
||||
* the binding to collection IDs instead.
|
||||
*
|
||||
* However, it turned out that the account binding is needed even with collection IDs for the case
|
||||
* that the collection is not available in the database anymore (for instance, because it has been
|
||||
* removed on the server). In that case, the [at.bitfire.davdroid.sync.Syncer] still needs to get
|
||||
* a list of all address book accounts that belong to the account, and not _all_ address books.
|
||||
*
|
||||
* So this migration again assigns address book accounts to accounts.
|
||||
*/
|
||||
class AccountSettingsMigration18 @Inject constructor(
|
||||
@ApplicationContext private val context: Context,
|
||||
private val db: AppDatabase
|
||||
): AccountSettingsMigration {
|
||||
|
||||
override fun migrate(account: Account, accountSettings: AccountSettings) {
|
||||
val accountManager = AccountManager.get(context)
|
||||
db.serviceDao().getByAccountAndType(account.name, Service.TYPE_CARDDAV)?.let { service ->
|
||||
db.collectionDao().getByService(service.id).forEach { collection ->
|
||||
// Find associated address book account by collection ID (if it exists)
|
||||
val addressBookAccount = accountManager
|
||||
.getAccountsByType(context.getString(R.string.account_type_address_book))
|
||||
.firstOrNull { accountManager.getUserData(it, LocalAddressBook.USER_DATA_COLLECTION_ID) == collection.id.toString() }
|
||||
|
||||
if (addressBookAccount != null) {
|
||||
// (Re-)assign address book to account
|
||||
accountManager.setAndVerifyUserData(addressBookAccount, LocalAddressBook.USER_DATA_ACCOUNT_NAME, account.name)
|
||||
accountManager.setAndVerifyUserData(addressBookAccount, LocalAddressBook.USER_DATA_ACCOUNT_TYPE, account.type)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Address books without an assigned account will be removed by AccountsCleanupWorker
|
||||
}
|
||||
|
||||
|
||||
@Module
|
||||
@InstallIn(SingletonComponent::class)
|
||||
abstract class AccountSettingsMigrationModule {
|
||||
@Binds @IntoMap
|
||||
@IntKey(18)
|
||||
abstract fun provide(impl: AccountSettingsMigration18): AccountSettingsMigration
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,50 +0,0 @@
|
||||
/*
|
||||
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
|
||||
*/
|
||||
|
||||
package at.bitfire.davdroid.settings.migration
|
||||
|
||||
import android.accounts.Account
|
||||
import android.accounts.AccountManager
|
||||
import android.content.Context
|
||||
import android.provider.CalendarContract
|
||||
import at.bitfire.davdroid.settings.AccountSettings
|
||||
import at.bitfire.davdroid.sync.account.setAndVerifyUserData
|
||||
import at.bitfire.ical4android.AndroidCalendar
|
||||
import dagger.Binds
|
||||
import dagger.Module
|
||||
import dagger.hilt.InstallIn
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import dagger.hilt.components.SingletonComponent
|
||||
import dagger.multibindings.IntKey
|
||||
import dagger.multibindings.IntoMap
|
||||
import javax.inject.Inject
|
||||
import kotlin.use
|
||||
|
||||
class AccountSettingsMigration7 @Inject constructor(
|
||||
@ApplicationContext private val context: Context
|
||||
): AccountSettingsMigration {
|
||||
|
||||
override fun migrate(account: Account, accountSettings: AccountSettings) {
|
||||
// add calendar colors
|
||||
context.contentResolver.acquireContentProviderClient(CalendarContract.AUTHORITY)?.use { provider ->
|
||||
AndroidCalendar.insertColors(provider, account)
|
||||
}
|
||||
|
||||
// update allowed WiFi settings key
|
||||
val accountManager = AccountManager.get(context)
|
||||
val onlySSID = accountManager.getUserData(account, "wifi_only_ssid")
|
||||
accountManager.setAndVerifyUserData(account, AccountSettings.KEY_WIFI_ONLY_SSIDS, onlySSID)
|
||||
accountManager.setAndVerifyUserData(account, "wifi_only_ssid", null)
|
||||
}
|
||||
|
||||
|
||||
@Module
|
||||
@InstallIn(SingletonComponent::class)
|
||||
abstract class AccountSettingsMigrationModule {
|
||||
@Binds @IntoMap
|
||||
@IntKey(7)
|
||||
abstract fun provide(impl: AccountSettingsMigration7): AccountSettingsMigration
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,71 +0,0 @@
|
||||
/*
|
||||
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
|
||||
*/
|
||||
|
||||
package at.bitfire.davdroid.settings.migration
|
||||
|
||||
import android.accounts.Account
|
||||
import android.content.ContentUris
|
||||
import android.content.ContentValues
|
||||
import android.content.Context
|
||||
import at.bitfire.davdroid.settings.AccountSettings
|
||||
import at.bitfire.ical4android.TaskProvider
|
||||
import at.techbee.jtx.JtxContract.asSyncAdapter
|
||||
import dagger.Binds
|
||||
import dagger.Module
|
||||
import dagger.hilt.InstallIn
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import dagger.hilt.components.SingletonComponent
|
||||
import dagger.multibindings.IntKey
|
||||
import dagger.multibindings.IntoMap
|
||||
import org.dmfs.tasks.contract.TaskContract
|
||||
import org.dmfs.tasks.contract.TaskContract.CommonSyncColumns
|
||||
import java.util.logging.Level
|
||||
import java.util.logging.Logger
|
||||
import javax.inject.Inject
|
||||
|
||||
class AccountSettingsMigration8 @Inject constructor(
|
||||
@ApplicationContext private val context: Context,
|
||||
private val logger: Logger
|
||||
): AccountSettingsMigration {
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
override fun migrate(account: Account, accountSettings: AccountSettings) {
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@Module
|
||||
@InstallIn(SingletonComponent::class)
|
||||
abstract class AccountSettingsMigrationModule {
|
||||
@Binds @IntoMap
|
||||
@IntKey(8)
|
||||
abstract fun provide(impl: AccountSettingsMigration8): AccountSettingsMigration
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,48 +0,0 @@
|
||||
/*
|
||||
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
|
||||
*/
|
||||
|
||||
package at.bitfire.davdroid.settings.migration
|
||||
|
||||
import android.accounts.Account
|
||||
import android.content.ContentResolver
|
||||
import at.bitfire.davdroid.db.AppDatabase
|
||||
import at.bitfire.davdroid.db.Service
|
||||
import at.bitfire.davdroid.settings.AccountSettings
|
||||
import at.bitfire.ical4android.TaskProvider
|
||||
import dagger.Binds
|
||||
import dagger.Module
|
||||
import dagger.hilt.InstallIn
|
||||
import dagger.hilt.components.SingletonComponent
|
||||
import dagger.multibindings.IntKey
|
||||
import dagger.multibindings.IntoMap
|
||||
import java.util.logging.Logger
|
||||
import javax.inject.Inject
|
||||
|
||||
/**
|
||||
* It seems that somehow some non-CalDAV accounts got OpenTasks syncable, which caused battery problems.
|
||||
* Disable it on those accounts for the future.
|
||||
*/
|
||||
class AccountSettingsMigration9 @Inject constructor(
|
||||
private val db: AppDatabase,
|
||||
private val logger: Logger
|
||||
): AccountSettingsMigration {
|
||||
|
||||
override fun migrate(account: Account, accountSettings: AccountSettings) {
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@Module
|
||||
@InstallIn(SingletonComponent::class)
|
||||
abstract class AccountSettingsMigrationModule {
|
||||
@Binds @IntoMap
|
||||
@IntKey(9)
|
||||
abstract fun provide(impl: AccountSettingsMigration9): AccountSettingsMigration
|
||||
}
|
||||
|
||||
}
|
||||
@@ -11,8 +11,9 @@ import at.bitfire.davdroid.db.Collection
|
||||
import at.bitfire.davdroid.db.Service
|
||||
import at.bitfire.davdroid.network.HttpClient
|
||||
import at.bitfire.davdroid.resource.LocalAddressBook
|
||||
import at.bitfire.davdroid.resource.LocalAddressBookStore
|
||||
import at.bitfire.davdroid.sync.account.setAndVerifyUserData
|
||||
import at.bitfire.davdroid.settings.Settings
|
||||
import at.bitfire.davdroid.settings.SettingsManager
|
||||
import at.bitfire.davdroid.util.setAndVerifyUserData
|
||||
import dagger.assisted.Assisted
|
||||
import dagger.assisted.AssistedFactory
|
||||
import dagger.assisted.AssistedInject
|
||||
@@ -25,16 +26,20 @@ class AddressBookSyncer @AssistedInject constructor(
|
||||
@Assisted account: Account,
|
||||
@Assisted extras: Array<String>,
|
||||
@Assisted syncResult: SyncResult,
|
||||
addressBookStore: LocalAddressBookStore,
|
||||
private val contactsSyncManagerFactory: ContactsSyncManager.Factory
|
||||
): Syncer<LocalAddressBookStore, LocalAddressBook>(account, extras, syncResult) {
|
||||
private val contactsSyncManagerFactory: ContactsSyncManager.Factory,
|
||||
settingsManager: SettingsManager
|
||||
): Syncer<LocalAddressBook>(account, extras, syncResult) {
|
||||
|
||||
@AssistedFactory
|
||||
interface Factory {
|
||||
fun create(account: Account, extras: Array<String>, syncResult: SyncResult): AddressBookSyncer
|
||||
}
|
||||
|
||||
override val dataStore = addressBookStore
|
||||
companion object {
|
||||
const val PREVIOUS_GROUP_METHOD = "previous_group_method"
|
||||
}
|
||||
|
||||
private val forceAllReadOnly = settingsManager.getBoolean(Settings.FORCE_READ_ONLY_ADDRESSBOOKS)
|
||||
|
||||
override val serviceType: String
|
||||
get() = Service.TYPE_CARDDAV
|
||||
@@ -42,9 +47,31 @@ class AddressBookSyncer @AssistedInject constructor(
|
||||
get() = ContactsContract.AUTHORITY // Address books use the contacts authority for sync
|
||||
|
||||
|
||||
override fun getLocalCollections(provider: ContentProviderClient): List<LocalAddressBook> =
|
||||
serviceRepository.getByAccountAndType(account.name, serviceType)?.let { service ->
|
||||
// Get _all_ address books; Otherwise address book accounts of unchecked address books will not be removed
|
||||
collectionRepository.getByService(service.id).mapNotNull { collection ->
|
||||
LocalAddressBook.findByCollection(context, provider, collection.id)
|
||||
}
|
||||
}.orEmpty()
|
||||
|
||||
override fun getDbSyncCollections(serviceId: Long): List<Collection> =
|
||||
collectionRepository.getByServiceAndSync(serviceId)
|
||||
|
||||
override fun update(localCollection: LocalAddressBook, remoteCollection: Collection) {
|
||||
try {
|
||||
logger.log(Level.FINE, "Updating local address book ${remoteCollection.url}", remoteCollection)
|
||||
localCollection.update(remoteCollection, forceAllReadOnly)
|
||||
} catch (e: Exception) {
|
||||
logger.log(Level.WARNING, "Couldn't rename address book account", e)
|
||||
}
|
||||
}
|
||||
|
||||
override fun create(provider: ContentProviderClient, remoteCollection: Collection): LocalAddressBook {
|
||||
logger.log(Level.INFO, "Adding local address book", remoteCollection)
|
||||
return LocalAddressBook.create(context, provider, remoteCollection, forceAllReadOnly)
|
||||
}
|
||||
|
||||
override fun syncCollection(provider: ContentProviderClient, localCollection: LocalAddressBook, remoteCollection: Collection) {
|
||||
logger.info("Synchronizing address book: ${localCollection.addressBookAccount.name}")
|
||||
syncAddressBook(
|
||||
@@ -106,11 +133,4 @@ class AddressBookSyncer @AssistedInject constructor(
|
||||
logger.info("Contacts sync complete")
|
||||
}
|
||||
|
||||
|
||||
companion object {
|
||||
|
||||
const val PREVIOUS_GROUP_METHOD = "previous_group_method"
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,55 +0,0 @@
|
||||
/*
|
||||
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
|
||||
*/
|
||||
|
||||
package at.bitfire.davdroid.sync
|
||||
|
||||
import android.accounts.Account
|
||||
import at.bitfire.davdroid.sync.worker.SyncWorkerManager
|
||||
import javax.inject.Inject
|
||||
|
||||
/**
|
||||
* Manages automatic synchronization, that is:
|
||||
*
|
||||
* - synchronization in given intervals, and
|
||||
* - synchronization on local data changes.
|
||||
*/
|
||||
class AutomaticSyncManager @Inject constructor(
|
||||
private val syncFramework: SyncFrameworkIntegration,
|
||||
private val workerManager: SyncWorkerManager
|
||||
) {
|
||||
|
||||
/**
|
||||
* Disable automatic synchronization for the given account and data type.
|
||||
*/
|
||||
fun disable(account: Account, authority: String) {
|
||||
workerManager.disablePeriodic(account, authority)
|
||||
syncFramework.disableSyncAbility(account, authority)
|
||||
}
|
||||
|
||||
/**
|
||||
* Enables automatic synchronization for the given account and data type and sets it to the given interval:
|
||||
*
|
||||
* 1. Sets up periodic sync for the given data type with the given interval.
|
||||
* 2. Enables sync in the sync framework for the given data type and sets up periodic sync with the given interval.
|
||||
*
|
||||
* @param account the account to synchronize
|
||||
* @param authority the authority to synchronize
|
||||
* @param wifiOnly whether to synchronize only on Wi-Fi
|
||||
* @param seconds interval in seconds, or `null` to disable periodic sync (only sync on local data changes)
|
||||
*/
|
||||
fun setSyncInterval(account: Account, authority: String, seconds: Long?, wifiOnly: Boolean) {
|
||||
if (seconds != null) {
|
||||
// update sync workers (needs already updated sync interval in AccountSettings)
|
||||
workerManager.enablePeriodic(account, authority, seconds, wifiOnly)
|
||||
} else
|
||||
workerManager.disablePeriodic(account, authority)
|
||||
|
||||
// Also enable/disable content change triggered syncs
|
||||
if (seconds != null)
|
||||
syncFramework.enableSyncOnContentChange(account, authority)
|
||||
else
|
||||
syncFramework.disableSyncOnContentChange(account, authority)
|
||||
}
|
||||
|
||||
}
|
||||
@@ -273,12 +273,14 @@ class CalendarSyncManager @AssistedInject constructor(
|
||||
local.eTag = eTag
|
||||
local.scheduleTag = scheduleTag
|
||||
local.update(event)
|
||||
syncResult.stats.numUpdates++
|
||||
} else {
|
||||
logger.log(Level.INFO, "Adding $fileName to local calendar", event)
|
||||
val newLocal = LocalEvent(localCollection, event, fileName, eTag, scheduleTag, LocalResource.FLAG_REMOTELY_PRESENT)
|
||||
SyncException.wrapWithLocalResource(newLocal) {
|
||||
newLocal.add()
|
||||
}
|
||||
syncResult.stats.numInserts++
|
||||
}
|
||||
}
|
||||
} else
|
||||
|
||||
@@ -6,15 +6,16 @@ package at.bitfire.davdroid.sync
|
||||
|
||||
import android.accounts.Account
|
||||
import android.content.ContentProviderClient
|
||||
import android.content.ContentUris
|
||||
import android.provider.CalendarContract
|
||||
import at.bitfire.davdroid.db.Collection
|
||||
import at.bitfire.davdroid.db.Service
|
||||
import at.bitfire.davdroid.resource.LocalCalendar
|
||||
import at.bitfire.davdroid.resource.LocalCalendarStore
|
||||
import at.bitfire.ical4android.AndroidCalendar
|
||||
import dagger.assisted.Assisted
|
||||
import dagger.assisted.AssistedFactory
|
||||
import dagger.assisted.AssistedInject
|
||||
import java.util.logging.Level
|
||||
|
||||
/**
|
||||
* Sync logic for calendars
|
||||
@@ -23,23 +24,23 @@ class CalendarSyncer @AssistedInject constructor(
|
||||
@Assisted account: Account,
|
||||
@Assisted extras: Array<String>,
|
||||
@Assisted syncResult: SyncResult,
|
||||
calendarStore: LocalCalendarStore,
|
||||
private val calendarSyncManagerFactory: CalendarSyncManager.Factory
|
||||
): Syncer<LocalCalendarStore, LocalCalendar>(account, extras, syncResult) {
|
||||
): Syncer<LocalCalendar>(account, extras, syncResult) {
|
||||
|
||||
@AssistedFactory
|
||||
interface Factory {
|
||||
fun create(account: Account, extras: Array<String>, syncResult: SyncResult): CalendarSyncer
|
||||
}
|
||||
|
||||
override val dataStore = calendarStore
|
||||
|
||||
override val serviceType: String
|
||||
get() = Service.TYPE_CALDAV
|
||||
override val authority: String
|
||||
get() = CalendarContract.AUTHORITY
|
||||
|
||||
|
||||
override fun getLocalCollections(provider: ContentProviderClient): List<LocalCalendar>
|
||||
= AndroidCalendar.find(account, provider, LocalCalendar.Factory, "${CalendarContract.Calendars.SYNC_EVENTS}!=0", null)
|
||||
|
||||
override fun prepare(provider: ContentProviderClient): Boolean {
|
||||
// Update colors
|
||||
if (accountSettings.getEventColors())
|
||||
@@ -68,4 +69,15 @@ class CalendarSyncer @AssistedInject constructor(
|
||||
syncManager.performSync()
|
||||
}
|
||||
|
||||
override fun update(localCollection: LocalCalendar, remoteCollection: Collection) {
|
||||
logger.log(Level.FINE, "Updating local calendar ${remoteCollection.url}", remoteCollection)
|
||||
localCollection.update(remoteCollection, accountSettings.getManageCalendarColors())
|
||||
}
|
||||
|
||||
override fun create(provider: ContentProviderClient, remoteCollection: Collection): LocalCalendar {
|
||||
logger.log(Level.INFO, "Adding local calendar", remoteCollection)
|
||||
val uri = LocalCalendar.create(account, provider, remoteCollection)
|
||||
return AndroidCalendar.findByID(account, provider, LocalCalendar.Factory, ContentUris.parseId(uri))
|
||||
}
|
||||
|
||||
}
|
||||
@@ -7,6 +7,7 @@ package at.bitfire.davdroid.sync
|
||||
import android.accounts.Account
|
||||
import android.content.ContentProviderClient
|
||||
import android.content.ContentResolver
|
||||
import android.os.Build
|
||||
import android.text.format.Formatter
|
||||
import at.bitfire.dav4jvm.DavAddressBook
|
||||
import at.bitfire.dav4jvm.MultiResponseCallback
|
||||
@@ -30,7 +31,6 @@ import at.bitfire.davdroid.resource.LocalAddressBook
|
||||
import at.bitfire.davdroid.resource.LocalContact
|
||||
import at.bitfire.davdroid.resource.LocalGroup
|
||||
import at.bitfire.davdroid.resource.LocalResource
|
||||
import at.bitfire.davdroid.resource.workaround.ContactDirtyVerifier
|
||||
import at.bitfire.davdroid.settings.AccountSettings
|
||||
import at.bitfire.davdroid.sync.groups.CategoriesStrategy
|
||||
import at.bitfire.davdroid.sync.groups.VCard4Strategy
|
||||
@@ -54,9 +54,7 @@ import java.io.ByteArrayOutputStream
|
||||
import java.io.IOException
|
||||
import java.io.Reader
|
||||
import java.io.StringReader
|
||||
import java.util.Optional
|
||||
import java.util.logging.Level
|
||||
import kotlin.jvm.optionals.getOrNull
|
||||
|
||||
/**
|
||||
* Synchronization manager for CardDAV collections; handles contacts and groups.
|
||||
@@ -102,8 +100,7 @@ class ContactsSyncManager @AssistedInject constructor(
|
||||
@Assisted syncResult: SyncResult,
|
||||
@Assisted val provider: ContentProviderClient,
|
||||
@Assisted localAddressBook: LocalAddressBook,
|
||||
@Assisted collection: Collection,
|
||||
val dirtyVerifier: Optional<ContactDirtyVerifier>
|
||||
@Assisted collection: Collection
|
||||
): SyncManager<LocalAddress, LocalAddressBook, DavAddressBook>(
|
||||
account,
|
||||
accountSettings,
|
||||
@@ -148,13 +145,18 @@ class ContactsSyncManager @AssistedInject constructor(
|
||||
|
||||
|
||||
override fun prepare(): Boolean {
|
||||
if (dirtyVerifier.isPresent) {
|
||||
logger.info("Sync will verify dirty contacts (Android 7.x workaround)")
|
||||
if (!dirtyVerifier.get().prepareAddressBook(localCollection, isUpload = extras.contains(ContentResolver.SYNC_EXTRAS_UPLOAD)))
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) {
|
||||
// workaround for Android 7 which sets DIRTY flag when only meta-data is changed
|
||||
val reallyDirty = localCollection.verifyDirty()
|
||||
val deleted = localCollection.findDeleted().size
|
||||
if (extras.contains(ContentResolver.SYNC_EXTRAS_UPLOAD) && reallyDirty == 0 && deleted == 0) {
|
||||
logger.info("This sync was called to up-sync dirty/deleted contacts, but no contacts have been changed")
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
davCollection = DavAddressBook(httpClient.okHttpClient, collection.url)
|
||||
|
||||
resourceDownloader = ResourceDownloader(davCollection.location)
|
||||
|
||||
logger.info("Contact group strategy: ${groupStrategy::class.java.simpleName}")
|
||||
@@ -389,12 +391,14 @@ class ContactsSyncManager @AssistedInject constructor(
|
||||
local.eTag = eTag
|
||||
local.flags = LocalResource.FLAG_REMOTELY_PRESENT
|
||||
local.update(newData)
|
||||
syncResult.stats.numUpdates++
|
||||
|
||||
} else if (local is LocalContact && !newData.group) {
|
||||
// update contact
|
||||
local.eTag = eTag
|
||||
local.flags = LocalResource.FLAG_REMOTELY_PRESENT
|
||||
local.update(newData)
|
||||
syncResult.stats.numUpdates++
|
||||
|
||||
} else {
|
||||
// group has become an individual contact or vice versa, delete and create with new type
|
||||
@@ -419,14 +423,12 @@ class ContactsSyncManager @AssistedInject constructor(
|
||||
local = newContact
|
||||
}
|
||||
}
|
||||
syncResult.stats.numInserts++
|
||||
}
|
||||
|
||||
dirtyVerifier.getOrNull()?.let { verifier ->
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O)
|
||||
// workaround for Android 7 which sets DIRTY flag when only meta-data is changed
|
||||
(local as? LocalContact)?.let { localContact ->
|
||||
verifier.updateHashCode(localCollection, localContact)
|
||||
}
|
||||
}
|
||||
(local as? LocalContact)?.updateHashCode(null)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -174,12 +174,14 @@ class JtxSyncManager @AssistedInject constructor(
|
||||
logger.log(Level.INFO, "Updating $fileName with recur instance ${jtxICalObject.recurid} in local list", jtxICalObject)
|
||||
if(local != null) {
|
||||
local.update(jtxICalObject)
|
||||
syncResult.stats.numUpdates++
|
||||
} else {
|
||||
val newLocal = LocalJtxICalObject(localCollection, fileName, eTag, null, LocalResource.FLAG_REMOTELY_PRESENT)
|
||||
SyncException.wrapWithLocalResource(newLocal) {
|
||||
newLocal.applyNewData(jtxICalObject)
|
||||
newLocal.add()
|
||||
}
|
||||
syncResult.stats.numInserts++
|
||||
}
|
||||
}
|
||||
} else {
|
||||
@@ -190,6 +192,7 @@ class JtxSyncManager @AssistedInject constructor(
|
||||
logger.log(Level.INFO, "Updating $fileName in local list", jtxICalObject)
|
||||
local.eTag = eTag
|
||||
local.update(jtxICalObject)
|
||||
syncResult.stats.numUpdates++
|
||||
} else {
|
||||
logger.log(Level.INFO, "Adding $fileName to local list", jtxICalObject)
|
||||
|
||||
@@ -198,6 +201,7 @@ class JtxSyncManager @AssistedInject constructor(
|
||||
newLocal.applyNewData(jtxICalObject)
|
||||
newLocal.add()
|
||||
}
|
||||
syncResult.stats.numInserts++
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,15 +7,19 @@ package at.bitfire.davdroid.sync
|
||||
import android.accounts.Account
|
||||
import android.accounts.AccountManager
|
||||
import android.content.ContentProviderClient
|
||||
import android.content.ContentUris
|
||||
import android.os.Build
|
||||
import at.bitfire.davdroid.db.Collection
|
||||
import at.bitfire.davdroid.db.Service
|
||||
import at.bitfire.davdroid.repository.PrincipalRepository
|
||||
import at.bitfire.davdroid.resource.LocalJtxCollection
|
||||
import at.bitfire.davdroid.resource.LocalJtxCollectionStore
|
||||
import at.bitfire.ical4android.JtxCollection
|
||||
import at.bitfire.ical4android.TaskProvider
|
||||
import at.techbee.jtx.JtxContract
|
||||
import dagger.assisted.Assisted
|
||||
import dagger.assisted.AssistedFactory
|
||||
import dagger.assisted.AssistedInject
|
||||
import java.util.logging.Level
|
||||
|
||||
/**
|
||||
* Sync logic for jtx board
|
||||
@@ -24,24 +28,25 @@ class JtxSyncer @AssistedInject constructor(
|
||||
@Assisted account: Account,
|
||||
@Assisted extras: Array<String>,
|
||||
@Assisted syncResult: SyncResult,
|
||||
localJtxCollectionStore: LocalJtxCollectionStore,
|
||||
private val jtxSyncManagerFactory: JtxSyncManager.Factory,
|
||||
private val principalRepository: PrincipalRepository,
|
||||
private val tasksAppManager: dagger.Lazy<TasksAppManager>
|
||||
): Syncer<LocalJtxCollectionStore, LocalJtxCollection>(account, extras, syncResult) {
|
||||
): Syncer<LocalJtxCollection>(account, extras, syncResult) {
|
||||
|
||||
@AssistedFactory
|
||||
interface Factory {
|
||||
fun create(account: Account, extras: Array<String>, syncResult: SyncResult): JtxSyncer
|
||||
}
|
||||
|
||||
override val dataStore = localJtxCollectionStore
|
||||
|
||||
override val serviceType: String
|
||||
get() = Service.TYPE_CALDAV
|
||||
override val authority: String
|
||||
get() = TaskProvider.ProviderName.JtxBoard.authority
|
||||
|
||||
|
||||
override fun getLocalCollections(provider: ContentProviderClient): List<LocalJtxCollection>
|
||||
= JtxCollection.find(account, provider, context, LocalJtxCollection.Factory, null, null)
|
||||
|
||||
override fun prepare(provider: ContentProviderClient): Boolean {
|
||||
// check whether jtx Board is new enough
|
||||
try {
|
||||
@@ -67,6 +72,26 @@ class JtxSyncer @AssistedInject constructor(
|
||||
override fun getDbSyncCollections(serviceId: Long): List<Collection> =
|
||||
collectionRepository.getSyncJtxCollections(serviceId)
|
||||
|
||||
override fun update(localCollection: LocalJtxCollection, remoteCollection: Collection) {
|
||||
logger.log(Level.FINE, "Updating local jtx collection ${remoteCollection.url}", remoteCollection)
|
||||
val owner = remoteCollection.ownerId?.let { principalRepository.get(it) }
|
||||
localCollection.updateCollection(remoteCollection, owner, accountSettings.getManageCalendarColors())
|
||||
}
|
||||
|
||||
override fun create(provider: ContentProviderClient, remoteCollection: Collection): LocalJtxCollection {
|
||||
logger.log(Level.INFO, "Adding local jtx collection", remoteCollection)
|
||||
val owner = remoteCollection.ownerId?.let { principalRepository.get(it) }
|
||||
val uri = LocalJtxCollection.create(account, provider, remoteCollection, owner)
|
||||
return JtxCollection.find(
|
||||
account,
|
||||
provider,
|
||||
context,
|
||||
LocalJtxCollection.Factory,
|
||||
"${JtxContract.JtxCollection.ID} = ?",
|
||||
arrayOf("${ContentUris.parseId(uri)}")
|
||||
).first()
|
||||
}
|
||||
|
||||
override fun syncCollection(provider: ContentProviderClient, localCollection: LocalJtxCollection, remoteCollection: Collection) {
|
||||
logger.info("Synchronizing jtx collection $localCollection")
|
||||
|
||||
|
||||
@@ -16,7 +16,6 @@ import android.content.SyncResult
|
||||
import android.os.Bundle
|
||||
import android.os.IBinder
|
||||
import android.provider.ContactsContract
|
||||
import androidx.work.WorkInfo
|
||||
import androidx.work.WorkManager
|
||||
import at.bitfire.davdroid.InvalidAccountException
|
||||
import at.bitfire.davdroid.R
|
||||
@@ -24,7 +23,6 @@ import at.bitfire.davdroid.repository.DavCollectionRepository
|
||||
import at.bitfire.davdroid.repository.DavServiceRepository
|
||||
import at.bitfire.davdroid.resource.LocalAddressBook.Companion.USER_DATA_COLLECTION_ID
|
||||
import at.bitfire.davdroid.settings.AccountSettings
|
||||
import at.bitfire.davdroid.sync.worker.BaseSyncWorker
|
||||
import at.bitfire.davdroid.sync.worker.SyncWorkerManager
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
@@ -50,15 +48,13 @@ abstract class SyncAdapterService: Service() {
|
||||
}
|
||||
|
||||
/**
|
||||
* Entry point for the Sync Adapter Framework.
|
||||
* Entry point for the sync adapter framework.
|
||||
*
|
||||
* Handles incoming sync requests from the Sync Adapter Framework.
|
||||
* Handles incoming sync requests from the sync adapter framework.
|
||||
*
|
||||
* Although we do not use the sync adapter for syncing anymore, we keep this sole
|
||||
* adapter to provide exported services, which allow android system components and calendar,
|
||||
* contacts or task apps to sync via DAVx5.
|
||||
*
|
||||
* All Sync Adapter Framework related interaction should happen inside [SyncFrameworkIntegration].
|
||||
*/
|
||||
class SyncAdapter @Inject constructor(
|
||||
private val accountSettingsFactory: AccountSettings.Factory,
|
||||
@@ -70,8 +66,8 @@ abstract class SyncAdapterService: Service() {
|
||||
private val syncWorkerManager: SyncWorkerManager
|
||||
): AbstractThreadedSyncAdapter(
|
||||
context,
|
||||
true // isSyncable shouldn't be -1 because DAVx5 (SyncFrameworkIntegration) sets it to 0 or 1.
|
||||
// However, if it is -1 by accident, set it to 1 to avoid endless sync loops.
|
||||
true // isSyncable shouldn't be -1 because DAVx5 sets it to 0 or 1.
|
||||
// However, if it is -1 by accident, set it to 1 to avoid endless sync loops.
|
||||
) {
|
||||
|
||||
/**
|
||||
@@ -139,17 +135,9 @@ abstract class SyncAdapterService: Service() {
|
||||
try {
|
||||
val waitJob = waitScope.launch {
|
||||
// wait for finished worker state
|
||||
workManager.getWorkInfosForUniqueWorkFlow(workerName).collect { infoList ->
|
||||
for (info in infoList)
|
||||
if (info.state.isFinished) {
|
||||
if (info.state == WorkInfo.State.FAILED) {
|
||||
if (info.outputData.getBoolean(BaseSyncWorker.OUTPUT_TOO_MANY_RETRIES, false))
|
||||
syncResult.tooManyRetries = true
|
||||
else
|
||||
syncResult.databaseError = true
|
||||
}
|
||||
cancel("$workerName has finished")
|
||||
}
|
||||
workManager.getWorkInfosForUniqueWorkFlow(workerName).collect { info ->
|
||||
if (info.any { it.state.isFinished })
|
||||
cancel("$workerName has finished")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -163,7 +151,7 @@ abstract class SyncAdapterService: Service() {
|
||||
logger.fine("Not waiting for OneTimeSyncWorker anymore.")
|
||||
}
|
||||
|
||||
logger.log(Level.INFO, "Returning to sync framework.", syncResult)
|
||||
logger.info("Returning to sync framework.")
|
||||
}
|
||||
|
||||
override fun onSecurityException(account: Account, extras: Bundle, authority: String, syncResult: SyncResult) {
|
||||
|
||||
@@ -4,8 +4,7 @@
|
||||
|
||||
package at.bitfire.davdroid.sync
|
||||
|
||||
import android.content.Context
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import android.app.Application
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
import kotlinx.coroutines.asCoroutineDispatcher
|
||||
import java.util.concurrent.LinkedBlockingQueue
|
||||
@@ -26,7 +25,7 @@ import javax.inject.Singleton
|
||||
*/
|
||||
@Singleton
|
||||
class SyncDispatcher @Inject constructor(
|
||||
@ApplicationContext context: Context
|
||||
context: Application
|
||||
) {
|
||||
|
||||
val dispatcher = createDispatcher(context.classLoader)
|
||||
|
||||
@@ -1,132 +0,0 @@
|
||||
package at.bitfire.davdroid.sync
|
||||
|
||||
import android.accounts.Account
|
||||
import android.content.ContentResolver
|
||||
import android.provider.CalendarContract
|
||||
import androidx.annotation.WorkerThread
|
||||
import java.util.logging.Logger
|
||||
import javax.inject.Inject
|
||||
|
||||
/**
|
||||
* Handles all Sync Adapter Framework related interaction. Other classes should never call
|
||||
* `ContentResolver.setIsSyncable()` or something similar themselves. Everything sync-framework
|
||||
* related must be handled by this class.
|
||||
*
|
||||
* Sync requests from the Sync Adapter Framework are handled by [SyncAdapterService].
|
||||
*/
|
||||
class SyncFrameworkIntegration @Inject constructor(
|
||||
private val logger: Logger
|
||||
) {
|
||||
|
||||
/**
|
||||
* Gets the global auto-sync setting that applies to all the providers and accounts. If this is
|
||||
* false then the per-provider auto-sync setting is ignored.
|
||||
*/
|
||||
fun getMasterSyncAutomatically() =
|
||||
ContentResolver.getMasterSyncAutomatically()
|
||||
|
||||
/**
|
||||
* Check if this account/provider is syncable.
|
||||
*/
|
||||
fun isSyncable(account: Account, authority: String): Boolean =
|
||||
ContentResolver.getIsSyncable(account, authority) > 0
|
||||
|
||||
/**
|
||||
* Enable this account/provider to be syncable.
|
||||
*/
|
||||
fun enableSyncAbility(account: Account, authority: String) {
|
||||
if (ContentResolver.getIsSyncable(account, authority) != 1)
|
||||
ContentResolver.setIsSyncable(account, authority, 1)
|
||||
}
|
||||
|
||||
/**
|
||||
* Disable this account/provider to be syncable.
|
||||
*/
|
||||
fun disableSyncAbility(account: Account, authority: String) {
|
||||
if (ContentResolver.getIsSyncable(account, authority) != 0)
|
||||
ContentResolver.setIsSyncable(account, authority, 0)
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the provider should be synced when content (contact, calendar event or task) changes.
|
||||
*/
|
||||
fun syncsOnContentChange(account: Account, authority: String) =
|
||||
ContentResolver.getSyncAutomatically(account, authority)
|
||||
|
||||
/**
|
||||
* Enable syncing on content (contact, calendar event or task) changes.
|
||||
*/
|
||||
fun enableSyncOnContentChange(account: Account, authority: String) {
|
||||
if (!isSyncable(account, authority))
|
||||
enableSyncAbility(account, authority)
|
||||
|
||||
if (!ContentResolver.getSyncAutomatically(account, authority))
|
||||
setSyncOnContentChange(account, authority, true)
|
||||
}
|
||||
|
||||
/**
|
||||
* Disable syncing on content (contact, calendar event or task) changes.
|
||||
*/
|
||||
fun disableSyncOnContentChange(account: Account, authority: String) {
|
||||
if (ContentResolver.getSyncAutomatically(account, authority))
|
||||
setSyncOnContentChange(account, authority, false)
|
||||
}
|
||||
|
||||
/**
|
||||
* Enables/disables sync adapter automatic sync (content triggered sync) for the given
|
||||
* account and authority. Does *not* call [ContentResolver.setIsSyncable].
|
||||
*
|
||||
* We use the sync adapter framework only for the trigger, actual syncing is implemented
|
||||
* with WorkManager. The trigger comes in through SyncAdapterService.
|
||||
*
|
||||
* Because there is no callback for when the sync status/interval has been updated, 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 account account to enable/disable content change sync triggers for
|
||||
* @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
|
||||
*/
|
||||
@WorkerThread
|
||||
private fun setSyncOnContentChange(account: Account, authority: String, enable: Boolean): Boolean {
|
||||
// Try up to 10 times with 100 ms pause
|
||||
repeat(10) {
|
||||
if (setContentTrigger(account, authority, enable)) {
|
||||
// Remove periodic syncs created by ContentResolver.setSyncAutomatically
|
||||
ContentResolver.getPeriodicSyncs(account, authority).forEach { periodicSync ->
|
||||
ContentResolver.removePeriodicSync(
|
||||
periodicSync.account,
|
||||
periodicSync.authority,
|
||||
periodicSync.extras
|
||||
)
|
||||
}
|
||||
// Set successfully
|
||||
return true
|
||||
}
|
||||
Thread.sleep(100)
|
||||
}
|
||||
// Failed to set
|
||||
return false
|
||||
}
|
||||
|
||||
/**
|
||||
* Enable or disable content change sync triggers of the Sync Adapter Framework.
|
||||
*
|
||||
* @param account account to enable/disable content change sync triggers for
|
||||
* @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
|
||||
*/
|
||||
private fun setContentTrigger(account: Account, authority: String, enable: Boolean): Boolean =
|
||||
if (enable) {
|
||||
logger.fine("Enabling content-triggered sync of $account/$authority")
|
||||
ContentResolver.setSyncAutomatically(account, authority, true)
|
||||
/* return */ ContentResolver.getSyncAutomatically(account, authority)
|
||||
} else {
|
||||
logger.fine("Disabling content-triggered sync of $account/$authority")
|
||||
ContentResolver.setSyncAutomatically(account, authority, false)
|
||||
/* return */ !ContentResolver.getSyncAutomatically(account, authority)
|
||||
}
|
||||
|
||||
}
|
||||
@@ -56,7 +56,6 @@ import at.bitfire.ical4android.CalendarStorageException
|
||||
import at.bitfire.ical4android.Ical4Android
|
||||
import at.bitfire.ical4android.TaskProvider
|
||||
import at.bitfire.vcard4android.ContactsStorageException
|
||||
import com.google.common.base.Ascii
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import kotlinx.coroutines.coroutineScope
|
||||
import kotlinx.coroutines.launch
|
||||
@@ -71,6 +70,7 @@ import java.security.cert.CertificateException
|
||||
import java.time.Instant
|
||||
import java.util.LinkedList
|
||||
import java.util.concurrent.LinkedBlockingQueue
|
||||
import java.util.concurrent.atomic.AtomicInteger
|
||||
import java.util.logging.Level
|
||||
import java.util.logging.Logger
|
||||
import javax.inject.Inject
|
||||
@@ -332,7 +332,7 @@ abstract class SyncManager<ResourceType: LocalResource<*>, out CollectionType: L
|
||||
logger.log(Level.WARNING, "Got 503 Service unavailable, trying again later", e)
|
||||
// determine when to retry
|
||||
syncResult.delayUntil = getDelayUntil(e.retryAfter).epochSecond
|
||||
syncResult.numServiceUnavailableExceptions++ // Indicate a soft error occurred
|
||||
syncResult.stats.numServiceUnavailableExceptions++ // Indicate a soft error occurred
|
||||
}
|
||||
|
||||
// all others
|
||||
@@ -395,6 +395,7 @@ abstract class SyncManager<ResourceType: LocalResource<*>, out CollectionType: L
|
||||
} else
|
||||
logger.info("Removing local record #${local.id} which has been deleted locally and was never uploaded")
|
||||
local.delete()
|
||||
syncResult.stats.numDeletes++
|
||||
}
|
||||
}
|
||||
logger.info("Removed $numDeleted record(s) from server")
|
||||
@@ -422,6 +423,7 @@ abstract class SyncManager<ResourceType: LocalResource<*>, out CollectionType: L
|
||||
}
|
||||
}
|
||||
}
|
||||
syncResult.stats.numEntries += numUploaded
|
||||
logger.info("Sent $numUploaded record(s) to server")
|
||||
return numUploaded > 0
|
||||
}
|
||||
@@ -579,6 +581,12 @@ abstract class SyncManager<ResourceType: LocalResource<*>, out CollectionType: L
|
||||
* @param listRemote function to list remote resources (for instance, all since a certain sync-token)
|
||||
*/
|
||||
protected open fun syncRemote(listRemote: (MultiResponseCallback) -> Unit) {
|
||||
// thread-safe sync stats
|
||||
val nInserted = AtomicInteger()
|
||||
val nUpdated = AtomicInteger()
|
||||
val nDeleted = AtomicInteger()
|
||||
val nSkipped = AtomicInteger()
|
||||
|
||||
runBlocking {
|
||||
// download queue
|
||||
val toDownload = LinkedBlockingQueue<HttpUrl>()
|
||||
@@ -618,15 +626,18 @@ abstract class SyncManager<ResourceType: LocalResource<*>, out CollectionType: L
|
||||
if (local == null) {
|
||||
logger.info("$name has been added remotely, queueing download")
|
||||
download(response.href)
|
||||
nInserted.incrementAndGet()
|
||||
} else {
|
||||
val localETag = local.eTag
|
||||
val remoteETag = response[GetETag::class.java]?.eTag
|
||||
?: throw DavException("Server didn't provide ETag")
|
||||
if (localETag == remoteETag) {
|
||||
logger.info("$name has not been changed on server (ETag still $remoteETag)")
|
||||
nSkipped.incrementAndGet()
|
||||
} else {
|
||||
logger.info("$name has been changed on server (current ETag=$remoteETag, last known ETag=$localETag)")
|
||||
download(response.href)
|
||||
nUpdated.incrementAndGet()
|
||||
}
|
||||
|
||||
// mark as remotely present, so that this resource won't be deleted at the end
|
||||
@@ -642,6 +653,7 @@ abstract class SyncManager<ResourceType: LocalResource<*>, out CollectionType: L
|
||||
SyncException.wrapWithLocalResource(local) {
|
||||
logger.info("$name has been deleted on server, deleting locally")
|
||||
local.delete()
|
||||
nDeleted.incrementAndGet()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -652,6 +664,14 @@ abstract class SyncManager<ResourceType: LocalResource<*>, out CollectionType: L
|
||||
// download remaining resources
|
||||
download(null)
|
||||
}
|
||||
|
||||
// update sync stats
|
||||
with(syncResult.stats) {
|
||||
numInserts += nInserted.get()
|
||||
numUpdates += nUpdated.get()
|
||||
numDeletes += nDeleted.get()
|
||||
numSkippedEntries += nSkipped.get()
|
||||
}
|
||||
}
|
||||
|
||||
protected abstract fun listAllRemote(callback: MultiResponseCallback)
|
||||
@@ -682,7 +702,7 @@ abstract class SyncManager<ResourceType: LocalResource<*>, out CollectionType: L
|
||||
if (syncToken == null)
|
||||
throw DavException("Received sync-collection response without sync-token")
|
||||
|
||||
return Pair(syncToken, furtherResults)
|
||||
return Pair(syncToken!!, furtherResults)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -716,6 +736,7 @@ abstract class SyncManager<ResourceType: LocalResource<*>, out CollectionType: L
|
||||
protected open fun deleteNotPresentRemotely() {
|
||||
val removed = localCollection.removeNotDirtyMarked(0)
|
||||
logger.info("Removed $removed local resources which are not present on the server anymore")
|
||||
syncResult.stats.numDeletes += removed
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -754,19 +775,19 @@ abstract class SyncManager<ResourceType: LocalResource<*>, out CollectionType: L
|
||||
is IOException -> {
|
||||
logger.log(Level.WARNING, "I/O error", e)
|
||||
message = context.getString(R.string.sync_error_io, e.localizedMessage)
|
||||
syncResult.numIoExceptions++
|
||||
syncResult.stats.numIoExceptions++
|
||||
}
|
||||
|
||||
is UnauthorizedException -> {
|
||||
logger.log(Level.SEVERE, "Not authorized anymore", e)
|
||||
message = context.getString(R.string.sync_error_authentication_failed)
|
||||
syncResult.numAuthExceptions++
|
||||
syncResult.stats.numAuthExceptions++
|
||||
}
|
||||
|
||||
is HttpException, is DavException -> {
|
||||
logger.log(Level.SEVERE, "HTTP/DAV exception", e)
|
||||
message = context.getString(R.string.sync_error_http_dav, e.localizedMessage)
|
||||
syncResult.numHttpExceptions++
|
||||
syncResult.stats.numHttpExceptions++
|
||||
}
|
||||
|
||||
is CalendarStorageException, is ContactsStorageException, is RemoteException -> {
|
||||
@@ -778,7 +799,7 @@ abstract class SyncManager<ResourceType: LocalResource<*>, out CollectionType: L
|
||||
else -> {
|
||||
logger.log(Level.SEVERE, "Unclassified sync error", e)
|
||||
message = e.localizedMessage ?: e::class.java.simpleName
|
||||
syncResult.numUnclassifiedErrors++
|
||||
syncResult.stats.numUnclassifiedErrors++
|
||||
}
|
||||
}
|
||||
|
||||
@@ -832,25 +853,21 @@ abstract class SyncManager<ResourceType: LocalResource<*>, out CollectionType: L
|
||||
}
|
||||
}
|
||||
|
||||
private fun buildDebugInfoIntent(e: Throwable, local: LocalResource<*>?, remote: HttpUrl?): Intent {
|
||||
val builder = DebugInfoActivity.IntentBuilder(context)
|
||||
private fun buildDebugInfoIntent(e: Throwable, local: LocalResource<*>?, remote: HttpUrl?) =
|
||||
DebugInfoActivity.IntentBuilder(context)
|
||||
.withAccount(account)
|
||||
.withAuthority(authority)
|
||||
.withCause(e)
|
||||
|
||||
if (local != null)
|
||||
try {
|
||||
// Truncate the string to avoid the Intent to be > 1 MB, which doesn't work (IPC limit)
|
||||
builder.withLocalResource(Ascii.truncate(local.toString(), 10000, "[…]"))
|
||||
} catch (_: OutOfMemoryError) {
|
||||
// For instance because of a huge contact photo; maybe we're lucky and can catch it
|
||||
}
|
||||
|
||||
if (remote != null)
|
||||
builder.withRemoteResource(remote)
|
||||
|
||||
return builder.build()
|
||||
}
|
||||
.withLocalResource(
|
||||
try {
|
||||
local?.toString()
|
||||
} catch (e: OutOfMemoryError) {
|
||||
// for instance because of a huge contact photo; maybe we're lucky and can fetch it
|
||||
null
|
||||
}
|
||||
)
|
||||
.withRemoteResource(remote)
|
||||
.build()
|
||||
|
||||
private fun buildViewItemAction(local: LocalResource<*>): NotificationCompat.Action? {
|
||||
logger.log(Level.FINE, "Adding view action for local resource", local)
|
||||
|
||||
@@ -6,22 +6,10 @@ package at.bitfire.davdroid.sync
|
||||
* Used by [at.bitfire.davdroid.sync.worker.BaseSyncWorker] to determine whether or not there will be retries etc.
|
||||
*/
|
||||
data class SyncResult(
|
||||
// hard errors by Syncer
|
||||
var contentProviderError: Boolean = false,
|
||||
var localStorageError: Boolean = false,
|
||||
|
||||
// hard errors by SyncManager
|
||||
var numAuthExceptions: Long = 0,
|
||||
var numHttpExceptions: Long = 0,
|
||||
var numUnclassifiedErrors: Long = 0,
|
||||
|
||||
// soft errors by SyncMAnager
|
||||
var numDeadObjectExceptions: Long = 0,
|
||||
var numIoExceptions: Long = 0,
|
||||
var numServiceUnavailableExceptions: Long = 0,
|
||||
|
||||
// Other values
|
||||
var delayUntil: Long = 0
|
||||
var delayUntil: Long = 0,
|
||||
val stats: SyncStats = SyncStats()
|
||||
) {
|
||||
|
||||
/**
|
||||
@@ -30,17 +18,17 @@ data class SyncResult(
|
||||
fun hasHardError(): Boolean =
|
||||
contentProviderError
|
||||
|| localStorageError
|
||||
|| numAuthExceptions > 0
|
||||
|| numHttpExceptions > 0
|
||||
|| numUnclassifiedErrors > 0
|
||||
|| stats.numAuthExceptions > 0
|
||||
|| stats.numHttpExceptions > 0
|
||||
|| stats.numUnclassifiedErrors > 0
|
||||
|
||||
/**
|
||||
* Whether a soft error occurred.
|
||||
*/
|
||||
fun hasSoftError(): Boolean =
|
||||
numDeadObjectExceptions > 0
|
||||
|| numIoExceptions > 0
|
||||
|| numServiceUnavailableExceptions > 0
|
||||
stats.numDeadObjectExceptions > 0
|
||||
|| stats.numIoExceptions > 0
|
||||
|| stats.numServiceUnavailableExceptions > 0
|
||||
|
||||
/**
|
||||
* Whether a hard or a soft error occurred.
|
||||
@@ -48,4 +36,27 @@ data class SyncResult(
|
||||
fun hasError(): Boolean =
|
||||
hasHardError() || hasSoftError()
|
||||
|
||||
/**
|
||||
* Holds statistics about the sync operation. Used to determine retries. Also useful for
|
||||
* debugging and customer support when logged.
|
||||
*/
|
||||
data class SyncStats(
|
||||
// Stats
|
||||
var numDeletes: Long = 0,
|
||||
var numEntries: Long = 0,
|
||||
var numInserts: Long = 0,
|
||||
var numSkippedEntries: Long = 0,
|
||||
var numUpdates: Long = 0,
|
||||
|
||||
// Hard errors
|
||||
var numAuthExceptions: Long = 0,
|
||||
var numHttpExceptions: Long = 0,
|
||||
var numUnclassifiedErrors: Long = 0,
|
||||
|
||||
// Soft errors
|
||||
var numDeadObjectExceptions: Long = 0,
|
||||
var numIoExceptions: Long = 0,
|
||||
var numServiceUnavailableExceptions: Long = 0
|
||||
)
|
||||
|
||||
}
|
||||
53
app/src/main/kotlin/at/bitfire/davdroid/sync/SyncUtils.kt
Normal file
53
app/src/main/kotlin/at/bitfire/davdroid/sync/SyncUtils.kt
Normal file
@@ -0,0 +1,53 @@
|
||||
/*
|
||||
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
|
||||
*/
|
||||
|
||||
package at.bitfire.davdroid.sync
|
||||
|
||||
import android.content.Context
|
||||
import android.provider.CalendarContract
|
||||
import at.bitfire.davdroid.R
|
||||
import dagger.hilt.EntryPoint
|
||||
import dagger.hilt.InstallIn
|
||||
import dagger.hilt.android.EntryPointAccessors
|
||||
import dagger.hilt.components.SingletonComponent
|
||||
|
||||
/**
|
||||
* Utility methods related to synchronization management (authorities, workers etc.)
|
||||
*/
|
||||
object SyncUtils {
|
||||
|
||||
@EntryPoint
|
||||
@InstallIn(SingletonComponent::class)
|
||||
interface SyncUtilsEntryPoint {
|
||||
fun tasksAppManager(): TasksAppManager
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a list of all available sync authorities:
|
||||
*
|
||||
* 1. calendar authority
|
||||
* 2. address books authority
|
||||
* 3. current tasks authority (if available)
|
||||
*
|
||||
* Checking the availability of authorities may be relatively expensive, so the
|
||||
* result should be cached for the current operation.
|
||||
*
|
||||
* @return list of available sync authorities for DAVx5 accounts
|
||||
*/
|
||||
fun syncAuthorities(context: Context): List<String> {
|
||||
val result = mutableListOf(
|
||||
CalendarContract.AUTHORITY,
|
||||
context.getString(R.string.address_books_authority)
|
||||
)
|
||||
|
||||
val entryPoint = EntryPointAccessors.fromApplication<SyncUtilsEntryPoint>(context)
|
||||
val tasksAppManager = entryPoint.tasksAppManager()
|
||||
tasksAppManager.currentProvider()?.let { taskProvider ->
|
||||
result += taskProvider.authority
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
}
|
||||
@@ -15,7 +15,6 @@ import at.bitfire.davdroid.network.HttpClient
|
||||
import at.bitfire.davdroid.repository.DavCollectionRepository
|
||||
import at.bitfire.davdroid.repository.DavServiceRepository
|
||||
import at.bitfire.davdroid.resource.LocalCollection
|
||||
import at.bitfire.davdroid.resource.LocalDataStore
|
||||
import at.bitfire.davdroid.settings.AccountSettings
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import okhttp3.HttpUrl
|
||||
@@ -30,7 +29,7 @@ import javax.inject.Inject
|
||||
*
|
||||
* Contains generic sync code, equal for all sync authorities.
|
||||
*/
|
||||
abstract class Syncer<StoreType: LocalDataStore<CollectionType>, CollectionType: LocalCollection<*>>(
|
||||
abstract class Syncer<CollectionType: LocalCollection<*>>(
|
||||
protected val account: Account,
|
||||
protected val extras: Array<String>,
|
||||
protected val syncResult: SyncResult
|
||||
@@ -59,8 +58,6 @@ abstract class Syncer<StoreType: LocalDataStore<CollectionType>, CollectionType:
|
||||
|
||||
}
|
||||
|
||||
abstract val dataStore: StoreType
|
||||
|
||||
@Inject
|
||||
lateinit var accountSettingsFactory: AccountSettings.Factory
|
||||
|
||||
@@ -98,7 +95,7 @@ abstract class Syncer<StoreType: LocalDataStore<CollectionType>, CollectionType:
|
||||
|
||||
// Find collections in database and provider which should be synced (are sync-enabled)
|
||||
val dbCollections = getSyncEnabledCollections()
|
||||
val localCollections = dataStore.getAll(account, provider)
|
||||
val localCollections = getLocalCollections(provider)
|
||||
|
||||
// Create/update/delete local collections according to DB
|
||||
val updatedLocalCollections = updateCollections(provider, localCollections, dbCollections)
|
||||
@@ -150,13 +147,13 @@ abstract class Syncer<StoreType: LocalDataStore<CollectionType>, CollectionType:
|
||||
val dbCollection = dbCollections[localCollection.collectionUrl?.toHttpUrlOrNull()]
|
||||
if (dbCollection == null) {
|
||||
// Collection not available in db = on server (anymore), delete and remove from the updated list
|
||||
logger.info("Deleting local collection ${localCollection.title} without matching remote collection")
|
||||
dataStore.delete(localCollection)
|
||||
logger.fine("Deleting local collection ${localCollection.title}")
|
||||
localCollection.deleteCollection()
|
||||
updatedLocalCollections -= localCollection
|
||||
} else {
|
||||
// Collection exists locally, update local collection and remove it from "to be created" map
|
||||
logger.fine("Updating local collection ${localCollection.title} with $dbCollection")
|
||||
dataStore.update(provider, localCollection, dbCollection)
|
||||
update(localCollection, dbCollection)
|
||||
newDbCollections -= dbCollection.url
|
||||
}
|
||||
}
|
||||
@@ -164,7 +161,7 @@ abstract class Syncer<StoreType: LocalDataStore<CollectionType>, CollectionType:
|
||||
// Create local collections which are in DB, but don't exist locally yet
|
||||
if (newDbCollections.isNotEmpty()) {
|
||||
val toBeCreated = newDbCollections.values.toList()
|
||||
logger.log(Level.INFO, "Creating new local collections", toBeCreated.toTypedArray())
|
||||
logger.log(Level.FINE, "Creating new local collections", toBeCreated.toTypedArray())
|
||||
val newLocalCollections = createLocalCollections(provider, toBeCreated)
|
||||
// Add the newly created collections to the updated list
|
||||
updatedLocalCollections.addAll(newLocalCollections)
|
||||
@@ -186,10 +183,7 @@ abstract class Syncer<StoreType: LocalDataStore<CollectionType>, CollectionType:
|
||||
provider: ContentProviderClient,
|
||||
dbCollections: List<Collection>
|
||||
): List<CollectionType> =
|
||||
dbCollections.map { collection ->
|
||||
dataStore.create(provider, collection)
|
||||
?: throw IllegalStateException("Couldn't create local collection for $collection")
|
||||
}
|
||||
dbCollections.map { collection -> create(provider, collection) }
|
||||
|
||||
/**
|
||||
* Synchronize the actual collection contents.
|
||||
@@ -217,6 +211,17 @@ abstract class Syncer<StoreType: LocalDataStore<CollectionType>, CollectionType:
|
||||
*/
|
||||
open fun prepare(provider: ContentProviderClient): Boolean = true
|
||||
|
||||
/**
|
||||
* Gets all local collections (not from the database, but from the content provider).
|
||||
*
|
||||
* [Syncer] will remove collections which are returned by this method, but not by
|
||||
* [getDbSyncCollections], and add collections which are returned by [getDbSyncCollections], but not by this method.
|
||||
*
|
||||
* @param provider Content provider to access local collections
|
||||
* @return Local collections to be updated
|
||||
*/
|
||||
abstract fun getLocalCollections(provider: ContentProviderClient): List<CollectionType>
|
||||
|
||||
/**
|
||||
* Get the local database collections which are sync-enabled (should by synchronized).
|
||||
*
|
||||
@@ -228,6 +233,22 @@ abstract class Syncer<StoreType: LocalDataStore<CollectionType>, CollectionType:
|
||||
*/
|
||||
abstract fun getDbSyncCollections(serviceId: Long): List<Collection>
|
||||
|
||||
/**
|
||||
* Updates an existing local collection (in the content provider) with remote collection information (from the DB).
|
||||
*
|
||||
* @param localCollection The local collection to be updated
|
||||
* @param remoteCollection The new remote collection information
|
||||
*/
|
||||
abstract fun update(localCollection: CollectionType, remoteCollection: Collection)
|
||||
|
||||
/**
|
||||
* Creates a new local collection (in the content provider) from remote collection information (from the DB).
|
||||
*
|
||||
* @param provider The content provider client to create the local collection
|
||||
* @param remoteCollection The remote collection to be created locally
|
||||
*/
|
||||
abstract fun create(provider: ContentProviderClient, remoteCollection: Collection): CollectionType
|
||||
|
||||
/**
|
||||
* Synchronizes local with remote collection contents.
|
||||
*
|
||||
@@ -275,14 +296,14 @@ abstract class Syncer<StoreType: LocalDataStore<CollectionType>, CollectionType:
|
||||
/* May happen when the remote process dies or (since Android 14) when IPC (for instance with the calendar provider)
|
||||
is suddenly forbidden because our sync process was demoted from a "service process" to a "cached process". */
|
||||
logger.log(Level.WARNING, "Received DeadObjectException, treating as soft error", e)
|
||||
syncResult.numDeadObjectExceptions++
|
||||
syncResult.stats.numDeadObjectExceptions++
|
||||
|
||||
} catch (e: InvalidAccountException) {
|
||||
logger.log(Level.WARNING, "Account was removed during synchronization", e)
|
||||
|
||||
} catch (e: Exception) {
|
||||
logger.log(Level.SEVERE, "Couldn't sync $authority", e)
|
||||
syncResult.numUnclassifiedErrors++ // Hard sync error
|
||||
syncResult.stats.numUnclassifiedErrors++ // Hard sync error
|
||||
|
||||
} finally {
|
||||
if (httpClient.isInitialized())
|
||||
|
||||
@@ -7,15 +7,18 @@ package at.bitfire.davdroid.sync
|
||||
import android.accounts.Account
|
||||
import android.accounts.AccountManager
|
||||
import android.content.ContentProviderClient
|
||||
import android.content.ContentUris
|
||||
import android.os.Build
|
||||
import at.bitfire.davdroid.db.Collection
|
||||
import at.bitfire.davdroid.db.Service
|
||||
import at.bitfire.davdroid.resource.LocalTaskList
|
||||
import at.bitfire.davdroid.resource.LocalTaskListStore
|
||||
import at.bitfire.ical4android.DmfsTaskList
|
||||
import at.bitfire.ical4android.TaskProvider
|
||||
import dagger.assisted.Assisted
|
||||
import dagger.assisted.AssistedFactory
|
||||
import dagger.assisted.AssistedInject
|
||||
import org.dmfs.tasks.contract.TaskContract.TaskLists
|
||||
import java.util.logging.Level
|
||||
|
||||
/**
|
||||
* Sync logic for tasks in CalDAV collections ({@code VTODO}).
|
||||
@@ -25,10 +28,9 @@ class TaskSyncer @AssistedInject constructor(
|
||||
@Assisted override val authority: String,
|
||||
@Assisted extras: Array<String>,
|
||||
@Assisted syncResult: SyncResult,
|
||||
private val localTaskListStoreFactory: LocalTaskListStore.Factory,
|
||||
private val tasksAppManager: dagger.Lazy<TasksAppManager>,
|
||||
private val tasksSyncManagerFactory: TasksSyncManager.Factory,
|
||||
): Syncer<LocalTaskListStore, LocalTaskList>(account, extras, syncResult) {
|
||||
): Syncer<LocalTaskList>(account, extras, syncResult) {
|
||||
|
||||
@AssistedFactory
|
||||
interface Factory {
|
||||
@@ -37,11 +39,11 @@ class TaskSyncer @AssistedInject constructor(
|
||||
|
||||
private val providerName = TaskProvider.ProviderName.fromAuthority(authority)
|
||||
|
||||
override val dataStore = localTaskListStoreFactory.create(authority)
|
||||
|
||||
override val serviceType: String
|
||||
get() = Service.TYPE_CALDAV
|
||||
|
||||
override fun getLocalCollections(provider: ContentProviderClient): List<LocalTaskList>
|
||||
= DmfsTaskList.find(account, LocalTaskList.Factory, provider, providerName, "${TaskLists.SYNC_ENABLED}!=0", null)
|
||||
|
||||
override fun prepare(provider: ContentProviderClient): Boolean {
|
||||
// Don't sync if task provider is too old
|
||||
@@ -68,6 +70,17 @@ class TaskSyncer @AssistedInject constructor(
|
||||
override fun getDbSyncCollections(serviceId: Long): List<Collection> =
|
||||
collectionRepository.getSyncTaskLists(serviceId)
|
||||
|
||||
override fun update(localCollection: LocalTaskList, remoteCollection: Collection) {
|
||||
logger.log(Level.FINE, "Updating local task list ${remoteCollection.url}", remoteCollection)
|
||||
localCollection.update(remoteCollection, accountSettings.getManageCalendarColors())
|
||||
}
|
||||
|
||||
override fun create(provider: ContentProviderClient, remoteCollection: Collection): LocalTaskList {
|
||||
logger.log(Level.INFO, "Adding local task list", remoteCollection)
|
||||
val uri = LocalTaskList.create(account, provider, providerName, remoteCollection)
|
||||
return DmfsTaskList.findByID(account, provider, providerName, LocalTaskList.Factory, ContentUris.parseId(uri))
|
||||
}
|
||||
|
||||
override fun syncCollection(provider: ContentProviderClient, localCollection: LocalTaskList, remoteCollection: Collection) {
|
||||
logger.info("Synchronizing task list #${localCollection.id} [${localCollection.syncId}]")
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ package at.bitfire.davdroid.sync
|
||||
|
||||
import android.accounts.Account
|
||||
import android.app.PendingIntent
|
||||
import android.content.ContentResolver
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.pm.PackageManager
|
||||
@@ -20,32 +21,33 @@ import at.bitfire.davdroid.repository.AccountRepository
|
||||
import at.bitfire.davdroid.settings.AccountSettings
|
||||
import at.bitfire.davdroid.settings.Settings
|
||||
import at.bitfire.davdroid.settings.SettingsManager
|
||||
import at.bitfire.davdroid.sync.worker.SyncWorkerManager
|
||||
import at.bitfire.davdroid.ui.NotificationRegistry
|
||||
import at.bitfire.davdroid.util.PermissionUtils
|
||||
import at.bitfire.ical4android.TaskProvider
|
||||
import at.bitfire.ical4android.TaskProvider.ProviderName
|
||||
import dagger.Lazy
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import java.util.logging.Logger
|
||||
import javax.inject.Inject
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import java.util.logging.Logger
|
||||
import javax.inject.Inject
|
||||
|
||||
/**
|
||||
* Responsible for setting/getting the currently used tasks app, and for communicating with it.
|
||||
*/
|
||||
class TasksAppManager @Inject constructor(
|
||||
private val automaticSyncManager: AutomaticSyncManager,
|
||||
@ApplicationContext private val context: Context,
|
||||
@ApplicationContext val context: Context,
|
||||
private val accountRepository: Lazy<AccountRepository>,
|
||||
private val accountSettingsFactory: AccountSettings.Factory,
|
||||
private val db: AppDatabase,
|
||||
private val logger: Logger,
|
||||
private val notificationRegistry: Lazy<NotificationRegistry>,
|
||||
private val settingsManager: SettingsManager
|
||||
private val settingsManager: SettingsManager,
|
||||
private val syncWorkerManager: SyncWorkerManager
|
||||
) {
|
||||
|
||||
/**
|
||||
@@ -108,7 +110,12 @@ class TasksAppManager @Inject constructor(
|
||||
val syncable = hasCalDAV && providerName == selectedProvider
|
||||
|
||||
// enable/disable sync for the given account and authority
|
||||
setSyncable(account, providerName.authority, syncable)
|
||||
setSyncable(
|
||||
context,
|
||||
account,
|
||||
providerName.authority,
|
||||
syncable
|
||||
)
|
||||
|
||||
// if sync has just been enabled: check whether additional permissions are required
|
||||
if (syncable && !PermissionUtils.havePermissions(context, providerName.permissions))
|
||||
@@ -122,22 +129,30 @@ class TasksAppManager @Inject constructor(
|
||||
}
|
||||
}
|
||||
|
||||
private fun setSyncable(account: Account, authority: String, syncable: Boolean) {
|
||||
private fun setSyncable(context: Context, account: Account, authority: String, syncable: Boolean) {
|
||||
try {
|
||||
val settings = accountSettingsFactory.create(account)
|
||||
if (syncable) {
|
||||
logger.info("Enabling $authority sync for $account")
|
||||
|
||||
// make account syncable by sync framework
|
||||
ContentResolver.setIsSyncable(account, authority, 1)
|
||||
|
||||
// set sync interval according to settings; also updates periodic sync workers and sync framework on-content-change
|
||||
val interval = settings.getTasksSyncInterval() ?: settingsManager.getLong(Settings.DEFAULT_SYNC_INTERVAL)
|
||||
settings.setSyncInterval(authority, interval)
|
||||
} else {
|
||||
logger.info("Disabling $authority sync for $account")
|
||||
automaticSyncManager.disable(account, authority)
|
||||
|
||||
// make account not syncable by sync framework
|
||||
ContentResolver.setIsSyncable(account, authority, 0)
|
||||
|
||||
// disable periodic sync worker
|
||||
syncWorkerManager.disablePeriodic(account, authority)
|
||||
}
|
||||
} catch (_: InvalidAccountException) {
|
||||
} catch (e: InvalidAccountException) {
|
||||
// account has already been removed, make sure periodic sync is disabled, too
|
||||
automaticSyncManager.disable(account, authority)
|
||||
syncWorkerManager.disablePeriodic(account, authority)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -171,12 +171,14 @@ class TasksSyncManager @AssistedInject constructor(
|
||||
logger.log(Level.INFO, "Updating $fileName in local task list", newData)
|
||||
local.eTag = eTag
|
||||
local.update(newData)
|
||||
syncResult.stats.numUpdates++
|
||||
} else {
|
||||
logger.log(Level.INFO, "Adding $fileName to local task list", newData)
|
||||
val newLocal = LocalTask(localCollection, newData, fileName, eTag, LocalResource.FLAG_REMOTELY_PRESENT)
|
||||
SyncException.wrapWithLocalResource(newLocal) {
|
||||
newLocal.add()
|
||||
}
|
||||
syncResult.stats.numInserts++
|
||||
}
|
||||
}
|
||||
} else
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
|
||||
package at.bitfire.davdroid.sync.account
|
||||
|
||||
import android.accounts.Account
|
||||
import android.accounts.AccountManager
|
||||
import android.content.Context
|
||||
import androidx.annotation.VisibleForTesting
|
||||
@@ -17,7 +18,8 @@ import androidx.work.WorkerParameters
|
||||
import at.bitfire.davdroid.R
|
||||
import at.bitfire.davdroid.db.AppDatabase
|
||||
import at.bitfire.davdroid.repository.AccountRepository
|
||||
import at.bitfire.davdroid.resource.LocalAddressBook
|
||||
import at.bitfire.davdroid.repository.DavCollectionRepository
|
||||
import at.bitfire.davdroid.resource.LocalAddressBook.Companion.USER_DATA_COLLECTION_ID
|
||||
import dagger.assisted.Assisted
|
||||
import dagger.assisted.AssistedFactory
|
||||
import dagger.assisted.AssistedInject
|
||||
@@ -31,6 +33,7 @@ class AccountsCleanupWorker @AssistedInject constructor(
|
||||
@Assisted val context: Context,
|
||||
@Assisted workerParameters: WorkerParameters,
|
||||
private val accountRepository: AccountRepository,
|
||||
private val collectionRepository: DavCollectionRepository,
|
||||
private val db: AppDatabase,
|
||||
private val logger: Logger
|
||||
): Worker(context, workerParameters) {
|
||||
@@ -46,19 +49,14 @@ class AccountsCleanupWorker @AssistedInject constructor(
|
||||
override fun doWork(): Result {
|
||||
lockAccountsCleanup()
|
||||
try {
|
||||
cleanUpServices()
|
||||
cleanUpAddressBooks()
|
||||
cleanupAccounts()
|
||||
} finally {
|
||||
unlockAccountsCleanup()
|
||||
}
|
||||
return Result.success()
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes services in the database which are not associated to a valid account.
|
||||
*/
|
||||
@VisibleForTesting
|
||||
internal fun cleanUpServices() {
|
||||
private fun cleanupAccounts() {
|
||||
// Later, accounts which are not in the DB should be deleted here
|
||||
|
||||
// Delete orphaned services in DB – only necessary as long as accounts are implemented as system accounts (not in DB)
|
||||
@@ -69,20 +67,28 @@ class AccountsCleanupWorker @AssistedInject constructor(
|
||||
serviceDao.deleteAll()
|
||||
else
|
||||
serviceDao.deleteExceptAccounts(accounts.map { it.name }.toTypedArray())
|
||||
|
||||
// Delete orphaned address book accounts (where db collection is missing)
|
||||
val addressBookAccountType = context.getString(R.string.account_type_address_book)
|
||||
deleteOrphanedAddressBookAccounts(accountManager.getAccountsByType(addressBookAccountType))
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes address book accounts which are not assigned to a valid account.
|
||||
* Deletes address book accounts if they do not have a corresponding collection
|
||||
*
|
||||
* @param addressBookAccounts Address book accounts to check
|
||||
*/
|
||||
@VisibleForTesting
|
||||
internal fun cleanUpAddressBooks() {
|
||||
val accounts = accountRepository.getAll()
|
||||
for (addressBookAccount in accountManager.getAccountsByType(context.getString(R.string.account_type_address_book))) {
|
||||
val accountName = accountManager.getUserData(addressBookAccount, LocalAddressBook.USER_DATA_ACCOUNT_NAME)
|
||||
val accountType = accountManager.getUserData(addressBookAccount, LocalAddressBook.USER_DATA_ACCOUNT_TYPE)
|
||||
if (!accounts.any { it.name == accountName && it.type == accountType }) {
|
||||
// If no valid account exists for this address book, we can delete it
|
||||
logger.info("Deleting address book account without valid account: $addressBookAccount")
|
||||
internal fun deleteOrphanedAddressBookAccounts(addressBookAccounts: Array<Account>) {
|
||||
addressBookAccounts.forEach { addressBookAccount ->
|
||||
val collection = accountManager.getUserData(addressBookAccount, USER_DATA_COLLECTION_ID)
|
||||
?.toLongOrNull()
|
||||
?.let { collectionId ->
|
||||
collectionRepository.get(collectionId)
|
||||
}
|
||||
if (collection == null) {
|
||||
// If no collection for this address book exists, we can delete it
|
||||
logger.info("Deleting address book account without collection: $addressBookAccount")
|
||||
accountManager.removeAccountExplicitly(addressBookAccount)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@ import android.accounts.Account
|
||||
import android.accounts.AccountManager
|
||||
import android.content.Context
|
||||
import android.os.Bundle
|
||||
import java.util.logging.Logger
|
||||
import at.bitfire.davdroid.util.setAndVerifyUserData
|
||||
|
||||
object SystemAccountUtils {
|
||||
|
||||
@@ -45,25 +45,4 @@ object SystemAccountUtils {
|
||||
return true
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* [AccountManager.setUserData] has been found to be unreliable at times. This extension function
|
||||
* checks whether the user data has actually been set and retries up to ten times before failing silently.
|
||||
*
|
||||
* It should only be used to store the reference to the database (like the collection ID that this account represents).
|
||||
* Everything else should be in the DB.
|
||||
*/
|
||||
fun AccountManager.setAndVerifyUserData(account: Account, key: String, value: String?) {
|
||||
for (i in 1..10) {
|
||||
if (getUserData(account, key) == value)
|
||||
/* already set / success */
|
||||
return
|
||||
|
||||
setUserData(account, key, value)
|
||||
|
||||
// wait a bit because AccountManager access sometimes seems a bit asynchronous
|
||||
Thread.sleep(100)
|
||||
}
|
||||
Logger.getGlobal().warning("AccountManager failed to set $account user data $key := $value")
|
||||
}
|
||||
@@ -16,6 +16,7 @@ import androidx.work.CoroutineWorker
|
||||
import androidx.work.Data
|
||||
import androidx.work.WorkInfo
|
||||
import androidx.work.WorkManager
|
||||
import androidx.work.WorkQuery
|
||||
import androidx.work.WorkerParameters
|
||||
import at.bitfire.davdroid.InvalidAccountException
|
||||
import at.bitfire.davdroid.R
|
||||
@@ -26,16 +27,18 @@ import at.bitfire.davdroid.sync.CalendarSyncer
|
||||
import at.bitfire.davdroid.sync.JtxSyncer
|
||||
import at.bitfire.davdroid.sync.SyncConditions
|
||||
import at.bitfire.davdroid.sync.SyncResult
|
||||
import at.bitfire.davdroid.sync.SyncUtils
|
||||
import at.bitfire.davdroid.sync.Syncer
|
||||
import at.bitfire.davdroid.sync.TaskSyncer
|
||||
import at.bitfire.davdroid.ui.NotificationRegistry
|
||||
import at.bitfire.ical4android.TaskProvider
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.runInterruptible
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.util.Collections
|
||||
import java.util.logging.Level
|
||||
import java.util.logging.Logger
|
||||
import javax.inject.Inject
|
||||
|
||||
@@ -45,6 +48,92 @@ abstract class BaseSyncWorker(
|
||||
private val syncDispatcher: CoroutineDispatcher
|
||||
) : CoroutineWorker(context, workerParams) {
|
||||
|
||||
companion object {
|
||||
|
||||
// common worker input parameters
|
||||
const val INPUT_ACCOUNT_NAME = "accountName"
|
||||
const val INPUT_ACCOUNT_TYPE = "accountType"
|
||||
const val INPUT_AUTHORITY = "authority"
|
||||
|
||||
/** set to `true` for user-initiated sync that skips network checks */
|
||||
const val INPUT_MANUAL = "manual"
|
||||
|
||||
/** set to `true` for syncs that are caused by local changes */
|
||||
const val INPUT_UPLOAD = "upload"
|
||||
|
||||
/** Whether re-synchronization is requested. One of [NO_RESYNC] (default), [RESYNC] or [FULL_RESYNC]. */
|
||||
const val INPUT_RESYNC = "resync"
|
||||
@IntDef(NO_RESYNC, RESYNC, FULL_RESYNC)
|
||||
annotation class InputResync
|
||||
const val NO_RESYNC = 0
|
||||
/** Re-synchronization is requested. See [Syncer.SYNC_EXTRAS_RESYNC] for details. */
|
||||
const val RESYNC = 1
|
||||
/** Full re-synchronization is requested. See [Syncer.SYNC_EXTRAS_FULL_RESYNC] for details. */
|
||||
const val FULL_RESYNC = 2
|
||||
|
||||
/**
|
||||
* How often this work will be retried to run after soft (network) errors.
|
||||
*
|
||||
* Retry strategy is defined in work request ([enqueue]).
|
||||
*/
|
||||
internal const val MAX_RUN_ATTEMPTS = 5
|
||||
|
||||
/**
|
||||
* Set of currently running syncs, identified by their [commonTag].
|
||||
*/
|
||||
private val runningSyncs = Collections.synchronizedSet(HashSet<String>())
|
||||
|
||||
/**
|
||||
* Stops running sync workers and removes pending sync workers from queue, for all authorities.
|
||||
*/
|
||||
fun cancelAllWork(context: Context, account: Account) {
|
||||
val workManager = WorkManager.getInstance(context)
|
||||
for (authority in SyncUtils.syncAuthorities(context)) {
|
||||
workManager.cancelUniqueWork(OneTimeSyncWorker.workerName(account, authority))
|
||||
workManager.cancelUniqueWork(PeriodicSyncWorker.workerName(account, authority))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* This tag shall be added to every worker that is enqueued by a subclass.
|
||||
*/
|
||||
fun commonTag(account: Account, authority: String): String =
|
||||
"sync-$authority ${account.type}/${account.name}"
|
||||
|
||||
/**
|
||||
* Observes whether >0 sync workers (both [PeriodicSyncWorker] and [OneTimeSyncWorker])
|
||||
* exist, belonging to given account and authorities, and which are/is in the given worker state.
|
||||
*
|
||||
* @param workStates list of states of workers to match
|
||||
* @param account the account which the workers belong to
|
||||
* @param authorities type of sync work, ie [CalendarContract.AUTHORITY]
|
||||
* @param whichTag function to generate tag that should be observed for given account and authority
|
||||
*
|
||||
* @return flow that emits `true` if at least one worker with matching query was found; `false` otherwise
|
||||
*/
|
||||
fun exists(
|
||||
context: Context,
|
||||
workStates: List<WorkInfo.State>,
|
||||
account: Account? = null,
|
||||
authorities: List<String>? = null,
|
||||
whichTag: (account: Account, authority: String) -> String = { account, authority ->
|
||||
commonTag(account, authority)
|
||||
}
|
||||
): Flow<Boolean> {
|
||||
val workQuery = WorkQuery.Builder.fromStates(workStates)
|
||||
if (account != null && authorities != null)
|
||||
workQuery.addTags(
|
||||
authorities.map { authority -> whichTag(account, authority) }
|
||||
)
|
||||
return WorkManager.getInstance(context)
|
||||
.getWorkInfosFlow(workQuery.build())
|
||||
.map { workInfoList ->
|
||||
workInfoList.isNotEmpty()
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@Inject
|
||||
lateinit var accountSettingsFactory: AccountSettings.Factory
|
||||
|
||||
@@ -76,10 +165,10 @@ abstract class BaseSyncWorker(
|
||||
override suspend fun doWork(): Result {
|
||||
// ensure we got the required arguments
|
||||
val account = Account(
|
||||
inputData.getString(INPUT_ACCOUNT_NAME) ?: throw IllegalArgumentException("INPUT_ACCOUNT_NAME required"),
|
||||
inputData.getString(INPUT_ACCOUNT_TYPE) ?: throw IllegalArgumentException("INPUT_ACCOUNT_TYPE required")
|
||||
inputData.getString(INPUT_ACCOUNT_NAME) ?: throw IllegalArgumentException("$INPUT_ACCOUNT_NAME required"),
|
||||
inputData.getString(INPUT_ACCOUNT_TYPE) ?: throw IllegalArgumentException("$INPUT_ACCOUNT_TYPE required")
|
||||
)
|
||||
val authority = inputData.getString(INPUT_AUTHORITY) ?: throw IllegalArgumentException("INPUT_AUTHORITY required")
|
||||
val authority = inputData.getString(INPUT_AUTHORITY) ?: throw IllegalArgumentException("$INPUT_AUTHORITY required")
|
||||
|
||||
val syncTag = commonTag(account, authority)
|
||||
logger.info("${javaClass.simpleName} called for $syncTag")
|
||||
@@ -95,7 +184,7 @@ abstract class BaseSyncWorker(
|
||||
try {
|
||||
val accountSettings = try {
|
||||
accountSettingsFactory.create(account)
|
||||
} catch (_: InvalidAccountException) {
|
||||
} catch (e: InvalidAccountException) {
|
||||
val workId = workerParams.id
|
||||
logger.warning("Account $account doesn't exist anymore, cancelling worker $workId")
|
||||
|
||||
@@ -153,19 +242,19 @@ abstract class BaseSyncWorker(
|
||||
|
||||
// We still use the sync adapter framework's SyncResult to pass the sync results, but this
|
||||
// is only for legacy reasons and can be replaced by our own result class in the future.
|
||||
val syncResult = SyncResult()
|
||||
val result = SyncResult()
|
||||
|
||||
// What are we going to sync? Select syncer based on authority
|
||||
val syncer = when (authority) {
|
||||
applicationContext.getString(R.string.address_books_authority) ->
|
||||
addressBookSyncer.create(account, extras, syncResult)
|
||||
addressBookSyncer.create(account, extras, result)
|
||||
CalendarContract.AUTHORITY ->
|
||||
calendarSyncer.create(account, extras, syncResult)
|
||||
calendarSyncer.create(account, extras, result)
|
||||
TaskProvider.ProviderName.JtxBoard.authority ->
|
||||
jtxSyncer.create(account, extras, syncResult)
|
||||
jtxSyncer.create(account, extras, result)
|
||||
TaskProvider.ProviderName.OpenTasks.authority,
|
||||
TaskProvider.ProviderName.TasksOrg.authority ->
|
||||
taskSyncer.create(account, authority, extras, syncResult)
|
||||
taskSyncer.create(account, authority, extras, result)
|
||||
else ->
|
||||
throw IllegalArgumentException("Invalid authority $authority")
|
||||
}
|
||||
@@ -175,19 +264,20 @@ abstract class BaseSyncWorker(
|
||||
syncer()
|
||||
}
|
||||
|
||||
// convert SyncResult from Syncers to worker Data
|
||||
val output = Data.Builder()
|
||||
.putString("syncresult", syncResult.toString())
|
||||
|
||||
// Check for errors
|
||||
if (syncResult.hasError()) {
|
||||
if (result.hasError()) {
|
||||
val syncResult = Data.Builder()
|
||||
.putString("syncresult", result.toString())
|
||||
.putString("syncResultStats", result.stats.toString())
|
||||
.build()
|
||||
|
||||
val softErrorNotificationTag = account.type + "-" + account.name + "-" + authority
|
||||
|
||||
// On soft errors the sync is retried a few times before considered failed
|
||||
if (syncResult.hasSoftError()) {
|
||||
logger.log(Level.WARNING, "Soft error while syncing", syncResult)
|
||||
if (result.hasSoftError()) {
|
||||
logger.warning("Soft error while syncing: result=$result, stats=${result.stats}")
|
||||
if (runAttemptCount < MAX_RUN_ATTEMPTS) {
|
||||
val blockDuration = syncResult.delayUntil - System.currentTimeMillis() / 1000
|
||||
val blockDuration = result.delayUntil - System.currentTimeMillis() / 1000
|
||||
logger.warning("Waiting for $blockDuration seconds, before retrying ...")
|
||||
|
||||
// We block the SyncWorker here so that it won't be started by the sync framework immediately again.
|
||||
@@ -200,6 +290,7 @@ abstract class BaseSyncWorker(
|
||||
}
|
||||
|
||||
logger.warning("Max retries on soft errors reached ($runAttemptCount of $MAX_RUN_ATTEMPTS). Treating as failed")
|
||||
|
||||
notificationRegistry.notifyIfPossible(NotificationRegistry.NOTIFY_SYNC_ERROR, tag = softErrorNotificationTag) {
|
||||
NotificationCompat.Builder(applicationContext, notificationRegistry.CHANNEL_SYNC_IO_ERRORS)
|
||||
.setSmallIcon(R.drawable.ic_sync_problem_notify)
|
||||
@@ -212,8 +303,7 @@ abstract class BaseSyncWorker(
|
||||
.build()
|
||||
}
|
||||
|
||||
output.putBoolean(OUTPUT_TOO_MANY_RETRIES, true)
|
||||
return@withContext Result.failure(output.build())
|
||||
return@withContext Result.failure(syncResult)
|
||||
}
|
||||
|
||||
// If no soft error found, dismiss sync error notification
|
||||
@@ -225,60 +315,13 @@ abstract class BaseSyncWorker(
|
||||
|
||||
// On a hard error - fail with an error message
|
||||
// Note: SyncManager should have notified the user
|
||||
if (syncResult.hasHardError()) {
|
||||
logger.log(Level.WARNING, "Hard error while syncing", syncResult)
|
||||
return@withContext Result.failure(output.build())
|
||||
if (result.hasHardError()) {
|
||||
logger.warning("Hard error while syncing: result=$result, stats=${result.stats}")
|
||||
return@withContext Result.failure(syncResult)
|
||||
}
|
||||
}
|
||||
|
||||
logger.log(Level.INFO, "Sync worker succeeded", syncResult)
|
||||
return@withContext Result.success(output.build())
|
||||
}
|
||||
|
||||
|
||||
companion object {
|
||||
|
||||
// common worker input parameters
|
||||
const val INPUT_ACCOUNT_NAME = "accountName"
|
||||
const val INPUT_ACCOUNT_TYPE = "accountType"
|
||||
const val INPUT_AUTHORITY = "authority"
|
||||
|
||||
/** set to `true` for user-initiated sync that skips network checks */
|
||||
const val INPUT_MANUAL = "manual"
|
||||
|
||||
/** set to `true` for syncs that are caused by local changes */
|
||||
const val INPUT_UPLOAD = "upload"
|
||||
|
||||
/** Whether re-synchronization is requested. One of [NO_RESYNC] (default), [RESYNC] or [FULL_RESYNC]. */
|
||||
const val INPUT_RESYNC = "resync"
|
||||
@IntDef(NO_RESYNC, RESYNC, FULL_RESYNC)
|
||||
annotation class InputResync
|
||||
const val NO_RESYNC = 0
|
||||
/** Re-synchronization is requested. See [Syncer.SYNC_EXTRAS_RESYNC] for details. */
|
||||
const val RESYNC = 1
|
||||
/** Full re-synchronization is requested. See [Syncer.SYNC_EXTRAS_FULL_RESYNC] for details. */
|
||||
const val FULL_RESYNC = 2
|
||||
|
||||
const val OUTPUT_TOO_MANY_RETRIES = "tooManyRetries"
|
||||
|
||||
/**
|
||||
* How often this work will be retried to run after soft (network) errors.
|
||||
*
|
||||
* Retry strategy is defined in work request ([enqueue]).
|
||||
*/
|
||||
internal const val MAX_RUN_ATTEMPTS = 5
|
||||
|
||||
/**
|
||||
* Set of currently running syncs, identified by their [commonTag].
|
||||
*/
|
||||
private val runningSyncs = Collections.synchronizedSet(HashSet<String>())
|
||||
|
||||
/**
|
||||
* This tag shall be added to every worker that is enqueued by a subclass.
|
||||
*/
|
||||
fun commonTag(account: Account, authority: String): String =
|
||||
"sync-$authority ${account.type}/${account.name}"
|
||||
|
||||
return@withContext Result.success()
|
||||
}
|
||||
|
||||
}
|
||||
@@ -31,6 +31,23 @@ class OneTimeSyncWorker @AssistedInject constructor(
|
||||
syncDispatcher: SyncDispatcher
|
||||
) : BaseSyncWorker(appContext, workerParams, syncDispatcher.dispatcher) {
|
||||
|
||||
companion object {
|
||||
|
||||
/**
|
||||
* Unique work name of this worker. Can also be used as tag.
|
||||
*
|
||||
* Mainly used to query [WorkManager] for work state (by unique work name or tag).
|
||||
*
|
||||
* @param account the account this worker is running for
|
||||
* @param authority the authority this worker is running for
|
||||
* @return Name of this worker composed as "onetime-sync $authority ${account.type}/${account.name}"
|
||||
*/
|
||||
fun workerName(account: Account, authority: String): String =
|
||||
"onetime-sync $authority ${account.type}/${account.name}"
|
||||
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Used by WorkManager to show a foreground service notification for expedited jobs on Android <12.
|
||||
*/
|
||||
@@ -48,21 +65,4 @@ class OneTimeSyncWorker @AssistedInject constructor(
|
||||
return ForegroundInfo(NotificationRegistry.NOTIFY_SYNC_EXPEDITED, notification)
|
||||
}
|
||||
|
||||
|
||||
companion object {
|
||||
|
||||
/**
|
||||
* Unique work name of this worker. Can also be used as tag.
|
||||
*
|
||||
* Mainly used to query [WorkManager] for work state (by unique work name or tag).
|
||||
*
|
||||
* @param account the account this worker is running for
|
||||
* @param authority the authority this worker is running for
|
||||
* @return Name of this worker composed as "onetime-sync $authority ${account.type}/${account.name}"
|
||||
*/
|
||||
fun workerName(account: Account, authority: String): String =
|
||||
"onetime-sync $authority ${account.type}/${account.name}"
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
@@ -20,13 +20,10 @@ import androidx.work.Operation
|
||||
import androidx.work.OutOfQuotaPolicy
|
||||
import androidx.work.PeriodicWorkRequest
|
||||
import androidx.work.PeriodicWorkRequestBuilder
|
||||
import androidx.work.WorkInfo
|
||||
import androidx.work.WorkManager
|
||||
import androidx.work.WorkQuery
|
||||
import androidx.work.WorkRequest
|
||||
import at.bitfire.davdroid.R
|
||||
import at.bitfire.davdroid.push.PushNotificationManager
|
||||
import at.bitfire.davdroid.sync.TasksAppManager
|
||||
import at.bitfire.davdroid.sync.SyncUtils
|
||||
import at.bitfire.davdroid.sync.worker.BaseSyncWorker.Companion.INPUT_ACCOUNT_NAME
|
||||
import at.bitfire.davdroid.sync.worker.BaseSyncWorker.Companion.INPUT_ACCOUNT_TYPE
|
||||
import at.bitfire.davdroid.sync.worker.BaseSyncWorker.Companion.INPUT_AUTHORITY
|
||||
@@ -36,10 +33,8 @@ import at.bitfire.davdroid.sync.worker.BaseSyncWorker.Companion.INPUT_UPLOAD
|
||||
import at.bitfire.davdroid.sync.worker.BaseSyncWorker.Companion.InputResync
|
||||
import at.bitfire.davdroid.sync.worker.BaseSyncWorker.Companion.NO_RESYNC
|
||||
import at.bitfire.davdroid.sync.worker.BaseSyncWorker.Companion.commonTag
|
||||
import dagger.Lazy
|
||||
import at.bitfire.davdroid.sync.worker.OneTimeSyncWorker.Companion.workerName
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.map
|
||||
import java.util.concurrent.TimeUnit
|
||||
import java.util.logging.Logger
|
||||
import javax.inject.Inject
|
||||
@@ -52,8 +47,7 @@ import javax.inject.Inject
|
||||
class SyncWorkerManager @Inject constructor(
|
||||
@ApplicationContext val context: Context,
|
||||
val logger: Logger,
|
||||
val pushNotificationManager: PushNotificationManager,
|
||||
val tasksAppManager: Lazy<TasksAppManager>
|
||||
val pushNotificationManager: PushNotificationManager
|
||||
) {
|
||||
|
||||
// one-time sync workers
|
||||
@@ -88,7 +82,7 @@ class SyncWorkerManager @Inject constructor(
|
||||
.setRequiredNetworkType(NetworkType.CONNECTED) // require a network connection
|
||||
.build()
|
||||
return OneTimeWorkRequestBuilder<OneTimeSyncWorker>()
|
||||
.addTag(OneTimeSyncWorker.workerName(account, authority))
|
||||
.addTag(workerName(account, authority))
|
||||
.addTag(commonTag(account, authority))
|
||||
.setInputData(argumentsBuilder.build())
|
||||
.setBackoffCriteria(
|
||||
@@ -127,7 +121,7 @@ class SyncWorkerManager @Inject constructor(
|
||||
fromPush: Boolean = false
|
||||
): String {
|
||||
// enqueue and start syncing
|
||||
val name = OneTimeSyncWorker.workerName(account, authority)
|
||||
val name = workerName(account, authority)
|
||||
val request = buildOneTime(
|
||||
account = account,
|
||||
authority = authority,
|
||||
@@ -164,7 +158,7 @@ class SyncWorkerManager @Inject constructor(
|
||||
upload: Boolean = false,
|
||||
fromPush: Boolean = false
|
||||
) {
|
||||
for (authority in syncAuthorities())
|
||||
for (authority in SyncUtils.syncAuthorities(context))
|
||||
enqueueOneTime(
|
||||
account = account,
|
||||
authority = authority,
|
||||
@@ -236,74 +230,4 @@ class SyncWorkerManager @Inject constructor(
|
||||
WorkManager.getInstance(context)
|
||||
.cancelUniqueWork(PeriodicSyncWorker.workerName(account, authority))
|
||||
|
||||
|
||||
// common / helpers
|
||||
|
||||
/**
|
||||
* Stops running sync workers and removes pending sync workers from queue, for all authorities.
|
||||
*/
|
||||
fun cancelAllWork(account: Account) {
|
||||
val workManager = WorkManager.getInstance(context)
|
||||
for (authority in syncAuthorities()) {
|
||||
workManager.cancelUniqueWork(OneTimeSyncWorker.workerName(account, authority))
|
||||
workManager.cancelUniqueWork(PeriodicSyncWorker.workerName(account, authority))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Observes whether >0 sync workers (both [PeriodicSyncWorker] and [OneTimeSyncWorker])
|
||||
* exist, belonging to given account and authorities, and which are/is in the given worker state.
|
||||
*
|
||||
* @param workStates list of states of workers to match
|
||||
* @param account the account which the workers belong to
|
||||
* @param authorities type of sync work, ie [CalendarContract.AUTHORITY]
|
||||
* @param whichTag function to generate tag that should be observed for given account and authority
|
||||
*
|
||||
* @return flow that emits `true` if at least one worker with matching query was found; `false` otherwise
|
||||
*/
|
||||
fun hasAnyFlow(
|
||||
workStates: List<WorkInfo.State>,
|
||||
account: Account? = null,
|
||||
authorities: List<String>? = null,
|
||||
whichTag: (account: Account, authority: String) -> String = { account, authority ->
|
||||
commonTag(account, authority)
|
||||
}
|
||||
): Flow<Boolean> {
|
||||
val workQuery = WorkQuery.Builder.fromStates(workStates)
|
||||
if (account != null && authorities != null)
|
||||
workQuery.addTags(
|
||||
authorities.map { authority -> whichTag(account, authority) }
|
||||
)
|
||||
return WorkManager.getInstance(context)
|
||||
.getWorkInfosFlow(workQuery.build())
|
||||
.map { workInfoList ->
|
||||
workInfoList.isNotEmpty()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a list of all available sync authorities:
|
||||
*
|
||||
* 1. calendar authority
|
||||
* 2. address books authority
|
||||
* 3. current tasks authority (if available)
|
||||
*
|
||||
* Checking the availability of authorities may be relatively expensive, so the
|
||||
* result should be cached for the current operation.
|
||||
*
|
||||
* @return list of available sync authorities for DAVx5 accounts
|
||||
*/
|
||||
fun syncAuthorities(): List<String> {
|
||||
val result = mutableListOf(
|
||||
CalendarContract.AUTHORITY,
|
||||
context.getString(R.string.address_books_authority)
|
||||
)
|
||||
|
||||
tasksAppManager.get().currentProvider()?.let { taskProvider ->
|
||||
result += taskProvider.authority
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
}
|
||||
@@ -14,10 +14,8 @@ import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.navigationBarsPadding
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.statusBarsPadding
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
@@ -82,7 +80,6 @@ abstract class AccountsDrawerHandler {
|
||||
Column(modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.verticalScroll(rememberScrollState())
|
||||
.navigationBarsPadding()
|
||||
) {
|
||||
BrandingHeader()
|
||||
|
||||
@@ -251,7 +248,6 @@ fun MenuEntry_Preview() {
|
||||
fun BrandingHeader() {
|
||||
Column(
|
||||
Modifier
|
||||
.statusBarsPadding()
|
||||
.background(Color.DarkGray)
|
||||
.fillMaxWidth()
|
||||
.padding(16.dp)
|
||||
|
||||
@@ -18,6 +18,7 @@ import androidx.work.WorkQuery
|
||||
import at.bitfire.davdroid.db.AppDatabase
|
||||
import at.bitfire.davdroid.repository.AccountRepository
|
||||
import at.bitfire.davdroid.servicedetection.RefreshCollectionsWorker
|
||||
import at.bitfire.davdroid.sync.SyncUtils
|
||||
import at.bitfire.davdroid.sync.worker.BaseSyncWorker
|
||||
import at.bitfire.davdroid.sync.worker.OneTimeSyncWorker
|
||||
import at.bitfire.davdroid.sync.worker.SyncWorkerManager
|
||||
@@ -84,7 +85,7 @@ class AccountsModel @AssistedInject constructor(
|
||||
private val runningWorkers = workManager.getWorkInfosFlow(WorkQuery.fromStates(WorkInfo.State.ENQUEUED, WorkInfo.State.RUNNING))
|
||||
|
||||
val accountInfos: Flow<List<AccountInfo>> = combine(accounts, runningWorkers) { accounts, workInfos ->
|
||||
val authorities = syncWorkerManager.syncAuthorities()
|
||||
val authorities = SyncUtils.syncAuthorities(context)
|
||||
val collator = Collator.getInstance()
|
||||
|
||||
accounts
|
||||
|
||||
@@ -11,7 +11,6 @@ import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.WindowInsets
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
@@ -53,7 +52,6 @@ import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
@@ -96,14 +94,10 @@ fun AccountsScreen(
|
||||
val showSyncAll by model.showSyncAll.collectAsStateWithLifecycle(true)
|
||||
val showAddAccount by model.showAddAccount.collectAsStateWithLifecycle(AccountsModel.FABStyle.Standard)
|
||||
|
||||
// Remember shown state, so the intro does not restart on rotation or theme-change
|
||||
var shown by rememberSaveable { mutableStateOf(false) }
|
||||
val showAppIntro by model.showAppIntro.collectAsState(false)
|
||||
LaunchedEffect(showAppIntro) {
|
||||
if (showAppIntro && !shown) {
|
||||
shown = true
|
||||
if (showAppIntro)
|
||||
onShowAppIntro()
|
||||
}
|
||||
}
|
||||
|
||||
AccountsScreen(
|
||||
@@ -160,9 +154,7 @@ fun AccountsScreen(
|
||||
ModalNavigationDrawer(
|
||||
drawerState = drawerState,
|
||||
drawerContent = {
|
||||
ModalDrawerSheet(
|
||||
windowInsets = WindowInsets(0.dp)
|
||||
) {
|
||||
ModalDrawerSheet {
|
||||
accountsDrawerHandler.AccountsDrawer(
|
||||
snackbarHostState = snackbarHostState,
|
||||
onCloseDrawer = {
|
||||
|
||||
@@ -3,7 +3,6 @@ package at.bitfire.davdroid.ui
|
||||
import android.content.Context
|
||||
import android.content.IntentFilter
|
||||
import android.content.pm.PackageManager
|
||||
import android.graphics.drawable.Drawable
|
||||
import android.os.PowerManager
|
||||
import androidx.core.content.getSystemService
|
||||
import androidx.lifecycle.ViewModel
|
||||
@@ -20,14 +19,9 @@ import at.bitfire.davdroid.util.PermissionUtils
|
||||
import at.bitfire.davdroid.util.broadcastReceiverFlow
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.launch
|
||||
import org.unifiedpush.android.connector.UnifiedPush
|
||||
import javax.inject.Inject
|
||||
|
||||
@HiltViewModel
|
||||
@@ -35,10 +29,9 @@ class AppSettingsModel @Inject constructor(
|
||||
@ApplicationContext val context: Context,
|
||||
private val preference: PreferenceRepository,
|
||||
private val settings: SettingsManager,
|
||||
tasksAppManager: TasksAppManager
|
||||
private val tasksAppManager: TasksAppManager
|
||||
) : ViewModel() {
|
||||
|
||||
|
||||
// debugging
|
||||
|
||||
private val powerManager = context.getSystemService<PowerManager>()!!
|
||||
@@ -100,85 +93,18 @@ class AppSettingsModel @Inject constructor(
|
||||
|
||||
// tasks
|
||||
|
||||
private val pm: PackageManager = context.packageManager
|
||||
val pm: PackageManager = context.packageManager
|
||||
private val appInfoFlow = tasksAppManager.currentProviderFlow(viewModelScope).map { tasksProvider ->
|
||||
tasksProvider?.packageName?.let { pkgName ->
|
||||
pm.getApplicationInfo(pkgName, 0)
|
||||
}
|
||||
}
|
||||
val tasksAppName = appInfoFlow.map { it?.loadLabel(pm)?.toString() }
|
||||
val tasksAppIcon = appInfoFlow.map { it?.loadIcon(pm) }
|
||||
val appName = appInfoFlow.map { it?.loadLabel(pm)?.toString() }
|
||||
val icon = appInfoFlow.map { it?.loadIcon(pm) }
|
||||
|
||||
|
||||
// push
|
||||
|
||||
private val _pushDistributor = MutableStateFlow<String?>(null)
|
||||
val pushDistributor = _pushDistributor.asStateFlow()
|
||||
|
||||
private val _pushDistributors = MutableStateFlow<List<PushDistributorInfo>?>(null)
|
||||
val pushDistributors = _pushDistributors.asStateFlow()
|
||||
|
||||
/**
|
||||
* Loads the push distributors configuration:
|
||||
*
|
||||
* - Loads the currently selected distributor into [pushDistributor].
|
||||
* - Loads all the available distributors into [pushDistributors].
|
||||
* - If there's only one push distributor available, and none is selected, it's selected automatically.
|
||||
* - Makes sure the app is registered with UnifiedPush if there's already a distributor selected.
|
||||
*/
|
||||
private suspend fun loadPushDistributors() {
|
||||
val savedPushDistributor = UnifiedPush.getSavedDistributor(context)
|
||||
_pushDistributor.value = savedPushDistributor
|
||||
|
||||
val pushDistributors = UnifiedPush.getDistributors(context)
|
||||
.map { pushDistributor ->
|
||||
try {
|
||||
val applicationInfo = pm.getApplicationInfo(pushDistributor, 0)
|
||||
val label = pm.getApplicationLabel(applicationInfo).toString()
|
||||
val icon = pm.getApplicationIcon(applicationInfo)
|
||||
PushDistributorInfo(pushDistributor, label, icon)
|
||||
} catch (_: PackageManager.NameNotFoundException) {
|
||||
// The app is not available for some reason, do not include the app data.
|
||||
PushDistributorInfo(pushDistributor)
|
||||
}
|
||||
}
|
||||
_pushDistributors.value = pushDistributors
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates the current push distributor selection.
|
||||
*
|
||||
* Saves the preference in UnifiedPush, (un)registers the app, and writes the selection to [pushDistributor].
|
||||
*
|
||||
* @param pushDistributor The package name of the push distributor, _null_ to disable push.
|
||||
*/
|
||||
fun updatePushDistributor(pushDistributor: String?) {
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
if (pushDistributor == null) {
|
||||
// Disable UnifiedPush if the distributor given is null
|
||||
UnifiedPush.safeRemoveDistributor(context)
|
||||
UnifiedPush.unregisterApp(context)
|
||||
} else {
|
||||
// If a distributor was passed, store it and register the app
|
||||
UnifiedPush.saveDistributor(context, pushDistributor)
|
||||
UnifiedPush.registerApp(context)
|
||||
}
|
||||
_pushDistributor.value = pushDistributor
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
init {
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
loadPushDistributors()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
data class PushDistributorInfo(
|
||||
val packageName: String,
|
||||
val appName: String? = null,
|
||||
val appIcon: Drawable? = null
|
||||
)
|
||||
val pushEndpoint = preference.unifiedPushEndpointFlow()
|
||||
|
||||
}
|
||||
@@ -3,15 +3,9 @@ package at.bitfire.davdroid.ui
|
||||
import android.annotation.SuppressLint
|
||||
import android.graphics.drawable.Drawable
|
||||
import android.os.Build
|
||||
import androidx.appcompat.content.res.AppCompatResources
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
@@ -21,30 +15,22 @@ import androidx.compose.material.icons.filled.Adb
|
||||
import androidx.compose.material.icons.filled.BugReport
|
||||
import androidx.compose.material.icons.filled.InvertColors
|
||||
import androidx.compose.material.icons.filled.Notifications
|
||||
import androidx.compose.material.icons.filled.RadioButtonChecked
|
||||
import androidx.compose.material.icons.filled.RadioButtonUnchecked
|
||||
import androidx.compose.material.icons.filled.SyncProblem
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.ListItem
|
||||
import androidx.compose.material3.ListItemDefaults
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.SnackbarHost
|
||||
import androidx.compose.material3.SnackbarHostState
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.asImageBitmap
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalUriHandler
|
||||
@@ -59,14 +45,14 @@ import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import at.bitfire.davdroid.Constants
|
||||
import at.bitfire.davdroid.R
|
||||
import at.bitfire.davdroid.settings.Settings
|
||||
import at.bitfire.davdroid.ui.AppSettingsModel.PushDistributorInfo
|
||||
import at.bitfire.davdroid.ui.composable.EditTextInputDialog
|
||||
import at.bitfire.davdroid.ui.composable.MultipleChoiceInputDialog
|
||||
import at.bitfire.davdroid.ui.composable.Setting
|
||||
import at.bitfire.davdroid.ui.composable.SettingsHeader
|
||||
import at.bitfire.davdroid.ui.composable.SwitchSetting
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlin.collections.orEmpty
|
||||
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
|
||||
import org.unifiedpush.android.connector.UnifiedPush
|
||||
|
||||
@Composable
|
||||
fun AppSettingsScreen(
|
||||
@@ -110,11 +96,9 @@ fun AppSettingsScreen(
|
||||
onResetHints = model::resetHints,
|
||||
|
||||
// Integration (Tasks and Push)
|
||||
tasksAppName = model.tasksAppName.collectAsStateWithLifecycle(null).value ?: stringResource(R.string.app_settings_tasks_provider_none),
|
||||
tasksAppIcon = model.tasksAppIcon.collectAsStateWithLifecycle(null).value,
|
||||
pushDistributors = model.pushDistributors.collectAsState().value,
|
||||
pushDistributor = model.pushDistributor.collectAsState().value,
|
||||
onPushDistributorChange = model::updatePushDistributor,
|
||||
tasksAppName = model.appName.collectAsStateWithLifecycle(null).value ?: stringResource(R.string.app_settings_tasks_provider_none),
|
||||
tasksAppIcon = model.icon.collectAsStateWithLifecycle(null).value,
|
||||
pushEndpoint = model.pushEndpoint.collectAsStateWithLifecycle(null).value,
|
||||
onNavTasksScreen = onNavTasksScreen
|
||||
)
|
||||
}
|
||||
@@ -153,9 +137,7 @@ fun AppSettingsScreen(
|
||||
// AppSettings Integration
|
||||
tasksAppName: String,
|
||||
tasksAppIcon: Drawable?,
|
||||
pushDistributors: List<PushDistributorInfo>?,
|
||||
pushDistributor: String?,
|
||||
onPushDistributorChange: (String?) -> Unit,
|
||||
pushEndpoint: String?,
|
||||
onNavTasksScreen: () -> Unit,
|
||||
|
||||
onShowNotificationSettings: () -> Unit,
|
||||
@@ -240,11 +222,9 @@ fun AppSettingsScreen(
|
||||
)
|
||||
|
||||
AppSettings_Integration(
|
||||
tasksAppName = tasksAppName,
|
||||
tasksAppIcon = tasksAppIcon,
|
||||
pushDistributors = pushDistributors,
|
||||
pushDistributor = pushDistributor,
|
||||
onPushDistributorChange = onPushDistributorChange,
|
||||
appName = tasksAppName,
|
||||
icon = tasksAppIcon,
|
||||
pushEndpoint = pushEndpoint,
|
||||
onNavTasksScreen = onNavTasksScreen
|
||||
)
|
||||
}
|
||||
@@ -280,9 +260,7 @@ fun AppSettingsScreen_Preview() {
|
||||
onResetHints = {},
|
||||
tasksAppName = "No tasks app",
|
||||
tasksAppIcon = null,
|
||||
pushDistributors = null,
|
||||
pushDistributor = null,
|
||||
onPushDistributorChange = {},
|
||||
pushEndpoint = null,
|
||||
onNavTasksScreen = {}
|
||||
)
|
||||
}
|
||||
@@ -490,146 +468,11 @@ fun AppSettings_UserInterface(
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun PushDistributorSelectionDialog(
|
||||
pushDistributor: String?,
|
||||
onPushDistributorChange: (String?) -> Unit,
|
||||
pushDistributors: List<PushDistributorInfo>?,
|
||||
onDismissRequested: () -> Unit
|
||||
) {
|
||||
var selectedDistributor by remember { mutableStateOf(pushDistributor) }
|
||||
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismissRequested,
|
||||
confirmButton = {
|
||||
TextButton(
|
||||
onClick = {
|
||||
onPushDistributorChange(selectedDistributor)
|
||||
onDismissRequested()
|
||||
}
|
||||
) { Text(stringResource(android.R.string.ok)) }
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(
|
||||
onClick = onDismissRequested
|
||||
) { Text(stringResource(android.R.string.cancel)) }
|
||||
},
|
||||
title = {
|
||||
Text(stringResource(R.string.app_settings_unifiedpush_choose_distributor))
|
||||
},
|
||||
text = {
|
||||
LazyColumn(modifier = Modifier.fillMaxWidth()) {
|
||||
if (pushDistributors.isNullOrEmpty()) item {
|
||||
Text(stringResource(R.string.app_settings_unifiedpush_no_distributor))
|
||||
} else item {
|
||||
ListItem(
|
||||
leadingContent = {
|
||||
Icon(
|
||||
imageVector = if (selectedDistributor == null) {
|
||||
Icons.Default.RadioButtonChecked
|
||||
} else {
|
||||
Icons.Default.RadioButtonUnchecked
|
||||
},
|
||||
contentDescription = null
|
||||
)
|
||||
},
|
||||
headlineContent = {
|
||||
Text(stringResource(R.string.app_settings_unifiedpush_disable))
|
||||
},
|
||||
modifier = Modifier.clickable {
|
||||
selectedDistributor = null
|
||||
},
|
||||
colors = ListItemDefaults.colors(
|
||||
containerColor = Color.Transparent
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
items(pushDistributors.orEmpty()) { (distributor, name, icon) ->
|
||||
ListItem(
|
||||
leadingContent = {
|
||||
Icon(
|
||||
imageVector = if (selectedDistributor == distributor) {
|
||||
Icons.Default.RadioButtonChecked
|
||||
} else {
|
||||
Icons.Default.RadioButtonUnchecked
|
||||
},
|
||||
contentDescription = null
|
||||
)
|
||||
},
|
||||
trailingContent = {
|
||||
icon?.let {
|
||||
Image(
|
||||
bitmap = icon.toBitmap().asImageBitmap(),
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(32.dp)
|
||||
)
|
||||
}
|
||||
},
|
||||
headlineContent = {
|
||||
Text(name ?: distributor)
|
||||
},
|
||||
modifier = Modifier.clickable {
|
||||
selectedDistributor = distributor
|
||||
},
|
||||
colors = ListItemDefaults.colors(
|
||||
containerColor = Color.Transparent
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
@Preview("No distributors installed", "PushDistributorSelectionDialog")
|
||||
fun PushDistributorSelectionDialog_Preview_NoDistributors() {
|
||||
PushDistributorSelectionDialog(null, {}, null) { }
|
||||
}
|
||||
|
||||
@Composable
|
||||
@Preview("Push disabled", "PushDistributorSelectionDialog")
|
||||
fun PushDistributorSelectionDialog_Preview_PushDisabled() {
|
||||
val ctx = LocalContext.current
|
||||
PushDistributorSelectionDialog(
|
||||
null,
|
||||
{},
|
||||
listOf(
|
||||
PushDistributorInfo(
|
||||
"com.example.distributor1",
|
||||
"Distributor 1",
|
||||
AppCompatResources.getDrawable(ctx, R.drawable.ic_launcher_foreground)
|
||||
)
|
||||
)
|
||||
) { }
|
||||
}
|
||||
|
||||
@Composable
|
||||
@Preview("Distributor Selected", "PushDistributorSelectionDialog")
|
||||
fun PushDistributorSelectionDialog_Preview_DistributorSelected() {
|
||||
val ctx = LocalContext.current
|
||||
PushDistributorSelectionDialog(
|
||||
"com.example.distributor1",
|
||||
{},
|
||||
listOf(
|
||||
PushDistributorInfo(
|
||||
"com.example.distributor1",
|
||||
"Distributor 1",
|
||||
AppCompatResources.getDrawable(ctx, R.drawable.ic_launcher_foreground)
|
||||
),
|
||||
PushDistributorInfo("com.example.distributor2")
|
||||
)
|
||||
) { }
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun AppSettings_Integration(
|
||||
tasksAppName: String,
|
||||
tasksAppIcon: Drawable? = null,
|
||||
pushDistributors: List<PushDistributorInfo>?,
|
||||
pushDistributor: String?,
|
||||
onPushDistributorChange: (String?) -> Unit,
|
||||
appName: String,
|
||||
pushEndpoint: String?,
|
||||
icon: Drawable? = null,
|
||||
onNavTasksScreen: () -> Unit = {}
|
||||
) {
|
||||
SettingsHeader(divider = true) {
|
||||
@@ -640,32 +483,24 @@ fun AppSettings_Integration(
|
||||
Text(stringResource(R.string.app_settings_tasks_provider))
|
||||
},
|
||||
icon = {
|
||||
tasksAppIcon?.let {
|
||||
Image(tasksAppIcon.toBitmap().asImageBitmap(), tasksAppName)
|
||||
}
|
||||
icon?.let {
|
||||
Image(icon.toBitmap().asImageBitmap(), appName)
|
||||
}
|
||||
},
|
||||
summary = tasksAppName,
|
||||
summary = appName,
|
||||
onClick = onNavTasksScreen
|
||||
)
|
||||
|
||||
var showingDistributorDialog by remember { mutableStateOf(false) }
|
||||
if (showingDistributorDialog) {
|
||||
PushDistributorSelectionDialog(
|
||||
pushDistributor = pushDistributor,
|
||||
onPushDistributorChange = onPushDistributorChange,
|
||||
pushDistributors = pushDistributors
|
||||
) { showingDistributorDialog = false }
|
||||
}
|
||||
val context = LocalContext.current
|
||||
|
||||
val pushAppName = pushDistributor?.let {
|
||||
pushDistributors?.find { it.packageName == pushDistributor }
|
||||
}?.appName
|
||||
Setting(
|
||||
name = stringResource(R.string.app_settings_unifiedpush),
|
||||
summary = if (pushDistributor != null)
|
||||
stringResource(R.string.app_settings_unifiedpush_ready, pushAppName ?: pushDistributor)
|
||||
summary = if (pushEndpoint != null)
|
||||
stringResource(R.string.app_settings_unifiedpush_endpoint_domain, pushEndpoint.toHttpUrlOrNull()?.host ?: pushEndpoint)
|
||||
else
|
||||
stringResource(R.string.app_settings_unifiedpush_no_endpoint),
|
||||
onClick = { showingDistributorDialog = true }
|
||||
onClick = {
|
||||
UnifiedPush.registerAppWithDialog(context)
|
||||
}
|
||||
)
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user