diff --git a/opencloudApp/build.gradle b/opencloudApp/build.gradle index 24fd7d582..880b361ae 100644 --- a/opencloudApp/build.gradle +++ b/opencloudApp/build.gradle @@ -100,8 +100,8 @@ android { testInstrumentationRunner "eu.opencloud.android.utils.OCTestAndroidJUnitRunner" - versionCode = 5 - versionName = "1.2.0" + versionCode = 6 + versionName = "1.2.1" buildConfigField "String", gitRemote, "\"" + getGitOriginRemote() + "\"" buildConfigField "String", commitSHA1, "\"" + getLatestGitHash() + "\"" diff --git a/opencloudApp/src/main/java/eu/opencloud/android/MainApp.kt b/opencloudApp/src/main/java/eu/opencloud/android/MainApp.kt index 4f8c64c26..1a87639d8 100644 --- a/opencloudApp/src/main/java/eu/opencloud/android/MainApp.kt +++ b/opencloudApp/src/main/java/eu/opencloud/android/MainApp.kt @@ -67,6 +67,7 @@ import eu.opencloud.android.presentation.settings.logging.SettingsLogsFragment.C import eu.opencloud.android.providers.CoroutinesDispatcherProvider import eu.opencloud.android.providers.LogsProvider import eu.opencloud.android.providers.MdmProvider +import eu.opencloud.android.providers.WorkManagerProvider import eu.opencloud.android.ui.activity.FileDisplayActivity import eu.opencloud.android.ui.activity.FileDisplayActivity.Companion.PREFERENCE_CLEAR_DATA_ALREADY_TRIGGERED import eu.opencloud.android.ui.activity.WhatsNewActivity @@ -78,6 +79,8 @@ import eu.opencloud.android.utils.FILE_SYNC_NOTIFICATION_CHANNEL_ID import eu.opencloud.android.utils.MEDIA_SERVICE_NOTIFICATION_CHANNEL_ID import eu.opencloud.android.utils.UPLOAD_NOTIFICATION_CHANNEL_ID import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch import kotlinx.coroutines.runBlocking import kotlinx.coroutines.withContext import org.koin.android.ext.android.inject @@ -117,10 +120,11 @@ class MainApp : Application() { SingleSessionManager.setUserAgent(userAgent) - - initDependencyInjection() + val workManagerProvider: WorkManagerProvider by inject() + var startedActivities = 0 + // register global protection with pass code, pattern lock and biometric lock registerActivityLifecycleCallbacks(object : ActivityLifecycleCallbacks { override fun onActivityCreated(activity: Activity, savedInstanceState: Bundle?) { @@ -219,6 +223,16 @@ class MainApp : Application() { override fun onActivityStarted(activity: Activity) { Timber.v("${activity.javaClass.simpleName} onStart() starting") + if (startedActivities == 0) { + // App entered foreground — ensure the periodic worker is registered + // (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 { + workManagerProvider.enqueueAutomaticUploadsWorker() + workManagerProvider.enqueueImmediateAutomaticUploadsWorker() + } + } + startedActivities++ PassCodeManager.onActivityStarted(activity) PatternManager.onActivityStarted(activity) BiometricManager.onActivityStarted(activity) @@ -233,6 +247,7 @@ class MainApp : Application() { } override fun onActivityStopped(activity: Activity) { + startedActivities-- Timber.v("${activity.javaClass.simpleName} onStop() ending") PassCodeManager.onActivityStopped(activity) PatternManager.onActivityStopped(activity) diff --git a/opencloudApp/src/main/java/eu/opencloud/android/presentation/transfers/TransfersAdapter.kt b/opencloudApp/src/main/java/eu/opencloud/android/presentation/transfers/TransfersAdapter.kt index a5610a589..8f7aea65a 100644 --- a/opencloudApp/src/main/java/eu/opencloud/android/presentation/transfers/TransfersAdapter.kt +++ b/opencloudApp/src/main/java/eu/opencloud/android/presentation/transfers/TransfersAdapter.kt @@ -155,7 +155,7 @@ class TransfersAdapter( uploadStatus.text = " — " + holder.itemView.context.getString(transferItem.transfer.statusToStringRes()) Glide.with(holder.itemView) - .load(transferItem.transfer.localPath) + .load(transferItem.transfer.sourcePath ?: transferItem.transfer.localPath) .diskCacheStrategy(DiskCacheStrategy.ALL) .placeholder( MimetypeIconUtil.getFileTypeIconId( diff --git a/opencloudApp/src/main/java/eu/opencloud/android/providers/WorkManagerProvider.kt b/opencloudApp/src/main/java/eu/opencloud/android/providers/WorkManagerProvider.kt index 4d538eb76..e6f586572 100644 --- a/opencloudApp/src/main/java/eu/opencloud/android/providers/WorkManagerProvider.kt +++ b/opencloudApp/src/main/java/eu/opencloud/android/providers/WorkManagerProvider.kt @@ -25,6 +25,7 @@ import android.content.Context import androidx.lifecycle.LiveData import androidx.work.Constraints import androidx.work.ExistingPeriodicWorkPolicy +import androidx.work.ExistingWorkPolicy import androidx.work.NetworkType import androidx.work.OneTimeWorkRequestBuilder import androidx.work.PeriodicWorkRequestBuilder @@ -40,6 +41,7 @@ import eu.opencloud.android.workers.OldLogsCollectorWorker import eu.opencloud.android.workers.RemoveLocallyFilesWithLastUsageOlderThanGivenTimeWorker import eu.opencloud.android.workers.UploadFileFromContentUriWorker import eu.opencloud.android.workers.UploadFileFromFileSystemWorker +import timber.log.Timber class WorkManagerProvider( val context: Context @@ -55,6 +57,36 @@ class WorkManagerProvider( .enqueueUniquePeriodicWork(AutomaticUploadsWorker.AUTOMATIC_UPLOADS_WORKER, ExistingPeriodicWorkPolicy.KEEP, automaticUploadsWorker) } + /** + * 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 + * concurrent scans or redundant enqueues on rapid foreground/background switches. + */ + fun enqueueImmediateAutomaticUploadsWorker() { + val wm = WorkManager.getInstance(context) + + val periodicRunning = wm.getWorkInfosForUniqueWork(AutomaticUploadsWorker.AUTOMATIC_UPLOADS_WORKER) + .get().any { it.state == WorkInfo.State.RUNNING } + val immediateRunning = wm.getWorkInfosForUniqueWork(AutomaticUploadsWorker.IMMEDIATE_UPLOADS_WORKER) + .get().any { it.state == WorkInfo.State.RUNNING } + + if (periodicRunning || immediateRunning) { + Timber.d("Automatic uploads worker already running, skipping immediate run") + return + } + + val immediateWorker = OneTimeWorkRequestBuilder() + .addTag(AutomaticUploadsWorker.IMMEDIATE_UPLOADS_WORKER) + .setInitialDelay(AutomaticUploadsWorker.WRITE_SAFETY_BUFFER_MS, java.util.concurrent.TimeUnit.MILLISECONDS) + .build() + + wm.enqueueUniqueWork( + AutomaticUploadsWorker.IMMEDIATE_UPLOADS_WORKER, + ExistingWorkPolicy.KEEP, + immediateWorker + ) + } + fun enqueueOldLogsCollectorWorker() { val constraintsRequired = Constraints.Builder().setRequiredNetworkType(NetworkType.NOT_REQUIRED).build() diff --git a/opencloudApp/src/main/java/eu/opencloud/android/usecases/transfers/uploads/RetryUploadFromContentUriUseCase.kt b/opencloudApp/src/main/java/eu/opencloud/android/usecases/transfers/uploads/RetryUploadFromContentUriUseCase.kt index ad3b4c757..43b92de9d 100644 --- a/opencloudApp/src/main/java/eu/opencloud/android/usecases/transfers/uploads/RetryUploadFromContentUriUseCase.kt +++ b/opencloudApp/src/main/java/eu/opencloud/android/usecases/transfers/uploads/RetryUploadFromContentUriUseCase.kt @@ -63,7 +63,7 @@ class RetryUploadFromContentUriUseCase( uploadFileFromContentUriUseCase( UploadFileFromContentUriUseCase.Params( accountName = uploadToRetry.accountName, - contentUri = uploadToRetry.localPath.toUri(), + contentUri = (uploadToRetry.sourcePath ?: uploadToRetry.localPath).toUri(), lastModifiedInSeconds = lastModifiedInSeconds, behavior = uploadToRetry.localBehaviour.name, uploadPath = uploadToRetry.remotePath, diff --git a/opencloudApp/src/main/java/eu/opencloud/android/usecases/transfers/uploads/UploadFilesFromContentUriUseCase.kt b/opencloudApp/src/main/java/eu/opencloud/android/usecases/transfers/uploads/UploadFilesFromContentUriUseCase.kt index fef6a55f2..cf080e2b1 100644 --- a/opencloudApp/src/main/java/eu/opencloud/android/usecases/transfers/uploads/UploadFilesFromContentUriUseCase.kt +++ b/opencloudApp/src/main/java/eu/opencloud/android/usecases/transfers/uploads/UploadFilesFromContentUriUseCase.kt @@ -86,6 +86,7 @@ class UploadFilesFromContentUriUseCase( forceOverwrite = false, createdBy = UploadEnqueuedBy.ENQUEUED_BY_USER, spaceId = spaceId, + sourcePath = documentFile.uri.toString(), ) return transferRepository.saveTransfer(ocTransfer).also { diff --git a/opencloudApp/src/main/java/eu/opencloud/android/workers/AutomaticUploadsWorker.kt b/opencloudApp/src/main/java/eu/opencloud/android/workers/AutomaticUploadsWorker.kt index b60fba020..6b1a7486e 100644 --- a/opencloudApp/src/main/java/eu/opencloud/android/workers/AutomaticUploadsWorker.kt +++ b/opencloudApp/src/main/java/eu/opencloud/android/workers/AutomaticUploadsWorker.kt @@ -90,7 +90,6 @@ class AutomaticUploadsWorker( } catch (illegalArgumentException: IllegalArgumentException) { Timber.e(illegalArgumentException, "Source path for picture uploads is not valid") showNotificationToUpdateUri(SyncType.PICTURE_UPLOADS) - return Result.failure() } } cameraUploadsConfiguration.videoUploadsConfiguration?.let { videoUploadsConfiguration -> @@ -100,7 +99,6 @@ class AutomaticUploadsWorker( } catch (illegalArgumentException: IllegalArgumentException) { Timber.e(illegalArgumentException, "Source path for video uploads is not valid") showNotificationToUpdateUri(SyncType.VIDEO_UPLOADS) - return Result.failure() } } } @@ -144,6 +142,16 @@ class AutomaticUploadsWorker( showNotification(syncType, localPicturesDocumentFiles.size) 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 + // 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 uploadId = storeInUploadsDatabase( documentFile = documentFile, uploadPath = folderBackUpConfiguration.uploadPath.plus(File.separator).plus(documentFile.name), @@ -166,7 +174,10 @@ class AutomaticUploadsWorker( chargingOnly = folderBackUpConfiguration.chargingOnly ) } - updateTimestamp(folderBackUpConfiguration, syncType, currentTimestamp) + // Save safeTimestamp (not currentTimestamp) so that files skipped by the + // write-safety buffer are re-evaluated on the next run instead of being lost. + val safeTimestamp = currentTimestamp - WRITE_SAFETY_BUFFER_MS + updateTimestamp(folderBackUpConfiguration, syncType, safeTimestamp) } private fun showNotification( @@ -247,10 +258,15 @@ class AutomaticUploadsWorker( val documentTree = DocumentFile.fromTreeUri(applicationContext, sourceUri) val arrayOfLocalFiles = documentTree?.listFiles() ?: arrayOf() + // 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 + // can result in uploading a truncated or 0-byte JPEG. + val safeTimestamp = currentTimestamp - WRITE_SAFETY_BUFFER_MS + val filteredList: List = arrayOfLocalFiles .sortedBy { it.lastModified() } .filter { it.lastModified() >= lastSyncTimestamp } - .filter { it.lastModified() < currentTimestamp } + .filter { it.lastModified() < safeTimestamp } .filter { MimetypeIconUtil.getBestMimeTypeByFilename(it.name).startsWith(syncType.prefixForType) } Timber.i("Last sync ${syncType.name}: ${Date(lastSyncTimestamp)}") @@ -305,6 +321,7 @@ class AutomaticUploadsWorker( forceOverwrite = false, createdBy = createdByWorker, spaceId = spaceId, + sourcePath = documentFile.uri.toString(), ) return transferRepository.saveTransfer(ocTransfer) @@ -312,9 +329,11 @@ class AutomaticUploadsWorker( companion object { const val AUTOMATIC_UPLOADS_WORKER = "AUTOMATIC_UPLOADS_WORKER" + const val IMMEDIATE_UPLOADS_WORKER = "IMMEDIATE_AUTOMATIC_UPLOADS_WORKER" const val repeatInterval: Long = 15L val repeatIntervalTimeUnit: TimeUnit = TimeUnit.MINUTES private const val pictureUploadsNotificationId = 101 private const val videoUploadsNotificationId = 102 + const val WRITE_SAFETY_BUFFER_MS = 10_000L } } diff --git a/opencloudApp/src/main/java/eu/opencloud/android/workers/UploadFileFromContentUriWorker.kt b/opencloudApp/src/main/java/eu/opencloud/android/workers/UploadFileFromContentUriWorker.kt index 4025d811f..bee2f350d 100644 --- a/opencloudApp/src/main/java/eu/opencloud/android/workers/UploadFileFromContentUriWorker.kt +++ b/opencloudApp/src/main/java/eu/opencloud/android/workers/UploadFileFromContentUriWorker.kt @@ -204,13 +204,26 @@ class UploadFileFromContentUriWorker( } cacheFile.createNewFile() + // openInputStream can return null if the content provider is unavailable or permissions were revoked. + // Failing here avoids silently uploading a 0-byte file. val inputStream = appContext.contentResolver.openInputStream(contentUri) + if (inputStream == null) { + Timber.e("Failed to open input stream for %s — content provider unavailable or permissions revoked", contentUri) + throw LocalFileNotFoundException() + } val outputStream = FileOutputStream(cachePath) - outputStream.use { fileOut -> - inputStream?.copyTo(fileOut) + inputStream.use { input -> + outputStream.use { output -> + input.copyTo(output) + } + } + + // Guard against a truncated or empty copy (e.g. file deleted mid-read). + if (cacheFile.length() == 0L) { + Timber.e("Cache file is 0 bytes after copy from %s — source may have been deleted mid-read", contentUri) + cacheFile.delete() + throw LocalFileNotFoundException() } - inputStream?.close() - outputStream.close() transferRepository.updateTransferSourcePath(uploadIdInStorageManager, contentUri.toString()) transferRepository.updateTransferLocalPath(uploadIdInStorageManager, cachePath) diff --git a/opencloudData/src/main/java/eu/opencloud/android/data/transfers/datasources/LocalTransferDataSource.kt b/opencloudData/src/main/java/eu/opencloud/android/data/transfers/datasources/LocalTransferDataSource.kt index 11c5ff976..764cc39de 100644 --- a/opencloudData/src/main/java/eu/opencloud/android/data/transfers/datasources/LocalTransferDataSource.kt +++ b/opencloudData/src/main/java/eu/opencloud/android/data/transfers/datasources/LocalTransferDataSource.kt @@ -57,6 +57,7 @@ interface LocalTransferDataSource { fun getFinishedTransfers(): List fun clearFailedTransfers() fun clearSuccessfulTransfers() + fun existsNonFailedTransferForUri(uri: String): Boolean // TUS state management fun updateTusState( diff --git a/opencloudData/src/main/java/eu/opencloud/android/data/transfers/datasources/implementation/OCLocalTransferDataSource.kt b/opencloudData/src/main/java/eu/opencloud/android/data/transfers/datasources/implementation/OCLocalTransferDataSource.kt index 09c18915a..7b3101589 100644 --- a/opencloudData/src/main/java/eu/opencloud/android/data/transfers/datasources/implementation/OCLocalTransferDataSource.kt +++ b/opencloudData/src/main/java/eu/opencloud/android/data/transfers/datasources/implementation/OCLocalTransferDataSource.kt @@ -141,6 +141,9 @@ class OCLocalTransferDataSource( transferDao.deleteTransfersWithStatus(TransferStatus.TRANSFER_SUCCEEDED.value) } + override fun existsNonFailedTransferForUri(uri: String): Boolean = + transferDao.existsNonFailedTransferForUri(uri) + // TUS state management override fun updateTusState( id: Long, diff --git a/opencloudData/src/main/java/eu/opencloud/android/data/transfers/db/TransferDao.kt b/opencloudData/src/main/java/eu/opencloud/android/data/transfers/db/TransferDao.kt index 529f65eff..d23aedee0 100644 --- a/opencloudData/src/main/java/eu/opencloud/android/data/transfers/db/TransferDao.kt +++ b/opencloudData/src/main/java/eu/opencloud/android/data/transfers/db/TransferDao.kt @@ -88,6 +88,9 @@ interface TransferDao { @Query(DELETE_TRANSFERS_WITH_STATUS) fun deleteTransfersWithStatus(status: Int) + @Query(EXISTS_NON_FAILED_TRANSFER_FOR_URI) + fun existsNonFailedTransferForUri(uri: String): Boolean + companion object { private const val SELECT_TRANSFER_WITH_ID = """ SELECT * @@ -177,5 +180,15 @@ interface TransferDao { FROM $TRANSFERS_TABLE_NAME WHERE status = :status """ + + /** status 2 = TRANSFER_FAILED, see [TransferStatus] */ + private const val EXISTS_NON_FAILED_TRANSFER_FOR_URI = """ + SELECT EXISTS( + SELECT 1 + FROM $TRANSFERS_TABLE_NAME + WHERE sourcePath = :uri + AND status != 2 + ) + """ } } diff --git a/opencloudData/src/main/java/eu/opencloud/android/data/transfers/repository/OCTransferRepository.kt b/opencloudData/src/main/java/eu/opencloud/android/data/transfers/repository/OCTransferRepository.kt index fa69e355f..e27b95f45 100644 --- a/opencloudData/src/main/java/eu/opencloud/android/data/transfers/repository/OCTransferRepository.kt +++ b/opencloudData/src/main/java/eu/opencloud/android/data/transfers/repository/OCTransferRepository.kt @@ -112,6 +112,9 @@ class OCTransferRepository( override fun clearSuccessfulTransfers() = localTransferDataSource.clearSuccessfulTransfers() + override fun existsNonFailedTransferForUri(uri: String): Boolean = + localTransferDataSource.existsNonFailedTransferForUri(uri) + // TUS state management override fun updateTusState( id: Long, diff --git a/opencloudDomain/src/main/java/eu/opencloud/android/domain/transfers/TransferRepository.kt b/opencloudDomain/src/main/java/eu/opencloud/android/domain/transfers/TransferRepository.kt index 376fde79c..3fd9b8d72 100644 --- a/opencloudDomain/src/main/java/eu/opencloud/android/domain/transfers/TransferRepository.kt +++ b/opencloudDomain/src/main/java/eu/opencloud/android/domain/transfers/TransferRepository.kt @@ -57,6 +57,7 @@ interface TransferRepository { fun getFinishedTransfers(): List fun clearFailedTransfers() fun clearSuccessfulTransfers() + fun existsNonFailedTransferForUri(uri: String): Boolean // TUS state management fun updateTusState(