Skip to content
Merged
Original file line number Diff line number Diff line change
Expand Up @@ -115,7 +115,7 @@ internal class ActivityPresentation(data: Map<String, String>, private val activ
}

val chip = when {
!active -> null
!active -> if (phase == ActivityPhase.FAILED) "Failed" else "Done"
phase == null -> data["activity_chip"]?.takeIf { it.isNotBlank() }?.take(7) ?: "Active"
phase == ActivityPhase.RUNNING && activeCount > 1 ->
"${if (activeCount > 9) "9+" else activeCount} live"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -297,21 +297,21 @@ object AgentNotifications {
)
val presentation = ActivityPresentation(data, active)
val openThread = contentIntent(context, scheme, data["activity_path"], ACTIVITY_ID)
// Retained results still need Android's ongoing flag to remain promoted.
val ongoing = active || Build.VERSION.SDK_INT >= 36
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
val builder = base(context, ACTIVITY_CHANNEL)
.setOngoing(active).setOnlyAlertOnce(true).setSilent(true)
.setOngoing(ongoing).setOnlyAlertOnce(true).setSilent(true)
.setTimeoutAfter(remainingMs)
// Live Updates must remain uncolorized to qualify for promotion.
.setColorized(false)
.setRequestPromotedOngoing(active)
.setShortCriticalText(presentation.chip)
.setRequestPromotedOngoing(ongoing)
.setShortCriticalText(if (ongoing) presentation.chip else null)
.setContentIntent(openThread)
.setDeleteIntent(dismissIntent)
presentation.applyTo(builder, context)
val action = presentation.action
if (action != null) {
if (openThread != null) builder.addAction(0, action, openThread)
builder.addAction(0, "Dismiss", dismissIntent)
}
if (action != null && openThread != null) builder.addAction(0, action, openThread)
if (ongoing) builder.addAction(0, "Dismiss", dismissIntent)
manager(context).notify(ACTIVITY_TAG, ACTIVITY_ID, builder.build())
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) {
// Notification timeouts were added in API 26. One inexact alarm also
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package expo.modules.t3agentnotifications
import android.app.Activity
import android.app.AlarmManager
import android.app.Application
import android.app.NotificationChannel
import android.app.Notification
import android.app.NotificationManager
import android.content.ComponentName
Expand Down Expand Up @@ -264,7 +265,7 @@ class AgentNotificationsTest : AgentNotificationsFixture() {
}

@Test
fun finishedCardIsRetainedSilentlyWithoutOngoingFlagAndExpiresAtTheOriginalDeadline() {
fun finishedCardIsRetainedSilentlyAndExpiresAtTheOriginalDeadline() {
lifecycle.currentState = Lifecycle.State.RESUMED
val expiresAt = System.currentTimeMillis() + 15 * 60 * 1000L
val finished = update("finished", false) + mapOf(
Expand All @@ -275,7 +276,7 @@ class AgentNotificationsTest : AgentNotificationsFixture() {
AgentNotifications.receive(context, finished)
val card = manager.activeNotifications.single().notification
assertEquals("Agent work failed", card.extras.getString(Notification.EXTRA_TITLE))
assertFalse(card.flags and Notification.FLAG_ONGOING_EVENT != 0)
assertEquals(Build.VERSION.SDK_INT >= 36, card.flags and Notification.FLAG_ONGOING_EVENT != 0)
assertTimeout(card, 1..15 * 60 * 1000L)
AgentNotifications.receive(
context,
Expand Down Expand Up @@ -413,10 +414,22 @@ class AgentNotificationsTest : AgentNotificationsFixture() {
"android.app.Notification\$BigTextStyle",
card.extras.getString(Notification.EXTRA_TEMPLATE)
)
assertEquals(active, NotificationCompat.isRequestPromotedOngoing(card))
assertEquals(
active || Build.VERSION.SDK_INT >= 36,
NotificationCompat.isRequestPromotedOngoing(card)
)
if (!active) {
assertEquals(null, card.extras.getString(NotificationCompat.EXTRA_SHORT_CRITICAL_TEXT))
assertEquals(0, card.actions?.size ?: 0)
assertEquals(
if (Build.VERSION.SDK_INT < 36) {
null
} else if (phase == "failed") {
"Failed"
} else {
"Done"
},
card.extras.getString(NotificationCompat.EXTRA_SHORT_CRITICAL_TEXT)
)
assertEquals(if (Build.VERSION.SDK_INT >= 36) 1 else 0, card.actions?.size ?: 0)
}
}
}
Expand Down Expand Up @@ -566,7 +579,7 @@ class AgentNotificationsTest : AgentNotificationsFixture() {
}

@Test
fun liveUpdateChipChangesWithActivityAndClearsOnCompletion() {
fun liveUpdateChipChangesWithActivityAndShowsDoneOnCompletion() {
lifecycle.currentState = Lifecycle.State.RESUMED
AgentNotifications.receive(context, update("work", true))
assertEquals(
Expand All @@ -590,9 +603,13 @@ class AgentNotificationsTest : AgentNotificationsFixture() {
)
)
val finishedCard = manager.activeNotifications.single().notification
assertEquals(null, finishedCard.extras.getString(NotificationCompat.EXTRA_SHORT_CRITICAL_TEXT))
assertFalse(NotificationCompat.isRequestPromotedOngoing(finishedCard))
assertFalse(finishedCard.flags and Notification.FLAG_ONGOING_EVENT != 0)
val promoted = Build.VERSION.SDK_INT >= 36
assertEquals(
if (promoted) "Done" else null,
finishedCard.extras.getString(NotificationCompat.EXTRA_SHORT_CRITICAL_TEXT)
)
assertEquals(promoted, NotificationCompat.isRequestPromotedOngoing(finishedCard))
assertEquals(promoted, finishedCard.flags and Notification.FLAG_ONGOING_EVENT != 0)
}

@Test
Expand Down Expand Up @@ -635,6 +652,92 @@ class AgentNotificationsTest : AgentNotificationsFixture() {
}
}

@RunWith(RobolectricTestRunner::class)
@Config(sdk = [26, 33, 36], manifest = Config.NONE)
class AgentNotificationChannelsTest {
@Test
fun mutedAlertChannelKeepsTheSameActivityThroughCompletionUntilDismissedOrExpired() {
val context = RuntimeEnvironment.getApplication()
val manager = context.getSystemService(NotificationManager::class.java)
shadowOf(manager).setNotificationsEnabled(true)
AgentNotifications.clear(context)
AgentNotifications.configure(context, "device", "user", "t3code-dev", true)
val running = mapOf(
"device_id" to "device",
"user_id" to "user",
"updated_at" to System.currentTimeMillis().toString(),
"active" to "true",
"activity_title" to "Agent activity"
)
manager.createNotificationChannel(
NotificationChannel("agent-alerts", "Agent alerts", NotificationManager.IMPORTANCE_NONE)
)
AgentNotifications.receive(context, running)
val initialCard = manager.activeNotifications.single()
assertEquals("t3-agent-activity", initialCard.tag)

AgentNotifications.configure(context, "device", "user", "t3code-dev", true)
AgentNotifications.receive(
context,
running + mapOf(
"activity_phase" to "waiting_for_approval",
"activity_attention_count" to "1"
)
)
val card = manager.activeNotifications.single().notification
assertEquals("Approve", card.extras.getString(NotificationCompat.EXTRA_SHORT_CRITICAL_TEXT))

val finished = running + mapOf(
"active" to "false",
"activity_phase" to "completed",
"activity_line_0" to "Done\tTest thread\tTest project",
"activity_expires_at" to (System.currentTimeMillis() + 900000).toString()
)
AgentNotifications.receive(context, finished)
AgentNotifications.receive(context, finished)
val completedCard = manager.activeNotifications.single()
assertEquals(initialCard.id, completedCard.id)
assertEquals(initialCard.tag, completedCard.tag)
assertEquals("agent-activity", completedCard.notification.channelId)
assertEquals(
"Test thread",
completedCard.notification.extras.getString(Notification.EXTRA_TITLE)
)
assertEquals(
"Done Test project",
completedCard.notification.extras.getCharSequence(Notification.EXTRA_TEXT).toString()
)
val promoted = Build.VERSION.SDK_INT >= 36
assertEquals(promoted, NotificationCompat.isRequestPromotedOngoing(completedCard.notification))
assertEquals(
promoted,
completedCard.notification.flags and Notification.FLAG_ONGOING_EVENT != 0
)
assertEquals(
if (promoted) "Done" else null,
completedCard.notification.extras.getString(NotificationCompat.EXTRA_SHORT_CRITICAL_TEXT)
)

if (promoted) {
val dismiss = completedCard.notification.actions.single()
assertEquals("Dismiss", dismiss.title.toString())
AgentActivityDismissReceiver().onReceive(context, shadowOf(dismiss.actionIntent).savedIntent)
} else {
AgentNotifications.dismiss(context)
}
AgentNotifications.receive(context, finished)
assertTrue(manager.activeNotifications.isEmpty())

AgentNotifications.receive(context, running)
assertEquals("t3-agent-activity", manager.activeNotifications.single().tag)
AgentNotifications.receive(
context,
finished + ("activity_expires_at" to (System.currentTimeMillis() - 1).toString())
)
assertTrue(manager.activeNotifications.isEmpty())
}
}

@RunWith(RobolectricTestRunner::class)
@Config(sdk = [24, 26, 33, 36], manifest = Config.NONE)
class AgentNotificationsCompletionTest : AgentNotificationsFixture() {
Expand Down
12 changes: 10 additions & 2 deletions apps/mobile/src/features/agent-awareness/registrationPayload.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,11 +21,19 @@ export function makeRelayDeviceRegistrationRequest(
readonly preferences: Preferences;
} & (
| { readonly platform?: "ios"; readonly iosMajorVersion: number }
| { readonly platform: "android"; readonly androidApiLevel: number }
| {
readonly platform: "android";
readonly androidApiLevel: number;
readonly notificationPermissionGranted: boolean;
}
),
): RelayDeviceRegistrationRequest {
const pushAvailable = supportsAgentAwarenessPush();
const liveActivitiesEnabled = pushAvailable && input.preferences.liveActivitiesEnabled !== false;
const liveActivitiesEnabled =
pushAvailable &&
input.preferences.liveActivitiesEnabled !== false &&
(input.platform !== "android" ||
(input.notificationPermissionGranted && Boolean(input.pushToken)));
return {
deviceId: input.deviceId,
label: input.label,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,8 @@ vi.mock("../../state/server", () => ({
}));

vi.mock("expo-notifications", () => ({
AndroidImportance: { NONE: 0, HIGH: 4 },
getNotificationChannelAsync: vi.fn(() => Promise.resolve(null)),
addPushTokenListener: vi.fn(() => ({ remove: vi.fn() })),
getDevicePushTokenAsync: vi.fn(() => Promise.resolve({ type: "ios", data: "apns-token" })),
getPermissionsAsync: vi.fn(() => Promise.resolve({ granted: true })),
Expand Down Expand Up @@ -248,6 +250,11 @@ describe("makeRelayDeviceRegistrationRequest", () => {
type: "ios",
data: "apns-token",
});
vi.mocked(Notifications.getPermissionsAsync).mockReset();
vi.mocked(Notifications.getPermissionsAsync).mockResolvedValue({
granted: true,
} as Awaited<ReturnType<typeof Notifications.getPermissionsAsync>>);
vi.mocked(Notifications.getNotificationChannelAsync).mockResolvedValue(null);
vi.unstubAllGlobals();
vi.stubGlobal("__DEV__", false);
secureStore.clear();
Expand All @@ -260,6 +267,7 @@ describe("makeRelayDeviceRegistrationRequest", () => {
vi.mocked(loadAgentAwarenessRegistrationRecord).mockClear();
vi.mocked(clearAgentAwarenessRegistrationRecord).mockClear();
vi.mocked(loadOrCreateAgentAwarenessDeviceId).mockResolvedValue("device-1");
vi.mocked(loadPreferences).mockResolvedValue({ liveActivitiesEnabled: false });
widgetMocks.getInstances.mockReset();
widgetMocks.getInstances.mockReturnValue([]);
widgetMocks.start.mockClear();
Expand Down Expand Up @@ -634,6 +642,7 @@ describe("makeRelayDeviceRegistrationRequest", () => {
it.effect.each(["ios", "android"] as const)(
"does not enable %s notifications when a token rotates after permission is revoked",
(os) => {
vi.mocked(loadPreferences).mockResolvedValue({ liveActivitiesEnabled: true });
vi.spyOn(Platform, "OS", "get").mockReturnValue(os);
vi.spyOn(Platform, "Version", "get").mockReturnValue(os === "ios" ? 18 : 36);
vi.mocked(Notifications.getDevicePushTokenAsync).mockResolvedValue({
Expand All @@ -660,7 +669,7 @@ describe("makeRelayDeviceRegistrationRequest", () => {
return Effect.gen(function* () {
yield* runBackgroundOperations();
expect(registrations.at(-1)).toMatchObject({
preferences: { notificationsEnabled: true },
preferences: { notificationsEnabled: true, liveActivitiesEnabled: true },
});
vi.mocked(Notifications.getPermissionsAsync).mockResolvedValueOnce({
granted: false,
Expand All @@ -669,9 +678,21 @@ describe("makeRelayDeviceRegistrationRequest", () => {
listener({ type: os, data: "rotated" });
yield* runBackgroundOperations();
expect(registrations.at(-1)).toMatchObject({
preferences: { notificationsEnabled: false },
preferences: { notificationsEnabled: false, liveActivitiesEnabled: os === "ios" },
});
expect(registrations.at(-1)).not.toHaveProperty("pushToken");
if (os === "android") {
expect(registrations.at(-1)).toMatchObject({ pushToken: "rotated" });
vi.mocked(Notifications.getPermissionsAsync).mockResolvedValueOnce({
granted: false,
} as Awaited<ReturnType<typeof Notifications.getPermissionsAsync>>);
yield* refreshAgentAwarenessRegistration();
expect(registrations.at(-1)).toMatchObject({
pushToken: "initial",
preferences: { notificationsEnabled: false, liveActivitiesEnabled: false },
});
} else {
expect(registrations.at(-1)).not.toHaveProperty("pushToken");
}
}).pipe(
Effect.provideService(FetchHttpClient.Fetch, globalThis.fetch),
Effect.provide(
Expand All @@ -682,6 +703,71 @@ describe("makeRelayDeviceRegistrationRequest", () => {
);
},
);
it.effect.each(["blocked", "unavailable"] as const)(
"keeps Android push delivery registered when the alert channel is %s",
(channelState) => {
vi.mocked(loadPreferences).mockResolvedValue({ liveActivitiesEnabled: true });
vi.spyOn(Platform, "OS", "get").mockReturnValue("android");
vi.spyOn(Platform, "Version", "get").mockReturnValue(36);
vi.mocked(Notifications.getDevicePushTokenAsync).mockResolvedValue({
type: "android",
data: "fcm-token",
});
vi.mocked(Notifications.getNotificationChannelAsync).mockResolvedValue({
id: "agent-alerts",
name: "Agent alerts",
importance: Notifications.AndroidImportance.NONE,
} as Awaited<ReturnType<typeof Notifications.getNotificationChannelAsync>>);
if (channelState === "unavailable") {
vi.mocked(Notifications.getNotificationChannelAsync).mockRejectedValue(
new Error("Android channel lookup failed"),
);
}
const registrations: unknown[] = [];
vi.stubGlobal("fetch", async (input: RequestInfo | URL, init?: RequestInit) => {
const request = new Request(input, init);
if (request.url.endsWith("/v1/client/dpop-token")) {
return Response.json({
access_token: "dpop",
issued_token_type: "urn:ietf:params:oauth:token-type:access_token",
token_type: "DPoP",
expires_in: 300,
scope: "mobile:registration",
});
}
registrations.push(await request.json());
return Response.json({ ok: true });
});
Constants.expoConfig!.extra = { relay: { url: "https://relay.example.test" } };
setAgentAwarenessRelayTokenProvider(() => Promise.resolve("clerk"), "user-a");
return Effect.gen(function* () {
yield* runBackgroundOperations();
expect(registrations.at(-1)).toMatchObject({
pushToken: "fcm-token",
preferences: { notificationsEnabled: false, liveActivitiesEnabled: true },
});
expect(getAgentAwarenessRegistrationStatus()).toBe("registered");

const listener = vi.mocked(Notifications.addPushTokenListener).mock.calls.at(-1)![0];
listener({ type: "android", data: "rotated-fcm-token" });
yield* runBackgroundOperations();
expect(registrations.at(-1)).toMatchObject({
pushToken: "rotated-fcm-token",
preferences: { notificationsEnabled: false, liveActivitiesEnabled: true },
});

vi.mocked(Notifications.getNotificationChannelAsync).mockResolvedValue(null);
yield* refreshAgentAwarenessRegistration();
expect(registrations.at(-1)).toMatchObject({
pushToken: "fcm-token",
preferences: { notificationsEnabled: true },
});
}).pipe(
Effect.provideService(FetchHttpClient.Fetch, globalThis.fetch),
Effect.provide(layerRelayTest),
);
},
);
it.effect("preserves relay rejection errors with React Native response headers", () => {
vi.spyOn(Platform, "OS", "get").mockReturnValue("android");
vi.mocked(Notifications.getDevicePushTokenAsync).mockResolvedValue({
Expand Down
Loading
Loading