/* * 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 { 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, lastAccepted: Fix | null, options: { timeoutMs?: number; nowMs?: () => number } = {}, ): Promise { 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); }