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
2 changes: 1 addition & 1 deletion app/docs/ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -127,7 +127,7 @@ Shared components in `ui/`, used the same way on every screen:
- **Continuous Native Generation:** `android/` and `ios/` are generated by `expo prebuild` from `app.config.ts` and config plugins, and gitignored. Native changes are made in config or a config plugin, never by hand. The build scripts regenerate the native project for their variant before building.
- **Variants:** `app.config.ts` selects the package id (`com.lakaniemi.lifeapp.dev` vs `com.lakaniemi.lifeapp`), name ("Life App Dev" vs "Life App"), URL scheme (`lifeapp-dev` vs `lifeapp`), icon and API URL from one `APP_VARIANT` variable, so dev and prod install side by side and don't claim each other's links. It defaults to `development`, so a production build is always explicit. Google's Android OAuth client is keyed by package name plus signing-certificate SHA-1, so each variant and signing key needs its own (`docs/AUTH.md`).
- **Local API:** the Android emulator reaches the Mac's `localhost` at `10.0.2.2`, the dev variant's default. A physical phone uses `adb reverse tcp:8080 tcp:8080` or the LAN IP, through an `API_URL` override read with the config, so in development changing it needs only a Metro restart.
- **Distribution: GitHub Releases.** Each native release is a GitHub release with the signed APK attached under a fixed file name. The phones install it directly (sideloading). Because the asset name is fixed, `https://github.com/lakaniemi/life-app/releases/latest/download/<name>.apk` always points to the newest APK; the "Update required" screen links there ([Version compatibility](#10-version-compatibility)).
- **Distribution: GitHub Releases.** Each native release is a GitHub release with the signed APK attached under a fixed file name. The phones install it directly (sideloading). Because the asset name is fixed, `https://github.com/lakaniemi/life-app/releases/latest/download/life-app.apk` always points to the newest APK; the "Update required" screen links there ([Version compatibility](#10-version-compatibility)).
- **No paid Expo services.** EAS Build is a paid product beyond a small free tier, so APKs are built on GitHub Actions: `expo prebuild`, then `./gradlew assembleRelease`. Once `expo-updates` is added, its Gradle plugin computes the fingerprint runtime version there too, without EAS.
- **Signed with our own key**, which keeps the SHA-1 for Google sign-in stable. A local config plugin adds the release `signingConfig` to the generated `build.gradle`, reading the keystore and passwords from Gradle properties. The keystore is a GitHub secret, with a backup outside GitHub: Android installs an update only over an app signed with the same key, so losing it means reinstalling on every phone and registering a new SHA-1.
- The repo is public, so anyone can download the APK. That's acceptable: it holds no secrets (the Web client ID and API URL are public by nature), and everything is behind sign-in.
Expand Down
47 changes: 47 additions & 0 deletions app/src/api/client.test.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,14 @@
import { z } from "zod";

import { apiFetch } from "@/api/client";
import { appCompatLevel, markAppOutdated } from "@/api/compat";
import { ApiError, NetworkError } from "@/api/errors";

jest.mock("@/api/compat", () => ({
...jest.requireActual<typeof import("@/api/compat")>("@/api/compat"),
markAppOutdated: jest.fn(),
}));

// jest.setup.ts mocks the API URL as https://api.test.
const fetchMock = jest.spyOn(globalThis, "fetch");

Expand All @@ -16,6 +22,7 @@ const itemSchema = z.object({ id: z.string() });

afterEach(() => {
fetchMock.mockReset();
jest.mocked(markAppOutdated).mockClear();
// Here rather than in the timeout test, so that a failure there doesn't
// leave fake timers on for the tests after it.
jest.useRealTimers();
Expand All @@ -31,6 +38,46 @@ test("requests the path from the API and parses the response", async () => {
);
});

test("sends the compatibility level with every request", async () => {
fetchMock.mockResolvedValue(jsonResponse(200, { id: "a" }));

await apiFetch("/items/a", itemSchema);

expect(fetchMock).toHaveBeenCalledWith(
"https://api.test/items/a",
expect.objectContaining({
headers: expect.objectContaining({
"App-Compat-Level": String(appCompatLevel),
}),
}),
);
});

test("marks the app outdated when the API turns it away", async () => {
fetchMock.mockResolvedValue(
jsonResponse(400, {
error: { code: "app_outdated", message: "this app version is too old" },
}),
);

await expect(apiFetch("/items", itemSchema)).rejects.toMatchObject({
status: 400,
code: "app_outdated",
});
expect(markAppOutdated).toHaveBeenCalledTimes(1);
});

test("doesn't mark the app outdated for other errors", async () => {
fetchMock.mockResolvedValue(
jsonResponse(400, {
error: { code: "invalid_body", message: "invalid request body" },
}),
);

await expect(apiFetch("/items", itemSchema)).rejects.toBeInstanceOf(ApiError);
expect(markAppOutdated).not.toHaveBeenCalled();
});

test("sends a body as JSON", async () => {
fetchMock.mockResolvedValue(jsonResponse(201, { id: "a" }));

Expand Down
19 changes: 17 additions & 2 deletions app/src/api/client.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,11 @@
import { z } from "zod";

import {
appCompatLevel,
appCompatLevelHeader,
appOutdatedCode,
markAppOutdated,
} from "@/api/compat";
import { apiUrl } from "@/api/config";
import { ApiError, NetworkError } from "@/api/errors";

Expand Down Expand Up @@ -37,7 +43,13 @@ export const apiFetch = async <T>(
const response = await send(path, options);

if (!response.ok) {
throw await toApiError(response);
const error = await toApiError(response);
// Here rather than in the query cache, so that every kind of request is
// covered, mutations and one-off calls included.
if (error.code === appOutdatedCode) {
markAppOutdated();
}
throw error;
}
if (response.status === 204) {
return schema.parse(undefined);
Expand Down Expand Up @@ -73,7 +85,10 @@ const send = async (
}
signal?.addEventListener("abort", abort);

const headers: Record<string, string> = { Accept: "application/json" };
const headers: Record<string, string> = {
Accept: "application/json",
[appCompatLevelHeader]: String(appCompatLevel),
};
if (body !== undefined) {
headers["Content-Type"] = "application/json";
}
Expand Down
15 changes: 15 additions & 0 deletions app/src/api/compat.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import { act, renderHook } from "@testing-library/react-native";

import { markAppOutdated, useIsAppOutdated } from "@/api/compat";

// One test, because the flag is never reset, which is the point of it.
test("tells subscribers once the app is marked outdated", async () => {
const { result } = await renderHook(() => useIsAppOutdated());
expect(result.current).toBe(false);

await act(() => {
markAppOutdated();
});

expect(result.current).toBe(true);
});
36 changes: 36 additions & 0 deletions app/src/api/compat.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import { useSyncExternalStore } from "react";

// The API compatibility level this build speaks, sent with every request. The
// API turns away levels below its minimum. Bump it in the update that starts
// depending on a breaking API change (docs/ARCHITECTURE.md, "Version
// compatibility").
export const appCompatLevel = 1;

export const appCompatLevelHeader = "App-Compat-Level";

// The error code of a request the API turned away for its level being too low.
export const appOutdatedCode = "app_outdated";

// Whether the API has turned this build away. It lives outside React because
// the API client, which notices it, isn't a component, like TanStack Query's
// onlineManager. It is never reset: only a new version of the app fixes it.
let isOutdated = false;
const listeners = new Set<() => void>();

export const markAppOutdated = () => {
if (isOutdated) {
return;
}
isOutdated = true;
listeners.forEach((listener) => listener());
};

const subscribe = (listener: () => void) => {
listeners.add(listener);
return () => {
listeners.delete(listener);
};
};

export const useIsAppOutdated = () =>
useSyncExternalStore(subscribe, () => isOutdated);
5 changes: 4 additions & 1 deletion app/src/app/_layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import "@/i18n/i18n";
import { Stack } from "expo-router";
import * as SplashScreen from "expo-splash-screen";

import { UpdateGate } from "@/features/app-update/update-gate";
import { AppProviders } from "@/ui/app-providers";

// Kept up until the persisted query cache is restored (QueryProvider hides
Expand All @@ -12,7 +13,9 @@ void SplashScreen.preventAutoHideAsync();
const RootLayout = () => {
return (
<AppProviders>
<Stack />
<UpdateGate>
<Stack />
</UpdateGate>
</AppProviders>
);
};
Expand Down
22 changes: 22 additions & 0 deletions app/src/features/app-update/update-gate.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import { act, render, screen } from "@testing-library/react-native";
import { Text } from "react-native";

import { markAppOutdated } from "@/api/compat";
import { UpdateGate } from "@/features/app-update/update-gate";

// One test, because the outdated flag is never reset.
test("shows the app until the API turns it away, then only the update screen", async () => {
await render(
<UpdateGate>
<Text>The app</Text>
</UpdateGate>,
);
expect(screen.getByText("The app")).toBeOnTheScreen();

await act(() => {
markAppOutdated();
});

expect(screen.queryByText("The app")).not.toBeOnTheScreen();
expect(screen.getByText("Update required")).toBeOnTheScreen();
});
12 changes: 12 additions & 0 deletions app/src/features/app-update/update-gate.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import type { ReactNode } from "react";

import { useIsAppOutdated } from "@/api/compat";
import { UpdateRequiredScreen } from "@/features/app-update/update-required-screen";

// Replaces the whole app with the "Update required" screen once the API has
// turned it away, so nothing else is reachable.
export const UpdateGate = ({ children }: { children: ReactNode }) => {
const isOutdated = useIsAppOutdated();

return isOutdated ? <UpdateRequiredScreen /> : children;
};
29 changes: 29 additions & 0 deletions app/src/features/app-update/update-required-screen.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import { fireEvent, render, screen } from "@testing-library/react-native";
import { Linking } from "react-native";

import {
latestApkUrl,
UpdateRequiredScreen,
} from "@/features/app-update/update-required-screen";

test("explains that the app is too old", async () => {
await render(<UpdateRequiredScreen />);

expect(screen.getByText("Update required")).toBeOnTheScreen();
expect(
screen.getByText(
"This version of Life App is too old. Install the latest version to continue.",
),
).toBeOnTheScreen();
});

test("opens the latest APK when Download update is pressed", async () => {
const openURL = jest.spyOn(Linking, "openURL").mockResolvedValue(undefined);
await render(<UpdateRequiredScreen />);

await fireEvent.press(
screen.getByRole("button", { name: "Download update" }),
);

expect(openURL).toHaveBeenCalledWith(latestApkUrl);
});
57 changes: 57 additions & 0 deletions app/src/features/app-update/update-required-screen.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import { useTranslation } from "react-i18next";
import { Linking, StyleSheet, View } from "react-native";
import { Button, Icon, Text, useTheme } from "react-native-paper";
import { useSafeAreaInsets } from "react-native-safe-area-context";

// The release workflow attaches the APK under this fixed name, so the URL
// always points to the newest one (docs/ARCHITECTURE.md, "Distribution").
export const latestApkUrl =
"https://github.com/lakaniemi/life-app/releases/latest/download/life-app.apk";

// Blocks the whole app when the API has turned it away as too old. Until
// over-the-air updates exist, the only fix is a new APK.
export const UpdateRequiredScreen = () => {
const { t } = useTranslation();
const { colors } = useTheme();
const insets = useSafeAreaInsets();

return (
<View
style={[
styles.container,
{
backgroundColor: colors.background,
paddingTop: insets.top,
paddingBottom: insets.bottom,
},
]}
>
<Icon source="cellphone-arrow-down" size={48} color={colors.primary} />
<Text variant="headlineSmall" style={styles.text}>
{t("updateRequired.title")}
</Text>
<Text variant="bodyLarge" style={styles.text}>
{t("updateRequired.body")}
</Text>
<Button
mode="contained"
onPress={() => void Linking.openURL(latestApkUrl)}
>
{t("updateRequired.download")}
</Button>
</View>
);
};

const styles = StyleSheet.create({
container: {
flex: 1,
alignItems: "center",
justifyContent: "center",
gap: 16,
padding: 24,
},
text: {
textAlign: "center",
},
});
5 changes: 5 additions & 0 deletions app/src/i18n/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -21,5 +21,10 @@
},
"offline": {
"banner": "Offline · showing cached data"
},
"updateRequired": {
"title": "Update required",
"body": "This version of Life App is too old. Install the latest version to continue.",
"download": "Download update"
}
}
4 changes: 2 additions & 2 deletions specs/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,8 +25,8 @@ The next slices, in order. Each slice is one or more PRs. Its plan sizes and spl
1. ~~Dev build and tooling~~ (done, #20): SDK 58, the development build and the dev and prod variants, the folder structure replacing the template's leftovers, Jest and React Native Testing Library, and app CI
2. ~~UI foundation~~ (done, #23): the Paper theme, i18n, the shared loading, empty and error states, and the snackbar host
3. ~~Data layer~~ (done, #24): the API client and `ApiError`, the query client with its persisted cache and the focus and online wiring, and the offline banner
4. **API compatibility level**: the app's header and "Update required" screen, and the API's minimum-level middleware
5. **Release APKs**: signed on GitHub Actions and published as GitHub releases. Last, so that no installed app lacks the "Update required" handling.
4. ~~API compatibility level~~ (done, #27 and #28): the app's header and "Update required" screen, and the API's minimum-level middleware
5. **Release APKs**: signed on GitHub Actions and published as GitHub releases. Last, so that no installed app lacks the "Update required" handling. The APK must be attached as exactly `life-app.apk`, because the "Update required" screen links to `releases/latest/download/life-app.apk`.
2. **App sign-in**: sign in with Google, stay signed in, sign out ([sign-in.md](sign-in.md))
3. **App families**: first run (create a family), family screen, roles, leave and delete ([families.md](families.md#ui))
4. **App invites**: show an invite as a QR code and a link, and join by scanning or pasting one ([families.md](families.md#ui))
Expand Down
1 change: 1 addition & 0 deletions specs/app-shell.md
Original file line number Diff line number Diff line change
Expand Up @@ -62,4 +62,5 @@ Every screen uses the same states:
- API: the minimum compatibility level is enforced by `api/internal/server/compat.go`.
- App: most of this is built in the App baseline slice, and app updates in the Over-the-air updates slice.
- The shared screen states are in `app/src/ui/` (`loading-state.tsx`, `empty-state.tsx`, `error-state.tsx`), and the snackbar host is `app/src/ui/snackbar.tsx` (#23).
- The compatibility level, the header and the "outdated" flag are in `app/src/api/compat.ts` and `client.ts`. The "Update required" screen and the gate that shows it are in `app/src/features/app-update/`. It links to the APK only, until the Over-the-air updates slice adds Restart.
- The API client, error copy and query setup are in `app/src/api/`: the persisted cache and "Couldn't refresh" in `query-client.ts` and `query-provider.tsx`, refetching on focus and reconnect in `managers.ts` and `use-api-query.ts`, and the offline first load in `query-state.ts`. The offline banner is `app/src/ui/offline-banner.tsx`, shown by `Screen` (#24).
Loading