feat(update): tell sideloaded installs that a newer version exists (#179)

AGE-110: 64% of 30d-active users sit on v0.4.10 and 0.2% on the newest
build, so a client-side fix (the AGE-105 Sentry noise gate) reaches almost
nobody. Play hygiene fixes one channel; the direct APK, the self-hosted
F-Droid repo and third-party mirrors have no update mechanism at all — a
device installed from a downloaded APK has literally no way to learn a newer
version exists.

- src/lib/update-check-policy.ts: pure, node --test'able decision logic —
  numeric version compare (a string compare puts 0.4.10 BEFORE 0.4.9, i.e.
  it would have told the largest stale cohort it was current), a 24h check
  throttle that survives a backwards clock, per-version dismissal, and a
  cached last-known-latest so the affordance survives between checks
- src/lib/update-check.ts: Android-only runtime wiring. One unauthenticated
  GET per 24h to the GitHub releases API (every non-Play channel is
  downstream of a GitHub release; expo-updates cannot replace a native
  binary, which is what this cohort needs). Never throws.
- UpdateBanner on the sessions list: one dismissible strip, no modal.
  "Not now" sticks for that version only.
- Settings "Version" row showed a hard-coded "1.0.0" for every build ever
  shipped. It now shows the real version, plus "0.4.10 -> 0.4.14" when an
  update exists (ignoreDismissed: dismissal silences the banner, not the
  place a user goes to check).
- en/zh-Hans strings, catalog parity kept.

Tests: 19 new cases in update-check-policy.test.ts; full suite 300 pass,
tsc --noEmit clean.

Co-authored-by: engineer <engineer@macbookpro.lan>
Co-authored-by: Paperclip <noreply@paperclip.ing>
This commit is contained in:
Den
2026-08-14 09:45:54 -07:00
committed by GitHub
parent dde4553016
commit 54ddc64c74
8 changed files with 724 additions and 2 deletions

View File

@@ -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() {
</View>
)}
<UpdateBanner isDark={isDark} />
<FlatList
data={rows}
keyExtractor={(row) => (row.type === "header" ? `dir:${row.directory}` : row.session.id)}

View File

@@ -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<boolean | null>(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<AvailableUpdate | null>(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<boolean>(hasTelemetryConsent() ?? false)
@@ -247,7 +265,25 @@ export default function SettingsScreen() {
onPress={handleLanguagePress}
right={<Ionicons name="chevron-forward" size={20} color={isDark ? "#666666" : "#999999"} />}
/>
<SettingRow icon="information-circle" label={t("settings.about.version")} description="1.0.0" isDark={isDark} />
<SettingRow
icon="information-circle"
label={t("settings.about.version")}
// Was hard-coded "1.0.0" — wrong for every build ever shipped, and the
// one place a user could have checked what they are running while 64%
// of the base sat on a four-week-old build (AGE-110).
description={
updateAvailable
? `${CURRENT_VERSION} → ${updateAvailable.version}`
: `${CURRENT_VERSION} · ${t("update.upToDate")}`
}
isDark={isDark}
onPress={updateAvailable ? () => Linking.openURL(updateAvailable.url) : undefined}
right={
updateAvailable ? (
<Ionicons name="arrow-up-circle" size={20} color={isDark ? "#7dd3fc" : "#0369a1"} />
) : undefined
}
/>
<SettingRow
icon="logo-github"
label={t("settings.about.github.label")}

View File

@@ -0,0 +1,107 @@
/**
* "A newer version exists" — the only way a sideloaded install ever learns that.
*
* Deliberately not a modal: a modal on launch is the fastest way to get an app
* uninstalled, and this must be safe to ship to the whole install base. It is a
* single dismissible strip above the session list, with a "Not now" that sticks
* for that version (see update-check-policy.ts).
*
* Renders nothing at all when there is no update, when the check failed, on
* iOS, or once dismissed — so the common case costs one hook and no pixels.
*/
import { useCallback, useEffect, useState } from "react"
import { View, Text, TouchableOpacity, StyleSheet, Linking } from "react-native"
import { Ionicons } from "@expo/vector-icons"
import { useTranslation } from "react-i18next"
import { checkForUpdate, dismissUpdate, CURRENT_VERSION, type AvailableUpdate } from "../lib/update-check"
export function UpdateBanner({ isDark }: { isDark: boolean }) {
const { t } = useTranslation()
const [update, setUpdate] = useState<AvailableUpdate | null>(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 (
<View style={[styles.banner, isDark && styles.bannerDark]} testID="update-banner">
<Ionicons name="arrow-up-circle" size={20} color={isDark ? "#7dd3fc" : "#0369a1"} />
<View style={styles.text}>
<Text style={[styles.title, isDark && styles.titleDark]}>{t("update.available")}</Text>
<Text style={[styles.body, isDark && styles.bodyDark]} numberOfLines={1}>
{t("update.body", { version: update.version, current: CURRENT_VERSION })}
</Text>
</View>
<TouchableOpacity onPress={onOpen} testID="update-banner-open" hitSlop={8}>
<Text style={[styles.action, isDark && styles.actionDark]}>{t("update.action")}</Text>
</TouchableOpacity>
<TouchableOpacity onPress={onDismiss} testID="update-banner-dismiss" hitSlop={8}>
<Ionicons name="close" size={18} color={isDark ? "#94a3b8" : "#64748b"} />
</TouchableOpacity>
</View>
)
}
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",
},
})

View File

@@ -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"
}
}

View File

@@ -444,5 +444,12 @@
"connectButton": "连接您自己的服务器",
"setupGuideLink": "如何设置服务器",
"hostedCtaLink": "没有服务器?加入 OpenCode Connect 候补名单 — 托管,无需搭建"
},
"update": {
"available": "有可用更新",
"body": "版本 {{version}} 已发布,你当前使用的是 {{current}}。",
"action": "去获取",
"dismiss": "暂不",
"upToDate": "已是最新"
}
}

View File

@@ -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<string, string> = {}) {
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")
})

View File

@@ -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<string | null>
setItem: (key: string, value: string) => Promise<void>
}
/**
* 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<AvailableUpdate | null>
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<AvailableUpdate | null> {
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<AvailableUpdate>
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
}

100
src/lib/update-check.ts Normal file
View File

@@ -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<AvailableUpdate | null> {
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<AvailableUpdate | null> {
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<void> {
try {
await AsyncStorage.setItem(DISMISSED_KEY, version)
} catch {
// A dismissal we could not persist costs one extra banner, nothing more.
}
}