diff --git a/android/Gutenberg/detekt-baseline.xml b/android/Gutenberg/detekt-baseline.xml
index 3e5f564c..428e9d6f 100644
--- a/android/Gutenberg/detekt-baseline.xml
+++ b/android/Gutenberg/detekt-baseline.xml
@@ -38,8 +38,6 @@
MaxLineLength:RESTAPIRepositoryTest.kt$RESTAPIRepositoryTest$val rawJSON = """{"styles":[{"css":".theme-style{color:blue}","isGlobalStyles":true},{"css":".another{margin:0}","isGlobalStyles":false}]}"""
MaxLineLength:RequestBody.kt$Buffer$internal
NestedBlockDepth:EditorAssetsLibrary.kt$EditorAssetsLibrary$private fun cleanupOldCache()
- NestedBlockDepth:FileCache.kt$FileCache$fun copyToCache(context: Context, uri: Uri, maxSizeBytes: Long = DEFAULT_MAX_FILE_SIZE): Uri?
- NestedBlockDepth:FileCache.kt$FileCache$private fun getFileSize(context: Context, uri: Uri): Long?
NestedBlockDepth:FixtureTests.kt$FixtureTests$@Test fun `multipart parsing - all cases pass`()
NestedBlockDepth:FixtureTests.kt$FixtureTests$@Test fun `request parsing - all incremental cases pass`()
NestedBlockDepth:MultipartPart.kt$MultipartPart.Companion$fun parseChunked( source: RequestBody.FileBacked, boundary: String ): List<MultipartPart>
@@ -66,7 +64,6 @@
TooGenericExceptionCaught:EditorDependenciesSerializer.kt$EditorDependenciesSerializer$e: Exception
TooGenericExceptionCaught:EditorHTTPClient.kt$EditorHTTPClient$e: Exception
TooGenericExceptionCaught:EditorURLResponse.kt$EditorURLResponse$e: Exception
- TooGenericExceptionCaught:FileCache.kt$FileCache$e: Exception
TooGenericExceptionCaught:GutenbergView.kt$GutenbergView$e: Exception
TooGenericExceptionCaught:HttpServer.kt$HttpServer$e: Exception
TooGenericExceptionCaught:LocalEditorAssetManifest.kt$LocalEditorAssetManifest$e: Exception
diff --git a/android/Gutenberg/src/main/java/org/wordpress/gutenberg/FileCache.kt b/android/Gutenberg/src/main/java/org/wordpress/gutenberg/FileCache.kt
deleted file mode 100644
index e9cdafd5..00000000
--- a/android/Gutenberg/src/main/java/org/wordpress/gutenberg/FileCache.kt
+++ /dev/null
@@ -1,202 +0,0 @@
-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
-import java.io.IOException
-
-/**
- * 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.
- */
-internal 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.
- *
- * 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
- * @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, 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()
- }
-
- 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 {
- fileName
- }
-
- // Create a unique file to avoid conflicts
- 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()
- }
- 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()
- }
- }
- }
-
- /**
- * 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.
- *
- * @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 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/main/java/org/wordpress/gutenberg/GutenbergView.kt b/android/Gutenberg/src/main/java/org/wordpress/gutenberg/GutenbergView.kt
index 5184c26c..74ba0cb6 100644
--- a/android/Gutenberg/src/main/java/org/wordpress/gutenberg/GutenbergView.kt
+++ b/android/Gutenberg/src/main/java/org/wordpress/gutenberg/GutenbergView.kt
@@ -33,7 +33,6 @@ import androidx.webkit.WebViewAssetLoader.AssetsPathHandler
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
-import kotlinx.coroutines.withContext
import org.json.JSONException
import org.json.JSONObject
import org.wordpress.gutenberg.model.EditorConfiguration
@@ -455,7 +454,12 @@ class GutenbergView : FrameLayout {
// Only use `acceptTypes` if it is not merely an empty string
val mimeTypes = fileChooserParams?.acceptTypes?.takeUnless { it.size == 1 && it[0].isEmpty() } ?: arrayOf("*/*")
- val intent = Intent(Intent.ACTION_GET_CONTENT)
+ // Use ACTION_OPEN_DOCUMENT instead of ACTION_PICK_IMAGES to
+ // bypass the Android Photo Picker, which returns proxy URIs
+ // that trigger Chromium's ERR_UPLOAD_FILE_CHANGED error.
+ // ACTION_OPEN_DOCUMENT routes directly to DocumentsUI, which
+ // returns stable content URIs suitable for WebView uploads.
+ val intent = Intent(Intent.ACTION_OPEN_DOCUMENT)
intent.setType(mimeTypes[0])
intent.putExtra(Intent.EXTRA_MIME_TYPES, mimeTypes)
intent.addCategory(Intent.CATEGORY_OPENABLE)
@@ -466,7 +470,7 @@ class GutenbergView : FrameLayout {
onFileChooserRequested?.let { callback ->
handler.post {
- callback(Intent.createChooser(intent, "Select Files"), pickImageRequestCode)
+ callback(intent, pickImageRequestCode)
}
}
return true
@@ -953,48 +957,6 @@ class GutenbergView : FrameLayout {
} else null
}
- /**
- * 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) {
- 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 onAttachedToWindow() {
super.onAttachedToWindow()
startNetworkMonitoring()
@@ -1011,7 +973,6 @@ class GutenbergView : FrameLayout {
errorView.animate().cancel()
webView.animate().cancel()
webView.stopLoading()
- FileCache.clearCache(context)
contentChangeListener = null
historyChangeListener = null
featuredImageChangeListener = null
diff --git a/android/Gutenberg/src/test/java/org/wordpress/gutenberg/FileCacheTest.kt b/android/Gutenberg/src/test/java/org/wordpress/gutenberg/FileCacheTest.kt
deleted file mode 100644
index 1296e211..00000000
--- a/android/Gutenberg/src/test/java/org/wordpress/gutenberg/FileCacheTest.kt
+++ /dev/null
@@ -1,182 +0,0 @@
-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.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)
- }
-
- // 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/Gutenberg/src/test/java/org/wordpress/gutenberg/GutenbergViewTest.kt b/android/Gutenberg/src/test/java/org/wordpress/gutenberg/GutenbergViewTest.kt
index 783d6abf..3e4ccf5f 100644
--- a/android/Gutenberg/src/test/java/org/wordpress/gutenberg/GutenbergViewTest.kt
+++ b/android/Gutenberg/src/test/java/org/wordpress/gutenberg/GutenbergViewTest.kt
@@ -82,15 +82,10 @@ class GutenbergViewTest {
// Then
assertTrue("Intent should not be null", capturedIntent != null)
- assertTrue("Intent should be a chooser", capturedIntent?.action == Intent.ACTION_CHOOSER)
-
- // Get the original intent from the chooser
- val originalIntent = capturedIntent?.getParcelableExtra(Intent.EXTRA_INTENT)
- assertTrue("Original intent should not be null", originalIntent != null)
- assertTrue("Original intent action should be ACTION_GET_CONTENT",
- originalIntent?.action == Intent.ACTION_GET_CONTENT)
- assertTrue("Original intent should have CATEGORY_OPENABLE",
- originalIntent?.hasCategory(Intent.CATEGORY_OPENABLE) == true)
+ assertTrue("Intent action should be ACTION_OPEN_DOCUMENT",
+ capturedIntent?.action == Intent.ACTION_OPEN_DOCUMENT)
+ assertTrue("Intent should have CATEGORY_OPENABLE",
+ capturedIntent?.hasCategory(Intent.CATEGORY_OPENABLE) == true)
assertEquals("Pick image request code should be 1",
1, gutenbergView.pickImageRequestCode)
}
@@ -122,13 +117,10 @@ class GutenbergViewTest {
// Then
assertTrue("Intent should not be null", capturedIntent != null)
- assertTrue("Intent should be a chooser", capturedIntent?.action == Intent.ACTION_CHOOSER)
-
- // Get the original intent from the chooser
- val originalIntent = capturedIntent?.getParcelableExtra(Intent.EXTRA_INTENT)
- assertTrue("Original intent should not be null", originalIntent != null)
- assertTrue("Original intent should allow multiple selection",
- originalIntent?.getBooleanExtra(Intent.EXTRA_ALLOW_MULTIPLE, false) == true)
+ assertTrue("Intent action should be ACTION_OPEN_DOCUMENT",
+ capturedIntent?.action == Intent.ACTION_OPEN_DOCUMENT)
+ assertTrue("Intent should allow multiple selection",
+ capturedIntent?.getBooleanExtra(Intent.EXTRA_ALLOW_MULTIPLE, false) == true)
}
@Test
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 fb2c2e6b..213b75ab 100644
--- a/android/app/src/main/java/com/example/gutenbergkit/EditorActivity.kt
+++ b/android/app/src/main/java/com/example/gutenbergkit/EditorActivity.kt
@@ -76,12 +76,9 @@ class EditorActivity : ComponentActivity() {
filePickerLauncher = registerForActivityResult(
ActivityResultContracts.StartActivityForResult()
) { result ->
- lifecycleScope.launch {
- val uris = gutenbergView?.extractUrisFromIntent(result.data)
- val processedUris = gutenbergView?.processFileUris(this@EditorActivity, uris)
- gutenbergView?.filePathCallback?.onReceiveValue(processedUris)
- gutenbergView?.resetFilePathCallback()
- }
+ val uris = gutenbergView?.extractUrisFromIntent(result.data)
+ gutenbergView?.filePathCallback?.onReceiveValue(uris)
+ gutenbergView?.resetFilePathCallback()
}
if (0 != (applicationInfo.flags and ApplicationInfo.FLAG_DEBUGGABLE)) {