diff --git a/.github/workflows/deploy-relay.yml b/.github/workflows/deploy-relay.yml
index f652844a54f3..25e744a42968 100644
--- a/.github/workflows/deploy-relay.yml
+++ b/.github/workflows/deploy-relay.yml
@@ -63,6 +63,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 217334fd754a..f34f9c4b39d8 100644
--- a/apps/mobile/app.config.ts
+++ b/apps/mobile/app.config.ts
@@ -186,7 +186,7 @@ const config: ExpoConfig = {
icon: variant.assets.appIcon,
userInterfaceStyle: "automatic",
updates: {
- enabled: true,
+ enabled: repoEnv.T3CODE_MOBILE_UPDATES_ENABLED !== "0",
url: "https://u.expo.dev/d763fcb8-d37c-41ea-a773-b54a0ab4a454",
checkAutomatically: "ON_LOAD",
fallbackToCacheTimeout: 0,
@@ -237,6 +237,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,
...(variant.assets.androidAdaptiveBackgroundImage
@@ -356,6 +359,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 000000000000..34b1d92593f1
--- /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 000000000000..a7e07f45ec73
--- /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 000000000000..d1b81661151c
--- /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", "t3code") ?: "t3code"
+ 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 000000000000..d394db56115f
--- /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 000000000000..734fbbc2f3b2
--- /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", "t3code-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("t3code-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", "t3code-dev", true)
+ assertEquals(2, manager.activeNotifications.size)
+ AgentNotifications.dismiss(context)
+ AgentNotifications.configure(context, "device", "user", "t3code-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", "t3code-dev", true)
+ AgentNotifications.receive(context, update("attention", true))
+ assertTrue(manager.activeNotifications.isEmpty())
+ AgentNotifications.configure(context, "different-device", "user", "t3code-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(
+ "t3code-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", "t3code-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", "t3code-dev", false)
+ AgentNotifications.configure(context, "device", "user", "t3code-dev", true)
+ AgentNotifications.receive(context, update("work", true))
+ assertEquals(1, alarms.scheduledAlarms.size)
+ AgentNotifications.configure(context, "device", "user", "t3code-dev", false)
+ assertTrue(alarms.scheduledAlarms.isEmpty())
+ AgentNotifications.configure(context, "device", "user", "t3code-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 000000000000..2ac57ee280a9
--- /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 9a2925ccd9cf..d5285f263fd7 100644
--- a/apps/mobile/package.json
+++ b/apps/mobile/package.json
@@ -145,6 +145,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 000000000000..5bb472d3a4fa
--- /dev/null
+++ b/apps/mobile/src/features/agent-awareness/androidNotifications.test.ts
@@ -0,0 +1,63 @@
+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: ["t3code-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", "t3code-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 000000000000..a65ff1758e78
--- /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) ?? "t3code",
+ 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 d627f365754d..e439c89741dd 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 000000000000..def7443d543e
--- /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 423d5c1ba721..eddee8b25091 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.TaggedError = 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 8279a27fee62..629ec5f60abb 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 39bce6b4b90d..bd4109c275e6 100644
--- a/apps/mobile/src/features/agent-awareness/remoteRegistration.test.ts
+++ b/apps/mobile/src/features/agent-awareness/remoteRegistration.test.ts
@@ -5,10 +5,16 @@ 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";
-import { FetchHttpClient } from "effect/unstable/http";
+import {
+ Cookies,
+ FetchHttpClient,
+ HttpClientRequest,
+ HttpClientResponse,
+} from "effect/unstable/http";
import { ManagedRelay } from "@t3tools/client-runtime/relay";
import type { EnvironmentId } from "@t3tools/contracts";
@@ -39,8 +45,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 +156,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 +257,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 +951,201 @@ 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* () {
+ // Hermes' compiled error hashing reads the response's cookie getter.
+ const httpResponse = HttpClientResponse.fromWeb(
+ HttpClientRequest.post("https://relay.example.test/v1/mobile/devices"),
+ rejectedResponse,
+ );
+ expect(httpResponse.cookies).toEqual(Cookies.empty);
+ 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 14b1769c670c..5b4ca2423249 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 aec583d67f73..3e0ead2ce52b 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 94fa5965e994..6cdf8a3e1f86 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 57e6ab30a1d8..77036c212517 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 T3 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 T3 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 T3 Code.",
+ "Open system Settings to disable notifications for T3 Code.",
[
{ 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 fcc660e8305a..979f57cff16a 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 · T3 Code 1.2.3");
+ });
+
it("distinguishes disabled notifications from an empty event selection", () => {
expect(
mobileClientNotificationDetail(
@@ -59,6 +67,9 @@ describe("mobile client presentation", () => {
});
it("handles missing app versions and invalid update timestamps", () => {
+ expect(mobileClientPlatformLabel(device({ iosMajorVersion: null }))).toBe(
+ "iOS · T3 Code 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 5ca9595bef4d..b52d381f6ddf 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 ? ` · T3 Code ${device.appVersion}` : ""}`;
+ const platform =
+ device.platform === "android"
+ ? "Android"
+ : device.iosMajorVersion === null
+ ? "iOS"
+ : `iOS ${device.iosMajorVersion}`;
+ return `${platform}${device.appVersion ? ` · T3 Code ${device.appVersion}` : ""}`;
}
export function mobileClientNotificationDetail(device: RelayClientDeviceRecord): string {
diff --git a/docs/operations/android-notifications.md b/docs/operations/android-notifications.md
new file mode 100644
index 000000000000..48822f91a41d
--- /dev/null
+++ b/docs/operations/android-notifications.md
@@ -0,0 +1,121 @@
+# Android notifications
+
+The Android app receives Firebase Cloud Messaging (FCM) data messages. The relay sends them directly through FCM HTTP v1; an Expo Push account is not required.
+
+## Android compatibility and automated checks
+
+The app's minimum is Android 7.0 (API 24), declared in `app.config.ts` and enforced by the relay's device-registration schema. Compile/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 existing Mobile Native Static Analysis job separately runs ktlint and detekt. The native fingerprint check marks this change as requiring a new binary; the production workflow cannot deliver it to an older binary by OTA. 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 and register each Android application identifier you intend to build: `com.t3tools.t3code.dev`, `com.t3tools.t3code.preview`, or `com.t3tools.t3code`.
+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.
+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 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 hosted relay, configure the build's T3 Connect public settings for that relay and Clerk application as described in [T3 Connect](../internals/t3-connect.md).
+
+Set `T3CODE_MOBILE_UPDATES_ENABLED=0` before prebuild and bundling a private binary to disable the repository's configured Expo OTA update source. 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.t3tools.t3code.dev.callback
+```
+
+The app already declares the matching callback receiver. 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](./connect-setup.md#android-native-sign-in-redirects) for the other variants.
+
+Using T3's existing production publishable key selects the maintainers' Clerk instance. It grants no access to change that instance's allowlist. The chosen package's callback must already be allowed or be added by 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.t3tools.t3code` and its corresponding 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 official installation signed by the maintainer 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 with existing T3 services
+
+You do not need to duplicate T3 Connect's hosted infrastructure to develop Android push. Keep the normal Clerk login and environment connections. `scripts/android-push-watch.ts` subscribes to one paired environment's shell stream, uses the shared agent-awareness projection, and sends updates through the new 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 the existing hosted relay. The hosted relay needs the changes below before its notification settings and delivery work end to end.
+
+### Hosted delivery
+
+The existing Alchemy deployment provisions Cloudflare Workers, delivery queues, Hyperdrive, tunnel/DNS resources, PlanetScale Postgres, and Axiom observability by default. It requires credentials for the enabled services and private Clerk configuration; the repository's public app settings do not grant deployment access. Set `APNS_ENABLED=false` in an Android-only development relay to skip Apple delivery and its credential requirements. APNs remains enabled by default.
+
+#### Personal stage in the existing deployment accounts
+
+A maintainer with access to the existing Alchemy state and deployment credentials can deploy the Android changes to a personal stage. Non-production stages reference the retained database and DNS zones owned by the `prod` stage, create a separate PlanetScale branch, and apply migrations to that branch. A personal stage is therefore not a standalone deployment into an unrelated account.
+
+1. Apply the Android changes to a checkout with the existing deployment credentials. Create a private `infra/relay/.env.android-dev` using the existing Cloudflare, PlanetScale, Axiom, domain, and Clerk configuration described in the [relay README](../../infra/relay/README.md#deployment-ci). 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 this 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_ryan_android --env-file .env.android-dev --dry-run
+ vp run --filter t3code-relay deploy --stage dev_ryan_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 existing Firebase Android file, and OTA updates disabled. If using the separate development package, authorize its Clerk callback as described above.
+5. Configure one isolated T3 server with the same relay URL and link that test environment through the new relay. Existing production relay links do not automatically 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.
+
+The maintainer can perform deployment themselves and return only the public client configuration; the tester does not need copies of their hosting or Clerk server credentials. A fully independent deployment needs its own initial Cloudflare stack, PostgreSQL database, Firebase project, and a Clerk instance the operator can configure. Its Alchemy deployment needs PlanetScale and Axiom credentials.
+
+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 T3 home separate from an existing installation. Signing into the phone alone does not link a host environment. Use the host client's T3 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`; the repository's production CLI client ID belongs to the maintainers' instance.
+
+For deployment through GitHub Actions, add `FCM_SERVICE_ACCOUNT` to the `production` environment's secrets. The relay workflow passes it to Alchemy. The maintainer must also supply `google-services.json` for the production Android package in the native build environment; changing the relay secret alone cannot move an installed app to another Firebase project.
+
+Android delivery uses `RelayFcmDeliveryQueue` and a separate 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/operations/connect-setup.md b/docs/operations/connect-setup.md
index 49b93e49c9c4..86697d2798c9 100644
--- a/docs/operations/connect-setup.md
+++ b/docs/operations/connect-setup.md
@@ -74,6 +74,18 @@ Development uses `t3code-dev://app`; production uses `t3code://app`. Update the
The Clerk Electron integration handles token
persistence and system-browser callback delivery.
+## Android native sign-in redirects
+
+Clerk's native Android SDK uses `clerk://.callback`. In the Clerk instance selected by the app's publishable key, add each supported package to **Native applications > Allowlist for mobile SSO redirect**:
+
+| Variant | Callback |
+| ----------- | --------------------------------------------- |
+| Development | `clerk://com.t3tools.t3code.dev.callback` |
+| Preview | `clerk://com.t3tools.t3code.preview.callback` |
+| Production | `clerk://com.t3tools.t3code.callback` |
+
+Preserve existing entries. These callbacks are separate from the `t3code-dev` / `t3code-preview` / `t3code` navigation schemes. A private development build using the production Clerk key still needs its development callback allowed by that instance's administrator; rebuilding the same package does not change the allowlist.
+
## Desktop passkeys
For a production macOS app with bundle ID `com.t3tools.t3code`:
diff --git a/docs/user/mobile-notifications.md b/docs/user/mobile-notifications.md
new file mode 100644
index 000000000000..3a8d61d1cb7e
--- /dev/null
+++ b/docs/user/mobile-notifications.md
@@ -0,0 +1,11 @@
+# Mobile notifications
+
+Sign in to T3 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 T3 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.
diff --git a/infra/relay/.env.example b/infra/relay/.env.example
index 7ab2a5d6e44f..f885bff17c0c 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 37b1a631970d..33bb0ba74352 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.
@@ -150,6 +151,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 000000000000..85fa040da625
--- /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 000000000000..9d89ffe61a17
--- /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 32dd40290c1f..94d1d5036a4a 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": "tsc --noEmit"
diff --git a/infra/relay/scripts/android-push-smoke.ts b/infra/relay/scripts/android-push-smoke.ts
new file mode 100644
index 000000000000..0c2078159ae7
--- /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.TaggedError()("SmokeUsageError", {}) {
+ override get message() {
+ return "Usage: node scripts/android-push-smoke.ts ";
+ }
+}
+
+class SmokeCredentialReadError extends Schema.TaggedError()(
+ "SmokeCredentialReadError",
+ { cause: Schema.Defect() },
+) {
+ override get message() {
+ return "Could not read service-account file.";
+ }
+}
+
+class SmokeDeviceReadError extends Schema.TaggedError()(
+ "SmokeDeviceReadError",
+ { cause: Schema.Defect() },
+) {
+ override get message() {
+ return "Could not read device file.";
+ }
+}
+
+class SmokeUnregisteredDeviceError extends Schema.TaggedError()(
+ "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: "T3 Code 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 000000000000..2b902b33f946
--- /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.TaggedError()(
+ "WatchUnregisteredDeviceError",
+ {},
+) {
+ override get message() {
+ return "Device token is no longer registered";
+ }
+}
+
+class WatchStoppedError extends Schema.TaggedError()("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 e7c7d42f2ae1..1f9872c08f23 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 000000000000..72358a027324
--- /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 a70a85a8e6ed..ae3d4e4ad24d 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 063da58a90b3..61a420dd7858 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 0d41bfce781a..10ef32c9f576 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 45620ebbfa8b..a08404630ded 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,
@@ -840,6 +850,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",
@@ -1077,6 +1097,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,
@@ -1096,6 +1117,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 3723bf4778b9..da6e410618cd 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 000000000000..3c804cd8ab4f
--- /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.TaggedError()(
+ "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 000000000000..90818f92f079
--- /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 000000000000..eb8d327f7a5e
--- /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.TaggedError()("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 000000000000..9e4868b7f1b2
--- /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 000000000000..8f998d9d9574
--- /dev/null
+++ b/infra/relay/src/agentActivity/FcmDeliveries.ts
@@ -0,0 +1,345 @@
+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.TaggedError()("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 000000000000..dd97edcd6a35
--- /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 000000000000..3d9a731082b7
--- /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 94c583c6d65d..0c88fbb73c39 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 ca39484373e9..74d5c3740a51 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 000000000000..798714d4665b
--- /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 222deafd621b..66b87e643a48 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 62a38d666b2f..fcaeca640420 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 61b72f2df868..d9a7074bc01d 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 bfae5ced4b28..0ca0dbd0b4e1 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 2755c416fe09..caa5425b083f 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),
+ ),
+ );
+
yield* Cloudflare.Workers.cron("*/5 * * * *", () =>
DpopProofs.DpopProofReplay.pipe(
Effect.flatMap((dpopProofs) => dpopProofs.pruneExpired),
diff --git a/packages/client-runtime/src/relay/managedRelay.test.ts b/packages/client-runtime/src/relay/managedRelay.test.ts
index 2699c4ad0803..b66f91b00961 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 3229549b0867..eadece417681 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 4ad600953b9e..441fecd33268 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 7b1fbb73255b..8b6562497681 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,