Skip to content

Commit a3a4b08

Browse files
authored
fix: Mitigate Android ERR_UPLOAD_FILE_CHANGE errors (#224)
* feat: Android demo app handles file picker * refactor: Remove unused imports * 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 * feat: Disable Android upload file caching for known local providers Avoid unnecessary CPU and disk usage for providers that succeed without caching. * feat: Extend caching to all file types The editor uploads numerous file types. * refactor: Remove unused imports * refactor: Add FileCache logs * feat: Limit caching by file size Avoid OOM from attempts to cache large files. * fix: Avoid stale file picker callback errors * refactor: Relocate cache clearing to `onDestroy` Avoid stale files after closing the editor. * refactor: Encapsulate file picker logic Simplify the API for uploading selected files in GutenbergKit. * refactor: Redesign API for more flexibility Enable host apps to act on extracted URIs as before.
1 parent ef47b09 commit a3a4b08

File tree

5 files changed

+486
-4
lines changed

5 files changed

+486
-4
lines changed
Lines changed: 202 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,202 @@
1+
package org.wordpress.gutenberg
2+
3+
import android.content.Context
4+
import android.net.Uri
5+
import android.provider.OpenableColumns
6+
import android.util.Log
7+
import android.webkit.MimeTypeMap
8+
import java.io.File
9+
import java.io.FileOutputStream
10+
import java.io.IOException
11+
12+
/**
13+
* Internal utility class for caching files from content providers to avoid ERR_UPLOAD_FILE_CHANGED
14+
* errors in WebView when uploading files from cloud storage providers.
15+
*
16+
* This is an internal implementation detail of GutenbergView and should not be used directly by apps.
17+
* Apps should use GutenbergView.handleFilePickerResult() instead.
18+
*/
19+
internal object FileCache {
20+
private const val TAG = "FileCache"
21+
private const val CACHE_DIR_NAME = "gutenberg_file_uploads"
22+
private const val BUFFER_SIZE = 8192
23+
const val DEFAULT_MAX_FILE_SIZE = 100 * 1024 * 1024L // 100MB in bytes
24+
25+
/**
26+
* Copies a file from a content URI to the app's cache directory.
27+
*
28+
* This is necessary to work around Android WebView issues with uploading files from
29+
* cloud storage providers (Google Drive, Dropbox, etc.) which can trigger
30+
* ERR_UPLOAD_FILE_CHANGED errors due to streaming content or changing metadata.
31+
*
32+
* @param context Android context
33+
* @param uri The content:// URI to copy
34+
* @param maxSizeBytes Maximum file size in bytes (default: 100MB)
35+
* @return URI of the cached file, or null if the copy failed or file exceeds size limit
36+
*/
37+
fun copyToCache(context: Context, uri: Uri, maxSizeBytes: Long = DEFAULT_MAX_FILE_SIZE): Uri? {
38+
// Check file size before attempting to copy
39+
val fileSize = getFileSize(context, uri)
40+
if (fileSize != null && fileSize > maxSizeBytes) {
41+
val fileSizeMB = fileSize / (1024 * 1024)
42+
val maxSizeMB = maxSizeBytes / (1024 * 1024)
43+
Log.w(TAG, "File exceeds maximum size limit: uri=$uri, size=${fileSizeMB}MB, limit=${maxSizeMB}MB")
44+
return null
45+
}
46+
47+
if (fileSize != null) {
48+
Log.d(TAG, "File size check passed: uri=$uri, size=${fileSize / (1024 * 1024)}MB")
49+
} else {
50+
Log.w(TAG, "Unable to determine file size, proceeding with copy attempt: uri=$uri")
51+
}
52+
53+
val cacheDir = File(context.cacheDir, CACHE_DIR_NAME)
54+
if (!cacheDir.exists()) {
55+
cacheDir.mkdirs()
56+
}
57+
58+
val fileName = getFileName(context, uri) ?: "upload_${System.currentTimeMillis()}"
59+
val extension = getFileExtension(context, uri)
60+
val mimeType = context.contentResolver.getType(uri)
61+
val fileNameWithExtension = if (extension != null && !fileName.endsWith(".$extension")) {
62+
"$fileName.$extension"
63+
} else {
64+
fileName
65+
}
66+
67+
// Create a unique file to avoid conflicts
68+
val uniqueFileName = "${System.currentTimeMillis()}_$fileNameWithExtension"
69+
val cachedFile = File(cacheDir, uniqueFileName)
70+
71+
Log.d(TAG, "Attempting to cache file: uri=$uri, fileName=$fileName, mimeType=$mimeType, destination=$cachedFile")
72+
73+
return try {
74+
var totalBytesRead = 0L
75+
context.contentResolver.openInputStream(uri)?.use { input ->
76+
FileOutputStream(cachedFile).use { output ->
77+
val buffer = ByteArray(BUFFER_SIZE)
78+
var bytesRead: Int
79+
while (input.read(buffer).also { bytesRead = it } != -1) {
80+
output.write(buffer, 0, bytesRead)
81+
totalBytesRead += bytesRead
82+
}
83+
}
84+
}
85+
Log.d(TAG, "Successfully cached file: uri=$uri, cachedFile=$cachedFile, size=$totalBytesRead bytes")
86+
Uri.fromFile(cachedFile)
87+
} catch (e: IOException) {
88+
Log.e(TAG, "Failed to copy file to cache: uri=$uri, error=${e.message}", e)
89+
// Clean up partial file if copy failed
90+
if (cachedFile.exists()) {
91+
cachedFile.delete()
92+
}
93+
null
94+
}
95+
}
96+
97+
/**
98+
* Clears all cached files from previous sessions to prevent storage accumulation.
99+
*
100+
* @param context Android context
101+
*/
102+
fun clearCache(context: Context) {
103+
val cacheDir = File(context.cacheDir, CACHE_DIR_NAME)
104+
if (cacheDir.exists() && cacheDir.isDirectory) {
105+
cacheDir.listFiles()?.forEach { file ->
106+
file.delete()
107+
}
108+
}
109+
}
110+
111+
/**
112+
* Gets the file size from a content URI.
113+
*
114+
* Queries the content provider for the file size using OpenableColumns.SIZE.
115+
* Some content providers may not provide size information, in which case this
116+
* returns null.
117+
*
118+
* @param context Android context
119+
* @param uri The content URI
120+
* @return File size in bytes, or null if size cannot be determined
121+
*/
122+
private fun getFileSize(context: Context, uri: Uri): Long? {
123+
return try {
124+
context.contentResolver.query(uri, null, null, null, null)?.use { cursor ->
125+
if (cursor.moveToFirst()) {
126+
val sizeIndex = cursor.getColumnIndex(OpenableColumns.SIZE)
127+
if (sizeIndex != -1) {
128+
val size = cursor.getLong(sizeIndex)
129+
// Some providers return -1 or 0 when size is unknown
130+
if (size > 0) size else null
131+
} else {
132+
null
133+
}
134+
} else {
135+
null
136+
}
137+
}
138+
} catch (e: Exception) {
139+
Log.w(TAG, "Failed to query file size for uri: $uri, error=${e.message}", e)
140+
null
141+
}
142+
}
143+
144+
/**
145+
* Retrieves the display name of a file from a content URI.
146+
*
147+
* @param context Android context
148+
* @param uri The content URI
149+
* @return The file name, or null if it cannot be determined
150+
*/
151+
private fun getFileName(context: Context, uri: Uri): String? {
152+
var fileName: String? = null
153+
context.contentResolver.query(uri, null, null, null, null)?.use { cursor ->
154+
if (cursor.moveToFirst()) {
155+
val nameIndex = cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME)
156+
if (nameIndex != -1) {
157+
fileName = cursor.getString(nameIndex)
158+
}
159+
}
160+
}
161+
return fileName
162+
}
163+
164+
/**
165+
* Gets the file extension from a content URI by checking its MIME type.
166+
*
167+
* @param context Android context
168+
* @param uri The content URI
169+
* @return The file extension (without the dot), or null if it cannot be determined
170+
*/
171+
private fun getFileExtension(context: Context, uri: Uri): String? {
172+
val mimeType = context.contentResolver.getType(uri)
173+
return mimeType?.let { MimeTypeMap.getSingleton().getExtensionFromMimeType(it) }
174+
}
175+
176+
/**
177+
* Checks if a URI comes from a known-safe local content provider.
178+
*
179+
* These providers serve local files that won't change during upload, so copying
180+
* them to cache is unnecessary. This allow list includes only Android's built-in
181+
* local content providers.
182+
*
183+
* @param uri The content URI to check
184+
* @return true if the URI is from a known-safe local provider
185+
*/
186+
fun isKnownSafeLocalProvider(uri: Uri): Boolean {
187+
val authority = uri.authority ?: return false
188+
189+
// Android's MediaStore (photos, videos, audio from device)
190+
if (authority.startsWith("com.android.providers.media")) {
191+
return true
192+
}
193+
194+
// Android's Downloads provider
195+
if (authority.startsWith("com.android.providers.downloads")) {
196+
return true
197+
}
198+
199+
// All other providers (including cloud providers) are not on the allow list
200+
return false
201+
}
202+
}

android/Gutenberg/src/main/java/org/wordpress/gutenberg/GutenbergView.kt

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,8 @@ import android.webkit.WebView
2424
import android.webkit.WebViewClient
2525
import androidx.webkit.WebViewAssetLoader
2626
import androidx.webkit.WebViewAssetLoader.AssetsPathHandler
27+
import kotlinx.coroutines.Dispatchers
28+
import kotlinx.coroutines.withContext
2729
import org.json.JSONException
2830
import org.json.JSONObject
2931
import java.util.Locale
@@ -215,6 +217,9 @@ class GutenbergView : WebView {
215217
newFilePathCallback: ValueCallback<Array<Uri?>?>?,
216218
fileChooserParams: FileChooserParams?
217219
): Boolean {
220+
// Cancel any existing callback to prevent WebView state corruption
221+
filePathCallback?.onReceiveValue(null)
222+
218223
filePathCallback = newFilePathCallback
219224
val allowMultiple = fileChooserParams?.mode == FileChooserParams.MODE_OPEN_MULTIPLE
220225
// Only use `acceptTypes` if it is not merely an empty string
@@ -590,11 +595,75 @@ class GutenbergView : WebView {
590595
filePathCallback = null
591596
}
592597

598+
/**
599+
* Extracts file URIs from a file picker Intent result.
600+
*
601+
* Handles both single file selection (Intent.data) and multiple file selection
602+
* (Intent.clipData). This is a utility method for processing ActivityResult data
603+
* from file picker requests.
604+
*
605+
* @param data Intent data from file picker result
606+
* @return Array of selected URIs, or null if no files were selected
607+
*/
608+
fun extractUrisFromIntent(data: Intent?): Array<Uri?>? {
609+
return if (data != null) {
610+
if (data.clipData != null) {
611+
val clipData = data.clipData!!
612+
Array(clipData.itemCount) { i -> clipData.getItemAt(i).uri }
613+
} else if (data.data != null) {
614+
arrayOf(data.data)
615+
} else null
616+
} else null
617+
}
618+
619+
/**
620+
* Processes file URIs to work around Chrome ERR_UPLOAD_FILE_CHANGED bug.
621+
*
622+
* This method caches files from cloud storage providers (Google Drive, OneDrive, etc.)
623+
* to local storage to prevent upload failures. Files from known-safe local providers
624+
* (MediaStore, Downloads) are passed through unchanged for optimal performance.
625+
*
626+
* Apps should call this method with URIs from the file picker, then pass the result
627+
* to filePathCallback.onReceiveValue() to complete the file selection.
628+
*
629+
* @param context Android context for file operations
630+
* @param uris Array of URIs from file picker
631+
* @return Array of processed URIs (cached for cloud URIs, original for local URIs)
632+
*/
633+
suspend fun processFileUris(context: Context, uris: Array<Uri?>?): Array<Uri?>? {
634+
if (uris == null) return null
635+
636+
return withContext(Dispatchers.IO) {
637+
uris.map { uri ->
638+
if (uri == null) return@map null
639+
640+
if (uri.scheme == "content") {
641+
if (FileCache.isKnownSafeLocalProvider(uri)) {
642+
Log.i("GutenbergView", "Using local provider URI directly: $uri")
643+
uri
644+
} else {
645+
val cachedUri = FileCache.copyToCache(context, uri)
646+
if (cachedUri != null) {
647+
Log.i("GutenbergView", "Copied content URI to cache: $uri -> $cachedUri")
648+
cachedUri
649+
} else {
650+
Log.w("GutenbergView", "Failed to copy content URI to cache, using original: $uri")
651+
uri
652+
}
653+
}
654+
} else {
655+
uri
656+
}
657+
}.toTypedArray()
658+
}
659+
}
660+
593661
override fun onDetachedFromWindow() {
594662
super.onDetachedFromWindow()
595663
clearConfig()
596664
this.stopLoading()
597665
(requestInterceptor as? CachedAssetRequestInterceptor)?.shutdown()
666+
FileCache.clearCache(context)
598667
contentChangeListener = null
599668
historyChangeListener = null
600669
featuredImageChangeListener = null

0 commit comments

Comments
 (0)