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:
@@ -27,6 +27,7 @@ import type BottomSheet from "@gorhom/bottom-sheet"
|
|||||||
import type { Session, Project } from "../../src/lib/sdk"
|
import type { Session, Project } from "../../src/lib/sdk"
|
||||||
import { DirectorySwitcher, DirectoryBrowserSheet } from "../../src/components/chat"
|
import { DirectorySwitcher, DirectoryBrowserSheet } from "../../src/components/chat"
|
||||||
import { groupByDirectory } from "../../src/lib/session-grouping"
|
import { groupByDirectory } from "../../src/lib/session-grouping"
|
||||||
|
import { UpdateBanner } from "../../src/components/UpdateBanner"
|
||||||
import { nameOf } from "../../src/lib/path-utils"
|
import { nameOf } from "../../src/lib/path-utils"
|
||||||
import { SETUP_GUIDE_URL } from "../../src/lib/links"
|
import { SETUP_GUIDE_URL } from "../../src/lib/links"
|
||||||
|
|
||||||
@@ -548,6 +549,8 @@ export default function SessionsScreen() {
|
|||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
<UpdateBanner isDark={isDark} />
|
||||||
|
|
||||||
<FlatList
|
<FlatList
|
||||||
data={rows}
|
data={rows}
|
||||||
keyExtractor={(row) => (row.type === "header" ? `dir:${row.directory}` : row.session.id)}
|
keyExtractor={(row) => (row.type === "header" ? `dir:${row.directory}` : row.session.id)}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useCallback, useState } from "react"
|
import { useCallback, useEffect, useState } from "react"
|
||||||
import {
|
import {
|
||||||
View,
|
View,
|
||||||
Text,
|
Text,
|
||||||
@@ -23,6 +23,7 @@ import {
|
|||||||
import type { Category } from "../../src/lib/notifications"
|
import type { Category } from "../../src/lib/notifications"
|
||||||
import { hasTelemetryConsent, setTelemetryConsent } from "../../src/lib/telemetry"
|
import { hasTelemetryConsent, setTelemetryConsent } from "../../src/lib/telemetry"
|
||||||
import { PRIVACY_POLICY_URL } from "../../src/lib/links"
|
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"
|
import type { LocalePreference } from "../../src/lib/i18n/locale-resolve"
|
||||||
|
|
||||||
function SettingRow({
|
function SettingRow({
|
||||||
@@ -79,6 +80,23 @@ export default function SettingsScreen() {
|
|||||||
const [osGranted, setOsGranted] = useState<boolean | null>(null)
|
const [osGranted, setOsGranted] = useState<boolean | null>(null)
|
||||||
const [telemetryUpdating, setTelemetryUpdating] = useState(false)
|
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.
|
// Telemetry consent: hasTelemetryConsent() returns null (unknown), true, or false.
|
||||||
// We initialise local state from in-memory value; updates call setTelemetryConsent().
|
// We initialise local state from in-memory value; updates call setTelemetryConsent().
|
||||||
const [crashReporting, setCrashReporting] = useState<boolean>(hasTelemetryConsent() ?? false)
|
const [crashReporting, setCrashReporting] = useState<boolean>(hasTelemetryConsent() ?? false)
|
||||||
@@ -247,7 +265,25 @@ export default function SettingsScreen() {
|
|||||||
onPress={handleLanguagePress}
|
onPress={handleLanguagePress}
|
||||||
right={<Ionicons name="chevron-forward" size={20} color={isDark ? "#666666" : "#999999"} />}
|
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
|
<SettingRow
|
||||||
icon="logo-github"
|
icon="logo-github"
|
||||||
label={t("settings.about.github.label")}
|
label={t("settings.about.github.label")}
|
||||||
|
|||||||
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",
|
"connectButton": "Connect your own server",
|
||||||
"setupGuideLink": "How to set up a server",
|
"setupGuideLink": "How to set up a server",
|
||||||
"hostedCtaLink": "No server? Join the OpenCode Connect waitlist — hosted, no setup"
|
"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": "连接您自己的服务器",
|
"connectButton": "连接您自己的服务器",
|
||||||
"setupGuideLink": "如何设置服务器",
|
"setupGuideLink": "如何设置服务器",
|
||||||
"hostedCtaLink": "没有服务器?加入 OpenCode Connect 候补名单 — 托管,无需搭建"
|
"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