forked from c-fu/arah2
- types.ts: the shared Fix shape + LocationState machine (type-only, D-04/D-05/D-07) - telegram.ts: version-gated LocationManager init/getLocation, hard 8s timeout (Pitfall 3), null-safe telegramLocationSupported (plain browser/Desktop = false, not TypeError), looksStale + haversine + the Android nudge (issue #56 navbar read DISCARDED, then retry) - webview.ts: one-shot geolocation read, maximumAge: 0 (freshness axis), 8s timeout - chat-pin.ts: bounded 60s poll of GET /api/location (Bearer token) + POST /api/location-fallback throw-on-fail so the ASK VIA CHAT handler can revalidate (D-03) - telegram/webapp.ts created early (Rule 3: task 1 imports TelegramWebAppLike from task 3's file; declared the narrow hand-rolled interface now, verbatim per plan)
109 lines
4.8 KiB
TypeScript
109 lines
4.8 KiB
TypeScript
/*
|
|
* telegram.ts — source 1: the Telegram LocationManager wrapper (GEO-01).
|
|
* -------------------------------------------------------------------
|
|
* Version-gated (Bot API 8.0+), init()-before-getLocation(), NULL as the
|
|
* one denial signal (no error object exists), and a hard wrapper timeout
|
|
* because the API documents no timeout — without it the boot choreography
|
|
* wedges in ACQUIRING forever on a wedged client (RESEARCH Pitfall 3).
|
|
*
|
|
* Staleness (the Android static-fix, Telegram-Mini-Apps issue #56) is a
|
|
* SEPARATE axis from availability: two byte-identical successive fixes
|
|
* mean the native cache never refreshed. `looksStale` detects it and
|
|
* `nudgeAndRetryTelegram` routes it to recovery — one discarded webview
|
|
* read (the nudge) refreshes the native cache so the NEXT telegram read
|
|
* returns fresh coordinates — never to a "successful" stale pin.
|
|
*/
|
|
import type { Fix } from "./types.ts";
|
|
import type { TelegramWebAppLike } from "../telegram/webapp.ts";
|
|
|
|
/** NULL-SAFE by contract: `getWebApp()` returns `TelegramWebAppLike | null`, so
|
|
* this must accept null and answer false — Desktop Telegram and the plain
|
|
* browser both have no WebApp object at all (issue #77), and a TypeError here
|
|
* would crash the boot before the chain can fall through to the next link. */
|
|
export function telegramLocationSupported(wa: TelegramWebAppLike | null): boolean {
|
|
if (!wa) return false;
|
|
return typeof wa.isVersionAtLeast === "function" && wa.isVersionAtLeast("8.0");
|
|
}
|
|
|
|
export async function requestTelegramFix(
|
|
wa: TelegramWebAppLike,
|
|
options: { timeoutMs?: number; nowMs?: () => number } = {},
|
|
): Promise<Fix | null> {
|
|
const timeoutMs = options.timeoutMs ?? 8000;
|
|
const nowMs = options.nowMs ?? Date.now;
|
|
const lm = wa.LocationManager;
|
|
if (!lm) return null; // field absent on sub-8.0 clients — denial signal, not an error
|
|
return new Promise((resolve) => {
|
|
let settled = false;
|
|
const finish = (data: unknown | null): void => {
|
|
if (settled) return;
|
|
settled = true;
|
|
clearTimeout(timer);
|
|
if (!data) {
|
|
// getLocation calls back with null when access was NOT granted —
|
|
// the whole denial class. The chain advances; nothing throws.
|
|
resolve(null);
|
|
return;
|
|
}
|
|
const loc = data as { latitude: number; longitude: number; horizontal_accuracy?: number; course?: number };
|
|
resolve({
|
|
lat: loc.latitude,
|
|
lng: loc.longitude,
|
|
accuracyM: loc.horizontal_accuracy ?? null,
|
|
headingDeg: loc.course ?? null,
|
|
ts: nowMs(),
|
|
source: "telegram",
|
|
});
|
|
};
|
|
// The hard timeout (Pitfall 3): init()/getLocation() have no documented
|
|
// timeout and no error channel — this is the only guarantee the boot
|
|
// choreography ever leaves ACQUIRING.
|
|
const timer = setTimeout(() => finish(null), timeoutMs);
|
|
if (lm.isInited) lm.getLocation(finish);
|
|
else lm.init(() => lm.getLocation(finish));
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Android cache-detection (issue #56): a device that has MOVED but returns
|
|
* byte-identical coordinates to sub-centimetre precision is the signature
|
|
* of the never-refreshing native cache. Only telegram-vs-telegram pairs
|
|
* qualify — different sources bounding identically is coincidence, not stale.
|
|
*/
|
|
export function looksStale(prev: Fix | null, next: Fix): boolean {
|
|
if (prev === null || prev.source !== "telegram" || next.source !== "telegram") return false;
|
|
return haversineMeters(prev, next) < 0.01;
|
|
}
|
|
|
|
export function haversineMeters(a: Fix, b: Fix): number {
|
|
const R = 6371008.8;
|
|
const toRad = (d: number): number => (d * Math.PI) / 180;
|
|
const dLat = toRad(b.lat - a.lat);
|
|
const dLng = toRad(b.lng - a.lng);
|
|
const s =
|
|
Math.sin(dLat / 2) ** 2 +
|
|
Math.cos(toRad(a.lat)) * Math.cos(toRad(b.lat)) * Math.sin(dLng / 2) ** 2;
|
|
return 2 * R * Math.asin(Math.min(1, Math.sqrt(s)));
|
|
}
|
|
|
|
/**
|
|
* The Android stale-fix recovery: read telegram once; if the fix is
|
|
* byte-identical to the last accepted one, perform the nudge — ONE
|
|
* navigator.geolocation read whose result is DISCARDED (a WebView read
|
|
* refreshes the native cache the NEXT telegram read then returns, per
|
|
* issue #56) — then retry telegram. Degrades to "telegram still wins or
|
|
* the caller falls through" — never crashes, never returns the stale fix.
|
|
*/
|
|
export async function nudgeAndRetryTelegram(
|
|
wa: TelegramWebAppLike,
|
|
readWebviewFix: (o?: { timeoutMs?: number }) => Promise<Fix | null>,
|
|
lastAccepted: Fix | null,
|
|
options: { timeoutMs?: number; nowMs?: () => number } = {},
|
|
): Promise<Fix | null> {
|
|
const first = await requestTelegramFix(wa, options);
|
|
if (first === null) return null;
|
|
if (!looksStale(lastAccepted, first)) return first;
|
|
await readWebviewFix({ timeoutMs: 5000 }); // the nudge — result DISCARDED (issue #56)
|
|
return requestTelegramFix(wa, options);
|
|
}
|