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 <assistant@shakespeare.diy>
This commit is contained in:
shakespeare.diy 2026-07-24 23:30:17 +07:00
parent a9e0c1dcb7
commit 700ebee17e
2 changed files with 187 additions and 51 deletions

View file

@ -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<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>;
}
@ -157,6 +163,98 @@ export async function getCurrentPosition(): Promise<GeoCoords> {
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: <T>(fn: () => Promise<T>) => Promise<T>,
): Promise<GeoCoords> {
return new Promise<GeoCoords>((resolve, reject) => {
let settled = false;
let watchId: string | null = null;
let deadline: ReturnType<typeof setTimeout> | 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<GeoCoord
throw new GeolocationError('denied', 'Location permission denied.');
}
// Getting a location fix — especially a "cold start" the first time after
// granting permission, or indoors — can be slow. A GPS chip can take 30s+
// to acquire its first lock. So we try three increasingly-forgiving stages
// and return the first one that succeeds. Each is guarded so a failure just
// moves on to the next stage rather than surfacing as an error.
// Stage 1 — INSTANT: accept any recently cached fix (up to 5 min old).
// enableHighAccuracy:false + short timeout means "give me a cached/coarse
// fix immediately if you have one". This resolves instantly on a warm chip.
try {
const pos = await safeCall(() =>
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<GeoCoord
/**
* 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)) {
@ -266,18 +348,72 @@ function getWebPosition(): Promise<GeoCoords> {
}
return new Promise<GeoCoords>((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<typeof setTimeout> | 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 },
);
});
}

View file

@ -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',
});