From c563169055e278d05bde35ca473b00ecb71067fc Mon Sep 17 00:00:00 2001 From: Ryan Hughes Date: Sun, 6 Sep 2026 16:05:37 -0400 Subject: [PATCH 01/17] feat(mobile): add Android agent notifications and ongoing activity --- .github/workflows/deploy-relay.yml | 1 + apps/mobile/app.config.ts | 5 +- .../android/build.gradle | 27 + .../android/src/main/AndroidManifest.xml | 13 + .../AgentNotifications.kt | 203 +++ .../T3AgentNotificationsModule.kt | 18 + .../AgentNotificationsTest.kt | 257 +++ .../expo-module.config.json | 4 + apps/mobile/package.json | 1 + .../agent-awareness/androidNotifications.ts | 31 + .../notificationPermissions.test.ts | 59 + .../notificationPermissions.ts | 17 +- .../agent-awareness/registrationPayload.ts | 10 +- .../remoteRegistration.test.ts | 176 +- .../agent-awareness/remoteRegistration.ts | 86 +- .../SettingsRouteScreen.logic.test.ts | 6 +- .../settings/SettingsRouteScreen.logic.ts | 4 +- .../features/settings/SettingsRouteScreen.tsx | 57 +- ...MobileClientsUserProfilePage.logic.test.ts | 8 + .../MobileClientsUserProfilePage.logic.ts | 3 +- docs/operations/android-notifications.md | 105 ++ docs/operations/connect-setup.md | 12 + docs/user/mobile-notifications.md | 19 + infra/relay/.env.example | 7 +- infra/relay/README.md | 8 +- .../migration.sql | 2 + .../snapshot.json | 1543 +++++++++++++++++ infra/relay/scripts/android-push-smoke.ts | 120 ++ infra/relay/scripts/android-push-watch.ts | 201 +++ infra/relay/src/Config.ts | 3 +- .../AgentActivityPublisher.test.ts | 78 +- .../agentActivity/AgentActivityPublisher.ts | 16 +- .../src/agentActivity/ApnsDeliveries.test.ts | 44 +- .../relay/src/agentActivity/ApnsDeliveries.ts | 22 + infra/relay/src/agentActivity/Devices.ts | 8 +- .../relay/src/agentActivity/FcmClient.test.ts | 176 ++ infra/relay/src/agentActivity/FcmClient.ts | 187 ++ .../src/agentActivity/FcmDeliveries.test.ts | 623 +++++++ .../relay/src/agentActivity/FcmDeliveries.ts | 357 ++++ .../relay/src/agentActivity/LiveActivities.ts | 4 +- .../agentActivity/MobileRegistrations.test.ts | 12 +- infra/relay/src/agentActivity/fcmPayloads.ts | 81 + infra/relay/src/http/Api.ts | 6 + infra/relay/src/persistence/schema.ts | 5 +- infra/relay/src/queues.ts | 5 + infra/relay/src/worker.ts | 73 +- packages/contracts/src/relay.test.ts | 39 +- packages/contracts/src/relay.ts | 19 +- 48 files changed, 4670 insertions(+), 91 deletions(-) create mode 100644 apps/mobile/modules/t3-agent-notifications/android/build.gradle create mode 100644 apps/mobile/modules/t3-agent-notifications/android/src/main/AndroidManifest.xml create mode 100644 apps/mobile/modules/t3-agent-notifications/android/src/main/java/expo/modules/t3agentnotifications/AgentNotifications.kt create mode 100644 apps/mobile/modules/t3-agent-notifications/android/src/main/java/expo/modules/t3agentnotifications/T3AgentNotificationsModule.kt create mode 100644 apps/mobile/modules/t3-agent-notifications/android/src/test/java/expo/modules/t3agentnotifications/AgentNotificationsTest.kt create mode 100644 apps/mobile/modules/t3-agent-notifications/expo-module.config.json create mode 100644 apps/mobile/src/features/agent-awareness/androidNotifications.ts create mode 100644 apps/mobile/src/features/agent-awareness/notificationPermissions.test.ts create mode 100644 docs/operations/android-notifications.md create mode 100644 docs/user/mobile-notifications.md create mode 100644 infra/relay/migrations/postgres/20260906042516_android_devices/migration.sql create mode 100644 infra/relay/migrations/postgres/20260906042516_android_devices/snapshot.json create mode 100644 infra/relay/scripts/android-push-smoke.ts create mode 100644 infra/relay/scripts/android-push-watch.ts create mode 100644 infra/relay/src/agentActivity/FcmClient.test.ts create mode 100644 infra/relay/src/agentActivity/FcmClient.ts create mode 100644 infra/relay/src/agentActivity/FcmDeliveries.test.ts create mode 100644 infra/relay/src/agentActivity/FcmDeliveries.ts create mode 100644 infra/relay/src/agentActivity/fcmPayloads.ts 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..b463c0a19ad2 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 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..2b4d5237043e --- /dev/null +++ b/apps/mobile/modules/t3-agent-notifications/android/build.gradle @@ -0,0 +1,27 @@ +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.14.1' +} 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..9434f013394b --- /dev/null +++ b/apps/mobile/modules/t3-agent-notifications/android/src/main/AndroidManifest.xml @@ -0,0 +1,13 @@ + + + + + + + + + + + + + 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..247a1b0dc58a --- /dev/null +++ b/apps/mobile/modules/t3-agent-notifications/android/src/main/java/expo/modules/t3agentnotifications/AgentNotifications.kt @@ -0,0 +1,203 @@ +package expo.modules.t3agentnotifications + +import android.app.NotificationChannel +import android.app.NotificationManager +import android.app.PendingIntent +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +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) + } +} + +/** 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) manager(context).cancel(ACTIVITY_TAG, ACTIVITY_ID) + channels(context) + } + + @Synchronized + fun clear(context: 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() + manager(context).cancel(ACTIVITY_TAG, ACTIVITY_ID) + } + + @Synchronized + fun receive(context: Context, data: Map) { + val prefs = context.getSharedPreferences(STORE, Context.MODE_PRIVATE) + if (!prefs.getBoolean("enabled", false) || data["device_id"] != prefs.getString("deviceId", null)) return + if (data["user_id"] != prefs.getString("userId", null)) return + val updatedAt = data["updated_at"]?.toLongOrNull() ?: return + if (System.currentTimeMillis() - updatedAt > MAX_MESSAGE_AGE_MS) return + channels(context) + val manager = manager(context) + val scheme = prefs.getString("scheme", null) ?: return + if (!NotificationManagerCompat.from(context).areNotificationsEnabled()) return + + // 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.getStringSet("seenAlerts", emptySet()).orEmpty() + 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.notify(ALERT_TAG, id, notification) + } + prefs.edit().putStringSet("seenAlerts", (seen.toList().takeLast(63) + alertId).toSet()).apply() + } + + // 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)) { + manager.cancel(ACTIVITY_TAG, ACTIVITY_ID) + 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)) return + 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) + .setRequestPromotedOngoing(active) + .setContentIntent(contentIntent(context, scheme, data["activity_path"], ACTIVITY_ID)) + .setDeleteIntent(dismissIntent) + .addAction(0, "Dismiss", dismissIntent) + .build() + manager.notify(ACTIVITY_TAG, ACTIVITY_ID, notification) + } + + 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) + .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 route = if (path != null && path.startsWith("/threads/") && !path.contains('?') && !path.contains('#')) path else "/" + val intent = context.packageManager.getLaunchIntentForPackage(context.packageName)!! + .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..ecb14fb82ffb --- /dev/null +++ b/apps/mobile/modules/t3-agent-notifications/android/src/main/java/expo/modules/t3agentnotifications/T3AgentNotificationsModule.kt @@ -0,0 +1,18 @@ +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..81456cdfee0e --- /dev/null +++ b/apps/mobile/modules/t3-agent-notifications/android/src/test/java/expo/modules/t3agentnotifications/AgentNotificationsTest.kt @@ -0,0 +1,257 @@ +package expo.modules.t3agentnotifications + +import android.app.Activity +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 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 = [35], 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 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 + assertTrue(card.timeoutAfter > 119 * 60 * 1000L) + assertTrue(card.timeoutAfter <= 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) + assertTrue(card.timeoutAfter in 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()) + } + } + +} 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.ts b/apps/mobile/src/features/agent-awareness/androidNotifications.ts new file mode 100644 index 000000000000..326c5c4389cb --- /dev/null +++ b/apps/mobile/src/features/agent-awareness/androidNotifications.ts @@ -0,0 +1,31 @@ +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 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/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..084ce7d3079e 100644 --- a/apps/mobile/src/features/agent-awareness/registrationPayload.ts +++ b/apps/mobile/src/features/agent-awareness/registrationPayload.ts @@ -13,7 +13,9 @@ export function resolveApsEnvironment(appVariant: unknown): "sandbox" | "product export function makeRelayDeviceRegistrationRequest(input: { readonly deviceId: string; readonly label: string; - readonly iosMajorVersion: number; + readonly platform?: "ios" | "android"; + readonly iosMajorVersion?: number; + readonly androidApiLevel?: number; readonly appVersion?: string; readonly bundleId?: string; readonly apsEnvironment?: "sandbox" | "production"; @@ -27,8 +29,10 @@ export function makeRelayDeviceRegistrationRequest(input: { 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..903d01054945 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,19 @@ 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", () => ({ + configureAndroidAgentNotifications: vi.fn(), + clearAndroidAgentNotifications: vi.fn(), +})); const secureStore = vi.hoisted(() => new Map()); const widgetMocks = vi.hoisted(() => ({ @@ -138,8 +155,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 +256,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(); @@ -967,4 +993,148 @@ describe("makeRelayDeviceRegistrationRequest", () => { ); }, ); + 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", + }) + : 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://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(getAgentAwarenessRegistrationStatus()).toBe("registered"); + expect(saveAgentAwarenessRegistrationRecord).toHaveBeenCalledTimes(1); + yield* refreshAgentAwarenessRegistration(); + expect(saveAgentAwarenessRegistrationRecord).toHaveBeenCalledTimes(1); + releaseAgentAwarenessRelayTokenProvider(); + setAgentAwarenessRelayTokenProvider(() => Promise.resolve("clerk-token"), "user-a"); + yield* runBackgroundOperations(); + 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..131f1f930d66 100644 --- a/apps/mobile/src/features/settings/SettingsRouteScreen.tsx +++ b/apps/mobile/src/features/settings/SettingsRouteScreen.tsx @@ -181,7 +181,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 +246,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 +259,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 +299,31 @@ function ConfiguredSettingsRouteScreen() { } setLiveActivityStatus("linking"); + if (Platform.OS === "android") { + const permission = await settleAsyncResult(() => + runtime.runPromiseExit(requestAgentNotificationPermission), + ); + if (permission._tag === "Failure" || 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 +348,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 +365,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 +389,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( @@ -512,7 +533,7 @@ function ConfiguredSettingsRouteScreen() { liveActivityStatus === "linking" } icon="bolt.circle" - label="Live Activity Updates" + label={Platform.OS === "android" ? "Ongoing Agent Activity" : "Live Activity Updates"} subtitle={agentAwarenessPlatform.subtitle} // Same gate: a saved preference is meaningless until the device // registration the relay needs to push updates has succeeded. diff --git a/apps/web/src/components/clerk/MobileClientsUserProfilePage.logic.test.ts b/apps/web/src/components/clerk/MobileClientsUserProfilePage.logic.test.ts index fcc660e8305a..531ca1d5a1bc 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( diff --git a/apps/web/src/components/clerk/MobileClientsUserProfilePage.logic.ts b/apps/web/src/components/clerk/MobileClientsUserProfilePage.logic.ts index 5ca9595bef4d..1a69425aa4fd 100644 --- a/apps/web/src/components/clerk/MobileClientsUserProfilePage.logic.ts +++ b/apps/web/src/components/clerk/MobileClientsUserProfilePage.logic.ts @@ -15,7 +15,8 @@ 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" : `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..7f6e2b4f2118 --- /dev/null +++ b/docs/operations/android-notifications.md @@ -0,0 +1,105 @@ +# 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. + +## 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 the build environment, using an EAS file variable for the Google services file. FCM 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 +node scripts/android-push-smoke.ts /path/service-account.json /path/device.json running +node scripts/android-push-smoke.ts /path/service-account.json /path/device.json approval +node scripts/android-push-smoke.ts /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 +node scripts/android-push-watch.ts /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..83684929f7cb 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..4c4d00eb8d86 --- /dev/null +++ b/docs/user/mobile-notifications.md @@ -0,0 +1,19 @@ +# 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. + +Individual alerts show the thread title, followed by the status and project name: **Approval: Project**, **Input: Project**, **Done: Project**, or **Failed: Project**. + +With ongoing activity enabled, several threads changing in the same update can produce one alert: **2 agents need attention** or **2 agents finished**, followed by their thread titles. On Android, tapping a grouped alert opens the priority thread: one needing attention first, then a failed thread, then other work. + +The mobile app suppresses ordinary alert notifications while it is in the foreground. Ongoing activity continues to update. Viewing a thread on another device does not silence your phone's alerts. + +On Android, **Ongoing Agent Activity** shows the active count and how many agents need attention. Expand the notification to see up to five threads, including each project and status. Threads needing attention appear first, followed by failures and other work. Supported Android versions can promote it to a Live Update on the lock screen and status bar. Android controls whether promotion is available and enabled. Other devices show a regular ongoing notification. + +You can dismiss the ongoing notification without disabling completion and attention alerts. Disabling Ongoing Agent Activity removes the current activity notification. Signing out clears T3's notifications from the device. Finished results stay visible for up to 15 minutes with an **Agent work completed** or **Agent work failed** heading. A failure takes priority when results are mixed. Dismissing a run keeps its finished card dismissed; new work can show a new card. + +Reopening the app preserves existing notifications and refreshes activity from T3 Connect. If an environment stops sending updates, working states expire after two hours and approval/input states after 24 hours. + +On iOS, enable **Live Activity Updates** to show agent status using Apple Live Activities. + +Notification permission and Android notification channels are controlled in system Settings. Background delivery uses T3 Connect and the platform's push service; 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..a885f4f0390d --- /dev/null +++ b/infra/relay/migrations/postgres/20260906042516_android_devices/snapshot.json @@ -0,0 +1,1543 @@ +{ + "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": [] +} \ No newline at end of file diff --git a/infra/relay/scripts/android-push-smoke.ts b/infra/relay/scripts/android-push-smoke.ts new file mode 100644 index 000000000000..c6f8d1c96c8e --- /dev/null +++ b/infra/relay/scripts/android-push-smoke.ts @@ -0,0 +1,120 @@ +// @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 { RelayConfiguration } from "../src/Config.ts"; +import { FcmClient, layer } 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 SmokeError extends Schema.TaggedErrorClass()("SmokeError", { + message: Schema.String, +}) {} + +const main = Effect.gen(function* () { + const [credentialPath, devicePath, phaseArg] = process.argv.slice(2); + if (!credentialPath || !devicePath || !phaseArg) + return yield* new SmokeError({ + message: + "Usage: node scripts/android-push-smoke.ts ", + }); + const phase = yield* decodePhase(phaseArg); + const credentials = yield* Effect.tryPromise({ + try: () => NodeFSP.readFile(credentialPath, "utf8"), + catch: () => new SmokeError({ message: "Could not read service-account file." }), + }); + const device = yield* Effect.tryPromise({ + try: () => NodeFSP.readFile(devicePath, "utf8"), + catch: () => new SmokeError({ message: "Could not read device file." }), + }).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["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.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( + layer.pipe( + Layer.provide( + Layer.mergeAll(Layer.succeed(RelayConfiguration, config), FetchHttpClient.layer), + ), + ), + ), + ); + if (result.unregistered) + return yield* new SmokeError({ + message: "This device token is no longer registered with Firebase.", + }); + 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..89c049f796e4 --- /dev/null +++ b/infra/relay/scripts/android-push-watch.ts @@ -0,0 +1,201 @@ +// @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 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 { RpcClient, RpcSerialization } from "effect/unstable/rpc"; +import * as Socket from "effect/unstable/socket/Socket"; + +import { RelayConfiguration } from "../src/Config.ts"; +import { androidActivityData, fitFcmData } from "../src/agentActivity/fcmPayloads.ts"; +import { FcmClient, layer as fcmLayer } from "../src/agentActivity/FcmClient.ts"; +import { androidAlertForState } 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 WatchError extends Schema.TaggedErrorClass()("AndroidPushWatchError", { + message: Schema.String, +}) {} +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 = fcmLayer.pipe( + Layer.provide( + Layer.mergeAll( + FetchHttpClient.layer, + Layer.succeed(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; + 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" + ? 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()].map(({ updatedAt: _, ...value }) => value)) === + encodeJson([...states.values()].map(({ updatedAt: _, ...value }) => value)); + 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 WatchError({ message: "Device token is no longer registered" }); + 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, + // Socket failures may contain credential-bearing request headers. + Effect.catchCause(() => + Effect.fail( + new WatchError({ + message: + "Android push watcher stopped. Check the private connection and Firebase configuration.", + }), + ), + ), + ), +); 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/agentActivity/AgentActivityPublisher.test.ts b/infra/relay/src/agentActivity/AgentActivityPublisher.test.ts index a70a85a8e6ed..053634c5764d 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 }, + ]); + 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..97ee4ccc8383 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 }); + } 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/FcmClient.test.ts b/infra/relay/src/agentActivity/FcmClient.test.ts new file mode 100644 index 000000000000..3cc033b14482 --- /dev/null +++ b/infra/relay/src/agentActivity/FcmClient.test.ts @@ -0,0 +1,176 @@ +import * as NodeCrypto from "node:crypto"; +import { describe, expect, it } from "@effect/vitest"; +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 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, makeFcmAssertion } from "./FcmClient.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( + Layer.mergeAll( + Layer.succeed(RelayConfiguration, config), + Layer.succeed(HttpClient.HttpClient, http), + ), + ), + ); +} + +describe("FCM delivery", () => { + it.effect("signs a verifiable Google OAuth assertion scoped to messaging", () => + Effect.gen(function* () { + const assertion = yield* makeFcmAssertion(account, 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, + }); + }), + ); + + 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..b60b9fd0e021 --- /dev/null +++ b/infra/relay/src/agentActivity/FcmClient.ts @@ -0,0 +1,187 @@ +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 { RelayConfiguration } from "../Config.ts"; + +const ServiceAccount = Schema.Struct({ + project_id: Schema.NonEmptyString, + client_email: Schema.NonEmptyString, + private_key: Schema.NonEmptyString, +}); +const decodeServiceAccount = Schema.decodeUnknownOption(Schema.fromJsonString(ServiceAccount)); +const decodeAccessToken = Schema.decodeUnknownEffect( + Schema.Struct({ access_token: Schema.NonEmptyString }), +); +const encodeJson = Schema.encodeSync(Schema.fromJsonString(Schema.Unknown)); +const decodeFcmError = Schema.decodeUnknownOption( + Schema.Struct({ + error: Schema.Struct({ + status: Schema.optional(Schema.String), + details: Schema.optional( + Schema.Array( + Schema.Struct({ + "@type": Schema.optional(Schema.String), + errorCode: Schema.optional(Schema.String), + }), + ), + ), + }), + }), +); + +export class FcmClientError extends Schema.TaggedErrorClass()("FcmClientError", { + operation: Schema.Literals(["configuration", "authorize", "send"]), + status: Schema.NullOr(Schema.Number), +}) { + 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") {} + +function base64Url(bytes: Uint8Array): string { + return btoa(String.fromCharCode(...bytes)) + .replaceAll("+", "-") + .replaceAll("/", "_") + .replace(/=+$/, ""); +} + +export const makeFcmAssertion = Effect.fn("relay.fcm.assertion")(function* ( + account: typeof ServiceAccount.Type, + issuedAt: number, +) { + 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: account.client_email, + scope: "https://www.googleapis.com/auth/firebase.messaging", + aud: "https://oauth2.googleapis.com/token", + iat: issuedAt, + exp: issuedAt + 3600, + }), + ), + ); + const pem = account.private_key.replace(/-----[^-]+-----|\s/g, ""); + const key = await crypto.subtle.importKey( + "pkcs8", + Uint8Array.from(atob(pem), (c) => c.charCodeAt(0)), + { name: "RSASSA-PKCS1-v1_5", hash: "SHA-256" }, + false, + ["sign"], + ); + const signature = await crypto.subtle.sign( + "RSASSA-PKCS1-v1_5", + key, + encoder.encode(`${header}.${claims}`), + ); + return `${header}.${claims}.${base64Url(new Uint8Array(signature))}`; + }, + catch: () => new FcmClientError({ operation: "authorize", status: null }), + }); +}); + +export const make = Effect.gen(function* () { + const config = yield* RelayConfiguration; + 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* makeFcmAssertion( + account.value, + Math.floor(now.epochMilliseconds / 1000), + ); + 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.mapError(() => new FcmClientError({ operation: "authorize", status: null }))); + if (response.status !== 200) + return yield* new FcmClientError({ operation: "authorize", status: response.status }); + return yield* response.json.pipe( + Effect.flatMap(decodeAccessToken), + Effect.map((body) => body.access_token), + Effect.mapError( + () => new FcmClientError({ operation: "authorize", status: response.status }), + ), + ); + }); + 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.mapError(() => new FcmClientError({ operation: "send", status: null })), + ); + if (response.status >= 200 && response.status < 300) return { unregistered: false }; + if (response.status === 401) yield* invalidateToken; + const body = yield* response.json.pipe(Effect.orElseSucceed(() => null)); + 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..dbeb3c5dcc58 --- /dev/null +++ b/infra/relay/src/agentActivity/FcmDeliveries.test.ts @@ -0,0 +1,623 @@ +import { EnvironmentId, ThreadId } from "@t3tools/contracts"; +import type { RelayAgentActivityState } from "@t3tools/contracts/relay"; +import { describe, expect, it } from "@effect/vitest"; +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 { 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 { FcmClient } from "./FcmClient.ts"; +import { + FcmDeliveries, + FcmDeliveryQueueSender, + 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[], + revokedEnvironments: [] as string[], + linked: true, + }; + const services = Layer.mergeAll( + Layer.succeed(RelayConfiguration, config), + Layer.succeed(FcmDeliveryQueueSender, { + send: (job) => + Effect.sync(() => { + queued.push(job); + }), + }), + Layer.succeed(FcmClient, { + send: (input) => + 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), + ) + : [], + ), + 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: true, + }, + ] + : [], + ), + 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)); + }); + + 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.process({ ...h.job, state: null }); + 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 }); + 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 }); + 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); + }); +}); diff --git a/infra/relay/src/agentActivity/FcmDeliveries.ts b/infra/relay/src/agentActivity/FcmDeliveries.ts new file mode 100644 index 000000000000..9a3391ece8bd --- /dev/null +++ b/infra/relay/src/agentActivity/FcmDeliveries.ts @@ -0,0 +1,357 @@ +import * as Alchemy from "alchemy"; +import * as Cloudflare from "alchemy/Cloudflare"; +import { and, eq } from "drizzle-orm"; +import { + RelayAgentActivityState, + RelayAgentActivityAggregateState, + RelayAgentAwarenessPreferences, + type RelayDeliveryResult, +} from "@t3tools/contracts/relay"; +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 { RelayConfiguration } from "../Config.ts"; +import { RelayDb } from "../db.ts"; +import { relayMobileDevices } from "../persistence/schema.ts"; +import { EnvironmentLinks } from "../environments/EnvironmentLinks.ts"; +import { AgentActivityRows } from "./AgentActivityRows.ts"; +import { LiveActivities, type TargetRow } from "./LiveActivities.ts"; +import { 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, + isFreshTerminalNotification, +} from "./agentActivityAlerts.ts"; + +export const FcmDeliveryJob = Schema.Struct({ + userId: Schema.String, + deviceId: Schema.String, + token: Schema.String, + state: Schema.NullOr(RelayAgentActivityState), + queuedAt: Schema.Number, +}); +export type FcmDeliveryJob = typeof FcmDeliveryJob.Type; +const decodeJob = Schema.decodeUnknownEffect(FcmDeliveryJob); +const decodePreferences = Schema.decodeUnknownOption( + Schema.fromJsonString(RelayAgentAwarenessPreferences), +); +const decodePreviousActivity = Schema.decodeUnknownOption( + Schema.fromJsonString(RelayAgentActivityAggregateState), +); + +export class FcmDeliveryError extends Schema.TaggedErrorClass()( + "FcmDeliveryError", + { + operation: Schema.Literals(["enqueue", "process"]), + cause: Schema.Defect(), + }, +) { + override get message() { + return `Failed to ${this.operation} Android notification delivery.`; + } +} + +export class FcmDeliveryQueueSender extends Context.Service< + FcmDeliveryQueueSender, + { + readonly send: (body: FcmDeliveryJob) => Effect.Effect; + } +>()("t3code-relay/agentActivity/FcmDeliveries/FcmDeliveryQueueSender") {} + +export class FcmDeliveries extends Context.Service< + FcmDeliveries, + { + readonly enqueue: (input: { + readonly target: TargetRow; + readonly state: RelayAgentActivityState | null; + }) => Effect.Effect; + readonly process: (body: unknown) => Effect.Effect; + } +>()("t3code-relay/agentActivity/FcmDeliveries") {} + +export function androidAlertForState( + state: RelayAgentActivityState, + preferences: RelayAgentAwarenessPreferences, + nowMs: number, +) { + if (!preferences.notificationsEnabled) return null; + if ( + (state.phase === "completed" || state.phase === "failed") && + !isFreshTerminalNotification(state.updatedAt, nowMs) + ) + return null; + const enabled = + (state.phase === "waiting_for_approval" && preferences.notifyOnApproval) || + (state.phase === "waiting_for_input" && preferences.notifyOnInput) || + (state.phase === "completed" && preferences.notifyOnCompletion) || + (state.phase === "failed" && preferences.notifyOnFailure); + if (!enabled) 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); + 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 const make = Effect.gen(function* () { + const config = yield* RelayConfiguration; + const sender = yield* FcmDeliveryQueueSender; + const client = yield* FcmClient; + const devices = yield* LiveActivities; + const rows = yield* AgentActivityRows; + const links = yield* EnvironmentLinks; + const db = yield* 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, + 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); + 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; + 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); + if ( + deliveryUser?.notificationsEnabled && + 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) { + if (environmentId === job.state.environmentId) { + allowedEnvironments.add(environmentId); + continue; + } + 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* Effect.promise(() => + crypto.subtle.digest("SHA-256", new TextEncoder().encode(alert.alert_id)), + ); + data.alert_id = Array.from(new Uint8Array(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), + ), + ); + } else { + 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), + }); + } + }, + Effect.mapError((cause) => new FcmDeliveryError({ operation: "process", cause })), + ), + }); +}); + +export const layer = Layer.effect(FcmDeliveries, make); +export const layerCloudflareQueues = ( + sender: Cloudflare.Queues.WriteQueueClient, + runtime: Alchemy.BaseRuntimeContext, +) => + layer.pipe( + Layer.provide( + Layer.succeed(FcmDeliveryQueueSender, { + send: (body) => + sender.send(body).pipe(Effect.provideService(Alchemy.RuntimeContext, runtime)), + }), + ), + ); 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..47f7ed280d29 --- /dev/null +++ b/infra/relay/src/agentActivity/fcmPayloads.ts @@ -0,0 +1,81 @@ +import type { + RelayAgentActivityAggregateRow, + RelayAgentActivityAggregateState, +} from "@t3tools/contracts/relay"; +import { TERMINAL_AGENT_ACTIVITY_DISPLAY_TTL_MS } from "./agentActivityAggregate.ts"; +import { agentActivityExpiresAt } from "./agentActivityPayloads.ts"; + +function priority(row: RelayAgentActivityAggregateRow): number { + if (row.phase === "waiting_for_approval" || row.phase === "waiting_for_input") return 0; + if (row.phase === "failed") return 1; + if (row.phase === "starting" || row.phase === "running") return 2; + return 3; +} + +export function androidActivityHero(aggregate: RelayAgentActivityAggregateState) { + return [...aggregate.activities].sort((a, b) => priority(a) - priority(b))[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) => priority(a) - priority(b)); + const activeCount = aggregate?.activeCount ?? 0; + const attentionCount = rows.filter((row) => priority(row) === 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 || data[key]!.length <= 8) break; + 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]!); + if (characters.length <= 4) break; + 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.ts b/infra/relay/src/http/Api.ts index 62a38d666b2f..42963da18fb7 100644 --- a/infra/relay/src/http/Api.ts +++ b/infra/relay/src/http/Api.ts @@ -981,6 +981,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..15e5e6143302 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,14 @@ 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 FcmClient from "./agentActivity/FcmClient.ts"; +import * as FcmDeliveries from "./agentActivity/FcmDeliveries.ts"; import * as RelayConfiguration from "./Config.ts"; import * as AgentActivityPublisher from "./agentActivity/AgentActivityPublisher.ts"; import * as ApnsClient from "./agentActivity/ApnsClient.ts"; @@ -117,6 +126,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 +137,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 +184,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 +220,16 @@ export const ApiLive = Api.make( ), Layer.provideMerge(DpopProofs.layer), Layer.provideMerge(ApnsDeliveries.layer), + Layer.provideMerge( + FcmDeliveries.layerCloudflareQueues(fcmDeliveryQueueSender, alchemyRuntimeContext).pipe( + Layer.provideMerge(FcmClient.layer), + ), + ), 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 +279,26 @@ 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.runForEach((message) => + FcmDeliveries.FcmDeliveries.pipe( + Effect.flatMap((deliveries) => deliveries.process(message.body)), + ), + ), + Effect.provide(runtimeLayer), + ), + ); + yield* Cloudflare.Workers.cron("*/5 * * * *", () => DpopProofs.DpopProofReplay.pipe( Effect.flatMap((dpopProofs) => dpopProofs.pruneExpired), diff --git a/packages/contracts/src/relay.test.ts b/packages/contracts/src/relay.test.ts index 4ad600953b9e..e43e2a08dfb4 100644 --- a/packages/contracts/src/relay.test.ts +++ b/packages/contracts/src/relay.test.ts @@ -1,7 +1,44 @@ 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("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..60b7a8a3df76 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, From 427b6c4728aee83ef9213064b23e94b3f90b5a64 Mon Sep 17 00:00:00 2001 From: Ryan Hughes Date: Sun, 6 Sep 2026 17:15:15 -0400 Subject: [PATCH 02/17] fix(mobile): harden Android notification compatibility and CI --- .github/workflows/mobile-android.yml | 87 ++++++++ apps/mobile/app.config.ts | 4 + .../android/build.gradle | 9 +- .../android/src/main/AndroidManifest.xml | 1 + .../AgentNotifications.kt | 189 ++++++++++++++--- .../T3AgentNotificationsModule.kt | 11 +- .../AgentNotificationsTest.kt | 190 ++++++++++++++++-- .../androidNotifications.test.ts | 63 ++++++ .../agent-awareness/androidNotifications.ts | 8 +- .../features/agent-awareness/capabilities.ts | 6 +- .../remoteRegistration.test.ts | 1 + .../features/settings/SettingsRouteScreen.tsx | 8 +- docs/operations/android-notifications.md | 16 +- docs/user/mobile-notifications.md | 6 +- .../snapshot.json | 49 +---- packages/contracts/src/relay.test.ts | 8 + 16 files changed, 559 insertions(+), 97 deletions(-) create mode 100644 .github/workflows/mobile-android.yml create mode 100644 apps/mobile/src/features/agent-awareness/androidNotifications.test.ts diff --git a/.github/workflows/mobile-android.yml b/.github/workflows/mobile-android.yml new file mode 100644 index 000000000000..8f7fa751dbee --- /dev/null +++ b/.github/workflows/mobile-android.yml @@ -0,0 +1,87 @@ +name: Android Notifications + +on: + pull_request: + paths: + - apps/mobile/** + - packages/client-runtime/** + - packages/contracts/** + - packages/shared/** + - assets/** + - scripts/** + - patches/** + - pnpm-lock.yaml + - pnpm-workspace.yaml + - package.json + - .github/workflows/mobile-android.yml + push: + branches: [main] + paths: + - apps/mobile/** + - packages/client-runtime/** + - packages/contracts/** + - packages/shared/** + - assets/** + - scripts/** + - patches/** + - pnpm-lock.yaml + - pnpm-workspace.yaml + - package.json + - .github/workflows/mobile-android.yml + +concurrency: + group: android-notifications-${{ github.event.pull_request.number || github.sha }} + cancel-in-progress: true + +permissions: + contents: read + +jobs: + native_tests: + name: Android notification compatibility + runs-on: ubuntu-24.04 + timeout-minutes: 30 + env: + CI: "1" + APP_VARIANT: development + EXPO_NO_DOTENV: "1" + T3CODE_MOBILE_UPDATES_ENABLED: "0" + steps: + - uses: actions/checkout@v6 + - name: Setup Vite+ + uses: voidzero-dev/setup-vp@v1 + with: + node-version-file: package.json + cache: true + run-install: | + args: + - --filter=@t3tools/mobile... + - --frozen-lockfile + + - uses: actions/setup-java@v5 + with: + distribution: temurin + java-version: | + 17 + 21 + - uses: android-actions/setup-android@v3 + - uses: gradle/actions/setup-gradle@v4 + + # Exercise Expo autolinking and the real native dependency graph. These + # tests need no Firebase project, device, signing key or relay secrets. + - name: Generate Android project + working-directory: apps/mobile + run: vp exec expo prebuild --platform android --no-install + + - name: Compile, test supported Android versions, and lint + working-directory: apps/mobile/android + run: ./gradlew :t3-agent-notifications:testDebugUnitTest :t3-agent-notifications:lintRelease --no-daemon --console=plain + + - name: Upload native test and lint reports + if: always() + uses: actions/upload-artifact@v7 + with: + name: android-notification-reports + path: apps/mobile/modules/t3-agent-notifications/android/build/reports/ + if-no-files-found: ignore + retention-days: 14 diff --git a/apps/mobile/app.config.ts b/apps/mobile/app.config.ts index b463c0a19ad2..f34f9c4b39d8 100644 --- a/apps/mobile/app.config.ts +++ b/apps/mobile/app.config.ts @@ -359,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 index 2b4d5237043e..34b1d92593f1 100644 --- a/apps/mobile/modules/t3-agent-notifications/android/build.gradle +++ b/apps/mobile/modules/t3-agent-notifications/android/build.gradle @@ -23,5 +23,12 @@ dependencies { 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.14.1' + 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 index 9434f013394b..a7e07f45ec73 100644 --- a/apps/mobile/modules/t3-agent-notifications/android/src/main/AndroidManifest.xml +++ b/apps/mobile/modules/t3-agent-notifications/android/src/main/AndroidManifest.xml @@ -9,5 +9,6 @@ + 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 index 247a1b0dc58a..ae8d79a713ea 100644 --- 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 @@ -1,11 +1,14 @@ 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 @@ -33,6 +36,12 @@ class AgentActivityDismissReceiver : BroadcastReceiver() { } } +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" @@ -46,23 +55,35 @@ object AgentNotifications { private const val MAX_LIFETIME_MS = 24 * 60 * 60 * 1000L @Synchronized - fun configure(context: Context, deviceId: String, userId: String, scheme: String, ongoingEnabled: Boolean) { + 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) { + 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) + 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) manager(context).cancel(ACTIVITY_TAG, ACTIVITY_ID) + 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 } @@ -71,22 +92,45 @@ object AgentNotifications { @Synchronized fun dismiss(context: Context) { - context.getSharedPreferences(STORE, Context.MODE_PRIVATE).edit().putBoolean("dismissed", true).apply() - manager(context).cancel(ACTIVITY_TAG, ACTIVITY_ID) + 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) - if (!prefs.getBoolean("enabled", false) || data["device_id"] != prefs.getString("deviceId", null)) return - if (data["user_id"] != prefs.getString("userId", null)) return val updatedAt = data["updated_at"]?.toLongOrNull() ?: return - if (System.currentTimeMillis() - updatedAt > MAX_MESSAGE_AGE_MS) return - channels(context) - val manager = manager(context) - val scheme = prefs.getString("scheme", null) ?: return - if (!NotificationManagerCompat.from(context).areNotificationsEnabled()) 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"] @@ -105,11 +149,22 @@ object AgentNotifications { .setAutoCancel(true) .setContentIntent(contentIntent(context, scheme, data["alert_path"], id)) .build() - manager.notify(ALERT_TAG, id, notification) + manager(context).notify(ALERT_TAG, id, notification) } - prefs.edit().putStringSet("seenAlerts", (seen.toList().takeLast(63) + alertId).toSet()).apply() + prefs.edit().putStringSet( + "seenAlerts", + (seen.toList().takeLast(63) + alertId).toSet() + ).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() @@ -122,19 +177,35 @@ object AgentNotifications { val wasActive = prefs.getBoolean("lastActive", false) prefs.edit().putBoolean("lastActive", active).apply() if (remainingMs <= 0 || !prefs.getBoolean("ongoing", false)) { - manager.cancel(ACTIVITY_TAG, ACTIVITY_ID) + 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)) return + 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, + 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) } } + 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") @@ -145,12 +216,40 @@ object AgentNotifications { .setStyle(style) .setOngoing(active).setOnlyAlertOnce(true).setSilent(true) .setTimeoutAfter(remainingMs) + // Android 16 requires colorization to consider a non-call card promotable. + .setColorized(active) .setRequestPromotedOngoing(active) .setContentIntent(contentIntent(context, scheme, data["activity_path"], ACTIVITY_ID)) .setDeleteIntent(dismissIntent) .addAction(0, "Dismiss", dismissIntent) .build() - manager.notify(ACTIVITY_TAG, ACTIVITY_ID, notification) + 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 { @@ -170,7 +269,12 @@ object AgentNotifications { 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) + val project = TextUtils.ellipsize( + parts[2], + paint, + available - titleWidth, + TextUtils.TruncateAt.END + ) return "$prefix$title$separator$project" } @@ -178,26 +282,53 @@ object AgentNotifications { 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), - )) + 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 route = if (path != null && path.startsWith("/threads/") && !path.contains('?') && !path.contains('#')) path else "/" + 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)!! .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) + 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 index ecb14fb82ffb..d394db56115f 100644 --- 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 @@ -7,8 +7,15 @@ 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("configure") { + deviceId: String, + userId: String, + scheme: String, + ongoingEnabled: Boolean + -> + appContext.reactContext?.let { + AgentNotifications.configure(it, deviceId, userId, scheme, ongoingEnabled) + } } Function("clear") { diff --git a/apps/mobile/modules/t3-agent-notifications/android/src/test/java/expo/modules/t3agentnotifications/AgentNotificationsTest.kt b/apps/mobile/modules/t3-agent-notifications/android/src/test/java/expo/modules/t3agentnotifications/AgentNotificationsTest.kt index 81456cdfee0e..c4def65ee64c 100644 --- a/apps/mobile/modules/t3-agent-notifications/android/src/test/java/expo/modules/t3agentnotifications/AgentNotificationsTest.kt +++ b/apps/mobile/modules/t3-agent-notifications/android/src/test/java/expo/modules/t3agentnotifications/AgentNotificationsTest.kt @@ -1,12 +1,15 @@ 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 @@ -22,7 +25,7 @@ import org.robolectric.Shadows.shadowOf import org.robolectric.annotation.Config @RunWith(RobolectricTestRunner::class) -@Config(sdk = [35], manifest = Config.NONE) +@Config(sdk = [24, 26, 33, 36], manifest = Config.NONE) class AgentNotificationsTest { private lateinit var context: Application private lateinit var manager: NotificationManager @@ -38,9 +41,12 @@ class AgentNotificationsTest { 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) - }) + 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) } @@ -125,7 +131,10 @@ class AgentNotificationsTest { ) AgentNotifications.receive(context, grouped) - AgentNotifications.receive(context, grouped + ("alert_body" to "A retry must not replace this alert")) + 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)) @@ -147,6 +156,7 @@ class AgentNotificationsTest { assertEquals("t3-agent-activity", manager.activeNotifications.single().tag) } + @Test fun reopeningSameAccountPreservesCardsDeduplicationAndDismissal() { val message = update("attention", true) @@ -176,22 +186,37 @@ class AgentNotificationsTest { @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 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) + 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())) + AgentNotifications.receive( + context, + update("work", true) + ("activity_expires_at" to expiresAt.toString()) + ) val card = manager.activeNotifications.single().notification - assertTrue(card.timeoutAfter > 119 * 60 * 1000L) - assertTrue(card.timeoutAfter <= 120 * 60 * 1000L) + assertTimeout(card, 119 * 60 * 1000L..120 * 60 * 1000L) } @Test @@ -199,15 +224,19 @@ class AgentNotificationsTest { 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_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) - assertTrue(card.timeoutAfter in 1..15 * 60 * 1000L) - AgentNotifications.receive(context, finished + ("activity_expires_at" to (System.currentTimeMillis() - 1).toString())) + assertTimeout(card, 1..15 * 60 * 1000L) + AgentNotifications.receive( + context, + finished + ("activity_expires_at" to (System.currentTimeMillis() - 1).toString()) + ) assertTrue(manager.activeNotifications.isEmpty()) } @@ -216,7 +245,9 @@ class AgentNotificationsTest { 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()) + 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()) @@ -230,7 +261,10 @@ class AgentNotificationsTest { 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())) + 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) @@ -242,8 +276,13 @@ class AgentNotificationsTest { 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') + 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: ")) @@ -254,4 +293,117 @@ class AgentNotificationsTest { } } + 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)) + if (Build.VERSION.SDK_INT >= 36) { + assertTrue(card.hasPromotableCharacteristics()) + assertFalse(alert.hasPromotableCharacteristics()) + } + 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() + 600001).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/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 index 326c5c4389cb..a65ff1758e78 100644 --- a/apps/mobile/src/features/agent-awareness/androidNotifications.ts +++ b/apps/mobile/src/features/agent-awareness/androidNotifications.ts @@ -12,13 +12,17 @@ const native = ? 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( + native?.configure?.( deviceId, userId, (Array.isArray(scheme) ? scheme[0] : scheme) ?? "t3code", @@ -27,5 +31,5 @@ export function configureAndroidAgentNotifications( } export function clearAndroidAgentNotifications(): void { - native?.clear(); + 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/remoteRegistration.test.ts b/apps/mobile/src/features/agent-awareness/remoteRegistration.test.ts index 903d01054945..03f3d14e5962 100644 --- a/apps/mobile/src/features/agent-awareness/remoteRegistration.test.ts +++ b/apps/mobile/src/features/agent-awareness/remoteRegistration.test.ts @@ -55,6 +55,7 @@ import { } from "./androidNotifications"; vi.mock("./androidNotifications", () => ({ + supportsAndroidAgentNotifications: vi.fn(() => true), configureAndroidAgentNotifications: vi.fn(), clearAndroidAgentNotifications: vi.fn(), })); diff --git a/apps/mobile/src/features/settings/SettingsRouteScreen.tsx b/apps/mobile/src/features/settings/SettingsRouteScreen.tsx index 131f1f930d66..3b6b360d1e4b 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 }); @@ -515,7 +519,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. @@ -534,7 +538,7 @@ function ConfiguredSettingsRouteScreen() { } icon="bolt.circle" label={Platform.OS === "android" ? "Ongoing Agent Activity" : "Live Activity Updates"} - subtitle={agentAwarenessPlatform.subtitle} + 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/docs/operations/android-notifications.md b/docs/operations/android-notifications.md index 7f6e2b4f2118..30a1e043aaa6 100644 --- a/docs/operations/android-notifications.md +++ b/docs/operations/android-notifications.md @@ -2,6 +2,20 @@ 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 Android Notifications workflow performs a clean Expo prebuild, compiles the notification module, runs Robolectric tests on API 24, 26, 33 and 36 (plus API 25 for legacy expiry), and runs Android lint. It uses no Firebase, signing or relay secrets. Run the same tasks from a generated `apps/mobile/android` project with JDK 21 available: + +```sh +./gradlew :t3-agent-notifications:testDebugUnitTest :t3-agent-notifications:lintRelease +``` + +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. + ## 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`. @@ -18,7 +32,7 @@ T3CODE_ANDROID_GOOGLE_SERVICES_FILE=/absolute/path/google-services.json \ vp run android:dev ``` -For an EAS build, provide the same configuration through the build environment, using an EAS file variable for the Google services file. FCM 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). +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. diff --git a/docs/user/mobile-notifications.md b/docs/user/mobile-notifications.md index 4c4d00eb8d86..9a2e37e85b6c 100644 --- a/docs/user/mobile-notifications.md +++ b/docs/user/mobile-notifications.md @@ -8,12 +8,14 @@ With ongoing activity enabled, several threads changing in the same update can p The mobile app suppresses ordinary alert notifications while it is in the foreground. Ongoing activity continues to update. Viewing a thread on another device does not silence your phone's alerts. -On Android, **Ongoing Agent Activity** shows the active count and how many agents need attention. Expand the notification to see up to five threads, including each project and status. Threads needing attention appear first, followed by failures and other work. Supported Android versions can promote it to a Live Update on the lock screen and status bar. Android controls whether promotion is available and enabled. Other devices show a regular ongoing notification. +On Android 7.0 and newer, **Ongoing Agent Activity** shows the active count and how many agents need attention. Expand the notification to see up to five threads, including each project and status. Threads needing attention appear first, followed by failures and other work. Android 16 and newer can promote it to a Live Update on the lock screen and status bar. Android controls whether promotion is available and enabled. Other devices show a regular ongoing notification. You can dismiss the ongoing notification without disabling completion and attention alerts. Disabling Ongoing Agent Activity removes the current activity notification. Signing out clears T3's notifications from the device. Finished results stay visible for up to 15 minutes with an **Agent work completed** or **Agent work failed** heading. A failure takes priority when results are mixed. Dismissing a run keeps its finished card dismissed; new work can show a new card. Reopening the app preserves existing notifications and refreshes activity from T3 Connect. If an environment stops sending updates, working states expire after two hours and approval/input states after 24 hours. +Android 7 uses a system alarm to remove expired cards; battery-saving modes can delay removal. Android 8 and newer use the system notification timeout. + On iOS, enable **Live Activity Updates** to show agent status using Apple Live Activities. -Notification permission and Android notification channels are controlled in system Settings. Background delivery uses T3 Connect and the platform's push service; the mobile app does not need to maintain a connection to your environment. Force-stopping the Android app in system Settings prevents push delivery until you open it again. +Notification permission and Android notification channels are controlled in system Settings. Background delivery uses T3 Connect and the platform's push service; Android requires Google Play services. 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/migrations/postgres/20260906042516_android_devices/snapshot.json b/infra/relay/migrations/postgres/20260906042516_android_devices/snapshot.json index a885f4f0390d..9d89ffe61a17 100644 --- a/infra/relay/migrations/postgres/20260906042516_android_devices/snapshot.json +++ b/infra/relay/migrations/postgres/20260906042516_android_devices/snapshot.json @@ -2,9 +2,7 @@ "version": "8", "dialect": "postgres", "id": "1109ed23-036c-4c39-a459-52422a7ffd99", - "prevIds": [ - "2374caff-40bf-423c-9255-55e76dddbc2a" - ], + "prevIds": ["2374caff-40bf-423c-9255-55e76dddbc2a"], "ddl": [ { "isRlsEnabled": false, @@ -1442,11 +1440,7 @@ "table": "relay_mobile_devices" }, { - "columns": [ - "environment_id", - "environment_public_key", - "thread_id" - ], + "columns": ["environment_id", "environment_public_key", "thread_id"], "nameExplicit": false, "name": "relay_agent_activity_rows_pkey", "entityType": "pks", @@ -1454,10 +1448,7 @@ "table": "relay_agent_activity_rows" }, { - "columns": [ - "thumbprint", - "jti" - ], + "columns": ["thumbprint", "jti"], "nameExplicit": false, "name": "relay_dpop_proofs_pkey", "entityType": "pks", @@ -1465,10 +1456,7 @@ "table": "relay_dpop_proofs" }, { - "columns": [ - "user_id", - "environment_id" - ], + "columns": ["user_id", "environment_id"], "nameExplicit": false, "name": "relay_environment_links_pkey", "entityType": "pks", @@ -1476,10 +1464,7 @@ "table": "relay_environment_links" }, { - "columns": [ - "user_id", - "device_id" - ], + "columns": ["user_id", "device_id"], "nameExplicit": false, "name": "relay_live_activities_pkey", "entityType": "pks", @@ -1487,10 +1472,7 @@ "table": "relay_live_activities" }, { - "columns": [ - "user_id", - "environment_id" - ], + "columns": ["user_id", "environment_id"], "nameExplicit": false, "name": "relay_managed_endpoint_allocations_pkey", "entityType": "pks", @@ -1498,10 +1480,7 @@ "table": "relay_managed_endpoint_allocations" }, { - "columns": [ - "user_id", - "device_id" - ], + "columns": ["user_id", "device_id"], "nameExplicit": false, "name": "relay_mobile_devices_pkey", "entityType": "pks", @@ -1509,9 +1488,7 @@ "table": "relay_mobile_devices" }, { - "columns": [ - "id" - ], + "columns": ["id"], "nameExplicit": false, "name": "relay_delivery_attempts_pkey", "schema": "public", @@ -1519,9 +1496,7 @@ "entityType": "pks" }, { - "columns": [ - "credential_id" - ], + "columns": ["credential_id"], "nameExplicit": false, "name": "relay_environment_credentials_pkey", "schema": "public", @@ -1529,9 +1504,7 @@ "entityType": "pks" }, { - "columns": [ - "user_id" - ], + "columns": ["user_id"], "nameExplicit": false, "name": "relay_managed_tunnel_limits_pkey", "schema": "public", @@ -1540,4 +1513,4 @@ } ], "renames": [] -} \ No newline at end of file +} diff --git a/packages/contracts/src/relay.test.ts b/packages/contracts/src/relay.test.ts index e43e2a08dfb4..441fecd33268 100644 --- a/packages/contracts/src/relay.test.ts +++ b/packages/contracts/src/relay.test.ts @@ -20,6 +20,14 @@ const device = { }; 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", From 7c1d61f788c21b1f1c4d18a5fe9fa2b35816d5b4 Mon Sep 17 00:00:00 2001 From: Ryan Hughes Date: Sun, 6 Sep 2026 18:02:28 -0400 Subject: [PATCH 03/17] fix(mobile): preserve Live Update promotion on current Android --- .github/workflows/mobile-android.yml | 4 +++- .../t3agentnotifications/AgentNotifications.kt | 4 ++-- .../AgentNotificationsTest.kt | 10 +++++----- docs/operations/android-notifications.md | 12 +++++++----- docs/operations/connect-setup.md | 10 +++++----- docs/user/mobile-notifications.md | 18 ++++-------------- infra/relay/package.json | 2 ++ 7 files changed, 28 insertions(+), 32 deletions(-) diff --git a/.github/workflows/mobile-android.yml b/.github/workflows/mobile-android.yml index 8f7fa751dbee..049d54e0907a 100644 --- a/.github/workflows/mobile-android.yml +++ b/.github/workflows/mobile-android.yml @@ -75,7 +75,9 @@ jobs: - name: Compile, test supported Android versions, and lint working-directory: apps/mobile/android - run: ./gradlew :t3-agent-notifications:testDebugUnitTest :t3-agent-notifications:lintRelease --no-daemon --console=plain + # AGP's K2 lint frontend crashes on Worklets 0.10's Gradle Kotlin scripts. + # Use the K1 frontend; all lint checks remain enabled. + run: ./gradlew :t3-agent-notifications:testDebugUnitTest :t3-agent-notifications:lintRelease -Pandroid.lint.useK2Uast=false --no-daemon --console=plain - name: Upload native test and lint reports if: always() 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 index ae8d79a713ea..f752ada20d74 100644 --- 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 @@ -216,8 +216,8 @@ object AgentNotifications { .setStyle(style) .setOngoing(active).setOnlyAlertOnce(true).setSilent(true) .setTimeoutAfter(remainingMs) - // Android 16 requires colorization to consider a non-call card promotable. - .setColorized(active) + // Live Updates must remain uncolorized to qualify for promotion. + .setColorized(false) .setRequestPromotedOngoing(active) .setContentIntent(contentIntent(context, scheme, data["activity_path"], ACTIVITY_ID)) .setDeleteIntent(dismissIntent) diff --git a/apps/mobile/modules/t3-agent-notifications/android/src/test/java/expo/modules/t3agentnotifications/AgentNotificationsTest.kt b/apps/mobile/modules/t3-agent-notifications/android/src/test/java/expo/modules/t3agentnotifications/AgentNotificationsTest.kt index c4def65ee64c..c080cd92ba0e 100644 --- a/apps/mobile/modules/t3-agent-notifications/android/src/test/java/expo/modules/t3agentnotifications/AgentNotificationsTest.kt +++ b/apps/mobile/modules/t3-agent-notifications/android/src/test/java/expo/modules/t3agentnotifications/AgentNotificationsTest.kt @@ -371,10 +371,10 @@ class AgentNotificationsTest { } assertTrue(NotificationCompat.isRequestPromotedOngoing(card)) assertFalse(NotificationCompat.isRequestPromotedOngoing(alert)) - if (Build.VERSION.SDK_INT >= 36) { - assertTrue(card.hasPromotableCharacteristics()) - assertFalse(alert.hasPromotableCharacteristics()) - } + // 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) } @@ -389,7 +389,7 @@ class AgentNotificationsTest { ) AgentNotifications.receive( context, - invalid + ("updated_at" to (System.currentTimeMillis() + 600001).toString()) + invalid + ("updated_at" to (System.currentTimeMillis() + 3600000).toString()) ) assertTrue(manager.activeNotifications.isEmpty()) AgentNotifications.receive(context, update("valid", true)) diff --git a/docs/operations/android-notifications.md b/docs/operations/android-notifications.md index 30a1e043aaa6..c787e158fbdd 100644 --- a/docs/operations/android-notifications.md +++ b/docs/operations/android-notifications.md @@ -11,11 +11,13 @@ API 24–25 use a single inexact system alarm to expire cards after process exit The Android Notifications workflow performs a clean Expo prebuild, compiles the notification module, runs Robolectric tests on API 24, 26, 33 and 36 (plus API 25 for legacy expiry), and runs Android lint. It uses no Firebase, signing or relay secrets. Run the same tasks from a generated `apps/mobile/android` project with JDK 21 available: ```sh -./gradlew :t3-agent-notifications:testDebugUnitTest :t3-agent-notifications:lintRelease +./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`. @@ -57,9 +59,9 @@ Building with `APP_VARIANT=production` selects `com.t3tools.t3code` and its corr 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 -node scripts/android-push-smoke.ts /path/service-account.json /path/device.json running -node scripts/android-push-smoke.ts /path/service-account.json /path/device.json approval -node scripts/android-push-smoke.ts /path/service-account.json /path/device.json completed +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. @@ -85,7 +87,7 @@ You do not need to duplicate T3 Connect's hosted infrastructure to develop Andro 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 -node scripts/android-push-watch.ts /path/service-account.json /path/device.json /path/connection.json +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. diff --git a/docs/operations/connect-setup.md b/docs/operations/connect-setup.md index 83684929f7cb..86697d2798c9 100644 --- a/docs/operations/connect-setup.md +++ b/docs/operations/connect-setup.md @@ -78,11 +78,11 @@ persistence and system-browser callback delivery. 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` | +| 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. diff --git a/docs/user/mobile-notifications.md b/docs/user/mobile-notifications.md index 9a2e37e85b6c..3a8d61d1cb7e 100644 --- a/docs/user/mobile-notifications.md +++ b/docs/user/mobile-notifications.md @@ -2,20 +2,10 @@ 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. -Individual alerts show the thread title, followed by the status and project name: **Approval: Project**, **Input: Project**, **Done: Project**, or **Failed: Project**. +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. -With ongoing activity enabled, several threads changing in the same update can produce one alert: **2 agents need attention** or **2 agents finished**, followed by their thread titles. On Android, tapping a grouped alert opens the priority thread: one needing attention first, then a failed thread, then other work. +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. -The mobile app suppresses ordinary alert notifications while it 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. -On Android 7.0 and newer, **Ongoing Agent Activity** shows the active count and how many agents need attention. Expand the notification to see up to five threads, including each project and status. Threads needing attention appear first, followed by failures and other work. Android 16 and newer can promote it to a Live Update on the lock screen and status bar. Android controls whether promotion is available and enabled. Other devices show a regular ongoing notification. - -You can dismiss the ongoing notification without disabling completion and attention alerts. Disabling Ongoing Agent Activity removes the current activity notification. Signing out clears T3's notifications from the device. Finished results stay visible for up to 15 minutes with an **Agent work completed** or **Agent work failed** heading. A failure takes priority when results are mixed. Dismissing a run keeps its finished card dismissed; new work can show a new card. - -Reopening the app preserves existing notifications and refreshes activity from T3 Connect. If an environment stops sending updates, working states expire after two hours and approval/input states after 24 hours. - -Android 7 uses a system alarm to remove expired cards; battery-saving modes can delay removal. Android 8 and newer use the system notification timeout. - -On iOS, enable **Live Activity Updates** to show agent status using Apple Live Activities. - -Notification permission and Android notification channels are controlled in system Settings. Background delivery uses T3 Connect and the platform's push service; Android requires Google Play services. The mobile app does not need to maintain a connection to your environment. Force-stopping the Android app in system Settings prevents push delivery until you open it again. +Notification permission 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/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" From ec01afce3a34c5f1b7212d3473ccdfd5bda9193d Mon Sep 17 00:00:00 2001 From: Ryan Hughes Date: Sun, 6 Sep 2026 18:05:25 -0400 Subject: [PATCH 04/17] fix(relay): bound Firebase authorization and delivery requests --- .../relay/src/agentActivity/FcmClient.test.ts | 51 +++++++++++++++++++ infra/relay/src/agentActivity/FcmClient.ts | 14 ++++- 2 files changed, 63 insertions(+), 2 deletions(-) diff --git a/infra/relay/src/agentActivity/FcmClient.test.ts b/infra/relay/src/agentActivity/FcmClient.test.ts index 3cc033b14482..afdd480570b0 100644 --- a/infra/relay/src/agentActivity/FcmClient.test.ts +++ b/infra/relay/src/agentActivity/FcmClient.test.ts @@ -1,9 +1,12 @@ 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"; @@ -68,6 +71,54 @@ function testLayer(requests: HttpClientRequest.HttpClientRequest[], responses: R } 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, + }); + expect(yield* client.send(input)).toEqual({ unregistered: false }); + }).pipe( + Effect.provide( + layer.pipe( + 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 assertion = yield* makeFcmAssertion(account, 1000); diff --git a/infra/relay/src/agentActivity/FcmClient.ts b/infra/relay/src/agentActivity/FcmClient.ts index b60b9fd0e021..86d8e0ff55da 100644 --- a/infra/relay/src/agentActivity/FcmClient.ts +++ b/infra/relay/src/agentActivity/FcmClient.ts @@ -10,6 +10,8 @@ import * as HttpClientRequest from "effect/unstable/http/HttpClientRequest"; import { RelayConfiguration } from "../Config.ts"; +const FCM_HTTP_STAGE_TIMEOUT = "10 seconds"; + const ServiceAccount = Schema.Struct({ project_id: Schema.NonEmptyString, client_email: Schema.NonEmptyString, @@ -125,10 +127,14 @@ export const make = Effect.gen(function* () { }), ), ) - .pipe(Effect.mapError(() => new FcmClientError({ operation: "authorize", status: null }))); + .pipe( + Effect.timeout(FCM_HTTP_STAGE_TIMEOUT), + Effect.mapError(() => new FcmClientError({ operation: "authorize", status: null })), + ); 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( @@ -165,11 +171,15 @@ export const make = Effect.gen(function* () { }, }), Effect.flatMap(client.execute), + Effect.timeout(FCM_HTTP_STAGE_TIMEOUT), Effect.mapError(() => new FcmClientError({ operation: "send", status: null })), ); if (response.status >= 200 && response.status < 300) return { unregistered: false }; if (response.status === 401) yield* invalidateToken; - const body = yield* response.json.pipe(Effect.orElseSucceed(() => null)); + const body = yield* response.json.pipe( + Effect.timeout(FCM_HTTP_STAGE_TIMEOUT), + Effect.orElseSucceed(() => null), + ); const decoded = decodeFcmError(body); const unregistered = Option.isSome(decoded) && From d419bc4d8e2b120668bfbccbad6d4695f069d84a Mon Sep 17 00:00:00 2001 From: Ryan Hughes Date: Sun, 6 Sep 2026 19:06:16 -0400 Subject: [PATCH 05/17] chore(ci): remove standalone Android notification workflow --- .github/workflows/mobile-android.yml | 89 ------------------------ docs/operations/android-notifications.md | 2 +- 2 files changed, 1 insertion(+), 90 deletions(-) delete mode 100644 .github/workflows/mobile-android.yml diff --git a/.github/workflows/mobile-android.yml b/.github/workflows/mobile-android.yml deleted file mode 100644 index 049d54e0907a..000000000000 --- a/.github/workflows/mobile-android.yml +++ /dev/null @@ -1,89 +0,0 @@ -name: Android Notifications - -on: - pull_request: - paths: - - apps/mobile/** - - packages/client-runtime/** - - packages/contracts/** - - packages/shared/** - - assets/** - - scripts/** - - patches/** - - pnpm-lock.yaml - - pnpm-workspace.yaml - - package.json - - .github/workflows/mobile-android.yml - push: - branches: [main] - paths: - - apps/mobile/** - - packages/client-runtime/** - - packages/contracts/** - - packages/shared/** - - assets/** - - scripts/** - - patches/** - - pnpm-lock.yaml - - pnpm-workspace.yaml - - package.json - - .github/workflows/mobile-android.yml - -concurrency: - group: android-notifications-${{ github.event.pull_request.number || github.sha }} - cancel-in-progress: true - -permissions: - contents: read - -jobs: - native_tests: - name: Android notification compatibility - runs-on: ubuntu-24.04 - timeout-minutes: 30 - env: - CI: "1" - APP_VARIANT: development - EXPO_NO_DOTENV: "1" - T3CODE_MOBILE_UPDATES_ENABLED: "0" - steps: - - uses: actions/checkout@v6 - - name: Setup Vite+ - uses: voidzero-dev/setup-vp@v1 - with: - node-version-file: package.json - cache: true - run-install: | - args: - - --filter=@t3tools/mobile... - - --frozen-lockfile - - - uses: actions/setup-java@v5 - with: - distribution: temurin - java-version: | - 17 - 21 - - uses: android-actions/setup-android@v3 - - uses: gradle/actions/setup-gradle@v4 - - # Exercise Expo autolinking and the real native dependency graph. These - # tests need no Firebase project, device, signing key or relay secrets. - - name: Generate Android project - working-directory: apps/mobile - run: vp exec expo prebuild --platform android --no-install - - - name: Compile, test supported Android versions, and lint - working-directory: apps/mobile/android - # AGP's K2 lint frontend crashes on Worklets 0.10's Gradle Kotlin scripts. - # Use the K1 frontend; all lint checks remain enabled. - run: ./gradlew :t3-agent-notifications:testDebugUnitTest :t3-agent-notifications:lintRelease -Pandroid.lint.useK2Uast=false --no-daemon --console=plain - - - name: Upload native test and lint reports - if: always() - uses: actions/upload-artifact@v7 - with: - name: android-notification-reports - path: apps/mobile/modules/t3-agent-notifications/android/build/reports/ - if-no-files-found: ignore - retention-days: 14 diff --git a/docs/operations/android-notifications.md b/docs/operations/android-notifications.md index c787e158fbdd..48822f91a41d 100644 --- a/docs/operations/android-notifications.md +++ b/docs/operations/android-notifications.md @@ -8,7 +8,7 @@ The app's minimum is Android 7.0 (API 24), declared in `app.config.ts` and enfor 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 Android Notifications workflow performs a clean Expo prebuild, compiles the notification module, runs Robolectric tests on API 24, 26, 33 and 36 (plus API 25 for legacy expiry), and runs Android lint. It uses no Firebase, signing or relay secrets. Run the same tasks from a generated `apps/mobile/android` project with JDK 21 available: +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 From 94a3d77d1cc882742b8cc7e992a4ad43ebb00b77 Mon Sep 17 00:00:00 2001 From: Julius Marminge <51714798+juliusmarminge@users.noreply.github.com> Date: Tue, 8 Sep 2026 16:19:05 -0700 Subject: [PATCH 06/17] fix(mobile): apply shared alert policy to Android delivery --- .../remoteRegistration.test.ts | 92 ++++++++++--------- infra/relay/scripts/android-push-smoke.ts | 17 ++-- infra/relay/scripts/android-push-watch.ts | 16 ++-- infra/relay/src/agentActivity/FcmClient.ts | 6 +- .../src/agentActivity/FcmDeliveries.test.ts | 53 +++++++++++ .../relay/src/agentActivity/FcmDeliveries.ts | 69 +++++--------- infra/relay/src/agentActivity/fcmPayloads.ts | 27 +++--- 7 files changed, 161 insertions(+), 119 deletions(-) diff --git a/apps/mobile/src/features/agent-awareness/remoteRegistration.test.ts b/apps/mobile/src/features/agent-awareness/remoteRegistration.test.ts index 03f3d14e5962..bd4109c275e6 100644 --- a/apps/mobile/src/features/agent-awareness/remoteRegistration.test.ts +++ b/apps/mobile/src/features/agent-awareness/remoteRegistration.test.ts @@ -951,49 +951,59 @@ 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", - 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: "ios", data: "rotated" }); - yield* runBackgroundOperations(); - expect(registrations.at(-1)).toMatchObject({ - preferences: { notificationsEnabled: false }, + 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", }); - 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)), + 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({ diff --git a/infra/relay/scripts/android-push-smoke.ts b/infra/relay/scripts/android-push-smoke.ts index c6f8d1c96c8e..423b78a53d3f 100644 --- a/infra/relay/scripts/android-push-smoke.ts +++ b/infra/relay/scripts/android-push-smoke.ts @@ -8,8 +8,8 @@ import * as Redacted from "effect/Redacted"; import * as Schema from "effect/Schema"; import * as FetchHttpClient from "effect/unstable/http/FetchHttpClient"; -import { RelayConfiguration } from "../src/Config.ts"; -import { FcmClient, layer } from "../src/agentActivity/FcmClient.ts"; +import * as RelayConfiguration from "../src/Config.ts"; +import * as FcmClient from "../src/agentActivity/FcmClient.ts"; const Device = Schema.Struct({ token: Schema.NonEmptyString, @@ -22,7 +22,7 @@ const decodeDevice = Schema.decodeUnknownEffect(Schema.fromJsonString(Device)); const Phase = Schema.Literals(["running", "approval", "input", "completed", "failed", "end"]); const decodePhase = Schema.decodeUnknownEffect(Phase); -class SmokeError extends Schema.TaggedErrorClass()("SmokeError", { +class SmokeError extends Schema.TaggedError()("SmokeError", { message: Schema.String, }) {} @@ -54,7 +54,7 @@ const main = Effect.gen(function* () { : null; const active = phase === "running" || phase === "approval" || phase === "input"; const now = yield* Clock.currentTimeMillis; - const config: RelayConfiguration["Service"] = { + const config: RelayConfiguration.RelayConfiguration["Service"] = { relayIssuer: "http://localhost", fcmServiceAccount: Redacted.make(credentials), apns: null, @@ -67,7 +67,7 @@ const main = Effect.gen(function* () { managedEndpointBaseDomain: undefined, managedEndpointNamespace: undefined, }; - const result = yield* FcmClient.pipe( + const result = yield* FcmClient.FcmClient.pipe( Effect.flatMap((client) => client.send({ token: device.token, @@ -101,9 +101,12 @@ const main = Effect.gen(function* () { }), ), Effect.provide( - layer.pipe( + FcmClient.layer.pipe( Layer.provide( - Layer.mergeAll(Layer.succeed(RelayConfiguration, config), FetchHttpClient.layer), + Layer.mergeAll( + Layer.succeed(RelayConfiguration.RelayConfiguration, config), + FetchHttpClient.layer, + ), ), ), ), diff --git a/infra/relay/scripts/android-push-watch.ts b/infra/relay/scripts/android-push-watch.ts index 89c049f796e4..bb0eb250ac43 100644 --- a/infra/relay/scripts/android-push-watch.ts +++ b/infra/relay/scripts/android-push-watch.ts @@ -21,10 +21,10 @@ import * as FetchHttpClient from "effect/unstable/http/FetchHttpClient"; import { RpcClient, RpcSerialization } from "effect/unstable/rpc"; import * as Socket from "effect/unstable/socket/Socket"; -import { RelayConfiguration } from "../src/Config.ts"; +import * as RelayConfiguration from "../src/Config.ts"; import { androidActivityData, fitFcmData } from "../src/agentActivity/fcmPayloads.ts"; -import { FcmClient, layer as fcmLayer } from "../src/agentActivity/FcmClient.ts"; -import { androidAlertForState } from "../src/agentActivity/FcmDeliveries.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({ @@ -39,7 +39,7 @@ const Connection = Schema.Struct({ }); const readFile = (path: string) => Effect.tryPromise(() => NodeFSP.readFile(path, "utf8")); const encodeJson = Schema.encodeSync(Schema.fromJsonString(Schema.Unknown)); -class WatchError extends Schema.TaggedErrorClass()("AndroidPushWatchError", { +class WatchError extends Schema.TaggedError()("AndroidPushWatchError", { message: Schema.String, }) {} const preferences = { @@ -76,11 +76,11 @@ const main = Effect.gen(function* () { Layer.provide(Socket.layerWebSocket(connection.wsUrl).pipe(Layer.provide(socketConstructor))), Layer.provide(RpcSerialization.layerJson), ); - const fcm = fcmLayer.pipe( + const fcm = FcmClient.layer.pipe( Layer.provide( Layer.mergeAll( FetchHttpClient.layer, - Layer.succeed(RelayConfiguration, { + Layer.succeed(RelayConfiguration.RelayConfiguration, { relayIssuer: "http://localhost", apns: null, fcmServiceAccount: Redacted.make(credentials), @@ -98,7 +98,7 @@ const main = Effect.gen(function* () { ); yield* Effect.gen(function* () { const rpc = yield* RpcClient.make(WsRpcGroup); - const sender = yield* FcmClient; + const sender = yield* FcmClient.FcmClient; const config = yield* rpc[WS_METHODS.serverGetConfig]({}); const projects = new Map(); const threads = new Map(); @@ -147,7 +147,7 @@ const main = Effect.gen(function* () { const now = yield* Clock.currentTimeMillis; const alert = state && state.phase !== previous?.phase && item.kind !== "snapshot" - ? androidAlertForState(state, preferences, now) + ? FcmDeliveries.androidAlertForState(state, preferences, now) : null; const aggregate = makeAggregateState({ activeStates: [...next.values()], diff --git a/infra/relay/src/agentActivity/FcmClient.ts b/infra/relay/src/agentActivity/FcmClient.ts index 86d8e0ff55da..7337281fb7b9 100644 --- a/infra/relay/src/agentActivity/FcmClient.ts +++ b/infra/relay/src/agentActivity/FcmClient.ts @@ -8,7 +8,7 @@ import * as Schema from "effect/Schema"; import * as HttpClient from "effect/unstable/http/HttpClient"; import * as HttpClientRequest from "effect/unstable/http/HttpClientRequest"; -import { RelayConfiguration } from "../Config.ts"; +import * as RelayConfiguration from "../Config.ts"; const FCM_HTTP_STAGE_TIMEOUT = "10 seconds"; @@ -38,7 +38,7 @@ const decodeFcmError = Schema.decodeUnknownOption( }), ); -export class FcmClientError extends Schema.TaggedErrorClass()("FcmClientError", { +export class FcmClientError extends Schema.TaggedError()("FcmClientError", { operation: Schema.Literals(["configuration", "authorize", "send"]), status: Schema.NullOr(Schema.Number), }) { @@ -105,7 +105,7 @@ export const makeFcmAssertion = Effect.fn("relay.fcm.assertion")(function* ( }); export const make = Effect.gen(function* () { - const config = yield* RelayConfiguration; + const config = yield* RelayConfiguration.RelayConfiguration; const client = yield* HttpClient.HttpClient; const account = config.fcmServiceAccount ? decodeServiceAccount(Redacted.value(config.fcmServiceAccount)) diff --git a/infra/relay/src/agentActivity/FcmDeliveries.test.ts b/infra/relay/src/agentActivity/FcmDeliveries.test.ts index dbeb3c5dcc58..e30cc48dfb99 100644 --- a/infra/relay/src/agentActivity/FcmDeliveries.test.ts +++ b/infra/relay/src/agentActivity/FcmDeliveries.test.ts @@ -621,3 +621,56 @@ describe("Android delivery routing", () => { 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(); + }); +}); diff --git a/infra/relay/src/agentActivity/FcmDeliveries.ts b/infra/relay/src/agentActivity/FcmDeliveries.ts index 9a3391ece8bd..c2d52d64c2f5 100644 --- a/infra/relay/src/agentActivity/FcmDeliveries.ts +++ b/infra/relay/src/agentActivity/FcmDeliveries.ts @@ -14,13 +14,13 @@ import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import * as Schema from "effect/Schema"; -import { RelayConfiguration } from "../Config.ts"; -import { RelayDb } from "../db.ts"; +import * as RelayConfiguration from "../Config.ts"; +import * as RelayDb from "../db.ts"; import { relayMobileDevices } from "../persistence/schema.ts"; -import { EnvironmentLinks } from "../environments/EnvironmentLinks.ts"; -import { AgentActivityRows } from "./AgentActivityRows.ts"; -import { LiveActivities, type TargetRow } from "./LiveActivities.ts"; -import { FcmClient } from "./FcmClient.ts"; +import * as EnvironmentLinks from "../environments/EnvironmentLinks.ts"; +import * as AgentActivityRows from "./AgentActivityRows.ts"; +import * as LiveActivities from "./LiveActivities.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"; @@ -28,7 +28,7 @@ import { alertForActivityRows, attentionTransitionRows, terminalTransitionRows, - isFreshTerminalNotification, + shouldAlertForActivity, } from "./agentActivityAlerts.ts"; export const FcmDeliveryJob = Schema.Struct({ @@ -47,13 +47,10 @@ const decodePreviousActivity = Schema.decodeUnknownOption( Schema.fromJsonString(RelayAgentActivityAggregateState), ); -export class FcmDeliveryError extends Schema.TaggedErrorClass()( - "FcmDeliveryError", - { - operation: Schema.Literals(["enqueue", "process"]), - cause: Schema.Defect(), - }, -) { +export class FcmDeliveryError extends Schema.TaggedError()("FcmDeliveryError", { + operation: Schema.Literals(["enqueue", "process"]), + cause: Schema.Defect(), +}) { override get message() { return `Failed to ${this.operation} Android notification delivery.`; } @@ -70,7 +67,7 @@ export class FcmDeliveries extends Context.Service< FcmDeliveries, { readonly enqueue: (input: { - readonly target: TargetRow; + readonly target: LiveActivities.TargetRow; readonly state: RelayAgentActivityState | null; }) => Effect.Effect; readonly process: (body: unknown) => Effect.Effect; @@ -82,18 +79,7 @@ export function androidAlertForState( preferences: RelayAgentAwarenessPreferences, nowMs: number, ) { - if (!preferences.notificationsEnabled) return null; - if ( - (state.phase === "completed" || state.phase === "failed") && - !isFreshTerminalNotification(state.updatedAt, nowMs) - ) - return null; - const enabled = - (state.phase === "waiting_for_approval" && preferences.notifyOnApproval) || - (state.phase === "waiting_for_input" && preferences.notifyOnInput) || - (state.phase === "completed" && preferences.notifyOnCompletion) || - (state.phase === "failed" && preferences.notifyOnFailure); - if (!enabled) return null; + 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]), @@ -111,7 +97,10 @@ export function androidAlertForAggregate(input: { }) { if (!input.preferences.notificationsEnabled) return null; const attention = attentionTransitionRows(input); - const activities = attention.length > 0 ? attention : terminalTransitionRows(input); + const activities = + attention.length > 0 + ? attention + : terminalTransitionRows({ ...input, includeUnobserved: true }); const first = activities[0]; const alert = alertForActivityRows(activities); if (!first || !alert) return null; @@ -139,13 +128,13 @@ export function androidAlertForAggregate(input: { } export const make = Effect.gen(function* () { - const config = yield* RelayConfiguration; + const config = yield* RelayConfiguration.RelayConfiguration; const sender = yield* FcmDeliveryQueueSender; - const client = yield* FcmClient; - const devices = yield* LiveActivities; - const rows = yield* AgentActivityRows; - const links = yield* EnvironmentLinks; - const db = yield* RelayDb; + 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) { @@ -233,22 +222,12 @@ export const make = Effect.gen(function* () { }) : []; const deliveryUser = deliveryUsers.find((user) => user.userId === job.userId); - if ( - deliveryUser?.notificationsEnabled && - deliveryUser.liveActivitiesEnabled && - preferences.value.liveActivitiesEnabled && - previousAggregate && - aggregate - ) { + if (preferences.value.liveActivitiesEnabled && previousAggregate && aggregate) { const environmentIds = [ ...new Set(aggregate.activities.map((row) => row.environmentId)), ]; const allowedEnvironments = new Set(); for (const environmentId of environmentIds) { - if (environmentId === job.state.environmentId) { - allowedEnvironments.add(environmentId); - continue; - } const environmentLink = yield* links.getForUser({ userId: job.userId, environmentId, diff --git a/infra/relay/src/agentActivity/fcmPayloads.ts b/infra/relay/src/agentActivity/fcmPayloads.ts index 47f7ed280d29..c9a8573bc531 100644 --- a/infra/relay/src/agentActivity/fcmPayloads.ts +++ b/infra/relay/src/agentActivity/fcmPayloads.ts @@ -1,26 +1,23 @@ -import type { - RelayAgentActivityAggregateRow, - RelayAgentActivityAggregateState, -} from "@t3tools/contracts/relay"; -import { TERMINAL_AGENT_ACTIVITY_DISPLAY_TTL_MS } from "./agentActivityAggregate.ts"; +import type { RelayAgentActivityAggregateState } from "@t3tools/contracts/relay"; +import { + activityPhasePriority, + TERMINAL_AGENT_ACTIVITY_DISPLAY_TTL_MS, +} from "./agentActivityAggregate.ts"; import { agentActivityExpiresAt } from "./agentActivityPayloads.ts"; -function priority(row: RelayAgentActivityAggregateRow): number { - if (row.phase === "waiting_for_approval" || row.phase === "waiting_for_input") return 0; - if (row.phase === "failed") return 1; - if (row.phase === "starting" || row.phase === "running") return 2; - return 3; -} - export function androidActivityHero(aggregate: RelayAgentActivityAggregateState) { - return [...aggregate.activities].sort((a, b) => priority(a) - priority(b))[0]; + 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) => priority(a) - priority(b)); + const rows = [...(aggregate?.activities ?? [])].sort( + (a, b) => activityPhasePriority(a.phase) - activityPhasePriority(b.phase), + ); const activeCount = aggregate?.activeCount ?? 0; - const attentionCount = rows.filter((row) => priority(row) === 0).length; + 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) => From 6fff76ab8fa22ccc0c0d5f19ea421e493b7de188 Mon Sep 17 00:00:00 2001 From: Julius Marminge <51714798+juliusmarminge@users.noreply.github.com> Date: Tue, 8 Sep 2026 16:46:58 -0700 Subject: [PATCH 07/17] fix(mobile): address Android notification review findings --- .../AgentNotifications.kt | 16 ++++---- .../AgentNotificationsTest.kt | 18 ++++++++ .../agent-awareness/registrationPayload.ts | 30 +++++++------- .../features/settings/SettingsRouteScreen.tsx | 11 ++++- ...MobileClientsUserProfilePage.logic.test.ts | 3 ++ .../MobileClientsUserProfilePage.logic.ts | 7 +++- .../relay/src/agentActivity/FcmClient.test.ts | 1 + infra/relay/src/agentActivity/FcmClient.ts | 15 ++++--- .../src/agentActivity/FcmDeliveries.test.ts | 41 ++++++++++++++++++- .../relay/src/agentActivity/FcmDeliveries.ts | 20 +++------ infra/relay/src/agentActivity/fcmPayloads.ts | 11 ++++- infra/relay/src/worker.ts | 10 ++++- 12 files changed, 136 insertions(+), 47 deletions(-) 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 index f752ada20d74..d1b81661151c 100644 --- 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 @@ -134,7 +134,8 @@ object AgentNotifications { // 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.getStringSet("seenAlerts", emptySet()).orEmpty() + 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. @@ -151,9 +152,9 @@ object AgentNotifications { .build() manager(context).notify(ALERT_TAG, id, notification) } - prefs.edit().putStringSet( - "seenAlerts", - (seen.toList().takeLast(63) + alertId).toSet() + prefs.edit().remove("seenAlerts").putString( + "seenAlertsOrdered", + (seen.takeLast(63) + alertId).joinToString("\n") ).apply() } } @@ -318,11 +319,12 @@ object AgentNotifications { scheme: String, path: String?, id: Int - ): PendingIntent { + ): PendingIntent? { val threadPath = path?.takeIf { it.startsWith("/threads/") } val route = threadPath?.takeUnless { it.contains('?') || it.contains('#') } ?: "/" - val intent = context.packageManager.getLaunchIntentForPackage(context.packageName)!! - .setAction(Intent.ACTION_VIEW).setData(Uri.parse("$scheme:/$route")) + 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, diff --git a/apps/mobile/modules/t3-agent-notifications/android/src/test/java/expo/modules/t3agentnotifications/AgentNotificationsTest.kt b/apps/mobile/modules/t3-agent-notifications/android/src/test/java/expo/modules/t3agentnotifications/AgentNotificationsTest.kt index c080cd92ba0e..d30374bae7a9 100644 --- a/apps/mobile/modules/t3-agent-notifications/android/src/test/java/expo/modules/t3agentnotifications/AgentNotificationsTest.kt +++ b/apps/mobile/modules/t3-agent-notifications/android/src/test/java/expo/modules/t3agentnotifications/AgentNotificationsTest.kt @@ -65,6 +65,24 @@ class AgentNotificationsTest { "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 diff --git a/apps/mobile/src/features/agent-awareness/registrationPayload.ts b/apps/mobile/src/features/agent-awareness/registrationPayload.ts index 084ce7d3079e..629ec5f60abb 100644 --- a/apps/mobile/src/features/agent-awareness/registrationPayload.ts +++ b/apps/mobile/src/features/agent-awareness/registrationPayload.ts @@ -10,20 +10,22 @@ export function resolveApsEnvironment(appVariant: unknown): "sandbox" | "product return appVariant === "development" ? "sandbox" : "production"; } -export function makeRelayDeviceRegistrationRequest(input: { - readonly deviceId: string; - readonly label: string; - readonly platform?: "ios" | "android"; - readonly iosMajorVersion?: number; - readonly androidApiLevel?: 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 { diff --git a/apps/mobile/src/features/settings/SettingsRouteScreen.tsx b/apps/mobile/src/features/settings/SettingsRouteScreen.tsx index 3b6b360d1e4b..77036c212517 100644 --- a/apps/mobile/src/features/settings/SettingsRouteScreen.tsx +++ b/apps/mobile/src/features/settings/SettingsRouteScreen.tsx @@ -307,7 +307,16 @@ function ConfiguredSettingsRouteScreen() { const permission = await settleAsyncResult(() => runtime.runPromiseExit(requestAgentNotificationPermission), ); - if (permission._tag === "Failure" || permission.value.type !== "granted") { + 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", diff --git a/apps/web/src/components/clerk/MobileClientsUserProfilePage.logic.test.ts b/apps/web/src/components/clerk/MobileClientsUserProfilePage.logic.test.ts index 531ca1d5a1bc..979f57cff16a 100644 --- a/apps/web/src/components/clerk/MobileClientsUserProfilePage.logic.test.ts +++ b/apps/web/src/components/clerk/MobileClientsUserProfilePage.logic.test.ts @@ -67,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 1a69425aa4fd..b52d381f6ddf 100644 --- a/apps/web/src/components/clerk/MobileClientsUserProfilePage.logic.ts +++ b/apps/web/src/components/clerk/MobileClientsUserProfilePage.logic.ts @@ -15,7 +15,12 @@ const NOTIFICATION_PREFERENCES = [ >; export function mobileClientPlatformLabel(device: RelayClientDeviceRecord): string { - const platform = device.platform === "android" ? "Android" : `iOS ${device.iosMajorVersion}`; + const platform = + device.platform === "android" + ? "Android" + : device.iosMajorVersion === null + ? "iOS" + : `iOS ${device.iosMajorVersion}`; return `${platform}${device.appVersion ? ` · T3 Code ${device.appVersion}` : ""}`; } diff --git a/infra/relay/src/agentActivity/FcmClient.test.ts b/infra/relay/src/agentActivity/FcmClient.test.ts index afdd480570b0..d4a4b35014b9 100644 --- a/infra/relay/src/agentActivity/FcmClient.test.ts +++ b/infra/relay/src/agentActivity/FcmClient.test.ts @@ -106,6 +106,7 @@ describe("FCM delivery", () => { _tag: "FcmClientError", operation: scenario.operation, status: scenario.status, + cause: expect.objectContaining({ _tag: "TimeoutError" }), }); expect(yield* client.send(input)).toEqual({ unregistered: false }); }).pipe( diff --git a/infra/relay/src/agentActivity/FcmClient.ts b/infra/relay/src/agentActivity/FcmClient.ts index 7337281fb7b9..a9c33eca8c98 100644 --- a/infra/relay/src/agentActivity/FcmClient.ts +++ b/infra/relay/src/agentActivity/FcmClient.ts @@ -41,6 +41,7 @@ const decodeFcmError = Schema.decodeUnknownOption( 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})`}.`; @@ -100,7 +101,7 @@ export const makeFcmAssertion = Effect.fn("relay.fcm.assertion")(function* ( ); return `${header}.${claims}.${base64Url(new Uint8Array(signature))}`; }, - catch: () => new FcmClientError({ operation: "authorize", status: null }), + catch: (cause) => new FcmClientError({ operation: "authorize", status: null, cause }), }); }); @@ -129,7 +130,9 @@ export const make = Effect.gen(function* () { ) .pipe( Effect.timeout(FCM_HTTP_STAGE_TIMEOUT), - Effect.mapError(() => new FcmClientError({ operation: "authorize", status: null })), + Effect.mapError( + (cause) => new FcmClientError({ operation: "authorize", status: null, cause }), + ), ); if (response.status !== 200) return yield* new FcmClientError({ operation: "authorize", status: response.status }); @@ -138,7 +141,7 @@ export const make = Effect.gen(function* () { Effect.flatMap(decodeAccessToken), Effect.map((body) => body.access_token), Effect.mapError( - () => new FcmClientError({ operation: "authorize", status: response.status }), + (cause) => new FcmClientError({ operation: "authorize", status: response.status, cause }), ), ); }); @@ -172,13 +175,15 @@ export const make = Effect.gen(function* () { }), Effect.flatMap(client.execute), Effect.timeout(FCM_HTTP_STAGE_TIMEOUT), - Effect.mapError(() => new FcmClientError({ operation: "send", status: null })), + 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.orElseSucceed(() => null), + Effect.mapError( + (cause) => new FcmClientError({ operation: "send", status: response.status, cause }), + ), ); const decoded = decodeFcmError(body); const unregistered = diff --git a/infra/relay/src/agentActivity/FcmDeliveries.test.ts b/infra/relay/src/agentActivity/FcmDeliveries.test.ts index e30cc48dfb99..4f77e72bf098 100644 --- a/infra/relay/src/agentActivity/FcmDeliveries.test.ts +++ b/infra/relay/src/agentActivity/FcmDeliveries.test.ts @@ -94,6 +94,7 @@ function harness() { state: { ...state } as RelayAgentActivityState | null, otherStates: [] as RelayAgentActivityState[], mutedEnvironments: [] as string[], + notificationOnlyEnvironments: [] as string[], revokedEnvironments: [] as string[], linked: true, }; @@ -132,7 +133,9 @@ function harness() { Effect.sync(() => current.linked ? [...(current.state ? [current.state] : []), ...current.otherStates].filter( - (row) => !current.revokedEnvironments.includes(row.environmentId), + (row) => + !current.revokedEnvironments.includes(row.environmentId) && + !current.notificationOnlyEnvironments.includes(row.environmentId), ) : [], ), @@ -156,7 +159,9 @@ function harness() { { userId: "user", notificationsEnabled: !current.mutedEnvironments.includes(input.environmentId), - liveActivitiesEnabled: true, + liveActivitiesEnabled: !current.notificationOnlyEnvironments.includes( + input.environmentId, + ), }, ] : [], @@ -674,3 +679,35 @@ describe("delivery policy regressions", () => { ).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😀😀😀😀"); +}); diff --git a/infra/relay/src/agentActivity/FcmDeliveries.ts b/infra/relay/src/agentActivity/FcmDeliveries.ts index c2d52d64c2f5..13fe1fcd48d0 100644 --- a/infra/relay/src/agentActivity/FcmDeliveries.ts +++ b/infra/relay/src/agentActivity/FcmDeliveries.ts @@ -1,4 +1,3 @@ -import * as Alchemy from "alchemy"; import * as Cloudflare from "alchemy/Cloudflare"; import { and, eq } from "drizzle-orm"; import { @@ -222,7 +221,12 @@ export const make = Effect.gen(function* () { }) : []; const deliveryUser = deliveryUsers.find((user) => user.userId === job.userId); - if (preferences.value.liveActivitiesEnabled && previousAggregate && aggregate) { + if ( + deliveryUser?.liveActivitiesEnabled && + preferences.value.liveActivitiesEnabled && + previousAggregate && + aggregate + ) { const environmentIds = [ ...new Set(aggregate.activities.map((row) => row.environmentId)), ]; @@ -322,15 +326,3 @@ export const make = Effect.gen(function* () { }); export const layer = Layer.effect(FcmDeliveries, make); -export const layerCloudflareQueues = ( - sender: Cloudflare.Queues.WriteQueueClient, - runtime: Alchemy.BaseRuntimeContext, -) => - layer.pipe( - Layer.provide( - Layer.succeed(FcmDeliveryQueueSender, { - send: (body) => - sender.send(body).pipe(Effect.provideService(Alchemy.RuntimeContext, runtime)), - }), - ), - ); diff --git a/infra/relay/src/agentActivity/fcmPayloads.ts b/infra/relay/src/agentActivity/fcmPayloads.ts index c9a8573bc531..877dcaaa425c 100644 --- a/infra/relay/src/agentActivity/fcmPayloads.ts +++ b/infra/relay/src/agentActivity/fcmPayloads.ts @@ -62,11 +62,18 @@ export function fitFcmData(input: Readonly>): Record encoder.encode(data[b]!).length - encoder.encode(data[a]!).length, )[0]; - if (!key || data[key]!.length <= 8) break; + 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]!); - if (characters.length <= 4) break; + if (characters.length <= 4) { + textKeys.splice(textKeys.indexOf(key), 1); + continue; + } parts[part] = characters .slice(0, Math.floor(characters.length * 0.8)) diff --git a/infra/relay/src/worker.ts b/infra/relay/src/worker.ts index 15e5e6143302..633872ad3719 100644 --- a/infra/relay/src/worker.ts +++ b/infra/relay/src/worker.ts @@ -221,7 +221,15 @@ export const ApiLive = Api.make( Layer.provideMerge(DpopProofs.layer), Layer.provideMerge(ApnsDeliveries.layer), Layer.provideMerge( - FcmDeliveries.layerCloudflareQueues(fcmDeliveryQueueSender, alchemyRuntimeContext).pipe( + FcmDeliveries.layer.pipe( + Layer.provide( + Layer.succeed(FcmDeliveries.FcmDeliveryQueueSender, { + send: (body) => + fcmDeliveryQueueSender + .send(body) + .pipe(Effect.provideService(Alchemy.RuntimeContext, alchemyRuntimeContext)), + }), + ), Layer.provideMerge(FcmClient.layer), ), ), From 8d2e363a808fbc3da3c6b9acd34c50b11d8976e5 Mon Sep 17 00:00:00 2001 From: Julius Marminge <51714798+juliusmarminge@users.noreply.github.com> Date: Tue, 8 Sep 2026 16:54:29 -0700 Subject: [PATCH 08/17] fix(relay): preserve card alerts across notification-only jobs --- .../AgentNotificationsTest.kt | 5 ++++- .../src/agentActivity/FcmDeliveries.test.ts | 19 +++++++++++++++++++ .../relay/src/agentActivity/FcmDeliveries.ts | 8 +++++++- 3 files changed, 30 insertions(+), 2 deletions(-) diff --git a/apps/mobile/modules/t3-agent-notifications/android/src/test/java/expo/modules/t3agentnotifications/AgentNotificationsTest.kt b/apps/mobile/modules/t3-agent-notifications/android/src/test/java/expo/modules/t3agentnotifications/AgentNotificationsTest.kt index d30374bae7a9..734fbbc2f3b2 100644 --- a/apps/mobile/modules/t3-agent-notifications/android/src/test/java/expo/modules/t3agentnotifications/AgentNotificationsTest.kt +++ b/apps/mobile/modules/t3-agent-notifications/android/src/test/java/expo/modules/t3agentnotifications/AgentNotificationsTest.kt @@ -80,7 +80,10 @@ class AgentNotificationsTest { 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)) + assertEquals( + "Test thread", + manager.activeNotifications.single().notification.extras.getString(Notification.EXTRA_TITLE) + ) } @Test diff --git a/infra/relay/src/agentActivity/FcmDeliveries.test.ts b/infra/relay/src/agentActivity/FcmDeliveries.test.ts index 4f77e72bf098..c62636d18e1f 100644 --- a/infra/relay/src/agentActivity/FcmDeliveries.test.ts +++ b/infra/relay/src/agentActivity/FcmDeliveries.test.ts @@ -711,3 +711,22 @@ it("continues shrinking text when a longer activity line is already minimal", () 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)); +}); diff --git a/infra/relay/src/agentActivity/FcmDeliveries.ts b/infra/relay/src/agentActivity/FcmDeliveries.ts index 13fe1fcd48d0..eee89185ad7c 100644 --- a/infra/relay/src/agentActivity/FcmDeliveries.ts +++ b/infra/relay/src/agentActivity/FcmDeliveries.ts @@ -199,6 +199,7 @@ export const make = Effect.gen(function* () { ? Option.getOrNull(decodePreviousActivity(target.last_aggregate_json)) : null; let alert: ReturnType = null; + let acknowledgeAggregate = true; if (job.state && preferences.value.notificationsEnabled) { const state = yield* rows.getForUserThread({ userId: job.userId, @@ -221,6 +222,11 @@ export const make = Effect.gen(function* () { }) : []; 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 && @@ -308,7 +314,7 @@ export const make = Effect.gen(function* () { eq(relayMobileDevices.pushToken, job.token), ), ); - } else { + } else if (acknowledgeAggregate) { yield* devices.markDelivery({ userId: job.userId, deviceId: job.deviceId, From 693fffacad1594fbdbe713f155cd612a402c5b51 Mon Sep 17 00:00:00 2001 From: Julius Marminge <51714798+juliusmarminge@users.noreply.github.com> Date: Tue, 8 Sep 2026 17:02:25 -0700 Subject: [PATCH 09/17] refactor(relay): declare FCM crypto dependencies and preserve errors --- infra/relay/scripts/android-push-smoke.ts | 2 + infra/relay/scripts/android-push-watch.ts | 2 + .../src/agentActivity/FcmAssertionSigner.ts | 74 ++++ .../relay/src/agentActivity/FcmClient.test.ts | 15 +- infra/relay/src/agentActivity/FcmClient.ts | 62 +--- .../src/agentActivity/FcmDeliveries.test.ts | 37 +- .../relay/src/agentActivity/FcmDeliveries.ts | 320 +++++++++--------- infra/relay/src/agentActivity/fcmPayloads.ts | 3 +- infra/relay/src/worker.ts | 3 +- 9 files changed, 305 insertions(+), 213 deletions(-) create mode 100644 infra/relay/src/agentActivity/FcmAssertionSigner.ts diff --git a/infra/relay/scripts/android-push-smoke.ts b/infra/relay/scripts/android-push-smoke.ts index 423b78a53d3f..77961437f877 100644 --- a/infra/relay/scripts/android-push-smoke.ts +++ b/infra/relay/scripts/android-push-smoke.ts @@ -9,6 +9,7 @@ import * as Schema from "effect/Schema"; import * as FetchHttpClient from "effect/unstable/http/FetchHttpClient"; import * as RelayConfiguration from "../src/Config.ts"; +import * as FcmAssertionSigner from "../src/agentActivity/FcmAssertionSigner.ts"; import * as FcmClient from "../src/agentActivity/FcmClient.ts"; const Device = Schema.Struct({ @@ -102,6 +103,7 @@ const main = Effect.gen(function* () { ), Effect.provide( FcmClient.layer.pipe( + Layer.provide(FcmAssertionSigner.layer), Layer.provide( Layer.mergeAll( Layer.succeed(RelayConfiguration.RelayConfiguration, config), diff --git a/infra/relay/scripts/android-push-watch.ts b/infra/relay/scripts/android-push-watch.ts index bb0eb250ac43..e8b4d80289d4 100644 --- a/infra/relay/scripts/android-push-watch.ts +++ b/infra/relay/scripts/android-push-watch.ts @@ -23,6 +23,7 @@ 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 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"; @@ -77,6 +78,7 @@ const main = Effect.gen(function* () { Layer.provide(RpcSerialization.layerJson), ); const fcm = FcmClient.layer.pipe( + Layer.provide(FcmAssertionSigner.layer), Layer.provide( Layer.mergeAll( FetchHttpClient.layer, diff --git a/infra/relay/src/agentActivity/FcmAssertionSigner.ts b/infra/relay/src/agentActivity/FcmAssertionSigner.ts new file mode 100644 index 000000000000..9e7996fd7118 --- /dev/null +++ b/infra/relay/src/agentActivity/FcmAssertionSigner.ts @@ -0,0 +1,74 @@ +import * as Context from "effect/Context"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Schema from "effect/Schema"; + +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."; + } +} + +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") {} + +function base64Url(bytes: Uint8Array): string { + return btoa(String.fromCharCode(...bytes)) + .replaceAll("+", "-") + .replaceAll("/", "_") + .replace(/=+$/, ""); +} + +export const make = Effect.sync(() => { + const subtle = globalThis.crypto.subtle; + 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 index d4a4b35014b9..5fbe3f66b3f8 100644 --- a/infra/relay/src/agentActivity/FcmClient.test.ts +++ b/infra/relay/src/agentActivity/FcmClient.test.ts @@ -12,7 +12,9 @@ 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, makeFcmAssertion } from "./FcmClient.ts"; +import { FcmClient, layer } from "./FcmClient.ts"; + +import * as FcmAssertionSigner from "./FcmAssertionSigner.ts"; const { privateKey, publicKey } = NodeCrypto.generateKeyPairSync("rsa", { modulusLength: 2048, @@ -61,6 +63,7 @@ function testLayer(requests: HttpClientRequest.HttpClientRequest[], responses: R : Effect.die("unexpected request"); }); return layer.pipe( + Layer.provide(FcmAssertionSigner.layer), Layer.provide( Layer.mergeAll( Layer.succeed(RelayConfiguration, config), @@ -112,6 +115,7 @@ describe("FCM delivery", () => { }).pipe( Effect.provide( layer.pipe( + Layer.provide(FcmAssertionSigner.layer), Layer.provide(Layer.succeed(RelayConfiguration, config)), Layer.provide(Layer.succeed(HttpClient.HttpClient, http)), ), @@ -122,7 +126,12 @@ describe("FCM delivery", () => { it.effect("signs a verifiable Google OAuth assertion scoped to messaging", () => Effect.gen(function* () { - const assertion = yield* makeFcmAssertion(account, 1000); + 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( @@ -139,7 +148,7 @@ describe("FCM delivery", () => { iat: 1000, exp: 4600, }); - }), + }).pipe(Effect.provide(FcmAssertionSigner.layer)), ); it.effect( diff --git a/infra/relay/src/agentActivity/FcmClient.ts b/infra/relay/src/agentActivity/FcmClient.ts index a9c33eca8c98..eb8d327f7a5e 100644 --- a/infra/relay/src/agentActivity/FcmClient.ts +++ b/infra/relay/src/agentActivity/FcmClient.ts @@ -9,6 +9,7 @@ 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"; @@ -60,53 +61,9 @@ export class FcmClient extends Context.Service< } >()("t3code-relay/agentActivity/FcmClient") {} -function base64Url(bytes: Uint8Array): string { - return btoa(String.fromCharCode(...bytes)) - .replaceAll("+", "-") - .replaceAll("/", "_") - .replace(/=+$/, ""); -} - -export const makeFcmAssertion = Effect.fn("relay.fcm.assertion")(function* ( - account: typeof ServiceAccount.Type, - issuedAt: number, -) { - 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: account.client_email, - scope: "https://www.googleapis.com/auth/firebase.messaging", - aud: "https://oauth2.googleapis.com/token", - iat: issuedAt, - exp: issuedAt + 3600, - }), - ), - ); - const pem = account.private_key.replace(/-----[^-]+-----|\s/g, ""); - const key = await crypto.subtle.importKey( - "pkcs8", - Uint8Array.from(atob(pem), (c) => c.charCodeAt(0)), - { name: "RSASSA-PKCS1-v1_5", hash: "SHA-256" }, - false, - ["sign"], - ); - const signature = await crypto.subtle.sign( - "RSASSA-PKCS1-v1_5", - key, - encoder.encode(`${header}.${claims}`), - ); - return `${header}.${claims}.${base64Url(new Uint8Array(signature))}`; - }, - catch: (cause) => new FcmClientError({ operation: "authorize", status: null, cause }), - }); -}); - 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)) @@ -115,10 +72,17 @@ export const make = Effect.gen(function* () { if (Option.isNone(account)) return yield* new FcmClientError({ operation: "configuration", status: null }); const now = yield* DateTime.now; - const assertion = yield* makeFcmAssertion( - account.value, - Math.floor(now.epochMilliseconds / 1000), - ); + 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( diff --git a/infra/relay/src/agentActivity/FcmDeliveries.test.ts b/infra/relay/src/agentActivity/FcmDeliveries.test.ts index c62636d18e1f..5d48677bb287 100644 --- a/infra/relay/src/agentActivity/FcmDeliveries.test.ts +++ b/infra/relay/src/agentActivity/FcmDeliveries.test.ts @@ -1,6 +1,7 @@ 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 Layer from "effect/Layer"; import * as Redacted from "effect/Redacted"; @@ -11,7 +12,7 @@ import { RelayDb } from "../db.ts"; import { EnvironmentLinks } from "../environments/EnvironmentLinks.ts"; import { AgentActivityRows } from "./AgentActivityRows.ts"; import { LiveActivities, type TargetRow } from "./LiveActivities.ts"; -import { FcmClient } from "./FcmClient.ts"; +import { FcmClient, FcmClientError } from "./FcmClient.ts"; import { FcmDeliveries, FcmDeliveryQueueSender, @@ -97,8 +98,10 @@ function harness() { 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, { send: (job) => @@ -108,10 +111,14 @@ function harness() { }), Layer.succeed(FcmClient, { send: (input) => - Effect.sync(() => { - sent.push(input); - return { unregistered: false }; - }), + Effect.suspend(() => + current.deliveryFailure + ? Effect.fail(current.deliveryFailure) + : Effect.sync(() => { + sent.push(input); + return { unregistered: false }; + }), + ), }), Layer.succeed(LiveActivities, { register: () => Effect.void, @@ -730,3 +737,23 @@ it.effect("notification-only jobs do not consume another environment's card aler 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); +}); diff --git a/infra/relay/src/agentActivity/FcmDeliveries.ts b/infra/relay/src/agentActivity/FcmDeliveries.ts index eee89185ad7c..3d785d17ff12 100644 --- a/infra/relay/src/agentActivity/FcmDeliveries.ts +++ b/infra/relay/src/agentActivity/FcmDeliveries.ts @@ -6,6 +6,8 @@ import { 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"; @@ -47,7 +49,7 @@ const decodePreviousActivity = Schema.decodeUnknownOption( ); export class FcmDeliveryError extends Schema.TaggedError()("FcmDeliveryError", { - operation: Schema.Literals(["enqueue", "process"]), + operation: Schema.Literals(["enqueue", "decode-job", "invalidate-token"]), cause: Schema.Defect(), }) { override get message() { @@ -69,7 +71,19 @@ export class FcmDeliveries extends Context.Service< readonly target: LiveActivities.TargetRow; readonly state: RelayAgentActivityState | null; }) => Effect.Effect; - readonly process: (body: unknown) => 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") {} @@ -128,6 +142,7 @@ export function androidAlertForAggregate(input: { export const make = Effect.gen(function* () { const config = yield* RelayConfiguration.RelayConfiguration; + const crypto = yield* Crypto.Crypto; const sender = yield* FcmDeliveryQueueSender; const client = yield* FcmClient.FcmClient; const devices = yield* LiveActivities.LiveActivities; @@ -169,165 +184,162 @@ export const make = Effect.gen(function* () { apnsId: null, }; }), - process: Effect.fn("relay.fcm.process")( - function* (body) { - const job = yield* decodeJob(body); - 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; + 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, + // 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; + let acknowledgeAggregate = true; + if (job.state && preferences.value.notificationsEnabled) { + const state = yield* rows.getForUserThread({ + userId: job.userId, + environmentId: job.state.environmentId, + threadId: job.state.threadId, }); - const previousAggregate = target.last_aggregate_json - ? Option.getOrNull(decodePreviousActivity(target.last_aggregate_json)) - : null; - let alert: ReturnType = null; - let acknowledgeAggregate = true; - 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, + 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, }); - } 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); + if (users.some((user) => user.userId === job.userId && user.notificationsEnabled)) { + allowedEnvironments.add(environmentId); + } } - } - 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* Effect.promise(() => - crypto.subtle.digest("SHA-256", new TextEncoder().encode(alert.alert_id)), - ); - data.alert_id = Array.from(new Uint8Array(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), + alert = androidAlertForAggregate({ + previousAggregate, + nextAggregate: { + ...aggregate, + activities: aggregate.activities.filter((row) => + allowedEnvironments.has(row.environmentId), ), - ); - } 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), + }, + 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); } - }, - Effect.mapError((cause) => new FcmDeliveryError({ operation: "process", cause })), - ), + } + 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), + }); + } + }), }); }); diff --git a/infra/relay/src/agentActivity/fcmPayloads.ts b/infra/relay/src/agentActivity/fcmPayloads.ts index 877dcaaa425c..798714d4665b 100644 --- a/infra/relay/src/agentActivity/fcmPayloads.ts +++ b/infra/relay/src/agentActivity/fcmPayloads.ts @@ -70,7 +70,8 @@ export function fitFcmData(input: Readonly>): Record parts[2]!.length ? 1 : 2) : 0; const characters = Array.from(parts[part]!); - if (characters.length <= 4) { + // Five characters would become four plus the ellipsis and never shrink. + if (characters.length <= 5) { textKeys.splice(textKeys.indexOf(key), 1); continue; } diff --git a/infra/relay/src/worker.ts b/infra/relay/src/worker.ts index 633872ad3719..ceba9ce0c035 100644 --- a/infra/relay/src/worker.ts +++ b/infra/relay/src/worker.ts @@ -52,6 +52,7 @@ import { RelayFcmDeliveryQueue, RelayFcmDeliveryDeadLetterQueue, } from "./queues.ts"; +import * as FcmAssertionSigner from "./agentActivity/FcmAssertionSigner.ts"; import * as FcmClient from "./agentActivity/FcmClient.ts"; import * as FcmDeliveries from "./agentActivity/FcmDeliveries.ts"; import * as RelayConfiguration from "./Config.ts"; @@ -230,7 +231,7 @@ export const ApiLive = Api.make( .pipe(Effect.provideService(Alchemy.RuntimeContext, alchemyRuntimeContext)), }), ), - Layer.provideMerge(FcmClient.layer), + Layer.provideMerge(FcmClient.layer.pipe(Layer.provide(FcmAssertionSigner.layer))), ), ), Layer.provideMerge(ApnsClient.layer.pipe(Layer.provideMerge(ApnsProviderTokens.layer))), From 38c4221066a0b361b87f2b603d08f30c53551e95 Mon Sep 17 00:00:00 2001 From: Julius Marminge <51714798+juliusmarminge@users.noreply.github.com> Date: Tue, 8 Sep 2026 17:11:03 -0700 Subject: [PATCH 10/17] refactor(relay): give the FCM queue sender its own service module --- .../src/agentActivity/FcmAssertionSigner.ts | 14 ++--- .../src/agentActivity/FcmDeliveries.test.ts | 4 +- .../relay/src/agentActivity/FcmDeliveries.ts | 57 ++++++++----------- .../agentActivity/FcmDeliveryQueueSender.ts | 14 +++++ infra/relay/src/worker.ts | 3 +- 5 files changed, 50 insertions(+), 42 deletions(-) create mode 100644 infra/relay/src/agentActivity/FcmDeliveryQueueSender.ts diff --git a/infra/relay/src/agentActivity/FcmAssertionSigner.ts b/infra/relay/src/agentActivity/FcmAssertionSigner.ts index 9e7996fd7118..63251efe7660 100644 --- a/infra/relay/src/agentActivity/FcmAssertionSigner.ts +++ b/infra/relay/src/agentActivity/FcmAssertionSigner.ts @@ -14,6 +14,13 @@ export class FcmAssertionSigningError extends Schema.TaggedError()("t3code-relay/agentActivity/FcmAssertionSigner") {} -function base64Url(bytes: Uint8Array): string { - return btoa(String.fromCharCode(...bytes)) - .replaceAll("+", "-") - .replaceAll("/", "_") - .replace(/=+$/, ""); -} - export const make = Effect.sync(() => { const subtle = globalThis.crypto.subtle; return FcmAssertionSigner.of({ diff --git a/infra/relay/src/agentActivity/FcmDeliveries.test.ts b/infra/relay/src/agentActivity/FcmDeliveries.test.ts index 5d48677bb287..0934a411c545 100644 --- a/infra/relay/src/agentActivity/FcmDeliveries.test.ts +++ b/infra/relay/src/agentActivity/FcmDeliveries.test.ts @@ -12,10 +12,10 @@ 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, - FcmDeliveryQueueSender, androidAlertForState, androidAlertForAggregate, layer, @@ -103,7 +103,7 @@ function harness() { const services = Layer.mergeAll( NodeCryptoLayer.layer, Layer.succeed(RelayConfiguration, config), - Layer.succeed(FcmDeliveryQueueSender, { + Layer.succeed(FcmDeliveryQueueSender.FcmDeliveryQueueSender, { send: (job) => Effect.sync(() => { queued.push(job); diff --git a/infra/relay/src/agentActivity/FcmDeliveries.ts b/infra/relay/src/agentActivity/FcmDeliveries.ts index 3d785d17ff12..9019a64ed80d 100644 --- a/infra/relay/src/agentActivity/FcmDeliveries.ts +++ b/infra/relay/src/agentActivity/FcmDeliveries.ts @@ -1,4 +1,3 @@ -import * as Cloudflare from "alchemy/Cloudflare"; import { and, eq } from "drizzle-orm"; import { RelayAgentActivityState, @@ -21,6 +20,7 @@ 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"; @@ -57,36 +57,6 @@ export class FcmDeliveryError extends Schema.TaggedError()("Fc } } -export class FcmDeliveryQueueSender extends Context.Service< - FcmDeliveryQueueSender, - { - readonly send: (body: FcmDeliveryJob) => Effect.Effect; - } ->()("t3code-relay/agentActivity/FcmDeliveries/FcmDeliveryQueueSender") {} - -export class FcmDeliveries extends Context.Service< - FcmDeliveries, - { - readonly enqueue: (input: { - readonly target: LiveActivities.TargetRow; - readonly state: RelayAgentActivityState | null; - }) => 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 function androidAlertForState( state: RelayAgentActivityState, preferences: RelayAgentAwarenessPreferences, @@ -140,10 +110,33 @@ export function androidAlertForAggregate(input: { }; } +export class FcmDeliveries extends Context.Service< + FcmDeliveries, + { + readonly enqueue: (input: { + readonly target: LiveActivities.TargetRow; + readonly state: RelayAgentActivityState | null; + }) => 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; + const sender = yield* FcmDeliveryQueueSender.FcmDeliveryQueueSender; const client = yield* FcmClient.FcmClient; const devices = yield* LiveActivities.LiveActivities; const rows = yield* AgentActivityRows.AgentActivityRows; 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/worker.ts b/infra/relay/src/worker.ts index ceba9ce0c035..45ed379e32f0 100644 --- a/infra/relay/src/worker.ts +++ b/infra/relay/src/worker.ts @@ -54,6 +54,7 @@ import { } from "./queues.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 RelayConfiguration from "./Config.ts"; import * as AgentActivityPublisher from "./agentActivity/AgentActivityPublisher.ts"; @@ -224,7 +225,7 @@ export const ApiLive = Api.make( Layer.provideMerge( FcmDeliveries.layer.pipe( Layer.provide( - Layer.succeed(FcmDeliveries.FcmDeliveryQueueSender, { + Layer.succeed(FcmDeliveryQueueSender.FcmDeliveryQueueSender, { send: (body) => fcmDeliveryQueueSender .send(body) From 7c9e30313cbeddc80725fe99b8d209614ecccefe Mon Sep 17 00:00:00 2001 From: Julius Marminge <51714798+juliusmarminge@users.noreply.github.com> Date: Tue, 8 Sep 2026 17:18:00 -0700 Subject: [PATCH 11/17] refactor(relay): inject Web Crypto at application boundaries --- infra/relay/scripts/android-push-smoke.ts | 7 +++++- infra/relay/scripts/android-push-watch.ts | 10 ++++++-- infra/relay/src/WebCrypto.ts | 5 ++++ .../src/agentActivity/FcmAssertionSigner.ts | 6 +++-- .../relay/src/agentActivity/FcmClient.test.ts | 23 ++++++++++++++++--- infra/relay/src/worker.ts | 10 +++++++- 6 files changed, 52 insertions(+), 9 deletions(-) create mode 100644 infra/relay/src/WebCrypto.ts diff --git a/infra/relay/scripts/android-push-smoke.ts b/infra/relay/scripts/android-push-smoke.ts index 77961437f877..f4693dfd8b5a 100644 --- a/infra/relay/scripts/android-push-smoke.ts +++ b/infra/relay/scripts/android-push-smoke.ts @@ -9,6 +9,7 @@ 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"; @@ -103,7 +104,11 @@ const main = Effect.gen(function* () { ), Effect.provide( FcmClient.layer.pipe( - Layer.provide(FcmAssertionSigner.layer), + Layer.provide( + FcmAssertionSigner.layer.pipe( + Layer.provide(Layer.succeed(WebCrypto.WebCrypto, { subtle: globalThis.crypto.subtle })), + ), + ), Layer.provide( Layer.mergeAll( Layer.succeed(RelayConfiguration.RelayConfiguration, config), diff --git a/infra/relay/scripts/android-push-watch.ts b/infra/relay/scripts/android-push-watch.ts index e8b4d80289d4..554e8afe8ff3 100644 --- a/infra/relay/scripts/android-push-watch.ts +++ b/infra/relay/scripts/android-push-watch.ts @@ -18,11 +18,13 @@ 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 { RpcClient, RpcSerialization } from "effect/unstable/rpc"; +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"; @@ -78,7 +80,11 @@ const main = Effect.gen(function* () { Layer.provide(RpcSerialization.layerJson), ); const fcm = FcmClient.layer.pipe( - Layer.provide(FcmAssertionSigner.layer), + Layer.provide( + FcmAssertionSigner.layer.pipe( + Layer.provide(Layer.succeed(WebCrypto.WebCrypto, { subtle: globalThis.crypto.subtle })), + ), + ), Layer.provide( Layer.mergeAll( FetchHttpClient.layer, 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/FcmAssertionSigner.ts b/infra/relay/src/agentActivity/FcmAssertionSigner.ts index 63251efe7660..3c804cd8ab4f 100644 --- a/infra/relay/src/agentActivity/FcmAssertionSigner.ts +++ b/infra/relay/src/agentActivity/FcmAssertionSigner.ts @@ -3,6 +3,8 @@ 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()( @@ -32,8 +34,8 @@ export class FcmAssertionSigner extends Context.Service< } >()("t3code-relay/agentActivity/FcmAssertionSigner") {} -export const make = Effect.sync(() => { - const subtle = globalThis.crypto.subtle; +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({ diff --git a/infra/relay/src/agentActivity/FcmClient.test.ts b/infra/relay/src/agentActivity/FcmClient.test.ts index 5fbe3f66b3f8..90818f92f079 100644 --- a/infra/relay/src/agentActivity/FcmClient.test.ts +++ b/infra/relay/src/agentActivity/FcmClient.test.ts @@ -14,6 +14,7 @@ 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", { @@ -63,7 +64,11 @@ function testLayer(requests: HttpClientRequest.HttpClientRequest[], responses: R : Effect.die("unexpected request"); }); return layer.pipe( - Layer.provide(FcmAssertionSigner.layer), + Layer.provide( + FcmAssertionSigner.layer.pipe( + Layer.provide(Layer.succeed(WebCrypto.WebCrypto, { subtle: globalThis.crypto.subtle })), + ), + ), Layer.provide( Layer.mergeAll( Layer.succeed(RelayConfiguration, config), @@ -115,7 +120,13 @@ describe("FCM delivery", () => { }).pipe( Effect.provide( layer.pipe( - Layer.provide(FcmAssertionSigner.layer), + 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)), ), @@ -148,7 +159,13 @@ describe("FCM delivery", () => { iat: 1000, exp: 4600, }); - }).pipe(Effect.provide(FcmAssertionSigner.layer)), + }).pipe( + Effect.provide( + FcmAssertionSigner.layer.pipe( + Layer.provide(Layer.succeed(WebCrypto.WebCrypto, { subtle: globalThis.crypto.subtle })), + ), + ), + ), ); it.effect( diff --git a/infra/relay/src/worker.ts b/infra/relay/src/worker.ts index 45ed379e32f0..433f8d57038d 100644 --- a/infra/relay/src/worker.ts +++ b/infra/relay/src/worker.ts @@ -52,6 +52,7 @@ import { 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"; @@ -232,7 +233,14 @@ export const ApiLive = Api.make( .pipe(Effect.provideService(Alchemy.RuntimeContext, alchemyRuntimeContext)), }), ), - Layer.provideMerge(FcmClient.layer.pipe(Layer.provide(FcmAssertionSigner.layer))), + 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))), From 6df36b6428aa61ab24378c0b59153e0f64e5833d Mon Sep 17 00:00:00 2001 From: Julius Marminge <51714798+juliusmarminge@users.noreply.github.com> Date: Tue, 8 Sep 2026 17:24:43 -0700 Subject: [PATCH 12/17] fix(relay): retain Android push CLI error causes --- infra/relay/scripts/android-push-smoke.ts | 27 ++++++++++++++++------ infra/relay/scripts/android-push-watch.ts | 28 ++++++++++++----------- 2 files changed, 35 insertions(+), 20 deletions(-) diff --git a/infra/relay/scripts/android-push-smoke.ts b/infra/relay/scripts/android-push-smoke.ts index f4693dfd8b5a..da3f5f5b9b57 100644 --- a/infra/relay/scripts/android-push-smoke.ts +++ b/infra/relay/scripts/android-push-smoke.ts @@ -25,24 +25,37 @@ const Phase = Schema.Literals(["running", "approval", "input", "completed", "fai const decodePhase = Schema.decodeUnknownEffect(Phase); class SmokeError extends Schema.TaggedError()("SmokeError", { - message: Schema.String, -}) {} + reason: Schema.Literals(["usage", "read-credentials", "read-device", "unregistered"]), + cause: Schema.optional(Schema.Defect()), +}) { + override get message() { + switch (this.reason) { + case "usage": + return "Usage: node scripts/android-push-smoke.ts "; + case "read-credentials": + return "Could not read service-account file."; + case "read-device": + return "Could not read device file."; + case "unregistered": + 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 SmokeError({ - message: - "Usage: node scripts/android-push-smoke.ts ", + reason: "usage", }); const phase = yield* decodePhase(phaseArg); const credentials = yield* Effect.tryPromise({ try: () => NodeFSP.readFile(credentialPath, "utf8"), - catch: () => new SmokeError({ message: "Could not read service-account file." }), + catch: (cause) => new SmokeError({ reason: "read-credentials", cause }), }); const device = yield* Effect.tryPromise({ try: () => NodeFSP.readFile(devicePath, "utf8"), - catch: () => new SmokeError({ message: "Could not read device file." }), + catch: (cause) => new SmokeError({ reason: "read-device", cause }), }).pipe(Effect.flatMap(decodeDevice)); const title = phase === "completed" @@ -120,7 +133,7 @@ const main = Effect.gen(function* () { ); if (result.unregistered) return yield* new SmokeError({ - message: "This device token is no longer registered with Firebase.", + reason: "unregistered", }); yield* Effect.logInfo( `Firebase accepted the ${phase} notification. Verify delivery on the device.`, diff --git a/infra/relay/scripts/android-push-watch.ts b/infra/relay/scripts/android-push-watch.ts index 554e8afe8ff3..bd9def2e984d 100644 --- a/infra/relay/scripts/android-push-watch.ts +++ b/infra/relay/scripts/android-push-watch.ts @@ -43,8 +43,15 @@ const Connection = Schema.Struct({ const readFile = (path: string) => Effect.tryPromise(() => NodeFSP.readFile(path, "utf8")); const encodeJson = Schema.encodeSync(Schema.fromJsonString(Schema.Unknown)); class WatchError extends Schema.TaggedError()("AndroidPushWatchError", { - message: Schema.String, -}) {} + reason: Schema.Literals(["unregistered", "stopped"]), + cause: Schema.optional(Schema.Defect()), +}) { + override get message() { + return this.reason === "unregistered" + ? "Device token is no longer registered" + : "Android push watcher stopped. Check the private connection and Firebase configuration."; + } +} const preferences = { notificationsEnabled: true, liveActivitiesEnabled: true, @@ -181,8 +188,7 @@ const main = Effect.gen(function* () { ...alert, }), }); - if (result.unregistered) - return yield* new WatchError({ message: "Device token is no longer registered" }); + if (result.unregistered) return yield* new WatchError({ reason: "unregistered" }); previouslyActive = active; yield* Effect.logInfo( `Android push accepted: ${state?.phase ?? (active ? "active" : "ended")}`, @@ -196,14 +202,10 @@ const main = Effect.gen(function* () { NodeRuntime.runMain( main.pipe( Effect.scoped, - // Socket failures may contain credential-bearing request headers. - Effect.catchCause(() => - Effect.fail( - new WatchError({ - message: - "Android push watcher stopped. Check the private connection and Firebase configuration.", - }), - ), - ), + Effect.catchCause((cause) => Effect.fail(new WatchError({ reason: "stopped", 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 }, ); From d1d09e6aeb0852cac2fa0450b569d6fd7b3b22f5 Mon Sep 17 00:00:00 2001 From: Julius Marminge <51714798+juliusmarminge@users.noreply.github.com> Date: Tue, 8 Sep 2026 17:30:01 -0700 Subject: [PATCH 13/17] fix(relay): refresh watched Android activity timestamps --- infra/relay/scripts/android-push-smoke.ts | 57 +++++++++++++---------- infra/relay/scripts/android-push-watch.ts | 27 ++++++----- 2 files changed, 49 insertions(+), 35 deletions(-) diff --git a/infra/relay/scripts/android-push-smoke.ts b/infra/relay/scripts/android-push-smoke.ts index da3f5f5b9b57..0c2078159ae7 100644 --- a/infra/relay/scripts/android-push-smoke.ts +++ b/infra/relay/scripts/android-push-smoke.ts @@ -24,38 +24,50 @@ const decodeDevice = Schema.decodeUnknownEffect(Schema.fromJsonString(Device)); const Phase = Schema.Literals(["running", "approval", "input", "completed", "failed", "end"]); const decodePhase = Schema.decodeUnknownEffect(Phase); -class SmokeError extends Schema.TaggedError()("SmokeError", { - reason: Schema.Literals(["usage", "read-credentials", "read-device", "unregistered"]), - cause: Schema.optional(Schema.Defect()), -}) { +class SmokeUsageError extends Schema.TaggedError()("SmokeUsageError", {}) { override get message() { - switch (this.reason) { - case "usage": - return "Usage: node scripts/android-push-smoke.ts "; - case "read-credentials": - return "Could not read service-account file."; - case "read-device": - return "Could not read device file."; - case "unregistered": - return "This device token is no longer registered with Firebase."; - } + 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 SmokeError({ - reason: "usage", - }); + 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 SmokeError({ reason: "read-credentials", cause }), + catch: (cause) => new SmokeCredentialReadError({ cause }), }); const device = yield* Effect.tryPromise({ try: () => NodeFSP.readFile(devicePath, "utf8"), - catch: (cause) => new SmokeError({ reason: "read-device", cause }), + catch: (cause) => new SmokeDeviceReadError({ cause }), }).pipe(Effect.flatMap(decodeDevice)); const title = phase === "completed" @@ -131,10 +143,7 @@ const main = Effect.gen(function* () { ), ), ); - if (result.unregistered) - return yield* new SmokeError({ - reason: "unregistered", - }); + if (result.unregistered) return yield* new SmokeUnregisteredDeviceError({}); yield* Effect.logInfo( `Firebase accepted the ${phase} notification. Verify delivery on the device.`, ); diff --git a/infra/relay/scripts/android-push-watch.ts b/infra/relay/scripts/android-push-watch.ts index bd9def2e984d..8c8fc71be724 100644 --- a/infra/relay/scripts/android-push-watch.ts +++ b/infra/relay/scripts/android-push-watch.ts @@ -42,16 +42,23 @@ const Connection = Schema.Struct({ }); const readFile = (path: string) => Effect.tryPromise(() => NodeFSP.readFile(path, "utf8")); const encodeJson = Schema.encodeSync(Schema.fromJsonString(Schema.Unknown)); -class WatchError extends Schema.TaggedError()("AndroidPushWatchError", { - reason: Schema.Literals(["unregistered", "stopped"]), - cause: Schema.optional(Schema.Defect()), +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 this.reason === "unregistered" - ? "Device token is no longer registered" - : "Android push watcher stopped. Check the private connection and Firebase configuration."; + return "Android push watcher stopped. Check the private connection and Firebase configuration."; } } + const preferences = { notificationsEnabled: true, liveActivitiesEnabled: true, @@ -170,9 +177,7 @@ const main = Effect.gen(function* () { nowMs: now, }); const active = (aggregate?.activeCount ?? 0) > 0; - const same = - encodeJson([...next.values()].map(({ updatedAt: _, ...value }) => value)) === - encodeJson([...states.values()].map(({ updatedAt: _, ...value }) => value)); + const same = encodeJson([...next.values()]) === encodeJson([...states.values()]); states = next; if ((!active && !previouslyActive && !alert) || (same && !alert)) return; const result = yield* sender.send({ @@ -188,7 +193,7 @@ const main = Effect.gen(function* () { ...alert, }), }); - if (result.unregistered) return yield* new WatchError({ reason: "unregistered" }); + if (result.unregistered) return yield* new WatchUnregisteredDeviceError({}); previouslyActive = active; yield* Effect.logInfo( `Android push accepted: ${state?.phase ?? (active ? "active" : "ended")}`, @@ -202,7 +207,7 @@ const main = Effect.gen(function* () { NodeRuntime.runMain( main.pipe( Effect.scoped, - Effect.catchCause((cause) => Effect.fail(new WatchError({ reason: "stopped", cause }))), + Effect.catchCause((cause) => Effect.fail(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)), From afcbc6f792b2697b48d05740ca895eaf6dcb6bcc Mon Sep 17 00:00:00 2001 From: Julius Marminge <51714798+juliusmarminge@users.noreply.github.com> Date: Tue, 8 Sep 2026 17:41:37 -0700 Subject: [PATCH 14/17] fix(relay): report unregistered Android watcher tokens --- infra/relay/scripts/android-push-watch.ts | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/infra/relay/scripts/android-push-watch.ts b/infra/relay/scripts/android-push-watch.ts index 8c8fc71be724..2b902b33f946 100644 --- a/infra/relay/scripts/android-push-watch.ts +++ b/infra/relay/scripts/android-push-watch.ts @@ -11,6 +11,8 @@ import { } 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"; @@ -59,6 +61,8 @@ class WatchStoppedError extends Schema.TaggedError()("WatchSt } } +const isWatchUnregisteredDeviceError = Schema.is(WatchUnregisteredDeviceError); + const preferences = { notificationsEnabled: true, liveActivitiesEnabled: true, @@ -207,7 +211,14 @@ const main = Effect.gen(function* () { NodeRuntime.runMain( main.pipe( Effect.scoped, - Effect.catchCause((cause) => Effect.fail(new WatchStoppedError({ cause }))), + 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)), From b970b65fccb76bee226ebd9014cd11ad0561d69b Mon Sep 17 00:00:00 2001 From: Julius Marminge <51714798+juliusmarminge@users.noreply.github.com> Date: Tue, 8 Sep 2026 19:12:48 -0700 Subject: [PATCH 15/17] fix(relay): version device listing for Android compatibility --- infra/relay/src/http/Api.test.ts | 89 ++++++++++++++++++- infra/relay/src/http/Api.ts | 14 +++ .../src/relay/managedRelay.test.ts | 27 +++++- .../client-runtime/src/relay/managedRelay.ts | 2 +- packages/contracts/src/relay.ts | 16 ++++ 5 files changed, 144 insertions(+), 4 deletions(-) diff --git a/infra/relay/src/http/Api.test.ts b/infra/relay/src/http/Api.test.ts index 222deafd621b..67ae919fb5f3 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 HttpApiTest from "effect/unstable/httpapi/HttpApiTest"; +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,79 @@ 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 client = yield* HttpApiTest.groups( + HttpApi.make("RelayApi").add(RelayApi.groups.client), + ["client"], + ); + const request = { headers: { authorization: "Bearer test-token" } }; + expect(yield* client.client.listDevices(request)).toEqual({ devices: [iphone] }); + expect(yield* client.client.listDevicesV2(request)).toEqual({ devices: [iphone, android] }); + }).pipe(Effect.provide(Layer.mergeAll(handlers, HttpServer.layerServices)), 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 42963da18fb7..6891ce4000b4 100644 --- a/infra/relay/src/http/Api.ts +++ b/infra/relay/src/http/Api.ts @@ -545,6 +545,20 @@ export const clientApi = HttpApiBuilder.group( .handle( "listDevices", Effect.fn("relay.api.client.listDevices")(function* () { + 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* () { const { userId } = yield* RelayClientPrincipal; return { devices: yield* devices.listForUser({ userId }) }; }, mapRelayCommonApiErrors("not_authorized")), 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.ts b/packages/contracts/src/relay.ts index 60b7a8a3df76..8b6562497681 100644 --- a/packages/contracts/src/relay.ts +++ b/packages/contracts/src/relay.ts @@ -97,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, @@ -975,6 +986,11 @@ const RelayClientGroup = HttpApiGroup.make("client") error: RelayAuthAndInternalErrors, }).annotate(OpenApi.Summary, "List linked environments"), HttpApiEndpoint.get("listDevices", "/v1/client/devices", { + headers: RelayBearerRequestHeaders, + success: RelayListDevicesResponseV1, + error: RelayAuthAndInternalErrors, + }).annotate(OpenApi.Summary, "List registered iOS devices (legacy clients)"), + HttpApiEndpoint.get("listDevicesV2", "/v2/client/devices", { headers: RelayBearerRequestHeaders, success: RelayListDevicesResponse, error: RelayAuthAndInternalErrors, From 68c70817286f7e8dcbd50adb89a0174f705e9343 Mon Sep 17 00:00:00 2001 From: Julius Marminge <51714798+juliusmarminge@users.noreply.github.com> Date: Tue, 8 Sep 2026 21:15:49 -0700 Subject: [PATCH 16/17] fix(relay): isolate FCM queue message failures --- .../src/agentActivity/FcmDeliveries.test.ts | 51 +++++++++++++++++++ .../agentActivity/FcmDeliveryQueueConsumer.ts | 25 +++++++++ infra/relay/src/http/Api.test.ts | 36 ++++++++++--- infra/relay/src/http/Api.ts | 2 + infra/relay/src/worker.ts | 8 ++- 5 files changed, 109 insertions(+), 13 deletions(-) create mode 100644 infra/relay/src/agentActivity/FcmDeliveryQueueConsumer.ts diff --git a/infra/relay/src/agentActivity/FcmDeliveries.test.ts b/infra/relay/src/agentActivity/FcmDeliveries.test.ts index 0934a411c545..5de46063165f 100644 --- a/infra/relay/src/agentActivity/FcmDeliveries.test.ts +++ b/infra/relay/src/agentActivity/FcmDeliveries.test.ts @@ -3,9 +3,13 @@ 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"; @@ -757,3 +761,50 @@ it("stops reducing five-character row fields and fits the remaining alert", () = 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/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/http/Api.test.ts b/infra/relay/src/http/Api.test.ts index 67ae919fb5f3..66b87e643a48 100644 --- a/infra/relay/src/http/Api.test.ts +++ b/infra/relay/src/http/Api.test.ts @@ -16,7 +16,7 @@ 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 HttpApiTest from "effect/unstable/httpapi/HttpApiTest"; +import * as HttpApiBuilder from "effect/unstable/httpapi/HttpApiBuilder"; import * as HttpApi from "effect/unstable/httpapi/HttpApi"; import { EnvironmentId } from "@t3tools/contracts"; import { @@ -136,14 +136,34 @@ describe("device listing compatibility", () => { ), ); return Effect.gen(function* () { - const client = yield* HttpApiTest.groups( - HttpApi.make("RelayApi").add(RelayApi.groups.client), - ["client"], + 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()), ); - const request = { headers: { authorization: "Bearer test-token" } }; - expect(yield* client.client.listDevices(request)).toEqual({ devices: [iphone] }); - expect(yield* client.client.listDevicesV2(request)).toEqual({ devices: [iphone, android] }); - }).pipe(Effect.provide(Layer.mergeAll(handlers, HttpServer.layerServices)), Effect.scoped); + 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); }); }); diff --git a/infra/relay/src/http/Api.ts b/infra/relay/src/http/Api.ts index 6891ce4000b4..fcaeca640420 100644 --- a/infra/relay/src/http/Api.ts +++ b/infra/relay/src/http/Api.ts @@ -545,6 +545,7 @@ 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 { @@ -559,6 +560,7 @@ export const clientApi = HttpApiBuilder.group( .handle( "listDevicesV2", Effect.fn("relay.api.client.listDevicesV2")(function* () { + yield* appendRelayCredentialResponseHeaders; const { userId } = yield* RelayClientPrincipal; return { devices: yield* devices.listForUser({ userId }) }; }, mapRelayCommonApiErrors("not_authorized")), diff --git a/infra/relay/src/worker.ts b/infra/relay/src/worker.ts index 433f8d57038d..caa5425b083f 100644 --- a/infra/relay/src/worker.ts +++ b/infra/relay/src/worker.ts @@ -57,6 +57,7 @@ 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"; @@ -308,11 +309,8 @@ export const ApiLive = Api.make( }, (stream) => stream.pipe( - Stream.runForEach((message) => - FcmDeliveries.FcmDeliveries.pipe( - Effect.flatMap((deliveries) => deliveries.process(message.body)), - ), - ), + Stream.withSpan("relay.fcm_delivery_queue.process_batch"), + Stream.runForEach(FcmDeliveryQueueConsumer.processMessage), Effect.provide(runtimeLayer), ), ); From 7cc5781c1e4054ce65c6a3c1b30c046a173a270e Mon Sep 17 00:00:00 2001 From: Julius Marminge <51714798+juliusmarminge@users.noreply.github.com> Date: Tue, 8 Sep 2026 21:40:53 -0700 Subject: [PATCH 17/17] fix(relay): preserve pending alerts across thread deletion --- .../AgentActivityPublisher.test.ts | 2 +- .../agentActivity/AgentActivityPublisher.ts | 2 +- .../src/agentActivity/FcmDeliveries.test.ts | 32 +++++++++++++++++-- .../relay/src/agentActivity/FcmDeliveries.ts | 8 ++++- 4 files changed, 38 insertions(+), 6 deletions(-) diff --git a/infra/relay/src/agentActivity/AgentActivityPublisher.test.ts b/infra/relay/src/agentActivity/AgentActivityPublisher.test.ts index 053634c5764d..ae3d4e4ad24d 100644 --- a/infra/relay/src/agentActivity/AgentActivityPublisher.test.ts +++ b/infra/relay/src/agentActivity/AgentActivityPublisher.test.ts @@ -158,7 +158,7 @@ describe("AgentActivityPublisher", () => { }); expect(fcmCalls).toEqual([ { target: android, state }, - { target: android, state: null }, + { target: android, state: null, replay: true }, ]); expect(appleDevices).toEqual(["ios"]); }).pipe( diff --git a/infra/relay/src/agentActivity/AgentActivityPublisher.ts b/infra/relay/src/agentActivity/AgentActivityPublisher.ts index 97ee4ccc8383..61a420dd7858 100644 --- a/infra/relay/src/agentActivity/AgentActivityPublisher.ts +++ b/infra/relay/src/agentActivity/AgentActivityPublisher.ts @@ -128,7 +128,7 @@ export const make = Effect.gen(function* () { return null; } if (target.platform === "android") { - return yield* fcmDeliveries.enqueue({ target, state: null }); + return yield* fcmDeliveries.enqueue({ target, state: null, replay: true }); } const now = yield* DateTime.now; const aggregate = makeAggregateState({ diff --git a/infra/relay/src/agentActivity/FcmDeliveries.test.ts b/infra/relay/src/agentActivity/FcmDeliveries.test.ts index 5de46063165f..9e4868b7f1b2 100644 --- a/infra/relay/src/agentActivity/FcmDeliveries.test.ts +++ b/infra/relay/src/agentActivity/FcmDeliveries.test.ts @@ -299,13 +299,39 @@ describe("Android delivery routing", () => { }).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.process({ ...h.job, state: null }); + 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); @@ -558,11 +584,11 @@ describe("Android delivery routing", () => { activity_expires_at: "900000", }); yield* TestClock.adjust("5 minutes"); - yield* delivery.process({ ...h.job, queuedAt: 300000, state: null }); + 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 }); + 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)); diff --git a/infra/relay/src/agentActivity/FcmDeliveries.ts b/infra/relay/src/agentActivity/FcmDeliveries.ts index 9019a64ed80d..8f998d9d9574 100644 --- a/infra/relay/src/agentActivity/FcmDeliveries.ts +++ b/infra/relay/src/agentActivity/FcmDeliveries.ts @@ -38,6 +38,7 @@ export const FcmDeliveryJob = Schema.Struct({ 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); @@ -116,6 +117,7 @@ export class FcmDeliveries extends Context.Service< readonly enqueue: (input: { readonly target: LiveActivities.TargetRow; readonly state: RelayAgentActivityState | null; + readonly replay?: boolean; }) => Effect.Effect; readonly process: ( body: unknown, @@ -164,6 +166,7 @@ export const make = Effect.gen(function* () { 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 }))); @@ -208,7 +211,10 @@ export const make = Effect.gen(function* () { ? Option.getOrNull(decodePreviousActivity(target.last_aggregate_json)) : null; let alert: ReturnType = null; - let acknowledgeAggregate = true; + // 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,