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 android.content.Context
|
||||||
import eu.qsfera.android.R
|
import eu.qsfera.android.R
|
||||||
import eu.qsfera.android.domain.automaticuploads.model.FolderBackUpConfiguration
|
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 {
|
internal object AutomaticUploadStatusFormatter {
|
||||||
|
|
||||||
@@ -21,17 +24,147 @@ internal object AutomaticUploadStatusFormatter {
|
|||||||
configuration: FolderBackUpConfiguration?,
|
configuration: FolderBackUpConfiguration?,
|
||||||
sourcePaths: List<String>,
|
sourcePaths: List<String>,
|
||||||
lastSyncSummary: String,
|
lastSyncSummary: String,
|
||||||
|
unreadableSourcePaths: Set<String> = emptySet(),
|
||||||
|
userQuota: UserQuota? = null,
|
||||||
|
sourcePathLabels: Map<String, String> = emptyMap(),
|
||||||
): String {
|
): 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)
|
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(
|
return listOf(
|
||||||
context.getString(R.string.prefs_camera_upload_status_on),
|
context.getString(R.string.prefs_camera_upload_status_on),
|
||||||
formatSourceCount(context, sourcePaths.size),
|
formatRunState(context, status.runState),
|
||||||
formatConditions(context, configuration),
|
formatSourceCount(context, status.sourceStatuses.size),
|
||||||
|
formatSourceDetails(context, status.sourceStatuses, sourcePathLabels),
|
||||||
|
formatConditions(context, enabledConfiguration),
|
||||||
context.getString(R.string.prefs_camera_upload_status_last_scan, lastSyncSummary),
|
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 =
|
private fun formatSourceCount(context: Context, sourceCount: Int): String =
|
||||||
@@ -61,7 +194,7 @@ internal object AutomaticUploadStatusFormatter {
|
|||||||
if (configuration.chargingOnly) {
|
if (configuration.chargingOnly) {
|
||||||
conditions += context.getString(R.string.prefs_camera_upload_status_charging_only)
|
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 {
|
private fun formatMobileDataLimit(context: Context, mobileDataLimitBytes: Long): String {
|
||||||
@@ -71,4 +204,39 @@ internal object AutomaticUploadStatusFormatter {
|
|||||||
return entries.getOrNull(index)
|
return entries.getOrNull(index)
|
||||||
?: context.getString(R.string.prefs_camera_upload_status_custom_mobile_data_limit, mobileDataLimitBytes)
|
?: 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,
|
||||||
}
|
}
|
||||||
|
|||||||
+30
-4
@@ -31,6 +31,7 @@ import android.provider.DocumentsContract
|
|||||||
import android.view.View
|
import android.view.View
|
||||||
import androidx.activity.result.contract.ActivityResultContracts
|
import androidx.activity.result.contract.ActivityResultContracts
|
||||||
import androidx.core.net.toUri
|
import androidx.core.net.toUri
|
||||||
|
import androidx.documentfile.provider.DocumentFile
|
||||||
import androidx.lifecycle.Lifecycle
|
import androidx.lifecycle.Lifecycle
|
||||||
import androidx.lifecycle.lifecycleScope
|
import androidx.lifecycle.lifecycleScope
|
||||||
import androidx.lifecycle.repeatOnLifecycle
|
import androidx.lifecycle.repeatOnLifecycle
|
||||||
@@ -164,13 +165,19 @@ class SettingsPictureUploadsFragment : PreferenceFragmentCompat() {
|
|||||||
prefPictureUploadsAccount?.value = it.accountName
|
prefPictureUploadsAccount?.value = it.accountName
|
||||||
prefPictureUploadsPath?.summary = picturesViewModel.getUploadPathString()
|
prefPictureUploadsPath?.summary = picturesViewModel.getUploadPathString()
|
||||||
val sourcePaths = picturesViewModel.getPictureUploadsSourcePaths()
|
val sourcePaths = picturesViewModel.getPictureUploadsSourcePaths()
|
||||||
|
val sourcePathLabels = getSourcePathLabels(sourcePaths)
|
||||||
prefPictureUploadsStatus?.summary = AutomaticUploadStatusFormatter.format(
|
prefPictureUploadsStatus?.summary = AutomaticUploadStatusFormatter.format(
|
||||||
context = requireContext(),
|
context = requireContext(),
|
||||||
configuration = it,
|
configuration = it,
|
||||||
sourcePaths = sourcePaths,
|
sourcePaths = sourcePaths,
|
||||||
lastSyncSummary = formatLastSyncSummary(it.lastSyncTimestamp),
|
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()
|
prefPictureUploadsClearSourcePaths?.isEnabled = sourcePaths.isNotEmpty()
|
||||||
prefPictureUploadsOnWifi?.isChecked = it.wifiOnly
|
prefPictureUploadsOnWifi?.isChecked = it.wifiOnly
|
||||||
prefPictureUploadsAllowRoaming?.isChecked = it.allowRoaming
|
prefPictureUploadsAllowRoaming?.isChecked = it.allowRoaming
|
||||||
@@ -340,13 +347,32 @@ class SettingsPictureUploadsFragment : PreferenceFragmentCompat() {
|
|||||||
prefPictureUploadsLastSync?.summary = getString(R.string.prefs_camera_upload_last_sync_never)
|
prefPictureUploadsLastSync?.summary = getString(R.string.prefs_camera_upload_last_sync_never)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun getSourcePathsSummary(sourcePaths: List<String>): String =
|
private fun getSourcePathsSummary(sourcePathLabels: Map<String, String>): String =
|
||||||
if (sourcePaths.isEmpty()) {
|
if (sourcePathLabels.isEmpty()) {
|
||||||
getString(R.string.prefs_camera_upload_source_paths_empty)
|
getString(R.string.prefs_camera_upload_source_paths_empty)
|
||||||
} else {
|
} else {
|
||||||
sourcePaths.joinToString(separator = "\n") { sourcePath ->
|
sourcePathLabels.values.joinToString(separator = "\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun getSourcePathLabels(sourcePaths: List<String>): Map<String, String> =
|
||||||
|
sourcePaths.associateWith { sourcePath ->
|
||||||
DisplayUtils.getPathWithoutLastSlash(sourcePath.toUri().path)
|
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 =
|
private fun formatLastSyncSummary(lastSyncTimestamp: Long): String =
|
||||||
|
|||||||
+30
-4
@@ -31,6 +31,7 @@ import android.provider.DocumentsContract
|
|||||||
import android.view.View
|
import android.view.View
|
||||||
import androidx.activity.result.contract.ActivityResultContracts
|
import androidx.activity.result.contract.ActivityResultContracts
|
||||||
import androidx.core.net.toUri
|
import androidx.core.net.toUri
|
||||||
|
import androidx.documentfile.provider.DocumentFile
|
||||||
import androidx.lifecycle.Lifecycle
|
import androidx.lifecycle.Lifecycle
|
||||||
import androidx.lifecycle.lifecycleScope
|
import androidx.lifecycle.lifecycleScope
|
||||||
import androidx.lifecycle.repeatOnLifecycle
|
import androidx.lifecycle.repeatOnLifecycle
|
||||||
@@ -162,13 +163,19 @@ class SettingsVideoUploadsFragment : PreferenceFragmentCompat() {
|
|||||||
prefVideoUploadsAccount?.value = it.accountName
|
prefVideoUploadsAccount?.value = it.accountName
|
||||||
prefVideoUploadsPath?.summary = videosViewModel.getUploadPathString()
|
prefVideoUploadsPath?.summary = videosViewModel.getUploadPathString()
|
||||||
val sourcePaths = videosViewModel.getVideoUploadsSourcePaths()
|
val sourcePaths = videosViewModel.getVideoUploadsSourcePaths()
|
||||||
|
val sourcePathLabels = getSourcePathLabels(sourcePaths)
|
||||||
prefVideoUploadsStatus?.summary = AutomaticUploadStatusFormatter.format(
|
prefVideoUploadsStatus?.summary = AutomaticUploadStatusFormatter.format(
|
||||||
context = requireContext(),
|
context = requireContext(),
|
||||||
configuration = it,
|
configuration = it,
|
||||||
sourcePaths = sourcePaths,
|
sourcePaths = sourcePaths,
|
||||||
lastSyncSummary = formatLastSyncSummary(it.lastSyncTimestamp),
|
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()
|
prefVideoUploadsClearSourcePaths?.isEnabled = sourcePaths.isNotEmpty()
|
||||||
prefVideoUploadsOnWifi?.isChecked = it.wifiOnly
|
prefVideoUploadsOnWifi?.isChecked = it.wifiOnly
|
||||||
prefVideoUploadsAllowRoaming?.isChecked = it.allowRoaming
|
prefVideoUploadsAllowRoaming?.isChecked = it.allowRoaming
|
||||||
@@ -338,13 +345,32 @@ class SettingsVideoUploadsFragment : PreferenceFragmentCompat() {
|
|||||||
prefVideoUploadsLastSync?.summary = getString(R.string.prefs_camera_upload_last_sync_never)
|
prefVideoUploadsLastSync?.summary = getString(R.string.prefs_camera_upload_last_sync_never)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun getSourcePathsSummary(sourcePaths: List<String>): String =
|
private fun getSourcePathsSummary(sourcePathLabels: Map<String, String>): String =
|
||||||
if (sourcePaths.isEmpty()) {
|
if (sourcePathLabels.isEmpty()) {
|
||||||
getString(R.string.prefs_camera_upload_source_paths_empty)
|
getString(R.string.prefs_camera_upload_source_paths_empty)
|
||||||
} else {
|
} else {
|
||||||
sourcePaths.joinToString(separator = "\n") { sourcePath ->
|
sourcePathLabels.values.joinToString(separator = "\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun getSourcePathLabels(sourcePaths: List<String>): Map<String, String> =
|
||||||
|
sourcePaths.associateWith { sourcePath ->
|
||||||
DisplayUtils.getPathWithoutLastSlash(sourcePath.toUri().path)
|
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 =
|
private fun formatLastSyncSummary(lastSyncTimestamp: Long): String =
|
||||||
|
|||||||
@@ -566,18 +566,29 @@
|
|||||||
<string name="prefs_camera_upload_status_title">Состояние копирования</string>
|
<string name="prefs_camera_upload_status_title">Состояние копирования</string>
|
||||||
<string name="prefs_camera_upload_status_off">Выключено</string>
|
<string name="prefs_camera_upload_status_off">Выключено</string>
|
||||||
<string name="prefs_camera_upload_status_on">Включено</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">
|
<plurals name="prefs_camera_upload_status_source_count">
|
||||||
<item quantity="one">%d папка телефона</item>
|
<item quantity="one">%d папка телефона</item>
|
||||||
<item quantity="few">%d папки телефона</item>
|
<item quantity="few">%d папки телефона</item>
|
||||||
<item quantity="many">%d папок телефона</item>
|
<item quantity="many">%d папок телефона</item>
|
||||||
<item quantity="other">%d папки телефона</item>
|
<item quantity="other">%d папки телефона</item>
|
||||||
</plurals>
|
</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_wifi_only">Только Wi-Fi</string>
|
||||||
<string name="prefs_camera_upload_status_mobile_data">Мобильные данные: %1$s</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_custom_mobile_data_limit">%1$d байт в день</string>
|
||||||
<string name="prefs_camera_upload_status_roaming_allowed">роуминг разрешён</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_charging_only">только во время зарядки</string>
|
||||||
<string name="prefs_camera_upload_status_last_scan">Последняя проверка: %1$s</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">Разрешить копирование в роуминге</string>
|
||||||
<string name="prefs_camera_upload_allow_roaming_summary">Применяется только если копирование только по Wi-Fi выключено</string>
|
<string name="prefs_camera_upload_allow_roaming_summary">Применяется только если копирование только по Wi-Fi выключено</string>
|
||||||
<string name="prefs_camera_upload_behaviour_dialog_title">Исходный файл после копирования</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_title">Backup status</string>
|
||||||
<string name="prefs_camera_upload_status_off">Off</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_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">
|
<plurals name="prefs_camera_upload_status_source_count">
|
||||||
<item quantity="one">%d phone folder</item>
|
<item quantity="one">%d phone folder</item>
|
||||||
<item quantity="other">%d phone folders</item>
|
<item quantity="other">%d phone folders</item>
|
||||||
</plurals>
|
</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_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_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_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_roaming_allowed">roaming allowed</string>
|
||||||
<string name="prefs_camera_upload_status_charging_only">charging only</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_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">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_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>
|
<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 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 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 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.
|
- 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.
|
- 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.
|
- 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
|
## 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.
|
- 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.
|
- 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