Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 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
3 changes: 2 additions & 1 deletion apps/mobile/src/features/home/HomeRouteScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +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 { checkForAppUpdateOnLaunch, startAppUpdateForegroundRecheck } from "../updates/app-updates";
import { AndroidHomeFabLayout } from "./AndroidHomeFab";
import { HomeScreen } from "./HomeScreen";
import { HomeHeader } from "./HomeHeader";
Expand All @@ -34,6 +34,7 @@ export function HomeRouteScreen() {

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

const {
Expand Down
17 changes: 12 additions & 5 deletions apps/mobile/src/features/settings/SettingsRouteScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -608,7 +608,10 @@ function AppSettingsSection() {
if (updateInFlight.current) return;
updateInFlight.current = true;
try {
// The user asked for this restart by tapping the version row, so it may
// apply immediately instead of prompting.
await runAppUpdateCheck({
applyMode: "immediate",
onFailure: (message) => Alert.alert("Update failed", message),
onStateChange: setUpdateState,
});
Expand All @@ -631,11 +634,15 @@ function AppSettingsSection() {
? "Checking…"
: updateState === "downloading"
? "Downloading…"
: updateState === "restarting"
? "Restarting…"
: updateState === "current"
? "Up to date"
: null;
: // "ready" appears only when this check joined an in-flight background-mode
// check; that download installs at the next backgrounding.
updateState === "ready"
? "Update ready"
: updateState === "restarting"
? "Restarting…"
: updateState === "current"
? "Up to date"
: null;

const versionRow = (
<View className="flex-row items-center gap-4 p-4">
Expand Down
231 changes: 222 additions & 9 deletions apps/mobile/src/features/updates/app-updates.test.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,15 @@
import { describe, expect, it, vi } from "vite-plus/test";

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

vi.mock("expo-updates", () => ({
Expand All @@ -31,6 +35,41 @@ function makeUpdateClient(overrides: Partial<AppUpdateClient> = {}): AppUpdateCl
};
}

function makeUpdateEnvironment(overrides: Partial<AppUpdateEnvironment> = {}): {
readonly backgroundCallbacks: Array<() => void>;
readonly environment: AppUpdateEnvironment;
readonly foregroundStayCallbacks: Array<() => void>;
} {
const backgroundCallbacks: Array<() => void> = [];
const foregroundStayCallbacks: Array<() => void> = [];
return {
backgroundCallbacks,
foregroundStayCallbacks,
environment: {
confirmInstallNow: vi.fn(async () => true),
flushPendingWrites: vi.fn(async () => {}),
isSafeToRestartInBackground: vi.fn(async () => true),
onNextBackground: vi.fn((apply: () => void) => {
backgroundCallbacks.push(apply);
}),
onForegroundStay: vi.fn((apply: () => void) => {
foregroundStayCallbacks.push(apply);
}),
...overrides,
},
};
}

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

describe("runAppUpdateCheck", () => {
it("does nothing while running from the Metro development server", async () => {
vi.stubGlobal("__DEV__", true);
Expand All @@ -45,19 +84,160 @@ describe("runAppUpdateCheck", () => {
expect(client.checkForUpdateAsync).not.toHaveBeenCalled();
});

it("downloads and restarts when a new update is available", async () => {
const client = makeUpdateClient({
checkForUpdateAsync: vi.fn(async () => ({
isAvailable: true,
isRollBackToEmbedded: false,
})),
});
it("downloads silently and installs at the next backgrounding", async () => {
const client = makeAvailableUpdateClient();
const { backgroundCallbacks, environment } = makeUpdateEnvironment();
const deferral = createAppUpdateDeferral();
const states: AppUpdateCheckState[] = [];

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

expect(client.checkForUpdateAsync).toHaveBeenCalledOnce();
expect(client.fetchUpdateAsync).toHaveBeenCalledOnce();
expect(environment.confirmInstallNow).not.toHaveBeenCalled();
expect(client.reloadAsync).not.toHaveBeenCalled();
expect(states).toEqual(["checking", "downloading", "ready"]);
expect(deferral.pendingInstall).toBe(true);
expect(backgroundCallbacks).toHaveLength(1);

backgroundCallbacks[0]!();
await vi.waitFor(() => expect(client.reloadAsync).toHaveBeenCalledOnce());
expect(environment.flushPendingWrites).toHaveBeenCalled();
});

it("flushes pending writes before restarting", async () => {
const client = makeAvailableUpdateClient();
const { environment } = makeUpdateEnvironment();

await runAppUpdateCheck({
applyMode: "immediate",
client,
deferral: createAppUpdateDeferral(),
environment,
});

const flushOrder = vi.mocked(environment.flushPendingWrites).mock.invocationCallOrder[0]!;
const reloadOrder = vi.mocked(client.reloadAsync).mock.invocationCallOrder[0]!;
expect(flushOrder).toBeLessThan(reloadOrder);
});

it("prompts once the app has stayed foregrounded with the download waiting", async () => {
const client = makeAvailableUpdateClient();
const { environment, foregroundStayCallbacks } = makeUpdateEnvironment();
const deferral = createAppUpdateDeferral();

await runAppUpdateCheck({ client, deferral, environment });
expect(environment.confirmInstallNow).not.toHaveBeenCalled();
expect(foregroundStayCallbacks).toHaveLength(1);

foregroundStayCallbacks[0]!();
await vi.waitFor(() => expect(client.reloadAsync).toHaveBeenCalledOnce());
expect(environment.confirmInstallNow).toHaveBeenCalledOnce();
expect(environment.flushPendingWrites).toHaveBeenCalled();
});

it("keeps the background install armed when the foreground prompt is declined", async () => {
const client = makeAvailableUpdateClient();
const { backgroundCallbacks, environment, foregroundStayCallbacks } = makeUpdateEnvironment({
confirmInstallNow: vi.fn(async () => false),
});
const deferral = createAppUpdateDeferral();

await runAppUpdateCheck({ client, deferral, environment });

foregroundStayCallbacks[0]!();
await vi.waitFor(() => expect(environment.confirmInstallNow).toHaveBeenCalledOnce());
expect(client.reloadAsync).not.toHaveBeenCalled();
expect(deferral.pendingInstall).toBe(true);

backgroundCallbacks[0]!();
await vi.waitFor(() => expect(client.reloadAsync).toHaveBeenCalledOnce());
});

it("skips the foreground prompt once the install is no longer pending", async () => {
const client = makeAvailableUpdateClient();
const { environment, foregroundStayCallbacks } = makeUpdateEnvironment();
const deferral = createAppUpdateDeferral();

await runAppUpdateCheck({ client, deferral, environment });

// A failed deferred reload resets the deferral before the stay fires.
deferral.pendingInstall = false;
foregroundStayCallbacks[0]!();

expect(environment.confirmInstallNow).not.toHaveBeenCalled();
expect(client.reloadAsync).not.toHaveBeenCalled();
});

it("re-arms instead of restarting when the app is no longer safely backgrounded", async () => {
const client = makeAvailableUpdateClient();
const safe = vi.fn(async () => false);
const { backgroundCallbacks, environment } = makeUpdateEnvironment({
isSafeToRestartInBackground: safe,
});
const deferral = createAppUpdateDeferral();

await runAppUpdateCheck({ client, deferral, environment });
expect(backgroundCallbacks).toHaveLength(1);

backgroundCallbacks[0]!();
await vi.waitFor(() => expect(backgroundCallbacks).toHaveLength(2));
expect(client.reloadAsync).not.toHaveBeenCalled();
expect(deferral.pendingInstall).toBe(true);

safe.mockResolvedValue(true);
backgroundCallbacks[1]!();
await vi.waitFor(() => expect(client.reloadAsync).toHaveBeenCalledOnce());
});

it("resets the deferral when the deferred restart fails", async () => {
const reportError = vi.spyOn(console, "error").mockImplementation(() => {});
const client = makeAvailableUpdateClient({
reloadAsync: vi.fn(async () => {
throw new Error("reload rejected");
}),
});
const { backgroundCallbacks, environment } = makeUpdateEnvironment();
const deferral = createAppUpdateDeferral();

await runAppUpdateCheck({ client, deferral, environment });
backgroundCallbacks[0]!();

await vi.waitFor(() => expect(deferral.pendingInstall).toBe(false));
reportError.mockRestore();
});

it("arms the deferred install once across repeated checks", async () => {
const client = makeAvailableUpdateClient();
const { environment } = makeUpdateEnvironment();
const deferral = createAppUpdateDeferral();

await runAppUpdateCheck({ client, deferral, environment });
await runAppUpdateCheck({ client, deferral, environment });

expect(environment.onNextBackground).toHaveBeenCalledOnce();
expect(environment.onForegroundStay).toHaveBeenCalledOnce();
});

it("restarts without prompting when the caller asked for an immediate install", async () => {
const client = makeAvailableUpdateClient();
const { environment } = makeUpdateEnvironment();
const states: AppUpdateCheckState[] = [];

await runAppUpdateCheck({
applyMode: "immediate",
client,
deferral: createAppUpdateDeferral(),
environment,
onStateChange: (state) => states.push(state),
});

expect(environment.confirmInstallNow).not.toHaveBeenCalled();
expect(client.reloadAsync).toHaveBeenCalledOnce();
expect(states).toEqual(["checking", "downloading", "restarting"]);
});
Expand All @@ -73,10 +253,13 @@ describe("runAppUpdateCheck", () => {
isRollBackToEmbedded: true,
})),
});
const { environment } = makeUpdateEnvironment();

await runAppUpdateCheck({ client });
await runAppUpdateCheck({ client, deferral: createAppUpdateDeferral(), environment });

expect(client.fetchUpdateAsync).toHaveBeenCalledOnce();
// A rollback pulls a broken bundle, so it never waits on the prompt.
expect(environment.confirmInstallNow).not.toHaveBeenCalled();
expect(client.reloadAsync).toHaveBeenCalledOnce();
});

Expand Down Expand Up @@ -276,6 +459,36 @@ describe("createAppUpdateLaunchCheck", () => {
});
});

describe("shouldRecheckAppUpdateOnForeground", () => {
it("requires a meaningful background gap", () => {
expect(shouldRecheckAppUpdateOnForeground(null, 100_000, false)).toBe(false);
expect(
shouldRecheckAppUpdateOnForeground(
100_000,
100_000 + FOREGROUND_APP_UPDATE_RECHECK_AFTER_MS - 1,
false,
),
).toBe(false);
expect(
shouldRecheckAppUpdateOnForeground(
100_000,
100_000 + FOREGROUND_APP_UPDATE_RECHECK_AFTER_MS,
false,
),
).toBe(true);
});

it("stays quiet while a downloaded update waits for its install", () => {
expect(
shouldRecheckAppUpdateOnForeground(
100_000,
100_000 + FOREGROUND_APP_UPDATE_RECHECK_AFTER_MS,
true,
),
).toBe(false);
});
});

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