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/GBKMediaUploadProcessor.kt b/WordPress/src/main/java/org/wordpress/android/ui/posts/editor/GBKMediaUploadProcessor.kt new file mode 100644 index 000000000000..295e852c3780 --- /dev/null +++ b/WordPress/src/main/java/org/wordpress/android/ui/posts/editor/GBKMediaUploadProcessor.kt @@ -0,0 +1,263 @@ +package org.wordpress.android.ui.posts.editor + +import android.content.Context +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) + + // 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)) + } + + 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 + } + } + + @Suppress("ReturnCount") + private suspend fun processVideo(file: File, filename: String): ProcessedProxyFile { + if (mediaUtilsWrapper.isProhibitedVideoDuration(appContext, site, 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") + 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() + } + + @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 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" + + /** + * 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..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() } 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..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 @@ -35,6 +36,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) @@ -60,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..e1bcbffb7456 --- /dev/null +++ b/WordPress/src/test/java/org/wordpress/android/ui/posts/editor/GBKMediaUploadProcessorTest.kt @@ -0,0 +1,280 @@ +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 `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 `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." + } +} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index ccd85d72a937..7fea53cea12b 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' @@ -102,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'