Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
5649b5e
fix(mobile): disabled environments stop notifying that phone
shivamhwp Nov 7, 2026
5f40259
fix(relay): keep disabled environments hidden through queued and re-e…
shivamhwp Nov 7, 2026
2346eca
fix(android): keep re-enabled alerts silent with an empty activity ba…
shivamhwp Nov 7, 2026
978fbcb
fix(android): preserve disabled environment history when activity car…
shivamhwp Nov 7, 2026
b8e2fd5
fix(android): approvals queued while an environment was hidden stay s…
shivamhwp Nov 10, 2026
541598f
fix(mobile): device registrations run one at a time so the newest tog…
shivamhwp Nov 10, 2026
50fc3d6
fix(android): alerts queued before a disable stay silent after re-enable
shivamhwp Nov 10, 2026
9e4652e
test(mobile): assert the queued toggle's landed preferences, not cata…
shivamhwp Nov 10, 2026
e8edaa5
fix(android): re-enabling an environment records the time at registra…
shivamhwp Nov 10, 2026
f6bf4ae
fix(android): suppress re-enabled history in aggregate alerts
shivamhwp Nov 10, 2026
e6b2460
fix(relay): retain re-enable history for active attention
shivamhwp Nov 10, 2026
b423193
fix(mobile): cancel preference saves after an account switch
shivamhwp Nov 10, 2026
9321f14
fix(mobile): return from interrupted preference updates
shivamhwp Nov 10, 2026
00ea3d7
Merge remote-tracking branch 'github/main' into issue-watch/32453
maria-rcks Nov 10, 2026
3f7fcf5
Merge remote-tracking branch 'origin/main' into issue-watch/32453
juliusmarminge Nov 11, 2026
9ef8e57
test(relay): cut the Android re-enable matrix to the rows that catch …
juliusmarminge Nov 11, 2026
a9a481f
fix(relay): alert for attention reached after re-enabling an environment
juliusmarminge Nov 11, 2026
d5f63e3
fix(relay): queued iOS alerts survive filter changes for other enviro…
juliusmarminge Nov 11, 2026
5e08a6f
Merge branch 'main' into issue-watch/32453
shivamhwp Nov 12, 2026
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
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,10 @@ import {
linkEnvironmentToCloudWithPreference,
} from "../cloud/linkEnvironment";
import { setLiveActivityUpdatesEnabled } from "./liveActivityPreferences";
import { updateAgentAwarenessRegistrationPreferences } from "./remoteRegistration";
import {
getAgentAwarenessRegistrationGeneration,
updateAgentAwarenessRegistrationPreferences,
} from "./remoteRegistration";

vi.mock("expo-secure-store", () => ({
deleteItemAsync: vi.fn(),
Expand All @@ -38,6 +41,7 @@ vi.mock("../cloud/linkEnvironment", async (importOriginal) => ({

vi.mock("./remoteRegistration", () => ({
updateAgentAwarenessRegistrationPreferences: vi.fn(() => Effect.void),
getAgentAwarenessRegistrationGeneration: vi.fn(() => 1),
}));

const connection: SavedRemoteConnection = {
Expand Down Expand Up @@ -73,11 +77,25 @@ const layerTest = Layer.mergeAll(
describe("liveActivityPreferences", () => {
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(getAgentAwarenessRegistrationGeneration).mockReturnValue(1);
});

it.effect("restores relay preferences when an environment update fails", () => {
it.effect.each([
"same-session failure",
"account-change failure",
"account-change success",
] as const)("restores preferences only in the original session: %s", (scenario) => {
const accountChanged = scenario !== "same-session failure";
vi.mocked(linkEnvironmentToCloudWithPreference).mockImplementationOnce(() =>
Effect.fail(new CloudEnvironmentLinkError({ message: "environment update failed" })),
Effect.sync(() => {
if (accountChanged) vi.mocked(getAgentAwarenessRegistrationGeneration).mockReturnValue(2);
}).pipe(
Effect.andThen(
scenario === "account-change success"
? Effect.void
: Effect.fail(new CloudEnvironmentLinkError({ message: "environment update failed" })),
),
),
);

return Effect.gen(function* () {
Expand All @@ -95,6 +113,11 @@ describe("liveActivityPreferences", () => {
expect(updateAgentAwarenessRegistrationPreferences).toHaveBeenNthCalledWith(1, {
liveActivitiesEnabled: false,
});
if (accountChanged) {
expect(updateAgentAwarenessRegistrationPreferences).toHaveBeenCalledTimes(1);
expect(linkEnvironmentToCloudWithPreference).toHaveBeenCalledTimes(1);
return;
}
expect(updateAgentAwarenessRegistrationPreferences).toHaveBeenNthCalledWith(2, {
liveActivitiesEnabled: true,
});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,10 @@ import type { EnvironmentId } from "@t3tools/contracts";

import type { SavedRemoteConnection } from "../../lib/connection";
import { linkEnvironmentToCloudWithPreference } from "../cloud/linkEnvironment";
import { updateAgentAwarenessRegistrationPreferences } from "./remoteRegistration";
import {
getAgentAwarenessRegistrationGeneration,
updateAgentAwarenessRegistrationPreferences,
} from "./remoteRegistration";

export const setLiveActivityUpdatesEnabled = Effect.fn("setLiveActivityUpdatesEnabled")(
function* (input: {
Expand All @@ -13,6 +16,8 @@ export const setLiveActivityUpdatesEnabled = Effect.fn("setLiveActivityUpdatesEn
readonly connections: ReadonlyArray<SavedRemoteConnection>;
readonly canConfigureEnvironment: (environmentId: EnvironmentId) => boolean;
}) {
const generation = getAgentAwarenessRegistrationGeneration();
const isCurrentSession = () => generation === getAgentAwarenessRegistrationGeneration();
const linkedConnections = input.connections.filter(
(connection) => connection.bearerToken !== null,
);
Expand All @@ -22,6 +27,7 @@ export const setLiveActivityUpdatesEnabled = Effect.fn("setLiveActivityUpdatesEn
enabled: boolean,
clerkToken: string,
) {
if (!isCurrentSession()) return yield* Effect.interrupt;
if (!input.canConfigureEnvironment(connection.environmentId)) return;

yield* linkEnvironmentToCloudWithPreference({
Expand All @@ -47,6 +53,7 @@ export const setLiveActivityUpdatesEnabled = Effect.fn("setLiveActivityUpdatesEn
});

const restoreRelayPreference = Effect.fn("restoreRelayPreference")(function* () {
if (!isCurrentSession()) return;
yield* updateAgentAwarenessRegistrationPreferences({
liveActivitiesEnabled: input.previousEnabled,
}).pipe(
Expand Down Expand Up @@ -74,6 +81,7 @@ export const setLiveActivityUpdatesEnabled = Effect.fn("setLiveActivityUpdatesEn
});

yield* updateRelayPreference(input.enabled).pipe(
Effect.andThen(Effect.suspend(() => (isCurrentSession() ? Effect.void : Effect.interrupt))),
Effect.onError(() => restoreRelayPreference()),
);
},
Expand Down
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import type { EnvironmentId } from "@t3tools/contracts";
import type { RelayDeviceRegistrationRequest } from "@t3tools/contracts/relay";

import type { Preferences } from "../../persistence/mobile-preferences";
Expand All @@ -19,6 +20,7 @@ export function makeRelayDeviceRegistrationRequest(
readonly pushToStartToken?: string;
readonly notificationsEnabled: boolean;
readonly preferences: Preferences;
readonly disabledEnvironmentIds?: ReadonlyArray<EnvironmentId>;
} & (
| { readonly platform?: "ios"; readonly iosMajorVersion: number }
| {
Expand Down Expand Up @@ -53,6 +55,9 @@ export function makeRelayDeviceRegistrationRequest(
notifyOnInput: true,
notifyOnCompletion: true,
notifyOnFailure: true,
...(input.disabledEnvironmentIds
? { disabledEnvironmentIds: input.disabledEnvironmentIds }
: {}),
},
};
}
217 changes: 215 additions & 2 deletions apps/mobile/src/features/agent-awareness/remoteRegistration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -102,13 +102,28 @@ vi.mock("./agentLiveActivity", () => ({
startAgentLiveActivity: widgetMocks.start,
}));

// Avoid importing the native connection stack; this gate only needs configs.
const catalogMock = vi.hoisted(() => ({
atom: Symbol("catalogValueAtom"),
value: { isReady: true, entries: new Map<string, { readonly enabled: boolean }>() },
listeners: new Set<(value: unknown) => void>(),
}));

// Avoid importing the native connection stack; registration only reads these atoms.
vi.mock("../../state/atom-registry", () => ({
appAtomRegistry: {
get: () => environmentConfigsMock.configs,
get: (atom: unknown) =>
atom === catalogMock.atom ? catalogMock.value : environmentConfigsMock.configs,
subscribe: (_atom: unknown, listener: (value: unknown) => void) => {
catalogMock.listeners.add(listener);
return () => catalogMock.listeners.delete(listener);
},
},
}));

vi.mock("../../connection/catalog", () => ({
environmentCatalog: { catalogValueAtom: catalogMock.atom },
}));

vi.mock("../../state/server", () => ({
environmentServerConfigsAtom: Symbol("environmentServerConfigsAtom"),
}));
Expand Down Expand Up @@ -280,6 +295,8 @@ describe("makeRelayDeviceRegistrationRequest", () => {
publishWidgetMock.mockClear();
widgetMocks.dismissEnded.mockReset();
environmentConfigsMock.configs.clear();
catalogMock.value = { isReady: true, entries: new Map() };
catalogMock.listeners.clear();
});

it("disables push features in Personal Team relay registrations", () => {
Expand Down Expand Up @@ -645,6 +662,202 @@ describe("makeRelayDeviceRegistrationRequest", () => {
}).pipe(Effect.provide(layerRelayTest));
});

it.effect(
"re-registers the latest disabled environments after toggles during registration",
() => {
const registrations: Array<{ readonly preferences: unknown }> = [];
let openFirstRegistration: (() => void) | undefined;
const firstRegistrationGate = new Promise<void>((resolve) => {
openFirstRegistration = resolve;
});
let markFirstRegistrationArrived: (() => void) | undefined;
const firstRegistrationArrived = new Promise<void>((resolve) => {
markFirstRegistrationArrived = resolve;
});
vi.stubGlobal("fetch", async (input: RequestInfo | URL, init?: RequestInit) => {
const request = new Request(input, init);
if (request.url.endsWith("/v1/client/dpop-token")) {
return Response.json({
access_token: "relay-dpop-token",
issued_token_type: "urn:ietf:params:oauth:token-type:access_token",
token_type: "DPoP",
expires_in: 300,
scope: "mobile:registration",
});
}
registrations.push(await request.json());
if (registrations.length === 1) {
markFirstRegistrationArrived?.();
await firstRegistrationGate;
}
return Response.json({ ok: true });
});
Constants.expoConfig!.extra = { relay: { url: "https://relay.example.test/" } };
const setCatalog = (entries: ReadonlyArray<readonly [string, boolean]>) => {
catalogMock.value = {
isReady: true,
entries: new Map(entries.map(([id, enabled]) => [id, { enabled }])),
};
for (const listener of catalogMock.listeners) listener(catalogMock.value);
};
setCatalog([
["env-a", true],
["env-b", true],
]);
setAgentAwarenessRelayTokenProvider(() => Promise.resolve("clerk-token-user-a"), "user-a");

return Effect.gen(function* () {
const drain = yield* Effect.forkChild(runBackgroundOperations());
yield* Effect.promise(() => firstRegistrationArrived);
setCatalog([
["env-a", false],
["env-b", true],
]);
setCatalog([
["env-a", false],
["env-b", false],
]);
openFirstRegistration?.();
yield* Fiber.join(drain);
yield* runBackgroundOperations();

expect(registrations.map((body) => body.preferences)).toEqual([
expect.objectContaining({ disabledEnvironmentIds: [] }),
expect.objectContaining({ disabledEnvironmentIds: ["env-a", "env-b"] }),
]);
}).pipe(
Effect.provideService(FetchHttpClient.Fetch, globalThis.fetch),
Effect.provide(layerRelayTest),
);
},
);

it.effect("a toggle during a preference update registers after it with both changes", () => {
const landed: Array<{ readonly preferences: unknown }> = [];
let openUpdate: (() => void) | undefined;
const updateGate = new Promise<void>((resolve) => {
openUpdate = resolve;
});
let markUpdateArrived: (() => void) | undefined;
const updateArrived = new Promise<void>((resolve) => {
markUpdateArrived = resolve;
});
let requests = 0;
vi.stubGlobal("fetch", async (input: RequestInfo | URL, init?: RequestInit) => {
const request = new Request(input, init);
if (request.url.endsWith("/v1/client/dpop-token")) {
return Response.json({
access_token: "relay-dpop-token",
issued_token_type: "urn:ietf:params:oauth:token-type:access_token",
token_type: "DPoP",
expires_in: 300,
scope: "mobile:registration",
});
}
const body = await request.json();
if (++requests === 2) {
markUpdateArrived?.();
await updateGate;
}
landed.push(body);
return Response.json({ ok: true });
});
Constants.expoConfig!.extra = { relay: { url: "https://relay.example.test/" } };
catalogMock.value = { isReady: true, entries: new Map([["env-a", { enabled: true }]]) };
setAgentAwarenessRelayTokenProvider(() => Promise.resolve("clerk-token-user-a"), "user-a");

return Effect.gen(function* () {
yield* runBackgroundOperations();
const update = yield* Effect.forkChild(
updateAgentAwarenessRegistrationPreferences({ liveActivitiesEnabled: true }),
);
yield* Effect.promise(() => updateArrived);
catalogMock.value = { isReady: true, entries: new Map([["env-a", { enabled: false }]]) };
for (const listener of catalogMock.listeners) listener(catalogMock.value);
const toggle = backgroundRuntime.pending.shift()!;
const toggleFiber = yield* Effect.forkChild(
toggle.operation as Effect.Effect<
unknown,
AgentAwarenessRegistrationError,
ManagedRelay.ManagedRelayClient
>,
{ startImmediately: true },
);
openUpdate?.();
yield* Fiber.join(update);
toggle.resolve(yield* Fiber.await(toggleFiber));
yield* runBackgroundOperations();

expect(landed.map((body) => body.preferences)).toEqual([
expect.objectContaining({ liveActivitiesEnabled: false, disabledEnvironmentIds: [] }),
expect.objectContaining({ liveActivitiesEnabled: true, disabledEnvironmentIds: [] }),
expect.objectContaining({
liveActivitiesEnabled: true,
disabledEnvironmentIds: ["env-a"],
}),
]);
}).pipe(
Effect.provideService(FetchHttpClient.Fetch, globalThis.fetch),
Effect.provide(layerRelayTest),
);
});

it.effect("rejects old-account preference updates without changing the new account", () => {
const landed: Array<{ readonly preferences: unknown }> = [];
const arrived = Promise.withResolvers<void>();
const response = Promise.withResolvers<void>();
let requests = 0;
vi.stubGlobal("fetch", async (input: RequestInfo | URL, init?: RequestInit) => {
const request = new Request(input, init);
if (request.url.endsWith("/v1/client/dpop-token")) {
return Response.json({
access_token: "relay-dpop-token",
issued_token_type: "urn:ietf:params:oauth:token-type:access_token",
token_type: "DPoP",
expires_in: 300,
scope: "mobile:registration",
});
}
const body = await request.json();
if (++requests === 2) {
arrived.resolve();
await response.promise;
}
landed.push(body);
return Response.json({ ok: true });
});
Constants.expoConfig!.extra = { relay: { url: "https://relay.example.test/" } };
setAgentAwarenessRelayTokenProvider(() => Promise.resolve("clerk-a"), "user-a");

return Effect.gen(function* () {
yield* runBackgroundOperations();
const inFlight = yield* Effect.forkChild(
updateAgentAwarenessRegistrationPreferences({ liveActivitiesEnabled: true }),
);
yield* Effect.promise(() => arrived.promise);
const waiting = yield* Effect.forkChild(
updateAgentAwarenessRegistrationPreferences({ liveActivitiesEnabled: true }),
{ startImmediately: true },
);
setAgentAwarenessRelayTokenProvider(null);
setAgentAwarenessRelayTokenProvider(() => Promise.resolve("clerk-b"), "user-b");
yield* runBackgroundOperations();
expect(landed.at(-1)?.preferences).toMatchObject({ liveActivitiesEnabled: false });
response.resolve();
for (const fiber of [inFlight, waiting]) {
const exit = yield* Fiber.await(fiber);
expect(Exit.isFailure(exit) && Cause.hasInterrupts(exit.cause)).toBe(true);
}
vi.mocked(loadAgentAwarenessRegistrationRecord).mockResolvedValueOnce(null);
yield* refreshAgentAwarenessRegistration();
expect(registrationRecordStore.current?.identity).toBe("user-b");
expect(landed.at(-1)?.preferences).toMatchObject({ liveActivitiesEnabled: false });
}).pipe(
Effect.provideService(FetchHttpClient.Fetch, globalThis.fetch),
Effect.provide(layerRelayTest),
);
});

it.effect.each(["ios", "android"] as const)(
"does not enable %s notifications when a token rotates after permission is revoked",
(os) => {
Expand Down
Loading
Loading