diff --git a/apps/mobile/src/features/settings/SettingsRouteScreen.tsx b/apps/mobile/src/features/settings/SettingsRouteScreen.tsx index 4c33675f57f..ab167d50550 100644 --- a/apps/mobile/src/features/settings/SettingsRouteScreen.tsx +++ b/apps/mobile/src/features/settings/SettingsRouteScreen.tsx @@ -8,11 +8,20 @@ import { NativeStackScreenOptions } from "../../native/StackHeader"; import { SymbolView } from "../../components/AppSymbol"; import * as Effect from "effect/Effect"; import { AsyncResult } from "effect/unstable/reactivity"; -import { useCallback, useEffect, useMemo, useState, useSyncExternalStore } from "react"; -import { Alert, Linking, Platform, ScrollView, View } from "react-native"; +import { useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore } from "react"; +import { + ActivityIndicator, + Alert, + Linking, + Platform, + Pressable, + ScrollView, + View, +} from "react-native"; import { useSafeAreaInsets } from "react-native-safe-area-context"; import { + type AtomCommandResult, isAtomCommandInterrupted, reportAtomCommandResult, settleAsyncResult, @@ -570,8 +579,12 @@ function BetaSettingsSection() { ); } +type UpdateCheckState = "idle" | "checking" | "downloading" | "restarting" | "current"; + function AppSettingsSection() { const icon = useThemeColor("--color-icon"); + const [updateState, setUpdateState] = useState("idle"); + const updateInFlight = useRef(false); const version = Constants.expoConfig?.version ?? "0.0.0"; // Fall back to "production" to match resolveAppVariant in app.config.ts, so a @@ -590,30 +603,140 @@ function AppSettingsSection() { : null : null; + const busy = + updateState === "checking" || updateState === "downloading" || updateState === "restarting"; + + // "Up to date" is a transient acknowledgement, not a state worth persisting — + // drop back to the bundle label so the row keeps answering "what am I running?". + useEffect(() => { + if (updateState !== "current") return; + const timer = setTimeout(() => setUpdateState("idle"), 3000); + return () => clearTimeout(timer); + }, [updateState]); + + const checkForUpdate = useCallback(async () => { + // `disabled={busy}` only takes effect on the next render, so two taps in the + // same frame would both get through. The ref closes that window. + if (updateInFlight.current) return; + updateInFlight.current = true; + try { + await runUpdateCheck(setUpdateState); + } finally { + updateInFlight.current = false; + } + }, []); + + const statusLabel = + updateState === "checking" + ? "Checking…" + : updateState === "downloading" + ? "Downloading…" + : updateState === "restarting" + ? "Restarting…" + : updateState === "current" + ? "Up to date" + : bundleLabel; + + const versionRow = ( + + + Version + + {versionLabel} + {statusLabel ? ( + {statusLabel} + ) : null} + + {Updates.isEnabled ? ( + + {busy ? ( + + ) : ( + + )} + + ) : null} + + ); + return ( - - - Version - - {versionLabel} - {bundleLabel ? ( - {bundleLabel} - ) : null} - - + {Updates.isEnabled ? ( + void checkForUpdate()} + > + {versionRow} + + ) : ( + versionRow + )} ); } +async function runUpdateCheck(setUpdateState: (state: UpdateCheckState) => void): Promise { + setUpdateState("checking"); + const check = await settlePromise(() => Updates.checkForUpdateAsync()); + if (check._tag === "Failure") { + reportUpdateFailure(check, "Could not check for updates."); + setUpdateState("idle"); + return; + } + // A rollback directive (`eas update:rollback`) arrives as isAvailable: false + // with isRollBackToEmbedded: true — there is nothing newer to install, but the + // running OTA still has to be dropped for the embedded bundle. + if (!check.value.isAvailable && !check.value.isRollBackToEmbedded) { + setUpdateState("current"); + return; + } + + setUpdateState("downloading"); + const fetched = await settlePromise(() => Updates.fetchUpdateAsync()); + if (fetched._tag === "Failure") { + reportUpdateFailure(fetched, "Could not download the update."); + setUpdateState("idle"); + return; + } + // isNew is always false for a rollback, so it can't be the sole gate here either. + if (!fetched.value.isNew && !fetched.value.isRollBackToEmbedded) { + setUpdateState("current"); + return; + } + + setUpdateState("restarting"); + // reloadAsync never resolves on success — the JS context is torn down — so + // reaching the failure branch below is the only way this returns. + const reloaded = await settlePromise(() => Updates.reloadAsync()); + if (reloaded._tag === "Failure") { + reportUpdateFailure(reloaded, "Downloaded, but could not restart the app."); + setUpdateState("idle"); + } +} + +function reportUpdateFailure(result: AtomCommandResult, fallback: string): void { + reportAtomCommandResult(result, { label: "app update check" }); + if (result._tag !== "Failure" || isAtomCommandInterrupted(result)) return; + const error = squashAtomCommandFailure(result); + Alert.alert("Update failed", error instanceof Error ? error.message : fallback); +} + function capitalize(value: string): string { return value.length > 0 ? value.charAt(0).toUpperCase() + value.slice(1) : value; }