fix(library): import books opened via "Open with" by default on mobile (#4746) (#4747)

A recent change (#4407) made Android's "Open with Readest" (VIEW intent,
used by Telegram and similar apps) always open the file as a transient
book: into the reader but never written to the library, with its filePath
pointing at the original content:// URI. Once that temporary URI grant
dies the book can no longer be reopened, so the user has to re-share it
from the source app every time.

Make the transient behavior an opt-out gated by the existing
autoImportBooksOnOpen setting, and surface it on mobile:

- The VIEW handler now consults the setting via a new shouldOpenTransient
  predicate. When auto-import is on it falls through to the same library
  ingest path as a share-sheet SEND (full ingest plus cloud upload); when
  off it keeps the transient open.
- Read the setting from disk in the handler rather than the settings
  store, since on a cold-start "Open with" the store is not hydrated yet
  and would wrongly fall back to a transient open.
- Show the "Auto Import on File Open" toggle on mobile (was desktop only).
- Default autoImportBooksOnOpen to true on mobile so shared files persist
  and sync by default; the desktop default is unchanged.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Huang Xin
2026-06-24 00:28:29 +08:00
committed by GitHub
parent acd4a67dcf
commit 6301c620a8
5 changed files with 68 additions and 13 deletions
@@ -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);
});
});
@@ -376,7 +376,7 @@ const SettingsMenu: React.FC<SettingsMenuProps> = ({ onPullLibrary, setIsDropdow
onClick={toggleAutoUploadBooks}
/>
{isTauriAppPlatform() && !appService?.isMobile && (
{isTauriAppPlatform() && (
<MenuItem
label={_('Auto Import on File Open')}
toggled={isAutoImportBooksOnOpen}
+17
View File
@@ -60,6 +60,23 @@ const parseIntentOpenWithFiles = async (appService: AppService | null) => {
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 [];
+23 -11
View File
@@ -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/<hash>/` 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);
@@ -172,6 +172,9 @@ export const DEFAULT_SYSTEM_SETTINGS: Partial<SystemSettings> = {
export const DEFAULT_MOBILE_SYSTEM_SETTINGS: Partial<SystemSettings> = {
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<HighlightColor, string> = {