Compare commits

..

1 Commits

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -66,7 +66,7 @@ class LocalCalendarTest {
@After
fun tearDown() {
calendar.delete()
calendar.deleteCollection()
}

View File

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

View File

@@ -5,7 +5,6 @@
package at.bitfire.davdroid.resource
import android.Manifest
import android.accounts.Account
import android.content.ContentProviderClient
import android.content.ContentUris
import android.content.ContentValues
@@ -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()

View File

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

View File

@@ -5,7 +5,6 @@
package at.bitfire.davdroid.resource.contactrow
import android.Manifest
import android.accounts.Account
import android.content.ContentProviderClient
import android.content.ContentValues
import android.content.Context
@@ -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)

View File

@@ -5,7 +5,6 @@
package at.bitfire.davdroid.resource.contactrow
import android.Manifest
import android.accounts.Account
import android.content.ContentProviderClient
import android.content.Context
import android.net.Uri
@@ -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)

View File

@@ -5,7 +5,6 @@
package at.bitfire.davdroid.resource.contactrow
import android.Manifest
import android.accounts.Account
import android.content.ContentProviderClient
import android.content.ContentValues
import android.content.Context
@@ -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)

View File

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

View File

@@ -1,102 +0,0 @@
/*
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
*/
package at.bitfire.davdroid.settings.migration
import android.accounts.Account
import android.accounts.AccountManager
import android.content.Context
import androidx.test.rule.GrantPermissionRule
import at.bitfire.davdroid.R
import at.bitfire.davdroid.db.AppDatabase
import at.bitfire.davdroid.db.Collection
import at.bitfire.davdroid.db.Service
import at.bitfire.davdroid.resource.LocalAddressBook
import at.bitfire.davdroid.sync.account.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)
}
}
}
}

View File

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

View File

@@ -21,6 +21,8 @@ class LocalTestCollection(
override val readOnly: Boolean
get() = throw NotImplementedError()
override fun deleteCollection(): Boolean = true
override fun findDeleted() = entries.filter { it.deleted }
override fun findDirty() = entries.filter { it.dirty }

View File

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

View File

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

View File

@@ -22,7 +22,7 @@ import org.junit.Test
import javax.inject.Inject
@HiltAndroidTest
class SystemAccountUtilsTest {
class AccountUtilsTest {
@get:Rule
val hiltRule = HiltAndroidRule(this)

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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