Skip to content
Open
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
Expand Down Expand Up @@ -53,6 +54,7 @@ export function makeRelayDeviceRegistrationRequest(
notifyOnInput: true,
notifyOnCompletion: true,
notifyOnFailure: true,
...(input.soundsEnabled === undefined ? {} : { soundsEnabled: input.soundsEnabled }),
},
};
}
Original file line number Diff line number Diff line change
Expand Up @@ -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<typeof Notifications.getPermissionsAsync>
>);
vi.mocked(Notifications.getDevicePushTokenAsync).mockResolvedValue({
type: "ios",
data: "apns-token",
Expand Down Expand Up @@ -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<typeof Notifications.getPermissionsAsync>
>;
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({
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -295,6 +297,7 @@ const nativePushTokenRegistration = Effect.fn("remoteRegistration.nativePushToke
notificationsEnabled: alertsEnabled,
pushToken: observedPushToken,
notificationPermissionGranted: permissions.granted,
...sounds,
};
}
const token = yield* Effect.tryPromise({
Expand All @@ -320,6 +323,7 @@ const nativePushTokenRegistration = Effect.fn("remoteRegistration.nativePushToke
notificationsEnabled: alertsEnabled && pushToken !== null,
pushToken,
notificationPermissionGranted: permissions.granted,
...sounds,
};
},
);
Expand Down Expand Up @@ -354,6 +358,7 @@ function registrationSignature(body: RelayDeviceRegistrationRequest): string {
body.preferences.notifyOnInput,
body.preferences.notifyOnCompletion,
body.preferences.notifyOnFailure,
body.preferences.soundsEnabled ?? "",
].join("|");
}

Expand Down Expand Up @@ -724,6 +729,7 @@ const registerDevice = Effect.fn("remoteRegistration.registerDevice")(function*
: {}),
...(pushTokenRegistration.pushToken ? { pushToken: pushTokenRegistration.pushToken } : {}),
notificationsEnabled: pushTokenRegistration.notificationsEnabled,
soundsEnabled: pushTokenRegistration.soundsEnabled,
preferences,
}),
expectedGeneration,
Expand Down
12 changes: 8 additions & 4 deletions infra/relay/src/agentActivity/ApnsClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 =
Expand All @@ -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" } : {}),
},
};
}
Expand Down Expand Up @@ -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),
},
Expand All @@ -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,
},
Expand Down
67 changes: 67 additions & 0 deletions infra/relay/src/agentActivity/ApnsDeliveries.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<DeliveryAttempts.DeliveryAttemptInput> = [];
let executeCount = 0;
Expand Down
7 changes: 5 additions & 2 deletions infra/relay/src/agentActivity/ApnsDeliveries.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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":
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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({
Expand All @@ -737,6 +739,7 @@ const make = Effect.gen(function* () {
apns,
{ ...input, aggregate, alert } as SendLiveActivityDeliveryInput,
now,
preferences,
);
const result = yield* apns
.sendLiveActivityRequest({
Expand Down
2 changes: 2 additions & 0 deletions packages/contracts/src/relay.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down
Loading