Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 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
6 changes: 6 additions & 0 deletions apps/mobile/src/features/home/HomeRouteScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import { useSavedRemoteConnections } from "../../state/use-remote-environment-re
import { useAdaptiveWorkspaceLayout } from "../layout/AdaptiveWorkspaceLayout";
import { WorkspaceEmptyDetail } from "../layout/WorkspaceEmptyDetail";
import { WorkspaceSidebarToolbar } from "../layout/workspace-sidebar-toolbar";
import { checkForAppUpdateOnLaunch } from "../updates/app-updates";
import { AndroidHomeFabLayout } from "./AndroidHomeFab";
import { HomeScreen } from "./HomeScreen";
import { HomeHeader } from "./HomeHeader";
Expand All @@ -29,6 +30,11 @@ export function HomeRouteScreen() {
const { savedConnectionsById } = useSavedRemoteConnections();
const navigation = useNavigation();
const [searchQuery, setSearchQuery] = useState("");

useEffect(() => {
void checkForAppUpdateOnLaunch();
}, []);

const { archiveThread, confirmDeleteThread, settleThread, unsettleThread } =
useThreadListActions();
const pendingTasks = usePendingNewTasks();
Expand Down
117 changes: 26 additions & 91 deletions apps/mobile/src/features/settings/SettingsRouteScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,19 +9,10 @@ import { SymbolView } from "../../components/AppSymbol";
import * as Effect from "effect/Effect";
import { AsyncResult } from "effect/unstable/reactivity";
import { useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore } from "react";
import {
ActivityIndicator,
Alert,
Linking,
Platform,
Pressable,
ScrollView,
View,
} from "react-native";
import { Alert, Linking, Platform, Pressable, ScrollView, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";

import {
type AtomCommandResult,
isAtomCommandInterrupted,
reportAtomCommandResult,
settleAsyncResult,
Expand All @@ -47,6 +38,11 @@ import { runtime } from "../../lib/runtime";
import { useThemeColor } from "../../lib/useThemeColor";
import { mobilePreferencesAtom, updateMobilePreferencesAtom } from "../../state/preferences";
import { useThreadListV2Enabled } from "../threads/use-thread-list-v2-enabled";
import {
type AppUpdateCheckState,
registerHiddenUpdateTap,
runAppUpdateCheck,
} from "../updates/app-updates";
import { useSavedRemoteConnections } from "../../state/use-remote-environment-registry";
import { SettingsRow } from "./components/SettingsRow";
import { SettingsSection } from "./components/SettingsSection";
Expand Down Expand Up @@ -578,35 +574,23 @@ function BetaSettingsSection() {
);
}

type UpdateCheckState = "idle" | "checking" | "downloading" | "restarting" | "current";

function AppSettingsSection() {
const icon = useThemeColor("--color-icon");
const [updateState, setUpdateState] = useState<UpdateCheckState>("idle");
const [updateState, setUpdateState] = useState<AppUpdateCheckState>("idle");
const updateInFlight = useRef(false);
const hiddenUpdateTapCount = useRef(0);

const version = Constants.expoConfig?.version ?? "0.0.0";
// Fall back to "production" to match resolveAppVariant in app.config.ts, so a
// missing variant never mislabels a production build as development.
const variant = (Constants.expoConfig?.extra?.appVariant as string | undefined) ?? "production";
const variantLabel = variant === "production" ? "" : capitalize(variant);
const versionLabel = variantLabel ? `${version} · ${variantLabel}` : version;
// Which JS is actually running: the bundle shipped in the binary, or an OTA
// update downloaded on top of it. Surfacing this makes "am I even on the
// right build?" answerable at a glance.
const bundleLabel = Updates.isEnabled
? Updates.isEmbeddedLaunch
? "Embedded"
: Updates.updateId
? `OTA ${Updates.updateId.slice(0, 7)}`
: 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?".
// return the version row to its normal, deliberately quiet state.
useEffect(() => {
if (updateState !== "current") return;
const timer = setTimeout(() => setUpdateState("idle"), 3000);
Expand All @@ -619,12 +603,24 @@ function AppSettingsSection() {
if (updateInFlight.current) return;
updateInFlight.current = true;
try {
await runUpdateCheck(setUpdateState);
await runAppUpdateCheck({
onFailure: (message) => Alert.alert("Update failed", message),
onStateChange: setUpdateState,
});
} finally {
updateInFlight.current = false;
}
}, []);

const handleVersionPress = useCallback(() => {
if (!Updates.isEnabled || updateInFlight.current) return;
const tap = registerHiddenUpdateTap(hiddenUpdateTapCount.current);
hiddenUpdateTapCount.current = tap.nextCount;
if (tap.shouldCheck) {
void checkForUpdate();
}
}, [checkForUpdate]);

const statusLabel =
updateState === "checking"
? "Checking…"
Expand All @@ -634,7 +630,7 @@ function AppSettingsSection() {
? "Restarting…"
: updateState === "current"
? "Up to date"
: bundleLabel;
: null;

const versionRow = (
<View className="flex-row items-center gap-4 p-4">
Expand All @@ -652,21 +648,6 @@ function AppSettingsSection() {
<Text className="text-xs text-foreground-muted/70">{statusLabel}</Text>
) : null}
</View>
{Updates.isEnabled ? (
<View className="w-[22px] items-center">
{busy ? (
<ActivityIndicator color={icon} size="small" />
) : (
<SymbolView
name="arrow.clockwise"
size={18}
tintColor={icon}
type="monochrome"
weight="semibold"
/>
)}
</View>
) : null}
</View>
);

Expand All @@ -676,10 +657,10 @@ function AppSettingsSection() {
<SettingsRow icon="doc.text" label="Legal" fullScreenTarget="SettingsLegal" />
{Updates.isEnabled ? (
<Pressable
accessibilityLabel="Check for updates"
accessibilityRole="button"
accessibilityLabel={`Version ${versionLabel}`}
accessibilityRole="text"
disabled={busy}
onPress={() => void checkForUpdate()}
onPress={handleVersionPress}
>
{versionRow}
</Pressable>
Expand All @@ -690,52 +671,6 @@ function AppSettingsSection() {
);
}

async function runUpdateCheck(setUpdateState: (state: UpdateCheckState) => void): Promise<void> {
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<unknown, unknown>, 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;
}
Expand Down
169 changes: 169 additions & 0 deletions apps/mobile/src/features/updates/app-updates.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,169 @@
import { describe, expect, it, vi } from "vite-plus/test";

import {
createAppUpdateLaunchCheck,
registerHiddenUpdateTap,
runAppUpdateCheck,
type AppUpdateCheckState,
type AppUpdateClient,
} from "./app-updates";

vi.mock("expo-updates", () => ({
isEnabled: true,
checkForUpdateAsync: vi.fn(),
fetchUpdateAsync: vi.fn(),
reloadAsync: vi.fn(),
}));

function makeUpdateClient(overrides: Partial<AppUpdateClient> = {}): AppUpdateClient {
return {
isEnabled: true,
checkForUpdateAsync: vi.fn(async () => ({
isAvailable: false,
isRollBackToEmbedded: false,
})),
fetchUpdateAsync: vi.fn(async () => ({
isNew: true,
isRollBackToEmbedded: false,
})),
reloadAsync: vi.fn(async () => {}),
...overrides,
};
}

describe("runAppUpdateCheck", () => {
it("downloads and restarts when a new update is available", async () => {
const client = makeUpdateClient({
checkForUpdateAsync: vi.fn(async () => ({
isAvailable: true,
isRollBackToEmbedded: false,
})),
});
const states: AppUpdateCheckState[] = [];

await runAppUpdateCheck({ client, onStateChange: (state) => states.push(state) });

expect(client.checkForUpdateAsync).toHaveBeenCalledOnce();
expect(client.fetchUpdateAsync).toHaveBeenCalledOnce();
expect(client.reloadAsync).toHaveBeenCalledOnce();
expect(states).toEqual(["checking", "downloading", "restarting"]);
});

it("restarts into the embedded bundle for a rollback directive", async () => {
const client = makeUpdateClient({
checkForUpdateAsync: vi.fn(async () => ({
isAvailable: false,
isRollBackToEmbedded: true,
})),
fetchUpdateAsync: vi.fn(async () => ({
isNew: false,
isRollBackToEmbedded: true,
})),
});

await runAppUpdateCheck({ client });

expect(client.fetchUpdateAsync).toHaveBeenCalledOnce();
expect(client.reloadAsync).toHaveBeenCalledOnce();
});

it("stops quietly when the running bundle is current", async () => {
const client = makeUpdateClient();
const states: AppUpdateCheckState[] = [];

await runAppUpdateCheck({ client, onStateChange: (state) => states.push(state) });

expect(client.fetchUpdateAsync).not.toHaveBeenCalled();
expect(client.reloadAsync).not.toHaveBeenCalled();
expect(states).toEqual(["checking", "current"]);
});

it("reports manual failures without continuing the update", async () => {
const reportError = vi.spyOn(console, "error").mockImplementation(() => {});
const client = makeUpdateClient({
checkForUpdateAsync: vi.fn(async () => {
throw new Error("offline");
}),
});
const failures: string[] = [];
const states: AppUpdateCheckState[] = [];

await runAppUpdateCheck({
client,
onFailure: (message) => failures.push(message),
onStateChange: (state) => states.push(state),
});

expect(client.fetchUpdateAsync).not.toHaveBeenCalled();
expect(failures).toEqual(["offline"]);
expect(states).toEqual(["checking", "idle"]);
reportError.mockRestore();
});

it("coalesces overlapping launch and manual checks", async () => {
let resolveCheck!: (result: {
readonly isAvailable: boolean;
readonly isRollBackToEmbedded: boolean;
}) => void;
const checkResult = new Promise<{
readonly isAvailable: boolean;
readonly isRollBackToEmbedded: boolean;
}>((resolve) => {
resolveCheck = resolve;
});
const client = makeUpdateClient({
checkForUpdateAsync: vi.fn(() => checkResult),
});
const checkOnLaunch = createAppUpdateLaunchCheck(client);

const launchCheck = checkOnLaunch();
const manualCheck = runAppUpdateCheck({ client });

expect(client.checkForUpdateAsync).toHaveBeenCalledOnce();

resolveCheck({
isAvailable: false,
isRollBackToEmbedded: false,
});
await Promise.all([launchCheck, manualCheck]);

await runAppUpdateCheck({ client });
expect(client.checkForUpdateAsync).toHaveBeenCalledTimes(2);
});
});

describe("createAppUpdateLaunchCheck", () => {
it("checks at most once for each JavaScript launch", async () => {
const client = makeUpdateClient();
const checkOnLaunch = createAppUpdateLaunchCheck(client);

const first = checkOnLaunch();
const second = checkOnLaunch();
await first;

expect(second).toBeUndefined();
expect(client.checkForUpdateAsync).toHaveBeenCalledOnce();
});

it("does nothing when Expo updates are disabled", () => {
const client = makeUpdateClient({ isEnabled: false });
const checkOnLaunch = createAppUpdateLaunchCheck(client);

expect(checkOnLaunch()).toBeUndefined();
expect(client.checkForUpdateAsync).not.toHaveBeenCalled();
});
});

describe("registerHiddenUpdateTap", () => {
it("unlocks the manual check on the fifth tap", () => {
let count = 0;

for (let tap = 1; tap <= 5; tap += 1) {
const result = registerHiddenUpdateTap(count);
expect(result.shouldCheck).toBe(tap === 5);
count = result.nextCount;
}

expect(count).toBe(0);
});
});
Loading
Loading