forked from akai/readest
7185dca1a2
* 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>
121 lines
4.1 KiB
TypeScript
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' })),
|
|
);
|
|
});
|
|
});
|