Skip to content
Open
Show file tree
Hide file tree
Changes from 5 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