From fecba76234ccf26330bd6319c9694d00c67f1c2e Mon Sep 17 00:00:00 2001 From: alvaroemtnez Date: Sun, 26 Jul 2026 17:04:18 +0200 Subject: [PATCH 1/2] Support ACTION_OPEN_DOCUMENT_TREE for Android Storage Access Framework, include a toggle to pretend to be local storage and solve COLUMN_FLAGS being declared twice in RootCursor.kt --- .../DocumentsStorageProvider.kt | 35 ++++++++++++++++++- .../documentsprovider/cursors/RootCursor.kt | 14 +++++--- .../advanced/SettingsAdvancedFragment.kt | 10 ++++++ .../src/main/res/values-de/strings.xml | 2 ++ .../src/main/res/values-es/strings.xml | 2 ++ .../src/main/res/values-fr/strings.xml | 2 ++ .../src/main/res/values-it/strings.xml | 2 ++ .../src/main/res/values-nl/strings.xml | 2 ++ .../src/main/res/values-pl/strings.xml | 2 ++ opencloudApp/src/main/res/values/strings.xml | 2 ++ .../src/main/res/xml/settings_advanced.xml | 7 ++++ 11 files changed, 74 insertions(+), 6 deletions(-) diff --git a/opencloudApp/src/main/java/eu/opencloud/android/presentation/documentsprovider/DocumentsStorageProvider.kt b/opencloudApp/src/main/java/eu/opencloud/android/presentation/documentsprovider/DocumentsStorageProvider.kt index 13877b9638..09d43c2cd4 100644 --- a/opencloudApp/src/main/java/eu/opencloud/android/presentation/documentsprovider/DocumentsStorageProvider.kt +++ b/opencloudApp/src/main/java/eu/opencloud/android/presentation/documentsprovider/DocumentsStorageProvider.kt @@ -61,6 +61,7 @@ import eu.opencloud.android.presentation.documentsprovider.cursors.FileCursor import eu.opencloud.android.presentation.documentsprovider.cursors.RootCursor import eu.opencloud.android.presentation.documentsprovider.cursors.SpaceCursor import eu.opencloud.android.presentation.settings.security.SettingsSecurityFragment.Companion.PREFERENCE_LOCK_ACCESS_FROM_DOCUMENT_PROVIDER +import eu.opencloud.android.presentation.settings.advanced.SettingsAdvancedFragment.Companion.PREFERENCE_PRETEND_LOCAL_STORAGE import eu.opencloud.android.usecases.synchronization.SynchronizeFileUseCase import eu.opencloud.android.usecases.transfers.downloads.DownloadFileUseCase import eu.opencloud.android.usecases.synchronization.SynchronizeFolderUseCase @@ -350,6 +351,10 @@ class DocumentsStorageProvider : DocumentsProvider() { // If access from document provider is not allowed, return empty cursor val preferences: SharedPreferencesProvider by inject() val lockAccessFromDocumentProvider = preferences.getBoolean(PREFERENCE_LOCK_ACCESS_FROM_DOCUMENT_PROVIDER, false) + + // Get if user selected to pretend local storage + val pretendLocal = preferences.getBoolean(PREFERENCE_PRETEND_LOCAL_STORAGE, false) + return if (lockAccessFromDocumentProvider && accounts.isNotEmpty()) { result.apply { addProtectedRoot(contextApp) } } else { @@ -362,7 +367,7 @@ class DocumentsStorageProvider : DocumentsProvider() { ) val spacesFeatureAllowedForAccount = AccountUtils.isSpacesFeatureAllowedForAccount(contextApp, account, capabilities) - result.addRoot(account, contextApp, spacesFeatureAllowedForAccount) + result.addRoot(account, contextApp, spacesFeatureAllowedForAccount, pretendLocal) } result } @@ -497,6 +502,34 @@ class DocumentsStorageProvider : DocumentsProvider() { } } + override fun isChildDocument(parentDocumentId: String, documentId: String): Boolean { + Timber.d("isChildDocument($parentDocumentId, $documentId)") + + // If they are the same, Android specs usually consider it a child/match + if (parentDocumentId == documentId) return true + + return try { + // Parse the child file + val childFile = getFileByIdOrException(documentId.toInt()) + val parentIdInt = parentDocumentId.toIntOrNull() + + if (parentIdInt != null) { + // The parent is a standard folder + val parentFile = getFileByIdOrException(parentIdInt) + + // Check if the child belongs to the same account and its path sits inside the parent's path + childFile.owner == parentFile.owner && childFile.remotePath.startsWith(parentFile.remotePath) + } else { + // The parentDocumentId is a string, meaning it's the account root (e.g., "user@server.com") + // Just verify the child file belongs to this account + childFile.owner == parentDocumentId + } + } catch (e: Exception) { + Timber.e(e, "Error evaluating isChildDocument for parent: $parentDocumentId, child: $documentId") + false + } + } + private fun checkUseCaseResult(result: UseCaseResult, folderToNotify: String) { if (!result.isSuccess) { Timber.e(result.getThrowableOrNull()!!) diff --git a/opencloudApp/src/main/java/eu/opencloud/android/presentation/documentsprovider/cursors/RootCursor.kt b/opencloudApp/src/main/java/eu/opencloud/android/presentation/documentsprovider/cursors/RootCursor.kt index fe45dbe041..c61e2fc758 100644 --- a/opencloudApp/src/main/java/eu/opencloud/android/presentation/documentsprovider/cursors/RootCursor.kt +++ b/opencloudApp/src/main/java/eu/opencloud/android/presentation/documentsprovider/cursors/RootCursor.kt @@ -32,18 +32,23 @@ import eu.opencloud.android.datamodel.FileDataStorageManager class RootCursor(projection: Array?) : MatrixCursor(projection ?: DEFAULT_ROOT_PROJECTION) { - fun addRoot(account: Account, context: Context, spacesAllowed: Boolean) { + fun addRoot(account: Account, context: Context, spacesAllowed: Boolean, pretendLocal: Boolean) { val manager = FileDataStorageManager(account) val mainDirId = if (spacesAllowed) { // To display the list of spaces for an account, we need to do this trick. // If the document id is not a number, we will know that it is the time to display the list of spaces for the account account.name } else { - // Root directory of the personal space or "Files" (old server) manager.getRootPersonalFolder()?.id } - val flags = Root.FLAG_SUPPORTS_SEARCH or Root.FLAG_SUPPORTS_CREATE + // Add FLAG_SUPPORTS_IS_CHILD to enable Folder selection + var flags = Root.FLAG_SUPPORTS_SEARCH or Root.FLAG_SUPPORTS_CREATE or Root.FLAG_SUPPORTS_IS_CHILD + + // Add FLAG_LOCAL_ONLY if the user enabled it + if (pretendLocal) { + flags = flags or Root.FLAG_LOCAL_ONLY + } newRow() .add(Root.COLUMN_ROOT_ID, account.name) @@ -72,8 +77,7 @@ class RootCursor(projection: Array?) : MatrixCursor(projection ?: DEFAUL Root.COLUMN_TITLE, Root.COLUMN_DOCUMENT_ID, Root.COLUMN_AVAILABLE_BYTES, - Root.COLUMN_SUMMARY, - Root.COLUMN_FLAGS + Root.COLUMN_SUMMARY ) } } diff --git a/opencloudApp/src/main/java/eu/opencloud/android/presentation/settings/advanced/SettingsAdvancedFragment.kt b/opencloudApp/src/main/java/eu/opencloud/android/presentation/settings/advanced/SettingsAdvancedFragment.kt index 410d698ab4..2eb044cbb7 100644 --- a/opencloudApp/src/main/java/eu/opencloud/android/presentation/settings/advanced/SettingsAdvancedFragment.kt +++ b/opencloudApp/src/main/java/eu/opencloud/android/presentation/settings/advanced/SettingsAdvancedFragment.kt @@ -23,11 +23,13 @@ package eu.opencloud.android.presentation.settings.advanced import android.os.Bundle import android.view.View +import androidx.preference.CheckBoxPreference import androidx.preference.ListPreference import androidx.preference.Preference import androidx.preference.PreferenceFragmentCompat import androidx.preference.SwitchPreferenceCompat import eu.opencloud.android.R +import eu.opencloud.android.presentation.documentsprovider.DocumentsProviderUtils.notifyDocumentsProviderRoots import org.koin.androidx.viewmodel.ext.android.viewModel class SettingsAdvancedFragment : PreferenceFragmentCompat() { @@ -37,11 +39,13 @@ class SettingsAdvancedFragment : PreferenceFragmentCompat() { private var prefShowHiddenFiles: SwitchPreferenceCompat? = null private var prefRemoveLocalFiles: ListPreference? = null + private var prefPretendLocal: CheckBoxPreference? = null override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) { setPreferencesFromResource(R.xml.settings_advanced, rootKey) prefShowHiddenFiles = findPreference(PREF_SHOW_HIDDEN_FILES) + prefPretendLocal = findPreference(PREFERENCE_PRETEND_LOCAL_STORAGE) prefRemoveLocalFiles = findPreference(PREFERENCE_REMOVE_LOCAL_FILES)?.apply { entries = listOf( getString(R.string.prefs_delete_local_files_entries_never), @@ -80,9 +84,15 @@ class SettingsAdvancedFragment : PreferenceFragmentCompat() { advancedViewModel.scheduleDeleteLocalFiles(newValue) true } + + prefPretendLocal?.setOnPreferenceChangeListener { _: Preference?, _: Any -> + notifyDocumentsProviderRoots(requireContext()) + true + } } companion object { const val PREF_SHOW_HIDDEN_FILES = "show_hidden_files" + const val PREFERENCE_PRETEND_LOCAL_STORAGE = "pretend_local_storage" } } diff --git a/opencloudApp/src/main/res/values-de/strings.xml b/opencloudApp/src/main/res/values-de/strings.xml index 7ffe960e1d..57d4bb9da3 100644 --- a/opencloudApp/src/main/res/values-de/strings.xml +++ b/opencloudApp/src/main/res/values-de/strings.xml @@ -57,6 +57,8 @@ Nach 30 Minuten Zugriff vom Dokumentanbieter sperren Sperren Sie den Zugriff anderer Apps auf die Dateien der Konten innerhalb der App über den nativen Android-Dateimanager. + Als lokaler Speicher ausgeben + Ermöglicht Drittanbieter-Apps, OpenCloud zu sehen, wenn diese strikt nach lokalen Dateien suchen. Dies umgeht standardmäßige Android-Einschränkungen, kann jedoch in einigen Apps zu Rucklern oder Aufhängern der Benutzeroberfläche führen, wenn Ihre Netzwerkverbindung langsam ist. Berührungen mit anderen sichtbaren Fenstern Ermöglicht Berührungen, wenn die Ansicht durch ein anderes sichtbares Fenster verdeckt ist. Aktivieren Sie diese Option, um Apps zur Lichtfilterung zu nutzen. Sind Sie sicher, dass Sie diese Funktion aktivieren möchten\? diff --git a/opencloudApp/src/main/res/values-es/strings.xml b/opencloudApp/src/main/res/values-es/strings.xml index 6066711142..7a246686d9 100644 --- a/opencloudApp/src/main/res/values-es/strings.xml +++ b/opencloudApp/src/main/res/values-es/strings.xml @@ -57,6 +57,8 @@ Después de 30 minutos Bloquear acceso desde administrador de archivos Bloquear el acceso de otras aplicaciones a los archivos de las cuentas a través del administrador de archivos de Android. + Simular almacenamiento local + Permite que las aplicaciones de terceros vean OpenCloud cuando soliciten estrictamente archivos locales. Esto elude las limitaciones estándar de Android, pero puede causar bloqueos en la interfaz de algunas aplicaciones si la conexión de red es lenta. Bloquear pulsaciones con aplicaciones superpuestas Permite interactuar con la aplicación aunque haya otras ventanas visibles por encima. Activa para usar aplicaciones de atenuación de pantalla. ¿Seguro que quieres activar esta función\? diff --git a/opencloudApp/src/main/res/values-fr/strings.xml b/opencloudApp/src/main/res/values-fr/strings.xml index c4b4881544..0891378e75 100644 --- a/opencloudApp/src/main/res/values-fr/strings.xml +++ b/opencloudApp/src/main/res/values-fr/strings.xml @@ -57,6 +57,8 @@ Après 30 minutes Verrouiller l’accès au gestionnaire de fichier Verrouiller l\'accès des autres applications aux fichiers d\'utilisateurs de l\'application via le navigateur de fichiers natif d\'Android. + Simuler un stockage local + Permet aux applications tierces de voir OpenCloud lorsqu\'elles demandent strictement des fichiers locaux. Cela contourne les limitations standard d\'Android, mais peut provoquer des blocages de l\'interface dans certaines applications si votre réseau est lent. Interactions avec d\'autres fenêtres visibles Autoriser les interactions quand une autre fenêtre visible se superpose à l\'affichage. Activer pour utiliser le fonctionnement des applications de filtre lumineux. Êtes-vous sûr de vouloir activer cette fonctionnalité \? diff --git a/opencloudApp/src/main/res/values-it/strings.xml b/opencloudApp/src/main/res/values-it/strings.xml index 2af9076f69..0c227ac67a 100644 --- a/opencloudApp/src/main/res/values-it/strings.xml +++ b/opencloudApp/src/main/res/values-it/strings.xml @@ -57,6 +57,8 @@ Dopo 30 minuti Blocca accesso dal provider documenti Impedisci ad altre app di accedere ai file degli account dell\'app tramite l\'esploratore file nativo di Android. + Simula archiviazione locale + Consente alle app di terze parti di vedere OpenCloud quando richiedono rigorosamente file locali. Questo aggira le limitazioni standard di Android, ma potrebbe causare blocchi dell\'interfaccia utente in alcune app se la rete è lenta. Tocchi con altre finestre visibili Consenti tocchi quando la vista è oscurata da un\'altra finestra visibile. Attiva per utilizzare app con filtro luminosità. Vuoi davvero abilitare questa funzione? diff --git a/opencloudApp/src/main/res/values-nl/strings.xml b/opencloudApp/src/main/res/values-nl/strings.xml index 3f5319e005..d5f1082b3e 100644 --- a/opencloudApp/src/main/res/values-nl/strings.xml +++ b/opencloudApp/src/main/res/values-nl/strings.xml @@ -57,6 +57,8 @@ Na 30 minuten Toegang blokkeren vanuit bestandsbeheer Blokkeer de toegang van andere apps tot de bestanden van accounts in de app via Android bestandsbeheer. + Lokale opslag simuleren + Hiermee kunnen externe apps OpenCloud zien wanneer ze specifiek om lokale bestanden vragen. Dit omzeilt standaard Android-beperkingen, maar kan bij een trage netwerkverbinding leiden tot vastlopers in de gebruikersinterface van sommige apps. Raakt andere zichtbare vensters Sta aanrakingen toe als de weergave door een ander zichtbaar venster wordt bedekt. Schakel dit in om apps voor lichtfiltering te kunnen gebruiken. Weet u zeker dat u deze functie wilt inschakelen\? diff --git a/opencloudApp/src/main/res/values-pl/strings.xml b/opencloudApp/src/main/res/values-pl/strings.xml index 16f1b63812..c5e0bc32db 100644 --- a/opencloudApp/src/main/res/values-pl/strings.xml +++ b/opencloudApp/src/main/res/values-pl/strings.xml @@ -57,6 +57,8 @@ Po 30 minutach Zablokuj dostęp dostawcy dokumentów Wymagaj kodu PIN + Symuluj pamięć lokalną + Pozwala aplikacjom innych firm widzieć OpenCloud, gdy ściśle wymagają plików lokalnych. Omija to standardowe ograniczenia Androida, ale może powodować zawieszanie się interfejsu w niektórych aplikacjach, jeśli połączenie sieciowe jest powolne. Zezwalaj na dotyk przy innych widocznych oknach Zezwalaj na interakcję przy nakładających się oknach Czy na pewno chcesz aktywować tę funkcję\? diff --git a/opencloudApp/src/main/res/values/strings.xml b/opencloudApp/src/main/res/values/strings.xml index 55c256587f..7ddb547903 100644 --- a/opencloudApp/src/main/res/values/strings.xml +++ b/opencloudApp/src/main/res/values/strings.xml @@ -57,6 +57,8 @@ After 30 minutes Lock access from document provider Lock access from other apps to the files of the accounts in the app via the Android native file explorer. + Pretend to be local storage + Allows third-party apps to see OpenCloud when they strictly request local files. This bypasses standard Android limitations but may cause UI lockups in some apps if your network is slow. Touches with other visible windows Allow touches when the view is obscured by another visible window. Enable it to use light filtering apps. diff --git a/opencloudApp/src/main/res/xml/settings_advanced.xml b/opencloudApp/src/main/res/xml/settings_advanced.xml index 97c6eacdaf..345ca97f9d 100644 --- a/opencloudApp/src/main/res/xml/settings_advanced.xml +++ b/opencloudApp/src/main/res/xml/settings_advanced.xml @@ -29,4 +29,11 @@ app:key="remove_local_files" app:negativeButtonText="" app:title="@string/prefs_delete_local_files" /> + + \ No newline at end of file From 2cdda93854cbd57dd4e6273424eab17410327d3b Mon Sep 17 00:00:00 2001 From: alvaroemtnez Date: Sun, 26 Jul 2026 19:13:07 +0200 Subject: [PATCH 2/2] Implemented file upload with ConcurrentHashMap and UUIDs to support multi-threaded file creation --- .../DocumentsStorageProvider.kt | 37 ++++++++++++++----- 1 file changed, 28 insertions(+), 9 deletions(-) diff --git a/opencloudApp/src/main/java/eu/opencloud/android/presentation/documentsprovider/DocumentsStorageProvider.kt b/opencloudApp/src/main/java/eu/opencloud/android/presentation/documentsprovider/DocumentsStorageProvider.kt index 09d43c2cd4..0001bb0444 100644 --- a/opencloudApp/src/main/java/eu/opencloud/android/presentation/documentsprovider/DocumentsStorageProvider.kt +++ b/opencloudApp/src/main/java/eu/opencloud/android/presentation/documentsprovider/DocumentsStorageProvider.kt @@ -95,6 +95,7 @@ class DocumentsStorageProvider : DocumentsProvider() { private var spacesSyncRequired = true private lateinit var fileToUpload: OCFile + private val pendingUploads = ConcurrentHashMap() // Cache to avoid redundant PROPFINDs when apps (e.g. Google Photos) call // openDocument many times for the same file. Two layers: @@ -116,7 +117,7 @@ class DocumentsStorageProvider : DocumentsProvider() { // If documentId == NONEXISTENT_DOCUMENT_ID only Upload is needed because file does not exist in our database yet. var ocFile: OCFile - val uploadOnly: Boolean = documentId == NONEXISTENT_DOCUMENT_ID || documentId == "null" + val uploadOnly: Boolean = documentId == NONEXISTENT_DOCUMENT_ID || documentId == "null" || documentId.startsWith("pending_") var accessMode: Int = ParcelFileDescriptor.parseMode(mode) val isWrite: Boolean = mode.contains("w") @@ -198,7 +199,7 @@ class DocumentsStorageProvider : DocumentsProvider() { } } } else { - ocFile = fileToUpload + ocFile = pendingUploads[documentId] ?: fileToUpload accessMode = accessMode or ParcelFileDescriptor.MODE_CREATE } @@ -215,6 +216,8 @@ class DocumentsStorageProvider : DocumentsProvider() { Timber.d("A file with id $documentId has been closed! Time to synchronize it with server.") // If only needs to upload that file if (uploadOnly) { + pendingUploads.remove(documentId) + ocFile.length = fileToOpen.length() val uploadFilesUseCase: UploadFilesFromSystemUseCase by inject() val uploadFilesUseCaseParams = UploadFilesFromSystemUseCase.Params( @@ -319,7 +322,13 @@ class DocumentsStorageProvider : DocumentsProvider() { override fun queryDocument(documentId: String, projection: Array?): Cursor { Timber.d("Query Document: $documentId") if (documentId == NONEXISTENT_DOCUMENT_ID) return FileCursor(projection).apply { - addFile(fileToUpload) + if (this@DocumentsStorageProvider::fileToUpload.isInitialized) addFile(fileToUpload) + } + + if (documentId.startsWith("pending_")) { + return FileCursor(projection).apply { + pendingUploads[documentId]?.let { addFile(it) } + } } val fileId = try { @@ -509,8 +518,15 @@ class DocumentsStorageProvider : DocumentsProvider() { if (parentDocumentId == documentId) return true return try { - // Parse the child file - val childFile = getFileByIdOrException(documentId.toInt()) + // Parse the child file. If it's a new un-uploaded file, pull from memory. Otherwise, query DB. + val childFile = if (documentId == NONEXISTENT_DOCUMENT_ID && this::fileToUpload.isInitialized) { + fileToUpload + } else if (documentId.startsWith("pending_")) { + pendingUploads[documentId] ?: return false + } else { + getFileByIdOrException(documentId.toInt()) + } + val parentIdInt = parentDocumentId.toIntOrNull() if (parentIdInt != null) { @@ -563,12 +579,13 @@ class DocumentsStorageProvider : DocumentsProvider() { mimeType: String, displayName: String, ): String { - // We just need to return a Document ID, so we'll return an empty one. File does not exist in our db yet. - // File will be created at [openDocument] method. val tempDir = File(FileStorageUtils.getTemporalPath(parentDocument.owner, parentDocument.spaceId)) val newFile = File(tempDir, displayName) newFile.parentFile?.mkdirs() - fileToUpload = OCFile( + + val pendingId = "pending_" + UUID.randomUUID().toString() + + val ocFile = OCFile( remotePath = parentDocument.remotePath + displayName, mimeType = mimeType, parentId = parentDocument.id, @@ -578,7 +595,9 @@ class DocumentsStorageProvider : DocumentsProvider() { storagePath = newFile.path } - return NONEXISTENT_DOCUMENT_ID + pendingUploads[pendingId] = ocFile + + return pendingId } /**