Implement background media ingestion and Pi deployment
Android / test-and-build (push) Canceled after 0s
Server / deployment-config (push) Canceled after 0s
Server / vulnerability-scan (push) Canceled after 0s

This commit is contained in:
Курнат Андрей
2026-07-15 22:58:03 +03:00
parent 8da166fb6b
commit e48e1e36a5
32 changed files with 825 additions and 76 deletions
+19 -5
View File
@@ -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()
}
}
@@ -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
}
@@ -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,
@@ -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
@@ -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,
@@ -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
}
@@ -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"
@@ -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),
)
)
}
}
@@ -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))
}
}