From 61d804a54f9196a7f8fc995e9826536e9dd452bb Mon Sep 17 00:00:00 2001 From: Huang Xin Date: Fri, 12 Jun 2026 18:01:29 +0800 Subject: [PATCH] fix(dict): resolve Android content-URI filenames via native basename (#4553) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit On some Android devices the SAF picker returns an opaque, extension-less content:// document URI (e.g. .../downloads.documents/document/msf%3A20). Dictionary bundle grouping derived each filename from getFilename() — a pure string-parse of the URI — so no .ifo/.idx/.dict marker was found, every file was orphaned, and the user saw "Skipped incomplete bundles" even though the bundle was complete. Devices whose URI happens to embed the name (e.g. primary%3ADictionaries%3A21cen.dict.dz) worked, which is why it reproduced only on some Android devices. The same string-parse also wrote the bundle files (and synced metadata / contentId) under the mangled URI-segment names, so a re-import elsewhere did not dedupe. tauri's Android path.file_name (basename) special-cases content:// / file:// URIs and queries the content resolver for the real DISPLAY_NAME — the same call AppService.openFile already relies on. Resolve the display name once at selection time, store it on SelectedFile.name, and have bundle grouping classify by that name instead of re-parsing the URI. The old extension filter already used basename but discarded the resolved name; threading it through removes that divergence. Also fix the Settings -> Dictionaries "+" badges (Import Dictionary / Add Web Search) collapsing to a black spot in e-ink mode by adding eink-inverted, mirroring the font import button (#4454). Fixes #4489 Fixes #4472 Co-authored-by: Claude Opus 4.8 (1M context) --- .../dictionaries/groupBundlesByStem.test.ts | 32 +++++++++ .../settings/CustomDictionaries.tsx | 6 ++ apps/readest-app/src/hooks/useFileSelector.ts | 69 ++++++++++++------- .../dictionaries/dictionaryService.ts | 7 +- 4 files changed, 90 insertions(+), 24 deletions(-) diff --git a/apps/readest-app/src/__tests__/services/dictionaries/groupBundlesByStem.test.ts b/apps/readest-app/src/__tests__/services/dictionaries/groupBundlesByStem.test.ts index 6e69abff..60aec2bc 100644 --- a/apps/readest-app/src/__tests__/services/dictionaries/groupBundlesByStem.test.ts +++ b/apps/readest-app/src/__tests__/services/dictionaries/groupBundlesByStem.test.ts @@ -88,3 +88,35 @@ describe('groupBundlesByStem — MDict companion MDDs', () => { expect(r.orphans).toEqual(['Base-01.mdd', 'Base-02.mdd']); }); }); + +describe('groupBundlesByStem — Android content-URI display names (#4489)', () => { + // On some Android devices the SAF picker returns an opaque `content://` + // document id that carries no filename/extension in the URI string (e.g. + // `.../document/document%3A1001`). The file selector resolves the real + // DISPLAY_NAME via the native content resolver into `SelectedFile.name`; + // bundle grouping must classify by that name, not by parsing the ext-less + // URI path — otherwise every file is orphaned and the user sees the bogus + // "Skipped incomplete bundles" error even though the bundle is complete. + const cu = (name: string, id: number): SelectedFile => ({ + path: `content://com.android.providers.media.documents/document/document%3A${id}`, + name, + }); + + it('forms a complete StarDict bundle from ext-less content URIs via name', () => { + const { bundles, orphans } = groupBundlesByStem([ + cu('21cen.ifo', 1001), + cu('21cen.idx', 1002), + cu('21cen.dict.dz', 1003), + ]); + expect(orphans).toEqual([]); + expect(bundles).toHaveLength(1); + const b = bundles[0]!; + expect(b.kind).toBe('stardict'); + if (b.kind === 'stardict') { + expect(b.ifo.name).toBe('21cen.ifo'); + expect(b.idx.name).toBe('21cen.idx'); + expect(b.dict.name).toBe('21cen.dict.dz'); + expect(b.dict.isDictZip).toBe(true); + } + }); +}); diff --git a/apps/readest-app/src/components/settings/CustomDictionaries.tsx b/apps/readest-app/src/components/settings/CustomDictionaries.tsx index f6703154..c4d3cb70 100644 --- a/apps/readest-app/src/components/settings/CustomDictionaries.tsx +++ b/apps/readest-app/src/components/settings/CustomDictionaries.tsx @@ -761,6 +761,9 @@ const CustomDictionaries: React.FC = ({ onBack }) => { > = ({ onBack }) => { > => { }); }; +/** + * Resolve the real display name (with extension) for a picked Tauri path. + * + * On Android a SAF `content://` URI may be an opaque document id that carries + * no filename/extension in the URI string at all (varies by device / provider + * — #4489); on iOS a security-scoped `file://` URI is likewise unreliable. + * For those we query the native content resolver via `basename` (the same call + * `AppService.openFile` uses). Plain filesystem paths parse fine with + * `getFilename`. + */ +const resolveTauriFileName = async (path: string, appService: AppService): Promise => { + if (isContentURI(path) || (isFileURI(path) && appService.isIOSApp)) { + try { + return await basename(path); + } catch { + // Fall through to a best-effort string parse. + } + } + return getFilename(path); +}; + const selectFileTauri = async ( options: FileSelectorOptions, appService: AppService, _: (key: string) => string, -): Promise => { +): Promise => { // Android's SAF picker filters by MIME type. Niche/custom extensions // (e.g. ".mrexpt" from Moon+ Reader) have no registered MIME and would // appear greyed-out, so for those cases we ask the native side for an @@ -58,21 +88,21 @@ const selectFileTauri = async ( (options.type === 'books' || options.type === 'dictionaries' || options.type === 'generic')); const exts = noFilter ? [] : options.extensions || []; const title = options.dialogTitle || _('Select Files'); - let files = (await appService?.selectFiles(_(title), exts)) || []; + const paths = (await appService?.selectFiles(_(title), exts)) || []; + + // Resolve the display name once, up front. Both the extension whitelist + // below and downstream consumers (dictionary bundle grouping) must classify + // by this resolved name rather than parsing the raw URI — see #4489. + let files: SelectedFile[] = await Promise.all( + paths.map(async (path) => ({ path, name: await resolveTauriFileName(path, appService) })), + ); if (noFilter && options.extensions) { - files = await Promise.all( - files.map(async (file: string) => { - let processedFile = file; - if (appService?.isAndroidApp && file.startsWith('content://')) { - processedFile = await basename(file); - } - const fileExt = processedFile.split('.').pop()?.toLowerCase() || 'unknown'; - const extensions = options.extensions!; - const shouldInclude = extensions.includes(fileExt) || extensions.includes('*'); - return shouldInclude ? file : null; - }), - ).then((results) => results.filter((file) => file !== null)); + const extensions = options.extensions; + files = files.filter(({ name }) => { + const fileExt = name?.split('.').pop()?.toLowerCase() || 'unknown'; + return extensions.includes(fileExt) || extensions.includes('*'); + }); } return files; @@ -84,12 +114,6 @@ const processWebFiles = (files: File[]): SelectedFile[] => { })); }; -const processTauriFiles = (files: string[]): SelectedFile[] => { - return files.map((path) => ({ - path, - })); -}; - export const useFileSelector = (appService: AppService | null, _: (key: string) => string) => { const selectFiles = async (options: FileSelectorOptions = { type: 'generic' }) => { options = { ...FILE_SELECTION_PRESETS[options.type], ...options }; @@ -98,8 +122,7 @@ export const useFileSelector = (appService: AppService | null, _: (key: string) } try { if (isTauriAppPlatform()) { - const filePaths = await selectFileTauri(options, appService, _); - const files = await processTauriFiles(filePaths); + const files = await selectFileTauri(options, appService, _); return { files }; } else { const webFiles = await selectFileWeb(options); diff --git a/apps/readest-app/src/services/dictionaries/dictionaryService.ts b/apps/readest-app/src/services/dictionaries/dictionaryService.ts index ad9a3224..3d94cc0f 100644 --- a/apps/readest-app/src/services/dictionaries/dictionaryService.ts +++ b/apps/readest-app/src/services/dictionaries/dictionaryService.ts @@ -92,7 +92,12 @@ async function readSource(fs: FileSystem, source: SelectedFile): Promise { } function classify(source: SelectedFile): SourceFile { - const rawName = source.file?.name ?? (source.path ? getFilename(source.path) : ''); + // Prefer the resolved display name. For Tauri `content://` URIs the path may + // be an opaque SAF document id with no filename/extension (some Android + // devices, #4489); `source.name` carries the real DISPLAY_NAME resolved via + // the native content resolver. `getFilename(path)` is only a last-resort + // fallback for the rare case neither is set. + const rawName = source.file?.name ?? source.name ?? (source.path ? getFilename(source.path) : ''); const name = rawName; const lower = name.toLowerCase(); const lastDot = lower.lastIndexOf('.');