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,
)
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
@@ -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,
)
@@ -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 ''")
}
}
@@ -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)
}
}
@@ -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,
)
+1 -1
View File
@@ -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.