Skip to content
Open
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
3 changes: 3 additions & 0 deletions apps/mobile/app.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -161,6 +161,9 @@ const config: ExpoConfig = {
bundleIdentifier: `${variant.iosBundleIdentifier}.widgets`,
groupIdentifier: `group.${variant.iosBundleIdentifier}`,
enablePushNotifications: true,
// Agent activity can update many times an hour; without the
// frequent-updates entitlement iOS throttles the update budget sooner.
frequentUpdates: true,
widgets: [
{
name: "AgentActivity",
Expand Down
11 changes: 11 additions & 0 deletions apps/mobile/src/features/agent-awareness/registrationPayload.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,20 @@ import type { RelayDeviceRegistrationRequest } from "@t3tools/contracts/relay";

import type { Preferences } from "../../lib/storage";

// Development builds are Xcode-signed and receive sandbox APNs tokens;
// preview and production builds are distribution-signed and use production
// APNs. The relay routes each device's pushes accordingly.
export function resolveApsEnvironment(appVariant: unknown): "sandbox" | "production" {
return appVariant === "development" ? "sandbox" : "production";
Comment thread
cursor[bot] marked this conversation as resolved.
}

export function makeRelayDeviceRegistrationRequest(input: {
readonly deviceId: string;
readonly label: string;
readonly iosMajorVersion: number;
readonly appVersion?: string;
readonly bundleId?: string;
readonly apsEnvironment?: "sandbox" | "production";
readonly pushToken?: string;
readonly pushToStartToken?: string;
readonly notificationsEnabled: boolean;
Expand All @@ -19,6 +28,8 @@ export function makeRelayDeviceRegistrationRequest(input: {
platform: "ios",
iosMajorVersion: input.iosMajorVersion,
appVersion: input.appVersion,
...(input.bundleId ? { bundleId: input.bundleId } : {}),
...(input.apsEnvironment ? { apsEnvironment: input.apsEnvironment } : {}),
...(input.pushToken ? { pushToken: input.pushToken } : {}),
...(input.pushToStartToken ? { pushToStartToken: input.pushToStartToken } : {}),
preferences: {
Expand Down
184 changes: 183 additions & 1 deletion apps/mobile/src/features/agent-awareness/remoteRegistration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,10 +16,17 @@ import { verifyDpopProof } from "@t3tools/shared/dpop";
import type { SavedRemoteConnection } from "../../lib/connection";
import { cryptoLayer } from "../cloud/dpop";
import { managedRelayClientLayer } from "../cloud/managedRelayLayer";
import { makeRelayDeviceRegistrationRequest } from "./registrationPayload";
import {
clearAgentAwarenessRegistrationRecord,
loadAgentAwarenessRegistrationRecord,
loadOrCreateAgentAwarenessDeviceId,
saveAgentAwarenessRegistrationRecord,
} from "../../lib/storage";
import { makeRelayDeviceRegistrationRequest, resolveApsEnvironment } from "./registrationPayload";
import {
AgentAwarenessOperationError,
__resetAgentAwarenessRemoteRegistrationForTest,
getAgentAwarenessRegistrationStatus,
refreshActiveLiveActivityRemoteRegistration,
refreshAgentAwarenessRegistration,
normalizeAgentAwarenessRelayBaseUrl,
Expand All @@ -41,6 +48,12 @@ const backgroundRuntime = vi.hoisted(() => ({
readonly resolve: (exit: Exit.Exit<unknown, unknown>) => void;
}>,
}));
const appStateMock = vi.hoisted(() => ({
listeners: [] as Array<(state: string) => void>,
}));
const registrationRecordStore = vi.hoisted(() => ({
current: null as { readonly identity: string; readonly signature: string } | null,
}));

vi.mock("expo-constants", () => ({
default: {
Expand Down Expand Up @@ -100,6 +113,19 @@ vi.mock("react-native", () => ({
OS: "ios",
Version: "18.0",
},
AppState: {
addEventListener: (_event: string, listener: (state: string) => void) => {
appStateMock.listeners.push(listener);
return {
remove: () => {
const index = appStateMock.listeners.indexOf(listener);
if (index >= 0) {
appStateMock.listeners.splice(index, 1);
}
},
};
},
},
}));

vi.mock("../../lib/runtime", () => ({
Expand All @@ -115,6 +141,17 @@ vi.mock("../../lib/storage", () => ({
loadAgentAwarenessDeviceId: vi.fn(() => Promise.resolve("device-1")),
loadOrCreateAgentAwarenessDeviceId: vi.fn(() => Promise.resolve("device-1")),
loadPreferences: vi.fn(() => Promise.resolve({})),
loadAgentAwarenessRegistrationRecord: vi.fn(() =>
Promise.resolve(registrationRecordStore.current),
),
saveAgentAwarenessRegistrationRecord: vi.fn((record: { identity: string; signature: string }) => {
registrationRecordStore.current = record;
return Promise.resolve();
}),
clearAgentAwarenessRegistrationRecord: vi.fn(() => {
registrationRecordStore.current = null;
return Promise.resolve();
}),
}));

function proofIat(proof: string): number {
Expand Down Expand Up @@ -176,6 +213,12 @@ describe("makeRelayDeviceRegistrationRequest", () => {
backgroundRuntime.pending.length = 0;
Constants.expoConfig!.extra = {};
__resetAgentAwarenessRemoteRegistrationForTest();
appStateMock.listeners.length = 0;
registrationRecordStore.current = null;
vi.mocked(saveAgentAwarenessRegistrationRecord).mockClear();
vi.mocked(loadAgentAwarenessRegistrationRecord).mockClear();
vi.mocked(clearAgentAwarenessRegistrationRecord).mockClear();
vi.mocked(loadOrCreateAgentAwarenessDeviceId).mockResolvedValue("device-1");
widgetMocks.getInstances.mockReset();
widgetMocks.getInstances.mockReturnValue([]);
});
Expand Down Expand Up @@ -213,6 +256,31 @@ describe("makeRelayDeviceRegistrationRequest", () => {
});
});

it("registers the app's APNs routing so the relay targets the right bundle", () => {
expect(
makeRelayDeviceRegistrationRequest({
deviceId: "device-1",
label: "Julius's iPhone",
iosMajorVersion: 18,
appVersion: "1.0.0",
bundleId: "com.t3tools.t3code.preview",
apsEnvironment: resolveApsEnvironment("preview"),
notificationsEnabled: true,
preferences: {},
}),
).toMatchObject({
bundleId: "com.t3tools.t3code.preview",
apsEnvironment: "production",
});
});

it("routes development builds to the APNs sandbox", () => {
expect(resolveApsEnvironment("development")).toBe("sandbox");
expect(resolveApsEnvironment("preview")).toBe("production");
expect(resolveApsEnvironment("production")).toBe("production");
expect(resolveApsEnvironment(undefined)).toBe("production");
});

it("marks notification delivery disabled when APNs permission is unavailable", () => {
expect(
makeRelayDeviceRegistrationRequest({
Expand Down Expand Up @@ -329,6 +397,53 @@ describe("makeRelayDeviceRegistrationRequest", () => {
},
);

it.effect(
"re-registers active Live Activity tokens when the app returns to the foreground",
() => {
const activity = {
getPushToken: vi.fn(() => Promise.resolve("activity-token")),
addPushTokenListener: vi.fn(),
};
widgetMocks.getInstances.mockReturnValue([activity] as never);
setAgentAwarenessRelayTokenProvider(() => Promise.resolve("clerk-token-user-a"));

return Effect.gen(function* () {
yield* runBackgroundOperations();
activity.getPushToken.mockClear();

expect(appStateMock.listeners).toHaveLength(1);
for (const listener of appStateMock.listeners) {
listener("background");
}
yield* runBackgroundOperations();
expect(activity.getPushToken).not.toHaveBeenCalled();

for (const listener of appStateMock.listeners) {
listener("active");
}
yield* runBackgroundOperations();
expect(activity.getPushToken).toHaveBeenCalled();
}).pipe(Effect.provide(relayTestLayer));
},
);

it("ends local Live Activities and stops foreground reconciliation on cloud sign-out", () => {
const end = vi.fn(() => Promise.resolve());
const activity = {
getPushToken: vi.fn(() => Promise.resolve("activity-token")),
addPushTokenListener: vi.fn(),
end,
};
widgetMocks.getInstances.mockReturnValue([activity] as never);
setAgentAwarenessRelayTokenProvider(() => Promise.resolve("clerk-token-user-a"));
expect(appStateMock.listeners).toHaveLength(1);

setAgentAwarenessRelayTokenProvider(null);

expect(end).toHaveBeenCalledWith("immediate");
expect(appStateMock.listeners).toHaveLength(0);
});

it.effect("refreshes APNs registration for connected environments after settings changes", () => {
registerAgentAwarenessConnection(savedConnection());
return Effect.gen(function* () {
Expand Down Expand Up @@ -395,6 +510,73 @@ describe("makeRelayDeviceRegistrationRequest", () => {
nowEpochSeconds: proofIat(dpop),
}),
).toMatchObject({ ok: true });
expect(getAgentAwarenessRegistrationStatus()).toBe("registered");
}).pipe(Effect.provide(relayTestLayer));
});

it.effect("marks registration failed when device registration cannot complete", () => {
Constants.expoConfig!.extra = {
relay: {
url: "https://relay.example.test/",
},
};
vi.mocked(loadOrCreateAgentAwarenessDeviceId).mockRejectedValueOnce(
new Error("registration failed"),
);
setAgentAwarenessRelayTokenProvider(() => Promise.resolve("clerk-token-user-a"));

return Effect.gen(function* () {
// Drive the registration directly so the assertion does not depend on the
// background queue draining; refreshAgentAwarenessRegistration swallows the
// error but must record the failed status so the settings toggles cannot
// read as enabled.
yield* refreshAgentAwarenessRegistration();
expect(getAgentAwarenessRegistrationStatus()).toBe("failed");
}).pipe(Effect.provide(relayTestLayer));
});

it("clears registration status on cloud sign-out", () => {
setAgentAwarenessRelayTokenProvider(() => Promise.resolve("clerk-token-user-a"));
setAgentAwarenessRelayTokenProvider(null);
expect(getAgentAwarenessRegistrationStatus()).toBe("unknown");
expect(clearAgentAwarenessRegistrationRecord).toHaveBeenCalled();
});

it.effect("does not re-register the same account when nothing has changed", () => {
Constants.expoConfig!.extra = {
relay: {
url: "https://relay.example.test/",
},
};
setAgentAwarenessRelayTokenProvider(() => Promise.resolve("clerk-token-user-a"));

return Effect.gen(function* () {
yield* refreshAgentAwarenessRegistration();
expect(getAgentAwarenessRegistrationStatus()).toBe("registered");
expect(saveAgentAwarenessRegistrationRecord).toHaveBeenCalledTimes(1);
expect(registrationRecordStore.current).not.toBeNull();

// Second attempt with an identical payload must skip the relay entirely,
// so no new registration record is written.
vi.mocked(saveAgentAwarenessRegistrationRecord).mockClear();
yield* refreshAgentAwarenessRegistration();
expect(getAgentAwarenessRegistrationStatus()).toBe("registered");
expect(saveAgentAwarenessRegistrationRecord).not.toHaveBeenCalled();
}).pipe(Effect.provide(relayTestLayer));
});

it.effect("re-registers when the stored account identity differs", () => {
Constants.expoConfig!.extra = {
relay: {
url: "https://relay.example.test/",
},
};
registrationRecordStore.current = { identity: "someone-else", signature: "stale" };
setAgentAwarenessRelayTokenProvider(() => Promise.resolve("clerk-token-user-a"));

return Effect.gen(function* () {
yield* refreshAgentAwarenessRegistration();
expect(saveAgentAwarenessRegistrationRecord).toHaveBeenCalledTimes(1);
}).pipe(Effect.provide(relayTestLayer));
});

Expand Down
Loading
Loading