Geolocation: GrapheneOS-correct fix — parallel hi/lo watches, 60s, progress

The persistent failure on GrapheneOS (no Play Services, GPS-only) was
caused by the acquisition strategy itself, not the OS:

- The old low-accuracy-only watch never fired without a fused/network
  provider, and the 25s deadline killed it before a cold GPS lock (which
  takes 30–60s on GrapheneOS) could ever deliver.

Rewrite raceForFirstFix to actually work on GPS-only devices:
- Run BOTH a high-accuracy (GPS) and low-accuracy (network) watch in
  parallel — high accuracy is what engages the GPS chip and is usually
  the one that delivers on GrapheneOS.
- Also fire a one-shot high-accuracy getCurrentPosition.
- Extend the deadline to 60s (real cold-GPS lock time) and classify a
  timeout-flavored failure correctly.
- Ignore invalid/null-island (0,0) readings.
- Drop the redundant post-race extra attempt.

UX: show a persistent "Locating you…" toast (dismissed on success/fail)
so the up-to-60s GPS lock doesn't look frozen, plus a "Found you"
confirmation. Failure still surfaces the raw error text for diagnosis.

Co-authored-by: shakespeare.diy <assistant@shakespeare.diy>
This commit is contained in:
shakespeare.diy 2026-07-25 00:36:16 +07:00
parent a3dad2cbec
commit 59a801d525
2 changed files with 79 additions and 41 deletions

View file

@ -184,24 +184,37 @@ function raceForFirstFix(
): Promise<GeoCoords> {
return new Promise<GeoCoords>((resolve, reject) => {
let settled = false;
let watchId: string | null = null;
const watchIds: string[] = [];
let deadline: ReturnType<typeof setTimeout> | null = null;
let sawTimeoutish = false; // remember if the failure looked like a timeout
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.
const clearAllWatches = () => {
while (watchIds.length) {
const id = watchIds.pop()!;
safeCall(() => native.clearWatch({ id })).catch(() => {});
}
};
const cleanup = () => {
if (deadline) clearTimeout(deadline);
clearAllWatches();
};
const succeed = (pos: CapPosition) => {
if (settled) return;
// Guard against garbage readings (0,0 / null island).
const { latitude, longitude } = pos?.coords ?? {};
if (
typeof latitude !== 'number' ||
typeof longitude !== 'number' ||
(latitude === 0 && longitude === 0)
) {
console.warn('[geolocation] ignoring invalid reading', pos);
return;
}
settled = true;
cleanup();
resolve({ lat: pos.coords.latitude, lng: pos.coords.longitude });
resolve({ lat: latitude, lng: longitude });
};
const fail = (err: unknown) => {
@ -211,46 +224,67 @@ function raceForFirstFix(
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);
// GrapheneOS (no Play Services) has no fast fused provider — a cold GPS lock
// can genuinely take 30–60s. Give it a real chance before giving up.
deadline = setTimeout(
() => fail(new Error(sawTimeoutish ? 'timeout' : 'fast-fix timeout')),
60000,
);
// 1) watchPosition — first update wins (fast network/fused fix).
if (typeof native.watchPosition === 'function') {
// Register a watch with the given accuracy; first valid reading wins.
const startWatch = (highAccuracy: boolean) => {
if (typeof native.watchPosition !== 'function') return;
safeCall(() =>
native.watchPosition(
{ enableHighAccuracy: false, timeout: 20000, maximumAge: 60000 },
{ enableHighAccuracy: highAccuracy, timeout: 60000, maximumAge: 0 },
(position, err) => {
if (position) succeed(position);
else if (err) console.warn('[geolocation] watch update error:', err);
if (position) {
succeed(position);
} else if (err) {
const m = String(
(err as { message?: string })?.message ?? err,
).toLowerCase();
if (m.includes('timeout') || m.includes('timed out')) sawTimeoutish = true;
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;
watchIds.push(id);
}
})
.catch((watchErr) => {
console.warn('[geolocation] watchPosition unavailable:', watchErr);
console.warn('[geolocation] watchPosition failed:', watchErr);
});
}
};
// 2) getCurrentPosition — instant if the OS has a recent fix.
// Run BOTH a high-accuracy (GPS) and low-accuracy (network, if any) watch in
// parallel. On GrapheneOS the GPS one is usually what actually delivers, so
// high accuracy MUST be engaged — the old low-accuracy-only watch never
// fired without a network provider. Whichever returns first wins.
startWatch(true);
startWatch(false);
// Also fire a one-shot high-accuracy getCurrentPosition (some plugin
// versions deliver here even when the watch is quiet). Cached ok.
safeCall(() =>
native.getCurrentPosition({
enableHighAccuracy: false,
timeout: 15000,
maximumAge: 120000,
enableHighAccuracy: true,
timeout: 55000,
maximumAge: 60000,
}),
)
.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);
const m = String(
(gcpErr as { message?: string })?.message ?? gcpErr,
).toLowerCase();
if (m.includes('timeout') || m.includes('timed out')) sawTimeoutish = true;
console.warn('[geolocation] getCurrentPosition failed:', gcpErr);
});
});
}
@ -298,21 +332,10 @@ async function getNativePosition(native: CapGeolocationPlugin): Promise<GeoCoord
// 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,
maximumAge: 300000,
}),
);
return { lat: pos.coords.latitude, lng: pos.coords.longitude };
}
// raceForFirstFix already runs parallel high+low accuracy watches AND a
// one-shot getCurrentPosition for up to 60s, so there's nothing more to try
// if it fails — let the error propagate to the classifier below.
return await raceForFirstFix(native, safeCall);
} catch (err) {
if (err instanceof GeolocationError) throw err;
const msg = (err instanceof Error ? err.message : String(err)).toLowerCase();

View file

@ -629,13 +629,28 @@ export default function MapPage() {
setNearMeError(null);
setNearMeLoading(true);
// Getting a GPS lock (especially first-time / on ROMs without Google's
// fused provider) can take a while — let the user know it's working.
const locatingToast = toast({
title: 'Locating you…',
description: 'Getting a GPS fix. This can take up to a minute the first time.',
duration: 60000,
});
try {
const { lat, lng } = await getCurrentPosition();
locatingToast.dismiss();
setFlyTo({ lat, lng, zoom: 14 });
setUserLocation({ lat, lon: lng });
setNearMeLoading(false);
setNearMeError(null);
toast({
title: 'Found you',
description: 'Centered the map on your location.',
duration: 3000,
});
} catch (err) {
locatingToast.dismiss();
setNearMeLoading(false);
const kind = err instanceof GeolocationError ? err.kind : 'unavailable';