fix(dict): resolve Android content-URI filenames via native basename (#4553)

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) <noreply@anthropic.com>
This commit is contained in:
Huang Xin
2026-06-12 18:01:29 +08:00
committed by GitHub
parent 12ac7ae6c0
commit 61d804a54f
4 changed files with 90 additions and 24 deletions
@@ -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);
}
});
});
@@ -761,6 +761,9 @@ const CustomDictionaries: React.FC<CustomDictionariesProps> = ({ onBack }) => {
>
<span
className={clsx(
// eink-inverted keeps the "+" legible on its dark badge (#4454);
// without it the badge collapses to a solid black spot in eink.
'eink-inverted',
'flex h-5 w-5 items-center justify-center rounded-full',
'bg-base-200 text-base-content/60',
'transition-colors duration-150',
@@ -789,6 +792,9 @@ const CustomDictionaries: React.FC<CustomDictionariesProps> = ({ onBack }) => {
>
<span
className={clsx(
// eink-inverted keeps the "+" legible on its dark badge (#4454);
// without it the badge collapses to a solid black spot in eink.
'eink-inverted',
'flex h-5 w-5 items-center justify-center rounded-full',
'bg-base-200 text-base-content/60',
'transition-colors duration-150',
+46 -23
View File
@@ -1,7 +1,8 @@
import { AppService } from '@/types/system';
import { isTauriAppPlatform } from '@/services/environment';
import { basename } from '@tauri-apps/api/path';
import { stubTranslation as _ } from '@/utils/misc';
import { isContentURI, isFileURI, stubTranslation as _ } from '@/utils/misc';
import { getFilename } from '@/utils/path';
import { BOOK_ACCEPT_FORMATS, SUPPORTED_BOOK_EXTS } from '@/services/constants';
export interface FileSelectorOptions {
@@ -19,6 +20,14 @@ export interface SelectedFile {
// For Tauri file
path?: string;
basePath?: string;
// Resolved display name (with extension). For Tauri `content://` / `file://`
// URIs the `path` may not carry the filename/extension at all (opaque SAF
// document ids on some Android devices), so the native content resolver is
// queried up front and the real DISPLAY_NAME stored here. Consumers that
// classify by extension (dictionary bundle grouping) must use this, not a
// naive parse of `path`. See #4489.
name?: string;
}
export interface FileSelectionResult {
@@ -40,11 +49,32 @@ const selectFileWeb = (options: FileSelectorOptions): Promise<File[]> => {
});
};
/**
* 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<string> => {
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<string[]> => {
): Promise<SelectedFile[]> => {
// 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);
@@ -92,7 +92,12 @@ async function readSource(fs: FileSystem, source: SelectedFile): Promise<File> {
}
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('.');