Implement background media ingestion and Pi deployment
This commit is contained in:
@@ -14,6 +14,20 @@ def envValue(String primary, String legacy = null) {
|
||||
return legacy ? System.getenv(legacy) : null
|
||||
}
|
||||
|
||||
def releaseSigningProperties = new Properties()
|
||||
def releaseSigningPropertiesFile = rootProject.file('signing/qsfera-signing.local.properties')
|
||||
if (releaseSigningPropertiesFile.exists()) {
|
||||
releaseSigningPropertiesFile.withInputStream { stream ->
|
||||
releaseSigningProperties.load(stream)
|
||||
}
|
||||
}
|
||||
|
||||
def signingValue = { String primary, String legacy = null ->
|
||||
envValue(primary, legacy) ?:
|
||||
releaseSigningProperties.getProperty(primary) ?:
|
||||
(legacy ? releaseSigningProperties.getProperty(legacy) : null)
|
||||
}
|
||||
|
||||
dependencies {
|
||||
// Data and domain modules
|
||||
implementation project(':qsferaDomain')
|
||||
@@ -162,12 +176,12 @@ android {
|
||||
|
||||
signingConfigs {
|
||||
release {
|
||||
def releaseKeystore = envValue('QSFERA_RELEASE_KEYSTORE', 'OC_RELEASE_KEYSTORE')
|
||||
def releaseKeystore = signingValue('QSFERA_RELEASE_KEYSTORE', 'OC_RELEASE_KEYSTORE')
|
||||
if (releaseKeystore) {
|
||||
storeFile file(releaseKeystore) // use an absolute path
|
||||
storePassword envValue('QSFERA_RELEASE_KEYSTORE_PASSWORD', 'OC_RELEASE_KEYSTORE_PASSWORD')
|
||||
keyAlias envValue('QSFERA_RELEASE_KEY_ALIAS', 'OC_RELEASE_KEY_ALIAS')
|
||||
keyPassword envValue('QSFERA_RELEASE_KEY_PASSWORD', 'OC_RELEASE_KEY_PASSWORD')
|
||||
storePassword signingValue('QSFERA_RELEASE_KEYSTORE_PASSWORD', 'OC_RELEASE_KEYSTORE_PASSWORD')
|
||||
keyAlias signingValue('QSFERA_RELEASE_KEY_ALIAS', 'OC_RELEASE_KEY_ALIAS')
|
||||
keyPassword signingValue('QSFERA_RELEASE_KEY_PASSWORD', 'OC_RELEASE_KEY_PASSWORD')
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -175,7 +189,7 @@ android {
|
||||
buildTypes {
|
||||
|
||||
release {
|
||||
if (envValue('QSFERA_RELEASE_KEYSTORE', 'OC_RELEASE_KEYSTORE')) {
|
||||
if (signingValue('QSFERA_RELEASE_KEYSTORE', 'OC_RELEASE_KEYSTORE')) {
|
||||
signingConfig signingConfigs.release
|
||||
}
|
||||
}
|
||||
|
||||
@@ -38,6 +38,7 @@ import android.view.WindowManager
|
||||
import android.widget.CheckBox
|
||||
import androidx.appcompat.app.AlertDialog
|
||||
import androidx.core.content.pm.PackageInfoCompat
|
||||
import androidx.work.ExistingWorkPolicy
|
||||
import eu.qsfera.android.data.providers.implementation.OCSharedPreferencesProvider
|
||||
|
||||
|
||||
@@ -48,6 +49,7 @@ import eu.qsfera.android.dependecyinjection.remoteDataSourceModule
|
||||
import eu.qsfera.android.dependecyinjection.repositoryModule
|
||||
import eu.qsfera.android.dependecyinjection.useCaseModule
|
||||
import eu.qsfera.android.dependecyinjection.viewModelModule
|
||||
import eu.qsfera.android.domain.automaticuploads.usecases.GetAutomaticUploadsConfigurationUseCase
|
||||
import eu.qsfera.android.domain.capabilities.usecases.GetStoredCapabilitiesUseCase
|
||||
import eu.qsfera.android.domain.spaces.model.OCSpace
|
||||
import eu.qsfera.android.domain.spaces.usecases.GetPersonalSpaceForAccountUseCase
|
||||
@@ -123,6 +125,7 @@ class MainApp : Application() {
|
||||
initDependencyInjection()
|
||||
|
||||
val workManagerProvider: WorkManagerProvider by inject()
|
||||
val getAutomaticUploadsConfigurationUseCase: GetAutomaticUploadsConfigurationUseCase by inject()
|
||||
var startedActivities = 0
|
||||
|
||||
// register global protection with pass code, pattern lock and biometric lock
|
||||
@@ -228,8 +231,15 @@ class MainApp : Application() {
|
||||
// (recovers if the chain was dropped) and trigger an immediate scan
|
||||
// so the user doesn't have to wait up to 15 min.
|
||||
CoroutineScope(Dispatchers.IO).launch {
|
||||
val sourcePaths = getAutomaticUploadsConfigurationUseCase(Unit)
|
||||
.getDataOrNull()
|
||||
?.sourcePaths
|
||||
.orEmpty()
|
||||
workManagerProvider.enqueueAutomaticUploadsWorker()
|
||||
workManagerProvider.enqueueMediaStoreAutomaticUploadsWorker()
|
||||
workManagerProvider.enqueueMediaStoreAutomaticUploadsWorker(
|
||||
existingWorkPolicy = ExistingWorkPolicy.REPLACE,
|
||||
sourcePaths = sourcePaths,
|
||||
)
|
||||
workManagerProvider.enqueueImmediateAutomaticUploadsWorker()
|
||||
}
|
||||
}
|
||||
|
||||
-18
@@ -41,14 +41,12 @@ import androidx.preference.PreferenceFragmentCompat
|
||||
import androidx.preference.SwitchPreferenceCompat
|
||||
import eu.qsfera.android.R
|
||||
import eu.qsfera.android.db.PreferenceManager.PREF__CAMERA_PICTURE_UPLOADS_ACCOUNT_NAME
|
||||
import eu.qsfera.android.db.PreferenceManager.PREF__CAMERA_PICTURE_UPLOADS_BEHAVIOUR
|
||||
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_PATH
|
||||
import eu.qsfera.android.db.PreferenceManager.PREF__CAMERA_PICTURE_UPLOADS_SOURCE
|
||||
import eu.qsfera.android.db.PreferenceManager.PREF__CAMERA_PICTURE_UPLOADS_WIFI_ONLY
|
||||
import eu.qsfera.android.domain.automaticuploads.model.UploadBehavior
|
||||
import eu.qsfera.android.extensions.collectLatestLifecycleFlow
|
||||
import eu.qsfera.android.extensions.showAlertDialog
|
||||
import eu.qsfera.android.extensions.showMessageInSnackbar
|
||||
@@ -71,7 +69,6 @@ class SettingsPictureUploadsFragment : PreferenceFragmentCompat() {
|
||||
private var prefPictureUploadsOnCharging: CheckBoxPreference? = null
|
||||
private var prefPictureUploadsSourcePath: Preference? = null
|
||||
private var prefPictureUploadsClearSourcePaths: Preference? = null
|
||||
private var prefPictureUploadsBehaviour: ListPreference? = null
|
||||
private var prefPictureUploadsAccount: ListPreference? = null
|
||||
private var prefPictureUploadsLastSync: Preference? = null
|
||||
private var spaceId: String? = null
|
||||
@@ -104,13 +101,6 @@ class SettingsPictureUploadsFragment : PreferenceFragmentCompat() {
|
||||
prefPictureUploadsSourcePath = findPreference(PREF__CAMERA_PICTURE_UPLOADS_SOURCE)
|
||||
prefPictureUploadsClearSourcePaths = findPreference(PREF_PICTURE_UPLOADS_CLEAR_SOURCE_PATHS)
|
||||
prefPictureUploadsLastSync = findPreference(PREF__CAMERA_PICTURE_UPLOADS_LAST_SYNC)
|
||||
prefPictureUploadsBehaviour = findPreference<ListPreference>(PREF__CAMERA_PICTURE_UPLOADS_BEHAVIOUR)?.apply {
|
||||
entries = listOf(
|
||||
getString(R.string.pref_behaviour_entries_keep_file),
|
||||
getString(R.string.pref_behaviour_entries_remove_original_file)
|
||||
).toTypedArray()
|
||||
entryValues = listOf(UploadBehavior.COPY.name, UploadBehavior.MOVE.name).toTypedArray()
|
||||
}
|
||||
prefPictureUploadsAccount = findPreference(PREF__CAMERA_PICTURE_UPLOADS_ACCOUNT_NAME)
|
||||
|
||||
val comment = getString(R.string.prefs_camera_upload_source_path_title_required)
|
||||
@@ -158,7 +148,6 @@ class SettingsPictureUploadsFragment : PreferenceFragmentCompat() {
|
||||
prefPictureUploadsClearSourcePaths?.isEnabled = sourcePaths.isNotEmpty()
|
||||
prefPictureUploadsOnWifi?.isChecked = it.wifiOnly
|
||||
prefPictureUploadsOnCharging?.isChecked = it.chargingOnly
|
||||
prefPictureUploadsBehaviour?.value = it.behavior.name
|
||||
prefPictureUploadsLastSync?.summary = DisplayUtils.unixTimeToHumanReadable(it.lastSyncTimestamp)
|
||||
spaceId = it.spaceId
|
||||
} ?: resetFields()
|
||||
@@ -258,11 +247,6 @@ class SettingsPictureUploadsFragment : PreferenceFragmentCompat() {
|
||||
true
|
||||
}
|
||||
|
||||
prefPictureUploadsBehaviour?.setOnPreferenceChangeListener { _, newValue ->
|
||||
newValue as String
|
||||
picturesViewModel.handleSelectBehaviour(newValue)
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
@@ -280,7 +264,6 @@ class SettingsPictureUploadsFragment : PreferenceFragmentCompat() {
|
||||
prefPictureUploadsOnCharging?.isEnabled = value
|
||||
prefPictureUploadsSourcePath?.isEnabled = value
|
||||
prefPictureUploadsClearSourcePaths?.isEnabled = value && picturesViewModel.getPictureUploadsSourcePaths().isNotEmpty()
|
||||
prefPictureUploadsBehaviour?.isEnabled = value
|
||||
prefPictureUploadsAccount?.isEnabled = value
|
||||
prefPictureUploadsLastSync?.isEnabled = value
|
||||
}
|
||||
@@ -292,7 +275,6 @@ class SettingsPictureUploadsFragment : PreferenceFragmentCompat() {
|
||||
prefPictureUploadsClearSourcePaths?.isEnabled = false
|
||||
prefPictureUploadsOnWifi?.isChecked = false
|
||||
prefPictureUploadsOnCharging?.isChecked = false
|
||||
prefPictureUploadsBehaviour?.value = UploadBehavior.COPY.name
|
||||
prefPictureUploadsLastSync?.summary = null
|
||||
}
|
||||
|
||||
|
||||
+12
-14
@@ -26,12 +26,14 @@ import android.content.Intent
|
||||
import android.net.Uri
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import androidx.work.ExistingWorkPolicy
|
||||
import eu.qsfera.android.R
|
||||
import eu.qsfera.android.db.PreferenceManager.PREF__CAMERA_UPLOADS_DEFAULT_PATH
|
||||
import eu.qsfera.android.domain.automaticuploads.model.FolderBackUpConfiguration
|
||||
import eu.qsfera.android.domain.automaticuploads.model.FolderBackUpConfiguration.Companion.encodeSourcePaths
|
||||
import eu.qsfera.android.domain.automaticuploads.model.FolderBackUpConfiguration.Companion.pictureUploadsName
|
||||
import eu.qsfera.android.domain.automaticuploads.model.UploadBehavior
|
||||
import eu.qsfera.android.domain.automaticuploads.usecases.GetAutomaticUploadsConfigurationUseCase
|
||||
import eu.qsfera.android.domain.automaticuploads.usecases.GetPictureUploadsConfigurationStreamUseCase
|
||||
import eu.qsfera.android.domain.automaticuploads.usecases.ResetPictureUploadsUseCase
|
||||
import eu.qsfera.android.domain.automaticuploads.usecases.SavePictureUploadsConfigurationUseCase
|
||||
@@ -53,6 +55,7 @@ import timber.log.Timber
|
||||
class SettingsPictureUploadsViewModel(
|
||||
private val accountProvider: AccountProvider,
|
||||
private val savePictureUploadsConfigurationUseCase: SavePictureUploadsConfigurationUseCase,
|
||||
private val getAutomaticUploadsConfigurationUseCase: GetAutomaticUploadsConfigurationUseCase,
|
||||
private val getPictureUploadsConfigurationStreamUseCase: GetPictureUploadsConfigurationStreamUseCase,
|
||||
private val resetPictureUploadsUseCase: ResetPictureUploadsUseCase,
|
||||
private val getPersonalSpaceForAccountUseCase: GetPersonalSpaceForAccountUseCase,
|
||||
@@ -161,16 +164,6 @@ class SettingsPictureUploadsViewModel(
|
||||
}
|
||||
}
|
||||
|
||||
fun handleSelectBehaviour(behaviorString: String) {
|
||||
val behavior = UploadBehavior.fromString(behaviorString)
|
||||
|
||||
viewModelScope.launch(coroutinesDispatcherProvider.io) {
|
||||
savePictureUploadsConfigurationUseCase(
|
||||
SavePictureUploadsConfigurationUseCase.Params(composePictureUploadsConfiguration(behavior = behavior))
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun handleSelectPictureUploadsSourcePath(contentUriForTree: Uri) {
|
||||
val previousSourcePaths = getPictureUploadsSourcePaths()
|
||||
val newSourcePath = contentUriForTree.toString()
|
||||
@@ -202,8 +195,14 @@ class SettingsPictureUploadsViewModel(
|
||||
}
|
||||
|
||||
fun schedulePictureUploads() {
|
||||
workManagerProvider.enqueueAutomaticUploadsWorker()
|
||||
workManagerProvider.enqueueMediaStoreAutomaticUploadsWorker()
|
||||
viewModelScope.launch(coroutinesDispatcherProvider.io) {
|
||||
val sourcePaths = getAutomaticUploadsConfigurationUseCase(Unit).getDataOrNull()?.sourcePaths.orEmpty()
|
||||
workManagerProvider.enqueueAutomaticUploadsWorker()
|
||||
workManagerProvider.enqueueMediaStoreAutomaticUploadsWorker(
|
||||
existingWorkPolicy = ExistingWorkPolicy.REPLACE,
|
||||
sourcePaths = sourcePaths,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun composePictureUploadsConfiguration(
|
||||
@@ -212,12 +211,11 @@ class SettingsPictureUploadsViewModel(
|
||||
wifiOnly: Boolean? = _pictureUploads.value?.wifiOnly,
|
||||
chargingOnly: Boolean? = _pictureUploads.value?.chargingOnly,
|
||||
sourcePath: String? = _pictureUploads.value?.sourcePath,
|
||||
behavior: UploadBehavior? = _pictureUploads.value?.behavior,
|
||||
timestamp: Long? = _pictureUploads.value?.lastSyncTimestamp,
|
||||
spaceId: String? = _pictureUploads.value?.spaceId,
|
||||
): FolderBackUpConfiguration = FolderBackUpConfiguration(
|
||||
accountName = accountName ?: accountProvider.getCurrentQSferaAccount()!!.name,
|
||||
behavior = behavior ?: UploadBehavior.COPY,
|
||||
behavior = UploadBehavior.MOVE,
|
||||
sourcePath = sourcePath.orEmpty(),
|
||||
uploadPath = uploadPath ?: PREF__CAMERA_UPLOADS_DEFAULT_PATH,
|
||||
wifiOnly = wifiOnly ?: false,
|
||||
|
||||
+11
-2
@@ -26,12 +26,14 @@ import android.content.Intent
|
||||
import android.net.Uri
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import androidx.work.ExistingWorkPolicy
|
||||
import eu.qsfera.android.R
|
||||
import eu.qsfera.android.db.PreferenceManager.PREF__CAMERA_UPLOADS_DEFAULT_PATH
|
||||
import eu.qsfera.android.domain.automaticuploads.model.FolderBackUpConfiguration
|
||||
import eu.qsfera.android.domain.automaticuploads.model.FolderBackUpConfiguration.Companion.encodeSourcePaths
|
||||
import eu.qsfera.android.domain.automaticuploads.model.FolderBackUpConfiguration.Companion.videoUploadsName
|
||||
import eu.qsfera.android.domain.automaticuploads.model.UploadBehavior
|
||||
import eu.qsfera.android.domain.automaticuploads.usecases.GetAutomaticUploadsConfigurationUseCase
|
||||
import eu.qsfera.android.domain.automaticuploads.usecases.GetVideoUploadsConfigurationStreamUseCase
|
||||
import eu.qsfera.android.domain.automaticuploads.usecases.ResetVideoUploadsUseCase
|
||||
import eu.qsfera.android.domain.automaticuploads.usecases.SaveVideoUploadsConfigurationUseCase
|
||||
@@ -53,6 +55,7 @@ import timber.log.Timber
|
||||
class SettingsVideoUploadsViewModel(
|
||||
private val accountProvider: AccountProvider,
|
||||
private val saveVideoUploadsConfigurationUseCase: SaveVideoUploadsConfigurationUseCase,
|
||||
private val getAutomaticUploadsConfigurationUseCase: GetAutomaticUploadsConfigurationUseCase,
|
||||
private val getVideoUploadsConfigurationStreamUseCase: GetVideoUploadsConfigurationStreamUseCase,
|
||||
private val resetVideoUploadsUseCase: ResetVideoUploadsUseCase,
|
||||
private val getPersonalSpaceForAccountUseCase: GetPersonalSpaceForAccountUseCase,
|
||||
@@ -202,8 +205,14 @@ class SettingsVideoUploadsViewModel(
|
||||
}
|
||||
|
||||
fun scheduleVideoUploads() {
|
||||
workManagerProvider.enqueueAutomaticUploadsWorker()
|
||||
workManagerProvider.enqueueMediaStoreAutomaticUploadsWorker()
|
||||
viewModelScope.launch(coroutinesDispatcherProvider.io) {
|
||||
val sourcePaths = getAutomaticUploadsConfigurationUseCase(Unit).getDataOrNull()?.sourcePaths.orEmpty()
|
||||
workManagerProvider.enqueueAutomaticUploadsWorker()
|
||||
workManagerProvider.enqueueMediaStoreAutomaticUploadsWorker(
|
||||
existingWorkPolicy = ExistingWorkPolicy.REPLACE,
|
||||
sourcePaths = sourcePaths,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun composeVideoUploadsConfiguration(
|
||||
|
||||
@@ -45,6 +45,7 @@ import android.net.Uri
|
||||
import android.os.CancellationSignal
|
||||
import android.os.ParcelFileDescriptor
|
||||
import android.text.TextUtils
|
||||
import androidx.work.ExistingWorkPolicy
|
||||
import androidx.work.WorkManager
|
||||
import eu.qsfera.android.MainApp
|
||||
import eu.qsfera.android.R
|
||||
@@ -994,8 +995,14 @@ class FileContentProvider(val executors: Executors = Executors()) : ContentProvi
|
||||
videoUploadsConfiguration?.let { backupLocalDataSource.saveFolderBackupConfiguration(it) }
|
||||
if (pictureUploadsConfiguration != null || videoUploadsConfiguration != null) {
|
||||
val workManagerProvider = WorkManagerProvider(context!!)
|
||||
val sourcePaths = listOfNotNull(pictureUploadsConfiguration, videoUploadsConfiguration)
|
||||
.flatMap { it.sourcePaths }
|
||||
.distinct()
|
||||
workManagerProvider.enqueueAutomaticUploadsWorker()
|
||||
workManagerProvider.enqueueMediaStoreAutomaticUploadsWorker()
|
||||
workManagerProvider.enqueueMediaStoreAutomaticUploadsWorker(
|
||||
existingWorkPolicy = ExistingWorkPolicy.REPLACE,
|
||||
sourcePaths = sourcePaths,
|
||||
)
|
||||
}
|
||||
}
|
||||
cursor.close()
|
||||
|
||||
@@ -21,8 +21,12 @@
|
||||
|
||||
package eu.qsfera.android.providers
|
||||
|
||||
import android.content.ContentResolver
|
||||
import android.content.Context
|
||||
import android.net.Uri
|
||||
import android.provider.DocumentsContract
|
||||
import android.provider.MediaStore
|
||||
import androidx.core.net.toUri
|
||||
import androidx.lifecycle.LiveData
|
||||
import androidx.work.Constraints
|
||||
import androidx.work.ExistingPeriodicWorkPolicy
|
||||
@@ -60,14 +64,21 @@ class WorkManagerProvider(
|
||||
}
|
||||
|
||||
fun enqueueMediaStoreAutomaticUploadsWorker(
|
||||
existingWorkPolicy: ExistingWorkPolicy = ExistingWorkPolicy.KEEP
|
||||
existingWorkPolicy: ExistingWorkPolicy = ExistingWorkPolicy.KEEP,
|
||||
sourcePaths: Collection<String> = emptyList(),
|
||||
) {
|
||||
val mediaStoreTriggers = Constraints.Builder()
|
||||
val constraintsBuilder = Constraints.Builder()
|
||||
.addContentUriTrigger(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, true)
|
||||
.addContentUriTrigger(MediaStore.Video.Media.EXTERNAL_CONTENT_URI, true)
|
||||
.setTriggerContentUpdateDelay(AutomaticUploadsWorker.WRITE_SAFETY_BUFFER_MS, TimeUnit.MILLISECONDS)
|
||||
.setTriggerContentMaxDelay(AutomaticUploadsWorker.MEDIA_STORE_TRIGGER_MAX_DELAY_MS, TimeUnit.MILLISECONDS)
|
||||
.build()
|
||||
|
||||
sourcePaths
|
||||
.flatMap(::sourcePathTriggerUris)
|
||||
.distinct()
|
||||
.forEach { constraintsBuilder.addContentUriTrigger(it, true) }
|
||||
|
||||
val mediaStoreTriggers = constraintsBuilder.build()
|
||||
|
||||
val mediaStoreWorker = OneTimeWorkRequestBuilder<AutomaticUploadsWorker>()
|
||||
.addTag(AutomaticUploadsWorker.MEDIA_STORE_UPLOADS_WORKER)
|
||||
@@ -84,6 +95,24 @@ class WorkManagerProvider(
|
||||
)
|
||||
}
|
||||
|
||||
private fun sourcePathTriggerUris(sourcePath: String): List<Uri> {
|
||||
val treeUri = sourcePath.trim().takeIf { it.isNotEmpty() }?.toUri() ?: return emptyList()
|
||||
if (treeUri.scheme != ContentResolver.SCHEME_CONTENT) return emptyList()
|
||||
|
||||
val documentUri = runCatching {
|
||||
if (DocumentsContract.isTreeUri(treeUri)) {
|
||||
DocumentsContract.buildDocumentUriUsingTree(
|
||||
treeUri,
|
||||
DocumentsContract.getTreeDocumentId(treeUri),
|
||||
)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}.getOrNull()
|
||||
|
||||
return listOfNotNull(treeUri, documentUri)
|
||||
}
|
||||
|
||||
/**
|
||||
* Trigger an immediate one-time upload scan, e.g. when the app enters foreground.
|
||||
* Skips if either the periodic or immediate worker is already running to avoid
|
||||
|
||||
+5
@@ -83,6 +83,11 @@ class UploadFileFromContentUriUseCase(
|
||||
if (behavior == UploadBehavior.MOVE) {
|
||||
val removeSourceFileWorker = OneTimeWorkRequestBuilder<RemoveSourceFileWorker>()
|
||||
.setInputData(inputDataRemoveSourceFileWorker)
|
||||
.setBackoffCriteria(
|
||||
BackoffPolicy.EXPONENTIAL,
|
||||
10,
|
||||
TimeUnit.SECONDS,
|
||||
)
|
||||
.build()
|
||||
workManager.beginUniqueWork(
|
||||
uniqueWorkName,
|
||||
|
||||
+63
-7
@@ -79,6 +79,7 @@ class AutomaticUploadsWorker(
|
||||
override suspend fun doWork(): Result {
|
||||
Timber.i("Starting AutomaticUploadsWorker with UUID ${this.id}")
|
||||
var automaticUploadsEnabled = true
|
||||
var configuredSourcePaths: List<String> = emptyList()
|
||||
when (val useCaseResult = getAutomaticUploadsConfigurationUseCase(Unit)) {
|
||||
is UseCaseResult.Success -> {
|
||||
val cameraUploadsConfiguration = useCaseResult.data
|
||||
@@ -86,6 +87,7 @@ class AutomaticUploadsWorker(
|
||||
cancelWorker()
|
||||
automaticUploadsEnabled = false
|
||||
} else {
|
||||
configuredSourcePaths = cameraUploadsConfiguration.sourcePaths
|
||||
cameraUploadsConfiguration.pictureUploadsConfiguration?.let { pictureUploadsConfiguration ->
|
||||
try {
|
||||
checkSourcePathsAreValidUrisOrThrowException(pictureUploadsConfiguration.sourcePaths)
|
||||
@@ -110,17 +112,21 @@ class AutomaticUploadsWorker(
|
||||
Timber.e(useCaseResult.throwable, "Worker ${useCaseResult.throwable}")
|
||||
}
|
||||
}
|
||||
rescheduleMediaStoreTriggerIfNeeded(automaticUploadsEnabled)
|
||||
rescheduleMediaStoreTriggerIfNeeded(automaticUploadsEnabled, configuredSourcePaths)
|
||||
Timber.i("Finishing CameraUploadsWorker with UUID ${this.id}")
|
||||
return Result.success()
|
||||
}
|
||||
|
||||
private fun rescheduleMediaStoreTriggerIfNeeded(automaticUploadsEnabled: Boolean) {
|
||||
private fun rescheduleMediaStoreTriggerIfNeeded(
|
||||
automaticUploadsEnabled: Boolean,
|
||||
sourcePaths: List<String>,
|
||||
) {
|
||||
val shouldReschedule = inputData.getBoolean(KEY_PARAM_RESCHEDULE_MEDIA_STORE_TRIGGER, false)
|
||||
if (!shouldReschedule || !automaticUploadsEnabled) return
|
||||
|
||||
WorkManagerProvider(appContext).enqueueMediaStoreAutomaticUploadsWorker(
|
||||
existingWorkPolicy = ExistingWorkPolicy.APPEND_OR_REPLACE
|
||||
existingWorkPolicy = ExistingWorkPolicy.APPEND_OR_REPLACE,
|
||||
sourcePaths = sourcePaths,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -146,8 +152,30 @@ class AutomaticUploadsWorker(
|
||||
// Else should not happen for the moment. Maybe in upcoming features..
|
||||
else -> SyncType.PICTURE_UPLOADS
|
||||
}
|
||||
val effectiveBehavior = folderBackUpConfiguration.effectiveBehavior
|
||||
|
||||
val currentTimestamp = System.currentTimeMillis()
|
||||
val automaticUploadSourceType = when (syncType) {
|
||||
SyncType.PICTURE_UPLOADS -> UploadEnqueuedBy.ENQUEUED_AS_AUTOMATIC_UPLOAD_PICTURE
|
||||
SyncType.VIDEO_UPLOADS -> UploadEnqueuedBy.ENQUEUED_AS_AUTOMATIC_UPLOAD_VIDEO
|
||||
}
|
||||
val completedUploadTimesBySourceUri = if (effectiveBehavior == UploadBehavior.MOVE) {
|
||||
transferRepository.getFinishedTransfers()
|
||||
.asSequence()
|
||||
.filter {
|
||||
it.createdBy == automaticUploadSourceType &&
|
||||
it.accountName == folderBackUpConfiguration.accountName
|
||||
}
|
||||
.mapNotNull { transfer ->
|
||||
val sourcePath = transfer.sourcePath
|
||||
val completedAt = transfer.transferEndTimestamp
|
||||
if (sourcePath != null && completedAt != null) sourcePath to completedAt else null
|
||||
}
|
||||
.groupBy(keySelector = { it.first }, valueTransform = { it.second })
|
||||
.mapValues { (_, completionTimes) -> completionTimes.maxOrNull()!! }
|
||||
} else {
|
||||
emptyMap()
|
||||
}
|
||||
|
||||
val localPicturesDocumentFiles: List<DocumentFile> = folderBackUpConfiguration.sourcePaths.flatMap { sourcePath ->
|
||||
getFilesReadyToUpload(
|
||||
@@ -155,6 +183,7 @@ class AutomaticUploadsWorker(
|
||||
sourcePath = sourcePath,
|
||||
lastSyncTimestamp = folderBackUpConfiguration.lastSyncTimestamp,
|
||||
currentTimestamp = currentTimestamp,
|
||||
completedUploadTimesBySourceUri = completedUploadTimesBySourceUri,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -175,7 +204,7 @@ class AutomaticUploadsWorker(
|
||||
documentFile = documentFile,
|
||||
uploadPath = folderBackUpConfiguration.uploadPath.plus(File.separator).plus(documentFile.name),
|
||||
accountName = folderBackUpConfiguration.accountName,
|
||||
behavior = folderBackUpConfiguration.behavior,
|
||||
behavior = effectiveBehavior,
|
||||
createdByWorker = when (syncType) {
|
||||
SyncType.PICTURE_UPLOADS -> UploadEnqueuedBy.ENQUEUED_AS_AUTOMATIC_UPLOAD_PICTURE
|
||||
SyncType.VIDEO_UPLOADS -> UploadEnqueuedBy.ENQUEUED_AS_AUTOMATIC_UPLOAD_VIDEO
|
||||
@@ -186,7 +215,7 @@ class AutomaticUploadsWorker(
|
||||
contentUri = documentFile.uri,
|
||||
uploadPath = folderBackUpConfiguration.uploadPath.plus(File.separator).plus(documentFile.name),
|
||||
lastModified = documentFile.lastModified(),
|
||||
behavior = folderBackUpConfiguration.behavior.toString(),
|
||||
behavior = effectiveBehavior.toString(),
|
||||
accountName = folderBackUpConfiguration.accountName,
|
||||
uploadId = uploadId,
|
||||
wifiOnly = folderBackUpConfiguration.wifiOnly,
|
||||
@@ -272,6 +301,7 @@ class AutomaticUploadsWorker(
|
||||
sourcePath: String,
|
||||
lastSyncTimestamp: Long,
|
||||
currentTimestamp: Long,
|
||||
completedUploadTimesBySourceUri: Map<String, Long>,
|
||||
): List<DocumentFile> {
|
||||
val sourceUri: Uri = sourcePath.toUri()
|
||||
val documentTree = DocumentFile.fromTreeUri(applicationContext, sourceUri)
|
||||
@@ -282,11 +312,28 @@ class AutomaticUploadsWorker(
|
||||
// can result in uploading a truncated or 0-byte JPEG.
|
||||
val safeTimestamp = currentTimestamp - WRITE_SAFETY_BUFFER_MS
|
||||
|
||||
val filteredList: List<DocumentFile> = arrayOfLocalFiles
|
||||
val mediaFiles = arrayOfLocalFiles
|
||||
.sortedBy { it.lastModified() }
|
||||
.filter { MimetypeIconUtil.getBestMimeTypeByFilename(it.name).startsWith(syncType.prefixForType) }
|
||||
|
||||
val previouslyUploadedFiles = mediaFiles.filter { documentFile ->
|
||||
shouldRemovePreviouslyUploadedSource(
|
||||
sourceUri = documentFile.uri.toString(),
|
||||
lastModified = documentFile.lastModified(),
|
||||
completedUploadTimesBySourceUri = completedUploadTimesBySourceUri,
|
||||
)
|
||||
}
|
||||
previouslyUploadedFiles.forEach { documentFile ->
|
||||
if (!removeSourceDocument(documentFile)) {
|
||||
Timber.w("Uploaded source file could not be removed yet: %s", documentFile.uri)
|
||||
}
|
||||
}
|
||||
val previouslyUploadedUris = previouslyUploadedFiles.mapTo(mutableSetOf()) { it.uri }
|
||||
|
||||
val filteredList: List<DocumentFile> = mediaFiles
|
||||
.filterNot { it.uri in previouslyUploadedUris }
|
||||
.filter { it.lastModified() >= lastSyncTimestamp }
|
||||
.filter { it.lastModified() < safeTimestamp }
|
||||
.filter { MimetypeIconUtil.getBestMimeTypeByFilename(it.name).startsWith(syncType.prefixForType) }
|
||||
|
||||
Timber.i("Last sync ${syncType.name}: ${Date(lastSyncTimestamp)}")
|
||||
Timber.i("CurrentTimestamp ${Date(currentTimestamp)}")
|
||||
@@ -359,3 +406,12 @@ class AutomaticUploadsWorker(
|
||||
const val MEDIA_STORE_TRIGGER_MAX_DELAY_MS = 60_000L
|
||||
}
|
||||
}
|
||||
|
||||
internal fun shouldRemovePreviouslyUploadedSource(
|
||||
sourceUri: String,
|
||||
lastModified: Long,
|
||||
completedUploadTimesBySourceUri: Map<String, Long>,
|
||||
): Boolean {
|
||||
val completedAt = completedUploadTimesBySourceUri[sourceUri] ?: return false
|
||||
return lastModified > 0 && lastModified <= completedAt
|
||||
}
|
||||
|
||||
+14
-3
@@ -44,11 +44,15 @@ class RemoveSourceFileWorker(
|
||||
if (!areParametersValid()) return Result.failure()
|
||||
return try {
|
||||
val documentFile = DocumentFile.fromSingleUri(appContext, contentUri)
|
||||
documentFile?.delete()
|
||||
Result.success()
|
||||
if (removeSourceDocument(documentFile)) {
|
||||
Result.success()
|
||||
} else {
|
||||
Timber.w("Source file could not be removed yet: %s", contentUri)
|
||||
Result.retry()
|
||||
}
|
||||
} catch (throwable: Throwable) {
|
||||
Timber.e(throwable)
|
||||
Result.failure()
|
||||
Result.retry()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -60,3 +64,10 @@ class RemoveSourceFileWorker(
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
internal fun removeSourceDocument(documentFile: DocumentFile?): Boolean {
|
||||
if (documentFile == null) return false
|
||||
if (!documentFile.exists()) return true
|
||||
|
||||
return documentFile.delete() || !documentFile.exists()
|
||||
}
|
||||
|
||||
@@ -568,7 +568,7 @@
|
||||
<string name="upload_copy_files">Скопировать файл</string>
|
||||
<string name="upload_move_files">Переместить файл</string>
|
||||
<string name="pref_behaviour_entries_keep_file">остался в исходной папке</string>
|
||||
<string name="pref_behaviour_entries_remove_original_file">удалено из исходной папки</string>
|
||||
<string name="pref_behaviour_entries_remove_original_file">удалён из исходной папки</string>
|
||||
<string name="share_dialog_title">Общий доступ</string>
|
||||
<string name="share_file">Поделиться %1$s</string>
|
||||
<string name="share_with_user_section_title">Пользователи и группы</string>
|
||||
|
||||
@@ -44,14 +44,11 @@
|
||||
app:iconSpaceReserved="false"
|
||||
app:key="picture_uploads_clear_source_paths"
|
||||
app:title="@string/prefs_camera_upload_source_paths_clear_title" />
|
||||
<ListPreference
|
||||
app:defaultValue="NOTHING"
|
||||
app:dialogTitle="@string/prefs_camera_upload_behaviour_dialog_title"
|
||||
<Preference
|
||||
app:iconSpaceReserved="false"
|
||||
app:key="picture_uploads_behaviour"
|
||||
app:negativeButtonText=""
|
||||
app:title="@string/prefs_camera_upload_behaviour_title"
|
||||
app:useSimpleSummaryProvider="true" />
|
||||
app:selectable="false"
|
||||
app:summary="@string/pref_behaviour_entries_remove_original_file"
|
||||
app:title="@string/prefs_camera_upload_behaviour_title" />
|
||||
<Preference
|
||||
app:iconSpaceReserved="false"
|
||||
app:key="picture_uploads_last_sync"
|
||||
|
||||
+53
@@ -0,0 +1,53 @@
|
||||
/**
|
||||
* qsfera Android client application
|
||||
*
|
||||
* Copyright (C) 2026 QSfera.
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License version 2,
|
||||
* as published by the Free Software Foundation.
|
||||
*/
|
||||
|
||||
package eu.qsfera.android.workers
|
||||
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class AutomaticUploadsWorkerTest {
|
||||
|
||||
private val sourceUri = "content://camera/photo.jpg"
|
||||
|
||||
@Test
|
||||
fun `completed upload identifies original source`() {
|
||||
assertTrue(
|
||||
shouldRemovePreviouslyUploadedSource(
|
||||
sourceUri = sourceUri,
|
||||
lastModified = 1_000,
|
||||
completedUploadTimesBySourceUri = mapOf(sourceUri to 2_000),
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `new file reusing uri is not treated as uploaded source`() {
|
||||
assertFalse(
|
||||
shouldRemovePreviouslyUploadedSource(
|
||||
sourceUri = sourceUri,
|
||||
lastModified = 3_000,
|
||||
completedUploadTimesBySourceUri = mapOf(sourceUri to 2_000),
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `unknown modification time is not deleted`() {
|
||||
assertFalse(
|
||||
shouldRemovePreviouslyUploadedSource(
|
||||
sourceUri = sourceUri,
|
||||
lastModified = 0,
|
||||
completedUploadTimesBySourceUri = mapOf(sourceUri to 2_000),
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
+47
@@ -0,0 +1,47 @@
|
||||
/**
|
||||
* qsfera Android client application
|
||||
*
|
||||
* Copyright (C) 2026 QSfera.
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License version 2,
|
||||
* as published by the Free Software Foundation.
|
||||
*/
|
||||
|
||||
package eu.qsfera.android.workers
|
||||
|
||||
import androidx.documentfile.provider.DocumentFile
|
||||
import io.mockk.every
|
||||
import io.mockk.mockk
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class RemoveSourceFileWorkerTest {
|
||||
|
||||
@Test
|
||||
fun `missing source is already removed`() {
|
||||
val documentFile = mockk<DocumentFile>()
|
||||
every { documentFile.exists() } returns false
|
||||
|
||||
assertTrue(removeSourceDocument(documentFile))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `successful delete removes source`() {
|
||||
val documentFile = mockk<DocumentFile>()
|
||||
every { documentFile.exists() } returns true
|
||||
every { documentFile.delete() } returns true
|
||||
|
||||
assertTrue(removeSourceDocument(documentFile))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `failed delete requests another attempt`() {
|
||||
val documentFile = mockk<DocumentFile>()
|
||||
every { documentFile.exists() } returnsMany listOf(true, true)
|
||||
every { documentFile.delete() } returns false
|
||||
|
||||
assertFalse(removeSourceDocument(documentFile))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user