diff --git a/src/app/components/image-viewer/ImageViewer.test.tsx b/src/app/components/image-viewer/ImageViewer.test.tsx index 9fa01ea8a..55160a4a1 100644 --- a/src/app/components/image-viewer/ImageViewer.test.tsx +++ b/src/app/components/image-viewer/ImageViewer.test.tsx @@ -8,7 +8,7 @@ import type { IImageInfo } from '$types/matrix/common'; const downloadMedia = vi.fn<(src: string) => Promise>(); const saveMediaToGallery = - vi.fn<(input: string, filename: string, mimeType: string) => Promise>(); + vi.fn<(input: Blob | string, filename: string, mimeType: string) => Promise>(); const toastMocks = vi.hoisted(() => ({ showToast: vi.fn<(text: string, durationMs?: number) => void>(), })); @@ -50,7 +50,7 @@ vi.mock('$utils/matrix', () => ({ })); vi.mock('$utils/download', async (importOriginal) => ({ ...(await importOriginal()), - saveMediaToGallery: (...args: [string, string, string]) => saveMediaToGallery(...args), + saveMediaToGallery: (...args: [Blob | string, string, string]) => saveMediaToGallery(...args), })); vi.mock('file-saver', () => ({ @@ -254,15 +254,19 @@ describe('ImageViewer', () => { mockPlatform('android'); const source = 'https://matrix.example.org/_matrix/client/v1/media/download/example.org/kitten'; const src = `https://sable-media.localhost/${encodeURIComponent(source)}?__sable_media_cache=3`; + const blob = new Blob(['image'], { type: 'image/png' }); saveMediaToGallery.mockClear(); + downloadMedia.mockClear(); + downloadMedia.mockResolvedValue(blob); renderViewer({ src, info: { mimetype: 'image/png' } }); fireEvent.contextMenu(screen.getByAltText('kitten.png')); fireEvent.click(screen.getByText('Save to Gallery')); await waitFor(() => - expect(saveMediaToGallery).toHaveBeenCalledWith(source, 'kitten.png', 'image/png') + expect(saveMediaToGallery).toHaveBeenCalledWith(blob, 'kitten.png', 'image/png') ); + expect(downloadMedia).toHaveBeenCalledWith(source); }); it('labels the primary action Save to Photos on iOS without duplicating it in the overflow menu', () => { @@ -277,8 +281,10 @@ describe('ImageViewer', () => { it('routes the primary iOS action for trusted images straight to Photos', async () => { mockPlatform('ios'); + const blob = new Blob(['image'], { type: 'image/png' }); saveMediaToGallery.mockClear(); downloadMedia.mockClear(); + downloadMedia.mockResolvedValue(blob); vi.mocked(FileSaver.saveAs).mockClear(); renderViewer({ info: { mimetype: 'image/png' } }); @@ -286,13 +292,9 @@ describe('ImageViewer', () => { fireEvent.click(screen.getByText('Save to Photos')); await waitFor(() => - expect(saveMediaToGallery).toHaveBeenCalledWith( - 'https://example.org/kitten.png', - 'kitten.png', - 'image/png' - ) + expect(saveMediaToGallery).toHaveBeenCalledWith(blob, 'kitten.png', 'image/png') ); - expect(downloadMedia).not.toHaveBeenCalled(); + expect(downloadMedia).toHaveBeenCalledWith('https://example.org/kitten.png'); expect(FileSaver.saveAs).not.toHaveBeenCalled(); }); diff --git a/src/app/components/image-viewer/ImageViewer.tsx b/src/app/components/image-viewer/ImageViewer.tsx index 3b60789fa..2fd2c286e 100644 --- a/src/app/components/image-viewer/ImageViewer.tsx +++ b/src/app/components/image-viewer/ImageViewer.tsx @@ -156,20 +156,26 @@ export const ImageViewer = as<'div', ImageViewerProps>( const downloadFilename = getDownloadFilename(filename, alt, 'image'); const canSaveToGallery = isAndroidTauri() && (galleryMimeType?.startsWith('image/') ?? false); + const loadDownloadBlob = () => + getDownloadBlob ? getDownloadBlob() : downloadMedia(getTauriMediaSourceUrl(src) ?? src); + + const saveToGallery = async () => { + try { + await saveMediaToGallery(await loadDownloadBlob(), downloadFilename, galleryMimeType!); + } catch (error) { + const message = error instanceof Error ? error.message : 'unknown error'; + showToast(`Failed to save to gallery: ${message}`); + } + }; + const handleDownload = async () => { if (iosSaveToPhotos) { - await saveMediaToGallery( - getDownloadBlob ? await getDownloadBlob() : src, - downloadFilename, - galleryMimeType! - ); + await saveToGallery(); return; } let fileContent: Blob; try { - fileContent = await (getDownloadBlob - ? getDownloadBlob() - : downloadMedia(getTauriMediaSourceUrl(src) ?? src)); + fileContent = await loadDownloadBlob(); } catch (error) { const message = error instanceof Error ? error.message : 'unknown error'; showToast(`Failed to download file: ${message}`); @@ -214,7 +220,7 @@ export const ImageViewer = as<'div', ImageViewerProps>( const shareActivation = useMobileTapActivation(isMobile, () => { void (async () => { try { - const blob = await (getDownloadBlob ? getDownloadBlob() : downloadMedia(src)); + const blob = await loadDownloadBlob(); const file = new File([blob], downloadFilename, { type: blob.type || galleryMimeType || 'application/octet-stream', }); @@ -228,7 +234,7 @@ export const ImageViewer = as<'div', ImageViewerProps>( }); const copyImageActivation = useMobileTapActivation(isMobile, () => { closeMenu(); - void (getDownloadBlob ? getDownloadBlob() : downloadMedia(src)).then(copyImageToClipboard); + void loadDownloadBlob().then(copyImageToClipboard); }); const pixelatedMenuActivation = useMobileTapActivation(isMobile, () => { setIsPixelated(!isPixelated); @@ -242,13 +248,7 @@ export const ImageViewer = as<'div', ImageViewerProps>( const nextActivation = useMobileTapActivation(isMobile, () => onNext?.()); const galleryActivation = useMobileTapActivation(isMobile, () => { closeMenu(); - void (async () => { - await saveMediaToGallery( - getDownloadBlob ? await getDownloadBlob() : (getTauriMediaSourceUrl(src) ?? src), - downloadFilename, - galleryMimeType! - ); - })(); + void saveToGallery(); }); const resetZoomMenuActivation = useMobileTapActivation(isMobile, () => { resetTransforms(); diff --git a/src/app/utils/download.test.ts b/src/app/utils/download.test.ts index a8e8f4cde..29934dcd0 100644 --- a/src/app/utils/download.test.ts +++ b/src/app/utils/download.test.ts @@ -23,7 +23,7 @@ const mocks = vi.hoisted(() => ({ isTauri: vi.fn<() => boolean>(), osType: vi.fn<() => string>(), showToast: vi.fn<(text: string, durationMs?: number) => void>(), - fetch: vi.fn<(input: string) => Promise>(), + fetchMediaBlob: vi.fn<(input: string) => Promise>(), })); const { androidFs, save, writeFile } = mocks; @@ -34,7 +34,7 @@ vi.mock('@tauri-apps/api/core', () => ({ })); vi.mock('@tauri-apps/plugin-os', () => ({ type: mocks.osType })); vi.mock('$state/toast', () => ({ showToast: mocks.showToast })); -vi.mock('$utils/fetch', () => ({ fetch: mocks.fetch })); +vi.mock('$utils/mediaTransport', () => ({ fetchMediaBlob: mocks.fetchMediaBlob })); vi.mock('tauri-plugin-android-fs-api', () => ({ AndroidFs: mocks.androidFs, AndroidPublicGeneralPurposeDir: { Download: 'Download' }, @@ -121,6 +121,22 @@ describe('saveFileToDevice', () => { expect(result).toBe('saved'); expect(FileSaver.saveAs).toHaveBeenCalledWith(expect.any(Blob), 'file.txt'); }); + + it('uses authenticated media transport when saving a URL on Android', async () => { + const blob = new Blob(['data'], { type: 'image/png' }); + mocks.fetchMediaBlob.mockResolvedValue(blob); + + await expect( + saveFileToDevice( + 'https://matrix.example.org/_matrix/client/v1/media/download/example.org/photo', + 'photo.png' + ) + ).resolves.toBe('saved'); + + expect(mocks.fetchMediaBlob).toHaveBeenCalledWith( + 'https://matrix.example.org/_matrix/client/v1/media/download/example.org/photo' + ); + }); }); describe('downloadJsonFile', () => { @@ -171,7 +187,7 @@ describe('saveMediaToGallery', () => { it('writes all fetched Android image bytes before publishing the gallery file', async () => { const bytes = new Uint8Array([0x89, 0x50, 0x4e, 0x47]); - mocks.fetch.mockResolvedValueOnce(new Response(bytes, { status: 200 })); + mocks.fetchMediaBlob.mockResolvedValueOnce(new Blob([bytes])); await saveMediaToGallery('https://matrix.example.org/photo', 'photo.png', 'image/png'); @@ -180,6 +196,7 @@ describe('saveMediaToGallery', () => { androidFs.setPublicFilePending.mock.invocationCallOrder[0]! ); expect(androidFs.setPublicFilePending).toHaveBeenCalledWith('content://media/image', false); + expect(mocks.fetchMediaBlob).toHaveBeenCalledWith('https://matrix.example.org/photo'); }); it('does not create a gallery file when Android storage permission is denied', async () => { @@ -288,7 +305,7 @@ describe('saveMediaToGallery', () => { }); it('shows exactly one gallery failure toast when fetching the media fails on Android', async () => { - mocks.fetch.mockRejectedValueOnce(new Error('network down')); + mocks.fetchMediaBlob.mockRejectedValueOnce(new Error('network down')); await saveMediaToGallery('mxc://example/photo.png', 'photo.png', 'image/png'); @@ -301,9 +318,7 @@ describe('saveMediaToGallery', () => { }); it('does not save an HTTP error response as an Android gallery image', async () => { - mocks.fetch.mockResolvedValueOnce( - new Response('not found', { status: 404, statusText: 'Not Found' }) - ); + mocks.fetchMediaBlob.mockRejectedValueOnce(new Error('Failed to fetch media: 404 Not Found')); await saveMediaToGallery('mxc://example/missing.png', 'missing.png', 'image/png'); @@ -317,10 +332,7 @@ describe('saveMediaToGallery', () => { it('shows exactly one photos failure toast when blob conversion fails on iOS', async () => { vi.mocked(osType).mockReturnValue('ios'); - mocks.fetch.mockResolvedValueOnce({ - ok: true, - blob: () => Promise.reject(new Error('decode failed')), - } as unknown as Response); + mocks.fetchMediaBlob.mockRejectedValueOnce(new Error('decode failed')); await saveMediaToGallery('mxc://example/photo.png', 'photo.png', 'image/png'); diff --git a/src/app/utils/download.ts b/src/app/utils/download.ts index cd15781f5..864e6dc83 100644 --- a/src/app/utils/download.ts +++ b/src/app/utils/download.ts @@ -1,8 +1,9 @@ import FileSaver from 'file-saver'; import { invoke, isTauri } from '@tauri-apps/api/core'; import { type as osType } from '@tauri-apps/plugin-os'; -import { fetch } from '$utils/fetch'; import { showToast } from '$state/toast'; +import { fetchMediaBlob } from '$utils/mediaTransport'; +import { getTauriMediaSourceUrl } from '$utils/mediaUrl'; const INVALID_FILENAME_CHARS = /[<>:"/\\|?*]/g; const CONTROL_CHARS = /\p{Cc}/gu; @@ -53,11 +54,7 @@ export const getDownloadFilename = ( async function resolveBlob(input: Blob | string): Promise { if (typeof input !== 'string') return input; - const response = await fetch(input); - if (!response.ok) { - throw new Error(`Failed to fetch media: ${response.status} ${response.statusText}`); - } - return response.blob(); + return fetchMediaBlob(getTauriMediaSourceUrl(input) ?? input); } export async function saveMediaToGallery(