Skip to content
Open
Show file tree
Hide file tree
Changes from all 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
6 changes: 6 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -22,3 +22,9 @@
# T3CODE_MOBILE_OTLP_TRACES_URL=https://api.axiom.co/v1/traces
# T3CODE_MOBILE_OTLP_TRACES_DATASET=t3-code-mobile-traces-dev
# T3CODE_MOBILE_OTLP_TRACES_TOKEN=xaat-...

# Optional: Android push notifications (T3 Connect) for local mobile builds.
# Path to the Firebase google-services.json registering the build's Android
# package. EAS builds get this from the EAS environment instead (a file
# variable with sensitive visibility); see apps/mobile/README.md.
# T3CODE_ANDROID_GOOGLE_SERVICES_FILE=/absolute/path/to/google-services.json
18 changes: 18 additions & 0 deletions .github/workflows/mobile-eas-preview.yml
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,24 @@ jobs:
EXPO_TOKEN: ${{ secrets.EXPO_TOKEN }}
run: eas env:pull preview --non-interactive

# The Android fingerprint must include google-services.json: computing it
# without the file while the EAS build worker injects it would produce a
# runtime-version mismatch, and a build without it ships an APK whose
# push notifications (T3 Connect) silently never work.
- name: Require Android push credentials
if: steps.expo-token.outputs.present == 'true'
working-directory: apps/mobile
run: |
file_path="$(grep -m1 '^T3CODE_ANDROID_GOOGLE_SERVICES_FILE=' .env.local | cut -d= -f2- | sed -e 's/^"//' -e 's/"$//')"
if [ -z "$file_path" ]; then
echo "::error::T3CODE_ANDROID_GOOGLE_SERVICES_FILE is missing from the EAS preview environment. Upload the Firebase google-services.json for com.t3tools.t3code.preview as a file variable with sensitive visibility: eas env:create --environment preview --name T3CODE_ANDROID_GOOGLE_SERVICES_FILE --type file --visibility sensitive --value ./google-services.json"
exit 1
fi
if [ ! -f "$file_path" ]; then
echo "::error::T3CODE_ANDROID_GOOGLE_SERVICES_FILE points to '$file_path' but the file was not downloaded by 'eas env:pull preview'. Check that the variable has file type and sensitive (not secret) visibility so CI can read it."
exit 1
fi

- name: Deploy with fingerprint check
if: steps.expo-token.outputs.present == 'true'
uses: expo/expo-github-action/continuous-deploy-fingerprint@main
Expand Down
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -32,3 +32,6 @@ node_modules/
*.log
.env*
!.env.example
# Firebase Android push credentials pulled from EAS; never commit them.
google-services*.json
.eas/
27 changes: 27 additions & 0 deletions apps/mobile/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,33 @@ For preview or production EAS environments, set `T3CODE_CLERK_PUBLISHABLE_KEY`,
`T3CODE_CLERK_JWT_TEMPLATE`, and `T3CODE_RELAY_URL`
as EAS environment variables. Expo config maps the canonical values into the mobile build.

### Android push notifications (T3 Connect)

Android delivery goes app → Expo push token → T3 relay → Expo Push Service → FCM, so the APK
must embed a Firebase `google-services.json` that registers the variant's Android package
(for preview: `com.t3tools.t3code.preview`). Expo config wires it through
`T3CODE_ANDROID_GOOGLE_SERVICES_FILE`:

1. In the Firebase project backing the existing EAS Android credentials, add an Android app for
the package and download its `google-services.json`.
2. Upload it to the EAS environment as a **file** variable with **sensitive** visibility (CI
pulls it with `eas env:pull` to compute the native fingerprint; secret files cannot be
pulled):

```bash
eas env:create --environment preview --name T3CODE_ANDROID_GOOGLE_SERVICES_FILE \
--type file --visibility sensitive --value ./google-services.json
```

3. In EAS Credentials, assign the matching FCM V1 service-account key to the Android app so the
Expo Push Service can deliver through FCM.

Android EAS preview builds fail fast when the variable is missing — an APK built without it
installs fine but can never obtain a push token, which is why this is enforced. Development
builds skip the check (no Firebase app is registered for `com.t3tools.t3code.dev`). Adding the
file changes the native fingerprint, so shipping it requires a new native build, not an OTA
update.

Create a PR preview dev-client build manually:

```bash
Expand Down
19 changes: 19 additions & 0 deletions apps/mobile/app.config.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import type { ExpoConfig } from "expo/config";

import { resolveAndroidGoogleServicesFile } from "../../scripts/lib/android-google-services.ts";
import { loadRepoEnv } from "../../scripts/lib/public-config.ts";

type AppVariant = "development" | "preview" | "production";
Expand Down Expand Up @@ -77,6 +78,16 @@ function resolveAppVariant(value: string | undefined): AppVariant {

const variant = VARIANT_CONFIG[APP_VARIANT];

// google-services.json wires FCM into the Android build; without it Expo
// cannot mint an Android push token and T3 Connect notifications silently
// break. The resolver fails Android EAS preview builds when the EAS file
// variable is missing so that APK can never build again.
const androidGoogleServicesFile = resolveAndroidGoogleServicesFile({
env: repoEnv,
appVariant: APP_VARIANT,
androidPackage: variant.androidPackage,
});

const dmSansFonts = {
regular: "@expo-google-fonts/dm-sans/400Regular/DMSans_400Regular.ttf",
medium: "@expo-google-fonts/dm-sans/500Medium/DMSans_500Medium.ttf",
Expand Down Expand Up @@ -153,6 +164,7 @@ const config: ExpoConfig = {
android: {
icon: "./assets/icon.png",
package: variant.androidPackage,
...(androidGoogleServicesFile ? { googleServicesFile: androidGoogleServicesFile } : {}),
adaptiveIcon: {
backgroundColor: "#E6F4FE",
foregroundImage: "./assets/android-icon-foreground.png",
Expand Down Expand Up @@ -195,6 +207,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
Loading
Loading