Skip to content
Merged
Show file tree
Hide file tree
Changes from 27 commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
fd13636
feat(flags): register local notification avatars
Sep 11, 2026
f930adf
fix(web): scope avatar query retention
Sep 11, 2026
aa43f78
feat(notifications): include assistant name in intents
Sep 11, 2026
8f6aae9
feat(ipc): define scoped notification contracts
Sep 11, 2026
d3e4ab7
refactor(ios): share notification helpers
Sep 11, 2026
bd8ff71
refactor(android): share notification inputs
Sep 11, 2026
3816908
docs(flags): link local avatar companion PR
Sep 11, 2026
35ba49d
fix(ipc): support targeted identity reset revisions
Sep 11, 2026
90b1424
feat(android): coordinate notification delivery in memory
Sep 11, 2026
385616f
refactor(web): centralize notification sender resolution
Sep 11, 2026
a3fd588
fix(android): harden notification delivery ownership
Sep 11, 2026
8d32387
feat(ios): own local sender notification delivery
Sep 11, 2026
bddeb18
fix(web): retain notification generation safety
Sep 11, 2026
f395b8d
fix(ios): retain notification generation safety
Sep 11, 2026
c6dcf0a
feat(android): add scoped sender notification bridge
Sep 11, 2026
09ac9a3
feat(web): prepare notification avatars across surfaces
Sep 11, 2026
2324daa
feat(web): carry assistant identity through notification intents
Sep 11, 2026
8d5087d
refactor(web): centralize notification platform ids
Sep 11, 2026
ebd2499
refactor(web): route notification taps with explicit assistant identity
Sep 11, 2026
9f899fc
feat(web): show prepared avatars in browser notifications
Sep 11, 2026
979d601
feat(desktop): retain notification senders across windows in memory
Sep 11, 2026
cf84057
feat(macos): personalize notification permission confirmation
Sep 11, 2026
ba2e9ca
feat(ios): route local notifications through native ownership
Sep 11, 2026
4676418
feat(android): unify foreground push and local notification ownership
Sep 11, 2026
92e1495
test(web): use distinct notification sender ids
Sep 11, 2026
614c0cb
docs(notifications): record local avatar verification and rollout gates
Sep 11, 2026
f887e1f
fix(android): avoid notification writer id shadowing
Sep 11, 2026
e1406ea
fix(notifications): fence renderer identity generations
Sep 12, 2026
aa9f720
fix(notifications): keep preload sandbox safe
Sep 12, 2026
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
45 changes: 34 additions & 11 deletions ARCHITECTURE.md

Large diffs are not rendered by default.

87 changes: 85 additions & 2 deletions assistant/src/__tests__/notification-vellum-adapter.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,11 @@ import { beforeEach, describe, expect, mock, test } from "bun:test";
// ── Mocks: declared before imports that depend on them ──────────────

let updateMessageContentShouldThrow = false;
let assistantName: string | null = null;

mock.module("../daemon/identity-helpers.js", () => ({
getAssistantName: () => assistantName,
}));

const updateMessageContentMock = mock(
(_messageId: string, _content: string) => {
Expand Down Expand Up @@ -55,12 +60,21 @@ function makeDestination(
function captureBroadcast(): {
adapter: VellumAdapter;
sent: AssistantEvent[];
conversationIds: Array<string | undefined>;
} {
const sent: AssistantEvent[] = [];
const adapter = new VellumAdapter((msg) => sent.push(msg));
return { adapter, sent };
const conversationIds: Array<string | undefined> = [];
const adapter = new VellumAdapter((msg, conversationId) => {
sent.push(msg);
conversationIds.push(conversationId);
});
return { adapter, sent, conversationIds };
}

beforeEach(() => {
assistantName = null;
});

describe("VellumAdapter silent flag", () => {
test("non-urgent (low) urgency broadcasts silent: true", async () => {
const { adapter, sent } = captureBroadcast();
Expand Down Expand Up @@ -181,6 +195,75 @@ describe("VellumAdapter remotePushDispatched pass-through", () => {
});
});

describe("VellumAdapter assistant name", () => {
test("broadcasts the current verified assistant name after trimming it", async () => {
assistantName = " Example Assistant ";
const { adapter, sent } = captureBroadcast();

await adapter.send(makePayload(), makeDestination());

const intent = sent[0] as Extract<
AssistantEvent,
{ type: "notification_intent" }
>;
expect(intent.assistantName).toBe("Example Assistant");
});

test("omits the assistant name when it is unavailable", async () => {
const { adapter, sent } = captureBroadcast();

await adapter.send(makePayload(), makeDestination());

const intent = sent[0] as Extract<
AssistantEvent,
{ type: "notification_intent" }
>;
expect(intent.assistantName).toBeUndefined();
expect("assistantName" in intent).toBe(false);
});

test("omits a blank assistant name", async () => {
assistantName = " \t\n ";
const { adapter, sent } = captureBroadcast();

await adapter.send(makePayload(), makeDestination());

const intent = sent[0] as Extract<
AssistantEvent,
{ type: "notification_intent" }
>;
expect(intent.assistantName).toBeUndefined();
expect("assistantName" in intent).toBe(false);
});

test("reflects a renamed assistant on the next intent", async () => {
assistantName = "Assistant A";
const { adapter, sent } = captureBroadcast();
await adapter.send(makePayload(), makeDestination());

assistantName = "Assistant B";
await adapter.send(makePayload(), makeDestination());

const intents = sent as Array<
Extract<AssistantEvent, { type: "notification_intent" }>
>;
expect(intents.map((intent) => intent.assistantName)).toEqual([
"Assistant A",
"Assistant B",
]);
});

test("keeps notification intents outside conversation replay scope", async () => {
assistantName = "Example Assistant";
const { adapter, sent, conversationIds } = captureBroadcast();

await adapter.send(makePayload(), makeDestination());

expect(conversationIds).toEqual([undefined]);
expect("conversationId" in sent[0]!).toBe(false);
});
});

describe("VellumAdapter update", () => {
beforeEach(() => {
updateMessageContentMock.mockClear();
Expand Down
2 changes: 2 additions & 0 deletions assistant/src/api/events/notification-intent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@ import { z } from "zod";
export const NotificationIntentEventSchema = z.object({
type: z.literal("notification_intent"),
sourceEventName: z.string(),
/** Verified assistant name supplied by the assistant when available. */
assistantName: z.string().optional(),
title: z.string(),
body: z.string(),
deliveryId: z.string().optional(),
Expand Down
3 changes: 3 additions & 0 deletions assistant/src/notifications/adapters/macos.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@

import type { AssistantEvent } from "../../api/index.js";
import type { InterfaceId } from "../../channels/types.js";
import { getAssistantName } from "../../daemon/identity-helpers.js";
import { updateMessageContent } from "../../persistence/conversation-crud.js";
import { publishConversationMessagesChanged } from "../../runtime/sync/resource-sync-events.js";
import { getLogger } from "../../util/logger.js";
Expand Down Expand Up @@ -101,12 +102,14 @@ export class VellumAdapter implements ChannelAdapter {

const silent =
payload.urgency !== "high" && payload.urgency !== "critical";
const assistantName = getAssistantName()?.trim() || undefined;

this.broadcast({
type: "notification_intent",
deliveryId: payload.deliveryId,
correlationId: payload.correlationId,
sourceEventName: payload.sourceEventName,
...(assistantName ? { assistantName } : {}),
title: payload.copy.title,
body: payload.copy.body,
deepLinkMetadata: payload.deepLinkTarget,
Expand Down
68 changes: 50 additions & 18 deletions clients/android/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -405,17 +405,41 @@ one natively must never claim it. The claim is deliberately not gated on
shape, while the capability says which tokens could render one, so a shell that
can render natively says so whether the flag is on or off.

`SafeMessagingService` routes each push to exactly one renderer. A push whose
payload carries a Firebase notification block, or a data-only push the web
layer can render right now, goes to `PushNotificationsPlugin`. Everything else
is rendered natively, including a data-only push that arrives while the app is
on screen but the bridge is not up yet, so a cold start never drops one. "Right
now" means an activity of ours is resumed and the web runtime holds a foreground
push handler, which it asserts through
`AndroidPushRegistration.setForegroundHandler` for exactly as long as it holds
one. Those foreground pushes are drawn by the web layer and carry no avatar
treatment, an accepted scope cut. A native render that throws falls through to
`PushNotificationsPlugin` rather than losing the push.
The token capability is not live foreground ownership. New web code negotiates
that separately with a versioned, page-generation-bound
`AndroidPushRegistration.setNotificationOwnership` handshake only after the
coordinator-backed local route is ready. The identity adapter is installed
before the first asynchronous capability or foreground announcement. Page
start, renderer loss, activity destruction, and bridge destruction enqueue a
serialized clear. A stale call from an older page generation cannot restore
ownership. These clears do not reset retained delivery results. Ownership
negotiation is intentionally independent of `local-notification-avatar`, so
turning assistant presentation off cannot send a local request back through an
unshared deduplication lane.

`SafeMessagingService` routes each push to exactly one owner. A push carrying a
Firebase notification block keeps the existing `PushNotificationsPlugin`
route. Data-only FCM and app-originated local requests after the ownership
handshake use the process-wide `NotificationDeliveryCoordinator`. Both
normalize the first semantically present correlation id, delivery id, or
request key with the same trim and 512 UTF-16 code-unit bound. An overlength
higher-precedence candidate fails closed rather than falling through. This full
string key is separate from the numeric Android notification id, and the
original SSE delivery id remains the acknowledgment id.
`local-notification-avatar` controls whether a qualifying local request asks
for assistant presentation. With it off, the same coordinator posts the app or
plain presentation.

Negotiated foreground data-only FCM visits the web runtime only for the same
active-conversation, chat-route, and page-visible suppression decision used by
SSE. If display is still required, it returns to the shared native coordinator
for the only banner and channel-owned sound. Bounded process-RAM focus
tombstones use the canonical key across FCM-first and SSE-first arrival, so
navigating away between matching events does not reopen display. Before live
ownership, compatibility routes remain available. After a coordinator claim,
every success, duplicate, blocked result, timeout, malformed bridge response,
or failure remains native-owned and cannot schedule a JavaScript fallback.
Only a native process restart clears the bounded coordinator memory.

`NativePushRenderer` posts the native notification. With a sender it is a
`MessagingStyle` conversation: the avatar is the large icon, the assistant's
Expand All @@ -425,12 +449,10 @@ channel a payload may name: it is created if it is missing, because from API 26
posting to a channel that does not exist is a silent no-op, and any other name
posts here anyway and is logged once. Existing is not enough, since the voice
session channel and Firebase's own fallback both exist and both post silently.
The notification id walks the same seed chain the web layer hashes: the
`delivery_id`, then the Firebase message id, then the composite of the source
event with the copy. Every rung is trimmed on both sides, so padded copy cannot
split one delivery in two. A payload carrying no `delivery_id` therefore keys on
the Firebase message id, which is per-delivery, so it does not collapse onto the
conversation either.
The coordinator's full key and Android's numeric notification id serve
different purposes. The coordinator prevents duplicate ownership across FCM
and SSE without hashing away the full correlation identity. The numeric id
still gives Android a stable integer for posting and replacement.

Each conversation notification also publishes a long-lived dynamic shortcut,
which is what gives Android the conversation treatment. The shortcut intent is
Expand Down Expand Up @@ -467,7 +489,10 @@ avatar.
### Device QA checklist

Native rendering needs a physical device with Play services and a data-only
push from a lower environment. Verify:
push from a lower environment. This local checklist supplements the canonical
[notification avatar and local delivery QA ledger](../../docs/notification-avatar-local-qa.md),
which owns statuses, the two-flag matrix, compatibility cases, and rollout
gates. Verify:

- Killed, background, and foreground delivery each post exactly one banner,
never two.
Expand All @@ -489,6 +514,13 @@ push from a lower environment. Verify:
publishes no launcher shortcut.
- On API 24 or 25 the notification plays the default sound.
- A push naming an unknown channel still arrives, on `vellum-alerts`.
- Focused delivery suppresses both FCM-first and SSE-first orders while the
matching conversation route is visible, without losing the original SSE
acknowledgment id.
- Reloading the WebView clears page ownership but does not make an already
claimed full key eligible for another banner or sound.
- All four combinations of `push-avatar-sender` and
`local-notification-avatar` preserve exactly one delivery owner.

## Structure

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,13 +18,16 @@ public class AndroidPushRegistrationPlugin extends Plugin {
* natively must never claim it.
*/
static final String NATIVE_NOTIFICATION_RENDER = "native-notification-render";
static final int NOTIFICATION_OWNERSHIP_VERSION = 1;

/**
* Whether the web runtime currently holds a handler for foreground pushes.
* The plugin instance outlives that handler, so the renderer asks this
* instead: a push handed to a torn-down handler is simply lost.
*/
private static volatile boolean foregroundHandler;
private static volatile boolean notificationOwnership;
private static int bridgeGeneration;

@PluginMethod
public void register(PluginCall call) {
Expand Down Expand Up @@ -56,6 +59,20 @@ public void setForegroundHandler(PluginCall call) {
call.resolve();
}

@PluginMethod
public void getNotificationOwnershipGeneration(PluginCall call) {
call.resolve(notificationOwnershipGenerationPayload());
}

@PluginMethod
public void setNotificationOwnership(PluginCall call) {
Integer version = call.getInt("version");
Integer generation = call.getInt("generation");
Boolean active = call.getBoolean("active");
boolean accepted = negotiateNotificationOwnership(version, generation, active);
call.resolve(notificationOwnershipPayload(accepted));
}

static void setForegroundHandler(boolean active) {
foregroundHandler = active;
}
Expand All @@ -64,9 +81,43 @@ public static boolean hasForegroundHandler() {
return foregroundHandler;
}

/** A page load takes the handler with it without running its own teardown. */
public static void clearForegroundHandler() {
public static boolean hasNotificationOwnership() {
return notificationOwnership;
}

static synchronized boolean negotiateNotificationOwnership(
Integer version,
Integer generation,
Boolean active
) {
if (
version == null
|| version != NOTIFICATION_OWNERSHIP_VERSION
|| generation == null
|| generation != bridgeGeneration
|| active == null
) {
return false;
}
notificationOwnership = active;
return true;
}

static synchronized int bridgeGeneration() {
return bridgeGeneration;
}

public static synchronized void clearBridgeState() {
foregroundHandler = false;
notificationOwnership = false;
bridgeGeneration = bridgeGeneration == Integer.MAX_VALUE
? 0
: bridgeGeneration + 1;
}

static void clearBridgeStateSerialized(Consumer<Runnable> bridgeExecutor) {
clearBridgeState();
bridgeExecutor.accept(AndroidPushRegistrationPlugin::clearBridgeState);
}

static JSObject capabilitiesPayload() {
Expand All @@ -77,6 +128,27 @@ static JSObject capabilitiesPayload() {
return payload;
}

static synchronized JSObject notificationOwnershipGenerationPayload() {
return new JSObject()
.put("version", NOTIFICATION_OWNERSHIP_VERSION)
.put("generation", bridgeGeneration);
}

static synchronized JSObject notificationOwnershipPayload(boolean accepted) {
JSObject payload = new JSObject();
payload.put("version", NOTIFICATION_OWNERSHIP_VERSION);
payload.put("generation", bridgeGeneration);
payload.put("active", notificationOwnership);
payload.put("accepted", accepted);
return payload;
}

@Override
protected void handleOnDestroy() {
clearBridgeState();
super.handleOnDestroy();
}

private void invokeSafely(PluginCall call, Consumer<PushNotificationsPlugin> operation) {
PushRegistrationGuard.call(call, () -> {
PushNotificationsPlugin plugin = PushNotificationsPlugin.getPushNotificationsInstance();
Expand Down
Loading
Loading