Restore mobile push for delivery validation

This commit is contained in:
Jinwoo-H
2026-09-11 17:00:07 -04:00
parent 729491597f
commit d35c06d005
91 changed files with 5954 additions and 4279 deletions
+28
View File
@@ -0,0 +1,28 @@
// Why this file exists: a bare "expo-notifications" plugin entry writes
// `aps-environment: development` into the iOS entitlements, while push-token.ts
// reports `production` for every non-__DEV__ build. A TestFlight or App Store build
// would then register a production APNs token against a sandbox entitlement, and the
// gateway's pushes would be accepted by Apple and delivered nowhere. Deriving the
// mode from an env var the release workflow sets makes the two agree by construction
// instead of relying on the export step to rewrite the entitlement.
//
// app.json stays the source for everything else: Expo reads it first and hands it to
// this function, so the fastlane version/buildNumber rewrite still flows through.
const APS_ENVIRONMENT =
process.env.ORCA_IOS_APS_ENVIRONMENT === 'production' ? 'production' : 'development'
module.exports = ({ config }) => ({
...config,
plugins: (config.plugins ?? []).map((plugin) =>
plugin === 'expo-notifications'
? [
'expo-notifications',
{
enableBackgroundRemoteNotifications: true,
mode: APS_ENVIRONMENT,
icon: './assets/notification-icon.png'
}
]
: plugin
)
})
+3 -1
View File
@@ -75,10 +75,12 @@
"allowBackup": false,
"permissions": ["RECORD_AUDIO", "MODIFY_AUDIO_SETTINGS"],
"package": "com.stably.orca.mobile",
"versionCode": 16
"versionCode": 16,
"googleServicesFile": "./google-services.json"
},
"plugins": [
"expo-router",
"expo-notifications",
"./plugins/android-respect-rotation-lock.js",
[
"expo-splash-screen",
+45 -15
View File
@@ -1,6 +1,9 @@
import { registerPushDismissalTask } from '../src/notifications/push-background-dismissal'
import { readNativeNotificationData } from '../src/notifications/native-notification-data'
import { setNotificationViewingWorkspace } from '../src/notifications/notification-viewing-policy'
import { useCallback, useEffect, useRef } from 'react'
import { View, StyleSheet } from 'react-native'
import { Stack, useRouter } from 'expo-router'
import { Stack, useRouter, useGlobalSearchParams, usePathname } from 'expo-router'
import { StatusBar } from 'expo-status-bar'
import * as SplashScreen from 'expo-splash-screen'
import * as Notifications from 'expo-notifications'
@@ -10,6 +13,13 @@ import { OrcaLogo } from '../src/components/OrcaLogo'
import { RpcClientProvider } from '../src/transport/client-context'
import { getNotificationNavigationTarget } from '../src/notifications/notification-routing'
import { useOpenNotificationRoute } from '../src/notifications/use-open-notification-route'
import {
isRemotePushTrigger,
pushNotificationRouteData,
foregroundNotificationBehavior
} from '../src/notifications/push-receive'
import { startPushTokenSync } from '../src/notifications/push-registration'
import { ensureDesktopNotificationChannel } from '../src/notifications/desktop-notification-channel'
import { loadHostCatalog } from '../src/transport/host-store'
import { extractPairingCodeFromUrl } from '../src/transport/pairing'
import { recoverMobileRelayPairing } from '../src/transport/mobile-relay-pairing-recovery'
@@ -19,22 +29,29 @@ import { recoverMobileRelayPairing } from '../src/transport/mobile-relay-pairing
// between the native splash and the first React paint.
SplashScreen.preventAutoHideAsync()
// Why: without this, expo-notifications silently drops notifications when
// the app is in the foreground. Setting all three to true makes iOS/Android
// display the banner, play the sound, and show the badge even while the
// app is active. This runs once at module load time before any notification
// is scheduled.
// Why at boot and not only on subscribe: the gateway's FCM payload targets the
// 'orca-desktop' channel, and a background push can land before any socket has
// connected. Android drops a notification whose channel does not exist yet.
void ensureDesktopNotificationChannel().catch(() => {})
void registerPushDismissalTask().catch(() => {})
// Register before scheduling so foreground delivery uses the same suppression policy.
Notifications.setNotificationHandler({
handleNotification: async () => ({
shouldShowBanner: true,
shouldShowList: true,
shouldPlaySound: true,
shouldSetBadge: false
})
handleNotification: foregroundNotificationBehavior
})
export default function RootLayout() {
const router = useRouter()
const pathname = usePathname()
const { hostId, worktreeId } = useGlobalSearchParams<{ hostId?: string; worktreeId?: string }>()
useEffect(() => {
setNotificationViewingWorkspace(
pathname.includes('/session/') && typeof hostId === 'string' && typeof worktreeId === 'string'
? { hostId, worktreeId }
: null
)
return () => setNotificationViewingWorkspace(null)
}, [pathname, hostId, worktreeId])
const openNotificationRoute = useOpenNotificationRoute()
const handledNotificationIdsRef = useRef<Set<string>>(new Set())
@@ -44,6 +61,10 @@ export default function RootLayout() {
void recoverMobileRelayPairing()
}, [])
// Why: a rolled APNs/FCM token stops delivering silently, so every paired host
// has to be re-registered with the new one as soon as the provider hands it over.
useEffect(() => startPushTokenSync(), [])
// Why: route `orca://pair?...` deep links to the confirm screen so
// the same pairing flow runs whether the link arrived via QR scan,
// paste, AirDrop, Messages, or `xcrun simctl openurl`. getInitialURL
@@ -94,9 +115,18 @@ export default function RootLayout() {
}
}
async function getNavigationTarget(data: unknown) {
async function getNavigationTarget(notification: Notifications.Notification) {
const hosts = await loadHostCatalog().catch(() => null)
return getNotificationNavigationTarget(data, {
const data = readNativeNotificationData(notification.request)
// A gateway push names its host by key fingerprint, not by this device's hostId.
// With no catalog to resolve against, such a push stays unrouted instead of
// falling back to whatever hostId its raw data carries.
const routeData = pushNotificationRouteData(
data,
hosts ?? [],
isRemotePushTrigger(notification.request.trigger)
)
return getNotificationNavigationTarget(routeData, {
knownHostIds: hosts ? new Set(hosts.map((host) => host.id)) : undefined,
credentialStatusByHostId: hosts
? new Map(hosts.map((host) => [host.id, host.credentialStatus]))
@@ -124,7 +154,7 @@ export default function RootLayout() {
}
}
const target = await getNavigationTarget(response.notification.request.content.data)
const target = await getNavigationTarget(response.notification)
clearLastNotificationResponse()
if (disposed) {
return
+2 -2
View File
@@ -22,7 +22,7 @@ import {
saveDefaultSessionView,
type MobileSessionView
} from '../src/storage/session-view-preferences'
import { savePushNotificationsEnabled } from '../src/storage/preferences'
import { setRemotePushEnabled } from '../src/notifications/push-registration'
const SLIDE_DURATION_MS = 280
@@ -127,7 +127,7 @@ function MobileOnboardingFlow({
setError(null)
try {
const enabled = choice === 'enable' ? await ensureNotificationPermissions() : false
await savePushNotificationsEnabled(enabled)
await setRemotePushEnabled(enabled)
advanceOrContinue()
} catch {
setError('Notification settings could not be updated. Try again.')
+5 -1
View File
@@ -1,3 +1,4 @@
import { NativeNotificationDeliverySettings } from '../src/settings/native-notification-delivery-settings'
import { useRouter } from 'expo-router'
import NotificationsScreen from '../src/settings/notification-settings-screen'
import { nativeNotificationSettingsOperations } from '../src/settings/native-notification-settings-operations'
@@ -7,6 +8,9 @@ export default function NativeNotificationsRoute() {
<NotificationsScreen
operations={nativeNotificationSettingsOperations}
onBack={() => router.back()}
/>
description="Get agent alerts even when the app is closed. Delivered through Orca’s push service and Apple or Google."
>
{(enabled) => <NativeNotificationDeliverySettings enabled={enabled} />}
</NotificationsScreen>
)
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 625 B

+39
View File
@@ -0,0 +1,39 @@
{
"project_info": {
"project_number": "120364513935",
"project_id": "onorca-cloud",
"storage_bucket": "onorca-cloud.firebasestorage.app"
},
"client": [
{
"client_info": {
"mobilesdk_app_id": "1:120364513935:android:1d951dc430aeb9bc664efa",
"android_client_info": {
"package_name": "com.stably.orca.mobile"
}
},
"oauth_client": [
{
"client_id": "120364513935-evfa8502bp5r9hn7afhd9i03oibs8223.apps.googleusercontent.com",
"client_type": 3
}
],
"api_key": [
{
"current_key": "AIzaSyBmT_w0OUQSiVfxblx-F0qlRvGkBBkTNQU"
}
],
"services": {
"appinvite_service": {
"other_platform_oauth_client": [
{
"client_id": "120364513935-evfa8502bp5r9hn7afhd9i03oibs8223.apps.googleusercontent.com",
"client_type": 3
}
]
}
}
}
],
"configuration_version": "1"
}
@@ -0,0 +1,7 @@
{
"platforms": ["apple"],
"apple": {
"modules": ["OrcaNotificationDismissalModule"],
"appDelegateSubscribers": ["OrcaNotificationDismissalSubscriber"]
}
}
@@ -0,0 +1,15 @@
Pod::Spec.new do |s|
s.name = 'OrcaNotificationDismissal'
s.version = '0.0.1'
s.summary = 'Native notification dismissal and sequence fencing'
s.description = s.summary
s.license = { :type => 'MIT' }
s.author = 'Orca'
s.homepage = 'https://onorca.dev'
s.source = { :git => 'https://github.com/stablyai/orca.git' }
s.platforms = { :ios => '15.1' }
s.swift_version = '5.9'
s.static_framework = true
s.dependency 'ExpoModulesCore'
s.source_files = '**/*.swift'
end
@@ -0,0 +1,14 @@
import ExpoModulesCore
public class OrcaNotificationDismissalModule: Module {
public func definition() -> ModuleDefinition {
Name("OrcaNotificationDismissal")
AsyncFunction("remember") { (payload: [String: Any]) in
if let identity = PushDismissalIdentity(payload) { PushDismissalLedger.shared.remember(identity) }
}
AsyncFunction("wasDismissed") { (payload: [String: Any]) -> Bool in
guard let identity = PushDismissalIdentity(payload) else { return false }
return PushDismissalLedger.shared.contains(identity)
}
}
}
@@ -0,0 +1,26 @@
import ExpoModulesCore
import UserNotifications
public class OrcaNotificationDismissalSubscriber: ExpoAppDelegateSubscriber {
public func application(
_ application: UIApplication,
didReceiveRemoteNotification userInfo: [AnyHashable: Any],
fetchCompletionHandler completionHandler: @escaping (UIBackgroundFetchResult) -> Void
) {
guard let payload = userInfo["orca"] as? [String: Any],
payload["kind"] as? String == "dismiss", let fence = PushDismissalIdentity(payload)
else { completionHandler(.noData); return }
PushDismissalLedger.shared.remember(fence)
let center = UNUserNotificationCenter.current()
center.getDeliveredNotifications { notifications in
let ids = notifications.compactMap { notification -> String? in
guard let data = notification.request.content.userInfo["orca"] as? [String: Any],
data["hostFingerprint"] as? String == fence.hostFingerprint,
PushDismissalLedger.shared.containsNotification(data) else { return nil }
return notification.request.identifier
}
center.removeDeliveredNotifications(withIdentifiers: ids)
completionHandler(ids.isEmpty ? .noData : .newData)
}
}
}
@@ -0,0 +1,67 @@
import Foundation
import CoreFoundation
struct PushDismissalIdentity: Codable {
let hostFingerprint: String
let notificationId: String
let notificationEpoch: String
let notificationSeq: Int64
init?(_ value: [String: Any]) {
guard let host = value["hostFingerprint"] as? String, !host.isEmpty, host.count <= 512,
let id = value["notificationId"] as? String, !id.isEmpty, id.count <= 2048,
let epoch = value["notificationEpoch"] as? String, !epoch.isEmpty, epoch.count <= 128,
let seq = value["notificationSeq"] as? NSNumber,
CFGetTypeID(seq) != CFBooleanGetTypeID(), seq.doubleValue.isFinite,
seq.doubleValue >= 0, seq.doubleValue <= 9_007_199_254_740_991,
seq.doubleValue.rounded(.down) == seq.doubleValue else { return nil }
hostFingerprint = host; notificationId = id; notificationEpoch = epoch
notificationSeq = seq.int64Value
}
func matches(_ other: PushDismissalIdentity) -> Bool {
hostFingerprint == other.hostFingerprint && notificationId == other.notificationId &&
notificationEpoch == other.notificationEpoch
}
}
final class PushDismissalLedger {
static let shared = PushDismissalLedger()
private struct Entry: Codable { let identity: PushDismissalIdentity; let expiresAt: TimeInterval }
private let defaults: UserDefaults
private let lock = NSLock()
private let storageKey = "orca.pushDismissals.v1"
init(defaults: UserDefaults = .standard) { self.defaults = defaults }
private func read(now: TimeInterval) -> [Entry] {
guard let data = defaults.data(forKey: storageKey),
let entries = try? JSONDecoder().decode([Entry].self, from: data) else { return [] }
return entries.filter { $0.expiresAt > now }
}
func remember(_ identity: PushDismissalIdentity, now: TimeInterval = Date().timeIntervalSince1970) {
lock.lock(); defer { lock.unlock() }
let entries = read(now: now)
let previous = entries.first { $0.identity.matches(identity) }
let newest = (previous?.identity.notificationSeq ?? -1) > identity.notificationSeq
? previous!.identity : identity
// Keep every live fence: count-based eviction lets delayed alerts reappear.
let next = entries.filter { !$0.identity.matches(identity) } +
[Entry(identity: newest, expiresAt: now + 86400)]
if let data = try? JSONEncoder().encode(next) {
defaults.set(data, forKey: storageKey)
}
}
func contains(_ identity: PushDismissalIdentity, now: TimeInterval = Date().timeIntervalSince1970) -> Bool {
lock.lock(); defer { lock.unlock() }
return read(now: now).contains {
$0.identity.matches(identity) && $0.identity.notificationSeq >= identity.notificationSeq
}
}
func containsNotification(_ payload: [String: Any], now: TimeInterval = Date().timeIntervalSince1970) -> Bool {
guard let identity = PushDismissalIdentity(payload) else { return false }
return contains(identity, now: now)
}
}
@@ -0,0 +1,5 @@
{
"name": "orca-notification-dismissal",
"version": "0.0.1",
"private": true
}
@@ -0,0 +1,44 @@
import Foundation
@main struct PushDismissalLedgerChecks {
static func main() {
let suite = "orca.qa.dismissal." + UUID().uuidString
let defaults = UserDefaults(suiteName: suite)!
defer { defaults.removePersistentDomain(forName: suite) }
func payload(_ seq: Int, _ host: String = "qa-host", _ epoch: String = "qa-epoch", _ id: String = "qa-alert") -> [String: Any] {
["hostFingerprint": host, "notificationId": id, "notificationEpoch": epoch, "notificationSeq": seq]
}
func identity(_ seq: Int, _ host: String = "qa-host", _ epoch: String = "qa-epoch", _ id: String = "qa-alert") -> PushDismissalIdentity {
PushDismissalIdentity(payload(seq, host, epoch, id))!
}
let ledger = PushDismissalLedger(defaults: defaults)
ledger.remember(identity(2), now: 100)
ledger.remember(identity(1), now: 101)
let restored = PushDismissalLedger(defaults: defaults)
precondition(restored.contains(identity(1), now: 102))
precondition(restored.contains(identity(2), now: 102))
precondition(!restored.contains(identity(3), now: 102))
precondition(!restored.contains(identity(1, "other"), now: 102))
precondition(!restored.contains(identity(1, "qa-host", "other"), now: 102))
precondition(!restored.contains(identity(1, "qa-host", "qa-epoch", "other"), now: 102))
precondition(!restored.contains(identity(1), now: 86501))
precondition(PushDismissalIdentity(["hostFingerprint":"h", "notificationId":"n", "notificationEpoch":"e", "notificationSeq":true]) == nil)
precondition(restored.containsNotification(payload(1), now: 102))
precondition(!restored.containsNotification(payload(3), now: 102))
precondition(!restored.containsNotification(payload(1, "other"), now: 102))
precondition(!restored.containsNotification(payload(1, "qa-host", "other"), now: 102))
precondition(!restored.containsNotification(payload(1, "qa-host", "qa-epoch", "other"), now: 102))
precondition(!restored.containsNotification(["hostFingerprint": "qa-host"], now: 102))
for hosts in [1, 3] {
for index in 0..<520 {
ledger.remember(identity(2, "host-\(index % hosts)", "epoch-\(hosts)", "note-\(index)"), now: 200)
}
let reopened = PushDismissalLedger(defaults: defaults)
for index in [0, 1, 519] {
precondition(reopened.contains(identity(2, "host-\(index % hosts)", "epoch-\(hosts)", "note-\(index)"), now: 201))
precondition(!reopened.contains(identity(3, "host-\(index % hosts)", "epoch-\(hosts)", "note-\(index)"), now: 201))
precondition(!reopened.contains(identity(2, "host-\(index % hosts)", "epoch-\(hosts)", "note-\(index)"), now: 86600))
}
}
print("Native persisted fence: restart, ordering, identity isolation, expiry and invalid sequence checks passed")
}
}
+1
View File
@@ -46,6 +46,7 @@
"expo-secure-store": "^55.0.18",
"expo-splash-screen": "^55.0.25",
"expo-status-bar": "^55.0.6",
"expo-task-manager": "~55.0.20",
"lowlight": "^3.3.0",
"lucide-react-native": "^1.14.0",
"mermaid": "11.17.2",
@@ -0,0 +1,36 @@
diff --git a/build/getDevicePushTokenAsync.js b/build/getDevicePushTokenAsync.js
index f0875c5eaa84d45d646edd1ad5f21a962f68ab02..d93813b2636f1ac5e09081c3207407410a404698 100644
--- a/build/getDevicePushTokenAsync.js
+++ b/build/getDevicePushTokenAsync.js
@@ -20,8 +20,11 @@ export async function getDevicePushTokenAsync() {
else {
// Create a new Promise and clear it afterwards
nativeTokenPromise = PushTokenManager.getDevicePushTokenAsync();
- devicePushToken = await nativeTokenPromise;
- nativeTokenPromise = null;
+ try {
+ devicePushToken = await nativeTokenPromise;
+ } finally {
+ nativeTokenPromise = null;
+ }
}
// @ts-ignore: TS thinks Platform.OS could be anything and can't decide what type is it
return { type: Platform.OS, data: devicePushToken };
diff --git a/src/getDevicePushTokenAsync.ts b/src/getDevicePushTokenAsync.ts
index ab518dff463bc1a92052329ce5ac7c9055cbcf62..ad164f162998b5c1e218f089be82ab474727d68e 100644
--- a/src/getDevicePushTokenAsync.ts
+++ b/src/getDevicePushTokenAsync.ts
@@ -24,8 +24,11 @@ export async function getDevicePushTokenAsync(): Promise<DevicePushToken> {
} else {
// Create a new Promise and clear it afterwards
nativeTokenPromise = PushTokenManager.getDevicePushTokenAsync();
- devicePushToken = await nativeTokenPromise;
- nativeTokenPromise = null;
+ try {
+ devicePushToken = await nativeTokenPromise;
+ } finally {
+ nativeTokenPromise = null;
+ }
}
// @ts-ignore: TS thinks Platform.OS could be anything and can't decide what type is it
+1223 -1225
View File
File diff suppressed because it is too large Load Diff
+1
View File
@@ -7,5 +7,6 @@ overrides:
xcode>uuid: 11.1.1
patchedDependencies:
expo-notifications@55.0.27: patches/expo-notifications@55.0.27.patch
react-native-webview@13.16.2: patches/react-native-webview@13.16.2.patch
react-native@0.83.10: patches/react-native@0.83.10.patch
@@ -1,6 +1,7 @@
import { useEffect, useMemo, useRef, useState } from 'react'
import { decodeAccountsSnapshot } from '../components/AccountUsage'
import { subscribeToDesktopNotifications } from '../notifications/mobile-notifications'
import { attachPushRegistration } from '../notifications/push-registration'
import { usePrimeHosts } from '../transport/client-context'
import { createHostConnectRefetchGate } from '../transport/host-connect-refetch-gate'
import { selectHomeAutoConnectHostIds } from '../transport/home-host-auto-connect'
@@ -37,11 +38,15 @@ function wireMobileHomeHostSubscriptions(
): () => void {
let unsubscribeNotifications: (() => void) | null = null
let unsubscribeAccounts: (() => void) | null = null
let detachPushRegistration: (() => void) | null = null
const refetchGate = createHostConnectRefetchGate()
const wireState = (state: ConnectionState): void => {
const reconnected = refetchGate.observe(state)
if (state === 'connected') {
unsubscribeNotifications ??= subscribeToDesktopNotifications(entry.client, entry.hostId)
// Why here: this is the one place a host is known to be authenticated, which is
// what registerPush needs; it no-ops on hosts without the push capability.
detachPushRegistration ??= attachPushRegistration(entry.hostId, entry.client)
unsubscribeAccounts ??= entry.client.subscribe('accounts.subscribe', null, (payload) => {
if (!payload || typeof payload !== 'object') {
return
@@ -78,6 +83,8 @@ function wireMobileHomeHostSubscriptions(
unsubscribeNotifications = null
unsubscribeAccounts?.()
unsubscribeAccounts = null
detachPushRegistration?.()
detachPushRegistration = null
}
wireState(entry.state)
const unsubscribeState = entry.client.onStateChange(wireState)
@@ -85,6 +92,7 @@ function wireMobileHomeHostSubscriptions(
unsubscribeState()
unsubscribeNotifications?.()
unsubscribeAccounts?.()
detachPushRegistration?.()
}
}
@@ -0,0 +1,37 @@
import { createElement } from 'react'
import { act, create } from 'react-test-renderer'
import { expect, it, vi } from 'vitest'
import { NotificationDeliverySection } from './NotificationDeliverySection'
import { DEFAULT_NOTIFICATION_DELIVERY } from './notification-delivery-preferences'
vi.mock('@react-native-async-storage/async-storage', () => ({ default: {} }))
vi.mock('react-native', () => ({
StyleSheet: { create: (value: unknown) => value },
View: 'View',
Text: 'Text',
Switch: 'Switch'
}))
it('shows only phone-specific controls while desktop owns category eligibility', () => {
const onChange = vi.fn()
let renderer: ReturnType<typeof create>
act(() => {
renderer = create(
createElement(NotificationDeliverySection, { value: DEFAULT_NOTIFICATION_DELIVERY, onChange })
)
})
const switches = () => renderer.root.findAllByType('Switch' as never)
expect(switches().map((node) => node.props.accessibilityLabel)).toEqual([
'Only when away from desktop',
'Notification sound',
'Suppress while focused'
])
expect(JSON.stringify(renderer.toJSON())).toContain(
'Alert types follow each paired desktop’s notification settings.'
)
act(() => switches()[0].props.onValueChange(false))
expect(onChange).toHaveBeenLastCalledWith(
expect.objectContaining({ onlyWhenDesktopAway: false, sound: true, suppressWhileViewing: true })
)
act(() => renderer.unmount())
})
@@ -0,0 +1,72 @@
import { StyleSheet, Switch, Text, View } from 'react-native'
import { colors, radii, spacing, typography } from '../theme/mobile-theme'
import type { NotificationDeliveryPreferences } from './notification-delivery-preferences'
type Props = {
value: NotificationDeliveryPreferences
disabled?: boolean
onChange: (value: NotificationDeliveryPreferences) => void
}
export function NotificationDeliverySection({ value, disabled, onChange }: Props) {
const row = (key: keyof NotificationDeliveryPreferences, label: string, hint?: string) => {
return (
<View key={key} style={[styles.row, disabled && styles.disabled]}>
<View style={styles.labelGroup}>
<Text style={styles.label}>{label}</Text>
{hint && <Text style={styles.hint}>{hint}</Text>}
</View>
<Switch
accessibilityLabel={label}
testID={`notification-${key}`}
value={value[key]}
disabled={disabled}
onValueChange={(enabled) => onChange({ ...value, [key]: enabled })}
trackColor={{ false: colors.bgRaised, true: colors.textSecondary }}
thumbColor={colors.textPrimary}
/>
</View>
)
}
return (
<>
<View style={styles.section}>
{row(
'onlyWhenDesktopAway',
'Only when away from desktop',
'After 3 minutes without keyboard or mouse activity, or when locked.'
)}
{row('sound', 'Notification sound')}
{row(
'suppressWhileViewing',
'Suppress while focused',
'Skip alerts for the workspace open on this phone.'
)}
</View>
<Text style={styles.footer}>
Alert types follow each paired desktop’s notification settings. Notifications pause after 7
days without using this app; open it and reconnect to resume.
</Text>
</>
)
}
const styles = StyleSheet.create({
section: {
backgroundColor: colors.bgPanel,
borderRadius: radii.card,
overflow: 'hidden',
marginTop: spacing.md
},
row: { flexDirection: 'row', alignItems: 'center', gap: spacing.sm, padding: spacing.md },
labelGroup: { flex: 1, gap: spacing.xs },
label: { fontSize: typography.bodySize, fontWeight: '500', color: colors.textPrimary },
hint: { fontSize: typography.metaSize, color: colors.textMuted },
disabled: { opacity: 0.5 },
footer: {
fontSize: typography.metaSize,
color: colors.textMuted,
marginTop: spacing.md,
paddingHorizontal: spacing.sm
}
})
@@ -0,0 +1,62 @@
import { readFileSync } from 'node:fs'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import * as Notifications from 'expo-notifications'
import { Platform } from 'react-native'
import {
DESKTOP_NOTIFICATION_CHANNEL_ID,
ensureDesktopNotificationChannel
} from './desktop-notification-channel'
vi.mock('expo-notifications', () => ({
AndroidImportance: { HIGH: 'high' },
setNotificationChannelAsync: vi.fn()
}))
vi.mock('react-native', () => ({
AppState: { currentState: 'background' },
Platform: { OS: 'android' }
}))
beforeEach(() => {
vi.clearAllMocks()
Object.assign(Platform, { OS: 'android' })
vi.mocked(Notifications.setNotificationChannelAsync).mockResolvedValue(null as never)
})
describe('ensureDesktopNotificationChannel', () => {
it('creates the channel the gateway payload names', async () => {
await ensureDesktopNotificationChannel()
expect(Notifications.setNotificationChannelAsync).toHaveBeenCalledWith(
'orca-desktop',
expect.objectContaining({ importance: 'high' })
)
expect(DESKTOP_NOTIFICATION_CHANNEL_ID).toBe('orca-desktop')
})
it('does nothing on iOS, which has no notification channels', () => {
Object.assign(Platform, { OS: 'ios' })
ensureDesktopNotificationChannel()
expect(Notifications.setNotificationChannelAsync).not.toHaveBeenCalled()
})
it('reports channel failure so registration can retry', async () => {
vi.mocked(Notifications.setNotificationChannelAsync).mockRejectedValue(new Error('no channels'))
await expect(ensureDesktopNotificationChannel()).rejects.toThrow('no channels')
})
})
describe('app boot', () => {
it('creates the channel at startup, not only once a socket subscribes', () => {
// A background push can be the first thing to target 'orca-desktop', and Android
// drops a notification whose channel does not exist. Asserted against the source
// because vitest only collects src/, so app/_layout.tsx has no runtime coverage.
const layout = readFileSync(new URL('../../app/_layout.tsx', import.meta.url), 'utf8')
expect(layout).toContain("from '../src/notifications/desktop-notification-channel'")
expect(layout).toMatch(/^void ensureDesktopNotificationChannel\(\)\.catch\(/m)
})
})
@@ -0,0 +1,27 @@
import * as Notifications from 'expo-notifications'
import { Platform } from 'react-native'
// Why an id both sides share: the gateway's FCM payload names this channel, so a
// background push can be the first thing that ever targets it. Android drops a
// notification whose channel does not exist, and the channel used to be created
// only inside subscribeToDesktopNotifications — i.e. only once a socket connected.
export const DESKTOP_NOTIFICATION_CHANNEL_ID = 'orca-desktop'
/** Idempotent on Android (the OS updates the existing channel); a no-op elsewhere. */
export async function ensureDesktopNotificationChannel(): Promise<void> {
if (Platform.OS !== 'android') {
return
}
await Notifications.setNotificationChannelAsync(`${DESKTOP_NOTIFICATION_CHANNEL_ID}-silent`, {
name: 'Orca silent notifications',
importance: Notifications.AndroidImportance.HIGH,
sound: null,
enableVibrate: false
})
await Notifications.setNotificationChannelAsync(DESKTOP_NOTIFICATION_CHANNEL_ID, {
name: 'Desktop Notifications',
importance: Notifications.AndroidImportance.HIGH,
vibrationPattern: [0, 250],
lightColor: '#6366f1'
})
}
@@ -0,0 +1,6 @@
export type DismissNotificationEvent = {
type: 'dismiss'
notificationId: string
notificationSeq?: number
notificationEpoch?: string
}
@@ -0,0 +1,40 @@
import { beforeEach, expect, it, vi } from 'vitest'
const native = vi.hoisted(() => vi.fn())
vi.mock('expo-modules-core', () => ({ Platform: { OS: 'ios' }, UnavailabilityError: Error }))
vi.mock('expo-notifications/build/PushTokenManager', () => ({
default: { getDevicePushTokenAsync: native }
}))
vi.mock('expo-notifications/build/warnOfExpoGoPushUsage', () => ({
warnOfExpoGoPushUsage: () => {}
}))
beforeEach(() => {
vi.resetModules()
native.mockReset()
})
it('releases a failed Expo native-token request so the next attempt can succeed', async () => {
const { getDevicePushTokenAsync } =
await import('expo-notifications/build/getDevicePushTokenAsync')
native.mockRejectedValueOnce(new Error('APNs unavailable')).mockResolvedValueOnce('device-token')
await expect(getDevicePushTokenAsync()).rejects.toThrow('APNs unavailable')
await expect(getDevicePushTokenAsync()).resolves.toEqual({ type: 'ios', data: 'device-token' })
expect(native).toHaveBeenCalledTimes(2)
})
it('still shares one pending native request between concurrent callers', async () => {
const { getDevicePushTokenAsync } =
await import('expo-notifications/build/getDevicePushTokenAsync')
let resolve!: (token: string) => void
native.mockReturnValue(
new Promise<string>((done) => {
resolve = done
})
)
const first = getDevicePushTokenAsync()
const second = getDevicePushTokenAsync()
expect(native).toHaveBeenCalledOnce()
resolve('device-token')
expect(await first).toEqual(await second)
})
@@ -1,191 +0,0 @@
import * as Notifications from 'expo-notifications'
import { Platform } from 'react-native'
import { loadPushNotificationsEnabled } from '../storage/preferences'
import { buildLocalNotificationData, type DesktopNotificationSource } from './notification-routing'
import { ensureNotificationPermissions } from './notification-permissions'
export type NotificationEvent = {
type: 'notification'
source: DesktopNotificationSource
title: string
body: string
worktreeId?: string
notificationId?: string
// Desktop-assigned seq for reconnect catch-up (#8129); optional since older runtimes may omit it.
notificationSeq?: number
// Counter lifetime the seq belongs to (#8591); absent on older runtimes.
notificationEpoch?: string
}
export type DismissNotificationEvent = {
type: 'dismiss'
notificationId: string
notificationSeq?: number
notificationEpoch?: string
}
type ScheduledNotificationState = {
identifier?: string
pending?: Promise<string | null>
dismissAfterSchedule?: boolean
}
const scheduledNotificationsByHostAndNotificationId = new Map<string, ScheduledNotificationState>()
// Why: keys never repeat and are only freed on desktop dismiss (which remote users often miss), so bound the map to stop unbounded growth.
const MAX_SCHEDULED_NOTIFICATIONS = 256
let maxScheduledNotifications = MAX_SCHEDULED_NOTIFICATIONS
function getStoredNotificationKey(hostId: string, notificationId: string): string {
return `${encodeURIComponent(hostId)}:${encodeURIComponent(notificationId)}`
}
// Evict oldest settled entries (never mid-schedule); Map iteration is insertion order so the first match is oldest.
function boundScheduledNotifications(): void {
while (scheduledNotificationsByHostAndNotificationId.size > maxScheduledNotifications) {
let evicted = false
for (const [key, state] of scheduledNotificationsByHostAndNotificationId) {
if (!state.pending) {
scheduledNotificationsByHostAndNotificationId.delete(key)
evicted = true
break
}
}
if (!evicted) {
break
}
}
}
/** Test-only: override the cap (pass no arg to restore the default). */
export function setScheduledNotificationsMaxForTests(max?: number): void {
maxScheduledNotifications = max ?? MAX_SCHEDULED_NOTIFICATIONS
}
export function configureNotificationChannel(): void {
if (Platform.OS === 'android') {
void Notifications.setNotificationChannelAsync('orca-desktop', {
name: 'Desktop Notifications',
importance: Notifications.AndroidImportance.HIGH,
vibrationPattern: [0, 250],
lightColor: '#6366f1'
})
}
}
export async function showLocalNotification(
event: NotificationEvent,
hostId: string
): Promise<void> {
const storedKey = event.notificationId
? getStoredNotificationKey(hostId, event.notificationId)
: null
if (!storedKey) {
const enabled = await loadPushNotificationsEnabled()
if (!enabled) {
return
}
const granted = await ensureNotificationPermissions()
if (!granted) {
return
}
await Notifications.scheduleNotificationAsync({
content: {
title: event.title,
body: event.body,
data: buildLocalNotificationData(event, hostId),
...(Platform.OS === 'android' ? { channelId: 'orca-desktop' } : {})
},
trigger: null
})
return
}
let state = scheduledNotificationsByHostAndNotificationId.get(storedKey)
if (state?.pending) {
return
}
if (!state) {
state = {}
scheduledNotificationsByHostAndNotificationId.set(storedKey, state)
}
const notificationState = state
const pending = (async () => {
const enabled = await loadPushNotificationsEnabled()
if (!enabled) {
return null
}
const granted = await ensureNotificationPermissions()
if (!granted) {
return null
}
if (notificationState.identifier) {
await Notifications.dismissNotificationAsync(notificationState.identifier).catch(() => {})
notificationState.identifier = undefined
}
return Notifications.scheduleNotificationAsync({
content: {
title: event.title,
body: event.body,
data: buildLocalNotificationData(event, hostId),
...(Platform.OS === 'android' ? { channelId: 'orca-desktop' } : {})
},
trigger: null
})
})()
notificationState.pending = pending
try {
const scheduledIdentifier = await pending
if (!scheduledIdentifier) {
if (!notificationState.identifier) {
scheduledNotificationsByHostAndNotificationId.delete(storedKey)
}
return
}
if (notificationState.dismissAfterSchedule) {
notificationState.dismissAfterSchedule = false
scheduledNotificationsByHostAndNotificationId.delete(storedKey)
await Notifications.dismissNotificationAsync(scheduledIdentifier).catch(() => {})
return
}
notificationState.identifier = scheduledIdentifier
boundScheduledNotifications()
} finally {
if (notificationState.pending === pending) {
notificationState.pending = undefined
notificationState.dismissAfterSchedule = false
}
}
}
export async function dismissLocalNotification(
event: DismissNotificationEvent,
hostId: string
): Promise<void> {
if (!event.notificationId) {
return
}
const storedKey = getStoredNotificationKey(hostId, event.notificationId)
const state = scheduledNotificationsByHostAndNotificationId.get(storedKey)
if (!state) {
return
}
if (state.pending) {
// Why: dismiss can arrive while the OS is still scheduling; defer it so no stale banner survives.
state.dismissAfterSchedule = true
return
}
if (!state.identifier) {
return
}
scheduledNotificationsByHostAndNotificationId.delete(storedKey)
await Notifications.dismissNotificationAsync(state.identifier).catch(() => {})
}
File diff suppressed because it is too large Load Diff
+12 -252
View File
@@ -1,211 +1,22 @@
import { requestNotificationCatchup } from './push-dismissal-reconciliation'
import { dismissHostPushNotification } from './push-socket-dismissal'
import type { DismissNotificationEvent } from './desktop-notification-events'
import type { RpcClient } from '../transport/rpc-client'
// Re-exported so the existing importers (and their vi.mock paths) keep working.
export {
ensureNotificationPermissions,
getNotificationPermissionState,
type NotificationPermissionState
} from './notification-permissions'
export { setScheduledNotificationsMaxForTests } from './local-notification-scheduling'
import {
configureNotificationChannel,
dismissLocalNotification,
showLocalNotification,
type DismissNotificationEvent,
type NotificationEvent
} from './local-notification-scheduling'
import {
adoptNotificationEpoch,
catchUpWatermarkSeq,
enqueueHostDelivery,
getHostNotificationSession,
quarantineCatchUpWatermark,
releaseQueuedShowNotificationId,
resolveCatchUpQuarantine,
saveWatermark,
seedWatermarkFromStorage,
seenKeyForEvent,
shouldQueueShowForNotificationId
} from './notification-reconnect-catchup'
type SubscribeResult = {
type: 'ready'
subscriptionId: string
// Desktop counter lifetime (#8591); absent from runtimes that predate it.
epoch?: string
}
// Per-connection subscription; a reconnect `ready` triggers watermarked catch-up (#8129) so already-pushed events aren't re-sent.
export function subscribeToDesktopNotifications(client: RpcClient, hostId: string): () => void {
configureNotificationChannel()
let subscriptionId: string | null = null
let disposed = false
// Why (#8591): survives the unsubscribe/resubscribe the app performs on every
// socket drop, so a reconnect still knows its watermark and that it reconnected.
const session = getHostNotificationSession(hostId)
/**
* Queue one delivery on the host chain, dropping a show whose notificationId
* already has one queued.
*
* Why the claim is taken HERE and not inside deliverLive (#8591): the point of
* the dedup is to notice a second event arriving while the first is still
* outstanding. Inside the queued task the first has already finished, so the
* overlap is no longer observable — it has to be checked before enqueueing.
*/
function queueDelivery(
type: 'notification' | 'dismiss',
event: NotificationEvent | DismissNotificationEvent
): Promise<void> {
if (
type === 'notification' &&
!shouldQueueShowForNotificationId(session, event.notificationId)
) {
return Promise.resolve()
}
return enqueueHostDelivery(session, async () => {
try {
await deliverLive(type, event)
} finally {
if (type === 'notification') {
releaseQueuedShowNotificationId(session, event.notificationId)
}
}
// Why swallowed: the caller is an un-awaited handler, so a rejected show would
// surface as an unhandled rejection (a RN redbox) instead of being retried by
// the next catch-up — which is now possible, since `seen` is marked after the show.
}).catch(() => {})
}
async function deliverLive(
type: 'notification' | 'dismiss',
event: NotificationEvent | DismissNotificationEvent
): Promise<void> {
adoptNotificationEpoch(session, hostId, event.notificationEpoch)
const epochAtDelivery = session.lastDeliveredEpoch
if (type === 'notification') {
await showLocalNotification(event as NotificationEvent, hostId)
} else {
await dismissLocalNotification(event as DismissNotificationEvent, hostId)
}
// Why after the await, exactly like the watermark below: `seen` asserts this event
// reached the user (#8129). Marked before, a rejected show leaves the key behind and
// every later replay is dropped as a duplicate — loss the quarantine cannot recover,
// since the first event to drain a batch lifts it past the one never shown.
const key = seenKeyForEvent(event)
// A mid-flight epoch adoption already cleared the counter lifetime this key indexes.
if (key && session.lastDeliveredEpoch === epochAtDelivery) {
session.seen.add(key)
}
// Why after the await (#8591): the watermark is a promise that everything up
// to this seq has been shown. Advancing it before the local notification lands
// means a process death in between silently drops it — the next launch asks the
// desktop for seq greater than one the user never saw.
if (event.notificationSeq != null && event.notificationSeq > session.lastDeliveredSeq) {
session.lastDeliveredSeq = event.notificationSeq
// Why clamped: while a failed catch-up's range is still unrecovered, persisting
// the live seq would let the next catch-up ask from above the gap and the desktop
// would cut it. resolveCatchUpQuarantine writes the held-back value on success.
void saveWatermark(hostId, {
seq: catchUpWatermarkSeq(session),
epoch: session.lastDeliveredEpoch
})
}
}
// Claimed inline rather than via queueDelivery: the batch is already one queue
// entry, and re-enqueueing per item is what let a live event cut in.
async function deliverMissedEvent(
event: NotificationEvent | DismissNotificationEvent
): Promise<void> {
// No pre-marking here either: deliverLive marks the key once the show lands.
const key = seenKeyForEvent(event)
if (key && session.seen.has(key)) {
return
}
if (event.type === 'notification') {
if (!shouldQueueShowForNotificationId(session, event.notificationId)) {
return
}
try {
await deliverLive('notification', event)
} finally {
releaseQueuedShowNotificationId(session, event.notificationId)
}
return
}
if (event.type === 'dismiss') {
await deliverLive('dismiss', event)
}
}
// Why: desktop cuts by seq > lastSeenSeq, so re-fetching from the watermark is idempotent (session.seen guards residual overlap).
async function fetchMissed(): Promise<void> {
if (disposed) {
return
}
// Captured before the request: everything at or below it is known delivered, so
// it is the floor the watermark falls back to if this catch-up never completes.
const askFrom = catchUpWatermarkSeq(session)
const missed = await client
.sendRequest('notifications.getMissedSince', {
lastSeenSeq: askFrom,
// Why: sending the epoch lets the desktop reject a watermark from a counter
// it no longer has and return the whole retained buffer instead of nothing.
...(session.lastDeliveredEpoch != null ? { epoch: session.lastDeliveredEpoch } : {})
})
.then((response) => {
if (!response.ok) {
return null
}
const result = response.result as { notifications?: unknown[]; epoch?: string } | undefined
adoptNotificationEpoch(session, hostId, result?.epoch)
return Array.isArray(result?.notifications) ? result.notifications : []
})
.catch(() => null)
if (missed == null) {
// Why quarantine rather than retry: the range this catch-up abandoned stays
// unrecovered until SOME later one succeeds, and a live seq persisting past it
// meanwhile would make the desktop cut it forever.
quarantineCatchUpWatermark(session, hostId, askFrom)
return
}
// Why the whole batch is ONE queue entry (#8591): awaiting per event returns to
// the event loop between replays, so a live seq 11 slots into the chain between
// seq 6 and 7 and persists a watermark past a notification still unshown. Why the
// request stays OUTSIDE the queue: sendRequest waits up to 30s, and holding the
// chain for that would stall live delivery on a slow link.
await enqueueHostDelivery(session, async () => {
// Advances only past events this batch settled, so a teardown or a failing show
// quarantines the true contiguous point instead of the range it never reached.
let contiguousSeq = askFrom
let drained = false
try {
for (const raw of missed) {
// Re-checked per event: the batch can start before a teardown and still be
// draining after it, and a torn-down host must stop pushing.
if (disposed) {
return
}
const event = raw as NotificationEvent | DismissNotificationEvent
await deliverMissedEvent(event)
contiguousSeq = event.notificationSeq ?? contiguousSeq
}
drained = true
} finally {
if (drained) {
resolveCatchUpQuarantine(session, hostId)
} else {
quarantineCatchUpWatermark(session, hostId, contiguousSeq)
}
}
// Why swallowed here: the `finally` above already recorded the contiguous point,
// and the only caller is an un-awaited 'ready' continuation — letting a failed
// show escape turns every one into an unhandled rejection (a RN redbox).
}).catch(() => {})
}
seedWatermarkFromStorage(session, hostId)
function unsubscribeServer(id: string) {
if (client.getState() === 'connected') {
@@ -213,79 +24,28 @@ export function subscribeToDesktopNotifications(client: RpcClient, hostId: strin
}
}
const unsubscribeStream = client.subscribe('notifications.subscribe', {}, (data: unknown) => {
const event = data as
| NotificationEvent
| DismissNotificationEvent
| SubscribeResult
| { type: 'end' }
const params = { includeDesktopSuppressed: true }
const unsubscribeStream = client.subscribe('notifications.subscribe', params, (data: unknown) => {
const event = data as DismissNotificationEvent | SubscribeResult | { type: string }
if (event.type === 'ready') {
subscriptionId = (event as SubscribeResult).subscriptionId
const isReconnect = session.connectedBefore
session.connectedBefore = true
if (disposed) {
unsubscribeServer(subscriptionId)
unsubscribeStream()
return
}
const readyEpoch = (event as SubscribeResult).epoch
// Why (#8591) the await: on a cold app open the persisted read is still in
// flight, so deciding here would see watermarkLoaded false and skip catch-up —
// which is precisely the post-upgrade / post-process-death case that loses
// every notification between the stored watermark and the next live seq.
void (async () => {
await session.watermarkSeeded
if (disposed) {
return
}
// Why before fetchMissed: adopting the epoch here is what voids a watermark
// left over from a previous desktop lifetime, so the catch-up request carries
// a watermark that means something against the counter now answering it.
adoptNotificationEpoch(session, hostId, readyEpoch)
// A reconnect always catches up. A cold open catches up only when this device
// has delivered for this host before — a first-ever pairing must not be handed
// the desktop's whole retained buffer.
if (isReconnect || session.hadStoredWatermark) {
await fetchMissed()
}
})()
// A max watermark asks only which delivered pushes are stale; socket history
// never becomes a second OS-notification delivery route.
void requestNotificationCatchup(client, hostId, () => disposed).catch(() => {})
return
}
if (event.type === 'end') {
if (disposed) {
unsubscribeStream()
}
return
if (!disposed && event.type === 'dismiss') {
void dismissHostPushNotification(event as DismissNotificationEvent, hostId).catch(() => {})
}
if (disposed) {
return
}
if (event.type !== 'notification' && event.type !== 'dismiss') {
return
}
// Why the await (#8591): deliverLive advances the watermark. A live event landing
// while the persisted read is still in flight would push it past the buffered seqs
// the catch-up is about to ask for, and getMissedSince would cut them. Ordering is
// preserved — every handler waits on the same promise, and the 'ready' continuation
// registered on it first, so catch-up still builds its request before any live seq.
const liveEvent = event
void (async () => {
await session.watermarkSeeded
if (disposed) {
return
}
// Why the queue (#8591): a live event must not overtake an in-flight
// catch-up replay, or it persists a watermark past seqs still unshown.
await queueDelivery(
liveEvent.type === 'notification' ? 'notification' : 'dismiss',
liveEvent as NotificationEvent | DismissNotificationEvent
)
})()
})
return () => {
disposed = true
// Why: drop the local stream first — readiness can race unmount; don't hold the callback while a subscription id is pending.
unsubscribeStream()
if (subscriptionId) {
unsubscribeServer(subscriptionId)
@@ -0,0 +1,39 @@
import { afterEach, expect, it, vi } from 'vitest'
import { AppState } from 'react-native'
import { startMobilePushLeaseRenewal } from './mobile-push-lease-renewal'
let onChange: (state: string) => void
const remove = vi.fn()
vi.mock('react-native', () => ({
AppState: {
currentState: 'active',
addEventListener: (_: string, callback: typeof onChange) => {
onChange = callback
return { remove }
}
}
}))
afterEach(() => {
vi.useRealTimers()
vi.clearAllMocks()
})
it('renews only while mobile is foregrounded, resumes on return, and tears down', async () => {
vi.useFakeTimers()
AppState.currentState = 'active'
const renew = vi.fn(async () => {})
const stop = startMobilePushLeaseRenewal(renew)
await vi.advanceTimersByTimeAsync(15 * 60_000)
expect(renew).toHaveBeenCalledTimes(1)
AppState.currentState = 'background'
onChange('background')
await vi.advanceTimersByTimeAsync(8 * 24 * 60 * 60_000)
expect(renew).toHaveBeenCalledTimes(1)
AppState.currentState = 'active'
onChange('active')
expect(renew).toHaveBeenCalledTimes(2)
stop()
await vi.advanceTimersByTimeAsync(15 * 60_000)
expect(renew).toHaveBeenCalledTimes(2)
expect(remove).toHaveBeenCalledOnce()
})
@@ -0,0 +1,19 @@
import { AppState } from 'react-native'
export function startMobilePushLeaseRenewal(renew: () => Promise<void>): () => void {
const refresh = () => {
if (AppState.currentState === 'active') {
void renew().catch(() => {})
}
}
const subscription = AppState.addEventListener('change', (state) => {
if (state === 'active') {
refresh()
}
})
const timer = setInterval(refresh, 15 * 60_000)
return () => {
subscription.remove()
clearInterval(timer)
}
}
@@ -0,0 +1,22 @@
import { expect, it } from 'vitest'
import { readNativeNotificationData } from './native-notification-data'
import { readOrcaPushPayload } from './push-payload'
it('reads actual Expo APNs payloads when content.data is null', () => {
const orca = {
hostFingerprint: 'qa-host',
notificationId: 'done',
notificationSeq: 4,
notificationEpoch: 'epoch'
}
const data = readNativeNotificationData({
content: { data: null },
trigger: { type: 'push', payload: { aps: {}, orca } }
})
expect(readOrcaPushPayload(data)).toMatchObject(orca)
})
it('keeps Android push and local notification data', () => {
const data = { hostId: 'host', notificationId: 'done' }
expect(readNativeNotificationData({ content: { data }, trigger: { type: 'push' } })).toBe(data)
expect(readNativeNotificationData({ content: { data }, trigger: null })).toBe(data)
})
@@ -0,0 +1,13 @@
export function readNativeNotificationData(request: {
content: { data?: unknown }
trigger?: unknown
}): unknown {
const trigger = request.trigger
if (trigger && typeof trigger === 'object' && 'type' in trigger && trigger.type === 'push') {
// Expo iOS keeps raw APNs custom fields here when content.data is null.
if ('payload' in trigger && trigger.payload && typeof trigger.payload === 'object') {
return trigger.payload
}
}
return request.content.data
}
@@ -0,0 +1,4 @@
import { requireNativeModule } from 'expo-modules-core'
import type { NativeDismissal } from './native-push-dismissal'
export const nativePushDismissal = requireNativeModule<NativeDismissal>('OrcaNotificationDismissal')
@@ -0,0 +1,23 @@
import { beforeEach, expect, it, vi } from 'vitest'
const requireNativeModule = vi.hoisted(() => vi.fn())
vi.mock('expo-modules-core', () => ({ requireNativeModule }))
beforeEach(() => {
vi.resetModules()
requireNativeModule.mockReset()
})
it('requires the iOS ledger and surfaces a missing native module as a build defect', async () => {
requireNativeModule.mockImplementation(() => {
throw new Error('Cannot find native module OrcaNotificationDismissal')
})
await expect(import('./native-push-dismissal.ios')).rejects.toThrow(
'Cannot find native module OrcaNotificationDismissal'
)
})
it('does not load an iOS module on the default Android/web path', async () => {
expect((await import('./native-push-dismissal')).nativePushDismissal).toBeNull()
expect(requireNativeModule).not.toHaveBeenCalled()
})
@@ -0,0 +1,8 @@
import type { OrcaPushPayload } from './push-payload'
export type NativeDismissal = {
remember(payload: OrcaPushPayload): Promise<void>
wasDismissed(payload: OrcaPushPayload): Promise<boolean>
}
// Android and web use JavaScript storage; iOS requires the native ledger.
export const nativePushDismissal: NativeDismissal | null = null
@@ -1,316 +0,0 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import * as Notifications from 'expo-notifications'
import { subscribeToDesktopNotifications } from './mobile-notifications'
import { resetHostNotificationSessionsForTests } from './notification-reconnect-catchup'
import type { RpcClient } from '../transport/rpc-client'
import { loadPushNotificationsEnabled } from '../storage/preferences'
vi.mock('expo-notifications', () => ({
AndroidImportance: { HIGH: 'high' },
setNotificationChannelAsync: vi.fn(),
getPermissionsAsync: vi.fn(),
requestPermissionsAsync: vi.fn(),
scheduleNotificationAsync: vi.fn(),
dismissNotificationAsync: vi.fn()
}))
vi.mock('react-native', () => ({
Platform: { OS: 'ios', Version: 18 }
}))
const WATERMARK_KEY = 'orca:mobileNotificationsWatermark:host-1'
const storage = new Map<string, string>()
vi.mock('@react-native-async-storage/async-storage', () => ({
default: {
getItem: vi.fn(async (key: string) => storage.get(key) ?? null),
setItem: vi.fn(async (key: string, value: string) => {
storage.set(key, value)
})
}
}))
vi.mock('../storage/preferences', () => ({
loadPushNotificationsEnabled: vi.fn()
}))
function flushAsync(): Promise<void> {
return new Promise((resolve) => {
setTimeout(resolve, 10)
})
}
function persistedSeq(): number {
return (JSON.parse(storage.get(WATERMARK_KEY) ?? '{}') as { seq?: number }).seq ?? 0
}
type MissedOutcome =
| { kind: 'reject' }
| { kind: 'notOk' }
| { kind: 'ok'; notifications: unknown[] }
// Rejects only once `settle()` is called, so a live event can land mid-request.
| { kind: 'heldReject' }
function makeHostClient() {
let onData: ((data: unknown) => void) | null = null
const askedFrom: number[] = []
let outcome: MissedOutcome = { kind: 'ok', notifications: [] }
let releaseHeld: (() => void) | null = null
const client = {
subscribe: vi.fn((_m: string, _p: unknown, cb: (data: unknown) => void) => {
onData = cb
return vi.fn(() => {
onData = null
})
}),
getState: vi.fn(() => 'connected'),
sendRequest: vi.fn(async (method: string, params: unknown = {}) => {
if (method !== 'notifications.getMissedSince') {
return { ok: true, result: undefined } as never
}
askedFrom.push((params as { lastSeenSeq: number }).lastSeenSeq)
if (outcome.kind === 'heldReject') {
await new Promise<void>((resolve) => {
releaseHeld = resolve
})
throw new Error('socket closed')
}
if (outcome.kind === 'reject') {
throw new Error('socket closed')
}
if (outcome.kind === 'notOk') {
return { ok: false, error: { message: 'timeout' } } as never
}
return { ok: true, result: { notifications: outcome.notifications } } as never
})
}
return {
client: client as unknown as RpcClient,
get onData() {
return onData
},
askedFrom,
setOutcome(next: MissedOutcome) {
outcome = next
},
settleHeld() {
releaseHeld?.()
}
}
}
function notification(seq: number) {
return {
type: 'notification',
title: `m${seq}`,
body: 'b',
notificationId: `agent:${seq}`,
notificationSeq: seq
}
}
describe('#8591 catch-up failure quarantines the watermark', () => {
beforeEach(() => {
vi.clearAllMocks()
storage.clear()
resetHostNotificationSessionsForTests()
vi.mocked(loadPushNotificationsEnabled).mockResolvedValue(true)
vi.mocked(Notifications.getPermissionsAsync).mockResolvedValue({
status: 'granted',
canAskAgain: true
} as never)
vi.mocked(Notifications.scheduleNotificationAsync).mockResolvedValue('sched-1')
vi.mocked(Notifications.dismissNotificationAsync).mockResolvedValue(undefined)
})
it('keeps asking from the abandoned range until a catch-up actually succeeds', async () => {
// The phone was offline while seqs 6-7 dispatched. The catch-up that would have
// replayed them dies (socket close / timeout / ok:false), and live traffic keeps
// flowing. If a live seq is allowed to persist past 6-7, the desktop cuts by
// `seq > lastSeenSeq` on the next catch-up and they are gone for good — and the
// window stays open until some catch-up succeeds, not for one round trip.
storage.set(WATERMARK_KEY, JSON.stringify({ seq: 5, epoch: 'epoch-1' }))
const host = makeHostClient()
host.setOutcome({ kind: 'reject' })
subscribeToDesktopNotifications(host.client, 'host-1')
host.onData?.({ type: 'ready', subscriptionId: 'sub-1', epoch: 'epoch-1' })
await flushAsync()
expect(host.askedFrom).toEqual([5])
host.onData?.({ ...notification(11), notificationEpoch: 'epoch-1' })
await flushAsync()
expect(persistedSeq()).toBe(5)
// Second catch-up also fails; the gap is still open.
host.setOutcome({ kind: 'notOk' })
host.onData?.({ type: 'ready', subscriptionId: 'sub-1', epoch: 'epoch-1' })
await flushAsync()
host.onData?.({ ...notification(12), notificationEpoch: 'epoch-1' })
await flushAsync()
expect(host.askedFrom).toEqual([5, 5])
expect(persistedSeq()).toBe(5)
// Third succeeds and replays the abandoned range.
host.setOutcome({ kind: 'ok', notifications: [notification(6), notification(7)] })
host.onData?.({ type: 'ready', subscriptionId: 'sub-1', epoch: 'epoch-1' })
await flushAsync()
expect(host.askedFrom).toEqual([5, 5, 5])
const titles = vi
.mocked(Notifications.scheduleNotificationAsync)
.mock.calls.map((call) => (call[0] as { content: { title: string } }).content.title)
// Exact, not arrayContaining: a duplicate here is the double-push `seen` prevents.
// m11/m12 are the live events that kept flowing while the gap stayed open.
expect(titles).toEqual(['m11', 'm12', 'm6', 'm7'])
// Only now may the watermark move past the recovered range.
expect(persistedSeq()).toBe(12)
host.onData?.({ type: 'ready', subscriptionId: 'sub-1', epoch: 'epoch-1' })
await flushAsync()
expect(host.askedFrom).toEqual([5, 5, 5, 12])
})
it('rolls back a watermark a live event stored while the catch-up was in flight', async () => {
// getMissedSince waits up to 30s, so live traffic routinely persists during it.
// Clamping only writes made AFTER the failure leaves that higher seq on disk, and
// the next launch reads it back and resumes past the range this catch-up abandoned.
storage.set(WATERMARK_KEY, JSON.stringify({ seq: 5, epoch: 'epoch-1' }))
const host = makeHostClient()
host.setOutcome({ kind: 'heldReject' })
subscribeToDesktopNotifications(host.client, 'host-1')
host.onData?.({ type: 'ready', subscriptionId: 'sub-1', epoch: 'epoch-1' })
await flushAsync()
expect(host.askedFrom).toEqual([5])
host.onData?.({ ...notification(11), notificationEpoch: 'epoch-1' })
await flushAsync()
expect(persistedSeq()).toBe(11)
host.settleHeld()
await flushAsync()
expect(persistedSeq()).toBe(5)
})
it('quarantines at the last replayed seq when a teardown cuts the batch short', async () => {
// The batch can start before a teardown and still be draining after it, so the
// events past the interruption were never shown. A live seq arriving on the next
// connection must not persist over them.
storage.set(WATERMARK_KEY, JSON.stringify({ seq: 5, epoch: 'epoch-1' }))
const host = makeHostClient()
host.setOutcome({
kind: 'ok',
notifications: [notification(6), notification(7), notification(8)]
})
let unsubscribe: (() => void) | null = null
vi.mocked(Notifications.scheduleNotificationAsync).mockImplementation(async (request) => {
if ((request as { content: { title: string } }).content.title === 'm6') {
unsubscribe?.()
}
return 'sched-1'
})
unsubscribe = subscribeToDesktopNotifications(host.client, 'host-1')
host.onData?.({ type: 'ready', subscriptionId: 'sub-1', epoch: 'epoch-1' })
await flushAsync()
const titles = vi
.mocked(Notifications.scheduleNotificationAsync)
.mock.calls.map((call) => (call[0] as { content: { title: string } }).content.title)
expect(titles).toEqual(['m6'])
// A fresh subscription on the same module-scope session takes a live seq 20 before
// its own catch-up, then resumes from 6 rather than from 20.
vi.mocked(Notifications.scheduleNotificationAsync).mockResolvedValue('sched-1')
const host2 = makeHostClient()
host2.setOutcome({ kind: 'ok', notifications: [notification(7), notification(8)] })
subscribeToDesktopNotifications(host2.client, 'host-1')
host2.onData?.({ ...notification(20), notificationEpoch: 'epoch-1' })
await flushAsync()
expect(persistedSeq()).toBe(6)
host2.onData?.({ type: 'ready', subscriptionId: 'sub-2', epoch: 'epoch-1' })
await flushAsync()
expect(host2.askedFrom).toEqual([6])
expect(
vi
.mocked(Notifications.scheduleNotificationAsync)
.mock.calls.map((call) => (call[0] as { content: { title: string } }).content.title)
).toEqual(['m6', 'm20', 'm7', 'm8'])
expect(persistedSeq()).toBe(20)
})
it('re-shows a replay whose show threw, instead of dropping it as already seen', async () => {
// The quarantine only holds the RANGE. If the failing event is also marked seen,
// the next catch-up re-fetches it and the dedup guard drops it — the banner is
// never shown, and the first later event to drain the batch lifts the quarantine
// past it. Silent loss with the watermark looking healthy.
storage.set(WATERMARK_KEY, JSON.stringify({ seq: 5, epoch: 'epoch-1' }))
const host = makeHostClient()
host.setOutcome({ kind: 'ok', notifications: [notification(6), notification(7)] })
let failNext = true
vi.mocked(Notifications.scheduleNotificationAsync).mockImplementation(async (request) => {
const title = (request as { content: { title: string } }).content.title
if (title === 'm6' && failNext) {
failNext = false
throw new Error('scheduling rejected')
}
return 'sched-1'
})
subscribeToDesktopNotifications(host.client, 'host-1')
host.onData?.({ type: 'ready', subscriptionId: 'sub-1', epoch: 'epoch-1' })
await flushAsync()
expect(persistedSeq()).toBe(5)
host.onData?.({ type: 'ready', subscriptionId: 'sub-1', epoch: 'epoch-1' })
await flushAsync()
const titles = vi
.mocked(Notifications.scheduleNotificationAsync)
.mock.calls.map((call) => (call[0] as { content: { title: string } }).content.title)
expect(titles).toEqual(['m6', 'm6', 'm7'])
expect(host.askedFrom).toEqual([5, 5])
expect(persistedSeq()).toBe(7)
})
it('re-shows a live event whose show threw, instead of dropping it as already seen', async () => {
// The same hole without any catch-up failing: the live path marks seen before the
// show, so a rejected show leaves the key behind while the watermark stays put.
// The next catch-up dutifully re-fetches the seq and the guard eats it.
storage.set(WATERMARK_KEY, JSON.stringify({ seq: 5, epoch: 'epoch-1' }))
const host = makeHostClient()
host.setOutcome({ kind: 'ok', notifications: [] })
let failNext = true
vi.mocked(Notifications.scheduleNotificationAsync).mockImplementation(async () => {
if (failNext) {
failNext = false
throw new Error('scheduling rejected')
}
return 'sched-1'
})
subscribeToDesktopNotifications(host.client, 'host-1')
host.onData?.({ type: 'ready', subscriptionId: 'sub-1', epoch: 'epoch-1' })
await flushAsync()
host.onData?.({ ...notification(6), notificationEpoch: 'epoch-1' })
await flushAsync()
expect(persistedSeq()).toBe(5)
host.setOutcome({ kind: 'ok', notifications: [notification(6)] })
host.onData?.({ type: 'ready', subscriptionId: 'sub-1', epoch: 'epoch-1' })
await flushAsync()
const titles = vi
.mocked(Notifications.scheduleNotificationAsync)
.mock.calls.map((call) => (call[0] as { content: { title: string } }).content.title)
expect(titles).toEqual(['m6', 'm6'])
expect(persistedSeq()).toBe(6)
})
})
@@ -0,0 +1,308 @@
import { createElement } from 'react'
import { act, create, type ReactTestRenderer } from 'react-test-renderer'
import { afterEach, beforeEach, expect, it, vi } from 'vitest'
import AsyncStorage from '@react-native-async-storage/async-storage'
import NotificationsScreen from '../../app/notifications'
import MobileOnboardingScreen from '../../app/mobile-onboarding'
import { shouldPresentNotificationOptIn } from './notification-opt-in-gate'
import {
attachPushRegistration,
NOTIFICATIONS_REMOTE_PUSH_CAPABILITY,
resetPushRegistrationForTests,
setRemotePushEnabled,
startPushTokenSync
} from './push-registration'
import { getDevicePushToken } from './push-token'
const mocks = vi.hoisted(() => ({ storage: new Map<string, string>(), replace: vi.fn() }))
vi.mock('@react-native-async-storage/async-storage', () => ({
default: {
getItem: vi.fn(async (key: string) => mocks.storage.get(key) ?? null),
setItem: vi.fn(async (key: string, value: string) => {
mocks.storage.set(key, value)
})
}
}))
vi.mock('react-native', () => ({
AppState: { currentState: 'active', addEventListener: () => ({ remove: vi.fn() }) },
AccessibilityInfo: {
addEventListener: () => ({ remove: vi.fn() }),
isReduceMotionEnabled: async () => false
},
Animated: { Value: class {}, View: 'View', multiply: () => 0 },
BackHandler: { addEventListener: () => ({ remove: vi.fn() }) },
StyleSheet: { create: (styles: unknown) => styles },
Text: 'Text',
View: 'View',
Switch: 'Switch',
ScrollView: 'ScrollView',
Pressable: 'Pressable',
Alert: { alert: vi.fn() },
Linking: { openSettings: vi.fn() },
useWindowDimensions: () => ({ width: 390, height: 844 })
}))
vi.mock('expo-router', () => ({
useFocusEffect: vi.fn(),
useLocalSearchParams: () => ({ hostId: 'host', steps: 'notifications' }),
useRouter: () => ({ replace: mocks.replace })
}))
vi.mock('react-native-safe-area-context', () => ({
SafeAreaView: 'View',
useSafeAreaInsets: () => ({ top: 0, bottom: 0 })
}))
vi.mock('lucide-react-native', () => ({ ChevronLeft: 'Icon' }))
vi.mock('../components/OrcaLogo', () => ({ OrcaLogo: 'Logo' }))
vi.mock('../onboarding/MobileOnboardingPage', () => ({ MobileOnboardingPage: 'Page' }))
vi.mock('./NotificationDeliverySection', () => ({ NotificationDeliverySection: 'Delivery' }))
vi.mock('./use-remote-push-capable-hosts', () => ({ useRemotePushCapableHosts: () => [] }))
vi.mock('./notification-permissions', () => ({
ensureNotificationPermissions: async () => true,
getNotificationPermissionState: async () => ({
granted: true,
status: 'granted',
canAskAgain: true,
authorizationReflectsUserChoice: true
})
}))
vi.mock('./mobile-notifications', () => ({
ensureNotificationPermissions: async () => true,
getNotificationPermissionState: async () => ({
granted: true,
status: 'granted',
canAskAgain: true,
authorizationReflectsUserChoice: true
})
}))
vi.mock('./desktop-notification-channel', () => ({
ensureDesktopNotificationChannel: async () => {}
}))
vi.mock('./push-token', () => ({
getDevicePushToken: vi.fn(),
addPushTokenListener: () => () => {}
}))
const token = {
platform: 'ios' as const,
token: 'a'.repeat(64),
apnsEnvironment: 'sandbox' as const
}
let renderer: ReactTestRenderer | undefined
let stopSync: () => void
const records = () => JSON.parse(mocks.storage.get('orca:remotePushHostRegistrations') ?? '{}')
const drain = () => vi.advanceTimersByTimeAsync(0)
function deferred<T>() {
let resolve!: (value: T) => void
const promise = new Promise<T>((done) => {
resolve = done
})
return { promise, resolve }
}
function connection() {
return {
sendRequest: vi.fn(async (method: string): Promise<unknown> => ({
ok: true,
result:
method === 'status.get'
? { capabilities: [NOTIFICATIONS_REMOTE_PUSH_CAPABILITY] }
: { registered: true, unregistered: true }
}))
}
}
async function connectedHost() {
const client = connection()
attachPushRegistration('host', client as never)
await drain()
client.sendRequest.mockClear()
return client
}
async function choose(entry: string) {
await act(async () => {
renderer = create(
createElement(entry === 'settings' ? NotificationsScreen : MobileOnboardingScreen)
)
})
await act(async () => {
if (entry === 'settings') {
renderer!.root.findByType('Switch').props.onValueChange(true)
} else {
renderer!.root.findByType('Page').props.onNotificationChoice('enable')
}
})
}
function expectChoiceComplete(entry: string) {
expect(mocks.storage.get('orca:pushServiceNotificationsEnabled')).toBe('true')
if (entry === 'settings') {
expect(renderer!.root.findByType('Switch').props).toMatchObject({
value: true,
disabled: false
})
}
if (entry === 'onboarding') {
expect(mocks.replace).toHaveBeenCalledExactlyOnceWith('/h/host')
}
}
beforeEach(() => {
vi.useFakeTimers()
vi.clearAllMocks()
mocks.storage.clear()
resetPushRegistrationForTests()
vi.mocked(getDevicePushToken).mockResolvedValue(token)
stopSync = startPushTokenSync()
})
afterEach(async () => {
await act(async () => renderer?.unmount())
renderer = undefined
stopSync()
resetPushRegistrationForTests()
vi.useRealTimers()
})
it.each(['true', 'false'])(
'requires consent before registering a legacy %s user',
async (legacy) => {
mocks.storage.set('orca:pushNotificationsEnabled', legacy)
const client = await connectedHost()
await expect(shouldPresentNotificationOptIn()).resolves.toBe(true)
await drain()
expect(getDevicePushToken).not.toHaveBeenCalled()
expect(client.sendRequest).not.toHaveBeenCalled()
await choose('onboarding')
await drain()
await expect(shouldPresentNotificationOptIn()).resolves.toBe(false)
expect(client.sendRequest.mock.calls.map(([method]) => method)).toEqual([
'notifications.registerPush'
])
}
)
it('remembers Not now without registering and does not ask again', async () => {
mocks.storage.set('orca:pushNotificationsEnabled', 'true')
const client = await connectedHost()
await act(async () => {
renderer = create(createElement(MobileOnboardingScreen))
})
await act(async () => {
renderer!.root.findByType('Page').props.onNotificationChoice('skip')
})
await drain()
await expect(shouldPresentNotificationOptIn()).resolves.toBe(false)
expect(mocks.storage.get('orca:pushServiceNotificationsEnabled')).toBe('false')
expect(getDevicePushToken).not.toHaveBeenCalled()
expect(
client.sendRequest.mock.calls.some(([method]) => method === 'notifications.registerPush')
).toBe(false)
})
it.each(['settings', 'onboarding'])(
'%s schedules exactly one registration with token sync running',
async (entry) => {
const client = await connectedHost()
await choose(entry)
await drain()
expectChoiceComplete(entry)
expect(client.sendRequest.mock.calls.map(([method]) => method)).toEqual([
'notifications.registerPush'
])
expect(records().registeredHostIds).toEqual(['host'])
}
)
it.each(['settings', 'onboarding'])(
'%s finishes local consent while native token acquisition is pending',
async (entry) => {
const client = await connectedHost()
const pending = deferred<typeof token>()
vi.mocked(getDevicePushToken).mockReturnValue(pending.promise)
await choose(entry)
await drain()
expectChoiceComplete(entry)
expect(getDevicePushToken).toHaveBeenCalledOnce()
expect(client.sendRequest).not.toHaveBeenCalled()
pending.resolve(token)
await drain()
expect(client.sendRequest.mock.calls.map(([method]) => method)).toEqual([
'notifications.registerPush'
])
}
)
it.each(['settings', 'onboarding'])(
'%s finishes local consent while registration RPC is pending',
async (entry) => {
const client = await connectedHost()
const pending = deferred<unknown>()
client.sendRequest.mockImplementationOnce(() => pending.promise)
await choose(entry)
await drain()
expectChoiceComplete(entry)
expect(client.sendRequest).toHaveBeenCalledOnce()
expect(records().registeredHostIds).toEqual([])
pending.resolve({ ok: true, result: { registered: true } })
await drain()
expect(records().registeredHostIds).toEqual(['host'])
expect(client.sendRequest).toHaveBeenCalledOnce()
}
)
it('waits for durable local records and schedules one unregister without waiting for its RPC', async () => {
const client = await connectedHost()
await setRemotePushEnabled(true)
await drain()
client.sendRequest.mockClear()
const write = deferred<void>()
vi.mocked(AsyncStorage.setItem)
.mockImplementationOnce(async (key, value) => {
mocks.storage.set(key, value)
})
.mockImplementationOnce(async (key, value) => {
await write.promise
mocks.storage.set(key, value)
})
const rpc = deferred<unknown>()
client.sendRequest.mockImplementationOnce(() => rpc.promise)
const completed = vi.fn()
const disable = setRemotePushEnabled(false).then(completed)
await drain()
expect(completed).not.toHaveBeenCalled()
expect(client.sendRequest).not.toHaveBeenCalled()
write.resolve()
await disable
await drain()
expect(completed).toHaveBeenCalledOnce()
expect(records().pendingUnregisterHostIds).toEqual(['host'])
expect(client.sendRequest.mock.calls.map(([method]) => method)).toEqual([
'notifications.unregisterPush'
])
rpc.resolve({ ok: true })
await drain()
expect(records()).toEqual({ registeredHostIds: [], pendingUnregisterHostIds: [] })
expect(client.sendRequest).toHaveBeenCalledOnce()
})
it('exposes a failed consent write without scheduling or changing durable consent', async () => {
const client = await connectedHost()
vi.mocked(AsyncStorage.setItem).mockRejectedValueOnce(new Error('consent write failed'))
await expect(setRemotePushEnabled(true)).rejects.toThrow('consent write failed')
await drain()
expect(mocks.storage.has('orca:pushServiceNotificationsEnabled')).toBe(false)
expect(client.sendRequest).not.toHaveBeenCalled()
})
it('exposes a failed records write and still schedules exactly one cleanup', async () => {
const client = await connectedHost()
await setRemotePushEnabled(true)
await drain()
client.sendRequest.mockClear()
vi.mocked(AsyncStorage.setItem)
.mockImplementationOnce(async (key, value) => {
mocks.storage.set(key, value)
})
.mockRejectedValueOnce(new Error('records write failed'))
await expect(setRemotePushEnabled(false)).rejects.toThrow('records write failed')
await drain()
expect(mocks.storage.get('orca:pushServiceNotificationsEnabled')).toBe('false')
expect(client.sendRequest.mock.calls.map(([method]) => method)).toEqual([
'notifications.unregisterPush'
])
expect(records()).toEqual({ registeredHostIds: [], pendingUnregisterHostIds: [] })
})
@@ -1,248 +0,0 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import * as Notifications from 'expo-notifications'
import { subscribeToDesktopNotifications } from './mobile-notifications'
import { resetHostNotificationSessionsForTests } from './notification-reconnect-catchup'
import type { RpcClient } from '../transport/rpc-client'
import { loadPushNotificationsEnabled } from '../storage/preferences'
vi.mock('expo-notifications', () => ({
AndroidImportance: { HIGH: 'high' },
setNotificationChannelAsync: vi.fn(),
getPermissionsAsync: vi.fn(),
requestPermissionsAsync: vi.fn(),
scheduleNotificationAsync: vi.fn(),
dismissNotificationAsync: vi.fn()
}))
vi.mock('react-native', () => ({
Platform: { OS: 'ios', Version: 18 }
}))
const WATERMARK_KEY = 'orca:mobileNotificationsWatermark:host-1'
const storage = new Map<string, string>()
let getItemImpl: (key: string) => Promise<string | null> = async (key) => storage.get(key) ?? null
vi.mock('@react-native-async-storage/async-storage', () => ({
default: {
getItem: vi.fn((key: string) => getItemImpl(key)),
setItem: vi.fn(async (key: string, value: string) => {
storage.set(key, value)
})
}
}))
vi.mock('../storage/preferences', () => ({
loadPushNotificationsEnabled: vi.fn()
}))
function flushAsync(): Promise<void> {
return new Promise((resolve) => {
setTimeout(resolve, 10)
})
}
function persistedSeq(): number {
return (JSON.parse(storage.get(WATERMARK_KEY) ?? '{}') as { seq?: number }).seq ?? 0
}
describe('#8591 per-host delivery ordering', () => {
beforeEach(() => {
vi.clearAllMocks()
storage.clear()
getItemImpl = async (key) => storage.get(key) ?? null
resetHostNotificationSessionsForTests()
vi.mocked(loadPushNotificationsEnabled).mockResolvedValue(true)
vi.mocked(Notifications.getPermissionsAsync).mockResolvedValue({
status: 'granted',
canAskAgain: true
} as never)
vi.mocked(Notifications.scheduleNotificationAsync).mockResolvedValue('sched-1')
vi.mocked(Notifications.dismissNotificationAsync).mockResolvedValue(undefined)
})
it('never persists a watermark past a notification the catch-up has not shown', async () => {
// The watermark is a promise that everything up to that seq reached the user.
// If a live seq 11 is processed while catch-up is still showing seq 6, it
// persists 11 — and a process death before 7 is shown loses 7 forever, because
// the next launch asks the desktop for seq > 11. That is the original #8591
// loss re-entered through concurrency rather than through a restarted counter.
let releaseFirstShow!: () => void
const firstShowBlocked = new Promise<void>((resolve) => {
releaseFirstShow = resolve
})
let shown = 0
vi.mocked(Notifications.scheduleNotificationAsync).mockImplementation(async () => {
shown += 1
if (shown === 1) {
await firstShowBlocked
}
return 'sched-1'
})
let onData: ((data: unknown) => void) | null = null
const client = {
subscribe: vi.fn((_m: string, _p: unknown, cb: (data: unknown) => void) => {
onData = cb
return vi.fn()
}),
getState: vi.fn(() => 'connected'),
sendRequest: vi.fn(async (method: string) => {
if (method === 'notifications.getMissedSince') {
return {
ok: true,
result: {
notifications: [
{
type: 'notification',
title: 'm6',
body: 'b',
notificationId: 'a:6',
notificationSeq: 6
},
{
type: 'notification',
title: 'm7',
body: 'b',
notificationId: 'a:7',
notificationSeq: 7
}
]
}
} as never
}
return { ok: true, result: undefined } as never
})
} as unknown as RpcClient
storage.set(WATERMARK_KEY, JSON.stringify({ seq: 5, epoch: 'epoch-1' }))
subscribeToDesktopNotifications(client, 'host-1')
onData?.({ type: 'ready', subscriptionId: 'sub-1', epoch: 'epoch-1' })
await flushAsync()
// Live seq 11 arrives while the replay is wedged on seq 6.
onData?.({
type: 'notification',
title: 'live-11',
body: 'b',
notificationId: 'a:11',
notificationSeq: 11
})
await flushAsync()
expect(persistedSeq()).toBeLessThan(6)
releaseFirstShow()
await flushAsync()
// Once the chain drains, everything is shown and the watermark catches up.
expect(persistedSeq()).toBe(11)
const titles = vi
.mocked(Notifications.scheduleNotificationAsync)
.mock.calls.map((call) => (call[0] as { content: { title: string } }).content.title)
expect(titles).toEqual(['m6', 'm7', 'live-11'])
})
it('shows one banner when a replay and a live event carry the same notification id', async () => {
// Serializing deliveries removed the overlap the old dedup relied on: the
// replay's show now COMPLETES before the live duplicate starts, so nothing is
// pending for it to observe and the user gets the same notification twice.
let releaseFirstShow!: () => void
const firstShowBlocked = new Promise<void>((resolve) => {
releaseFirstShow = resolve
})
let shown = 0
vi.mocked(Notifications.scheduleNotificationAsync).mockImplementation(async () => {
shown += 1
if (shown === 1) {
await firstShowBlocked
}
return `sched-${shown}`
})
let onData: ((data: unknown) => void) | null = null
const client = {
subscribe: vi.fn((_m: string, _p: unknown, cb: (data: unknown) => void) => {
onData = cb
return vi.fn()
}),
getState: vi.fn(() => 'connected'),
sendRequest: vi.fn(async (method: string) => {
if (method === 'notifications.getMissedSince') {
return {
ok: true,
result: {
notifications: [
{
type: 'notification',
title: 'dup',
body: 'b',
notificationId: 'agent:dup',
notificationSeq: 6
}
]
}
} as never
}
return { ok: true, result: undefined } as never
})
} as unknown as RpcClient
storage.set(WATERMARK_KEY, JSON.stringify({ seq: 5, epoch: 'epoch-1' }))
subscribeToDesktopNotifications(client, 'host-1')
onData?.({ type: 'ready', subscriptionId: 'sub-1', epoch: 'epoch-1' })
await flushAsync()
// Same id arrives live while the replay's show is still blocked. A different
// seq, so the seen-set does not catch it — only the queued-show claim does.
onData?.({
type: 'notification',
title: 'dup',
body: 'b',
notificationId: 'agent:dup',
notificationSeq: 7
})
await flushAsync()
releaseFirstShow()
await flushAsync()
expect(vi.mocked(Notifications.scheduleNotificationAsync)).toHaveBeenCalledTimes(1)
})
it('still delivers when the persisted watermark read never resolves', async () => {
// Every delivery awaits the seed, so a wedged AsyncStorage read would disable
// this host's notifications for the whole app lifetime — silently.
getItemImpl = () => new Promise<string | null>(() => {})
let onData: ((data: unknown) => void) | null = null
const client = {
subscribe: vi.fn((_m: string, _p: unknown, cb: (data: unknown) => void) => {
onData = cb
return vi.fn()
}),
getState: vi.fn(() => 'connected'),
sendRequest: vi.fn(async () => ({ ok: true, result: undefined }) as never)
} as unknown as RpcClient
vi.useFakeTimers()
try {
subscribeToDesktopNotifications(client, 'host-1')
onData?.({ type: 'ready', subscriptionId: 'sub-1', epoch: 'epoch-1' })
onData?.({
type: 'notification',
title: 'live-1',
body: 'b',
notificationId: 'a:1',
notificationSeq: 1
})
await vi.advanceTimersByTimeAsync(3100)
} finally {
vi.useRealTimers()
}
const titles = vi
.mocked(Notifications.scheduleNotificationAsync)
.mock.calls.map((call) => (call[0] as { content: { title: string } }).content.title)
expect(titles).toContain('live-1')
})
})
@@ -0,0 +1,86 @@
import { beforeEach, expect, it, vi } from 'vitest'
import { AppState } from 'react-native'
import {
DEFAULT_NOTIFICATION_DELIVERY,
loadNotificationDeliveryPreferences,
notificationPreferencesFilter,
saveNotificationDeliveryPreferences
} from './notification-delivery-preferences'
import {
setNotificationViewingWorkspace,
shouldSuppressNotificationWhileViewing
} from './notification-viewing-policy'
const storage = new Map<string, string>()
vi.mock('@react-native-async-storage/async-storage', () => ({
default: {
getItem: vi.fn(async (key: string) => storage.get(key) ?? null),
setItem: vi.fn(async (key: string, value: string) => {
storage.set(key, value)
})
}
}))
vi.mock('react-native', () => ({ AppState: { currentState: 'background' } }))
beforeEach(() => {
storage.clear()
setNotificationViewingWorkspace(null)
AppState.currentState = 'background'
})
it('persists only phone-specific delivery preferences', async () => {
expect(await loadNotificationDeliveryPreferences()).toEqual(DEFAULT_NOTIFICATION_DELIVERY)
const value = {
...DEFAULT_NOTIFICATION_DELIVERY,
onlyWhenDesktopAway: false,
sound: false
}
await saveNotificationDeliveryPreferences(value)
expect(await loadNotificationDeliveryPreferences()).toEqual(value)
expect(notificationPreferencesFilter(value)).toEqual({
onlyWhenDesktopAway: false,
sound: false
})
})
it('ignores unrelated stored preferences', async () => {
storage.set(
'orca:notificationDeliveryPreferences',
JSON.stringify({
onlyWhenDesktopAway: false,
sound: false,
suppressWhileViewing: false,
unrelatedSetting: false
})
)
expect(await loadNotificationDeliveryPreferences()).toEqual({
onlyWhenDesktopAway: false,
sound: false,
suppressWhileViewing: false
})
expect(notificationPreferencesFilter(await loadNotificationDeliveryPreferences())).toEqual({
onlyWhenDesktopAway: false,
sound: false
})
})
it('suppresses only the workspace being viewed on this phone, and never while backgrounded', async () => {
const event = { source: 'terminal-bell', worktreeId: 'folder-id' }
setNotificationViewingWorkspace({ hostId: 'ssh-host', worktreeId: 'folder-id' })
AppState.currentState = 'active'
expect(await shouldSuppressNotificationWhileViewing(event, 'ssh-host', true)).toBe(true)
expect(await shouldSuppressNotificationWhileViewing(event, 'another-host', true)).toBe(false)
expect(
await shouldSuppressNotificationWhileViewing(
{ ...event, worktreeId: 'other' },
'ssh-host',
true
)
).toBe(false)
AppState.currentState = 'background'
expect(await shouldSuppressNotificationWhileViewing(event, 'ssh-host', true)).toBe(false)
})
it('recovers defaults from malformed stored preferences', async () => {
storage.set('orca:notificationDeliveryPreferences', '{broken')
expect(await loadNotificationDeliveryPreferences()).toEqual(DEFAULT_NOTIFICATION_DELIVERY)
})
@@ -0,0 +1,49 @@
import AsyncStorage from '@react-native-async-storage/async-storage'
import type { MobilePushFilter } from '../../../src/shared/mobile-push-contract'
const KEY = 'orca:notificationDeliveryPreferences'
export type NotificationDeliveryPreferences = {
onlyWhenDesktopAway: boolean
sound: boolean
suppressWhileViewing: boolean
}
export const DEFAULT_NOTIFICATION_DELIVERY: NotificationDeliveryPreferences = {
onlyWhenDesktopAway: true,
sound: true,
suppressWhileViewing: true
}
export async function loadNotificationDeliveryPreferences(): Promise<NotificationDeliveryPreferences> {
try {
const raw = await AsyncStorage.getItem(KEY)
if (!raw) {
return { ...DEFAULT_NOTIFICATION_DELIVERY }
}
const stored = JSON.parse(raw) as Record<string, unknown>
const result = { ...DEFAULT_NOTIFICATION_DELIVERY }
for (const key of Object.keys(result) as (keyof NotificationDeliveryPreferences)[]) {
if (typeof stored?.[key] === 'boolean') {
result[key] = stored[key]
}
}
return result
} catch {
return { ...DEFAULT_NOTIFICATION_DELIVERY }
}
}
export async function saveNotificationDeliveryPreferences(
value: NotificationDeliveryPreferences
): Promise<void> {
await AsyncStorage.setItem(KEY, JSON.stringify(value))
}
export function notificationPreferencesFilter(
value: NotificationDeliveryPreferences
): MobilePushFilter {
return {
onlyWhenDesktopAway: value.onlyWhenDesktopAway,
sound: value.sound
}
}
@@ -1,92 +1,24 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import {
readPushNotificationsPreference,
savePushNotificationsEnabled
} from '../storage/preferences'
import { getNotificationPermissionState } from './mobile-notifications'
import { describe, expect, it, vi } from 'vitest'
import { readPushNotificationsPreference } from '../storage/preferences'
import { shouldPresentNotificationOptIn } from './notification-opt-in-gate'
vi.mock('../storage/preferences', () => ({
readPushNotificationsPreference: vi.fn(),
savePushNotificationsEnabled: vi.fn()
}))
vi.mock('./mobile-notifications', () => ({
getNotificationPermissionState: vi.fn()
readPushNotificationsPreference: vi.fn()
}))
describe('notification opt-in gate', () => {
beforeEach(() => {
vi.mocked(readPushNotificationsPreference).mockReset()
vi.mocked(savePushNotificationsEnabled).mockReset()
vi.mocked(getNotificationPermissionState).mockReset()
})
it('presents only when the local preference and system decision are both unset', async () => {
it('asks for push-service consent when no choice is saved, regardless of OS permission', async () => {
vi.mocked(readPushNotificationsPreference).mockResolvedValue({ value: null, loaded: true })
vi.mocked(getNotificationPermissionState).mockResolvedValue({
granted: false,
status: 'undetermined',
canAskAgain: true,
authorizationReflectsUserChoice: false
})
await expect(shouldPresentNotificationOptIn()).resolves.toBe(true)
expect(savePushNotificationsEnabled).not.toHaveBeenCalled()
})
it.each([true, false])('preserves an existing %s mobile preference', async (value) => {
it.each([true, false])('does not ask again after choosing %s', async (value) => {
vi.mocked(readPushNotificationsPreference).mockResolvedValue({ value, loaded: true })
await expect(shouldPresentNotificationOptIn()).resolves.toBe(false)
expect(getNotificationPermissionState).not.toHaveBeenCalled()
})
it('adopts existing system authorization without prompting', async () => {
vi.mocked(readPushNotificationsPreference).mockResolvedValue({ value: null, loaded: true })
vi.mocked(getNotificationPermissionState).mockResolvedValue({
granted: true,
status: 'granted',
canAskAgain: true,
authorizationReflectsUserChoice: true
})
await expect(shouldPresentNotificationOptIn()).resolves.toBe(false)
expect(savePushNotificationsEnabled).toHaveBeenCalledWith(true)
})
it('still presents when a pre-Android 13 default grant is not an opt-in decision', async () => {
vi.mocked(readPushNotificationsPreference).mockResolvedValue({ value: null, loaded: true })
vi.mocked(getNotificationPermissionState).mockResolvedValue({
granted: true,
status: 'granted',
canAskAgain: true,
authorizationReflectsUserChoice: false
})
await expect(shouldPresentNotificationOptIn()).resolves.toBe(true)
expect(savePushNotificationsEnabled).not.toHaveBeenCalled()
})
it('skips the gate when iOS has already denied permission', async () => {
vi.mocked(readPushNotificationsPreference).mockResolvedValue({ value: null, loaded: true })
vi.mocked(getNotificationPermissionState).mockResolvedValue({
granted: false,
status: 'denied',
canAskAgain: false,
authorizationReflectsUserChoice: false
})
await expect(shouldPresentNotificationOptIn()).resolves.toBe(false)
expect(savePushNotificationsEnabled).toHaveBeenCalledWith(false)
})
it('does not block startup when storage or permission checks fail', async () => {
it('does not prompt when the saved choice cannot be read', async () => {
vi.mocked(readPushNotificationsPreference).mockResolvedValue({ value: null, loaded: false })
await expect(shouldPresentNotificationOptIn()).resolves.toBe(false)
vi.mocked(readPushNotificationsPreference).mockResolvedValue({ value: null, loaded: true })
vi.mocked(getNotificationPermissionState).mockRejectedValue(new Error('unavailable'))
await expect(shouldPresentNotificationOptIn()).resolves.toBe(false)
})
})
@@ -1,36 +1,6 @@
import {
readPushNotificationsPreference,
savePushNotificationsEnabled
} from '../storage/preferences'
import { getNotificationPermissionState } from './mobile-notifications'
import { readPushNotificationsPreference } from '../storage/preferences'
export async function shouldPresentNotificationOptIn(): Promise<boolean> {
const preference = await readPushNotificationsPreference()
if (!preference.loaded || preference.value !== null) {
return false
}
try {
const permission = await getNotificationPermissionState()
if (permission.granted) {
if (!permission.authorizationReflectsUserChoice) {
return true
}
// Why: an already-authorized device should inherit the useful default
// without seeing an onboarding decision it has effectively made.
await savePushNotificationsEnabled(true)
return false
}
if (permission.status === 'denied' || !permission.canAskAgain) {
// Why: iOS cannot show its authorization prompt again, so a blocking
// onboarding screen would be a dead end; Settings remains the recovery.
await savePushNotificationsEnabled(false)
return false
}
return permission.status === 'undetermined'
} catch {
// Why: permission or persistence failures must not trap startup behind a
// decision screen whose result cannot be applied reliably.
return false
}
return preference.loaded && preference.value === null
}
@@ -1,412 +0,0 @@
import AsyncStorage from '@react-native-async-storage/async-storage'
// Why: the reconnect catch-up watermark + dedup helpers for #8129, extracted
// from mobile-notifications.ts so that file stays under its max-lines budget.
// The highest desktop notification seq this device has delivered is persisted
// per-host so it survives app restarts. On reconnect we send it to
// notifications.getMissedSince as the catch-up watermark — the desktop then
// returns only notifications dispatched after it, so we never re-push a
// notification we already delivered. The in-memory seen-set is a second guard
// against double-delivery for events that arrive on both the live stream and a
// replay (e.g. a brief liveness spell before a reap).
// Why (#8591): a seq is meaningless without the counter it indexes — after a
// desktop restart that counter is gone. The epoch names the counter's lifetime so
// a reconnect can tell "nothing missed" from "different counter".
//
// Why ONE key holding both, rather than a key each: they are only meaningful as a
// pair. Written separately, a process death between the two writes leaves an epoch
// from one counter beside a seq from another — a pair that looks internally valid
// on the next launch and is therefore trusted, silently cutting real notifications.
// A single JSON value cannot tear that way.
const WATERMARK_STORAGE_KEY_PREFIX = 'orca:mobileNotificationsWatermark:'
// Pre-#8591 installs wrote the seq alone. Read once to migrate; never written.
const LEGACY_SEQ_STORAGE_KEY_PREFIX = 'orca:mobileNotificationsLastSeq:'
function watermarkStorageKey(hostId: string): string {
return WATERMARK_STORAGE_KEY_PREFIX + encodeURIComponent(hostId)
}
// A null epoch means "the counter this seq came from is unknown" — a legacy
// watermark, or nothing stored. It can never be assumed to be the live counter.
export type PersistedWatermark = { seq: number; epoch: string | null }
// `stored` is the record's existence, independent of its seq: it answers "has this
// device ever been subscribed to this host", which is what a cold open needs to tell
// a returning device from a first pairing. A seq of 0 is a real answer, not an absence.
export type LoadedWatermark = PersistedWatermark & { stored: boolean }
function coerceSeq(value: unknown): number {
const parsed = typeof value === 'number' ? value : Number(value)
return Number.isFinite(parsed) && parsed > 0 ? parsed : 0
}
export async function loadWatermark(hostId: string): Promise<LoadedWatermark> {
try {
const raw = await AsyncStorage.getItem(watermarkStorageKey(hostId))
if (raw != null) {
const parsed = JSON.parse(raw) as { seq?: unknown; epoch?: unknown }
const epoch =
typeof parsed.epoch === 'string' && parsed.epoch.length > 0 ? parsed.epoch : null
return { seq: coerceSeq(parsed.seq), epoch, stored: true }
}
} catch {
// Unreadable or malformed: fall through to the legacy key rather than throw.
}
try {
const legacy = await AsyncStorage.getItem(
LEGACY_SEQ_STORAGE_KEY_PREFIX + encodeURIComponent(hostId)
)
return { seq: coerceSeq(legacy), epoch: null, stored: legacy != null }
} catch {
return { seq: 0, epoch: null, stored: false }
}
}
export async function clearWatermark(hostId: string): Promise<void> {
// Why both keys: loadWatermark falls back to the legacy one, so removing only the
// current key would let a re-paired host resurrect a pre-#8591 seq from a counter
// lifetime that is long gone — the exact stale cut this fix removes.
await Promise.all([
AsyncStorage.removeItem(watermarkStorageKey(hostId)).catch(() => {}),
AsyncStorage.removeItem(LEGACY_SEQ_STORAGE_KEY_PREFIX + encodeURIComponent(hostId)).catch(
() => {}
)
])
}
export async function saveWatermark(hostId: string, watermark: PersistedWatermark): Promise<void> {
try {
await AsyncStorage.setItem(watermarkStorageKey(hostId), JSON.stringify(watermark))
} catch {
// Why: persisting the watermark is best-effort. If it fails (or lags), the
// stored value stays BELOW what we delivered, so a later cold start can
// re-fetch — and, once the in-memory seen-set is gone, re-show — an already
// delivered notification. That's the accepted at-least-once trade-off;
// within a live session the in-memory watermark is authoritative, so only
// post-restart reconnects are affected.
}
}
// Why: bounded in-memory dedup window for notificationIds/dismiss ids observed
// on the current connection. The desktop already dedupes by seq on replay, but
// a socket that flickers background→foreground→background can deliver an event
// on the live stream and again in a replay; the seen-set guarantees each
// notificationId maps to at most one local push for the connection lifetime.
// Bounded so a long-lived session can't grow without limit — a 2x superset of
// the desktop's 256-entry replay buffer and the 256 scheduled-notification cap.
const RECENTLY_SEEN_CAP = 512
export function createSeenNotificationGuard(): {
has: (id: string) => boolean
add: (id: string) => void
clear: () => void
} {
const seen = new Set<string>()
return {
has(id: string): boolean {
return seen.has(id)
},
add(id: string): void {
seen.add(id)
if (seen.size > RECENTLY_SEEN_CAP) {
// Why: insertion order; the oldest entries are first. Drop one to stay
// bounded without disturbing the more-recently-relevant keys.
const first = seen.values().next().value
if (first !== undefined) {
seen.delete(first)
}
}
},
clear(): void {
seen.clear()
}
}
}
// Why (#8591): app/index.tsx tears the notification subscription down on every
// non-'connected' state and builds a fresh one on reconnect, so everything held
// in the subscription closure — the ready counter, the delivered watermark, the
// seen-set — is destroyed exactly when a reconnect needs it. Keeping it per host
// at module scope is what makes the catch-up recognise a reconnect (instead of
// mistaking it for a cold open) and keeps dedup effective across the teardown.
export type HostNotificationSession = {
// Highest desktop seq delivered for this host in this app process. Outranks
// the persisted value, which lags because saveLastSeenSeq is fire-and-forget.
lastDeliveredSeq: number
// Counter lifetime lastDeliveredSeq belongs to; null until one is known. A
// mismatch on reconnect means the desktop restarted and the watermark is void.
lastDeliveredEpoch: string | null
// Highest seq known delivered CONTIGUOUSLY, frozen here while a catch-up is
// outstanding; null when none has failed. See quarantineCatchUpWatermark.
catchUpQuarantineSeq: number | null
seen: ReturnType<typeof createSeenNotificationGuard>
// False only until the host's first subscription reaches 'ready' — a true cold open.
connectedBefore: boolean
// Why (#8591): distinguishes "this device has delivered for this host before"
// from a first-ever pairing. Only the former may catch up on a cold open — a
// brand-new pairing fetching from seq 0 would push the desktop's whole buffer
// at someone who was never subscribed for any of it.
hadStoredWatermark: boolean
// Resolves once the persisted read has landed, so the first 'ready' can wait for
// it instead of deciding catch-up against an unread watermark.
watermarkSeeded: Promise<void> | null
// Tail of the per-host delivery chain; see enqueueHostDelivery.
deliveryTail: Promise<void>
// notificationIds with a show queued or in flight on that chain; see
// shouldQueueShowForNotificationId.
queuedShowIds: Set<string>
}
const sessionsByHost = new Map<string, HostNotificationSession>()
export function getHostNotificationSession(hostId: string): HostNotificationSession {
let session = sessionsByHost.get(hostId)
if (!session) {
session = {
lastDeliveredSeq: 0,
lastDeliveredEpoch: null,
catchUpQuarantineSeq: null,
seen: createSeenNotificationGuard(),
connectedBefore: false,
hadStoredWatermark: false,
watermarkSeeded: null,
deliveryTail: Promise.resolve(),
queuedShowIds: new Set<string>()
}
sessionsByHost.set(hostId, session)
}
return session
}
/**
* Run `task` after every delivery already queued for this host, and return a
* promise for its completion.
*
* Why (#8591): the watermark is persisted by whichever delivery advances it, so
* replay and live delivery running concurrently can persist out of order. A live
* seq 11 handled while catch-up is still showing seq 6 writes watermark 11, and a
* process death before 7..10 are shown loses them permanently — the next launch
* asks the desktop for seq > 11. Serializing per host makes the watermark's
* monotonic advance mean "everything up to here was actually delivered".
*
* A rejected task does not break the chain: the tail swallows the failure so a
* single bad notification cannot wedge the host's queue forever.
*/
export function enqueueHostDelivery(
session: HostNotificationSession,
task: () => Promise<void>
): Promise<void> {
const run = session.deliveryTail.then(task)
session.deliveryTail = run.catch(() => {})
return run
}
/**
* Claim a notificationId for a queued show, returning false if one is already
* queued or in flight for it.
*
* Why this exists (#8591): showLocalNotification deduped two same-id events by
* observing that the first was still pending when the second arrived. Serializing
* deliveries removed that overlap — the first now COMPLETES before the second
* starts, so the second reads no pending state and schedules a second banner for
* the same notification. The dedup has to happen where concurrency is still
* visible, which after serialization is enqueue time rather than delivery time.
*
* Only shows are tracked. A dismiss for the same id must still run: it is the
* mechanism that retires the notification the show created.
*/
export function shouldQueueShowForNotificationId(
session: HostNotificationSession,
notificationId: string | undefined
): boolean {
if (notificationId == null) {
return true
}
if (session.queuedShowIds.has(notificationId)) {
return false
}
session.queuedShowIds.add(notificationId)
return true
}
/** Release the claim taken by shouldQueueShowForNotificationId once the show settles. */
export function releaseQueuedShowNotificationId(
session: HostNotificationSession,
notificationId: string | undefined
): void {
if (notificationId != null) {
session.queuedShowIds.delete(notificationId)
}
}
/** Test-only: drop per-host session state so each test starts from a cold open. */
export function resetHostNotificationSessionsForTests(): void {
sessionsByHost.clear()
}
/**
* Freeze the catch-up watermark at the last seq known delivered contiguously,
* after a catch-up that did not complete.
*
* Why: live delivery advances lastDeliveredSeq unconditionally, so an abandoned
* catch-up otherwise lets the NEXT one ask from above the range it gave up on —
* the desktop cuts by seq, so those notifications are never replayed and are
* gone. Lowest wins: an earlier failure's gap is still open.
*/
export function quarantineCatchUpWatermark(
session: HostNotificationSession,
hostId: string,
contiguousSeq: number
): void {
session.catchUpQuarantineSeq =
session.catchUpQuarantineSeq == null
? contiguousSeq
: Math.min(session.catchUpQuarantineSeq, contiguousSeq)
// Why re-persist: a live event delivered while the catch-up was still in flight
// already stored a seq above the gap. Clamping only later writes would leave that
// value on disk, so a restart still resumes past the abandoned range.
void saveWatermark(hostId, {
seq: catchUpWatermarkSeq(session),
epoch: session.lastDeliveredEpoch
})
}
/** Lift the quarantine once a catch-up completes, persisting what it held back. */
export function resolveCatchUpQuarantine(session: HostNotificationSession, hostId: string): void {
if (session.catchUpQuarantineSeq == null) {
return
}
session.catchUpQuarantineSeq = null
void saveWatermark(hostId, {
seq: session.lastDeliveredSeq,
epoch: session.lastDeliveredEpoch
})
}
/**
* The seq a catch-up may ask from and the highest seq safe to persist — the live
* watermark, clamped to any open gap.
*/
export function catchUpWatermarkSeq(session: HostNotificationSession): number {
return session.catchUpQuarantineSeq == null
? session.lastDeliveredSeq
: Math.min(session.catchUpQuarantineSeq, session.lastDeliveredSeq)
}
// Why (#8591): the desktop's seq counter restarts at 0 every launch, so a watermark
// from a previous lifetime indexes a counter that no longer exists. Comparing it
// against the fresh counter makes `lastSeenSeq >= seq` true for everything and
// catch-up dies silently until the new process out-dispatches the old watermark.
// Adopting the new epoch means dropping the watermark with it.
export function adoptNotificationEpoch(
session: HostNotificationSession,
hostId: string,
epoch: string | undefined
): void {
if (!epoch || epoch === session.lastDeliveredEpoch) {
return
}
// Why reset on a FIRST observation too (lastDeliveredEpoch === null): a seq seeded
// from a legacy store carries no epoch, so it cannot be shown to belong to this
// counter. Keeping it would let a pre-upgrade 57 cut the new counter's 1..57 —
// the exact #8591 failure, reached through the upgrade path instead of a restart.
session.lastDeliveredSeq = 0
// Why clear `seen`: its keys are seq-derived, and terminal-bell notifications have
// no notificationId at all (they key on `seq:N` alone). Across a restart the new
// counter re-issues those same low seqs, so a stale `seq:1` would silently drop
// the new counter's first bell. The dedup window belongs to one counter lifetime.
session.seen.clear()
// The quarantined gap indexed the dead counter; the watermark it guarded is gone too.
session.catchUpQuarantineSeq = null
session.lastDeliveredEpoch = epoch
void saveWatermark(hostId, { seq: 0, epoch })
}
// Why: seed the watermark lazily so subscribe() doesn't block on an AsyncStorage read.
// Only the first subscription for a host needs it; later ones inherit the live value.
/**
* Ms the persisted read may block catch-up and live delivery before they proceed
* without it. AsyncStorage normally answers in single-digit ms; a read that has
* not landed by now is assumed wedged.
*
* Why a bound at all (#8591): every delivery awaits this promise, so a read that
* never settles silently disables notifications for the host for the whole app
* lifetime — no error, no banner, nothing to see. Proceeding unseeded is strictly
* better: the watermark stays 0, so catch-up over-fetches and the seen-set
* de-duplicates, which costs a redundant request instead of every notification.
*/
const WATERMARK_SEED_TIMEOUT_MS = 3000
function withTimeout(promise: Promise<void>, ms: number): Promise<void> {
return new Promise<void>((resolve) => {
const timer = setTimeout(resolve, ms)
void promise.then(
() => {
clearTimeout(timer)
resolve()
},
() => {
clearTimeout(timer)
resolve()
}
)
})
}
export function seedWatermarkFromStorage(session: HostNotificationSession, hostId: string): void {
if (session.watermarkSeeded) {
return
}
const seeded = loadWatermark(hostId).then(({ seq, epoch, stored }) => {
// Why the record's existence and not `seq > 0`: adoptNotificationEpoch persists
// `{seq: 0, epoch}` when it voids a watermark, so a device that HAS delivered for
// this host reloads as seq 0. Keying on the seq would read that as a first pairing
// and skip catch-up for the whole window the epoch change was meant to recover.
if (stored) {
session.hadStoredWatermark = true
}
// Why the epoch comparison: this read can land AFTER 'ready' already adopted a
// live epoch. If the stored watermark belongs to a different (older) counter,
// applying it here would silently reinstate exactly the stale cut this fixes.
// A null stored epoch is a legacy watermark of unknown provenance — it may only
// seed while no live epoch is known, and adopting one later resets it.
if (session.lastDeliveredEpoch === null || session.lastDeliveredEpoch === epoch) {
session.lastDeliveredSeq = Math.max(session.lastDeliveredSeq, seq)
if (session.lastDeliveredEpoch === null && epoch !== null) {
session.lastDeliveredEpoch = epoch
}
}
})
// The late seed still applies when it eventually lands; the timeout only stops it
// from holding delivery hostage. `seeded` never rejects into the awaiters.
session.watermarkSeeded = withTimeout(seeded, WATERMARK_SEED_TIMEOUT_MS)
}
// Why (#8591): sessions live at module scope so they survive the subscription
// teardown a reconnect performs. Nothing else drops them, so a host that is removed
// and re-paired would retain its session and up to 512 seen keys until app restart.
export function forgetHostNotificationSession(hostId: string): void {
sessionsByHost.delete(hostId)
}
// Why: key for the replay dedup guard. Uses notificationId when present, but
// disambiguates by seq so a legitimate live re-delivery of the same id at a
// NEW seq (content refresh, allowed by the existing behaviour) is NOT treated
// as a duplicate, while a replay re-returning the SAME id+seq already delivered
// live is suppressed. Replay events always carry a seq (the desktop assigns
// one), so the guard is effective on the reconnect path.
export function seenKeyForEvent(event: {
notificationId?: string
notificationSeq?: number
}): string | null {
const id = event.notificationId
if (id != null && event.notificationSeq != null) {
return `id:${id}#${event.notificationSeq}`
}
if (id != null) {
return `id:${id}`
}
if (event.notificationSeq != null) {
return `seq:${event.notificationSeq}`
}
return null
}
@@ -1,201 +0,0 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import * as Notifications from 'expo-notifications'
import { subscribeToDesktopNotifications } from './mobile-notifications'
import { resetHostNotificationSessionsForTests } from './notification-reconnect-catchup'
import AsyncStorage from '@react-native-async-storage/async-storage'
import type { RpcClient } from '../transport/rpc-client'
import { loadPushNotificationsEnabled } from '../storage/preferences'
vi.mock('expo-notifications', () => ({
AndroidImportance: { HIGH: 'high' },
setNotificationChannelAsync: vi.fn(),
getPermissionsAsync: vi.fn(),
requestPermissionsAsync: vi.fn(),
scheduleNotificationAsync: vi.fn(),
dismissNotificationAsync: vi.fn()
}))
vi.mock('react-native', () => ({
Platform: { OS: 'ios', Version: 18 }
}))
// In-memory AsyncStorage so the persisted watermark survives across the
// subscribe/unsubscribe cycles this test exercises (the real device behaviour).
const storage = new Map<string, string>()
vi.mock('@react-native-async-storage/async-storage', () => ({
default: {
getItem: vi.fn(async (k: string) => storage.get(k) ?? null),
setItem: vi.fn(async (k: string, v: string) => {
storage.set(k, v)
})
}
}))
vi.mock('../storage/preferences', () => ({
loadPushNotificationsEnabled: vi.fn()
}))
function flushAsync(): Promise<void> {
return new Promise((resolve) => {
setTimeout(resolve, 10)
})
}
// Models mobile/app/index.tsx:497-537: a per-host client whose notification
// subscription is torn down on any non-'connected' state and re-created from
// scratch on the next 'connected'.
function makeHostClient() {
let onData: ((data: unknown) => void) | null = null
const getMissedCalls: { lastSeenSeq: number }[] = []
const client = {
subscribe: vi.fn((_m: string, _p: unknown, cb: (data: unknown) => void) => {
onData = cb
return vi.fn(() => {
onData = null
})
}),
getState: vi.fn(() => 'connected'),
sendRequest: vi.fn(async (method: string, params: unknown = {}) => {
if (method === 'notifications.getMissedSince') {
getMissedCalls.push(params as { lastSeenSeq: number })
return { ok: true, result: { notifications: missedQueue } } as never
}
return { ok: true, result: undefined } as never
})
}
let missedQueue: unknown[] = []
return {
client: client as unknown as RpcClient,
get onData() {
return onData
},
getMissedCalls,
setMissed(events: unknown[]) {
missedQueue = events
}
}
}
describe('#8591 reconnect catch-up under the real app teardown lifecycle', () => {
beforeEach(() => {
vi.clearAllMocks()
storage.clear()
resetHostNotificationSessionsForTests()
vi.mocked(loadPushNotificationsEnabled).mockResolvedValue(true)
vi.mocked(Notifications.getPermissionsAsync).mockResolvedValue({
status: 'granted',
canAskAgain: true
} as never)
vi.mocked(Notifications.scheduleNotificationAsync).mockResolvedValue('sched-1')
vi.mocked(Notifications.dismissNotificationAsync).mockResolvedValue(undefined)
vi.mocked(AsyncStorage.getItem).mockClear()
})
it('fetches missed notifications after a disconnect tears the subscription down', async () => {
const host = makeHostClient()
// ── Connected: cold open, one live notification delivered (desktop seq 7).
const unsub = subscribeToDesktopNotifications(host.client, 'host-1')
host.onData?.({ type: 'ready', subscriptionId: 'sub-1' })
await flushAsync()
host.onData?.({
type: 'notification',
title: 'live',
body: 'b',
notificationId: 'agent:live',
notificationSeq: 7
})
await flushAsync()
// ── Socket drops. app/index.tsx wireUp() calls unsubNotif() on the
// non-'connected' state, destroying the subscribeToDesktopNotifications
// closure (and with it reconnectReadyCount / lastDeliveredSeq).
unsub()
await flushAsync()
// ── While disconnected the desktop dispatched seq 8 and 9.
host.setMissed([
{
type: 'notification',
title: 'missed-8',
body: 'b',
notificationId: 'agent:m8',
notificationSeq: 8
},
{
type: 'notification',
title: 'missed-9',
body: 'b',
notificationId: 'agent:m9',
notificationSeq: 9
}
])
// ── Reconnected: app re-subscribes with a FRESH closure.
subscribeToDesktopNotifications(host.client, 'host-1')
host.onData?.({ type: 'ready', subscriptionId: 'sub-2' })
await flushAsync()
// The user must be told about seq 8 and 9. Nothing else can deliver them:
// the desktop only fans out live, so this catch-up is the only path.
expect(host.getMissedCalls).toHaveLength(1)
expect(host.getMissedCalls[0]).toEqual({ lastSeenSeq: 7 })
const titles = vi
.mocked(Notifications.scheduleNotificationAsync)
.mock.calls.map((c) => (c[0] as { content: { title: string } }).content.title)
expect(titles).toContain('missed-8')
expect(titles).toContain('missed-9')
})
it('does not re-push a live notification the catch-up replays after a teardown', async () => {
// Why: the seen-set lives on the host session precisely so it survives the teardown.
// getMissedSince cuts by seq > lastSeenSeq, but a notification delivered live in the
// brief window before the drop is still inside the desktop's retained buffer, so the
// reconnect fetch returns it again. Only the session-scoped seen-set stops a duplicate
// banner for something the user was already shown.
const host = makeHostClient()
const unsub = subscribeToDesktopNotifications(host.client, 'host-1')
host.onData?.({ type: 'ready', subscriptionId: 'sub-1' })
await flushAsync()
host.onData?.({
type: 'notification',
title: 'live-7',
body: 'b',
notificationId: 'agent:seven',
notificationSeq: 7
})
await flushAsync()
unsub()
await flushAsync()
// The desktop replays seq 7 alongside the genuinely-missed seq 8.
host.setMissed([
{
type: 'notification',
title: 'live-7',
body: 'b',
notificationId: 'agent:seven',
notificationSeq: 7
},
{
type: 'notification',
title: 'missed-8',
body: 'b',
notificationId: 'agent:m8',
notificationSeq: 8
}
])
subscribeToDesktopNotifications(host.client, 'host-1')
host.onData?.({ type: 'ready', subscriptionId: 'sub-2' })
await flushAsync()
const titles = vi
.mocked(Notifications.scheduleNotificationAsync)
.mock.calls.map((c) => (c[0] as { content: { title: string } }).content.title)
expect(titles.filter((title) => title === 'live-7')).toHaveLength(1)
expect(titles).toContain('missed-8')
})
})
@@ -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(
@@ -88,3 +69,11 @@ describe('notification routing', () => {
expect(notificationCredentialRecoveryRoute(target!)).toBeNull()
})
})
it('preserves the originating pane in the workspace route', () => {
const paneKey = 'tab-b:11111111-1111-4111-8111-111111111111'
expect(
getNotificationNavigationTarget({ hostId: 'host', worktreeId: 'folder:/work', paneKey })
?.sessionTarget?.params
).toEqual({ hostId: 'host', worktreeId: 'folder:/work', paneKey })
})
@@ -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<string>
credentialStatusByHostId?: ReadonlyMap<string, HostCredentialStatus>
@@ -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/<id>` push is shallow enough to need no host-stack coordination. */
export type NotificationNavigationTarget = Readonly<{
@@ -81,7 +49,13 @@ export function getNotificationNavigationTarget(
const credentialStatus = options.credentialStatusByHostId?.get(hostId)
return {
hostId,
sessionTarget: worktreeId ? mobileSessionRouteTarget({ hostId, worktreeId }) : null,
sessionTarget: worktreeId
? mobileSessionRouteTarget({
hostId,
worktreeId,
paneKey: readNonEmptyString(record.paneKey) ?? undefined
})
: null,
...(credentialStatus === 'missing'
? { credentialRecovery: 're-pair' as const }
: credentialStatus === 'temporarily-unavailable'
@@ -0,0 +1,19 @@
import { AppState } from 'react-native'
let viewing: { hostId: string; worktreeId: string } | null = null
export function setNotificationViewingWorkspace(value: typeof viewing): void {
viewing = value
}
export function shouldSuppressNotificationWhileViewing(
event: { worktreeId?: string },
hostId: string,
suppressWhileViewing: boolean
): boolean {
return (
suppressWhileViewing &&
AppState.currentState === 'active' &&
viewing?.hostId === hostId &&
viewing.worktreeId === event.worktreeId
)
}
@@ -1,206 +0,0 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import * as Notifications from 'expo-notifications'
import { subscribeToDesktopNotifications } from './mobile-notifications'
import {
adoptNotificationEpoch,
clearWatermark,
getHostNotificationSession,
resetHostNotificationSessionsForTests,
seedWatermarkFromStorage
} from './notification-reconnect-catchup'
import AsyncStorage from '@react-native-async-storage/async-storage'
import type { RpcClient } from '../transport/rpc-client'
import { loadPushNotificationsEnabled } from '../storage/preferences'
vi.mock('expo-notifications', () => ({
AndroidImportance: { HIGH: 'high' },
setNotificationChannelAsync: vi.fn(),
getPermissionsAsync: vi.fn(),
requestPermissionsAsync: vi.fn(),
scheduleNotificationAsync: vi.fn(),
dismissNotificationAsync: vi.fn()
}))
vi.mock('react-native', () => ({
Platform: { OS: 'ios', Version: 18 }
}))
// A storage whose reads can be held open, so a live event can be injected into the
// exact window a real cold open has: subscription up, persisted watermark not yet read.
const storage = new Map<string, string>()
let heldReads: (() => void)[] = []
let holdReads = false
vi.mock('@react-native-async-storage/async-storage', () => ({
default: {
getItem: vi.fn((key: string) => {
const read = (): string | null => storage.get(key) ?? null
if (!holdReads) {
return Promise.resolve(read())
}
return new Promise<string | null>((resolve) => {
heldReads.push(() => resolve(read()))
})
}),
setItem: vi.fn(async (key: string, value: string) => {
storage.set(key, value)
}),
removeItem: vi.fn(async (key: string) => {
storage.delete(key)
})
}
}))
vi.mock('../storage/preferences', () => ({
loadPushNotificationsEnabled: vi.fn()
}))
function flushAsync(): Promise<void> {
return new Promise((resolve) => {
setTimeout(resolve, 10)
})
}
function releaseReads(): void {
const pending = heldReads
heldReads = []
for (const resolve of pending) {
resolve()
}
}
function makeHostClient() {
let onData: ((data: unknown) => void) | null = null
const getMissedCalls: { lastSeenSeq: number; epoch?: string }[] = []
const client = {
subscribe: vi.fn((_m: string, _p: unknown, cb: (data: unknown) => void) => {
onData = cb
return vi.fn(() => {
onData = null
})
}),
getState: vi.fn(() => 'connected'),
sendRequest: vi.fn(async (method: string, params: unknown = {}) => {
if (method === 'notifications.getMissedSince') {
getMissedCalls.push(params as { lastSeenSeq: number; epoch?: string })
return { ok: true, result: { notifications: [] } } as never
}
return { ok: true, result: undefined } as never
})
}
return {
client: client as unknown as RpcClient,
get onData() {
return onData
},
getMissedCalls
}
}
const WATERMARK_KEY = 'orca:mobileNotificationsWatermark:host-1'
const LEGACY_KEY = 'orca:mobileNotificationsLastSeq:host-1'
describe('#8591 watermark seeding races a cold open', () => {
beforeEach(() => {
vi.clearAllMocks()
storage.clear()
heldReads = []
holdReads = false
resetHostNotificationSessionsForTests()
vi.mocked(loadPushNotificationsEnabled).mockResolvedValue(true)
vi.mocked(Notifications.getPermissionsAsync).mockResolvedValue({
status: 'granted',
canAskAgain: true
} as never)
vi.mocked(Notifications.scheduleNotificationAsync).mockResolvedValue('sched-1')
vi.mocked(Notifications.dismissNotificationAsync).mockResolvedValue(undefined)
})
it('asks for catch-up from the persisted seq even if a live event lands first', async () => {
// The window is real: app/index.tsx subscribes immediately, and the desktop's
// 'ready' plus its first live fan-out can both beat an AsyncStorage read. If the
// live seq is allowed to advance the watermark first, getMissedSince is asked to
// start from it and the desktop cuts everything the device actually missed.
storage.set(WATERMARK_KEY, JSON.stringify({ seq: 5, epoch: 'epoch-a' }))
holdReads = true
const host = makeHostClient()
subscribeToDesktopNotifications(host.client, 'host-1')
host.onData?.({ type: 'ready', subscriptionId: 'sub-1', epoch: 'epoch-a' })
host.onData?.({
type: 'notification',
title: 'live-12',
body: 'b',
notificationId: 'agent:live',
notificationSeq: 12,
notificationEpoch: 'epoch-a'
})
await flushAsync()
// Nothing may be decided while the read is outstanding.
expect(host.getMissedCalls).toHaveLength(0)
releaseReads()
await flushAsync()
expect(host.getMissedCalls).toEqual([{ lastSeenSeq: 5, epoch: 'epoch-a' }])
})
it('treats a zeroed-but-present watermark as a returning device, not a first pairing', async () => {
// adoptNotificationEpoch persists {seq: 0, epoch} when it voids a watermark from a
// dead counter. That record still proves this device has been subscribed here, so a
// cold open after it must catch up — reading it as "never paired" drops the window.
storage.set(WATERMARK_KEY, JSON.stringify({ seq: 0, epoch: 'epoch-a' }))
const host = makeHostClient()
subscribeToDesktopNotifications(host.client, 'host-1')
host.onData?.({ type: 'ready', subscriptionId: 'sub-1', epoch: 'epoch-a' })
await flushAsync()
expect(host.getMissedCalls).toEqual([{ lastSeenSeq: 0, epoch: 'epoch-a' }])
})
it('does not catch up on a first-ever pairing', async () => {
const host = makeHostClient()
subscribeToDesktopNotifications(host.client, 'host-1')
host.onData?.({ type: 'ready', subscriptionId: 'sub-1', epoch: 'epoch-a' })
await flushAsync()
expect(host.getMissedCalls).toEqual([])
})
it('a seed landing after a live epoch is adopted cannot reinstate the dead watermark', async () => {
// Ordering invariant on the exported pair, not a path subscribeToDesktopNotifications
// can currently take — 'ready' awaits watermarkSeeded before adopting, so the seed
// always resolves first today. Pinned anyway because the guard is load-bearing the
// moment any caller adopts an epoch before seeding: applying a seq 40 from a counter
// that no longer exists would let getMissedSince cut the new counter's 1..40, which
// is the original #8591 loss re-entered through the seeding path.
const session = getHostNotificationSession('host-1')
adoptNotificationEpoch(session, 'host-1', 'epoch-new')
await flushAsync()
storage.set(WATERMARK_KEY, JSON.stringify({ seq: 40, epoch: 'epoch-old' }))
seedWatermarkFromStorage(session, 'host-1')
await session.watermarkSeeded
await flushAsync()
expect(session.lastDeliveredEpoch).toBe('epoch-new')
expect(session.lastDeliveredSeq).toBe(0)
})
it('clears the legacy seq key too, so an unpaired host cannot resurrect it', async () => {
// loadWatermark falls back to the legacy key, so leaving it behind lets a re-paired
// host read a pre-#8591 seq belonging to a counter lifetime that no longer exists.
storage.set(WATERMARK_KEY, JSON.stringify({ seq: 9, epoch: 'epoch-a' }))
storage.set(LEGACY_KEY, '57')
await clearWatermark('host-1')
expect(vi.mocked(AsyncStorage.removeItem).mock.calls.map((call) => call[0])).toEqual(
expect.arrayContaining([WATERMARK_KEY, LEGACY_KEY])
)
expect(storage.has(WATERMARK_KEY)).toBe(false)
expect(storage.has(LEGACY_KEY)).toBe(false)
})
})
@@ -0,0 +1,40 @@
import { expect, it, vi } from 'vitest'
const state = vi.hoisted(() => ({ task: null as null | ((input: unknown) => Promise<void>) }))
vi.mock('expo-task-manager', () => ({
defineTask: (_name: string, task: typeof state.task) => {
state.task = task
},
isAvailableAsync: async () => true
}))
vi.mock('expo-notifications', () => ({ registerTaskAsync: vi.fn() }))
vi.mock('./push-tray-dismissal', () => ({ dismissPresentedPushNotification: vi.fn() }))
import { dismissPresentedPushNotification } from './push-tray-dismissal'
import { registerPushDismissalTask } from './push-background-dismissal'
it('handles native background JSON and scopes dismissal to the originating host', async () => {
await registerPushDismissalTask()
await state.task!({
data: {
data: {
dataString: JSON.stringify({
kind: 'dismiss',
hostFingerprint: 'host-a',
notificationId: 'same-id'
})
}
}
})
expect(dismissPresentedPushNotification).toHaveBeenCalledWith(
'same-id',
'host-a',
expect.objectContaining({ kind: 'dismiss' })
)
})
it('does not turn ordinary alerts into dismissals', async () => {
vi.mocked(dismissPresentedPushNotification).mockClear()
await state.task!({
data: { data: { orca: { hostFingerprint: 'host-a', notificationId: 'same-id' } } }
})
expect(dismissPresentedPushNotification).not.toHaveBeenCalled()
})
@@ -0,0 +1,41 @@
import { wasPushDismissed } from './push-dismissal-watermarks'
import * as TaskManager from 'expo-task-manager'
import * as Notifications from 'expo-notifications'
import { readOrcaPushPayload } from './push-payload'
import { dismissPresentedPushNotification } from './push-tray-dismissal'
const TASK_NAME = 'orca-push-dismissal'
TaskManager.defineTask<Notifications.NotificationTaskPayload>(
TASK_NAME,
async ({ data, error }) => {
if (error || !data || 'actionIdentifier' in data) {
return
}
let raw: unknown = data.data
if (typeof data.data.dataString === 'string') {
try {
raw = JSON.parse(data.data.dataString)
} catch {
return
}
}
const payload = readOrcaPushPayload(raw)
if (
payload?.notificationId &&
(payload.kind === 'dismiss' || (await wasPushDismissed(payload)))
) {
await dismissPresentedPushNotification(
payload.notificationId,
payload.hostFingerprint,
payload
)
}
}
)
export async function registerPushDismissalTask(): Promise<void> {
if (await TaskManager.isAvailableAsync()) {
await Notifications.registerTaskAsync(TASK_NAME)
}
}
@@ -0,0 +1,128 @@
import { beforeEach, expect, it, vi } from 'vitest'
import AsyncStorage from '@react-native-async-storage/async-storage'
import { nativePushDismissal } from './native-push-dismissal'
import { rememberPushDismissal, wasPushDismissed } from './push-dismissal-watermarks'
import { foregroundNotificationBehavior } from './push-receive'
import { loadNotificationDeliveryPreferences } from './notification-delivery-preferences'
const memory = vi.hoisted(() => new Map<string, string>())
const nativeLedger = vi.hoisted(() => new Map<string, number>())
vi.mock('./native-push-dismissal', () => ({
nativePushDismissal: {
remember: vi.fn(async (payload) => {
const key = JSON.stringify([
payload.hostFingerprint,
payload.notificationEpoch,
payload.notificationId
])
nativeLedger.set(key, Math.max(nativeLedger.get(key) ?? 0, payload.notificationSeq))
}),
wasDismissed: vi.fn(
async (payload) =>
(nativeLedger.get(
JSON.stringify([
payload.hostFingerprint,
payload.notificationEpoch,
payload.notificationId
])
) ?? -1) >= payload.notificationSeq
)
}
}))
vi.mock('@react-native-async-storage/async-storage', () => ({
default: {
getItem: vi.fn(async (key: string) => memory.get(key) ?? null),
setItem: vi.fn(async (key: string, value: string) => {
memory.set(key, value)
})
}
}))
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
}))
vi.mock('./notification-viewing-policy', () => ({
shouldSuppressNotificationWhileViewing: () => false
}))
vi.mock('./notification-delivery-preferences', () => ({
loadNotificationDeliveryPreferences: vi.fn(async () => ({ sound: true }))
}))
const payload = {
hostFingerprint: 'abcdefghijklmnop',
notificationEpoch: 'epoch',
notificationId: 'note',
notificationSeq: 20
}
const fence = { ...payload, notificationSeq: 21 }
beforeEach(() => {
vi.clearAllMocks()
vi.mocked(AsyncStorage.getItem).mockImplementation(async (key) => memory.get(key) ?? null)
memory.clear()
nativeLedger.clear()
})
it('uses only native storage for iOS dismissal reads and writes', async () => {
await rememberPushDismissal(fence)
expect(await wasPushDismissed(payload)).toBe(true)
expect(await wasPushDismissed({ ...payload, notificationSeq: 22 })).toBe(false)
expect(await wasPushDismissed({ ...payload, notificationEpoch: 'new-epoch' })).toBe(false)
expect(AsyncStorage.getItem).not.toHaveBeenCalled()
expect(AsyncStorage.setItem).not.toHaveBeenCalled()
})
it('rechecks a negative native snapshot overtaken by a dismissal', async () => {
let finish!: () => void
vi.mocked(nativePushDismissal!.wasDismissed).mockImplementationOnce(async () => {
await new Promise<void>((resolve) => {
finish = resolve
})
return false
})
const pending = wasPushDismissed(payload)
await vi.waitFor(() => expect(finish).toBeDefined())
await rememberPushDismissal(fence)
finish()
expect(await pending).toBe(true)
expect(nativePushDismissal!.wasDismissed).toHaveBeenCalledTimes(2)
})
it('surfaces native write failures without switching storage or poisoning later operations', async () => {
vi.mocked(nativePushDismissal!.remember).mockRejectedValueOnce(new Error('native failure'))
await expect(rememberPushDismissal(fence)).rejects.toThrow('native failure')
expect(AsyncStorage.setItem).not.toHaveBeenCalled()
await rememberPushDismissal(fence)
expect(await wasPushDismissed(payload)).toBe(true)
})
it('suppresses presentation when dismissal completes during the handler sound read', async () => {
let finish!: () => void
vi.mocked(loadNotificationDeliveryPreferences).mockImplementationOnce(async () => {
await new Promise<void>((resolve) => {
finish = resolve
})
return { sound: true } as Awaited<ReturnType<typeof loadNotificationDeliveryPreferences>>
})
const pending = foregroundNotificationBehavior({
request: {
identifier: 'foreground-alert',
trigger: null,
content: { title: null, subtitle: null, body: null, sound: null, data: { orca: payload } }
}
})
await vi.waitFor(() => expect(finish).toBeDefined())
await foregroundNotificationBehavior({
request: { content: { data: { orca: { ...fence, kind: 'dismiss' } } } }
})
expect(await wasPushDismissed(payload)).toBe(true)
finish()
expect(await pending).toEqual({
shouldShowBanner: false,
shouldShowList: false,
shouldPlaySound: false,
shouldSetBadge: false
})
})
@@ -0,0 +1,145 @@
import { beforeEach, expect, it, vi } from 'vitest'
import * as Notifications from 'expo-notifications'
import { loadHostCatalog } from '../transport/host-store'
import { deriveHostFingerprint } from './push-host-fingerprint'
import { requestNotificationCatchup } from './push-dismissal-reconciliation'
vi.mock('../transport/host-store', () => ({ loadHostCatalog: vi.fn() }))
vi.mock('expo-notifications', () => ({
getPresentedNotificationsAsync: vi.fn(),
dismissNotificationAsync: vi.fn()
}))
vi.mock('@react-native-async-storage/async-storage', () => ({
default: { getItem: async () => null, setItem: async () => {} }
}))
const publicKeyB64 = Buffer.alloc(32, 1).toString('base64')
const hostFingerprint = deriveHostFingerprint(publicKeyB64)
const id = {
notificationId: 'old-alert',
notificationEpoch: 'previous-host-process',
notificationSeq: 12
}
function presented(identifier: string, overrides = {}) {
return { request: { identifier, content: { data: { hostFingerprint, ...id, ...overrides } } } }
}
beforeEach(() => {
vi.clearAllMocks()
vi.mocked(loadHostCatalog).mockResolvedValue([{ id: 'host-a', publicKeyB64 }] as never)
vi.mocked(Notifications.getPresentedNotificationsAsync).mockResolvedValue([
presented('old'),
presented('new', { notificationSeq: 14 }),
presented('other', { hostFingerprint: 'other-host' })
] as never)
vi.mocked(Notifications.dismissNotificationAsync).mockResolvedValue(undefined)
})
it('clears a confirmed prior-epoch alert even with empty replay and preserves newer and other-host entries', async () => {
const sendRequest = vi.fn(async () => ({
ok: true,
result: { notifications: [], epoch: 'new-process', dismissedPushes: [id] }
}))
await requestNotificationCatchup({ sendRequest } as never, 'host-a', () => false)
expect(sendRequest).toHaveBeenCalledWith('notifications.getMissedSince', {
lastSeenSeq: Number.MAX_SAFE_INTEGER,
deliveredPushes: [id, { ...id, notificationSeq: 14 }]
})
expect(Notifications.dismissNotificationAsync).toHaveBeenCalledExactlyOnceWith('old')
})
it('keeps alerts when an old host omits reconciliation or the request fails', async () => {
for (const response of [{ ok: true, result: { notifications: [] } }, { ok: false }]) {
await requestNotificationCatchup(
{ sendRequest: async () => response } as never,
'host-a',
() => 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 },
{ ...id, notificationEpoch: 'different-epoch' },
{ ...id, notificationId: 'different-alert' }
]
}
}))
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('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}`,
notificationEpoch: 'previous-host-process',
notificationSeq: index
}))
vi.mocked(Notifications.getPresentedNotificationsAsync).mockResolvedValue(
all.map((payload) => presented(payload.notificationId, payload)) as never
)
const sendRequest = vi.fn(async (_method: string, params: { deliveredPushes?: typeof all }) => ({
ok: true,
result: { notifications: [], dismissedPushes: params.deliveredPushes ?? [] }
}))
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,
deliveredPushes: all
.slice(256)
.map(({ notificationId, notificationEpoch, notificationSeq }) => ({
notificationId,
notificationEpoch,
notificationSeq
}))
})
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')
}
)
@@ -0,0 +1,81 @@
import * as Notifications from 'expo-notifications'
import type { RpcClient } from '../transport/rpc-client'
import { loadHostCatalog } from '../transport/host-store'
import { resolveHostIdForFingerprint } from './push-host-fingerprint'
import { readNativeNotificationData } from './native-notification-data'
import { readOrcaPushPayload, type OrcaPushPayload } from './push-payload'
import { dismissRememberedPushNotifications } from './push-tray-dismissal'
import { rememberPushDismissal } from './push-dismissal-watermarks'
import {
readPushNotificationIdentity,
type PushNotificationIdentity
} from './push-notification-identity'
const key = (item: PushNotificationIdentity) =>
JSON.stringify([item.notificationId, item.notificationEpoch, item.notificationSeq])
async function readDelivered(hostId: string): Promise<Map<string, OrcaPushPayload>> {
const selected = new Map<string, OrcaPushPayload>()
try {
const [presented, hosts] = await Promise.all([
Notifications.getPresentedNotificationsAsync(),
loadHostCatalog()
])
for (const notification of presented) {
const payload = readOrcaPushPayload(readNativeNotificationData(notification.request))
if (!payload || resolveHostIdForFingerprint(payload.hostFingerprint, hosts) !== hostId) {
continue
}
const identity = readPushNotificationIdentity(payload)
if (identity && selected.size < 2048) {
selected.set(key(identity), payload)
}
if (selected.size === 2048) {
break
}
}
} catch {
// Tray inspection is best-effort; failure leaves OS banners for later reconciliation.
}
return selected
}
export async function requestNotificationCatchup(
client: Pick<RpcClient, 'sendRequest'>,
hostId: string,
isDisposed: () => boolean
): Promise<void> {
const entries = [...(await readDelivered(hostId)).entries()]
for (let offset = 0; offset < entries.length && !isDisposed(); offset += 256) {
const requested = new Map(entries.slice(offset, offset + 256))
const reply = await client.sendRequest('notifications.getMissedSince', {
// Reconcile the tray without requesting historical alerts.
lastSeenSeq: Number.MAX_SAFE_INTEGER,
deliveredPushes: [...requested.values()].map((payload) =>
readPushNotificationIdentity(payload)!
)
})
if (!reply.ok || isDisposed()) {
return
}
const result = reply.result as { dismissedPushes?: unknown } | undefined
if (!Array.isArray(result?.dismissedPushes)) {
continue
}
const confirmed: OrcaPushPayload[] = []
for (const raw of result.dismissedPushes.slice(0, 256)) {
if (isDisposed()) {
break
}
const id = readPushNotificationIdentity(raw)
const payload = id ? requested.get(key(id)) : undefined
if (payload && id) {
await rememberPushDismissal(payload)
confirmed.push(payload)
requested.delete(key(id))
}
}
if (confirmed.length && !isDisposed()) {
await dismissRememberedPushNotifications(confirmed[0]!.hostFingerprint, confirmed)
}
}
}
@@ -0,0 +1,93 @@
import { beforeEach, expect, it, vi } from 'vitest'
import AsyncStorage from '@react-native-async-storage/async-storage'
const storage = vi.hoisted(() => new Map<string, string>())
vi.mock('@react-native-async-storage/async-storage', () => ({
default: {
getItem: vi.fn(async (key: string) => storage.get(key) ?? null),
setItem: async (key: string, value: string) => {
storage.set(key, value)
}
}
}))
import { rememberPushDismissal, wasPushDismissed } from './push-dismissal-watermarks'
const payload = {
hostFingerprint: 'host-a',
notificationEpoch: 'epoch-a',
notificationId: 'note',
notificationSeq: 2
}
beforeEach(() => {
storage.clear()
vi.mocked(AsyncStorage.getItem)
.mockReset()
.mockImplementation(async (key) => storage.get(key) ?? null)
vi.useRealTimers()
})
it('persists dismissal through restart while preserving newer alerts and other hosts or epochs', async () => {
await rememberPushDismissal(payload)
vi.resetModules()
const restarted = await import('./push-dismissal-watermarks')
expect(await restarted.wasPushDismissed({ ...payload, notificationSeq: 1 })).toBe(true)
expect(await restarted.wasPushDismissed({ ...payload, notificationSeq: 3 })).toBe(false)
expect(await restarted.wasPushDismissed({ ...payload, hostFingerprint: 'host-b' })).toBe(false)
expect(await restarted.wasPushDismissed({ ...payload, notificationEpoch: 'epoch-b' })).toBe(false)
})
it('serializes concurrent dismissals and never lowers a watermark', async () => {
await Promise.all([
rememberPushDismissal({ ...payload, notificationSeq: 5 }),
rememberPushDismissal(payload),
rememberPushDismissal({ ...payload, notificationId: 'other' })
])
expect(await wasPushDismissed({ ...payload, notificationSeq: 5 })).toBe(true)
expect(await wasPushDismissed({ ...payload, notificationId: 'other' })).toBe(true)
})
it('expires retained metadata and ignores unversioned dismissals', async () => {
vi.useFakeTimers()
await rememberPushDismissal(payload)
vi.setSystemTime(Date.now() + 24 * 60 * 60 * 1000)
expect(await wasPushDismissed(payload)).toBe(false)
await rememberPushDismissal({ ...payload, notificationEpoch: undefined })
expect(await wasPushDismissed(payload)).toBe(false)
})
it('joins an overtaking JavaScript write before retrying a delayed negative snapshot', async () => {
let finish!: () => void
vi.mocked(AsyncStorage.getItem).mockImplementationOnce(async (key) => {
const snapshot = storage.get(key) ?? null
await new Promise<void>((resolve) => {
finish = resolve
})
return snapshot
})
const pending = wasPushDismissed(payload)
await vi.waitFor(() => expect(finish).toBeDefined())
await rememberPushDismissal(payload)
finish()
expect(await pending).toBe(true)
expect(AsyncStorage.getItem).toHaveBeenCalledTimes(3)
expect(await wasPushDismissed({ ...payload, notificationSeq: 3 })).toBe(false)
})
it.each([1, 3])('retains live dismissals beyond 512 entries across %i hosts', async (hosts) => {
for (let index = 0; index < 520; index++) {
await rememberPushDismissal({
...payload,
hostFingerprint: `host-${index % hosts}`,
notificationId: `note-${index}`
})
}
vi.resetModules()
const restarted = await import('./push-dismissal-watermarks')
for (const index of [0, 1, 519]) {
const alert = {
...payload,
hostFingerprint: `host-${index % hosts}`,
notificationId: `note-${index}`
}
expect(await restarted.wasPushDismissed(alert)).toBe(true)
expect(await restarted.wasPushDismissed({ ...alert, notificationSeq: 3 })).toBe(false)
}
})
@@ -0,0 +1,104 @@
import AsyncStorage from '@react-native-async-storage/async-storage'
import type { OrcaPushPayload } from './push-payload'
import { nativePushDismissal } from './native-push-dismissal'
const STORAGE_KEY = 'orca:pushDismissalWatermarks:v1'
// Keep every live fence: count-based eviction lets delayed alerts reappear.
const RETENTION_MS = 24 * 60 * 60 * 1000
type Entry = { key: string; seq: number; expiresAt: number }
let writes: Promise<void> = Promise.resolve()
function queueDismissalOperation<T>(operation: () => Promise<T>): Promise<T> {
const pending = writes.then(operation)
writes = pending.then(
() => {},
() => {}
)
return pending
}
function eventKey(payload: OrcaPushPayload): string | null {
if (
!payload.notificationId ||
!payload.notificationEpoch ||
!Number.isSafeInteger(payload.notificationSeq) ||
payload.notificationSeq! < 0
) {
return null
}
return JSON.stringify([
payload.hostFingerprint,
payload.notificationEpoch,
payload.notificationId
])
}
async function readEntries(): Promise<Entry[]> {
try {
const raw: unknown = JSON.parse((await AsyncStorage.getItem(STORAGE_KEY)) ?? '[]')
if (!Array.isArray(raw)) {
return []
}
return raw.filter(
(entry): entry is Entry =>
entry !== null &&
typeof entry === 'object' &&
typeof entry.key === 'string' &&
Number.isSafeInteger(entry.seq) &&
entry.seq >= 0 &&
Number.isFinite(entry.expiresAt) &&
entry.expiresAt > Date.now()
)
} catch {
return []
}
}
export async function rememberPushDismissal(payload: OrcaPushPayload): Promise<void> {
const key = eventKey(payload)
if (!key) {
return
}
return queueDismissalOperation(async () => {
if (nativePushDismissal) {
await nativePushDismissal.remember(payload)
return
}
const entries = await readEntries()
const previous = entries.find((entry) => entry.key === key)
const entry = {
key,
seq: Math.max(previous?.seq ?? 0, payload.notificationSeq!),
expiresAt: Date.now() + RETENTION_MS
}
await AsyncStorage.setItem(
STORAGE_KEY,
JSON.stringify([...entries.filter((item) => item.key !== key), entry])
)
})
}
async function readDismissal(payload: OrcaPushPayload, key: string): Promise<boolean> {
if (nativePushDismissal) {
return nativePushDismissal.wasDismissed(payload)
}
return (await readEntries()).some(
(entry) => entry.key === key && entry.seq >= payload.notificationSeq!
)
}
export async function wasPushDismissed(payload: OrcaPushPayload): Promise<boolean> {
const key = eventKey(payload)
if (!key) {
return false
}
const precedingWrites = writes
await precedingWrites
const dismissed = await readDismissal(payload, key)
if (dismissed || writes === precedingWrites) {
return dismissed
}
// An overtaking write invalidates a negative snapshot; one queued read cannot be overtaken again.
return queueDismissalOperation(() => readDismissal(payload, key))
}
@@ -0,0 +1,62 @@
import { describe, expect, it } from 'vitest'
import { sha256 } from '@noble/hashes/sha256'
import { deriveHostFingerprint, resolveHostIdForFingerprint } from './push-host-fingerprint'
// Why Buffer here: it computes the same value through a completely different
// base64 path than the module's btoa/replace, so the vector is a real cross-check
// of the derivation the desktop and gateway independently perform.
function expectedFingerprint(publicKey: Uint8Array): string {
return Buffer.from(sha256(publicKey)).toString('base64url').slice(0, 16)
}
const publicKey = Uint8Array.from({ length: 32 }, (_, index) => index)
const publicKeyB64 = Buffer.from(publicKey).toString('base64')
describe('deriveHostFingerprint', () => {
it('matches base64url(sha256(publicKey)) truncated to 16 chars', () => {
const fingerprint = deriveHostFingerprint(publicKeyB64)
expect(fingerprint).toBe(expectedFingerprint(publicKey))
expect(fingerprint).toHaveLength(16)
})
it('produces url-safe characters only, so a fingerprint survives a JSON payload', () => {
// 0xff bytes are what push '+' and '/' into a standard base64 digest.
const dense = new Uint8Array(32).fill(0xff)
const fingerprint = deriveHostFingerprint(Buffer.from(dense).toString('base64'))
expect(fingerprint).toBe(expectedFingerprint(dense))
expect(fingerprint).toMatch(/^[A-Za-z0-9_-]{16}$/)
})
it.each([
['a key of the wrong length', Buffer.from(new Uint8Array(16)).toString('base64')],
['text that is not base64 at all', '!!!not base64!!!'],
['an empty key', '']
])('returns null for %s', (_label, value) => {
expect(deriveHostFingerprint(value)).toBeNull()
})
})
describe('resolveHostIdForFingerprint', () => {
const other = Uint8Array.from({ length: 32 }, (_, index) => index + 1)
const hosts = [
{ id: 'host-corrupt', publicKeyB64: 'not-a-key' },
{ id: 'host-other', publicKeyB64: Buffer.from(other).toString('base64') },
{ id: 'host-1', publicKeyB64 }
]
it('maps a push fingerprint back to the paired host id', () => {
expect(resolveHostIdForFingerprint(expectedFingerprint(publicKey), hosts)).toBe('host-1')
})
it('returns null for a fingerprint no paired host derives', () => {
expect(resolveHostIdForFingerprint('0123456789abcdef', hosts)).toBeNull()
})
it('rejects a fingerprint of the wrong length before hashing anything', () => {
expect(
resolveHostIdForFingerprint(expectedFingerprint(publicKey).slice(0, 8), hosts)
).toBeNull()
})
})
@@ -0,0 +1,58 @@
import { sha256 } from '@noble/hashes/sha256'
// Why: a push arrives from the gateway, so it can only name the host by something
// both sides derive independently — base64url(sha256(hostPublicKey)) truncated to
// 16 chars, identical to deriveRelayHostId in
// src/main/runtime/relay/relay-http-client.ts. The phone maps it back to its own
// hostId by re-deriving over each stored host's publicKeyB64.
//
// Base64 is inlined rather than imported (same call as mobile-relay-credential-hash.ts):
// the only shared encoders live in modules that drag in tweetnacl, expo-crypto, or
// the host store, none of which a pure derivation should need.
const HOST_FINGERPRINT_LENGTH = 16
function decodeBase64(value: string): Uint8Array | null {
try {
const binary = atob(value)
const bytes = new Uint8Array(binary.length)
for (let index = 0; index < binary.length; index++) {
bytes[index] = binary.charCodeAt(index)
}
return bytes
} catch {
return null
}
}
function encodeBase64Url(bytes: Uint8Array): string {
let binary = ''
for (const byte of bytes) {
binary += String.fromCharCode(byte)
}
return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '')
}
/** Null when the stored key is unreadable, so a corrupt host entry can't shadow a real match. */
export function deriveHostFingerprint(publicKeyB64: string): string | null {
const publicKey = decodeBase64(publicKeyB64)
if (!publicKey || publicKey.length !== 32) {
return null
}
return encodeBase64Url(sha256(publicKey)).slice(0, HOST_FINGERPRINT_LENGTH)
}
export function resolveHostIdForFingerprint(
fingerprint: string,
hosts: readonly { readonly id: string; readonly publicKeyB64: string }[]
): string | null {
if (fingerprint.length !== HOST_FINGERPRINT_LENGTH) {
return null
}
for (const host of hosts) {
if (deriveHostFingerprint(host.publicKeyB64) === fingerprint) {
return host.id
}
}
return null
}
@@ -0,0 +1,25 @@
import { expect, it } from 'vitest'
import {
readPushNotificationIdentity,
type PushNotificationIdentity
} from './push-notification-identity'
it('reads a bounded individual notification identity', () => {
const identity: PushNotificationIdentity = {
notificationId: 'agent:one',
notificationEpoch: 'epoch-1',
notificationSeq: 7
}
expect(readPushNotificationIdentity(identity)).toEqual(identity)
})
it('rejects incomplete or non-integral notification identities', () => {
expect(readPushNotificationIdentity({ notificationId: 'agent:one' })).toBeNull()
expect(
readPushNotificationIdentity({
notificationId: 'agent:one',
notificationEpoch: 'epoch-1',
notificationSeq: 1.5
})
).toBeNull()
})
@@ -0,0 +1,26 @@
export type PushNotificationIdentity = {
notificationId: string
notificationEpoch: string
notificationSeq: number
}
export function readPushNotificationIdentity(value: unknown): PushNotificationIdentity | null {
if (!value || typeof value !== 'object') {
return null
}
const item = value as PushNotificationIdentity
return typeof item.notificationId === 'string' &&
item.notificationId.length > 0 &&
item.notificationId.length <= 2048 &&
typeof item.notificationEpoch === 'string' &&
item.notificationEpoch.length > 0 &&
item.notificationEpoch.length <= 128 &&
Number.isSafeInteger(item.notificationSeq) &&
item.notificationSeq >= 0
? {
notificationId: item.notificationId,
notificationEpoch: item.notificationEpoch,
notificationSeq: item.notificationSeq
}
: null
}
+43
View File
@@ -0,0 +1,43 @@
// Why two shapes: APNs nests Orca's fields under `orca` beside `aps`, while FCM
// carries them flat in `data` as strings. Both reach JS as the notification's
// `content.data`, so the reader accepts either and coerces the numeric fields.
export type OrcaPushPayload = {
readonly kind?: 'alert' | 'dismiss'
readonly hostFingerprint: string
readonly notificationId?: string
readonly notificationSeq?: number
readonly notificationEpoch?: string
readonly paneKey?: string
readonly worktreeId?: string
}
function readString(value: unknown): string | undefined {
return typeof value === 'string' && value.length > 0 ? value : undefined
}
function readSeq(value: unknown): number | undefined {
const raw = typeof value === 'number' ? value : Number(readString(value))
return Number.isFinite(raw) ? raw : undefined
}
export function readOrcaPushPayload(data: unknown): OrcaPushPayload | null {
if (!data || typeof data !== 'object') {
return null
}
const nested = (data as { orca?: unknown }).orca
const record = (nested && typeof nested === 'object' ? nested : data) as Record<string, unknown>
// The fingerprint is what makes this a gateway push; locally scheduled data never has one.
const hostFingerprint = readString(record.hostFingerprint)
if (!hostFingerprint) {
return null
}
return {
hostFingerprint,
...(record.kind === 'dismiss' || record.kind === 'alert' ? { kind: record.kind } : {}),
notificationId: readString(record.notificationId),
notificationSeq: readSeq(record.notificationSeq),
notificationEpoch: readString(record.notificationEpoch),
paneKey: readString(record.paneKey),
worktreeId: readString(record.worktreeId)
}
}
@@ -0,0 +1,86 @@
vi.mock('./desktop-notification-channel', () => ({
ensureDesktopNotificationChannel: vi.fn(async () => {})
}))
import { AppState } from 'react-native'
import { beforeEach, expect, it, vi } from 'vitest'
import {
attachPushRegistration,
resetPushRegistrationForTests,
setNotificationDeliveryPreferences,
NOTIFICATIONS_REMOTE_PUSH_CAPABILITY
} from './push-registration'
import { DEFAULT_NOTIFICATION_DELIVERY } from './notification-delivery-preferences'
const storage = new Map<string, string>()
vi.mock('@react-native-async-storage/async-storage', () => ({
default: {
getItem: vi.fn(async (key: string) => storage.get(key) ?? null),
setItem: vi.fn(async (key: string, value: string) => {
storage.set(key, value)
})
}
}))
vi.mock('react-native', () => ({
AppState: { currentState: 'active', addEventListener: vi.fn(() => ({ remove: vi.fn() })) }
}))
vi.mock('./push-token', () => ({
getDevicePushToken: vi.fn(async () => ({
platform: 'ios',
token: 'a'.repeat(64),
apnsEnvironment: 'sandbox'
})),
addPushTokenListener: vi.fn()
}))
beforeEach(() => {
AppState.currentState = 'active'
resetPushRegistrationForTests()
storage.clear()
storage.set('orca:pushServiceNotificationsEnabled', 'true')
})
it('replaces an in-flight registration with the latest away and sound preferences', async () => {
const calls: { method: string; params: unknown }[] = []
let finishFirst: ((value: unknown) => void) | undefined
const client = {
sendRequest: vi.fn(async (method: string, params?: unknown) => {
calls.push({ method, params })
if (method === 'status.get') {
return { ok: true, result: { capabilities: [NOTIFICATIONS_REMOTE_PUSH_CAPABILITY] } }
}
if (method === 'notifications.registerPush') {
if (!finishFirst) {
return new Promise((resolve) => {
finishFirst = resolve
})
}
return { ok: true, result: { registered: true, registrationId: 'new' } }
}
return { ok: true, result: { unregistered: true } }
})
}
const detach = attachPushRegistration('host', client as never)
await vi.waitFor(() => expect(finishFirst).toBeDefined())
const update = setNotificationDeliveryPreferences({
...DEFAULT_NOTIFICATION_DELIVERY,
onlyWhenDesktopAway: false,
sound: false
})
finishFirst!({ ok: true, result: { registered: true, registrationId: 'old' } })
await update
await vi.waitFor(() =>
expect(
calls.filter((call) => call.method === 'notifications.registerPush').length
).toBeGreaterThan(1)
)
const latest = calls.findLast((call) => call.method === 'notifications.registerPush')
expect(latest?.params).toMatchObject({
filter: {
onlyWhenDesktopAway: false,
sound: false
}
})
expect(calls.some((call) => call.method === 'notifications.unregisterPush')).toBe(true)
detach()
})
@@ -0,0 +1,258 @@
import AsyncStorage from '@react-native-async-storage/async-storage'
import { AppState } from 'react-native'
import { setNotificationViewingWorkspace } from './notification-viewing-policy'
vi.mock('./push-tray-dismissal', () => ({ dismissPresentedPushNotification: vi.fn() }))
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { sha256 } from '@noble/hashes/sha256'
import { loadHostCatalog } from '../transport/host-store'
import type { HostCatalogEntry } from '../transport/types'
import { getNotificationNavigationTarget } from './notification-routing'
import {
foregroundNotificationBehavior,
isRemotePushTrigger,
pushNotificationRouteData,
resetForegroundPushClaimsForTests
} from './push-receive'
async function shouldSuppressForegroundPush(data: unknown): Promise<boolean> {
return !(await foregroundNotificationBehavior({ request: { content: { data } } }))
.shouldShowBanner
}
vi.mock('react-native', () => ({ AppState: { currentState: 'background' } }))
vi.mock('../transport/host-store', () => ({ loadHostCatalog: vi.fn() }))
const storage = vi.hoisted(() => new Map<string, string>())
vi.mock('@react-native-async-storage/async-storage', () => ({
default: {
getItem: vi.fn(async (key: string) => storage.get(key) ?? null),
setItem: vi.fn(async (key: string, value: string) => storage.set(key, value))
}
}))
const publicKeyB64 = Buffer.alloc(32, 1).toString('base64')
const hostFingerprint = Buffer.from(sha256(Buffer.alloc(32, 1)))
.toString('base64url')
.slice(0, 16)
const hosts = [{ id: 'host-1', publicKeyB64 }] as unknown as HostCatalogEntry[]
const otherPublicKeyB64 = Buffer.alloc(32, 2).toString('base64')
const otherHostFingerprint = Buffer.from(sha256(Buffer.alloc(32, 2)))
.toString('base64url')
.slice(0, 16)
function apnsData(orca: Record<string, unknown>): unknown {
return { aps: { alert: { title: 'Orca', body: 'Agent needs input' } }, orca }
}
function fcmData(orca: Record<string, unknown>): unknown {
return Object.fromEntries(Object.entries(orca).map(([key, value]) => [key, String(value)]))
}
beforeEach(() => {
vi.clearAllMocks()
AppState.currentState = 'background'
setNotificationViewingWorkspace(null)
storage.clear()
storage.set('orca:pushServiceNotificationsEnabled', 'true')
resetForegroundPushClaimsForTests()
vi.mocked(loadHostCatalog).mockResolvedValue([
...hosts,
{ id: 'host-2', publicKeyB64: otherPublicKeyB64 }
] as unknown as HostCatalogEntry[])
})
describe('shouldSuppressForegroundPush', () => {
const push = () =>
apnsData({
hostFingerprint,
notificationId: 'agent:one',
notificationSeq: 7,
notificationEpoch: 'epoch-1'
})
it('allows one eligible native push and suppresses an in-process duplicate', async () => {
await expect(shouldSuppressForegroundPush(push())).resolves.toBe(false)
await expect(shouldSuppressForegroundPush(push())).resolves.toBe(true)
})
it('reads flat FCM fields and allows the first native push', async () => {
await expect(
shouldSuppressForegroundPush(
fcmData({
hostFingerprint,
notificationId: 'agent:one',
notificationSeq: 8,
notificationEpoch: 'epoch-1'
})
)
).resolves.toBe(false)
})
it('deduplicates ID-less bells by host, epoch, and valid sequence', async () => {
const bell = (overrides: Record<string, unknown> = {}) =>
apnsData({
hostFingerprint,
source: 'terminal-bell',
notificationSeq: 4,
notificationEpoch: 'epoch-1',
...overrides
})
await expect(shouldSuppressForegroundPush(bell())).resolves.toBe(false)
await expect(shouldSuppressForegroundPush(bell())).resolves.toBe(true)
await expect(shouldSuppressForegroundPush(bell({ notificationSeq: 5 }))).resolves.toBe(false)
await expect(
shouldSuppressForegroundPush(bell({ notificationEpoch: 'epoch-2' }))
).resolves.toBe(false)
await expect(
shouldSuppressForegroundPush(bell({ hostFingerprint: otherHostFingerprint }))
).resolves.toBe(false)
})
it('does not claim invalid sequence values as duplicate identities', async () => {
const invalid = apnsData({
hostFingerprint,
source: 'plugin',
notificationSeq: 1.5,
notificationEpoch: 'epoch-1'
})
await expect(shouldSuppressForegroundPush(invalid)).resolves.toBe(false)
await expect(shouldSuppressForegroundPush(invalid)).resolves.toBe(false)
})
it('suppresses pushes for an unpaired host', async () => {
vi.mocked(loadHostCatalog).mockResolvedValue([])
await expect(
shouldSuppressForegroundPush(apnsData({ hostFingerprint, notificationSeq: 1 }))
).resolves.toBe(true)
})
it('suppresses a push after a matching persisted dismissal', async () => {
const { rememberPushDismissal } = await import('./push-dismissal-watermarks')
const payload = {
hostFingerprint,
notificationId: 'dismissed',
notificationSeq: 2,
notificationEpoch: 'epoch-1'
}
await rememberPushDismissal(payload)
await expect(shouldSuppressForegroundPush(apnsData(payload))).resolves.toBe(true)
})
it('fails closed for recognized pushes when suppression checks throw', async () => {
const dismissals = await import('./push-dismissal-watermarks')
const dismissalSpy = vi
.spyOn(dismissals, 'wasPushDismissed')
.mockRejectedValueOnce(new Error('dismissal read failed'))
await expect(
foregroundNotificationBehavior({ request: { content: { data: push() } } })
).resolves.toMatchObject({ shouldShowBanner: false, shouldShowList: false })
dismissalSpy.mockRestore()
})
it('keeps unrelated notifications visible when suppression checks throw', async () => {
const dismissals = await import('./push-dismissal-watermarks')
const dismissalSpy = vi
.spyOn(dismissals, 'wasPushDismissed')
.mockRejectedValue(new Error('dismissal read failed'))
await expect(
foregroundNotificationBehavior({
request: { content: { data: { title: 'Other app notification' } } }
})
).resolves.toMatchObject({ shouldShowBanner: true, shouldShowList: true })
dismissalSpy.mockRestore()
})
})
describe('pushNotificationRouteData', () => {
it('routes a tap by mapping the fingerprint to the paired host id', () => {
const data = pushNotificationRouteData(
apnsData({ hostFingerprint, worktreeId: 'repo::/feature', source: 'agent-task-complete' }),
hosts
)
expect(getNotificationNavigationTarget(data, { knownHostIds: new Set(['host-1']) })).toEqual({
hostId: 'host-1',
sessionTarget: {
name: '[hostId]/session/[worktreeId]',
params: { hostId: 'host-1', worktreeId: 'repo::/feature' }
}
})
})
it('maps a push without a worktree to the host screen', () => {
const data = pushNotificationRouteData(
fcmData({ hostFingerprint, source: 'terminal-bell' }),
hosts
)
expect(getNotificationNavigationTarget(data)).toEqual({ hostId: 'host-1', sessionTarget: null })
})
it('keeps local data untouched and rejects an unresolvable remote fingerprint', () => {
const local = { hostId: 'host-9', source: 'agent-task-complete' }
expect(pushNotificationRouteData(local, hosts)).toBe(local)
expect(
pushNotificationRouteData(
{ hostId: 'host-1', orca: { hostFingerprint: 'unknown' } },
hosts,
true
)
).toBeNull()
})
it('recognises only provider-delivered triggers', () => {
expect(isRemotePushTrigger({ type: 'push' })).toBe(true)
expect(isRemotePushTrigger({ type: 'timeInterval' })).toBe(false)
})
})
it('uses one delivery snapshot for sound and viewing even when settings change during host lookup', async () => {
AppState.currentState = 'active'
setNotificationViewingWorkspace({ hostId: 'host-1', worktreeId: 'folder' })
storage.set(
'orca:notificationDeliveryPreferences',
JSON.stringify({
sound: false,
suppressWhileViewing: false
})
)
vi.mocked(loadHostCatalog).mockImplementationOnce(async () => {
storage.set(
'orca:notificationDeliveryPreferences',
JSON.stringify({
sound: true,
suppressWhileViewing: true
})
)
return hosts
})
const behavior = await foregroundNotificationBehavior({
request: {
content: {
data: apnsData({
hostFingerprint,
worktreeId: 'folder',
notificationEpoch: 'snapshot',
notificationSeq: 1
})
}
}
})
expect(behavior).toMatchObject({ shouldShowBanner: true, shouldPlaySound: false })
expect(
vi
.mocked(AsyncStorage.getItem)
.mock.calls.filter(([key]) => key === 'orca:notificationDeliveryPreferences')
).toHaveLength(1)
})
it.each(['apns', 'fcm'])(
'routes %s pane payload to the correct host, workspace and pane',
(provider) => {
const paneKey = 'tab-b:11111111-1111-4111-8111-111111111111'
const payload = { hostFingerprint, worktreeId: 'folder:/work', paneKey }
const data = provider === 'apns' ? { orca: payload } : payload
const routed = pushNotificationRouteData(data, [{ id: 'host', publicKeyB64 }], true)
expect(getNotificationNavigationTarget(routed)?.sessionTarget?.params).toEqual({
hostId: 'host',
worktreeId: 'folder:/work',
paneKey
})
}
)
+146
View File
@@ -0,0 +1,146 @@
import { wasPushDismissed } from './push-dismissal-watermarks'
import { dismissPresentedPushNotification } from './push-tray-dismissal'
import { shouldSuppressNotificationWhileViewing } from './notification-viewing-policy'
import { loadPushNotificationsEnabled } from '../storage/preferences'
import { loadHostCatalog } from '../transport/host-store'
import { resolveHostIdForFingerprint } from './push-host-fingerprint'
import { readOrcaPushPayload, type OrcaPushPayload } from './push-payload'
import type { Notification, NotificationBehavior } from 'expo-notifications'
import { readNativeNotificationData } from './native-notification-data'
import { loadNotificationDeliveryPreferences } from './notification-delivery-preferences'
const RECENT_FOREGROUND_PUSH_CAP = 512
const recentForegroundPushes = new Set<string>()
function claimForegroundPush(payload: OrcaPushPayload): boolean {
const seq = payload.notificationSeq
if (
!payload.notificationEpoch ||
typeof seq !== 'number' ||
!Number.isSafeInteger(seq) ||
seq < 0
) {
return true
}
const key = JSON.stringify([
payload.hostFingerprint,
payload.notificationEpoch,
payload.notificationId ?? null,
seq
])
if (recentForegroundPushes.has(key)) {
return false
}
recentForegroundPushes.add(key)
if (recentForegroundPushes.size > RECENT_FOREGROUND_PUSH_CAP) {
const oldest = recentForegroundPushes.values().next().value
if (oldest !== undefined) {
recentForegroundPushes.delete(oldest)
}
}
return true
}
export function resetForegroundPushClaimsForTests(): void {
recentForegroundPushes.clear()
}
export async function foregroundNotificationBehavior(
notification: Pick<Notification, 'request'>
): Promise<NotificationBehavior> {
const data = readNativeNotificationData(notification.request)
const payload = readOrcaPushPayload(data)
const preferences = await loadNotificationDeliveryPreferences()
// Unrecognized notifications retain normal behavior; recognized pushes fail closed
// when consent, host, viewing, or dismissal checks cannot complete.
const suppressed = await shouldSuppressForegroundPush(
payload,
preferences.suppressWhileViewing
).catch(() => payload !== null)
return {
shouldShowBanner: !suppressed,
shouldShowList: !suppressed,
shouldPlaySound: !suppressed && preferences.sound,
shouldSetBadge: false
}
}
async function resolvePushHostId(payload: OrcaPushPayload): Promise<string | null> {
const hosts = await loadHostCatalog().catch(() => [])
return resolveHostIdForFingerprint(payload.hostFingerprint, hosts)
}
async function shouldSuppressForegroundPush(
payload: OrcaPushPayload | null,
suppressWhileViewing: boolean
): Promise<boolean> {
if (!payload) {
return false
}
if (payload.kind === 'dismiss') {
if (payload.notificationId) {
await dismissPresentedPushNotification(
payload.notificationId,
payload.hostFingerprint,
payload
)
}
return true
}
const hostId = await resolvePushHostId(payload)
// Why suppressed rather than shown: the only pushes that outlive their host are
// ones a gateway registration still holds after a removal whose unregister never
// reached the desktop. A banner naming a host this phone no longer has cannot be
// tapped anywhere, so it is noise the user cannot act on or turn off per-host.
if (!hostId) {
return true
}
if (!(await loadPushNotificationsEnabled())) {
return true
}
if (shouldSuppressNotificationWhileViewing(payload, hostId, suppressWhileViewing)) {
return true
}
// Keep this last: a socket/native dismissal may land during any preference or host read.
return (await wasPushDismissed(payload)) || !claimForegroundPush(payload)
}
/** Whether the OS says a notification came from a provider rather than this app. */
export function isRemotePushTrigger(trigger: unknown): boolean {
return (
typeof trigger === 'object' &&
trigger !== null &&
(trigger as { readonly type?: unknown }).type === 'push'
)
}
/**
* Notification data a tap can route with: the gateway names the host by fingerprint,
* so it is mapped back to this device's hostId. Locally scheduled data passes
* through untouched, which is what keeps its taps on their existing path.
*
* Why null and not the raw data when the fingerprint does not resolve: a gateway
* payload is attacker-adjacent input, and passing it on would let a stray `hostId`
* beside the `orca` block route a tap at a host the push never named. A remote
* push with no fingerprint at all is the same input minus the block, so it is
* unrouted too rather than handed to the local path as if this app scheduled it.
*/
export function pushNotificationRouteData(
data: unknown,
hosts: readonly { readonly id: string; readonly publicKeyB64: string }[],
remote = false
): unknown {
const payload = readOrcaPushPayload(data)
if (!payload) {
return remote ? null : data
}
const hostId = resolveHostIdForFingerprint(payload.hostFingerprint, hosts)
if (!hostId) {
return null
}
return {
hostId,
...(payload.paneKey ? { paneKey: payload.paneKey } : {}),
...(payload.worktreeId ? { worktreeId: payload.worktreeId } : {})
}
}
@@ -0,0 +1,263 @@
import { ensureDesktopNotificationChannel } from './desktop-notification-channel'
vi.mock('./desktop-notification-channel', () => ({
ensureDesktopNotificationChannel: vi.fn(async () => {})
}))
import { afterEach, beforeEach, expect, it, vi } from 'vitest'
import {
attachPushRegistration,
resetPushRegistrationForTests,
setRemotePushEnabled,
startPushTokenSync,
unregisterPushForRemovedHost,
NOTIFICATIONS_REMOTE_PUSH_CAPABILITY
} from './push-registration'
import { addPushTokenListener, getDevicePushToken } from './push-token'
import type { MobilePushToken } from './push-token'
import AsyncStorage from '@react-native-async-storage/async-storage'
import { removeHost } from '../transport/host-store'
import { removeHostAndCloseClient } from '../transport/host-removal-lifecycle'
vi.mock('../transport/host-store', () => ({ removeHost: vi.fn() }))
vi.mock('./mobile-push-lease-renewal', () => ({ startMobilePushLeaseRenewal: () => () => {} }))
const storage = new Map<string, string>()
vi.mock('@react-native-async-storage/async-storage', () => ({
default: {
getItem: async (key: string) => storage.get(key) ?? null,
setItem: vi.fn(async (key: string, value: string) => {
storage.set(key, value)
})
}
}))
vi.mock('react-native', () => ({ AppState: { currentState: 'active' } }))
vi.mock('./push-token', () => ({ getDevicePushToken: vi.fn(), addPushTokenListener: vi.fn() }))
const token: MobilePushToken = {
platform: 'ios',
token: 'a'.repeat(64),
apnsEnvironment: 'sandbox'
}
const records = () => JSON.parse(storage.get('orca:remotePushHostRegistrations') ?? '{}')
function deferred<T>() {
let resolve!: (value: T) => void
const promise = new Promise<T>((done) => {
resolve = done
})
return { promise, resolve }
}
function client(
register: () => Promise<unknown> = async () => ({ ok: true, result: { registered: true } })
) {
return {
sendRequest: vi.fn(async (method: string) => {
if (method === 'status.get') {
return { ok: true, result: { capabilities: [NOTIFICATIONS_REMOTE_PUSH_CAPABILITY] } }
}
if (method === 'notifications.registerPush') {
return register()
}
return { ok: true, result: { unregistered: true } }
})
}
}
beforeEach(() => {
vi.clearAllMocks()
resetPushRegistrationForTests()
storage.clear()
storage.set('orca:pushServiceNotificationsEnabled', 'true')
vi.mocked(getDevicePushToken).mockResolvedValue(token)
vi.mocked(addPushTokenListener).mockReturnValue(() => {})
vi.mocked(removeHost).mockReset()
})
afterEach(() => vi.useRealTimers())
it('does not resurrect a removed host when its registration response arrives late', async () => {
const pending = deferred<unknown>()
const connection = client(() => pending.promise)
attachPushRegistration('host', connection as never)
await vi.waitFor(() =>
expect(connection.sendRequest).toHaveBeenCalledWith(
'notifications.registerPush',
expect.anything(),
expect.anything()
)
)
const removal = unregisterPushForRemovedHost('host')
expect(connection.sendRequest.mock.calls.map(([method]) => method)).not.toContain(
'notifications.unregisterPush'
)
pending.resolve({ ok: true, result: { registered: true } })
await removal
await new Promise((resolve) => setTimeout(resolve, 10))
expect(records().registeredHostIds).toEqual([])
expect(records().pendingUnregisterHostIds).toEqual([])
})
it('does not start registration after removal while native token lookup was pending', async () => {
const pending = deferred<MobilePushToken | null>()
vi.mocked(getDevicePushToken).mockReturnValueOnce(pending.promise)
const connection = client()
attachPushRegistration('host', connection as never)
await vi.waitFor(() => expect(getDevicePushToken).toHaveBeenCalled())
await unregisterPushForRemovedHost('host')
pending.resolve(token)
await new Promise((resolve) => setTimeout(resolve, 10))
expect(connection.sendRequest.mock.calls.map(([method]) => method)).not.toContain(
'notifications.registerPush'
)
})
it('does not register with stale consent after the user disables notifications during token lookup', async () => {
const pending = deferred<MobilePushToken | null>()
vi.mocked(getDevicePushToken).mockReturnValueOnce(pending.promise)
const connection = client()
attachPushRegistration('host', connection as never)
await vi.waitFor(() => expect(getDevicePushToken).toHaveBeenCalled())
const disabled = setRemotePushEnabled(false)
pending.resolve(token)
await disabled
await vi.waitFor(() =>
expect(connection.sendRequest.mock.calls.map(([method]) => method)).toContain(
'notifications.unregisterPush'
)
)
expect(connection.sendRequest.mock.calls.map(([method]) => method)).not.toContain(
'notifications.registerPush'
)
})
it('waits for the Android notification channel before registering a token', async () => {
const pending = deferred<void>()
vi.mocked(ensureDesktopNotificationChannel).mockReturnValueOnce(pending.promise)
const connection = client()
attachPushRegistration('host', connection as never)
await vi.waitFor(() => expect(ensureDesktopNotificationChannel).toHaveBeenCalled())
expect(getDevicePushToken).not.toHaveBeenCalled()
expect(connection.sendRequest.mock.calls.map(([method]) => method)).not.toContain(
'notifications.registerPush'
)
pending.resolve()
await vi.waitFor(() =>
expect(connection.sendRequest.mock.calls.map(([method]) => method)).toContain(
'notifications.registerPush'
)
)
})
it('completes disable while native token acquisition remains unresolved, and rejects late tokens', async () => {
vi.useFakeTimers()
storage.set(
'orca:remotePushHostRegistrations',
JSON.stringify({
registeredHostIds: ['host'],
pendingUnregisterHostIds: []
})
)
const pending = deferred<MobilePushToken | null>()
vi.mocked(getDevicePushToken).mockReturnValueOnce(pending.promise)
const connection = client()
const stop = startPushTokenSync()
attachPushRegistration('host', connection as never)
await vi.advanceTimersByTimeAsync(0)
expect(getDevicePushToken).toHaveBeenCalledOnce()
await setRemotePushEnabled(false)
expect(records().pendingUnregisterHostIds).toEqual(['host'])
expect(connection.sendRequest.mock.calls.map(([method]) => method)).not.toContain(
'notifications.unregisterPush'
)
await vi.advanceTimersByTimeAsync(2_000)
expect(storage.get('orca:pushServiceNotificationsEnabled')).toBe('false')
expect(records()).toEqual({ registeredHostIds: [], pendingUnregisterHostIds: [] })
expect(connection.sendRequest.mock.calls.map(([method]) => method)).toContain(
'notifications.unregisterPush'
)
pending.resolve(token)
vi.mocked(addPushTokenListener).mock.calls[0]![0](token)
await vi.advanceTimersByTimeAsync(0)
expect(connection.sendRequest.mock.calls.map(([method]) => method)).not.toContain(
'notifications.registerPush'
)
stop()
})
it('restores registration without reconnect after metadata removal fails, retaining detach ownership', async () => {
const connection = client()
const detach = attachPushRegistration('host', connection as never)
await vi.waitFor(() => expect(records().registeredHostIds).toEqual(['host']))
vi.mocked(removeHost).mockRejectedValueOnce(new Error('metadata failure'))
const close = vi.fn()
await expect(removeHostAndCloseClient('host', close)).rejects.toThrow('metadata failure')
expect(close).not.toHaveBeenCalled()
await vi.waitFor(() => expect(records().registeredHostIds).toEqual(['host']))
expect(connection.sendRequest.mock.calls.map(([method]) => method)).toEqual([
'status.get',
'notifications.registerPush',
'notifications.unregisterPush',
'status.get',
'notifications.registerPush'
])
detach()
connection.sendRequest.mockClear()
await setRemotePushEnabled(true)
await new Promise((resolve) => setTimeout(resolve, 0))
expect(connection.sendRequest).not.toHaveBeenCalled()
})
it('does not revive a connection detached while metadata removal was pending', async () => {
const connection = client()
const detach = attachPushRegistration('host', connection as never)
await vi.waitFor(() => expect(records().registeredHostIds).toEqual(['host']))
const commit = deferred<void>()
vi.mocked(removeHost).mockImplementationOnce(async () => {
await commit.promise
throw new Error('metadata failure')
})
const removal = expect(removeHostAndCloseClient('host', vi.fn())).rejects.toThrow(
'metadata failure'
)
await vi.waitFor(() => expect(removeHost).toHaveBeenCalled())
detach()
connection.sendRequest.mockClear()
commit.resolve()
await removal
await setRemotePushEnabled(true)
await new Promise((resolve) => setTimeout(resolve, 0))
expect(connection.sendRequest).not.toHaveBeenCalled()
})
it('retires late registration ownership before a failed removal restores a fresh registration', async () => {
const oldRegister = deferred<unknown>()
const newRegister = deferred<unknown>()
const register = vi
.fn()
.mockReturnValueOnce(oldRegister.promise)
.mockReturnValue(newRegister.promise)
const connection = client(register)
attachPushRegistration('host', connection as never)
await vi.waitFor(() => expect(register).toHaveBeenCalledOnce())
vi.mocked(removeHost).mockRejectedValueOnce(new Error('metadata failure'))
const removal = expect(removeHostAndCloseClient('host', vi.fn())).rejects.toThrow(
'metadata failure'
)
expect(connection.sendRequest.mock.calls.map(([method]) => method)).not.toContain(
'notifications.unregisterPush'
)
oldRegister.resolve({ ok: true, result: { registered: true } })
await removal
await vi.waitFor(() => expect(register).toHaveBeenCalledTimes(2))
expect(records().registeredHostIds).toEqual([])
newRegister.resolve({ ok: true, result: { registered: true } })
await vi.waitFor(() => expect(records().registeredHostIds).toEqual(['host']))
})
it('still commits removal when unregister and cleanup storage fail', async () => {
const connection = client()
attachPushRegistration('host', connection as never)
await vi.waitFor(() => expect(records().registeredHostIds).toEqual(['host']))
connection.sendRequest.mockRejectedValueOnce(new Error('socket closed'))
vi.mocked(AsyncStorage.setItem).mockRejectedValueOnce(new Error('disk full'))
const close = vi.fn()
await removeHostAndCloseClient('host', close)
expect(removeHost).toHaveBeenCalledWith('host')
expect(close).toHaveBeenCalledWith('host')
})
@@ -0,0 +1,423 @@
vi.mock('@react-native-async-storage/async-storage', () => ({
default: { getItem: vi.fn(async () => null) }
}))
vi.mock('./desktop-notification-channel', () => ({
ensureDesktopNotificationChannel: vi.fn(async () => {})
}))
import { AppState } from 'react-native'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { RpcClient, SendRequestOptions } from '../transport/rpc-client'
import type { RpcResponse } from '../transport/types'
import {
loadPushNotificationsEnabled,
loadRemotePushHostRegistrations,
savePushNotificationsEnabled,
saveRemotePushHostRegistrations,
type RemotePushHostRegistrations
} from '../storage/preferences'
import { addPushTokenListener, getDevicePushToken, type MobilePushToken } from './push-token'
import {
NOTIFICATIONS_REMOTE_PUSH_CAPABILITY,
attachPushRegistration,
resetPushRegistrationForTests,
setRemotePushEnabled,
startPushTokenSync,
unregisterPushForRemovedHost
} from './push-registration'
vi.mock('../storage/preferences', () => ({
loadPushNotificationsEnabled: vi.fn(),
savePushNotificationsEnabled: vi.fn(),
loadRemotePushHostRegistrations: vi.fn(),
saveRemotePushHostRegistrations: vi.fn()
}))
vi.mock('react-native', () => ({
AppState: { currentState: 'active', addEventListener: vi.fn(() => ({ remove: vi.fn() })) }
}))
vi.mock('./push-token', () => ({
getDevicePushToken: vi.fn(),
addPushTokenListener: vi.fn()
}))
const IOS_TOKEN: MobilePushToken = {
platform: 'ios',
token: 'a'.repeat(64),
apnsEnvironment: 'production'
}
// Every await in the module resolves immediately, so one macrotask drains the whole
// per-host reconcile chain no matter how many hops deep it happens to be.
function flush(): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, 0))
}
function ok(result: unknown): RpcResponse {
return { id: 'req', ok: true, result, _meta: { runtimeId: 'runtime-1' } }
}
type SentRequest = { method: string; params?: unknown; options?: SendRequestOptions }
function makeClient(capabilities: readonly string[]): {
client: Pick<RpcClient, 'sendRequest'>
sent: SentRequest[]
} {
const sent: SentRequest[] = []
const client = {
sendRequest: vi.fn(async (method: string, params?: unknown, options?: SendRequestOptions) => {
sent.push({ method, params, options })
if (method === 'status.get') {
return ok({ capabilities: [...capabilities] })
}
if (method === 'notifications.registerPush') {
return ok({ registered: true, registrationId: 'registration-1' })
}
if (method === 'notifications.unregisterPush') {
return ok({ unregistered: true })
}
return ok(null)
})
}
return { client, sent }
}
function methodsIn(sent: SentRequest[]): string[] {
return sent.map((request) => request.method)
}
let enabled = false
let stored: RemotePushHostRegistrations
beforeEach(() => {
vi.clearAllMocks()
AppState.currentState = 'active'
resetPushRegistrationForTests()
enabled = false
stored = { registeredHostIds: [], pendingUnregisterHostIds: [] }
vi.mocked(loadPushNotificationsEnabled).mockImplementation(async () => enabled)
vi.mocked(savePushNotificationsEnabled).mockImplementation(async (value) => {
enabled = value
})
vi.mocked(loadRemotePushHostRegistrations).mockImplementation(async () => stored)
vi.mocked(saveRemotePushHostRegistrations).mockImplementation(async (value) => {
stored = value
})
vi.mocked(getDevicePushToken).mockResolvedValue(IOS_TOKEN)
})
describe('push registration capability gating', () => {
it('registers a connected host that advertises remote push', async () => {
const { client, sent } = makeClient([NOTIFICATIONS_REMOTE_PUSH_CAPABILITY])
await setRemotePushEnabled(true)
attachPushRegistration('host-1', client)
await flush()
const register = sent.find((request) => request.method === 'notifications.registerPush')
expect(register?.params).toEqual({
platform: 'ios',
token: IOS_TOKEN.token,
apnsEnvironment: 'production',
filter: { onlyWhenDesktopAway: true, sound: true }
})
expect(stored.registeredHostIds).toEqual(['host-1'])
})
it('never calls registerPush on a host without the capability', async () => {
const { client, sent } = makeClient(['some-other.v1'])
await setRemotePushEnabled(true)
attachPushRegistration('host-legacy', client)
await flush()
expect(methodsIn(sent)).toEqual(['status.get'])
expect(stored.registeredHostIds).toEqual([])
})
it('reconciles disabled consent even without registration records', async () => {
const { client, sent } = makeClient([NOTIFICATIONS_REMOTE_PUSH_CAPABILITY])
attachPushRegistration('host-1', client)
await flush()
expect(methodsIn(sent)).toEqual(['status.get', 'notifications.unregisterPush'])
})
it('omits apnsEnvironment for an Android token', async () => {
vi.mocked(getDevicePushToken).mockResolvedValue({ platform: 'android', token: 'fcm-token' })
const { client, sent } = makeClient([NOTIFICATIONS_REMOTE_PUSH_CAPABILITY])
await setRemotePushEnabled(true)
attachPushRegistration('host-1', client)
await flush()
const register = sent.find((request) => request.method === 'notifications.registerPush')
expect(register?.params).toMatchObject({ platform: 'android', token: 'fcm-token' })
expect(register?.params).not.toHaveProperty('apnsEnvironment')
})
it('registers nothing when the device has no push token at all', async () => {
vi.mocked(getDevicePushToken).mockResolvedValue(null)
const { client, sent } = makeClient([NOTIFICATIONS_REMOTE_PUSH_CAPABILITY])
await setRemotePushEnabled(true)
attachPushRegistration('host-simulator', client)
await flush()
expect(methodsIn(sent)).toEqual(['status.get'])
})
it('asks only once when the host answers that it has no push capability', async () => {
const { client, sent } = makeClient(['some-other.v1'])
await setRemotePushEnabled(true)
attachPushRegistration('host-legacy', client)
await flush()
await setRemotePushEnabled(true)
await flush()
expect(methodsIn(sent)).toEqual(['status.get'])
})
it('re-probes a host whose first status.get never answered', async () => {
vi.useFakeTimers()
const sent: string[] = []
let probeFails = true
const client = {
sendRequest: vi.fn(async (method: string) => {
sent.push(method)
if (method === 'status.get') {
if (probeFails) {
throw new Error('request timed out')
}
return ok({ capabilities: [NOTIFICATIONS_REMOTE_PUSH_CAPABILITY] })
}
return ok({ registered: true, registrationId: 'registration-1' })
})
}
await setRemotePushEnabled(true)
attachPushRegistration('host-1', client)
await vi.advanceTimersByTimeAsync(0)
expect(sent).toEqual(['status.get'])
// A failed probe retries while the same connection remains active.
probeFails = false
await vi.advanceTimersByTimeAsync(1_000)
await Promise.resolve()
await Promise.resolve()
expect(sent).toEqual(['status.get', 'status.get', 'notifications.registerPush'])
vi.useRealTimers()
})
it('retries the device token on the next reconcile after the device had none', async () => {
vi.mocked(getDevicePushToken).mockResolvedValueOnce(null).mockResolvedValue(IOS_TOKEN)
const { client, sent } = makeClient([NOTIFICATIONS_REMOTE_PUSH_CAPABILITY])
await setRemotePushEnabled(true)
attachPushRegistration('host-1', client)
await flush()
expect(methodsIn(sent)).toEqual(['status.get'])
// A token can be missing only for now — APNs registration still in flight.
await setRemotePushEnabled(true)
await flush()
expect(methodsIn(sent)).toContain('notifications.registerPush')
})
})
describe('push registration token changes', () => {
it('re-registers every connected host when the provider rolls the token', async () => {
let onTokenChange: ((token: MobilePushToken) => void) | null = null
vi.mocked(addPushTokenListener).mockImplementation((listener) => {
onTokenChange = listener
return () => {}
})
const { client, sent } = makeClient([NOTIFICATIONS_REMOTE_PUSH_CAPABILITY])
await setRemotePushEnabled(true)
attachPushRegistration('host-1', client)
await flush()
const stop = startPushTokenSync()
onTokenChange?.({ platform: 'ios', token: 'b'.repeat(64), apnsEnvironment: 'sandbox' })
await flush()
const registers = sent.filter((request) => request.method === 'notifications.registerPush')
expect(registers).toHaveLength(2)
expect(registers[1]?.params).toMatchObject({
token: 'b'.repeat(64),
apnsEnvironment: 'sandbox'
})
stop()
})
})
describe('push unregistration', () => {
it('unregisters a connected host as soon as the switch goes off', async () => {
const { client, sent } = makeClient([NOTIFICATIONS_REMOTE_PUSH_CAPABILITY])
await setRemotePushEnabled(true)
attachPushRegistration('host-1', client)
await flush()
await setRemotePushEnabled(false)
await flush()
expect(methodsIn(sent)).toContain('notifications.unregisterPush')
expect(stored.registeredHostIds).toEqual([])
expect(stored.pendingUnregisterHostIds).toEqual([])
})
it('retries the unregister on a host that was offline when the switch went off', async () => {
const first = makeClient([NOTIFICATIONS_REMOTE_PUSH_CAPABILITY])
await setRemotePushEnabled(true)
const detach = attachPushRegistration('host-1', first.client)
await flush()
detach()
await setRemotePushEnabled(false)
await flush()
expect(methodsIn(first.sent)).not.toContain('notifications.unregisterPush')
expect(stored.pendingUnregisterHostIds).toEqual(['host-1'])
// A fresh process: only the persisted intent survives the restart.
AppState.currentState = 'active'
resetPushRegistrationForTests()
const reconnected = makeClient([NOTIFICATIONS_REMOTE_PUSH_CAPABILITY])
attachPushRegistration('host-1', reconnected.client)
await flush()
// No probe first: a pending entry is a switch-off the user already performed, so
// it must not wait on a status.get that may never answer.
expect(methodsIn(reconnected.sent)).toEqual(['notifications.unregisterPush'])
expect(stored.pendingUnregisterHostIds).toEqual([])
})
it('recovers an offline disable after its cleanup write fails and mobile restarts', async () => {
const first = makeClient([NOTIFICATIONS_REMOTE_PUSH_CAPABILITY])
await setRemotePushEnabled(true)
const detach = attachPushRegistration('host-1', first.client)
await flush()
detach()
vi.mocked(saveRemotePushHostRegistrations).mockRejectedValueOnce(new Error('disk full'))
await expect(setRemotePushEnabled(false)).rejects.toThrow('disk full')
expect(enabled).toBe(false)
expect(stored.pendingUnregisterHostIds).toEqual([])
resetPushRegistrationForTests()
const reconnected = makeClient([NOTIFICATIONS_REMOTE_PUSH_CAPABILITY])
attachPushRegistration('host-1', reconnected.client)
await flush()
expect(methodsIn(reconnected.sent)).toEqual(['status.get', 'notifications.unregisterPush'])
expect(stored).toEqual({ registeredHostIds: [], pendingUnregisterHostIds: [] })
})
it('keeps the pending intent when the retry itself fails', async () => {
stored = { registeredHostIds: ['host-1'], pendingUnregisterHostIds: ['host-1'] }
const client = {
sendRequest: vi.fn(async (method: string) =>
method === 'status.get'
? ok({ capabilities: [NOTIFICATIONS_REMOTE_PUSH_CAPABILITY] })
: Promise.reject(new Error('socket closed'))
)
}
attachPushRegistration('host-1', client)
await flush()
expect(stored.pendingUnregisterHostIds).toEqual(['host-1'])
})
it('unregisters best-effort before a removed host loses its credentials', async () => {
const { client, sent } = makeClient([NOTIFICATIONS_REMOTE_PUSH_CAPABILITY])
await setRemotePushEnabled(true)
attachPushRegistration('host-1', client)
await flush()
await unregisterPushForRemovedHost('host-1')
expect(methodsIn(sent)).toContain('notifications.unregisterPush')
expect(stored.registeredHostIds).toEqual([])
expect(stored.pendingUnregisterHostIds).toEqual([])
})
it('drops a removed host that was never connected without any request', async () => {
stored = { registeredHostIds: ['host-gone'], pendingUnregisterHostIds: ['host-gone'] }
await unregisterPushForRemovedHost('host-gone')
expect(stored).toEqual({ registeredHostIds: [], pendingUnregisterHostIds: [] })
})
it('unregisters a pending host even when its capability probe never answers', async () => {
stored = { registeredHostIds: ['host-1'], pendingUnregisterHostIds: ['host-1'] }
const sent: string[] = []
const client = {
sendRequest: vi.fn(async (method: string) => {
sent.push(method)
if (method === 'status.get') {
throw new Error('request timed out')
}
return ok({ unregistered: true })
})
}
attachPushRegistration('host-1', client)
await flush()
// Gating this on the probe leaves the gateway pushing while the switch reads off.
expect(sent).toEqual(['notifications.unregisterPush'])
expect(stored.pendingUnregisterHostIds).toEqual([])
})
it('re-arms the unregister when the switch goes off while a register is in flight', async () => {
const sent: string[] = []
let releaseRegister: (() => void) | null = null
const client = {
sendRequest: vi.fn(async (method: string) => {
sent.push(method)
if (method === 'status.get') {
return ok({ capabilities: [NOTIFICATIONS_REMOTE_PUSH_CAPABILITY] })
}
if (method === 'notifications.registerPush') {
await new Promise<void>((resolve) => {
releaseRegister = resolve
})
return ok({ registered: true, registrationId: 'registration-1' })
}
return ok({ unregistered: true })
})
}
await setRemotePushEnabled(true)
attachPushRegistration('host-1', client)
await flush()
// The sweep snapshots `registered` while this host is still only in flight.
const switchedOff = setRemotePushEnabled(false)
await flush()
releaseRegister?.()
await switchedOff
await flush()
// Recording the late success would leave a live gateway registration behind a
// switch that reads off, with nothing pending to ever retract it.
expect(sent).toContain('notifications.unregisterPush')
expect(stored).toEqual({ registeredHostIds: [], pendingUnregisterHostIds: [] })
})
})
it('does not register or renew when a connected phone is in the background', async () => {
AppState.currentState = 'background'
const { client, sent } = makeClient([NOTIFICATIONS_REMOTE_PUSH_CAPABILITY])
await setRemotePushEnabled(true)
attachPushRegistration('background-phone', client)
await flush()
expect(methodsIn(sent)).not.toContain('notifications.registerPush')
AppState.currentState = 'active'
attachPushRegistration('background-phone', client)
await flush()
expect(methodsIn(sent)).toContain('notifications.registerPush')
})
@@ -0,0 +1,328 @@
import { ensureDesktopNotificationChannel } from './desktop-notification-channel'
import { AppState } from 'react-native'
import { startMobilePushLeaseRenewal } from './mobile-push-lease-renewal'
import {
loadNotificationDeliveryPreferences,
notificationPreferencesFilter,
saveNotificationDeliveryPreferences,
type NotificationDeliveryPreferences
} from './notification-delivery-preferences'
import type {
MobilePushFilter,
MobilePushRegisterInput,
MobilePushRegisterResult
} from '../../../src/shared/mobile-push-contract'
import { NOTIFICATIONS_REMOTE_PUSH_RUNTIME_CAPABILITY } from '../../../src/shared/protocol-version'
import type { RpcClient } from '../transport/rpc-client'
import { startRuntimeCapabilityProbe } from '../transport/runtime-capability-probe'
import {
loadPushNotificationsEnabled,
loadRemotePushHostRegistrations,
savePushNotificationsEnabled,
saveRemotePushHostRegistrations
} from '../storage/preferences'
import { addPushTokenListener, getDevicePushToken, type MobilePushToken } from './push-token'
export const NOTIFICATIONS_REMOTE_PUSH_CAPABILITY = NOTIFICATIONS_REMOTE_PUSH_RUNTIME_CAPABILITY
type PushClient = Pick<RpcClient, 'sendRequest'>
const REQUEST_TIMEOUT_MS = 5_000
const REMOVAL_TIMEOUT_MS = 2_000
const TOKEN_TIMEOUT_MS = 2_000
type HostPushState = {
connection: { client: PushClient | null }
// An unanswered probe is unknown, not unsupported.
supported: boolean | null
capabilityProbeStop: (() => void) | null
chain: Promise<void>
}
type RegistrationRecords = { registered: Set<string>; pending: Set<string> }
const hostsById = new Map<string, HostPushState>()
let registrationRecords: RegistrationRecords | null = null
let tokenPromise: Promise<MobilePushToken | null> | null = null
// A late registration must not overwrite a newer preference or consent choice.
let consentGeneration = 0
function hostState(hostId: string): HostPushState {
let state = hostsById.get(hostId)
if (!state) {
state = {
connection: { client: null },
supported: null,
capabilityProbeStop: null,
chain: Promise.resolve()
}
hostsById.set(hostId, state)
}
return state
}
async function readRecords(): Promise<RegistrationRecords> {
if (!registrationRecords) {
const stored = await loadRemotePushHostRegistrations()
registrationRecords ??= {
registered: new Set(stored.registeredHostIds),
pending: new Set(stored.pendingUnregisterHostIds)
}
}
return registrationRecords
}
async function mutateRecords(mutate: (value: RegistrationRecords) => void): Promise<void> {
const value = await readRecords()
mutate(value)
await saveRemotePushHostRegistrations({
registeredHostIds: [...value.registered],
pendingUnregisterHostIds: [...value.pending]
})
}
// A missing token is retried: APNs registration may still be in flight.
async function currentToken(): Promise<MobilePushToken | null> {
await ensureDesktopNotificationChannel()
if (!tokenPromise) {
const pending: Promise<MobilePushToken | null> = getDevicePushToken().then((token) => {
if (!token && tokenPromise === pending) {
tokenPromise = null
}
return token
})
tokenPromise = pending
}
return tokenPromise
}
async function sendRegister(
client: PushClient,
token: MobilePushToken,
filter: MobilePushFilter
): Promise<boolean> {
const params: Omit<MobilePushRegisterInput, 'deviceId'> = {
platform: token.platform,
token: token.token,
...(token.apnsEnvironment ? { apnsEnvironment: token.apnsEnvironment } : {}),
filter
}
const response = await client
.sendRequest('notifications.registerPush', params, {
timeoutMs: REQUEST_TIMEOUT_MS,
failWhenDisconnected: true
})
.catch(() => null)
if (!response?.ok) {
return false
}
return (response.result as MobilePushRegisterResult | null)?.registered === true
}
async function sendUnregister(client: PushClient, timeoutMs: number): Promise<boolean> {
const response = await client
.sendRequest('notifications.unregisterPush', null, {
timeoutMs,
failWhenDisconnected: true
})
.catch(() => null)
return response?.ok === true
}
async function reconcileHost(hostId: string): Promise<void> {
const state = hostsById.get(hostId)
const client = state?.connection.client
if (!state || !client) {
return
}
const generation = consentGeneration
const isCurrent = () => hostsById.get(hostId) === state && state.connection.client === client
const value = await readRecords()
// Unregister intent takes priority even before the capability probe answers.
if (value.pending.has(hostId)) {
if (state.supported === false || !(await sendUnregister(client, REQUEST_TIMEOUT_MS))) {
return
}
await mutateRecords((current) => {
current.pending.delete(hostId)
current.registered.delete(hostId)
})
// A preference change can invalidate a register without disabling push.
if (!(await loadPushNotificationsEnabled())) {
return
}
}
if (state.supported == null) {
if (!isCurrent()) {
return
}
state.capabilityProbeStop ??= startRuntimeCapabilityProbe(client, (capabilities) => {
if (!isCurrent()) {
return
}
state.supported = capabilities.includes(NOTIFICATIONS_REMOTE_PUSH_CAPABILITY)
void enqueueReconcile(hostId)
})
return
}
if (!state.supported || !isCurrent()) {
return
}
if (!(await loadPushNotificationsEnabled())) {
// Saved consent recovers a disable even if its pending-record write failed.
if (await sendUnregister(client, REQUEST_TIMEOUT_MS)) {
await mutateRecords((current) => {
current.pending.delete(hostId)
current.registered.delete(hostId)
})
}
return
}
if (AppState.currentState !== 'active') {
return
}
let timer: ReturnType<typeof setTimeout> | undefined
const token = await Promise.race([
currentToken(),
new Promise<null>((resolve) => {
timer = setTimeout(() => resolve(null), TOKEN_TIMEOUT_MS)
})
]).finally(() => clearTimeout(timer))
const filter = notificationPreferencesFilter(await loadNotificationDeliveryPreferences())
if (
!token ||
!isCurrent() ||
generation !== consentGeneration ||
AppState.currentState !== 'active'
) {
return
}
if (!(await sendRegister(client, token, filter)) || hostsById.get(hostId) !== state) {
return
}
if (generation !== consentGeneration) {
await mutateRecords((current) => current.pending.add(hostId))
void enqueueReconcile(hostId)
return
}
await mutateRecords((current) => current.registered.add(hostId))
}
function enqueueReconcile(hostId: string): Promise<void> {
const state = hostState(hostId)
const run = state.chain
.then(() => (hostsById.get(hostId) === state ? reconcileHost(hostId) : undefined))
.catch(() => {
console.warn('[push] Failed to reconcile notification registration')
})
state.chain = run
return run
}
async function reconcileAllHosts(): Promise<void> {
await Promise.all([...hostsById.keys()].map((hostId) => enqueueReconcile(hostId)))
}
/**
* Track a host whose client has reached `connected`, registering (or retrying a
* pending unregister) as the current preference requires. The returned function
* detaches the client on disconnect; the host's tracked state survives it.
*/
export function attachPushRegistration(hostId: string, client: PushClient): () => void {
const state = hostState(hostId)
if (state.connection.client !== client) {
state.capabilityProbeStop?.()
state.capabilityProbeStop = null
state.connection.client = client
state.supported = null
}
void enqueueReconcile(hostId)
const connection = state.connection
return () => {
if (connection.client === client) {
connection.client = null
state.capabilityProbeStop?.()
state.capabilityProbeStop = null
state.supported = null
const current = hostsById.get(hostId)
if (current && current !== state) {
current.capabilityProbeStop?.()
current.capabilityProbeStop = null
current.supported = null
}
}
}
}
// Consent completion covers local persistence; host reconciliation runs in the background.
export async function setRemotePushEnabled(enabled: boolean): Promise<void> {
consentGeneration++
await savePushNotificationsEnabled(enabled)
try {
await mutateRecords((current) => {
if (!enabled) {
for (const hostId of current.registered) {
current.pending.add(hostId)
}
return
}
current.pending.clear()
})
} finally {
void reconcileAllHosts()
}
}
export async function setNotificationDeliveryPreferences(
value: NotificationDeliveryPreferences
): Promise<void> {
consentGeneration++
await saveNotificationDeliveryPreferences(value)
await reconcileAllHosts()
}
// Offline hosts retain the registration until unpaired or its mobile-use lease expires.
export async function unregisterPushForRemovedHost(hostId: string): Promise<() => void> {
const state = hostsById.get(hostId)
// Retire ownership before waiting for earlier RPCs to settle.
hostsById.delete(hostId)
state?.capabilityProbeStop?.()
if (state) {
state.capabilityProbeStop = null
}
await state?.chain
if (state?.connection.client && state.supported !== false) {
await sendUnregister(state.connection.client, REMOVAL_TIMEOUT_MS)
}
await mutateRecords((current) => {
current.registered.delete(hostId)
current.pending.delete(hostId)
}).catch(() => {})
return () => {
if (state && !hostsById.has(hostId)) {
// Preserve disconnect ownership without reviving stale registration work.
hostsById.set(hostId, { ...state, supported: null, capabilityProbeStop: null })
void enqueueReconcile(hostId)
}
}
}
/** A rolled token stops delivering, so re-register every connected host at once. */
export function startPushTokenSync(): () => void {
const stopLease = startMobilePushLeaseRenewal(reconcileAllHosts)
const stopToken = addPushTokenListener((token) => {
tokenPromise = Promise.resolve(token)
void reconcileAllHosts()
})
return () => {
stopLease()
stopToken()
}
}
export function resetPushRegistrationForTests(): void {
hostsById.clear()
registrationRecords = null
tokenPromise = null
consentGeneration = 0
}
@@ -0,0 +1,60 @@
import { expect, it, vi } from 'vitest'
import * as Notifications from 'expo-notifications'
import { loadHostCatalog } from '../transport/host-store'
import { deriveHostFingerprint } from './push-host-fingerprint'
import { dismissHostPushNotification } from './push-socket-dismissal'
vi.mock('../transport/host-store', () => ({ loadHostCatalog: vi.fn() }))
vi.mock('expo-notifications', () => ({
getPresentedNotificationsAsync: vi.fn(),
dismissNotificationAsync: vi.fn()
}))
vi.mock('@react-native-async-storage/async-storage', () => ({
default: { getItem: async () => null, setItem: async () => undefined }
}))
it('a socket dismissal cannot clear another desktop or a newer notification', async () => {
const publicKeyB64 = Buffer.alloc(32, 1).toString('base64')
vi.mocked(loadHostCatalog).mockResolvedValue([{ id: 'host-a', publicKeyB64 }] as never)
const hostFingerprint = deriveHostFingerprint(publicKeyB64)
const event = {
type: 'dismiss' as const,
notificationId: 'same',
notificationEpoch: 'epoch',
notificationSeq: 2
}
const presented = (identifier: string, overrides: Record<string, unknown>) => ({
request: { identifier, content: { data: { hostFingerprint, ...event, ...overrides } } }
})
vi.mocked(Notifications.getPresentedNotificationsAsync).mockResolvedValue([
presented('older', { notificationSeq: 1 }),
presented('newer', { notificationSeq: 3 }),
presented('other', { hostFingerprint: 'other-host' }),
presented('restarted', { notificationEpoch: 'new-epoch' })
] as never)
vi.mocked(Notifications.dismissNotificationAsync).mockResolvedValue(undefined)
await dismissHostPushNotification(event, 'host-a')
expect(vi.mocked(Notifications.dismissNotificationAsync).mock.calls).toEqual([['older']])
})
it('supports ID-only legacy dismissal while preserving host isolation', async () => {
vi.clearAllMocks()
const publicKeyB64 = Buffer.alloc(32, 1).toString('base64')
vi.mocked(loadHostCatalog).mockResolvedValue([{ id: 'host-a', publicKeyB64 }] as never)
const hostFingerprint = deriveHostFingerprint(publicKeyB64)
vi.mocked(Notifications.getPresentedNotificationsAsync).mockResolvedValue([
{
request: {
identifier: 'legacy',
content: { data: { hostFingerprint, notificationId: 'same' } }
}
},
{
request: {
identifier: 'foreign',
content: { data: { hostFingerprint: 'other-host', notificationId: 'same' } }
}
}
] as never)
await dismissHostPushNotification({ type: 'dismiss', notificationId: 'same' }, 'host-a')
expect(Notifications.dismissNotificationAsync).toHaveBeenCalledExactlyOnceWith('legacy')
})
@@ -0,0 +1,22 @@
import { loadHostCatalog } from '../transport/host-store'
import { deriveHostFingerprint } from './push-host-fingerprint'
import { dismissPresentedPushNotification } from './push-tray-dismissal'
import type { DismissNotificationEvent } from './desktop-notification-events'
async function hostFingerprint(hostId: string): Promise<string | null> {
const hosts = await loadHostCatalog().catch(() => [])
const host = hosts.find((item) => item.id === hostId)
return host ? deriveHostFingerprint(host.publicKeyB64) : null
}
export async function dismissHostPushNotification(
event: DismissNotificationEvent,
hostId: string
): Promise<void> {
const fingerprint = await hostFingerprint(hostId)
if (!fingerprint) {
return
}
const fence = event.notificationEpoch && event.notificationSeq !== undefined ? event : undefined
await dismissPresentedPushNotification(event.notificationId, fingerprint, fence)
}
@@ -0,0 +1,92 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import * as Notifications from 'expo-notifications'
import { addPushTokenListener, getDevicePushToken } from './push-token'
vi.mock('expo-notifications', () => ({
getDevicePushTokenAsync: vi.fn(),
addPushTokenListener: vi.fn()
}))
const dev = globalThis as { __DEV__?: boolean }
beforeEach(() => {
vi.clearAllMocks()
})
afterEach(() => {
delete dev.__DEV__
})
describe('getDevicePushToken', () => {
it.each([
[true, 'sandbox'],
[false, 'production']
])('reports apnsEnvironment for a __DEV__=%s iOS build as %s', async (isDev, environment) => {
dev.__DEV__ = isDev
vi.mocked(Notifications.getDevicePushTokenAsync).mockResolvedValue({
type: 'ios',
data: 'a'.repeat(64)
} as never)
await expect(getDevicePushToken()).resolves.toEqual({
platform: 'ios',
token: 'a'.repeat(64),
apnsEnvironment: environment
})
})
it('omits apnsEnvironment for Android, where FCM has no environment split', async () => {
vi.mocked(Notifications.getDevicePushTokenAsync).mockResolvedValue({
type: 'android',
data: 'fcm-registration-token'
} as never)
await expect(getDevicePushToken()).resolves.toEqual({
platform: 'android',
token: 'fcm-registration-token'
})
})
it.each([
['a web push subscription', { type: 'web', data: { endpoint: 'https://example.test' } }],
['an empty token', { type: 'ios', data: '' }]
])('returns null for %s', async (_label, raw) => {
vi.mocked(Notifications.getDevicePushTokenAsync).mockResolvedValue(raw as never)
await expect(getDevicePushToken()).resolves.toBeNull()
})
it('returns null when the shell cannot mint a token at all', async () => {
vi.mocked(Notifications.getDevicePushTokenAsync).mockRejectedValue(new Error('no entitlement'))
await expect(getDevicePushToken()).resolves.toBeNull()
})
})
describe('addPushTokenListener', () => {
it('forwards a rolled native token and removes the subscription on teardown', () => {
const remove = vi.fn()
let emit: ((raw: unknown) => void) | null = null
vi.mocked(Notifications.addPushTokenListener).mockImplementation((listener) => {
emit = listener as (raw: unknown) => void
return { remove } as never
})
const seen: unknown[] = []
const stop = addPushTokenListener((token) => seen.push(token))
emit?.({ type: 'android', data: 'rolled' })
emit?.({ type: 'web', data: {} })
stop()
expect(seen).toEqual([{ platform: 'android', token: 'rolled' }])
expect(remove).toHaveBeenCalledTimes(1)
})
it('degrades to a no-op on a shell that cannot subscribe to token changes', () => {
vi.mocked(Notifications.addPushTokenListener).mockImplementation(() => {
throw new Error('no push support')
})
expect(() => addPushTokenListener(() => {})()).not.toThrow()
})
})
+58
View File
@@ -0,0 +1,58 @@
import * as Notifications from 'expo-notifications'
import type {
MobilePushApnsEnvironment,
MobilePushPlatform
} from '../../../src/shared/mobile-push-contract'
// Why: the native APNs/FCM token, not an Expo push token — Orca's own gateway
// talks to Apple and Google directly, so it needs the raw device token.
export type MobilePushToken = {
readonly platform: MobilePushPlatform
readonly token: string
readonly apnsEnvironment?: MobilePushApnsEnvironment
}
// Dev-client builds are debug and get sandbox APNs; TestFlight and App Store are release.
function apnsEnvironment(): MobilePushApnsEnvironment {
return typeof __DEV__ !== 'undefined' && __DEV__ ? 'sandbox' : 'production'
}
function toMobilePushToken(raw: { type: string; data: unknown }): MobilePushToken | null {
if (typeof raw.data !== 'string' || raw.data.length === 0) {
return null
}
if (raw.type === 'ios') {
return { platform: 'ios', token: raw.data, apnsEnvironment: apnsEnvironment() }
}
// Web tokens carry an object payload and no Orca gateway path; only native counts.
return raw.type === 'android' ? { platform: 'android', token: raw.data } : null
}
/**
* The native push token, or null if registration is unavailable or fails.
*/
export async function getDevicePushToken(): Promise<MobilePushToken | null> {
try {
return toMobilePushToken(await Notifications.getDevicePushTokenAsync())
} catch {
return null
}
}
/** Providers can roll a token while the app runs; the old one stops delivering. */
export function addPushTokenListener(listener: (token: MobilePushToken) => void): () => void {
try {
const subscription = Notifications.addPushTokenListener((raw) => {
const token = toMobilePushToken(raw)
if (token) {
listener(token)
}
})
return () => subscription.remove()
} catch {
// A shell with no push capability cannot subscribe; the caller is a root-level
// effect, so throwing here would take the whole app down over an optional feature.
return () => {}
}
}
@@ -0,0 +1,84 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import * as Notifications from 'expo-notifications'
import { dismissPresentedPushNotification } from './push-tray-dismissal'
vi.mock('@react-native-async-storage/async-storage', () => ({
default: { getItem: vi.fn(async () => null), setItem: vi.fn(async () => {}) }
}))
vi.mock('expo-notifications', () => ({
getPresentedNotificationsAsync: vi.fn(),
dismissNotificationAsync: vi.fn()
}))
function presented(identifier: string, data: unknown): unknown {
return { request: { identifier, content: { data } } }
}
beforeEach(() => {
vi.clearAllMocks()
vi.mocked(Notifications.dismissNotificationAsync).mockResolvedValue(undefined)
})
describe('dismissPresentedPushNotification', () => {
it('dismisses only the tray entries whose push payload carries the same notification id', async () => {
vi.mocked(Notifications.getPresentedNotificationsAsync).mockResolvedValue([
presented('tray-1', {
orca: { hostFingerprint: 'fp0123456789abcd', notificationId: 'agent:one' }
}),
presented('tray-2', {
orca: { hostFingerprint: 'fp0123456789abcd', notificationId: 'agent:two' }
}),
presented('other-host', { hostFingerprint: 'another-host', notificationId: 'agent:one' }),
// Flat FCM shape for the same notification, presented on Android.
presented('tray-3', { hostFingerprint: 'fp0123456789abcd', notificationId: 'agent:one' })
] as never)
await dismissPresentedPushNotification('agent:one', 'fp0123456789abcd')
expect(vi.mocked(Notifications.dismissNotificationAsync).mock.calls.map(([id]) => id)).toEqual([
'tray-1',
'tray-3'
])
})
it('ignores notifications without a gateway identity', async () => {
vi.mocked(Notifications.getPresentedNotificationsAsync).mockResolvedValue([
presented('tray-1', { hostId: 'host-1', notificationId: 'agent:one' })
] as never)
await dismissPresentedPushNotification('agent:one', 'fp0123456789abcd')
expect(Notifications.dismissNotificationAsync).not.toHaveBeenCalled()
})
it('reports tray query failures to the caller', async () => {
vi.mocked(Notifications.getPresentedNotificationsAsync).mockRejectedValue(
new Error('unavailable')
)
await expect(dismissPresentedPushNotification('agent:one', 'fp0123456789abcd')).rejects.toThrow(
'unavailable'
)
})
})
it('a delayed dismissal preserves newer alerts, other epochs, and other hosts', async () => {
const base = { hostFingerprint: 'host-a', notificationId: 'note', notificationEpoch: 'epoch-a' }
vi.mocked(Notifications.getPresentedNotificationsAsync).mockResolvedValue([
presented('older', { ...base, notificationSeq: 1 }),
presented('equal', { ...base, notificationSeq: 2 }),
presented('newer', { ...base, notificationSeq: 3 }),
presented('restarted', { ...base, notificationSeq: 1, notificationEpoch: 'epoch-b' }),
presented('other-host', { ...base, notificationSeq: 1, hostFingerprint: 'host-b' }),
presented('legacy', base)
] as never)
await dismissPresentedPushNotification('note', 'host-a', {
notificationEpoch: 'epoch-a',
notificationSeq: 2
})
expect(vi.mocked(Notifications.dismissNotificationAsync).mock.calls.map(([id]) => id)).toEqual([
'older',
'equal'
])
})
@@ -0,0 +1,66 @@
import { readNativeNotificationData } from './native-notification-data'
import * as Notifications from 'expo-notifications'
import { readOrcaPushPayload, type OrcaPushPayload } from './push-payload'
import { rememberPushDismissal, wasPushDismissed } from './push-dismissal-watermarks'
async function dismissMatchingPresentedPushes(
matches: (payload: OrcaPushPayload) => boolean | Promise<boolean>
): Promise<void> {
const presented = await Notifications.getPresentedNotificationsAsync()
await Promise.all(
presented.map(async (notification) => {
const payload = readOrcaPushPayload(readNativeNotificationData(notification.request))
if (payload && (await matches(payload))) {
await Notifications.dismissNotificationAsync(notification.request.identifier)
}
})
)
}
export function dismissRememberedPushNotifications(
hostFingerprint: string,
confirmed: readonly OrcaPushPayload[]
): Promise<void> {
return dismissMatchingPresentedPushes(async (payload) => {
if (payload.hostFingerprint !== hostFingerprint) {
return false
}
return (
confirmed.some(
(fence) =>
fence.notificationId === payload.notificationId &&
fence.notificationEpoch === payload.notificationEpoch &&
fence.notificationSeq !== undefined &&
payload.notificationSeq !== undefined &&
fence.notificationSeq >= payload.notificationSeq
) || wasPushDismissed(payload)
)
})
}
// Pushes shown while Orca was closed are absent from the local scheduling registry.
export async function dismissPresentedPushNotification(
notificationId: string,
hostFingerprint: string,
fence?: { notificationEpoch?: string; notificationSeq?: number }
): Promise<void> {
if (fence) {
await rememberPushDismissal({ hostFingerprint, notificationId, ...fence })
}
await dismissMatchingPresentedPushes((payload) => {
if (payload.hostFingerprint !== hostFingerprint) {
return false
}
return (
payload.notificationId === notificationId &&
(!fence ||
Boolean(
fence.notificationEpoch &&
fence.notificationSeq !== undefined &&
payload.notificationEpoch === fence.notificationEpoch &&
payload.notificationSeq !== undefined &&
payload.notificationSeq <= fence.notificationSeq
))
)
})
}
@@ -0,0 +1,180 @@
import { createElement } from 'react'
import { act, create, type ReactTestRenderer } from 'react-test-renderer'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { loadHostCatalog } from '../transport/host-store'
import type { HostCatalogEntry } from '../transport/types'
import type { RpcClient } from '../transport/rpc-client'
import { startRuntimeCapabilityProbe } from '../transport/runtime-capability-probe'
import { useAllHostClients } from '../transport/use-all-host-clients'
import {
useRemotePushCapableHosts,
type RemotePushHostSupport
} from './use-remote-push-capable-hosts'
vi.mock('../transport/host-store', () => ({ loadHostCatalog: vi.fn() }))
vi.mock('../transport/use-all-host-clients', () => ({ useAllHostClients: vi.fn() }))
vi.mock('../transport/runtime-capability-probe', () => ({
startRuntimeCapabilityProbe: vi.fn()
}))
// The real module reaches expo-notifications and the preference store for the token
// path; only the capability string matters here.
vi.mock('./push-registration', () => ({
NOTIFICATIONS_REMOTE_PUSH_CAPABILITY: 'notifications.remote-push.v1'
}))
const CAPABILITY = 'notifications.remote-push.v1'
type ClientEntry = { hostId: string; client: RpcClient; state: string }
/** Distinct object per host, so identity changes are the thing under test. */
function clientFor(hostId: string): RpcClient {
return { hostId } as unknown as RpcClient
}
let renderer: ReactTestRenderer | null = null
let latest: RemotePushHostSupport = { supported: false, resolved: false }
const answerByHostId = new Map<string, (capabilities: readonly string[]) => void>()
const stopProbe = vi.fn()
function Harness(): null {
latest = useRemotePushCapableHosts()
return null
}
async function mount(): Promise<void> {
await act(async () => {
renderer = create(createElement(Harness))
await Promise.resolve()
})
}
async function setClients(entries: readonly ClientEntry[]): Promise<void> {
vi.mocked(useAllHostClients).mockReturnValue(entries as never)
await act(async () => {
renderer?.update(createElement(Harness))
await Promise.resolve()
})
}
async function answer(hostId: string, capabilities: readonly string[]): Promise<void> {
await act(async () => {
answerByHostId.get(hostId)?.(capabilities)
await Promise.resolve()
})
}
beforeEach(() => {
vi.clearAllMocks()
answerByHostId.clear()
latest = { supported: false, resolved: false }
vi.mocked(useAllHostClients).mockReturnValue([] as never)
vi.mocked(startRuntimeCapabilityProbe).mockImplementation((client, onCapabilities) => {
answerByHostId.set((client as unknown as { hostId: string }).hostId, onCapabilities)
return stopProbe
})
vi.mocked(loadHostCatalog).mockResolvedValue([
{ id: 'host-1', publicKeyB64: 'k1' },
{ id: 'host-2', publicKeyB64: 'k2' }
] as unknown as HostCatalogEntry[])
})
afterEach(() => {
act(() => renderer?.unmount())
renderer = null
})
describe('useRemotePushCapableHosts', () => {
it('stays unresolved when the host catalog cannot be read', async () => {
vi.mocked(loadHostCatalog).mockRejectedValue(new Error('keychain locked'))
await mount()
// Resolving here would render "Update your desktop app" at someone whose desktop
// is already current, on the strength of a catalog read that simply failed.
expect(latest).toEqual({ supported: false, resolved: false })
})
it('waits for every connected host before answering', async () => {
await mount()
await setClients([
{ hostId: 'host-1', client: clientFor('host-1'), state: 'connected' },
{ hostId: 'host-2', client: clientFor('host-2'), state: 'connected' }
])
await answer('host-1', [CAPABILITY])
expect(latest.resolved).toBe(false)
await answer('host-2', ['some-other.v1'])
expect(latest).toEqual({ supported: true, resolved: true })
})
it('keeps the answer of a host that has since disconnected', async () => {
await mount()
const client = clientFor('host-1')
await setClients([{ hostId: 'host-1', client, state: 'connected' }])
await answer('host-1', [CAPABILITY])
await setClients([{ hostId: 'host-1', client, state: 'connecting' }])
expect(latest).toEqual({ supported: true, resolved: true })
})
it('resolves immediately when nothing is paired', async () => {
vi.mocked(loadHostCatalog).mockResolvedValue([])
await mount()
expect(latest).toEqual({ supported: false, resolved: true })
})
it('leaves a running probe alone when another host changes state', async () => {
await mount()
const first = clientFor('host-1')
await setClients([{ hostId: 'host-1', client: first, state: 'connected' }])
expect(startRuntimeCapabilityProbe).toHaveBeenCalledTimes(1)
// useAllHostClients rebuilds its array on every connection tick, so a plain
// dependency on it would tear down and restart host-1's probe here.
await setClients([
{ hostId: 'host-1', client: first, state: 'connected' },
{ hostId: 'host-2', client: clientFor('host-2'), state: 'connecting' }
])
await setClients([
{ hostId: 'host-1', client: first, state: 'connected' },
{ hostId: 'host-2', client: clientFor('host-2'), state: 'connected' }
])
expect(stopProbe).not.toHaveBeenCalled()
expect(
vi.mocked(startRuntimeCapabilityProbe).mock.calls.map(([client]) => client)
).toHaveLength(2)
})
it('restarts the probe when a reconnect replaces the host client', async () => {
await mount()
await setClients([{ hostId: 'host-1', client: clientFor('host-1'), state: 'connected' }])
await setClients([{ hostId: 'host-1', client: clientFor('host-1'), state: 'connected' }])
expect(stopProbe).toHaveBeenCalledTimes(1)
expect(startRuntimeCapabilityProbe).toHaveBeenCalledTimes(2)
await answer('host-1', [])
expect(latest).toEqual({ supported: false, resolved: true })
await answer('host-1', [CAPABILITY])
expect(latest).toEqual({ supported: true, resolved: true })
})
it('ignores an answer from a host the catalog no longer lists', async () => {
await mount()
await setClients([
{ hostId: 'host-ghost', client: clientFor('host-ghost'), state: 'connected' }
])
await answer('host-ghost', [CAPABILITY])
// An unpaired desktop cannot push to this phone, so its vote must not offer
// the switch — nor count as the answer that resolves the section.
expect(latest).toEqual({ supported: false, resolved: false })
})
})
@@ -0,0 +1,105 @@
import { useEffect, useRef, useState } from 'react'
import { loadHostCatalog } from '../transport/host-store'
import type { RpcClient } from '../transport/rpc-client'
import { startRuntimeCapabilityProbe } from '../transport/runtime-capability-probe'
import { useAllHostClients } from '../transport/use-all-host-clients'
import { NOTIFICATIONS_REMOTE_PUSH_CAPABILITY } from './push-registration'
export type RemotePushHostSupport = {
/** At least one paired host advertises `notifications.remote-push.v1`. */
supported: boolean
/** Whether the answer above is final rather than "nobody has replied yet". */
resolved: boolean
}
/**
* Whether background push can be offered at all. The desktop advertises the
* capability in `status.get`, so the answer needs a connected host — until one
* replies the screen must stay silent rather than tell someone to update a
* desktop that is already current.
*/
export function useRemotePushCapableHosts(): RemotePushHostSupport {
const [hostIds, setHostIds] = useState<string[]>([])
const [hostsLoaded, setHostsLoaded] = useState(false)
const [supportedByHostId, setSupportedByHostId] = useState<Record<string, boolean>>({})
const probesRef = useRef(new Map<string, { client: RpcClient; stop: () => void }>())
useEffect(() => {
let cancelled = false
void loadHostCatalog()
.then((hosts) => {
if (!cancelled) {
setHostIds(hosts.map((host) => host.id))
setHostsLoaded(true)
}
})
// Why nothing on failure: an unread catalog marked loaded resolves the answer as
// "no paired host supports push", which tells the user to update a current desktop.
.catch(() => {})
return () => {
cancelled = true
}
}, [])
const clients = useAllHostClients(hostIds)
// Why pruned rather than left: an answer for a host that is no longer paired is a
// vote from a desktop this phone cannot receive a push from.
useEffect(() => {
setSupportedByHostId((previous) => {
const kept = Object.entries(previous).filter(([hostId]) => hostIds.includes(hostId))
return kept.length === Object.keys(previous).length ? previous : Object.fromEntries(kept)
})
}, [hostIds])
// Why diffed by client identity rather than restarted on every `clients` value:
// useAllHostClients rebuilds the array on each connection tick, so a plain
// dependency tears down and re-runs every host's probe whenever any host moves.
useEffect(() => {
const connected = new Map(
clients
.filter((entry) => entry.state === 'connected')
.map((entry) => [entry.hostId, entry.client])
)
const probes = probesRef.current
for (const [hostId, probe] of probes) {
if (connected.get(hostId) !== probe.client) {
probe.stop()
probes.delete(hostId)
}
}
for (const [hostId, client] of connected) {
if (!probes.has(hostId)) {
const stop = startRuntimeCapabilityProbe(client, (capabilities) => {
setSupportedByHostId((previous) => ({
...previous,
[hostId]: capabilities.includes(NOTIFICATIONS_REMOTE_PUSH_CAPABILITY)
}))
})
probes.set(hostId, { client, stop })
}
}
}, [clients])
useEffect(() => {
const probes = probesRef.current
return () => {
for (const probe of probes.values()) {
probe.stop()
}
probes.clear()
}
}, [])
const answeredHostIds = hostIds.filter((hostId) => hostId in supportedByHostId)
return {
supported: answeredHostIds.some((hostId) => supportedByHostId[hostId]),
// A connected host that has not answered yet is exactly the case the silence is
// for, so one outstanding probe holds the whole section back. Disconnected hosts
// do not: their earlier answer stands, and one that never answered never will.
resolved:
(hostsLoaded && hostIds.length === 0) ||
(answeredHostIds.length > 0 &&
clients.every((entry) => entry.state !== 'connected' || entry.hostId in supportedByHostId))
}
}
+12 -2
View File
@@ -47,16 +47,26 @@ export function MobileOnboardingPage({
)}
</View>
<Text style={styles.title}>
{isSessionView ? 'How should sessions open?' : 'Stay updated while away'}
{isSessionView ? 'How should sessions open?' : 'Enable notifications'}
</Text>
<Text style={styles.body}>
{isSessionView
? 'Choose whether supported agent sessions open in the terminal or Chat UI on this device. Press and hold a session tab to switch its view, or change the default later in Settings.'
: 'Get notified on this device when an agent needs your input or finishes a task.'}
: 'Get notified when an agent finishes a task or needs your input.'}
</Text>
{!isSessionView ? (
<Text style={styles.body}>
By default, notifications arrive after your desktop has been idle for 3 minutes.
</Text>
) : null}
</View>
<View style={styles.footer}>
{!isSessionView ? (
<Text style={styles.disclosure}>
Delivered through Orca’s push service. Change this anytime in Settings.
</Text>
) : null}
{error ? (
<Text style={styles.error} accessibilityRole="alert">
{error}
@@ -10,7 +10,7 @@ const mocks = vi.hoisted(() => ({
animatedTiming: vi.fn(),
ensureNotificationPermissions: vi.fn(),
saveDefaultSessionView: vi.fn(),
savePushNotificationsEnabled: vi.fn()
setRemotePushEnabled: vi.fn()
}))
vi.mock('react-native', () => ({
@@ -48,8 +48,8 @@ vi.mock('../notifications/mobile-notifications', () => ({
vi.mock('../storage/session-view-preferences', () => ({
saveDefaultSessionView: mocks.saveDefaultSessionView
}))
vi.mock('../storage/preferences', () => ({
savePushNotificationsEnabled: mocks.savePushNotificationsEnabled
vi.mock('../notifications/push-registration', () => ({
setRemotePushEnabled: mocks.setRemotePushEnabled
}))
describe('MobileOnboardingScreen', () => {
@@ -64,7 +64,7 @@ describe('MobileOnboardingScreen', () => {
})
mocks.ensureNotificationPermissions.mockReset().mockResolvedValue(true)
mocks.saveDefaultSessionView.mockReset().mockResolvedValue(undefined)
mocks.savePushNotificationsEnabled.mockReset().mockResolvedValue(undefined)
mocks.setRemotePushEnabled.mockReset().mockResolvedValue(undefined)
})
afterEach(() => {
@@ -100,7 +100,30 @@ describe('MobileOnboardingScreen', () => {
await act(async () => pages()[1].props.onNotificationChoice('skip'))
expect(mocks.ensureNotificationPermissions).not.toHaveBeenCalled()
expect(mocks.savePushNotificationsEnabled).toHaveBeenCalledWith(false)
expect(mocks.setRemotePushEnabled).toHaveBeenCalledWith(false)
expect(mocks.replace).toHaveBeenCalledWith('/h/paired-host')
})
it.each([true, false])(
'saves permission result %s through the consent owner once',
async (granted) => {
mocks.params = { hostId: 'paired-host', steps: 'notifications' }
mocks.ensureNotificationPermissions.mockResolvedValue(granted)
await renderScreen()
await act(async () => pages()[0].props.onNotificationChoice('enable'))
expect(mocks.setRemotePushEnabled).toHaveBeenCalledExactlyOnceWith(granted)
expect(mocks.replace).toHaveBeenCalledWith('/h/paired-host')
}
)
it('keeps notification consent retryable when its local write fails', async () => {
mocks.params = { hostId: 'paired-host', steps: 'notifications' }
mocks.setRemotePushEnabled.mockRejectedValueOnce(new Error('disk full'))
await renderScreen()
await act(async () => pages()[0].props.onNotificationChoice('enable'))
expect(pages()[0].props.error).toBe('Notification settings could not be updated. Try again.')
expect(mocks.replace).not.toHaveBeenCalled()
await act(async () => pages()[0].props.onNotificationChoice('enable'))
expect(mocks.replace).toHaveBeenCalledWith('/h/paired-host')
})
@@ -90,6 +90,13 @@ export const mobileOnboardingStyles = StyleSheet.create({
alignSelf: 'center',
paddingBottom: spacing.lg
},
disclosure: {
color: colors.textSecondary,
fontSize: typography.metaSize,
lineHeight: 18,
textAlign: 'center',
marginBottom: spacing.lg
},
primaryButton: {
minHeight: 44,
alignItems: 'center',
@@ -62,8 +62,8 @@ const HOST_COMPONENT_NAMES = new Set([
'View'
])
const HEAD_MAIN_HOOK_SHA256 = '10071240ef9edafc2b9c8bed73be83dceaf7828e3b29f17dab55da020a7697a6'
const HEAD_HOOK_BINDING_SHA256 = '1dadb8c3dc0573ea20659ce7251629669e618dd0effaeac3a4536b29c2e865a1'
const HEAD_MAIN_HOOK_SHA256 = 'c3e33699e3e3fa7e24408f3d4946fcc451e9b9419442d985c4ccde01782e5114'
const HEAD_HOOK_BINDING_SHA256 = '7f907e028893721d662eeee0aa9002ad1e00359948f39fb148d274596cd9b3c0'
const HEAD_CALLBACK_IDENTITY_SHA256 =
'2a9e4825df007f6ef53b81aa5004991d6318eee7507b44d625c07e630be432eb'
const HEAD_CALLBACK_BODY_SHA256 = 'af7f3c62954250d4be7ee432ecd10dc2689792aad8230fed2d1d68bbc892d776'
@@ -472,7 +472,7 @@ describe('mobile session route extraction parity', () => {
const contentBindings = CONTENT_COMPONENT_NAMES.flatMap(
(name) => readHookFacts(name, definitions).bindings
)
expect(main.hooks).toHaveLength(266)
expect(main.hooks).toHaveLength(267)
expect(hash(main.hooks)).toBe(HEAD_MAIN_HOOK_SHA256)
expect(hash(main.bindings)).toBe(HEAD_HOOK_BINDING_SHA256)
expect(main.callbacks).toHaveLength(77)
+4 -2
View File
@@ -3,6 +3,7 @@ import type { HostStackRouteTarget } from '../navigation/host-stack-navigation'
export type MobileSessionRouteParams = {
hostId: string
worktreeId: string
paneKey?: string
name?: string
}
@@ -11,10 +12,11 @@ export type MobileSessionRouteParams = {
export function mobileSessionRouteTarget({
hostId,
worktreeId,
name
name,
paneKey
}: MobileSessionRouteParams): HostStackRouteTarget {
return {
name: '[hostId]/session/[worktreeId]',
params: name ? { hostId, worktreeId, name } : { hostId, worktreeId }
params: { hostId, worktreeId, ...(name ? { name } : {}), ...(paneKey ? { paneKey } : {}) }
}
}
@@ -1,3 +1,4 @@
import { useNotificationPaneNavigation } from './use-notification-pane-navigation'
import { useMobileSessionFoundation } from './use-mobile-session-foundation'
import { useMobileSessionScreenState } from './use-mobile-session-screen-state'
import { useMobileSessionTerminalRuntime } from './use-mobile-session-terminal-runtime'
@@ -82,6 +83,7 @@ export function useMobileSessionController() {
useMobileSessionStartup(keyboardState)
useMobileSessionPreferenceFocus(keyboardState)
const tabSwitching = Object.assign(keyboardState, useMobileSessionTabSwitching(keyboardState))
useNotificationPaneNavigation(tabSwitching)
const terminalWebview = Object.assign(tabSwitching, useMobileSessionTerminalWebview(tabSwitching))
const terminalSendActions = Object.assign(
terminalWebview,
@@ -0,0 +1,67 @@
import { createElement } from 'react'
import { act, create } from 'react-test-renderer'
import { expect, it, vi } from 'vitest'
import {
notificationPaneTab,
useNotificationPaneNavigation
} from './use-notification-pane-navigation'
import type { MobileSessionTab } from './mobile-session-route-types'
const route = vi.hoisted(() => ({ paneKey: '', setParams: vi.fn() }))
vi.mock('expo-router', () => ({
useLocalSearchParams: () => ({ paneKey: route.paneKey }),
useRouter: () => ({ setParams: route.setParams })
}))
const leaf = '11111111-1111-4111-8111-111111111111'
const tabs: MobileSessionTab[] = [
{
type: 'terminal',
id: 'first',
parentTabId: 'tab-a',
leafId: leaf,
title: 'first',
terminal: 'pty-a',
isActive: true
},
{
type: 'terminal',
id: 'second',
parentTabId: 'tab-b',
leafId: leaf,
title: 'agent',
terminal: 'pty-b',
isActive: false
}
]
it('selects the originating split pane, not the first tab; closed and invalid panes fall back', () => {
expect(notificationPaneTab(tabs, `tab-b:${leaf}`)).toBe(tabs[1])
expect(notificationPaneTab(tabs, `closed:${leaf}`)).toBeUndefined()
expect(notificationPaneTab(tabs, 'invalid')).toBeUndefined()
})
it('waits for tabs, switches through the existing action, and consumes the navigation request', async () => {
route.paneKey = `tab-b:${leaf}`
const switchSessionTab = vi.fn()
function Probe({ loaded }: { loaded: boolean }) {
useNotificationPaneNavigation({
sessionTabs: loaded ? tabs : [],
terminalsLoaded: loaded,
switchSessionTab
})
return null
}
let renderer: ReturnType<typeof create>
await act(async () => {
renderer = create(createElement(Probe, { loaded: false }))
})
expect(switchSessionTab).not.toHaveBeenCalled()
await act(async () => {
renderer.update(createElement(Probe, { loaded: true }))
})
expect(switchSessionTab).toHaveBeenCalledExactlyOnceWith(tabs[1])
expect(route.setParams).toHaveBeenCalledWith({ paneKey: '' })
route.paneKey = ''
await act(async () => {
renderer.update(createElement(Probe, { loaded: true }))
})
expect(switchSessionTab).toHaveBeenCalledOnce()
await act(async () => renderer.unmount())
})
@@ -0,0 +1,40 @@
import { useEffect } from 'react'
import { useLocalSearchParams, useRouter } from 'expo-router'
import { parsePaneKey } from '../../../src/shared/stable-pane-id'
import type { MobileSessionTab } from './mobile-session-route-types'
export function notificationPaneTab(tabs: readonly MobileSessionTab[], paneKey: string) {
const pane = parsePaneKey(paneKey)
if (!pane) {
return undefined
}
return tabs.find((tab) =>
tab.type === 'terminal'
? (tab.parentTabId ?? tab.id) === pane.tabId && tab.leafId === pane.leafId
: tab.type === 'agent-session' && tab.id === pane.tabId
)
}
export function useNotificationPaneNavigation({
sessionTabs,
terminalsLoaded,
switchSessionTab
}: {
sessionTabs: MobileSessionTab[]
terminalsLoaded: boolean
switchSessionTab: (tab: MobileSessionTab) => void
}) {
const { paneKey } = useLocalSearchParams<{ paneKey?: string }>()
const router = useRouter()
useEffect(() => {
if (!terminalsLoaded || typeof paneKey !== 'string' || !paneKey) {
return
}
const tab = notificationPaneTab(sessionTabs, paneKey)
// Consume the tap even if the pane was closed; later snapshots must not steal selection.
router.setParams({ paneKey: '' })
if (tab) {
switchSessionTab(tab)
}
}, [paneKey, terminalsLoaded, sessionTabs, switchSessionTab, router])
}
@@ -0,0 +1,100 @@
import { createElement, useEffect } from 'react'
import { act, create, type ReactTestRenderer } from 'react-test-renderer'
import { afterEach, beforeEach, expect, it, vi } from 'vitest'
import { NativeNotificationDeliverySettings } from './native-notification-delivery-settings'
const mocks = vi.hoisted(() => ({
load: vi.fn(),
save: vi.fn(),
support: { resolved: true, supported: false }
}))
vi.mock('react-native', () => ({
Text: 'Text',
AppState: { addEventListener: () => ({ remove() {} }) }
}))
vi.mock('expo-router', () => ({
useFocusEffect: (callback: () => void) => useEffect(callback, [callback])
}))
vi.mock('../notifications/NotificationDeliverySection', () => ({
NotificationDeliverySection: 'Delivery'
}))
vi.mock('../notifications/notification-delivery-preferences', () => ({
DEFAULT_NOTIFICATION_DELIVERY: {
onlyWhenDesktopAway: true,
sound: true,
suppressWhileViewing: true
},
loadNotificationDeliveryPreferences: mocks.load
}))
vi.mock('../notifications/push-registration', () => ({
setNotificationDeliveryPreferences: mocks.save
}))
vi.mock('../notifications/use-remote-push-capable-hosts', () => ({
useRemotePushCapableHosts: () => mocks.support
}))
let renderer: ReactTestRenderer
const preferences = { onlyWhenDesktopAway: false, sound: false, suppressWhileViewing: true }
beforeEach(() => {
mocks.load.mockReset().mockResolvedValue(preferences)
mocks.save.mockReset().mockResolvedValue(undefined)
mocks.support = { resolved: true, supported: false }
})
afterEach(() => {
act(() => renderer?.unmount())
})
const section = () => renderer.root.findByType('Delivery').props
it('keeps stored controls visible but disabled without consent and explains an old host', async () => {
await act(async () => {
renderer = create(createElement(NativeNotificationDeliverySettings, { enabled: false }))
})
expect(section().value).toEqual(preferences)
expect(section().disabled).toBe(true)
expect(JSON.stringify(renderer.toJSON())).toContain('Pair an updated desktop')
await act(async () => {
renderer.update(createElement(NativeNotificationDeliverySettings, { enabled: true }))
})
expect(section().disabled).toBe(false)
})
it('disables edits until preferences load, then waits for save and retains the prior value on failure', async () => {
let load!: (value: typeof preferences) => void
mocks.load.mockReturnValue(
new Promise((resolve) => {
load = resolve
})
)
await act(async () => {
renderer = create(createElement(NativeNotificationDeliverySettings, { enabled: true }))
})
expect(section().disabled).toBe(true)
await act(async () => {
load(preferences)
})
let reject!: (error: Error) => void
mocks.save.mockReturnValue(
new Promise((_resolve, fail) => {
reject = fail
})
)
await act(async () => {
section().onChange({ ...preferences, sound: true })
})
expect(section().disabled).toBe(true)
await act(async () => {
reject(new Error('storage unavailable'))
})
expect(section().value).toEqual(preferences)
expect(section().disabled).toBe(false)
expect(JSON.stringify(renderer.toJSON())).toContain('Could not save delivery settings')
})
it('does not claim an upgrade is needed while probing or when a host supports push', async () => {
mocks.support = { resolved: false, supported: false }
await act(async () => {
renderer = create(createElement(NativeNotificationDeliverySettings, { enabled: true }))
})
expect(JSON.stringify(renderer.toJSON())).not.toContain('Pair an updated desktop')
mocks.support = { resolved: true, supported: true }
await act(async () => {
renderer.update(createElement(NativeNotificationDeliverySettings, { enabled: true }))
})
expect(JSON.stringify(renderer.toJSON())).not.toContain('Pair an updated desktop')
})
@@ -0,0 +1,78 @@
import { useCallback, useEffect, useState } from 'react'
import { AppState, Text } from 'react-native'
import { useFocusEffect } from 'expo-router'
import { NotificationDeliverySection } from '../notifications/NotificationDeliverySection'
import {
DEFAULT_NOTIFICATION_DELIVERY,
loadNotificationDeliveryPreferences,
type NotificationDeliveryPreferences
} from '../notifications/notification-delivery-preferences'
import { setNotificationDeliveryPreferences } from '../notifications/push-registration'
import { useRemotePushCapableHosts } from '../notifications/use-remote-push-capable-hosts'
import { colors, spacing, typography } from '../theme/mobile-theme'
export function NativeNotificationDeliverySettings({ enabled }: { enabled: boolean }) {
const [delivery, setDelivery] = useState(DEFAULT_NOTIFICATION_DELIVERY)
const [loaded, setLoaded] = useState(false)
const [saving, setSaving] = useState(false)
const [error, setError] = useState<string | null>(null)
const support = useRemotePushCapableHosts()
const refresh = useCallback(async () => {
try {
setDelivery(await loadNotificationDeliveryPreferences())
setLoaded(true)
setError(null)
} catch {
setError('Could not load delivery settings. Reopen this screen to retry.')
}
}, [])
useFocusEffect(
useCallback(() => {
void refresh()
}, [refresh])
)
useEffect(() => {
const subscription = AppState.addEventListener('change', (state) => {
if (state === 'active') {
void refresh()
}
})
return () => subscription.remove()
}, [refresh])
const change = async (value: NotificationDeliveryPreferences) => {
setSaving(true)
setError(null)
try {
await setNotificationDeliveryPreferences(value)
setDelivery(value)
} catch {
setError('Could not save delivery settings. Try again.')
} finally {
setSaving(false)
}
}
const hintStyle = {
color: colors.textMuted,
fontSize: typography.metaSize,
marginTop: spacing.md
}
return (
<>
<NotificationDeliverySection
value={delivery}
disabled={!enabled || !loaded || saving}
onChange={(value) => void change(value)}
/>
{error && (
<Text accessibilityRole="alert" style={hintStyle}>
{error}
</Text>
)}
{support.resolved && !support.supported && (
<Text style={hintStyle}>
Pair an updated desktop to receive notifications on this phone.
</Text>
)}
</>
)
}
@@ -3,7 +3,8 @@ import {
ensureNotificationPermissions,
getNotificationPermissionState
} from '../notifications/notification-permissions'
import { loadPushNotificationsEnabled, savePushNotificationsEnabled } from '../storage/preferences'
import { loadPushNotificationsEnabled } from '../storage/preferences'
import { setRemotePushEnabled } from '../notifications/push-registration'
import type { NotificationSettingsOperations } from './notification-settings-operations'
export const nativeNotificationSettingsOperations: NotificationSettingsOperations = {
@@ -15,7 +16,7 @@ export const nativeNotificationSettingsOperations: NotificationSettingsOperation
},
async preference(enabled) {
if (enabled !== undefined) {
await savePushNotificationsEnabled(enabled)
await setRemotePushEnabled(enabled)
}
return { enabled: await loadPushNotificationsEnabled() }
},
@@ -1,5 +1,5 @@
import { useState, useCallback, useEffect } from 'react'
import { AppState, View, Text, StyleSheet, Pressable, Switch } from 'react-native'
import { useState, useCallback, useEffect, type ReactNode } from 'react'
import { AppState, View, Text, StyleSheet, Pressable, Switch, ScrollView } from 'react-native'
import { useSafeAreaInsets } from 'react-native-safe-area-context'
import { useFocusEffect } from 'expo-router'
import type { NotificationSettingsOperations } from './notification-settings-operations'
@@ -16,12 +16,17 @@ const DEFAULT_PERMISSION_STATE: NotificationPermissionState = {
export default function NotificationsScreen({
operations,
onBack
onBack,
description,
children
}: {
operations: NotificationSettingsOperations
onBack: () => void
description?: string
children?: (enabled: boolean) => ReactNode
}) {
const insets = useSafeAreaInsets()
const [saving, setSaving] = useState(false)
const [error, setError] = useState<string | null>(null)
const [pushEnabled, setPushEnabled] = useState(false)
const [permissionState, setPermissionState] = useState(DEFAULT_PERMISSION_STATE)
@@ -57,6 +62,7 @@ export default function NotificationsScreen({
const togglePush = async (value: boolean) => {
setError(null)
setSaving(true)
try {
const permission = await operations.permission(value)
setPermissionState(permission)
@@ -64,6 +70,8 @@ export default function NotificationsScreen({
setPushEnabled(saved.enabled)
} catch {
setError('Could not save notification settings. Try again.')
} finally {
setSaving(false)
}
}
@@ -71,10 +79,17 @@ export default function NotificationsScreen({
const notificationsBlocked = permissionState.status === 'denied'
const hint = notificationsBlocked
? 'Notifications are disabled in system settings.'
: 'Get notified on this device when an agent needs your input or finishes a task.'
: (description ??
'Get notified on this device when an agent needs your input or finishes a task.')
return (
<View style={[styles.container, { paddingTop: insets.top + spacing.sm }]}>
<ScrollView
style={styles.container}
contentContainerStyle={{
paddingTop: insets.top + spacing.sm,
paddingBottom: insets.bottom + spacing.xl
}}
>
<View style={styles.topRow}>
<Pressable
accessibilityRole="button"
@@ -94,12 +109,12 @@ export default function NotificationsScreen({
)}
<View style={styles.section}>
<View style={styles.row}>
<Text style={styles.rowLabel}>Agent notifications</Text>
<Text style={styles.rowLabel}>Enable notifications</Text>
<Switch
value={switchEnabled}
testID="notification-enabled"
accessibilityLabel="Agent notifications"
disabled={notificationsBlocked}
accessibilityLabel="Enable notifications"
disabled={notificationsBlocked || saving}
onValueChange={(v) => void togglePush(v)}
trackColor={{ false: colors.bgRaised, true: colors.textSecondary }}
thumbColor={colors.textPrimary}
@@ -123,7 +138,8 @@ export default function NotificationsScreen({
</Pressable>
)}
</View>
</View>
{children?.(switchEnabled && !saving)}
</ScrollView>
)
}
+22 -7
View File
@@ -278,8 +278,18 @@ describe('push notification preference', () => {
vi.mocked(AsyncStorage.setItem).mockReset()
})
it.each(['true', 'false'])('requires fresh consent for legacy choice %s', async (legacy) => {
vi.mocked(AsyncStorage.getItem).mockImplementation(async (key) =>
key === 'orca:pushNotificationsEnabled' ? legacy : null
)
await expect(readPushNotificationsPreference()).resolves.toEqual({ value: null, loaded: true })
await expect(loadPushNotificationsEnabled()).resolves.toBe(false)
})
it('distinguishes an unset preference from an explicit disabled choice', async () => {
vi.mocked(AsyncStorage.getItem).mockResolvedValue(null)
vi.mocked(AsyncStorage.getItem).mockImplementation(async (key) =>
key === 'orca:remotePushEnabled' ? 'true' : null
)
await expect(readPushNotificationsPreference()).resolves.toEqual({
value: null,
loaded: true
@@ -303,12 +313,17 @@ describe('push notification preference', () => {
await expect(loadPushNotificationsEnabled()).resolves.toBe(false)
})
it('persists the onboarding decision in the existing mobile toggle', async () => {
await savePushNotificationsEnabled(true)
expect(AsyncStorage.setItem).toHaveBeenCalledWith('orca:pushNotificationsEnabled', 'true')
await savePushNotificationsEnabled(false)
expect(AsyncStorage.setItem).toHaveBeenCalledWith('orca:pushNotificationsEnabled', 'false')
it('persists and reloads master consent', async () => {
const storage = new Map<string, string>()
vi.mocked(AsyncStorage.getItem).mockImplementation(async (key) => storage.get(key) ?? null)
vi.mocked(AsyncStorage.setItem).mockImplementation(async (key, value) => {
storage.set(key, value)
})
for (const enabled of [true, false]) {
await savePushNotificationsEnabled(enabled)
await expect(loadPushNotificationsEnabled()).resolves.toBe(enabled)
}
expect([...storage]).toEqual([['orca:pushServiceNotificationsEnabled', 'false']])
})
})
+39 -1
View File
@@ -1,7 +1,8 @@
import AsyncStorage from '@react-native-async-storage/async-storage'
const PINS_PREFIX = 'orca:pins:'
const NOTIF_KEY = 'orca:pushNotificationsEnabled'
// Consent to the push service is separate from the old socket notification choice.
const NOTIF_KEY = 'orca:pushServiceNotificationsEnabled'
export type PushNotificationsPreference = {
readonly value: boolean | null
@@ -30,6 +31,43 @@ export async function savePushNotificationsEnabled(enabled: boolean): Promise<vo
await AsyncStorage.setItem(NOTIF_KEY, String(enabled))
}
const REMOTE_PUSH_HOST_REGISTRATIONS_KEY = 'orca:remotePushHostRegistrations'
// Why persisted: switching off while a host is offline leaves a token the gateway
// would still push to. The pending list is the phone's side of the desktop's
// unregister outbox — it survives a restart so the retry actually happens.
export type RemotePushHostRegistrations = {
readonly registeredHostIds: readonly string[]
readonly pendingUnregisterHostIds: readonly string[]
}
const EMPTY_REMOTE_PUSH_HOST_REGISTRATIONS: RemotePushHostRegistrations = {
registeredHostIds: [],
pendingUnregisterHostIds: []
}
export async function loadRemotePushHostRegistrations(): Promise<RemotePushHostRegistrations> {
try {
const raw = await AsyncStorage.getItem(REMOTE_PUSH_HOST_REGISTRATIONS_KEY)
if (!raw) {
return EMPTY_REMOTE_PUSH_HOST_REGISTRATIONS
}
const parsed = JSON.parse(raw) as Record<string, unknown>
return {
registeredHostIds: stringArray(parsed.registeredHostIds),
pendingUnregisterHostIds: stringArray(parsed.pendingUnregisterHostIds)
}
} catch {
return EMPTY_REMOTE_PUSH_HOST_REGISTRATIONS
}
}
export async function saveRemotePushHostRegistrations(
value: RemotePushHostRegistrations
): Promise<void> {
await AsyncStorage.setItem(REMOTE_PUSH_HOST_REGISTRATIONS_KEY, JSON.stringify(value))
}
const TEXT_SCALE_KEY = 'orca:terminalTextScale'
// Why: the mobile terminal fits the desktop's full column count to the phone
@@ -1,91 +1,50 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
const removeHostMock = vi.hoisted(() => vi.fn())
const asyncStorage = vi.hoisted(() => ({
getItem: vi.fn(async () => null),
setItem: vi.fn(async () => undefined),
// Why removeItem is here: clearWatermark() swallows its own failures, so a mock
// missing this method turns the persisted-watermark cleanup into a caught
// TypeError — the assertion below would pass even if the call were deleted.
removeItem: vi.fn(async () => undefined)
}))
vi.mock('@react-native-async-storage/async-storage', () => ({ default: asyncStorage }))
const unregisterPushMock = vi.hoisted(() => vi.fn(async () => vi.fn()))
vi.mock('./host-store', () => ({
removeHost: (hostId: string) => removeHostMock(hostId)
}))
vi.mock('../notifications/push-registration', () => ({
unregisterPushForRemovedHost: (hostId: string) => unregisterPushMock(hostId)
}))
import { removeHostAndCloseClient } from './host-removal-lifecycle'
import {
getHostNotificationSession,
resetHostNotificationSessionsForTests
} from '../notifications/notification-reconnect-catchup'
describe('host removal lifecycle', () => {
beforeEach(() => {
removeHostMock.mockReset()
asyncStorage.removeItem.mockClear()
resetHostNotificationSessionsForTests()
unregisterPushMock.mockClear()
})
it('closes the client only after metadata removal commits', async () => {
let commitRemoval: (() => void) | null = null
removeHostMock.mockReturnValue(
new Promise<void>((resolve) => {
commitRemoval = resolve
})
)
removeHostMock.mockReturnValue(new Promise<void>((resolve) => (commitRemoval = resolve)))
const closeHostClient = vi.fn()
const removal = removeHostAndCloseClient('host-1', closeHostClient)
expect(closeHostClient).not.toHaveBeenCalled()
commitRemoval?.()
await removal
expect(closeHostClient).toHaveBeenCalledWith('host-1')
})
it('keeps the client open when metadata removal fails', async () => {
removeHostMock.mockRejectedValue(new Error('storage unavailable'))
const closeHostClient = vi.fn()
await expect(removeHostAndCloseClient('host-1', closeHostClient)).rejects.toThrow(
'storage unavailable'
)
expect(closeHostClient).not.toHaveBeenCalled()
})
it('retires the notification session so a removed host leaves nothing behind', async () => {
// Round-1 review finding: the session lives at module scope (it must survive the
// subscription teardown a reconnect performs), so removal is the only thing that
// can retire it. Left behind, each remove/re-pair cycle strands a session plus up
// to 512 seen keys, and a re-paired host inherits a watermark it never earned.
it('drops the gateway push registration before the credentials it needs are gone', async () => {
removeHostMock.mockResolvedValue(undefined)
const session = getHostNotificationSession('host-1')
session.lastDeliveredSeq = 42
session.lastDeliveredEpoch = 'epoch-A'
await removeHostAndCloseClient('host-1', vi.fn())
// A fresh session for the same id — not the retained one.
const afterRemoval = getHostNotificationSession('host-1')
expect(afterRemoval).not.toBe(session)
expect(afterRemoval.lastDeliveredSeq).toBe(0)
expect(afterRemoval.lastDeliveredEpoch).toBeNull()
})
it('erases the persisted watermark, not just the in-memory session', async () => {
// Why separately from the test above: the session is process-local, the
// watermark is not. Retiring only the session lets a re-pair of the same host
// read the old seq off disk and resume against a counter it never saw — the
// catch-up would then start above the real cut and drop everything below it.
removeHostMock.mockResolvedValue(undefined)
await removeHostAndCloseClient('host-1', vi.fn())
// clearWatermark is fire-and-forget; let its microtask land.
await Promise.resolve()
expect(asyncStorage.removeItem).toHaveBeenCalledWith('orca:mobileNotificationsWatermark:host-1')
expect(unregisterPushMock).toHaveBeenCalledWith('host-1')
expect(unregisterPushMock.mock.invocationCallOrder[0]).toBeLessThan(
removeHostMock.mock.invocationCallOrder[0]
)
})
})
+10 -10
View File
@@ -1,20 +1,20 @@
import {
clearWatermark,
forgetHostNotificationSession
} from '../notifications/notification-reconnect-catchup'
import { unregisterPushForRemovedHost } from '../notifications/push-registration'
import { removeHost } from './host-store'
export async function removeHostAndCloseClient(
hostId: string,
forgetHostClient: (hostId: string) => void
): Promise<void> {
// Why before removeHost: the unregister needs the still-authenticated client, and
// the desktop's own revoke path covers the case where this call cannot land.
const restorePushRegistration = await unregisterPushForRemovedHost(hostId)
// Why: closing before the metadata commit can strand a still-paired host on
// storage failure; closing immediately after success prevents socket leaks.
await removeHost(hostId)
try {
await removeHost(hostId)
} catch (error) {
restorePushRegistration()
throw error
}
forgetHostClient(hostId)
// Why: the notification session outlives the socket by design (it must survive
// reconnects), so removal is the only thing that can retire it. Left behind, a
// re-pair of the same host would inherit a watermark for a counter it never saw.
forgetHostNotificationSession(hostId)
void clearWatermark(hostId)
}
@@ -10,7 +10,7 @@ const FAILURE_RETRY_BASE_DELAY_MS = 1_000
const FAILURE_RETRY_MAX_DELAY_MS = 15_000
export function startRuntimeCapabilityProbe(
client: RpcClient,
client: Pick<RpcClient, 'sendRequest'>,
onCapabilities: (capabilities: readonly string[]) => void
): () => void {
let cancelled = false