mirror of
https://github.com/stablyai/orca.git
synced 2026-09-28 08:02:43 +00:00
Restore mobile push for delivery validation
This commit is contained in:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user