Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
61 changes: 51 additions & 10 deletions app/(tabs)/settings.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ import {
useColorScheme,
Linking,
Alert,
ActivityIndicator,
Platform,
} from "react-native"
import { Ionicons } from "@expo/vector-icons"
import { useTranslation } from "react-i18next"
Expand All @@ -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,
Expand Down Expand Up @@ -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) => {
Expand Down Expand Up @@ -234,7 +259,15 @@ export default function SettingsScreen() {
onPress={handleLanguagePress}
right={<Ionicons name="chevron-forward" size={20} color={isDark ? "#666666" : "#999999"} />}
/>
<View style={styles.settingRow}>
<TouchableOpacity
style={styles.settingRow}
onPress={() => void checkNow()}
disabled={checking || !updatesCheckable}
activeOpacity={0.6}
accessibilityRole="button"
accessibilityLabel={t("update.checkNowAccessibility")}
testID="settings-check-update"
>
<View style={[styles.settingIcon, isDark && styles.settingIconDark]}>
<Ionicons name="information-circle" size={22} color={isDark ? "#ffffff" : "#0a0a0a"} />
</View>
Expand All @@ -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. */}
<Text style={[styles.settingDescription, isDark && styles.metaDark]}>
{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")}`}
</Text>
<BuildStampLinks style={styles.settingDescription} />
</View>
{updateAvailable && (
<TouchableOpacity onPress={() => Linking.openURL(updateAvailable.url)}>
<Ionicons name="arrow-up-circle" size={20} color={isDark ? "#7dd3fc" : "#0369a1"} />
</TouchableOpacity>
{checking ? (
<ActivityIndicator size="small" color={isDark ? "#7dd3fc" : "#0369a1"} />
) : (
updateAvailable && (
<TouchableOpacity onPress={() => Linking.openURL(updateAvailable.url)} hitSlop={8}>
<Ionicons name="arrow-up-circle" size={20} color={isDark ? "#7dd3fc" : "#0369a1"} />
</TouchableOpacity>
)
)}
</View>
</TouchableOpacity>
<SettingRow
icon="logo-github"
label={t("settings.about.github.label")}
Expand Down
5 changes: 4 additions & 1 deletion src/lib/i18n/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -489,7 +489,10 @@
"body": "Version {{version}} is out. You're on {{current}}.",
"action": "Get it",
"dismiss": "Not now",
"upToDate": "Up to date"
"upToDate": "Up to date",
"checking": "Checking for updates…",
"checkFailed": "Couldn't reach GitHub — try again later",
"checkNowAccessibility": "Check for updates now"
},
"changes": {
"title": "Changes",
Expand Down
5 changes: 4 additions & 1 deletion src/lib/i18n/zh-Hans.json
Original file line number Diff line number Diff line change
Expand Up @@ -489,7 +489,10 @@
"body": "版本 {{version}} 已发布,你当前使用的是 {{current}}。",
"action": "去获取",
"dismiss": "暂不",
"upToDate": "已是最新"
"upToDate": "已是最新",
"checking": "正在检查更新…",
"checkFailed": "无法连接到 GitHub — 请稍后再试",
"checkNowAccessibility": "立即检查更新"
},
"changes": {
"title": "更改",
Expand Down
18 changes: 18 additions & 0 deletions src/lib/update-check-policy.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import {
LAST_CHECK_KEY,
LATEST_KEY,
DISMISSED_KEY,
classifyCheck,
} from "./update-check-policy.ts"

test("parseVersion accepts the shapes our releases actually use", () => {
Expand Down Expand Up @@ -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")
})
15 changes: 15 additions & 0 deletions src/lib/update-check-policy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string | null>
Expand Down
32 changes: 31 additions & 1 deletion src/lib/update-check.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -98,3 +102,29 @@ export async function dismissUpdate(version: string): Promise<void> {
// 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<UpdateCheckResult> {
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)
}
Loading