authorgravatar for ryan@heyoodle.comRyan Hughes <ryan@heyoodle.com> 2026-09-09 00:54:28-04:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-08 21:54:28-07:00
log6c583620ff7ad3235b135af7107c0543467eecfa
tree645f4345d5b44a0fcbd55163f868ba64bababfb9
parent2a303535305d7d224a750fdee78c66e2a52ab3ab
signature Signed by PGP key B5690EEEBB952194

feat(mobile): add Android agent notifications and ongoing activity (#10416)

Co-authored-by: Julius Marminge <51714798+juliusmarminge@users.noreply.github.com>

58 files changed, 5761 insertions(+), 142 deletions(-)

.github/workflows/deploy-relay.yml+1
...@@ -63,6 +63,7 @@ jobs:...@@ -63,6 +63,7 @@ jobs:
63 AXIOM_TOKEN: ${{ secrets.AXIOM_TOKEN }}63 AXIOM_TOKEN: ${{ secrets.AXIOM_TOKEN }}
64 CLERK_SECRET_KEY: ${{ secrets.CLERK_SECRET_KEY }}64 CLERK_SECRET_KEY: ${{ secrets.CLERK_SECRET_KEY }}
65 APNS_PRIVATE_KEY: ${{ secrets.APNS_PRIVATE_KEY }}65 APNS_PRIVATE_KEY: ${{ secrets.APNS_PRIVATE_KEY }}
66 FCM_SERVICE_ACCOUNT: ${{ secrets.FCM_SERVICE_ACCOUNT }}
6667
67 - name: Publish relay deploy commit status68 - name: Publish relay deploy commit status
68 uses: actions/github-script@v869 uses: actions/github-script@v8
apps/mobile/app.config.ts+8-1
...@@ -186,7 +186,7 @@ const config: ExpoConfig = {...@@ -186,7 +186,7 @@ const config: ExpoConfig = {
186 icon: variant.assets.appIcon,186 icon: variant.assets.appIcon,
187 userInterfaceStyle: "automatic",187 userInterfaceStyle: "automatic",
188 updates: {188 updates: {
189 enabled: true,189 enabled: repoEnv.T3CODE_MOBILE_UPDATES_ENABLED !== "0",
190 url: "https://u.expo.dev/d763fcb8-d37c-41ea-a773-b54a0ab4a454",190 url: "https://u.expo.dev/d763fcb8-d37c-41ea-a773-b54a0ab4a454",
191 checkAutomatically: "ON_LOAD",191 checkAutomatically: "ON_LOAD",
192 fallbackToCacheTimeout: 0,192 fallbackToCacheTimeout: 0,
...@@ -237,6 +237,9 @@ const config: ExpoConfig = {...@@ -237,6 +237,9 @@ const config: ExpoConfig = {
237 android: {237 android: {
238 icon: variant.assets.appIcon,238 icon: variant.assets.appIcon,
239 package: variant.androidPackage,239 package: variant.androidPackage,
240 ...(repoEnv.T3CODE_ANDROID_GOOGLE_SERVICES_FILE
241 ? { googleServicesFile: repoEnv.T3CODE_ANDROID_GOOGLE_SERVICES_FILE }
242 : {}),
240 adaptiveIcon: {243 adaptiveIcon: {
241 backgroundColor: variant.assets.androidAdaptiveBackgroundColor,244 backgroundColor: variant.assets.androidAdaptiveBackgroundColor,
242 ...(variant.assets.androidAdaptiveBackgroundImage245 ...(variant.assets.androidAdaptiveBackgroundImage
...@@ -356,6 +359,10 @@ const config: ExpoConfig = {...@@ -356,6 +359,10 @@ const config: ExpoConfig = {
356 [359 [
357 "expo-build-properties",360 "expo-build-properties",
358 {361 {
362 android: {
363 // Keep the supported floor explicit and covered by native notification tests.
364 minSdkVersion: 24,
365 },
359 ios: {366 ios: {
360 deploymentTarget: "18.0",367 deploymentTarget: "18.0",
361 // AppCheckCore 11.3+ includes Swift and needs module maps for these Objective-C dependencies.368 // AppCheckCore 11.3+ includes Swift and needs module maps for these Objective-C dependencies.
apps/mobile/modules/t3-agent-notifications/android/build.gradle created+34
...@@ -0,0 +1,34 @@
1plugins {
2 id 'com.android.library'
3 id 'expo-module-gradle-plugin'
4}
5
6group = 'com.t3tools.agentnotifications'
7version = '0.0.0'
8
9android {
10 namespace 'expo.modules.t3agentnotifications'
11 defaultConfig {
12 versionCode 1
13 versionName '0.0.0'
14 }
15 testOptions {
16 unitTests.includeAndroidResources = true
17 }
18}
19
20dependencies {
21 implementation project(':expo-notifications')
22 implementation 'com.google.firebase:firebase-messaging:25.0.1'
23 implementation 'androidx.core:core:1.17.0'
24 implementation 'androidx.lifecycle:lifecycle-process:2.9.3'
25 testImplementation 'junit:junit:4.13.2'
26 testImplementation 'org.robolectric:robolectric:4.16.1'
27}
28
29// Expo compiles modules with Java 17; Robolectric's Android 16 runtime needs 21.
30tasks.withType(Test).configureEach {
31 javaLauncher = javaToolchains.launcherFor {
32 languageVersion = JavaLanguageVersion.of(21)
33 }
34}
apps/mobile/modules/t3-agent-notifications/android/src/main/AndroidManifest.xml created+14
...@@ -0,0 +1,14 @@
1<manifest xmlns:android="http://schemas.android.com/apk/res/android" xmlns:tools="http://schemas.android.com/tools">
2 <uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
3 <uses-permission android:name="android.permission.POST_PROMOTED_NOTIFICATIONS" />
4 <application>
5 <service android:name="expo.modules.notifications.service.ExpoFirebaseMessagingService" tools:node="remove" />
6 <service android:name=".AgentMessagingService" android:exported="false">
7 <intent-filter>
8 <action android:name="com.google.firebase.MESSAGING_EVENT" />
9 </intent-filter>
10 </service>
11 <receiver android:name=".AgentActivityDismissReceiver" android:exported="false" />
12 <receiver android:name=".AgentActivityExpiryReceiver" android:exported="false" />
13 </application>
14</manifest>
apps/mobile/modules/t3-agent-notifications/android/src/main/java/expo/modules/t3agentnotifications/AgentNotifications.kt created+336
...@@ -0,0 +1,336 @@
1package expo.modules.t3agentnotifications
2
3import android.app.NotificationChannel
4import android.app.Notification
5import android.app.AlarmManager
6import android.app.NotificationManager
7import android.app.PendingIntent
8import android.content.BroadcastReceiver
9import android.content.Context
10import android.content.Intent
11import android.content.SharedPreferences
12import android.net.Uri
13import android.os.Build
14import android.text.TextPaint
15import android.text.TextUtils
16import androidx.core.app.NotificationCompat
17import androidx.core.app.NotificationManagerCompat
18import androidx.lifecycle.Lifecycle
19import androidx.lifecycle.ProcessLifecycleOwner
20import com.google.firebase.messaging.RemoteMessage
21import expo.modules.notifications.service.ExpoFirebaseMessagingService
22
23class AgentMessagingService : ExpoFirebaseMessagingService() {
24 override fun onMessageReceived(remoteMessage: RemoteMessage) {
25 if (remoteMessage.data["t3_kind"] == "agent_activity") {
26 AgentNotifications.receive(this, remoteMessage.data)
27 } else {
28 super.onMessageReceived(remoteMessage)
29 }
30 }
31}
32
33class AgentActivityDismissReceiver : BroadcastReceiver() {
34 override fun onReceive(context: Context, intent: Intent) {
35 AgentNotifications.dismiss(context)
36 }
37}
38
39class AgentActivityExpiryReceiver : BroadcastReceiver() {
40 override fun onReceive(context: Context, intent: Intent) {
41 AgentNotifications.expire(context)
42 }
43}
44
45/** Handles data pushes natively so delivery does not depend on a running JS bridge. */
46object AgentNotifications {
47 private const val STORE = "t3-agent-notifications"
48 private const val ACTIVITY_CHANNEL = "agent-activity"
49 private const val ALERT_CHANNEL = "agent-alerts"
50 private const val ACTIVITY_TAG = "t3-agent-activity"
51 private const val ALERT_TAG = "t3-agent-alert"
52 private const val ACTIVITY_ID = 73001
53 private const val MAX_MESSAGE_AGE_MS = 10 * 60 * 1000L
54 private const val RUNNING_LIFETIME_MS = 2 * 60 * 60 * 1000L
55 private const val MAX_LIFETIME_MS = 24 * 60 * 60 * 1000L
56
57 @Synchronized
58 fun configure(
59 context: Context,
60 deviceId: String,
61 userId: String,
62 scheme: String,
63 ongoingEnabled: Boolean
64 ) {
65 val prefs = context.getSharedPreferences(STORE, Context.MODE_PRIVATE)
66 // JS identity is empty on a cold start. Compare the durable identity here
67 // so reopening preserves cards, dismissal and replay history for this user.
68 if (prefs.getString("userId", null) != userId ||
69 prefs.getString("deviceId", null) != deviceId
70 ) {
71 clear(context)
72 }
73 val wasEnabled = prefs.getBoolean("ongoing", false)
74 prefs.edit().putString(
75 "deviceId",
76 deviceId
77 ).putString("userId", userId).putString("scheme", scheme)
78 .putBoolean("enabled", true).putBoolean("ongoing", ongoingEnabled).apply()
79 if (ongoingEnabled && !wasEnabled) prefs.edit().putBoolean("dismissed", false).apply()
80 if (!ongoingEnabled) cancelActivity(context)
81 channels(context)
82 }
83
84 @Synchronized
85 fun clear(context: Context) {
86 cancelActivity(context)
87 context.getSharedPreferences(STORE, Context.MODE_PRIVATE).edit().clear().apply()
88 val manager = manager(context)
89 manager.activeNotifications.filter { it.tag == ACTIVITY_TAG || it.tag == ALERT_TAG }
90 .forEach { manager.cancel(it.tag, it.id) }
91 }
92
93 @Synchronized
94 fun dismiss(context: Context) {
95 context.getSharedPreferences(
96 STORE,
97 Context.MODE_PRIVATE
98 ).edit().putBoolean("dismissed", true).apply()
99 cancelActivity(context)
100 }
101
102 @Synchronized
103 fun expire(context: Context, now: Long = System.currentTimeMillis()) {
104 val expiresAt = context.getSharedPreferences(
105 STORE,
106 Context.MODE_PRIVATE
107 ).getLong("expiresAt", 0)
108 // An already-dispatched alarm must not remove a newer run's card.
109 if (expiresAt > 0 && expiresAt <= now) cancelActivity(context)
110 }
111
112 @Synchronized
113 fun receive(context: Context, data: Map<String, String>) {
114 val prefs = context.getSharedPreferences(STORE, Context.MODE_PRIVATE)
115 val updatedAt = data["updated_at"]?.toLongOrNull() ?: return
116 val registered = prefs.getBoolean("enabled", false) &&
117 data["device_id"] == prefs.getString("deviceId", null) &&
118 data["user_id"] == prefs.getString("userId", null)
119 val fresh = System.currentTimeMillis() - updatedAt in -MAX_MESSAGE_AGE_MS..MAX_MESSAGE_AGE_MS
120 if (registered && fresh && NotificationManagerCompat.from(context).areNotificationsEnabled()) {
121 channels(context)
122 val scheme = prefs.getString("scheme", "t3code") ?: "t3code"
123 showAlert(context, prefs, scheme, data)
124 updateActivity(context, prefs, scheme, data, updatedAt)
125 }
126 }
127
128 private fun showAlert(
129 context: Context,
130 prefs: SharedPreferences,
131 scheme: String,
132 data: Map<String, String>
133 ) {
134 // Queue retries carry the same alert id. Keep a bounded history even when
135 // notification A is retried after notification B has already arrived.
136 val alertId = data["alert_id"]
137 val seen = prefs.getString("seenAlertsOrdered", null)?.split('\n')
138 ?: prefs.getStringSet("seenAlerts", emptySet()).orEmpty().toList()
139 if (alertId != null && alertId !in seen) {
140 // Match iOS foreground presentation. Consume suppressed alerts as well,
141 // so a delivery retry cannot surface them after the app backgrounds.
142 if (!ProcessLifecycleOwner.get().lifecycle.currentState.isAtLeast(Lifecycle.State.RESUMED)) {
143 val title = data["alert_title"].orEmpty().take(120)
144 // Grouped alerts list up to five 120-character thread titles.
145 val body = data["alert_body"].orEmpty().take(608)
146 val id = alertId.hashCode()
147 val notification = base(context, ALERT_CHANNEL)
148 .setContentTitle(title).setContentText(body)
149 .setStyle(NotificationCompat.BigTextStyle().bigText(body))
150 .setAutoCancel(true)
151 .setContentIntent(contentIntent(context, scheme, data["alert_path"], id))
152 .build()
153 manager(context).notify(ALERT_TAG, id, notification)
154 }
155 prefs.edit().remove("seenAlerts").putString(
156 "seenAlertsOrdered",
157 (seen.takeLast(63) + alertId).joinToString("\n")
158 ).apply()
159 }
160 }
161
162 private fun updateActivity(
163 context: Context,
164 prefs: SharedPreferences,
165 scheme: String,
166 data: Map<String, String>,
167 updatedAt: Long
168 ) {
169 // Ignore reordered status updates without dropping an unrelated alert.
170 if (updatedAt < prefs.getLong("lastUpdate", 0)) return
171 prefs.edit().putLong("lastUpdate", updatedAt).apply()
172 val active = data["active"] == "true"
173 // Use absolute state expiry: a replay must not extend a finished card or
174 // make an abandoned host look active indefinitely. Older relays omit it.
175 val expiresAt = data["activity_expires_at"]?.toLongOrNull()
176 ?: if (active) updatedAt + RUNNING_LIFETIME_MS else 0L
177 val remainingMs = (expiresAt - System.currentTimeMillis()).coerceAtMost(MAX_LIFETIME_MS)
178 val wasActive = prefs.getBoolean("lastActive", false)
179 prefs.edit().putBoolean("lastActive", active).apply()
180 if (remainingMs <= 0 || !prefs.getBoolean("ongoing", false)) {
181 cancelActivity(context)
182 prefs.edit().putBoolean("dismissed", false).apply()
183 return
184 }
185 // Dismissing a run includes its finished card. A new run, or toggling
186 // activity off/on, arms it again; terminal replays stay dismissed.
187 if (active && !wasActive) prefs.edit().putBoolean("dismissed", false).apply()
188 if (!prefs.getBoolean("dismissed", false)) {
189 showActivity(context, scheme, data, active, remainingMs)
190 }
191 }
192
193 private fun showActivity(
194 context: Context,
195 scheme: String,
196 data: Map<String, String>,
197 active: Boolean,
198 remainingMs: Long
199 ) {
200 val body = data["activity_body"].orEmpty().take(240)
201 val dismissIntent = PendingIntent.getBroadcast(
202 context,
203 ACTIVITY_ID,
204 Intent(context, AgentActivityDismissReceiver::class.java),
205 PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
206 )
207 val lines = (0..4).mapNotNull {
208 data["activity_line_$it"]?.let { line -> activityLine(context, line) }
209 }
210 // BigTextStyle remains eligible for Android Live Update promotion.
211 val style = NotificationCompat.BigTextStyle().bigText(
212 if (lines.isEmpty()) body else lines.joinToString("\n")
213 )
214 val notification = base(context, ACTIVITY_CHANNEL)
215 .setContentTitle(data["activity_title"].orEmpty().take(120))
216 .setContentText(body)
217 .setStyle(style)
218 .setOngoing(active).setOnlyAlertOnce(true).setSilent(true)
219 .setTimeoutAfter(remainingMs)
220 // Live Updates must remain uncolorized to qualify for promotion.
221 .setColorized(false)
222 .setRequestPromotedOngoing(active)
223 .setContentIntent(contentIntent(context, scheme, data["activity_path"], ACTIVITY_ID))
224 .setDeleteIntent(dismissIntent)
225 .addAction(0, "Dismiss", dismissIntent)
226 .build()
227 manager(context).notify(ACTIVITY_TAG, ACTIVITY_ID, notification)
228 if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) {
229 // Notification timeouts were added in API 26. One inexact alarm also
230 // expires cards on Android 7, including when the app process has exited.
231 // No exact-alarm permission, foreground service or periodic work needed.
232 val expiresAt = System.currentTimeMillis() + remainingMs
233 context.getSharedPreferences(STORE, Context.MODE_PRIVATE).edit()
234 .putLong("expiresAt", expiresAt).apply()
235 context.getSystemService(
236 AlarmManager::class.java
237 ).setAndAllowWhileIdle(AlarmManager.RTC_WAKEUP, expiresAt, expiryIntent(context))
238 }
239 }
240
241 private fun expiryIntent(context: Context): PendingIntent = PendingIntent.getBroadcast(
242 context,
243 ACTIVITY_ID,
244 Intent(context, AgentActivityExpiryReceiver::class.java),
245 PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
246 )
247
248 private fun cancelActivity(context: Context) {
249 manager(context).cancel(ACTIVITY_TAG, ACTIVITY_ID)
250 if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) {
251 context.getSystemService(AlarmManager::class.java).cancel(expiryIntent(context))
252 context.getSharedPreferences(STORE, Context.MODE_PRIVATE).edit().remove("expiresAt").apply()
253 }
254 }
255
256 private fun activityLine(context: Context, value: String): String {
257 val parts = value.split('\t', limit = 3)
258 if (parts.size != 3) return value.take(300)
259 val metrics = context.resources.displayMetrics
260 val paint = TextPaint().apply { textSize = 14 * metrics.scaledDensity }
261 val prefix = "${parts[0]}: "
262 val separator = " · "
263 // Reserve the system notification's icon and margins. Fit the two titles
264 // independently so large fonts/long names never hide the project or status.
265 // The shade uses a narrow column even when a headless service sees a
266 // foldable's wider display metrics. Keep rows inside that column too.
267 val width = (metrics.widthPixels - 152 * metrics.density)
268 .coerceIn(120 * metrics.density, 280 * metrics.density)
269 val available = (width - paint.measureText(prefix + separator)).coerceAtLeast(0f)
270 val projectWidth = paint.measureText(parts[2]).coerceAtMost(available * 0.4f)
271 val titleWidth = paint.measureText(parts[1]).coerceAtMost(available - projectWidth)
272 val title = TextUtils.ellipsize(parts[1], paint, titleWidth, TextUtils.TruncateAt.END)
273 val project = TextUtils.ellipsize(
274 parts[2],
275 paint,
276 available - titleWidth,
277 TextUtils.TruncateAt.END
278 )
279 return "$prefix$title$separator$project"
280 }
281
282 private fun manager(context: Context) = context.getSystemService(NotificationManager::class.java)
283
284 private fun channels(context: Context) {
285 if (Build.VERSION.SDK_INT >= 26) {
286 manager(context).createNotificationChannels(
287 listOf(
288 NotificationChannel(ALERT_CHANNEL, "Agent alerts", NotificationManager.IMPORTANCE_HIGH),
289 NotificationChannel(
290 ACTIVITY_CHANNEL,
291 "Ongoing agent activity",
292 NotificationManager.IMPORTANCE_LOW
293 ),
294 )
295 )
296 }
297 }
298
299 private fun base(context: Context, channel: String): NotificationCompat.Builder {
300 val icon = context.resources.getIdentifier("notification_icon", "drawable", context.packageName)
301 return NotificationCompat.Builder(context, channel)
302 .setPriority(
303 if (channel ==
304 ALERT_CHANNEL
305 ) {
306 NotificationCompat.PRIORITY_HIGH
307 } else {
308 NotificationCompat.PRIORITY_LOW
309 }
310 )
311 .setDefaults(if (channel == ALERT_CHANNEL) Notification.DEFAULT_ALL else 0)
312 .setSmallIcon(if (icon != 0) icon else android.R.drawable.ic_dialog_info)
313 .setVisibility(NotificationCompat.VISIBILITY_PRIVATE)
314 .setShowWhen(false)
315 }
316
317 private fun contentIntent(
318 context: Context,
319 scheme: String,
320 path: String?,
321 id: Int
322 ): PendingIntent? {
323 val threadPath = path?.takeIf { it.startsWith("/threads/") }
324 val route = threadPath?.takeUnless { it.contains('?') || it.contains('#') } ?: "/"
325 val intent = context.packageManager.getLaunchIntentForPackage(context.packageName)
326 ?: return null
327 intent.setAction(Intent.ACTION_VIEW).setData(Uri.parse("$scheme:/$route"))
328 .addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_SINGLE_TOP)
329 return PendingIntent.getActivity(
330 context,
331 id,
332 intent,
333 PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
334 )
335 }
336}
apps/mobile/modules/t3-agent-notifications/android/src/main/java/expo/modules/t3agentnotifications/T3AgentNotificationsModule.kt created+25
...@@ -0,0 +1,25 @@
1package expo.modules.t3agentnotifications
2
3import expo.modules.kotlin.modules.Module
4import expo.modules.kotlin.modules.ModuleDefinition
5
6class T3AgentNotificationsModule : Module() {
7 override fun definition() = ModuleDefinition {
8 Name("T3AgentNotifications")
9
10 Function("configure") {
11 deviceId: String,
12 userId: String,
13 scheme: String,
14 ongoingEnabled: Boolean
15 ->
16 appContext.reactContext?.let {
17 AgentNotifications.configure(it, deviceId, userId, scheme, ongoingEnabled)
18 }
19 }
20
21 Function("clear") {
22 appContext.reactContext?.let { AgentNotifications.clear(it) }
23 }
24 }
25}
apps/mobile/modules/t3-agent-notifications/android/src/test/java/expo/modules/t3agentnotifications/AgentNotificationsTest.kt created+430
...@@ -0,0 +1,430 @@
1package expo.modules.t3agentnotifications
2
3import android.app.Activity
4import android.app.AlarmManager
5import android.app.Application
6import android.app.Notification
7import android.app.NotificationManager
8import android.content.ComponentName
9import android.content.Intent
10import android.content.IntentFilter
11import android.os.Build
12import androidx.core.app.NotificationCompat
13import androidx.lifecycle.Lifecycle
14import androidx.lifecycle.LifecycleRegistry
15import androidx.lifecycle.ProcessLifecycleOwner
16import org.junit.Assert.assertEquals
17import org.junit.Assert.assertFalse
18import org.junit.Assert.assertTrue
19import org.junit.Before
20import org.junit.Test
21import org.junit.runner.RunWith
22import org.robolectric.RobolectricTestRunner
23import org.robolectric.RuntimeEnvironment
24import org.robolectric.Shadows.shadowOf
25import org.robolectric.annotation.Config
26
27@RunWith(RobolectricTestRunner::class)
28@Config(sdk = [24, 26, 33, 36], manifest = Config.NONE)
29class AgentNotificationsTest {
30 private lateinit var context: Application
31 private lateinit var manager: NotificationManager
32 private lateinit var lifecycle: LifecycleRegistry
33
34 @Before
35 fun setUp() {
36 context = RuntimeEnvironment.getApplication()
37 manager = context.getSystemService(NotificationManager::class.java)
38 shadowOf(manager).setNotificationsEnabled(true)
39 lifecycle = ProcessLifecycleOwner.get().lifecycle as LifecycleRegistry
40 lifecycle.currentState = Lifecycle.State.CREATED
41
42 val launcher = ComponentName(context, Activity::class.java)
43 shadowOf(context.packageManager).addActivityIfNotPresent(launcher)
44 shadowOf(context.packageManager).addIntentFilterForActivity(
45 launcher,
46 IntentFilter(Intent.ACTION_MAIN).apply {
47 addCategory(Intent.CATEGORY_LAUNCHER)
48 }
49 )
50 AgentNotifications.clear(context)
51 AgentNotifications.configure(context, "device", "user", "t3code-dev", true)
52 }
53
54 private fun update(alertId: String, active: Boolean) = mapOf(
55 "device_id" to "device",
56 "user_id" to "user",
57 "updated_at" to System.currentTimeMillis().toString(),
58 "active" to active.toString(),
59 "activity_title" to "1 active agent",
60 "activity_body" to "Test thread · Working",
61 "activity_path" to "/threads/environment/thread",
62 "alert_id" to alertId,
63 "alert_title" to "Test thread",
64 "alert_body" to "Done: Test project",
65 "alert_path" to "/threads/environment/thread",
66 )
67
68 @Test
69 fun alertHistoryEvictsOnlyTheOldestEntryAfterCapacity() {
70 lifecycle.currentState = Lifecycle.State.RESUMED
71 for (id in 0..64) AgentNotifications.receive(context, update("alert-$id", false))
72 lifecycle.currentState = Lifecycle.State.CREATED
73 for (id in 1..64) AgentNotifications.receive(context, update("alert-$id", false))
74 assertTrue(manager.activeNotifications.isEmpty())
75 AgentNotifications.receive(context, update("alert-0", false))
76 assertEquals("alert-0".hashCode(), manager.activeNotifications.single().id)
77 }
78
79 @Test
80 fun missingLauncherDoesNotDiscardTheAlert() {
81 shadowOf(context.packageManager).removeActivity(ComponentName(context, Activity::class.java))
82 AgentNotifications.receive(context, update("no-launcher", false))
83 assertEquals(
84 "Test thread",
85 manager.activeNotifications.single().notification.extras.getString(Notification.EXTRA_TITLE)
86 )
87 }
88
89 @Test
90 fun foregroundSuppressesAlertsWhileOngoingActivityStillUpdatesAndClears() {
91 lifecycle.currentState = Lifecycle.State.RESUMED
92
93 AgentNotifications.receive(context, update("attention", true))
94
95 val ongoing = manager.activeNotifications.single()
96 assertEquals("t3-agent-activity", ongoing.tag)
97 assertEquals("1 active agent", ongoing.notification.extras.getString(Notification.EXTRA_TITLE))
98 assertTrue(ongoing.notification.flags and Notification.FLAG_ONGOING_EVENT != 0)
99
100 AgentNotifications.receive(context, update("completion", false))
101
102 assertTrue(manager.activeNotifications.isEmpty())
103 }
104
105 @Test
106 fun backgroundCompletionAlertsAndClearsOngoingActivity() {
107 lifecycle.currentState = Lifecycle.State.RESUMED
108 AgentNotifications.receive(context, update("running", true))
109 lifecycle.currentState = Lifecycle.State.CREATED
110
111 AgentNotifications.receive(context, update("completion", false))
112
113 val alert = manager.activeNotifications.single()
114 assertEquals("t3-agent-alert", alert.tag)
115 assertEquals("Test thread", alert.notification.extras.getString(Notification.EXTRA_TITLE))
116 assertFalse(alert.notification.flags and Notification.FLAG_ONGOING_EVENT != 0)
117 }
118
119 @Test
120 fun retryOfForegroundSuppressedAlertDoesNotAppearAfterBackgrounding() {
121 lifecycle.currentState = Lifecycle.State.RESUMED
122 val suppressed = update("foreground-completion", false)
123 AgentNotifications.receive(context, suppressed)
124 lifecycle.currentState = Lifecycle.State.CREATED
125
126 AgentNotifications.receive(context, suppressed)
127
128 assertTrue(manager.activeNotifications.isEmpty())
129
130 AgentNotifications.receive(context, update("later-background-completion", false))
131
132 assertEquals("later-background-completion".hashCode(), manager.activeNotifications.single().id)
133 }
134
135 @Test
136 fun returningToForegroundSuppressesNewAlertsWithoutRemovingPreviousOnes() {
137 AgentNotifications.receive(context, update("background-completion", false))
138 lifecycle.currentState = Lifecycle.State.RESUMED
139
140 AgentNotifications.receive(context, update("foreground-completion", false))
141
142 assertEquals("background-completion".hashCode(), manager.activeNotifications.single().id)
143 }
144
145 @Test
146 fun groupedAlertDisplaysEveryThreadAndRetriesStaySilent() {
147 val titles = (1..5).map { "Thread $it " + "x".repeat(111) }.joinToString(", ")
148 val grouped = update("group-completion", false) + mapOf(
149 "alert_title" to "5 agents finished",
150 "alert_body" to titles,
151 "alert_path" to "/",
152 )
153
154 AgentNotifications.receive(context, grouped)
155 AgentNotifications.receive(
156 context,
157 grouped + ("alert_body" to "A retry must not replace this alert")
158 )
159
160 val alert = manager.activeNotifications.single()
161 assertEquals("5 agents finished", alert.notification.extras.getString(Notification.EXTRA_TITLE))
162 assertEquals(titles, alert.notification.extras.getString(Notification.EXTRA_BIG_TEXT))
163 assertEquals("t3code-dev://", shadowOf(alert.notification.contentIntent).savedIntent.dataString)
164 }
165
166 @Test
167 fun foregroundSuppressedGroupCannotAppearOnBackgroundRetry() {
168 val grouped = update("group-attention", true) + mapOf(
169 "alert_title" to "2 agents need attention",
170 "alert_body" to "First thread, Second thread",
171 "alert_path" to "/",
172 )
173 lifecycle.currentState = Lifecycle.State.RESUMED
174 AgentNotifications.receive(context, grouped)
175 lifecycle.currentState = Lifecycle.State.CREATED
176 AgentNotifications.receive(context, grouped)
177
178 assertEquals("t3-agent-activity", manager.activeNotifications.single().tag)
179 }
180
181 @Test
182 fun reopeningSameAccountPreservesCardsDeduplicationAndDismissal() {
183 val message = update("attention", true)
184 AgentNotifications.receive(context, message)
185 AgentNotifications.configure(context, "device", "user", "t3code-dev", true)
186 assertEquals(2, manager.activeNotifications.size)
187 AgentNotifications.dismiss(context)
188 AgentNotifications.configure(context, "device", "user", "t3code-dev", true)
189 AgentNotifications.receive(context, message)
190 assertEquals("t3-agent-alert", manager.activeNotifications.single().tag)
191 }
192
193 @Test
194 fun changingAccountOrDeviceClearsOldCardsAndRejectsOldPushes() {
195 AgentNotifications.receive(context, update("attention", true))
196 AgentNotifications.configure(context, "device", "different-user", "t3code-dev", true)
197 AgentNotifications.receive(context, update("attention", true))
198 assertTrue(manager.activeNotifications.isEmpty())
199 AgentNotifications.configure(context, "different-device", "user", "t3code-dev", true)
200 AgentNotifications.receive(context, update("attention", true))
201 assertTrue(manager.activeNotifications.isEmpty())
202 AgentNotifications.clear(context)
203 AgentNotifications.receive(context, update("attention", true))
204 assertTrue(manager.activeNotifications.isEmpty())
205 }
206
207 @Test
208 fun expandedActivityShowsFiveRowsAndUsesThePriorityThreadRoute() {
209 lifecycle.currentState = Lifecycle.State.RESUMED
210 val lines =
211 listOf(
212 "Approval: First · Project",
213 "Input: Second · Project",
214 "Failed: Third · Project",
215 "Working: Fourth · Project",
216 "Done: Fifth · Project"
217 )
218 AgentNotifications.receive(
219 context,
220 update("attention", true) +
221 lines.mapIndexed { index, line -> "activity_line_$index" to line }.toMap()
222 )
223 val card = manager.activeNotifications.single().notification
224 assertEquals(lines.joinToString("\n"), card.extras.getString(Notification.EXTRA_BIG_TEXT))
225 assertEquals(
226 "t3code-dev://threads/environment/thread",
227 shadowOf(card.contentIntent).savedIntent.dataString
228 )
229 }
230
231 @Test
232 fun quietWorkUsesAbsoluteRelayLifetimeInsteadOfTenMinuteRemoval() {
233 lifecycle.currentState = Lifecycle.State.RESUMED
234 val expiresAt = System.currentTimeMillis() + 2 * 60 * 60 * 1000L
235 AgentNotifications.receive(
236 context,
237 update("work", true) + ("activity_expires_at" to expiresAt.toString())
238 )
239 val card = manager.activeNotifications.single().notification
240 assertTimeout(card, 119 * 60 * 1000L..120 * 60 * 1000L)
241 }
242
243 @Test
244 fun finishedCardIsRetainedSilentlyWithoutOngoingFlagAndExpiresAtTheOriginalDeadline() {
245 lifecycle.currentState = Lifecycle.State.RESUMED
246 val expiresAt = System.currentTimeMillis() + 15 * 60 * 1000L
247 val finished = update("finished", false) + mapOf(
248 "activity_title" to "Agent work failed",
249 "activity_body" to "Failed: Test thread · Project",
250 "activity_expires_at" to expiresAt.toString(),
251 )
252 AgentNotifications.receive(context, finished)
253 val card = manager.activeNotifications.single().notification
254 assertEquals("Agent work failed", card.extras.getString(Notification.EXTRA_TITLE))
255 assertFalse(card.flags and Notification.FLAG_ONGOING_EVENT != 0)
256 assertTimeout(card, 1..15 * 60 * 1000L)
257 AgentNotifications.receive(
258 context,
259 finished + ("activity_expires_at" to (System.currentTimeMillis() - 1).toString())
260 )
261 assertTrue(manager.activeNotifications.isEmpty())
262 }
263
264 @Test
265 fun dismissalIncludesFinishedReplaysAndANewRunRearmsTheCard() {
266 lifecycle.currentState = Lifecycle.State.RESUMED
267 AgentNotifications.receive(context, update("work", true))
268 AgentNotifications.dismiss(context)
269 val finished =
270 update("finished", false) +
271 ("activity_expires_at" to (System.currentTimeMillis() + 900000).toString())
272 AgentNotifications.receive(context, finished)
273 AgentNotifications.receive(context, finished)
274 assertTrue(manager.activeNotifications.isEmpty())
275 AgentNotifications.receive(context, update("new-work", true))
276 assertEquals("t3-agent-activity", manager.activeNotifications.single().tag)
277 AgentNotifications.configure(context, "device", "user", "t3code-dev", false)
278 assertTrue(manager.activeNotifications.isEmpty())
279 }
280
281 @Test
282 fun reorderedActivityDoesNotEraseNewerCardOrDropAnIndependentAlert() {
283 val now = System.currentTimeMillis()
284 AgentNotifications.receive(context, update("new", true) + ("updated_at" to now.toString()))
285 AgentNotifications.receive(
286 context,
287 update("older-alert", false) + ("updated_at" to (now - 1000).toString())
288 )
289 assertEquals(3, manager.activeNotifications.size)
290 assertEquals(1, manager.activeNotifications.count { it.tag == "t3-agent-activity" })
291 shadowOf(manager).setNotificationsEnabled(false)
292 AgentNotifications.receive(context, update("revoked-permission", true))
293 assertEquals(3, manager.activeNotifications.size)
294 }
295
296 @Test
297 fun longRowsKeepStatusAndBothTitlesWithinTheNotificationWidth() {
298 lifecycle.currentState = Lifecycle.State.RESUMED
299 val raw = "Approval\t${"Long thread name ".repeat(10)}\t${"Project name ".repeat(10)}"
300 AgentNotifications.receive(
301 context,
302 update("long-work", true) + (0..4).associate { "activity_line_$it" to raw }
303 )
304 val lines = manager.activeNotifications.single().notification.extras.getString(
305 Notification.EXTRA_BIG_TEXT
306 )!!.split('\n')
307 assertEquals(5, lines.size)
308 for (line in lines) {
309 assertTrue(line.startsWith("Approval: "))
310 assertTrue(line.contains(" · "))
311 assertTrue(line.length < raw.length)
312 assertFalse(line.contains('\t'))
313 assertTrue(line.substringAfter(" · ").isNotBlank())
314 }
315 }
316
317 private fun assertTimeout(card: Notification, expected: LongRange) {
318 if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
319 assertTrue(card.timeoutAfter in expected)
320 assertTrue(
321 shadowOf(context.getSystemService(AlarmManager::class.java)).scheduledAlarms.isEmpty()
322 )
323 } else {
324 val alarm = shadowOf(
325 context.getSystemService(AlarmManager::class.java)
326 ).scheduledAlarms.single()
327 assertTrue(alarm.triggerAtTime - System.currentTimeMillis() in expected)
328 assertEquals(AlarmManager.RTC_WAKEUP, alarm.type)
329 }
330 }
331
332 @Test
333 @Config(sdk = [24, 25])
334 fun legacyExpiryRemovesOnlyTheCardAndCannotRemoveANewerRun() {
335 val alarms = shadowOf(context.getSystemService(AlarmManager::class.java))
336 val expiresAt = System.currentTimeMillis() + 900000
337 AgentNotifications.receive(
338 context,
339 update("done", false) + ("activity_expires_at" to expiresAt.toString())
340 )
341 val oldExpiry = alarms.scheduledAlarms.single().operation!!
342 AgentNotifications.receive(context, update("next-run", true))
343 assertEquals(1, alarms.scheduledAlarms.size)
344 val receiver = AgentActivityExpiryReceiver()
345 receiver.onReceive(context, shadowOf(oldExpiry).savedIntent)
346 AgentNotifications.expire(context, expiresAt + 60_000)
347 assertEquals(1, manager.activeNotifications.count { it.tag == "t3-agent-activity" })
348 AgentNotifications.expire(context, expiresAt + 2 * 60 * 60 * 1000L)
349 assertTrue(manager.activeNotifications.all { it.tag == "t3-agent-alert" })
350 assertTrue(alarms.scheduledAlarms.isEmpty())
351 }
352
353 @Test
354 @Config(sdk = [24, 25])
355 fun legacyExpiryIsCancelledOnDismissDisableAndSignOut() {
356 val alarms = shadowOf(context.getSystemService(AlarmManager::class.java))
357 AgentNotifications.receive(context, update("work", true))
358 AgentNotifications.dismiss(context)
359 assertTrue(alarms.scheduledAlarms.isEmpty())
360 AgentNotifications.configure(context, "device", "user", "t3code-dev", false)
361 AgentNotifications.configure(context, "device", "user", "t3code-dev", true)
362 AgentNotifications.receive(context, update("work", true))
363 assertEquals(1, alarms.scheduledAlarms.size)
364 AgentNotifications.configure(context, "device", "user", "t3code-dev", false)
365 assertTrue(alarms.scheduledAlarms.isEmpty())
366 AgentNotifications.configure(context, "device", "user", "t3code-dev", true)
367 AgentNotifications.receive(context, update("work", true))
368 AgentNotifications.clear(context)
369 assertTrue(alarms.scheduledAlarms.isEmpty())
370 assertTrue(manager.activeNotifications.isEmpty())
371 }
372
373 @Test
374 fun alertsAndActivityUseVersionAppropriatePriorityAndPromotion() {
375 AgentNotifications.receive(context, update("work", true))
376 val alert = manager.activeNotifications.single { it.tag == "t3-agent-alert" }.notification
377 val card = manager.activeNotifications.single { it.tag == "t3-agent-activity" }.notification
378 if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) {
379 assertEquals(Notification.PRIORITY_HIGH, alert.priority)
380 assertTrue(alert.defaults and Notification.DEFAULT_SOUND != 0)
381 assertEquals(Notification.PRIORITY_LOW, card.priority)
382 assertEquals(0, card.defaults)
383 } else {
384 assertEquals(
385 NotificationManager.IMPORTANCE_HIGH,
386 manager.getNotificationChannel(alert.channelId).importance
387 )
388 assertEquals(
389 NotificationManager.IMPORTANCE_LOW,
390 manager.getNotificationChannel(card.channelId).importance
391 )
392 }
393 assertTrue(NotificationCompat.isRequestPromotedOngoing(card))
394 assertFalse(NotificationCompat.isRequestPromotedOngoing(alert))
395 // Robolectric's API 36 image predates the shipped Live Update rules;
396 // its hasPromotableCharacteristics() incorrectly requires colorization.
397 assertFalse(card.extras.getBoolean(NotificationCompat.EXTRA_COLORIZED))
398 assertTrue(card.flags and Notification.FLAG_ONGOING_EVENT != 0)
399 assertEquals(Notification.VISIBILITY_PRIVATE, card.visibility)
400 }
401
402 @Test
403 fun expiredMalformedAndFutureMessagesCannotDisplayOrPoisonLaterUpdates() {
404 val invalid = update("invalid", true)
405 AgentNotifications.receive(context, invalid - "updated_at")
406 AgentNotifications.receive(context, invalid + ("updated_at" to "invalid"))
407 AgentNotifications.receive(
408 context,
409 invalid + ("updated_at" to (System.currentTimeMillis() - 600001).toString())
410 )
411 AgentNotifications.receive(
412 context,
413 invalid + ("updated_at" to (System.currentTimeMillis() + 3600000).toString())
414 )
415 assertTrue(manager.activeNotifications.isEmpty())
416 AgentNotifications.receive(context, update("valid", true))
417 assertEquals(2, manager.activeNotifications.size)
418 }
419
420 @Test
421 fun deniedPermissionDoesNotConsumeAnAlertBeforeTheUserAllowsNotifications() {
422 shadowOf(manager).setNotificationsEnabled(false)
423 val message = update("attention", true)
424 AgentNotifications.receive(context, message)
425 assertTrue(manager.activeNotifications.isEmpty())
426 shadowOf(manager).setNotificationsEnabled(true)
427 AgentNotifications.receive(context, message)
428 assertEquals(2, manager.activeNotifications.size)
429 }
430}
apps/mobile/modules/t3-agent-notifications/expo-module.config.json created+4
...@@ -0,0 +1,4 @@
1{
2 "platforms": ["android"],
3 "android": { "modules": ["expo.modules.t3agentnotifications.T3AgentNotificationsModule"] }
4}
apps/mobile/package.json+1
...@@ -145,6 +145,7 @@...@@ -145,6 +145,7 @@
145 },145 },
146 "autolinking": {146 "autolinking": {
147 "buildFromSource": [147 "buildFromSource": [
148 "expo-notifications",
148 "react-native-screens",149 "react-native-screens",
149 "@react-native-menu/menu",150 "@react-native-menu/menu",
150 "expo-audio"151 "expo-audio"
apps/mobile/src/features/agent-awareness/androidNotifications.test.ts created+63
...@@ -0,0 +1,63 @@
1import { beforeEach, describe, expect, it, vi } from "vite-plus/test";
2
3const mocks = vi.hoisted(() => ({
4 os: "android",
5 native: null as { configure?: ReturnType<typeof vi.fn>; clear?: ReturnType<typeof vi.fn> } | null,
6 config: { scheme: ["t3code-preview"], extra: { iosPersonalTeamBuild: false } },
7 requireModule: vi.fn(),
8}));
9
10vi.mock("expo", () => ({ requireOptionalNativeModule: mocks.requireModule }));
11vi.mock("expo-constants", () => ({ default: { expoConfig: mocks.config } }));
12vi.mock("react-native", () => ({
13 Platform: {
14 get OS() {
15 return mocks.os;
16 },
17 },
18}));
19
20beforeEach(() => {
21 vi.resetModules();
22 mocks.os = "android";
23 mocks.native = { configure: vi.fn(), clear: vi.fn() };
24 mocks.config.extra.iosPersonalTeamBuild = false;
25 mocks.requireModule.mockReset().mockImplementation(() => mocks.native);
26});
27
28describe("Android native notification capability", () => {
29 it("uses the installed module and the build variant's deep-link scheme", async () => {
30 const { configureAndroidAgentNotifications, clearAndroidAgentNotifications } =
31 await import("./androidNotifications");
32 const { supportsAgentAwarenessPush } = await import("./capabilities");
33 // An iOS-only signing restriction must not disable Android notifications.
34 mocks.config.extra.iosPersonalTeamBuild = true;
35 expect(supportsAgentAwarenessPush()).toBe(true);
36 configureAndroidAgentNotifications("device", "user", false);
37 expect(mocks.native?.configure).toHaveBeenCalledWith("device", "user", "t3code-preview", false);
38 clearAndroidAgentNotifications();
39 expect(mocks.native?.clear).toHaveBeenCalledOnce();
40 });
41
42 it.each([null, { clear: vi.fn() }, { configure: vi.fn() }])(
43 "disables push when the native binary is missing required methods (%j)",
44 async (native) => {
45 mocks.native = native;
46 const { configureAndroidAgentNotifications, clearAndroidAgentNotifications } =
47 await import("./androidNotifications");
48 const { supportsAgentAwarenessPush } = await import("./capabilities");
49 expect(supportsAgentAwarenessPush()).toBe(false);
50 expect(() => configureAndroidAgentNotifications("device", "user", true)).not.toThrow();
51 expect(() => clearAndroidAgentNotifications()).not.toThrow();
52 },
53 );
54
55 it("preserves the iOS personal-team restriction without loading Android code", async () => {
56 mocks.os = "ios";
57 const { supportsAgentAwarenessPush } = await import("./capabilities");
58 expect(supportsAgentAwarenessPush()).toBe(true);
59 mocks.config.extra.iosPersonalTeamBuild = true;
60 expect(supportsAgentAwarenessPush()).toBe(false);
61 expect(mocks.requireModule).not.toHaveBeenCalled();
62 });
63});
apps/mobile/src/features/agent-awareness/androidNotifications.ts created+35
...@@ -0,0 +1,35 @@
1import Constants from "expo-constants";
2import { requireOptionalNativeModule } from "expo";
3import { Platform } from "react-native";
4
5interface AndroidAgentNotifications {
6 configure(deviceId: string, userId: string, scheme: string, ongoingEnabled: boolean): void;
7 clear(): void;
8}
9
10const native =
11 Platform.OS === "android"
12 ? requireOptionalNativeModule<AndroidAgentNotifications>("T3AgentNotifications")
13 : null;
14
15export function supportsAndroidAgentNotifications(): boolean {
16 return typeof native?.configure === "function" && typeof native?.clear === "function";
17}
18
19export function configureAndroidAgentNotifications(
20 deviceId: string,
21 userId: string,
22 ongoingEnabled: boolean,
23): void {
24 const scheme = Constants.expoConfig?.scheme;
25 native?.configure?.(
26 deviceId,
27 userId,
28 (Array.isArray(scheme) ? scheme[0] : scheme) ?? "t3code",
29 ongoingEnabled,
30 );
31}
32
33export function clearAndroidAgentNotifications(): void {
34 native?.clear?.();
35}
apps/mobile/src/features/agent-awareness/capabilities.ts+5-1
...@@ -1,5 +1,9 @@...@@ -1,5 +1,9 @@
1import Constants from "expo-constants";1import Constants from "expo-constants";
2import { Platform } from "react-native";
3import { supportsAndroidAgentNotifications } from "./androidNotifications";
24
3export function supportsAgentAwarenessPush() {5export function supportsAgentAwarenessPush() {
4 return Constants.expoConfig?.extra?.iosPersonalTeamBuild !== true;6 return Platform.OS === "android"
7 ? supportsAndroidAgentNotifications()
8 : Platform.OS === "ios" && Constants.expoConfig?.extra?.iosPersonalTeamBuild !== true;
5}9}
apps/mobile/src/features/agent-awareness/notificationPermissions.test.ts created+59
...@@ -0,0 +1,59 @@
1import { beforeEach, vi } from "vite-plus/test";
2import { describe, expect, it } from "@effect/vitest";
3import * as Effect from "effect/Effect";
4import * as Notifications from "expo-notifications";
5import { requestAgentNotificationPermission } from "./notificationPermissions";
6
7const platform = vi.hoisted(() => ({ OS: "android" }));
8vi.mock("react-native", () => ({ Platform: platform }));
9vi.mock("expo-notifications", () => ({
10 AndroidImportance: { HIGH: 4 },
11 setNotificationChannelAsync: vi.fn(() => Promise.resolve(null)),
12 getPermissionsAsync: vi.fn(() => Promise.resolve({ granted: false, canAskAgain: true })),
13 requestPermissionsAsync: vi.fn(() => Promise.resolve({ granted: true, canAskAgain: true })),
14}));
15
16describe("agent notification permission", () => {
17 beforeEach(() => {
18 vi.clearAllMocks();
19 platform.OS = "android";
20 });
21
22 it.effect("creates an Android channel before prompting for notification permission", () =>
23 Effect.gen(function* () {
24 expect(yield* requestAgentNotificationPermission).toEqual({ type: "granted" });
25 expect(Notifications.setNotificationChannelAsync).toHaveBeenCalledWith("agent-alerts", {
26 name: "Agent alerts",
27 importance: 4,
28 });
29 expect(
30 vi.mocked(Notifications.setNotificationChannelAsync).mock.invocationCallOrder[0],
31 ).toBeLessThan(vi.mocked(Notifications.requestPermissionsAsync).mock.invocationCallOrder[0]!);
32 }),
33 );
34
35 it.effect("preserves denied permission when Android cannot ask again", () =>
36 Effect.gen(function* () {
37 vi.mocked(Notifications.getPermissionsAsync).mockResolvedValueOnce({
38 granted: false,
39 canAskAgain: false,
40 } as Notifications.NotificationPermissionsStatus);
41 expect(yield* requestAgentNotificationPermission).toEqual({
42 type: "denied",
43 canAskAgain: false,
44 });
45 expect(Notifications.requestPermissionsAsync).not.toHaveBeenCalled();
46 }),
47 );
48
49 it.effect("keeps iOS permission requests free of Android channel setup", () =>
50 Effect.gen(function* () {
51 platform.OS = "ios";
52 expect(yield* requestAgentNotificationPermission).toEqual({ type: "granted" });
53 expect(Notifications.setNotificationChannelAsync).not.toHaveBeenCalled();
54 expect(Notifications.requestPermissionsAsync).toHaveBeenCalledWith({
55 ios: { allowAlert: true, allowBadge: true, allowSound: true },
56 });
57 }),
58 );
59});
apps/mobile/src/features/agent-awareness/notificationPermissions.ts+14-3
...@@ -15,7 +15,7 @@ export class NotificationPermissionReadError extends Schema.TaggedError<Notifica...@@ -15,7 +15,7 @@ export class NotificationPermissionReadError extends Schema.TaggedError<Notifica
15 },15 },
16) {16) {
17 override get message(): string {17 override get message(): string {
18 return "Failed to read notification permissions on iOS.";18 return "Failed to read notification permissions.";
19 }19 }
20}20}
2121
...@@ -26,7 +26,7 @@ export class NotificationPermissionRequestError extends Schema.TaggedError<Notif...@@ -26,7 +26,7 @@ export class NotificationPermissionRequestError extends Schema.TaggedError<Notif
26 },26 },
27) {27) {
28 override get message(): string {28 override get message(): string {
29 return "Failed to request notification permissions on iOS.";29 return "Failed to request notification permissions.";
30 }30 }
31}31}
3232
...@@ -34,10 +34,21 @@ export const requestAgentNotificationPermission: Effect.Effect<...@@ -34,10 +34,21 @@ export const requestAgentNotificationPermission: Effect.Effect<
34 NotificationPermissionResult,34 NotificationPermissionResult,
35 NotificationPermissionReadError | NotificationPermissionRequestError35 NotificationPermissionReadError | NotificationPermissionRequestError
36> = Effect.gen(function* () {36> = Effect.gen(function* () {
37 if (Platform.OS !== "ios") {37 if (Platform.OS !== "ios" && Platform.OS !== "android") {
38 return { type: "unsupported" };38 return { type: "unsupported" };
39 }39 }
4040
41 if (Platform.OS === "android") {
42 yield* Effect.tryPromise({
43 try: () =>
44 Notifications.setNotificationChannelAsync("agent-alerts", {
45 name: "Agent alerts",
46 importance: Notifications.AndroidImportance.HIGH,
47 }),
48 catch: (cause) => new NotificationPermissionRequestError({ cause }),
49 });
50 }
51
41 const existing = yield* Effect.tryPromise({52 const existing = yield* Effect.tryPromise({
42 try: () => Notifications.getPermissionsAsync(),53 try: () => Notifications.getPermissionsAsync(),
43 catch: (cause) => new NotificationPermissionReadError({ cause }),54 catch: (cause) => new NotificationPermissionReadError({ cause }),
apps/mobile/src/features/agent-awareness/registrationPayload.ts+20-14
...@@ -10,25 +10,31 @@ export function resolveApsEnvironment(appVariant: unknown): "sandbox" | "product...@@ -10,25 +10,31 @@ export function resolveApsEnvironment(appVariant: unknown): "sandbox" | "product
10 return appVariant === "development" ? "sandbox" : "production";10 return appVariant === "development" ? "sandbox" : "production";
11}11}
1212
13export function makeRelayDeviceRegistrationRequest(input: {13export function makeRelayDeviceRegistrationRequest(
14 readonly deviceId: string;14 input: {
15 readonly label: string;15 readonly deviceId: string;
16 readonly iosMajorVersion: number;16 readonly label: string;
17 readonly appVersion?: string;17 readonly appVersion?: string;
18 readonly bundleId?: string;18 readonly bundleId?: string;
19 readonly apsEnvironment?: "sandbox" | "production";19 readonly apsEnvironment?: "sandbox" | "production";
20 readonly pushToken?: string;20 readonly pushToken?: string;
21 readonly pushToStartToken?: string;21 readonly pushToStartToken?: string;
22 readonly notificationsEnabled: boolean;22 readonly notificationsEnabled: boolean;
23 readonly preferences: Preferences;23 readonly preferences: Preferences;
24}): RelayDeviceRegistrationRequest {24 } & (
25 | { readonly platform?: "ios"; readonly iosMajorVersion: number }
26 | { readonly platform: "android"; readonly androidApiLevel: number }
27 ),
28): RelayDeviceRegistrationRequest {
25 const pushAvailable = supportsAgentAwarenessPush();29 const pushAvailable = supportsAgentAwarenessPush();
26 const liveActivitiesEnabled = pushAvailable && input.preferences.liveActivitiesEnabled !== false;30 const liveActivitiesEnabled = pushAvailable && input.preferences.liveActivitiesEnabled !== false;
27 return {31 return {
28 deviceId: input.deviceId,32 deviceId: input.deviceId,
29 label: input.label,33 label: input.label,
30 platform: "ios",34 platform: input.platform ?? "ios",
31 iosMajorVersion: input.iosMajorVersion,35 ...(input.platform === "android"
36 ? { androidApiLevel: input.androidApiLevel }
37 : { iosMajorVersion: input.iosMajorVersion }),
32 appVersion: input.appVersion,38 appVersion: input.appVersion,
33 ...(input.bundleId ? { bundleId: input.bundleId } : {}),39 ...(input.bundleId ? { bundleId: input.bundleId } : {}),
34 ...(input.apsEnvironment ? { apsEnvironment: input.apsEnvironment } : {}),40 ...(input.apsEnvironment ? { apsEnvironment: input.apsEnvironment } : {}),
apps/mobile/src/features/agent-awareness/remoteRegistration.test.ts+217-36
...@@ -5,10 +5,16 @@ import * as NodeCrypto from "node:crypto";...@@ -5,10 +5,16 @@ import * as NodeCrypto from "node:crypto";
5import { beforeEach, vi } from "vite-plus/test";5import { beforeEach, vi } from "vite-plus/test";
6import { describe, expect, it } from "@effect/vitest";6import { describe, expect, it } from "@effect/vitest";
7import Constants from "expo-constants";7import Constants from "expo-constants";
8import * as Cause from "effect/Cause";
8import * as Effect from "effect/Effect";9import * as Effect from "effect/Effect";
9import * as Exit from "effect/Exit";10import * as Exit from "effect/Exit";
10import * as Layer from "effect/Layer";11import * as Layer from "effect/Layer";
11import { FetchHttpClient } from "effect/unstable/http";12import {
13 Cookies,
14 FetchHttpClient,
15 HttpClientRequest,
16 HttpClientResponse,
17} from "effect/unstable/http";
12import { ManagedRelay } from "@t3tools/client-runtime/relay";18import { ManagedRelay } from "@t3tools/client-runtime/relay";
1319
14import type { EnvironmentId } from "@t3tools/contracts";20import type { EnvironmentId } from "@t3tools/contracts";
...@@ -39,8 +45,20 @@ import {...@@ -39,8 +45,20 @@ import {
39 setAgentAwarenessRelayTokenProvider,45 setAgentAwarenessRelayTokenProvider,
40 shouldRegisterAgentAwarenessDeviceForProvider,46 shouldRegisterAgentAwarenessDeviceForProvider,
41 unregisterAgentAwarenessConnection,47 unregisterAgentAwarenessConnection,
48 updateAgentAwarenessRegistrationPreferences,
42} from "./remoteRegistration";49} from "./remoteRegistration";
43import * as Notifications from "expo-notifications";50import * as Notifications from "expo-notifications";
51import { Platform } from "react-native";
52import {
53 configureAndroidAgentNotifications,
54 clearAndroidAgentNotifications,
55} from "./androidNotifications";
56
57vi.mock("./androidNotifications", () => ({
58 supportsAndroidAgentNotifications: vi.fn(() => true),
59 configureAndroidAgentNotifications: vi.fn(),
60 clearAndroidAgentNotifications: vi.fn(),
61}));
4462
45const secureStore = vi.hoisted(() => new Map<string, string>());63const secureStore = vi.hoisted(() => new Map<string, string>());
46const widgetMocks = vi.hoisted(() => ({64const widgetMocks = vi.hoisted(() => ({
...@@ -138,8 +156,12 @@ vi.mock("expo-secure-store", () => ({...@@ -138,8 +156,12 @@ vi.mock("expo-secure-store", () => ({
138156
139vi.mock("react-native", () => ({157vi.mock("react-native", () => ({
140 Platform: {158 Platform: {
141 OS: "ios",159 get OS() {
142 Version: "18.0",160 return "ios";
161 },
162 get Version() {
163 return "18.0";
164 },
143 },165 },
144 AppState: {166 AppState: {
145 addEventListener: (_event: string, listener: (state: string) => void) => {167 addEventListener: (_event: string, listener: (state: string) => void) => {
...@@ -235,6 +257,11 @@ const runBackgroundOperations = Effect.fn("TestRemoteRegistration.runBackgroundO...@@ -235,6 +257,11 @@ const runBackgroundOperations = Effect.fn("TestRemoteRegistration.runBackgroundO
235257
236describe("makeRelayDeviceRegistrationRequest", () => {258describe("makeRelayDeviceRegistrationRequest", () => {
237 beforeEach(() => {259 beforeEach(() => {
260 vi.restoreAllMocks();
261 vi.mocked(Notifications.getDevicePushTokenAsync).mockResolvedValue({
262 type: "ios",
263 data: "apns-token",
264 });
238 vi.unstubAllGlobals();265 vi.unstubAllGlobals();
239 vi.stubGlobal("__DEV__", false);266 vi.stubGlobal("__DEV__", false);
240 secureStore.clear();267 secureStore.clear();
...@@ -924,47 +951,201 @@ describe("makeRelayDeviceRegistrationRequest", () => {...@@ -924,47 +951,201 @@ describe("makeRelayDeviceRegistrationRequest", () => {
924 await new Promise((resolve) => setTimeout(resolve, 0));951 await new Promise((resolve) => setTimeout(resolve, 0));
925 expect(widgetMocks.start).toHaveBeenCalledTimes(1);952 expect(widgetMocks.start).toHaveBeenCalledTimes(1);
926 });953 });
927 it.effect(954 for (const os of ["ios", "android"] as const) {
928 "does not enable notifications when a token rotates after permission is revoked",955 it.effect(
929 () => {956 `does not enable ${os} notifications when a token rotates after permission is revoked`,
930 const registrations: unknown[] = [];957 () => {
931 vi.stubGlobal("fetch", async (input: RequestInfo | URL, init?: RequestInit) => {958 vi.spyOn(Platform, "OS", "get").mockReturnValue(os);
932 const request = new Request(input, init);959 vi.spyOn(Platform, "Version", "get").mockReturnValue(os === "ios" ? 18 : 36);
933 if (request.url.endsWith("/v1/client/dpop-token")) {960 vi.mocked(Notifications.getDevicePushTokenAsync).mockResolvedValue({
934 return Response.json({961 type: os,
935 access_token: "dpop",962 data: "initial",
963 });
964 const registrations: unknown[] = [];
965 vi.stubGlobal("fetch", async (input: RequestInfo | URL, init?: RequestInit) => {
966 const request = new Request(input, init);
967 if (request.url.endsWith("/v1/client/dpop-token")) {
968 return Response.json({
969 access_token: "dpop",
970 issued_token_type: "urn:ietf:params:oauth:token-type:access_token",
971 token_type: "DPoP",
972 expires_in: 300,
973 scope: "mobile:registration",
974 });
975 }
976 registrations.push(await request.json());
977 return Response.json({ ok: true });
978 });
979 Constants.expoConfig!.extra = { relay: { url: "https://permission-relay.example.test" } };
980 setAgentAwarenessRelayTokenProvider(() => Promise.resolve("clerk"), "user-a");
981 return Effect.gen(function* () {
982 yield* runBackgroundOperations();
983 expect(registrations.at(-1)).toMatchObject({
984 preferences: { notificationsEnabled: true },
985 });
986 vi.mocked(Notifications.getPermissionsAsync).mockResolvedValueOnce({
987 granted: false,
988 } as Awaited<ReturnType<typeof Notifications.getPermissionsAsync>>);
989 const listener = vi.mocked(Notifications.addPushTokenListener).mock.calls.at(-1)![0];
990 listener({ type: os, data: "rotated" });
991 yield* runBackgroundOperations();
992 expect(registrations.at(-1)).toMatchObject({
993 preferences: { notificationsEnabled: false },
994 });
995 expect(registrations.at(-1)).not.toHaveProperty("pushToken");
996 }).pipe(
997 Effect.provideService(FetchHttpClient.Fetch, globalThis.fetch),
998 Effect.provide(
999 managedRelayClientLayer("https://permission-relay.example.test").pipe(
1000 Layer.provide(Layer.mergeAll(FetchHttpClient.layer, cryptoLayer)),
1001 ),
1002 ),
1003 );
1004 },
1005 );
1006 }
1007 it.effect("preserves relay rejection errors with React Native response headers", () => {
1008 vi.spyOn(Platform, "OS", "get").mockReturnValue("android");
1009 vi.mocked(Notifications.getDevicePushTokenAsync).mockResolvedValue({
1010 type: "android",
1011 data: "fcm-token",
1012 });
1013 const rejectedResponse = new Response("Unsupported device platform", { status: 400 });
1014 Object.defineProperty(rejectedResponse.headers, "getSetCookie", { value: undefined });
1015 vi.stubGlobal("fetch", (request: RequestInfo | URL) => {
1016 const url = request instanceof Request ? request.url : String(request);
1017 const response = url.endsWith("/v1/client/dpop-token")
1018 ? Response.json({
1019 access_token: "relay-dpop-token",
936 issued_token_type: "urn:ietf:params:oauth:token-type:access_token",1020 issued_token_type: "urn:ietf:params:oauth:token-type:access_token",
937 token_type: "DPoP",1021 token_type: "DPoP",
938 expires_in: 300,1022 expires_in: 300,
939 scope: "mobile:registration",1023 scope: "mobile:registration",
940 });1024 })
941 }1025 : rejectedResponse;
942 registrations.push(await request.json());1026 Object.defineProperty(response.headers, "getSetCookie", { value: undefined });
943 return Response.json({ ok: true });1027 return Promise.resolve(response);
1028 });
1029 Constants.expoConfig!.extra = { relay: { url: "https://relay.example.test" } };
1030 setAgentAwarenessRelayTokenProvider(() => Promise.resolve("clerk-token-user-a"), "user-a");
1031
1032 return Effect.gen(function* () {
1033 // Hermes' compiled error hashing reads the response's cookie getter.
1034 const httpResponse = HttpClientResponse.fromWeb(
1035 HttpClientRequest.post("https://relay.example.test/v1/mobile/devices"),
1036 rejectedResponse,
1037 );
1038 expect(httpResponse.cookies).toEqual(Cookies.empty);
1039 const result = yield* Effect.exit(
1040 updateAgentAwarenessRegistrationPreferences({ liveActivitiesEnabled: true }),
1041 );
1042 expect(Exit.isFailure(result)).toBe(true);
1043 if (Exit.isFailure(result)) {
1044 expect(Cause.hasDies(result.cause)).toBe(false);
1045 expect(Cause.squash(result.cause)).toBeInstanceOf(
1046 ManagedRelay.ManagedRelayRequestFailedError,
1047 );
1048 }
1049 expect(getAgentAwarenessRegistrationStatus()).toBe("failed");
1050 expect(saveAgentAwarenessRegistrationRecord).not.toHaveBeenCalled();
1051 }).pipe(Effect.provide(relayTestLayer));
1052 });
1053
1054 it.effect("registers an Android FCM token without invoking Apple Live Activities", () => {
1055 vi.spyOn(Platform, "OS", "get").mockReturnValue("android");
1056 vi.spyOn(Platform, "Version", "get").mockReturnValue(36);
1057 vi.mocked(Notifications.getDevicePushTokenAsync).mockResolvedValue({
1058 type: "android",
1059 data: "fcm-token",
1060 });
1061 vi.stubGlobal(
1062 "fetch",
1063 vi.fn((request: RequestInfo | URL) => {
1064 const url = request instanceof Request ? request.url : String(request);
1065 return Promise.resolve(
1066 Response.json(
1067 url.endsWith("/v1/client/dpop-token")
1068 ? {
1069 access_token: "relay-dpop-token",
1070 issued_token_type: "urn:ietf:params:oauth:token-type:access_token",
1071 token_type: "DPoP",
1072 expires_in: 300,
1073 scope: "mobile:registration",
1074 }
1075 : { ok: true },
1076 ),
1077 );
1078 }),
1079 );
1080 Constants.expoConfig!.extra = { relay: { url: "https://relay.example.test" } };
1081 setAgentAwarenessRelayTokenProvider(() => Promise.resolve("clerk-token-user-a"), "user-a");
1082 return Effect.gen(function* () {
1083 yield* refreshAgentAwarenessRegistration();
1084 expect(Notifications.getDevicePushTokenAsync).toHaveBeenCalled();
1085 expect(saveAgentAwarenessRegistrationRecord).toHaveBeenCalled();
1086 expect(registrationRecordStore.current?.signature).toContain("fcm-token");
1087 expect(registrationRecordStore.current?.signature).toContain("android");
1088 expect(widgetMocks.getInstances).not.toHaveBeenCalled();
1089 expect(configureAndroidAgentNotifications).toHaveBeenCalledWith("device-1", "user-a", true);
1090 vi.mocked(clearAndroidAgentNotifications).mockClear();
1091 setAgentAwarenessRelayTokenProvider(() => Promise.resolve("clerk-token-user-b"), "user-b");
1092 expect(clearAndroidAgentNotifications).toHaveBeenCalled();
1093 }).pipe(Effect.provide(relayTestLayer));
1094 });
1095 it.effect(
1096 "preserves same-account Android notifications and replays on remount and later foreground",
1097 () => {
1098 vi.spyOn(Platform, "OS", "get").mockReturnValue("android");
1099 vi.spyOn(Platform, "Version", "get").mockReturnValue(36);
1100 vi.mocked(Notifications.getDevicePushTokenAsync).mockResolvedValue({
1101 type: "android",
1102 data: "fcm-token",
944 });1103 });
945 Constants.expoConfig!.extra = { relay: { url: "https://permission-relay.example.test" } };1104 Constants.expoConfig!.extra = { relay: { url: "https://relay.example.test" } };
946 setAgentAwarenessRelayTokenProvider(() => Promise.resolve("clerk"), "user-a");1105 const now = vi.spyOn(Date, "now").mockReturnValue(1000000);
1106 vi.mocked(clearAndroidAgentNotifications).mockClear();
1107 setAgentAwarenessRelayTokenProvider(() => Promise.resolve("clerk-token"), "user-a");
947 return Effect.gen(function* () {1108 return Effect.gen(function* () {
948 yield* runBackgroundOperations();1109 yield* runBackgroundOperations();
949 expect(registrations.at(-1)).toMatchObject({ preferences: { notificationsEnabled: true } });1110 expect(getAgentAwarenessRegistrationStatus()).toBe("registered");
950 vi.mocked(Notifications.getPermissionsAsync).mockResolvedValueOnce({1111 expect(saveAgentAwarenessRegistrationRecord).toHaveBeenCalledTimes(1);
951 granted: false,1112 yield* refreshAgentAwarenessRegistration();
952 } as Awaited<ReturnType<typeof Notifications.getPermissionsAsync>>);1113 expect(saveAgentAwarenessRegistrationRecord).toHaveBeenCalledTimes(1);
953 const listener = vi.mocked(Notifications.addPushTokenListener).mock.calls.at(-1)![0];1114 releaseAgentAwarenessRelayTokenProvider();
954 listener({ type: "ios", data: "rotated" });1115 setAgentAwarenessRelayTokenProvider(() => Promise.resolve("clerk-token"), "user-a");
955 yield* runBackgroundOperations();1116 yield* runBackgroundOperations();
956 expect(registrations.at(-1)).toMatchObject({1117 expect(clearAndroidAgentNotifications).not.toHaveBeenCalled();
957 preferences: { notificationsEnabled: false },1118 expect(saveAgentAwarenessRegistrationRecord).toHaveBeenCalledTimes(2);
958 });1119 for (const listener of appStateMock.listeners) listener("active");
959 expect(registrations.at(-1)).not.toHaveProperty("pushToken");1120 yield* runBackgroundOperations();
960 }).pipe(1121 expect(saveAgentAwarenessRegistrationRecord).toHaveBeenCalledTimes(2);
961 Effect.provideService(FetchHttpClient.Fetch, globalThis.fetch),1122 now.mockReturnValue(1060001);
962 Effect.provide(1123 for (const listener of appStateMock.listeners) listener("active");
963 managedRelayClientLayer("https://permission-relay.example.test").pipe(1124 yield* runBackgroundOperations();
964 Layer.provide(Layer.mergeAll(FetchHttpClient.layer, cryptoLayer)),1125 expect(saveAgentAwarenessRegistrationRecord).toHaveBeenCalledTimes(3);
965 ),1126 expect(clearAndroidAgentNotifications).not.toHaveBeenCalled();
966 ),1127 expect(widgetMocks.getInstances).not.toHaveBeenCalled();
967 );1128 setAgentAwarenessRelayTokenProvider(null);
1129 expect(clearAndroidAgentNotifications).toHaveBeenCalled();
1130 expect(clearAgentAwarenessRegistrationRecord).toHaveBeenCalled();
1131 }).pipe(Effect.provide(relayTestLayer));
1132 },
1133 );
1134
1135 it.effect(
1136 "does not reconfigure or register a detached Android account from an in-flight operation",
1137 () => {
1138 vi.spyOn(Platform, "OS", "get").mockReturnValue("android");
1139 vi.spyOn(Platform, "Version", "get").mockReturnValue(36);
1140 Constants.expoConfig!.extra = { relay: { url: "https://relay.example.test" } };
1141 setAgentAwarenessRelayTokenProvider(() => Promise.resolve("clerk-token"), "user-a");
1142 releaseAgentAwarenessRelayTokenProvider();
1143 vi.mocked(configureAndroidAgentNotifications).mockClear();
1144 return Effect.gen(function* () {
1145 yield* runBackgroundOperations();
1146 expect(configureAndroidAgentNotifications).not.toHaveBeenCalled();
1147 expect(saveAgentAwarenessRegistrationRecord).not.toHaveBeenCalled();
1148 }).pipe(Effect.provide(relayTestLayer));
968 },1149 },
969 );1150 );
970});1151});
apps/mobile/src/features/agent-awareness/remoteRegistration.ts+72-14
...@@ -1,4 +1,8 @@...@@ -1,4 +1,8 @@
1import { type LiveActivity } from "expo-widgets";1import { type LiveActivity } from "expo-widgets";
2import {
3 configureAndroidAgentNotifications,
4 clearAndroidAgentNotifications,
5} from "./androidNotifications";
2import Constants from "expo-constants";6import Constants from "expo-constants";
3import * as Notifications from "expo-notifications";7import * as Notifications from "expo-notifications";
4import * as Effect from "effect/Effect";8import * as Effect from "effect/Effect";
...@@ -79,6 +83,7 @@ const activityPushTokenListeners = new WeakSet<LiveActivity<AgentActivityProps>>...@@ -79,6 +83,7 @@ const activityPushTokenListeners = new WeakSet<LiveActivity<AgentActivityProps>>
79// sign-out/identity change alongside the device registration state.83// sign-out/identity change alongside the device registration state.
80const ACTIVITY_TOKEN_REREGISTER_INTERVAL_MS = 60_000;84const ACTIVITY_TOKEN_REREGISTER_INTERVAL_MS = 60_000;
81const registeredActivityPushTokens = new Map<string, number>();85const registeredActivityPushTokens = new Map<string, number>();
86let androidDeviceReplayedAt: number | null = null;
82let pushTokenSubscription: { remove: () => void } | null = null;87let pushTokenSubscription: { remove: () => void } | null = null;
83let appStateSubscription: { remove: () => void } | null = null;88let appStateSubscription: { remove: () => void } | null = null;
8489
...@@ -153,6 +158,10 @@ function canRegisterRemoteLiveActivities(): boolean {...@@ -153,6 +158,10 @@ function canRegisterRemoteLiveActivities(): boolean {
153 return Platform.OS === "ios";158 return Platform.OS === "ios";
154}159}
155160
161function canRegisterPushNotifications(): boolean {
162 return Platform.OS === "ios" || Platform.OS === "android";
163}
164
156export function shouldRegisterAgentAwarenessDeviceForProvider(165export function shouldRegisterAgentAwarenessDeviceForProvider(
157 previousIdentity: string | null,166 previousIdentity: string | null,
158 identity: string | undefined,167 identity: string | undefined,
...@@ -168,6 +177,12 @@ export function setAgentAwarenessRelayTokenProvider(...@@ -168,6 +177,12 @@ export function setAgentAwarenessRelayTokenProvider(
168 provider !== null &&177 provider !== null &&
169 !shouldRegisterAgentAwarenessDeviceForProvider(relayTokenProviderIdentity, identity);178 !shouldRegisterAgentAwarenessDeviceForProvider(relayTokenProviderIdentity, identity);
170 if (!isExistingIdentity) {179 if (!isExistingIdentity) {
180 // Native configure compares the persisted account on cold start. An
181 // unset JS identity is a remount, not evidence of a different account.
182 if (relayTokenProviderIdentity && identity !== relayTokenProviderIdentity) {
183 clearAndroidAgentNotifications();
184 }
185 androidDeviceReplayedAt = null;
171 deviceRegistrationGeneration++;186 deviceRegistrationGeneration++;
172 activeDeviceRegistration = null;187 activeDeviceRegistration = null;
173 pendingDeviceRegistration = null;188 pendingDeviceRegistration = null;
...@@ -176,6 +191,7 @@ export function setAgentAwarenessRelayTokenProvider(...@@ -176,6 +191,7 @@ export function setAgentAwarenessRelayTokenProvider(
176 relayTokenProvider = provider;191 relayTokenProvider = provider;
177 relayTokenProviderIdentity = provider ? (identity ?? null) : null;192 relayTokenProviderIdentity = provider ? (identity ?? null) : null;
178 if (!provider) {193 if (!provider) {
194 clearAndroidAgentNotifications();
179 pushTokenSubscription?.remove();195 pushTokenSubscription?.remove();
180 pushTokenSubscription = null;196 pushTokenSubscription = null;
181 appStateSubscription?.remove();197 appStateSubscription?.remove();
...@@ -221,6 +237,10 @@ export function setAgentAwarenessRelayTokenProvider(...@@ -221,6 +237,10 @@ export function setAgentAwarenessRelayTokenProvider(
221export function releaseAgentAwarenessRelayTokenProvider(): void {237export function releaseAgentAwarenessRelayTokenProvider(): void {
222 relayTokenProvider = null;238 relayTokenProvider = null;
223 relayTokenProviderIdentity = null;239 relayTokenProviderIdentity = null;
240 deviceRegistrationGeneration++;
241 activeDeviceRegistration = null;
242 pendingDeviceRegistration = null;
243 androidDeviceReplayedAt = null;
224 pushTokenSubscription?.remove();244 pushTokenSubscription?.remove();
225 pushTokenSubscription = null;245 pushTokenSubscription = null;
226 appStateSubscription?.remove();246 appStateSubscription?.remove();
...@@ -242,7 +262,7 @@ function iosMajorVersion(): number {...@@ -242,7 +262,7 @@ function iosMajorVersion(): number {
242262
243function nativePushTokenRegistration(observedPushToken?: string) {263function nativePushTokenRegistration(observedPushToken?: string) {
244 return Effect.gen(function* () {264 return Effect.gen(function* () {
245 if (!canRegisterRemoteLiveActivities() || !supportsAgentAwarenessPush()) {265 if (!canRegisterPushNotifications() || !supportsAgentAwarenessPush()) {
246 return { notificationsEnabled: false, pushToken: null };266 return { notificationsEnabled: false, pushToken: null };
247 }267 }
248 const permissions = yield* Effect.tryPromise({268 const permissions = yield* Effect.tryPromise({
...@@ -269,13 +289,13 @@ function nativePushTokenRegistration(observedPushToken?: string) {...@@ -269,13 +289,13 @@ function nativePushTokenRegistration(observedPushToken?: string) {
269 }).pipe(289 }).pipe(
270 Effect.tapError((error) =>290 Effect.tapError((error) =>
271 Effect.sync(() => {291 Effect.sync(() => {
272 logRegistrationError("native APNs token lookup failed", error);292 logRegistrationError("native push token lookup failed", error);
273 }),293 }),
274 ),294 ),
275 Effect.orElseSucceed(() => null),295 Effect.orElseSucceed(() => null),
276 );296 );
277 const pushToken =297 const pushToken =
278 token?.type === "ios" && typeof token.data === "string" && token.data.trim().length > 0298 token?.type === Platform.OS && typeof token.data === "string" && token.data.trim().length > 0
279 ? token.data.trim()299 ? token.data.trim()
280 : null;300 : null;
281 return { notificationsEnabled: pushToken !== null, pushToken };301 return { notificationsEnabled: pushToken !== null, pushToken };
...@@ -307,7 +327,9 @@ function registrationSignature(body: RelayDeviceRegistrationRequest): string {...@@ -307,7 +327,9 @@ function registrationSignature(body: RelayDeviceRegistrationRequest): string {
307 body.apsEnvironment ?? "",327 body.apsEnvironment ?? "",
308 body.appVersion ?? "",328 body.appVersion ?? "",
309 body.label,329 body.label,
330 body.platform,
310 body.iosMajorVersion,331 body.iosMajorVersion,
332 body.androidApiLevel,
311 body.preferences.notificationsEnabled,333 body.preferences.notificationsEnabled,
312 body.preferences.liveActivitiesEnabled,334 body.preferences.liveActivitiesEnabled,
313 body.preferences.notifyOnApproval,335 body.preferences.notifyOnApproval,
...@@ -372,7 +394,19 @@ function registerDeviceWithRelay(...@@ -372,7 +394,19 @@ function registerDeviceWithRelay(
372 // The relay URL participates so pointing the app at a different relay394 // The relay URL participates so pointing the app at a different relay
373 // invalidates the record and re-registers there.395 // invalidates the record and re-registers there.
374 const signature = `${relayConfig.url}|${registrationSignature(payload)}`;396 const signature = `${relayConfig.url}|${registrationSignature(payload)}`;
375 if (persisted && persisted.identity === identity && persisted.signature === signature) {397 // Android registration also silently replays the current card. Collapse
398 // foreground bursts, but repair missed pushes on cold start or a return
399 // after time away, just like re-registering an iOS activity token.
400 const needsAndroidReplay =
401 body.platform === "android" &&
402 (androidDeviceReplayedAt === null ||
403 Date.now() - androidDeviceReplayedAt >= ACTIVITY_TOKEN_REREGISTER_INTERVAL_MS);
404 if (
405 persisted &&
406 persisted.identity === identity &&
407 persisted.signature === signature &&
408 !needsAndroidReplay
409 ) {
376 setRegistrationStatus("registered");410 setRegistrationStatus("registered");
377 logRegistrationDebug("relay device registration skipped; already registered for account", {411 logRegistrationDebug("relay device registration skipped; already registered for account", {
378 expectedGeneration,412 expectedGeneration,
...@@ -398,6 +432,7 @@ function registerDeviceWithRelay(...@@ -398,6 +432,7 @@ function registerDeviceWithRelay(
398 });432 });
399 return;433 return;
400 }434 }
435 if (body.platform === "android") androidDeviceReplayedAt = Date.now();
401 setRegistrationStatus("registered");436 setRegistrationStatus("registered");
402 yield* Effect.promise(() =>437 yield* Effect.promise(() =>
403 saveAgentAwarenessRegistrationRecord({438 saveAgentAwarenessRegistrationRecord({
...@@ -684,7 +719,7 @@ function registerDevice(...@@ -684,7 +719,7 @@ function registerDevice(
684 expectedGeneration = deviceRegistrationGeneration,719 expectedGeneration = deviceRegistrationGeneration,
685): Effect.Effect<void, unknown, ManagedRelay.ManagedRelayClient> {720): Effect.Effect<void, unknown, ManagedRelay.ManagedRelayClient> {
686 return Effect.gen(function* () {721 return Effect.gen(function* () {
687 if (!canRegisterRemoteLiveActivities()) {722 if (!canRegisterPushNotifications()) {
688 logRegistrationDebug("device registration skipped; platform does not support it");723 logRegistrationDebug("device registration skipped; platform does not support it");
689 return;724 return;
690 }725 }
...@@ -712,20 +747,37 @@ function registerDevice(...@@ -712,20 +747,37 @@ function registerDevice(
712 storedPreferences,747 storedPreferences,
713 input.preferencesOverride,748 input.preferencesOverride,
714 );749 );
750 if (expectedGeneration !== deviceRegistrationGeneration) return;
751 if (relayTokenProvider && relayTokenProviderIdentity) {
752 configureAndroidAgentNotifications(
753 deviceId,
754 relayTokenProviderIdentity,
755 preferences.liveActivitiesEnabled !== false,
756 );
757 }
715 const pushTokenRegistration = yield* nativePushTokenRegistration(input?.observedPushToken);758 const pushTokenRegistration = yield* nativePushTokenRegistration(input?.observedPushToken);
716 logRegistrationDebug("device registration local state ready", {759 logRegistrationDebug("device registration local state ready", {
717 expectedGeneration,760 expectedGeneration,
718 notificationsEnabled: pushTokenRegistration.notificationsEnabled,761 notificationsEnabled: pushTokenRegistration.notificationsEnabled,
719 });762 });
720 const bundleId = Constants.expoConfig?.ios?.bundleIdentifier?.trim();763 const bundleId =
764 Platform.OS === "android"
765 ? Constants.expoConfig?.android?.package?.trim()
766 : Constants.expoConfig?.ios?.bundleIdentifier?.trim();
721 yield* registerDeviceWithRelay(767 yield* registerDeviceWithRelay(
722 makeRelayDeviceRegistrationRequest({768 makeRelayDeviceRegistrationRequest({
723 deviceId,769 deviceId,
724 label: Constants.deviceName?.trim() || "iOS device",770 label:
725 iosMajorVersion: iosMajorVersion(),771 Constants.deviceName?.trim() ||
772 (Platform.OS === "android" ? "Android device" : "iOS device"),
773 ...(Platform.OS === "android"
774 ? { platform: "android" as const, androidApiLevel: Number(Platform.Version) }
775 : { platform: "ios" as const, iosMajorVersion: iosMajorVersion() }),
726 appVersion: Constants.expoConfig?.version,776 appVersion: Constants.expoConfig?.version,
727 ...(bundleId ? { bundleId } : {}),777 ...(bundleId ? { bundleId } : {}),
728 apsEnvironment: resolveApsEnvironment(Constants.expoConfig?.extra?.appVariant),778 ...(Platform.OS === "ios"
779 ? { apsEnvironment: resolveApsEnvironment(Constants.expoConfig?.extra?.appVariant) }
780 : {}),
729 ...(pushTokenRegistration.pushToken ? { pushToken: pushTokenRegistration.pushToken } : {}),781 ...(pushTokenRegistration.pushToken ? { pushToken: pushTokenRegistration.pushToken } : {}),
730 notificationsEnabled: pushTokenRegistration.notificationsEnabled,782 notificationsEnabled: pushTokenRegistration.notificationsEnabled,
731 preferences,783 preferences,
...@@ -744,15 +796,19 @@ function registerDeviceForCurrentUser(): Effect.Effect<...@@ -744,15 +796,19 @@ function registerDeviceForCurrentUser(): Effect.Effect<
744}796}
745797
746function ensurePushTokenListener(): void {798function ensurePushTokenListener(): void {
747 if (pushTokenSubscription || !canRegisterRemoteLiveActivities()) {799 if (pushTokenSubscription || !canRegisterPushNotifications()) {
748 return;800 return;
749 }801 }
750802
751 pushTokenSubscription = Notifications.addPushTokenListener((token) => {803 pushTokenSubscription = Notifications.addPushTokenListener((token) => {
752 if (token.type === "ios" && typeof token.data === "string" && token.data.trim().length > 0) {804 if (
805 token.type === Platform.OS &&
806 typeof token.data === "string" &&
807 token.data.trim().length > 0
808 ) {
753 enqueueDeviceRegistration(809 enqueueDeviceRegistration(
754 { observedPushToken: token.data.trim() },810 { observedPushToken: token.data.trim() },
755 "native APNs token rotation registration failed",811 "native push token rotation registration failed",
756 );812 );
757 }813 }
758 });814 });
...@@ -765,7 +821,7 @@ function ensurePushTokenListener(): void {...@@ -765,7 +821,7 @@ function ensurePushTokenListener(): void {
765// foreground/sign-in bursts collapse to one registration, but returning after821// foreground/sign-in bursts collapse to one registration, but returning after
766// real time away still replays.)822// real time away still replays.)
767function ensureAppStateListener(): void {823function ensureAppStateListener(): void {
768 if (appStateSubscription || !canRegisterRemoteLiveActivities()) {824 if (appStateSubscription || !canRegisterPushNotifications()) {
769 return;825 return;
770 }826 }
771827
...@@ -773,6 +829,7 @@ function ensureAppStateListener(): void {...@@ -773,6 +829,7 @@ function ensureAppStateListener(): void {
773 if (state !== "active") {829 if (state !== "active") {
774 return;830 return;
775 }831 }
832 enqueueDeviceRegistration({}, "device registration after app foreground failed");
776 runRegistrationInBackground(833 runRegistrationInBackground(
777 refreshActiveLiveActivityRemoteRegistration(),834 refreshActiveLiveActivityRemoteRegistration(),
778 "active live activity reconciliation after app foreground failed",835 "active live activity reconciliation after app foreground failed",
...@@ -796,7 +853,7 @@ function endLocalLiveActivities(context: string): void {...@@ -796,7 +853,7 @@ function endLocalLiveActivities(context: string): void {
796}853}
797854
798export function registerAgentAwarenessConnection(connection: SavedRemoteConnection): void {855export function registerAgentAwarenessConnection(connection: SavedRemoteConnection): void {
799 if (!canRegisterRemoteLiveActivities()) {856 if (!canRegisterPushNotifications()) {
800 return;857 return;
801 }858 }
802859
...@@ -868,6 +925,7 @@ export function __resetAgentAwarenessRemoteRegistrationForTest(): void {...@@ -868,6 +925,7 @@ export function __resetAgentAwarenessRemoteRegistrationForTest(): void {
868 activeDeviceRegistration = null;925 activeDeviceRegistration = null;
869 pendingDeviceRegistration = null;926 pendingDeviceRegistration = null;
870 registrationStatus = "unknown";927 registrationStatus = "unknown";
928 androidDeviceReplayedAt = null;
871 registrationStatusListeners.clear();929 registrationStatusListeners.clear();
872 registeredActivityPushTokens.clear();930 registeredActivityPushTokens.clear();
873}931}
apps/mobile/src/features/settings/SettingsRouteScreen.logic.test.ts+3-3
...@@ -3,10 +3,10 @@ import { describe, expect, it } from "vite-plus/test";...@@ -3,10 +3,10 @@ import { describe, expect, it } from "vite-plus/test";
3import { resolveAgentAwarenessPlatformPresentation } from "./SettingsRouteScreen.logic";3import { resolveAgentAwarenessPlatformPresentation } from "./SettingsRouteScreen.logic";
44
5describe("resolveAgentAwarenessPlatformPresentation", () => {5describe("resolveAgentAwarenessPlatformPresentation", () => {
6 it("explains that agent awareness settings are unavailable on Android", () => {6 it("supports agent awareness settings on Android", () => {
7 expect(resolveAgentAwarenessPlatformPresentation("android")).toEqual({7 expect(resolveAgentAwarenessPlatformPresentation("android")).toEqual({
8 supported: false,8 supported: true,
9 subtitle: "iOS only",9 subtitle: undefined,
10 });10 });
11 });11 });
1212
apps/mobile/src/features/settings/SettingsRouteScreen.logic.ts+2-2
...@@ -2,7 +2,7 @@ export function resolveAgentAwarenessPlatformPresentation(platform: string): {...@@ -2,7 +2,7 @@ export function resolveAgentAwarenessPlatformPresentation(platform: string): {
2 readonly supported: boolean;2 readonly supported: boolean;
3 readonly subtitle: string | undefined;3 readonly subtitle: string | undefined;
4} {4} {
5 return platform === "ios"5 return platform === "ios" || platform === "android"
6 ? { supported: true, subtitle: undefined }6 ? { supported: true, subtitle: undefined }
7 : { supported: false, subtitle: "iOS only" };7 : { supported: false, subtitle: "Unavailable on this platform" };
8}8}
apps/mobile/src/features/settings/SettingsRouteScreen.tsx+54-20
...@@ -160,6 +160,10 @@ function ConfiguredSettingsRouteScreen() {...@@ -160,6 +160,10 @@ function ConfiguredSettingsRouteScreen() {
160 const savePreferences = useAtomSet(updateMobilePreferencesAtom);160 const savePreferences = useAtomSet(updateMobilePreferencesAtom);
161 const agentAwarenessPushAvailable = supportsAgentAwarenessPush();161 const agentAwarenessPushAvailable = supportsAgentAwarenessPush();
162 const agentAwarenessPlatform = resolveAgentAwarenessPlatformPresentation(Platform.OS);162 const agentAwarenessPlatform = resolveAgentAwarenessPlatformPresentation(Platform.OS);
163 const agentAwarenessSubtitle =
164 Platform.OS === "android" && !agentAwarenessPushAvailable
165 ? "Install a newer app build to enable notifications"
166 : agentAwarenessPlatform.subtitle;
163 const insets = useSafeAreaInsets();167 const insets = useSafeAreaInsets();
164 const navigation = useNavigation();168 const navigation = useNavigation();
165 const { getToken, isLoaded, isSignedIn } = useAuth({ treatPendingAsSignedOut: false });169 const { getToken, isLoaded, isSignedIn } = useAuth({ treatPendingAsSignedOut: false });
...@@ -181,7 +185,7 @@ function ConfiguredSettingsRouteScreen() {...@@ -181,7 +185,7 @@ function ConfiguredSettingsRouteScreen() {
181 }, [isLoaded, isSignedIn, user?.primaryEmailAddress?.emailAddress]);185 }, [isLoaded, isSignedIn, user?.primaryEmailAddress?.emailAddress]);
182186
183 const refreshNotifications = useCallback(async () => {187 const refreshNotifications = useCallback(async () => {
184 if (process.env.EXPO_OS !== "ios") {188 if (Platform.OS !== "ios" && Platform.OS !== "android") {
185 setNotificationStatus("unsupported");189 setNotificationStatus("unsupported");
186 return;190 return;
187 }191 }
...@@ -246,10 +250,7 @@ function ConfiguredSettingsRouteScreen() {...@@ -246,10 +250,7 @@ function ConfiguredSettingsRouteScreen() {
246 // Permission alone is not enough: the switch stays off until the relay250 // Permission alone is not enough: the switch stays off until the relay
247 // registration succeeds, so tell the user the truth about which happened.251 // registration succeeds, so tell the user the truth about which happened.
248 if (getAgentAwarenessRegistrationStatus() === "registered") {252 if (getAgentAwarenessRegistrationStatus() === "registered") {
249 Alert.alert(253 Alert.alert("Notifications enabled", "Agent notifications are enabled for this device.");
250 "Notifications enabled",
251 "Live Activity notifications are enabled for this device.",
252 );
253 } else {254 } else {
254 Alert.alert(255 Alert.alert(
255 "Couldn't finish enabling notifications",256 "Couldn't finish enabling notifications",
...@@ -262,7 +263,7 @@ function ConfiguredSettingsRouteScreen() {...@@ -262,7 +263,7 @@ function ConfiguredSettingsRouteScreen() {
262 setNotificationStatus("unsupported");263 setNotificationStatus("unsupported");
263 Alert.alert(264 Alert.alert(
264 "Notifications unavailable",265 "Notifications unavailable",
265 "Live Activity notifications are only available on iOS.",266 "Agent notifications are unavailable on this platform.",
266 );267 );
267 return;268 return;
268 }269 }
...@@ -302,13 +303,40 @@ function ConfiguredSettingsRouteScreen() {...@@ -302,13 +303,40 @@ function ConfiguredSettingsRouteScreen() {
302 }303 }
303304
304 setLiveActivityStatus("linking");305 setLiveActivityStatus("linking");
306 if (Platform.OS === "android") {
307 const permission = await settleAsyncResult(() =>
308 runtime.runPromiseExit(requestAgentNotificationPermission),
309 );
310 if (permission._tag === "Failure") {
311 setLiveActivityStatus("disabled");
312 const error = squashAtomCommandFailure(permission);
313 Alert.alert(
314 "Ongoing activity unavailable",
315 error instanceof Error ? error.message : "Could not enable agent notifications.",
316 );
317 return;
318 }
319 if (permission.value.type !== "granted") {
320 setLiveActivityStatus("disabled");
321 Alert.alert(
322 "Notification permission needed",
323 "Enable notifications in system Settings to show ongoing agent activity.",
324 [
325 { text: "Cancel", style: "cancel" },
326 { text: "Open Settings", onPress: () => void Linking.openSettings() },
327 ],
328 );
329 return;
330 }
331 setNotificationStatus("enabled");
332 }
305 const tokenResult = await settlePromise(() => getToken(resolveRelayClerkTokenOptions()));333 const tokenResult = await settlePromise(() => getToken(resolveRelayClerkTokenOptions()));
306 if (tokenResult._tag === "Failure") {334 if (tokenResult._tag === "Failure") {
307 setLiveActivityStatus("disabled");335 setLiveActivityStatus("disabled");
308 const error = squashAtomCommandFailure(tokenResult);336 const error = squashAtomCommandFailure(tokenResult);
309 Alert.alert(337 Alert.alert(
310 "Live Activities unavailable",338 Platform.OS === "android" ? "Ongoing activity unavailable" : "Live Activities unavailable",
311 error instanceof Error ? error.message : "Could not enable Live Activity updates.",339 error instanceof Error ? error.message : "Could not enable agent activity updates.",
312 );340 );
313 return;341 return;
314 }342 }
...@@ -333,8 +361,10 @@ function ConfiguredSettingsRouteScreen() {...@@ -333,8 +361,10 @@ function ConfiguredSettingsRouteScreen() {
333 if (!isAtomCommandInterrupted(updateResult)) {361 if (!isAtomCommandInterrupted(updateResult)) {
334 const error = squashAtomCommandFailure(updateResult);362 const error = squashAtomCommandFailure(updateResult);
335 Alert.alert(363 Alert.alert(
336 "Live Activities unavailable",364 Platform.OS === "android"
337 error instanceof Error ? error.message : "Could not enable Live Activity updates.",365 ? "Ongoing activity unavailable"
366 : "Live Activities unavailable",
367 error instanceof Error ? error.message : "Could not enable agent activity updates.",
338 );368 );
339 }369 }
340 return;370 return;
...@@ -348,15 +378,15 @@ function ConfiguredSettingsRouteScreen() {...@@ -348,15 +378,15 @@ function ConfiguredSettingsRouteScreen() {
348 // Activities are live until the device is actually registered.378 // Activities are live until the device is actually registered.
349 if (getAgentAwarenessRegistrationStatus() === "registered") {379 if (getAgentAwarenessRegistrationStatus() === "registered") {
350 Alert.alert(380 Alert.alert(
351 "Live Activities enabled",381 Platform.OS === "android" ? "Ongoing activity enabled" : "Live Activities enabled",
352 environmentCount > 0382 environmentCount > 0
353 ? `${environmentCount} environment${environmentCount === 1 ? "" : "s"} linked for Live Activity updates.`383 ? `${environmentCount} environment${environmentCount === 1 ? "" : "s"} linked for agent activity updates.`
354 : "Live Activity updates are enabled. Add an environment to start receiving updates.",384 : "Agent activity updates are enabled. Add an environment to start receiving updates.",
355 );385 );
356 } else {386 } else {
357 Alert.alert(387 Alert.alert(
358 "Couldn't finish enabling Live Activities",388 "Couldn't finish enabling activity updates",
359 "This device could not be registered with T3 Connect, so Live Activities won't appear yet. They'll start once registration succeeds.",389 "This device could not be registered with T3 Connect, so activity updates won't appear yet. They'll start once registration succeeds.",
360 );390 );
361 }391 }
362 }, [392 }, [
...@@ -372,20 +402,24 @@ function ConfiguredSettingsRouteScreen() {...@@ -372,20 +402,24 @@ function ConfiguredSettingsRouteScreen() {
372 const handleDeviceNotificationsChange = useCallback(402 const handleDeviceNotificationsChange = useCallback(
373 (enabled: boolean) => {403 (enabled: boolean) => {
374 if (enabled) {404 if (enabled) {
405 if (!isSignedIn) {
406 promptSignIn();
407 return;
408 }
375 void requestNotifications();409 void requestNotifications();
376 return;410 return;
377 }411 }
378412
379 Alert.alert(413 Alert.alert(
380 "Disable notifications",414 "Disable notifications",
381 "Notification permission is controlled by iOS. Open Settings to disable notifications for T3 Code.",415 "Open system Settings to disable notifications for T3 Code.",
382 [416 [
383 { text: "Cancel", style: "cancel" },417 { text: "Cancel", style: "cancel" },
384 { text: "Open Settings", onPress: () => void Linking.openSettings() },418 { text: "Open Settings", onPress: () => void Linking.openSettings() },
385 ],419 ],
386 );420 );
387 },421 },
388 [requestNotifications],422 [isSignedIn, promptSignIn, requestNotifications],
389 );423 );
390424
391 const handleLiveActivitiesChange = useCallback(425 const handleLiveActivitiesChange = useCallback(
...@@ -494,7 +528,7 @@ function ConfiguredSettingsRouteScreen() {...@@ -494,7 +528,7 @@ function ConfiguredSettingsRouteScreen() {
494 notificationStatus === "checking" ||528 notificationStatus === "checking" ||
495 notificationStatus === "unsupported"529 notificationStatus === "unsupported"
496 }530 }
497 subtitle={agentAwarenessPlatform.subtitle}531 subtitle={agentAwarenessSubtitle}
498 // Only reads as on when this device is actually registered with the532 // Only reads as on when this device is actually registered with the
499 // relay; otherwise notifications cannot be delivered regardless of533 // relay; otherwise notifications cannot be delivered regardless of
500 // the local iOS permission.534 // the local iOS permission.
...@@ -512,8 +546,8 @@ function ConfiguredSettingsRouteScreen() {...@@ -512,8 +546,8 @@ function ConfiguredSettingsRouteScreen() {
512 liveActivityStatus === "linking"546 liveActivityStatus === "linking"
513 }547 }
514 icon="bolt.circle"548 icon="bolt.circle"
515 label="Live Activity Updates"549 label={Platform.OS === "android" ? "Ongoing Agent Activity" : "Live Activity Updates"}
516 subtitle={agentAwarenessPlatform.subtitle}550 subtitle={agentAwarenessSubtitle}
517 // Same gate: a saved preference is meaningless until the device551 // Same gate: a saved preference is meaningless until the device
518 // registration the relay needs to push updates has succeeded.552 // registration the relay needs to push updates has succeeded.
519 value={553 value={
apps/web/src/components/clerk/MobileClientsUserProfilePage.logic.test.ts+11
...@@ -37,6 +37,14 @@ describe("mobile client presentation", () => {...@@ -37,6 +37,14 @@ describe("mobile client presentation", () => {
37 );37 );
38 });38 });
3939
40 it("labels Android devices without displaying an iOS version", () => {
41 expect(
42 mobileClientPlatformLabel(
43 device({ platform: "android", iosMajorVersion: null, androidApiLevel: 36 }),
44 ),
45 ).toBe("Android · T3 Code 1.2.3");
46 });
47
40 it("distinguishes disabled notifications from an empty event selection", () => {48 it("distinguishes disabled notifications from an empty event selection", () => {
41 expect(49 expect(
42 mobileClientNotificationDetail(50 mobileClientNotificationDetail(
...@@ -59,6 +67,9 @@ describe("mobile client presentation", () => {...@@ -59,6 +67,9 @@ describe("mobile client presentation", () => {
59 });67 });
6068
61 it("handles missing app versions and invalid update timestamps", () => {69 it("handles missing app versions and invalid update timestamps", () => {
70 expect(mobileClientPlatformLabel(device({ iosMajorVersion: null }))).toBe(
71 "iOS · T3 Code 1.2.3",
72 );
62 expect(mobileClientPlatformLabel(device({ appVersion: null }))).toBe("iOS 18");73 expect(mobileClientPlatformLabel(device({ appVersion: null }))).toBe("iOS 18");
63 expect(mobileClientUpdatedAtLabel("not-a-date")).toBe("Update time unavailable");74 expect(mobileClientUpdatedAtLabel("not-a-date")).toBe("Update time unavailable");
64 });75 });
apps/web/src/components/clerk/MobileClientsUserProfilePage.logic.ts+7-1
...@@ -15,7 +15,13 @@ const NOTIFICATION_PREFERENCES = [...@@ -15,7 +15,13 @@ const NOTIFICATION_PREFERENCES = [
15>;15>;
1616
17export function mobileClientPlatformLabel(device: RelayClientDeviceRecord): string {17export function mobileClientPlatformLabel(device: RelayClientDeviceRecord): string {
18 return `iOS ${device.iosMajorVersion}${device.appVersion ? ` · T3 Code ${device.appVersion}` : ""}`;18 const platform =
19 device.platform === "android"
20 ? "Android"
21 : device.iosMajorVersion === null
22 ? "iOS"
23 : `iOS ${device.iosMajorVersion}`;
24 return `${platform}${device.appVersion ? ` · T3 Code ${device.appVersion}` : ""}`;
19}25}
2026
21export function mobileClientNotificationDetail(device: RelayClientDeviceRecord): string {27export function mobileClientNotificationDetail(device: RelayClientDeviceRecord): string {
docs/operations/android-notifications.md created+121
...@@ -0,0 +1,121 @@
1# Android notifications
2
3The 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.
4
5## Android compatibility and automated checks
6
7The 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.
8
9API 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.
10
11The 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:
12
13```sh
14./gradlew :t3-agent-notifications:testDebugUnitTest :t3-agent-notifications:lintRelease -Pandroid.lint.useK2Uast=false
15```
16
17Robolectric'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.
18
19The 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.
20
21## Firebase and app build
22
231. 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`.
242. 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.
253. 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.
264. 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.
275. 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.
28
29For a local development build, from `apps/mobile`:
30
31```sh
32APP_VARIANT=development \
33T3CODE_ANDROID_GOOGLE_SERVICES_FILE=/absolute/path/google-services.json \
34vp run android:dev
35```
36
37For 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).
38
39Set `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.
40
41## Clerk sign-in for private builds
42
43Clerk's native Android sign-in uses `clerk://<applicationId>.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:
44
45```text
46clerk://com.t3tools.t3code.dev.callback
47```
48
49The 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.
50
51Using 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.
52
53Building 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.
54
55## Focused delivery check
56
57`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.
58
59Provide 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`:
60
61```sh
62vp run push:android:smoke /path/service-account.json /path/device.json running
63vp run push:android:smoke /path/service-account.json /path/device.json approval
64vp run push:android:smoke /path/service-account.json /path/device.json completed
65```
66
67Supported 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.
68
69Android 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.
70
71With 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.
72
73Verify 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.
74
75After Android prebuild, run the native presentation regression tests from `apps/mobile/android`:
76
77```sh
78./gradlew :t3-agent-notifications:testDebugUnitTest --tests expo.modules.t3agentnotifications.AgentNotificationsTest
79```
80
81## Relay deployment
82
83### Local verification with existing T3 services
84
85You 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.
86
87Create 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`:
88
89```sh
90vp run push:android:watch /path/service-account.json /path/device.json /path/connection.json
91```
92
93The 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.
94
95### Hosted delivery
96
97The 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.
98
99#### Personal stage in the existing deployment accounts
100
101A 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.
102
1031. 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.
1042. 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.
1053. From the repository root, inspect the deployment plan, then deploy the same stage:
106
107 ```sh
108 vp run --filter t3code-relay deploy --stage dev_ryan_android --env-file .env.android-dev --dry-run
109 vp run --filter t3code-relay deploy --stage dev_ryan_android --env-file .env.android-dev
110 ```
111
1124. 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.
1135. 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.
114
115The 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.
116
117Build 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.
118
119For 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.
120
121Android 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.
docs/operations/connect-setup.md+12
...@@ -74,6 +74,18 @@ Development uses `t3code-dev://app`; production uses `t3code://app`. Update the...@@ -74,6 +74,18 @@ Development uses `t3code-dev://app`; production uses `t3code://app`. Update the
74The Clerk Electron integration handles token74The Clerk Electron integration handles token
75persistence and system-browser callback delivery.75persistence and system-browser callback delivery.
7676
77## Android native sign-in redirects
78
79Clerk's native Android SDK uses `clerk://<applicationId>.callback`. In the Clerk instance selected by the app's publishable key, add each supported package to **Native applications > Allowlist for mobile SSO redirect**:
80
81| Variant | Callback |
82| ----------- | --------------------------------------------- |
83| Development | `clerk://com.t3tools.t3code.dev.callback` |
84| Preview | `clerk://com.t3tools.t3code.preview.callback` |
85| Production | `clerk://com.t3tools.t3code.callback` |
86
87Preserve 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.
88
77## Desktop passkeys89## Desktop passkeys
7890
79For a production macOS app with bundle ID `com.t3tools.t3code`:91For a production macOS app with bundle ID `com.t3tools.t3code`:
docs/user/mobile-notifications.md created+11
...@@ -0,0 +1,11 @@
1# Mobile notifications
2
3Sign 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.
4
5Enable **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.
6
7Ordinary 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.
8
9Android 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.
10
11Notification 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.
infra/relay/.env.example+6-1
...@@ -16,7 +16,8 @@ CLERK_PUBLISHABLE_KEY=pk_test_......@@ -16,7 +16,8 @@ CLERK_PUBLISHABLE_KEY=pk_test_...
16CLERK_SECRET_KEY=sk_test_...16CLERK_SECRET_KEY=sk_test_...
17CLERK_JWT_AUDIENCE=t3-code-relay17CLERK_JWT_AUDIENCE=t3-code-relay
1818
19# Required: Apple Push Notification service19# Apple Push Notification service (required unless APNS_ENABLED=false)
20# Set APNS_ENABLED=false for an Android-only development relay.
20# Get these values from your Apple Developer account. Use `sandbox` for21# Get these values from your Apple Developer account. Use `sandbox` for
21# development APNs credentials and `production` for production credentials.22# development APNs credentials and `production` for production credentials.
22APNS_ENVIRONMENT=sandbox23APNS_ENVIRONMENT=sandbox
...@@ -24,3 +25,7 @@ APNS_TEAM_ID=......@@ -24,3 +25,7 @@ APNS_TEAM_ID=...
24APNS_KEY_ID=...25APNS_KEY_ID=...
25APNS_BUNDLE_ID=...26APNS_BUNDLE_ID=...
26APNS_PRIVATE_KEY=...27APNS_PRIVATE_KEY=...
28
29# Optional: Android push. Set this secret to the service account JSON from
30# Firebase Project settings > Service accounts. Never put it in an app build.
31# FCM_SERVICE_ACCOUNT={...}
infra/relay/README.md+5-3
...@@ -20,7 +20,7 @@ The relay currently owns:...@@ -20,7 +20,7 @@ The relay currently owns:
20- Provisioning and tracking managed environment endpoints.20- Provisioning and tracking managed environment endpoints.
21- Issuing short-lived credentials used to connect clients to linked environments.21- Issuing short-lived credentials used to connect clients to linked environments.
22- Listing linked environments and registered mobile devices for an account.22- Listing linked environments and registered mobile devices for an account.
23- Registering mobile notification preferences and APNs tokens.23- Registering mobile notification preferences and APNs or FCM tokens.
24- Receiving published agent activity and delivering notifications or Live Activity updates.24- Receiving published agent activity and delivering notifications or Live Activity updates.
25- Persisting relay state and exposing relay-specific traces for diagnostics.25- Persisting relay state and exposing relay-specific traces for diagnostics.
2626
...@@ -37,7 +37,7 @@ credential, or authorization behavior....@@ -37,7 +37,7 @@ credential, or authorization behavior.
37- [`src/environments`](./src/environments) contains environment linking, credentials, endpoint37- [`src/environments`](./src/environments) contains environment linking, credentials, endpoint
38 provisioning, and connection flows.38 provisioning, and connection flows.
39- [`src/agentActivity`](./src/agentActivity) contains mobile device registration, activity state,39- [`src/agentActivity`](./src/agentActivity) contains mobile device registration, activity state,
40 APNs delivery, and queue processing.40 APNs and FCM delivery, and queue processing.
41- [`src/auth`](./src/auth) contains relay token and DPoP proof handling.41- [`src/auth`](./src/auth) contains relay token and DPoP proof handling.
42- [`src/persistence/schema.ts`](./src/persistence/schema.ts) defines persisted relay state. Keep42- [`src/persistence/schema.ts`](./src/persistence/schema.ts) defines persisted relay state. Keep
43 schema and migration changes together.43 schema and migration changes together.
...@@ -85,7 +85,8 @@ vp run --filter t3code-relay deploy...@@ -85,7 +85,8 @@ vp run --filter t3code-relay deploy
85The stack provisions the Cloudflare Worker and queues, managed endpoint resources, database85The stack provisions the Cloudflare Worker and queues, managed endpoint resources, database
86connectivity, and relay tracing resources. Copy [`infra/relay/.env.example`](./.env.example) to86connectivity, and relay tracing resources. Copy [`infra/relay/.env.example`](./.env.example) to
87`infra/relay/.env` and fill in the deployment-specific values before deploying. Alchemy loads that87`infra/relay/.env` and fill in the deployment-specific values before deploying. Alchemy loads that
88file from the relay directory. Runtime secrets include Clerk and APNs credentials. Production adopts88file from the relay directory. Runtime secrets include Clerk, APNs, and optional FCM credentials. Set
89`APNS_ENABLED=false` for an Android-only development deployment without Apple credentials. Production adopts
89the configured API and tunnel DNS zones as retained Cloudflare resources. Personal stages reference90the configured API and tunnel DNS zones as retained Cloudflare resources. Personal stages reference
90the production-owned zones.91the production-owned zones.
9192
...@@ -150,6 +151,7 @@ The `production` GitHub environment must define these Actions secrets:...@@ -150,6 +151,7 @@ The `production` GitHub environment must define these Actions secrets:
150151
151- `CLERK_SECRET_KEY`152- `CLERK_SECRET_KEY`
152- `APNS_PRIVATE_KEY`153- `APNS_PRIVATE_KEY`
154- `FCM_SERVICE_ACCOUNT` when Android push is enabled
153155
154The account-scoped repository credentials are consumed by Alchemy while provisioning relay stages; they156The account-scoped repository credentials are consumed by Alchemy while provisioning relay stages; they
155are not bound into the relay Worker. The production deployment uses an Axiom personal access token,157are not bound into the relay Worker. The production deployment uses an Axiom personal access token,
infra/relay/migrations/postgres/20260906042516_android_devices/migration.sql created+2
...@@ -0,0 +1,2 @@
1ALTER TABLE "relay_mobile_devices" ADD COLUMN "android_api_level" integer;--> statement-breakpoint
2ALTER TABLE "relay_mobile_devices" ALTER COLUMN "ios_major_version" DROP NOT NULL;
\ No newline at end of file
infra/relay/migrations/postgres/20260906042516_android_devices/snapshot.json created+1516
...@@ -0,0 +1,1516 @@
1{
2 "version": "8",
3 "dialect": "postgres",
4 "id": "1109ed23-036c-4c39-a459-52422a7ffd99",
5 "prevIds": ["2374caff-40bf-423c-9255-55e76dddbc2a"],
6 "ddl": [
7 {
8 "isRlsEnabled": false,
9 "name": "relay_agent_activity_rows",
10 "entityType": "tables",
11 "schema": "public"
12 },
13 {
14 "isRlsEnabled": false,
15 "name": "relay_delivery_attempts",
16 "entityType": "tables",
17 "schema": "public"
18 },
19 {
20 "isRlsEnabled": false,
21 "name": "relay_dpop_proofs",
22 "entityType": "tables",
23 "schema": "public"
24 },
25 {
26 "isRlsEnabled": false,
27 "name": "relay_environment_credentials",
28 "entityType": "tables",
29 "schema": "public"
30 },
31 {
32 "isRlsEnabled": false,
33 "name": "relay_environment_links",
34 "entityType": "tables",
35 "schema": "public"
36 },
37 {
38 "isRlsEnabled": false,
39 "name": "relay_live_activities",
40 "entityType": "tables",
41 "schema": "public"
42 },
43 {
44 "isRlsEnabled": false,
45 "name": "relay_managed_endpoint_allocations",
46 "entityType": "tables",
47 "schema": "public"
48 },
49 {
50 "isRlsEnabled": false,
51 "name": "relay_managed_tunnel_limits",
52 "entityType": "tables",
53 "schema": "public"
54 },
55 {
56 "isRlsEnabled": false,
57 "name": "relay_mobile_devices",
58 "entityType": "tables",
59 "schema": "public"
60 },
61 {
62 "type": "varchar(191)",
63 "typeSchema": null,
64 "notNull": true,
65 "dimensions": 0,
66 "default": null,
67 "generated": null,
68 "identity": null,
69 "name": "environment_id",
70 "entityType": "columns",
71 "schema": "public",
72 "table": "relay_agent_activity_rows"
73 },
74 {
75 "type": "text",
76 "typeSchema": null,
77 "notNull": true,
78 "dimensions": 0,
79 "default": null,
80 "generated": null,
81 "identity": null,
82 "name": "environment_public_key",
83 "entityType": "columns",
84 "schema": "public",
85 "table": "relay_agent_activity_rows"
86 },
87 {
88 "type": "varchar(191)",
89 "typeSchema": null,
90 "notNull": true,
91 "dimensions": 0,
92 "default": null,
93 "generated": null,
94 "identity": null,
95 "name": "thread_id",
96 "entityType": "columns",
97 "schema": "public",
98 "table": "relay_agent_activity_rows"
99 },
100 {
101 "type": "jsonb",
102 "typeSchema": null,
103 "notNull": true,
104 "dimensions": 0,
105 "default": null,
106 "generated": null,
107 "identity": null,
108 "name": "state_json",
109 "entityType": "columns",
110 "schema": "public",
111 "table": "relay_agent_activity_rows"
112 },
113 {
114 "type": "varchar(64)",
115 "typeSchema": null,
116 "notNull": true,
117 "dimensions": 0,
118 "default": null,
119 "generated": null,
120 "identity": null,
121 "name": "updated_at",
122 "entityType": "columns",
123 "schema": "public",
124 "table": "relay_agent_activity_rows"
125 },
126 {
127 "type": "varchar(64)",
128 "typeSchema": null,
129 "notNull": true,
130 "dimensions": 0,
131 "default": null,
132 "generated": null,
133 "identity": null,
134 "name": "created_at",
135 "entityType": "columns",
136 "schema": "public",
137 "table": "relay_agent_activity_rows"
138 },
139 {
140 "type": "varchar(36)",
141 "typeSchema": null,
142 "notNull": true,
143 "dimensions": 0,
144 "default": null,
145 "generated": null,
146 "identity": null,
147 "name": "id",
148 "entityType": "columns",
149 "schema": "public",
150 "table": "relay_delivery_attempts"
151 },
152 {
153 "type": "varchar(64)",
154 "typeSchema": null,
155 "notNull": true,
156 "dimensions": 0,
157 "default": null,
158 "generated": null,
159 "identity": null,
160 "name": "created_at",
161 "entityType": "columns",
162 "schema": "public",
163 "table": "relay_delivery_attempts"
164 },
165 {
166 "type": "varchar(255)",
167 "typeSchema": null,
168 "notNull": false,
169 "dimensions": 0,
170 "default": null,
171 "generated": null,
172 "identity": null,
173 "name": "user_id",
174 "entityType": "columns",
175 "schema": "public",
176 "table": "relay_delivery_attempts"
177 },
178 {
179 "type": "varchar(191)",
180 "typeSchema": null,
181 "notNull": false,
182 "dimensions": 0,
183 "default": null,
184 "generated": null,
185 "identity": null,
186 "name": "environment_id",
187 "entityType": "columns",
188 "schema": "public",
189 "table": "relay_delivery_attempts"
190 },
191 {
192 "type": "varchar(191)",
193 "typeSchema": null,
194 "notNull": false,
195 "dimensions": 0,
196 "default": null,
197 "generated": null,
198 "identity": null,
199 "name": "thread_id",
200 "entityType": "columns",
201 "schema": "public",
202 "table": "relay_delivery_attempts"
203 },
204 {
205 "type": "varchar(255)",
206 "typeSchema": null,
207 "notNull": false,
208 "dimensions": 0,
209 "default": null,
210 "generated": null,
211 "identity": null,
212 "name": "device_id",
213 "entityType": "columns",
214 "schema": "public",
215 "table": "relay_delivery_attempts"
216 },
217 {
218 "type": "varchar(64)",
219 "typeSchema": null,
220 "notNull": true,
221 "dimensions": 0,
222 "default": null,
223 "generated": null,
224 "identity": null,
225 "name": "kind",
226 "entityType": "columns",
227 "schema": "public",
228 "table": "relay_delivery_attempts"
229 },
230 {
231 "type": "varchar(64)",
232 "typeSchema": null,
233 "notNull": false,
234 "dimensions": 0,
235 "default": null,
236 "generated": null,
237 "identity": null,
238 "name": "source_job_id",
239 "entityType": "columns",
240 "schema": "public",
241 "table": "relay_delivery_attempts"
242 },
243 {
244 "type": "varchar(16)",
245 "typeSchema": null,
246 "notNull": false,
247 "dimensions": 0,
248 "default": null,
249 "generated": null,
250 "identity": null,
251 "name": "token_suffix",
252 "entityType": "columns",
253 "schema": "public",
254 "table": "relay_delivery_attempts"
255 },
256 {
257 "type": "integer",
258 "typeSchema": null,
259 "notNull": false,
260 "dimensions": 0,
261 "default": null,
262 "generated": null,
263 "identity": null,
264 "name": "apns_status",
265 "entityType": "columns",
266 "schema": "public",
267 "table": "relay_delivery_attempts"
268 },
269 {
270 "type": "text",
271 "typeSchema": null,
272 "notNull": false,
273 "dimensions": 0,
274 "default": null,
275 "generated": null,
276 "identity": null,
277 "name": "apns_reason",
278 "entityType": "columns",
279 "schema": "public",
280 "table": "relay_delivery_attempts"
281 },
282 {
283 "type": "varchar(128)",
284 "typeSchema": null,
285 "notNull": false,
286 "dimensions": 0,
287 "default": null,
288 "generated": null,
289 "identity": null,
290 "name": "apns_id",
291 "entityType": "columns",
292 "schema": "public",
293 "table": "relay_delivery_attempts"
294 },
295 {
296 "type": "text",
297 "typeSchema": null,
298 "notNull": false,
299 "dimensions": 0,
300 "default": null,
301 "generated": null,
302 "identity": null,
303 "name": "transport_error",
304 "entityType": "columns",
305 "schema": "public",
306 "table": "relay_delivery_attempts"
307 },
308 {
309 "type": "varchar(128)",
310 "typeSchema": null,
311 "notNull": true,
312 "dimensions": 0,
313 "default": null,
314 "generated": null,
315 "identity": null,
316 "name": "thumbprint",
317 "entityType": "columns",
318 "schema": "public",
319 "table": "relay_dpop_proofs"
320 },
321 {
322 "type": "varchar(255)",
323 "typeSchema": null,
324 "notNull": true,
325 "dimensions": 0,
326 "default": null,
327 "generated": null,
328 "identity": null,
329 "name": "jti",
330 "entityType": "columns",
331 "schema": "public",
332 "table": "relay_dpop_proofs"
333 },
334 {
335 "type": "integer",
336 "typeSchema": null,
337 "notNull": true,
338 "dimensions": 0,
339 "default": null,
340 "generated": null,
341 "identity": null,
342 "name": "iat",
343 "entityType": "columns",
344 "schema": "public",
345 "table": "relay_dpop_proofs"
346 },
347 {
348 "type": "varchar(64)",
349 "typeSchema": null,
350 "notNull": true,
351 "dimensions": 0,
352 "default": null,
353 "generated": null,
354 "identity": null,
355 "name": "expires_at",
356 "entityType": "columns",
357 "schema": "public",
358 "table": "relay_dpop_proofs"
359 },
360 {
361 "type": "varchar(64)",
362 "typeSchema": null,
363 "notNull": true,
364 "dimensions": 0,
365 "default": null,
366 "generated": null,
367 "identity": null,
368 "name": "created_at",
369 "entityType": "columns",
370 "schema": "public",
371 "table": "relay_dpop_proofs"
372 },
373 {
374 "type": "varchar(64)",
375 "typeSchema": null,
376 "notNull": true,
377 "dimensions": 0,
378 "default": null,
379 "generated": null,
380 "identity": null,
381 "name": "credential_id",
382 "entityType": "columns",
383 "schema": "public",
384 "table": "relay_environment_credentials"
385 },
386 {
387 "type": "varchar(191)",
388 "typeSchema": null,
389 "notNull": true,
390 "dimensions": 0,
391 "default": null,
392 "generated": null,
393 "identity": null,
394 "name": "environment_id",
395 "entityType": "columns",
396 "schema": "public",
397 "table": "relay_environment_credentials"
398 },
399 {
400 "type": "text",
401 "typeSchema": null,
402 "notNull": true,
403 "dimensions": 0,
404 "default": null,
405 "generated": null,
406 "identity": null,
407 "name": "environment_public_key",
408 "entityType": "columns",
409 "schema": "public",
410 "table": "relay_environment_credentials"
411 },
412 {
413 "type": "varchar(191)",
414 "typeSchema": null,
415 "notNull": true,
416 "dimensions": 0,
417 "default": null,
418 "generated": null,
419 "identity": null,
420 "name": "credential_hash",
421 "entityType": "columns",
422 "schema": "public",
423 "table": "relay_environment_credentials"
424 },
425 {
426 "type": "varchar(64)",
427 "typeSchema": null,
428 "notNull": false,
429 "dimensions": 0,
430 "default": null,
431 "generated": null,
432 "identity": null,
433 "name": "revoked_at",
434 "entityType": "columns",
435 "schema": "public",
436 "table": "relay_environment_credentials"
437 },
438 {
439 "type": "varchar(64)",
440 "typeSchema": null,
441 "notNull": true,
442 "dimensions": 0,
443 "default": null,
444 "generated": null,
445 "identity": null,
446 "name": "created_at",
447 "entityType": "columns",
448 "schema": "public",
449 "table": "relay_environment_credentials"
450 },
451 {
452 "type": "varchar(64)",
453 "typeSchema": null,
454 "notNull": true,
455 "dimensions": 0,
456 "default": null,
457 "generated": null,
458 "identity": null,
459 "name": "updated_at",
460 "entityType": "columns",
461 "schema": "public",
462 "table": "relay_environment_credentials"
463 },
464 {
465 "type": "varchar(191)",
466 "typeSchema": null,
467 "notNull": true,
468 "dimensions": 0,
469 "default": null,
470 "generated": null,
471 "identity": null,
472 "name": "user_id",
473 "entityType": "columns",
474 "schema": "public",
475 "table": "relay_environment_links"
476 },
477 {
478 "type": "varchar(191)",
479 "typeSchema": null,
480 "notNull": true,
481 "dimensions": 0,
482 "default": null,
483 "generated": null,
484 "identity": null,
485 "name": "environment_id",
486 "entityType": "columns",
487 "schema": "public",
488 "table": "relay_environment_links"
489 },
490 {
491 "type": "text",
492 "typeSchema": null,
493 "notNull": true,
494 "dimensions": 0,
495 "default": "'T3 Environment'",
496 "generated": null,
497 "identity": null,
498 "name": "environment_label",
499 "entityType": "columns",
500 "schema": "public",
501 "table": "relay_environment_links"
502 },
503 {
504 "type": "text",
505 "typeSchema": null,
506 "notNull": true,
507 "dimensions": 0,
508 "default": null,
509 "generated": null,
510 "identity": null,
511 "name": "environment_public_key",
512 "entityType": "columns",
513 "schema": "public",
514 "table": "relay_environment_links"
515 },
516 {
517 "type": "text",
518 "typeSchema": null,
519 "notNull": true,
520 "dimensions": 0,
521 "default": null,
522 "generated": null,
523 "identity": null,
524 "name": "endpoint_http_base_url",
525 "entityType": "columns",
526 "schema": "public",
527 "table": "relay_environment_links"
528 },
529 {
530 "type": "text",
531 "typeSchema": null,
532 "notNull": true,
533 "dimensions": 0,
534 "default": null,
535 "generated": null,
536 "identity": null,
537 "name": "endpoint_ws_base_url",
538 "entityType": "columns",
539 "schema": "public",
540 "table": "relay_environment_links"
541 },
542 {
543 "type": "varchar(32)",
544 "typeSchema": null,
545 "notNull": true,
546 "dimensions": 0,
547 "default": null,
548 "generated": null,
549 "identity": null,
550 "name": "endpoint_provider_kind",
551 "entityType": "columns",
552 "schema": "public",
553 "table": "relay_environment_links"
554 },
555 {
556 "type": "boolean",
557 "typeSchema": null,
558 "notNull": true,
559 "dimensions": 0,
560 "default": "true",
561 "generated": null,
562 "identity": null,
563 "name": "notifications_enabled",
564 "entityType": "columns",
565 "schema": "public",
566 "table": "relay_environment_links"
567 },
568 {
569 "type": "boolean",
570 "typeSchema": null,
571 "notNull": true,
572 "dimensions": 0,
573 "default": "true",
574 "generated": null,
575 "identity": null,
576 "name": "live_activities_enabled",
577 "entityType": "columns",
578 "schema": "public",
579 "table": "relay_environment_links"
580 },
581 {
582 "type": "boolean",
583 "typeSchema": null,
584 "notNull": true,
585 "dimensions": 0,
586 "default": "false",
587 "generated": null,
588 "identity": null,
589 "name": "managed_tunnels_enabled",
590 "entityType": "columns",
591 "schema": "public",
592 "table": "relay_environment_links"
593 },
594 {
595 "type": "varchar(191)",
596 "typeSchema": null,
597 "notNull": false,
598 "dimensions": 0,
599 "default": null,
600 "generated": null,
601 "identity": null,
602 "name": "created_by_device_id",
603 "entityType": "columns",
604 "schema": "public",
605 "table": "relay_environment_links"
606 },
607 {
608 "type": "varchar(64)",
609 "typeSchema": null,
610 "notNull": false,
611 "dimensions": 0,
612 "default": null,
613 "generated": null,
614 "identity": null,
615 "name": "revoked_at",
616 "entityType": "columns",
617 "schema": "public",
618 "table": "relay_environment_links"
619 },
620 {
621 "type": "varchar(64)",
622 "typeSchema": null,
623 "notNull": true,
624 "dimensions": 0,
625 "default": null,
626 "generated": null,
627 "identity": null,
628 "name": "created_at",
629 "entityType": "columns",
630 "schema": "public",
631 "table": "relay_environment_links"
632 },
633 {
634 "type": "varchar(64)",
635 "typeSchema": null,
636 "notNull": true,
637 "dimensions": 0,
638 "default": null,
639 "generated": null,
640 "identity": null,
641 "name": "updated_at",
642 "entityType": "columns",
643 "schema": "public",
644 "table": "relay_environment_links"
645 },
646 {
647 "type": "varchar(255)",
648 "typeSchema": null,
649 "notNull": true,
650 "dimensions": 0,
651 "default": null,
652 "generated": null,
653 "identity": null,
654 "name": "user_id",
655 "entityType": "columns",
656 "schema": "public",
657 "table": "relay_live_activities"
658 },
659 {
660 "type": "varchar(255)",
661 "typeSchema": null,
662 "notNull": true,
663 "dimensions": 0,
664 "default": null,
665 "generated": null,
666 "identity": null,
667 "name": "device_id",
668 "entityType": "columns",
669 "schema": "public",
670 "table": "relay_live_activities"
671 },
672 {
673 "type": "text",
674 "typeSchema": null,
675 "notNull": false,
676 "dimensions": 0,
677 "default": null,
678 "generated": null,
679 "identity": null,
680 "name": "activity_push_token",
681 "entityType": "columns",
682 "schema": "public",
683 "table": "relay_live_activities"
684 },
685 {
686 "type": "varchar(64)",
687 "typeSchema": null,
688 "notNull": false,
689 "dimensions": 0,
690 "default": null,
691 "generated": null,
692 "identity": null,
693 "name": "remote_start_queued_at",
694 "entityType": "columns",
695 "schema": "public",
696 "table": "relay_live_activities"
697 },
698 {
699 "type": "varchar(64)",
700 "typeSchema": null,
701 "notNull": false,
702 "dimensions": 0,
703 "default": null,
704 "generated": null,
705 "identity": null,
706 "name": "remote_started_at",
707 "entityType": "columns",
708 "schema": "public",
709 "table": "relay_live_activities"
710 },
711 {
712 "type": "varchar(64)",
713 "typeSchema": null,
714 "notNull": false,
715 "dimensions": 0,
716 "default": null,
717 "generated": null,
718 "identity": null,
719 "name": "ended_at",
720 "entityType": "columns",
721 "schema": "public",
722 "table": "relay_live_activities"
723 },
724 {
725 "type": "jsonb",
726 "typeSchema": null,
727 "notNull": false,
728 "dimensions": 0,
729 "default": null,
730 "generated": null,
731 "identity": null,
732 "name": "last_aggregate_json",
733 "entityType": "columns",
734 "schema": "public",
735 "table": "relay_live_activities"
736 },
737 {
738 "type": "varchar(64)",
739 "typeSchema": null,
740 "notNull": false,
741 "dimensions": 0,
742 "default": null,
743 "generated": null,
744 "identity": null,
745 "name": "last_live_activity_delivery_at",
746 "entityType": "columns",
747 "schema": "public",
748 "table": "relay_live_activities"
749 },
750 {
751 "type": "varchar(64)",
752 "typeSchema": null,
753 "notNull": true,
754 "dimensions": 0,
755 "default": null,
756 "generated": null,
757 "identity": null,
758 "name": "created_at",
759 "entityType": "columns",
760 "schema": "public",
761 "table": "relay_live_activities"
762 },
763 {
764 "type": "varchar(64)",
765 "typeSchema": null,
766 "notNull": true,
767 "dimensions": 0,
768 "default": null,
769 "generated": null,
770 "identity": null,
771 "name": "updated_at",
772 "entityType": "columns",
773 "schema": "public",
774 "table": "relay_live_activities"
775 },
776 {
777 "type": "varchar(191)",
778 "typeSchema": null,
779 "notNull": true,
780 "dimensions": 0,
781 "default": null,
782 "generated": null,
783 "identity": null,
784 "name": "user_id",
785 "entityType": "columns",
786 "schema": "public",
787 "table": "relay_managed_endpoint_allocations"
788 },
789 {
790 "type": "varchar(191)",
791 "typeSchema": null,
792 "notNull": true,
793 "dimensions": 0,
794 "default": null,
795 "generated": null,
796 "identity": null,
797 "name": "environment_id",
798 "entityType": "columns",
799 "schema": "public",
800 "table": "relay_managed_endpoint_allocations"
801 },
802 {
803 "type": "text",
804 "typeSchema": null,
805 "notNull": true,
806 "dimensions": 0,
807 "default": null,
808 "generated": null,
809 "identity": null,
810 "name": "hostname",
811 "entityType": "columns",
812 "schema": "public",
813 "table": "relay_managed_endpoint_allocations"
814 },
815 {
816 "type": "varchar(191)",
817 "typeSchema": null,
818 "notNull": false,
819 "dimensions": 0,
820 "default": null,
821 "generated": null,
822 "identity": null,
823 "name": "tunnel_id",
824 "entityType": "columns",
825 "schema": "public",
826 "table": "relay_managed_endpoint_allocations"
827 },
828 {
829 "type": "text",
830 "typeSchema": null,
831 "notNull": true,
832 "dimensions": 0,
833 "default": null,
834 "generated": null,
835 "identity": null,
836 "name": "tunnel_name",
837 "entityType": "columns",
838 "schema": "public",
839 "table": "relay_managed_endpoint_allocations"
840 },
841 {
842 "type": "varchar(191)",
843 "typeSchema": null,
844 "notNull": false,
845 "dimensions": 0,
846 "default": null,
847 "generated": null,
848 "identity": null,
849 "name": "dns_record_id",
850 "entityType": "columns",
851 "schema": "public",
852 "table": "relay_managed_endpoint_allocations"
853 },
854 {
855 "type": "varchar(64)",
856 "typeSchema": null,
857 "notNull": false,
858 "dimensions": 0,
859 "default": null,
860 "generated": null,
861 "identity": null,
862 "name": "ready_at",
863 "entityType": "columns",
864 "schema": "public",
865 "table": "relay_managed_endpoint_allocations"
866 },
867 {
868 "type": "varchar(64)",
869 "typeSchema": null,
870 "notNull": true,
871 "dimensions": 0,
872 "default": null,
873 "generated": null,
874 "identity": null,
875 "name": "created_at",
876 "entityType": "columns",
877 "schema": "public",
878 "table": "relay_managed_endpoint_allocations"
879 },
880 {
881 "type": "varchar(64)",
882 "typeSchema": null,
883 "notNull": true,
884 "dimensions": 0,
885 "default": null,
886 "generated": null,
887 "identity": null,
888 "name": "updated_at",
889 "entityType": "columns",
890 "schema": "public",
891 "table": "relay_managed_endpoint_allocations"
892 },
893 {
894 "type": "varchar(191)",
895 "typeSchema": null,
896 "notNull": true,
897 "dimensions": 0,
898 "default": null,
899 "generated": null,
900 "identity": null,
901 "name": "user_id",
902 "entityType": "columns",
903 "schema": "public",
904 "table": "relay_managed_tunnel_limits"
905 },
906 {
907 "type": "integer",
908 "typeSchema": null,
909 "notNull": true,
910 "dimensions": 0,
911 "default": null,
912 "generated": null,
913 "identity": null,
914 "name": "max_tunnels",
915 "entityType": "columns",
916 "schema": "public",
917 "table": "relay_managed_tunnel_limits"
918 },
919 {
920 "type": "varchar(64)",
921 "typeSchema": null,
922 "notNull": true,
923 "dimensions": 0,
924 "default": null,
925 "generated": null,
926 "identity": null,
927 "name": "created_at",
928 "entityType": "columns",
929 "schema": "public",
930 "table": "relay_managed_tunnel_limits"
931 },
932 {
933 "type": "varchar(64)",
934 "typeSchema": null,
935 "notNull": true,
936 "dimensions": 0,
937 "default": null,
938 "generated": null,
939 "identity": null,
940 "name": "updated_at",
941 "entityType": "columns",
942 "schema": "public",
943 "table": "relay_managed_tunnel_limits"
944 },
945 {
946 "type": "varchar(255)",
947 "typeSchema": null,
948 "notNull": true,
949 "dimensions": 0,
950 "default": null,
951 "generated": null,
952 "identity": null,
953 "name": "user_id",
954 "entityType": "columns",
955 "schema": "public",
956 "table": "relay_mobile_devices"
957 },
958 {
959 "type": "varchar(255)",
960 "typeSchema": null,
961 "notNull": true,
962 "dimensions": 0,
963 "default": null,
964 "generated": null,
965 "identity": null,
966 "name": "device_id",
967 "entityType": "columns",
968 "schema": "public",
969 "table": "relay_mobile_devices"
970 },
971 {
972 "type": "text",
973 "typeSchema": null,
974 "notNull": true,
975 "dimensions": 0,
976 "default": "'iOS device'",
977 "generated": null,
978 "identity": null,
979 "name": "label",
980 "entityType": "columns",
981 "schema": "public",
982 "table": "relay_mobile_devices"
983 },
984 {
985 "type": "varchar(16)",
986 "typeSchema": null,
987 "notNull": true,
988 "dimensions": 0,
989 "default": null,
990 "generated": null,
991 "identity": null,
992 "name": "platform",
993 "entityType": "columns",
994 "schema": "public",
995 "table": "relay_mobile_devices"
996 },
997 {
998 "type": "integer",
999 "typeSchema": null,
1000 "notNull": false,
1001 "dimensions": 0,
1002 "default": null,
1003 "generated": null,
1004 "identity": null,
1005 "name": "ios_major_version",
1006 "entityType": "columns",
1007 "schema": "public",
1008 "table": "relay_mobile_devices"
1009 },
1010 {
1011 "type": "integer",
1012 "typeSchema": null,
1013 "notNull": false,
1014 "dimensions": 0,
1015 "default": null,
1016 "generated": null,
1017 "identity": null,
1018 "name": "android_api_level",
1019 "entityType": "columns",
1020 "schema": "public",
1021 "table": "relay_mobile_devices"
1022 },
1023 {
1024 "type": "varchar(64)",
1025 "typeSchema": null,
1026 "notNull": false,
1027 "dimensions": 0,
1028 "default": null,
1029 "generated": null,
1030 "identity": null,
1031 "name": "app_version",
1032 "entityType": "columns",
1033 "schema": "public",
1034 "table": "relay_mobile_devices"
1035 },
1036 {
1037 "type": "varchar(255)",
1038 "typeSchema": null,
1039 "notNull": false,
1040 "dimensions": 0,
1041 "default": null,
1042 "generated": null,
1043 "identity": null,
1044 "name": "bundle_id",
1045 "entityType": "columns",
1046 "schema": "public",
1047 "table": "relay_mobile_devices"
1048 },
1049 {
1050 "type": "varchar(16)",
1051 "typeSchema": null,
1052 "notNull": false,
1053 "dimensions": 0,
1054 "default": null,
1055 "generated": null,
1056 "identity": null,
1057 "name": "aps_environment",
1058 "entityType": "columns",
1059 "schema": "public",
1060 "table": "relay_mobile_devices"
1061 },
1062 {
1063 "type": "text",
1064 "typeSchema": null,
1065 "notNull": false,
1066 "dimensions": 0,
1067 "default": null,
1068 "generated": null,
1069 "identity": null,
1070 "name": "push_token",
1071 "entityType": "columns",
1072 "schema": "public",
1073 "table": "relay_mobile_devices"
1074 },
1075 {
1076 "type": "text",
1077 "typeSchema": null,
1078 "notNull": false,
1079 "dimensions": 0,
1080 "default": null,
1081 "generated": null,
1082 "identity": null,
1083 "name": "push_to_start_token",
1084 "entityType": "columns",
1085 "schema": "public",
1086 "table": "relay_mobile_devices"
1087 },
1088 {
1089 "type": "jsonb",
1090 "typeSchema": null,
1091 "notNull": true,
1092 "dimensions": 0,
1093 "default": null,
1094 "generated": null,
1095 "identity": null,
1096 "name": "preferences_json",
1097 "entityType": "columns",
1098 "schema": "public",
1099 "table": "relay_mobile_devices"
1100 },
1101 {
1102 "type": "varchar(64)",
1103 "typeSchema": null,
1104 "notNull": true,
1105 "dimensions": 0,
1106 "default": null,
1107 "generated": null,
1108 "identity": null,
1109 "name": "created_at",
1110 "entityType": "columns",
1111 "schema": "public",
1112 "table": "relay_mobile_devices"
1113 },
1114 {
1115 "type": "varchar(64)",
1116 "typeSchema": null,
1117 "notNull": true,
1118 "dimensions": 0,
1119 "default": null,
1120 "generated": null,
1121 "identity": null,
1122 "name": "updated_at",
1123 "entityType": "columns",
1124 "schema": "public",
1125 "table": "relay_mobile_devices"
1126 },
1127 {
1128 "nameExplicit": true,
1129 "columns": [
1130 {
1131 "value": "updated_at",
1132 "isExpression": false,
1133 "asc": true,
1134 "nullsFirst": false,
1135 "opclass": null
1136 }
1137 ],
1138 "isUnique": false,
1139 "where": null,
1140 "with": "",
1141 "method": "btree",
1142 "concurrently": false,
1143 "name": "idx_relay_agent_activity_rows_updated",
1144 "entityType": "indexes",
1145 "schema": "public",
1146 "table": "relay_agent_activity_rows"
1147 },
1148 {
1149 "nameExplicit": true,
1150 "columns": [
1151 {
1152 "value": "environment_id",
1153 "isExpression": false,
1154 "asc": true,
1155 "nullsFirst": false,
1156 "opclass": null
1157 },
1158 {
1159 "value": "thread_id",
1160 "isExpression": false,
1161 "asc": true,
1162 "nullsFirst": false,
1163 "opclass": null
1164 },
1165 {
1166 "value": "created_at",
1167 "isExpression": false,
1168 "asc": true,
1169 "nullsFirst": false,
1170 "opclass": null
1171 }
1172 ],
1173 "isUnique": false,
1174 "where": null,
1175 "with": "",
1176 "method": "btree",
1177 "concurrently": false,
1178 "name": "idx_relay_delivery_attempts_environment",
1179 "entityType": "indexes",
1180 "schema": "public",
1181 "table": "relay_delivery_attempts"
1182 },
1183 {
1184 "nameExplicit": true,
1185 "columns": [
1186 {
1187 "value": "source_job_id",
1188 "isExpression": false,
1189 "asc": true,
1190 "nullsFirst": false,
1191 "opclass": null
1192 }
1193 ],
1194 "isUnique": true,
1195 "where": null,
1196 "with": "",
1197 "method": "btree",
1198 "concurrently": false,
1199 "name": "idx_relay_delivery_attempts_source_job",
1200 "entityType": "indexes",
1201 "schema": "public",
1202 "table": "relay_delivery_attempts"
1203 },
1204 {
1205 "nameExplicit": true,
1206 "columns": [
1207 {
1208 "value": "expires_at",
1209 "isExpression": false,
1210 "asc": true,
1211 "nullsFirst": false,
1212 "opclass": null
1213 }
1214 ],
1215 "isUnique": false,
1216 "where": null,
1217 "with": "",
1218 "method": "btree",
1219 "concurrently": false,
1220 "name": "idx_relay_dpop_proofs_expires_at",
1221 "entityType": "indexes",
1222 "schema": "public",
1223 "table": "relay_dpop_proofs"
1224 },
1225 {
1226 "nameExplicit": true,
1227 "columns": [
1228 {
1229 "value": "credential_hash",
1230 "isExpression": false,
1231 "asc": true,
1232 "nullsFirst": false,
1233 "opclass": null
1234 }
1235 ],
1236 "isUnique": true,
1237 "where": null,
1238 "with": "",
1239 "method": "btree",
1240 "concurrently": false,
1241 "name": "idx_relay_environment_credentials_hash",
1242 "entityType": "indexes",
1243 "schema": "public",
1244 "table": "relay_environment_credentials"
1245 },
1246 {
1247 "nameExplicit": true,
1248 "columns": [
1249 {
1250 "value": "environment_id",
1251 "isExpression": false,
1252 "asc": true,
1253 "nullsFirst": false,
1254 "opclass": null
1255 },
1256 {
1257 "value": "revoked_at",
1258 "isExpression": false,
1259 "asc": true,
1260 "nullsFirst": false,
1261 "opclass": null
1262 }
1263 ],
1264 "isUnique": false,
1265 "where": null,
1266 "with": "",
1267 "method": "btree",
1268 "concurrently": false,
1269 "name": "idx_relay_environment_credentials_environment",
1270 "entityType": "indexes",
1271 "schema": "public",
1272 "table": "relay_environment_credentials"
1273 },
1274 {
1275 "nameExplicit": true,
1276 "columns": [
1277 {
1278 "value": "environment_id",
1279 "isExpression": false,
1280 "asc": true,
1281 "nullsFirst": false,
1282 "opclass": null
1283 },
1284 {
1285 "value": "environment_public_key",
1286 "isExpression": false,
1287 "asc": true,
1288 "nullsFirst": false,
1289 "opclass": null
1290 },
1291 {
1292 "value": "revoked_at",
1293 "isExpression": false,
1294 "asc": true,
1295 "nullsFirst": false,
1296 "opclass": null
1297 }
1298 ],
1299 "isUnique": false,
1300 "where": null,
1301 "with": "",
1302 "method": "btree",
1303 "concurrently": false,
1304 "name": "idx_relay_environment_credentials_environment_key",
1305 "entityType": "indexes",
1306 "schema": "public",
1307 "table": "relay_environment_credentials"
1308 },
1309 {
1310 "nameExplicit": true,
1311 "columns": [
1312 {
1313 "value": "environment_id",
1314 "isExpression": false,
1315 "asc": true,
1316 "nullsFirst": false,
1317 "opclass": null
1318 },
1319 {
1320 "value": "revoked_at",
1321 "isExpression": false,
1322 "asc": true,
1323 "nullsFirst": false,
1324 "opclass": null
1325 }
1326 ],
1327 "isUnique": false,
1328 "where": null,
1329 "with": "",
1330 "method": "btree",
1331 "concurrently": false,
1332 "name": "idx_relay_environment_links_environment",
1333 "entityType": "indexes",
1334 "schema": "public",
1335 "table": "relay_environment_links"
1336 },
1337 {
1338 "nameExplicit": true,
1339 "columns": [
1340 {
1341 "value": "activity_push_token",
1342 "isExpression": false,
1343 "asc": true,
1344 "nullsFirst": false,
1345 "opclass": null
1346 }
1347 ],
1348 "isUnique": true,
1349 "where": null,
1350 "with": "",
1351 "method": "btree",
1352 "concurrently": false,
1353 "name": "idx_relay_live_activities_activity_push_token",
1354 "entityType": "indexes",
1355 "schema": "public",
1356 "table": "relay_live_activities"
1357 },
1358 {
1359 "nameExplicit": true,
1360 "columns": [
1361 {
1362 "value": "hostname",
1363 "isExpression": false,
1364 "asc": true,
1365 "nullsFirst": false,
1366 "opclass": null
1367 }
1368 ],
1369 "isUnique": true,
1370 "where": null,
1371 "with": "",
1372 "method": "btree",
1373 "concurrently": false,
1374 "name": "idx_relay_managed_endpoint_allocations_hostname",
1375 "entityType": "indexes",
1376 "schema": "public",
1377 "table": "relay_managed_endpoint_allocations"
1378 },
1379 {
1380 "nameExplicit": true,
1381 "columns": [
1382 {
1383 "value": "tunnel_name",
1384 "isExpression": false,
1385 "asc": true,
1386 "nullsFirst": false,
1387 "opclass": null
1388 }
1389 ],
1390 "isUnique": true,
1391 "where": null,
1392 "with": "",
1393 "method": "btree",
1394 "concurrently": false,
1395 "name": "idx_relay_managed_endpoint_allocations_tunnel_name",
1396 "entityType": "indexes",
1397 "schema": "public",
1398 "table": "relay_managed_endpoint_allocations"
1399 },
1400 {
1401 "nameExplicit": true,
1402 "columns": [
1403 {
1404 "value": "push_token",
1405 "isExpression": false,
1406 "asc": true,
1407 "nullsFirst": false,
1408 "opclass": null
1409 }
1410 ],
1411 "isUnique": true,
1412 "where": null,
1413 "with": "",
1414 "method": "btree",
1415 "concurrently": false,
1416 "name": "idx_relay_mobile_devices_push_token",
1417 "entityType": "indexes",
1418 "schema": "public",
1419 "table": "relay_mobile_devices"
1420 },
1421 {
1422 "nameExplicit": true,
1423 "columns": [
1424 {
1425 "value": "push_to_start_token",
1426 "isExpression": false,
1427 "asc": true,
1428 "nullsFirst": false,
1429 "opclass": null
1430 }
1431 ],
1432 "isUnique": true,
1433 "where": null,
1434 "with": "",
1435 "method": "btree",
1436 "concurrently": false,
1437 "name": "idx_relay_mobile_devices_push_to_start_token",
1438 "entityType": "indexes",
1439 "schema": "public",
1440 "table": "relay_mobile_devices"
1441 },
1442 {
1443 "columns": ["environment_id", "environment_public_key", "thread_id"],
1444 "nameExplicit": false,
1445 "name": "relay_agent_activity_rows_pkey",
1446 "entityType": "pks",
1447 "schema": "public",
1448 "table": "relay_agent_activity_rows"
1449 },
1450 {
1451 "columns": ["thumbprint", "jti"],
1452 "nameExplicit": false,
1453 "name": "relay_dpop_proofs_pkey",
1454 "entityType": "pks",
1455 "schema": "public",
1456 "table": "relay_dpop_proofs"
1457 },
1458 {
1459 "columns": ["user_id", "environment_id"],
1460 "nameExplicit": false,
1461 "name": "relay_environment_links_pkey",
1462 "entityType": "pks",
1463 "schema": "public",
1464 "table": "relay_environment_links"
1465 },
1466 {
1467 "columns": ["user_id", "device_id"],
1468 "nameExplicit": false,
1469 "name": "relay_live_activities_pkey",
1470 "entityType": "pks",
1471 "schema": "public",
1472 "table": "relay_live_activities"
1473 },
1474 {
1475 "columns": ["user_id", "environment_id"],
1476 "nameExplicit": false,
1477 "name": "relay_managed_endpoint_allocations_pkey",
1478 "entityType": "pks",
1479 "schema": "public",
1480 "table": "relay_managed_endpoint_allocations"
1481 },
1482 {
1483 "columns": ["user_id", "device_id"],
1484 "nameExplicit": false,
1485 "name": "relay_mobile_devices_pkey",
1486 "entityType": "pks",
1487 "schema": "public",
1488 "table": "relay_mobile_devices"
1489 },
1490 {
1491 "columns": ["id"],
1492 "nameExplicit": false,
1493 "name": "relay_delivery_attempts_pkey",
1494 "schema": "public",
1495 "table": "relay_delivery_attempts",
1496 "entityType": "pks"
1497 },
1498 {
1499 "columns": ["credential_id"],
1500 "nameExplicit": false,
1501 "name": "relay_environment_credentials_pkey",
1502 "schema": "public",
1503 "table": "relay_environment_credentials",
1504 "entityType": "pks"
1505 },
1506 {
1507 "columns": ["user_id"],
1508 "nameExplicit": false,
1509 "name": "relay_managed_tunnel_limits_pkey",
1510 "schema": "public",
1511 "table": "relay_managed_tunnel_limits",
1512 "entityType": "pks"
1513 }
1514 ],
1515 "renames": []
1516}
infra/relay/package.json+2
...@@ -4,6 +4,8 @@...@@ -4,6 +4,8 @@
4 "type": "module",4 "type": "module",
5 "scripts": {5 "scripts": {
6 "deploy": "node -- scripts/deploy.ts",6 "deploy": "node -- scripts/deploy.ts",
7 "push:android:smoke": "node scripts/android-push-smoke.ts",
8 "push:android:watch": "node scripts/android-push-watch.ts",
7 "destroy": "alchemy destroy",9 "destroy": "alchemy destroy",
8 "test": "vp test run",10 "test": "vp test run",
9 "typecheck": "tsc --noEmit"11 "typecheck": "tsc --noEmit"
infra/relay/scripts/android-push-smoke.ts created+152
...@@ -0,0 +1,152 @@
1// @effect-diagnostics nodeBuiltinImport:off - This developer command reads local credential files.
2import * as NodeFSP from "node:fs/promises";
3import * as NodeRuntime from "@effect/platform-node/NodeRuntime";
4import * as Effect from "effect/Effect";
5import * as Clock from "effect/Clock";
6import * as Layer from "effect/Layer";
7import * as Redacted from "effect/Redacted";
8import * as Schema from "effect/Schema";
9import * as FetchHttpClient from "effect/unstable/http/FetchHttpClient";
10
11import * as RelayConfiguration from "../src/Config.ts";
12import * as WebCrypto from "../src/WebCrypto.ts";
13import * as FcmAssertionSigner from "../src/agentActivity/FcmAssertionSigner.ts";
14import * as FcmClient from "../src/agentActivity/FcmClient.ts";
15
16const Device = Schema.Struct({
17 token: Schema.NonEmptyString,
18 deviceId: Schema.NonEmptyString,
19 userId: Schema.NonEmptyString,
20 packageName: Schema.NonEmptyString,
21 deepLink: Schema.optional(Schema.NonEmptyString),
22});
23const decodeDevice = Schema.decodeUnknownEffect(Schema.fromJsonString(Device));
24const Phase = Schema.Literals(["running", "approval", "input", "completed", "failed", "end"]);
25const decodePhase = Schema.decodeUnknownEffect(Phase);
26
27class SmokeUsageError extends Schema.TaggedError<SmokeUsageError>()("SmokeUsageError", {}) {
28 override get message() {
29 return "Usage: node scripts/android-push-smoke.ts <service-account.json> <device.json> <running|approval|input|completed|failed|end>";
30 }
31}
32
33class SmokeCredentialReadError extends Schema.TaggedError<SmokeCredentialReadError>()(
34 "SmokeCredentialReadError",
35 { cause: Schema.Defect() },
36) {
37 override get message() {
38 return "Could not read service-account file.";
39 }
40}
41
42class SmokeDeviceReadError extends Schema.TaggedError<SmokeDeviceReadError>()(
43 "SmokeDeviceReadError",
44 { cause: Schema.Defect() },
45) {
46 override get message() {
47 return "Could not read device file.";
48 }
49}
50
51class SmokeUnregisteredDeviceError extends Schema.TaggedError<SmokeUnregisteredDeviceError>()(
52 "SmokeUnregisteredDeviceError",
53 {},
54) {
55 override get message() {
56 return "This device token is no longer registered with Firebase.";
57 }
58}
59
60const main = Effect.gen(function* () {
61 const [credentialPath, devicePath, phaseArg] = process.argv.slice(2);
62 if (!credentialPath || !devicePath || !phaseArg) return yield* new SmokeUsageError({});
63 const phase = yield* decodePhase(phaseArg);
64 const credentials = yield* Effect.tryPromise({
65 try: () => NodeFSP.readFile(credentialPath, "utf8"),
66 catch: (cause) => new SmokeCredentialReadError({ cause }),
67 });
68 const device = yield* Effect.tryPromise({
69 try: () => NodeFSP.readFile(devicePath, "utf8"),
70 catch: (cause) => new SmokeDeviceReadError({ cause }),
71 }).pipe(Effect.flatMap(decodeDevice));
72 const title =
73 phase === "completed"
74 ? "Agent finished"
75 : phase === "failed"
76 ? "Agent failed"
77 : phase === "approval"
78 ? "Approval needed"
79 : phase === "input"
80 ? "Input needed"
81 : null;
82 const active = phase === "running" || phase === "approval" || phase === "input";
83 const now = yield* Clock.currentTimeMillis;
84 const config: RelayConfiguration.RelayConfiguration["Service"] = {
85 relayIssuer: "http://localhost",
86 fcmServiceAccount: Redacted.make(credentials),
87 apns: null,
88 clerkSecretKey: Redacted.make(""),
89 clerkPublishableKey: "",
90 clerkJwtAudience: "",
91 apnsDeliveryJobSigningSecret: Redacted.make(""),
92 cloudMintPrivateKey: Redacted.make(""),
93 cloudMintPublicKey: "",
94 managedEndpointBaseDomain: undefined,
95 managedEndpointNamespace: undefined,
96 };
97 const result = yield* FcmClient.FcmClient.pipe(
98 Effect.flatMap((client) =>
99 client.send({
100 token: device.token,
101 packageName: device.packageName,
102 alert: title !== null,
103 data: {
104 t3_kind: "agent_activity",
105 device_id: device.deviceId,
106 user_id: device.userId,
107 updated_at: String(now),
108 active: String(active),
109 activity_title: active
110 ? "1 active agent"
111 : phase === "failed"
112 ? "Agent work failed"
113 : "Agent work completed",
114 activity_expires_at: String(
115 phase === "end" ? 0 : now + (active ? 2 * 60 * 60 * 1000 : 15 * 60 * 1000),
116 ),
117 activity_body: "Android notification test",
118 activity_path: device.deepLink ?? "/",
119 ...(title
120 ? {
121 alert_id: `smoke-${now}`,
122 alert_title: title,
123 alert_body: "T3 Code Android push test",
124 alert_path: device.deepLink ?? "/",
125 }
126 : {}),
127 },
128 }),
129 ),
130 Effect.provide(
131 FcmClient.layer.pipe(
132 Layer.provide(
133 FcmAssertionSigner.layer.pipe(
134 Layer.provide(Layer.succeed(WebCrypto.WebCrypto, { subtle: globalThis.crypto.subtle })),
135 ),
136 ),
137 Layer.provide(
138 Layer.mergeAll(
139 Layer.succeed(RelayConfiguration.RelayConfiguration, config),
140 FetchHttpClient.layer,
141 ),
142 ),
143 ),
144 ),
145 );
146 if (result.unregistered) return yield* new SmokeUnregisteredDeviceError({});
147 yield* Effect.logInfo(
148 `Firebase accepted the ${phase} notification. Verify delivery on the device.`,
149 );
150});
151
152NodeRuntime.runMain(main);
infra/relay/scripts/android-push-watch.ts created+227
...@@ -0,0 +1,227 @@
1// @effect-diagnostics nodeBuiltinImport:off - Local developer verification reads private credential files.
2import * as NodeFSP from "node:fs/promises";
3import * as NodeRuntime from "@effect/platform-node/NodeRuntime";
4import * as NodeSocket from "@effect/platform-node/NodeSocket";
5import {
6 ORCHESTRATION_WS_METHODS,
7 WS_METHODS,
8 WsRpcGroup,
9 type OrchestrationProjectShell,
10 type OrchestrationThreadShell,
11} from "@t3tools/contracts";
12import type { RelayAgentActivityState } from "@t3tools/contracts/relay";
13import { projectThreadAwareness } from "@t3tools/shared/agentAwareness";
14import * as Cause from "effect/Cause";
15import * as Option from "effect/Option";
16import * as Clock from "effect/Clock";
17import * as Effect from "effect/Effect";
18import * as Layer from "effect/Layer";
19import * as Redacted from "effect/Redacted";
20import * as Schema from "effect/Schema";
21import * as Stream from "effect/Stream";
22import * as FetchHttpClient from "effect/unstable/http/FetchHttpClient";
23import * as RpcClient from "effect/unstable/rpc/RpcClient";
24import * as RpcSerialization from "effect/unstable/rpc/RpcSerialization";
25import * as Socket from "effect/unstable/socket/Socket";
26
27import * as RelayConfiguration from "../src/Config.ts";
28import { androidActivityData, fitFcmData } from "../src/agentActivity/fcmPayloads.ts";
29import * as WebCrypto from "../src/WebCrypto.ts";
30import * as FcmAssertionSigner from "../src/agentActivity/FcmAssertionSigner.ts";
31import * as FcmClient from "../src/agentActivity/FcmClient.ts";
32import * as FcmDeliveries from "../src/agentActivity/FcmDeliveries.ts";
33import { makeAggregateState } from "../src/agentActivity/agentActivityAggregate.ts";
34
35const Device = Schema.Struct({
36 token: Schema.NonEmptyString,
37 deviceId: Schema.NonEmptyString,
38 userId: Schema.NonEmptyString,
39 packageName: Schema.NonEmptyString,
40});
41const Connection = Schema.Struct({
42 wsUrl: Schema.NonEmptyString,
43 bearerToken: Schema.NonEmptyString,
44});
45const readFile = (path: string) => Effect.tryPromise(() => NodeFSP.readFile(path, "utf8"));
46const encodeJson = Schema.encodeSync(Schema.fromJsonString(Schema.Unknown));
47class WatchUnregisteredDeviceError extends Schema.TaggedError<WatchUnregisteredDeviceError>()(
48 "WatchUnregisteredDeviceError",
49 {},
50) {
51 override get message() {
52 return "Device token is no longer registered";
53 }
54}
55
56class WatchStoppedError extends Schema.TaggedError<WatchStoppedError>()("WatchStoppedError", {
57 cause: Schema.Defect(),
58}) {
59 override get message() {
60 return "Android push watcher stopped. Check the private connection and Firebase configuration.";
61 }
62}
63
64const isWatchUnregisteredDeviceError = Schema.is(WatchUnregisteredDeviceError);
65
66const preferences = {
67 notificationsEnabled: true,
68 liveActivitiesEnabled: true,
69 notifyOnApproval: true,
70 notifyOnInput: true,
71 notifyOnCompletion: true,
72 notifyOnFailure: true,
73};
74
75const main = Effect.gen(function* () {
76 const [credentialsPath, devicePath, connectionPath] = process.argv.slice(2);
77 if (!credentialsPath || !devicePath || !connectionPath) {
78 return yield* Effect.logError(
79 "Usage: node scripts/android-push-watch.ts <service-account.json> <device.json> <connection.json>",
80 );
81 }
82 const credentials = yield* readFile(credentialsPath);
83 const device = yield* readFile(devicePath).pipe(
84 Effect.flatMap(Schema.decodeUnknownEffect(Schema.fromJsonString(Device))),
85 );
86 const connection = yield* readFile(connectionPath).pipe(
87 Effect.flatMap(Schema.decodeUnknownEffect(Schema.fromJsonString(Connection))),
88 );
89 const socketConstructor = Layer.succeed(
90 Socket.WebSocketConstructor,
91 (url, protocols) =>
92 new NodeSocket.NodeWS.WebSocket(url, protocols, {
93 headers: { authorization: `Bearer ${connection.bearerToken}` },
94 }) as unknown as globalThis.WebSocket,
95 );
96 const protocol = RpcClient.layerProtocolSocket().pipe(
97 Layer.provide(Socket.layerWebSocket(connection.wsUrl).pipe(Layer.provide(socketConstructor))),
98 Layer.provide(RpcSerialization.layerJson),
99 );
100 const fcm = FcmClient.layer.pipe(
101 Layer.provide(
102 FcmAssertionSigner.layer.pipe(
103 Layer.provide(Layer.succeed(WebCrypto.WebCrypto, { subtle: globalThis.crypto.subtle })),
104 ),
105 ),
106 Layer.provide(
107 Layer.mergeAll(
108 FetchHttpClient.layer,
109 Layer.succeed(RelayConfiguration.RelayConfiguration, {
110 relayIssuer: "http://localhost",
111 apns: null,
112 fcmServiceAccount: Redacted.make(credentials),
113 clerkSecretKey: Redacted.make(""),
114 clerkPublishableKey: "",
115 clerkJwtAudience: "",
116 apnsDeliveryJobSigningSecret: Redacted.make(""),
117 cloudMintPrivateKey: Redacted.make(""),
118 cloudMintPublicKey: "",
119 managedEndpointBaseDomain: undefined,
120 managedEndpointNamespace: undefined,
121 }),
122 ),
123 ),
124 );
125 yield* Effect.gen(function* () {
126 const rpc = yield* RpcClient.make(WsRpcGroup);
127 const sender = yield* FcmClient.FcmClient;
128 const config = yield* rpc[WS_METHODS.serverGetConfig]({});
129 const projects = new Map<string, OrchestrationProjectShell>();
130 const threads = new Map<string, OrchestrationThreadShell>();
131 let states = new Map<string, RelayAgentActivityState>();
132 let previouslyActive = false;
133 yield* Effect.logInfo("Watching this paired environment for Android push verification.");
134 yield* rpc[ORCHESTRATION_WS_METHODS.subscribeShell]({}).pipe(
135 Stream.runForEach(
136 Effect.fnUntraced(function* (item) {
137 switch (item.kind) {
138 case "synchronized":
139 return;
140 case "snapshot":
141 projects.clear();
142 threads.clear();
143 for (const project of item.snapshot.projects) projects.set(project.id, project);
144 for (const thread of item.snapshot.threads) threads.set(thread.id, thread);
145 break;
146 case "project-upserted":
147 projects.set(item.project.id, item.project);
148 break;
149 case "project-removed":
150 projects.delete(item.projectId);
151 break;
152 case "thread-upserted":
153 threads.set(item.thread.id, item.thread);
154 break;
155 case "thread-removed":
156 threads.delete(item.threadId);
157 break;
158 }
159 const next = new Map<string, RelayAgentActivityState>();
160 for (const thread of threads.values()) {
161 const project = projects.get(thread.projectId);
162 if (!project || thread.archivedAt) continue;
163 const state = projectThreadAwareness({
164 environmentId: config.environment.environmentId,
165 project,
166 thread,
167 });
168 if (state) next.set(thread.id, state);
169 }
170 const state = item.kind === "thread-upserted" ? next.get(item.thread.id) : undefined;
171 const previous = state ? states.get(state.threadId) : undefined;
172 // A fresh subscription restores ongoing work without announcing old completions.
173 const now = yield* Clock.currentTimeMillis;
174 const alert =
175 state && state.phase !== previous?.phase && item.kind !== "snapshot"
176 ? FcmDeliveries.androidAlertForState(state, preferences, now)
177 : null;
178 const aggregate = makeAggregateState({
179 activeStates: [...next.values()],
180 terminalState: null,
181 nowMs: now,
182 });
183 const active = (aggregate?.activeCount ?? 0) > 0;
184 const same = encodeJson([...next.values()]) === encodeJson([...states.values()]);
185 states = next;
186 if ((!active && !previouslyActive && !alert) || (same && !alert)) return;
187 const result = yield* sender.send({
188 token: device.token,
189 packageName: device.packageName,
190 alert: alert !== null,
191 data: fitFcmData({
192 t3_kind: "agent_activity",
193 device_id: device.deviceId,
194 user_id: device.userId,
195 updated_at: String(now),
196 ...androidActivityData(aggregate),
197 ...alert,
198 }),
199 });
200 if (result.unregistered) return yield* new WatchUnregisteredDeviceError({});
201 previouslyActive = active;
202 yield* Effect.logInfo(
203 `Android push accepted: ${state?.phase ?? (active ? "active" : "ended")}`,
204 );
205 }),
206 ),
207 );
208 }).pipe(Effect.provide(Layer.mergeAll(protocol, fcm)));
209});
210
211NodeRuntime.runMain(
212 main.pipe(
213 Effect.scoped,
214 Effect.catchCause((cause) => {
215 const failure = Cause.findErrorOption(cause);
216 return Effect.fail(
217 Option.isSome(failure) && isWatchUnregisteredDeviceError(failure.value)
218 ? failure.value
219 : new WatchStoppedError({ cause }),
220 );
221 }),
222 // Socket failures may contain credential-bearing request headers. Keep the
223 // cause on the error, but print only the fixed message at the CLI boundary.
224 Effect.tapError((error) => Effect.logError(error.message)),
225 ),
226 { disableErrorReporting: true },
227);
infra/relay/src/Config.ts+2-1
...@@ -18,7 +18,8 @@ export class RelayConfiguration extends Context.Service<...@@ -18,7 +18,8 @@ export class RelayConfiguration extends Context.Service<
18 RelayConfiguration,18 RelayConfiguration,
19 {19 {
20 readonly relayIssuer: string;20 readonly relayIssuer: string;
21 readonly apns: ApnsCredentials;21 readonly apns: ApnsCredentials | null;
22 readonly fcmServiceAccount?: Redacted.Redacted<string>;
22 readonly clerkSecretKey: Redacted.Redacted<string>;23 readonly clerkSecretKey: Redacted.Redacted<string>;
23 readonly clerkPublishableKey: string;24 readonly clerkPublishableKey: string;
24 readonly clerkJwtAudience: string;25 readonly clerkJwtAudience: string;
infra/relay/src/WebCrypto.ts created+5
...@@ -0,0 +1,5 @@
1import * as Context from "effect/Context";
2
3export class WebCrypto extends Context.Service<WebCrypto, { readonly subtle: SubtleCrypto }>()(
4 "t3code-relay/WebCrypto",
5) {}
infra/relay/src/agentActivity/AgentActivityPublisher.test.ts+73-5
...@@ -7,8 +7,18 @@ import * as AgentActivityRows from "./AgentActivityRows.ts";...@@ -7,8 +7,18 @@ import * as AgentActivityRows from "./AgentActivityRows.ts";
7import * as EnvironmentLinks from "../environments/EnvironmentLinks.ts";7import * as EnvironmentLinks from "../environments/EnvironmentLinks.ts";
8import * as LiveActivities from "./LiveActivities.ts";8import * as LiveActivities from "./LiveActivities.ts";
9import * as AgentActivityPublisher from "./AgentActivityPublisher.ts";9import * as AgentActivityPublisher from "./AgentActivityPublisher.ts";
10import { FcmDeliveries } from "./FcmDeliveries.ts";
10import * as ApnsDeliveries from "./ApnsDeliveries.ts";11import * as ApnsDeliveries from "./ApnsDeliveries.ts";
1112
13const publisherLayer = AgentActivityPublisher.layer.pipe(
14 Layer.provide(
15 Layer.succeed(FcmDeliveries, {
16 enqueue: () => Effect.succeed(null),
17 process: () => Effect.void,
18 }),
19 ),
20);
21
12const state: RelayAgentActivityState = {22const state: RelayAgentActivityState = {
13 environmentId: "env" as RelayAgentActivityState["environmentId"],23 environmentId: "env" as RelayAgentActivityState["environmentId"],
14 threadId: "thread" as RelayAgentActivityState["threadId"],24 threadId: "thread" as RelayAgentActivityState["threadId"],
...@@ -129,6 +139,64 @@ function makeApnsDeliveries(...@@ -129,6 +139,64 @@ function makeApnsDeliveries(
129}139}
130140
131describe("AgentActivityPublisher", () => {141describe("AgentActivityPublisher", () => {
142 it.effect("routes Android publication and registration replay to FCM alongside iOS", () => {
143 const android = { ...target("android"), platform: "android" as const, ios_major_version: null };
144 const ios = target("ios");
145 const fcmCalls: Array<Parameters<FcmDeliveries["Service"]["enqueue"]>[0]> = [];
146 const appleDevices: string[] = [];
147 return Effect.gen(function* () {
148 const publisher = yield* AgentActivityPublisher.AgentActivityPublisher;
149 yield* publisher.publish({
150 environmentId: state.environmentId,
151 environmentPublicKey: "key",
152 threadId: state.threadId,
153 state,
154 });
155 yield* publisher.replayForLiveActivityRegistration({
156 userId: android.user_id,
157 deviceId: android.device_id,
158 });
159 expect(fcmCalls).toEqual([
160 { target: android, state },
161 { target: android, state: null, replay: true },
162 ]);
163 expect(appleDevices).toEqual(["ios"]);
164 }).pipe(
165 Effect.provide(
166 AgentActivityPublisher.layer.pipe(
167 Layer.provide(
168 Layer.mergeAll(
169 Layer.succeed(AgentActivityRows.AgentActivityRows, makeAgentActivityRows()),
170 Layer.succeed(EnvironmentLinks.EnvironmentLinks, makeEnvironmentLinks()),
171 Layer.succeed(
172 LiveActivities.LiveActivities,
173 makeLiveActivities({ listTargets: () => Effect.succeed([android, ios]) }),
174 ),
175 Layer.succeed(
176 ApnsDeliveries.ApnsDeliveries,
177 makeApnsDeliveries({
178 sendForTarget: (input) =>
179 Effect.sync(() => {
180 appleDevices.push(input.target.device_id);
181 return null;
182 }),
183 }),
184 ),
185 Layer.succeed(FcmDeliveries, {
186 enqueue: (input) =>
187 Effect.sync(() => {
188 fcmCalls.push(input);
189 return null;
190 }),
191 process: () => Effect.void,
192 }),
193 ),
194 ),
195 ),
196 ),
197 );
198 });
199
132 it.effect("replays the latest aggregate when a Live Activity token registers", () => {200 it.effect("replays the latest aggregate when a Live Activity token registers", () => {
133 const registeredTarget: LiveActivities.TargetRow = {201 const registeredTarget: LiveActivities.TargetRow = {
134 ...target("device-1"),202 ...target("device-1"),
...@@ -157,7 +225,7 @@ describe("AgentActivityPublisher", () => {...@@ -157,7 +225,7 @@ describe("AgentActivityPublisher", () => {
157 });225 });
158 }).pipe(226 }).pipe(
159 Effect.provide(227 Effect.provide(
160 AgentActivityPublisher.layer.pipe(228 publisherLayer.pipe(
161 Layer.provide(229 Layer.provide(
162 Layer.mergeAll(230 Layer.mergeAll(
163 Layer.succeed(AgentActivityRows.AgentActivityRows, makeAgentActivityRows()),231 Layer.succeed(AgentActivityRows.AgentActivityRows, makeAgentActivityRows()),
...@@ -230,7 +298,7 @@ describe("AgentActivityPublisher", () => {...@@ -230,7 +298,7 @@ describe("AgentActivityPublisher", () => {
230 });298 });
231 }).pipe(299 }).pipe(
232 Effect.provide(300 Effect.provide(
233 AgentActivityPublisher.layer.pipe(301 publisherLayer.pipe(
234 Layer.provide(302 Layer.provide(
235 Layer.mergeAll(303 Layer.mergeAll(
236 Layer.succeed(304 Layer.succeed(
...@@ -324,7 +392,7 @@ describe("AgentActivityPublisher", () => {...@@ -324,7 +392,7 @@ describe("AgentActivityPublisher", () => {
324 });392 });
325 }).pipe(393 }).pipe(
326 Effect.provide(394 Effect.provide(
327 AgentActivityPublisher.layer.pipe(395 publisherLayer.pipe(
328 Layer.provide(396 Layer.provide(
329 Layer.mergeAll(397 Layer.mergeAll(
330 Layer.succeed(398 Layer.succeed(
...@@ -430,7 +498,7 @@ describe("AgentActivityPublisher", () => {...@@ -430,7 +498,7 @@ describe("AgentActivityPublisher", () => {
430 });498 });
431 }).pipe(499 }).pipe(
432 Effect.provide(500 Effect.provide(
433 AgentActivityPublisher.layer.pipe(501 publisherLayer.pipe(
434 Layer.provide(502 Layer.provide(
435 Layer.mergeAll(503 Layer.mergeAll(
436 Layer.succeed(504 Layer.succeed(
...@@ -542,7 +610,7 @@ describe("AgentActivityPublisher", () => {...@@ -542,7 +610,7 @@ describe("AgentActivityPublisher", () => {
542 });610 });
543 }).pipe(611 }).pipe(
544 Effect.provide(612 Effect.provide(
545 AgentActivityPublisher.layer.pipe(613 publisherLayer.pipe(
546 Layer.provide(614 Layer.provide(
547 Layer.mergeAll(615 Layer.mergeAll(
548 Layer.succeed(616 Layer.succeed(
infra/relay/src/agentActivity/AgentActivityPublisher.ts+13-3
...@@ -20,8 +20,10 @@ import * as AgentActivityRows from "./AgentActivityRows.ts";...@@ -20,8 +20,10 @@ import * as AgentActivityRows from "./AgentActivityRows.ts";
20import * as EnvironmentLinks from "../environments/EnvironmentLinks.ts";20import * as EnvironmentLinks from "../environments/EnvironmentLinks.ts";
21import * as LiveActivities from "./LiveActivities.ts";21import * as LiveActivities from "./LiveActivities.ts";
22import * as ApnsDeliveries from "./ApnsDeliveries.ts";22import * as ApnsDeliveries from "./ApnsDeliveries.ts";
23import * as FcmDeliveries from "./FcmDeliveries.ts";
2324
24export type AgentActivityPublishError =25export type AgentActivityPublishError =
26 | FcmDeliveries.FcmDeliveryError
25 | AgentActivityRows.AgentActivityRowUpsertPersistenceError27 | AgentActivityRows.AgentActivityRowUpsertPersistenceError
26 | AgentActivityRows.AgentActivityRowDeletePersistenceError28 | AgentActivityRows.AgentActivityRowDeletePersistenceError
27 | AgentActivityRows.AgentActivityRowListPersistenceError29 | AgentActivityRows.AgentActivityRowListPersistenceError
...@@ -50,6 +52,7 @@ export const make = Effect.gen(function* () {...@@ -50,6 +52,7 @@ export const make = Effect.gen(function* () {
50 const links = yield* EnvironmentLinks.EnvironmentLinks;52 const links = yield* EnvironmentLinks.EnvironmentLinks;
51 const liveActivities = yield* LiveActivities.LiveActivities;53 const liveActivities = yield* LiveActivities.LiveActivities;
52 const apnsDeliveries = yield* ApnsDeliveries.ApnsDeliveries;54 const apnsDeliveries = yield* ApnsDeliveries.ApnsDeliveries;
55 const fcmDeliveries = yield* FcmDeliveries.FcmDeliveries;
5356
54 const publishForDeliveryUser = Effect.fnUntraced(function* (input: {57 const publishForDeliveryUser = Effect.fnUntraced(function* (input: {
55 readonly deliveryUser: EnvironmentLinks.AgentAwarenessDeliveryUserRecord;58 readonly deliveryUser: EnvironmentLinks.AgentAwarenessDeliveryUserRecord;
...@@ -79,8 +82,11 @@ export const make = Effect.gen(function* () {...@@ -79,8 +82,11 @@ export const make = Effect.gen(function* () {
79 const targets = yield* liveActivities.listTargets({ userId: input.deliveryUser.userId });82 const targets = yield* liveActivities.listTargets({ userId: input.deliveryUser.userId });
80 const deliveriesByTarget = yield* Effect.forEach(83 const deliveriesByTarget = yield* Effect.forEach(
81 targets,84 targets,
82 (target) =>85 Effect.fnUntraced(function* (target) {
83 Effect.all(86 if (target.platform === "android") {
87 return [yield* fcmDeliveries.enqueue({ target, state: input.state })];
88 }
89 return yield* Effect.all(
84 [90 [
85 apnsDeliveries.sendForTarget({91 apnsDeliveries.sendForTarget({
86 target,92 target,
...@@ -95,7 +101,8 @@ export const make = Effect.gen(function* () {...@@ -95,7 +101,8 @@ export const make = Effect.gen(function* () {
95 }),101 }),
96 ],102 ],
97 { concurrency: 2 },103 { concurrency: 2 },
98 ),104 );
105 }),
99 { concurrency: 4 },106 { concurrency: 4 },
100 );107 );
101 return deliveriesByTarget.flat();108 return deliveriesByTarget.flat();
...@@ -120,6 +127,9 @@ export const make = Effect.gen(function* () {...@@ -120,6 +127,9 @@ export const make = Effect.gen(function* () {
120 if (target === null) {127 if (target === null) {
121 return null;128 return null;
122 }129 }
130 if (target.platform === "android") {
131 return yield* fcmDeliveries.enqueue({ target, state: null, replay: true });
132 }
123 const now = yield* DateTime.now;133 const now = yield* DateTime.now;
124 const aggregate = makeAggregateState({134 const aggregate = makeAggregateState({
125 activeStates,135 activeStates,
infra/relay/src/agentActivity/ApnsDeliveries.test.ts+43-1
...@@ -63,7 +63,7 @@ const apnsSigningKeyPair = NodeCrypto.generateKeyPairSync("ec", {...@@ -63,7 +63,7 @@ const apnsSigningKeyPair = NodeCrypto.generateKeyPairSync("ec", {
63const signingConfig = RelayConfiguration.RelayConfiguration.of({63const signingConfig = RelayConfiguration.RelayConfiguration.of({
64 ...config,64 ...config,
65 apns: {65 apns: {
66 ...config.apns,66 ...config.apns!,
67 privateKey: Redacted.make(apnsSigningKeyPair.privateKey),67 privateKey: Redacted.make(apnsSigningKeyPair.privateKey),
68 },68 },
69});69});
...@@ -257,6 +257,48 @@ function makeLayer(input: {...@@ -257,6 +257,48 @@ function makeLayer(input: {
257}257}
258258
259describe("ApnsDeliveries", () => {259describe("ApnsDeliveries", () => {
260 it.effect("skips Apple delivery when an Android-only relay disables APNs", () => {
261 const attempts: Array<DeliveryAttempts.DeliveryAttemptInput> = [];
262 const queuedJobs: Array<SignedApnsDeliveryJob> = [];
263 return Effect.gen(function* () {
264 const service = yield* ApnsDeliveries.ApnsDeliveries;
265 expect(yield* service.sendForTarget({ target, aggregate, nowMs: 0 })).toBeNull();
266 expect(yield* service.sendPushNotificationForTarget({ target, aggregate })).toBeNull();
267 expect(
268 yield* service.sendLiveActivity({
269 target,
270 token: "activity-token",
271 kind: "live_activity_update",
272 aggregate,
273 }),
274 ).toMatchObject({ ok: false, apnsReason: "APNs is disabled for this relay." });
275 expect(
276 yield* service.sendPushNotification({
277 target,
278 token: "push-token",
279 notification: {
280 title: "Finished",
281 body: "Thread",
282 environmentId: state.environmentId,
283 threadId: state.threadId,
284 deepLink: state.deepLink,
285 },
286 }),
287 ).toMatchObject({ ok: false, apnsReason: "APNs is disabled for this relay." });
288 expect(queuedJobs).toHaveLength(0);
289 expect(attempts).toHaveLength(0);
290 }).pipe(
291 Effect.provide(
292 makeLayer({
293 attempts,
294 queuedJobs,
295 config: { ...config, apns: null },
296 execute: () => Effect.die("Disabled APNs must not make HTTP requests"),
297 }),
298 ),
299 );
300 });
301
260 it.effect("never starts an activity remotely when no update token is registered", () => {302 it.effect("never starts an activity remotely when no update token is registered", () => {
261 const attempts: Array<DeliveryAttempts.DeliveryAttemptInput> = [];303 const attempts: Array<DeliveryAttempts.DeliveryAttemptInput> = [];
262 const queuedJobs: Array<SignedApnsDeliveryJob> = [];304 const queuedJobs: Array<SignedApnsDeliveryJob> = [];
infra/relay/src/agentActivity/ApnsDeliveries.ts+22
...@@ -676,6 +676,16 @@ export const make = Effect.gen(function* () {...@@ -676,6 +676,16 @@ export const make = Effect.gen(function* () {
676 const sendLiveActivity: ApnsDeliveries["Service"]["sendLiveActivity"] = Effect.fn(676 const sendLiveActivity: ApnsDeliveries["Service"]["sendLiveActivity"] = Effect.fn(
677 "relay.apns_deliveries.send_live_activity",677 "relay.apns_deliveries.send_live_activity",
678 )(function* (input) {678 )(function* (input) {
679 if (!config.apns) {
680 return {
681 deviceId: input.target.device_id,
682 kind: input.kind,
683 ok: false,
684 apnsStatus: null,
685 apnsReason: "APNs is disabled for this relay.",
686 apnsId: null,
687 };
688 }
679 yield* Effect.annotateCurrentSpan({689 yield* Effect.annotateCurrentSpan({
680 "relay.mobile.device_id": input.target.device_id,690 "relay.mobile.device_id": input.target.device_id,
681 "relay.delivery.kind": input.kind,691 "relay.delivery.kind": input.kind,
...@@ -842,6 +852,16 @@ export const make = Effect.gen(function* () {...@@ -842,6 +852,16 @@ export const make = Effect.gen(function* () {
842 const sendPushNotification: ApnsDeliveries["Service"]["sendPushNotification"] = Effect.fn(852 const sendPushNotification: ApnsDeliveries["Service"]["sendPushNotification"] = Effect.fn(
843 "relay.apns_deliveries.send_push_notification",853 "relay.apns_deliveries.send_push_notification",
844 )(function* (input) {854 )(function* (input) {
855 if (!config.apns) {
856 return {
857 deviceId: input.target.device_id,
858 kind: "push_notification",
859 ok: false,
860 apnsStatus: null,
861 apnsReason: "APNs is disabled for this relay.",
862 apnsId: null,
863 };
864 }
845 yield* Effect.annotateCurrentSpan({865 yield* Effect.annotateCurrentSpan({
846 "relay.mobile.device_id": input.target.device_id,866 "relay.mobile.device_id": input.target.device_id,
847 "relay.delivery.kind": "push_notification",867 "relay.delivery.kind": "push_notification",
...@@ -1081,6 +1101,7 @@ export const make = Effect.gen(function* () {...@@ -1081,6 +1101,7 @@ export const make = Effect.gen(function* () {
1081 sendPushNotification,1101 sendPushNotification,
1082 processSignedJob,1102 processSignedJob,
1083 sendPushNotificationForTarget: Effect.fnUntraced(function* (input) {1103 sendPushNotificationForTarget: Effect.fnUntraced(function* (input) {
1104 if (!config.apns) return null;
1084 const now = yield* DateTime.now;1105 const now = yield* DateTime.now;
1085 const notification = notificationForAggregate({1106 const notification = notificationForAggregate({
1086 target: input.target,1107 target: input.target,
...@@ -1100,6 +1121,7 @@ export const make = Effect.gen(function* () {...@@ -1100,6 +1121,7 @@ export const make = Effect.gen(function* () {
1100 : Effect.succeed(null);1121 : Effect.succeed(null);
1101 }),1122 }),
1102 sendForTarget: Effect.fnUntraced(function* (input) {1123 sendForTarget: Effect.fnUntraced(function* (input) {
1124 if (!config.apns) return null;
1103 const delivery = chooseDelivery({1125 const delivery = chooseDelivery({
1104 target: input.target,1126 target: input.target,
1105 aggregate: input.aggregate,1127 aggregate: input.aggregate,
infra/relay/src/agentActivity/Devices.ts+6-2
...@@ -128,7 +128,8 @@ export const make = Effect.gen(function* () {...@@ -128,7 +128,8 @@ export const make = Effect.gen(function* () {
128 deviceId: registration.deviceId,128 deviceId: registration.deviceId,
129 label: registration.label,129 label: registration.label,
130 platform: registration.platform,130 platform: registration.platform,
131 iosMajorVersion: registration.iosMajorVersion,131 iosMajorVersion: registration.iosMajorVersion ?? null,
132 androidApiLevel: registration.androidApiLevel ?? null,
132 appVersion: registration.appVersion ?? null,133 appVersion: registration.appVersion ?? null,
133 bundleId: registration.bundleId ?? null,134 bundleId: registration.bundleId ?? null,
134 apsEnvironment: registration.apsEnvironment ?? null,135 apsEnvironment: registration.apsEnvironment ?? null,
...@@ -143,7 +144,8 @@ export const make = Effect.gen(function* () {...@@ -143,7 +144,8 @@ export const make = Effect.gen(function* () {
143 set: {144 set: {
144 platform: registration.platform,145 platform: registration.platform,
145 label: registration.label,146 label: registration.label,
146 iosMajorVersion: registration.iosMajorVersion,147 iosMajorVersion: registration.iosMajorVersion ?? null,
148 androidApiLevel: registration.androidApiLevel ?? null,
147 appVersion: registration.appVersion ?? null,149 appVersion: registration.appVersion ?? null,
148 // Preserve routing from newer app builds when an older build150 // Preserve routing from newer app builds when an older build
149 // re-registers without these fields.151 // re-registers without these fields.
...@@ -225,6 +227,7 @@ export const make = Effect.gen(function* () {...@@ -225,6 +227,7 @@ export const make = Effect.gen(function* () {
225 label: relayMobileDevices.label,227 label: relayMobileDevices.label,
226 platform: relayMobileDevices.platform,228 platform: relayMobileDevices.platform,
227 iosMajorVersion: relayMobileDevices.iosMajorVersion,229 iosMajorVersion: relayMobileDevices.iosMajorVersion,
230 androidApiLevel: relayMobileDevices.androidApiLevel,
228 appVersion: relayMobileDevices.appVersion,231 appVersion: relayMobileDevices.appVersion,
229 preferences: relayMobileDevices.preferencesJson,232 preferences: relayMobileDevices.preferencesJson,
230 updatedAt: relayMobileDevices.updatedAt,233 updatedAt: relayMobileDevices.updatedAt,
...@@ -241,6 +244,7 @@ export const make = Effect.gen(function* () {...@@ -241,6 +244,7 @@ export const make = Effect.gen(function* () {
241 label: row.label,244 label: row.label,
242 platform: row.platform,245 platform: row.platform,
243 iosMajorVersion: row.iosMajorVersion,246 iosMajorVersion: row.iosMajorVersion,
247 androidApiLevel: row.androidApiLevel,
244 appVersion: row.appVersion,248 appVersion: row.appVersion,
245 notifications: {249 notifications: {
246 enabled: row.preferences.notificationsEnabled,250 enabled: row.preferences.notificationsEnabled,
infra/relay/src/agentActivity/FcmAssertionSigner.ts created+76
...@@ -0,0 +1,76 @@
1import * as Context from "effect/Context";
2import * as Effect from "effect/Effect";
3import * as Layer from "effect/Layer";
4import * as Schema from "effect/Schema";
5
6import * as WebCrypto from "../WebCrypto.ts";
7
8const encodeJson = Schema.encodeSync(Schema.fromJsonString(Schema.Unknown));
9
10export class FcmAssertionSigningError extends Schema.TaggedError<FcmAssertionSigningError>()(
11 "FcmAssertionSigningError",
12 { cause: Schema.Defect() },
13) {
14 override get message() {
15 return "Failed to sign Firebase authorization assertion.";
16 }
17}
18
19function base64Url(bytes: Uint8Array): string {
20 return btoa(String.fromCharCode(...bytes))
21 .replaceAll("+", "-")
22 .replaceAll("/", "_")
23 .replace(/=+$/, "");
24}
25
26export class FcmAssertionSigner extends Context.Service<
27 FcmAssertionSigner,
28 {
29 readonly sign: (input: {
30 readonly privateKey: string;
31 readonly clientEmail: string;
32 readonly issuedAt: number;
33 }) => Effect.Effect<string, FcmAssertionSigningError>;
34 }
35>()("t3code-relay/agentActivity/FcmAssertionSigner") {}
36
37export const make = Effect.gen(function* () {
38 const { subtle } = yield* WebCrypto.WebCrypto;
39 return FcmAssertionSigner.of({
40 sign: Effect.fn("relay.fcm.assertion")(function* (input) {
41 return yield* Effect.tryPromise({
42 try: async () => {
43 const encoder = new TextEncoder();
44 const header = base64Url(encoder.encode(encodeJson({ alg: "RS256", typ: "JWT" })));
45 const claims = base64Url(
46 encoder.encode(
47 encodeJson({
48 iss: input.clientEmail,
49 scope: "https://www.googleapis.com/auth/firebase.messaging",
50 aud: "https://oauth2.googleapis.com/token",
51 iat: input.issuedAt,
52 exp: input.issuedAt + 3600,
53 }),
54 ),
55 );
56 const pem = input.privateKey.replace(/-----[^-]+-----|\s/g, "");
57 const key = await subtle.importKey(
58 "pkcs8",
59 Uint8Array.from(atob(pem), (c) => c.charCodeAt(0)),
60 { name: "RSASSA-PKCS1-v1_5", hash: "SHA-256" },
61 false,
62 ["sign"],
63 );
64 const signature = await subtle.sign(
65 "RSASSA-PKCS1-v1_5",
66 key,
67 encoder.encode(`${header}.${claims}`),
68 );
69 return `${header}.${claims}.${base64Url(new Uint8Array(signature))}`;
70 },
71 catch: (cause) => new FcmAssertionSigningError({ cause }),
72 });
73 }),
74 });
75});
76export const layer = Layer.effect(FcmAssertionSigner, make);
infra/relay/src/agentActivity/FcmClient.test.ts created+254
...@@ -0,0 +1,254 @@
1import * as NodeCrypto from "node:crypto";
2import { describe, expect, it } from "@effect/vitest";
3import * as Deferred from "effect/Deferred";
4import * as Effect from "effect/Effect";
5import * as Fiber from "effect/Fiber";
6import * as Layer from "effect/Layer";
7import * as Redacted from "effect/Redacted";
8import * as Schema from "effect/Schema";
9import * as TestClock from "effect/testing/TestClock";
10import * as HttpClient from "effect/unstable/http/HttpClient";
11import * as HttpClientResponse from "effect/unstable/http/HttpClientResponse";
12import type * as HttpClientRequest from "effect/unstable/http/HttpClientRequest";
13
14import { RelayConfiguration } from "../Config.ts";
15import { FcmClient, layer } from "./FcmClient.ts";
16
17import * as WebCrypto from "../WebCrypto.ts";
18import * as FcmAssertionSigner from "./FcmAssertionSigner.ts";
19
20const { privateKey, publicKey } = NodeCrypto.generateKeyPairSync("rsa", {
21 modulusLength: 2048,
22 privateKeyEncoding: { type: "pkcs8", format: "pem" },
23 publicKeyEncoding: { type: "spki", format: "pem" },
24});
25const account = {
26 project_id: "test-project",
27 client_email: "push@test-project.iam.gserviceaccount.com",
28 private_key: privateKey,
29};
30const encodeJson = Schema.encodeSync(Schema.fromJsonString(Schema.Unknown));
31const decodeJson = Schema.decodeUnknownSync(Schema.fromJsonString(Schema.Unknown));
32const config = {
33 relayIssuer: "https://relay.test",
34 apns: {
35 environment: "sandbox",
36 teamId: "team",
37 keyId: "key",
38 bundleId: "app",
39 privateKey: Redacted.make("unused"),
40 },
41 fcmServiceAccount: Redacted.make(encodeJson(account)),
42 clerkSecretKey: Redacted.make("unused"),
43 clerkPublishableKey: "unused",
44 clerkJwtAudience: "unused",
45 apnsDeliveryJobSigningSecret: Redacted.make("unused"),
46 cloudMintPrivateKey: Redacted.make("unused"),
47 cloudMintPublicKey: "unused",
48 managedEndpointBaseDomain: undefined,
49 managedEndpointNamespace: undefined,
50} satisfies RelayConfiguration["Service"];
51const input = {
52 token: "device-token",
53 packageName: "com.t3tools.t3code.dev",
54 data: { t3_kind: "agent_activity", active: "true" },
55 alert: false,
56};
57
58function testLayer(requests: HttpClientRequest.HttpClientRequest[], responses: Response[]) {
59 const http = HttpClient.make((request) => {
60 requests.push(request);
61 const response = responses.shift();
62 return response
63 ? Effect.succeed(HttpClientResponse.fromWeb(request, response))
64 : Effect.die("unexpected request");
65 });
66 return layer.pipe(
67 Layer.provide(
68 FcmAssertionSigner.layer.pipe(
69 Layer.provide(Layer.succeed(WebCrypto.WebCrypto, { subtle: globalThis.crypto.subtle })),
70 ),
71 ),
72 Layer.provide(
73 Layer.mergeAll(
74 Layer.succeed(RelayConfiguration, config),
75 Layer.succeed(HttpClient.HttpClient, http),
76 ),
77 ),
78 );
79}
80
81describe("FCM delivery", () => {
82 it.effect.each([
83 { operation: "authorize", stage: "headers", status: null },
84 { operation: "authorize", stage: "body", status: 200 },
85 { operation: "send", stage: "headers", status: null },
86 { operation: "send", stage: "body", status: 503 },
87 ] as const)("bounds a stalled $operation $stage and allows the next delivery", (scenario) =>
88 Effect.gen(function* () {
89 const started = yield* Deferred.make<void>();
90 let shouldStall = true;
91 const http = HttpClient.make((request) => {
92 const isAuthorization = request.url === "https://oauth2.googleapis.com/token";
93 const stall = shouldStall && isAuthorization === (scenario.operation === "authorize");
94 const response = HttpClientResponse.fromWeb(
95 request,
96 isAuthorization
97 ? Response.json({ access_token: "access-token" })
98 : Response.json({}, { status: stall ? 503 : 200 }),
99 );
100 if (stall) {
101 shouldStall = false;
102 const stalled = Deferred.succeed(started, undefined).pipe(Effect.andThen(Effect.never));
103 if (scenario.stage === "headers") return stalled;
104 Object.defineProperty(response, "json", { value: stalled });
105 }
106 return Effect.succeed(response);
107 });
108 yield* Effect.gen(function* () {
109 const client = yield* FcmClient;
110 const delivery = yield* client.send(input).pipe(Effect.flip, Effect.forkChild);
111 yield* Deferred.await(started);
112 yield* TestClock.adjust("10 seconds");
113 expect(yield* Fiber.join(delivery)).toMatchObject({
114 _tag: "FcmClientError",
115 operation: scenario.operation,
116 status: scenario.status,
117 cause: expect.objectContaining({ _tag: "TimeoutError" }),
118 });
119 expect(yield* client.send(input)).toEqual({ unregistered: false });
120 }).pipe(
121 Effect.provide(
122 layer.pipe(
123 Layer.provide(
124 FcmAssertionSigner.layer.pipe(
125 Layer.provide(
126 Layer.succeed(WebCrypto.WebCrypto, { subtle: globalThis.crypto.subtle }),
127 ),
128 ),
129 ),
130 Layer.provide(Layer.succeed(RelayConfiguration, config)),
131 Layer.provide(Layer.succeed(HttpClient.HttpClient, http)),
132 ),
133 ),
134 );
135 }),
136 );
137
138 it.effect("signs a verifiable Google OAuth assertion scoped to messaging", () =>
139 Effect.gen(function* () {
140 const signer = yield* FcmAssertionSigner.FcmAssertionSigner;
141 const assertion = yield* signer.sign({
142 privateKey: account.private_key,
143 clientEmail: account.client_email,
144 issuedAt: 1000,
145 });
146 const [header, claims, signature] = assertion.split(".");
147 expect(
148 NodeCrypto.verify(
149 "RSA-SHA256",
150 Buffer.from(`${header}.${claims}`),
151 publicKey,
152 Buffer.from(signature!, "base64url"),
153 ),
154 ).toBe(true);
155 expect(decodeJson(Buffer.from(claims!, "base64url").toString())).toEqual({
156 iss: account.client_email,
157 scope: "https://www.googleapis.com/auth/firebase.messaging",
158 aud: "https://oauth2.googleapis.com/token",
159 iat: 1000,
160 exp: 4600,
161 });
162 }).pipe(
163 Effect.provide(
164 FcmAssertionSigner.layer.pipe(
165 Layer.provide(Layer.succeed(WebCrypto.WebCrypto, { subtle: globalThis.crypto.subtle })),
166 ),
167 ),
168 ),
169 );
170
171 it.effect(
172 "reuses OAuth authorization and sends native data messages to the correct package",
173 () => {
174 const requests: HttpClientRequest.HttpClientRequest[] = [];
175 return Effect.gen(function* () {
176 const client = yield* FcmClient;
177 yield* client.send(input);
178 yield* client.send({ ...input, alert: true });
179 expect(requests.map((request) => request.url)).toEqual([
180 "https://oauth2.googleapis.com/token",
181 "https://fcm.googleapis.com/v1/projects/test-project/messages:send",
182 "https://fcm.googleapis.com/v1/projects/test-project/messages:send",
183 ]);
184 expect(requests[1]!.headers.authorization).toBe("Bearer access-token");
185 const body = requests[1]!.body;
186 if (body._tag !== "Uint8Array") throw new Error("Expected encoded FCM body");
187 expect(decodeJson(new TextDecoder().decode(body.body))).toEqual({
188 message: {
189 token: input.token,
190 data: input.data,
191 android: {
192 priority: "HIGH",
193 ttl: "300s",
194 collapse_key: "t3-agent-activity",
195 restricted_package_name: input.packageName,
196 },
197 },
198 });
199 }).pipe(
200 Effect.provide(
201 testLayer(requests, [
202 Response.json({ access_token: "access-token" }),
203 Response.json({ name: "one" }),
204 Response.json({ name: "two" }),
205 ]),
206 ),
207 );
208 },
209 );
210
211 it.effect("invalidates authorization after 401 and recognizes unregistered device tokens", () => {
212 const requests: HttpClientRequest.HttpClientRequest[] = [];
213 return Effect.gen(function* () {
214 const client = yield* FcmClient;
215 const first = yield* client.send(input).pipe(Effect.flip);
216 expect(first.status).toBe(401);
217 expect(yield* client.send(input)).toEqual({ unregistered: true });
218 expect(requests[2]!.url).toBe("https://oauth2.googleapis.com/token");
219 expect(requests[3]!.headers.authorization).toBe("Bearer fresh-token");
220 }).pipe(
221 Effect.provide(
222 testLayer(requests, [
223 Response.json({ access_token: "old-token" }),
224 Response.json({}, { status: 401 }),
225 Response.json({ access_token: "fresh-token" }),
226 Response.json(
227 {
228 error: {
229 details: [
230 {
231 "@type": "type.googleapis.com/google.firebase.fcm.v1.FcmError",
232 errorCode: "UNREGISTERED",
233 },
234 ],
235 },
236 },
237 { status: 404 },
238 ),
239 ]),
240 ),
241 );
242 });
243 it.effect("rejects oversized data before contacting Firebase", () => {
244 const requests: HttpClientRequest.HttpClientRequest[] = [];
245 return Effect.gen(function* () {
246 const client = yield* FcmClient;
247 const error = yield* client
248 .send({ ...input, data: { body: "æ¼¢".repeat(1500) } })
249 .pipe(Effect.flip);
250 expect(error.operation).toBe("send");
251 expect(requests).toHaveLength(0);
252 }).pipe(Effect.provide(testLayer(requests, [])));
253 });
254});
infra/relay/src/agentActivity/FcmClient.ts created+166
...@@ -0,0 +1,166 @@
1import * as Context from "effect/Context";
2import * as DateTime from "effect/DateTime";
3import * as Effect from "effect/Effect";
4import * as Layer from "effect/Layer";
5import * as Option from "effect/Option";
6import * as Redacted from "effect/Redacted";
7import * as Schema from "effect/Schema";
8import * as HttpClient from "effect/unstable/http/HttpClient";
9import * as HttpClientRequest from "effect/unstable/http/HttpClientRequest";
10
11import * as RelayConfiguration from "../Config.ts";
12import * as FcmAssertionSigner from "./FcmAssertionSigner.ts";
13
14const FCM_HTTP_STAGE_TIMEOUT = "10 seconds";
15
16const ServiceAccount = Schema.Struct({
17 project_id: Schema.NonEmptyString,
18 client_email: Schema.NonEmptyString,
19 private_key: Schema.NonEmptyString,
20});
21const decodeServiceAccount = Schema.decodeUnknownOption(Schema.fromJsonString(ServiceAccount));
22const decodeAccessToken = Schema.decodeUnknownEffect(
23 Schema.Struct({ access_token: Schema.NonEmptyString }),
24);
25const encodeJson = Schema.encodeSync(Schema.fromJsonString(Schema.Unknown));
26const decodeFcmError = Schema.decodeUnknownOption(
27 Schema.Struct({
28 error: Schema.Struct({
29 status: Schema.optional(Schema.String),
30 details: Schema.optional(
31 Schema.Array(
32 Schema.Struct({
33 "@type": Schema.optional(Schema.String),
34 errorCode: Schema.optional(Schema.String),
35 }),
36 ),
37 ),
38 }),
39 }),
40);
41
42export class FcmClientError extends Schema.TaggedError<FcmClientError>()("FcmClientError", {
43 operation: Schema.Literals(["configuration", "authorize", "send"]),
44 status: Schema.NullOr(Schema.Number),
45 cause: Schema.optional(Schema.Defect()),
46}) {
47 override get message() {
48 return `FCM ${this.operation} failed${this.status === null ? "" : ` (${this.status})`}.`;
49 }
50}
51
52export class FcmClient extends Context.Service<
53 FcmClient,
54 {
55 readonly send: (input: {
56 readonly token: string;
57 readonly packageName: string | null;
58 readonly data: Readonly<Record<string, string>>;
59 readonly alert: boolean;
60 }) => Effect.Effect<{ readonly unregistered: boolean }, FcmClientError>;
61 }
62>()("t3code-relay/agentActivity/FcmClient") {}
63
64export const make = Effect.gen(function* () {
65 const config = yield* RelayConfiguration.RelayConfiguration;
66 const signer = yield* FcmAssertionSigner.FcmAssertionSigner;
67 const client = yield* HttpClient.HttpClient;
68 const account = config.fcmServiceAccount
69 ? decodeServiceAccount(Redacted.value(config.fcmServiceAccount))
70 : Option.none();
71 const authorize = Effect.gen(function* () {
72 if (Option.isNone(account))
73 return yield* new FcmClientError({ operation: "configuration", status: null });
74 const now = yield* DateTime.now;
75 const assertion = yield* signer
76 .sign({
77 privateKey: account.value.private_key,
78 clientEmail: account.value.client_email,
79 issuedAt: Math.floor(now.epochMilliseconds / 1000),
80 })
81 .pipe(
82 Effect.mapError(
83 (cause) => new FcmClientError({ operation: "authorize", status: null, cause }),
84 ),
85 );
86 const response = yield* client
87 .execute(
88 HttpClientRequest.post("https://oauth2.googleapis.com/token").pipe(
89 HttpClientRequest.bodyUrlParams({
90 grant_type: "urn:ietf:params:oauth:grant-type:jwt-bearer",
91 assertion,
92 }),
93 ),
94 )
95 .pipe(
96 Effect.timeout(FCM_HTTP_STAGE_TIMEOUT),
97 Effect.mapError(
98 (cause) => new FcmClientError({ operation: "authorize", status: null, cause }),
99 ),
100 );
101 if (response.status !== 200)
102 return yield* new FcmClientError({ operation: "authorize", status: response.status });
103 return yield* response.json.pipe(
104 Effect.timeout(FCM_HTTP_STAGE_TIMEOUT),
105 Effect.flatMap(decodeAccessToken),
106 Effect.map((body) => body.access_token),
107 Effect.mapError(
108 (cause) => new FcmClientError({ operation: "authorize", status: response.status, cause }),
109 ),
110 );
111 });
112 const [accessToken, invalidateToken] = yield* Effect.cachedInvalidateWithTTL(
113 authorize,
114 "50 minutes",
115 );
116
117 return FcmClient.of({
118 send: Effect.fn("relay.fcm.send")(function* (input) {
119 if (new TextEncoder().encode(encodeJson(input.data)).length > 4096)
120 return yield* new FcmClientError({ operation: "send", status: null });
121 if (Option.isNone(account))
122 return yield* new FcmClientError({ operation: "configuration", status: null });
123 const token = yield* accessToken.pipe(Effect.tapError(() => invalidateToken));
124 const response = yield* HttpClientRequest.post(
125 `https://fcm.googleapis.com/v1/projects/${encodeURIComponent(account.value.project_id)}/messages:send`,
126 ).pipe(
127 HttpClientRequest.bearerToken(token),
128 HttpClientRequest.bodyJson({
129 message: {
130 token: input.token,
131 data: input.data,
132 android: {
133 priority: "HIGH",
134 ttl: "300s",
135 ...(!input.alert ? { collapse_key: "t3-agent-activity" } : {}),
136 ...(input.packageName ? { restricted_package_name: input.packageName } : {}),
137 },
138 },
139 }),
140 Effect.flatMap(client.execute),
141 Effect.timeout(FCM_HTTP_STAGE_TIMEOUT),
142 Effect.mapError((cause) => new FcmClientError({ operation: "send", status: null, cause })),
143 );
144 if (response.status >= 200 && response.status < 300) return { unregistered: false };
145 if (response.status === 401) yield* invalidateToken;
146 const body = yield* response.json.pipe(
147 Effect.timeout(FCM_HTTP_STAGE_TIMEOUT),
148 Effect.mapError(
149 (cause) => new FcmClientError({ operation: "send", status: response.status, cause }),
150 ),
151 );
152 const decoded = decodeFcmError(body);
153 const unregistered =
154 Option.isSome(decoded) &&
155 decoded.value.error.details?.some(
156 (detail) =>
157 detail["@type"] === "type.googleapis.com/google.firebase.fcm.v1.FcmError" &&
158 detail.errorCode === "UNREGISTERED",
159 ) === true;
160 if (unregistered) return { unregistered: true };
161 return yield* new FcmClientError({ operation: "send", status: response.status });
162 }),
163 });
164});
165
166export const layer = Layer.effect(FcmClient, make);
infra/relay/src/agentActivity/FcmDeliveries.test.ts created+836
...@@ -0,0 +1,836 @@
1import { EnvironmentId, ThreadId } from "@t3tools/contracts";
2import type { RelayAgentActivityState } from "@t3tools/contracts/relay";
3import { describe, expect, it } from "@effect/vitest";
4import * as NodeCryptoLayer from "@effect/platform-node/NodeCrypto";
5import * as Effect from "effect/Effect";
6import * as DateTime from "effect/DateTime";
7import * as Layer from "effect/Layer";
8import * as Redacted from "effect/Redacted";
9import * as Schema from "effect/Schema";
10import * as Stream from "effect/Stream";
11import type * as Cloudflare from "alchemy/Cloudflare";
12import * as FcmDeliveryQueueConsumer from "./FcmDeliveryQueueConsumer.ts";
13
14import { RelayConfiguration } from "../Config.ts";
15import { RelayDb } from "../db.ts";
16import { EnvironmentLinks } from "../environments/EnvironmentLinks.ts";
17import { AgentActivityRows } from "./AgentActivityRows.ts";
18import { LiveActivities, type TargetRow } from "./LiveActivities.ts";
19import * as FcmDeliveryQueueSender from "./FcmDeliveryQueueSender.ts";
20import { FcmClient, FcmClientError } from "./FcmClient.ts";
21import {
22 FcmDeliveries,
23 androidAlertForState,
24 androidAlertForAggregate,
25 layer,
26 type FcmDeliveryJob,
27} from "./FcmDeliveries.ts";
28import { TestClock } from "effect/testing";
29import { androidActivityData, fitFcmData } from "./fcmPayloads.ts";
30import { makeAggregateState } from "./agentActivityAggregate.ts";
31
32const aggregateFor = (states: ReadonlyArray<RelayAgentActivityState>) =>
33 makeAggregateState({ activeStates: states, terminalState: null, nowMs: 0 })!;
34
35const encodeJson = Schema.encodeSync(Schema.fromJsonString(Schema.Unknown));
36const state: RelayAgentActivityState = {
37 environmentId: EnvironmentId.make("env"),
38 threadId: ThreadId.make("thread"),
39 projectTitle: "Project",
40 threadTitle: "Fix notifications",
41 phase: "running",
42 headline: "Working",
43 modelTitle: "Codex",
44 updatedAt: "1970-01-01T00:00:00.000Z",
45 deepLink: "/threads/env/thread",
46};
47const preferences = {
48 notificationsEnabled: true,
49 liveActivitiesEnabled: true,
50 notifyOnApproval: true,
51 notifyOnInput: true,
52 notifyOnCompletion: true,
53 notifyOnFailure: true,
54};
55const target: TargetRow = {
56 user_id: "user",
57 device_id: "phone",
58 platform: "android",
59 ios_major_version: null,
60 app_version: null,
61 bundle_id: "com.t3tools.t3code.dev",
62 aps_environment: null,
63 push_token: "fcm-token",
64 push_to_start_token: null,
65 preferences_json: encodeJson(preferences),
66 activity_push_token: null,
67 remote_start_queued_at: null,
68 remote_started_at: null,
69 ended_at: null,
70 last_aggregate_json: null,
71 last_live_activity_delivery_at: null,
72};
73const config = {
74 relayIssuer: "https://relay.test",
75 fcmServiceAccount: Redacted.make("configured"),
76 apns: {
77 environment: "sandbox",
78 teamId: "unused",
79 keyId: "unused",
80 bundleId: "unused",
81 privateKey: Redacted.make("unused"),
82 },
83 clerkSecretKey: Redacted.make("unused"),
84 clerkPublishableKey: "unused",
85 clerkJwtAudience: "unused",
86 apnsDeliveryJobSigningSecret: Redacted.make("unused"),
87 cloudMintPrivateKey: Redacted.make("unused"),
88 cloudMintPublicKey: "unused",
89 managedEndpointBaseDomain: undefined,
90 managedEndpointNamespace: undefined,
91} satisfies RelayConfiguration["Service"];
92
93function harness() {
94 const sent: Array<Parameters<FcmClient["Service"]["send"]>[0]> = [];
95 const queued: FcmDeliveryJob[] = [];
96 const marked: Array<Parameters<LiveActivities["Service"]["markDelivery"]>[0]> = [];
97 const current = {
98 target: { ...target },
99 state: { ...state } as RelayAgentActivityState | null,
100 otherStates: [] as RelayAgentActivityState[],
101 mutedEnvironments: [] as string[],
102 notificationOnlyEnvironments: [] as string[],
103 revokedEnvironments: [] as string[],
104 linked: true,
105 deliveryFailure: null as FcmClientError | null,
106 };
107 const services = Layer.mergeAll(
108 NodeCryptoLayer.layer,
109 Layer.succeed(RelayConfiguration, config),
110 Layer.succeed(FcmDeliveryQueueSender.FcmDeliveryQueueSender, {
111 send: (job) =>
112 Effect.sync(() => {
113 queued.push(job);
114 }),
115 }),
116 Layer.succeed(FcmClient, {
117 send: (input) =>
118 Effect.suspend(() =>
119 current.deliveryFailure
120 ? Effect.fail(current.deliveryFailure)
121 : Effect.sync(() => {
122 sent.push(input);
123 return { unregistered: false };
124 }),
125 ),
126 }),
127 Layer.succeed(LiveActivities, {
128 register: () => Effect.void,
129 listTargets: () => Effect.sync(() => [current.target]),
130 markDelivery: (input) =>
131 Effect.sync(() => {
132 marked.push(input);
133 current.target.last_aggregate_json = input.aggregate ? encodeJson(input.aggregate) : null;
134 }),
135 markStartQueued: () => Effect.void,
136 clearStartQueued: () => Effect.void,
137 invalidateDeliveryToken: () => Effect.void,
138 }),
139 Layer.succeed(AgentActivityRows, {
140 upsert: () => Effect.void,
141 remove: () => Effect.void,
142 pruneTerminal: () => Effect.void,
143 listForUser: () =>
144 Effect.sync(() =>
145 current.linked
146 ? [...(current.state ? [current.state] : []), ...current.otherStates].filter(
147 (row) =>
148 !current.revokedEnvironments.includes(row.environmentId) &&
149 !current.notificationOnlyEnvironments.includes(row.environmentId),
150 )
151 : [],
152 ),
153 getForUserThread: (input) =>
154 Effect.sync(() =>
155 current.linked
156 ? ([current.state, ...current.otherStates].find(
157 (row) =>
158 row?.environmentId === input.environmentId && row.threadId === input.threadId,
159 ) ?? null)
160 : null,
161 ),
162 }),
163 Layer.succeed(EnvironmentLinks, {
164 upsert: () => Effect.void,
165 listUsersForEnvironment: () => Effect.succeed(["user"]),
166 listDeliveryUsersForEnvironment: (input) =>
167 Effect.sync(() =>
168 current.linked && !current.revokedEnvironments.includes(input.environmentId)
169 ? [
170 {
171 userId: "user",
172 notificationsEnabled: !current.mutedEnvironments.includes(input.environmentId),
173 liveActivitiesEnabled: !current.notificationOnlyEnvironments.includes(
174 input.environmentId,
175 ),
176 },
177 ]
178 : [],
179 ),
180 listPublicKeysForEnvironment: () => Effect.succeed([]),
181 listForUser: () => Effect.succeed([]),
182 revokeForUser: () => Effect.succeed(false),
183 getForUser: (input) =>
184 Effect.sync(() =>
185 current.linked && !current.revokedEnvironments.includes(input.environmentId)
186 ? {
187 environmentId: EnvironmentId.make(input.environmentId),
188 label: "Desktop",
189 environmentPublicKey: "key",
190 linkedAt: state.updatedAt,
191 endpoint: {
192 httpBaseUrl: "https://env.test",
193 wsBaseUrl: "wss://env.test",
194 providerKind: "manual",
195 },
196 }
197 : null,
198 ),
199 }),
200 Layer.succeed(RelayDb, {} as RelayDb["Service"]),
201 );
202 return {
203 sent,
204 queued,
205 marked,
206 current,
207 layer: layer.pipe(Layer.provide(services)),
208 job: {
209 userId: "user",
210 deviceId: "phone",
211 token: "fcm-token",
212 state,
213 queuedAt: 0,
214 } satisfies FcmDeliveryJob,
215 };
216}
217
218describe("Android delivery routing", () => {
219 const secondState: RelayAgentActivityState = {
220 ...state,
221 threadId: ThreadId.make("second-thread"),
222 threadTitle: "Second thread",
223 deepLink: "/threads/env/second-thread",
224 };
225
226 for (const [firstPhase, secondPhase, title, active] of [
227 ["waiting_for_approval", "waiting_for_input", "2 agents need attention", "true"],
228 ["completed", "failed", "2 agents finished", "false"],
229 ] as const) {
230 it.effect(`groups ${firstPhase} and ${secondPhase} once across their queued jobs`, () => {
231 const h = harness();
232 h.current.otherStates = [secondState];
233 return Effect.gen(function* () {
234 const delivery = yield* FcmDeliveries;
235 yield* delivery.process(h.job);
236 h.current.state = { ...state, phase: firstPhase };
237 h.current.otherStates = [{ ...secondState, phase: secondPhase }];
238 yield* delivery.process({ ...h.job, state: h.current.state });
239 yield* delivery.process({ ...h.job, state: h.current.otherStates[0] });
240 yield* delivery.process({ ...h.job, state: h.current.state });
241 const alerts = h.sent.filter((message) => message.alert);
242 expect(alerts).toHaveLength(1);
243 expect(alerts[0]?.data).toMatchObject({
244 alert_title: title,
245 alert_body: "Fix notifications, Second thread",
246 alert_path: firstPhase === "completed" ? secondState.deepLink : state.deepLink,
247 active,
248 });
249 expect(h.marked.at(-1)?.aggregate?.activities).toHaveLength(2);
250 }).pipe(Effect.provide(h.layer));
251 });
252 }
253
254 it.effect("filters disabled event types before counting a group", () => {
255 const h = harness();
256 h.current.target.last_aggregate_json = encodeJson(aggregateFor([state, secondState]));
257 h.current.target.preferences_json = encodeJson({ ...preferences, notifyOnApproval: false });
258 h.current.state = { ...state, phase: "waiting_for_approval" };
259 h.current.otherStates = [{ ...secondState, phase: "waiting_for_input" }];
260 return Effect.gen(function* () {
261 const delivery = yield* FcmDeliveries;
262 yield* delivery.process({ ...h.job, state: h.current.state });
263 expect(h.sent[0]?.data).toMatchObject({
264 alert_title: "Second thread",
265 alert_body: "Input: Project",
266 alert_path: "/threads/env/second-thread",
267 });
268 }).pipe(Effect.provide(h.layer));
269 });
270
271 it.effect("excludes already delivered attention and stale completions from groups", () => {
272 const h = harness();
273 h.current.state = { ...state, phase: "waiting_for_approval" };
274 h.current.target.last_aggregate_json = encodeJson(aggregateFor([h.current.state, secondState]));
275 h.current.otherStates = [
276 { ...secondState, phase: "completed", updatedAt: "1969-12-31T23:57:00.000Z" },
277 ];
278 return Effect.gen(function* () {
279 const delivery = yield* FcmDeliveries;
280 yield* delivery.process({ ...h.job, state: h.current.state });
281 expect(h.sent.every((message) => !message.alert)).toBe(true);
282 }).pipe(Effect.provide(h.layer));
283 });
284
285 it.effect("prioritizes attention over simultaneous completions like iOS", () => {
286 const h = harness();
287 h.current.target.last_aggregate_json = encodeJson(aggregateFor([state, secondState]));
288 h.current.state = { ...state, phase: "completed" };
289 h.current.otherStates = [{ ...secondState, phase: "waiting_for_input" }];
290 return Effect.gen(function* () {
291 const delivery = yield* FcmDeliveries;
292 yield* delivery.process({ ...h.job, state: h.current.state });
293 expect(h.sent[0]?.data).toMatchObject({
294 alert_title: "Second thread",
295 alert_body: "Input: Project",
296 });
297 yield* delivery.process({ ...h.job, state: h.current.otherStates[0] });
298 expect(h.sent.filter((message) => message.alert)).toHaveLength(1);
299 }).pipe(Effect.provide(h.layer));
300 });
301
302 for (const phase of [
303 "completed",
304 "waiting_for_approval",
305 "waiting_for_input",
306 "failed",
307 ] as const) {
308 it.effect(`deleting one thread preserves another thread's ${phase} alert`, () => {
309 const h = harness();
310 h.current.otherStates = [secondState];
311 return Effect.gen(function* () {
312 const delivery = yield* FcmDeliveries;
313 yield* delivery.process(h.job);
314 h.current.otherStates = [];
315 h.current.state = { ...state, phase };
316 yield* delivery.enqueue({ target, state: null });
317 yield* delivery.process(h.queued[0]);
318 expect(h.sent.at(-1)?.alert).toBe(false);
319 yield* delivery.process({ ...h.job, state: h.current.state });
320 expect(h.sent.filter((message) => message.alert)).toHaveLength(1);
321 yield* delivery.process({ ...h.job, state: h.current.state });
322 expect(h.sent.filter((message) => message.alert)).toHaveLength(1);
323 }).pipe(Effect.provide(h.layer));
324 });
325 }
326
327 it.effect("registration replay establishes a baseline without alerting", () => {
328 const h = harness();
329 h.current.state = { ...state, phase: "waiting_for_approval" };
330 h.current.otherStates = [{ ...secondState, phase: "waiting_for_input" }];
331 return Effect.gen(function* () {
332 const delivery = yield* FcmDeliveries;
333 yield* delivery.enqueue({ target, state: null, replay: true });
334 yield* delivery.process(h.queued[0]);
335 yield* delivery.process({ ...h.job, state: h.current.state });
336 expect(h.sent.every((message) => !message.alert)).toBe(true);
337 expect(h.marked[0]?.aggregate?.activities).toHaveLength(2);
338 }).pipe(Effect.provide(h.layer));
339 });
340
341 for (const restriction of ["mutedEnvironments", "revokedEnvironments"] as const) {
342 it.effect(`excludes ${restriction} when forming cross-environment groups`, () => {
343 const h = harness();
344 const other = { ...secondState, environmentId: EnvironmentId.make("other-env") };
345 h.current.target.last_aggregate_json = encodeJson(aggregateFor([state, other]));
346 h.current.state = { ...state, phase: "waiting_for_approval" };
347 h.current.otherStates = [{ ...other, phase: "waiting_for_input" }];
348 h.current[restriction] = [other.environmentId];
349 return Effect.gen(function* () {
350 const delivery = yield* FcmDeliveries;
351 yield* delivery.process({ ...h.job, state: h.current.state });
352 expect(h.sent[0]?.data).toMatchObject({
353 alert_title: "Fix notifications",
354 alert_body: "Approval: Project",
355 });
356 }).pipe(Effect.provide(h.layer));
357 });
358 }
359
360 it("gives a group a stable retry identity independent of row order", () => {
361 const other = {
362 ...secondState,
363 environmentId: EnvironmentId.make("other-env"),
364 threadId: state.threadId,
365 };
366 const input = {
367 previousAggregate: aggregateFor([state, other]),
368 nextAggregate: aggregateFor([
369 { ...state, phase: "completed" },
370 { ...other, phase: "failed" },
371 ]),
372 preferences,
373 nowMs: 0,
374 };
375 const alert = androidAlertForAggregate(input);
376 expect(alert?.alert_title).toBe("2 agents finished");
377 expect(
378 androidAlertForAggregate({
379 ...input,
380 nextAggregate: {
381 ...input.nextAggregate,
382 activities: input.nextAggregate.activities.toReversed(),
383 },
384 })?.alert_id,
385 ).toBe(alert?.alert_id);
386 expect(
387 androidAlertForAggregate({
388 ...input,
389 nextAggregate: {
390 ...input.nextAggregate,
391 activities: input.nextAggregate.activities.map((row) => ({
392 ...row,
393 updatedAt: "1970-01-01T00:00:01.000Z",
394 })),
395 },
396 })?.alert_id,
397 ).not.toBe(alert?.alert_id);
398 });
399
400 it("distinguishes matching thread IDs in different environments", () => {
401 const other = {
402 ...secondState,
403 environmentId: EnvironmentId.make("other-env"),
404 threadId: state.threadId,
405 };
406 const alreadyWaiting = { ...state, phase: "waiting_for_approval" as const };
407 expect(
408 androidAlertForAggregate({
409 previousAggregate: aggregateFor([alreadyWaiting, other]),
410 nextAggregate: aggregateFor([alreadyWaiting, { ...other, phase: "waiting_for_input" }]),
411 preferences,
412 nowMs: 0,
413 }),
414 ).toMatchObject({ alert_title: "Second thread", alert_body: "Input: Project" });
415 });
416
417 for (const [phase, body, preference] of [
418 ["waiting_for_approval", "Approval: Project", "notifyOnApproval"],
419 ["waiting_for_input", "Input: Project", "notifyOnInput"],
420 ["completed", "Done: Project", "notifyOnCompletion"],
421 ["failed", "Failed: Project", "notifyOnFailure"],
422 ] as const) {
423 it.effect(`uses iOS alert wording for ${phase} and honors its preference`, () => {
424 const h = harness();
425 h.current.state = { ...state, phase };
426 return Effect.gen(function* () {
427 const delivery = yield* FcmDeliveries;
428 yield* delivery.process({ ...h.job, state: h.current.state });
429 expect(h.sent[0]?.data).toMatchObject({
430 alert_title: "Fix notifications",
431 alert_body: body,
432 alert_path: "/threads/env/thread",
433 });
434 h.current.target.preferences_json = encodeJson({
435 ...preferences,
436 [preference]: false,
437 });
438 yield* delivery.process({ ...h.job, state: h.current.state });
439 expect(h.sent.slice(1).every((sent) => !sent.alert && !sent.data.alert_id)).toBe(true);
440 }).pipe(Effect.provide(h.layer));
441 });
442 }
443
444 it("trims and truncates alert text like iOS", () => {
445 expect(
446 androidAlertForState(
447 {
448 ...state,
449 phase: "completed",
450 threadTitle: ` ${"T".repeat(150)} `,
451 projectTitle: ` ${"P".repeat(150)} `,
452 },
453 preferences,
454 0,
455 ),
456 ).toMatchObject({
457 alert_title: `${"T".repeat(117)}...`,
458 alert_body: `Done: ${"P".repeat(111)}...`,
459 });
460 });
461
462 it.effect(
463 "queues Android devices and sends the latest aggregate instead of a stale running state",
464 () => {
465 const h = harness();
466 return Effect.gen(function* () {
467 const delivery = yield* FcmDeliveries;
468 yield* delivery.enqueue({ target, state });
469 h.current.state = { ...state, phase: "completed" };
470 yield* delivery.process(h.queued[0]);
471 expect(h.sent).toHaveLength(0);
472 yield* delivery.process({ ...h.job, state: h.current.state });
473 expect(h.sent[0]?.data).toMatchObject({
474 active: "false",
475 alert_title: "Fix notifications",
476 alert_body: "Done: Project",
477 alert_path: "/threads/env/thread",
478 });
479 }).pipe(Effect.provide(h.layer));
480 },
481 );
482
483 it.effect("keeps completion alerts working with ongoing activity disabled", () => {
484 const h = harness();
485 h.current.state = { ...state, phase: "completed" };
486 h.current.target.preferences_json = encodeJson({
487 ...preferences,
488 liveActivitiesEnabled: false,
489 });
490 return Effect.gen(function* () {
491 const delivery = yield* FcmDeliveries;
492 yield* delivery.process({ ...h.job, state: h.current.state });
493 expect(h.sent[0]?.data).toMatchObject({
494 active: "false",
495 alert_title: "Fix notifications",
496 alert_body: "Done: Project",
497 });
498 expect(h.sent[0]?.data.user_id).toBe("user");
499 }).pipe(Effect.provide(h.layer));
500 });
501
502 it.effect("drops jobs for rotated tokens, expired jobs, and revoked links", () => {
503 const h = harness();
504 return Effect.gen(function* () {
505 const delivery = yield* FcmDeliveries;
506 yield* delivery.process({ ...h.job, token: "old-token" });
507 yield* delivery.process({ ...h.job, queuedAt: -400_000 });
508 h.current.linked = false;
509 yield* delivery.process(h.job);
510 expect(h.sent).toHaveLength(0);
511 }).pipe(Effect.provide(h.layer));
512 });
513
514 it.effect("ends an existing ongoing notification when the environment stops publishing", () => {
515 const h = harness();
516 h.current.target.last_aggregate_json = encodeJson(aggregateFor([state]));
517 h.current.state = null;
518 return Effect.gen(function* () {
519 const delivery = yield* FcmDeliveries;
520 yield* delivery.process({ ...h.job, state: null });
521 expect(h.sent[0]?.data).toMatchObject({ active: "false" });
522 expect(h.sent[0]?.alert).toBe(false);
523 expect(h.marked[0]?.kind).toBe("live_activity_end");
524 }).pipe(Effect.provide(h.layer));
525 });
526
527 it.effect("leaves iOS devices on their existing delivery path", () => {
528 const h = harness();
529 return Effect.gen(function* () {
530 const delivery = yield* FcmDeliveries;
531 expect(yield* delivery.enqueue({ target: { ...target, platform: "ios" }, state })).toBeNull();
532 expect(h.queued).toHaveLength(0);
533 }).pipe(Effect.provide(h.layer));
534 });
535
536 it.effect("honors disabled alert preferences while continuing ongoing activity", () => {
537 const h = harness();
538 h.current.state = { ...state, phase: "waiting_for_approval" };
539 h.current.target.preferences_json = encodeJson({ ...preferences, notifyOnApproval: false });
540 return Effect.gen(function* () {
541 const delivery = yield* FcmDeliveries;
542 yield* delivery.process({ ...h.job, state: h.current.state });
543 expect(h.sent[0]?.data.active).toBe("true");
544 expect(h.sent[0]?.data.alert_id).toBeUndefined();
545 h.current.target.preferences_json = encodeJson({
546 ...preferences,
547 notificationsEnabled: false,
548 });
549 h.current.target.last_aggregate_json = encodeJson(aggregateFor([state]));
550 yield* delivery.process({ ...h.job, state: h.current.state });
551 expect(h.sent[1]?.data.active).toBe("false");
552 expect(h.sent[1]?.data.alert_id).toBeUndefined();
553 }).pipe(Effect.provide(h.layer));
554 });
555 for (const ongoing of [true, false]) {
556 for (const phase of ["completed", "failed"] as const) {
557 it.effect(`does not alert a stale ${phase} without a baseline (ongoing=${ongoing})`, () => {
558 const h = harness();
559 h.current.state = { ...state, phase, updatedAt: "1969-12-31T23:57:00.000Z" };
560 h.current.target.preferences_json = encodeJson({
561 ...preferences,
562 liveActivitiesEnabled: ongoing,
563 });
564 return Effect.gen(function* () {
565 const delivery = yield* FcmDeliveries;
566 yield* delivery.process({ ...h.job, state: h.current.state });
567 expect(h.sent.every((message) => !message.alert)).toBe(true);
568 }).pipe(Effect.provide(h.layer));
569 });
570 }
571 }
572
573 it.effect(
574 "retains finished results without extending expiry on replay and clears expired cards",
575 () => {
576 const h = harness();
577 h.current.state = { ...state, phase: "failed" };
578 return Effect.gen(function* () {
579 const delivery = yield* FcmDeliveries;
580 yield* delivery.process({ ...h.job, state: h.current.state });
581 expect(h.sent[0]?.data).toMatchObject({
582 active: "false",
583 activity_title: "Agent work failed",
584 activity_expires_at: "900000",
585 });
586 yield* TestClock.adjust("5 minutes");
587 yield* delivery.process({ ...h.job, queuedAt: 300000, state: null, replay: true });
588 expect(h.sent[1]?.data.activity_expires_at).toBe("900000");
589 expect(h.sent[1]?.alert).toBe(false);
590 yield* TestClock.adjust("11 minutes");
591 yield* delivery.process({ ...h.job, queuedAt: 960000, state: null, replay: true });
592 expect(h.sent[2]?.data).toMatchObject({ active: "false", activity_expires_at: "0" });
593 expect(h.marked.at(-1)?.aggregate).toBeNull();
594 }).pipe(Effect.provide(h.layer));
595 },
596 );
597
598 it.effect("replays an empty card to repair an orphan without a delivery baseline", () => {
599 const h = harness();
600 h.current.state = null;
601 return Effect.gen(function* () {
602 const delivery = yield* FcmDeliveries;
603 yield* delivery.process({ ...h.job, state: null });
604 expect(h.sent[0]?.data.activity_expires_at).toBe("0");
605 expect(h.sent[0]?.alert).toBe(false);
606 }).pipe(Effect.provide(h.layer));
607 });
608
609 it("shows five rows with attention then failure first, including project and status", () => {
610 const aggregate = aggregateFor([
611 state,
612 { ...secondState, phase: "failed" },
613 { ...state, threadId: ThreadId.make("approval"), phase: "waiting_for_approval" },
614 { ...state, threadId: ThreadId.make("input"), phase: "waiting_for_input" },
615 { ...state, threadId: ThreadId.make("done"), phase: "completed" },
616 ]);
617 const data = androidActivityData(aggregate);
618 expect(data.activity_title).toBe("3 active agents · 2 need attention");
619 expect(
620 Object.entries(data)
621 .filter(([key]) => key.startsWith("activity_line_"))
622 .map(([, value]) => value),
623 ).toEqual([
624 "Approval\tFix notifications\tProject",
625 "Input\tFix notifications\tProject",
626 "Failed\tSecond thread\tProject",
627 "Working\tFix notifications\tProject",
628 "Done\tFix notifications\tProject",
629 ]);
630 expect(data.activity_expires_at).toBe(String(24 * 60 * 60 * 1000));
631 expect(androidActivityData(aggregateFor([state])).activity_expires_at).toBe(
632 String(2 * 60 * 60 * 1000),
633 );
634 });
635
636 it("fits five Unicode rows and a grouped alert in the FCM budget without corrupting text or routes", () => {
637 const longTitle = '🤖漢字"\\'.repeat(30);
638 const aggregate = aggregateFor(
639 Array.from({ length: 5 }, (_, i) => ({
640 ...state,
641 threadId: ThreadId.make(`thread-${i}`),
642 threadTitle: longTitle,
643 projectTitle: longTitle,
644 })),
645 );
646 const data = fitFcmData({
647 ...androidActivityData(aggregate),
648 t3_kind: "agent_activity",
649 device_id: "d".repeat(128),
650 user_id: "u".repeat(128),
651 updated_at: "1788780000000",
652 alert_id: "a".repeat(64),
653 alert_title: "5 agents finished",
654 alert_body: Array(5).fill(longTitle).join(", "),
655 alert_path: "/threads/env/thread",
656 });
657 expect(new TextEncoder().encode(JSON.stringify(data)).length).toBeLessThanOrEqual(3800);
658 expect(data.alert_path).toBe("/threads/env/thread");
659 expect(data.activity_path).toBe("/threads/env/thread");
660 expect(data.alert_id).toBe("a".repeat(64));
661 expect(data.activity_line_4).toContain("Working\t");
662 for (const value of Object.values(data))
663 expect(new TextDecoder().decode(new TextEncoder().encode(value))).toBe(value);
664 });
665});
666
667describe("delivery policy regressions", () => {
668 it.effect("alerts a quick completion even with a previous unrelated card", () => {
669 const h = harness();
670 const old = { ...state, threadId: ThreadId.make("old"), phase: "completed" as const };
671 h.current.target.last_aggregate_json = encodeJson(aggregateFor([old]));
672 h.current.otherStates = [old];
673 h.current.state = { ...state, phase: "completed" };
674 return Effect.gen(function* () {
675 const d = yield* FcmDeliveries;
676 yield* d.process(h.job);
677 yield* d.process({ ...h.job, state: h.current.state });
678 expect(h.sent.filter((x) => x.alert)).toHaveLength(1);
679 }).pipe(Effect.provide(h.layer));
680 });
681 it.effect("a muted environment job cannot consume another environment's attention alert", () => {
682 const h = harness();
683 const other = {
684 ...state,
685 environmentId: EnvironmentId.make("other"),
686 threadId: ThreadId.make("other"),
687 };
688 h.current.target.last_aggregate_json = encodeJson(aggregateFor([state, other]));
689 h.current.otherStates = [{ ...other, phase: "waiting_for_input" }];
690 h.current.mutedEnvironments = [state.environmentId];
691 return Effect.gen(function* () {
692 const d = yield* FcmDeliveries;
693 yield* d.process(h.job);
694 yield* d.process({ ...h.job, state: h.current.otherStates[0]! });
695 expect(h.sent.filter((x) => x.alert)).toHaveLength(1);
696 }).pipe(Effect.provide(h.layer));
697 });
698 it("keeps an older waiting thread visible and alertable beyond five running rows", () => {
699 const running = Array.from({ length: 5 }, (_, i) => ({
700 ...state,
701 threadId: ThreadId.make(`running-${i}`),
702 }));
703 const waiting = {
704 ...state,
705 phase: "waiting_for_approval" as const,
706 updatedAt: "1969-12-31T23:59:00.000Z",
707 };
708 const next = aggregateFor([...running, waiting]);
709 expect(
710 androidAlertForAggregate({
711 previousAggregate: aggregateFor(running),
712 nextAggregate: next,
713 preferences,
714 nowMs: 0,
715 }),
716 ).not.toBeNull();
717 });
718});
719
720describe("notification-only environments", () => {
721 it.effect("alerts independently while another environment has a live card", () => {
722 const h = harness();
723 const other = {
724 ...state,
725 environmentId: EnvironmentId.make("other"),
726 threadId: ThreadId.make("other"),
727 };
728 h.current.target.last_aggregate_json = encodeJson(aggregateFor([other]));
729 h.current.otherStates = [other];
730 h.current.state = { ...state, phase: "waiting_for_input" };
731 h.current.notificationOnlyEnvironments = [state.environmentId];
732 return Effect.gen(function* () {
733 const deliveries = yield* FcmDeliveries;
734 yield* deliveries.process({ ...h.job, state: h.current.state });
735 expect(h.sent).toHaveLength(1);
736 expect(h.sent[0]?.alert).toBe(true);
737 expect(h.sent[0]?.data.alert_path).toBe(state.deepLink);
738 }).pipe(Effect.provide(h.layer));
739 });
740});
741
742it("continues shrinking text when a longer activity line is already minimal", () => {
743 const data = fitFcmData({
744 activity_line_0: "Approval\t😀😀😀😀\t😀😀😀😀",
745 alert_body: "x".repeat(30),
746 device_id: "x".repeat(3680),
747 });
748 expect(new TextEncoder().encode(encodeJson(data)).length).toBeLessThanOrEqual(3800);
749 expect(data.activity_line_0).toBe("Approval\t😀😀😀😀\t😀😀😀😀");
750});
751
752it.effect("notification-only jobs do not consume another environment's card alert", () => {
753 const h = harness();
754 const other = {
755 ...state,
756 environmentId: EnvironmentId.make("other"),
757 threadId: ThreadId.make("other"),
758 };
759 h.current.target.last_aggregate_json = encodeJson(aggregateFor([other]));
760 h.current.otherStates = [{ ...other, phase: "waiting_for_approval" }];
761 h.current.state = { ...state, phase: "waiting_for_input" };
762 h.current.notificationOnlyEnvironments = [state.environmentId];
763 return Effect.gen(function* () {
764 const deliveries = yield* FcmDeliveries;
765 yield* deliveries.process({ ...h.job, state: h.current.state });
766 yield* deliveries.process({ ...h.job, state: h.current.otherStates[0]! });
767 expect(h.sent.filter((delivery) => delivery.alert)).toHaveLength(2);
768 }).pipe(Effect.provide(h.layer));
769});
770
771it.effect("preserves a structured Firebase failure through the queue consumer", () => {
772 const h = harness();
773 const failure = new FcmClientError({ operation: "send", status: 503 });
774 h.current.deliveryFailure = failure;
775 return Effect.gen(function* () {
776 const deliveries = yield* FcmDeliveries;
777 expect(yield* deliveries.process(h.job).pipe(Effect.flip)).toBe(failure);
778 }).pipe(Effect.provide(h.layer));
779});
780
781it("stops reducing five-character row fields and fits the remaining alert", () => {
782 const data = fitFcmData({
783 device_id: "x".repeat(3710),
784 activity_line_0: "Approval\taaaaa\tbbbbb",
785 alert_body: "y".repeat(200),
786 });
787 expect(data.activity_line_0).toBe("Approval\taaaaa\tbbbbb");
788 expect(new TextEncoder().encode(encodeJson(data)).length).toBeLessThanOrEqual(3800);
789});
790
791describe("FCM queue message isolation", () => {
792 for (const failure of ["invalid-job", "fcm-rejection"] as const) {
793 it.effect(`retries only the ${failure} message and delivers the rest of its batch`, () => {
794 const h = harness();
795 const outcomes = new Map<string, "ack" | "retry">();
796 const message = (id: string, body: unknown): Cloudflare.Queues.Message<unknown> => ({
797 id,
798 body,
799 timestamp: DateTime.toDateUtc(DateTime.makeUnsafe(0)),
800 attempts: 1,
801 ack: () => {
802 if (!outcomes.has(id)) outcomes.set(id, "ack");
803 },
804 retry: () => {
805 if (!outcomes.has(id)) outcomes.set(id, "retry");
806 },
807 });
808 const batch = [
809 message("failed", failure === "invalid-job" ? {} : h.job),
810 message("healthy", h.job),
811 ];
812 return Effect.gen(function* () {
813 yield* Stream.fromIterable(batch).pipe(
814 Stream.tap((item) =>
815 Effect.sync(() => {
816 h.current.deliveryFailure =
817 item.id === "failed" && failure === "fcm-rejection"
818 ? new FcmClientError({ operation: "send", status: 400 })
819 : null;
820 }),
821 ),
822 Stream.runForEach(FcmDeliveryQueueConsumer.processMessage),
823 );
824 // Alchemy acknowledges the batch after a successful stream. Cloudflare
825 // ignores those acknowledgements for messages explicitly retried earlier.
826 for (const item of batch) item.ack();
827 expect([...outcomes]).toEqual([
828 ["failed", "retry"],
829 ["healthy", "ack"],
830 ]);
831 expect(h.sent).toHaveLength(1);
832 expect(h.marked).toHaveLength(1);
833 }).pipe(Effect.provide(h.layer));
834 });
835 }
836});
infra/relay/src/agentActivity/FcmDeliveries.ts created+345
...@@ -0,0 +1,345 @@
1import { and, eq } from "drizzle-orm";
2import {
3 RelayAgentActivityState,
4 RelayAgentActivityAggregateState,
5 RelayAgentAwarenessPreferences,
6 type RelayDeliveryResult,
7} from "@t3tools/contracts/relay";
8import * as Crypto from "effect/Crypto";
9import type * as PlatformError from "effect/PlatformError";
10import * as Context from "effect/Context";
11import * as DateTime from "effect/DateTime";
12import * as Effect from "effect/Effect";
13import * as Layer from "effect/Layer";
14import * as Option from "effect/Option";
15import * as Schema from "effect/Schema";
16
17import * as RelayConfiguration from "../Config.ts";
18import * as RelayDb from "../db.ts";
19import { relayMobileDevices } from "../persistence/schema.ts";
20import * as EnvironmentLinks from "../environments/EnvironmentLinks.ts";
21import * as AgentActivityRows from "./AgentActivityRows.ts";
22import * as LiveActivities from "./LiveActivities.ts";
23import * as FcmDeliveryQueueSender from "./FcmDeliveryQueueSender.ts";
24import * as FcmClient from "./FcmClient.ts";
25import { androidActivityData, androidActivityHero, fitFcmData } from "./fcmPayloads.ts";
26import { makeAggregateState, statusForPhase } from "./agentActivityAggregate.ts";
27import { isExpiredAgentActivityState, notificationForActivity } from "./agentActivityPayloads.ts";
28import {
29 alertForActivityRows,
30 attentionTransitionRows,
31 terminalTransitionRows,
32 shouldAlertForActivity,
33} from "./agentActivityAlerts.ts";
34
35export const FcmDeliveryJob = Schema.Struct({
36 userId: Schema.String,
37 deviceId: Schema.String,
38 token: Schema.String,
39 state: Schema.NullOr(RelayAgentActivityState),
40 queuedAt: Schema.Number,
41 replay: Schema.optional(Schema.Boolean),
42});
43export type FcmDeliveryJob = typeof FcmDeliveryJob.Type;
44const decodeJob = Schema.decodeUnknownEffect(FcmDeliveryJob);
45const decodePreferences = Schema.decodeUnknownOption(
46 Schema.fromJsonString(RelayAgentAwarenessPreferences),
47);
48const decodePreviousActivity = Schema.decodeUnknownOption(
49 Schema.fromJsonString(RelayAgentActivityAggregateState),
50);
51
52export class FcmDeliveryError extends Schema.TaggedError<FcmDeliveryError>()("FcmDeliveryError", {
53 operation: Schema.Literals(["enqueue", "decode-job", "invalidate-token"]),
54 cause: Schema.Defect(),
55}) {
56 override get message() {
57 return `Failed to ${this.operation} Android notification delivery.`;
58 }
59}
60
61export function androidAlertForState(
62 state: RelayAgentActivityState,
63 preferences: RelayAgentAwarenessPreferences,
64 nowMs: number,
65) {
66 if (!shouldAlertForActivity({ ...state, preferences, nowMs })) return null;
67 const notification = notificationForActivity({ ...state, status: statusForPhase(state.phase) });
68 return {
69 alert_id: JSON.stringify([state.environmentId, state.threadId, state.phase, state.updatedAt]),
70 alert_title: notification.title,
71 alert_body: notification.body,
72 alert_path: notification.deepLink,
73 };
74}
75
76export function androidAlertForAggregate(input: {
77 readonly previousAggregate: RelayAgentActivityAggregateState;
78 readonly nextAggregate: RelayAgentActivityAggregateState;
79 readonly preferences: RelayAgentAwarenessPreferences;
80 readonly nowMs: number;
81}) {
82 if (!input.preferences.notificationsEnabled) return null;
83 const attention = attentionTransitionRows(input);
84 const activities =
85 attention.length > 0
86 ? attention
87 : terminalTransitionRows({ ...input, includeUnobserved: true });
88 const first = activities[0];
89 const alert = alertForActivityRows(activities);
90 if (!first || !alert) return null;
91 if (activities.length === 1) {
92 const notification = notificationForActivity(first);
93 return {
94 alert_id: JSON.stringify([first.environmentId, first.threadId, first.phase, first.updatedAt]),
95 alert_title: notification.title,
96 alert_body: notification.body,
97 alert_path: notification.deepLink,
98 };
99 }
100 return {
101 // Every contributing queue job identifies the same group, including after
102 // retries or a different database row order. The native handler deduplicates it.
103 alert_id: JSON.stringify(
104 activities
105 .map((row) => [row.environmentId, row.threadId, row.phase, row.updatedAt])
106 .sort((a, b) => JSON.stringify(a).localeCompare(JSON.stringify(b))),
107 ),
108 alert_title: alert.title,
109 alert_body: alert.body,
110 alert_path: androidActivityHero(input.nextAggregate)?.deepLink ?? "/",
111 };
112}
113
114export class FcmDeliveries extends Context.Service<
115 FcmDeliveries,
116 {
117 readonly enqueue: (input: {
118 readonly target: LiveActivities.TargetRow;
119 readonly state: RelayAgentActivityState | null;
120 readonly replay?: boolean;
121 }) => Effect.Effect<RelayDeliveryResult | null, FcmDeliveryError>;
122 readonly process: (
123 body: unknown,
124 ) => Effect.Effect<
125 void,
126 | FcmDeliveryError
127 | FcmClient.FcmClientError
128 | PlatformError.PlatformError
129 | LiveActivities.LiveActivityTargetListPersistenceError
130 | LiveActivities.LiveActivityDeliveryMarkPersistenceError
131 | AgentActivityRows.AgentActivityRowListPersistenceError
132 | EnvironmentLinks.EnvironmentLinkLookupPersistenceError
133 | EnvironmentLinks.EnvironmentLinkUserListPersistenceError
134 >;
135 }
136>()("t3code-relay/agentActivity/FcmDeliveries") {}
137
138export const make = Effect.gen(function* () {
139 const config = yield* RelayConfiguration.RelayConfiguration;
140 const crypto = yield* Crypto.Crypto;
141 const sender = yield* FcmDeliveryQueueSender.FcmDeliveryQueueSender;
142 const client = yield* FcmClient.FcmClient;
143 const devices = yield* LiveActivities.LiveActivities;
144 const rows = yield* AgentActivityRows.AgentActivityRows;
145 const links = yield* EnvironmentLinks.EnvironmentLinks;
146 const db = yield* RelayDb.RelayDb;
147
148 return FcmDeliveries.of({
149 enqueue: Effect.fn("relay.fcm.enqueue")(function* (input) {
150 if (input.target.platform !== "android" || !input.target.push_token) return null;
151 if (!config.fcmServiceAccount) {
152 yield* Effect.logWarning("Android notifications are not configured for this relay");
153 return {
154 deviceId: input.target.device_id,
155 kind: "push_notification",
156 ok: false,
157 apnsStatus: null,
158 apnsReason: null,
159 apnsId: null,
160 };
161 }
162 const now = yield* DateTime.now;
163 yield* sender
164 .send({
165 userId: input.target.user_id,
166 deviceId: input.target.device_id,
167 token: input.target.push_token,
168 state: input.state,
169 ...(input.replay ? { replay: true } : {}),
170 queuedAt: now.epochMilliseconds,
171 })
172 .pipe(Effect.mapError((cause) => new FcmDeliveryError({ operation: "enqueue", cause })));
173 return {
174 deviceId: input.target.device_id,
175 kind: "push_notification",
176 ok: true,
177 queued: true,
178 apnsStatus: null,
179 apnsReason: null,
180 apnsId: null,
181 };
182 }),
183 process: Effect.fn("relay.fcm.process")(function* (body) {
184 const job = yield* decodeJob(body).pipe(
185 Effect.mapError((cause) => new FcmDeliveryError({ operation: "decode-job", cause })),
186 );
187 const now = yield* DateTime.now;
188 if (now.epochMilliseconds - job.queuedAt > 5 * 60_000) return;
189 const targets = yield* devices.listTargets({ userId: job.userId });
190 const target = targets.find(
191 (device) =>
192 device.device_id === job.deviceId &&
193 device.platform === "android" &&
194 device.push_token === job.token,
195 );
196 if (!target) return;
197 const preferences = decodePreferences(target.preferences_json);
198 if (Option.isNone(preferences)) return;
199
200 // Re-read links and state when consuming: queued messages must honor
201 // sign-out, token rotation, disabled publishing, and newer thread states.
202 const states = preferences.value.liveActivitiesEnabled
203 ? yield* rows.listForUser({ userId: job.userId })
204 : [];
205 const aggregate = makeAggregateState({
206 activeStates: states,
207 terminalState: null,
208 nowMs: now.epochMilliseconds,
209 });
210 const previousAggregate = target.last_aggregate_json
211 ? Option.getOrNull(decodePreviousActivity(target.last_aggregate_json))
212 : null;
213 let alert: ReturnType<typeof androidAlertForState> = null;
214 // Deletion jobs can observe another thread's newly completed state. They
215 // update the card, but must leave that transition for its own alert job.
216 // Registration replay deliberately establishes a silent baseline.
217 let acknowledgeAggregate = job.state !== null || job.replay === true || aggregate === null;
218 if (job.state && preferences.value.notificationsEnabled) {
219 const state = yield* rows.getForUserThread({
220 userId: job.userId,
221 environmentId: job.state.environmentId,
222 threadId: job.state.threadId,
223 });
224 if (
225 !previousAggregate &&
226 (!state || state.phase !== job.state.phase || state.updatedAt !== job.state.updatedAt)
227 )
228 return;
229 const link = yield* links.getForUser({
230 userId: job.userId,
231 environmentId: job.state.environmentId,
232 });
233 const deliveryUsers = link
234 ? yield* links.listDeliveryUsersForEnvironment({
235 environmentId: job.state.environmentId,
236 environmentPublicKey: link.environmentPublicKey,
237 })
238 : [];
239 const deliveryUser = deliveryUsers.find((user) => user.userId === job.userId);
240 // A notification-only job must not acknowledge transitions on another
241 // environment's live card before that environment's own job can alert.
242 acknowledgeAggregate =
243 deliveryUser?.liveActivitiesEnabled === true || !preferences.value.liveActivitiesEnabled;
244 if (
245 deliveryUser?.liveActivitiesEnabled &&
246 preferences.value.liveActivitiesEnabled &&
247 previousAggregate &&
248 aggregate
249 ) {
250 const environmentIds = [...new Set(aggregate.activities.map((row) => row.environmentId))];
251 const allowedEnvironments = new Set<string>();
252 for (const environmentId of environmentIds) {
253 const environmentLink = yield* links.getForUser({
254 userId: job.userId,
255 environmentId,
256 });
257 if (!environmentLink) continue;
258 const users = yield* links.listDeliveryUsersForEnvironment({
259 environmentId,
260 environmentPublicKey: environmentLink.environmentPublicKey,
261 });
262 if (users.some((user) => user.userId === job.userId && user.notificationsEnabled)) {
263 allowedEnvironments.add(environmentId);
264 }
265 }
266 alert = androidAlertForAggregate({
267 previousAggregate,
268 nextAggregate: {
269 ...aggregate,
270 activities: aggregate.activities.filter((row) =>
271 allowedEnvironments.has(row.environmentId),
272 ),
273 },
274 preferences: preferences.value,
275 nowMs: now.epochMilliseconds,
276 });
277 } else if (
278 deliveryUser?.notificationsEnabled &&
279 state?.phase === job.state.phase &&
280 state.updatedAt === job.state.updatedAt &&
281 !isExpiredAgentActivityState(state, now.epochMilliseconds)
282 ) {
283 alert = androidAlertForState(state, preferences.value, now.epochMilliseconds);
284 }
285 }
286 const displayedAggregate =
287 preferences.value.notificationsEnabled && preferences.value.liveActivitiesEnabled
288 ? aggregate
289 : null;
290 const active = (displayedAggregate?.activeCount ?? 0) > 0;
291 // A registration replay must clear an orphan even when the relay has
292 // already forgotten its baseline. Finished cards are visible, but idle.
293 if (!displayedAggregate && !alert && !previousAggregate && job.state !== null) return;
294 const data = {
295 t3_kind: "agent_activity",
296 device_id: job.deviceId,
297 user_id: job.userId,
298 updated_at: String(now.epochMilliseconds),
299 ...androidActivityData(displayedAggregate),
300 ...alert,
301 };
302 if (alert) {
303 // Group identities can contain five sets of IDs. Hash the full,
304 // stable identity rather than spending the payload budget on it.
305 const digest = yield* crypto.digest("SHA-256", new TextEncoder().encode(alert.alert_id));
306 data.alert_id = Array.from(digest, (byte) => byte.toString(16).padStart(2, "0")).join("");
307 }
308 const result = yield* client.send({
309 token: job.token,
310 packageName: target.bundle_id,
311 alert: alert !== null,
312 data: fitFcmData(data),
313 });
314 if (result.unregistered) {
315 yield* db
316 .update(relayMobileDevices)
317 .set({ pushToken: null })
318 .where(
319 and(
320 eq(relayMobileDevices.userId, job.userId),
321 eq(relayMobileDevices.deviceId, job.deviceId),
322 eq(relayMobileDevices.pushToken, job.token),
323 ),
324 )
325 .pipe(
326 Effect.mapError(
327 (cause) => new FcmDeliveryError({ operation: "invalidate-token", cause }),
328 ),
329 );
330 } else if (acknowledgeAggregate) {
331 yield* devices.markDelivery({
332 userId: job.userId,
333 deviceId: job.deviceId,
334 kind: active ? "live_activity_update" : "live_activity_end",
335 // Keep the delivered terminal rows as the next transition baseline,
336 // so replaying the finished card cannot alert again.
337 aggregate: preferences.value.liveActivitiesEnabled ? aggregate : null,
338 deliveredAt: DateTime.formatIso(now),
339 });
340 }
341 }),
342 });
343});
344
345export const layer = Layer.effect(FcmDeliveries, make);
infra/relay/src/agentActivity/FcmDeliveryQueueConsumer.ts created+25
...@@ -0,0 +1,25 @@
1import type * as Cloudflare from "alchemy/Cloudflare";
2import * as Effect from "effect/Effect";
3
4import * as FcmDeliveries from "./FcmDeliveries.ts";
5
6export const processMessage = Effect.fn("relay.fcm_delivery_queue.process_message")(function* (
7 message: Cloudflare.Queues.Message<unknown>,
8) {
9 const deliveries = yield* FcmDeliveries.FcmDeliveries;
10 yield* deliveries.process(message.body).pipe(
11 Effect.matchEffect({
12 onFailure: (error) =>
13 Effect.gen(function* () {
14 // Decide this message's outcome before Alchemy acknowledges the batch.
15 // Cloudflare keeps the first ack/retry decision for each message.
16 message.retry();
17 yield* Effect.logWarning("FCM queue delivery failed; retrying message", {
18 messageId: message.id,
19 errorTag: error._tag,
20 });
21 }),
22 onSuccess: () => Effect.sync(() => message.ack()),
23 }),
24 );
25});
infra/relay/src/agentActivity/FcmDeliveryQueueSender.ts created+14
...@@ -0,0 +1,14 @@
1import type * as Cloudflare from "alchemy/Cloudflare";
2import * as Context from "effect/Context";
3import type * as Effect from "effect/Effect";
4
5import type * as FcmDeliveries from "./FcmDeliveries.ts";
6
7export class FcmDeliveryQueueSender extends Context.Service<
8 FcmDeliveryQueueSender,
9 {
10 readonly send: (
11 body: FcmDeliveries.FcmDeliveryJob,
12 ) => Effect.Effect<void, Cloudflare.Queues.SendError>;
13 }
14>()("t3code-relay/agentActivity/FcmDeliveryQueueSender") {}
infra/relay/src/agentActivity/LiveActivities.ts+2-2
...@@ -66,8 +66,8 @@ export class LiveActivityDeliveryMarkPersistenceError extends Schema.TaggedError...@@ -66,8 +66,8 @@ export class LiveActivityDeliveryMarkPersistenceError extends Schema.TaggedError
66export interface DeviceRow {66export interface DeviceRow {
67 readonly user_id: string;67 readonly user_id: string;
68 readonly device_id: string;68 readonly device_id: string;
69 readonly platform: "ios";69 readonly platform: "ios" | "android";
70 readonly ios_major_version: number;70 readonly ios_major_version: number | null;
71 readonly app_version: string | null;71 readonly app_version: string | null;
72 readonly bundle_id: string | null;72 readonly bundle_id: string | null;
73 readonly aps_environment: "sandbox" | "production" | null;73 readonly aps_environment: "sandbox" | "production" | null;
infra/relay/src/agentActivity/MobileRegistrations.test.ts+11-1
...@@ -18,6 +18,16 @@ import * as EnvironmentLinks from "../environments/EnvironmentLinks.ts";...@@ -18,6 +18,16 @@ import * as EnvironmentLinks from "../environments/EnvironmentLinks.ts";
18import * as LiveActivities from "./LiveActivities.ts";18import * as LiveActivities from "./LiveActivities.ts";
19import * as RelayConfiguration from "../Config.ts";19import * as RelayConfiguration from "../Config.ts";
20import * as AgentActivityPublisher from "./AgentActivityPublisher.ts";20import * as AgentActivityPublisher from "./AgentActivityPublisher.ts";
21import { FcmDeliveries } from "./FcmDeliveries.ts";
22
23const publisherLayer = AgentActivityPublisher.layer.pipe(
24 Layer.provide(
25 Layer.succeed(FcmDeliveries, {
26 enqueue: () => Effect.succeed(null),
27 process: () => Effect.void,
28 }),
29 ),
30);
21import * as ApnsDeliveries from "./ApnsDeliveries.ts";31import * as ApnsDeliveries from "./ApnsDeliveries.ts";
22import * as ApnsClient from "./ApnsClient.ts";32import * as ApnsClient from "./ApnsClient.ts";
23import * as ApnsProviderTokens from "./ApnsProviderTokens.ts";33import * as ApnsProviderTokens from "./ApnsProviderTokens.ts";
...@@ -149,7 +159,7 @@ function makeRegistrationReplayLayer(input: {...@@ -149,7 +159,7 @@ function makeRegistrationReplayLayer(input: {
149 readonly queuedJobs: Array<SignedApnsDeliveryJob>;159 readonly queuedJobs: Array<SignedApnsDeliveryJob>;
150}) {160}) {
151 return MobileRegistrations.layer.pipe(161 return MobileRegistrations.layer.pipe(
152 Layer.provide(AgentActivityPublisher.layer),162 Layer.provide(publisherLayer),
153 Layer.provide(163 Layer.provide(
154 ApnsDeliveries.layer.pipe(164 ApnsDeliveries.layer.pipe(
155 Layer.provide(ApnsClient.layer.pipe(Layer.provide(ApnsProviderTokens.layer))),165 Layer.provide(ApnsClient.layer.pipe(Layer.provide(ApnsProviderTokens.layer))),
infra/relay/src/agentActivity/fcmPayloads.ts created+86
...@@ -0,0 +1,86 @@
1import type { RelayAgentActivityAggregateState } from "@t3tools/contracts/relay";
2import {
3 activityPhasePriority,
4 TERMINAL_AGENT_ACTIVITY_DISPLAY_TTL_MS,
5} from "./agentActivityAggregate.ts";
6import { agentActivityExpiresAt } from "./agentActivityPayloads.ts";
7
8export function androidActivityHero(aggregate: RelayAgentActivityAggregateState) {
9 return [...aggregate.activities].sort(
10 (a, b) => activityPhasePriority(a.phase) - activityPhasePriority(b.phase),
11 )[0];
12}
13
14/** The expanded Android card uses the same rows and priority as the iOS widget. */
15export function androidActivityData(aggregate: RelayAgentActivityAggregateState | null) {
16 const rows = [...(aggregate?.activities ?? [])].sort(
17 (a, b) => activityPhasePriority(a.phase) - activityPhasePriority(b.phase),
18 );
19 const activeCount = aggregate?.activeCount ?? 0;
20 const attentionCount = rows.filter((row) => activityPhasePriority(row.phase) === 0).length;
21 const failed = rows.some((row) => row.phase === "failed");
22 const clean = (value: string) => value.replace(/\s+/g, " ").trim();
23 const lines = rows.map((row) =>
24 [row.status, clean(row.threadTitle), clean(row.projectTitle)].join("\t"),
25 );
26 const hero = rows[0];
27 const title =
28 activeCount > 0
29 ? `${activeCount} active agent${activeCount === 1 ? "" : "s"}${attentionCount ? ` · ${attentionCount} need${attentionCount === 1 ? "s" : ""} attention` : ""}`
30 : failed
31 ? "Agent work failed"
32 : "Agent work completed";
33 const expiresAt = Math.max(
34 0,
35 ...rows.map((row) =>
36 row.phase === "completed" || row.phase === "failed"
37 ? Date.parse(row.updatedAt) + TERMINAL_AGENT_ACTIVITY_DISPLAY_TTL_MS
38 : agentActivityExpiresAt(row),
39 ),
40 );
41 return {
42 active: String(activeCount > 0),
43 activity_title: title,
44 activity_body: hero
45 ? `${hero.status}: ${clean(hero.threadTitle)} · ${clean(hero.projectTitle)}`
46 : "",
47 // Separate keys avoid double JSON encoding and preserve each expanded row when bounding the payload.
48 ...Object.fromEntries(lines.map((line, index) => [`activity_line_${index}`, line])),
49 activity_path: rows[0]?.deepLink ?? "/",
50 activity_expires_at: String(expiresAt),
51 };
52}
53
54/** Keep Unicode, escaping and grouped alerts within FCM's 4 KB data budget. */
55export function fitFcmData(input: Readonly<Record<string, string>>): Record<string, string> {
56 const data = { ...input };
57 const encoder = new TextEncoder();
58 const textKeys = Object.keys(data).filter(
59 (key) => key.endsWith("_body") || key.endsWith("_title") || key.startsWith("activity_line_"),
60 );
61 while (encoder.encode(JSON.stringify(data)).length > 3800) {
62 const key = textKeys.sort(
63 (a, b) => encoder.encode(data[b]!).length - encoder.encode(data[a]!).length,
64 )[0];
65 if (!key) break;
66 if (data[key]!.length <= 8) {
67 textKeys.splice(textKeys.indexOf(key), 1);
68 continue;
69 }
70 const parts = key.startsWith("activity_line_") ? data[key]!.split("\t") : [data[key]!];
71 const part = parts.length === 3 ? (parts[1]!.length > parts[2]!.length ? 1 : 2) : 0;
72 const characters = Array.from(parts[part]!);
73 // Five characters would become four plus the ellipsis and never shrink.
74 if (characters.length <= 5) {
75 textKeys.splice(textKeys.indexOf(key), 1);
76 continue;
77 }
78 parts[part] =
79 characters
80 .slice(0, Math.floor(characters.length * 0.8))
81 .join("")
82 .trimEnd() + "…";
83 data[key] = parts.join("\t");
84 }
85 return data;
86}
infra/relay/src/http/Api.test.ts+108-1
...@@ -2,6 +2,7 @@ import { createClerkClient, verifyToken } from "@clerk/backend";...@@ -2,6 +2,7 @@ import { createClerkClient, verifyToken } from "@clerk/backend";
2import { describe, expect, it } from "@effect/vitest";2import { describe, expect, it } from "@effect/vitest";
3import { vi } from "vite-plus/test";3import { vi } from "vite-plus/test";
4import * as Context from "effect/Context";4import * as Context from "effect/Context";
5import * as NodeCrypto from "@effect/platform-node/NodeCrypto";
5import * as Duration from "effect/Duration";6import * as Duration from "effect/Duration";
6import * as Effect from "effect/Effect";7import * as Effect from "effect/Effect";
7import * as Fiber from "effect/Fiber";8import * as Fiber from "effect/Fiber";
...@@ -14,11 +15,21 @@ import * as Tracer from "effect/Tracer";...@@ -14,11 +15,21 @@ import * as Tracer from "effect/Tracer";
14import * as HttpRouter from "effect/unstable/http/HttpRouter";15import * as HttpRouter from "effect/unstable/http/HttpRouter";
15import * as HttpServerRequest from "effect/unstable/http/HttpServerRequest";16import * as HttpServerRequest from "effect/unstable/http/HttpServerRequest";
16import * as HttpServerResponse from "effect/unstable/http/HttpServerResponse";17import * as HttpServerResponse from "effect/unstable/http/HttpServerResponse";
18import * as HttpServer from "effect/unstable/http/HttpServer";
19import * as HttpApiBuilder from "effect/unstable/httpapi/HttpApiBuilder";
20import * as HttpApi from "effect/unstable/httpapi/HttpApi";
17import { EnvironmentId } from "@t3tools/contracts";21import { EnvironmentId } from "@t3tools/contracts";
18import { RelayEnvironmentAuth } from "@t3tools/contracts/relay";22import {
23 RelayApi,
24 RelayClientAuth,
25 RelayClientPrincipal,
26 RelayEnvironmentAuth,
27 type RelayClientDeviceRecord,
28} from "@t3tools/contracts/relay";
1929
20import {30import {
21 RELAY_REQUEST_DEADLINE_MS,31 RELAY_REQUEST_DEADLINE_MS,
32 clientApi,
22 relayCors,33 relayCors,
23 relayDocsRedirectRoute,34 relayDocsRedirectRoute,
24 relayEnvironmentAuthLayer,35 relayEnvironmentAuthLayer,
...@@ -35,6 +46,9 @@ import * as RelayDb from "../db.ts";...@@ -35,6 +46,9 @@ import * as RelayDb from "../db.ts";
35import * as EnvironmentCredentials from "../environments/EnvironmentCredentials.ts";46import * as EnvironmentCredentials from "../environments/EnvironmentCredentials.ts";
36import * as EnvironmentLinks from "../environments/EnvironmentLinks.ts";47import * as EnvironmentLinks from "../environments/EnvironmentLinks.ts";
37import * as ManagedEndpointProvider from "../environments/ManagedEndpointProvider.ts";48import * as ManagedEndpointProvider from "../environments/ManagedEndpointProvider.ts";
49import * as EnvironmentLinker from "../environments/EnvironmentLinker.ts";
50import * as RelayTokens from "../auth/RelayTokens.ts";
51import * as Devices from "../agentActivity/Devices.ts";
3852
39vi.mock("@clerk/backend", () => ({53vi.mock("@clerk/backend", () => ({
40 createClerkClient: vi.fn(),54 createClerkClient: vi.fn(),
...@@ -60,6 +74,99 @@ const relaySettings: RelayConfiguration.RelayConfiguration["Service"] = {...@@ -60,6 +74,99 @@ const relaySettings: RelayConfiguration.RelayConfiguration["Service"] = {
60 managedEndpointNamespace: undefined,74 managedEndpointNamespace: undefined,
61};75};
6276
77describe("device listing compatibility", () => {
78 it.effect("keeps v1 iOS-only while v2 returns every platform for the same account", () => {
79 const iphone: RelayClientDeviceRecord = {
80 deviceId: "iphone",
81 label: "iPhone",
82 platform: "ios",
83 iosMajorVersion: 18,
84 appVersion: "1.0.0",
85 notifications: {
86 enabled: true,
87 notifyOnApproval: true,
88 notifyOnInput: true,
89 notifyOnCompletion: true,
90 notifyOnFailure: true,
91 },
92 liveActivities: { enabled: true },
93 updatedAt: "2026-09-09T00:00:00.000Z",
94 };
95 const android: RelayClientDeviceRecord = {
96 ...iphone,
97 deviceId: "android",
98 label: "Android",
99 platform: "android",
100 iosMajorVersion: null,
101 androidApiLevel: 36,
102 };
103 const handlers = clientApi.pipe(
104 HttpRouter.provideRequest(
105 Layer.mergeAll(
106 Layer.mock(EnvironmentCredentials.EnvironmentCredentials, {}),
107 Layer.mock(EnvironmentLinks.EnvironmentLinks, {}),
108 Layer.mock(ManagedEndpointProvider.ManagedEndpointProvider, {}),
109 Layer.mock(RelayDb.RelayTransactions, {}),
110 ),
111 ),
112 Layer.provide(
113 Layer.mergeAll(
114 Layer.succeed(RelayConfiguration.RelayConfiguration, relaySettings),
115 NodeCrypto.layer,
116 Layer.mock(RelayTokens.RelayTokens, { resolveDpopAccessTokenScopes: () => null }),
117 Layer.mock(EnvironmentLinker.EnvironmentLinker, {}),
118 Layer.mock(EnvironmentLinks.EnvironmentLinks, {}),
119 Layer.mock(ManagedEndpointProvider.ManagedEndpointProvider, {}),
120 Layer.mock(Devices.Devices, {
121 listForUser: ({ userId }) => {
122 expect(userId).toBe("user-1");
123 return Effect.succeed([iphone, android]);
124 },
125 }),
126 ),
127 ),
128 Layer.provideMerge(
129 Layer.succeed(RelayClientAuth, {
130 clientBearer: (effect) =>
131 Effect.provideService(effect, RelayClientPrincipal, {
132 userId: "user-1",
133 token: "test-token",
134 }),
135 }),
136 ),
137 );
138 return Effect.gen(function* () {
139 const app = yield* Effect.acquireRelease(
140 Effect.sync(() =>
141 HttpRouter.toWebHandler(
142 HttpApiBuilder.layer(HttpApi.make("RelayApi").add(RelayApi.groups.client)).pipe(
143 Layer.provide(handlers),
144 Layer.provide(HttpServer.layerServices),
145 ),
146 { disableLogger: true },
147 ),
148 ),
149 (app) => Effect.promise(() => app.dispose()),
150 );
151 for (const [version, devices] of [
152 ["v1", [iphone]],
153 ["v2", [iphone, android]],
154 ] as const) {
155 const response = yield* Effect.promise(() =>
156 app.handler(
157 new Request(`https://relay.example.test/${version}/client/devices`, {
158 headers: { authorization: "Bearer test-token" },
159 }),
160 ),
161 );
162 expect(response.status).toBe(200);
163 expect(response.headers.get("cache-control")).toBe("no-store");
164 expect(yield* Effect.promise(() => response.json())).toEqual({ devices });
165 }
166 }).pipe(Effect.scoped);
167 });
168});
169
63describe("relay client authentication", () => {170describe("relay client authentication", () => {
64 it.effect("preserves the existing Clerk session JWT path", () =>171 it.effect("preserves the existing Clerk session JWT path", () =>
65 Effect.gen(function* () {172 Effect.gen(function* () {
infra/relay/src/http/Api.ts+22
...@@ -545,6 +545,22 @@ export const clientApi = HttpApiBuilder.group(...@@ -545,6 +545,22 @@ export const clientApi = HttpApiBuilder.group(
545 .handle(545 .handle(
546 "listDevices",546 "listDevices",
547 Effect.fn("relay.api.client.listDevices")(function* () {547 Effect.fn("relay.api.client.listDevices")(function* () {
548 yield* appendRelayCredentialResponseHeaders;
549 const { userId } = yield* RelayClientPrincipal;
550 const registered = yield* devices.listForUser({ userId });
551 return {
552 devices: registered.flatMap((device) =>
553 device.platform === "ios" && device.iosMajorVersion !== null
554 ? [{ ...device, platform: "ios" as const, iosMajorVersion: device.iosMajorVersion }]
555 : [],
556 ),
557 };
558 }, mapRelayCommonApiErrors("not_authorized")),
559 )
560 .handle(
561 "listDevicesV2",
562 Effect.fn("relay.api.client.listDevicesV2")(function* () {
563 yield* appendRelayCredentialResponseHeaders;
548 const { userId } = yield* RelayClientPrincipal;564 const { userId } = yield* RelayClientPrincipal;
549 return { devices: yield* devices.listForUser({ userId }) };565 return { devices: yield* devices.listForUser({ userId }) };
550 }, mapRelayCommonApiErrors("not_authorized")),566 }, mapRelayCommonApiErrors("not_authorized")),
...@@ -981,6 +997,12 @@ export const serverApi = HttpApiBuilder.group(...@@ -981,6 +997,12 @@ export const serverApi = HttpApiBuilder.group(
981 reason: "upstream_unavailable",997 reason: "upstream_unavailable",
982 traceId,998 traceId,
983 }),999 }),
1000 FcmDeliveryError: (_error, traceId) =>
1001 new RelayInternalError({
1002 code: "internal_error",
1003 reason: "upstream_unavailable",
1004 traceId,
1005 }),
984 }),1006 }),
985 mapRelayCommonApiErrors("not_authorized"),1007 mapRelayCommonApiErrors("not_authorized"),
986 ),1008 ),
infra/relay/src/persistence/schema.ts+3-2
...@@ -21,8 +21,9 @@ export const relayMobileDevices = pgTable(...@@ -21,8 +21,9 @@ export const relayMobileDevices = pgTable(
21 userId: varchar("user_id", { length: 255 }).notNull(),21 userId: varchar("user_id", { length: 255 }).notNull(),
22 deviceId: varchar("device_id", { length: 255 }).notNull(),22 deviceId: varchar("device_id", { length: 255 }).notNull(),
23 label: text("label").notNull().default("iOS device"),23 label: text("label").notNull().default("iOS device"),
24 platform: varchar("platform", { length: 16 }).notNull().$type<"ios">(),24 platform: varchar("platform", { length: 16 }).notNull().$type<"ios" | "android">(),
25 iosMajorVersion: integer("ios_major_version").notNull(),25 iosMajorVersion: integer("ios_major_version"),
26 androidApiLevel: integer("android_api_level"),
26 appVersion: varchar("app_version", { length: 64 }),27 appVersion: varchar("app_version", { length: 64 }),
27 bundleId: varchar("bundle_id", { length: 255 }),28 bundleId: varchar("bundle_id", { length: 255 }),
28 apsEnvironment: varchar("aps_environment", { length: 16 }).$type<"sandbox" | "production">(),29 apsEnvironment: varchar("aps_environment", { length: 16 }).$type<"sandbox" | "production">(),
infra/relay/src/queues.ts+5
...@@ -5,3 +5,8 @@ export const RelayApnsDeliveryDeadLetterQueue = Cloudflare.Queues.Queue(...@@ -5,3 +5,8 @@ export const RelayApnsDeliveryDeadLetterQueue = Cloudflare.Queues.Queue(
5);5);
66
7export const RelayApnsDeliveryQueue = Cloudflare.Queues.Queue("RelayApnsDeliveryQueue");7export const RelayApnsDeliveryQueue = Cloudflare.Queues.Queue("RelayApnsDeliveryQueue");
8
9export const RelayFcmDeliveryQueue = Cloudflare.Queues.Queue("RelayFcmDeliveryQueue");
10export const RelayFcmDeliveryDeadLetterQueue = Cloudflare.Queues.Queue(
11 "RelayFcmDeliveryDeadLetterQueue",
12);
infra/relay/src/worker.ts+72-17
...@@ -6,6 +6,8 @@ import * as DateTime from "effect/DateTime";...@@ -6,6 +6,8 @@ import * as DateTime from "effect/DateTime";
6import * as Crypto from "effect/Crypto";6import * as Crypto from "effect/Crypto";
7import * as Effect from "effect/Effect";7import * as Effect from "effect/Effect";
8import * as Layer from "effect/Layer";8import * as Layer from "effect/Layer";
9import * as Option from "effect/Option";
10import * as Redacted from "effect/Redacted";
9import * as Stream from "effect/Stream";11import * as Stream from "effect/Stream";
10import * as Etag from "effect/unstable/http/Etag";12import * as Etag from "effect/unstable/http/Etag";
11import * as HttpPlatform from "effect/unstable/http/HttpPlatform";13import * as HttpPlatform from "effect/unstable/http/HttpPlatform";
...@@ -44,7 +46,18 @@ import * as EnvironmentLinks from "./environments/EnvironmentLinks.ts";...@@ -44,7 +46,18 @@ import * as EnvironmentLinks from "./environments/EnvironmentLinks.ts";
44import * as ManagedEndpointAllocations from "./environments/ManagedEndpointAllocations.ts";46import * as ManagedEndpointAllocations from "./environments/ManagedEndpointAllocations.ts";
45import * as LiveActivities from "./agentActivity/LiveActivities.ts";47import * as LiveActivities from "./agentActivity/LiveActivities.ts";
46import * as RelayDb from "./db.ts";48import * as RelayDb from "./db.ts";
47import { RelayApnsDeliveryDeadLetterQueue, RelayApnsDeliveryQueue } from "./queues.ts";49import {
50 RelayApnsDeliveryDeadLetterQueue,
51 RelayApnsDeliveryQueue,
52 RelayFcmDeliveryQueue,
53 RelayFcmDeliveryDeadLetterQueue,
54} from "./queues.ts";
55import * as WebCrypto from "./WebCrypto.ts";
56import * as FcmAssertionSigner from "./agentActivity/FcmAssertionSigner.ts";
57import * as FcmClient from "./agentActivity/FcmClient.ts";
58import * as FcmDeliveryQueueSender from "./agentActivity/FcmDeliveryQueueSender.ts";
59import * as FcmDeliveries from "./agentActivity/FcmDeliveries.ts";
60import * as FcmDeliveryQueueConsumer from "./agentActivity/FcmDeliveryQueueConsumer.ts";
48import * as RelayConfiguration from "./Config.ts";61import * as RelayConfiguration from "./Config.ts";
49import * as AgentActivityPublisher from "./agentActivity/AgentActivityPublisher.ts";62import * as AgentActivityPublisher from "./agentActivity/AgentActivityPublisher.ts";
50import * as ApnsClient from "./agentActivity/ApnsClient.ts";63import * as ApnsClient from "./agentActivity/ApnsClient.ts";
...@@ -117,6 +130,8 @@ export const ApiLive = Api.make(...@@ -117,6 +130,8 @@ export const ApiLive = Api.make(
117 const { relayPublicOrigin, stage } = yield* RelayDeploymentConfig;130 const { relayPublicOrigin, stage } = yield* RelayDeploymentConfig;
118 const apnsDeliveryQueue = yield* RelayApnsDeliveryQueue;131 const apnsDeliveryQueue = yield* RelayApnsDeliveryQueue;
119 const apnsDeliveryDeadLetterQueue = yield* RelayApnsDeliveryDeadLetterQueue;132 const apnsDeliveryDeadLetterQueue = yield* RelayApnsDeliveryDeadLetterQueue;
133 const fcmDeliveryQueue = yield* RelayFcmDeliveryQueue;
134 const fcmDeliveryDeadLetterQueue = yield* RelayFcmDeliveryDeadLetterQueue;
120 const cloudMintKeyPair = yield* CloudMintKeyPair;135 const cloudMintKeyPair = yield* CloudMintKeyPair;
121 const relayApiZone = yield* RelayApiZone;136 const relayApiZone = yield* RelayApiZone;
122 const managedEndpointZone = yield* ManagedEndpointZone;137 const managedEndpointZone = yield* ManagedEndpointZone;
...@@ -126,16 +141,25 @@ export const ApiLive = Api.make(...@@ -126,16 +141,25 @@ export const ApiLive = Api.make(
126 //141 //
127 // 2. Create bindings142 // 2. Create bindings
128 //143 //
129 const environment = yield* Config.schema(144 const apnsEnabled = yield* Config.boolean("APNS_ENABLED").pipe(Config.withDefault(true));
130 RelayConfiguration.ApnsEnvironment,145 const apnsCredentials = apnsEnabled
131 "APNS_ENVIRONMENT",146 ? {
147 environment: yield* Config.schema(RelayConfiguration.ApnsEnvironment, "APNS_ENVIRONMENT"),
148 teamId: yield* Config.string("APNS_TEAM_ID"),
149 keyId: yield* Config.string("APNS_KEY_ID"),
150 bundleId: yield* Config.string("APNS_BUNDLE_ID"),
151 privateKey: yield* Config.redacted("APNS_PRIVATE_KEY"),
152 }
153 : null;
154 const fcmServiceAccount = Option.getOrUndefined(
155 Option.filter(
156 yield* Config.option(Config.redacted("FCM_SERVICE_ACCOUNT")),
157 (value) => Redacted.value(value).trim().length > 0,
158 ),
132 );159 );
133 const apnsTeamId = yield* Config.string("APNS_TEAM_ID");
134 const apnsKeyId = yield* Config.string("APNS_KEY_ID");
135 const apnsBundleId = yield* Config.string("APNS_BUNDLE_ID");
136 const apnsPrivateKey = yield* Config.redacted("APNS_PRIVATE_KEY");
137 const apnsDeliveryJobSigningSecret = yield* randomApnsDeliveryJobSigningSecret;160 const apnsDeliveryJobSigningSecret = yield* randomApnsDeliveryJobSigningSecret;
138 const apnsDeliveryQueueSender = yield* Cloudflare.Queues.WriteQueue(apnsDeliveryQueue);161 const apnsDeliveryQueueSender = yield* Cloudflare.Queues.WriteQueue(apnsDeliveryQueue);
162 const fcmDeliveryQueueSender = yield* Cloudflare.Queues.WriteQueue(fcmDeliveryQueue);
139163
140 const axiomDatasetName = yield* observability.traces.name;164 const axiomDatasetName = yield* observability.traces.name;
141 const axiomIngestToken = yield* observability.workerIngestToken.token;165 const axiomIngestToken = yield* observability.workerIngestToken.token;
...@@ -164,13 +188,8 @@ export const ApiLive = Api.make(...@@ -164,13 +188,8 @@ export const ApiLive = Api.make(
164 const loadSettings = Effect.gen(function* () {188 const loadSettings = Effect.gen(function* () {
165 return RelayConfiguration.RelayConfiguration.of({189 return RelayConfiguration.RelayConfiguration.of({
166 relayIssuer: relayPublicOrigin,190 relayIssuer: relayPublicOrigin,
167 apns: {191 ...(fcmServiceAccount ? { fcmServiceAccount } : {}),
168 environment,192 apns: apnsCredentials,
169 teamId: apnsTeamId,
170 keyId: apnsKeyId,
171 bundleId: apnsBundleId,
172 privateKey: apnsPrivateKey,
173 },
174 apnsDeliveryJobSigningSecret: yield* apnsDeliveryJobSigningSecret,193 apnsDeliveryJobSigningSecret: yield* apnsDeliveryJobSigningSecret,
175 clerkSecretKey,194 clerkSecretKey,
176 clerkPublishableKey,195 clerkPublishableKey,
...@@ -205,12 +224,31 @@ export const ApiLive = Api.make(...@@ -205,12 +224,31 @@ export const ApiLive = Api.make(
205 ),224 ),
206 Layer.provideMerge(DpopProofs.layer),225 Layer.provideMerge(DpopProofs.layer),
207 Layer.provideMerge(ApnsDeliveries.layer),226 Layer.provideMerge(ApnsDeliveries.layer),
227 Layer.provideMerge(
228 FcmDeliveries.layer.pipe(
229 Layer.provide(
230 Layer.succeed(FcmDeliveryQueueSender.FcmDeliveryQueueSender, {
231 send: (body) =>
232 fcmDeliveryQueueSender
233 .send(body)
234 .pipe(Effect.provideService(Alchemy.RuntimeContext, alchemyRuntimeContext)),
235 }),
236 ),
237 Layer.provideMerge(
238 FcmClient.layer.pipe(
239 Layer.provide(FcmAssertionSigner.layer),
240 Layer.provide(
241 Layer.succeed(WebCrypto.WebCrypto, { subtle: globalThis.crypto.subtle }),
242 ),
243 ),
244 ),
245 ),
246 ),
208 Layer.provideMerge(ApnsClient.layer.pipe(Layer.provideMerge(ApnsProviderTokens.layer))),247 Layer.provideMerge(ApnsClient.layer.pipe(Layer.provideMerge(ApnsProviderTokens.layer))),
209 Layer.provideMerge(248 Layer.provideMerge(
210 ApnsDeliveryQueue.layerCloudflareQueues(apnsDeliveryQueueSender, alchemyRuntimeContext),249 ApnsDeliveryQueue.layerCloudflareQueues(apnsDeliveryQueueSender, alchemyRuntimeContext),
211 ),250 ),
212 Layer.provideMerge(AgentActivityRows.layer),251 Layer.provideMerge(Layer.mergeAll(AgentActivityRows.layer, Devices.layer)),
213 Layer.provideMerge(Devices.layer),
214 Layer.provideMerge(EnvironmentCredentials.layer),252 Layer.provideMerge(EnvironmentCredentials.layer),
215 Layer.provideMerge(253 Layer.provideMerge(
216 Layer.mergeAll(254 Layer.mergeAll(
...@@ -260,6 +298,23 @@ export const ApiLive = Api.make(...@@ -260,6 +298,23 @@ export const ApiLive = Api.make(
260 ),298 ),
261 );299 );
262300
301 yield* Cloudflare.Queues.consumeQueueMessages<unknown>(
302 fcmDeliveryQueue,
303 {
304 batchSize: 10,
305 maxRetries: 5,
306 maxWaitTime: "1 second",
307 retryDelay: "30 seconds",
308 deadLetterQueue: fcmDeliveryDeadLetterQueue.queueName as unknown as string,
309 },
310 (stream) =>
311 stream.pipe(
312 Stream.withSpan("relay.fcm_delivery_queue.process_batch"),
313 Stream.runForEach(FcmDeliveryQueueConsumer.processMessage),
314 Effect.provide(runtimeLayer),
315 ),
316 );
317
263 yield* Cloudflare.Workers.cron("*/5 * * * *", () =>318 yield* Cloudflare.Workers.cron("*/5 * * * *", () =>
264 DpopProofs.DpopProofReplay.pipe(319 DpopProofs.DpopProofReplay.pipe(
265 Effect.flatMap((dpopProofs) => dpopProofs.pruneExpired),320 Effect.flatMap((dpopProofs) => dpopProofs.pruneExpired),
packages/client-runtime/src/relay/managedRelay.test.ts+25-2
...@@ -636,9 +636,9 @@ describe("ManagedRelayClient", () => {...@@ -636,9 +636,9 @@ describe("ManagedRelayClient", () => {
636 }).pipe(Effect.provide(managedRelayTestLayer(fetchFn)));636 }).pipe(Effect.provide(managedRelayTestLayer(fetchFn)));
637 });637 });
638638
639 it.effect("lists account devices through the Clerk bearer client endpoint", () => {639 it.effect("lists account devices through the v2 Clerk bearer client endpoint", () => {
640 const fetchFn = ((input, init) => {640 const fetchFn = ((input, init) => {
641 expect(String(input)).toBe("https://relay.example.test/v1/client/devices");641 expect(String(input)).toBe("https://relay.example.test/v2/client/devices");
642 expect(init?.headers).toMatchObject({642 expect(init?.headers).toMatchObject({
643 authorization: "Bearer clerk-token",643 authorization: "Bearer clerk-token",
644 });644 });
...@@ -663,6 +663,23 @@ describe("ManagedRelayClient", () => {...@@ -663,6 +663,23 @@ describe("ManagedRelayClient", () => {
663 },663 },
664 updatedAt: "2026-06-01T00:00:00.000Z",664 updatedAt: "2026-06-01T00:00:00.000Z",
665 },665 },
666 {
667 deviceId: "device-2",
668 label: "Android phone",
669 platform: "android",
670 iosMajorVersion: null,
671 androidApiLevel: 36,
672 appVersion: "1.0.0",
673 notifications: {
674 enabled: true,
675 notifyOnApproval: true,
676 notifyOnInput: true,
677 notifyOnCompletion: true,
678 notifyOnFailure: true,
679 },
680 liveActivities: { enabled: true },
681 updatedAt: "2026-06-01T00:00:00.000Z",
682 },
666 ],683 ],
667 }),684 }),
668 );685 );
...@@ -679,6 +696,12 @@ describe("ManagedRelayClient", () => {...@@ -679,6 +696,12 @@ describe("ManagedRelayClient", () => {
679 enabled: false,696 enabled: false,
680 },697 },
681 },698 },
699 {
700 deviceId: "device-2",
701 platform: "android",
702 iosMajorVersion: null,
703 androidApiLevel: 36,
704 },
682 ]);705 ]);
683 }).pipe(Effect.provide(managedRelayTestLayer(fetchFn)));706 }).pipe(Effect.provide(managedRelayTestLayer(fetchFn)));
684 });707 });
packages/client-runtime/src/relay/managedRelay.ts+1-1
...@@ -729,7 +729,7 @@ export const make = Effect.fn("ManagedRelayClient.make")(function* (...@@ -729,7 +729,7 @@ export const make = Effect.fn("ManagedRelayClient.make")(function* (
729 listDevices: Effect.fnUntraced(729 listDevices: Effect.fnUntraced(
730 function* (input) {730 function* (input) {
731 return yield* client.client731 return yield* client.client
732 .listDevices({732 .listDevicesV2({
733 headers: bearerHeaders(input.clerkToken),733 headers: bearerHeaders(input.clerkToken),
734 })734 })
735 .pipe(735 .pipe(
packages/contracts/src/relay.test.ts+46-1
...@@ -1,7 +1,52 @@...@@ -1,7 +1,52 @@
1import { describe, expect, it } from "vite-plus/test";1import { describe, expect, it } from "vite-plus/test";
2import * as OpenApi from "effect/unstable/httpapi/OpenApi";2import * as OpenApi from "effect/unstable/httpapi/OpenApi";
3import * as Schema from "effect/Schema";
34
4import { RelayApi } from "./relay.ts";5import { RelayApi, RelayDeviceRegistrationRequest } from "./relay.ts";
6
7const decodeDevice = Schema.decodeUnknownExit(RelayDeviceRegistrationRequest);
8const device = {
9 deviceId: "device",
10 label: "Phone",
11 pushToken: "token",
12 preferences: {
13 notificationsEnabled: true,
14 liveActivitiesEnabled: true,
15 notifyOnApproval: true,
16 notifyOnInput: true,
17 notifyOnCompletion: true,
18 notifyOnFailure: true,
19 },
20};
21
22describe("mobile device platforms", () => {
23 it.each([
24 [23, "Failure"],
25 [24, "Success"],
26 [37, "Success"],
27 ])("enforces the Android minimum without an upper bound (API %i)", (androidApiLevel, result) => {
28 expect(decodeDevice({ ...device, platform: "android", androidApiLevel })._tag).toBe(result);
29 });
30
31 it("accepts Android tokens without Apple routing and preserves older iOS registrations", () => {
32 expect(decodeDevice({ ...device, platform: "android", androidApiLevel: 36 })._tag).toBe(
33 "Success",
34 );
35 expect(decodeDevice({ ...device, platform: "ios", iosMajorVersion: 18 })._tag).toBe("Success");
36 });
37 it("rejects missing platform versions and Apple activity tokens on Android", () => {
38 expect(decodeDevice({ ...device, platform: "ios" })._tag).toBe("Failure");
39 expect(decodeDevice({ ...device, platform: "android" })._tag).toBe("Failure");
40 expect(
41 decodeDevice({
42 ...device,
43 platform: "android",
44 androidApiLevel: 36,
45 pushToStartToken: "apple-token",
46 })._tag,
47 ).toBe("Failure");
48 });
49});
550
6describe("RelayApi security", () => {51describe("RelayApi security", () => {
7 it("describes DPoP access tokens using the HTTP DPoP authorization scheme", () => {52 it("describes DPoP access tokens using the HTTP DPoP authorization scheme", () => {
packages/contracts/src/relay.ts+31-4
...@@ -16,7 +16,7 @@ import {...@@ -16,7 +16,7 @@ import {
16} from "./baseSchemas.ts";16} from "./baseSchemas.ts";
17import { ExecutionEnvironmentDescriptor } from "./environment.ts";17import { ExecutionEnvironmentDescriptor } from "./environment.ts";
1818
19export const RelayAgentAwarenessPlatform = Schema.Literal("ios");19export const RelayAgentAwarenessPlatform = Schema.Literals(["ios", "android"]);
20export type RelayAgentAwarenessPlatform = typeof RelayAgentAwarenessPlatform.Type;20export type RelayAgentAwarenessPlatform = typeof RelayAgentAwarenessPlatform.Type;
2121
22export const RelayAgentAwarenessPhase = Schema.Literals([22export const RelayAgentAwarenessPhase = Schema.Literals([
...@@ -47,7 +47,8 @@ export const RelayDeviceRegistrationRequest = Schema.Struct({...@@ -47,7 +47,8 @@ export const RelayDeviceRegistrationRequest = Schema.Struct({
47 deviceId: TrimmedNonEmptyString,47 deviceId: TrimmedNonEmptyString,
48 label: TrimmedNonEmptyString,48 label: TrimmedNonEmptyString,
49 platform: RelayAgentAwarenessPlatform,49 platform: RelayAgentAwarenessPlatform,
50 iosMajorVersion: Schema.Int.check(Schema.isGreaterThanOrEqualTo(18)),50 iosMajorVersion: Schema.optional(Schema.Int.check(Schema.isGreaterThanOrEqualTo(18))),
51 androidApiLevel: Schema.optional(Schema.Int.check(Schema.isGreaterThanOrEqualTo(24))),
51 appVersion: Schema.optional(TrimmedNonEmptyString),52 appVersion: Schema.optional(TrimmedNonEmptyString),
52 // APNs routing for this install: the topic must match the app's bundle id53 // APNs routing for this install: the topic must match the app's bundle id
53 // (dev/preview/prod variants differ) and development-signed builds receive54 // (dev/preview/prod variants differ) and development-signed builds receive
...@@ -58,14 +59,24 @@ export const RelayDeviceRegistrationRequest = Schema.Struct({...@@ -58,14 +59,24 @@ export const RelayDeviceRegistrationRequest = Schema.Struct({
58 pushToken: Schema.optional(TrimmedNonEmptyString),59 pushToken: Schema.optional(TrimmedNonEmptyString),
59 pushToStartToken: Schema.optional(TrimmedNonEmptyString),60 pushToStartToken: Schema.optional(TrimmedNonEmptyString),
60 preferences: RelayAgentAwarenessPreferences,61 preferences: RelayAgentAwarenessPreferences,
61});62}).check(
63 Schema.makeFilter((device) =>
64 device.platform === "ios"
65 ? device.iosMajorVersion !== undefined
66 : device.androidApiLevel !== undefined &&
67 device.iosMajorVersion === undefined &&
68 device.apsEnvironment === undefined &&
69 device.pushToStartToken === undefined,
70 ),
71);
62export type RelayDeviceRegistrationRequest = typeof RelayDeviceRegistrationRequest.Type;72export type RelayDeviceRegistrationRequest = typeof RelayDeviceRegistrationRequest.Type;
6373
64export const RelayClientDeviceRecord = Schema.Struct({74export const RelayClientDeviceRecord = Schema.Struct({
65 deviceId: TrimmedNonEmptyString,75 deviceId: TrimmedNonEmptyString,
66 label: TrimmedNonEmptyString,76 label: TrimmedNonEmptyString,
67 platform: RelayAgentAwarenessPlatform,77 platform: RelayAgentAwarenessPlatform,
68 iosMajorVersion: Schema.Int.check(Schema.isGreaterThanOrEqualTo(18)),78 iosMajorVersion: Schema.NullOr(Schema.Int.check(Schema.isGreaterThanOrEqualTo(18))),
79 androidApiLevel: Schema.optional(Schema.NullOr(Schema.Int)),
69 appVersion: Schema.NullOr(TrimmedNonEmptyString),80 appVersion: Schema.NullOr(TrimmedNonEmptyString),
70 notifications: Schema.Struct({81 notifications: Schema.Struct({
71 enabled: Schema.Boolean,82 enabled: Schema.Boolean,
...@@ -86,6 +97,17 @@ export const RelayListDevicesResponse = Schema.Struct({...@@ -86,6 +97,17 @@ export const RelayListDevicesResponse = Schema.Struct({
86});97});
87export type RelayListDevicesResponse = typeof RelayListDevicesResponse.Type;98export type RelayListDevicesResponse = typeof RelayListDevicesResponse.Type;
8899
100// Installed clients decode v1 as iOS-only. Keep that response contract frozen.
101export const RelayListDevicesResponseV1 = Schema.Struct({
102 devices: Schema.Array(
103 Schema.Struct({
104 ...RelayClientDeviceRecord.fields,
105 platform: Schema.Literal("ios"),
106 iosMajorVersion: Schema.Int.check(Schema.isGreaterThanOrEqualTo(18)),
107 }),
108 ),
109});
110
89export const RelayLiveActivityRegistrationRequest = Schema.Struct({111export const RelayLiveActivityRegistrationRequest = Schema.Struct({
90 deviceId: TrimmedNonEmptyString,112 deviceId: TrimmedNonEmptyString,
91 activityPushToken: TrimmedNonEmptyString,113 activityPushToken: TrimmedNonEmptyString,
...@@ -964,6 +986,11 @@ const RelayClientGroup = HttpApiGroup.make("client")...@@ -964,6 +986,11 @@ const RelayClientGroup = HttpApiGroup.make("client")
964 error: RelayAuthAndInternalErrors,986 error: RelayAuthAndInternalErrors,
965 }).annotate(OpenApi.Summary, "List linked environments"),987 }).annotate(OpenApi.Summary, "List linked environments"),
966 HttpApiEndpoint.get("listDevices", "/v1/client/devices", {988 HttpApiEndpoint.get("listDevices", "/v1/client/devices", {
989 headers: RelayBearerRequestHeaders,
990 success: RelayListDevicesResponseV1,
991 error: RelayAuthAndInternalErrors,
992 }).annotate(OpenApi.Summary, "List registered iOS devices (legacy clients)"),
993 HttpApiEndpoint.get("listDevicesV2", "/v2/client/devices", {
967 headers: RelayBearerRequestHeaders,994 headers: RelayBearerRequestHeaders,
968 success: RelayListDevicesResponse,995 success: RelayListDevicesResponse,
969 error: RelayAuthAndInternalErrors,996 error: RelayAuthAndInternalErrors,