From 7a9ae07379efe7bceda17380edcfcc50fc690ab5 Mon Sep 17 00:00:00 2001 From: Erwan Leboucher Date: Tue, 28 Jul 2026 17:17:52 +0200 Subject: [PATCH 1/2] feat(mobile): use native attachment pickers --- src-tauri/Cargo.toml | 2 +- src-tauri/capabilities/android.json | 2 + src-tauri/capabilities/ios.json | 7 +- src-tauri/src/lib.rs | 5 + src/app/features/room/RoomInput.tsx | 116 ++++-------------- .../features/room/nativeFilePicker.test.ts | 82 +++++++++++++ src/app/features/room/nativeFilePicker.ts | 74 +++++++++++ 7 files changed, 193 insertions(+), 95 deletions(-) create mode 100644 src/app/features/room/nativeFilePicker.test.ts create mode 100644 src/app/features/room/nativeFilePicker.ts diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index c8c27dac89..b0fa29a23c 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -124,6 +124,7 @@ tauri-plugin-notifications = { git = "https://github.com/SableClient/tauri-plugi ] } tauri-plugin-edge-to-edge = { git = "https://github.com/SableClient/tauri-plugin-edge-to-edge.git", rev = "33c6116c27be28c06df5a9d02231ecc5fdeb93c5" } tauri-plugin-sharekit = { git = "https://github.com/Choochmeque/tauri-plugin-sharekit", rev = "9f2b4c5d8a4f0ab910d900ba234ed8bae0ab854b" } +tauri-plugin-fs = "2" [target.'cfg(target_os = "ios")'.dependencies] objc2 = "0.6" @@ -146,7 +147,6 @@ objc2-photos = { version = "0.3.2", default-features = false, features = [ "dispatch2", ] } block2 = "0.6" -tauri-plugin-fs = "2" objc2-avf-audio = { version = "0.3", default-features = false, features = [ "AVAudioSession", "AVAudioSessionTypes", diff --git a/src-tauri/capabilities/android.json b/src-tauri/capabilities/android.json index fc3fa717e2..973636d655 100644 --- a/src-tauri/capabilities/android.json +++ b/src-tauri/capabilities/android.json @@ -4,6 +4,8 @@ "platforms": ["android"], "windows": ["main"], "permissions": [ + "dialog:allow-open", + "fs:allow-read-file", "android-fs:allow-check-public-files-permission", "android-fs:allow-create-new-public-file", "android-fs:allow-create-new-public-image-file", diff --git a/src-tauri/capabilities/ios.json b/src-tauri/capabilities/ios.json index c02f6dc521..9f65e10f58 100644 --- a/src-tauri/capabilities/ios.json +++ b/src-tauri/capabilities/ios.json @@ -3,5 +3,10 @@ "identifier": "ios-capability", "platforms": ["iOS"], "windows": ["main"], - "permissions": ["dialog:allow-save", "fs:allow-write-file"] + "permissions": [ + "dialog:allow-save", + "dialog:allow-open", + "fs:allow-write-file", + "fs:allow-read-file" + ] } diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 7fcfe176c5..788a07a570 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -352,6 +352,11 @@ pub fn run() { .plugin(tauri_plugin_dialog::init()) .plugin(tauri_plugin_fs::init()); + #[cfg(target_os = "android")] + let builder = builder + .plugin(tauri_plugin_dialog::init()) + .plugin(tauri_plugin_fs::init()); + #[cfg(mobile)] let builder = builder .plugin(tauri_plugin_edge_to_edge::init()) diff --git a/src/app/features/room/RoomInput.tsx b/src/app/features/room/RoomInput.tsx index d081c7e229..f76bae4cab 100644 --- a/src/app/features/room/RoomInput.tsx +++ b/src/app/features/room/RoomInput.tsx @@ -115,7 +115,7 @@ import { getEditedEvent, getMentionContent, getThreadReplyEvents } from '$utils/ import { buildReplacementContent } from './buildReplacementContent'; import { htmlToMarkdown } from '$plugins/markdown'; import { Command, SHRUG, TABLEFLIP, UNFLIP, useCommands } from '$hooks/useCommands'; -import { isMobileOrTablet } from '$utils/platform'; +import { isMobileOrTablet, isMobileTauri } from '$utils/platform'; import { Reply, ThreadIndicator } from '$components/message'; import { roomToParentsAtom } from '$state/room/roomToParents'; import { nicknamesAtom } from '$state/nicknames'; @@ -163,9 +163,6 @@ import { dropzoneIcon, File as FileIcon, Gif, - Image as ImageIcon, - ListBullets, - MapPinPlusIcon, menuIcon, Microphone, PaperPlaneTilt, @@ -217,6 +214,7 @@ import * as prefix from '$unstable/prefixes'; import { PollDialog } from './poll-modals'; import { useClientConfig } from '$hooks/useClientConfig'; import { PersonaPicker, type PersonaPickerTab } from './persona-picker/PersonaPicker.tsx'; +import { pickNativeFile } from './nativeFilePicker'; const LocationDialog = lazy(() => import('./location-modal').then((module) => ({ default: module.LocationDialog })) @@ -312,7 +310,6 @@ export const RoomInput = forwardRef( const clientConfig = useClientConfig(); const useAuthentication = useMediaAuthentication(); const [enterForNewline] = useSetting(settingsAtom, 'enterForNewline'); - const [editorOldAddFile] = useSetting(settingsAtom, 'editorOldAddFile'); const [editorGifButton] = useSetting(settingsAtom, 'editorGifButton'); const [editorEmojiButton] = useSetting(settingsAtom, 'editorEmojiButton'); const [editorStickerButton] = useSetting(settingsAtom, 'editorStickerButton'); @@ -492,6 +489,24 @@ export const RoomInput = forwardRef( [setSelectedFiles, room] ); const pickFile = useFilePicker(handleFiles, true); + const pickAttachment = useCallback( + async (pickerMode: 'media' | 'document', accept: string) => { + if (!isMobileTauri()) { + await pickFile(accept); + return; + } + + try { + const files = await pickNativeFile(pickerMode, (path, error) => { + log.warn('Failed to read native attachment file:', path, error); + }); + if (files.length > 0) await handleFiles(files); + } catch (error) { + log.error('Failed to open native attachment picker', { roomId }, error); + } + }, + [handleFiles, pickFile, roomId] + ); const handlePaste = useFilePasteHandler(handleFiles); const dropZoneVisible = useFileDropZone(fileDropContainerRef, handleFiles); const [hasText, setHasText] = useState(false); @@ -508,7 +523,6 @@ export const RoomInput = forwardRef( const [editingScheduledDelayId, setEditingScheduledDelayId] = useAtom( roomIdToEditingScheduledDelayIdAtomFamily(roomId) ); - const [AddMenuAnchor, setAddMenuAnchor] = useState(); const [showAttachmentSheet, setShowAttachmentSheet] = useState(false); const attachmentSkipReturnFocusRef = useRef(false); const [showPollPicker, setShowPollPicker] = useState(false); @@ -2063,7 +2077,7 @@ export const RoomInput = forwardRef( } before={ <> - {isMobileOrTablet() ? ( + {isMobileOrTablet() && ( <> { @@ -2091,10 +2105,10 @@ export const RoomInput = forwardRef( {() => ( { - pickFile('image/*,.tgs'); + void pickAttachment('media', 'image/*,video/*,.tgs'); }} onPickFile={() => { - pickFile('*'); + void pickAttachment('document', '*'); }} onPickPoll={() => { setShowPollPicker(true); @@ -2108,90 +2122,6 @@ export const RoomInput = forwardRef( )} - ) : ( - <> - setAddMenuAnchor(undefined), - clickOutsideDeactivates: true, - escapeDeactivates: stopPropagation, - }} - > - - - { - setAddMenuAnchor(undefined); - setShowPollPicker(true); - }} - before={menuIcon(ListBullets)} - > - Create Poll - - { - setAddMenuAnchor(undefined); - setShowLocationPicker(true); - }} - before={menuIcon(MapPinPlusIcon)} - > - Add Location - - { - pickFile('image/*,.tgs'); - setAddMenuAnchor(undefined); - }} - before={menuIcon(ImageIcon)} - > - Photos - - { - pickFile('*'); - setAddMenuAnchor(undefined); - }} - before={menuIcon(PlusCircle)} - > - Add File - - - - - } - /> - - editorOldAddFile - ? pickFile('*') - : setAddMenuAnchor(evt.currentTarget.getBoundingClientRect()) - } - onPointerDown={suppressEditorRefocus} - variant="SurfaceVariant" - size="300" - radii="300" - style={{ backgroundColor: 'transparent' }} - title={editorOldAddFile ? 'Upload File' : 'Add'} - aria-label={editorOldAddFile ? 'Upload and attach a File' : 'Add new Item'} - > - {composerIcon(PlusCircle)} - - )} {pmpPickerEnable && ( ({ + open: vi.fn< + (options: { + pickerMode: 'media' | 'document'; + multiple: true; + }) => Promise + >(), + readFile: vi.fn<(path: string) => Promise>(), +})); + +vi.mock('@tauri-apps/plugin-dialog', () => ({ open: mocks.open })); +vi.mock('@tauri-apps/plugin-fs', () => ({ readFile: mocks.readFile })); + +describe('pickNativeFile', () => { + beforeEach(() => { + mocks.open.mockResolvedValue(null); + mocks.readFile.mockResolvedValue(new Uint8Array([1, 2, 3])); + }); + + afterEach(() => { + vi.clearAllMocks(); + }); + + it('opens the native media picker and converts selected paths to Files', async () => { + mocks.open.mockResolvedValue(['/photos/My%20photo.JPG', '/videos/clip.mp4']); + + const files = await pickNativeFile('media'); + + expect(open).toHaveBeenCalledWith({ pickerMode: 'media', multiple: true }); + expect(readFile).toHaveBeenCalledWith('/photos/My%20photo.JPG'); + expect(readFile).toHaveBeenCalledWith('/videos/clip.mp4'); + expect(files.map(({ name, type }) => ({ name, type }))).toEqual([ + { name: 'My photo.JPG', type: 'image/jpeg' }, + { name: 'clip.mp4', type: 'video/mp4' }, + ]); + }); + + it('returns readable files when an individual read fails', async () => { + const failure = new Error('permission denied'); + mocks.open.mockResolvedValue(['/photos/readable.png', '/photos/unreadable.jpg']); + mocks.readFile.mockResolvedValueOnce(new Uint8Array([1])).mockRejectedValueOnce(failure); + const onReadFailure = vi.fn<(path: string, error: unknown) => void>(); + + const files = await pickNativeFile('media', onReadFailure); + + expect(files).toHaveLength(1); + expect(files[0]?.name).toBe('readable.png'); + expect(onReadFailure).toHaveBeenCalledWith('/photos/unreadable.jpg', failure); + }); + + it('does not read files after picker cancellation', async () => { + const files = await pickNativeFile('media'); + + expect(files).toEqual([]); + expect(readFile).not.toHaveBeenCalled(); + }); + + it('propagates picker errors without attempting another picker', async () => { + const error = new Error('picker failed'); + mocks.open.mockRejectedValue(error); + + await expect(pickNativeFile('media')).rejects.toBe(error); + expect(readFile).not.toHaveBeenCalled(); + }); + + it('opens the native document picker and converts selected paths to Files', async () => { + mocks.open.mockResolvedValue('/documents/report.pdf'); + + const files = await pickNativeFile('document'); + + expect(open).toHaveBeenCalledWith({ pickerMode: 'document', multiple: true }); + expect(readFile).toHaveBeenCalledWith('/documents/report.pdf'); + expect(files.map(({ name, type }) => ({ name, type }))).toEqual([ + { name: 'report.pdf', type: 'application/pdf' }, + ]); + }); +}); diff --git a/src/app/features/room/nativeFilePicker.ts b/src/app/features/room/nativeFilePicker.ts new file mode 100644 index 0000000000..2b5d5a3693 --- /dev/null +++ b/src/app/features/room/nativeFilePicker.ts @@ -0,0 +1,74 @@ +import { FALLBACK_MIMETYPE, TGS_MIMETYPE } from '$utils/mimeTypes'; + +const MIME_TYPES_BY_EXTENSION: Record = { + apng: 'image/apng', + avif: 'image/avif', + bmp: 'image/bmp', + gif: 'image/gif', + heic: 'image/heic', + heif: 'image/heif', + json: 'application/json', + jpeg: 'image/jpeg', + jpg: 'image/jpeg', + md: 'text/markdown', + mov: 'video/quicktime', + mp4: 'video/mp4', + m4v: 'video/mp4', + ogg: 'video/ogg', + ogv: 'video/ogg', + pdf: 'application/pdf', + png: 'image/png', + svg: 'image/svg+xml', + tgs: TGS_MIMETYPE, + txt: 'text/plain', + webm: 'video/webm', + webp: 'image/webp', +}; + +export type NativePickerMode = 'media' | 'document'; +export type NativeFileReadFailureHandler = (path: string, error: unknown) => void; + +const normalizeSelectedPaths = (selected: string | string[] | null | undefined): string[] => + (typeof selected === 'string' ? [selected] : (selected ?? [])).filter((path) => path.length > 0); + +const getFileName = (path: string, index: number): string => { + const pathName = path.split(/[\\/]/).pop(); + if (!pathName) return `attachment-${index + 1}`; + + try { + return decodeURIComponent(pathName); + } catch { + return pathName; + } +}; + +const getMimeType = (fileName: string): string => { + const extension = fileName.split('.').pop()?.toLowerCase(); + return extension ? (MIME_TYPES_BY_EXTENSION[extension] ?? FALLBACK_MIMETYPE) : FALLBACK_MIMETYPE; +}; + +export const pickNativeFile = async ( + pickerMode: NativePickerMode, + onReadFailure?: NativeFileReadFailureHandler +): Promise => { + const { open } = await import('@tauri-apps/plugin-dialog'); + const selected = await open({ pickerMode, multiple: true }); + const paths = normalizeSelectedPaths(selected); + if (paths.length === 0) return []; + + const { readFile } = await import('@tauri-apps/plugin-fs'); + const files = await Promise.all( + paths.map(async (path, index): Promise => { + try { + const name = getFileName(path, index); + const contents = await readFile(path); + return new File([contents], name, { type: getMimeType(name) }); + } catch (error) { + onReadFailure?.(path, error); + return undefined; + } + }) + ); + + return files.filter((file): file is File => file !== undefined); +}; From c018f25b57730764f9a1718ec6e500bb22a67f58 Mon Sep 17 00:00:00 2001 From: Erwan Leboucher Date: Fri, 31 Jul 2026 10:58:16 +0200 Subject: [PATCH 2/2] fix(mobile): use android-fs for android pickers and clean up ios copies --- src-tauri/Cargo.toml | 2 +- src-tauri/capabilities/android.json | 5 +- src-tauri/capabilities/ios.json | 9 +- src-tauri/src/lib.rs | 5 - src/app/features/room/RoomInput.tsx | 116 +++++++-- .../features/room/nativeFilePicker.test.ts | 229 ++++++++++++++---- src/app/features/room/nativeFilePicker.ts | 80 +++++- 7 files changed, 368 insertions(+), 78 deletions(-) diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index b0fa29a23c..c8c27dac89 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -124,7 +124,6 @@ tauri-plugin-notifications = { git = "https://github.com/SableClient/tauri-plugi ] } tauri-plugin-edge-to-edge = { git = "https://github.com/SableClient/tauri-plugin-edge-to-edge.git", rev = "33c6116c27be28c06df5a9d02231ecc5fdeb93c5" } tauri-plugin-sharekit = { git = "https://github.com/Choochmeque/tauri-plugin-sharekit", rev = "9f2b4c5d8a4f0ab910d900ba234ed8bae0ab854b" } -tauri-plugin-fs = "2" [target.'cfg(target_os = "ios")'.dependencies] objc2 = "0.6" @@ -147,6 +146,7 @@ objc2-photos = { version = "0.3.2", default-features = false, features = [ "dispatch2", ] } block2 = "0.6" +tauri-plugin-fs = "2" objc2-avf-audio = { version = "0.3", default-features = false, features = [ "AVAudioSession", "AVAudioSessionTypes", diff --git a/src-tauri/capabilities/android.json b/src-tauri/capabilities/android.json index 973636d655..82fbd4e44a 100644 --- a/src-tauri/capabilities/android.json +++ b/src-tauri/capabilities/android.json @@ -4,8 +4,9 @@ "platforms": ["android"], "windows": ["main"], "permissions": [ - "dialog:allow-open", - "fs:allow-read-file", + "android-fs:allow-show-open-file-picker", + "android-fs:allow-get-metadata", + "android-fs:allow-read-file", "android-fs:allow-check-public-files-permission", "android-fs:allow-create-new-public-file", "android-fs:allow-create-new-public-image-file", diff --git a/src-tauri/capabilities/ios.json b/src-tauri/capabilities/ios.json index 9f65e10f58..823ad5a7d5 100644 --- a/src-tauri/capabilities/ios.json +++ b/src-tauri/capabilities/ios.json @@ -7,6 +7,13 @@ "dialog:allow-save", "dialog:allow-open", "fs:allow-write-file", - "fs:allow-read-file" + { + "identifier": "fs:allow-read-file", + "allow": [{ "path": "$TEMP/**/*" }, { "path": "$APPCACHE/**/*" }] + }, + { + "identifier": "fs:allow-remove", + "allow": [{ "path": "$TEMP/**/*" }, { "path": "$APPCACHE/**/*" }] + } ] } diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 788a07a570..7fcfe176c5 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -352,11 +352,6 @@ pub fn run() { .plugin(tauri_plugin_dialog::init()) .plugin(tauri_plugin_fs::init()); - #[cfg(target_os = "android")] - let builder = builder - .plugin(tauri_plugin_dialog::init()) - .plugin(tauri_plugin_fs::init()); - #[cfg(mobile)] let builder = builder .plugin(tauri_plugin_edge_to_edge::init()) diff --git a/src/app/features/room/RoomInput.tsx b/src/app/features/room/RoomInput.tsx index f76bae4cab..cab8f4199b 100644 --- a/src/app/features/room/RoomInput.tsx +++ b/src/app/features/room/RoomInput.tsx @@ -163,6 +163,8 @@ import { dropzoneIcon, File as FileIcon, Gif, + ListBullets, + MapPinPlusIcon, menuIcon, Microphone, PaperPlaneTilt, @@ -310,6 +312,7 @@ export const RoomInput = forwardRef( const clientConfig = useClientConfig(); const useAuthentication = useMediaAuthentication(); const [enterForNewline] = useSetting(settingsAtom, 'enterForNewline'); + const [editorOldAddFile] = useSetting(settingsAtom, 'editorOldAddFile'); const [editorGifButton] = useSetting(settingsAtom, 'editorGifButton'); const [editorEmojiButton] = useSetting(settingsAtom, 'editorEmojiButton'); const [editorStickerButton] = useSetting(settingsAtom, 'editorStickerButton'); @@ -428,22 +431,27 @@ export const RoomInput = forwardRef( }, []); const handleFiles = useCallback( - async (files: File[], audioMeta?: { waveform: number[]; audioDuration: number }) => { + async ( + files: File[], + audioMeta?: { waveform: number[]; audioDuration: number }, + options?: { alreadyInMemory?: boolean } + ) => { setUploadBoard(true); const safeFiles = await Promise.all(files.map(safeUploadFile)); // Eager-read to avoid Android content URI expiry after SAF picker - const blobbedFiles = isMobileOrTablet() - ? await Promise.all( - safeFiles.map(async (f) => { - try { - const buf = await f.arrayBuffer(); - return new File([buf], f.name, { type: f.type, lastModified: f.lastModified }); - } catch { - return f; - } - }) - ) - : safeFiles; + const blobbedFiles = + isMobileOrTablet() && !options?.alreadyInMemory + ? await Promise.all( + safeFiles.map(async (f) => { + try { + const buf = await f.arrayBuffer(); + return new File([buf], f.name, { type: f.type, lastModified: f.lastModified }); + } catch { + return f; + } + }) + ) + : safeFiles; const makeMetadata = () => ({ markedAsSpoiler: false, waveform: audioMeta?.waveform, @@ -497,10 +505,10 @@ export const RoomInput = forwardRef( } try { - const files = await pickNativeFile(pickerMode, (path, error) => { - log.warn('Failed to read native attachment file:', path, error); + const files = await pickNativeFile(pickerMode, (source, error) => { + log.warn('Native attachment file error:', source, error); }); - if (files.length > 0) await handleFiles(files); + if (files.length > 0) await handleFiles(files, undefined, { alreadyInMemory: true }); } catch (error) { log.error('Failed to open native attachment picker', { roomId }, error); } @@ -523,6 +531,7 @@ export const RoomInput = forwardRef( const [editingScheduledDelayId, setEditingScheduledDelayId] = useAtom( roomIdToEditingScheduledDelayIdAtomFamily(roomId) ); + const [AddMenuAnchor, setAddMenuAnchor] = useState(); const [showAttachmentSheet, setShowAttachmentSheet] = useState(false); const attachmentSkipReturnFocusRef = useRef(false); const [showPollPicker, setShowPollPicker] = useState(false); @@ -2077,7 +2086,7 @@ export const RoomInput = forwardRef( } before={ <> - {isMobileOrTablet() && ( + {isMobileOrTablet() ? ( <> { @@ -2122,6 +2131,79 @@ export const RoomInput = forwardRef( )} + ) : ( + <> + setAddMenuAnchor(undefined), + clickOutsideDeactivates: true, + escapeDeactivates: stopPropagation, + }} + > + + + { + setAddMenuAnchor(undefined); + setShowPollPicker(true); + }} + before={menuIcon(ListBullets)} + > + Create Poll + + { + setAddMenuAnchor(undefined); + setShowLocationPicker(true); + }} + before={menuIcon(MapPinPlusIcon)} + > + Add Location + + { + pickFile('*'); + setAddMenuAnchor(undefined); + }} + before={menuIcon(PlusCircle)} + > + Add File + + + + + } + /> + + editorOldAddFile + ? pickFile('*') + : setAddMenuAnchor(evt.currentTarget.getBoundingClientRect()) + } + onPointerDown={suppressEditorRefocus} + variant="SurfaceVariant" + size="300" + radii="300" + style={{ backgroundColor: 'transparent' }} + title={editorOldAddFile ? 'Upload File' : 'Add'} + aria-label={editorOldAddFile ? 'Upload and attach a File' : 'Add new Item'} + > + {composerIcon(PlusCircle)} + + )} {pmpPickerEnable && ( ({ open: vi.fn< (options: { @@ -11,72 +16,212 @@ const mocks = vi.hoisted(() => ({ }) => Promise >(), readFile: vi.fn<(path: string) => Promise>(), + remove: vi.fn<(path: string) => Promise>(), + androidFs: { + showOpenFilePicker: + vi.fn< + (options: { + pickerType: 'Gallery' | 'FilePicker'; + mimeTypes: string[]; + multiple: boolean; + }) => Promise + >(), + getMetadata: vi.fn<(uri: AndroidUri) => Promise>(), + readFile: vi.fn<(uri: AndroidUri) => Promise>(), + }, + isAndroidTauri: vi.fn<() => boolean>(), })); vi.mock('@tauri-apps/plugin-dialog', () => ({ open: mocks.open })); -vi.mock('@tauri-apps/plugin-fs', () => ({ readFile: mocks.readFile })); +vi.mock('@tauri-apps/plugin-fs', () => ({ readFile: mocks.readFile, remove: mocks.remove })); +vi.mock('tauri-plugin-android-fs-api', () => ({ AndroidFs: mocks.androidFs })); +vi.mock('$utils/platform', () => ({ isAndroidTauri: mocks.isAndroidTauri })); + +const androidUri = (uri: string): AndroidUri => ({ uri, documentTopTreeUri: null }); describe('pickNativeFile', () => { beforeEach(() => { + mocks.isAndroidTauri.mockReturnValue(false); mocks.open.mockResolvedValue(null); mocks.readFile.mockResolvedValue(new Uint8Array([1, 2, 3])); + mocks.remove.mockResolvedValue(undefined); + mocks.androidFs.showOpenFilePicker.mockResolvedValue([]); + mocks.androidFs.readFile.mockResolvedValue(new Uint8Array([1, 2, 3])); }); afterEach(() => { vi.clearAllMocks(); }); - it('opens the native media picker and converts selected paths to Files', async () => { - mocks.open.mockResolvedValue(['/photos/My%20photo.JPG', '/videos/clip.mp4']); + describe('on iOS', () => { + it('opens the native media picker and converts selected files', async () => { + mocks.open.mockResolvedValue([ + 'file:///var/mobile/Library/Caches/My%20photo.JPG', + 'file:///var/mobile/Library/Caches/clip.mp4', + ]); - const files = await pickNativeFile('media'); + const files = await pickNativeFile('media'); - expect(open).toHaveBeenCalledWith({ pickerMode: 'media', multiple: true }); - expect(readFile).toHaveBeenCalledWith('/photos/My%20photo.JPG'); - expect(readFile).toHaveBeenCalledWith('/videos/clip.mp4'); - expect(files.map(({ name, type }) => ({ name, type }))).toEqual([ - { name: 'My photo.JPG', type: 'image/jpeg' }, - { name: 'clip.mp4', type: 'video/mp4' }, - ]); - }); + expect(open).toHaveBeenCalledWith({ pickerMode: 'media', multiple: true }); + expect(files.map(({ name, type }) => ({ name, type }))).toEqual([ + { name: 'My photo.JPG', type: 'image/jpeg' }, + { name: 'clip.mp4', type: 'video/mp4' }, + ]); + }); - it('returns readable files when an individual read fails', async () => { - const failure = new Error('permission denied'); - mocks.open.mockResolvedValue(['/photos/readable.png', '/photos/unreadable.jpg']); - mocks.readFile.mockResolvedValueOnce(new Uint8Array([1])).mockRejectedValueOnce(failure); - const onReadFailure = vi.fn<(path: string, error: unknown) => void>(); + it('deletes the sandbox copies the picker handed over', async () => { + mocks.open.mockResolvedValue(['/Caches/a.png', '/Caches/b.pdf']); - const files = await pickNativeFile('media', onReadFailure); + await pickNativeFile('document'); - expect(files).toHaveLength(1); - expect(files[0]?.name).toBe('readable.png'); - expect(onReadFailure).toHaveBeenCalledWith('/photos/unreadable.jpg', failure); - }); + expect(readFile).toHaveBeenCalledWith('/Caches/a.png'); + expect(remove).toHaveBeenCalledWith('/Caches/a.png'); + expect(remove).toHaveBeenCalledWith('/Caches/b.pdf'); + }); - it('does not read files after picker cancellation', async () => { - const files = await pickNativeFile('media'); + it('reports read failures, keeps readable files and still deletes the copies', async () => { + const failure = new Error('permission denied'); + mocks.open.mockResolvedValue(['/Caches/readable.png', '/Caches/unreadable.jpg']); + mocks.readFile.mockResolvedValueOnce(new Uint8Array([1])).mockRejectedValueOnce(failure); + const onFileFailure = vi.fn<(source: string, error: unknown) => void>(); - expect(files).toEqual([]); - expect(readFile).not.toHaveBeenCalled(); - }); + const files = await pickNativeFile('media', onFileFailure); - it('propagates picker errors without attempting another picker', async () => { - const error = new Error('picker failed'); - mocks.open.mockRejectedValue(error); + expect(files).toHaveLength(1); + expect(files[0]?.name).toBe('readable.png'); + expect(onFileFailure).toHaveBeenCalledWith('/Caches/unreadable.jpg', failure); + expect(remove).toHaveBeenCalledWith('/Caches/unreadable.jpg'); + }); - await expect(pickNativeFile('media')).rejects.toBe(error); - expect(readFile).not.toHaveBeenCalled(); - }); + it('reports a failed cleanup without dropping the file', async () => { + const failure = new Error('cleanup failed'); + mocks.open.mockResolvedValue(['/Caches/photo.png']); + mocks.remove.mockRejectedValue(failure); + const onFileFailure = vi.fn<(source: string, error: unknown) => void>(); + + const files = await pickNativeFile('media', onFileFailure); - it('opens the native document picker and converts selected paths to Files', async () => { - mocks.open.mockResolvedValue('/documents/report.pdf'); + expect(files).toHaveLength(1); + expect(onFileFailure).toHaveBeenCalledWith('/Caches/photo.png', failure); + }); - const files = await pickNativeFile('document'); + it('does not read files after picker cancellation', async () => { + const files = await pickNativeFile('media'); + + expect(files).toEqual([]); + expect(readFile).not.toHaveBeenCalled(); + expect(remove).not.toHaveBeenCalled(); + }); + + it('propagates picker errors without attempting another picker', async () => { + const error = new Error('picker failed'); + mocks.open.mockRejectedValue(error); + + await expect(pickNativeFile('media')).rejects.toBe(error); + expect(readFile).not.toHaveBeenCalled(); + }); + + it('opens the native document picker for documents', async () => { + mocks.open.mockResolvedValue('/documents/report.pdf'); + + const files = await pickNativeFile('document'); + + expect(open).toHaveBeenCalledWith({ pickerMode: 'document', multiple: true }); + expect(files.map(({ name, type }) => ({ name, type }))).toEqual([ + { name: 'report.pdf', type: 'application/pdf' }, + ]); + }); + }); - expect(open).toHaveBeenCalledWith({ pickerMode: 'document', multiple: true }); - expect(readFile).toHaveBeenCalledWith('/documents/report.pdf'); - expect(files.map(({ name, type }) => ({ name, type }))).toEqual([ - { name: 'report.pdf', type: 'application/pdf' }, - ]); + describe('on Android', () => { + beforeEach(() => { + mocks.isAndroidTauri.mockReturnValue(true); + }); + + it('picks media through the gallery and takes name and mime from the provider', async () => { + const uri = androidUri('content://media/external/images/media/1000000034'); + mocks.androidFs.showOpenFilePicker.mockResolvedValue([uri]); + mocks.androidFs.getMetadata.mockResolvedValue({ + type: 'File', + name: '1000000034.png', + lastModified: new Date(1700000000000), + byteLength: 3, + mimeType: 'image/png', + }); + + const files = await pickNativeFile('media'); + + expect(mocks.androidFs.showOpenFilePicker).toHaveBeenCalledWith({ + pickerType: 'Gallery', + mimeTypes: ['image/*', 'video/*'], + multiple: true, + }); + expect(mocks.androidFs.readFile).toHaveBeenCalledWith(uri); + expect(open).not.toHaveBeenCalled(); + expect(files.map(({ name, type, lastModified }) => ({ name, type, lastModified }))).toEqual([ + { name: '1000000034.png', type: 'image/png', lastModified: 1700000000000 }, + ]); + }); + + it('picks documents through the file picker', async () => { + mocks.androidFs.showOpenFilePicker.mockResolvedValue([androidUri('content://docs/1')]); + mocks.androidFs.getMetadata.mockResolvedValue({ + type: 'File', + name: 'report.pdf', + lastModified: new Date(1700000000000), + byteLength: 3, + mimeType: 'application/pdf', + }); + + const files = await pickNativeFile('document'); + + expect(mocks.androidFs.showOpenFilePicker).toHaveBeenCalledWith({ + pickerType: 'FilePicker', + mimeTypes: [], + multiple: true, + }); + expect(files.map(({ name, type }) => ({ name, type }))).toEqual([ + { name: 'report.pdf', type: 'application/pdf' }, + ]); + }); + + it('falls back to the extension when the provider reports a generic mime type', async () => { + mocks.androidFs.showOpenFilePicker.mockResolvedValue([androidUri('content://docs/2')]); + mocks.androidFs.getMetadata.mockResolvedValue({ + type: 'File', + name: 'sticker.tgs', + lastModified: new Date(1700000000000), + byteLength: 3, + mimeType: 'application/octet-stream', + }); + + const files = await pickNativeFile('document'); + + expect(files[0]?.type).toBe('application/x-tgsticker'); + }); + + it('reports failures per file and skips directories', async () => { + const failure = new Error('no read permission'); + const dir = androidUri('content://docs/dir'); + const broken = androidUri('content://docs/broken'); + mocks.androidFs.showOpenFilePicker.mockResolvedValue([dir, broken]); + mocks.androidFs.getMetadata + .mockResolvedValueOnce({ type: 'Dir', name: 'folder', lastModified: new Date(0) }) + .mockRejectedValueOnce(failure); + const onFileFailure = vi.fn<(source: string, error: unknown) => void>(); + + const files = await pickNativeFile('document', onFileFailure); + + expect(files).toEqual([]); + expect(onFileFailure).toHaveBeenCalledWith('content://docs/broken', failure); + expect(mocks.androidFs.readFile).not.toHaveBeenCalled(); + }); + + it('returns no files when the picker is cancelled', async () => { + const files = await pickNativeFile('media'); + + expect(files).toEqual([]); + expect(mocks.androidFs.getMetadata).not.toHaveBeenCalled(); + }); }); }); diff --git a/src/app/features/room/nativeFilePicker.ts b/src/app/features/room/nativeFilePicker.ts index 2b5d5a3693..07e3223c68 100644 --- a/src/app/features/room/nativeFilePicker.ts +++ b/src/app/features/room/nativeFilePicker.ts @@ -1,32 +1,42 @@ import { FALLBACK_MIMETYPE, TGS_MIMETYPE } from '$utils/mimeTypes'; +import { isAndroidTauri } from '$utils/platform'; const MIME_TYPES_BY_EXTENSION: Record = { + aac: 'audio/aac', apng: 'image/apng', avif: 'image/avif', bmp: 'image/bmp', + flac: 'audio/flac', gif: 'image/gif', heic: 'image/heic', heif: 'image/heif', json: 'application/json', jpeg: 'image/jpeg', jpg: 'image/jpeg', + m4a: 'audio/mp4', md: 'text/markdown', mov: 'video/quicktime', + mp3: 'audio/mpeg', mp4: 'video/mp4', m4v: 'video/mp4', - ogg: 'video/ogg', + oga: 'audio/ogg', + ogg: 'audio/ogg', ogv: 'video/ogg', + opus: 'audio/opus', pdf: 'application/pdf', png: 'image/png', svg: 'image/svg+xml', tgs: TGS_MIMETYPE, txt: 'text/plain', + wav: 'audio/wav', webm: 'video/webm', webp: 'image/webp', }; +const MEDIA_MIME_TYPES = ['image/*', 'video/*']; + export type NativePickerMode = 'media' | 'document'; -export type NativeFileReadFailureHandler = (path: string, error: unknown) => void; +export type NativeFileFailureHandler = (source: string, error: unknown) => void; const normalizeSelectedPaths = (selected: string | string[] | null | undefined): string[] => (typeof selected === 'string' ? [selected] : (selected ?? [])).filter((path) => path.length > 0); @@ -42,33 +52,83 @@ const getFileName = (path: string, index: number): string => { } }; -const getMimeType = (fileName: string): string => { +const getMimeTypeFromName = (fileName: string): string => { const extension = fileName.split('.').pop()?.toLowerCase(); return extension ? (MIME_TYPES_BY_EXTENSION[extension] ?? FALLBACK_MIMETYPE) : FALLBACK_MIMETYPE; }; -export const pickNativeFile = async ( +const resolveMimeType = (fileName: string, reportedMimeType?: string): string => + reportedMimeType && reportedMimeType !== FALLBACK_MIMETYPE + ? reportedMimeType + : getMimeTypeFromName(fileName); + +const isFile = (file: File | undefined): file is File => file !== undefined; + +const pickAndroidFiles = async ( pickerMode: NativePickerMode, - onReadFailure?: NativeFileReadFailureHandler + onFileFailure?: NativeFileFailureHandler +): Promise => { + const { AndroidFs } = await import('tauri-plugin-android-fs-api'); + const uris = await AndroidFs.showOpenFilePicker({ + pickerType: pickerMode === 'media' ? 'Gallery' : 'FilePicker', + mimeTypes: pickerMode === 'media' ? MEDIA_MIME_TYPES : [], + multiple: true, + }); + + const files = await Promise.all( + uris.map(async (uri, index): Promise => { + try { + const metadata = await AndroidFs.getMetadata(uri); + if (metadata.type !== 'File') return undefined; + + const name = metadata.name || `attachment-${index + 1}`; + const contents = await AndroidFs.readFile(uri); + return new File([contents], name, { + type: resolveMimeType(name, metadata.mimeType), + lastModified: metadata.lastModified.getTime(), + }); + } catch (error) { + onFileFailure?.(uri.uri, error); + return undefined; + } + }) + ); + + return files.filter(isFile); +}; + +const pickIosFiles = async ( + pickerMode: NativePickerMode, + onFileFailure?: NativeFileFailureHandler ): Promise => { const { open } = await import('@tauri-apps/plugin-dialog'); const selected = await open({ pickerMode, multiple: true }); const paths = normalizeSelectedPaths(selected); if (paths.length === 0) return []; - const { readFile } = await import('@tauri-apps/plugin-fs'); + const { readFile, remove } = await import('@tauri-apps/plugin-fs'); const files = await Promise.all( paths.map(async (path, index): Promise => { + const name = getFileName(path, index); try { - const name = getFileName(path, index); const contents = await readFile(path); - return new File([contents], name, { type: getMimeType(name) }); + return new File([contents], name, { type: getMimeTypeFromName(name) }); } catch (error) { - onReadFailure?.(path, error); + onFileFailure?.(path, error); return undefined; + } finally { + await remove(path).catch((error: unknown) => onFileFailure?.(path, error)); } }) ); - return files.filter((file): file is File => file !== undefined); + return files.filter(isFile); }; + +export const pickNativeFile = async ( + pickerMode: NativePickerMode, + onFileFailure?: NativeFileFailureHandler +): Promise => + isAndroidTauri() + ? pickAndroidFiles(pickerMode, onFileFailure) + : pickIosFiles(pickerMode, onFileFailure);