Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 11 additions & 9 deletions src/app/components/image-viewer/ImageViewer.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import type { IImageInfo } from '$types/matrix/common';

const downloadMedia = vi.fn<(src: string) => Promise<Blob>>();
const saveMediaToGallery =
vi.fn<(input: string, filename: string, mimeType: string) => Promise<void>>();
vi.fn<(input: Blob | string, filename: string, mimeType: string) => Promise<void>>();
const toastMocks = vi.hoisted(() => ({
showToast: vi.fn<(text: string, durationMs?: number) => void>(),
}));
Expand Down Expand Up @@ -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', () => ({
Expand Down Expand Up @@ -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', () => {
Expand All @@ -277,22 +281,20 @@ 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' } });

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();
});

Expand Down
34 changes: 17 additions & 17 deletions src/app/components/image-viewer/ImageViewer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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}`);
Expand Down Expand Up @@ -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',
});
Expand All @@ -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);
Expand All @@ -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();
Expand Down
34 changes: 23 additions & 11 deletions src/app/utils/download.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Response>>(),
fetchMediaBlob: vi.fn<(input: string) => Promise<Blob>>(),
}));
const { androidFs, save, writeFile } = mocks;

Expand All @@ -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' },
Expand Down Expand Up @@ -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', () => {
Expand Down Expand Up @@ -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');

Expand All @@ -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 () => {
Expand Down Expand Up @@ -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');

Expand All @@ -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');

Expand All @@ -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');

Expand Down
9 changes: 3 additions & 6 deletions src/app/utils/download.ts
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -53,11 +54,7 @@ export const getDownloadFilename = (

async function resolveBlob(input: Blob | string): Promise<Blob> {
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(
Expand Down
Loading