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..e790cc281de3 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,8 @@ 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.SiteUtilsWrapper import org.wordpress.android.util.NetworkUtils import org.wordpress.android.util.ReblogUtils import org.wordpress.android.util.ShortcutUtils @@ -205,6 +209,7 @@ import org.wordpress.android.util.WPMediaUtils import org.wordpress.android.util.WPPermissionUtils import org.wordpress.android.util.WPUrlUtils import org.wordpress.android.util.analytics.AnalyticsTrackerWrapper +import org.wordpress.android.util.analytics.AnalyticsUtilsWrapper import org.wordpress.android.util.analytics.AnalyticsUtils import org.wordpress.android.util.analytics.AnalyticsUtils.BlockEditorEnabledSource import org.wordpress.android.util.config.ContactSupportFeatureConfig @@ -357,6 +362,7 @@ class GutenbergKitActivity : BaseAppCompatActivity(), EditorImageSettingsListene @Inject lateinit var reblogUtils: ReblogUtils @Inject lateinit var analyticsTrackerWrapper: AnalyticsTrackerWrapper + @Inject lateinit var analyticsUtilsWrapper: AnalyticsUtilsWrapper @Inject lateinit var publishPostImmediatelyUseCase: PublishPostImmediatelyUseCase @@ -387,6 +393,9 @@ 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 siteUtilsWrapper: SiteUtilsWrapper + @Inject lateinit var appPrefsWrapper: AppPrefsWrapper private lateinit var editPostNavigationViewModel: EditPostNavigationViewModel private lateinit var editPostSettingsViewModel: EditPostSettingsViewModel private lateinit var prepublishingViewModel: PrepublishingViewModel @@ -2260,6 +2269,19 @@ 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, + siteUtilsWrapper = siteUtilsWrapper, + analyticsTrackerWrapper = analyticsTrackerWrapper, + analyticsUtilsWrapper = analyticsUtilsWrapper, + ) + ) } VIEW_PAGER_PAGE_SETTINGS -> editPostSettingsFragment = fragment as EditPostSettingsFragment } 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..7870807d6580 --- /dev/null +++ b/WordPress/src/main/java/org/wordpress/android/ui/posts/editor/GBKMediaUploadProcessor.kt @@ -0,0 +1,516 @@ +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.analytics.AnalyticsTracker +import org.wordpress.android.util.MediaUtilsWrapper +import org.wordpress.android.util.SiteUtilsWrapper +import org.wordpress.android.util.WPVideoUtils +import org.wordpress.android.util.analytics.AnalyticsTrackerWrapper +import org.wordpress.android.util.analytics.AnalyticsUtilsWrapper +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. + */ +@Suppress("LongParameterList") +class GBKMediaUploadProcessor( + private val site: SiteModel, + private val appContext: Context, + private val mediaUtilsWrapper: MediaUtilsWrapper, + private val appPrefsWrapper: AppPrefsWrapper, + private val siteUtilsWrapper: SiteUtilsWrapper, + private val analyticsTrackerWrapper: AnalyticsTrackerWrapper, + private val analyticsUtilsWrapper: AnalyticsUtilsWrapper, + private val ioDispatcher: CoroutineDispatcher = Dispatchers.IO, +) : MediaUploadDelegate { + /** + * Metadata-only gate GutenbergKit consults before copying an upload to a temp file. Declining + * makes it relay the original request body straight to WordPress, skipping a copy this + * delegate would not have used — GIFs, non-media, and any media whose processing is switched + * off would otherwise pay a full byte-for-byte copy only to be passed through. + * + * The media branches mirror the decisions [processVideo] and [processImage] actually make + * (see [needsVideoFile] / [needsImageFile]) rather than claiming every image and video, so a + * site with optimization off does not copy uploads it will never touch. + * + * This is an optimization hint, never the enforcement point. It sees only the client-supplied + * mime type and filename, which can disagree with the file's actual bytes, so the free-plan + * check inside [processFile] stays authoritative — the copy here is a fast path, not a + * replacement. + */ + @Suppress("ReturnCount") + override fun handlesFile(mimeType: String, filename: String): Boolean { + val resolvedMimeType = resolveMimeType(mimeType, filename) + + // Claim plan-rejected types so processFile still runs and throws the localized rejection. + // Declining would forward them to WordPress instead, spending a full upload on a file the + // site's plan won't accept and surfacing the server's untranslated error in place of ours. + if (isRejectedByFreePlan(resolvedMimeType)) return true + + return when { + // Never re-encoded; processFile always returns Original. + resolvedMimeType == MIME_GIF -> false + mediaUtilsWrapper.isVideoMimeType(resolvedMimeType) -> needsVideoFile() + resolvedMimeType.startsWith(MIME_IMAGE_PREFIX) -> needsImageFile(resolvedMimeType) + // Non-media files (documents, archives, audio on paid plans) upload unchanged. + else -> false + } + } + + /** + * Whether [processVideo] would actually read the staged file, mirroring its two exits: + * the duration check (which only measures on a free plan without VideoPress — see + * [MediaUtilsWrapper.isProhibitedVideoDuration]) and the transcode. + * + * With video optimization off on a paid or self-hosted site — a common configuration, since + * optimization is opt-in — the method returns [ProcessedProxyFile.Original] without touching + * the file, so claiming it would make GutenbergKit write a full byte-for-byte copy of the + * upload to the cache dir for nothing. For a multi-gigabyte video that is gigabytes of I/O + * and a plausible ENOSPC. + */ + private fun needsVideoFile(): Boolean { + val durationCheckApplies = site.hasFreePlan && !site.isActiveModuleEnabled(VIDEOPRESS_MODULE) + return durationCheckApplies || appPrefsWrapper.isVideoOptimize + } + + /** + * Whether [processImage] would actually read the staged file, mirroring its three exits: + * optimization ([WPMediaUtils.getOptimizedMedia] short-circuits when the pref is off), + * the self-hosted rotation fallback for issue #5737, and the EXIF location strip (which only + * copies for the formats androidx ExifInterface can rewrite — see [EXIF_MIME_TYPES]). + * + * All three must be inapplicable to decline. Dropping the rotation term would regress #5737 + * parity on self-hosted sites, where the server does not rotate for us. + */ + private fun needsImageFile(resolvedMimeType: String): Boolean { + val stripsLocation = appPrefsWrapper.isStripImageLocation && resolvedMimeType in EXIF_MIME_TYPES + return appPrefsWrapper.isImageOptimize || !site.isWPCom || stripsLocation + } + + override suspend fun processFile( + file: File, + mimeType: String, + filename: String + ): ProcessedProxyFile = withContext(ioDispatcher) { + val resolvedMimeType = resolveMimeType(mimeType, filename) + + if (isRejectedByFreePlan(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, resolvedMimeType, filename) + resolvedMimeType.startsWith(MIME_IMAGE_PREFIX) -> processImage(file, resolvedMimeType, filename) + // Non-media files (documents, archives, audio on paid plans) upload unchanged. + else -> ProcessedProxyFile.Original + } + } + + /** + * Rejects the one upload class the app can judge better than the server: audio and documents + * on a free WordPress.com plan, where the restriction is a plan entitlement rather than a + * format question, so a localized message beats the server's untranslated error. + * + * Deliberately narrow. [MediaUtilsWrapper.isMimeTypeSupportedBySitePlan] matches against a + * closed, hand-maintained table ([org.wordpress.android.fluxc.utils.MimeTypes]) that has + * drifted from what WordPress accepts — it has no `image/avif` (core-supported since 6.5), no + * `image/svg+xml`, and no text types at all, and it maps self-hosted to the same document set + * as WP.com paid. Applying it to every upload therefore rejects files the server would store. + * GutenbergKit already validates against the site's real `allowedMimeTypes` from + * `/wp-block-editor/v1/settings` before this delegate runs, so images and videos are left to + * that check and to the server, which are both authoritative where this table is not. + * + * The free-plan test mirrors [WPMediaUtils.getSitePlanForMimeTypes], which selects + * `WP_COM_FREE` from [SiteUtilsWrapper.onFreePlan] — using `hasFreePlan` here instead would + * let the gate and the allowlist it guards disagree. + * + * [MIME_OCTET_STREAM] is excluded because [resolveMimeType] emits it for uploads whose type + * could not be resolved at all; it counts as an `application` type but means "unknown bytes", + * and rejecting it would answer "we could not identify this file" with "this file type is not + * allowed". + */ + private fun isRejectedByFreePlan(resolvedMimeType: String): Boolean { + if (!site.isWPCom || !siteUtilsWrapper.onFreePlan(site)) return false + + val isPlanRestrictedType = resolvedMimeType != MIME_OCTET_STREAM && + (mediaUtilsWrapper.isAudioMimeType(resolvedMimeType) || + mediaUtilsWrapper.isApplicationMimeType(resolvedMimeType)) + + return isPlanRestrictedType && !mediaUtilsWrapper.isMimeTypeSupportedBySitePlan(site, resolvedMimeType) + } + + @Suppress("ReturnCount") + private suspend fun processVideo(file: File, mimeType: String, filename: String): ProcessedProxyFile { + // Pass the resolved mime type rather than letting the check re-derive "is this a video" + // from the staged file: that path is an extension-only test, and GutenbergKit names the + // staged copy after the client-supplied filename, which need not carry one. + if (mediaUtilsWrapper.isProhibitedVideoDuration(appContext, site, file, mimeType)) { + 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") + private suspend fun transcodeVideo(input: File): File? = suspendCancellableCoroutine { continuation -> + // createTempFile rather than MediaUtils.generateTimeStampedFileName, which is only + // "wp-{currentTimeMillis}.mp4" and collides for two transcodes started in the same + // millisecond. Uniqueness comes from the filesystem, with no check-then-create race. + val output = File.createTempFile("wp-", ".mp4", appContext.cacheDir) + val startTimeMs = System.currentTimeMillis() + 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() { + trackTranscodeFinished(input, output, startTimeMs, null) + if (continuation.isActive) continuation.resume(output) + } + + override fun onError(exception: Exception) { + AppLog.e(AppLog.T.MEDIA, "GBKMediaUploadProcessor > video transcode failed", exception) + trackTranscodeFinished(input, output, startTimeMs, exception) + output.delete() + if (continuation.isActive) continuation.resume(null) + } + } + + var wasNpeDetected = false + 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}") + wasNpeDetected = true + null + } + + if (composer == null) { + trackCantOptimize(input, wasNpeDetected) + 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() + } + + // m4m throws IllegalStateException from start() when it cannot set up the codec (codec + // unavailable, memory pressure, unsupported track config). Without this guard the + // exception escapes the coroutine and GutenbergKit's catch-all turns it into a 500 whose + // raw, untranslated m4m message is shown to the user — losing an upload the legacy + // pipeline would have completed with the original file. Guard it as VideoOptimizer does. + try { + composer.start() + } catch (e: IllegalStateException) { + AppLog.e(AppLog.T.MEDIA, "GBKMediaUploadProcessor > failed to start composer", e) + output.delete() + if (continuation.isActive) continuation.resume(null) + } + } + + /** + * Mirrors [org.wordpress.android.ui.uploads.VideoOptimizer]'s null-composer event so a + * GutenbergKit transcode that never starts is as visible in telemetry as a legacy one. + */ + private fun trackCantOptimize(input: File, wasNpeDetected: Boolean) { + val properties = analyticsUtilsWrapper.getMediaProperties(true, null, input.absolutePath) + properties["was_npe_detected"] = wasNpeDetected + properties[PROPERTY_OPTIMIZER_LIB] = OPTIMIZER_LIB_M4M + analyticsTrackerWrapper.track(AnalyticsTracker.Stat.MEDIA_VIDEO_CANT_OPTIMIZE, properties) + } + + /** + * Mirrors [org.wordpress.android.ui.uploads.VideoOptimizer.trackVideoProcessingEvents], + * including its `input_video_`/`output_video_` property prefixes, so GutenbergKit transcodes + * are comparable with legacy ones while the rollout measures parity. + * + * Output properties are only attached on success: on failure the output file is deleted + * moments later and its size would be meaningless. + */ + private fun trackTranscodeFinished(input: File, output: File, startTimeMs: Long, exception: Exception?) { + val properties = mutableMapOf() + analyticsUtilsWrapper.getMediaProperties(true, null, input.absolutePath) + .forEach { (key, value) -> properties["input_video_$key"] = value } + + if (exception == null) { + analyticsUtilsWrapper.getMediaProperties(true, null, output.absolutePath) + .forEach { (key, value) -> properties["output_video_$key"] = value } + properties["saved_megabytes"] = ((input.length() - output.length()) / BYTES_PER_MEGABYTE).toString() + } else { + properties["exception_name"] = exception.javaClass.canonicalName + properties["exception_message"] = exception.message + } + + properties["elapsed_time_ms"] = System.currentTimeMillis() - startTimeMs + properties[PROPERTY_OPTIMIZER_LIB] = OPTIMIZER_LIB_M4M + + val stat = if (exception == null) { + AnalyticsTracker.Stat.MEDIA_VIDEO_OPTIMIZED + } else { + AnalyticsTracker.Stat.MEDIA_VIDEO_OPTIMIZE_ERROR + } + analyticsTrackerWrapper.track(stat, properties) + } + + @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 + // ImageUtils.optimizeImage) — treat that as "not optimized" too, otherwise the original + // file would be mislabeled with a corrected JPEG mime type below. + val optimized = mediaUtilsWrapper.getOptimizedMedia(file.absolutePath, false) + .toDistinctExistingFile(file) + + if (optimized != null) { + return processedImage(optimized, 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 rotated = mediaUtilsWrapper.fixOrientationIssue(file.absolutePath, false) + .toDistinctExistingFile(file) + if (rotated != null) { + return processedImage(rotated, 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) + // A failed copy (a full disk being the likely cause) would otherwise leave the + // just-created temp file behind in the cache dir: GutenbergKit only deletes the files + // it is handed back, and this one never gets returned. + runCatching { file.copyTo(copy, overwrite = true) }.onFailure { + copy.delete() + throw it + } + 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 + } + + /** + * Resolves an image-processing result to a usable output file, or null to fall back to the + * original upload. + * + * Both [WPMediaUtils.getOptimizedMedia] and [WPMediaUtils.fixOrientationIssue] return the + * *input* path unchanged in their no-op cases (GIF-like skips, decode failures inside + * ImageUtils, no rotation needed), which must be read as "not processed" — otherwise the + * original file would be relabeled with a corrected JPEG mime type by [processedImage]. + * + * The existence check is the backstop: GutenbergKit names the staged file after the + * client-supplied multipart filename, which is far less constrained than the MediaStore paths + * the legacy callers pass in, so a path that cannot be resolved degrades to a clean + * passthrough instead of an upload of a file that is not there. + */ + @Suppress("ReturnCount") + private fun Uri?.toDistinctExistingFile(input: File): File? { + val resolvedPath = this?.path ?: return null + if (resolvedPath == input.absolutePath) return null + + val output = File(resolvedPath) + if (!output.exists()) { + AppLog.w( + AppLog.T.MEDIA, + "GBKMediaUploadProcessor > processed image path does not exist, using the original" + ) + return null + } + return output + } + + /** + * 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, so the metadata sent to WordPress must reflect the + * actual output format. + * + * The format is read off the *output* file rather than predicted from the declared input mime + * type. ImageUtils picks its encoder from the extension it derives for the output + * (`resizeImageAndWriteToStream` writes PNG only when that extension is literally "png") and + * names the output file with the same extension, so the written name is a faithful record of + * the encode decision. The declared input mime is not: for an extensionless upload + * `MediaUtils.getMediaFileName` supplies an extension sniffed from the bytes, which can + * disagree with what the client declared. Labeling from the input therefore produced JPEG + * bytes tagged `image/png` (and the reverse) — a mislabel WordPress then stores permanently. + */ + private fun processedImage(output: File, 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 (output.extension.lowercase() == EXTENSION_PNG) { + ProcessedProxyFile.Processed(output, MIME_PNG, "$basename.png") + } else { + ProcessedProxyFile.Processed(output, MIME_JPEG, "$basename.jpg") + } + } + + /** + * Normalizes the client-supplied mime type, falling back to the filename extension when it + * carries no usable information. + * + * The result feeds [isRejectedByFreePlan] and the type routing below. The plan check it + * performs is an exact, case-sensitive match against a closed allowlist, so anything but a + * bare lowercase `type/subtype` is rejected outright. Two shapes reach us that it would miss: + * - Parameters and casing: `Content-Type` may legitimately carry parameters + * (`image/jpeg; charset=binary`) and its casing is not significant (RFC 9110 §8.3). + * - Missing header: GutenbergKit's multipart parser defaults a part with no `Content-Type` + * to `text/plain` (RFC 7578 §4.4), and it picks the file part by the presence of a + * `filename` parameter, not by content type — so a real image can arrive labeled + * `text/plain`. Treat that like the other placeholders and fall back to the extension. + */ + private fun resolveMimeType(mimeType: String, filename: String): String { + val normalized = mimeType.substringBefore(';').trim().lowercase() + if (normalized.isNotBlank() && normalized !in PLACEHOLDER_MIME_TYPES) return normalized + + val extension = filename.substringAfterLast('.', "").lowercase() + // Fall back to the declared type when the extension resolves to nothing: it is a + // placeholder, but a placeholder the plan check can still reject coherently, whereas an + // empty string is neither. getSingleton() is @NonNull on device but null under the unit + // test stubs, so it is treated as an unresolvable lookup rather than dereferenced. + val fromExtension = MimeTypeMap.getSingleton()?.getMimeTypeFromExtension(extension) + return fromExtension ?: normalized.ifBlank { MIME_OCTET_STREAM } + } + + companion object { + /** + * 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. + * + * Process-wide rather than per-instance: a new processor is constructed for every editor + * fragment (see GutenbergKitActivity's SectionsPagerAdapter), and GutenbergKitActivity has + * no launchMode, so instances stack. A per-instance mutex would let two editors transcode + * in parallel — exactly what this exists to prevent. + */ + private val transcodeMutex = Mutex() + + /** Site module that lifts the free-plan video duration limit. */ + private const val VIDEOPRESS_MODULE = "videopress" + + /** Matches VideoOptimizer's analytics so GutenbergKit and legacy transcodes compare. */ + private const val PROPERTY_OPTIMIZER_LIB = "optimizer_lib" + private const val OPTIMIZER_LIB_M4M = "m4m" + private const val BYTES_PER_MEGABYTE = 1024 * 1024 + + 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" + private const val MIME_TEXT_PLAIN = "text/plain" + + /** The one extension ImageUtils treats as "encode as PNG"; everything else becomes JPEG. */ + private const val EXTENSION_PNG = "png" + + /** + * Mime types that carry no usable type information for an upload, so [resolveMimeType] + * prefers the filename extension over them. `application/octet-stream` is the generic + * "unknown bytes" type; `text/plain` is the multipart default for a part that sent no + * `Content-Type` header at all. + */ + private val PLACEHOLDER_MIME_TYPES = setOf(MIME_OCTET_STREAM, MIME_TEXT_PLAIN) + + /** + * 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") + } +} + +/** + * 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) 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..320efc446b12 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 @@ -190,6 +192,14 @@ class GutenbergKitEditorFragment : GutenbergKitEditorFragmentBase() { context = requireContext() ) + // Must be set before the editor loads: GutenbergKit captures the delegate when the page + // begins loading and throws from the setter afterward. The constructor already kicks off + // the load when dependencies are preloaded, so assign it here rather than alongside the + // listeners below. + mediaUploadDelegate?.let { + gutenbergView.mediaUploadDelegate = it + } + gutenbergViewContainer.addView( gutenbergView, FrameLayout.LayoutParams( @@ -552,6 +562,29 @@ class GutenbergKitEditorFragment : GutenbergKitEditorFragmentBase() { gutenbergView?.setNetworkRequestListener(listener) } + /** + * Sets the delegate that processes media before upload. Must be called before [onCreateView], + * which is the only place the delegate reaches the view: GutenbergKit captures it when the + * editor page begins loading and throws from its setter afterward, so pushing it into a live + * view here would crash. The field is the single source of truth. + * + * Unlike the other hooks here ([setNetworkRequestListener], [setImageLoader]), this one cannot + * push into an already-created view, so arriving late is a silent no-op: uploads fall back to + * GutenbergKit's unprocessed WebView path with no error. That can only happen if the view was + * created before the delegate was assigned — e.g. a configuration change restoring the fragment + * ahead of a deferred setupViewPager() callback. Log it rather than let it pass unnoticed. + */ + fun setMediaUploadDelegate(delegate: MediaUploadDelegate) { + if (gutenbergView != null) { + AppLog.w( + AppLog.T.MEDIA, + "GutenbergKitEditorFragment: media upload delegate set after the view was created" + + " - uploads will bypass the app's media settings for this session" + ) + } + mediaUploadDelegate = delegate + } + override fun onUndoPressed() { gutenbergView?.undo() } 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 8fc18b7bf05e..46e4323281e4 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,21 @@ class AppPrefsWrapper @Inject constructor(val buildConfigWrapper: BuildConfigWra get() = AppPrefs.isAztecEditorEnabled() set(enabled) = AppPrefs.setAztecEditorEnabled(enabled) + val isImageOptimize: Boolean + get() = AppPrefs.isImageOptimize() + + 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..fa09667c825b 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 @@ -35,6 +36,15 @@ class MediaUtilsWrapper @Inject constructor(private val appContext: Context) { fun isVideoMimeType(mimeType: String?): Boolean = org.wordpress.android.fluxc.utils.MediaUtils.isVideoMimeType(mimeType) + fun isAudioMimeType(mimeType: String?): Boolean = + org.wordpress.android.fluxc.utils.MediaUtils.isAudioMimeType(mimeType) + + fun isApplicationMimeType(mimeType: String?): Boolean = + org.wordpress.android.fluxc.utils.MediaUtils.isApplicationMimeType(mimeType) + + fun stripImageLocation(imagePath: String) = + org.wordpress.android.fluxc.utils.MediaUtils.stripLocation(imagePath) + fun isInMediaStore(mediaUri: Uri?): Boolean = MediaUtils.isInMediaStore(mediaUri) @@ -60,8 +70,28 @@ class MediaUtilsWrapper @Inject constructor(private val appContext: Context) { fun isVideoFile(mediaUri: Uri): Boolean = isVideo(mediaUri) || isVideoMimeType(getMimeType(mediaUri)) - fun isProhibitedVideoDuration(context: Context, site: SiteModel, uri: Uri): Boolean { - if (isVideoFile(uri) && site.hasFreePlan && !site.isActiveModuleEnabled("videopress")) { + /** + * Duration check for a file whose type is already known from its upload metadata. + * + * Callers must pass the mime type they resolved, because the [Uri] overload cannot recover it + * here: a `file://` URI makes [getMimeType] return null (ContentResolver only types content + * URIs), collapsing [isVideoFile] to [MediaUtils.isVideo] — an extension-only test. A video + * whose filename carries no recognized extension would then skip the check entirely and let a + * free site upload a video over the limit. + */ + fun isProhibitedVideoDuration(context: Context, site: SiteModel, file: File, mimeType: String): Boolean = + isProhibitedVideoDuration(context, site, Uri.fromFile(file), isVideoMimeType(mimeType)) + + fun isProhibitedVideoDuration(context: Context, site: SiteModel, uri: Uri): Boolean = + isProhibitedVideoDuration(context, site, uri, isVideoFile(uri)) + + private fun isProhibitedVideoDuration( + context: Context, + site: SiteModel, + uri: Uri, + isVideo: Boolean + ): Boolean { + if (isVideo && site.hasFreePlan && !site.isActiveModuleEnabled("videopress")) { val retriever = MediaMetadataRetriever() try { diff --git a/WordPress/src/main/java/org/wordpress/android/util/WPMediaUtils.java b/WordPress/src/main/java/org/wordpress/android/util/WPMediaUtils.java index e37725a067da..9b882e8741bb 100644 --- a/WordPress/src/main/java/org/wordpress/android/util/WPMediaUtils.java +++ b/WordPress/src/main/java/org/wordpress/android/util/WPMediaUtils.java @@ -92,7 +92,11 @@ public static Uri getOptimizedMedia(Context context, String path, boolean isVide ExifUtils.writeExifData(exifData, optimizedPath); AnalyticsTracker.track(AnalyticsTracker.Stat.MEDIA_PHOTO_OPTIMIZED); - return Uri.parse(optimizedPath); + // fromFile, not parse: these are filesystem paths, and Uri.parse reads everything + // after a '#' as a fragment and after a '?' as a query, so a filename containing + // either (e.g. "IMG_#1.jpg") yields a Uri whose getPath() is truncated to a file that + // does not exist. fromFile encodes the path instead of parsing it. + return Uri.fromFile(new File(optimizedPath)); } return null; } @@ -104,7 +108,9 @@ public static Uri fixOrientationIssue(Context context, String path, boolean isVi String rotatedPath = ImageUtils.rotateImageIfNecessary(context, path); if (rotatedPath != null) { - return Uri.parse(rotatedPath); + // See getOptimizedMedia above: fromFile encodes a filesystem path, Uri.parse would + // truncate it at a '#' or '?' in the filename. + return Uri.fromFile(new File(rotatedPath)); } return null; 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..d93aa6bf8d66 --- /dev/null +++ b/WordPress/src/test/java/org/wordpress/android/ui/posts/editor/GBKMediaUploadProcessorTest.kt @@ -0,0 +1,630 @@ +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.mockito.kotlin.any +import org.mockito.kotlin.anyOrNull +import org.mockito.kotlin.doReturn +import org.mockito.kotlin.eq +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.android.util.SiteUtilsWrapper +import org.wordpress.android.util.analytics.AnalyticsTrackerWrapper +import org.wordpress.android.util.analytics.AnalyticsUtilsWrapper +import org.wordpress.gutenberg.ProcessedProxyFile +import java.io.File + +// BaseUnitTest carries this too, but Kotlin's opt-in requirement is not inherited by subclasses. +@ExperimentalCoroutinesApi +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 siteUtilsWrapper: SiteUtilsWrapper + private lateinit var analyticsTrackerWrapper: AnalyticsTrackerWrapper + private lateinit var analyticsUtilsWrapper: AnalyticsUtilsWrapper + private lateinit var stagedFile: File + private lateinit var cacheDir: File + + @Before + fun setUp() { + // A real directory so tests can assert on the temp files the processor writes there. + cacheDir = tempFolder.newFolder("cache") + 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 + on { getCacheDir() } doReturn cacheDir + } + mediaUtilsWrapper = mock() + appPrefsWrapper = mock() + // Default to a paid plan: the free-plan rejection is opt-in per test. + siteUtilsWrapper = mock { + on { onFreePlan(any()) } doReturn false + } + analyticsTrackerWrapper = mock() + analyticsUtilsWrapper = 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, + siteUtilsWrapper = siteUtilsWrapper, + analyticsTrackerWrapper = analyticsTrackerWrapper, + analyticsUtilsWrapper = analyticsUtilsWrapper, + ioDispatcher = testDispatcher() + ) + + private fun wpComSite() = SiteModel().apply { setIsWPCom(true) } + + private fun selfHostedSite() = SiteModel().apply { setIsWPCom(false) } + + /** Puts the site on a free WP.com plan, where audio/document uploads are plan-restricted. */ + private fun onFreePlan() { + whenever(siteUtilsWrapper.onFreePlan(any())).thenReturn(true) + } + + /** + * A site subject to the free-plan video duration limit. Note this is [SiteModel.hasFreePlan] + * (the API's `plan.is_free`), which gates the duration check, and is distinct from + * [SiteUtilsWrapper.onFreePlan] (a plan-id match) used for the mime allowlist. + */ + private fun freePlanSite() = SiteModel().apply { + setIsWPCom(true) + setHasFreePlan(true) + } + + @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 `optimized path that does not exist falls back to the original`() = test { + // GutenbergKit names the staged file after the client-supplied multipart filename, so a + // path that fails to resolve is reachable here in a way it never was for the MediaStore + // paths the legacy callers pass in. Uploading a File that is not there would send nothing. + val missing = mock { + on { path } doReturn File(tempFolder.root, "never-written.jpg").absolutePath + } + whenever(mediaUtilsWrapper.getOptimizedMedia(stagedFile.absolutePath, false)).thenReturn(missing) + + val result = createProcessor().processFile(stagedFile, "image/jpeg", "photo.jpg") + + assertThat(result).isEqualTo(ProcessedProxyFile.Original) + } + + @Test + fun `optimized path containing a percent sign is used verbatim`() = test { + // Uri.getPath is already decoded. Decoding it again reads "%_d" as an escape sequence and + // mangles the path, so a file whose name contains a literal '%' would fail to resolve. + val optimized = tempFolder.newFile("100%_done.jpg") + val optimizedUri = fileUri(optimized) + whenever(mediaUtilsWrapper.getOptimizedMedia(stagedFile.absolutePath, false)) + .thenReturn(optimizedUri) + + val result = createProcessor().processFile(stagedFile, "image/jpeg", "100%_done.jpg") + + result as ProcessedProxyFile.Processed + assertThat(result.file.absolutePath).isEqualTo(optimized.absolutePath) + } + + @Test + fun `failed strip copy does not leak the temp file`() = test { + // GutenbergKit only deletes files handed back to it, so a temp file abandoned mid-copy + // (a full disk being the likely cause) would sit in the cache dir indefinitely. + val missing = File(tempFolder.root, "vanished.jpg") + whenever(mediaUtilsWrapper.getOptimizedMedia(missing.absolutePath, false)).thenReturn(null) + whenever(appPrefsWrapper.isStripImageLocation).thenReturn(true) + val cacheFilesBefore = cacheDir.listFiles()?.size ?: 0 + + val thrown = runCatching { + createProcessor(wpComSite()).processFile(missing, "image/jpeg", "vanished.jpg") + }.exceptionOrNull() + + assertThat(thrown).isNotNull() + assertThat(cacheDir.listFiles()?.size ?: 0).isEqualTo(cacheFilesBefore) + } + + @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 `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") + 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 `output format is read from the encoded file, not the declared mime type`() = test { + // ImageUtils picks its encoder from the extension it derives for the output, which for an + // extensionless upload comes from the file's sniffed bytes rather than the declared mime. + // Labeling from the declared type would tag these PNG bytes as image/jpeg, and WordPress + // would store that mislabel permanently. + val extensionless = tempFolder.newFile("screenshot") + val optimized = tempFolder.newFile("optimized-sniffed.png") + val optimizedUri = fileUri(optimized) + whenever(mediaUtilsWrapper.getOptimizedMedia(extensionless.absolutePath, false)) + .thenReturn(optimizedUri) + + val result = createProcessor().processFile(extensionless, "image/jpeg", "screenshot") + + result as ProcessedProxyFile.Processed + assertThat(result.mimeType).isEqualTo("image/png") + assertThat(result.filename).isEqualTo("screenshot.png") + } + + @Test + fun `jpeg output is labeled jpeg even when the input declared png`() = test { + val staged = tempFolder.newFile("mystery") + val optimized = tempFolder.newFile("optimized-sniffed.jpg") + val optimizedUri = fileUri(optimized) + whenever(mediaUtilsWrapper.getOptimizedMedia(staged.absolutePath, false)) + .thenReturn(optimizedUri) + + val result = createProcessor().processFile(staged, "image/png", "mystery") + + result as ProcessedProxyFile.Processed + assertThat(result.mimeType).isEqualTo("image/jpeg") + assertThat(result.filename).isEqualTo("mystery.jpg") + } + + @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 `document disallowed by a free plan throws with localized message`() = test { + onFreePlan() + whenever(mediaUtilsWrapper.isApplicationMimeType("application/zip")).thenReturn(true) + 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 `audio disallowed by a free plan throws with localized message`() = test { + onFreePlan() + whenever(mediaUtilsWrapper.isAudioMimeType("audio/mpeg")).thenReturn(true) + whenever(mediaUtilsWrapper.isMimeTypeSupportedBySitePlan(anyOrNull(), any())).thenReturn(false) + val audioStaged = tempFolder.newFile("song.mp3") + + val thrown = runCatching { + createProcessor().processFile(audioStaged, "audio/mpeg", "song.mp3") + }.exceptionOrNull() + + assertThat(thrown) + .isInstanceOf(GBKMediaUploadException::class.java) + .hasMessage(FILE_TYPE_ERROR) + } + + @Test + fun `document missing from the allowlist uploads on a paid plan`() = test { + // Paid and self-hosted sites are not plan-restricted, so the stale MimeTypes table must + // not reject for them — the server is authoritative. The allowlist is left unstubbed + // deliberately: reaching it at all would be the bug. + val zipStaged = tempFolder.newFile("archive.zip") + + val result = createProcessor().processFile(zipStaged, "application/zip", "archive.zip") + + assertThat(result).isEqualTo(ProcessedProxyFile.Original) + verify(mediaUtilsWrapper, never()).isMimeTypeSupportedBySitePlan(anyOrNull(), any()) + } + + @Test + fun `image missing from the allowlist uploads even on a free plan`() = test { + // AVIF is core-supported since WP 6.5 but absent from the app's MimeTypes table. Images + // are never plan-restricted, so the table must not be consulted for them at all. + onFreePlan() + whenever(mediaUtilsWrapper.getOptimizedMedia(any(), any())).thenReturn(null) + val avifStaged = tempFolder.newFile("photo.avif") + + val result = createProcessor().processFile(avifStaged, "image/avif", "photo.avif") + + assertThat(result).isEqualTo(ProcessedProxyFile.Original) + verify(mediaUtilsWrapper, never()).isMimeTypeSupportedBySitePlan(anyOrNull(), any()) + } + + @Test + fun `unresolvable type is not rejected as a plan-restricted document`() = test { + // resolveMimeType emits application/octet-stream for "unknown bytes". It would classify as + // an application type, so the exclusion has to short-circuit ahead of that classification — + // isApplicationMimeType is deliberately left unstubbed to pin that ordering. + onFreePlan() + val unknownStaged = tempFolder.newFile("mystery.bin") + + val result = createProcessor().processFile(unknownStaged, "application/octet-stream", "mystery.bin") + + assertThat(result).isEqualTo(ProcessedProxyFile.Original) + verify(mediaUtilsWrapper, never()).isMimeTypeSupportedBySitePlan(anyOrNull(), any()) + } + + @Test + fun `video exceeding duration limit throws with localized message`() = test { + whenever(mediaUtilsWrapper.isVideoMimeType("video/mp4")).thenReturn(true) + whenever(mediaUtilsWrapper.isProhibitedVideoDuration(any(), 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(), 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 `duration check receives the resolved mime type, not the staged path`() = test { + // The staged file is named after the client-supplied filename, which need not carry an + // extension. Deriving "is this a video" from that path is an extension-only test, so the + // duration limit must be keyed off the resolved mime type instead. + whenever(mediaUtilsWrapper.isVideoMimeType("video/mp4")).thenReturn(true) + whenever(mediaUtilsWrapper.isProhibitedVideoDuration(any(), any(), any(), any())) + .thenReturn(false) + whenever(appPrefsWrapper.isVideoOptimize).thenReturn(false) + val extensionlessVideo = tempFolder.newFile("upload") + + createProcessor().processFile(extensionlessVideo, "video/mp4", "upload") + + verify(mediaUtilsWrapper).isProhibitedVideoDuration( + any(), + any(), + eq(extensionlessVideo), + eq("video/mp4") + ) + } + + @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) + } + + @Test + fun `handlesFile declines gif so the copy is skipped`() { + assertThat(createProcessor().handlesFile("image/gif", "anim.gif")).isFalse() + } + + @Test + fun `handlesFile declines non-media so the copy is skipped`() { + assertThat(createProcessor().handlesFile("application/pdf", "doc.pdf")).isFalse() + } + + @Test + fun `handlesFile claims images when optimization is on`() { + whenever(appPrefsWrapper.isImageOptimize).thenReturn(true) + + assertThat(createProcessor().handlesFile("image/jpeg", "photo.jpg")).isTrue() + } + + @Test + fun `handlesFile claims images when the location strip applies`() { + whenever(appPrefsWrapper.isStripImageLocation).thenReturn(true) + + assertThat(createProcessor().handlesFile("image/jpeg", "photo.jpg")).isTrue() + } + + @Test + fun `handlesFile claims images on self-hosted for the rotation fallback`() { + // Issue #5737: self-hosted sites are not rotated server-side, so processImage still needs + // the file even with every optimization pref off. + assertThat(createProcessor(selfHostedSite()).handlesFile("image/jpeg", "photo.jpg")).isTrue() + } + + @Test + fun `handlesFile declines images when nothing would touch the file`() { + // WP.com, optimization off, strip off: processImage returns Original without reading the + // file, so claiming it would cost a full copy for nothing. + assertThat(createProcessor(wpComSite()).handlesFile("image/jpeg", "photo.jpg")).isFalse() + } + + @Test + fun `handlesFile declines images whose format cannot be exif-stripped`() { + // androidx ExifInterface cannot rewrite HEIC, so the strip is a no-op for it and the file + // is never read — see EXIF_MIME_TYPES. + whenever(appPrefsWrapper.isStripImageLocation).thenReturn(true) + + assertThat(createProcessor(wpComSite()).handlesFile("image/heic", "photo.heic")).isFalse() + } + + @Test + fun `handlesFile claims videos when optimization is on`() { + whenever(mediaUtilsWrapper.isVideoMimeType("video/mp4")).thenReturn(true) + whenever(appPrefsWrapper.isVideoOptimize).thenReturn(true) + + assertThat(createProcessor().handlesFile("video/mp4", "movie.mp4")).isTrue() + } + + @Test + fun `handlesFile claims videos on a free plan for the duration check`() { + whenever(mediaUtilsWrapper.isVideoMimeType("video/mp4")).thenReturn(true) + + assertThat(createProcessor(freePlanSite()).handlesFile("video/mp4", "movie.mp4")).isTrue() + } + + @Test + fun `handlesFile declines videos when nothing would touch the file`() { + // Paid plan with video optimization off — a common configuration, since optimization is + // opt-in. Claiming here copies the whole video to cache for a guaranteed passthrough. + whenever(mediaUtilsWrapper.isVideoMimeType("video/mp4")).thenReturn(true) + + assertThat(createProcessor(wpComSite()).handlesFile("video/mp4", "movie.mp4")).isFalse() + } + + @Test + fun `handlesFile declines videos on a free plan with VideoPress enabled`() { + // VideoPress lifts the duration limit, so the check does not measure and nothing else + // reads the file with optimization off. + whenever(mediaUtilsWrapper.isVideoMimeType("video/mp4")).thenReturn(true) + val site = freePlanSite().apply { activeModules = "videopress" } + + assertThat(createProcessor(site).handlesFile("video/mp4", "movie.mp4")).isFalse() + } + + @Test + fun `mime type parameters are stripped before the type is routed`() = test { + // Content-Type may legitimately carry parameters. An unnormalized value would miss the + // image/ prefix test and fall through to the non-media passthrough. + 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; charset=binary", "photo.jpg") + + result as ProcessedProxyFile.Processed + assertThat(result.mimeType).isEqualTo("image/jpeg") + } + + @Test + fun `mime type casing is normalized before the type is routed`() = test { + // An uppercase type must still route to the image path: getOptimizedMedia being consulted + // is what proves the normalization happened. + whenever(mediaUtilsWrapper.getOptimizedMedia(stagedFile.absolutePath, false)).thenReturn(null) + whenever(appPrefsWrapper.isStripImageLocation).thenReturn(false) + + val result = createProcessor(wpComSite()).processFile(stagedFile, "IMAGE/JPEG", "photo.jpg") + + verify(mediaUtilsWrapper).getOptimizedMedia(stagedFile.absolutePath, false) + assertThat(result).isEqualTo(ProcessedProxyFile.Original) + } + + @Test + fun `text plain is treated as a placeholder and resolved from the filename`() = test { + // GutenbergKit's multipart parser defaults a part with no Content-Type to text/plain + // (RFC 7578), and picks the file part by its filename parameter rather than its type — so + // a real image can arrive labeled text/plain and must not be rejected as a disallowed type. + // + // MimeTypeMap is a stub returning null under unit tests, so the extension lookup cannot + // resolve here; this asserts the surrounding contract instead — a free-plan site does not + // reject text/plain, which is neither audio nor an application type. + onFreePlan() + val staged = tempFolder.newFile("note.txt") + + val result = createProcessor().processFile(staged, "text/plain", "note.txt") + + assertThat(result).isEqualTo(ProcessedProxyFile.Original) + verify(mediaUtilsWrapper, never()).isMimeTypeSupportedBySitePlan(anyOrNull(), any()) + } + + @Test + fun `blank mime type never resolves to an empty string`() { + // An empty resolved type would be meaningless to the type routing; a placeholder is the + // safe floor. octet-stream routes to neither image nor video, so the file is not claimed. + assertThat(createProcessor().handlesFile("", "mystery")).isFalse() + } + + @Test + fun `handlesFile claims plan-rejected types so processFile can reject them locally`() { + // Declining would relay the file to WordPress instead, wasting a full upload and replacing + // our localized message with the server's. Pairs with the processFile rejection test above, + // which uses the same mime type. + onFreePlan() + whenever(mediaUtilsWrapper.isApplicationMimeType("application/zip")).thenReturn(true) + whenever(mediaUtilsWrapper.isMimeTypeSupportedBySitePlan(anyOrNull(), any())).thenReturn(false) + + assertThat(createProcessor().handlesFile("application/zip", "archive.zip")).isTrue() + } + + @Test + fun `handlesFile declines documents that are not plan-rejected`() { + // On a paid plan there is nothing for processFile to say about a document, so claiming it + // would cost a full byte-for-byte copy for a guaranteed passthrough. The plan test + // short-circuits before the type is even classified. + assertThat(createProcessor().handlesFile("application/zip", "archive.zip")).isFalse() + verify(mediaUtilsWrapper, never()).isMimeTypeSupportedBySitePlan(anyOrNull(), any()) + } + + /** A Uri standing in for [android.net.Uri.fromFile], whose path resolves to the real file. */ + 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." + } +} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 5c2fc92b3667..8fa3252e5883 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -73,7 +73,7 @@ google-play-review = '2.0.2' google-services = '4.5.0' gravatar = '2.5.0' greenrobot-eventbus = '3.3.1' -gutenberg-kit = 'v0.19.0' +gutenberg-kit = 'v0.20.0-alpha.0' gutenberg-mobile = 'v1.121.0' indexos-media-for-mobile = '43a9026f0973a2f0a74fa813132f6a16f7499c3a' jackson-databind = '2.12.7.1' @@ -102,7 +102,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-f375205024780f54c4b6e4e834ae1bbace69322a' automattic-ucrop = '2.2.11' zendesk = '5.5.3' turbine = '1.2.1'