diff --git a/web/src/location/chat-pin.ts b/web/src/location/chat-pin.ts new file mode 100644 index 0000000..206765e --- /dev/null +++ b/web/src/location/chat-pin.ts @@ -0,0 +1,67 @@ +/* + * chat-pin.ts — source 3: the chat-keyboard pin relay (GEO-02). + * ----------------------------------------------------------- + * Telegram has no push channel from chat → WebView, so the app POLLS + * GET /api/location with its memory-held Bearer token until the user + * taps the request_location keyboard in chat and the bot relays the + * pin into TTL KV. + * + * The poll is BOUNDED (60s): AWAITING PIN can never persist forever — + * the bounded poll is what returns the UI to the denied card instead + * of wedging (RESEARCH §Pattern 3). requestChatFallback (the ASK VIA + * CHAT trigger) POSTs to /api/location-fallback — implemented in plan + * 03-01 task 3 Step D as handleLocationFallback. The pathname is a + * CONTRACT with 03-01, not a guess; if 03-01 ever renames the route, + * both plans change together. The route is gated identically to the + * poll: a 401 means the session expired and the caller must + * revalidate (D-03) via createSession, not retry blindly — so this + * function THROWS rather than silently leaving the user on a card + * whose button appears to do nothing. + */ +import type { Fix } from "./types.ts"; + +export const CHAT_PIN_POLL_MS = 2000; +export const CHAT_PIN_TIMEOUT_MS = 60000; + +export async function fetchChatPin(token: string): Promise { + const res = await fetch("/api/location", { + method: "GET", + headers: { authorization: `Bearer ${token}` }, + }); + if (!res.ok) return null; // 401 (expired session) and 405 both read as "no pin yet"; caller revalidates on the denied path + const body: unknown = await res.json(); + const pin = (body as { pin?: { latitude?: number; longitude?: number; horizontal_accuracy?: number | null } }).pin; + if (!pin || typeof pin.latitude !== "number" || typeof pin.longitude !== "number") return null; + return { + lat: pin.latitude, + lng: pin.longitude, + accuracyM: pin.horizontal_accuracy ?? null, + headingDeg: null, + ts: Date.now(), + source: "chat-pin", + }; +} + +export async function pollChatPin( + token: string, + options: { timeoutMs?: number; pollMs?: number; sleep?: (ms: number) => Promise } = {}, +): Promise { + const timeoutMs = options.timeoutMs ?? CHAT_PIN_TIMEOUT_MS; + const pollMs = options.pollMs ?? CHAT_PIN_POLL_MS; + const sleep = options.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms))); + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + const fix = await fetchChatPin(token); + if (fix !== null) return fix; + await sleep(pollMs); + } + return null; // bounded — returns to the denied card, never wedges AWAITING PIN +} + +export async function requestChatFallback(token: string): Promise { + const res = await fetch("/api/location-fallback", { + method: "POST", + headers: { authorization: `Bearer ${token}` }, + }); + if (!res.ok) throw new Error("location-fallback-request-failed"); +} diff --git a/web/src/location/telegram.ts b/web/src/location/telegram.ts new file mode 100644 index 0000000..dd0fc2a --- /dev/null +++ b/web/src/location/telegram.ts @@ -0,0 +1,108 @@ +/* + * 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); +} diff --git a/web/src/location/types.ts b/web/src/location/types.ts new file mode 100644 index 0000000..b644ec5 --- /dev/null +++ b/web/src/location/types.ts @@ -0,0 +1,45 @@ +/* + * types.ts — the phase's shared location contract (D-04, D-05, D-07). + * --------------------------------------------------------------- + * The single `Fix` shape every acquisition source normalizes to: + * `LocationData` (Telegram), `GeolocationPosition` (WebView), and the + * bot-relayed chat pin all reduce to these six fields. Freshness lives + * IN the fix (ts) — consumers measure age rather than trusting sources + * to be fresh (RESEARCH Pitfall 2, the Android static-fix). + * + * Type-only module: no runtime values that close over Fix instances, + * no storage, no imports. D-07: a Fix is a value in memory; nothing + * here (or anywhere downstream) may write it to a persistent store. + * + * The machine states mirror UI-SPEC §Status Strip: validating / + * acquiring / live / stale(signal-lost) / denied, plus awaiting-pin — + * the D-04 chat-keyboard wait surface (D-06: deliberate card, never + * a silent banner). + */ + +export type FixSource = "telegram" | "webview" | "chat-pin"; + +export type Fix = { + /** degrees — WGS-84 as the sources report them */ + lat: number; + lng: number; + /** horizontal accuracy in metres; null when the source did not report one */ + accuracyM: number | null; + /** heading in degrees or null — the UI hides the cone on null (no guess) */ + headingDeg: number | null; + /** ms epoch of the ACCEPTANCE moment — this drives FIX Ns AGO telemetry */ + ts: number; + source: FixSource; +}; + +export type LocationState = + | { kind: "idle" } + | { kind: "validating" } + | { kind: "acquiring"; since: number } + | { kind: "awaiting-pin"; since: number } + | { kind: "live"; fix: Fix } + | { kind: "stale"; fix: Fix } + | { kind: "denied"; reason: "no-permission" | "unsupported" | "timeout" }; + +/** KL map default (AGENTS.md region focus: Kuala Lumpur metro). [lng, lat]. */ +export const KL_CENTER: [number, number] = [101.6958, 3.1513]; diff --git a/web/src/location/webview.ts b/web/src/location/webview.ts new file mode 100644 index 0000000..878ed5b --- /dev/null +++ b/web/src/location/webview.ts @@ -0,0 +1,44 @@ +/* + * webview.ts — source 2: navigator.geolocation inside the WebView, and the + * Android freshness nudge's reading hand (D-04, D-05). + * ------------------------------------------------------------------- + * maximumAge: 0 is load-bearing — D-05 requires the freshest fix, and a + * cached position would silently defeat the freshness axis (the whole + * point of the nudge is to force a NEW read, not to re-read a cache). + * Success AND timeout both resolve; the error callback resolves null so + * the chain advances instead of rejecting. Timeout present for the same + * reason as the telegram wrapper: wedging ACQUIRING is never acceptable. + */ +import type { Fix } from "./types.ts"; + +export function webviewLocationSupported(): boolean { + return typeof navigator !== "undefined" && typeof navigator.geolocation?.getCurrentPosition === "function"; +} + +export function readWebviewFix(options: { timeoutMs?: number; nowMs?: () => number } = {}): Promise { + const timeoutMs = options.timeoutMs ?? 8000; + if (!webviewLocationSupported()) return Promise.resolve(null); // Desktop Telegram, plain browsers without permission + return new Promise((resolve) => { + let settled = false; + const done = (fix: Fix | null): void => { + if (settled) return; + settled = true; + clearTimeout(timer); + resolve(fix); + }; + const timer = setTimeout(() => done(null), timeoutMs); + navigator.geolocation.getCurrentPosition( + (pos) => + done({ + lat: pos.coords.latitude, + lng: pos.coords.longitude, + accuracyM: pos.coords.accuracy ?? null, + headingDeg: pos.coords.heading ?? null, + ts: (options.nowMs ?? Date.now)(), + source: "webview", + }), + () => done(null), // denial / unavailability = null; the chain's normal signal (no error object escapes) + { enableHighAccuracy: true, timeout: timeoutMs, maximumAge: 0 }, + ); + }); +} diff --git a/web/src/telegram/webapp.ts b/web/src/telegram/webapp.ts new file mode 100644 index 0000000..788719d --- /dev/null +++ b/web/src/telegram/webapp.ts @@ -0,0 +1,43 @@ +/* + * webapp.ts — the ONE narrow typed accessor for window.Telegram.WebApp + * (D-11, RESEARCH §Standard Stack note). + * ------------------------------------------------------------------- + * `@types/telegram-web-app` is deliberately NOT a dependency (plan 03-01 + * dropped it): this hand-rolled `TelegramWebAppLike` IS the contract — + * every test fake, the D-11 harness mock, and the production clients + * satisfy exactly these ~12 members. The official 500-member `WebApp` + * type would force each fake to stub ~50 members. + * + * getWebApp() may honestly return NULL (plain browser, no Telegram) — + * that null is the contract the location chain must handle. All + * `Telegram.WebApp.*` calls must originate from the app's own document + * (Bot API 10.2 origin restriction). + */ +export interface TelegramWebAppLike { + initData: string; + colorScheme?: "light" | "dark"; + themeParams?: { header_bg_color?: string }; + version?: string; + platform?: string; + safeAreaInset?: { top: number; bottom: number; left: number; right: number }; + contentSafeAreaInset?: { top: number; bottom: number; left: number; right: number }; + isVersionAtLeast(version: string): boolean; + close(): void; + onEvent?(event: string, cb: () => void): void; + offEvent?(event: string, cb: () => void): void; + BackButton?: { show(): void; hide(): void; onClick(cb: () => void): void; offClick(cb: () => void): void }; + LocationManager?: { + isInited: boolean; + isLocationAvailable?: boolean; + isAccessRequested?: boolean; + isAccessGranted?: boolean; + init(cb: () => void): void; + getLocation(cb: (data: unknown | null) => void): void; + openSettings?(): void; + }; +} + +export function getWebApp(): TelegramWebAppLike | null { + const w = window as unknown as { Telegram?: { WebApp?: TelegramWebAppLike } }; + return w.Telegram?.WebApp ?? null; +}