/** * Platform-aware geolocation. * * THE BUG THIS FIXES * ------------------ * On the native Android app (ZapStore APK) the in-WebView * `navigator.geolocation.getCurrentPosition` returns PERMISSION_DENIED, because * the WebView never gets the OS location permission on its own. The app then * showed a "your BROWSER blocked location" help sheet — wrong and useless inside * a native app. * * THE FIX * ------- * On native we go through Capacitor's Geolocation plugin, accessed via * `registerPlugin('Geolocation')` from the bundled @capacitor/core (same runtime * pattern used for Camera + the Amber signer — no bare `@capacitor/geolocation` * import in the JS bundle). We call requestPermissions() first, which triggers * the real Android location prompt, then getCurrentPosition(). * * 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'; export interface GeoCoords { lat: number; lng: number; } export type GeoErrorKind = 'denied' | 'unavailable' | 'timeout' | 'no_provider'; export class GeolocationError extends Error { kind: GeoErrorKind; constructor(kind: GeoErrorKind, message: string) { super(message); this.kind = kind; this.name = 'GeolocationError'; } } // ─── Native plugin proxy ────────────────────────────────────────────────────── interface CapPermissionStatus { location: 'granted' | 'denied' | 'prompt' | 'prompt-with-rationale'; coarseLocation?: 'granted' | 'denied' | 'prompt' | 'prompt-with-rationale'; } interface CapPosition { coords: { latitude: number; longitude: number }; } /** * Permission methods are always safe. getCurrentPosition is attempted (safely * wrapped) because on GrapheneOS the WebView's navigator.geolocation is blocked * even after the OS permission is granted — so the native plugin is the only * way to actually get a fix there. Recent @capacitor/geolocation falls back to * Android LocationManager when Play Services is absent, so it no longer crashes. */ interface CapGeolocationPlugin { checkPermissions(): Promise; requestPermissions(options?: { permissions?: string[] }): Promise; getCurrentPosition(options?: { enableHighAccuracy?: boolean; timeout?: number; maximumAge?: number; }): 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 { if (!isNativeGeoAvailable()) return null; if (!geoPlugin) { try { geoPlugin = registerPlugin('Geolocation'); } catch { return null; } } return geoPlugin; } /** * 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 { return false; } } // ─── Public API ─────────────────────────────────────────────────────────────── /** * Get the device's current position. Uses the native Geolocation plugin on the * app (with a proper OS permission prompt) and navigator.geolocation on web. * Throws a GeolocationError with a `kind` the UI can branch on. */ export async function getCurrentPosition(): Promise { const native = getNativeGeo(); if (native) { return getNativePosition(native); } return getWebPosition(); } /** * Native path. * * 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. * * 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 { // 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. // 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(() => 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.'); } // Permission granted natively. Now get the actual position. // // On GrapheneOS the WebView's navigator.geolocation is BLOCKED even after the // OS-level permission is granted (the WebView has its own geolocation gate // that Capacitor doesn't open) → navigator.geolocation returns // PERMISSION_DENIED, which is the "granted but still denied" bug users hit. // // So we ask the NATIVE plugin for the position first — it talks to Android's // LocationManager directly and honours the permission we just granted. // Modern @capacitor/geolocation degrades to LocationManager when Play // Services is absent, so this no longer crashes de-Googled devices. We wrap // it defensively and fall back to navigator.geolocation only if it fails. try { const pos = await safeCall(() => native.getCurrentPosition({ enableHighAccuracy: false, timeout: 15000, maximumAge: 60000 }), ); const lat = pos?.coords?.latitude; const lng = pos?.coords?.longitude; if (typeof lat === 'number' && typeof lng === 'number' && !(lat === 0 && lng === 0)) { return { lat, lng }; } // Bad/empty reading — fall through to the web path. } catch (nativePosErr) { console.warn('[geolocation] native getCurrentPosition failed, falling back to WebView:', nativePosErr); } // Fallback: WebView navigator.geolocation (works on stock Android, shares // the app's granted permission). return await getWebPosition(); } 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.'); } // "location disabled" / "provider" → the OS has NO usable location provider. // On GrapheneOS (no Google Play Services) there's often no network/fused // provider, so only GPS satellites work — which fail indoors. Surface this // as its own kind so the UI can give accurate advice. if ( msg.includes('disabled') || msg.includes('provider') || msg.includes('settings') || msg.includes('unavailable') ) { throw new GeolocationError( 'no_provider', err instanceof Error ? err.message : String(err) || 'No location provider available.', ); } if (msg.includes('timeout') || msg.includes('timed out')) { throw new GeolocationError('timeout', 'Location request timed out.'); } // Anything else — 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 (also used as a safe fallback when the native plugin isn't genuinely * registered). Uses the WebView/browser `navigator.geolocation`. * * Same fast-fix idea as native: start a low-accuracy watchPosition and resolve * on the FIRST update (the quick network/wifi fix), racing it against a cheap * getCurrentPosition. Whichever returns first wins. */ function getWebPosition(): Promise { if (!('geolocation' in navigator)) { return Promise.reject( new GeolocationError('unavailable', 'Geolocation is not supported by this browser.'), ); } return new Promise((resolve, reject) => { let settled = false; let watchId: number | null = null; let deadline: ReturnType | null = null; const cleanup = () => { if (deadline) clearTimeout(deadline); if (watchId !== null) { try { navigator.geolocation.clearWatch(watchId); } catch { // ignore } watchId = null; } }; const succeed = (pos: GeolocationPosition) => { if (settled) return; settled = true; cleanup(); resolve({ lat: pos.coords.latitude, lng: pos.coords.longitude }); }; const mapError = (err: GeolocationPositionError): GeolocationError => { if (err.code === err.PERMISSION_DENIED) { return new GeolocationError('denied', 'Location permission denied.'); } if (err.code === err.TIMEOUT) { return new GeolocationError('timeout', 'Location request timed out.'); } return new GeolocationError('unavailable', 'Location unavailable.'); }; const fail = (err: GeolocationPositionError) => { if (settled) return; // Permission denial is terminal; other errors let the watch/deadline decide. if (err.code === err.PERMISSION_DENIED) { settled = true; cleanup(); reject(mapError(err)); } }; deadline = setTimeout(() => { if (settled) return; settled = true; cleanup(); reject(new GeolocationError('timeout', 'Location request timed out.')); }, 25000); // 1) watchPosition — first update wins (fast network fix). try { watchId = navigator.geolocation.watchPosition( (pos) => succeed(pos), (err) => fail(err), { enableHighAccuracy: false, timeout: 20000, maximumAge: 60000 }, ); } catch { // ignore — getCurrentPosition below still runs } // 2) getCurrentPosition — instant if a recent fix is cached. navigator.geolocation.getCurrentPosition( (pos) => succeed(pos), (err) => fail(err), { enableHighAccuracy: false, timeout: 15000, maximumAge: 120000 }, ); }); }