From 700ebee17e197c327fce092e49cbdb1f325c4e89 Mon Sep 17 00:00:00 2001 From: "shakespeare.diy" Date: Fri, 24 Jul 2026 23:30:17 +0700 Subject: [PATCH] Geolocation: fast first-fix via watchPosition race (Google-Maps-like) The real reason Near Me failed indoors: getCurrentPosition with high accuracy waits for a single satisfactory GPS reading, which needs satellite visibility and times out indoors. Google Maps feels instant because it takes the FIRST update from any provider (wifi/cell/fused). Rewrite the acquisition strategy to do the same: - raceForFirstFix() starts a low-accuracy watchPosition and resolves on the very first update (the fast network/fused fix, ~1s indoors), racing it against a cheap cached getCurrentPosition. First to return wins; watch is cleared immediately after. - 25s deadline falls back to one long high-accuracy attempt for the cold-outdoor-GPS case. - Apply the same watchPosition-race to the web path. - watchPosition/clearWatch added to the plugin proxy interface, guarded so builds without them fall back to getCurrentPosition. - Update timeout toast (drop the "walk to a window" advice; point at the GPS/Location quick-setting toggle instead). Co-authored-by: shakespeare.diy --- src/lib/geolocation.ts | 236 ++++++++++++++++++++++++++++++++--------- src/pages/MapPage.tsx | 2 +- 2 files changed, 187 insertions(+), 51 deletions(-) diff --git a/src/lib/geolocation.ts b/src/lib/geolocation.ts index 853a940..f952909 100644 --- a/src/lib/geolocation.ts +++ b/src/lib/geolocation.ts @@ -66,8 +66,14 @@ interface CapPermissionStatus { location: 'granted' | 'denied' | 'prompt' | 'prompt-with-rationale'; coarseLocation?: 'granted' | 'denied' | 'prompt' | 'prompt-with-rationale'; } +type CapWatchCallback = (position: CapPosition | null, err?: unknown) => void; interface CapGeolocationPlugin { getCurrentPosition(options?: { enableHighAccuracy?: boolean; timeout?: number; maximumAge?: number }): Promise; + watchPosition( + options: { enableHighAccuracy?: boolean; timeout?: number; maximumAge?: number }, + callback: CapWatchCallback, + ): Promise; + clearWatch(options: { id: string }): Promise; checkPermissions(): Promise; requestPermissions(options?: { permissions?: string[] }): Promise; } @@ -157,6 +163,98 @@ export async function getCurrentPosition(): Promise { return getWebPosition(); } +/** + * Get the FIRST location fix from any provider as fast as possible — the trick + * that makes Google Maps feel instant indoors. + * + * 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. + * + * 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: (fn: () => Promise) => Promise, +): Promise { + return new Promise((resolve, reject) => { + let settled = false; + let watchId: string | null = null; + let deadline: ReturnType | null = null; + + const cleanup = () => { + if (deadline) clearTimeout(deadline); + if (watchId !== null) { + const id = watchId; + watchId = null; + // clearWatch is fire-and-forget; guard so it can't throw into us. + safeCall(() => native.clearWatch({ id })).catch(() => {}); + } + }; + + const succeed = (pos: CapPosition) => { + if (settled) return; + settled = true; + cleanup(); + resolve({ lat: pos.coords.latitude, lng: pos.coords.longitude }); + }; + + const fail = (err: unknown) => { + if (settled) return; + settled = true; + cleanup(); + reject(err instanceof Error ? err : new Error(String(err))); + }; + + // Overall deadline — if nothing arrives, let the caller try high-accuracy. + deadline = setTimeout(() => fail(new Error('fast-fix timeout')), 25000); + + // 1) watchPosition — first update wins (fast network/fused fix). + if (typeof native.watchPosition === 'function') { + safeCall(() => + native.watchPosition( + { enableHighAccuracy: false, timeout: 20000, maximumAge: 60000 }, + (position, err) => { + if (position) succeed(position); + else if (err) console.warn('[geolocation] watch update error:', err); + }, + ), + ) + .then((id) => { + if (settled) { + // Already resolved by getCurrentPosition; tidy up this watch. + safeCall(() => native.clearWatch({ id })).catch(() => {}); + } else { + watchId = id; + } + }) + .catch((watchErr) => { + console.warn('[geolocation] watchPosition unavailable:', watchErr); + }); + } + + // 2) getCurrentPosition — instant if the OS has a recent fix. + safeCall(() => + native.getCurrentPosition({ + enableHighAccuracy: false, + timeout: 15000, + maximumAge: 120000, + }), + ) + .then((pos) => succeed(pos)) + .catch((gcpErr) => { + // Don't fail the whole race — watchPosition may still deliver. Only the + // deadline (or watch) decides the outcome. Log for visibility. + console.warn('[geolocation] getCurrentPosition (fast) 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 @@ -188,53 +286,33 @@ async function getNativePosition(native: CapGeolocationPlugin): Promise - native.getCurrentPosition({ - enableHighAccuracy: false, - timeout: 8000, - maximumAge: 300000, // accept a fix up to 5 minutes old - }), - ); - return { lat: pos.coords.latitude, lng: pos.coords.longitude }; - } catch (cachedErr) { - console.warn('[geolocation] cached/coarse fix unavailable, trying GPS:', cachedErr); - } - - // Stage 2 — PRECISE: full high-accuracy GPS with a generous window so a - // cold first-fix has room to complete. + // ── 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. try { + return await raceForFirstFix(native, safeCall); + } catch (fixErr) { + console.warn('[geolocation] fast-fix strategy failed:', fixErr); + // Last-ditch: one more high-accuracy attempt with a long window (outdoors + // / cold GPS). If this also fails we fall through to the catch below. const pos = await safeCall(() => native.getCurrentPosition({ enableHighAccuracy: true, - timeout: 30000, // give a cold GPS lock plenty of time - maximumAge: 60000, + timeout: 30000, + maximumAge: 300000, }), ); return { lat: pos.coords.latitude, lng: pos.coords.longitude }; - } catch (highAccErr) { - console.warn('[geolocation] high-accuracy failed, trying coarse:', highAccErr); } - - // Stage 3 — COARSE FALLBACK: network/cell/wifi fix with the longest window. - // This rescues the common "GPS can't lock indoors" case. - const pos = await safeCall(() => - native.getCurrentPosition({ - enableHighAccuracy: false, - timeout: 30000, - maximumAge: 300000, - }), - ); - 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(); @@ -257,6 +335,10 @@ async function getNativePosition(native: CapGeolocationPlugin): Promise { if (!('geolocation' in navigator)) { @@ -266,18 +348,72 @@ function getWebPosition(): Promise { } return new Promise((resolve, reject) => { - navigator.geolocation.getCurrentPosition( - (pos) => resolve({ lat: pos.coords.latitude, lng: pos.coords.longitude }), - (err) => { - if (err.code === err.PERMISSION_DENIED) { - reject(new GeolocationError('denied', 'Location permission denied.')); - } else if (err.code === err.TIMEOUT) { - reject(new GeolocationError('timeout', 'Location request timed out.')); - } else { - reject(new GeolocationError('unavailable', 'Location unavailable.')); + 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 } - }, - { enableHighAccuracy: true, timeout: 20000, maximumAge: 60000 }, + 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 }, ); }); } diff --git a/src/pages/MapPage.tsx b/src/pages/MapPage.tsx index c77125c..da077a7 100644 --- a/src/pages/MapPage.tsx +++ b/src/pages/MapPage.tsx @@ -658,7 +658,7 @@ export default function MapPage() { toast({ title: 'Location timed out', description: isNativeGeolocation() - ? "Couldn't get a GPS fix in time. This is common on the first try or indoors — step near a window or outside and tap Near Me again." + ? "Couldn't get a location fix. Make sure Location/GPS is turned on in your phone's quick settings, then tap Near Me again." : 'Could not get your location in time. Check your signal and try again.', variant: 'destructive', });