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.
+ }
+}