diff --git a/app/(tabs)/index.tsx b/app/(tabs)/index.tsx index c82349d1..7c69237e 100644 --- a/app/(tabs)/index.tsx +++ b/app/(tabs)/index.tsx @@ -27,6 +27,7 @@ import type BottomSheet from "@gorhom/bottom-sheet" import type { Session, Project } from "../../src/lib/sdk" import { DirectorySwitcher, DirectoryBrowserSheet } from "../../src/components/chat" import { groupByDirectory } from "../../src/lib/session-grouping" +import { UpdateBanner } from "../../src/components/UpdateBanner" import { nameOf } from "../../src/lib/path-utils" import { SETUP_GUIDE_URL } from "../../src/lib/links" @@ -548,6 +549,8 @@ export default function SessionsScreen() { )} + + (row.type === "header" ? `dir:${row.directory}` : row.session.id)} diff --git a/app/(tabs)/settings.tsx b/app/(tabs)/settings.tsx index f694ddd7..af3a4559 100644 --- a/app/(tabs)/settings.tsx +++ b/app/(tabs)/settings.tsx @@ -1,4 +1,4 @@ -import { useCallback, useState } from "react" +import { useCallback, useEffect, useState } from "react" import { View, Text, @@ -23,6 +23,7 @@ import { import type { Category } from "../../src/lib/notifications" import { hasTelemetryConsent, setTelemetryConsent } from "../../src/lib/telemetry" import { PRIVACY_POLICY_URL } from "../../src/lib/links" +import { CURRENT_VERSION, checkForUpdate, type AvailableUpdate } from "../../src/lib/update-check" import type { LocalePreference } from "../../src/lib/i18n/locale-resolve" function SettingRow({ @@ -79,6 +80,23 @@ export default function SettingsScreen() { const [osGranted, setOsGranted] = useState(null) const [telemetryUpdating, setTelemetryUpdating] = useState(false) + // Settings is where a user goes to ask "what am I running?". Answer it, and if + // a newer build exists say so here too — the banner on the sessions list is + // dismissible, this row is not (AGE-110). Uses the same 24h-throttled check, + // so opening Settings repeatedly costs no extra requests. + const [updateAvailable, setUpdateAvailable] = useState(null) + useEffect(() => { + let cancelled = false + checkForUpdate({ ignoreDismissed: true }) + .then((result) => { + if (!cancelled) setUpdateAvailable(result) + }) + .catch(() => undefined) + return () => { + cancelled = true + } + }, []) + // Telemetry consent: hasTelemetryConsent() returns null (unknown), true, or false. // We initialise local state from in-memory value; updates call setTelemetryConsent(). const [crashReporting, setCrashReporting] = useState(hasTelemetryConsent() ?? false) @@ -247,7 +265,25 @@ export default function SettingsScreen() { onPress={handleLanguagePress} right={} /> - + Linking.openURL(updateAvailable.url) : undefined} + right={ + updateAvailable ? ( + + ) : undefined + } + /> (null) + + useEffect(() => { + let cancelled = false + checkForUpdate() + .then((result) => { + if (!cancelled) setUpdate(result) + }) + .catch(() => undefined) + return () => { + cancelled = true + } + }, []) + + const onDismiss = useCallback(() => { + if (update) void dismissUpdate(update.version) + setUpdate(null) + }, [update]) + + const onOpen = useCallback(() => { + if (!update) return + // Opening the release page is also an implicit "I've seen this version": + // do not nag about it again either. + void dismissUpdate(update.version) + void Linking.openURL(update.url) + }, [update]) + + if (!update) return null + + return ( + + + + {t("update.available")} + + {t("update.body", { version: update.version, current: CURRENT_VERSION })} + + + + {t("update.action")} + + + + + + ) +} + +const styles = StyleSheet.create({ + banner: { + flexDirection: "row", + alignItems: "center", + gap: 10, + paddingHorizontal: 16, + paddingVertical: 10, + backgroundColor: "#e0f2fe", + }, + bannerDark: { + backgroundColor: "#0c2b3d", + }, + text: { + flex: 1, + }, + title: { + fontSize: 14, + fontWeight: "600", + color: "#0c4a6e", + }, + titleDark: { + color: "#e0f2fe", + }, + body: { + fontSize: 12, + color: "#0369a1", + }, + bodyDark: { + color: "#94a3b8", + }, + action: { + fontSize: 14, + fontWeight: "600", + color: "#0369a1", + }, + actionDark: { + color: "#7dd3fc", + }, +}) diff --git a/src/lib/i18n/en.json b/src/lib/i18n/en.json index c909fbd0..13519f23 100644 --- a/src/lib/i18n/en.json +++ b/src/lib/i18n/en.json @@ -444,5 +444,12 @@ "connectButton": "Connect your own server", "setupGuideLink": "How to set up a server", "hostedCtaLink": "No server? Join the OpenCode Connect waitlist — hosted, no setup" + }, + "update": { + "available": "Update available", + "body": "Version {{version}} is out. You're on {{current}}.", + "action": "Get it", + "dismiss": "Not now", + "upToDate": "Up to date" } } diff --git a/src/lib/i18n/zh-Hans.json b/src/lib/i18n/zh-Hans.json index 7ebe4742..9ba906f7 100644 --- a/src/lib/i18n/zh-Hans.json +++ b/src/lib/i18n/zh-Hans.json @@ -444,5 +444,12 @@ "connectButton": "连接您自己的服务器", "setupGuideLink": "如何设置服务器", "hostedCtaLink": "没有服务器?加入 OpenCode Connect 候补名单 — 托管,无需搭建" + }, + "update": { + "available": "有可用更新", + "body": "版本 {{version}} 已发布,你当前使用的是 {{current}}。", + "action": "去获取", + "dismiss": "暂不", + "upToDate": "已是最新" } } diff --git a/src/lib/update-check-policy.test.ts b/src/lib/update-check-policy.test.ts new file mode 100644 index 00000000..4b6e7715 --- /dev/null +++ b/src/lib/update-check-policy.test.ts @@ -0,0 +1,266 @@ +import { test } from "node:test" +import assert from "node:assert/strict" +import { + CHECK_INTERVAL_MS, + compareVersions, + isNewer, + parseVersion, + shouldCheck, + shouldPrompt, + resolveUpdate, + LAST_CHECK_KEY, + LATEST_KEY, + DISMISSED_KEY, +} from "./update-check-policy.ts" + +test("parseVersion accepts the shapes our releases actually use", () => { + assert.deepEqual(parseVersion("0.4.10"), [0, 4, 10]) + assert.deepEqual(parseVersion("v0.4.10"), [0, 4, 10]) + assert.deepEqual(parseVersion(" v0.4.14 "), [0, 4, 14]) + assert.deepEqual(parseVersion("0.4.14-rc.1"), [0, 4, 14]) + assert.deepEqual(parseVersion("1.0"), [1, 0]) +}) + +test("parseVersion rejects anything it cannot compare", () => { + assert.equal(parseVersion("unknown"), null) + assert.equal(parseVersion(""), null) + assert.equal(parseVersion(null), null) + assert.equal(parseVersion(undefined), null) + assert.equal(parseVersion("nightly"), null) + assert.equal(parseVersion("0.4.x"), null) +}) + +test("0.4.9 is older than 0.4.10 — the comparison a string sort gets backwards", () => { + // Not a hypothetical: v0.4.10 held 64% of the 30d-active base on 2026-08-14. + // A lexicographic compare would have told that exact cohort it was current. + assert.equal(compareVersions("0.4.9", "0.4.10"), -1) + assert.equal(compareVersions("0.4.10", "0.4.9"), 1) + assert.equal(isNewer("0.4.10", "0.4.9"), true) + assert.equal(isNewer("0.4.9", "0.4.10"), false) +}) + +test("compareVersions handles equality and differing segment counts", () => { + assert.equal(compareVersions("0.4.14", "0.4.14"), 0) + assert.equal(compareVersions("0.4", "0.4.0"), 0) + assert.equal(compareVersions("0.5", "0.4.99"), 1) + assert.equal(compareVersions("1.0.0", "0.9.9"), 1) +}) + +test("unknown versions never count as newer", () => { + assert.equal(isNewer("unknown", "0.4.10"), false) + assert.equal(isNewer("0.4.15", "unknown"), false) + assert.equal(isNewer(null, "0.4.10"), false) + assert.equal(isNewer("0.4.15", null), false) +}) + +test("shouldCheck throttles to one check per interval", () => { + const now = 1_786_723_000_000 + assert.equal(shouldCheck({ lastCheckedAt: null, now }), true) + assert.equal(shouldCheck({ lastCheckedAt: now - CHECK_INTERVAL_MS, now }), true) + assert.equal(shouldCheck({ lastCheckedAt: now - CHECK_INTERVAL_MS + 1, now }), false) + assert.equal(shouldCheck({ lastCheckedAt: now, now }), false) +}) + +test("shouldCheck recovers from a clock that moved backwards", () => { + const now = 1_786_723_000_000 + // Restored backup / NTP correction: a future timestamp must not disable + // update checks until real time catches up. + assert.equal(shouldCheck({ lastCheckedAt: now + CHECK_INTERVAL_MS * 30, now }), true) + assert.equal(shouldCheck({ lastCheckedAt: Number.NaN, now }), true) +}) + +test("shouldCheck honours a custom interval", () => { + const now = 1_786_723_000_000 + assert.equal(shouldCheck({ lastCheckedAt: now - 1000, now, intervalMs: 500 }), true) + assert.equal(shouldCheck({ lastCheckedAt: now - 100, now, intervalMs: 500 }), false) +}) + +test("prompts only for a strictly newer version", () => { + assert.equal( + shouldPrompt({ currentVersion: "0.4.10", latestVersion: "0.4.14", dismissedVersion: null }), + true, + ) + assert.equal( + shouldPrompt({ currentVersion: "0.4.14", latestVersion: "0.4.14", dismissedVersion: null }), + false, + ) + // A mirror or a stale CDN reporting an older tag must never trigger a prompt. + assert.equal( + shouldPrompt({ currentVersion: "0.4.14", latestVersion: "0.4.10", dismissedVersion: null }), + false, + ) +}) + +test("a dismissal sticks for that version but not for the next one", () => { + assert.equal( + shouldPrompt({ currentVersion: "0.4.10", latestVersion: "0.4.14", dismissedVersion: "0.4.14" }), + false, + ) + assert.equal( + shouldPrompt({ currentVersion: "0.4.10", latestVersion: "0.4.15", dismissedVersion: "0.4.14" }), + true, + ) + // Older-than-dismissed stays dismissed. + assert.equal( + shouldPrompt({ currentVersion: "0.4.9", latestVersion: "0.4.12", dismissedVersion: "0.4.14" }), + false, + ) +}) + +test("never prompts when the running version is unknown", () => { + assert.equal( + shouldPrompt({ currentVersion: "unknown", latestVersion: "0.4.14", dismissedVersion: null }), + false, + ) + assert.equal( + shouldPrompt({ currentVersion: "0.4.10", latestVersion: null, dismissedVersion: null }), + false, + ) +}) + +function memoryStorage(initial: Record = {}) { + const data = new Map(Object.entries(initial)) + return { + data, + getItem: async (key: string) => data.get(key) ?? null, + setItem: async (key: string, value: string) => { + data.set(key, value) + }, + } +} + +const NOW = 1_786_723_000_000 + +test("resolveUpdate: fetches, caches and reports a newer release", async () => { + const storage = memoryStorage() + let calls = 0 + const update = await resolveUpdate({ + storage, + currentVersion: "0.4.10", + now: NOW, + fetchLatest: async () => { + calls++ + return { version: "0.4.14", url: "https://example.test/v0.4.14" } + }, + }) + + assert.deepEqual(update, { version: "0.4.14", url: "https://example.test/v0.4.14" }) + assert.equal(calls, 1) + assert.equal(storage.data.get(LAST_CHECK_KEY), String(NOW)) + assert.deepEqual(JSON.parse(storage.data.get(LATEST_KEY) as string), { + version: "0.4.14", + url: "https://example.test/v0.4.14", + }) +}) + +test("resolveUpdate: within the interval it serves the cache without a network call", async () => { + const storage = memoryStorage({ + [LAST_CHECK_KEY]: String(NOW - 1000), + [LATEST_KEY]: JSON.stringify({ version: "0.4.14", url: "https://example.test/v0.4.14" }), + }) + let calls = 0 + const update = await resolveUpdate({ + storage, + currentVersion: "0.4.10", + now: NOW, + fetchLatest: async () => { + calls++ + return { version: "0.4.15", url: "https://example.test/v0.4.15" } + }, + }) + + assert.equal(calls, 0) + assert.equal(update?.version, "0.4.14") +}) + +test("resolveUpdate: a failed fetch is silent and does not consume the interval", async () => { + const storage = memoryStorage() + const update = await resolveUpdate({ + storage, + currentVersion: "0.4.10", + now: NOW, + fetchLatest: async () => { + throw new Error("offline") + }, + }) + + assert.equal(update, null) + // No timestamp written: the next launch with network must try again. + assert.equal(storage.data.get(LAST_CHECK_KEY), undefined) +}) + +test("resolveUpdate: garbage from the network or the cache is ignored", async () => { + const storage = memoryStorage({ [LATEST_KEY]: "not json" }) + const update = await resolveUpdate({ + storage, + currentVersion: "0.4.10", + now: NOW, + fetchLatest: async () => ({ version: "nightly", url: "https://example.test/nightly" }), + }) + + assert.equal(update, null) + assert.equal(storage.data.get(LAST_CHECK_KEY), undefined) +}) + +test("resolveUpdate: a dismissed version stays dismissed even though it is cached", async () => { + const storage = memoryStorage({ + [LAST_CHECK_KEY]: String(NOW - 1000), + [LATEST_KEY]: JSON.stringify({ version: "0.4.14", url: "https://example.test/v0.4.14" }), + [DISMISSED_KEY]: "0.4.14", + }) + const update = await resolveUpdate({ + storage, + currentVersion: "0.4.10", + now: NOW, + fetchLatest: async () => null, + }) + + assert.equal(update, null) +}) + +test("resolveUpdate: already on the newest build -> nothing to show", async () => { + const storage = memoryStorage() + const update = await resolveUpdate({ + storage, + currentVersion: "0.4.14", + now: NOW, + fetchLatest: async () => ({ version: "0.4.14", url: "https://example.test/v0.4.14" }), + }) + + assert.equal(update, null) + // The check still counted — it succeeded, it just had nothing to report. + assert.equal(storage.data.get(LAST_CHECK_KEY), String(NOW)) +}) + +test("resolveUpdate: a broken store never throws at the caller", async () => { + const update = await resolveUpdate({ + storage: { + getItem: async () => { + throw new Error("keystore locked") + }, + setItem: async () => undefined, + }, + currentVersion: "0.4.10", + now: NOW, + fetchLatest: async () => ({ version: "0.4.14", url: "https://example.test/v0.4.14" }), + }) + + assert.equal(update, null) +}) + +test("resolveUpdate: ignoreDismissed still reports a dismissed version (Settings row)", async () => { + const storage = memoryStorage({ + [LAST_CHECK_KEY]: String(NOW - 1000), + [LATEST_KEY]: JSON.stringify({ version: "0.4.14", url: "https://example.test/v0.4.14" }), + [DISMISSED_KEY]: "0.4.14", + }) + const update = await resolveUpdate({ + storage, + currentVersion: "0.4.10", + now: NOW, + ignoreDismissed: true, + fetchLatest: async () => null, + }) + + assert.equal(update?.version, "0.4.14") +}) diff --git a/src/lib/update-check-policy.ts b/src/lib/update-check-policy.ts new file mode 100644 index 00000000..af384ec4 --- /dev/null +++ b/src/lib/update-check-policy.ts @@ -0,0 +1,196 @@ +/** + * When should the app tell the user a newer build exists? — pure decision logic. + * + * WHY THIS EXISTS (AGE-110) + * ------------------------- + * This app has no update mechanism of its own. It ships through four channels + * and only ONE of them auto-updates: + * + * Play Store auto-updates ... but only to whatever is on the + * production track (which lagged eight weeks, see + * .github/workflows/publish-play-store.yml) + * self-hosted F-Droid updates only if the user enabled auto-update AND has + * the repo added + * direct APK download never updates + * third-party mirrors never update (APKCombo currently advertises v0.4.10 + * as its newest listing) + * + * The measured result on 2026-08-14: 64% of 30d-active users sat on v0.4.10 and + * 0.2% on the newest build. A device on a direct-APK install has literally no + * way to learn a newer version exists — so any client-side fix (the AGE-105 + * Sentry noise gate, for one) is capped at the slice of the base that happens to + * update by luck. + * + * DESIGN RULES, so this never becomes an ad + * ----------------------------------------- + * 1. At most one network check per CHECK_INTERVAL_MS (24h). It is a single + * unauthenticated GET; it must not become a per-launch beacon. + * 2. A dismissal is remembered PER VERSION. Dismiss v0.4.15 and you are never + * asked about v0.4.15 again — but v0.4.16 may ask once. + * 3. Never prompt when the running version is unknown or unparseable, and + * never prompt for a version that is not strictly newer. A false "update + * available" is worse than silence. + * + * Everything here is pure so it runs under `node --test` with no React Native, + * no network and no clock. The effectful half lives in update-check.ts. + */ + +/** One check per day, max. */ +export const CHECK_INTERVAL_MS = 24 * 60 * 60 * 1000 + +/** + * Parse a dotted numeric version ("0.4.10", "v0.4.10", "0.4.10-rc.1") into + * comparable segments. Returns null for anything that is not a numeric-dotted + * version, which is the signal to stay quiet. + */ +export function parseVersion(raw: string | null | undefined): number[] | null { + if (typeof raw !== "string") return null + const trimmed = raw.trim().replace(/^v/i, "") + // Drop pre-release / build metadata: 0.4.10-rc.1+abc -> 0.4.10 + const core = trimmed.split(/[-+]/, 1)[0] + if (!/^\d+(\.\d+)*$/.test(core)) return null + const parts = core.split(".").map((n) => Number(n)) + if (parts.some((n) => !Number.isFinite(n))) return null + return parts +} + +/** + * -1 / 0 / 1, comparing segment-by-segment with numeric semantics. + * + * The whole point: a string compare puts "0.4.9" AFTER "0.4.10", which would + * have told the largest stale cohort in the install base that it was already + * up to date. + */ +export function compareVersions(a: string, b: string): number { + const pa = parseVersion(a) + const pb = parseVersion(b) + if (!pa || !pb) return 0 + const len = Math.max(pa.length, pb.length) + for (let i = 0; i < len; i++) { + const da = pa[i] ?? 0 + const db = pb[i] ?? 0 + if (da !== db) return da < db ? -1 : 1 + } + return 0 +} + +/** Is `latest` strictly newer than `current`? Unparseable input => false. */ +export function isNewer(latest: string | null | undefined, current: string | null | undefined): boolean { + if (!parseVersion(latest) || !parseVersion(current)) return false + return compareVersions(latest as string, current as string) > 0 +} + +/** Throttle: has enough time passed since the last completed check? */ +export function shouldCheck(input: { + lastCheckedAt: number | null + now: number + intervalMs?: number +}): boolean { + const interval = input.intervalMs ?? CHECK_INTERVAL_MS + if (input.lastCheckedAt === null || !Number.isFinite(input.lastCheckedAt)) return true + // A clock that moved backwards (timezone/NTP correction, or a restored + // backup) must not lock checks out until the future timestamp passes. + if (input.lastCheckedAt > input.now) return true + return input.now - input.lastCheckedAt >= interval +} + +/** Storage keys. Values are a timestamp and two version strings — no user data. */ +export const LAST_CHECK_KEY = "opencode_update_last_check" +export const DISMISSED_KEY = "opencode_update_dismissed_version" +export const LATEST_KEY = "opencode_update_latest" + +export type AvailableUpdate = { version: string; url: string } + +/** The two storage calls this needs, injected so the logic stays testable. */ +export type UpdateStorage = { + getItem: (key: string) => Promise + setItem: (key: string, value: string) => Promise +} + +/** + * Decide what to show, doing at most one network call per interval. + * + * The last successful lookup is cached so the banner survives between checks: + * without it a user who dismissed nothing would still only ever see the prompt + * on the one launch that happened to perform the fetch. + * + * Never throws. A failed fetch or an unreadable store degrades to "say nothing" + * (or to the cached answer), because a broken update check must not be visible + * to the user in any way. + */ +export async function resolveUpdate(deps: { + storage: UpdateStorage + fetchLatest: () => Promise + currentVersion: string + now?: number + force?: boolean + intervalMs?: number + /** + * Settings shows "0.4.10 -> 0.4.14" even after the banner was dismissed: + * "not now" means stop interrupting me, not lie to me when I go looking. + */ + ignoreDismissed?: boolean +}): Promise { + const now = deps.now ?? Date.now() + try { + const [rawLastCheck, dismissedVersion, rawCached] = await Promise.all([ + deps.storage.getItem(LAST_CHECK_KEY), + deps.storage.getItem(DISMISSED_KEY), + deps.storage.getItem(LATEST_KEY), + ]) + + let latest = parseCachedUpdate(rawCached) + const lastCheckedAt = rawLastCheck === null ? null : Number(rawLastCheck) + + if (deps.force || shouldCheck({ lastCheckedAt, now, intervalMs: deps.intervalMs })) { + const fetched = await deps.fetchLatest().catch(() => null) + if (fetched && parseVersion(fetched.version)) { + latest = fetched + // Only a SUCCESSFUL lookup resets the clock. A device that is offline + // for a week should check on its next launch with network, not be told + // it already checked. + await deps.storage.setItem(LAST_CHECK_KEY, String(now)) + await deps.storage.setItem(LATEST_KEY, JSON.stringify(fetched)) + } + } + + if (!latest) return null + if ( + !shouldPrompt({ + currentVersion: deps.currentVersion, + latestVersion: latest.version, + dismissedVersion: deps.ignoreDismissed ? null : dismissedVersion, + }) + ) + return null + return latest + } catch { + return null + } +} + +function parseCachedUpdate(raw: string | null): AvailableUpdate | null { + if (!raw) return null + try { + const parsed = JSON.parse(raw) as Partial + if (typeof parsed?.version !== "string" || typeof parsed?.url !== "string") return null + if (!parseVersion(parsed.version)) return null + return { version: parsed.version, url: parsed.url } + } catch { + return null + } +} + +/** Should the update affordance be shown right now? */ +export function shouldPrompt(input: { + currentVersion: string | null | undefined + latestVersion: string | null | undefined + dismissedVersion: string | null | undefined +}): boolean { + if (!isNewer(input.latestVersion, input.currentVersion)) return false + if (!input.dismissedVersion) return true + // Dismissal sticks for that version and anything older than it, so a user who + // said "not now" to v0.4.15 is not re-asked when a mirror briefly reports an + // older tag. + return compareVersions(input.latestVersion as string, input.dismissedVersion) > 0 +} diff --git a/src/lib/update-check.ts b/src/lib/update-check.ts new file mode 100644 index 00000000..70005a7c --- /dev/null +++ b/src/lib/update-check.ts @@ -0,0 +1,100 @@ +/** + * In-app update discovery (AGE-110) — the device half. + * + * Runtime wiring only: AsyncStorage, the GitHub releases API, the running app + * version. All decisions live in update-check-policy.ts so they are testable + * under `node --test`; see that file for why this exists at all. + * + * WHY GITHUB RELEASES AND NOT `expo-updates` + * ------------------------------------------ + * expo-updates ships JS over the air, which cannot replace a native binary and + * would not have fixed the cohort this targets (they need a new APK). The + * GitHub releases API is the one source that is correct for every non-Play + * channel at once: the direct APK, the self-hosted F-Droid repo and the + * third-party mirrors are all downstream of a GitHub release. + * + * WHY ANDROID ONLY + * ---------------- + * iOS installs come from TestFlight/App Store, which already handle updates and + * where pointing a user at a GitHub download is nonsense (and against review + * guidelines). + * + * PRIVACY + * ------- + * One unauthenticated GET per 24h to api.github.com, no query params, no ids, + * no analytics. GitHub sees an IP that already downloaded the APK from GitHub. + * The only thing stored on device is a timestamp and two version strings. + */ + +import { Platform } from "react-native" +import AsyncStorage from "@react-native-async-storage/async-storage" +import appJson from "../../app.json" +import { + DISMISSED_KEY, + resolveUpdate, + type AvailableUpdate, + type UpdateStorage, +} from "./update-check-policy" + +export type { AvailableUpdate } + +/** Same source Sentry uses for `release`, so the two always agree. */ +export const CURRENT_VERSION = (appJson as { expo?: { version?: string } }).expo?.version ?? "unknown" + +const RELEASES_API = "https://api.github.com/repos/dzianisv/opencode-mobile/releases/latest" +const RELEASES_PAGE = "https://github.com/dzianisv/opencode-mobile/releases/latest" +const TIMEOUT_MS = 8000 + +const storage: UpdateStorage = { + getItem: (key) => AsyncStorage.getItem(key), + setItem: (key, value) => AsyncStorage.setItem(key, value), +} + +async function fetchLatestRelease(): Promise { + const controller = new AbortController() + const timer = setTimeout(() => controller.abort(), TIMEOUT_MS) + try { + const response = await fetch(RELEASES_API, { + headers: { Accept: "application/vnd.github+json" }, + signal: controller.signal, + }) + if (!response.ok) return null + const body = (await response.json()) as { tag_name?: string; html_url?: string; draft?: boolean; prerelease?: boolean } + if (body?.draft || body?.prerelease) return null + const tag = typeof body?.tag_name === "string" ? body.tag_name.replace(/^v/i, "") : null + if (!tag) return null + return { version: tag, url: typeof body?.html_url === "string" ? body.html_url : RELEASES_PAGE } + } catch { + return null + } finally { + clearTimeout(timer) + } +} + +/** + * Returns the update the user should be told about, or null for "stay quiet". + * Safe to call on every foreground: the 24h throttle lives in the policy. + */ +export async function checkForUpdate(options?: { + force?: boolean + /** Settings passes true: a dismissal silences the banner, not the About row. */ + ignoreDismissed?: boolean +}): Promise { + if (Platform.OS !== "android") return null + return resolveUpdate({ + storage, + fetchLatest: fetchLatestRelease, + currentVersion: CURRENT_VERSION, + force: options?.force, + ignoreDismissed: options?.ignoreDismissed, + }) +} + +/** "Not now" — remembered for this version only, never re-asked for it. */ +export async function dismissUpdate(version: string): Promise { + try { + await AsyncStorage.setItem(DISMISSED_KEY, version) + } catch { + // A dismissal we could not persist costs one extra banner, nothing more. + } +}