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_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";
@@ -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)
@@ -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
@@ -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<ListPreference>(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)
@@ -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
@@ -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<DocumentFile> = 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<DocumentFile> {
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
}
}
@@ -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="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_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_title">Original file after backup</string>
<string name="prefs_camera_upload_last_sync_title">Last backup scan</string>
@@ -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" />
<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
app:iconSpaceReserved="false"
app:key="picture_uploads_on_charging"
@@ -67,6 +67,15 @@
app:key="video_uploads_allow_roaming"
app:summary="@string/prefs_camera_upload_allow_roaming_summary"
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
app:iconSpaceReserved="false"
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 NEW_DB_NAME = "qsfera_database";
public static final int DB_VERSION = 50;
public static final int DB_VERSION = 51;
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_48_49
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.OCShareEntity
import eu.qsfera.android.data.spaces.db.SpaceSpecialEntity
@@ -129,7 +130,8 @@ abstract class QSferaDatabase : RoomDatabase() {
MIGRATION_42_43,
MIGRATION_47_48,
MIGRATION_48_49,
MIGRATION_49_50)
MIGRATION_49_50,
MIGRATION_50_51)
.build()
INSTANCE = instance
instance
@@ -69,6 +69,9 @@ class OCLocalFolderBackupDataSource(
uploadPath = uploadPath,
wifiOnly = wifiOnly,
allowRoaming = allowRoaming,
mobileDataLimitBytes = mobileDataLimitBytes,
mobileDataPeriodStartTimestamp = mobileDataPeriodStartTimestamp,
mobileDataBytesQueuedInPeriod = mobileDataBytesQueuedInPeriod,
chargingOnly = chargingOnly,
name = name,
lastSyncTimestamp = lastSyncTimestamp,
@@ -85,6 +88,9 @@ class OCLocalFolderBackupDataSource(
uploadPath = uploadPath,
wifiOnly = wifiOnly,
allowRoaming = allowRoaming,
mobileDataLimitBytes = mobileDataLimitBytes,
mobileDataPeriodStartTimestamp = mobileDataPeriodStartTimestamp,
mobileDataBytesQueuedInPeriod = mobileDataBytesQueuedInPeriod,
chargingOnly = chargingOnly,
lastSyncTimestamp = lastSyncTimestamp,
name = name,
@@ -30,6 +30,9 @@ data class FolderBackUpEntity(
val uploadPath: String,
val wifiOnly: Boolean,
val allowRoaming: Boolean,
val mobileDataLimitBytes: Long,
val mobileDataPeriodStartTimestamp: Long,
val mobileDataBytesQueuedInPeriod: Long,
val chargingOnly: Boolean,
val name: String,
val lastSyncTimestamp: Long,
@@ -48,7 +48,7 @@ class CameraUploadsMigrationToRoom(val sharedPreferencesProvider: SharedPreferen
if (!sharedPreferencesProvider.getBoolean(PREF__CAMERA_PICTURE_UPLOADS_ENABLED, false)) return null
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),
uploadPath = getUploadPathForPreference(PREF__CAMERA_PICTURE_UPLOADS_PATH),
sourcePath = getSourcePathForPreference(PREF__CAMERA_PICTURE_UPLOADS_SOURCE),
@@ -56,6 +56,9 @@ class CameraUploadsMigrationToRoom(val sharedPreferencesProvider: SharedPreferen
lastSyncTimestamp = timestamp,
name = pictureUploadsName,
allowRoaming = false,
mobileDataLimitBytes = 0,
mobileDataPeriodStartTimestamp = 0,
mobileDataBytesQueuedInPeriod = 0,
chargingOnly = false,
spaceId = null,
)
@@ -65,7 +68,7 @@ class CameraUploadsMigrationToRoom(val sharedPreferencesProvider: SharedPreferen
if (!sharedPreferencesProvider.getBoolean(PREF__CAMERA_VIDEO_UPLOADS_ENABLED, false)) return null
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),
uploadPath = getUploadPathForPreference(PREF__CAMERA_VIDEO_UPLOADS_PATH),
sourcePath = getSourcePathForPreference(PREF__CAMERA_VIDEO_UPLOADS_SOURCE),
@@ -73,6 +76,9 @@ class CameraUploadsMigrationToRoom(val sharedPreferencesProvider: SharedPreferen
lastSyncTimestamp = timestamp,
name = videoUploadsName,
allowRoaming = false,
mobileDataLimitBytes = 0,
mobileDataPeriodStartTimestamp = 0,
mobileDataBytesQueuedInPeriod = 0,
chargingOnly = false,
spaceId = null,
)
@@ -87,7 +93,7 @@ class CameraUploadsMigrationToRoom(val sharedPreferencesProvider: SharedPreferen
}
private fun getSourcePathForPreference(keyPreference: String): String =
sharedPreferencesProvider.getString(keyPreference, null) ?: ""
sharedPreferencesProvider.getString(keyPreference, null).orEmpty()
private fun getBehaviorForPreference(keyPreference: String): UploadBehavior {
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 wifiOnly: Boolean,
val allowRoaming: Boolean,
val mobileDataLimitBytes: Long,
val mobileDataPeriodStartTimestamp: Long,
val mobileDataBytesQueuedInPeriod: Long,
val chargingOnly: Boolean,
val lastSyncTimestamp: Long,
val name: String,
@@ -40,6 +43,7 @@ data class FolderBackUpConfiguration(
const val pictureUploadsName = "Picture uploads"
const val videoUploadsName = "Video uploads"
const val initialSyncTimestamp = 0L
private const val SOURCE_PATH_SEPARATOR = "\n"
fun parseSourcePaths(sourcePath: String): List<String> =
sourcePath
@@ -54,8 +58,6 @@ data class FolderBackUpConfiguration(
.filter { it.isNotEmpty() }
.distinct()
.joinToString(SOURCE_PATH_SEPARATOR)
private const val SOURCE_PATH_SEPARATOR = "\n"
}
}
@@ -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 = "",
+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 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.