Store Android auto-upload cursors per source
This commit is contained in:
+14
-1
@@ -30,6 +30,7 @@ import eu.qsfera.android.R
|
|||||||
import eu.qsfera.android.db.PreferenceManager.PREF__CAMERA_UPLOADS_DEFAULT_PATH
|
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
|
||||||
import eu.qsfera.android.domain.automaticuploads.model.FolderBackUpConfiguration.Companion.encodeSourcePaths
|
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.initialSyncTimestamp
|
||||||
import eu.qsfera.android.domain.automaticuploads.model.FolderBackUpConfiguration.Companion.pictureUploadsName
|
import eu.qsfera.android.domain.automaticuploads.model.FolderBackUpConfiguration.Companion.pictureUploadsName
|
||||||
import eu.qsfera.android.domain.automaticuploads.model.UploadBehavior
|
import eu.qsfera.android.domain.automaticuploads.model.UploadBehavior
|
||||||
@@ -196,13 +197,22 @@ class SettingsPictureUploadsViewModel(
|
|||||||
val previousSourcePaths = getPictureUploadsSourcePaths()
|
val previousSourcePaths = getPictureUploadsSourcePaths()
|
||||||
val newSourcePath = contentUriForTree.toString()
|
val newSourcePath = contentUriForTree.toString()
|
||||||
val updatedSourcePaths = (previousSourcePaths + newSourcePath).distinct()
|
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) {
|
viewModelScope.launch(coroutinesDispatcherProvider.io) {
|
||||||
savePictureUploadsConfigurationUseCase(
|
savePictureUploadsConfigurationUseCase(
|
||||||
SavePictureUploadsConfigurationUseCase.Params(
|
SavePictureUploadsConfigurationUseCase.Params(
|
||||||
composePictureUploadsConfiguration(
|
composePictureUploadsConfiguration(
|
||||||
sourcePath = encodeSourcePaths(updatedSourcePaths),
|
sourcePath = encodeSourcePaths(updatedSourcePaths),
|
||||||
timestamp = initialSyncTimestamp.takeIf { updatedSourcePaths != previousSourcePaths }
|
sourcePathSyncTimestamps = encodeSourcePathSyncTimestamps(updatedSourceSyncTimestamps)
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
@@ -215,6 +225,7 @@ class SettingsPictureUploadsViewModel(
|
|||||||
SavePictureUploadsConfigurationUseCase.Params(
|
SavePictureUploadsConfigurationUseCase.Params(
|
||||||
composePictureUploadsConfiguration(
|
composePictureUploadsConfiguration(
|
||||||
sourcePath = "",
|
sourcePath = "",
|
||||||
|
sourcePathSyncTimestamps = "",
|
||||||
timestamp = System.currentTimeMillis()
|
timestamp = System.currentTimeMillis()
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
@@ -237,6 +248,7 @@ class SettingsPictureUploadsViewModel(
|
|||||||
mobileDataBytesQueuedInPeriod: Long? = _pictureUploads.value?.mobileDataBytesQueuedInPeriod,
|
mobileDataBytesQueuedInPeriod: Long? = _pictureUploads.value?.mobileDataBytesQueuedInPeriod,
|
||||||
chargingOnly: Boolean? = _pictureUploads.value?.chargingOnly,
|
chargingOnly: Boolean? = _pictureUploads.value?.chargingOnly,
|
||||||
sourcePath: String? = _pictureUploads.value?.sourcePath,
|
sourcePath: String? = _pictureUploads.value?.sourcePath,
|
||||||
|
sourcePathSyncTimestamps: String? = _pictureUploads.value?.sourcePathSyncTimestamps,
|
||||||
behavior: UploadBehavior? = _pictureUploads.value?.behavior,
|
behavior: UploadBehavior? = _pictureUploads.value?.behavior,
|
||||||
timestamp: Long? = _pictureUploads.value?.lastSyncTimestamp,
|
timestamp: Long? = _pictureUploads.value?.lastSyncTimestamp,
|
||||||
spaceId: String? = _pictureUploads.value?.spaceId,
|
spaceId: String? = _pictureUploads.value?.spaceId,
|
||||||
@@ -252,6 +264,7 @@ class SettingsPictureUploadsViewModel(
|
|||||||
mobileDataBytesQueuedInPeriod = mobileDataBytesQueuedInPeriod ?: 0,
|
mobileDataBytesQueuedInPeriod = mobileDataBytesQueuedInPeriod ?: 0,
|
||||||
chargingOnly = chargingOnly == true,
|
chargingOnly = chargingOnly == true,
|
||||||
lastSyncTimestamp = timestamp ?: initialSyncTimestamp,
|
lastSyncTimestamp = timestamp ?: initialSyncTimestamp,
|
||||||
|
sourcePathSyncTimestamps = sourcePathSyncTimestamps.orEmpty(),
|
||||||
name = _pictureUploads.value?.name ?: pictureUploadsName,
|
name = _pictureUploads.value?.name ?: pictureUploadsName,
|
||||||
spaceId = spaceId,
|
spaceId = spaceId,
|
||||||
).also {
|
).also {
|
||||||
|
|||||||
+14
-1
@@ -30,6 +30,7 @@ import eu.qsfera.android.R
|
|||||||
import eu.qsfera.android.db.PreferenceManager.PREF__CAMERA_UPLOADS_DEFAULT_PATH
|
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
|
||||||
import eu.qsfera.android.domain.automaticuploads.model.FolderBackUpConfiguration.Companion.encodeSourcePaths
|
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.initialSyncTimestamp
|
||||||
import eu.qsfera.android.domain.automaticuploads.model.FolderBackUpConfiguration.Companion.videoUploadsName
|
import eu.qsfera.android.domain.automaticuploads.model.FolderBackUpConfiguration.Companion.videoUploadsName
|
||||||
import eu.qsfera.android.domain.automaticuploads.model.UploadBehavior
|
import eu.qsfera.android.domain.automaticuploads.model.UploadBehavior
|
||||||
@@ -196,13 +197,22 @@ class SettingsVideoUploadsViewModel(
|
|||||||
val previousSourcePaths = getVideoUploadsSourcePaths()
|
val previousSourcePaths = getVideoUploadsSourcePaths()
|
||||||
val newSourcePath = contentUriForTree.toString()
|
val newSourcePath = contentUriForTree.toString()
|
||||||
val updatedSourcePaths = (previousSourcePaths + newSourcePath).distinct()
|
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) {
|
viewModelScope.launch(coroutinesDispatcherProvider.io) {
|
||||||
saveVideoUploadsConfigurationUseCase(
|
saveVideoUploadsConfigurationUseCase(
|
||||||
SaveVideoUploadsConfigurationUseCase.Params(
|
SaveVideoUploadsConfigurationUseCase.Params(
|
||||||
composeVideoUploadsConfiguration(
|
composeVideoUploadsConfiguration(
|
||||||
sourcePath = encodeSourcePaths(updatedSourcePaths),
|
sourcePath = encodeSourcePaths(updatedSourcePaths),
|
||||||
timestamp = initialSyncTimestamp.takeIf { updatedSourcePaths != previousSourcePaths }
|
sourcePathSyncTimestamps = encodeSourcePathSyncTimestamps(updatedSourceSyncTimestamps)
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
@@ -215,6 +225,7 @@ class SettingsVideoUploadsViewModel(
|
|||||||
SaveVideoUploadsConfigurationUseCase.Params(
|
SaveVideoUploadsConfigurationUseCase.Params(
|
||||||
composeVideoUploadsConfiguration(
|
composeVideoUploadsConfiguration(
|
||||||
sourcePath = "",
|
sourcePath = "",
|
||||||
|
sourcePathSyncTimestamps = "",
|
||||||
timestamp = System.currentTimeMillis()
|
timestamp = System.currentTimeMillis()
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
@@ -237,6 +248,7 @@ class SettingsVideoUploadsViewModel(
|
|||||||
mobileDataBytesQueuedInPeriod: Long? = _videoUploads.value?.mobileDataBytesQueuedInPeriod,
|
mobileDataBytesQueuedInPeriod: Long? = _videoUploads.value?.mobileDataBytesQueuedInPeriod,
|
||||||
chargingOnly: Boolean? = _videoUploads.value?.chargingOnly,
|
chargingOnly: Boolean? = _videoUploads.value?.chargingOnly,
|
||||||
sourcePath: String? = _videoUploads.value?.sourcePath,
|
sourcePath: String? = _videoUploads.value?.sourcePath,
|
||||||
|
sourcePathSyncTimestamps: String? = _videoUploads.value?.sourcePathSyncTimestamps,
|
||||||
behavior: UploadBehavior? = _videoUploads.value?.behavior,
|
behavior: UploadBehavior? = _videoUploads.value?.behavior,
|
||||||
timestamp: Long? = _videoUploads.value?.lastSyncTimestamp,
|
timestamp: Long? = _videoUploads.value?.lastSyncTimestamp,
|
||||||
spaceId: String? = _videoUploads.value?.spaceId,
|
spaceId: String? = _videoUploads.value?.spaceId,
|
||||||
@@ -253,6 +265,7 @@ class SettingsVideoUploadsViewModel(
|
|||||||
mobileDataBytesQueuedInPeriod = mobileDataBytesQueuedInPeriod ?: 0,
|
mobileDataBytesQueuedInPeriod = mobileDataBytesQueuedInPeriod ?: 0,
|
||||||
chargingOnly = chargingOnly == true,
|
chargingOnly = chargingOnly == true,
|
||||||
lastSyncTimestamp = timestamp ?: initialSyncTimestamp,
|
lastSyncTimestamp = timestamp ?: initialSyncTimestamp,
|
||||||
|
sourcePathSyncTimestamps = sourcePathSyncTimestamps.orEmpty(),
|
||||||
name = _videoUploads.value?.name ?: videoUploadsName,
|
name = _videoUploads.value?.name ?: videoUploadsName,
|
||||||
spaceId = spaceId,
|
spaceId = spaceId,
|
||||||
).also {
|
).also {
|
||||||
|
|||||||
+38
-3
@@ -163,11 +163,14 @@ class AutomaticUploadsWorker(
|
|||||||
var mobileDataQuotaState = resolveMobileDataQuotaState(folderBackUpConfiguration, currentTimestamp)
|
var mobileDataQuotaState = resolveMobileDataQuotaState(folderBackUpConfiguration, currentTimestamp)
|
||||||
var mobileDataLimitReached = false
|
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(
|
getFilesReadyToUpload(
|
||||||
syncType = syncType,
|
syncType = syncType,
|
||||||
sourcePath = sourcePath,
|
sourcePath = sourcePath,
|
||||||
lastSyncTimestamp = folderBackUpConfiguration.lastSyncTimestamp,
|
lastSyncTimestamp = lastSyncTimestamp,
|
||||||
currentTimestamp = currentTimestamp,
|
currentTimestamp = currentTimestamp,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -241,7 +244,20 @@ class AutomaticUploadsWorker(
|
|||||||
// so skipped files are re-evaluated after the quota period resets.
|
// so skipped files are re-evaluated after the quota period resets.
|
||||||
val safeTimestamp = currentTimestamp - WRITE_SAFETY_BUFFER_MS
|
val safeTimestamp = currentTimestamp - WRITE_SAFETY_BUFFER_MS
|
||||||
val nextTimestamp = if (mobileDataLimitReached) folderBackUpConfiguration.lastSyncTimestamp else safeTimestamp
|
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(
|
private fun showNotification(
|
||||||
@@ -294,10 +310,12 @@ class AutomaticUploadsWorker(
|
|||||||
folderBackUpConfiguration: FolderBackUpConfiguration,
|
folderBackUpConfiguration: FolderBackUpConfiguration,
|
||||||
syncType: SyncType,
|
syncType: SyncType,
|
||||||
nextSyncTimestamp: Long,
|
nextSyncTimestamp: Long,
|
||||||
|
nextSourceSyncTimestamps: Map<String, Long>,
|
||||||
mobileDataQuotaState: MobileDataQuotaState,
|
mobileDataQuotaState: MobileDataQuotaState,
|
||||||
) {
|
) {
|
||||||
val updatedConfiguration = folderBackUpConfiguration.copy(
|
val updatedConfiguration = folderBackUpConfiguration.copy(
|
||||||
lastSyncTimestamp = nextSyncTimestamp,
|
lastSyncTimestamp = nextSyncTimestamp,
|
||||||
|
sourcePathSyncTimestamps = FolderBackUpConfiguration.encodeSourcePathSyncTimestamps(nextSourceSyncTimestamps),
|
||||||
mobileDataPeriodStartTimestamp = mobileDataQuotaState.periodStartTimestamp,
|
mobileDataPeriodStartTimestamp = mobileDataQuotaState.periodStartTimestamp,
|
||||||
mobileDataBytesQueuedInPeriod = mobileDataQuotaState.bytesQueuedInPeriod
|
mobileDataBytesQueuedInPeriod = mobileDataQuotaState.bytesQueuedInPeriod
|
||||||
)
|
)
|
||||||
@@ -459,5 +477,22 @@ class AutomaticUploadsWorker(
|
|||||||
): Boolean =
|
): Boolean =
|
||||||
!shouldCountAgainstMobileDataLimit(wifiOnly, isActiveNetworkMetered, mobileDataLimitBytes) ||
|
!shouldCountAgainstMobileDataLimit(wifiOnly, isActiveNetworkMetered, mobileDataLimitBytes) ||
|
||||||
fileSize <= mobileDataLimitBytes - mobileDataBytesQueuedInPeriod
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+42
@@ -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(
|
private fun backupConfiguration(
|
||||||
mobileDataPeriodStartTimestamp: Long,
|
mobileDataPeriodStartTimestamp: Long,
|
||||||
mobileDataBytesQueuedInPeriod: Long,
|
mobileDataBytesQueuedInPeriod: Long,
|
||||||
@@ -137,6 +178,7 @@ class AutomaticUploadsWorkerTest {
|
|||||||
mobileDataBytesQueuedInPeriod = mobileDataBytesQueuedInPeriod,
|
mobileDataBytesQueuedInPeriod = mobileDataBytesQueuedInPeriod,
|
||||||
chargingOnly = false,
|
chargingOnly = false,
|
||||||
lastSyncTimestamp = 0L,
|
lastSyncTimestamp = 0L,
|
||||||
|
sourcePathSyncTimestamps = "",
|
||||||
name = FolderBackUpConfiguration.pictureUploadsName,
|
name = FolderBackUpConfiguration.pictureUploadsName,
|
||||||
spaceId = null,
|
spaceId = null,
|
||||||
)
|
)
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -31,7 +31,7 @@ public class ProviderMeta {
|
|||||||
|
|
||||||
public static final String DB_NAME = "filelist";
|
public static final String DB_NAME = "filelist";
|
||||||
public static final String NEW_DB_NAME = "qsfera_database";
|
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() {
|
private ProviderMeta() {
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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_48_49
|
||||||
import eu.qsfera.android.data.migrations.MIGRATION_49_50
|
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_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.OCShareDao
|
||||||
import eu.qsfera.android.data.sharing.shares.db.OCShareEntity
|
import eu.qsfera.android.data.sharing.shares.db.OCShareEntity
|
||||||
import eu.qsfera.android.data.spaces.db.SpaceSpecialEntity
|
import eu.qsfera.android.data.spaces.db.SpaceSpecialEntity
|
||||||
@@ -131,7 +132,8 @@ abstract class QSferaDatabase : RoomDatabase() {
|
|||||||
MIGRATION_47_48,
|
MIGRATION_47_48,
|
||||||
MIGRATION_48_49,
|
MIGRATION_48_49,
|
||||||
MIGRATION_49_50,
|
MIGRATION_49_50,
|
||||||
MIGRATION_50_51)
|
MIGRATION_50_51,
|
||||||
|
MIGRATION_51_52)
|
||||||
.build()
|
.build()
|
||||||
INSTANCE = instance
|
INSTANCE = instance
|
||||||
instance
|
instance
|
||||||
|
|||||||
+2
@@ -75,6 +75,7 @@ class OCLocalFolderBackupDataSource(
|
|||||||
chargingOnly = chargingOnly,
|
chargingOnly = chargingOnly,
|
||||||
name = name,
|
name = name,
|
||||||
lastSyncTimestamp = lastSyncTimestamp,
|
lastSyncTimestamp = lastSyncTimestamp,
|
||||||
|
sourcePathSyncTimestamps = sourcePathSyncTimestamps,
|
||||||
spaceId = spaceId,
|
spaceId = spaceId,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -93,6 +94,7 @@ class OCLocalFolderBackupDataSource(
|
|||||||
mobileDataBytesQueuedInPeriod = mobileDataBytesQueuedInPeriod,
|
mobileDataBytesQueuedInPeriod = mobileDataBytesQueuedInPeriod,
|
||||||
chargingOnly = chargingOnly,
|
chargingOnly = chargingOnly,
|
||||||
lastSyncTimestamp = lastSyncTimestamp,
|
lastSyncTimestamp = lastSyncTimestamp,
|
||||||
|
sourcePathSyncTimestamps = sourcePathSyncTimestamps,
|
||||||
name = name,
|
name = name,
|
||||||
spaceId = spaceId,
|
spaceId = spaceId,
|
||||||
)
|
)
|
||||||
|
|||||||
+1
@@ -36,6 +36,7 @@ data class FolderBackUpEntity(
|
|||||||
val chargingOnly: Boolean,
|
val chargingOnly: Boolean,
|
||||||
val name: String,
|
val name: String,
|
||||||
val lastSyncTimestamp: Long,
|
val lastSyncTimestamp: Long,
|
||||||
|
val sourcePathSyncTimestamps: String,
|
||||||
val spaceId: String?,
|
val spaceId: String?,
|
||||||
) {
|
) {
|
||||||
@PrimaryKey(autoGenerate = true) var id: Int = 0
|
@PrimaryKey(autoGenerate = true) var id: Int = 0
|
||||||
|
|||||||
@@ -54,6 +54,7 @@ class CameraUploadsMigrationToRoom(val sharedPreferencesProvider: SharedPreferen
|
|||||||
sourcePath = getSourcePathForPreference(PREF__CAMERA_PICTURE_UPLOADS_SOURCE),
|
sourcePath = getSourcePathForPreference(PREF__CAMERA_PICTURE_UPLOADS_SOURCE),
|
||||||
behavior = getBehaviorForPreference(PREF__CAMERA_PICTURE_UPLOADS_BEHAVIOUR),
|
behavior = getBehaviorForPreference(PREF__CAMERA_PICTURE_UPLOADS_BEHAVIOUR),
|
||||||
lastSyncTimestamp = timestamp,
|
lastSyncTimestamp = timestamp,
|
||||||
|
sourcePathSyncTimestamps = "",
|
||||||
name = pictureUploadsName,
|
name = pictureUploadsName,
|
||||||
allowRoaming = false,
|
allowRoaming = false,
|
||||||
mobileDataLimitBytes = 0,
|
mobileDataLimitBytes = 0,
|
||||||
@@ -74,6 +75,7 @@ class CameraUploadsMigrationToRoom(val sharedPreferencesProvider: SharedPreferen
|
|||||||
sourcePath = getSourcePathForPreference(PREF__CAMERA_VIDEO_UPLOADS_SOURCE),
|
sourcePath = getSourcePathForPreference(PREF__CAMERA_VIDEO_UPLOADS_SOURCE),
|
||||||
behavior = getBehaviorForPreference(PREF__CAMERA_VIDEO_UPLOADS_BEHAVIOUR),
|
behavior = getBehaviorForPreference(PREF__CAMERA_VIDEO_UPLOADS_BEHAVIOUR),
|
||||||
lastSyncTimestamp = timestamp,
|
lastSyncTimestamp = timestamp,
|
||||||
|
sourcePathSyncTimestamps = "",
|
||||||
name = videoUploadsName,
|
name = videoUploadsName,
|
||||||
allowRoaming = false,
|
allowRoaming = false,
|
||||||
mobileDataLimitBytes = 0,
|
mobileDataLimitBytes = 0,
|
||||||
|
|||||||
@@ -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 ''")
|
||||||
|
}
|
||||||
|
}
|
||||||
+50
@@ -19,6 +19,9 @@
|
|||||||
|
|
||||||
package eu.qsfera.android.domain.automaticuploads.model
|
package eu.qsfera.android.domain.automaticuploads.model
|
||||||
|
|
||||||
|
import java.net.URLDecoder
|
||||||
|
import java.net.URLEncoder
|
||||||
|
|
||||||
data class FolderBackUpConfiguration(
|
data class FolderBackUpConfiguration(
|
||||||
val accountName: String,
|
val accountName: String,
|
||||||
val behavior: UploadBehavior,
|
val behavior: UploadBehavior,
|
||||||
@@ -31,6 +34,7 @@ data class FolderBackUpConfiguration(
|
|||||||
val mobileDataBytesQueuedInPeriod: Long,
|
val mobileDataBytesQueuedInPeriod: Long,
|
||||||
val chargingOnly: Boolean,
|
val chargingOnly: Boolean,
|
||||||
val lastSyncTimestamp: Long,
|
val lastSyncTimestamp: Long,
|
||||||
|
val sourcePathSyncTimestamps: String,
|
||||||
val name: String,
|
val name: String,
|
||||||
val spaceId: String?,
|
val spaceId: String?,
|
||||||
) {
|
) {
|
||||||
@@ -38,12 +42,15 @@ data class FolderBackUpConfiguration(
|
|||||||
val isPictureUploads get() = name == pictureUploadsName
|
val isPictureUploads get() = name == pictureUploadsName
|
||||||
val isVideoUploads get() = name == videoUploadsName
|
val isVideoUploads get() = name == videoUploadsName
|
||||||
val sourcePaths get() = parseSourcePaths(sourcePath)
|
val sourcePaths get() = parseSourcePaths(sourcePath)
|
||||||
|
val sourceSyncTimestamps get() = parseSourcePathSyncTimestamps(sourcePathSyncTimestamps)
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
const val pictureUploadsName = "Picture uploads"
|
const val pictureUploadsName = "Picture uploads"
|
||||||
const val videoUploadsName = "Video uploads"
|
const val videoUploadsName = "Video uploads"
|
||||||
const val initialSyncTimestamp = 0L
|
const val initialSyncTimestamp = 0L
|
||||||
private const val SOURCE_PATH_SEPARATOR = "\n"
|
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<String> =
|
fun parseSourcePaths(sourcePath: String): List<String> =
|
||||||
sourcePath
|
sourcePath
|
||||||
@@ -58,6 +65,49 @@ data class FolderBackUpConfiguration(
|
|||||||
.filter { it.isNotEmpty() }
|
.filter { it.isNotEmpty() }
|
||||||
.distinct()
|
.distinct()
|
||||||
.joinToString(SOURCE_PATH_SEPARATOR)
|
.joinToString(SOURCE_PATH_SEPARATOR)
|
||||||
|
|
||||||
|
fun parseSourcePathSyncTimestamps(sourcePathSyncTimestamps: String): Map<String, Long> =
|
||||||
|
sourcePathSyncTimestamps
|
||||||
|
.split(SOURCE_PATH_SEPARATOR)
|
||||||
|
.map { it.trim() }
|
||||||
|
.filter { it.isNotEmpty() }
|
||||||
|
.fold(LinkedHashMap<String, Long>()) { 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, Long>): String =
|
||||||
|
sourceSyncTimestamps
|
||||||
|
.entries
|
||||||
|
.fold(LinkedHashMap<String, Long>()) { 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
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+31
@@ -11,7 +11,9 @@
|
|||||||
package eu.qsfera.android.domain.automaticuploads.model
|
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.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.parseSourcePaths
|
||||||
|
import eu.qsfera.android.domain.automaticuploads.model.FolderBackUpConfiguration.Companion.parseSourcePathSyncTimestamps
|
||||||
import org.junit.Assert.assertEquals
|
import org.junit.Assert.assertEquals
|
||||||
import org.junit.Test
|
import org.junit.Test
|
||||||
|
|
||||||
@@ -59,4 +61,33 @@ class FolderBackUpConfigurationTest {
|
|||||||
|
|
||||||
assertEquals("$firstSourcePath\n$secondSourcePath", encodedSourcePaths)
|
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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+2
@@ -37,6 +37,7 @@ val OC_BACKUP = FolderBackUpConfiguration(
|
|||||||
mobileDataBytesQueuedInPeriod = 0,
|
mobileDataBytesQueuedInPeriod = 0,
|
||||||
chargingOnly = true,
|
chargingOnly = true,
|
||||||
lastSyncTimestamp = 1542628397,
|
lastSyncTimestamp = 1542628397,
|
||||||
|
sourcePathSyncTimestamps = "",
|
||||||
name = "",
|
name = "",
|
||||||
spaceId = null,
|
spaceId = null,
|
||||||
)
|
)
|
||||||
@@ -53,6 +54,7 @@ val OC_BACKUP_ENTITY = FolderBackUpEntity(
|
|||||||
mobileDataBytesQueuedInPeriod = 0,
|
mobileDataBytesQueuedInPeriod = 0,
|
||||||
chargingOnly = true,
|
chargingOnly = true,
|
||||||
lastSyncTimestamp = 1542628397,
|
lastSyncTimestamp = 1542628397,
|
||||||
|
sourcePathSyncTimestamps = "",
|
||||||
name = "",
|
name = "",
|
||||||
spaceId = null,
|
spaceId = null,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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 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 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 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` 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/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.
|
- 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
|
## 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.
|
- 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.
|
- 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.
|
- Verify sharing controls against Google Drive and Yandex Disk expectations: link expiration, password, disable downloads, organization-only access, and role inheritance.
|
||||||
|
|||||||
Reference in New Issue
Block a user