From d79f4b93d0ad710c43a4ce8931f419d5c51e5604 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=9A=D1=83=D1=80=D0=BD=D0=B0=D1=82=20=D0=90=D0=BD=D0=B4?= =?UTF-8?q?=D1=80=D0=B5=D0=B9?= Date: Wed, 10 Jun 2026 07:35:14 +0300 Subject: [PATCH] Store Android auto-upload cursors per source --- .../SettingsPictureUploadsViewModel.kt | 15 +- .../SettingsVideoUploadsViewModel.kt | 15 +- .../android/workers/AutomaticUploadsWorker.kt | 41 +- .../workers/AutomaticUploadsWorkerTest.kt | 42 + .../52.json | 1254 +++++++++++++++++ .../eu/qsfera/android/data/ProviderMeta.java | 2 +- .../eu/qsfera/android/data/QSferaDatabase.kt | 4 +- .../OCLocalFolderBackupDataSource.kt | 2 + .../folderbackup/db/FolderBackUpEntity.kt | 1 + .../android/data/migrations/Migration_34.kt | 2 + .../android/data/migrations/Migration_52.kt | 11 + .../model/FolderBackUpConfiguration.kt | 50 + .../model/FolderBackUpConfigurationTest.kt | 31 + .../testutil/OCFolderBackUpConfiguration.kt | 2 + PRODUCTION_READINESS.md | 2 +- 15 files changed, 1466 insertions(+), 8 deletions(-) create mode 100644 Android/qsferaData/schemas/eu.qsfera.android.data.QSferaDatabase/52.json create mode 100644 Android/qsferaData/src/main/java/eu/qsfera/android/data/migrations/Migration_52.kt diff --git a/Android/qsferaApp/src/main/java/eu/qsfera/android/presentation/settings/automaticuploads/SettingsPictureUploadsViewModel.kt b/Android/qsferaApp/src/main/java/eu/qsfera/android/presentation/settings/automaticuploads/SettingsPictureUploadsViewModel.kt index 27b9a171..12627c9b 100644 --- a/Android/qsferaApp/src/main/java/eu/qsfera/android/presentation/settings/automaticuploads/SettingsPictureUploadsViewModel.kt +++ b/Android/qsferaApp/src/main/java/eu/qsfera/android/presentation/settings/automaticuploads/SettingsPictureUploadsViewModel.kt @@ -30,6 +30,7 @@ import eu.qsfera.android.R import eu.qsfera.android.db.PreferenceManager.PREF__CAMERA_UPLOADS_DEFAULT_PATH import eu.qsfera.android.domain.automaticuploads.model.FolderBackUpConfiguration import eu.qsfera.android.domain.automaticuploads.model.FolderBackUpConfiguration.Companion.encodeSourcePaths +import eu.qsfera.android.domain.automaticuploads.model.FolderBackUpConfiguration.Companion.encodeSourcePathSyncTimestamps import eu.qsfera.android.domain.automaticuploads.model.FolderBackUpConfiguration.Companion.initialSyncTimestamp import eu.qsfera.android.domain.automaticuploads.model.FolderBackUpConfiguration.Companion.pictureUploadsName import eu.qsfera.android.domain.automaticuploads.model.UploadBehavior @@ -196,13 +197,22 @@ class SettingsPictureUploadsViewModel( val previousSourcePaths = getPictureUploadsSourcePaths() val newSourcePath = contentUriForTree.toString() val updatedSourcePaths = (previousSourcePaths + newSourcePath).distinct() + val previousSourceSyncTimestamps = _pictureUploads.value?.sourceSyncTimestamps.orEmpty() + val fallbackLastSyncTimestamp = _pictureUploads.value?.lastSyncTimestamp ?: initialSyncTimestamp + val updatedSourceSyncTimestamps = updatedSourcePaths.associateWith { sourcePath -> + previousSourceSyncTimestamps[sourcePath] ?: if (sourcePath in previousSourcePaths) { + fallbackLastSyncTimestamp + } else { + initialSyncTimestamp + } + } viewModelScope.launch(coroutinesDispatcherProvider.io) { savePictureUploadsConfigurationUseCase( SavePictureUploadsConfigurationUseCase.Params( composePictureUploadsConfiguration( sourcePath = encodeSourcePaths(updatedSourcePaths), - timestamp = initialSyncTimestamp.takeIf { updatedSourcePaths != previousSourcePaths } + sourcePathSyncTimestamps = encodeSourcePathSyncTimestamps(updatedSourceSyncTimestamps) ) ) ) @@ -215,6 +225,7 @@ class SettingsPictureUploadsViewModel( SavePictureUploadsConfigurationUseCase.Params( composePictureUploadsConfiguration( sourcePath = "", + sourcePathSyncTimestamps = "", timestamp = System.currentTimeMillis() ) ) @@ -237,6 +248,7 @@ class SettingsPictureUploadsViewModel( mobileDataBytesQueuedInPeriod: Long? = _pictureUploads.value?.mobileDataBytesQueuedInPeriod, chargingOnly: Boolean? = _pictureUploads.value?.chargingOnly, sourcePath: String? = _pictureUploads.value?.sourcePath, + sourcePathSyncTimestamps: String? = _pictureUploads.value?.sourcePathSyncTimestamps, behavior: UploadBehavior? = _pictureUploads.value?.behavior, timestamp: Long? = _pictureUploads.value?.lastSyncTimestamp, spaceId: String? = _pictureUploads.value?.spaceId, @@ -252,6 +264,7 @@ class SettingsPictureUploadsViewModel( mobileDataBytesQueuedInPeriod = mobileDataBytesQueuedInPeriod ?: 0, chargingOnly = chargingOnly == true, lastSyncTimestamp = timestamp ?: initialSyncTimestamp, + sourcePathSyncTimestamps = sourcePathSyncTimestamps.orEmpty(), name = _pictureUploads.value?.name ?: pictureUploadsName, spaceId = spaceId, ).also { diff --git a/Android/qsferaApp/src/main/java/eu/qsfera/android/presentation/settings/automaticuploads/SettingsVideoUploadsViewModel.kt b/Android/qsferaApp/src/main/java/eu/qsfera/android/presentation/settings/automaticuploads/SettingsVideoUploadsViewModel.kt index 8958622e..f2d63d9b 100644 --- a/Android/qsferaApp/src/main/java/eu/qsfera/android/presentation/settings/automaticuploads/SettingsVideoUploadsViewModel.kt +++ b/Android/qsferaApp/src/main/java/eu/qsfera/android/presentation/settings/automaticuploads/SettingsVideoUploadsViewModel.kt @@ -30,6 +30,7 @@ import eu.qsfera.android.R import eu.qsfera.android.db.PreferenceManager.PREF__CAMERA_UPLOADS_DEFAULT_PATH import eu.qsfera.android.domain.automaticuploads.model.FolderBackUpConfiguration import eu.qsfera.android.domain.automaticuploads.model.FolderBackUpConfiguration.Companion.encodeSourcePaths +import eu.qsfera.android.domain.automaticuploads.model.FolderBackUpConfiguration.Companion.encodeSourcePathSyncTimestamps import eu.qsfera.android.domain.automaticuploads.model.FolderBackUpConfiguration.Companion.initialSyncTimestamp import eu.qsfera.android.domain.automaticuploads.model.FolderBackUpConfiguration.Companion.videoUploadsName import eu.qsfera.android.domain.automaticuploads.model.UploadBehavior @@ -196,13 +197,22 @@ class SettingsVideoUploadsViewModel( val previousSourcePaths = getVideoUploadsSourcePaths() val newSourcePath = contentUriForTree.toString() val updatedSourcePaths = (previousSourcePaths + newSourcePath).distinct() + val previousSourceSyncTimestamps = _videoUploads.value?.sourceSyncTimestamps.orEmpty() + val fallbackLastSyncTimestamp = _videoUploads.value?.lastSyncTimestamp ?: initialSyncTimestamp + val updatedSourceSyncTimestamps = updatedSourcePaths.associateWith { sourcePath -> + previousSourceSyncTimestamps[sourcePath] ?: if (sourcePath in previousSourcePaths) { + fallbackLastSyncTimestamp + } else { + initialSyncTimestamp + } + } viewModelScope.launch(coroutinesDispatcherProvider.io) { saveVideoUploadsConfigurationUseCase( SaveVideoUploadsConfigurationUseCase.Params( composeVideoUploadsConfiguration( sourcePath = encodeSourcePaths(updatedSourcePaths), - timestamp = initialSyncTimestamp.takeIf { updatedSourcePaths != previousSourcePaths } + sourcePathSyncTimestamps = encodeSourcePathSyncTimestamps(updatedSourceSyncTimestamps) ) ) ) @@ -215,6 +225,7 @@ class SettingsVideoUploadsViewModel( SaveVideoUploadsConfigurationUseCase.Params( composeVideoUploadsConfiguration( sourcePath = "", + sourcePathSyncTimestamps = "", timestamp = System.currentTimeMillis() ) ) @@ -237,6 +248,7 @@ class SettingsVideoUploadsViewModel( mobileDataBytesQueuedInPeriod: Long? = _videoUploads.value?.mobileDataBytesQueuedInPeriod, chargingOnly: Boolean? = _videoUploads.value?.chargingOnly, sourcePath: String? = _videoUploads.value?.sourcePath, + sourcePathSyncTimestamps: String? = _videoUploads.value?.sourcePathSyncTimestamps, behavior: UploadBehavior? = _videoUploads.value?.behavior, timestamp: Long? = _videoUploads.value?.lastSyncTimestamp, spaceId: String? = _videoUploads.value?.spaceId, @@ -253,6 +265,7 @@ class SettingsVideoUploadsViewModel( mobileDataBytesQueuedInPeriod = mobileDataBytesQueuedInPeriod ?: 0, chargingOnly = chargingOnly == true, lastSyncTimestamp = timestamp ?: initialSyncTimestamp, + sourcePathSyncTimestamps = sourcePathSyncTimestamps.orEmpty(), name = _videoUploads.value?.name ?: videoUploadsName, spaceId = spaceId, ).also { diff --git a/Android/qsferaApp/src/main/java/eu/qsfera/android/workers/AutomaticUploadsWorker.kt b/Android/qsferaApp/src/main/java/eu/qsfera/android/workers/AutomaticUploadsWorker.kt index 9b753c95..ca99c26e 100644 --- a/Android/qsferaApp/src/main/java/eu/qsfera/android/workers/AutomaticUploadsWorker.kt +++ b/Android/qsferaApp/src/main/java/eu/qsfera/android/workers/AutomaticUploadsWorker.kt @@ -163,11 +163,14 @@ class AutomaticUploadsWorker( var mobileDataQuotaState = resolveMobileDataQuotaState(folderBackUpConfiguration, currentTimestamp) var mobileDataLimitReached = false - val localPicturesDocumentFiles: List = folderBackUpConfiguration.sourcePaths.flatMap { sourcePath -> + val sourcePaths = folderBackUpConfiguration.sourcePaths + val currentSourceSyncTimestamps = folderBackUpConfiguration.sourceSyncTimestamps + val localPicturesDocumentFiles: List = sourcePaths.flatMap { sourcePath -> + val lastSyncTimestamp = currentSourceSyncTimestamps[sourcePath] ?: folderBackUpConfiguration.lastSyncTimestamp getFilesReadyToUpload( syncType = syncType, sourcePath = sourcePath, - lastSyncTimestamp = folderBackUpConfiguration.lastSyncTimestamp, + lastSyncTimestamp = lastSyncTimestamp, currentTimestamp = currentTimestamp, ) } @@ -241,7 +244,20 @@ class AutomaticUploadsWorker( // so skipped files are re-evaluated after the quota period resets. val safeTimestamp = currentTimestamp - WRITE_SAFETY_BUFFER_MS val nextTimestamp = if (mobileDataLimitReached) folderBackUpConfiguration.lastSyncTimestamp else safeTimestamp - updateConfigurationAfterScan(folderBackUpConfiguration, syncType, nextTimestamp, mobileDataQuotaState) + val nextSourceSyncTimestamps = resolveSourceSyncTimestampsAfterScan( + sourcePaths = sourcePaths, + currentSourceSyncTimestamps = currentSourceSyncTimestamps, + fallbackLastSyncTimestamp = folderBackUpConfiguration.lastSyncTimestamp, + nextSyncTimestamp = nextTimestamp, + scanCompleted = !mobileDataLimitReached, + ) + updateConfigurationAfterScan( + folderBackUpConfiguration = folderBackUpConfiguration, + syncType = syncType, + nextSyncTimestamp = nextTimestamp, + nextSourceSyncTimestamps = nextSourceSyncTimestamps, + mobileDataQuotaState = mobileDataQuotaState + ) } private fun showNotification( @@ -294,10 +310,12 @@ class AutomaticUploadsWorker( folderBackUpConfiguration: FolderBackUpConfiguration, syncType: SyncType, nextSyncTimestamp: Long, + nextSourceSyncTimestamps: Map, mobileDataQuotaState: MobileDataQuotaState, ) { val updatedConfiguration = folderBackUpConfiguration.copy( lastSyncTimestamp = nextSyncTimestamp, + sourcePathSyncTimestamps = FolderBackUpConfiguration.encodeSourcePathSyncTimestamps(nextSourceSyncTimestamps), mobileDataPeriodStartTimestamp = mobileDataQuotaState.periodStartTimestamp, mobileDataBytesQueuedInPeriod = mobileDataQuotaState.bytesQueuedInPeriod ) @@ -459,5 +477,22 @@ class AutomaticUploadsWorker( ): Boolean = !shouldCountAgainstMobileDataLimit(wifiOnly, isActiveNetworkMetered, mobileDataLimitBytes) || fileSize <= mobileDataLimitBytes - mobileDataBytesQueuedInPeriod + + fun resolveSourceSyncTimestampsAfterScan( + sourcePaths: List, + currentSourceSyncTimestamps: Map, + fallbackLastSyncTimestamp: Long, + nextSyncTimestamp: Long, + scanCompleted: Boolean, + ): Map = + sourcePaths + .distinct() + .associateWith { sourcePath -> + if (scanCompleted) { + nextSyncTimestamp + } else { + currentSourceSyncTimestamps[sourcePath] ?: fallbackLastSyncTimestamp + } + } } } diff --git a/Android/qsferaApp/src/test/java/eu/qsfera/android/workers/AutomaticUploadsWorkerTest.kt b/Android/qsferaApp/src/test/java/eu/qsfera/android/workers/AutomaticUploadsWorkerTest.kt index b7048619..f07886ea 100644 --- a/Android/qsferaApp/src/test/java/eu/qsfera/android/workers/AutomaticUploadsWorkerTest.kt +++ b/Android/qsferaApp/src/test/java/eu/qsfera/android/workers/AutomaticUploadsWorkerTest.kt @@ -121,6 +121,47 @@ class AutomaticUploadsWorkerTest { ) } + @Test + fun `completed scan advances current source sync timestamps and drops removed sources`() { + val timestamps = AutomaticUploadsWorker.resolveSourceSyncTimestampsAfterScan( + sourcePaths = listOf("content://camera", "content://screenshots", "content://camera"), + currentSourceSyncTimestamps = mapOf( + "content://camera" to 100L, + "content://old-folder" to 200L, + ), + fallbackLastSyncTimestamp = 50L, + nextSyncTimestamp = 1_000L, + scanCompleted = true, + ) + + assertEquals( + mapOf( + "content://camera" to 1_000L, + "content://screenshots" to 1_000L, + ), + timestamps + ) + } + + @Test + fun `interrupted scan keeps source sync timestamps and falls back to global timestamp`() { + val timestamps = AutomaticUploadsWorker.resolveSourceSyncTimestampsAfterScan( + sourcePaths = listOf("content://camera", "content://screenshots"), + currentSourceSyncTimestamps = mapOf("content://camera" to 100L), + fallbackLastSyncTimestamp = 50L, + nextSyncTimestamp = 1_000L, + scanCompleted = false, + ) + + assertEquals( + mapOf( + "content://camera" to 100L, + "content://screenshots" to 50L, + ), + timestamps + ) + } + private fun backupConfiguration( mobileDataPeriodStartTimestamp: Long, mobileDataBytesQueuedInPeriod: Long, @@ -137,6 +178,7 @@ class AutomaticUploadsWorkerTest { mobileDataBytesQueuedInPeriod = mobileDataBytesQueuedInPeriod, chargingOnly = false, lastSyncTimestamp = 0L, + sourcePathSyncTimestamps = "", name = FolderBackUpConfiguration.pictureUploadsName, spaceId = null, ) diff --git a/Android/qsferaData/schemas/eu.qsfera.android.data.QSferaDatabase/52.json b/Android/qsferaData/schemas/eu.qsfera.android.data.QSferaDatabase/52.json new file mode 100644 index 00000000..80ae6719 --- /dev/null +++ b/Android/qsferaData/schemas/eu.qsfera.android.data.QSferaDatabase/52.json @@ -0,0 +1,1254 @@ +{ + "formatVersion": 1, + "database": { + "version": 52, + "identityHash": "588851784d53645ef76be75bb9593efa", + "entities": [ + { + "tableName": "app_registry", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`account_name` TEXT NOT NULL, `mime_type` TEXT NOT NULL, `ext` TEXT, `app_providers` TEXT NOT NULL, `name` TEXT, `icon` TEXT, `description` TEXT, `allow_creation` INTEGER, `default_application` TEXT, PRIMARY KEY(`account_name`, `mime_type`))", + "fields": [ + { + "fieldPath": "accountName", + "columnName": "account_name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "mimeType", + "columnName": "mime_type", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "ext", + "columnName": "ext", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "appProviders", + "columnName": "app_providers", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "icon", + "columnName": "icon", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "description", + "columnName": "description", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "allowCreation", + "columnName": "allow_creation", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "defaultApplication", + "columnName": "default_application", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "account_name", + "mime_type" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "folder_backup", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`accountName` TEXT NOT NULL, `behavior` TEXT NOT NULL, `sourcePath` TEXT NOT NULL, `uploadPath` TEXT NOT NULL, `wifiOnly` INTEGER NOT NULL, `allowRoaming` INTEGER NOT NULL, `mobileDataLimitBytes` INTEGER NOT NULL, `mobileDataPeriodStartTimestamp` INTEGER NOT NULL, `mobileDataBytesQueuedInPeriod` INTEGER NOT NULL, `chargingOnly` INTEGER NOT NULL, `name` TEXT NOT NULL, `lastSyncTimestamp` INTEGER NOT NULL, `sourcePathSyncTimestamps` TEXT NOT NULL, `spaceId` TEXT, `id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL)", + "fields": [ + { + "fieldPath": "accountName", + "columnName": "accountName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "behavior", + "columnName": "behavior", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sourcePath", + "columnName": "sourcePath", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "uploadPath", + "columnName": "uploadPath", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "wifiOnly", + "columnName": "wifiOnly", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "allowRoaming", + "columnName": "allowRoaming", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "mobileDataLimitBytes", + "columnName": "mobileDataLimitBytes", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "mobileDataPeriodStartTimestamp", + "columnName": "mobileDataPeriodStartTimestamp", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "mobileDataBytesQueuedInPeriod", + "columnName": "mobileDataBytesQueuedInPeriod", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "chargingOnly", + "columnName": "chargingOnly", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lastSyncTimestamp", + "columnName": "lastSyncTimestamp", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "sourcePathSyncTimestamps", + "columnName": "sourcePathSyncTimestamps", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "spaceId", + "columnName": "spaceId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "capabilities", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`account` TEXT, `version_major` INTEGER NOT NULL, `version_minor` INTEGER NOT NULL, `version_micro` INTEGER NOT NULL, `version_string` TEXT, `version_edition` TEXT, `core_pollinterval` INTEGER NOT NULL, `dav_chunking_version` TEXT NOT NULL, `sharing_api_enabled` INTEGER NOT NULL DEFAULT -1, `sharing_public_enabled` INTEGER NOT NULL DEFAULT -1, `sharing_public_password_enforced` INTEGER NOT NULL DEFAULT -1, `sharing_public_password_enforced_read_only` INTEGER NOT NULL DEFAULT -1, `sharing_public_password_enforced_read_write` INTEGER NOT NULL DEFAULT -1, `sharing_public_password_enforced_public_only` INTEGER NOT NULL DEFAULT -1, `sharing_public_expire_date_enabled` INTEGER NOT NULL DEFAULT -1, `sharing_public_expire_date_days` INTEGER NOT NULL, `sharing_public_expire_date_enforced` INTEGER NOT NULL DEFAULT -1, `sharing_public_upload` INTEGER NOT NULL DEFAULT -1, `sharing_public_multiple` INTEGER NOT NULL DEFAULT -1, `supports_upload_only` INTEGER NOT NULL DEFAULT -1, `sharing_resharing` INTEGER NOT NULL DEFAULT -1, `sharing_federation_outgoing` INTEGER NOT NULL DEFAULT -1, `sharing_federation_incoming` INTEGER NOT NULL DEFAULT -1, `sharing_user_profile_picture` INTEGER NOT NULL DEFAULT -1, `files_bigfilechunking` INTEGER NOT NULL DEFAULT -1, `files_undelete` INTEGER NOT NULL DEFAULT -1, `files_versioning` INTEGER NOT NULL DEFAULT -1, `files_private_links` INTEGER NOT NULL DEFAULT -1, `id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `app_providers_enabled` INTEGER, `app_providers_version` TEXT, `app_providers_appsUrl` TEXT, `app_providers_openUrl` TEXT, `app_providers_openWebUrl` TEXT, `app_providers_newUrl` TEXT, `tus_support_version` TEXT, `tus_support_resumable` TEXT, `tus_support_extension` TEXT, `tus_support_maxChunkSize` INTEGER, `tus_support_httpMethodOverride` TEXT, `spaces_enabled` INTEGER, `spaces_projects` INTEGER, `spaces_shareJail` INTEGER, `spaces_hasMultiplePersonalSpaces` INTEGER, `password_policy_maxCharacters` INTEGER, `password_policy_minCharacters` INTEGER, `password_policy_minDigits` INTEGER, `password_policy_minLowercaseCharacters` INTEGER, `password_policy_minSpecialCharacters` INTEGER, `password_policy_minUppercaseCharacters` INTEGER)", + "fields": [ + { + "fieldPath": "accountName", + "columnName": "account", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "versionMajor", + "columnName": "version_major", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "versionMinor", + "columnName": "version_minor", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "versionMicro", + "columnName": "version_micro", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "versionString", + "columnName": "version_string", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "versionEdition", + "columnName": "version_edition", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "corePollInterval", + "columnName": "core_pollinterval", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "davChunkingVersion", + "columnName": "dav_chunking_version", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "filesSharingApiEnabled", + "columnName": "sharing_api_enabled", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "-1" + }, + { + "fieldPath": "filesSharingPublicEnabled", + "columnName": "sharing_public_enabled", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "-1" + }, + { + "fieldPath": "filesSharingPublicPasswordEnforced", + "columnName": "sharing_public_password_enforced", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "-1" + }, + { + "fieldPath": "filesSharingPublicPasswordEnforcedReadOnly", + "columnName": "sharing_public_password_enforced_read_only", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "-1" + }, + { + "fieldPath": "filesSharingPublicPasswordEnforcedReadWrite", + "columnName": "sharing_public_password_enforced_read_write", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "-1" + }, + { + "fieldPath": "filesSharingPublicPasswordEnforcedUploadOnly", + "columnName": "sharing_public_password_enforced_public_only", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "-1" + }, + { + "fieldPath": "filesSharingPublicExpireDateEnabled", + "columnName": "sharing_public_expire_date_enabled", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "-1" + }, + { + "fieldPath": "filesSharingPublicExpireDateDays", + "columnName": "sharing_public_expire_date_days", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "filesSharingPublicExpireDateEnforced", + "columnName": "sharing_public_expire_date_enforced", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "-1" + }, + { + "fieldPath": "filesSharingPublicUpload", + "columnName": "sharing_public_upload", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "-1" + }, + { + "fieldPath": "filesSharingPublicMultiple", + "columnName": "sharing_public_multiple", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "-1" + }, + { + "fieldPath": "filesSharingPublicSupportsUploadOnly", + "columnName": "supports_upload_only", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "-1" + }, + { + "fieldPath": "filesSharingResharing", + "columnName": "sharing_resharing", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "-1" + }, + { + "fieldPath": "filesSharingFederationOutgoing", + "columnName": "sharing_federation_outgoing", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "-1" + }, + { + "fieldPath": "filesSharingFederationIncoming", + "columnName": "sharing_federation_incoming", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "-1" + }, + { + "fieldPath": "filesSharingUserProfilePicture", + "columnName": "sharing_user_profile_picture", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "-1" + }, + { + "fieldPath": "filesBigFileChunking", + "columnName": "files_bigfilechunking", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "-1" + }, + { + "fieldPath": "filesUndelete", + "columnName": "files_undelete", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "-1" + }, + { + "fieldPath": "filesVersioning", + "columnName": "files_versioning", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "-1" + }, + { + "fieldPath": "filesPrivateLinks", + "columnName": "files_private_links", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "-1" + }, + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "appProviders.enabled", + "columnName": "app_providers_enabled", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "appProviders.version", + "columnName": "app_providers_version", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "appProviders.appsUrl", + "columnName": "app_providers_appsUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "appProviders.openUrl", + "columnName": "app_providers_openUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "appProviders.openWebUrl", + "columnName": "app_providers_openWebUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "appProviders.newUrl", + "columnName": "app_providers_newUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "tusSupport.version", + "columnName": "tus_support_version", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "tusSupport.resumable", + "columnName": "tus_support_resumable", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "tusSupport.extension", + "columnName": "tus_support_extension", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "tusSupport.maxChunkSize", + "columnName": "tus_support_maxChunkSize", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "tusSupport.httpMethodOverride", + "columnName": "tus_support_httpMethodOverride", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "spaces.enabled", + "columnName": "spaces_enabled", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "spaces.projects", + "columnName": "spaces_projects", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "spaces.shareJail", + "columnName": "spaces_shareJail", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "spaces.hasMultiplePersonalSpaces", + "columnName": "spaces_hasMultiplePersonalSpaces", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "passwordPolicy.maxCharacters", + "columnName": "password_policy_maxCharacters", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "passwordPolicy.minCharacters", + "columnName": "password_policy_minCharacters", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "passwordPolicy.minDigits", + "columnName": "password_policy_minDigits", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "passwordPolicy.minLowercaseCharacters", + "columnName": "password_policy_minLowercaseCharacters", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "passwordPolicy.minSpecialCharacters", + "columnName": "password_policy_minSpecialCharacters", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "passwordPolicy.minUppercaseCharacters", + "columnName": "password_policy_minUppercaseCharacters", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "files", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`parentId` INTEGER, `owner` TEXT NOT NULL, `remotePath` TEXT NOT NULL, `remoteId` TEXT, `length` INTEGER NOT NULL, `creationTimestamp` INTEGER, `modificationTimestamp` INTEGER NOT NULL, `mimeType` TEXT NOT NULL, `etag` TEXT, `remoteEtag` TEXT, `permissions` TEXT, `privateLink` TEXT, `storagePath` TEXT, `name` TEXT, `treeEtag` TEXT, `keepInSync` INTEGER, `lastSyncDateForData` INTEGER, `lastUsage` INTEGER, `fileShareViaLink` INTEGER, `needsToUpdateThumbnail` INTEGER NOT NULL, `modifiedAtLastSyncForData` INTEGER, `etagInConflict` TEXT, `fileIsDownloading` INTEGER, `sharedWithSharee` INTEGER, `sharedByLink` INTEGER NOT NULL, `spaceId` TEXT, `id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, FOREIGN KEY(`owner`, `spaceId`) REFERENCES `spaces`(`account_name`, `space_id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "parentId", + "columnName": "parentId", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "owner", + "columnName": "owner", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "remotePath", + "columnName": "remotePath", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "remoteId", + "columnName": "remoteId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "length", + "columnName": "length", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "creationTimestamp", + "columnName": "creationTimestamp", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "modificationTimestamp", + "columnName": "modificationTimestamp", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "mimeType", + "columnName": "mimeType", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "etag", + "columnName": "etag", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "remoteEtag", + "columnName": "remoteEtag", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "permissions", + "columnName": "permissions", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "privateLink", + "columnName": "privateLink", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "storagePath", + "columnName": "storagePath", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "treeEtag", + "columnName": "treeEtag", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "availableOfflineStatus", + "columnName": "keepInSync", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "lastSyncDateForData", + "columnName": "lastSyncDateForData", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "lastUsage", + "columnName": "lastUsage", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "fileShareViaLink", + "columnName": "fileShareViaLink", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "needsToUpdateThumbnail", + "columnName": "needsToUpdateThumbnail", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "modifiedAtLastSyncForData", + "columnName": "modifiedAtLastSyncForData", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "etagInConflict", + "columnName": "etagInConflict", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "fileIsDownloading", + "columnName": "fileIsDownloading", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "sharedWithSharee", + "columnName": "sharedWithSharee", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "sharedByLink", + "columnName": "sharedByLink", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "spaceId", + "columnName": "spaceId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [ + { + "table": "spaces", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "owner", + "spaceId" + ], + "referencedColumns": [ + "account_name", + "space_id" + ] + } + ] + }, + { + "tableName": "files_sync", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`fileId` INTEGER NOT NULL, `uploadWorkerUuid` BLOB, `downloadWorkerUuid` BLOB, `isSynchronizing` INTEGER NOT NULL, PRIMARY KEY(`fileId`), FOREIGN KEY(`fileId`) REFERENCES `files`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "fileId", + "columnName": "fileId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "uploadWorkerUuid", + "columnName": "uploadWorkerUuid", + "affinity": "BLOB", + "notNull": false + }, + { + "fieldPath": "downloadWorkerUuid", + "columnName": "downloadWorkerUuid", + "affinity": "BLOB", + "notNull": false + }, + { + "fieldPath": "isSynchronizing", + "columnName": "isSynchronizing", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "fileId" + ] + }, + "indices": [], + "foreignKeys": [ + { + "table": "files", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "fileId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "ocshares", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`share_type` INTEGER NOT NULL, `share_with` TEXT, `path` TEXT NOT NULL, `permissions` INTEGER NOT NULL, `shared_date` INTEGER NOT NULL, `expiration_date` INTEGER NOT NULL, `token` TEXT, `shared_with_display_name` TEXT, `share_with_additional_info` TEXT, `is_directory` INTEGER NOT NULL, `id_remote_shared` TEXT NOT NULL, `owner_share` TEXT NOT NULL, `name` TEXT, `url` TEXT, `id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL)", + "fields": [ + { + "fieldPath": "shareType", + "columnName": "share_type", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "shareWith", + "columnName": "share_with", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "path", + "columnName": "path", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "permissions", + "columnName": "permissions", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "sharedDate", + "columnName": "shared_date", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "expirationDate", + "columnName": "expiration_date", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "token", + "columnName": "token", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "sharedWithDisplayName", + "columnName": "shared_with_display_name", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "sharedWithAdditionalInfo", + "columnName": "share_with_additional_info", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "isFolder", + "columnName": "is_directory", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "remoteId", + "columnName": "id_remote_shared", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "accountOwner", + "columnName": "owner_share", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "shareLink", + "columnName": "url", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "transfers", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`localPath` TEXT NOT NULL, `remotePath` TEXT NOT NULL, `accountName` TEXT NOT NULL, `fileSize` INTEGER NOT NULL, `status` INTEGER NOT NULL, `localBehaviour` INTEGER NOT NULL, `forceOverwrite` INTEGER NOT NULL, `transferEndTimestamp` INTEGER, `lastResult` INTEGER, `createdBy` INTEGER NOT NULL, `transferId` TEXT, `spaceId` TEXT, `sourcePath` TEXT, `tusUploadUrl` TEXT, `tusUploadLength` INTEGER, `tusUploadMetadata` TEXT, `tusUploadChecksum` TEXT, `tusResumableVersion` TEXT, `tusUploadExpires` INTEGER, `tusUploadConcat` TEXT, `id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL)", + "fields": [ + { + "fieldPath": "localPath", + "columnName": "localPath", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "remotePath", + "columnName": "remotePath", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "accountName", + "columnName": "accountName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "fileSize", + "columnName": "fileSize", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "status", + "columnName": "status", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "localBehaviour", + "columnName": "localBehaviour", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "forceOverwrite", + "columnName": "forceOverwrite", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "transferEndTimestamp", + "columnName": "transferEndTimestamp", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "lastResult", + "columnName": "lastResult", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "createdBy", + "columnName": "createdBy", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "transferId", + "columnName": "transferId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "spaceId", + "columnName": "spaceId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "sourcePath", + "columnName": "sourcePath", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "tusUploadUrl", + "columnName": "tusUploadUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "tusUploadLength", + "columnName": "tusUploadLength", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "tusUploadMetadata", + "columnName": "tusUploadMetadata", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "tusUploadChecksum", + "columnName": "tusUploadChecksum", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "tusResumableVersion", + "columnName": "tusResumableVersion", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "tusUploadExpires", + "columnName": "tusUploadExpires", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "tusUploadConcat", + "columnName": "tusUploadConcat", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "spaces", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`account_name` TEXT NOT NULL, `drive_alias` TEXT, `drive_type` TEXT NOT NULL, `space_id` TEXT NOT NULL, `last_modified_date_time` TEXT, `name` TEXT NOT NULL, `owner_id` TEXT, `web_url` TEXT, `description` TEXT, `quota_remaining` INTEGER, `quota_state` TEXT, `quota_total` INTEGER, `quota_used` INTEGER, `root_etag` TEXT, `root_id` TEXT NOT NULL, `root_web_dav_url` TEXT NOT NULL, `root_deleted_state` TEXT, PRIMARY KEY(`account_name`, `space_id`))", + "fields": [ + { + "fieldPath": "accountName", + "columnName": "account_name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "driveAlias", + "columnName": "drive_alias", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "driveType", + "columnName": "drive_type", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "id", + "columnName": "space_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lastModifiedDateTime", + "columnName": "last_modified_date_time", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "ownerId", + "columnName": "owner_id", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "webUrl", + "columnName": "web_url", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "description", + "columnName": "description", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "quota.remaining", + "columnName": "quota_remaining", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "quota.state", + "columnName": "quota_state", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "quota.total", + "columnName": "quota_total", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "quota.used", + "columnName": "quota_used", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "root.eTag", + "columnName": "root_etag", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "root.id", + "columnName": "root_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "root.webDavUrl", + "columnName": "root_web_dav_url", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "root.deleteState", + "columnName": "root_deleted_state", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "account_name", + "space_id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "spaces_special", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`spaces_special_account_name` TEXT NOT NULL, `spaces_special_space_id` TEXT NOT NULL, `spaces_special_etag` TEXT NOT NULL, `file_mime_type` TEXT NOT NULL, `special_id` TEXT NOT NULL, `last_modified_date_time` TEXT, `name` TEXT NOT NULL, `size` INTEGER NOT NULL, `special_folder_name` TEXT NOT NULL, `special_web_dav_url` TEXT NOT NULL, PRIMARY KEY(`spaces_special_space_id`, `special_id`), FOREIGN KEY(`spaces_special_account_name`, `spaces_special_space_id`) REFERENCES `spaces`(`account_name`, `space_id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "accountName", + "columnName": "spaces_special_account_name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "spaceId", + "columnName": "spaces_special_space_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "eTag", + "columnName": "spaces_special_etag", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "fileMimeType", + "columnName": "file_mime_type", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "id", + "columnName": "special_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lastModifiedDateTime", + "columnName": "last_modified_date_time", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "size", + "columnName": "size", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "specialFolderName", + "columnName": "special_folder_name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "webDavUrl", + "columnName": "special_web_dav_url", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "spaces_special_space_id", + "special_id" + ] + }, + "indices": [], + "foreignKeys": [ + { + "table": "spaces", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "spaces_special_account_name", + "spaces_special_space_id" + ], + "referencedColumns": [ + "account_name", + "space_id" + ] + } + ] + }, + { + "tableName": "user_quotas", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`accountName` TEXT NOT NULL, `used` INTEGER NOT NULL, `available` INTEGER NOT NULL, `total` INTEGER, `state` TEXT, PRIMARY KEY(`accountName`))", + "fields": [ + { + "fieldPath": "accountName", + "columnName": "accountName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "used", + "columnName": "used", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "available", + "columnName": "available", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "total", + "columnName": "total", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "state", + "columnName": "state", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "accountName" + ] + }, + "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, '588851784d53645ef76be75bb9593efa')" + ] + } +} \ No newline at end of file diff --git a/Android/qsferaData/src/main/java/eu/qsfera/android/data/ProviderMeta.java b/Android/qsferaData/src/main/java/eu/qsfera/android/data/ProviderMeta.java index 535c6ade..640fb2e3 100644 --- a/Android/qsferaData/src/main/java/eu/qsfera/android/data/ProviderMeta.java +++ b/Android/qsferaData/src/main/java/eu/qsfera/android/data/ProviderMeta.java @@ -31,7 +31,7 @@ public class ProviderMeta { public static final String DB_NAME = "filelist"; public static final String NEW_DB_NAME = "qsfera_database"; - public static final int DB_VERSION = 51; + public static final int DB_VERSION = 52; private ProviderMeta() { } diff --git a/Android/qsferaData/src/main/java/eu/qsfera/android/data/QSferaDatabase.kt b/Android/qsferaData/src/main/java/eu/qsfera/android/data/QSferaDatabase.kt index 2e8736b2..32d77061 100644 --- a/Android/qsferaData/src/main/java/eu/qsfera/android/data/QSferaDatabase.kt +++ b/Android/qsferaData/src/main/java/eu/qsfera/android/data/QSferaDatabase.kt @@ -54,6 +54,7 @@ import eu.qsfera.android.data.migrations.MIGRATION_47_48 import eu.qsfera.android.data.migrations.MIGRATION_48_49 import eu.qsfera.android.data.migrations.MIGRATION_49_50 import eu.qsfera.android.data.migrations.MIGRATION_50_51 +import eu.qsfera.android.data.migrations.MIGRATION_51_52 import eu.qsfera.android.data.sharing.shares.db.OCShareDao import eu.qsfera.android.data.sharing.shares.db.OCShareEntity import eu.qsfera.android.data.spaces.db.SpaceSpecialEntity @@ -131,7 +132,8 @@ abstract class QSferaDatabase : RoomDatabase() { MIGRATION_47_48, MIGRATION_48_49, MIGRATION_49_50, - MIGRATION_50_51) + MIGRATION_50_51, + MIGRATION_51_52) .build() INSTANCE = instance instance diff --git a/Android/qsferaData/src/main/java/eu/qsfera/android/data/folderbackup/datasources/implementation/OCLocalFolderBackupDataSource.kt b/Android/qsferaData/src/main/java/eu/qsfera/android/data/folderbackup/datasources/implementation/OCLocalFolderBackupDataSource.kt index a197a6a9..0d3ff212 100644 --- a/Android/qsferaData/src/main/java/eu/qsfera/android/data/folderbackup/datasources/implementation/OCLocalFolderBackupDataSource.kt +++ b/Android/qsferaData/src/main/java/eu/qsfera/android/data/folderbackup/datasources/implementation/OCLocalFolderBackupDataSource.kt @@ -75,6 +75,7 @@ class OCLocalFolderBackupDataSource( chargingOnly = chargingOnly, name = name, lastSyncTimestamp = lastSyncTimestamp, + sourcePathSyncTimestamps = sourcePathSyncTimestamps, spaceId = spaceId, ) @@ -93,6 +94,7 @@ class OCLocalFolderBackupDataSource( mobileDataBytesQueuedInPeriod = mobileDataBytesQueuedInPeriod, chargingOnly = chargingOnly, lastSyncTimestamp = lastSyncTimestamp, + sourcePathSyncTimestamps = sourcePathSyncTimestamps, name = name, spaceId = spaceId, ) diff --git a/Android/qsferaData/src/main/java/eu/qsfera/android/data/folderbackup/db/FolderBackUpEntity.kt b/Android/qsferaData/src/main/java/eu/qsfera/android/data/folderbackup/db/FolderBackUpEntity.kt index b5e4142e..149c9b91 100644 --- a/Android/qsferaData/src/main/java/eu/qsfera/android/data/folderbackup/db/FolderBackUpEntity.kt +++ b/Android/qsferaData/src/main/java/eu/qsfera/android/data/folderbackup/db/FolderBackUpEntity.kt @@ -36,6 +36,7 @@ data class FolderBackUpEntity( val chargingOnly: Boolean, val name: String, val lastSyncTimestamp: Long, + val sourcePathSyncTimestamps: String, val spaceId: String?, ) { @PrimaryKey(autoGenerate = true) var id: Int = 0 diff --git a/Android/qsferaData/src/main/java/eu/qsfera/android/data/migrations/Migration_34.kt b/Android/qsferaData/src/main/java/eu/qsfera/android/data/migrations/Migration_34.kt index 5b3cfc79..5bb97f6b 100644 --- a/Android/qsferaData/src/main/java/eu/qsfera/android/data/migrations/Migration_34.kt +++ b/Android/qsferaData/src/main/java/eu/qsfera/android/data/migrations/Migration_34.kt @@ -54,6 +54,7 @@ class CameraUploadsMigrationToRoom(val sharedPreferencesProvider: SharedPreferen sourcePath = getSourcePathForPreference(PREF__CAMERA_PICTURE_UPLOADS_SOURCE), behavior = getBehaviorForPreference(PREF__CAMERA_PICTURE_UPLOADS_BEHAVIOUR), lastSyncTimestamp = timestamp, + sourcePathSyncTimestamps = "", name = pictureUploadsName, allowRoaming = false, mobileDataLimitBytes = 0, @@ -74,6 +75,7 @@ class CameraUploadsMigrationToRoom(val sharedPreferencesProvider: SharedPreferen sourcePath = getSourcePathForPreference(PREF__CAMERA_VIDEO_UPLOADS_SOURCE), behavior = getBehaviorForPreference(PREF__CAMERA_VIDEO_UPLOADS_BEHAVIOUR), lastSyncTimestamp = timestamp, + sourcePathSyncTimestamps = "", name = videoUploadsName, allowRoaming = false, mobileDataLimitBytes = 0, diff --git a/Android/qsferaData/src/main/java/eu/qsfera/android/data/migrations/Migration_52.kt b/Android/qsferaData/src/main/java/eu/qsfera/android/data/migrations/Migration_52.kt new file mode 100644 index 00000000..0c428c20 --- /dev/null +++ b/Android/qsferaData/src/main/java/eu/qsfera/android/data/migrations/Migration_52.kt @@ -0,0 +1,11 @@ +package eu.qsfera.android.data.migrations + +import androidx.room.migration.Migration +import androidx.sqlite.db.SupportSQLiteDatabase +import eu.qsfera.android.data.ProviderMeta.ProviderTableMeta.FOLDER_BACKUP_TABLE_NAME + +val MIGRATION_51_52 = object : Migration(51, 52) { + override fun migrate(database: SupportSQLiteDatabase) { + database.execSQL("ALTER TABLE $FOLDER_BACKUP_TABLE_NAME ADD COLUMN sourcePathSyncTimestamps TEXT NOT NULL DEFAULT ''") + } +} diff --git a/Android/qsferaDomain/src/main/java/eu/qsfera/android/domain/automaticuploads/model/FolderBackUpConfiguration.kt b/Android/qsferaDomain/src/main/java/eu/qsfera/android/domain/automaticuploads/model/FolderBackUpConfiguration.kt index 6ac66499..4c5f8ffc 100644 --- a/Android/qsferaDomain/src/main/java/eu/qsfera/android/domain/automaticuploads/model/FolderBackUpConfiguration.kt +++ b/Android/qsferaDomain/src/main/java/eu/qsfera/android/domain/automaticuploads/model/FolderBackUpConfiguration.kt @@ -19,6 +19,9 @@ package eu.qsfera.android.domain.automaticuploads.model +import java.net.URLDecoder +import java.net.URLEncoder + data class FolderBackUpConfiguration( val accountName: String, val behavior: UploadBehavior, @@ -31,6 +34,7 @@ data class FolderBackUpConfiguration( val mobileDataBytesQueuedInPeriod: Long, val chargingOnly: Boolean, val lastSyncTimestamp: Long, + val sourcePathSyncTimestamps: String, val name: String, val spaceId: String?, ) { @@ -38,12 +42,15 @@ data class FolderBackUpConfiguration( val isPictureUploads get() = name == pictureUploadsName val isVideoUploads get() = name == videoUploadsName val sourcePaths get() = parseSourcePaths(sourcePath) + val sourceSyncTimestamps get() = parseSourcePathSyncTimestamps(sourcePathSyncTimestamps) companion object { const val pictureUploadsName = "Picture uploads" const val videoUploadsName = "Video uploads" const val initialSyncTimestamp = 0L private const val SOURCE_PATH_SEPARATOR = "\n" + private const val SOURCE_PATH_TIMESTAMP_SEPARATOR = "\t" + private const val UTF_8 = "UTF-8" fun parseSourcePaths(sourcePath: String): List = sourcePath @@ -58,6 +65,49 @@ data class FolderBackUpConfiguration( .filter { it.isNotEmpty() } .distinct() .joinToString(SOURCE_PATH_SEPARATOR) + + fun parseSourcePathSyncTimestamps(sourcePathSyncTimestamps: String): Map = + sourcePathSyncTimestamps + .split(SOURCE_PATH_SEPARATOR) + .map { it.trim() } + .filter { it.isNotEmpty() } + .fold(LinkedHashMap()) { acc, encodedEntry -> + val separatorIndex = encodedEntry.indexOf(SOURCE_PATH_TIMESTAMP_SEPARATOR) + if (separatorIndex <= 0) { + return@fold acc + } + val sourcePath = decodeSourcePath(encodedEntry.substring(0, separatorIndex)) ?: return@fold acc + val timestamp = encodedEntry.substring(separatorIndex + 1).toLongOrNull() ?: return@fold acc + if (sourcePath.isNotBlank() && timestamp >= initialSyncTimestamp) { + acc[sourcePath] = timestamp + } + acc + } + + fun encodeSourcePathSyncTimestamps(sourceSyncTimestamps: Map): String = + sourceSyncTimestamps + .entries + .fold(LinkedHashMap()) { acc, (sourcePath, timestamp) -> + val normalizedSourcePath = sourcePath.trim() + if (normalizedSourcePath.isNotEmpty() && timestamp >= initialSyncTimestamp) { + acc[normalizedSourcePath] = timestamp + } + acc + } + .map { (sourcePath, timestamp) -> + "${encodeSourcePath(sourcePath)}$SOURCE_PATH_TIMESTAMP_SEPARATOR$timestamp" + } + .joinToString(SOURCE_PATH_SEPARATOR) + + private fun encodeSourcePath(sourcePath: String): String = + URLEncoder.encode(sourcePath, UTF_8) + + private fun decodeSourcePath(encodedSourcePath: String): String? = + try { + URLDecoder.decode(encodedSourcePath, UTF_8) + } catch (illegalArgumentException: IllegalArgumentException) { + null + } } } diff --git a/Android/qsferaDomain/src/test/java/eu/qsfera/android/domain/automaticuploads/model/FolderBackUpConfigurationTest.kt b/Android/qsferaDomain/src/test/java/eu/qsfera/android/domain/automaticuploads/model/FolderBackUpConfigurationTest.kt index 3dc95871..b0beb9ed 100644 --- a/Android/qsferaDomain/src/test/java/eu/qsfera/android/domain/automaticuploads/model/FolderBackUpConfigurationTest.kt +++ b/Android/qsferaDomain/src/test/java/eu/qsfera/android/domain/automaticuploads/model/FolderBackUpConfigurationTest.kt @@ -11,7 +11,9 @@ package eu.qsfera.android.domain.automaticuploads.model import eu.qsfera.android.domain.automaticuploads.model.FolderBackUpConfiguration.Companion.encodeSourcePaths +import eu.qsfera.android.domain.automaticuploads.model.FolderBackUpConfiguration.Companion.encodeSourcePathSyncTimestamps import eu.qsfera.android.domain.automaticuploads.model.FolderBackUpConfiguration.Companion.parseSourcePaths +import eu.qsfera.android.domain.automaticuploads.model.FolderBackUpConfiguration.Companion.parseSourcePathSyncTimestamps import org.junit.Assert.assertEquals import org.junit.Test @@ -59,4 +61,33 @@ class FolderBackUpConfigurationTest { assertEquals("$firstSourcePath\n$secondSourcePath", encodedSourcePaths) } + + @Test + fun `source path sync timestamps round trip encoded source paths`() { + val firstSourcePath = "content://com.android.externalstorage.documents/tree/primary%3ADCIM" + val secondSourcePath = "content://com.android.externalstorage.documents/tree/primary%3AScreenshots" + val sourceSyncTimestamps = linkedMapOf( + firstSourcePath to 100L, + secondSourcePath to 200L, + ) + + val encodedSourceSyncTimestamps = encodeSourcePathSyncTimestamps(sourceSyncTimestamps) + val parsedSourceSyncTimestamps = parseSourcePathSyncTimestamps(encodedSourceSyncTimestamps) + + assertEquals(sourceSyncTimestamps, parsedSourceSyncTimestamps) + } + + @Test + fun `parseSourcePathSyncTimestamps ignores malformed entries`() { + val sourcePath = "content://com.android.externalstorage.documents/tree/primary%3ADCIM" + val encodedSourceSyncTimestamps = """ + malformed + ${java.net.URLEncoder.encode(sourcePath, "UTF-8")} not-a-number + ${java.net.URLEncoder.encode(sourcePath, "UTF-8")} 123 + """.trimIndent() + + val parsedSourceSyncTimestamps = parseSourcePathSyncTimestamps(encodedSourceSyncTimestamps) + + assertEquals(mapOf(sourcePath to 123L), parsedSourceSyncTimestamps) + } } diff --git a/Android/qsferaTestUtil/src/main/java/eu/qsfera/android/testutil/OCFolderBackUpConfiguration.kt b/Android/qsferaTestUtil/src/main/java/eu/qsfera/android/testutil/OCFolderBackUpConfiguration.kt index 09ce57d6..577a6916 100644 --- a/Android/qsferaTestUtil/src/main/java/eu/qsfera/android/testutil/OCFolderBackUpConfiguration.kt +++ b/Android/qsferaTestUtil/src/main/java/eu/qsfera/android/testutil/OCFolderBackUpConfiguration.kt @@ -37,6 +37,7 @@ val OC_BACKUP = FolderBackUpConfiguration( mobileDataBytesQueuedInPeriod = 0, chargingOnly = true, lastSyncTimestamp = 1542628397, + sourcePathSyncTimestamps = "", name = "", spaceId = null, ) @@ -53,6 +54,7 @@ val OC_BACKUP_ENTITY = FolderBackUpEntity( mobileDataBytesQueuedInPeriod = 0, chargingOnly = true, lastSyncTimestamp = 1542628397, + sourcePathSyncTimestamps = "", name = "", spaceId = null, ) diff --git a/PRODUCTION_READINESS.md b/PRODUCTION_READINESS.md index f09dd069..a9a510a8 100644 --- a/PRODUCTION_READINESS.md +++ b/PRODUCTION_READINESS.md @@ -35,6 +35,7 @@ Date: 2026-06-08. - Android auto-upload now exposes a photo/video daily mobile-data limit, stores it in Room schema version `51`, and stops queuing additional uploads on metered automatic-upload runs after the configured daily byte limit is reached while keeping skipped files eligible for the next scan period. - Android video backup settings now label the Wi-Fi-only control as "Never use mobile data for videos" and explain that turning it off enables metered-network limits and roaming controls. - Android photo/video backup settings now show a compact overall backup status: enabled state, selected phone-folder count, network/roaming/charging conditions, and last backup scan. +- Android auto-upload now stores per-source folder sync cursors in Room schema version `52`, keeps the legacy `lastSyncTimestamp` as a migration fallback, starts newly added source folders at timestamp `0`, and preserves per-source cursors when a metered run stops at the daily mobile-data limit. `:qsferaDomain:testDebugUnitTest --tests "eu.qsfera.android.domain.automaticuploads.model.FolderBackUpConfigurationTest"`, `:qsferaApp:testOriginalDebugUnitTest --tests "eu.qsfera.android.workers.AutomaticUploadsWorkerTest"`, and `:qsferaData:compileDebugKotlin` passed on 2026-06-10. - Server `qsfera_full` compose files now require explicit admin, Keycloak, and S3/MinIO secrets instead of demo defaults. - Server `qsfera_full/env.production.example` provides a tracked production-safe starting point with TLS validation enabled and demo users disabled. - Server `qsfera_full/validate-production-env.sh` checks production `.env` invariants before compose validation: TLS validation enabled, demo users disabled, pinned production image, required domains, required secrets, and no public-production MinIO. @@ -87,6 +88,5 @@ Date: 2026-06-08. ## Remaining P1 - Extend Android backup status UX beyond the new overall settings summary with per-item status, queued/paused/permanent-failure states, and low-storage messaging. -- Store auto-upload sync cursors per source folder rather than one timestamp for all folders. - Configure `QSFERA_ALERT_WEBHOOK_URL` in `/etc/qsfera/rpi-production-check.env` if off-host alert delivery is required; the installed failure service currently logs through `systemd-cat` and journal without an external endpoint. - Verify sharing controls against Google Drive and Yandex Disk expectations: link expiration, password, disable downloads, organization-only access, and role inheritance.