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
@@ -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
}
}
}
@@ -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)
}
}