@@ -62,10 +62,13 @@ import eu.opencloud.android.presentation.documentsprovider.cursors.RootCursor
6262import eu.opencloud.android.presentation.documentsprovider.cursors.SpaceCursor
6363import eu.opencloud.android.presentation.settings.security.SettingsSecurityFragment.Companion.PREFERENCE_LOCK_ACCESS_FROM_DOCUMENT_PROVIDER
6464import eu.opencloud.android.usecases.synchronization.SynchronizeFileUseCase
65+ import eu.opencloud.android.usecases.transfers.downloads.DownloadFileUseCase
6566import eu.opencloud.android.usecases.synchronization.SynchronizeFolderUseCase
6667import eu.opencloud.android.usecases.transfers.uploads.UploadFilesFromSystemUseCase
6768import eu.opencloud.android.utils.FileStorageUtils
6869import eu.opencloud.android.utils.NotificationUtils
70+ import androidx.work.WorkInfo
71+ import androidx.work.WorkManager
6972import kotlinx.coroutines.CoroutineScope
7073import kotlinx.coroutines.Dispatchers
7174import kotlinx.coroutines.launch
@@ -74,7 +77,10 @@ import timber.log.Timber
7477import java.io.File
7578import java.io.FileNotFoundException
7679import java.io.IOException
80+ import java.util.UUID
7781import java.util.Vector
82+ import java.util.concurrent.ConcurrentHashMap
83+ import java.util.concurrent.CompletableFuture
7884
7985class 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}
0 commit comments