From 439cf8289368cc2205d6828e0ac180fc1cfa2867 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=9A=D1=83=D1=80=D0=BD=D0=B0=D1=82=20=D0=90=D0=BD=D0=B4?= =?UTF-8?q?=D1=80=D0=B5=D0=B9?= Date: Wed, 10 Jun 2026 08:08:00 +0300 Subject: [PATCH] Show detailed Android backup status --- .../AutomaticUploadStatusFormatter.kt | 178 +++++++++++++++++- .../SettingsPictureUploadsFragment.kt | 38 +++- .../SettingsVideoUploadsFragment.kt | 38 +++- .../src/main/res/values-ru/strings.xml | 11 ++ .../qsferaApp/src/main/res/values/strings.xml | 11 ++ .../AutomaticUploadStatusFormatterTest.kt | 146 ++++++++++++++ PRODUCTION_READINESS.md | 3 +- 7 files changed, 407 insertions(+), 18 deletions(-) create mode 100644 Android/qsferaApp/src/test/java/eu/qsfera/android/presentation/settings/automaticuploads/AutomaticUploadStatusFormatterTest.kt diff --git a/Android/qsferaApp/src/main/java/eu/qsfera/android/presentation/settings/automaticuploads/AutomaticUploadStatusFormatter.kt b/Android/qsferaApp/src/main/java/eu/qsfera/android/presentation/settings/automaticuploads/AutomaticUploadStatusFormatter.kt index 73d39ad2..2411cb26 100644 --- a/Android/qsferaApp/src/main/java/eu/qsfera/android/presentation/settings/automaticuploads/AutomaticUploadStatusFormatter.kt +++ b/Android/qsferaApp/src/main/java/eu/qsfera/android/presentation/settings/automaticuploads/AutomaticUploadStatusFormatter.kt @@ -13,6 +13,9 @@ package eu.qsfera.android.presentation.settings.automaticuploads import android.content.Context import eu.qsfera.android.R import eu.qsfera.android.domain.automaticuploads.model.FolderBackUpConfiguration +import eu.qsfera.android.domain.user.model.UserQuota +import eu.qsfera.android.domain.user.model.UserQuotaState +import eu.qsfera.android.utils.DisplayUtils internal object AutomaticUploadStatusFormatter { @@ -21,19 +24,149 @@ internal object AutomaticUploadStatusFormatter { configuration: FolderBackUpConfiguration?, sourcePaths: List, lastSyncSummary: String, + unreadableSourcePaths: Set = emptySet(), + userQuota: UserQuota? = null, + sourcePathLabels: Map = emptyMap(), ): String { - if (configuration == null) { + val status = statusFor( + configuration = configuration, + sourcePaths = sourcePaths, + unreadableSourcePaths = unreadableSourcePaths, + userQuota = userQuota, + ) + if (status.runState == AutomaticUploadRunState.OFF) { return context.getString(R.string.prefs_camera_upload_status_off) } + val enabledConfiguration = configuration ?: return context.getString(R.string.prefs_camera_upload_status_off) return listOf( context.getString(R.string.prefs_camera_upload_status_on), - formatSourceCount(context, sourcePaths.size), - formatConditions(context, configuration), + formatRunState(context, status.runState), + formatSourceCount(context, status.sourceStatuses.size), + formatSourceDetails(context, status.sourceStatuses, sourcePathLabels), + formatConditions(context, enabledConfiguration), context.getString(R.string.prefs_camera_upload_status_last_scan, lastSyncSummary), - ).joinToString(separator = "\n") + formatStorageWarning(context, status.storageWarning), + ).filter { it.isNotEmpty() }.joinToString(separator = "\n") } + fun statusFor( + configuration: FolderBackUpConfiguration?, + sourcePaths: List, + unreadableSourcePaths: Set = emptySet(), + userQuota: UserQuota? = null, + ): AutomaticUploadStatus { + if (configuration == null) { + return AutomaticUploadStatus( + runState = AutomaticUploadRunState.OFF, + sourceStatuses = emptyList(), + storageWarning = AutomaticUploadStorageWarning.NONE, + ) + } + + val currentSourceSyncTimestamps = configuration.sourceSyncTimestamps + val sourceStatuses = sourcePaths.distinct().map { sourcePath -> + val lastSyncTimestamp = currentSourceSyncTimestamps[sourcePath] ?: configuration.lastSyncTimestamp + AutomaticUploadSourceStatus( + sourcePath = sourcePath, + lastSyncTimestamp = lastSyncTimestamp, + state = when { + sourcePath in unreadableSourcePaths -> AutomaticUploadSourceState.NEEDS_ACCESS + lastSyncTimestamp <= FolderBackUpConfiguration.initialSyncTimestamp -> + AutomaticUploadSourceState.WAITING_FOR_FIRST_SCAN + else -> AutomaticUploadSourceState.SCANNED + } + ) + } + + val runState = when { + sourceStatuses.isEmpty() -> AutomaticUploadRunState.NEEDS_SOURCE + sourceStatuses.any { it.state == AutomaticUploadSourceState.NEEDS_ACCESS } -> + AutomaticUploadRunState.NEEDS_SOURCE_ACCESS + sourceStatuses.any { it.state == AutomaticUploadSourceState.WAITING_FOR_FIRST_SCAN } -> + AutomaticUploadRunState.WAITING_FOR_FIRST_SCAN + else -> AutomaticUploadRunState.READY + } + + return AutomaticUploadStatus( + runState = runState, + sourceStatuses = sourceStatuses, + storageWarning = resolveStorageWarning(userQuota), + ) + } + + private fun formatRunState(context: Context, runState: AutomaticUploadRunState): String = + when (runState) { + AutomaticUploadRunState.OFF -> context.getString(R.string.prefs_camera_upload_status_off) + AutomaticUploadRunState.NEEDS_SOURCE -> context.getString(R.string.prefs_camera_upload_status_needs_source) + AutomaticUploadRunState.NEEDS_SOURCE_ACCESS -> + context.getString(R.string.prefs_camera_upload_status_needs_source_access) + AutomaticUploadRunState.WAITING_FOR_FIRST_SCAN -> + context.getString(R.string.prefs_camera_upload_status_waiting_first_scan) + AutomaticUploadRunState.READY -> context.getString(R.string.prefs_camera_upload_status_ready) + } + + private fun formatSourceDetails( + context: Context, + sourceStatuses: List, + sourcePathLabels: Map, + ): String = + sourceStatuses + .take(MAX_SOURCE_STATUS_LINES) + .map { sourceStatus -> + val label = sourcePathLabels[sourceStatus.sourcePath] ?: sourceStatus.sourcePath + when (sourceStatus.state) { + AutomaticUploadSourceState.NEEDS_ACCESS -> + context.getString(R.string.prefs_camera_upload_status_source_needs_access, label) + AutomaticUploadSourceState.WAITING_FOR_FIRST_SCAN -> + context.getString(R.string.prefs_camera_upload_status_source_waiting, label) + AutomaticUploadSourceState.SCANNED -> + context.getString( + R.string.prefs_camera_upload_status_source_scanned, + label, + DisplayUtils.unixTimeToHumanReadable(sourceStatus.lastSyncTimestamp) + ) + } + } + .plus(formatHiddenSourceCount(context, sourceStatuses.size)) + .filter { it.isNotEmpty() } + .joinToString(separator = "\n") + + private fun formatHiddenSourceCount(context: Context, sourceCount: Int): String = + if (sourceCount > MAX_SOURCE_STATUS_LINES) { + context.getString( + R.string.prefs_camera_upload_status_more_sources, + sourceCount - MAX_SOURCE_STATUS_LINES + ) + } else { + "" + } + + private fun formatStorageWarning( + context: Context, + storageWarning: AutomaticUploadStorageWarning, + ): String = + when (storageWarning) { + AutomaticUploadStorageWarning.NONE -> "" + AutomaticUploadStorageWarning.NEARING -> + context.getString(R.string.prefs_camera_upload_status_storage_nearing) + AutomaticUploadStorageWarning.CRITICAL -> + context.getString(R.string.prefs_camera_upload_status_storage_critical) + AutomaticUploadStorageWarning.EXCEEDED -> + context.getString(R.string.prefs_camera_upload_status_storage_exceeded) + } + + private fun resolveStorageWarning(userQuota: UserQuota?): AutomaticUploadStorageWarning = + when { + userQuota == null -> AutomaticUploadStorageWarning.NONE + userQuota.available < 0L -> AutomaticUploadStorageWarning.NONE + userQuota.available == 0L -> AutomaticUploadStorageWarning.EXCEEDED + userQuota.state == UserQuotaState.EXCEEDED -> AutomaticUploadStorageWarning.EXCEEDED + userQuota.state == UserQuotaState.CRITICAL -> AutomaticUploadStorageWarning.CRITICAL + userQuota.state == UserQuotaState.NEARING -> AutomaticUploadStorageWarning.NEARING + else -> AutomaticUploadStorageWarning.NONE + } + private fun formatSourceCount(context: Context, sourceCount: Int): String = if (sourceCount == 0) { context.getString(R.string.prefs_camera_upload_source_paths_empty) @@ -61,7 +194,7 @@ internal object AutomaticUploadStatusFormatter { if (configuration.chargingOnly) { conditions += context.getString(R.string.prefs_camera_upload_status_charging_only) } - return conditions.joinToString(separator = " • ") + return conditions.joinToString(separator = " - ") } private fun formatMobileDataLimit(context: Context, mobileDataLimitBytes: Long): String { @@ -71,4 +204,39 @@ internal object AutomaticUploadStatusFormatter { return entries.getOrNull(index) ?: context.getString(R.string.prefs_camera_upload_status_custom_mobile_data_limit, mobileDataLimitBytes) } + + private const val MAX_SOURCE_STATUS_LINES = 3 +} + +internal data class AutomaticUploadStatus( + val runState: AutomaticUploadRunState, + val sourceStatuses: List, + val storageWarning: AutomaticUploadStorageWarning, +) + +internal data class AutomaticUploadSourceStatus( + val sourcePath: String, + val lastSyncTimestamp: Long, + val state: AutomaticUploadSourceState, +) + +internal enum class AutomaticUploadRunState { + OFF, + NEEDS_SOURCE, + NEEDS_SOURCE_ACCESS, + WAITING_FOR_FIRST_SCAN, + READY, +} + +internal enum class AutomaticUploadSourceState { + NEEDS_ACCESS, + WAITING_FOR_FIRST_SCAN, + SCANNED, +} + +internal enum class AutomaticUploadStorageWarning { + NONE, + NEARING, + CRITICAL, + EXCEEDED, } diff --git a/Android/qsferaApp/src/main/java/eu/qsfera/android/presentation/settings/automaticuploads/SettingsPictureUploadsFragment.kt b/Android/qsferaApp/src/main/java/eu/qsfera/android/presentation/settings/automaticuploads/SettingsPictureUploadsFragment.kt index f8e1a0f1..ff192547 100644 --- a/Android/qsferaApp/src/main/java/eu/qsfera/android/presentation/settings/automaticuploads/SettingsPictureUploadsFragment.kt +++ b/Android/qsferaApp/src/main/java/eu/qsfera/android/presentation/settings/automaticuploads/SettingsPictureUploadsFragment.kt @@ -31,6 +31,7 @@ import android.provider.DocumentsContract import android.view.View import androidx.activity.result.contract.ActivityResultContracts import androidx.core.net.toUri +import androidx.documentfile.provider.DocumentFile import androidx.lifecycle.Lifecycle import androidx.lifecycle.lifecycleScope import androidx.lifecycle.repeatOnLifecycle @@ -164,13 +165,19 @@ class SettingsPictureUploadsFragment : PreferenceFragmentCompat() { prefPictureUploadsAccount?.value = it.accountName prefPictureUploadsPath?.summary = picturesViewModel.getUploadPathString() val sourcePaths = picturesViewModel.getPictureUploadsSourcePaths() + val sourcePathLabels = getSourcePathLabels(sourcePaths) prefPictureUploadsStatus?.summary = AutomaticUploadStatusFormatter.format( context = requireContext(), configuration = it, sourcePaths = sourcePaths, lastSyncSummary = formatLastSyncSummary(it.lastSyncTimestamp), + unreadableSourcePaths = getUnreadableSourcePaths(sourcePaths), + userQuota = availableAccounts.firstOrNull { userQuota -> + userQuota.accountName == it.accountName + }, + sourcePathLabels = sourcePathLabels, ) - prefPictureUploadsSourcePath?.summary = getSourcePathsSummary(sourcePaths) + prefPictureUploadsSourcePath?.summary = getSourcePathsSummary(sourcePathLabels) prefPictureUploadsClearSourcePaths?.isEnabled = sourcePaths.isNotEmpty() prefPictureUploadsOnWifi?.isChecked = it.wifiOnly prefPictureUploadsAllowRoaming?.isChecked = it.allowRoaming @@ -340,13 +347,32 @@ class SettingsPictureUploadsFragment : PreferenceFragmentCompat() { prefPictureUploadsLastSync?.summary = getString(R.string.prefs_camera_upload_last_sync_never) } - private fun getSourcePathsSummary(sourcePaths: List): String = - if (sourcePaths.isEmpty()) { + private fun getSourcePathsSummary(sourcePathLabels: Map): String = + if (sourcePathLabels.isEmpty()) { getString(R.string.prefs_camera_upload_source_paths_empty) } else { - sourcePaths.joinToString(separator = "\n") { sourcePath -> - DisplayUtils.getPathWithoutLastSlash(sourcePath.toUri().path) - } + sourcePathLabels.values.joinToString(separator = "\n") + } + + private fun getSourcePathLabels(sourcePaths: List): Map = + sourcePaths.associateWith { sourcePath -> + DisplayUtils.getPathWithoutLastSlash(sourcePath.toUri().path) + } + + private fun getUnreadableSourcePaths(sourcePaths: List): Set = + sourcePaths.filterTo(linkedSetOf()) { sourcePath -> + !canReadSourcePath(sourcePath) + } + + private fun canReadSourcePath(sourcePath: String): Boolean = + try { + val sourceUri = sourcePath.toUri() + val hasPersistedReadPermission = requireContext().contentResolver.persistedUriPermissions + .any { uriPermission -> uriPermission.uri == sourceUri && uriPermission.isReadPermission } + val documentTree = DocumentFile.fromTreeUri(requireContext(), sourceUri) + hasPersistedReadPermission && documentTree?.canRead() == true + } catch (_: RuntimeException) { + false } private fun formatLastSyncSummary(lastSyncTimestamp: Long): String = diff --git a/Android/qsferaApp/src/main/java/eu/qsfera/android/presentation/settings/automaticuploads/SettingsVideoUploadsFragment.kt b/Android/qsferaApp/src/main/java/eu/qsfera/android/presentation/settings/automaticuploads/SettingsVideoUploadsFragment.kt index 0ff44ecc..f12aef8a 100644 --- a/Android/qsferaApp/src/main/java/eu/qsfera/android/presentation/settings/automaticuploads/SettingsVideoUploadsFragment.kt +++ b/Android/qsferaApp/src/main/java/eu/qsfera/android/presentation/settings/automaticuploads/SettingsVideoUploadsFragment.kt @@ -31,6 +31,7 @@ import android.provider.DocumentsContract import android.view.View import androidx.activity.result.contract.ActivityResultContracts import androidx.core.net.toUri +import androidx.documentfile.provider.DocumentFile import androidx.lifecycle.Lifecycle import androidx.lifecycle.lifecycleScope import androidx.lifecycle.repeatOnLifecycle @@ -162,13 +163,19 @@ class SettingsVideoUploadsFragment : PreferenceFragmentCompat() { prefVideoUploadsAccount?.value = it.accountName prefVideoUploadsPath?.summary = videosViewModel.getUploadPathString() val sourcePaths = videosViewModel.getVideoUploadsSourcePaths() + val sourcePathLabels = getSourcePathLabels(sourcePaths) prefVideoUploadsStatus?.summary = AutomaticUploadStatusFormatter.format( context = requireContext(), configuration = it, sourcePaths = sourcePaths, lastSyncSummary = formatLastSyncSummary(it.lastSyncTimestamp), + unreadableSourcePaths = getUnreadableSourcePaths(sourcePaths), + userQuota = availableAccounts.firstOrNull { userQuota -> + userQuota.accountName == it.accountName + }, + sourcePathLabels = sourcePathLabels, ) - prefVideoUploadsSourcePath?.summary = getSourcePathsSummary(sourcePaths) + prefVideoUploadsSourcePath?.summary = getSourcePathsSummary(sourcePathLabels) prefVideoUploadsClearSourcePaths?.isEnabled = sourcePaths.isNotEmpty() prefVideoUploadsOnWifi?.isChecked = it.wifiOnly prefVideoUploadsAllowRoaming?.isChecked = it.allowRoaming @@ -338,13 +345,32 @@ class SettingsVideoUploadsFragment : PreferenceFragmentCompat() { prefVideoUploadsLastSync?.summary = getString(R.string.prefs_camera_upload_last_sync_never) } - private fun getSourcePathsSummary(sourcePaths: List): String = - if (sourcePaths.isEmpty()) { + private fun getSourcePathsSummary(sourcePathLabels: Map): String = + if (sourcePathLabels.isEmpty()) { getString(R.string.prefs_camera_upload_source_paths_empty) } else { - sourcePaths.joinToString(separator = "\n") { sourcePath -> - DisplayUtils.getPathWithoutLastSlash(sourcePath.toUri().path) - } + sourcePathLabels.values.joinToString(separator = "\n") + } + + private fun getSourcePathLabels(sourcePaths: List): Map = + sourcePaths.associateWith { sourcePath -> + DisplayUtils.getPathWithoutLastSlash(sourcePath.toUri().path) + } + + private fun getUnreadableSourcePaths(sourcePaths: List): Set = + sourcePaths.filterTo(linkedSetOf()) { sourcePath -> + !canReadSourcePath(sourcePath) + } + + private fun canReadSourcePath(sourcePath: String): Boolean = + try { + val sourceUri = sourcePath.toUri() + val hasPersistedReadPermission = requireContext().contentResolver.persistedUriPermissions + .any { uriPermission -> uriPermission.uri == sourceUri && uriPermission.isReadPermission } + val documentTree = DocumentFile.fromTreeUri(requireContext(), sourceUri) + hasPersistedReadPermission && documentTree?.canRead() == true + } catch (_: RuntimeException) { + false } private fun formatLastSyncSummary(lastSyncTimestamp: Long): String = diff --git a/Android/qsferaApp/src/main/res/values-ru/strings.xml b/Android/qsferaApp/src/main/res/values-ru/strings.xml index b8339f8d..665b926c 100644 --- a/Android/qsferaApp/src/main/res/values-ru/strings.xml +++ b/Android/qsferaApp/src/main/res/values-ru/strings.xml @@ -566,18 +566,29 @@ Состояние копирования Выключено Включено + Готово к следующей проверке + Добавьте папку телефона, чтобы начать копирование + Одной или нескольким папкам телефона снова нужен доступ + Ожидает первой проверки копирования %d папка телефона %d папки телефона %d папок телефона %d папки телефона + %1$s: последняя проверка %2$s + %1$s: ожидает первой проверки + %1$s: нет доступа + Ещё папок не показано: %1$d Только Wi-Fi Мобильные данные: %1$s %1$d байт в день роуминг разрешён только во время зарядки Последняя проверка: %1$s + Место в учётной записи почти закончилось; копирование может скоро остановиться + Место в учётной записи почти полностью занято; копирование может завершиться ошибкой + Место в учётной записи закончилось; новые копии не загрузятся, пока не освободится место Разрешить копирование в роуминге Применяется только если копирование только по Wi-Fi выключено Исходный файл после копирования diff --git a/Android/qsferaApp/src/main/res/values/strings.xml b/Android/qsferaApp/src/main/res/values/strings.xml index da90b72c..79a80e94 100644 --- a/Android/qsferaApp/src/main/res/values/strings.xml +++ b/Android/qsferaApp/src/main/res/values/strings.xml @@ -605,16 +605,27 @@ Backup status Off On + Ready for the next scan + Add a phone folder to start backup + One or more phone folders need access again + Waiting for the first backup scan %d phone folder %d phone folders + %1$s: last scanned %2$s + %1$s: waiting for first scan + %1$s: access missing + %1$d more folders not shown Wi-Fi only Mobile data: %1$s %1$d bytes per day roaming allowed charging only Last scan: %1$s + Account storage is nearly full; backup may stop soon + Account storage is very nearly full; backup may fail + Account storage is full; new backups will fail until space is freed Allow backup while roaming Only applies when Wi-Fi-only backup is off Mobile data daily limit diff --git a/Android/qsferaApp/src/test/java/eu/qsfera/android/presentation/settings/automaticuploads/AutomaticUploadStatusFormatterTest.kt b/Android/qsferaApp/src/test/java/eu/qsfera/android/presentation/settings/automaticuploads/AutomaticUploadStatusFormatterTest.kt new file mode 100644 index 00000000..01051ce5 --- /dev/null +++ b/Android/qsferaApp/src/test/java/eu/qsfera/android/presentation/settings/automaticuploads/AutomaticUploadStatusFormatterTest.kt @@ -0,0 +1,146 @@ +package eu.qsfera.android.presentation.settings.automaticuploads + +import eu.qsfera.android.domain.automaticuploads.model.FolderBackUpConfiguration +import eu.qsfera.android.domain.automaticuploads.model.FolderBackUpConfiguration.Companion.encodeSourcePathSyncTimestamps +import eu.qsfera.android.domain.automaticuploads.model.FolderBackUpConfiguration.Companion.encodeSourcePaths +import eu.qsfera.android.domain.automaticuploads.model.UploadBehavior +import eu.qsfera.android.domain.user.model.UserQuota +import eu.qsfera.android.domain.user.model.UserQuotaState +import org.junit.Assert.assertEquals +import org.junit.Test + +class AutomaticUploadStatusFormatterTest { + + @Test + fun `statusFor reports disabled backup when configuration is missing`() { + val status = AutomaticUploadStatusFormatter.statusFor( + configuration = null, + sourcePaths = emptyList(), + ) + + assertEquals(AutomaticUploadRunState.OFF, status.runState) + assertEquals(emptyList(), status.sourceStatuses) + assertEquals(AutomaticUploadStorageWarning.NONE, status.storageWarning) + } + + @Test + fun `statusFor asks for source folder when backup is enabled without sources`() { + val status = AutomaticUploadStatusFormatter.statusFor( + configuration = backupConfiguration(sourcePaths = emptyList()), + sourcePaths = emptyList(), + ) + + assertEquals(AutomaticUploadRunState.NEEDS_SOURCE, status.runState) + assertEquals(emptyList(), status.sourceStatuses) + } + + @Test + fun `statusFor reports per-source scanned and waiting states`() { + val camera = "content://camera" + val screenshots = "content://screenshots" + + val status = AutomaticUploadStatusFormatter.statusFor( + configuration = backupConfiguration( + sourcePaths = listOf(camera, screenshots), + sourceSyncTimestamps = mapOf(camera to 1_000L), + ), + sourcePaths = listOf(camera, screenshots), + ) + + assertEquals(AutomaticUploadRunState.WAITING_FOR_FIRST_SCAN, status.runState) + assertEquals( + listOf( + AutomaticUploadSourceStatus(camera, 1_000L, AutomaticUploadSourceState.SCANNED), + AutomaticUploadSourceStatus(screenshots, 0L, AutomaticUploadSourceState.WAITING_FOR_FIRST_SCAN), + ), + status.sourceStatuses + ) + } + + @Test + fun `statusFor reports missing source access before timestamp state`() { + val camera = "content://camera" + + val status = AutomaticUploadStatusFormatter.statusFor( + configuration = backupConfiguration( + sourcePaths = listOf(camera), + sourceSyncTimestamps = mapOf(camera to 1_000L), + ), + sourcePaths = listOf(camera), + unreadableSourcePaths = setOf(camera), + ) + + assertEquals(AutomaticUploadRunState.NEEDS_SOURCE_ACCESS, status.runState) + assertEquals( + listOf(AutomaticUploadSourceStatus(camera, 1_000L, AutomaticUploadSourceState.NEEDS_ACCESS)), + status.sourceStatuses + ) + } + + @Test + fun `statusFor reports storage warnings from selected account quota`() { + assertEquals( + AutomaticUploadStorageWarning.NEARING, + AutomaticUploadStatusFormatter.statusFor( + configuration = backupConfiguration(), + sourcePaths = listOf(DEFAULT_SOURCE_PATH), + userQuota = quota(state = UserQuotaState.NEARING), + ).storageWarning + ) + assertEquals( + AutomaticUploadStorageWarning.CRITICAL, + AutomaticUploadStatusFormatter.statusFor( + configuration = backupConfiguration(), + sourcePaths = listOf(DEFAULT_SOURCE_PATH), + userQuota = quota(state = UserQuotaState.CRITICAL), + ).storageWarning + ) + assertEquals( + AutomaticUploadStorageWarning.EXCEEDED, + AutomaticUploadStatusFormatter.statusFor( + configuration = backupConfiguration(), + sourcePaths = listOf(DEFAULT_SOURCE_PATH), + userQuota = quota(available = 0L, state = UserQuotaState.NORMAL), + ).storageWarning + ) + } + + private fun backupConfiguration( + sourcePaths: List = listOf(DEFAULT_SOURCE_PATH), + sourceSyncTimestamps: Map = emptyMap(), + lastSyncTimestamp: Long = 0L, + ): FolderBackUpConfiguration = + FolderBackUpConfiguration( + accountName = ACCOUNT_NAME, + behavior = UploadBehavior.COPY, + sourcePath = encodeSourcePaths(sourcePaths), + uploadPath = "/Photos", + wifiOnly = true, + allowRoaming = false, + mobileDataLimitBytes = 0L, + mobileDataPeriodStartTimestamp = 0L, + mobileDataBytesQueuedInPeriod = 0L, + chargingOnly = false, + lastSyncTimestamp = lastSyncTimestamp, + sourcePathSyncTimestamps = encodeSourcePathSyncTimestamps(sourceSyncTimestamps), + name = FolderBackUpConfiguration.pictureUploadsName, + spaceId = null, + ) + + private fun quota( + available: Long = 1_000L, + state: UserQuotaState = UserQuotaState.NORMAL, + ): UserQuota = + UserQuota( + accountName = ACCOUNT_NAME, + available = available, + used = 1_000L, + total = 2_000L, + state = state, + ) + + private companion object { + const val ACCOUNT_NAME = "user@example.com" + const val DEFAULT_SOURCE_PATH = "content://camera" + } +} diff --git a/PRODUCTION_READINESS.md b/PRODUCTION_READINESS.md index 9c9cb05f..74fa8ca0 100644 --- a/PRODUCTION_READINESS.md +++ b/PRODUCTION_READINESS.md @@ -37,6 +37,7 @@ Date: 2026-06-08. - 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. +- Android photo/video backup status now shows per-source scan state, missing SAF folder access, and selected-account low-storage warnings in the settings summary. `:qsferaApp:testOriginalDebugUnitTest --tests "eu.qsfera.android.presentation.settings.automaticuploads.AutomaticUploadStatusFormatterTest"` and `:qsferaApp:compileOriginalDebugKotlin` passed on 2026-06-10. - Android public-link rows now show the effective access mode, password state, and expiration state directly in the share list, so users can review link safety without reopening each link. `:qsferaApp:testOriginalDebugUnitTest --tests "eu.qsfera.android.presentation.sharing.shares.PublicShareStatusFormatterTest"` and `:qsferaApp:compileOriginalDebugKotlin` passed on 2026-06-10. - Sharing controls were audited against the Google Drive and Yandex Disk benchmarks in `Server/docs/sharing-controls-production-audit.md`, and `scripts/verify-sharing-controls-coverage.ps1` now checks local Android UI, Graph link types, server link handling, and acceptance-feature coverage for password, expiration, access roles, internal links, secure-view/no-download behavior, and role-based resharing. - Server `qsfera_full` compose files now require explicit admin, Keycloak, and S3/MinIO secrets instead of demo defaults. @@ -90,6 +91,6 @@ 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. +- Extend Android backup status UX with live WorkManager/transfer queue counts and current paused-by-network/battery state. Per-source scan state, missing folder access, and low-storage warnings are now shown in settings. - 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. - Decide and implement Android-facing controls for Graph `internal` links and `blocksDownload` secure-view links, or explicitly keep them server-managed/custom-access states. Server/API coverage is documented in `Server/docs/sharing-controls-production-audit.md`; the Android public-link dialog still exposes only password, expiration, read-only, read/write, and upload-only OCS controls.