Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -212,14 +212,21 @@ object AgentNotifications {
if (lines.isEmpty()) body else lines.joinToString("\n")
)
val notification = base(context, ACTIVITY_CHANNEL)
.setContentTitle(data["activity_title"].orEmpty().take(120))
.setContentTitle(
data["activity_title"]?.takeIf {
it.isNotBlank()
}?.take(120) ?: "Agent activity"
)
.setContentText(body)
.setStyle(style)
.setOngoing(active).setOnlyAlertOnce(true).setSilent(true)
.setTimeoutAfter(remainingMs)
// Live Updates must remain uncolorized to qualify for promotion.
.setColorized(false)
.setRequestPromotedOngoing(active)
.setShortCriticalText(
if (active) data["activity_chip"]?.takeIf { it.isNotBlank() }?.take(7) ?: "Active" else null
)
.setContentIntent(contentIntent(context, scheme, data["activity_path"], ACTIVITY_ID))
.setDeleteIntent(dismissIntent)
.addAction(0, "Dismiss", dismissIntent)
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
package expo.modules.t3agentnotifications

import android.content.ActivityNotFoundException
import android.content.Intent
import android.os.Build
import android.provider.Settings
import expo.modules.kotlin.modules.Module
import expo.modules.kotlin.modules.ModuleDefinition

Expand All @@ -21,5 +25,23 @@ class T3AgentNotificationsModule : Module() {
Function("clear") {
appContext.reactContext?.let { AgentNotifications.clear(it) }
}

Function("openLiveUpdateSettings") {
val context = appContext.reactContext
if (context == null || Build.VERSION.SDK_INT < 36) {
false
} else {
try {
context.startActivity(
Intent(Settings.ACTION_APP_NOTIFICATION_PROMOTION_SETTINGS)
.putExtra(Settings.EXTRA_APP_PACKAGE, context.packageName)
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
)
true
} catch (_: ActivityNotFoundException) {
false
}
}
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -399,6 +399,46 @@ class AgentNotificationsTest {
assertEquals(Notification.VISIBILITY_PRIVATE, card.visibility)
}

@Test
fun liveUpdateChipChangesWithActivityAndClearsOnCompletion() {
lifecycle.currentState = Lifecycle.State.RESUMED
AgentNotifications.receive(context, update("work", true))
assertEquals(
"Active",
manager.activeNotifications.single().notification.extras
.getString(NotificationCompat.EXTRA_SHORT_CRITICAL_TEXT)
)
AgentNotifications.receive(context, update("input", true) + ("activity_chip" to "Review"))
val activeCard = manager.activeNotifications.single().notification
assertEquals(
"Review",
activeCard.extras.getString(NotificationCompat.EXTRA_SHORT_CRITICAL_TEXT)
)
assertTrue(NotificationCompat.isRequestPromotedOngoing(activeCard))

AgentNotifications.receive(
context,
update("done", false) + mapOf(
"activity_chip" to "Review",
"activity_expires_at" to (System.currentTimeMillis() + 900000).toString()
)
)
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)
}

@Test
fun blankTitleCannotMakeAnActivityIneligibleForPromotion() {
lifecycle.currentState = Lifecycle.State.RESUMED
AgentNotifications.receive(context, update("work", true) + ("activity_title" to " "))
assertEquals(
"Agent activity",
manager.activeNotifications.single().notification.extras.getString(Notification.EXTRA_TITLE)
)
}

@Test
fun expiredMalformedAndFutureMessagesCannotDisplayOrPoisonLaterUpdates() {
val invalid = update("invalid", true)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,15 +2,25 @@ import { beforeEach, describe, expect, it, vi } from "vite-plus/test";

const mocks = vi.hoisted(() => ({
os: "android",
native: null as { configure?: ReturnType<typeof vi.fn>; clear?: ReturnType<typeof vi.fn> } | null,
version: 36,
openSettings: vi.fn(),
native: null as {
configure?: ReturnType<typeof vi.fn>;
clear?: ReturnType<typeof vi.fn>;
openLiveUpdateSettings?: ReturnType<typeof vi.fn>;
} | null,
config: { scheme: ["t3code-preview"], extra: { iosPersonalTeamBuild: false } },
requireModule: vi.fn(),
}));

vi.mock("expo", () => ({ requireOptionalNativeModule: mocks.requireModule }));
vi.mock("expo-constants", () => ({ default: { expoConfig: mocks.config } }));
vi.mock("react-native", () => ({
Linking: { openSettings: mocks.openSettings },
Platform: {
get Version() {
return mocks.version;
},
get OS() {
return mocks.os;
},
Expand All @@ -20,12 +30,39 @@ vi.mock("react-native", () => ({
beforeEach(() => {
vi.resetModules();
mocks.os = "android";
mocks.version = 36;
mocks.openSettings.mockReset().mockResolvedValue(undefined);
mocks.native = { configure: vi.fn(), clear: vi.fn() };
mocks.config.extra.iosPersonalTeamBuild = false;
mocks.requireModule.mockReset().mockImplementation(() => mocks.native);
});

describe("Android native notification capability", () => {
it("opens the Live Update controls on supported Android builds", async () => {
mocks.native!.openLiveUpdateSettings = vi.fn(() => true);
const { openAndroidLiveUpdateSettings, supportsAndroidLiveUpdateSettings } =
await import("./androidNotifications");
expect(supportsAndroidLiveUpdateSettings()).toBe(true);
await openAndroidLiveUpdateSettings();
expect(mocks.native!.openLiveUpdateSettings).toHaveBeenCalledOnce();
expect(mocks.openSettings).not.toHaveBeenCalled();
mocks.version = 35;
expect(supportsAndroidLiveUpdateSettings()).toBe(false);
mocks.os = "ios";
mocks.version = 36;
expect(supportsAndroidLiveUpdateSettings()).toBe(false);
});

it.each([undefined, vi.fn(() => false)])(
"falls back to app settings for older binaries or missing system activities (%j)",
async (openLiveUpdateSettings) => {
if (openLiveUpdateSettings) mocks.native!.openLiveUpdateSettings = openLiveUpdateSettings;
const { openAndroidLiveUpdateSettings } = await import("./androidNotifications");
await openAndroidLiveUpdateSettings();
expect(mocks.openSettings).toHaveBeenCalledOnce();
},
);

it("uses the installed module and the build variant's deep-link scheme", async () => {
const { configureAndroidAgentNotifications, clearAndroidAgentNotifications } =
await import("./androidNotifications");
Expand Down
13 changes: 12 additions & 1 deletion apps/mobile/src/features/agent-awareness/androidNotifications.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
import Constants from "expo-constants";
import { requireOptionalNativeModule } from "expo";
import { Platform } from "react-native";
import { Linking, Platform } from "react-native";

interface AndroidAgentNotifications {
configure(deviceId: string, userId: string, scheme: string, ongoingEnabled: boolean): void;
clear(): void;
openLiveUpdateSettings?(): boolean;
}

const native =
Expand Down Expand Up @@ -33,3 +34,13 @@ export function configureAndroidAgentNotifications(
export function clearAndroidAgentNotifications(): void {
native?.clear?.();
}

export function supportsAndroidLiveUpdateSettings(): boolean {
return Platform.OS === "android" && Number(Platform.Version) >= 36;
}

export async function openAndroidLiveUpdateSettings(): Promise<void> {
if (!native?.openLiveUpdateSettings?.()) {
await Linking.openSettings();
}
}
26 changes: 25 additions & 1 deletion apps/mobile/src/features/settings/SettingsRouteScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,10 @@ import {
import { AndroidScreenHeader } from "../../components/AndroidScreenHeader";
import { AppText as Text, AppTextInput as TextInput } from "../../components/AppText";
import { supportsAgentAwarenessPush } from "../agent-awareness/capabilities";
import {
openAndroidLiveUpdateSettings,
supportsAndroidLiveUpdateSettings,
} from "../agent-awareness/androidNotifications";
import { setLiveActivityUpdatesEnabled } from "../agent-awareness/liveActivityPreferences";
import { requestAgentNotificationPermission } from "../agent-awareness/notificationPermissions";
import {
Expand Down Expand Up @@ -541,7 +545,13 @@ function ConfiguredSettingsRouteScreen() {
liveActivityStatus === "linking"
}
icon="bolt.circle"
label={Platform.OS === "android" ? "Ongoing Agent Activity" : "Live Activity Updates"}
label={
Platform.OS === "android"
? supportsAndroidLiveUpdateSettings()
? "Agent Live Updates"
: "Ongoing Agent Activity"
: "Live Activity Updates"
}
subtitle={agentAwarenessSubtitle}
// Same gate: a saved preference is meaningless until the device
// registration the relay needs to push updates has succeeded.
Expand All @@ -552,6 +562,20 @@ function ConfiguredSettingsRouteScreen() {
}
onValueChange={handleLiveActivitiesChange}
/>
{supportsAndroidLiveUpdateSettings() ? (
<SettingsRow
icon="bolt.circle"
label="Live Update Settings"
onPress={() => {
void openAndroidLiveUpdateSettings().catch(() => {
Alert.alert(
"Couldn't open Settings",
"Open Android Settings, select T3 Code, then enable Live Updates in Notifications.",
);
});
}}
/>
) : null}
</SettingsSection>

<GeneralSettingsSection />
Expand Down
2 changes: 2 additions & 0 deletions docs/operations/android-notifications.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ The Android app receives Firebase Cloud Messaging (FCM) data messages. The relay

The app's minimum is Android 7.0 (API 24), declared in `app.config.ts` and enforced by the relay's device-registration schema. Compile/target SDK versions follow the locked Expo/React Native toolchain (currently API 36). Notification channels begin at API 26; the notification permission prompt begins at API 33. Live Update promotion requires API 36 and remains subject to system settings and device support. Alerts and ordinary activity cards work below API 36.

On Android 16+, open T3 Code Settings → Live Update Settings to allow status bar chips. Android controls this separately from notification permission. The chip reads `Active` during work and `Review` when an agent needs input or approval; completed work returns to a normal notification. Use an Android 16 QPR2 or newer emulator image to verify the shipped promotion behavior.

API 24–25 use a single inexact system alarm to expire cards after process exit, with no exact-alarm permission. Android can delay that alarm in power-saving modes. API 26+ use notification timeouts. Disabling activity, dismissal, account changes and sign-out cancel the legacy alarm. A stale expiry broadcast cannot remove a newer run's card.

The native notification tests cover API 24, 26, 33 and 36 (plus API 25 for legacy expiry) using Robolectric. To compile the module, run these tests and run Android lint, use the following command from a generated `apps/mobile/android` project with JDK 21 available. No Firebase, signing or relay secrets are required:
Expand Down
6 changes: 6 additions & 0 deletions infra/relay/src/agentActivity/FcmDeliveries.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -616,6 +616,12 @@ describe("Android delivery routing", () => {
]);
const data = androidActivityData(aggregate);
expect(data.activity_title).toBe("3 active agents · 2 need attention");
expect(data.activity_chip).toBe("Review");
expect(androidActivityData(aggregateFor([state])).activity_chip).toBe("Active");
expect(
androidActivityData(aggregateFor([{ ...state, phase: "completed" }])).activity_chip,
).toBe("");
expect(androidActivityData(null).activity_chip).toBe("");
expect(
Object.entries(data)
.filter(([key]) => key.startsWith("activity_line_"))
Expand Down
2 changes: 2 additions & 0 deletions infra/relay/src/agentActivity/fcmPayloads.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,8 @@ export function androidActivityData(aggregate: RelayAgentActivityAggregateState
);
return {
active: String(activeCount > 0),
// Keep the status bar chip short enough to display alongside the app icon.
activity_chip: activeCount > 0 ? (attentionCount > 0 ? "Review" : "Active") : "",
activity_title: title,
activity_body: hero
? `${hero.status}: ${clean(hero.threadTitle)} · ${clean(hero.projectTitle)}`
Expand Down
Loading