Show live Android backup queue status
This commit is contained in:
+60
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
+96
@@ -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,
|
||||||
|
}
|
||||||
|
|||||||
+41
-31
@@ -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()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+20
@@ -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()
|
||||||
}
|
}
|
||||||
|
|||||||
+41
-31
@@ -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()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+20
@@ -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>
|
||||||
|
|||||||
+107
@@ -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"
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
Reference in New Issue
Block a user