Skip to content
Open
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions apps/mobile/app.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -195,6 +195,13 @@ const config: ExpoConfig = {
],
"expo-secure-store",
"expo-sqlite",
[
"expo-notifications",
{
color: "#2563EB",
defaultChannel: "t3-connect-activity",
},
],
// appleSignIn must be gated here: withoutIosPersonalTeamCapabilities.cjs runs before
// plugins earlier in this array, so it cannot strip the entitlement Clerk would add.
["@clerk/expo", { theme: "./clerk-theme.json", appleSignIn: !isIosPersonalTeamBuild }],
Expand Down
12 changes: 5 additions & 7 deletions apps/mobile/src/connection/platform.ts
Original file line number Diff line number Diff line change
Expand Up @@ -125,14 +125,12 @@ const capabilitiesLayer = Layer.effectContext(
RelayDeviceIdentity,
RelayDeviceIdentity.of({
deviceId: storage.loadOrCreateAgentAwarenessDeviceId.pipe(
Effect.mapError(
(cause) =>
new ConnectionTransientError({
reason: "remote-unavailable",
detail: `Could not load the mobile device identity: ${String(cause)}`,
}),
),
Effect.map(Option.some),
Effect.catch((cause) =>
Effect.logWarning(
"Could not load the mobile device identity; connecting without agent awareness.",
).pipe(Effect.annotateLogs({ cause }), Effect.as(Option.none<string>())),
),
),
}),
),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,11 @@ vi.mock("expo-secure-store", () => ({
setItemAsync: vi.fn(),
}));

vi.mock("expo-crypto", () => ({
randomUUID: () => "00000000-0000-4000-a000-000000000001",
getRandomBytes: (length: number) => new Uint8Array(length),
}));

vi.mock("react-native", () => ({
Platform: { OS: "ios" },
}));
Expand Down
41 changes: 41 additions & 0 deletions apps/mobile/src/features/agent-awareness/notificationChannels.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import * as Notifications from "expo-notifications";
import { Platform } from "react-native";

export const ANDROID_ACTIVITY_CHANNEL_ID = "t3-connect-activity";
export const ANDROID_ALERTS_CHANNEL_ID = "t3-connect-alerts";

let configured = false;

export async function configureAgentAwarenessNotificationChannels(): Promise<void> {
if (configured || Platform.OS !== "android") return;

Notifications.setNotificationHandler({
handleNotification: async () => ({
shouldShowBanner: true,
shouldShowList: true,
// Background delivery follows the selected Android channel. Avoid a
// second sound decision when Expo invokes this handler in the foreground.
shouldPlaySound: false,
shouldSetBadge: false,
}),
});
await Promise.all([
Notifications.setNotificationChannelAsync(ANDROID_ACTIVITY_CHANNEL_ID, {
name: "T3 Connect activity",
description: "Quiet live status updates for active T3 Code agents.",
importance: Notifications.AndroidImportance.DEFAULT,
sound: null,
vibrationPattern: null,
showBadge: false,
}),
Notifications.setNotificationChannelAsync(ANDROID_ALERTS_CHANNEL_ID, {
name: "T3 Connect alerts",
description: "Approvals, input requests, completions, and failures.",
importance: Notifications.AndroidImportance.HIGH,
sound: "default",
vibrationPattern: [0, 250, 150, 250],
showBadge: true,
}),
]);
configured = true;
}
28 changes: 18 additions & 10 deletions apps/mobile/src/features/agent-awareness/notificationPermissions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import * as Notifications from "expo-notifications";
import * as Effect from "effect/Effect";
import * as Schema from "effect/Schema";
import { Platform } from "react-native";
import { configureAgentAwarenessNotificationChannels } from "./notificationChannels";

export type NotificationPermissionResult =
| { readonly type: "unsupported" }
Expand All @@ -15,7 +16,7 @@ export class NotificationPermissionReadError extends Schema.TaggedErrorClass<Not
},
) {
override get message(): string {
return "Failed to read notification permissions on iOS.";
return "Failed to read notification permissions.";
}
}

Expand All @@ -26,18 +27,23 @@ export class NotificationPermissionRequestError extends Schema.TaggedErrorClass<
},
) {
override get message(): string {
return "Failed to request notification permissions on iOS.";
return "Failed to request notification permissions.";
}
}

export const requestAgentNotificationPermission: Effect.Effect<
NotificationPermissionResult,
NotificationPermissionReadError | NotificationPermissionRequestError
> = Effect.gen(function* () {
if (Platform.OS !== "ios") {
if (Platform.OS !== "ios" && Platform.OS !== "android") {
return { type: "unsupported" };
}

yield* Effect.tryPromise({
try: () => configureAgentAwarenessNotificationChannels(),
catch: (cause) => new NotificationPermissionRequestError({ cause }),
});

const existing = yield* Effect.tryPromise({
try: () => Notifications.getPermissionsAsync(),
catch: (cause) => new NotificationPermissionReadError({ cause }),
Expand All @@ -52,13 +58,15 @@ export const requestAgentNotificationPermission: Effect.Effect<

const requested = yield* Effect.tryPromise({
try: () =>
Notifications.requestPermissionsAsync({
ios: {
allowAlert: true,
allowBadge: true,
allowSound: true,
},
}),
Platform.OS === "ios"
? Notifications.requestPermissionsAsync({
ios: {
allowAlert: true,
allowBadge: true,
allowSound: true,
},
})
: Notifications.requestPermissionsAsync(),
catch: (cause) => new NotificationPermissionRequestError({ cause }),
});
return requested.granted
Expand Down
11 changes: 8 additions & 3 deletions apps/mobile/src/features/agent-awareness/registrationPayload.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,13 +11,16 @@ export function resolveApsEnvironment(appVariant: unknown): "sandbox" | "product
}

export function makeRelayDeviceRegistrationRequest(input: {
readonly platform?: "ios" | "android";
readonly deviceId: string;
readonly label: string;
readonly iosMajorVersion: number;
readonly iosMajorVersion?: number;
readonly androidApiLevel?: number;
readonly appVersion?: string;
readonly bundleId?: string;
readonly apsEnvironment?: "sandbox" | "production";
readonly pushToken?: string;
readonly expoPushToken?: string;
readonly pushToStartToken?: string;
readonly notificationsEnabled: boolean;
readonly preferences: Preferences;
Expand All @@ -27,12 +30,14 @@ export function makeRelayDeviceRegistrationRequest(input: {
return {
deviceId: input.deviceId,
label: input.label,
platform: "ios",
iosMajorVersion: input.iosMajorVersion,
platform: input.platform ?? "ios",
...(input.iosMajorVersion === undefined ? {} : { iosMajorVersion: input.iosMajorVersion }),
...(input.androidApiLevel === undefined ? {} : { androidApiLevel: input.androidApiLevel }),
appVersion: input.appVersion,
...(input.bundleId ? { bundleId: input.bundleId } : {}),
...(input.apsEnvironment ? { apsEnvironment: input.apsEnvironment } : {}),
...(input.pushToken ? { pushToken: input.pushToken } : {}),
...(input.expoPushToken ? { expoPushToken: input.expoPushToken } : {}),
...(input.pushToStartToken ? { pushToStartToken: input.pushToStartToken } : {}),
preferences: {
liveActivitiesEnabled,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -262,6 +262,29 @@ describe("makeRelayDeviceRegistrationRequest", () => {
});
});

it("builds an Android registration with an Expo push token", () => {
expect(
makeRelayDeviceRegistrationRequest({
platform: "android",
deviceId: "device-1",
label: "Pixel",
androidApiLevel: 36,
appVersion: "1.0.0",
expoPushToken: "ExponentPushToken[test]",
notificationsEnabled: true,
preferences: { liveActivitiesEnabled: true },
}),
).toMatchObject({
platform: "android",
androidApiLevel: 36,
expoPushToken: "ExponentPushToken[test]",
preferences: {
liveActivitiesEnabled: true,
notificationsEnabled: true,
},
});
});

it("registers the app's APNs routing so the relay targets the right bundle", () => {
expect(
makeRelayDeviceRegistrationRequest({
Expand Down
Loading
Loading