From 5d2301297777a78da7e544252eea32c6c8d11fbb Mon Sep 17 00:00:00 2001 From: Horus Lugo Date: Sun, 28 Jun 2026 23:38:00 +0200 Subject: [PATCH] Add Android agent activity notifications --- apps/mobile/app.config.ts | 1 + apps/mobile/src/app/settings/index.tsx | 38 ++-- .../androidNotifications.test.ts | 107 ++++++++++ .../agent-awareness/androidNotifications.ts | 94 +++++++++ .../notificationNavigation.test.ts | 30 +++ .../agent-awareness/notificationNavigation.ts | 3 + .../agent-awareness/notificationPayload.ts | 39 +++- .../notificationPermissions.ts | 31 ++- .../agent-awareness/registrationPayload.ts | 50 ++++- .../remoteRegistration.test.ts | 193 ++++++++++++++++-- .../agent-awareness/remoteRegistration.ts | 139 ++++++++++--- ...MobileClientsUserProfilePage.logic.test.ts | 39 +++- .../MobileClientsUserProfilePage.logic.ts | 14 +- .../clerk/MobileClientsUserProfilePage.tsx | 7 +- .../migration.sql | 4 + infra/relay/src/agentActivity/Devices.test.ts | 137 ++++++++++++- infra/relay/src/agentActivity/Devices.ts | 76 +++++-- .../relay/src/agentActivity/LiveActivities.ts | 19 +- infra/relay/src/persistence/schema.ts | 7 +- .../client-runtime/src/relay/managedRelay.ts | 28 ++- packages/contracts/src/relay.test.ts | 65 +++++- packages/contracts/src/relay.ts | 55 ++++- 22 files changed, 1040 insertions(+), 136 deletions(-) create mode 100644 apps/mobile/src/features/agent-awareness/androidNotifications.test.ts create mode 100644 apps/mobile/src/features/agent-awareness/androidNotifications.ts create mode 100644 infra/relay/migrations/postgres/20260628000000_android_agent_activity_devices/migration.sql diff --git a/apps/mobile/app.config.ts b/apps/mobile/app.config.ts index 0cb550059e3..4fac9f88d60 100644 --- a/apps/mobile/app.config.ts +++ b/apps/mobile/app.config.ts @@ -135,6 +135,7 @@ const config: ExpoConfig = { "expo-asset", "expo-font", "expo-secure-store", + ["expo-notifications", { defaultChannel: "agent-activity" }], ["@clerk/expo", { theme: "./clerk-theme.json", appleSignIn: !isIosPersonalTeamBuild }], "expo-web-browser", [ diff --git a/apps/mobile/src/app/settings/index.tsx b/apps/mobile/src/app/settings/index.tsx index fc78679ff2c..b7fa2fd0543 100644 --- a/apps/mobile/src/app/settings/index.tsx +++ b/apps/mobile/src/app/settings/index.tsx @@ -35,6 +35,14 @@ import { useSavedRemoteConnections } from "../../state/use-remote-environment-re type NotificationStatus = "checking" | "enabled" | "disabled" | "unsupported"; type LiveActivityStatus = "checking" | "enabled" | "disabled" | "signed-out" | "linking"; +function activityUpdatesLabel(): string { + return Platform.OS === "android" ? "Agent Activity Updates" : "Live Activity Updates"; +} + +function activityUpdatesDescription(): string { + return Platform.OS === "android" ? "Agent Activity" : "Live Activity"; +} + export default function SettingsRouteScreen() { const router = useRouter(); const { layout } = useAdaptiveWorkspaceLayout(); @@ -112,7 +120,7 @@ function ConfiguredSettingsRouteScreen() { }, [isLoaded, isSignedIn, user?.primaryEmailAddress?.emailAddress]); const refreshNotifications = useCallback(async () => { - if (process.env.EXPO_OS !== "ios") { + if (Platform.OS !== "ios" && Platform.OS !== "android") { setNotificationStatus("unsupported"); return; } @@ -173,7 +181,7 @@ function ConfiguredSettingsRouteScreen() { setNotificationStatus("enabled"); Alert.alert( "Notifications enabled", - "Live Activity notifications are enabled for this device.", + `${activityUpdatesDescription()} notifications are enabled for this device.`, ); return; } @@ -181,7 +189,7 @@ function ConfiguredSettingsRouteScreen() { setNotificationStatus("unsupported"); Alert.alert( "Notifications unavailable", - "Live Activity notifications are only available on iOS.", + "Agent Activity notifications are only available on iOS and Android.", ); return; } @@ -203,7 +211,7 @@ function ConfiguredSettingsRouteScreen() { const promptSignIn = useCallback(() => { Alert.alert( "Request T3 Cloud access", - "Live Activity updates require approved T3 Cloud access so relay can deliver updates to this device.", + `${activityUpdatesDescription()} updates require approved T3 Cloud access so relay can deliver updates to this device.`, [ { text: "Cancel", style: "cancel" }, { text: "Continue", onPress: () => push("/settings/waitlist") }, @@ -223,8 +231,10 @@ function ConfiguredSettingsRouteScreen() { setLiveActivityStatus("disabled"); const error = squashAtomCommandFailure(tokenResult); Alert.alert( - "Live Activities unavailable", - error instanceof Error ? error.message : "Could not enable Live Activity updates.", + `${activityUpdatesDescription()} updates unavailable`, + error instanceof Error + ? error.message + : `Could not enable ${activityUpdatesLabel().toLowerCase()}.`, ); return; } @@ -248,8 +258,10 @@ function ConfiguredSettingsRouteScreen() { if (!isAtomCommandInterrupted(updateResult)) { const error = squashAtomCommandFailure(updateResult); Alert.alert( - "Live Activities unavailable", - error instanceof Error ? error.message : "Could not enable Live Activity updates.", + `${activityUpdatesDescription()} updates unavailable`, + error instanceof Error + ? error.message + : `Could not enable ${activityUpdatesLabel().toLowerCase()}.`, ); } return; @@ -258,10 +270,10 @@ function ConfiguredSettingsRouteScreen() { refreshManagedRelayEnvironments(); setLiveActivityStatus("enabled"); Alert.alert( - "Live Activities enabled", + `${activityUpdatesDescription()} updates enabled`, environmentCount > 0 - ? `${environmentCount} environment${environmentCount === 1 ? "" : "s"} linked for Live Activity updates.` - : "Live Activity updates are enabled. Add an environment to start receiving updates.", + ? `${environmentCount} environment${environmentCount === 1 ? "" : "s"} linked for ${activityUpdatesLabel().toLowerCase()}.` + : `${activityUpdatesDescription()} updates are enabled. Add an environment to start receiving updates.`, ); }, [connections, environmentCount, getToken, isSignedIn, promptSignIn]); @@ -274,7 +286,7 @@ function ConfiguredSettingsRouteScreen() { Alert.alert( "Disable notifications", - "Notification permission is controlled by iOS. Open Settings to disable notifications for T3 Code.", + "Notification permission is controlled by the system. Open Settings to disable notifications for T3 Code.", [ { text: "Cancel", style: "cancel" }, { text: "Open Settings", onPress: () => void Linking.openSettings() }, @@ -390,7 +402,7 @@ function ConfiguredSettingsRouteScreen() { !isLoaded || liveActivityStatus === "checking" || liveActivityStatus === "linking" } icon="bolt.circle" - label="Live Activity Updates" + label={activityUpdatesLabel()} value={liveActivityStatus === "enabled" || liveActivityStatus === "linking"} onValueChange={handleLiveActivitiesChange} /> diff --git a/apps/mobile/src/features/agent-awareness/androidNotifications.test.ts b/apps/mobile/src/features/agent-awareness/androidNotifications.test.ts new file mode 100644 index 00000000000..7783a20a6b2 --- /dev/null +++ b/apps/mobile/src/features/agent-awareness/androidNotifications.test.ts @@ -0,0 +1,107 @@ +import { beforeEach, describe, expect, it, vi } from "vite-plus/test"; +import * as Notifications from "expo-notifications"; +import { Platform } from "react-native"; + +import { + ANDROID_AGENT_ACTIVITY_ALERT_NOTIFICATION_CHANNEL_ID, + ANDROID_AGENT_ACTIVITY_NOTIFICATION_CHANNEL_ID, + __resetAndroidAgentActivityNotificationsForTest, + agentNotificationBehaviorForData, + configureAndroidAgentActivityNotificationHandling, + ensureAndroidAgentActivityNotificationChannels, +} from "./androidNotifications"; + +vi.mock("react-native", () => ({ + Platform: { + OS: "android", + }, +})); + +vi.mock("expo-notifications", () => ({ + AndroidImportance: { + DEFAULT: 5, + HIGH: 6, + }, + AndroidNotificationPriority: { + DEFAULT: "default", + HIGH: "high", + }, + AndroidNotificationVisibility: { + PUBLIC: 1, + }, + setNotificationChannelAsync: vi.fn(() => Promise.resolve(null)), + setNotificationHandler: vi.fn(), +})); + +describe("Android agent activity notifications", () => { + beforeEach(() => { + Object.assign(Platform, { OS: "android" }); + __resetAndroidAgentActivityNotificationsForTest(); + vi.clearAllMocks(); + }); + + it("configures ongoing and alert notification channels", async () => { + await ensureAndroidAgentActivityNotificationChannels(); + + expect(Notifications.setNotificationChannelAsync).toHaveBeenCalledWith( + ANDROID_AGENT_ACTIVITY_NOTIFICATION_CHANNEL_ID, + expect.objectContaining({ + name: "Agent Activity", + importance: Notifications.AndroidImportance.DEFAULT, + showBadge: false, + }), + ); + expect(Notifications.setNotificationChannelAsync).toHaveBeenCalledWith( + ANDROID_AGENT_ACTIVITY_ALERT_NOTIFICATION_CHANNEL_ID, + expect.objectContaining({ + name: "Agent Activity Alerts", + importance: Notifications.AndroidImportance.HIGH, + showBadge: true, + }), + ); + }); + + it("keeps running agent updates quiet in the foreground", () => { + expect( + agentNotificationBehaviorForData({ + environmentId: "env-1", + threadId: "thread-1", + phase: "running", + }), + ).toEqual({ + shouldShowBanner: false, + shouldShowList: true, + shouldPlaySound: false, + shouldSetBadge: false, + priority: Notifications.AndroidNotificationPriority.DEFAULT, + }); + }); + + it("surfaces waiting agent updates prominently in the foreground", () => { + expect( + agentNotificationBehaviorForData({ + deepLink: "/threads/env-1/thread-1", + phase: "waiting_for_input", + }), + ).toEqual({ + shouldShowBanner: true, + shouldShowList: true, + shouldPlaySound: true, + shouldSetBadge: false, + priority: Notifications.AndroidNotificationPriority.HIGH, + }); + }); + + it("registers the foreground handler once on Android only", () => { + configureAndroidAgentActivityNotificationHandling(); + configureAndroidAgentActivityNotificationHandling(); + + expect(Notifications.setNotificationHandler).toHaveBeenCalledTimes(1); + + Object.assign(Platform, { OS: "ios" }); + __resetAndroidAgentActivityNotificationsForTest(); + configureAndroidAgentActivityNotificationHandling(); + + expect(Notifications.setNotificationHandler).toHaveBeenCalledTimes(1); + }); +}); diff --git a/apps/mobile/src/features/agent-awareness/androidNotifications.ts b/apps/mobile/src/features/agent-awareness/androidNotifications.ts new file mode 100644 index 00000000000..87ddce3aa29 --- /dev/null +++ b/apps/mobile/src/features/agent-awareness/androidNotifications.ts @@ -0,0 +1,94 @@ +import * as Notifications from "expo-notifications"; +import { Platform } from "react-native"; + +import { extractAgentNotificationDataFromNotification } from "./notificationPayload"; + +export const ANDROID_AGENT_ACTIVITY_NOTIFICATION_CHANNEL_ID = "agent-activity"; +export const ANDROID_AGENT_ACTIVITY_ALERT_NOTIFICATION_CHANNEL_ID = "agent-activity-alerts"; + +export interface AndroidAgentActivityNotificationChannels { + readonly notificationChannelId: string; + readonly alertNotificationChannelId: string; +} + +const PROMINENT_AGENT_ACTIVITY_PHASES = new Set([ + "waiting_for_approval", + "waiting_for_input", + "failed", +]); + +let notificationHandlerConfigured = false; + +export function androidAgentActivityNotificationChannels(): AndroidAgentActivityNotificationChannels | null { + if (Platform.OS !== "android") { + return null; + } + return { + notificationChannelId: ANDROID_AGENT_ACTIVITY_NOTIFICATION_CHANNEL_ID, + alertNotificationChannelId: ANDROID_AGENT_ACTIVITY_ALERT_NOTIFICATION_CHANNEL_ID, + }; +} + +export async function ensureAndroidAgentActivityNotificationChannels(): Promise { + const channels = androidAgentActivityNotificationChannels(); + if (!channels) { + return null; + } + + await Notifications.setNotificationChannelAsync(channels.notificationChannelId, { + name: "Agent Activity", + description: "Ongoing T3 Code agent status updates.", + importance: Notifications.AndroidImportance.DEFAULT, + lockscreenVisibility: Notifications.AndroidNotificationVisibility.PUBLIC, + showBadge: false, + sound: null, + }); + await Notifications.setNotificationChannelAsync(channels.alertNotificationChannelId, { + name: "Agent Activity Alerts", + description: "Prominent T3 Code agent updates that need attention.", + importance: Notifications.AndroidImportance.HIGH, + lockscreenVisibility: Notifications.AndroidNotificationVisibility.PUBLIC, + showBadge: true, + sound: "default", + enableVibrate: true, + }); + + return channels; +} + +export function agentNotificationBehaviorForData( + data: Record | null, +): Notifications.NotificationBehavior { + const phase = typeof data?.phase === "string" ? data.phase : null; + const isAgentActivityNotification = + typeof data?.deepLink === "string" || + (typeof data?.environmentId === "string" && typeof data?.threadId === "string"); + const isProminent = + isAgentActivityNotification && phase !== null && PROMINENT_AGENT_ACTIVITY_PHASES.has(phase); + + return { + shouldShowBanner: isProminent || !isAgentActivityNotification, + shouldShowList: true, + shouldPlaySound: isProminent, + shouldSetBadge: false, + priority: isProminent + ? Notifications.AndroidNotificationPriority.HIGH + : Notifications.AndroidNotificationPriority.DEFAULT, + }; +} + +export function configureAndroidAgentActivityNotificationHandling(): void { + if (notificationHandlerConfigured || Platform.OS !== "android") { + return; + } + + notificationHandlerConfigured = true; + Notifications.setNotificationHandler({ + handleNotification: async (notification) => + agentNotificationBehaviorForData(extractAgentNotificationDataFromNotification(notification)), + }); +} + +export function __resetAndroidAgentActivityNotificationsForTest(): void { + notificationHandlerConfigured = false; +} diff --git a/apps/mobile/src/features/agent-awareness/notificationNavigation.test.ts b/apps/mobile/src/features/agent-awareness/notificationNavigation.test.ts index 2dd3ca03de2..58e516a503b 100644 --- a/apps/mobile/src/features/agent-awareness/notificationNavigation.test.ts +++ b/apps/mobile/src/features/agent-awareness/notificationNavigation.test.ts @@ -21,6 +21,25 @@ function responseWithData(data: Record, identifier = "notificat }; } +function androidResponseWithRemoteData( + data: Record, + identifier = "android-notification-1", +) { + return { + notification: { + request: { + identifier, + content: {}, + trigger: { + remoteMessage: { + data, + }, + }, + }, + }, + }; +} + afterEach(() => { vi.restoreAllMocks(); }); @@ -125,6 +144,17 @@ describe("extractAgentNotificationDeepLink", () => { ).toBe("/threads/env%201/thread%2F2"); }); + it("falls back to Android Firebase remote message data", () => { + expect( + extractAgentNotificationDeepLink( + androidResponseWithRemoteData({ + environmentId: "env 1", + threadId: "thread/2", + }), + ), + ).toBe("/threads/env%201/thread%2F2"); + }); + it("falls back to ids when explicit deep link is not an agent thread route", () => { expect( extractAgentNotificationDeepLink( diff --git a/apps/mobile/src/features/agent-awareness/notificationNavigation.ts b/apps/mobile/src/features/agent-awareness/notificationNavigation.ts index 18bb93d723e..c9817bb974e 100644 --- a/apps/mobile/src/features/agent-awareness/notificationNavigation.ts +++ b/apps/mobile/src/features/agent-awareness/notificationNavigation.ts @@ -2,6 +2,7 @@ import { useEffect, useRef } from "react"; import * as Notifications from "expo-notifications"; import { useRouter } from "expo-router"; +import { configureAndroidAgentActivityNotificationHandling } from "./androidNotifications"; import { routeAgentNotificationResponseOnce } from "./notificationPayload"; import { consumeLastAgentNotificationResponse } from "./notificationResponseConsumer"; @@ -10,6 +11,8 @@ export function useAgentNotificationNavigation(): void { const handledResponseIds = useRef(new Set()); useEffect(() => { + configureAndroidAgentActivityNotificationHandling(); + const handleResponse = (response: Notifications.NotificationResponse): void => { routeAgentNotificationResponseOnce({ handledResponseIds: handledResponseIds.current, diff --git a/apps/mobile/src/features/agent-awareness/notificationPayload.ts b/apps/mobile/src/features/agent-awareness/notificationPayload.ts index dc72e3d1bd2..0b54d38ab1a 100644 --- a/apps/mobile/src/features/agent-awareness/notificationPayload.ts +++ b/apps/mobile/src/features/agent-awareness/notificationPayload.ts @@ -1,11 +1,6 @@ -function dataFromNotificationResponse(response: unknown): Record | null { - if (typeof response !== "object" || response === null) { - return null; - } - const notification = (response as { readonly notification?: unknown }).notification; - if (typeof notification !== "object" || notification === null) { - return null; - } +export function extractAgentNotificationDataFromNotification( + notification: unknown, +): Record | null { const request = (notification as { readonly request?: unknown }).request; if (typeof request !== "object" || request === null) { return null; @@ -15,7 +10,33 @@ function dataFromNotificationResponse(response: unknown): Record) : null; + if (typeof data === "object" && data !== null) { + return data as Record; + } + + const trigger = (request as { readonly trigger?: unknown }).trigger; + if (typeof trigger !== "object" || trigger === null) { + return null; + } + const remoteMessage = (trigger as { readonly remoteMessage?: unknown }).remoteMessage; + if (typeof remoteMessage !== "object" || remoteMessage === null) { + return null; + } + const remoteData = (remoteMessage as { readonly data?: unknown }).data; + return typeof remoteData === "object" && remoteData !== null + ? (remoteData as Record) + : null; +} + +function dataFromNotificationResponse(response: unknown): Record | null { + if (typeof response !== "object" || response === null) { + return null; + } + const notification = (response as { readonly notification?: unknown }).notification; + if (typeof notification !== "object" || notification === null) { + return null; + } + return extractAgentNotificationDataFromNotification(notification); } function identifierFromNotificationResponse(response: unknown): string | null { diff --git a/apps/mobile/src/features/agent-awareness/notificationPermissions.ts b/apps/mobile/src/features/agent-awareness/notificationPermissions.ts index dc275774a50..901d6e72000 100644 --- a/apps/mobile/src/features/agent-awareness/notificationPermissions.ts +++ b/apps/mobile/src/features/agent-awareness/notificationPermissions.ts @@ -3,6 +3,8 @@ import * as Effect from "effect/Effect"; import * as Schema from "effect/Schema"; import { Platform } from "react-native"; +import { ensureAndroidAgentActivityNotificationChannels } from "./androidNotifications"; + export type NotificationPermissionResult = | { readonly type: "unsupported" } | { readonly type: "granted" } @@ -15,7 +17,7 @@ export class NotificationPermissionReadError extends Schema.TaggedErrorClass + Platform.OS === "android" + ? ensureAndroidAgentActivityNotificationChannels() + : Promise.resolve(null), + catch: (cause) => new NotificationPermissionRequestError({ cause }), +}); + export const requestAgentNotificationPermission: Effect.Effect< NotificationPermissionResult, NotificationPermissionReadError | NotificationPermissionRequestError > = Effect.gen(function* () { - if (Platform.OS !== "ios") { + if (!supportsAgentNotifications()) { return { type: "unsupported" }; } @@ -43,6 +57,7 @@ export const requestAgentNotificationPermission: Effect.Effect< catch: (cause) => new NotificationPermissionReadError({ cause }), }); if (existing.granted) { + yield* ensurePlatformNotificationSetup; return { type: "granted" }; } @@ -53,6 +68,7 @@ export const requestAgentNotificationPermission: Effect.Effect< const requested = yield* Effect.tryPromise({ try: () => Notifications.requestPermissionsAsync({ + android: {}, ios: { allowAlert: true, allowBadge: true, @@ -61,7 +77,10 @@ export const requestAgentNotificationPermission: Effect.Effect< }), catch: (cause) => new NotificationPermissionRequestError({ cause }), }); - return requested.granted - ? { type: "granted" } - : { type: "denied", canAskAgain: requested.canAskAgain }; + if (!requested.granted) { + return { type: "denied", canAskAgain: requested.canAskAgain }; + } + + yield* ensurePlatformNotificationSetup; + return { type: "granted" }; }); diff --git a/apps/mobile/src/features/agent-awareness/registrationPayload.ts b/apps/mobile/src/features/agent-awareness/registrationPayload.ts index a4e6fc3d6db..fb2221c576f 100644 --- a/apps/mobile/src/features/agent-awareness/registrationPayload.ts +++ b/apps/mobile/src/features/agent-awareness/registrationPayload.ts @@ -2,25 +2,39 @@ import type { RelayDeviceRegistrationRequest } from "@t3tools/contracts/relay"; import type { Preferences } from "../../lib/storage"; -export function makeRelayDeviceRegistrationRequest(input: { +interface BaseRelayDeviceRegistrationInput { readonly deviceId: string; readonly label: string; - readonly iosMajorVersion: number; readonly appVersion?: string; readonly pushToken?: string; - readonly pushToStartToken?: string; readonly notificationsEnabled: boolean; readonly preferences: Preferences; -}): RelayDeviceRegistrationRequest { +} + +type RelayDeviceRegistrationInput = BaseRelayDeviceRegistrationInput & + ( + | { + readonly platform: "ios"; + readonly iosMajorVersion: number; + readonly pushToStartToken?: string; + } + | { + readonly platform: "android"; + readonly androidApiLevel: number; + readonly notificationChannelId: string; + readonly alertNotificationChannelId: string; + } + ); + +export function makeRelayDeviceRegistrationRequest( + input: RelayDeviceRegistrationInput, +): RelayDeviceRegistrationRequest { const liveActivitiesEnabled = input.preferences.liveActivitiesEnabled !== false; - return { + const base = { deviceId: input.deviceId, label: input.label, - platform: "ios", - iosMajorVersion: input.iosMajorVersion, - appVersion: input.appVersion, ...(input.pushToken ? { pushToken: input.pushToken } : {}), - ...(input.pushToStartToken ? { pushToStartToken: input.pushToStartToken } : {}), + ...(input.appVersion ? { appVersion: input.appVersion } : {}), preferences: { liveActivitiesEnabled, notificationsEnabled: input.notificationsEnabled, @@ -30,4 +44,22 @@ export function makeRelayDeviceRegistrationRequest(input: { notifyOnFailure: true, }, }; + + switch (input.platform) { + case "ios": + return { + ...base, + platform: "ios", + iosMajorVersion: input.iosMajorVersion, + ...(input.pushToStartToken ? { pushToStartToken: input.pushToStartToken } : {}), + }; + case "android": + return { + ...base, + platform: "android", + androidApiLevel: input.androidApiLevel, + notificationChannelId: input.notificationChannelId, + alertNotificationChannelId: input.alertNotificationChannelId, + }; + } } diff --git a/apps/mobile/src/features/agent-awareness/remoteRegistration.test.ts b/apps/mobile/src/features/agent-awareness/remoteRegistration.test.ts index 7f97d7c718c..73b97c8d2bb 100644 --- a/apps/mobile/src/features/agent-awareness/remoteRegistration.test.ts +++ b/apps/mobile/src/features/agent-awareness/remoteRegistration.test.ts @@ -8,14 +8,19 @@ import Constants from "expo-constants"; import * as Effect from "effect/Effect"; import * as Exit from "effect/Exit"; import * as Layer from "effect/Layer"; -import { FetchHttpClient } from "effect/unstable/http"; import { ManagedRelay } from "@t3tools/client-runtime/relay"; +import { remoteHttpClientLayer } from "@t3tools/client-runtime/rpc"; +import { Platform } from "react-native"; import type { EnvironmentId } from "@t3tools/contracts"; import { verifyDpopProof } from "@t3tools/shared/dpop"; import type { SavedRemoteConnection } from "../../lib/connection"; import { cryptoLayer } from "../cloud/dpop"; import { managedRelayClientLayer } from "../cloud/managedRelayLayer"; +import { + ANDROID_AGENT_ACTIVITY_ALERT_NOTIFICATION_CHANNEL_ID, + ANDROID_AGENT_ACTIVITY_NOTIFICATION_CHANNEL_ID, +} from "./androidNotifications"; import { makeRelayDeviceRegistrationRequest } from "./registrationPayload"; import { AgentAwarenessOperationError, @@ -62,9 +67,17 @@ vi.mock("../../widgets/AgentActivity", () => ({ })); vi.mock("expo-notifications", () => ({ + AndroidImportance: { + DEFAULT: 5, + HIGH: 6, + }, + AndroidNotificationVisibility: { + PUBLIC: 1, + }, addPushTokenListener: vi.fn(() => ({ remove: vi.fn() })), getDevicePushTokenAsync: vi.fn(() => Promise.resolve({ type: "ios", data: "apns-token" })), getPermissionsAsync: vi.fn(() => Promise.resolve({ granted: true })), + setNotificationChannelAsync: vi.fn(() => Promise.resolve(null)), })); vi.mock("expo-crypto", () => ({ @@ -140,9 +153,30 @@ function savedConnection(): SavedRemoteConnection { }; } -const relayTestLayer = managedRelayClientLayer("https://relay.example.test").pipe( - Layer.provide(Layer.mergeAll(FetchHttpClient.layer, cryptoLayer)), -); +function requestUrl(request: RequestInfo | URL): string { + return request instanceof Request ? request.url : String(request); +} + +async function requestBodyJson( + request: RequestInfo | URL, + init: RequestInit | undefined, +): Promise { + if (request instanceof Request) { + return await request.clone().json(); + } + const body = init?.body; + if (typeof body === "string") { + return JSON.parse(body) as unknown; + } + return await new Request(String(request), init).json(); +} + +function relayTestLayer() { + const httpClientLayer = remoteHttpClientLayer((input, init) => globalThis.fetch(input, init)); + return managedRelayClientLayer("https://relay.example.test").pipe( + Layer.provide(Layer.mergeAll(httpClientLayer, cryptoLayer)), + ); +} const runBackgroundOperations = Effect.fn("TestRemoteRegistration.runBackgroundOperations")( function* () { @@ -174,8 +208,20 @@ describe("makeRelayDeviceRegistrationRequest", () => { vi.stubGlobal("__DEV__", false); secureStore.clear(); backgroundRuntime.pending.length = 0; + Object.assign(Platform, { OS: "ios", Version: "18.0" }); Constants.expoConfig!.extra = {}; __resetAgentAwarenessRemoteRegistrationForTest(); + vi.mocked(Notifications.addPushTokenListener).mockClear(); + vi.mocked(Notifications.addPushTokenListener).mockReturnValue({ remove: vi.fn() }); + vi.mocked(Notifications.getDevicePushTokenAsync).mockClear(); + vi.mocked(Notifications.getDevicePushTokenAsync).mockResolvedValue({ + type: "ios", + data: "apns-token", + }); + vi.mocked(Notifications.getPermissionsAsync).mockClear(); + vi.mocked(Notifications.getPermissionsAsync).mockResolvedValue({ granted: true } as never); + vi.mocked(Notifications.setNotificationChannelAsync).mockClear(); + vi.mocked(Notifications.setNotificationChannelAsync).mockResolvedValue(null); widgetMocks.getInstances.mockReset(); widgetMocks.getInstances.mockReturnValue([]); }); @@ -185,6 +231,7 @@ describe("makeRelayDeviceRegistrationRequest", () => { makeRelayDeviceRegistrationRequest({ deviceId: "device-1", label: "Julius's iPhone", + platform: "ios", iosMajorVersion: 18, appVersion: "1.0.0", pushToken: "apns-token", @@ -218,6 +265,7 @@ describe("makeRelayDeviceRegistrationRequest", () => { makeRelayDeviceRegistrationRequest({ deviceId: "device-1", label: "Julius's iPhone", + platform: "ios", iosMajorVersion: 18, appVersion: "1.0.0", pushToStartToken: "push-to-start-token", @@ -244,6 +292,42 @@ describe("makeRelayDeviceRegistrationRequest", () => { }); }); + it("constructs Android agent activity notification registrations", () => { + expect( + makeRelayDeviceRegistrationRequest({ + deviceId: "device-1", + label: "Julius's Pixel", + platform: "android", + androidApiLevel: 35, + appVersion: "1.0.0", + pushToken: "fcm-token", + notificationChannelId: ANDROID_AGENT_ACTIVITY_NOTIFICATION_CHANNEL_ID, + alertNotificationChannelId: ANDROID_AGENT_ACTIVITY_ALERT_NOTIFICATION_CHANNEL_ID, + notificationsEnabled: true, + preferences: { + liveActivitiesEnabled: true, + }, + }), + ).toEqual({ + deviceId: "device-1", + label: "Julius's Pixel", + platform: "android", + androidApiLevel: 35, + appVersion: "1.0.0", + pushToken: "fcm-token", + notificationChannelId: ANDROID_AGENT_ACTIVITY_NOTIFICATION_CHANNEL_ID, + alertNotificationChannelId: ANDROID_AGENT_ACTIVITY_ALERT_NOTIFICATION_CHANNEL_ID, + preferences: { + liveActivitiesEnabled: true, + notificationsEnabled: true, + notifyOnApproval: true, + notifyOnInput: true, + notifyOnCompletion: true, + notifyOnFailure: true, + }, + }); + }); + it("normalizes relay base URLs for APNs registration requests", () => { expect(normalizeAgentAwarenessRelayBaseUrl(" https://relay.example.test/// ")).toBe( "https://relay.example.test", @@ -265,7 +349,7 @@ describe("makeRelayDeviceRegistrationRequest", () => { expect(activity.getPushToken).toHaveBeenCalledTimes(2); expect(addPushTokenListener).toHaveBeenCalledTimes(1); - }).pipe(Effect.provide(relayTestLayer)); + }).pipe(Effect.provide(relayTestLayer())); }); it.effect("preserves Live Activity push-token lookup failures", () => { @@ -287,7 +371,7 @@ describe("makeRelayDeviceRegistrationRequest", () => { cause, message: "Agent awareness operation read-live-activity-push-token failed.", }); - }).pipe(Effect.provide(relayTestLayer)); + }).pipe(Effect.provide(relayTestLayer())); }); it.effect( @@ -301,7 +385,7 @@ describe("makeRelayDeviceRegistrationRequest", () => { return Effect.gen(function* () { expect(yield* registerLiveActivityPushToken({ activity: activity as never })).toBe(false); - }).pipe(Effect.provide(relayTestLayer)); + }).pipe(Effect.provide(relayTestLayer())); }, ); @@ -325,7 +409,7 @@ describe("makeRelayDeviceRegistrationRequest", () => { expect(activity.start).not.toHaveBeenCalled(); expect(activity.update).not.toHaveBeenCalled(); expect(activity.end).not.toHaveBeenCalled(); - }).pipe(Effect.provide(relayTestLayer)); + }).pipe(Effect.provide(relayTestLayer())); }, ); @@ -338,11 +422,11 @@ describe("makeRelayDeviceRegistrationRequest", () => { yield* refreshAgentAwarenessRegistration(); expect(Notifications.getDevicePushTokenAsync).toHaveBeenCalledTimes(1); - }).pipe(Effect.provide(relayTestLayer)); + }).pipe(Effect.provide(relayTestLayer())); }); it.effect("registers the APNs device when cloud auth becomes available", () => { - const fetchMock = vi.fn((request: RequestInfo | URL) => { + const fetchMock = vi.fn((request: RequestInfo | URL, _init?: RequestInit) => { const url = request instanceof Request ? request.url : String(request); return Promise.resolve( Response.json( @@ -395,7 +479,86 @@ describe("makeRelayDeviceRegistrationRequest", () => { nowEpochSeconds: proofIat(dpop), }), ).toMatchObject({ ok: true }); - }).pipe(Effect.provide(relayTestLayer)); + }).pipe(Effect.provide(relayTestLayer())); + }); + + it.effect("registers the Android FCM device when cloud auth becomes available", () => { + Object.assign(Platform, { OS: "android", Version: 35 }); + vi.mocked(Notifications.getDevicePushTokenAsync).mockResolvedValue({ + type: "android", + data: "fcm-token", + } as never); + const fetchMock = vi.fn((request: RequestInfo | URL, _init?: RequestInit) => { + const url = requestUrl(request); + return Promise.resolve( + Response.json( + url.endsWith("/v1/client/dpop-token") + ? { + 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", + } + : { ok: true }, + ), + ); + }); + vi.stubGlobal("fetch", fetchMock); + Constants.expoConfig!.extra = { + relay: { + url: "https://relay.example.test/", + }, + }; + + setAgentAwarenessRelayTokenProvider(() => Promise.resolve("clerk-token-user-a")); + + return Effect.gen(function* () { + yield* refreshAgentAwarenessRegistration(); + + expect(Notifications.setNotificationChannelAsync).toHaveBeenCalledWith( + ANDROID_AGENT_ACTIVITY_NOTIFICATION_CHANNEL_ID, + expect.objectContaining({ + name: "Agent Activity", + importance: Notifications.AndroidImportance.DEFAULT, + showBadge: false, + }), + ); + expect(Notifications.setNotificationChannelAsync).toHaveBeenCalledWith( + ANDROID_AGENT_ACTIVITY_ALERT_NOTIFICATION_CHANNEL_ID, + expect.objectContaining({ + name: "Agent Activity Alerts", + importance: Notifications.AndroidImportance.HIGH, + showBadge: true, + }), + ); + + const deviceCall = fetchMock.mock.calls.find(([request]) => + requestUrl(request as RequestInfo | URL).endsWith("/v1/mobile/devices"), + ); + expect(deviceCall).toBeDefined(); + if (!deviceCall) { + throw new Error("Missing relay device registration request."); + } + const body = yield* Effect.promise(() => + requestBodyJson(deviceCall[0] as RequestInfo | URL, deviceCall[1] as RequestInit), + ); + expect(body).toMatchObject({ + deviceId: "device-1", + label: "Android device", + platform: "android", + androidApiLevel: 35, + appVersion: "1.0.0", + pushToken: "fcm-token", + notificationChannelId: ANDROID_AGENT_ACTIVITY_NOTIFICATION_CHANNEL_ID, + alertNotificationChannelId: ANDROID_AGENT_ACTIVITY_ALERT_NOTIFICATION_CHANNEL_ID, + preferences: { + notificationsEnabled: true, + liveActivitiesEnabled: true, + }, + }); + expect(widgetMocks.getInstances).not.toHaveBeenCalled(); + }).pipe(Effect.provide(relayTestLayer())); }); it.effect("coalesces simultaneous sign-in and environment connection registrations", () => { @@ -429,7 +592,7 @@ describe("makeRelayDeviceRegistrationRequest", () => { return Effect.gen(function* () { yield* runBackgroundOperations(); expect(Notifications.getPermissionsAsync).toHaveBeenCalledTimes(1); - }).pipe(Effect.provide(relayTestLayer)); + }).pipe(Effect.provide(relayTestLayer())); }); it.effect("continues queued device registration after a failed auth lookup", () => { @@ -453,7 +616,7 @@ describe("makeRelayDeviceRegistrationRequest", () => { expect(backgroundRuntime.pending).toHaveLength(0); expect(tokenProvider).toHaveBeenCalledTimes(2); - }).pipe(Effect.provide(relayTestLayer)); + }).pipe(Effect.provide(relayTestLayer())); }); it("only registers again when the authenticated identity changes", () => { @@ -497,7 +660,7 @@ describe("makeRelayDeviceRegistrationRequest", () => { return Effect.gen(function* () { yield* runBackgroundOperations(); expect(Notifications.getDevicePushTokenAsync).toHaveBeenCalledTimes(1); - }).pipe(Effect.provide(relayTestLayer)); + }).pipe(Effect.provide(relayTestLayer())); }); it.effect( @@ -535,7 +698,7 @@ describe("makeRelayDeviceRegistrationRequest", () => { unregisterAgentAwarenessConnection(savedConnection().environmentId); expect(fetchMock).not.toHaveBeenCalled(); - }).pipe(Effect.provide(relayTestLayer)); + }).pipe(Effect.provide(relayTestLayer())); }, ); }); diff --git a/apps/mobile/src/features/agent-awareness/remoteRegistration.ts b/apps/mobile/src/features/agent-awareness/remoteRegistration.ts index 3281381e0e1..1f50f9aaba3 100644 --- a/apps/mobile/src/features/agent-awareness/remoteRegistration.ts +++ b/apps/mobile/src/features/agent-awareness/remoteRegistration.ts @@ -26,6 +26,11 @@ import { } from "../../lib/storage"; import AgentActivity, { type AgentActivityProps } from "../../widgets/AgentActivity"; import { resolveCloudPublicConfig } from "../cloud/publicConfig"; +import { + androidAgentActivityNotificationChannels, + ensureAndroidAgentActivityNotificationChannels, + type AndroidAgentActivityNotificationChannels, +} from "./androidNotifications"; import { makeRelayDeviceRegistrationRequest } from "./registrationPayload"; const REMOTE_ACTIVITY_REGISTRATION_RETRY_MS = 15_000; @@ -33,6 +38,7 @@ const REMOTE_ACTIVITY_REGISTRATION_RETRY_MS = 15_000; const AgentAwarenessOperation = Schema.Literals([ "read-notification-permissions", "read-native-push-token", + "configure-android-notification-channel", "read-device-registration-relay-token", "read-device-unregistration-relay-token", "read-live-activity-registration-relay-token", @@ -102,6 +108,21 @@ function canRegisterRemoteLiveActivities(): boolean { return Platform.OS === "ios"; } +function canRegisterAgentAwarenessDevice(): boolean { + return Platform.OS === "ios" || Platform.OS === "android"; +} + +function nativePushTokenType(): "ios" | "android" | null { + switch (Platform.OS) { + case "ios": + return "ios"; + case "android": + return "android"; + default: + return null; + } +} + export function shouldRegisterAgentAwarenessDeviceForProvider( previousIdentity: string | null, identity: string | undefined, @@ -155,13 +176,56 @@ function iosMajorVersion(): number { return Number.isFinite(major) ? major : 18; } +function androidApiLevel(): number { + const version = Platform.Version; + if (typeof version === "number") { + return Math.max(1, Math.floor(version)); + } + const apiLevel = Number.parseInt(version.split(".")[0] ?? "", 10); + return Number.isFinite(apiLevel) && apiLevel > 0 ? apiLevel : 1; +} + +function deviceRegistrationLabel(): string { + const deviceName = Constants.deviceName?.trim(); + if (deviceName) { + return deviceName; + } + return Platform.OS === "android" ? "Android device" : "iOS device"; +} + +function configureAndroidNotificationChannels(): Effect.Effect< + AndroidAgentActivityNotificationChannels | null, + AgentAwarenessOperationError +> { + return Effect.tryPromise({ + try: () => ensureAndroidAgentActivityNotificationChannels(), + catch: (cause) => + new AgentAwarenessOperationError({ + operation: "configure-android-notification-channel", + cause, + }), + }).pipe( + Effect.tapError((error) => + Effect.sync(() => { + logRegistrationError("Android agent activity notification channel setup failed", error); + }), + ), + Effect.orElseSucceed(() => androidAgentActivityNotificationChannels()), + ); +} + function nativePushTokenRegistration(observedPushToken?: string) { return Effect.gen(function* () { - if (!canRegisterRemoteLiveActivities()) { - return { notificationsEnabled: false, pushToken: null }; + const tokenType = nativePushTokenType(); + if (!tokenType) { + return { notificationsEnabled: false, pushToken: null, androidChannels: null }; } + + const androidChannels = + Platform.OS === "android" ? yield* configureAndroidNotificationChannels() : null; + if (observedPushToken) { - return { notificationsEnabled: true, pushToken: observedPushToken }; + return { notificationsEnabled: true, pushToken: observedPushToken, androidChannels }; } const permissions = yield* Effect.tryPromise({ try: () => Notifications.getPermissionsAsync(), @@ -172,7 +236,7 @@ function nativePushTokenRegistration(observedPushToken?: string) { }), }); if (!permissions.granted) { - return { notificationsEnabled: false, pushToken: null }; + return { notificationsEnabled: false, pushToken: null, androidChannels }; } const token = yield* Effect.tryPromise({ try: () => Notifications.getDevicePushTokenAsync(), @@ -184,16 +248,16 @@ function nativePushTokenRegistration(observedPushToken?: string) { }).pipe( Effect.tapError((error) => Effect.sync(() => { - logRegistrationError("native APNs token lookup failed", error); + logRegistrationError("native push token lookup failed", error); }), ), Effect.orElseSucceed(() => null), ); const pushToken = - token?.type === "ios" && typeof token.data === "string" && token.data.trim().length > 0 + token?.type === tokenType && typeof token.data === "string" && token.data.trim().length > 0 ? token.data.trim() : null; - return { notificationsEnabled: pushToken !== null, pushToken }; + return { notificationsEnabled: pushToken !== null, pushToken, androidChannels }; }); } @@ -415,7 +479,7 @@ function registerDevice( expectedGeneration = deviceRegistrationGeneration, ): Effect.Effect { return Effect.gen(function* () { - if (!canRegisterRemoteLiveActivities()) { + if (!canRegisterAgentAwarenessDevice()) { logRegistrationDebug("device registration skipped; platform does not support it"); return; } @@ -444,19 +508,30 @@ function registerDevice( expectedGeneration, notificationsEnabled: pushTokenRegistration.notificationsEnabled, }); - yield* registerDeviceWithRelay( - makeRelayDeviceRegistrationRequest({ - deviceId, - label: Constants.deviceName?.trim() || "iOS device", - iosMajorVersion: iosMajorVersion(), - appVersion: Constants.expoConfig?.version, - ...(pushTokenRegistration.pushToken ? { pushToken: pushTokenRegistration.pushToken } : {}), - ...(input?.pushToStartToken ? { pushToStartToken: input.pushToStartToken } : {}), - notificationsEnabled: pushTokenRegistration.notificationsEnabled, - preferences, - }), - expectedGeneration, - ); + const commonRegistrationInput = { + deviceId, + label: deviceRegistrationLabel(), + appVersion: Constants.expoConfig?.version, + ...(pushTokenRegistration.pushToken ? { pushToken: pushTokenRegistration.pushToken } : {}), + notificationsEnabled: pushTokenRegistration.notificationsEnabled, + preferences, + }; + const registrationRequest = + Platform.OS === "android" + ? makeRelayDeviceRegistrationRequest({ + ...commonRegistrationInput, + platform: "android", + androidApiLevel: androidApiLevel(), + ...((pushTokenRegistration.androidChannels ?? + androidAgentActivityNotificationChannels()) as AndroidAgentActivityNotificationChannels), + }) + : makeRelayDeviceRegistrationRequest({ + ...commonRegistrationInput, + platform: "ios", + iosMajorVersion: iosMajorVersion(), + ...(input?.pushToStartToken ? { pushToStartToken: input.pushToStartToken } : {}), + }); + yield* registerDeviceWithRelay(registrationRequest, expectedGeneration); }); } @@ -484,22 +559,26 @@ function ensurePushToStartListener(): void { } function ensurePushTokenListener(): void { - if (pushTokenSubscription || !canRegisterRemoteLiveActivities()) { + if (pushTokenSubscription || !canRegisterAgentAwarenessDevice()) { return; } pushTokenSubscription = Notifications.addPushTokenListener((token) => { - if (token.type === "ios" && typeof token.data === "string" && token.data.trim().length > 0) { + if ( + token.type === nativePushTokenType() && + typeof token.data === "string" && + token.data.trim().length > 0 + ) { enqueueDeviceRegistration( { observedPushToken: token.data.trim() }, - "native APNs token rotation registration failed", + "native push token rotation registration failed", ); } }); } export function registerAgentAwarenessConnection(connection: SavedRemoteConnection): void { - if (!canRegisterRemoteLiveActivities()) { + if (!canRegisterAgentAwarenessDevice()) { return; } @@ -507,10 +586,12 @@ export function registerAgentAwarenessConnection(connection: SavedRemoteConnecti ensurePushToStartListener(); ensurePushTokenListener(); enqueueDeviceRegistration({}, "device registration failed"); - runRegistrationInBackground( - refreshActiveLiveActivityRemoteRegistration(), - "active live activity registration after environment connection failed", - ); + if (canRegisterRemoteLiveActivities()) { + runRegistrationInBackground( + refreshActiveLiveActivityRemoteRegistration(), + "active live activity registration after environment connection failed", + ); + } } function removeAgentAwarenessConnection(environmentId: EnvironmentId): void { diff --git a/apps/web/src/components/clerk/MobileClientsUserProfilePage.logic.test.ts b/apps/web/src/components/clerk/MobileClientsUserProfilePage.logic.test.ts index fcc660e8305..222ee203a64 100644 --- a/apps/web/src/components/clerk/MobileClientsUserProfilePage.logic.test.ts +++ b/apps/web/src/components/clerk/MobileClientsUserProfilePage.logic.test.ts @@ -2,12 +2,16 @@ import type { RelayClientDeviceRecord } from "@t3tools/contracts/relay"; import { describe, expect, it } from "vite-plus/test"; import { + mobileClientActivityUpdatesLabel, mobileClientNotificationDetail, mobileClientPlatformLabel, mobileClientUpdatedAtLabel, } from "./MobileClientsUserProfilePage.logic"; -function device(overrides: Partial = {}): RelayClientDeviceRecord { +type IosDeviceRecord = Extract; +type AndroidDeviceRecord = Extract; + +function device(overrides: Partial = {}): IosDeviceRecord { return { deviceId: "device-1", label: "Julius’s iPhone", @@ -27,16 +31,46 @@ function device(overrides: Partial = {}): RelayClientDe }; } +function androidDevice(overrides: Partial = {}): AndroidDeviceRecord { + return { + deviceId: "device-2", + label: "Julius’s Pixel", + platform: "android", + androidApiLevel: 35, + appVersion: "1.2.3", + notificationChannelId: "agent-activity", + alertNotificationChannelId: "agent-activity-alerts", + notifications: { + enabled: true, + notifyOnApproval: true, + notifyOnInput: true, + notifyOnCompletion: true, + notifyOnFailure: true, + }, + liveActivities: { enabled: true }, + updatedAt: "2026-06-21T12:00:00.000Z", + ...overrides, + }; +} + describe("mobile client presentation", () => { it("describes the client platform and enabled notification events", () => { const client = device(); expect(mobileClientPlatformLabel(client)).toBe("iOS 18 · T3 Code 1.2.3"); + expect(mobileClientActivityUpdatesLabel(client)).toBe("Live Activities"); expect(mobileClientNotificationDetail(client)).toBe( "Alerts enabled for approvals, completions.", ); }); + it("describes Android agent activity clients", () => { + const client = androidDevice(); + + expect(mobileClientPlatformLabel(client)).toBe("Android API 35 · T3 Code 1.2.3"); + expect(mobileClientActivityUpdatesLabel(client)).toBe("Agent Activity"); + }); + it("distinguishes disabled notifications from an empty event selection", () => { expect( mobileClientNotificationDetail( @@ -60,6 +94,9 @@ describe("mobile client presentation", () => { it("handles missing app versions and invalid update timestamps", () => { expect(mobileClientPlatformLabel(device({ appVersion: null }))).toBe("iOS 18"); + expect( + mobileClientPlatformLabel(androidDevice({ androidApiLevel: null, appVersion: null })), + ).toBe("Android"); expect(mobileClientUpdatedAtLabel("not-a-date")).toBe("Update time unavailable"); }); }); diff --git a/apps/web/src/components/clerk/MobileClientsUserProfilePage.logic.ts b/apps/web/src/components/clerk/MobileClientsUserProfilePage.logic.ts index 5ca9595bef4..398ec0346f6 100644 --- a/apps/web/src/components/clerk/MobileClientsUserProfilePage.logic.ts +++ b/apps/web/src/components/clerk/MobileClientsUserProfilePage.logic.ts @@ -15,7 +15,19 @@ const NOTIFICATION_PREFERENCES = [ >; export function mobileClientPlatformLabel(device: RelayClientDeviceRecord): string { - return `iOS ${device.iosMajorVersion}${device.appVersion ? ` · T3 Code ${device.appVersion}` : ""}`; + const appVersion = device.appVersion ? ` · T3 Code ${device.appVersion}` : ""; + switch (device.platform) { + case "ios": + return `iOS ${device.iosMajorVersion}${appVersion}`; + case "android": + return device.androidApiLevel + ? `Android API ${device.androidApiLevel}${appVersion}` + : `Android${appVersion}`; + } +} + +export function mobileClientActivityUpdatesLabel(device: RelayClientDeviceRecord): string { + return device.platform === "android" ? "Agent Activity" : "Live Activities"; } export function mobileClientNotificationDetail(device: RelayClientDeviceRecord): string { diff --git a/apps/web/src/components/clerk/MobileClientsUserProfilePage.tsx b/apps/web/src/components/clerk/MobileClientsUserProfilePage.tsx index 26af10ba5b8..888d9a904bd 100644 --- a/apps/web/src/components/clerk/MobileClientsUserProfilePage.tsx +++ b/apps/web/src/components/clerk/MobileClientsUserProfilePage.tsx @@ -8,6 +8,7 @@ import { Button } from "../ui/button"; import { Empty, EmptyDescription, EmptyHeader, EmptyMedia, EmptyTitle } from "../ui/empty"; import { Skeleton } from "../ui/skeleton"; import { + mobileClientActivityUpdatesLabel, mobileClientNotificationDetail, mobileClientPlatformLabel, mobileClientUpdatedAtLabel, @@ -53,7 +54,7 @@ function MobileClientRow({ device }: { readonly device: RelayClientDeviceRecord />

@@ -96,8 +97,8 @@ function EmptyMobileClients() { No mobile clients - Sign in to T3 Code on your iPhone to register it for push notifications and Live - Activities. + Sign in to T3 Code on your phone to register it for push notifications and agent activity + updates. diff --git a/infra/relay/migrations/postgres/20260628000000_android_agent_activity_devices/migration.sql b/infra/relay/migrations/postgres/20260628000000_android_agent_activity_devices/migration.sql new file mode 100644 index 00000000000..25151b3a349 --- /dev/null +++ b/infra/relay/migrations/postgres/20260628000000_android_agent_activity_devices/migration.sql @@ -0,0 +1,4 @@ +ALTER TABLE "relay_mobile_devices" ALTER COLUMN "ios_major_version" DROP NOT NULL;--> statement-breakpoint +ALTER TABLE "relay_mobile_devices" ADD COLUMN "android_api_level" integer;--> statement-breakpoint +ALTER TABLE "relay_mobile_devices" ADD COLUMN "notification_channel_id" varchar(191);--> statement-breakpoint +ALTER TABLE "relay_mobile_devices" ADD COLUMN "alert_notification_channel_id" varchar(191); diff --git a/infra/relay/src/agentActivity/Devices.test.ts b/infra/relay/src/agentActivity/Devices.test.ts index 553899da178..51eb1a3ca39 100644 --- a/infra/relay/src/agentActivity/Devices.test.ts +++ b/infra/relay/src/agentActivity/Devices.test.ts @@ -9,14 +9,23 @@ import * as RelayDb from "../db.ts"; import { relayLiveActivities, relayMobileDevices } from "../persistence/schema.ts"; import * as Devices from "./Devices.ts"; -const registration: RelayDeviceRegistrationRequest = { - deviceId: "device-1" as RelayDeviceRegistrationRequest["deviceId"], +type IosDeviceRegistrationRequest = Extract< + RelayDeviceRegistrationRequest, + { readonly platform: "ios" } +>; +type AndroidDeviceRegistrationRequest = Extract< + RelayDeviceRegistrationRequest, + { readonly platform: "android" } +>; + +const registration: IosDeviceRegistrationRequest = { + deviceId: "device-1" as IosDeviceRegistrationRequest["deviceId"], label: "Julius's iPhone", platform: "ios", iosMajorVersion: 18, - appVersion: "1.0.0" as RelayDeviceRegistrationRequest["appVersion"], - pushToken: "apns-device-token" as RelayDeviceRegistrationRequest["pushToken"], - pushToStartToken: "push-to-start-token" as RelayDeviceRegistrationRequest["pushToStartToken"], + appVersion: "1.0.0" as IosDeviceRegistrationRequest["appVersion"], + pushToken: "apns-device-token" as IosDeviceRegistrationRequest["pushToken"], + pushToStartToken: "push-to-start-token" as IosDeviceRegistrationRequest["pushToStartToken"], preferences: { notificationsEnabled: true, liveActivitiesEnabled: true, @@ -27,6 +36,18 @@ const registration: RelayDeviceRegistrationRequest = { }, }; +const androidRegistration: AndroidDeviceRegistrationRequest = { + deviceId: "device-2" as AndroidDeviceRegistrationRequest["deviceId"], + label: "Julius's Pixel", + platform: "android", + androidApiLevel: 35, + appVersion: "1.0.0" as AndroidDeviceRegistrationRequest["appVersion"], + pushToken: "fcm-device-token" as AndroidDeviceRegistrationRequest["pushToken"], + notificationChannelId: "agent-activity", + alertNotificationChannelId: "agent-activity-alerts", + preferences: registration.preferences, +}; + describe("Devices", () => { it.effect("claims APNs tokens globally before upserting the current user device", () => { const calls: Array = []; @@ -115,6 +136,77 @@ describe("Devices", () => { ); }); + it.effect("registers Android FCM devices without claiming push-to-start tokens", () => { + const calls: Array = []; + const updateSets: Array> = []; + const insertedValues: Array> = []; + + const fakeDb = { + update: (table: unknown) => { + expect(table).toBe(relayMobileDevices); + calls.push("update"); + return { + set: (values: Record) => { + updateSets.push(values); + calls.push("update.set"); + return { + where: () => { + calls.push("update.where"); + return Effect.void; + }, + }; + }, + }; + }, + insert: (table: unknown) => { + expect(table).toBe(relayMobileDevices); + calls.push("insert"); + return { + values: (values: Record) => { + insertedValues.push(values); + calls.push("insert.values"); + return { + onConflictDoUpdate: () => { + calls.push("insert.onConflictDoUpdate"); + return Effect.void; + }, + }; + }, + }; + }, + } as unknown as RelayDb.RelayDb["Service"]; + + return Effect.gen(function* () { + const devices = yield* Devices.Devices; + yield* devices.register({ userId: "user-2", registration: androidRegistration }); + + expect(calls).toEqual([ + "update", + "update.set", + "update.where", + "insert", + "insert.values", + "insert.onConflictDoUpdate", + ]); + expect(updateSets).toEqual([expect.objectContaining({ pushToken: null })]); + expect(insertedValues).toEqual([ + expect.objectContaining({ + userId: "user-2", + deviceId: "device-2", + platform: "android", + iosMajorVersion: null, + androidApiLevel: 35, + pushToken: "fcm-device-token", + pushToStartToken: null, + notificationChannelId: "agent-activity", + alertNotificationChannelId: "agent-activity-alerts", + }), + ]); + }).pipe( + Effect.provide(Devices.layer.pipe(Layer.provide(Layer.succeed(RelayDb.RelayDb, fakeDb)))), + ); + }); + it.effect("unregisters APNs state only for the current user device", () => { const calls: Array = []; const deleteConditions: Array = []; @@ -179,7 +271,22 @@ describe("Devices", () => { label: "Julius's iPhone", platform: "ios" as const, iosMajorVersion: 18, + androidApiLevel: null, + appVersion: "1.0.0", + notificationChannelId: null, + alertNotificationChannelId: null, + preferences: registration.preferences, + updatedAt: "2026-06-01T00:00:00.000Z", + }, + { + deviceId: "device-2", + label: "Julius's Pixel", + platform: "android" as const, + iosMajorVersion: null, + androidApiLevel: 35, appVersion: "1.0.0", + notificationChannelId: "agent-activity", + alertNotificationChannelId: "agent-activity-alerts", preferences: registration.preferences, updatedAt: "2026-06-01T00:00:00.000Z", }, @@ -218,6 +325,26 @@ describe("Devices", () => { }, updatedAt: "2026-06-01T00:00:00.000Z", }, + { + deviceId: "device-2", + label: "Julius's Pixel", + platform: "android", + androidApiLevel: 35, + appVersion: "1.0.0", + notificationChannelId: "agent-activity", + alertNotificationChannelId: "agent-activity-alerts", + notifications: { + enabled: true, + notifyOnApproval: true, + notifyOnInput: true, + notifyOnCompletion: true, + notifyOnFailure: true, + }, + liveActivities: { + enabled: true, + }, + updatedAt: "2026-06-01T00:00:00.000Z", + }, ]); }).pipe( Effect.provide(Devices.layer.pipe(Layer.provide(Layer.succeed(RelayDb.RelayDb, fakeDb)))), diff --git a/infra/relay/src/agentActivity/Devices.ts b/infra/relay/src/agentActivity/Devices.ts index 86e3564d5be..f6399dcdd4c 100644 --- a/infra/relay/src/agentActivity/Devices.ts +++ b/infra/relay/src/agentActivity/Devices.ts @@ -100,7 +100,7 @@ export const make = Effect.gen(function* () { ), ) : Effect.void, - registration.pushToStartToken + registration.platform === "ios" && registration.pushToStartToken ? db .update(relayMobileDevices) .set({ pushToStartToken: null, updatedAt }) @@ -128,10 +128,17 @@ export const make = Effect.gen(function* () { deviceId: registration.deviceId, label: registration.label, platform: registration.platform, - iosMajorVersion: registration.iosMajorVersion, + iosMajorVersion: registration.platform === "ios" ? registration.iosMajorVersion : null, + androidApiLevel: + registration.platform === "android" ? registration.androidApiLevel : null, appVersion: registration.appVersion ?? null, pushToken: registration.pushToken ?? null, - pushToStartToken: registration.pushToStartToken ?? null, + pushToStartToken: + registration.platform === "ios" ? (registration.pushToStartToken ?? null) : null, + notificationChannelId: + registration.platform === "android" ? registration.notificationChannelId : null, + alertNotificationChannelId: + registration.platform === "android" ? registration.alertNotificationChannelId : null, preferencesJson: registration.preferences, createdAt: updatedAt, updatedAt, @@ -141,13 +148,19 @@ export const make = Effect.gen(function* () { set: { platform: registration.platform, label: registration.label, - iosMajorVersion: registration.iosMajorVersion, + iosMajorVersion: registration.platform === "ios" ? registration.iosMajorVersion : null, + androidApiLevel: + registration.platform === "android" ? registration.androidApiLevel : null, appVersion: registration.appVersion ?? null, pushToken: sql`coalesce(excluded.push_token, ${relayMobileDevices.pushToken})`, pushToStartToken: sql`coalesce( excluded.push_to_start_token, ${relayMobileDevices.pushToStartToken} )`, + notificationChannelId: + registration.platform === "android" ? registration.notificationChannelId : null, + alertNotificationChannelId: + registration.platform === "android" ? registration.alertNotificationChannelId : null, preferencesJson: registration.preferences, updatedAt, }, @@ -219,7 +232,10 @@ export const make = Effect.gen(function* () { label: relayMobileDevices.label, platform: relayMobileDevices.platform, iosMajorVersion: relayMobileDevices.iosMajorVersion, + androidApiLevel: relayMobileDevices.androidApiLevel, appVersion: relayMobileDevices.appVersion, + notificationChannelId: relayMobileDevices.notificationChannelId, + alertNotificationChannelId: relayMobileDevices.alertNotificationChannelId, preferences: relayMobileDevices.preferencesJson, updatedAt: relayMobileDevices.updatedAt, }) @@ -230,24 +246,40 @@ export const make = Effect.gen(function* () { (cause) => new DeviceListPersistenceError({ userId: input.userId, cause }), ), ); - return rows.map((row) => ({ - deviceId: row.deviceId, - label: row.label, - platform: row.platform, - iosMajorVersion: row.iosMajorVersion, - appVersion: row.appVersion, - notifications: { - enabled: row.preferences.notificationsEnabled, - notifyOnApproval: row.preferences.notifyOnApproval, - notifyOnInput: row.preferences.notifyOnInput, - notifyOnCompletion: row.preferences.notifyOnCompletion, - notifyOnFailure: row.preferences.notifyOnFailure, - }, - liveActivities: { - enabled: row.preferences.liveActivitiesEnabled, - }, - updatedAt: row.updatedAt, - })); + return rows.map((row) => { + const base = { + deviceId: row.deviceId, + label: row.label, + appVersion: row.appVersion, + notifications: { + enabled: row.preferences.notificationsEnabled, + notifyOnApproval: row.preferences.notifyOnApproval, + notifyOnInput: row.preferences.notifyOnInput, + notifyOnCompletion: row.preferences.notifyOnCompletion, + notifyOnFailure: row.preferences.notifyOnFailure, + }, + liveActivities: { + enabled: row.preferences.liveActivitiesEnabled, + }, + updatedAt: row.updatedAt, + }; + + if (row.platform === "android") { + return { + ...base, + platform: "android" as const, + androidApiLevel: row.androidApiLevel, + notificationChannelId: row.notificationChannelId, + alertNotificationChannelId: row.alertNotificationChannelId, + }; + } + + return { + ...base, + platform: "ios" as const, + iosMajorVersion: row.iosMajorVersion ?? 18, + }; + }); }), }); }); diff --git a/infra/relay/src/agentActivity/LiveActivities.ts b/infra/relay/src/agentActivity/LiveActivities.ts index 608ee0704ab..9c70117bd7b 100644 --- a/infra/relay/src/agentActivity/LiveActivities.ts +++ b/infra/relay/src/agentActivity/LiveActivities.ts @@ -214,7 +214,9 @@ export const make = Effect.gen(function* () { eq(relayLiveActivities.deviceId, relayMobileDevices.deviceId), ), ) - .where(eq(relayMobileDevices.userId, input.userId)) + .where( + and(eq(relayMobileDevices.userId, input.userId), eq(relayMobileDevices.platform, "ios")), + ) .pipe( Effect.flatMap((rows) => Effect.forEach( @@ -235,7 +237,20 @@ export const make = Effect.gen(function* () { { concurrency: "unbounded" }, ), ), - Effect.map((rows): ReadonlyArray => rows), + Effect.map( + (rows): ReadonlyArray => + rows.flatMap((row) => + row.platform === "ios" && row.ios_major_version !== null + ? [ + { + ...row, + platform: "ios" as const, + ios_major_version: row.ios_major_version, + }, + ] + : [], + ), + ), Effect.mapError( (cause) => new LiveActivityTargetListPersistenceError({ diff --git a/infra/relay/src/persistence/schema.ts b/infra/relay/src/persistence/schema.ts index ab3d2dfd97a..dc8f5ef3efa 100644 --- a/infra/relay/src/persistence/schema.ts +++ b/infra/relay/src/persistence/schema.ts @@ -21,11 +21,14 @@ export const relayMobileDevices = pgTable( userId: varchar("user_id", { length: 255 }).notNull(), deviceId: varchar("device_id", { length: 255 }).notNull(), label: text("label").notNull().default("iOS device"), - platform: varchar("platform", { length: 16 }).notNull().$type<"ios">(), - iosMajorVersion: integer("ios_major_version").notNull(), + platform: varchar("platform", { length: 16 }).notNull().$type<"ios" | "android">(), + iosMajorVersion: integer("ios_major_version"), + androidApiLevel: integer("android_api_level"), appVersion: varchar("app_version", { length: 64 }), pushToken: text("push_token"), pushToStartToken: text("push_to_start_token"), + notificationChannelId: varchar("notification_channel_id", { length: 191 }), + alertNotificationChannelId: varchar("alert_notification_channel_id", { length: 191 }), preferencesJson: jsonb("preferences_json").notNull().$type(), createdAt: varchar("created_at", { length: 64 }).notNull(), updatedAt: varchar("updated_at", { length: 64 }).notNull(), diff --git a/packages/client-runtime/src/relay/managedRelay.ts b/packages/client-runtime/src/relay/managedRelay.ts index 08b720b46a3..af7b71db14a 100644 --- a/packages/client-runtime/src/relay/managedRelay.ts +++ b/packages/client-runtime/src/relay/managedRelay.ts @@ -808,16 +808,24 @@ export const make = Effect.fn("ManagedRelayClient.make")(function* ( clerkToken: input.clerkToken, target: dpopProofTargets.registerDevice(), }, - (authorization) => - client.mobile - .registerDevice({ - headers: dpopHeaders(authorization), - payload: input.payload, - }) - .pipe( - Effect.mapError(relayRequestError("register relay mobile device")), - timeoutRelayRequest("Relay mobile device registration"), - ), + (authorization) => { + const payload = input.payload; + // Narrow the registration union before calling the generated endpoint overload. + const request = + payload.platform === "android" + ? client.mobile.registerDevice({ + headers: dpopHeaders(authorization), + payload, + }) + : client.mobile.registerDevice({ + headers: dpopHeaders(authorization), + payload, + }); + return request.pipe( + Effect.mapError(relayRequestError("register relay mobile device")), + timeoutRelayRequest("Relay mobile device registration"), + ); + }, ); }, Effect.withSpan("clientRuntime.managedRelay.registerDevice"), diff --git a/packages/contracts/src/relay.test.ts b/packages/contracts/src/relay.test.ts index 4ad600953b9..d7817219963 100644 --- a/packages/contracts/src/relay.test.ts +++ b/packages/contracts/src/relay.test.ts @@ -1,7 +1,13 @@ import { describe, expect, it } from "vite-plus/test"; +import * as Schema from "effect/Schema"; import * as OpenApi from "effect/unstable/httpapi/OpenApi"; -import { RelayApi } from "./relay.ts"; +import { RelayApi, RelayClientDeviceRecord, RelayDeviceRegistrationRequest } from "./relay.ts"; + +const decodeRelayDeviceRegistrationRequest = Schema.decodeUnknownSync( + RelayDeviceRegistrationRequest, +); +const decodeRelayClientDeviceRecord = Schema.decodeUnknownSync(RelayClientDeviceRecord); describe("RelayApi security", () => { it("describes DPoP access tokens using the HTTP DPoP authorization scheme", () => { @@ -14,3 +20,60 @@ describe("RelayApi security", () => { }); }); }); + +describe("Relay device schemas", () => { + it("accepts Android agent activity notification registration payloads", () => { + expect( + decodeRelayDeviceRegistrationRequest({ + deviceId: "device-1", + label: "Julius Pixel", + platform: "android", + androidApiLevel: 35, + appVersion: "1.0.0", + pushToken: "fcm-token", + notificationChannelId: "agent-activity", + alertNotificationChannelId: "agent-activity-alerts", + preferences: { + liveActivitiesEnabled: true, + notificationsEnabled: true, + notifyOnApproval: true, + notifyOnInput: true, + notifyOnCompletion: true, + notifyOnFailure: true, + }, + }), + ).toMatchObject({ + platform: "android", + notificationChannelId: "agent-activity", + alertNotificationChannelId: "agent-activity-alerts", + }); + }); + + it("accepts Android client device records", () => { + expect( + decodeRelayClientDeviceRecord({ + deviceId: "device-1", + label: "Julius Pixel", + platform: "android", + androidApiLevel: 35, + appVersion: null, + notificationChannelId: "agent-activity", + alertNotificationChannelId: "agent-activity-alerts", + notifications: { + enabled: true, + notifyOnApproval: true, + notifyOnInput: true, + notifyOnCompletion: true, + notifyOnFailure: true, + }, + liveActivities: { + enabled: true, + }, + updatedAt: "2026-06-01T00:00:00.000Z", + }), + ).toMatchObject({ + platform: "android", + androidApiLevel: 35, + }); + }); +}); diff --git a/packages/contracts/src/relay.ts b/packages/contracts/src/relay.ts index dea3709f488..4eb5b25cc7d 100644 --- a/packages/contracts/src/relay.ts +++ b/packages/contracts/src/relay.ts @@ -11,7 +11,7 @@ import * as OpenApi from "effect/unstable/httpapi/OpenApi"; import { EnvironmentId, ThreadId, TrimmedNonEmptyString } from "./baseSchemas.ts"; import { ExecutionEnvironmentDescriptor } from "./environment.ts"; -export const RelayAgentAwarenessPlatform = Schema.Literal("ios"); +export const RelayAgentAwarenessPlatform = Schema.Literals(["ios", "android"]); export type RelayAgentAwarenessPlatform = typeof RelayAgentAwarenessPlatform.Type; export const RelayAgentAwarenessPhase = Schema.Literals([ @@ -35,23 +35,41 @@ export const RelayAgentAwarenessPreferences = Schema.Struct({ }); export type RelayAgentAwarenessPreferences = typeof RelayAgentAwarenessPreferences.Type; -export const RelayDeviceRegistrationRequest = Schema.Struct({ +const RelayDeviceRegistrationRequestBase = { deviceId: TrimmedNonEmptyString, label: TrimmedNonEmptyString, - platform: RelayAgentAwarenessPlatform, - iosMajorVersion: Schema.Int.check(Schema.isGreaterThanOrEqualTo(18)), appVersion: Schema.optional(TrimmedNonEmptyString), pushToken: Schema.optional(TrimmedNonEmptyString), - pushToStartToken: Schema.optional(TrimmedNonEmptyString), preferences: RelayAgentAwarenessPreferences, +} as const; + +export const RelayIosDeviceRegistrationRequest = Schema.Struct({ + ...RelayDeviceRegistrationRequestBase, + platform: Schema.Literal("ios"), + iosMajorVersion: Schema.Int.check(Schema.isGreaterThanOrEqualTo(18)), + pushToStartToken: Schema.optional(TrimmedNonEmptyString), }); +export type RelayIosDeviceRegistrationRequest = typeof RelayIosDeviceRegistrationRequest.Type; + +export const RelayAndroidDeviceRegistrationRequest = Schema.Struct({ + ...RelayDeviceRegistrationRequestBase, + platform: Schema.Literal("android"), + androidApiLevel: Schema.Int.check(Schema.isGreaterThanOrEqualTo(1)), + notificationChannelId: TrimmedNonEmptyString, + alertNotificationChannelId: TrimmedNonEmptyString, +}); +export type RelayAndroidDeviceRegistrationRequest = + typeof RelayAndroidDeviceRegistrationRequest.Type; + +export const RelayDeviceRegistrationRequest = Schema.Union([ + RelayIosDeviceRegistrationRequest, + RelayAndroidDeviceRegistrationRequest, +]); export type RelayDeviceRegistrationRequest = typeof RelayDeviceRegistrationRequest.Type; -export const RelayClientDeviceRecord = Schema.Struct({ +const RelayClientDeviceRecordBase = { deviceId: TrimmedNonEmptyString, label: TrimmedNonEmptyString, - platform: RelayAgentAwarenessPlatform, - iosMajorVersion: Schema.Int.check(Schema.isGreaterThanOrEqualTo(18)), appVersion: Schema.NullOr(TrimmedNonEmptyString), notifications: Schema.Struct({ enabled: Schema.Boolean, @@ -64,7 +82,28 @@ export const RelayClientDeviceRecord = Schema.Struct({ enabled: Schema.Boolean, }), updatedAt: TrimmedNonEmptyString, +} as const; + +export const RelayClientIosDeviceRecord = Schema.Struct({ + ...RelayClientDeviceRecordBase, + platform: Schema.Literal("ios"), + iosMajorVersion: Schema.Int.check(Schema.isGreaterThanOrEqualTo(18)), +}); +export type RelayClientIosDeviceRecord = typeof RelayClientIosDeviceRecord.Type; + +export const RelayClientAndroidDeviceRecord = Schema.Struct({ + ...RelayClientDeviceRecordBase, + platform: Schema.Literal("android"), + androidApiLevel: Schema.NullOr(Schema.Int.check(Schema.isGreaterThanOrEqualTo(1))), + notificationChannelId: Schema.NullOr(TrimmedNonEmptyString), + alertNotificationChannelId: Schema.NullOr(TrimmedNonEmptyString), }); +export type RelayClientAndroidDeviceRecord = typeof RelayClientAndroidDeviceRecord.Type; + +export const RelayClientDeviceRecord = Schema.Union([ + RelayClientIosDeviceRecord, + RelayClientAndroidDeviceRecord, +]); export type RelayClientDeviceRecord = typeof RelayClientDeviceRecord.Type; export const RelayListDevicesResponse = Schema.Struct({