Show detailed Android backup status
This commit is contained in:
+173
-5
@@ -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,
|
||||
}
|
||||
|
||||
+32
-6
@@ -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 =
|
||||
|
||||
+32
-6
@@ -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>
|
||||
|
||||
+146
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user