SkateSpots/src/lib/geolocation.ts
SkateSpots 5a819d5a31 feat(directions): follow mode, choose-your-start, offline route cache
Follow mode (explicit Start; off by default — a watcher drains battery):
watchPosition in geolocation.ts, live dot, 'NEXT · step N' banner with
distance-to-turn + distance-to-go, current step highlighted, map follows.
A transient watch error (timeout/unavailable) no longer kills it — only a
permission denial does; the watcher recovers on its own.

Choose your start: 'From → My location / Choose on map' — tapping the map sets
the origin, and the route recomputes from there.

Offline: src/lib/routeCache.ts keeps the last route per destination+mode with
the line downsampled to <=250 points. If a fetch fails (no signal / routing
server down) the dialog falls back to it with an 'Offline · saved route {age}'
badge instead of a dead error.

Also tidied the map overlays (offline badge bottom-left, Whole route
bottom-right) so nothing covers Leaflet's zoom control.

Verified with CDP: controls present; follow banner tracks movement (step 1 ->
step 7, distances update); blocking routing shows the cached route + badge;
picking a start on the map changed the route (968 m -> 922 m). Still no voice
guidance or re-routing if you leave the route (documented).
2026-09-27 19:11:14 +07:00

456 lines
18 KiB
TypeScript

/**
* 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';
import { saveLastLocation } from '@/lib/lastLocation';
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<CapPermissionStatus>;
requestPermissions(options?: { permissions?: string[] }): Promise<CapPermissionStatus>;
getCurrentPosition(options?: {
enableHighAccuracy?: boolean;
timeout?: number;
maximumAge?: number;
}): Promise<CapPosition>;
}
/**
* 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<string, unknown> };
};
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<CapGeolocationPlugin>('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<GeoCoords> {
const native = getNativeGeo();
const coords = native ? await getNativePosition(native) : await getWebPosition();
// Remember the fix so the map can open optimistically centered next time and
// "Near Me" has something to fly to instantly (see lib/lastLocation).
saveLastLocation(coords.lat, coords.lng);
return coords;
}
export interface GeoWatch {
/** Stop watching. Safe to call more than once. */
stop: () => void;
}
/**
* Watch the device position (for "follow me" navigation).
*
* EXPLICIT ONLY — never call this without a user action: a watcher runs the GPS
* continuously and drains battery. The caller owns the `GeoWatch` and must
* `stop()` it on unmount / when the user stops following.
*
* Same platform posture as `getCurrentPosition`: on native we use the plugin
* only to make sure the OS permission is granted, then take positions from the
* WebView's `navigator.geolocation` (avoids the Play-Services path that crashes
* de-Googled ROMs).
*/
export function watchPosition(
onUpdate: (coords: GeoCoords) => void,
onError?: (err: GeolocationError) => void,
): GeoWatch {
let watchId: number | null = null;
let stopped = false;
const start = async () => {
const native = getNativeGeo();
if (native) {
try {
const safe = <T>(fn: () => Promise<T>) => Promise.resolve().then(fn);
let status = await safe(() => native.checkPermissions()).catch(() => null);
if (!status || (status.location !== 'granted' && status.coarseLocation !== 'granted')) {
status = await safe(() =>
native.requestPermissions({ permissions: ['location', 'coarseLocation'] }),
).catch(() => null);
}
if (status && status.location !== 'granted' && status.coarseLocation !== 'granted') {
onError?.(new GeolocationError('denied', 'Location permission denied.'));
return;
}
} catch {
// Permission bridge failed — fall through and let the WebView try.
}
}
if (stopped) return;
if (!('geolocation' in navigator)) {
onError?.(new GeolocationError('unavailable', 'Geolocation is not supported.'));
return;
}
try {
watchId = navigator.geolocation.watchPosition(
(pos) => {
const coords = { lat: pos.coords.latitude, lng: pos.coords.longitude };
saveLastLocation(coords.lat, coords.lng);
onUpdate(coords);
},
(err) => {
// Distinguish a real denial from a transient one: a watcher commonly
// emits TIMEOUT while waiting for the first fix (indoors, tunnels),
// then recovers. Callers should NOT tear down the watcher for those.
if (err.code === err.PERMISSION_DENIED) {
onError?.(new GeolocationError('denied', 'Location permission denied.'));
} else if (err.code === err.TIMEOUT) {
onError?.(new GeolocationError('timeout', 'Waiting for a GPS fix.'));
} else {
onError?.(new GeolocationError('unavailable', 'Location temporarily unavailable.'));
}
},
{ enableHighAccuracy: false, maximumAge: 5000, timeout: 30000 },
);
} catch {
onError?.(new GeolocationError('unavailable', 'Could not start location tracking.'));
}
};
void start();
return {
stop: () => {
stopped = true;
if (watchId !== null) {
try {
navigator.geolocation.clearWatch(watchId);
} catch {
// ignore
}
watchId = null;
}
},
};
}
/**
* 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<GeoCoords> {
// 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 = <T>(fn: () => Promise<T>): Promise<T> =>
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: 8000, 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<GeoCoords> {
if (!('geolocation' in navigator)) {
return Promise.reject(
new GeolocationError('unavailable', 'Geolocation is not supported by this browser.'),
);
}
return new Promise<GeoCoords>((resolve, reject) => {
let settled = false;
let watchId: number | null = null;
let deadline: ReturnType<typeof setTimeout> | 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.'));
}, 8000);
// 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 },
);
});
}