From aea5875e555743f629b4c7111cd5fa7a5f2cded0 Mon Sep 17 00:00:00 2001 From: David Calhoun Date: Thu, 23 Jul 2026 10:15:23 -0400 Subject: [PATCH 01/10] build: bump GutenbergKit to PR 357 media-upload-delegate snapshot Consumes the snapshot build of wordpress-mobile/GutenbergKit#357, which adds the MediaUploadDelegate API for host-side media upload processing. To be swapped to a tagged release before merge. Co-Authored-By: Claude Opus 4.8 (1M context) --- gradle/libs.versions.toml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index ccd85d72a937..8839290106ea 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -73,7 +73,8 @@ google-play-review = '2.0.2' google-services = '4.5.0' gravatar = '2.5.0' greenrobot-eventbus = '3.3.1' -gutenberg-kit = 'v0.17.2' +# TODO: Swap to a tagged GutenbergKit release before merge (snapshot of wordpress-mobile/GutenbergKit#357) +gutenberg-kit = '357-45219181e3aa60b5ce3119367758d90e6b6beb0b' gutenberg-mobile = 'v1.121.0' indexos-media-for-mobile = '43a9026f0973a2f0a74fa813132f6a16f7499c3a' jackson-databind = '2.12.7.1' From 01aa9205593302ff326fb3404ab56ef0f4d62fa8 Mon Sep 17 00:00:00 2001 From: David Calhoun Date: Thu, 23 Jul 2026 10:17:59 -0400 Subject: [PATCH 02/10] feat: expose media optimization prefs and EXIF strip via wrappers Adds video optimization and strip-image-location accessors to AppPrefsWrapper, and a stripImageLocation passthrough to MediaUtilsWrapper, so the upcoming GutenbergKit media upload processor can read settings and strip GPS EXIF through injectable, testable wrappers. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../wordpress/android/ui/prefs/AppPrefsWrapper.kt | 12 ++++++++++++ .../org/wordpress/android/util/MediaUtilsWrapper.kt | 3 +++ 2 files changed, 15 insertions(+) diff --git a/WordPress/src/main/java/org/wordpress/android/ui/prefs/AppPrefsWrapper.kt b/WordPress/src/main/java/org/wordpress/android/ui/prefs/AppPrefsWrapper.kt index 1292b8dceb6c..4d0df733fa7b 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/prefs/AppPrefsWrapper.kt +++ b/WordPress/src/main/java/org/wordpress/android/ui/prefs/AppPrefsWrapper.kt @@ -49,6 +49,18 @@ class AppPrefsWrapper @Inject constructor(val buildConfigWrapper: BuildConfigWra get() = AppPrefs.isAztecEditorEnabled() set(enabled) = AppPrefs.setAztecEditorEnabled(enabled) + val isVideoOptimize: Boolean + get() = AppPrefs.isVideoOptimize() + + val videoOptimizeWidth: Int + get() = AppPrefs.getVideoOptimizeWidth() + + val videoOptimizeQuality: Int + get() = AppPrefs.getVideoOptimizeQuality() + + val isStripImageLocation: Boolean + get() = AppPrefs.isStripImageLocation() + var postListAuthorSelection: AuthorFilterSelection get() = AppPrefs.getAuthorFilterSelection() set(value) = AppPrefs.setAuthorFilterSelection(value) diff --git a/WordPress/src/main/java/org/wordpress/android/util/MediaUtilsWrapper.kt b/WordPress/src/main/java/org/wordpress/android/util/MediaUtilsWrapper.kt index b906fbd95702..f0781a8728e7 100644 --- a/WordPress/src/main/java/org/wordpress/android/util/MediaUtilsWrapper.kt +++ b/WordPress/src/main/java/org/wordpress/android/util/MediaUtilsWrapper.kt @@ -35,6 +35,9 @@ class MediaUtilsWrapper @Inject constructor(private val appContext: Context) { fun isVideoMimeType(mimeType: String?): Boolean = org.wordpress.android.fluxc.utils.MediaUtils.isVideoMimeType(mimeType) + fun stripImageLocation(imagePath: String) = + org.wordpress.android.fluxc.utils.MediaUtils.stripLocation(imagePath) + fun isInMediaStore(mediaUri: Uri?): Boolean = MediaUtils.isInMediaStore(mediaUri) From ce33121f06371fc7002975ec850d2f9066f899b0 Mon Sep 17 00:00:00 2001 From: David Calhoun Date: Thu, 23 Jul 2026 10:19:32 -0400 Subject: [PATCH 03/10] feat: add GBKMediaUploadProcessor for GutenbergKit media uploads Implements GutenbergKit's MediaUploadDelegate to process device media picked in the experimental block editor before upload, honoring the app's media settings the same way the legacy editor pipeline does: - Images are optimized per the max size/quality settings via WPMediaUtils.getOptimizedMedia, with GPS EXIF stripped when the strip-location setting is on, and the reported mime type/extension corrected to the actual output format (non-PNG inputs, including HEIC, re-encode to JPEG). - When processing would be a no-op, the original file passes through untouched, avoiding a needless lossy re-encode. - Sideways-captured images are rotated for self-hosted sites when optimization is off (WP.com rotates server-side; issue #5737). - GIFs always pass through to preserve animation. - Videos enforce the free-plan 5-minute duration limit and transcode via WPVideoUtils/m4m only when the optimize-video setting is on, keeping the transcoded file only when smaller than the original. Transcodes are serialized to bound codec/memory pressure. - File types disallowed by the site plan are rejected with a localized message relayed to the editor as a notice. Nothing wires the processor yet; that lands separately. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../posts/editor/GBKMediaUploadProcessor.kt | 251 ++++++++++++++++++ 1 file changed, 251 insertions(+) create mode 100644 WordPress/src/main/java/org/wordpress/android/ui/posts/editor/GBKMediaUploadProcessor.kt diff --git a/WordPress/src/main/java/org/wordpress/android/ui/posts/editor/GBKMediaUploadProcessor.kt b/WordPress/src/main/java/org/wordpress/android/ui/posts/editor/GBKMediaUploadProcessor.kt new file mode 100644 index 000000000000..0098bb3ef18b --- /dev/null +++ b/WordPress/src/main/java/org/wordpress/android/ui/posts/editor/GBKMediaUploadProcessor.kt @@ -0,0 +1,251 @@ +package org.wordpress.android.ui.posts.editor + +import android.content.Context +import android.net.Uri +import android.webkit.MimeTypeMap +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.suspendCancellableCoroutine +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlinx.coroutines.withContext +import org.m4m.IProgressListener +import org.wordpress.android.R +import org.wordpress.android.fluxc.model.SiteModel +import org.wordpress.android.ui.prefs.AppPrefsWrapper +import org.wordpress.android.util.AppLog +import org.wordpress.android.util.MediaUtils +import org.wordpress.android.util.MediaUtilsWrapper +import org.wordpress.android.util.WPVideoUtils +import org.wordpress.gutenberg.MediaUploadDelegate +import org.wordpress.gutenberg.ProcessedProxyFile +import java.io.File +import kotlin.coroutines.resume + +/** + * Processes device media picked in the GutenbergKit editor before upload, honoring the app's + * media settings (image optimization, quality, EXIF location stripping, video optimization) the + * same way the legacy editor's upload pipeline does. + * + * Set as [org.wordpress.gutenberg.GutenbergView.mediaUploadDelegate]; GutenbergKit invokes + * [processFile] for every editor upload and uploads the result itself (this class deliberately + * does not override `uploadFile`, so GutenbergKit's default uploader posts to `/wp/v2/media` + * and relays WordPress's raw response to the editor). + * + * Contract notes (see GutenbergKit's MediaUploadServer): + * - Returning [ProcessedProxyFile.Original] makes GutenbergKit forward the original request body + * byte-for-byte — mutations to the staged [File] are NOT uploaded. Any change intended for + * WordPress must be returned as [ProcessedProxyFile.Processed]. + * - Processed output files are deleted by GutenbergKit after the upload, so they are written to + * the cache dir and never registered in the app's media store. + * - Thrown exceptions are relayed to the editor as an error notice showing the exception message, + * so messages must be localized and user-facing. + */ +class GBKMediaUploadProcessor( + private val site: SiteModel, + private val appContext: Context, + private val mediaUtilsWrapper: MediaUtilsWrapper, + private val appPrefsWrapper: AppPrefsWrapper, + private val ioDispatcher: CoroutineDispatcher = Dispatchers.IO, +) : MediaUploadDelegate { + /** + * Serializes video transcodes. GutenbergKit's upload server handles requests concurrently, + * but parallel m4m hardware transcodes are memory/codec-heavy; the legacy pipeline + * effectively serialized them through the upload queue. + */ + private val transcodeMutex = Mutex() + + override suspend fun processFile( + file: File, + mimeType: String, + filename: String + ): ProcessedProxyFile = withContext(ioDispatcher) { + val resolvedMimeType = resolveMimeType(mimeType, filename) + + // Reject types the site's plan doesn't allow (e.g. audio on free WP.com plans) with a + // localized message instead of an opaque server-side error. + if (!mediaUtilsWrapper.isMimeTypeSupportedBySitePlan(site, resolvedMimeType)) { + throw GBKMediaUploadException(appContext.getString(R.string.error_media_file_type_not_allowed)) + } + + when { + // Never re-encode GIFs — it would flatten animation. Passthrough skips even a copy. + resolvedMimeType == MIME_GIF -> ProcessedProxyFile.Original + mediaUtilsWrapper.isVideoMimeType(resolvedMimeType) -> processVideo(file, filename) + resolvedMimeType.startsWith(MIME_IMAGE_PREFIX) -> processImage(file, resolvedMimeType, filename) + // Non-media files (documents, archives, audio on paid plans) upload unchanged. + else -> ProcessedProxyFile.Original + } + } + + private suspend fun processVideo(file: File, filename: String): ProcessedProxyFile { + if (mediaUtilsWrapper.isProhibitedVideoDuration(appContext, site, Uri.fromFile(file))) { + throw GBKMediaUploadException( + appContext.getString(R.string.error_media_video_duration_exceeds_limit) + ) + } + + // Match the legacy pipeline: transcode only when the user enabled video optimization. + if (!appPrefsWrapper.isVideoOptimize) return ProcessedProxyFile.Original + + val output = transcodeMutex.withLock { transcodeVideo(file) } ?: return ProcessedProxyFile.Original + + // Match VideoOptimizer: only use the transcoded file when it is actually smaller. + if (output.length() >= file.length()) { + output.delete() + return ProcessedProxyFile.Original + } + + return ProcessedProxyFile.Processed( + file = output, + mimeType = MIME_MP4, + filename = "${filename.substringBeforeLast('.')}.mp4" + ) + } + + /** + * Transcodes the video per the user's optimization settings, mirroring the legacy + * [org.wordpress.android.ui.uploads.VideoOptimizer] semantics: any failure (no composer, + * m4m error) resolves to null so the caller falls back to uploading the original. + */ + @Suppress("TooGenericExceptionCaught", "SwallowedException") + private suspend fun transcodeVideo(input: File): File? = suspendCancellableCoroutine { continuation -> + val output = File(appContext.cacheDir, MediaUtils.generateTimeStampedFileName(MIME_MP4)) + val listener = object : IProgressListener { + override fun onMediaStart() = Unit + override fun onMediaProgress(progress: Float) = Unit + override fun onMediaPause() = Unit + + // onMediaStop fires both on completion (before onMediaDone) and on manual stop, so + // only onMediaDone/onError complete the coroutine, guarded against double-resume. + override fun onMediaStop() = Unit + + override fun onMediaDone() { + if (continuation.isActive) continuation.resume(output) + } + + override fun onError(exception: Exception) { + AppLog.e(AppLog.T.MEDIA, "GBKMediaUploadProcessor > video transcode failed", exception) + output.delete() + if (continuation.isActive) continuation.resume(null) + } + } + + val composer = try { + WPVideoUtils.getVideoOptimizationComposer( + appContext, + input.absolutePath, + output.absolutePath, + listener, + appPrefsWrapper.videoOptimizeWidth, + appPrefsWrapper.videoOptimizeQuality + ) + } catch (npe: NullPointerException) { + // m4m throws NPEs on some malformed inputs; the legacy pipeline guards this too. + AppLog.w(AppLog.T.MEDIA, "GBKMediaUploadProcessor > NPE getting composer: ${npe.message}") + null + } + + if (composer == null) { + output.delete() + continuation.resume(null) + return@suspendCancellableCoroutine + } + + continuation.invokeOnCancellation { + try { + composer.stop() + } catch (e: Exception) { + AppLog.w(AppLog.T.MEDIA, "GBKMediaUploadProcessor > error stopping composer: ${e.message}") + } + output.delete() + } + + composer.start() + } + + private fun processImage(file: File, mimeType: String, filename: String): ProcessedProxyFile { + // getOptimizedMedia returns null when optimization is disabled or a no-op. It can also + // return the *input* path unchanged (GIF-like skips, decode failures inside + // ImageUtils.optimizeImage) — treat that as "not optimized" too, otherwise the original + // file would be mislabeled with a corrected JPEG mime type below. + val optimizedPath = mediaUtilsWrapper.getOptimizedMedia(file.absolutePath, false) + ?.path + ?.takeIf { it != file.absolutePath } + + if (optimizedPath != null) { + return processedImage(File(optimizedPath), mimeType, filename) + } + + // With optimization off, WP.com rotates sideways-captured images server-side but + // self-hosted sites don't, so rotate physically (legacy parity — see issue #5737). + // Returns null when no rotation is needed. + if (!site.isWPCom) { + val rotatedPath = mediaUtilsWrapper.fixOrientationIssue(file.absolutePath, false) + ?.path + ?.takeIf { it != file.absolutePath } + if (rotatedPath != null) { + return processedImage(File(rotatedPath), mimeType, filename) + } + } + + if (appPrefsWrapper.isStripImageLocation && mimeType in EXIF_MIME_TYPES) { + // A copy is required: returning Original makes GutenbergKit forward the original + // request body byte-for-byte, so stripping EXIF from the staged file in place would + // silently upload the un-stripped bytes. + val copy = File.createTempFile("gbk-media", ".${file.extension}", appContext.cacheDir) + file.copyTo(copy, overwrite = true) + mediaUtilsWrapper.stripImageLocation(copy.absolutePath) + return ProcessedProxyFile.Processed(copy, mimeType, filename) + } + + // No-op: optimization off/unneeded, no rotation, no location strip. Passing the original + // through avoids the needless lossy re-encode the legacy pipeline never did either. + return ProcessedProxyFile.Original + } + + /** + * Wraps an optimized/rotated image file, stripping GPS EXIF when enabled and correcting the + * reported mime type and filename: ImageUtils re-encodes PNG to PNG and everything else + * (including HEIC/WebP) to JPEG bytes while keeping the original file extension, so the + * metadata sent to WordPress must reflect the actual output format. + */ + private fun processedImage(output: File, inputMimeType: String, filename: String): ProcessedProxyFile { + if (appPrefsWrapper.isStripImageLocation) { + // getOptimizedMedia copies the original's EXIF (including GPS) onto its output, so + // the strip must run on the output — matching the legacy strip-at-upload behavior. + mediaUtilsWrapper.stripImageLocation(output.absolutePath) + } + + val basename = filename.substringBeforeLast('.') + return if (inputMimeType == MIME_PNG) { + ProcessedProxyFile.Processed(output, MIME_PNG, "$basename.png") + } else { + ProcessedProxyFile.Processed(output, MIME_JPEG, "$basename.jpg") + } + } + + private fun resolveMimeType(mimeType: String, filename: String): String { + if (mimeType.isNotBlank() && mimeType != MIME_OCTET_STREAM) return mimeType + val extension = filename.substringAfterLast('.', "").lowercase() + return MimeTypeMap.getSingleton().getMimeTypeFromExtension(extension) ?: MIME_OCTET_STREAM + } + + companion object { + private const val MIME_IMAGE_PREFIX = "image/" + private const val MIME_GIF = "image/gif" + private const val MIME_PNG = "image/png" + private const val MIME_JPEG = "image/jpeg" + private const val MIME_MP4 = "video/mp4" + private const val MIME_OCTET_STREAM = "application/octet-stream" + + /** Image formats that can carry EXIF GPS metadata. */ + private val EXIF_MIME_TYPES = setOf(MIME_JPEG, "image/heic", "image/heif", "image/webp") + } +} + +/** + * Thrown to reject an upload; GutenbergKit relays [message] to the editor as an error notice, + * so it must be localized and user-facing. + */ +class GBKMediaUploadException(message: String) : Exception(message) From 77ea28039bf39a93e9b5d807ab5f558322a85e95 Mon Sep 17 00:00:00 2001 From: David Calhoun Date: Thu, 23 Jul 2026 10:25:18 -0400 Subject: [PATCH 04/10] test: cover GBKMediaUploadProcessor decision table Adds JVM unit tests for the processor's decision table: optimized image output with corrected mime/extension (incl. HEIC to JPEG and PNG passthrough), no-op short-circuit, GPS stripping onto the optimized output and onto a copy when optimization is off, GIF passthrough, self-hosted orientation fix, plan-disallowed type and over-limit video rejections with localized messages, video passthrough when optimization is disabled, and same-path optimization results treated as unprocessed. Also adds a File-based isProhibitedVideoDuration overload to MediaUtilsWrapper so the processor avoids Uri.fromFile, keeping it testable on the JVM. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../posts/editor/GBKMediaUploadProcessor.kt | 3 +- .../android/util/MediaUtilsWrapper.kt | 4 + .../editor/GBKMediaUploadProcessorTest.kt | 250 ++++++++++++++++++ 3 files changed, 255 insertions(+), 2 deletions(-) create mode 100644 WordPress/src/test/java/org/wordpress/android/ui/posts/editor/GBKMediaUploadProcessorTest.kt diff --git a/WordPress/src/main/java/org/wordpress/android/ui/posts/editor/GBKMediaUploadProcessor.kt b/WordPress/src/main/java/org/wordpress/android/ui/posts/editor/GBKMediaUploadProcessor.kt index 0098bb3ef18b..a18e54428fdf 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/posts/editor/GBKMediaUploadProcessor.kt +++ b/WordPress/src/main/java/org/wordpress/android/ui/posts/editor/GBKMediaUploadProcessor.kt @@ -1,7 +1,6 @@ package org.wordpress.android.ui.posts.editor import android.content.Context -import android.net.Uri import android.webkit.MimeTypeMap import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.Dispatchers @@ -79,7 +78,7 @@ class GBKMediaUploadProcessor( } private suspend fun processVideo(file: File, filename: String): ProcessedProxyFile { - if (mediaUtilsWrapper.isProhibitedVideoDuration(appContext, site, Uri.fromFile(file))) { + if (mediaUtilsWrapper.isProhibitedVideoDuration(appContext, site, file)) { throw GBKMediaUploadException( appContext.getString(R.string.error_media_video_duration_exceeds_limit) ) diff --git a/WordPress/src/main/java/org/wordpress/android/util/MediaUtilsWrapper.kt b/WordPress/src/main/java/org/wordpress/android/util/MediaUtilsWrapper.kt index f0781a8728e7..6c31562b887d 100644 --- a/WordPress/src/main/java/org/wordpress/android/util/MediaUtilsWrapper.kt +++ b/WordPress/src/main/java/org/wordpress/android/util/MediaUtilsWrapper.kt @@ -8,6 +8,7 @@ import org.wordpress.android.editor.EditorMediaUtils import org.wordpress.android.fluxc.model.SiteModel import org.wordpress.android.fluxc.utils.MimeTypes.Plan import org.wordpress.android.util.AppLog.T +import java.io.File import java.util.concurrent.TimeUnit import javax.inject.Inject @@ -63,6 +64,9 @@ class MediaUtilsWrapper @Inject constructor(private val appContext: Context) { fun isVideoFile(mediaUri: Uri): Boolean = isVideo(mediaUri) || isVideoMimeType(getMimeType(mediaUri)) + fun isProhibitedVideoDuration(context: Context, site: SiteModel, file: File): Boolean = + isProhibitedVideoDuration(context, site, Uri.fromFile(file)) + fun isProhibitedVideoDuration(context: Context, site: SiteModel, uri: Uri): Boolean { if (isVideoFile(uri) && site.hasFreePlan && !site.isActiveModuleEnabled("videopress")) { val retriever = MediaMetadataRetriever() diff --git a/WordPress/src/test/java/org/wordpress/android/ui/posts/editor/GBKMediaUploadProcessorTest.kt b/WordPress/src/test/java/org/wordpress/android/ui/posts/editor/GBKMediaUploadProcessorTest.kt new file mode 100644 index 000000000000..29d3789846cc --- /dev/null +++ b/WordPress/src/test/java/org/wordpress/android/ui/posts/editor/GBKMediaUploadProcessorTest.kt @@ -0,0 +1,250 @@ +package org.wordpress.android.ui.posts.editor + +import android.content.Context +import kotlinx.coroutines.ExperimentalCoroutinesApi +import org.assertj.core.api.Assertions.assertThat +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TemporaryFolder +import org.junit.runner.RunWith +import org.mockito.junit.MockitoJUnitRunner +import org.mockito.kotlin.any +import org.mockito.kotlin.anyOrNull +import org.mockito.kotlin.doReturn +import org.mockito.kotlin.mock +import org.mockito.kotlin.never +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever +import org.wordpress.android.BaseUnitTest +import org.wordpress.android.R +import org.wordpress.android.fluxc.model.SiteModel +import org.wordpress.android.ui.prefs.AppPrefsWrapper +import org.wordpress.android.util.MediaUtilsWrapper +import org.wordpress.gutenberg.ProcessedProxyFile +import java.io.File + +@ExperimentalCoroutinesApi +@RunWith(MockitoJUnitRunner::class) +class GBKMediaUploadProcessorTest : BaseUnitTest() { + @get:Rule + val tempFolder = TemporaryFolder() + + private lateinit var appContext: Context + private lateinit var mediaUtilsWrapper: MediaUtilsWrapper + private lateinit var appPrefsWrapper: AppPrefsWrapper + private lateinit var stagedFile: File + + @Before + fun setUp() { + appContext = mock { + on { getString(R.string.error_media_file_type_not_allowed) } doReturn FILE_TYPE_ERROR + on { getString(R.string.error_media_video_duration_exceeds_limit) } doReturn VIDEO_LIMIT_ERROR + } + mediaUtilsWrapper = mock { + on { isMimeTypeSupportedBySitePlan(anyOrNull(), any()) } doReturn true + } + appPrefsWrapper = mock() + stagedFile = tempFolder.newFile("photo.jpg").apply { writeText("staged-bytes") } + } + + private fun createProcessor(site: SiteModel = wpComSite()) = GBKMediaUploadProcessor( + site = site, + appContext = appContext, + mediaUtilsWrapper = mediaUtilsWrapper, + appPrefsWrapper = appPrefsWrapper, + ioDispatcher = testDispatcher() + ) + + private fun wpComSite() = SiteModel().apply { setIsWPCom(true) } + + private fun selfHostedSite() = SiteModel().apply { setIsWPCom(false) } + + @Test + fun `image is optimized when optimization produces a new file`() = test { + val optimized = tempFolder.newFile("optimized.jpg") + val optimizedUri = fileUri(optimized) + whenever(mediaUtilsWrapper.getOptimizedMedia(stagedFile.absolutePath, false)) + .thenReturn(optimizedUri) + + val result = createProcessor().processFile(stagedFile, "image/jpeg", "photo.jpg") + + assertThat(result).isInstanceOf(ProcessedProxyFile.Processed::class.java) + result as ProcessedProxyFile.Processed + assertThat(result.file.absolutePath).isEqualTo(optimized.absolutePath) + assertThat(result.mimeType).isEqualTo("image/jpeg") + assertThat(result.filename).isEqualTo("photo.jpg") + } + + @Test + fun `image passes through when processing would be a no-op`() = test { + whenever(mediaUtilsWrapper.getOptimizedMedia(stagedFile.absolutePath, false)).thenReturn(null) + whenever(appPrefsWrapper.isStripImageLocation).thenReturn(false) + + val result = createProcessor(wpComSite()).processFile(stagedFile, "image/jpeg", "photo.jpg") + + assertThat(result).isEqualTo(ProcessedProxyFile.Original) + verify(mediaUtilsWrapper, never()).fixOrientationIssue(any(), any()) + } + + @Test + fun `gps is stripped onto a copy when strip enabled and optimization off`() = test { + whenever(mediaUtilsWrapper.getOptimizedMedia(stagedFile.absolutePath, false)).thenReturn(null) + whenever(appPrefsWrapper.isStripImageLocation).thenReturn(true) + + val result = createProcessor(wpComSite()).processFile(stagedFile, "image/jpeg", "photo.jpg") + + assertThat(result).isInstanceOf(ProcessedProxyFile.Processed::class.java) + result as ProcessedProxyFile.Processed + // Stripping must run on a copy, never on the staged file — Original passthrough would + // re-send the original request body and discard an in-place edit. + assertThat(result.file.absolutePath).isNotEqualTo(stagedFile.absolutePath) + assertThat(result.file.readText()).isEqualTo("staged-bytes") + assertThat(result.mimeType).isEqualTo("image/jpeg") + assertThat(result.filename).isEqualTo("photo.jpg") + verify(mediaUtilsWrapper).stripImageLocation(result.file.absolutePath) + result.file.delete() + } + + @Test + fun `gps is stripped from the optimized output when strip enabled`() = test { + val optimized = tempFolder.newFile("optimized.jpg") + val optimizedUri = fileUri(optimized) + whenever(mediaUtilsWrapper.getOptimizedMedia(stagedFile.absolutePath, false)) + .thenReturn(optimizedUri) + whenever(appPrefsWrapper.isStripImageLocation).thenReturn(true) + + createProcessor().processFile(stagedFile, "image/jpeg", "photo.jpg") + + verify(mediaUtilsWrapper).stripImageLocation(optimized.absolutePath) + } + + @Test + fun `heic reports jpeg mime type and extension after optimization`() = test { + val heicStaged = tempFolder.newFile("photo.heic") + val optimized = tempFolder.newFile("optimized.heic") + val optimizedUri = fileUri(optimized) + whenever(mediaUtilsWrapper.getOptimizedMedia(heicStaged.absolutePath, false)) + .thenReturn(optimizedUri) + + val result = createProcessor().processFile(heicStaged, "image/heic", "photo.heic") + + assertThat(result).isInstanceOf(ProcessedProxyFile.Processed::class.java) + result as ProcessedProxyFile.Processed + assertThat(result.mimeType).isEqualTo("image/jpeg") + assertThat(result.filename).isEqualTo("photo.jpg") + } + + @Test + fun `png keeps png mime type and extension after optimization`() = test { + val pngStaged = tempFolder.newFile("art.png") + val optimized = tempFolder.newFile("optimized.png") + val optimizedUri = fileUri(optimized) + whenever(mediaUtilsWrapper.getOptimizedMedia(pngStaged.absolutePath, false)) + .thenReturn(optimizedUri) + + val result = createProcessor().processFile(pngStaged, "image/png", "art.png") + + result as ProcessedProxyFile.Processed + assertThat(result.mimeType).isEqualTo("image/png") + assertThat(result.filename).isEqualTo("art.png") + } + + @Test + fun `gif passes through untouched`() = test { + val gifStaged = tempFolder.newFile("anim.gif") + + val result = createProcessor().processFile(gifStaged, "image/gif", "anim.gif") + + assertThat(result).isEqualTo(ProcessedProxyFile.Original) + verify(mediaUtilsWrapper, never()).getOptimizedMedia(any(), any()) + } + + @Test + fun `disallowed file type throws with localized message`() = test { + whenever(mediaUtilsWrapper.isMimeTypeSupportedBySitePlan(anyOrNull(), any())).thenReturn(false) + val zipStaged = tempFolder.newFile("archive.zip") + + val thrown = runCatching { + createProcessor().processFile(zipStaged, "application/zip", "archive.zip") + }.exceptionOrNull() + + assertThat(thrown) + .isInstanceOf(GBKMediaUploadException::class.java) + .hasMessage(FILE_TYPE_ERROR) + } + + @Test + fun `video exceeding duration limit throws with localized message`() = test { + whenever(mediaUtilsWrapper.isVideoMimeType("video/mp4")).thenReturn(true) + whenever(mediaUtilsWrapper.isProhibitedVideoDuration(any(), any(), any())).thenReturn(true) + val videoStaged = tempFolder.newFile("movie.mp4") + + val thrown = runCatching { + createProcessor().processFile(videoStaged, "video/mp4", "movie.mp4") + }.exceptionOrNull() + + assertThat(thrown) + .isInstanceOf(GBKMediaUploadException::class.java) + .hasMessage(VIDEO_LIMIT_ERROR) + } + + @Test + fun `video passes through when optimization disabled`() = test { + whenever(mediaUtilsWrapper.isVideoMimeType("video/mp4")).thenReturn(true) + whenever(mediaUtilsWrapper.isProhibitedVideoDuration(any(), any(), any())).thenReturn(false) + whenever(appPrefsWrapper.isVideoOptimize).thenReturn(false) + val videoStaged = tempFolder.newFile("movie.mp4") + + val result = createProcessor().processFile(videoStaged, "video/mp4", "movie.mp4") + + assertThat(result).isEqualTo(ProcessedProxyFile.Original) + } + + @Test + fun `optimization returning the input path is treated as not optimized`() = test { + // ImageUtils.optimizeImage returns the original path for skips/failures; wrapping it in + // Processed would mislabel the original file with a corrected mime type. + val inputPathUri = fileUri(stagedFile) + whenever(mediaUtilsWrapper.getOptimizedMedia(stagedFile.absolutePath, false)) + .thenReturn(inputPathUri) + whenever(appPrefsWrapper.isStripImageLocation).thenReturn(false) + + val result = createProcessor(wpComSite()).processFile(stagedFile, "image/jpeg", "photo.jpg") + + assertThat(result).isEqualTo(ProcessedProxyFile.Original) + } + + @Test + fun `self-hosted image is rotated when optimization is off`() = test { + whenever(mediaUtilsWrapper.getOptimizedMedia(stagedFile.absolutePath, false)).thenReturn(null) + val rotated = tempFolder.newFile("rotated.jpg") + val rotatedUri = fileUri(rotated) + whenever(mediaUtilsWrapper.fixOrientationIssue(stagedFile.absolutePath, false)) + .thenReturn(rotatedUri) + + val result = createProcessor(selfHostedSite()).processFile(stagedFile, "image/jpeg", "photo.jpg") + + assertThat(result).isInstanceOf(ProcessedProxyFile.Processed::class.java) + result as ProcessedProxyFile.Processed + assertThat(result.file.absolutePath).isEqualTo(rotated.absolutePath) + } + + @Test + fun `non-media file allowed by the site plan passes through`() = test { + val docStaged = tempFolder.newFile("doc.pdf") + + val result = createProcessor().processFile(docStaged, "application/pdf", "doc.pdf") + + assertThat(result).isEqualTo(ProcessedProxyFile.Original) + } + + private fun fileUri(file: File): android.net.Uri = mock { + on { path } doReturn file.absolutePath + } + + companion object { + private const val FILE_TYPE_ERROR = "This file type is not allowed" + private const val VIDEO_LIMIT_ERROR = "Uploading videos longer than 5 minutes requires a paid plan." + } +} From bb19d3c984d3d2300d22c2ac29da1cbff5da45b6 Mon Sep 17 00:00:00 2001 From: David Calhoun Date: Thu, 23 Jul 2026 10:28:48 -0400 Subject: [PATCH 05/10] feat: wire GBKMediaUploadProcessor into the GutenbergKit editor Sets the media upload delegate on GutenbergView so device media picked in the experimental block editor is processed per the app's media settings before upload. The fragment stores and forwards the delegate following the existing setNetworkRequestListener pattern (GutenbergView tolerates assignment before or after page load), and the activity constructs the processor with its SiteModel and injected wrappers. If GutenbergKit's upload server fails to start, uploads degrade to the existing WebView path unchanged. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../android/ui/posts/GutenbergKitActivity.kt | 15 +++++++++++++++ .../ui/posts/editor/GutenbergKitEditorFragment.kt | 10 ++++++++++ 2 files changed, 25 insertions(+) diff --git a/WordPress/src/main/java/org/wordpress/android/ui/posts/GutenbergKitActivity.kt b/WordPress/src/main/java/org/wordpress/android/ui/posts/GutenbergKitActivity.kt index 8af182756744..fd85f47a9e52 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/posts/GutenbergKitActivity.kt +++ b/WordPress/src/main/java/org/wordpress/android/ui/posts/GutenbergKitActivity.kt @@ -65,6 +65,7 @@ import org.wordpress.android.editor.EditorImagePreviewListener import org.wordpress.android.editor.EditorImageSettingsListener import org.wordpress.android.editor.ExceptionLogger import org.wordpress.android.editor.gutenberg.DialogVisibility +import org.wordpress.android.ui.posts.editor.GBKMediaUploadProcessor import org.wordpress.android.ui.posts.editor.GutenbergKitEditorFragment import org.wordpress.android.ui.posts.editor.GutenbergKitNetworkLogger import org.wordpress.android.editor.savedinstance.SavedInstanceDatabase @@ -172,6 +173,7 @@ import org.wordpress.android.ui.posts.reactnative.ReactNativeRequestHandler import org.wordpress.android.ui.posts.sharemessage.EditJetpackSocialShareMessageActivity import org.wordpress.android.ui.posts.sharemessage.EditJetpackSocialShareMessageActivity.Companion.createIntent import org.wordpress.android.ui.prefs.AppPrefs +import org.wordpress.android.ui.prefs.AppPrefsWrapper import org.wordpress.android.ui.prefs.SiteSettingsInterface import org.wordpress.android.ui.prefs.SiteSettingsInterface.SiteSettingsListener import org.wordpress.android.ui.reader.utils.ReaderUtilsWrapper @@ -193,6 +195,7 @@ import org.wordpress.android.util.DateTimeUtilsWrapper import org.wordpress.android.util.DisplayUtils import org.wordpress.android.util.FluxCUtils import org.wordpress.android.util.MediaUtils +import org.wordpress.android.util.MediaUtilsWrapper import org.wordpress.android.util.NetworkUtils import org.wordpress.android.util.ReblogUtils import org.wordpress.android.util.ShortcutUtils @@ -387,6 +390,8 @@ class GutenbergKitActivity : BaseAppCompatActivity(), EditorImageSettingsListene @Inject lateinit var editorJetpackSocialViewModel: EditorJetpackSocialViewModel @Inject lateinit var gutenbergKitNetworkLogger: GutenbergKitNetworkLogger @Inject lateinit var gutenbergKitSettingsBuilder: GutenbergKitSettingsBuilder + @Inject lateinit var mediaUtilsWrapper: MediaUtilsWrapper + @Inject lateinit var appPrefsWrapper: AppPrefsWrapper private lateinit var editPostNavigationViewModel: EditPostNavigationViewModel private lateinit var editPostSettingsViewModel: EditPostSettingsViewModel private lateinit var prepublishingViewModel: PrepublishingViewModel @@ -2260,6 +2265,16 @@ class GutenbergKitActivity : BaseAppCompatActivity(), EditorImageSettingsListene } ) } + + // Process device media per the app's media settings before upload + editorFragment?.setMediaUploadDelegate( + GBKMediaUploadProcessor( + site = siteModel, + appContext = applicationContext, + mediaUtilsWrapper = mediaUtilsWrapper, + appPrefsWrapper = appPrefsWrapper, + ) + ) } VIEW_PAGER_PAGE_SETTINGS -> editPostSettingsFragment = fragment as EditPostSettingsFragment } diff --git a/WordPress/src/main/java/org/wordpress/android/ui/posts/editor/GutenbergKitEditorFragment.kt b/WordPress/src/main/java/org/wordpress/android/ui/posts/editor/GutenbergKitEditorFragment.kt index 820975ce3ec2..393f080ea756 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/posts/editor/GutenbergKitEditorFragment.kt +++ b/WordPress/src/main/java/org/wordpress/android/ui/posts/editor/GutenbergKitEditorFragment.kt @@ -39,6 +39,7 @@ import org.wordpress.gutenberg.GutenbergView.LogJsExceptionListener import org.wordpress.gutenberg.GutenbergView.OpenMediaLibraryListener import org.wordpress.gutenberg.GutenbergView.TitleAndContentCallback import org.wordpress.gutenberg.Media +import org.wordpress.gutenberg.MediaUploadDelegate import org.wordpress.gutenberg.model.EditorConfiguration import java.util.concurrent.CountDownLatch import java.util.concurrent.TimeUnit @@ -61,6 +62,7 @@ class GutenbergKitEditorFragment : GutenbergKitEditorFragmentBase() { private var onLogJsExceptionListener: LogJsExceptionListener? = null private var modalDialogStateListener: GutenbergView.ModalDialogStateListener? = null private var networkRequestListener: GutenbergView.NetworkRequestListener? = null + private var mediaUploadDelegate: MediaUploadDelegate? = null private var rootView: View? = null private var isXPostsEnabled: Boolean = false @@ -224,6 +226,9 @@ class GutenbergKitEditorFragment : GutenbergKitEditorFragmentBase() { networkRequestListener?.let( gutenbergView::setNetworkRequestListener ) + mediaUploadDelegate?.let { + gutenbergView.mediaUploadDelegate = it + } // Set up content provider for WebView refresh recovery gutenbergView.setLatestContentProvider( @@ -552,6 +557,11 @@ class GutenbergKitEditorFragment : GutenbergKitEditorFragmentBase() { gutenbergView?.setNetworkRequestListener(listener) } + fun setMediaUploadDelegate(delegate: MediaUploadDelegate) { + mediaUploadDelegate = delegate + gutenbergView?.mediaUploadDelegate = delegate + } + override fun onUndoPressed() { gutenbergView?.undo() } From 898fb530788929aecacd1227f5b5a559b7d985b7 Mon Sep 17 00:00:00 2001 From: David Calhoun Date: Thu, 23 Jul 2026 15:58:38 -0400 Subject: [PATCH 06/10] build: bump WordPress-Utils to the orientation-fix snapshot Consumes the PR-build snapshot of WordPress-Utils-Android#156, which stops ImageUtils.getImageOrientation from logging a spurious "Volume data not found" error on every optimized upload staged in the app cache dir (GutenbergKit native media uploads). To be swapped to a tagged release once that PR merges. Co-Authored-By: Claude Opus 4.8 (1M context) --- gradle/libs.versions.toml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 8839290106ea..7fea53cea12b 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -103,7 +103,8 @@ wordpress-aztec = 'v2.1.4' wordpress-lint = '2.2.0' wordpress-persistent-edittext = '1.0.2' wordpress-rs = '0.6.0' -wordpress-utils = '3.14.0' +# TODO: Restore to a tagged release once WordPress-Utils-Android#156 merges (PR-build snapshot) +wordpress-utils = '156-2e542df55715dff22c21def058bea551ee9569d0' automattic-ucrop = '2.2.11' zendesk = '5.5.3' turbine = '1.2.1' From 70cf911324391094dfe0af508d543b5a41273390 Mon Sep 17 00:00:00 2001 From: David Calhoun Date: Fri, 24 Jul 2026 09:13:35 -0400 Subject: [PATCH 07/10] style: suppress ReturnCount on the processor's decision-table methods processVideo and processImage are guard-clause decision tables where early returns read clearer than nesting; suppress ReturnCount to match the existing house style rather than fragment the logic. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../android/ui/posts/editor/GBKMediaUploadProcessor.kt | 2 ++ 1 file changed, 2 insertions(+) diff --git a/WordPress/src/main/java/org/wordpress/android/ui/posts/editor/GBKMediaUploadProcessor.kt b/WordPress/src/main/java/org/wordpress/android/ui/posts/editor/GBKMediaUploadProcessor.kt index a18e54428fdf..e9ee71723b46 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/posts/editor/GBKMediaUploadProcessor.kt +++ b/WordPress/src/main/java/org/wordpress/android/ui/posts/editor/GBKMediaUploadProcessor.kt @@ -77,6 +77,7 @@ class GBKMediaUploadProcessor( } } + @Suppress("ReturnCount") private suspend fun processVideo(file: File, filename: String): ProcessedProxyFile { if (mediaUtilsWrapper.isProhibitedVideoDuration(appContext, site, file)) { throw GBKMediaUploadException( @@ -163,6 +164,7 @@ class GBKMediaUploadProcessor( composer.start() } + @Suppress("ReturnCount") private fun processImage(file: File, mimeType: String, filename: String): ProcessedProxyFile { // getOptimizedMedia returns null when optimization is disabled or a no-op. It can also // return the *input* path unchanged (GIF-like skips, decode failures inside From 85831df74f4c6f4286933cd5975986bbc9f91fd9 Mon Sep 17 00:00:00 2001 From: David Calhoun Date: Fri, 24 Jul 2026 15:06:15 -0400 Subject: [PATCH 08/10] fix: strip location only from ExifInterface-writable image formats EXIF_MIME_TYPES gated the copy-and-strip branch on formats that can carry GPS, but androidx ExifInterface.saveAttributes() can only rewrite JPEG, PNG, and WebP. The set omitted PNG (so location-bearing PNGs uploaded un-stripped, a regression vs. the legacy pipeline) and included HEIC/HEIF (where saveAttributes throws an IOException that stripLocation swallows, uploading a still-geotagged copy while appearing to honor the setting). Correct the set to {JPEG, PNG, WebP} and cover the PNG-stripped and HEIC-passthrough cases in the decision-table tests. Co-Authored-By: Claude Opus 4.8 --- .../posts/editor/GBKMediaUploadProcessor.kt | 9 ++++-- .../editor/GBKMediaUploadProcessorTest.kt | 30 +++++++++++++++++++ 2 files changed, 37 insertions(+), 2 deletions(-) diff --git a/WordPress/src/main/java/org/wordpress/android/ui/posts/editor/GBKMediaUploadProcessor.kt b/WordPress/src/main/java/org/wordpress/android/ui/posts/editor/GBKMediaUploadProcessor.kt index e9ee71723b46..fc2b06e5c573 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/posts/editor/GBKMediaUploadProcessor.kt +++ b/WordPress/src/main/java/org/wordpress/android/ui/posts/editor/GBKMediaUploadProcessor.kt @@ -240,8 +240,13 @@ class GBKMediaUploadProcessor( private const val MIME_MP4 = "video/mp4" private const val MIME_OCTET_STREAM = "application/octet-stream" - /** Image formats that can carry EXIF GPS metadata. */ - private val EXIF_MIME_TYPES = setOf(MIME_JPEG, "image/heic", "image/heif", "image/webp") + /** + * Formats androidx ExifInterface can actually strip GPS from: saveAttributes() supports + * only JPEG, PNG, and WebP. HEIC/HEIF are deliberately excluded — the library throws an + * IOException (swallowed by stripLocation), so listing them would make a doomed copy and + * upload a still-geotagged file while appearing to honor the strip-location setting. + */ + private val EXIF_MIME_TYPES = setOf(MIME_JPEG, MIME_PNG, "image/webp") } } diff --git a/WordPress/src/test/java/org/wordpress/android/ui/posts/editor/GBKMediaUploadProcessorTest.kt b/WordPress/src/test/java/org/wordpress/android/ui/posts/editor/GBKMediaUploadProcessorTest.kt index 29d3789846cc..e1bcbffb7456 100644 --- a/WordPress/src/test/java/org/wordpress/android/ui/posts/editor/GBKMediaUploadProcessorTest.kt +++ b/WordPress/src/test/java/org/wordpress/android/ui/posts/editor/GBKMediaUploadProcessorTest.kt @@ -119,6 +119,36 @@ class GBKMediaUploadProcessorTest : BaseUnitTest() { verify(mediaUtilsWrapper).stripImageLocation(optimized.absolutePath) } + @Test + fun `png gps is stripped onto a copy when strip enabled and optimization off`() = test { + val pngStaged = tempFolder.newFile("art.png") + whenever(mediaUtilsWrapper.getOptimizedMedia(pngStaged.absolutePath, false)).thenReturn(null) + whenever(appPrefsWrapper.isStripImageLocation).thenReturn(true) + + // androidx ExifInterface can write PNG, so PNG must take the copy-and-strip branch. + val result = createProcessor(wpComSite()).processFile(pngStaged, "image/png", "art.png") + + assertThat(result).isInstanceOf(ProcessedProxyFile.Processed::class.java) + result as ProcessedProxyFile.Processed + assertThat(result.file.absolutePath).isNotEqualTo(pngStaged.absolutePath) + verify(mediaUtilsWrapper).stripImageLocation(result.file.absolutePath) + result.file.delete() + } + + @Test + fun `heic passes through when strip enabled and optimization off`() = test { + val heicStaged = tempFolder.newFile("photo.heic") + whenever(mediaUtilsWrapper.getOptimizedMedia(heicStaged.absolutePath, false)).thenReturn(null) + whenever(appPrefsWrapper.isStripImageLocation).thenReturn(true) + + // androidx ExifInterface cannot write HEIF, so copy-and-strip would silently fail and + // upload a still-geotagged copy — HEIC must not take the strip branch. + val result = createProcessor(wpComSite()).processFile(heicStaged, "image/heic", "photo.heic") + + assertThat(result).isEqualTo(ProcessedProxyFile.Original) + verify(mediaUtilsWrapper, never()).stripImageLocation(any()) + } + @Test fun `heic reports jpeg mime type and extension after optimization`() = test { val heicStaged = tempFolder.newFile("photo.heic") From ada39320207789c5347708bbcf4e575594746a99 Mon Sep 17 00:00:00 2001 From: David Calhoun Date: Fri, 24 Jul 2026 15:17:27 -0400 Subject: [PATCH 09/10] style: drop stale SwallowedException suppression on transcodeVideo MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit transcodeVideo logs each caught exception via .message, so Detekt's SwallowedException never fires — only TooGenericExceptionCaught does (NullPointerException and Exception are both on its generic-names list). Trim the suppression to the rule that actually triggers. Co-Authored-By: Claude Opus 4.8 --- .../android/ui/posts/editor/GBKMediaUploadProcessor.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/WordPress/src/main/java/org/wordpress/android/ui/posts/editor/GBKMediaUploadProcessor.kt b/WordPress/src/main/java/org/wordpress/android/ui/posts/editor/GBKMediaUploadProcessor.kt index fc2b06e5c573..27810da7a9dc 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/posts/editor/GBKMediaUploadProcessor.kt +++ b/WordPress/src/main/java/org/wordpress/android/ui/posts/editor/GBKMediaUploadProcessor.kt @@ -108,7 +108,7 @@ class GBKMediaUploadProcessor( * [org.wordpress.android.ui.uploads.VideoOptimizer] semantics: any failure (no composer, * m4m error) resolves to null so the caller falls back to uploading the original. */ - @Suppress("TooGenericExceptionCaught", "SwallowedException") + @Suppress("TooGenericExceptionCaught") private suspend fun transcodeVideo(input: File): File? = suspendCancellableCoroutine { continuation -> val output = File(appContext.cacheDir, MediaUtils.generateTimeStampedFileName(MIME_MP4)) val listener = object : IProgressListener { From d70d4a0815c3afb80a582c73133fe9bd5c673a25 Mon Sep 17 00:00:00 2001 From: David Calhoun Date: Fri, 24 Jul 2026 15:49:16 -0400 Subject: [PATCH 10/10] docs: clarify the processFile plan check is a cache-aware fallback GutenbergKit validates uploads in the WebView against the site's allowedMimeTypes (fetched from /wp-block-editor/v1/settings and cached on disk) before the request reaches this delegate, so it rejects most disallowed types with its own localized message first. Document that this app-side check is a fallback for the cache-miss/undefined-settings path and can over-reject when its static MimeTypes table is stricter than the server's cached list. Co-Authored-By: Claude Opus 4.8 --- .../android/ui/posts/editor/GBKMediaUploadProcessor.kt | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/WordPress/src/main/java/org/wordpress/android/ui/posts/editor/GBKMediaUploadProcessor.kt b/WordPress/src/main/java/org/wordpress/android/ui/posts/editor/GBKMediaUploadProcessor.kt index 27810da7a9dc..295e852c3780 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/posts/editor/GBKMediaUploadProcessor.kt +++ b/WordPress/src/main/java/org/wordpress/android/ui/posts/editor/GBKMediaUploadProcessor.kt @@ -61,8 +61,14 @@ class GBKMediaUploadProcessor( ): ProcessedProxyFile = withContext(ioDispatcher) { val resolvedMimeType = resolveMimeType(mimeType, filename) - // Reject types the site's plan doesn't allow (e.g. audio on free WP.com plans) with a - // localized message instead of an opaque server-side error. + // Fallback plan check. GutenbergKit's editor validates uploads in the WebView against the + // site's allowedMimeTypes (from /wp-block-editor/v1/settings) before the request reaches + // this delegate, so for most disallowed types the editor rejects with its own localized + // message first. Those settings are cached on disk and reused on later opens, so GB + // validates against whatever mime list was cached — not necessarily the site's current + // one. This check still fires when GB's list is absent (cache miss where editor settings + // resolve to undefined) or when the app's static MimeTypes table is stricter than the + // server's list — in which case it can over-reject a type the server would accept. if (!mediaUtilsWrapper.isMimeTypeSupportedBySitePlan(site, resolvedMimeType)) { throw GBKMediaUploadException(appContext.getString(R.string.error_media_file_type_not_allowed)) }