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:
parent
a9e0c1dcb7
commit
700ebee17e
2 changed files with 187 additions and 51 deletions
|
|
@ -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 },
|
||||
);
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in a new issue