Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions apps/geolibre-desktop/src/components/panels/LayerPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1775,6 +1775,10 @@ export function LayerPanel({
extensions: ["json", "sld", "qml", "xml"],
},
],
// Android filters document pickers by MIME type, but SLD and QML do
// not have consistently reported MIME types. Leave the native picker
// broad there, then validate the selected file by content below.
androidFilters: [],
accept: ".json,.sld,.qml,.xml,application/json,application/xml,text/xml",
readText: true,
});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -354,6 +354,9 @@ export function StyleManagerPanel() {
extensions: ["json", "qml", "sld", "xml"],
},
],
// Android cannot reliably map the SLD/QML extensions to MIME types.
// Accept any document there and validate its contents after selection.
androidFilters: [],
accept: ".json,.qml,.sld,.xml,application/json,application/xml,text/xml",
readText: true,
});
Expand Down
27 changes: 27 additions & 0 deletions apps/geolibre-desktop/src/lib/file-dialog-filters.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import { isAndroid } from "./is-mobile";

export interface FileDialogFilter {
name: string;
extensions: string[];
}

/**
* Select native file-dialog filters for the current platform.
*
* Android's document picker filters by MIME type and cannot reliably map
* uncommon filename extensions. Callers can therefore provide a separate
* Android filter set while retaining precise extension filters on desktop and
* iOS.
*
* @param filters - Default file filters used outside Android.
* @param androidFilters - Android-specific filters, when required.
* @param userAgent - Override for testing; defaults to `navigator.userAgent`.
* @returns The filters appropriate for the current platform.
*/
export function nativeFileDialogFilters(
filters: FileDialogFilter[],
androidFilters: FileDialogFilter[] | undefined,
userAgent: string = typeof navigator !== "undefined" ? navigator.userAgent : "",
): FileDialogFilter[] {
return isAndroid(userAgent) && androidFilters !== undefined ? androidFilters : filters;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Passing an explicit empty array (androidFilters: []) rather than omitting the property is the crux of this fix, and it relies on the Tauri Android dialog plugin treating a zero-length filter list the same as "no filters" (i.e. same as undefined) — showing all documents rather than, say, matching nothing. That's native (Kotlin/Rust) plugin behavior I can't verify from the JS side. The PR description says the author manually tested SLD/QML import on device, so this is presumably already confirmed in practice — flagging only as a low-confidence note in case this assumption needs re-checking on a future @tauri-apps/plugin-dialog upgrade.

}
16 changes: 16 additions & 0 deletions apps/geolibre-desktop/src/lib/is-mobile.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,22 @@ import { isIpadDesktopUserAgent } from "@geolibre/core";
* @returns True on Android/iOS (including desktop-UA iPadOS).
*/
const MOBILE_UA_PATTERN = /Android|iPhone|iPad|iPod/i;
const ANDROID_UA_PATTERN = /Android/i;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Minor readability nit: the pre-existing JSDoc block just above this line documents isMobile, but the new isAndroid pattern/function/doc is now spliced in between it and the isMobile function it describes (lines 43+). A reader hits the isMobile doc comment immediately followed by unrelated isAndroid code. Consider moving the whole isAndroid block (pattern + doc + function) above the isMobile doc comment instead, so each doc stays adjacent to what it documents. Purely cosmetic — no functional impact. Confidence: low.


/**
* Whether the app is running on Android.
*
* This narrower check is used for platform APIs whose Android behavior differs
* from iOS, such as native document-picker MIME filtering.
*
* @param userAgent - Override for testing; defaults to `navigator.userAgent`.
* @returns True when the user agent identifies Android.
*/
export function isAndroid(
userAgent: string = typeof navigator !== "undefined" ? navigator.userAgent : "",
): boolean {
return ANDROID_UA_PATTERN.test(userAgent);
}

export function isMobile(
userAgent: string = typeof navigator !== "undefined" ? navigator.userAgent : "",
Expand Down
9 changes: 4 additions & 5 deletions apps/geolibre-desktop/src/lib/tauri-io.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ import {
import type { GeotaggedPhotoResult } from "./geotagged-photos";
import { PHOTO_IMAGE_EXTENSIONS, isPhotoDropFileName, isPhotoFileName } from "./geotagged-photos";
import { projectedGeoJsonCrs } from "./crs-utils";
import { nativeFileDialogFilters, type FileDialogFilter } from "./file-dialog-filters";
import { parseGpxLayer } from "./gpx";
import { isTauri } from "./is-tauri";
import { SHAPEFILE_COMPANION_EXTENSIONS, shapefileCompanionPathsFromSelection } from "./mas-build";
Expand Down Expand Up @@ -71,10 +72,7 @@ function browserSafeFileName(path: string): string {
return path.split(/[/\\]/).pop() || "project.geolibre.json";
}

export interface FileDialogFilter {
name: string;
extensions: string[];
}
export type { FileDialogFilter } from "./file-dialog-filters";

interface PickLocalPathOptions {
accept?: string;
Expand All @@ -90,6 +88,7 @@ interface PickSavePathOptions {

interface LocalDataFileOptions {
filters: FileDialogFilter[];
androidFilters?: FileDialogFilter[];
accept: string;
readBinary?: boolean;
readText?: boolean;
Expand Down Expand Up @@ -2462,7 +2461,7 @@ export async function openLocalDataFileWithFallback(options: LocalDataFileOption
if (isTauri()) {
const selected = await open({
multiple: false,
filters: options.filters,
filters: nativeFileDialogFilters(options.filters, options.androidFilters),
});
if (!selected || typeof selected !== "string") return null;
const data = options.readBinary ? toArrayBuffer(await readFile(selected)) : undefined;
Expand Down
40 changes: 40 additions & 0 deletions tests/file-dialog-filters.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import assert from "node:assert/strict";
import { describe, it } from "node:test";
import {
nativeFileDialogFilters,
type FileDialogFilter,
} from "../apps/geolibre-desktop/src/lib/file-dialog-filters";

const styleFilters: FileDialogFilter[] = [
{
name: "Style",
extensions: ["json", "sld", "qml", "xml"],
},
];

describe("nativeFileDialogFilters", () => {
it("uses the Android override even when it intentionally has no filters", () => {
assert.deepEqual(
nativeFileDialogFilters(styleFilters, [], "Mozilla/5.0 (Linux; Android 16; Mobile)"),
[],
);
});

it("keeps extension filters on desktop and iOS", () => {
assert.equal(
nativeFileDialogFilters(styleFilters, [], "Mozilla/5.0 (X11; Linux x86_64)"),
styleFilters,
);
assert.equal(
nativeFileDialogFilters(styleFilters, [], "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0)"),
styleFilters,
);
});

it("keeps the default filters on Android when no override is supplied", () => {
assert.equal(
nativeFileDialogFilters(styleFilters, undefined, "Mozilla/5.0 (Linux; Android 16)"),
styleFilters,
);
});
});
18 changes: 17 additions & 1 deletion tests/is-mobile.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import assert from "node:assert/strict";
import { describe, it } from "node:test";
import { isMobile } from "../apps/geolibre-desktop/src/lib/is-mobile";
import { isAndroid, isMobile } from "../apps/geolibre-desktop/src/lib/is-mobile";

describe("isMobile", () => {
it("detects Android (incl. the Tauri webview UA)", () => {
Expand Down Expand Up @@ -45,3 +45,19 @@ describe("isMobile", () => {
assert.equal(isMobile(""), false);
});
});

describe("isAndroid", () => {
it("detects the Android WebView user agent", () => {
assert.equal(
isAndroid(
"Mozilla/5.0 (Linux; Android 16; Mobile) AppleWebKit/537.36 Version/4.0 Chrome/138 Mobile Safari/537.36 wv",
),
true,
);
});

it("does not classify iOS or desktop user agents as Android", () => {
assert.equal(isAndroid("Mozilla/5.0 (iPhone; CPU iPhone OS 18_0 like Mac OS X)"), false);
assert.equal(isAndroid("Mozilla/5.0 (X11; Linux x86_64) Chrome/138 Safari/537.36"), false);
});
});
Loading