Add Android auto-upload mobile data limit

This commit is contained in:
Курнат Андрей
2026-06-09 21:54:25 +03:00
parent 4bf54f7fda
commit 89f3cd0934
20 changed files with 1684 additions and 60 deletions
@@ -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_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_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_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_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_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_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_PICTURE_UPLOADS_PATH = "picture_uploads_path";
public static final String PREF__CAMERA_VIDEO_UPLOADS_PATH = "video_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"; public static final String PREF__CAMERA_PICTURE_UPLOADS_BEHAVIOUR = "picture_uploads_behaviour";
@@ -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_CHARGING_ONLY
import eu.qsfera.android.db.PreferenceManager.PREF__CAMERA_PICTURE_UPLOADS_ENABLED 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_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_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
@@ -70,6 +71,7 @@ class SettingsPictureUploadsFragment : PreferenceFragmentCompat() {
private var prefPictureUploadsPath: Preference? = null private var prefPictureUploadsPath: Preference? = null
private var prefPictureUploadsOnWifi: CheckBoxPreference? = null private var prefPictureUploadsOnWifi: CheckBoxPreference? = null
private var prefPictureUploadsAllowRoaming: CheckBoxPreference? = null private var prefPictureUploadsAllowRoaming: CheckBoxPreference? = null
private var prefPictureUploadsMobileDataLimit: ListPreference? = null
private var prefPictureUploadsOnCharging: CheckBoxPreference? = null private var prefPictureUploadsOnCharging: CheckBoxPreference? = null
private var prefPictureUploadsSourcePath: Preference? = null private var prefPictureUploadsSourcePath: Preference? = null
private var prefPictureUploadsClearSourcePaths: Preference? = null private var prefPictureUploadsClearSourcePaths: Preference? = null
@@ -103,6 +105,7 @@ class SettingsPictureUploadsFragment : PreferenceFragmentCompat() {
prefPictureUploadsPath = findPreference(PREF__CAMERA_PICTURE_UPLOADS_PATH) prefPictureUploadsPath = findPreference(PREF__CAMERA_PICTURE_UPLOADS_PATH)
prefPictureUploadsOnWifi = findPreference(PREF__CAMERA_PICTURE_UPLOADS_WIFI_ONLY) prefPictureUploadsOnWifi = findPreference(PREF__CAMERA_PICTURE_UPLOADS_WIFI_ONLY)
prefPictureUploadsAllowRoaming = findPreference(PREF__CAMERA_PICTURE_UPLOADS_ALLOW_ROAMING) 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) prefPictureUploadsOnCharging = findPreference(PREF__CAMERA_PICTURE_UPLOADS_CHARGING_ONLY)
prefPictureUploadsSourcePath = findPreference(PREF__CAMERA_PICTURE_UPLOADS_SOURCE) prefPictureUploadsSourcePath = findPreference(PREF__CAMERA_PICTURE_UPLOADS_SOURCE)
prefPictureUploadsClearSourcePaths = findPreference(PREF_PICTURE_UPLOADS_CLEAR_SOURCE_PATHS) prefPictureUploadsClearSourcePaths = findPreference(PREF_PICTURE_UPLOADS_CLEAR_SOURCE_PATHS)
@@ -117,7 +120,9 @@ class SettingsPictureUploadsFragment : PreferenceFragmentCompat() {
prefPictureUploadsAccount = findPreference(PREF__CAMERA_PICTURE_UPLOADS_ACCOUNT_NAME) prefPictureUploadsAccount = findPreference(PREF__CAMERA_PICTURE_UPLOADS_ACCOUNT_NAME)
val comment = getString(R.string.prefs_camera_upload_source_path_title_required) 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() initPreferenceListeners()
} }
@@ -143,7 +148,7 @@ class SettingsPictureUploadsFragment : PreferenceFragmentCompat() {
showMessageInSnackbar(getString(R.string.prefs_automatic_uploads_not_available_users_light)) showMessageInSnackbar(getString(R.string.prefs_automatic_uploads_not_available_users_light))
} else { } else {
val currentAccount = manageAccountsViewModel.getCurrentAccount()?.name val currentAccount = manageAccountsViewModel.getCurrentAccount()?.name
currentAccount?.let { if (currentAccount != null) {
selectedAccount = if (manageAccountsViewModel.checkUserLight(currentAccount)) { selectedAccount = if (manageAccountsViewModel.checkUserLight(currentAccount)) {
availableAccounts.first().accountName availableAccounts.first().accountName
} else { } else {
@@ -162,6 +167,8 @@ class SettingsPictureUploadsFragment : PreferenceFragmentCompat() {
prefPictureUploadsOnWifi?.isChecked = it.wifiOnly prefPictureUploadsOnWifi?.isChecked = it.wifiOnly
prefPictureUploadsAllowRoaming?.isChecked = it.allowRoaming prefPictureUploadsAllowRoaming?.isChecked = it.allowRoaming
prefPictureUploadsAllowRoaming?.isEnabled = !it.wifiOnly prefPictureUploadsAllowRoaming?.isEnabled = !it.wifiOnly
prefPictureUploadsMobileDataLimit?.value = it.mobileDataLimitBytes.toString()
prefPictureUploadsMobileDataLimit?.isEnabled = !it.wifiOnly
prefPictureUploadsOnCharging?.isChecked = it.chargingOnly prefPictureUploadsOnCharging?.isChecked = it.chargingOnly
prefPictureUploadsBehaviour?.value = it.behavior.name prefPictureUploadsBehaviour?.value = it.behavior.name
prefPictureUploadsLastSync?.summary = formatLastSyncSummary(it.lastSyncTimestamp) prefPictureUploadsLastSync?.summary = formatLastSyncSummary(it.lastSyncTimestamp)
@@ -246,33 +253,40 @@ class SettingsPictureUploadsFragment : PreferenceFragmentCompat() {
} }
prefPictureUploadsOnWifi?.setOnPreferenceChangeListener { _, newValue -> prefPictureUploadsOnWifi?.setOnPreferenceChangeListener { _, newValue ->
newValue as Boolean val value = newValue as? Boolean ?: return@setOnPreferenceChangeListener false
picturesViewModel.useWifiOnly(newValue) picturesViewModel.useWifiOnly(value)
prefPictureUploadsAllowRoaming?.isEnabled = !newValue prefPictureUploadsAllowRoaming?.isEnabled = !value
newValue prefPictureUploadsMobileDataLimit?.isEnabled = !value
value
} }
prefPictureUploadsAllowRoaming?.setOnPreferenceChangeListener { _, newValue -> prefPictureUploadsAllowRoaming?.setOnPreferenceChangeListener { _, newValue ->
newValue as Boolean val value = newValue as? Boolean ?: return@setOnPreferenceChangeListener false
picturesViewModel.allowRoaming(newValue) picturesViewModel.allowRoaming(value)
newValue value
}
prefPictureUploadsMobileDataLimit?.setOnPreferenceChangeListener { _, newValue ->
val value = (newValue as? String)?.toLongOrNull() ?: return@setOnPreferenceChangeListener false
picturesViewModel.setMobileDataLimitBytes(value)
true
} }
prefPictureUploadsOnCharging?.setOnPreferenceChangeListener { _, newValue -> prefPictureUploadsOnCharging?.setOnPreferenceChangeListener { _, newValue ->
newValue as Boolean val value = newValue as? Boolean ?: return@setOnPreferenceChangeListener false
picturesViewModel.useChargingOnly(newValue) picturesViewModel.useChargingOnly(value)
newValue value
} }
prefPictureUploadsAccount?.setOnPreferenceChangeListener { _, newValue -> prefPictureUploadsAccount?.setOnPreferenceChangeListener { _, newValue ->
newValue as String val value = newValue as? String ?: return@setOnPreferenceChangeListener false
picturesViewModel.handleSelectAccount(newValue) picturesViewModel.handleSelectAccount(value)
true true
} }
prefPictureUploadsBehaviour?.setOnPreferenceChangeListener { _, newValue -> prefPictureUploadsBehaviour?.setOnPreferenceChangeListener { _, newValue ->
newValue as String val value = newValue as? String ?: return@setOnPreferenceChangeListener false
picturesViewModel.handleSelectBehaviour(newValue) picturesViewModel.handleSelectBehaviour(value)
true true
} }
} }
@@ -290,6 +304,7 @@ class SettingsPictureUploadsFragment : PreferenceFragmentCompat() {
prefPictureUploadsPath?.isEnabled = value prefPictureUploadsPath?.isEnabled = value
prefPictureUploadsOnWifi?.isEnabled = value prefPictureUploadsOnWifi?.isEnabled = value
prefPictureUploadsAllowRoaming?.isEnabled = value && prefPictureUploadsOnWifi?.isChecked != true prefPictureUploadsAllowRoaming?.isEnabled = value && prefPictureUploadsOnWifi?.isChecked != true
prefPictureUploadsMobileDataLimit?.isEnabled = value && prefPictureUploadsOnWifi?.isChecked != true
prefPictureUploadsOnCharging?.isEnabled = value prefPictureUploadsOnCharging?.isEnabled = value
prefPictureUploadsSourcePath?.isEnabled = value prefPictureUploadsSourcePath?.isEnabled = value
prefPictureUploadsClearSourcePaths?.isEnabled = value && picturesViewModel.getPictureUploadsSourcePaths().isNotEmpty() prefPictureUploadsClearSourcePaths?.isEnabled = value && picturesViewModel.getPictureUploadsSourcePaths().isNotEmpty()
@@ -305,6 +320,7 @@ class SettingsPictureUploadsFragment : PreferenceFragmentCompat() {
prefPictureUploadsClearSourcePaths?.isEnabled = false prefPictureUploadsClearSourcePaths?.isEnabled = false
prefPictureUploadsOnWifi?.isChecked = false prefPictureUploadsOnWifi?.isChecked = false
prefPictureUploadsAllowRoaming?.isChecked = false prefPictureUploadsAllowRoaming?.isChecked = false
prefPictureUploadsMobileDataLimit?.value = "0"
prefPictureUploadsOnCharging?.isChecked = false prefPictureUploadsOnCharging?.isChecked = false
prefPictureUploadsBehaviour?.value = UploadBehavior.COPY.name prefPictureUploadsBehaviour?.value = UploadBehavior.COPY.name
prefPictureUploadsLastSync?.summary = getString(R.string.prefs_camera_upload_last_sync_never) prefPictureUploadsLastSync?.summary = getString(R.string.prefs_camera_upload_last_sync_never)
@@ -122,6 +122,16 @@ class SettingsPictureUploadsViewModel(
} }
} }
fun setMobileDataLimitBytes(mobileDataLimitBytes: Long) {
viewModelScope.launch(coroutinesDispatcherProvider.io) {
savePictureUploadsConfigurationUseCase(
SavePictureUploadsConfigurationUseCase.Params(
composePictureUploadsConfiguration(mobileDataLimitBytes = mobileDataLimitBytes)
)
)
}
}
fun useChargingOnly(chargingOnly: Boolean) { fun useChargingOnly(chargingOnly: Boolean) {
viewModelScope.launch(coroutinesDispatcherProvider.io) { viewModelScope.launch(coroutinesDispatcherProvider.io) {
savePictureUploadsConfigurationUseCase( savePictureUploadsConfigurationUseCase(
@@ -222,6 +232,9 @@ class SettingsPictureUploadsViewModel(
uploadPath: String? = _pictureUploads.value?.uploadPath, uploadPath: String? = _pictureUploads.value?.uploadPath,
wifiOnly: Boolean? = _pictureUploads.value?.wifiOnly, wifiOnly: Boolean? = _pictureUploads.value?.wifiOnly,
allowRoaming: Boolean? = _pictureUploads.value?.allowRoaming, 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, chargingOnly: Boolean? = _pictureUploads.value?.chargingOnly,
sourcePath: String? = _pictureUploads.value?.sourcePath, sourcePath: String? = _pictureUploads.value?.sourcePath,
behavior: UploadBehavior? = _pictureUploads.value?.behavior, behavior: UploadBehavior? = _pictureUploads.value?.behavior,
@@ -232,9 +245,12 @@ class SettingsPictureUploadsViewModel(
behavior = behavior ?: UploadBehavior.COPY, behavior = behavior ?: UploadBehavior.COPY,
sourcePath = sourcePath.orEmpty(), sourcePath = sourcePath.orEmpty(),
uploadPath = uploadPath ?: PREF__CAMERA_UPLOADS_DEFAULT_PATH, uploadPath = uploadPath ?: PREF__CAMERA_UPLOADS_DEFAULT_PATH,
wifiOnly = wifiOnly ?: true, wifiOnly = wifiOnly != false,
allowRoaming = allowRoaming ?: false, allowRoaming = allowRoaming == true,
chargingOnly = chargingOnly ?: false, mobileDataLimitBytes = mobileDataLimitBytes ?: 0,
mobileDataPeriodStartTimestamp = mobileDataPeriodStartTimestamp ?: 0,
mobileDataBytesQueuedInPeriod = mobileDataBytesQueuedInPeriod ?: 0,
chargingOnly = chargingOnly == true,
lastSyncTimestamp = timestamp ?: initialSyncTimestamp, lastSyncTimestamp = timestamp ?: initialSyncTimestamp,
name = _pictureUploads.value?.name ?: pictureUploadsName, name = _pictureUploads.value?.name ?: pictureUploadsName,
spaceId = spaceId, spaceId = spaceId,
@@ -251,7 +267,7 @@ class SettingsPictureUploadsViewModel(
fun getUploadPathString(): String { fun getUploadPathString(): String {
val spaceName = handleSpaceName(pictureUploadsSpace?.name) val spaceName = handleSpaceName(pictureUploadsSpace?.name).orEmpty()
val uploadPath = pictureUploads.value?.uploadPath val uploadPath = pictureUploads.value?.uploadPath
val spaceId = pictureUploads.value?.spaceId val spaceId = pictureUploads.value?.spaceId
@@ -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_BEHAVIOUR
import eu.qsfera.android.db.PreferenceManager.PREF__CAMERA_VIDEO_UPLOADS_CHARGING_ONLY 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_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_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
@@ -70,6 +71,7 @@ class SettingsVideoUploadsFragment : PreferenceFragmentCompat() {
private var prefVideoUploadsPath: Preference? = null private var prefVideoUploadsPath: Preference? = null
private var prefVideoUploadsOnWifi: CheckBoxPreference? = null private var prefVideoUploadsOnWifi: CheckBoxPreference? = null
private var prefVideoUploadsAllowRoaming: CheckBoxPreference? = null private var prefVideoUploadsAllowRoaming: CheckBoxPreference? = null
private var prefVideoUploadsMobileDataLimit: ListPreference? = null
private var prefVideoUploadsOnCharging: CheckBoxPreference? = null private var prefVideoUploadsOnCharging: CheckBoxPreference? = null
private var prefVideoUploadsSourcePath: Preference? = null private var prefVideoUploadsSourcePath: Preference? = null
private var prefVideoUploadsClearSourcePaths: Preference? = null private var prefVideoUploadsClearSourcePaths: Preference? = null
@@ -103,6 +105,7 @@ class SettingsVideoUploadsFragment : PreferenceFragmentCompat() {
prefVideoUploadsPath = findPreference(PREF__CAMERA_VIDEO_UPLOADS_PATH) prefVideoUploadsPath = findPreference(PREF__CAMERA_VIDEO_UPLOADS_PATH)
prefVideoUploadsOnWifi = findPreference(PREF__CAMERA_VIDEO_UPLOADS_WIFI_ONLY) prefVideoUploadsOnWifi = findPreference(PREF__CAMERA_VIDEO_UPLOADS_WIFI_ONLY)
prefVideoUploadsAllowRoaming = findPreference(PREF__CAMERA_VIDEO_UPLOADS_ALLOW_ROAMING) 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) prefVideoUploadsOnCharging = findPreference(PREF__CAMERA_VIDEO_UPLOADS_CHARGING_ONLY)
prefVideoUploadsSourcePath = findPreference(PREF__CAMERA_VIDEO_UPLOADS_SOURCE) prefVideoUploadsSourcePath = findPreference(PREF__CAMERA_VIDEO_UPLOADS_SOURCE)
prefVideoUploadsClearSourcePaths = findPreference(PREF_VIDEO_UPLOADS_CLEAR_SOURCE_PATHS) prefVideoUploadsClearSourcePaths = findPreference(PREF_VIDEO_UPLOADS_CLEAR_SOURCE_PATHS)
@@ -115,7 +118,9 @@ class SettingsVideoUploadsFragment : PreferenceFragmentCompat() {
prefVideoUploadsAccount = findPreference<ListPreference>(PREF__CAMERA_VIDEO_UPLOADS_ACCOUNT_NAME) prefVideoUploadsAccount = findPreference<ListPreference>(PREF__CAMERA_VIDEO_UPLOADS_ACCOUNT_NAME)
val comment = getString(R.string.prefs_camera_upload_source_path_title_required) 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() initPreferenceListeners()
} }
@@ -141,7 +146,7 @@ class SettingsVideoUploadsFragment : PreferenceFragmentCompat() {
showMessageInSnackbar(getString(R.string.prefs_automatic_uploads_not_available_users_light)) showMessageInSnackbar(getString(R.string.prefs_automatic_uploads_not_available_users_light))
} else { } else {
val currentAccount = manageAccountsViewModel.getCurrentAccount()?.name val currentAccount = manageAccountsViewModel.getCurrentAccount()?.name
currentAccount?.let { if (currentAccount != null) {
selectedAccount = if (manageAccountsViewModel.checkUserLight(currentAccount)) { selectedAccount = if (manageAccountsViewModel.checkUserLight(currentAccount)) {
availableAccounts.first().accountName availableAccounts.first().accountName
} else { } else {
@@ -160,6 +165,8 @@ class SettingsVideoUploadsFragment : PreferenceFragmentCompat() {
prefVideoUploadsOnWifi?.isChecked = it.wifiOnly prefVideoUploadsOnWifi?.isChecked = it.wifiOnly
prefVideoUploadsAllowRoaming?.isChecked = it.allowRoaming prefVideoUploadsAllowRoaming?.isChecked = it.allowRoaming
prefVideoUploadsAllowRoaming?.isEnabled = !it.wifiOnly prefVideoUploadsAllowRoaming?.isEnabled = !it.wifiOnly
prefVideoUploadsMobileDataLimit?.value = it.mobileDataLimitBytes.toString()
prefVideoUploadsMobileDataLimit?.isEnabled = !it.wifiOnly
prefVideoUploadsOnCharging?.isChecked = it.chargingOnly prefVideoUploadsOnCharging?.isChecked = it.chargingOnly
prefVideoUploadsBehaviour?.value = it.behavior.name prefVideoUploadsBehaviour?.value = it.behavior.name
prefVideoUploadsLastSync?.summary = formatLastSyncSummary(it.lastSyncTimestamp) prefVideoUploadsLastSync?.summary = formatLastSyncSummary(it.lastSyncTimestamp)
@@ -244,33 +251,40 @@ class SettingsVideoUploadsFragment : PreferenceFragmentCompat() {
} }
prefVideoUploadsOnWifi?.setOnPreferenceChangeListener { _, newValue -> prefVideoUploadsOnWifi?.setOnPreferenceChangeListener { _, newValue ->
newValue as Boolean val value = newValue as? Boolean ?: return@setOnPreferenceChangeListener false
videosViewModel.useWifiOnly(newValue) videosViewModel.useWifiOnly(value)
prefVideoUploadsAllowRoaming?.isEnabled = !newValue prefVideoUploadsAllowRoaming?.isEnabled = !value
newValue prefVideoUploadsMobileDataLimit?.isEnabled = !value
value
} }
prefVideoUploadsAllowRoaming?.setOnPreferenceChangeListener { _, newValue -> prefVideoUploadsAllowRoaming?.setOnPreferenceChangeListener { _, newValue ->
newValue as Boolean val value = newValue as? Boolean ?: return@setOnPreferenceChangeListener false
videosViewModel.allowRoaming(newValue) videosViewModel.allowRoaming(value)
newValue value
}
prefVideoUploadsMobileDataLimit?.setOnPreferenceChangeListener { _, newValue ->
val value = (newValue as? String)?.toLongOrNull() ?: return@setOnPreferenceChangeListener false
videosViewModel.setMobileDataLimitBytes(value)
true
} }
prefVideoUploadsOnCharging?.setOnPreferenceChangeListener { _, newValue -> prefVideoUploadsOnCharging?.setOnPreferenceChangeListener { _, newValue ->
newValue as Boolean val value = newValue as? Boolean ?: return@setOnPreferenceChangeListener false
videosViewModel.useChargingOnly(newValue) videosViewModel.useChargingOnly(value)
newValue value
} }
prefVideoUploadsAccount?.setOnPreferenceChangeListener { _, newValue -> prefVideoUploadsAccount?.setOnPreferenceChangeListener { _, newValue ->
newValue as String val value = newValue as? String ?: return@setOnPreferenceChangeListener false
videosViewModel.handleSelectAccount(newValue) videosViewModel.handleSelectAccount(value)
true true
} }
prefVideoUploadsBehaviour?.setOnPreferenceChangeListener { _, newValue -> prefVideoUploadsBehaviour?.setOnPreferenceChangeListener { _, newValue ->
newValue as String val value = newValue as? String ?: return@setOnPreferenceChangeListener false
videosViewModel.handleSelectBehaviour(newValue) videosViewModel.handleSelectBehaviour(value)
true true
} }
} }
@@ -288,6 +302,7 @@ class SettingsVideoUploadsFragment : PreferenceFragmentCompat() {
prefVideoUploadsPath?.isEnabled = value prefVideoUploadsPath?.isEnabled = value
prefVideoUploadsOnWifi?.isEnabled = value prefVideoUploadsOnWifi?.isEnabled = value
prefVideoUploadsAllowRoaming?.isEnabled = value && prefVideoUploadsOnWifi?.isChecked != true prefVideoUploadsAllowRoaming?.isEnabled = value && prefVideoUploadsOnWifi?.isChecked != true
prefVideoUploadsMobileDataLimit?.isEnabled = value && prefVideoUploadsOnWifi?.isChecked != true
prefVideoUploadsOnCharging?.isEnabled = value prefVideoUploadsOnCharging?.isEnabled = value
prefVideoUploadsSourcePath?.isEnabled = value prefVideoUploadsSourcePath?.isEnabled = value
prefVideoUploadsClearSourcePaths?.isEnabled = value && videosViewModel.getVideoUploadsSourcePaths().isNotEmpty() prefVideoUploadsClearSourcePaths?.isEnabled = value && videosViewModel.getVideoUploadsSourcePaths().isNotEmpty()
@@ -303,6 +318,7 @@ class SettingsVideoUploadsFragment : PreferenceFragmentCompat() {
prefVideoUploadsClearSourcePaths?.isEnabled = false prefVideoUploadsClearSourcePaths?.isEnabled = false
prefVideoUploadsOnWifi?.isChecked = false prefVideoUploadsOnWifi?.isChecked = false
prefVideoUploadsAllowRoaming?.isChecked = false prefVideoUploadsAllowRoaming?.isChecked = false
prefVideoUploadsMobileDataLimit?.value = "0"
prefVideoUploadsOnCharging?.isChecked = false prefVideoUploadsOnCharging?.isChecked = false
prefVideoUploadsBehaviour?.value = UploadBehavior.COPY.name prefVideoUploadsBehaviour?.value = UploadBehavior.COPY.name
prefVideoUploadsLastSync?.summary = getString(R.string.prefs_camera_upload_last_sync_never) prefVideoUploadsLastSync?.summary = getString(R.string.prefs_camera_upload_last_sync_never)
@@ -122,6 +122,16 @@ class SettingsVideoUploadsViewModel(
} }
} }
fun setMobileDataLimitBytes(mobileDataLimitBytes: Long) {
viewModelScope.launch(coroutinesDispatcherProvider.io) {
saveVideoUploadsConfigurationUseCase(
SaveVideoUploadsConfigurationUseCase.Params(
composeVideoUploadsConfiguration(mobileDataLimitBytes = mobileDataLimitBytes)
)
)
}
}
fun useChargingOnly(chargingOnly: Boolean) { fun useChargingOnly(chargingOnly: Boolean) {
viewModelScope.launch(coroutinesDispatcherProvider.io) { viewModelScope.launch(coroutinesDispatcherProvider.io) {
saveVideoUploadsConfigurationUseCase( saveVideoUploadsConfigurationUseCase(
@@ -222,6 +232,9 @@ class SettingsVideoUploadsViewModel(
uploadPath: String? = _videoUploads.value?.uploadPath, uploadPath: String? = _videoUploads.value?.uploadPath,
wifiOnly: Boolean? = _videoUploads.value?.wifiOnly, wifiOnly: Boolean? = _videoUploads.value?.wifiOnly,
allowRoaming: Boolean? = _videoUploads.value?.allowRoaming, 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, chargingOnly: Boolean? = _videoUploads.value?.chargingOnly,
sourcePath: String? = _videoUploads.value?.sourcePath, sourcePath: String? = _videoUploads.value?.sourcePath,
behavior: UploadBehavior? = _videoUploads.value?.behavior, behavior: UploadBehavior? = _videoUploads.value?.behavior,
@@ -233,9 +246,12 @@ class SettingsVideoUploadsViewModel(
behavior = behavior ?: UploadBehavior.COPY, behavior = behavior ?: UploadBehavior.COPY,
sourcePath = sourcePath.orEmpty(), sourcePath = sourcePath.orEmpty(),
uploadPath = uploadPath ?: PREF__CAMERA_UPLOADS_DEFAULT_PATH, uploadPath = uploadPath ?: PREF__CAMERA_UPLOADS_DEFAULT_PATH,
wifiOnly = wifiOnly ?: true, wifiOnly = wifiOnly != false,
allowRoaming = allowRoaming ?: false, allowRoaming = allowRoaming == true,
chargingOnly = chargingOnly ?: false, mobileDataLimitBytes = mobileDataLimitBytes ?: 0,
mobileDataPeriodStartTimestamp = mobileDataPeriodStartTimestamp ?: 0,
mobileDataBytesQueuedInPeriod = mobileDataBytesQueuedInPeriod ?: 0,
chargingOnly = chargingOnly == true,
lastSyncTimestamp = timestamp ?: initialSyncTimestamp, lastSyncTimestamp = timestamp ?: initialSyncTimestamp,
name = _videoUploads.value?.name ?: videoUploadsName, name = _videoUploads.value?.name ?: videoUploadsName,
spaceId = spaceId, spaceId = spaceId,
@@ -252,7 +268,7 @@ class SettingsVideoUploadsViewModel(
fun getUploadPathString(): String { fun getUploadPathString(): String {
val spaceName = handleSpaceName(videoUploadsSpace?.name) val spaceName = handleSpaceName(videoUploadsSpace?.name).orEmpty()
val uploadPath = videoUploads.value?.uploadPath val uploadPath = videoUploads.value?.uploadPath
val spaceId = videoUploads.value?.spaceId val spaceId = videoUploads.value?.spaceId
@@ -22,6 +22,7 @@
package eu.qsfera.android.workers package eu.qsfera.android.workers
import android.content.Context import android.content.Context
import android.net.ConnectivityManager
import android.net.Uri import android.net.Uri
import androidx.core.net.toUri import androidx.core.net.toUri
import androidx.documentfile.provider.DocumentFile import androidx.documentfile.provider.DocumentFile
@@ -51,7 +52,9 @@ import org.koin.core.component.inject
import timber.log.Timber import timber.log.Timber
import java.io.File import java.io.File
import java.util.Calendar
import java.util.Date import java.util.Date
import java.util.TimeZone
import java.util.concurrent.TimeUnit import java.util.concurrent.TimeUnit
class AutomaticUploadsWorker( class AutomaticUploadsWorker(
@@ -142,9 +145,12 @@ class AutomaticUploadsWorker(
WorkManager.getInstance(appContext).cancelUniqueWork(AUTOMATIC_UPLOADS_WORKER) WorkManager.getInstance(appContext).cancelUniqueWork(AUTOMATIC_UPLOADS_WORKER)
} }
private fun syncFolder(folderBackUpConfiguration: FolderBackUpConfiguration?) { private fun isActiveNetworkMetered(): Boolean {
if (folderBackUpConfiguration == null) return val connectivityManager = appContext.getSystemService(Context.CONNECTIVITY_SERVICE) as? ConnectivityManager
return connectivityManager?.isActiveNetworkMetered != false
}
private fun syncFolder(folderBackUpConfiguration: FolderBackUpConfiguration) {
val syncType = when { val syncType = when {
folderBackUpConfiguration.isPictureUploads -> SyncType.PICTURE_UPLOADS folderBackUpConfiguration.isPictureUploads -> SyncType.PICTURE_UPLOADS
folderBackUpConfiguration.isVideoUploads -> SyncType.VIDEO_UPLOADS folderBackUpConfiguration.isVideoUploads -> SyncType.VIDEO_UPLOADS
@@ -153,6 +159,9 @@ class AutomaticUploadsWorker(
} }
val currentTimestamp = System.currentTimeMillis() val currentTimestamp = System.currentTimeMillis()
val activeNetworkMetered = isActiveNetworkMetered()
var mobileDataQuotaState = resolveMobileDataQuotaState(folderBackUpConfiguration, currentTimestamp)
var mobileDataLimitReached = false
val localPicturesDocumentFiles: List<DocumentFile> = folderBackUpConfiguration.sourcePaths.flatMap { sourcePath -> val localPicturesDocumentFiles: List<DocumentFile> = folderBackUpConfiguration.sourcePaths.flatMap { sourcePath ->
getFilesReadyToUpload( getFilesReadyToUpload(
@@ -163,19 +172,34 @@ class AutomaticUploadsWorker(
) )
} }
showNotification(syncType, localPicturesDocumentFiles.size) var queuedUploadsCount = 0
for (documentFile in localPicturesDocumentFiles) { for (documentFile in localPicturesDocumentFiles) {
// Dedup: if this content URI already has a queued, in-progress, or succeeded transfer, // 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 // 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 // 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. // when two workers race on the same cache path.
val contentUri = documentFile.uri.toString() val contentUri = documentFile.uri.toString()
if (transferRepository.existsNonFailedTransferForUri(contentUri)) { if (transferRepository.existsNonFailedTransferForUri(contentUri)) {
Timber.d("Skipping already-tracked file: %s", documentFile.name) Timber.d("Skipping already-tracked file: %s", documentFile.name)
continue 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( val uploadId = storeInUploadsDatabase(
documentFile = documentFile, documentFile = documentFile,
uploadPath = folderBackUpConfiguration.uploadPath.plus(File.separator).plus(documentFile.name), uploadPath = folderBackUpConfiguration.uploadPath.plus(File.separator).plus(documentFile.name),
@@ -198,11 +222,26 @@ class AutomaticUploadsWorker(
allowRoaming = folderBackUpConfiguration.allowRoaming, allowRoaming = folderBackUpConfiguration.allowRoaming,
chargingOnly = folderBackUpConfiguration.chargingOnly 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 // Save safeTimestamp (not currentTimestamp) so that files skipped by the
// write-safety buffer are re-evaluated on the next run instead of being lost. // 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 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( private fun showNotification(
@@ -251,23 +290,29 @@ class AutomaticUploadsWorker(
) )
} }
private fun updateTimestamp( private fun updateConfigurationAfterScan(
folderBackUpConfiguration: FolderBackUpConfiguration, folderBackUpConfiguration: FolderBackUpConfiguration,
syncType: SyncType, syncType: SyncType,
currentTimestamp: Long, nextSyncTimestamp: Long,
mobileDataQuotaState: MobileDataQuotaState,
) { ) {
val updatedConfiguration = folderBackUpConfiguration.copy(
lastSyncTimestamp = nextSyncTimestamp,
mobileDataPeriodStartTimestamp = mobileDataQuotaState.periodStartTimestamp,
mobileDataBytesQueuedInPeriod = mobileDataQuotaState.bytesQueuedInPeriod
)
when (syncType) { when (syncType) {
SyncType.PICTURE_UPLOADS -> { SyncType.PICTURE_UPLOADS -> {
val savePictureUploadsConfigurationUseCase: SavePictureUploadsConfigurationUseCase by inject() val savePictureUploadsConfigurationUseCase: SavePictureUploadsConfigurationUseCase by inject()
savePictureUploadsConfigurationUseCase( savePictureUploadsConfigurationUseCase(
SavePictureUploadsConfigurationUseCase.Params(folderBackUpConfiguration.copy(lastSyncTimestamp = currentTimestamp)) SavePictureUploadsConfigurationUseCase.Params(updatedConfiguration)
) )
} }
SyncType.VIDEO_UPLOADS -> { SyncType.VIDEO_UPLOADS -> {
val saveVideoUploadsConfigurationUseCase: SaveVideoUploadsConfigurationUseCase by inject() val saveVideoUploadsConfigurationUseCase: SaveVideoUploadsConfigurationUseCase by inject()
saveVideoUploadsConfigurationUseCase( saveVideoUploadsConfigurationUseCase(
SaveVideoUploadsConfigurationUseCase.Params(folderBackUpConfiguration.copy(lastSyncTimestamp = currentTimestamp)) SaveVideoUploadsConfigurationUseCase.Params(updatedConfiguration)
) )
} }
} }
@@ -281,7 +326,7 @@ class AutomaticUploadsWorker(
): List<DocumentFile> { ): List<DocumentFile> {
val sourceUri: Uri = sourcePath.toUri() val sourceUri: Uri = sourcePath.toUri()
val documentTree = DocumentFile.fromTreeUri(applicationContext, sourceUri) 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 // 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 // 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("Last sync ${syncType.name}: ${Date(lastSyncTimestamp)}")
Timber.i("CurrentTimestamp ${Date(currentTimestamp)}") 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") Timber.i("${filteredList.size} files are ${syncType.name} and were taken after last sync")
return filteredList return filteredList
@@ -355,6 +400,11 @@ class AutomaticUploadsWorker(
} }
companion object { companion object {
data class MobileDataQuotaState(
val periodStartTimestamp: Long,
val bytesQueuedInPeriod: Long,
)
const val AUTOMATIC_UPLOADS_WORKER = "AUTOMATIC_UPLOADS_WORKER" const val AUTOMATIC_UPLOADS_WORKER = "AUTOMATIC_UPLOADS_WORKER"
const val IMMEDIATE_UPLOADS_WORKER = "IMMEDIATE_AUTOMATIC_UPLOADS_WORKER" const val IMMEDIATE_UPLOADS_WORKER = "IMMEDIATE_AUTOMATIC_UPLOADS_WORKER"
const val MEDIA_STORE_UPLOADS_WORKER = "MEDIA_STORE_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 private const val videoUploadsNotificationId = 102
const val WRITE_SAFETY_BUFFER_MS = 10_000L const val WRITE_SAFETY_BUFFER_MS = 10_000L
const val MEDIA_STORE_TRIGGER_MAX_DELAY_MS = 60_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
} }
} }
@@ -601,6 +601,22 @@
<string name="confirmation_clear_camera_upload_sources_message">Backup will stop scanning phone folders until you add a folder again.</string> <string name="confirmation_clear_camera_upload_sources_message">Backup will stop scanning phone folders until you add a folder again.</string>
<string name="prefs_camera_upload_allow_roaming">Allow backup while roaming</string> <string name="prefs_camera_upload_allow_roaming">Allow backup while roaming</string>
<string name="prefs_camera_upload_allow_roaming_summary">Only applies when Wi-Fi-only backup is off</string> <string name="prefs_camera_upload_allow_roaming_summary">Only applies when Wi-Fi-only backup is off</string>
<string name="prefs_camera_upload_mobile_data_limit_title">Mobile data daily limit</string>
<string name="prefs_camera_upload_mobile_data_limit_summary">Only applies when Wi-Fi-only backup is off and the active network is metered</string>
<string-array name="prefs_camera_upload_mobile_data_limit_entries">
<item>Unlimited</item>
<item>10 MB per day</item>
<item>50 MB per day</item>
<item>200 MB per day</item>
<item>1 GB per day</item>
</string-array>
<string-array name="prefs_camera_upload_mobile_data_limit_values">
<item>0</item>
<item>10485760</item>
<item>52428800</item>
<item>209715200</item>
<item>1073741824</item>
</string-array>
<string name="prefs_camera_upload_behaviour_dialog_title">Original file after backup</string> <string name="prefs_camera_upload_behaviour_dialog_title">Original file after backup</string>
<string name="prefs_camera_upload_behaviour_title">Original file after backup</string> <string name="prefs_camera_upload_behaviour_title">Original file after backup</string>
<string name="prefs_camera_upload_last_sync_title">Last backup scan</string> <string name="prefs_camera_upload_last_sync_title">Last backup scan</string>
@@ -69,6 +69,15 @@
app:key="picture_uploads_allow_roaming" app:key="picture_uploads_allow_roaming"
app:summary="@string/prefs_camera_upload_allow_roaming_summary" app:summary="@string/prefs_camera_upload_allow_roaming_summary"
app:title="@string/prefs_camera_upload_allow_roaming" /> app:title="@string/prefs_camera_upload_allow_roaming" />
<ListPreference
app:entries="@array/prefs_camera_upload_mobile_data_limit_entries"
app:entryValues="@array/prefs_camera_upload_mobile_data_limit_values"
app:iconSpaceReserved="false"
app:key="picture_uploads_mobile_data_limit"
app:negativeButtonText=""
app:summary="@string/prefs_camera_upload_mobile_data_limit_summary"
app:title="@string/prefs_camera_upload_mobile_data_limit_title"
app:useSimpleSummaryProvider="true" />
<CheckBoxPreference <CheckBoxPreference
app:iconSpaceReserved="false" app:iconSpaceReserved="false"
app:key="picture_uploads_on_charging" app:key="picture_uploads_on_charging"
@@ -67,6 +67,15 @@
app:key="video_uploads_allow_roaming" app:key="video_uploads_allow_roaming"
app:summary="@string/prefs_camera_upload_allow_roaming_summary" app:summary="@string/prefs_camera_upload_allow_roaming_summary"
app:title="@string/prefs_camera_upload_allow_roaming" /> app:title="@string/prefs_camera_upload_allow_roaming" />
<ListPreference
app:entries="@array/prefs_camera_upload_mobile_data_limit_entries"
app:entryValues="@array/prefs_camera_upload_mobile_data_limit_values"
app:iconSpaceReserved="false"
app:key="video_uploads_mobile_data_limit"
app:negativeButtonText=""
app:summary="@string/prefs_camera_upload_mobile_data_limit_summary"
app:title="@string/prefs_camera_upload_mobile_data_limit_title"
app:useSimpleSummaryProvider="true" />
<CheckBoxPreference <CheckBoxPreference
app:iconSpaceReserved="false" app:iconSpaceReserved="false"
app:key="video_uploads_on_charging" app:key="video_uploads_on_charging"
@@ -0,0 +1,143 @@
package eu.qsfera.android.workers
import eu.qsfera.android.domain.automaticuploads.model.FolderBackUpConfiguration
import eu.qsfera.android.domain.automaticuploads.model.UploadBehavior
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
import java.util.TimeZone
class AutomaticUploadsWorkerTest {
@Test
fun `mobile data quota period starts at local midnight`() {
val timeZone = TimeZone.getTimeZone("UTC")
val timestamp = 1_717_411_245_000L // 2024-06-03T10:40:45Z
val periodStart = AutomaticUploadsWorker.getMobileDataLimitPeriodStart(timestamp, timeZone)
assertEquals(1_717_372_800_000L, periodStart) // 2024-06-03T00:00:00Z
}
@Test
fun `mobile data quota resets when period changes`() {
val timeZone = TimeZone.getTimeZone("UTC")
val configuration = backupConfiguration(
mobileDataPeriodStartTimestamp = 1_717_286_400_000L,
mobileDataBytesQueuedInPeriod = 10_000L
)
val state = AutomaticUploadsWorker.resolveMobileDataQuotaState(
folderBackUpConfiguration = configuration,
currentTimestamp = 1_717_372_800_000L,
timeZone = timeZone
)
assertEquals(1_717_372_800_000L, state.periodStartTimestamp)
assertEquals(0L, state.bytesQueuedInPeriod)
}
@Test
fun `mobile data quota keeps bytes when period matches`() {
val periodStart = 1_717_372_800_000L
val configuration = backupConfiguration(
mobileDataPeriodStartTimestamp = periodStart,
mobileDataBytesQueuedInPeriod = 10_000L
)
val state = AutomaticUploadsWorker.resolveMobileDataQuotaState(
folderBackUpConfiguration = configuration,
currentTimestamp = periodStart + 60_000L,
timeZone = TimeZone.getTimeZone("UTC")
)
assertEquals(periodStart, state.periodStartTimestamp)
assertEquals(10_000L, state.bytesQueuedInPeriod)
}
@Test
fun `wifi only uploads ignore mobile data quota`() {
assertTrue(
AutomaticUploadsWorker.canQueueMobileDataUpload(
wifiOnly = true,
isActiveNetworkMetered = true,
mobileDataLimitBytes = 10L,
mobileDataBytesQueuedInPeriod = 10L,
fileSize = 1_000L
)
)
}
@Test
fun `unlimited mobile data quota allows upload`() {
assertTrue(
AutomaticUploadsWorker.canQueueMobileDataUpload(
wifiOnly = false,
isActiveNetworkMetered = true,
mobileDataLimitBytes = 0L,
mobileDataBytesQueuedInPeriod = Long.MAX_VALUE,
fileSize = 1_000L
)
)
}
@Test
fun `unmetered active network ignores mobile data quota`() {
assertTrue(
AutomaticUploadsWorker.canQueueMobileDataUpload(
wifiOnly = false,
isActiveNetworkMetered = false,
mobileDataLimitBytes = 10L,
mobileDataBytesQueuedInPeriod = 10L,
fileSize = 1_000L
)
)
}
@Test
fun `limited mobile data quota blocks upload that would exceed limit`() {
assertFalse(
AutomaticUploadsWorker.canQueueMobileDataUpload(
wifiOnly = false,
isActiveNetworkMetered = true,
mobileDataLimitBytes = 1_000L,
mobileDataBytesQueuedInPeriod = 800L,
fileSize = 201L
)
)
}
@Test
fun `limited mobile data quota allows upload at exact limit`() {
assertTrue(
AutomaticUploadsWorker.canQueueMobileDataUpload(
wifiOnly = false,
isActiveNetworkMetered = true,
mobileDataLimitBytes = 1_000L,
mobileDataBytesQueuedInPeriod = 800L,
fileSize = 200L
)
)
}
private fun backupConfiguration(
mobileDataPeriodStartTimestamp: Long,
mobileDataBytesQueuedInPeriod: Long,
): FolderBackUpConfiguration =
FolderBackUpConfiguration(
accountName = "user@example.com",
behavior = UploadBehavior.COPY,
sourcePath = "content://source",
uploadPath = "/CameraUpload",
wifiOnly = false,
allowRoaming = false,
mobileDataLimitBytes = 100_000L,
mobileDataPeriodStartTimestamp = mobileDataPeriodStartTimestamp,
mobileDataBytesQueuedInPeriod = mobileDataBytesQueuedInPeriod,
chargingOnly = false,
lastSyncTimestamp = 0L,
name = FolderBackUpConfiguration.pictureUploadsName,
spaceId = null,
)
}
File diff suppressed because it is too large Load Diff
@@ -31,7 +31,7 @@ public class ProviderMeta {
public static final String DB_NAME = "filelist"; public static final String DB_NAME = "filelist";
public static final String NEW_DB_NAME = "qsfera_database"; public static final String NEW_DB_NAME = "qsfera_database";
public static final int DB_VERSION = 50; public static final int DB_VERSION = 51;
private ProviderMeta() { private ProviderMeta() {
} }
@@ -53,6 +53,7 @@ import eu.qsfera.android.data.migrations.MIGRATION_42_43
import eu.qsfera.android.data.migrations.MIGRATION_47_48 import eu.qsfera.android.data.migrations.MIGRATION_47_48
import eu.qsfera.android.data.migrations.MIGRATION_48_49 import eu.qsfera.android.data.migrations.MIGRATION_48_49
import eu.qsfera.android.data.migrations.MIGRATION_49_50 import eu.qsfera.android.data.migrations.MIGRATION_49_50
import eu.qsfera.android.data.migrations.MIGRATION_50_51
import eu.qsfera.android.data.sharing.shares.db.OCShareDao import eu.qsfera.android.data.sharing.shares.db.OCShareDao
import eu.qsfera.android.data.sharing.shares.db.OCShareEntity import eu.qsfera.android.data.sharing.shares.db.OCShareEntity
import eu.qsfera.android.data.spaces.db.SpaceSpecialEntity import eu.qsfera.android.data.spaces.db.SpaceSpecialEntity
@@ -129,7 +130,8 @@ abstract class QSferaDatabase : RoomDatabase() {
MIGRATION_42_43, MIGRATION_42_43,
MIGRATION_47_48, MIGRATION_47_48,
MIGRATION_48_49, MIGRATION_48_49,
MIGRATION_49_50) MIGRATION_49_50,
MIGRATION_50_51)
.build() .build()
INSTANCE = instance INSTANCE = instance
instance instance
@@ -69,6 +69,9 @@ class OCLocalFolderBackupDataSource(
uploadPath = uploadPath, uploadPath = uploadPath,
wifiOnly = wifiOnly, wifiOnly = wifiOnly,
allowRoaming = allowRoaming, allowRoaming = allowRoaming,
mobileDataLimitBytes = mobileDataLimitBytes,
mobileDataPeriodStartTimestamp = mobileDataPeriodStartTimestamp,
mobileDataBytesQueuedInPeriod = mobileDataBytesQueuedInPeriod,
chargingOnly = chargingOnly, chargingOnly = chargingOnly,
name = name, name = name,
lastSyncTimestamp = lastSyncTimestamp, lastSyncTimestamp = lastSyncTimestamp,
@@ -85,6 +88,9 @@ class OCLocalFolderBackupDataSource(
uploadPath = uploadPath, uploadPath = uploadPath,
wifiOnly = wifiOnly, wifiOnly = wifiOnly,
allowRoaming = allowRoaming, allowRoaming = allowRoaming,
mobileDataLimitBytes = mobileDataLimitBytes,
mobileDataPeriodStartTimestamp = mobileDataPeriodStartTimestamp,
mobileDataBytesQueuedInPeriod = mobileDataBytesQueuedInPeriod,
chargingOnly = chargingOnly, chargingOnly = chargingOnly,
lastSyncTimestamp = lastSyncTimestamp, lastSyncTimestamp = lastSyncTimestamp,
name = name, name = name,
@@ -30,6 +30,9 @@ data class FolderBackUpEntity(
val uploadPath: String, val uploadPath: String,
val wifiOnly: Boolean, val wifiOnly: Boolean,
val allowRoaming: Boolean, val allowRoaming: Boolean,
val mobileDataLimitBytes: Long,
val mobileDataPeriodStartTimestamp: Long,
val mobileDataBytesQueuedInPeriod: Long,
val chargingOnly: Boolean, val chargingOnly: Boolean,
val name: String, val name: String,
val lastSyncTimestamp: Long, val lastSyncTimestamp: Long,
@@ -48,7 +48,7 @@ class CameraUploadsMigrationToRoom(val sharedPreferencesProvider: SharedPreferen
if (!sharedPreferencesProvider.getBoolean(PREF__CAMERA_PICTURE_UPLOADS_ENABLED, false)) return null if (!sharedPreferencesProvider.getBoolean(PREF__CAMERA_PICTURE_UPLOADS_ENABLED, false)) return null
return FolderBackUpConfiguration( return FolderBackUpConfiguration(
accountName = sharedPreferencesProvider.getString(PREF__CAMERA_PICTURE_UPLOADS_ACCOUNT_NAME, null) ?: "", accountName = sharedPreferencesProvider.getString(PREF__CAMERA_PICTURE_UPLOADS_ACCOUNT_NAME, null).orEmpty(),
wifiOnly = sharedPreferencesProvider.getBoolean(PREF__CAMERA_PICTURE_UPLOADS_WIFI_ONLY, false), wifiOnly = sharedPreferencesProvider.getBoolean(PREF__CAMERA_PICTURE_UPLOADS_WIFI_ONLY, false),
uploadPath = getUploadPathForPreference(PREF__CAMERA_PICTURE_UPLOADS_PATH), uploadPath = getUploadPathForPreference(PREF__CAMERA_PICTURE_UPLOADS_PATH),
sourcePath = getSourcePathForPreference(PREF__CAMERA_PICTURE_UPLOADS_SOURCE), sourcePath = getSourcePathForPreference(PREF__CAMERA_PICTURE_UPLOADS_SOURCE),
@@ -56,6 +56,9 @@ class CameraUploadsMigrationToRoom(val sharedPreferencesProvider: SharedPreferen
lastSyncTimestamp = timestamp, lastSyncTimestamp = timestamp,
name = pictureUploadsName, name = pictureUploadsName,
allowRoaming = false, allowRoaming = false,
mobileDataLimitBytes = 0,
mobileDataPeriodStartTimestamp = 0,
mobileDataBytesQueuedInPeriod = 0,
chargingOnly = false, chargingOnly = false,
spaceId = null, spaceId = null,
) )
@@ -65,7 +68,7 @@ class CameraUploadsMigrationToRoom(val sharedPreferencesProvider: SharedPreferen
if (!sharedPreferencesProvider.getBoolean(PREF__CAMERA_VIDEO_UPLOADS_ENABLED, false)) return null if (!sharedPreferencesProvider.getBoolean(PREF__CAMERA_VIDEO_UPLOADS_ENABLED, false)) return null
return FolderBackUpConfiguration( return FolderBackUpConfiguration(
accountName = sharedPreferencesProvider.getString(PREF__CAMERA_VIDEO_UPLOADS_ACCOUNT_NAME, null) ?: "", accountName = sharedPreferencesProvider.getString(PREF__CAMERA_VIDEO_UPLOADS_ACCOUNT_NAME, null).orEmpty(),
wifiOnly = sharedPreferencesProvider.getBoolean(PREF__CAMERA_VIDEO_UPLOADS_WIFI_ONLY, false), wifiOnly = sharedPreferencesProvider.getBoolean(PREF__CAMERA_VIDEO_UPLOADS_WIFI_ONLY, false),
uploadPath = getUploadPathForPreference(PREF__CAMERA_VIDEO_UPLOADS_PATH), uploadPath = getUploadPathForPreference(PREF__CAMERA_VIDEO_UPLOADS_PATH),
sourcePath = getSourcePathForPreference(PREF__CAMERA_VIDEO_UPLOADS_SOURCE), sourcePath = getSourcePathForPreference(PREF__CAMERA_VIDEO_UPLOADS_SOURCE),
@@ -73,6 +76,9 @@ class CameraUploadsMigrationToRoom(val sharedPreferencesProvider: SharedPreferen
lastSyncTimestamp = timestamp, lastSyncTimestamp = timestamp,
name = videoUploadsName, name = videoUploadsName,
allowRoaming = false, allowRoaming = false,
mobileDataLimitBytes = 0,
mobileDataPeriodStartTimestamp = 0,
mobileDataBytesQueuedInPeriod = 0,
chargingOnly = false, chargingOnly = false,
spaceId = null, spaceId = null,
) )
@@ -87,7 +93,7 @@ class CameraUploadsMigrationToRoom(val sharedPreferencesProvider: SharedPreferen
} }
private fun getSourcePathForPreference(keyPreference: String): String = private fun getSourcePathForPreference(keyPreference: String): String =
sharedPreferencesProvider.getString(keyPreference, null) ?: "" sharedPreferencesProvider.getString(keyPreference, null).orEmpty()
private fun getBehaviorForPreference(keyPreference: String): UploadBehavior { private fun getBehaviorForPreference(keyPreference: String): UploadBehavior {
val storedBehaviour = sharedPreferencesProvider.getString(keyPreference, null) ?: return UploadBehavior.COPY val storedBehaviour = sharedPreferencesProvider.getString(keyPreference, null) ?: return UploadBehavior.COPY
@@ -0,0 +1,13 @@
package eu.qsfera.android.data.migrations
import androidx.room.migration.Migration
import androidx.sqlite.db.SupportSQLiteDatabase
import eu.qsfera.android.data.ProviderMeta.ProviderTableMeta.FOLDER_BACKUP_TABLE_NAME
val MIGRATION_50_51 = object : Migration(50, 51) {
override fun migrate(database: SupportSQLiteDatabase) {
database.execSQL("ALTER TABLE $FOLDER_BACKUP_TABLE_NAME ADD COLUMN mobileDataLimitBytes INTEGER NOT NULL DEFAULT '0'")
database.execSQL("ALTER TABLE $FOLDER_BACKUP_TABLE_NAME ADD COLUMN mobileDataPeriodStartTimestamp INTEGER NOT NULL DEFAULT '0'")
database.execSQL("ALTER TABLE $FOLDER_BACKUP_TABLE_NAME ADD COLUMN mobileDataBytesQueuedInPeriod INTEGER NOT NULL DEFAULT '0'")
}
}
@@ -26,6 +26,9 @@ data class FolderBackUpConfiguration(
val uploadPath: String, val uploadPath: String,
val wifiOnly: Boolean, val wifiOnly: Boolean,
val allowRoaming: Boolean, val allowRoaming: Boolean,
val mobileDataLimitBytes: Long,
val mobileDataPeriodStartTimestamp: Long,
val mobileDataBytesQueuedInPeriod: Long,
val chargingOnly: Boolean, val chargingOnly: Boolean,
val lastSyncTimestamp: Long, val lastSyncTimestamp: Long,
val name: String, val name: String,
@@ -40,6 +43,7 @@ data class FolderBackUpConfiguration(
const val pictureUploadsName = "Picture uploads" const val pictureUploadsName = "Picture uploads"
const val videoUploadsName = "Video uploads" const val videoUploadsName = "Video uploads"
const val initialSyncTimestamp = 0L const val initialSyncTimestamp = 0L
private const val SOURCE_PATH_SEPARATOR = "\n"
fun parseSourcePaths(sourcePath: String): List<String> = fun parseSourcePaths(sourcePath: String): List<String> =
sourcePath sourcePath
@@ -54,8 +58,6 @@ data class FolderBackUpConfiguration(
.filter { it.isNotEmpty() } .filter { it.isNotEmpty() }
.distinct() .distinct()
.joinToString(SOURCE_PATH_SEPARATOR) .joinToString(SOURCE_PATH_SEPARATOR)
private const val SOURCE_PATH_SEPARATOR = "\n"
} }
} }
@@ -32,6 +32,9 @@ val OC_BACKUP = FolderBackUpConfiguration(
uploadPath = "/Photos", uploadPath = "/Photos",
wifiOnly = true, wifiOnly = true,
allowRoaming = false, allowRoaming = false,
mobileDataLimitBytes = 0,
mobileDataPeriodStartTimestamp = 0,
mobileDataBytesQueuedInPeriod = 0,
chargingOnly = true, chargingOnly = true,
lastSyncTimestamp = 1542628397, lastSyncTimestamp = 1542628397,
name = "", name = "",
@@ -45,6 +48,9 @@ val OC_BACKUP_ENTITY = FolderBackUpEntity(
uploadPath = "/Photos", uploadPath = "/Photos",
wifiOnly = true, wifiOnly = true,
allowRoaming = false, allowRoaming = false,
mobileDataLimitBytes = 0,
mobileDataPeriodStartTimestamp = 0,
mobileDataBytesQueuedInPeriod = 0,
chargingOnly = true, chargingOnly = true,
lastSyncTimestamp = 1542628397, lastSyncTimestamp = 1542628397,
name = "", name = "",
+2 -1
View File
@@ -30,6 +30,7 @@ Date: 2026-06-08.
- Android immediate auto-upload scheduling no longer blocks the caller while waiting for WorkManager state. - 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 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 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` 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/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. - 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 ## Remaining P1
- Add Google Photos-style backup status UX: overall status, per-item status, queued/paused/permanent-failure states, and low-storage messaging. - 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. - 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. - 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. - Verify sharing controls against Google Drive and Yandex Disk expectations: link expiration, password, disable downloads, organization-only access, and role inheritance.