From 6c583620ff7ad3235b135af7107c0543467eecfa Mon Sep 17 00:00:00 2001 From: Ryan Hughes Date: Wed, 9 Sep 2026 00:54:28 -0400 Subject: [PATCH] feat(mobile): add Android agent notifications and ongoing activity (#10416) Co-authored-by: Julius Marminge <51714798+juliusmarminge@users.noreply.github.com> --- .github/workflows/deploy-relay.yml | 1 + apps/mobile/app.config.ts | 9 +- .../android/build.gradle | 34 + .../android/src/main/AndroidManifest.xml | 14 + .../AgentNotifications.kt | 336 ++++ .../T3AgentNotificationsModule.kt | 25 + .../AgentNotificationsTest.kt | 430 +++++ .../expo-module.config.json | 4 + apps/mobile/package.json | 1 + .../androidNotifications.test.ts | 63 + .../agent-awareness/androidNotifications.ts | 35 + .../features/agent-awareness/capabilities.ts | 6 +- .../notificationPermissions.test.ts | 59 + .../notificationPermissions.ts | 17 +- .../agent-awareness/registrationPayload.ts | 34 +- .../remoteRegistration.test.ts | 259 ++- .../agent-awareness/remoteRegistration.ts | 86 +- .../SettingsRouteScreen.logic.test.ts | 6 +- .../settings/SettingsRouteScreen.logic.ts | 4 +- .../features/settings/SettingsRouteScreen.tsx | 74 +- ...MobileClientsUserProfilePage.logic.test.ts | 11 + .../MobileClientsUserProfilePage.logic.ts | 8 +- docs/operations/android-notifications.md | 121 ++ docs/operations/connect-setup.md | 12 + docs/user/mobile-notifications.md | 11 + infra/relay/.env.example | 7 +- infra/relay/README.md | 8 +- .../migration.sql | 2 + .../snapshot.json | 1516 +++++++++++++++++ infra/relay/package.json | 2 + infra/relay/scripts/android-push-smoke.ts | 152 ++ infra/relay/scripts/android-push-watch.ts | 227 +++ infra/relay/src/Config.ts | 3 +- infra/relay/src/WebCrypto.ts | 5 + .../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 +- .../src/agentActivity/FcmAssertionSigner.ts | 76 + .../relay/src/agentActivity/FcmClient.test.ts | 254 +++ infra/relay/src/agentActivity/FcmClient.ts | 166 ++ .../src/agentActivity/FcmDeliveries.test.ts | 836 +++++++++ .../relay/src/agentActivity/FcmDeliveries.ts | 345 ++++ .../agentActivity/FcmDeliveryQueueConsumer.ts | 25 + .../agentActivity/FcmDeliveryQueueSender.ts | 14 + .../relay/src/agentActivity/LiveActivities.ts | 4 +- .../agentActivity/MobileRegistrations.test.ts | 12 +- infra/relay/src/agentActivity/fcmPayloads.ts | 86 + infra/relay/src/http/Api.test.ts | 109 +- infra/relay/src/http/Api.ts | 22 + infra/relay/src/persistence/schema.ts | 5 +- infra/relay/src/queues.ts | 5 + infra/relay/src/worker.ts | 89 +- .../src/relay/managedRelay.test.ts | 27 +- .../client-runtime/src/relay/managedRelay.ts | 2 +- packages/contracts/src/relay.test.ts | 47 +- packages/contracts/src/relay.ts | 35 +- 58 files changed, 5764 insertions(+), 145 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.test.ts 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/WebCrypto.ts create mode 100644 infra/relay/src/agentActivity/FcmAssertionSigner.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/FcmDeliveryQueueConsumer.ts create mode 100644 infra/relay/src/agentActivity/FcmDeliveryQueueSender.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 f652844a54f32adc4488a753705551cd51c04711..25e744a429680d4c851be1608d67ced70b1d0db4 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 217334fd754a718df9287a17d2cda445d31a9a50..f34f9c4b39d8c3981f8c9c3c1e59abb8af8065e0 100644 --- a/apps/mobile/app.config.ts +++ b/apps/mobile/app.config.ts @@ -186,7 +186,7 @@ const config: ExpoConfig = { icon: variant.assets.appIcon, userInterfaceStyle: "automatic", updates: { - enabled: true, + enabled: repoEnv.T3CODE_MOBILE_UPDATES_ENABLED !== "0", url: "https://u.expo.dev/d763fcb8-d37c-41ea-a773-b54a0ab4a454", checkAutomatically: "ON_LOAD", fallbackToCacheTimeout: 0, @@ -237,6 +237,9 @@ const config: ExpoConfig = { android: { icon: variant.assets.appIcon, package: variant.androidPackage, + ...(repoEnv.T3CODE_ANDROID_GOOGLE_SERVICES_FILE + ? { googleServicesFile: repoEnv.T3CODE_ANDROID_GOOGLE_SERVICES_FILE } + : {}), adaptiveIcon: { backgroundColor: variant.assets.androidAdaptiveBackgroundColor, ...(variant.assets.androidAdaptiveBackgroundImage @@ -356,6 +359,10 @@ const config: ExpoConfig = { [ "expo-build-properties", { + android: { + // Keep the supported floor explicit and covered by native notification tests. + minSdkVersion: 24, + }, ios: { deploymentTarget: "18.0", // AppCheckCore 11.3+ includes Swift and needs module maps for these Objective-C dependencies. diff --git a/apps/mobile/modules/t3-agent-notifications/android/build.gradle b/apps/mobile/modules/t3-agent-notifications/android/build.gradle new file mode 100644 index 0000000000000000000000000000000000000000..34b1d92593f16f7437c26bbd9943cb925f4f1e0c --- /dev/null +++ b/apps/mobile/modules/t3-agent-notifications/android/build.gradle @@ -0,0 +1,34 @@ +plugins { + id 'com.android.library' + id 'expo-module-gradle-plugin' +} + +group = 'com.t3tools.agentnotifications' +version = '0.0.0' + +android { + namespace 'expo.modules.t3agentnotifications' + defaultConfig { + versionCode 1 + versionName '0.0.0' + } + testOptions { + unitTests.includeAndroidResources = true + } +} + +dependencies { + implementation project(':expo-notifications') + implementation 'com.google.firebase:firebase-messaging:25.0.1' + implementation 'androidx.core:core:1.17.0' + implementation 'androidx.lifecycle:lifecycle-process:2.9.3' + testImplementation 'junit:junit:4.13.2' + testImplementation 'org.robolectric:robolectric:4.16.1' +} + +// Expo compiles modules with Java 17; Robolectric's Android 16 runtime needs 21. +tasks.withType(Test).configureEach { + javaLauncher = javaToolchains.launcherFor { + languageVersion = JavaLanguageVersion.of(21) + } +} diff --git a/apps/mobile/modules/t3-agent-notifications/android/src/main/AndroidManifest.xml b/apps/mobile/modules/t3-agent-notifications/android/src/main/AndroidManifest.xml new file mode 100644 index 0000000000000000000000000000000000000000..a7e07f45ec7349d42d455392e3fc6b58a1a63064 --- /dev/null +++ b/apps/mobile/modules/t3-agent-notifications/android/src/main/AndroidManifest.xml @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + diff --git a/apps/mobile/modules/t3-agent-notifications/android/src/main/java/expo/modules/t3agentnotifications/AgentNotifications.kt b/apps/mobile/modules/t3-agent-notifications/android/src/main/java/expo/modules/t3agentnotifications/AgentNotifications.kt new file mode 100644 index 0000000000000000000000000000000000000000..d1b81661151c52d41faee9e6f0648ba7bff94cea --- /dev/null +++ b/apps/mobile/modules/t3-agent-notifications/android/src/main/java/expo/modules/t3agentnotifications/AgentNotifications.kt @@ -0,0 +1,336 @@ +package expo.modules.t3agentnotifications + +import android.app.NotificationChannel +import android.app.Notification +import android.app.AlarmManager +import android.app.NotificationManager +import android.app.PendingIntent +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import android.content.SharedPreferences +import android.net.Uri +import android.os.Build +import android.text.TextPaint +import android.text.TextUtils +import androidx.core.app.NotificationCompat +import androidx.core.app.NotificationManagerCompat +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.ProcessLifecycleOwner +import com.google.firebase.messaging.RemoteMessage +import expo.modules.notifications.service.ExpoFirebaseMessagingService + +class AgentMessagingService : ExpoFirebaseMessagingService() { + override fun onMessageReceived(remoteMessage: RemoteMessage) { + if (remoteMessage.data["t3_kind"] == "agent_activity") { + AgentNotifications.receive(this, remoteMessage.data) + } else { + super.onMessageReceived(remoteMessage) + } + } +} + +class AgentActivityDismissReceiver : BroadcastReceiver() { + override fun onReceive(context: Context, intent: Intent) { + AgentNotifications.dismiss(context) + } +} + +class AgentActivityExpiryReceiver : BroadcastReceiver() { + override fun onReceive(context: Context, intent: Intent) { + AgentNotifications.expire(context) + } +} + +/** Handles data pushes natively so delivery does not depend on a running JS bridge. */ +object AgentNotifications { + private const val STORE = "t3-agent-notifications" + private const val ACTIVITY_CHANNEL = "agent-activity" + private const val ALERT_CHANNEL = "agent-alerts" + private const val ACTIVITY_TAG = "t3-agent-activity" + private const val ALERT_TAG = "t3-agent-alert" + private const val ACTIVITY_ID = 73001 + private const val MAX_MESSAGE_AGE_MS = 10 * 60 * 1000L + private const val RUNNING_LIFETIME_MS = 2 * 60 * 60 * 1000L + private const val MAX_LIFETIME_MS = 24 * 60 * 60 * 1000L + + @Synchronized + fun configure( + context: Context, + deviceId: String, + userId: String, + scheme: String, + ongoingEnabled: Boolean + ) { + val prefs = context.getSharedPreferences(STORE, Context.MODE_PRIVATE) + // JS identity is empty on a cold start. Compare the durable identity here + // so reopening preserves cards, dismissal and replay history for this user. + if (prefs.getString("userId", null) != userId || + prefs.getString("deviceId", null) != deviceId + ) { + clear(context) + } + val wasEnabled = prefs.getBoolean("ongoing", false) + prefs.edit().putString( + "deviceId", + deviceId + ).putString("userId", userId).putString("scheme", scheme) + .putBoolean("enabled", true).putBoolean("ongoing", ongoingEnabled).apply() + if (ongoingEnabled && !wasEnabled) prefs.edit().putBoolean("dismissed", false).apply() + if (!ongoingEnabled) cancelActivity(context) + channels(context) + } + + @Synchronized + fun clear(context: Context) { + cancelActivity(context) + context.getSharedPreferences(STORE, Context.MODE_PRIVATE).edit().clear().apply() + val manager = manager(context) + manager.activeNotifications.filter { it.tag == ACTIVITY_TAG || it.tag == ALERT_TAG } + .forEach { manager.cancel(it.tag, it.id) } + } + + @Synchronized + fun dismiss(context: Context) { + context.getSharedPreferences( + STORE, + Context.MODE_PRIVATE + ).edit().putBoolean("dismissed", true).apply() + cancelActivity(context) + } + + @Synchronized + fun expire(context: Context, now: Long = System.currentTimeMillis()) { + val expiresAt = context.getSharedPreferences( + STORE, + Context.MODE_PRIVATE + ).getLong("expiresAt", 0) + // An already-dispatched alarm must not remove a newer run's card. + if (expiresAt > 0 && expiresAt <= now) cancelActivity(context) + } + + @Synchronized + fun receive(context: Context, data: Map) { + val prefs = context.getSharedPreferences(STORE, Context.MODE_PRIVATE) + val updatedAt = data["updated_at"]?.toLongOrNull() ?: return + val registered = prefs.getBoolean("enabled", false) && + data["device_id"] == prefs.getString("deviceId", null) && + data["user_id"] == prefs.getString("userId", null) + val fresh = System.currentTimeMillis() - updatedAt in -MAX_MESSAGE_AGE_MS..MAX_MESSAGE_AGE_MS + if (registered && fresh && NotificationManagerCompat.from(context).areNotificationsEnabled()) { + channels(context) + val scheme = prefs.getString("scheme", "t3code") ?: "t3code" + showAlert(context, prefs, scheme, data) + updateActivity(context, prefs, scheme, data, updatedAt) + } + } + + private fun showAlert( + context: Context, + prefs: SharedPreferences, + scheme: String, + data: Map + ) { + // Queue retries carry the same alert id. Keep a bounded history even when + // notification A is retried after notification B has already arrived. + val alertId = data["alert_id"] + val seen = prefs.getString("seenAlertsOrdered", null)?.split('\n') + ?: prefs.getStringSet("seenAlerts", emptySet()).orEmpty().toList() + if (alertId != null && alertId !in seen) { + // Match iOS foreground presentation. Consume suppressed alerts as well, + // so a delivery retry cannot surface them after the app backgrounds. + if (!ProcessLifecycleOwner.get().lifecycle.currentState.isAtLeast(Lifecycle.State.RESUMED)) { + val title = data["alert_title"].orEmpty().take(120) + // Grouped alerts list up to five 120-character thread titles. + val body = data["alert_body"].orEmpty().take(608) + val id = alertId.hashCode() + val notification = base(context, ALERT_CHANNEL) + .setContentTitle(title).setContentText(body) + .setStyle(NotificationCompat.BigTextStyle().bigText(body)) + .setAutoCancel(true) + .setContentIntent(contentIntent(context, scheme, data["alert_path"], id)) + .build() + manager(context).notify(ALERT_TAG, id, notification) + } + prefs.edit().remove("seenAlerts").putString( + "seenAlertsOrdered", + (seen.takeLast(63) + alertId).joinToString("\n") + ).apply() + } + } + + private fun updateActivity( + context: Context, + prefs: SharedPreferences, + scheme: String, + data: Map, + updatedAt: Long + ) { + // Ignore reordered status updates without dropping an unrelated alert. + if (updatedAt < prefs.getLong("lastUpdate", 0)) return + prefs.edit().putLong("lastUpdate", updatedAt).apply() + val active = data["active"] == "true" + // Use absolute state expiry: a replay must not extend a finished card or + // make an abandoned host look active indefinitely. Older relays omit it. + val expiresAt = data["activity_expires_at"]?.toLongOrNull() + ?: if (active) updatedAt + RUNNING_LIFETIME_MS else 0L + val remainingMs = (expiresAt - System.currentTimeMillis()).coerceAtMost(MAX_LIFETIME_MS) + val wasActive = prefs.getBoolean("lastActive", false) + prefs.edit().putBoolean("lastActive", active).apply() + if (remainingMs <= 0 || !prefs.getBoolean("ongoing", false)) { + cancelActivity(context) + prefs.edit().putBoolean("dismissed", false).apply() + return + } + // Dismissing a run includes its finished card. A new run, or toggling + // activity off/on, arms it again; terminal replays stay dismissed. + if (active && !wasActive) prefs.edit().putBoolean("dismissed", false).apply() + if (!prefs.getBoolean("dismissed", false)) { + showActivity(context, scheme, data, active, remainingMs) + } + } + + private fun showActivity( + context: Context, + scheme: String, + data: Map, + active: Boolean, + remainingMs: Long + ) { + val body = data["activity_body"].orEmpty().take(240) + val dismissIntent = PendingIntent.getBroadcast( + context, + ACTIVITY_ID, + Intent(context, AgentActivityDismissReceiver::class.java), + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE + ) + val lines = (0..4).mapNotNull { + data["activity_line_$it"]?.let { line -> activityLine(context, line) } + } + // BigTextStyle remains eligible for Android Live Update promotion. + val style = NotificationCompat.BigTextStyle().bigText( + if (lines.isEmpty()) body else lines.joinToString("\n") + ) + val notification = base(context, ACTIVITY_CHANNEL) + .setContentTitle(data["activity_title"].orEmpty().take(120)) + .setContentText(body) + .setStyle(style) + .setOngoing(active).setOnlyAlertOnce(true).setSilent(true) + .setTimeoutAfter(remainingMs) + // Live Updates must remain uncolorized to qualify for promotion. + .setColorized(false) + .setRequestPromotedOngoing(active) + .setContentIntent(contentIntent(context, scheme, data["activity_path"], ACTIVITY_ID)) + .setDeleteIntent(dismissIntent) + .addAction(0, "Dismiss", dismissIntent) + .build() + manager(context).notify(ACTIVITY_TAG, ACTIVITY_ID, notification) + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) { + // Notification timeouts were added in API 26. One inexact alarm also + // expires cards on Android 7, including when the app process has exited. + // No exact-alarm permission, foreground service or periodic work needed. + val expiresAt = System.currentTimeMillis() + remainingMs + context.getSharedPreferences(STORE, Context.MODE_PRIVATE).edit() + .putLong("expiresAt", expiresAt).apply() + context.getSystemService( + AlarmManager::class.java + ).setAndAllowWhileIdle(AlarmManager.RTC_WAKEUP, expiresAt, expiryIntent(context)) + } + } + + private fun expiryIntent(context: Context): PendingIntent = PendingIntent.getBroadcast( + context, + ACTIVITY_ID, + Intent(context, AgentActivityExpiryReceiver::class.java), + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE + ) + + private fun cancelActivity(context: Context) { + manager(context).cancel(ACTIVITY_TAG, ACTIVITY_ID) + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) { + context.getSystemService(AlarmManager::class.java).cancel(expiryIntent(context)) + context.getSharedPreferences(STORE, Context.MODE_PRIVATE).edit().remove("expiresAt").apply() + } + } + + private fun activityLine(context: Context, value: String): String { + val parts = value.split('\t', limit = 3) + if (parts.size != 3) return value.take(300) + val metrics = context.resources.displayMetrics + val paint = TextPaint().apply { textSize = 14 * metrics.scaledDensity } + val prefix = "${parts[0]}: " + val separator = " · " + // Reserve the system notification's icon and margins. Fit the two titles + // independently so large fonts/long names never hide the project or status. + // The shade uses a narrow column even when a headless service sees a + // foldable's wider display metrics. Keep rows inside that column too. + val width = (metrics.widthPixels - 152 * metrics.density) + .coerceIn(120 * metrics.density, 280 * metrics.density) + val available = (width - paint.measureText(prefix + separator)).coerceAtLeast(0f) + val projectWidth = paint.measureText(parts[2]).coerceAtMost(available * 0.4f) + val titleWidth = paint.measureText(parts[1]).coerceAtMost(available - projectWidth) + val title = TextUtils.ellipsize(parts[1], paint, titleWidth, TextUtils.TruncateAt.END) + val project = TextUtils.ellipsize( + parts[2], + paint, + available - titleWidth, + TextUtils.TruncateAt.END + ) + return "$prefix$title$separator$project" + } + + private fun manager(context: Context) = context.getSystemService(NotificationManager::class.java) + + private fun channels(context: Context) { + if (Build.VERSION.SDK_INT >= 26) { + manager(context).createNotificationChannels( + listOf( + NotificationChannel(ALERT_CHANNEL, "Agent alerts", NotificationManager.IMPORTANCE_HIGH), + NotificationChannel( + ACTIVITY_CHANNEL, + "Ongoing agent activity", + NotificationManager.IMPORTANCE_LOW + ), + ) + ) + } + } + + private fun base(context: Context, channel: String): NotificationCompat.Builder { + val icon = context.resources.getIdentifier("notification_icon", "drawable", context.packageName) + return NotificationCompat.Builder(context, channel) + .setPriority( + if (channel == + ALERT_CHANNEL + ) { + NotificationCompat.PRIORITY_HIGH + } else { + NotificationCompat.PRIORITY_LOW + } + ) + .setDefaults(if (channel == ALERT_CHANNEL) Notification.DEFAULT_ALL else 0) + .setSmallIcon(if (icon != 0) icon else android.R.drawable.ic_dialog_info) + .setVisibility(NotificationCompat.VISIBILITY_PRIVATE) + .setShowWhen(false) + } + + private fun contentIntent( + context: Context, + scheme: String, + path: String?, + id: Int + ): PendingIntent? { + val threadPath = path?.takeIf { it.startsWith("/threads/") } + val route = threadPath?.takeUnless { it.contains('?') || it.contains('#') } ?: "/" + val intent = context.packageManager.getLaunchIntentForPackage(context.packageName) + ?: return null + intent.setAction(Intent.ACTION_VIEW).setData(Uri.parse("$scheme:/$route")) + .addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_SINGLE_TOP) + return PendingIntent.getActivity( + context, + id, + intent, + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE + ) + } +} diff --git a/apps/mobile/modules/t3-agent-notifications/android/src/main/java/expo/modules/t3agentnotifications/T3AgentNotificationsModule.kt b/apps/mobile/modules/t3-agent-notifications/android/src/main/java/expo/modules/t3agentnotifications/T3AgentNotificationsModule.kt new file mode 100644 index 0000000000000000000000000000000000000000..d394db56115ff5744259462f20ff78cecc83e5b7 --- /dev/null +++ b/apps/mobile/modules/t3-agent-notifications/android/src/main/java/expo/modules/t3agentnotifications/T3AgentNotificationsModule.kt @@ -0,0 +1,25 @@ +package expo.modules.t3agentnotifications + +import expo.modules.kotlin.modules.Module +import expo.modules.kotlin.modules.ModuleDefinition + +class T3AgentNotificationsModule : Module() { + override fun definition() = ModuleDefinition { + Name("T3AgentNotifications") + + Function("configure") { + deviceId: String, + userId: String, + scheme: String, + ongoingEnabled: Boolean + -> + appContext.reactContext?.let { + AgentNotifications.configure(it, deviceId, userId, scheme, ongoingEnabled) + } + } + + Function("clear") { + appContext.reactContext?.let { AgentNotifications.clear(it) } + } + } +} diff --git a/apps/mobile/modules/t3-agent-notifications/android/src/test/java/expo/modules/t3agentnotifications/AgentNotificationsTest.kt b/apps/mobile/modules/t3-agent-notifications/android/src/test/java/expo/modules/t3agentnotifications/AgentNotificationsTest.kt new file mode 100644 index 0000000000000000000000000000000000000000..734fbbc2f3b271293d6c941679e6ad2407615239 --- /dev/null +++ b/apps/mobile/modules/t3-agent-notifications/android/src/test/java/expo/modules/t3agentnotifications/AgentNotificationsTest.kt @@ -0,0 +1,430 @@ +package expo.modules.t3agentnotifications + +import android.app.Activity +import android.app.AlarmManager +import android.app.Application +import android.app.Notification +import android.app.NotificationManager +import android.content.ComponentName +import android.content.Intent +import android.content.IntentFilter +import android.os.Build +import androidx.core.app.NotificationCompat +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleRegistry +import androidx.lifecycle.ProcessLifecycleOwner +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.RuntimeEnvironment +import org.robolectric.Shadows.shadowOf +import org.robolectric.annotation.Config + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [24, 26, 33, 36], manifest = Config.NONE) +class AgentNotificationsTest { + private lateinit var context: Application + private lateinit var manager: NotificationManager + private lateinit var lifecycle: LifecycleRegistry + + @Before + fun setUp() { + context = RuntimeEnvironment.getApplication() + manager = context.getSystemService(NotificationManager::class.java) + shadowOf(manager).setNotificationsEnabled(true) + lifecycle = ProcessLifecycleOwner.get().lifecycle as LifecycleRegistry + lifecycle.currentState = Lifecycle.State.CREATED + + val launcher = ComponentName(context, Activity::class.java) + shadowOf(context.packageManager).addActivityIfNotPresent(launcher) + shadowOf(context.packageManager).addIntentFilterForActivity( + launcher, + IntentFilter(Intent.ACTION_MAIN).apply { + addCategory(Intent.CATEGORY_LAUNCHER) + } + ) + AgentNotifications.clear(context) + AgentNotifications.configure(context, "device", "user", "t3code-dev", true) + } + + private fun update(alertId: String, active: Boolean) = mapOf( + "device_id" to "device", + "user_id" to "user", + "updated_at" to System.currentTimeMillis().toString(), + "active" to active.toString(), + "activity_title" to "1 active agent", + "activity_body" to "Test thread · Working", + "activity_path" to "/threads/environment/thread", + "alert_id" to alertId, + "alert_title" to "Test thread", + "alert_body" to "Done: Test project", + "alert_path" to "/threads/environment/thread", + ) + + @Test + fun alertHistoryEvictsOnlyTheOldestEntryAfterCapacity() { + lifecycle.currentState = Lifecycle.State.RESUMED + for (id in 0..64) AgentNotifications.receive(context, update("alert-$id", false)) + lifecycle.currentState = Lifecycle.State.CREATED + for (id in 1..64) AgentNotifications.receive(context, update("alert-$id", false)) + assertTrue(manager.activeNotifications.isEmpty()) + AgentNotifications.receive(context, update("alert-0", false)) + assertEquals("alert-0".hashCode(), manager.activeNotifications.single().id) + } + + @Test + fun missingLauncherDoesNotDiscardTheAlert() { + shadowOf(context.packageManager).removeActivity(ComponentName(context, Activity::class.java)) + AgentNotifications.receive(context, update("no-launcher", false)) + assertEquals( + "Test thread", + manager.activeNotifications.single().notification.extras.getString(Notification.EXTRA_TITLE) + ) + } + + @Test + fun foregroundSuppressesAlertsWhileOngoingActivityStillUpdatesAndClears() { + lifecycle.currentState = Lifecycle.State.RESUMED + + AgentNotifications.receive(context, update("attention", true)) + + val ongoing = manager.activeNotifications.single() + assertEquals("t3-agent-activity", ongoing.tag) + assertEquals("1 active agent", ongoing.notification.extras.getString(Notification.EXTRA_TITLE)) + assertTrue(ongoing.notification.flags and Notification.FLAG_ONGOING_EVENT != 0) + + AgentNotifications.receive(context, update("completion", false)) + + assertTrue(manager.activeNotifications.isEmpty()) + } + + @Test + fun backgroundCompletionAlertsAndClearsOngoingActivity() { + lifecycle.currentState = Lifecycle.State.RESUMED + AgentNotifications.receive(context, update("running", true)) + lifecycle.currentState = Lifecycle.State.CREATED + + AgentNotifications.receive(context, update("completion", false)) + + val alert = manager.activeNotifications.single() + assertEquals("t3-agent-alert", alert.tag) + assertEquals("Test thread", alert.notification.extras.getString(Notification.EXTRA_TITLE)) + assertFalse(alert.notification.flags and Notification.FLAG_ONGOING_EVENT != 0) + } + + @Test + fun retryOfForegroundSuppressedAlertDoesNotAppearAfterBackgrounding() { + lifecycle.currentState = Lifecycle.State.RESUMED + val suppressed = update("foreground-completion", false) + AgentNotifications.receive(context, suppressed) + lifecycle.currentState = Lifecycle.State.CREATED + + AgentNotifications.receive(context, suppressed) + + assertTrue(manager.activeNotifications.isEmpty()) + + AgentNotifications.receive(context, update("later-background-completion", false)) + + assertEquals("later-background-completion".hashCode(), manager.activeNotifications.single().id) + } + + @Test + fun returningToForegroundSuppressesNewAlertsWithoutRemovingPreviousOnes() { + AgentNotifications.receive(context, update("background-completion", false)) + lifecycle.currentState = Lifecycle.State.RESUMED + + AgentNotifications.receive(context, update("foreground-completion", false)) + + assertEquals("background-completion".hashCode(), manager.activeNotifications.single().id) + } + + @Test + fun groupedAlertDisplaysEveryThreadAndRetriesStaySilent() { + val titles = (1..5).map { "Thread $it " + "x".repeat(111) }.joinToString(", ") + val grouped = update("group-completion", false) + mapOf( + "alert_title" to "5 agents finished", + "alert_body" to titles, + "alert_path" to "/", + ) + + AgentNotifications.receive(context, grouped) + AgentNotifications.receive( + context, + grouped + ("alert_body" to "A retry must not replace this alert") + ) + + val alert = manager.activeNotifications.single() + assertEquals("5 agents finished", alert.notification.extras.getString(Notification.EXTRA_TITLE)) + assertEquals(titles, alert.notification.extras.getString(Notification.EXTRA_BIG_TEXT)) + assertEquals("t3code-dev://", shadowOf(alert.notification.contentIntent).savedIntent.dataString) + } + + @Test + fun foregroundSuppressedGroupCannotAppearOnBackgroundRetry() { + val grouped = update("group-attention", true) + mapOf( + "alert_title" to "2 agents need attention", + "alert_body" to "First thread, Second thread", + "alert_path" to "/", + ) + lifecycle.currentState = Lifecycle.State.RESUMED + AgentNotifications.receive(context, grouped) + lifecycle.currentState = Lifecycle.State.CREATED + AgentNotifications.receive(context, grouped) + + assertEquals("t3-agent-activity", manager.activeNotifications.single().tag) + } + + @Test + fun reopeningSameAccountPreservesCardsDeduplicationAndDismissal() { + val message = update("attention", true) + AgentNotifications.receive(context, message) + AgentNotifications.configure(context, "device", "user", "t3code-dev", true) + assertEquals(2, manager.activeNotifications.size) + AgentNotifications.dismiss(context) + AgentNotifications.configure(context, "device", "user", "t3code-dev", true) + AgentNotifications.receive(context, message) + assertEquals("t3-agent-alert", manager.activeNotifications.single().tag) + } + + @Test + fun changingAccountOrDeviceClearsOldCardsAndRejectsOldPushes() { + AgentNotifications.receive(context, update("attention", true)) + AgentNotifications.configure(context, "device", "different-user", "t3code-dev", true) + AgentNotifications.receive(context, update("attention", true)) + assertTrue(manager.activeNotifications.isEmpty()) + AgentNotifications.configure(context, "different-device", "user", "t3code-dev", true) + AgentNotifications.receive(context, update("attention", true)) + assertTrue(manager.activeNotifications.isEmpty()) + AgentNotifications.clear(context) + AgentNotifications.receive(context, update("attention", true)) + assertTrue(manager.activeNotifications.isEmpty()) + } + + @Test + fun expandedActivityShowsFiveRowsAndUsesThePriorityThreadRoute() { + lifecycle.currentState = Lifecycle.State.RESUMED + val lines = + listOf( + "Approval: First · Project", + "Input: Second · Project", + "Failed: Third · Project", + "Working: Fourth · Project", + "Done: Fifth · Project" + ) + AgentNotifications.receive( + context, + update("attention", true) + + lines.mapIndexed { index, line -> "activity_line_$index" to line }.toMap() + ) + val card = manager.activeNotifications.single().notification + assertEquals(lines.joinToString("\n"), card.extras.getString(Notification.EXTRA_BIG_TEXT)) + assertEquals( + "t3code-dev://threads/environment/thread", + shadowOf(card.contentIntent).savedIntent.dataString + ) + } + + @Test + fun quietWorkUsesAbsoluteRelayLifetimeInsteadOfTenMinuteRemoval() { + lifecycle.currentState = Lifecycle.State.RESUMED + val expiresAt = System.currentTimeMillis() + 2 * 60 * 60 * 1000L + AgentNotifications.receive( + context, + update("work", true) + ("activity_expires_at" to expiresAt.toString()) + ) + val card = manager.activeNotifications.single().notification + assertTimeout(card, 119 * 60 * 1000L..120 * 60 * 1000L) + } + + @Test + fun finishedCardIsRetainedSilentlyWithoutOngoingFlagAndExpiresAtTheOriginalDeadline() { + lifecycle.currentState = Lifecycle.State.RESUMED + val expiresAt = System.currentTimeMillis() + 15 * 60 * 1000L + val finished = update("finished", false) + mapOf( + "activity_title" to "Agent work failed", + "activity_body" to "Failed: Test thread · Project", + "activity_expires_at" to expiresAt.toString(), + ) + AgentNotifications.receive(context, finished) + val card = manager.activeNotifications.single().notification + assertEquals("Agent work failed", card.extras.getString(Notification.EXTRA_TITLE)) + assertFalse(card.flags and Notification.FLAG_ONGOING_EVENT != 0) + assertTimeout(card, 1..15 * 60 * 1000L) + AgentNotifications.receive( + context, + finished + ("activity_expires_at" to (System.currentTimeMillis() - 1).toString()) + ) + assertTrue(manager.activeNotifications.isEmpty()) + } + + @Test + fun dismissalIncludesFinishedReplaysAndANewRunRearmsTheCard() { + lifecycle.currentState = Lifecycle.State.RESUMED + AgentNotifications.receive(context, update("work", true)) + AgentNotifications.dismiss(context) + val finished = + update("finished", false) + + ("activity_expires_at" to (System.currentTimeMillis() + 900000).toString()) + AgentNotifications.receive(context, finished) + AgentNotifications.receive(context, finished) + assertTrue(manager.activeNotifications.isEmpty()) + AgentNotifications.receive(context, update("new-work", true)) + assertEquals("t3-agent-activity", manager.activeNotifications.single().tag) + AgentNotifications.configure(context, "device", "user", "t3code-dev", false) + assertTrue(manager.activeNotifications.isEmpty()) + } + + @Test + fun reorderedActivityDoesNotEraseNewerCardOrDropAnIndependentAlert() { + val now = System.currentTimeMillis() + AgentNotifications.receive(context, update("new", true) + ("updated_at" to now.toString())) + AgentNotifications.receive( + context, + update("older-alert", false) + ("updated_at" to (now - 1000).toString()) + ) + assertEquals(3, manager.activeNotifications.size) + assertEquals(1, manager.activeNotifications.count { it.tag == "t3-agent-activity" }) + shadowOf(manager).setNotificationsEnabled(false) + AgentNotifications.receive(context, update("revoked-permission", true)) + assertEquals(3, manager.activeNotifications.size) + } + + @Test + fun longRowsKeepStatusAndBothTitlesWithinTheNotificationWidth() { + lifecycle.currentState = Lifecycle.State.RESUMED + val raw = "Approval\t${"Long thread name ".repeat(10)}\t${"Project name ".repeat(10)}" + AgentNotifications.receive( + context, + update("long-work", true) + (0..4).associate { "activity_line_$it" to raw } + ) + val lines = manager.activeNotifications.single().notification.extras.getString( + Notification.EXTRA_BIG_TEXT + )!!.split('\n') + assertEquals(5, lines.size) + for (line in lines) { + assertTrue(line.startsWith("Approval: ")) + assertTrue(line.contains(" · ")) + assertTrue(line.length < raw.length) + assertFalse(line.contains('\t')) + assertTrue(line.substringAfter(" · ").isNotBlank()) + } + } + + private fun assertTimeout(card: Notification, expected: LongRange) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + assertTrue(card.timeoutAfter in expected) + assertTrue( + shadowOf(context.getSystemService(AlarmManager::class.java)).scheduledAlarms.isEmpty() + ) + } else { + val alarm = shadowOf( + context.getSystemService(AlarmManager::class.java) + ).scheduledAlarms.single() + assertTrue(alarm.triggerAtTime - System.currentTimeMillis() in expected) + assertEquals(AlarmManager.RTC_WAKEUP, alarm.type) + } + } + + @Test + @Config(sdk = [24, 25]) + fun legacyExpiryRemovesOnlyTheCardAndCannotRemoveANewerRun() { + val alarms = shadowOf(context.getSystemService(AlarmManager::class.java)) + val expiresAt = System.currentTimeMillis() + 900000 + AgentNotifications.receive( + context, + update("done", false) + ("activity_expires_at" to expiresAt.toString()) + ) + val oldExpiry = alarms.scheduledAlarms.single().operation!! + AgentNotifications.receive(context, update("next-run", true)) + assertEquals(1, alarms.scheduledAlarms.size) + val receiver = AgentActivityExpiryReceiver() + receiver.onReceive(context, shadowOf(oldExpiry).savedIntent) + AgentNotifications.expire(context, expiresAt + 60_000) + assertEquals(1, manager.activeNotifications.count { it.tag == "t3-agent-activity" }) + AgentNotifications.expire(context, expiresAt + 2 * 60 * 60 * 1000L) + assertTrue(manager.activeNotifications.all { it.tag == "t3-agent-alert" }) + assertTrue(alarms.scheduledAlarms.isEmpty()) + } + + @Test + @Config(sdk = [24, 25]) + fun legacyExpiryIsCancelledOnDismissDisableAndSignOut() { + val alarms = shadowOf(context.getSystemService(AlarmManager::class.java)) + AgentNotifications.receive(context, update("work", true)) + AgentNotifications.dismiss(context) + assertTrue(alarms.scheduledAlarms.isEmpty()) + AgentNotifications.configure(context, "device", "user", "t3code-dev", false) + AgentNotifications.configure(context, "device", "user", "t3code-dev", true) + AgentNotifications.receive(context, update("work", true)) + assertEquals(1, alarms.scheduledAlarms.size) + AgentNotifications.configure(context, "device", "user", "t3code-dev", false) + assertTrue(alarms.scheduledAlarms.isEmpty()) + AgentNotifications.configure(context, "device", "user", "t3code-dev", true) + AgentNotifications.receive(context, update("work", true)) + AgentNotifications.clear(context) + assertTrue(alarms.scheduledAlarms.isEmpty()) + assertTrue(manager.activeNotifications.isEmpty()) + } + + @Test + fun alertsAndActivityUseVersionAppropriatePriorityAndPromotion() { + AgentNotifications.receive(context, update("work", true)) + val alert = manager.activeNotifications.single { it.tag == "t3-agent-alert" }.notification + val card = manager.activeNotifications.single { it.tag == "t3-agent-activity" }.notification + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) { + assertEquals(Notification.PRIORITY_HIGH, alert.priority) + assertTrue(alert.defaults and Notification.DEFAULT_SOUND != 0) + assertEquals(Notification.PRIORITY_LOW, card.priority) + assertEquals(0, card.defaults) + } else { + assertEquals( + NotificationManager.IMPORTANCE_HIGH, + manager.getNotificationChannel(alert.channelId).importance + ) + assertEquals( + NotificationManager.IMPORTANCE_LOW, + manager.getNotificationChannel(card.channelId).importance + ) + } + assertTrue(NotificationCompat.isRequestPromotedOngoing(card)) + assertFalse(NotificationCompat.isRequestPromotedOngoing(alert)) + // Robolectric's API 36 image predates the shipped Live Update rules; + // its hasPromotableCharacteristics() incorrectly requires colorization. + assertFalse(card.extras.getBoolean(NotificationCompat.EXTRA_COLORIZED)) + assertTrue(card.flags and Notification.FLAG_ONGOING_EVENT != 0) + assertEquals(Notification.VISIBILITY_PRIVATE, card.visibility) + } + + @Test + fun expiredMalformedAndFutureMessagesCannotDisplayOrPoisonLaterUpdates() { + val invalid = update("invalid", true) + AgentNotifications.receive(context, invalid - "updated_at") + AgentNotifications.receive(context, invalid + ("updated_at" to "invalid")) + AgentNotifications.receive( + context, + invalid + ("updated_at" to (System.currentTimeMillis() - 600001).toString()) + ) + AgentNotifications.receive( + context, + invalid + ("updated_at" to (System.currentTimeMillis() + 3600000).toString()) + ) + assertTrue(manager.activeNotifications.isEmpty()) + AgentNotifications.receive(context, update("valid", true)) + assertEquals(2, manager.activeNotifications.size) + } + + @Test + fun deniedPermissionDoesNotConsumeAnAlertBeforeTheUserAllowsNotifications() { + shadowOf(manager).setNotificationsEnabled(false) + val message = update("attention", true) + AgentNotifications.receive(context, message) + assertTrue(manager.activeNotifications.isEmpty()) + shadowOf(manager).setNotificationsEnabled(true) + AgentNotifications.receive(context, message) + assertEquals(2, manager.activeNotifications.size) + } +} diff --git a/apps/mobile/modules/t3-agent-notifications/expo-module.config.json b/apps/mobile/modules/t3-agent-notifications/expo-module.config.json new file mode 100644 index 0000000000000000000000000000000000000000..2ac57ee280a986ea800ed401de13c6a1a6042fcb --- /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 9a2925ccd9cf83f3a627e9306396e40fef387d79..d5285f263fd707e5254c6bf9abeaade1c17eddbb 100644 --- a/apps/mobile/package.json +++ b/apps/mobile/package.json @@ -145,6 +145,7 @@ }, "autolinking": { "buildFromSource": [ + "expo-notifications", "react-native-screens", "@react-native-menu/menu", "expo-audio" diff --git a/apps/mobile/src/features/agent-awareness/androidNotifications.test.ts b/apps/mobile/src/features/agent-awareness/androidNotifications.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..5bb472d3a4fabf354e1c5fe8b234c867cfe54a12 --- /dev/null +++ b/apps/mobile/src/features/agent-awareness/androidNotifications.test.ts @@ -0,0 +1,63 @@ +import { beforeEach, describe, expect, it, vi } from "vite-plus/test"; + +const mocks = vi.hoisted(() => ({ + os: "android", + native: null as { configure?: ReturnType; clear?: ReturnType } | null, + config: { scheme: ["t3code-preview"], extra: { iosPersonalTeamBuild: false } }, + requireModule: vi.fn(), +})); + +vi.mock("expo", () => ({ requireOptionalNativeModule: mocks.requireModule })); +vi.mock("expo-constants", () => ({ default: { expoConfig: mocks.config } })); +vi.mock("react-native", () => ({ + Platform: { + get OS() { + return mocks.os; + }, + }, +})); + +beforeEach(() => { + vi.resetModules(); + mocks.os = "android"; + mocks.native = { configure: vi.fn(), clear: vi.fn() }; + mocks.config.extra.iosPersonalTeamBuild = false; + mocks.requireModule.mockReset().mockImplementation(() => mocks.native); +}); + +describe("Android native notification capability", () => { + it("uses the installed module and the build variant's deep-link scheme", async () => { + const { configureAndroidAgentNotifications, clearAndroidAgentNotifications } = + await import("./androidNotifications"); + const { supportsAgentAwarenessPush } = await import("./capabilities"); + // An iOS-only signing restriction must not disable Android notifications. + mocks.config.extra.iosPersonalTeamBuild = true; + expect(supportsAgentAwarenessPush()).toBe(true); + configureAndroidAgentNotifications("device", "user", false); + expect(mocks.native?.configure).toHaveBeenCalledWith("device", "user", "t3code-preview", false); + clearAndroidAgentNotifications(); + expect(mocks.native?.clear).toHaveBeenCalledOnce(); + }); + + it.each([null, { clear: vi.fn() }, { configure: vi.fn() }])( + "disables push when the native binary is missing required methods (%j)", + async (native) => { + mocks.native = native; + const { configureAndroidAgentNotifications, clearAndroidAgentNotifications } = + await import("./androidNotifications"); + const { supportsAgentAwarenessPush } = await import("./capabilities"); + expect(supportsAgentAwarenessPush()).toBe(false); + expect(() => configureAndroidAgentNotifications("device", "user", true)).not.toThrow(); + expect(() => clearAndroidAgentNotifications()).not.toThrow(); + }, + ); + + it("preserves the iOS personal-team restriction without loading Android code", async () => { + mocks.os = "ios"; + const { supportsAgentAwarenessPush } = await import("./capabilities"); + expect(supportsAgentAwarenessPush()).toBe(true); + mocks.config.extra.iosPersonalTeamBuild = true; + expect(supportsAgentAwarenessPush()).toBe(false); + expect(mocks.requireModule).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/mobile/src/features/agent-awareness/androidNotifications.ts b/apps/mobile/src/features/agent-awareness/androidNotifications.ts new file mode 100644 index 0000000000000000000000000000000000000000..a65ff1758e780fff5f341fa7f2ab85a4bee0bd51 --- /dev/null +++ b/apps/mobile/src/features/agent-awareness/androidNotifications.ts @@ -0,0 +1,35 @@ +import Constants from "expo-constants"; +import { requireOptionalNativeModule } from "expo"; +import { Platform } from "react-native"; + +interface AndroidAgentNotifications { + configure(deviceId: string, userId: string, scheme: string, ongoingEnabled: boolean): void; + clear(): void; +} + +const native = + Platform.OS === "android" + ? requireOptionalNativeModule("T3AgentNotifications") + : null; + +export function supportsAndroidAgentNotifications(): boolean { + return typeof native?.configure === "function" && typeof native?.clear === "function"; +} + +export function configureAndroidAgentNotifications( + deviceId: string, + userId: string, + ongoingEnabled: boolean, +): void { + const scheme = Constants.expoConfig?.scheme; + native?.configure?.( + deviceId, + userId, + (Array.isArray(scheme) ? scheme[0] : scheme) ?? "t3code", + ongoingEnabled, + ); +} + +export function clearAndroidAgentNotifications(): void { + native?.clear?.(); +} diff --git a/apps/mobile/src/features/agent-awareness/capabilities.ts b/apps/mobile/src/features/agent-awareness/capabilities.ts index d627f365754dfe3f00854f62ed6f1b4913024b20..e439c89741dd4720b5ee15cfd93faff60b9878fc 100644 --- a/apps/mobile/src/features/agent-awareness/capabilities.ts +++ b/apps/mobile/src/features/agent-awareness/capabilities.ts @@ -1,5 +1,9 @@ import Constants from "expo-constants"; +import { Platform } from "react-native"; +import { supportsAndroidAgentNotifications } from "./androidNotifications"; export function supportsAgentAwarenessPush() { - return Constants.expoConfig?.extra?.iosPersonalTeamBuild !== true; + return Platform.OS === "android" + ? supportsAndroidAgentNotifications() + : Platform.OS === "ios" && Constants.expoConfig?.extra?.iosPersonalTeamBuild !== true; } diff --git a/apps/mobile/src/features/agent-awareness/notificationPermissions.test.ts b/apps/mobile/src/features/agent-awareness/notificationPermissions.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..def7443d543e7e22650bed3fccf1e60444bc130f --- /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 423d5c1ba7213f2ab05346eed47cce8dcd809cf3..eddee8b250919133e19a320475015fa4f49b5222 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 8279a27fee623bef1659b2fb63a7406ddf53703a..629ec5f60abbe1bc9986bc31060c3a54c881ebf1 100644 --- a/apps/mobile/src/features/agent-awareness/registrationPayload.ts +++ b/apps/mobile/src/features/agent-awareness/registrationPayload.ts @@ -10,25 +10,31 @@ export function resolveApsEnvironment(appVariant: unknown): "sandbox" | "product return appVariant === "development" ? "sandbox" : "production"; } -export function makeRelayDeviceRegistrationRequest(input: { - readonly deviceId: string; - readonly label: string; - readonly iosMajorVersion: number; - readonly appVersion?: string; - readonly bundleId?: string; - readonly apsEnvironment?: "sandbox" | "production"; - readonly pushToken?: string; - readonly pushToStartToken?: string; - readonly notificationsEnabled: boolean; - readonly preferences: Preferences; -}): RelayDeviceRegistrationRequest { +export function makeRelayDeviceRegistrationRequest( + input: { + readonly deviceId: string; + readonly label: string; + readonly appVersion?: string; + readonly bundleId?: string; + readonly apsEnvironment?: "sandbox" | "production"; + readonly pushToken?: string; + readonly pushToStartToken?: string; + readonly notificationsEnabled: boolean; + readonly preferences: Preferences; + } & ( + | { readonly platform?: "ios"; readonly iosMajorVersion: number } + | { readonly platform: "android"; readonly androidApiLevel: number } + ), +): RelayDeviceRegistrationRequest { const pushAvailable = supportsAgentAwarenessPush(); const liveActivitiesEnabled = pushAvailable && input.preferences.liveActivitiesEnabled !== false; return { deviceId: input.deviceId, label: input.label, - platform: "ios", - iosMajorVersion: input.iosMajorVersion, + platform: input.platform ?? "ios", + ...(input.platform === "android" + ? { androidApiLevel: input.androidApiLevel } + : { iosMajorVersion: input.iosMajorVersion }), appVersion: input.appVersion, ...(input.bundleId ? { bundleId: input.bundleId } : {}), ...(input.apsEnvironment ? { apsEnvironment: input.apsEnvironment } : {}), diff --git a/apps/mobile/src/features/agent-awareness/remoteRegistration.test.ts b/apps/mobile/src/features/agent-awareness/remoteRegistration.test.ts index 39bce6b4b90d9d4ee3d7858f3a65e5e98b771159..bd4109c275e68b6906089afecdd45f469754b175 100644 --- a/apps/mobile/src/features/agent-awareness/remoteRegistration.test.ts +++ b/apps/mobile/src/features/agent-awareness/remoteRegistration.test.ts @@ -5,10 +5,16 @@ import * as NodeCrypto from "node:crypto"; import { beforeEach, vi } from "vite-plus/test"; import { describe, expect, it } from "@effect/vitest"; import Constants from "expo-constants"; +import * as Cause from "effect/Cause"; import * as Effect from "effect/Effect"; import * as Exit from "effect/Exit"; import * as Layer from "effect/Layer"; -import { FetchHttpClient } from "effect/unstable/http"; +import { + Cookies, + FetchHttpClient, + HttpClientRequest, + HttpClientResponse, +} from "effect/unstable/http"; import { ManagedRelay } from "@t3tools/client-runtime/relay"; import type { EnvironmentId } from "@t3tools/contracts"; @@ -39,8 +45,20 @@ import { setAgentAwarenessRelayTokenProvider, shouldRegisterAgentAwarenessDeviceForProvider, unregisterAgentAwarenessConnection, + updateAgentAwarenessRegistrationPreferences, } from "./remoteRegistration"; import * as Notifications from "expo-notifications"; +import { Platform } from "react-native"; +import { + configureAndroidAgentNotifications, + clearAndroidAgentNotifications, +} from "./androidNotifications"; + +vi.mock("./androidNotifications", () => ({ + supportsAndroidAgentNotifications: vi.fn(() => true), + configureAndroidAgentNotifications: vi.fn(), + clearAndroidAgentNotifications: vi.fn(), +})); const secureStore = vi.hoisted(() => new Map()); const widgetMocks = vi.hoisted(() => ({ @@ -138,8 +156,12 @@ vi.mock("expo-secure-store", () => ({ vi.mock("react-native", () => ({ Platform: { - OS: "ios", - Version: "18.0", + get OS() { + return "ios"; + }, + get Version() { + return "18.0"; + }, }, AppState: { addEventListener: (_event: string, listener: (state: string) => void) => { @@ -235,6 +257,11 @@ const runBackgroundOperations = Effect.fn("TestRemoteRegistration.runBackgroundO describe("makeRelayDeviceRegistrationRequest", () => { beforeEach(() => { + vi.restoreAllMocks(); + vi.mocked(Notifications.getDevicePushTokenAsync).mockResolvedValue({ + type: "ios", + data: "apns-token", + }); vi.unstubAllGlobals(); vi.stubGlobal("__DEV__", false); secureStore.clear(); @@ -924,47 +951,201 @@ describe("makeRelayDeviceRegistrationRequest", () => { await new Promise((resolve) => setTimeout(resolve, 0)); expect(widgetMocks.start).toHaveBeenCalledTimes(1); }); - it.effect( - "does not enable notifications when a token rotates after permission is revoked", - () => { - const registrations: unknown[] = []; - vi.stubGlobal("fetch", async (input: RequestInfo | URL, init?: RequestInit) => { - const request = new Request(input, init); - if (request.url.endsWith("/v1/client/dpop-token")) { - return Response.json({ - access_token: "dpop", + for (const os of ["ios", "android"] as const) { + it.effect( + `does not enable ${os} notifications when a token rotates after permission is revoked`, + () => { + vi.spyOn(Platform, "OS", "get").mockReturnValue(os); + vi.spyOn(Platform, "Version", "get").mockReturnValue(os === "ios" ? 18 : 36); + vi.mocked(Notifications.getDevicePushTokenAsync).mockResolvedValue({ + type: os, + data: "initial", + }); + const registrations: unknown[] = []; + vi.stubGlobal("fetch", async (input: RequestInfo | URL, init?: RequestInit) => { + const request = new Request(input, init); + if (request.url.endsWith("/v1/client/dpop-token")) { + return Response.json({ + access_token: "dpop", + issued_token_type: "urn:ietf:params:oauth:token-type:access_token", + token_type: "DPoP", + expires_in: 300, + scope: "mobile:registration", + }); + } + registrations.push(await request.json()); + return Response.json({ ok: true }); + }); + Constants.expoConfig!.extra = { relay: { url: "https://permission-relay.example.test" } }; + setAgentAwarenessRelayTokenProvider(() => Promise.resolve("clerk"), "user-a"); + return Effect.gen(function* () { + yield* runBackgroundOperations(); + expect(registrations.at(-1)).toMatchObject({ + preferences: { notificationsEnabled: true }, + }); + vi.mocked(Notifications.getPermissionsAsync).mockResolvedValueOnce({ + granted: false, + } as Awaited>); + const listener = vi.mocked(Notifications.addPushTokenListener).mock.calls.at(-1)![0]; + listener({ type: os, data: "rotated" }); + yield* runBackgroundOperations(); + expect(registrations.at(-1)).toMatchObject({ + preferences: { notificationsEnabled: false }, + }); + expect(registrations.at(-1)).not.toHaveProperty("pushToken"); + }).pipe( + Effect.provideService(FetchHttpClient.Fetch, globalThis.fetch), + Effect.provide( + managedRelayClientLayer("https://permission-relay.example.test").pipe( + Layer.provide(Layer.mergeAll(FetchHttpClient.layer, cryptoLayer)), + ), + ), + ); + }, + ); + } + it.effect("preserves relay rejection errors with React Native response headers", () => { + vi.spyOn(Platform, "OS", "get").mockReturnValue("android"); + vi.mocked(Notifications.getDevicePushTokenAsync).mockResolvedValue({ + type: "android", + data: "fcm-token", + }); + const rejectedResponse = new Response("Unsupported device platform", { status: 400 }); + Object.defineProperty(rejectedResponse.headers, "getSetCookie", { value: undefined }); + vi.stubGlobal("fetch", (request: RequestInfo | URL) => { + const url = request instanceof Request ? request.url : String(request); + const response = url.endsWith("/v1/client/dpop-token") + ? Response.json({ + access_token: "relay-dpop-token", issued_token_type: "urn:ietf:params:oauth:token-type:access_token", token_type: "DPoP", expires_in: 300, scope: "mobile:registration", - }); - } - registrations.push(await request.json()); - return Response.json({ ok: true }); - }); - 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 }, - }); - 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)), + }) + : 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 14b1769c670c471d25af84886625cc90e7d604f9..5b4ca2423249d8000dfbca60bdf57ee5b9032cd6 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 aec583d67f73a0b4ce3268b8155f869120dab021..3e0ead2ce52bff0766ea2a73df52ad68726ae887 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 94fa5965e994d1cc639e887c03415b722d3ed18b..6cdf8a3e1f8634bd9726e7ffcc485e9db4491628 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 57e6ab30a1d8a0d3bf34524db65e5a9d8432400a..77036c212517095fac2033d5222803e07404c6d4 100644 --- a/apps/mobile/src/features/settings/SettingsRouteScreen.tsx +++ b/apps/mobile/src/features/settings/SettingsRouteScreen.tsx @@ -160,6 +160,10 @@ function ConfiguredSettingsRouteScreen() { const savePreferences = useAtomSet(updateMobilePreferencesAtom); const agentAwarenessPushAvailable = supportsAgentAwarenessPush(); const agentAwarenessPlatform = resolveAgentAwarenessPlatformPresentation(Platform.OS); + const agentAwarenessSubtitle = + Platform.OS === "android" && !agentAwarenessPushAvailable + ? "Install a newer app build to enable notifications" + : agentAwarenessPlatform.subtitle; const insets = useSafeAreaInsets(); const navigation = useNavigation(); const { getToken, isLoaded, isSignedIn } = useAuth({ treatPendingAsSignedOut: false }); @@ -181,7 +185,7 @@ function ConfiguredSettingsRouteScreen() { }, [isLoaded, isSignedIn, user?.primaryEmailAddress?.emailAddress]); const refreshNotifications = useCallback(async () => { - if (process.env.EXPO_OS !== "ios") { + if (Platform.OS !== "ios" && Platform.OS !== "android") { setNotificationStatus("unsupported"); return; } @@ -246,10 +250,7 @@ function ConfiguredSettingsRouteScreen() { // Permission alone is not enough: the switch stays off until the relay // registration succeeds, so tell the user the truth about which happened. if (getAgentAwarenessRegistrationStatus() === "registered") { - Alert.alert( - "Notifications enabled", - "Live Activity notifications are enabled for this device.", - ); + Alert.alert("Notifications enabled", "Agent notifications are enabled for this device."); } else { Alert.alert( "Couldn't finish enabling notifications", @@ -262,7 +263,7 @@ function ConfiguredSettingsRouteScreen() { setNotificationStatus("unsupported"); Alert.alert( "Notifications unavailable", - "Live Activity notifications are only available on iOS.", + "Agent notifications are unavailable on this platform.", ); return; } @@ -302,13 +303,40 @@ function ConfiguredSettingsRouteScreen() { } setLiveActivityStatus("linking"); + if (Platform.OS === "android") { + const permission = await settleAsyncResult(() => + runtime.runPromiseExit(requestAgentNotificationPermission), + ); + if (permission._tag === "Failure") { + setLiveActivityStatus("disabled"); + const error = squashAtomCommandFailure(permission); + Alert.alert( + "Ongoing activity unavailable", + error instanceof Error ? error.message : "Could not enable agent notifications.", + ); + return; + } + if (permission.value.type !== "granted") { + setLiveActivityStatus("disabled"); + Alert.alert( + "Notification permission needed", + "Enable notifications in system Settings to show ongoing agent activity.", + [ + { text: "Cancel", style: "cancel" }, + { text: "Open Settings", onPress: () => void Linking.openSettings() }, + ], + ); + return; + } + setNotificationStatus("enabled"); + } const tokenResult = await settlePromise(() => getToken(resolveRelayClerkTokenOptions())); if (tokenResult._tag === "Failure") { setLiveActivityStatus("disabled"); const error = squashAtomCommandFailure(tokenResult); Alert.alert( - "Live Activities unavailable", - error instanceof Error ? error.message : "Could not enable Live Activity updates.", + Platform.OS === "android" ? "Ongoing activity unavailable" : "Live Activities unavailable", + error instanceof Error ? error.message : "Could not enable agent activity updates.", ); return; } @@ -333,8 +361,10 @@ function ConfiguredSettingsRouteScreen() { if (!isAtomCommandInterrupted(updateResult)) { const error = squashAtomCommandFailure(updateResult); Alert.alert( - "Live Activities unavailable", - error instanceof Error ? error.message : "Could not enable Live Activity updates.", + Platform.OS === "android" + ? "Ongoing activity unavailable" + : "Live Activities unavailable", + error instanceof Error ? error.message : "Could not enable agent activity updates.", ); } return; @@ -348,15 +378,15 @@ function ConfiguredSettingsRouteScreen() { // Activities are live until the device is actually registered. if (getAgentAwarenessRegistrationStatus() === "registered") { Alert.alert( - "Live Activities enabled", + Platform.OS === "android" ? "Ongoing activity enabled" : "Live Activities enabled", environmentCount > 0 - ? `${environmentCount} environment${environmentCount === 1 ? "" : "s"} linked for Live Activity updates.` - : "Live Activity updates are enabled. Add an environment to start receiving updates.", + ? `${environmentCount} environment${environmentCount === 1 ? "" : "s"} linked for agent activity updates.` + : "Agent activity updates are enabled. Add an environment to start receiving updates.", ); } else { Alert.alert( - "Couldn't finish enabling Live Activities", - "This device could not be registered with T3 Connect, so Live Activities won't appear yet. They'll start once registration succeeds.", + "Couldn't finish enabling activity updates", + "This device could not be registered with T3 Connect, so activity updates won't appear yet. They'll start once registration succeeds.", ); } }, [ @@ -372,20 +402,24 @@ function ConfiguredSettingsRouteScreen() { const handleDeviceNotificationsChange = useCallback( (enabled: boolean) => { if (enabled) { + if (!isSignedIn) { + promptSignIn(); + return; + } void requestNotifications(); return; } Alert.alert( "Disable notifications", - "Notification permission is controlled by iOS. Open Settings to disable notifications for T3 Code.", + "Open system Settings to disable notifications for T3 Code.", [ { text: "Cancel", style: "cancel" }, { text: "Open Settings", onPress: () => void Linking.openSettings() }, ], ); }, - [requestNotifications], + [isSignedIn, promptSignIn, requestNotifications], ); const handleLiveActivitiesChange = useCallback( @@ -494,7 +528,7 @@ function ConfiguredSettingsRouteScreen() { notificationStatus === "checking" || notificationStatus === "unsupported" } - subtitle={agentAwarenessPlatform.subtitle} + subtitle={agentAwarenessSubtitle} // Only reads as on when this device is actually registered with the // relay; otherwise notifications cannot be delivered regardless of // the local iOS permission. @@ -512,8 +546,8 @@ function ConfiguredSettingsRouteScreen() { liveActivityStatus === "linking" } icon="bolt.circle" - label="Live Activity Updates" - subtitle={agentAwarenessPlatform.subtitle} + label={Platform.OS === "android" ? "Ongoing Agent Activity" : "Live Activity Updates"} + subtitle={agentAwarenessSubtitle} // Same gate: a saved preference is meaningless until the device // registration the relay needs to push updates has succeeded. value={ diff --git a/apps/web/src/components/clerk/MobileClientsUserProfilePage.logic.test.ts b/apps/web/src/components/clerk/MobileClientsUserProfilePage.logic.test.ts index fcc660e8305af2d87ab201e212dacb84841ab075..979f57cff16a62de87b944dfa76f375cd9dd6fda 100644 --- a/apps/web/src/components/clerk/MobileClientsUserProfilePage.logic.test.ts +++ b/apps/web/src/components/clerk/MobileClientsUserProfilePage.logic.test.ts @@ -37,6 +37,14 @@ describe("mobile client presentation", () => { ); }); + it("labels Android devices without displaying an iOS version", () => { + expect( + mobileClientPlatformLabel( + device({ platform: "android", iosMajorVersion: null, androidApiLevel: 36 }), + ), + ).toBe("Android · T3 Code 1.2.3"); + }); + it("distinguishes disabled notifications from an empty event selection", () => { expect( mobileClientNotificationDetail( @@ -59,6 +67,9 @@ describe("mobile client presentation", () => { }); it("handles missing app versions and invalid update timestamps", () => { + expect(mobileClientPlatformLabel(device({ iosMajorVersion: null }))).toBe( + "iOS · T3 Code 1.2.3", + ); expect(mobileClientPlatformLabel(device({ appVersion: null }))).toBe("iOS 18"); expect(mobileClientUpdatedAtLabel("not-a-date")).toBe("Update time unavailable"); }); diff --git a/apps/web/src/components/clerk/MobileClientsUserProfilePage.logic.ts b/apps/web/src/components/clerk/MobileClientsUserProfilePage.logic.ts index 5ca9595bef4d380e7d364048235f38d5a683757e..b52d381f6ddfb025f8fa0ca8c2ddd07c97d09326 100644 --- a/apps/web/src/components/clerk/MobileClientsUserProfilePage.logic.ts +++ b/apps/web/src/components/clerk/MobileClientsUserProfilePage.logic.ts @@ -15,7 +15,13 @@ const NOTIFICATION_PREFERENCES = [ >; export function mobileClientPlatformLabel(device: RelayClientDeviceRecord): string { - return `iOS ${device.iosMajorVersion}${device.appVersion ? ` · T3 Code ${device.appVersion}` : ""}`; + const platform = + device.platform === "android" + ? "Android" + : device.iosMajorVersion === null + ? "iOS" + : `iOS ${device.iosMajorVersion}`; + return `${platform}${device.appVersion ? ` · T3 Code ${device.appVersion}` : ""}`; } export function mobileClientNotificationDetail(device: RelayClientDeviceRecord): string { diff --git a/docs/operations/android-notifications.md b/docs/operations/android-notifications.md new file mode 100644 index 0000000000000000000000000000000000000000..48822f91a41d887ae60295c02537ac12551f57bb --- /dev/null +++ b/docs/operations/android-notifications.md @@ -0,0 +1,121 @@ +# Android notifications + +The Android app receives Firebase Cloud Messaging (FCM) data messages. The relay sends them directly through FCM HTTP v1; an Expo Push account is not required. + +## Android compatibility and automated checks + +The app's minimum is Android 7.0 (API 24), declared in `app.config.ts` and enforced by the relay's device-registration schema. Compile/target SDK versions follow the locked Expo/React Native toolchain (currently API 36). Notification channels begin at API 26; the notification permission prompt begins at API 33. Live Update promotion requires API 36 and remains subject to system settings and device support. Alerts and ordinary activity cards work below API 36. + +API 24–25 use a single inexact system alarm to expire cards after process exit, with no exact-alarm permission. Android can delay that alarm in power-saving modes. API 26+ use notification timeouts. Disabling activity, dismissal, account changes and sign-out cancel the legacy alarm. A stale expiry broadcast cannot remove a newer run's card. + +The native notification tests cover API 24, 26, 33 and 36 (plus API 25 for legacy expiry) using Robolectric. To compile the module, run these tests and run Android lint, use the following command from a generated `apps/mobile/android` project with JDK 21 available. No Firebase, signing or relay secrets are required: + +```sh +./gradlew :t3-agent-notifications:testDebugUnitTest :t3-agent-notifications:lintRelease -Pandroid.lint.useK2Uast=false +``` + +Robolectric's API 36 runtime requires JDK 21; module compilation still uses Expo's Java 17 toolchain. The existing Mobile Native Static Analysis job separately runs ktlint and detekt. The native fingerprint check marks this change as requiring a new binary; the production workflow cannot deliver it to an older binary by OTA. Settings disable Android notifications if the installed native module is missing required methods. + +The lint command uses the K1 frontend because AGP's K2 frontend crashes while analyzing Worklets 0.10's Gradle Kotlin scripts. This does not disable lint checks. Live Update eligibility must be verified on a device: Robolectric's API 36 image implements older promotion rules that require colorization, while shipped Live Updates require uncolorized notifications. + +## Firebase and app build + +1. Create a Firebase project and register each Android application identifier you intend to build: `com.t3tools.t3code.dev`, `com.t3tools.t3code.preview`, or `com.t3tools.t3code`. +2. Download `google-services.json`. Set `T3CODE_ANDROID_GOOGLE_SERVICES_FILE` to its path when running Expo prebuild and building the app. The JSON must contain the selected variant's package identifier. +3. Create a service-account key with permission to send FCM messages for that Firebase project. Keep this private JSON outside the repository and the app bundle. +4. Enable the Firebase Cloud Messaging API in the Google project if it is not already enabled. For hosted delivery, set the relay's `FCM_SERVICE_ACCOUNT` secret to the service-account JSON. +5. Build a new Android binary. A JavaScript-only update cannot install the native notification handler or Firebase configuration. Hosted delivery also needs the relay database migration and updated relay deployment; local verification can use the watcher below. + +For a local development build, from `apps/mobile`: + +```sh +APP_VARIANT=development \ +T3CODE_ANDROID_GOOGLE_SERVICES_FILE=/absolute/path/google-services.json \ +vp run android:dev +``` + +For an EAS build, provide the same configuration through each selected build environment, using an EAS file variable named `T3CODE_ANDROID_GOOGLE_SERVICES_FILE` for the Google services file. Make the file available to fingerprint generation as well as the native build. FCM service-account credentials belong on the relay, not in EAS's app environment. If deploying a separate hosted relay, configure the build's T3 Connect public settings for that relay and Clerk application as described in [T3 Connect](../internals/t3-connect.md). + +Set `T3CODE_MOBILE_UPDATES_ENABLED=0` before prebuild and bundling a private binary to disable the repository's configured Expo OTA update source. A debug development-client APK requires Metro; a bundled release build is needed to verify cold-start notification taps without Expo's development launcher. + +## Clerk sign-in for private builds + +Clerk's native Android sign-in uses `clerk://.callback`. In the Clerk instance selected by the build's publishable key, its administrator must allow the exact callback under **Native applications > Allowlist for mobile SSO redirect**. For the development package, add: + +```text +clerk://com.t3tools.t3code.dev.callback +``` + +The app already declares the matching callback receiver. A "redirect url ... does not match an authorized redirect URI" error requires a Clerk configuration change; rebuilding the same APK does not fix it. Reopen sign-in after the administrator saves the entry. See [Android native sign-in redirects](./connect-setup.md#android-native-sign-in-redirects) for the other variants. + +Using T3's existing production publishable key selects the maintainers' Clerk instance. It grants no access to change that instance's allowlist. The chosen package's callback must already be allowed or be added by that instance's administrator. Android device registration and hosted delivery separately require the relay deployment below. A successful direct-pairing or FCM smoke test does not verify hosted sign-in or device registration. + +Building with `APP_VARIANT=production` selects `com.t3tools.t3code` and its corresponding Clerk callback. Set the same variant during prebuild and bundling, and supply a Google services file that includes that package. Keep OTA updates disabled for a private binary. A locally signed build with this package cannot update an official installation signed by the maintainer or coexist with it; removing that installation also removes its app-local data. The development package remains a separate app. + +## Focused delivery check + +`infra/relay/scripts/android-push-smoke.ts` sends a message through the production FCM client implementation without provisioning the relay's database, Clerk integration, or Cloudflare queues. It verifies only Firebase-to-device delivery. + +Provide a private device JSON file containing the app's native FCM `token`, registered `deviceId`, signed-in `userId`, and Android `packageName`. An optional `deepLink` can target an existing thread for tap verification. The app must have registered its local native notification handler and have notification permission. From `infra/relay`: + +```sh +vp run push:android:smoke /path/service-account.json /path/device.json running +vp run push:android:smoke /path/service-account.json /path/device.json approval +vp run push:android:smoke /path/service-account.json /path/device.json completed +``` + +Supported states are `running`, `approval`, `input`, `completed`, `failed`, and `end`. Firebase acceptance is not proof that a device displayed the message. Check the actual notification, background the app, and test a notification tap. Also test dismissal, disabling ongoing activity, sign-out, token rotation, and delivery after the app process has exited. Android Settings **Force stop** intentionally prevents delivery until the app is opened again. + +Android suppresses ordinary alerts while the app is foregrounded, matching iOS notification presentation. Activity cards still update in the foreground and retain finished results silently. Check that completion stays quiet with the app open, that a later completion alerts after backgrounding, and that retrying a foreground-suppressed alert does not show it later. This uses the app lifecycle on the receiving phone, not thread visibility on other clients. + +With ongoing activity enabled, verify two threads entering approval/input together produce one `2 agents need attention` alert, and two observed active threads completing/failing together produce one `2 agents finished` alert. The body lists their titles. The relay shares iOS transition selection and retains its delivered baseline when work finishes; publishing the same states again must not produce another alert. Grouped alerts open the aggregate’s priority thread; individual alerts retain their thread link. + +Verify an expanded card with five threads, attention/failure priority, project names and statuses. When all work finishes, the card should show **Agent work completed** or **Agent work failed**, lose its ongoing/promotion flag, and expire 15 minutes after the newest displayed result. Replays must not extend that deadline. Quiet running work uses the relay’s two-hour state lifetime; approval/input states use 24 hours. Reopen the same signed-in app and confirm existing alerts and dismissal survive, with a silent aggregate replay on cold start or a foreground after at least 60 seconds. Also check empty replays remove an orphaned card and completions older than two minutes never alert, even with ongoing activity disabled. + +After Android prebuild, run the native presentation regression tests from `apps/mobile/android`: + +```sh +./gradlew :t3-agent-notifications:testDebugUnitTest --tests expo.modules.t3agentnotifications.AgentNotificationsTest +``` + +## Relay deployment + +### Local verification with existing T3 services + +You do not need to duplicate T3 Connect's hosted infrastructure to develop Android push. Keep the normal Clerk login and environment connections. `scripts/android-push-watch.ts` subscribes to one paired environment's shell stream, uses the shared agent-awareness projection, and sends updates through the new FCM client. It holds transient state in memory and needs no hosted database or Clerk secret. + +Create a private `connection.json` containing `wsUrl` (the environment's `/ws` URL) and `bearerToken` (a normal paired environment access token). Use a separate pairing credential for this watcher. Supply the same device file described above, then run from `infra/relay`: + +```sh +vp run push:android:watch /path/service-account.json /path/device.json /path/connection.json +``` + +The Android native handler must already be configured with that device and account, and notifications must be allowed. A native instrumentation harness can configure a disposable emulator before testing; a signed-in development app configures the handler during device registration. This watcher is a development transport: it observes all unarchived threads in its paired environment, enables all alert types, keeps no durable queue, and must stay running. It does not register Android devices with the existing hosted relay. The hosted relay needs the changes below before its notification settings and delivery work end to end. + +### Hosted delivery + +The existing Alchemy deployment provisions Cloudflare Workers, delivery queues, Hyperdrive, tunnel/DNS resources, PlanetScale Postgres, and Axiom observability by default. It requires credentials for the enabled services and private Clerk configuration; the repository's public app settings do not grant deployment access. Set `APNS_ENABLED=false` in an Android-only development relay to skip Apple delivery and its credential requirements. APNs remains enabled by default. + +#### Personal stage in the existing deployment accounts + +A maintainer with access to the existing Alchemy state and deployment credentials can deploy the Android changes to a personal stage. Non-production stages reference the retained database and DNS zones owned by the `prod` stage, create a separate PlanetScale branch, and apply migrations to that branch. A personal stage is therefore not a standalone deployment into an unrelated account. + +1. Apply the Android changes to a checkout with the existing deployment credentials. Create a private `infra/relay/.env.android-dev` using the existing Cloudflare, PlanetScale, Axiom, domain, and Clerk configuration described in the [relay README](../../infra/relay/README.md#deployment-ci). Keep `CLERK_PUBLISHABLE_KEY`, `CLERK_SECRET_KEY`, and `CLERK_JWT_AUDIENCE` on the same Clerk instance used by the test clients. +2. Add the Firebase service-account JSON as `FCM_SERVICE_ACCOUNT` and set `APNS_ENABLED=false` for this Android-only stage. Leave `RELAY_DOMAIN` unset so the deployment derives a hostname for the personal stage instead of using the production hostname. +3. From the repository root, inspect the deployment plan, then deploy the same stage: + + ```sh + vp run --filter t3code-relay deploy --stage dev_ryan_android --env-file .env.android-dev --dry-run + vp run --filter t3code-relay deploy --stage dev_ryan_android --env-file .env.android-dev + ``` + +4. Give the tester the deployed relay URL and matching public Clerk configuration. The deploy wrapper also writes the relay URL and public tracing configuration into that checkout's root `.env`. Rebuild the private APK with this `T3CODE_RELAY_URL`, the existing Firebase Android file, and OTA updates disabled. If using the separate development package, authorize its Clerk callback as described above. +5. Configure one isolated T3 server with the same relay URL and link that test environment through the new relay. Existing production relay links do not automatically move to a personal stage. Enable activity publishing for the test environment, enable notifications on the phone, and verify a real agent turn produces a running update and completion alert while the phone is locked. + +The maintainer can perform deployment themselves and return only the public client configuration; the tester does not need copies of their hosting or Clerk server credentials. A fully independent deployment needs its own initial Cloudflare stack, PostgreSQL database, Firebase project, and a Clerk instance the operator can configure. Its Alchemy deployment needs PlanetScale and Axiom credentials. + +Build the host client and mobile app with the same relay URL and Clerk public configuration. A source server or desktop development build can host the test environment; keep its T3 home separate from an existing installation. Signing into the phone alone does not link a host environment. Use the host client's T3 Connect settings to link it and enable activity publishing. A private Clerk instance also needs its own CLI OAuth application before using `t3 connect login`; the repository's production CLI client ID belongs to the maintainers' instance. + +For deployment through GitHub Actions, add `FCM_SERVICE_ACCOUNT` to the `production` environment's secrets. The relay workflow passes it to Alchemy. The maintainer must also supply `google-services.json` for the production Android package in the native build environment; changing the relay secret alone cannot move an installed app to another Firebase project. + +Android delivery uses `RelayFcmDeliveryQueue` and a separate dead-letter queue. Failed requests are retried; messages expire after five minutes. Before sending, the consumer rechecks the device token, current preferences, environment links, and current thread state. `UNREGISTERED` responses invalidate only the matching device token. OAuth tokens are cached within the FCM service and refreshed after an authorization failure. diff --git a/docs/operations/connect-setup.md b/docs/operations/connect-setup.md index 49b93e49c9c474a2244cc857e5ff386a22499866..86697d2798c92eb91e4ee4538efb2b77eedd0318 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 0000000000000000000000000000000000000000..3a8d61d1cb7e9611fe1b48a5a25d04c11f025a0d --- /dev/null +++ b/docs/user/mobile-notifications.md @@ -0,0 +1,11 @@ +# Mobile notifications + +Sign in to T3 Connect, link your environments, and enable **Device Notifications** in Settings to receive alerts when an agent finishes, fails, needs approval, or asks for input. Tap a notification to open its thread. Your environment must have agent activity publishing enabled. + +Enable **Ongoing Agent Activity** on Android or **Live Activity Updates** on iOS to follow work without opening the app. Finished results remain visible for up to 15 minutes. You can dismiss an Android activity card without disabling alerts; turn off ongoing activity in Settings to stop future cards. + +Ordinary alerts stay quiet while the mobile app is in the foreground. Ongoing activity continues to update. Viewing a thread on another device does not silence your phone's alerts. + +Android notifications require Android 7.0 or newer and Google Play services. Android 16 and newer can promote ongoing activity to a Live Update, subject to system settings and device support. Other devices show a regular ongoing notification. Android 7's battery-saving modes can delay removal of expired cards. + +Notification permission and Android notification channels are controlled in system Settings. Background delivery requires T3 Connect; a direct or Tailscale connection alone does not enable push notifications. The mobile app does not need to maintain a connection to your environment. Force-stopping the Android app in system Settings prevents push delivery until you open it again. diff --git a/infra/relay/.env.example b/infra/relay/.env.example index 7ab2a5d6e44ff350871c67faf586916e064c7358..f885bff17c0c14bc3cb78421c4212e390c92b048 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 37b1a631970d36dfddf346f86da10df7f8bc0740..33bb0ba743520161a935bc3d4182eecdca42752d 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 0000000000000000000000000000000000000000..85fa040da625b255e585020fd38bf00fe2fe6070 --- /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 0000000000000000000000000000000000000000..9d89ffe61a175f0a1be6407e8b883121dca64b8e --- /dev/null +++ b/infra/relay/migrations/postgres/20260906042516_android_devices/snapshot.json @@ -0,0 +1,1516 @@ +{ + "version": "8", + "dialect": "postgres", + "id": "1109ed23-036c-4c39-a459-52422a7ffd99", + "prevIds": ["2374caff-40bf-423c-9255-55e76dddbc2a"], + "ddl": [ + { + "isRlsEnabled": false, + "name": "relay_agent_activity_rows", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "relay_delivery_attempts", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "relay_dpop_proofs", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "relay_environment_credentials", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "relay_environment_links", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "relay_live_activities", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "relay_managed_endpoint_allocations", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "relay_managed_tunnel_limits", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "relay_mobile_devices", + "entityType": "tables", + "schema": "public" + }, + { + "type": "varchar(191)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "environment_id", + "entityType": "columns", + "schema": "public", + "table": "relay_agent_activity_rows" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "environment_public_key", + "entityType": "columns", + "schema": "public", + "table": "relay_agent_activity_rows" + }, + { + "type": "varchar(191)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "thread_id", + "entityType": "columns", + "schema": "public", + "table": "relay_agent_activity_rows" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "state_json", + "entityType": "columns", + "schema": "public", + "table": "relay_agent_activity_rows" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "updated_at", + "entityType": "columns", + "schema": "public", + "table": "relay_agent_activity_rows" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "created_at", + "entityType": "columns", + "schema": "public", + "table": "relay_agent_activity_rows" + }, + { + "type": "varchar(36)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "relay_delivery_attempts" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "created_at", + "entityType": "columns", + "schema": "public", + "table": "relay_delivery_attempts" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "user_id", + "entityType": "columns", + "schema": "public", + "table": "relay_delivery_attempts" + }, + { + "type": "varchar(191)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "environment_id", + "entityType": "columns", + "schema": "public", + "table": "relay_delivery_attempts" + }, + { + "type": "varchar(191)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "thread_id", + "entityType": "columns", + "schema": "public", + "table": "relay_delivery_attempts" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "device_id", + "entityType": "columns", + "schema": "public", + "table": "relay_delivery_attempts" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "kind", + "entityType": "columns", + "schema": "public", + "table": "relay_delivery_attempts" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "source_job_id", + "entityType": "columns", + "schema": "public", + "table": "relay_delivery_attempts" + }, + { + "type": "varchar(16)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "token_suffix", + "entityType": "columns", + "schema": "public", + "table": "relay_delivery_attempts" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "apns_status", + "entityType": "columns", + "schema": "public", + "table": "relay_delivery_attempts" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "apns_reason", + "entityType": "columns", + "schema": "public", + "table": "relay_delivery_attempts" + }, + { + "type": "varchar(128)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "apns_id", + "entityType": "columns", + "schema": "public", + "table": "relay_delivery_attempts" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "transport_error", + "entityType": "columns", + "schema": "public", + "table": "relay_delivery_attempts" + }, + { + "type": "varchar(128)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "thumbprint", + "entityType": "columns", + "schema": "public", + "table": "relay_dpop_proofs" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "jti", + "entityType": "columns", + "schema": "public", + "table": "relay_dpop_proofs" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "iat", + "entityType": "columns", + "schema": "public", + "table": "relay_dpop_proofs" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "expires_at", + "entityType": "columns", + "schema": "public", + "table": "relay_dpop_proofs" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "created_at", + "entityType": "columns", + "schema": "public", + "table": "relay_dpop_proofs" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "credential_id", + "entityType": "columns", + "schema": "public", + "table": "relay_environment_credentials" + }, + { + "type": "varchar(191)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "environment_id", + "entityType": "columns", + "schema": "public", + "table": "relay_environment_credentials" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "environment_public_key", + "entityType": "columns", + "schema": "public", + "table": "relay_environment_credentials" + }, + { + "type": "varchar(191)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "credential_hash", + "entityType": "columns", + "schema": "public", + "table": "relay_environment_credentials" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "revoked_at", + "entityType": "columns", + "schema": "public", + "table": "relay_environment_credentials" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "created_at", + "entityType": "columns", + "schema": "public", + "table": "relay_environment_credentials" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "updated_at", + "entityType": "columns", + "schema": "public", + "table": "relay_environment_credentials" + }, + { + "type": "varchar(191)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "user_id", + "entityType": "columns", + "schema": "public", + "table": "relay_environment_links" + }, + { + "type": "varchar(191)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "environment_id", + "entityType": "columns", + "schema": "public", + "table": "relay_environment_links" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'T3 Environment'", + "generated": null, + "identity": null, + "name": "environment_label", + "entityType": "columns", + "schema": "public", + "table": "relay_environment_links" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "environment_public_key", + "entityType": "columns", + "schema": "public", + "table": "relay_environment_links" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "endpoint_http_base_url", + "entityType": "columns", + "schema": "public", + "table": "relay_environment_links" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "endpoint_ws_base_url", + "entityType": "columns", + "schema": "public", + "table": "relay_environment_links" + }, + { + "type": "varchar(32)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "endpoint_provider_kind", + "entityType": "columns", + "schema": "public", + "table": "relay_environment_links" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "true", + "generated": null, + "identity": null, + "name": "notifications_enabled", + "entityType": "columns", + "schema": "public", + "table": "relay_environment_links" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "true", + "generated": null, + "identity": null, + "name": "live_activities_enabled", + "entityType": "columns", + "schema": "public", + "table": "relay_environment_links" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "managed_tunnels_enabled", + "entityType": "columns", + "schema": "public", + "table": "relay_environment_links" + }, + { + "type": "varchar(191)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "created_by_device_id", + "entityType": "columns", + "schema": "public", + "table": "relay_environment_links" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "revoked_at", + "entityType": "columns", + "schema": "public", + "table": "relay_environment_links" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "created_at", + "entityType": "columns", + "schema": "public", + "table": "relay_environment_links" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "updated_at", + "entityType": "columns", + "schema": "public", + "table": "relay_environment_links" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "user_id", + "entityType": "columns", + "schema": "public", + "table": "relay_live_activities" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "device_id", + "entityType": "columns", + "schema": "public", + "table": "relay_live_activities" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "activity_push_token", + "entityType": "columns", + "schema": "public", + "table": "relay_live_activities" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "remote_start_queued_at", + "entityType": "columns", + "schema": "public", + "table": "relay_live_activities" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "remote_started_at", + "entityType": "columns", + "schema": "public", + "table": "relay_live_activities" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "ended_at", + "entityType": "columns", + "schema": "public", + "table": "relay_live_activities" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "last_aggregate_json", + "entityType": "columns", + "schema": "public", + "table": "relay_live_activities" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "last_live_activity_delivery_at", + "entityType": "columns", + "schema": "public", + "table": "relay_live_activities" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "created_at", + "entityType": "columns", + "schema": "public", + "table": "relay_live_activities" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "updated_at", + "entityType": "columns", + "schema": "public", + "table": "relay_live_activities" + }, + { + "type": "varchar(191)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "user_id", + "entityType": "columns", + "schema": "public", + "table": "relay_managed_endpoint_allocations" + }, + { + "type": "varchar(191)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "environment_id", + "entityType": "columns", + "schema": "public", + "table": "relay_managed_endpoint_allocations" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "hostname", + "entityType": "columns", + "schema": "public", + "table": "relay_managed_endpoint_allocations" + }, + { + "type": "varchar(191)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "tunnel_id", + "entityType": "columns", + "schema": "public", + "table": "relay_managed_endpoint_allocations" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "tunnel_name", + "entityType": "columns", + "schema": "public", + "table": "relay_managed_endpoint_allocations" + }, + { + "type": "varchar(191)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "dns_record_id", + "entityType": "columns", + "schema": "public", + "table": "relay_managed_endpoint_allocations" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "ready_at", + "entityType": "columns", + "schema": "public", + "table": "relay_managed_endpoint_allocations" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "created_at", + "entityType": "columns", + "schema": "public", + "table": "relay_managed_endpoint_allocations" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "updated_at", + "entityType": "columns", + "schema": "public", + "table": "relay_managed_endpoint_allocations" + }, + { + "type": "varchar(191)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "user_id", + "entityType": "columns", + "schema": "public", + "table": "relay_managed_tunnel_limits" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "max_tunnels", + "entityType": "columns", + "schema": "public", + "table": "relay_managed_tunnel_limits" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "created_at", + "entityType": "columns", + "schema": "public", + "table": "relay_managed_tunnel_limits" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "updated_at", + "entityType": "columns", + "schema": "public", + "table": "relay_managed_tunnel_limits" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "user_id", + "entityType": "columns", + "schema": "public", + "table": "relay_mobile_devices" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "device_id", + "entityType": "columns", + "schema": "public", + "table": "relay_mobile_devices" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'iOS device'", + "generated": null, + "identity": null, + "name": "label", + "entityType": "columns", + "schema": "public", + "table": "relay_mobile_devices" + }, + { + "type": "varchar(16)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "platform", + "entityType": "columns", + "schema": "public", + "table": "relay_mobile_devices" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "ios_major_version", + "entityType": "columns", + "schema": "public", + "table": "relay_mobile_devices" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "android_api_level", + "entityType": "columns", + "schema": "public", + "table": "relay_mobile_devices" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "app_version", + "entityType": "columns", + "schema": "public", + "table": "relay_mobile_devices" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "bundle_id", + "entityType": "columns", + "schema": "public", + "table": "relay_mobile_devices" + }, + { + "type": "varchar(16)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "aps_environment", + "entityType": "columns", + "schema": "public", + "table": "relay_mobile_devices" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "push_token", + "entityType": "columns", + "schema": "public", + "table": "relay_mobile_devices" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "push_to_start_token", + "entityType": "columns", + "schema": "public", + "table": "relay_mobile_devices" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "preferences_json", + "entityType": "columns", + "schema": "public", + "table": "relay_mobile_devices" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "created_at", + "entityType": "columns", + "schema": "public", + "table": "relay_mobile_devices" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "updated_at", + "entityType": "columns", + "schema": "public", + "table": "relay_mobile_devices" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "updated_at", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "idx_relay_agent_activity_rows_updated", + "entityType": "indexes", + "schema": "public", + "table": "relay_agent_activity_rows" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "environment_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "thread_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "created_at", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "idx_relay_delivery_attempts_environment", + "entityType": "indexes", + "schema": "public", + "table": "relay_delivery_attempts" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "source_job_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "idx_relay_delivery_attempts_source_job", + "entityType": "indexes", + "schema": "public", + "table": "relay_delivery_attempts" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "expires_at", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "idx_relay_dpop_proofs_expires_at", + "entityType": "indexes", + "schema": "public", + "table": "relay_dpop_proofs" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "credential_hash", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "idx_relay_environment_credentials_hash", + "entityType": "indexes", + "schema": "public", + "table": "relay_environment_credentials" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "environment_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "revoked_at", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "idx_relay_environment_credentials_environment", + "entityType": "indexes", + "schema": "public", + "table": "relay_environment_credentials" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "environment_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "environment_public_key", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "revoked_at", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "idx_relay_environment_credentials_environment_key", + "entityType": "indexes", + "schema": "public", + "table": "relay_environment_credentials" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "environment_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "revoked_at", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "idx_relay_environment_links_environment", + "entityType": "indexes", + "schema": "public", + "table": "relay_environment_links" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "activity_push_token", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "idx_relay_live_activities_activity_push_token", + "entityType": "indexes", + "schema": "public", + "table": "relay_live_activities" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "hostname", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "idx_relay_managed_endpoint_allocations_hostname", + "entityType": "indexes", + "schema": "public", + "table": "relay_managed_endpoint_allocations" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "tunnel_name", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "idx_relay_managed_endpoint_allocations_tunnel_name", + "entityType": "indexes", + "schema": "public", + "table": "relay_managed_endpoint_allocations" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "push_token", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "idx_relay_mobile_devices_push_token", + "entityType": "indexes", + "schema": "public", + "table": "relay_mobile_devices" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "push_to_start_token", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "idx_relay_mobile_devices_push_to_start_token", + "entityType": "indexes", + "schema": "public", + "table": "relay_mobile_devices" + }, + { + "columns": ["environment_id", "environment_public_key", "thread_id"], + "nameExplicit": false, + "name": "relay_agent_activity_rows_pkey", + "entityType": "pks", + "schema": "public", + "table": "relay_agent_activity_rows" + }, + { + "columns": ["thumbprint", "jti"], + "nameExplicit": false, + "name": "relay_dpop_proofs_pkey", + "entityType": "pks", + "schema": "public", + "table": "relay_dpop_proofs" + }, + { + "columns": ["user_id", "environment_id"], + "nameExplicit": false, + "name": "relay_environment_links_pkey", + "entityType": "pks", + "schema": "public", + "table": "relay_environment_links" + }, + { + "columns": ["user_id", "device_id"], + "nameExplicit": false, + "name": "relay_live_activities_pkey", + "entityType": "pks", + "schema": "public", + "table": "relay_live_activities" + }, + { + "columns": ["user_id", "environment_id"], + "nameExplicit": false, + "name": "relay_managed_endpoint_allocations_pkey", + "entityType": "pks", + "schema": "public", + "table": "relay_managed_endpoint_allocations" + }, + { + "columns": ["user_id", "device_id"], + "nameExplicit": false, + "name": "relay_mobile_devices_pkey", + "entityType": "pks", + "schema": "public", + "table": "relay_mobile_devices" + }, + { + "columns": ["id"], + "nameExplicit": false, + "name": "relay_delivery_attempts_pkey", + "schema": "public", + "table": "relay_delivery_attempts", + "entityType": "pks" + }, + { + "columns": ["credential_id"], + "nameExplicit": false, + "name": "relay_environment_credentials_pkey", + "schema": "public", + "table": "relay_environment_credentials", + "entityType": "pks" + }, + { + "columns": ["user_id"], + "nameExplicit": false, + "name": "relay_managed_tunnel_limits_pkey", + "schema": "public", + "table": "relay_managed_tunnel_limits", + "entityType": "pks" + } + ], + "renames": [] +} diff --git a/infra/relay/package.json b/infra/relay/package.json index 32dd40290c1f6f2e3eafe285d8a32d270c56f4bf..94d1d5036a4a394612f4cf12aac9e089887a1389 100644 --- a/infra/relay/package.json +++ b/infra/relay/package.json @@ -4,6 +4,8 @@ "type": "module", "scripts": { "deploy": "node -- scripts/deploy.ts", + "push:android:smoke": "node scripts/android-push-smoke.ts", + "push:android:watch": "node scripts/android-push-watch.ts", "destroy": "alchemy destroy", "test": "vp test run", "typecheck": "tsc --noEmit" diff --git a/infra/relay/scripts/android-push-smoke.ts b/infra/relay/scripts/android-push-smoke.ts new file mode 100644 index 0000000000000000000000000000000000000000..0c2078159ae776424b0931141294175bbcbfd2de --- /dev/null +++ b/infra/relay/scripts/android-push-smoke.ts @@ -0,0 +1,152 @@ +// @effect-diagnostics nodeBuiltinImport:off - This developer command reads local credential files. +import * as NodeFSP from "node:fs/promises"; +import * as NodeRuntime from "@effect/platform-node/NodeRuntime"; +import * as Effect from "effect/Effect"; +import * as Clock from "effect/Clock"; +import * as Layer from "effect/Layer"; +import * as Redacted from "effect/Redacted"; +import * as Schema from "effect/Schema"; +import * as FetchHttpClient from "effect/unstable/http/FetchHttpClient"; + +import * as RelayConfiguration from "../src/Config.ts"; +import * as WebCrypto from "../src/WebCrypto.ts"; +import * as FcmAssertionSigner from "../src/agentActivity/FcmAssertionSigner.ts"; +import * as FcmClient from "../src/agentActivity/FcmClient.ts"; + +const Device = Schema.Struct({ + token: Schema.NonEmptyString, + deviceId: Schema.NonEmptyString, + userId: Schema.NonEmptyString, + packageName: Schema.NonEmptyString, + deepLink: Schema.optional(Schema.NonEmptyString), +}); +const decodeDevice = Schema.decodeUnknownEffect(Schema.fromJsonString(Device)); +const Phase = Schema.Literals(["running", "approval", "input", "completed", "failed", "end"]); +const decodePhase = Schema.decodeUnknownEffect(Phase); + +class SmokeUsageError extends Schema.TaggedError()("SmokeUsageError", {}) { + override get message() { + return "Usage: node scripts/android-push-smoke.ts "; + } +} + +class SmokeCredentialReadError extends Schema.TaggedError()( + "SmokeCredentialReadError", + { cause: Schema.Defect() }, +) { + override get message() { + return "Could not read service-account file."; + } +} + +class SmokeDeviceReadError extends Schema.TaggedError()( + "SmokeDeviceReadError", + { cause: Schema.Defect() }, +) { + override get message() { + return "Could not read device file."; + } +} + +class SmokeUnregisteredDeviceError extends Schema.TaggedError()( + "SmokeUnregisteredDeviceError", + {}, +) { + override get message() { + return "This device token is no longer registered with Firebase."; + } +} + +const main = Effect.gen(function* () { + const [credentialPath, devicePath, phaseArg] = process.argv.slice(2); + if (!credentialPath || !devicePath || !phaseArg) return yield* new SmokeUsageError({}); + const phase = yield* decodePhase(phaseArg); + const credentials = yield* Effect.tryPromise({ + try: () => NodeFSP.readFile(credentialPath, "utf8"), + catch: (cause) => new SmokeCredentialReadError({ cause }), + }); + const device = yield* Effect.tryPromise({ + try: () => NodeFSP.readFile(devicePath, "utf8"), + catch: (cause) => new SmokeDeviceReadError({ cause }), + }).pipe(Effect.flatMap(decodeDevice)); + const title = + phase === "completed" + ? "Agent finished" + : phase === "failed" + ? "Agent failed" + : phase === "approval" + ? "Approval needed" + : phase === "input" + ? "Input needed" + : null; + const active = phase === "running" || phase === "approval" || phase === "input"; + const now = yield* Clock.currentTimeMillis; + const config: RelayConfiguration.RelayConfiguration["Service"] = { + relayIssuer: "http://localhost", + fcmServiceAccount: Redacted.make(credentials), + apns: null, + clerkSecretKey: Redacted.make(""), + clerkPublishableKey: "", + clerkJwtAudience: "", + apnsDeliveryJobSigningSecret: Redacted.make(""), + cloudMintPrivateKey: Redacted.make(""), + cloudMintPublicKey: "", + managedEndpointBaseDomain: undefined, + managedEndpointNamespace: undefined, + }; + const result = yield* FcmClient.FcmClient.pipe( + Effect.flatMap((client) => + client.send({ + token: device.token, + packageName: device.packageName, + alert: title !== null, + data: { + t3_kind: "agent_activity", + device_id: device.deviceId, + user_id: device.userId, + updated_at: String(now), + active: String(active), + activity_title: active + ? "1 active agent" + : phase === "failed" + ? "Agent work failed" + : "Agent work completed", + activity_expires_at: String( + phase === "end" ? 0 : now + (active ? 2 * 60 * 60 * 1000 : 15 * 60 * 1000), + ), + activity_body: "Android notification test", + activity_path: device.deepLink ?? "/", + ...(title + ? { + alert_id: `smoke-${now}`, + alert_title: title, + alert_body: "T3 Code Android push test", + alert_path: device.deepLink ?? "/", + } + : {}), + }, + }), + ), + Effect.provide( + FcmClient.layer.pipe( + Layer.provide( + FcmAssertionSigner.layer.pipe( + Layer.provide(Layer.succeed(WebCrypto.WebCrypto, { subtle: globalThis.crypto.subtle })), + ), + ), + Layer.provide( + Layer.mergeAll( + Layer.succeed(RelayConfiguration.RelayConfiguration, config), + FetchHttpClient.layer, + ), + ), + ), + ), + ); + if (result.unregistered) return yield* new SmokeUnregisteredDeviceError({}); + yield* Effect.logInfo( + `Firebase accepted the ${phase} notification. Verify delivery on the device.`, + ); +}); + +NodeRuntime.runMain(main); diff --git a/infra/relay/scripts/android-push-watch.ts b/infra/relay/scripts/android-push-watch.ts new file mode 100644 index 0000000000000000000000000000000000000000..2b902b33f94661b53aff2bd523e57a9054a6cba2 --- /dev/null +++ b/infra/relay/scripts/android-push-watch.ts @@ -0,0 +1,227 @@ +// @effect-diagnostics nodeBuiltinImport:off - Local developer verification reads private credential files. +import * as NodeFSP from "node:fs/promises"; +import * as NodeRuntime from "@effect/platform-node/NodeRuntime"; +import * as NodeSocket from "@effect/platform-node/NodeSocket"; +import { + ORCHESTRATION_WS_METHODS, + WS_METHODS, + WsRpcGroup, + type OrchestrationProjectShell, + type OrchestrationThreadShell, +} from "@t3tools/contracts"; +import type { RelayAgentActivityState } from "@t3tools/contracts/relay"; +import { projectThreadAwareness } from "@t3tools/shared/agentAwareness"; +import * as Cause from "effect/Cause"; +import * as Option from "effect/Option"; +import * as Clock from "effect/Clock"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Redacted from "effect/Redacted"; +import * as Schema from "effect/Schema"; +import * as Stream from "effect/Stream"; +import * as FetchHttpClient from "effect/unstable/http/FetchHttpClient"; +import * as RpcClient from "effect/unstable/rpc/RpcClient"; +import * as RpcSerialization from "effect/unstable/rpc/RpcSerialization"; +import * as Socket from "effect/unstable/socket/Socket"; + +import * as RelayConfiguration from "../src/Config.ts"; +import { androidActivityData, fitFcmData } from "../src/agentActivity/fcmPayloads.ts"; +import * as WebCrypto from "../src/WebCrypto.ts"; +import * as FcmAssertionSigner from "../src/agentActivity/FcmAssertionSigner.ts"; +import * as FcmClient from "../src/agentActivity/FcmClient.ts"; +import * as FcmDeliveries from "../src/agentActivity/FcmDeliveries.ts"; +import { makeAggregateState } from "../src/agentActivity/agentActivityAggregate.ts"; + +const Device = Schema.Struct({ + token: Schema.NonEmptyString, + deviceId: Schema.NonEmptyString, + userId: Schema.NonEmptyString, + packageName: Schema.NonEmptyString, +}); +const Connection = Schema.Struct({ + wsUrl: Schema.NonEmptyString, + bearerToken: Schema.NonEmptyString, +}); +const readFile = (path: string) => Effect.tryPromise(() => NodeFSP.readFile(path, "utf8")); +const encodeJson = Schema.encodeSync(Schema.fromJsonString(Schema.Unknown)); +class WatchUnregisteredDeviceError extends Schema.TaggedError()( + "WatchUnregisteredDeviceError", + {}, +) { + override get message() { + return "Device token is no longer registered"; + } +} + +class WatchStoppedError extends Schema.TaggedError()("WatchStoppedError", { + cause: Schema.Defect(), +}) { + override get message() { + return "Android push watcher stopped. Check the private connection and Firebase configuration."; + } +} + +const isWatchUnregisteredDeviceError = Schema.is(WatchUnregisteredDeviceError); + +const preferences = { + notificationsEnabled: true, + liveActivitiesEnabled: true, + notifyOnApproval: true, + notifyOnInput: true, + notifyOnCompletion: true, + notifyOnFailure: true, +}; + +const main = Effect.gen(function* () { + const [credentialsPath, devicePath, connectionPath] = process.argv.slice(2); + if (!credentialsPath || !devicePath || !connectionPath) { + return yield* Effect.logError( + "Usage: node scripts/android-push-watch.ts ", + ); + } + const credentials = yield* readFile(credentialsPath); + const device = yield* readFile(devicePath).pipe( + Effect.flatMap(Schema.decodeUnknownEffect(Schema.fromJsonString(Device))), + ); + const connection = yield* readFile(connectionPath).pipe( + Effect.flatMap(Schema.decodeUnknownEffect(Schema.fromJsonString(Connection))), + ); + const socketConstructor = Layer.succeed( + Socket.WebSocketConstructor, + (url, protocols) => + new NodeSocket.NodeWS.WebSocket(url, protocols, { + headers: { authorization: `Bearer ${connection.bearerToken}` }, + }) as unknown as globalThis.WebSocket, + ); + const protocol = RpcClient.layerProtocolSocket().pipe( + Layer.provide(Socket.layerWebSocket(connection.wsUrl).pipe(Layer.provide(socketConstructor))), + Layer.provide(RpcSerialization.layerJson), + ); + const fcm = FcmClient.layer.pipe( + Layer.provide( + FcmAssertionSigner.layer.pipe( + Layer.provide(Layer.succeed(WebCrypto.WebCrypto, { subtle: globalThis.crypto.subtle })), + ), + ), + Layer.provide( + Layer.mergeAll( + FetchHttpClient.layer, + Layer.succeed(RelayConfiguration.RelayConfiguration, { + relayIssuer: "http://localhost", + apns: null, + fcmServiceAccount: Redacted.make(credentials), + clerkSecretKey: Redacted.make(""), + clerkPublishableKey: "", + clerkJwtAudience: "", + apnsDeliveryJobSigningSecret: Redacted.make(""), + cloudMintPrivateKey: Redacted.make(""), + cloudMintPublicKey: "", + managedEndpointBaseDomain: undefined, + managedEndpointNamespace: undefined, + }), + ), + ), + ); + yield* Effect.gen(function* () { + const rpc = yield* RpcClient.make(WsRpcGroup); + const sender = yield* FcmClient.FcmClient; + const config = yield* rpc[WS_METHODS.serverGetConfig]({}); + const projects = new Map(); + const threads = new Map(); + let states = new Map(); + let previouslyActive = false; + yield* Effect.logInfo("Watching this paired environment for Android push verification."); + yield* rpc[ORCHESTRATION_WS_METHODS.subscribeShell]({}).pipe( + Stream.runForEach( + Effect.fnUntraced(function* (item) { + switch (item.kind) { + case "synchronized": + return; + case "snapshot": + projects.clear(); + threads.clear(); + for (const project of item.snapshot.projects) projects.set(project.id, project); + for (const thread of item.snapshot.threads) threads.set(thread.id, thread); + break; + case "project-upserted": + projects.set(item.project.id, item.project); + break; + case "project-removed": + projects.delete(item.projectId); + break; + case "thread-upserted": + threads.set(item.thread.id, item.thread); + break; + case "thread-removed": + threads.delete(item.threadId); + break; + } + const next = new Map(); + for (const thread of threads.values()) { + const project = projects.get(thread.projectId); + if (!project || thread.archivedAt) continue; + const state = projectThreadAwareness({ + environmentId: config.environment.environmentId, + project, + thread, + }); + if (state) next.set(thread.id, state); + } + const state = item.kind === "thread-upserted" ? next.get(item.thread.id) : undefined; + const previous = state ? states.get(state.threadId) : undefined; + // A fresh subscription restores ongoing work without announcing old completions. + const now = yield* Clock.currentTimeMillis; + const alert = + state && state.phase !== previous?.phase && item.kind !== "snapshot" + ? FcmDeliveries.androidAlertForState(state, preferences, now) + : null; + const aggregate = makeAggregateState({ + activeStates: [...next.values()], + terminalState: null, + nowMs: now, + }); + const active = (aggregate?.activeCount ?? 0) > 0; + const same = encodeJson([...next.values()]) === encodeJson([...states.values()]); + states = next; + if ((!active && !previouslyActive && !alert) || (same && !alert)) return; + const result = yield* sender.send({ + token: device.token, + packageName: device.packageName, + alert: alert !== null, + data: fitFcmData({ + t3_kind: "agent_activity", + device_id: device.deviceId, + user_id: device.userId, + updated_at: String(now), + ...androidActivityData(aggregate), + ...alert, + }), + }); + if (result.unregistered) return yield* new WatchUnregisteredDeviceError({}); + previouslyActive = active; + yield* Effect.logInfo( + `Android push accepted: ${state?.phase ?? (active ? "active" : "ended")}`, + ); + }), + ), + ); + }).pipe(Effect.provide(Layer.mergeAll(protocol, fcm))); +}); + +NodeRuntime.runMain( + main.pipe( + Effect.scoped, + Effect.catchCause((cause) => { + const failure = Cause.findErrorOption(cause); + return Effect.fail( + Option.isSome(failure) && isWatchUnregisteredDeviceError(failure.value) + ? failure.value + : new WatchStoppedError({ cause }), + ); + }), + // Socket failures may contain credential-bearing request headers. Keep the + // cause on the error, but print only the fixed message at the CLI boundary. + Effect.tapError((error) => Effect.logError(error.message)), + ), + { disableErrorReporting: true }, +); diff --git a/infra/relay/src/Config.ts b/infra/relay/src/Config.ts index e7c7d42f2ae1eeace31b68fd931ab4e0dc7326e7..1f9872c08f2394dc2872143db9baffb11238be43 100644 --- a/infra/relay/src/Config.ts +++ b/infra/relay/src/Config.ts @@ -18,7 +18,8 @@ export class RelayConfiguration extends Context.Service< RelayConfiguration, { readonly relayIssuer: string; - readonly apns: ApnsCredentials; + readonly apns: ApnsCredentials | null; + readonly fcmServiceAccount?: Redacted.Redacted; readonly clerkSecretKey: Redacted.Redacted; readonly clerkPublishableKey: string; readonly clerkJwtAudience: string; diff --git a/infra/relay/src/WebCrypto.ts b/infra/relay/src/WebCrypto.ts new file mode 100644 index 0000000000000000000000000000000000000000..72358a027324e261690965d2d1dde2927dadc5d2 --- /dev/null +++ b/infra/relay/src/WebCrypto.ts @@ -0,0 +1,5 @@ +import * as Context from "effect/Context"; + +export class WebCrypto extends Context.Service()( + "t3code-relay/WebCrypto", +) {} diff --git a/infra/relay/src/agentActivity/AgentActivityPublisher.test.ts b/infra/relay/src/agentActivity/AgentActivityPublisher.test.ts index a70a85a8e6ed360fe494f86712b092385128b3e0..ae3d4e4ad24d736f4736fab2d29b80a92e6bd39f 100644 --- a/infra/relay/src/agentActivity/AgentActivityPublisher.test.ts +++ b/infra/relay/src/agentActivity/AgentActivityPublisher.test.ts @@ -7,8 +7,18 @@ import * as AgentActivityRows from "./AgentActivityRows.ts"; import * as EnvironmentLinks from "../environments/EnvironmentLinks.ts"; import * as LiveActivities from "./LiveActivities.ts"; import * as AgentActivityPublisher from "./AgentActivityPublisher.ts"; +import { FcmDeliveries } from "./FcmDeliveries.ts"; import * as ApnsDeliveries from "./ApnsDeliveries.ts"; +const publisherLayer = AgentActivityPublisher.layer.pipe( + Layer.provide( + Layer.succeed(FcmDeliveries, { + enqueue: () => Effect.succeed(null), + process: () => Effect.void, + }), + ), +); + const state: RelayAgentActivityState = { environmentId: "env" as RelayAgentActivityState["environmentId"], threadId: "thread" as RelayAgentActivityState["threadId"], @@ -129,6 +139,64 @@ function makeApnsDeliveries( } describe("AgentActivityPublisher", () => { + it.effect("routes Android publication and registration replay to FCM alongside iOS", () => { + const android = { ...target("android"), platform: "android" as const, ios_major_version: null }; + const ios = target("ios"); + const fcmCalls: Array[0]> = []; + const appleDevices: string[] = []; + return Effect.gen(function* () { + const publisher = yield* AgentActivityPublisher.AgentActivityPublisher; + yield* publisher.publish({ + environmentId: state.environmentId, + environmentPublicKey: "key", + threadId: state.threadId, + state, + }); + yield* publisher.replayForLiveActivityRegistration({ + userId: android.user_id, + deviceId: android.device_id, + }); + expect(fcmCalls).toEqual([ + { target: android, state }, + { target: android, state: null, replay: true }, + ]); + expect(appleDevices).toEqual(["ios"]); + }).pipe( + Effect.provide( + AgentActivityPublisher.layer.pipe( + Layer.provide( + Layer.mergeAll( + Layer.succeed(AgentActivityRows.AgentActivityRows, makeAgentActivityRows()), + Layer.succeed(EnvironmentLinks.EnvironmentLinks, makeEnvironmentLinks()), + Layer.succeed( + LiveActivities.LiveActivities, + makeLiveActivities({ listTargets: () => Effect.succeed([android, ios]) }), + ), + Layer.succeed( + ApnsDeliveries.ApnsDeliveries, + makeApnsDeliveries({ + sendForTarget: (input) => + Effect.sync(() => { + appleDevices.push(input.target.device_id); + return null; + }), + }), + ), + Layer.succeed(FcmDeliveries, { + enqueue: (input) => + Effect.sync(() => { + fcmCalls.push(input); + return null; + }), + process: () => Effect.void, + }), + ), + ), + ), + ), + ); + }); + it.effect("replays the latest aggregate when a Live Activity token registers", () => { const registeredTarget: LiveActivities.TargetRow = { ...target("device-1"), @@ -157,7 +225,7 @@ describe("AgentActivityPublisher", () => { }); }).pipe( Effect.provide( - AgentActivityPublisher.layer.pipe( + publisherLayer.pipe( Layer.provide( Layer.mergeAll( Layer.succeed(AgentActivityRows.AgentActivityRows, makeAgentActivityRows()), @@ -230,7 +298,7 @@ describe("AgentActivityPublisher", () => { }); }).pipe( Effect.provide( - AgentActivityPublisher.layer.pipe( + publisherLayer.pipe( Layer.provide( Layer.mergeAll( Layer.succeed( @@ -324,7 +392,7 @@ describe("AgentActivityPublisher", () => { }); }).pipe( Effect.provide( - AgentActivityPublisher.layer.pipe( + publisherLayer.pipe( Layer.provide( Layer.mergeAll( Layer.succeed( @@ -430,7 +498,7 @@ describe("AgentActivityPublisher", () => { }); }).pipe( Effect.provide( - AgentActivityPublisher.layer.pipe( + publisherLayer.pipe( Layer.provide( Layer.mergeAll( Layer.succeed( @@ -542,7 +610,7 @@ describe("AgentActivityPublisher", () => { }); }).pipe( Effect.provide( - AgentActivityPublisher.layer.pipe( + publisherLayer.pipe( Layer.provide( Layer.mergeAll( Layer.succeed( diff --git a/infra/relay/src/agentActivity/AgentActivityPublisher.ts b/infra/relay/src/agentActivity/AgentActivityPublisher.ts index 063da58a90b3f1a9c355c5c19c5fef5a88a78a7b..61a420dd78584570f48d37b9e8229352ebc85e62 100644 --- a/infra/relay/src/agentActivity/AgentActivityPublisher.ts +++ b/infra/relay/src/agentActivity/AgentActivityPublisher.ts @@ -20,8 +20,10 @@ import * as AgentActivityRows from "./AgentActivityRows.ts"; import * as EnvironmentLinks from "../environments/EnvironmentLinks.ts"; import * as LiveActivities from "./LiveActivities.ts"; import * as ApnsDeliveries from "./ApnsDeliveries.ts"; +import * as FcmDeliveries from "./FcmDeliveries.ts"; export type AgentActivityPublishError = + | FcmDeliveries.FcmDeliveryError | AgentActivityRows.AgentActivityRowUpsertPersistenceError | AgentActivityRows.AgentActivityRowDeletePersistenceError | AgentActivityRows.AgentActivityRowListPersistenceError @@ -50,6 +52,7 @@ export const make = Effect.gen(function* () { const links = yield* EnvironmentLinks.EnvironmentLinks; const liveActivities = yield* LiveActivities.LiveActivities; const apnsDeliveries = yield* ApnsDeliveries.ApnsDeliveries; + const fcmDeliveries = yield* FcmDeliveries.FcmDeliveries; const publishForDeliveryUser = Effect.fnUntraced(function* (input: { readonly deliveryUser: EnvironmentLinks.AgentAwarenessDeliveryUserRecord; @@ -79,8 +82,11 @@ export const make = Effect.gen(function* () { const targets = yield* liveActivities.listTargets({ userId: input.deliveryUser.userId }); const deliveriesByTarget = yield* Effect.forEach( targets, - (target) => - Effect.all( + Effect.fnUntraced(function* (target) { + if (target.platform === "android") { + return [yield* fcmDeliveries.enqueue({ target, state: input.state })]; + } + return yield* Effect.all( [ apnsDeliveries.sendForTarget({ target, @@ -95,7 +101,8 @@ export const make = Effect.gen(function* () { }), ], { concurrency: 2 }, - ), + ); + }), { concurrency: 4 }, ); return deliveriesByTarget.flat(); @@ -120,6 +127,9 @@ export const make = Effect.gen(function* () { if (target === null) { return null; } + if (target.platform === "android") { + return yield* fcmDeliveries.enqueue({ target, state: null, replay: true }); + } const now = yield* DateTime.now; const aggregate = makeAggregateState({ activeStates, diff --git a/infra/relay/src/agentActivity/ApnsDeliveries.test.ts b/infra/relay/src/agentActivity/ApnsDeliveries.test.ts index 75b821bcfa7d73e0fd0a6939e01dcf09ab38a79a..e98c2b639598985e94a35aa51c1a5675cb9ba927 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 db01d1e7880c721a184fbad50627f9cb517d3c30..651f031f0efa7610428f346dd638b5fe7e228b70 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, @@ -842,6 +852,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", @@ -1081,6 +1101,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, @@ -1100,6 +1121,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 3723bf4778b91145f07263582a8260caec08a24d..da6e410618cd2e812fb0f34ce708f7d6338899cb 100644 --- a/infra/relay/src/agentActivity/Devices.ts +++ b/infra/relay/src/agentActivity/Devices.ts @@ -128,7 +128,8 @@ export const make = Effect.gen(function* () { deviceId: registration.deviceId, label: registration.label, platform: registration.platform, - iosMajorVersion: registration.iosMajorVersion, + iosMajorVersion: registration.iosMajorVersion ?? null, + androidApiLevel: registration.androidApiLevel ?? null, appVersion: registration.appVersion ?? null, bundleId: registration.bundleId ?? null, apsEnvironment: registration.apsEnvironment ?? null, @@ -143,7 +144,8 @@ export const make = Effect.gen(function* () { set: { platform: registration.platform, label: registration.label, - iosMajorVersion: registration.iosMajorVersion, + iosMajorVersion: registration.iosMajorVersion ?? null, + androidApiLevel: registration.androidApiLevel ?? null, appVersion: registration.appVersion ?? null, // Preserve routing from newer app builds when an older build // re-registers without these fields. @@ -225,6 +227,7 @@ export const make = Effect.gen(function* () { label: relayMobileDevices.label, platform: relayMobileDevices.platform, iosMajorVersion: relayMobileDevices.iosMajorVersion, + androidApiLevel: relayMobileDevices.androidApiLevel, appVersion: relayMobileDevices.appVersion, preferences: relayMobileDevices.preferencesJson, updatedAt: relayMobileDevices.updatedAt, @@ -241,6 +244,7 @@ export const make = Effect.gen(function* () { label: row.label, platform: row.platform, iosMajorVersion: row.iosMajorVersion, + androidApiLevel: row.androidApiLevel, appVersion: row.appVersion, notifications: { enabled: row.preferences.notificationsEnabled, diff --git a/infra/relay/src/agentActivity/FcmAssertionSigner.ts b/infra/relay/src/agentActivity/FcmAssertionSigner.ts new file mode 100644 index 0000000000000000000000000000000000000000..3c804cd8ab4ffdc0bc30694e09f840ce3861ed92 --- /dev/null +++ b/infra/relay/src/agentActivity/FcmAssertionSigner.ts @@ -0,0 +1,76 @@ +import * as Context from "effect/Context"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Schema from "effect/Schema"; + +import * as WebCrypto from "../WebCrypto.ts"; + +const encodeJson = Schema.encodeSync(Schema.fromJsonString(Schema.Unknown)); + +export class FcmAssertionSigningError extends Schema.TaggedError()( + "FcmAssertionSigningError", + { cause: Schema.Defect() }, +) { + override get message() { + return "Failed to sign Firebase authorization assertion."; + } +} + +function base64Url(bytes: Uint8Array): string { + return btoa(String.fromCharCode(...bytes)) + .replaceAll("+", "-") + .replaceAll("/", "_") + .replace(/=+$/, ""); +} + +export class FcmAssertionSigner extends Context.Service< + FcmAssertionSigner, + { + readonly sign: (input: { + readonly privateKey: string; + readonly clientEmail: string; + readonly issuedAt: number; + }) => Effect.Effect; + } +>()("t3code-relay/agentActivity/FcmAssertionSigner") {} + +export const make = Effect.gen(function* () { + const { subtle } = yield* WebCrypto.WebCrypto; + return FcmAssertionSigner.of({ + sign: Effect.fn("relay.fcm.assertion")(function* (input) { + return yield* Effect.tryPromise({ + try: async () => { + const encoder = new TextEncoder(); + const header = base64Url(encoder.encode(encodeJson({ alg: "RS256", typ: "JWT" }))); + const claims = base64Url( + encoder.encode( + encodeJson({ + iss: input.clientEmail, + scope: "https://www.googleapis.com/auth/firebase.messaging", + aud: "https://oauth2.googleapis.com/token", + iat: input.issuedAt, + exp: input.issuedAt + 3600, + }), + ), + ); + const pem = input.privateKey.replace(/-----[^-]+-----|\s/g, ""); + const key = await subtle.importKey( + "pkcs8", + Uint8Array.from(atob(pem), (c) => c.charCodeAt(0)), + { name: "RSASSA-PKCS1-v1_5", hash: "SHA-256" }, + false, + ["sign"], + ); + const signature = await subtle.sign( + "RSASSA-PKCS1-v1_5", + key, + encoder.encode(`${header}.${claims}`), + ); + return `${header}.${claims}.${base64Url(new Uint8Array(signature))}`; + }, + catch: (cause) => new FcmAssertionSigningError({ cause }), + }); + }), + }); +}); +export const layer = Layer.effect(FcmAssertionSigner, make); diff --git a/infra/relay/src/agentActivity/FcmClient.test.ts b/infra/relay/src/agentActivity/FcmClient.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..90818f92f0791bafff6874948f4be50caa417ced --- /dev/null +++ b/infra/relay/src/agentActivity/FcmClient.test.ts @@ -0,0 +1,254 @@ +import * as NodeCrypto from "node:crypto"; +import { describe, expect, it } from "@effect/vitest"; +import * as Deferred from "effect/Deferred"; +import * as Effect from "effect/Effect"; +import * as Fiber from "effect/Fiber"; +import * as Layer from "effect/Layer"; +import * as Redacted from "effect/Redacted"; +import * as Schema from "effect/Schema"; +import * as TestClock from "effect/testing/TestClock"; +import * as HttpClient from "effect/unstable/http/HttpClient"; +import * as HttpClientResponse from "effect/unstable/http/HttpClientResponse"; +import type * as HttpClientRequest from "effect/unstable/http/HttpClientRequest"; + +import { RelayConfiguration } from "../Config.ts"; +import { FcmClient, layer } from "./FcmClient.ts"; + +import * as WebCrypto from "../WebCrypto.ts"; +import * as FcmAssertionSigner from "./FcmAssertionSigner.ts"; + +const { privateKey, publicKey } = NodeCrypto.generateKeyPairSync("rsa", { + modulusLength: 2048, + privateKeyEncoding: { type: "pkcs8", format: "pem" }, + publicKeyEncoding: { type: "spki", format: "pem" }, +}); +const account = { + project_id: "test-project", + client_email: "push@test-project.iam.gserviceaccount.com", + private_key: privateKey, +}; +const encodeJson = Schema.encodeSync(Schema.fromJsonString(Schema.Unknown)); +const decodeJson = Schema.decodeUnknownSync(Schema.fromJsonString(Schema.Unknown)); +const config = { + relayIssuer: "https://relay.test", + apns: { + environment: "sandbox", + teamId: "team", + keyId: "key", + bundleId: "app", + privateKey: Redacted.make("unused"), + }, + fcmServiceAccount: Redacted.make(encodeJson(account)), + clerkSecretKey: Redacted.make("unused"), + clerkPublishableKey: "unused", + clerkJwtAudience: "unused", + apnsDeliveryJobSigningSecret: Redacted.make("unused"), + cloudMintPrivateKey: Redacted.make("unused"), + cloudMintPublicKey: "unused", + managedEndpointBaseDomain: undefined, + managedEndpointNamespace: undefined, +} satisfies RelayConfiguration["Service"]; +const input = { + token: "device-token", + packageName: "com.t3tools.t3code.dev", + data: { t3_kind: "agent_activity", active: "true" }, + alert: false, +}; + +function testLayer(requests: HttpClientRequest.HttpClientRequest[], responses: Response[]) { + const http = HttpClient.make((request) => { + requests.push(request); + const response = responses.shift(); + return response + ? Effect.succeed(HttpClientResponse.fromWeb(request, response)) + : Effect.die("unexpected request"); + }); + return layer.pipe( + Layer.provide( + FcmAssertionSigner.layer.pipe( + Layer.provide(Layer.succeed(WebCrypto.WebCrypto, { subtle: globalThis.crypto.subtle })), + ), + ), + Layer.provide( + Layer.mergeAll( + Layer.succeed(RelayConfiguration, config), + Layer.succeed(HttpClient.HttpClient, http), + ), + ), + ); +} + +describe("FCM delivery", () => { + it.effect.each([ + { operation: "authorize", stage: "headers", status: null }, + { operation: "authorize", stage: "body", status: 200 }, + { operation: "send", stage: "headers", status: null }, + { operation: "send", stage: "body", status: 503 }, + ] as const)("bounds a stalled $operation $stage and allows the next delivery", (scenario) => + Effect.gen(function* () { + const started = yield* Deferred.make(); + let shouldStall = true; + const http = HttpClient.make((request) => { + const isAuthorization = request.url === "https://oauth2.googleapis.com/token"; + const stall = shouldStall && isAuthorization === (scenario.operation === "authorize"); + const response = HttpClientResponse.fromWeb( + request, + isAuthorization + ? Response.json({ access_token: "access-token" }) + : Response.json({}, { status: stall ? 503 : 200 }), + ); + if (stall) { + shouldStall = false; + const stalled = Deferred.succeed(started, undefined).pipe(Effect.andThen(Effect.never)); + if (scenario.stage === "headers") return stalled; + Object.defineProperty(response, "json", { value: stalled }); + } + return Effect.succeed(response); + }); + yield* Effect.gen(function* () { + const client = yield* FcmClient; + const delivery = yield* client.send(input).pipe(Effect.flip, Effect.forkChild); + yield* Deferred.await(started); + yield* TestClock.adjust("10 seconds"); + expect(yield* Fiber.join(delivery)).toMatchObject({ + _tag: "FcmClientError", + operation: scenario.operation, + status: scenario.status, + cause: expect.objectContaining({ _tag: "TimeoutError" }), + }); + expect(yield* client.send(input)).toEqual({ unregistered: false }); + }).pipe( + Effect.provide( + layer.pipe( + Layer.provide( + FcmAssertionSigner.layer.pipe( + Layer.provide( + Layer.succeed(WebCrypto.WebCrypto, { subtle: globalThis.crypto.subtle }), + ), + ), + ), + Layer.provide(Layer.succeed(RelayConfiguration, config)), + Layer.provide(Layer.succeed(HttpClient.HttpClient, http)), + ), + ), + ); + }), + ); + + it.effect("signs a verifiable Google OAuth assertion scoped to messaging", () => + Effect.gen(function* () { + const signer = yield* FcmAssertionSigner.FcmAssertionSigner; + const assertion = yield* signer.sign({ + privateKey: account.private_key, + clientEmail: account.client_email, + issuedAt: 1000, + }); + const [header, claims, signature] = assertion.split("."); + expect( + NodeCrypto.verify( + "RSA-SHA256", + Buffer.from(`${header}.${claims}`), + publicKey, + Buffer.from(signature!, "base64url"), + ), + ).toBe(true); + expect(decodeJson(Buffer.from(claims!, "base64url").toString())).toEqual({ + iss: account.client_email, + scope: "https://www.googleapis.com/auth/firebase.messaging", + aud: "https://oauth2.googleapis.com/token", + iat: 1000, + exp: 4600, + }); + }).pipe( + Effect.provide( + FcmAssertionSigner.layer.pipe( + Layer.provide(Layer.succeed(WebCrypto.WebCrypto, { subtle: globalThis.crypto.subtle })), + ), + ), + ), + ); + + it.effect( + "reuses OAuth authorization and sends native data messages to the correct package", + () => { + const requests: HttpClientRequest.HttpClientRequest[] = []; + return Effect.gen(function* () { + const client = yield* FcmClient; + yield* client.send(input); + yield* client.send({ ...input, alert: true }); + expect(requests.map((request) => request.url)).toEqual([ + "https://oauth2.googleapis.com/token", + "https://fcm.googleapis.com/v1/projects/test-project/messages:send", + "https://fcm.googleapis.com/v1/projects/test-project/messages:send", + ]); + expect(requests[1]!.headers.authorization).toBe("Bearer access-token"); + const body = requests[1]!.body; + if (body._tag !== "Uint8Array") throw new Error("Expected encoded FCM body"); + expect(decodeJson(new TextDecoder().decode(body.body))).toEqual({ + message: { + token: input.token, + data: input.data, + android: { + priority: "HIGH", + ttl: "300s", + collapse_key: "t3-agent-activity", + restricted_package_name: input.packageName, + }, + }, + }); + }).pipe( + Effect.provide( + testLayer(requests, [ + Response.json({ access_token: "access-token" }), + Response.json({ name: "one" }), + Response.json({ name: "two" }), + ]), + ), + ); + }, + ); + + it.effect("invalidates authorization after 401 and recognizes unregistered device tokens", () => { + const requests: HttpClientRequest.HttpClientRequest[] = []; + return Effect.gen(function* () { + const client = yield* FcmClient; + const first = yield* client.send(input).pipe(Effect.flip); + expect(first.status).toBe(401); + expect(yield* client.send(input)).toEqual({ unregistered: true }); + expect(requests[2]!.url).toBe("https://oauth2.googleapis.com/token"); + expect(requests[3]!.headers.authorization).toBe("Bearer fresh-token"); + }).pipe( + Effect.provide( + testLayer(requests, [ + Response.json({ access_token: "old-token" }), + Response.json({}, { status: 401 }), + Response.json({ access_token: "fresh-token" }), + Response.json( + { + error: { + details: [ + { + "@type": "type.googleapis.com/google.firebase.fcm.v1.FcmError", + errorCode: "UNREGISTERED", + }, + ], + }, + }, + { status: 404 }, + ), + ]), + ), + ); + }); + it.effect("rejects oversized data before contacting Firebase", () => { + const requests: HttpClientRequest.HttpClientRequest[] = []; + return Effect.gen(function* () { + const client = yield* FcmClient; + const error = yield* client + .send({ ...input, data: { body: "漢".repeat(1500) } }) + .pipe(Effect.flip); + expect(error.operation).toBe("send"); + expect(requests).toHaveLength(0); + }).pipe(Effect.provide(testLayer(requests, []))); + }); +}); diff --git a/infra/relay/src/agentActivity/FcmClient.ts b/infra/relay/src/agentActivity/FcmClient.ts new file mode 100644 index 0000000000000000000000000000000000000000..eb8d327f7a5e2482ffa315df91085c2afb6f3f26 --- /dev/null +++ b/infra/relay/src/agentActivity/FcmClient.ts @@ -0,0 +1,166 @@ +import * as Context from "effect/Context"; +import * as DateTime from "effect/DateTime"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as Redacted from "effect/Redacted"; +import * as Schema from "effect/Schema"; +import * as HttpClient from "effect/unstable/http/HttpClient"; +import * as HttpClientRequest from "effect/unstable/http/HttpClientRequest"; + +import * as RelayConfiguration from "../Config.ts"; +import * as FcmAssertionSigner from "./FcmAssertionSigner.ts"; + +const FCM_HTTP_STAGE_TIMEOUT = "10 seconds"; + +const ServiceAccount = Schema.Struct({ + project_id: Schema.NonEmptyString, + client_email: Schema.NonEmptyString, + private_key: Schema.NonEmptyString, +}); +const decodeServiceAccount = Schema.decodeUnknownOption(Schema.fromJsonString(ServiceAccount)); +const decodeAccessToken = Schema.decodeUnknownEffect( + Schema.Struct({ access_token: Schema.NonEmptyString }), +); +const encodeJson = Schema.encodeSync(Schema.fromJsonString(Schema.Unknown)); +const decodeFcmError = Schema.decodeUnknownOption( + Schema.Struct({ + error: Schema.Struct({ + status: Schema.optional(Schema.String), + details: Schema.optional( + Schema.Array( + Schema.Struct({ + "@type": Schema.optional(Schema.String), + errorCode: Schema.optional(Schema.String), + }), + ), + ), + }), + }), +); + +export class FcmClientError extends Schema.TaggedError()("FcmClientError", { + operation: Schema.Literals(["configuration", "authorize", "send"]), + status: Schema.NullOr(Schema.Number), + cause: Schema.optional(Schema.Defect()), +}) { + override get message() { + return `FCM ${this.operation} failed${this.status === null ? "" : ` (${this.status})`}.`; + } +} + +export class FcmClient extends Context.Service< + FcmClient, + { + readonly send: (input: { + readonly token: string; + readonly packageName: string | null; + readonly data: Readonly>; + readonly alert: boolean; + }) => Effect.Effect<{ readonly unregistered: boolean }, FcmClientError>; + } +>()("t3code-relay/agentActivity/FcmClient") {} + +export const make = Effect.gen(function* () { + const config = yield* RelayConfiguration.RelayConfiguration; + const signer = yield* FcmAssertionSigner.FcmAssertionSigner; + const client = yield* HttpClient.HttpClient; + const account = config.fcmServiceAccount + ? decodeServiceAccount(Redacted.value(config.fcmServiceAccount)) + : Option.none(); + const authorize = Effect.gen(function* () { + if (Option.isNone(account)) + return yield* new FcmClientError({ operation: "configuration", status: null }); + const now = yield* DateTime.now; + const assertion = yield* signer + .sign({ + privateKey: account.value.private_key, + clientEmail: account.value.client_email, + issuedAt: Math.floor(now.epochMilliseconds / 1000), + }) + .pipe( + Effect.mapError( + (cause) => new FcmClientError({ operation: "authorize", status: null, cause }), + ), + ); + const response = yield* client + .execute( + HttpClientRequest.post("https://oauth2.googleapis.com/token").pipe( + HttpClientRequest.bodyUrlParams({ + grant_type: "urn:ietf:params:oauth:grant-type:jwt-bearer", + assertion, + }), + ), + ) + .pipe( + Effect.timeout(FCM_HTTP_STAGE_TIMEOUT), + Effect.mapError( + (cause) => new FcmClientError({ operation: "authorize", status: null, cause }), + ), + ); + if (response.status !== 200) + return yield* new FcmClientError({ operation: "authorize", status: response.status }); + return yield* response.json.pipe( + Effect.timeout(FCM_HTTP_STAGE_TIMEOUT), + Effect.flatMap(decodeAccessToken), + Effect.map((body) => body.access_token), + Effect.mapError( + (cause) => new FcmClientError({ operation: "authorize", status: response.status, cause }), + ), + ); + }); + const [accessToken, invalidateToken] = yield* Effect.cachedInvalidateWithTTL( + authorize, + "50 minutes", + ); + + return FcmClient.of({ + send: Effect.fn("relay.fcm.send")(function* (input) { + if (new TextEncoder().encode(encodeJson(input.data)).length > 4096) + return yield* new FcmClientError({ operation: "send", status: null }); + if (Option.isNone(account)) + return yield* new FcmClientError({ operation: "configuration", status: null }); + const token = yield* accessToken.pipe(Effect.tapError(() => invalidateToken)); + const response = yield* HttpClientRequest.post( + `https://fcm.googleapis.com/v1/projects/${encodeURIComponent(account.value.project_id)}/messages:send`, + ).pipe( + HttpClientRequest.bearerToken(token), + HttpClientRequest.bodyJson({ + message: { + token: input.token, + data: input.data, + android: { + priority: "HIGH", + ttl: "300s", + ...(!input.alert ? { collapse_key: "t3-agent-activity" } : {}), + ...(input.packageName ? { restricted_package_name: input.packageName } : {}), + }, + }, + }), + Effect.flatMap(client.execute), + Effect.timeout(FCM_HTTP_STAGE_TIMEOUT), + Effect.mapError((cause) => new FcmClientError({ operation: "send", status: null, cause })), + ); + if (response.status >= 200 && response.status < 300) return { unregistered: false }; + if (response.status === 401) yield* invalidateToken; + const body = yield* response.json.pipe( + Effect.timeout(FCM_HTTP_STAGE_TIMEOUT), + Effect.mapError( + (cause) => new FcmClientError({ operation: "send", status: response.status, cause }), + ), + ); + const decoded = decodeFcmError(body); + const unregistered = + Option.isSome(decoded) && + decoded.value.error.details?.some( + (detail) => + detail["@type"] === "type.googleapis.com/google.firebase.fcm.v1.FcmError" && + detail.errorCode === "UNREGISTERED", + ) === true; + if (unregistered) return { unregistered: true }; + return yield* new FcmClientError({ operation: "send", status: response.status }); + }), + }); +}); + +export const layer = Layer.effect(FcmClient, make); diff --git a/infra/relay/src/agentActivity/FcmDeliveries.test.ts b/infra/relay/src/agentActivity/FcmDeliveries.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..9e4868b7f1b20ac5b57aef813413686e147ff431 --- /dev/null +++ b/infra/relay/src/agentActivity/FcmDeliveries.test.ts @@ -0,0 +1,836 @@ +import { EnvironmentId, ThreadId } from "@t3tools/contracts"; +import type { RelayAgentActivityState } from "@t3tools/contracts/relay"; +import { describe, expect, it } from "@effect/vitest"; +import * as NodeCryptoLayer from "@effect/platform-node/NodeCrypto"; +import * as Effect from "effect/Effect"; +import * as DateTime from "effect/DateTime"; +import * as Layer from "effect/Layer"; +import * as Redacted from "effect/Redacted"; +import * as Schema from "effect/Schema"; +import * as Stream from "effect/Stream"; +import type * as Cloudflare from "alchemy/Cloudflare"; +import * as FcmDeliveryQueueConsumer from "./FcmDeliveryQueueConsumer.ts"; + +import { RelayConfiguration } from "../Config.ts"; +import { RelayDb } from "../db.ts"; +import { EnvironmentLinks } from "../environments/EnvironmentLinks.ts"; +import { AgentActivityRows } from "./AgentActivityRows.ts"; +import { LiveActivities, type TargetRow } from "./LiveActivities.ts"; +import * as FcmDeliveryQueueSender from "./FcmDeliveryQueueSender.ts"; +import { FcmClient, FcmClientError } from "./FcmClient.ts"; +import { + FcmDeliveries, + androidAlertForState, + androidAlertForAggregate, + layer, + type FcmDeliveryJob, +} from "./FcmDeliveries.ts"; +import { TestClock } from "effect/testing"; +import { androidActivityData, fitFcmData } from "./fcmPayloads.ts"; +import { makeAggregateState } from "./agentActivityAggregate.ts"; + +const aggregateFor = (states: ReadonlyArray) => + makeAggregateState({ activeStates: states, terminalState: null, nowMs: 0 })!; + +const encodeJson = Schema.encodeSync(Schema.fromJsonString(Schema.Unknown)); +const state: RelayAgentActivityState = { + environmentId: EnvironmentId.make("env"), + threadId: ThreadId.make("thread"), + projectTitle: "Project", + threadTitle: "Fix notifications", + phase: "running", + headline: "Working", + modelTitle: "Codex", + updatedAt: "1970-01-01T00:00:00.000Z", + deepLink: "/threads/env/thread", +}; +const preferences = { + notificationsEnabled: true, + liveActivitiesEnabled: true, + notifyOnApproval: true, + notifyOnInput: true, + notifyOnCompletion: true, + notifyOnFailure: true, +}; +const target: TargetRow = { + user_id: "user", + device_id: "phone", + platform: "android", + ios_major_version: null, + app_version: null, + bundle_id: "com.t3tools.t3code.dev", + aps_environment: null, + push_token: "fcm-token", + push_to_start_token: null, + preferences_json: encodeJson(preferences), + activity_push_token: null, + remote_start_queued_at: null, + remote_started_at: null, + ended_at: null, + last_aggregate_json: null, + last_live_activity_delivery_at: null, +}; +const config = { + relayIssuer: "https://relay.test", + fcmServiceAccount: Redacted.make("configured"), + apns: { + environment: "sandbox", + teamId: "unused", + keyId: "unused", + bundleId: "unused", + privateKey: Redacted.make("unused"), + }, + clerkSecretKey: Redacted.make("unused"), + clerkPublishableKey: "unused", + clerkJwtAudience: "unused", + apnsDeliveryJobSigningSecret: Redacted.make("unused"), + cloudMintPrivateKey: Redacted.make("unused"), + cloudMintPublicKey: "unused", + managedEndpointBaseDomain: undefined, + managedEndpointNamespace: undefined, +} satisfies RelayConfiguration["Service"]; + +function harness() { + const sent: Array[0]> = []; + const queued: FcmDeliveryJob[] = []; + const marked: Array[0]> = []; + const current = { + target: { ...target }, + state: { ...state } as RelayAgentActivityState | null, + otherStates: [] as RelayAgentActivityState[], + mutedEnvironments: [] as string[], + notificationOnlyEnvironments: [] as string[], + revokedEnvironments: [] as string[], + linked: true, + deliveryFailure: null as FcmClientError | null, + }; + const services = Layer.mergeAll( + NodeCryptoLayer.layer, + Layer.succeed(RelayConfiguration, config), + Layer.succeed(FcmDeliveryQueueSender.FcmDeliveryQueueSender, { + send: (job) => + Effect.sync(() => { + queued.push(job); + }), + }), + Layer.succeed(FcmClient, { + send: (input) => + Effect.suspend(() => + current.deliveryFailure + ? Effect.fail(current.deliveryFailure) + : Effect.sync(() => { + sent.push(input); + return { unregistered: false }; + }), + ), + }), + Layer.succeed(LiveActivities, { + register: () => Effect.void, + listTargets: () => Effect.sync(() => [current.target]), + markDelivery: (input) => + Effect.sync(() => { + marked.push(input); + current.target.last_aggregate_json = input.aggregate ? encodeJson(input.aggregate) : null; + }), + markStartQueued: () => Effect.void, + clearStartQueued: () => Effect.void, + invalidateDeliveryToken: () => Effect.void, + }), + Layer.succeed(AgentActivityRows, { + upsert: () => Effect.void, + remove: () => Effect.void, + pruneTerminal: () => Effect.void, + listForUser: () => + Effect.sync(() => + current.linked + ? [...(current.state ? [current.state] : []), ...current.otherStates].filter( + (row) => + !current.revokedEnvironments.includes(row.environmentId) && + !current.notificationOnlyEnvironments.includes(row.environmentId), + ) + : [], + ), + getForUserThread: (input) => + Effect.sync(() => + current.linked + ? ([current.state, ...current.otherStates].find( + (row) => + row?.environmentId === input.environmentId && row.threadId === input.threadId, + ) ?? null) + : null, + ), + }), + Layer.succeed(EnvironmentLinks, { + upsert: () => Effect.void, + listUsersForEnvironment: () => Effect.succeed(["user"]), + listDeliveryUsersForEnvironment: (input) => + Effect.sync(() => + current.linked && !current.revokedEnvironments.includes(input.environmentId) + ? [ + { + userId: "user", + notificationsEnabled: !current.mutedEnvironments.includes(input.environmentId), + liveActivitiesEnabled: !current.notificationOnlyEnvironments.includes( + input.environmentId, + ), + }, + ] + : [], + ), + listPublicKeysForEnvironment: () => Effect.succeed([]), + listForUser: () => Effect.succeed([]), + revokeForUser: () => Effect.succeed(false), + getForUser: (input) => + Effect.sync(() => + current.linked && !current.revokedEnvironments.includes(input.environmentId) + ? { + environmentId: EnvironmentId.make(input.environmentId), + label: "Desktop", + environmentPublicKey: "key", + linkedAt: state.updatedAt, + endpoint: { + httpBaseUrl: "https://env.test", + wsBaseUrl: "wss://env.test", + providerKind: "manual", + }, + } + : null, + ), + }), + Layer.succeed(RelayDb, {} as RelayDb["Service"]), + ); + return { + sent, + queued, + marked, + current, + layer: layer.pipe(Layer.provide(services)), + job: { + userId: "user", + deviceId: "phone", + token: "fcm-token", + state, + queuedAt: 0, + } satisfies FcmDeliveryJob, + }; +} + +describe("Android delivery routing", () => { + const secondState: RelayAgentActivityState = { + ...state, + threadId: ThreadId.make("second-thread"), + threadTitle: "Second thread", + deepLink: "/threads/env/second-thread", + }; + + for (const [firstPhase, secondPhase, title, active] of [ + ["waiting_for_approval", "waiting_for_input", "2 agents need attention", "true"], + ["completed", "failed", "2 agents finished", "false"], + ] as const) { + it.effect(`groups ${firstPhase} and ${secondPhase} once across their queued jobs`, () => { + const h = harness(); + h.current.otherStates = [secondState]; + return Effect.gen(function* () { + const delivery = yield* FcmDeliveries; + yield* delivery.process(h.job); + h.current.state = { ...state, phase: firstPhase }; + h.current.otherStates = [{ ...secondState, phase: secondPhase }]; + yield* delivery.process({ ...h.job, state: h.current.state }); + yield* delivery.process({ ...h.job, state: h.current.otherStates[0] }); + yield* delivery.process({ ...h.job, state: h.current.state }); + const alerts = h.sent.filter((message) => message.alert); + expect(alerts).toHaveLength(1); + expect(alerts[0]?.data).toMatchObject({ + alert_title: title, + alert_body: "Fix notifications, Second thread", + alert_path: firstPhase === "completed" ? secondState.deepLink : state.deepLink, + active, + }); + expect(h.marked.at(-1)?.aggregate?.activities).toHaveLength(2); + }).pipe(Effect.provide(h.layer)); + }); + } + + it.effect("filters disabled event types before counting a group", () => { + const h = harness(); + h.current.target.last_aggregate_json = encodeJson(aggregateFor([state, secondState])); + h.current.target.preferences_json = encodeJson({ ...preferences, notifyOnApproval: false }); + h.current.state = { ...state, phase: "waiting_for_approval" }; + h.current.otherStates = [{ ...secondState, phase: "waiting_for_input" }]; + return Effect.gen(function* () { + const delivery = yield* FcmDeliveries; + yield* delivery.process({ ...h.job, state: h.current.state }); + expect(h.sent[0]?.data).toMatchObject({ + alert_title: "Second thread", + alert_body: "Input: Project", + alert_path: "/threads/env/second-thread", + }); + }).pipe(Effect.provide(h.layer)); + }); + + it.effect("excludes already delivered attention and stale completions from groups", () => { + const h = harness(); + h.current.state = { ...state, phase: "waiting_for_approval" }; + h.current.target.last_aggregate_json = encodeJson(aggregateFor([h.current.state, secondState])); + h.current.otherStates = [ + { ...secondState, phase: "completed", updatedAt: "1969-12-31T23:57:00.000Z" }, + ]; + return Effect.gen(function* () { + const delivery = yield* FcmDeliveries; + yield* delivery.process({ ...h.job, state: h.current.state }); + expect(h.sent.every((message) => !message.alert)).toBe(true); + }).pipe(Effect.provide(h.layer)); + }); + + it.effect("prioritizes attention over simultaneous completions like iOS", () => { + const h = harness(); + h.current.target.last_aggregate_json = encodeJson(aggregateFor([state, secondState])); + h.current.state = { ...state, phase: "completed" }; + h.current.otherStates = [{ ...secondState, phase: "waiting_for_input" }]; + return Effect.gen(function* () { + const delivery = yield* FcmDeliveries; + yield* delivery.process({ ...h.job, state: h.current.state }); + expect(h.sent[0]?.data).toMatchObject({ + alert_title: "Second thread", + alert_body: "Input: Project", + }); + yield* delivery.process({ ...h.job, state: h.current.otherStates[0] }); + expect(h.sent.filter((message) => message.alert)).toHaveLength(1); + }).pipe(Effect.provide(h.layer)); + }); + + for (const phase of [ + "completed", + "waiting_for_approval", + "waiting_for_input", + "failed", + ] as const) { + it.effect(`deleting one thread preserves another thread's ${phase} alert`, () => { + const h = harness(); + h.current.otherStates = [secondState]; + return Effect.gen(function* () { + const delivery = yield* FcmDeliveries; + yield* delivery.process(h.job); + h.current.otherStates = []; + h.current.state = { ...state, phase }; + yield* delivery.enqueue({ target, state: null }); + yield* delivery.process(h.queued[0]); + expect(h.sent.at(-1)?.alert).toBe(false); + yield* delivery.process({ ...h.job, state: h.current.state }); + expect(h.sent.filter((message) => message.alert)).toHaveLength(1); + yield* delivery.process({ ...h.job, state: h.current.state }); + expect(h.sent.filter((message) => message.alert)).toHaveLength(1); + }).pipe(Effect.provide(h.layer)); + }); + } + + it.effect("registration replay establishes a baseline without alerting", () => { + const h = harness(); + h.current.state = { ...state, phase: "waiting_for_approval" }; + h.current.otherStates = [{ ...secondState, phase: "waiting_for_input" }]; + return Effect.gen(function* () { + const delivery = yield* FcmDeliveries; + yield* delivery.enqueue({ target, state: null, replay: true }); + yield* delivery.process(h.queued[0]); + yield* delivery.process({ ...h.job, state: h.current.state }); + expect(h.sent.every((message) => !message.alert)).toBe(true); + expect(h.marked[0]?.aggregate?.activities).toHaveLength(2); + }).pipe(Effect.provide(h.layer)); + }); + + for (const restriction of ["mutedEnvironments", "revokedEnvironments"] as const) { + it.effect(`excludes ${restriction} when forming cross-environment groups`, () => { + const h = harness(); + const other = { ...secondState, environmentId: EnvironmentId.make("other-env") }; + h.current.target.last_aggregate_json = encodeJson(aggregateFor([state, other])); + h.current.state = { ...state, phase: "waiting_for_approval" }; + h.current.otherStates = [{ ...other, phase: "waiting_for_input" }]; + h.current[restriction] = [other.environmentId]; + return Effect.gen(function* () { + const delivery = yield* FcmDeliveries; + yield* delivery.process({ ...h.job, state: h.current.state }); + expect(h.sent[0]?.data).toMatchObject({ + alert_title: "Fix notifications", + alert_body: "Approval: Project", + }); + }).pipe(Effect.provide(h.layer)); + }); + } + + it("gives a group a stable retry identity independent of row order", () => { + const other = { + ...secondState, + environmentId: EnvironmentId.make("other-env"), + threadId: state.threadId, + }; + const input = { + previousAggregate: aggregateFor([state, other]), + nextAggregate: aggregateFor([ + { ...state, phase: "completed" }, + { ...other, phase: "failed" }, + ]), + preferences, + nowMs: 0, + }; + const alert = androidAlertForAggregate(input); + expect(alert?.alert_title).toBe("2 agents finished"); + expect( + androidAlertForAggregate({ + ...input, + nextAggregate: { + ...input.nextAggregate, + activities: input.nextAggregate.activities.toReversed(), + }, + })?.alert_id, + ).toBe(alert?.alert_id); + expect( + androidAlertForAggregate({ + ...input, + nextAggregate: { + ...input.nextAggregate, + activities: input.nextAggregate.activities.map((row) => ({ + ...row, + updatedAt: "1970-01-01T00:00:01.000Z", + })), + }, + })?.alert_id, + ).not.toBe(alert?.alert_id); + }); + + it("distinguishes matching thread IDs in different environments", () => { + const other = { + ...secondState, + environmentId: EnvironmentId.make("other-env"), + threadId: state.threadId, + }; + const alreadyWaiting = { ...state, phase: "waiting_for_approval" as const }; + expect( + androidAlertForAggregate({ + previousAggregate: aggregateFor([alreadyWaiting, other]), + nextAggregate: aggregateFor([alreadyWaiting, { ...other, phase: "waiting_for_input" }]), + preferences, + nowMs: 0, + }), + ).toMatchObject({ alert_title: "Second thread", alert_body: "Input: Project" }); + }); + + for (const [phase, body, preference] of [ + ["waiting_for_approval", "Approval: Project", "notifyOnApproval"], + ["waiting_for_input", "Input: Project", "notifyOnInput"], + ["completed", "Done: Project", "notifyOnCompletion"], + ["failed", "Failed: Project", "notifyOnFailure"], + ] as const) { + it.effect(`uses iOS alert wording for ${phase} and honors its preference`, () => { + const h = harness(); + h.current.state = { ...state, phase }; + return Effect.gen(function* () { + const delivery = yield* FcmDeliveries; + yield* delivery.process({ ...h.job, state: h.current.state }); + expect(h.sent[0]?.data).toMatchObject({ + alert_title: "Fix notifications", + alert_body: body, + alert_path: "/threads/env/thread", + }); + h.current.target.preferences_json = encodeJson({ + ...preferences, + [preference]: false, + }); + yield* delivery.process({ ...h.job, state: h.current.state }); + expect(h.sent.slice(1).every((sent) => !sent.alert && !sent.data.alert_id)).toBe(true); + }).pipe(Effect.provide(h.layer)); + }); + } + + it("trims and truncates alert text like iOS", () => { + expect( + androidAlertForState( + { + ...state, + phase: "completed", + threadTitle: ` ${"T".repeat(150)} `, + projectTitle: ` ${"P".repeat(150)} `, + }, + preferences, + 0, + ), + ).toMatchObject({ + alert_title: `${"T".repeat(117)}...`, + alert_body: `Done: ${"P".repeat(111)}...`, + }); + }); + + it.effect( + "queues Android devices and sends the latest aggregate instead of a stale running state", + () => { + const h = harness(); + return Effect.gen(function* () { + const delivery = yield* FcmDeliveries; + yield* delivery.enqueue({ target, state }); + h.current.state = { ...state, phase: "completed" }; + yield* delivery.process(h.queued[0]); + expect(h.sent).toHaveLength(0); + yield* delivery.process({ ...h.job, state: h.current.state }); + expect(h.sent[0]?.data).toMatchObject({ + active: "false", + alert_title: "Fix notifications", + alert_body: "Done: Project", + alert_path: "/threads/env/thread", + }); + }).pipe(Effect.provide(h.layer)); + }, + ); + + it.effect("keeps completion alerts working with ongoing activity disabled", () => { + const h = harness(); + h.current.state = { ...state, phase: "completed" }; + h.current.target.preferences_json = encodeJson({ + ...preferences, + liveActivitiesEnabled: false, + }); + return Effect.gen(function* () { + const delivery = yield* FcmDeliveries; + yield* delivery.process({ ...h.job, state: h.current.state }); + expect(h.sent[0]?.data).toMatchObject({ + active: "false", + alert_title: "Fix notifications", + alert_body: "Done: Project", + }); + expect(h.sent[0]?.data.user_id).toBe("user"); + }).pipe(Effect.provide(h.layer)); + }); + + it.effect("drops jobs for rotated tokens, expired jobs, and revoked links", () => { + const h = harness(); + return Effect.gen(function* () { + const delivery = yield* FcmDeliveries; + yield* delivery.process({ ...h.job, token: "old-token" }); + yield* delivery.process({ ...h.job, queuedAt: -400_000 }); + h.current.linked = false; + yield* delivery.process(h.job); + expect(h.sent).toHaveLength(0); + }).pipe(Effect.provide(h.layer)); + }); + + it.effect("ends an existing ongoing notification when the environment stops publishing", () => { + const h = harness(); + h.current.target.last_aggregate_json = encodeJson(aggregateFor([state])); + h.current.state = null; + return Effect.gen(function* () { + const delivery = yield* FcmDeliveries; + yield* delivery.process({ ...h.job, state: null }); + expect(h.sent[0]?.data).toMatchObject({ active: "false" }); + expect(h.sent[0]?.alert).toBe(false); + expect(h.marked[0]?.kind).toBe("live_activity_end"); + }).pipe(Effect.provide(h.layer)); + }); + + it.effect("leaves iOS devices on their existing delivery path", () => { + const h = harness(); + return Effect.gen(function* () { + const delivery = yield* FcmDeliveries; + expect(yield* delivery.enqueue({ target: { ...target, platform: "ios" }, state })).toBeNull(); + expect(h.queued).toHaveLength(0); + }).pipe(Effect.provide(h.layer)); + }); + + it.effect("honors disabled alert preferences while continuing ongoing activity", () => { + const h = harness(); + h.current.state = { ...state, phase: "waiting_for_approval" }; + h.current.target.preferences_json = encodeJson({ ...preferences, notifyOnApproval: false }); + return Effect.gen(function* () { + const delivery = yield* FcmDeliveries; + yield* delivery.process({ ...h.job, state: h.current.state }); + expect(h.sent[0]?.data.active).toBe("true"); + expect(h.sent[0]?.data.alert_id).toBeUndefined(); + h.current.target.preferences_json = encodeJson({ + ...preferences, + notificationsEnabled: false, + }); + h.current.target.last_aggregate_json = encodeJson(aggregateFor([state])); + yield* delivery.process({ ...h.job, state: h.current.state }); + expect(h.sent[1]?.data.active).toBe("false"); + expect(h.sent[1]?.data.alert_id).toBeUndefined(); + }).pipe(Effect.provide(h.layer)); + }); + for (const ongoing of [true, false]) { + for (const phase of ["completed", "failed"] as const) { + it.effect(`does not alert a stale ${phase} without a baseline (ongoing=${ongoing})`, () => { + const h = harness(); + h.current.state = { ...state, phase, updatedAt: "1969-12-31T23:57:00.000Z" }; + h.current.target.preferences_json = encodeJson({ + ...preferences, + liveActivitiesEnabled: ongoing, + }); + return Effect.gen(function* () { + const delivery = yield* FcmDeliveries; + yield* delivery.process({ ...h.job, state: h.current.state }); + expect(h.sent.every((message) => !message.alert)).toBe(true); + }).pipe(Effect.provide(h.layer)); + }); + } + } + + it.effect( + "retains finished results without extending expiry on replay and clears expired cards", + () => { + const h = harness(); + h.current.state = { ...state, phase: "failed" }; + return Effect.gen(function* () { + const delivery = yield* FcmDeliveries; + yield* delivery.process({ ...h.job, state: h.current.state }); + expect(h.sent[0]?.data).toMatchObject({ + active: "false", + activity_title: "Agent work failed", + activity_expires_at: "900000", + }); + yield* TestClock.adjust("5 minutes"); + yield* delivery.process({ ...h.job, queuedAt: 300000, state: null, replay: true }); + expect(h.sent[1]?.data.activity_expires_at).toBe("900000"); + expect(h.sent[1]?.alert).toBe(false); + yield* TestClock.adjust("11 minutes"); + yield* delivery.process({ ...h.job, queuedAt: 960000, state: null, replay: true }); + expect(h.sent[2]?.data).toMatchObject({ active: "false", activity_expires_at: "0" }); + expect(h.marked.at(-1)?.aggregate).toBeNull(); + }).pipe(Effect.provide(h.layer)); + }, + ); + + it.effect("replays an empty card to repair an orphan without a delivery baseline", () => { + const h = harness(); + h.current.state = null; + return Effect.gen(function* () { + const delivery = yield* FcmDeliveries; + yield* delivery.process({ ...h.job, state: null }); + expect(h.sent[0]?.data.activity_expires_at).toBe("0"); + expect(h.sent[0]?.alert).toBe(false); + }).pipe(Effect.provide(h.layer)); + }); + + it("shows five rows with attention then failure first, including project and status", () => { + const aggregate = aggregateFor([ + state, + { ...secondState, phase: "failed" }, + { ...state, threadId: ThreadId.make("approval"), phase: "waiting_for_approval" }, + { ...state, threadId: ThreadId.make("input"), phase: "waiting_for_input" }, + { ...state, threadId: ThreadId.make("done"), phase: "completed" }, + ]); + const data = androidActivityData(aggregate); + expect(data.activity_title).toBe("3 active agents · 2 need attention"); + expect( + Object.entries(data) + .filter(([key]) => key.startsWith("activity_line_")) + .map(([, value]) => value), + ).toEqual([ + "Approval\tFix notifications\tProject", + "Input\tFix notifications\tProject", + "Failed\tSecond thread\tProject", + "Working\tFix notifications\tProject", + "Done\tFix notifications\tProject", + ]); + expect(data.activity_expires_at).toBe(String(24 * 60 * 60 * 1000)); + expect(androidActivityData(aggregateFor([state])).activity_expires_at).toBe( + String(2 * 60 * 60 * 1000), + ); + }); + + it("fits five Unicode rows and a grouped alert in the FCM budget without corrupting text or routes", () => { + const longTitle = '🤖漢字"\\'.repeat(30); + const aggregate = aggregateFor( + Array.from({ length: 5 }, (_, i) => ({ + ...state, + threadId: ThreadId.make(`thread-${i}`), + threadTitle: longTitle, + projectTitle: longTitle, + })), + ); + const data = fitFcmData({ + ...androidActivityData(aggregate), + t3_kind: "agent_activity", + device_id: "d".repeat(128), + user_id: "u".repeat(128), + updated_at: "1788780000000", + alert_id: "a".repeat(64), + alert_title: "5 agents finished", + alert_body: Array(5).fill(longTitle).join(", "), + alert_path: "/threads/env/thread", + }); + expect(new TextEncoder().encode(JSON.stringify(data)).length).toBeLessThanOrEqual(3800); + expect(data.alert_path).toBe("/threads/env/thread"); + expect(data.activity_path).toBe("/threads/env/thread"); + expect(data.alert_id).toBe("a".repeat(64)); + expect(data.activity_line_4).toContain("Working\t"); + for (const value of Object.values(data)) + expect(new TextDecoder().decode(new TextEncoder().encode(value))).toBe(value); + }); +}); + +describe("delivery policy regressions", () => { + it.effect("alerts a quick completion even with a previous unrelated card", () => { + const h = harness(); + const old = { ...state, threadId: ThreadId.make("old"), phase: "completed" as const }; + h.current.target.last_aggregate_json = encodeJson(aggregateFor([old])); + h.current.otherStates = [old]; + h.current.state = { ...state, phase: "completed" }; + return Effect.gen(function* () { + const d = yield* FcmDeliveries; + yield* d.process(h.job); + yield* d.process({ ...h.job, state: h.current.state }); + expect(h.sent.filter((x) => x.alert)).toHaveLength(1); + }).pipe(Effect.provide(h.layer)); + }); + it.effect("a muted environment job cannot consume another environment's attention alert", () => { + const h = harness(); + const other = { + ...state, + environmentId: EnvironmentId.make("other"), + threadId: ThreadId.make("other"), + }; + h.current.target.last_aggregate_json = encodeJson(aggregateFor([state, other])); + h.current.otherStates = [{ ...other, phase: "waiting_for_input" }]; + h.current.mutedEnvironments = [state.environmentId]; + return Effect.gen(function* () { + const d = yield* FcmDeliveries; + yield* d.process(h.job); + yield* d.process({ ...h.job, state: h.current.otherStates[0]! }); + expect(h.sent.filter((x) => x.alert)).toHaveLength(1); + }).pipe(Effect.provide(h.layer)); + }); + it("keeps an older waiting thread visible and alertable beyond five running rows", () => { + const running = Array.from({ length: 5 }, (_, i) => ({ + ...state, + threadId: ThreadId.make(`running-${i}`), + })); + const waiting = { + ...state, + phase: "waiting_for_approval" as const, + updatedAt: "1969-12-31T23:59:00.000Z", + }; + const next = aggregateFor([...running, waiting]); + expect( + androidAlertForAggregate({ + previousAggregate: aggregateFor(running), + nextAggregate: next, + preferences, + nowMs: 0, + }), + ).not.toBeNull(); + }); +}); + +describe("notification-only environments", () => { + it.effect("alerts independently while another environment has a live card", () => { + const h = harness(); + const other = { + ...state, + environmentId: EnvironmentId.make("other"), + threadId: ThreadId.make("other"), + }; + h.current.target.last_aggregate_json = encodeJson(aggregateFor([other])); + h.current.otherStates = [other]; + h.current.state = { ...state, phase: "waiting_for_input" }; + h.current.notificationOnlyEnvironments = [state.environmentId]; + return Effect.gen(function* () { + const deliveries = yield* FcmDeliveries; + yield* deliveries.process({ ...h.job, state: h.current.state }); + expect(h.sent).toHaveLength(1); + expect(h.sent[0]?.alert).toBe(true); + expect(h.sent[0]?.data.alert_path).toBe(state.deepLink); + }).pipe(Effect.provide(h.layer)); + }); +}); + +it("continues shrinking text when a longer activity line is already minimal", () => { + const data = fitFcmData({ + activity_line_0: "Approval\t😀😀😀😀\t😀😀😀😀", + alert_body: "x".repeat(30), + device_id: "x".repeat(3680), + }); + expect(new TextEncoder().encode(encodeJson(data)).length).toBeLessThanOrEqual(3800); + expect(data.activity_line_0).toBe("Approval\t😀😀😀😀\t😀😀😀😀"); +}); + +it.effect("notification-only jobs do not consume another environment's card alert", () => { + const h = harness(); + const other = { + ...state, + environmentId: EnvironmentId.make("other"), + threadId: ThreadId.make("other"), + }; + h.current.target.last_aggregate_json = encodeJson(aggregateFor([other])); + h.current.otherStates = [{ ...other, phase: "waiting_for_approval" }]; + h.current.state = { ...state, phase: "waiting_for_input" }; + h.current.notificationOnlyEnvironments = [state.environmentId]; + return Effect.gen(function* () { + const deliveries = yield* FcmDeliveries; + yield* deliveries.process({ ...h.job, state: h.current.state }); + yield* deliveries.process({ ...h.job, state: h.current.otherStates[0]! }); + expect(h.sent.filter((delivery) => delivery.alert)).toHaveLength(2); + }).pipe(Effect.provide(h.layer)); +}); + +it.effect("preserves a structured Firebase failure through the queue consumer", () => { + const h = harness(); + const failure = new FcmClientError({ operation: "send", status: 503 }); + h.current.deliveryFailure = failure; + return Effect.gen(function* () { + const deliveries = yield* FcmDeliveries; + expect(yield* deliveries.process(h.job).pipe(Effect.flip)).toBe(failure); + }).pipe(Effect.provide(h.layer)); +}); + +it("stops reducing five-character row fields and fits the remaining alert", () => { + const data = fitFcmData({ + device_id: "x".repeat(3710), + activity_line_0: "Approval\taaaaa\tbbbbb", + alert_body: "y".repeat(200), + }); + expect(data.activity_line_0).toBe("Approval\taaaaa\tbbbbb"); + expect(new TextEncoder().encode(encodeJson(data)).length).toBeLessThanOrEqual(3800); +}); + +describe("FCM queue message isolation", () => { + for (const failure of ["invalid-job", "fcm-rejection"] as const) { + it.effect(`retries only the ${failure} message and delivers the rest of its batch`, () => { + const h = harness(); + const outcomes = new Map(); + const message = (id: string, body: unknown): Cloudflare.Queues.Message => ({ + id, + body, + timestamp: DateTime.toDateUtc(DateTime.makeUnsafe(0)), + attempts: 1, + ack: () => { + if (!outcomes.has(id)) outcomes.set(id, "ack"); + }, + retry: () => { + if (!outcomes.has(id)) outcomes.set(id, "retry"); + }, + }); + const batch = [ + message("failed", failure === "invalid-job" ? {} : h.job), + message("healthy", h.job), + ]; + return Effect.gen(function* () { + yield* Stream.fromIterable(batch).pipe( + Stream.tap((item) => + Effect.sync(() => { + h.current.deliveryFailure = + item.id === "failed" && failure === "fcm-rejection" + ? new FcmClientError({ operation: "send", status: 400 }) + : null; + }), + ), + Stream.runForEach(FcmDeliveryQueueConsumer.processMessage), + ); + // Alchemy acknowledges the batch after a successful stream. Cloudflare + // ignores those acknowledgements for messages explicitly retried earlier. + for (const item of batch) item.ack(); + expect([...outcomes]).toEqual([ + ["failed", "retry"], + ["healthy", "ack"], + ]); + expect(h.sent).toHaveLength(1); + expect(h.marked).toHaveLength(1); + }).pipe(Effect.provide(h.layer)); + }); + } +}); diff --git a/infra/relay/src/agentActivity/FcmDeliveries.ts b/infra/relay/src/agentActivity/FcmDeliveries.ts new file mode 100644 index 0000000000000000000000000000000000000000..8f998d9d95743eaed2d827b10c82a39d75aa3fca --- /dev/null +++ b/infra/relay/src/agentActivity/FcmDeliveries.ts @@ -0,0 +1,345 @@ +import { and, eq } from "drizzle-orm"; +import { + RelayAgentActivityState, + RelayAgentActivityAggregateState, + RelayAgentAwarenessPreferences, + type RelayDeliveryResult, +} from "@t3tools/contracts/relay"; +import * as Crypto from "effect/Crypto"; +import type * as PlatformError from "effect/PlatformError"; +import * as Context from "effect/Context"; +import * as DateTime from "effect/DateTime"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as Schema from "effect/Schema"; + +import * as RelayConfiguration from "../Config.ts"; +import * as RelayDb from "../db.ts"; +import { relayMobileDevices } from "../persistence/schema.ts"; +import * as EnvironmentLinks from "../environments/EnvironmentLinks.ts"; +import * as AgentActivityRows from "./AgentActivityRows.ts"; +import * as LiveActivities from "./LiveActivities.ts"; +import * as FcmDeliveryQueueSender from "./FcmDeliveryQueueSender.ts"; +import * as FcmClient from "./FcmClient.ts"; +import { androidActivityData, androidActivityHero, fitFcmData } from "./fcmPayloads.ts"; +import { makeAggregateState, statusForPhase } from "./agentActivityAggregate.ts"; +import { isExpiredAgentActivityState, notificationForActivity } from "./agentActivityPayloads.ts"; +import { + alertForActivityRows, + attentionTransitionRows, + terminalTransitionRows, + shouldAlertForActivity, +} from "./agentActivityAlerts.ts"; + +export const FcmDeliveryJob = Schema.Struct({ + userId: Schema.String, + deviceId: Schema.String, + token: Schema.String, + state: Schema.NullOr(RelayAgentActivityState), + queuedAt: Schema.Number, + replay: Schema.optional(Schema.Boolean), +}); +export type FcmDeliveryJob = typeof FcmDeliveryJob.Type; +const decodeJob = Schema.decodeUnknownEffect(FcmDeliveryJob); +const decodePreferences = Schema.decodeUnknownOption( + Schema.fromJsonString(RelayAgentAwarenessPreferences), +); +const decodePreviousActivity = Schema.decodeUnknownOption( + Schema.fromJsonString(RelayAgentActivityAggregateState), +); + +export class FcmDeliveryError extends Schema.TaggedError()("FcmDeliveryError", { + operation: Schema.Literals(["enqueue", "decode-job", "invalidate-token"]), + cause: Schema.Defect(), +}) { + override get message() { + return `Failed to ${this.operation} Android notification delivery.`; + } +} + +export function androidAlertForState( + state: RelayAgentActivityState, + preferences: RelayAgentAwarenessPreferences, + nowMs: number, +) { + if (!shouldAlertForActivity({ ...state, preferences, nowMs })) return null; + const notification = notificationForActivity({ ...state, status: statusForPhase(state.phase) }); + return { + alert_id: JSON.stringify([state.environmentId, state.threadId, state.phase, state.updatedAt]), + alert_title: notification.title, + alert_body: notification.body, + alert_path: notification.deepLink, + }; +} + +export function androidAlertForAggregate(input: { + readonly previousAggregate: RelayAgentActivityAggregateState; + readonly nextAggregate: RelayAgentActivityAggregateState; + readonly preferences: RelayAgentAwarenessPreferences; + readonly nowMs: number; +}) { + if (!input.preferences.notificationsEnabled) return null; + const attention = attentionTransitionRows(input); + const activities = + attention.length > 0 + ? attention + : terminalTransitionRows({ ...input, includeUnobserved: true }); + const first = activities[0]; + const alert = alertForActivityRows(activities); + if (!first || !alert) return null; + if (activities.length === 1) { + const notification = notificationForActivity(first); + return { + alert_id: JSON.stringify([first.environmentId, first.threadId, first.phase, first.updatedAt]), + alert_title: notification.title, + alert_body: notification.body, + alert_path: notification.deepLink, + }; + } + return { + // Every contributing queue job identifies the same group, including after + // retries or a different database row order. The native handler deduplicates it. + alert_id: JSON.stringify( + activities + .map((row) => [row.environmentId, row.threadId, row.phase, row.updatedAt]) + .sort((a, b) => JSON.stringify(a).localeCompare(JSON.stringify(b))), + ), + alert_title: alert.title, + alert_body: alert.body, + alert_path: androidActivityHero(input.nextAggregate)?.deepLink ?? "/", + }; +} + +export class FcmDeliveries extends Context.Service< + FcmDeliveries, + { + readonly enqueue: (input: { + readonly target: LiveActivities.TargetRow; + readonly state: RelayAgentActivityState | null; + readonly replay?: boolean; + }) => Effect.Effect; + readonly process: ( + body: unknown, + ) => Effect.Effect< + void, + | FcmDeliveryError + | FcmClient.FcmClientError + | PlatformError.PlatformError + | LiveActivities.LiveActivityTargetListPersistenceError + | LiveActivities.LiveActivityDeliveryMarkPersistenceError + | AgentActivityRows.AgentActivityRowListPersistenceError + | EnvironmentLinks.EnvironmentLinkLookupPersistenceError + | EnvironmentLinks.EnvironmentLinkUserListPersistenceError + >; + } +>()("t3code-relay/agentActivity/FcmDeliveries") {} + +export const make = Effect.gen(function* () { + const config = yield* RelayConfiguration.RelayConfiguration; + const crypto = yield* Crypto.Crypto; + const sender = yield* FcmDeliveryQueueSender.FcmDeliveryQueueSender; + const client = yield* FcmClient.FcmClient; + const devices = yield* LiveActivities.LiveActivities; + const rows = yield* AgentActivityRows.AgentActivityRows; + const links = yield* EnvironmentLinks.EnvironmentLinks; + const db = yield* RelayDb.RelayDb; + + return FcmDeliveries.of({ + enqueue: Effect.fn("relay.fcm.enqueue")(function* (input) { + if (input.target.platform !== "android" || !input.target.push_token) return null; + if (!config.fcmServiceAccount) { + yield* Effect.logWarning("Android notifications are not configured for this relay"); + return { + deviceId: input.target.device_id, + kind: "push_notification", + ok: false, + apnsStatus: null, + apnsReason: null, + apnsId: null, + }; + } + const now = yield* DateTime.now; + yield* sender + .send({ + userId: input.target.user_id, + deviceId: input.target.device_id, + token: input.target.push_token, + state: input.state, + ...(input.replay ? { replay: true } : {}), + queuedAt: now.epochMilliseconds, + }) + .pipe(Effect.mapError((cause) => new FcmDeliveryError({ operation: "enqueue", cause }))); + return { + deviceId: input.target.device_id, + kind: "push_notification", + ok: true, + queued: true, + apnsStatus: null, + apnsReason: null, + apnsId: null, + }; + }), + process: Effect.fn("relay.fcm.process")(function* (body) { + const job = yield* decodeJob(body).pipe( + Effect.mapError((cause) => new FcmDeliveryError({ operation: "decode-job", cause })), + ); + const now = yield* DateTime.now; + if (now.epochMilliseconds - job.queuedAt > 5 * 60_000) return; + const targets = yield* devices.listTargets({ userId: job.userId }); + const target = targets.find( + (device) => + device.device_id === job.deviceId && + device.platform === "android" && + device.push_token === job.token, + ); + if (!target) return; + const preferences = decodePreferences(target.preferences_json); + if (Option.isNone(preferences)) return; + + // Re-read links and state when consuming: queued messages must honor + // sign-out, token rotation, disabled publishing, and newer thread states. + const states = preferences.value.liveActivitiesEnabled + ? yield* rows.listForUser({ userId: job.userId }) + : []; + const aggregate = makeAggregateState({ + activeStates: states, + terminalState: null, + nowMs: now.epochMilliseconds, + }); + const previousAggregate = target.last_aggregate_json + ? Option.getOrNull(decodePreviousActivity(target.last_aggregate_json)) + : null; + let alert: ReturnType = null; + // Deletion jobs can observe another thread's newly completed state. They + // update the card, but must leave that transition for its own alert job. + // Registration replay deliberately establishes a silent baseline. + let acknowledgeAggregate = job.state !== null || job.replay === true || aggregate === null; + if (job.state && preferences.value.notificationsEnabled) { + const state = yield* rows.getForUserThread({ + userId: job.userId, + environmentId: job.state.environmentId, + threadId: job.state.threadId, + }); + if ( + !previousAggregate && + (!state || state.phase !== job.state.phase || state.updatedAt !== job.state.updatedAt) + ) + return; + const link = yield* links.getForUser({ + userId: job.userId, + environmentId: job.state.environmentId, + }); + const deliveryUsers = link + ? yield* links.listDeliveryUsersForEnvironment({ + environmentId: job.state.environmentId, + environmentPublicKey: link.environmentPublicKey, + }) + : []; + const deliveryUser = deliveryUsers.find((user) => user.userId === job.userId); + // A notification-only job must not acknowledge transitions on another + // environment's live card before that environment's own job can alert. + acknowledgeAggregate = + deliveryUser?.liveActivitiesEnabled === true || !preferences.value.liveActivitiesEnabled; + if ( + deliveryUser?.liveActivitiesEnabled && + preferences.value.liveActivitiesEnabled && + previousAggregate && + aggregate + ) { + const environmentIds = [...new Set(aggregate.activities.map((row) => row.environmentId))]; + const allowedEnvironments = new Set(); + for (const environmentId of environmentIds) { + const environmentLink = yield* links.getForUser({ + userId: job.userId, + environmentId, + }); + if (!environmentLink) continue; + const users = yield* links.listDeliveryUsersForEnvironment({ + environmentId, + environmentPublicKey: environmentLink.environmentPublicKey, + }); + if (users.some((user) => user.userId === job.userId && user.notificationsEnabled)) { + allowedEnvironments.add(environmentId); + } + } + alert = androidAlertForAggregate({ + previousAggregate, + nextAggregate: { + ...aggregate, + activities: aggregate.activities.filter((row) => + allowedEnvironments.has(row.environmentId), + ), + }, + preferences: preferences.value, + nowMs: now.epochMilliseconds, + }); + } else if ( + deliveryUser?.notificationsEnabled && + state?.phase === job.state.phase && + state.updatedAt === job.state.updatedAt && + !isExpiredAgentActivityState(state, now.epochMilliseconds) + ) { + alert = androidAlertForState(state, preferences.value, now.epochMilliseconds); + } + } + const displayedAggregate = + preferences.value.notificationsEnabled && preferences.value.liveActivitiesEnabled + ? aggregate + : null; + const active = (displayedAggregate?.activeCount ?? 0) > 0; + // A registration replay must clear an orphan even when the relay has + // already forgotten its baseline. Finished cards are visible, but idle. + if (!displayedAggregate && !alert && !previousAggregate && job.state !== null) return; + const data = { + t3_kind: "agent_activity", + device_id: job.deviceId, + user_id: job.userId, + updated_at: String(now.epochMilliseconds), + ...androidActivityData(displayedAggregate), + ...alert, + }; + if (alert) { + // Group identities can contain five sets of IDs. Hash the full, + // stable identity rather than spending the payload budget on it. + const digest = yield* crypto.digest("SHA-256", new TextEncoder().encode(alert.alert_id)); + data.alert_id = Array.from(digest, (byte) => byte.toString(16).padStart(2, "0")).join(""); + } + const result = yield* client.send({ + token: job.token, + packageName: target.bundle_id, + alert: alert !== null, + data: fitFcmData(data), + }); + if (result.unregistered) { + yield* db + .update(relayMobileDevices) + .set({ pushToken: null }) + .where( + and( + eq(relayMobileDevices.userId, job.userId), + eq(relayMobileDevices.deviceId, job.deviceId), + eq(relayMobileDevices.pushToken, job.token), + ), + ) + .pipe( + Effect.mapError( + (cause) => new FcmDeliveryError({ operation: "invalidate-token", cause }), + ), + ); + } else if (acknowledgeAggregate) { + yield* devices.markDelivery({ + userId: job.userId, + deviceId: job.deviceId, + kind: active ? "live_activity_update" : "live_activity_end", + // Keep the delivered terminal rows as the next transition baseline, + // so replaying the finished card cannot alert again. + aggregate: preferences.value.liveActivitiesEnabled ? aggregate : null, + deliveredAt: DateTime.formatIso(now), + }); + } + }), + }); +}); + +export const layer = Layer.effect(FcmDeliveries, make); diff --git a/infra/relay/src/agentActivity/FcmDeliveryQueueConsumer.ts b/infra/relay/src/agentActivity/FcmDeliveryQueueConsumer.ts new file mode 100644 index 0000000000000000000000000000000000000000..dd97edcd6a350a4341974af10d74ed52ab4562e0 --- /dev/null +++ b/infra/relay/src/agentActivity/FcmDeliveryQueueConsumer.ts @@ -0,0 +1,25 @@ +import type * as Cloudflare from "alchemy/Cloudflare"; +import * as Effect from "effect/Effect"; + +import * as FcmDeliveries from "./FcmDeliveries.ts"; + +export const processMessage = Effect.fn("relay.fcm_delivery_queue.process_message")(function* ( + message: Cloudflare.Queues.Message, +) { + const deliveries = yield* FcmDeliveries.FcmDeliveries; + yield* deliveries.process(message.body).pipe( + Effect.matchEffect({ + onFailure: (error) => + Effect.gen(function* () { + // Decide this message's outcome before Alchemy acknowledges the batch. + // Cloudflare keeps the first ack/retry decision for each message. + message.retry(); + yield* Effect.logWarning("FCM queue delivery failed; retrying message", { + messageId: message.id, + errorTag: error._tag, + }); + }), + onSuccess: () => Effect.sync(() => message.ack()), + }), + ); +}); diff --git a/infra/relay/src/agentActivity/FcmDeliveryQueueSender.ts b/infra/relay/src/agentActivity/FcmDeliveryQueueSender.ts new file mode 100644 index 0000000000000000000000000000000000000000..3d9a731082b77ee429d9204e2361634f15de91ed --- /dev/null +++ b/infra/relay/src/agentActivity/FcmDeliveryQueueSender.ts @@ -0,0 +1,14 @@ +import type * as Cloudflare from "alchemy/Cloudflare"; +import * as Context from "effect/Context"; +import type * as Effect from "effect/Effect"; + +import type * as FcmDeliveries from "./FcmDeliveries.ts"; + +export class FcmDeliveryQueueSender extends Context.Service< + FcmDeliveryQueueSender, + { + readonly send: ( + body: FcmDeliveries.FcmDeliveryJob, + ) => Effect.Effect; + } +>()("t3code-relay/agentActivity/FcmDeliveryQueueSender") {} diff --git a/infra/relay/src/agentActivity/LiveActivities.ts b/infra/relay/src/agentActivity/LiveActivities.ts index 94c583c6d65d3179d000357dd2a8311c92457840..0c88fbb73c39d14e5e51b8e996a57465772a20bb 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 ca39484373e94dc91631cd2c9a380b38dd051aca..74d5c3740a51ebc07056db983ecd991b30f1a43e 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 0000000000000000000000000000000000000000..798714d4665b1f6f0c936ceb77c439f2405c3331 --- /dev/null +++ b/infra/relay/src/agentActivity/fcmPayloads.ts @@ -0,0 +1,86 @@ +import type { RelayAgentActivityAggregateState } from "@t3tools/contracts/relay"; +import { + activityPhasePriority, + TERMINAL_AGENT_ACTIVITY_DISPLAY_TTL_MS, +} from "./agentActivityAggregate.ts"; +import { agentActivityExpiresAt } from "./agentActivityPayloads.ts"; + +export function androidActivityHero(aggregate: RelayAgentActivityAggregateState) { + return [...aggregate.activities].sort( + (a, b) => activityPhasePriority(a.phase) - activityPhasePriority(b.phase), + )[0]; +} + +/** The expanded Android card uses the same rows and priority as the iOS widget. */ +export function androidActivityData(aggregate: RelayAgentActivityAggregateState | null) { + const rows = [...(aggregate?.activities ?? [])].sort( + (a, b) => activityPhasePriority(a.phase) - activityPhasePriority(b.phase), + ); + const activeCount = aggregate?.activeCount ?? 0; + const attentionCount = rows.filter((row) => activityPhasePriority(row.phase) === 0).length; + const failed = rows.some((row) => row.phase === "failed"); + const clean = (value: string) => value.replace(/\s+/g, " ").trim(); + const lines = rows.map((row) => + [row.status, clean(row.threadTitle), clean(row.projectTitle)].join("\t"), + ); + const hero = rows[0]; + const title = + activeCount > 0 + ? `${activeCount} active agent${activeCount === 1 ? "" : "s"}${attentionCount ? ` · ${attentionCount} need${attentionCount === 1 ? "s" : ""} attention` : ""}` + : failed + ? "Agent work failed" + : "Agent work completed"; + const expiresAt = Math.max( + 0, + ...rows.map((row) => + row.phase === "completed" || row.phase === "failed" + ? Date.parse(row.updatedAt) + TERMINAL_AGENT_ACTIVITY_DISPLAY_TTL_MS + : agentActivityExpiresAt(row), + ), + ); + return { + active: String(activeCount > 0), + activity_title: title, + activity_body: hero + ? `${hero.status}: ${clean(hero.threadTitle)} · ${clean(hero.projectTitle)}` + : "", + // Separate keys avoid double JSON encoding and preserve each expanded row when bounding the payload. + ...Object.fromEntries(lines.map((line, index) => [`activity_line_${index}`, line])), + activity_path: rows[0]?.deepLink ?? "/", + activity_expires_at: String(expiresAt), + }; +} + +/** Keep Unicode, escaping and grouped alerts within FCM's 4 KB data budget. */ +export function fitFcmData(input: Readonly>): Record { + const data = { ...input }; + const encoder = new TextEncoder(); + const textKeys = Object.keys(data).filter( + (key) => key.endsWith("_body") || key.endsWith("_title") || key.startsWith("activity_line_"), + ); + while (encoder.encode(JSON.stringify(data)).length > 3800) { + const key = textKeys.sort( + (a, b) => encoder.encode(data[b]!).length - encoder.encode(data[a]!).length, + )[0]; + if (!key) break; + if (data[key]!.length <= 8) { + textKeys.splice(textKeys.indexOf(key), 1); + continue; + } + const parts = key.startsWith("activity_line_") ? data[key]!.split("\t") : [data[key]!]; + const part = parts.length === 3 ? (parts[1]!.length > parts[2]!.length ? 1 : 2) : 0; + const characters = Array.from(parts[part]!); + // Five characters would become four plus the ellipsis and never shrink. + if (characters.length <= 5) { + textKeys.splice(textKeys.indexOf(key), 1); + continue; + } + parts[part] = + characters + .slice(0, Math.floor(characters.length * 0.8)) + .join("") + .trimEnd() + "…"; + data[key] = parts.join("\t"); + } + return data; +} diff --git a/infra/relay/src/http/Api.test.ts b/infra/relay/src/http/Api.test.ts index 222deafd621b43c2f0c1eb2995572bbf63d288fd..66b87e643a48738de80cf121638ed1afe72fbbc0 100644 --- a/infra/relay/src/http/Api.test.ts +++ b/infra/relay/src/http/Api.test.ts @@ -2,6 +2,7 @@ import { createClerkClient, verifyToken } from "@clerk/backend"; import { describe, expect, it } from "@effect/vitest"; import { vi } from "vite-plus/test"; import * as Context from "effect/Context"; +import * as NodeCrypto from "@effect/platform-node/NodeCrypto"; import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; import * as Fiber from "effect/Fiber"; @@ -14,11 +15,21 @@ import * as Tracer from "effect/Tracer"; import * as HttpRouter from "effect/unstable/http/HttpRouter"; import * as HttpServerRequest from "effect/unstable/http/HttpServerRequest"; import * as HttpServerResponse from "effect/unstable/http/HttpServerResponse"; +import * as HttpServer from "effect/unstable/http/HttpServer"; +import * as HttpApiBuilder from "effect/unstable/httpapi/HttpApiBuilder"; +import * as HttpApi from "effect/unstable/httpapi/HttpApi"; import { EnvironmentId } from "@t3tools/contracts"; -import { RelayEnvironmentAuth } from "@t3tools/contracts/relay"; +import { + RelayApi, + RelayClientAuth, + RelayClientPrincipal, + RelayEnvironmentAuth, + type RelayClientDeviceRecord, +} from "@t3tools/contracts/relay"; import { RELAY_REQUEST_DEADLINE_MS, + clientApi, relayCors, relayDocsRedirectRoute, relayEnvironmentAuthLayer, @@ -35,6 +46,9 @@ import * as RelayDb from "../db.ts"; import * as EnvironmentCredentials from "../environments/EnvironmentCredentials.ts"; import * as EnvironmentLinks from "../environments/EnvironmentLinks.ts"; import * as ManagedEndpointProvider from "../environments/ManagedEndpointProvider.ts"; +import * as EnvironmentLinker from "../environments/EnvironmentLinker.ts"; +import * as RelayTokens from "../auth/RelayTokens.ts"; +import * as Devices from "../agentActivity/Devices.ts"; vi.mock("@clerk/backend", () => ({ createClerkClient: vi.fn(), @@ -60,6 +74,99 @@ const relaySettings: RelayConfiguration.RelayConfiguration["Service"] = { managedEndpointNamespace: undefined, }; +describe("device listing compatibility", () => { + it.effect("keeps v1 iOS-only while v2 returns every platform for the same account", () => { + const iphone: RelayClientDeviceRecord = { + deviceId: "iphone", + label: "iPhone", + platform: "ios", + iosMajorVersion: 18, + appVersion: "1.0.0", + notifications: { + enabled: true, + notifyOnApproval: true, + notifyOnInput: true, + notifyOnCompletion: true, + notifyOnFailure: true, + }, + liveActivities: { enabled: true }, + updatedAt: "2026-09-09T00:00:00.000Z", + }; + const android: RelayClientDeviceRecord = { + ...iphone, + deviceId: "android", + label: "Android", + platform: "android", + iosMajorVersion: null, + androidApiLevel: 36, + }; + const handlers = clientApi.pipe( + HttpRouter.provideRequest( + Layer.mergeAll( + Layer.mock(EnvironmentCredentials.EnvironmentCredentials, {}), + Layer.mock(EnvironmentLinks.EnvironmentLinks, {}), + Layer.mock(ManagedEndpointProvider.ManagedEndpointProvider, {}), + Layer.mock(RelayDb.RelayTransactions, {}), + ), + ), + Layer.provide( + Layer.mergeAll( + Layer.succeed(RelayConfiguration.RelayConfiguration, relaySettings), + NodeCrypto.layer, + Layer.mock(RelayTokens.RelayTokens, { resolveDpopAccessTokenScopes: () => null }), + Layer.mock(EnvironmentLinker.EnvironmentLinker, {}), + Layer.mock(EnvironmentLinks.EnvironmentLinks, {}), + Layer.mock(ManagedEndpointProvider.ManagedEndpointProvider, {}), + Layer.mock(Devices.Devices, { + listForUser: ({ userId }) => { + expect(userId).toBe("user-1"); + return Effect.succeed([iphone, android]); + }, + }), + ), + ), + Layer.provideMerge( + Layer.succeed(RelayClientAuth, { + clientBearer: (effect) => + Effect.provideService(effect, RelayClientPrincipal, { + userId: "user-1", + token: "test-token", + }), + }), + ), + ); + return Effect.gen(function* () { + const app = yield* Effect.acquireRelease( + Effect.sync(() => + HttpRouter.toWebHandler( + HttpApiBuilder.layer(HttpApi.make("RelayApi").add(RelayApi.groups.client)).pipe( + Layer.provide(handlers), + Layer.provide(HttpServer.layerServices), + ), + { disableLogger: true }, + ), + ), + (app) => Effect.promise(() => app.dispose()), + ); + for (const [version, devices] of [ + ["v1", [iphone]], + ["v2", [iphone, android]], + ] as const) { + const response = yield* Effect.promise(() => + app.handler( + new Request(`https://relay.example.test/${version}/client/devices`, { + headers: { authorization: "Bearer test-token" }, + }), + ), + ); + expect(response.status).toBe(200); + expect(response.headers.get("cache-control")).toBe("no-store"); + expect(yield* Effect.promise(() => response.json())).toEqual({ devices }); + } + }).pipe(Effect.scoped); + }); +}); + describe("relay client authentication", () => { it.effect("preserves the existing Clerk session JWT path", () => Effect.gen(function* () { diff --git a/infra/relay/src/http/Api.ts b/infra/relay/src/http/Api.ts index 62a38d666b2f2050ba20d93a75a1510a6309c009..fcaeca64042097f2ba0f31ce2bda50aaf8b3651e 100644 --- a/infra/relay/src/http/Api.ts +++ b/infra/relay/src/http/Api.ts @@ -545,6 +545,22 @@ export const clientApi = HttpApiBuilder.group( .handle( "listDevices", Effect.fn("relay.api.client.listDevices")(function* () { + yield* appendRelayCredentialResponseHeaders; + const { userId } = yield* RelayClientPrincipal; + const registered = yield* devices.listForUser({ userId }); + return { + devices: registered.flatMap((device) => + device.platform === "ios" && device.iosMajorVersion !== null + ? [{ ...device, platform: "ios" as const, iosMajorVersion: device.iosMajorVersion }] + : [], + ), + }; + }, mapRelayCommonApiErrors("not_authorized")), + ) + .handle( + "listDevicesV2", + Effect.fn("relay.api.client.listDevicesV2")(function* () { + yield* appendRelayCredentialResponseHeaders; const { userId } = yield* RelayClientPrincipal; return { devices: yield* devices.listForUser({ userId }) }; }, mapRelayCommonApiErrors("not_authorized")), @@ -981,6 +997,12 @@ export const serverApi = HttpApiBuilder.group( reason: "upstream_unavailable", traceId, }), + FcmDeliveryError: (_error, traceId) => + new RelayInternalError({ + code: "internal_error", + reason: "upstream_unavailable", + traceId, + }), }), mapRelayCommonApiErrors("not_authorized"), ), diff --git a/infra/relay/src/persistence/schema.ts b/infra/relay/src/persistence/schema.ts index 61b72f2df868c65bc0f9063e7bc53a0c7c7efcef..d9a7074bc01d4a04775dcbe6d1a4ec28d39916e6 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 bfae5ced4b287f1176876aad6c4bf411b475e6ac..0ca0dbd0b4e1979e05d69e6e5878d5179ba85c86 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 2755c416fe09c2514d2d0a52948c887451e5552e..caa5425b083fdbd024231e89c2a6ecb37b72752d 100644 --- a/infra/relay/src/worker.ts +++ b/infra/relay/src/worker.ts @@ -6,6 +6,8 @@ import * as DateTime from "effect/DateTime"; import * as Crypto from "effect/Crypto"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as Redacted from "effect/Redacted"; import * as Stream from "effect/Stream"; import * as Etag from "effect/unstable/http/Etag"; import * as HttpPlatform from "effect/unstable/http/HttpPlatform"; @@ -44,7 +46,18 @@ import * as EnvironmentLinks from "./environments/EnvironmentLinks.ts"; import * as ManagedEndpointAllocations from "./environments/ManagedEndpointAllocations.ts"; import * as LiveActivities from "./agentActivity/LiveActivities.ts"; import * as RelayDb from "./db.ts"; -import { RelayApnsDeliveryDeadLetterQueue, RelayApnsDeliveryQueue } from "./queues.ts"; +import { + RelayApnsDeliveryDeadLetterQueue, + RelayApnsDeliveryQueue, + RelayFcmDeliveryQueue, + RelayFcmDeliveryDeadLetterQueue, +} from "./queues.ts"; +import * as WebCrypto from "./WebCrypto.ts"; +import * as FcmAssertionSigner from "./agentActivity/FcmAssertionSigner.ts"; +import * as FcmClient from "./agentActivity/FcmClient.ts"; +import * as FcmDeliveryQueueSender from "./agentActivity/FcmDeliveryQueueSender.ts"; +import * as FcmDeliveries from "./agentActivity/FcmDeliveries.ts"; +import * as FcmDeliveryQueueConsumer from "./agentActivity/FcmDeliveryQueueConsumer.ts"; import * as RelayConfiguration from "./Config.ts"; import * as AgentActivityPublisher from "./agentActivity/AgentActivityPublisher.ts"; import * as ApnsClient from "./agentActivity/ApnsClient.ts"; @@ -117,6 +130,8 @@ export const ApiLive = Api.make( const { relayPublicOrigin, stage } = yield* RelayDeploymentConfig; const apnsDeliveryQueue = yield* RelayApnsDeliveryQueue; const apnsDeliveryDeadLetterQueue = yield* RelayApnsDeliveryDeadLetterQueue; + const fcmDeliveryQueue = yield* RelayFcmDeliveryQueue; + const fcmDeliveryDeadLetterQueue = yield* RelayFcmDeliveryDeadLetterQueue; const cloudMintKeyPair = yield* CloudMintKeyPair; const relayApiZone = yield* RelayApiZone; const managedEndpointZone = yield* ManagedEndpointZone; @@ -126,16 +141,25 @@ export const ApiLive = Api.make( // // 2. Create bindings // - const environment = yield* Config.schema( - RelayConfiguration.ApnsEnvironment, - "APNS_ENVIRONMENT", + const apnsEnabled = yield* Config.boolean("APNS_ENABLED").pipe(Config.withDefault(true)); + const apnsCredentials = apnsEnabled + ? { + environment: yield* Config.schema(RelayConfiguration.ApnsEnvironment, "APNS_ENVIRONMENT"), + teamId: yield* Config.string("APNS_TEAM_ID"), + keyId: yield* Config.string("APNS_KEY_ID"), + bundleId: yield* Config.string("APNS_BUNDLE_ID"), + privateKey: yield* Config.redacted("APNS_PRIVATE_KEY"), + } + : null; + const fcmServiceAccount = Option.getOrUndefined( + Option.filter( + yield* Config.option(Config.redacted("FCM_SERVICE_ACCOUNT")), + (value) => Redacted.value(value).trim().length > 0, + ), ); - const apnsTeamId = yield* Config.string("APNS_TEAM_ID"); - const apnsKeyId = yield* Config.string("APNS_KEY_ID"); - const apnsBundleId = yield* Config.string("APNS_BUNDLE_ID"); - const apnsPrivateKey = yield* Config.redacted("APNS_PRIVATE_KEY"); const apnsDeliveryJobSigningSecret = yield* randomApnsDeliveryJobSigningSecret; const apnsDeliveryQueueSender = yield* Cloudflare.Queues.WriteQueue(apnsDeliveryQueue); + const fcmDeliveryQueueSender = yield* Cloudflare.Queues.WriteQueue(fcmDeliveryQueue); const axiomDatasetName = yield* observability.traces.name; const axiomIngestToken = yield* observability.workerIngestToken.token; @@ -164,13 +188,8 @@ export const ApiLive = Api.make( const loadSettings = Effect.gen(function* () { return RelayConfiguration.RelayConfiguration.of({ relayIssuer: relayPublicOrigin, - apns: { - environment, - teamId: apnsTeamId, - keyId: apnsKeyId, - bundleId: apnsBundleId, - privateKey: apnsPrivateKey, - }, + ...(fcmServiceAccount ? { fcmServiceAccount } : {}), + apns: apnsCredentials, apnsDeliveryJobSigningSecret: yield* apnsDeliveryJobSigningSecret, clerkSecretKey, clerkPublishableKey, @@ -205,12 +224,31 @@ export const ApiLive = Api.make( ), Layer.provideMerge(DpopProofs.layer), Layer.provideMerge(ApnsDeliveries.layer), + Layer.provideMerge( + FcmDeliveries.layer.pipe( + Layer.provide( + Layer.succeed(FcmDeliveryQueueSender.FcmDeliveryQueueSender, { + send: (body) => + fcmDeliveryQueueSender + .send(body) + .pipe(Effect.provideService(Alchemy.RuntimeContext, alchemyRuntimeContext)), + }), + ), + Layer.provideMerge( + FcmClient.layer.pipe( + Layer.provide(FcmAssertionSigner.layer), + Layer.provide( + Layer.succeed(WebCrypto.WebCrypto, { subtle: globalThis.crypto.subtle }), + ), + ), + ), + ), + ), Layer.provideMerge(ApnsClient.layer.pipe(Layer.provideMerge(ApnsProviderTokens.layer))), Layer.provideMerge( ApnsDeliveryQueue.layerCloudflareQueues(apnsDeliveryQueueSender, alchemyRuntimeContext), ), - Layer.provideMerge(AgentActivityRows.layer), - Layer.provideMerge(Devices.layer), + Layer.provideMerge(Layer.mergeAll(AgentActivityRows.layer, Devices.layer)), Layer.provideMerge(EnvironmentCredentials.layer), Layer.provideMerge( Layer.mergeAll( @@ -260,6 +298,23 @@ export const ApiLive = Api.make( ), ); + yield* Cloudflare.Queues.consumeQueueMessages( + fcmDeliveryQueue, + { + batchSize: 10, + maxRetries: 5, + maxWaitTime: "1 second", + retryDelay: "30 seconds", + deadLetterQueue: fcmDeliveryDeadLetterQueue.queueName as unknown as string, + }, + (stream) => + stream.pipe( + Stream.withSpan("relay.fcm_delivery_queue.process_batch"), + Stream.runForEach(FcmDeliveryQueueConsumer.processMessage), + Effect.provide(runtimeLayer), + ), + ); + yield* Cloudflare.Workers.cron("*/5 * * * *", () => DpopProofs.DpopProofReplay.pipe( Effect.flatMap((dpopProofs) => dpopProofs.pruneExpired), diff --git a/packages/client-runtime/src/relay/managedRelay.test.ts b/packages/client-runtime/src/relay/managedRelay.test.ts index 2699c4ad0803aa6bb2dd5578755c59d4e461d4ab..b66f91b00961aea8293d6654a01d12c3106f2222 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 3229549b0867abff18d366aea6de49176cfe8fe2..eadece417681263a9e595dc8a942f0cc783cab0f 100644 --- a/packages/client-runtime/src/relay/managedRelay.ts +++ b/packages/client-runtime/src/relay/managedRelay.ts @@ -729,7 +729,7 @@ export const make = Effect.fn("ManagedRelayClient.make")(function* ( listDevices: Effect.fnUntraced( function* (input) { return yield* client.client - .listDevices({ + .listDevicesV2({ headers: bearerHeaders(input.clerkToken), }) .pipe( diff --git a/packages/contracts/src/relay.test.ts b/packages/contracts/src/relay.test.ts index 4ad600953b9eafa7ba3808ac694622e3313a6668..441fecd33268ed0ef33b5e4a6a4a879153e1a210 100644 --- a/packages/contracts/src/relay.test.ts +++ b/packages/contracts/src/relay.test.ts @@ -1,7 +1,52 @@ import { describe, expect, it } from "vite-plus/test"; import * as OpenApi from "effect/unstable/httpapi/OpenApi"; +import * as Schema from "effect/Schema"; -import { RelayApi } from "./relay.ts"; +import { RelayApi, RelayDeviceRegistrationRequest } from "./relay.ts"; + +const decodeDevice = Schema.decodeUnknownExit(RelayDeviceRegistrationRequest); +const device = { + deviceId: "device", + label: "Phone", + pushToken: "token", + preferences: { + notificationsEnabled: true, + liveActivitiesEnabled: true, + notifyOnApproval: true, + notifyOnInput: true, + notifyOnCompletion: true, + notifyOnFailure: true, + }, +}; + +describe("mobile device platforms", () => { + it.each([ + [23, "Failure"], + [24, "Success"], + [37, "Success"], + ])("enforces the Android minimum without an upper bound (API %i)", (androidApiLevel, result) => { + expect(decodeDevice({ ...device, platform: "android", androidApiLevel })._tag).toBe(result); + }); + + it("accepts Android tokens without Apple routing and preserves older iOS registrations", () => { + expect(decodeDevice({ ...device, platform: "android", androidApiLevel: 36 })._tag).toBe( + "Success", + ); + expect(decodeDevice({ ...device, platform: "ios", iosMajorVersion: 18 })._tag).toBe("Success"); + }); + it("rejects missing platform versions and Apple activity tokens on Android", () => { + expect(decodeDevice({ ...device, platform: "ios" })._tag).toBe("Failure"); + expect(decodeDevice({ ...device, platform: "android" })._tag).toBe("Failure"); + expect( + decodeDevice({ + ...device, + platform: "android", + androidApiLevel: 36, + pushToStartToken: "apple-token", + })._tag, + ).toBe("Failure"); + }); +}); describe("RelayApi security", () => { it("describes DPoP access tokens using the HTTP DPoP authorization scheme", () => { diff --git a/packages/contracts/src/relay.ts b/packages/contracts/src/relay.ts index 7b1fbb73255b6389932e241e7afe61a5eeff02d2..8b6562497681a254bbc8167e396337431006c949 100644 --- a/packages/contracts/src/relay.ts +++ b/packages/contracts/src/relay.ts @@ -16,7 +16,7 @@ import { } from "./baseSchemas.ts"; import { ExecutionEnvironmentDescriptor } from "./environment.ts"; -export const RelayAgentAwarenessPlatform = Schema.Literal("ios"); +export const RelayAgentAwarenessPlatform = Schema.Literals(["ios", "android"]); export type RelayAgentAwarenessPlatform = typeof RelayAgentAwarenessPlatform.Type; export const RelayAgentAwarenessPhase = Schema.Literals([ @@ -47,7 +47,8 @@ export const RelayDeviceRegistrationRequest = Schema.Struct({ deviceId: TrimmedNonEmptyString, label: TrimmedNonEmptyString, platform: RelayAgentAwarenessPlatform, - iosMajorVersion: Schema.Int.check(Schema.isGreaterThanOrEqualTo(18)), + iosMajorVersion: Schema.optional(Schema.Int.check(Schema.isGreaterThanOrEqualTo(18))), + androidApiLevel: Schema.optional(Schema.Int.check(Schema.isGreaterThanOrEqualTo(24))), appVersion: Schema.optional(TrimmedNonEmptyString), // APNs routing for this install: the topic must match the app's bundle id // (dev/preview/prod variants differ) and development-signed builds receive @@ -58,14 +59,24 @@ export const RelayDeviceRegistrationRequest = Schema.Struct({ pushToken: Schema.optional(TrimmedNonEmptyString), pushToStartToken: Schema.optional(TrimmedNonEmptyString), preferences: RelayAgentAwarenessPreferences, -}); +}).check( + Schema.makeFilter((device) => + device.platform === "ios" + ? device.iosMajorVersion !== undefined + : device.androidApiLevel !== undefined && + device.iosMajorVersion === undefined && + device.apsEnvironment === undefined && + device.pushToStartToken === undefined, + ), +); export type RelayDeviceRegistrationRequest = typeof RelayDeviceRegistrationRequest.Type; export const RelayClientDeviceRecord = Schema.Struct({ deviceId: TrimmedNonEmptyString, label: TrimmedNonEmptyString, platform: RelayAgentAwarenessPlatform, - iosMajorVersion: Schema.Int.check(Schema.isGreaterThanOrEqualTo(18)), + iosMajorVersion: Schema.NullOr(Schema.Int.check(Schema.isGreaterThanOrEqualTo(18))), + androidApiLevel: Schema.optional(Schema.NullOr(Schema.Int)), appVersion: Schema.NullOr(TrimmedNonEmptyString), notifications: Schema.Struct({ enabled: Schema.Boolean, @@ -86,6 +97,17 @@ export const RelayListDevicesResponse = Schema.Struct({ }); export type RelayListDevicesResponse = typeof RelayListDevicesResponse.Type; +// Installed clients decode v1 as iOS-only. Keep that response contract frozen. +export const RelayListDevicesResponseV1 = Schema.Struct({ + devices: Schema.Array( + Schema.Struct({ + ...RelayClientDeviceRecord.fields, + platform: Schema.Literal("ios"), + iosMajorVersion: Schema.Int.check(Schema.isGreaterThanOrEqualTo(18)), + }), + ), +}); + export const RelayLiveActivityRegistrationRequest = Schema.Struct({ deviceId: TrimmedNonEmptyString, activityPushToken: TrimmedNonEmptyString, @@ -964,6 +986,11 @@ const RelayClientGroup = HttpApiGroup.make("client") error: RelayAuthAndInternalErrors, }).annotate(OpenApi.Summary, "List linked environments"), HttpApiEndpoint.get("listDevices", "/v1/client/devices", { + headers: RelayBearerRequestHeaders, + success: RelayListDevicesResponseV1, + error: RelayAuthAndInternalErrors, + }).annotate(OpenApi.Summary, "List registered iOS devices (legacy clients)"), + HttpApiEndpoint.get("listDevicesV2", "/v2/client/devices", { headers: RelayBearerRequestHeaders, success: RelayListDevicesResponse, error: RelayAuthAndInternalErrors, -- 2.54.0