Skip to content

Commit 901f157

Browse files
committed
fix: serve up-to-date files via SAF and deduplicate concurrent requests
The previous SAF (DocumentsStorageProvider) implementation fired SynchronizeFileUseCase asynchronously and polled isAvailableLocally, serving stale local copies when a newer version existed on the server. Rewrite openDocument to handle sync results synchronously: - For already-downloaded files: run PROPFIND, act on the result (wait for download, handle conflicts, etc.) instead of fire-and-forget. - For new files: enqueue download directly, skip unnecessary PROPFIND. Apps like Google Photos call openDocument 4+ times concurrently for the same file. Add two layers of dedup to avoid redundant network requests: - In-flight dedup (ConcurrentHashMap + CompletableFuture): concurrent calls share one PROPFIND or one download instead of each doing their own. - TTL cache: skip re-checking a file that was just synced/downloaded. Fix false "changed locally" detection in DownloadFileWorker by using the file's actual filesystem mtime for lastSyncDateForData instead of System.currentTimeMillis(), which could be slightly earlier than the file's mtime due to second-precision rounding.
1 parent 9fa8930 commit 901f157

2 files changed

Lines changed: 254 additions & 21 deletions

File tree

opencloudApp/src/main/java/eu/opencloud/android/presentation/documentsprovider/DocumentsStorageProvider.kt

Lines changed: 242 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -62,10 +62,13 @@ import eu.opencloud.android.presentation.documentsprovider.cursors.RootCursor
6262
import eu.opencloud.android.presentation.documentsprovider.cursors.SpaceCursor
6363
import eu.opencloud.android.presentation.settings.security.SettingsSecurityFragment.Companion.PREFERENCE_LOCK_ACCESS_FROM_DOCUMENT_PROVIDER
6464
import eu.opencloud.android.usecases.synchronization.SynchronizeFileUseCase
65+
import eu.opencloud.android.usecases.transfers.downloads.DownloadFileUseCase
6566
import eu.opencloud.android.usecases.synchronization.SynchronizeFolderUseCase
6667
import eu.opencloud.android.usecases.transfers.uploads.UploadFilesFromSystemUseCase
6768
import eu.opencloud.android.utils.FileStorageUtils
6869
import eu.opencloud.android.utils.NotificationUtils
70+
import androidx.work.WorkInfo
71+
import androidx.work.WorkManager
6972
import kotlinx.coroutines.CoroutineScope
7073
import kotlinx.coroutines.Dispatchers
7174
import kotlinx.coroutines.launch
@@ -74,7 +77,10 @@ import timber.log.Timber
7477
import java.io.File
7578
import java.io.FileNotFoundException
7679
import java.io.IOException
80+
import java.util.UUID
7781
import java.util.Vector
82+
import java.util.concurrent.ConcurrentHashMap
83+
import java.util.concurrent.CompletableFuture
7884

7985
class DocumentsStorageProvider : DocumentsProvider() {
8086
/**
@@ -89,6 +95,17 @@ class DocumentsStorageProvider : DocumentsProvider() {
8995

9096
private lateinit var fileToUpload: OCFile
9197

98+
// Cache to avoid redundant PROPFINDs when apps (e.g. Google Photos) call
99+
// openDocument many times for the same file. Two layers:
100+
// 1. In-flight dedup: concurrent calls for the same file share one PROPFIND via
101+
// a CompletableFuture. The first caller does the actual work, others wait.
102+
// 2. TTL cache: after a sync completes, skip re-checking the same file for a
103+
// few seconds to handle rapid sequential calls.
104+
private val inFlightSyncs = ConcurrentHashMap<Long, CompletableFuture<SynchronizeFileUseCase.SyncType?>>()
105+
private val inFlightDownloads = ConcurrentHashMap<Long, CompletableFuture<Boolean>>()
106+
private var propfindCacheFileId: Long? = null
107+
private var propfindCacheTimestamp: Long = 0
108+
92109
override fun openDocument(
93110
documentId: String,
94111
mode: String,
@@ -106,15 +123,78 @@ class DocumentsStorageProvider : DocumentsProvider() {
106123
if (!uploadOnly) {
107124
ocFile = getFileByIdOrException(documentId.toInt())
108125

109-
if (!ocFile.isAvailableLocally || !isWrite) {
110-
syncFileWithServer(ocFile)
111-
do {
112-
if (!waitOrGetCancelled(signal)) {
113-
return null
126+
if (!ocFile.isAvailableLocally) {
127+
// File has never been downloaded. Enqueue the download directly —
128+
// no need for a PROPFIND since we already know we need the file.
129+
// Apps like Google Photos call openDocument concurrently for the
130+
// same file — dedup so only one download is enqueued.
131+
if (!downloadFileCoalesced(ocFile.id!!, ocFile, documentId.toInt(), signal)) {
132+
return null
133+
}
134+
ocFile = getFileByIdOrException(documentId.toInt())
135+
if (!ocFile.isAvailableLocally) {
136+
return null
137+
}
138+
// Seed the TTL cache — the file was just downloaded, so there's no need
139+
// for a PROPFIND if Google Photos immediately calls openDocument again.
140+
propfindCacheFileId = ocFile.id
141+
propfindCacheTimestamp = System.currentTimeMillis()
142+
} else if (!isWrite) {
143+
// File is available locally and opened for reading. Check with the server
144+
// (PROPFIND) whether a newer version exists, and download it if so.
145+
//
146+
// Apps like Google Photos call openDocument many times concurrently for
147+
// the same file. Without dedup, each call does its own PROPFIND, and due
148+
// to the synchronized lock in OpenCloudClient.executeHttpMethod they
149+
// serialize — causing 10+ second waits per extra call. We handle this
150+
// with two layers:
151+
// 1. TTL cache: skip if we just confirmed this file is up-to-date.
152+
// 2. In-flight dedup: concurrent calls share one PROPFIND result.
153+
val fileId = ocFile.id!!
154+
val now = System.currentTimeMillis()
155+
if (fileId == propfindCacheFileId && now - propfindCacheTimestamp <= PROPFIND_CACHE_TTL_MS) {
156+
Timber.d("Skipping PROPFIND for file $fileId, recently synced ${now - propfindCacheTimestamp}ms ago")
157+
} else {
158+
val syncResult = syncFileWithServerCoalesced(ocFile)
159+
160+
when (syncResult) {
161+
is SynchronizeFileUseCase.SyncType.AlreadySynchronized -> {
162+
// File is up to date, nothing to wait for.
163+
}
164+
is SynchronizeFileUseCase.SyncType.DownloadEnqueued -> {
165+
// A newer version exists. SynchronizeFileUseCase only enqueues
166+
// a WorkManager download, it does not wait for it to finish.
167+
if (!waitForDownload(syncResult.workerId, documentId.toInt(), signal)) {
168+
return null
169+
}
170+
}
171+
is SynchronizeFileUseCase.SyncType.ConflictDetected -> {
172+
// File changed both locally and remotely. Notify the user and
173+
// serve the local version (same behavior as before).
174+
context?.let {
175+
NotificationUtils.notifyConflict(fileInConflict = ocFile, context = it)
176+
}
177+
}
178+
is SynchronizeFileUseCase.SyncType.FileNotFound -> {
179+
return null
180+
}
181+
is SynchronizeFileUseCase.SyncType.UploadEnqueued -> {
182+
// Local file is newer, upload was enqueued. Serve the local version.
183+
}
184+
null -> {
185+
// Sync failed, serve the local version anyway.
186+
}
114187
}
115-
ocFile = getFileByIdOrException(documentId.toInt())
116188

117-
} while (!ocFile.isAvailableLocally)
189+
propfindCacheFileId = fileId
190+
propfindCacheTimestamp = System.currentTimeMillis()
191+
192+
// Re-read the file from DB to get the updated state after download.
193+
ocFile = getFileByIdOrException(documentId.toInt())
194+
if (!ocFile.isAvailableLocally) {
195+
return null
196+
}
197+
}
118198
}
119199
} else {
120200
ocFile = fileToUpload
@@ -146,7 +226,7 @@ class DocumentsStorageProvider : DocumentsProvider() {
146226
uploadFilesUseCase(uploadFilesUseCaseParams)
147227
}
148228
} else {
149-
syncFileWithServer(ocFile)
229+
syncFileWithServerAsync(ocFile)
150230
}
151231
}
152232
} catch (e: IOException) {
@@ -468,28 +548,170 @@ class DocumentsStorageProvider : DocumentsProvider() {
468548
return NONEXISTENT_DOCUMENT_ID
469549
}
470550

471-
private fun syncFileWithServer(fileToSync: OCFile) {
472-
Timber.d("Trying to sync a file ${fileToSync.id} with server")
551+
/**
552+
* Synchronize a file with the server, coalescing concurrent requests.
553+
*
554+
* If another thread is already syncing this file, we wait for its result instead of
555+
* starting a second PROPFIND. This avoids the serialized lock contention in
556+
* OpenCloudClient.executeHttpMethod when multiple binder threads call openDocument
557+
* for the same file simultaneously.
558+
*
559+
* The future is always removed from [inFlightSyncs] when done (via finally),
560+
* so errors or timeouts cannot leave stale entries that would block future syncs.
561+
*/
562+
private fun syncFileWithServerCoalesced(fileToSync: OCFile): SynchronizeFileUseCase.SyncType? {
563+
val fileId = fileToSync.id!!
564+
val newFuture = CompletableFuture<SynchronizeFileUseCase.SyncType?>()
565+
val existingFuture = inFlightSyncs.putIfAbsent(fileId, newFuture)
566+
567+
if (existingFuture != null) {
568+
// Another thread is already syncing this file. Wait for its result.
569+
Timber.d("Sync for file $fileId already in flight, waiting for result")
570+
return try {
571+
existingFuture.get()
572+
} catch (e: Exception) {
573+
Timber.w(e, "In-flight sync for file $fileId failed, serving local version")
574+
null
575+
}
576+
}
577+
578+
// We are the first thread — do the actual PROPFIND.
579+
return try {
580+
val result = syncFileWithServerBlocking(fileToSync)
581+
newFuture.complete(result)
582+
result
583+
} catch (e: Exception) {
584+
newFuture.completeExceptionally(e)
585+
throw e
586+
} finally {
587+
inFlightSyncs.remove(fileId)
588+
}
589+
}
590+
591+
/**
592+
* Download a file, deduplicating concurrent requests for the same file.
593+
*
594+
* Same pattern as [syncFileWithServerCoalesced]: the first thread enqueues the
595+
* WorkManager download and waits; concurrent threads wait on the same future.
596+
* This prevents apps like Google Photos (which call openDocument 4+ times
597+
* concurrently) from enqueuing 4 separate download workers for the same file.
598+
*
599+
* @return true if the download succeeded, false otherwise.
600+
*/
601+
private fun downloadFileCoalesced(fileId: Long, ocFile: OCFile, docId: Int, signal: CancellationSignal?): Boolean {
602+
val newFuture = CompletableFuture<Boolean>()
603+
val existingFuture = inFlightDownloads.putIfAbsent(fileId, newFuture)
473604

474-
val synchronizeFileUseCase : SynchronizeFileUseCase by inject()
475-
val synchronizeFileUseCaseParam = SynchronizeFileUseCase.Params(
476-
fileToSynchronize = fileToSync
605+
if (existingFuture != null) {
606+
Timber.d("Download for file $fileId already in flight, waiting")
607+
return try { existingFuture.get() } catch (_: Exception) { false }
608+
}
609+
610+
return try {
611+
val downloadFileUseCase: DownloadFileUseCase by inject()
612+
val workerId = downloadFileUseCase(
613+
DownloadFileUseCase.Params(accountName = ocFile.owner, file = ocFile)
614+
)
615+
val ok = waitForDownload(workerId, docId, signal)
616+
newFuture.complete(ok)
617+
ok
618+
} catch (e: Exception) {
619+
Timber.w(e, "Download for file $fileId failed")
620+
newFuture.complete(false)
621+
false
622+
} finally {
623+
inFlightDownloads.remove(fileId)
624+
}
625+
}
626+
627+
/**
628+
* Synchronize a file with the server and return the result.
629+
* Runs synchronously on the calling thread (blocks until the PROPFIND completes).
630+
* Note: if a download is needed, this only *enqueues* it — use [waitForDownload] to
631+
* wait for the actual download to finish.
632+
*/
633+
private fun syncFileWithServerBlocking(fileToSync: OCFile): SynchronizeFileUseCase.SyncType? {
634+
Timber.d("Trying to sync file ${fileToSync.id} with server (blocking)")
635+
636+
val synchronizeFileUseCase: SynchronizeFileUseCase by inject()
637+
val useCaseResult = synchronizeFileUseCase(
638+
SynchronizeFileUseCase.Params(fileToSynchronize = fileToSync)
477639
)
640+
Timber.d("${fileToSync.remotePath} from ${fileToSync.owner} synced with result: $useCaseResult")
641+
642+
return useCaseResult.getDataOrNull()
643+
}
644+
645+
/**
646+
* Fire-and-forget sync: used in the close handler after writes,
647+
* where we don't need to wait for the result.
648+
*/
649+
private fun syncFileWithServerAsync(fileToSync: OCFile) {
650+
Timber.d("Trying to sync file ${fileToSync.id} with server (async)")
651+
652+
val synchronizeFileUseCase: SynchronizeFileUseCase by inject()
478653
CoroutineScope(Dispatchers.IO).launch {
479-
val useCaseResult = synchronizeFileUseCase(synchronizeFileUseCaseParam)
480-
Timber.d("${fileToSync.remotePath} from ${fileToSync.owner} was synced with server with result: $useCaseResult")
654+
val useCaseResult = synchronizeFileUseCase(
655+
SynchronizeFileUseCase.Params(fileToSynchronize = fileToSync)
656+
)
657+
Timber.d("${fileToSync.remotePath} from ${fileToSync.owner} synced with result: $useCaseResult")
481658

482659
if (useCaseResult.getDataOrNull() is SynchronizeFileUseCase.SyncType.ConflictDetected) {
483660
context?.let {
484-
NotificationUtils.notifyConflict(
485-
fileInConflict = fileToSync,
486-
context = it
487-
)
661+
NotificationUtils.notifyConflict(fileInConflict = fileToSync, context = it)
488662
}
489663
}
490664
}
491665
}
492666

667+
/**
668+
* Wait for a download to finish.
669+
*
670+
* If [workerId] is non-null, we use WorkManager to wait directly for that specific job.
671+
* If [workerId] is null, it means a download for this file was already in progress
672+
* (enqueued by a previous call), so we fall back to polling the DB until the file
673+
* becomes available locally.
674+
*
675+
* Note: openDocument can be called concurrently on multiple binder threads for the
676+
* same file (e.g. the calling app retries or requests the file multiple times).
677+
* The first call enqueues the download and gets a workerId; subsequent concurrent
678+
* calls get null (DownloadFileUseCase deduplicates) and use the polling fallback.
679+
*
680+
* @return true if the file is ready, false if cancelled.
681+
*/
682+
private fun waitForDownload(workerId: UUID?, fileId: Int, signal: CancellationSignal?): Boolean {
683+
if (workerId != null) {
684+
// Poll WorkManager until this specific job reaches a terminal state.
685+
// Note: getWorkInfoById().get() returns the *current* state immediately,
686+
// it does NOT block until the work finishes.
687+
Timber.d("Waiting for download worker $workerId to finish")
688+
val workManager = WorkManager.getInstance(context!!)
689+
do {
690+
if (!waitOrGetCancelled(signal)) {
691+
return false
692+
}
693+
val workInfo = workManager.getWorkInfoById(workerId).get()
694+
Timber.d("Download worker $workerId state: ${workInfo.state}")
695+
when (workInfo.state) {
696+
WorkInfo.State.SUCCEEDED -> return true
697+
WorkInfo.State.FAILED, WorkInfo.State.CANCELLED -> return false
698+
else -> { /* ENQUEUED, RUNNING, BLOCKED — keep waiting */ }
699+
}
700+
} while (true)
701+
}
702+
703+
// workerId is null — a download was already in progress from a previous request.
704+
// Poll until the file appears locally, checking for cancellation each second.
705+
Timber.d("Download already in progress for file $fileId, polling until available")
706+
do {
707+
if (!waitOrGetCancelled(signal)) {
708+
return false
709+
}
710+
val file = getFileByIdOrException(fileId)
711+
if (file.isAvailableLocally) return true
712+
} while (true)
713+
}
714+
493715
private fun syncDirectoryWithServer(parentDocumentId: String) {
494716
Timber.d("Trying to sync $parentDocumentId with server")
495717
val folderToSync = getFileByIdOrException(parentDocumentId.toInt())
@@ -585,5 +807,6 @@ class DocumentsStorageProvider : DocumentsProvider() {
585807

586808
companion object {
587809
const val NONEXISTENT_DOCUMENT_ID = "-1"
810+
const val PROPFIND_CACHE_TTL_MS = 3000L
588811
}
589812
}

opencloudApp/src/main/java/eu/opencloud/android/workers/DownloadFileWorker.kt

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -205,14 +205,24 @@ class DownloadFileWorker(
205205
* We will update info about local storage (where it was stored and its size)
206206
*/
207207
private fun updateDatabaseWithLatestInfoForThisFile() {
208+
val finalFile = File(finalLocationForFile)
208209
val currentTime = System.currentTimeMillis()
209210
ocFile.apply {
210211
needsToUpdateThumbnail = true
211212
modificationTimestamp = downloadRemoteFileOperation.modificationTimestamp
212213
etag = downloadRemoteFileOperation.etag
213214
storagePath = finalLocationForFile
214-
length = (File(finalLocationForFile).length())
215-
lastSyncDateForData = currentTime
215+
length = finalFile.length()
216+
// Use the file's actual mtime, not the current time. SynchronizeFileUseCase
217+
// compares lastSyncDateForData against the file's filesystem mtime to detect
218+
// local modifications. Using currentTime here can be slightly earlier than the
219+
// file's mtime (due to write time and second-precision rounding), which causes
220+
// a false "changed locally" detection and an unnecessary upload.
221+
// This mainly affects SAF (DocumentsStorageProvider) where apps like Google
222+
// Photos call openDocument repeatedly right after download, triggering the
223+
// false positive immediately. In normal app usage, the next sync is typically
224+
// much later and the small timestamp difference doesn't cause issues.
225+
lastSyncDateForData = finalFile.lastModified()
216226
modifiedAtLastSyncForData = downloadRemoteFileOperation.modificationTimestamp
217227
lastUsage = currentTime
218228
}

0 commit comments

Comments
 (0)