Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions RELEASE-NOTES.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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
-----
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -32,13 +32,16 @@
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;
import java.util.Map;

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;

Expand Down Expand Up @@ -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.
*
* <p>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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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"))
Expand Down
Original file line number Diff line number Diff line change
@@ -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()
}
}
Loading