From 5d96ce784688fa22ade64259a66692c7c6899c20 Mon Sep 17 00:00:00 2001 From: Nick Bradbury Date: Wed, 19 Aug 2026 13:05:18 -0400 Subject: [PATCH 1/4] Name the shared media copy from the provider's MIME type downloadExternalMedia() names its cached copy after the provider's display name, falling back to a MIME type parsed out of the URI string - which a content:// URI never carries. Providers that generate media on the fly (Google Photos sharing an "Enhanced" photo, the photo picker, SAF) report no usable display name, so the copy is left with no extension at all. Every MIME check downstream reads the file name, and FluxCUtils.mediaModelFromLocalUri ends its fallback chain at image/jpeg, so a shared PNG, HEIC or video was uploaded labelled as a JPEG. Rename the copy using the type the provider reported, which isAllowedMediaType() already resolves. --- .../ui/ShareIntentReceiverActivity.java | 45 ++++++++++++++++++- 1 file changed, 44 insertions(+), 1 deletion(-) diff --git a/WordPress/src/main/java/org/wordpress/android/ui/ShareIntentReceiverActivity.java b/WordPress/src/main/java/org/wordpress/android/ui/ShareIntentReceiverActivity.java index b85017db479b..945bf894d566 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/ShareIntentReceiverActivity.java +++ b/WordPress/src/main/java/org/wordpress/android/ui/ShareIntentReceiverActivity.java @@ -32,6 +32,7 @@ import org.wordpress.android.util.ToastUtils; import org.wordpress.android.util.analytics.AnalyticsUtils; +import java.io.File; import java.util.ArrayList; import java.util.HashMap; import java.util.List; @@ -39,6 +40,7 @@ import javax.inject.Inject; +import static org.wordpress.android.fluxc.utils.MediaUtils.getExtension; import static org.wordpress.android.fluxc.utils.MediaUtils.isSupportedImageMimeType; import static org.wordpress.android.fluxc.utils.MediaUtils.isSupportedVideoMimeType; @@ -148,10 +150,51 @@ private boolean addLocalMediaUri(@NonNull Uri uri) { AppLog.e(T.MEDIA, "ShareIntentReceiver failed to download media " + uri); return false; } - mLocalMediaUris.add(localUri); + mLocalMediaUris.add(withFileExtension(localUri, getContentResolver().getType(uri))); return true; } + /** + * Renames the cached copy so its name carries a file extension, using the MIME type the provider + * reported for the shared URI. + * + *

downloadExternalMedia() names the copy after the provider's display name, falling back to a + * MIME type parsed out of the URI string - which a content:// URI never carries. A provider that + * reports no display name, or one without an extension, therefore leaves the copy with no + * extension at all. Every MIME check downstream reads the file name, so the media ends up + * uploaded as image/jpeg no matter what was actually shared. + * + * @return the renamed URI, or the original one when there is nothing to repair or the rename + * fails. This only ever improves on what we already have, so it must not introduce a failure. + */ + @NonNull + private Uri withFileExtension(@NonNull Uri localUri, @Nullable String mimeType) { + String path = localUri.getPath(); + if (path == null || mimeType == null) { + return localUri; + } + + File localFile = new File(path); + if (getExtension(localFile.getName()) != null) { + return localUri; + } + + String extension = MediaUtils.getExtensionForMimeType(mimeType); + if (TextUtils.isEmpty(extension)) { + return localUri; + } + + // an extension-less name still ends in a dot when downloadExternalMedia() had none to append + String renamedPath = (path.endsWith(".") ? path.substring(0, path.length() - 1) : path) + "." + extension; + // renameTo() overwrites, and an earlier share may still be uploading from that name + File renamedFile = new File(renamedPath); + if (renamedFile.exists() || !localFile.renameTo(renamedFile)) { + AppLog.w(T.MEDIA, "ShareIntentReceiver could not rename " + path + " to " + renamedPath); + return localUri; + } + return Uri.fromFile(renamedFile); + } + private boolean isAllowedMediaType(@NonNull Uri uri) { // Try the MIME type reported by the provider first: photo picker URIs have no file extension // and no readable _data column, so the path-based check below can't recognize them. A provider From 12c1c73285b92617c8a03f72fd44d09e54231b01 Mon Sep 17 00:00:00 2001 From: Nick Bradbury Date: Wed, 19 Aug 2026 13:05:25 -0400 Subject: [PATCH 2/4] Stop dropping extension-less URIs when several photos are shared at once isMediaTypeIntent() derives the type from the URI's file extension, and getFileExtensionFromUrl() returns an empty string both for a name left without an extension by the provider that shared it and for any name containing a space ("Screenshot 2026-08-19.jpg"). Those items were dropped with no toast and no snackbar. Single ACTION_SEND escaped this because it reads intent.type instead. Fall back to intent.type when the URI yields nothing, so ACTION_SEND_MULTIPLE behaves like the single-share path it already trusts. --- RELEASE-NOTES.txt | 1 + .../android/ui/posts/EditorUnitFunctions.kt | 8 ++- .../ui/posts/EditorUnitFunctionsTest.kt | 63 +++++++++++++++++++ 3 files changed, 71 insertions(+), 1 deletion(-) create mode 100644 WordPress/src/test/java/org/wordpress/android/ui/posts/EditorUnitFunctionsTest.kt diff --git a/RELEASE-NOTES.txt b/RELEASE-NOTES.txt index 34101edc8557..287104b1035e 100644 --- a/RELEASE-NOTES.txt +++ b/RELEASE-NOTES.txt @@ -12,6 +12,7 @@ * [*] The experimental block editor now lays out right-to-left when the app language is a right-to-left language. * [*] Sharing content to the app now lists self-hosted sites connected with an application password. * [**] Images and videos shared to the app from the photo picker now upload instead of being silently dropped. +* [**] Media shared from apps that generate it on the fly, such as an "Enhanced" photo from Google Photos, now keeps its correct file type instead of always uploading as a JPEG, and sharing several photos at once no longer drops some of them. [https://github.com/wordpress-mobile/WordPress-Android/issues/23047] 26.9 ----- diff --git a/WordPress/src/main/java/org/wordpress/android/ui/posts/EditorUnitFunctions.kt b/WordPress/src/main/java/org/wordpress/android/ui/posts/EditorUnitFunctions.kt index b702802e092d..588f04fda2c6 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/posts/EditorUnitFunctions.kt +++ b/WordPress/src/main/java/org/wordpress/android/ui/posts/EditorUnitFunctions.kt @@ -61,6 +61,11 @@ object EditorUnitFunctions { /** * Checks if an intent contains media (image or video) content. + * + * Falls back to the intent's own type when the URI yields nothing, because + * getFileExtensionFromUrl() returns an empty string for names containing a space + * ("Screenshot 2026-08-19.jpg") and for names left without an extension by the provider that + * shared them. Without the fallback those items are dropped with no feedback at all. */ fun isMediaTypeIntent(intent: Intent, uri: Uri?): Boolean { var type: String? = null @@ -69,7 +74,8 @@ object EditorUnitFunctions { if (extension != null) { type = MimeTypeMap.getSingleton().getMimeTypeFromExtension(extension) } - } else { + } + if (type == null) { type = intent.type } return type != null && (type.startsWith("image") || type.startsWith("video")) diff --git a/WordPress/src/test/java/org/wordpress/android/ui/posts/EditorUnitFunctionsTest.kt b/WordPress/src/test/java/org/wordpress/android/ui/posts/EditorUnitFunctionsTest.kt new file mode 100644 index 000000000000..53cf9cf61ce2 --- /dev/null +++ b/WordPress/src/test/java/org/wordpress/android/ui/posts/EditorUnitFunctionsTest.kt @@ -0,0 +1,63 @@ +package org.wordpress.android.ui.posts + +import android.content.Intent +import android.webkit.MimeTypeMap +import androidx.core.net.toUri +import org.assertj.core.api.Assertions.assertThat +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +/** + * Robolectric tests for [EditorUnitFunctions.isMediaTypeIntent], which needs real URI parsing and a + * real [MimeTypeMap]. + */ +@RunWith(RobolectricTestRunner::class) +@Config(application = android.app.Application::class) +class EditorUnitFunctionsTest { + @Test + fun `accepts a uri whose name carries a recognized extension`() { + val intent = Intent().apply { type = "text/plain" } + + assertThat(EditorUnitFunctions.isMediaTypeIntent(intent, "file:///cache/photo.jpg".toUri())).isTrue() + } + + @Test + fun `falls back to the intent type when the name contains a space`() { + // getFileExtensionFromUrl() returns an empty string for names containing a space + val intent = Intent().apply { type = "image/jpeg" } + + assertThat( + EditorUnitFunctions.isMediaTypeIntent(intent, "file:///cache/Screenshot 2026-08-19.jpg".toUri()) + ).isTrue() + } + + @Test + fun `falls back to the intent type when the shared file has no extension`() { + // what a provider that reports no usable display name leaves behind + val intent = Intent().apply { type = "image/jpeg" } + + assertThat(EditorUnitFunctions.isMediaTypeIntent(intent, "file:///cache/wp-1755600000000.".toUri())).isTrue() + } + + @Test + fun `rejects an extension-less uri when the intent type is not media`() { + val intent = Intent().apply { type = "text/plain" } + + assertThat(EditorUnitFunctions.isMediaTypeIntent(intent, "file:///cache/wp-1755600000000.".toUri())).isFalse() + } + + @Test + fun `rejects an extension-less uri when the intent has no type at all`() { + assertThat(EditorUnitFunctions.isMediaTypeIntent(Intent(), "file:///cache/wp-1755600000000.".toUri())) + .isFalse() + } + + @Test + fun `uses the intent type when no uri is given`() { + val intent = Intent().apply { type = "video/mp4" } + + assertThat(EditorUnitFunctions.isMediaTypeIntent(intent, null)).isTrue() + } +} From 9970da322cdfeed18f43e3c51d53f81bd907b0d8 Mon Sep 17 00:00:00 2001 From: Nick Bradbury Date: Wed, 19 Aug 2026 15:40:26 -0400 Subject: [PATCH 3/4] Only rename the shared copy to an extension that maps back to a MIME type getExtensionForMimeType() never fails: when MimeTypeMap doesn't know the type it returns the subtype, so a provider reporting application/octet-stream produced "wp-123.octet-stream". That reads as a real extension downstream, which suppresses the image/jpeg fallback in FluxCUtils.mediaModelFromLocalUri() that had been making the upload work - leaving the media worse off than with no extension at all. Also cover the case the intent.type fallback could have regressed: a URI whose extension resolves to a non-media type must still be rejected when the intent type is media, because the URI wins whenever it resolves. --- .../android/ui/ShareIntentReceiverActivity.java | 9 ++++++++- .../android/ui/posts/EditorUnitFunctionsTest.kt | 8 ++++++++ 2 files changed, 16 insertions(+), 1 deletion(-) diff --git a/WordPress/src/main/java/org/wordpress/android/ui/ShareIntentReceiverActivity.java b/WordPress/src/main/java/org/wordpress/android/ui/ShareIntentReceiverActivity.java index 945bf894d566..67d19edcaf6d 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/ShareIntentReceiverActivity.java +++ b/WordPress/src/main/java/org/wordpress/android/ui/ShareIntentReceiverActivity.java @@ -5,6 +5,7 @@ import android.net.Uri; import android.os.Bundle; import android.text.TextUtils; +import android.webkit.MimeTypeMap; import androidx.annotation.NonNull; import androidx.annotation.Nullable; @@ -179,8 +180,14 @@ private Uri withFileExtension(@NonNull Uri localUri, @Nullable String mimeType) return localUri; } + // getExtensionForMimeType() never fails: when the MIME type is unknown to MimeTypeMap it + // returns the subtype, so "application/octet-stream" would name the file ".octet-stream". + // That reads as a real extension downstream and suppresses the image/jpeg fallback in + // FluxCUtils.mediaModelFromLocalUri() that would otherwise have made the upload work, so + // only rename when the extension maps back to a MIME type. String extension = MediaUtils.getExtensionForMimeType(mimeType); - if (TextUtils.isEmpty(extension)) { + if (TextUtils.isEmpty(extension) + || MimeTypeMap.getSingleton().getMimeTypeFromExtension(extension) == null) { return localUri; } diff --git a/WordPress/src/test/java/org/wordpress/android/ui/posts/EditorUnitFunctionsTest.kt b/WordPress/src/test/java/org/wordpress/android/ui/posts/EditorUnitFunctionsTest.kt index 53cf9cf61ce2..91fe03892d02 100644 --- a/WordPress/src/test/java/org/wordpress/android/ui/posts/EditorUnitFunctionsTest.kt +++ b/WordPress/src/test/java/org/wordpress/android/ui/posts/EditorUnitFunctionsTest.kt @@ -23,6 +23,14 @@ class EditorUnitFunctionsTest { assertThat(EditorUnitFunctions.isMediaTypeIntent(intent, "file:///cache/photo.jpg".toUri())).isTrue() } + @Test + fun `rejects a uri whose extension is not media even when the intent type is`() { + // the URI wins whenever it resolves: the intent type is only a fallback for when it doesn't + val intent = Intent().apply { type = "image/jpeg" } + + assertThat(EditorUnitFunctions.isMediaTypeIntent(intent, "file:///cache/notes.pdf".toUri())).isFalse() + } + @Test fun `falls back to the intent type when the name contains a space`() { // getFileExtensionFromUrl() returns an empty string for names containing a space From 09a7b61a62d34d9694e37cea608f78444d222f3c Mon Sep 17 00:00:00 2001 From: Nick Bradbury Date: Wed, 19 Aug 2026 15:43:33 -0400 Subject: [PATCH 4/4] Validate the renamed extension against the types we accept, not MimeTypeMap MimeTypeMap is backed by libcore's MimeUtils, whose coverage of heic, heif, ogv and 3g2 varies by API level - exactly the types isAllowedMediaType() accepts and the ones Google Photos is most likely to hand us. Where the table is short the guard rejected a good extension, no rename happened, and the media fell back to the image/jpeg default this change exists to avoid. MimeTypes.getMimeTypeForExtension() searches the same lists isSupportedImageMimeType() and isSupportedVideoMimeType() gate on, so the guard is now symmetric with the accept check by construction instead of depending on platform coverage. --- .../android/ui/ShareIntentReceiverActivity.java | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/WordPress/src/main/java/org/wordpress/android/ui/ShareIntentReceiverActivity.java b/WordPress/src/main/java/org/wordpress/android/ui/ShareIntentReceiverActivity.java index 67d19edcaf6d..69224f78a495 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/ShareIntentReceiverActivity.java +++ b/WordPress/src/main/java/org/wordpress/android/ui/ShareIntentReceiverActivity.java @@ -5,7 +5,6 @@ import android.net.Uri; import android.os.Bundle; import android.text.TextUtils; -import android.webkit.MimeTypeMap; import androidx.annotation.NonNull; import androidx.annotation.Nullable; @@ -42,6 +41,7 @@ import javax.inject.Inject; import static org.wordpress.android.fluxc.utils.MediaUtils.getExtension; +import static org.wordpress.android.fluxc.utils.MediaUtils.getMimeTypeForExtension; import static org.wordpress.android.fluxc.utils.MediaUtils.isSupportedImageMimeType; import static org.wordpress.android.fluxc.utils.MediaUtils.isSupportedVideoMimeType; @@ -180,14 +180,15 @@ private Uri withFileExtension(@NonNull Uri localUri, @Nullable String mimeType) return localUri; } - // getExtensionForMimeType() never fails: when the MIME type is unknown to MimeTypeMap it - // returns the subtype, so "application/octet-stream" would name the file ".octet-stream". - // That reads as a real extension downstream and suppresses the image/jpeg fallback in + // getExtensionForMimeType() never fails: when the MIME type is unknown it returns the + // subtype, so "application/octet-stream" would name the file ".octet-stream". That reads as + // a real extension downstream and suppresses the image/jpeg fallback in // FluxCUtils.mediaModelFromLocalUri() that would otherwise have made the upload work, so - // only rename when the extension maps back to a MIME type. + // only rename when the extension maps back to a MIME type. Check that against the same + // table isAllowedMediaType() accepts from, rather than MimeTypeMap, whose coverage of + // heic/heif/ogv/3g2 varies by API level. String extension = MediaUtils.getExtensionForMimeType(mimeType); - if (TextUtils.isEmpty(extension) - || MimeTypeMap.getSingleton().getMimeTypeFromExtension(extension) == null) { + if (TextUtils.isEmpty(extension) || getMimeTypeForExtension(extension) == null) { return localUri; }