Files
readest/apps/readest-app/src/__tests__/components/ImageViewerSave.test.tsx
T
Huang Xin 7185dca1a2 feat(reader): add save/share button to image gallery toolbar (#4680)
* feat(reader): add save/share button to image gallery toolbar

Add a button to the top-right toolbar of the fullscreen image viewer
that saves the currently viewed image to the device. It uses the native
or web Share flow where available (iOS/Android/macOS, navigator.share)
and falls back to a save dialog or browser download otherwise, reusing
the existing export path via appService.saveFile.

The button icon and label reflect the active flow (share vs save).
Adds dataUrlToBytes/imageExtensionFromMime helpers, unit and component
tests, and translations for the new strings across all locales.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(share): write shareable file to a Temp subdirectory to avoid 0-byte share

On Android, Tauri's Temp dir is the app cache dir, and the sharekit plugin
copies the shared file to <cacheDir>/<name> before firing the share intent.
When saveFile wrote the shareable file to the Temp root, that copy became a
copy onto itself whose output stream truncated the source to 0 bytes, so the
shared image (and any shared export) arrived as a 0 KB file. Write the file
to a Temp subdirectory instead so the plugin's copy has a distinct source.

Verified on a Xiaomi device: sharing a file in the Temp root truncated it to
0 bytes, while sharing from the subdirectory produced a real, non-empty copy.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(reader): save image to system gallery on Android

The Android share sheet cannot save an image to a file (no file manager
registers as an ACTION_SEND target), so the Save Image button now writes
the image straight into the system photo gallery via MediaStore. It lands
in Pictures/Readest, visible in Gallery and the Files app, with no picker
and no storage permission on Android 10+.

Adds a save_image_to_gallery command to the native-bridge plugin (Rust +
Kotlin MediaStore insert) and an appService.saveImageToGallery method. On
Android the Save button uses it; iOS/macOS/desktop/web keep the existing
share/export flow, and the button label/icon reflect the actual action.

Also includes local agent memory notes that were staged alongside.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-20 06:28:08 +02:00

121 lines
4.1 KiB
TypeScript

import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { render, cleanup, fireEvent, waitFor } from '@testing-library/react';
import ImageViewer from '@/app/reader/components/ImageViewer';
vi.mock('@/hooks/useTranslation', () => ({
useTranslation: () => (s: string) => s,
}));
vi.mock('@/hooks/useKeyDownActions', () => ({
useKeyDownActions: () => {},
}));
vi.mock('@/store/themeStore', () => ({
useThemeStore: () => ({ systemUIVisible: false, statusBarHeight: 0 }),
}));
const h = vi.hoisted(() => ({
appService: null as {
isMobileApp: boolean;
isMacOSApp: boolean;
isAndroidApp?: boolean;
saveFile: ReturnType<typeof vi.fn>;
saveImageToGallery?: ReturnType<typeof vi.fn>;
} | null,
dispatch: vi.fn(),
}));
vi.mock('@/context/EnvContext', () => ({
useEnv: () => ({ appService: h.appService }),
}));
vi.mock('@/utils/event', () => ({
eventDispatcher: { dispatch: h.dispatch },
}));
// "AAEC" base64 decodes to bytes [0, 1, 2].
const PNG_DATA_URL = 'data:image/png;base64,AAEC';
const gridInsets = { top: 0, right: 0, bottom: 0, left: 0 };
beforeEach(() => {
h.appService = null;
h.dispatch.mockReset();
});
afterEach(cleanup);
describe('ImageViewer save/share button', () => {
it('exports the image and toasts when sharing is unavailable', async () => {
const saveFile = vi.fn().mockResolvedValue(true);
h.appService = { isMobileApp: false, isMacOSApp: false, saveFile };
const { getByLabelText } = render(
<ImageViewer src={PNG_DATA_URL} onClose={vi.fn()} gridInsets={gridInsets} />,
);
// No native/web share → the affordance is the "Save Image" (export) button.
fireEvent.click(getByLabelText('Save Image'));
await waitFor(() => expect(saveFile).toHaveBeenCalledTimes(1));
const [filename, content, options] = saveFile.mock.calls[0]!;
expect(filename).toBe('image.png');
expect(content).toBeInstanceOf(ArrayBuffer);
expect(new Uint8Array(content as ArrayBuffer)).toEqual(new Uint8Array([0, 1, 2]));
expect(options).toMatchObject({ share: true, mimeType: 'image/png' });
expect(options.sharePosition).toMatchObject({ preferredEdge: 'bottom' });
// Export path confirms with a toast.
await waitFor(() =>
expect(h.dispatch).toHaveBeenCalledWith('toast', expect.objectContaining({ type: 'info' })),
);
});
it('shares via saveFile without a toast on a share-capable platform', async () => {
const saveFile = vi.fn().mockResolvedValue(true);
h.appService = { isMobileApp: true, isMacOSApp: false, saveFile };
const { getByLabelText } = render(
<ImageViewer src={PNG_DATA_URL} onClose={vi.fn()} gridInsets={gridInsets} />,
);
// Share-capable → the affordance is the "Share Image" button.
fireEvent.click(getByLabelText('Share Image'));
await waitFor(() => expect(saveFile).toHaveBeenCalledTimes(1));
expect(saveFile.mock.calls[0]![2]).toMatchObject({ share: true });
// The OS share sheet is its own feedback; no toast on the share path.
expect(h.dispatch).not.toHaveBeenCalled();
});
it('saves to the photo gallery on Android instead of sharing', async () => {
const saveImageToGallery = vi.fn().mockResolvedValue(true);
const saveFile = vi.fn().mockResolvedValue(true);
h.appService = {
isMobileApp: true,
isMacOSApp: false,
isAndroidApp: true,
saveFile,
saveImageToGallery,
};
const { getByLabelText } = render(
<ImageViewer src={PNG_DATA_URL} onClose={vi.fn()} gridInsets={gridInsets} />,
);
// Android saves to the gallery, so the affordance is "Save Image", not Share.
fireEvent.click(getByLabelText('Save Image'));
await waitFor(() => expect(saveImageToGallery).toHaveBeenCalledTimes(1));
const [filename, content, mime] = saveImageToGallery.mock.calls[0]!;
expect(filename).toBe('image.png');
expect(content).toBeInstanceOf(ArrayBuffer);
expect(mime).toBe('image/png');
// It must NOT fall through to the share sheet on Android.
expect(saveFile).not.toHaveBeenCalled();
await waitFor(() =>
expect(h.dispatch).toHaveBeenCalledWith('toast', expect.objectContaining({ type: 'info' })),
);
});
});