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