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..e9cdafd5f --- /dev/null +++ b/android/Gutenberg/src/main/java/org/wordpress/gutenberg/FileCache.kt @@ -0,0 +1,202 @@ +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 14aa75f71..7967f52a6 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 @@ -215,6 +217,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 @@ -590,11 +595,75 @@ class GutenbergView : WebView { filePathCallback = null } + /** + * 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 data Intent data from file picker result + * @return Array of selected URIs, or null if no files were selected + */ + 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 + } + + /** + * 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 onDetachedFromWindow() { super.onDetachedFromWindow() clearConfig() this.stopLoading() (requestInterceptor as? CachedAssetRequestInterceptor)?.shutdown() + 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 new file mode 100644 index 000000000..1296e2112 --- /dev/null +++ b/android/Gutenberg/src/test/java/org/wordpress/gutenberg/FileCacheTest.kt @@ -0,0 +1,182 @@ +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/app/src/main/java/com/example/gutenbergkit/EditorActivity.kt b/android/app/src/main/java/com/example/gutenbergkit/EditorActivity.kt index b7dadce5c..3e3a7f8ff 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,6 @@ package com.example.gutenbergkit +import android.content.Intent import android.os.Bundle import android.webkit.WebView import android.content.pm.ApplicationInfo @@ -7,6 +8,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 @@ -33,15 +36,31 @@ 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.launch 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 -> + lifecycleScope.launch { + val uris = gutenbergView?.extractUrisFromIntent(result.data) + val processedUris = gutenbergView?.processFileUris(this@EditorActivity, uris) + gutenbergView?.filePathCallback?.onReceiveValue(processedUris) + gutenbergView?.resetFilePathCallback() + } + } + if (0 != (applicationInfo.flags and ApplicationInfo.FLAG_DEBUGGABLE)) { WebView.setWebContentsDebuggingEnabled(true) } @@ -62,18 +81,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 +226,7 @@ fun EditorScreen( } }) start(configuration) + onGutenbergViewCreated(this) } }, modifier = Modifier 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