diff --git a/Android/qsferaApp/src/main/java/eu/qsfera/android/presentation/settings/automaticuploads/AutomaticUploadRuntimeStatusResolver.kt b/Android/qsferaApp/src/main/java/eu/qsfera/android/presentation/settings/automaticuploads/AutomaticUploadRuntimeStatusResolver.kt new file mode 100644 index 00000000..16be0b5e --- /dev/null +++ b/Android/qsferaApp/src/main/java/eu/qsfera/android/presentation/settings/automaticuploads/AutomaticUploadRuntimeStatusResolver.kt @@ -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 = + 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 + } +} diff --git a/Android/qsferaApp/src/main/java/eu/qsfera/android/presentation/settings/automaticuploads/AutomaticUploadStatusFormatter.kt b/Android/qsferaApp/src/main/java/eu/qsfera/android/presentation/settings/automaticuploads/AutomaticUploadStatusFormatter.kt index 2411cb26..5ef926aa 100644 --- a/Android/qsferaApp/src/main/java/eu/qsfera/android/presentation/settings/automaticuploads/AutomaticUploadStatusFormatter.kt +++ b/Android/qsferaApp/src/main/java/eu/qsfera/android/presentation/settings/automaticuploads/AutomaticUploadStatusFormatter.kt @@ -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 = emptySet(), userQuota: UserQuota? = null, sourcePathLabels: Map = emptyMap(), + transferSummary: AutomaticUploadTransferSummary = AutomaticUploadTransferSummary.EMPTY, + pauseReasons: Set = 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 { + if (transferSummary.queuedCount == 0) { + return emptySet() + } + + val reasons = linkedSetOf() + 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): 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, @@ -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, + 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, +} diff --git a/Android/qsferaApp/src/main/java/eu/qsfera/android/presentation/settings/automaticuploads/SettingsPictureUploadsFragment.kt b/Android/qsferaApp/src/main/java/eu/qsfera/android/presentation/settings/automaticuploads/SettingsPictureUploadsFragment.kt index ff192547..103dc501 100644 --- a/Android/qsferaApp/src/main/java/eu/qsfera/android/presentation/settings/automaticuploads/SettingsPictureUploadsFragment.kt +++ b/Android/qsferaApp/src/main/java/eu/qsfera/android/presentation/settings/automaticuploads/SettingsPictureUploadsFragment.kt @@ -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() } } } diff --git a/Android/qsferaApp/src/main/java/eu/qsfera/android/presentation/settings/automaticuploads/SettingsPictureUploadsViewModel.kt b/Android/qsferaApp/src/main/java/eu/qsfera/android/presentation/settings/automaticuploads/SettingsPictureUploadsViewModel.kt index 12627c9b..924e403f 100644 --- a/Android/qsferaApp/src/main/java/eu/qsfera/android/presentation/settings/automaticuploads/SettingsPictureUploadsViewModel.kt +++ b/Android/qsferaApp/src/main/java/eu/qsfera/android/presentation/settings/automaticuploads/SettingsPictureUploadsViewModel.kt @@ -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 = + 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() } diff --git a/Android/qsferaApp/src/main/java/eu/qsfera/android/presentation/settings/automaticuploads/SettingsVideoUploadsFragment.kt b/Android/qsferaApp/src/main/java/eu/qsfera/android/presentation/settings/automaticuploads/SettingsVideoUploadsFragment.kt index f12aef8a..ee843efd 100644 --- a/Android/qsferaApp/src/main/java/eu/qsfera/android/presentation/settings/automaticuploads/SettingsVideoUploadsFragment.kt +++ b/Android/qsferaApp/src/main/java/eu/qsfera/android/presentation/settings/automaticuploads/SettingsVideoUploadsFragment.kt @@ -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() } } } diff --git a/Android/qsferaApp/src/main/java/eu/qsfera/android/presentation/settings/automaticuploads/SettingsVideoUploadsViewModel.kt b/Android/qsferaApp/src/main/java/eu/qsfera/android/presentation/settings/automaticuploads/SettingsVideoUploadsViewModel.kt index f2d63d9b..bba1748a 100644 --- a/Android/qsferaApp/src/main/java/eu/qsfera/android/presentation/settings/automaticuploads/SettingsVideoUploadsViewModel.kt +++ b/Android/qsferaApp/src/main/java/eu/qsfera/android/presentation/settings/automaticuploads/SettingsVideoUploadsViewModel.kt @@ -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 = + 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() } diff --git a/Android/qsferaApp/src/main/res/values-ru/strings.xml b/Android/qsferaApp/src/main/res/values-ru/strings.xml index 665b926c..bd4195ee 100644 --- a/Android/qsferaApp/src/main/res/values-ru/strings.xml +++ b/Android/qsferaApp/src/main/res/values-ru/strings.xml @@ -570,6 +570,10 @@ Добавьте папку телефона, чтобы начать копирование Одной или нескольким папкам телефона снова нужен доступ Ожидает первой проверки копирования + Загрузки: выполняется %1$d, в очереди %2$d, ошибок %3$d + Приостановлено: ожидается Wi-Fi или другая сеть без тарификации + Приостановлено: ожидается сеть без роуминга + Приостановлено: ожидается зарядка %d папка телефона %d папки телефона diff --git a/Android/qsferaApp/src/main/res/values/strings.xml b/Android/qsferaApp/src/main/res/values/strings.xml index 79a80e94..b0f24673 100644 --- a/Android/qsferaApp/src/main/res/values/strings.xml +++ b/Android/qsferaApp/src/main/res/values/strings.xml @@ -609,6 +609,10 @@ Add a phone folder to start backup One or more phone folders need access again Waiting for the first backup scan + Uploads: %1$d uploading, %2$d queued, %3$d failed + Paused: waiting for Wi-Fi or another unmetered network + Paused: waiting for a non-roaming network + Paused: waiting for charging %d phone folder %d phone folders diff --git a/Android/qsferaApp/src/test/java/eu/qsfera/android/presentation/settings/automaticuploads/AutomaticUploadStatusFormatterTest.kt b/Android/qsferaApp/src/test/java/eu/qsfera/android/presentation/settings/automaticuploads/AutomaticUploadStatusFormatterTest.kt index 01051ce5..cb31e643 100644 --- a/Android/qsferaApp/src/test/java/eu/qsfera/android/presentation/settings/automaticuploads/AutomaticUploadStatusFormatterTest.kt +++ b/Android/qsferaApp/src/test/java/eu/qsfera/android/presentation/settings/automaticuploads/AutomaticUploadStatusFormatterTest.kt @@ -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(), 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 = listOf(DEFAULT_SOURCE_PATH), sourceSyncTimestamps: Map = 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" diff --git a/PRODUCTION_READINESS.md b/PRODUCTION_READINESS.md index 74fa8ca0..6c4c4c39 100644 --- a/PRODUCTION_READINESS.md +++ b/PRODUCTION_READINESS.md @@ -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.