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:
107
src/components/UpdateBanner.tsx
Normal file
107
src/components/UpdateBanner.tsx
Normal 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",
|
||||
},
|
||||
})
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -444,5 +444,12 @@
|
||||
"connectButton": "连接您自己的服务器",
|
||||
"setupGuideLink": "如何设置服务器",
|
||||
"hostedCtaLink": "没有服务器?加入 OpenCode Connect 候补名单 — 托管,无需搭建"
|
||||
},
|
||||
"update": {
|
||||
"available": "有可用更新",
|
||||
"body": "版本 {{version}} 已发布,你当前使用的是 {{current}}。",
|
||||
"action": "去获取",
|
||||
"dismiss": "暂不",
|
||||
"upToDate": "已是最新"
|
||||
}
|
||||
}
|
||||
|
||||
266
src/lib/update-check-policy.test.ts
Normal file
266
src/lib/update-check-policy.test.ts
Normal 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")
|
||||
})
|
||||
196
src/lib/update-check-policy.ts
Normal file
196
src/lib/update-check-policy.ts
Normal 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
100
src/lib/update-check.ts
Normal 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.
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user