From e115116380d2d6c67db5eff8310a8fde996e87d8 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 4 Sep 2026 09:52:01 -0700 Subject: [PATCH 01/10] fix(mobile): remove provider setup (#9721) (cherry picked from commit 9eb4d71681dc7d002082db2f8b4bacf7614412f4) --- apps/mobile/src/Stack.tsx | 8 - .../connection/ConnectionEnvironmentRow.tsx | 21 - .../connection/ConnectionsRouteScreen.tsx | 6 - .../features/settings/ProviderSetupLink.tsx | 29 - .../SettingsEnvironmentsRouteScreen.tsx | 6 - .../SettingsProviderSetupRouteScreen.tsx | 574 ------------------ .../settings/provider-setup-state.test.ts | 159 ----- .../features/settings/provider-setup-state.ts | 76 --- docs/user/providers-antigravity.md | 26 +- 9 files changed, 12 insertions(+), 893 deletions(-) delete mode 100644 apps/mobile/src/features/settings/ProviderSetupLink.tsx delete mode 100644 apps/mobile/src/features/settings/SettingsProviderSetupRouteScreen.tsx delete mode 100644 apps/mobile/src/features/settings/provider-setup-state.test.ts delete mode 100644 apps/mobile/src/features/settings/provider-setup-state.ts diff --git a/apps/mobile/src/Stack.tsx b/apps/mobile/src/Stack.tsx index 7993f9924d..8b2fb3919b 100644 --- a/apps/mobile/src/Stack.tsx +++ b/apps/mobile/src/Stack.tsx @@ -52,7 +52,6 @@ import { NewTaskFlowProvider } from "./features/threads/new-task-flow-provider"; import { NewTaskRouteScreen } from "./features/threads/NewTaskRouteScreen"; import { SettingsAppearanceRouteScreen } from "./features/settings/SettingsAppearanceRouteScreen"; import { SettingsClientStorageRouteScreen } from "./features/settings/SettingsClientStorageRouteScreen"; -import { SettingsProviderSetupRouteScreen } from "./features/settings/SettingsProviderSetupRouteScreen"; import { SettingsAuthRouteScreen } from "./features/settings/SettingsAuthRouteScreen"; import { SettingsEnvironmentsRouteScreen } from "./features/settings/SettingsEnvironmentsRouteScreen"; import { SettingsLegalRouteScreen } from "./features/settings/SettingsLegalRouteScreen"; @@ -623,13 +622,6 @@ export const RootStack = createNativeStackNavigator({ sheetGrabberVisible: true, }, }), - SettingsProviderSetup: createNativeStackScreen({ - screen: SettingsProviderSetupRouteScreen, - linking: "providers/:environmentId/:instanceId", - options: { - title: "Antigravity", - }, - }), NewTaskSheet: createNativeStackScreen({ screen: NewTaskSheetStack, linking: "new", diff --git a/apps/mobile/src/features/connection/ConnectionEnvironmentRow.tsx b/apps/mobile/src/features/connection/ConnectionEnvironmentRow.tsx index fae61637cb..fc1748fe5d 100644 --- a/apps/mobile/src/features/connection/ConnectionEnvironmentRow.tsx +++ b/apps/mobile/src/features/connection/ConnectionEnvironmentRow.tsx @@ -5,7 +5,6 @@ import { connectionStatusText } from "@t3tools/client-runtime/connection"; import type { AtomCommandResult } from "@t3tools/client-runtime/state/runtime"; import { useAtomValue } from "@effect/atom-react"; import type { EnvironmentId, ProviderInstanceId } from "@t3tools/contracts"; -import { ProviderSetupLink } from "../settings/ProviderSetupLink"; import * as Cause from "effect/Cause"; import { AsyncResult } from "effect/unstable/reactivity"; import { useCallback, useState } from "react"; @@ -114,10 +113,6 @@ export function ConnectionEnvironmentRow(props: { environmentId: EnvironmentId, updates: { readonly label: string; readonly displayUrl: string }, ) => Promise>; - readonly onSetupProvider: (target: { - readonly environmentId: EnvironmentId; - readonly instanceId: ProviderInstanceId; - }) => void; }) { const [label, setLabel] = useState(props.environment.environmentLabel); const [url, setUrl] = useState(props.environment.displayUrl); @@ -262,22 +257,6 @@ export function ConnectionEnvironmentRow(props: { )} - {serverConfig?.providers - .filter((provider) => provider.setup?.canAuthenticate || provider.setup?.canInstall) - .map((provider) => ( - - props.onSetupProvider({ - environmentId: props.environment.environmentId, - instanceId: provider.instanceId, - }) - } - /> - ))} - diff --git a/apps/mobile/src/features/connection/ConnectionsRouteScreen.tsx b/apps/mobile/src/features/connection/ConnectionsRouteScreen.tsx index 88cb1bf5a3..88d4e2d4be 100644 --- a/apps/mobile/src/features/connection/ConnectionsRouteScreen.tsx +++ b/apps/mobile/src/features/connection/ConnectionsRouteScreen.tsx @@ -75,12 +75,6 @@ export function ConnectionsRouteScreen() { onReconnect={onReconnectEnvironment} onRemove={onRemoveEnvironmentPress} onUpdate={onUpdateEnvironment} - onSetupProvider={(target) => - navigation.navigate("SettingsProviderSetup", { - environmentId: target.environmentId, - instanceId: target.instanceId, - }) - } /> ))} diff --git a/apps/mobile/src/features/settings/ProviderSetupLink.tsx b/apps/mobile/src/features/settings/ProviderSetupLink.tsx deleted file mode 100644 index 6e402005bd..0000000000 --- a/apps/mobile/src/features/settings/ProviderSetupLink.tsx +++ /dev/null @@ -1,29 +0,0 @@ -import type { ServerProvider } from "@t3tools/contracts"; -import { Pressable } from "react-native"; - -import { AppText as Text } from "../../components/AppText"; -import { SymbolView } from "../../components/AppSymbol"; -import { ProviderIcon } from "../../components/ProviderIcon"; -import { providerNeedsSetup } from "./provider-setup-state"; - -export function ProviderSetupLink(props: { - readonly provider: ServerProvider; - readonly disabled?: boolean; - readonly onPress: () => void; -}) { - const label = props.provider.displayName ?? props.provider.driver; - return ( - - - - {providerNeedsSetup(props.provider) ? `Set up ${label}` : `Manage ${label}`} - - - - ); -} diff --git a/apps/mobile/src/features/settings/SettingsEnvironmentsRouteScreen.tsx b/apps/mobile/src/features/settings/SettingsEnvironmentsRouteScreen.tsx index 366d3b4001..793d265115 100644 --- a/apps/mobile/src/features/settings/SettingsEnvironmentsRouteScreen.tsx +++ b/apps/mobile/src/features/settings/SettingsEnvironmentsRouteScreen.tsx @@ -137,12 +137,6 @@ export function SettingsEnvironmentsRouteScreen() { onReconnect={onReconnectEnvironment} onRemove={onRemoveEnvironmentPress} onUpdate={handleUpdateEnvironment} - onSetupProvider={(target) => - navigation.navigate("SettingsProviderSetup", { - environmentId: target.environmentId, - instanceId: target.instanceId, - }) - } /> ))} diff --git a/apps/mobile/src/features/settings/SettingsProviderSetupRouteScreen.tsx b/apps/mobile/src/features/settings/SettingsProviderSetupRouteScreen.tsx deleted file mode 100644 index 61ca7297fd..0000000000 --- a/apps/mobile/src/features/settings/SettingsProviderSetupRouteScreen.tsx +++ /dev/null @@ -1,574 +0,0 @@ -import { useAtomValue } from "@effect/atom-react"; -import { useNavigation, type StaticScreenProps } from "@react-navigation/native"; -import type { AtomCommandResult } from "@t3tools/client-runtime/state/runtime"; -import { - ANTIGRAVITY_AUTH_METHODS, - AuthOrchestrationOperateScope, - EnvironmentId, - ProviderInstanceId, -} from "@t3tools/contracts"; -import * as Schema from "effect/Schema"; -import { AsyncResult } from "effect/unstable/reactivity"; -import { useCallback, useEffect, useRef, useState } from "react"; -import { Alert, Platform, Pressable, ScrollView, View } from "react-native"; -import { useSafeAreaInsets } from "react-native-safe-area-context"; - -import { AndroidScreenHeader } from "../../components/AndroidScreenHeader"; -import { AppText as Text, AppTextInput as TextInput } from "../../components/AppText"; -import { ProviderIcon } from "../../components/ProviderIcon"; -import { cn } from "../../lib/cn"; -import { tryCopyTextWithHaptic } from "../../lib/copyTextWithHaptic"; -import { tryOpenExternalUrl } from "../../lib/openExternalUrl"; -import { NativeStackScreenOptions } from "../../native/StackHeader"; -import { appAtomRegistry } from "../../state/atom-registry"; -import { useEnvironments } from "../../state/environments"; -import { useEnvironmentQuery } from "../../state/query"; -import { serverEnvironment } from "../../state/server"; -import { environmentSession } from "../../state/session"; -import { useAtomCommand } from "../../state/use-atom-command"; -import { - antigravityEnabledPatch, - readAntigravityAuthMethod, - resolveProviderSignInPresentation, -} from "./provider-setup-state"; - -export type ProviderSetupRouteParams = { - readonly environmentId: EnvironmentId; - readonly instanceId: ProviderInstanceId; -}; - -const PRIVATE_COMMAND_OPTIONS = { reportFailure: false, reportDefect: false } as const; -const isEnvironmentId = Schema.is(EnvironmentId); -const isProviderInstanceId = Schema.is(ProviderInstanceId); - -function SetupButton(props: { - readonly label: string; - readonly disabled?: boolean; - readonly primary?: boolean; - readonly destructive?: boolean; - readonly onPress: () => void; -}) { - return ( - - - {props.label} - - - ); -} - -/** The same screen stays inside Settings or the current model picker. */ -export function SettingsProviderSetupRouteScreen({ - route, -}: StaticScreenProps) { - if ( - !isEnvironmentId(route.params?.environmentId) || - !isProviderInstanceId(route.params?.instanceId) - ) { - return ( - - This provider link is not valid. - - ); - } - return ( - - ); -} - -function ProviderSetupScreen({ environmentId, instanceId }: ProviderSetupRouteParams) { - const navigation = useNavigation(); - const insets = useSafeAreaInsets(); - const { presentationById } = useEnvironments(); - const environment = presentationById.get(environmentId); - const environmentLabel = environment?.entry.target.label ?? "this environment"; - const isConnected = environment?.connection.phase === "connected"; - const config = useAtomValue(serverEnvironment.configValueAtom(environmentId)); - const provider = config?.providers.find((item) => item.instanceId === instanceId); - const access = useEnvironmentQuery(environmentSession.sessionStateAtom(environmentId)); - const canOperate = - access.data?.authenticated === true && - access.data.scopes?.includes(AuthOrchestrationOperateScope) === true; - const target = { environmentId, input: { instanceId } }; - const authQuery = useEnvironmentQuery( - canOperate && provider?.setup?.canAuthenticate - ? serverEnvironment.providerAuthState(target) - : null, - ); - const installQuery = useEnvironmentQuery( - canOperate && provider?.setup?.canInstall - ? serverEnvironment.providerInstallState(target) - : null, - ); - const auth = authQuery.data; - const installation = installQuery.data; - const startAuth = useAtomCommand(serverEnvironment.startProviderAuth, PRIVATE_COMMAND_OPTIONS); - const completeAuth = useAtomCommand( - serverEnvironment.completeProviderAuth, - PRIVATE_COMMAND_OPTIONS, - ); - const cancelAuth = useAtomCommand(serverEnvironment.cancelProviderAuth, PRIVATE_COMMAND_OPTIONS); - const logout = useAtomCommand(serverEnvironment.logoutProviderAuth, PRIVATE_COMMAND_OPTIONS); - const startInstall = useAtomCommand( - serverEnvironment.startProviderInstall, - PRIVATE_COMMAND_OPTIONS, - ); - const cancelInstall = useAtomCommand( - serverEnvironment.cancelProviderInstall, - PRIVATE_COMMAND_OPTIONS, - ); - const removeInstall = useAtomCommand( - serverEnvironment.removeProviderInstallation, - PRIVATE_COMMAND_OPTIONS, - ); - const updateSettings = useAtomCommand(serverEnvironment.updateSettings, PRIVATE_COMMAND_OPTIONS); - const [callbackUrl, setCallbackUrl] = useState(""); - const [error, setError] = useState(null); - const [busy, setBusy] = useState(false); - const busyRef = useRef(false); - const authActive = - auth?.phase === "starting" || auth?.phase === "waiting" || auth?.phase === "verifying"; - const installActive = - installation?.phase === "downloading" || - installation?.phase === "extracting" || - installation?.phase === "verifying"; - const controlsDisabled = busy || !isConnected || !canOperate; - const { - signedIn, - showSignOut, - message: authMessage, - } = resolveProviderSignInPresentation(provider, auth); - const authMethod = readAntigravityAuthMethod( - config?.settings.providerInstances[instanceId]?.config ?? - (instanceId === "antigravity" ? config?.settings.providers.antigravity : undefined), - ); - const usesBrowser = authMethod === "oauth-personal" || authMethod === "oauth-business"; - const methodLabel = - ANTIGRAVITY_AUTH_METHODS.find((method) => method.value === authMethod)?.label ?? - "Google account"; - - // Return URLs never enter saved drafts, navigation params, or diagnostics. - useEffect(() => { - setCallbackUrl(""); - }, [auth?.flowId, auth?.phase]); - - const perform = useCallback( - async (command: () => Promise>, failureMessage: string) => { - if (busyRef.current || !isConnected || !canOperate) return false; - busyRef.current = true; - setBusy(true); - setError(null); - try { - const result = await command(); - if (!AsyncResult.isSuccess(result)) { - setError(failureMessage); - return false; - } - return true; - } catch { - setError(failureMessage); - return false; - } finally { - busyRef.current = false; - setBusy(false); - } - }, - [canOperate, isConnected], - ); - - const setEnabled = (enabled: boolean) => { - const currentConfig = appAtomRegistry.get(serverEnvironment.configValueAtom(environmentId)); - const currentProvider = currentConfig?.providers.find((item) => item.instanceId === instanceId); - if (!currentConfig || !currentProvider) return; - const patch = antigravityEnabledPatch(currentConfig.settings, currentProvider, enabled); - if (!patch) return; - void perform( - () => updateSettings({ environmentId, input: { patch } }), - "Could not change this provider. Reconnect and try again.", - ); - }; - - const title = provider?.displayName ?? "Antigravity"; - return ( - - - {Platform.OS === "android" ? ( - navigation.goBack()} /> - ) : null} - - - - {environmentLabel} - - {!isConnected ? ( - - Reconnect to this environment to continue setup. - - ) : access.error ? ( - - Could not check your environment access. - - - ) : !access.data ? ( - Checking environment access. - ) : !canOperate ? ( - - This connection cannot manage providers. Pair again with permission to operate this - environment. - - ) : null} - {!config ? ( - Loading provider settings. - ) : !provider || provider.driver !== "antigravity" ? ( - - This provider is not available on this environment. - - ) : ( - <> - - - {provider.enabled ? "Enabled" : "Disabled"} - {provider.installed - ? `. Installed${provider.version ? ` ${provider.version}` : ""}` - : ". Not installed"} - - { - if (!provider.enabled) { - setEnabled(true); - return; - } - Alert.alert( - "Disable Antigravity?", - `This stops Antigravity sessions on ${environmentLabel}. Google credentials stay saved.`, - [ - { text: "Cancel", style: "cancel" }, - { text: "Disable", style: "destructive", onPress: () => setEnabled(false) }, - ], - ); - }} - /> - - {provider.setup?.canInstall ? ( - - {installActive ? ( - <> - - {installation.phase === "downloading" - ? `Downloading ${Math.round(installation.downloadedBytes / 1_048_576)}${installation.totalBytes === null ? "" : ` of ${Math.round(installation.totalBytes / 1_048_576)}`} MB` - : installation.phase === "extracting" - ? "Extracting Antigravity files." - : "Checking the Antigravity installation."} - - { - const operationId = installation.operationId; - if (!operationId) return; - void perform( - () => - cancelInstall({ environmentId, input: { instanceId, operationId } }), - "Could not cancel the install. Try again.", - ); - }} - /> - - ) : !provider.installed ? ( - - void perform( - () => startInstall(target), - "Could not start the install. Try again.", - ) - } - /> - ) : null} - {installation?.message ? ( - {installation.message} - ) : null} - {installation?.canRemove && !installActive ? ( - { - Alert.alert( - "Remove the Antigravity install?", - `This removes T3's managed install from ${environmentLabel}. Providers that use it will need it installed again. Google credentials and threads stay.`, - [ - { text: "Cancel", style: "cancel" }, - { - text: "Remove", - style: "destructive", - onPress: () => - void perform( - () => removeInstall(target), - "Could not remove the managed install. Try again.", - ), - }, - ], - ); - }} - /> - ) : null} - {installQuery.error ? ( - - Install status is unavailable. Reconnect and try again. - - ) : null} - - ) : !provider.installed ? ( - - Antigravity cannot be installed on this environment. Use a supported server host or - set an executable path in provider settings. - - ) : null} - {provider.setup?.canAuthenticate ? ( - - {methodLabel} - - {signedIn - ? "Signed in. Credentials stay on this environment." - : provider.auth.status === "unknown" - ? "Sign-in has not been checked." - : usesBrowser - ? "Sign in with the Google account you use for Antigravity." - : "Connect with the credentials set in provider settings on web or desktop."} - - {authActive ? ( - <> - - {auth.phase === "starting" - ? usesBrowser - ? "Starting Google sign-in." - : "Checking credentials." - : auth.phase === "verifying" - ? usesBrowser - ? "Checking Google sign-in." - : "Checking credentials." - : auth.authorizationUrl - ? "Complete sign-in in your browser." - : "Sign-in is open on another client. Complete it there or wait for it to expire."} - - {auth.authorizationUrl && auth.phase === "waiting" ? ( - <> - { - if (!auth.authorizationUrl) return; - void tryOpenExternalUrl(auth.authorizationUrl, "provider-auth").then( - (opened) => { - if (!opened) - setError( - "Could not open Google sign-in. Copy the link and open it in your browser.", - ); - }, - ); - }} - /> - { - if (!auth.authorizationUrl) return; - void tryCopyTextWithHaptic(auth.authorizationUrl, { - target: "provider-sign-in-link", - }).then((copied) => { - if (!copied) setError("Could not copy the sign-in link."); - }); - }} - /> - - After sign-in, the browser will open a 127.0.0.1 address that cannot load - on your phone. Copy that full address and paste it here. - - - { - const flowId = auth.flowId; - if (!flowId) return; - const submittedUrl = callbackUrl.trim(); - setCallbackUrl(""); - void perform( - () => - completeAuth({ - environmentId, - input: { instanceId, flowId, callbackUrl: submittedUrl }, - }), - "Could not complete Google sign-in. Check the return URL and try again.", - ); - }} - /> - - ) : null} - {auth.expiresAt ? ( - - Sign-in expires at{" "} - {new Date(auth.expiresAt).toLocaleTimeString([], { - hour: "numeric", - minute: "2-digit", - })} - . - - ) : null} - {auth.flowId ? ( - { - const flowId = auth.flowId; - if (!flowId) return; - setCallbackUrl(""); - void perform( - () => cancelAuth({ environmentId, input: { instanceId, flowId } }), - "Could not cancel sign-in. Try again.", - ); - }} - /> - ) : null} - - ) : showSignOut ? ( - { - Alert.alert( - usesBrowser ? "Sign out of Google?" : "Disconnect Antigravity?", - `This stops Antigravity sessions for ${title} on ${environmentLabel}. Threads and files stay.`, - [ - { text: "Cancel", style: "cancel" }, - { - text: "Sign out", - style: "destructive", - onPress: () => - void perform( - () => logout(target), - "Could not sign out of Google. Try again.", - ), - }, - ], - ); - }} - /> - ) : ( - - void perform( - () => startAuth(target), - "Could not start Google sign-in. Try again.", - ) - } - /> - )} - {!provider.enabled && !signedIn ? ( - Enable Antigravity to sign in. - ) : !provider.installed && !signedIn ? ( - Install Antigravity to sign in. - ) : null} - {authMessage ? {authMessage} : null} - {authQuery.error ? ( - - Sign-in status is unavailable. Reconnect and try again. - - ) : null} - - ) : null} - {provider.message ? ( - {provider.message} - ) : null} - - )} - {error ? ( - - {error} - - ) : null} - {busy ? ( - - Waiting for the environment. - - ) : null} - - - ); -} diff --git a/apps/mobile/src/features/settings/provider-setup-state.test.ts b/apps/mobile/src/features/settings/provider-setup-state.test.ts deleted file mode 100644 index 3f85b41f2c..0000000000 --- a/apps/mobile/src/features/settings/provider-setup-state.test.ts +++ /dev/null @@ -1,159 +0,0 @@ -import { - DEFAULT_SERVER_SETTINGS, - ProviderDriverKind, - ProviderInstanceId, - ServerProvider, - resolveProviderInstanceEnabled, -} from "@t3tools/contracts"; -import * as Schema from "effect/Schema"; -import { describe, expect, it } from "vite-plus/test"; - -import { antigravityEnabledPatch, resolveProviderSignInPresentation } from "./provider-setup-state"; - -const provider = Schema.decodeSync(ServerProvider)({ - instanceId: "antigravity", - driver: "antigravity", - enabled: false, - installed: false, - version: null, - status: "disabled", - auth: { status: "unauthenticated" }, - checkedAt: "2026-09-02T00:00:00.000Z", - models: [], -}); - -describe("resolveProviderSignInPresentation", () => { - const completedFlow = { phase: "succeeded", message: "Google sign-in is complete." } as const; - - it("offers sign-in after saved credentials expire, even if the old flow succeeded", () => { - expect( - resolveProviderSignInPresentation( - { - enabled: true, - auth: { status: "authenticated" }, - }, - completedFlow, - ), - ).toEqual({ - signedIn: true, - showSignOut: true, - message: completedFlow.message, - }); - - expect( - resolveProviderSignInPresentation( - { - enabled: true, - auth: { status: "unauthenticated" }, - }, - completedFlow, - ), - ).toEqual({ - signedIn: false, - showSignOut: false, - message: null, - }); - }); - - it("allows credential cleanup without claiming a disabled unknown account is signed in", () => { - expect( - resolveProviderSignInPresentation( - { - enabled: false, - auth: { status: "unknown" }, - }, - completedFlow, - ), - ).toEqual({ - signedIn: false, - showSignOut: true, - message: null, - }); - }); - - it("keeps progress while Google sign-in is still pending", () => { - const flow = { phase: "verifying", message: "Checking Google sign-in." } as const; - expect( - resolveProviderSignInPresentation( - { - enabled: true, - auth: { status: "unauthenticated" }, - }, - flow, - ), - ).toEqual({ - signedIn: false, - showSignOut: false, - message: flow.message, - }); - }); -}); - -describe("antigravityEnabledPatch", () => { - it("enables a legacy instance without losing its executable path or models", () => { - const settings = { - ...DEFAULT_SERVER_SETTINGS, - providers: { - ...DEFAULT_SERVER_SETTINGS.providers, - antigravity: { - ...DEFAULT_SERVER_SETTINGS.providers.antigravity, - enabled: false, - binaryPath: "/opt/google/agy-acp", - customModels: ["gemini-native"], - }, - }, - }; - const patch = antigravityEnabledPatch(settings, provider, true); - const instance = patch?.providerInstances?.[provider.instanceId]; - - expect(instance).toMatchObject({ - enabled: true, - config: { binaryPath: "/opt/google/agy-acp", customModels: ["gemini-native"] }, - }); - expect(instance?.config).not.toHaveProperty("enabled"); - expect(instance && resolveProviderInstanceEnabled(instance)).toBe(true); - expect(settings.providers.antigravity.enabled).toBe(false); - }); - - it("keeps separate accounts and environment overrides when enabling a custom instance", () => { - const workId = ProviderInstanceId.make("google_work"); - const personalId = ProviderInstanceId.make("google_personal"); - const personal = { driver: ProviderDriverKind.make("antigravity"), enabled: true }; - const settings = { - ...DEFAULT_SERVER_SETTINGS, - providerInstances: { - [personalId]: personal, - [workId]: { - driver: ProviderDriverKind.make("antigravity"), - displayName: "Work Google", - enabled: false, - config: { enabled: false, binaryPath: "/work/agy-acp", futureSetting: "keep" }, - environment: [{ name: "WORK_PROXY", value: "http://proxy", sensitive: false }], - }, - }, - }; - const patch = antigravityEnabledPatch(settings, { ...provider, instanceId: workId }, true); - - expect(patch?.providerInstances?.[personalId]).toBe(personal); - expect(patch?.providerInstances?.[workId]).toMatchObject({ - displayName: "Work Google", - enabled: true, - config: { binaryPath: "/work/agy-acp", futureSetting: "keep" }, - environment: [{ name: "WORK_PROXY", value: "http://proxy", sensitive: false }], - }); - expect(patch?.providers).toBeUndefined(); - }); - - it("does not change a different driver", () => { - expect( - antigravityEnabledPatch( - DEFAULT_SERVER_SETTINGS, - { - ...provider, - driver: ProviderDriverKind.make("codex"), - }, - true, - ), - ).toBeNull(); - }); -}); diff --git a/apps/mobile/src/features/settings/provider-setup-state.ts b/apps/mobile/src/features/settings/provider-setup-state.ts deleted file mode 100644 index 4bb9aebd8f..0000000000 --- a/apps/mobile/src/features/settings/provider-setup-state.ts +++ /dev/null @@ -1,76 +0,0 @@ -import { - ANTIGRAVITY_AUTH_METHODS, - type AntigravityAuthMethod, - DEFAULT_SERVER_SETTINGS, - type ProviderAuthState, - type ServerProvider, - type ServerSettings, - type ServerSettingsPatch, -} from "@t3tools/contracts"; - -/** Read the configured method from an instance config. Unknown values fall back to personal. */ -export function readAntigravityAuthMethod(config: unknown): AntigravityAuthMethod { - const value = - config !== null && typeof config === "object" && "authMethod" in config - ? config.authMethod - : undefined; - return ( - ANTIGRAVITY_AUTH_METHODS.find((method) => method.value === value)?.value ?? "oauth-personal" - ); -} - -/** A completed sign-in flow does not prove that saved credentials are still valid. */ -export function resolveProviderSignInPresentation( - provider: Pick | undefined, - flow: Pick | null, -) { - const signedIn = provider?.auth.status === "authenticated"; - return { - signedIn, - showSignOut: signedIn || (provider?.enabled === false && provider.auth.status === "unknown"), - message: flow?.phase === "succeeded" && !signedIn ? null : (flow?.message ?? null), - }; -} - -/** Keep one enabled flag when a legacy provider becomes an explicit instance. */ -export function antigravityEnabledPatch( - settings: ServerSettings, - provider: ServerProvider, - enabled: boolean, -): ServerSettingsPatch | null { - if (provider.driver !== "antigravity") return null; - - const { enabled: _legacyEnabled, ...legacyConfig } = settings.providers.antigravity; - const instance = settings.providerInstances[provider.instanceId] ?? { - driver: provider.driver, - config: legacyConfig, - }; - const config = - instance.config !== null && - typeof instance.config === "object" && - !Array.isArray(instance.config) - ? Object.fromEntries(Object.entries(instance.config).filter(([key]) => key !== "enabled")) - : instance.config; - - return { - ...(provider.instanceId === "antigravity" - ? { providers: { antigravity: DEFAULT_SERVER_SETTINGS.providers.antigravity } } - : {}), - providerInstances: { - ...settings.providerInstances, - [provider.instanceId]: { ...instance, enabled, config }, - }, - }; -} - -/** Setup remains available when the provider has no selectable models. */ -export function providerNeedsSetup(provider: ServerProvider): boolean { - return ( - (provider.setup?.canAuthenticate === true || provider.setup?.canInstall === true) && - (!provider.enabled || - !provider.installed || - provider.auth.status !== "authenticated" || - provider.availability === "unavailable" || - provider.models.length === 0) - ); -} diff --git a/docs/user/providers-antigravity.md b/docs/user/providers-antigravity.md index 9b67015d08..41ac4216e4 100644 --- a/docs/user/providers-antigravity.md +++ b/docs/user/providers-antigravity.md @@ -12,16 +12,13 @@ not sign in this Pylon provider. ## Set up Antigravity On web or desktop, open **Settings** > **Providers**, select the device that runs your project, -then select **Antigravity**. On mobile, open **Settings** > **Environments**, expand the -environment, then choose **Set up Antigravity**. An existing setup shows **Manage Antigravity**. -The model picker can open setup for the selected environment too. +then select **Antigravity**. Provider setup is not available in the mobile app. 1. Choose **Enable Antigravity**. Antigravity is off by default. 2. Choose **Install Antigravity**. Pylon downloads the official runtime from Google to that environment. Installation continues if you leave the page or reconnect. 3. Choose **Sign in with Google**. -4. Choose **Open sign-in page** on web or desktop, or **Open Google sign-in** on mobile. You can - use **Copy sign-in link** to open it in another browser. +4. Choose **Open sign-in page**. You can use **Copy sign-in link** to open it in another browser. 5. Complete Google sign-in. Use the account you use for Antigravity. 6. Wait for Pylon to confirm sign-in and load the model choices. Select an Antigravity model in the thread's model picker. @@ -36,8 +33,8 @@ machine as the Pylon environment, sign-in can finish directly. On a phone or another computer, the final page will usually fail to load. This is expected. Copy the full address from the browser, including everything after `?`, and paste it into the -return URL field in Pylon. Choose **Continue** on web or desktop, or **Complete sign-in** on -mobile. Do not change the address to your server's hostname. +return URL field in the web or desktop client where you started setup. Choose **Continue**. Do not +change the address to your server's hostname. Return to the same Pylon client and environment where you started sign-in. Another client can see that sign-in is in progress, but cannot complete that attempt. The setup screen shows @@ -50,7 +47,7 @@ callback page alone does not prove account access. ### Other sign-in methods **Sign-in method** in the Antigravity provider settings on web or desktop selects how the -agent authenticates. Mobile shows the selected method and its connect controls. +agent authenticates. | Method | What you enter | How it signs in | | -------------------------- | ---------------------------------------- | ------------------------------- | @@ -159,11 +156,11 @@ Each Antigravity provider instance has its own Google sign-in on its environment **Add provider** in web or desktop provider settings to create a separate instance for another account. To replace an instance's account, sign out first, then sign in again. -| Action | Result | -| -------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------- | -| Disable Antigravity | Stops the instance's sessions. Keeps Google sign-in, thread history, and files. | -| Sign out of Google | Stops the instance's sessions and removes its saved Google credentials. Keeps thread history and files. | -| Remove downloaded runtime on web or desktop, or Remove managed install on mobile | Removes the runtime shared by Antigravity instances on that environment. Keeps Google credentials, thread history, and files. | +| Action | Result | +| ------------------------- | ----------------------------------------------------------------------------------------------------------------------------- | +| Disable Antigravity | Stops the instance's sessions. Keeps Google sign-in, thread history, and files. | +| Sign out of Google | Stops the instance's sessions and removes its saved Google credentials. Keeps thread history and files. | +| Remove downloaded runtime | Removes the runtime shared by Antigravity instances on that environment. Keeps Google credentials, thread history, and files. | Send `/logout` by itself in an Antigravity thread to sign out that provider instance. This has the same effect as **Sign out of Google**, including stopping its other sessions. @@ -182,7 +179,8 @@ After an environment restarts, Google sign-in can show as not checked until an a session succeeds. To check account access and reload models, use **Refresh provider status** in web or desktop provider settings, or **Refresh models** in the mobile model picker. Refresh uses saved Google sign-in and does not open a login page. If sign-in is required, use the -provider's setup controls. Automatic status checks verify the installation only. +provider's setup controls on web or desktop. Automatic status checks verify the installation +only. The packaged runtime can be slow to start, especially on Windows. Health checks, model refresh, and sign-out each allow up to 90 seconds before reporting a timeout. From 2314be7dbef1143552b206c7f8ebe16ca81bf7b7 Mon Sep 17 00:00:00 2001 From: Trevor Walker Date: Thu, 10 Sep 2026 18:07:25 -0600 Subject: [PATCH 02/10] fix(mobile): configure iOS Keychain access group SecureStore keeps connection credentials in the iOS Keychain. Declare the bundle-scoped access group explicitly so pairing does not fail with a missing entitlement. The group uses $(AppIdentifierPrefix), which Xcode resolves from the signing team, so Pylon still pins no Apple team or T3 identifier. Adopted from 02443335b155e46248f96d80e037603abc742a61 (#3665) --- apps/mobile/app.config.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/apps/mobile/app.config.ts b/apps/mobile/app.config.ts index 4a05633373..ba88689ca7 100644 --- a/apps/mobile/app.config.ts +++ b/apps/mobile/app.config.ts @@ -243,6 +243,11 @@ const config: ExpoConfig = { ], } : {}), + // SecureStore keeps connection credentials in the Keychain. `$(AppIdentifierPrefix)` + // resolves to the signing team at build time, so no team ID is pinned here. + entitlements: { + "keychain-access-groups": [`$(AppIdentifierPrefix)${variant.iosBundleIdentifier}`], + }, infoPlist: { NSAppTransportSecurity: { NSAllowsArbitraryLoads: true, From f1fc1d928af54e02c211844b2c139586088030d8 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Tue, 8 Sep 2026 09:27:29 -0700 Subject: [PATCH 03/10] feat(mobile): use Android wallpaper colors (#10691) Co-authored-by: Pixel Perfect (cherry picked from commit 579266caa89238611ddb974ffebe9968c6a6e5d5) --- .../t3composereditor/T3ComposerEditorView.kt | 41 ++++++- apps/mobile/package.json | 1 + .../review/nativeReviewDiffAdapter.test.ts | 2 +- .../AppearancePreferencesProvider.tsx | 63 ++++++++++- .../sections/ThemeAppearanceSection.tsx | 27 +++-- .../settings/components/SettingsSwitchRow.tsx | 1 + .../src/features/terminal/terminalTheme.ts | 2 +- .../src/lib/materialYouPalette.android.ts | 14 +++ apps/mobile/src/lib/materialYouPalette.ts | 36 +++++++ apps/mobile/src/lib/materialYouTheme.test.ts | 65 +++++++++++ apps/mobile/src/lib/materialYouTheme.ts | 101 ++++++++++++++++++ apps/mobile/src/lib/mobileTheme.ts | 8 +- apps/mobile/src/lib/mobileThemeRuntime.ts | 4 +- apps/mobile/src/lib/mobileThemeVariables.ts | 2 +- apps/mobile/src/lib/storage.test.ts | 8 ++ apps/mobile/src/lib/useUniwindTheme.ts | 9 +- .../src/native/T3ComposerEditor.native.tsx | 3 + docs/user/mobile-appearance.md | 5 + pnpm-lock.yaml | 8 ++ 19 files changed, 375 insertions(+), 25 deletions(-) create mode 100644 apps/mobile/src/lib/materialYouPalette.android.ts create mode 100644 apps/mobile/src/lib/materialYouPalette.ts create mode 100644 apps/mobile/src/lib/materialYouTheme.test.ts create mode 100644 apps/mobile/src/lib/materialYouTheme.ts diff --git a/apps/mobile/modules/t3-composer-editor/android/src/main/java/expo/modules/t3composereditor/T3ComposerEditorView.kt b/apps/mobile/modules/t3-composer-editor/android/src/main/java/expo/modules/t3composereditor/T3ComposerEditorView.kt index 3010b52409..48423ef48f 100644 --- a/apps/mobile/modules/t3-composer-editor/android/src/main/java/expo/modules/t3composereditor/T3ComposerEditorView.kt +++ b/apps/mobile/modules/t3-composer-editor/android/src/main/java/expo/modules/t3composereditor/T3ComposerEditorView.kt @@ -7,11 +7,13 @@ import android.graphics.Canvas import android.graphics.Paint import android.graphics.RectF import android.graphics.Typeface +import android.os.Build import android.text.Editable import android.text.InputType import android.text.Spanned import android.text.TextWatcher import android.text.style.ReplacementSpan +import android.util.TypedValue import android.view.Gravity import android.view.ViewGroup import android.view.inputmethod.InputMethodManager @@ -27,6 +29,11 @@ class T3ComposerEditorView(context: Context, appContext: AppContext) : ExpoView( appContext ) { private val editor = SelectionAwareEditText(context) + private val defaultHighlightColor = editor.highlightColor + private val defaultSelectionColor = context.resolveThemeColor( + android.R.attr.colorAccent, + editor.currentTextColor, + ) private val onComposerChange by EventDispatcher() private val onComposerSelectionChange by EventDispatcher() private val onComposerFocus by EventDispatcher() @@ -152,6 +159,11 @@ class T3ComposerEditorView(context: Context, appContext: AppContext) : ExpoView( val theme = JSONObject(themeJson) editor.setTextColor(parseColor(theme.optString("text"), Color.BLACK)) editor.setHintTextColor(parseColor(theme.optString("placeholder"), Color.GRAY)) + if (theme.isNull("selection")) { + resetSelectionTheme() + } else { + applySelectionTheme(parseColor(theme.optString("selection"), editor.currentTextColor)) + } chipTheme = ComposerChipTheme( chipBackground = parseColor(theme.optString("chipBackground"), chipTheme.chipBackground), chipBorder = parseColor(theme.optString("chipBorder"), chipTheme.chipBorder), @@ -277,6 +289,21 @@ class T3ComposerEditorView(context: Context, appContext: AppContext) : ExpoView( editor.setLineSpacing(max(0, desiredLineHeightPx - fontHeight).toFloat(), 1f) } + private fun applySelectionTheme(color: Int) { + editor.highlightColor = color.withAlpha(0x52) + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) return + + editor.textCursorDrawable = editor.textCursorDrawable?.mutate()?.apply { setTint(color) } + editor.textSelectHandle?.mutate()?.setTint(color) + editor.textSelectHandleLeft?.mutate()?.setTint(color) + editor.textSelectHandleRight?.mutate()?.setTint(color) + } + + private fun resetSelectionTheme() { + applySelectionTheme(defaultSelectionColor) + editor.highlightColor = defaultHighlightColor + } + private fun currentSelectionPayload(): Map = mapOf( "start" to editor.selectionStart.coerceAtLeast(0), @@ -335,12 +362,24 @@ class T3ComposerEditorView(context: Context, appContext: AppContext) : ExpoView( private fun parseColor(value: String, fallback: Int): Int = try { - Color.parseColor(value) + val androidColor = if (value.length == 9 && value.startsWith("#")) { + "#${value.takeLast(2)}${value.substring(1, 7)}" + } else { + value + } + Color.parseColor(androidColor) } catch (_: Exception) { fallback } } +private fun Int.withAlpha(alpha: Int): Int = (this and 0x00FFFFFF) or (alpha shl 24) + +private fun Context.resolveThemeColor(attribute: Int, fallback: Int): Int { + val value = TypedValue() + return if (theme.resolveAttribute(attribute, value, true)) value.data else fallback +} + private data class ComposerToken( val type: String, val source: String, diff --git a/apps/mobile/package.json b/apps/mobile/package.json index 33ecd74090..c3d287b70d 100644 --- a/apps/mobile/package.json +++ b/apps/mobile/package.json @@ -51,6 +51,7 @@ "@expo/metro-runtime": "~57.0.14", "@expo/ui": "~57.0.14", "@legendapp/list": "catalog:", + "@material/material-color-utilities": "0.3.0", "@noble/curves": "catalog:", "@pierre/diffs": "catalog:", "@react-native-ai/apple": "0.12.0", diff --git a/apps/mobile/src/features/review/nativeReviewDiffAdapter.test.ts b/apps/mobile/src/features/review/nativeReviewDiffAdapter.test.ts index ffc4a71f7a..bca293b3e7 100644 --- a/apps/mobile/src/features/review/nativeReviewDiffAdapter.test.ts +++ b/apps/mobile/src/features/review/nativeReviewDiffAdapter.test.ts @@ -66,7 +66,7 @@ function filesPatch(paths: ReadonlyArray) { } function appTheme(themeId: MobileThemeId, appearance: MobileThemeAppearance) { - return themeId === DEFAULT_MOBILE_THEME_ID + return themeId === DEFAULT_MOBILE_THEME_ID || themeId === "material-you" ? readDefaultMobileThemeVariables(appearance) : getMobileThemeVariables(themeId, appearance); } diff --git a/apps/mobile/src/features/settings/appearance/AppearancePreferencesProvider.tsx b/apps/mobile/src/features/settings/appearance/AppearancePreferencesProvider.tsx index f161e65478..31b29a1dda 100644 --- a/apps/mobile/src/features/settings/appearance/AppearancePreferencesProvider.tsx +++ b/apps/mobile/src/features/settings/appearance/AppearancePreferencesProvider.tsx @@ -3,17 +3,19 @@ import { startTransition, use, useCallback, + useEffect, + useState, useLayoutEffect, useMemo, useRef, type ReactNode, } from "react"; -import { Appearance, useColorScheme } from "react-native"; +import { AppState, Appearance, useColorScheme } from "react-native"; import { useAtomSet, useAtomValue } from "@effect/atom-react"; import { AsyncResult } from "effect/unstable/reactivity"; -import { ScopedTheme, Uniwind } from "uniwind"; +import { ScopedTheme, ScopedVariables, Uniwind } from "uniwind"; import { resolveAppearance, @@ -22,6 +24,10 @@ import { } from "../../../lib/appearancePreferences"; import { mobilePreferencesAtom, updateMobilePreferencesAtom } from "../../../state/preferences"; import type { Preferences } from "../../../persistence/mobile-preferences"; +import { isSystemColorsAvailable, readSystemColorPalettes } from "../../../lib/materialYouPalette"; +import { materialYouPaletteToMobileThemeVariables } from "../../../lib/materialYouTheme"; +import { getMobileThemeRuntimeVariables } from "../../../lib/mobileThemeVariables"; +import type { MobileThemeVariables } from "../../../lib/mobileTheme"; import { createMobileThemePairPatch, createMobileThemeSelectionPatch, @@ -45,6 +51,13 @@ interface AppearancePreferencesContextValue { readonly themeIds: MobileThemeIds; readonly themeMode: MobileThemeMode; readonly themeAppearance: MobileThemeAppearance; + readonly systemColorsAvailable: boolean; + readonly systemColorsActive: boolean; + readonly themeVariables: MobileThemeVariables; + readonly themeVariablesByAppearance: Readonly< + Record + >; + readonly systemColorPalettes: ReturnType; readonly isReady: boolean; readonly setThemeIdForAppearance: ( appearance: MobileThemeAppearance, @@ -81,6 +94,39 @@ export function AppearancePreferencesProvider(props: { readonly children: ReactN [resolvedThemeIds.dark, resolvedThemeIds.light], ); const themeId = themeIds[themeAppearance]; + const systemColorsActive = themeId === "material-you" && isSystemColorsAvailable; + const [systemColorPalettes, setSystemColorPalettes] = useState(readSystemColorPalettes); + useEffect(() => { + if (!isSystemColorsAvailable) return; + const refresh = () => { + const next = readSystemColorPalettes(); + setSystemColorPalettes((previous) => + JSON.stringify(previous) === JSON.stringify(next) ? previous : next, + ); + }; + const subscription = AppState.addEventListener("change", (state) => { + if (state === "active") refresh(); + }); + const focusSubscription = AppState.addEventListener("focus", refresh); + return () => { + subscription.remove(); + focusSubscription.remove(); + }; + }, []); + const themeVariablesByAppearance = useMemo(() => { + const resolve = (appearance: MobileThemeAppearance) => { + const base = getMobileThemeRuntimeVariables(themeIds[appearance], appearance); + return themeIds[appearance] === "material-you" && systemColorPalettes + ? materialYouPaletteToMobileThemeVariables( + systemColorPalettes[appearance], + appearance, + base, + ) + : base; + }; + return { light: resolve("light"), dark: resolve("dark") }; + }, [themeIds, systemColorPalettes]); + const themeVariables = themeVariablesByAppearance[themeAppearance]; const activeThemeName = getMobileUniwindThemeName(themeId, themeAppearance); const { baseFontSize, codeFontSize, codeWordBreak, terminalFontSize } = preferences; const appearance = useMemo( @@ -233,6 +279,11 @@ export function AppearancePreferencesProvider(props: { readonly children: ReactN themeIds, themeMode, themeAppearance, + systemColorsAvailable: isSystemColorsAvailable, + systemColorsActive, + themeVariables, + themeVariablesByAppearance, + systemColorPalettes, isReady, setThemeIdForAppearance, setThemeIdForBothAppearances, @@ -248,6 +299,10 @@ export function AppearancePreferencesProvider(props: { readonly children: ReactN themeIds, themeMode, themeAppearance, + systemColorsActive, + themeVariables, + themeVariablesByAppearance, + systemColorPalettes, isReady, setThemeIdForAppearance, setThemeIdForBothAppearances, @@ -261,7 +316,9 @@ export function AppearancePreferencesProvider(props: { readonly children: ReactN return ( - {props.children} + + {props.children} + ); } diff --git a/apps/mobile/src/features/settings/appearance/sections/ThemeAppearanceSection.tsx b/apps/mobile/src/features/settings/appearance/sections/ThemeAppearanceSection.tsx index 2257115828..788d04b047 100644 --- a/apps/mobile/src/features/settings/appearance/sections/ThemeAppearanceSection.tsx +++ b/apps/mobile/src/features/settings/appearance/sections/ThemeAppearanceSection.tsx @@ -1,7 +1,7 @@ import { memo, useId } from "react"; import { Pressable, View } from "react-native"; import Svg, { Circle, Defs, RadialGradient, Stop } from "react-native-svg"; -import { ScopedTheme } from "uniwind"; +import { ScopedTheme, ScopedVariables } from "uniwind"; import { mixThemePreviewBase, THEME_PREVIEW_RENDER_SPECS } from "@t3tools/shared/themePreview"; @@ -38,7 +38,12 @@ const PreviewOrb = memo(function PreviewOrb(props: { const idPrefix = useId().replaceAll(":", ""); const accentGradientId = `${idPrefix}-accent-glow`; const actionGradientId = `${idPrefix}-action-glow`; - const colors = getMobileThemePreviewColors(props.themeId, props.appearance); + const { systemColorPalettes } = useAppearancePreferences(); + const palette = systemColorPalettes?.[props.appearance]; + const colors = + props.themeId === "material-you" && palette + ? { canvas: palette.surface, accent: palette.primary, messageAction: palette.tertiary } + : getMobileThemePreviewColors(props.themeId, props.appearance); const spec = THEME_PREVIEW_RENDER_SPECS[props.appearance]; const accentRadius = Math.hypot( Math.max(spec.accent.center[0], 1 - spec.accent.center[0]), @@ -204,15 +209,20 @@ function PreviewPane(props: { readonly compact?: boolean }) { } function ModePreview(props: { readonly mode: MobileThemeMode; readonly themeIds: MobileThemeIds }) { + const { themeVariablesByAppearance } = useAppearancePreferences(); if (props.mode === "system") { return ( - + + + - + + + @@ -224,7 +234,9 @@ function ModePreview(props: { readonly mode: MobileThemeMode; readonly themeIds: - + + + @@ -278,6 +290,7 @@ export function ThemeAppearanceSection() { setThemeMode, themeIds, themeMode, + systemColorsAvailable, } = useAppearancePreferences(); return ( @@ -302,7 +315,9 @@ export function ThemeAppearanceSection() { Themes - {MOBILE_THEME_OPTIONS.map((theme) => ( + {MOBILE_THEME_OPTIONS.filter( + (theme) => theme.id !== "material-you" || systemColorsAvailable, + ).map((theme) => ( candidate.id === themeId) ?? BUILT_IN_THEMES[0]; const palette = getThemeColorsForAppearance(theme, scheme) ?? theme.colors; diff --git a/apps/mobile/src/lib/materialYouPalette.android.ts b/apps/mobile/src/lib/materialYouPalette.android.ts new file mode 100644 index 0000000000..c88dbc3747 --- /dev/null +++ b/apps/mobile/src/lib/materialYouPalette.android.ts @@ -0,0 +1,14 @@ +import { getMaterialColors, isDynamicColorAvailable } from "@expo/ui/jetpack-compose"; + +import type { MaterialYouPalettes } from "./materialYouPalette"; + +export const isSystemColorsAvailable = isDynamicColorAvailable; + +export function readSystemColorPalettes(): MaterialYouPalettes | null { + if (!isDynamicColorAvailable) return null; + + return { + light: getMaterialColors({ scheme: "light" }), + dark: getMaterialColors({ scheme: "dark" }), + }; +} diff --git a/apps/mobile/src/lib/materialYouPalette.ts b/apps/mobile/src/lib/materialYouPalette.ts new file mode 100644 index 0000000000..e1e52c8fa7 --- /dev/null +++ b/apps/mobile/src/lib/materialYouPalette.ts @@ -0,0 +1,36 @@ +export interface MaterialYouPalette { + readonly primary: string; + readonly onPrimary: string; + readonly primaryContainer: string; + readonly onPrimaryContainer: string; + readonly inversePrimary: string; + readonly secondaryContainer: string; + readonly onSecondaryContainer: string; + readonly tertiary: string; + readonly tertiaryContainer: string; + readonly onTertiaryContainer: string; + readonly surface: string; + readonly onSurface: string; + readonly onSurfaceVariant: string; + readonly surfaceContainer: string; + readonly surfaceContainerHigh: string; + readonly surfaceContainerHighest: string; + readonly surfaceContainerLow: string; + readonly surfaceContainerLowest: string; + readonly errorContainer: string; + readonly onErrorContainer: string; + readonly outline: string; + readonly outlineVariant: string; + readonly scrim: string; +} + +export interface MaterialYouPalettes { + readonly light: MaterialYouPalette; + readonly dark: MaterialYouPalette; +} + +export const isSystemColorsAvailable = false; + +export function readSystemColorPalettes(): MaterialYouPalettes | null { + return null; +} diff --git a/apps/mobile/src/lib/materialYouTheme.test.ts b/apps/mobile/src/lib/materialYouTheme.test.ts new file mode 100644 index 0000000000..7d4676c06e --- /dev/null +++ b/apps/mobile/src/lib/materialYouTheme.test.ts @@ -0,0 +1,65 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { getMobileThemeRuntimeVariables } from "./mobileThemeVariables"; +import type { MaterialYouPalette } from "./materialYouPalette"; +import { materialYouPaletteToMobileThemeVariables } from "./materialYouTheme"; + +const palette: MaterialYouPalette = { + primary: "#6750A4FF", + onPrimary: "#FFFFFFFF", + primaryContainer: "#EADDFFFF", + onPrimaryContainer: "#21005DFF", + inversePrimary: "#D0BCFFFF", + secondaryContainer: "#E8DEF8FF", + onSecondaryContainer: "#1D192BFF", + tertiary: "#7D5260FF", + tertiaryContainer: "#FFD8E4FF", + onTertiaryContainer: "#31111DFF", + surface: "#FFFBFEFF", + onSurface: "#1C1B1FFF", + onSurfaceVariant: "#49454FFF", + surfaceContainer: "#F3EDF7FF", + surfaceContainerHigh: "#ECE6F0FF", + surfaceContainerHighest: "#E6E0E9FF", + surfaceContainerLow: "#F7F2FAFF", + surfaceContainerLowest: "#FFFFFFFF", + errorContainer: "#F9DEDCFF", + onErrorContainer: "#410E0BFF", + outline: "#79747EFF", + outlineVariant: "#CAC4D0FF", + scrim: "#000000FF", +}; + +describe("Material You system colors", () => { + it("overrides the selected theme without mutating its base variables", () => { + const base = getMobileThemeRuntimeVariables("t3-code", "dark"); + const snapshot = { ...base }; + + const variables = materialYouPaletteToMobileThemeVariables(palette, "dark", base); + + expect(base).toEqual(snapshot); + expect(variables["--color-screen"]).toBe(palette.surface); + expect(variables["--color-header"]).toBe(palette.surfaceContainerHigh); + expect(variables["--color-primary"]).toBe(palette.primary); + expect(variables["--color-placeholder"]).toBe("#1C1B1F9E"); + }); + + it("uses the Messages-style RCS tones for sent messages", () => { + const base = getMobileThemeRuntimeVariables("t3-code", "dark"); + const dark = materialYouPaletteToMobileThemeVariables( + { ...palette, inversePrimary: "#A31D8DFF" }, + "dark", + base, + ); + const light = materialYouPaletteToMobileThemeVariables( + { ...palette, inversePrimary: "#A31D8DFF" }, + "light", + getMobileThemeRuntimeVariables("t3-code", "light"), + ); + + expect(dark["--color-user-bubble"]).toBe("#850073FF"); + expect(dark["--color-user-bubble-foreground"]).toBe("#FFD7EFFF"); + expect(light["--color-user-bubble"]).toBe(palette.primary); + expect(light["--color-user-bubble-foreground"]).toBe(palette.onPrimary); + }); +}); diff --git a/apps/mobile/src/lib/materialYouTheme.ts b/apps/mobile/src/lib/materialYouTheme.ts new file mode 100644 index 0000000000..d4542743ce --- /dev/null +++ b/apps/mobile/src/lib/materialYouTheme.ts @@ -0,0 +1,101 @@ +import { Hct, argbFromHex, hexFromArgb } from "@material/material-color-utilities"; + +import type { MobileThemeAppearance, MobileThemeVariables } from "./mobileTheme"; +import type { MaterialYouPalette } from "./materialYouPalette"; + +function withAlpha(color: string, alpha: number): string { + const match = /^#([\da-f]{6})(?:[\da-f]{2})?$/iu.exec(color); + if (!match?.[1]) return color; + const alphaByte = Math.round(Math.min(1, Math.max(0, alpha)) * 255) + .toString(16) + .padStart(2, "0") + .toUpperCase(); + return `#${match[1]}${alphaByte}`; +} + +function materialTone(color: string, tone: number): string { + const match = /^#([\da-f]{6})(?:[\da-f]{2})?$/iu.exec(color); + if (!match?.[1]) return color; + + const source = Hct.fromInt(argbFromHex(match[1])); + return `${hexFromArgb(Hct.from(source.hue, source.chroma, tone).toInt()).toUpperCase()}FF`; +} + +export function materialYouPaletteToMobileThemeVariables( + palette: MaterialYouPalette, + appearance: MobileThemeAppearance, + base: MobileThemeVariables, +): MobileThemeVariables { + const dark = appearance === "dark"; + const userBubble = dark ? materialTone(palette.inversePrimary, 30) : palette.primary; + const userBubbleForeground = dark ? materialTone(palette.inversePrimary, 90) : palette.onPrimary; + + return { + ...base, + "--color-screen": palette.surface, + "--color-sheet": withAlpha(palette.surfaceContainerLow, 0.98), + "--color-sheet-solid": palette.surfaceContainerLow, + "--color-card": palette.surfaceContainer, + "--color-card-alt": palette.surfaceContainerHigh, + "--color-card-translucent": withAlpha(palette.surfaceContainer, 0.8), + "--color-foreground": palette.onSurface, + "--color-foreground-secondary": palette.onSurfaceVariant, + "--color-foreground-muted": palette.onSurfaceVariant, + "--color-foreground-tertiary": withAlpha(palette.onSurfaceVariant, 0.58), + "--color-border": withAlpha(palette.outlineVariant, dark ? 0.5 : 0.65), + "--color-border-subtle": withAlpha(palette.outlineVariant, dark ? 0.34 : 0.46), + "--color-separator": withAlpha(palette.outlineVariant, dark ? 0.24 : 0.34), + "--color-subtle": withAlpha(palette.onSurface, 0.05), + "--color-subtle-strong": withAlpha(palette.onSurface, 0.1), + "--color-inline-skill-background": withAlpha(palette.tertiaryContainer, 0.52), + "--color-inline-skill-border": withAlpha(palette.tertiary, 0.38), + "--color-inline-skill-foreground": palette.onTertiaryContainer, + "--color-primary": palette.primary, + "--color-primary-foreground": palette.onPrimary, + "--color-primary-shadow": withAlpha(palette.scrim, dark ? 0.22 : 0.18), + "--color-secondary": palette.secondaryContainer, + "--color-secondary-foreground": palette.onSecondaryContainer, + "--color-secondary-border": withAlpha(palette.outlineVariant, dark ? 0.42 : 0.56), + "--color-switch-active-track": palette.primary, + "--color-switch-active-thumb": palette.onPrimary, + "--color-switch-inactive-track": palette.surfaceContainerHighest, + "--color-switch-inactive-thumb": palette.outline, + "--color-danger": palette.errorContainer, + "--color-danger-border": withAlpha(palette.onErrorContainer, dark ? 0.42 : 0.5), + "--color-danger-foreground": palette.onErrorContainer, + "--color-input": palette.surfaceContainerLowest, + "--color-input-border": withAlpha(palette.outline, dark ? 0.42 : 0.5), + "--color-sidebar-search": palette.surfaceContainerLowest, + "--color-placeholder": withAlpha(palette.onSurface, 0.62), + "--color-icon": palette.onSurface, + "--color-icon-muted": palette.onSurfaceVariant, + "--color-icon-subtle": withAlpha(palette.onSurfaceVariant, 0.68), + "--color-header": palette.surfaceContainerHigh, + "--color-header-border": withAlpha(palette.outlineVariant, dark ? 0.36 : 0.48), + "--color-glass-surface": withAlpha(palette.surfaceContainerHigh, dark ? 0.78 : 0.72), + "--color-glass-tint": withAlpha(palette.primaryContainer, dark ? 0.24 : 0.18), + "--color-status-bar": palette.surfaceContainerLow, + "--color-md-body": materialTone(palette.onSurfaceVariant, dark ? 90 : 10), + "--color-md-strong": materialTone(palette.onSurfaceVariant, dark ? 90 : 10), + "--color-md-link": palette.primary, + "--color-md-blockquote-border": withAlpha(palette.primary, dark ? 0.34 : 0.26), + "--color-md-blockquote-bg": withAlpha(palette.primaryContainer, dark ? 0.14 : 0.2), + "--color-md-code-bg": withAlpha(palette.onSurface, dark ? 0.08 : 0.06), + "--color-md-code-text": palette.onSurface, + "--color-md-user-code-bg": withAlpha(userBubbleForeground, dark ? 0.18 : 0.22), + "--color-md-user-code-text": userBubbleForeground, + "--color-md-user-fence-bg": withAlpha(userBubbleForeground, dark ? 0.14 : 0.18), + "--color-md-user-fence-text": userBubbleForeground, + "--color-md-hr": withAlpha(palette.outlineVariant, dark ? 0.48 : 0.6), + "--color-user-bubble": userBubble, + "--color-user-bubble-foreground": userBubbleForeground, + "--color-user-bubble-foreground-muted": withAlpha(userBubbleForeground, 0.78), + "--color-user-bubble-skill-foreground": userBubbleForeground, + "--color-backdrop": withAlpha(palette.scrim, dark ? 0.48 : 0.22), + "--color-drawer": palette.surface, + "--color-drawer-shadow": withAlpha(palette.scrim, dark ? 0.32 : 0.12), + "--color-dot-separator": withAlpha(palette.onSurface, 0.2), + "--color-wordmark": palette.onSurface, + "--color-chevron": withAlpha(palette.onSurface, 0.2), + }; +} diff --git a/apps/mobile/src/lib/mobileTheme.ts b/apps/mobile/src/lib/mobileTheme.ts index 247377a30f..e070107b71 100644 --- a/apps/mobile/src/lib/mobileTheme.ts +++ b/apps/mobile/src/lib/mobileTheme.ts @@ -14,8 +14,8 @@ import { } from "@t3tools/shared/themePreview"; export const DEFAULT_MOBILE_THEME_ID = MOBILE_DEFAULT_THEME_ID; -export const MOBILE_THEME_IDS = SHARED_MOBILE_THEME_IDS; -export type MobileThemeId = SharedMobileThemeId; +export const MOBILE_THEME_IDS = [...SHARED_MOBILE_THEME_IDS, "material-you"] as const; +export type MobileThemeId = SharedMobileThemeId | "material-you"; export type MobileThemeAppearance = ThemeAppearance; export type MobileThemeMode = MobileThemeAppearance | "system"; export type MobileThemeIds = Readonly>; @@ -25,6 +25,7 @@ export const MOBILE_THEME_OPTIONS: ReadonlyArray<{ readonly label: string; }> = [ { id: DEFAULT_MOBILE_THEME_ID, label: "Pylon" }, + { id: "material-you", label: "Material You" }, ...BUILT_IN_THEMES.map((theme) => ({ id: theme.id as MobileThemeId, label: theme.label })), ]; @@ -303,7 +304,8 @@ export function getMobileThemePreviewColors( themeId: MobileThemeId, appearance: MobileThemeAppearance, ): ThemePreviewColors { - if (themeId === DEFAULT_MOBILE_THEME_ID) return STANDARD_THEME_PREVIEW_COLORS[appearance]; + if (themeId === DEFAULT_MOBILE_THEME_ID || themeId === "material-you") + return STANDARD_THEME_PREVIEW_COLORS[appearance]; const theme = BUILT_IN_THEMES.find((candidate) => candidate.id === themeId) ?? BUILT_IN_THEMES[0]; const colors = getThemeColorsForAppearance(theme, appearance) ?? theme.colors; return { diff --git a/apps/mobile/src/lib/mobileThemeRuntime.ts b/apps/mobile/src/lib/mobileThemeRuntime.ts index 0c30de6bd4..a777b2929f 100644 --- a/apps/mobile/src/lib/mobileThemeRuntime.ts +++ b/apps/mobile/src/lib/mobileThemeRuntime.ts @@ -42,7 +42,9 @@ export function getMobileUniwindThemeName( themeId: MobileThemeId, appearance: MobileThemeAppearance, ): MobileUniwindThemeName { - return themeId === DEFAULT_MOBILE_THEME_ID ? appearance : `${themeId}-${appearance}`; + return themeId === DEFAULT_MOBILE_THEME_ID || themeId === "material-you" + ? appearance + : `${themeId}-${appearance}`; } /** diff --git a/apps/mobile/src/lib/mobileThemeVariables.ts b/apps/mobile/src/lib/mobileThemeVariables.ts index 79a479b4fe..30c38e6bf4 100644 --- a/apps/mobile/src/lib/mobileThemeVariables.ts +++ b/apps/mobile/src/lib/mobileThemeVariables.ts @@ -21,7 +21,7 @@ export function getMobileThemeRuntimeVariables( themeId: MobileThemeId, appearance: MobileThemeAppearance, ): MobileThemeVariables { - return themeId === DEFAULT_MOBILE_THEME_ID + return themeId === DEFAULT_MOBILE_THEME_ID || themeId === "material-you" ? defaults[appearance] : getMobileThemeVariables(themeId, appearance); } diff --git a/apps/mobile/src/lib/storage.test.ts b/apps/mobile/src/lib/storage.test.ts index 15e385067d..52202bdc5e 100644 --- a/apps/mobile/src/lib/storage.test.ts +++ b/apps/mobile/src/lib/storage.test.ts @@ -196,6 +196,14 @@ describe("mobile connection storage", () => { }); }); + it("persists Material You independently for each appearance", async () => { + const themes = { lightThemeId: "material-you", darkThemeId: "ocean" } as const; + await savePreferencesPatch(themes); + await expect(loadPreferences()).resolves.toEqual(themes); + await savePreferencesPatch({ lightThemeId: "t3-chat" }); + await expect(loadPreferences()).resolves.toEqual({ ...themes, lightThemeId: "t3-chat" }); + }); + it("drops the removed theme transition preference", async () => { mocks.setPreferencesJson(JSON.stringify({ themeTransition: "circle-bottom-left" }), 10); diff --git a/apps/mobile/src/lib/useUniwindTheme.ts b/apps/mobile/src/lib/useUniwindTheme.ts index 06c50c859a..b76dfca96b 100644 --- a/apps/mobile/src/lib/useUniwindTheme.ts +++ b/apps/mobile/src/lib/useUniwindTheme.ts @@ -1,8 +1,5 @@ -import { useMemo } from "react"; - import { useAppearancePreferences } from "../features/settings/appearance/AppearancePreferencesProvider"; import type { MobileThemeVariables } from "./mobileTheme"; -import { getMobileThemeRuntimeVariables } from "./mobileThemeVariables"; /** * Complete JS palette for native and third-party APIs that cannot consume a @@ -13,9 +10,5 @@ import { getMobileThemeRuntimeVariables } from "./mobileThemeVariables"; * ScopedTheme instead of subscribing every consumer to CSS-variable updates. */ export function useUniwindTheme(): MobileThemeVariables { - const { themeAppearance, themeId } = useAppearancePreferences(); - return useMemo( - () => getMobileThemeRuntimeVariables(themeId, themeAppearance), - [themeAppearance, themeId], - ); + return useAppearancePreferences().themeVariables; } diff --git a/apps/mobile/src/native/T3ComposerEditor.native.tsx b/apps/mobile/src/native/T3ComposerEditor.native.tsx index 1a488d34f0..129e1af2a9 100644 --- a/apps/mobile/src/native/T3ComposerEditor.native.tsx +++ b/apps/mobile/src/native/T3ComposerEditor.native.tsx @@ -18,6 +18,7 @@ import { resolveMarkdownFileIcon } from "@t3tools/mobile-markdown-text/links"; import { MOBILE_TYPOGRAPHY } from "../lib/typography"; import { useNativePaste } from "../lib/useNativePaste"; import { useFontFamily } from "../lib/useFontFamily"; +import { useAppearancePreferences } from "../features/settings/appearance/AppearancePreferencesProvider"; import { useUniwindTheme } from "../lib/useUniwindTheme"; import { acknowledgeComposerNativeEvent, @@ -211,7 +212,9 @@ export function ComposerEditor({ }, [], ); + const { systemColorsActive } = useAppearancePreferences(); const themeJson = JSON.stringify({ + selection: systemColorsActive ? theme["--color-primary"] : null, text: theme["--color-foreground"], placeholder: theme["--color-placeholder"], chipBackground: theme["--color-subtle"], diff --git a/docs/user/mobile-appearance.md b/docs/user/mobile-appearance.md index 1e1dad5597..8d8a9d2979 100644 --- a/docs/user/mobile-appearance.md +++ b/docs/user/mobile-appearance.md @@ -17,5 +17,10 @@ To change themes: Tap a theme card to use it for both light and dark appearance. To mix themes, tap the light or dark preview circle inside a card to change only that appearance. +On Android 12 or newer, choose the **Material You** theme to use colors from your wallpaper. Selecting +another theme replaces those colors. Like other themes, Material You can be selected separately for +light and dark appearances. If you change your wallpaper, the colors update when you return to +Pylon. + **System** follows the device appearance automatically. Theme, text, code, and terminal appearance preferences are stored on the device. diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 601eb11471..b69f2b3518 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -237,6 +237,9 @@ importers: '@legendapp/list': specifier: 'catalog:' version: 3.3.5(patch_hash=bbc9bd2c43392aacabc7a7a31d94fec0510cdf9fc53a55402b1f74d5a15160e5)(react-dom@19.2.3(react@19.2.3))(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) + '@material/material-color-utilities': + specifier: 0.3.0 + version: 0.3.0 '@noble/curves': specifier: 'catalog:' version: 1.9.1 @@ -3211,6 +3214,9 @@ packages: resolution: {integrity: sha512-9QOtNffcOF/c1seMCDnjckb3R9WHcG34tky+FHpNKKCW0wc/scYLwMtO+ptyGUfMW0/b/n4qRiALlaFHc9Oj7Q==} engines: {node: '>= 10.0.0'} + '@material/material-color-utilities@0.3.0': + resolution: {integrity: sha512-ztmtTd6xwnuh2/xu+Vb01btgV8SQWYCaK56CkRK8gEkWe5TuDyBcYJ0wgkMRn+2VcE9KUmhvkz+N9GHrqw/C0g==} + '@modelcontextprotocol/sdk@1.29.0': resolution: {integrity: sha512-zo37mZA9hJWpULgkRpowewez1y6ML5GsXJPY8FI0tBBCd77HEvza4jDqRKOXgHNn867PVGCyTdzqpz0izu5ZjQ==} engines: {node: '>=18'} @@ -13982,6 +13988,8 @@ snapshots: transitivePeerDependencies: - supports-color + '@material/material-color-utilities@0.3.0': {} + '@modelcontextprotocol/sdk@1.29.0(zod@4.4.3)': dependencies: '@hono/node-server': 1.19.14(hono@4.12.27) From 0ab3cdacd63e9d0b4632182385292645a9ea0e10 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Tue, 8 Sep 2026 09:27:29 -0700 Subject: [PATCH 04/10] feat(mobile): add optional Material You layout (#10692) Co-authored-by: Pixel Perfect (cherry picked from commit 4664c572a78231611491f63f677f0e007ebaab03) --- ...rated-uniwind-default-theme-variables.json | 14 ++ apps/mobile/generated-uniwind-themes.css | 70 ++++++ apps/mobile/global.css | 14 ++ .../src/components/AndroidScreenHeader.tsx | 2 + apps/mobile/src/components/ThemedSwitch.tsx | 48 +++- .../features/files/ThreadFilesRouteScreen.tsx | 33 ++- apps/mobile/src/features/home/HomeHeader.tsx | 16 +- apps/mobile/src/features/home/HomeScreen.tsx | 222 ++++++++++-------- .../layout/AdaptiveWorkspaceLayout.tsx | 11 +- .../features/layout/WorkspaceEmptyDetail.tsx | 10 +- .../AppearancePreferencesProvider.tsx | 20 +- .../sections/ThemeAppearanceSection.tsx | 19 +- .../src/features/threads/ThreadComposer.tsx | 24 +- .../threads/ThreadNavigationSidebar.tsx | 69 +++++- .../features/threads/ThreadRouteScreen.tsx | 22 +- .../features/threads/ThreadSettingsSheet.tsx | 4 + .../features/threads/thread-list-items.tsx | 104 ++++++-- .../features/threads/thread-list-v2-items.tsx | 91 +++++-- .../features/threads/thread-search-match.tsx | 16 +- apps/mobile/src/lib/materialYouTheme.test.ts | 2 + apps/mobile/src/lib/materialYouTheme.ts | 7 + apps/mobile/src/lib/mobileTheme.test.ts | 2 +- apps/mobile/src/lib/mobileTheme.ts | 10 + apps/mobile/src/lib/storage.test.ts | 17 ++ .../src/persistence/mobile-preferences.ts | 5 + docs/user/mobile-appearance.md | 4 + 26 files changed, 686 insertions(+), 170 deletions(-) diff --git a/apps/mobile/generated-uniwind-default-theme-variables.json b/apps/mobile/generated-uniwind-default-theme-variables.json index 1953880fa9..3f1e143feb 100644 --- a/apps/mobile/generated-uniwind-default-theme-variables.json +++ b/apps/mobile/generated-uniwind-default-theme-variables.json @@ -6,6 +6,13 @@ "--color-card": "#ffffff", "--color-card-alt": "#f5f5f5", "--color-card-translucent": "rgba(255, 255, 255, 0.8)", + "--color-thread-canvas": "#f5f5f5", + "--color-thread-selected": "#ffffff", + "--color-thread-selected-foreground": "#262626", + "--color-thread-selected-foreground-muted": "#737373", + "--color-composer-panel": "rgba(245, 245, 245, 0.88)", + "--color-composer-surface": "rgba(255, 255, 255, 0.94)", + "--color-composer-border": "rgba(0, 0, 0, 0.1)", "--color-foreground": "#262626", "--color-foreground-secondary": "#525252", "--color-foreground-muted": "#737373", @@ -76,6 +83,13 @@ "--color-card": "#171717", "--color-card-alt": "#1c1c1c", "--color-card-translucent": "rgba(17, 17, 17, 0.8)", + "--color-thread-canvas": "#1c1c1c", + "--color-thread-selected": "#171717", + "--color-thread-selected-foreground": "#f5f5f5", + "--color-thread-selected-foreground-muted": "#8e8e93", + "--color-composer-panel": "rgba(28, 28, 28, 0.92)", + "--color-composer-surface": "rgba(23, 23, 23, 0.9)", + "--color-composer-border": "rgba(255, 255, 255, 0.08)", "--color-foreground": "#f5f5f5", "--color-foreground-secondary": "#a3a3a3", "--color-foreground-muted": "#8e8e93", diff --git a/apps/mobile/generated-uniwind-themes.css b/apps/mobile/generated-uniwind-themes.css index 2816efc894..df5294f1a4 100644 --- a/apps/mobile/generated-uniwind-themes.css +++ b/apps/mobile/generated-uniwind-themes.css @@ -146,6 +146,13 @@ --color-card: #fdfafd; --color-card-alt: #faf3fb; --color-card-translucent: rgba(253, 250, 253, 0.8); + --color-thread-canvas: #faf3fb; + --color-thread-selected: #fdfafd; + --color-thread-selected-foreground: #501854; + --color-thread-selected-foreground-muted: #ac1668; + --color-composer-panel: rgba(250, 243, 251, 0.88); + --color-composer-surface: rgba(253, 250, 253, 0.94); + --color-composer-border: rgba(238, 225, 237, 0.54); --color-foreground: #501854; --color-foreground-secondary: #ac1668; --color-foreground-muted: #8d1255; @@ -283,6 +290,13 @@ --color-card: #2c2631; --color-card-alt: #29232d; --color-card-translucent: rgba(44, 38, 49, 0.8); + --color-thread-canvas: #29232d; + --color-thread-selected: #2c2631; + --color-thread-selected-foreground: #f9f8fb; + --color-thread-selected-foreground-muted: #e7d0dd; + --color-composer-panel: rgba(41, 35, 45, 0.92); + --color-composer-surface: rgba(44, 38, 49, 0.9); + --color-composer-border: rgba(39, 36, 44, 0.46); --color-foreground: #f9f8fb; --color-foreground-secondary: #e7d0dd; --color-foreground-muted: #e7d0dd; @@ -420,6 +434,13 @@ --color-card: #ecefed; --color-card-alt: #f3f7f4; --color-card-translucent: rgba(236, 239, 237, 0.8); + --color-thread-canvas: #f3f7f4; + --color-thread-selected: #ecefed; + --color-thread-selected-foreground: #241523; + --color-thread-selected-foreground-muted: #746c73; + --color-composer-panel: rgba(243, 247, 244, 0.88); + --color-composer-surface: rgba(236, 239, 237, 0.94); + --color-composer-border: rgba(203, 213, 209, 0.54); --color-foreground: #241523; --color-foreground-secondary: #746c73; --color-foreground-muted: #6e696f; @@ -557,6 +578,13 @@ --color-card: #36413c; --color-card-alt: #1b2821; --color-card-translucent: rgba(54, 65, 60, 0.8); + --color-thread-canvas: #1b2821; + --color-thread-selected: #36413c; + --color-thread-selected-foreground: #fffaff; + --color-thread-selected-foreground-muted: #919595; + --color-composer-panel: rgba(27, 40, 33, 0.92); + --color-composer-surface: rgba(54, 65, 60, 0.9); + --color-composer-border: rgba(65, 95, 79, 0.46); --color-foreground: #fffaff; --color-foreground-secondary: #919595; --color-foreground-muted: #9da5a2; @@ -694,6 +722,13 @@ --color-card: #edeff1; --color-card-alt: #f5f7f8; --color-card-translucent: rgba(237, 239, 241, 0.8); + --color-thread-canvas: #f5f7f8; + --color-thread-selected: #edeff1; + --color-thread-selected-foreground: #241523; + --color-thread-selected-foreground-muted: #746c75; + --color-composer-panel: rgba(245, 247, 248, 0.88); + --color-composer-surface: rgba(237, 239, 241, 0.94); + --color-composer-border: rgba(205, 212, 220, 0.54); --color-foreground: #241523; --color-foreground-secondary: #746c75; --color-foreground-muted: #6f6873; @@ -831,6 +866,13 @@ --color-card: #333b45; --color-card-alt: #17212b; --color-card-translucent: rgba(51, 59, 69, 0.8); + --color-thread-canvas: #17212b; + --color-thread-selected: #333b45; + --color-thread-selected-foreground: #fffaff; + --color-thread-selected-foreground-muted: #8d8f97; + --color-composer-panel: rgba(23, 33, 43, 0.92); + --color-composer-surface: rgba(51, 59, 69, 0.9); + --color-composer-border: rgba(64, 85, 103, 0.46); --color-foreground: #fffaff; --color-foreground-secondary: #8d8f97; --color-foreground-muted: #969ca6; @@ -968,6 +1010,13 @@ --color-card: #f1efee; --color-card-alt: #f9f7f5; --color-card-translucent: rgba(241, 239, 238, 0.8); + --color-thread-canvas: #f9f7f5; + --color-thread-selected: #f1efee; + --color-thread-selected-foreground: #241523; + --color-thread-selected-foreground-muted: #766c74; + --color-composer-panel: rgba(249, 247, 245, 0.88); + --color-composer-surface: rgba(241, 239, 238, 0.94); + --color-composer-border: rgba(221, 210, 206, 0.54); --color-foreground: #241523; --color-foreground-secondary: #766c74; --color-foreground-muted: #74686f; @@ -1105,6 +1154,13 @@ --color-card: #433835; --color-card-alt: #291e1a; --color-card-translucent: rgba(67, 56, 53, 0.8); + --color-thread-canvas: #291e1a; + --color-thread-selected: #433835; + --color-thread-selected-foreground: #fffaff; + --color-thread-selected-foreground-muted: #968e8f; + --color-composer-panel: rgba(41, 30, 26, 0.92); + --color-composer-surface: rgba(67, 56, 53, 0.9); + --color-composer-border: rgba(102, 76, 63, 0.46); --color-foreground: #fffaff; --color-foreground-secondary: #968e8f; --color-foreground-muted: #a59996; @@ -1242,6 +1298,13 @@ --color-card: #f0eff2; --color-card-alt: #f8f7f9; --color-card-translucent: rgba(240, 239, 242, 0.8); + --color-thread-canvas: #f8f7f9; + --color-thread-selected: #f0eff2; + --color-thread-selected-foreground: #241523; + --color-thread-selected-foreground-muted: #766c76; + --color-composer-panel: rgba(248, 247, 249, 0.88); + --color-composer-surface: rgba(240, 239, 242, 0.94); + --color-composer-border: rgba(214, 209, 222, 0.54); --color-foreground: #241523; --color-foreground-secondary: #766c76; --color-foreground-muted: #726874; @@ -1379,6 +1442,13 @@ --color-card: #383443; --color-card-alt: #1d1929; --color-card-translucent: rgba(56, 52, 67, 0.8); + --color-thread-canvas: #1d1929; + --color-thread-selected: #383443; + --color-thread-selected-foreground: #fffaff; + --color-thread-selected-foreground-muted: #8e8a95; + --color-composer-panel: rgba(29, 25, 41, 0.92); + --color-composer-surface: rgba(56, 52, 67, 0.9); + --color-composer-border: rgba(77, 67, 102, 0.46); --color-foreground: #fffaff; --color-foreground-secondary: #8e8a95; --color-foreground-muted: #9690a1; diff --git a/apps/mobile/global.css b/apps/mobile/global.css index e153107b18..6ec28f6e37 100644 --- a/apps/mobile/global.css +++ b/apps/mobile/global.css @@ -19,6 +19,13 @@ --color-card: #ffffff; --color-card-alt: #f5f5f5; --color-card-translucent: rgba(255, 255, 255, 0.8); + --color-thread-canvas: #f5f5f5; + --color-thread-selected: #ffffff; + --color-thread-selected-foreground: #262626; + --color-thread-selected-foreground-muted: #737373; + --color-composer-panel: rgba(245, 245, 245, 0.88); + --color-composer-surface: rgba(255, 255, 255, 0.94); + --color-composer-border: rgba(0, 0, 0, 0.1); /* Text */ --color-foreground: #262626; @@ -123,6 +130,13 @@ --color-card: #171717; --color-card-alt: #1c1c1c; --color-card-translucent: rgba(17, 17, 17, 0.8); + --color-thread-canvas: #1c1c1c; + --color-thread-selected: #171717; + --color-thread-selected-foreground: #f5f5f5; + --color-thread-selected-foreground-muted: #8e8e93; + --color-composer-panel: rgba(28, 28, 28, 0.92); + --color-composer-surface: rgba(23, 23, 23, 0.9); + --color-composer-border: rgba(255, 255, 255, 0.08); /* Text */ --color-foreground: #f5f5f5; diff --git a/apps/mobile/src/components/AndroidScreenHeader.tsx b/apps/mobile/src/components/AndroidScreenHeader.tsx index 46bc2c7c09..f397c18a0a 100644 --- a/apps/mobile/src/components/AndroidScreenHeader.tsx +++ b/apps/mobile/src/components/AndroidScreenHeader.tsx @@ -48,6 +48,7 @@ export function AndroidScreenHeader(props: { readonly trailing?: ReactNode; readonly onBack?: () => void; readonly embedded?: boolean; + readonly hideBottomBorder?: boolean; }) { const insets = useSafeAreaInsets(); @@ -56,6 +57,7 @@ export function AndroidScreenHeader(props: { className="border-b border-header-border bg-header px-3 pb-2.5" style={{ paddingTop: props.embedded ? 8 : Math.max(insets.top, 12), + borderBottomWidth: props.hideBottomBorder ? 0 : undefined, }} > diff --git a/apps/mobile/src/components/ThemedSwitch.tsx b/apps/mobile/src/components/ThemedSwitch.tsx index 5b4603fd12..08cef56f3e 100644 --- a/apps/mobile/src/components/ThemedSwitch.tsx +++ b/apps/mobile/src/components/ThemedSwitch.tsx @@ -1,6 +1,52 @@ -import { Platform, Switch, type SwitchProps } from "react-native"; +import { Platform, Pressable, Switch, View, type SwitchProps } from "react-native"; + +import { useAppearancePreferences } from "../features/settings/appearance/AppearancePreferencesProvider"; +import { SymbolView } from "./AppSymbol"; export function ThemedSwitch(props: SwitchProps) { + const { materialYouStyleLayoutActive } = useAppearancePreferences(); + if (materialYouStyleLayoutActive) { + return ( + props.onValueChange?.(!props.value)} + style={props.style} + testID={props.testID} + className={props.disabled ? "opacity-40" : "active:opacity-70"} + > + + + + + + + ); + } + return ( {/* Static header config (glass preset and title) lives in Stack.tsx. The live sheet color stays dynamic here so the FlatList can remain the direct scene child for native scroll-edge sampling. */} 0 ? projectName : undefined, @@ -466,6 +470,7 @@ export function ThreadFilesTreeScreen(props: ThreadFilesRouteScreenProps) { title="Files" subtitle={projectName} onBack={handleReturnToThread} + hideBottomBorder={materialYouStyleLayoutActive} actions={[ { accessibilityLabel: "Refresh files", @@ -474,7 +479,13 @@ export function ThreadFilesTreeScreen(props: ThreadFilesRouteScreenProps) { }, ]} /> - + ); + + return materialYouStyleLayoutActive ? ( + + {content} + + ) : ( + content + ); } export function ThreadFileScreen(props: ThreadFileRouteScreenProps) { diff --git a/apps/mobile/src/features/home/HomeHeader.tsx b/apps/mobile/src/features/home/HomeHeader.tsx index b076d8ccd6..e0d15ac145 100644 --- a/apps/mobile/src/features/home/HomeHeader.tsx +++ b/apps/mobile/src/features/home/HomeHeader.tsx @@ -1,3 +1,4 @@ +import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider"; import type { EnvironmentId, SidebarThreadSortOrder } from "@t3tools/contracts"; import type { MenuAction } from "@react-native-menu/menu"; import Constants from "expo-constants"; @@ -66,6 +67,7 @@ function checkedMenuState(checked: boolean) { } function AndroidHomeHeader(props: HomeHeaderProps) { + const { materialYouStyleLayoutActive } = useAppearancePreferences(); const insets = useSafeAreaInsets(); const stageLabel = resolveMobileStageLabel(Constants.expoConfig?.extra?.appVariant); // Thread List v2 lays the list out in fixed creation order, so the @@ -199,7 +201,11 @@ function AndroidHomeHeader(props: HomeHeaderProps) { <> - + >(() => new Map()); @@ -1094,26 +1097,31 @@ export function HomeScreen(props: HomeScreenProps) { if (!hasAnyThreads) { return ( - - - - {emptyState.loading ? ( - - - - ) : null} + + + + + {emptyState.loading ? ( + + + + ) : null} + ); @@ -1158,100 +1166,116 @@ export function HomeScreen(props: HomeScreenProps) { if (threadListV2Enabled) { return ( - + + + + 0 ? ( + ({ opacity: pressed ? 0.6 : 1 })} + > + + Show more ({threadListV2Layout.hiddenSettledCount} settled hidden) + + + ) : null + } + ListEmptyComponent={v2ListEmpty} + style={{ flex: 1 }} + automaticallyAdjustsScrollIndicatorInsets={Platform.OS === "ios"} + contentInsetAdjustmentBehavior={Platform.OS === "ios" ? "automatic" : "never"} + showsVerticalScrollIndicator={false} + keyboardDismissMode="on-drag" + keyboardShouldPersistTaps="handled" + {...scrollGateHandlers} + scrollEventThrottle={16} + contentContainerStyle={{ + paddingBottom: + Platform.OS === "ios" + ? Math.max(insets.bottom, 24) + 96 + iosBottomToolbarClearance + : Math.max(insets.bottom, 16) + 88, + }} + /> + + + + ); + } + + return ( + + + {/* Sticky headers are deliberately not wired up: LegendList's JS sticky + implementation mispositions pinned headers at mount under iOS + automatic content insets (headers render one nav-inset too low until + the first scroll event) and blanks non-pinned headers after + collapse/expand data changes. The flattened layout still exposes + `stickyHeaderIndices` if this gets revisited. */} - 0 ? ( - ({ opacity: pressed ? 0.6 : 1 })} - > - - Show more ({threadListV2Layout.hiddenSettledCount} settled hidden) - - - ) : null - } - ListEmptyComponent={v2ListEmpty} + - ); - } - - return ( - - {/* Sticky headers are deliberately not wired up: LegendList's JS sticky - implementation mispositions pinned headers at mount under iOS - automatic content insets (headers render one nav-inset too low until - the first scroll event) and blanks non-pinned headers after - collapse/expand data changes. The flattened layout still exposes - `stickyHeaderIndices` if this gets revisited. */} - - - ); } diff --git a/apps/mobile/src/features/layout/AdaptiveWorkspaceLayout.tsx b/apps/mobile/src/features/layout/AdaptiveWorkspaceLayout.tsx index 9ea8eb88d4..25786cc103 100644 --- a/apps/mobile/src/features/layout/AdaptiveWorkspaceLayout.tsx +++ b/apps/mobile/src/features/layout/AdaptiveWorkspaceLayout.tsx @@ -47,6 +47,7 @@ import { } from "../keyboard/hardwareKeyboardCommands"; import { AndroidHomeFabLayout } from "../home/AndroidHomeFab"; import { HomeListOptionsProvider } from "../home/home-list-options"; +import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider"; import { ThreadNavigationSidebar } from "../threads/ThreadNavigationSidebar"; import { WORKSPACE_PANE_TIMING } from "./workspace-pane-animation"; import { WorkspaceInspectorPane } from "./workspace-inspector-pane"; @@ -219,6 +220,7 @@ function AdaptiveWorkspaceLayoutContent( }, ) { const projectGroupingMode = props.projectGroupingMode; + const { materialYouStyleLayoutActive } = useAppearancePreferences(); const { width, height } = useWindowDimensions(); const pathname = props.pathname; const navigation = useNavigation(); @@ -551,7 +553,14 @@ function AdaptiveWorkspaceLayoutContent( ) : null} - + void }) { + const { materialYouStyleLayoutActive } = useAppearancePreferences(); return ( - + void; readonly systemColorsAvailable: boolean; readonly systemColorsActive: boolean; readonly themeVariables: MobileThemeVariables; @@ -94,6 +97,8 @@ export function AppearancePreferencesProvider(props: { readonly children: ReactN [resolvedThemeIds.dark, resolvedThemeIds.light], ); const themeId = themeIds[themeAppearance]; + const materialYouStyleLayoutEnabled = storedPreferences?.materialYouStyleLayoutEnabled ?? false; + const materialYouStyleLayoutActive = Platform.OS === "android" && materialYouStyleLayoutEnabled; const systemColorsActive = themeId === "material-you" && isSystemColorsAvailable; const [systemColorPalettes, setSystemColorPalettes] = useState(readSystemColorPalettes); useEffect(() => { @@ -242,6 +247,13 @@ export function AppearancePreferencesProvider(props: { readonly children: ReactN [runtimeState, syncThemeRuntime, updateThemePreferences], ); + const setMaterialYouStyleLayoutEnabled = useCallback( + (value: boolean) => { + updatePreferences({ materialYouStyleLayoutEnabled: value }); + }, + [updatePreferences], + ); + const setBaseFontSize = useCallback( (value: number) => { const current = appliedRuntimeStateRef.current ?? runtimeState; @@ -281,6 +293,9 @@ export function AppearancePreferencesProvider(props: { readonly children: ReactN themeAppearance, systemColorsAvailable: isSystemColorsAvailable, systemColorsActive, + materialYouStyleLayoutEnabled, + materialYouStyleLayoutActive, + setMaterialYouStyleLayoutEnabled, themeVariables, themeVariablesByAppearance, systemColorPalettes, @@ -300,6 +315,9 @@ export function AppearancePreferencesProvider(props: { readonly children: ReactN themeMode, themeAppearance, systemColorsActive, + materialYouStyleLayoutEnabled, + materialYouStyleLayoutActive, + setMaterialYouStyleLayoutEnabled, themeVariables, themeVariablesByAppearance, systemColorPalettes, diff --git a/apps/mobile/src/features/settings/appearance/sections/ThemeAppearanceSection.tsx b/apps/mobile/src/features/settings/appearance/sections/ThemeAppearanceSection.tsx index 788d04b047..a677cc5707 100644 --- a/apps/mobile/src/features/settings/appearance/sections/ThemeAppearanceSection.tsx +++ b/apps/mobile/src/features/settings/appearance/sections/ThemeAppearanceSection.tsx @@ -1,5 +1,5 @@ import { memo, useId } from "react"; -import { Pressable, View } from "react-native"; +import { Platform, Pressable, View } from "react-native"; import Svg, { Circle, Defs, RadialGradient, Stop } from "react-native-svg"; import { ScopedTheme, ScopedVariables } from "uniwind"; @@ -19,6 +19,9 @@ import { getMobileUniwindThemeName } from "../../../../lib/mobileThemeRuntime"; import { cn } from "../../../../lib/cn"; import { useAppearancePreferences } from "../AppearancePreferencesProvider"; +import { SettingsSection } from "../../components/SettingsSection"; +import { SettingsSwitchRow } from "../../components/SettingsSwitchRow"; + const APPEARANCE_MODES: ReadonlyArray<{ readonly id: MobileThemeMode; readonly label: string; @@ -290,11 +293,25 @@ export function ThemeAppearanceSection() { setThemeMode, themeIds, themeMode, + materialYouStyleLayoutEnabled, + setMaterialYouStyleLayoutEnabled, systemColorsAvailable, } = useAppearancePreferences(); return ( + {Platform.OS === "android" ? ( + + + + ) : null} Color scheme diff --git a/apps/mobile/src/features/threads/ThreadComposer.tsx b/apps/mobile/src/features/threads/ThreadComposer.tsx index bcda2d10be..a059c77b5f 100644 --- a/apps/mobile/src/features/threads/ThreadComposer.tsx +++ b/apps/mobile/src/features/threads/ThreadComposer.tsx @@ -1,3 +1,4 @@ +import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider"; import { useAtomValue } from "@effect/atom-react"; import type { ContextWindowSnapshot } from "@t3tools/client-runtime/state/context-window"; import { resolveProviderContinuationTransition } from "@t3tools/client-runtime/providerContinuation"; @@ -300,6 +301,7 @@ export function ComposerSurface(props: { /** Morphs between the compact and expanded composer layouts. */ readonly animateLayout?: boolean; }) { + const { materialYouStyleLayoutActive } = useAppearancePreferences(); // Drop shadow lives on a wrapper: `overflow: "hidden"` on the surface itself // (needed to clip content to the pill shape) would clip the shadow on iOS. // @@ -340,10 +342,18 @@ export function ComposerSurface(props: { }; return ( - + {/* The backdrop gradient lives on a plain View: Reanimated's Animated.View silently drops experimental_backgroundImage on Android, which left this strip fully transparent and the feed text legible through the composer. */} ); case "v2-settled-shelf": @@ -975,7 +981,7 @@ function ThreadNavigationSidebarPane( disabled={!shelfPreferencesLoaded} expanded={item.expanded} onToggle={toggleSettledShelf} - pane="sidebar" + pane={materialYouStyleLayoutActive ? "screen" : "sidebar"} /> ); case "v2-show-more": @@ -995,7 +1001,7 @@ function ThreadNavigationSidebarPane( case "header": return ( - + - + + {materialYouStyleLayoutActive && props.searchQuery.length > 0 ? ( + props.onSearchQueryChange("")} + > + + + ) : null} diff --git a/apps/mobile/src/features/threads/ThreadRouteScreen.tsx b/apps/mobile/src/features/threads/ThreadRouteScreen.tsx index 56c55801fd..73caa34ce7 100644 --- a/apps/mobile/src/features/threads/ThreadRouteScreen.tsx +++ b/apps/mobile/src/features/threads/ThreadRouteScreen.tsx @@ -81,6 +81,7 @@ import { useSelectedThreadWorktree } from "../../state/use-selected-thread-workt import { useThreadComposerState } from "../../state/use-thread-composer-state"; import { threadEnvironment } from "../../state/threads"; import { projectThreadContentPresentation } from "./threadContentPresentation"; +import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider"; import { useAdaptiveWorkspaceLayout, useAdaptiveWorkspacePaneRole, @@ -198,6 +199,8 @@ function ThreadRouteContent( readonly selectedThreadDetailState: ReturnType; }, ) { + const { materialYouStyleLayoutActive, themeVariables } = useAppearancePreferences(); + const headerColor = themeVariables["--color-header"]; const { fileInspector, layout, @@ -964,7 +967,19 @@ function ThreadRouteContent( - + (layout.usesSplitView ? threadCenterHeaderItems : compactRightHeaderItems) : undefined, unstable_headerSubtitle: usesNativeHeaderGlass ? headerSubtitle : undefined, + contentStyle: + Platform.OS === "android" && materialYouStyleLayoutActive + ? { backgroundColor: headerColor } + : undefined, }} /> @@ -1095,6 +1114,7 @@ function ThreadRouteContent( subtitle={headerSubtitle} onBack={layout.usesSplitView ? undefined : () => navigation.goBack()} actions={androidHeaderActions} + hideBottomBorder={materialYouStyleLayoutActive} /> ) : null} diff --git a/apps/mobile/src/features/threads/ThreadSettingsSheet.tsx b/apps/mobile/src/features/threads/ThreadSettingsSheet.tsx index 22bfcd86a8..84fb881d67 100644 --- a/apps/mobile/src/features/threads/ThreadSettingsSheet.tsx +++ b/apps/mobile/src/features/threads/ThreadSettingsSheet.tsx @@ -51,6 +51,7 @@ import { NATIVE_LIQUID_GLASS_SUPPORTED } from "../../native/native-glass"; import { serverEnvironment } from "../../state/server"; import { useAtomCommand } from "../../state/use-atom-command"; import { useNewTaskFlow } from "./new-task-flow-provider"; +import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider"; import { createProviderCatalogRefreshRunner, providerCatalogRefreshError, @@ -103,6 +104,8 @@ function ModelRow(props: { readonly isFirst: boolean; readonly isLast: boolean; }) { + const { materialYouStyleLayoutActive } = useAppearancePreferences(); + const selectedMaterialRow = materialYouStyleLayoutActive && props.selected; return ( ["simultaneousWithExternalGesture"]; }) { const { width: windowWidth } = useWindowDimensions(); - const { themeAppearance: colorScheme } = useAppearancePreferences(); + const { themeAppearance: colorScheme, materialYouStyleLayoutActive } = useAppearancePreferences(); const compact = props.variant === "compact"; const selected = props.selected === true; + const visuallySelected = selected && (!compact || materialYouStyleLayoutActive); // Recycling-safe: resets when the list container is reused for another // thread, so a hover highlight can't leak across rows. const [hovered, setHovered] = useRecyclingState(false); @@ -456,6 +457,8 @@ export const ThreadListRow = memo(function ThreadListRow(props: { const drawerColor = useUniwindTheme()["--color-drawer"]; const pressedBackgroundColor = useUniwindTheme()["--color-subtle"]; const selectedBackgroundColor = useUniwindTheme()["--color-user-bubble"]; + const materialSelectedBackgroundColor = useUniwindTheme()["--color-thread-selected"]; + const materialSelectedForegroundColor = useUniwindTheme()["--color-thread-selected-foreground"]; const selectedForegroundColor = useUniwindTheme()["--color-user-bubble-foreground"]; const { thread, onSelectThread, onArchiveThread, onDeleteThread, onRegenerateThreadTitle } = @@ -471,15 +474,25 @@ export const ThreadListRow = memo(function ThreadListRow(props: { ); const backgroundColor = compact ? screenColor : drawerColor; - const effectivePressedBackground = selected - ? themeColorWithAlpha(String(selectedForegroundColor), 0.16) + const effectiveSelectedBackground = materialYouStyleLayoutActive + ? materialSelectedBackgroundColor + : selectedBackgroundColor; + const effectiveSelectedForeground = materialYouStyleLayoutActive + ? materialSelectedForegroundColor + : selectedForegroundColor; + const effectivePressedBackground = visuallySelected + ? themeColorWithAlpha(String(effectiveSelectedForeground), 0.16) : pressedBackgroundColor; const effectiveStatus = - selected && status + visuallySelected && status ? { ...status, - pillClassName: "bg-user-bubble-foreground/20", - textClassName: "text-user-bubble-foreground", + pillClassName: materialYouStyleLayoutActive + ? "bg-thread-selected-foreground/20" + : "bg-user-bubble-foreground/20", + textClassName: materialYouStyleLayoutActive + ? "text-thread-selected-foreground" + : "text-user-bubble-foreground", } : status; @@ -539,7 +552,9 @@ export const ThreadListRow = memo(function ThreadListRow(props: { compact ? "accent-icon-subtle" : selected - ? "accent-user-bubble-foreground-muted" + ? materialYouStyleLayoutActive + ? "accent-thread-selected-foreground-muted" + : "accent-user-bubble-foreground-muted" : "accent-foreground-muted" } /> @@ -547,9 +562,12 @@ export const ThreadListRow = memo(function ThreadListRow(props: { @@ -562,12 +580,18 @@ export const ThreadListRow = memo(function ThreadListRow(props: { {pr.label} @@ -584,6 +608,14 @@ export const ThreadListRow = memo(function ThreadListRow(props: { accessibilityLabel={threadAccessibilityLabel} accessibilityRole="button" className="bg-screen active:opacity-70" + style={ + materialYouStyleLayoutActive + ? { + backgroundColor: visuallySelected ? effectiveSelectedBackground : backgroundColor, + borderRadius: SIDEBAR_ROW_RADIUS, + } + : undefined + } onPress={() => { close(); onSelectThread(thread); @@ -592,12 +624,33 @@ export const ThreadListRow = memo(function ThreadListRow(props: { - + {thread.title} {statusPill} - {timestamp} + + {timestamp} + ) : null} {subtitleRow} @@ -630,8 +684,8 @@ export const ThreadListRow = memo(function ThreadListRow(props: { onSelectThread(thread); }} style={({ pressed }) => ({ - backgroundColor: selected - ? selectedBackgroundColor + backgroundColor: visuallySelected + ? effectiveSelectedBackground : pressed || hovered ? effectivePressedBackground : backgroundColor, @@ -648,7 +702,11 @@ export const ThreadListRow = memo(function ThreadListRow(props: { @@ -659,7 +717,11 @@ export const ThreadListRow = memo(function ThreadListRow(props: { @@ -671,7 +733,7 @@ export const ThreadListRow = memo(function ThreadListRow(props: { ) : null} {subtitleRow} @@ -683,7 +745,9 @@ export const ThreadListRow = memo(function ThreadListRow(props: { @@ -708,7 +717,9 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { className={cn( "text-xs tabular-nums", selected - ? "text-user-bubble-foreground" + ? materialYouStyleLayoutActive + ? "text-thread-selected-foreground" + : "text-user-bubble-foreground" : (statusLabel?.className ?? "text-foreground-tertiary"), )} > @@ -720,7 +731,11 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { @@ -740,7 +755,11 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { @@ -757,7 +776,11 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { @@ -765,7 +788,11 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { @@ -777,7 +804,11 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { {props.environmentLabel} @@ -789,7 +820,11 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { kind={props.environmentMachine} size={11} tintColorClassName={ - selected ? "accent-user-bubble-foreground-muted" : "accent-foreground-tertiary" + selected + ? materialYouStyleLayoutActive + ? "accent-thread-selected-foreground-muted" + : "accent-user-bubble-foreground-muted" + : "accent-foreground-tertiary" } /> ) : null} @@ -800,7 +835,14 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { {pr ? ( #{pr.label} @@ -827,16 +869,17 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { onSelectThread(thread); }} style={ - sidebarPane + sidebarPane || materialYouStyleLayoutActive ? ({ pressed }) => ({ backgroundColor: selected ? selectedBackgroundColor : pressed ? pressedBackgroundColor - : drawerColor, + : sidebarPane + ? drawerColor + : screenColor, borderRadius: SIDEBAR_V2_ROW_RADIUS, - paddingHorizontal: 12, - paddingVertical: 10, + ...(sidebarPane ? { paddingHorizontal: 12, paddingVertical: 10 } : null), }) : ({ pressed }) => ({ opacity: pressed ? 0.7 : 1 }) } @@ -848,7 +891,7 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { labels and text hierarchy carry state, an inset hairline separates rows. The opaque screen background stays so swipe actions reveal behind the row. */ - + {cardContent} {props.showTrailingDivider !== false ? ( @@ -862,19 +905,21 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { accessibilityLabel={thread.title} accessibilityRole="button" accessibilityState={{ selected }} - className={sidebarPane ? undefined : "bg-screen"} + className={sidebarPane || materialYouStyleLayoutActive ? undefined : "bg-screen"} onPress={() => { close(); onSelectThread(thread); }} style={ - sidebarPane + sidebarPane || materialYouStyleLayoutActive ? ({ pressed }) => ({ backgroundColor: selected ? selectedBackgroundColor : pressed ? pressedBackgroundColor - : drawerColor, + : sidebarPane + ? drawerColor + : screenColor, borderRadius: SIDEBAR_V2_ROW_RADIUS, }) : ({ pressed }) => ({ opacity: pressed ? 0.7 : 1 }) @@ -902,7 +947,11 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { @@ -920,7 +969,9 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { className={cn( "text-sm tabular-nums", selected - ? "text-user-bubble-foreground-muted" + ? materialYouStyleLayoutActive + ? "text-thread-selected-foreground-muted" + : "text-user-bubble-foreground-muted" : snoozedRow ? "text-foreground-secondary" : "text-foreground-tertiary", diff --git a/apps/mobile/src/features/threads/thread-search-match.tsx b/apps/mobile/src/features/threads/thread-search-match.tsx index 9c478f3c45..bc10bd9fe9 100644 --- a/apps/mobile/src/features/threads/thread-search-match.tsx +++ b/apps/mobile/src/features/threads/thread-search-match.tsx @@ -2,6 +2,7 @@ import type { EnvironmentThreadSearchMatch } from "@t3tools/client-runtime/state import { AppText as Text } from "../../components/AppText"; import { cn } from "../../lib/cn"; +import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider"; function foldAsciiCase(value: string): string { return value.replace(/[A-Z]/g, (character) => character.toLowerCase()); @@ -50,12 +51,17 @@ export function ThreadSearchMatchExcerpt(props: { readonly compact?: boolean; }) { const isUser = props.match.source === "user"; + const { materialYouStyleLayoutActive } = useAppearancePreferences(); const parts = splitHighlightParts(props.match.snippet, props.query); return ( @@ -63,7 +69,9 @@ export function ThreadSearchMatchExcerpt(props: { className={cn( props.compact ? "text-sm font-t3-medium" : "text-xs font-t3-medium", props.selected - ? "text-user-bubble-foreground" + ? materialYouStyleLayoutActive + ? "text-thread-selected-foreground" + : "text-user-bubble-foreground" : isUser ? "text-foreground-secondary" : "text-adaptive-emerald-600-400", @@ -77,7 +85,9 @@ export function ThreadSearchMatchExcerpt(props: { props.compact ? "text-sm" : "text-xs", part.highlighted && "font-t3-bold", props.selected - ? "text-user-bubble-foreground" + ? materialYouStyleLayoutActive + ? "text-thread-selected-foreground" + : "text-user-bubble-foreground" : part.highlighted ? "text-foreground" : "text-foreground-muted", diff --git a/apps/mobile/src/lib/materialYouTheme.test.ts b/apps/mobile/src/lib/materialYouTheme.test.ts index 7d4676c06e..af52c2635e 100644 --- a/apps/mobile/src/lib/materialYouTheme.test.ts +++ b/apps/mobile/src/lib/materialYouTheme.test.ts @@ -39,6 +39,8 @@ describe("Material You system colors", () => { expect(base).toEqual(snapshot); expect(variables["--color-screen"]).toBe(palette.surface); + expect(variables["--color-thread-canvas"]).toBe(palette.surfaceContainerLow); + expect(variables["--color-thread-selected"]).toBe(palette.surfaceContainer); expect(variables["--color-header"]).toBe(palette.surfaceContainerHigh); expect(variables["--color-primary"]).toBe(palette.primary); expect(variables["--color-placeholder"]).toBe("#1C1B1F9E"); diff --git a/apps/mobile/src/lib/materialYouTheme.ts b/apps/mobile/src/lib/materialYouTheme.ts index d4542743ce..ce16b23c89 100644 --- a/apps/mobile/src/lib/materialYouTheme.ts +++ b/apps/mobile/src/lib/materialYouTheme.ts @@ -38,6 +38,13 @@ export function materialYouPaletteToMobileThemeVariables( "--color-card": palette.surfaceContainer, "--color-card-alt": palette.surfaceContainerHigh, "--color-card-translucent": withAlpha(palette.surfaceContainer, 0.8), + "--color-thread-canvas": palette.surfaceContainerLow, + "--color-thread-selected": palette.surfaceContainer, + "--color-thread-selected-foreground": palette.onSurface, + "--color-thread-selected-foreground-muted": palette.onSurfaceVariant, + "--color-composer-panel": withAlpha(palette.surfaceContainerLow, dark ? 0.92 : 0.88), + "--color-composer-surface": withAlpha(palette.surfaceContainerHigh, dark ? 0.9 : 0.94), + "--color-composer-border": withAlpha(palette.outlineVariant, dark ? 0.34 : 0.46), "--color-foreground": palette.onSurface, "--color-foreground-secondary": palette.onSurfaceVariant, "--color-foreground-muted": palette.onSurfaceVariant, diff --git a/apps/mobile/src/lib/mobileTheme.test.ts b/apps/mobile/src/lib/mobileTheme.test.ts index 4757cde713..78a10f6ab6 100644 --- a/apps/mobile/src/lib/mobileTheme.test.ts +++ b/apps/mobile/src/lib/mobileTheme.test.ts @@ -153,7 +153,7 @@ describe("mobile themes", () => { it("maps semantic palette roles onto every mobile color variable", () => { const variables = createMobileThemeVariables(BUILT_IN_THEMES[0].colors, "light"); - expect(Object.keys(variables)).toHaveLength(68); + expect(Object.keys(variables)).toHaveLength(75); expect(variables["--color-sheet-solid"]).toBe( themeColorToNativeColor(BUILT_IN_THEMES[0].colors.chrome), ); diff --git a/apps/mobile/src/lib/mobileTheme.ts b/apps/mobile/src/lib/mobileTheme.ts index e070107b71..e6669db9c5 100644 --- a/apps/mobile/src/lib/mobileTheme.ts +++ b/apps/mobile/src/lib/mobileTheme.ts @@ -218,6 +218,16 @@ export function createMobileThemeVariables( "--color-card": c.surfaceRaised, "--color-card-alt": c.surface, "--color-card-translucent": withAlpha(c.surfaceRaised, 0.8), + "--color-thread-canvas": c.surface, + "--color-thread-selected": c.surfaceRaised, + "--color-thread-selected-foreground": c.text, + "--color-thread-selected-foreground-muted": c.textMuted, + "--color-composer-panel": themeColorWithAlpha(c.surface, appearance === "dark" ? 0.92 : 0.88), + "--color-composer-surface": themeColorWithAlpha( + c.surfaceRaised, + appearance === "dark" ? 0.9 : 0.94, + ), + "--color-composer-border": themeColorWithAlpha(c.border, appearance === "dark" ? 0.46 : 0.54), "--color-foreground": c.text, "--color-foreground-secondary": c.textMuted, "--color-foreground-muted": c.mutedForeground, diff --git a/apps/mobile/src/lib/storage.test.ts b/apps/mobile/src/lib/storage.test.ts index 52202bdc5e..856e253923 100644 --- a/apps/mobile/src/lib/storage.test.ts +++ b/apps/mobile/src/lib/storage.test.ts @@ -204,6 +204,23 @@ describe("mobile connection storage", () => { await expect(loadPreferences()).resolves.toEqual({ ...themes, lightThemeId: "t3-chat" }); }); + it("persists the Material You layout independently of the selected theme", async () => { + await savePreferencesPatch({ + lightThemeId: "material-you", + materialYouStyleLayoutEnabled: true, + }); + await savePreferencesPatch({ lightThemeId: "t3-chat" }); + await expect(loadPreferences()).resolves.toEqual({ + lightThemeId: "t3-chat", + materialYouStyleLayoutEnabled: true, + }); + await savePreferencesPatch({ materialYouStyleLayoutEnabled: false }); + await expect(loadPreferences()).resolves.toEqual({ + lightThemeId: "t3-chat", + materialYouStyleLayoutEnabled: false, + }); + }); + it("drops the removed theme transition preference", async () => { mocks.setPreferencesJson(JSON.stringify({ themeTransition: "circle-bottom-left" }), 10); diff --git a/apps/mobile/src/persistence/mobile-preferences.ts b/apps/mobile/src/persistence/mobile-preferences.ts index f14a73f15c..77984cb135 100644 --- a/apps/mobile/src/persistence/mobile-preferences.ts +++ b/apps/mobile/src/persistence/mobile-preferences.ts @@ -21,6 +21,7 @@ export interface Preferences { readonly lightThemeId?: MobileThemeId; readonly darkThemeId?: MobileThemeId; readonly themeMode?: MobileThemeMode; + readonly materialYouStyleLayoutEnabled?: boolean; readonly baseFontSize?: number; readonly terminalFontSize?: number | null; readonly markdownFontSize?: number; @@ -90,6 +91,7 @@ function sanitizePreferences(parsed: Preferences): Preferences { lightThemeId?: MobileThemeId; darkThemeId?: MobileThemeId; themeMode?: MobileThemeMode; + materialYouStyleLayoutEnabled?: boolean; baseFontSize?: number; terminalFontSize?: number | null; markdownFontSize?: number; @@ -133,6 +135,9 @@ function sanitizePreferences(parsed: Preferences): Preferences { ) { preferences.themeMode = parsed.themeMode; } + if (typeof parsed.materialYouStyleLayoutEnabled === "boolean") { + preferences.materialYouStyleLayoutEnabled = parsed.materialYouStyleLayoutEnabled; + } if (typeof parsed.baseFontSize === "number") preferences.baseFontSize = parsed.baseFontSize; if (typeof parsed.terminalFontSize === "number" || parsed.terminalFontSize === null) { preferences.terminalFontSize = parsed.terminalFontSize; diff --git a/docs/user/mobile-appearance.md b/docs/user/mobile-appearance.md index 8d8a9d2979..d4d0454f02 100644 --- a/docs/user/mobile-appearance.md +++ b/docs/user/mobile-appearance.md @@ -22,5 +22,9 @@ another theme replaces those colors. Like other themes, Material You can be sele light and dark appearances. If you change your wallpaper, the colors update when you return to Pylon. +Android also offers **Material You Layout** in Appearance. It uses rounded panels, pill-shaped +search fields, tonal selected threads, and Material-style switches. It is off by default and works +with any theme. + **System** follows the device appearance automatically. Theme, text, code, and terminal appearance preferences are stored on the device. From 62f78cfa789fef9fc8e93297446a72b5ff825350 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Tue, 8 Sep 2026 18:51:10 -0700 Subject: [PATCH 05/10] fix(relay): use current APNs registration routing for queued jobs (#10859) (cherry picked from commit 1862686f9e42bf1d77e7f2ad03e24d7b0c7908f1) --- .../src/agentActivity/ApnsDeliveries.test.ts | 76 +++++++++++++++++++ .../relay/src/agentActivity/ApnsDeliveries.ts | 8 +- 2 files changed, 82 insertions(+), 2 deletions(-) diff --git a/infra/relay/src/agentActivity/ApnsDeliveries.test.ts b/infra/relay/src/agentActivity/ApnsDeliveries.test.ts index 0d41bfce78..75b821bcfa 100644 --- a/infra/relay/src/agentActivity/ApnsDeliveries.test.ts +++ b/infra/relay/src/agentActivity/ApnsDeliveries.test.ts @@ -486,6 +486,9 @@ describe("ApnsDeliveries", () => { Effect.provide( makeLayer({ attempts, + currentTargets: [ + { ...target, bundle_id: "com.t3tools.t3code.preview", aps_environment: "sandbox" }, + ], config: signingConfig, execute, }), @@ -1972,3 +1975,76 @@ describe("fast completion delivery", () => { }).pipe(Effect.provide(makeLayer({ attempts: [], queuedJobs }))); }); }); + +describe("signed APNs registration metadata", () => { + for (const kind of ["live_activity_update", "push_notification"] as const) { + for (const changed of ["bundle", "environment", "legacy"] as const) { + it.effect(`routes ${kind} using current registration with ${changed} job metadata`, () => { + const attempts: DeliveryAttempts.DeliveryAttemptInput[] = []; + const requests: HttpClientRequest.HttpClientRequest[] = []; + const payload = makeApnsDeliveryJobPayload({ + kind, + userId: target.user_id, + deviceId: target.device_id, + token: "unchanged-token", + ...(changed === "legacy" + ? {} + : { bundleId: "com.t3tools.t3code.dev", apsEnvironment: "sandbox" as const }), + aggregate: kind === "live_activity_update" ? aggregate : null, + ...(kind === "push_notification" + ? { + notification: { + title: "Thread", + body: "Input: Project", + environmentId: "env", + threadId: "thread", + deepLink: "/", + }, + } + : {}), + createdAt: "1970-01-01T00:00:00.000Z", + expiresAt: "1970-01-01T00:10:00.000Z", + jobId: `metadata-${kind}-${changed}`, + }); + const signed = signApnsDeliveryJob({ + secret: config.apnsDeliveryJobSigningSecret, + payload, + }); + return Effect.gen(function* () { + const deliveries = yield* ApnsDeliveries.ApnsDeliveries; + const result = yield* deliveries.processSignedJob(signed); + expect(result.ok).toBe(true); + expect(requests).toHaveLength(1); + expect(requests[0]?.url).toBe( + `${changed === "environment" ? "https://api.push.apple.com" : "https://api.sandbox.push.apple.com"}/3/device/unchanged-token`, + ); + expect(requests[0]?.headers["apns-topic"]).toBe( + `${changed === "bundle" ? "com.t3tools.t3code.preview" : "com.t3tools.t3code.dev"}${kind === "live_activity_update" ? ".push-type.liveactivity" : ""}`, + ); + }).pipe( + Effect.provide( + makeLayer({ + attempts, + config: signingConfig, + currentTargets: [ + { + ...target, + push_token: "unchanged-token", + activity_push_token: "unchanged-token", + bundle_id: + changed === "bundle" ? "com.t3tools.t3code.preview" : "com.t3tools.t3code.dev", + aps_environment: changed === "environment" ? "production" : "sandbox", + }, + ], + execute: (request) => + Effect.sync(() => { + requests.push(request); + return HttpClientResponse.fromWeb(request, new Response("", { status: 200 })); + }), + }), + ), + ); + }); + } + } +}); diff --git a/infra/relay/src/agentActivity/ApnsDeliveries.ts b/infra/relay/src/agentActivity/ApnsDeliveries.ts index 2908d55b66..411190013e 100644 --- a/infra/relay/src/agentActivity/ApnsDeliveries.ts +++ b/infra/relay/src/agentActivity/ApnsDeliveries.ts @@ -681,6 +681,7 @@ export const make = Effect.gen(function* () { "relay.delivery.kind": input.kind, ...(input.sourceJobId ? { "relay.delivery.job_id": input.sourceJobId } : {}), }); + let deliveryTarget = input.target; const now = yield* DateTime.now; const aggregate = input.aggregate === null ? null : sanitizeAgentActivityAggregateState(input.aggregate); @@ -722,6 +723,7 @@ export const make = Effect.gen(function* () { }); return staleJobResult({ deviceId: input.target.device_id, kind: input.kind }); } + deliveryTarget = currentTarget; if (alert) { const preferences = parsePreferences(currentTarget.preferences_json); const previousAggregate = parseAggregate(currentTarget.last_aggregate_json); @@ -779,7 +781,7 @@ export const make = Effect.gen(function* () { ); const result = yield* apns .sendLiveActivityRequest({ - credentials: credentialsForTarget(config.apns, input.target), + credentials: credentialsForTarget(config.apns, deliveryTarget), request, issuedAtUnixSeconds: epochSeconds, }) @@ -845,6 +847,7 @@ export const make = Effect.gen(function* () { "relay.delivery.kind": "push_notification", ...(input.sourceJobId ? { "relay.delivery.job_id": input.sourceJobId } : {}), }); + let deliveryTarget = input.target; const now = yield* DateTime.now; const epochSeconds = Math.floor(now.epochMilliseconds / 1_000); const notification = sanitizeApnsNotificationPayload(input.notification); @@ -914,6 +917,7 @@ export const make = Effect.gen(function* () { kind: "push_notification", }); } + deliveryTarget = currentTarget; const preferences = parsePreferences(currentTarget.preferences_json); const alertAllowed = notification.phase !== undefined && notification.updatedAt !== undefined @@ -935,7 +939,7 @@ export const make = Effect.gen(function* () { } const result = yield* apns .sendPushNotificationRequest({ - credentials: credentialsForTarget(config.apns, input.target), + credentials: credentialsForTarget(config.apns, deliveryTarget), request, issuedAtUnixSeconds: epochSeconds, }) From 54b8235053b08f3a4ac3ab8524efa31e11e337ce Mon Sep 17 00:00:00 2001 From: Ryan Hughes Date: Wed, 9 Sep 2026 00:54:28 -0400 Subject: [PATCH 06/10] feat(mobile): add Android agent notifications and ongoing activity (#10416) Co-authored-by: Julius Marminge <51714798+juliusmarminge@users.noreply.github.com> (cherry picked from commit 6c583620ff7ad3235b135af7107c0543467eecfa) --- .github/workflows/deploy-relay.yml | 1 + apps/mobile/app.config.ts | 27 +- .../android/build.gradle | 34 + .../android/src/main/AndroidManifest.xml | 14 + .../AgentNotifications.kt | 336 ++++ .../T3AgentNotificationsModule.kt | 25 + .../AgentNotificationsTest.kt | 430 +++++ .../expo-module.config.json | 4 + apps/mobile/package.json | 1 + .../androidNotifications.test.ts | 68 + .../agent-awareness/androidNotifications.ts | 35 + .../features/agent-awareness/capabilities.ts | 6 +- .../notificationPermissions.test.ts | 59 + .../notificationPermissions.ts | 17 +- .../agent-awareness/registrationPayload.ts | 34 +- .../remoteRegistration.test.ts | 242 ++- .../agent-awareness/remoteRegistration.ts | 86 +- .../SettingsRouteScreen.logic.test.ts | 6 +- .../settings/SettingsRouteScreen.logic.ts | 4 +- .../features/settings/SettingsRouteScreen.tsx | 74 +- ...MobileClientsUserProfilePage.logic.test.ts | 9 + .../MobileClientsUserProfilePage.logic.ts | 8 +- docs/internals/t3-connect.md | 16 + docs/operations/android-notifications.md | 239 +++ docs/user/mobile-notifications.md | 26 + infra/relay/.env.example | 7 +- infra/relay/README.md | 8 +- .../migration.sql | 2 + .../snapshot.json | 1516 +++++++++++++++++ infra/relay/package.json | 2 + infra/relay/scripts/android-push-smoke.ts | 152 ++ infra/relay/scripts/android-push-watch.ts | 227 +++ infra/relay/src/Config.ts | 3 +- infra/relay/src/WebCrypto.ts | 5 + .../AgentActivityPublisher.test.ts | 78 +- .../agentActivity/AgentActivityPublisher.ts | 16 +- .../src/agentActivity/ApnsDeliveries.test.ts | 44 +- .../relay/src/agentActivity/ApnsDeliveries.ts | 22 + infra/relay/src/agentActivity/Devices.ts | 8 +- .../src/agentActivity/FcmAssertionSigner.ts | 76 + .../relay/src/agentActivity/FcmClient.test.ts | 254 +++ infra/relay/src/agentActivity/FcmClient.ts | 166 ++ .../src/agentActivity/FcmDeliveries.test.ts | 836 +++++++++ .../relay/src/agentActivity/FcmDeliveries.ts | 348 ++++ .../agentActivity/FcmDeliveryQueueConsumer.ts | 25 + .../agentActivity/FcmDeliveryQueueSender.ts | 14 + .../relay/src/agentActivity/LiveActivities.ts | 4 +- .../agentActivity/MobileRegistrations.test.ts | 12 +- infra/relay/src/agentActivity/fcmPayloads.ts | 86 + infra/relay/src/http/Api.test.ts | 109 +- infra/relay/src/http/Api.ts | 22 + infra/relay/src/persistence/schema.ts | 5 +- infra/relay/src/queues.ts | 5 + infra/relay/src/worker.ts | 89 +- .../src/relay/managedRelay.test.ts | 27 +- .../client-runtime/src/relay/managedRelay.ts | 2 +- packages/contracts/src/relay.test.ts | 47 +- packages/contracts/src/relay.ts | 35 +- 58 files changed, 5904 insertions(+), 149 deletions(-) create mode 100644 apps/mobile/modules/t3-agent-notifications/android/build.gradle create mode 100644 apps/mobile/modules/t3-agent-notifications/android/src/main/AndroidManifest.xml create mode 100644 apps/mobile/modules/t3-agent-notifications/android/src/main/java/expo/modules/t3agentnotifications/AgentNotifications.kt create mode 100644 apps/mobile/modules/t3-agent-notifications/android/src/main/java/expo/modules/t3agentnotifications/T3AgentNotificationsModule.kt create mode 100644 apps/mobile/modules/t3-agent-notifications/android/src/test/java/expo/modules/t3agentnotifications/AgentNotificationsTest.kt create mode 100644 apps/mobile/modules/t3-agent-notifications/expo-module.config.json 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 apps/mobile/src/features/agent-awareness/notificationPermissions.test.ts create mode 100644 docs/operations/android-notifications.md create mode 100644 docs/user/mobile-notifications.md create mode 100644 infra/relay/migrations/postgres/20260906042516_android_devices/migration.sql create mode 100644 infra/relay/migrations/postgres/20260906042516_android_devices/snapshot.json create mode 100644 infra/relay/scripts/android-push-smoke.ts create mode 100644 infra/relay/scripts/android-push-watch.ts create mode 100644 infra/relay/src/WebCrypto.ts create mode 100644 infra/relay/src/agentActivity/FcmAssertionSigner.ts create mode 100644 infra/relay/src/agentActivity/FcmClient.test.ts create mode 100644 infra/relay/src/agentActivity/FcmClient.ts create mode 100644 infra/relay/src/agentActivity/FcmDeliveries.test.ts create mode 100644 infra/relay/src/agentActivity/FcmDeliveries.ts create mode 100644 infra/relay/src/agentActivity/FcmDeliveryQueueConsumer.ts create mode 100644 infra/relay/src/agentActivity/FcmDeliveryQueueSender.ts create mode 100644 infra/relay/src/agentActivity/fcmPayloads.ts diff --git a/.github/workflows/deploy-relay.yml b/.github/workflows/deploy-relay.yml index 203d1ee74e..9accf5f96b 100644 --- a/.github/workflows/deploy-relay.yml +++ b/.github/workflows/deploy-relay.yml @@ -78,6 +78,7 @@ jobs: AXIOM_TOKEN: ${{ secrets.AXIOM_TOKEN }} CLERK_SECRET_KEY: ${{ secrets.CLERK_SECRET_KEY }} APNS_PRIVATE_KEY: ${{ secrets.APNS_PRIVATE_KEY }} + FCM_SERVICE_ACCOUNT: ${{ secrets.FCM_SERVICE_ACCOUNT }} - name: Publish relay deploy commit status uses: actions/github-script@v8 diff --git a/apps/mobile/app.config.ts b/apps/mobile/app.config.ts index ba88689ca7..80f4cf7607 100644 --- a/apps/mobile/app.config.ts +++ b/apps/mobile/app.config.ts @@ -212,15 +212,17 @@ const config: ExpoConfig = { userInterfaceStyle: "automatic", // Over-the-air updates follow the configured EAS project. Off until one is // set: an update URL is a remote code channel, and inheriting T3's would let - // their bundles run inside Pylon. - updates: easProjectId - ? { - enabled: true, - url: `https://u.expo.dev/${easProjectId}`, - checkAutomatically: "ON_LOAD", - fallbackToCacheTimeout: 0, - } - : { enabled: false }, + // their bundles run inside Pylon. `T3CODE_MOBILE_UPDATES_ENABLED=0` also turns + // them off for a private binary built against a configured project. + updates: + easProjectId && repoEnv.T3CODE_MOBILE_UPDATES_ENABLED !== "0" + ? { + enabled: true, + url: `https://u.expo.dev/${easProjectId}`, + checkAutomatically: "ON_LOAD", + fallbackToCacheTimeout: 0, + } + : { enabled: false }, ios: { icon: variant.assets.iosIcon, supportsTablet: true, @@ -276,6 +278,9 @@ const config: ExpoConfig = { android: { icon: variant.assets.appIcon, package: variant.androidPackage, + ...(repoEnv.T3CODE_ANDROID_GOOGLE_SERVICES_FILE + ? { googleServicesFile: repoEnv.T3CODE_ANDROID_GOOGLE_SERVICES_FILE } + : {}), adaptiveIcon: { backgroundColor: variant.assets.androidAdaptiveBackgroundColor, foregroundImage: variant.assets.androidAdaptiveForeground, @@ -380,6 +385,10 @@ const config: ExpoConfig = { [ "expo-build-properties", { + android: { + // Keep the supported floor explicit and covered by native notification tests. + minSdkVersion: 24, + }, ios: { deploymentTarget: "18.0", // AppCheckCore 11.3+ includes Swift and needs module maps for these Objective-C dependencies. diff --git a/apps/mobile/modules/t3-agent-notifications/android/build.gradle b/apps/mobile/modules/t3-agent-notifications/android/build.gradle new file mode 100644 index 0000000000..34b1d92593 --- /dev/null +++ b/apps/mobile/modules/t3-agent-notifications/android/build.gradle @@ -0,0 +1,34 @@ +plugins { + id 'com.android.library' + id 'expo-module-gradle-plugin' +} + +group = 'com.t3tools.agentnotifications' +version = '0.0.0' + +android { + namespace 'expo.modules.t3agentnotifications' + defaultConfig { + versionCode 1 + versionName '0.0.0' + } + testOptions { + unitTests.includeAndroidResources = true + } +} + +dependencies { + implementation project(':expo-notifications') + implementation 'com.google.firebase:firebase-messaging:25.0.1' + implementation 'androidx.core:core:1.17.0' + implementation 'androidx.lifecycle:lifecycle-process:2.9.3' + testImplementation 'junit:junit:4.13.2' + testImplementation 'org.robolectric:robolectric:4.16.1' +} + +// Expo compiles modules with Java 17; Robolectric's Android 16 runtime needs 21. +tasks.withType(Test).configureEach { + javaLauncher = javaToolchains.launcherFor { + languageVersion = JavaLanguageVersion.of(21) + } +} diff --git a/apps/mobile/modules/t3-agent-notifications/android/src/main/AndroidManifest.xml b/apps/mobile/modules/t3-agent-notifications/android/src/main/AndroidManifest.xml new file mode 100644 index 0000000000..a7e07f45ec --- /dev/null +++ b/apps/mobile/modules/t3-agent-notifications/android/src/main/AndroidManifest.xml @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + diff --git a/apps/mobile/modules/t3-agent-notifications/android/src/main/java/expo/modules/t3agentnotifications/AgentNotifications.kt b/apps/mobile/modules/t3-agent-notifications/android/src/main/java/expo/modules/t3agentnotifications/AgentNotifications.kt new file mode 100644 index 0000000000..e04a781003 --- /dev/null +++ b/apps/mobile/modules/t3-agent-notifications/android/src/main/java/expo/modules/t3agentnotifications/AgentNotifications.kt @@ -0,0 +1,336 @@ +package expo.modules.t3agentnotifications + +import android.app.NotificationChannel +import android.app.Notification +import android.app.AlarmManager +import android.app.NotificationManager +import android.app.PendingIntent +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import android.content.SharedPreferences +import android.net.Uri +import android.os.Build +import android.text.TextPaint +import android.text.TextUtils +import androidx.core.app.NotificationCompat +import androidx.core.app.NotificationManagerCompat +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.ProcessLifecycleOwner +import com.google.firebase.messaging.RemoteMessage +import expo.modules.notifications.service.ExpoFirebaseMessagingService + +class AgentMessagingService : ExpoFirebaseMessagingService() { + override fun onMessageReceived(remoteMessage: RemoteMessage) { + if (remoteMessage.data["t3_kind"] == "agent_activity") { + AgentNotifications.receive(this, remoteMessage.data) + } else { + super.onMessageReceived(remoteMessage) + } + } +} + +class AgentActivityDismissReceiver : BroadcastReceiver() { + override fun onReceive(context: Context, intent: Intent) { + AgentNotifications.dismiss(context) + } +} + +class AgentActivityExpiryReceiver : BroadcastReceiver() { + override fun onReceive(context: Context, intent: Intent) { + AgentNotifications.expire(context) + } +} + +/** Handles data pushes natively so delivery does not depend on a running JS bridge. */ +object AgentNotifications { + private const val STORE = "t3-agent-notifications" + private const val ACTIVITY_CHANNEL = "agent-activity" + private const val ALERT_CHANNEL = "agent-alerts" + private const val ACTIVITY_TAG = "t3-agent-activity" + private const val ALERT_TAG = "t3-agent-alert" + private const val ACTIVITY_ID = 73001 + private const val MAX_MESSAGE_AGE_MS = 10 * 60 * 1000L + private const val RUNNING_LIFETIME_MS = 2 * 60 * 60 * 1000L + private const val MAX_LIFETIME_MS = 24 * 60 * 60 * 1000L + + @Synchronized + fun configure( + context: Context, + deviceId: String, + userId: String, + scheme: String, + ongoingEnabled: Boolean + ) { + val prefs = context.getSharedPreferences(STORE, Context.MODE_PRIVATE) + // JS identity is empty on a cold start. Compare the durable identity here + // so reopening preserves cards, dismissal and replay history for this user. + if (prefs.getString("userId", null) != userId || + prefs.getString("deviceId", null) != deviceId + ) { + clear(context) + } + val wasEnabled = prefs.getBoolean("ongoing", false) + prefs.edit().putString( + "deviceId", + deviceId + ).putString("userId", userId).putString("scheme", scheme) + .putBoolean("enabled", true).putBoolean("ongoing", ongoingEnabled).apply() + if (ongoingEnabled && !wasEnabled) prefs.edit().putBoolean("dismissed", false).apply() + if (!ongoingEnabled) cancelActivity(context) + channels(context) + } + + @Synchronized + fun clear(context: Context) { + cancelActivity(context) + context.getSharedPreferences(STORE, Context.MODE_PRIVATE).edit().clear().apply() + val manager = manager(context) + manager.activeNotifications.filter { it.tag == ACTIVITY_TAG || it.tag == ALERT_TAG } + .forEach { manager.cancel(it.tag, it.id) } + } + + @Synchronized + fun dismiss(context: Context) { + context.getSharedPreferences( + STORE, + Context.MODE_PRIVATE + ).edit().putBoolean("dismissed", true).apply() + cancelActivity(context) + } + + @Synchronized + fun expire(context: Context, now: Long = System.currentTimeMillis()) { + val expiresAt = context.getSharedPreferences( + STORE, + Context.MODE_PRIVATE + ).getLong("expiresAt", 0) + // An already-dispatched alarm must not remove a newer run's card. + if (expiresAt > 0 && expiresAt <= now) cancelActivity(context) + } + + @Synchronized + fun receive(context: Context, data: Map) { + val prefs = context.getSharedPreferences(STORE, Context.MODE_PRIVATE) + val updatedAt = data["updated_at"]?.toLongOrNull() ?: return + val registered = prefs.getBoolean("enabled", false) && + data["device_id"] == prefs.getString("deviceId", null) && + data["user_id"] == prefs.getString("userId", null) + val fresh = System.currentTimeMillis() - updatedAt in -MAX_MESSAGE_AGE_MS..MAX_MESSAGE_AGE_MS + if (registered && fresh && NotificationManagerCompat.from(context).areNotificationsEnabled()) { + channels(context) + val scheme = prefs.getString("scheme", "pylon-code") ?: "pylon-code" + showAlert(context, prefs, scheme, data) + updateActivity(context, prefs, scheme, data, updatedAt) + } + } + + private fun showAlert( + context: Context, + prefs: SharedPreferences, + scheme: String, + data: Map + ) { + // Queue retries carry the same alert id. Keep a bounded history even when + // notification A is retried after notification B has already arrived. + val alertId = data["alert_id"] + val seen = prefs.getString("seenAlertsOrdered", null)?.split('\n') + ?: prefs.getStringSet("seenAlerts", emptySet()).orEmpty().toList() + if (alertId != null && alertId !in seen) { + // Match iOS foreground presentation. Consume suppressed alerts as well, + // so a delivery retry cannot surface them after the app backgrounds. + if (!ProcessLifecycleOwner.get().lifecycle.currentState.isAtLeast(Lifecycle.State.RESUMED)) { + val title = data["alert_title"].orEmpty().take(120) + // Grouped alerts list up to five 120-character thread titles. + val body = data["alert_body"].orEmpty().take(608) + val id = alertId.hashCode() + val notification = base(context, ALERT_CHANNEL) + .setContentTitle(title).setContentText(body) + .setStyle(NotificationCompat.BigTextStyle().bigText(body)) + .setAutoCancel(true) + .setContentIntent(contentIntent(context, scheme, data["alert_path"], id)) + .build() + manager(context).notify(ALERT_TAG, id, notification) + } + prefs.edit().remove("seenAlerts").putString( + "seenAlertsOrdered", + (seen.takeLast(63) + alertId).joinToString("\n") + ).apply() + } + } + + private fun updateActivity( + context: Context, + prefs: SharedPreferences, + scheme: String, + data: Map, + updatedAt: Long + ) { + // Ignore reordered status updates without dropping an unrelated alert. + if (updatedAt < prefs.getLong("lastUpdate", 0)) return + prefs.edit().putLong("lastUpdate", updatedAt).apply() + val active = data["active"] == "true" + // Use absolute state expiry: a replay must not extend a finished card or + // make an abandoned host look active indefinitely. Older relays omit it. + val expiresAt = data["activity_expires_at"]?.toLongOrNull() + ?: if (active) updatedAt + RUNNING_LIFETIME_MS else 0L + val remainingMs = (expiresAt - System.currentTimeMillis()).coerceAtMost(MAX_LIFETIME_MS) + val wasActive = prefs.getBoolean("lastActive", false) + prefs.edit().putBoolean("lastActive", active).apply() + if (remainingMs <= 0 || !prefs.getBoolean("ongoing", false)) { + cancelActivity(context) + prefs.edit().putBoolean("dismissed", false).apply() + return + } + // Dismissing a run includes its finished card. A new run, or toggling + // activity off/on, arms it again; terminal replays stay dismissed. + if (active && !wasActive) prefs.edit().putBoolean("dismissed", false).apply() + if (!prefs.getBoolean("dismissed", false)) { + showActivity(context, scheme, data, active, remainingMs) + } + } + + private fun showActivity( + context: Context, + scheme: String, + data: Map, + active: Boolean, + remainingMs: Long + ) { + val body = data["activity_body"].orEmpty().take(240) + val dismissIntent = PendingIntent.getBroadcast( + context, + ACTIVITY_ID, + Intent(context, AgentActivityDismissReceiver::class.java), + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE + ) + val lines = (0..4).mapNotNull { + data["activity_line_$it"]?.let { line -> activityLine(context, line) } + } + // BigTextStyle remains eligible for Android Live Update promotion. + val style = NotificationCompat.BigTextStyle().bigText( + if (lines.isEmpty()) body else lines.joinToString("\n") + ) + val notification = base(context, ACTIVITY_CHANNEL) + .setContentTitle(data["activity_title"].orEmpty().take(120)) + .setContentText(body) + .setStyle(style) + .setOngoing(active).setOnlyAlertOnce(true).setSilent(true) + .setTimeoutAfter(remainingMs) + // Live Updates must remain uncolorized to qualify for promotion. + .setColorized(false) + .setRequestPromotedOngoing(active) + .setContentIntent(contentIntent(context, scheme, data["activity_path"], ACTIVITY_ID)) + .setDeleteIntent(dismissIntent) + .addAction(0, "Dismiss", dismissIntent) + .build() + manager(context).notify(ACTIVITY_TAG, ACTIVITY_ID, notification) + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) { + // Notification timeouts were added in API 26. One inexact alarm also + // expires cards on Android 7, including when the app process has exited. + // No exact-alarm permission, foreground service or periodic work needed. + val expiresAt = System.currentTimeMillis() + remainingMs + context.getSharedPreferences(STORE, Context.MODE_PRIVATE).edit() + .putLong("expiresAt", expiresAt).apply() + context.getSystemService( + AlarmManager::class.java + ).setAndAllowWhileIdle(AlarmManager.RTC_WAKEUP, expiresAt, expiryIntent(context)) + } + } + + private fun expiryIntent(context: Context): PendingIntent = PendingIntent.getBroadcast( + context, + ACTIVITY_ID, + Intent(context, AgentActivityExpiryReceiver::class.java), + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE + ) + + private fun cancelActivity(context: Context) { + manager(context).cancel(ACTIVITY_TAG, ACTIVITY_ID) + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) { + context.getSystemService(AlarmManager::class.java).cancel(expiryIntent(context)) + context.getSharedPreferences(STORE, Context.MODE_PRIVATE).edit().remove("expiresAt").apply() + } + } + + private fun activityLine(context: Context, value: String): String { + val parts = value.split('\t', limit = 3) + if (parts.size != 3) return value.take(300) + val metrics = context.resources.displayMetrics + val paint = TextPaint().apply { textSize = 14 * metrics.scaledDensity } + val prefix = "${parts[0]}: " + val separator = " · " + // Reserve the system notification's icon and margins. Fit the two titles + // independently so large fonts/long names never hide the project or status. + // The shade uses a narrow column even when a headless service sees a + // foldable's wider display metrics. Keep rows inside that column too. + val width = (metrics.widthPixels - 152 * metrics.density) + .coerceIn(120 * metrics.density, 280 * metrics.density) + val available = (width - paint.measureText(prefix + separator)).coerceAtLeast(0f) + val projectWidth = paint.measureText(parts[2]).coerceAtMost(available * 0.4f) + val titleWidth = paint.measureText(parts[1]).coerceAtMost(available - projectWidth) + val title = TextUtils.ellipsize(parts[1], paint, titleWidth, TextUtils.TruncateAt.END) + val project = TextUtils.ellipsize( + parts[2], + paint, + available - titleWidth, + TextUtils.TruncateAt.END + ) + return "$prefix$title$separator$project" + } + + private fun manager(context: Context) = context.getSystemService(NotificationManager::class.java) + + private fun channels(context: Context) { + if (Build.VERSION.SDK_INT >= 26) { + manager(context).createNotificationChannels( + listOf( + NotificationChannel(ALERT_CHANNEL, "Agent alerts", NotificationManager.IMPORTANCE_HIGH), + NotificationChannel( + ACTIVITY_CHANNEL, + "Ongoing agent activity", + NotificationManager.IMPORTANCE_LOW + ), + ) + ) + } + } + + private fun base(context: Context, channel: String): NotificationCompat.Builder { + val icon = context.resources.getIdentifier("notification_icon", "drawable", context.packageName) + return NotificationCompat.Builder(context, channel) + .setPriority( + if (channel == + ALERT_CHANNEL + ) { + NotificationCompat.PRIORITY_HIGH + } else { + NotificationCompat.PRIORITY_LOW + } + ) + .setDefaults(if (channel == ALERT_CHANNEL) Notification.DEFAULT_ALL else 0) + .setSmallIcon(if (icon != 0) icon else android.R.drawable.ic_dialog_info) + .setVisibility(NotificationCompat.VISIBILITY_PRIVATE) + .setShowWhen(false) + } + + private fun contentIntent( + context: Context, + scheme: String, + path: String?, + id: Int + ): PendingIntent? { + val threadPath = path?.takeIf { it.startsWith("/threads/") } + val route = threadPath?.takeUnless { it.contains('?') || it.contains('#') } ?: "/" + val intent = context.packageManager.getLaunchIntentForPackage(context.packageName) + ?: return null + intent.setAction(Intent.ACTION_VIEW).setData(Uri.parse("$scheme:/$route")) + .addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_SINGLE_TOP) + return PendingIntent.getActivity( + context, + id, + intent, + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE + ) + } +} diff --git a/apps/mobile/modules/t3-agent-notifications/android/src/main/java/expo/modules/t3agentnotifications/T3AgentNotificationsModule.kt b/apps/mobile/modules/t3-agent-notifications/android/src/main/java/expo/modules/t3agentnotifications/T3AgentNotificationsModule.kt new file mode 100644 index 0000000000..d394db5611 --- /dev/null +++ b/apps/mobile/modules/t3-agent-notifications/android/src/main/java/expo/modules/t3agentnotifications/T3AgentNotificationsModule.kt @@ -0,0 +1,25 @@ +package expo.modules.t3agentnotifications + +import expo.modules.kotlin.modules.Module +import expo.modules.kotlin.modules.ModuleDefinition + +class T3AgentNotificationsModule : Module() { + override fun definition() = ModuleDefinition { + Name("T3AgentNotifications") + + Function("configure") { + deviceId: String, + userId: String, + scheme: String, + ongoingEnabled: Boolean + -> + appContext.reactContext?.let { + AgentNotifications.configure(it, deviceId, userId, scheme, ongoingEnabled) + } + } + + Function("clear") { + appContext.reactContext?.let { AgentNotifications.clear(it) } + } + } +} diff --git a/apps/mobile/modules/t3-agent-notifications/android/src/test/java/expo/modules/t3agentnotifications/AgentNotificationsTest.kt b/apps/mobile/modules/t3-agent-notifications/android/src/test/java/expo/modules/t3agentnotifications/AgentNotificationsTest.kt new file mode 100644 index 0000000000..2cfe346669 --- /dev/null +++ b/apps/mobile/modules/t3-agent-notifications/android/src/test/java/expo/modules/t3agentnotifications/AgentNotificationsTest.kt @@ -0,0 +1,430 @@ +package expo.modules.t3agentnotifications + +import android.app.Activity +import android.app.AlarmManager +import android.app.Application +import android.app.Notification +import android.app.NotificationManager +import android.content.ComponentName +import android.content.Intent +import android.content.IntentFilter +import android.os.Build +import androidx.core.app.NotificationCompat +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleRegistry +import androidx.lifecycle.ProcessLifecycleOwner +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.RuntimeEnvironment +import org.robolectric.Shadows.shadowOf +import org.robolectric.annotation.Config + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [24, 26, 33, 36], manifest = Config.NONE) +class AgentNotificationsTest { + private lateinit var context: Application + private lateinit var manager: NotificationManager + private lateinit var lifecycle: LifecycleRegistry + + @Before + fun setUp() { + context = RuntimeEnvironment.getApplication() + manager = context.getSystemService(NotificationManager::class.java) + shadowOf(manager).setNotificationsEnabled(true) + lifecycle = ProcessLifecycleOwner.get().lifecycle as LifecycleRegistry + lifecycle.currentState = Lifecycle.State.CREATED + + val launcher = ComponentName(context, Activity::class.java) + shadowOf(context.packageManager).addActivityIfNotPresent(launcher) + shadowOf(context.packageManager).addIntentFilterForActivity( + launcher, + IntentFilter(Intent.ACTION_MAIN).apply { + addCategory(Intent.CATEGORY_LAUNCHER) + } + ) + AgentNotifications.clear(context) + AgentNotifications.configure(context, "device", "user", "pylon-code-dev", true) + } + + private fun update(alertId: String, active: Boolean) = mapOf( + "device_id" to "device", + "user_id" to "user", + "updated_at" to System.currentTimeMillis().toString(), + "active" to active.toString(), + "activity_title" to "1 active agent", + "activity_body" to "Test thread · Working", + "activity_path" to "/threads/environment/thread", + "alert_id" to alertId, + "alert_title" to "Test thread", + "alert_body" to "Done: Test project", + "alert_path" to "/threads/environment/thread", + ) + + @Test + fun alertHistoryEvictsOnlyTheOldestEntryAfterCapacity() { + lifecycle.currentState = Lifecycle.State.RESUMED + for (id in 0..64) AgentNotifications.receive(context, update("alert-$id", false)) + lifecycle.currentState = Lifecycle.State.CREATED + for (id in 1..64) AgentNotifications.receive(context, update("alert-$id", false)) + assertTrue(manager.activeNotifications.isEmpty()) + AgentNotifications.receive(context, update("alert-0", false)) + assertEquals("alert-0".hashCode(), manager.activeNotifications.single().id) + } + + @Test + fun missingLauncherDoesNotDiscardTheAlert() { + shadowOf(context.packageManager).removeActivity(ComponentName(context, Activity::class.java)) + AgentNotifications.receive(context, update("no-launcher", false)) + assertEquals( + "Test thread", + manager.activeNotifications.single().notification.extras.getString(Notification.EXTRA_TITLE) + ) + } + + @Test + fun foregroundSuppressesAlertsWhileOngoingActivityStillUpdatesAndClears() { + lifecycle.currentState = Lifecycle.State.RESUMED + + AgentNotifications.receive(context, update("attention", true)) + + val ongoing = manager.activeNotifications.single() + assertEquals("t3-agent-activity", ongoing.tag) + assertEquals("1 active agent", ongoing.notification.extras.getString(Notification.EXTRA_TITLE)) + assertTrue(ongoing.notification.flags and Notification.FLAG_ONGOING_EVENT != 0) + + AgentNotifications.receive(context, update("completion", false)) + + assertTrue(manager.activeNotifications.isEmpty()) + } + + @Test + fun backgroundCompletionAlertsAndClearsOngoingActivity() { + lifecycle.currentState = Lifecycle.State.RESUMED + AgentNotifications.receive(context, update("running", true)) + lifecycle.currentState = Lifecycle.State.CREATED + + AgentNotifications.receive(context, update("completion", false)) + + val alert = manager.activeNotifications.single() + assertEquals("t3-agent-alert", alert.tag) + assertEquals("Test thread", alert.notification.extras.getString(Notification.EXTRA_TITLE)) + assertFalse(alert.notification.flags and Notification.FLAG_ONGOING_EVENT != 0) + } + + @Test + fun retryOfForegroundSuppressedAlertDoesNotAppearAfterBackgrounding() { + lifecycle.currentState = Lifecycle.State.RESUMED + val suppressed = update("foreground-completion", false) + AgentNotifications.receive(context, suppressed) + lifecycle.currentState = Lifecycle.State.CREATED + + AgentNotifications.receive(context, suppressed) + + assertTrue(manager.activeNotifications.isEmpty()) + + AgentNotifications.receive(context, update("later-background-completion", false)) + + assertEquals("later-background-completion".hashCode(), manager.activeNotifications.single().id) + } + + @Test + fun returningToForegroundSuppressesNewAlertsWithoutRemovingPreviousOnes() { + AgentNotifications.receive(context, update("background-completion", false)) + lifecycle.currentState = Lifecycle.State.RESUMED + + AgentNotifications.receive(context, update("foreground-completion", false)) + + assertEquals("background-completion".hashCode(), manager.activeNotifications.single().id) + } + + @Test + fun groupedAlertDisplaysEveryThreadAndRetriesStaySilent() { + val titles = (1..5).map { "Thread $it " + "x".repeat(111) }.joinToString(", ") + val grouped = update("group-completion", false) + mapOf( + "alert_title" to "5 agents finished", + "alert_body" to titles, + "alert_path" to "/", + ) + + AgentNotifications.receive(context, grouped) + AgentNotifications.receive( + context, + grouped + ("alert_body" to "A retry must not replace this alert") + ) + + val alert = manager.activeNotifications.single() + assertEquals("5 agents finished", alert.notification.extras.getString(Notification.EXTRA_TITLE)) + assertEquals(titles, alert.notification.extras.getString(Notification.EXTRA_BIG_TEXT)) + assertEquals("pylon-code-dev://", shadowOf(alert.notification.contentIntent).savedIntent.dataString) + } + + @Test + fun foregroundSuppressedGroupCannotAppearOnBackgroundRetry() { + val grouped = update("group-attention", true) + mapOf( + "alert_title" to "2 agents need attention", + "alert_body" to "First thread, Second thread", + "alert_path" to "/", + ) + lifecycle.currentState = Lifecycle.State.RESUMED + AgentNotifications.receive(context, grouped) + lifecycle.currentState = Lifecycle.State.CREATED + AgentNotifications.receive(context, grouped) + + assertEquals("t3-agent-activity", manager.activeNotifications.single().tag) + } + + @Test + fun reopeningSameAccountPreservesCardsDeduplicationAndDismissal() { + val message = update("attention", true) + AgentNotifications.receive(context, message) + AgentNotifications.configure(context, "device", "user", "pylon-code-dev", true) + assertEquals(2, manager.activeNotifications.size) + AgentNotifications.dismiss(context) + AgentNotifications.configure(context, "device", "user", "pylon-code-dev", true) + AgentNotifications.receive(context, message) + assertEquals("t3-agent-alert", manager.activeNotifications.single().tag) + } + + @Test + fun changingAccountOrDeviceClearsOldCardsAndRejectsOldPushes() { + AgentNotifications.receive(context, update("attention", true)) + AgentNotifications.configure(context, "device", "different-user", "pylon-code-dev", true) + AgentNotifications.receive(context, update("attention", true)) + assertTrue(manager.activeNotifications.isEmpty()) + AgentNotifications.configure(context, "different-device", "user", "pylon-code-dev", true) + AgentNotifications.receive(context, update("attention", true)) + assertTrue(manager.activeNotifications.isEmpty()) + AgentNotifications.clear(context) + AgentNotifications.receive(context, update("attention", true)) + assertTrue(manager.activeNotifications.isEmpty()) + } + + @Test + fun expandedActivityShowsFiveRowsAndUsesThePriorityThreadRoute() { + lifecycle.currentState = Lifecycle.State.RESUMED + val lines = + listOf( + "Approval: First · Project", + "Input: Second · Project", + "Failed: Third · Project", + "Working: Fourth · Project", + "Done: Fifth · Project" + ) + AgentNotifications.receive( + context, + update("attention", true) + + lines.mapIndexed { index, line -> "activity_line_$index" to line }.toMap() + ) + val card = manager.activeNotifications.single().notification + assertEquals(lines.joinToString("\n"), card.extras.getString(Notification.EXTRA_BIG_TEXT)) + assertEquals( + "pylon-code-dev://threads/environment/thread", + shadowOf(card.contentIntent).savedIntent.dataString + ) + } + + @Test + fun quietWorkUsesAbsoluteRelayLifetimeInsteadOfTenMinuteRemoval() { + lifecycle.currentState = Lifecycle.State.RESUMED + val expiresAt = System.currentTimeMillis() + 2 * 60 * 60 * 1000L + AgentNotifications.receive( + context, + update("work", true) + ("activity_expires_at" to expiresAt.toString()) + ) + val card = manager.activeNotifications.single().notification + assertTimeout(card, 119 * 60 * 1000L..120 * 60 * 1000L) + } + + @Test + fun finishedCardIsRetainedSilentlyWithoutOngoingFlagAndExpiresAtTheOriginalDeadline() { + lifecycle.currentState = Lifecycle.State.RESUMED + val expiresAt = System.currentTimeMillis() + 15 * 60 * 1000L + val finished = update("finished", false) + mapOf( + "activity_title" to "Agent work failed", + "activity_body" to "Failed: Test thread · Project", + "activity_expires_at" to expiresAt.toString(), + ) + AgentNotifications.receive(context, finished) + val card = manager.activeNotifications.single().notification + assertEquals("Agent work failed", card.extras.getString(Notification.EXTRA_TITLE)) + assertFalse(card.flags and Notification.FLAG_ONGOING_EVENT != 0) + assertTimeout(card, 1..15 * 60 * 1000L) + AgentNotifications.receive( + context, + finished + ("activity_expires_at" to (System.currentTimeMillis() - 1).toString()) + ) + assertTrue(manager.activeNotifications.isEmpty()) + } + + @Test + fun dismissalIncludesFinishedReplaysAndANewRunRearmsTheCard() { + lifecycle.currentState = Lifecycle.State.RESUMED + AgentNotifications.receive(context, update("work", true)) + AgentNotifications.dismiss(context) + val finished = + update("finished", false) + + ("activity_expires_at" to (System.currentTimeMillis() + 900000).toString()) + AgentNotifications.receive(context, finished) + AgentNotifications.receive(context, finished) + assertTrue(manager.activeNotifications.isEmpty()) + AgentNotifications.receive(context, update("new-work", true)) + assertEquals("t3-agent-activity", manager.activeNotifications.single().tag) + AgentNotifications.configure(context, "device", "user", "pylon-code-dev", false) + assertTrue(manager.activeNotifications.isEmpty()) + } + + @Test + fun reorderedActivityDoesNotEraseNewerCardOrDropAnIndependentAlert() { + val now = System.currentTimeMillis() + AgentNotifications.receive(context, update("new", true) + ("updated_at" to now.toString())) + AgentNotifications.receive( + context, + update("older-alert", false) + ("updated_at" to (now - 1000).toString()) + ) + assertEquals(3, manager.activeNotifications.size) + assertEquals(1, manager.activeNotifications.count { it.tag == "t3-agent-activity" }) + shadowOf(manager).setNotificationsEnabled(false) + AgentNotifications.receive(context, update("revoked-permission", true)) + assertEquals(3, manager.activeNotifications.size) + } + + @Test + fun longRowsKeepStatusAndBothTitlesWithinTheNotificationWidth() { + lifecycle.currentState = Lifecycle.State.RESUMED + val raw = "Approval\t${"Long thread name ".repeat(10)}\t${"Project name ".repeat(10)}" + AgentNotifications.receive( + context, + update("long-work", true) + (0..4).associate { "activity_line_$it" to raw } + ) + val lines = manager.activeNotifications.single().notification.extras.getString( + Notification.EXTRA_BIG_TEXT + )!!.split('\n') + assertEquals(5, lines.size) + for (line in lines) { + assertTrue(line.startsWith("Approval: ")) + assertTrue(line.contains(" · ")) + assertTrue(line.length < raw.length) + assertFalse(line.contains('\t')) + assertTrue(line.substringAfter(" · ").isNotBlank()) + } + } + + private fun assertTimeout(card: Notification, expected: LongRange) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + assertTrue(card.timeoutAfter in expected) + assertTrue( + shadowOf(context.getSystemService(AlarmManager::class.java)).scheduledAlarms.isEmpty() + ) + } else { + val alarm = shadowOf( + context.getSystemService(AlarmManager::class.java) + ).scheduledAlarms.single() + assertTrue(alarm.triggerAtTime - System.currentTimeMillis() in expected) + assertEquals(AlarmManager.RTC_WAKEUP, alarm.type) + } + } + + @Test + @Config(sdk = [24, 25]) + fun legacyExpiryRemovesOnlyTheCardAndCannotRemoveANewerRun() { + val alarms = shadowOf(context.getSystemService(AlarmManager::class.java)) + val expiresAt = System.currentTimeMillis() + 900000 + AgentNotifications.receive( + context, + update("done", false) + ("activity_expires_at" to expiresAt.toString()) + ) + val oldExpiry = alarms.scheduledAlarms.single().operation!! + AgentNotifications.receive(context, update("next-run", true)) + assertEquals(1, alarms.scheduledAlarms.size) + val receiver = AgentActivityExpiryReceiver() + receiver.onReceive(context, shadowOf(oldExpiry).savedIntent) + AgentNotifications.expire(context, expiresAt + 60_000) + assertEquals(1, manager.activeNotifications.count { it.tag == "t3-agent-activity" }) + AgentNotifications.expire(context, expiresAt + 2 * 60 * 60 * 1000L) + assertTrue(manager.activeNotifications.all { it.tag == "t3-agent-alert" }) + assertTrue(alarms.scheduledAlarms.isEmpty()) + } + + @Test + @Config(sdk = [24, 25]) + fun legacyExpiryIsCancelledOnDismissDisableAndSignOut() { + val alarms = shadowOf(context.getSystemService(AlarmManager::class.java)) + AgentNotifications.receive(context, update("work", true)) + AgentNotifications.dismiss(context) + assertTrue(alarms.scheduledAlarms.isEmpty()) + AgentNotifications.configure(context, "device", "user", "pylon-code-dev", false) + AgentNotifications.configure(context, "device", "user", "pylon-code-dev", true) + AgentNotifications.receive(context, update("work", true)) + assertEquals(1, alarms.scheduledAlarms.size) + AgentNotifications.configure(context, "device", "user", "pylon-code-dev", false) + assertTrue(alarms.scheduledAlarms.isEmpty()) + AgentNotifications.configure(context, "device", "user", "pylon-code-dev", true) + AgentNotifications.receive(context, update("work", true)) + AgentNotifications.clear(context) + assertTrue(alarms.scheduledAlarms.isEmpty()) + assertTrue(manager.activeNotifications.isEmpty()) + } + + @Test + fun alertsAndActivityUseVersionAppropriatePriorityAndPromotion() { + AgentNotifications.receive(context, update("work", true)) + val alert = manager.activeNotifications.single { it.tag == "t3-agent-alert" }.notification + val card = manager.activeNotifications.single { it.tag == "t3-agent-activity" }.notification + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) { + assertEquals(Notification.PRIORITY_HIGH, alert.priority) + assertTrue(alert.defaults and Notification.DEFAULT_SOUND != 0) + assertEquals(Notification.PRIORITY_LOW, card.priority) + assertEquals(0, card.defaults) + } else { + assertEquals( + NotificationManager.IMPORTANCE_HIGH, + manager.getNotificationChannel(alert.channelId).importance + ) + assertEquals( + NotificationManager.IMPORTANCE_LOW, + manager.getNotificationChannel(card.channelId).importance + ) + } + assertTrue(NotificationCompat.isRequestPromotedOngoing(card)) + assertFalse(NotificationCompat.isRequestPromotedOngoing(alert)) + // Robolectric's API 36 image predates the shipped Live Update rules; + // its hasPromotableCharacteristics() incorrectly requires colorization. + assertFalse(card.extras.getBoolean(NotificationCompat.EXTRA_COLORIZED)) + assertTrue(card.flags and Notification.FLAG_ONGOING_EVENT != 0) + assertEquals(Notification.VISIBILITY_PRIVATE, card.visibility) + } + + @Test + fun expiredMalformedAndFutureMessagesCannotDisplayOrPoisonLaterUpdates() { + val invalid = update("invalid", true) + AgentNotifications.receive(context, invalid - "updated_at") + AgentNotifications.receive(context, invalid + ("updated_at" to "invalid")) + AgentNotifications.receive( + context, + invalid + ("updated_at" to (System.currentTimeMillis() - 600001).toString()) + ) + AgentNotifications.receive( + context, + invalid + ("updated_at" to (System.currentTimeMillis() + 3600000).toString()) + ) + assertTrue(manager.activeNotifications.isEmpty()) + AgentNotifications.receive(context, update("valid", true)) + assertEquals(2, manager.activeNotifications.size) + } + + @Test + fun deniedPermissionDoesNotConsumeAnAlertBeforeTheUserAllowsNotifications() { + shadowOf(manager).setNotificationsEnabled(false) + val message = update("attention", true) + AgentNotifications.receive(context, message) + assertTrue(manager.activeNotifications.isEmpty()) + shadowOf(manager).setNotificationsEnabled(true) + AgentNotifications.receive(context, message) + assertEquals(2, manager.activeNotifications.size) + } +} diff --git a/apps/mobile/modules/t3-agent-notifications/expo-module.config.json b/apps/mobile/modules/t3-agent-notifications/expo-module.config.json new file mode 100644 index 0000000000..2ac57ee280 --- /dev/null +++ b/apps/mobile/modules/t3-agent-notifications/expo-module.config.json @@ -0,0 +1,4 @@ +{ + "platforms": ["android"], + "android": { "modules": ["expo.modules.t3agentnotifications.T3AgentNotificationsModule"] } +} diff --git a/apps/mobile/package.json b/apps/mobile/package.json index c3d287b70d..dee50460a6 100644 --- a/apps/mobile/package.json +++ b/apps/mobile/package.json @@ -144,6 +144,7 @@ }, "autolinking": { "buildFromSource": [ + "expo-notifications", "react-native-screens", "@react-native-menu/menu", "expo-audio" 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 0000000000..3adf89a1d6 --- /dev/null +++ b/apps/mobile/src/features/agent-awareness/androidNotifications.test.ts @@ -0,0 +1,68 @@ +import { beforeEach, describe, expect, it, vi } from "vite-plus/test"; + +const mocks = vi.hoisted(() => ({ + os: "android", + native: null as { configure?: ReturnType; clear?: ReturnType } | null, + config: { scheme: ["pylon-code-preview"], extra: { iosPersonalTeamBuild: false } }, + requireModule: vi.fn(), +})); + +vi.mock("expo", () => ({ requireOptionalNativeModule: mocks.requireModule })); +vi.mock("expo-constants", () => ({ default: { expoConfig: mocks.config } })); +vi.mock("react-native", () => ({ + Platform: { + get OS() { + return mocks.os; + }, + }, +})); + +beforeEach(() => { + vi.resetModules(); + mocks.os = "android"; + mocks.native = { configure: vi.fn(), clear: vi.fn() }; + mocks.config.extra.iosPersonalTeamBuild = false; + mocks.requireModule.mockReset().mockImplementation(() => mocks.native); +}); + +describe("Android native notification capability", () => { + it("uses the installed module and the build variant's deep-link scheme", async () => { + const { configureAndroidAgentNotifications, clearAndroidAgentNotifications } = + await import("./androidNotifications"); + const { supportsAgentAwarenessPush } = await import("./capabilities"); + // An iOS-only signing restriction must not disable Android notifications. + mocks.config.extra.iosPersonalTeamBuild = true; + expect(supportsAgentAwarenessPush()).toBe(true); + configureAndroidAgentNotifications("device", "user", false); + expect(mocks.native?.configure).toHaveBeenCalledWith( + "device", + "user", + "pylon-code-preview", + false, + ); + clearAndroidAgentNotifications(); + expect(mocks.native?.clear).toHaveBeenCalledOnce(); + }); + + it.each([null, { clear: vi.fn() }, { configure: vi.fn() }])( + "disables push when the native binary is missing required methods (%j)", + async (native) => { + mocks.native = native; + const { configureAndroidAgentNotifications, clearAndroidAgentNotifications } = + await import("./androidNotifications"); + const { supportsAgentAwarenessPush } = await import("./capabilities"); + expect(supportsAgentAwarenessPush()).toBe(false); + expect(() => configureAndroidAgentNotifications("device", "user", true)).not.toThrow(); + expect(() => clearAndroidAgentNotifications()).not.toThrow(); + }, + ); + + it("preserves the iOS personal-team restriction without loading Android code", async () => { + mocks.os = "ios"; + const { supportsAgentAwarenessPush } = await import("./capabilities"); + expect(supportsAgentAwarenessPush()).toBe(true); + mocks.config.extra.iosPersonalTeamBuild = true; + expect(supportsAgentAwarenessPush()).toBe(false); + expect(mocks.requireModule).not.toHaveBeenCalled(); + }); +}); 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 0000000000..7e89f7c2f6 --- /dev/null +++ b/apps/mobile/src/features/agent-awareness/androidNotifications.ts @@ -0,0 +1,35 @@ +import Constants from "expo-constants"; +import { requireOptionalNativeModule } from "expo"; +import { Platform } from "react-native"; + +interface AndroidAgentNotifications { + configure(deviceId: string, userId: string, scheme: string, ongoingEnabled: boolean): void; + clear(): void; +} + +const native = + Platform.OS === "android" + ? requireOptionalNativeModule("T3AgentNotifications") + : null; + +export function supportsAndroidAgentNotifications(): boolean { + return typeof native?.configure === "function" && typeof native?.clear === "function"; +} + +export function configureAndroidAgentNotifications( + deviceId: string, + userId: string, + ongoingEnabled: boolean, +): void { + const scheme = Constants.expoConfig?.scheme; + native?.configure?.( + deviceId, + userId, + (Array.isArray(scheme) ? scheme[0] : scheme) ?? "pylon-code", + ongoingEnabled, + ); +} + +export function clearAndroidAgentNotifications(): void { + native?.clear?.(); +} diff --git a/apps/mobile/src/features/agent-awareness/capabilities.ts b/apps/mobile/src/features/agent-awareness/capabilities.ts index d627f36575..e439c89741 100644 --- a/apps/mobile/src/features/agent-awareness/capabilities.ts +++ b/apps/mobile/src/features/agent-awareness/capabilities.ts @@ -1,5 +1,9 @@ import Constants from "expo-constants"; +import { Platform } from "react-native"; +import { supportsAndroidAgentNotifications } from "./androidNotifications"; export function supportsAgentAwarenessPush() { - return Constants.expoConfig?.extra?.iosPersonalTeamBuild !== true; + return Platform.OS === "android" + ? supportsAndroidAgentNotifications() + : Platform.OS === "ios" && Constants.expoConfig?.extra?.iosPersonalTeamBuild !== true; } diff --git a/apps/mobile/src/features/agent-awareness/notificationPermissions.test.ts b/apps/mobile/src/features/agent-awareness/notificationPermissions.test.ts new file mode 100644 index 0000000000..def7443d54 --- /dev/null +++ b/apps/mobile/src/features/agent-awareness/notificationPermissions.test.ts @@ -0,0 +1,59 @@ +import { beforeEach, vi } from "vite-plus/test"; +import { describe, expect, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Notifications from "expo-notifications"; +import { requestAgentNotificationPermission } from "./notificationPermissions"; + +const platform = vi.hoisted(() => ({ OS: "android" })); +vi.mock("react-native", () => ({ Platform: platform })); +vi.mock("expo-notifications", () => ({ + AndroidImportance: { HIGH: 4 }, + setNotificationChannelAsync: vi.fn(() => Promise.resolve(null)), + getPermissionsAsync: vi.fn(() => Promise.resolve({ granted: false, canAskAgain: true })), + requestPermissionsAsync: vi.fn(() => Promise.resolve({ granted: true, canAskAgain: true })), +})); + +describe("agent notification permission", () => { + beforeEach(() => { + vi.clearAllMocks(); + platform.OS = "android"; + }); + + it.effect("creates an Android channel before prompting for notification permission", () => + Effect.gen(function* () { + expect(yield* requestAgentNotificationPermission).toEqual({ type: "granted" }); + expect(Notifications.setNotificationChannelAsync).toHaveBeenCalledWith("agent-alerts", { + name: "Agent alerts", + importance: 4, + }); + expect( + vi.mocked(Notifications.setNotificationChannelAsync).mock.invocationCallOrder[0], + ).toBeLessThan(vi.mocked(Notifications.requestPermissionsAsync).mock.invocationCallOrder[0]!); + }), + ); + + it.effect("preserves denied permission when Android cannot ask again", () => + Effect.gen(function* () { + vi.mocked(Notifications.getPermissionsAsync).mockResolvedValueOnce({ + granted: false, + canAskAgain: false, + } as Notifications.NotificationPermissionsStatus); + expect(yield* requestAgentNotificationPermission).toEqual({ + type: "denied", + canAskAgain: false, + }); + expect(Notifications.requestPermissionsAsync).not.toHaveBeenCalled(); + }), + ); + + it.effect("keeps iOS permission requests free of Android channel setup", () => + Effect.gen(function* () { + platform.OS = "ios"; + expect(yield* requestAgentNotificationPermission).toEqual({ type: "granted" }); + expect(Notifications.setNotificationChannelAsync).not.toHaveBeenCalled(); + expect(Notifications.requestPermissionsAsync).toHaveBeenCalledWith({ + ios: { allowAlert: true, allowBadge: true, allowSound: true }, + }); + }), + ); +}); diff --git a/apps/mobile/src/features/agent-awareness/notificationPermissions.ts b/apps/mobile/src/features/agent-awareness/notificationPermissions.ts index dc275774a5..969f8c7055 100644 --- a/apps/mobile/src/features/agent-awareness/notificationPermissions.ts +++ b/apps/mobile/src/features/agent-awareness/notificationPermissions.ts @@ -15,7 +15,7 @@ export class NotificationPermissionReadError extends Schema.TaggedErrorClass = Effect.gen(function* () { - if (Platform.OS !== "ios") { + if (Platform.OS !== "ios" && Platform.OS !== "android") { return { type: "unsupported" }; } + if (Platform.OS === "android") { + yield* Effect.tryPromise({ + try: () => + Notifications.setNotificationChannelAsync("agent-alerts", { + name: "Agent alerts", + importance: Notifications.AndroidImportance.HIGH, + }), + catch: (cause) => new NotificationPermissionRequestError({ cause }), + }); + } + const existing = yield* Effect.tryPromise({ try: () => Notifications.getPermissionsAsync(), catch: (cause) => new NotificationPermissionReadError({ cause }), diff --git a/apps/mobile/src/features/agent-awareness/registrationPayload.ts b/apps/mobile/src/features/agent-awareness/registrationPayload.ts index 8279a27fee..629ec5f60a 100644 --- a/apps/mobile/src/features/agent-awareness/registrationPayload.ts +++ b/apps/mobile/src/features/agent-awareness/registrationPayload.ts @@ -10,25 +10,31 @@ export function resolveApsEnvironment(appVariant: unknown): "sandbox" | "product return appVariant === "development" ? "sandbox" : "production"; } -export function makeRelayDeviceRegistrationRequest(input: { - readonly deviceId: string; - readonly label: string; - readonly iosMajorVersion: number; - readonly appVersion?: string; - readonly bundleId?: string; - readonly apsEnvironment?: "sandbox" | "production"; - readonly pushToken?: string; - readonly pushToStartToken?: string; - readonly notificationsEnabled: boolean; - readonly preferences: Preferences; -}): RelayDeviceRegistrationRequest { +export function makeRelayDeviceRegistrationRequest( + input: { + readonly deviceId: string; + readonly label: string; + readonly appVersion?: string; + readonly bundleId?: string; + readonly apsEnvironment?: "sandbox" | "production"; + readonly pushToken?: string; + readonly pushToStartToken?: string; + readonly notificationsEnabled: boolean; + readonly preferences: Preferences; + } & ( + | { readonly platform?: "ios"; readonly iosMajorVersion: number } + | { readonly platform: "android"; readonly androidApiLevel: number } + ), +): RelayDeviceRegistrationRequest { const pushAvailable = supportsAgentAwarenessPush(); const liveActivitiesEnabled = pushAvailable && input.preferences.liveActivitiesEnabled !== false; return { deviceId: input.deviceId, label: input.label, - platform: "ios", - iosMajorVersion: input.iosMajorVersion, + platform: input.platform ?? "ios", + ...(input.platform === "android" + ? { androidApiLevel: input.androidApiLevel } + : { iosMajorVersion: input.iosMajorVersion }), appVersion: input.appVersion, ...(input.bundleId ? { bundleId: input.bundleId } : {}), ...(input.apsEnvironment ? { apsEnvironment: input.apsEnvironment } : {}), diff --git a/apps/mobile/src/features/agent-awareness/remoteRegistration.test.ts b/apps/mobile/src/features/agent-awareness/remoteRegistration.test.ts index 39bce6b4b9..f1b84f0af8 100644 --- a/apps/mobile/src/features/agent-awareness/remoteRegistration.test.ts +++ b/apps/mobile/src/features/agent-awareness/remoteRegistration.test.ts @@ -5,6 +5,7 @@ import * as NodeCrypto from "node:crypto"; import { beforeEach, vi } from "vite-plus/test"; import { describe, expect, it } from "@effect/vitest"; import Constants from "expo-constants"; +import * as Cause from "effect/Cause"; import * as Effect from "effect/Effect"; import * as Exit from "effect/Exit"; import * as Layer from "effect/Layer"; @@ -39,8 +40,20 @@ import { setAgentAwarenessRelayTokenProvider, shouldRegisterAgentAwarenessDeviceForProvider, unregisterAgentAwarenessConnection, + updateAgentAwarenessRegistrationPreferences, } from "./remoteRegistration"; import * as Notifications from "expo-notifications"; +import { Platform } from "react-native"; +import { + configureAndroidAgentNotifications, + clearAndroidAgentNotifications, +} from "./androidNotifications"; + +vi.mock("./androidNotifications", () => ({ + supportsAndroidAgentNotifications: vi.fn(() => true), + configureAndroidAgentNotifications: vi.fn(), + clearAndroidAgentNotifications: vi.fn(), +})); const secureStore = vi.hoisted(() => new Map()); const widgetMocks = vi.hoisted(() => ({ @@ -138,8 +151,12 @@ vi.mock("expo-secure-store", () => ({ vi.mock("react-native", () => ({ Platform: { - OS: "ios", - Version: "18.0", + get OS() { + return "ios"; + }, + get Version() { + return "18.0"; + }, }, AppState: { addEventListener: (_event: string, listener: (state: string) => void) => { @@ -235,6 +252,11 @@ const runBackgroundOperations = Effect.fn("TestRemoteRegistration.runBackgroundO describe("makeRelayDeviceRegistrationRequest", () => { beforeEach(() => { + vi.restoreAllMocks(); + vi.mocked(Notifications.getDevicePushTokenAsync).mockResolvedValue({ + type: "ios", + data: "apns-token", + }); vi.unstubAllGlobals(); vi.stubGlobal("__DEV__", false); secureStore.clear(); @@ -924,47 +946,197 @@ describe("makeRelayDeviceRegistrationRequest", () => { await new Promise((resolve) => setTimeout(resolve, 0)); expect(widgetMocks.start).toHaveBeenCalledTimes(1); }); - it.effect( - "does not enable notifications when a token rotates after permission is revoked", - () => { - 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", + for (const os of ["ios", "android"] as const) { + it.effect( + `does not enable ${os} notifications when a token rotates after permission is revoked`, + () => { + vi.spyOn(Platform, "OS", "get").mockReturnValue(os); + vi.spyOn(Platform, "Version", "get").mockReturnValue(os === "ios" ? 18 : 36); + vi.mocked(Notifications.getDevicePushTokenAsync).mockResolvedValue({ + type: os, + data: "initial", + }); + 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 }); + }); + Constants.expoConfig!.extra = { relay: { url: "https://permission-relay.example.test" } }; + setAgentAwarenessRelayTokenProvider(() => Promise.resolve("clerk"), "user-a"); + return Effect.gen(function* () { + yield* runBackgroundOperations(); + expect(registrations.at(-1)).toMatchObject({ + preferences: { notificationsEnabled: true }, + }); + vi.mocked(Notifications.getPermissionsAsync).mockResolvedValueOnce({ + granted: false, + } as Awaited>); + const listener = vi.mocked(Notifications.addPushTokenListener).mock.calls.at(-1)![0]; + listener({ type: os, data: "rotated" }); + yield* runBackgroundOperations(); + expect(registrations.at(-1)).toMatchObject({ + preferences: { notificationsEnabled: false }, + }); + expect(registrations.at(-1)).not.toHaveProperty("pushToken"); + }).pipe( + Effect.provideService(FetchHttpClient.Fetch, globalThis.fetch), + Effect.provide( + managedRelayClientLayer("https://permission-relay.example.test").pipe( + Layer.provide(Layer.mergeAll(FetchHttpClient.layer, cryptoLayer)), + ), + ), + ); + }, + ); + } + it.effect("preserves relay rejection errors with React Native response headers", () => { + vi.spyOn(Platform, "OS", "get").mockReturnValue("android"); + vi.mocked(Notifications.getDevicePushTokenAsync).mockResolvedValue({ + type: "android", + data: "fcm-token", + }); + const rejectedResponse = new Response("Unsupported device platform", { status: 400 }); + Object.defineProperty(rejectedResponse.headers, "getSetCookie", { value: undefined }); + vi.stubGlobal("fetch", (request: RequestInfo | URL) => { + const url = request instanceof Request ? request.url : String(request); + const response = url.endsWith("/v1/client/dpop-token") + ? Response.json({ + access_token: "relay-dpop-token", issued_token_type: "urn:ietf:params:oauth:token-type:access_token", token_type: "DPoP", expires_in: 300, scope: "mobile:registration", - }); - } - registrations.push(await request.json()); - return Response.json({ ok: true }); + }) + : rejectedResponse; + Object.defineProperty(response.headers, "getSetCookie", { value: undefined }); + return Promise.resolve(response); + }); + Constants.expoConfig!.extra = { relay: { url: "https://relay.example.test" } }; + setAgentAwarenessRelayTokenProvider(() => Promise.resolve("clerk-token-user-a"), "user-a"); + + return Effect.gen(function* () { + // Headers lack getSetCookie here, as on Hermes. Reading response cookies + // safely is the Effect patch's concern; this checks the typed failure. + const result = yield* Effect.exit( + updateAgentAwarenessRegistrationPreferences({ liveActivitiesEnabled: true }), + ); + expect(Exit.isFailure(result)).toBe(true); + if (Exit.isFailure(result)) { + expect(Cause.hasDies(result.cause)).toBe(false); + expect(Cause.squash(result.cause)).toBeInstanceOf( + ManagedRelay.ManagedRelayRequestFailedError, + ); + } + expect(getAgentAwarenessRegistrationStatus()).toBe("failed"); + expect(saveAgentAwarenessRegistrationRecord).not.toHaveBeenCalled(); + }).pipe(Effect.provide(relayTestLayer)); + }); + + it.effect("registers an Android FCM token without invoking Apple Live Activities", () => { + vi.spyOn(Platform, "OS", "get").mockReturnValue("android"); + vi.spyOn(Platform, "Version", "get").mockReturnValue(36); + vi.mocked(Notifications.getDevicePushTokenAsync).mockResolvedValue({ + type: "android", + data: "fcm-token", + }); + vi.stubGlobal( + "fetch", + vi.fn((request: RequestInfo | URL) => { + const url = request instanceof Request ? request.url : String(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 }, + ), + ); + }), + ); + Constants.expoConfig!.extra = { relay: { url: "https://relay.example.test" } }; + setAgentAwarenessRelayTokenProvider(() => Promise.resolve("clerk-token-user-a"), "user-a"); + return Effect.gen(function* () { + yield* refreshAgentAwarenessRegistration(); + expect(Notifications.getDevicePushTokenAsync).toHaveBeenCalled(); + expect(saveAgentAwarenessRegistrationRecord).toHaveBeenCalled(); + expect(registrationRecordStore.current?.signature).toContain("fcm-token"); + expect(registrationRecordStore.current?.signature).toContain("android"); + expect(widgetMocks.getInstances).not.toHaveBeenCalled(); + expect(configureAndroidAgentNotifications).toHaveBeenCalledWith("device-1", "user-a", true); + vi.mocked(clearAndroidAgentNotifications).mockClear(); + setAgentAwarenessRelayTokenProvider(() => Promise.resolve("clerk-token-user-b"), "user-b"); + expect(clearAndroidAgentNotifications).toHaveBeenCalled(); + }).pipe(Effect.provide(relayTestLayer)); + }); + it.effect( + "preserves same-account Android notifications and replays on remount and later foreground", + () => { + vi.spyOn(Platform, "OS", "get").mockReturnValue("android"); + vi.spyOn(Platform, "Version", "get").mockReturnValue(36); + vi.mocked(Notifications.getDevicePushTokenAsync).mockResolvedValue({ + type: "android", + data: "fcm-token", }); - Constants.expoConfig!.extra = { relay: { url: "https://permission-relay.example.test" } }; - setAgentAwarenessRelayTokenProvider(() => Promise.resolve("clerk"), "user-a"); + Constants.expoConfig!.extra = { relay: { url: "https://relay.example.test" } }; + const now = vi.spyOn(Date, "now").mockReturnValue(1000000); + vi.mocked(clearAndroidAgentNotifications).mockClear(); + setAgentAwarenessRelayTokenProvider(() => Promise.resolve("clerk-token"), "user-a"); return Effect.gen(function* () { yield* runBackgroundOperations(); - expect(registrations.at(-1)).toMatchObject({ preferences: { notificationsEnabled: true } }); - vi.mocked(Notifications.getPermissionsAsync).mockResolvedValueOnce({ - granted: false, - } as Awaited>); - const listener = vi.mocked(Notifications.addPushTokenListener).mock.calls.at(-1)![0]; - listener({ type: "ios", data: "rotated" }); + expect(getAgentAwarenessRegistrationStatus()).toBe("registered"); + expect(saveAgentAwarenessRegistrationRecord).toHaveBeenCalledTimes(1); + yield* refreshAgentAwarenessRegistration(); + expect(saveAgentAwarenessRegistrationRecord).toHaveBeenCalledTimes(1); + releaseAgentAwarenessRelayTokenProvider(); + setAgentAwarenessRelayTokenProvider(() => Promise.resolve("clerk-token"), "user-a"); yield* runBackgroundOperations(); - expect(registrations.at(-1)).toMatchObject({ - preferences: { notificationsEnabled: false }, - }); - expect(registrations.at(-1)).not.toHaveProperty("pushToken"); - }).pipe( - Effect.provideService(FetchHttpClient.Fetch, globalThis.fetch), - Effect.provide( - managedRelayClientLayer("https://permission-relay.example.test").pipe( - Layer.provide(Layer.mergeAll(FetchHttpClient.layer, cryptoLayer)), - ), - ), - ); + expect(clearAndroidAgentNotifications).not.toHaveBeenCalled(); + expect(saveAgentAwarenessRegistrationRecord).toHaveBeenCalledTimes(2); + for (const listener of appStateMock.listeners) listener("active"); + yield* runBackgroundOperations(); + expect(saveAgentAwarenessRegistrationRecord).toHaveBeenCalledTimes(2); + now.mockReturnValue(1060001); + for (const listener of appStateMock.listeners) listener("active"); + yield* runBackgroundOperations(); + expect(saveAgentAwarenessRegistrationRecord).toHaveBeenCalledTimes(3); + expect(clearAndroidAgentNotifications).not.toHaveBeenCalled(); + expect(widgetMocks.getInstances).not.toHaveBeenCalled(); + setAgentAwarenessRelayTokenProvider(null); + expect(clearAndroidAgentNotifications).toHaveBeenCalled(); + expect(clearAgentAwarenessRegistrationRecord).toHaveBeenCalled(); + }).pipe(Effect.provide(relayTestLayer)); + }, + ); + + it.effect( + "does not reconfigure or register a detached Android account from an in-flight operation", + () => { + vi.spyOn(Platform, "OS", "get").mockReturnValue("android"); + vi.spyOn(Platform, "Version", "get").mockReturnValue(36); + Constants.expoConfig!.extra = { relay: { url: "https://relay.example.test" } }; + setAgentAwarenessRelayTokenProvider(() => Promise.resolve("clerk-token"), "user-a"); + releaseAgentAwarenessRelayTokenProvider(); + vi.mocked(configureAndroidAgentNotifications).mockClear(); + return Effect.gen(function* () { + yield* runBackgroundOperations(); + expect(configureAndroidAgentNotifications).not.toHaveBeenCalled(); + expect(saveAgentAwarenessRegistrationRecord).not.toHaveBeenCalled(); + }).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 ca0024cf15..0ab2543e0f 100644 --- a/apps/mobile/src/features/agent-awareness/remoteRegistration.ts +++ b/apps/mobile/src/features/agent-awareness/remoteRegistration.ts @@ -1,4 +1,8 @@ import { type LiveActivity } from "expo-widgets"; +import { + configureAndroidAgentNotifications, + clearAndroidAgentNotifications, +} from "./androidNotifications"; import Constants from "expo-constants"; import * as Notifications from "expo-notifications"; import * as Effect from "effect/Effect"; @@ -79,6 +83,7 @@ const activityPushTokenListeners = new WeakSet> // sign-out/identity change alongside the device registration state. const ACTIVITY_TOKEN_REREGISTER_INTERVAL_MS = 60_000; const registeredActivityPushTokens = new Map(); +let androidDeviceReplayedAt: number | null = null; let pushTokenSubscription: { remove: () => void } | null = null; let appStateSubscription: { remove: () => void } | null = null; @@ -153,6 +158,10 @@ function canRegisterRemoteLiveActivities(): boolean { return Platform.OS === "ios"; } +function canRegisterPushNotifications(): boolean { + return Platform.OS === "ios" || Platform.OS === "android"; +} + export function shouldRegisterAgentAwarenessDeviceForProvider( previousIdentity: string | null, identity: string | undefined, @@ -168,6 +177,12 @@ export function setAgentAwarenessRelayTokenProvider( provider !== null && !shouldRegisterAgentAwarenessDeviceForProvider(relayTokenProviderIdentity, identity); if (!isExistingIdentity) { + // Native configure compares the persisted account on cold start. An + // unset JS identity is a remount, not evidence of a different account. + if (relayTokenProviderIdentity && identity !== relayTokenProviderIdentity) { + clearAndroidAgentNotifications(); + } + androidDeviceReplayedAt = null; deviceRegistrationGeneration++; activeDeviceRegistration = null; pendingDeviceRegistration = null; @@ -176,6 +191,7 @@ export function setAgentAwarenessRelayTokenProvider( relayTokenProvider = provider; relayTokenProviderIdentity = provider ? (identity ?? null) : null; if (!provider) { + clearAndroidAgentNotifications(); pushTokenSubscription?.remove(); pushTokenSubscription = null; appStateSubscription?.remove(); @@ -221,6 +237,10 @@ export function setAgentAwarenessRelayTokenProvider( export function releaseAgentAwarenessRelayTokenProvider(): void { relayTokenProvider = null; relayTokenProviderIdentity = null; + deviceRegistrationGeneration++; + activeDeviceRegistration = null; + pendingDeviceRegistration = null; + androidDeviceReplayedAt = null; pushTokenSubscription?.remove(); pushTokenSubscription = null; appStateSubscription?.remove(); @@ -242,7 +262,7 @@ function iosMajorVersion(): number { function nativePushTokenRegistration(observedPushToken?: string) { return Effect.gen(function* () { - if (!canRegisterRemoteLiveActivities() || !supportsAgentAwarenessPush()) { + if (!canRegisterPushNotifications() || !supportsAgentAwarenessPush()) { return { notificationsEnabled: false, pushToken: null }; } const permissions = yield* Effect.tryPromise({ @@ -269,13 +289,13 @@ 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 === Platform.OS && typeof token.data === "string" && token.data.trim().length > 0 ? token.data.trim() : null; return { notificationsEnabled: pushToken !== null, pushToken }; @@ -307,7 +327,9 @@ function registrationSignature(body: RelayDeviceRegistrationRequest): string { body.apsEnvironment ?? "", body.appVersion ?? "", body.label, + body.platform, body.iosMajorVersion, + body.androidApiLevel, body.preferences.notificationsEnabled, body.preferences.liveActivitiesEnabled, body.preferences.notifyOnApproval, @@ -372,7 +394,19 @@ function registerDeviceWithRelay( // The relay URL participates so pointing the app at a different relay // invalidates the record and re-registers there. const signature = `${relayConfig.url}|${registrationSignature(payload)}`; - if (persisted && persisted.identity === identity && persisted.signature === signature) { + // Android registration also silently replays the current card. Collapse + // foreground bursts, but repair missed pushes on cold start or a return + // after time away, just like re-registering an iOS activity token. + const needsAndroidReplay = + body.platform === "android" && + (androidDeviceReplayedAt === null || + Date.now() - androidDeviceReplayedAt >= ACTIVITY_TOKEN_REREGISTER_INTERVAL_MS); + if ( + persisted && + persisted.identity === identity && + persisted.signature === signature && + !needsAndroidReplay + ) { setRegistrationStatus("registered"); logRegistrationDebug("relay device registration skipped; already registered for account", { expectedGeneration, @@ -398,6 +432,7 @@ function registerDeviceWithRelay( }); return; } + if (body.platform === "android") androidDeviceReplayedAt = Date.now(); setRegistrationStatus("registered"); yield* Effect.promise(() => saveAgentAwarenessRegistrationRecord({ @@ -684,7 +719,7 @@ function registerDevice( expectedGeneration = deviceRegistrationGeneration, ): Effect.Effect { return Effect.gen(function* () { - if (!canRegisterRemoteLiveActivities()) { + if (!canRegisterPushNotifications()) { logRegistrationDebug("device registration skipped; platform does not support it"); return; } @@ -712,20 +747,37 @@ function registerDevice( storedPreferences, input.preferencesOverride, ); + if (expectedGeneration !== deviceRegistrationGeneration) return; + if (relayTokenProvider && relayTokenProviderIdentity) { + configureAndroidAgentNotifications( + deviceId, + relayTokenProviderIdentity, + preferences.liveActivitiesEnabled !== false, + ); + } const pushTokenRegistration = yield* nativePushTokenRegistration(input?.observedPushToken); logRegistrationDebug("device registration local state ready", { expectedGeneration, notificationsEnabled: pushTokenRegistration.notificationsEnabled, }); - const bundleId = Constants.expoConfig?.ios?.bundleIdentifier?.trim(); + const bundleId = + Platform.OS === "android" + ? Constants.expoConfig?.android?.package?.trim() + : Constants.expoConfig?.ios?.bundleIdentifier?.trim(); yield* registerDeviceWithRelay( makeRelayDeviceRegistrationRequest({ deviceId, - label: Constants.deviceName?.trim() || "iOS device", - iosMajorVersion: iosMajorVersion(), + label: + Constants.deviceName?.trim() || + (Platform.OS === "android" ? "Android device" : "iOS device"), + ...(Platform.OS === "android" + ? { platform: "android" as const, androidApiLevel: Number(Platform.Version) } + : { platform: "ios" as const, iosMajorVersion: iosMajorVersion() }), appVersion: Constants.expoConfig?.version, ...(bundleId ? { bundleId } : {}), - apsEnvironment: resolveApsEnvironment(Constants.expoConfig?.extra?.appVariant), + ...(Platform.OS === "ios" + ? { apsEnvironment: resolveApsEnvironment(Constants.expoConfig?.extra?.appVariant) } + : {}), ...(pushTokenRegistration.pushToken ? { pushToken: pushTokenRegistration.pushToken } : {}), notificationsEnabled: pushTokenRegistration.notificationsEnabled, preferences, @@ -744,15 +796,19 @@ function registerDeviceForCurrentUser(): Effect.Effect< } function ensurePushTokenListener(): void { - if (pushTokenSubscription || !canRegisterRemoteLiveActivities()) { + if (pushTokenSubscription || !canRegisterPushNotifications()) { return; } pushTokenSubscription = Notifications.addPushTokenListener((token) => { - if (token.type === "ios" && typeof token.data === "string" && token.data.trim().length > 0) { + if ( + token.type === Platform.OS && + 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", ); } }); @@ -765,7 +821,7 @@ function ensurePushTokenListener(): void { // foreground/sign-in bursts collapse to one registration, but returning after // real time away still replays.) function ensureAppStateListener(): void { - if (appStateSubscription || !canRegisterRemoteLiveActivities()) { + if (appStateSubscription || !canRegisterPushNotifications()) { return; } @@ -773,6 +829,7 @@ function ensureAppStateListener(): void { if (state !== "active") { return; } + enqueueDeviceRegistration({}, "device registration after app foreground failed"); runRegistrationInBackground( refreshActiveLiveActivityRemoteRegistration(), "active live activity reconciliation after app foreground failed", @@ -796,7 +853,7 @@ function endLocalLiveActivities(context: string): void { } export function registerAgentAwarenessConnection(connection: SavedRemoteConnection): void { - if (!canRegisterRemoteLiveActivities()) { + if (!canRegisterPushNotifications()) { return; } @@ -868,6 +925,7 @@ export function __resetAgentAwarenessRemoteRegistrationForTest(): void { activeDeviceRegistration = null; pendingDeviceRegistration = null; registrationStatus = "unknown"; + androidDeviceReplayedAt = null; registrationStatusListeners.clear(); registeredActivityPushTokens.clear(); } diff --git a/apps/mobile/src/features/settings/SettingsRouteScreen.logic.test.ts b/apps/mobile/src/features/settings/SettingsRouteScreen.logic.test.ts index aec583d67f..3e0ead2ce5 100644 --- a/apps/mobile/src/features/settings/SettingsRouteScreen.logic.test.ts +++ b/apps/mobile/src/features/settings/SettingsRouteScreen.logic.test.ts @@ -3,10 +3,10 @@ import { describe, expect, it } from "vite-plus/test"; import { resolveAgentAwarenessPlatformPresentation } from "./SettingsRouteScreen.logic"; describe("resolveAgentAwarenessPlatformPresentation", () => { - it("explains that agent awareness settings are unavailable on Android", () => { + it("supports agent awareness settings on Android", () => { expect(resolveAgentAwarenessPlatformPresentation("android")).toEqual({ - supported: false, - subtitle: "iOS only", + supported: true, + subtitle: undefined, }); }); diff --git a/apps/mobile/src/features/settings/SettingsRouteScreen.logic.ts b/apps/mobile/src/features/settings/SettingsRouteScreen.logic.ts index 94fa5965e9..6cdf8a3e1f 100644 --- a/apps/mobile/src/features/settings/SettingsRouteScreen.logic.ts +++ b/apps/mobile/src/features/settings/SettingsRouteScreen.logic.ts @@ -2,7 +2,7 @@ export function resolveAgentAwarenessPlatformPresentation(platform: string): { readonly supported: boolean; readonly subtitle: string | undefined; } { - return platform === "ios" + return platform === "ios" || platform === "android" ? { supported: true, subtitle: undefined } - : { supported: false, subtitle: "iOS only" }; + : { supported: false, subtitle: "Unavailable on this platform" }; } diff --git a/apps/mobile/src/features/settings/SettingsRouteScreen.tsx b/apps/mobile/src/features/settings/SettingsRouteScreen.tsx index a7f68112db..7423db855d 100644 --- a/apps/mobile/src/features/settings/SettingsRouteScreen.tsx +++ b/apps/mobile/src/features/settings/SettingsRouteScreen.tsx @@ -160,6 +160,10 @@ function ConfiguredSettingsRouteScreen() { const savePreferences = useAtomSet(updateMobilePreferencesAtom); const agentAwarenessPushAvailable = supportsAgentAwarenessPush(); const agentAwarenessPlatform = resolveAgentAwarenessPlatformPresentation(Platform.OS); + const agentAwarenessSubtitle = + Platform.OS === "android" && !agentAwarenessPushAvailable + ? "Install a newer app build to enable notifications" + : agentAwarenessPlatform.subtitle; const insets = useSafeAreaInsets(); const navigation = useNavigation(); const { getToken, isLoaded, isSignedIn } = useAuth({ treatPendingAsSignedOut: false }); @@ -181,7 +185,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; } @@ -246,10 +250,7 @@ function ConfiguredSettingsRouteScreen() { // Permission alone is not enough: the switch stays off until the relay // registration succeeds, so tell the user the truth about which happened. if (getAgentAwarenessRegistrationStatus() === "registered") { - Alert.alert( - "Notifications enabled", - "Live Activity notifications are enabled for this device.", - ); + Alert.alert("Notifications enabled", "Agent notifications are enabled for this device."); } else { Alert.alert( "Couldn't finish enabling notifications", @@ -262,7 +263,7 @@ function ConfiguredSettingsRouteScreen() { setNotificationStatus("unsupported"); Alert.alert( "Notifications unavailable", - "Live Activity notifications are only available on iOS.", + "Agent notifications are unavailable on this platform.", ); return; } @@ -302,13 +303,40 @@ function ConfiguredSettingsRouteScreen() { } setLiveActivityStatus("linking"); + if (Platform.OS === "android") { + const permission = await settleAsyncResult(() => + runtime.runPromiseExit(requestAgentNotificationPermission), + ); + if (permission._tag === "Failure") { + setLiveActivityStatus("disabled"); + const error = squashAtomCommandFailure(permission); + Alert.alert( + "Ongoing activity unavailable", + error instanceof Error ? error.message : "Could not enable agent notifications.", + ); + return; + } + if (permission.value.type !== "granted") { + setLiveActivityStatus("disabled"); + Alert.alert( + "Notification permission needed", + "Enable notifications in system Settings to show ongoing agent activity.", + [ + { text: "Cancel", style: "cancel" }, + { text: "Open Settings", onPress: () => void Linking.openSettings() }, + ], + ); + return; + } + setNotificationStatus("enabled"); + } const tokenResult = await settlePromise(() => getToken(resolveRelayClerkTokenOptions())); if (tokenResult._tag === "Failure") { setLiveActivityStatus("disabled"); const error = squashAtomCommandFailure(tokenResult); Alert.alert( - "Live Activities unavailable", - error instanceof Error ? error.message : "Could not enable Live Activity updates.", + Platform.OS === "android" ? "Ongoing activity unavailable" : "Live Activities unavailable", + error instanceof Error ? error.message : "Could not enable agent activity updates.", ); return; } @@ -333,8 +361,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.", + Platform.OS === "android" + ? "Ongoing activity unavailable" + : "Live Activities unavailable", + error instanceof Error ? error.message : "Could not enable agent activity updates.", ); } return; @@ -348,15 +378,15 @@ function ConfiguredSettingsRouteScreen() { // Activities are live until the device is actually registered. if (getAgentAwarenessRegistrationStatus() === "registered") { Alert.alert( - "Live Activities enabled", + Platform.OS === "android" ? "Ongoing activity enabled" : "Live Activities 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 agent activity updates.` + : "Agent activity updates are enabled. Add an environment to start receiving updates.", ); } else { Alert.alert( - "Couldn't finish enabling Live Activities", - "This device could not be registered with Pylon Connect, so Live Activities won't appear yet. They'll start once registration succeeds.", + "Couldn't finish enabling activity updates", + "This device could not be registered with Pylon Connect, so activity updates won't appear yet. They'll start once registration succeeds.", ); } }, [ @@ -372,20 +402,24 @@ function ConfiguredSettingsRouteScreen() { const handleDeviceNotificationsChange = useCallback( (enabled: boolean) => { if (enabled) { + if (!isSignedIn) { + promptSignIn(); + return; + } void requestNotifications(); return; } Alert.alert( "Disable notifications", - "Notification permission is controlled by iOS. Open Settings to disable notifications for Pylon.", + "Open system Settings to disable notifications for Pylon.", [ { text: "Cancel", style: "cancel" }, { text: "Open Settings", onPress: () => void Linking.openSettings() }, ], ); }, - [requestNotifications], + [isSignedIn, promptSignIn, requestNotifications], ); const handleLiveActivitiesChange = useCallback( @@ -494,7 +528,7 @@ function ConfiguredSettingsRouteScreen() { notificationStatus === "checking" || notificationStatus === "unsupported" } - subtitle={agentAwarenessPlatform.subtitle} + subtitle={agentAwarenessSubtitle} // Only reads as on when this device is actually registered with the // relay; otherwise notifications cannot be delivered regardless of // the local iOS permission. @@ -512,8 +546,8 @@ function ConfiguredSettingsRouteScreen() { liveActivityStatus === "linking" } icon="bolt.circle" - label="Live Activity Updates" - subtitle={agentAwarenessPlatform.subtitle} + label={Platform.OS === "android" ? "Ongoing Agent Activity" : "Live Activity Updates"} + subtitle={agentAwarenessSubtitle} // Same gate: a saved preference is meaningless until the device // registration the relay needs to push updates has succeeded. value={ diff --git a/apps/web/src/components/clerk/MobileClientsUserProfilePage.logic.test.ts b/apps/web/src/components/clerk/MobileClientsUserProfilePage.logic.test.ts index 23494c56c9..1465159bb9 100644 --- a/apps/web/src/components/clerk/MobileClientsUserProfilePage.logic.test.ts +++ b/apps/web/src/components/clerk/MobileClientsUserProfilePage.logic.test.ts @@ -37,6 +37,14 @@ describe("mobile client presentation", () => { ); }); + it("labels Android devices without displaying an iOS version", () => { + expect( + mobileClientPlatformLabel( + device({ platform: "android", iosMajorVersion: null, androidApiLevel: 36 }), + ), + ).toBe("Android · Pylon 1.2.3"); + }); + it("distinguishes disabled notifications from an empty event selection", () => { expect( mobileClientNotificationDetail( @@ -59,6 +67,7 @@ describe("mobile client presentation", () => { }); it("handles missing app versions and invalid update timestamps", () => { + expect(mobileClientPlatformLabel(device({ iosMajorVersion: null }))).toBe("iOS · Pylon 1.2.3"); expect(mobileClientPlatformLabel(device({ appVersion: null }))).toBe("iOS 18"); 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 4fbd399c01..e8b96b0184 100644 --- a/apps/web/src/components/clerk/MobileClientsUserProfilePage.logic.ts +++ b/apps/web/src/components/clerk/MobileClientsUserProfilePage.logic.ts @@ -15,7 +15,13 @@ const NOTIFICATION_PREFERENCES = [ >; export function mobileClientPlatformLabel(device: RelayClientDeviceRecord): string { - return `iOS ${device.iosMajorVersion}${device.appVersion ? ` · Pylon ${device.appVersion}` : ""}`; + const platform = + device.platform === "android" + ? "Android" + : device.iosMajorVersion === null + ? "iOS" + : `iOS ${device.iosMajorVersion}`; + return `${platform}${device.appVersion ? ` · Pylon ${device.appVersion}` : ""}`; } export function mobileClientNotificationDetail(device: RelayClientDeviceRecord): string { diff --git a/docs/internals/t3-connect.md b/docs/internals/t3-connect.md index d5b9386398..aaa0e80963 100644 --- a/docs/internals/t3-connect.md +++ b/docs/internals/t3-connect.md @@ -279,6 +279,22 @@ is the exact string the allowlist needs. Note that these are bundle identifiers (`com.pylon.code.preview`), while the desktop entries above are URL schemes (`pylon-code`); the two namespaces are easy to confuse. +### Android native sign-in redirects + +Clerk's native Android SDK uses `clerk://.callback`. Add each Android package you build +to the same **Native applications > Allowlist for mobile SSO redirect** list: + +| Variant | Callback | +| ----------- | ----------------------------------------- | +| Development | `clerk://com.pylon.code.dev.callback` | +| Preview | `clerk://com.pylon.code.preview.callback` | +| Production | `clerk://com.pylon.code.callback` | + +Preserve existing entries. These callbacks are separate from the `pylon-code-dev` / +`pylon-code-preview` / `pylon-code` navigation schemes. A private development build that uses the +production Clerk key still needs its development callback allowed by that instance's administrator; +rebuilding the same package does not change the allowlist. + ## Sign-in Surfaces Signed-in users manage Pylon Connect under **Connections**. The settings sidebar also has dedicated diff --git a/docs/operations/android-notifications.md b/docs/operations/android-notifications.md new file mode 100644 index 0000000000..adac44d513 --- /dev/null +++ b/docs/operations/android-notifications.md @@ -0,0 +1,239 @@ +# Android notifications + +The Android app receives Firebase Cloud Messaging (FCM) data messages. The Pylon Connect relay sends +them directly through FCM HTTP v1; an Expo Push account is not required. + +Nothing here is provisioned by default. Until a Firebase project, an Android build with its Google +services file, and the relay's `FCM_SERVICE_ACCOUNT` secret all exist, the relay logs that Android +notifications are not configured and skips Android deliveries. iOS delivery is unaffected. + +## Android compatibility and automated checks + +The app's minimum is Android 7.0 (API 24), declared in `apps/mobile/app.config.ts` and enforced by +the relay's device-registration schema. Compile and target SDK versions follow the locked +Expo/React Native toolchain (currently API 36). Notification channels begin at API 26; the +notification permission prompt begins at API 33. Live Update promotion requires API 36 and remains +subject to system settings and device support. Alerts and ordinary activity cards work below API 36. + +API 24–25 use a single inexact system alarm to expire cards after process exit, with no exact-alarm +permission. Android can delay that alarm in power-saving modes. API 26+ use notification timeouts. +Disabling activity, dismissal, account changes and sign-out cancel the legacy alarm. A stale expiry +broadcast cannot remove a newer run's card. + +The native notification tests cover API 24, 26, 33 and 36 (plus API 25 for legacy expiry) using +Robolectric. To compile the module, run these tests and run Android lint, use the following command +from a generated `apps/mobile/android` project with JDK 21 available. No Firebase, signing or relay +secrets are required: + +```sh +./gradlew :t3-agent-notifications:testDebugUnitTest :t3-agent-notifications:lintRelease -Pandroid.lint.useK2Uast=false +``` + +Robolectric's API 36 runtime requires JDK 21; module compilation still uses Expo's Java 17 +toolchain. The Mobile Native Static Analysis job separately runs ktlint and detekt. The native +module and its Expo config changes alter the runtime fingerprint, so this needs a new binary; an OTA +update cannot deliver it to an older one. Settings disable Android notifications if the installed +native module is missing required methods. + +The lint command uses the K1 frontend because AGP's K2 frontend crashes while analyzing Worklets +0.10's Gradle Kotlin scripts. This does not disable lint checks. Live Update eligibility must be +verified on a device: Robolectric's API 36 image implements older promotion rules that require +colorization, while shipped Live Updates require uncolorized notifications. + +## Firebase and app build + +1. Create a Firebase project owned by the Pylon maintainers and register each Android application + identifier you intend to build: `com.pylon.code.dev`, `com.pylon.code.preview`, or + `com.pylon.code`. +2. Download `google-services.json`. Set `T3CODE_ANDROID_GOOGLE_SERVICES_FILE` to its path when + running Expo prebuild and building the app. The JSON must contain the selected variant's package + identifier. Never commit it. +3. Create a service-account key with permission to send FCM messages for that Firebase project. + Keep this private JSON outside the repository and the app bundle. +4. Enable the Firebase Cloud Messaging API in the Google project if it is not already enabled. For + hosted delivery, set the relay's `FCM_SERVICE_ACCOUNT` secret to the service-account JSON. +5. Build a new Android binary. A JavaScript-only update cannot install the native notification + handler or Firebase configuration. Hosted delivery also needs the relay database migration and + an updated relay deployment; local verification can use the watcher below. + +For a local development build, from `apps/mobile`: + +```sh +APP_VARIANT=development \ +T3CODE_ANDROID_GOOGLE_SERVICES_FILE=/absolute/path/google-services.json \ +vp run android:dev +``` + +For an EAS build, provide the same configuration through each selected build environment, using an +EAS file variable named `T3CODE_ANDROID_GOOGLE_SERVICES_FILE` for the Google services file. Make the +file available to fingerprint generation as well as the native build. FCM service-account +credentials belong on the relay, not in EAS's app environment. If deploying a separate relay, +configure the build's Pylon Connect public settings for that relay and Clerk application as +described in [Pylon Connect](../internals/t3-connect.md). + +Over-the-air updates are already off unless `PYLON_EAS_PROJECT_ID` is set. When it is set, also set +`T3CODE_MOBILE_UPDATES_ENABLED=0` before prebuild and bundling a private binary to keep it off the +configured update channel. A debug development-client APK requires Metro; a bundled release build is +needed to verify cold-start notification taps without Expo's development launcher. + +## Clerk sign-in for private builds + +Clerk's native Android sign-in uses `clerk://.callback`. In the Clerk instance +selected by the build's publishable key, its administrator must allow the exact callback under +**Native applications > Allowlist for mobile SSO redirect**. For the development package, add: + +```text +clerk://com.pylon.code.dev.callback +``` + +A "redirect url ... does not match an authorized redirect URI" error requires a Clerk configuration +change; rebuilding the same APK does not fix it. Reopen sign-in after the administrator saves the +entry. See [Android native sign-in redirects](../internals/t3-connect.md#android-native-sign-in-redirects) +for the other variants. + +A build that uses the production publishable key selects the production Clerk instance; changing its +allowlist requires that instance's administrator. Android device registration and hosted delivery +separately require the relay deployment below. A successful direct-pairing or FCM smoke test does +not verify hosted sign-in or device registration. + +Building with `APP_VARIANT=production` selects `com.pylon.code` and its Clerk callback. Set the same +variant during prebuild and bundling, and supply a Google services file that includes that package. +Keep OTA updates disabled for a private binary. A locally signed build with this package cannot +update an installation signed with the release key or coexist with it; removing that installation +also removes its app-local data. The development package remains a separate app. + +## Focused delivery check + +`infra/relay/scripts/android-push-smoke.ts` sends a message through the production FCM client +implementation without provisioning the relay's database, Clerk integration, or Cloudflare queues. +It verifies only Firebase-to-device delivery. + +Provide a private device JSON file containing the app's native FCM `token`, registered `deviceId`, +signed-in `userId`, and Android `packageName`. An optional `deepLink` can target an existing thread +for tap verification. The app must have registered its local native notification handler and have +notification permission. From `infra/relay`: + +```sh +vp run push:android:smoke /path/service-account.json /path/device.json running +vp run push:android:smoke /path/service-account.json /path/device.json approval +vp run push:android:smoke /path/service-account.json /path/device.json completed +``` + +Supported states are `running`, `approval`, `input`, `completed`, `failed`, and `end`. Firebase +acceptance is not proof that a device displayed the message. Check the actual notification, +background the app, and test a notification tap. Also test dismissal, disabling ongoing activity, +sign-out, token rotation, and delivery after the app process has exited. Android Settings +**Force stop** intentionally prevents delivery until the app is opened again. + +Android suppresses ordinary alerts while the app is foregrounded, matching iOS notification +presentation. Activity cards still update in the foreground and retain finished results silently. +Check that completion stays quiet with the app open, that a later completion alerts after +backgrounding, and that retrying a foreground-suppressed alert does not show it later. This uses the +app lifecycle on the receiving phone, not thread visibility on other clients. + +With ongoing activity enabled, verify two threads entering approval/input together produce one +`2 agents need attention` alert, and two observed active threads completing/failing together produce +one `2 agents finished` alert. The body lists their titles. The relay shares iOS transition +selection and retains its delivered baseline when work finishes; publishing the same states again +must not produce another alert. Grouped alerts open the aggregate's priority thread; individual +alerts retain their thread link. + +Verify an expanded card with five threads, attention/failure priority, project names and statuses. +When all work finishes, the card should show **Agent work completed** or **Agent work failed**, lose +its ongoing/promotion flag, and expire 15 minutes after the newest displayed result. Replays must not +extend that deadline. Quiet running work uses the relay's two-hour state lifetime; approval/input +states use 24 hours. Reopen the same signed-in app and confirm existing alerts and dismissal survive, +with a silent aggregate replay on cold start or a foreground after at least 60 seconds. Also check +empty replays remove an orphaned card and completions older than two minutes never alert, even with +ongoing activity disabled. + +After Android prebuild, run the native presentation regression tests from `apps/mobile/android`: + +```sh +./gradlew :t3-agent-notifications:testDebugUnitTest --tests expo.modules.t3agentnotifications.AgentNotificationsTest +``` + +## Relay deployment + +### Local verification without a hosted relay + +You do not need a hosted relay stage to develop Android push. Keep the normal Clerk login and +environment connections. `infra/relay/scripts/android-push-watch.ts` subscribes to one paired +environment's shell stream, uses the shared agent-awareness projection, and sends updates through +the FCM client. It holds transient state in memory and needs no hosted database or Clerk secret. + +Create a private `connection.json` containing `wsUrl` (the environment's `/ws` URL) and +`bearerToken` (a normal paired environment access token). Use a separate pairing credential for this +watcher. Supply the same device file described above, then run from `infra/relay`: + +```sh +vp run push:android:watch /path/service-account.json /path/device.json /path/connection.json +``` + +The Android native handler must already be configured with that device and account, and +notifications must be allowed. A native instrumentation harness can configure a disposable emulator +before testing; a signed-in development app configures the handler during device registration. This +watcher is a development transport: it observes all unarchived threads in its paired environment, +enables all alert types, keeps no durable queue, and must stay running. It does not register Android +devices with a hosted relay. + +### Hosted delivery + +The Alchemy deployment described in the [relay README](../../infra/relay/README.md) provisions the +Cloudflare Worker, the APNs and FCM delivery queues, Hyperdrive, tunnel and DNS resources, the Neon +database, and Axiom observability. It requires credentials for those services and private Clerk +configuration. Set `APNS_ENABLED=false` in an Android-only development relay to skip Apple delivery +and its credential requirements. APNs remains enabled by default. + +Hosted delivery adds `RelayFcmDeliveryQueue` and `RelayFcmDeliveryDeadLetterQueue`, and the +`20260906042516_android_devices` relay migration (an `android_api_level` column, with +`ios_major_version` made nullable). Deploying the relay applies both. + +#### Personal stage + +Non-production stages reference the Neon project owned by the `prod` stage and fork their own +branch, so a personal stage is not a standalone deployment into an unrelated account. + +1. Create a private env file, for example `infra/relay/.env.android-dev`, with the Cloudflare, Neon, + Axiom, domain, and Clerk configuration described in the relay README. Keep + `CLERK_PUBLISHABLE_KEY`, `CLERK_SECRET_KEY`, and `CLERK_JWT_AUDIENCE` on the same Clerk instance + used by the test clients. +2. Add the Firebase service-account JSON as `FCM_SERVICE_ACCOUNT` and set `APNS_ENABLED=false` for + an Android-only stage. Leave `RELAY_DOMAIN` unset so the deployment derives a hostname for the + personal stage instead of using the production hostname. +3. From the repository root, inspect the deployment plan, then deploy the same stage: + + ```sh + vp run --filter t3code-relay deploy -- --stage dev_android --env-file .env.android-dev --dry-run + vp run --filter t3code-relay deploy -- --stage dev_android --env-file .env.android-dev + ``` + +4. Give the tester the deployed relay URL and matching public Clerk configuration. The deploy + wrapper also writes the relay URL and public tracing configuration into that checkout's root + `.env`. Rebuild the private APK with this `T3CODE_RELAY_URL`, the Firebase Android file, and OTA + updates disabled. If using the development package, authorize its Clerk callback as described + above. +5. Configure one isolated Pylon server with the same relay URL and link that test environment through + the new relay. Existing production relay links do not move to a personal stage. Enable activity + publishing for the test environment, enable notifications on the phone, and verify a real agent + turn produces a running update and completion alert while the phone is locked. + +Build the host client and mobile app with the same relay URL and Clerk public configuration. A +source server or desktop development build can host the test environment; keep its runtime home +separate from an existing installation. Signing into the phone alone does not link a host +environment. Use the host client's Pylon Connect settings to link it and enable activity publishing. +A private Clerk instance also needs its own CLI OAuth application before using `t3 connect login`. + +#### Production + +`.github/workflows/deploy-relay.yml` passes `FCM_SERVICE_ACCOUNT` from the `production` environment's +secrets to Alchemy. Add that secret there to turn on Android delivery; without it, the deploy +succeeds and Android deliveries are skipped. The release Android build also needs the production +package's `google-services.json` in its native build environment; changing the relay secret alone +cannot move an installed app to another Firebase project. + +Android delivery uses `RelayFcmDeliveryQueue` and its dead-letter queue. Failed requests are +retried; messages expire after five minutes. Before sending, the consumer rechecks the device token, +current preferences, environment links, and current thread state. `UNREGISTERED` responses +invalidate only the matching device token. OAuth tokens are cached within the FCM service and +refreshed after an authorization failure. diff --git a/docs/user/mobile-notifications.md b/docs/user/mobile-notifications.md new file mode 100644 index 0000000000..7366215f06 --- /dev/null +++ b/docs/user/mobile-notifications.md @@ -0,0 +1,26 @@ +# Mobile notifications + +Sign in to Pylon Connect, link your environments, and enable **Device Notifications** in Settings to +receive alerts when an agent finishes, fails, needs approval, or asks for input. Tap a notification +to open its thread. Your environment must have agent activity publishing enabled. + +Enable **Ongoing Agent Activity** on Android or **Live Activity Updates** on iOS to follow work +without opening the app. Finished results remain visible for up to 15 minutes. You can dismiss an +Android activity card without disabling alerts; turn off ongoing activity in Settings to stop future +cards. + +Ordinary alerts stay quiet while the mobile app is in the foreground. Ongoing activity continues to +update. Viewing a thread on another device does not silence your phone's alerts. + +Android notifications require Android 7.0 or newer and Google Play services. Android 16 and newer +can promote ongoing activity to a Live Update, subject to system settings and device support. Other +devices show a regular ongoing notification. Android 7's battery-saving modes can delay removal of +expired cards. + +Notification permission and Android notification channels are controlled in system Settings. +Background delivery requires Pylon Connect; a direct or Tailscale connection alone does not enable +push notifications. The mobile app does not need to maintain a connection to your environment. +Force-stopping the Android app in system Settings prevents push delivery until you open it again. + +If Settings on Android says to install a newer app build, update Pylon Mobile to turn on +notifications. diff --git a/infra/relay/.env.example b/infra/relay/.env.example index 7ab2a5d6e4..f885bff17c 100644 --- a/infra/relay/.env.example +++ b/infra/relay/.env.example @@ -16,7 +16,8 @@ CLERK_PUBLISHABLE_KEY=pk_test_... CLERK_SECRET_KEY=sk_test_... CLERK_JWT_AUDIENCE=t3-code-relay -# Required: Apple Push Notification service +# Apple Push Notification service (required unless APNS_ENABLED=false) +# Set APNS_ENABLED=false for an Android-only development relay. # Get these values from your Apple Developer account. Use `sandbox` for # development APNs credentials and `production` for production credentials. APNS_ENVIRONMENT=sandbox @@ -24,3 +25,7 @@ APNS_TEAM_ID=... APNS_KEY_ID=... APNS_BUNDLE_ID=... APNS_PRIVATE_KEY=... + +# Optional: Android push. Set this secret to the service account JSON from +# Firebase Project settings > Service accounts. Never put it in an app build. +# FCM_SERVICE_ACCOUNT={...} diff --git a/infra/relay/README.md b/infra/relay/README.md index b4cc1d1561..6deca98da8 100644 --- a/infra/relay/README.md +++ b/infra/relay/README.md @@ -20,7 +20,7 @@ The relay currently owns: - Provisioning and tracking managed environment endpoints. - Issuing short-lived credentials used to connect clients to linked environments. - Listing linked environments and registered mobile devices for an account. -- Registering mobile notification preferences and APNs tokens. +- Registering mobile notification preferences and APNs or FCM tokens. - Receiving published agent activity and delivering notifications or Live Activity updates. - Persisting relay state and exposing relay-specific traces for diagnostics. @@ -37,7 +37,7 @@ credential, or authorization behavior. - [`src/environments`](./src/environments) contains environment linking, credentials, endpoint provisioning, and connection flows. - [`src/agentActivity`](./src/agentActivity) contains mobile device registration, activity state, - APNs delivery, and queue processing. + APNs and FCM delivery, and queue processing. - [`src/auth`](./src/auth) contains relay token and DPoP proof handling. - [`src/persistence/schema.ts`](./src/persistence/schema.ts) defines persisted relay state. Keep schema and migration changes together. @@ -85,7 +85,8 @@ vp run --filter t3code-relay deploy The stack provisions the Cloudflare Worker and queues, managed endpoint resources, database connectivity, and relay tracing resources. Copy [`infra/relay/.env.example`](./.env.example) to `infra/relay/.env` and fill in the deployment-specific values before deploying. Alchemy loads that -file from the relay directory. Runtime secrets include Clerk and APNs credentials. Production adopts +file from the relay directory. Runtime secrets include Clerk, APNs, and optional FCM credentials. Set +`APNS_ENABLED=false` for an Android-only development deployment without Apple credentials. Production adopts the configured API and tunnel DNS zones as retained Cloudflare resources. Personal stages reference the production-owned zones. @@ -148,6 +149,7 @@ The `production` GitHub environment must define these Actions secrets: - `CLERK_SECRET_KEY` - `APNS_PRIVATE_KEY` +- `FCM_SERVICE_ACCOUNT` when Android push is enabled The account-scoped repository credentials are consumed by Alchemy while provisioning relay stages; they are not bound into the relay Worker. The production deployment uses an Axiom personal access token, diff --git a/infra/relay/migrations/postgres/20260906042516_android_devices/migration.sql b/infra/relay/migrations/postgres/20260906042516_android_devices/migration.sql new file mode 100644 index 0000000000..85fa040da6 --- /dev/null +++ b/infra/relay/migrations/postgres/20260906042516_android_devices/migration.sql @@ -0,0 +1,2 @@ +ALTER TABLE "relay_mobile_devices" ADD COLUMN "android_api_level" integer;--> statement-breakpoint +ALTER TABLE "relay_mobile_devices" ALTER COLUMN "ios_major_version" DROP NOT NULL; \ No newline at end of file diff --git a/infra/relay/migrations/postgres/20260906042516_android_devices/snapshot.json b/infra/relay/migrations/postgres/20260906042516_android_devices/snapshot.json new file mode 100644 index 0000000000..9d89ffe61a --- /dev/null +++ b/infra/relay/migrations/postgres/20260906042516_android_devices/snapshot.json @@ -0,0 +1,1516 @@ +{ + "version": "8", + "dialect": "postgres", + "id": "1109ed23-036c-4c39-a459-52422a7ffd99", + "prevIds": ["2374caff-40bf-423c-9255-55e76dddbc2a"], + "ddl": [ + { + "isRlsEnabled": false, + "name": "relay_agent_activity_rows", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "relay_delivery_attempts", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "relay_dpop_proofs", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "relay_environment_credentials", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "relay_environment_links", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "relay_live_activities", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "relay_managed_endpoint_allocations", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "relay_managed_tunnel_limits", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "relay_mobile_devices", + "entityType": "tables", + "schema": "public" + }, + { + "type": "varchar(191)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "environment_id", + "entityType": "columns", + "schema": "public", + "table": "relay_agent_activity_rows" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "environment_public_key", + "entityType": "columns", + "schema": "public", + "table": "relay_agent_activity_rows" + }, + { + "type": "varchar(191)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "thread_id", + "entityType": "columns", + "schema": "public", + "table": "relay_agent_activity_rows" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "state_json", + "entityType": "columns", + "schema": "public", + "table": "relay_agent_activity_rows" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "updated_at", + "entityType": "columns", + "schema": "public", + "table": "relay_agent_activity_rows" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "created_at", + "entityType": "columns", + "schema": "public", + "table": "relay_agent_activity_rows" + }, + { + "type": "varchar(36)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "relay_delivery_attempts" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "created_at", + "entityType": "columns", + "schema": "public", + "table": "relay_delivery_attempts" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "user_id", + "entityType": "columns", + "schema": "public", + "table": "relay_delivery_attempts" + }, + { + "type": "varchar(191)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "environment_id", + "entityType": "columns", + "schema": "public", + "table": "relay_delivery_attempts" + }, + { + "type": "varchar(191)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "thread_id", + "entityType": "columns", + "schema": "public", + "table": "relay_delivery_attempts" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "device_id", + "entityType": "columns", + "schema": "public", + "table": "relay_delivery_attempts" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "kind", + "entityType": "columns", + "schema": "public", + "table": "relay_delivery_attempts" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "source_job_id", + "entityType": "columns", + "schema": "public", + "table": "relay_delivery_attempts" + }, + { + "type": "varchar(16)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "token_suffix", + "entityType": "columns", + "schema": "public", + "table": "relay_delivery_attempts" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "apns_status", + "entityType": "columns", + "schema": "public", + "table": "relay_delivery_attempts" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "apns_reason", + "entityType": "columns", + "schema": "public", + "table": "relay_delivery_attempts" + }, + { + "type": "varchar(128)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "apns_id", + "entityType": "columns", + "schema": "public", + "table": "relay_delivery_attempts" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "transport_error", + "entityType": "columns", + "schema": "public", + "table": "relay_delivery_attempts" + }, + { + "type": "varchar(128)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "thumbprint", + "entityType": "columns", + "schema": "public", + "table": "relay_dpop_proofs" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "jti", + "entityType": "columns", + "schema": "public", + "table": "relay_dpop_proofs" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "iat", + "entityType": "columns", + "schema": "public", + "table": "relay_dpop_proofs" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "expires_at", + "entityType": "columns", + "schema": "public", + "table": "relay_dpop_proofs" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "created_at", + "entityType": "columns", + "schema": "public", + "table": "relay_dpop_proofs" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "credential_id", + "entityType": "columns", + "schema": "public", + "table": "relay_environment_credentials" + }, + { + "type": "varchar(191)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "environment_id", + "entityType": "columns", + "schema": "public", + "table": "relay_environment_credentials" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "environment_public_key", + "entityType": "columns", + "schema": "public", + "table": "relay_environment_credentials" + }, + { + "type": "varchar(191)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "credential_hash", + "entityType": "columns", + "schema": "public", + "table": "relay_environment_credentials" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "revoked_at", + "entityType": "columns", + "schema": "public", + "table": "relay_environment_credentials" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "created_at", + "entityType": "columns", + "schema": "public", + "table": "relay_environment_credentials" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "updated_at", + "entityType": "columns", + "schema": "public", + "table": "relay_environment_credentials" + }, + { + "type": "varchar(191)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "user_id", + "entityType": "columns", + "schema": "public", + "table": "relay_environment_links" + }, + { + "type": "varchar(191)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "environment_id", + "entityType": "columns", + "schema": "public", + "table": "relay_environment_links" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'T3 Environment'", + "generated": null, + "identity": null, + "name": "environment_label", + "entityType": "columns", + "schema": "public", + "table": "relay_environment_links" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "environment_public_key", + "entityType": "columns", + "schema": "public", + "table": "relay_environment_links" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "endpoint_http_base_url", + "entityType": "columns", + "schema": "public", + "table": "relay_environment_links" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "endpoint_ws_base_url", + "entityType": "columns", + "schema": "public", + "table": "relay_environment_links" + }, + { + "type": "varchar(32)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "endpoint_provider_kind", + "entityType": "columns", + "schema": "public", + "table": "relay_environment_links" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "true", + "generated": null, + "identity": null, + "name": "notifications_enabled", + "entityType": "columns", + "schema": "public", + "table": "relay_environment_links" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "true", + "generated": null, + "identity": null, + "name": "live_activities_enabled", + "entityType": "columns", + "schema": "public", + "table": "relay_environment_links" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "managed_tunnels_enabled", + "entityType": "columns", + "schema": "public", + "table": "relay_environment_links" + }, + { + "type": "varchar(191)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "created_by_device_id", + "entityType": "columns", + "schema": "public", + "table": "relay_environment_links" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "revoked_at", + "entityType": "columns", + "schema": "public", + "table": "relay_environment_links" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "created_at", + "entityType": "columns", + "schema": "public", + "table": "relay_environment_links" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "updated_at", + "entityType": "columns", + "schema": "public", + "table": "relay_environment_links" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "user_id", + "entityType": "columns", + "schema": "public", + "table": "relay_live_activities" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "device_id", + "entityType": "columns", + "schema": "public", + "table": "relay_live_activities" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "activity_push_token", + "entityType": "columns", + "schema": "public", + "table": "relay_live_activities" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "remote_start_queued_at", + "entityType": "columns", + "schema": "public", + "table": "relay_live_activities" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "remote_started_at", + "entityType": "columns", + "schema": "public", + "table": "relay_live_activities" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "ended_at", + "entityType": "columns", + "schema": "public", + "table": "relay_live_activities" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "last_aggregate_json", + "entityType": "columns", + "schema": "public", + "table": "relay_live_activities" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "last_live_activity_delivery_at", + "entityType": "columns", + "schema": "public", + "table": "relay_live_activities" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "created_at", + "entityType": "columns", + "schema": "public", + "table": "relay_live_activities" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "updated_at", + "entityType": "columns", + "schema": "public", + "table": "relay_live_activities" + }, + { + "type": "varchar(191)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "user_id", + "entityType": "columns", + "schema": "public", + "table": "relay_managed_endpoint_allocations" + }, + { + "type": "varchar(191)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "environment_id", + "entityType": "columns", + "schema": "public", + "table": "relay_managed_endpoint_allocations" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "hostname", + "entityType": "columns", + "schema": "public", + "table": "relay_managed_endpoint_allocations" + }, + { + "type": "varchar(191)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "tunnel_id", + "entityType": "columns", + "schema": "public", + "table": "relay_managed_endpoint_allocations" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "tunnel_name", + "entityType": "columns", + "schema": "public", + "table": "relay_managed_endpoint_allocations" + }, + { + "type": "varchar(191)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "dns_record_id", + "entityType": "columns", + "schema": "public", + "table": "relay_managed_endpoint_allocations" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "ready_at", + "entityType": "columns", + "schema": "public", + "table": "relay_managed_endpoint_allocations" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "created_at", + "entityType": "columns", + "schema": "public", + "table": "relay_managed_endpoint_allocations" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "updated_at", + "entityType": "columns", + "schema": "public", + "table": "relay_managed_endpoint_allocations" + }, + { + "type": "varchar(191)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "user_id", + "entityType": "columns", + "schema": "public", + "table": "relay_managed_tunnel_limits" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "max_tunnels", + "entityType": "columns", + "schema": "public", + "table": "relay_managed_tunnel_limits" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "created_at", + "entityType": "columns", + "schema": "public", + "table": "relay_managed_tunnel_limits" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "updated_at", + "entityType": "columns", + "schema": "public", + "table": "relay_managed_tunnel_limits" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "user_id", + "entityType": "columns", + "schema": "public", + "table": "relay_mobile_devices" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "device_id", + "entityType": "columns", + "schema": "public", + "table": "relay_mobile_devices" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'iOS device'", + "generated": null, + "identity": null, + "name": "label", + "entityType": "columns", + "schema": "public", + "table": "relay_mobile_devices" + }, + { + "type": "varchar(16)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "platform", + "entityType": "columns", + "schema": "public", + "table": "relay_mobile_devices" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "ios_major_version", + "entityType": "columns", + "schema": "public", + "table": "relay_mobile_devices" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "android_api_level", + "entityType": "columns", + "schema": "public", + "table": "relay_mobile_devices" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "app_version", + "entityType": "columns", + "schema": "public", + "table": "relay_mobile_devices" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "bundle_id", + "entityType": "columns", + "schema": "public", + "table": "relay_mobile_devices" + }, + { + "type": "varchar(16)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "aps_environment", + "entityType": "columns", + "schema": "public", + "table": "relay_mobile_devices" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "push_token", + "entityType": "columns", + "schema": "public", + "table": "relay_mobile_devices" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "push_to_start_token", + "entityType": "columns", + "schema": "public", + "table": "relay_mobile_devices" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "preferences_json", + "entityType": "columns", + "schema": "public", + "table": "relay_mobile_devices" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "created_at", + "entityType": "columns", + "schema": "public", + "table": "relay_mobile_devices" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "updated_at", + "entityType": "columns", + "schema": "public", + "table": "relay_mobile_devices" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "updated_at", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "idx_relay_agent_activity_rows_updated", + "entityType": "indexes", + "schema": "public", + "table": "relay_agent_activity_rows" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "environment_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "thread_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "created_at", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "idx_relay_delivery_attempts_environment", + "entityType": "indexes", + "schema": "public", + "table": "relay_delivery_attempts" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "source_job_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "idx_relay_delivery_attempts_source_job", + "entityType": "indexes", + "schema": "public", + "table": "relay_delivery_attempts" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "expires_at", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "idx_relay_dpop_proofs_expires_at", + "entityType": "indexes", + "schema": "public", + "table": "relay_dpop_proofs" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "credential_hash", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "idx_relay_environment_credentials_hash", + "entityType": "indexes", + "schema": "public", + "table": "relay_environment_credentials" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "environment_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "revoked_at", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "idx_relay_environment_credentials_environment", + "entityType": "indexes", + "schema": "public", + "table": "relay_environment_credentials" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "environment_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "environment_public_key", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "revoked_at", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "idx_relay_environment_credentials_environment_key", + "entityType": "indexes", + "schema": "public", + "table": "relay_environment_credentials" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "environment_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "revoked_at", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "idx_relay_environment_links_environment", + "entityType": "indexes", + "schema": "public", + "table": "relay_environment_links" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "activity_push_token", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "idx_relay_live_activities_activity_push_token", + "entityType": "indexes", + "schema": "public", + "table": "relay_live_activities" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "hostname", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "idx_relay_managed_endpoint_allocations_hostname", + "entityType": "indexes", + "schema": "public", + "table": "relay_managed_endpoint_allocations" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "tunnel_name", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "idx_relay_managed_endpoint_allocations_tunnel_name", + "entityType": "indexes", + "schema": "public", + "table": "relay_managed_endpoint_allocations" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "push_token", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "idx_relay_mobile_devices_push_token", + "entityType": "indexes", + "schema": "public", + "table": "relay_mobile_devices" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "push_to_start_token", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "idx_relay_mobile_devices_push_to_start_token", + "entityType": "indexes", + "schema": "public", + "table": "relay_mobile_devices" + }, + { + "columns": ["environment_id", "environment_public_key", "thread_id"], + "nameExplicit": false, + "name": "relay_agent_activity_rows_pkey", + "entityType": "pks", + "schema": "public", + "table": "relay_agent_activity_rows" + }, + { + "columns": ["thumbprint", "jti"], + "nameExplicit": false, + "name": "relay_dpop_proofs_pkey", + "entityType": "pks", + "schema": "public", + "table": "relay_dpop_proofs" + }, + { + "columns": ["user_id", "environment_id"], + "nameExplicit": false, + "name": "relay_environment_links_pkey", + "entityType": "pks", + "schema": "public", + "table": "relay_environment_links" + }, + { + "columns": ["user_id", "device_id"], + "nameExplicit": false, + "name": "relay_live_activities_pkey", + "entityType": "pks", + "schema": "public", + "table": "relay_live_activities" + }, + { + "columns": ["user_id", "environment_id"], + "nameExplicit": false, + "name": "relay_managed_endpoint_allocations_pkey", + "entityType": "pks", + "schema": "public", + "table": "relay_managed_endpoint_allocations" + }, + { + "columns": ["user_id", "device_id"], + "nameExplicit": false, + "name": "relay_mobile_devices_pkey", + "entityType": "pks", + "schema": "public", + "table": "relay_mobile_devices" + }, + { + "columns": ["id"], + "nameExplicit": false, + "name": "relay_delivery_attempts_pkey", + "schema": "public", + "table": "relay_delivery_attempts", + "entityType": "pks" + }, + { + "columns": ["credential_id"], + "nameExplicit": false, + "name": "relay_environment_credentials_pkey", + "schema": "public", + "table": "relay_environment_credentials", + "entityType": "pks" + }, + { + "columns": ["user_id"], + "nameExplicit": false, + "name": "relay_managed_tunnel_limits_pkey", + "schema": "public", + "table": "relay_managed_tunnel_limits", + "entityType": "pks" + } + ], + "renames": [] +} diff --git a/infra/relay/package.json b/infra/relay/package.json index eadf58421b..332b71d4a6 100644 --- a/infra/relay/package.json +++ b/infra/relay/package.json @@ -4,6 +4,8 @@ "type": "module", "scripts": { "deploy": "node -- scripts/deploy.ts", + "push:android:smoke": "node scripts/android-push-smoke.ts", + "push:android:watch": "node scripts/android-push-watch.ts", "destroy": "alchemy destroy", "test": "vp test run", "typecheck": "tsgo --noEmit" diff --git a/infra/relay/scripts/android-push-smoke.ts b/infra/relay/scripts/android-push-smoke.ts new file mode 100644 index 0000000000..68be288542 --- /dev/null +++ b/infra/relay/scripts/android-push-smoke.ts @@ -0,0 +1,152 @@ +// @effect-diagnostics nodeBuiltinImport:off - This developer command reads local credential files. +import * as NodeFSP from "node:fs/promises"; +import * as NodeRuntime from "@effect/platform-node/NodeRuntime"; +import * as Effect from "effect/Effect"; +import * as Clock from "effect/Clock"; +import * as Layer from "effect/Layer"; +import * as Redacted from "effect/Redacted"; +import * as Schema from "effect/Schema"; +import * as FetchHttpClient from "effect/unstable/http/FetchHttpClient"; + +import * as RelayConfiguration from "../src/Config.ts"; +import * as WebCrypto from "../src/WebCrypto.ts"; +import * as FcmAssertionSigner from "../src/agentActivity/FcmAssertionSigner.ts"; +import * as FcmClient from "../src/agentActivity/FcmClient.ts"; + +const Device = Schema.Struct({ + token: Schema.NonEmptyString, + deviceId: Schema.NonEmptyString, + userId: Schema.NonEmptyString, + packageName: Schema.NonEmptyString, + deepLink: Schema.optional(Schema.NonEmptyString), +}); +const decodeDevice = Schema.decodeUnknownEffect(Schema.fromJsonString(Device)); +const Phase = Schema.Literals(["running", "approval", "input", "completed", "failed", "end"]); +const decodePhase = Schema.decodeUnknownEffect(Phase); + +class SmokeUsageError extends Schema.TaggedErrorClass()("SmokeUsageError", {}) { + override get message() { + return "Usage: node scripts/android-push-smoke.ts "; + } +} + +class SmokeCredentialReadError extends Schema.TaggedErrorClass()( + "SmokeCredentialReadError", + { cause: Schema.Defect() }, +) { + override get message() { + return "Could not read service-account file."; + } +} + +class SmokeDeviceReadError extends Schema.TaggedErrorClass()( + "SmokeDeviceReadError", + { cause: Schema.Defect() }, +) { + override get message() { + return "Could not read device file."; + } +} + +class SmokeUnregisteredDeviceError extends Schema.TaggedErrorClass()( + "SmokeUnregisteredDeviceError", + {}, +) { + override get message() { + return "This device token is no longer registered with Firebase."; + } +} + +const main = Effect.gen(function* () { + const [credentialPath, devicePath, phaseArg] = process.argv.slice(2); + if (!credentialPath || !devicePath || !phaseArg) return yield* new SmokeUsageError({}); + const phase = yield* decodePhase(phaseArg); + const credentials = yield* Effect.tryPromise({ + try: () => NodeFSP.readFile(credentialPath, "utf8"), + catch: (cause) => new SmokeCredentialReadError({ cause }), + }); + const device = yield* Effect.tryPromise({ + try: () => NodeFSP.readFile(devicePath, "utf8"), + catch: (cause) => new SmokeDeviceReadError({ cause }), + }).pipe(Effect.flatMap(decodeDevice)); + const title = + phase === "completed" + ? "Agent finished" + : phase === "failed" + ? "Agent failed" + : phase === "approval" + ? "Approval needed" + : phase === "input" + ? "Input needed" + : null; + const active = phase === "running" || phase === "approval" || phase === "input"; + const now = yield* Clock.currentTimeMillis; + const config: RelayConfiguration.RelayConfiguration["Service"] = { + relayIssuer: "http://localhost", + fcmServiceAccount: Redacted.make(credentials), + apns: null, + clerkSecretKey: Redacted.make(""), + clerkPublishableKey: "", + clerkJwtAudience: "", + apnsDeliveryJobSigningSecret: Redacted.make(""), + cloudMintPrivateKey: Redacted.make(""), + cloudMintPublicKey: "", + managedEndpointBaseDomain: undefined, + managedEndpointNamespace: undefined, + }; + const result = yield* FcmClient.FcmClient.pipe( + Effect.flatMap((client) => + client.send({ + token: device.token, + packageName: device.packageName, + alert: title !== null, + data: { + t3_kind: "agent_activity", + device_id: device.deviceId, + user_id: device.userId, + updated_at: String(now), + active: String(active), + activity_title: active + ? "1 active agent" + : phase === "failed" + ? "Agent work failed" + : "Agent work completed", + activity_expires_at: String( + phase === "end" ? 0 : now + (active ? 2 * 60 * 60 * 1000 : 15 * 60 * 1000), + ), + activity_body: "Android notification test", + activity_path: device.deepLink ?? "/", + ...(title + ? { + alert_id: `smoke-${now}`, + alert_title: title, + alert_body: "Pylon Android push test", + alert_path: device.deepLink ?? "/", + } + : {}), + }, + }), + ), + Effect.provide( + FcmClient.layer.pipe( + Layer.provide( + FcmAssertionSigner.layer.pipe( + Layer.provide(Layer.succeed(WebCrypto.WebCrypto, { subtle: globalThis.crypto.subtle })), + ), + ), + Layer.provide( + Layer.mergeAll( + Layer.succeed(RelayConfiguration.RelayConfiguration, config), + FetchHttpClient.layer, + ), + ), + ), + ), + ); + if (result.unregistered) return yield* new SmokeUnregisteredDeviceError({}); + yield* Effect.logInfo( + `Firebase accepted the ${phase} notification. Verify delivery on the device.`, + ); +}); + +NodeRuntime.runMain(main); diff --git a/infra/relay/scripts/android-push-watch.ts b/infra/relay/scripts/android-push-watch.ts new file mode 100644 index 0000000000..a1590b7e83 --- /dev/null +++ b/infra/relay/scripts/android-push-watch.ts @@ -0,0 +1,227 @@ +// @effect-diagnostics nodeBuiltinImport:off - Local developer verification reads private credential files. +import * as NodeFSP from "node:fs/promises"; +import * as NodeRuntime from "@effect/platform-node/NodeRuntime"; +import * as NodeSocket from "@effect/platform-node/NodeSocket"; +import { + ORCHESTRATION_WS_METHODS, + WS_METHODS, + WsRpcGroup, + type OrchestrationProjectShell, + type OrchestrationThreadShell, +} from "@t3tools/contracts"; +import type { RelayAgentActivityState } from "@t3tools/contracts/relay"; +import { projectThreadAwareness } from "@t3tools/shared/agentAwareness"; +import * as Cause from "effect/Cause"; +import * as Option from "effect/Option"; +import * as Clock from "effect/Clock"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Redacted from "effect/Redacted"; +import * as Schema from "effect/Schema"; +import * as Stream from "effect/Stream"; +import * as FetchHttpClient from "effect/unstable/http/FetchHttpClient"; +import * as RpcClient from "effect/unstable/rpc/RpcClient"; +import * as RpcSerialization from "effect/unstable/rpc/RpcSerialization"; +import * as Socket from "effect/unstable/socket/Socket"; + +import * as RelayConfiguration from "../src/Config.ts"; +import { androidActivityData, fitFcmData } from "../src/agentActivity/fcmPayloads.ts"; +import * as WebCrypto from "../src/WebCrypto.ts"; +import * as FcmAssertionSigner from "../src/agentActivity/FcmAssertionSigner.ts"; +import * as FcmClient from "../src/agentActivity/FcmClient.ts"; +import * as FcmDeliveries from "../src/agentActivity/FcmDeliveries.ts"; +import { makeAggregateState } from "../src/agentActivity/agentActivityAggregate.ts"; + +const Device = Schema.Struct({ + token: Schema.NonEmptyString, + deviceId: Schema.NonEmptyString, + userId: Schema.NonEmptyString, + packageName: Schema.NonEmptyString, +}); +const Connection = Schema.Struct({ + wsUrl: Schema.NonEmptyString, + bearerToken: Schema.NonEmptyString, +}); +const readFile = (path: string) => Effect.tryPromise(() => NodeFSP.readFile(path, "utf8")); +const encodeJson = Schema.encodeSync(Schema.fromJsonString(Schema.Unknown)); +class WatchUnregisteredDeviceError extends Schema.TaggedErrorClass()( + "WatchUnregisteredDeviceError", + {}, +) { + override get message() { + return "Device token is no longer registered"; + } +} + +class WatchStoppedError extends Schema.TaggedErrorClass()("WatchStoppedError", { + cause: Schema.Defect(), +}) { + override get message() { + return "Android push watcher stopped. Check the private connection and Firebase configuration."; + } +} + +const isWatchUnregisteredDeviceError = Schema.is(WatchUnregisteredDeviceError); + +const preferences = { + notificationsEnabled: true, + liveActivitiesEnabled: true, + notifyOnApproval: true, + notifyOnInput: true, + notifyOnCompletion: true, + notifyOnFailure: true, +}; + +const main = Effect.gen(function* () { + const [credentialsPath, devicePath, connectionPath] = process.argv.slice(2); + if (!credentialsPath || !devicePath || !connectionPath) { + return yield* Effect.logError( + "Usage: node scripts/android-push-watch.ts ", + ); + } + const credentials = yield* readFile(credentialsPath); + const device = yield* readFile(devicePath).pipe( + Effect.flatMap(Schema.decodeUnknownEffect(Schema.fromJsonString(Device))), + ); + const connection = yield* readFile(connectionPath).pipe( + Effect.flatMap(Schema.decodeUnknownEffect(Schema.fromJsonString(Connection))), + ); + const socketConstructor = Layer.succeed( + Socket.WebSocketConstructor, + (url, protocols) => + new NodeSocket.NodeWS.WebSocket(url, protocols, { + headers: { authorization: `Bearer ${connection.bearerToken}` }, + }) as unknown as globalThis.WebSocket, + ); + const protocol = RpcClient.layerProtocolSocket().pipe( + Layer.provide(Socket.layerWebSocket(connection.wsUrl).pipe(Layer.provide(socketConstructor))), + Layer.provide(RpcSerialization.layerJson), + ); + const fcm = FcmClient.layer.pipe( + Layer.provide( + FcmAssertionSigner.layer.pipe( + Layer.provide(Layer.succeed(WebCrypto.WebCrypto, { subtle: globalThis.crypto.subtle })), + ), + ), + Layer.provide( + Layer.mergeAll( + FetchHttpClient.layer, + Layer.succeed(RelayConfiguration.RelayConfiguration, { + relayIssuer: "http://localhost", + apns: null, + fcmServiceAccount: Redacted.make(credentials), + clerkSecretKey: Redacted.make(""), + clerkPublishableKey: "", + clerkJwtAudience: "", + apnsDeliveryJobSigningSecret: Redacted.make(""), + cloudMintPrivateKey: Redacted.make(""), + cloudMintPublicKey: "", + managedEndpointBaseDomain: undefined, + managedEndpointNamespace: undefined, + }), + ), + ), + ); + yield* Effect.gen(function* () { + const rpc = yield* RpcClient.make(WsRpcGroup); + const sender = yield* FcmClient.FcmClient; + const config = yield* rpc[WS_METHODS.serverGetConfig]({}); + const projects = new Map(); + const threads = new Map(); + let states = new Map(); + let previouslyActive = false; + yield* Effect.logInfo("Watching this paired environment for Android push verification."); + yield* rpc[ORCHESTRATION_WS_METHODS.subscribeShell]({}).pipe( + Stream.runForEach( + Effect.fnUntraced(function* (item) { + switch (item.kind) { + case "synchronized": + return; + case "snapshot": + projects.clear(); + threads.clear(); + for (const project of item.snapshot.projects) projects.set(project.id, project); + for (const thread of item.snapshot.threads) threads.set(thread.id, thread); + break; + case "project-upserted": + projects.set(item.project.id, item.project); + break; + case "project-removed": + projects.delete(item.projectId); + break; + case "thread-upserted": + threads.set(item.thread.id, item.thread); + break; + case "thread-removed": + threads.delete(item.threadId); + break; + } + const next = new Map(); + for (const thread of threads.values()) { + const project = projects.get(thread.projectId); + if (!project || thread.archivedAt) continue; + const state = projectThreadAwareness({ + environmentId: config.environment.environmentId, + project, + thread, + }); + if (state) next.set(thread.id, state); + } + const state = item.kind === "thread-upserted" ? next.get(item.thread.id) : undefined; + const previous = state ? states.get(state.threadId) : undefined; + // A fresh subscription restores ongoing work without announcing old completions. + const now = yield* Clock.currentTimeMillis; + const alert = + state && state.phase !== previous?.phase && item.kind !== "snapshot" + ? FcmDeliveries.androidAlertForState(state, preferences, now) + : null; + const aggregate = makeAggregateState({ + activeStates: [...next.values()], + terminalState: null, + nowMs: now, + }); + const active = (aggregate?.activeCount ?? 0) > 0; + const same = encodeJson([...next.values()]) === encodeJson([...states.values()]); + states = next; + if ((!active && !previouslyActive && !alert) || (same && !alert)) return; + const result = yield* sender.send({ + token: device.token, + packageName: device.packageName, + alert: alert !== null, + data: fitFcmData({ + t3_kind: "agent_activity", + device_id: device.deviceId, + user_id: device.userId, + updated_at: String(now), + ...androidActivityData(aggregate), + ...alert, + }), + }); + if (result.unregistered) return yield* new WatchUnregisteredDeviceError({}); + previouslyActive = active; + yield* Effect.logInfo( + `Android push accepted: ${state?.phase ?? (active ? "active" : "ended")}`, + ); + }), + ), + ); + }).pipe(Effect.provide(Layer.mergeAll(protocol, fcm))); +}); + +NodeRuntime.runMain( + main.pipe( + Effect.scoped, + Effect.catchCause((cause) => { + const failure = Cause.findErrorOption(cause); + return Effect.fail( + Option.isSome(failure) && isWatchUnregisteredDeviceError(failure.value) + ? failure.value + : new WatchStoppedError({ cause }), + ); + }), + // Socket failures may contain credential-bearing request headers. Keep the + // cause on the error, but print only the fixed message at the CLI boundary. + Effect.tapError((error) => Effect.logError(error.message)), + ), + { disableErrorReporting: true }, +); diff --git a/infra/relay/src/Config.ts b/infra/relay/src/Config.ts index e7c7d42f2a..1f9872c08f 100644 --- a/infra/relay/src/Config.ts +++ b/infra/relay/src/Config.ts @@ -18,7 +18,8 @@ export class RelayConfiguration extends Context.Service< RelayConfiguration, { readonly relayIssuer: string; - readonly apns: ApnsCredentials; + readonly apns: ApnsCredentials | null; + readonly fcmServiceAccount?: Redacted.Redacted; readonly clerkSecretKey: Redacted.Redacted; readonly clerkPublishableKey: string; readonly clerkJwtAudience: string; diff --git a/infra/relay/src/WebCrypto.ts b/infra/relay/src/WebCrypto.ts new file mode 100644 index 0000000000..72358a0273 --- /dev/null +++ b/infra/relay/src/WebCrypto.ts @@ -0,0 +1,5 @@ +import * as Context from "effect/Context"; + +export class WebCrypto extends Context.Service()( + "t3code-relay/WebCrypto", +) {} diff --git a/infra/relay/src/agentActivity/AgentActivityPublisher.test.ts b/infra/relay/src/agentActivity/AgentActivityPublisher.test.ts index a70a85a8e6..ae3d4e4ad2 100644 --- a/infra/relay/src/agentActivity/AgentActivityPublisher.test.ts +++ b/infra/relay/src/agentActivity/AgentActivityPublisher.test.ts @@ -7,8 +7,18 @@ import * as AgentActivityRows from "./AgentActivityRows.ts"; import * as EnvironmentLinks from "../environments/EnvironmentLinks.ts"; import * as LiveActivities from "./LiveActivities.ts"; import * as AgentActivityPublisher from "./AgentActivityPublisher.ts"; +import { FcmDeliveries } from "./FcmDeliveries.ts"; import * as ApnsDeliveries from "./ApnsDeliveries.ts"; +const publisherLayer = AgentActivityPublisher.layer.pipe( + Layer.provide( + Layer.succeed(FcmDeliveries, { + enqueue: () => Effect.succeed(null), + process: () => Effect.void, + }), + ), +); + const state: RelayAgentActivityState = { environmentId: "env" as RelayAgentActivityState["environmentId"], threadId: "thread" as RelayAgentActivityState["threadId"], @@ -129,6 +139,64 @@ function makeApnsDeliveries( } describe("AgentActivityPublisher", () => { + it.effect("routes Android publication and registration replay to FCM alongside iOS", () => { + const android = { ...target("android"), platform: "android" as const, ios_major_version: null }; + const ios = target("ios"); + const fcmCalls: Array[0]> = []; + const appleDevices: string[] = []; + return Effect.gen(function* () { + const publisher = yield* AgentActivityPublisher.AgentActivityPublisher; + yield* publisher.publish({ + environmentId: state.environmentId, + environmentPublicKey: "key", + threadId: state.threadId, + state, + }); + yield* publisher.replayForLiveActivityRegistration({ + userId: android.user_id, + deviceId: android.device_id, + }); + expect(fcmCalls).toEqual([ + { target: android, state }, + { target: android, state: null, replay: true }, + ]); + expect(appleDevices).toEqual(["ios"]); + }).pipe( + Effect.provide( + AgentActivityPublisher.layer.pipe( + Layer.provide( + Layer.mergeAll( + Layer.succeed(AgentActivityRows.AgentActivityRows, makeAgentActivityRows()), + Layer.succeed(EnvironmentLinks.EnvironmentLinks, makeEnvironmentLinks()), + Layer.succeed( + LiveActivities.LiveActivities, + makeLiveActivities({ listTargets: () => Effect.succeed([android, ios]) }), + ), + Layer.succeed( + ApnsDeliveries.ApnsDeliveries, + makeApnsDeliveries({ + sendForTarget: (input) => + Effect.sync(() => { + appleDevices.push(input.target.device_id); + return null; + }), + }), + ), + Layer.succeed(FcmDeliveries, { + enqueue: (input) => + Effect.sync(() => { + fcmCalls.push(input); + return null; + }), + process: () => Effect.void, + }), + ), + ), + ), + ), + ); + }); + it.effect("replays the latest aggregate when a Live Activity token registers", () => { const registeredTarget: LiveActivities.TargetRow = { ...target("device-1"), @@ -157,7 +225,7 @@ describe("AgentActivityPublisher", () => { }); }).pipe( Effect.provide( - AgentActivityPublisher.layer.pipe( + publisherLayer.pipe( Layer.provide( Layer.mergeAll( Layer.succeed(AgentActivityRows.AgentActivityRows, makeAgentActivityRows()), @@ -230,7 +298,7 @@ describe("AgentActivityPublisher", () => { }); }).pipe( Effect.provide( - AgentActivityPublisher.layer.pipe( + publisherLayer.pipe( Layer.provide( Layer.mergeAll( Layer.succeed( @@ -324,7 +392,7 @@ describe("AgentActivityPublisher", () => { }); }).pipe( Effect.provide( - AgentActivityPublisher.layer.pipe( + publisherLayer.pipe( Layer.provide( Layer.mergeAll( Layer.succeed( @@ -430,7 +498,7 @@ describe("AgentActivityPublisher", () => { }); }).pipe( Effect.provide( - AgentActivityPublisher.layer.pipe( + publisherLayer.pipe( Layer.provide( Layer.mergeAll( Layer.succeed( @@ -542,7 +610,7 @@ describe("AgentActivityPublisher", () => { }); }).pipe( Effect.provide( - AgentActivityPublisher.layer.pipe( + publisherLayer.pipe( Layer.provide( Layer.mergeAll( Layer.succeed( diff --git a/infra/relay/src/agentActivity/AgentActivityPublisher.ts b/infra/relay/src/agentActivity/AgentActivityPublisher.ts index 063da58a90..61a420dd78 100644 --- a/infra/relay/src/agentActivity/AgentActivityPublisher.ts +++ b/infra/relay/src/agentActivity/AgentActivityPublisher.ts @@ -20,8 +20,10 @@ import * as AgentActivityRows from "./AgentActivityRows.ts"; import * as EnvironmentLinks from "../environments/EnvironmentLinks.ts"; import * as LiveActivities from "./LiveActivities.ts"; import * as ApnsDeliveries from "./ApnsDeliveries.ts"; +import * as FcmDeliveries from "./FcmDeliveries.ts"; export type AgentActivityPublishError = + | FcmDeliveries.FcmDeliveryError | AgentActivityRows.AgentActivityRowUpsertPersistenceError | AgentActivityRows.AgentActivityRowDeletePersistenceError | AgentActivityRows.AgentActivityRowListPersistenceError @@ -50,6 +52,7 @@ export const make = Effect.gen(function* () { const links = yield* EnvironmentLinks.EnvironmentLinks; const liveActivities = yield* LiveActivities.LiveActivities; const apnsDeliveries = yield* ApnsDeliveries.ApnsDeliveries; + const fcmDeliveries = yield* FcmDeliveries.FcmDeliveries; const publishForDeliveryUser = Effect.fnUntraced(function* (input: { readonly deliveryUser: EnvironmentLinks.AgentAwarenessDeliveryUserRecord; @@ -79,8 +82,11 @@ export const make = Effect.gen(function* () { const targets = yield* liveActivities.listTargets({ userId: input.deliveryUser.userId }); const deliveriesByTarget = yield* Effect.forEach( targets, - (target) => - Effect.all( + Effect.fnUntraced(function* (target) { + if (target.platform === "android") { + return [yield* fcmDeliveries.enqueue({ target, state: input.state })]; + } + return yield* Effect.all( [ apnsDeliveries.sendForTarget({ target, @@ -95,7 +101,8 @@ export const make = Effect.gen(function* () { }), ], { concurrency: 2 }, - ), + ); + }), { concurrency: 4 }, ); return deliveriesByTarget.flat(); @@ -120,6 +127,9 @@ export const make = Effect.gen(function* () { if (target === null) { return null; } + if (target.platform === "android") { + return yield* fcmDeliveries.enqueue({ target, state: null, replay: true }); + } const now = yield* DateTime.now; const aggregate = makeAggregateState({ activeStates, diff --git a/infra/relay/src/agentActivity/ApnsDeliveries.test.ts b/infra/relay/src/agentActivity/ApnsDeliveries.test.ts index 75b821bcfa..e98c2b6395 100644 --- a/infra/relay/src/agentActivity/ApnsDeliveries.test.ts +++ b/infra/relay/src/agentActivity/ApnsDeliveries.test.ts @@ -63,7 +63,7 @@ const apnsSigningKeyPair = NodeCrypto.generateKeyPairSync("ec", { const signingConfig = RelayConfiguration.RelayConfiguration.of({ ...config, apns: { - ...config.apns, + ...config.apns!, privateKey: Redacted.make(apnsSigningKeyPair.privateKey), }, }); @@ -257,6 +257,48 @@ function makeLayer(input: { } describe("ApnsDeliveries", () => { + it.effect("skips Apple delivery when an Android-only relay disables APNs", () => { + const attempts: Array = []; + const queuedJobs: Array = []; + return Effect.gen(function* () { + const service = yield* ApnsDeliveries.ApnsDeliveries; + expect(yield* service.sendForTarget({ target, aggregate, nowMs: 0 })).toBeNull(); + expect(yield* service.sendPushNotificationForTarget({ target, aggregate })).toBeNull(); + expect( + yield* service.sendLiveActivity({ + target, + token: "activity-token", + kind: "live_activity_update", + aggregate, + }), + ).toMatchObject({ ok: false, apnsReason: "APNs is disabled for this relay." }); + expect( + yield* service.sendPushNotification({ + target, + token: "push-token", + notification: { + title: "Finished", + body: "Thread", + environmentId: state.environmentId, + threadId: state.threadId, + deepLink: state.deepLink, + }, + }), + ).toMatchObject({ ok: false, apnsReason: "APNs is disabled for this relay." }); + expect(queuedJobs).toHaveLength(0); + expect(attempts).toHaveLength(0); + }).pipe( + Effect.provide( + makeLayer({ + attempts, + queuedJobs, + config: { ...config, apns: null }, + execute: () => Effect.die("Disabled APNs must not make HTTP requests"), + }), + ), + ); + }); + it.effect("never starts an activity remotely when no update token is registered", () => { const attempts: Array = []; const queuedJobs: Array = []; diff --git a/infra/relay/src/agentActivity/ApnsDeliveries.ts b/infra/relay/src/agentActivity/ApnsDeliveries.ts index 411190013e..7ad25fd920 100644 --- a/infra/relay/src/agentActivity/ApnsDeliveries.ts +++ b/infra/relay/src/agentActivity/ApnsDeliveries.ts @@ -676,6 +676,16 @@ export const make = Effect.gen(function* () { const sendLiveActivity: ApnsDeliveries["Service"]["sendLiveActivity"] = Effect.fn( "relay.apns_deliveries.send_live_activity", )(function* (input) { + if (!config.apns) { + return { + deviceId: input.target.device_id, + kind: input.kind, + ok: false, + apnsStatus: null, + apnsReason: "APNs is disabled for this relay.", + apnsId: null, + }; + } yield* Effect.annotateCurrentSpan({ "relay.mobile.device_id": input.target.device_id, "relay.delivery.kind": input.kind, @@ -842,6 +852,16 @@ export const make = Effect.gen(function* () { const sendPushNotification: ApnsDeliveries["Service"]["sendPushNotification"] = Effect.fn( "relay.apns_deliveries.send_push_notification", )(function* (input) { + if (!config.apns) { + return { + deviceId: input.target.device_id, + kind: "push_notification", + ok: false, + apnsStatus: null, + apnsReason: "APNs is disabled for this relay.", + apnsId: null, + }; + } yield* Effect.annotateCurrentSpan({ "relay.mobile.device_id": input.target.device_id, "relay.delivery.kind": "push_notification", @@ -1081,6 +1101,7 @@ export const make = Effect.gen(function* () { sendPushNotification, processSignedJob, sendPushNotificationForTarget: Effect.fnUntraced(function* (input) { + if (!config.apns) return null; const now = yield* DateTime.now; const notification = notificationForAggregate({ target: input.target, @@ -1100,6 +1121,7 @@ export const make = Effect.gen(function* () { : Effect.succeed(null); }), sendForTarget: Effect.fnUntraced(function* (input) { + if (!config.apns) return null; const delivery = chooseDelivery({ target: input.target, aggregate: input.aggregate, diff --git a/infra/relay/src/agentActivity/Devices.ts b/infra/relay/src/agentActivity/Devices.ts index 42087128c3..1071cc85e5 100644 --- a/infra/relay/src/agentActivity/Devices.ts +++ b/infra/relay/src/agentActivity/Devices.ts @@ -128,7 +128,8 @@ export const make = Effect.gen(function* () { deviceId: registration.deviceId, label: registration.label, platform: registration.platform, - iosMajorVersion: registration.iosMajorVersion, + iosMajorVersion: registration.iosMajorVersion ?? null, + androidApiLevel: registration.androidApiLevel ?? null, appVersion: registration.appVersion ?? null, bundleId: registration.bundleId ?? null, apsEnvironment: registration.apsEnvironment ?? null, @@ -143,7 +144,8 @@ export const make = Effect.gen(function* () { set: { platform: registration.platform, label: registration.label, - iosMajorVersion: registration.iosMajorVersion, + iosMajorVersion: registration.iosMajorVersion ?? null, + androidApiLevel: registration.androidApiLevel ?? null, appVersion: registration.appVersion ?? null, // Preserve routing from newer app builds when an older build // re-registers without these fields. @@ -225,6 +227,7 @@ export const make = Effect.gen(function* () { label: relayMobileDevices.label, platform: relayMobileDevices.platform, iosMajorVersion: relayMobileDevices.iosMajorVersion, + androidApiLevel: relayMobileDevices.androidApiLevel, appVersion: relayMobileDevices.appVersion, preferences: relayMobileDevices.preferencesJson, updatedAt: relayMobileDevices.updatedAt, @@ -241,6 +244,7 @@ export const make = Effect.gen(function* () { label: row.label, platform: row.platform, iosMajorVersion: row.iosMajorVersion, + androidApiLevel: row.androidApiLevel, appVersion: row.appVersion, notifications: { enabled: row.preferences.notificationsEnabled, diff --git a/infra/relay/src/agentActivity/FcmAssertionSigner.ts b/infra/relay/src/agentActivity/FcmAssertionSigner.ts new file mode 100644 index 0000000000..86ee270a1c --- /dev/null +++ b/infra/relay/src/agentActivity/FcmAssertionSigner.ts @@ -0,0 +1,76 @@ +import * as Context from "effect/Context"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Schema from "effect/Schema"; + +import * as WebCrypto from "../WebCrypto.ts"; + +const encodeJson = Schema.encodeSync(Schema.fromJsonString(Schema.Unknown)); + +export class FcmAssertionSigningError extends Schema.TaggedErrorClass()( + "FcmAssertionSigningError", + { cause: Schema.Defect() }, +) { + override get message() { + return "Failed to sign Firebase authorization assertion."; + } +} + +function base64Url(bytes: Uint8Array): string { + return btoa(String.fromCharCode(...bytes)) + .replaceAll("+", "-") + .replaceAll("/", "_") + .replace(/=+$/, ""); +} + +export class FcmAssertionSigner extends Context.Service< + FcmAssertionSigner, + { + readonly sign: (input: { + readonly privateKey: string; + readonly clientEmail: string; + readonly issuedAt: number; + }) => Effect.Effect; + } +>()("t3code-relay/agentActivity/FcmAssertionSigner") {} + +export const make = Effect.gen(function* () { + const { subtle } = yield* WebCrypto.WebCrypto; + return FcmAssertionSigner.of({ + sign: Effect.fn("relay.fcm.assertion")(function* (input) { + return yield* Effect.tryPromise({ + try: async () => { + const encoder = new TextEncoder(); + const header = base64Url(encoder.encode(encodeJson({ alg: "RS256", typ: "JWT" }))); + const claims = base64Url( + encoder.encode( + encodeJson({ + iss: input.clientEmail, + scope: "https://www.googleapis.com/auth/firebase.messaging", + aud: "https://oauth2.googleapis.com/token", + iat: input.issuedAt, + exp: input.issuedAt + 3600, + }), + ), + ); + const pem = input.privateKey.replace(/-----[^-]+-----|\s/g, ""); + const key = await subtle.importKey( + "pkcs8", + Uint8Array.from(atob(pem), (c) => c.charCodeAt(0)), + { name: "RSASSA-PKCS1-v1_5", hash: "SHA-256" }, + false, + ["sign"], + ); + const signature = await subtle.sign( + "RSASSA-PKCS1-v1_5", + key, + encoder.encode(`${header}.${claims}`), + ); + return `${header}.${claims}.${base64Url(new Uint8Array(signature))}`; + }, + catch: (cause) => new FcmAssertionSigningError({ cause }), + }); + }), + }); +}); +export const layer = Layer.effect(FcmAssertionSigner, make); diff --git a/infra/relay/src/agentActivity/FcmClient.test.ts b/infra/relay/src/agentActivity/FcmClient.test.ts new file mode 100644 index 0000000000..90818f92f0 --- /dev/null +++ b/infra/relay/src/agentActivity/FcmClient.test.ts @@ -0,0 +1,254 @@ +import * as NodeCrypto from "node:crypto"; +import { describe, expect, it } from "@effect/vitest"; +import * as Deferred from "effect/Deferred"; +import * as Effect from "effect/Effect"; +import * as Fiber from "effect/Fiber"; +import * as Layer from "effect/Layer"; +import * as Redacted from "effect/Redacted"; +import * as Schema from "effect/Schema"; +import * as TestClock from "effect/testing/TestClock"; +import * as HttpClient from "effect/unstable/http/HttpClient"; +import * as HttpClientResponse from "effect/unstable/http/HttpClientResponse"; +import type * as HttpClientRequest from "effect/unstable/http/HttpClientRequest"; + +import { RelayConfiguration } from "../Config.ts"; +import { FcmClient, layer } from "./FcmClient.ts"; + +import * as WebCrypto from "../WebCrypto.ts"; +import * as FcmAssertionSigner from "./FcmAssertionSigner.ts"; + +const { privateKey, publicKey } = NodeCrypto.generateKeyPairSync("rsa", { + modulusLength: 2048, + privateKeyEncoding: { type: "pkcs8", format: "pem" }, + publicKeyEncoding: { type: "spki", format: "pem" }, +}); +const account = { + project_id: "test-project", + client_email: "push@test-project.iam.gserviceaccount.com", + private_key: privateKey, +}; +const encodeJson = Schema.encodeSync(Schema.fromJsonString(Schema.Unknown)); +const decodeJson = Schema.decodeUnknownSync(Schema.fromJsonString(Schema.Unknown)); +const config = { + relayIssuer: "https://relay.test", + apns: { + environment: "sandbox", + teamId: "team", + keyId: "key", + bundleId: "app", + privateKey: Redacted.make("unused"), + }, + fcmServiceAccount: Redacted.make(encodeJson(account)), + clerkSecretKey: Redacted.make("unused"), + clerkPublishableKey: "unused", + clerkJwtAudience: "unused", + apnsDeliveryJobSigningSecret: Redacted.make("unused"), + cloudMintPrivateKey: Redacted.make("unused"), + cloudMintPublicKey: "unused", + managedEndpointBaseDomain: undefined, + managedEndpointNamespace: undefined, +} satisfies RelayConfiguration["Service"]; +const input = { + token: "device-token", + packageName: "com.t3tools.t3code.dev", + data: { t3_kind: "agent_activity", active: "true" }, + alert: false, +}; + +function testLayer(requests: HttpClientRequest.HttpClientRequest[], responses: Response[]) { + const http = HttpClient.make((request) => { + requests.push(request); + const response = responses.shift(); + return response + ? Effect.succeed(HttpClientResponse.fromWeb(request, response)) + : Effect.die("unexpected request"); + }); + return layer.pipe( + Layer.provide( + FcmAssertionSigner.layer.pipe( + Layer.provide(Layer.succeed(WebCrypto.WebCrypto, { subtle: globalThis.crypto.subtle })), + ), + ), + Layer.provide( + Layer.mergeAll( + Layer.succeed(RelayConfiguration, config), + Layer.succeed(HttpClient.HttpClient, http), + ), + ), + ); +} + +describe("FCM delivery", () => { + it.effect.each([ + { operation: "authorize", stage: "headers", status: null }, + { operation: "authorize", stage: "body", status: 200 }, + { operation: "send", stage: "headers", status: null }, + { operation: "send", stage: "body", status: 503 }, + ] as const)("bounds a stalled $operation $stage and allows the next delivery", (scenario) => + Effect.gen(function* () { + const started = yield* Deferred.make(); + let shouldStall = true; + const http = HttpClient.make((request) => { + const isAuthorization = request.url === "https://oauth2.googleapis.com/token"; + const stall = shouldStall && isAuthorization === (scenario.operation === "authorize"); + const response = HttpClientResponse.fromWeb( + request, + isAuthorization + ? Response.json({ access_token: "access-token" }) + : Response.json({}, { status: stall ? 503 : 200 }), + ); + if (stall) { + shouldStall = false; + const stalled = Deferred.succeed(started, undefined).pipe(Effect.andThen(Effect.never)); + if (scenario.stage === "headers") return stalled; + Object.defineProperty(response, "json", { value: stalled }); + } + return Effect.succeed(response); + }); + yield* Effect.gen(function* () { + const client = yield* FcmClient; + const delivery = yield* client.send(input).pipe(Effect.flip, Effect.forkChild); + yield* Deferred.await(started); + yield* TestClock.adjust("10 seconds"); + expect(yield* Fiber.join(delivery)).toMatchObject({ + _tag: "FcmClientError", + operation: scenario.operation, + status: scenario.status, + cause: expect.objectContaining({ _tag: "TimeoutError" }), + }); + expect(yield* client.send(input)).toEqual({ unregistered: false }); + }).pipe( + Effect.provide( + layer.pipe( + Layer.provide( + FcmAssertionSigner.layer.pipe( + Layer.provide( + Layer.succeed(WebCrypto.WebCrypto, { subtle: globalThis.crypto.subtle }), + ), + ), + ), + Layer.provide(Layer.succeed(RelayConfiguration, config)), + Layer.provide(Layer.succeed(HttpClient.HttpClient, http)), + ), + ), + ); + }), + ); + + it.effect("signs a verifiable Google OAuth assertion scoped to messaging", () => + Effect.gen(function* () { + const signer = yield* FcmAssertionSigner.FcmAssertionSigner; + const assertion = yield* signer.sign({ + privateKey: account.private_key, + clientEmail: account.client_email, + issuedAt: 1000, + }); + const [header, claims, signature] = assertion.split("."); + expect( + NodeCrypto.verify( + "RSA-SHA256", + Buffer.from(`${header}.${claims}`), + publicKey, + Buffer.from(signature!, "base64url"), + ), + ).toBe(true); + expect(decodeJson(Buffer.from(claims!, "base64url").toString())).toEqual({ + iss: account.client_email, + scope: "https://www.googleapis.com/auth/firebase.messaging", + aud: "https://oauth2.googleapis.com/token", + iat: 1000, + exp: 4600, + }); + }).pipe( + Effect.provide( + FcmAssertionSigner.layer.pipe( + Layer.provide(Layer.succeed(WebCrypto.WebCrypto, { subtle: globalThis.crypto.subtle })), + ), + ), + ), + ); + + it.effect( + "reuses OAuth authorization and sends native data messages to the correct package", + () => { + const requests: HttpClientRequest.HttpClientRequest[] = []; + return Effect.gen(function* () { + const client = yield* FcmClient; + yield* client.send(input); + yield* client.send({ ...input, alert: true }); + expect(requests.map((request) => request.url)).toEqual([ + "https://oauth2.googleapis.com/token", + "https://fcm.googleapis.com/v1/projects/test-project/messages:send", + "https://fcm.googleapis.com/v1/projects/test-project/messages:send", + ]); + expect(requests[1]!.headers.authorization).toBe("Bearer access-token"); + const body = requests[1]!.body; + if (body._tag !== "Uint8Array") throw new Error("Expected encoded FCM body"); + expect(decodeJson(new TextDecoder().decode(body.body))).toEqual({ + message: { + token: input.token, + data: input.data, + android: { + priority: "HIGH", + ttl: "300s", + collapse_key: "t3-agent-activity", + restricted_package_name: input.packageName, + }, + }, + }); + }).pipe( + Effect.provide( + testLayer(requests, [ + Response.json({ access_token: "access-token" }), + Response.json({ name: "one" }), + Response.json({ name: "two" }), + ]), + ), + ); + }, + ); + + it.effect("invalidates authorization after 401 and recognizes unregistered device tokens", () => { + const requests: HttpClientRequest.HttpClientRequest[] = []; + return Effect.gen(function* () { + const client = yield* FcmClient; + const first = yield* client.send(input).pipe(Effect.flip); + expect(first.status).toBe(401); + expect(yield* client.send(input)).toEqual({ unregistered: true }); + expect(requests[2]!.url).toBe("https://oauth2.googleapis.com/token"); + expect(requests[3]!.headers.authorization).toBe("Bearer fresh-token"); + }).pipe( + Effect.provide( + testLayer(requests, [ + Response.json({ access_token: "old-token" }), + Response.json({}, { status: 401 }), + Response.json({ access_token: "fresh-token" }), + Response.json( + { + error: { + details: [ + { + "@type": "type.googleapis.com/google.firebase.fcm.v1.FcmError", + errorCode: "UNREGISTERED", + }, + ], + }, + }, + { status: 404 }, + ), + ]), + ), + ); + }); + it.effect("rejects oversized data before contacting Firebase", () => { + const requests: HttpClientRequest.HttpClientRequest[] = []; + return Effect.gen(function* () { + const client = yield* FcmClient; + const error = yield* client + .send({ ...input, data: { body: "漢".repeat(1500) } }) + .pipe(Effect.flip); + expect(error.operation).toBe("send"); + expect(requests).toHaveLength(0); + }).pipe(Effect.provide(testLayer(requests, []))); + }); +}); diff --git a/infra/relay/src/agentActivity/FcmClient.ts b/infra/relay/src/agentActivity/FcmClient.ts new file mode 100644 index 0000000000..aed56fac83 --- /dev/null +++ b/infra/relay/src/agentActivity/FcmClient.ts @@ -0,0 +1,166 @@ +import * as Context from "effect/Context"; +import * as DateTime from "effect/DateTime"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as Redacted from "effect/Redacted"; +import * as Schema from "effect/Schema"; +import * as HttpClient from "effect/unstable/http/HttpClient"; +import * as HttpClientRequest from "effect/unstable/http/HttpClientRequest"; + +import * as RelayConfiguration from "../Config.ts"; +import * as FcmAssertionSigner from "./FcmAssertionSigner.ts"; + +const FCM_HTTP_STAGE_TIMEOUT = "10 seconds"; + +const ServiceAccount = Schema.Struct({ + project_id: Schema.NonEmptyString, + client_email: Schema.NonEmptyString, + private_key: Schema.NonEmptyString, +}); +const decodeServiceAccount = Schema.decodeUnknownOption(Schema.fromJsonString(ServiceAccount)); +const decodeAccessToken = Schema.decodeUnknownEffect( + Schema.Struct({ access_token: Schema.NonEmptyString }), +); +const encodeJson = Schema.encodeSync(Schema.fromJsonString(Schema.Unknown)); +const decodeFcmError = Schema.decodeUnknownOption( + Schema.Struct({ + error: Schema.Struct({ + status: Schema.optional(Schema.String), + details: Schema.optional( + Schema.Array( + Schema.Struct({ + "@type": Schema.optional(Schema.String), + errorCode: Schema.optional(Schema.String), + }), + ), + ), + }), + }), +); + +export class FcmClientError extends Schema.TaggedErrorClass()("FcmClientError", { + operation: Schema.Literals(["configuration", "authorize", "send"]), + status: Schema.NullOr(Schema.Number), + cause: Schema.optional(Schema.Defect()), +}) { + override get message() { + return `FCM ${this.operation} failed${this.status === null ? "" : ` (${this.status})`}.`; + } +} + +export class FcmClient extends Context.Service< + FcmClient, + { + readonly send: (input: { + readonly token: string; + readonly packageName: string | null; + readonly data: Readonly>; + readonly alert: boolean; + }) => Effect.Effect<{ readonly unregistered: boolean }, FcmClientError>; + } +>()("t3code-relay/agentActivity/FcmClient") {} + +export const make = Effect.gen(function* () { + const config = yield* RelayConfiguration.RelayConfiguration; + const signer = yield* FcmAssertionSigner.FcmAssertionSigner; + const client = yield* HttpClient.HttpClient; + const account = config.fcmServiceAccount + ? decodeServiceAccount(Redacted.value(config.fcmServiceAccount)) + : Option.none(); + const authorize = Effect.gen(function* () { + if (Option.isNone(account)) + return yield* new FcmClientError({ operation: "configuration", status: null }); + const now = yield* DateTime.now; + const assertion = yield* signer + .sign({ + privateKey: account.value.private_key, + clientEmail: account.value.client_email, + issuedAt: Math.floor(now.epochMilliseconds / 1000), + }) + .pipe( + Effect.mapError( + (cause) => new FcmClientError({ operation: "authorize", status: null, cause }), + ), + ); + const response = yield* client + .execute( + HttpClientRequest.post("https://oauth2.googleapis.com/token").pipe( + HttpClientRequest.bodyUrlParams({ + grant_type: "urn:ietf:params:oauth:grant-type:jwt-bearer", + assertion, + }), + ), + ) + .pipe( + Effect.timeout(FCM_HTTP_STAGE_TIMEOUT), + Effect.mapError( + (cause) => new FcmClientError({ operation: "authorize", status: null, cause }), + ), + ); + if (response.status !== 200) + return yield* new FcmClientError({ operation: "authorize", status: response.status }); + return yield* response.json.pipe( + Effect.timeout(FCM_HTTP_STAGE_TIMEOUT), + Effect.flatMap(decodeAccessToken), + Effect.map((body) => body.access_token), + Effect.mapError( + (cause) => new FcmClientError({ operation: "authorize", status: response.status, cause }), + ), + ); + }); + const [accessToken, invalidateToken] = yield* Effect.cachedInvalidateWithTTL( + authorize, + "50 minutes", + ); + + return FcmClient.of({ + send: Effect.fn("relay.fcm.send")(function* (input) { + if (new TextEncoder().encode(encodeJson(input.data)).length > 4096) + return yield* new FcmClientError({ operation: "send", status: null }); + if (Option.isNone(account)) + return yield* new FcmClientError({ operation: "configuration", status: null }); + const token = yield* accessToken.pipe(Effect.tapError(() => invalidateToken)); + const response = yield* HttpClientRequest.post( + `https://fcm.googleapis.com/v1/projects/${encodeURIComponent(account.value.project_id)}/messages:send`, + ).pipe( + HttpClientRequest.bearerToken(token), + HttpClientRequest.bodyJson({ + message: { + token: input.token, + data: input.data, + android: { + priority: "HIGH", + ttl: "300s", + ...(!input.alert ? { collapse_key: "t3-agent-activity" } : {}), + ...(input.packageName ? { restricted_package_name: input.packageName } : {}), + }, + }, + }), + Effect.flatMap(client.execute), + Effect.timeout(FCM_HTTP_STAGE_TIMEOUT), + Effect.mapError((cause) => new FcmClientError({ operation: "send", status: null, cause })), + ); + if (response.status >= 200 && response.status < 300) return { unregistered: false }; + if (response.status === 401) yield* invalidateToken; + const body = yield* response.json.pipe( + Effect.timeout(FCM_HTTP_STAGE_TIMEOUT), + Effect.mapError( + (cause) => new FcmClientError({ operation: "send", status: response.status, cause }), + ), + ); + const decoded = decodeFcmError(body); + const unregistered = + Option.isSome(decoded) && + decoded.value.error.details?.some( + (detail) => + detail["@type"] === "type.googleapis.com/google.firebase.fcm.v1.FcmError" && + detail.errorCode === "UNREGISTERED", + ) === true; + if (unregistered) return { unregistered: true }; + return yield* new FcmClientError({ operation: "send", status: response.status }); + }), + }); +}); + +export const layer = Layer.effect(FcmClient, make); diff --git a/infra/relay/src/agentActivity/FcmDeliveries.test.ts b/infra/relay/src/agentActivity/FcmDeliveries.test.ts new file mode 100644 index 0000000000..9e4868b7f1 --- /dev/null +++ b/infra/relay/src/agentActivity/FcmDeliveries.test.ts @@ -0,0 +1,836 @@ +import { EnvironmentId, ThreadId } from "@t3tools/contracts"; +import type { RelayAgentActivityState } from "@t3tools/contracts/relay"; +import { describe, expect, it } from "@effect/vitest"; +import * as NodeCryptoLayer from "@effect/platform-node/NodeCrypto"; +import * as Effect from "effect/Effect"; +import * as DateTime from "effect/DateTime"; +import * as Layer from "effect/Layer"; +import * as Redacted from "effect/Redacted"; +import * as Schema from "effect/Schema"; +import * as Stream from "effect/Stream"; +import type * as Cloudflare from "alchemy/Cloudflare"; +import * as FcmDeliveryQueueConsumer from "./FcmDeliveryQueueConsumer.ts"; + +import { RelayConfiguration } from "../Config.ts"; +import { RelayDb } from "../db.ts"; +import { EnvironmentLinks } from "../environments/EnvironmentLinks.ts"; +import { AgentActivityRows } from "./AgentActivityRows.ts"; +import { LiveActivities, type TargetRow } from "./LiveActivities.ts"; +import * as FcmDeliveryQueueSender from "./FcmDeliveryQueueSender.ts"; +import { FcmClient, FcmClientError } from "./FcmClient.ts"; +import { + FcmDeliveries, + androidAlertForState, + androidAlertForAggregate, + layer, + type FcmDeliveryJob, +} from "./FcmDeliveries.ts"; +import { TestClock } from "effect/testing"; +import { androidActivityData, fitFcmData } from "./fcmPayloads.ts"; +import { makeAggregateState } from "./agentActivityAggregate.ts"; + +const aggregateFor = (states: ReadonlyArray) => + makeAggregateState({ activeStates: states, terminalState: null, nowMs: 0 })!; + +const encodeJson = Schema.encodeSync(Schema.fromJsonString(Schema.Unknown)); +const state: RelayAgentActivityState = { + environmentId: EnvironmentId.make("env"), + threadId: ThreadId.make("thread"), + projectTitle: "Project", + threadTitle: "Fix notifications", + phase: "running", + headline: "Working", + modelTitle: "Codex", + updatedAt: "1970-01-01T00:00:00.000Z", + deepLink: "/threads/env/thread", +}; +const preferences = { + notificationsEnabled: true, + liveActivitiesEnabled: true, + notifyOnApproval: true, + notifyOnInput: true, + notifyOnCompletion: true, + notifyOnFailure: true, +}; +const target: TargetRow = { + user_id: "user", + device_id: "phone", + platform: "android", + ios_major_version: null, + app_version: null, + bundle_id: "com.t3tools.t3code.dev", + aps_environment: null, + push_token: "fcm-token", + push_to_start_token: null, + preferences_json: encodeJson(preferences), + activity_push_token: null, + remote_start_queued_at: null, + remote_started_at: null, + ended_at: null, + last_aggregate_json: null, + last_live_activity_delivery_at: null, +}; +const config = { + relayIssuer: "https://relay.test", + fcmServiceAccount: Redacted.make("configured"), + apns: { + environment: "sandbox", + teamId: "unused", + keyId: "unused", + bundleId: "unused", + privateKey: Redacted.make("unused"), + }, + clerkSecretKey: Redacted.make("unused"), + clerkPublishableKey: "unused", + clerkJwtAudience: "unused", + apnsDeliveryJobSigningSecret: Redacted.make("unused"), + cloudMintPrivateKey: Redacted.make("unused"), + cloudMintPublicKey: "unused", + managedEndpointBaseDomain: undefined, + managedEndpointNamespace: undefined, +} satisfies RelayConfiguration["Service"]; + +function harness() { + const sent: Array[0]> = []; + const queued: FcmDeliveryJob[] = []; + const marked: Array[0]> = []; + const current = { + target: { ...target }, + state: { ...state } as RelayAgentActivityState | null, + otherStates: [] as RelayAgentActivityState[], + mutedEnvironments: [] as string[], + notificationOnlyEnvironments: [] as string[], + revokedEnvironments: [] as string[], + linked: true, + deliveryFailure: null as FcmClientError | null, + }; + const services = Layer.mergeAll( + NodeCryptoLayer.layer, + Layer.succeed(RelayConfiguration, config), + Layer.succeed(FcmDeliveryQueueSender.FcmDeliveryQueueSender, { + send: (job) => + Effect.sync(() => { + queued.push(job); + }), + }), + Layer.succeed(FcmClient, { + send: (input) => + Effect.suspend(() => + current.deliveryFailure + ? Effect.fail(current.deliveryFailure) + : Effect.sync(() => { + sent.push(input); + return { unregistered: false }; + }), + ), + }), + Layer.succeed(LiveActivities, { + register: () => Effect.void, + listTargets: () => Effect.sync(() => [current.target]), + markDelivery: (input) => + Effect.sync(() => { + marked.push(input); + current.target.last_aggregate_json = input.aggregate ? encodeJson(input.aggregate) : null; + }), + markStartQueued: () => Effect.void, + clearStartQueued: () => Effect.void, + invalidateDeliveryToken: () => Effect.void, + }), + Layer.succeed(AgentActivityRows, { + upsert: () => Effect.void, + remove: () => Effect.void, + pruneTerminal: () => Effect.void, + listForUser: () => + Effect.sync(() => + current.linked + ? [...(current.state ? [current.state] : []), ...current.otherStates].filter( + (row) => + !current.revokedEnvironments.includes(row.environmentId) && + !current.notificationOnlyEnvironments.includes(row.environmentId), + ) + : [], + ), + getForUserThread: (input) => + Effect.sync(() => + current.linked + ? ([current.state, ...current.otherStates].find( + (row) => + row?.environmentId === input.environmentId && row.threadId === input.threadId, + ) ?? null) + : null, + ), + }), + Layer.succeed(EnvironmentLinks, { + upsert: () => Effect.void, + listUsersForEnvironment: () => Effect.succeed(["user"]), + listDeliveryUsersForEnvironment: (input) => + Effect.sync(() => + current.linked && !current.revokedEnvironments.includes(input.environmentId) + ? [ + { + userId: "user", + notificationsEnabled: !current.mutedEnvironments.includes(input.environmentId), + liveActivitiesEnabled: !current.notificationOnlyEnvironments.includes( + input.environmentId, + ), + }, + ] + : [], + ), + listPublicKeysForEnvironment: () => Effect.succeed([]), + listForUser: () => Effect.succeed([]), + revokeForUser: () => Effect.succeed(false), + getForUser: (input) => + Effect.sync(() => + current.linked && !current.revokedEnvironments.includes(input.environmentId) + ? { + environmentId: EnvironmentId.make(input.environmentId), + label: "Desktop", + environmentPublicKey: "key", + linkedAt: state.updatedAt, + endpoint: { + httpBaseUrl: "https://env.test", + wsBaseUrl: "wss://env.test", + providerKind: "manual", + }, + } + : null, + ), + }), + Layer.succeed(RelayDb, {} as RelayDb["Service"]), + ); + return { + sent, + queued, + marked, + current, + layer: layer.pipe(Layer.provide(services)), + job: { + userId: "user", + deviceId: "phone", + token: "fcm-token", + state, + queuedAt: 0, + } satisfies FcmDeliveryJob, + }; +} + +describe("Android delivery routing", () => { + const secondState: RelayAgentActivityState = { + ...state, + threadId: ThreadId.make("second-thread"), + threadTitle: "Second thread", + deepLink: "/threads/env/second-thread", + }; + + for (const [firstPhase, secondPhase, title, active] of [ + ["waiting_for_approval", "waiting_for_input", "2 agents need attention", "true"], + ["completed", "failed", "2 agents finished", "false"], + ] as const) { + it.effect(`groups ${firstPhase} and ${secondPhase} once across their queued jobs`, () => { + const h = harness(); + h.current.otherStates = [secondState]; + return Effect.gen(function* () { + const delivery = yield* FcmDeliveries; + yield* delivery.process(h.job); + h.current.state = { ...state, phase: firstPhase }; + h.current.otherStates = [{ ...secondState, phase: secondPhase }]; + yield* delivery.process({ ...h.job, state: h.current.state }); + yield* delivery.process({ ...h.job, state: h.current.otherStates[0] }); + yield* delivery.process({ ...h.job, state: h.current.state }); + const alerts = h.sent.filter((message) => message.alert); + expect(alerts).toHaveLength(1); + expect(alerts[0]?.data).toMatchObject({ + alert_title: title, + alert_body: "Fix notifications, Second thread", + alert_path: firstPhase === "completed" ? secondState.deepLink : state.deepLink, + active, + }); + expect(h.marked.at(-1)?.aggregate?.activities).toHaveLength(2); + }).pipe(Effect.provide(h.layer)); + }); + } + + it.effect("filters disabled event types before counting a group", () => { + const h = harness(); + h.current.target.last_aggregate_json = encodeJson(aggregateFor([state, secondState])); + h.current.target.preferences_json = encodeJson({ ...preferences, notifyOnApproval: false }); + h.current.state = { ...state, phase: "waiting_for_approval" }; + h.current.otherStates = [{ ...secondState, phase: "waiting_for_input" }]; + return Effect.gen(function* () { + const delivery = yield* FcmDeliveries; + yield* delivery.process({ ...h.job, state: h.current.state }); + expect(h.sent[0]?.data).toMatchObject({ + alert_title: "Second thread", + alert_body: "Input: Project", + alert_path: "/threads/env/second-thread", + }); + }).pipe(Effect.provide(h.layer)); + }); + + it.effect("excludes already delivered attention and stale completions from groups", () => { + const h = harness(); + h.current.state = { ...state, phase: "waiting_for_approval" }; + h.current.target.last_aggregate_json = encodeJson(aggregateFor([h.current.state, secondState])); + h.current.otherStates = [ + { ...secondState, phase: "completed", updatedAt: "1969-12-31T23:57:00.000Z" }, + ]; + return Effect.gen(function* () { + const delivery = yield* FcmDeliveries; + yield* delivery.process({ ...h.job, state: h.current.state }); + expect(h.sent.every((message) => !message.alert)).toBe(true); + }).pipe(Effect.provide(h.layer)); + }); + + it.effect("prioritizes attention over simultaneous completions like iOS", () => { + const h = harness(); + h.current.target.last_aggregate_json = encodeJson(aggregateFor([state, secondState])); + h.current.state = { ...state, phase: "completed" }; + h.current.otherStates = [{ ...secondState, phase: "waiting_for_input" }]; + return Effect.gen(function* () { + const delivery = yield* FcmDeliveries; + yield* delivery.process({ ...h.job, state: h.current.state }); + expect(h.sent[0]?.data).toMatchObject({ + alert_title: "Second thread", + alert_body: "Input: Project", + }); + yield* delivery.process({ ...h.job, state: h.current.otherStates[0] }); + expect(h.sent.filter((message) => message.alert)).toHaveLength(1); + }).pipe(Effect.provide(h.layer)); + }); + + for (const phase of [ + "completed", + "waiting_for_approval", + "waiting_for_input", + "failed", + ] as const) { + it.effect(`deleting one thread preserves another thread's ${phase} alert`, () => { + const h = harness(); + h.current.otherStates = [secondState]; + return Effect.gen(function* () { + const delivery = yield* FcmDeliveries; + yield* delivery.process(h.job); + h.current.otherStates = []; + h.current.state = { ...state, phase }; + yield* delivery.enqueue({ target, state: null }); + yield* delivery.process(h.queued[0]); + expect(h.sent.at(-1)?.alert).toBe(false); + yield* delivery.process({ ...h.job, state: h.current.state }); + expect(h.sent.filter((message) => message.alert)).toHaveLength(1); + yield* delivery.process({ ...h.job, state: h.current.state }); + expect(h.sent.filter((message) => message.alert)).toHaveLength(1); + }).pipe(Effect.provide(h.layer)); + }); + } + + it.effect("registration replay establishes a baseline without alerting", () => { + const h = harness(); + h.current.state = { ...state, phase: "waiting_for_approval" }; + h.current.otherStates = [{ ...secondState, phase: "waiting_for_input" }]; + return Effect.gen(function* () { + const delivery = yield* FcmDeliveries; + yield* delivery.enqueue({ target, state: null, replay: true }); + yield* delivery.process(h.queued[0]); + yield* delivery.process({ ...h.job, state: h.current.state }); + expect(h.sent.every((message) => !message.alert)).toBe(true); + expect(h.marked[0]?.aggregate?.activities).toHaveLength(2); + }).pipe(Effect.provide(h.layer)); + }); + + for (const restriction of ["mutedEnvironments", "revokedEnvironments"] as const) { + it.effect(`excludes ${restriction} when forming cross-environment groups`, () => { + const h = harness(); + const other = { ...secondState, environmentId: EnvironmentId.make("other-env") }; + h.current.target.last_aggregate_json = encodeJson(aggregateFor([state, other])); + h.current.state = { ...state, phase: "waiting_for_approval" }; + h.current.otherStates = [{ ...other, phase: "waiting_for_input" }]; + h.current[restriction] = [other.environmentId]; + return Effect.gen(function* () { + const delivery = yield* FcmDeliveries; + yield* delivery.process({ ...h.job, state: h.current.state }); + expect(h.sent[0]?.data).toMatchObject({ + alert_title: "Fix notifications", + alert_body: "Approval: Project", + }); + }).pipe(Effect.provide(h.layer)); + }); + } + + it("gives a group a stable retry identity independent of row order", () => { + const other = { + ...secondState, + environmentId: EnvironmentId.make("other-env"), + threadId: state.threadId, + }; + const input = { + previousAggregate: aggregateFor([state, other]), + nextAggregate: aggregateFor([ + { ...state, phase: "completed" }, + { ...other, phase: "failed" }, + ]), + preferences, + nowMs: 0, + }; + const alert = androidAlertForAggregate(input); + expect(alert?.alert_title).toBe("2 agents finished"); + expect( + androidAlertForAggregate({ + ...input, + nextAggregate: { + ...input.nextAggregate, + activities: input.nextAggregate.activities.toReversed(), + }, + })?.alert_id, + ).toBe(alert?.alert_id); + expect( + androidAlertForAggregate({ + ...input, + nextAggregate: { + ...input.nextAggregate, + activities: input.nextAggregate.activities.map((row) => ({ + ...row, + updatedAt: "1970-01-01T00:00:01.000Z", + })), + }, + })?.alert_id, + ).not.toBe(alert?.alert_id); + }); + + it("distinguishes matching thread IDs in different environments", () => { + const other = { + ...secondState, + environmentId: EnvironmentId.make("other-env"), + threadId: state.threadId, + }; + const alreadyWaiting = { ...state, phase: "waiting_for_approval" as const }; + expect( + androidAlertForAggregate({ + previousAggregate: aggregateFor([alreadyWaiting, other]), + nextAggregate: aggregateFor([alreadyWaiting, { ...other, phase: "waiting_for_input" }]), + preferences, + nowMs: 0, + }), + ).toMatchObject({ alert_title: "Second thread", alert_body: "Input: Project" }); + }); + + for (const [phase, body, preference] of [ + ["waiting_for_approval", "Approval: Project", "notifyOnApproval"], + ["waiting_for_input", "Input: Project", "notifyOnInput"], + ["completed", "Done: Project", "notifyOnCompletion"], + ["failed", "Failed: Project", "notifyOnFailure"], + ] as const) { + it.effect(`uses iOS alert wording for ${phase} and honors its preference`, () => { + const h = harness(); + h.current.state = { ...state, phase }; + return Effect.gen(function* () { + const delivery = yield* FcmDeliveries; + yield* delivery.process({ ...h.job, state: h.current.state }); + expect(h.sent[0]?.data).toMatchObject({ + alert_title: "Fix notifications", + alert_body: body, + alert_path: "/threads/env/thread", + }); + h.current.target.preferences_json = encodeJson({ + ...preferences, + [preference]: false, + }); + yield* delivery.process({ ...h.job, state: h.current.state }); + expect(h.sent.slice(1).every((sent) => !sent.alert && !sent.data.alert_id)).toBe(true); + }).pipe(Effect.provide(h.layer)); + }); + } + + it("trims and truncates alert text like iOS", () => { + expect( + androidAlertForState( + { + ...state, + phase: "completed", + threadTitle: ` ${"T".repeat(150)} `, + projectTitle: ` ${"P".repeat(150)} `, + }, + preferences, + 0, + ), + ).toMatchObject({ + alert_title: `${"T".repeat(117)}...`, + alert_body: `Done: ${"P".repeat(111)}...`, + }); + }); + + it.effect( + "queues Android devices and sends the latest aggregate instead of a stale running state", + () => { + const h = harness(); + return Effect.gen(function* () { + const delivery = yield* FcmDeliveries; + yield* delivery.enqueue({ target, state }); + h.current.state = { ...state, phase: "completed" }; + yield* delivery.process(h.queued[0]); + expect(h.sent).toHaveLength(0); + yield* delivery.process({ ...h.job, state: h.current.state }); + expect(h.sent[0]?.data).toMatchObject({ + active: "false", + alert_title: "Fix notifications", + alert_body: "Done: Project", + alert_path: "/threads/env/thread", + }); + }).pipe(Effect.provide(h.layer)); + }, + ); + + it.effect("keeps completion alerts working with ongoing activity disabled", () => { + const h = harness(); + h.current.state = { ...state, phase: "completed" }; + h.current.target.preferences_json = encodeJson({ + ...preferences, + liveActivitiesEnabled: false, + }); + return Effect.gen(function* () { + const delivery = yield* FcmDeliveries; + yield* delivery.process({ ...h.job, state: h.current.state }); + expect(h.sent[0]?.data).toMatchObject({ + active: "false", + alert_title: "Fix notifications", + alert_body: "Done: Project", + }); + expect(h.sent[0]?.data.user_id).toBe("user"); + }).pipe(Effect.provide(h.layer)); + }); + + it.effect("drops jobs for rotated tokens, expired jobs, and revoked links", () => { + const h = harness(); + return Effect.gen(function* () { + const delivery = yield* FcmDeliveries; + yield* delivery.process({ ...h.job, token: "old-token" }); + yield* delivery.process({ ...h.job, queuedAt: -400_000 }); + h.current.linked = false; + yield* delivery.process(h.job); + expect(h.sent).toHaveLength(0); + }).pipe(Effect.provide(h.layer)); + }); + + it.effect("ends an existing ongoing notification when the environment stops publishing", () => { + const h = harness(); + h.current.target.last_aggregate_json = encodeJson(aggregateFor([state])); + h.current.state = null; + return Effect.gen(function* () { + const delivery = yield* FcmDeliveries; + yield* delivery.process({ ...h.job, state: null }); + expect(h.sent[0]?.data).toMatchObject({ active: "false" }); + expect(h.sent[0]?.alert).toBe(false); + expect(h.marked[0]?.kind).toBe("live_activity_end"); + }).pipe(Effect.provide(h.layer)); + }); + + it.effect("leaves iOS devices on their existing delivery path", () => { + const h = harness(); + return Effect.gen(function* () { + const delivery = yield* FcmDeliveries; + expect(yield* delivery.enqueue({ target: { ...target, platform: "ios" }, state })).toBeNull(); + expect(h.queued).toHaveLength(0); + }).pipe(Effect.provide(h.layer)); + }); + + it.effect("honors disabled alert preferences while continuing ongoing activity", () => { + const h = harness(); + h.current.state = { ...state, phase: "waiting_for_approval" }; + h.current.target.preferences_json = encodeJson({ ...preferences, notifyOnApproval: false }); + return Effect.gen(function* () { + const delivery = yield* FcmDeliveries; + yield* delivery.process({ ...h.job, state: h.current.state }); + expect(h.sent[0]?.data.active).toBe("true"); + expect(h.sent[0]?.data.alert_id).toBeUndefined(); + h.current.target.preferences_json = encodeJson({ + ...preferences, + notificationsEnabled: false, + }); + h.current.target.last_aggregate_json = encodeJson(aggregateFor([state])); + yield* delivery.process({ ...h.job, state: h.current.state }); + expect(h.sent[1]?.data.active).toBe("false"); + expect(h.sent[1]?.data.alert_id).toBeUndefined(); + }).pipe(Effect.provide(h.layer)); + }); + for (const ongoing of [true, false]) { + for (const phase of ["completed", "failed"] as const) { + it.effect(`does not alert a stale ${phase} without a baseline (ongoing=${ongoing})`, () => { + const h = harness(); + h.current.state = { ...state, phase, updatedAt: "1969-12-31T23:57:00.000Z" }; + h.current.target.preferences_json = encodeJson({ + ...preferences, + liveActivitiesEnabled: ongoing, + }); + return Effect.gen(function* () { + const delivery = yield* FcmDeliveries; + yield* delivery.process({ ...h.job, state: h.current.state }); + expect(h.sent.every((message) => !message.alert)).toBe(true); + }).pipe(Effect.provide(h.layer)); + }); + } + } + + it.effect( + "retains finished results without extending expiry on replay and clears expired cards", + () => { + const h = harness(); + h.current.state = { ...state, phase: "failed" }; + return Effect.gen(function* () { + const delivery = yield* FcmDeliveries; + yield* delivery.process({ ...h.job, state: h.current.state }); + expect(h.sent[0]?.data).toMatchObject({ + active: "false", + activity_title: "Agent work failed", + activity_expires_at: "900000", + }); + yield* TestClock.adjust("5 minutes"); + yield* delivery.process({ ...h.job, queuedAt: 300000, state: null, replay: true }); + expect(h.sent[1]?.data.activity_expires_at).toBe("900000"); + expect(h.sent[1]?.alert).toBe(false); + yield* TestClock.adjust("11 minutes"); + yield* delivery.process({ ...h.job, queuedAt: 960000, state: null, replay: true }); + expect(h.sent[2]?.data).toMatchObject({ active: "false", activity_expires_at: "0" }); + expect(h.marked.at(-1)?.aggregate).toBeNull(); + }).pipe(Effect.provide(h.layer)); + }, + ); + + it.effect("replays an empty card to repair an orphan without a delivery baseline", () => { + const h = harness(); + h.current.state = null; + return Effect.gen(function* () { + const delivery = yield* FcmDeliveries; + yield* delivery.process({ ...h.job, state: null }); + expect(h.sent[0]?.data.activity_expires_at).toBe("0"); + expect(h.sent[0]?.alert).toBe(false); + }).pipe(Effect.provide(h.layer)); + }); + + it("shows five rows with attention then failure first, including project and status", () => { + const aggregate = aggregateFor([ + state, + { ...secondState, phase: "failed" }, + { ...state, threadId: ThreadId.make("approval"), phase: "waiting_for_approval" }, + { ...state, threadId: ThreadId.make("input"), phase: "waiting_for_input" }, + { ...state, threadId: ThreadId.make("done"), phase: "completed" }, + ]); + const data = androidActivityData(aggregate); + expect(data.activity_title).toBe("3 active agents · 2 need attention"); + expect( + Object.entries(data) + .filter(([key]) => key.startsWith("activity_line_")) + .map(([, value]) => value), + ).toEqual([ + "Approval\tFix notifications\tProject", + "Input\tFix notifications\tProject", + "Failed\tSecond thread\tProject", + "Working\tFix notifications\tProject", + "Done\tFix notifications\tProject", + ]); + expect(data.activity_expires_at).toBe(String(24 * 60 * 60 * 1000)); + expect(androidActivityData(aggregateFor([state])).activity_expires_at).toBe( + String(2 * 60 * 60 * 1000), + ); + }); + + it("fits five Unicode rows and a grouped alert in the FCM budget without corrupting text or routes", () => { + const longTitle = '🤖漢字"\\'.repeat(30); + const aggregate = aggregateFor( + Array.from({ length: 5 }, (_, i) => ({ + ...state, + threadId: ThreadId.make(`thread-${i}`), + threadTitle: longTitle, + projectTitle: longTitle, + })), + ); + const data = fitFcmData({ + ...androidActivityData(aggregate), + t3_kind: "agent_activity", + device_id: "d".repeat(128), + user_id: "u".repeat(128), + updated_at: "1788780000000", + alert_id: "a".repeat(64), + alert_title: "5 agents finished", + alert_body: Array(5).fill(longTitle).join(", "), + alert_path: "/threads/env/thread", + }); + expect(new TextEncoder().encode(JSON.stringify(data)).length).toBeLessThanOrEqual(3800); + expect(data.alert_path).toBe("/threads/env/thread"); + expect(data.activity_path).toBe("/threads/env/thread"); + expect(data.alert_id).toBe("a".repeat(64)); + expect(data.activity_line_4).toContain("Working\t"); + for (const value of Object.values(data)) + expect(new TextDecoder().decode(new TextEncoder().encode(value))).toBe(value); + }); +}); + +describe("delivery policy regressions", () => { + it.effect("alerts a quick completion even with a previous unrelated card", () => { + const h = harness(); + const old = { ...state, threadId: ThreadId.make("old"), phase: "completed" as const }; + h.current.target.last_aggregate_json = encodeJson(aggregateFor([old])); + h.current.otherStates = [old]; + h.current.state = { ...state, phase: "completed" }; + return Effect.gen(function* () { + const d = yield* FcmDeliveries; + yield* d.process(h.job); + yield* d.process({ ...h.job, state: h.current.state }); + expect(h.sent.filter((x) => x.alert)).toHaveLength(1); + }).pipe(Effect.provide(h.layer)); + }); + it.effect("a muted environment job cannot consume another environment's attention alert", () => { + const h = harness(); + const other = { + ...state, + environmentId: EnvironmentId.make("other"), + threadId: ThreadId.make("other"), + }; + h.current.target.last_aggregate_json = encodeJson(aggregateFor([state, other])); + h.current.otherStates = [{ ...other, phase: "waiting_for_input" }]; + h.current.mutedEnvironments = [state.environmentId]; + return Effect.gen(function* () { + const d = yield* FcmDeliveries; + yield* d.process(h.job); + yield* d.process({ ...h.job, state: h.current.otherStates[0]! }); + expect(h.sent.filter((x) => x.alert)).toHaveLength(1); + }).pipe(Effect.provide(h.layer)); + }); + it("keeps an older waiting thread visible and alertable beyond five running rows", () => { + const running = Array.from({ length: 5 }, (_, i) => ({ + ...state, + threadId: ThreadId.make(`running-${i}`), + })); + const waiting = { + ...state, + phase: "waiting_for_approval" as const, + updatedAt: "1969-12-31T23:59:00.000Z", + }; + const next = aggregateFor([...running, waiting]); + expect( + androidAlertForAggregate({ + previousAggregate: aggregateFor(running), + nextAggregate: next, + preferences, + nowMs: 0, + }), + ).not.toBeNull(); + }); +}); + +describe("notification-only environments", () => { + it.effect("alerts independently while another environment has a live card", () => { + const h = harness(); + const other = { + ...state, + environmentId: EnvironmentId.make("other"), + threadId: ThreadId.make("other"), + }; + h.current.target.last_aggregate_json = encodeJson(aggregateFor([other])); + h.current.otherStates = [other]; + h.current.state = { ...state, phase: "waiting_for_input" }; + h.current.notificationOnlyEnvironments = [state.environmentId]; + return Effect.gen(function* () { + const deliveries = yield* FcmDeliveries; + yield* deliveries.process({ ...h.job, state: h.current.state }); + expect(h.sent).toHaveLength(1); + expect(h.sent[0]?.alert).toBe(true); + expect(h.sent[0]?.data.alert_path).toBe(state.deepLink); + }).pipe(Effect.provide(h.layer)); + }); +}); + +it("continues shrinking text when a longer activity line is already minimal", () => { + const data = fitFcmData({ + activity_line_0: "Approval\t😀😀😀😀\t😀😀😀😀", + alert_body: "x".repeat(30), + device_id: "x".repeat(3680), + }); + expect(new TextEncoder().encode(encodeJson(data)).length).toBeLessThanOrEqual(3800); + expect(data.activity_line_0).toBe("Approval\t😀😀😀😀\t😀😀😀😀"); +}); + +it.effect("notification-only jobs do not consume another environment's card alert", () => { + const h = harness(); + const other = { + ...state, + environmentId: EnvironmentId.make("other"), + threadId: ThreadId.make("other"), + }; + h.current.target.last_aggregate_json = encodeJson(aggregateFor([other])); + h.current.otherStates = [{ ...other, phase: "waiting_for_approval" }]; + h.current.state = { ...state, phase: "waiting_for_input" }; + h.current.notificationOnlyEnvironments = [state.environmentId]; + return Effect.gen(function* () { + const deliveries = yield* FcmDeliveries; + yield* deliveries.process({ ...h.job, state: h.current.state }); + yield* deliveries.process({ ...h.job, state: h.current.otherStates[0]! }); + expect(h.sent.filter((delivery) => delivery.alert)).toHaveLength(2); + }).pipe(Effect.provide(h.layer)); +}); + +it.effect("preserves a structured Firebase failure through the queue consumer", () => { + const h = harness(); + const failure = new FcmClientError({ operation: "send", status: 503 }); + h.current.deliveryFailure = failure; + return Effect.gen(function* () { + const deliveries = yield* FcmDeliveries; + expect(yield* deliveries.process(h.job).pipe(Effect.flip)).toBe(failure); + }).pipe(Effect.provide(h.layer)); +}); + +it("stops reducing five-character row fields and fits the remaining alert", () => { + const data = fitFcmData({ + device_id: "x".repeat(3710), + activity_line_0: "Approval\taaaaa\tbbbbb", + alert_body: "y".repeat(200), + }); + expect(data.activity_line_0).toBe("Approval\taaaaa\tbbbbb"); + expect(new TextEncoder().encode(encodeJson(data)).length).toBeLessThanOrEqual(3800); +}); + +describe("FCM queue message isolation", () => { + for (const failure of ["invalid-job", "fcm-rejection"] as const) { + it.effect(`retries only the ${failure} message and delivers the rest of its batch`, () => { + const h = harness(); + const outcomes = new Map(); + const message = (id: string, body: unknown): Cloudflare.Queues.Message => ({ + id, + body, + timestamp: DateTime.toDateUtc(DateTime.makeUnsafe(0)), + attempts: 1, + ack: () => { + if (!outcomes.has(id)) outcomes.set(id, "ack"); + }, + retry: () => { + if (!outcomes.has(id)) outcomes.set(id, "retry"); + }, + }); + const batch = [ + message("failed", failure === "invalid-job" ? {} : h.job), + message("healthy", h.job), + ]; + return Effect.gen(function* () { + yield* Stream.fromIterable(batch).pipe( + Stream.tap((item) => + Effect.sync(() => { + h.current.deliveryFailure = + item.id === "failed" && failure === "fcm-rejection" + ? new FcmClientError({ operation: "send", status: 400 }) + : null; + }), + ), + Stream.runForEach(FcmDeliveryQueueConsumer.processMessage), + ); + // Alchemy acknowledges the batch after a successful stream. Cloudflare + // ignores those acknowledgements for messages explicitly retried earlier. + for (const item of batch) item.ack(); + expect([...outcomes]).toEqual([ + ["failed", "retry"], + ["healthy", "ack"], + ]); + expect(h.sent).toHaveLength(1); + expect(h.marked).toHaveLength(1); + }).pipe(Effect.provide(h.layer)); + }); + } +}); diff --git a/infra/relay/src/agentActivity/FcmDeliveries.ts b/infra/relay/src/agentActivity/FcmDeliveries.ts new file mode 100644 index 0000000000..8b8545d7e2 --- /dev/null +++ b/infra/relay/src/agentActivity/FcmDeliveries.ts @@ -0,0 +1,348 @@ +import { and, eq } from "drizzle-orm"; +import { + RelayAgentActivityState, + RelayAgentActivityAggregateState, + RelayAgentAwarenessPreferences, + type RelayDeliveryResult, +} from "@t3tools/contracts/relay"; +import * as Crypto from "effect/Crypto"; +import type * as PlatformError from "effect/PlatformError"; +import * as Context from "effect/Context"; +import * as DateTime from "effect/DateTime"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as Schema from "effect/Schema"; + +import * as RelayConfiguration from "../Config.ts"; +import * as RelayDb from "../db.ts"; +import { relayMobileDevices } from "../persistence/schema.ts"; +import * as EnvironmentLinks from "../environments/EnvironmentLinks.ts"; +import * as AgentActivityRows from "./AgentActivityRows.ts"; +import * as LiveActivities from "./LiveActivities.ts"; +import * as FcmDeliveryQueueSender from "./FcmDeliveryQueueSender.ts"; +import * as FcmClient from "./FcmClient.ts"; +import { androidActivityData, androidActivityHero, fitFcmData } from "./fcmPayloads.ts"; +import { makeAggregateState, statusForPhase } from "./agentActivityAggregate.ts"; +import { isExpiredAgentActivityState, notificationForActivity } from "./agentActivityPayloads.ts"; +import { + alertForActivityRows, + attentionTransitionRows, + terminalTransitionRows, + shouldAlertForActivity, +} from "./agentActivityAlerts.ts"; + +export const FcmDeliveryJob = Schema.Struct({ + userId: Schema.String, + deviceId: Schema.String, + token: Schema.String, + state: Schema.NullOr(RelayAgentActivityState), + queuedAt: Schema.Number, + replay: Schema.optional(Schema.Boolean), +}); +export type FcmDeliveryJob = typeof FcmDeliveryJob.Type; +const decodeJob = Schema.decodeUnknownEffect(FcmDeliveryJob); +const decodePreferences = Schema.decodeUnknownOption( + Schema.fromJsonString(RelayAgentAwarenessPreferences), +); +const decodePreviousActivity = Schema.decodeUnknownOption( + Schema.fromJsonString(RelayAgentActivityAggregateState), +); + +export class FcmDeliveryError extends Schema.TaggedErrorClass()( + "FcmDeliveryError", + { + operation: Schema.Literals(["enqueue", "decode-job", "invalidate-token"]), + cause: Schema.Defect(), + }, +) { + override get message() { + return `Failed to ${this.operation} Android notification delivery.`; + } +} + +export function androidAlertForState( + state: RelayAgentActivityState, + preferences: RelayAgentAwarenessPreferences, + nowMs: number, +) { + if (!shouldAlertForActivity({ ...state, preferences, nowMs })) return null; + const notification = notificationForActivity({ ...state, status: statusForPhase(state.phase) }); + return { + alert_id: JSON.stringify([state.environmentId, state.threadId, state.phase, state.updatedAt]), + alert_title: notification.title, + alert_body: notification.body, + alert_path: notification.deepLink, + }; +} + +export function androidAlertForAggregate(input: { + readonly previousAggregate: RelayAgentActivityAggregateState; + readonly nextAggregate: RelayAgentActivityAggregateState; + readonly preferences: RelayAgentAwarenessPreferences; + readonly nowMs: number; +}) { + if (!input.preferences.notificationsEnabled) return null; + const attention = attentionTransitionRows(input); + const activities = + attention.length > 0 + ? attention + : terminalTransitionRows({ ...input, includeUnobserved: true }); + const first = activities[0]; + const alert = alertForActivityRows(activities); + if (!first || !alert) return null; + if (activities.length === 1) { + const notification = notificationForActivity(first); + return { + alert_id: JSON.stringify([first.environmentId, first.threadId, first.phase, first.updatedAt]), + alert_title: notification.title, + alert_body: notification.body, + alert_path: notification.deepLink, + }; + } + return { + // Every contributing queue job identifies the same group, including after + // retries or a different database row order. The native handler deduplicates it. + alert_id: JSON.stringify( + activities + .map((row) => [row.environmentId, row.threadId, row.phase, row.updatedAt]) + .sort((a, b) => JSON.stringify(a).localeCompare(JSON.stringify(b))), + ), + alert_title: alert.title, + alert_body: alert.body, + alert_path: androidActivityHero(input.nextAggregate)?.deepLink ?? "/", + }; +} + +export class FcmDeliveries extends Context.Service< + FcmDeliveries, + { + readonly enqueue: (input: { + readonly target: LiveActivities.TargetRow; + readonly state: RelayAgentActivityState | null; + readonly replay?: boolean; + }) => Effect.Effect; + readonly process: ( + body: unknown, + ) => Effect.Effect< + void, + | FcmDeliveryError + | FcmClient.FcmClientError + | PlatformError.PlatformError + | LiveActivities.LiveActivityTargetListPersistenceError + | LiveActivities.LiveActivityDeliveryMarkPersistenceError + | AgentActivityRows.AgentActivityRowListPersistenceError + | EnvironmentLinks.EnvironmentLinkLookupPersistenceError + | EnvironmentLinks.EnvironmentLinkUserListPersistenceError + >; + } +>()("t3code-relay/agentActivity/FcmDeliveries") {} + +export const make = Effect.gen(function* () { + const config = yield* RelayConfiguration.RelayConfiguration; + const crypto = yield* Crypto.Crypto; + const sender = yield* FcmDeliveryQueueSender.FcmDeliveryQueueSender; + const client = yield* FcmClient.FcmClient; + const devices = yield* LiveActivities.LiveActivities; + const rows = yield* AgentActivityRows.AgentActivityRows; + const links = yield* EnvironmentLinks.EnvironmentLinks; + const db = yield* RelayDb.RelayDb; + + return FcmDeliveries.of({ + enqueue: Effect.fn("relay.fcm.enqueue")(function* (input) { + if (input.target.platform !== "android" || !input.target.push_token) return null; + if (!config.fcmServiceAccount) { + yield* Effect.logWarning("Android notifications are not configured for this relay"); + return { + deviceId: input.target.device_id, + kind: "push_notification", + ok: false, + apnsStatus: null, + apnsReason: null, + apnsId: null, + }; + } + const now = yield* DateTime.now; + yield* sender + .send({ + userId: input.target.user_id, + deviceId: input.target.device_id, + token: input.target.push_token, + state: input.state, + ...(input.replay ? { replay: true } : {}), + queuedAt: now.epochMilliseconds, + }) + .pipe(Effect.mapError((cause) => new FcmDeliveryError({ operation: "enqueue", cause }))); + return { + deviceId: input.target.device_id, + kind: "push_notification", + ok: true, + queued: true, + apnsStatus: null, + apnsReason: null, + apnsId: null, + }; + }), + process: Effect.fn("relay.fcm.process")(function* (body) { + const job = yield* decodeJob(body).pipe( + Effect.mapError((cause) => new FcmDeliveryError({ operation: "decode-job", cause })), + ); + const now = yield* DateTime.now; + if (now.epochMilliseconds - job.queuedAt > 5 * 60_000) return; + const targets = yield* devices.listTargets({ userId: job.userId }); + const target = targets.find( + (device) => + device.device_id === job.deviceId && + device.platform === "android" && + device.push_token === job.token, + ); + if (!target) return; + const preferences = decodePreferences(target.preferences_json); + if (Option.isNone(preferences)) return; + + // Re-read links and state when consuming: queued messages must honor + // sign-out, token rotation, disabled publishing, and newer thread states. + const states = preferences.value.liveActivitiesEnabled + ? yield* rows.listForUser({ userId: job.userId }) + : []; + const aggregate = makeAggregateState({ + activeStates: states, + terminalState: null, + nowMs: now.epochMilliseconds, + }); + const previousAggregate = target.last_aggregate_json + ? Option.getOrNull(decodePreviousActivity(target.last_aggregate_json)) + : null; + let alert: ReturnType = null; + // Deletion jobs can observe another thread's newly completed state. They + // update the card, but must leave that transition for its own alert job. + // Registration replay deliberately establishes a silent baseline. + let acknowledgeAggregate = job.state !== null || job.replay === true || aggregate === null; + if (job.state && preferences.value.notificationsEnabled) { + const state = yield* rows.getForUserThread({ + userId: job.userId, + environmentId: job.state.environmentId, + threadId: job.state.threadId, + }); + if ( + !previousAggregate && + (!state || state.phase !== job.state.phase || state.updatedAt !== job.state.updatedAt) + ) + return; + const link = yield* links.getForUser({ + userId: job.userId, + environmentId: job.state.environmentId, + }); + const deliveryUsers = link + ? yield* links.listDeliveryUsersForEnvironment({ + environmentId: job.state.environmentId, + environmentPublicKey: link.environmentPublicKey, + }) + : []; + const deliveryUser = deliveryUsers.find((user) => user.userId === job.userId); + // A notification-only job must not acknowledge transitions on another + // environment's live card before that environment's own job can alert. + acknowledgeAggregate = + deliveryUser?.liveActivitiesEnabled === true || !preferences.value.liveActivitiesEnabled; + if ( + deliveryUser?.liveActivitiesEnabled && + preferences.value.liveActivitiesEnabled && + previousAggregate && + aggregate + ) { + const environmentIds = [...new Set(aggregate.activities.map((row) => row.environmentId))]; + const allowedEnvironments = new Set(); + for (const environmentId of environmentIds) { + const environmentLink = yield* links.getForUser({ + userId: job.userId, + environmentId, + }); + if (!environmentLink) continue; + const users = yield* links.listDeliveryUsersForEnvironment({ + environmentId, + environmentPublicKey: environmentLink.environmentPublicKey, + }); + if (users.some((user) => user.userId === job.userId && user.notificationsEnabled)) { + allowedEnvironments.add(environmentId); + } + } + alert = androidAlertForAggregate({ + previousAggregate, + nextAggregate: { + ...aggregate, + activities: aggregate.activities.filter((row) => + allowedEnvironments.has(row.environmentId), + ), + }, + preferences: preferences.value, + nowMs: now.epochMilliseconds, + }); + } else if ( + deliveryUser?.notificationsEnabled && + state?.phase === job.state.phase && + state.updatedAt === job.state.updatedAt && + !isExpiredAgentActivityState(state, now.epochMilliseconds) + ) { + alert = androidAlertForState(state, preferences.value, now.epochMilliseconds); + } + } + const displayedAggregate = + preferences.value.notificationsEnabled && preferences.value.liveActivitiesEnabled + ? aggregate + : null; + const active = (displayedAggregate?.activeCount ?? 0) > 0; + // A registration replay must clear an orphan even when the relay has + // already forgotten its baseline. Finished cards are visible, but idle. + if (!displayedAggregate && !alert && !previousAggregate && job.state !== null) return; + const data = { + t3_kind: "agent_activity", + device_id: job.deviceId, + user_id: job.userId, + updated_at: String(now.epochMilliseconds), + ...androidActivityData(displayedAggregate), + ...alert, + }; + if (alert) { + // Group identities can contain five sets of IDs. Hash the full, + // stable identity rather than spending the payload budget on it. + const digest = yield* crypto.digest("SHA-256", new TextEncoder().encode(alert.alert_id)); + data.alert_id = Array.from(digest, (byte) => byte.toString(16).padStart(2, "0")).join(""); + } + const result = yield* client.send({ + token: job.token, + packageName: target.bundle_id, + alert: alert !== null, + data: fitFcmData(data), + }); + if (result.unregistered) { + yield* db + .update(relayMobileDevices) + .set({ pushToken: null }) + .where( + and( + eq(relayMobileDevices.userId, job.userId), + eq(relayMobileDevices.deviceId, job.deviceId), + eq(relayMobileDevices.pushToken, job.token), + ), + ) + .pipe( + Effect.mapError( + (cause) => new FcmDeliveryError({ operation: "invalidate-token", cause }), + ), + ); + } else if (acknowledgeAggregate) { + yield* devices.markDelivery({ + userId: job.userId, + deviceId: job.deviceId, + kind: active ? "live_activity_update" : "live_activity_end", + // Keep the delivered terminal rows as the next transition baseline, + // so replaying the finished card cannot alert again. + aggregate: preferences.value.liveActivitiesEnabled ? aggregate : null, + deliveredAt: DateTime.formatIso(now), + }); + } + }), + }); +}); + +export const layer = Layer.effect(FcmDeliveries, make); diff --git a/infra/relay/src/agentActivity/FcmDeliveryQueueConsumer.ts b/infra/relay/src/agentActivity/FcmDeliveryQueueConsumer.ts new file mode 100644 index 0000000000..dd97edcd6a --- /dev/null +++ b/infra/relay/src/agentActivity/FcmDeliveryQueueConsumer.ts @@ -0,0 +1,25 @@ +import type * as Cloudflare from "alchemy/Cloudflare"; +import * as Effect from "effect/Effect"; + +import * as FcmDeliveries from "./FcmDeliveries.ts"; + +export const processMessage = Effect.fn("relay.fcm_delivery_queue.process_message")(function* ( + message: Cloudflare.Queues.Message, +) { + const deliveries = yield* FcmDeliveries.FcmDeliveries; + yield* deliveries.process(message.body).pipe( + Effect.matchEffect({ + onFailure: (error) => + Effect.gen(function* () { + // Decide this message's outcome before Alchemy acknowledges the batch. + // Cloudflare keeps the first ack/retry decision for each message. + message.retry(); + yield* Effect.logWarning("FCM queue delivery failed; retrying message", { + messageId: message.id, + errorTag: error._tag, + }); + }), + onSuccess: () => Effect.sync(() => message.ack()), + }), + ); +}); diff --git a/infra/relay/src/agentActivity/FcmDeliveryQueueSender.ts b/infra/relay/src/agentActivity/FcmDeliveryQueueSender.ts new file mode 100644 index 0000000000..3d9a731082 --- /dev/null +++ b/infra/relay/src/agentActivity/FcmDeliveryQueueSender.ts @@ -0,0 +1,14 @@ +import type * as Cloudflare from "alchemy/Cloudflare"; +import * as Context from "effect/Context"; +import type * as Effect from "effect/Effect"; + +import type * as FcmDeliveries from "./FcmDeliveries.ts"; + +export class FcmDeliveryQueueSender extends Context.Service< + FcmDeliveryQueueSender, + { + readonly send: ( + body: FcmDeliveries.FcmDeliveryJob, + ) => Effect.Effect; + } +>()("t3code-relay/agentActivity/FcmDeliveryQueueSender") {} diff --git a/infra/relay/src/agentActivity/LiveActivities.ts b/infra/relay/src/agentActivity/LiveActivities.ts index a2179e1e76..09ad4d7788 100644 --- a/infra/relay/src/agentActivity/LiveActivities.ts +++ b/infra/relay/src/agentActivity/LiveActivities.ts @@ -66,8 +66,8 @@ export class LiveActivityDeliveryMarkPersistenceError extends Schema.TaggedError export interface DeviceRow { readonly user_id: string; readonly device_id: string; - readonly platform: "ios"; - readonly ios_major_version: number; + readonly platform: "ios" | "android"; + readonly ios_major_version: number | null; readonly app_version: string | null; readonly bundle_id: string | null; readonly aps_environment: "sandbox" | "production" | null; diff --git a/infra/relay/src/agentActivity/MobileRegistrations.test.ts b/infra/relay/src/agentActivity/MobileRegistrations.test.ts index ca39484373..74d5c3740a 100644 --- a/infra/relay/src/agentActivity/MobileRegistrations.test.ts +++ b/infra/relay/src/agentActivity/MobileRegistrations.test.ts @@ -18,6 +18,16 @@ import * as EnvironmentLinks from "../environments/EnvironmentLinks.ts"; import * as LiveActivities from "./LiveActivities.ts"; import * as RelayConfiguration from "../Config.ts"; import * as AgentActivityPublisher from "./AgentActivityPublisher.ts"; +import { FcmDeliveries } from "./FcmDeliveries.ts"; + +const publisherLayer = AgentActivityPublisher.layer.pipe( + Layer.provide( + Layer.succeed(FcmDeliveries, { + enqueue: () => Effect.succeed(null), + process: () => Effect.void, + }), + ), +); import * as ApnsDeliveries from "./ApnsDeliveries.ts"; import * as ApnsClient from "./ApnsClient.ts"; import * as ApnsProviderTokens from "./ApnsProviderTokens.ts"; @@ -149,7 +159,7 @@ function makeRegistrationReplayLayer(input: { readonly queuedJobs: Array; }) { return MobileRegistrations.layer.pipe( - Layer.provide(AgentActivityPublisher.layer), + Layer.provide(publisherLayer), Layer.provide( ApnsDeliveries.layer.pipe( Layer.provide(ApnsClient.layer.pipe(Layer.provide(ApnsProviderTokens.layer))), diff --git a/infra/relay/src/agentActivity/fcmPayloads.ts b/infra/relay/src/agentActivity/fcmPayloads.ts new file mode 100644 index 0000000000..798714d466 --- /dev/null +++ b/infra/relay/src/agentActivity/fcmPayloads.ts @@ -0,0 +1,86 @@ +import type { RelayAgentActivityAggregateState } from "@t3tools/contracts/relay"; +import { + activityPhasePriority, + TERMINAL_AGENT_ACTIVITY_DISPLAY_TTL_MS, +} from "./agentActivityAggregate.ts"; +import { agentActivityExpiresAt } from "./agentActivityPayloads.ts"; + +export function androidActivityHero(aggregate: RelayAgentActivityAggregateState) { + return [...aggregate.activities].sort( + (a, b) => activityPhasePriority(a.phase) - activityPhasePriority(b.phase), + )[0]; +} + +/** The expanded Android card uses the same rows and priority as the iOS widget. */ +export function androidActivityData(aggregate: RelayAgentActivityAggregateState | null) { + const rows = [...(aggregate?.activities ?? [])].sort( + (a, b) => activityPhasePriority(a.phase) - activityPhasePriority(b.phase), + ); + const activeCount = aggregate?.activeCount ?? 0; + const attentionCount = rows.filter((row) => activityPhasePriority(row.phase) === 0).length; + const failed = rows.some((row) => row.phase === "failed"); + const clean = (value: string) => value.replace(/\s+/g, " ").trim(); + const lines = rows.map((row) => + [row.status, clean(row.threadTitle), clean(row.projectTitle)].join("\t"), + ); + const hero = rows[0]; + const title = + activeCount > 0 + ? `${activeCount} active agent${activeCount === 1 ? "" : "s"}${attentionCount ? ` · ${attentionCount} need${attentionCount === 1 ? "s" : ""} attention` : ""}` + : failed + ? "Agent work failed" + : "Agent work completed"; + const expiresAt = Math.max( + 0, + ...rows.map((row) => + row.phase === "completed" || row.phase === "failed" + ? Date.parse(row.updatedAt) + TERMINAL_AGENT_ACTIVITY_DISPLAY_TTL_MS + : agentActivityExpiresAt(row), + ), + ); + return { + active: String(activeCount > 0), + activity_title: title, + activity_body: hero + ? `${hero.status}: ${clean(hero.threadTitle)} · ${clean(hero.projectTitle)}` + : "", + // Separate keys avoid double JSON encoding and preserve each expanded row when bounding the payload. + ...Object.fromEntries(lines.map((line, index) => [`activity_line_${index}`, line])), + activity_path: rows[0]?.deepLink ?? "/", + activity_expires_at: String(expiresAt), + }; +} + +/** Keep Unicode, escaping and grouped alerts within FCM's 4 KB data budget. */ +export function fitFcmData(input: Readonly>): Record { + const data = { ...input }; + const encoder = new TextEncoder(); + const textKeys = Object.keys(data).filter( + (key) => key.endsWith("_body") || key.endsWith("_title") || key.startsWith("activity_line_"), + ); + while (encoder.encode(JSON.stringify(data)).length > 3800) { + const key = textKeys.sort( + (a, b) => encoder.encode(data[b]!).length - encoder.encode(data[a]!).length, + )[0]; + if (!key) break; + if (data[key]!.length <= 8) { + textKeys.splice(textKeys.indexOf(key), 1); + continue; + } + const parts = key.startsWith("activity_line_") ? data[key]!.split("\t") : [data[key]!]; + const part = parts.length === 3 ? (parts[1]!.length > parts[2]!.length ? 1 : 2) : 0; + const characters = Array.from(parts[part]!); + // Five characters would become four plus the ellipsis and never shrink. + if (characters.length <= 5) { + textKeys.splice(textKeys.indexOf(key), 1); + continue; + } + parts[part] = + characters + .slice(0, Math.floor(characters.length * 0.8)) + .join("") + .trimEnd() + "…"; + data[key] = parts.join("\t"); + } + return data; +} diff --git a/infra/relay/src/http/Api.test.ts b/infra/relay/src/http/Api.test.ts index 222deafd62..66b87e643a 100644 --- a/infra/relay/src/http/Api.test.ts +++ b/infra/relay/src/http/Api.test.ts @@ -2,6 +2,7 @@ import { createClerkClient, verifyToken } from "@clerk/backend"; import { describe, expect, it } from "@effect/vitest"; import { vi } from "vite-plus/test"; import * as Context from "effect/Context"; +import * as NodeCrypto from "@effect/platform-node/NodeCrypto"; import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; import * as Fiber from "effect/Fiber"; @@ -14,11 +15,21 @@ import * as Tracer from "effect/Tracer"; import * as HttpRouter from "effect/unstable/http/HttpRouter"; import * as HttpServerRequest from "effect/unstable/http/HttpServerRequest"; import * as HttpServerResponse from "effect/unstable/http/HttpServerResponse"; +import * as HttpServer from "effect/unstable/http/HttpServer"; +import * as HttpApiBuilder from "effect/unstable/httpapi/HttpApiBuilder"; +import * as HttpApi from "effect/unstable/httpapi/HttpApi"; import { EnvironmentId } from "@t3tools/contracts"; -import { RelayEnvironmentAuth } from "@t3tools/contracts/relay"; +import { + RelayApi, + RelayClientAuth, + RelayClientPrincipal, + RelayEnvironmentAuth, + type RelayClientDeviceRecord, +} from "@t3tools/contracts/relay"; import { RELAY_REQUEST_DEADLINE_MS, + clientApi, relayCors, relayDocsRedirectRoute, relayEnvironmentAuthLayer, @@ -35,6 +46,9 @@ import * as RelayDb from "../db.ts"; import * as EnvironmentCredentials from "../environments/EnvironmentCredentials.ts"; import * as EnvironmentLinks from "../environments/EnvironmentLinks.ts"; import * as ManagedEndpointProvider from "../environments/ManagedEndpointProvider.ts"; +import * as EnvironmentLinker from "../environments/EnvironmentLinker.ts"; +import * as RelayTokens from "../auth/RelayTokens.ts"; +import * as Devices from "../agentActivity/Devices.ts"; vi.mock("@clerk/backend", () => ({ createClerkClient: vi.fn(), @@ -60,6 +74,99 @@ const relaySettings: RelayConfiguration.RelayConfiguration["Service"] = { managedEndpointNamespace: undefined, }; +describe("device listing compatibility", () => { + it.effect("keeps v1 iOS-only while v2 returns every platform for the same account", () => { + const iphone: RelayClientDeviceRecord = { + deviceId: "iphone", + label: "iPhone", + platform: "ios", + iosMajorVersion: 18, + appVersion: "1.0.0", + notifications: { + enabled: true, + notifyOnApproval: true, + notifyOnInput: true, + notifyOnCompletion: true, + notifyOnFailure: true, + }, + liveActivities: { enabled: true }, + updatedAt: "2026-09-09T00:00:00.000Z", + }; + const android: RelayClientDeviceRecord = { + ...iphone, + deviceId: "android", + label: "Android", + platform: "android", + iosMajorVersion: null, + androidApiLevel: 36, + }; + const handlers = clientApi.pipe( + HttpRouter.provideRequest( + Layer.mergeAll( + Layer.mock(EnvironmentCredentials.EnvironmentCredentials, {}), + Layer.mock(EnvironmentLinks.EnvironmentLinks, {}), + Layer.mock(ManagedEndpointProvider.ManagedEndpointProvider, {}), + Layer.mock(RelayDb.RelayTransactions, {}), + ), + ), + Layer.provide( + Layer.mergeAll( + Layer.succeed(RelayConfiguration.RelayConfiguration, relaySettings), + NodeCrypto.layer, + Layer.mock(RelayTokens.RelayTokens, { resolveDpopAccessTokenScopes: () => null }), + Layer.mock(EnvironmentLinker.EnvironmentLinker, {}), + Layer.mock(EnvironmentLinks.EnvironmentLinks, {}), + Layer.mock(ManagedEndpointProvider.ManagedEndpointProvider, {}), + Layer.mock(Devices.Devices, { + listForUser: ({ userId }) => { + expect(userId).toBe("user-1"); + return Effect.succeed([iphone, android]); + }, + }), + ), + ), + Layer.provideMerge( + Layer.succeed(RelayClientAuth, { + clientBearer: (effect) => + Effect.provideService(effect, RelayClientPrincipal, { + userId: "user-1", + token: "test-token", + }), + }), + ), + ); + return Effect.gen(function* () { + const app = yield* Effect.acquireRelease( + Effect.sync(() => + HttpRouter.toWebHandler( + HttpApiBuilder.layer(HttpApi.make("RelayApi").add(RelayApi.groups.client)).pipe( + Layer.provide(handlers), + Layer.provide(HttpServer.layerServices), + ), + { disableLogger: true }, + ), + ), + (app) => Effect.promise(() => app.dispose()), + ); + for (const [version, devices] of [ + ["v1", [iphone]], + ["v2", [iphone, android]], + ] as const) { + const response = yield* Effect.promise(() => + app.handler( + new Request(`https://relay.example.test/${version}/client/devices`, { + headers: { authorization: "Bearer test-token" }, + }), + ), + ); + expect(response.status).toBe(200); + expect(response.headers.get("cache-control")).toBe("no-store"); + expect(yield* Effect.promise(() => response.json())).toEqual({ devices }); + } + }).pipe(Effect.scoped); + }); +}); + describe("relay client authentication", () => { it.effect("preserves the existing Clerk session JWT path", () => Effect.gen(function* () { diff --git a/infra/relay/src/http/Api.ts b/infra/relay/src/http/Api.ts index 05197666c8..9fb8bdaf3b 100644 --- a/infra/relay/src/http/Api.ts +++ b/infra/relay/src/http/Api.ts @@ -545,6 +545,22 @@ export const clientApi = HttpApiBuilder.group( .handle( "listDevices", Effect.fn("relay.api.client.listDevices")(function* () { + yield* appendRelayCredentialResponseHeaders; + const { userId } = yield* RelayClientPrincipal; + const registered = yield* devices.listForUser({ userId }); + return { + devices: registered.flatMap((device) => + device.platform === "ios" && device.iosMajorVersion !== null + ? [{ ...device, platform: "ios" as const, iosMajorVersion: device.iosMajorVersion }] + : [], + ), + }; + }, mapRelayCommonApiErrors("not_authorized")), + ) + .handle( + "listDevicesV2", + Effect.fn("relay.api.client.listDevicesV2")(function* () { + yield* appendRelayCredentialResponseHeaders; const { userId } = yield* RelayClientPrincipal; return { devices: yield* devices.listForUser({ userId }) }; }, mapRelayCommonApiErrors("not_authorized")), @@ -981,6 +997,12 @@ export const serverApi = HttpApiBuilder.group( reason: "upstream_unavailable", traceId, }), + FcmDeliveryError: (_error, traceId) => + new RelayInternalError({ + code: "internal_error", + reason: "upstream_unavailable", + traceId, + }), }), mapRelayCommonApiErrors("not_authorized"), ), diff --git a/infra/relay/src/persistence/schema.ts b/infra/relay/src/persistence/schema.ts index 61b72f2df8..d9a7074bc0 100644 --- a/infra/relay/src/persistence/schema.ts +++ b/infra/relay/src/persistence/schema.ts @@ -21,8 +21,9 @@ 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 }), bundleId: varchar("bundle_id", { length: 255 }), apsEnvironment: varchar("aps_environment", { length: 16 }).$type<"sandbox" | "production">(), diff --git a/infra/relay/src/queues.ts b/infra/relay/src/queues.ts index bfae5ced4b..0ca0dbd0b4 100644 --- a/infra/relay/src/queues.ts +++ b/infra/relay/src/queues.ts @@ -5,3 +5,8 @@ export const RelayApnsDeliveryDeadLetterQueue = Cloudflare.Queues.Queue( ); export const RelayApnsDeliveryQueue = Cloudflare.Queues.Queue("RelayApnsDeliveryQueue"); + +export const RelayFcmDeliveryQueue = Cloudflare.Queues.Queue("RelayFcmDeliveryQueue"); +export const RelayFcmDeliveryDeadLetterQueue = Cloudflare.Queues.Queue( + "RelayFcmDeliveryDeadLetterQueue", +); diff --git a/infra/relay/src/worker.ts b/infra/relay/src/worker.ts index ad5fa68b6d..978a495ffd 100644 --- a/infra/relay/src/worker.ts +++ b/infra/relay/src/worker.ts @@ -6,6 +6,8 @@ import * as DateTime from "effect/DateTime"; import * as Crypto from "effect/Crypto"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as Redacted from "effect/Redacted"; import * as Stream from "effect/Stream"; import * as Etag from "effect/unstable/http/Etag"; import * as HttpPlatform from "effect/unstable/http/HttpPlatform"; @@ -44,7 +46,18 @@ import * as EnvironmentLinks from "./environments/EnvironmentLinks.ts"; import * as ManagedEndpointAllocations from "./environments/ManagedEndpointAllocations.ts"; import * as LiveActivities from "./agentActivity/LiveActivities.ts"; import * as RelayDb from "./db.ts"; -import { RelayApnsDeliveryDeadLetterQueue, RelayApnsDeliveryQueue } from "./queues.ts"; +import { + RelayApnsDeliveryDeadLetterQueue, + RelayApnsDeliveryQueue, + RelayFcmDeliveryQueue, + RelayFcmDeliveryDeadLetterQueue, +} from "./queues.ts"; +import * as WebCrypto from "./WebCrypto.ts"; +import * as FcmAssertionSigner from "./agentActivity/FcmAssertionSigner.ts"; +import * as FcmClient from "./agentActivity/FcmClient.ts"; +import * as FcmDeliveryQueueSender from "./agentActivity/FcmDeliveryQueueSender.ts"; +import * as FcmDeliveries from "./agentActivity/FcmDeliveries.ts"; +import * as FcmDeliveryQueueConsumer from "./agentActivity/FcmDeliveryQueueConsumer.ts"; import * as RelayConfiguration from "./Config.ts"; import * as AgentActivityPublisher from "./agentActivity/AgentActivityPublisher.ts"; import * as ApnsClient from "./agentActivity/ApnsClient.ts"; @@ -117,6 +130,8 @@ export const ApiLive = Api.make( const { relayPublicOrigin, stage } = yield* RelayDeploymentConfig; const apnsDeliveryQueue = yield* RelayApnsDeliveryQueue; const apnsDeliveryDeadLetterQueue = yield* RelayApnsDeliveryDeadLetterQueue; + const fcmDeliveryQueue = yield* RelayFcmDeliveryQueue; + const fcmDeliveryDeadLetterQueue = yield* RelayFcmDeliveryDeadLetterQueue; const cloudMintKeyPair = yield* CloudMintKeyPair; const relayApiZone = yield* RelayApiZone; const managedEndpointZone = yield* ManagedEndpointZone; @@ -126,16 +141,25 @@ export const ApiLive = Api.make( // // 2. Create bindings // - const environment = yield* Config.schema( - RelayConfiguration.ApnsEnvironment, - "APNS_ENVIRONMENT", + const apnsEnabled = yield* Config.boolean("APNS_ENABLED").pipe(Config.withDefault(true)); + const apnsCredentials = apnsEnabled + ? { + environment: yield* Config.schema(RelayConfiguration.ApnsEnvironment, "APNS_ENVIRONMENT"), + teamId: yield* Config.string("APNS_TEAM_ID"), + keyId: yield* Config.string("APNS_KEY_ID"), + bundleId: yield* Config.string("APNS_BUNDLE_ID"), + privateKey: yield* Config.redacted("APNS_PRIVATE_KEY"), + } + : null; + const fcmServiceAccount = Option.getOrUndefined( + Option.filter( + yield* Config.option(Config.redacted("FCM_SERVICE_ACCOUNT")), + (value) => Redacted.value(value).trim().length > 0, + ), ); - const apnsTeamId = yield* Config.string("APNS_TEAM_ID"); - const apnsKeyId = yield* Config.string("APNS_KEY_ID"); - const apnsBundleId = yield* Config.string("APNS_BUNDLE_ID"); - const apnsPrivateKey = yield* Config.redacted("APNS_PRIVATE_KEY"); const apnsDeliveryJobSigningSecret = yield* randomApnsDeliveryJobSigningSecret; const apnsDeliveryQueueSender = yield* Cloudflare.Queues.WriteQueue(apnsDeliveryQueue); + const fcmDeliveryQueueSender = yield* Cloudflare.Queues.WriteQueue(fcmDeliveryQueue); const axiomDatasetName = yield* observability.traces.name; const axiomIngestToken = yield* observability.workerIngestToken.token; @@ -164,13 +188,8 @@ export const ApiLive = Api.make( const loadSettings = Effect.gen(function* () { return RelayConfiguration.RelayConfiguration.of({ relayIssuer: relayPublicOrigin, - apns: { - environment, - teamId: apnsTeamId, - keyId: apnsKeyId, - bundleId: apnsBundleId, - privateKey: apnsPrivateKey, - }, + ...(fcmServiceAccount ? { fcmServiceAccount } : {}), + apns: apnsCredentials, apnsDeliveryJobSigningSecret: yield* apnsDeliveryJobSigningSecret, clerkSecretKey, clerkPublishableKey, @@ -205,12 +224,31 @@ export const ApiLive = Api.make( ), Layer.provideMerge(DpopProofs.layer), Layer.provideMerge(ApnsDeliveries.layer), + Layer.provideMerge( + FcmDeliveries.layer.pipe( + Layer.provide( + Layer.succeed(FcmDeliveryQueueSender.FcmDeliveryQueueSender, { + send: (body) => + fcmDeliveryQueueSender + .send(body) + .pipe(Effect.provideService(Alchemy.RuntimeContext, alchemyRuntimeContext)), + }), + ), + Layer.provideMerge( + FcmClient.layer.pipe( + Layer.provide(FcmAssertionSigner.layer), + Layer.provide( + Layer.succeed(WebCrypto.WebCrypto, { subtle: globalThis.crypto.subtle }), + ), + ), + ), + ), + ), Layer.provideMerge(ApnsClient.layer.pipe(Layer.provideMerge(ApnsProviderTokens.layer))), Layer.provideMerge( ApnsDeliveryQueue.layerCloudflareQueues(apnsDeliveryQueueSender, alchemyRuntimeContext), ), - Layer.provideMerge(AgentActivityRows.layer), - Layer.provideMerge(Devices.layer), + Layer.provideMerge(Layer.mergeAll(AgentActivityRows.layer, Devices.layer)), Layer.provideMerge(EnvironmentCredentials.layer), Layer.provideMerge( Layer.mergeAll( @@ -260,6 +298,23 @@ export const ApiLive = Api.make( ), ); + yield* Cloudflare.Queues.consumeQueueMessages( + fcmDeliveryQueue, + { + batchSize: 10, + maxRetries: 5, + maxWaitTime: "1 second", + retryDelay: "30 seconds", + deadLetterQueue: fcmDeliveryDeadLetterQueue.queueName as unknown as string, + }, + (stream) => + stream.pipe( + Stream.withSpan("relay.fcm_delivery_queue.process_batch"), + Stream.runForEach(FcmDeliveryQueueConsumer.processMessage), + Effect.provide(runtimeLayer), + ), + ); + // Hourly, not every five minutes: Neon's Free plan suspends the compute // after five idle minutes, so a five-minute sweep kept it awake around the // clock (~180 CU-hours/month against a 100 CU-hour allowance). Nothing diff --git a/packages/client-runtime/src/relay/managedRelay.test.ts b/packages/client-runtime/src/relay/managedRelay.test.ts index 2699c4ad08..b66f91b009 100644 --- a/packages/client-runtime/src/relay/managedRelay.test.ts +++ b/packages/client-runtime/src/relay/managedRelay.test.ts @@ -636,9 +636,9 @@ describe("ManagedRelayClient", () => { }).pipe(Effect.provide(managedRelayTestLayer(fetchFn))); }); - it.effect("lists account devices through the Clerk bearer client endpoint", () => { + it.effect("lists account devices through the v2 Clerk bearer client endpoint", () => { const fetchFn = ((input, init) => { - expect(String(input)).toBe("https://relay.example.test/v1/client/devices"); + expect(String(input)).toBe("https://relay.example.test/v2/client/devices"); expect(init?.headers).toMatchObject({ authorization: "Bearer clerk-token", }); @@ -663,6 +663,23 @@ describe("ManagedRelayClient", () => { }, updatedAt: "2026-06-01T00:00:00.000Z", }, + { + deviceId: "device-2", + label: "Android phone", + platform: "android", + iosMajorVersion: null, + androidApiLevel: 36, + appVersion: "1.0.0", + notifications: { + enabled: true, + notifyOnApproval: true, + notifyOnInput: true, + notifyOnCompletion: true, + notifyOnFailure: true, + }, + liveActivities: { enabled: true }, + updatedAt: "2026-06-01T00:00:00.000Z", + }, ], }), ); @@ -679,6 +696,12 @@ describe("ManagedRelayClient", () => { enabled: false, }, }, + { + deviceId: "device-2", + platform: "android", + iosMajorVersion: null, + androidApiLevel: 36, + }, ]); }).pipe(Effect.provide(managedRelayTestLayer(fetchFn))); }); diff --git a/packages/client-runtime/src/relay/managedRelay.ts b/packages/client-runtime/src/relay/managedRelay.ts index 70c43631da..9e932ccdac 100644 --- a/packages/client-runtime/src/relay/managedRelay.ts +++ b/packages/client-runtime/src/relay/managedRelay.ts @@ -729,7 +729,7 @@ export const make = Effect.fn("ManagedRelayClient.make")(function* ( listDevices: Effect.fnUntraced( function* (input) { return yield* client.client - .listDevices({ + .listDevicesV2({ headers: bearerHeaders(input.clerkToken), }) .pipe( diff --git a/packages/contracts/src/relay.test.ts b/packages/contracts/src/relay.test.ts index 4ad600953b..441fecd332 100644 --- a/packages/contracts/src/relay.test.ts +++ b/packages/contracts/src/relay.test.ts @@ -1,7 +1,52 @@ import { describe, expect, it } from "vite-plus/test"; import * as OpenApi from "effect/unstable/httpapi/OpenApi"; +import * as Schema from "effect/Schema"; -import { RelayApi } from "./relay.ts"; +import { RelayApi, RelayDeviceRegistrationRequest } from "./relay.ts"; + +const decodeDevice = Schema.decodeUnknownExit(RelayDeviceRegistrationRequest); +const device = { + deviceId: "device", + label: "Phone", + pushToken: "token", + preferences: { + notificationsEnabled: true, + liveActivitiesEnabled: true, + notifyOnApproval: true, + notifyOnInput: true, + notifyOnCompletion: true, + notifyOnFailure: true, + }, +}; + +describe("mobile device platforms", () => { + it.each([ + [23, "Failure"], + [24, "Success"], + [37, "Success"], + ])("enforces the Android minimum without an upper bound (API %i)", (androidApiLevel, result) => { + expect(decodeDevice({ ...device, platform: "android", androidApiLevel })._tag).toBe(result); + }); + + it("accepts Android tokens without Apple routing and preserves older iOS registrations", () => { + expect(decodeDevice({ ...device, platform: "android", androidApiLevel: 36 })._tag).toBe( + "Success", + ); + expect(decodeDevice({ ...device, platform: "ios", iosMajorVersion: 18 })._tag).toBe("Success"); + }); + it("rejects missing platform versions and Apple activity tokens on Android", () => { + expect(decodeDevice({ ...device, platform: "ios" })._tag).toBe("Failure"); + expect(decodeDevice({ ...device, platform: "android" })._tag).toBe("Failure"); + expect( + decodeDevice({ + ...device, + platform: "android", + androidApiLevel: 36, + pushToStartToken: "apple-token", + })._tag, + ).toBe("Failure"); + }); +}); describe("RelayApi security", () => { it("describes DPoP access tokens using the HTTP DPoP authorization scheme", () => { diff --git a/packages/contracts/src/relay.ts b/packages/contracts/src/relay.ts index 4e5352dc80..cc16d54803 100644 --- a/packages/contracts/src/relay.ts +++ b/packages/contracts/src/relay.ts @@ -16,7 +16,7 @@ import { } 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([ @@ -47,7 +47,8 @@ export const RelayDeviceRegistrationRequest = Schema.Struct({ deviceId: TrimmedNonEmptyString, label: TrimmedNonEmptyString, platform: RelayAgentAwarenessPlatform, - iosMajorVersion: Schema.Int.check(Schema.isGreaterThanOrEqualTo(18)), + iosMajorVersion: Schema.optional(Schema.Int.check(Schema.isGreaterThanOrEqualTo(18))), + androidApiLevel: Schema.optional(Schema.Int.check(Schema.isGreaterThanOrEqualTo(24))), appVersion: Schema.optional(TrimmedNonEmptyString), // APNs routing for this install: the topic must match the app's bundle id // (dev/preview/prod variants differ) and development-signed builds receive @@ -58,14 +59,24 @@ export const RelayDeviceRegistrationRequest = Schema.Struct({ pushToken: Schema.optional(TrimmedNonEmptyString), pushToStartToken: Schema.optional(TrimmedNonEmptyString), preferences: RelayAgentAwarenessPreferences, -}); +}).check( + Schema.makeFilter((device) => + device.platform === "ios" + ? device.iosMajorVersion !== undefined + : device.androidApiLevel !== undefined && + device.iosMajorVersion === undefined && + device.apsEnvironment === undefined && + device.pushToStartToken === undefined, + ), +); export type RelayDeviceRegistrationRequest = typeof RelayDeviceRegistrationRequest.Type; export const RelayClientDeviceRecord = Schema.Struct({ deviceId: TrimmedNonEmptyString, label: TrimmedNonEmptyString, platform: RelayAgentAwarenessPlatform, - iosMajorVersion: Schema.Int.check(Schema.isGreaterThanOrEqualTo(18)), + iosMajorVersion: Schema.NullOr(Schema.Int.check(Schema.isGreaterThanOrEqualTo(18))), + androidApiLevel: Schema.optional(Schema.NullOr(Schema.Int)), appVersion: Schema.NullOr(TrimmedNonEmptyString), notifications: Schema.Struct({ enabled: Schema.Boolean, @@ -86,6 +97,17 @@ export const RelayListDevicesResponse = Schema.Struct({ }); export type RelayListDevicesResponse = typeof RelayListDevicesResponse.Type; +// Installed clients decode v1 as iOS-only. Keep that response contract frozen. +export const RelayListDevicesResponseV1 = Schema.Struct({ + devices: Schema.Array( + Schema.Struct({ + ...RelayClientDeviceRecord.fields, + platform: Schema.Literal("ios"), + iosMajorVersion: Schema.Int.check(Schema.isGreaterThanOrEqualTo(18)), + }), + ), +}); + export const RelayLiveActivityRegistrationRequest = Schema.Struct({ deviceId: TrimmedNonEmptyString, activityPushToken: TrimmedNonEmptyString, @@ -964,6 +986,11 @@ const RelayClientGroup = HttpApiGroup.make("client") error: RelayAuthAndInternalErrors, }).annotate(OpenApi.Summary, "List linked environments"), HttpApiEndpoint.get("listDevices", "/v1/client/devices", { + headers: RelayBearerRequestHeaders, + success: RelayListDevicesResponseV1, + error: RelayAuthAndInternalErrors, + }).annotate(OpenApi.Summary, "List registered iOS devices (legacy clients)"), + HttpApiEndpoint.get("listDevicesV2", "/v2/client/devices", { headers: RelayBearerRequestHeaders, success: RelayListDevicesResponse, error: RelayAuthAndInternalErrors, From 3e4d6c9a6b9cb3da5caa95d7509b6e303d2e3b13 Mon Sep 17 00:00:00 2001 From: Trevor Walker Date: Thu, 10 Sep 2026 18:23:12 -0600 Subject: [PATCH 07/10] docs(upstream): record the mobile Android lane --- .agents/upstream-review.md | 1 + 1 file changed, 1 insertion(+) diff --git a/.agents/upstream-review.md b/.agents/upstream-review.md index 0c4194b88a..08374b6d73 100644 --- a/.agents/upstream-review.md +++ b/.agents/upstream-review.md @@ -55,6 +55,7 @@ Historical groups are indexed in the linked archive. This file migration changes | Live activity motion parity / `6c583620ff7ad3235b135af7107c0543467eecfa` | Corrects Pylon's rendering of already-classified `c7c1dfe4df` (#9709), `ce4712d5b0` (#9799) and the #444 port of `6cf0c6ea55` (#9093) / `c3b8825bf4` (#9606) | Pylon had diverged: `cdde7f3150` gave every active row the sweeping overlay and deleted `live-tool-shine`, while upstream reserves the overlay for label-only rows (`active && shimmer`, passed only by the Thinking row) and keeps the shine for tool labels. Restores upstream's `shimmer` prop, `animated = active && !failed` guard, row-level observer ref, overlay on worktree setup and compaction, and both CSS utilities byte-identical to upstream. That same commit also deleted `@utility visible-animate-spin` as dead while `spinner.tsx` and `refresh-icon.tsx` still applied it, so every spinner and refresh glyph was static; restored. Preserved: Pylon's `workingStepLabel`, and the `document.hasFocus()` gate AGENTS.md requires and upstream lacks. Cursor unchanged. | [Live activity parity #452](https://github.com/pylon-code/pylon/pull/452); 161 focused tests, web typecheck, scoped lint/format, region diffed against `t3code-upstream/main`. | | Skeleton loading pulse / `6c583620ff7ad3235b135af7107c0543467eecfa` | `21b9dda5afb00a33e228a68d2ccc885bba7285dc` (#9448) | Adopted. The shared `Skeleton` drops its swept gradient band for upstream's single stepped opacity pulse, whose keyframes were already byte-identical to the `ghost-pulse` Pylon ran on the pull-request ghosts; `ghost-pulse` is retired and every loading state now uses `animate-skeleton`. Preserve Pylon's `!seed` gate on the detail ghost, so a panel already showing real content does not breathe, and Pylon's own usage fill-in timing. The maintainer chose upstream theming and styling with only the logo staying Pylon's, which settles this kind of visual divergence in upstream's favour by default. Cursor unchanged. | [Skeleton pulse #451](https://github.com/pylon-code/pylon/pull/451); 339 focused tests, web typecheck, scoped lint/format. | | Panel links, markdown galleries, shared title model, usage columns, sidebar file drops, terminal link overrides, Codex limit naming and turn token telemetry / `6c583620ff7ad3235b135af7107c0543467eecfa` | Full ten-source list in PR #458, from #9132 through #10060 | Nine adopted; `b5d89038ae72142038dfa8cf69d49b7a607fe98e` is already covered as an empty duplicate of #7892. Turn telemetry stays inert without a Pylon PostHog key, is recorded only after runtime generation and session incarnation fences, and treats Prime, Cursor, Grok and Antigravity usage as unavailable; OpenCode uses the final #10116 step-retention design. Codex limit errors carry the session incarnation and Pylon keeps relaying every rate-limit notification. Preserve Pylon PR detection, environment-scoped PR caches, provider-instance settings writes, hub account keys, older-server window names, change-request row props and terminal external-browser fallback. New product analytics and usage data docs. Cursor unchanged. | [Web panels #458](https://github.com/pylon-code/pylon/pull/458); 1,687 focused tests, six package typechecks (web, server, shared, client-runtime, contracts, mobile), scoped lint/format; upstream UI evidence linked, no local client pass. | +| Mobile Android: provider setup removal, Material You, Keychain group, Android notifications / `6c583620ff7ad3235b135af7107c0543467eecfa` | Full ten-source list in #PR_NUMBER, from #9721 through #10416 | Adopted #9721 (mobile Antigravity setup from the #440 port removed; Pylon's Prime host maintenance status kept), #3665 (team via `$(AppIdentifierPrefix)`), #10691, #10692, #10859 and #10416 (FCM relay delivery, Android ongoing activity, `/v2/client/devices`, additive relay migration). #10598 and #10620 already covered: Pylon's exporter fixed launcher cropping and its splash mark fits the Android 12+ mask; T3 background artwork layers left to the maintainer. Two version bumps skipped. Adaptations: Pylon EAS OTA gate, package IDs, relay hourly cron, Effect `TaggedErrorClass`; Hermes cookie assertion belongs to #10851. Firebase/FCM not provisioned. Cursor unchanged. | [Mobile Android #PR_NUMBER](https://github.com/pylon-code/pylon/pull/PR_NUMBER); 249 focused tests, five package typechecks, scoped lint/format, generated-theme and icon checks. | ## Deferred register From 9f9a7304e2f4e78ac7d1a0e4a690c8dff05e9625 Mon Sep 17 00:00:00 2001 From: Trevor Walker Date: Thu, 10 Sep 2026 18:24:49 -0600 Subject: [PATCH 08/10] docs(upstream): link the mobile Android lane PR --- .agents/upstream-review.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.agents/upstream-review.md b/.agents/upstream-review.md index 08374b6d73..4846d7e89d 100644 --- a/.agents/upstream-review.md +++ b/.agents/upstream-review.md @@ -55,7 +55,7 @@ Historical groups are indexed in the linked archive. This file migration changes | Live activity motion parity / `6c583620ff7ad3235b135af7107c0543467eecfa` | Corrects Pylon's rendering of already-classified `c7c1dfe4df` (#9709), `ce4712d5b0` (#9799) and the #444 port of `6cf0c6ea55` (#9093) / `c3b8825bf4` (#9606) | Pylon had diverged: `cdde7f3150` gave every active row the sweeping overlay and deleted `live-tool-shine`, while upstream reserves the overlay for label-only rows (`active && shimmer`, passed only by the Thinking row) and keeps the shine for tool labels. Restores upstream's `shimmer` prop, `animated = active && !failed` guard, row-level observer ref, overlay on worktree setup and compaction, and both CSS utilities byte-identical to upstream. That same commit also deleted `@utility visible-animate-spin` as dead while `spinner.tsx` and `refresh-icon.tsx` still applied it, so every spinner and refresh glyph was static; restored. Preserved: Pylon's `workingStepLabel`, and the `document.hasFocus()` gate AGENTS.md requires and upstream lacks. Cursor unchanged. | [Live activity parity #452](https://github.com/pylon-code/pylon/pull/452); 161 focused tests, web typecheck, scoped lint/format, region diffed against `t3code-upstream/main`. | | Skeleton loading pulse / `6c583620ff7ad3235b135af7107c0543467eecfa` | `21b9dda5afb00a33e228a68d2ccc885bba7285dc` (#9448) | Adopted. The shared `Skeleton` drops its swept gradient band for upstream's single stepped opacity pulse, whose keyframes were already byte-identical to the `ghost-pulse` Pylon ran on the pull-request ghosts; `ghost-pulse` is retired and every loading state now uses `animate-skeleton`. Preserve Pylon's `!seed` gate on the detail ghost, so a panel already showing real content does not breathe, and Pylon's own usage fill-in timing. The maintainer chose upstream theming and styling with only the logo staying Pylon's, which settles this kind of visual divergence in upstream's favour by default. Cursor unchanged. | [Skeleton pulse #451](https://github.com/pylon-code/pylon/pull/451); 339 focused tests, web typecheck, scoped lint/format. | | Panel links, markdown galleries, shared title model, usage columns, sidebar file drops, terminal link overrides, Codex limit naming and turn token telemetry / `6c583620ff7ad3235b135af7107c0543467eecfa` | Full ten-source list in PR #458, from #9132 through #10060 | Nine adopted; `b5d89038ae72142038dfa8cf69d49b7a607fe98e` is already covered as an empty duplicate of #7892. Turn telemetry stays inert without a Pylon PostHog key, is recorded only after runtime generation and session incarnation fences, and treats Prime, Cursor, Grok and Antigravity usage as unavailable; OpenCode uses the final #10116 step-retention design. Codex limit errors carry the session incarnation and Pylon keeps relaying every rate-limit notification. Preserve Pylon PR detection, environment-scoped PR caches, provider-instance settings writes, hub account keys, older-server window names, change-request row props and terminal external-browser fallback. New product analytics and usage data docs. Cursor unchanged. | [Web panels #458](https://github.com/pylon-code/pylon/pull/458); 1,687 focused tests, six package typechecks (web, server, shared, client-runtime, contracts, mobile), scoped lint/format; upstream UI evidence linked, no local client pass. | -| Mobile Android: provider setup removal, Material You, Keychain group, Android notifications / `6c583620ff7ad3235b135af7107c0543467eecfa` | Full ten-source list in #PR_NUMBER, from #9721 through #10416 | Adopted #9721 (mobile Antigravity setup from the #440 port removed; Pylon's Prime host maintenance status kept), #3665 (team via `$(AppIdentifierPrefix)`), #10691, #10692, #10859 and #10416 (FCM relay delivery, Android ongoing activity, `/v2/client/devices`, additive relay migration). #10598 and #10620 already covered: Pylon's exporter fixed launcher cropping and its splash mark fits the Android 12+ mask; T3 background artwork layers left to the maintainer. Two version bumps skipped. Adaptations: Pylon EAS OTA gate, package IDs, relay hourly cron, Effect `TaggedErrorClass`; Hermes cookie assertion belongs to #10851. Firebase/FCM not provisioned. Cursor unchanged. | [Mobile Android #PR_NUMBER](https://github.com/pylon-code/pylon/pull/PR_NUMBER); 249 focused tests, five package typechecks, scoped lint/format, generated-theme and icon checks. | +| Mobile Android: provider setup removal, Material You, Keychain group, Android notifications / `6c583620ff7ad3235b135af7107c0543467eecfa` | Full ten-source list in #456, from #9721 through #10416 | Adopted #9721 (mobile Antigravity setup from the #440 port removed; Pylon's Prime host maintenance status kept), #3665 (team via `$(AppIdentifierPrefix)`), #10691, #10692, #10859 and #10416 (FCM relay delivery, Android ongoing activity, `/v2/client/devices`, additive relay migration). #10598 and #10620 already covered: Pylon's exporter fixed launcher cropping and its splash mark fits the Android 12+ mask; T3 background artwork layers left to the maintainer. Two version bumps skipped. Adaptations: Pylon EAS OTA gate, package IDs, relay hourly cron, Effect `TaggedErrorClass`; Hermes cookie assertion belongs to #10851. Firebase/FCM not provisioned. Cursor unchanged. | [Mobile Android #456](https://github.com/pylon-code/pylon/pull/456); 249 focused tests, five package typechecks, scoped lint/format, generated-theme and icon checks. | ## Deferred register From b147efcbc11245d33e5877514ce7dc960aa78837 Mon Sep 17 00:00:00 2001 From: Trevor Walker Date: Thu, 10 Sep 2026 18:28:14 -0600 Subject: [PATCH 09/10] fix(mobile): wrap the Android notification test assertion for ktlint The Pylon scheme made one deep-link assertion exceed ktlint's 100-column limit. --- .../modules/t3agentnotifications/AgentNotificationsTest.kt | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/apps/mobile/modules/t3-agent-notifications/android/src/test/java/expo/modules/t3agentnotifications/AgentNotificationsTest.kt b/apps/mobile/modules/t3-agent-notifications/android/src/test/java/expo/modules/t3agentnotifications/AgentNotificationsTest.kt index 2cfe346669..b236fde081 100644 --- a/apps/mobile/modules/t3-agent-notifications/android/src/test/java/expo/modules/t3agentnotifications/AgentNotificationsTest.kt +++ b/apps/mobile/modules/t3-agent-notifications/android/src/test/java/expo/modules/t3agentnotifications/AgentNotificationsTest.kt @@ -160,7 +160,10 @@ class AgentNotificationsTest { val alert = manager.activeNotifications.single() assertEquals("5 agents finished", alert.notification.extras.getString(Notification.EXTRA_TITLE)) assertEquals(titles, alert.notification.extras.getString(Notification.EXTRA_BIG_TEXT)) - assertEquals("pylon-code-dev://", shadowOf(alert.notification.contentIntent).savedIntent.dataString) + assertEquals( + "pylon-code-dev://", + shadowOf(alert.notification.contentIntent).savedIntent.dataString + ) } @Test From 0b1bdf0edce6164d5b11ad42f9ddf0c3b5f4a3a3 Mon Sep 17 00:00:00 2001 From: Trevor Walker Date: Thu, 10 Sep 2026 19:18:03 -0600 Subject: [PATCH 10/10] fix(mobile): only show notifications on when this device can receive them Android builds without Firebase config and devices whose APNs or FCM token lookup failed still registered with the relay, so Settings showed Device Notifications and Ongoing Agent Activity as on while nothing could arrive. The app now publishes whether Android push is configured, keeps both switches off with an honest subtitle when it is not, and reads a switch as on only once the relay accepts a registration that carries a push token. Also signs the Keychain access group with the bundle ID the build actually uses, makes the Mobile clients page and sign-in prompt name Android's ongoing activity, documents the relay rollback hazard, and links the new notification docs from the index. --- .agents/upstream-review.md | 2 +- apps/mobile/app.config.ts | 19 +++-- .../androidNotifications.test.ts | 21 ++++- .../agent-awareness/androidNotifications.ts | 21 ++++- .../remoteRegistration.test.ts | 83 +++++++++++++++++++ .../agent-awareness/remoteRegistration.ts | 28 +++++-- .../SettingsRouteScreen.logic.test.ts | 34 +++++++- .../settings/SettingsRouteScreen.logic.ts | 23 +++++ .../features/settings/SettingsRouteScreen.tsx | 82 ++++++++++++------ ...MobileClientsUserProfilePage.logic.test.ts | 10 +++ .../MobileClientsUserProfilePage.logic.ts | 5 ++ .../clerk/MobileClientsUserProfilePage.tsx | 9 +- docs/README.md | 2 + docs/operations/android-notifications.md | 34 +++++++- docs/user/mobile-notifications.md | 36 ++++---- 15 files changed, 349 insertions(+), 60 deletions(-) diff --git a/.agents/upstream-review.md b/.agents/upstream-review.md index 4846d7e89d..dbcaa885fb 100644 --- a/.agents/upstream-review.md +++ b/.agents/upstream-review.md @@ -55,7 +55,7 @@ Historical groups are indexed in the linked archive. This file migration changes | Live activity motion parity / `6c583620ff7ad3235b135af7107c0543467eecfa` | Corrects Pylon's rendering of already-classified `c7c1dfe4df` (#9709), `ce4712d5b0` (#9799) and the #444 port of `6cf0c6ea55` (#9093) / `c3b8825bf4` (#9606) | Pylon had diverged: `cdde7f3150` gave every active row the sweeping overlay and deleted `live-tool-shine`, while upstream reserves the overlay for label-only rows (`active && shimmer`, passed only by the Thinking row) and keeps the shine for tool labels. Restores upstream's `shimmer` prop, `animated = active && !failed` guard, row-level observer ref, overlay on worktree setup and compaction, and both CSS utilities byte-identical to upstream. That same commit also deleted `@utility visible-animate-spin` as dead while `spinner.tsx` and `refresh-icon.tsx` still applied it, so every spinner and refresh glyph was static; restored. Preserved: Pylon's `workingStepLabel`, and the `document.hasFocus()` gate AGENTS.md requires and upstream lacks. Cursor unchanged. | [Live activity parity #452](https://github.com/pylon-code/pylon/pull/452); 161 focused tests, web typecheck, scoped lint/format, region diffed against `t3code-upstream/main`. | | Skeleton loading pulse / `6c583620ff7ad3235b135af7107c0543467eecfa` | `21b9dda5afb00a33e228a68d2ccc885bba7285dc` (#9448) | Adopted. The shared `Skeleton` drops its swept gradient band for upstream's single stepped opacity pulse, whose keyframes were already byte-identical to the `ghost-pulse` Pylon ran on the pull-request ghosts; `ghost-pulse` is retired and every loading state now uses `animate-skeleton`. Preserve Pylon's `!seed` gate on the detail ghost, so a panel already showing real content does not breathe, and Pylon's own usage fill-in timing. The maintainer chose upstream theming and styling with only the logo staying Pylon's, which settles this kind of visual divergence in upstream's favour by default. Cursor unchanged. | [Skeleton pulse #451](https://github.com/pylon-code/pylon/pull/451); 339 focused tests, web typecheck, scoped lint/format. | | Panel links, markdown galleries, shared title model, usage columns, sidebar file drops, terminal link overrides, Codex limit naming and turn token telemetry / `6c583620ff7ad3235b135af7107c0543467eecfa` | Full ten-source list in PR #458, from #9132 through #10060 | Nine adopted; `b5d89038ae72142038dfa8cf69d49b7a607fe98e` is already covered as an empty duplicate of #7892. Turn telemetry stays inert without a Pylon PostHog key, is recorded only after runtime generation and session incarnation fences, and treats Prime, Cursor, Grok and Antigravity usage as unavailable; OpenCode uses the final #10116 step-retention design. Codex limit errors carry the session incarnation and Pylon keeps relaying every rate-limit notification. Preserve Pylon PR detection, environment-scoped PR caches, provider-instance settings writes, hub account keys, older-server window names, change-request row props and terminal external-browser fallback. New product analytics and usage data docs. Cursor unchanged. | [Web panels #458](https://github.com/pylon-code/pylon/pull/458); 1,687 focused tests, six package typechecks (web, server, shared, client-runtime, contracts, mobile), scoped lint/format; upstream UI evidence linked, no local client pass. | -| Mobile Android: provider setup removal, Material You, Keychain group, Android notifications / `6c583620ff7ad3235b135af7107c0543467eecfa` | Full ten-source list in #456, from #9721 through #10416 | Adopted #9721 (mobile Antigravity setup from the #440 port removed; Pylon's Prime host maintenance status kept), #3665 (team via `$(AppIdentifierPrefix)`), #10691, #10692, #10859 and #10416 (FCM relay delivery, Android ongoing activity, `/v2/client/devices`, additive relay migration). #10598 and #10620 already covered: Pylon's exporter fixed launcher cropping and its splash mark fits the Android 12+ mask; T3 background artwork layers left to the maintainer. Two version bumps skipped. Adaptations: Pylon EAS OTA gate, package IDs, relay hourly cron, Effect `TaggedErrorClass`; Hermes cookie assertion belongs to #10851. Firebase/FCM not provisioned. Cursor unchanged. | [Mobile Android #456](https://github.com/pylon-code/pylon/pull/456); 249 focused tests, five package typechecks, scoped lint/format, generated-theme and icon checks. | +| Mobile Android: provider setup removal, Material You, Keychain group, Android notifications / `6c583620ff7ad3235b135af7107c0543467eecfa` | Full ten-source list in #456, from #9721 through #10416 | Adopted #9721 (mobile Antigravity setup from the #440 port removed; Pylon's Prime host maintenance status kept), #3665 (team via `$(AppIdentifierPrefix)`), #10691, #10692, #10859 and #10416 (FCM relay delivery, Android ongoing activity, `/v2/client/devices`, additive relay migration). #10598 and #10620 already covered: Pylon's exporter fixed launcher cropping and its splash mark fits the Android 12+ mask; T3 background artwork layers left to the maintainer. Two version bumps skipped. Adaptations: Pylon EAS OTA gate, package IDs, signed-bundle Keychain group, relay hourly cron, Effect `TaggedErrorClass`; notification switches need Firebase config and a registered push token; Hermes cookie assertion belongs to #10851. Firebase/FCM not provisioned; rollback needs a forward fix. Cursor unchanged. | [Mobile Android #456](https://github.com/pylon-code/pylon/pull/456); 258 focused tests, five package typechecks, scoped lint/format, generated-theme and icon checks. | ## Deferred register diff --git a/apps/mobile/app.config.ts b/apps/mobile/app.config.ts index 80f4cf7607..2093eac787 100644 --- a/apps/mobile/app.config.ts +++ b/apps/mobile/app.config.ts @@ -95,6 +95,10 @@ const appleTeamId = repoEnv.PYLON_APPLE_TEAM_ID?.trim() || repoEnv.APPLE_TEAM_ID const easProjectId = repoEnv.PYLON_EAS_PROJECT_ID?.trim() ?? ""; const easOwner = repoEnv.PYLON_EAS_OWNER?.trim() ?? ""; +// Firebase config for Android push. Pylon ships none by default; builds without +// it still work but cannot receive agent notifications. +const androidGoogleServicesFile = repoEnv.T3CODE_ANDROID_GOOGLE_SERVICES_FILE?.trim() ?? ""; + const VARIANT_CONFIG = { development: { appName: "Pylon Dev", @@ -245,10 +249,12 @@ const config: ExpoConfig = { ], } : {}), - // SecureStore keeps connection credentials in the Keychain. `$(AppIdentifierPrefix)` - // resolves to the signing team at build time, so no team ID is pinned here. + // SecureStore keeps connection credentials in the Keychain. The group is the + // bundle ID this build actually signs with, including a personal-team + // override, and `$(AppIdentifierPrefix)` resolves to the signing team at + // build time, so no team ID is pinned here. entitlements: { - "keychain-access-groups": [`$(AppIdentifierPrefix)${variant.iosBundleIdentifier}`], + "keychain-access-groups": [`$(AppIdentifierPrefix)${iosBundleIdentifier}`], }, infoPlist: { NSAppTransportSecurity: { @@ -278,9 +284,7 @@ const config: ExpoConfig = { android: { icon: variant.assets.appIcon, package: variant.androidPackage, - ...(repoEnv.T3CODE_ANDROID_GOOGLE_SERVICES_FILE - ? { googleServicesFile: repoEnv.T3CODE_ANDROID_GOOGLE_SERVICES_FILE } - : {}), + ...(androidGoogleServicesFile ? { googleServicesFile: androidGoogleServicesFile } : {}), adaptiveIcon: { backgroundColor: variant.assets.androidAdaptiveBackgroundColor, foregroundImage: variant.assets.androidAdaptiveForeground, @@ -418,6 +422,9 @@ const config: ExpoConfig = { extra: { appVariant: APP_VARIANT, iosPersonalTeamBuild: isIosPersonalTeamBuild, + // Without Firebase config the app never gets an FCM token, so Settings must + // not offer Android notifications it cannot deliver. + androidPushConfigured: androidGoogleServicesFile.length > 0, relay: { url: repoEnv.T3CODE_RELAY_URL ?? null, }, diff --git a/apps/mobile/src/features/agent-awareness/androidNotifications.test.ts b/apps/mobile/src/features/agent-awareness/androidNotifications.test.ts index 3adf89a1d6..37b669cd34 100644 --- a/apps/mobile/src/features/agent-awareness/androidNotifications.test.ts +++ b/apps/mobile/src/features/agent-awareness/androidNotifications.test.ts @@ -3,7 +3,10 @@ import { beforeEach, describe, expect, it, vi } from "vite-plus/test"; const mocks = vi.hoisted(() => ({ os: "android", native: null as { configure?: ReturnType; clear?: ReturnType } | null, - config: { scheme: ["pylon-code-preview"], extra: { iosPersonalTeamBuild: false } }, + config: { + scheme: ["pylon-code-preview"], + extra: { iosPersonalTeamBuild: false, androidPushConfigured: true }, + }, requireModule: vi.fn(), })); @@ -22,6 +25,7 @@ beforeEach(() => { mocks.os = "android"; mocks.native = { configure: vi.fn(), clear: vi.fn() }; mocks.config.extra.iosPersonalTeamBuild = false; + mocks.config.extra.androidPushConfigured = true; mocks.requireModule.mockReset().mockImplementation(() => mocks.native); }); @@ -57,6 +61,21 @@ describe("Android native notification capability", () => { }, ); + it("disables push on a build without Firebase config even with the native module", async () => { + mocks.config.extra.androidPushConfigured = false; + const { resolveAndroidAgentNotificationsAvailability } = await import("./androidNotifications"); + const { supportsAgentAwarenessPush } = await import("./capabilities"); + expect(resolveAndroidAgentNotificationsAvailability()).toBe("push-not-configured"); + expect(supportsAgentAwarenessPush()).toBe(false); + }); + + it("reports a missing native module before missing Firebase config", async () => { + mocks.native = null; + mocks.config.extra.androidPushConfigured = false; + const { resolveAndroidAgentNotificationsAvailability } = await import("./androidNotifications"); + expect(resolveAndroidAgentNotificationsAvailability()).toBe("native-module-missing"); + }); + it("preserves the iOS personal-team restriction without loading Android code", async () => { mocks.os = "ios"; const { supportsAgentAwarenessPush } = await import("./capabilities"); diff --git a/apps/mobile/src/features/agent-awareness/androidNotifications.ts b/apps/mobile/src/features/agent-awareness/androidNotifications.ts index 7e89f7c2f6..0f16279525 100644 --- a/apps/mobile/src/features/agent-awareness/androidNotifications.ts +++ b/apps/mobile/src/features/agent-awareness/androidNotifications.ts @@ -12,8 +12,27 @@ const native = ? requireOptionalNativeModule("T3AgentNotifications") : null; +/** + * Why this Android build can or cannot receive agent notifications. An older + * binary lacks the native handler; a build without Firebase config can never + * obtain an FCM token, so the relay would have nothing to deliver to. + */ +export type AndroidAgentNotificationsAvailability = + | "available" + | "native-module-missing" + | "push-not-configured"; + +export function resolveAndroidAgentNotificationsAvailability(): AndroidAgentNotificationsAvailability { + if (typeof native?.configure !== "function" || typeof native?.clear !== "function") { + return "native-module-missing"; + } + return Constants.expoConfig?.extra?.androidPushConfigured === true + ? "available" + : "push-not-configured"; +} + export function supportsAndroidAgentNotifications(): boolean { - return typeof native?.configure === "function" && typeof native?.clear === "function"; + return resolveAndroidAgentNotificationsAvailability() === "available"; } export function configureAndroidAgentNotifications( diff --git a/apps/mobile/src/features/agent-awareness/remoteRegistration.test.ts b/apps/mobile/src/features/agent-awareness/remoteRegistration.test.ts index f1b84f0af8..44230e04de 100644 --- a/apps/mobile/src/features/agent-awareness/remoteRegistration.test.ts +++ b/apps/mobile/src/features/agent-awareness/remoteRegistration.test.ts @@ -30,6 +30,7 @@ import { AgentAwarenessOperationError, __resetAgentAwarenessRemoteRegistrationForTest, armAgentAwarenessLiveActivityForLocalWork, + getAgentAwarenessPushDeliveryReady, getAgentAwarenessRegistrationStatus, mergeAgentAwarenessRegistrationPreferences, refreshActiveLiveActivityRemoteRegistration, @@ -47,6 +48,7 @@ import { Platform } from "react-native"; import { configureAndroidAgentNotifications, clearAndroidAgentNotifications, + supportsAndroidAgentNotifications, } from "./androidNotifications"; vi.mock("./androidNotifications", () => ({ @@ -250,6 +252,34 @@ const runBackgroundOperations = Effect.fn("TestRemoteRegistration.runBackgroundO }, ); +/** + * Accepts relay auth and device registration on a relay URL of its own, + * recording each registration body. Provide `relayLayer` so requests reach + * this stub rather than a client another test already built. + */ +function stubRelayRegistrations(relayUrl: string) { + 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: "relay-dpop-token", + issued_token_type: "urn:ietf:params:oauth:token-type:access_token", + token_type: "DPoP", + expires_in: 300, + scope: "mobile:registration", + }); + } + registrations.push(await request.json()); + return Response.json({ ok: true }); + }); + Constants.expoConfig!.extra = { relay: { url: relayUrl } }; + const relayLayer = managedRelayClientLayer(relayUrl).pipe( + Layer.provide(Layer.mergeAll(FetchHttpClient.layer, cryptoLayer)), + ); + return { registrations, relayLayer }; +} + describe("makeRelayDeviceRegistrationRequest", () => { beforeEach(() => { vi.restoreAllMocks(); @@ -257,6 +287,7 @@ describe("makeRelayDeviceRegistrationRequest", () => { type: "ios", data: "apns-token", }); + vi.mocked(supportsAndroidAgentNotifications).mockReturnValue(true); vi.unstubAllGlobals(); vi.stubGlobal("__DEV__", false); secureStore.clear(); @@ -1074,6 +1105,7 @@ describe("makeRelayDeviceRegistrationRequest", () => { yield* refreshAgentAwarenessRegistration(); expect(Notifications.getDevicePushTokenAsync).toHaveBeenCalled(); expect(saveAgentAwarenessRegistrationRecord).toHaveBeenCalled(); + expect(getAgentAwarenessPushDeliveryReady()).toBe(true); expect(registrationRecordStore.current?.signature).toContain("fcm-token"); expect(registrationRecordStore.current?.signature).toContain("android"); expect(widgetMocks.getInstances).not.toHaveBeenCalled(); @@ -1083,6 +1115,57 @@ describe("makeRelayDeviceRegistrationRequest", () => { expect(clearAndroidAgentNotifications).toHaveBeenCalled(); }).pipe(Effect.provide(relayTestLayer)); }); + it.effect("keeps Android notifications off when the build cannot receive pushes", () => { + vi.spyOn(Platform, "OS", "get").mockReturnValue("android"); + vi.spyOn(Platform, "Version", "get").mockReturnValue(36); + // An Android build without Firebase config can never obtain an FCM token. + vi.mocked(supportsAndroidAgentNotifications).mockReturnValue(false); + vi.mocked(Notifications.getDevicePushTokenAsync).mockClear(); + vi.mocked(configureAndroidAgentNotifications).mockClear(); + const { registrations, relayLayer } = stubRelayRegistrations( + "https://unprovisioned-relay.example.test", + ); + setAgentAwarenessRelayTokenProvider(() => Promise.resolve("clerk-token-user-a"), "user-a"); + return Effect.gen(function* () { + yield* refreshAgentAwarenessRegistration(); + expect(getAgentAwarenessRegistrationStatus()).toBe("registered"); + expect(getAgentAwarenessPushDeliveryReady()).toBe(false); + expect(Notifications.getDevicePushTokenAsync).not.toHaveBeenCalled(); + expect(configureAndroidAgentNotifications).not.toHaveBeenCalled(); + expect(registrations.at(-1)).toMatchObject({ + platform: "android", + preferences: { notificationsEnabled: false, liveActivitiesEnabled: false }, + }); + expect(registrations.at(-1)).not.toHaveProperty("pushToken"); + }).pipe( + Effect.provideService(FetchHttpClient.Fetch, globalThis.fetch), + Effect.provide(relayLayer), + ); + }); + + it.effect("does not report push delivery when the native token lookup fails", () => { + vi.mocked(Notifications.getDevicePushTokenAsync).mockRejectedValue( + new Error("no valid aps-environment entitlement"), + ); + const { registrations, relayLayer } = stubRelayRegistrations( + "https://token-failure-relay.example.test", + ); + setAgentAwarenessRelayTokenProvider(() => Promise.resolve("clerk-token-user-a"), "user-a"); + return Effect.gen(function* () { + yield* refreshAgentAwarenessRegistration(); + expect(getAgentAwarenessRegistrationStatus()).toBe("registered"); + expect(getAgentAwarenessPushDeliveryReady()).toBe(false); + expect(registrations.at(-1)).toMatchObject({ + platform: "ios", + preferences: { notificationsEnabled: false }, + }); + expect(registrations.at(-1)).not.toHaveProperty("pushToken"); + }).pipe( + Effect.provideService(FetchHttpClient.Fetch, globalThis.fetch), + Effect.provide(relayLayer), + ); + }); + it.effect( "preserves same-account Android notifications and replays on remount and later foreground", () => { diff --git a/apps/mobile/src/features/agent-awareness/remoteRegistration.ts b/apps/mobile/src/features/agent-awareness/remoteRegistration.ts index 0ab2543e0f..4686788a60 100644 --- a/apps/mobile/src/features/agent-awareness/remoteRegistration.ts +++ b/apps/mobile/src/features/agent-awareness/remoteRegistration.ts @@ -94,13 +94,23 @@ let appStateSubscription: { remove: () => void } | null = null; // not read as enabled. export type AgentAwarenessRegistrationStatus = "unknown" | "pending" | "registered" | "failed"; let registrationStatus: AgentAwarenessRegistrationStatus = "unknown"; +// Whether that accepted registration carried a push token with notifications +// on. A device can register without one — permission denied, a failed APNs or +// FCM token lookup, or an Android build without Firebase config — and then the +// relay has nowhere to deliver alerts. +let registeredWithPushToken = false; const registrationStatusListeners = new Set<() => void>(); -function setRegistrationStatus(next: AgentAwarenessRegistrationStatus): void { - if (registrationStatus === next) { +function setRegistrationStatus( + next: AgentAwarenessRegistrationStatus, + pushTokenRegistered = false, +): void { + const nextPushTokenRegistered = next === "registered" && pushTokenRegistered; + if (registrationStatus === next && registeredWithPushToken === nextPushTokenRegistered) { return; } registrationStatus = next; + registeredWithPushToken = nextPushTokenRegistered; for (const listener of registrationStatusListeners) { listener(); } @@ -110,6 +120,11 @@ export function getAgentAwarenessRegistrationStatus(): AgentAwarenessRegistratio return registrationStatus; } +/** True only when the relay accepted a registration it can deliver alerts to. */ +export function getAgentAwarenessPushDeliveryReady(): boolean { + return registrationStatus === "registered" && registeredWithPushToken; +} + export function subscribeAgentAwarenessRegistrationStatus(listener: () => void): () => void { registrationStatusListeners.add(listener); return () => { @@ -407,7 +422,7 @@ function registerDeviceWithRelay( persisted.signature === signature && !needsAndroidReplay ) { - setRegistrationStatus("registered"); + setRegistrationStatus("registered", payload.preferences.notificationsEnabled); logRegistrationDebug("relay device registration skipped; already registered for account", { expectedGeneration, }); @@ -433,7 +448,7 @@ function registerDeviceWithRelay( return; } if (body.platform === "android") androidDeviceReplayedAt = Date.now(); - setRegistrationStatus("registered"); + setRegistrationStatus("registered", payload.preferences.notificationsEnabled); yield* Effect.promise(() => saveAgentAwarenessRegistrationRecord({ identity, @@ -748,7 +763,9 @@ function registerDevice( input.preferencesOverride, ); if (expectedGeneration !== deviceRegistrationGeneration) return; - if (relayTokenProvider && relayTokenProviderIdentity) { + // A build that cannot receive pushes should not create notification + // channels for alerts that will never arrive. + if (relayTokenProvider && relayTokenProviderIdentity && supportsAgentAwarenessPush()) { configureAndroidAgentNotifications( deviceId, relayTokenProviderIdentity, @@ -925,6 +942,7 @@ export function __resetAgentAwarenessRemoteRegistrationForTest(): void { activeDeviceRegistration = null; pendingDeviceRegistration = null; registrationStatus = "unknown"; + registeredWithPushToken = false; androidDeviceReplayedAt = null; registrationStatusListeners.clear(); registeredActivityPushTokens.clear(); diff --git a/apps/mobile/src/features/settings/SettingsRouteScreen.logic.test.ts b/apps/mobile/src/features/settings/SettingsRouteScreen.logic.test.ts index 3e0ead2ce5..0a4f53f54a 100644 --- a/apps/mobile/src/features/settings/SettingsRouteScreen.logic.test.ts +++ b/apps/mobile/src/features/settings/SettingsRouteScreen.logic.test.ts @@ -1,6 +1,10 @@ import { describe, expect, it } from "vite-plus/test"; -import { resolveAgentAwarenessPlatformPresentation } from "./SettingsRouteScreen.logic"; +import { + resolveAgentAwarenessPlatformPresentation, + resolveAgentAwarenessSignInMessage, + resolveAgentAwarenessSubtitle, +} from "./SettingsRouteScreen.logic"; describe("resolveAgentAwarenessPlatformPresentation", () => { it("supports agent awareness settings on Android", () => { @@ -17,3 +21,31 @@ describe("resolveAgentAwarenessPlatformPresentation", () => { }); }); }); + +describe("resolveAgentAwarenessSubtitle", () => { + it("says an Android build without Firebase config cannot receive notifications", () => { + expect(resolveAgentAwarenessSubtitle("android", "push-not-configured")).toBe( + "This app build can't receive notifications", + ); + }); + + it("asks for a newer build when the native handler is missing", () => { + expect(resolveAgentAwarenessSubtitle("android", "native-module-missing")).toBe( + "Install a newer app build to enable notifications", + ); + }); + + it("adds no subtitle when notifications can be delivered", () => { + expect(resolveAgentAwarenessSubtitle("android", "available")).toBeUndefined(); + expect(resolveAgentAwarenessSubtitle("ios")).toBeUndefined(); + expect(resolveAgentAwarenessSubtitle("web")).toBe("Unavailable on this platform"); + }); +}); + +describe("resolveAgentAwarenessSignInMessage", () => { + it("names the activity surface each platform actually has", () => { + expect(resolveAgentAwarenessSignInMessage("android")).toContain("ongoing activity"); + expect(resolveAgentAwarenessSignInMessage("android")).not.toContain("Live Activity"); + expect(resolveAgentAwarenessSignInMessage("ios")).toContain("Live Activity"); + }); +}); diff --git a/apps/mobile/src/features/settings/SettingsRouteScreen.logic.ts b/apps/mobile/src/features/settings/SettingsRouteScreen.logic.ts index 6cdf8a3e1f..dc31259c2c 100644 --- a/apps/mobile/src/features/settings/SettingsRouteScreen.logic.ts +++ b/apps/mobile/src/features/settings/SettingsRouteScreen.logic.ts @@ -1,3 +1,5 @@ +import type { AndroidAgentNotificationsAvailability } from "../agent-awareness/androidNotifications"; + export function resolveAgentAwarenessPlatformPresentation(platform: string): { readonly supported: boolean; readonly subtitle: string | undefined; @@ -6,3 +8,24 @@ export function resolveAgentAwarenessPlatformPresentation(platform: string): { ? { supported: true, subtitle: undefined } : { supported: false, subtitle: "Unavailable on this platform" }; } + +/** Explains a disabled notification switch instead of leaving it silently off. */ +export function resolveAgentAwarenessSubtitle( + platform: string, + androidAvailability?: AndroidAgentNotificationsAvailability, +): string | undefined { + if (platform === "android" && androidAvailability === "native-module-missing") { + return "Install a newer app build to enable notifications"; + } + if (platform === "android" && androidAvailability === "push-not-configured") { + return "This app build can't receive notifications"; + } + return resolveAgentAwarenessPlatformPresentation(platform).subtitle; +} + +/** Tells the user what signing in to Pylon Connect turns on for this device. */ +export function resolveAgentAwarenessSignInMessage(platform: string): string { + return platform === "android" + ? "Agent notifications and ongoing activity require Pylon Connect so the relay can deliver them to this device." + : "Notifications and Live Activity updates require Pylon Connect so the relay can deliver them to this device."; +} diff --git a/apps/mobile/src/features/settings/SettingsRouteScreen.tsx b/apps/mobile/src/features/settings/SettingsRouteScreen.tsx index 7423db855d..134d732969 100644 --- a/apps/mobile/src/features/settings/SettingsRouteScreen.tsx +++ b/apps/mobile/src/features/settings/SettingsRouteScreen.tsx @@ -20,10 +20,12 @@ import { } from "@t3tools/client-runtime/state/runtime"; import { AndroidScreenHeader } from "../../components/AndroidScreenHeader"; import { AppText as Text, AppTextInput as TextInput } from "../../components/AppText"; +import { resolveAndroidAgentNotificationsAvailability } from "../agent-awareness/androidNotifications"; import { supportsAgentAwarenessPush } from "../agent-awareness/capabilities"; import { setLiveActivityUpdatesEnabled } from "../agent-awareness/liveActivityPreferences"; import { requestAgentNotificationPermission } from "../agent-awareness/notificationPermissions"; import { + getAgentAwarenessPushDeliveryReady, getAgentAwarenessRegistrationStatus, refreshAgentAwarenessRegistration, subscribeAgentAwarenessRegistrationStatus, @@ -60,7 +62,11 @@ import { useSavedRemoteConnections } from "../../state/use-remote-environment-re import { SettingsRow } from "./components/SettingsRow"; import { SettingsSection } from "./components/SettingsSection"; import { SettingsSwitchRow } from "./components/SettingsSwitchRow"; -import { resolveAgentAwarenessPlatformPresentation } from "./SettingsRouteScreen.logic"; +import { + resolveAgentAwarenessPlatformPresentation, + resolveAgentAwarenessSignInMessage, + resolveAgentAwarenessSubtitle, +} from "./SettingsRouteScreen.logic"; type NotificationStatus = "checking" | "enabled" | "disabled" | "unsupported"; type LiveActivityStatus = "checking" | "enabled" | "disabled" | "signed-out" | "linking"; @@ -78,6 +84,17 @@ function useDeviceRegistered(): boolean { return status === "registered"; } +// Stricter than registration: the accepted registration must also carry a push +// token. A device registered without one (denied permission, a failed APNs/FCM +// token lookup) can receive nothing, so notification switches must read off. +function useDevicePushDeliveryReady(): boolean { + return useSyncExternalStore( + subscribeAgentAwarenessRegistrationStatus, + getAgentAwarenessPushDeliveryReady, + () => false, + ); +} + export function SettingsRouteScreen() { const navigation = useNavigation(); @@ -160,10 +177,10 @@ function ConfiguredSettingsRouteScreen() { const savePreferences = useAtomSet(updateMobilePreferencesAtom); const agentAwarenessPushAvailable = supportsAgentAwarenessPush(); const agentAwarenessPlatform = resolveAgentAwarenessPlatformPresentation(Platform.OS); - const agentAwarenessSubtitle = - Platform.OS === "android" && !agentAwarenessPushAvailable - ? "Install a newer app build to enable notifications" - : agentAwarenessPlatform.subtitle; + const agentAwarenessSubtitle = resolveAgentAwarenessSubtitle( + Platform.OS, + Platform.OS === "android" ? resolveAndroidAgentNotificationsAvailability() : undefined, + ); const insets = useSafeAreaInsets(); const navigation = useNavigation(); const { getToken, isLoaded, isSignedIn } = useAuth({ treatPendingAsSignedOut: false }); @@ -172,6 +189,11 @@ function ConfiguredSettingsRouteScreen() { const [notificationStatus, setNotificationStatus] = useState("checking"); const [liveActivityStatus, setLiveActivityStatus] = useState("checking"); const deviceRegistered = useDeviceRegistered(); + const devicePushDeliveryReady = useDevicePushDeliveryReady(); + // Android's ongoing activity card arrives over the same FCM token as alerts; + // iOS Live Activities register their own push-to-start token. + const activityUpdatesRegistered = + Platform.OS === "android" ? devicePushDeliveryReady : deviceRegistered; const liveActivitiesPreferenceEnabled = AsyncResult.isSuccess(preferencesResult) ? preferencesResult.value.liveActivitiesEnabled !== false : true; @@ -248,9 +270,15 @@ function ConfiguredSettingsRouteScreen() { if (result.value.type === "granted") { setNotificationStatus("enabled"); // Permission alone is not enough: the switch stays off until the relay - // registration succeeds, so tell the user the truth about which happened. - if (getAgentAwarenessRegistrationStatus() === "registered") { + // accepts a registration with a push token, so tell the user the truth + // about which happened. + if (getAgentAwarenessPushDeliveryReady()) { Alert.alert("Notifications enabled", "Agent notifications are enabled for this device."); + } else if (getAgentAwarenessRegistrationStatus() === "registered") { + Alert.alert( + "Couldn't finish enabling notifications", + "This device is registered with Pylon Connect, but it could not get a push token, so notifications can't be delivered yet.", + ); } else { Alert.alert( "Couldn't finish enabling notifications", @@ -283,17 +311,13 @@ function ConfiguredSettingsRouteScreen() { }, []); const promptSignIn = useCallback(() => { - Alert.alert( - "Sign in to Pylon Connect", - "Live Activity updates require Pylon Connect so relay can deliver updates to this device.", - [ - { text: "Cancel", style: "cancel" }, - { - text: "Continue", - onPress: () => navigation.navigate("SettingsSheet", { screen: "SettingsAuth" }), - }, - ], - ); + Alert.alert("Sign in to Pylon Connect", resolveAgentAwarenessSignInMessage(Platform.OS), [ + { text: "Cancel", style: "cancel" }, + { + text: "Continue", + onPress: () => navigation.navigate("SettingsSheet", { screen: "SettingsAuth" }), + }, + ]); }, [navigation]); const linkEnvironments = useCallback(async () => { @@ -374,9 +398,13 @@ function ConfiguredSettingsRouteScreen() { refreshManagedRelayEnvironments(); setLiveActivityStatus("enabled"); // The environment link can succeed while this device's own registration - // (the push-to-start token the relay needs) has not — don't claim Live - // Activities are live until the device is actually registered. - if (getAgentAwarenessRegistrationStatus() === "registered") { + // (the push-to-start or FCM token the relay needs) has not — don't claim + // activity updates are live until the device can actually receive them. + if ( + Platform.OS === "android" + ? getAgentAwarenessPushDeliveryReady() + : getAgentAwarenessRegistrationStatus() === "registered" + ) { Alert.alert( Platform.OS === "android" ? "Ongoing activity enabled" : "Live Activities enabled", environmentCount > 0 @@ -529,11 +557,13 @@ function ConfiguredSettingsRouteScreen() { notificationStatus === "unsupported" } subtitle={agentAwarenessSubtitle} - // Only reads as on when this device is actually registered with the - // relay; otherwise notifications cannot be delivered regardless of - // the local iOS permission. + // Only reads as on when the relay accepted this device with a push + // token; otherwise notifications cannot be delivered regardless of + // the local permission. value={ - agentAwarenessPushAvailable && notificationStatus === "enabled" && deviceRegistered + agentAwarenessPushAvailable && + notificationStatus === "enabled" && + devicePushDeliveryReady } onValueChange={handleDeviceNotificationsChange} /> @@ -553,7 +583,7 @@ function ConfiguredSettingsRouteScreen() { value={ agentAwarenessPushAvailable && (liveActivityStatus === "enabled" || liveActivityStatus === "linking") && - deviceRegistered + activityUpdatesRegistered } onValueChange={handleLiveActivitiesChange} /> diff --git a/apps/web/src/components/clerk/MobileClientsUserProfilePage.logic.test.ts b/apps/web/src/components/clerk/MobileClientsUserProfilePage.logic.test.ts index 1465159bb9..c623df58b1 100644 --- a/apps/web/src/components/clerk/MobileClientsUserProfilePage.logic.test.ts +++ b/apps/web/src/components/clerk/MobileClientsUserProfilePage.logic.test.ts @@ -2,6 +2,7 @@ import type { RelayClientDeviceRecord } from "@t3tools/contracts/relay"; import { describe, expect, it } from "vite-plus/test"; import { + mobileClientActivityLabel, mobileClientNotificationDetail, mobileClientPlatformLabel, mobileClientUpdatedAtLabel, @@ -45,6 +46,15 @@ describe("mobile client presentation", () => { ).toBe("Android · Pylon 1.2.3"); }); + it("names each platform's activity surface", () => { + expect(mobileClientActivityLabel(device())).toBe("Live Activities"); + expect( + mobileClientActivityLabel( + device({ platform: "android", iosMajorVersion: null, androidApiLevel: 36 }), + ), + ).toBe("Ongoing activity"); + }); + it("distinguishes disabled notifications from an empty event selection", () => { expect( mobileClientNotificationDetail( diff --git a/apps/web/src/components/clerk/MobileClientsUserProfilePage.logic.ts b/apps/web/src/components/clerk/MobileClientsUserProfilePage.logic.ts index e8b96b0184..9d9dc38c3b 100644 --- a/apps/web/src/components/clerk/MobileClientsUserProfilePage.logic.ts +++ b/apps/web/src/components/clerk/MobileClientsUserProfilePage.logic.ts @@ -24,6 +24,11 @@ export function mobileClientPlatformLabel(device: RelayClientDeviceRecord): stri return `${platform}${device.appVersion ? ` · Pylon ${device.appVersion}` : ""}`; } +/** Android shows an ongoing notification card; iOS shows a Live Activity. */ +export function mobileClientActivityLabel(device: RelayClientDeviceRecord): string { + return device.platform === "android" ? "Ongoing activity" : "Live Activities"; +} + export function mobileClientNotificationDetail(device: RelayClientDeviceRecord): string { if (!device.notifications.enabled) { return "Push notifications are disabled on this device."; diff --git a/apps/web/src/components/clerk/MobileClientsUserProfilePage.tsx b/apps/web/src/components/clerk/MobileClientsUserProfilePage.tsx index 58e2b609cf..154c5caff3 100644 --- a/apps/web/src/components/clerk/MobileClientsUserProfilePage.tsx +++ b/apps/web/src/components/clerk/MobileClientsUserProfilePage.tsx @@ -7,6 +7,7 @@ import { Button } from "../ui/button"; import { Empty, EmptyDescription, EmptyHeader, EmptyMedia, EmptyTitle } from "../ui/empty"; import { Skeleton } from "../ui/skeleton"; import { + mobileClientActivityLabel, mobileClientNotificationDetail, mobileClientPlatformLabel, mobileClientUpdatedAtLabel, @@ -54,7 +55,10 @@ function MobileClientRow({ device }: { readonly device: RelayClientDeviceRecord enabled={device.notifications.enabled} label="Push notifications" /> - +

{mobileClientNotificationDetail(device)} @@ -94,7 +98,8 @@ function EmptyMobileClients() { No mobile clients - Sign in to Pylon on your iPhone to register it for push notifications and Live Activities. + Sign in to Pylon on your phone to register it for push notifications and agent activity + updates. diff --git a/docs/README.md b/docs/README.md index 33e645622d..960609d47e 100644 --- a/docs/README.md +++ b/docs/README.md @@ -11,6 +11,7 @@ - [Product usage data](./user/telemetry.md) - [Customize a project icon](./user/project-settings.md) - [Mobile appearance](./user/mobile-appearance.md) +- [Mobile notifications](./user/mobile-notifications.md) - [Environment themes](./user/environment-theme.md) - [Remote access](./user/remote-access.md) - [Keeping app and server in sync](./user/updating.md) @@ -55,6 +56,7 @@ policy in [CONTRIBUTING.md](../CONTRIBUTING.md); agent rules in [AGENTS.md](../A - [Model manifest publishing](./operations/model-manifest.md) - [Observability](./operations/observability.md) - [Relay observability](./operations/relay-observability.md) +- [Android notifications](./operations/android-notifications.md) - [Prime Agent managed rollback](./operations/prime-agent-managed-rollback.md) - [Prime artifact graduation](./operations/prime-artifact-graduation.md) - [Rollback manual recovery](./operations/rollback-manual-recovery.md) diff --git a/docs/operations/android-notifications.md b/docs/operations/android-notifications.md index adac44d513..2c495cf261 100644 --- a/docs/operations/android-notifications.md +++ b/docs/operations/android-notifications.md @@ -3,9 +3,19 @@ The Android app receives Firebase Cloud Messaging (FCM) data messages. The Pylon Connect relay sends them directly through FCM HTTP v1; an Expo Push account is not required. -Nothing here is provisioned by default. Until a Firebase project, an Android build with its Google -services file, and the relay's `FCM_SERVICE_ACCOUNT` secret all exist, the relay logs that Android -notifications are not configured and skips Android deliveries. iOS delivery is unaffected. +Nothing here is provisioned by default, and each missing piece degrades on its own: + +- An Android build without `T3CODE_ANDROID_GOOGLE_SERVICES_FILE` publishes + `extra.androidPushConfigured: false`. Settings keeps **Device Notifications** and **Ongoing Agent + Activity** off and disabled with "This app build can't receive notifications", and the app does + not create its agent notification channels. The device still registers with the relay, without a push + token. +- A relay without the `FCM_SERVICE_ACCOUNT` secret logs that Android notifications are not + configured and skips Android deliveries. + +On both platforms, **Device Notifications** (and Android's **Ongoing Agent Activity**) reads on only +after the relay accepts a registration that carries a push token, so a failed APNs or FCM token +lookup leaves it off. iOS delivery does not depend on any of the Android setup. ## Android compatibility and automated checks @@ -232,6 +242,24 @@ succeeds and Android deliveries are skipped. The release Android build also need package's `google-services.json` in its native build environment; changing the relay secret alone cannot move an installed app to another Firebase project. +#### Rolling back + +Once any Android device has registered, redeploying a relay from before Android support is not a +safe revert. The older worker cannot encode Android device rows, so the web and desktop **Mobile +clients** page fails to load. It also treats every registered token as an APNs token: it sends FCM +tokens to APNs, and APNs' `BadDeviceToken` handling then clears those tokens. + +Fix forward instead. If the older worker must be redeployed, first delete the Android rows from the +relay database: + +```sql +DELETE FROM relay_mobile_devices WHERE platform = 'android'; +``` + +No other relay table references those rows. Affected phones register again, and their Android +settings reset, the next time they open a relay build that supports Android. The additive +`20260906042516_android_devices` migration does not need to be reversed. + Android delivery uses `RelayFcmDeliveryQueue` and its dead-letter queue. Failed requests are retried; messages expire after five minutes. Before sending, the consumer rechecks the device token, current preferences, environment links, and current thread state. `UNREGISTERED` responses diff --git a/docs/user/mobile-notifications.md b/docs/user/mobile-notifications.md index 7366215f06..0e92da21be 100644 --- a/docs/user/mobile-notifications.md +++ b/docs/user/mobile-notifications.md @@ -4,23 +4,31 @@ Sign in to Pylon Connect, link your environments, and enable **Device Notificati receive alerts when an agent finishes, fails, needs approval, or asks for input. Tap a notification to open its thread. Your environment must have agent activity publishing enabled. -Enable **Ongoing Agent Activity** on Android or **Live Activity Updates** on iOS to follow work -without opening the app. Finished results remain visible for up to 15 minutes. You can dismiss an -Android activity card without disabling alerts; turn off ongoing activity in Settings to stop future -cards. +Enable **Live Activity Updates** on iOS, or **Ongoing Agent Activity** on Android, to follow work +without opening the app. Finished results remain visible for up to 15 minutes. -Ordinary alerts stay quiet while the mobile app is in the foreground. Ongoing activity continues to -update. Viewing a thread on another device does not silence your phone's alerts. +A switch reads on only when this device can actually receive notifications. If Pylon Connect +registered the device but it could not get a push token from the system, the switch stays off and +Pylon tells you why when you try to turn it on. -Android notifications require Android 7.0 or newer and Google Play services. Android 16 and newer -can promote ongoing activity to a Live Update, subject to system settings and device support. Other -devices show a regular ongoing notification. Android 7's battery-saving modes can delay removal of -expired cards. +Ordinary alerts stay quiet while the mobile app is in the foreground. Activity updates continue. +Viewing a thread on another device does not silence your phone's alerts. -Notification permission and Android notification channels are controlled in system Settings. Background delivery requires Pylon Connect; a direct or Tailscale connection alone does not enable push notifications. The mobile app does not need to maintain a connection to your environment. -Force-stopping the Android app in system Settings prevents push delivery until you open it again. +Notification permission is controlled in system Settings. -If Settings on Android says to install a newer app build, update Pylon Mobile to turn on -notifications. +## Android + +Android notifications work only in Pylon Mobile builds that include push support. In other builds, +both switches stay off with **This app build can't receive notifications**. If Settings says to +install a newer app build instead, update Pylon Mobile. + +Where push is supported, Android notifications require Android 7.0 or newer and Google Play +services. You can dismiss an activity card without disabling alerts; turn off **Ongoing Agent +Activity** in Settings to stop future cards. Android 16 and newer can promote ongoing activity to a +Live Update, subject to system settings and device support; other devices show a regular ongoing +notification. Android 7's battery-saving modes can delay removal of expired cards. + +Android notification channels are controlled in system Settings. Force-stopping the app in system +Settings prevents push delivery until you open it again.