authorgravatar for 76637177+SunkenInTime@users.noreply.github.comDara Adedeji <76637177+SunkenInTime@users.noreply.github.com> 2026-09-14 20:14:58-04:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-15 00:14:58+00:00
logea6af5924b3d55265826641233a1eda0bfc9a2e7
treef83603420588e6ce448bd6eb4e77061b2ec04834
parent3be02ae5791480b8c8bf3a13020a932378eca1ba
signature Signed by PGP key B5690EEEBB952194

feat(mobile): redesign the Android agent activity card (#11645)

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

13 files changed, 570 insertions(+), 62 deletions(-)

apps/mobile/modules/t3-agent-notifications/android/src/main/java/expo/modules/t3agentnotifications/AgentActivityPresentation.kt created+201
......@@ -0,0 +1,201 @@
1package expo.modules.t3agentnotifications
2
3import android.content.Context
4import android.graphics.Typeface
5import android.text.SpannableStringBuilder
6import android.text.Spanned
7import android.text.style.ForegroundColorSpan
8import android.text.style.StyleSpan
9import androidx.core.app.NotificationCompat
10import androidx.core.content.ContextCompat
11
12internal data class ActivityRow(val status: String, val title: String, val project: String)
13
14/**
15 * One entry per relay phase. The status label matches the relay's row wording and the
16 * tint matches the web sidebar pills and the iOS Live Activity so a thread reads the
17 * same on every surface. The icon is always the T3 mark; the chip verb carries the state.
18 */
19internal enum class ActivityPhase(
20 val status: String,
21 val heading: String,
22 val chip: String,
23 val action: String,
24 val color: Int
25) {
26 STARTING("Connecting", "Starting", "Working", "Open", R.color.agent_activity_working),
27 RUNNING("Working", "Working", "Working", "Open", R.color.agent_activity_working),
28 APPROVAL(
29 "Approval",
30 "Approval needed",
31 "Approve",
32 "Approve",
33 R.color.agent_activity_attention
34 ),
35 INPUT(
36 "Input",
37 "Question for you",
38 "Answer",
39 "Answer",
40 R.color.agent_activity_input
41 ),
42 STALE(
43 "Waiting",
44 "Waiting for an update",
45 "Waiting",
46 "Open",
47 R.color.agent_activity_waiting
48 ),
49 COMPLETED(
50 "Done",
51 "Finished",
52 "Done",
53 "Open",
54 R.color.agent_activity_done
55 ),
56 FAILED(
57 "Failed",
58 "Failed",
59 "Failed",
60 "Open",
61 R.color.agent_activity_failed
62 );
63
64 val needsUser get() = this == APPROVAL || this == INPUT
65 val finished get() = this == COMPLETED || this == FAILED
66
67 companion object {
68 fun forStatus(status: String) = entries.firstOrNull { it.status == status }
69 }
70}
71
72/** The relay orders rows and their deep link together; never reorder them in the client. */
73internal fun activityRows(data: Map<String, String>) = (0..4).mapNotNull {
74 val parts = data["activity_line_$it"]?.split('\t', limit = 3) ?: return@mapNotNull null
75 if (parts.size != 3 || parts[1].isBlank()) {
76 null
77 } else {
78 ActivityRow(parts[0].take(40), parts[1].take(120), parts[2].take(120))
79 }
80}
81
82internal fun activityPhase(data: Map<String, String>, rows: List<ActivityRow>): ActivityPhase? =
83 when (data["activity_phase"]?.takeIf { it.isNotBlank() }) {
84 "starting" -> ActivityPhase.STARTING
85 "running" -> ActivityPhase.RUNNING
86 "waiting_for_approval" -> ActivityPhase.APPROVAL
87 "waiting_for_input" -> ActivityPhase.INPUT
88 "stale" -> ActivityPhase.STALE
89 "completed" -> ActivityPhase.COMPLETED
90 "failed" -> ActivityPhase.FAILED
91 else -> rows.firstOrNull()?.let { ActivityPhase.forStatus(it.status) }
92 }
93
94/**
95 * Header carries the state, the title says what needs you (or which thread, when
96 * there is only one), and the body lists every thread with its status in front.
97 * System UI renders all of it, so the same builder serves the shade, the lock
98 * screen and the status bar chip.
99 */
100internal class ActivityPresentation(data: Map<String, String>, private val active: Boolean) {
101 private val rows = activityRows(data)
102 private val hero = rows.firstOrNull()
103 val phase = activityPhase(data, rows)
104 private val activeCount = data["activity_active_count"]?.toIntOrNull()?.coerceAtLeast(0)
105 ?: rows.count { ActivityPhase.forStatus(it.status)?.finished != true }
106 private val attentionCount = data["activity_attention_count"]?.toIntOrNull()?.coerceAtLeast(0)
107 ?: rows.count { ActivityPhase.forStatus(it.status)?.needsUser == true }
108 private val failedCount = rows.count { it.status == ActivityPhase.FAILED.status }
109 val threadCount =
110 activeCount + rows.count { ActivityPhase.forStatus(it.status)?.finished == true }
111 private val singleProject = rows.map { it.project }.distinct().size == 1
112 private val legacyBody = (0..4).mapNotNull { data["activity_line_$it"]?.take(300) }
113 .takeIf { it.isNotEmpty() }?.joinToString("\n")
114 ?: data["activity_body"].orEmpty().take(240)
115
116 val summary = when {
117 hero == null -> data["activity_title"]?.takeIf { it.isNotBlank() }?.take(120)
118 ?: "Agent activity"
119 rows.size == 1 -> hero.title
120 attentionCount == 1 -> "1 needs you"
121 attentionCount > 1 -> "$attentionCount need you"
122 activeCount > 0 && failedCount > 0 -> "$failedCount failed"
123 activeCount > 0 -> "$activeCount working"
124 failedCount > 0 -> "Finished, $failedCount failed"
125 else -> "All finished"
126 }
127
128 val chip = when {
129 !active -> null
130 phase == null -> data["activity_chip"]?.takeIf { it.isNotBlank() }?.take(7) ?: "Active"
131 phase == ActivityPhase.RUNNING && activeCount > 1 ->
132 "${if (activeCount > 9) "9+" else activeCount} live"
133 else -> phase.chip
134 }
135
136 val action = if (active) phase?.action ?: "Open" else null
137
138 fun applyTo(builder: NotificationCompat.Builder, context: Context) {
139 val tint = phase?.let { ContextCompat.getColor(context, it.color) }
140 builder.setSmallIcon(R.drawable.agent_activity_mark)
141 if (tint != null) builder.setColor(tint)
142 // Tint the summary only when it names an outcome or a request; a plain
143 // "3 working" stays neutral so the accent keeps meaning something.
144 val tintedSummary = tint != null && rows.size > 1 && phase != ActivityPhase.RUNNING &&
145 phase != ActivityPhase.STARTING
146 builder.setContentTitle(if (tintedSummary) tinted(summary, tint!!) else summary)
147 if (rows.size > 1) {
148 builder.setSubText(
149 listOfNotNull(
150 hero!!.project.takeIf { singleProject && it.isNotBlank() },
151 if (activeCount > 0) "$activeCount active" else "$threadCount threads"
152 ).joinToString(" · ")
153 )
154 }
155 val body = body(context)
156 // The collapsed card gets the priority row; the expanded card gets them all.
157 val lineBreak = body.indexOf('\n')
158 val firstLine = if (lineBreak >= 0) body.subSequence(0, lineBreak) else body
159 builder.setContentText(firstLine).setStyle(NotificationCompat.BigTextStyle().bigText(body))
160 // Thread update timestamps can change while an approval remains pending.
161 // Leave the timer hidden until the payload has a stable phase-entry timestamp.
162 builder.setShowWhen(false).setUsesChronometer(false)
163 }
164
165 private fun body(context: Context): CharSequence = when {
166 hero == null -> legacyBody
167 // The title already names the thread; the body only needs its status and project.
168 rows.size == 1 -> statusLine(context, hero.copy(title = hero.project), "")
169 else -> SpannableStringBuilder().apply {
170 rows.forEachIndexed { index, row ->
171 if (index > 0) append("\n")
172 append(statusLine(context, row, row.project.takeUnless { singleProject }.orEmpty()))
173 }
174 }
175 }
176
177 private fun statusLine(context: Context, row: ActivityRow, trailing: String): CharSequence =
178 SpannableStringBuilder().apply {
179 val status = ActivityPhase.forStatus(row.status)
180 val color = ContextCompat.getColor(context, status?.color ?: R.color.agent_activity_waiting)
181 append(tinted(row.status, color, bold = true))
182 append(" ").append(row.title)
183 // Promoted cards drop text color, so the separator has to do the work of the dimming.
184 if (trailing.isNotBlank()) {
185 val start = length
186 append(" · ").append(trailing)
187 setSpan(
188 ForegroundColorSpan(ContextCompat.getColor(context, R.color.agent_activity_waiting)),
189 start,
190 length,
191 Spanned.SPAN_EXCLUSIVE_EXCLUSIVE
192 )
193 }
194 }
195
196 private fun tinted(text: String, color: Int, bold: Boolean = false) =
197 SpannableStringBuilder(text).apply {
198 setSpan(ForegroundColorSpan(color), 0, length, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE)
199 if (bold) setSpan(StyleSpan(Typeface.BOLD), 0, length, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE)
200 }
201}
apps/mobile/modules/t3-agent-notifications/android/src/main/java/expo/modules/t3agentnotifications/AgentNotifications.kt+14-44
......@@ -11,8 +11,6 @@ import android.content.Intent
1111import android.content.SharedPreferences
1212import android.net.Uri
1313import android.os.Build
14import android.text.TextPaint
15import android.text.TextUtils
1614import androidx.core.app.NotificationCompat
1715import androidx.core.app.NotificationManagerCompat
1816import androidx.lifecycle.Lifecycle
......@@ -197,34 +195,32 @@ object AgentNotifications {
197195 active: Boolean,
198196 remainingMs: Long
199197 ) {
200 val body = data["activity_body"].orEmpty().take(240)
201198 val dismissIntent = PendingIntent.getBroadcast(
202199 context,
203200 ACTIVITY_ID,
204201 Intent(context, AgentActivityDismissReceiver::class.java),
205202 PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
206203 )
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)
204 val presentation = ActivityPresentation(data, active)
205 val openThread = contentIntent(context, scheme, data["activity_path"], ACTIVITY_ID)
206 val builder = base(context, ACTIVITY_CHANNEL)
218207 .setOngoing(active).setOnlyAlertOnce(true).setSilent(true)
219208 .setTimeoutAfter(remainingMs)
220209 // Live Updates must remain uncolorized to qualify for promotion.
221210 .setColorized(false)
222211 .setRequestPromotedOngoing(active)
223 .setContentIntent(contentIntent(context, scheme, data["activity_path"], ACTIVITY_ID))
212 .setShortCriticalText(presentation.chip)
213 .setContentIntent(openThread)
224214 .setDeleteIntent(dismissIntent)
225 .addAction(0, "Dismiss", dismissIntent)
226 .build()
227 manager(context).notify(ACTIVITY_TAG, ACTIVITY_ID, notification)
215 presentation.applyTo(builder, context)
216 // A finished card is no longer ongoing, so it swipes away and a tap opens
217 // the thread; buttons would only repeat that.
218 val action = presentation.action
219 if (action != null) {
220 if (openThread != null) builder.addAction(0, action, openThread)
221 builder.addAction(0, "Dismiss", dismissIntent)
222 }
223 manager(context).notify(ACTIVITY_TAG, ACTIVITY_ID, builder.build())
228224 if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) {
229225 // Notification timeouts were added in API 26. One inexact alarm also
230226 // expires cards on Android 7, including when the app process has exited.
......@@ -253,32 +249,6 @@ object AgentNotifications {
253249 }
254250 }
255251
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
282252 private fun manager(context: Context) = context.getSystemService(NotificationManager::class.java)
283253
284254 private fun channels(context: Context) {
apps/mobile/modules/t3-agent-notifications/android/src/main/java/expo/modules/t3agentnotifications/T3AgentNotificationsModule.kt+22
......@@ -1,5 +1,9 @@
11package expo.modules.t3agentnotifications
22
3import android.content.ActivityNotFoundException
4import android.content.Intent
5import android.os.Build
6import android.provider.Settings
37import expo.modules.kotlin.modules.Module
48import expo.modules.kotlin.modules.ModuleDefinition
59
......@@ -21,5 +25,23 @@ class T3AgentNotificationsModule : Module() {
2125 Function("clear") {
2226 appContext.reactContext?.let { AgentNotifications.clear(it) }
2327 }
28
29 Function("openLiveUpdateSettings") {
30 val context = appContext.reactContext
31 if (context == null || Build.VERSION.SDK_INT < 36) {
32 false
33 } else {
34 try {
35 context.startActivity(
36 Intent(Settings.ACTION_APP_NOTIFICATION_PROMOTION_SETTINGS)
37 .putExtra(Settings.EXTRA_APP_PACKAGE, context.packageName)
38 .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
39 )
40 true
41 } catch (_: ActivityNotFoundException) {
42 false
43 }
44 }
45 }
2446 }
2547}
apps/mobile/modules/t3-agent-notifications/android/src/main/res/drawable/agent_activity_mark.xml created+9
......@@ -0,0 +1,9 @@
1<!-- The T3 mark, full bleed, so it renders as large as other apps' status bar icons. -->
2<vector xmlns:android="http://schemas.android.com/apk/res/android"
3 android:width="24dp" android:height="24dp"
4 android:viewportWidth="120" android:viewportHeight="120">
5 <group android:translateX="-2.5" android:translateY="-6">
6 <path android:fillColor="#FFFFFFFF"
7 android:pathData="M33.4509,93V47.56H15.5309V37H64.3309V47.56H46.4109V93H33.4509ZM86.7253,93.96C82.832,93.96 78.9653,93.4533 75.1253,92.44C71.2853,91.3733 68.032,89.88 65.3653,87.96L70.4053,78.04C72.5386,79.5867 75.0186,80.8133 77.8453,81.72C80.672,82.6267 83.5253,83.08 86.4053,83.08C89.6586,83.08 92.2186,82.44 94.0853,81.16C95.952,79.88 96.8853,78.12 96.8853,75.88C96.8853,73.7467 96.0586,72.0667 94.4053,70.84C92.752,69.6133 90.0853,69 86.4053,69H80.4853V60.44L96.0853,42.76L97.5253,47.4H68.1653V37H107.365V45.4L91.8453,63.08L85.2853,59.32H89.0453C95.9253,59.32 101.125,60.8667 104.645,63.96C108.165,67.0533 109.925,71.0267 109.925,75.88C109.925,79.0267 109.099,81.9867 107.445,84.76C105.792,87.48 103.259,89.6933 99.8453,91.4C96.432,93.1067 92.0586,93.96 86.7253,93.96Z" />
8 </group>
9</vector>
apps/mobile/modules/t3-agent-notifications/android/src/main/res/values-night/agent_activity_colors.xml created+9
......@@ -0,0 +1,9 @@
1<!-- Dark shade uses the 300 stops, matching the iOS banner on its dark material. -->
2<resources>
3 <color name="agent_activity_working">#7DD3FC</color>
4 <color name="agent_activity_attention">#FCD34D</color>
5 <color name="agent_activity_input">#A5B4FC</color>
6 <color name="agent_activity_waiting">#94A3B8</color>
7 <color name="agent_activity_done">#6EE7B7</color>
8 <color name="agent_activity_failed">#FCA5A5</color>
9</resources>
apps/mobile/modules/t3-agent-notifications/android/src/main/res/values/agent_activity_colors.xml created+9
......@@ -0,0 +1,9 @@
1<!-- Phase tints shared with the web sidebar pills and the iOS Live Activity (600 stops). -->
2<resources>
3 <color name="agent_activity_working">#0284C7</color>
4 <color name="agent_activity_attention">#D97706</color>
5 <color name="agent_activity_input">#4F46E5</color>
6 <color name="agent_activity_waiting">#64748B</color>
7 <color name="agent_activity_done">#059669</color>
8 <color name="agent_activity_failed">#DC2626</color>
9</resources>
apps/mobile/modules/t3-agent-notifications/android/src/test/java/expo/modules/t3agentnotifications/AgentNotificationsTest.kt+211-14
......@@ -294,26 +294,183 @@ class AgentNotificationsTest {
294294 }
295295
296296 @Test
297 fun longRowsKeepStatusAndBothTitlesWithinTheNotificationWidth() {
297 fun severalThreadsListEveryRowWithItsStatusAndActionsFollowThePriorityThread() {
298298 lifecycle.currentState = Lifecycle.State.RESUMED
299 val raw = "Approval\t${"Long thread name ".repeat(10)}\t${"Project name ".repeat(10)}"
299 val title = "A long thread title that should wrap rather than disappear"
300 val data = update("attention", true) + mapOf(
301 "activity_line_0" to "Approval $title Project",
302 "activity_line_1" to "Working Another thread Other project",
303 "activity_phase" to "waiting_for_approval",
304 "activity_active_count" to "8",
305 "activity_attention_count" to "1",
306 )
307 AgentNotifications.receive(context, data)
308 val card = manager.activeNotifications.single().notification
309 assertEquals("1 needs you", card.extras.getCharSequence(Notification.EXTRA_TITLE).toString())
310 assertEquals("8 active", card.extras.getString(Notification.EXTRA_SUB_TEXT))
311 assertEquals(
312 "Approval $title · Project\nWorking Another thread · Other project",
313 card.extras.getCharSequence(Notification.EXTRA_BIG_TEXT).toString()
314 )
315 assertEquals(
316 "Approval $title · Project",
317 card.extras.getCharSequence(Notification.EXTRA_TEXT).toString()
318 )
319 assertEquals(listOf("Approve", "Dismiss"), card.actions.map { it.title.toString() })
320 assertEquals(
321 "t3code-dev://threads/environment/thread",
322 shadowOf(card.actions[0].actionIntent).savedIntent.dataString
323 )
324 assertEquals("Approve", card.extras.getString(NotificationCompat.EXTRA_SHORT_CRITICAL_TEXT))
325
300326 AgentNotifications.receive(
301327 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())
328 data + mapOf(
329 "activity_phase" to "waiting_for_input",
330 "activity_attention_count" to "2",
331 "activity_path" to "/threads/another-environment/another-thread",
332 )
333 )
334 val next = manager.activeNotifications.single().notification
335 assertEquals("2 need you", next.extras.getCharSequence(Notification.EXTRA_TITLE).toString())
336 assertEquals("Answer", next.actions[0].title.toString())
337 assertEquals(
338 "t3code-dev://threads/another-environment/another-thread",
339 shadowOf(next.actions[0].actionIntent).savedIntent.dataString
340 )
341 assertEquals("Answer", next.extras.getString(NotificationCompat.EXTRA_SHORT_CRITICAL_TEXT))
342 }
343
344 @Test
345 fun waitingCardsIgnoreMutableThreadTimestampsEvenAfterRename() {
346 lifecycle.currentState = Lifecycle.State.RESUMED
347 val now = System.currentTimeMillis()
348 for (phase in listOf("waiting_for_approval", "waiting_for_input")) {
349 val status = if (phase == "waiting_for_approval") "Approval" else "Input"
350 for ((title, updatedAt) in listOf("Original" to now - 1200000L, "Renamed" to now)) {
351 AgentNotifications.receive(
352 context,
353 update("waiting", true) + mapOf(
354 "activity_line_0" to "$status\t$title\tProject",
355 "activity_phase" to phase,
356 "activity_since" to updatedAt.toString()
357 )
358 )
359 val card = manager.activeNotifications.single().notification
360 assertEquals(title, card.extras.getCharSequence(Notification.EXTRA_TITLE).toString())
361 assertFalse(card.extras.getBoolean(Notification.EXTRA_SHOW_WHEN))
362 assertFalse(card.extras.getBoolean(Notification.EXTRA_SHOW_CHRONOMETER))
363 }
314364 }
315365 }
316366
367 @Test
368 fun aSingleThreadUsesItsTitleAndNeverShowsAProgressBar() {
369 lifecycle.currentState = Lifecycle.State.RESUMED
370 val data = update("work", true) + mapOf(
371 "activity_line_0" to "Working Update dashboard Project",
372 "activity_phase" to "running",
373 )
374 AgentNotifications.receive(context, data)
375 val working = manager.activeNotifications.single().notification
376 assertEquals(
377 "Update dashboard",
378 working.extras.getCharSequence(Notification.EXTRA_TITLE).toString()
379 )
380 assertEquals(
381 "Working Project",
382 working.extras.getCharSequence(Notification.EXTRA_BIG_TEXT).toString()
383 )
384 assertEquals(null, working.extras.getString(Notification.EXTRA_SUB_TEXT))
385 assertEquals("Working", working.extras.getString(NotificationCompat.EXTRA_SHORT_CRITICAL_TEXT))
386 assertFalse(working.extras.getBoolean(Notification.EXTRA_SHOW_CHRONOMETER))
387 for (phase in listOf(
388 "waiting_for_approval",
389 "waiting_for_input",
390 "stale",
391 "failed",
392 "completed"
393 )) {
394 val active = phase != "failed" && phase != "completed"
395 AgentNotifications.receive(
396 context,
397 data + mapOf(
398 "activity_phase" to phase,
399 "active" to active.toString(),
400 "activity_expires_at" to (System.currentTimeMillis() + 900000).toString(),
401 )
402 )
403 val card = manager.activeNotifications.single().notification
404 assertEquals(
405 "android.app.Notification\$BigTextStyle",
406 card.extras.getString(Notification.EXTRA_TEMPLATE)
407 )
408 assertEquals(active, NotificationCompat.isRequestPromotedOngoing(card))
409 if (!active) {
410 assertEquals(null, card.extras.getString(NotificationCompat.EXTRA_SHORT_CRITICAL_TEXT))
411 assertEquals(0, card.actions?.size ?: 0)
412 }
413 }
414 }
415
416 @Test
417 fun olderRelayRowsStillSelectTheNativeStateAndAction() {
418 lifecycle.currentState = Lifecycle.State.RESUMED
419 AgentNotifications.receive(
420 context,
421 update("input", true) + mapOf(
422 "activity_line_0" to "Input Choose an icon Project",
423 )
424 )
425 val card = manager.activeNotifications.single().notification
426 assertEquals("Choose an icon", card.extras.getCharSequence(Notification.EXTRA_TITLE).toString())
427 assertEquals("Answer", card.actions.first().title.toString())
428 assertEquals(2, card.actions.size)
429 assertFalse(card.extras.getBoolean(Notification.EXTRA_SHOW_CHRONOMETER))
430 }
431
432 @Test
433 fun multipleAgentsUseTheChipCountAndFinishedThreadsRemainInTheSummary() {
434 lifecycle.currentState = Lifecycle.State.RESUMED
435 val data = update("multiple", true) + mapOf(
436 "activity_line_0" to "Working Build feature Project",
437 "activity_line_1" to "Done Write tests Project",
438 "activity_phase" to "running",
439 "activity_active_count" to "2",
440 )
441 AgentNotifications.receive(context, data)
442 val card = manager.activeNotifications.single().notification
443 assertEquals("2 live", card.extras.getString(NotificationCompat.EXTRA_SHORT_CRITICAL_TEXT))
444 assertEquals("2 working", card.extras.getCharSequence(Notification.EXTRA_TITLE).toString())
445 assertEquals("Project · 2 active", card.extras.getString(Notification.EXTRA_SUB_TEXT))
446 assertEquals(
447 "Working Build feature\nDone Write tests",
448 card.extras.getCharSequence(Notification.EXTRA_BIG_TEXT).toString()
449 )
450 AgentNotifications.receive(context, data + ("activity_active_count" to "15"))
451 assertEquals(
452 "9+ live",
453 manager.activeNotifications.single().notification.extras
454 .getString(NotificationCompat.EXTRA_SHORT_CRITICAL_TEXT)
455 )
456 AgentNotifications.receive(
457 context,
458 data + mapOf(
459 "activity_line_0" to "Failed Build feature Project",
460 "activity_phase" to "failed",
461 "activity_active_count" to "0",
462 "active" to "false",
463 "activity_expires_at" to (System.currentTimeMillis() + 900000).toString(),
464 )
465 )
466 val finished = manager.activeNotifications.single().notification
467 assertEquals(
468 "Finished, 1 failed",
469 finished.extras.getCharSequence(Notification.EXTRA_TITLE).toString()
470 )
471 assertEquals("Project · 2 threads", finished.extras.getString(Notification.EXTRA_SUB_TEXT))
472 }
473
317474 private fun assertTimeout(card: Notification, expected: LongRange) {
318475 if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
319476 assertTrue(card.timeoutAfter in expected)
......@@ -399,6 +556,46 @@ class AgentNotificationsTest {
399556 assertEquals(Notification.VISIBILITY_PRIVATE, card.visibility)
400557 }
401558
559 @Test
560 fun liveUpdateChipChangesWithActivityAndClearsOnCompletion() {
561 lifecycle.currentState = Lifecycle.State.RESUMED
562 AgentNotifications.receive(context, update("work", true))
563 assertEquals(
564 "Active",
565 manager.activeNotifications.single().notification.extras
566 .getString(NotificationCompat.EXTRA_SHORT_CRITICAL_TEXT)
567 )
568 AgentNotifications.receive(context, update("input", true) + ("activity_chip" to "Review"))
569 val activeCard = manager.activeNotifications.single().notification
570 assertEquals(
571 "Review",
572 activeCard.extras.getString(NotificationCompat.EXTRA_SHORT_CRITICAL_TEXT)
573 )
574 assertTrue(NotificationCompat.isRequestPromotedOngoing(activeCard))
575
576 AgentNotifications.receive(
577 context,
578 update("done", false) + mapOf(
579 "activity_chip" to "Review",
580 "activity_expires_at" to (System.currentTimeMillis() + 900000).toString()
581 )
582 )
583 val finishedCard = manager.activeNotifications.single().notification
584 assertEquals(null, finishedCard.extras.getString(NotificationCompat.EXTRA_SHORT_CRITICAL_TEXT))
585 assertFalse(NotificationCompat.isRequestPromotedOngoing(finishedCard))
586 assertFalse(finishedCard.flags and Notification.FLAG_ONGOING_EVENT != 0)
587 }
588
589 @Test
590 fun blankTitleCannotMakeAnActivityIneligibleForPromotion() {
591 lifecycle.currentState = Lifecycle.State.RESUMED
592 AgentNotifications.receive(context, update("work", true) + ("activity_title" to " "))
593 assertEquals(
594 "Agent activity",
595 manager.activeNotifications.single().notification.extras.getString(Notification.EXTRA_TITLE)
596 )
597 }
598
402599 @Test
403600 fun expiredMalformedAndFutureMessagesCannotDisplayOrPoisonLaterUpdates() {
404601 val invalid = update("invalid", true)
apps/mobile/src/features/agent-awareness/androidNotifications.test.ts+38-1
......@@ -2,7 +2,13 @@ import { beforeEach, describe, expect, it, vi } from "vite-plus/test";
22
33const mocks = vi.hoisted(() => ({
44 os: "android",
5 native: null as { configure?: ReturnType<typeof vi.fn>; clear?: ReturnType<typeof vi.fn> } | null,
5 version: 36,
6 openSettings: vi.fn(),
7 native: null as {
8 configure?: ReturnType<typeof vi.fn>;
9 clear?: ReturnType<typeof vi.fn>;
10 openLiveUpdateSettings?: ReturnType<typeof vi.fn>;
11 } | null,
612 config: { scheme: ["t3code-preview"], extra: { iosPersonalTeamBuild: false } },
713 requireModule: vi.fn(),
814}));
......@@ -10,7 +16,11 @@ const mocks = vi.hoisted(() => ({
1016vi.mock("expo", () => ({ requireOptionalNativeModule: mocks.requireModule }));
1117vi.mock("expo-constants", () => ({ default: { expoConfig: mocks.config } }));
1218vi.mock("react-native", () => ({
19 Linking: { openSettings: mocks.openSettings },
1320 Platform: {
21 get Version() {
22 return mocks.version;
23 },
1424 get OS() {
1525 return mocks.os;
1626 },
......@@ -20,12 +30,39 @@ vi.mock("react-native", () => ({
2030beforeEach(() => {
2131 vi.resetModules();
2232 mocks.os = "android";
33 mocks.version = 36;
34 mocks.openSettings.mockReset().mockResolvedValue(undefined);
2335 mocks.native = { configure: vi.fn(), clear: vi.fn() };
2436 mocks.config.extra.iosPersonalTeamBuild = false;
2537 mocks.requireModule.mockReset().mockImplementation(() => mocks.native);
2638});
2739
2840describe("Android native notification capability", () => {
41 it("opens the Live Update controls on supported Android builds", async () => {
42 mocks.native!.openLiveUpdateSettings = vi.fn(() => true);
43 const { openAndroidLiveUpdateSettings, supportsAndroidLiveUpdateSettings } =
44 await import("./androidNotifications");
45 expect(supportsAndroidLiveUpdateSettings()).toBe(true);
46 await openAndroidLiveUpdateSettings();
47 expect(mocks.native!.openLiveUpdateSettings).toHaveBeenCalledOnce();
48 expect(mocks.openSettings).not.toHaveBeenCalled();
49 mocks.version = 35;
50 expect(supportsAndroidLiveUpdateSettings()).toBe(false);
51 mocks.os = "ios";
52 mocks.version = 36;
53 expect(supportsAndroidLiveUpdateSettings()).toBe(false);
54 });
55
56 it.each([undefined, vi.fn(() => false)])(
57 "falls back to app settings for older binaries or missing system activities (%j)",
58 async (openLiveUpdateSettings) => {
59 if (openLiveUpdateSettings) mocks.native!.openLiveUpdateSettings = openLiveUpdateSettings;
60 const { openAndroidLiveUpdateSettings } = await import("./androidNotifications");
61 await openAndroidLiveUpdateSettings();
62 expect(mocks.openSettings).toHaveBeenCalledOnce();
63 },
64 );
65
2966 it("uses the installed module and the build variant's deep-link scheme", async () => {
3067 const { configureAndroidAgentNotifications, clearAndroidAgentNotifications } =
3168 await import("./androidNotifications");
apps/mobile/src/features/agent-awareness/androidNotifications.ts+12-1
......@@ -1,10 +1,11 @@
11import Constants from "expo-constants";
22import { requireOptionalNativeModule } from "expo";
3import { Platform } from "react-native";
3import { Linking, Platform } from "react-native";
44
55interface AndroidAgentNotifications {
66 configure(deviceId: string, userId: string, scheme: string, ongoingEnabled: boolean): void;
77 clear(): void;
8 openLiveUpdateSettings?(): boolean;
89}
910
1011const native =
......@@ -33,3 +34,13 @@ export function configureAndroidAgentNotifications(
3334export function clearAndroidAgentNotifications(): void {
3435 native?.clear?.();
3536}
37
38export function supportsAndroidLiveUpdateSettings(): boolean {
39 return Platform.OS === "android" && Number(Platform.Version) >= 36;
40}
41
42export async function openAndroidLiveUpdateSettings(): Promise<void> {
43 if (!native?.openLiveUpdateSettings?.()) {
44 await Linking.openSettings();
45 }
46}
apps/mobile/src/features/settings/SettingsRouteScreen.tsx+25-1
......@@ -21,6 +21,10 @@ import {
2121import { AndroidScreenHeader } from "../../components/AndroidScreenHeader";
2222import { AppText as Text, AppTextInput as TextInput } from "../../components/AppText";
2323import { supportsAgentAwarenessPush } from "../agent-awareness/capabilities";
24import {
25 openAndroidLiveUpdateSettings,
26 supportsAndroidLiveUpdateSettings,
27} from "../agent-awareness/androidNotifications";
2428import { setLiveActivityUpdatesEnabled } from "../agent-awareness/liveActivityPreferences";
2529import { requestAgentNotificationPermission } from "../agent-awareness/notificationPermissions";
2630import {
......@@ -541,7 +545,13 @@ function ConfiguredSettingsRouteScreen() {
541545 liveActivityStatus === "linking"
542546 }
543547 icon="bolt.circle"
544 label={Platform.OS === "android" ? "Ongoing Agent Activity" : "Live Activity Updates"}
548 label={
549 Platform.OS === "android"
550 ? supportsAndroidLiveUpdateSettings()
551 ? "Agent Live Updates"
552 : "Ongoing Agent Activity"
553 : "Live Activity Updates"
554 }
545555 subtitle={agentAwarenessSubtitle}
546556 // Same gate: a saved preference is meaningless until the device
547557 // registration the relay needs to push updates has succeeded.
......@@ -552,6 +562,20 @@ function ConfiguredSettingsRouteScreen() {
552562 }
553563 onValueChange={handleLiveActivitiesChange}
554564 />
565 {supportsAndroidLiveUpdateSettings() ? (
566 <SettingsRow
567 icon="bolt.circle"
568 label="Live Update Settings"
569 onPress={() => {
570 void openAndroidLiveUpdateSettings().catch(() => {
571 Alert.alert(
572 "Couldn't open Settings",
573 "Open Android Settings, select T3 Code, then enable Live Updates in Notifications.",
574 );
575 });
576 }}
577 />
578 ) : null}
555579 </SettingsSection>
556580
557581 <GeneralSettingsSection />
docs/operations/android-notifications.md+2
......@@ -6,6 +6,8 @@ The Android app receives Firebase Cloud Messaging (FCM) data messages. The relay
66
77The 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.
88
9On Android 16+, open T3 Code Settings → Live Update Settings to allow status bar chips. Android controls this separately from notification permission. The chip reads `Working` during work, `Approve` for approvals, and `Answer` for input requests; completed work returns to a normal notification. Use an Android 16 QPR2 or newer emulator image to verify the shipped promotion behavior.
10
911API 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.
1012
1113The 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:
infra/relay/src/agentActivity/FcmDeliveries.test.ts+12
......@@ -616,6 +616,18 @@ describe("Android delivery routing", () => {
616616 ]);
617617 const data = androidActivityData(aggregate);
618618 expect(data.activity_title).toBe("3 active agents · 2 need attention");
619 expect(data.activity_chip).toBe("Review");
620 expect(data.activity_phase).toBe("waiting_for_approval");
621 expect(data.activity_active_count).toBe("3");
622 expect(data.activity_attention_count).toBe("2");
623 const input = androidActivityData(aggregateFor([{ ...state, phase: "waiting_for_input" }]));
624 expect(input.activity_phase).toBe("waiting_for_input");
625 expect(input.activity_active_count).toBe("1");
626 expect(androidActivityData(aggregateFor([state])).activity_chip).toBe("Active");
627 expect(
628 androidActivityData(aggregateFor([{ ...state, phase: "completed" }])).activity_chip,
629 ).toBe("");
630 expect(androidActivityData(null).activity_chip).toBe("");
619631 expect(
620632 Object.entries(data)
621633 .filter(([key]) => key.startsWith("activity_line_"))
infra/relay/src/agentActivity/fcmPayloads.ts+6-1
......@@ -11,7 +11,7 @@ export function androidActivityHero(aggregate: RelayAgentActivityAggregateState)
1111 )[0];
1212}
1313
14/** The expanded Android card uses the same rows and priority as the iOS widget. */
14/** Android focuses the priority thread while retaining rows for older clients. */
1515export function androidActivityData(aggregate: RelayAgentActivityAggregateState | null) {
1616 const rows = [...(aggregate?.activities ?? [])].sort(
1717 (a, b) => activityPhasePriority(a.phase) - activityPhasePriority(b.phase),
......@@ -40,7 +40,12 @@ export function androidActivityData(aggregate: RelayAgentActivityAggregateState
4040 );
4141 return {
4242 active: String(activeCount > 0),
43 // Keep the status bar chip short enough to display alongside the app icon.
44 activity_chip: activeCount > 0 ? (attentionCount > 0 ? "Review" : "Active") : "",
4345 activity_title: title,
46 activity_phase: hero?.phase ?? "",
47 activity_active_count: String(activeCount),
48 activity_attention_count: String(attentionCount),
4449 activity_body: hero
4550 ? `${hero.status}: ${clean(hero.threadTitle)} · ${clean(hero.projectTitle)}`
4651 : "",