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
30 changes: 30 additions & 0 deletions .changeset/163-install-id-token-rotation.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
---
"mobile": patch
---

Clause-②: no
Metadata-protocol: none

The bridge exposes a stable install id and tells the Console when the push
token rotates, so the Console can keep one device row per install in the
server's device registry.

- `window.ObjectStackNative.installId`: a random UUID v4 generated on first
launch and kept in SecureStore (`AFTER_FIRST_UNLOCK_THIS_DEVICE_ONLY`), so
it survives the WebView's storage being cleared. It is not a hardware or
advertising id. Only pages on the configured server's origin see it; any
other main-frame page, such as a trusted sign-in host, reads `null`. It is
also `null` when secure storage is unavailable at page start. The Console
sends it as `deviceId`.
- New capability and window event `pushTokenChanged`: when APNs / FCM report a
token, the shell dispatches `objectstack-native:pushTokenChanged` with
`detail: { token, vendor }`. Only pages on the configured server's origin
receive it. A token reported before the page's `ready()` is held, and the
latest one is delivered at `ready()`.
- Notification taps also read `userInfo.data.route` / `.url` on iOS, as a
fourth candidate after `content.data`, the top-level keys and `body`.
- Docs: the `ObjectStackShell/<appVersion>` user-agent marker is now a
documented contract, and the Console registers no Service Worker inside
the shell.

Additive: no bridge protocol version bump.
133 changes: 133 additions & 0 deletions __tests__/components/ConsoleWebView.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,21 @@ jest.mock("~/hooks/useNetworkStatus", () => ({
useNetworkStatus: () => ({ isConnected: mockIsConnected }),
}));

jest.mock("~/lib/install-id", () => ({
getInstallId: () => "3b241101-e2bb-4255-8caf-4136c566a962",
}));

// The OS token listener, captured so a test can report a rotated token.
const mockTokenListeners: Array<(token: unknown) => void> = [];
const mockTokenRemove = jest.fn();
jest.mock("expo-notifications", () => ({
...jest.requireActual("../../__mocks__/expo-notifications"),
addPushTokenListener: jest.fn((listener: (token: unknown) => void) => {
mockTokenListeners.push(listener);
return { remove: mockTokenRemove };
}),
}));

const SERVER = "https://acme.example.com";

function renderConsole(overrides: Partial<ConsoleWebViewProps> = {}) {
Expand Down Expand Up @@ -103,6 +118,124 @@ describe("ConsoleWebView", () => {
expect(mockWebView.props.applicationNameForUserAgent).toMatch(/^ObjectStackShell\//);
});

it("announces the install id and the pushTokenChanged capability", () => {
renderConsole();
const window: Record<string, any> = { location: { origin: SERVER } };
const document = { documentElement: null, addEventListener: () => undefined };
new Function("window", "document", mockWebView.props.injectedJavaScriptBeforeContentLoaded)(
window,
document
);
expect(window.ObjectStackNative.installId).toBe("3b241101-e2bb-4255-8caf-4136c566a962");
expect(window.ObjectStackNative.has("pushTokenChanged")).toBe(true);
expect(window.ObjectStackNative.has("getPushToken")).toBe(true);
});

describe("pushTokenChanged", () => {
beforeEach(() => {
mockTokenListeners.length = 0;
mockTokenRemove.mockReset();
});

const rotate = (token: unknown) => act(() => mockTokenListeners[mockTokenListeners.length - 1](token));

/** Run an injected script on a page of `origin`; return the events it dispatched. */
function runOnPage(script: string, origin = SERVER) {
const target = new EventTarget();
const seen: unknown[] = [];
target.addEventListener("objectstack-native:pushTokenChanged", (event) =>
seen.push((event as CustomEvent).detail)
);
new Function("window", script)({
CustomEvent,
dispatchEvent: target.dispatchEvent.bind(target),
location: { origin },
});
return seen;
}

/** The Console posts `ready` (its shell-event listeners are mounted). */
async function postReady() {
await act(async () => {
mockWebView.props.onMessage({
nativeEvent: {
data: JSON.stringify({ type: "event", name: "ready" }),
url: `${SERVER}/_console/home`,
},
});
});
}

/** The events every injected pushTokenChanged script dispatched on a server page. */
const delivered = () =>
mockWebView.injectJavaScript.mock.calls
.map(([script]) => script as string)
.filter((script) => script.includes("objectstack-native:pushTokenChanged"))
.flatMap((script) => runOnPage(script));

it("delivers a rotated token to a ready page as a window event", async () => {
renderConsole();
finishLoad();
await postReady();
rotate({ type: "android", data: "fcm-2" });
expect(mockWebView.injectJavaScript).toHaveBeenCalledTimes(1);
const script = mockWebView.injectJavaScript.mock.calls[0][0];
expect(runOnPage(script)).toEqual([{ token: "fcm-2", vendor: "fcm" }]);
// Never to whatever else is in the main frame (a trusted sign-in host).
expect(runOnPage(script, "https://login.idp.example")).toEqual([]);
});

it("holds a token reported before ready — after load end too — and delivers the latest at ready", async () => {
renderConsole();
rotate({ type: "ios", data: "apns-1" }); // cold start: no page yet
finishLoad(); // document loaded, Console listeners not mounted yet
rotate({ type: "ios", data: "apns-2" });
expect(delivered()).toEqual([]);
await postReady();
expect(delivered()).toEqual([{ token: "apns-2", vendor: "apns" }]);
// Delivered once: a second ready has nothing held.
await postReady();
expect(delivered()).toHaveLength(1);
});

it("holds a token reported while the failure page is up, for the recovered page", async () => {
renderConsole();
failLoad();
rotate({ type: "android", data: "fcm-3" });
expect(delivered()).toEqual([]);
fireEvent.press(screen.getByTestId("shell-retry")); // a re-created WebView
act(() => mockWebView.props.onLoadStart({ nativeEvent: {} }));
finishLoad();
await postReady();
expect(delivered()).toEqual([{ token: "fcm-3", vendor: "fcm" }]);
});

it("re-arms the hold when a new document starts loading", async () => {
renderConsole();
finishLoad();
await postReady();
act(() => mockWebView.props.onLoadStart({ nativeEvent: {} })); // a reload
rotate({ type: "ios", data: "apns-4" });
expect(delivered()).toEqual([]);
await postReady();
expect(delivered()).toEqual([{ token: "apns-4", vendor: "apns" }]);
});

it("ignores a report that is not a native token", async () => {
renderConsole();
finishLoad();
await postReady();
rotate({ type: "ios", data: "" });
expect(mockWebView.injectJavaScript).not.toHaveBeenCalled();
});

it("stops listening when unmounted", () => {
renderConsole();
screen.unmount();
expect(mockTokenRemove).toHaveBeenCalledTimes(1);
});
});

it("shows a loading overlay until the page finishes loading", () => {
renderConsole();
expect(screen.getByText("Loading…")).toBeTruthy();
Expand Down
43 changes: 42 additions & 1 deletion __tests__/hooks/usePushRouting.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,16 @@
import { renderHook, waitFor } from "@testing-library/react-native";
import * as Notifications from "expo-notifications";
import { usePushRouting, _resetRoutedNotifications } from "~/hooks/usePushRouting";
import {
usePushRouting,
usePushTokenChanges,
_resetRoutedNotifications,
} from "~/hooks/usePushRouting";

// The shared manual mock predates token events; add the one listener used here.
jest.mock("expo-notifications", () => ({
...jest.requireActual("../../__mocks__/expo-notifications"),
addPushTokenListener: jest.fn(() => ({ remove: jest.fn() })),
}));

const mockListen = Notifications.addNotificationResponseReceivedListener as jest.Mock;
const mockLast = Notifications.getLastNotificationResponseAsync as jest.Mock;
Expand Down Expand Up @@ -63,3 +73,34 @@ describe("usePushRouting", () => {
expect(remove).toHaveBeenCalled();
});
});

describe("usePushTokenChanges", () => {
const mockAddToken = Notifications.addPushTokenListener as jest.Mock;

beforeEach(() => {
mockAddToken.mockReset();
mockAddToken.mockReturnValue({ remove: jest.fn() });
});

it("reports a rotated token to the latest callback, subscribing once", () => {
const first = jest.fn();
const second = jest.fn();
const { rerender } = renderHook(({ cb }) => usePushTokenChanges(cb), {
initialProps: { cb: first },
});
rerender({ cb: second });
expect(mockAddToken).toHaveBeenCalledTimes(1);

mockAddToken.mock.calls[0][0]({ type: "ios", data: "apns-2" });
expect(first).not.toHaveBeenCalled();
expect(second).toHaveBeenCalledWith({ token: "apns-2", vendor: "apns" });
});

it("unsubscribes on unmount", () => {
const remove = jest.fn();
mockAddToken.mockReturnValue({ remove });
const { unmount } = renderHook(() => usePushTokenChanges(jest.fn()));
unmount();
expect(remove).toHaveBeenCalledTimes(1);
});
});
122 changes: 122 additions & 0 deletions __tests__/lib/install-id.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
import { uuid } from "expo-modules-core";
import {
INSTALL_ID_KEY,
_resetInstallIdCache,
getInstallId,
isInstallId,
} from "~/lib/install-id";

const mockStore = new Map<string, string>();
const mockGetItem = jest.fn(
(key: string, _options?: unknown): string | null => mockStore.get(key) ?? null
);
const mockSetItem = jest.fn((key: string, value: string, _options?: unknown): void => {
mockStore.set(key, value);
});

// The shared setup mocks only the async API; the install id is read
// synchronously (the bridge bootstrap is built synchronously).
jest.mock("expo-secure-store", () => ({
AFTER_FIRST_UNLOCK_THIS_DEVICE_ONLY: "AFTER_FIRST_UNLOCK_THIS_DEVICE_ONLY",
getItem: (key: string, options: unknown) => mockGetItem(key, options),
setItem: (key: string, value: string, options: unknown) => mockSetItem(key, value, options),
}));

const UUID_V4 = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/;

beforeEach(() => {
mockStore.clear();
mockGetItem.mockClear();
mockSetItem.mockClear();
_resetInstallIdCache();
});

describe("getInstallId", () => {
it("generates a random UUID v4 on first use and keeps it in SecureStore", () => {
const id = getInstallId();
expect(id).toMatch(UUID_V4);
expect(mockStore.get(INSTALL_ID_KEY)).toBe(id);
// Kept out of backups restored onto another device, readable after the
// first unlock (a notification can start the app in the background).
expect(mockSetItem).toHaveBeenCalledWith(INSTALL_ID_KEY, id, {
keychainAccessible: "AFTER_FIRST_UNLOCK_THIS_DEVICE_ONLY",
});
});

it("is stable across calls", () => {
const first = getInstallId();
expect(getInstallId()).toBe(first);
expect(mockSetItem).toHaveBeenCalledTimes(1);
});

it("is stable across launches: a stored id is read back, not replaced", () => {
const first = getInstallId();
_resetInstallIdCache(); // a new process; SecureStore keeps the value
expect(getInstallId()).toBe(first);
expect(mockSetItem).toHaveBeenCalledTimes(1);
});

it("does not depend on WebView storage: only SecureStore is read", () => {
const first = getInstallId();
_resetInstallIdCache();
mockGetItem.mockClear();
expect(getInstallId()).toBe(first);
expect(mockGetItem).toHaveBeenCalledWith(INSTALL_ID_KEY, expect.anything());
});

it("is regenerated when missing", () => {
const first = getInstallId();
mockStore.delete(INSTALL_ID_KEY); // Android "clear storage", reinstall
_resetInstallIdCache();
const second = getInstallId();
expect(second).toMatch(UUID_V4);
expect(second).not.toBe(first);
expect(mockStore.get(INSTALL_ID_KEY)).toBe(second);
});

it("replaces a stored value that is not an install id", () => {
mockStore.set(INSTALL_ID_KEY, "not-an-id");
const id = getInstallId();
expect(id).toMatch(UUID_V4);
expect(mockStore.get(INSTALL_ID_KEY)).toBe(id);
});

it("answers null, and mints nothing, when SecureStore cannot be read", () => {
mockGetItem.mockImplementationOnce(() => {
throw new Error("keychain locked");
});
expect(getInstallId()).toBeNull();
expect(mockSetItem).not.toHaveBeenCalled();
// Tried again on the next call.
expect(getInstallId()).toMatch(UUID_V4);
});

it("never hands out an id it could not store", () => {
mockSetItem.mockImplementationOnce(() => {
throw new Error("write refused");
});
expect(getInstallId()).toBeNull();
const id = getInstallId();
expect(id).toMatch(UUID_V4);
expect(mockStore.get(INSTALL_ID_KEY)).toBe(id);
});

it("answers null when the platform has no UUID generator", () => {
const spy = jest.spyOn(uuid, "v4").mockImplementationOnce(() => {
throw new Error("Native UUID version 4 generator implementation wasn't found");
});
expect(getInstallId()).toBeNull();
expect(mockSetItem).not.toHaveBeenCalled();
spy.mockRestore();
});
});

describe("isInstallId", () => {
it("accepts only a lower-case UUID v4", () => {
expect(isInstallId("3b241101-e2bb-4255-8caf-4136c566a962")).toBe(true);
expect(isInstallId("3B241101-E2BB-4255-8CAF-4136C566A962")).toBe(false);
expect(isInstallId("3b241101-e2bb-1255-8caf-4136c566a962")).toBe(false);
expect(isInstallId(null)).toBe(false);
expect(isInstallId("")).toBe(false);
});
});
Loading
Loading