From efea6a545335fb128da218d2ae29ed52c7b08a60 Mon Sep 17 00:00:00 2001 From: Matt Van Horn <455140+mvanhorn@users.noreply.github.com> Date: Sat, 18 Jul 2026 10:05:04 -0700 Subject: [PATCH] fix: keep attachment file extension when saving Saving an attachment launched ACTION_CREATE_DOCUMENT with the MIME type declared in the mail Content-Type header. When that type did not round-trip to the display name's extension, the Storage Access Framework appended the declared type's preferred extension, so appointment.ics declared as text/plain was written as appointment.ics.txt. Derive a MIME type that is consistent with the display name's extension (the same approach the VIEW flow already uses in ViewIntentFinder) via a new MimeTypeUtil.getMimeTypeForFilename helper and pass it to the create-document intent, so the provider keeps the filename unchanged. When the name has no extension, the declared type (or the default) is used and the provider may add a suitable extension as before. Fixes #7968 --- .../java/com/fsck/k9/helper/MimeTypeUtil.java | 20 +++++++ .../com/fsck/k9/helper/MimeTypeUtilTest.kt | 53 +++++++++++++++++++ .../k9/ui/messageview/MessageViewFragment.kt | 5 +- 3 files changed, 75 insertions(+), 3 deletions(-) create mode 100644 legacy/core/src/test/java/com/fsck/k9/helper/MimeTypeUtilTest.kt diff --git a/legacy/core/src/main/java/com/fsck/k9/helper/MimeTypeUtil.java b/legacy/core/src/main/java/com/fsck/k9/helper/MimeTypeUtil.java index dc61bd87eab..a7447f01b1e 100644 --- a/legacy/core/src/main/java/com/fsck/k9/helper/MimeTypeUtil.java +++ b/legacy/core/src/main/java/com/fsck/k9/helper/MimeTypeUtil.java @@ -898,6 +898,26 @@ public static String getMimeTypeByExtension(String filename) { return DEFAULT_ATTACHMENT_MIME_TYPE; } + /** + * Returns a MIME type that is consistent with the extension of {@code displayName}. + * + *
This is used when creating a document via the Storage Access Framework. If the declared MIME type does not + * round-trip to the display name's extension, the provider appends the declared type's preferred extension (e.g. + * saving {@code appointment.ics} declared as {@code text/plain} would produce {@code appointment.ics.txt}). By + * passing a MIME type derived from the extension, the provider keeps the filename unchanged. + * + *
When the display name has no extension, there is nothing to preserve, so the declared MIME type (or + * {@link #DEFAULT_ATTACHMENT_MIME_TYPE} when it is {@code null}) is returned and the provider may add a suitable + * extension. + */ + public static String getMimeTypeForFilename(String displayName, String declaredMimeType) { + if (displayName != null && displayName.lastIndexOf('.') != -1) { + return getMimeTypeByExtension(displayName); + } + + return declaredMimeType != null ? declaredMimeType : DEFAULT_ATTACHMENT_MIME_TYPE; + } + public static String getExtensionByMimeType(@NotNull String mimeType) { String lowerCaseMimeType = mimeType.toLowerCase(Locale.US); for (String[] contentTypeMapEntry : MIME_TYPE_BY_EXTENSION_MAP) { diff --git a/legacy/core/src/test/java/com/fsck/k9/helper/MimeTypeUtilTest.kt b/legacy/core/src/test/java/com/fsck/k9/helper/MimeTypeUtilTest.kt new file mode 100644 index 00000000000..8de11221596 --- /dev/null +++ b/legacy/core/src/test/java/com/fsck/k9/helper/MimeTypeUtilTest.kt @@ -0,0 +1,53 @@ +package com.fsck.k9.helper + +import assertk.assertThat +import assertk.assertions.isEqualTo +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +@RunWith(RobolectricTestRunner::class) +class MimeTypeUtilTest { + + @Test + fun `getMimeTypeForFilename returns extension-consistent type for a known extension`() { + val result = MimeTypeUtil.getMimeTypeForFilename("appointment.ics", "text/plain") + + assertThat(result).isEqualTo("text/calendar") + } + + @Test + fun `getMimeTypeForFilename returns extension-consistent type for a pkpass file`() { + val result = MimeTypeUtil.getMimeTypeForFilename("boardingpass.pkpass", "application/octet-stream") + + assertThat(result).isEqualTo("application/vnd.apple.pkpass") + } + + @Test + fun `getMimeTypeForFilename ignores the declared type when the extension is known`() { + val result = MimeTypeUtil.getMimeTypeForFilename("notes.txt", "application/octet-stream") + + assertThat(result).isEqualTo("text/plain") + } + + @Test + fun `getMimeTypeForFilename falls back to declared type when there is no extension`() { + val result = MimeTypeUtil.getMimeTypeForFilename("noname", "text/plain") + + assertThat(result).isEqualTo("text/plain") + } + + @Test + fun `getMimeTypeForFilename falls back to default type when there is no extension and no declared type`() { + val result = MimeTypeUtil.getMimeTypeForFilename("noname", null) + + assertThat(result).isEqualTo("application/octet-stream") + } + + @Test + fun `getMimeTypeForFilename resolves an unknown extension to the default type`() { + val result = MimeTypeUtil.getMimeTypeForFilename("mystery.unknownext", "text/plain") + + assertThat(result).isEqualTo("application/octet-stream") + } +} diff --git a/legacy/ui/legacy/src/main/java/com/fsck/k9/ui/messageview/MessageViewFragment.kt b/legacy/ui/legacy/src/main/java/com/fsck/k9/ui/messageview/MessageViewFragment.kt index 0930fd9c5ef..2dea5c48149 100644 --- a/legacy/ui/legacy/src/main/java/com/fsck/k9/ui/messageview/MessageViewFragment.kt +++ b/legacy/ui/legacy/src/main/java/com/fsck/k9/ui/messageview/MessageViewFragment.kt @@ -48,6 +48,7 @@ import com.fsck.k9.fragment.ConfirmationDialogFragment import com.fsck.k9.fragment.ConfirmationDialogFragment.ConfirmationDialogFragmentListener import com.fsck.k9.helper.HttpsUnsubscribeUri import com.fsck.k9.helper.MailtoUnsubscribeUri +import com.fsck.k9.helper.MimeTypeUtil import com.fsck.k9.helper.UnsubscribeUri import com.fsck.k9.mail.Part import com.fsck.k9.mailstore.AttachmentViewInfo @@ -1197,9 +1198,7 @@ class MessageViewFragment : createDocumentLauncher.launch( input = CreateDocumentResultContract.Input( title = attachment.displayName ?: getString(MessageReaderR.string.unnamed_attachment_title), - mimeType = requireNotNull(attachment.mimeType) { - "Invalid attachment type. The mimeType is null. Attachment = $attachment" - }, + mimeType = MimeTypeUtil.getMimeTypeForFilename(attachment.displayName, attachment.mimeType), ), ) } catch (_: ActivityNotFoundException) {