Fix Near Me crash on GrapheneOS: avoid Play-Services position API
Root cause: @capacitor/geolocation v8 fetches position via Google Play Services (play-services-location / iongeolocation-android). On de-Googled devices (GrapheneOS) those classes don't exist, so the plugin's getCurrentPosition/watchPosition throw NoClassDefFoundError on the Android main thread -> FATAL EXCEPTION -> app killed. No JS try/catch can intercept it. Fix: use the native plugin ONLY for the OS location-permission prompt (pure Android, no Play Services), then fetch the actual position via the WebView's navigator.geolocation, which uses Android's LocationManager directly and works without Google. Removed the now-dead raceForFirstFix() and the native position/watch typings so the Play-Services code path is never invoked. Co-authored-by: shakespeare.diy <assistant@shakespeare.diy>
This commit is contained in:
parent
a46be4be68
commit
5ec1c84ef0
1 changed files with 29 additions and 157 deletions
|
|
@ -59,21 +59,17 @@ export class GeolocationError extends Error {
|
||||||
|
|
||||||
// ─── Native plugin proxy ──────────────────────────────────────────────────────
|
// ─── Native plugin proxy ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
interface CapPosition {
|
|
||||||
coords: { latitude: number; longitude: number };
|
|
||||||
}
|
|
||||||
interface CapPermissionStatus {
|
interface CapPermissionStatus {
|
||||||
location: 'granted' | 'denied' | 'prompt' | 'prompt-with-rationale';
|
location: 'granted' | 'denied' | 'prompt' | 'prompt-with-rationale';
|
||||||
coarseLocation?: 'granted' | 'denied' | 'prompt' | 'prompt-with-rationale';
|
coarseLocation?: 'granted' | 'denied' | 'prompt' | 'prompt-with-rationale';
|
||||||
}
|
}
|
||||||
type CapWatchCallback = (position: CapPosition | null, err?: unknown) => void;
|
/**
|
||||||
|
* We deliberately only type the PERMISSION methods. The position methods
|
||||||
|
* (getCurrentPosition/watchPosition) are NOT used on native because they route
|
||||||
|
* through Google Play Services and crash de-Googled devices — see
|
||||||
|
* getNativePosition below, which fetches the position via navigator.geolocation.
|
||||||
|
*/
|
||||||
interface CapGeolocationPlugin {
|
interface CapGeolocationPlugin {
|
||||||
getCurrentPosition(options?: { enableHighAccuracy?: boolean; timeout?: number; maximumAge?: number }): Promise<CapPosition>;
|
|
||||||
watchPosition(
|
|
||||||
options: { enableHighAccuracy?: boolean; timeout?: number; maximumAge?: number },
|
|
||||||
callback: CapWatchCallback,
|
|
||||||
): Promise<string>;
|
|
||||||
clearWatch(options: { id: string }): Promise<void>;
|
|
||||||
checkPermissions(): Promise<CapPermissionStatus>;
|
checkPermissions(): Promise<CapPermissionStatus>;
|
||||||
requestPermissions(options?: { permissions?: string[] }): Promise<CapPermissionStatus>;
|
requestPermissions(options?: { permissions?: string[] }): Promise<CapPermissionStatus>;
|
||||||
}
|
}
|
||||||
|
|
@ -164,136 +160,23 @@ export async function getCurrentPosition(): Promise<GeoCoords> {
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get the FIRST location fix from any provider as fast as possible — the trick
|
* Native path.
|
||||||
* that makes Google Maps feel instant indoors.
|
|
||||||
*
|
*
|
||||||
* Runs two things in parallel and returns whichever wins:
|
* CRITICAL — GrapheneOS / de-Googled devices
|
||||||
* 1. watchPosition (low accuracy): fires a callback on the first update from
|
* ------------------------------------------
|
||||||
* the network/wifi/cell (fused) provider — usually within ~1s, works
|
* Capacitor Geolocation v8 fetches the position through Google Play Services
|
||||||
* indoors. We resolve on that first callback, then immediately clearWatch.
|
* (`play-services-location` / Ionic's `iongeolocation-android`). On a phone
|
||||||
* 2. getCurrentPosition (low accuracy, accept cached): resolves instantly if
|
* WITHOUT Play Services (GrapheneOS, /e/OS, most de-Googled ROMs) the plugin's
|
||||||
* the OS already has a recent fix.
|
* `getCurrentPosition`/`watchPosition` touch Google classes that don't exist on
|
||||||
|
* the device → `NoClassDefFoundError` on the Android MAIN thread → the whole
|
||||||
|
* app is killed ("Near Me closes the app"). No JS try/catch can stop that.
|
||||||
*
|
*
|
||||||
* A 25s overall deadline rejects so the caller's last-ditch high-accuracy
|
* THE FIX: we use the native plugin ONLY to request the OS location permission
|
||||||
* attempt can run. watchPosition is guarded — if the plugin build doesn't
|
* (that path is pure Android, no Play Services). For the actual position we use
|
||||||
* expose it, we quietly rely on getCurrentPosition alone.
|
* the WebView's `navigator.geolocation`, which talks to Android's own
|
||||||
*/
|
* LocationManager (GPS/network) directly and works fine without Google. This
|
||||||
function raceForFirstFix(
|
* keeps a proper native permission prompt while never invoking the
|
||||||
native: CapGeolocationPlugin,
|
* Play-Services position code that crashes de-Googled devices.
|
||||||
safeCall: <T>(fn: () => Promise<T>) => Promise<T>,
|
|
||||||
): Promise<GeoCoords> {
|
|
||||||
return new Promise<GeoCoords>((resolve, reject) => {
|
|
||||||
let settled = false;
|
|
||||||
const watchIds: string[] = [];
|
|
||||||
let deadline: ReturnType<typeof setTimeout> | null = null;
|
|
||||||
let sawTimeoutish = false; // remember if the failure looked like a timeout
|
|
||||||
|
|
||||||
const clearAllWatches = () => {
|
|
||||||
while (watchIds.length) {
|
|
||||||
const id = watchIds.pop()!;
|
|
||||||
safeCall(() => native.clearWatch({ id })).catch(() => {});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const cleanup = () => {
|
|
||||||
if (deadline) clearTimeout(deadline);
|
|
||||||
clearAllWatches();
|
|
||||||
};
|
|
||||||
|
|
||||||
const succeed = (pos: CapPosition) => {
|
|
||||||
if (settled) return;
|
|
||||||
// Guard against garbage readings (0,0 / null island).
|
|
||||||
const { latitude, longitude } = pos?.coords ?? {};
|
|
||||||
if (
|
|
||||||
typeof latitude !== 'number' ||
|
|
||||||
typeof longitude !== 'number' ||
|
|
||||||
(latitude === 0 && longitude === 0)
|
|
||||||
) {
|
|
||||||
console.warn('[geolocation] ignoring invalid reading', pos);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
settled = true;
|
|
||||||
cleanup();
|
|
||||||
resolve({ lat: latitude, lng: longitude });
|
|
||||||
};
|
|
||||||
|
|
||||||
const fail = (err: unknown) => {
|
|
||||||
if (settled) return;
|
|
||||||
settled = true;
|
|
||||||
cleanup();
|
|
||||||
reject(err instanceof Error ? err : new Error(String(err)));
|
|
||||||
};
|
|
||||||
|
|
||||||
// GrapheneOS (no Play Services) has no fast fused provider — a cold GPS lock
|
|
||||||
// can genuinely take 30–60s. Give it a real chance before giving up.
|
|
||||||
deadline = setTimeout(
|
|
||||||
() => fail(new Error(sawTimeoutish ? 'timeout' : 'fast-fix timeout')),
|
|
||||||
60000,
|
|
||||||
);
|
|
||||||
|
|
||||||
// Register a watch with the given accuracy; first valid reading wins.
|
|
||||||
const startWatch = (highAccuracy: boolean) => {
|
|
||||||
if (typeof native.watchPosition !== 'function') return;
|
|
||||||
safeCall(() =>
|
|
||||||
native.watchPosition(
|
|
||||||
{ enableHighAccuracy: highAccuracy, timeout: 60000, maximumAge: 0 },
|
|
||||||
(position, err) => {
|
|
||||||
if (position) {
|
|
||||||
succeed(position);
|
|
||||||
} else if (err) {
|
|
||||||
const m = String(
|
|
||||||
(err as { message?: string })?.message ?? err,
|
|
||||||
).toLowerCase();
|
|
||||||
if (m.includes('timeout') || m.includes('timed out')) sawTimeoutish = true;
|
|
||||||
console.warn('[geolocation] watch update error:', err);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
),
|
|
||||||
)
|
|
||||||
.then((id) => {
|
|
||||||
if (settled) {
|
|
||||||
safeCall(() => native.clearWatch({ id })).catch(() => {});
|
|
||||||
} else {
|
|
||||||
watchIds.push(id);
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.catch((watchErr) => {
|
|
||||||
console.warn('[geolocation] watchPosition failed:', watchErr);
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
// Run BOTH a high-accuracy (GPS) and low-accuracy (network, if any) watch in
|
|
||||||
// parallel. On GrapheneOS the GPS one is usually what actually delivers, so
|
|
||||||
// high accuracy MUST be engaged — the old low-accuracy-only watch never
|
|
||||||
// fired without a network provider. Whichever returns first wins.
|
|
||||||
startWatch(true);
|
|
||||||
startWatch(false);
|
|
||||||
|
|
||||||
// Also fire a one-shot high-accuracy getCurrentPosition (some plugin
|
|
||||||
// versions deliver here even when the watch is quiet). Cached ok.
|
|
||||||
safeCall(() =>
|
|
||||||
native.getCurrentPosition({
|
|
||||||
enableHighAccuracy: true,
|
|
||||||
timeout: 55000,
|
|
||||||
maximumAge: 60000,
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
.then((pos) => succeed(pos))
|
|
||||||
.catch((gcpErr) => {
|
|
||||||
const m = String(
|
|
||||||
(gcpErr as { message?: string })?.message ?? gcpErr,
|
|
||||||
).toLowerCase();
|
|
||||||
if (m.includes('timeout') || m.includes('timed out')) sawTimeoutish = true;
|
|
||||||
console.warn('[geolocation] getCurrentPosition failed:', gcpErr);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Native path. The plugin has already been verified as genuinely registered by
|
|
||||||
* `getNativeGeo`, so calling it is safe. Every bridge call is still funneled
|
|
||||||
* through `safeCall` so a synchronous throw becomes a catchable rejection
|
|
||||||
* rather than an uncaught error.
|
|
||||||
*/
|
*/
|
||||||
async function getNativePosition(native: CapGeolocationPlugin): Promise<GeoCoords> {
|
async function getNativePosition(native: CapGeolocationPlugin): Promise<GeoCoords> {
|
||||||
// Wrap EVERY bridge call in Promise.resolve().then(...) so that even a
|
// Wrap EVERY bridge call in Promise.resolve().then(...) so that even a
|
||||||
|
|
@ -304,9 +187,8 @@ async function getNativePosition(native: CapGeolocationPlugin): Promise<GeoCoord
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Ask the OS for permission first — this shows the Android prompt.
|
// Ask the OS for permission first — this shows the Android prompt.
|
||||||
// Guard each step independently: if checkPermissions is missing/throws we
|
// checkPermissions/requestPermissions do NOT use Play Services, so they are
|
||||||
// still try requestPermissions, and if that's missing we proceed to the
|
// safe to call on de-Googled devices. Guard each step independently.
|
||||||
// position call (which will surface a clean error rather than crash).
|
|
||||||
let status = await safeCall(() => native.checkPermissions()).catch(() => null);
|
let status = await safeCall(() => native.checkPermissions()).catch(() => null);
|
||||||
if (!status || (status.location !== 'granted' && status.coarseLocation !== 'granted')) {
|
if (!status || (status.location !== 'granted' && status.coarseLocation !== 'granted')) {
|
||||||
status = await safeCall(() =>
|
status = await safeCall(() =>
|
||||||
|
|
@ -320,22 +202,12 @@ async function getNativePosition(native: CapGeolocationPlugin): Promise<GeoCoord
|
||||||
throw new GeolocationError('denied', 'Location permission denied.');
|
throw new GeolocationError('denied', 'Location permission denied.');
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── FAST FIX STRATEGY (Google-Maps-like) ──────────────────────────────
|
// Permission handled natively — now fetch the position via the WebView's
|
||||||
// The problem: getCurrentPosition({enableHighAccuracy:true}) waits for a
|
// navigator.geolocation (Android LocationManager, no Play Services). This is
|
||||||
// single "good" GPS reading, which can take 30s+ and fails indoors where
|
// what avoids the fatal Play-Services crash on GrapheneOS while still
|
||||||
// satellites aren't visible. Google Maps feels instant because it takes the
|
// working normally on stock Android (the WebView shares the app's granted
|
||||||
// FIRST location update from ANY provider (wifi/cell/network), which arrives
|
// location permission).
|
||||||
// in ~1s indoors.
|
return await getWebPosition();
|
||||||
//
|
|
||||||
// We replicate that with watchPosition: start a low-accuracy watch and
|
|
||||||
// resolve on the very first update that comes back — that's the fast
|
|
||||||
// network/fused fix. We run it in parallel with a cheap getCurrentPosition
|
|
||||||
// and take whichever resolves first. Only if BOTH fail do we surface an
|
|
||||||
// error. This gets an indoor fix without ever asking the user to move.
|
|
||||||
// raceForFirstFix already runs parallel high+low accuracy watches AND a
|
|
||||||
// one-shot getCurrentPosition for up to 60s, so there's nothing more to try
|
|
||||||
// if it fails — let the error propagate to the classifier below.
|
|
||||||
return await raceForFirstFix(native, safeCall);
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (err instanceof GeolocationError) throw err;
|
if (err instanceof GeolocationError) throw err;
|
||||||
const msg = (err instanceof Error ? err.message : String(err)).toLowerCase();
|
const msg = (err instanceof Error ? err.message : String(err)).toLowerCase();
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue