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/ShareIntentReceiverActivity.java b/WordPress/src/main/java/org/wordpress/android/ui/ShareIntentReceiverActivity.java index b85017db479b..69224f78a495 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,8 @@ 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; @@ -148,10 +151,58 @@ 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; + } + + // 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. 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) || getMimeTypeForExtension(extension) == null) { + 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 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..91fe03892d02 --- /dev/null +++ b/WordPress/src/test/java/org/wordpress/android/ui/posts/EditorUnitFunctionsTest.kt @@ -0,0 +1,71 @@ +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 `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 + 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() + } +}