diff --git a/apps/mobile/src/features/agent-awareness/registrationPayload.ts b/apps/mobile/src/features/agent-awareness/registrationPayload.ts index c66ed4c030a93..26213f834b9cf 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 } @@ -53,6 +54,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.test.ts b/apps/mobile/src/features/agent-awareness/remoteRegistration.test.ts index 9085f98058066..b7124d8f06045 100644 --- a/apps/mobile/src/features/agent-awareness/remoteRegistration.test.ts +++ b/apps/mobile/src/features/agent-awareness/remoteRegistration.test.ts @@ -252,6 +252,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", @@ -774,6 +777,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/apps/mobile/src/features/agent-awareness/remoteRegistration.ts b/apps/mobile/src/features/agent-awareness/remoteRegistration.ts index ec712608fad94..b4a679ef53910 100644 --- a/apps/mobile/src/features/agent-awareness/remoteRegistration.ts +++ b/apps/mobile/src/features/agent-awareness/remoteRegistration.ts @@ -275,6 +275,8 @@ const nativePushTokenRegistration = Effect.fn("remoteRegistration.nativePushToke if (!permissions.granted && Platform.OS !== "android") { return { notificationsEnabled: false, pushToken: null, notificationPermissionGranted: false }; } + const allowsSound = permissions.ios?.allowsSound; + const sounds = typeof allowsSound === "boolean" ? { soundsEnabled: allowsSound } : {}; const alertsEnabled = permissions.granted && (Platform.OS === "android" && Number(Platform.Version) >= 26 @@ -295,6 +297,7 @@ const nativePushTokenRegistration = Effect.fn("remoteRegistration.nativePushToke notificationsEnabled: alertsEnabled, pushToken: observedPushToken, notificationPermissionGranted: permissions.granted, + ...sounds, }; } const token = yield* Effect.tryPromise({ @@ -320,6 +323,7 @@ const nativePushTokenRegistration = Effect.fn("remoteRegistration.nativePushToke notificationsEnabled: alertsEnabled && pushToken !== null, pushToken, notificationPermissionGranted: permissions.granted, + ...sounds, }; }, ); @@ -354,6 +358,7 @@ function registrationSignature(body: RelayDeviceRegistrationRequest): string { body.preferences.notifyOnInput, body.preferences.notifyOnCompletion, body.preferences.notifyOnFailure, + body.preferences.soundsEnabled ?? "", ].join("|"); } @@ -724,6 +729,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.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.test.ts b/infra/relay/src/agentActivity/ApnsDeliveries.test.ts index 64823f4973593..d89d8c4afea98 100644 --- a/infra/relay/src/agentActivity/ApnsDeliveries.test.ts +++ b/infra/relay/src/agentActivity/ApnsDeliveries.test.ts @@ -848,6 +848,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(yield* 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; diff --git a/infra/relay/src/agentActivity/ApnsDeliveries.ts b/infra/relay/src/agentActivity/ApnsDeliveries.ts index 8913bdd1f8c5b..500b0ecc6c5b2 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": @@ -687,8 +689,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 @@ -724,7 +726,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({ @@ -737,6 +739,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;