diff --git a/apps/mobile/app.config.ts b/apps/mobile/app.config.ts index 97fe5cf977acc..e0e3dd64c1878 100644 --- a/apps/mobile/app.config.ts +++ b/apps/mobile/app.config.ts @@ -194,7 +194,15 @@ const widgetsPlugin: NonNullable[number] = [ name: "AgentActivity", displayName: "Agent Activity", description: "Shows the current state of active T3 Code agents.", - android: null, + android: { + minWidth: 250, + minHeight: 110, + targetCellWidth: 4, + targetCellHeight: 2, + resizeMode: "both", + // The embedded layout renders before first launch; publishing replaces it with stored props. + initialLayout: "./src/widgets/AgentActivityWidget.android.tsx", + }, ios: { supportedFamilies: ["systemSmall", "systemMedium", "accessoryRectangular"], // Embeds the layout so the gallery preview and a new widget render diff --git a/apps/mobile/modules/t3-agent-notifications/android/src/main/java/expo/modules/t3agentnotifications/AgentActivityWidgetSync.kt b/apps/mobile/modules/t3-agent-notifications/android/src/main/java/expo/modules/t3agentnotifications/AgentActivityWidgetSync.kt new file mode 100644 index 0000000000000..f6e91f85a1a60 --- /dev/null +++ b/apps/mobile/modules/t3-agent-notifications/android/src/main/java/expo/modules/t3agentnotifications/AgentActivityWidgetSync.kt @@ -0,0 +1,93 @@ +package expo.modules.t3agentnotifications + +import android.content.ComponentName +import android.content.Context +import android.content.Intent +import java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale +import java.util.TimeZone +import org.json.JSONArray +import org.json.JSONObject + +/** + * Repaints the Agent Activity home screen widget from a relay push, so it stays current + * while the app is closed. The app's foreground refresh still overwrites these props with + * the full snapshot; this only keeps the widget from sitting on "Out of date" in between. + */ +internal object AgentActivityWidgetSync { + private const val WIDGET = "AgentActivity" + private const val WIDGET_STORE = "expo.modules.widgets" + private const val PROPS_KEY = "__expo_widgets_${WIDGET}_props" + private const val PUSH_AT_KEY = "t3_agent_activity_push_at" + + // Pushes arrive only on changes; match the relay's running-row TTL (and the app's + // AGENT_ACTIVITY_ANDROID_WIDGET_STALE_AFTER_MS) instead of flagging quiet agents. + private const val STALE_AFTER_MS = 2 * 60 * 60 * 1000L + private const val EXPIRY_RECEIVER = "expo.modules.t3widgetexpiry.WidgetExpiryReceiver" + private const val EXPIRY_NAME = "expo.modules.t3widgetexpiry.NAME" + private const val EXPIRY_DEADLINES = "expo.modules.t3widgetexpiry.DEADLINES" + + fun update(context: Context, data: Map, updatedAt: Long) { + val store = context.applicationContext.getSharedPreferences(WIDGET_STORE, Context.MODE_PRIVATE) + // FCM can deliver out of order; an older push must not replace newer rows. + if (updatedAt <= store.getLong(PUSH_AT_KEY, 0)) return + val rows = activityRows(data) + val path = data["activity_path"].orEmpty() + val scheme = context.getSharedPreferences("t3-agent-notifications", Context.MODE_PRIVATE) + .getString("scheme", "t3code") ?: "t3code" + val updated = iso(updatedAt) + val current = JSONArray() + val stale = JSONArray() + var inFlight = false + rows.forEachIndexed { index, row -> + val phase = widgetPhase(ActivityPhase.forStatus(row.status)) + val finished = phase == "completed" || phase == "failed" + if (!finished) inFlight = true + fun json(phase: String, status: String) = JSONObject() + .put("environmentId", "push") + .put("threadId", index.toString()) + .put("projectTitle", row.project) + .put("threadTitle", row.title) + .put("modelTitle", "") + .put("phase", phase) + .put("status", status) + .put("updatedAt", updated) + .put("deepLink", data["activity_row_path_$index"] ?: if (index == 0) path else "") + current.put(json(phase, row.status)) + stale.put(if (finished) json(phase, row.status) else json("stale", "Out of date")) + } + val staleAt = updatedAt + STALE_AFTER_MS + val props = JSONObject() + .put("appUrl", "$scheme://") + .put("title", data["activity_title"].orEmpty()) + .put("subtitle", if (rows.isEmpty()) "No active agents" else "") + .put("activeCount", data["activity_active_count"]?.toIntOrNull() ?: 0) + .put("updatedAt", updated) + .put("activities", current) + if (inFlight) props.put("stale", JSONObject().put("at", staleAt).put("activities", stale)) + store.edit().putString(PROPS_KEY, props.toString()).putLong(PUSH_AT_KEY, updatedAt).commit() + + // The expiry receiver repaints now and sets the alarm that swaps in the stale rows. + context.sendBroadcast( + Intent().setComponent(ComponentName(context.packageName, EXPIRY_RECEIVER)) + .putExtra(EXPIRY_NAME, WIDGET) + .putExtra(EXPIRY_DEADLINES, if (inFlight) longArrayOf(staleAt) else LongArray(0)) + ) + } + + private fun widgetPhase(phase: ActivityPhase?) = when (phase) { + ActivityPhase.STARTING -> "starting" + ActivityPhase.APPROVAL -> "waiting_for_approval" + ActivityPhase.INPUT -> "waiting_for_input" + ActivityPhase.STALE -> "stale" + ActivityPhase.COMPLETED -> "completed" + ActivityPhase.FAILED -> "failed" + ActivityPhase.RUNNING, null -> "running" + } + + private fun iso(millis: Long) = + SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSS'Z'", Locale.US) + .apply { timeZone = TimeZone.getTimeZone("UTC") } + .format(Date(millis)) +} diff --git a/apps/mobile/modules/t3-agent-notifications/android/src/main/java/expo/modules/t3agentnotifications/AgentNotifications.kt b/apps/mobile/modules/t3-agent-notifications/android/src/main/java/expo/modules/t3agentnotifications/AgentNotifications.kt index fdfd144ebb9cc..976e5c57ca03a 100644 --- a/apps/mobile/modules/t3-agent-notifications/android/src/main/java/expo/modules/t3agentnotifications/AgentNotifications.kt +++ b/apps/mobile/modules/t3-agent-notifications/android/src/main/java/expo/modules/t3agentnotifications/AgentNotifications.kt @@ -124,6 +124,10 @@ object AgentNotifications { data["device_id"] == prefs.getString("deviceId", null) && data["user_id"] == prefs.getString("userId", null) val fresh = System.currentTimeMillis() - updatedAt in -MAX_MESSAGE_AGE_MS..MAX_MESSAGE_AGE_MS + // The widget needs no notification permission, so it updates even when alerts are off. + if (registered && fresh) { + runCatching { AgentActivityWidgetSync.update(context, data, updatedAt) } + } if (registered && fresh && NotificationManagerCompat.from(context).areNotificationsEnabled()) { channels(context) val scheme = prefs.getString("scheme", "t3code") ?: "t3code" diff --git a/apps/mobile/modules/t3-agent-notifications/android/src/test/java/expo/modules/t3agentnotifications/AgentActivityWidgetSyncTest.kt b/apps/mobile/modules/t3-agent-notifications/android/src/test/java/expo/modules/t3agentnotifications/AgentActivityWidgetSyncTest.kt new file mode 100644 index 0000000000000..11bb88c23bf28 --- /dev/null +++ b/apps/mobile/modules/t3-agent-notifications/android/src/test/java/expo/modules/t3agentnotifications/AgentActivityWidgetSyncTest.kt @@ -0,0 +1,76 @@ +package expo.modules.t3agentnotifications + +import android.content.Context +import org.json.JSONObject +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.Shadows.shadowOf +import org.robolectric.annotation.Config + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [24, 36], manifest = Config.NONE) +class AgentActivityWidgetSyncTest : AgentNotificationsFixture() { + private fun widgetStore() = + context.getSharedPreferences("expo.modules.widgets", Context.MODE_PRIVATE) + + private fun props() = + JSONObject(requireNotNull(widgetStore().getString("__expo_widgets_AgentActivity_props", null))) + + private fun push(updatedAt: Long, vararg lines: String) = mapOf( + "device_id" to "device", + "user_id" to "user", + "updated_at" to updatedAt.toString(), + "activity_title" to "2 active agents", + "activity_active_count" to "2", + "activity_path" to "/threads/env/first", + "activity_row_path_1" to "/threads/env/second", + ) + lines.mapIndexed { index, line -> "activity_line_$index" to line } + + @Test + fun pushRepaintsTheWidget() { + val now = System.currentTimeMillis() + + AgentNotifications.receive( + context, + push(now, "Approval\tShip it\tweb", "Working\tRefactor\tapi", "Done\tDocs\tweb") + ) + + val props = props() + assertEquals(2, props.getInt("activeCount")) + val rows = props.getJSONArray("activities") + assertEquals( + listOf("waiting_for_approval", "running", "completed"), + (0 until rows.length()).map { rows.getJSONObject(it).getString("phase") } + ) + assertEquals("Refactor", rows.getJSONObject(1).getString("threadTitle")) + assertEquals("/threads/env/second", rows.getJSONObject(1).getString("deepLink")) + assertEquals("t3code-dev://", props.getString("appUrl")) + assertEquals("/threads/env/first", rows.getJSONObject(0).getString("deepLink")) + val stale = props.getJSONObject("stale") + assertEquals(now + 2 * 60 * 60 * 1000L, stale.getLong("at")) + assertEquals( + listOf("Out of date", "Out of date", "Done"), + (0 until 3).map { stale.getJSONArray("activities").getJSONObject(it).getString("status") } + ) + val broadcast = shadowOf(context).broadcastIntents.last() + assertEquals("expo.modules.t3widgetexpiry.WidgetExpiryReceiver", broadcast.component?.className) + assertEquals("AgentActivity", broadcast.getStringExtra("expo.modules.t3widgetexpiry.NAME")) + } + + @Test + fun anOlderPushDoesNotReplaceNewerRows() { + val now = System.currentTimeMillis() + AgentNotifications.receive(context, push(now, "Done\tNewer\tweb")) + AgentNotifications.receive(context, push(now - 1000, "Working\tOlder\tweb")) + + val props = props() + assertEquals( + "Newer", + props.getJSONArray("activities").getJSONObject(0).getString("threadTitle") + ) + assertFalse(props.has("stale")) + } +} diff --git a/apps/mobile/src/features/agent-awareness/remoteRegistration.test.ts b/apps/mobile/src/features/agent-awareness/remoteRegistration.test.ts index d4438911eb7bf..778ac2e0cb58c 100644 --- a/apps/mobile/src/features/agent-awareness/remoteRegistration.test.ts +++ b/apps/mobile/src/features/agent-awareness/remoteRegistration.test.ts @@ -1038,6 +1038,21 @@ describe("makeRelayDeviceRegistrationRequest", () => { }).pipe(Effect.provide(relay)); }); + it.effect("repaints the Android widget without touching Live Activities", () => + Effect.gen(function* () { + vi.spyOn(Platform, "OS", "get").mockReturnValue("android"); + const relay = fakeRelay(() => Effect.succeed({ aggregate })); + setAgentAwarenessRelayTokenProvider(() => Promise.resolve("clerk-token"), "user-a"); + widgetMocks.getInstances.mockClear(); + + yield* refreshActiveLiveActivityRemoteRegistration().pipe(Effect.provide(relay)); + + expect(publishWidgetMock).toHaveBeenLastCalledWith({ kind: "snapshot", aggregate }); + expect(widgetMocks.getInstances).not.toHaveBeenCalled(); + expect(widgetMocks.dismissEnded).not.toHaveBeenCalled(); + }), + ); + it.effect("keeps the newest rows when an older overlapping read finishes last", () => Effect.gen(function* () { const newer = { ...aggregate, activeCount: 2 }; diff --git a/apps/mobile/src/features/agent-awareness/remoteRegistration.ts b/apps/mobile/src/features/agent-awareness/remoteRegistration.ts index a2b972b0ccc2d..48578fd3b898e 100644 --- a/apps/mobile/src/features/agent-awareness/remoteRegistration.ts +++ b/apps/mobile/src/features/agent-awareness/remoteRegistration.ts @@ -176,6 +176,11 @@ function canRegisterRemoteLiveActivities(): boolean { return Platform.OS === "ios"; } +// The home-screen widget reads the relay snapshot itself, so it needs no Live Activity. +function canPublishAgentActivityWidget(): boolean { + return Platform.OS === "ios" || Platform.OS === "android"; +} + function canRegisterPushNotifications(): boolean { return Platform.OS === "ios" || Platform.OS === "android"; } @@ -797,7 +802,7 @@ function ensureAppStateListener(): void { } function publishWidget(source: AgentActivityWidgetSource): void { - if (!canRegisterRemoteLiveActivities()) { + if (!canPublishAgentActivityWidget()) { return; } publishAgentActivityWidget(source).catch((error: unknown) => { @@ -1008,7 +1013,7 @@ function scheduleActiveLiveActivityRegistrationRetry(): void { export const refreshActiveLiveActivityRemoteRegistration = Effect.fn( "remoteRegistration.refreshActiveLiveActivityRemoteRegistration", )(function* (): Effect.fn.Return { - if (!canRegisterRemoteLiveActivities() || !relayTokenProvider) { + if (!canPublishAgentActivityWidget() || !relayTokenProvider) { return; } @@ -1039,6 +1044,12 @@ export const refreshActiveLiveActivityRemoteRegistration = Effect.fn( return { snapshot }; }); + // Android has no Live Activity to reconcile; its widget is the whole refresh. + if (!canRegisterRemoteLiveActivities()) { + yield* refreshWidget; + return; + } + dismissEndedLocalLiveActivities(); let activities = yield* Effect.try({ try: () => getAgentLiveActivities(), diff --git a/apps/mobile/src/widgets/AgentActivityWidget.android.tsx b/apps/mobile/src/widgets/AgentActivityWidget.android.tsx new file mode 100644 index 0000000000000..e428635ce8faa --- /dev/null +++ b/apps/mobile/src/widgets/AgentActivityWidget.android.tsx @@ -0,0 +1,141 @@ +import { Box, Button, Column, Row, Text } from "@expo/ui/jetpack-compose"; +import { fillMaxSize, fillMaxWidth, padding, paddingAll } from "@expo/ui/jetpack-compose/modifiers"; +import { createWidget, type WidgetEnvironment } from "expo-widgets"; + +import type { AgentActivityPhase } from "./AgentActivity"; +import type { AgentActivityWidgetSnapshot } from "./agentActivityWidgetTimeline"; + +// Android counterpart of the iOS Agent Activity widget, with the same header, +// row layout, and phase colors. The publisher sorts the rows, so the first one +// is the thread that most needs the user. +export function AgentActivityWidget( + publishedProps: Partial | undefined, + environment: WidgetEnvironment, +) { + "widget"; + // The widget runtime evaluates this function without the app's module scope. + // Before the app's first publish, the embedded layout renders with no props. + const props = publishedProps ?? {}; + const light = environment.colorScheme !== "dark"; + const background = light ? "#ffffff" : "#1c1c1e"; + const primary = light ? "#000000" : "#ffffff"; + const secondary = light ? "#66666b" : "#98989f"; + const phaseColor = (phase: AgentActivityPhase): string => { + switch (phase) { + case "waiting_for_approval": + return light ? "#d97706" : "#fcd34d"; + case "waiting_for_input": + return light ? "#4f46e5" : "#a5b4fc"; + case "failed": + return light ? "#dc2626" : "#fca5a5"; + case "completed": + return light ? "#059669" : "#6ee7b7"; + case "stale": + return secondary; + case "starting": + case "running": + default: + return light ? "#0284c7" : "#7dd3fc"; + } + }; + + const loaded = props.activities !== undefined; + // Android has no timeline; an expiry alarm re-renders this layout at `stale.at`. + const rows = + props.stale && Date.now() >= props.stale.at ? props.stale.activities : (props.activities ?? []); + const attentionCount = rows.filter( + (row) => row.phase === "waiting_for_approval" || row.phase === "waiting_for_input", + ).length; + const workingCount = rows.filter( + (row) => row.phase === "running" || row.phase === "starting", + ).length; + const summary = + attentionCount > 0 + ? `${attentionCount} need${attentionCount === 1 ? "s" : ""} attention` + : workingCount > 0 + ? `${props.activeCount ?? workingCount} active` + : rows.some((row) => row.phase === "stale") + ? "Out of date" + : rows.some((row) => row.phase === "failed") + ? "Failed" + : rows.length > 0 + ? "Done" + : ""; + const shown = rows.slice(0, 4); + const firstPath = props.appUrl ? (rows[0]?.deepLink ?? "").replace(/^\//, "") : ""; + + return ( + + ))} + + + ); +} + +export default createWidget>( + "AgentActivity", + AgentActivityWidget, +); diff --git a/apps/mobile/src/widgets/agentActivityWidgetTimeline.test.ts b/apps/mobile/src/widgets/agentActivityWidgetTimeline.test.ts index 9ffd91aeeeb20..4728ceee263db 100644 --- a/apps/mobile/src/widgets/agentActivityWidgetTimeline.test.ts +++ b/apps/mobile/src/widgets/agentActivityWidgetTimeline.test.ts @@ -4,7 +4,9 @@ import type { EnvironmentId, ThreadId } from "@t3tools/contracts"; import type { RelayAgentActivityAggregateState } from "@t3tools/contracts/relay"; import { + AGENT_ACTIVITY_ANDROID_WIDGET_STALE_AFTER_MS, AGENT_ACTIVITY_WIDGET_STALE_AFTER_MS, + agentActivityWidgetSnapshot, agentActivityWidgetTimeline, } from "./agentActivityWidgetTimeline"; @@ -69,4 +71,30 @@ describe("agentActivityWidgetTimeline", () => { expect(timeline).toHaveLength(1); expect(timeline[0]?.props.activities[0]?.phase).toBe("failed"); }); + + it("leads the Android snapshot with the thread that needs the user and carries the stale rows", () => { + const snapshot = agentActivityWidgetSnapshot( + { + kind: "snapshot", + aggregate: aggregate([ + row("completed", "Done"), + row("running", "Working"), + row("waiting_for_approval", "Approval"), + ]), + }, + now, + ); + + expect(snapshot.activities.map((entry) => entry.phase)).toEqual([ + "waiting_for_approval", + "running", + "completed", + ]); + expect(snapshot.stale?.at).toBe(now + AGENT_ACTIVITY_ANDROID_WIDGET_STALE_AFTER_MS); + expect(snapshot.stale?.activities.map((entry) => entry.status)).toEqual([ + "Out of date", + "Out of date", + "Done", + ]); + }); }); diff --git a/apps/mobile/src/widgets/agentActivityWidgetTimeline.ts b/apps/mobile/src/widgets/agentActivityWidgetTimeline.ts index 7a00789cc9b5a..02d5ffb271601 100644 --- a/apps/mobile/src/widgets/agentActivityWidgetTimeline.ts +++ b/apps/mobile/src/widgets/agentActivityWidgetTimeline.ts @@ -59,3 +59,43 @@ export function agentActivityWidgetTimeline(source: AgentActivityWidgetSource, n { date: new Date(now + AGENT_ACTIVITY_WIDGET_STALE_AFTER_MS), props: staleProps }, ]; } + +// Android's widget also repaints from relay pushes, which arrive only on changes, so a +// long-running agent is not out of date after 10 quiet minutes. Match the relay's running-row TTL. +export const AGENT_ACTIVITY_ANDROID_WIDGET_STALE_AFTER_MS = 2 * 60 * 60_000; + +export interface AgentActivityWidgetSnapshot extends AgentActivityProps { + readonly appUrl?: string; + // Android has no timeline, so its layout swaps to these rows once `at` passes. + readonly stale?: { readonly at: number; readonly activities: AgentActivityProps["activities"] }; +} + +export function agentActivityWidgetSnapshot( + source: AgentActivityWidgetSource, + now: number, +): AgentActivityWidgetSnapshot { + const [current, stale] = agentActivityWidgetTimeline(source, now); + const { activities } = current!.props; + const priority = (index: number) => { + const phase = activities[index]!.phase; + if (phase === "waiting_for_approval" || phase === "waiting_for_input") return 0; + if (phase === "failed") return 1; + return phase === "running" || phase === "starting" ? 2 : 3; + }; + // Threads that need the user come first, then failures, then running work. + // The stale rows keep that order so nothing moves when they go out of date. + const order = activities.map((_, index) => index).sort((a, b) => priority(a) - priority(b)); + const ordered = (rows: AgentActivityProps["activities"]) => order.map((index) => rows[index]!); + return { + ...current!.props, + activities: ordered(activities), + ...(stale + ? { + stale: { + at: now + AGENT_ACTIVITY_ANDROID_WIDGET_STALE_AFTER_MS, + activities: ordered(stale.props.activities), + }, + } + : {}), + }; +} diff --git a/apps/mobile/src/widgets/publishAgentActivityWidget.android.ts b/apps/mobile/src/widgets/publishAgentActivityWidget.android.ts new file mode 100644 index 0000000000000..989b58cfbffa4 --- /dev/null +++ b/apps/mobile/src/widgets/publishAgentActivityWidget.android.ts @@ -0,0 +1,21 @@ +import { requireOptionalNativeModule } from "expo"; +import * as Linking from "expo-linking"; + +import { + agentActivityWidgetSnapshot, + type AgentActivityWidgetSource, +} from "./agentActivityWidgetTimeline"; + +export async function publishAgentActivityWidget(source: AgentActivityWidgetSource) { + if (!requireOptionalNativeModule("ExpoWidgets")) return; + const { default: widget } = await import("./AgentActivityWidget"); + const snapshot = { + ...agentActivityWidgetSnapshot(source, Date.now()), + appUrl: Linking.createURL(""), + }; + widget.updateSnapshot(snapshot); + // Android lacks timelines; an alarm re-renders the snapshot when unfinished rows go out of date. + requireOptionalNativeModule<{ schedule: (name: string, deadlines: number[]) => void }>( + "T3WidgetExpiry", + )?.schedule("AgentActivity", snapshot.stale ? [snapshot.stale.at] : []); +} diff --git a/docs/user/mobile-notifications.md b/docs/user/mobile-notifications.md index 992efd5b7b29a..a77834606114e 100644 --- a/docs/user/mobile-notifications.md +++ b/docs/user/mobile-notifications.md @@ -6,7 +6,7 @@ Enable **Live Activity Updates** on iOS, **Agent Live Updates** on Android 16 an For activity without ordinary alerts on Android 8 or newer, leave **Ongoing agent activity** enabled and turn off **Agent alerts** in Android's notification settings for T3 Code. Activity updates continue, and the same card shows the finished result without a separate alert. On Android 16 and newer, the finished result remains a Live Update until dismissed or expired. Keep app-wide notification permission and **Live updates** enabled so Android can display it. -On iOS you can also add the **Agent Activity** widget to your Home Screen or Lock Screen. It shows the same agents as the Live Activity, using your T3 Connect sign-in. The widget refreshes when you open T3 and shows unfinished work as out of date after 10 minutes. +You can also add the **Agent Activity** widget to your Home Screen, and on iOS to your Lock Screen. It shows the same agents as the Live Activity or Android ongoing activity card, using your T3 Connect sign-in. The widget refreshes when you open T3 and shows unfinished work as out of date after 10 minutes on iOS. On Android it also updates from agent activity pushes while T3 is closed, so unfinished work shows as out of date only after 2 hours without an update. Ordinary alerts stay quiet while the mobile app is in the foreground. Ongoing activity continues to update. Viewing a thread on another device does not silence your phone's alerts. diff --git a/infra/relay/src/agentActivity/FcmDeliveries.test.ts b/infra/relay/src/agentActivity/FcmDeliveries.test.ts index f1ce50d5832b6..d578f719290c3 100644 --- a/infra/relay/src/agentActivity/FcmDeliveries.test.ts +++ b/infra/relay/src/agentActivity/FcmDeliveries.test.ts @@ -551,6 +551,7 @@ describe("Android delivery routing", () => { Array.from({ length: 5 }, (_, i) => ({ ...state, threadId: ThreadId.make(`thread-${i}`), + deepLink: `/threads/env/thread-${i}`, threadTitle: longTitle, projectTitle: longTitle, })), @@ -568,9 +569,12 @@ describe("Android delivery routing", () => { }); expect(new TextEncoder().encode(JSON.stringify(data)).length).toBeLessThanOrEqual(3800); expect(data.alert_path).toBe("/threads/env/thread"); - expect(data.activity_path).toBe("/threads/env/thread"); + expect(data.activity_path).toBe("/threads/env/thread-0"); expect(data.alert_id).toBe("a".repeat(64)); expect(data.activity_line_4).toContain("Working\t"); + for (let index = 0; index < 5; index++) { + expect(data[`activity_row_path_${index}`]).toBe(`/threads/env/thread-${index}`); + } for (const value of Object.values(data)) expect(new TextDecoder().decode(new TextEncoder().encode(value))).toBe(value); }); diff --git a/infra/relay/src/agentActivity/fcmPayloads.ts b/infra/relay/src/agentActivity/fcmPayloads.ts index 6c813235e4f61..525624f1f4723 100644 --- a/infra/relay/src/agentActivity/fcmPayloads.ts +++ b/infra/relay/src/agentActivity/fcmPayloads.ts @@ -42,6 +42,9 @@ export function androidActivityData(aggregate: RelayAgentActivityAggregateState ? `${hero.status}: ${clean(hero.threadTitle)} ยท ${clean(hero.projectTitle)}` : "", ...Object.fromEntries(lines.map((line, index) => [`activity_line_${index}`, line])), + ...Object.fromEntries( + rows.slice(0, 5).map((row, index) => [`activity_row_path_${index}`, row.deepLink]), + ), activity_path: rows[0]?.deepLink ?? "/", activity_expires_at: String(expiresAt), }; diff --git a/patches/expo-widgets@58.0.27.patch b/patches/expo-widgets@58.0.27.patch index 21252dcd848aa..e5400029a9b46 100644 --- a/patches/expo-widgets@58.0.27.patch +++ b/patches/expo-widgets@58.0.27.patch @@ -264,3 +264,30 @@ index 690d069..c60dd79 100644 getInstances(): NativeLiveActivity[]; + dismissEnded(): void; } +diff --git a/android/src/main/java/expo/modules/widgets/WidgetInteractionAction.kt b/android/src/main/java/expo/modules/widgets/WidgetInteractionAction.kt +--- a/android/src/main/java/expo/modules/widgets/WidgetInteractionAction.kt ++++ b/android/src/main/java/expo/modules/widgets/WidgetInteractionAction.kt +@@ -5,6 +5,7 @@ + import android.net.Uri + import androidx.glance.action.Action + import androidx.glance.appwidget.action.actionSendBroadcast ++import androidx.glance.appwidget.action.actionStartActivity + import expo.modules.widgets.notifications.ExpoWidgetsNotificationReceiver + + internal const val WIDGET_INTERACTION_ACTION = "expo.modules.widgets.ACTION_WIDGET_INTERACTION" +@@ -27,6 +28,15 @@ + ) + + internal fun WidgetInteraction.toGlanceAction(context: Context): Action { ++ if (source is InteractionSource.Widget && target.startsWith("url:")) { ++ val launch = context.packageManager.getLaunchIntentForPackage(context.packageName) ++ if (launch != null) { ++ val url = target.removePrefix("url:") ++ if (url.isNotEmpty()) launch.setAction(Intent.ACTION_VIEW).setData(Uri.parse(url)) ++ launch.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_SINGLE_TOP) ++ return actionStartActivity(launch) ++ } ++ } + val intent = when (source) { + is InteractionSource.Widget -> Intent(WIDGET_INTERACTION_ACTION) + .setComponent(widgetProviderComponentName(context, source.name)) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 05026f5eb7123..dc3c9de71b6d4 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -385,7 +385,7 @@ patchedDependencies: expo-network@58.0.4: 159c2d760d1cd5102e95df51c20600111e7a72096ef0daea8215d50cae48bd98 expo-sharing@58.0.22: 321a86e1471e34c53f1f2937fbb4304ea3f7b7a78ad9e4d35febc2b9e73e9688 expo-video@58.0.13: e80ec3ef8c4af50a607beea1c0917e71ba679844efdb702b45d17a5f03cf466c - expo-widgets@58.0.27: 8bf49067f93742855e15f29559ac75c8b50230b47069060993c9ea01dac11171 + expo-widgets@58.0.27: e7cd8b9e57973f18e30c5adcc20440d5e223b50852671c68109173441766a9a2 mdast-util-math@3.0.0: afe6b2c89e1eaddfd3c90cf2723f12c9a17d513e65beff51653781486013251c mdast-util-to-hast@13.2.1: 57ea46c73aea3c876ab8d351fe0482ddd081dcb4a0610576e399dbe5eb59d2b1 micromark-extension-gfm-table@2.1.1: 9ad23a530e45d9f83cba1da8654fe16d5af8b59d5c73d5c7a7c91fd25b9be1d8 @@ -738,7 +738,7 @@ importers: version: 58.0.7(expo@58.0.23)(react-native@0.88.1(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.88.1(@babel/core@7.29.7(supports-color@8.1.1))(bufferutil@4.1.0)(supports-color@8.1.1)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(supports-color@8.1.1)(utf-8-validate@6.0.6)) expo-widgets: specifier: 58.0.27 - version: 58.0.27(patch_hash=8bf49067f93742855e15f29559ac75c8b50230b47069060993c9ea01dac11171)(@babel/core@7.29.7(supports-color@8.1.1))(expo@58.0.23)(react-dom@19.3.0(react@19.3.0))(react-native-worklets@0.13.1(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.88.1(@babel/core@7.29.7(supports-color@8.1.1))(bufferutil@4.1.0)(supports-color@8.1.1)(utf-8-validate@6.0.6))(react-native@0.88.1(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.88.1(@babel/core@7.29.7(supports-color@8.1.1))(bufferutil@4.1.0)(supports-color@8.1.1)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(supports-color@8.1.1)(utf-8-validate@6.0.6))(react@19.3.0)(supports-color@8.1.1))(react-native@0.88.1(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.88.1(@babel/core@7.29.7(supports-color@8.1.1))(bufferutil@4.1.0)(supports-color@8.1.1)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(supports-color@8.1.1)(utf-8-validate@6.0.6))(react@19.3.0) + version: 58.0.27(patch_hash=e7cd8b9e57973f18e30c5adcc20440d5e223b50852671c68109173441766a9a2)(@babel/core@7.29.7(supports-color@8.1.1))(expo@58.0.23)(react-dom@19.3.0(react@19.3.0))(react-native-worklets@0.13.1(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.88.1(@babel/core@7.29.7(supports-color@8.1.1))(bufferutil@4.1.0)(supports-color@8.1.1)(utf-8-validate@6.0.6))(react-native@0.88.1(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.88.1(@babel/core@7.29.7(supports-color@8.1.1))(bufferutil@4.1.0)(supports-color@8.1.1)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(supports-color@8.1.1)(utf-8-validate@6.0.6))(react@19.3.0)(supports-color@8.1.1))(react-native@0.88.1(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.88.1(@babel/core@7.29.7(supports-color@8.1.1))(bufferutil@4.1.0)(supports-color@8.1.1)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(supports-color@8.1.1)(utf-8-validate@6.0.6))(react@19.3.0) gt-react-native: specifier: 11.4.18 version: 11.4.18(react-native@0.88.1(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.88.1(@babel/core@7.29.7(supports-color@8.1.1))(bufferutil@4.1.0)(supports-color@8.1.1)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(supports-color@8.1.1)(utf-8-validate@6.0.6))(react@19.3.0) @@ -18317,7 +18317,7 @@ snapshots: optionalDependencies: '@babel/runtime': 7.29.7 expo: 58.0.23(d226faf1ca90e630d8c56a2108dee32d) - expo-widgets: 58.0.27(patch_hash=8bf49067f93742855e15f29559ac75c8b50230b47069060993c9ea01dac11171)(@babel/core@7.29.7(supports-color@8.1.1))(expo@58.0.23)(react-dom@19.3.0(react@19.3.0))(react-native-worklets@0.13.1(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.88.1(@babel/core@7.29.7(supports-color@8.1.1))(bufferutil@4.1.0)(supports-color@8.1.1)(utf-8-validate@6.0.6))(react-native@0.88.1(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.88.1(@babel/core@7.29.7(supports-color@8.1.1))(bufferutil@4.1.0)(supports-color@8.1.1)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(supports-color@8.1.1)(utf-8-validate@6.0.6))(react@19.3.0)(supports-color@8.1.1))(react-native@0.88.1(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.88.1(@babel/core@7.29.7(supports-color@8.1.1))(bufferutil@4.1.0)(supports-color@8.1.1)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(supports-color@8.1.1)(utf-8-validate@6.0.6))(react@19.3.0) + expo-widgets: 58.0.27(patch_hash=e7cd8b9e57973f18e30c5adcc20440d5e223b50852671c68109173441766a9a2)(@babel/core@7.29.7(supports-color@8.1.1))(expo@58.0.23)(react-dom@19.3.0(react@19.3.0))(react-native-worklets@0.13.1(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.88.1(@babel/core@7.29.7(supports-color@8.1.1))(bufferutil@4.1.0)(supports-color@8.1.1)(utf-8-validate@6.0.6))(react-native@0.88.1(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.88.1(@babel/core@7.29.7(supports-color@8.1.1))(bufferutil@4.1.0)(supports-color@8.1.1)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(supports-color@8.1.1)(utf-8-validate@6.0.6))(react@19.3.0)(supports-color@8.1.1))(react-native@0.88.1(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.88.1(@babel/core@7.29.7(supports-color@8.1.1))(bufferutil@4.1.0)(supports-color@8.1.1)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(supports-color@8.1.1)(utf-8-validate@6.0.6))(react@19.3.0) transitivePeerDependencies: - '@babel/core' - supports-color @@ -19602,7 +19602,7 @@ snapshots: expo: 58.0.23(d226faf1ca90e630d8c56a2108dee32d) react-native: 0.88.1(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.88.1(@babel/core@7.29.7(supports-color@8.1.1))(bufferutil@4.1.0)(supports-color@8.1.1)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(supports-color@8.1.1)(utf-8-validate@6.0.6) - ? expo-widgets@58.0.27(patch_hash=8bf49067f93742855e15f29559ac75c8b50230b47069060993c9ea01dac11171)(@babel/core@7.29.7(supports-color@8.1.1))(expo@58.0.23)(react-dom@19.3.0(react@19.3.0))(react-native-worklets@0.13.1(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.88.1(@babel/core@7.29.7(supports-color@8.1.1))(bufferutil@4.1.0)(supports-color@8.1.1)(utf-8-validate@6.0.6))(react-native@0.88.1(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.88.1(@babel/core@7.29.7(supports-color@8.1.1))(bufferutil@4.1.0)(supports-color@8.1.1)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(supports-color@8.1.1)(utf-8-validate@6.0.6))(react@19.3.0)(supports-color@8.1.1))(react-native@0.88.1(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.88.1(@babel/core@7.29.7(supports-color@8.1.1))(bufferutil@4.1.0)(supports-color@8.1.1)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(supports-color@8.1.1)(utf-8-validate@6.0.6))(react@19.3.0) + ? expo-widgets@58.0.27(patch_hash=e7cd8b9e57973f18e30c5adcc20440d5e223b50852671c68109173441766a9a2)(@babel/core@7.29.7(supports-color@8.1.1))(expo@58.0.23)(react-dom@19.3.0(react@19.3.0))(react-native-worklets@0.13.1(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.88.1(@babel/core@7.29.7(supports-color@8.1.1))(bufferutil@4.1.0)(supports-color@8.1.1)(utf-8-validate@6.0.6))(react-native@0.88.1(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.88.1(@babel/core@7.29.7(supports-color@8.1.1))(bufferutil@4.1.0)(supports-color@8.1.1)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(supports-color@8.1.1)(utf-8-validate@6.0.6))(react@19.3.0)(supports-color@8.1.1))(react-native@0.88.1(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.88.1(@babel/core@7.29.7(supports-color@8.1.1))(bufferutil@4.1.0)(supports-color@8.1.1)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(supports-color@8.1.1)(utf-8-validate@6.0.6))(react@19.3.0) : dependencies: '@expo/plist': 0.10.1 '@expo/ui': 58.0.27(patch_hash=9f2547678939f4ec78fe45163b6455aad63d3c80353d7e0cc783f6a61a7ccc2f)(@babel/core@7.29.7(supports-color@8.1.1))(expo@58.0.23)(react-dom@19.3.0(react@19.3.0))(react-native-worklets@0.13.1(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.88.1(@babel/core@7.29.7(supports-color@8.1.1))(bufferutil@4.1.0)(supports-color@8.1.1)(utf-8-validate@6.0.6))(react-native@0.88.1(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.88.1(@babel/core@7.29.7(supports-color@8.1.1))(bufferutil@4.1.0)(supports-color@8.1.1)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(supports-color@8.1.1)(utf-8-validate@6.0.6))(react@19.3.0)(supports-color@8.1.1))(react-native@0.88.1(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.88.1(@babel/core@7.29.7(supports-color@8.1.1))(bufferutil@4.1.0)(supports-color@8.1.1)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(supports-color@8.1.1)(utf-8-validate@6.0.6))(react@19.3.0)