Store Android auto-upload cursors per source

This commit is contained in:
Курнат Андрей
2026-06-10 07:35:14 +03:00
parent f1cbf65a3c
commit d79f4b93d0
15 changed files with 1466 additions and 8 deletions
@@ -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 {
@@ -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 {
@@ -163,11 +163,14 @@ class AutomaticUploadsWorker(
var mobileDataQuotaState = resolveMobileDataQuotaState(folderBackUpConfiguration, currentTimestamp)
var mobileDataLimitReached = false
val localPicturesDocumentFiles: List<DocumentFile> = folderBackUpConfiguration.sourcePaths.flatMap { sourcePath ->
val sourcePaths = folderBackUpConfiguration.sourcePaths
val currentSourceSyncTimestamps = folderBackUpConfiguration.sourceSyncTimestamps
val localPicturesDocumentFiles: List<DocumentFile> = 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<String, Long>,
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<String>,
currentSourceSyncTimestamps: Map<String, Long>,
fallbackLastSyncTimestamp: Long,
nextSyncTimestamp: Long,
scanCompleted: Boolean,
): Map<String, Long> =
sourcePaths
.distinct()
.associateWith { sourcePath ->
if (scanCompleted) {
nextSyncTimestamp
} else {
currentSourceSyncTimestamps[sourcePath] ?: fallbackLastSyncTimestamp
}
}
}
}
@@ -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,
)