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.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 {
|
||||
|
||||
+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.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 {
|
||||
|
||||
+38
-3
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+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(
|
||||
mobileDataPeriodStartTimestamp: Long,
|
||||
mobileDataBytesQueuedInPeriod: Long,
|
||||
@@ -137,6 +178,7 @@ class AutomaticUploadsWorkerTest {
|
||||
mobileDataBytesQueuedInPeriod = mobileDataBytesQueuedInPeriod,
|
||||
chargingOnly = false,
|
||||
lastSyncTimestamp = 0L,
|
||||
sourcePathSyncTimestamps = "",
|
||||
name = FolderBackUpConfiguration.pictureUploadsName,
|
||||
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 NEW_DB_NAME = "qsfera_database";
|
||||
public static final int DB_VERSION = 51;
|
||||
public static final int DB_VERSION = 52;
|
||||
|
||||
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_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
|
||||
|
||||
+2
@@ -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,
|
||||
)
|
||||
|
||||
+1
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
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<String> =
|
||||
sourcePath
|
||||
@@ -58,6 +65,49 @@ data class FolderBackUpConfiguration(
|
||||
.filter { it.isNotEmpty() }
|
||||
.distinct()
|
||||
.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
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
+2
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user