Skip to content
Merged
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 @@ -22,6 +22,8 @@ class AgentMessagingService : ExpoFirebaseMessagingService() {
override fun onMessageReceived(remoteMessage: RemoteMessage) {
if (remoteMessage.data["t3_kind"] == "agent_activity") {
AgentNotifications.receive(this, remoteMessage.data)
} else if (remoteMessage.data["t3_kind"] == "paired_alert") {
AgentNotifications.receivePaired(this, remoteMessage.data)
} else {
super.onMessageReceived(remoteMessage)
}
Expand Down Expand Up @@ -51,6 +53,51 @@ object AgentNotifications {
private const val MAX_MESSAGE_AGE_MS = 10 * 60 * 1000L
private const val RUNNING_LIFETIME_MS = 2 * 60 * 60 * 1000L
private const val MAX_LIFETIME_MS = 24 * 60 * 60 * 1000L
private const val PAIRED_STORE = "t3-paired-notifications"
private const val PAIRED_TAG = "t3-paired-alert:"

/** Paired subscriptions are independent of the T3 Connect account lifecycle. */
@Synchronized
fun configurePaired(context: Context, registrationIds: List<String>, scheme: String) {
val prefs = context.getSharedPreferences(PAIRED_STORE, Context.MODE_PRIVATE)
prefs.edit().putStringSet("registrations", registrationIds.toSet())
.putString("scheme", scheme).apply()
manager(context).activeNotifications.filter {
it.tag?.startsWith(PAIRED_TAG) == true &&
it.tag?.removePrefix(PAIRED_TAG) !in registrationIds
}.forEach { manager(context).cancel(it.tag, it.id) }
}

@Synchronized
fun receivePaired(context: Context, data: Map<String, String>) {
val prefs = context.getSharedPreferences(PAIRED_STORE, Context.MODE_PRIVATE)
val registrationId = data["registration_id"] ?: return
val notificationId = data["notification_id"] ?: return
val updatedAt = data["updated_at"]?.toLongOrNull() ?: return
if (registrationId !in prefs.getStringSet("registrations", emptySet()).orEmpty() ||
System.currentTimeMillis() - updatedAt !in -MAX_MESSAGE_AGE_MS..MAX_MESSAGE_AGE_MS ||
!NotificationManagerCompat.from(context).areNotificationsEnabled()
) return
val body = when (data["phase"]) {
"waiting_for_approval" -> "An agent needs your approval."
"waiting_for_input" -> "An agent needs your input."
"completed" -> "An agent finished."
"failed" -> "An agent failed."
else -> return
}
val seen = prefs.getString("seen", null)?.split('\n').orEmpty()
if (notificationId in seen) return
prefs.edit().putString("seen", (seen.takeLast(63) + notificationId).joinToString("\n")).apply()
channels(context)
val scheme = prefs.getString("scheme", "t3code") ?: "t3code"
val id = notificationId.hashCode()
val notification = base(context, ALERT_CHANNEL)
.setContentTitle("T3 Code").setContentText(body)
.setAutoCancel(true)
.setContentIntent(contentIntent(context, scheme, "/notifications/$registrationId/$notificationId", id))
.build()
manager(context).notify("$PAIRED_TAG$registrationId", id, notification)
}

@Synchronized
fun configure(
Expand Down Expand Up @@ -346,7 +393,7 @@ object AgentNotifications {
path: String?,
id: Int
): PendingIntent? {
val threadPath = path?.takeIf { it.startsWith("/threads/") }
val threadPath = path?.takeIf { it.startsWith("/threads/") || it.startsWith("/notifications/") }
val route = threadPath?.takeUnless { it.contains('?') || it.contains('#') } ?: "/"
val intent = context.packageManager.getLaunchIntentForPackage(context.packageName)
?: return null
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,10 @@ class T3AgentNotificationsModule : Module() {
appContext.reactContext?.let { AgentNotifications.clear(it) }
}

Function("configurePaired") { registrationIds: List<String>, scheme: String ->
appContext.reactContext?.let { AgentNotifications.configurePaired(it, registrationIds, scheme) }
}

Function("showShowcaseActivity") { scheme: String, data: Map<String, String> ->
appContext.reactContext?.let { AgentNotifications.showcase(it, scheme, data) }
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ class AgentNotificationsTest {
}
)
AgentNotifications.clear(context)
context.getSharedPreferences("t3-paired-notifications", Application.MODE_PRIVATE).edit().clear().apply()
AgentNotifications.configure(context, "device", "user", "t3code-dev", true)
// The fixtures alert for this thread; a resumed app is showing it.
AgentNotifications.setThreadOnScreen("/threads/environment/thread")
Expand All @@ -68,6 +69,54 @@ class AgentNotificationsTest {
"alert_path" to "/threads/environment/thread",
)

private fun pairedAlert(id: String = "opaque-notice") = mapOf(
"registration_id" to "opaque-registration",
"notification_id" to id,
"phase" to "completed",
"updated_at" to System.currentTimeMillis().toString(),
)

@Test
fun pairedAlertUsesGenericTextAndAnOpaqueTapRoute() {
AgentNotifications.configurePaired(context, listOf("opaque-registration"), "t3code-dev")
AgentNotifications.receivePaired(context, pairedAlert())
val notification = manager.activeNotifications.single().notification
assertEquals("T3 Code", notification.extras.getString(Notification.EXTRA_TITLE))
assertEquals("An agent finished.", notification.extras.getString(Notification.EXTRA_TEXT))
val intent = shadowOf(notification.contentIntent).savedIntent
assertEquals("t3code-dev://notifications/opaque-registration/opaque-notice", intent.data.toString())
}

@Test
fun pairedAlertsSurviveConnectSignOutAndDoNotRepeat() {
AgentNotifications.configurePaired(context, listOf("opaque-registration"), "t3code-dev")
AgentNotifications.clear(context)
AgentNotifications.receivePaired(context, pairedAlert())
AgentNotifications.receivePaired(context, pairedAlert())
assertEquals(1, manager.activeNotifications.size)
}

@Test
fun disablingPairedNotificationsClearsAlertsAndBlocksNewDelivery() {
AgentNotifications.configurePaired(context, listOf("opaque-registration"), "t3code-dev")
AgentNotifications.receivePaired(context, pairedAlert())
AgentNotifications.configurePaired(context, emptyList(), "t3code-dev")
AgentNotifications.receivePaired(context, pairedAlert("another"))
assertTrue(manager.activeNotifications.isEmpty())
}

@Test
fun pairedReceiverRejectsUnknownRegistrationsExpiredMessagesAndMissingPermission() {
AgentNotifications.receivePaired(context, pairedAlert())
assertTrue(manager.activeNotifications.isEmpty())
AgentNotifications.configurePaired(context, listOf("opaque-registration"), "t3code-dev")
AgentNotifications.receivePaired(context, pairedAlert() + ("updated_at" to (System.currentTimeMillis() - 3_600_000).toString()))
assertTrue(manager.activeNotifications.isEmpty())
shadowOf(manager).setNotificationsEnabled(false)
AgentNotifications.receivePaired(context, pairedAlert())
assertTrue(manager.activeNotifications.isEmpty())
}

@Test
fun alertHistoryEvictsOnlyTheOldestEntryAfterCapacity() {
lifecycle.currentState = Lifecycle.State.RESUMED
Expand Down
8 changes: 8 additions & 0 deletions apps/mobile/src/Stack.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,8 @@ import {
} from "./components/RenderErrorBoundary";
import { ArchivedThreadsRouteScreen } from "./features/archive/ArchivedThreadsRouteScreen";
import { useAgentNotificationNavigation } from "./features/agent-awareness/notificationNavigation";
import { usePairedNotificationSync } from "./features/agent-awareness/pairedNotifications";
import { PairedNotificationRouteScreen } from "./features/agent-awareness/PairedNotificationRouteScreen";
import { ConnectOnboardingRouteScreen } from "./features/cloud/ConnectOnboardingRouteScreen";
import { useConnectOnboardingNavigation } from "./features/cloud/connectOnboardingNavigation";
import { AttachmentFileScreen } from "./features/files/AttachmentFileScreen";
Expand Down Expand Up @@ -603,6 +605,7 @@ function RootStackLayout(props: {
const pathname = path.startsWith("/") ? path : `/${path}`;
const workspaceLocation = workspaceLocationFromState(props.state);
useAgentNotificationNavigation(workspaceLocation.pathname);
usePairedNotificationSync();
// Presents the T3 Connect onboarding sheet after an in-session sign-in.
useConnectOnboardingNavigation();
// Launcher app shortcuts: routes shortcut taps and tracks opened threads.
Expand Down Expand Up @@ -701,6 +704,11 @@ const RootStackConfig = createWorkspaceStackNavigator({
linking: THREAD_LINKING_PREFIX,
options: GLASS_HEADER_OPTIONS,
}),
PairedNotification: createNativeStackScreen({
screen: PairedNotificationRouteScreen,
linking: "notifications/:registrationId/:notificationId",
options: GLASS_HEADER_OPTIONS,
}),
ThreadTerminal: createNativeStackScreen({
screen: ThreadTerminalRouteScreen,
linking: `${THREAD_LINKING_PREFIX}/terminal`,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
import { StackActions, useNavigation, type StaticScreenProps } from "@react-navigation/native";
import { useAtomValue } from "@effect/atom-react";
import { resolvePairedNotification } from "@t3tools/client-runtime/rpc";
import { PairedNotificationReference } from "@t3tools/contracts";
import * as Option from "effect/Option";
import * as Schema from "effect/Schema";
import { AsyncResult } from "effect/reactivity";
import { useEffect, useMemo, useState, type JSX } from "react";

import { AppText as Text } from "../../components/AppText";
import { runtime } from "../../lib/runtime";
import { mobilePreferencesAtom } from "../../state/preferences";
import { useSavedRemoteConnections } from "../../state/use-remote-environment-registry";
import { SettingsScreen } from "../settings/components/SettingsScreen";
import { SettingsRow } from "../settings/components/SettingsRow";
import { pairedNotificationConnection } from "./pairedNotifications";

const decodeReference = Schema.decodeUnknownOption(PairedNotificationReference);

export function PairedNotificationRouteScreen({
route,
}: StaticScreenProps<{ registrationId: string; notificationId: string }>): JSX.Element {
const navigation = useNavigation();
const preferences = useAtomValue(mobilePreferencesAtom);
const { savedConnectionsById, isLoadingSavedConnection } = useSavedRemoteConnections();
const [result, setResult] = useState<{
readonly attempt: number;
readonly message: string;
} | null>(null);
const [attempt, setAttempt] = useState(0);
const target = useMemo(() => {
if (isLoadingSavedConnection || !AsyncResult.isSuccess(preferences))
return { message: "Loading your paired environments…", request: null };
const reference = decodeReference(route.params);
if (Option.isNone(reference))
return { message: "This notification link is invalid.", request: null };
const registration = Object.entries(preferences.value.pairedNotifications ?? {}).find(
([, value]) => value.registrationId === reference.value.registrationId && value.enabled,
);
const saved = registration
? Object.values(savedConnectionsById).find((entry) => entry.environmentId === registration[0])
: null;
if (!saved)
return {
message:
"This notification's environment is no longer paired or notifications are disabled.",
request: null,
};
const connection = pairedNotificationConnection(saved);
if (!connection)
return { message: "Reconnect to this environment to open the notification.", request: null };
return {
message: null,
request: { connection, reference: reference.value, environmentId: saved.environmentId },
};
}, [preferences, savedConnectionsById, isLoadingSavedConnection, route.params]);
useEffect(() => {
const request = target.request;
if (!request) return;
let cancelled = false;
void runtime
.runPromise(resolvePairedNotification(request.connection, request.reference))
.then((destination) => {
if (cancelled) return;
if (!destination || destination.environmentId !== request.environmentId) {
setResult({
attempt,
message: "This notification has expired or is no longer available.",
});
return;
}
navigation.dispatch(StackActions.replace("Thread", destination));
})
.catch(() => {
if (!cancelled)
setResult({
attempt,
message:
"Could not reach your environment. Check Tailscale or your network connection and retry.",
});
});
return () => {
cancelled = true;
};
}, [target, navigation, attempt]);
const message =
target.message ??
(result?.attempt === attempt ? result.message : "Connecting to your environment…");
return (
<SettingsScreen title="Notification">
<Text className="px-5 pt-4 text-base text-foreground-muted">{message}</Text>
<SettingsRow
label="Retry"
icon="arrow.clockwise"
disabled={!target.request}
onPress={() => setAttempt((value) => value + 1)}
/>
</SettingsScreen>
);
}
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { requireOptionalNativeModule } from "expo";
import { Linking, Platform } from "react-native";

interface AndroidAgentNotifications {
configurePaired?(registrationIds: ReadonlyArray<string>, scheme: string): void;
configure(deviceId: string, userId: string, scheme: string, ongoingEnabled: boolean): void;
clear(): void;
setThreadOnScreen?(path: string | null): void;
Expand All @@ -19,6 +20,14 @@ export function supportsAndroidAgentNotifications(): boolean {
return typeof native?.configure === "function" && typeof native?.clear === "function";
}

export function supportsPairedAndroidNotifications(): boolean {
return typeof native?.configurePaired === "function";
}

export function configurePairedAndroidNotifications(registrationIds: ReadonlyArray<string>): void {
native?.configurePaired?.(registrationIds, appScheme());
}

function appScheme(): string {
const scheme = Constants.expoConfig?.scheme;
return (Array.isArray(scheme) ? scheme[0] : scheme) ?? "t3code";
Expand Down
Loading
Loading