diff --git a/apps/readest-app/src/__tests__/helpers/open-with.test.ts b/apps/readest-app/src/__tests__/helpers/open-with.test.ts index dfd12938..fd51111a 100644 --- a/apps/readest-app/src/__tests__/helpers/open-with.test.ts +++ b/apps/readest-app/src/__tests__/helpers/open-with.test.ts @@ -19,7 +19,7 @@ vi.mock('@tauri-apps/plugin-cli', () => ({ getMatches: () => mockGetMatches(), })); -import { parseOpenWithFiles } from '@/helpers/openWith'; +import { parseOpenWithFiles, shouldOpenTransient } from '@/helpers/openWith'; // Helper type matching the AppService subset used in openWith interface MockAppService { @@ -260,3 +260,26 @@ describe('parseOpenWithFiles', () => { }); }); }); + +describe('shouldOpenTransient', () => { + // Android "Open with" (VIEW) is the only intent that can open a file as a + // transient book, and only when the user has turned auto-import off. + test('VIEW with auto-import off opens transiently', () => { + expect(shouldOpenTransient('VIEW', false)).toBe(true); + }); + + test('VIEW with auto-import on imports into the library', () => { + expect(shouldOpenTransient('VIEW', true)).toBe(false); + }); + + // Share-sheet SEND captures always import to the library regardless. + test('SEND always imports into the library', () => { + expect(shouldOpenTransient('SEND', false)).toBe(false); + expect(shouldOpenTransient('SEND', true)).toBe(false); + }); + + test('undefined action always imports into the library', () => { + expect(shouldOpenTransient(undefined, false)).toBe(false); + expect(shouldOpenTransient(undefined, true)).toBe(false); + }); +}); diff --git a/apps/readest-app/src/app/library/components/SettingsMenu.tsx b/apps/readest-app/src/app/library/components/SettingsMenu.tsx index f81846cb..72149263 100644 --- a/apps/readest-app/src/app/library/components/SettingsMenu.tsx +++ b/apps/readest-app/src/app/library/components/SettingsMenu.tsx @@ -376,7 +376,7 @@ const SettingsMenu: React.FC = ({ onPullLibrary, setIsDropdow onClick={toggleAutoUploadBooks} /> - {isTauriAppPlatform() && !appService?.isMobile && ( + {isTauriAppPlatform() && ( { return null; }; +/** + * Decide whether an "Open with" file intent should open as a transient book + * (straight to the reader, no library write) or be imported into the library. + * + * Only Android's `VIEW` intent (the system "Open with Readest" chooser) can be + * transient, and only when the user has turned off "Auto Import on File Open". + * Every other case — a share-sheet `SEND` capture, or `VIEW` with auto-import + * on — imports the file so it persists in the library (and syncs to the cloud + * on mobile). + */ +export const shouldOpenTransient = ( + action: 'VIEW' | 'SEND' | undefined, + autoImportBooksOnOpen: boolean, +): boolean => { + return action === 'VIEW' && !autoImportBooksOnOpen; +}; + export const parseOpenWithFiles = async (appService: AppService | null) => { if (isWebAppPlatform()) return []; diff --git a/apps/readest-app/src/hooks/useOpenWithBooks.ts b/apps/readest-app/src/hooks/useOpenWithBooks.ts index 8c4d4de3..76c26fff 100644 --- a/apps/readest-app/src/hooks/useOpenWithBooks.ts +++ b/apps/readest-app/src/hooks/useOpenWithBooks.ts @@ -3,11 +3,11 @@ import { useRouter } from 'next/navigation'; import { getAllWindows, getCurrentWindow } from '@tauri-apps/api/window'; import { useEnv } from '@/context/EnvContext'; import { useLibraryStore } from '@/store/libraryStore'; -import { useSettingsStore } from '@/store/settingsStore'; import { isTauriAppPlatform } from '@/services/environment'; import { navigateToLibrary, navigateToReader, showLibraryWindow } from '@/utils/nav'; import { eventDispatcher } from '@/utils/event'; import { partialMD5 } from '@/utils/md5'; +import { shouldOpenTransient } from '@/helpers/openWith'; /** * Handle "Open with Readest" file imports. Consumes the `app-incoming-url` @@ -20,11 +20,13 @@ import { partialMD5 } from '@/utils/md5'; * Mount this hook alongside `useAppUrlIngress` so the ingress dispatcher is * actually running when URLs arrive. * - * Two routing modes by `action`: + * Two routing modes by `action`, gated by the `autoImportBooksOnOpen` setting + * (see `shouldOpenTransient`): * - * `'VIEW'` (Android only — user picked Readest from the system "Open with" - * chooser for an epub/pdf): we MUST NOT silently import the file into the - * library. We first hash the file ourselves and check the in-memory library: + * `'VIEW'` with auto-import OFF (Android only — user picked Readest from the + * system "Open with" chooser for an epub/pdf): open the file as a transient + * book without importing it. We first hash the file ourselves and check the + * in-memory library: * - if a non-deleted entry with the same hash already exists, jump * straight into the reader on that entry — no `importBook` call, so the * managed `Books//` filePath, createdAt and reading progress are @@ -35,10 +37,11 @@ import { partialMD5 } from '@/utils/md5'; * uploading to the cloud, then navigate to the reader on that hash. * * `'SEND'` / undefined (iOS / macOS / desktop / Android share-sheet - * capture): keep the existing flow that pushes the URLs through + * capture), and `'VIEW'` with auto-import ON: push the URLs through * `window.OPEN_WITH_FILES` so `library/page.tsx::processOpenWithFiles` - * does a full ingest + cloud upload — that's the contract a "Send to - * Readest" share is meant to honour. + * does a full ingest + cloud upload — the file lands in the library and + * syncs, which is what a "Send to Readest" share (and an opt-in "Open + * with" import) is meant to honour. */ export function useOpenWithBooks() { const router = useRouter(); @@ -155,8 +158,18 @@ export function useOpenWithBooks() { const filePaths = normalizeUrls(urls); if (filePaths.length === 0) return; - // Android "Open with" → straight to reader, no library write. - if (action === 'VIEW') { + // Read the persisted setting from disk rather than the settings store: + // on a cold-start "Open with" the store may not be hydrated yet (it's + // seeded by the library page's init effect, which races the queued + // intent replay), and an unhydrated store would wrongly fall back to a + // transient open for a user who has auto-import on. + const settings = await appService.loadSettings(); + + // Android "Open with" with auto-import off → straight to reader, no + // library write. When auto-import is on, fall through to the library + // ingest path below so the file is copied into the managed library + // and synced (the default on mobile). + if (shouldOpenTransient(action, settings.autoImportBooksOnOpen)) { // If a reader is already mounted, ignore the second tap rather // than try to swap books underneath it. The in-place URL swap // would otherwise leave ReaderContent's init effect (gated by @@ -172,7 +185,6 @@ export function useOpenWithBooks() { return; } - const settings = useSettingsStore.getState().settings; if (appService?.hasWindow && settings.openBookInNewWindow) { if (await isFirstWindow()) { showLibraryWindow(appService, filePaths); diff --git a/apps/readest-app/src/services/constants.ts b/apps/readest-app/src/services/constants.ts index 7b8f7209..b35acd72 100644 --- a/apps/readest-app/src/services/constants.ts +++ b/apps/readest-app/src/services/constants.ts @@ -172,6 +172,9 @@ export const DEFAULT_SYSTEM_SETTINGS: Partial = { export const DEFAULT_MOBILE_SYSTEM_SETTINGS: Partial = { libraryColumns: 3, + // Import files opened via the system "Open with" chooser into the library by + // default so they persist and sync, instead of opening them transiently. + autoImportBooksOnOpen: true, }; export const HIGHLIGHT_COLOR_HEX: Record = {