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('.');