Skip to content

Commit e06500c

Browse files
authored
Fix Android style file selection (#1806)
1 parent 8f05318 commit e06500c

7 files changed

Lines changed: 111 additions & 6 deletions

File tree

apps/geolibre-desktop/src/components/panels/LayerPanel.tsx

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1775,6 +1775,10 @@ export function LayerPanel({
17751775
extensions: ["json", "sld", "qml", "xml"],
17761776
},
17771777
],
1778+
// Android filters document pickers by MIME type, but SLD and QML do
1779+
// not have consistently reported MIME types. Leave the native picker
1780+
// broad there, then validate the selected file by content below.
1781+
androidFilters: [],
17781782
accept: ".json,.sld,.qml,.xml,application/json,application/xml,text/xml",
17791783
readText: true,
17801784
});

apps/geolibre-desktop/src/components/panels/StyleManagerPanel.tsx

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -354,6 +354,9 @@ export function StyleManagerPanel() {
354354
extensions: ["json", "qml", "sld", "xml"],
355355
},
356356
],
357+
// Android cannot reliably map the SLD/QML extensions to MIME types.
358+
// Accept any document there and validate its contents after selection.
359+
androidFilters: [],
357360
accept: ".json,.qml,.sld,.xml,application/json,application/xml,text/xml",
358361
readText: true,
359362
});
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
import { isAndroid } from "./is-mobile";
2+
3+
export interface FileDialogFilter {
4+
name: string;
5+
extensions: string[];
6+
}
7+
8+
/**
9+
* Select native file-dialog filters for the current platform.
10+
*
11+
* Android's document picker filters by MIME type and cannot reliably map
12+
* uncommon filename extensions. Callers can therefore provide a separate
13+
* Android filter set while retaining precise extension filters on desktop and
14+
* iOS.
15+
*
16+
* @param filters - Default file filters used outside Android.
17+
* @param androidFilters - Android-specific filters, when required.
18+
* @param userAgent - Override for testing; defaults to `navigator.userAgent`.
19+
* @returns The filters appropriate for the current platform.
20+
*/
21+
export function nativeFileDialogFilters(
22+
filters: FileDialogFilter[],
23+
androidFilters: FileDialogFilter[] | undefined,
24+
userAgent: string = typeof navigator !== "undefined" ? navigator.userAgent : "",
25+
): FileDialogFilter[] {
26+
return isAndroid(userAgent) && androidFilters !== undefined ? androidFilters : filters;
27+
}

apps/geolibre-desktop/src/lib/is-mobile.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,22 @@ import { isIpadDesktopUserAgent } from "@geolibre/core";
2323
* @returns True on Android/iOS (including desktop-UA iPadOS).
2424
*/
2525
const MOBILE_UA_PATTERN = /Android|iPhone|iPad|iPod/i;
26+
const ANDROID_UA_PATTERN = /Android/i;
27+
28+
/**
29+
* Whether the app is running on Android.
30+
*
31+
* This narrower check is used for platform APIs whose Android behavior differs
32+
* from iOS, such as native document-picker MIME filtering.
33+
*
34+
* @param userAgent - Override for testing; defaults to `navigator.userAgent`.
35+
* @returns True when the user agent identifies Android.
36+
*/
37+
export function isAndroid(
38+
userAgent: string = typeof navigator !== "undefined" ? navigator.userAgent : "",
39+
): boolean {
40+
return ANDROID_UA_PATTERN.test(userAgent);
41+
}
2642

2743
export function isMobile(
2844
userAgent: string = typeof navigator !== "undefined" ? navigator.userAgent : "",

apps/geolibre-desktop/src/lib/tauri-io.ts

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ import {
3838
import type { GeotaggedPhotoResult } from "./geotagged-photos";
3939
import { PHOTO_IMAGE_EXTENSIONS, isPhotoDropFileName, isPhotoFileName } from "./geotagged-photos";
4040
import { projectedGeoJsonCrs } from "./crs-utils";
41+
import { nativeFileDialogFilters, type FileDialogFilter } from "./file-dialog-filters";
4142
import { parseGpxLayer } from "./gpx";
4243
import { isTauri } from "./is-tauri";
4344
import { SHAPEFILE_COMPANION_EXTENSIONS, shapefileCompanionPathsFromSelection } from "./mas-build";
@@ -71,10 +72,7 @@ function browserSafeFileName(path: string): string {
7172
return path.split(/[/\\]/).pop() || "project.geolibre.json";
7273
}
7374

74-
export interface FileDialogFilter {
75-
name: string;
76-
extensions: string[];
77-
}
75+
export type { FileDialogFilter } from "./file-dialog-filters";
7876

7977
interface PickLocalPathOptions {
8078
accept?: string;
@@ -90,6 +88,7 @@ interface PickSavePathOptions {
9088

9189
interface LocalDataFileOptions {
9290
filters: FileDialogFilter[];
91+
androidFilters?: FileDialogFilter[];
9392
accept: string;
9493
readBinary?: boolean;
9594
readText?: boolean;
@@ -2462,7 +2461,7 @@ export async function openLocalDataFileWithFallback(options: LocalDataFileOption
24622461
if (isTauri()) {
24632462
const selected = await open({
24642463
multiple: false,
2465-
filters: options.filters,
2464+
filters: nativeFileDialogFilters(options.filters, options.androidFilters),
24662465
});
24672466
if (!selected || typeof selected !== "string") return null;
24682467
const data = options.readBinary ? toArrayBuffer(await readFile(selected)) : undefined;

tests/file-dialog-filters.test.ts

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
import assert from "node:assert/strict";
2+
import { describe, it } from "node:test";
3+
import {
4+
nativeFileDialogFilters,
5+
type FileDialogFilter,
6+
} from "../apps/geolibre-desktop/src/lib/file-dialog-filters";
7+
8+
const styleFilters: FileDialogFilter[] = [
9+
{
10+
name: "Style",
11+
extensions: ["json", "sld", "qml", "xml"],
12+
},
13+
];
14+
15+
describe("nativeFileDialogFilters", () => {
16+
it("uses the Android override even when it intentionally has no filters", () => {
17+
assert.deepEqual(
18+
nativeFileDialogFilters(styleFilters, [], "Mozilla/5.0 (Linux; Android 16; Mobile)"),
19+
[],
20+
);
21+
});
22+
23+
it("keeps extension filters on desktop and iOS", () => {
24+
assert.equal(
25+
nativeFileDialogFilters(styleFilters, [], "Mozilla/5.0 (X11; Linux x86_64)"),
26+
styleFilters,
27+
);
28+
assert.equal(
29+
nativeFileDialogFilters(styleFilters, [], "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0)"),
30+
styleFilters,
31+
);
32+
});
33+
34+
it("keeps the default filters on Android when no override is supplied", () => {
35+
assert.equal(
36+
nativeFileDialogFilters(styleFilters, undefined, "Mozilla/5.0 (Linux; Android 16)"),
37+
styleFilters,
38+
);
39+
});
40+
});

tests/is-mobile.test.ts

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import assert from "node:assert/strict";
22
import { describe, it } from "node:test";
3-
import { isMobile } from "../apps/geolibre-desktop/src/lib/is-mobile";
3+
import { isAndroid, isMobile } from "../apps/geolibre-desktop/src/lib/is-mobile";
44

55
describe("isMobile", () => {
66
it("detects Android (incl. the Tauri webview UA)", () => {
@@ -45,3 +45,19 @@ describe("isMobile", () => {
4545
assert.equal(isMobile(""), false);
4646
});
4747
});
48+
49+
describe("isAndroid", () => {
50+
it("detects the Android WebView user agent", () => {
51+
assert.equal(
52+
isAndroid(
53+
"Mozilla/5.0 (Linux; Android 16; Mobile) AppleWebKit/537.36 Version/4.0 Chrome/138 Mobile Safari/537.36 wv",
54+
),
55+
true,
56+
);
57+
});
58+
59+
it("does not classify iOS or desktop user agents as Android", () => {
60+
assert.equal(isAndroid("Mozilla/5.0 (iPhone; CPU iPhone OS 18_0 like Mac OS X)"), false);
61+
assert.equal(isAndroid("Mozilla/5.0 (X11; Linux x86_64) Chrome/138 Safari/537.36"), false);
62+
});
63+
});

0 commit comments

Comments
 (0)