From 6cda79c1b586742423ec1d362d7a6604590a2661 Mon Sep 17 00:00:00 2001 From: kjeldahl <19719+kjeldahl@users.noreply.github.com> Date: Thu, 12 Nov 2026 10:03:54 +0000 Subject: [PATCH] feat(settings): tap the version row to check for updates now The throttled check answers at most once per 24h, so a release published after the app's last successful check stays invisible until the throttle expires. The version row is now a button: it bypasses the throttle, and says which answer it got (new version / up to date / could not reach GitHub) instead of leaving the two silences indistinguishable. --- app/(tabs)/settings.tsx | 61 ++++++++++++++++++++++++----- src/lib/i18n/en.json | 5 ++- src/lib/i18n/zh-Hans.json | 5 ++- src/lib/update-check-policy.test.ts | 18 +++++++++ src/lib/update-check-policy.ts | 15 +++++++ src/lib/update-check.ts | 32 ++++++++++++++- 6 files changed, 123 insertions(+), 13 deletions(-) diff --git a/app/(tabs)/settings.tsx b/app/(tabs)/settings.tsx index b728c2223..b7a681fbf 100644 --- a/app/(tabs)/settings.tsx +++ b/app/(tabs)/settings.tsx @@ -9,6 +9,8 @@ import { useColorScheme, Linking, Alert, + ActivityIndicator, + Platform, } from "react-native" import { Ionicons } from "@expo/vector-icons" import { useTranslation } from "react-i18next" @@ -21,10 +23,16 @@ import { granted as notificationsGranted, } from "../../src/lib/notifications" import type { Category } from "../../src/lib/notifications" -import { CURRENT_VERSION, checkForUpdate, type AvailableUpdate } from "../../src/lib/update-check" +import { CURRENT_VERSION, checkForUpdate, checkForUpdateNow, type AvailableUpdate } from "../../src/lib/update-check" import { BuildStampLinks } from "../../src/lib/build-info" import type { LocalePreference } from "../../src/lib/i18n/locale-resolve" + +// The update check only means something where we ship the binary ourselves. +// TestFlight/App Store handle their own updates, and pointing an iOS user at a +// GitHub APK is worse than saying nothing, so the row is not a button there. +const updatesCheckable = Platform.OS === "android" + function SettingRow({ icon, label, @@ -95,6 +103,23 @@ export default function SettingsScreen() { } }, []) + // Tapping the version row skips the 24h throttle. The answer is shown in the + // row itself, including "couldn't reach GitHub", so a check that could not + // run never reads as "you already have the latest". + const [checking, setChecking] = useState(false) + const [checkUnreachable, setCheckUnreachable] = useState(false) + + const checkNow = useCallback(async () => { + if (checking || !updatesCheckable) return + setChecking(true) + setCheckUnreachable(false) + const result = await checkForUpdateNow() + if (result.status === "update") setUpdateAvailable(result.update) + if (result.status === "latest") setUpdateAvailable(null) + setCheckUnreachable(result.status === "unreachable") + setChecking(false) + }, [checking]) + // Check OS permission state on first toggle attempt const handleToggle = useCallback( async (category: Category, enabled: boolean) => { @@ -234,7 +259,15 @@ export default function SettingsScreen() { onPress={handleLanguagePress} right={} /> - + void checkNow()} + disabled={checking || !updatesCheckable} + activeOpacity={0.6} + accessibilityRole="button" + accessibilityLabel={t("update.checkNowAccessibility")} + testID="settings-check-update" + > @@ -244,18 +277,26 @@ export default function SettingsScreen() { additionally stamp the PR# and commit (build-info.json) so a side-loaded CI APK says which pull request it came from. */} - {updateAvailable - ? `${CURRENT_VERSION} → ${updateAvailable.version}` - : `${CURRENT_VERSION} · ${t("update.upToDate")}`} + {checking + ? t("update.checking") + : checkUnreachable + ? t("update.checkFailed") + : updateAvailable + ? `${CURRENT_VERSION} → ${updateAvailable.version}` + : `${CURRENT_VERSION} · ${t("update.upToDate")}`} - {updateAvailable && ( - Linking.openURL(updateAvailable.url)}> - - + {checking ? ( + + ) : ( + updateAvailable && ( + Linking.openURL(updateAvailable.url)} hitSlop={8}> + + + ) )} - + { @@ -264,3 +265,20 @@ test("resolveUpdate: ignoreDismissed still reports a dismissed version (Settings assert.equal(update?.version, "0.4.14") }) + +// --- an explicit check the user asked for ------------------------------------ + +test("classifyCheck: nothing fetched is unreachable, not 'latest'", () => { + assert.deepEqual(classifyCheck(null, "0.9.0"), { status: "unreachable" }) +}) + +test("classifyCheck: a strictly newer release is an update", () => { + const latest = { version: "0.10.0", url: "https://github.com/kjeldahl/opencode-mobile/releases/tag/v0.10.0" } + assert.deepEqual(classifyCheck(latest, "0.9.0"), { status: "update", update: latest }) +}) + +test("classifyCheck: same or older running version says you are up to date", () => { + const same = { version: "0.10.0", url: "u" } + assert.equal(classifyCheck(same, "0.10.0").status, "latest") + assert.equal(classifyCheck({ version: "0.9.1", url: "u" }, "0.10.0").status, "latest") +}) diff --git a/src/lib/update-check-policy.ts b/src/lib/update-check-policy.ts index af384ec49..a9273177a 100644 --- a/src/lib/update-check-policy.ts +++ b/src/lib/update-check-policy.ts @@ -101,6 +101,21 @@ export const LATEST_KEY = "opencode_update_latest" export type AvailableUpdate = { version: string; url: string } +/** What an explicit "check for updates now" found. */ +export type UpdateCheckResult = { status: "update"; update: AvailableUpdate } | { status: "latest" } | { status: "unreachable" } + +/** + * Classify the outcome of a check the user asked for. + * + * A throttled check may stay silent on failure; a requested one may not, + * because to the user "I could not look" and "there is nothing newer" are the + * same blank face. This keeps them apart. + */ +export function classifyCheck(latest: AvailableUpdate | null, currentVersion: string): UpdateCheckResult { + if (!latest) return { status: "unreachable" } + return isNewer(latest.version, currentVersion) ? { status: "update", update: latest } : { status: "latest" } +} + /** The two storage calls this needs, injected so the logic stays testable. */ export type UpdateStorage = { getItem: (key: string) => Promise diff --git a/src/lib/update-check.ts b/src/lib/update-check.ts index ca32b62c8..57e07dbcc 100644 --- a/src/lib/update-check.ts +++ b/src/lib/update-check.ts @@ -31,12 +31,16 @@ import AsyncStorage from "@react-native-async-storage/async-storage" import appJson from "../../app.json" import { DISMISSED_KEY, + LAST_CHECK_KEY, + LATEST_KEY, + classifyCheck, resolveUpdate, type AvailableUpdate, + type UpdateCheckResult, type UpdateStorage, } from "./update-check-policy" -export type { AvailableUpdate } +export type { AvailableUpdate, UpdateCheckResult } /** The version in app.json; release tags (vX.Y.Z) must match it. */ export const CURRENT_VERSION = (appJson as { expo?: { version?: string } }).expo?.version ?? "unknown" @@ -98,3 +102,29 @@ export async function dismissUpdate(version: string): Promise { // A dismissal we could not persist costs one extra banner, nothing more. } } + +/** + * The explicit "check for updates" path a user can ask for. + * + * The throttled checks deliberately say nothing when they fail, because a + * silent update check must not look like an error. Once the user *asks* for a + * check that changes: silence is indistinguishable from "you have the latest", + * so this reports which of the three answers it got instead of returning null + * for all of them. + */ +export async function checkForUpdateNow(): Promise { + if (Platform.OS !== "android") return { status: "unreachable" } + + const fetched = await fetchLatestRelease() + if (!fetched) return { status: "unreachable" } + + // Persist exactly what the throttled check would have stored, so the banner + // and this row agree afterwards and the 24h clock starts from here. + const now = String(Date.now()) + await Promise.all([ + storage.setItem(LAST_CHECK_KEY, now).catch(() => undefined), + storage.setItem(LATEST_KEY, JSON.stringify(fetched)).catch(() => undefined), + ]) + + return classifyCheck(fetched, CURRENT_VERSION) +}