From 89f3cd093401c474a289adbc74b6576da29795eb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=9A=D1=83=D1=80=D0=BD=D0=B0=D1=82=20=D0=90=D0=BD=D0=B4?= =?UTF-8?q?=D1=80=D0=B5=D0=B9?= Date: Tue, 9 Jun 2026 21:54:25 +0300 Subject: [PATCH] Add Android auto-upload mobile data limit --- .../qsfera/android/db/PreferenceManager.java | 2 + .../SettingsPictureUploadsFragment.kt | 48 +- .../SettingsPictureUploadsViewModel.kt | 24 +- .../SettingsVideoUploadsFragment.kt | 48 +- .../SettingsVideoUploadsViewModel.kt | 24 +- .../android/workers/AutomaticUploadsWorker.kt | 118 +- .../qsferaApp/src/main/res/values/strings.xml | 16 + .../main/res/xml/settings_picture_uploads.xml | 9 + .../main/res/xml/settings_video_uploads.xml | 9 + .../workers/AutomaticUploadsWorkerTest.kt | 143 ++ .../51.json | 1248 +++++++++++++++++ .../eu/qsfera/android/data/ProviderMeta.java | 2 +- .../eu/qsfera/android/data/QSferaDatabase.kt | 4 +- .../OCLocalFolderBackupDataSource.kt | 6 + .../folderbackup/db/FolderBackUpEntity.kt | 3 + .../android/data/migrations/Migration_34.kt | 12 +- .../android/data/migrations/Migration_51.kt | 13 + .../model/FolderBackUpConfiguration.kt | 6 +- .../testutil/OCFolderBackUpConfiguration.kt | 6 + PRODUCTION_READINESS.md | 3 +- 20 files changed, 1684 insertions(+), 60 deletions(-) create mode 100644 Android/qsferaApp/src/test/java/eu/qsfera/android/workers/AutomaticUploadsWorkerTest.kt create mode 100644 Android/qsferaData/schemas/eu.qsfera.android.data.QSferaDatabase/51.json create mode 100644 Android/qsferaData/src/main/java/eu/qsfera/android/data/migrations/Migration_51.kt diff --git a/Android/qsferaApp/src/main/java/eu/qsfera/android/db/PreferenceManager.java b/Android/qsferaApp/src/main/java/eu/qsfera/android/db/PreferenceManager.java index 67184488..4e75aa59 100644 --- a/Android/qsferaApp/src/main/java/eu/qsfera/android/db/PreferenceManager.java +++ b/Android/qsferaApp/src/main/java/eu/qsfera/android/db/PreferenceManager.java @@ -49,9 +49,11 @@ public abstract class PreferenceManager { public static final String PREF__CAMERA_PICTURE_UPLOADS_WIFI_ONLY = "picture_uploads_on_wifi"; public static final String PREF__CAMERA_PICTURE_UPLOADS_CHARGING_ONLY = "picture_uploads_on_charging"; public static final String PREF__CAMERA_PICTURE_UPLOADS_ALLOW_ROAMING = "picture_uploads_allow_roaming"; + public static final String PREF__CAMERA_PICTURE_UPLOADS_MOBILE_DATA_LIMIT = "picture_uploads_mobile_data_limit"; public static final String PREF__CAMERA_VIDEO_UPLOADS_WIFI_ONLY = "video_uploads_on_wifi"; public static final String PREF__CAMERA_VIDEO_UPLOADS_CHARGING_ONLY = "video_uploads_on_charging"; public static final String PREF__CAMERA_VIDEO_UPLOADS_ALLOW_ROAMING = "video_uploads_allow_roaming"; + public static final String PREF__CAMERA_VIDEO_UPLOADS_MOBILE_DATA_LIMIT = "video_uploads_mobile_data_limit"; public static final String PREF__CAMERA_PICTURE_UPLOADS_PATH = "picture_uploads_path"; public static final String PREF__CAMERA_VIDEO_UPLOADS_PATH = "video_uploads_path"; public static final String PREF__CAMERA_PICTURE_UPLOADS_BEHAVIOUR = "picture_uploads_behaviour"; 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 5b74f6c6..529a32ab 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 @@ -46,6 +46,7 @@ import eu.qsfera.android.db.PreferenceManager.PREF__CAMERA_PICTURE_UPLOADS_BEHAV import eu.qsfera.android.db.PreferenceManager.PREF__CAMERA_PICTURE_UPLOADS_CHARGING_ONLY import eu.qsfera.android.db.PreferenceManager.PREF__CAMERA_PICTURE_UPLOADS_ENABLED import eu.qsfera.android.db.PreferenceManager.PREF__CAMERA_PICTURE_UPLOADS_LAST_SYNC +import eu.qsfera.android.db.PreferenceManager.PREF__CAMERA_PICTURE_UPLOADS_MOBILE_DATA_LIMIT 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 @@ -70,6 +71,7 @@ class SettingsPictureUploadsFragment : PreferenceFragmentCompat() { private var prefPictureUploadsPath: Preference? = null private var prefPictureUploadsOnWifi: CheckBoxPreference? = null private var prefPictureUploadsAllowRoaming: CheckBoxPreference? = null + private var prefPictureUploadsMobileDataLimit: ListPreference? = null private var prefPictureUploadsOnCharging: CheckBoxPreference? = null private var prefPictureUploadsSourcePath: Preference? = null private var prefPictureUploadsClearSourcePaths: Preference? = null @@ -103,6 +105,7 @@ class SettingsPictureUploadsFragment : PreferenceFragmentCompat() { prefPictureUploadsPath = findPreference(PREF__CAMERA_PICTURE_UPLOADS_PATH) prefPictureUploadsOnWifi = findPreference(PREF__CAMERA_PICTURE_UPLOADS_WIFI_ONLY) prefPictureUploadsAllowRoaming = findPreference(PREF__CAMERA_PICTURE_UPLOADS_ALLOW_ROAMING) + prefPictureUploadsMobileDataLimit = findPreference(PREF__CAMERA_PICTURE_UPLOADS_MOBILE_DATA_LIMIT) prefPictureUploadsOnCharging = findPreference(PREF__CAMERA_PICTURE_UPLOADS_CHARGING_ONLY) prefPictureUploadsSourcePath = findPreference(PREF__CAMERA_PICTURE_UPLOADS_SOURCE) prefPictureUploadsClearSourcePaths = findPreference(PREF_PICTURE_UPLOADS_CLEAR_SOURCE_PATHS) @@ -117,7 +120,9 @@ class SettingsPictureUploadsFragment : PreferenceFragmentCompat() { prefPictureUploadsAccount = findPreference(PREF__CAMERA_PICTURE_UPLOADS_ACCOUNT_NAME) val comment = getString(R.string.prefs_camera_upload_source_path_title_required) - prefPictureUploadsSourcePath?.title = String.format(prefPictureUploadsSourcePath?.title.toString(), comment) + prefPictureUploadsSourcePath?.let { + it.title = String.format(it.title?.toString().orEmpty(), comment) + } initPreferenceListeners() } @@ -143,7 +148,7 @@ class SettingsPictureUploadsFragment : PreferenceFragmentCompat() { showMessageInSnackbar(getString(R.string.prefs_automatic_uploads_not_available_users_light)) } else { val currentAccount = manageAccountsViewModel.getCurrentAccount()?.name - currentAccount?.let { + if (currentAccount != null) { selectedAccount = if (manageAccountsViewModel.checkUserLight(currentAccount)) { availableAccounts.first().accountName } else { @@ -162,6 +167,8 @@ class SettingsPictureUploadsFragment : PreferenceFragmentCompat() { 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) @@ -246,33 +253,40 @@ class SettingsPictureUploadsFragment : PreferenceFragmentCompat() { } prefPictureUploadsOnWifi?.setOnPreferenceChangeListener { _, newValue -> - newValue as Boolean - picturesViewModel.useWifiOnly(newValue) - prefPictureUploadsAllowRoaming?.isEnabled = !newValue - newValue + val value = newValue as? Boolean ?: return@setOnPreferenceChangeListener false + picturesViewModel.useWifiOnly(value) + prefPictureUploadsAllowRoaming?.isEnabled = !value + prefPictureUploadsMobileDataLimit?.isEnabled = !value + value } prefPictureUploadsAllowRoaming?.setOnPreferenceChangeListener { _, newValue -> - newValue as Boolean - picturesViewModel.allowRoaming(newValue) - newValue + val value = newValue as? Boolean ?: return@setOnPreferenceChangeListener false + picturesViewModel.allowRoaming(value) + value + } + + prefPictureUploadsMobileDataLimit?.setOnPreferenceChangeListener { _, newValue -> + val value = (newValue as? String)?.toLongOrNull() ?: return@setOnPreferenceChangeListener false + picturesViewModel.setMobileDataLimitBytes(value) + true } prefPictureUploadsOnCharging?.setOnPreferenceChangeListener { _, newValue -> - newValue as Boolean - picturesViewModel.useChargingOnly(newValue) - newValue + val value = newValue as? Boolean ?: return@setOnPreferenceChangeListener false + picturesViewModel.useChargingOnly(value) + value } prefPictureUploadsAccount?.setOnPreferenceChangeListener { _, newValue -> - newValue as String - picturesViewModel.handleSelectAccount(newValue) + val value = newValue as? String ?: return@setOnPreferenceChangeListener false + picturesViewModel.handleSelectAccount(value) true } prefPictureUploadsBehaviour?.setOnPreferenceChangeListener { _, newValue -> - newValue as String - picturesViewModel.handleSelectBehaviour(newValue) + val value = newValue as? String ?: return@setOnPreferenceChangeListener false + picturesViewModel.handleSelectBehaviour(value) true } } @@ -290,6 +304,7 @@ class SettingsPictureUploadsFragment : PreferenceFragmentCompat() { prefPictureUploadsPath?.isEnabled = value prefPictureUploadsOnWifi?.isEnabled = value prefPictureUploadsAllowRoaming?.isEnabled = value && prefPictureUploadsOnWifi?.isChecked != true + prefPictureUploadsMobileDataLimit?.isEnabled = value && prefPictureUploadsOnWifi?.isChecked != true prefPictureUploadsOnCharging?.isEnabled = value prefPictureUploadsSourcePath?.isEnabled = value prefPictureUploadsClearSourcePaths?.isEnabled = value && picturesViewModel.getPictureUploadsSourcePaths().isNotEmpty() @@ -305,6 +320,7 @@ class SettingsPictureUploadsFragment : PreferenceFragmentCompat() { prefPictureUploadsClearSourcePaths?.isEnabled = false prefPictureUploadsOnWifi?.isChecked = false prefPictureUploadsAllowRoaming?.isChecked = false + prefPictureUploadsMobileDataLimit?.value = "0" prefPictureUploadsOnCharging?.isChecked = false prefPictureUploadsBehaviour?.value = UploadBehavior.COPY.name prefPictureUploadsLastSync?.summary = getString(R.string.prefs_camera_upload_last_sync_never) 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 f21c960e..27b9a171 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 @@ -122,6 +122,16 @@ class SettingsPictureUploadsViewModel( } } + fun setMobileDataLimitBytes(mobileDataLimitBytes: Long) { + viewModelScope.launch(coroutinesDispatcherProvider.io) { + savePictureUploadsConfigurationUseCase( + SavePictureUploadsConfigurationUseCase.Params( + composePictureUploadsConfiguration(mobileDataLimitBytes = mobileDataLimitBytes) + ) + ) + } + } + fun useChargingOnly(chargingOnly: Boolean) { viewModelScope.launch(coroutinesDispatcherProvider.io) { savePictureUploadsConfigurationUseCase( @@ -222,6 +232,9 @@ class SettingsPictureUploadsViewModel( uploadPath: String? = _pictureUploads.value?.uploadPath, wifiOnly: Boolean? = _pictureUploads.value?.wifiOnly, allowRoaming: Boolean? = _pictureUploads.value?.allowRoaming, + mobileDataLimitBytes: Long? = _pictureUploads.value?.mobileDataLimitBytes, + mobileDataPeriodStartTimestamp: Long? = _pictureUploads.value?.mobileDataPeriodStartTimestamp, + mobileDataBytesQueuedInPeriod: Long? = _pictureUploads.value?.mobileDataBytesQueuedInPeriod, chargingOnly: Boolean? = _pictureUploads.value?.chargingOnly, sourcePath: String? = _pictureUploads.value?.sourcePath, behavior: UploadBehavior? = _pictureUploads.value?.behavior, @@ -232,9 +245,12 @@ class SettingsPictureUploadsViewModel( behavior = behavior ?: UploadBehavior.COPY, sourcePath = sourcePath.orEmpty(), uploadPath = uploadPath ?: PREF__CAMERA_UPLOADS_DEFAULT_PATH, - wifiOnly = wifiOnly ?: true, - allowRoaming = allowRoaming ?: false, - chargingOnly = chargingOnly ?: false, + wifiOnly = wifiOnly != false, + allowRoaming = allowRoaming == true, + mobileDataLimitBytes = mobileDataLimitBytes ?: 0, + mobileDataPeriodStartTimestamp = mobileDataPeriodStartTimestamp ?: 0, + mobileDataBytesQueuedInPeriod = mobileDataBytesQueuedInPeriod ?: 0, + chargingOnly = chargingOnly == true, lastSyncTimestamp = timestamp ?: initialSyncTimestamp, name = _pictureUploads.value?.name ?: pictureUploadsName, spaceId = spaceId, @@ -251,7 +267,7 @@ class SettingsPictureUploadsViewModel( fun getUploadPathString(): String { - val spaceName = handleSpaceName(pictureUploadsSpace?.name) + val spaceName = handleSpaceName(pictureUploadsSpace?.name).orEmpty() val uploadPath = pictureUploads.value?.uploadPath val spaceId = pictureUploads.value?.spaceId 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 5414f1fa..010c13e3 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 @@ -46,6 +46,7 @@ import eu.qsfera.android.db.PreferenceManager.PREF__CAMERA_VIDEO_UPLOADS_ALLOW_R import eu.qsfera.android.db.PreferenceManager.PREF__CAMERA_VIDEO_UPLOADS_BEHAVIOUR import eu.qsfera.android.db.PreferenceManager.PREF__CAMERA_VIDEO_UPLOADS_CHARGING_ONLY import eu.qsfera.android.db.PreferenceManager.PREF__CAMERA_VIDEO_UPLOADS_ENABLED +import eu.qsfera.android.db.PreferenceManager.PREF__CAMERA_VIDEO_UPLOADS_MOBILE_DATA_LIMIT 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 @@ -70,6 +71,7 @@ class SettingsVideoUploadsFragment : PreferenceFragmentCompat() { private var prefVideoUploadsPath: Preference? = null private var prefVideoUploadsOnWifi: CheckBoxPreference? = null private var prefVideoUploadsAllowRoaming: CheckBoxPreference? = null + private var prefVideoUploadsMobileDataLimit: ListPreference? = null private var prefVideoUploadsOnCharging: CheckBoxPreference? = null private var prefVideoUploadsSourcePath: Preference? = null private var prefVideoUploadsClearSourcePaths: Preference? = null @@ -103,6 +105,7 @@ class SettingsVideoUploadsFragment : PreferenceFragmentCompat() { prefVideoUploadsPath = findPreference(PREF__CAMERA_VIDEO_UPLOADS_PATH) prefVideoUploadsOnWifi = findPreference(PREF__CAMERA_VIDEO_UPLOADS_WIFI_ONLY) prefVideoUploadsAllowRoaming = findPreference(PREF__CAMERA_VIDEO_UPLOADS_ALLOW_ROAMING) + prefVideoUploadsMobileDataLimit = findPreference(PREF__CAMERA_VIDEO_UPLOADS_MOBILE_DATA_LIMIT) prefVideoUploadsOnCharging = findPreference(PREF__CAMERA_VIDEO_UPLOADS_CHARGING_ONLY) prefVideoUploadsSourcePath = findPreference(PREF__CAMERA_VIDEO_UPLOADS_SOURCE) prefVideoUploadsClearSourcePaths = findPreference(PREF_VIDEO_UPLOADS_CLEAR_SOURCE_PATHS) @@ -115,7 +118,9 @@ class SettingsVideoUploadsFragment : PreferenceFragmentCompat() { prefVideoUploadsAccount = findPreference(PREF__CAMERA_VIDEO_UPLOADS_ACCOUNT_NAME) val comment = getString(R.string.prefs_camera_upload_source_path_title_required) - prefVideoUploadsSourcePath?.title = String.format(prefVideoUploadsSourcePath?.title.toString(), comment) + prefVideoUploadsSourcePath?.let { + it.title = String.format(it.title?.toString().orEmpty(), comment) + } initPreferenceListeners() } @@ -141,7 +146,7 @@ class SettingsVideoUploadsFragment : PreferenceFragmentCompat() { showMessageInSnackbar(getString(R.string.prefs_automatic_uploads_not_available_users_light)) } else { val currentAccount = manageAccountsViewModel.getCurrentAccount()?.name - currentAccount?.let { + if (currentAccount != null) { selectedAccount = if (manageAccountsViewModel.checkUserLight(currentAccount)) { availableAccounts.first().accountName } else { @@ -160,6 +165,8 @@ class SettingsVideoUploadsFragment : PreferenceFragmentCompat() { 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) @@ -244,33 +251,40 @@ class SettingsVideoUploadsFragment : PreferenceFragmentCompat() { } prefVideoUploadsOnWifi?.setOnPreferenceChangeListener { _, newValue -> - newValue as Boolean - videosViewModel.useWifiOnly(newValue) - prefVideoUploadsAllowRoaming?.isEnabled = !newValue - newValue + val value = newValue as? Boolean ?: return@setOnPreferenceChangeListener false + videosViewModel.useWifiOnly(value) + prefVideoUploadsAllowRoaming?.isEnabled = !value + prefVideoUploadsMobileDataLimit?.isEnabled = !value + value } prefVideoUploadsAllowRoaming?.setOnPreferenceChangeListener { _, newValue -> - newValue as Boolean - videosViewModel.allowRoaming(newValue) - newValue + val value = newValue as? Boolean ?: return@setOnPreferenceChangeListener false + videosViewModel.allowRoaming(value) + value + } + + prefVideoUploadsMobileDataLimit?.setOnPreferenceChangeListener { _, newValue -> + val value = (newValue as? String)?.toLongOrNull() ?: return@setOnPreferenceChangeListener false + videosViewModel.setMobileDataLimitBytes(value) + true } prefVideoUploadsOnCharging?.setOnPreferenceChangeListener { _, newValue -> - newValue as Boolean - videosViewModel.useChargingOnly(newValue) - newValue + val value = newValue as? Boolean ?: return@setOnPreferenceChangeListener false + videosViewModel.useChargingOnly(value) + value } prefVideoUploadsAccount?.setOnPreferenceChangeListener { _, newValue -> - newValue as String - videosViewModel.handleSelectAccount(newValue) + val value = newValue as? String ?: return@setOnPreferenceChangeListener false + videosViewModel.handleSelectAccount(value) true } prefVideoUploadsBehaviour?.setOnPreferenceChangeListener { _, newValue -> - newValue as String - videosViewModel.handleSelectBehaviour(newValue) + val value = newValue as? String ?: return@setOnPreferenceChangeListener false + videosViewModel.handleSelectBehaviour(value) true } } @@ -288,6 +302,7 @@ class SettingsVideoUploadsFragment : PreferenceFragmentCompat() { prefVideoUploadsPath?.isEnabled = value prefVideoUploadsOnWifi?.isEnabled = value prefVideoUploadsAllowRoaming?.isEnabled = value && prefVideoUploadsOnWifi?.isChecked != true + prefVideoUploadsMobileDataLimit?.isEnabled = value && prefVideoUploadsOnWifi?.isChecked != true prefVideoUploadsOnCharging?.isEnabled = value prefVideoUploadsSourcePath?.isEnabled = value prefVideoUploadsClearSourcePaths?.isEnabled = value && videosViewModel.getVideoUploadsSourcePaths().isNotEmpty() @@ -303,6 +318,7 @@ class SettingsVideoUploadsFragment : PreferenceFragmentCompat() { prefVideoUploadsClearSourcePaths?.isEnabled = false prefVideoUploadsOnWifi?.isChecked = false prefVideoUploadsAllowRoaming?.isChecked = false + prefVideoUploadsMobileDataLimit?.value = "0" prefVideoUploadsOnCharging?.isChecked = false prefVideoUploadsBehaviour?.value = UploadBehavior.COPY.name prefVideoUploadsLastSync?.summary = getString(R.string.prefs_camera_upload_last_sync_never) 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 bc87c471..8958622e 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 @@ -122,6 +122,16 @@ class SettingsVideoUploadsViewModel( } } + fun setMobileDataLimitBytes(mobileDataLimitBytes: Long) { + viewModelScope.launch(coroutinesDispatcherProvider.io) { + saveVideoUploadsConfigurationUseCase( + SaveVideoUploadsConfigurationUseCase.Params( + composeVideoUploadsConfiguration(mobileDataLimitBytes = mobileDataLimitBytes) + ) + ) + } + } + fun useChargingOnly(chargingOnly: Boolean) { viewModelScope.launch(coroutinesDispatcherProvider.io) { saveVideoUploadsConfigurationUseCase( @@ -222,6 +232,9 @@ class SettingsVideoUploadsViewModel( uploadPath: String? = _videoUploads.value?.uploadPath, wifiOnly: Boolean? = _videoUploads.value?.wifiOnly, allowRoaming: Boolean? = _videoUploads.value?.allowRoaming, + mobileDataLimitBytes: Long? = _videoUploads.value?.mobileDataLimitBytes, + mobileDataPeriodStartTimestamp: Long? = _videoUploads.value?.mobileDataPeriodStartTimestamp, + mobileDataBytesQueuedInPeriod: Long? = _videoUploads.value?.mobileDataBytesQueuedInPeriod, chargingOnly: Boolean? = _videoUploads.value?.chargingOnly, sourcePath: String? = _videoUploads.value?.sourcePath, behavior: UploadBehavior? = _videoUploads.value?.behavior, @@ -233,9 +246,12 @@ class SettingsVideoUploadsViewModel( behavior = behavior ?: UploadBehavior.COPY, sourcePath = sourcePath.orEmpty(), uploadPath = uploadPath ?: PREF__CAMERA_UPLOADS_DEFAULT_PATH, - wifiOnly = wifiOnly ?: true, - allowRoaming = allowRoaming ?: false, - chargingOnly = chargingOnly ?: false, + wifiOnly = wifiOnly != false, + allowRoaming = allowRoaming == true, + mobileDataLimitBytes = mobileDataLimitBytes ?: 0, + mobileDataPeriodStartTimestamp = mobileDataPeriodStartTimestamp ?: 0, + mobileDataBytesQueuedInPeriod = mobileDataBytesQueuedInPeriod ?: 0, + chargingOnly = chargingOnly == true, lastSyncTimestamp = timestamp ?: initialSyncTimestamp, name = _videoUploads.value?.name ?: videoUploadsName, spaceId = spaceId, @@ -252,7 +268,7 @@ class SettingsVideoUploadsViewModel( fun getUploadPathString(): String { - val spaceName = handleSpaceName(videoUploadsSpace?.name) + val spaceName = handleSpaceName(videoUploadsSpace?.name).orEmpty() val uploadPath = videoUploads.value?.uploadPath val spaceId = videoUploads.value?.spaceId diff --git a/Android/qsferaApp/src/main/java/eu/qsfera/android/workers/AutomaticUploadsWorker.kt b/Android/qsferaApp/src/main/java/eu/qsfera/android/workers/AutomaticUploadsWorker.kt index 27779d53..9b753c95 100644 --- a/Android/qsferaApp/src/main/java/eu/qsfera/android/workers/AutomaticUploadsWorker.kt +++ b/Android/qsferaApp/src/main/java/eu/qsfera/android/workers/AutomaticUploadsWorker.kt @@ -22,6 +22,7 @@ package eu.qsfera.android.workers import android.content.Context +import android.net.ConnectivityManager import android.net.Uri import androidx.core.net.toUri import androidx.documentfile.provider.DocumentFile @@ -51,7 +52,9 @@ import org.koin.core.component.inject import timber.log.Timber import java.io.File +import java.util.Calendar import java.util.Date +import java.util.TimeZone import java.util.concurrent.TimeUnit class AutomaticUploadsWorker( @@ -142,9 +145,12 @@ class AutomaticUploadsWorker( WorkManager.getInstance(appContext).cancelUniqueWork(AUTOMATIC_UPLOADS_WORKER) } - private fun syncFolder(folderBackUpConfiguration: FolderBackUpConfiguration?) { - if (folderBackUpConfiguration == null) return + private fun isActiveNetworkMetered(): Boolean { + val connectivityManager = appContext.getSystemService(Context.CONNECTIVITY_SERVICE) as? ConnectivityManager + return connectivityManager?.isActiveNetworkMetered != false + } + private fun syncFolder(folderBackUpConfiguration: FolderBackUpConfiguration) { val syncType = when { folderBackUpConfiguration.isPictureUploads -> SyncType.PICTURE_UPLOADS folderBackUpConfiguration.isVideoUploads -> SyncType.VIDEO_UPLOADS @@ -153,6 +159,9 @@ class AutomaticUploadsWorker( } val currentTimestamp = System.currentTimeMillis() + val activeNetworkMetered = isActiveNetworkMetered() + var mobileDataQuotaState = resolveMobileDataQuotaState(folderBackUpConfiguration, currentTimestamp) + var mobileDataLimitReached = false val localPicturesDocumentFiles: List = folderBackUpConfiguration.sourcePaths.flatMap { sourcePath -> getFilesReadyToUpload( @@ -163,19 +172,34 @@ class AutomaticUploadsWorker( ) } - showNotification(syncType, localPicturesDocumentFiles.size) - + var queuedUploadsCount = 0 for (documentFile in localPicturesDocumentFiles) { // Dedup: if this content URI already has a queued, in-progress, or succeeded transfer, // skip it. Without this, a worker killed mid-loop (before updateTimestamp) or a // file whose lastModified changed (e.g. media scanner) would be re-discovered and - // enqueued with a new upload ID — leading to duplicate uploads or 0-byte files + // enqueued with a new upload ID - leading to duplicate uploads or 0-byte files // when two workers race on the same cache path. val contentUri = documentFile.uri.toString() if (transferRepository.existsNonFailedTransferForUri(contentUri)) { Timber.d("Skipping already-tracked file: %s", documentFile.name) continue } + val documentFileSize = documentFile.length() + if (!canQueueMobileDataUpload( + wifiOnly = folderBackUpConfiguration.wifiOnly, + isActiveNetworkMetered = activeNetworkMetered, + mobileDataLimitBytes = folderBackUpConfiguration.mobileDataLimitBytes, + mobileDataBytesQueuedInPeriod = mobileDataQuotaState.bytesQueuedInPeriod, + fileSize = documentFileSize + ) + ) { + mobileDataLimitReached = true + Timber.i( + "Skipping automatic upload %s because the daily mobile data queue limit has been reached", + documentFile.name + ) + continue + } val uploadId = storeInUploadsDatabase( documentFile = documentFile, uploadPath = folderBackUpConfiguration.uploadPath.plus(File.separator).plus(documentFile.name), @@ -198,11 +222,26 @@ class AutomaticUploadsWorker( allowRoaming = folderBackUpConfiguration.allowRoaming, chargingOnly = folderBackUpConfiguration.chargingOnly ) + if (shouldCountAgainstMobileDataLimit( + wifiOnly = folderBackUpConfiguration.wifiOnly, + isActiveNetworkMetered = activeNetworkMetered, + mobileDataLimitBytes = folderBackUpConfiguration.mobileDataLimitBytes, + ) + ) { + mobileDataQuotaState = mobileDataQuotaState.copy( + bytesQueuedInPeriod = mobileDataQuotaState.bytesQueuedInPeriod + documentFileSize + ) + } + queuedUploadsCount += 1 } + showNotification(syncType, queuedUploadsCount) // Save safeTimestamp (not currentTimestamp) so that files skipped by the // write-safety buffer are re-evaluated on the next run instead of being lost. + // Keep the previous timestamp when the mobile-data daily limit stops the scan, + // so skipped files are re-evaluated after the quota period resets. val safeTimestamp = currentTimestamp - WRITE_SAFETY_BUFFER_MS - updateTimestamp(folderBackUpConfiguration, syncType, safeTimestamp) + val nextTimestamp = if (mobileDataLimitReached) folderBackUpConfiguration.lastSyncTimestamp else safeTimestamp + updateConfigurationAfterScan(folderBackUpConfiguration, syncType, nextTimestamp, mobileDataQuotaState) } private fun showNotification( @@ -251,23 +290,29 @@ class AutomaticUploadsWorker( ) } - private fun updateTimestamp( + private fun updateConfigurationAfterScan( folderBackUpConfiguration: FolderBackUpConfiguration, syncType: SyncType, - currentTimestamp: Long, + nextSyncTimestamp: Long, + mobileDataQuotaState: MobileDataQuotaState, ) { + val updatedConfiguration = folderBackUpConfiguration.copy( + lastSyncTimestamp = nextSyncTimestamp, + mobileDataPeriodStartTimestamp = mobileDataQuotaState.periodStartTimestamp, + mobileDataBytesQueuedInPeriod = mobileDataQuotaState.bytesQueuedInPeriod + ) when (syncType) { SyncType.PICTURE_UPLOADS -> { val savePictureUploadsConfigurationUseCase: SavePictureUploadsConfigurationUseCase by inject() savePictureUploadsConfigurationUseCase( - SavePictureUploadsConfigurationUseCase.Params(folderBackUpConfiguration.copy(lastSyncTimestamp = currentTimestamp)) + SavePictureUploadsConfigurationUseCase.Params(updatedConfiguration) ) } SyncType.VIDEO_UPLOADS -> { val saveVideoUploadsConfigurationUseCase: SaveVideoUploadsConfigurationUseCase by inject() saveVideoUploadsConfigurationUseCase( - SaveVideoUploadsConfigurationUseCase.Params(folderBackUpConfiguration.copy(lastSyncTimestamp = currentTimestamp)) + SaveVideoUploadsConfigurationUseCase.Params(updatedConfiguration) ) } } @@ -281,7 +326,7 @@ class AutomaticUploadsWorker( ): List { val sourceUri: Uri = sourcePath.toUri() val documentTree = DocumentFile.fromTreeUri(applicationContext, sourceUri) - val arrayOfLocalFiles = documentTree?.listFiles() ?: arrayOf() + val arrayOfLocalFiles = documentTree?.listFiles().orEmpty() // Exclude files modified within the last few seconds. Camera apps may still be // writing the file (not all apps use atomic rename), so picking it up too early @@ -296,7 +341,7 @@ class AutomaticUploadsWorker( Timber.i("Last sync ${syncType.name}: ${Date(lastSyncTimestamp)}") Timber.i("CurrentTimestamp ${Date(currentTimestamp)}") - Timber.i("${arrayOfLocalFiles.size} files found in folder: ${sourceUri.path}") + Timber.i("${arrayOfLocalFiles.size} files found in folder: ${sourceUri.path.orEmpty()}") Timber.i("${filteredList.size} files are ${syncType.name} and were taken after last sync") return filteredList @@ -355,6 +400,11 @@ class AutomaticUploadsWorker( } companion object { + data class MobileDataQuotaState( + val periodStartTimestamp: Long, + val bytesQueuedInPeriod: Long, + ) + const val AUTOMATIC_UPLOADS_WORKER = "AUTOMATIC_UPLOADS_WORKER" const val IMMEDIATE_UPLOADS_WORKER = "IMMEDIATE_AUTOMATIC_UPLOADS_WORKER" const val MEDIA_STORE_UPLOADS_WORKER = "MEDIA_STORE_AUTOMATIC_UPLOADS_WORKER" @@ -365,5 +415,49 @@ class AutomaticUploadsWorker( private const val videoUploadsNotificationId = 102 const val WRITE_SAFETY_BUFFER_MS = 10_000L const val MEDIA_STORE_TRIGGER_MAX_DELAY_MS = 60_000L + + fun resolveMobileDataQuotaState( + folderBackUpConfiguration: FolderBackUpConfiguration, + currentTimestamp: Long, + timeZone: TimeZone = TimeZone.getDefault(), + ): MobileDataQuotaState { + val currentPeriodStart = getMobileDataLimitPeriodStart(currentTimestamp, timeZone) + val bytesQueuedInPeriod = + if (folderBackUpConfiguration.mobileDataPeriodStartTimestamp == currentPeriodStart) { + folderBackUpConfiguration.mobileDataBytesQueuedInPeriod + } else { + 0L + } + return MobileDataQuotaState( + periodStartTimestamp = currentPeriodStart, + bytesQueuedInPeriod = bytesQueuedInPeriod, + ) + } + + fun getMobileDataLimitPeriodStart(timestamp: Long, timeZone: TimeZone = TimeZone.getDefault()): Long = + Calendar.getInstance(timeZone).apply { + timeInMillis = timestamp + set(Calendar.HOUR_OF_DAY, 0) + set(Calendar.MINUTE, 0) + set(Calendar.SECOND, 0) + set(Calendar.MILLISECOND, 0) + }.timeInMillis + + fun shouldCountAgainstMobileDataLimit( + wifiOnly: Boolean, + isActiveNetworkMetered: Boolean, + mobileDataLimitBytes: Long, + ): Boolean = + !wifiOnly && isActiveNetworkMetered && mobileDataLimitBytes > 0 + + fun canQueueMobileDataUpload( + wifiOnly: Boolean, + isActiveNetworkMetered: Boolean, + mobileDataLimitBytes: Long, + mobileDataBytesQueuedInPeriod: Long, + fileSize: Long, + ): Boolean = + !shouldCountAgainstMobileDataLimit(wifiOnly, isActiveNetworkMetered, mobileDataLimitBytes) || + fileSize <= mobileDataLimitBytes - mobileDataBytesQueuedInPeriod } } diff --git a/Android/qsferaApp/src/main/res/values/strings.xml b/Android/qsferaApp/src/main/res/values/strings.xml index 09b09129..201bf859 100644 --- a/Android/qsferaApp/src/main/res/values/strings.xml +++ b/Android/qsferaApp/src/main/res/values/strings.xml @@ -601,6 +601,22 @@ Backup will stop scanning phone folders until you add a folder again. Allow backup while roaming Only applies when Wi-Fi-only backup is off + Mobile data daily limit + Only applies when Wi-Fi-only backup is off and the active network is metered + + Unlimited + 10 MB per day + 50 MB per day + 200 MB per day + 1 GB per day + + + 0 + 10485760 + 52428800 + 209715200 + 1073741824 + Original file after backup Original file after backup Last backup scan diff --git a/Android/qsferaApp/src/main/res/xml/settings_picture_uploads.xml b/Android/qsferaApp/src/main/res/xml/settings_picture_uploads.xml index 5179a1ec..6a6fac66 100644 --- a/Android/qsferaApp/src/main/res/xml/settings_picture_uploads.xml +++ b/Android/qsferaApp/src/main/res/xml/settings_picture_uploads.xml @@ -69,6 +69,15 @@ app:key="picture_uploads_allow_roaming" app:summary="@string/prefs_camera_upload_allow_roaming_summary" app:title="@string/prefs_camera_upload_allow_roaming" /> + + = sourcePath @@ -54,8 +58,6 @@ data class FolderBackUpConfiguration( .filter { it.isNotEmpty() } .distinct() .joinToString(SOURCE_PATH_SEPARATOR) - - private const val SOURCE_PATH_SEPARATOR = "\n" } } diff --git a/Android/qsferaTestUtil/src/main/java/eu/qsfera/android/testutil/OCFolderBackUpConfiguration.kt b/Android/qsferaTestUtil/src/main/java/eu/qsfera/android/testutil/OCFolderBackUpConfiguration.kt index 669416d7..09ce57d6 100644 --- a/Android/qsferaTestUtil/src/main/java/eu/qsfera/android/testutil/OCFolderBackUpConfiguration.kt +++ b/Android/qsferaTestUtil/src/main/java/eu/qsfera/android/testutil/OCFolderBackUpConfiguration.kt @@ -32,6 +32,9 @@ val OC_BACKUP = FolderBackUpConfiguration( uploadPath = "/Photos", wifiOnly = true, allowRoaming = false, + mobileDataLimitBytes = 0, + mobileDataPeriodStartTimestamp = 0, + mobileDataBytesQueuedInPeriod = 0, chargingOnly = true, lastSyncTimestamp = 1542628397, name = "", @@ -45,6 +48,9 @@ val OC_BACKUP_ENTITY = FolderBackUpEntity( uploadPath = "/Photos", wifiOnly = true, allowRoaming = false, + mobileDataLimitBytes = 0, + mobileDataPeriodStartTimestamp = 0, + mobileDataBytesQueuedInPeriod = 0, chargingOnly = true, lastSyncTimestamp = 1542628397, name = "", diff --git a/PRODUCTION_READINESS.md b/PRODUCTION_READINESS.md index 1c8f177f..35556627 100644 --- a/PRODUCTION_READINESS.md +++ b/PRODUCTION_READINESS.md @@ -30,6 +30,7 @@ Date: 2026-06-08. - Android immediate auto-upload scheduling no longer blocks the caller while waiting for WorkManager state. - Android new auto-upload configurations default to Wi-Fi-only transfers for photos and videos. - Android auto-upload now exposes a no-roaming control for photo and video backup, stores it in Room schema version `50`, and maps mobile backup without roaming to WorkManager `NetworkType.NOT_ROAMING`. +- Android auto-upload now exposes a photo/video daily mobile-data limit, stores it in Room schema version `51`, and stops queuing additional uploads on metered automatic-upload runs after the configured daily byte limit is reached while keeping skipped files eligible for the next scan period. - Server `qsfera_full` compose files now require explicit admin, Keycloak, and S3/MinIO secrets instead of demo defaults. - Server `qsfera_full/env.production.example` provides a tracked production-safe starting point with TLS validation enabled and demo users disabled. - Server `qsfera_full/validate-production-env.sh` checks production `.env` invariants before compose validation: TLS validation enabled, demo users disabled, pinned production image, required domains, required secrets, and no public-production MinIO. @@ -79,7 +80,7 @@ Date: 2026-06-08. ## Remaining P1 - Add Google Photos-style backup status UX: overall status, per-item status, queued/paused/permanent-failure states, and low-storage messaging. -- Add mobile-data controls comparable to Google Photos/Yandex Disk beyond the Wi-Fi-only default: mobile-data limit and no-video-over-mobile-data options. +- Add a clearer video-specific "never use mobile data for videos" shortcut in the Android automatic-upload UI; videos are still Wi-Fi-only by default, and the new daily mobile-data limit applies to metered networks after Wi-Fi-only is turned off. - Store auto-upload sync cursors per source folder rather than one timestamp for all folders. - 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. - Verify sharing controls against Google Drive and Yandex Disk expectations: link expiration, password, disable downloads, organization-only access, and role inheritance.