fix: hand files with a generic type to other apps by the type their name implies

This commit is contained in:
Linus Rath
2026-10-16 19:11:50 +02:00
parent 95580b84ea
commit 11e74042e2
3 changed files with 105 additions and 5 deletions
+22 -1
View File
@@ -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');
});
});
+5 -4
View File
@@ -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<boolean> {
// 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);
}
+78
View File
@@ -179,3 +179,81 @@ export function needsWebImageViewer(file: Pick<PreviewableFile, 'name' | 'type'>
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<string, string> = {
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;
}