From 0e88bb418af2476eb4d9ff3739430c69b31c51d4 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Tue, 28 Jul 2026 00:40:30 +0200 Subject: [PATCH 1/2] feat(mobile): add OTA update check to settings - Let users check, download, and apply OTA updates from the Version row - Show update progress, current status, and failure alerts --- .../features/settings/SettingsRouteScreen.tsx | 140 +++++++++++++++--- 1 file changed, 123 insertions(+), 17 deletions(-) diff --git a/apps/mobile/src/features/settings/SettingsRouteScreen.tsx b/apps/mobile/src/features/settings/SettingsRouteScreen.tsx index 4c33675f57f..35df1cdbcd5 100644 --- a/apps/mobile/src/features/settings/SettingsRouteScreen.tsx +++ b/apps/mobile/src/features/settings/SettingsRouteScreen.tsx @@ -9,10 +9,19 @@ 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 { + 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,11 @@ function BetaSettingsSection() { ); } +type UpdateCheckState = "idle" | "checking" | "downloading" | "restarting" | "current"; + function AppSettingsSection() { const icon = useThemeColor("--color-icon"); + const [updateState, setUpdateState] = useState("idle"); const version = Constants.expoConfig?.version ?? "0.0.0"; // Fall back to "production" to match resolveAppVariant in app.config.ts, so a @@ -590,30 +602,124 @@ 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 () => { + setUpdateState("checking"); + const check = await settlePromise(() => Updates.checkForUpdateAsync()); + if (check._tag === "Failure") { + reportUpdateFailure(check, "Could not check for updates."); + setUpdateState("idle"); + return; + } + if (!check.value.isAvailable) { + 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; + } + if (!fetched.value.isNew) { + 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"); + } + }, []); + + 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 + )} ); } +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; } From 2a43228041a3dde19ab8fe0a714f1eb3a7bfd608 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Tue, 28 Jul 2026 00:47:02 +0200 Subject: [PATCH 2/2] fix(mobile): handle OTA rollbacks and duplicate update taps - Prevent concurrent update checks before the disabled state re-renders - Download and restart when Expo reports a rollback directive --- .../features/settings/SettingsRouteScreen.tsx | 81 +++++++++++-------- 1 file changed, 49 insertions(+), 32 deletions(-) diff --git a/apps/mobile/src/features/settings/SettingsRouteScreen.tsx b/apps/mobile/src/features/settings/SettingsRouteScreen.tsx index 35df1cdbcd5..ab167d50550 100644 --- a/apps/mobile/src/features/settings/SettingsRouteScreen.tsx +++ b/apps/mobile/src/features/settings/SettingsRouteScreen.tsx @@ -8,7 +8,7 @@ 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 { useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore } from "react"; import { ActivityIndicator, Alert, @@ -584,6 +584,7 @@ type UpdateCheckState = "idle" | "checking" | "downloading" | "restarting" | "cu 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 @@ -614,37 +615,14 @@ function AppSettingsSection() { }, [updateState]); const checkForUpdate = useCallback(async () => { - setUpdateState("checking"); - const check = await settlePromise(() => Updates.checkForUpdateAsync()); - if (check._tag === "Failure") { - reportUpdateFailure(check, "Could not check for updates."); - setUpdateState("idle"); - return; - } - if (!check.value.isAvailable) { - 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; - } - if (!fetched.value.isNew) { - 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"); + // `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; } }, []); @@ -713,6 +691,45 @@ function AppSettingsSection() { ); } +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;