diff --git a/android-shared/schemas/org.siloserver.silo.common.data.db.SiloDatabase/12.json b/android-shared/schemas/org.siloserver.silo.common.data.db.SiloDatabase/12.json new file mode 100644 index 000000000..ca15542f8 --- /dev/null +++ b/android-shared/schemas/org.siloserver.silo.common.data.db.SiloDatabase/12.json @@ -0,0 +1,984 @@ +{ + "formatVersion": 1, + "database": { + "version": 12, + "identityHash": "8a348d04acd6cd7fe41b6ee065fb65cb", + "entities": [ + { + "tableName": "user_item_state", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`serverId` TEXT NOT NULL, `profileId` TEXT NOT NULL, `contentId` TEXT NOT NULL, `fileId` INTEGER NOT NULL, `positionSeconds` REAL NOT NULL, `durationSeconds` REAL, `audioFingerprint` TEXT, `subtitleFingerprint` TEXT, `cfi` TEXT, `readProgress` REAL, `clientUpdatedAtMs` INTEGER NOT NULL, `serverUpdatedAtMs` INTEGER, PRIMARY KEY(`serverId`, `profileId`, `contentId`, `fileId`))", + "fields": [ + { + "fieldPath": "serverId", + "columnName": "serverId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "profileId", + "columnName": "profileId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "contentId", + "columnName": "contentId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "fileId", + "columnName": "fileId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "positionSeconds", + "columnName": "positionSeconds", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "durationSeconds", + "columnName": "durationSeconds", + "affinity": "REAL" + }, + { + "fieldPath": "audioFingerprint", + "columnName": "audioFingerprint", + "affinity": "TEXT" + }, + { + "fieldPath": "subtitleFingerprint", + "columnName": "subtitleFingerprint", + "affinity": "TEXT" + }, + { + "fieldPath": "cfi", + "columnName": "cfi", + "affinity": "TEXT" + }, + { + "fieldPath": "readProgress", + "columnName": "readProgress", + "affinity": "REAL" + }, + { + "fieldPath": "clientUpdatedAtMs", + "columnName": "clientUpdatedAtMs", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "serverUpdatedAtMs", + "columnName": "serverUpdatedAtMs", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "serverId", + "profileId", + "contentId", + "fileId" + ] + }, + "indices": [ + { + "name": "index_user_item_state_serverId_profileId_contentId", + "unique": false, + "columnNames": [ + "serverId", + "profileId", + "contentId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_user_item_state_serverId_profileId_contentId` ON `${TABLE_NAME}` (`serverId`, `profileId`, `contentId`)" + }, + { + "name": "index_user_item_state_serverId_profileId_clientUpdatedAtMs", + "unique": false, + "columnNames": [ + "serverId", + "profileId", + "clientUpdatedAtMs" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_user_item_state_serverId_profileId_clientUpdatedAtMs` ON `${TABLE_NAME}` (`serverId`, `profileId`, `clientUpdatedAtMs`)" + } + ] + }, + { + "tableName": "content_item_state", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`serverId` TEXT NOT NULL, `profileId` TEXT NOT NULL, `contentId` TEXT NOT NULL, `watched` INTEGER, `ratingValue` INTEGER, `favorite` INTEGER, `clientUpdatedAtMs` INTEGER NOT NULL, `serverUpdatedAtMs` INTEGER, PRIMARY KEY(`serverId`, `profileId`, `contentId`))", + "fields": [ + { + "fieldPath": "serverId", + "columnName": "serverId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "profileId", + "columnName": "profileId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "contentId", + "columnName": "contentId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "watched", + "columnName": "watched", + "affinity": "INTEGER" + }, + { + "fieldPath": "ratingValue", + "columnName": "ratingValue", + "affinity": "INTEGER" + }, + { + "fieldPath": "favorite", + "columnName": "favorite", + "affinity": "INTEGER" + }, + { + "fieldPath": "clientUpdatedAtMs", + "columnName": "clientUpdatedAtMs", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "serverUpdatedAtMs", + "columnName": "serverUpdatedAtMs", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "serverId", + "profileId", + "contentId" + ] + } + }, + { + "tableName": "dirty_operations", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `opKind` TEXT NOT NULL, `serverId` TEXT NOT NULL, `profileId` TEXT NOT NULL, `targetContentId` TEXT NOT NULL, `targetFileId` INTEGER, `coalesceKey` TEXT NOT NULL, `idempotencyKey` TEXT NOT NULL, `opVersion` INTEGER NOT NULL, `payloadJson` TEXT NOT NULL, `state` TEXT NOT NULL, `createdAtMs` INTEGER NOT NULL, `attemptCount` INTEGER NOT NULL, `lastAttemptAtMs` INTEGER, `nextAttemptAtMs` INTEGER NOT NULL, `lastError` TEXT, `membershipAuthority` TEXT, `membershipClaim` TEXT, `membershipOwner` TEXT)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "opKind", + "columnName": "opKind", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "serverId", + "columnName": "serverId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "profileId", + "columnName": "profileId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "targetContentId", + "columnName": "targetContentId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "targetFileId", + "columnName": "targetFileId", + "affinity": "INTEGER" + }, + { + "fieldPath": "coalesceKey", + "columnName": "coalesceKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "idempotencyKey", + "columnName": "idempotencyKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "opVersion", + "columnName": "opVersion", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "payloadJson", + "columnName": "payloadJson", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "state", + "columnName": "state", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "createdAtMs", + "columnName": "createdAtMs", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "attemptCount", + "columnName": "attemptCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastAttemptAtMs", + "columnName": "lastAttemptAtMs", + "affinity": "INTEGER" + }, + { + "fieldPath": "nextAttemptAtMs", + "columnName": "nextAttemptAtMs", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastError", + "columnName": "lastError", + "affinity": "TEXT" + }, + { + "fieldPath": "membershipAuthority", + "columnName": "membershipAuthority", + "affinity": "TEXT" + }, + { + "fieldPath": "membershipClaim", + "columnName": "membershipClaim", + "affinity": "TEXT" + }, + { + "fieldPath": "membershipOwner", + "columnName": "membershipOwner", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_dirty_operations_idempotencyKey", + "unique": true, + "columnNames": [ + "idempotencyKey" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_dirty_operations_idempotencyKey` ON `${TABLE_NAME}` (`idempotencyKey`)" + }, + { + "name": "index_dirty_operations_coalesceKey", + "unique": false, + "columnNames": [ + "coalesceKey" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_dirty_operations_coalesceKey` ON `${TABLE_NAME}` (`coalesceKey`)" + }, + { + "name": "index_dirty_operations_nextAttemptAtMs_id", + "unique": false, + "columnNames": [ + "nextAttemptAtMs", + "id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_dirty_operations_nextAttemptAtMs_id` ON `${TABLE_NAME}` (`nextAttemptAtMs`, `id`)" + } + ] + }, + { + "tableName": "downloads", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`serverId` TEXT NOT NULL, `profileId` TEXT NOT NULL, `mediaFileId` INTEGER NOT NULL, `recordId` TEXT NOT NULL, `contentId` TEXT NOT NULL, `title` TEXT NOT NULL, `subtitle` TEXT, `posterUrl` TEXT, `posterThumbhash` TEXT, `year` INTEGER, `seriesTitle` TEXT, `seriesContentId` TEXT, `seasonNumber` INTEGER, `episodeNumber` INTEGER, `fileName` TEXT, `container` TEXT, `localUri` TEXT, `mediaType` TEXT NOT NULL, `overview` TEXT, `author` TEXT, `narrator` TEXT, `durationSeconds` REAL, `chaptersJson` TEXT, `status` TEXT NOT NULL, `kind` TEXT NOT NULL, `fileSize` INTEGER NOT NULL, `bytesSent` INTEGER NOT NULL, `createdAt` TEXT NOT NULL, `completedAt` TEXT, `updatedAtMs` INTEGER NOT NULL, `resumeValidator` TEXT, `quality` TEXT, `effectiveQuality` TEXT, `revision` INTEGER, PRIMARY KEY(`serverId`, `profileId`, `mediaFileId`))", + "fields": [ + { + "fieldPath": "serverId", + "columnName": "serverId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "profileId", + "columnName": "profileId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "mediaFileId", + "columnName": "mediaFileId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "recordId", + "columnName": "recordId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "contentId", + "columnName": "contentId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "subtitle", + "columnName": "subtitle", + "affinity": "TEXT" + }, + { + "fieldPath": "posterUrl", + "columnName": "posterUrl", + "affinity": "TEXT" + }, + { + "fieldPath": "posterThumbhash", + "columnName": "posterThumbhash", + "affinity": "TEXT" + }, + { + "fieldPath": "year", + "columnName": "year", + "affinity": "INTEGER" + }, + { + "fieldPath": "seriesTitle", + "columnName": "seriesTitle", + "affinity": "TEXT" + }, + { + "fieldPath": "seriesContentId", + "columnName": "seriesContentId", + "affinity": "TEXT" + }, + { + "fieldPath": "seasonNumber", + "columnName": "seasonNumber", + "affinity": "INTEGER" + }, + { + "fieldPath": "episodeNumber", + "columnName": "episodeNumber", + "affinity": "INTEGER" + }, + { + "fieldPath": "fileName", + "columnName": "fileName", + "affinity": "TEXT" + }, + { + "fieldPath": "container", + "columnName": "container", + "affinity": "TEXT" + }, + { + "fieldPath": "localUri", + "columnName": "localUri", + "affinity": "TEXT" + }, + { + "fieldPath": "mediaType", + "columnName": "mediaType", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "overview", + "columnName": "overview", + "affinity": "TEXT" + }, + { + "fieldPath": "author", + "columnName": "author", + "affinity": "TEXT" + }, + { + "fieldPath": "narrator", + "columnName": "narrator", + "affinity": "TEXT" + }, + { + "fieldPath": "durationSeconds", + "columnName": "durationSeconds", + "affinity": "REAL" + }, + { + "fieldPath": "chaptersJson", + "columnName": "chaptersJson", + "affinity": "TEXT" + }, + { + "fieldPath": "status", + "columnName": "status", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "kind", + "columnName": "kind", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "fileSize", + "columnName": "fileSize", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "bytesSent", + "columnName": "bytesSent", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "completedAt", + "columnName": "completedAt", + "affinity": "TEXT" + }, + { + "fieldPath": "updatedAtMs", + "columnName": "updatedAtMs", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "resumeValidator", + "columnName": "resumeValidator", + "affinity": "TEXT" + }, + { + "fieldPath": "quality", + "columnName": "quality", + "affinity": "TEXT" + }, + { + "fieldPath": "effectiveQuality", + "columnName": "effectiveQuality", + "affinity": "TEXT" + }, + { + "fieldPath": "revision", + "columnName": "revision", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "serverId", + "profileId", + "mediaFileId" + ] + }, + "indices": [ + { + "name": "index_downloads_serverId_profileId_status", + "unique": false, + "columnNames": [ + "serverId", + "profileId", + "status" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_downloads_serverId_profileId_status` ON `${TABLE_NAME}` (`serverId`, `profileId`, `status`)" + }, + { + "name": "index_downloads_serverId_profileId_contentId", + "unique": false, + "columnNames": [ + "serverId", + "profileId", + "contentId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_downloads_serverId_profileId_contentId` ON `${TABLE_NAME}` (`serverId`, `profileId`, `contentId`)" + }, + { + "name": "index_downloads_recordId", + "unique": true, + "columnNames": [ + "recordId" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_downloads_recordId` ON `${TABLE_NAME}` (`recordId`)" + } + ] + }, + { + "tableName": "legacy_imports", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `sourceKind` TEXT NOT NULL, `sourcePath` TEXT NOT NULL, `sourceHash` TEXT NOT NULL, `sourceMtimeMs` INTEGER NOT NULL, `importedAtMs` INTEGER NOT NULL, `importVersion` INTEGER NOT NULL, `status` TEXT NOT NULL, `error` TEXT)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "sourceKind", + "columnName": "sourceKind", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sourcePath", + "columnName": "sourcePath", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sourceHash", + "columnName": "sourceHash", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sourceMtimeMs", + "columnName": "sourceMtimeMs", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "importedAtMs", + "columnName": "importedAtMs", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "importVersion", + "columnName": "importVersion", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "status", + "columnName": "status", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "error", + "columnName": "error", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_legacy_imports_sourceKind_sourcePath", + "unique": true, + "columnNames": [ + "sourceKind", + "sourcePath" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_legacy_imports_sourceKind_sourcePath` ON `${TABLE_NAME}` (`sourceKind`, `sourcePath`)" + } + ] + }, + { + "tableName": "home_cache", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`serverId` TEXT NOT NULL, `profileId` TEXT NOT NULL, `sectionsJson` TEXT NOT NULL, `cachedAtMs` INTEGER NOT NULL, PRIMARY KEY(`serverId`, `profileId`))", + "fields": [ + { + "fieldPath": "serverId", + "columnName": "serverId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "profileId", + "columnName": "profileId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sectionsJson", + "columnName": "sectionsJson", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "cachedAtMs", + "columnName": "cachedAtMs", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "serverId", + "profileId" + ] + } + }, + { + "tableName": "catalog_cache", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`serverId` TEXT NOT NULL, `profileId` TEXT NOT NULL, `cacheKey` TEXT NOT NULL, `json` TEXT NOT NULL, `cachedAtMs` INTEGER NOT NULL, PRIMARY KEY(`serverId`, `profileId`, `cacheKey`))", + "fields": [ + { + "fieldPath": "serverId", + "columnName": "serverId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "profileId", + "columnName": "profileId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "cacheKey", + "columnName": "cacheKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "json", + "columnName": "json", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "cachedAtMs", + "columnName": "cachedAtMs", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "serverId", + "profileId", + "cacheKey" + ] + } + }, + { + "tableName": "download_deletions", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`serverId` TEXT NOT NULL, `profileId` TEXT NOT NULL, `recordId` TEXT NOT NULL, `mediaFileId` INTEGER, `enqueuedAtMs` INTEGER NOT NULL, `loginId` TEXT, `origin` TEXT, `deviceId` TEXT, PRIMARY KEY(`serverId`, `profileId`, `recordId`))", + "fields": [ + { + "fieldPath": "serverId", + "columnName": "serverId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "profileId", + "columnName": "profileId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "recordId", + "columnName": "recordId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "mediaFileId", + "columnName": "mediaFileId", + "affinity": "INTEGER" + }, + { + "fieldPath": "enqueuedAtMs", + "columnName": "enqueuedAtMs", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "loginId", + "columnName": "loginId", + "affinity": "TEXT" + }, + { + "fieldPath": "origin", + "columnName": "origin", + "affinity": "TEXT" + }, + { + "fieldPath": "deviceId", + "columnName": "deviceId", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "serverId", + "profileId", + "recordId" + ] + } + }, + { + "tableName": "download_subscriptions", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `serverId` TEXT NOT NULL, `profileId` TEXT NOT NULL, `targetType` TEXT NOT NULL, `targetId` TEXT NOT NULL, `displayTitle` TEXT NOT NULL, `mediaKind` TEXT NOT NULL, `quality` TEXT NOT NULL, `wifiOnly` INTEGER NOT NULL, `enabled` INTEGER NOT NULL, `includeExisting` INTEGER NOT NULL, `keepUnwatchedLimit` INTEGER NOT NULL, `deleteWatchedAfterDays` INTEGER NOT NULL, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, `lastEvaluatedAt` INTEGER, `lastError` TEXT, PRIMARY KEY(`serverId`, `profileId`, `id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "serverId", + "columnName": "serverId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "profileId", + "columnName": "profileId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "targetType", + "columnName": "targetType", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "targetId", + "columnName": "targetId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "displayTitle", + "columnName": "displayTitle", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "mediaKind", + "columnName": "mediaKind", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "quality", + "columnName": "quality", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "wifiOnly", + "columnName": "wifiOnly", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "enabled", + "columnName": "enabled", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "includeExisting", + "columnName": "includeExisting", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "keepUnwatchedLimit", + "columnName": "keepUnwatchedLimit", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "deleteWatchedAfterDays", + "columnName": "deleteWatchedAfterDays", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastEvaluatedAt", + "columnName": "lastEvaluatedAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "lastError", + "columnName": "lastError", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "serverId", + "profileId", + "id" + ] + }, + "indices": [ + { + "name": "index_download_subscriptions_serverId_profileId_enabled", + "unique": false, + "columnNames": [ + "serverId", + "profileId", + "enabled" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_download_subscriptions_serverId_profileId_enabled` ON `${TABLE_NAME}` (`serverId`, `profileId`, `enabled`)" + }, + { + "name": "index_download_subscriptions_serverId_profileId_targetType_targetId", + "unique": true, + "columnNames": [ + "serverId", + "profileId", + "targetType", + "targetId" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_download_subscriptions_serverId_profileId_targetType_targetId` ON `${TABLE_NAME}` (`serverId`, `profileId`, `targetType`, `targetId`)" + } + ] + }, + { + "tableName": "membership_projection", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`authority` TEXT NOT NULL, `itemId` TEXT NOT NULL, `kind` TEXT NOT NULL, `commandId` INTEGER NOT NULL, `present` INTEGER NOT NULL, `disposition` TEXT, PRIMARY KEY(`authority`, `itemId`, `kind`))", + "fields": [ + { + "fieldPath": "authority", + "columnName": "authority", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "itemId", + "columnName": "itemId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "kind", + "columnName": "kind", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "commandId", + "columnName": "commandId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "present", + "columnName": "present", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "disposition", + "columnName": "disposition", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "authority", + "itemId", + "kind" + ] + } + }, + { + "tableName": "legacy_membership_quarantine", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`commandId` INTEGER NOT NULL, `originalState` TEXT NOT NULL, PRIMARY KEY(`commandId`))", + "fields": [ + { + "fieldPath": "commandId", + "columnName": "commandId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "originalState", + "columnName": "originalState", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "commandId" + ] + } + } + ], + "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, '8a348d04acd6cd7fe41b6ee065fb65cb')" + ] + } +} \ No newline at end of file diff --git a/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/data/db/SiloDatabase.kt b/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/data/db/SiloDatabase.kt index fc61993f6..f690dc1d8 100644 --- a/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/data/db/SiloDatabase.kt +++ b/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/data/db/SiloDatabase.kt @@ -56,7 +56,7 @@ import org.siloserver.silo.common.data.db.entity.UserItemStateEntity MembershipProjectionEntity::class, LegacyMembershipQuarantineEntity::class, ], - version = 11, + version = 12, exportSchema = true, autoMigrations = [ AutoMigration(from = 1, to = 2), @@ -68,6 +68,7 @@ import org.siloserver.silo.common.data.db.entity.UserItemStateEntity AutoMigration(from = 7, to = 8), AutoMigration(from = 8, to = 9), AutoMigration(from = 10, to = 11), + AutoMigration(from = 11, to = 12), ], ) abstract class SiloDatabase : RoomDatabase() { diff --git a/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/data/db/entity/DownloadEntity.kt b/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/data/db/entity/DownloadEntity.kt index 1da0341a1..4ce7c95f4 100644 --- a/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/data/db/entity/DownloadEntity.kt +++ b/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/data/db/entity/DownloadEntity.kt @@ -79,4 +79,7 @@ data class DownloadEntity( /** Quality the server actually delivered after any compatibility fallback; * may differ from [quality]. Same offline-fallback reason as above. */ val effectiveQuality: String? = null, + /** Registry revision whose bytes this row describes. A local completion + * only speaks for this revision; null on rows written before v12. */ + val revision: Int? = null, ) diff --git a/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/downloads/DownloadEnqueuer.kt b/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/downloads/DownloadEnqueuer.kt index 521ebf3f2..52a2a570d 100644 --- a/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/downloads/DownloadEnqueuer.kt +++ b/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/downloads/DownloadEnqueuer.kt @@ -444,6 +444,7 @@ class DownloadEnqueuer( deviceId = deviceId, loginId = authority?.loginId, origin = authority?.scope?.serverUrl, + revision = record.revision, ) } if (authority == null) persistAndEnqueue() diff --git a/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/downloads/DownloadSidecarMapping.kt b/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/downloads/DownloadSidecarMapping.kt index 316e2beb1..77478cbf6 100644 --- a/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/downloads/DownloadSidecarMapping.kt +++ b/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/downloads/DownloadSidecarMapping.kt @@ -57,6 +57,7 @@ fun DownloadSidecar.toEntity(serverId: String, profileId: String): DownloadEntit updatedAtMs = updatedAtMs, quality = record.quality, effectiveQuality = record.effectiveQuality, + revision = record.revision, ) fun DownloadEntity.toSidecar(): DownloadSidecar = @@ -75,6 +76,7 @@ fun DownloadEntity.toSidecar(): DownloadSidecar = completedAt = completedAt, quality = quality, effectiveQuality = effectiveQuality, + revision = revision, ), title = title, subtitle = subtitle, diff --git a/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/downloads/DownloadStatusWorker.kt b/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/downloads/DownloadStatusWorker.kt new file mode 100644 index 000000000..b87c601c7 --- /dev/null +++ b/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/downloads/DownloadStatusWorker.kt @@ -0,0 +1,169 @@ +package org.siloserver.silo.common.downloads + +import android.content.Context +import android.util.Log +import androidx.work.BackoffPolicy +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 androidx.work.workDataOf +import org.siloserver.silo.model.download.DownloadRecord +import org.siloserver.silo.model.download.DownloadStatusEvent +import org.siloserver.silo.network.ApiResult +import org.siloserver.silo.network.DeviceMetadataProvider +import org.siloserver.silo.network.DurableLoginAuthorityProvider +import org.siloserver.silo.repository.DownloadsRepository +import java.util.concurrent.TimeUnit + +/** + * Tells the server's managed registry what this device holds for one download + * (`PATCH /api/v2/downloads/{id}`: `downloading` when bytes start, `completed` + * once the file is published). The file route never marks completion itself, + * so without this report the server row stays `ready`. + * + * Runs as its own network-constrained unique job per download, separate from + * the transfer, so an event recorded offline or cut short by process death is + * sent once the device is back online. A newer event replaces a pending older + * one; a retry resends the same event, which the server acknowledges without + * changing the entry. + */ +class DownloadStatusWorker( + appContext: Context, + params: WorkerParameters, + private val repository: DownloadsRepository, + private val authorities: DurableLoginAuthorityProvider, + private val devices: DeviceMetadataProvider, +) : CoroutineWorker(appContext, params) { + + override suspend fun doWork(): Result { + val downloadId = inputData.getString(KEY_DOWNLOAD_ID) ?: return Result.failure() + val event = DownloadStatusEvent( + status = inputData.getString(KEY_STATUS) ?: return Result.failure(), + updatedAt = inputData.getString(KEY_UPDATED_AT) ?: return Result.failure(), + revision = inputData.getInt(KEY_REVISION, 0), + ) + val serverId = inputData.getString(KEY_SERVER_ID) ?: return Result.failure() + val profileId = inputData.getString(KEY_PROFILE_ID) ?: return Result.failure() + if (runAttemptCount >= MAX_ATTEMPTS) { + Log.w(TAG, "status report gave up id=$downloadId status=${event.status} after $runAttemptCount attempts") + return Result.failure() + } + // Only the owner the download ran for can report it. Another login or + // profile may be active for now; wait for it to come back. + val authority = authorities.snapshotDurableLoginAuthority() ?: return Result.retry() + if (!downloadWorkMatchesOwner(inputData.getString(KEY_LOGIN_ID), inputData.getString(KEY_ORIGIN), + inputData.getString(KEY_DEVICE_ID), serverId, profileId, authority, devices.current()?.id)) return Result.retry() + + val result = repository.reportStatus(downloadId, event, authority) + return when (downloadStatusReportOutcome(result)) { + DownloadStatusReportOutcome.Settled -> { + Log.i(TAG, "status report settled id=$downloadId status=${event.status} answer=${result.describe()}") + Result.success() + } + DownloadStatusReportOutcome.Reconcile -> { + // The entry's revision moved on, so this event describes replaced + // bytes. Drop it and read the registry instead. + Log.i(TAG, "status report superseded id=$downloadId revision=${event.revision}") + repository.refresh() + Result.success() + } + DownloadStatusReportOutcome.RetryLater -> { + Log.i(TAG, "status report retry id=$downloadId status=${event.status} answer=${result.describe()}") + Result.retry() + } + } + } + + private fun ApiResult<*>.describe(): String = when (this) { + is ApiResult.Success -> "ok" + is ApiResult.Error -> "$code ${error.ifBlank { message }}" + is ApiResult.NetworkError -> "network" + } + + companion object { + private const val TAG = "DownloadStatusWorker" + const val KEY_DOWNLOAD_ID = "download_id" + const val KEY_STATUS = "status" + const val KEY_UPDATED_AT = "updated_at" + const val KEY_REVISION = "revision" + const val KEY_SERVER_ID = "server_id" + const val KEY_PROFILE_ID = "profile_id" + const val KEY_DEVICE_ID = "device_id" + const val KEY_LOGIN_ID = "login_id" + const val KEY_ORIGIN = "origin" + + /** Exponential from [BACKOFF_SECONDS] (WorkManager caps each wait at + * 5 hours), so this spans a few days before the report is dropped. */ + private const val MAX_ATTEMPTS = 20 + private const val BACKOFF_SECONDS = 30L + + fun uniqueName(downloadId: String): String = "download_status_$downloadId" + + /** Queue [event] for [downloadId], replacing a report still waiting to go out. */ + fun enqueue( + context: Context, + downloadId: String, + event: DownloadStatusEvent, + serverId: String, + profileId: String, + deviceId: String?, + loginId: String?, + origin: String?, + ) { + val request = OneTimeWorkRequestBuilder() + .setInputData( + workDataOf( + KEY_DOWNLOAD_ID to downloadId, + KEY_STATUS to event.status, + KEY_UPDATED_AT to event.updatedAt, + KEY_REVISION to event.revision, + KEY_SERVER_ID to serverId, + KEY_PROFILE_ID to profileId, + KEY_DEVICE_ID to deviceId, + KEY_LOGIN_ID to loginId, + KEY_ORIGIN to origin, + ), + ) + .setConstraints(Constraints.Builder().setRequiredNetworkType(NetworkType.CONNECTED).build()) + .setBackoffCriteria(BackoffPolicy.EXPONENTIAL, BACKOFF_SECONDS, TimeUnit.SECONDS) + .build() + Log.i(TAG, "enqueue id=$downloadId status=${event.status} revision=${event.revision}") + WorkManager.getInstance(context) + .enqueueUniqueWork(uniqueName(downloadId), ExistingWorkPolicy.REPLACE, request) + } + + /** Drop a report still waiting to go out: the transfer it describes was + * cancelled or failed, so the server row must not move to `downloading`. */ + fun cancel(context: Context, downloadId: String) { + WorkManager.getInstance(context).cancelUniqueWork(uniqueName(downloadId)) + } + } +} + +internal enum class DownloadStatusReportOutcome { Settled, Reconcile, RetryLater } + +/** + * What the server's answer means for a queued status event. + * + * - `409`: the revision changed; the event describes replaced bytes. + * - `400`/`422`: most often the event time is ahead of the server's clock + * (future times are rejected), which a later retry of the same event fixes. + * - `404`/`403` and other client errors: the entry is gone, not reportable + * (preparing, failed, revoked), or downloads are off; resending cannot help. + * - Profile PIN verification, auth, throttling, server errors, network loss and identity changes are transient. + */ +internal fun downloadStatusReportOutcome(result: ApiResult): DownloadStatusReportOutcome = when (result) { + is ApiResult.Success -> DownloadStatusReportOutcome.Settled + is ApiResult.NetworkError -> DownloadStatusReportOutcome.RetryLater + is ApiResult.Error -> when (result.code) { + 409 -> DownloadStatusReportOutcome.Reconcile + 403 -> if (result.error == "profile_verification_required") DownloadStatusReportOutcome.RetryLater else DownloadStatusReportOutcome.Settled + 0, 400, 401, 408, 422, 429 -> DownloadStatusReportOutcome.RetryLater + in 500..599 -> DownloadStatusReportOutcome.RetryLater + else -> DownloadStatusReportOutcome.Settled + } +} diff --git a/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/downloads/DownloadWorker.kt b/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/downloads/DownloadWorker.kt index e83f3309d..c909952cd 100644 --- a/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/downloads/DownloadWorker.kt +++ b/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/downloads/DownloadWorker.kt @@ -21,6 +21,8 @@ import androidx.work.WorkManager import androidx.work.workDataOf import org.siloserver.silo.model.download.DownloadStatus import org.siloserver.silo.model.download.DownloadRecord +import org.siloserver.silo.model.download.DownloadStatusEvent +import org.siloserver.silo.model.download.statusEnum import org.siloserver.silo.repository.DownloadsRepository import org.siloserver.silo.network.* import org.siloserver.silo.network.apiv2.managedDownloadAuth @@ -43,6 +45,7 @@ import kotlinx.coroutines.NonCancellable import kotlinx.coroutines.withContext import java.io.IOException import java.net.URLDecoder +import java.time.Instant import java.util.concurrent.TimeUnit internal fun DownloadRecord.withWorkerStatus( @@ -141,20 +144,51 @@ class DownloadWorker( }.onFailure { Log.w(TAG, "setForeground initial failed", it) } var activeUri: String? = null - - // Resume state (survives process death + WorkManager retries): the partial's - // uri + the validator captured at download start. Resume offset is the REAL - // on-disk size (fd stat), never the metadata SIZE column (stale while pending). - val existing = runCatching { metadataStore.readSidecar(serverId, profileId, fileId) }.getOrNull() - val resumeUri = existing?.localUri - val resumeFrom = resumeUri?.let { storage.partialSize(it) } ?: 0L - val resumeValidator = existing?.resumeValidator - // Resume ONLY with a validator: an unvalidated Range append would silently - // corrupt the file if the source changed (the server can't tell us). No - // validator → behave as a fresh download (no Range header). - val canResume = resumeFrom > 0 && resumeUri != null && !resumeValidator.isNullOrBlank() - try { + var existing = runCatching { metadataStore.readSidecar(serverId, profileId, fileId) }.getOrNull() + // Old WorkManager jobs have no revision input. Resolve it as their owner + // and save it in Room so another attempt does not depend on a UI cache. + val revision = inputData.getInt(KEY_REVISION, 0).takeIf { it > 0 } + ?: existing?.record?.takeIf { it.id == downloadId }?.revision?.takeIf { it > 0 } + ?: when (val result = repository.resolveTransferRecord(downloadId, fileId, transferAuthority)) { + is ApiResult.Success -> { + val row = result.data + val sidecar = existing?.takeIf { it.record.id == downloadId } + ?: return@withContext Result.failure() + // Unversioned partial bytes cannot prove which registry target + // they came from. Start fresh before adopting this revision. + val restarted = sidecar.copy( + record = row.withWorkerStatus( + if (row.statusEnum() == DownloadStatus.Preparing) row.status else DownloadStatus.Ready.wire, + bytesSent = 0, + ), + localUri = null, + resumeValidator = null, + ) + ownedWrite { + storage.delete(serverId, profileId, fileId) + metadataStore.writeSidecar(serverId, profileId, restarted) + true + } + existing = restarted + checkNotNull(row.revision) + } + is ApiResult.NetworkError -> return@withContext Result.retry() + is ApiResult.Error -> return@withContext if (downloadStatusReportOutcome(result) == DownloadStatusReportOutcome.RetryLater) Result.retry() else Result.failure() + } + var transferStartedAtMs = 0L + // Resume state (survives process death + WorkManager retries): the partial's + // uri + the validator captured at download start. Resume offset is the REAL + // on-disk size (fd stat), never the metadata SIZE column (stale while pending). + val resumeUri = existing?.localUri + val resumeFrom = resumeUri?.let { storage.partialSize(it) } ?: 0L + val resumeValidator = existing?.resumeValidator + // Resume ONLY with a validator: an unvalidated Range append would silently + // corrupt the file if the source changed (the server can't tell us). No + // validator → behave as a fresh download (no Range header). + val canResume = existing?.record?.let { it.id == downloadId && it.revision == revision } == true && + resumeFrom > 0 && resumeUri != null && !resumeValidator.isNullOrBlank() + requireOwner() httpClient.prepareGet("/api/v2/downloads/${downloadId.encodeURLPathPart()}/file") { transferAuthority?.let { managedDownloadAuth(it.scope) } @@ -226,6 +260,9 @@ class DownloadWorker( out = fresh.openOutputStream() } + transferStartedAtMs = System.currentTimeMillis() + reportStatus(downloadId, DownloadStatus.Downloading, transferStartedAtMs, revision, serverId, profileId) + val channel = response.bodyAsChannel() val throttle = DownloadProgressThrottle() @@ -277,9 +314,6 @@ class DownloadWorker( } } - // Server flips status → completed when its serve handler returns; - // a refresh here ensures the cache reflects that before the - // worker exits and the UI re-renders. val pendingUri = activeUri ?: error("download target was not created") val finalBytes = storage.partialSize(pendingUri) // Publish the file and flip the sidecar to completed under one @@ -297,14 +331,30 @@ class DownloadWorker( fileSize = finalBytes, localUri = finalUri, resumeValidator = "", + revision = revision, ) true } Log.i(TAG, "doWork success id=$downloadId bytes=$finalBytes") DiagnosticsDownloadLogger.event("download completed") - // Server flips status → completed when its serve handler returns; - // refresh so the cache reflects that before the UI re-renders. - repository.refresh() + // Strictly after the `downloading` event: the server ignores an event + // that is not newer than the one it holds. + val completedAtMs = maxOf(System.currentTimeMillis(), transferStartedAtMs + 1) + // Only while the cached row still describes the bytes just fetched: a + // refresh may already hold a replacement revision, which stays as-is. + if (uiPushAllowed(serverId, profileId)) { + repository.recordForFile(fileId) + ?.takeIf { it.id == downloadId && (it.revision == null || it.revision == revision) } + ?.let { existing -> + repository.upsertLocal( + existing.withWorkerStatus(DownloadStatus.Completed.wire, bytesSent = finalBytes, fileSize = finalBytes) + .copy(completedAt = existing.completedAt ?: Instant.ofEpochMilli(completedAtMs).toString(), + revision = revision), + ) + } + } + // The v2 file route never marks the entry completed; this report does. + reportStatus(downloadId, DownloadStatus.Completed, completedAtMs, revision, serverId, profileId) Result.success(workDataOf(KEY_BYTES_WRITTEN to finalBytes, KEY_TOTAL_BYTES to finalBytes)) } catch (e: DownloadOwnerChanged) { // Preserve the original owner's partial; a new login cannot resume it. @@ -322,6 +372,9 @@ class DownloadWorker( Log.i(TAG, "doWork cancelled id=$downloadId") DiagnosticsDownloadLogger.event("download cancelled") withContext(NonCancellable) { + // WorkManager's notification action cancels this transfer directly. + // Its separate status job must stop before we remove the bytes. + runCatching { DownloadStatusWorker.cancel(appContext, downloadId) } // Delete by scope+fileId (not just activeUri): a cancel before the // response is classified leaves activeUri null but a prior attempt's // partial may still be on disk. @@ -373,6 +426,30 @@ class DownloadWorker( } } + /** + * Queue a status event for the server registry ([DownloadStatusWorker]). + * Skipped without a captured owner or a known revision: the server would + * refuse the event, and there is no owner to send it as. + */ + private fun reportStatus(downloadId: String, status: DownloadStatus, atMs: Long, revision: Int?, serverId: String, profileId: String) { + if (transferAuthority == null || revision == null) { + Log.w(TAG, "status report skipped id=$downloadId status=${status.wire} revision=$revision") + return + } + runCatching { + DownloadStatusWorker.enqueue( + context = appContext, + downloadId = downloadId, + event = DownloadStatusEvent(status.wire, Instant.ofEpochMilli(atMs).toString(), revision), + serverId = serverId, + profileId = profileId, + deviceId = inputData.getString(KEY_DEVICE_ID), + loginId = inputData.getString(KEY_LOGIN_ID), + origin = inputData.getString(KEY_ORIGIN), + ) + }.onFailure { Log.w(TAG, "status report enqueue failed id=$downloadId status=${status.wire}", it) } + } + /** Permanent failure — clean up local file and let the user retry manually. */ private suspend fun failPermanently( e: Throwable, @@ -387,6 +464,7 @@ class DownloadWorker( // Delete by scope+fileId so a partial from any attempt is cleaned up // even if this attempt failed before activeUri was assigned. runCatching { ownedWrite { storage.delete(serverId, profileId, fileId); true } } + runCatching { DownloadStatusWorker.cancel(appContext, downloadId) } // Best-effort: publish failed state into the repo + sidecar. val record = if (uiPushAllowed(serverId, profileId)) repository.recordForFile(fileId) else null if (record != null) { @@ -440,6 +518,7 @@ class DownloadWorker( localUri: String? = null, fileName: String? = null, resumeValidator: String? = null, + revision: Int? = null, ) { val existing = metadataStore.readSidecar(serverId, profileId, fileId) ?: return metadataStore.writeSidecar( @@ -449,7 +528,7 @@ class DownloadWorker( status = status, bytesSent = bytesSent, fileSize = fileSize, - ), + ).copy(revision = revision ?: existing.record.revision), localUri = localUri ?: existing.localUri, fileName = fileName?.takeIf { it.isNotBlank() } ?: existing.fileName, resumeValidator = when { @@ -522,6 +601,7 @@ class DownloadWorker( const val KEY_CONTAINER = "container" const val KEY_MEDIA_TYPE = "media_type" const val KEY_DISPLAY_TITLE = "display_title" + const val KEY_REVISION = "revision" const val KEY_BYTES_WRITTEN = "bytes" const val KEY_TOTAL_BYTES = "total" @@ -567,8 +647,10 @@ class DownloadWorker( deviceId: String? = null, loginId: String? = null, origin: String? = null, + revision: Int? = null, ) { val data = workDataOf( + KEY_REVISION to (revision ?: 0), KEY_DEVICE_ID to deviceId, KEY_LOGIN_ID to loginId, KEY_ORIGIN to origin, @@ -602,6 +684,7 @@ class DownloadWorker( fun cancel(context: Context, downloadId: String) { DownloadWorkerLifetime.beginCancellation(downloadId) WorkManager.getInstance(context).cancelAllWorkByTag(tagFor(downloadId)) + DownloadStatusWorker.cancel(context, downloadId) } /** @@ -611,6 +694,7 @@ class DownloadWorker( */ suspend fun cancelAndAwait(context: Context, downloadId: String) { DownloadWorkerLifetime.beginCancellation(downloadId) + DownloadStatusWorker.cancel(context, downloadId) withContext(Dispatchers.IO) { WorkManager.getInstance(context) .cancelUniqueWork(tagFor(downloadId)) diff --git a/android-shared/src/androidUnitTest/kotlin/org/siloserver/silo/common/data/db/SiloDatabaseMigrationTest.kt b/android-shared/src/androidUnitTest/kotlin/org/siloserver/silo/common/data/db/SiloDatabaseMigrationTest.kt index 7e269e7f2..6f4ffcdca 100644 --- a/android-shared/src/androidUnitTest/kotlin/org/siloserver/silo/common/data/db/SiloDatabaseMigrationTest.kt +++ b/android-shared/src/androidUnitTest/kotlin/org/siloserver/silo/common/data/db/SiloDatabaseMigrationTest.kt @@ -100,6 +100,26 @@ class SiloDatabaseMigrationTest { } } + @Test + fun migration11To12KeepsDownloadsWithAnUnknownRevision() { + val name = "migration-11-to-12" + migrationHelper.createDatabase(name, 11).use { database -> + database.execSQL( + "INSERT INTO downloads (serverId, profileId, mediaFileId, recordId, contentId, title, mediaType, status, kind, " + + "fileSize, bytesSent, createdAt, updatedAtMs) VALUES ('s', 'p', 42, 'row', 'movie', 'Movie', 'movie', " + + "'completed', 'queued', 1024, 1024, '2026-09-29T00:00:00Z', 123)", + ) + } + migrationHelper.runMigrationsAndValidate(name, 12, true).use { database -> + database.query("SELECT recordId, status, revision FROM downloads").use { cursor -> + assertEquals(true, cursor.moveToFirst()) + assertEquals("row", cursor.getString(0)); assertEquals("completed", cursor.getString(1)) + assertEquals(true, cursor.isNull(2)) + assertEquals(false, cursor.moveToNext()) + } + } + } + private companion object { const val DATABASE_NAME = "migration-7-to-8" } diff --git a/android-shared/src/androidUnitTest/kotlin/org/siloserver/silo/common/downloads/DownloadStatusReportOutcomeTest.kt b/android-shared/src/androidUnitTest/kotlin/org/siloserver/silo/common/downloads/DownloadStatusReportOutcomeTest.kt new file mode 100644 index 000000000..78e40d036 --- /dev/null +++ b/android-shared/src/androidUnitTest/kotlin/org/siloserver/silo/common/downloads/DownloadStatusReportOutcomeTest.kt @@ -0,0 +1,49 @@ +package org.siloserver.silo.common.downloads + +import java.io.IOException +import kotlin.test.Test +import kotlin.test.assertEquals +import org.siloserver.silo.model.download.DownloadRecord +import org.siloserver.silo.network.ApiResult + +class DownloadStatusReportOutcomeTest { + private val row = DownloadRecord(id = "dl", contentId = "movie", mediaFileId = 42, kind = "queued", + status = "completed", createdAt = "2026-09-29T00:00:00Z", revision = 1) + + private fun error(code: Int) = ApiResult.Error(code, "", "") + + @Test + fun `an accepted event is settled`() { + assertEquals(DownloadStatusReportOutcome.Settled, downloadStatusReportOutcome(ApiResult.Success(row))) + } + + @Test + fun `a moved revision reconciles instead of resending`() { + assertEquals(DownloadStatusReportOutcome.Reconcile, downloadStatusReportOutcome(error(409))) + } + + @Test + fun `offline and transient answers keep the event for later`() { + assertEquals(DownloadStatusReportOutcome.RetryLater, downloadStatusReportOutcome(ApiResult.NetworkError(IOException("offline")))) + // A device clock ahead of the server's makes the event "future" until time catches up. + for (code in listOf(0, 400, 401, 408, 422, 429, 500, 502, 503)) { + assertEquals(DownloadStatusReportOutcome.RetryLater, downloadStatusReportOutcome(error(code)), "HTTP $code") + } + } + + @Test + fun `answers a resend cannot change are settled`() { + for (code in listOf(403, 404, 410)) { + assertEquals(DownloadStatusReportOutcome.Settled, downloadStatusReportOutcome(error(code)), "HTTP $code") + } + } + + @Test + fun `a locked profile keeps its report until PIN verification succeeds`() { + val locked = ApiResult.Error(403, "profile_verification_required", "Verify the active profile.") + assertEquals(DownloadStatusReportOutcome.RetryLater, downloadStatusReportOutcome(locked)) + assertEquals(DownloadStatusReportOutcome.Settled, downloadStatusReportOutcome( + ApiResult.Error(403, "permission_denied", "Downloads are disabled."), + )) + } +} diff --git a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/di/AndroidModule.kt b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/di/AndroidModule.kt index 1f4b53f7c..8df2f18c3 100644 --- a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/di/AndroidModule.kt +++ b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/di/AndroidModule.kt @@ -333,6 +333,15 @@ val androidModule = module { gate = get(), ) } + worker { + org.siloserver.silo.common.downloads.DownloadStatusWorker( + appContext = androidContext(), + params = get(), + repository = get(), + authorities = get(), + devices = get(), + ) + } worker { DownloadSubscriptionWorker( appContext = androidContext(), diff --git a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/downloads/AppWorkerFactory.kt b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/downloads/AppWorkerFactory.kt index 3796a2de6..489c87525 100644 --- a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/downloads/AppWorkerFactory.kt +++ b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/downloads/AppWorkerFactory.kt @@ -10,6 +10,7 @@ import org.siloserver.silo.common.data.sync.SyncWorker import org.siloserver.silo.common.downloads.DownloadStorage import org.siloserver.silo.common.downloads.DownloadSubscriptionEvaluatorFactory import org.siloserver.silo.common.downloads.DownloadSubscriptionWorker +import org.siloserver.silo.common.downloads.DownloadStatusWorker import org.siloserver.silo.common.downloads.DownloadWorker import org.siloserver.silo.common.diagnostics.DiagnosticsCoordinator import org.siloserver.silo.common.diagnostics.DiagnosticsUploadWorker @@ -65,6 +66,16 @@ class AppWorkerFactory : WorkerFactory() { }, ) } + DownloadStatusWorker::class.java.name -> { + Log.i(TAG, "Building DownloadStatusWorker via Koin") + DownloadStatusWorker( + appContext = appContext, + params = workerParameters, + repository = koin.get(), + authorities = koin.get(), + devices = koin.get(), + ) + } DownloadSubscriptionWorker::class.java.name -> { Log.i(TAG, "Building DownloadSubscriptionWorker via Koin") DownloadSubscriptionWorker( diff --git a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/downloads/DownloadsViewModel.kt b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/downloads/DownloadsViewModel.kt index c55f7e335..d10c875f3 100644 --- a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/downloads/DownloadsViewModel.kt +++ b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/downloads/DownloadsViewModel.kt @@ -328,10 +328,20 @@ class DownloadsViewModel( // contentId + MOVIES + "missing file" whenever the join missed). // Live byte-progress/status is overlaid from the in-memory server // records for in-flight items. Reload sidecars when a record the - // map doesn't know about appears (newly enqueued). + // map doesn't know about appears (newly enqueued), or when a + // download finished: the worker writes the completed sidecar + // (status + final file URI) before publishing the record, and + // the local completion is what the row's Ready/Play state reads. val (sections, bytesUsed) = withContext(Dispatchers.IO) { - if (records.any { it.id !in metadataByRecordId }) { + val unseenCompletions = records.filter { sidecarMissesCompletion(it) }.map { it.id } + if (unseenCompletions.isNotEmpty() || records.any { it.id !in metadataByRecordId }) { reloadSidecarMetadata() + // Reload once per disagreement: a row the server holds as + // completed while it downloads again locally must not + // re-read Room on every progress tick. + completionReloadIds = (completionReloadIds + unseenCompletions).filterTo(mutableSetOf()) { + metadataByRecordId[it]?.record?.statusEnum() != DownloadStatus.Completed + } } val sects = buildSections(records.associateBy { it.id }) sects to sects.sumOf { it.totalBytesUsed } @@ -635,6 +645,13 @@ class DownloadsViewModel( } } + /** Completed records whose sidecar still disagreed after a reload. */ + private var completionReloadIds: Set = emptySet() + + private fun sidecarMissesCompletion(record: DownloadRecord): Boolean = + record.statusEnum() == DownloadStatus.Completed && record.id !in completionReloadIds && + metadataByRecordId[record.id]?.record?.statusEnum()?.let { it != DownloadStatus.Completed } == true + private suspend fun activeDownloadScope(): Pair { val serverId = serverRegistry.activeServerId.value ?: DownloadEnqueuer.DEFAULT_SERVER_ID val profileId = withContext(Dispatchers.IO) { diff --git a/shared/src/commonMain/kotlin/org/siloserver/silo/model/download/DownloadModels.kt b/shared/src/commonMain/kotlin/org/siloserver/silo/model/download/DownloadModels.kt index 4ed9d14c0..8d31b4dab 100644 --- a/shared/src/commonMain/kotlin/org/siloserver/silo/model/download/DownloadModels.kt +++ b/shared/src/commonMain/kotlin/org/siloserver/silo/model/download/DownloadModels.kt @@ -167,6 +167,17 @@ enum class DownloadStatus(val wire: String) { } } +/** + * One local status report for one registry revision (`PATCH /api/v2/downloads/{id}`). + * [status] is `downloading` or `completed`; [updatedAt] is the RFC 3339 time the + * local state changed, not the send time. A retry must resend all three values. + */ +data class DownloadStatusEvent( + val status: String, + val updatedAt: String, + val revision: Int, +) + /** * `direct` = browser one-shot serve (no persistent server record). * `queued` = tracked record visible in the user's downloads list. diff --git a/shared/src/commonMain/kotlin/org/siloserver/silo/network/api/DownloadsApi.kt b/shared/src/commonMain/kotlin/org/siloserver/silo/network/api/DownloadsApi.kt index e7031b1e0..05410d506 100644 --- a/shared/src/commonMain/kotlin/org/siloserver/silo/network/api/DownloadsApi.kt +++ b/shared/src/commonMain/kotlin/org/siloserver/silo/network/api/DownloadsApi.kt @@ -3,6 +3,7 @@ package org.siloserver.silo.network.api import org.siloserver.silo.model.download.DownloadCapability import org.siloserver.silo.model.download.DownloadRecord import org.siloserver.silo.model.download.DownloadRequest +import org.siloserver.silo.model.download.DownloadStatusEvent import org.siloserver.silo.model.download.DownloadsListResponse import org.siloserver.silo.network.ApiResult import org.siloserver.silo.network.AuthScopeSnapshot @@ -34,6 +35,9 @@ open class DownloadsApi( open suspend fun delete(id: String, scope: AuthScopeSnapshot?): ApiResult = if (scope == null) changed() else registry.delete(id, scope) + open suspend fun reportStatus(id: String, event: DownloadStatusEvent, scope: AuthScopeSnapshot?): ApiResult = + if (scope == null) changed() else registry.reportStatus(id, event, scope) + /** * Feature detection (issue #20 §3). Call at detail load / profile switch; * the picker offers only `quality_presets` and hides bitrate presets when diff --git a/shared/src/commonMain/kotlin/org/siloserver/silo/network/apiv2/DownloadRegistryV2Api.kt b/shared/src/commonMain/kotlin/org/siloserver/silo/network/apiv2/DownloadRegistryV2Api.kt index 2938bbcb4..217f1c65a 100644 --- a/shared/src/commonMain/kotlin/org/siloserver/silo/network/apiv2/DownloadRegistryV2Api.kt +++ b/shared/src/commonMain/kotlin/org/siloserver/silo/network/apiv2/DownloadRegistryV2Api.kt @@ -5,6 +5,8 @@ import io.ktor.client.request.* import io.ktor.http.* import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.put import org.siloserver.silo.model.download.* import org.siloserver.silo.network.* @@ -48,7 +50,9 @@ class DownloadRegistryV2Api(private val client: HttpClient, private val tokens: client.request(path) { this.method = method; authScope(owner!!); requireSiloAuth() // The existing auth plugin attaches the installation's device metadata. - if (method != HttpMethod.Get) singleAttempt() + // A status event retains its revision and timestamp, so replaying + // PATCH after an auth refresh is safe. DELETE remains one attempt. + if (method == HttpMethod.Delete) singleAttempt() configure() } }) { it } @@ -93,4 +97,29 @@ class DownloadRegistryV2Api(private val client: HttpClient, private val tokens: val device = devices.current()?.id ?: return identityChanged() return exchange(scope, device, HttpMethod.Delete, "/api/v2/downloads/${id.encodeURLPathPart()}") } + + /** + * `PATCH /downloads/{id}`: one revision-bound local status event. [updatedAt] is when the + * local state changed, not when this is sent; a retry resends the same event, which the + * server acknowledges without changing the entry. A moved revision answers `409`. + */ + suspend fun reportStatus(id: String, event: DownloadStatusEvent, scope: AuthScopeSnapshot): ApiResult { + val device = devices.current()?.id?.takeIf { it.isNotBlank() } ?: return identityChanged() + if (id.isBlank() || event.revision < 1 || event.updatedAt.isBlank() || + event.status != DownloadStatus.Downloading.wire && event.status != DownloadStatus.Completed.wire) return invalid() + val result = exchange(scope, device, HttpMethod.Patch, "/api/v2/downloads/${id.encodeURLPathPart()}") { + contentType(ContentType.Application.Json) + setBody(buildJsonObject { + put("status", event.status); put("updated_at", event.updatedAt); put("revision", event.revision) + }) + } + return when (result) { + is ApiResult.Success -> { + val row = try { result.data.project(device) } catch (_: IllegalArgumentException) { return invalid() } + if (row.id == id) ApiResult.Success(row) else invalid() + } + is ApiResult.Error -> result + is ApiResult.NetworkError -> result + } + } } diff --git a/shared/src/commonMain/kotlin/org/siloserver/silo/repository/DownloadsRepository.kt b/shared/src/commonMain/kotlin/org/siloserver/silo/repository/DownloadsRepository.kt index aac1b195d..2ea57f5ea 100644 --- a/shared/src/commonMain/kotlin/org/siloserver/silo/repository/DownloadsRepository.kt +++ b/shared/src/commonMain/kotlin/org/siloserver/silo/repository/DownloadsRepository.kt @@ -3,10 +3,14 @@ package org.siloserver.silo.repository import org.siloserver.silo.model.download.DownloadCapability import org.siloserver.silo.model.download.DownloadRecord import org.siloserver.silo.model.download.DownloadRequest +import org.siloserver.silo.model.download.DownloadStatus +import org.siloserver.silo.model.download.DownloadStatusEvent +import org.siloserver.silo.model.download.statusEnum import org.siloserver.silo.network.ApiResult import org.siloserver.silo.network.api.DownloadsApi import org.siloserver.silo.repository.port.DownloadDeletionPort import org.siloserver.silo.repository.port.NoOpDownloadDeletionPort +import org.siloserver.silo.util.parseRfc3339ToEpochMillis import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow @@ -21,9 +25,12 @@ import kotlinx.coroutines.flow.update * **Disk-as-truth (v1.1).** The Android side seeds this cache from the * on-disk sidecar files at startup via [seedFromSidecars]. [refresh] then * *merges* the server view into that cache instead of overwriting it — - * server-known records win on conflict (fresher status), but records that - * only exist on disk (server cleaned them up while we were offline, or we - * marked them [pendingDelete] but haven't synced yet) stay visible. + * server-known records win on conflict (fresher status), except that a + * locally completed download is never downgraded to the server's `ready` / + * `downloading` for the same revision before its completion report lands + * ([keepLocalCompletion]). + * Records that only exist on disk (server cleaned them up while we were + * offline, or we marked them [pendingDelete] but haven't synced yet) stay visible. * * **Two-phase delete.** The server has a quirky DELETE: for active records * (queued / downloading) it only flips status to cancelled rather than @@ -137,7 +144,11 @@ class DownloadsRepository( if (!localWrite(authority) { val hidden = pendingDelete + durable val serverList = result.data.downloads.filterNot { it.id in hidden } - _records.update { current -> serverList + current.filter { it.id !in serverIds && it.id !in hidden && it.id in keepIdsAbsentFromServer } } + _records.update { current -> + val local = current.associateBy { it.id } + serverList.map { keepLocalCompletion(it, local[it.id]) } + + current.filter { it.id !in serverIds && it.id !in hidden && it.id in keepIdsAbsentFromServer } + } }) return changed() if (serverId != null && profileId != null) { val deviceId = devices?.current()?.id @@ -174,6 +185,60 @@ class DownloadsRepository( } + /** Read the revision for an older queued transfer without relying on the UI cache. */ + suspend fun resolveTransferRecord( + id: String, + fileId: Int, + expectedAuthority: org.siloserver.silo.network.DurableLoginAuthority? = null, + ): ApiResult { + val authority = expectedAuthority ?: authorities?.snapshotDurableLoginAuthority() + if (!current(authority)) return changed() + val result = api.list(authority?.scope) + if (!current(authority)) return changed() + return when (result) { + is ApiResult.Success -> { + val row = result.data.downloads.singleOrNull { it.id == id } + ?: return ApiResult.Error(404, "download_not_found", "The queued download no longer exists.") + if (row.mediaFileId != fileId) return ApiResult.Error(409, "download_target_changed", "The queued download now targets another file.") + if (row.revision == null || row.revision < 1) return ApiResult.Error(0, "invalid_download_registry", "The download revision is unavailable.") + ApiResult.Success(row) + } + is ApiResult.Error -> result + is ApiResult.NetworkError -> result + } + } + + /** + * Reports one local status event under [expectedAuthority] (the owner the + * download ran for) and records the server's acknowledgement in the cache. + * The result is returned as-is so the caller can tell a moved revision (409) + * from a transient failure. + */ + suspend fun reportStatus( + id: String, + event: DownloadStatusEvent, + expectedAuthority: org.siloserver.silo.network.DurableLoginAuthority? = null, + ): ApiResult { + val authority = expectedAuthority ?: authorities?.snapshotDurableLoginAuthority() + if (!current(authority)) return changed() + val result = api.reportStatus(id, event, authority?.scope) + if (result is ApiResult.Success && !localWrite(authority) { + // The worker publishes local status itself, and an answer can land + // after the transfer moved on (completed, or failed and cleaned up), + // or after a refresh installed a replacement revision. Only the + // acknowledgement is taken from it, and only for the same revision. + val row = result.data + _records.update { list -> + list.map { + if (it.id != id || (it.revision != null && it.revision != row.revision)) it + else it.copy(completedAt = row.completedAt ?: it.completedAt, revision = row.revision, + statusEventAt = latestInstant(it.statusEventAt, row.statusEventAt)) + } + } + }) return changed() + return result + } + /** * Series-batch creation (one POST → N records sharing a batchId). All * returned records are upserted so the Downloads tab + per-row UI @@ -297,6 +362,32 @@ class DownloadsRepository( } } + /** + * A download this device finished stays completed while the server still + * reports it as `ready`/`downloading`: the completion report may not have + * reached it yet (offline, or still queued). The completion only speaks for + * the bytes it fetched, so the server row must be the same revision. A local + * row without a stored revision (saved before it was kept) proves nothing, + * and a replaced revision makes the server row win. + */ + internal fun keepLocalCompletion(server: DownloadRecord, local: DownloadRecord?): DownloadRecord { + if (local == null || local.statusEnum() != DownloadStatus.Completed) return server + if (server.statusEnum() != DownloadStatus.Ready && server.statusEnum() != DownloadStatus.Downloading) return server + if (local.revision == null || local.revision != server.revision) return server + return server.copy(status = local.status, bytesSent = local.bytesSent, completedAt = server.completedAt ?: local.completedAt) + } + + /** Compare event times at the client's millisecond precision, regardless of timestamp shape. */ + private fun latestInstant(a: String?, b: String?): String? { + val aMillis = a?.let(::parseRfc3339ToEpochMillis) + val bMillis = b?.let(::parseRfc3339ToEpochMillis) + return when { + bMillis == null -> a + aMillis == null || bMillis > aMillis -> b + else -> a + } + } + /** Lookup helper for callers (item detail) that key off `FileVersion.fileId`. */ fun recordForFile(fileId: Int): DownloadRecord? = _records.value.firstOrNull { it.mediaFileId == fileId } diff --git a/shared/src/commonTest/kotlin/org/siloserver/silo/network/apiv2/DownloadRegistryV2Test.kt b/shared/src/commonTest/kotlin/org/siloserver/silo/network/apiv2/DownloadRegistryV2Test.kt index 5f7a65b62..334a45452 100644 --- a/shared/src/commonTest/kotlin/org/siloserver/silo/network/apiv2/DownloadRegistryV2Test.kt +++ b/shared/src/commonTest/kotlin/org/siloserver/silo/network/apiv2/DownloadRegistryV2Test.kt @@ -81,6 +81,92 @@ class DownloadRegistryV2Test { } finally { c.close() } } + @Test fun reportStatusPatchesTheRevisionBoundEvent() = runTest { + val event = DownloadStatusEvent("completed", "2026-01-02T03:04:05.000Z", 1) + var answer: MockRequestHandleScope.() -> io.ktor.client.request.HttpResponseData = { + reply(row.replace("\"ready\"", "\"completed\"").replace("\"revision\":1", "\"revision\":1,\"status_event_at\":\"2026-01-02T03:04:05.000Z\"")) + } + var sent = 0 + val c = client { + sent++ + assertEquals(HttpMethod.Patch, it.method) + assertEquals("/api/v2/downloads/one", it.url.encodedPath) + assertEquals(scope, it.attributes[AuthScopeAttributeKey]) + val body = SiloJson.parseToJsonElement((it.body as io.ktor.http.content.TextContent).text) + assertEquals(SiloJson.parseToJsonElement("""{"status":"completed","updated_at":"2026-01-02T03:04:05.000Z","revision":1}"""), body) + answer() + } + try { + val api = DownloadRegistryV2Api(c,tokens,devices, ApiV2Gate.Unrestricted) + val answered = assertIs>(api.reportStatus("one", event, scope)).data + assertEquals("completed", answered.status); assertEquals("2026-01-02T03:04:05.000Z", answered.statusEventAt) + + answer = { respond("""{"code":"conflict","detail":"The download revision changed."}""", HttpStatusCode.Conflict, headersOf(HttpHeaders.ContentType, "application/problem+json")) } + assertEquals(409, assertIs(api.reportStatus("one", event, scope)).code) + + // An answer for another entry is not this event's receipt. + answer = { reply(row.replace("\"one\"", "\"two\"")) } + assertIs(api.reportStatus("one", event, scope)) + + // Events the server would refuse are never sent. + val before = sent + assertIs(api.reportStatus("one", event.copy(revision = 0), scope)) + assertIs(api.reportStatus("one", event.copy(status = "ready"), scope)) + assertEquals(before, sent) + } finally { c.close() } + } + + @Test fun delayedStatusRefreshesItsOwnerAndResendsTheRetainedEvent() = runTest { + var access = "expired-access" + var refreshedOwner: AuthScopeSnapshot? = null + val authenticated = object : TokenManager by TokenManagerImpl() { + override suspend fun snapshotCurrentScope() = scope + override suspend fun getAccessTokenForScope(scope: AuthScopeSnapshot) = access + override suspend fun getRefreshTokenForScope(scope: AuthScopeSnapshot) = "owner-refresh" + override suspend fun saveTokensForScope(scope: AuthScopeSnapshot, accessToken: String, refreshToken: String, expiresIn: Long) { + refreshedOwner = scope + access = accessToken + } + override suspend fun invalidateSession() = fail("A background report must retain its owner's session.") + } + val bodies = mutableListOf() + val bearers = mutableListOf() + var refreshes = 0 + val c = HttpClient(MockEngine { request -> + assertEquals("example.invalid", request.url.host) + when (request.url.encodedPath) { + "/api/v2/auth/refresh" -> { + refreshes++ + reply("""{"access_token":"rotated-access","refresh_token":"rotated-refresh","expires_in":900}""") + } + "/api/v2/downloads/one" -> { + assertEquals(HttpMethod.Patch, request.method) + assertEquals(scope, request.attributes[AuthScopeAttributeKey]) + bodies += request.body.toByteArray().decodeToString() + bearers += request.headers[HttpHeaders.Authorization] + if (bearers.size == 1) respond("""{"code":"invalid_token","detail":"Expired."}""", + HttpStatusCode.Unauthorized, headersOf(HttpHeaders.ContentType, "application/problem+json")) + else reply(row.replace("\"ready\"", "\"completed\"")) + } + else -> fail("Unexpected request ${request.url}") + } + }) { + install(ContentNegotiation) { json(SiloJson) } + install(SiloAuthPlugin) { tokenManager = authenticated; deviceMetadataProvider = devices } + } + try { + val event = DownloadStatusEvent("completed", "2026-01-02T03:04:05.000Z", 1) + assertIs>( + DownloadRegistryV2Api(c, authenticated, devices, ApiV2Gate.Unrestricted).reportStatus("one", event, scope), + ) + assertEquals(1, refreshes) + assertEquals(scope, refreshedOwner) + assertEquals>(listOf("Bearer expired-access", "Bearer rotated-access"), bearers) + assertEquals(2, bodies.size) + assertEquals(bodies[0], bodies[1]) + } finally { c.close() } + } + @Test fun capabilityRequiresVersionedStateAndFailsClosed() = runTest { var body = """{"revision":"rev","state":"future","enabled":true,"download_allowed":true}""" val c = client { reply(body) } diff --git a/shared/src/commonTest/kotlin/org/siloserver/silo/repository/DownloadsRepositoryCompletionTest.kt b/shared/src/commonTest/kotlin/org/siloserver/silo/repository/DownloadsRepositoryCompletionTest.kt new file mode 100644 index 000000000..04edd6fee --- /dev/null +++ b/shared/src/commonTest/kotlin/org/siloserver/silo/repository/DownloadsRepositoryCompletionTest.kt @@ -0,0 +1,287 @@ +package org.siloserver.silo.repository + +import io.ktor.client.HttpClient +import kotlinx.coroutines.test.runTest +import org.siloserver.silo.model.download.DownloadRecord +import org.siloserver.silo.model.download.DownloadStatusEvent +import org.siloserver.silo.model.download.DownloadsListResponse +import org.siloserver.silo.network.ApiResult +import org.siloserver.silo.network.AuthScopeSnapshot +import org.siloserver.silo.network.DeviceMetadataProvider +import org.siloserver.silo.network.DurableLoginAuthority +import org.siloserver.silo.network.DurableLoginAuthorityProvider +import org.siloserver.silo.network.SiloDeviceMetadata +import org.siloserver.silo.network.TokenManagerImpl +import org.siloserver.silo.network.api.DownloadsApi +import org.siloserver.silo.network.apiv2.ApiV2Gate +import org.siloserver.silo.network.apiv2.DownloadCreationV2Api +import org.siloserver.silo.network.apiv2.DownloadRegistryV2Api +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertIs + +private object CompletionTestNoDevices : DeviceMetadataProvider { + override suspend fun current(): SiloDeviceMetadata? = null +} + +private fun registryStub() = DownloadRegistryV2Api(HttpClient(), TokenManagerImpl(), CompletionTestNoDevices, ApiV2Gate.Unrestricted) + +private class RegistryFake : DownloadsApi( + registry = registryStub(), + tokens = TokenManagerImpl(), + creation = DownloadCreationV2Api(HttpClient(), TokenManagerImpl(), CompletionTestNoDevices, registryStub(), ApiV2Gate.Unrestricted), +) { + var server: List = emptyList() + var onList: (() -> Unit)? = null + val listedScopes = mutableListOf() + var reportAnswer: (DownloadStatusEvent) -> ApiResult = { ApiResult.NetworkError(IllegalStateException("offline")) } + val reports = mutableListOf>() + + override suspend fun list(scope: AuthScopeSnapshot?): ApiResult { + listedScopes += scope + onList?.invoke() + return ApiResult.Success(DownloadsListResponse(server)) + } + + override suspend fun reportStatus(id: String, event: DownloadStatusEvent, scope: AuthScopeSnapshot?): ApiResult { + reports += id to event + return reportAnswer(event) + } +} + +private fun entry(status: String, revision: Int? = 1, bytes: Long = 0, completedAt: String? = null, quality: String? = "original") = DownloadRecord( + id = "dl", contentId = "movie", mediaFileId = 42, fileSize = 1000, bytesSent = bytes, kind = "queued", + status = status, createdAt = "2026-09-29T00:00:00Z", completedAt = completedAt, revision = revision, + quality = quality, effectiveQuality = quality, +) + +class DownloadsRepositoryCompletionTest { + + @Test + fun `an old queued job resolves its revision with an empty UI cache`() = runTest { + val owner = DurableLoginAuthority("login", AuthScopeSnapshot("server", "profile", "https://example.invalid", "proof")) + val authorities = object : DurableLoginAuthorityProvider { override suspend fun snapshotDurableLoginAuthority() = owner } + val api = RegistryFake().apply { server = listOf(entry("ready", revision = 7)) } + val repo = DownloadsRepository(api, authorities = authorities) + + val row = assertIs>(repo.resolveTransferRecord("dl", 42, owner)).data + + assertEquals(7, row.revision) + assertEquals(listOf(owner.scope), api.listedScopes) + assertEquals(emptyList(), repo.records.value) + } + + @Test + fun `an old job cannot resolve a replacement targeting another file`() = runTest { + val api = RegistryFake().apply { server = listOf(entry("ready", revision = 2).copy(mediaFileId = 99)) } + assertEquals(409, assertIs(DownloadsRepository(api).resolveTransferRecord("dl", 42)).code) + assertEquals(404, assertIs(DownloadsRepository(api).resolveTransferRecord("gone", 42)).code) + } + + @Test + fun `an old job cannot use a revision returned after its owner changed`() = runTest { + val owner = DurableLoginAuthority("login", AuthScopeSnapshot("server", "profile", "https://example.invalid", null)) + var active = owner + val authorities = object : DurableLoginAuthorityProvider { override suspend fun snapshotDurableLoginAuthority() = active } + val api = RegistryFake().apply { + server = listOf(entry("ready")) + onList = { active = owner.copy(loginId = "other-login") } + } + val repo = DownloadsRepository(api, authorities = authorities) + + assertEquals("identity_changed", assertIs(repo.resolveTransferRecord("dl", 42, owner)).error) + assertEquals(emptyList(), repo.records.value) + } + + @Test + fun `refresh keeps a local completion while the server still says ready`() = runTest { + val api = RegistryFake().apply { server = listOf(entry("ready")) } + val repo = DownloadsRepository(api) + repo.refresh() + // The worker publishes the finished file before the server hears about it. + repo.upsertLocal(entry("completed", bytes = 1000, completedAt = "2026-09-29T00:01:00Z")) + + repo.refresh() + + val row = repo.records.value.single() + assertEquals("completed", row.status) + assertEquals(1000, row.bytesSent) + assertEquals("2026-09-29T00:01:00Z", row.completedAt) + } + + @Test + fun `refresh keeps a completion seeded from local metadata with the same revision`() = runTest { + val api = RegistryFake().apply { server = listOf(entry("downloading")) } + val repo = DownloadsRepository(api) + repo.seedFromSidecars(listOf(entry("completed", bytes = 1000))) + + repo.refresh() + + assertEquals("completed", repo.records.value.single().status) + } + + @Test + fun `a replaced server revision wins over the old local completion`() = runTest { + val api = RegistryFake().apply { server = listOf(entry("ready", revision = 2)) } + val repo = DownloadsRepository(api) + repo.upsertLocal(entry("completed", revision = 1, bytes = 1000)) + + repo.refresh() + + val row = repo.records.value.single() + assertEquals("ready", row.status) + assertEquals(2, row.revision) + } + + @Test + fun `a completion saved without a revision does not cover any server revision`() = runTest { + // Same file and quality: a failed row re-created at revision 2 keeps its target. + val api = RegistryFake().apply { server = listOf(entry("ready", revision = 2)) } + val repo = DownloadsRepository(api) + repo.seedFromSidecars(listOf(entry("completed", revision = null, bytes = 1000))) + + repo.refresh() + + assertEquals("ready", repo.records.value.single().status) + } + + @Test + fun `server failure states are not masked by a local completion`() = runTest { + val api = RegistryFake().apply { server = listOf(entry("failed")) } + val repo = DownloadsRepository(api) + repo.upsertLocal(entry("completed", bytes = 1000)) + + repo.refresh() + + assertEquals("failed", repo.records.value.single().status) + } + + @Test + fun `a completion report records the server acknowledgement and keeps local state`() = runTest { + val api = RegistryFake() + val repo = DownloadsRepository(api) + repo.upsertLocal(entry("completed", bytes = 1000)) + val event = DownloadStatusEvent("completed", "2026-09-29T00:01:00.000Z", 1) + api.reportAnswer = { entry("completed", completedAt = "2026-09-29T00:01:00Z").copy(statusEventAt = it.updatedAt).let { ApiResult.Success(it) } } + + assertIs>(repo.reportStatus("dl", event)) + + assertEquals(listOf("dl" to event), api.reports) + val row = repo.records.value.single() + assertEquals("completed", row.status) + assertEquals(1000, row.bytesSent) + assertEquals("2026-09-29T00:01:00Z", row.completedAt) + assertEquals("2026-09-29T00:01:00.000Z", row.statusEventAt) + } + + @Test + fun `a late downloading answer does not undo a local completion`() = runTest { + val api = RegistryFake() + val repo = DownloadsRepository(api) + repo.upsertLocal(entry("completed", bytes = 1000)) + api.reportAnswer = { ApiResult.Success(entry("downloading")) } + + repo.reportStatus("dl", DownloadStatusEvent("downloading", "2026-09-29T00:00:30.000Z", 1)) + + assertEquals("completed", repo.records.value.single().status) + } + + @Test + fun `a late downloading answer does not revive a failed transfer`() = runTest { + val api = RegistryFake() + val repo = DownloadsRepository(api) + // The worker failed permanently and cleaned up after the report was sent. + repo.upsertLocal(entry("failed")) + api.reportAnswer = { ApiResult.Success(entry("downloading").copy(statusEventAt = it.updatedAt)) } + + repo.reportStatus("dl", DownloadStatusEvent("downloading", "2026-09-29T00:00:30.000Z", 1)) + + val row = repo.records.value.single() + assertEquals("failed", row.status) + assertEquals("2026-09-29T00:00:30.000Z", row.statusEventAt) + } + + @Test + fun `an acknowledgement for a superseded revision leaves the replacement alone`() = runTest { + val api = RegistryFake().apply { server = listOf(entry("ready", revision = 2, quality = "2mbps")) } + val repo = DownloadsRepository(api) + repo.refresh() + api.reportAnswer = { ApiResult.Success(entry("completed", completedAt = "2026-09-29T00:01:00Z").copy(statusEventAt = it.updatedAt)) } + + repo.reportStatus("dl", DownloadStatusEvent("completed", "2026-09-29T00:01:00.000Z", 1)) + + assertEquals(entry("ready", revision = 2, quality = "2mbps"), repo.records.value.single()) + } + + @Test + fun `an older acknowledgement keeps the newer status event time`() = runTest { + val api = RegistryFake() + val repo = DownloadsRepository(api) + repo.upsertLocal(entry("completed", bytes = 1000).copy(statusEventAt = "2026-09-29T00:01:00.000Z")) + api.reportAnswer = { ApiResult.Success(entry("downloading").copy(statusEventAt = it.updatedAt)) } + + repo.reportStatus("dl", DownloadStatusEvent("downloading", "2026-09-29T00:00:30.000Z", 1)) + + assertEquals("2026-09-29T00:01:00.000Z", repo.records.value.single().statusEventAt) + } + + @Test + fun `acknowledgements compare whole and fractional seconds chronologically`() = runTest { + val whole = "2026-09-29T00:01:00Z" + val fractional = "2026-09-29T00:01:00.500Z" + for ((cached, acknowledged) in listOf(whole to fractional, fractional to whole)) { + val api = RegistryFake() + val repo = DownloadsRepository(api) + repo.upsertLocal(entry("completed", bytes = 1000).copy(statusEventAt = cached)) + api.reportAnswer = { ApiResult.Success(entry("completed").copy(statusEventAt = acknowledged)) } + + repo.reportStatus("dl", DownloadStatusEvent("completed", acknowledged, 1)) + + assertEquals(fractional, repo.records.value.single().statusEventAt) + } + } + + @Test + fun `acknowledgements compare instants across numeric offsets`() = runTest { + val older = "2026-09-29T01:01:00+01:00" + val newer = "2026-09-29T00:01:00.500Z" + for ((cached, acknowledged) in listOf(older to newer, newer to older)) { + val api = RegistryFake() + val repo = DownloadsRepository(api) + repo.upsertLocal(entry("completed", bytes = 1000).copy(statusEventAt = cached)) + api.reportAnswer = { ApiResult.Success(entry("completed").copy(statusEventAt = acknowledged)) } + + repo.reportStatus("dl", DownloadStatusEvent("completed", acknowledged, 1)) + + assertEquals(newer, repo.records.value.single().statusEventAt) + } + } + + @Test + fun `an invalid timestamp cannot replace a valid acknowledgement`() = runTest { + val valid = "2026-09-29T00:01:00.500Z" + for ((cached, acknowledged) in listOf("invalid" to valid, valid to "invalid")) { + val api = RegistryFake() + val repo = DownloadsRepository(api) + repo.upsertLocal(entry("completed", bytes = 1000).copy(statusEventAt = cached)) + api.reportAnswer = { ApiResult.Success(entry("completed").copy(statusEventAt = acknowledged)) } + + repo.reportStatus("dl", DownloadStatusEvent("completed", valid, 1)) + + assertEquals(valid, repo.records.value.single().statusEventAt) + } + } + + @Test + fun `a failed report leaves the cache untouched`() = runTest { + val api = RegistryFake() + val repo = DownloadsRepository(api) + repo.upsertLocal(entry("completed", bytes = 1000)) + api.reportAnswer = { ApiResult.Error(409, "conflict", "revision changed") } + + val result = repo.reportStatus("dl", DownloadStatusEvent("completed", "2026-09-29T00:01:00.000Z", 1)) + + assertEquals(409, assertIs(result).code) + assertEquals(entry("completed", bytes = 1000), repo.records.value.single()) + } +}