From 11e74042e28da3311fdf0048d1e7f7b0a21ca9a0 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Fri, 16 Oct 2026 19:11:50 +0200 Subject: [PATCH] fix: hand files with a generic type to other apps by the type their name implies --- src/lib/__tests__/file-preview.test.ts | 23 +++++++- src/lib/email-export.ts | 9 +-- src/lib/file-preview.ts | 78 ++++++++++++++++++++++++++ 3 files changed, 105 insertions(+), 5 deletions(-) diff --git a/src/lib/__tests__/file-preview.test.ts b/src/lib/__tests__/file-preview.test.ts index bdfe70c7..870d2b95 100644 --- a/src/lib/__tests__/file-preview.test.ts +++ b/src/lib/__tests__/file-preview.test.ts @@ -1,7 +1,7 @@ import { describe, it, expect } from 'vitest'; import { filePreviewKind, inAppPreviewKind, exceedsPreviewLimit, FILE_PREVIEW_MAX_BYTES, - needsNativeImageDecoder, needsWebImageViewer, + needsNativeImageDecoder, needsWebImageViewer, handoffMimeType, } from '../file-preview'; const IOS = { platform: 'ios' } as const; @@ -145,3 +145,24 @@ describe('preview size limits', () => { expect(inAppPreviewKind({ name: 'film.mp4', type: 'video/mp4', size: 300 * 1024 * 1024 }, ANDROID)).toBe('video'); }); }); + +describe('handoffMimeType', () => { + it('keeps a real declared type', () => { + expect(handoffMimeType('a.pdf', 'application/pdf')).toBe('application/pdf'); + expect(handoffMimeType('scan', 'image/png')).toBe('image/png'); + expect(handoffMimeType('a.zip', 'application/x-zip-compressed')).toBe('application/zip'); + }); + + it('names the format when the declared type does not, so Android finds a viewer', () => { + expect(handoffMimeType('contract.docx', 'application/octet-stream')) + .toBe('application/vnd.openxmlformats-officedocument.wordprocessingml.document'); + expect(handoffMimeType('invoice.PDF', '')).toBe('application/pdf'); + expect(handoffMimeType('invoice.pdf', 'application/x-as400attachment')).toBe('application/pdf'); + expect(handoffMimeType('photo.jpg', 'application/x-unknown')).toBe('image/jpeg'); + }); + + it('falls back to the declared type or octet-stream for unknown names', () => { + expect(handoffMimeType('data.bin', '')).toBe('application/octet-stream'); + expect(handoffMimeType('data.xyz', 'application/x-foo')).toBe('application/x-foo'); + }); +}); diff --git a/src/lib/email-export.ts b/src/lib/email-export.ts index b189caf2..c9b20160 100644 --- a/src/lib/email-export.ts +++ b/src/lib/email-export.ts @@ -14,6 +14,7 @@ import { } from './download-filename'; import { getClientCertAlias, secureFetch } from './client-cert'; import { sniffImageMime } from './email-html'; +import { handoffMimeType } from './file-preview'; const RFC822 = 'message/rfc822'; const FLAG_GRANT_READ_URI_PERMISSION = 0x00000001; @@ -156,7 +157,7 @@ async function openWithViewer(file: File, mimeType: string): Promise { // intent. Deliberately no FLAG_ACTIVITY_NEW_TASK: expo-intent-launcher // uses startActivityForResult, and a new task cancels that result. data: file.contentUri, - type: mimeType, + type: handoffMimeType(file.name, mimeType), flags: FLAG_GRANT_READ_URI_PERMISSION, }); return true; @@ -259,7 +260,7 @@ export async function shareAttachment( } try { await Sharing.shareAsync(downloaded.uri, { - mimeType, + mimeType: handoffMimeType(filename, mimeType), dialogTitle: filename, }); } finally { @@ -296,7 +297,7 @@ async function offerSavedFile(file: File, filename: string, mimeType: string): P throw new Error(t('files.share_unavailable', 'Sharing is not available on this device')); } await Sharing.shareAsync(file.uri, { - mimeType, + mimeType: handoffMimeType(filename, mimeType), dialogTitle: filename, }); } @@ -422,7 +423,7 @@ export async function shareLocalFile( throw new Error(t('files.share_unavailable', 'Sharing is not available on this device')); } try { - await Sharing.shareAsync(file.uri, { mimeType, dialogTitle: dialogTitle ?? file.name }); + await Sharing.shareAsync(file.uri, { mimeType: handoffMimeType(file.name, mimeType), dialogTitle: dialogTitle ?? file.name }); } finally { if (!opts.keep) scheduleTempCleanup(file); } diff --git a/src/lib/file-preview.ts b/src/lib/file-preview.ts index 0f9463ac..d0c1922f 100644 --- a/src/lib/file-preview.ts +++ b/src/lib/file-preview.ts @@ -179,3 +179,81 @@ export function needsWebImageViewer(file: Pick const mime = baseMime(file.type); return /^image\/(svg\+xml|x-icon|vnd\.microsoft\.icon|ico)$/.test(mime) || /\.(svg|ico)$/i.test(file.name); } + +// Types for handing a file to another app when the sender declared none, or +// a catch-all: Android picks the apps for "Open with" by type, and finds +// none for application/octet-stream. +const EXT_TO_MIME: Record = { + pdf: 'application/pdf', + doc: 'application/msword', + docx: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', + xls: 'application/vnd.ms-excel', + xlsx: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + ppt: 'application/vnd.ms-powerpoint', + pptx: 'application/vnd.openxmlformats-officedocument.presentationml.presentation', + odt: 'application/vnd.oasis.opendocument.text', + ods: 'application/vnd.oasis.opendocument.spreadsheet', + odp: 'application/vnd.oasis.opendocument.presentation', + rtf: 'application/rtf', + epub: 'application/epub+zip', + zip: 'application/zip', + '7z': 'application/x-7z-compressed', + rar: 'application/vnd.rar', + gz: 'application/gzip', + tar: 'application/x-tar', + apk: 'application/vnd.android.package-archive', + txt: 'text/plain', + csv: 'text/csv', + html: 'text/html', + htm: 'text/html', + md: 'text/markdown', + json: 'application/json', + xml: 'application/xml', + ics: 'text/calendar', + vcf: 'text/vcard', + eml: 'message/rfc822', + png: 'image/png', + jpg: 'image/jpeg', + jpeg: 'image/jpeg', + gif: 'image/gif', + webp: 'image/webp', + heic: 'image/heic', + heif: 'image/heif', + avif: 'image/avif', + bmp: 'image/bmp', + tif: 'image/tiff', + tiff: 'image/tiff', + svg: 'image/svg+xml', + ico: 'image/x-icon', + mp3: 'audio/mpeg', + m4a: 'audio/mp4', + aac: 'audio/aac', + wav: 'audio/wav', + ogg: 'audio/ogg', + opus: 'audio/ogg', + flac: 'audio/flac', + amr: 'audio/amr', + mp4: 'video/mp4', + m4v: 'video/mp4', + mov: 'video/quicktime', + webm: 'video/webm', + mkv: 'video/x-matroska', + avi: 'video/x-msvideo', + wmv: 'video/x-ms-wmv', + '3gp': 'video/3gpp', +}; + +/** + * The type to hand a file to another app with: the declared one, unless it + * is generic or a catch-all that names no format, in which case the one the + * file name implies. + */ +export function handoffMimeType(name: string | undefined, type: string | null | undefined): string { + const declared = baseMime(type); + const fromName = EXT_TO_MIME[extension(name || '')]; + if (!fromName) return type || 'application/octet-stream'; + if (GENERIC_MIME.has(declared)) return fromName; + // An "x-" application type the name contradicts, such as x-as400attachment. + if (/^application\/x-/.test(declared) && declared !== fromName) return fromName; + return type || fromName; +}