Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions opencloudApp/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -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() + "\""
Expand Down
19 changes: 17 additions & 2 deletions opencloudApp/src/main/java/eu/opencloud/android/MainApp.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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?) {
Expand Down Expand Up @@ -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)
Expand All @@ -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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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<AutomaticUploadsWorker>()
.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()

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,7 @@ class UploadFilesFromContentUriUseCase(
forceOverwrite = false,
createdBy = UploadEnqueuedBy.ENQUEUED_BY_USER,
spaceId = spaceId,
sourcePath = documentFile.uri.toString(),
)

return transferRepository.saveTransfer(ocTransfer).also {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 ->
Expand All @@ -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()
}
}
}
Expand Down Expand Up @@ -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),
Expand All @@ -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(
Expand Down Expand Up @@ -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<DocumentFile> = 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)}")
Expand Down Expand Up @@ -305,16 +321,19 @@ class AutomaticUploadsWorker(
forceOverwrite = false,
createdBy = createdByWorker,
spaceId = spaceId,
sourcePath = documentFile.uri.toString(),
)

return transferRepository.saveTransfer(ocTransfer)
}

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
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@ interface LocalTransferDataSource {
fun getFinishedTransfers(): List<OCTransfer>
fun clearFailedTransfers()
fun clearSuccessfulTransfers()
fun existsNonFailedTransferForUri(uri: String): Boolean

// TUS state management
fun updateTusState(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 *
Expand Down Expand Up @@ -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
)
"""
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@ interface TransferRepository {
fun getFinishedTransfers(): List<OCTransfer>
fun clearFailedTransfers()
fun clearSuccessfulTransfers()
fun existsNonFailedTransferForUri(uri: String): Boolean

// TUS state management
fun updateTusState(
Expand Down
Loading