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:
shakespeare.diy 2026-07-25 14:08:23 +07:00
parent a46be4be68
commit 5ec1c84ef0

View file

@ -59,21 +59,17 @@ export class GeolocationError extends Error {
// ─── Native plugin proxy ──────────────────────────────────────────────────────
interface CapPosition {
coords: { latitude: number; longitude: number };
}
interface CapPermissionStatus {
location: '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 {
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>;
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
* that makes Google Maps feel instant indoors.
* Native path.
*
* Runs two things in parallel and returns whichever wins:
* 1. watchPosition (low accuracy): fires a callback on the first update from
* the network/wifi/cell (fused) provider — usually within ~1s, works
* indoors. We resolve on that first callback, then immediately clearWatch.
* 2. getCurrentPosition (low accuracy, accept cached): resolves instantly if
* the OS already has a recent fix.
* CRITICAL — GrapheneOS / de-Googled devices
* ------------------------------------------
* Capacitor Geolocation v8 fetches the position through Google Play Services
* (`play-services-location` / Ionic's `iongeolocation-android`). On a phone
* WITHOUT Play Services (GrapheneOS, /e/OS, most de-Googled ROMs) the plugin's
* `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
* attempt can run. watchPosition is guarded — if the plugin build doesn't
* expose it, we quietly rely on getCurrentPosition alone.
*/
function raceForFirstFix(
native: CapGeolocationPlugin,
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.
* THE FIX: we use the native plugin ONLY to request the OS location permission
* (that path is pure Android, no Play Services). For the actual position we use
* the WebView's `navigator.geolocation`, which talks to Android's own
* LocationManager (GPS/network) directly and works fine without Google. This
* keeps a proper native permission prompt while never invoking the
* Play-Services position code that crashes de-Googled devices.
*/
async function getNativePosition(native: CapGeolocationPlugin): Promise<GeoCoords> {
// Wrap EVERY bridge call in Promise.resolve().then(...) so that even a
@ -304,9 +187,8 @@ async function getNativePosition(native: CapGeolocationPlugin): Promise<GeoCoord
try {
// Ask the OS for permission first — this shows the Android prompt.
// Guard each step independently: if checkPermissions is missing/throws we
// still try requestPermissions, and if that's missing we proceed to the
// position call (which will surface a clean error rather than crash).
// checkPermissions/requestPermissions do NOT use Play Services, so they are
// safe to call on de-Googled devices. Guard each step independently.
let status = await safeCall(() => native.checkPermissions()).catch(() => null);
if (!status || (status.location !== 'granted' && status.coarseLocation !== 'granted')) {
status = await safeCall(() =>
@ -320,22 +202,12 @@ async function getNativePosition(native: CapGeolocationPlugin): Promise<GeoCoord
throw new GeolocationError('denied', 'Location permission denied.');
}
// ── FAST FIX STRATEGY (Google-Maps-like) ──────────────────────────────
// The problem: getCurrentPosition({enableHighAccuracy:true}) waits for a
// single "good" GPS reading, which can take 30s+ and fails indoors where
// satellites aren't visible. Google Maps feels instant because it takes the
// FIRST location update from ANY provider (wifi/cell/network), which arrives
// in ~1s indoors.
//
// 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);
// Permission handled natively — now fetch the position via the WebView's
// navigator.geolocation (Android LocationManager, no Play Services). This is
// what avoids the fatal Play-Services crash on GrapheneOS while still
// working normally on stock Android (the WebView shares the app's granted
// location permission).
return await getWebPosition();
} catch (err) {
if (err instanceof GeolocationError) throw err;
const msg = (err instanceof Error ? err.message : String(err)).toLowerCase();