diff --git a/src/lib/geolocation.ts b/src/lib/geolocation.ts index 0e941b4..1c8569b 100644 --- a/src/lib/geolocation.ts +++ b/src/lib/geolocation.ts @@ -20,6 +20,23 @@ * IMPORTANT (native build): @capacitor/geolocation must be installed (it's in * package.json) and synced with `npx cap sync android` so the native plugin is * bundled into the APK and registered. On web we just use navigator.geolocation. + * + * CRASH HARDENING (the "Near Me closes the app / bounces to ZapStore" bug) + * ----------------------------------------------------------------------- + * A native plugin call that throws on the Android MAIN thread produces a + * `FATAL EXCEPTION: main` that kills the process. No JS try/catch or Promise + * wrapper can intercept that — it happens in Java land, off our promise chain. + * `registerPlugin('Geolocation')` ALWAYS returns a JS proxy, even when the + * native class isn't actually registered in the APK (e.g. after a messy plugin + * upgrade / incomplete `cap sync`, or a missing play-services-location dep). + * Invoking a method on such a "ghost" proxy is exactly what crashes the app. + * + * So before we ever touch the native plugin we verify it is GENUINELY present + * by checking `Capacitor.isPluginAvailable('Geolocation')` and the live + * `window.Capacitor.Plugins.Geolocation` registry. If it's not truly there we + * DO NOT call it — we fall back to the WebView's `navigator.geolocation` + * instead of detonating a native crash. This keeps the app alive no matter what + * state the native project is in. */ import { Capacitor, registerPlugin } from '@capacitor/core'; @@ -55,21 +72,67 @@ interface CapGeolocationPlugin { requestPermissions(options?: { permissions?: string[] }): Promise; } +/** + * Verify the Geolocation plugin is GENUINELY registered on the native side — + * not just that `registerPlugin` handed us a proxy (it always does). Calling a + * method on a proxy whose native class is missing is what crashes the app, so + * we must be certain the native implementation exists before we ever call it. + */ +function isNativeGeoAvailable(): boolean { + try { + if (!Capacitor?.isNativePlatform?.()) return false; + + // Capacitor's own registry check — returns false if the native plugin + // class wasn't compiled/registered into this APK. + if (typeof Capacitor.isPluginAvailable === 'function') { + if (!Capacitor.isPluginAvailable('Geolocation')) return false; + } + + // Belt-and-braces: confirm the live plugin registry actually holds a + // Geolocation entry with the methods we intend to call. + const w = globalThis as unknown as { + Capacitor?: { Plugins?: Record }; + }; + const registered = w.Capacitor?.Plugins?.Geolocation as + | { getCurrentPosition?: unknown } + | undefined; + if (!registered || typeof registered.getCurrentPosition !== 'function') { + return false; + } + + return true; + } catch { + return false; + } +} + let geoPlugin: CapGeolocationPlugin | null = null; function getNativeGeo(): CapGeolocationPlugin | null { - try { - if (!Capacitor?.isNativePlatform?.()) return null; - } catch { - return null; - } + if (!isNativeGeoAvailable()) return null; + if (!geoPlugin) { - geoPlugin = registerPlugin('Geolocation'); + try { + geoPlugin = registerPlugin('Geolocation'); + } catch { + return null; + } } return geoPlugin; } -/** True when running inside the native app (so we can tailor error copy). */ +/** + * True when running inside the native app AND the Geolocation plugin is + * genuinely registered — i.e. the native permission path is the one in use. + * The UI uses this to tailor error copy ("open device Settings" vs. "browser + * settings"). When the native plugin is a ghost proxy we fall back to the web + * path, so this returns false and the UI shows the browser-appropriate help. + */ export function isNativeGeolocation(): boolean { + return isNativeGeoAvailable(); +} + +/** True when running inside the native Capacitor app, regardless of plugins. */ +export function isNativePlatform(): boolean { try { return Capacitor?.isNativePlatform?.() ?? false; } catch { @@ -88,55 +151,96 @@ export async function getCurrentPosition(): Promise { const native = getNativeGeo(); if (native) { - // ── Native path ── - try { - // Ask the OS for permission first — this shows the Android prompt. - let status = await native.checkPermissions().catch(() => null); - if (!status || (status.location !== 'granted' && status.coarseLocation !== 'granted')) { - status = await native.requestPermissions({ permissions: ['location', 'coarseLocation'] }); - } - if (status && status.location !== 'granted' && status.coarseLocation !== 'granted') { - throw new GeolocationError('denied', 'Location permission denied.'); - } + return getNativePosition(native); + } - // First try a precise fix. High accuracy can fail/time out indoors or - // with a weak GPS signal, which shows up as "unavailable" or "timeout". - try { - const pos = await native.getCurrentPosition({ + return getWebPosition(); +} + +/** + * 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 { + // Wrap EVERY bridge call in Promise.resolve().then(...) so that even a + // SYNCHRONOUS throw from the native proxy becomes a rejected promise we can + // catch here — a sync throw is what can otherwise crash the whole app. + const safeCall = (fn: () => Promise): Promise => + Promise.resolve().then(fn); + + 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). + let status = await safeCall(() => native.checkPermissions()).catch(() => null); + if (!status || (status.location !== 'granted' && status.coarseLocation !== 'granted')) { + status = await safeCall(() => + native.requestPermissions({ permissions: ['location', 'coarseLocation'] }), + ).catch((permErr) => { + console.warn('[geolocation] requestPermissions failed:', permErr); + return null; + }); + } + if (status && status.location !== 'granted' && status.coarseLocation !== 'granted') { + throw new GeolocationError('denied', 'Location permission denied.'); + } + + // First try a precise fix. High accuracy can fail/time out indoors or + // with a weak GPS signal, which shows up as "unavailable" or "timeout". + try { + const pos = await safeCall(() => + native.getCurrentPosition({ enableHighAccuracy: true, timeout: 10000, maximumAge: 30000, - }); - return { lat: pos.coords.latitude, lng: pos.coords.longitude }; - } catch (highAccErr) { - // Fall back to a coarse (network/cell/wifi) fix with a longer window. - // This rescues the common "GPS can't lock" case that otherwise looks - // like a hard failure. - console.warn('[geolocation] high-accuracy failed, trying coarse:', highAccErr); - const pos = await native.getCurrentPosition({ + }), + ); + return { lat: pos.coords.latitude, lng: pos.coords.longitude }; + } catch (highAccErr) { + // Fall back to a coarse (network/cell/wifi) fix with a longer window. + // This rescues the common "GPS can't lock" case that otherwise looks + // like a hard failure. + console.warn('[geolocation] high-accuracy failed, trying coarse:', highAccErr); + const pos = await safeCall(() => + native.getCurrentPosition({ enableHighAccuracy: false, timeout: 20000, maximumAge: 120000, - }); - return { lat: pos.coords.latitude, lng: pos.coords.longitude }; - } - } catch (err) { - if (err instanceof GeolocationError) throw err; - const msg = (err instanceof Error ? err.message : String(err)).toLowerCase(); - if (msg.includes('denied') || msg.includes('permission')) { - throw new GeolocationError('denied', 'Location permission denied.'); - } - if (msg.includes('timeout') || msg.includes('timed out')) { - throw new GeolocationError('timeout', 'Location request timed out.'); - } - // "location disabled", "unavailable", plugin-not-implemented, etc. - throw new GeolocationError('unavailable', err instanceof Error ? err.message : 'Location unavailable.'); + }), + ); + return { lat: pos.coords.latitude, lng: pos.coords.longitude }; } + } catch (err) { + if (err instanceof GeolocationError) throw err; + const msg = (err instanceof Error ? err.message : String(err)).toLowerCase(); + if (msg.includes('denied') || msg.includes('permission')) { + throw new GeolocationError('denied', 'Location permission denied.'); + } + if (msg.includes('timeout') || msg.includes('timed out')) { + throw new GeolocationError('timeout', 'Location request timed out.'); + } + // "location disabled", "unavailable", plugin-not-implemented, etc. — pass + // the real native message through so it shows in the on-screen toast and + // we can see what actually failed without needing adb. + throw new GeolocationError( + 'unavailable', + err instanceof Error ? err.message : String(err) || 'Location unavailable.', + ); } +} - // ── Web path ── +/** + * Web path (also used as a safe fallback when the native plugin isn't genuinely + * registered). Uses the WebView/browser `navigator.geolocation`. + */ +function getWebPosition(): Promise { if (!('geolocation' in navigator)) { - throw new GeolocationError('unavailable', 'Geolocation is not supported by this browser.'); + return Promise.reject( + new GeolocationError('unavailable', 'Geolocation is not supported by this browser.'), + ); } return new Promise((resolve, reject) => {