Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .changeset/live-activity-push-state-getters.md
Original file line number Diff line number Diff line change
@@ -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')`.

Large diffs are not rendered by default.

27 changes: 14 additions & 13 deletions docs/adr/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
1 change: 1 addition & 0 deletions docs/agents/appduct-e2e.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down
23 changes: 23 additions & 0 deletions example/appduct/IosVoltraTools.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,13 +5,16 @@
import { postEvent, useAppductTool } from '@appduct/react-native'
import {
addVoltraListener,
areLiveActivitiesEnabled,
clearAllWidgets,
clearPreloadedImages,
clearWidget,
clearWidgetServerUpdate,
endAllLiveActivities,
getActiveWidgets,
getDynamicLiveActivityDefinitionIds,
getLiveActivityPushTokens,
getLiveActivityPushToStartToken,
getWidgetServerUpdate,
isLiveActivityActive,
preloadImages,
Expand Down Expand Up @@ -65,6 +68,7 @@ const VOLTRA_EVENT_KINDS = [
'stateChange',
'activityTokenReceived',
'activityPushToStartTokenReceived',
'activityEnablementChanged',
'dynamicLiveActivityRenderFailed',
] as const

Expand Down Expand Up @@ -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({
Expand Down
9 changes: 6 additions & 3 deletions packages/ios-client/ios/Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand Down
Original file line number Diff line number Diff line change
@@ -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"
)
}
}
Original file line number Diff line number Diff line change
@@ -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))
}
}
Loading
Loading