Show live Android backup queue status

This commit is contained in:
Курнат Андрей
2026-06-10 08:29:25 +03:00
parent dfe31ac68b
commit c247af04a1
10 changed files with 394 additions and 63 deletions
@@ -0,0 +1,60 @@
/**
* qsfera Android client application
*
* Copyright (C) 2026 QSfera.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License version 2,
* as published by the Free Software Foundation.
*/
package eu.qsfera.android.presentation.settings.automaticuploads
import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import android.net.ConnectivityManager
import android.net.NetworkCapabilities
import android.os.BatteryManager
import android.os.Build
import eu.qsfera.android.domain.automaticuploads.model.FolderBackUpConfiguration
internal object AutomaticUploadRuntimeStatusResolver {
fun pauseReasons(
context: Context,
configuration: FolderBackUpConfiguration,
transferSummary: AutomaticUploadTransferSummary,
): Set<AutomaticUploadPauseReason> =
AutomaticUploadStatusFormatter.resolvePauseReasons(
configuration = configuration,
transferSummary = transferSummary,
isActiveNetworkMetered = isActiveNetworkMetered(context),
isActiveNetworkRoaming = isActiveNetworkRoaming(context),
isCharging = isCharging(context),
)
private fun isActiveNetworkMetered(context: Context): Boolean? {
val connectivityManager = context.getSystemService(Context.CONNECTIVITY_SERVICE) as? ConnectivityManager
?: return null
return connectivityManager.isActiveNetworkMetered
}
private fun isActiveNetworkRoaming(context: Context): Boolean? {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.P) {
return null
}
val connectivityManager = context.getSystemService(Context.CONNECTIVITY_SERVICE) as? ConnectivityManager
?: return null
val activeNetwork = connectivityManager.activeNetwork ?: return null
val networkCapabilities = connectivityManager.getNetworkCapabilities(activeNetwork) ?: return null
return !networkCapabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_NOT_ROAMING)
}
private fun isCharging(context: Context): Boolean {
val batteryStatus = context.registerReceiver(null, IntentFilter(Intent.ACTION_BATTERY_CHANGED))
val status = batteryStatus?.getIntExtra(BatteryManager.EXTRA_STATUS, -1) ?: -1
return status == BatteryManager.BATTERY_STATUS_CHARGING ||
status == BatteryManager.BATTERY_STATUS_FULL
}
}
@@ -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.transfers.model.OCTransfer
import eu.qsfera.android.domain.transfers.model.TransferStatus
import eu.qsfera.android.domain.transfers.model.UploadEnqueuedBy
import eu.qsfera.android.domain.user.model.UserQuota
import eu.qsfera.android.domain.user.model.UserQuotaState
import eu.qsfera.android.utils.DisplayUtils
@@ -27,6 +30,8 @@ internal object AutomaticUploadStatusFormatter {
unreadableSourcePaths: Set<String> = emptySet(),
userQuota: UserQuota? = null,
sourcePathLabels: Map<String, String> = emptyMap(),
transferSummary: AutomaticUploadTransferSummary = AutomaticUploadTransferSummary.EMPTY,
pauseReasons: Set<AutomaticUploadPauseReason> = emptySet(),
): String {
val status = statusFor(
configuration = configuration,
@@ -42,6 +47,8 @@ internal object AutomaticUploadStatusFormatter {
return listOf(
context.getString(R.string.prefs_camera_upload_status_on),
formatRunState(context, status.runState),
formatTransferSummary(context, transferSummary),
formatPauseReasons(context, pauseReasons),
formatSourceCount(context, status.sourceStatuses.size),
formatSourceDetails(context, status.sourceStatuses, sourcePathLabels),
formatConditions(context, enabledConfiguration),
@@ -95,6 +102,30 @@ internal object AutomaticUploadStatusFormatter {
)
}
fun resolvePauseReasons(
configuration: FolderBackUpConfiguration,
transferSummary: AutomaticUploadTransferSummary,
isActiveNetworkMetered: Boolean?,
isActiveNetworkRoaming: Boolean?,
isCharging: Boolean,
): Set<AutomaticUploadPauseReason> {
if (transferSummary.queuedCount == 0) {
return emptySet()
}
val reasons = linkedSetOf<AutomaticUploadPauseReason>()
if (configuration.wifiOnly && isActiveNetworkMetered != false) {
reasons += AutomaticUploadPauseReason.WAITING_FOR_UNMETERED_NETWORK
}
if (!configuration.wifiOnly && !configuration.allowRoaming && isActiveNetworkRoaming == true) {
reasons += AutomaticUploadPauseReason.WAITING_FOR_NOT_ROAMING_NETWORK
}
if (configuration.chargingOnly && !isCharging) {
reasons += AutomaticUploadPauseReason.WAITING_FOR_CHARGING
}
return reasons
}
private fun formatRunState(context: Context, runState: AutomaticUploadRunState): String =
when (runState) {
AutomaticUploadRunState.OFF -> context.getString(R.string.prefs_camera_upload_status_off)
@@ -106,6 +137,33 @@ internal object AutomaticUploadStatusFormatter {
AutomaticUploadRunState.READY -> context.getString(R.string.prefs_camera_upload_status_ready)
}
private fun formatTransferSummary(
context: Context,
transferSummary: AutomaticUploadTransferSummary,
): String =
if (transferSummary == AutomaticUploadTransferSummary.EMPTY) {
""
} else {
context.getString(
R.string.prefs_camera_upload_status_transfer_summary,
transferSummary.inProgressCount,
transferSummary.queuedCount,
transferSummary.failedCount
)
}
private fun formatPauseReasons(context: Context, pauseReasons: Set<AutomaticUploadPauseReason>): String =
pauseReasons.joinToString(separator = "\n") { pauseReason ->
when (pauseReason) {
AutomaticUploadPauseReason.WAITING_FOR_UNMETERED_NETWORK ->
context.getString(R.string.prefs_camera_upload_status_paused_unmetered)
AutomaticUploadPauseReason.WAITING_FOR_NOT_ROAMING_NETWORK ->
context.getString(R.string.prefs_camera_upload_status_paused_not_roaming)
AutomaticUploadPauseReason.WAITING_FOR_CHARGING ->
context.getString(R.string.prefs_camera_upload_status_paused_charging)
}
}
private fun formatSourceDetails(
context: Context,
sourceStatuses: List<AutomaticUploadSourceStatus>,
@@ -240,3 +298,41 @@ internal enum class AutomaticUploadStorageWarning {
CRITICAL,
EXCEEDED,
}
internal data class AutomaticUploadTransferSummary(
val inProgressCount: Int,
val queuedCount: Int,
val failedCount: Int,
) {
companion object {
val EMPTY = AutomaticUploadTransferSummary(
inProgressCount = 0,
queuedCount = 0,
failedCount = 0,
)
fun fromTransfers(
transfers: List<OCTransfer>,
createdBy: UploadEnqueuedBy,
): AutomaticUploadTransferSummary {
val automaticTransfers = transfers.filter { transfer -> transfer.createdBy == createdBy }
return AutomaticUploadTransferSummary(
inProgressCount = automaticTransfers.count { transfer ->
transfer.status == TransferStatus.TRANSFER_IN_PROGRESS
},
queuedCount = automaticTransfers.count { transfer ->
transfer.status == TransferStatus.TRANSFER_QUEUED
},
failedCount = automaticTransfers.count { transfer ->
transfer.status == TransferStatus.TRANSFER_FAILED
},
)
}
}
}
internal enum class AutomaticUploadPauseReason {
WAITING_FOR_UNMETERED_NETWORK,
WAITING_FOR_NOT_ROAMING_NETWORK,
WAITING_FOR_CHARGING,
}
@@ -52,12 +52,12 @@ import eu.qsfera.android.db.PreferenceManager.PREF__CAMERA_PICTURE_UPLOADS_PATH
import eu.qsfera.android.db.PreferenceManager.PREF__CAMERA_PICTURE_UPLOADS_SOURCE
import eu.qsfera.android.db.PreferenceManager.PREF__CAMERA_PICTURE_UPLOADS_WIFI_ONLY
import eu.qsfera.android.domain.automaticuploads.model.UploadBehavior
import eu.qsfera.android.extensions.collectLatestLifecycleFlow
import eu.qsfera.android.extensions.showAlertDialog
import eu.qsfera.android.extensions.showMessageInSnackbar
import eu.qsfera.android.presentation.accounts.ManageAccountsViewModel
import eu.qsfera.android.ui.activity.FolderPickerActivity
import eu.qsfera.android.utils.DisplayUtils
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.launch
import org.koin.androidx.viewmodel.ext.android.viewModel
import java.io.File
@@ -139,7 +139,13 @@ class SettingsPictureUploadsFragment : PreferenceFragmentCompat() {
private fun initStateObservers() {
viewLifecycleOwner.lifecycleScope.launch {
viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) {
collectLatestLifecycleFlow(manageAccountsViewModel.userQuotas) { listUserQuotas ->
combine(
manageAccountsViewModel.userQuotas,
picturesViewModel.pictureUploads,
picturesViewModel.pictureUploadsTransferSummary,
) { listUserQuotas, pictureUploadsConfiguration, transferSummary ->
Triple(listUserQuotas, pictureUploadsConfiguration, transferSummary)
}.collect { (listUserQuotas, pictureUploadsConfiguration, transferSummary) ->
val availableAccounts = listUserQuotas.filter { it.available != -4L }
prefPictureUploadsAccount?.apply {
entries = availableAccounts.map { it.accountName }.toTypedArray()
@@ -159,37 +165,41 @@ class SettingsPictureUploadsFragment : PreferenceFragmentCompat() {
}
}
picturesViewModel.pictureUploads.collect { pictureUploadsConfiguration ->
enablePictureUploads(pictureUploadsConfiguration != null, false)
pictureUploadsConfiguration?.let {
prefPictureUploadsAccount?.value = it.accountName
prefPictureUploadsPath?.summary = picturesViewModel.getUploadPathString()
val sourcePaths = picturesViewModel.getPictureUploadsSourcePaths()
val sourcePathLabels = getSourcePathLabels(sourcePaths)
prefPictureUploadsStatus?.summary = AutomaticUploadStatusFormatter.format(
enablePictureUploads(pictureUploadsConfiguration != null, false)
pictureUploadsConfiguration?.let {
prefPictureUploadsAccount?.value = it.accountName
prefPictureUploadsPath?.summary = picturesViewModel.getUploadPathString()
val sourcePaths = it.sourcePaths
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,
transferSummary = transferSummary,
pauseReasons = AutomaticUploadRuntimeStatusResolver.pauseReasons(
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(sourcePathLabels)
prefPictureUploadsClearSourcePaths?.isEnabled = sourcePaths.isNotEmpty()
prefPictureUploadsOnWifi?.isChecked = it.wifiOnly
prefPictureUploadsAllowRoaming?.isChecked = it.allowRoaming
prefPictureUploadsAllowRoaming?.isEnabled = !it.wifiOnly
prefPictureUploadsMobileDataLimit?.value = it.mobileDataLimitBytes.toString()
prefPictureUploadsMobileDataLimit?.isEnabled = !it.wifiOnly
prefPictureUploadsOnCharging?.isChecked = it.chargingOnly
prefPictureUploadsBehaviour?.value = it.behavior.name
prefPictureUploadsLastSync?.summary = formatLastSyncSummary(it.lastSyncTimestamp)
spaceId = it.spaceId
} ?: resetFields()
}
transferSummary = transferSummary,
),
)
prefPictureUploadsSourcePath?.summary = getSourcePathsSummary(sourcePathLabels)
prefPictureUploadsClearSourcePaths?.isEnabled = sourcePaths.isNotEmpty()
prefPictureUploadsOnWifi?.isChecked = it.wifiOnly
prefPictureUploadsAllowRoaming?.isChecked = it.allowRoaming
prefPictureUploadsAllowRoaming?.isEnabled = !it.wifiOnly
prefPictureUploadsMobileDataLimit?.value = it.mobileDataLimitBytes.toString()
prefPictureUploadsMobileDataLimit?.isEnabled = !it.wifiOnly
prefPictureUploadsOnCharging?.isChecked = it.chargingOnly
prefPictureUploadsBehaviour?.value = it.behavior.name
prefPictureUploadsLastSync?.summary = formatLastSyncSummary(it.lastSyncTimestamp)
spaceId = it.spaceId
} ?: resetFields()
}
}
}
@@ -41,13 +41,18 @@ import eu.qsfera.android.domain.files.model.OCFile
import eu.qsfera.android.domain.spaces.model.OCSpace
import eu.qsfera.android.domain.spaces.usecases.GetPersonalSpaceForAccountUseCase
import eu.qsfera.android.domain.spaces.usecases.GetSpaceByIdForAccountUseCase
import eu.qsfera.android.domain.transfers.model.UploadEnqueuedBy
import eu.qsfera.android.domain.transfers.usecases.GetAllTransfersAsStreamUseCase
import eu.qsfera.android.providers.AccountProvider
import eu.qsfera.android.providers.ContextProvider
import eu.qsfera.android.providers.CoroutinesDispatcherProvider
import eu.qsfera.android.providers.WorkManagerProvider
import eu.qsfera.android.ui.activity.FolderPickerActivity
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import timber.log.Timber
@@ -59,6 +64,7 @@ class SettingsPictureUploadsViewModel(
private val resetPictureUploadsUseCase: ResetPictureUploadsUseCase,
private val getPersonalSpaceForAccountUseCase: GetPersonalSpaceForAccountUseCase,
private val getSpaceByIdForAccountUseCase: GetSpaceByIdForAccountUseCase,
getAllTransfersAsStreamUseCase: GetAllTransfersAsStreamUseCase,
private val workManagerProvider: WorkManagerProvider,
private val coroutinesDispatcherProvider: CoroutinesDispatcherProvider,
private val contextProvider: ContextProvider,
@@ -69,6 +75,20 @@ class SettingsPictureUploadsViewModel(
private var pictureUploadsSpace: OCSpace? = null
internal val pictureUploadsTransferSummary: StateFlow<AutomaticUploadTransferSummary> =
getAllTransfersAsStreamUseCase(Unit)
.map { transfers ->
AutomaticUploadTransferSummary.fromTransfers(
transfers = transfers,
createdBy = UploadEnqueuedBy.ENQUEUED_AS_AUTOMATIC_UPLOAD_PICTURE,
)
}
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000),
initialValue = AutomaticUploadTransferSummary.EMPTY,
)
init {
initPictureUploads()
}
@@ -52,12 +52,12 @@ import eu.qsfera.android.db.PreferenceManager.PREF__CAMERA_VIDEO_UPLOADS_PATH
import eu.qsfera.android.db.PreferenceManager.PREF__CAMERA_VIDEO_UPLOADS_SOURCE
import eu.qsfera.android.db.PreferenceManager.PREF__CAMERA_VIDEO_UPLOADS_WIFI_ONLY
import eu.qsfera.android.domain.automaticuploads.model.UploadBehavior
import eu.qsfera.android.extensions.collectLatestLifecycleFlow
import eu.qsfera.android.extensions.showAlertDialog
import eu.qsfera.android.extensions.showMessageInSnackbar
import eu.qsfera.android.presentation.accounts.ManageAccountsViewModel
import eu.qsfera.android.ui.activity.FolderPickerActivity
import eu.qsfera.android.utils.DisplayUtils
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.launch
import org.koin.androidx.viewmodel.ext.android.viewModel
import java.io.File
@@ -137,7 +137,13 @@ class SettingsVideoUploadsFragment : PreferenceFragmentCompat() {
private fun initLiveDataObservers() {
viewLifecycleOwner.lifecycleScope.launch {
viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) {
collectLatestLifecycleFlow(manageAccountsViewModel.userQuotas) { listUserQuotas ->
combine(
manageAccountsViewModel.userQuotas,
videosViewModel.videoUploads,
videosViewModel.videoUploadsTransferSummary,
) { listUserQuotas, videoUploadsConfiguration, transferSummary ->
Triple(listUserQuotas, videoUploadsConfiguration, transferSummary)
}.collect { (listUserQuotas, videoUploadsConfiguration, transferSummary) ->
val availableAccounts = listUserQuotas.filter { it.available != -4L }
prefVideoUploadsAccount?.apply {
entries = availableAccounts.map { it.accountName }.toTypedArray()
@@ -157,37 +163,41 @@ class SettingsVideoUploadsFragment : PreferenceFragmentCompat() {
}
}
videosViewModel.videoUploads.collect { videoUploadsConfiguration ->
enableVideoUploads(videoUploadsConfiguration != null, false)
videoUploadsConfiguration?.let {
prefVideoUploadsAccount?.value = it.accountName
prefVideoUploadsPath?.summary = videosViewModel.getUploadPathString()
val sourcePaths = videosViewModel.getVideoUploadsSourcePaths()
val sourcePathLabels = getSourcePathLabels(sourcePaths)
prefVideoUploadsStatus?.summary = AutomaticUploadStatusFormatter.format(
enableVideoUploads(videoUploadsConfiguration != null, false)
videoUploadsConfiguration?.let {
prefVideoUploadsAccount?.value = it.accountName
prefVideoUploadsPath?.summary = videosViewModel.getUploadPathString()
val sourcePaths = it.sourcePaths
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,
transferSummary = transferSummary,
pauseReasons = AutomaticUploadRuntimeStatusResolver.pauseReasons(
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(sourcePathLabels)
prefVideoUploadsClearSourcePaths?.isEnabled = sourcePaths.isNotEmpty()
prefVideoUploadsOnWifi?.isChecked = it.wifiOnly
prefVideoUploadsAllowRoaming?.isChecked = it.allowRoaming
prefVideoUploadsAllowRoaming?.isEnabled = !it.wifiOnly
prefVideoUploadsMobileDataLimit?.value = it.mobileDataLimitBytes.toString()
prefVideoUploadsMobileDataLimit?.isEnabled = !it.wifiOnly
prefVideoUploadsOnCharging?.isChecked = it.chargingOnly
prefVideoUploadsBehaviour?.value = it.behavior.name
prefVideoUploadsLastSync?.summary = formatLastSyncSummary(it.lastSyncTimestamp)
spaceId = it.spaceId
} ?: resetFields()
}
transferSummary = transferSummary,
),
)
prefVideoUploadsSourcePath?.summary = getSourcePathsSummary(sourcePathLabels)
prefVideoUploadsClearSourcePaths?.isEnabled = sourcePaths.isNotEmpty()
prefVideoUploadsOnWifi?.isChecked = it.wifiOnly
prefVideoUploadsAllowRoaming?.isChecked = it.allowRoaming
prefVideoUploadsAllowRoaming?.isEnabled = !it.wifiOnly
prefVideoUploadsMobileDataLimit?.value = it.mobileDataLimitBytes.toString()
prefVideoUploadsMobileDataLimit?.isEnabled = !it.wifiOnly
prefVideoUploadsOnCharging?.isChecked = it.chargingOnly
prefVideoUploadsBehaviour?.value = it.behavior.name
prefVideoUploadsLastSync?.summary = formatLastSyncSummary(it.lastSyncTimestamp)
spaceId = it.spaceId
} ?: resetFields()
}
}
}
@@ -41,13 +41,18 @@ import eu.qsfera.android.domain.files.model.OCFile
import eu.qsfera.android.domain.spaces.model.OCSpace
import eu.qsfera.android.domain.spaces.usecases.GetPersonalSpaceForAccountUseCase
import eu.qsfera.android.domain.spaces.usecases.GetSpaceByIdForAccountUseCase
import eu.qsfera.android.domain.transfers.model.UploadEnqueuedBy
import eu.qsfera.android.domain.transfers.usecases.GetAllTransfersAsStreamUseCase
import eu.qsfera.android.providers.AccountProvider
import eu.qsfera.android.providers.ContextProvider
import eu.qsfera.android.providers.CoroutinesDispatcherProvider
import eu.qsfera.android.providers.WorkManagerProvider
import eu.qsfera.android.ui.activity.FolderPickerActivity
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import timber.log.Timber
@@ -59,6 +64,7 @@ class SettingsVideoUploadsViewModel(
private val resetVideoUploadsUseCase: ResetVideoUploadsUseCase,
private val getPersonalSpaceForAccountUseCase: GetPersonalSpaceForAccountUseCase,
private val getSpaceByIdForAccountUseCase: GetSpaceByIdForAccountUseCase,
getAllTransfersAsStreamUseCase: GetAllTransfersAsStreamUseCase,
private val workManagerProvider: WorkManagerProvider,
private val coroutinesDispatcherProvider: CoroutinesDispatcherProvider,
private val contextProvider: ContextProvider,
@@ -69,6 +75,20 @@ class SettingsVideoUploadsViewModel(
private var videoUploadsSpace: OCSpace? = null
internal val videoUploadsTransferSummary: StateFlow<AutomaticUploadTransferSummary> =
getAllTransfersAsStreamUseCase(Unit)
.map { transfers ->
AutomaticUploadTransferSummary.fromTransfers(
transfers = transfers,
createdBy = UploadEnqueuedBy.ENQUEUED_AS_AUTOMATIC_UPLOAD_VIDEO,
)
}
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000),
initialValue = AutomaticUploadTransferSummary.EMPTY,
)
init {
initVideoUploads()
}
@@ -570,6 +570,10 @@
<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>
<string name="prefs_camera_upload_status_transfer_summary">Загрузки: выполняется %1$d, в очереди %2$d, ошибок %3$d</string>
<string name="prefs_camera_upload_status_paused_unmetered">Приостановлено: ожидается Wi-Fi или другая сеть без тарификации</string>
<string name="prefs_camera_upload_status_paused_not_roaming">Приостановлено: ожидается сеть без роуминга</string>
<string name="prefs_camera_upload_status_paused_charging">Приостановлено: ожидается зарядка</string>
<plurals name="prefs_camera_upload_status_source_count">
<item quantity="one">%d папка телефона</item>
<item quantity="few">%d папки телефона</item>
@@ -609,6 +609,10 @@
<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>
<string name="prefs_camera_upload_status_transfer_summary">Uploads: %1$d uploading, %2$d queued, %3$d failed</string>
<string name="prefs_camera_upload_status_paused_unmetered">Paused: waiting for Wi-Fi or another unmetered network</string>
<string name="prefs_camera_upload_status_paused_not_roaming">Paused: waiting for a non-roaming network</string>
<string name="prefs_camera_upload_status_paused_charging">Paused: waiting for charging</string>
<plurals name="prefs_camera_upload_status_source_count">
<item quantity="one">%d phone folder</item>
<item quantity="other">%d phone folders</item>
@@ -4,10 +4,14 @@ 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.transfers.model.OCTransfer
import eu.qsfera.android.domain.transfers.model.TransferStatus
import eu.qsfera.android.domain.transfers.model.UploadEnqueuedBy
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
import java.io.File
class AutomaticUploadStatusFormatterTest {
@@ -105,6 +109,94 @@ class AutomaticUploadStatusFormatterTest {
)
}
@Test
fun `transfer summary counts only matching automatic upload type`() {
val summary = AutomaticUploadTransferSummary.fromTransfers(
transfers = listOf(
transfer(TransferStatus.TRANSFER_IN_PROGRESS, UploadEnqueuedBy.ENQUEUED_AS_AUTOMATIC_UPLOAD_PICTURE),
transfer(TransferStatus.TRANSFER_QUEUED, UploadEnqueuedBy.ENQUEUED_AS_AUTOMATIC_UPLOAD_PICTURE),
transfer(TransferStatus.TRANSFER_FAILED, UploadEnqueuedBy.ENQUEUED_AS_AUTOMATIC_UPLOAD_PICTURE),
transfer(TransferStatus.TRANSFER_FAILED, UploadEnqueuedBy.ENQUEUED_AS_AUTOMATIC_UPLOAD_VIDEO),
transfer(TransferStatus.TRANSFER_QUEUED, UploadEnqueuedBy.ENQUEUED_BY_USER),
transfer(TransferStatus.TRANSFER_SUCCEEDED, UploadEnqueuedBy.ENQUEUED_AS_AUTOMATIC_UPLOAD_PICTURE),
),
createdBy = UploadEnqueuedBy.ENQUEUED_AS_AUTOMATIC_UPLOAD_PICTURE,
)
assertEquals(
AutomaticUploadTransferSummary(
inProgressCount = 1,
queuedCount = 1,
failedCount = 1,
),
summary
)
}
@Test
fun `pause reasons are empty when no uploads are queued`() {
val reasons = AutomaticUploadStatusFormatter.resolvePauseReasons(
configuration = backupConfiguration().copy(wifiOnly = true, chargingOnly = true),
transferSummary = AutomaticUploadTransferSummary(
inProgressCount = 1,
queuedCount = 0,
failedCount = 1,
),
isActiveNetworkMetered = true,
isActiveNetworkRoaming = true,
isCharging = false,
)
assertEquals(emptySet<AutomaticUploadPauseReason>(), reasons)
}
@Test
fun `pause reasons report unmet queued upload constraints`() {
val reasons = AutomaticUploadStatusFormatter.resolvePauseReasons(
configuration = backupConfiguration().copy(
wifiOnly = false,
allowRoaming = false,
chargingOnly = true,
),
transferSummary = AutomaticUploadTransferSummary(
inProgressCount = 0,
queuedCount = 2,
failedCount = 0,
),
isActiveNetworkMetered = false,
isActiveNetworkRoaming = true,
isCharging = false,
)
assertEquals(
linkedSetOf(
AutomaticUploadPauseReason.WAITING_FOR_NOT_ROAMING_NETWORK,
AutomaticUploadPauseReason.WAITING_FOR_CHARGING,
),
reasons
)
}
@Test
fun `pause reasons report wifi-only queued upload waiting for unmetered network`() {
val reasons = AutomaticUploadStatusFormatter.resolvePauseReasons(
configuration = backupConfiguration().copy(wifiOnly = true),
transferSummary = AutomaticUploadTransferSummary(
inProgressCount = 0,
queuedCount = 1,
failedCount = 0,
),
isActiveNetworkMetered = true,
isActiveNetworkRoaming = false,
isCharging = true,
)
assertEquals(
setOf(AutomaticUploadPauseReason.WAITING_FOR_UNMETERED_NETWORK),
reasons
)
}
private fun backupConfiguration(
sourcePaths: List<String> = listOf(DEFAULT_SOURCE_PATH),
sourceSyncTimestamps: Map<String, Long> = emptyMap(),
@@ -139,6 +231,21 @@ class AutomaticUploadStatusFormatterTest {
state = state,
)
private fun transfer(
status: TransferStatus,
createdBy: UploadEnqueuedBy,
): OCTransfer =
OCTransfer(
localPath = "content://local/${status.name}/${createdBy.name}",
remotePath = "${File.separator}Photos${File.separator}${status.name}-${createdBy.name}.jpg",
accountName = ACCOUNT_NAME,
fileSize = 1L,
status = status,
localBehaviour = UploadBehavior.COPY,
forceOverwrite = false,
createdBy = createdBy,
)
private companion object {
const val ACCOUNT_NAME = "user@example.com"
const val DEFAULT_SOURCE_PATH = "content://camera"
+1 -1
View File
@@ -38,6 +38,7 @@ Date: 2026-06-08.
- 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 photo/video backup status now also observes automatic-upload transfers live and reports uploading, queued, failed, and paused-by-network/roaming/charging states 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.
@@ -91,6 +92,5 @@ Date: 2026-06-08.
## Remaining P1
- 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.