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 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.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.UserQuota
import eu.qsfera.android.domain.user.model.UserQuotaState import eu.qsfera.android.domain.user.model.UserQuotaState
import eu.qsfera.android.utils.DisplayUtils import eu.qsfera.android.utils.DisplayUtils
@@ -27,6 +30,8 @@ internal object AutomaticUploadStatusFormatter {
unreadableSourcePaths: Set<String> = emptySet(), unreadableSourcePaths: Set<String> = emptySet(),
userQuota: UserQuota? = null, userQuota: UserQuota? = null,
sourcePathLabels: Map<String, String> = emptyMap(), sourcePathLabels: Map<String, String> = emptyMap(),
transferSummary: AutomaticUploadTransferSummary = AutomaticUploadTransferSummary.EMPTY,
pauseReasons: Set<AutomaticUploadPauseReason> = emptySet(),
): String { ): String {
val status = statusFor( val status = statusFor(
configuration = configuration, configuration = configuration,
@@ -42,6 +47,8 @@ internal object AutomaticUploadStatusFormatter {
return listOf( return listOf(
context.getString(R.string.prefs_camera_upload_status_on), context.getString(R.string.prefs_camera_upload_status_on),
formatRunState(context, status.runState), formatRunState(context, status.runState),
formatTransferSummary(context, transferSummary),
formatPauseReasons(context, pauseReasons),
formatSourceCount(context, status.sourceStatuses.size), formatSourceCount(context, status.sourceStatuses.size),
formatSourceDetails(context, status.sourceStatuses, sourcePathLabels), formatSourceDetails(context, status.sourceStatuses, sourcePathLabels),
formatConditions(context, enabledConfiguration), 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 = private fun formatRunState(context: Context, runState: AutomaticUploadRunState): String =
when (runState) { when (runState) {
AutomaticUploadRunState.OFF -> context.getString(R.string.prefs_camera_upload_status_off) 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) 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( private fun formatSourceDetails(
context: Context, context: Context,
sourceStatuses: List<AutomaticUploadSourceStatus>, sourceStatuses: List<AutomaticUploadSourceStatus>,
@@ -240,3 +298,41 @@ internal enum class AutomaticUploadStorageWarning {
CRITICAL, CRITICAL,
EXCEEDED, 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_SOURCE
import eu.qsfera.android.db.PreferenceManager.PREF__CAMERA_PICTURE_UPLOADS_WIFI_ONLY import eu.qsfera.android.db.PreferenceManager.PREF__CAMERA_PICTURE_UPLOADS_WIFI_ONLY
import eu.qsfera.android.domain.automaticuploads.model.UploadBehavior 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.showAlertDialog
import eu.qsfera.android.extensions.showMessageInSnackbar import eu.qsfera.android.extensions.showMessageInSnackbar
import eu.qsfera.android.presentation.accounts.ManageAccountsViewModel import eu.qsfera.android.presentation.accounts.ManageAccountsViewModel
import eu.qsfera.android.ui.activity.FolderPickerActivity import eu.qsfera.android.ui.activity.FolderPickerActivity
import eu.qsfera.android.utils.DisplayUtils import eu.qsfera.android.utils.DisplayUtils
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import org.koin.androidx.viewmodel.ext.android.viewModel import org.koin.androidx.viewmodel.ext.android.viewModel
import java.io.File import java.io.File
@@ -139,7 +139,13 @@ class SettingsPictureUploadsFragment : PreferenceFragmentCompat() {
private fun initStateObservers() { private fun initStateObservers() {
viewLifecycleOwner.lifecycleScope.launch { viewLifecycleOwner.lifecycleScope.launch {
viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) { 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 } val availableAccounts = listUserQuotas.filter { it.available != -4L }
prefPictureUploadsAccount?.apply { prefPictureUploadsAccount?.apply {
entries = availableAccounts.map { it.accountName }.toTypedArray() entries = availableAccounts.map { it.accountName }.toTypedArray()
@@ -159,37 +165,41 @@ class SettingsPictureUploadsFragment : PreferenceFragmentCompat() {
} }
} }
picturesViewModel.pictureUploads.collect { pictureUploadsConfiguration -> enablePictureUploads(pictureUploadsConfiguration != null, false)
enablePictureUploads(pictureUploadsConfiguration != null, false) pictureUploadsConfiguration?.let {
pictureUploadsConfiguration?.let { prefPictureUploadsAccount?.value = it.accountName
prefPictureUploadsAccount?.value = it.accountName prefPictureUploadsPath?.summary = picturesViewModel.getUploadPathString()
prefPictureUploadsPath?.summary = picturesViewModel.getUploadPathString() val sourcePaths = it.sourcePaths
val sourcePaths = picturesViewModel.getPictureUploadsSourcePaths() val sourcePathLabels = getSourcePathLabels(sourcePaths)
val sourcePathLabels = getSourcePathLabels(sourcePaths) prefPictureUploadsStatus?.summary = AutomaticUploadStatusFormatter.format(
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(), context = requireContext(),
configuration = it, configuration = it,
sourcePaths = sourcePaths, transferSummary = transferSummary,
lastSyncSummary = formatLastSyncSummary(it.lastSyncTimestamp), ),
unreadableSourcePaths = getUnreadableSourcePaths(sourcePaths), )
userQuota = availableAccounts.firstOrNull { userQuota -> prefPictureUploadsSourcePath?.summary = getSourcePathsSummary(sourcePathLabels)
userQuota.accountName == it.accountName prefPictureUploadsClearSourcePaths?.isEnabled = sourcePaths.isNotEmpty()
}, prefPictureUploadsOnWifi?.isChecked = it.wifiOnly
sourcePathLabels = sourcePathLabels, prefPictureUploadsAllowRoaming?.isChecked = it.allowRoaming
) prefPictureUploadsAllowRoaming?.isEnabled = !it.wifiOnly
prefPictureUploadsSourcePath?.summary = getSourcePathsSummary(sourcePathLabels) prefPictureUploadsMobileDataLimit?.value = it.mobileDataLimitBytes.toString()
prefPictureUploadsClearSourcePaths?.isEnabled = sourcePaths.isNotEmpty() prefPictureUploadsMobileDataLimit?.isEnabled = !it.wifiOnly
prefPictureUploadsOnWifi?.isChecked = it.wifiOnly prefPictureUploadsOnCharging?.isChecked = it.chargingOnly
prefPictureUploadsAllowRoaming?.isChecked = it.allowRoaming prefPictureUploadsBehaviour?.value = it.behavior.name
prefPictureUploadsAllowRoaming?.isEnabled = !it.wifiOnly prefPictureUploadsLastSync?.summary = formatLastSyncSummary(it.lastSyncTimestamp)
prefPictureUploadsMobileDataLimit?.value = it.mobileDataLimitBytes.toString() spaceId = it.spaceId
prefPictureUploadsMobileDataLimit?.isEnabled = !it.wifiOnly } ?: resetFields()
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.model.OCSpace
import eu.qsfera.android.domain.spaces.usecases.GetPersonalSpaceForAccountUseCase import eu.qsfera.android.domain.spaces.usecases.GetPersonalSpaceForAccountUseCase
import eu.qsfera.android.domain.spaces.usecases.GetSpaceByIdForAccountUseCase 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.AccountProvider
import eu.qsfera.android.providers.ContextProvider import eu.qsfera.android.providers.ContextProvider
import eu.qsfera.android.providers.CoroutinesDispatcherProvider import eu.qsfera.android.providers.CoroutinesDispatcherProvider
import eu.qsfera.android.providers.WorkManagerProvider import eu.qsfera.android.providers.WorkManagerProvider
import eu.qsfera.android.ui.activity.FolderPickerActivity import eu.qsfera.android.ui.activity.FolderPickerActivity
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.update import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import timber.log.Timber import timber.log.Timber
@@ -59,6 +64,7 @@ class SettingsPictureUploadsViewModel(
private val resetPictureUploadsUseCase: ResetPictureUploadsUseCase, private val resetPictureUploadsUseCase: ResetPictureUploadsUseCase,
private val getPersonalSpaceForAccountUseCase: GetPersonalSpaceForAccountUseCase, private val getPersonalSpaceForAccountUseCase: GetPersonalSpaceForAccountUseCase,
private val getSpaceByIdForAccountUseCase: GetSpaceByIdForAccountUseCase, private val getSpaceByIdForAccountUseCase: GetSpaceByIdForAccountUseCase,
getAllTransfersAsStreamUseCase: GetAllTransfersAsStreamUseCase,
private val workManagerProvider: WorkManagerProvider, private val workManagerProvider: WorkManagerProvider,
private val coroutinesDispatcherProvider: CoroutinesDispatcherProvider, private val coroutinesDispatcherProvider: CoroutinesDispatcherProvider,
private val contextProvider: ContextProvider, private val contextProvider: ContextProvider,
@@ -69,6 +75,20 @@ class SettingsPictureUploadsViewModel(
private var pictureUploadsSpace: OCSpace? = null 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 { init {
initPictureUploads() 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_SOURCE
import eu.qsfera.android.db.PreferenceManager.PREF__CAMERA_VIDEO_UPLOADS_WIFI_ONLY import eu.qsfera.android.db.PreferenceManager.PREF__CAMERA_VIDEO_UPLOADS_WIFI_ONLY
import eu.qsfera.android.domain.automaticuploads.model.UploadBehavior 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.showAlertDialog
import eu.qsfera.android.extensions.showMessageInSnackbar import eu.qsfera.android.extensions.showMessageInSnackbar
import eu.qsfera.android.presentation.accounts.ManageAccountsViewModel import eu.qsfera.android.presentation.accounts.ManageAccountsViewModel
import eu.qsfera.android.ui.activity.FolderPickerActivity import eu.qsfera.android.ui.activity.FolderPickerActivity
import eu.qsfera.android.utils.DisplayUtils import eu.qsfera.android.utils.DisplayUtils
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import org.koin.androidx.viewmodel.ext.android.viewModel import org.koin.androidx.viewmodel.ext.android.viewModel
import java.io.File import java.io.File
@@ -137,7 +137,13 @@ class SettingsVideoUploadsFragment : PreferenceFragmentCompat() {
private fun initLiveDataObservers() { private fun initLiveDataObservers() {
viewLifecycleOwner.lifecycleScope.launch { viewLifecycleOwner.lifecycleScope.launch {
viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) { 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 } val availableAccounts = listUserQuotas.filter { it.available != -4L }
prefVideoUploadsAccount?.apply { prefVideoUploadsAccount?.apply {
entries = availableAccounts.map { it.accountName }.toTypedArray() entries = availableAccounts.map { it.accountName }.toTypedArray()
@@ -157,37 +163,41 @@ class SettingsVideoUploadsFragment : PreferenceFragmentCompat() {
} }
} }
videosViewModel.videoUploads.collect { videoUploadsConfiguration -> enableVideoUploads(videoUploadsConfiguration != null, false)
enableVideoUploads(videoUploadsConfiguration != null, false) videoUploadsConfiguration?.let {
videoUploadsConfiguration?.let { prefVideoUploadsAccount?.value = it.accountName
prefVideoUploadsAccount?.value = it.accountName prefVideoUploadsPath?.summary = videosViewModel.getUploadPathString()
prefVideoUploadsPath?.summary = videosViewModel.getUploadPathString() val sourcePaths = it.sourcePaths
val sourcePaths = videosViewModel.getVideoUploadsSourcePaths() val sourcePathLabels = getSourcePathLabels(sourcePaths)
val sourcePathLabels = getSourcePathLabels(sourcePaths) prefVideoUploadsStatus?.summary = AutomaticUploadStatusFormatter.format(
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(), context = requireContext(),
configuration = it, configuration = it,
sourcePaths = sourcePaths, transferSummary = transferSummary,
lastSyncSummary = formatLastSyncSummary(it.lastSyncTimestamp), ),
unreadableSourcePaths = getUnreadableSourcePaths(sourcePaths), )
userQuota = availableAccounts.firstOrNull { userQuota -> prefVideoUploadsSourcePath?.summary = getSourcePathsSummary(sourcePathLabels)
userQuota.accountName == it.accountName prefVideoUploadsClearSourcePaths?.isEnabled = sourcePaths.isNotEmpty()
}, prefVideoUploadsOnWifi?.isChecked = it.wifiOnly
sourcePathLabels = sourcePathLabels, prefVideoUploadsAllowRoaming?.isChecked = it.allowRoaming
) prefVideoUploadsAllowRoaming?.isEnabled = !it.wifiOnly
prefVideoUploadsSourcePath?.summary = getSourcePathsSummary(sourcePathLabels) prefVideoUploadsMobileDataLimit?.value = it.mobileDataLimitBytes.toString()
prefVideoUploadsClearSourcePaths?.isEnabled = sourcePaths.isNotEmpty() prefVideoUploadsMobileDataLimit?.isEnabled = !it.wifiOnly
prefVideoUploadsOnWifi?.isChecked = it.wifiOnly prefVideoUploadsOnCharging?.isChecked = it.chargingOnly
prefVideoUploadsAllowRoaming?.isChecked = it.allowRoaming prefVideoUploadsBehaviour?.value = it.behavior.name
prefVideoUploadsAllowRoaming?.isEnabled = !it.wifiOnly prefVideoUploadsLastSync?.summary = formatLastSyncSummary(it.lastSyncTimestamp)
prefVideoUploadsMobileDataLimit?.value = it.mobileDataLimitBytes.toString() spaceId = it.spaceId
prefVideoUploadsMobileDataLimit?.isEnabled = !it.wifiOnly } ?: resetFields()
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.model.OCSpace
import eu.qsfera.android.domain.spaces.usecases.GetPersonalSpaceForAccountUseCase import eu.qsfera.android.domain.spaces.usecases.GetPersonalSpaceForAccountUseCase
import eu.qsfera.android.domain.spaces.usecases.GetSpaceByIdForAccountUseCase 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.AccountProvider
import eu.qsfera.android.providers.ContextProvider import eu.qsfera.android.providers.ContextProvider
import eu.qsfera.android.providers.CoroutinesDispatcherProvider import eu.qsfera.android.providers.CoroutinesDispatcherProvider
import eu.qsfera.android.providers.WorkManagerProvider import eu.qsfera.android.providers.WorkManagerProvider
import eu.qsfera.android.ui.activity.FolderPickerActivity import eu.qsfera.android.ui.activity.FolderPickerActivity
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.update import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import timber.log.Timber import timber.log.Timber
@@ -59,6 +64,7 @@ class SettingsVideoUploadsViewModel(
private val resetVideoUploadsUseCase: ResetVideoUploadsUseCase, private val resetVideoUploadsUseCase: ResetVideoUploadsUseCase,
private val getPersonalSpaceForAccountUseCase: GetPersonalSpaceForAccountUseCase, private val getPersonalSpaceForAccountUseCase: GetPersonalSpaceForAccountUseCase,
private val getSpaceByIdForAccountUseCase: GetSpaceByIdForAccountUseCase, private val getSpaceByIdForAccountUseCase: GetSpaceByIdForAccountUseCase,
getAllTransfersAsStreamUseCase: GetAllTransfersAsStreamUseCase,
private val workManagerProvider: WorkManagerProvider, private val workManagerProvider: WorkManagerProvider,
private val coroutinesDispatcherProvider: CoroutinesDispatcherProvider, private val coroutinesDispatcherProvider: CoroutinesDispatcherProvider,
private val contextProvider: ContextProvider, private val contextProvider: ContextProvider,
@@ -69,6 +75,20 @@ class SettingsVideoUploadsViewModel(
private var videoUploadsSpace: OCSpace? = null 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 { init {
initVideoUploads() initVideoUploads()
} }
@@ -570,6 +570,10 @@
<string name="prefs_camera_upload_status_needs_source">Добавьте папку телефона, чтобы начать копирование</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_needs_source_access">Одной или нескольким папкам телефона снова нужен доступ</string>
<string name="prefs_camera_upload_status_waiting_first_scan">Ожидает первой проверки копирования</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"> <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>
@@ -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">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_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_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"> <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>
@@ -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.encodeSourcePathSyncTimestamps
import eu.qsfera.android.domain.automaticuploads.model.FolderBackUpConfiguration.Companion.encodeSourcePaths import eu.qsfera.android.domain.automaticuploads.model.FolderBackUpConfiguration.Companion.encodeSourcePaths
import eu.qsfera.android.domain.automaticuploads.model.UploadBehavior 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.UserQuota
import eu.qsfera.android.domain.user.model.UserQuotaState import eu.qsfera.android.domain.user.model.UserQuotaState
import org.junit.Assert.assertEquals import org.junit.Assert.assertEquals
import org.junit.Test import org.junit.Test
import java.io.File
class AutomaticUploadStatusFormatterTest { 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( private fun backupConfiguration(
sourcePaths: List<String> = listOf(DEFAULT_SOURCE_PATH), sourcePaths: List<String> = listOf(DEFAULT_SOURCE_PATH),
sourceSyncTimestamps: Map<String, Long> = emptyMap(), sourceSyncTimestamps: Map<String, Long> = emptyMap(),
@@ -139,6 +231,21 @@ class AutomaticUploadStatusFormatterTest {
state = state, 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 { private companion object {
const val ACCOUNT_NAME = "user@example.com" const val ACCOUNT_NAME = "user@example.com"
const val DEFAULT_SOURCE_PATH = "content://camera" 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 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 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. - 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.
@@ -91,6 +92,5 @@ Date: 2026-06-08.
## Remaining P1 ## 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. - 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.