mirror of
https://github.com/stablyai/orca.git
synced 2026-09-22 00:02:31 +00:00
feat(mobile): receive native push notifications from paired desktops (#19951)
* feat(mobile): deliver native push notifications from paired desktops * fix(mobile): retry push capability probes * fix(mobile): cancel retired push capability probes * fix(mobile): ignore stale push reconciliations * fix(mobile): type capability probe at its boundary * fix(notifications): route mobile push taps to the originating pane * Require explicit mobile push-service consent on upgrade
This commit is contained in:
@@ -94,6 +94,13 @@ jobs:
|
||||
run: node -e 'const fs = require("node:fs"); const { expo } = require("./app.json"); fs.appendFileSync(process.env.GITHUB_OUTPUT, `version=${expo.version}\nbuild_number=${expo.ios.buildNumber}\n`)'
|
||||
|
||||
- name: Expo prebuild
|
||||
# Why the env var: app.config.js derives the expo-notifications plugin's
|
||||
# `mode` from it, which is what writes `aps-environment: production` into the
|
||||
# entitlements. push-token.ts reports a production APNs environment for every
|
||||
# non-__DEV__ build, so a development entitlement here would leave TestFlight
|
||||
# and App Store builds registered against a sandbox they never receive from.
|
||||
env:
|
||||
ORCA_IOS_APS_ENVIRONMENT: production
|
||||
run: npx expo prebuild --platform ios --no-install
|
||||
|
||||
- name: Install CocoaPods
|
||||
|
||||
@@ -83,3 +83,17 @@ jobs:
|
||||
|
||||
- name: Check formatting
|
||||
run: pnpm format:check
|
||||
|
||||
native-dismissal:
|
||||
runs-on: macos-15
|
||||
env:
|
||||
ORCA_BACKGROUND_LAUNCH: '1'
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
- name: Check native dismissal ledger
|
||||
run: |
|
||||
swiftc mobile/modules/orca-notification-dismissal/ios/PushDismissalLedger.swift \
|
||||
mobile/modules/orca-notification-dismissal/tests/PushDismissalLedgerChecks.swift \
|
||||
-o "$RUNNER_TEMP/push-dismissal-ledger-checks"
|
||||
"$RUNNER_TEMP/push-dismissal-ledger-checks"
|
||||
|
||||
@@ -5,6 +5,7 @@ export type PushOrcaData = {
|
||||
kind?: 'alert' | 'dismiss'
|
||||
hostFingerprint: string
|
||||
worktreeId?: string
|
||||
paneKey?: string
|
||||
notificationId?: string
|
||||
notificationSeq: number
|
||||
notificationEpoch: string
|
||||
@@ -51,6 +52,7 @@ export function buildPushDelivery(input: {
|
||||
orca: {
|
||||
...(notification.kind ? { kind: notification.kind } : {}),
|
||||
hostFingerprint,
|
||||
...(notification.paneKey === undefined ? {} : { paneKey: notification.paneKey }),
|
||||
...(notification.worktreeId === undefined ? {} : { worktreeId: notification.worktreeId }),
|
||||
...(notification.notificationId === undefined
|
||||
? {}
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
import { expect, it } from 'vitest'
|
||||
import { PushNotificationSchema } from '@orca-cloud/push-contract'
|
||||
import { buildPushDelivery, orcaDataStrings } from './push-delivery-message.js'
|
||||
|
||||
it('preserves pane identity for both APNs and FCM, and accepts older workspace-only messages', () => {
|
||||
const base = { notificationSeq: 1, notificationEpoch: 'epoch', source: 'agent-task-complete', agentState: 'finished', title: 'Done', body: '', worktreeId: 'folder:/work' }
|
||||
const paneKey = 'tab-b:11111111-1111-4111-8111-111111111111'
|
||||
for (const extra of [{}, { paneKey }]) {
|
||||
const notification = PushNotificationSchema.parse({ ...base, ...extra })
|
||||
const delivery = buildPushDelivery({ notification, hostFingerprint: 'host', registrationId: 'phone', expiresAt: Date.now() + 300000 })
|
||||
expect(delivery.orca.paneKey).toBe('paneKey' in extra ? paneKey : undefined)
|
||||
expect(orcaDataStrings(delivery.orca).paneKey).toBe('paneKey' in extra ? paneKey : undefined)
|
||||
}
|
||||
})
|
||||
@@ -26,7 +26,8 @@ export const PushNotificationSchema = z
|
||||
agentState: PushAgentStateSchema.nullable(),
|
||||
title: z.string().min(1).max(PUSH_LIMITS.titleMaxChars),
|
||||
body: z.string().max(PUSH_LIMITS.bodyMaxChars),
|
||||
worktreeId: z.string().min(1).max(2048).optional()
|
||||
worktreeId: z.string().min(1).max(2048).optional(),
|
||||
paneKey: z.string().min(1).max(2048).optional()
|
||||
})
|
||||
.strict()
|
||||
.refine(
|
||||
|
||||
@@ -31,7 +31,7 @@ The Orca mobile companion is an iOS/Android app that pairs with your desktop Orc
|
||||
- Create a workspace from mobile with the same Smart source modes as desktop: Smart, GitHub, Linear, GitLab, Branch, and Name. With **multiple connected desktops**, **New Workspace** asks which host should create it first (one connected host skips the picker).
|
||||
- Open a host card's **⋯** menu for **Edit**, **Connect**, **Remove**, and related actions (long-press still works as a shortcut).
|
||||
- Edit a saved host's display name or connection address without re-pairing (for example when the desktop moves between home LAN and Tailscale).
|
||||
- Get push notifications when an agent finishes, mirroring [desktop notifications](/docs/notifications).
|
||||
- Get push notifications when an agent finishes or needs input, mirroring [desktop notifications](/docs/notifications). Turn on **Enable notifications** in the phone's Notifications settings to keep receiving them while Orca is closed; see [Notifications](/docs/notifications#notifications-on-your-phone) for what that sends and where.
|
||||
|
||||
The mobile app is intentionally not a full editor — it's a remote control for the desktop you already have running.
|
||||
|
||||
|
||||
@@ -29,3 +29,21 @@ Pick a custom desktop notification sound per category under [Settings → Notifi
|
||||
Supported formats: MP3, WAV, OGG, M4A, AAC, FLAC. One file applies to all delivered desktop notifications.
|
||||
|
||||
When you use a custom sound, set its playback volume from the same settings pane.
|
||||
|
||||
## Notifications on your phone
|
||||
|
||||
Turn on **Enable notifications** in the mobile app to receive agent alerts, including while the app is closed. Notifications are delivered through Orca’s push service and Apple or Google.
|
||||
|
||||
The service receives the notification title and text, a native push token, and opaque host and device IDs. Notification text is not end-to-end encrypted. Turning notifications off or unpairing queues removal of the push registration; an offline desktop completes that removal when contact returns.
|
||||
|
||||
Alert types always follow each paired desktop’s notification settings. **Notification sound** and **Suppress while focused** apply only to the phone; the latter skips alerts for the workspace currently open there.
|
||||
|
||||
**Only when away from desktop** is on by default. Phone alerts are suppressed while the desktop has had keyboard or mouse activity within the last three minutes. Locking the desktop allows alerts immediately. This includes activity in other desktop apps. Suppressed events do not arrive later as a backlog. When desktop presence cannot be determined, alerts are allowed.
|
||||
|
||||
Notifications pause after seven days without a foreground mobile registration renewal. Open the mobile app and reconnect to resume. Using desktop alone does not renew this phone’s registration. The app renews while it is open and connected, including periodically during longer visits.
|
||||
|
||||
Phone notifications require an updated paired desktop with push support. This mobile version does not create notifications from the live connection to older desktops; update the desktop to receive phone alerts. Headless hosts cannot determine desktop presence and do not infer it from SSH activity; agent completion detection still requires the desktop app. Android background delivery requires Google Play services.
|
||||
|
||||
The push service temporarily stores pending notification content for delivery, for up to five minutes
|
||||
plus cleanup time. Content is cleared after processing; delivery identifiers and outcomes are retained
|
||||
for 24 hours for retry and duplicate protection. Alerts expire after five minutes.
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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.')
|
||||
|
||||
@@ -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 |
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
+26
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
Generated
+1223
-1225
File diff suppressed because it is too large
Load Diff
@@ -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
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
})
|
||||
}
|
||||
)
|
||||
@@ -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]
|
||||
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()
|
||||
})
|
||||
})
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -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']])
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -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]
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -60,6 +60,7 @@ describe('registerNotificationHandlers', () => {
|
||||
{
|
||||
source: 'agent-task-complete',
|
||||
worktreeId: 'repo::wt1',
|
||||
paneKey: 'tab-b:11111111-1111-4111-8111-111111111111',
|
||||
worktreeLabel: 'feat/notis',
|
||||
agentType: 'hermes',
|
||||
agentState: 'done',
|
||||
@@ -76,6 +77,7 @@ describe('registerNotificationHandlers', () => {
|
||||
title: 'feat/notis - Hermes finished',
|
||||
body: 'The diff updates notification formatting.',
|
||||
worktreeId: 'repo::wt1',
|
||||
paneKey: 'tab-b:11111111-1111-4111-8111-111111111111',
|
||||
agentState: 'done'
|
||||
})
|
||||
expect(notificationCtorMock).not.toHaveBeenCalled()
|
||||
|
||||
@@ -147,6 +147,7 @@ export function registerNotificationHandlers(store: Store, runtime?: OrcaRuntime
|
||||
title: notificationOptions.title,
|
||||
body: notificationOptions.body,
|
||||
worktreeId: args.worktreeId,
|
||||
...(args.paneKey ? { paneKey: args.paneKey } : {}),
|
||||
...(args.notificationId ? { notificationId: args.notificationId } : {}),
|
||||
// Why: background push needs the agent's real state to pick "needs input"
|
||||
// vs "finished" — and to stay silent while the agent is still working.
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user