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:
@@ -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',
|
||||
|
||||
@@ -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('.');
|
||||
|
||||
Reference in New Issue
Block a user