diff --git a/src-tauri/capabilities/android.json b/src-tauri/capabilities/android.json index fc3fa717e2..82fbd4e44a 100644 --- a/src-tauri/capabilities/android.json +++ b/src-tauri/capabilities/android.json @@ -4,6 +4,9 @@ "platforms": ["android"], "windows": ["main"], "permissions": [ + "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 c02f6dc521..823ad5a7d5 100644 --- a/src-tauri/capabilities/ios.json +++ b/src-tauri/capabilities/ios.json @@ -3,5 +3,17 @@ "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", + { + "identifier": "fs:allow-read-file", + "allow": [{ "path": "$TEMP/**/*" }, { "path": "$APPCACHE/**/*" }] + }, + { + "identifier": "fs:allow-remove", + "allow": [{ "path": "$TEMP/**/*" }, { "path": "$APPCACHE/**/*" }] + } + ] } diff --git a/src/app/features/room/RoomInput.tsx b/src/app/features/room/RoomInput.tsx index d081c7e229..cab8f4199b 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,7 +163,6 @@ import { dropzoneIcon, File as FileIcon, Gif, - Image as ImageIcon, ListBullets, MapPinPlusIcon, menuIcon, @@ -217,6 +216,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 })) @@ -431,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, @@ -492,6 +497,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, (source, error) => { + log.warn('Native attachment file error:', source, error); + }); + if (files.length > 0) await handleFiles(files, undefined, { alreadyInMemory: true }); + } 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); @@ -2091,10 +2114,10 @@ export const RoomInput = forwardRef( {() => ( { - pickFile('image/*,.tgs'); + void pickAttachment('media', 'image/*,video/*,.tgs'); }} onPickFile={() => { - pickFile('*'); + void pickAttachment('document', '*'); }} onPickPoll={() => { setShowPollPicker(true); @@ -2148,17 +2171,6 @@ export const RoomInput = forwardRef( > Add Location - { - pickFile('image/*,.tgs'); - setAddMenuAnchor(undefined); - }} - before={menuIcon(ImageIcon)} - > - Photos - ({ + open: vi.fn< + (options: { + pickerMode: 'media' | 'document'; + multiple: true; + }) => 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, 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(); + }); + + 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'); + + 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('deletes the sandbox copies the picker handed over', async () => { + mocks.open.mockResolvedValue(['/Caches/a.png', '/Caches/b.pdf']); + + await pickNativeFile('document'); + + expect(readFile).toHaveBeenCalledWith('/Caches/a.png'); + expect(remove).toHaveBeenCalledWith('/Caches/a.png'); + expect(remove).toHaveBeenCalledWith('/Caches/b.pdf'); + }); + + 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>(); + + const files = await pickNativeFile('media', onFileFailure); + + expect(files).toHaveLength(1); + expect(files[0]?.name).toBe('readable.png'); + expect(onFileFailure).toHaveBeenCalledWith('/Caches/unreadable.jpg', failure); + expect(remove).toHaveBeenCalledWith('/Caches/unreadable.jpg'); + }); + + 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); + + expect(files).toHaveLength(1); + expect(onFileFailure).toHaveBeenCalledWith('/Caches/photo.png', failure); + }); + + 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' }, + ]); + }); + }); + + 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 new file mode 100644 index 0000000000..07e3223c68 --- /dev/null +++ b/src/app/features/room/nativeFilePicker.ts @@ -0,0 +1,134 @@ +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', + 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 NativeFileFailureHandler = (source: 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 getMimeTypeFromName = (fileName: string): string => { + const extension = fileName.split('.').pop()?.toLowerCase(); + return extension ? (MIME_TYPES_BY_EXTENSION[extension] ?? FALLBACK_MIMETYPE) : FALLBACK_MIMETYPE; +}; + +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, + 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, 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 contents = await readFile(path); + return new File([contents], name, { type: getMimeTypeFromName(name) }); + } catch (error) { + onFileFailure?.(path, error); + return undefined; + } finally { + await remove(path).catch((error: unknown) => onFileFailure?.(path, error)); + } + }) + ); + + return files.filter(isFile); +}; + +export const pickNativeFile = async ( + pickerMode: NativePickerMode, + onFileFailure?: NativeFileFailureHandler +): Promise => + isAndroidTauri() + ? pickAndroidFiles(pickerMode, onFileFailure) + : pickIosFiles(pickerMode, onFileFailure);