Show detailed Android backup status

This commit is contained in:
Курнат Андрей
2026-06-10 08:08:00 +03:00
parent b3016e6c99
commit 439cf82893
7 changed files with 407 additions and 18 deletions
@@ -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<String>,
lastSyncSummary: String,
unreadableSourcePaths: Set<String> = emptySet(),
userQuota: UserQuota? = null,
sourcePathLabels: Map<String, String> = 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<String>,
unreadableSourcePaths: Set<String> = 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<AutomaticUploadSourceStatus>,
sourcePathLabels: Map<String, String>,
): 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<AutomaticUploadSourceStatus>,
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,
}
@@ -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>): String =
if (sourcePaths.isEmpty()) {
private fun getSourcePathsSummary(sourcePathLabels: Map<String, String>): 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<String>): Map<String, String> =
sourcePaths.associateWith { sourcePath ->
DisplayUtils.getPathWithoutLastSlash(sourcePath.toUri().path)
}
private fun getUnreadableSourcePaths(sourcePaths: List<String>): Set<String> =
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 =
@@ -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>): String =
if (sourcePaths.isEmpty()) {
private fun getSourcePathsSummary(sourcePathLabels: Map<String, String>): 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<String>): Map<String, String> =
sourcePaths.associateWith { sourcePath ->
DisplayUtils.getPathWithoutLastSlash(sourcePath.toUri().path)
}
private fun getUnreadableSourcePaths(sourcePaths: List<String>): Set<String> =
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 =
@@ -566,18 +566,29 @@
<string name="prefs_camera_upload_status_title">Состояние копирования</string>
<string name="prefs_camera_upload_status_off">Выключено</string>
<string name="prefs_camera_upload_status_on">Включено</string>
<string name="prefs_camera_upload_status_ready">Готово к следующей проверке</string>
<string name="prefs_camera_upload_status_needs_source">Добавьте папку телефона, чтобы начать копирование</string>
<string name="prefs_camera_upload_status_needs_source_access">Одной или нескольким папкам телефона снова нужен доступ</string>
<string name="prefs_camera_upload_status_waiting_first_scan">Ожидает первой проверки копирования</string>
<plurals name="prefs_camera_upload_status_source_count">
<item quantity="one">%d папка телефона</item>
<item quantity="few">%d папки телефона</item>
<item quantity="many">%d папок телефона</item>
<item quantity="other">%d папки телефона</item>
</plurals>
<string name="prefs_camera_upload_status_source_scanned">%1$s: последняя проверка %2$s</string>
<string name="prefs_camera_upload_status_source_waiting">%1$s: ожидает первой проверки</string>
<string name="prefs_camera_upload_status_source_needs_access">%1$s: нет доступа</string>
<string name="prefs_camera_upload_status_more_sources">Ещё папок не показано: %1$d</string>
<string name="prefs_camera_upload_status_wifi_only">Только Wi-Fi</string>
<string name="prefs_camera_upload_status_mobile_data">Мобильные данные: %1$s</string>
<string name="prefs_camera_upload_status_custom_mobile_data_limit">%1$d байт в день</string>
<string name="prefs_camera_upload_status_roaming_allowed">роуминг разрешён</string>
<string name="prefs_camera_upload_status_charging_only">только во время зарядки</string>
<string name="prefs_camera_upload_status_last_scan">Последняя проверка: %1$s</string>
<string name="prefs_camera_upload_status_storage_nearing">Место в учётной записи почти закончилось; копирование может скоро остановиться</string>
<string name="prefs_camera_upload_status_storage_critical">Место в учётной записи почти полностью занято; копирование может завершиться ошибкой</string>
<string name="prefs_camera_upload_status_storage_exceeded">Место в учётной записи закончилось; новые копии не загрузятся, пока не освободится место</string>
<string name="prefs_camera_upload_allow_roaming">Разрешить копирование в роуминге</string>
<string name="prefs_camera_upload_allow_roaming_summary">Применяется только если копирование только по Wi-Fi выключено</string>
<string name="prefs_camera_upload_behaviour_dialog_title">Исходный файл после копирования</string>
@@ -605,16 +605,27 @@
<string name="prefs_camera_upload_status_title">Backup status</string>
<string name="prefs_camera_upload_status_off">Off</string>
<string name="prefs_camera_upload_status_on">On</string>
<string name="prefs_camera_upload_status_ready">Ready for the next scan</string>
<string name="prefs_camera_upload_status_needs_source">Add a phone folder to start backup</string>
<string name="prefs_camera_upload_status_needs_source_access">One or more phone folders need access again</string>
<string name="prefs_camera_upload_status_waiting_first_scan">Waiting for the first backup scan</string>
<plurals name="prefs_camera_upload_status_source_count">
<item quantity="one">%d phone folder</item>
<item quantity="other">%d phone folders</item>
</plurals>
<string name="prefs_camera_upload_status_source_scanned">%1$s: last scanned %2$s</string>
<string name="prefs_camera_upload_status_source_waiting">%1$s: waiting for first scan</string>
<string name="prefs_camera_upload_status_source_needs_access">%1$s: access missing</string>
<string name="prefs_camera_upload_status_more_sources">%1$d more folders not shown</string>
<string name="prefs_camera_upload_status_wifi_only">Wi-Fi only</string>
<string name="prefs_camera_upload_status_mobile_data">Mobile data: %1$s</string>
<string name="prefs_camera_upload_status_custom_mobile_data_limit">%1$d bytes per day</string>
<string name="prefs_camera_upload_status_roaming_allowed">roaming allowed</string>
<string name="prefs_camera_upload_status_charging_only">charging only</string>
<string name="prefs_camera_upload_status_last_scan">Last scan: %1$s</string>
<string name="prefs_camera_upload_status_storage_nearing">Account storage is nearly full; backup may stop soon</string>
<string name="prefs_camera_upload_status_storage_critical">Account storage is very nearly full; backup may fail</string>
<string name="prefs_camera_upload_status_storage_exceeded">Account storage is full; new backups will fail until space is freed</string>
<string name="prefs_camera_upload_allow_roaming">Allow backup while roaming</string>
<string name="prefs_camera_upload_allow_roaming_summary">Only applies when Wi-Fi-only backup is off</string>
<string name="prefs_camera_upload_mobile_data_limit_title">Mobile data daily limit</string>
@@ -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<AutomaticUploadSourceStatus>(), 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<AutomaticUploadSourceStatus>(), 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<String> = listOf(DEFAULT_SOURCE_PATH),
sourceSyncTimestamps: Map<String, Long> = 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"
}
}
+2 -1
View File
@@ -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.