diff --git a/.changeset/live-activity-push-state-getters.md b/.changeset/live-activity-push-state-getters.md new file mode 100644 index 000000000..c58522055 --- /dev/null +++ b/.changeset/live-activity-push-state-getters.md @@ -0,0 +1,7 @@ +--- +'@use-voltra/ios-client': minor +--- + +Read Live Activity push state at any time: `getLiveActivityPushTokens()` returns the push token of every running Live Activity, `getLiveActivityPushToStartToken()` returns the push-to-start token, and `areLiveActivitiesEnabled()` reports whether the user allows Live Activities. A new `activityEnablementChanged` event fires when that setting changes. + +Button and toggle taps (`interaction` events) are no longer delivered twice. A tap your app already handled is not replayed on the next launch or reload, and a tap made while your app was killed or in the background arrives once your `interaction` listener is attached and the app is running. Taps are kept for up to 15 minutes (the 100 most recent), each event carries a unique `id`, and a mounted `VoltraView` no longer takes taps meant for your Live Activities. Keeping taps across launches, and receiving taps handled outside your app's process, requires `groupIdentifier` in the plugin config. Taps queued by an earlier release are discarded on upgrade. Code that subscribed to `getNativeVoltra().onInteraction` directly must switch to `addVoltraListener('interaction')`. diff --git a/docs/adr/0011-live-activity-push-state-getters-and-interaction-drain.md b/docs/adr/0011-live-activity-push-state-getters-and-interaction-drain.md new file mode 100644 index 000000000..a11424842 --- /dev/null +++ b/docs/adr/0011-live-activity-push-state-getters-and-interaction-drain.md @@ -0,0 +1,263 @@ +# ADR 0011: Live Activity push state getters and interaction drain + +Status: Accepted + +Implemented by [#353](https://github.com/callstackincubator/voltra/pull/353). + +## Introduction + +An iOS app that drives Live Activities from a server needs three things from the device: the push +token of each running activity, the push-to-start token, and whether the user allows Live Activities +at all. Voltra delivers the first two only as events, and the third not at all. An app that +subscribes a moment too late never learns its tokens, and has to start a new activity to get one. + +The same native event path also carries button and toggle taps (`interaction` events). Those are +queued in the App Group so a tap that launches the app is not lost, but the queue is cleared at the +wrong time: a tap the app already handled is delivered again on the next launch or Metro reload, and +a tap that launches the app can be replayed before any JavaScript listener exists. + +This ADR records how each kind of native data reaches JavaScript: state through getters plus change +events, interactions through a native queue that JavaScript pulls from when it is ready. + +## Context + +Native code starts monitoring when the TurboModule is created (`NativeVoltra.mm`, +`setEventEmitterCallback`). `VoltraEventBus` then routes every native event one of two ways: + +- **Transient** — `stateChange`, `activityTokenReceived`, `activityPushToStartTokenReceived`. Posted + to `NotificationCenter` and emitted through a codegen `EventEmitter`. If no JavaScript listener + is attached at that moment, the event is gone. +- **Persistent** — `interaction`. Written to the App Group queue (`Voltra_EventsQueue`) _and_ posted + for hot delivery. The queue is read and cleared once, in `subscribe()`, at module creation. + +That produces four defects: + +1. **Tokens are emitted once, at module start.** ActivityKit hands the current per-activity token + (`activity.pushTokenUpdates`) and push-to-start token (`Activity.pushToStartToken`, then + `pushToStartTokenUpdates`) to the observers as soon as monitoring begins. A listener added later, + for example after the user signs in, never sees them. Dynamic Live Activities have their own + observer with the same behavior. +2. **Enablement is invisible.** `ActivityAuthorizationInfo().areActivitiesEnabled` is read natively + before every start, but JavaScript can only find out by trying to start an activity and catching + the rejection. +3. **Handled taps replay.** `send()` always persists an interaction, even when it is delivered hot. + The queue is only cleared at the next module creation, so every tap the app handled while running + is delivered a second time on the next cold launch or reload. +4. **Taps can replay into nothing.** The module is created by the first `getNativeVoltra()` call, + and the replay runs at that moment. When any other Voltra call — `isHeadless()`, a widget + update, another event's listener — creates the module before the app's `interaction` listener + exists, the queue is read, emitted to no one, and cleared. + +The website also claims Voltra "handles event queuing automatically when using App Groups", which is +true only for interactions. + +`dynamicLiveActivityRenderFailed` already keeps its own App Group queue with a cross-process file +lock, and native code drains it only while JavaScript reports a listener. That diagnostic path keeps +its push model and is out of scope here, apart from being made safe for several React Native +instances (below). + +## Decision + +### Tokens and enablement are state: a getter for the current value, an event for changes + +```ts +export type LiveActivityPushToken = { + /** Same value as `activityName` on `activityTokenReceived`. Covers static and dynamic activities. */ + activityName: string + pushToken: string +} +/** Tokens of running activities that already have one. Empty when push notifications are disabled. */ +export function getLiveActivityPushTokens(): Promise +/** iOS 17.2+. null on older iOS, when push is disabled, or before APNs issues one. */ +export function getLiveActivityPushToStartToken(): Promise +/** Synchronous, like isLiveActivityActive. False below iOS 16.4. */ +export function areLiveActivitiesEnabled(): boolean +``` + +and a new event, `activityEnablementChanged`, carrying `{ enabled: boolean }`. + +The getters read ActivityKit directly at call time: + +- `getLiveActivityPushTokens` walks `Activity.activities` and every registered + Dynamic Live Activity attributes type, and returns each activity whose `pushToken` is set and + whose state is neither ended nor dismissed. Activities started on a broadcast channel have no + token and are not listed. +- `getLiveActivityPushToStartToken` reads `Activity.pushToStartToken`, the same + token `activityPushToStartTokenReceived` carries. Dynamic Live Activity attributes types have + push-to-start tokens of their own; neither the event nor the getter exposes them today. +- Both return empty results when `enablePushNotifications` is off (Info.plist + `Voltra_EnablePushNotifications`), because activities are then requested without a push type and + any token ActivityKit holds is not one the app asked for. +- `areLiveActivitiesEnabled` returns `ActivityAuthorizationInfo().areActivitiesEnabled`. + `activityEnablementChanged` is driven by `activityEnablementUpdates` and is emitted only when the + value differs from the last one seen, starting from the value read when monitoring began (the + stream is created before that read, so a change in between is not missed). The event is app-wide + and carries `timestamp` and `enabled`, but no `source`. Neither + depends on the push flag: an app without push still wants to hide its "start tracking" button. + Monitoring, and with it the enablement stream, starts regardless of the flag; only the token + callbacks are gated. Below iOS 16.4 (the pods' deployment target) the getter returns `false`. +- The getters report what ActivityKit holds. On the simulator, `pushToStartToken` kept returning the + previous token while Live Activities were disabled, and a new one was issued when they were + re-enabled; the event reported the new one. Backends should treat the latest report as current. + +The usage pattern this supports, and which the docs show, is **subscribe first, then call the +getter**. A change that lands between the two is then seen at least once. The same token can arrive +twice — once from the getter, once from the event — so the backend upserts by device and +`activityName` (or by device for the push-to-start token) rather than appending. Starting an +activity ends any running one with the same name, so a name identifies one running activity per +device; activities started remotely with a reused name are the app's responsibility. + +### Rejected: replay the last value when a listener subscribes + +A "sticky" event — resend the last token whenever JavaScript subscribes — would keep the event-only +API. It does not work on top of codegen `EventEmitter`s: + +- **Native cannot see subscriptions.** A codegen emitter is a JavaScript-side fan-out; native code + emits into it and is never told that a listener was added or removed. Replaying on subscribe would + need a counting wrapper in JavaScript plus a native "resend now" call for every token event. +- **A resend reaches every listener.** The emitter has no per-listener delivery, so resending for + the second subscriber re-delivers to the first, which then has to deduplicate. +- **A replayed value hides its freshness.** A token resent from memory looks exactly like a newly + issued one, even if its activity has since ended. A getter reads ActivityKit at call time and + can only return what is current. + +### Rejected: queue tokens like interactions + +A persisted token is a value that was correct when it was written. After a relaunch the activity may +have ended or the token rotated, and replaying it would send a dead token to the backend. +ActivityKit always knows the current value, so there is nothing to persist. Token and state-change +events stay transient. + +### Interactions are events: always queued natively, pulled by JavaScript + +Every tap is recorded in one bounded FIFO, in whichever process runs `VoltraInteractionIntent`, +whether or not anything listens. Native code never pushes tap data to JavaScript and keeps no notion +of "a listener is attached". Instead: + +1. **Record.** `VoltraEventBus.send(.interaction)` appends a `VoltraInteraction` (with a new `id`) + to `interactions-v1.json` in the App Group container, under an exclusive `flock` on a sibling + lock file — the same transaction model as the render-failure queue — and writes it atomically + with `completeUntilFirstUserAuthentication` protection, so an intent running on a locked device + after first unlock can still record. It then posts a Darwin notification, scoped to the App Group + so it reaches only this app and its extensions. +2. **Signal.** Each module turns that notification into `onInteractionsAvailable`, an internal + emitter with no tap data. +3. **Pull.** `popInteractions(sources)` is a synchronous TurboModule method: it removes the + entries and returns them in the same call, so an entry is either still in the queue or already in + JavaScript's hands. It runs on the JavaScript thread, so it never waits on another process's lock + for more than 250 ms; on timeout it returns nothing and the entries stay queued for the next pull. +4. **Dispatch.** `interactions.ts` owns the subscriber list. It pulls when a subscriber is added, on + the availability signal, and when `AppState` becomes `active` — always on a microtask, so + `addVoltraListener` returns before any listener runs, and calls in the same tick share one pull. + It dispatches oldest first; a pull requested during a dispatch runs after it, so order holds. A + listener that throws is logged and does not stop the others. Taps whose listeners all went away + during the dispatch are handed back with the synchronous `restoreInteractions`, which puts them at + the front of the queue unless an entry with the same `id` is already there. + Every listener that subscribes before that microtask runs — for example, all `useEffect` + subscriptions of one React commit — receives the same pulled taps. A listener that subscribes in + a later commit does not: those taps are already out of the queue. The docs therefore recommend + one app-level `interaction` listener, mounted at the root, that fans taps out within the app. +5. **No replay at module creation.** Subscribing to the bus installs the handler and the signal + observers; it reads nothing from the queue. + +`addVoltraListener('interaction')` keeps its signature. Its subscriber is unfiltered and takes every +tap. `VoltraView` uses an internal subscriber filtered by its `source`, kept across re-renders; when +only filtered subscribers exist, `popInteractions` takes only their sources and leaves every other +tap queued. A mounted `VoltraView` therefore never consumes taps meant for a Live Activity or +widget. Its own taps go through the same queue, so if the `VoltraView` unmounts before they are +pulled, the next unfiltered listener receives them, until they expire. + +**Delivery semantics: at most once.** A tap is handed to JavaScript once, by the pull that removes +it, and is never delivered twice. If the JavaScript instance dies after a pull returns and before +its listeners run (a fatal error, or the process being killed mid-dispatch), those taps are lost. +Closing that window would need an acknowledgement — a lease that returns the entry to the queue +unless JavaScript confirms it — and redelivery after a crash would reintroduce duplicates, the +defect this ADR fixes, for a window of milliseconds. + +Taps no longer depend on JavaScript running at the moment they are recorded. A tap made while the +app is suspended with a listener mounted is queued, and is taken when JavaScript next pulls — on the +signal if the process is awake, otherwise when the app becomes active. This was the case the +earlier "deliver hot, don't persist" design lost. + +**Bounds.** Entries older than 15 minutes are dropped, and the queue keeps the newest 100. A tap +nobody took within 15 minutes is no longer something to act on, and an app that never registers an +interaction listener does not grow the file without limit. Both bounds are applied on every record, +pull and restore. + +**Failures.** A queue file that cannot be read (for example, protected data while the device is +locked) aborts the transaction and is left untouched. A file that reads but does not decode is moved +to a single `interactions-v1.json.corrupt` and treated as empty. If recording to the App Group fails, +the tap is kept in the recording process's memory instead and still signalled, so a listener in that +process receives it; a tap recorded that way in an extension process is lost when the extension +exits. + +**Upgrade.** Earlier releases kept the queue in UserDefaults under `Voltra_EventsQueue`, including +taps the app had already handled. Migrating it would deliver those once more, so the first +subscription after the upgrade deletes the key instead. Any tap queued but not yet consumed at the +moment of the upgrade is lost with it. + +**Without an App Group** the queue lives in the app's memory. It still serves taps the system runs in +the app process — a `VoltraView`, or a Live Activity intent while the app is alive — but taps handled +by another process, such as a widget extension, never reach the app, and nothing survives a +relaunch. That matches what worked without `groupIdentifier` before. + +**Mixed versions.** A JavaScript bundle updated over the air can run on a binary without +`popInteractions`. `subscribeToInteractions` detects that and subscribes to the legacy +`onInteraction` emitter instead, deriving an `id` from the tap's timestamp, source and identifier. +The new getters and `addVoltraListener('activityEnablementChanged')` warn and return empty values on +such a binary instead of throwing. The reverse — an old bundle on a new binary — receives no taps at +all: it listens on `onInteraction`, which the new binary never emits, and never pulls. That lasts +until the bundle is updated, which is the usual state right after a store release ships both. + +### Rejected: deliver hot and persist only without a listener + +The first version of this ADR kept native push delivery: deliver hot without persisting while +JavaScript reported a listener, persist otherwise, drain on the listener count going from 0 to 1. A +review found that it moved the problem rather than removing it: + +- "Delivered" meant handed to the emitter, not received by JavaScript. The emitter hops to the + JavaScript thread asynchronously, so a tap delivered while the app was suspended, or while the + last listener was unmounting, was neither persisted nor received. +- The listener flag was set by an asynchronous `void` TurboModule call and lived in a per-process + singleton shared by every React Native instance, so reloads and multiple instances could clear + each other's state. +- Drained and hot taps could reach JavaScript out of order, and a source-filtered `VoltraView` + counted as a listener and drained taps it then discarded. + +Making JavaScript the side that removes entries, in a synchronous call, removes these: native holds +no listener state, and the only step that drops an entry is the one that returns it. + +### Several React Native instances + +`VoltraEventBus` is one per process. Each module subscribes with its own token and receives every +transient event and availability signal; `unsubscribe` removes only that token, so an instance torn +down after its replacement subscribed (a reload) cannot remove the replacement's handler. The +render-failure "listener active" state is kept per token for the same reason. Every instance pulls +from the same interaction queue, so a tap goes to whichever instance pulls first. + +### Other edge cases + +- **Direct emitter subscribers.** `getNativeVoltra().onInteraction` no longer fires on current + builds. Code that subscribed to it directly must use `addVoltraListener('interaction')`. + +### Android is out of scope + +Android sends no native-to-JavaScript events, Voltra owns no push tokens there (remote ongoing +notification updates arrive through the app's own push delivery), and whether ongoing notifications +can be posted or promoted is already exposed through +`getAndroidOngoingNotificationCapabilities()`. Nothing in this ADR has an Android counterpart to add. + +## Consequences + +- An app can read its tokens and the enablement state at any time, including after sign-in or a + reload, without starting a new activity. +- Backends must treat token reports as idempotent upserts. The docs say so. +- A tap the app handled is never delivered again. A tap that launched the app, or happened while the + app was suspended, is delivered once when JavaScript next pulls, if that is within 15 minutes and + the process survives the dispatch. +- Interaction events gain an `id`. +- The spec gains five methods and two emitters. `popInteractions`, `restoreInteractions` and + `onInteractionsAvailable` are internal and not re-exported; `onInteraction` stays in the spec + only for older native builds. +- The website no longer claims that every event is queued. It says which data is a value to read and + which is an event that waits for a listener. diff --git a/docs/adr/README.md b/docs/adr/README.md index 779b7cc09..c2c6cfffb 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -18,16 +18,17 @@ Status values: toward it should treat the documentation as the specification. - **Superseded by NNNN** — no longer binding; read the replacement. -| ADR | Title | Status | -| ------------------------------------------------------------- | ------------------------------------------------------------ | -------- | -| [0000](0000-android-widget-kind-separation.md) | Separate payload-driven and Dynamic Android widget paths | Accepted | -| [0001](0001-dynamic-live-activities.md) | Dynamic Live Activities rendering | Accepted | -| [0002](0002-server-driven-dynamic-widgets.md) | Server-driven Dynamic Widgets | Accepted | -| [0003](0003-widget-module-resolution.md) | Widget module resolution | Accepted | -| [0004](0004-android-arc-progress-indicator.md) | Bitmap-rendered arc progress indicator for Android widgets | Accepted | -| [0005](0005-native-modifiers.md) | Native modifiers | Proposed | -| [0006](0006-android-widget-instance-configuration.md) | Per-instance configuration for Android Dynamic Widgets | Accepted | -| [0007](0007-per-instance-server-fetches.md) | Per-instance server fetches for Dynamic Widgets | Accepted | -| [0008](0008-android-ongoing-notification-live-updates-api.md) | Android ongoing notification Live Updates API surface | Accepted | -| [0009](0009-dynamic-widget-localisation.md) | Localisation for Dynamic Widgets and Dynamic Live Activities | Accepted | -| [0010](0010-android-ongoing-notification-field-placement.md) | Where Android ongoing notification fields live | Accepted | +| ADR | Title | Status | +| ---------------------------------------------------------------------- | ------------------------------------------------------------ | -------- | +| [0000](0000-android-widget-kind-separation.md) | Separate payload-driven and Dynamic Android widget paths | Accepted | +| [0001](0001-dynamic-live-activities.md) | Dynamic Live Activities rendering | Accepted | +| [0002](0002-server-driven-dynamic-widgets.md) | Server-driven Dynamic Widgets | Accepted | +| [0003](0003-widget-module-resolution.md) | Widget module resolution | Accepted | +| [0004](0004-android-arc-progress-indicator.md) | Bitmap-rendered arc progress indicator for Android widgets | Accepted | +| [0005](0005-native-modifiers.md) | Native modifiers | Proposed | +| [0006](0006-android-widget-instance-configuration.md) | Per-instance configuration for Android Dynamic Widgets | Accepted | +| [0007](0007-per-instance-server-fetches.md) | Per-instance server fetches for Dynamic Widgets | Accepted | +| [0008](0008-android-ongoing-notification-live-updates-api.md) | Android ongoing notification Live Updates API surface | Accepted | +| [0009](0009-dynamic-widget-localisation.md) | Localisation for Dynamic Widgets and Dynamic Live Activities | Accepted | +| [0010](0010-android-ongoing-notification-field-placement.md) | Where Android ongoing notification fields live | Accepted | +| [0011](0011-live-activity-push-state-getters-and-interaction-drain.md) | Live Activity push state getters and interaction drain | Accepted | diff --git a/docs/agents/appduct-e2e.md b/docs/agents/appduct-e2e.md index 457381b11..5a2c670db 100644 --- a/docs/agents/appduct-e2e.md +++ b/docs/agents/appduct-e2e.md @@ -87,6 +87,7 @@ a simulator or an iPhone, and the `android_*` tools do not exist in that session | `ios_update_live_activity` | Replace its props | | `ios_stop_live_activity` | End one | | `ios_live_activity_status` | Whether an activity is running | +| `ios_live_activity_push_state` | Enablement, per-activity and push-to-start tokens | | `ios_preload_images` | Preload images for widgets and Live Activities | | `ios_clear_preloaded_images` | Drop them again | | `ios_read_events` | Voltra events the app received, since the last read | diff --git a/example/appduct/IosVoltraTools.tsx b/example/appduct/IosVoltraTools.tsx index 9e72a19cd..4d5e359d7 100644 --- a/example/appduct/IosVoltraTools.tsx +++ b/example/appduct/IosVoltraTools.tsx @@ -5,6 +5,7 @@ import { postEvent, useAppductTool } from '@appduct/react-native' import { addVoltraListener, + areLiveActivitiesEnabled, clearAllWidgets, clearPreloadedImages, clearWidget, @@ -12,6 +13,8 @@ import { endAllLiveActivities, getActiveWidgets, getDynamicLiveActivityDefinitionIds, + getLiveActivityPushTokens, + getLiveActivityPushToStartToken, getWidgetServerUpdate, isLiveActivityActive, preloadImages, @@ -65,6 +68,7 @@ const VOLTRA_EVENT_KINDS = [ 'stateChange', 'activityTokenReceived', 'activityPushToStartTokenReceived', + 'activityEnablementChanged', 'dynamicLiveActivityRenderFailed', ] as const @@ -361,6 +365,25 @@ export function IosVoltraTools() { }, }) + useAppductTool({ + name: 'ios_live_activity_push_state', + group: GROUP, + description: + 'Read the current Live Activity push state: whether Live Activities are enabled, the push token of every running activity, and the push-to-start token.', + annotations: { readOnlyHint: true }, + inputSchema: z.object({}), + outputSchema: z.object({ + enabled: z.boolean(), + pushTokens: z.array(z.object({ activityName: z.string(), pushToken: z.string() })), + pushToStartToken: z.string().nullable(), + }), + handler: async () => ({ + enabled: areLiveActivitiesEnabled(), + pushTokens: await getLiveActivityPushTokens(), + pushToStartToken: await getLiveActivityPushToStartToken(), + }), + }) + // --- Images --------------------------------------------------------------------------------- useAppductTool({ diff --git a/packages/ios-client/ios/Package.swift b/packages/ios-client/ios/Package.swift index 31ebef273..8e3a710e8 100644 --- a/packages/ios-client/ios/Package.swift +++ b/packages/ios-client/ios/Package.swift @@ -26,12 +26,9 @@ let package = Package( "Data+hexString.swift", "Date+toTimerInterval.swift", "VoltraAttributes.swift", - "VoltraEvent.swift", - "VoltraEventBus.swift", "VoltraImageStore.swift", "VoltraInteractionIntent.swift", "VoltraLiveActivityPayload.swift", - "VoltraPersistentEventQueue.swift", ], sources: [ "DynamicWidgetPropsCodec.swift", @@ -64,6 +61,12 @@ let package = Package( "dynamic-live-activity/VoltraDynamicLiveActivityTypes.swift", "dynamic-live-activity/VoltraDynamicLiveActivityPayloadValidator.swift", "dynamic-live-activity/VoltraDynamicLiveActivityRenderFailureQueue.swift", + "VoltraInteractionQueue.swift", + "VoltraInteractionReporter.swift", + "VoltraDarwinNotificationRelay.swift", + "VoltraEvent.swift", + "VoltraEventBus.swift", + "dynamic-live-activity/VoltraDynamicLiveActivityRenderFailureReporter.swift", "dynamic-live-activity/VoltraLiveActivityOrder.swift", "JSONValue.swift", "VoltraConfig.swift", diff --git a/packages/ios-client/ios/Tests/VoltraSharedTests/VoltraEventBusTests.swift b/packages/ios-client/ios/Tests/VoltraSharedTests/VoltraEventBusTests.swift new file mode 100644 index 000000000..d453bf0c6 --- /dev/null +++ b/packages/ios-client/ios/Tests/VoltraSharedTests/VoltraEventBusTests.swift @@ -0,0 +1,85 @@ +@testable import VoltraSharedCore +import XCTest + +/// Runs without an App Group, so interactions go through the in-process queue and the relay. +final class VoltraEventBusTests: XCTestCase { + private let bus = VoltraEventBus.shared + + override func setUp() { + super.setUp() + _ = bus.popInteractions(sources: nil) + } + + func testRecordedTapSignalsAndIsPoppedOnce() { + let signalled = expectation(description: "interactions available") + signalled.assertForOverFulfill = false + let token = bus.subscribe { name, _ in + if name == VoltraEventBus.interactionsAvailableEventName { + signalled.fulfill() + } + } + defer { bus.unsubscribe(token) } + + bus.send(.interaction(source: "order-123", identifier: "pause", payload: nil)) + wait(for: [signalled], timeout: 2) + + let popped = bus.popInteractions(sources: nil) + XCTAssertEqual(popped.map { $0["identifier"] as? String }, ["pause"]) + XCTAssertNotNil(popped.first?["id"] as? String) + XCTAssertTrue(bus.popInteractions(sources: nil).isEmpty) + } + + func testRestoredTapsComeBackFirstAndOnlyOnce() { + bus.send(.interaction(source: "order-123", identifier: "first", payload: nil)) + let popped = bus.popInteractions(sources: nil) + bus.send(.interaction(source: "order-123", identifier: "second", payload: nil)) + + bus.restoreInteractions(popped) + bus.restoreInteractions(popped) + + XCTAssertEqual(bus.popInteractions(sources: nil).map { $0["identifier"] as? String }, ["first", "second"]) + } + + func testPopBySourceLeavesOtherTaps() { + bus.send(.interaction(source: "order-123", identifier: "live-activity", payload: nil)) + bus.send(.interaction(source: "voltra-view-1", identifier: "view", payload: nil)) + + XCTAssertEqual(bus.popInteractions(sources: ["voltra-view-1"]).map { $0["identifier"] as? String }, ["view"]) + XCTAssertEqual(bus.popInteractions(sources: nil).map { $0["identifier"] as? String }, ["live-activity"]) + } + + func testTearingDownOneSubscriptionKeepsTheOther() { + let delivered = expectation(description: "replacement still receives events") + let stale = bus.subscribe { _, _ in XCTFail("an unsubscribed handler received an event") } + let replacement = bus.subscribe { name, _ in + if name == "stateChange" { + delivered.fulfill() + } + } + defer { bus.unsubscribe(replacement) } + + // The old instance is torn down after its replacement subscribed, as during a reload. + bus.unsubscribe(stale) + bus.send(.stateChange(activityName: "order-123", state: "active")) + + wait(for: [delivered], timeout: 2) + } + + func testDiscardsTheQueueLeftByEarlierReleases() throws { + let suite = "voltra-tests-\(UUID().uuidString)" + let defaults = try XCTUnwrap(UserDefaults(suiteName: suite)) + defer { defaults.removePersistentDomain(forName: suite) } + defaults.set(["{\"type\":\"interaction\"}"], forKey: VoltraInteractionReporter.legacyQueueKey) + + VoltraInteractionReporter.discardLegacyQueue(in: defaults) + + XCTAssertNil(defaults.object(forKey: VoltraInteractionReporter.legacyQueueKey)) + } + + func testDarwinNotificationNamesAreScopedToTheAppGroup() { + XCTAssertEqual( + VoltraDarwinNotificationRelay.scoped("com.voltra.interactions", groupIdentifier: "group.com.example"), + "com.voltra.interactions.group.com.example" + ) + } +} diff --git a/packages/ios-client/ios/Tests/VoltraSharedTests/VoltraInteractionQueueTests.swift b/packages/ios-client/ios/Tests/VoltraSharedTests/VoltraInteractionQueueTests.swift new file mode 100644 index 000000000..157d39482 --- /dev/null +++ b/packages/ios-client/ios/Tests/VoltraSharedTests/VoltraInteractionQueueTests.swift @@ -0,0 +1,156 @@ +@testable import VoltraSharedCore +import XCTest + +final class VoltraInteractionQueueTests: XCTestCase { + private var now = Date(timeIntervalSince1970: 10000) + + func testPopReturnsTapsOldestFirstAndEmptiesTheQueue() { + let queue = makeQueue() + queue.record(tap("a", at: 1)) + queue.record(tap("b", at: 2)) + + XCTAssertEqual(queue.pop().map(\.identifier), ["a", "b"]) + XCTAssertEqual(queue.pop(), []) + } + + func testPopBySourceLeavesOtherSourcesQueuedInOrder() { + let queue = makeQueue() + queue.record(tap("activity-1", source: "order-123", at: 1)) + queue.record(tap("view-1", source: "voltra-view", at: 2)) + queue.record(tap("activity-2", source: "order-123", at: 3)) + + XCTAssertEqual(queue.pop(sources: ["voltra-view"]).map(\.identifier), ["view-1"]) + XCTAssertEqual(queue.pop().map(\.identifier), ["activity-1", "activity-2"]) + } + + func testDropsTapsOlderThanMaxAge() { + let queue = makeQueue() + queue.record(tap("stale", at: -VoltraInteractionQueue.maxAge - 1)) + queue.record(tap("fresh", at: -VoltraInteractionQueue.maxAge + 1)) + + XCTAssertEqual(queue.pop().map(\.identifier), ["fresh"]) + } + + func testCapsQueueAndDropsTheOldestTaps() { + let queue = makeQueue() + for index in 0 ... VoltraInteractionQueue.capacity { + queue.record(tap("tap-\(index)", at: Double(index) / 1000)) + } + + let taps = queue.pop() + XCTAssertEqual(taps.count, VoltraInteractionQueue.capacity) + XCTAssertEqual(taps.first?.identifier, "tap-1") + XCTAssertEqual(taps.last?.identifier, "tap-\(VoltraInteractionQueue.capacity)") + } + + func testEqualTapsKeepDistinctIds() { + let queue = makeQueue() + queue.record(VoltraInteraction(source: "order-123", identifier: "pause", payload: nil, timestamp: now)) + queue.record(VoltraInteraction(source: "order-123", identifier: "pause", payload: nil, timestamp: now)) + + let taps = queue.pop() + XCTAssertEqual(taps.count, 2) + XCTAssertNotEqual(taps[0].id, taps[1].id) + } + + func testDictionaryCarriesTheJavaScriptEventShape() { + let interaction = VoltraInteraction( + source: "order-123", + identifier: "pause", + payload: nil, + id: "fixed-id", + timestamp: Date(timeIntervalSince1970: 42) + ) + + XCTAssertEqual(Set(interaction.dictionary.keys), ["type", "id", "source", "timestamp", "identifier", "payload"]) + XCTAssertEqual(interaction.dictionary["type"] as? String, "interaction") + XCTAssertEqual(interaction.dictionary["payload"] as? String, "") + XCTAssertEqual(interaction.dictionary["timestamp"] as? Double, 42) + } + + func testFileStorageSharesTheQueueAcrossInstancesAndRecoversFromCorruptData() throws { + let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + try Data("not-json".utf8).write(to: directory.appendingPathComponent(VoltraInteractionFileStorage.queueFileName)) + + let writer = makeQueue(storage: VoltraInteractionFileStorage(directoryURL: directory)) + let reader = makeQueue(storage: VoltraInteractionFileStorage(directoryURL: directory)) + XCTAssertTrue(writer.record(tap("a", at: 0))) + + XCTAssertEqual(reader.pop().map(\.identifier), ["a"]) + XCTAssertEqual(writer.pop(), []) + XCTAssertTrue( + try FileManager.default.contentsOfDirectory(atPath: directory.path).contains { $0 == VoltraInteractionFileStorage.quarantineFileName } + ) + } + + func testFileStorageLeavesAnUnreadableQueueUntouched() throws { + let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + let queue = makeQueue(storage: VoltraInteractionFileStorage(directoryURL: directory)) + XCTAssertTrue(queue.record(tap("a", at: 0))) + let queueURL = directory.appendingPathComponent(VoltraInteractionFileStorage.queueFileName) + try FileManager.default.setAttributes([.posixPermissions: 0], ofItemAtPath: queueURL.path) + defer { try? FileManager.default.setAttributes([.posixPermissions: 0o600], ofItemAtPath: queueURL.path) } + + XCTAssertFalse(queue.record(tap("b", at: 0))) + XCTAssertEqual(queue.pop(), []) + + try FileManager.default.setAttributes([.posixPermissions: 0o600], ofItemAtPath: queueURL.path) + XCTAssertEqual(queue.pop().map(\.identifier), ["a"]) + XCTAssertFalse(FileManager.default.fileExists( + atPath: directory.appendingPathComponent(VoltraInteractionFileStorage.quarantineFileName).path + )) + } + + func testRestorePutsTapsBackInFrontWithoutDuplicates() { + let queue = makeQueue() + queue.record(tap("a", at: 1)) + let popped = queue.pop() + queue.record(tap("b", at: 2)) + + queue.restore(popped) + queue.restore(popped) + + XCTAssertEqual(queue.pop().map(\.identifier), ["a", "b"]) + } + + func testInteractionRoundTripsThroughItsDictionary() { + let interaction = tap("a", at: 0) + XCTAssertEqual(VoltraInteraction(dictionary: interaction.dictionary), interaction) + XCTAssertNil(VoltraInteraction(dictionary: ["id": "missing-fields"])) + } + + func testFileStorageSerializesConcurrentWritersAndReaders() { + let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + let writers = (0 ..< 20).map { _ in makeQueue(storage: VoltraInteractionFileStorage(directoryURL: directory)) } + let reader = makeQueue(storage: VoltraInteractionFileStorage(directoryURL: directory)) + let lock = NSLock() + var popped: [VoltraInteraction] = [] + + let group = DispatchGroup() + for (index, writer) in writers.enumerated() { + group.enter() + DispatchQueue.global().async { + XCTAssertTrue(writer.record(self.tap("tap-\(index)", at: 0))) + let taken = reader.pop() + lock.lock() + popped += taken + lock.unlock() + group.leave() + } + } + XCTAssertEqual(group.wait(timeout: .now() + 5), .success) + popped += reader.pop() + + XCTAssertEqual(popped.count, 20) + XCTAssertEqual(Set(popped.map(\.id)).count, 20) + } + + private func makeQueue(storage: VoltraInteractionStorage = VoltraInteractionMemoryStorage()) -> VoltraInteractionQueue { + VoltraInteractionQueue(storage: storage, now: { [now] in now }) + } + + private func tap(_ identifier: String, source: String = "order-123", at offset: TimeInterval) -> VoltraInteraction { + VoltraInteraction(source: source, identifier: identifier, payload: nil, timestamp: now.addingTimeInterval(offset)) + } +} diff --git a/packages/ios-client/ios/app/NativeVoltra.mm b/packages/ios-client/ios/app/NativeVoltra.mm index b01007370..d9e5deebd 100644 --- a/packages/ios-client/ios/app/NativeVoltra.mm +++ b/packages/ios-client/ios/app/NativeVoltra.mm @@ -76,8 +76,8 @@ - (void)setEventEmitterCallback:(EventEmitterCallbackWrapper *_Nonnull)eventEmit return; } - if ([eventName isEqualToString:@"interaction"]) { - [strongSelf emitOnInteraction:eventData]; + if ([eventName isEqualToString:@"interactionsAvailable"]) { + [strongSelf emitOnInteractionsAvailable:eventData]; } else if ([eventName isEqualToString:@"dynamicLiveActivityRenderFailed"]) { [strongSelf emitOnDynamicLiveActivityRenderFailed:eventData]; } else if ([eventName isEqualToString:@"stateChange"]) { @@ -86,6 +86,8 @@ - (void)setEventEmitterCallback:(EventEmitterCallbackWrapper *_Nonnull)eventEmit [strongSelf emitOnActivityTokenReceived:eventData]; } else if ([eventName isEqualToString:@"activityPushToStartTokenReceived"]) { [strongSelf emitOnActivityPushToStartTokenReceived:eventData]; + } else if ([eventName isEqualToString:@"activityEnablementChanged"]) { + [strongSelf emitOnActivityEnablementChanged:eventData]; } }]; } @@ -115,6 +117,17 @@ - (void)setDynamicLiveActivityRenderFailureListenerActive:(BOOL)active [self.module setDynamicLiveActivityRenderFailureListenerActive:active]; } +- (NSArray *)popInteractions:(NSArray *)sources +{ + return [self.module popInteractions:sources]; +} + +- (NSNumber *)restoreInteractions:(NSArray *)interactions +{ + [self.module restoreInteractions:interactions]; + return @YES; +} + - (UIView *)reactRootViewInView:(UIView *)view { if ([view respondsToSelector:NSSelectorFromString(@"appProperties")] && @@ -312,6 +325,21 @@ - (NSNumber *)isLiveActivityActive:(NSString *)activityName return @([self.module isLiveActivityActive:activityName]); } +- (void)getLiveActivityPushTokens:(RCTPromiseResolveBlock)resolve reject:(RCTPromiseRejectBlock)reject +{ + resolve([self.module getLiveActivityPushTokens]); +} + +- (void)getLiveActivityPushToStartToken:(RCTPromiseResolveBlock)resolve reject:(RCTPromiseRejectBlock)reject +{ + resolve([self.module getLiveActivityPushToStartToken]); +} + +- (NSNumber *)areLiveActivitiesEnabled +{ + return @([self.module areLiveActivitiesEnabled]); +} + - (NSNumber *)isHeadless { BOOL isHeadless = [self.module isHeadless]; diff --git a/packages/ios-client/ios/app/VoltraLiveActivityManager.swift b/packages/ios-client/ios/app/VoltraLiveActivityManager.swift index 24e78b2dd..7321ae5db 100644 --- a/packages/ios-client/ios/app/VoltraLiveActivityManager.swift +++ b/packages/ios-client/ios/app/VoltraLiveActivityManager.swift @@ -28,6 +28,10 @@ public actor VoltraLiveActivityManager { /// Parameters: (activityName, stateDescription) private let onStateChanged: (@Sendable (String, String) -> Void)? + /// Called when the user allows or disallows Live Activities for the app. + /// Parameter: whether activities are now enabled + private let onEnablementChanged: (@Sendable (Bool) -> Void)? + // MARK: - Internal Task State /// Drives the main `Activity.activityUpdates` loop. When this task is cancelled, @@ -38,6 +42,13 @@ public actor VoltraLiveActivityManager { /// Drives the `Activity.pushToStartTokenUpdates` loop (iOS 17.2+ only). private var pushToStartTask: Task? + /// Drives the `ActivityAuthorizationInfo.activityEnablementUpdates` loop. + private var enablementTask: Task? + + /// The enablement value last seen, starting from the one read when observation began, so only + /// genuine changes reach the callback. + private var lastEnablement: Bool? + /// One push-token observer task per live activity, keyed by `activity.id`. /// Using a dictionary (rather than a flat array) lets us deduplicate and cancel /// individual tasks when an activity ends. @@ -64,12 +75,14 @@ public actor VoltraLiveActivityManager { onActivityDiscovered: (@Sendable (String) -> Void)? = nil, onTokenUpdated: (@Sendable (String, String) -> Void)? = nil, onPushToStartUpdated: (@Sendable (String) -> Void)? = nil, - onStateChanged: (@Sendable (String, String) -> Void)? = nil + onStateChanged: (@Sendable (String, String) -> Void)? = nil, + onEnablementChanged: (@Sendable (Bool) -> Void)? = nil ) { self.onActivityDiscovered = onActivityDiscovered self.onTokenUpdated = onTokenUpdated self.onPushToStartUpdated = onPushToStartUpdated self.onStateChanged = onStateChanged + self.onEnablementChanged = onEnablementChanged dynamicObserver = VoltraDynamicLiveActivityObserver( onActivityDiscovered: onActivityDiscovered, onTokenUpdated: onTokenUpdated, @@ -89,6 +102,7 @@ public actor VoltraLiveActivityManager { startActivityUpdatesObservation() startPushToStartObservation() + startEnablementObservation() Task { [weak self, dynamicObserver] in guard await self?.currentlyObserving() == true else { return } await VoltraDynamicLiveActivityRegistry.shared.startObserving(with: dynamicObserver) @@ -109,6 +123,10 @@ public actor VoltraLiveActivityManager { lastPushToStartToken = nil + enablementTask?.cancel() + enablementTask = nil + lastEnablement = nil + cancelAllPerActivityTasks() Task { [dynamicObserver] in await dynamicObserver.stopObserving() @@ -128,6 +146,7 @@ public actor VoltraLiveActivityManager { deinit { activityUpdatesTask?.cancel() pushToStartTask?.cancel() + enablementTask?.cancel() for task in tokenTasks.values { task.cancel() } @@ -188,6 +207,34 @@ public actor VoltraLiveActivityManager { callback(token) } + // MARK: - Enablement Loop + + private func startEnablementObservation() { + guard let onEnablementChanged else { return } + + let authorizationInfo = ActivityAuthorizationInfo() + // Subscribe before reading the baseline, so a change between the two is still yielded; the + // dedup below drops it if the baseline already reflects it. + let updates = authorizationInfo.activityEnablementUpdates + lastEnablement = authorizationInfo.areActivitiesEnabled + let callback = onEnablementChanged + + enablementTask = Task { [weak self] in + for await enabled in updates { + await self?.deliverEnablement(enabled, via: callback) + } + } + } + + /// Forward `enabled` only when it differs from the last value seen, so a stream that re-yields + /// the current value does not produce a spurious event. + private func deliverEnablement(_ enabled: Bool, via callback: @Sendable (Bool) -> Void) { + // A value already in flight when observation stopped must not reach the callback. + guard isObserving, enabled != lastEnablement else { return } + lastEnablement = enabled + callback(enabled) + } + // MARK: - Per-Activity Observation /// Start observing a single activity's push token and state changes. diff --git a/packages/ios-client/ios/app/VoltraLiveActivityService.swift b/packages/ios-client/ios/app/VoltraLiveActivityService.swift index 13e8e7c43..f4cb413a8 100644 --- a/packages/ios-client/ios/app/VoltraLiveActivityService.swift +++ b/packages/ios-client/ios/app/VoltraLiveActivityService.swift @@ -151,6 +151,26 @@ public class VoltraLiveActivityService { dynamicService.definitionIds() } + // MARK: - Push State + + /// Push tokens of every running Voltra activity, static and dynamic, that already has one. + /// Pairs are (activityName, hexToken), matching `activityTokenReceived`. + public func pushTokens() -> [(activityName: String, pushToken: String)] { + guard Self.isSupported() else { return [] } + let legacy: [(activityName: String, pushToken: String)] = getAllActivities().compactMap { activity in + guard activity.activityState.canReceivePushUpdates, let token = activity.pushToken else { return nil } + return (activity.attributes.name, token.hexString) + } + return legacy + dynamicService.pushTokens() + } + + /// The push-to-start token for `VoltraAttributes`, the same one `activityPushToStartTokenReceived` + /// carries. Nil below iOS 17.2 or before APNs has issued one. + public func pushToStartToken() -> String? { + guard #available(iOS 17.2, *) else { return nil } + return Activity.pushToStartToken?.hexString + } + // MARK: - Create Operations /// Create a new Live Activity @@ -355,6 +375,10 @@ public class VoltraLiveActivityService { onPushToStartUpdated: onPushToStartUpdated, onStateChanged: { activityName, state in VoltraEventBus.shared.send(.stateChange(activityName: activityName, state: state)) + }, + // Not gated on push: enablement matters to any app that starts Live Activities. + onEnablementChanged: { enabled in + VoltraEventBus.shared.send(.activityEnablementChanged(enabled: enabled)) } ) diff --git a/packages/ios-client/ios/app/VoltraModule.swift b/packages/ios-client/ios/app/VoltraModule.swift index 03c91f46b..9c4ef0901 100644 --- a/packages/ios-client/ios/app/VoltraModule.swift +++ b/packages/ios-client/ios/app/VoltraModule.swift @@ -180,6 +180,28 @@ public enum VoltraErrors: Error, CustomNSError { impl.setDynamicLiveActivityRenderFailureListenerActive(active) } + @objc public func popInteractions(_ sources: NSArray?) -> [[String: Any]] { + impl.popInteractions(sources: sources?.compactMap { $0 as? String }) + } + + @objc public func restoreInteractions(_ interactions: NSArray) { + impl.restoreInteractions(interactions.compactMap { $0 as? [String: Any] }) + } + + // MARK: - Push State + + @objc public func getLiveActivityPushTokens() -> [[String: String]] { + impl.getLiveActivityPushTokens() + } + + @objc public func getLiveActivityPushToStartToken() -> String? { + impl.getLiveActivityPushToStartToken() + } + + @objc public func areLiveActivitiesEnabled() -> Bool { + impl.areLiveActivitiesEnabled() + } + // MARK: - Images @objc public func preloadImages( diff --git a/packages/ios-client/ios/app/VoltraModuleImpl.swift b/packages/ios-client/ios/app/VoltraModuleImpl.swift index f514c0b40..8ae6bae24 100644 --- a/packages/ios-client/ios/app/VoltraModuleImpl.swift +++ b/packages/ios-client/ios/app/VoltraModuleImpl.swift @@ -56,6 +56,15 @@ public final class VoltraHeadlessState: NSObject { /// Implementation details for VoltraModule to keep the main module file clean public class VoltraModuleImpl { private let liveActivityService = VoltraLiveActivityService() + /// Written by `startMonitoring`/`stopMonitoring` and read by bridge calls on other threads. + private var eventSubscription: UUID? + private let eventSubscriptionLock = NSLock() + + private var currentEventSubscription: UUID? { + eventSubscriptionLock.lock() + defer { eventSubscriptionLock.unlock() } + return eventSubscription + } public init() { // Clean up data for widgets that are no longer installed @@ -94,7 +103,16 @@ public class VoltraModuleImpl { } func setDynamicLiveActivityRenderFailureListenerActive(_ active: Bool) { - VoltraEventBus.shared.setDynamicLiveActivityRenderFailureListenerActive(active) + guard let token = currentEventSubscription else { return } + VoltraEventBus.shared.setDynamicLiveActivityRenderFailureListenerActive(active, for: token) + } + + func popInteractions(sources: [String]?) -> [[String: Any]] { + VoltraEventBus.shared.popInteractions(sources: sources.map(Set.init)) + } + + func restoreInteractions(_ interactions: [[String: Any]]) { + VoltraEventBus.shared.restoreInteractions(interactions) } var pushNotificationsEnabled: Bool { @@ -106,12 +124,25 @@ public class VoltraModuleImpl { // MARK: - Lifecycle & Monitoring func startMonitoring(handler: @escaping (String, [String: Any]) -> Void) { - VoltraEventBus.shared.subscribe(handler: handler) + let token = VoltraEventBus.shared.subscribe(handler: handler) + eventSubscriptionLock.lock() + let previous = eventSubscription + eventSubscription = token + eventSubscriptionLock.unlock() + if let previous { + VoltraEventBus.shared.unsubscribe(previous) + } liveActivityService.startMonitoring(enablePush: pushNotificationsEnabled) } func stopMonitoring() { - VoltraEventBus.shared.unsubscribe() + eventSubscriptionLock.lock() + let token = eventSubscription + eventSubscription = nil + eventSubscriptionLock.unlock() + if let token { + VoltraEventBus.shared.unsubscribe(token) + } liveActivityService.stopMonitoring() } @@ -270,6 +301,26 @@ public class VoltraModuleImpl { return liveActivityService.isActivityActive(name: name) } + // MARK: - Push State + + /// Without `enablePushNotifications` activities are requested without a push type, so there is + /// no token the app asked for. + func getLiveActivityPushTokens() -> [[String: String]] { + guard pushNotificationsEnabled else { return [] } + return liveActivityService.pushTokens().map { + ["activityName": $0.activityName, "pushToken": $0.pushToken] + } + } + + func getLiveActivityPushToStartToken() -> String? { + guard pushNotificationsEnabled else { return nil } + return liveActivityService.pushToStartToken() + } + + func areLiveActivitiesEnabled() -> Bool { + VoltraLiveActivityService.areActivitiesEnabled() + } + func reloadLiveActivities(activityNames: [String]?) async throws { guard #available(iOS 16.4, *) else { throw VoltraErrors.unsupportedOS } diff --git a/packages/ios-client/ios/app/dynamic-live-activity/VoltraDynamicLiveActivityService.swift b/packages/ios-client/ios/app/dynamic-live-activity/VoltraDynamicLiveActivityService.swift index ab898ef2e..b852dd7cb 100644 --- a/packages/ios-client/ios/app/dynamic-live-activity/VoltraDynamicLiveActivityService.swift +++ b/packages/ios-client/ios/app/dynamic-live-activity/VoltraDynamicLiveActivityService.swift @@ -19,6 +19,10 @@ final class VoltraDynamicLiveActivityService { VoltraDynamicLiveActivityRegistry.shared.activities() } + func pushTokens() -> [(activityName: String, pushToken: String)] { + VoltraDynamicLiveActivityRegistry.shared.pushTokens() + } + func definitionIds() -> [String] { VoltraDynamicLiveActivityRegistry.shared.definitionIds() } diff --git a/packages/ios-client/ios/shared/VoltraDarwinNotificationRelay.swift b/packages/ios-client/ios/shared/VoltraDarwinNotificationRelay.swift new file mode 100644 index 000000000..a5426dfdb --- /dev/null +++ b/packages/ios-client/ios/shared/VoltraDarwinNotificationRelay.swift @@ -0,0 +1,83 @@ +import CoreFoundation +import Foundation + +/// Cross-process "something changed" signal for the App Group backed queues. Darwin notifications +/// carry no payload, so consumers always read storage after a signal. +final class VoltraDarwinNotificationRelay { + static let renderFailures = VoltraDarwinNotificationRelay(name: scoped("com.voltra.dynamicLiveActivityRenderFailures")) + static let interactions = VoltraDarwinNotificationRelay(name: scoped("com.voltra.interactions")) + + /// Darwin notification names are system-wide. Scoping them to the App Group, which the app and its + /// extension share, keeps one app's signals from waking every other app built with Voltra. + static func scoped(_ name: String, groupIdentifier: String? = VoltraConfig.groupIdentifier()) -> String { + guard let groupIdentifier, !groupIdentifier.isEmpty else { + return "\(name).\(Bundle.main.bundleIdentifier ?? "app")" + } + return "\(name).\(groupIdentifier)" + } + + private let lock = NSLock() + private var handlers: [UUID: () -> Void] = [:] + private let name: CFString + + private init(name: String) { + self.name = name as CFString + CFNotificationCenterAddObserver( + CFNotificationCenterGetDarwinNotifyCenter(), + Unmanaged.passUnretained(self).toOpaque(), + Self.didReceive, + self.name, + nil, + .deliverImmediately + ) + } + + deinit { + CFNotificationCenterRemoveObserver( + CFNotificationCenterGetDarwinNotifyCenter(), + Unmanaged.passUnretained(self).toOpaque(), + CFNotificationName(name), + nil + ) + } + + func add(_ handler: @escaping () -> Void) -> UUID { + let token = UUID() + lock.lock() + handlers[token] = handler + lock.unlock() + return token + } + + func remove(_ token: UUID) { + lock.lock() + handlers.removeValue(forKey: token) + lock.unlock() + } + + func post() { + notifyHandlers() + CFNotificationCenterPostNotification( + CFNotificationCenterGetDarwinNotifyCenter(), + CFNotificationName(name), + nil, + nil, + true + ) + } + + private static let didReceive: CFNotificationCallback = { _, observer, _, _, _ in + guard let observer else { return } + Unmanaged + .fromOpaque(observer) + .takeUnretainedValue() + .notifyHandlers() + } + + private func notifyHandlers() { + lock.lock() + let currentHandlers = Array(handlers.values) + lock.unlock() + currentHandlers.forEach { $0() } + } +} diff --git a/packages/ios-client/ios/shared/VoltraEvent.swift b/packages/ios-client/ios/shared/VoltraEvent.swift index 0bb27104e..6b50374fc 100644 --- a/packages/ios-client/ios/shared/VoltraEvent.swift +++ b/packages/ios-client/ios/shared/VoltraEvent.swift @@ -4,23 +4,14 @@ import Foundation /// All Voltra event types with their associated data public enum VoltraEventType { - /// Persistent events (widget → app, survives app death) + /// Persistent events: recorded in the App Group interaction queue, never posted directly case interaction(source: String, identifier: String, payload: String?) // Transient events (main app only, in-memory) case stateChange(activityName: String, state: String) case tokenReceived(activityName: String, pushToken: String) case pushToStartTokenReceived(token: String) - - /// Whether this event should be persisted to UserDefaults (survives app death) - var isPersistent: Bool { - switch self { - case .interaction: - return true - default: - return false - } - } + case activityEnablementChanged(enabled: Bool) /// The event name used for JS bridge var name: String { @@ -33,6 +24,8 @@ public enum VoltraEventType { return "activityTokenReceived" case .pushToStartTokenReceived: return "activityPushToStartTokenReceived" + case .activityEnablementChanged: + return "activityEnablementChanged" } } @@ -59,6 +52,10 @@ public enum VoltraEventType { case let .pushToStartTokenReceived(token): dict["pushToStartToken"] = token + + case let .activityEnablementChanged(enabled): + dict["timestamp"] = Date().timeIntervalSince1970 + dict["enabled"] = enabled } return dict diff --git a/packages/ios-client/ios/shared/VoltraEventBus.swift b/packages/ios-client/ios/shared/VoltraEventBus.swift index 6b6335999..8a9ec01a5 100644 --- a/packages/ios-client/ios/shared/VoltraEventBus.swift +++ b/packages/ios-client/ios/shared/VoltraEventBus.swift @@ -2,117 +2,167 @@ import Foundation import os /// A centralized event bus that manages Voltra events -/// - Persistent events (interactions): routed through UserDefaults for cross-process delivery -/// - Transient events (state changes, tokens): routed through NotificationCenter for in-process delivery +/// - Interactions: always recorded in the interaction queue. The bus only signals that taps are +/// available; JavaScript takes them with `popInteractions` (ADR 0011). +/// - Transient events (state changes, tokens, enablement): routed through NotificationCenter for +/// in-process delivery only. Their current values are readable through getters instead. +/// +/// Every React Native instance's module subscribes with its own token, so instances never replace +/// or tear down each other's handler. public class VoltraEventBus { public static let shared = VoltraEventBus() + /// Emitted to JavaScript, without tap data, whenever a tap is recorded in any process. + public static let interactionsAvailableEventName = "interactionsAvailable" + + public typealias Handler = (String, [String: Any]) -> Void + private var observer: NSObjectProtocol? private var renderFailureObserver: UUID? - private var handler: ((String, [String: Any]) -> Void)? - private var renderFailureListenerCount = 0 + private var interactionObserver: UUID? + private var handlers: [UUID: Handler] = [:] + /// Subscriptions whose JavaScript has a `dynamicLiveActivityRenderFailed` listener. + private var renderFailureListeners: Set = [] private let lock = NSLock() private init() {} /// Send an event. Routing is automatic based on event type: - /// - Persistent events → UserDefaults (survives app death, cross-process) + NotificationCenter (hot delivery) + /// - Interactions → the interaction queue, then an availability signal to every process /// - Transient events → NotificationCenter only (in-memory, same process) public func send(_ event: VoltraEventType) { - // Compute dictionary once to ensure consistent timestamp across all delivery paths - let eventData = event.asDictionary - - if event.isPersistent { - // Persistent: write to UserDefaults first (for widget → app communication, survives app death) - VoltraPersistentEventQueue.write(eventData) + if case let .interaction(source, identifier, payload) = event { + VoltraInteractionReporter.record(VoltraInteraction(source: source, identifier: identifier, payload: payload)) + return } - // Always post to NotificationCenter for immediate delivery when app is running NotificationCenter.default.post( name: .voltraEvent, object: nil, - userInfo: eventData + userInfo: event.asDictionary ) } - /// Subscribe to Voltra events. This will: - /// 1. Replay any persisted events from UserDefaults (cold start) - /// 2. Set up a NotificationCenter observer for all events (hot delivery) + /// Add a handler that forwards events to one JavaScript instance. + /// + /// Nothing is replayed here: queued interactions stay queued until JavaScript pops them. /// - /// - Parameter handler: A closure that receives the event name and event data dictionary - public func subscribe(handler: @escaping (String, [String: Any]) -> Void) { + /// - Returns: the token that identifies this subscription in `unsubscribe` and + /// `setDynamicLiveActivityRenderFailureListenerActive` + @discardableResult + public func subscribe(handler: @escaping Handler) -> UUID { + VoltraInteractionReporter.discardLegacyQueue() + + let token = UUID() lock.lock() - if let observer { - NotificationCenter.default.removeObserver(observer) - self.observer = nil + handlers[token] = handler + if handlers.count == 1 { + installObserversLocked() } - if let renderFailureObserver { - VoltraDynamicLiveActivityRenderFailureReporter.removeChangeObserver(renderFailureObserver) - self.renderFailureObserver = nil - } - self.handler = handler + lock.unlock() + return token + } - // 1. Replay persisted events from UserDefaults (interactions from widget) - let persistedEvents = VoltraPersistentEventQueue.popAll() - // 2. Listen for all events via NotificationCenter (hot delivery) - observer = NotificationCenter.default.addObserver( - forName: .voltraEvent, - object: nil, - queue: .main - ) { notification in - guard let userInfo = notification.userInfo as? [String: Any], - let eventName = userInfo["type"] as? String - else { - return - } - handler(eventName, userInfo) + /// Remove one subscription, or every subscription when `token` is nil. + public func unsubscribe(_ token: UUID? = nil) { + lock.lock() + defer { lock.unlock() } + + if let token { + handlers.removeValue(forKey: token) + renderFailureListeners.remove(token) + } else { + handlers.removeAll() + renderFailureListeners.removeAll() } - renderFailureObserver = VoltraDynamicLiveActivityRenderFailureReporter.observeChanges { [weak self] in - self?.drainDynamicLiveActivityRenderFailures() + if handlers.isEmpty { + removeObserversLocked() } - lock.unlock() + } - for event in persistedEvents { - handler(event.name, event.data) + /// Remove and return queued taps, oldest first, as JavaScript event dictionaries. With + /// `sources`, only taps from those sources are taken. + public func popInteractions(sources: Set?) -> [[String: Any]] { + let interactions = VoltraInteractionReporter.pop(sources: sources) + if !interactions.isEmpty { + VoltraLogger.event.info("Handed \(interactions.count) interactions to JavaScript") + } + return interactions.map(\.dictionary) + } + + /// Put back taps JavaScript took but had no listener for by the time it dispatched them. + public func restoreInteractions(_ dictionaries: [[String: Any]]) { + let interactions = dictionaries.compactMap(VoltraInteraction.init(dictionary:)) + VoltraInteractionReporter.restore(interactions) + if !interactions.isEmpty { + VoltraLogger.event.info("Restored \(interactions.count) undispatched interactions") } - VoltraLogger.event.info("Replayed \(persistedEvents.count) persisted events") } /// JS owns the dedicated emitter subscription count. Persisted failures must /// remain queued whenever there is no relevant JavaScript listener. - public func setDynamicLiveActivityRenderFailureListenerActive(_ active: Bool) { + public func setDynamicLiveActivityRenderFailureListenerActive(_ active: Bool, for token: UUID) { lock.lock() - renderFailureListenerCount = active ? 1 : 0 + if active, handlers[token] != nil { + renderFailureListeners.insert(token) + } else { + renderFailureListeners.remove(token) + } lock.unlock() if active { drainDynamicLiveActivityRenderFailures() } } - /// Flush only the dedicated Dynamic Live Activity diagnostic queue. This - /// cannot read, displace, or clear persistent interaction events. + /// Flush only the dedicated Dynamic Live Activity diagnostic queue, to every JavaScript instance + /// with a listener. This cannot read, displace, or clear persistent interaction events. public func drainDynamicLiveActivityRenderFailures() { lock.lock() - let handler = handler - let isReady = renderFailureListenerCount > 0 + let targets = renderFailureListeners.compactMap { handlers[$0] } lock.unlock() - guard isReady, let handler else { return } + guard !targets.isEmpty else { return } let failures = VoltraDynamicLiveActivityRenderFailureReporter.drain() for failure in failures { - handler(failure.type, failure.dictionary) + targets.forEach { $0(failure.type, failure.dictionary) } } if !failures.isEmpty { VoltraLogger.event.info("Replayed \(failures.count) Dynamic Live Activity render failures") } } - /// Unsubscribe from Voltra events - public func unsubscribe() { + private func forEachHandler(_ body: (Handler) -> Void) { lock.lock() - defer { lock.unlock() } + let current = Array(handlers.values) + lock.unlock() + current.forEach(body) + } + + private func installObserversLocked() { + observer = NotificationCenter.default.addObserver( + forName: .voltraEvent, + object: nil, + queue: .main + ) { [weak self] notification in + guard let userInfo = notification.userInfo as? [String: Any], + let eventName = userInfo["type"] as? String + else { + return + } + self?.forEachHandler { $0(eventName, userInfo) } + } + renderFailureObserver = VoltraDynamicLiveActivityRenderFailureReporter.observeChanges { [weak self] in + self?.drainDynamicLiveActivityRenderFailures() + } + interactionObserver = VoltraInteractionReporter.observeChanges { [weak self] in + self?.forEachHandler { + $0(Self.interactionsAvailableEventName, ["type": Self.interactionsAvailableEventName]) + } + } + } - if let observer = observer { + private func removeObserversLocked() { + if let observer { NotificationCenter.default.removeObserver(observer) self.observer = nil } @@ -120,8 +170,10 @@ public class VoltraEventBus { VoltraDynamicLiveActivityRenderFailureReporter.removeChangeObserver(renderFailureObserver) self.renderFailureObserver = nil } - handler = nil - renderFailureListenerCount = 0 + if let interactionObserver { + VoltraInteractionReporter.removeChangeObserver(interactionObserver) + self.interactionObserver = nil + } } deinit { diff --git a/packages/ios-client/ios/shared/VoltraInteractionQueue.swift b/packages/ios-client/ios/shared/VoltraInteractionQueue.swift new file mode 100644 index 000000000..db6c460c3 --- /dev/null +++ b/packages/ios-client/ios/shared/VoltraInteractionQueue.swift @@ -0,0 +1,238 @@ +import Foundation + +#if canImport(Darwin) + import Darwin +#else + import Glibc +#endif + +/// One button or toggle tap, as JavaScript receives it from `popInteractions`. +public struct VoltraInteraction: Codable, Equatable { + public static let eventType = "interaction" + + public let type: String + /// Stable for the lifetime of the entry, so a consumer can tell two equal taps apart. + public let id: String + public let source: String + /// Seconds since 1970, like every other Voltra event. + public let timestamp: TimeInterval + public let identifier: String + public let payload: String + + public init( + source: String, + identifier: String, + payload: String?, + id: String = UUID().uuidString, + timestamp: Date = Date() + ) { + type = Self.eventType + self.id = id + self.source = source + self.timestamp = timestamp.timeIntervalSince1970 + self.identifier = identifier + self.payload = payload ?? "" + } + + /// Rebuilds a tap JavaScript hands back; nil when a required field is missing. + public init?(dictionary: [String: Any]) { + guard + let id = dictionary["id"] as? String, + let source = dictionary["source"] as? String, + let timestamp = (dictionary["timestamp"] as? NSNumber)?.doubleValue, + let identifier = dictionary["identifier"] as? String + else { return nil } + type = Self.eventType + self.id = id + self.source = source + self.timestamp = timestamp + self.identifier = identifier + payload = dictionary["payload"] as? String ?? "" + } + + public var dictionary: [String: Any] { + [ + "type": type, + "id": id, + "source": source, + "timestamp": timestamp, + "identifier": identifier, + "payload": payload, + ] + } +} + +public protocol VoltraInteractionStorage { + /// Runs `body` on the stored entries as one transaction that excludes every other process and + /// thread using the same storage, and stores what `body` leaves in the array. + func update(_ body: (inout [VoltraInteraction]) throws -> Result) throws -> Result +} + +/// Cross-process-safe file storage, shared by the app and its extensions through the App Group. +/// +/// Every read/modify/write holds an advisory `flock` on a lock file next to the data, and the data +/// file is replaced atomically. Data that cannot be decoded is moved aside to a single quarantine +/// file and treated as an empty queue; a file that cannot be read (for example while the device is +/// locked) aborts the transaction and is left untouched. +public final class VoltraInteractionFileStorage: VoltraInteractionStorage { + private let directoryURL: URL + private let fileManager: FileManager + private let lockTimeout: TimeInterval + static let queueFileName = "interactions-v1.json" + static let quarantineFileName = "interactions-v1.json.corrupt" + private let lockFileName = "interactions-v1.lock" + + /// - Parameter lockTimeout: how long to wait for another process's transaction. `popInteractions` + /// runs on the JavaScript thread, so this never blocks indefinitely; on timeout the transaction + /// fails and the entries stay where they are. + public init(directoryURL: URL, fileManager: FileManager = .default, lockTimeout: TimeInterval = 0.25) { + self.directoryURL = directoryURL + self.fileManager = fileManager + self.lockTimeout = lockTimeout + } + + public func update(_ body: (inout [VoltraInteraction]) throws -> Result) throws -> Result { + try fileManager.createDirectory(at: directoryURL, withIntermediateDirectories: true) + let lockURL = directoryURL.appendingPathComponent(lockFileName, isDirectory: false) + let descriptor = open(lockURL.path, O_CREAT | O_RDWR, S_IRUSR | S_IWUSR) + guard descriptor >= 0 else { throw POSIXError(.EIO) } + defer { close(descriptor) } + try lock(descriptor) + defer { flock(descriptor, LOCK_UN) } + + let queueURL = directoryURL.appendingPathComponent(Self.queueFileName, isDirectory: false) + let original = try load(from: queueURL) + var entries = original + let result = try body(&entries) + if entries != original { + try JSONEncoder().encode(entries).write(to: queueURL, options: Self.writingOptions) + } + return result + } + + #if os(iOS) + /// Readable after the first unlock, so an intent running while the device is locked can record. + private static let writingOptions: Data.WritingOptions = [.atomic, .completeFileProtectionUntilFirstUserAuthentication] + #else + private static let writingOptions: Data.WritingOptions = [.atomic] + #endif + + private func lock(_ descriptor: Int32) throws { + let deadline = Date().addingTimeInterval(lockTimeout) + while true { + if flock(descriptor, LOCK_EX | LOCK_NB) == 0 { + return + } + let error = errno + guard error == EWOULDBLOCK || error == EINTR, Date() < deadline else { + throw POSIXError(POSIXErrorCode(rawValue: error) ?? .EIO) + } + usleep(2000) + } + } + + private func load(from url: URL) throws -> [VoltraInteraction] { + guard fileManager.fileExists(atPath: url.path) else { return [] } + let data = try Data(contentsOf: url) + do { + return try JSONDecoder().decode([VoltraInteraction].self, from: data) + } catch { + let quarantineURL = directoryURL.appendingPathComponent(Self.quarantineFileName, isDirectory: false) + try? fileManager.removeItem(at: quarantineURL) + try? fileManager.moveItem(at: url, to: quarantineURL) + return [] + } + } +} + +/// Storage for a process without an App Group. It only reaches taps made in the same process, such +/// as a `VoltraView` in the app or a Live Activity intent the system runs in the app. +public final class VoltraInteractionMemoryStorage: VoltraInteractionStorage { + private var entries: [VoltraInteraction] = [] + private let lock = NSLock() + + public init() {} + + public func update(_ body: (inout [VoltraInteraction]) throws -> Result) throws -> Result { + lock.lock() + defer { lock.unlock() } + return try body(&entries) + } +} + +/// The bounded FIFO of taps waiting for JavaScript (ADR 0011). +/// +/// Every tap is recorded here, whether or not the app is listening. JavaScript removes entries +/// with `pop`, which returns them in the same step, so an entry is either still queued or already +/// in JavaScript's hands. Entries older than `maxAge` and the oldest beyond `capacity` are dropped: +/// a tap nobody consumed for that long is no longer something to act on. +public final class VoltraInteractionQueue { + public static let capacity = 100 + public static let maxAge: TimeInterval = 15 * 60 + + private let storage: VoltraInteractionStorage + private let now: () -> Date + + public init(storage: VoltraInteractionStorage, now: @escaping () -> Date = Date.init) { + self.storage = storage + self.now = now + } + + @discardableResult + public func record(_ interaction: VoltraInteraction) -> Bool { + let cutoff = now().timeIntervalSince1970 - Self.maxAge + do { + try storage.update { entries in + entries.removeAll { $0.timestamp < cutoff } + entries.append(interaction) + if entries.count > Self.capacity { + entries.removeFirst(entries.count - Self.capacity) + } + } + return true + } catch { + return false + } + } + + /// Puts taps JavaScript took but could not hand to any listener back at the front of the queue, + /// skipping any already present. Age and capacity limits apply as usual. + @discardableResult + public func restore(_ interactions: [VoltraInteraction]) -> Bool { + guard !interactions.isEmpty else { return true } + let cutoff = now().timeIntervalSince1970 - Self.maxAge + do { + try storage.update { entries in + let present = Set(entries.map(\.id)) + entries.insert(contentsOf: interactions.filter { !present.contains($0.id) }, at: 0) + entries.removeAll { $0.timestamp < cutoff } + if entries.count > Self.capacity { + entries.removeFirst(entries.count - Self.capacity) + } + } + return true + } catch { + return false + } + } + + /// Removes and returns queued taps in the order they happened. With `sources`, only taps from + /// those sources are taken; the rest stay queued for a listener that wants them. + public func pop(sources: Set? = nil) -> [VoltraInteraction] { + let cutoff = now().timeIntervalSince1970 - Self.maxAge + do { + return try storage.update { entries in + entries.removeAll { $0.timestamp < cutoff } + guard let sources else { + defer { entries.removeAll() } + return entries + } + let taken = entries.filter { sources.contains($0.source) } + entries.removeAll { sources.contains($0.source) } + return taken + } + } catch { + return [] + } + } +} diff --git a/packages/ios-client/ios/shared/VoltraInteractionReporter.swift b/packages/ios-client/ios/shared/VoltraInteractionReporter.swift new file mode 100644 index 000000000..2dbed3437 --- /dev/null +++ b/packages/ios-client/ios/shared/VoltraInteractionReporter.swift @@ -0,0 +1,71 @@ +import Foundation +import os + +/// Owns the production interaction queue (ADR 0011). Any process that runs +/// `VoltraInteractionIntent` records here; the app pops when JavaScript asks. +public enum VoltraInteractionReporter { + /// UserDefaults key of the queue earlier releases used. Its entries include taps the app had + /// already handled, so they are discarded rather than migrated. + static let legacyQueueKey = "Voltra_EventsQueue" + + /// Taps this process could not write to the App Group, or every tap when there is no App Group. + /// They reach JavaScript only in this process. + private static let processQueue = VoltraInteractionQueue(storage: VoltraInteractionMemoryStorage()) + + private static var sharedQueue: VoltraInteractionQueue? { + guard + let group = VoltraConfig.groupIdentifier(), + let directoryURL = FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: group) + else { + return nil + } + return VoltraInteractionQueue(storage: VoltraInteractionFileStorage(directoryURL: directoryURL)) + } + + /// Records the tap and signals every process that taps are available. + @discardableResult + public static func record(_ interaction: VoltraInteraction) -> Bool { + if let sharedQueue, sharedQueue.record(interaction) { + VoltraLogger.event.debug("Recorded interaction \(interaction.identifier, privacy: .public)") + } else { + // No App Group, or the shared file is unavailable (lock timeout, locked device). Keep the tap + // for a listener in this process rather than dropping it. + guard processQueue.record(interaction) else { return false } + VoltraLogger.event.info("Recorded interaction \(interaction.identifier, privacy: .public) in process memory") + } + VoltraDarwinNotificationRelay.interactions.post() + return true + } + + /// Taps from the App Group and from this process, oldest first. + public static func pop(sources: Set?) -> [VoltraInteraction] { + let shared = sharedQueue?.pop(sources: sources) ?? [] + let local = processQueue.pop(sources: sources) + guard !local.isEmpty else { return shared } + return (shared + local).sorted { $0.timestamp < $1.timestamp } + } + + /// Puts taps JavaScript could not dispatch back at the front of the queue. + public static func restore(_ interactions: [VoltraInteraction]) { + guard !interactions.isEmpty else { return } + if let sharedQueue, sharedQueue.restore(interactions) { + return + } + processQueue.restore(interactions) + } + + /// The returned token must be removed when the JS bridge listener goes away. + public static func observeChanges(_ handler: @escaping () -> Void) -> UUID { + VoltraDarwinNotificationRelay.interactions.add(handler) + } + + public static func removeChangeObserver(_ token: UUID) { + VoltraDarwinNotificationRelay.interactions.remove(token) + } + + static func discardLegacyQueue(in defaults: UserDefaults? = VoltraConfig.groupIdentifier().flatMap(UserDefaults.init(suiteName:))) { + guard let defaults, defaults.object(forKey: legacyQueueKey) != nil else { return } + defaults.removeObject(forKey: legacyQueueKey) + VoltraLogger.event.info("Discarded the interaction queue left by an earlier release") + } +} diff --git a/packages/ios-client/ios/shared/VoltraLogger.swift b/packages/ios-client/ios/shared/VoltraLogger.swift index daca1da61..bbe9a77c5 100644 --- a/packages/ios-client/ios/shared/VoltraLogger.swift +++ b/packages/ios-client/ios/shared/VoltraLogger.swift @@ -19,7 +19,7 @@ enum VoltraLogger { /// Image preloading and the shared image store static let image = Logger(subsystem: subsystem, category: "image") - /// VoltraEventBus and VoltraPersistentEventQueue + /// VoltraEventBus and the interaction queue static let event = Logger(subsystem: subsystem, category: "event") /// Keychain reads and writes diff --git a/packages/ios-client/ios/shared/VoltraPersistentEventQueue.swift b/packages/ios-client/ios/shared/VoltraPersistentEventQueue.swift deleted file mode 100644 index 1f4f63700..000000000 --- a/packages/ios-client/ios/shared/VoltraPersistentEventQueue.swift +++ /dev/null @@ -1,67 +0,0 @@ -import Foundation -import os - -/// Persistent event queue for events that must survive app death (e.g., interactions from widget) -/// Uses UserDefaults with App Group for cross-process communication -public enum VoltraPersistentEventQueue { - private static let queueKey = "Voltra_EventsQueue" - - /// Write an event to the persistent queue (UserDefaults) - static func write(_ payload: [String: Any]) { - guard let group = VoltraConfig.groupIdentifier(), - let defaults = UserDefaults(suiteName: group), - JSONSerialization.isValidJSONObject(payload), - let data = try? JSONSerialization.data(withJSONObject: payload, options: []) - else { - VoltraLogger.event.error("Failed to write event — no group identifier or invalid payload") - return - } - - let jsonString = String(data: data, encoding: .utf8) ?? "{}" - - var queue = defaults.array(forKey: queueKey) as? [String] ?? [] - queue.append(jsonString) - defaults.set(queue, forKey: queueKey) - defaults.synchronize() - - let eventName = payload["type"] as? String ?? "unknown" - VoltraLogger.event.debug("Wrote event: \(eventName)") - } - - /// Read all events from the persistent queue and clear it - /// Returns an array of (eventName, eventData) tuples - static func popAll() -> [(name: String, data: [String: Any])] { - guard let group = VoltraConfig.groupIdentifier() else { - VoltraLogger.event.error("popAll: No group identifier found") - return [] - } - guard let defaults = UserDefaults(suiteName: group) else { - VoltraLogger.event.error("popAll: Could not access UserDefaults for group '\(group)'") - return [] - } - - let queue = defaults.array(forKey: queueKey) as? [String] ?? [] - let eventCount = queue.count - VoltraLogger.event.debug("popAll: Found \(eventCount) events in queue") - - // Clear the queue first - defaults.set([String](), forKey: queueKey) - defaults.synchronize() - - // Skip processing if queue is excessively large (stale data) - if eventCount > 1000 { - VoltraLogger.event.warning("popAll: Skipping \(eventCount) stale events") - return [] - } - - // Parse JSON strings into event tuples - return queue.compactMap { jsonString -> (name: String, data: [String: Any])? in - guard let data = jsonString.data(using: .utf8), - let dict = try? JSONSerialization.jsonObject(with: data) as? [String: Any], - let eventName = dict["type"] as? String - else { return nil } - - return (name: eventName, data: dict) - } - } -} diff --git a/packages/ios-client/ios/shared/dynamic-live-activity/VoltraDynamicLiveActivityOperations.swift b/packages/ios-client/ios/shared/dynamic-live-activity/VoltraDynamicLiveActivityOperations.swift index 37b1f95e5..63d8c79c6 100644 --- a/packages/ios-client/ios/shared/dynamic-live-activity/VoltraDynamicLiveActivityOperations.swift +++ b/packages/ios-client/ios/shared/dynamic-live-activity/VoltraDynamicLiveActivityOperations.swift @@ -91,6 +91,15 @@ public enum VoltraDynamicLiveActivityOperations { } } + public static func pushTokens( + _: Attributes.Type + ) -> [(activityName: String, pushToken: String)] { + Activity.activities.compactMap { activity in + guard activity.activityState.canReceivePushUpdates, let token = activity.pushToken else { return nil } + return (activity.attributes.name, token.hexString) + } + } + public static func reload( _: Attributes.Type ) async { @@ -103,3 +112,16 @@ public enum VoltraDynamicLiveActivityOperations { } } } + +extension ActivityState { + /// Ended and dismissed activities can linger in `Activity.activities`, but their push tokens no + /// longer reach anything. + var canReceivePushUpdates: Bool { + switch self { + case .ended, .dismissed: + return false + default: + return true + } + } +} diff --git a/packages/ios-client/ios/shared/dynamic-live-activity/VoltraDynamicLiveActivityRegistry.swift b/packages/ios-client/ios/shared/dynamic-live-activity/VoltraDynamicLiveActivityRegistry.swift index aef9d7e0f..2bcdb2aba 100644 --- a/packages/ios-client/ios/shared/dynamic-live-activity/VoltraDynamicLiveActivityRegistry.swift +++ b/packages/ios-client/ios/shared/dynamic-live-activity/VoltraDynamicLiveActivityRegistry.swift @@ -12,6 +12,7 @@ private struct VoltraDynamicLiveActivityDescriptor { let end: (String, ActivityUIDismissalPolicy) async -> Bool let endAll: (ActivityUIDismissalPolicy) async -> Void let activities: () -> [VoltraDynamicLiveActivityReference] + let pushTokens: () -> [(activityName: String, pushToken: String)] let observe: (VoltraDynamicLiveActivityObserver) async -> Void let reload: () async -> Void @@ -22,6 +23,7 @@ private struct VoltraDynamicLiveActivityDescriptor { end = { await VoltraDynamicLiveActivityOperations.end(attributes, byName: $0, dismissalPolicy: $1) } endAll = { await VoltraDynamicLiveActivityOperations.endAll(attributes, dismissalPolicy: $0) } activities = { VoltraDynamicLiveActivityOperations.activities(attributes) } + pushTokens = { VoltraDynamicLiveActivityOperations.pushTokens(attributes) } observe = { await $0.observe(attributes) } reload = { await VoltraDynamicLiveActivityOperations.reload(attributes) } } @@ -96,6 +98,11 @@ public final class VoltraDynamicLiveActivityRegistry { descriptorSnapshot().flatMap { $0.activities() } } + /// (activityName, hexToken) for every running activity of every registered type with a token. + public func pushTokens() -> [(activityName: String, pushToken: String)] { + descriptorSnapshot().flatMap { $0.pushTokens() } + } + public func startObserving(with observer: VoltraDynamicLiveActivityObserver) async { for descriptor in descriptorSnapshot() { await descriptor.observe(observer) diff --git a/packages/ios-client/ios/shared/dynamic-live-activity/VoltraDynamicLiveActivityRenderFailureReporter.swift b/packages/ios-client/ios/shared/dynamic-live-activity/VoltraDynamicLiveActivityRenderFailureReporter.swift index f0cbb299f..6aafc6001 100644 --- a/packages/ios-client/ios/shared/dynamic-live-activity/VoltraDynamicLiveActivityRenderFailureReporter.swift +++ b/packages/ios-client/ios/shared/dynamic-live-activity/VoltraDynamicLiveActivityRenderFailureReporter.swift @@ -1,76 +1,5 @@ -import CoreFoundation import Foundation -/// Cross-process notification relay for the App Group backed failure queue. -/// Darwin notifications carry no payload, so consumers always drain storage. -private final class VoltraDynamicLiveActivityRenderFailureNotifier { - static let shared = VoltraDynamicLiveActivityRenderFailureNotifier() - - private let lock = NSLock() - private var handlers: [UUID: () -> Void] = [:] - private let name = "com.voltra.dynamicLiveActivityRenderFailures" as CFString - - private init() { - CFNotificationCenterAddObserver( - CFNotificationCenterGetDarwinNotifyCenter(), - Unmanaged.passUnretained(self).toOpaque(), - Self.didReceive, - name, - nil, - .deliverImmediately - ) - } - - deinit { - CFNotificationCenterRemoveObserver( - CFNotificationCenterGetDarwinNotifyCenter(), - Unmanaged.passUnretained(self).toOpaque(), - CFNotificationName(name), - nil - ) - } - - func add(_ handler: @escaping () -> Void) -> UUID { - let token = UUID() - lock.lock() - handlers[token] = handler - lock.unlock() - return token - } - - func remove(_ token: UUID) { - lock.lock() - handlers.removeValue(forKey: token) - lock.unlock() - } - - func post() { - notifyHandlers() - CFNotificationCenterPostNotification( - CFNotificationCenterGetDarwinNotifyCenter(), - CFNotificationName(name), - nil, - nil, - true - ) - } - - private static let didReceive: CFNotificationCallback = { _, observer, _, _, _ in - guard let observer else { return } - Unmanaged - .fromOpaque(observer) - .takeUnretainedValue() - .notifyHandlers() - } - - private func notifyHandlers() { - lock.lock() - let currentHandlers = Array(handlers.values) - lock.unlock() - currentHandlers.forEach { $0() } - } -} - /// Owns the production App Group queue and OS logging path. The extension only /// records failures; the app's event bus drains them when JavaScript can listen. public enum VoltraDynamicLiveActivityRenderFailureReporter { @@ -98,7 +27,7 @@ public enum VoltraDynamicLiveActivityRenderFailureReporter { "[DynamicLiveActivity] activity=\(failure.activityName, privacy: .public) definitionId=\(failure.definitionId, privacy: .public) \(failure.message, privacy: .public)" ) if persisted { - VoltraDynamicLiveActivityRenderFailureNotifier.shared.post() + VoltraDarwinNotificationRelay.renderFailures.post() } else { VoltraLogger.event.error("Failed to persist Dynamic Live Activity render failure") } @@ -111,10 +40,10 @@ public enum VoltraDynamicLiveActivityRenderFailureReporter { /// The returned token must be removed when the JS bridge listener goes away. public static func observeChanges(_ handler: @escaping () -> Void) -> UUID { - VoltraDynamicLiveActivityRenderFailureNotifier.shared.add(handler) + VoltraDarwinNotificationRelay.renderFailures.add(handler) } public static func removeChangeObserver(_ token: UUID) { - VoltraDynamicLiveActivityRenderFailureNotifier.shared.remove(token) + VoltraDarwinNotificationRelay.renderFailures.remove(token) } } diff --git a/packages/ios-client/jest.dynamic-widget.config.js b/packages/ios-client/jest.dynamic-widget.config.js index 7dd2f0e05..1d3e3be06 100644 --- a/packages/ios-client/jest.dynamic-widget.config.js +++ b/packages/ios-client/jest.dynamic-widget.config.js @@ -5,6 +5,8 @@ module.exports = { testMatch: [ '/tests/dynamic-widget/**/*.node.test.ts', '/tests/dynamic-live-activity/**/*.node.test.ts', + '/tests/events/**/*.node.test.ts?(x)', + '/tests/live-activity/**/*.node.test.ts', ], modulePathIgnorePatterns: ['/build'], moduleNameMapper: { diff --git a/packages/ios-client/src/components/VoltraView.tsx b/packages/ios-client/src/components/VoltraView.tsx index bfb6e7ed7..7a2234b6b 100644 --- a/packages/ios-client/src/components/VoltraView.tsx +++ b/packages/ios-client/src/components/VoltraView.tsx @@ -1,11 +1,12 @@ -import React, { type ReactNode, useEffect, useMemo } from 'react' +import React, { type ReactNode, useEffect, useMemo, useRef } from 'react' import { type StyleProp, type ViewStyle } from 'react-native' import { renderVoltraVariantToJson } from '@use-voltra/ios' import VoltraFabricView from '../native/VoltraRNNativeComponent' -import { addVoltraListener, type VoltraInteractionEvent } from '../events.js' +import { type VoltraInteractionEvent } from '../events.js' +import { subscribeToInteractions } from '../interactions.js' const generateViewId = () => `voltra-view-${Date.now()}-${Math.random().toString(36).substring(2, 9)}` @@ -27,23 +28,30 @@ export function VoltraView({ id, children, style, onInteraction, testID }: Voltr const json = renderVoltraVariantToJson(children) const payload = JSON.stringify(json) + // An inline `onInteraction` is a new function on every render. Keep the subscription stable and + // call the latest one, so re-renders do not resubscribe and pull from the queue each time. + const onInteractionRef = useRef(onInteraction) + onInteractionRef.current = onInteraction + const hasInteractionHandler = onInteraction !== undefined + useEffect(() => { - if (!onInteraction) return - - const subscription = addVoltraListener('interaction', (event) => { - if (event.source === viewId) { - onInteraction({ - type: 'interaction', - source: event.source, - timestamp: event.timestamp, - identifier: event.identifier, - payload: event.payload, - }) - } - }) + if (!hasInteractionHandler) return + + // Filtered by source, so a mounted VoltraView never takes taps meant for a Live Activity or + // widget out of the queue. + const subscription = subscribeToInteractions((event) => { + onInteractionRef.current?.({ + type: 'interaction', + id: event.id, + source: event.source, + timestamp: event.timestamp, + identifier: event.identifier, + payload: event.payload, + }) + }, viewId) return () => subscription.remove() - }, [viewId, onInteraction]) + }, [viewId, hasInteractionHandler]) return } diff --git a/packages/ios-client/src/events.ts b/packages/ios-client/src/events.ts index e33ef0d62..e2e90598b 100644 --- a/packages/ios-client/src/events.ts +++ b/packages/ios-client/src/events.ts @@ -1,5 +1,6 @@ import { Platform } from 'react-native' +import { subscribeToInteractions } from './interactions.js' import type { EventSubscription } from './types.js' import { getNativeVoltra } from './VoltraModule.js' @@ -18,6 +19,14 @@ export type VoltraActivityPushToStartTokenReceivedEvent = BasicVoltraEvent & { type: 'activityPushToStartTokenReceived' pushToStartToken: string } +/** + * The user allowed or disallowed Live Activities for the app. Read the current value with + * `areLiveActivitiesEnabled()`. App-wide, so it has no `source`. + */ +export type VoltraActivityEnablementChangedEvent = Omit & { + type: 'activityEnablementChanged' + enabled: boolean +} export type VoltraActivityUpdateEvent = BasicVoltraEvent & { type: 'stateChange' activityName: string @@ -26,6 +35,8 @@ export type VoltraActivityUpdateEvent = BasicVoltraEvent & { export type VoltraInteractionEvent = BasicVoltraEvent & { type: 'interaction' + /** Unique per tap, so two taps on the same button can be told apart. */ + id: string identifier: string payload: string } @@ -49,6 +60,7 @@ let dynamicRenderFailureListenerCount = 0 export type VoltraEventMap = { activityTokenReceived: VoltraActivityTokenReceivedEvent activityPushToStartTokenReceived: VoltraActivityPushToStartTokenReceivedEvent + activityEnablementChanged: VoltraActivityEnablementChangedEvent stateChange: VoltraActivityUpdateEvent interaction: VoltraInteractionEvent dynamicLiveActivityRenderFailed: VoltraDynamicLiveActivityRenderFailedEvent @@ -72,10 +84,16 @@ export function addVoltraListener( return voltraModule.onActivityPushToStartTokenReceived( listener as (arg: VoltraActivityPushToStartTokenReceivedEvent) => void ) + case 'activityEnablementChanged': + if (typeof voltraModule.onActivityEnablementChanged !== 'function') { + console.warn(`[Voltra] Event '${event}' needs a newer native build. Returning no-op subscription.`) + return noopSubscription + } + return voltraModule.onActivityEnablementChanged(listener as (arg: VoltraActivityEnablementChangedEvent) => void) case 'stateChange': return voltraModule.onStateChanged(listener as (arg: VoltraActivityUpdateEvent) => void) case 'interaction': - return voltraModule.onInteraction(listener as (arg: VoltraInteractionEvent) => void) + return subscribeToInteractions(listener as (arg: VoltraInteractionEvent) => void) case 'dynamicLiveActivityRenderFailed': const subscription = voltraModule.onDynamicLiveActivityRenderFailed( listener as (arg: VoltraDynamicLiveActivityRenderFailedEvent) => void diff --git a/packages/ios-client/src/index.ts b/packages/ios-client/src/index.ts index 74312f947..2401f90eb 100644 --- a/packages/ios-client/src/index.ts +++ b/packages/ios-client/src/index.ts @@ -40,6 +40,12 @@ export { type DynamicLiveActivityProps, type DynamicLiveActivityPropsValue, } from './live-activity/dynamic-api.js' +export { + areLiveActivitiesEnabled, + getLiveActivityPushTokens, + getLiveActivityPushToStartToken, + type LiveActivityPushToken, +} from './live-activity/push.js' export type { DismissalPolicy, LiveActivityVariants } from '@use-voltra/ios' export { clearPreloadedImages, diff --git a/packages/ios-client/src/interactions.ts b/packages/ios-client/src/interactions.ts new file mode 100644 index 000000000..8798a4ed0 --- /dev/null +++ b/packages/ios-client/src/interactions.ts @@ -0,0 +1,157 @@ +import { AppState, type NativeEventSubscription } from 'react-native' + +import type { VoltraInteractionEvent } from './events.js' +import type { EventSubscription } from './types.js' +import { getNativeVoltra } from './VoltraModule.js' + +// Taps are always recorded in the native interaction queue (ADR 0011). JavaScript pulls them with +// the synchronous `popInteractions`, so a tap leaves the queue in the same call that hands it to +// JavaScript, and is dispatched here to the listeners that want it. + +type InteractionListener = (event: VoltraInteractionEvent) => void + +type Subscriber = { + listener: InteractionListener + /** Only taps from this source. Undefined means every tap. */ + source?: string +} + +const subscribers = new Set() +let availabilitySubscription: EventSubscription | null = null +let appStateSubscription: NativeEventSubscription | null = null +let isPumping = false +let pumpRequested = false +let pumpScheduled = false + +const wants = (subscriber: Subscriber, event: VoltraInteractionEvent) => + subscribers.has(subscriber) && (subscriber.source === undefined || subscriber.source === event.source) + +/** Returns whether any listener received the tap. */ +const dispatch = (event: VoltraInteractionEvent): boolean => { + let delivered = false + for (const subscriber of [...subscribers]) { + // Checked per call: an earlier listener may have removed this one. + if (!wants(subscriber, event)) continue + delivered = true + try { + subscriber.listener(event) + } catch (error) { + console.error('[Voltra] An interaction listener threw:', error) + } + } + return delivered +} + +/** + * Take every queued tap someone is listening for and dispatch it. Taps from sources nobody listens + * to stay queued: with only source-filtered listeners (a `VoltraView`), only their sources are taken. + * Taps whose listeners all went away during the dispatch are put back, in order. + */ +const pump = () => { + if (isPumping) { + // A signal arrived or a listener was added during dispatch; pump again once this batch is done + // so taps keep their order. + pumpRequested = true + return + } + + isPumping = true + try { + do { + pumpRequested = false + if (subscribers.size === 0) return + + const all = [...subscribers] + const sources = all.some((subscriber) => subscriber.source === undefined) + ? null + : [...new Set(all.map((subscriber) => subscriber.source as string))] + + const voltraModule = getNativeVoltra() + let events: readonly VoltraInteractionEvent[] + try { + events = voltraModule.popInteractions(sources) as unknown as VoltraInteractionEvent[] + } catch (error) { + console.error('[Voltra] Failed to read queued interactions:', error) + return + } + + const undelivered = events.filter((event) => !dispatch(event)) + if (undelivered.length > 0) { + try { + voltraModule.restoreInteractions(undelivered) + } catch (error) { + console.error('[Voltra] Failed to put back undelivered interactions:', error) + } + } + } while (pumpRequested) + } finally { + isPumping = false + } +} + +/** + * Pump on a microtask rather than inside the caller. `addVoltraListener` then returns before any + * listener runs, so a listener that refers to its own subscription, or to state its caller sets up + * after subscribing, does not throw on a tap that is already out of the queue. Calls in the same + * tick, such as several listeners mounting together or a doubled signal, share one pump. + */ +const schedulePump = () => { + if (pumpScheduled) return + pumpScheduled = true + Promise.resolve().then(() => { + pumpScheduled = false + pump() + }) +} + +const attachTriggers = () => { + availabilitySubscription = getNativeVoltra().onInteractionsAvailable(schedulePump) + appStateSubscription = AppState.addEventListener('change', (state) => { + if (state === 'active') schedulePump() + }) +} + +const detachTriggers = () => { + availabilitySubscription?.remove() + availabilitySubscription = null + appStateSubscription?.remove() + appStateSubscription = null +} + +/** + * Subscribe to taps. `source` limits the subscription to one Live Activity, widget or `VoltraView` + * and keeps it from taking other sources' taps out of the queue. + */ +export function subscribeToInteractions(listener: InteractionListener, source?: string): EventSubscription { + const voltraModule = getNativeVoltra() + + // A JavaScript bundle updated over the air can run on a native build from before the queue, + // which still emits every tap directly. + if (typeof voltraModule.popInteractions !== 'function' || typeof voltraModule.restoreInteractions !== 'function') { + // Those builds send no id; derive one that is the same for every listener of the same tap. + return voltraModule.onInteraction((event) => { + if (source === undefined || event.source === source) { + listener({ id: `${event.timestamp}:${event.source}:${event.identifier}`, ...event } as VoltraInteractionEvent) + } + }) + } + + const subscriber: Subscriber = { listener, source } + subscribers.add(subscriber) + if (subscribers.size === 1) { + attachTriggers() + } + schedulePump() + + let removed = false + return { + remove: () => { + if (removed) return + removed = true + subscribers.delete(subscriber) + if (subscribers.size === 0) { + detachTriggers() + } + }, + } +} diff --git a/packages/ios-client/src/live-activity/push.ts b/packages/ios-client/src/live-activity/push.ts new file mode 100644 index 000000000..a6b6c8c99 --- /dev/null +++ b/packages/ios-client/src/live-activity/push.ts @@ -0,0 +1,64 @@ +import type { Spec } from '../native/NativeVoltra.js' +import { assertRunningOnApple } from '../utils/index.js' +import { getNativeVoltra } from '../VoltraModule.js' + +/** + * A JavaScript bundle updated over the air can run on a native build without these methods. Warn + * and fall back instead of throwing a TypeError. + */ +const nativeMethod = < + K extends 'getLiveActivityPushTokens' | 'getLiveActivityPushToStartToken' | 'areLiveActivitiesEnabled' +>( + name: K +): Spec[K] | null => { + const voltraModule = getNativeVoltra() + const method = voltraModule[name] + if (typeof method !== 'function') { + console.warn(`[Voltra] ${name}() needs a newer native build. Rebuild the app to use it.`) + return null + } + return method.bind(voltraModule) as Spec[K] +} + +export type LiveActivityPushToken = { + /** Same value as `activityName` on `activityTokenReceived`. Covers static and dynamic activities. */ + activityName: string + pushToken: string +} + +/** + * Tokens of running activities that already have one. Empty when push notifications are disabled. + * + * Subscribe to `activityTokenReceived` first, then call this: a token issued in between is then seen + * at least once, and the same token may arrive from both, so upsert it on your backend. + */ +export async function getLiveActivityPushTokens(): Promise { + if (!assertRunningOnApple()) return [] + const getTokens = nativeMethod('getLiveActivityPushTokens') + if (!getTokens) return [] + const tokens = await getTokens() + return tokens.map(({ activityName, pushToken }) => ({ activityName, pushToken })) +} + +/** + * iOS 17.2+. null on older iOS, when push is disabled, or before APNs issues one. + * + * Subscribe to `activityPushToStartTokenReceived` first, then call this, and upsert the token on your + * backend. + */ +export async function getLiveActivityPushToStartToken(): Promise { + if (!assertRunningOnApple()) return null + const getToken = nativeMethod('getLiveActivityPushToStartToken') + if (!getToken) return null + return (await getToken()) ?? null +} + +/** + * Whether the user allows Live Activities for this app. Synchronous, like `isLiveActivityActive`. + * False below iOS 16.4. Listen for `activityEnablementChanged` to learn when it changes. + */ +export function areLiveActivitiesEnabled(): boolean { + if (!assertRunningOnApple()) return false + const areEnabled = nativeMethod('areLiveActivitiesEnabled') + return areEnabled ? areEnabled() : false +} diff --git a/packages/ios-client/src/native/NativeVoltra.ts b/packages/ios-client/src/native/NativeVoltra.ts index 78446c9e6..ea8404d64 100644 --- a/packages/ios-client/src/native/NativeVoltra.ts +++ b/packages/ios-client/src/native/NativeVoltra.ts @@ -16,6 +16,17 @@ type VoltraActivityPushToStartTokenReceivedEvent = Readonly<{ pushToStartToken: string }> +type VoltraActivityEnablementChangedEvent = Readonly<{ + timestamp: number + type: 'activityEnablementChanged' + enabled: boolean +}> + +type LiveActivityPushToken = Readonly<{ + activityName: string + pushToken: string +}> + type VoltraActivityUpdateEvent = Readonly<{ source: string timestamp: number @@ -32,6 +43,19 @@ type VoltraInteractionEvent = Readonly<{ payload: string }> +type QueuedVoltraInteraction = Readonly<{ + type: string + id: string + source: string + timestamp: number + identifier: string + payload: string +}> + +type VoltraInteractionsAvailableEvent = Readonly<{ + type: string +}> + type VoltraDynamicLiveActivityRenderFailedEvent = Readonly<{ source: string timestamp: number @@ -94,13 +118,24 @@ type WidgetServerCredentials = Readonly<{ }> export interface Spec extends TurboModule { + /** Native builds before the interaction queue (ADR 0011) emit taps here; current builds never do. */ readonly onInteraction: CodegenTypes.EventEmitter + /** Internal: a tap was recorded in the interaction queue. Carries no tap data; call `popInteractions`. */ + readonly onInteractionsAvailable: CodegenTypes.EventEmitter readonly onDynamicLiveActivityRenderFailed: CodegenTypes.EventEmitter readonly onStateChanged: CodegenTypes.EventEmitter readonly onActivityTokenReceived: CodegenTypes.EventEmitter readonly onActivityPushToStartTokenReceived: CodegenTypes.EventEmitter + readonly onActivityEnablementChanged: CodegenTypes.EventEmitter drainDynamicLiveActivityRenderFailures(): void setDynamicLiveActivityRenderFailureListenerActive(active: boolean): void + /** Internal: remove and return queued taps, oldest first; with `sources`, only taps from those sources. */ + popInteractions(sources?: string[] | null): QueuedVoltraInteraction[] + /** Internal: put back taps that were popped but had no listener left when dispatched. Synchronous so a following pop sees them. */ + restoreInteractions(interactions: QueuedVoltraInteraction[]): boolean + getLiveActivityPushTokens(): Promise + getLiveActivityPushToStartToken(): Promise + areLiveActivitiesEnabled(): boolean startLiveActivity(jsonString: string, options: StartVoltraOptions): Promise updateLiveActivity(activityId: string, jsonString: string, options: UpdateVoltraOptions): Promise startDynamicLiveActivity(definitionId: string, propsJson: string, options: StartVoltraOptions): Promise diff --git a/packages/ios-client/tests/events/interactionEvents.node.test.ts b/packages/ios-client/tests/events/interactionEvents.node.test.ts new file mode 100644 index 000000000..93e87b79c --- /dev/null +++ b/packages/ios-client/tests/events/interactionEvents.node.test.ts @@ -0,0 +1,302 @@ +import type { VoltraActivityEnablementChangedEvent, VoltraInteractionEvent } from '../../src/events.js' + +jest.mock('react-native', () => ({ + Platform: { OS: 'ios' }, + AppState: { addEventListener: jest.fn() }, +})) +jest.mock('../../src/VoltraModule.js', () => ({ getNativeVoltra: jest.fn() })) + +type Tap = Omit + +const tap = (id: string, source = 'order-123'): VoltraInteractionEvent => ({ + type: 'interaction', + id, + source, + identifier: `button-${id}`, + payload: '', + timestamp: 1, +}) + +// Each test gets fresh module state, as a new JavaScript instance after a reload would. +const setup = (queued: VoltraInteractionEvent[] = []) => { + jest.resetModules() + const { AppState } = require('react-native') as typeof import('react-native') + const { getNativeVoltra } = require('../../src/VoltraModule.js') as typeof import('../../src/VoltraModule.js') + const { addVoltraListener } = require('../../src/events.js') as typeof import('../../src/events.js') + const { subscribeToInteractions } = require('../../src/interactions.js') as typeof import('../../src/interactions.js') + + const queue = [...queued] + let signal: (() => void) | undefined + const signalSubscription = { remove: jest.fn(() => (signal = undefined)) } + let appStateListener: ((state: string) => void) | undefined + const appStateSubscription = { remove: jest.fn(() => (appStateListener = undefined)) } + jest.mocked(AppState.addEventListener).mockImplementation(((_: string, listener: (state: string) => void) => { + appStateListener = listener + return appStateSubscription + }) as unknown as typeof AppState.addEventListener) + + const popInteractions = jest.fn((sources?: string[] | null) => { + const taken = queue.filter((event) => !sources || sources.includes(event.source)) + for (const event of taken) queue.splice(queue.indexOf(event), 1) + return taken + }) + const onInteractionsAvailable = jest.fn((handler: () => void) => { + signal = handler + return signalSubscription + }) + const restoreInteractions = jest.fn((events: VoltraInteractionEvent[]) => { + queue.unshift(...events) + return true + }) + const native = { popInteractions, restoreInteractions, onInteractionsAvailable, onInteraction: jest.fn() } + jest.mocked(getNativeVoltra).mockReturnValue(native as never) + + return { + addVoltraListener, + subscribeToInteractions, + native, + queue, + record: (event: VoltraInteractionEvent) => { + queue.push(event) + signal?.() + }, + foreground: () => appStateListener?.('active'), + signalSubscription, + appStateSubscription, + } +} + +const ids = (listener: jest.Mock) => listener.mock.calls.map(([event]: [Tap]) => event.id) + +// Pumps run on a microtask after the call that scheduled them. +const flush = () => new Promise((resolve) => setImmediate(resolve)) + +describe('interaction delivery', () => { + afterEach(() => { + jest.restoreAllMocks() + }) + + it('takes queued taps, oldest first, after the first listener attaches', async () => { + const { addVoltraListener, queue } = setup([tap('1'), tap('2')]) + const listener = jest.fn() + + addVoltraListener('interaction', listener) + expect(listener).not.toHaveBeenCalled() + await flush() + + expect(ids(listener)).toEqual(['1', '2']) + expect(queue).toEqual([]) + }) + + it('gives queued taps to every listener that subscribes in the same tick', async () => { + const { addVoltraListener, native } = setup([tap('1')]) + const appLevel = jest.fn() + const nested = jest.fn() + + // As when a root component and a child both subscribe in effects of the same React commit. + addVoltraListener('interaction', appLevel) + addVoltraListener('interaction', nested) + await flush() + + expect(ids(appLevel)).toEqual(['1']) + expect(ids(nested)).toEqual(['1']) + expect(native.popInteractions).toHaveBeenCalledTimes(1) + }) + + it('does not give already delivered taps to a listener that subscribes later', async () => { + const { addVoltraListener } = setup([tap('1')]) + addVoltraListener('interaction', jest.fn()) + await flush() + + const late = jest.fn() + addVoltraListener('interaction', late) + await flush() + + expect(late).not.toHaveBeenCalled() + }) + + it('lets a listener refer to its own subscription when a tap is already queued', async () => { + const { addVoltraListener } = setup([tap('1')]) + const error = jest.spyOn(console, 'error').mockImplementation(() => {}) + const received: string[] = [] + + const subscription = addVoltraListener('interaction', (event) => { + received.push(event.id) + subscription.remove() + }) + await flush() + + expect(received).toEqual(['1']) + expect(error).not.toHaveBeenCalled() + }) + + it('delivers a newly recorded tap once to every listener, in one pop', async () => { + const { addVoltraListener, record, native } = setup() + const first = jest.fn() + const second = jest.fn() + addVoltraListener('interaction', first) + addVoltraListener('interaction', second) + await flush() + native.popInteractions.mockClear() + + record(tap('1')) + record(tap('2')) + await flush() + + expect(ids(first)).toEqual(['1', '2']) + expect(ids(second)).toEqual(['1', '2']) + expect(native.popInteractions).toHaveBeenCalledTimes(1) + expect(native.onInteractionsAvailable).toHaveBeenCalledTimes(1) + }) + + it('takes taps recorded while the app was in the background when it becomes active', async () => { + const { addVoltraListener, queue, foreground } = setup() + const listener = jest.fn() + addVoltraListener('interaction', listener) + await flush() + + // Recorded by another process: no signal reached this instance. + queue.push(tap('1')) + foreground() + await flush() + + expect(ids(listener)).toEqual(['1']) + }) + + it('leaves taps queued while nobody listens, and stops listening for signals', async () => { + const { addVoltraListener, record, queue, native, signalSubscription, appStateSubscription } = setup() + const listener = jest.fn() + addVoltraListener('interaction', listener).remove() + + record(tap('1')) + await flush() + + expect(listener).not.toHaveBeenCalled() + expect(queue).toHaveLength(1) + expect(signalSubscription.remove).toHaveBeenCalledTimes(1) + expect(appStateSubscription.remove).toHaveBeenCalledTimes(1) + expect(native.popInteractions).not.toHaveBeenCalled() + }) + + it('puts back taps whose last listener went away during the dispatch', async () => { + const { addVoltraListener, queue, native } = setup([tap('1'), tap('2'), tap('3')]) + const subscription = addVoltraListener('interaction', (event) => { + if (event.id === '1') subscription.remove() + }) + await flush() + + expect(native.restoreInteractions).toHaveBeenCalledTimes(1) + expect(queue.map((event) => event.id)).toEqual(['2', '3']) + }) + + it('lets a source-filtered subscriber (VoltraView) take only its own taps', async () => { + const { addVoltraListener, subscribeToInteractions, queue } = setup([ + tap('live-activity', 'order-123'), + tap('view', 'voltra-view-1'), + ]) + const viewListener = jest.fn() + + subscribeToInteractions(viewListener, 'voltra-view-1') + await flush() + + expect(ids(viewListener)).toEqual(['view']) + expect(queue.map((event) => event.id)).toEqual(['live-activity']) + + const appListener = jest.fn() + addVoltraListener('interaction', appListener) + await flush() + expect(ids(appListener)).toEqual(['live-activity']) + expect(ids(viewListener)).toEqual(['view']) + }) + + it('keeps dispatching to other listeners when one throws', async () => { + const { addVoltraListener, record } = setup() + const error = jest.spyOn(console, 'error').mockImplementation(() => {}) + const listener = jest.fn() + addVoltraListener('interaction', () => { + throw new Error('boom') + }) + addVoltraListener('interaction', listener) + + record(tap('1')) + await flush() + + expect(ids(listener)).toEqual(['1']) + expect(error).toHaveBeenCalled() + }) + + it('keeps taps in order when a listener subscribes during dispatch', async () => { + const { addVoltraListener, queue } = setup([tap('1'), tap('2', 'voltra-view-1')]) + const late = jest.fn() + const order: string[] = [] + addVoltraListener('interaction', (event) => { + order.push(event.id) + if (event.id === '1') { + queue.push(tap('3')) + addVoltraListener('interaction', late) + } + }) + await flush() + + expect(order).toEqual(['1', '2', '3']) + // Subscribed before '2' was dispatched, so it receives the rest of the batch, then '3'. + expect(ids(late)).toEqual(['2', '3']) + }) + + it('does not throw when reading the queue fails', async () => { + const { addVoltraListener, native } = setup() + const error = jest.spyOn(console, 'error').mockImplementation(() => {}) + native.popInteractions.mockImplementation(() => { + throw new Error('unavailable') + }) + + expect(() => addVoltraListener('interaction', jest.fn())).not.toThrow() + await flush() + expect(error).toHaveBeenCalled() + }) + + it('falls back to direct delivery on a native build without the queue', () => { + const { addVoltraListener, subscribeToInteractions, native } = setup() + const subscription = { remove: jest.fn() } + const emitters: ((event: Omit) => void)[] = [] + native.onInteraction.mockImplementation((handler) => { + emitters.push(handler) + return subscription + }) + ;(native as { popInteractions?: unknown }).popInteractions = undefined + + const listener = jest.fn() + const viewListener = jest.fn() + expect(addVoltraListener('interaction', listener)).toBe(subscription) + subscribeToInteractions(viewListener, 'voltra-view-1') + const { id: _, ...event } = tap('ignored') + emitters.forEach((emit) => emit(event)) + + expect(listener).toHaveBeenCalledWith(expect.objectContaining({ id: '1:order-123:button-ignored' })) + expect(viewListener).not.toHaveBeenCalled() + }) +}) + +describe('activityEnablementChanged', () => { + it('subscribes through the dedicated native emitter', () => { + const { addVoltraListener, native } = setup() + const subscription = { remove: jest.fn() } + const onActivityEnablementChanged = jest.fn(() => subscription) + Object.assign(native, { onActivityEnablementChanged }) + const listener = jest.fn() + + expect(addVoltraListener('activityEnablementChanged', listener)).toBe(subscription) + expect(onActivityEnablementChanged).toHaveBeenCalledWith(listener) + }) + + it('returns a no-op subscription on a native build without the emitter', () => { + const { addVoltraListener } = setup() + const warn = jest.spyOn(console, 'warn').mockImplementation(() => {}) + + const subscription = addVoltraListener('activityEnablementChanged', jest.fn()) + + expect(() => subscription.remove()).not.toThrow() + expect(warn).toHaveBeenCalled() + warn.mockRestore() + }) +}) diff --git a/packages/ios-client/tests/events/voltraView.node.test.tsx b/packages/ios-client/tests/events/voltraView.node.test.tsx new file mode 100644 index 000000000..c7d531e45 --- /dev/null +++ b/packages/ios-client/tests/events/voltraView.node.test.tsx @@ -0,0 +1,47 @@ +import * as React from 'react' + +import { VoltraView } from '../../src/components/VoltraView.js' +import { subscribeToInteractions } from '../../src/interactions.js' + +const ReactTestRenderer = require('react-test-renderer') as { + act: (callback: () => void | Promise) => Promise + create: (element: React.ReactElement) => { update: (element: React.ReactElement) => void; unmount: () => void } +} + +jest.mock('react-native', () => ({ Platform: { OS: 'ios' }, AppState: { addEventListener: jest.fn() } })) +jest.mock('@use-voltra/ios', () => ({ renderVoltraVariantToJson: () => ({}) })) +jest.mock('../../src/native/VoltraRNNativeComponent', () => () => null) +jest.mock('../../src/interactions.js', () => ({ subscribeToInteractions: jest.fn() })) + +const mockedSubscribe = jest.mocked(subscribeToInteractions) + +describe('VoltraView interactions', () => { + it('keeps one source-filtered subscription across re-renders and calls the latest handler', async () => { + const remove = jest.fn() + mockedSubscribe.mockReturnValue({ remove }) + const first = jest.fn() + const second = jest.fn() + + let renderer: ReturnType + await ReactTestRenderer.act(async () => { + renderer = ReactTestRenderer.create( + React.createElement(VoltraView, { id: 'voltra-view-1', onInteraction: (event) => first(event) }, null) + ) + }) + await ReactTestRenderer.act(async () => { + renderer!.update( + React.createElement(VoltraView, { id: 'voltra-view-1', onInteraction: (event) => second(event) }, null) + ) + }) + + expect(mockedSubscribe).toHaveBeenCalledTimes(1) + expect(mockedSubscribe.mock.calls[0]![1]).toBe('voltra-view-1') + const deliver = mockedSubscribe.mock.calls[0]![0] + deliver({ type: 'interaction', id: 'a', source: 'voltra-view-1', identifier: 'b', payload: '', timestamp: 1 }) + expect(first).not.toHaveBeenCalled() + expect(second).toHaveBeenCalledWith(expect.objectContaining({ id: 'a', identifier: 'b' })) + + await ReactTestRenderer.act(async () => renderer!.unmount()) + expect(remove).toHaveBeenCalledTimes(1) + }) +}) diff --git a/packages/ios-client/tests/live-activity/push.node.test.ts b/packages/ios-client/tests/live-activity/push.node.test.ts new file mode 100644 index 000000000..3deb5f902 --- /dev/null +++ b/packages/ios-client/tests/live-activity/push.node.test.ts @@ -0,0 +1,82 @@ +import { Platform } from 'react-native' + +import { + areLiveActivitiesEnabled, + getLiveActivityPushTokens, + getLiveActivityPushToStartToken, +} from '../../src/live-activity/push.js' +import { getNativeVoltra, type Spec } from '../../src/VoltraModule.js' + +jest.mock('react-native', () => ({ Platform: { OS: 'ios' } })) +jest.mock('../../src/VoltraModule.js', () => ({ getNativeVoltra: jest.fn() })) + +const mockedGetNativeVoltra = jest.mocked(getNativeVoltra) +const mockedPlatform = Platform as { OS: string } + +describe('Live Activity push state getters', () => { + afterEach(() => { + mockedPlatform.OS = 'ios' + jest.clearAllMocks() + jest.restoreAllMocks() + }) + + it('returns the push tokens native reports, keyed by activity name', async () => { + const getLiveActivityPushTokens = jest.fn().mockResolvedValue([ + { activityName: 'order-123', pushToken: 'aa11' }, + { activityName: 'dynamic-ride', pushToken: 'bb22' }, + ]) + mockedGetNativeVoltra.mockReturnValue({ getLiveActivityPushTokens } as unknown as Spec) + + await expect(getLiveActivityPushTokens()).resolves.toEqual([ + { activityName: 'order-123', pushToken: 'aa11' }, + { activityName: 'dynamic-ride', pushToken: 'bb22' }, + ]) + expect(getLiveActivityPushTokens).toHaveBeenCalledTimes(1) + }) + + it('returns an empty list when native has no tokens', async () => { + mockedGetNativeVoltra.mockReturnValue({ + getLiveActivityPushTokens: jest.fn().mockResolvedValue([]), + } as unknown as Spec) + + await expect(getLiveActivityPushTokens()).resolves.toEqual([]) + }) + + it('returns the push-to-start token, or null before one is issued', async () => { + const getLiveActivityPushToStartToken = jest.fn().mockResolvedValueOnce('cc33').mockResolvedValueOnce(null) + mockedGetNativeVoltra.mockReturnValue({ getLiveActivityPushToStartToken } as unknown as Spec) + + await expect(getLiveActivityPushToStartToken()).resolves.toBe('cc33') + await expect(getLiveActivityPushToStartToken()).resolves.toBeNull() + }) + + it('reads enablement synchronously', () => { + const areLiveActivitiesEnabledNative = jest.fn().mockReturnValueOnce(true).mockReturnValueOnce(false) + mockedGetNativeVoltra.mockReturnValue({ + areLiveActivitiesEnabled: areLiveActivitiesEnabledNative, + } as unknown as Spec) + + expect(areLiveActivitiesEnabled()).toBe(true) + expect(areLiveActivitiesEnabled()).toBe(false) + }) + + it('warns and returns empty values on a native build without the getters', async () => { + mockedGetNativeVoltra.mockReturnValue({} as unknown as Spec) + const warn = jest.spyOn(console, 'warn').mockImplementation(() => {}) + + await expect(getLiveActivityPushTokens()).resolves.toEqual([]) + await expect(getLiveActivityPushToStartToken()).resolves.toBeNull() + expect(areLiveActivitiesEnabled()).toBe(false) + expect(warn).toHaveBeenCalledTimes(3) + }) + + it('returns empty values without touching the native module off iOS', async () => { + mockedPlatform.OS = 'android' + jest.spyOn(console, 'error').mockImplementation(() => {}) + + await expect(getLiveActivityPushTokens()).resolves.toEqual([]) + await expect(getLiveActivityPushToStartToken()).resolves.toBeNull() + expect(areLiveActivitiesEnabled()).toBe(false) + expect(mockedGetNativeVoltra).not.toHaveBeenCalled() + }) +}) diff --git a/website/src/content/docs/ios/development/events.md b/website/src/content/docs/ios/development/events.md index 71b04887d..7d47172a3 100644 --- a/website/src/content/docs/ios/development/events.md +++ b/website/src/content/docs/ios/development/events.md @@ -7,12 +7,16 @@ Voltra emits several types of events that allow your app to respond to changes i ## Overview -Voltra provides four main event types: +Voltra provides these event types: - **Activity state changes**: Notifications when a Live Activity's state changes (e.g., dismissed, ended) - **Push tokens**: Tokens needed for server-side updates via push notifications - **Push-to-start tokens**: Tokens for starting Live Activities remotely (iOS 17.2+) +- **Enablement changes**: Notifications when the user allows or disallows Live Activities for your app - **User interactions**: Events triggered when users interact with buttons or toggles in your Live Activity +- **Dynamic Live Activity render failures**: Reported when a [Dynamic Live Activity](/ios/development/dynamic-live-activities) can't be rendered + +Push tokens and enablement are values that can change, not one-off events. The events only report changes, and only to listeners attached at that moment. Read the current value with `getLiveActivityPushTokens()`, `getLiveActivityPushToStartToken()`, and `areLiveActivitiesEnabled()`. ## Activity state changes @@ -108,9 +112,56 @@ subscription.remove() For more information about using push tokens for starting Live Activity remotely, see the [server-side updates guide](/ios/development/server-side-updates). +### Reading the current tokens + +A token event fires when iOS issues or rotates a token. If your listener attaches later — for example, after the user signs in — it doesn't receive tokens issued earlier. Subscribe first, then read the current values: + +```typescript +import { + addVoltraListener, + getLiveActivityPushTokens, + getLiveActivityPushToStartToken, +} from '@use-voltra/ios-client' + +async function registerLiveActivityTokens() { + const subscription = addVoltraListener('activityTokenReceived', ({ activityName, pushToken }) => { + sendTokenToServer({ activityName, pushToken }) + }) + + for (const { activityName, pushToken } of await getLiveActivityPushTokens()) { + sendTokenToServer({ activityName, pushToken }) + } + + const pushToStartToken = await getLiveActivityPushToStartToken() + if (pushToStartToken) { + sendTokenToServer({ pushToStartToken }) + } + + return subscription +} +``` + +The same token can arrive from both the getter and the event, so store tokens on your server with an upsert keyed by device and `activityName`. See [Reading the current tokens](/ios/development/server-side-updates#reading-the-current-tokens) for what each getter returns. + +## Live Activity enablement + +Users can turn Live Activities off for your app in Settings. `startLiveActivity()` rejects while they're off. Use `areLiveActivitiesEnabled()` to check first, for example to hide a "Track order" button, and listen for `'activityEnablementChanged'` to react when the user changes the setting: + +```typescript +import { addVoltraListener, areLiveActivitiesEnabled } from '@use-voltra/ios-client' + +const subscription = addVoltraListener('activityEnablementChanged', (event) => { + setTrackingAvailable(event.enabled) +}) + +setTrackingAvailable(areLiveActivitiesEnabled()) +``` + +`areLiveActivitiesEnabled()` is synchronous and returns `false` below iOS 16.4. Neither the getter nor the event needs `enablePushNotifications`. Users change this setting in the Settings app, so also read `areLiveActivitiesEnabled()` again when your app returns to the foreground. + ## User interactions -When users interact with buttons or toggles in your Live Activity, Voltra emits an `'interaction'` event via `addVoltraListener` — this works even when your app isn't running. For the full example, component identifiers, deep linking, and app lifecycle considerations, see the [interactions guide](/ios/development/interactions). +When users interact with buttons or toggles in your Live Activity, Voltra emits an `'interaction'` event via `addVoltraListener` — this works even when your app isn't running. Voltra keeps each tap until an `'interaction'` listener is attached and your app is running, then delivers it once; a tap is never delivered twice. Taps that wait longer than 15 minutes are dropped. Keeping taps across app launches, and receiving taps handled outside your app's process, requires a `groupIdentifier` in the plugin config. For the full example, component identifiers, deep linking, and app lifecycle considerations, see the [interactions guide](/ios/development/interactions). ## Best practices @@ -143,4 +194,4 @@ function MyComponent() { ### Handle app launch scenarios -When your app is launched from a terminated state (e.g., due to a Live Activity interaction), make sure your event listeners are set up before processing any queued events. Voltra handles event queuing automatically when using App Groups. +When a tap on a Live Activity launches your app, the `'interaction'` event waits until your `'interaction'` listener attaches, then arrives once. Only interactions are held this way, for up to 15 minutes, and only across launches when you configure a `groupIdentifier`. State changes and token events are not held: an event that fires before your listener attaches is not delivered later. Read push tokens and enablement with their getters instead. diff --git a/website/src/content/docs/ios/development/interactions.md b/website/src/content/docs/ios/development/interactions.md index 44049a1e7..1c51da8bc 100644 --- a/website/src/content/docs/ios/development/interactions.md +++ b/website/src/content/docs/ios/development/interactions.md @@ -125,6 +125,10 @@ For styled navigation buttons, wrap a Link with custom children to achieve butto - **Event delivery timing**: Events are delivered asynchronously. There may be a slight delay between the user's interaction and your event handler being called, especially if the app needs to be launched. +- **Events wait for a listener**: Voltra keeps every tap until an `'interaction'` listener is attached and your app is running — for example, when the tap launched your app, or happened while it was in the background — then delivers it once. Each tap has a unique `id`. Taps that wait longer than 15 minutes, or beyond the 100 most recent, are dropped. Keeping taps across app launches, and receiving taps handled outside your app's process (such as by a widget extension), requires a `groupIdentifier` in the plugin config; without one, only taps handled in your running app arrive. If your app is killed while it is handling a batch of taps, the rest of that batch is not delivered. A waiting tap goes to every `'interaction'` listener attached when Voltra delivers it; a listener you add later doesn't receive it. Register one app-level listener in your root component and pass taps on from there, rather than subscribing separately in screens that mount later. A `VoltraView` with `onInteraction` receives only taps from its own content and leaves the rest for your `'interaction'` listeners. + +- **Listeners usually run in the background**: A button or toggle runs its action in your app's process, so iOS wakes your app, or launches it in the background if it was killed, and your `'interaction'` listener runs right away while `AppState` is `'background'`. iOS suspends the app again within a few seconds, so keep the handler short. Work that takes longer, such as a slow network request, may pause until your app next runs. + ## Example Here's a complete example showing how to handle interactions: diff --git a/website/src/content/docs/ios/development/server-side-updates.md b/website/src/content/docs/ios/development/server-side-updates.md index 348d6156b..f29c16ee1 100644 --- a/website/src/content/docs/ios/development/server-side-updates.md +++ b/website/src/content/docs/ios/development/server-side-updates.md @@ -230,6 +230,44 @@ useEffect(() => { }, []) ``` +### Reading the current tokens + +Token events fire when iOS issues or rotates a token, and only reach listeners attached at that moment. When your app needs the tokens later — after the user signs in, or after a reload — read them directly. Subscribe to the event first and then call the getter, so a token issued in between isn't missed: + +```tsx +import { + addVoltraListener, + getLiveActivityPushTokens, + getLiveActivityPushToStartToken, +} from '@use-voltra/ios-client' + +useEffect(() => { + const subscription = addVoltraListener('activityTokenReceived', ({ activityName, pushToken }) => { + sendTokenToServer({ activityName, token: pushToken, type: 'update' }) + }) + + getLiveActivityPushTokens().then((tokens) => { + for (const { activityName, pushToken } of tokens) { + sendTokenToServer({ activityName, token: pushToken, type: 'update' }) + } + }) + + getLiveActivityPushToStartToken().then((token) => { + if (token) { + sendTokenToServer({ token, type: 'start' }) + } + }) + + return () => subscription.remove() +}, []) +``` + +- `getLiveActivityPushTokens()` returns `{ activityName, pushToken }` for every running Live Activity, including Dynamic Live Activities, that already has a token. Activities started with a `channelId` have no token and aren't listed. +- `getLiveActivityPushToStartToken()` returns the push-to-start token, or `null` below iOS 17.2 or before APNs has issued one. +- Both return empty results (`[]` and `null`) when `enablePushNotifications` is not set in the plugin config. + +The same token can arrive from both the getter and the event, and iOS can issue a new token at any time. Store tokens with an upsert keyed by device and `activityName` (or by device for the push-to-start token), and treat the most recent value as current. + Use only Voltra-provided tokens, which are specialized for Live Activity push notifications and different from regular device tokens. Update tokens are tied to specific Live Activities, while push-to-start tokens are for starting new activities. Update tokens are provided when Live Activities are started and may change during the activity's lifecycle. When sending notifications via APNS, use these push tokens as the target device token to route notifications to the correct Live Activity or device. ## Broadcast push notifications (iOS 18+)