From 3a16b5ca3f5144a9b93e3785dc62dc1377209c85 Mon Sep 17 00:00:00 2001 From: Ricki Hirner Date: Sun, 17 Nov 2024 14:39:22 +0100 Subject: [PATCH] [Push] Handle subscription expiration (#1131) * Store subscription expiration in DB * Regularly run PushRegistrationWorker, if needed * Skip re-registering subscriptions that are not about to expire * Add back-off for PushRegistrationWorkerManager * Request expiration in 3 days * Show expiration in UI, timestamps in seconds * Fix tests --- .../15.json | 675 ++++++++++++++++++ .../kotlin/at/bitfire/davdroid/TestModules.kt | 4 +- .../repository/DavCollectionRepositoryTest.kt | 13 +- .../at/bitfire/davdroid/db/AppDatabase.kt | 27 +- .../at/bitfire/davdroid/db/Collection.kt | 5 +- .../at/bitfire/davdroid/db/CollectionDao.kt | 7 +- .../davdroid/push/PushRegistrationWorker.kt | 107 ++- .../push/PushRegistrationWorkerManager.kt | 98 +++ .../davdroid/push/UnifiedPushReceiver.kt | 11 +- .../repository/DavCollectionRepository.kt | 27 +- .../davdroid/ui/account/CollectionScreen.kt | 15 +- app/src/main/res/values/strings.xml | 2 +- 12 files changed, 878 insertions(+), 113 deletions(-) create mode 100644 app/schemas/at.bitfire.davdroid.db.AppDatabase/15.json create mode 100644 app/src/main/kotlin/at/bitfire/davdroid/push/PushRegistrationWorkerManager.kt diff --git a/app/schemas/at.bitfire.davdroid.db.AppDatabase/15.json b/app/schemas/at.bitfire.davdroid.db.AppDatabase/15.json new file mode 100644 index 000000000..17e8d8e21 --- /dev/null +++ b/app/schemas/at.bitfire.davdroid.db.AppDatabase/15.json @@ -0,0 +1,675 @@ +{ + "formatVersion": 1, + "database": { + "version": 15, + "identityHash": "ab1cb6057d8e050f6648bea46ae0943d", + "entities": [ + { + "tableName": "service", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `accountName` TEXT NOT NULL, `type` TEXT NOT NULL, `principal` TEXT)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "accountName", + "columnName": "accountName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "principal", + "columnName": "principal", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_service_accountName_type", + "unique": true, + "columnNames": [ + "accountName", + "type" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_service_accountName_type` ON `${TABLE_NAME}` (`accountName`, `type`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "homeset", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `serviceId` INTEGER NOT NULL, `personal` INTEGER NOT NULL, `url` TEXT NOT NULL, `privBind` INTEGER NOT NULL, `displayName` TEXT, FOREIGN KEY(`serviceId`) REFERENCES `service`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "serviceId", + "columnName": "serviceId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "personal", + "columnName": "personal", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "privBind", + "columnName": "privBind", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "displayName", + "columnName": "displayName", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_homeset_serviceId_url", + "unique": true, + "columnNames": [ + "serviceId", + "url" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_homeset_serviceId_url` ON `${TABLE_NAME}` (`serviceId`, `url`)" + } + ], + "foreignKeys": [ + { + "table": "service", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "serviceId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "collection", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `serviceId` INTEGER NOT NULL, `homeSetId` INTEGER, `ownerId` INTEGER, `type` TEXT NOT NULL, `url` TEXT NOT NULL, `privWriteContent` INTEGER NOT NULL, `privUnbind` INTEGER NOT NULL, `forceReadOnly` INTEGER NOT NULL, `displayName` TEXT, `description` TEXT, `color` INTEGER, `timezone` TEXT, `supportsVEVENT` INTEGER, `supportsVTODO` INTEGER, `supportsVJOURNAL` INTEGER, `source` TEXT, `sync` INTEGER NOT NULL, `pushTopic` TEXT, `supportsWebPush` INTEGER NOT NULL DEFAULT 0, `pushSubscription` TEXT, `pushSubscriptionExpires` INTEGER, `pushSubscriptionCreated` INTEGER, FOREIGN KEY(`serviceId`) REFERENCES `service`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`homeSetId`) REFERENCES `homeset`(`id`) ON UPDATE NO ACTION ON DELETE SET NULL , FOREIGN KEY(`ownerId`) REFERENCES `principal`(`id`) ON UPDATE NO ACTION ON DELETE SET NULL )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "serviceId", + "columnName": "serviceId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "homeSetId", + "columnName": "homeSetId", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "ownerId", + "columnName": "ownerId", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "privWriteContent", + "columnName": "privWriteContent", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "privUnbind", + "columnName": "privUnbind", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "forceReadOnly", + "columnName": "forceReadOnly", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "displayName", + "columnName": "displayName", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "description", + "columnName": "description", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "color", + "columnName": "color", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "timezone", + "columnName": "timezone", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "supportsVEVENT", + "columnName": "supportsVEVENT", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "supportsVTODO", + "columnName": "supportsVTODO", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "supportsVJOURNAL", + "columnName": "supportsVJOURNAL", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "source", + "columnName": "source", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "sync", + "columnName": "sync", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "pushTopic", + "columnName": "pushTopic", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "supportsWebPush", + "columnName": "supportsWebPush", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "pushSubscription", + "columnName": "pushSubscription", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "pushSubscriptionExpires", + "columnName": "pushSubscriptionExpires", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "pushSubscriptionCreated", + "columnName": "pushSubscriptionCreated", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_collection_serviceId_type", + "unique": false, + "columnNames": [ + "serviceId", + "type" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_collection_serviceId_type` ON `${TABLE_NAME}` (`serviceId`, `type`)" + }, + { + "name": "index_collection_homeSetId_type", + "unique": false, + "columnNames": [ + "homeSetId", + "type" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_collection_homeSetId_type` ON `${TABLE_NAME}` (`homeSetId`, `type`)" + }, + { + "name": "index_collection_ownerId_type", + "unique": false, + "columnNames": [ + "ownerId", + "type" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_collection_ownerId_type` ON `${TABLE_NAME}` (`ownerId`, `type`)" + }, + { + "name": "index_collection_pushTopic_type", + "unique": false, + "columnNames": [ + "pushTopic", + "type" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_collection_pushTopic_type` ON `${TABLE_NAME}` (`pushTopic`, `type`)" + }, + { + "name": "index_collection_url", + "unique": false, + "columnNames": [ + "url" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_collection_url` ON `${TABLE_NAME}` (`url`)" + } + ], + "foreignKeys": [ + { + "table": "service", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "serviceId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "homeset", + "onDelete": "SET NULL", + "onUpdate": "NO ACTION", + "columns": [ + "homeSetId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "principal", + "onDelete": "SET NULL", + "onUpdate": "NO ACTION", + "columns": [ + "ownerId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "principal", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `serviceId` INTEGER NOT NULL, `url` TEXT NOT NULL, `displayName` TEXT, FOREIGN KEY(`serviceId`) REFERENCES `service`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "serviceId", + "columnName": "serviceId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "displayName", + "columnName": "displayName", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_principal_serviceId_url", + "unique": true, + "columnNames": [ + "serviceId", + "url" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_principal_serviceId_url` ON `${TABLE_NAME}` (`serviceId`, `url`)" + } + ], + "foreignKeys": [ + { + "table": "service", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "serviceId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "syncstats", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `collectionId` INTEGER NOT NULL, `authority` TEXT NOT NULL, `lastSync` INTEGER NOT NULL, FOREIGN KEY(`collectionId`) REFERENCES `collection`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "collectionId", + "columnName": "collectionId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "authority", + "columnName": "authority", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lastSync", + "columnName": "lastSync", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_syncstats_collectionId_authority", + "unique": true, + "columnNames": [ + "collectionId", + "authority" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_syncstats_collectionId_authority` ON `${TABLE_NAME}` (`collectionId`, `authority`)" + } + ], + "foreignKeys": [ + { + "table": "collection", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "collectionId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "webdav_document", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `mountId` INTEGER NOT NULL, `parentId` INTEGER, `name` TEXT NOT NULL, `isDirectory` INTEGER NOT NULL, `displayName` TEXT, `mimeType` TEXT, `eTag` TEXT, `lastModified` INTEGER, `size` INTEGER, `mayBind` INTEGER, `mayUnbind` INTEGER, `mayWriteContent` INTEGER, `quotaAvailable` INTEGER, `quotaUsed` INTEGER, FOREIGN KEY(`mountId`) REFERENCES `webdav_mount`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`parentId`) REFERENCES `webdav_document`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "mountId", + "columnName": "mountId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "parentId", + "columnName": "parentId", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "isDirectory", + "columnName": "isDirectory", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "displayName", + "columnName": "displayName", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "mimeType", + "columnName": "mimeType", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "eTag", + "columnName": "eTag", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "lastModified", + "columnName": "lastModified", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "size", + "columnName": "size", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "mayBind", + "columnName": "mayBind", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "mayUnbind", + "columnName": "mayUnbind", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "mayWriteContent", + "columnName": "mayWriteContent", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "quotaAvailable", + "columnName": "quotaAvailable", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "quotaUsed", + "columnName": "quotaUsed", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_webdav_document_mountId_parentId_name", + "unique": true, + "columnNames": [ + "mountId", + "parentId", + "name" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_webdav_document_mountId_parentId_name` ON `${TABLE_NAME}` (`mountId`, `parentId`, `name`)" + }, + { + "name": "index_webdav_document_parentId", + "unique": false, + "columnNames": [ + "parentId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_webdav_document_parentId` ON `${TABLE_NAME}` (`parentId`)" + } + ], + "foreignKeys": [ + { + "table": "webdav_mount", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "mountId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "webdav_document", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "parentId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "webdav_mount", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL, `url` TEXT NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + } + ], + "views": [], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'ab1cb6057d8e050f6648bea46ae0943d')" + ] + } +} \ No newline at end of file diff --git a/app/src/androidTest/kotlin/at/bitfire/davdroid/TestModules.kt b/app/src/androidTest/kotlin/at/bitfire/davdroid/TestModules.kt index 26304df07..9a8f94792 100644 --- a/app/src/androidTest/kotlin/at/bitfire/davdroid/TestModules.kt +++ b/app/src/androidTest/kotlin/at/bitfire/davdroid/TestModules.kt @@ -1,6 +1,6 @@ package at.bitfire.davdroid -import at.bitfire.davdroid.push.PushRegistrationWorker +import at.bitfire.davdroid.push.PushRegistrationWorkerManager import at.bitfire.davdroid.repository.DavCollectionRepository import at.bitfire.davdroid.startup.StartupPlugin import at.bitfire.davdroid.startup.TasksAppWatcher @@ -15,7 +15,7 @@ interface TestModules { @Module @TestInstallIn( components = [SingletonComponent::class], - replaces = [PushRegistrationWorker.PushRegistrationWorkerModule::class] + replaces = [PushRegistrationWorkerManager.PushRegistrationWorkerModule::class] ) abstract class TestPushRegistrationWorkerModule { // provides empty set of listeners diff --git a/app/src/androidTest/kotlin/at/bitfire/davdroid/repository/DavCollectionRepositoryTest.kt b/app/src/androidTest/kotlin/at/bitfire/davdroid/repository/DavCollectionRepositoryTest.kt index 5f572aeb7..09a06e3de 100644 --- a/app/src/androidTest/kotlin/at/bitfire/davdroid/repository/DavCollectionRepositoryTest.kt +++ b/app/src/androidTest/kotlin/at/bitfire/davdroid/repository/DavCollectionRepositoryTest.kt @@ -5,6 +5,7 @@ import at.bitfire.davdroid.db.AppDatabase import at.bitfire.davdroid.db.Collection import at.bitfire.davdroid.db.Service import at.bitfire.davdroid.settings.AccountSettings +import dagger.Lazy import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.android.testing.HiltAndroidRule import dagger.hilt.android.testing.HiltAndroidTest @@ -63,7 +64,17 @@ class DavCollectionRepositoryTest { ) ) val testObserver = mockk(relaxed = true) - val collectionRepository = DavCollectionRepository(accountSettingsFactory, context, db, mutableSetOf(testObserver), serviceRepository) + val collectionRepository = DavCollectionRepository( + accountSettingsFactory, + context, + db, + object : Lazy> { + override fun get(): Set { + return mutableSetOf(testObserver) + } + }, + serviceRepository + ) assert(db.collectionDao().get(collectionId)?.forceReadOnly == false) verify(exactly = 0) { diff --git a/app/src/main/kotlin/at/bitfire/davdroid/db/AppDatabase.kt b/app/src/main/kotlin/at/bitfire/davdroid/db/AppDatabase.kt index 52694907d..294c88324 100644 --- a/app/src/main/kotlin/at/bitfire/davdroid/db/AppDatabase.kt +++ b/app/src/main/kotlin/at/bitfire/davdroid/db/AppDatabase.kt @@ -44,12 +44,13 @@ import javax.inject.Singleton SyncStats::class, WebDavDocument::class, WebDavMount::class -], exportSchema = true, version = 14, autoMigrations = [ +], exportSchema = true, version = 15, 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 = 13, to = 14), + AutoMigration(from = 14, to = 15) ]) @TypeConverters(Converters::class) abstract class AppDatabase: RoomDatabase() { @@ -218,28 +219,6 @@ abstract class AppDatabase: RoomDatabase() { // 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() - }*/ } }, diff --git a/app/src/main/kotlin/at/bitfire/davdroid/db/Collection.kt b/app/src/main/kotlin/at/bitfire/davdroid/db/Collection.kt index 945c42372..092712354 100644 --- a/app/src/main/kotlin/at/bitfire/davdroid/db/Collection.kt +++ b/app/src/main/kotlin/at/bitfire/davdroid/db/Collection.kt @@ -127,7 +127,10 @@ data class Collection( /** WebDAV-Push subscription URL */ var pushSubscription: String? = null, - /** when the [pushSubscription] was created/updated (used to determine whether we need to re-subscribe) */ + /** 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) */ var pushSubscriptionCreated: Long? = null ) { diff --git a/app/src/main/kotlin/at/bitfire/davdroid/db/CollectionDao.kt b/app/src/main/kotlin/at/bitfire/davdroid/db/CollectionDao.kt index 49951059e..3504ef918 100644 --- a/app/src/main/kotlin/at/bitfire/davdroid/db/CollectionDao.kt +++ b/app/src/main/kotlin/at/bitfire/davdroid/db/CollectionDao.kt @@ -38,6 +38,9 @@ interface CollectionDao { @Query("SELECT COUNT(*) FROM collection WHERE serviceId=:serviceId AND type=:type") suspend fun anyOfType(serviceId: Long, type: String): Boolean + @Query("SELECT COUNT(*) FROM collection WHERE supportsWebPush AND pushTopic IS NOT NULL") + suspend fun anyPushCapable(): Boolean + /** * Returns collections which * - support VEVENT and/or VTODO (= supported calendar collections), or @@ -87,8 +90,8 @@ interface CollectionDao { @Query("UPDATE collection SET forceReadOnly=:forceReadOnly WHERE id=:id") suspend fun updateForceReadOnly(id: Long, forceReadOnly: Boolean) - @Query("UPDATE collection SET pushSubscription=:pushSubscription, pushSubscriptionCreated=:updatedAt WHERE id=:id") - fun updatePushSubscription(id: Long, pushSubscription: String?, updatedAt: Long = System.currentTimeMillis()) + @Query("UPDATE collection SET pushSubscription=:pushSubscription, pushSubscriptionExpires=:pushSubscriptionExpires, pushSubscriptionCreated=:updatedAt WHERE id=:id") + fun updatePushSubscription(id: Long, pushSubscription: String?, pushSubscriptionExpires: Long?, updatedAt: Long = System.currentTimeMillis()/1000) @Query("UPDATE collection SET sync=:sync WHERE id=:id") suspend fun updateSync(id: Long, sync: Boolean) diff --git a/app/src/main/kotlin/at/bitfire/davdroid/push/PushRegistrationWorker.kt b/app/src/main/kotlin/at/bitfire/davdroid/push/PushRegistrationWorker.kt index 2a7cb1a68..eb0711eb0 100644 --- a/app/src/main/kotlin/at/bitfire/davdroid/push/PushRegistrationWorker.kt +++ b/app/src/main/kotlin/at/bitfire/davdroid/push/PushRegistrationWorker.kt @@ -7,15 +7,11 @@ package at.bitfire.davdroid.push import android.accounts.Account import android.content.Context import androidx.hilt.work.HiltWorker -import androidx.work.Constraints import androidx.work.CoroutineWorker -import androidx.work.ExistingWorkPolicy -import androidx.work.NetworkType -import androidx.work.OneTimeWorkRequestBuilder -import androidx.work.WorkManager import androidx.work.WorkerParameters import at.bitfire.dav4jvm.DavCollection import at.bitfire.dav4jvm.DavResource +import at.bitfire.dav4jvm.HttpUtils import at.bitfire.dav4jvm.Property import at.bitfire.dav4jvm.XmlUtils import at.bitfire.dav4jvm.XmlUtils.insertTag @@ -28,31 +24,23 @@ import at.bitfire.davdroid.repository.DavCollectionRepository import at.bitfire.davdroid.repository.DavServiceRepository import at.bitfire.davdroid.repository.PreferenceRepository import at.bitfire.davdroid.settings.AccountSettings -import dagger.Binds -import dagger.Module import dagger.assisted.Assisted import dagger.assisted.AssistedInject -import dagger.hilt.InstallIn -import dagger.hilt.android.qualifiers.ApplicationContext -import dagger.hilt.components.SingletonComponent -import dagger.multibindings.IntoSet import kotlinx.coroutines.runInterruptible import okhttp3.HttpUrl import okhttp3.HttpUrl.Companion.toHttpUrlOrNull import okhttp3.RequestBody.Companion.toRequestBody +import java.io.IOException import java.io.StringWriter -import java.util.concurrent.TimeUnit +import java.time.Duration +import java.time.Instant import java.util.logging.Level import java.util.logging.Logger -import javax.inject.Inject /** * Worker that registers push for all collections that support it. * To be run as soon as a collection that supports push is changed (selected for sync status * changes, or collection is created, deleted, etc). - * - * TODO Should run periodically, too (to refresh registrations that are about to expire). - * Not required for a first demonstration version. */ @Suppress("unused") @HiltWorker @@ -66,34 +54,15 @@ class PushRegistrationWorker @AssistedInject constructor( private val serviceRepository: DavServiceRepository ) : CoroutineWorker(context, workerParameters) { - companion object { - - private const val UNIQUE_WORK_NAME = "push-registration" - - /** - * Enqueues a push registration worker with a minimum delay of 5 seconds. - */ - fun enqueue(context: Context) { - val constraints = Constraints.Builder() - .setRequiredNetworkType(NetworkType.CONNECTED) // require a network connection - .build() - val workRequest = OneTimeWorkRequestBuilder() - .setInitialDelay(5, TimeUnit.SECONDS) - .setConstraints(constraints) - .build() - Logger.getGlobal().info("Enqueueing push registration worker") - WorkManager.getInstance(context) - .enqueueUniqueWork(UNIQUE_WORK_NAME, ExistingWorkPolicy.REPLACE, workRequest) - } - - } - - override suspend fun doWork(): Result { logger.info("Running push registration worker") - registerSyncable() - unregisterNotSyncable() + try { + registerSyncable() + unregisterNotSyncable() + } catch (_: IOException) { + return Result.retry() // retry on I/O errors + } return Result.success() } @@ -108,17 +77,25 @@ 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() @@ -126,9 +103,15 @@ class PushRegistrationWorker @AssistedInject constructor( val xml = writer.toString().toRequestBody(DavResource.MIME_XML) DavCollection(httpClient, collection.url).post(xml) { response -> if (response.isSuccessful) { - response.header("Location")?.let { subscriptionUrl -> - collectionRepository.updatePushSubscription(collection.id, subscriptionUrl) - } + val subscriptionUrl = response.header("Location") + val expires = response.header("Expires")?.let { expiresDate -> + HttpUtils.parseDate(expiresDate) + } ?: requestedExpiration + collectionRepository.updatePushSubscription( + id = collection.id, + subscriptionUrl = subscriptionUrl, + expires = expires?.epochSecond + ) } else logger.warning("Couldn't register push for ${collection.url}: $response") } @@ -142,6 +125,15 @@ class PushRegistrationWorker @AssistedInject constructor( // register push subscription for syncable collections if (endpoint != null) for (collection in collectionRepository.getPushCapableAndSyncable()) { + val expires = collection.pushSubscriptionExpires + // calculate next run time, but use the duplicate interval for safety (times are not exact) + val nextRun = Instant.now() + Duration.ofDays(2*PushRegistrationWorkerManager.INTERVAL_DAYS) + if (expires != null && expires >= nextRun.epochSecond) { + logger.fine("Push subscription for ${collection.url} is still valid until ${collection.pushSubscriptionExpires}") + continue + } + + // no existing subscription or expiring soon logger.info("Registering push for ${collection.url}") serviceRepository.get(collection.serviceId)?.let { service -> val account = Account(service.accountName, applicationContext.getString(R.string.account_type)) @@ -176,7 +168,11 @@ class PushRegistrationWorker @AssistedInject constructor( } // remove registration URL from DB in any case - collectionRepository.updatePushSubscription(collection.id, null) + collectionRepository.updatePushSubscription( + id = collection.id, + subscriptionUrl = null, + expires = null + ) } } } @@ -193,25 +189,4 @@ class PushRegistrationWorker @AssistedInject constructor( } } - - /** - * Listener that enqueues a push registration worker when the collection list changes. - */ - class CollectionsListener @Inject constructor( - @ApplicationContext val context: Context - ): DavCollectionRepository.OnChangeListener { - override fun onCollectionsChanged() = enqueue(context) - } - - /** - * Hilt module that registers [CollectionsListener] in [DavCollectionRepository]. - */ - @Module - @InstallIn(SingletonComponent::class) - interface PushRegistrationWorkerModule { - @Binds - @IntoSet - fun listener(impl: CollectionsListener): DavCollectionRepository.OnChangeListener - } - } \ No newline at end of file diff --git a/app/src/main/kotlin/at/bitfire/davdroid/push/PushRegistrationWorkerManager.kt b/app/src/main/kotlin/at/bitfire/davdroid/push/PushRegistrationWorkerManager.kt new file mode 100644 index 000000000..453f32a52 --- /dev/null +++ b/app/src/main/kotlin/at/bitfire/davdroid/push/PushRegistrationWorkerManager.kt @@ -0,0 +1,98 @@ +/* + * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. + */ + +package at.bitfire.davdroid.push + +import android.content.Context +import androidx.work.BackoffPolicy +import androidx.work.Constraints +import androidx.work.ExistingPeriodicWorkPolicy +import androidx.work.NetworkType +import androidx.work.PeriodicWorkRequest +import androidx.work.WorkManager +import at.bitfire.davdroid.repository.DavCollectionRepository +import dagger.Binds +import dagger.Module +import dagger.hilt.InstallIn +import dagger.hilt.android.qualifiers.ApplicationContext +import dagger.hilt.components.SingletonComponent +import dagger.multibindings.IntoSet +import kotlinx.coroutines.runBlocking +import java.util.concurrent.TimeUnit +import java.util.logging.Logger +import javax.inject.Inject + +class PushRegistrationWorkerManager @Inject constructor( + @ApplicationContext val context: Context, + val collectionRepository: DavCollectionRepository, + val logger: Logger +) { + + /** + * Determines whether there are any push-capable collections and updates the periodic worker accordingly. + * + * If there are push-capable collections, a unique periodic worker with an initial delay of 5 seconds is enqueued. + * A potentially existing worker is replaced, so that the first run should be soon. + * + * Otherwise, a potentially existing worker is cancelled. + */ + fun updatePeriodicWorker() { + val workerNeeded = runBlocking { + collectionRepository.anyPushCapable() + } + + val workManager = WorkManager.getInstance(context) + if (workerNeeded) { + logger.info("Enqueuing periodic PushRegistrationWorker") + workManager.enqueueUniquePeriodicWork( + UNIQUE_WORK_NAME, + ExistingPeriodicWorkPolicy.CANCEL_AND_REENQUEUE, + PeriodicWorkRequest.Builder(PushRegistrationWorker::class, INTERVAL_DAYS, TimeUnit.DAYS) + .setInitialDelay(5, TimeUnit.SECONDS) + .setConstraints( + Constraints.Builder() + .setRequiredNetworkType(NetworkType.CONNECTED) + .build() + ) + .setBackoffCriteria(BackoffPolicy.EXPONENTIAL, 1, TimeUnit.MINUTES) + .build() + ) + } else { + logger.info("Cancelling periodic PushRegistrationWorker") + workManager.cancelUniqueWork(UNIQUE_WORK_NAME) + } + } + + + companion object { + private const val UNIQUE_WORK_NAME = "push-registration" + const val INTERVAL_DAYS = 1L + } + + + /** + * Listener that enqueues a push registration worker when the collection list changes. + */ + class CollectionsListener @Inject constructor( + @ApplicationContext val context: Context, + val workerManager: PushRegistrationWorkerManager + ): DavCollectionRepository.OnChangeListener { + + override fun onCollectionsChanged() { + workerManager.updatePeriodicWorker() + } + + } + + /** + * Hilt module that registers [CollectionsListener] in [DavCollectionRepository]. + */ + @Module + @InstallIn(SingletonComponent::class) + interface PushRegistrationWorkerModule { + @Binds + @IntoSet + fun listener(impl: CollectionsListener): DavCollectionRepository.OnChangeListener + } +} \ No newline at end of file diff --git a/app/src/main/kotlin/at/bitfire/davdroid/push/UnifiedPushReceiver.kt b/app/src/main/kotlin/at/bitfire/davdroid/push/UnifiedPushReceiver.kt index 8a5ed76c2..ce2264af3 100644 --- a/app/src/main/kotlin/at/bitfire/davdroid/push/UnifiedPushReceiver.kt +++ b/app/src/main/kotlin/at/bitfire/davdroid/push/UnifiedPushReceiver.kt @@ -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,6 +40,9 @@ class UnifiedPushReceiver: MessagingReceiver() { @Inject lateinit var parsePushMessage: PushMessageParser + @Inject + lateinit var pushRegistrationWorkerManager: PushRegistrationWorkerManager + @Inject lateinit var syncWorkerManager: SyncWorkerManager @@ -49,7 +52,7 @@ class UnifiedPushReceiver: MessagingReceiver() { preferenceRepository.unifiedPushEndpoint(endpoint) // register new endpoint at CalDAV/CardDAV servers - PushRegistrationWorker.enqueue(context) + pushRegistrationWorkerManager.updatePeriodicWorker() } override fun onUnregistered(context: Context, instance: String) { diff --git a/app/src/main/kotlin/at/bitfire/davdroid/repository/DavCollectionRepository.kt b/app/src/main/kotlin/at/bitfire/davdroid/repository/DavCollectionRepository.kt index fe28ef209..deeb285f0 100644 --- a/app/src/main/kotlin/at/bitfire/davdroid/repository/DavCollectionRepository.kt +++ b/app/src/main/kotlin/at/bitfire/davdroid/repository/DavCollectionRepository.kt @@ -29,15 +29,12 @@ import at.bitfire.davdroid.servicedetection.RefreshCollectionsWorker import at.bitfire.davdroid.settings.AccountSettings import at.bitfire.davdroid.util.DavUtils import at.bitfire.ical4android.util.DateUtils +import dagger.Lazy import dagger.Module import dagger.hilt.InstallIn import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.components.SingletonComponent import dagger.multibindings.Multibinds -import java.io.StringWriter -import java.util.Collections -import java.util.UUID -import javax.inject.Inject import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.runInterruptible import kotlinx.coroutines.withContext @@ -46,6 +43,10 @@ import net.fortuna.ical4j.model.Component import net.fortuna.ical4j.model.ComponentList import net.fortuna.ical4j.model.component.VTimeZone import okhttp3.HttpUrl +import java.io.StringWriter +import java.util.Collections +import java.util.UUID +import javax.inject.Inject /** * Repository for managing collections. @@ -56,14 +57,20 @@ class DavCollectionRepository @Inject constructor( private val accountSettingsFactory: AccountSettings.Factory, @ApplicationContext val context: Context, db: AppDatabase, - defaultListeners: Set<@JvmSuppressWildcards OnChangeListener>, + defaultListeners: Lazy>, private val serviceRepository: DavServiceRepository ) { - private val listeners = Collections.synchronizedSet(defaultListeners.toMutableSet()) + private val listeners by lazy { Collections.synchronizedSet(defaultListeners.get().toMutableSet()) } private val dao = db.collectionDao() + /** + * Whether there are any collections that are registered for push. + */ + suspend fun anyPushCapable() = + dao.anyPushCapable() + /** * Creates address book collection on server and locally */ @@ -256,8 +263,12 @@ class DavCollectionRepository @Inject constructor( notifyOnChangeListeners() } - fun updatePushSubscription(id: Long, subscriptionUrl: String?) { - dao.updatePushSubscription(id, subscriptionUrl) + fun updatePushSubscription(id: Long, subscriptionUrl: String?, expires: Long?) { + dao.updatePushSubscription( + id = id, + pushSubscription = subscriptionUrl, + pushSubscriptionExpires = expires + ) } /** diff --git a/app/src/main/kotlin/at/bitfire/davdroid/ui/account/CollectionScreen.kt b/app/src/main/kotlin/at/bitfire/davdroid/ui/account/CollectionScreen.kt index 94916d8f8..3d7e64ef6 100644 --- a/app/src/main/kotlin/at/bitfire/davdroid/ui/account/CollectionScreen.kt +++ b/app/src/main/kotlin/at/bitfire/davdroid/ui/account/CollectionScreen.kt @@ -97,6 +97,7 @@ fun CollectionScreen( lastSynced = model.lastSynced.collectAsStateWithLifecycle(emptyList()).value, supportsWebPush = collection.supportsWebPush, pushSubscriptionCreated = collection.pushSubscriptionCreated, + pushSubscriptionExpires = collection.pushSubscriptionExpires, url = collection.url.toString(), onDelete = model::delete, onNavUp = onNavUp @@ -121,6 +122,7 @@ fun CollectionScreen( lastSynced: List = emptyList(), supportsWebPush: Boolean = false, pushSubscriptionCreated: Long? = null, + pushSubscriptionExpires: Long? = null, url: String, onDelete: () -> Unit = {}, onNavUp: () -> Unit = {} @@ -249,10 +251,13 @@ fun CollectionScreen( if (supportsWebPush) { val text = - if (pushSubscriptionCreated != null) { + if (pushSubscriptionCreated != null && pushSubscriptionExpires != null) { val formatter = DateTimeFormatter.ofLocalizedDateTime(FormatStyle.SHORT).withZone(ZoneId.systemDefault()) - val time = Instant.ofEpochMilli(pushSubscriptionCreated) - stringResource(R.string.collection_push_subscribed_at, formatter.format(time)) + stringResource( + R.string.collection_push_subscribed_at, + formatter.format(Instant.ofEpochSecond(pushSubscriptionCreated)), + formatter.format(Instant.ofEpochSecond(pushSubscriptionExpires)) + ) } else stringResource(R.string.collection_push_web_push) CollectionScreen_Entry( @@ -360,7 +365,9 @@ fun CollectionScreen_Preview() { lastSynced = 1234567890 ) ), - supportsWebPush = true + supportsWebPush = true, + pushSubscriptionCreated = 1731846565, + pushSubscriptionExpires = 1731847565 ) } diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 8ea9917c3..5b118de79 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -416,7 +416,7 @@ Owner Push support Server advertises Push support - Subscribed (%s) + Subscribed at %1$s, expires at %2$s Last sync (%s) Address (URL)