From 47e6c580743865b57d96736f199544f3c1f39a0c Mon Sep 17 00:00:00 2001 From: David Calhoun Date: Thu, 6 Nov 2025 13:05:55 -0500 Subject: [PATCH 01/12] feat: Android demo app handles file picker --- .../example/gutenbergkit/EditorActivity.kt | 49 ++++++++++++++++++- 1 file changed, 47 insertions(+), 2 deletions(-) diff --git a/android/app/src/main/java/com/example/gutenbergkit/EditorActivity.kt b/android/app/src/main/java/com/example/gutenbergkit/EditorActivity.kt index b7dadce5c..3cd6cbe28 100644 --- a/android/app/src/main/java/com/example/gutenbergkit/EditorActivity.kt +++ b/android/app/src/main/java/com/example/gutenbergkit/EditorActivity.kt @@ -1,5 +1,7 @@ package com.example.gutenbergkit +import android.content.Intent +import android.net.Uri import android.os.Bundle import android.webkit.WebView import android.content.pm.ApplicationInfo @@ -7,6 +9,8 @@ import androidx.activity.ComponentActivity import androidx.activity.compose.BackHandler import androidx.activity.compose.setContent import androidx.activity.enableEdgeToEdge +import androidx.activity.result.ActivityResultLauncher +import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.imePadding @@ -38,10 +42,39 @@ import org.wordpress.gutenberg.EditorConfiguration import org.wordpress.gutenberg.GutenbergView class EditorActivity : ComponentActivity() { + private var gutenbergView: GutenbergView? = null + private lateinit var filePickerLauncher: ActivityResultLauncher override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) enableEdgeToEdge() + // Register file picker launcher before setContent + filePickerLauncher = registerForActivityResult( + ActivityResultContracts.StartActivityForResult() + ) { result -> + val data = result.data + val uris = if (data != null) { + if (data.clipData != null) { + // Multiple files selected + val clipData = data.clipData!! + Array(clipData.itemCount) { i -> + clipData.getItemAt(i).uri + } + } else if (data.data != null) { + // Single file selected + arrayOf(data.data) + } else { + null + } + } else { + null + } + + // Pass the result back to the WebView + gutenbergView?.filePathCallback?.onReceiveValue(uris) + gutenbergView?.resetFilePathCallback() + } + if (0 != (applicationInfo.flags and ApplicationInfo.FLAG_DEBUGGABLE)) { WebView.setWebContentsDebuggingEnabled(true) } @@ -62,18 +95,29 @@ class EditorActivity : ComponentActivity() { AppTheme { EditorScreen( configuration = configuration, - onClose = { finish() } + onClose = { finish() }, + onGutenbergViewCreated = { view -> + gutenbergView = view + setupFileChooserListener(view) + } ) } } } + + private fun setupFileChooserListener(view: GutenbergView) { + view.setOnFileChooserRequestedListener { intent, _ -> + filePickerLauncher.launch(intent) + } + } } @OptIn(ExperimentalMaterial3Api::class) @Composable fun EditorScreen( configuration: EditorConfiguration, - onClose: () -> Unit + onClose: () -> Unit, + onGutenbergViewCreated: (GutenbergView) -> Unit = {} ) { var showMenu by remember { mutableStateOf(false) } var isModalDialogOpen by remember { mutableStateOf(false) } @@ -196,6 +240,7 @@ fun EditorScreen( } }) start(configuration) + onGutenbergViewCreated(this) } }, modifier = Modifier From fc36b4424c89ddbfb36873b3fa5dd358871f61fc Mon Sep 17 00:00:00 2001 From: David Calhoun Date: Mon, 10 Nov 2025 16:21:19 -0500 Subject: [PATCH 02/12] refactor: Remove unused imports --- .../app/src/main/java/com/example/gutenbergkit/MainActivity.kt | 2 -- 1 file changed, 2 deletions(-) diff --git a/android/app/src/main/java/com/example/gutenbergkit/MainActivity.kt b/android/app/src/main/java/com/example/gutenbergkit/MainActivity.kt index ab40035a6..2134a45d1 100644 --- a/android/app/src/main/java/com/example/gutenbergkit/MainActivity.kt +++ b/android/app/src/main/java/com/example/gutenbergkit/MainActivity.kt @@ -9,14 +9,12 @@ import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues -import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.material.icons.Icons From 372f470aaf6a6010982926840188ee3b5c05e95f Mon Sep 17 00:00:00 2001 From: David Calhoun Date: Tue, 11 Nov 2025 11:23:26 -0500 Subject: [PATCH 03/12] fix: Avoid `ERR_UPLOAD_FILE_CHANGED` by caching content provider files A Chrome bug results in `ERR_UPLOAD_FILE_CHANGED` errors when selecting a file from a cloud content provider (e.g., the Google Drive app). This does not disrupt all file uploads--e.g., images--but larger files (e.g., videos) and resumable uploads often fail. Caching the selected file ensures the file remains stable throughout the Chrome upload process. See: https://issues.chromium.org/issues/40123366 --- .../java/org/wordpress/gutenberg/FileCache.kt | 125 ++++++++++++++++++ .../org/wordpress/gutenberg/GutenbergView.kt | 3 + .../org/wordpress/gutenberg/FileCacheTest.kt | 80 +++++++++++ .../example/gutenbergkit/EditorActivity.kt | 52 +++++++- 4 files changed, 257 insertions(+), 3 deletions(-) create mode 100644 android/Gutenberg/src/main/java/org/wordpress/gutenberg/FileCache.kt create mode 100644 android/Gutenberg/src/test/java/org/wordpress/gutenberg/FileCacheTest.kt diff --git a/android/Gutenberg/src/main/java/org/wordpress/gutenberg/FileCache.kt b/android/Gutenberg/src/main/java/org/wordpress/gutenberg/FileCache.kt new file mode 100644 index 000000000..05c050fec --- /dev/null +++ b/android/Gutenberg/src/main/java/org/wordpress/gutenberg/FileCache.kt @@ -0,0 +1,125 @@ +package org.wordpress.gutenberg + +import android.content.Context +import android.net.Uri +import android.provider.OpenableColumns +import android.webkit.MimeTypeMap +import java.io.File +import java.io.FileOutputStream +import java.io.IOException + +/** + * Utility class for caching files from content providers to avoid ERR_UPLOAD_FILE_CHANGED errors + * in WebView when uploading files from cloud storage providers. + */ +object FileCache { + private const val CACHE_DIR_NAME = "gutenberg_file_uploads" + private const val BUFFER_SIZE = 8192 + + /** + * Copies a file from a content URI to the app's cache directory. + * + * This is necessary to work around Android WebView issues with uploading files from + * cloud storage providers (Google Drive, Dropbox, etc.) which can trigger + * ERR_UPLOAD_FILE_CHANGED errors due to streaming content or changing metadata. + * + * @param context Android context + * @param uri The content:// URI to copy + * @return URI of the cached file, or null if the copy failed + */ + fun copyToCache(context: Context, uri: Uri): Uri? { + val cacheDir = File(context.cacheDir, CACHE_DIR_NAME) + if (!cacheDir.exists()) { + cacheDir.mkdirs() + } + + val fileName = getFileName(context, uri) ?: "upload_${System.currentTimeMillis()}" + val extension = getFileExtension(context, uri) + val fileNameWithExtension = if (extension != null && !fileName.endsWith(".$extension")) { + "$fileName.$extension" + } else { + fileName + } + + // Create a unique file to avoid conflicts + val uniqueFileName = "${System.currentTimeMillis()}_$fileNameWithExtension" + val cachedFile = File(cacheDir, uniqueFileName) + + return try { + context.contentResolver.openInputStream(uri)?.use { input -> + FileOutputStream(cachedFile).use { output -> + val buffer = ByteArray(BUFFER_SIZE) + var bytesRead: Int + while (input.read(buffer).also { bytesRead = it } != -1) { + output.write(buffer, 0, bytesRead) + } + } + } + Uri.fromFile(cachedFile) + } catch (e: IOException) { + // Clean up partial file if copy failed + if (cachedFile.exists()) { + cachedFile.delete() + } + null + } + } + + /** + * Clears all cached files from previous sessions to prevent storage accumulation. + * + * @param context Android context + */ + fun clearCache(context: Context) { + val cacheDir = File(context.cacheDir, CACHE_DIR_NAME) + if (cacheDir.exists() && cacheDir.isDirectory) { + cacheDir.listFiles()?.forEach { file -> + file.delete() + } + } + } + + /** + * Retrieves the display name of a file from a content URI. + * + * @param context Android context + * @param uri The content URI + * @return The file name, or null if it cannot be determined + */ + private fun getFileName(context: Context, uri: Uri): String? { + var fileName: String? = null + context.contentResolver.query(uri, null, null, null, null)?.use { cursor -> + if (cursor.moveToFirst()) { + val nameIndex = cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME) + if (nameIndex != -1) { + fileName = cursor.getString(nameIndex) + } + } + } + return fileName + } + + /** + * Gets the file extension from a content URI by checking its MIME type. + * + * @param context Android context + * @param uri The content URI + * @return The file extension (without the dot), or null if it cannot be determined + */ + private fun getFileExtension(context: Context, uri: Uri): String? { + val mimeType = context.contentResolver.getType(uri) + return mimeType?.let { MimeTypeMap.getSingleton().getExtensionFromMimeType(it) } + } + + /** + * Checks if a MIME type represents a media file (image or video). + * + * @param context Android context + * @param uri The content URI + * @return true if the MIME type starts with "image/" or "video/" + */ + fun isMediaFile(context: Context, uri: Uri): Boolean { + val mimeType = context.contentResolver.getType(uri) ?: return false + return mimeType.startsWith("image/") || mimeType.startsWith("video/") + } +} diff --git a/android/Gutenberg/src/main/java/org/wordpress/gutenberg/GutenbergView.kt b/android/Gutenberg/src/main/java/org/wordpress/gutenberg/GutenbergView.kt index 14aa75f71..6da9c35a5 100644 --- a/android/Gutenberg/src/main/java/org/wordpress/gutenberg/GutenbergView.kt +++ b/android/Gutenberg/src/main/java/org/wordpress/gutenberg/GutenbergView.kt @@ -115,6 +115,9 @@ class GutenbergView : WebView { this.addJavascriptInterface(this, "editorDelegate") this.visibility = View.GONE + // Clear file upload cache from previous sessions + FileCache.clearCache(context) + this.webViewClient = object : WebViewClient() { override fun onReceivedError( view: WebView?, diff --git a/android/Gutenberg/src/test/java/org/wordpress/gutenberg/FileCacheTest.kt b/android/Gutenberg/src/test/java/org/wordpress/gutenberg/FileCacheTest.kt new file mode 100644 index 000000000..a6bca2d14 --- /dev/null +++ b/android/Gutenberg/src/test/java/org/wordpress/gutenberg/FileCacheTest.kt @@ -0,0 +1,80 @@ +package org.wordpress.gutenberg + +import android.content.Context +import android.net.Uri +import org.junit.After +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.RuntimeEnvironment +import org.robolectric.annotation.Config +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Assert.assertFalse +import java.io.File + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [28], manifest = Config.NONE) +class FileCacheTest { + private lateinit var context: Context + + @Before + fun setup() { + context = RuntimeEnvironment.getApplication() + + // Clean up cache before each test + FileCache.clearCache(context) + } + + @After + fun tearDown() { + // Clean up cache after each test + FileCache.clearCache(context) + } + + @Test + fun `clearCache removes all cached files`() { + // Given - create some test files in the cache directory + val cacheDir = File(context.cacheDir, "gutenberg_file_uploads") + cacheDir.mkdirs() + + val testFile1 = File(cacheDir, "test1.jpg") + val testFile2 = File(cacheDir, "test2.mp4") + testFile1.writeText("test content 1") + testFile2.writeText("test content 2") + + assertTrue("Test file 1 should exist", testFile1.exists()) + assertTrue("Test file 2 should exist", testFile2.exists()) + + // When + FileCache.clearCache(context) + + // Then + assertFalse("Test file 1 should be deleted", testFile1.exists()) + assertFalse("Test file 2 should be deleted", testFile2.exists()) + assertTrue("Cache directory should still exist", cacheDir.exists()) + } + + @Test + fun `clearCache handles non-existent cache directory`() { + // Given - ensure cache directory doesn't exist + val cacheDir = File(context.cacheDir, "gutenberg_file_uploads") + if (cacheDir.exists()) { + cacheDir.deleteRecursively() + } + + // When - should not throw an exception + FileCache.clearCache(context) + + // Then - no exception should be thrown + assertTrue("Test should complete without exception", true) + } + + // Note: Tests for copyToCache() and isMediaFile() require ContentResolver access + // which is not easily testable in unit tests. These methods should be tested + // in instrumented tests (androidTest) with real content providers. + // The core cache management functionality (clearCache) is tested above. +} diff --git a/android/app/src/main/java/com/example/gutenbergkit/EditorActivity.kt b/android/app/src/main/java/com/example/gutenbergkit/EditorActivity.kt index 3cd6cbe28..5aa91c299 100644 --- a/android/app/src/main/java/com/example/gutenbergkit/EditorActivity.kt +++ b/android/app/src/main/java/com/example/gutenbergkit/EditorActivity.kt @@ -3,6 +3,7 @@ package com.example.gutenbergkit import android.content.Intent import android.net.Uri import android.os.Bundle +import android.util.Log import android.webkit.WebView import android.content.pm.ApplicationInfo import androidx.activity.ComponentActivity @@ -37,8 +38,13 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource import androidx.compose.ui.viewinterop.AndroidView +import androidx.lifecycle.lifecycleScope import com.example.gutenbergkit.ui.theme.AppTheme +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext import org.wordpress.gutenberg.EditorConfiguration +import org.wordpress.gutenberg.FileCache import org.wordpress.gutenberg.GutenbergView class EditorActivity : ComponentActivity() { @@ -70,9 +76,13 @@ class EditorActivity : ComponentActivity() { null } - // Pass the result back to the WebView - gutenbergView?.filePathCallback?.onReceiveValue(uris) - gutenbergView?.resetFilePathCallback() + // Process URIs asynchronously to avoid blocking the main thread + lifecycleScope.launch { + val processedUris = processSelectedFiles(uris) + // Pass the result back to the WebView on the main thread + gutenbergView?.filePathCallback?.onReceiveValue(processedUris) + gutenbergView?.resetFilePathCallback() + } } if (0 != (applicationInfo.flags and ApplicationInfo.FLAG_DEBUGGABLE)) { @@ -110,6 +120,42 @@ class EditorActivity : ComponentActivity() { filePickerLauncher.launch(intent) } } + + /** + * Processes selected files by copying content:// URIs to cache to avoid + * ERR_UPLOAD_FILE_CHANGED errors when uploading from cloud storage providers. + * + * @param uris Array of selected file URIs + * @return Array of processed URIs (cached for content:// URIs, original for others) + */ + private suspend fun processSelectedFiles(uris: Array?): Array? { + if (uris == null) { + return null + } + + return withContext(Dispatchers.IO) { + uris.map { uri -> + if (uri == null) { + return@map null + } + + // Only process content:// URIs that are media files + if (uri.scheme == "content" && FileCache.isMediaFile(this@EditorActivity, uri)) { + val cachedUri = FileCache.copyToCache(this@EditorActivity, uri) + if (cachedUri != null) { + Log.i("EditorActivity", "Copied content URI to cache: $uri -> $cachedUri") + cachedUri + } else { + Log.w("EditorActivity", "Failed to copy content URI to cache, using original: $uri") + uri + } + } else { + // Pass through file:// URIs and non-media content:// URIs unchanged + uri + } + }.toTypedArray() + } + } } @OptIn(ExperimentalMaterial3Api::class) From 60735563dc37edb9560836c71f3fa5a46a90ec1c Mon Sep 17 00:00:00 2001 From: David Calhoun Date: Tue, 11 Nov 2025 12:09:37 -0500 Subject: [PATCH 04/12] feat: Disable Android upload file caching for known local providers Avoid unnecessary CPU and disk usage for providers that succeed without caching. --- .../java/org/wordpress/gutenberg/FileCache.kt | 27 +++++ .../org/wordpress/gutenberg/FileCacheTest.kt | 113 +++++++++++++++++- .../example/gutenbergkit/EditorActivity.kt | 25 ++-- 3 files changed, 154 insertions(+), 11 deletions(-) diff --git a/android/Gutenberg/src/main/java/org/wordpress/gutenberg/FileCache.kt b/android/Gutenberg/src/main/java/org/wordpress/gutenberg/FileCache.kt index 05c050fec..720313824 100644 --- a/android/Gutenberg/src/main/java/org/wordpress/gutenberg/FileCache.kt +++ b/android/Gutenberg/src/main/java/org/wordpress/gutenberg/FileCache.kt @@ -122,4 +122,31 @@ object FileCache { val mimeType = context.contentResolver.getType(uri) ?: return false return mimeType.startsWith("image/") || mimeType.startsWith("video/") } + + /** + * Checks if a URI comes from a known-safe local content provider. + * + * These providers serve local files that won't change during upload, so copying + * them to cache is unnecessary. This allow list includes only Android's built-in + * local content providers. + * + * @param uri The content URI to check + * @return true if the URI is from a known-safe local provider + */ + fun isKnownSafeLocalProvider(uri: Uri): Boolean { + val authority = uri.authority ?: return false + + // Android's MediaStore (photos, videos, audio from device) + if (authority.startsWith("com.android.providers.media")) { + return true + } + + // Android's Downloads provider + if (authority.startsWith("com.android.providers.downloads")) { + return true + } + + // All other providers (including cloud providers) are not on the allow list + return false + } } diff --git a/android/Gutenberg/src/test/java/org/wordpress/gutenberg/FileCacheTest.kt b/android/Gutenberg/src/test/java/org/wordpress/gutenberg/FileCacheTest.kt index a6bca2d14..28a373cef 100644 --- a/android/Gutenberg/src/test/java/org/wordpress/gutenberg/FileCacheTest.kt +++ b/android/Gutenberg/src/test/java/org/wordpress/gutenberg/FileCacheTest.kt @@ -73,8 +73,113 @@ class FileCacheTest { assertTrue("Test should complete without exception", true) } - // Note: Tests for copyToCache() and isMediaFile() require ContentResolver access - // which is not easily testable in unit tests. These methods should be tested - // in instrumented tests (androidTest) with real content providers. - // The core cache management functionality (clearCache) is tested above. + // Tests for isKnownSafeLocalProvider() - Allow List + + @Test + fun `isKnownSafeLocalProvider returns true for MediaStore images`() { + // Given + val mediaStoreUri = Uri.parse("content://com.android.providers.media.documents/document/image:12345") + + // When + val result = FileCache.isKnownSafeLocalProvider(mediaStoreUri) + + // Then + assertTrue("MediaStore images should be recognized as safe local provider", result) + } + + @Test + fun `isKnownSafeLocalProvider returns true for MediaStore videos`() { + // Given + val mediaStoreUri = Uri.parse("content://com.android.providers.media/external/video/media/456") + + // When + val result = FileCache.isKnownSafeLocalProvider(mediaStoreUri) + + // Then + assertTrue("MediaStore videos should be recognized as safe local provider", result) + } + + @Test + fun `isKnownSafeLocalProvider returns true for Downloads provider`() { + // Given + val downloadsUri = Uri.parse("content://com.android.providers.downloads.documents/document/123") + + // When + val result = FileCache.isKnownSafeLocalProvider(downloadsUri) + + // Then + assertTrue("Downloads provider should be recognized as safe local provider", result) + } + + @Test + fun `isKnownSafeLocalProvider returns false for Google Drive`() { + // Given + val driveUri = Uri.parse("content://com.google.android.apps.docs.storage/document/acc=1;doc=12345") + + // When + val result = FileCache.isKnownSafeLocalProvider(driveUri) + + // Then + assertFalse("Google Drive should NOT be on the allow list", result) + } + + @Test + fun `isKnownSafeLocalProvider returns false for OneDrive`() { + // Given + val oneDriveUri = Uri.parse("content://com.microsoft.skydrive.documents/document/primary:path/to/file") + + // When + val result = FileCache.isKnownSafeLocalProvider(oneDriveUri) + + // Then + assertFalse("OneDrive should NOT be on the allow list", result) + } + + @Test + fun `isKnownSafeLocalProvider returns false for unknown cloud provider`() { + // Given + val unknownCloudUri = Uri.parse("content://com.example.cloudstorage/document/file123") + + // When + val result = FileCache.isKnownSafeLocalProvider(unknownCloudUri) + + // Then + assertFalse("Unknown cloud providers should NOT be on the allow list", result) + } + + @Test + fun `isKnownSafeLocalProvider returns false for file URIs`() { + // Given + val fileUri = Uri.parse("file:///storage/emulated/0/Pictures/photo.jpg") + + // When + val result = FileCache.isKnownSafeLocalProvider(fileUri) + + // Then + assertFalse("File URIs should return false (not a content provider)", result) + } + + @Test + fun `isKnownSafeLocalProvider returns false for null authority`() { + // Given + val malformedUri = Uri.parse("content://") + + // When + val result = FileCache.isKnownSafeLocalProvider(malformedUri) + + // Then + assertFalse("URIs with null authority should return false", result) + } + + @Test + fun `isKnownSafeLocalProvider returns false for other Android providers`() { + // Given - Android's contacts provider is a local provider but NOT on our allow list + val contactsUri = Uri.parse("content://com.android.contacts/data/123") + + // When + val result = FileCache.isKnownSafeLocalProvider(contactsUri) + + // Then + assertFalse("Other Android providers not on allow list should return false", result) + } } diff --git a/android/app/src/main/java/com/example/gutenbergkit/EditorActivity.kt b/android/app/src/main/java/com/example/gutenbergkit/EditorActivity.kt index 5aa91c299..5a8269361 100644 --- a/android/app/src/main/java/com/example/gutenbergkit/EditorActivity.kt +++ b/android/app/src/main/java/com/example/gutenbergkit/EditorActivity.kt @@ -125,8 +125,12 @@ class EditorActivity : ComponentActivity() { * Processes selected files by copying content:// URIs to cache to avoid * ERR_UPLOAD_FILE_CHANGED errors when uploading from cloud storage providers. * + * Files from known-safe local providers (MediaStore, Downloads) are not copied + * to avoid unnecessary overhead. All other content providers (cloud or unknown) + * are copied to ensure reliable uploads. + * * @param uris Array of selected file URIs - * @return Array of processed URIs (cached for content:// URIs, original for others) + * @return Array of processed URIs (cached for cloud URIs, original for local/file URIs) */ private suspend fun processSelectedFiles(uris: Array?): Array? { if (uris == null) { @@ -141,13 +145,20 @@ class EditorActivity : ComponentActivity() { // Only process content:// URIs that are media files if (uri.scheme == "content" && FileCache.isMediaFile(this@EditorActivity, uri)) { - val cachedUri = FileCache.copyToCache(this@EditorActivity, uri) - if (cachedUri != null) { - Log.i("EditorActivity", "Copied content URI to cache: $uri -> $cachedUri") - cachedUri - } else { - Log.w("EditorActivity", "Failed to copy content URI to cache, using original: $uri") + // Skip copying from known-safe local providers (MediaStore, Downloads) + if (FileCache.isKnownSafeLocalProvider(uri)) { + Log.i("EditorActivity", "Using local provider URI directly: $uri") uri + } else { + // Copy from cloud providers or unknown providers to avoid upload errors + val cachedUri = FileCache.copyToCache(this@EditorActivity, uri) + if (cachedUri != null) { + Log.i("EditorActivity", "Copied content URI to cache: $uri -> $cachedUri") + cachedUri + } else { + Log.w("EditorActivity", "Failed to copy content URI to cache, using original: $uri") + uri + } } } else { // Pass through file:// URIs and non-media content:// URIs unchanged From ab4c6a990a7339dd19c027571c0dda9076d548b1 Mon Sep 17 00:00:00 2001 From: David Calhoun Date: Tue, 11 Nov 2025 12:25:40 -0500 Subject: [PATCH 05/12] feat: Extend caching to all file types The editor uploads numerous file types. --- .../main/java/org/wordpress/gutenberg/FileCache.kt | 12 ------------ .../java/com/example/gutenbergkit/EditorActivity.kt | 6 +++--- 2 files changed, 3 insertions(+), 15 deletions(-) diff --git a/android/Gutenberg/src/main/java/org/wordpress/gutenberg/FileCache.kt b/android/Gutenberg/src/main/java/org/wordpress/gutenberg/FileCache.kt index 720313824..8b64ee9eb 100644 --- a/android/Gutenberg/src/main/java/org/wordpress/gutenberg/FileCache.kt +++ b/android/Gutenberg/src/main/java/org/wordpress/gutenberg/FileCache.kt @@ -111,18 +111,6 @@ object FileCache { return mimeType?.let { MimeTypeMap.getSingleton().getExtensionFromMimeType(it) } } - /** - * Checks if a MIME type represents a media file (image or video). - * - * @param context Android context - * @param uri The content URI - * @return true if the MIME type starts with "image/" or "video/" - */ - fun isMediaFile(context: Context, uri: Uri): Boolean { - val mimeType = context.contentResolver.getType(uri) ?: return false - return mimeType.startsWith("image/") || mimeType.startsWith("video/") - } - /** * Checks if a URI comes from a known-safe local content provider. * diff --git a/android/app/src/main/java/com/example/gutenbergkit/EditorActivity.kt b/android/app/src/main/java/com/example/gutenbergkit/EditorActivity.kt index 5a8269361..e9a078394 100644 --- a/android/app/src/main/java/com/example/gutenbergkit/EditorActivity.kt +++ b/android/app/src/main/java/com/example/gutenbergkit/EditorActivity.kt @@ -143,8 +143,8 @@ class EditorActivity : ComponentActivity() { return@map null } - // Only process content:// URIs that are media files - if (uri.scheme == "content" && FileCache.isMediaFile(this@EditorActivity, uri)) { + // Only process content:// URIs + if (uri.scheme == "content") { // Skip copying from known-safe local providers (MediaStore, Downloads) if (FileCache.isKnownSafeLocalProvider(uri)) { Log.i("EditorActivity", "Using local provider URI directly: $uri") @@ -161,7 +161,7 @@ class EditorActivity : ComponentActivity() { } } } else { - // Pass through file:// URIs and non-media content:// URIs unchanged + // Pass through file:// URIs unchanged uri } }.toTypedArray() From f97d40355d1b8d0e1269ecfa8f2e1a2d0ff6681c Mon Sep 17 00:00:00 2001 From: David Calhoun Date: Tue, 11 Nov 2025 12:34:16 -0500 Subject: [PATCH 06/12] refactor: Remove unused imports --- .../src/test/java/org/wordpress/gutenberg/FileCacheTest.kt | 3 --- 1 file changed, 3 deletions(-) diff --git a/android/Gutenberg/src/test/java/org/wordpress/gutenberg/FileCacheTest.kt b/android/Gutenberg/src/test/java/org/wordpress/gutenberg/FileCacheTest.kt index 28a373cef..1296e2112 100644 --- a/android/Gutenberg/src/test/java/org/wordpress/gutenberg/FileCacheTest.kt +++ b/android/Gutenberg/src/test/java/org/wordpress/gutenberg/FileCacheTest.kt @@ -9,9 +9,6 @@ import org.junit.runner.RunWith import org.robolectric.RobolectricTestRunner import org.robolectric.RuntimeEnvironment import org.robolectric.annotation.Config -import org.junit.Assert.assertEquals -import org.junit.Assert.assertNotNull -import org.junit.Assert.assertNull import org.junit.Assert.assertTrue import org.junit.Assert.assertFalse import java.io.File From 33411d6fd19ad5dcbac1879690b26785c2dc258b Mon Sep 17 00:00:00 2001 From: David Calhoun Date: Tue, 11 Nov 2025 12:36:56 -0500 Subject: [PATCH 07/12] refactor: Add FileCache logs --- .../src/main/java/org/wordpress/gutenberg/FileCache.kt | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/android/Gutenberg/src/main/java/org/wordpress/gutenberg/FileCache.kt b/android/Gutenberg/src/main/java/org/wordpress/gutenberg/FileCache.kt index 8b64ee9eb..f4c350bab 100644 --- a/android/Gutenberg/src/main/java/org/wordpress/gutenberg/FileCache.kt +++ b/android/Gutenberg/src/main/java/org/wordpress/gutenberg/FileCache.kt @@ -3,6 +3,7 @@ package org.wordpress.gutenberg import android.content.Context import android.net.Uri import android.provider.OpenableColumns +import android.util.Log import android.webkit.MimeTypeMap import java.io.File import java.io.FileOutputStream @@ -13,6 +14,7 @@ import java.io.IOException * in WebView when uploading files from cloud storage providers. */ object FileCache { + private const val TAG = "FileCache" private const val CACHE_DIR_NAME = "gutenberg_file_uploads" private const val BUFFER_SIZE = 8192 @@ -35,6 +37,7 @@ object FileCache { val fileName = getFileName(context, uri) ?: "upload_${System.currentTimeMillis()}" val extension = getFileExtension(context, uri) + val mimeType = context.contentResolver.getType(uri) val fileNameWithExtension = if (extension != null && !fileName.endsWith(".$extension")) { "$fileName.$extension" } else { @@ -45,18 +48,24 @@ object FileCache { val uniqueFileName = "${System.currentTimeMillis()}_$fileNameWithExtension" val cachedFile = File(cacheDir, uniqueFileName) + Log.d(TAG, "Attempting to cache file: uri=$uri, fileName=$fileName, mimeType=$mimeType, destination=$cachedFile") + return try { + var totalBytesRead = 0L context.contentResolver.openInputStream(uri)?.use { input -> FileOutputStream(cachedFile).use { output -> val buffer = ByteArray(BUFFER_SIZE) var bytesRead: Int while (input.read(buffer).also { bytesRead = it } != -1) { output.write(buffer, 0, bytesRead) + totalBytesRead += bytesRead } } } + Log.d(TAG, "Successfully cached file: uri=$uri, cachedFile=$cachedFile, size=$totalBytesRead bytes") Uri.fromFile(cachedFile) } catch (e: IOException) { + Log.e(TAG, "Failed to copy file to cache: uri=$uri, error=${e.message}", e) // Clean up partial file if copy failed if (cachedFile.exists()) { cachedFile.delete() From 3b4a90b507e8e8e0ab0caa5d099f8543dd039bed Mon Sep 17 00:00:00 2001 From: David Calhoun Date: Tue, 11 Nov 2025 12:48:25 -0500 Subject: [PATCH 08/12] feat: Limit caching by file size Avoid OOM from attempts to cache large files. --- .../java/org/wordpress/gutenberg/FileCache.kt | 54 ++++++++++++++++++- 1 file changed, 52 insertions(+), 2 deletions(-) diff --git a/android/Gutenberg/src/main/java/org/wordpress/gutenberg/FileCache.kt b/android/Gutenberg/src/main/java/org/wordpress/gutenberg/FileCache.kt index f4c350bab..efc2aa590 100644 --- a/android/Gutenberg/src/main/java/org/wordpress/gutenberg/FileCache.kt +++ b/android/Gutenberg/src/main/java/org/wordpress/gutenberg/FileCache.kt @@ -17,6 +17,7 @@ object FileCache { private const val TAG = "FileCache" private const val CACHE_DIR_NAME = "gutenberg_file_uploads" private const val BUFFER_SIZE = 8192 + const val DEFAULT_MAX_FILE_SIZE = 100 * 1024 * 1024L // 100MB in bytes /** * Copies a file from a content URI to the app's cache directory. @@ -27,9 +28,25 @@ object FileCache { * * @param context Android context * @param uri The content:// URI to copy - * @return URI of the cached file, or null if the copy failed + * @param maxSizeBytes Maximum file size in bytes (default: 100MB) + * @return URI of the cached file, or null if the copy failed or file exceeds size limit */ - fun copyToCache(context: Context, uri: Uri): Uri? { + fun copyToCache(context: Context, uri: Uri, maxSizeBytes: Long = DEFAULT_MAX_FILE_SIZE): Uri? { + // Check file size before attempting to copy + val fileSize = getFileSize(context, uri) + if (fileSize != null && fileSize > maxSizeBytes) { + val fileSizeMB = fileSize / (1024 * 1024) + val maxSizeMB = maxSizeBytes / (1024 * 1024) + Log.w(TAG, "File exceeds maximum size limit: uri=$uri, size=${fileSizeMB}MB, limit=${maxSizeMB}MB") + return null + } + + if (fileSize != null) { + Log.d(TAG, "File size check passed: uri=$uri, size=${fileSize / (1024 * 1024)}MB") + } else { + Log.w(TAG, "Unable to determine file size, proceeding with copy attempt: uri=$uri") + } + val cacheDir = File(context.cacheDir, CACHE_DIR_NAME) if (!cacheDir.exists()) { cacheDir.mkdirs() @@ -88,6 +105,39 @@ object FileCache { } } + /** + * Gets the file size from a content URI. + * + * Queries the content provider for the file size using OpenableColumns.SIZE. + * Some content providers may not provide size information, in which case this + * returns null. + * + * @param context Android context + * @param uri The content URI + * @return File size in bytes, or null if size cannot be determined + */ + private fun getFileSize(context: Context, uri: Uri): Long? { + return try { + context.contentResolver.query(uri, null, null, null, null)?.use { cursor -> + if (cursor.moveToFirst()) { + val sizeIndex = cursor.getColumnIndex(OpenableColumns.SIZE) + if (sizeIndex != -1) { + val size = cursor.getLong(sizeIndex) + // Some providers return -1 or 0 when size is unknown + if (size > 0) size else null + } else { + null + } + } else { + null + } + } + } catch (e: Exception) { + Log.w(TAG, "Failed to query file size for uri: $uri, error=${e.message}", e) + null + } + } + /** * Retrieves the display name of a file from a content URI. * From e497dca6b9e5fc5b886a96d545307109ec3b0f62 Mon Sep 17 00:00:00 2001 From: David Calhoun Date: Tue, 11 Nov 2025 12:54:44 -0500 Subject: [PATCH 09/12] fix: Avoid stale file picker callback errors --- .../src/main/java/org/wordpress/gutenberg/GutenbergView.kt | 3 +++ 1 file changed, 3 insertions(+) diff --git a/android/Gutenberg/src/main/java/org/wordpress/gutenberg/GutenbergView.kt b/android/Gutenberg/src/main/java/org/wordpress/gutenberg/GutenbergView.kt index 6da9c35a5..04d5c2f4f 100644 --- a/android/Gutenberg/src/main/java/org/wordpress/gutenberg/GutenbergView.kt +++ b/android/Gutenberg/src/main/java/org/wordpress/gutenberg/GutenbergView.kt @@ -218,6 +218,9 @@ class GutenbergView : WebView { newFilePathCallback: ValueCallback?>?, fileChooserParams: FileChooserParams? ): Boolean { + // Cancel any existing callback to prevent WebView state corruption + filePathCallback?.onReceiveValue(null) + filePathCallback = newFilePathCallback val allowMultiple = fileChooserParams?.mode == FileChooserParams.MODE_OPEN_MULTIPLE // Only use `acceptTypes` if it is not merely an empty string From 3d2fb27eab00576e9ce8292c976d63d2c875442e Mon Sep 17 00:00:00 2001 From: David Calhoun Date: Tue, 11 Nov 2025 13:45:24 -0500 Subject: [PATCH 10/12] refactor: Relocate cache clearing to `onDestroy` Avoid stale files after closing the editor. --- .../src/main/java/org/wordpress/gutenberg/GutenbergView.kt | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/android/Gutenberg/src/main/java/org/wordpress/gutenberg/GutenbergView.kt b/android/Gutenberg/src/main/java/org/wordpress/gutenberg/GutenbergView.kt index 04d5c2f4f..4ed35ab53 100644 --- a/android/Gutenberg/src/main/java/org/wordpress/gutenberg/GutenbergView.kt +++ b/android/Gutenberg/src/main/java/org/wordpress/gutenberg/GutenbergView.kt @@ -115,9 +115,6 @@ class GutenbergView : WebView { this.addJavascriptInterface(this, "editorDelegate") this.visibility = View.GONE - // Clear file upload cache from previous sessions - FileCache.clearCache(context) - this.webViewClient = object : WebViewClient() { override fun onReceivedError( view: WebView?, @@ -601,6 +598,7 @@ class GutenbergView : WebView { clearConfig() this.stopLoading() (requestInterceptor as? CachedAssetRequestInterceptor)?.shutdown() + FileCache.clearCache(context) contentChangeListener = null historyChangeListener = null featuredImageChangeListener = null From 48364ac5b32dd1d49f2aee441f07381af82aa0ce Mon Sep 17 00:00:00 2001 From: David Calhoun Date: Tue, 11 Nov 2025 14:33:29 -0500 Subject: [PATCH 11/12] refactor: Encapsulate file picker logic Simplify the API for uploading selected files in GutenbergKit. --- .../java/org/wordpress/gutenberg/FileCache.kt | 9 ++- .../org/wordpress/gutenberg/GutenbergView.kt | 58 ++++++++++++++ .../example/gutenbergkit/EditorActivity.kt | 76 +------------------ 3 files changed, 65 insertions(+), 78 deletions(-) diff --git a/android/Gutenberg/src/main/java/org/wordpress/gutenberg/FileCache.kt b/android/Gutenberg/src/main/java/org/wordpress/gutenberg/FileCache.kt index efc2aa590..e9cdafd5f 100644 --- a/android/Gutenberg/src/main/java/org/wordpress/gutenberg/FileCache.kt +++ b/android/Gutenberg/src/main/java/org/wordpress/gutenberg/FileCache.kt @@ -10,10 +10,13 @@ import java.io.FileOutputStream import java.io.IOException /** - * Utility class for caching files from content providers to avoid ERR_UPLOAD_FILE_CHANGED errors - * in WebView when uploading files from cloud storage providers. + * Internal utility class for caching files from content providers to avoid ERR_UPLOAD_FILE_CHANGED + * errors in WebView when uploading files from cloud storage providers. + * + * This is an internal implementation detail of GutenbergView and should not be used directly by apps. + * Apps should use GutenbergView.handleFilePickerResult() instead. */ -object FileCache { +internal object FileCache { private const val TAG = "FileCache" private const val CACHE_DIR_NAME = "gutenberg_file_uploads" private const val BUFFER_SIZE = 8192 diff --git a/android/Gutenberg/src/main/java/org/wordpress/gutenberg/GutenbergView.kt b/android/Gutenberg/src/main/java/org/wordpress/gutenberg/GutenbergView.kt index 4ed35ab53..cc2d78174 100644 --- a/android/Gutenberg/src/main/java/org/wordpress/gutenberg/GutenbergView.kt +++ b/android/Gutenberg/src/main/java/org/wordpress/gutenberg/GutenbergView.kt @@ -24,6 +24,8 @@ import android.webkit.WebView import android.webkit.WebViewClient import androidx.webkit.WebViewAssetLoader import androidx.webkit.WebViewAssetLoader.AssetsPathHandler +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext import org.json.JSONException import org.json.JSONObject import java.util.Locale @@ -593,6 +595,62 @@ class GutenbergView : WebView { filePathCallback = null } + /** + * Handles file picker result by processing URIs and completing the file selection. + * Implements workaround for Chrome ERR_UPLOAD_FILE_CHANGED bug by caching files + * from cloud providers. + * + * @param context Android context for file operations + * @param data Intent data from file picker result + */ + suspend fun handleFilePickerResult(context: Context, data: Intent?) { + val uris = extractUrisFromIntent(data) + val processedUris = processFileSelection(context, uris) + withContext(Dispatchers.Main) { + filePathCallback?.onReceiveValue(processedUris) + resetFilePathCallback() + } + } + + private fun extractUrisFromIntent(data: Intent?): Array? { + return if (data != null) { + if (data.clipData != null) { + val clipData = data.clipData!! + Array(clipData.itemCount) { i -> clipData.getItemAt(i).uri } + } else if (data.data != null) { + arrayOf(data.data) + } else null + } else null + } + + private suspend fun processFileSelection(context: Context, uris: Array?): Array? { + if (uris == null) return null + + return withContext(Dispatchers.IO) { + uris.map { uri -> + if (uri == null) return@map null + + if (uri.scheme == "content") { + if (FileCache.isKnownSafeLocalProvider(uri)) { + Log.i("GutenbergView", "Using local provider URI directly: $uri") + uri + } else { + val cachedUri = FileCache.copyToCache(context, uri) + if (cachedUri != null) { + Log.i("GutenbergView", "Copied content URI to cache: $uri -> $cachedUri") + cachedUri + } else { + Log.w("GutenbergView", "Failed to copy content URI to cache, using original: $uri") + uri + } + } + } else { + uri + } + }.toTypedArray() + } + } + override fun onDetachedFromWindow() { super.onDetachedFromWindow() clearConfig() diff --git a/android/app/src/main/java/com/example/gutenbergkit/EditorActivity.kt b/android/app/src/main/java/com/example/gutenbergkit/EditorActivity.kt index e9a078394..b3d8afc70 100644 --- a/android/app/src/main/java/com/example/gutenbergkit/EditorActivity.kt +++ b/android/app/src/main/java/com/example/gutenbergkit/EditorActivity.kt @@ -1,9 +1,7 @@ package com.example.gutenbergkit import android.content.Intent -import android.net.Uri import android.os.Bundle -import android.util.Log import android.webkit.WebView import android.content.pm.ApplicationInfo import androidx.activity.ComponentActivity @@ -40,11 +38,8 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.viewinterop.AndroidView import androidx.lifecycle.lifecycleScope import com.example.gutenbergkit.ui.theme.AppTheme -import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext import org.wordpress.gutenberg.EditorConfiguration -import org.wordpress.gutenberg.FileCache import org.wordpress.gutenberg.GutenbergView class EditorActivity : ComponentActivity() { @@ -58,30 +53,8 @@ class EditorActivity : ComponentActivity() { filePickerLauncher = registerForActivityResult( ActivityResultContracts.StartActivityForResult() ) { result -> - val data = result.data - val uris = if (data != null) { - if (data.clipData != null) { - // Multiple files selected - val clipData = data.clipData!! - Array(clipData.itemCount) { i -> - clipData.getItemAt(i).uri - } - } else if (data.data != null) { - // Single file selected - arrayOf(data.data) - } else { - null - } - } else { - null - } - - // Process URIs asynchronously to avoid blocking the main thread lifecycleScope.launch { - val processedUris = processSelectedFiles(uris) - // Pass the result back to the WebView on the main thread - gutenbergView?.filePathCallback?.onReceiveValue(processedUris) - gutenbergView?.resetFilePathCallback() + gutenbergView?.handleFilePickerResult(this@EditorActivity, result.data) } } @@ -120,53 +93,6 @@ class EditorActivity : ComponentActivity() { filePickerLauncher.launch(intent) } } - - /** - * Processes selected files by copying content:// URIs to cache to avoid - * ERR_UPLOAD_FILE_CHANGED errors when uploading from cloud storage providers. - * - * Files from known-safe local providers (MediaStore, Downloads) are not copied - * to avoid unnecessary overhead. All other content providers (cloud or unknown) - * are copied to ensure reliable uploads. - * - * @param uris Array of selected file URIs - * @return Array of processed URIs (cached for cloud URIs, original for local/file URIs) - */ - private suspend fun processSelectedFiles(uris: Array?): Array? { - if (uris == null) { - return null - } - - return withContext(Dispatchers.IO) { - uris.map { uri -> - if (uri == null) { - return@map null - } - - // Only process content:// URIs - if (uri.scheme == "content") { - // Skip copying from known-safe local providers (MediaStore, Downloads) - if (FileCache.isKnownSafeLocalProvider(uri)) { - Log.i("EditorActivity", "Using local provider URI directly: $uri") - uri - } else { - // Copy from cloud providers or unknown providers to avoid upload errors - val cachedUri = FileCache.copyToCache(this@EditorActivity, uri) - if (cachedUri != null) { - Log.i("EditorActivity", "Copied content URI to cache: $uri -> $cachedUri") - cachedUri - } else { - Log.w("EditorActivity", "Failed to copy content URI to cache, using original: $uri") - uri - } - } - } else { - // Pass through file:// URIs unchanged - uri - } - }.toTypedArray() - } - } } @OptIn(ExperimentalMaterial3Api::class) From 0ee5e3e85f9cb7fb0a1259865323f89ae21d8a9c Mon Sep 17 00:00:00 2001 From: David Calhoun Date: Tue, 11 Nov 2025 14:58:56 -0500 Subject: [PATCH 12/12] refactor: Redesign API for more flexibility Enable host apps to act on extracted URIs as before. --- .../org/wordpress/gutenberg/GutenbergView.kt | 37 +++++++++++-------- .../example/gutenbergkit/EditorActivity.kt | 5 ++- 2 files changed, 26 insertions(+), 16 deletions(-) diff --git a/android/Gutenberg/src/main/java/org/wordpress/gutenberg/GutenbergView.kt b/android/Gutenberg/src/main/java/org/wordpress/gutenberg/GutenbergView.kt index cc2d78174..7967f52a6 100644 --- a/android/Gutenberg/src/main/java/org/wordpress/gutenberg/GutenbergView.kt +++ b/android/Gutenberg/src/main/java/org/wordpress/gutenberg/GutenbergView.kt @@ -596,23 +596,16 @@ class GutenbergView : WebView { } /** - * Handles file picker result by processing URIs and completing the file selection. - * Implements workaround for Chrome ERR_UPLOAD_FILE_CHANGED bug by caching files - * from cloud providers. + * Extracts file URIs from a file picker Intent result. + * + * Handles both single file selection (Intent.data) and multiple file selection + * (Intent.clipData). This is a utility method for processing ActivityResult data + * from file picker requests. * - * @param context Android context for file operations * @param data Intent data from file picker result + * @return Array of selected URIs, or null if no files were selected */ - suspend fun handleFilePickerResult(context: Context, data: Intent?) { - val uris = extractUrisFromIntent(data) - val processedUris = processFileSelection(context, uris) - withContext(Dispatchers.Main) { - filePathCallback?.onReceiveValue(processedUris) - resetFilePathCallback() - } - } - - private fun extractUrisFromIntent(data: Intent?): Array? { + fun extractUrisFromIntent(data: Intent?): Array? { return if (data != null) { if (data.clipData != null) { val clipData = data.clipData!! @@ -623,7 +616,21 @@ class GutenbergView : WebView { } else null } - private suspend fun processFileSelection(context: Context, uris: Array?): Array? { + /** + * Processes file URIs to work around Chrome ERR_UPLOAD_FILE_CHANGED bug. + * + * This method caches files from cloud storage providers (Google Drive, OneDrive, etc.) + * to local storage to prevent upload failures. Files from known-safe local providers + * (MediaStore, Downloads) are passed through unchanged for optimal performance. + * + * Apps should call this method with URIs from the file picker, then pass the result + * to filePathCallback.onReceiveValue() to complete the file selection. + * + * @param context Android context for file operations + * @param uris Array of URIs from file picker + * @return Array of processed URIs (cached for cloud URIs, original for local URIs) + */ + suspend fun processFileUris(context: Context, uris: Array?): Array? { if (uris == null) return null return withContext(Dispatchers.IO) { diff --git a/android/app/src/main/java/com/example/gutenbergkit/EditorActivity.kt b/android/app/src/main/java/com/example/gutenbergkit/EditorActivity.kt index b3d8afc70..3e3a7f8ff 100644 --- a/android/app/src/main/java/com/example/gutenbergkit/EditorActivity.kt +++ b/android/app/src/main/java/com/example/gutenbergkit/EditorActivity.kt @@ -54,7 +54,10 @@ class EditorActivity : ComponentActivity() { ActivityResultContracts.StartActivityForResult() ) { result -> lifecycleScope.launch { - gutenbergView?.handleFilePickerResult(this@EditorActivity, result.data) + val uris = gutenbergView?.extractUrisFromIntent(result.data) + val processedUris = gutenbergView?.processFileUris(this@EditorActivity, uris) + gutenbergView?.filePathCallback?.onReceiveValue(processedUris) + gutenbergView?.resetFilePathCallback() } }