From b4dd44cb1d13b2bffcf67e0e673dc7328e27fca1 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Mon, 2 Nov 2026 05:03:25 +0000 Subject: [PATCH 1/4] test: reproduce #29063 Live Activity alerts ignore iOS Sounds off Relay: a Done Live Activity alert for a device whose stored preferences say soundsEnabled=false still carries sound "default". Mobile: turning iOS notification Sounds off and returning to the app does not report soundsEnabled=false to the relay. Both tests fail on main for that reason. Co-Authored-By: Claude Opus 5.5 --- .../remoteRegistration.test.ts | 46 +++++++++++++ .../src/agentActivity/ApnsDeliveries.test.ts | 67 +++++++++++++++++++ 2 files changed, 113 insertions(+) diff --git a/apps/mobile/src/features/agent-awareness/remoteRegistration.test.ts b/apps/mobile/src/features/agent-awareness/remoteRegistration.test.ts index d7e340d886ff2..b21371b8f8385 100644 --- a/apps/mobile/src/features/agent-awareness/remoteRegistration.test.ts +++ b/apps/mobile/src/features/agent-awareness/remoteRegistration.test.ts @@ -656,6 +656,52 @@ describe("makeRelayDeviceRegistrationRequest", () => { ); }, ); + it.effect( + "reports iOS notification sounds turned off when the app returns to the foreground", + () => { + const registrations: unknown[] = []; + 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: "dpop", + 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()); + return Response.json({ ok: true }); + }); + const permissions = (allowsSound: boolean) => + ({ granted: true, ios: { allowsSound } }) as Awaited< + ReturnType + >; + vi.mocked(Notifications.getPermissionsAsync).mockResolvedValue(permissions(true)); + Constants.expoConfig!.extra = { relay: { url: "https://sound-relay.example.test" } }; + setAgentAwarenessRelayTokenProvider(() => Promise.resolve("clerk"), "user-a"); + return Effect.gen(function* () { + yield* runBackgroundOperations(); + expect(registrations).toHaveLength(1); + + vi.mocked(Notifications.getPermissionsAsync).mockResolvedValue(permissions(false)); + for (const listener of appStateMock.listeners) listener("active"); + yield* runBackgroundOperations(); + + expect(registrations.at(-1)).toMatchObject({ + preferences: { notificationsEnabled: true, soundsEnabled: false }, + }); + }).pipe( + Effect.provideService(FetchHttpClient.Fetch, globalThis.fetch), + Effect.provide( + ManagedRelayLayer.layer("https://sound-relay.example.test").pipe( + Layer.provide(Layer.mergeAll(FetchHttpClient.layer, Dpop.layer)), + ), + ), + ); + }, + ); it.effect("preserves relay rejection errors with React Native response headers", () => { vi.spyOn(Platform, "OS", "get").mockReturnValue("android"); vi.mocked(Notifications.getDevicePushTokenAsync).mockResolvedValue({ diff --git a/infra/relay/src/agentActivity/ApnsDeliveries.test.ts b/infra/relay/src/agentActivity/ApnsDeliveries.test.ts index b2445b0ab4c08..7de5dd81e7f37 100644 --- a/infra/relay/src/agentActivity/ApnsDeliveries.test.ts +++ b/infra/relay/src/agentActivity/ApnsDeliveries.test.ts @@ -847,6 +847,73 @@ describe("ApnsDeliveries", () => { ); }); + it.effect("sends a silent Done Live Activity alert when the device turned sounds off", () => { + const requests: HttpClientRequest.HttpClientRequest[] = []; + const completedAt = "1970-01-01T00:00:01.000Z"; + const completedAggregate: RelayAgentActivityAggregateState = { + ...aggregate, + activeCount: 0, + updatedAt: completedAt, + activities: [ + { + ...aggregate.activities[0]!, + phase: "completed", + status: "Done", + updatedAt: completedAt, + }, + ], + }; + const soundsOffTarget: LiveActivities.TargetRow = { + ...target, + preferences_json: JSON.stringify({ ...JSON.parse(enabledPreferences), soundsEnabled: false }), + last_aggregate_json: JSON.stringify(aggregate), + }; + const signed = signApnsDeliveryJob({ + secret: config.apnsDeliveryJobSigningSecret, + payload: makeApnsDeliveryJobPayload({ + kind: "live_activity_update", + userId: target.user_id, + deviceId: target.device_id, + token: "activity-token", + aggregate: completedAggregate, + alert: { title: "Thread", body: "Done: Project" }, + createdAt: completedAt, + expiresAt: "1970-01-01T00:10:00.000Z", + jobId: "job-update-sounds-off", + }), + }); + + return Effect.gen(function* () { + const deliveries = yield* ApnsDeliveries.ApnsDeliveries; + const result = yield* deliveries.processSignedJob(signed); + + expect(result.ok).toBe(true); + expect(requests).toHaveLength(1); + const body = requests[0]!.body; + expect(body._tag).toBe("Uint8Array"); + if (body._tag === "Uint8Array") { + const { aps } = JSON.parse(new TextDecoder().decode(body.body)); + expect(aps.alert).toEqual({ title: "Thread", body: "Done: Project" }); + } + }).pipe( + Effect.provide( + layerFor({ + attempts: [], + config: signingConfig, + currentTargets: [soundsOffTarget], + activityStates: [ + { ...state, phase: "completed", headline: "Done", updatedAt: completedAt }, + ], + execute: (request) => + Effect.sync(() => { + requests.push(request); + return HttpClientResponse.fromWeb(request, new Response("", { status: 200 })); + }), + }), + ), + ); + }); + it.effect("retries signed queue jobs that are already claimed but not completed", () => { const attempts: Array = []; let executeCount = 0; From 7a322cebd932afb1cb6d1078d0081f6faac43f2e Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Mon, 2 Nov 2026 05:09:11 +0000 Subject: [PATCH 2/4] fix(relay): Live Activity alerts honour iOS notification Sounds off iOS mutes ordinary notifications when Settings > Notifications > T3 Code > Sounds is off, but not Live Activity alerts, which the relay always sent with sound "default". The mobile app now reports the iOS sound permission as preferences.soundsEnabled (re-registering when it changes), and the relay drops the alert sound for Live Activity updates and ends when the device reports sounds off. Absent means sounds are allowed. Co-Authored-By: Claude Opus 5.5 --- .../agent-awareness/registrationPayload.ts | 2 ++ .../agent-awareness/remoteRegistration.ts | 8 +++++-- .../src/agentActivity/ApnsClient.test.ts | 22 +++++++++++++++++++ infra/relay/src/agentActivity/ApnsClient.ts | 12 ++++++---- .../relay/src/agentActivity/ApnsDeliveries.ts | 7 ++++-- packages/contracts/src/relay.ts | 2 ++ 6 files changed, 45 insertions(+), 8 deletions(-) diff --git a/apps/mobile/src/features/agent-awareness/registrationPayload.ts b/apps/mobile/src/features/agent-awareness/registrationPayload.ts index 858e0facdf33d..4b6f71b3aeebd 100644 --- a/apps/mobile/src/features/agent-awareness/registrationPayload.ts +++ b/apps/mobile/src/features/agent-awareness/registrationPayload.ts @@ -18,6 +18,7 @@ export function makeRelayDeviceRegistrationRequest( readonly pushToken?: string; readonly pushToStartToken?: string; readonly notificationsEnabled: boolean; + readonly soundsEnabled?: boolean | undefined; readonly preferences: Preferences; } & ( | { readonly platform?: "ios"; readonly iosMajorVersion: number } @@ -45,6 +46,7 @@ export function makeRelayDeviceRegistrationRequest( notifyOnInput: true, notifyOnCompletion: true, notifyOnFailure: true, + ...(input.soundsEnabled === undefined ? {} : { soundsEnabled: input.soundsEnabled }), }, }; } diff --git a/apps/mobile/src/features/agent-awareness/remoteRegistration.ts b/apps/mobile/src/features/agent-awareness/remoteRegistration.ts index fc24d180a3a04..7dd4fd4e703d9 100644 --- a/apps/mobile/src/features/agent-awareness/remoteRegistration.ts +++ b/apps/mobile/src/features/agent-awareness/remoteRegistration.ts @@ -259,8 +259,10 @@ const nativePushTokenRegistration = Effect.fn("remoteRegistration.nativePushToke if (!permissions.granted) { return { notificationsEnabled: false, pushToken: null }; } + const allowsSound = permissions.ios?.allowsSound; + const sounds = typeof allowsSound === "boolean" ? { soundsEnabled: allowsSound } : {}; if (observedPushToken) { - return { notificationsEnabled: true, pushToken: observedPushToken }; + return { notificationsEnabled: true, pushToken: observedPushToken, ...sounds }; } const token = yield* Effect.tryPromise({ try: () => Notifications.getDevicePushTokenAsync(), @@ -281,7 +283,7 @@ const nativePushTokenRegistration = Effect.fn("remoteRegistration.nativePushToke token?.type === Platform.OS && typeof token.data === "string" && token.data.trim().length > 0 ? token.data.trim() : null; - return { notificationsEnabled: pushToken !== null, pushToken }; + return { notificationsEnabled: pushToken !== null, pushToken, ...sounds }; }, ); @@ -315,6 +317,7 @@ function registrationSignature(body: RelayDeviceRegistrationRequest): string { body.preferences.notifyOnInput, body.preferences.notifyOnCompletion, body.preferences.notifyOnFailure, + body.preferences.soundsEnabled ?? "", ].join("|"); } @@ -679,6 +682,7 @@ const registerDevice = Effect.fn("remoteRegistration.registerDevice")(function* : {}), ...(pushTokenRegistration.pushToken ? { pushToken: pushTokenRegistration.pushToken } : {}), notificationsEnabled: pushTokenRegistration.notificationsEnabled, + soundsEnabled: pushTokenRegistration.soundsEnabled, preferences, }), expectedGeneration, diff --git a/infra/relay/src/agentActivity/ApnsClient.test.ts b/infra/relay/src/agentActivity/ApnsClient.test.ts index 81e76b0391aae..e7c84efb9d9ec 100644 --- a/infra/relay/src/agentActivity/ApnsClient.test.ts +++ b/infra/relay/src/agentActivity/ApnsClient.test.ts @@ -204,6 +204,28 @@ describe("ApnsClient", () => { }).pipe(Effect.provide(layerTest)), ); + it.effect("keeps a sounds-off alert silent when shortening it to fit 4096 bytes", () => + Effect.gen(function* () { + const apns = yield* ApnsClient.ApnsClient; + const request = apns.makeLiveActivityRequest({ + event: "end", + token: "token", + state: sanitizeAgentActivityAggregateState({ + ...state, + activities: [{ ...state.activities[0]!, threadTitle: "界".repeat(200) }], + }), + alert: { title: "界".repeat(800), body: "界".repeat(800) }, + soundsEnabled: false, + nowEpochSeconds: Math.floor(now.epochMilliseconds / 1_000), + nowIso: DateTime.formatIso(now), + }); + + const payload = request.payload as { aps: { alert: { title: string } } }; + expect(payload.aps.alert.title).toMatch(/^界+\.\.\.$/); + expect(payload.aps.alert).not.toHaveProperty("sound"); + }).pipe(Effect.provide(layerTest)), + ); + it.effect("fits Live Activity updates at every sanitizer cap within 4096 bytes", () => Effect.gen(function* () { const apns = yield* ApnsClient.ApnsClient; diff --git a/infra/relay/src/agentActivity/ApnsClient.ts b/infra/relay/src/agentActivity/ApnsClient.ts index b21953c59eecc..f384451fd25bb 100644 --- a/infra/relay/src/agentActivity/ApnsClient.ts +++ b/infra/relay/src/agentActivity/ApnsClient.ts @@ -89,6 +89,8 @@ interface LiveActivityRequestBase { readonly token: string; readonly nowEpochSeconds: number; readonly nowIso: string; + // iOS applies the per-app Sounds switch to notifications but not to Live Activity alerts. + readonly soundsEnabled?: boolean | undefined; } type MakeLiveActivityRequestInput = @@ -104,12 +106,12 @@ type MakeLiveActivityRequestInput = }); // Alert dictionaries wake the iOS screen and trigger haptics instead of silently refreshing. -function liveActivityAlertPayload(alert: ApnsLiveActivityAlert) { +function liveActivityAlertPayload(alert: ApnsLiveActivityAlert, soundsEnabled = true) { return { alert: { title: alert.title, body: alert.body, - sound: "default", + ...(soundsEnabled ? { sound: "default" } : {}), }, }; } @@ -179,7 +181,7 @@ function buildLiveActivityRequest(input: MakeLiveActivityRequestInput): ApnsLive timestamp, event: "end", ...(input.state ? { "content-state": contentState(input.state) } : {}), - ...(input.alert ? liveActivityAlertPayload(input.alert) : {}), + ...(input.alert ? liveActivityAlertPayload(input.alert, input.soundsEnabled) : {}), "dismissal-date": timestamp + (input.state ? DISMISS_AFTER_SECONDS : CONTENTLESS_DISMISS_AFTER_SECONDS), }, @@ -196,7 +198,9 @@ function buildLiveActivityRequest(input: MakeLiveActivityRequestInput): ApnsLive aps: { timestamp, event: input.event, - ...(input.event === "update" && input.alert ? liveActivityAlertPayload(input.alert) : {}), + ...(input.event === "update" && input.alert + ? liveActivityAlertPayload(input.alert, input.soundsEnabled) + : {}), "content-state": contentState(state), "stale-date": timestamp + STALE_AFTER_SECONDS, }, diff --git a/infra/relay/src/agentActivity/ApnsDeliveries.ts b/infra/relay/src/agentActivity/ApnsDeliveries.ts index 6e0e4bdc6e236..0bd18196d57aa 100644 --- a/infra/relay/src/agentActivity/ApnsDeliveries.ts +++ b/infra/relay/src/agentActivity/ApnsDeliveries.ts @@ -445,12 +445,14 @@ function makeLiveActivityDeliveryRequest( apns: Apns.ApnsClient["Service"], input: SendLiveActivityDeliveryInput, now: DateTime.DateTime, + preferences: RelayAgentAwarenessPreferences | null, ) { const epochSeconds = Math.floor(now.epochMilliseconds / 1_000); const base = { token: input.token, nowEpochSeconds: epochSeconds, nowIso: DateTime.formatIso(now), + soundsEnabled: preferences?.soundsEnabled, }; switch (input.kind) { case "live_activity_update": @@ -686,8 +688,8 @@ const make = Effect.gen(function* () { return staleJobResult({ deviceId: input.target.device_id, kind: input.kind }); } deliveryTarget = currentTarget; + const preferences = parsePreferences(currentTarget.preferences_json); if (alert) { - const preferences = parsePreferences(currentTarget.preferences_json); const previousAggregate = parseAggregate(currentTarget.last_aggregate_json); alert = !preferences?.notificationsEnabled || !aggregate @@ -723,7 +725,7 @@ const make = Effect.gen(function* () { if ( input.kind === "live_activity_end" && aggregate === null && - parsePreferences(currentTarget.preferences_json)?.liveActivitiesEnabled !== false && + preferences?.liveActivitiesEnabled !== false && (yield* userHasContentToShow(input.target.user_id)) ) { yield* attempts.completeSourceJob({ @@ -736,6 +738,7 @@ const make = Effect.gen(function* () { apns, { ...input, aggregate, alert } as SendLiveActivityDeliveryInput, now, + preferences, ); const result = yield* apns .sendLiveActivityRequest({ diff --git a/packages/contracts/src/relay.ts b/packages/contracts/src/relay.ts index 8ebc9df23a0bb..d4a8ddf618d84 100644 --- a/packages/contracts/src/relay.ts +++ b/packages/contracts/src/relay.ts @@ -37,6 +37,8 @@ export const RelayAgentAwarenessPreferences = Schema.Struct({ notifyOnInput: Schema.Boolean, notifyOnCompletion: Schema.Boolean, notifyOnFailure: Schema.Boolean, + // Absent means allowed; only iOS reports this. + soundsEnabled: Schema.optional(Schema.Boolean), }); export type RelayAgentAwarenessPreferences = typeof RelayAgentAwarenessPreferences.Type; From 69d8f0c20811fc999f736b94011c703b43ef2700 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Mon, 2 Nov 2026 05:20:37 +0000 Subject: [PATCH 3/4] test(mobile): reset notification permission mock between registration tests Co-Authored-By: Claude Opus 5.5 --- .../src/features/agent-awareness/remoteRegistration.test.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/apps/mobile/src/features/agent-awareness/remoteRegistration.test.ts b/apps/mobile/src/features/agent-awareness/remoteRegistration.test.ts index b21371b8f8385..4e6574c0603f5 100644 --- a/apps/mobile/src/features/agent-awareness/remoteRegistration.test.ts +++ b/apps/mobile/src/features/agent-awareness/remoteRegistration.test.ts @@ -228,6 +228,9 @@ const runBackgroundOperations = Effect.fn("TestRemoteRegistration.runBackgroundO describe("makeRelayDeviceRegistrationRequest", () => { beforeEach(() => { vi.restoreAllMocks(); + vi.mocked(Notifications.getPermissionsAsync).mockResolvedValue({ granted: true } as Awaited< + ReturnType + >); vi.mocked(Notifications.getDevicePushTokenAsync).mockResolvedValue({ type: "ios", data: "apns-token", From 4ca89a4fdda4d7c7bc6b19a22ffcd01e6bda1d95 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Tue, 10 Nov 2026 23:41:17 +0000 Subject: [PATCH 4/4] test(relay): adapt sounds-off delivery test to async job signing; drop duplicate client test AI-assisted; verified with the tests and tools listed above. --- .../src/agentActivity/ApnsClient.test.ts | 22 ------------------- .../src/agentActivity/ApnsDeliveries.test.ts | 2 +- 2 files changed, 1 insertion(+), 23 deletions(-) diff --git a/infra/relay/src/agentActivity/ApnsClient.test.ts b/infra/relay/src/agentActivity/ApnsClient.test.ts index e7c84efb9d9ec..81e76b0391aae 100644 --- a/infra/relay/src/agentActivity/ApnsClient.test.ts +++ b/infra/relay/src/agentActivity/ApnsClient.test.ts @@ -204,28 +204,6 @@ describe("ApnsClient", () => { }).pipe(Effect.provide(layerTest)), ); - it.effect("keeps a sounds-off alert silent when shortening it to fit 4096 bytes", () => - Effect.gen(function* () { - const apns = yield* ApnsClient.ApnsClient; - const request = apns.makeLiveActivityRequest({ - event: "end", - token: "token", - state: sanitizeAgentActivityAggregateState({ - ...state, - activities: [{ ...state.activities[0]!, threadTitle: "界".repeat(200) }], - }), - alert: { title: "界".repeat(800), body: "界".repeat(800) }, - soundsEnabled: false, - nowEpochSeconds: Math.floor(now.epochMilliseconds / 1_000), - nowIso: DateTime.formatIso(now), - }); - - const payload = request.payload as { aps: { alert: { title: string } } }; - expect(payload.aps.alert.title).toMatch(/^界+\.\.\.$/); - expect(payload.aps.alert).not.toHaveProperty("sound"); - }).pipe(Effect.provide(layerTest)), - ); - it.effect("fits Live Activity updates at every sanitizer cap within 4096 bytes", () => Effect.gen(function* () { const apns = yield* ApnsClient.ApnsClient; diff --git a/infra/relay/src/agentActivity/ApnsDeliveries.test.ts b/infra/relay/src/agentActivity/ApnsDeliveries.test.ts index 45b179a35e10f..d89d8c4afea98 100644 --- a/infra/relay/src/agentActivity/ApnsDeliveries.test.ts +++ b/infra/relay/src/agentActivity/ApnsDeliveries.test.ts @@ -886,7 +886,7 @@ describe("ApnsDeliveries", () => { return Effect.gen(function* () { const deliveries = yield* ApnsDeliveries.ApnsDeliveries; - const result = yield* deliveries.processSignedJob(signed); + const result = yield* deliveries.processSignedJob(yield* signed); expect(result.ok).toBe(true); expect(requests).toHaveLength(1);