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 ──────────────────────────────────────────────────────
|
||||
|
||||
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();
|
||||
|
|
|
|||
Loading…
Reference in a new issue