diff --git a/.github/workflows/mobile.yml b/.github/workflows/mobile.yml
index 6dbfc02aa3c..3c32dda64d4 100644
--- a/.github/workflows/mobile.yml
+++ b/.github/workflows/mobile.yml
@@ -83,3 +83,17 @@ jobs:
- name: Check formatting
run: pnpm format:check
+
+ native-dismissal:
+ runs-on: macos-15
+ env:
+ ORCA_BACKGROUND_LAUNCH: '1'
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v6
+ - name: Check native dismissal ledger
+ run: |
+ swiftc mobile/modules/orca-notification-dismissal/ios/PushDismissalLedger.swift \
+ mobile/modules/orca-notification-dismissal/tests/PushDismissalLedgerChecks.swift \
+ -o "$RUNNER_TEMP/push-dismissal-ledger-checks"
+ "$RUNNER_TEMP/push-dismissal-ledger-checks"
diff --git a/docs/reference/mobile-push-contract.md b/docs/reference/mobile-push-contract.md
index c5ff8955dbf..68730b6f5f3 100644
--- a/docs/reference/mobile-push-contract.md
+++ b/docs/reference/mobile-push-contract.md
@@ -324,12 +324,13 @@ Secret Manager names (already exist in `onorca-cloud`): `orca-cloud-push-apns-ke
controls native push registration. Hint: “Get agent alerts even when the app is closed.
Delivered through Orca’s push service and Apple or Google.” Desktop category controls are
authoritative and are not duplicated as phone overrides. Phone sound and viewing controls remain
- independent. **Only when away from desktop** defaults on (180 seconds of OS input idle,
- or locked). Unknown/headless presence does not suppress; it is never inferred from remote CPU
+ independent. Consent is stored only in `orca:pushNotificationsEnabled`; a missing preference
+ remains off, and obsolete test-build push keys do not grant consent. **Only when away from desktop**
+ defaults on (180 seconds of OS input idle, or locked). Unknown/headless presence does not suppress; it is never inferred from remote CPU
activity. The detailed payload disclosure remains in the notification documentation.
-- `notifications.delivery-policy.v1` advertises the away and mobile-inactivity lease policy.
- Filter flags are optional and ignored by older hosts; the UI identifies paired hosts requiring
- an update. Category mirroring and seven-day expiry are fixed product rules.
+- `notifications.remote-push.v1` is the single push capability, including category mirroring,
+ away filtering and seven-day expiry. Settings retain pair/update guidance for hosts without
+ push support and leave unanswered probes unresolved.
- All registrations receive a persisted seven-day `expiresAt` on the
paired desktop. Delivery and transport retries exclude expired registrations. Only foreground
mobile registration renews it: on connection, foreground return, and every 15 minutes while
@@ -348,8 +349,9 @@ Secret Manager names (already exist in `onorca-cloud`): `orca-cloud-push-apns-ke
stored host's `publicKeyB64`; then existing `getNotificationNavigationTarget` + `useOpenNotificationRoute`.
- Reopen: subscribe to socket notifications for live dismissals, but ignore ordinary
notification frames for banner presentation. Reconnect reconciliation sends identities currently in
- the native tray and applies returned dismissal decisions; it does not replay notifications or create
- banners. Live dismiss events also remove matching presented notifications.
+ the native tray in pages of 256 and applies only confirmed host/epoch/sequence identities;
+ it requests no historical events and creates no banners. The server replay RPC remains compatible
+ with independently updated clients. Live dismiss events also remove matching presented notifications.
- Old host without the capability: nothing changes.
## Infra (`cloud/infra/terraform`, `.github/workflows`)
@@ -381,9 +383,8 @@ alert messages, Live Activities, account-based quota tiers.
### Device delivery preferences
-The desktop advertises `notifications.delivery-preferences.v1`. Completion detection remains active
-when desktop notifications are off; semantic validity checks still precede delivery. IPC publishes
-`desktopAllowed: false` when the desktop master or source/category switch rejects an event. That
+Completion detection remains active when desktop notifications are off; semantic validity checks still
+precede delivery. IPC publishes `desktopAllowed: false` when the desktop master or source/category switch rejects an event. That
desktop category decision is authoritative for both desktop and phone alerts. Desktop focus and
native authorization remain desktop-only presentation gates and do not change mobile eligibility.
diff --git a/mobile/app/notifications.tsx b/mobile/app/notifications.tsx
index 03c44b4c1d5..1d4f54dba08 100644
--- a/mobile/app/notifications.tsx
+++ b/mobile/app/notifications.tsx
@@ -156,13 +156,6 @@ export default function NotificationsScreen() {
)}
- {remotePushSupport.resolved &&
- remotePushSupport.supported &&
- !remotePushSupport.policySupported && (
-
- Update your paired desktops to use the away and 7-day pause rules.
-
- )}
({
dismissHostPushNotification: vi.fn(async () => {})
}))
vi.mock('./push-dismissal-reconciliation', () => ({
- requestNotificationCatchup: vi.fn(async () => ({ ok: true }))
+ requestNotificationCatchup: vi.fn(async () => {})
}))
vi.mock('./notification-permissions', () => ({}))
@@ -43,12 +43,7 @@ describe('subscribeToDesktopNotifications', () => {
source: 'agent-task-complete'
})
await Promise.resolve()
- expect(requestNotificationCatchup).toHaveBeenCalledWith(
- rpc,
- 'host-1',
- undefined,
- expect.any(Function)
- )
+ expect(requestNotificationCatchup).toHaveBeenCalledWith(rpc, 'host-1', expect.any(Function))
expect(dismissHostPushNotification).not.toHaveBeenCalled()
})
diff --git a/mobile/src/notifications/mobile-notifications.ts b/mobile/src/notifications/mobile-notifications.ts
index 62f668871f2..974c9516263 100644
--- a/mobile/src/notifications/mobile-notifications.ts
+++ b/mobile/src/notifications/mobile-notifications.ts
@@ -36,7 +36,7 @@ export function subscribeToDesktopNotifications(client: RpcClient, hostId: strin
}
// A max watermark asks only which delivered pushes are stale; socket history
// never becomes a second OS-notification delivery route.
- void requestNotificationCatchup(client, hostId, undefined, () => disposed).catch(() => {})
+ void requestNotificationCatchup(client, hostId, () => disposed).catch(() => {})
return
}
if (!disposed && event.type === 'dismiss') {
diff --git a/mobile/src/notifications/notification-routing.test.ts b/mobile/src/notifications/notification-routing.test.ts
index 779aa2425e5..3dac953c85b 100644
--- a/mobile/src/notifications/notification-routing.test.ts
+++ b/mobile/src/notifications/notification-routing.test.ts
@@ -1,29 +1,10 @@
import { describe, expect, it } from 'vitest'
import {
- buildLocalNotificationData,
getNotificationNavigationTarget,
notificationCredentialRecoveryRoute
} from './notification-routing'
describe('notification routing', () => {
- it('includes the host id in locally scheduled notification data', () => {
- expect(
- buildLocalNotificationData(
- {
- source: 'agent-task-complete',
- worktreeId: 'repo::/Users/me/orca/workspaces/feature',
- notificationId: 'agent:one'
- },
- 'host-1'
- )
- ).toEqual({
- source: 'agent-task-complete',
- hostId: 'host-1',
- worktreeId: 'repo::/Users/me/orca/workspaces/feature',
- notificationId: 'agent:one'
- })
- })
-
// Identities stay raw: the target is dispatched as navigator params, not a URL.
it('routes notification taps to the worktree terminal screen', () => {
expect(
diff --git a/mobile/src/notifications/notification-routing.ts b/mobile/src/notifications/notification-routing.ts
index 5f81fb3567d..2136bd69f1b 100644
--- a/mobile/src/notifications/notification-routing.ts
+++ b/mobile/src/notifications/notification-routing.ts
@@ -2,21 +2,6 @@ import type { HostStackRouteTarget } from '../navigation/host-stack-navigation'
import { mobileSessionRouteTarget } from '../session/mobile-session-route'
import type { HostCredentialStatus } from '../transport/types'
-export type DesktopNotificationSource = 'agent-task-complete' | 'terminal-bell' | 'test'
-
-export type DesktopNotificationEvent = {
- source: DesktopNotificationSource
- worktreeId?: string
- notificationId?: string
-}
-
-export type LocalNotificationData = {
- source: DesktopNotificationSource
- hostId: string
- worktreeId?: string
- notificationId?: string
-}
-
export type NotificationNavigationOptions = {
knownHostIds?: ReadonlySet
credentialStatusByHostId?: ReadonlyMap
@@ -26,23 +11,6 @@ function readNonEmptyString(value: unknown): string | null {
return typeof value === 'string' && value.trim().length > 0 ? value : null
}
-export function buildLocalNotificationData(
- event: DesktopNotificationEvent,
- hostId: string
-): LocalNotificationData {
- const data: LocalNotificationData = {
- source: event.source,
- hostId
- }
- if (event.worktreeId) {
- data.worktreeId = event.worktreeId
- }
- if (event.notificationId) {
- data.notificationId = event.notificationId
- }
- return data
-}
-
/** Where a tap should land. `sessionTarget` is null for a host-only notification, whose
* `/h/` push is shallow enough to need no host-stack coordination. */
export type NotificationNavigationTarget = Readonly<{
diff --git a/mobile/src/notifications/push-dismissal-native-races.test.ts b/mobile/src/notifications/push-dismissal-native-races.test.ts
index b20c48adfba..e2746963e0c 100644
--- a/mobile/src/notifications/push-dismissal-native-races.test.ts
+++ b/mobile/src/notifications/push-dismissal-native-races.test.ts
@@ -41,8 +41,7 @@ vi.mock('expo-notifications', () => ({ getPresentedNotificationsAsync: async ()
vi.mock('../transport/host-store', () => ({ loadHostCatalog: async () => [{ id: 'host' }] }))
vi.mock('./push-host-fingerprint', () => ({ resolveHostIdForFingerprint: () => 'host' }))
vi.mock('../storage/preferences', () => ({
- loadPushNotificationsEnabled: async () => true,
- loadRemotePushEnabled: async () => true
+ loadPushNotificationsEnabled: async () => true
}))
vi.mock('./notification-viewing-policy', () => ({
shouldSuppressNotificationWhileViewing: async () => false
diff --git a/mobile/src/notifications/push-dismissal-reconciliation.test.ts b/mobile/src/notifications/push-dismissal-reconciliation.test.ts
index 2a9cf9b2ba6..f1e1d34c606 100644
--- a/mobile/src/notifications/push-dismissal-reconciliation.test.ts
+++ b/mobile/src/notifications/push-dismissal-reconciliation.test.ts
@@ -36,14 +36,9 @@ it('clears a confirmed prior-epoch alert even with empty replay and preserves ne
ok: true,
result: { notifications: [], epoch: 'new-process', dismissedPushes: [id] }
}))
- await requestNotificationCatchup(
- { sendRequest } as never,
- 'host-a',
- { lastSeenSeq: 20 },
- () => false
- )
+ await requestNotificationCatchup({ sendRequest } as never, 'host-a', () => false)
expect(sendRequest).toHaveBeenCalledWith('notifications.getMissedSince', {
- lastSeenSeq: 20,
+ lastSeenSeq: Number.MAX_SAFE_INTEGER,
deliveredPushes: [id, { ...id, notificationSeq: 14 }]
})
expect(Notifications.dismissNotificationAsync).toHaveBeenCalledExactlyOnceWith('old')
@@ -53,34 +48,43 @@ it('keeps alerts when an old host omits reconciliation or the request fails', as
await requestNotificationCatchup(
{ sendRequest: async () => response } as never,
'host-a',
- { lastSeenSeq: 0 },
() => false
)
}
expect(Notifications.dismissNotificationAsync).not.toHaveBeenCalled()
})
it('ignores unrequested identities and a response arriving after disconnect', async () => {
+ let disposed = false
const sendRequest = vi.fn(async () => ({
ok: true,
- result: { dismissedPushes: [{ ...id, notificationSeq: 99 }] }
+ result: {
+ dismissedPushes: [
+ { ...id, notificationSeq: 99 },
+ { ...id, notificationEpoch: 'different-epoch' },
+ { ...id, notificationId: 'different-alert' }
+ ]
+ }
}))
- await requestNotificationCatchup(
- { sendRequest } as never,
- 'host-a',
- { lastSeenSeq: 0 },
- () => false
- )
- sendRequest.mockResolvedValue({ ok: true, result: { dismissedPushes: [id] } })
- await requestNotificationCatchup(
- { sendRequest } as never,
- 'host-a',
- { lastSeenSeq: 0 },
- () => true
- )
+ await requestNotificationCatchup({ sendRequest } as never, 'host-a', () => disposed)
+ sendRequest.mockImplementationOnce(async () => {
+ disposed = true
+ return { ok: true, result: { dismissedPushes: [id] } }
+ })
+ await requestNotificationCatchup({ sendRequest } as never, 'host-a', () => disposed)
+ expect(sendRequest).toHaveBeenCalledTimes(2)
expect(Notifications.dismissNotificationAsync).not.toHaveBeenCalled()
})
-it('pages individual tray identities without replaying history twice', async () => {
+it('skips the replay RPC when the tray has no alerts for this host', async () => {
+ vi.mocked(Notifications.getPresentedNotificationsAsync).mockResolvedValue([
+ presented('other', { hostFingerprint: 'other-host' })
+ ] as never)
+ const sendRequest = vi.fn()
+ await requestNotificationCatchup({ sendRequest } as never, 'host-a', () => false)
+ expect(sendRequest).not.toHaveBeenCalled()
+})
+
+it('pages individual tray identities without requesting historical alerts', async () => {
const all = Array.from({ length: 288 }, (_, index) => ({
hostFingerprint,
notificationId: `paged-${index}`,
@@ -94,13 +98,11 @@ it('pages individual tray identities without replaying history twice', async ()
ok: true,
result: { notifications: [], dismissedPushes: params.deliveredPushes ?? [] }
}))
- await requestNotificationCatchup(
- { sendRequest } as never,
- 'host-a',
- { lastSeenSeq: 4 },
- () => false
- )
+ await requestNotificationCatchup({ sendRequest } as never, 'host-a', () => false)
expect(sendRequest).toHaveBeenCalledTimes(2)
+ expect(sendRequest.mock.calls[0]?.[1]).toMatchObject({
+ lastSeenSeq: Number.MAX_SAFE_INTEGER
+ })
expect(sendRequest.mock.calls[0]?.[1].deliveredPushes).toHaveLength(256)
expect(sendRequest.mock.calls[1]?.[1]).toMatchObject({
lastSeenSeq: Number.MAX_SAFE_INTEGER,
@@ -114,3 +116,30 @@ it('pages individual tray identities without replaying history twice', async ()
})
expect(vi.mocked(Notifications.dismissNotificationAsync)).toHaveBeenCalledTimes(288)
})
+
+it.each(['failure', 'disconnect'])(
+ 'stops after a second-page %s without removing unconfirmed alerts',
+ async (outcome) => {
+ vi.mocked(Notifications.getPresentedNotificationsAsync).mockResolvedValue(
+ Array.from({ length: 513 }, (_, index) =>
+ presented(`paged-${index}`, { notificationId: `paged-${index}`, notificationSeq: index })
+ ) as never
+ )
+ let disposed = false
+ let pages = 0
+ const sendRequest = vi.fn(
+ async (_method: string, params: { deliveredPushes: (typeof id)[] }) => {
+ pages++
+ disposed = pages === 2 && outcome === 'disconnect'
+ return {
+ ok: !(pages === 2 && outcome === 'failure'),
+ result: { dismissedPushes: params.deliveredPushes }
+ }
+ }
+ )
+ await requestNotificationCatchup({ sendRequest } as never, 'host-a', () => disposed)
+ expect(sendRequest).toHaveBeenCalledTimes(2)
+ expect(Notifications.dismissNotificationAsync).toHaveBeenCalledTimes(256)
+ expect(Notifications.dismissNotificationAsync).not.toHaveBeenCalledWith('paged-256')
+ }
+)
diff --git a/mobile/src/notifications/push-dismissal-reconciliation.ts b/mobile/src/notifications/push-dismissal-reconciliation.ts
index 7269fdd7874..4c39ccfeb0f 100644
--- a/mobile/src/notifications/push-dismissal-reconciliation.ts
+++ b/mobile/src/notifications/push-dismissal-reconciliation.ts
@@ -42,35 +42,24 @@ async function readDelivered(hostId: string): Promise