diff --git a/Sources/App/AppDelegate.swift b/Sources/App/AppDelegate.swift index 7c10e2a8a..3c797690f 100644 --- a/Sources/App/AppDelegate.swift +++ b/Sources/App/AppDelegate.swift @@ -407,6 +407,12 @@ class AppDelegate: UIResponder, UIApplicationDelegate { Task { await registry.startObservingPushToStartToken() } + + // Observe activities that ActivityKit starts directly from APNs push-to-start. + // The stream is infinite; this Task is kept alive for the app's lifetime. + Task { + await registry.startObservingRemoteActivityStarts() + } } #endif } diff --git a/Sources/Shared/LiveActivity/LiveActivityRegistry.swift b/Sources/Shared/LiveActivity/LiveActivityRegistry.swift index 0701cfb9b..38258d6bd 100644 --- a/Sources/Shared/LiveActivity/LiveActivityRegistry.swift +++ b/Sources/Shared/LiveActivity/LiveActivityRegistry.swift @@ -15,6 +15,13 @@ public protocol LiveActivityRegistryProtocol: AnyObject { func reattach() async @available(iOS 17.2, *) func startObservingPushToStartToken() async + @available(iOS 17.2, *) + func startObservingRemoteActivityStarts() async +} + +public extension LiveActivityRegistryProtocol { + @available(iOS 17.2, *) + func startObservingRemoteActivityStarts() async {} } /// Thread-safe registry for active `Activity` instances. @@ -38,12 +45,12 @@ public actor LiveActivityRegistry: LiveActivityRegistryProtocol { /// Webhook type for reporting a new per-activity push token to HA. static let webhookTypeToken = "live_activity_token" /// Keys in the token webhook request data dictionary. - static let tokenWebhookKeys: Set = ["activity_id", "push_token", "apns_environment"] + static let tokenWebhookKeys: Set = ["tag", "push_token"] /// Webhook type for reporting that a Live Activity was dismissed. static let webhookTypeDismissed = "live_activity_dismissed" /// Keys in the dismissed webhook request data dictionary. - static let dismissedWebhookKeys: Set = ["activity_id", "tag", "reason"] + static let dismissedWebhookKeys: Set = ["tag"] // MARK: - State @@ -257,6 +264,24 @@ public actor LiveActivityRegistry: LiveActivityRegistryProtocol { } } + /// Observe activities started remotely by ActivityKit push-to-start notifications. + /// + /// When APNs starts a Live Activity without launching through the notification command + /// handler, ActivityKit delivers the created activity through this stream. Attach our + /// normal push-token and lifecycle observers so Core can receive the per-activity token. + public func startObservingRemoteActivityStarts() async { + for await activity in Activity.activityUpdates { + let tag = activity.attributes.tag + guard entries[tag] == nil else { continue } + + let observationTask = makeObservationTask(for: activity) + entries[tag] = Entry(activity: activity, observationTask: observationTask) + Current.Log.verbose( + "LiveActivityRegistry: observed remotely started activity for tag \(tag), id=\(activity.id)" + ) + } + } + // MARK: - Public Helpers /// The stored push-to-start token for inclusion in registration payloads. @@ -299,7 +324,7 @@ public actor LiveActivityRegistry: LiveActivityRegistryProtocol { Current.Log.verbose( "LiveActivityRegistry: new push token for tag \(activity.attributes.tag)" ) - await self.reportPushToken(tokenHex, activityID: activity.id) + await self.reportPushToken(tokenHex, tag: activity.attributes.tag) } } @@ -308,11 +333,7 @@ public actor LiveActivityRegistry: LiveActivityRegistryProtocol { for await state in activity.activityStateUpdates { switch state { case .dismissed, .ended: - await self.reportActivityDismissed( - activityID: activity.id, - tag: activity.attributes.tag, - reason: state == .dismissed ? "user_dismissed" : "ended" - ) + await self.reportActivityDismissed(tag: activity.attributes.tag) _ = await self.remove(id: activity.attributes.tag) return case .active, .stale: @@ -333,13 +354,12 @@ public actor LiveActivityRegistry: LiveActivityRegistryProtocol { /// Report a new activity push token to all connected HA servers. /// The token is used by the relay server to send APNs updates directly to this activity. - private func reportPushToken(_ tokenHex: String, activityID: String) async { + private func reportPushToken(_ tokenHex: String, tag: String) async { let request = WebhookRequest( type: Self.webhookTypeToken, data: [ - "activity_id": activityID, + "tag": tag, "push_token": tokenHex, - "apns_environment": Current.apnsEnvironment, ] ) for server in Current.servers.all { @@ -349,13 +369,11 @@ public actor LiveActivityRegistry: LiveActivityRegistryProtocol { /// Notify HA servers that the Live Activity was dismissed or ended externally. /// This allows HA to stop sending APNs updates for this activity. - private func reportActivityDismissed(activityID: String, tag: String, reason: String) async { + private func reportActivityDismissed(tag: String) async { let request = WebhookRequest( type: Self.webhookTypeDismissed, data: [ - "activity_id": activityID, "tag": tag, - "reason": reason, ] ) for server in Current.servers.all { diff --git a/Tests/Shared/LiveActivity/LiveActivityContractTests.swift b/Tests/Shared/LiveActivity/LiveActivityContractTests.swift index a3c24509f..c28c2373c 100644 --- a/Tests/Shared/LiveActivity/LiveActivityContractTests.swift +++ b/Tests/Shared/LiveActivity/LiveActivityContractTests.swift @@ -99,7 +99,7 @@ final class LiveActivityContractTests: XCTestCase { func testTokenWebhookKeys_areFrozen() { XCTAssertEqual( LiveActivityRegistry.tokenWebhookKeys, - ["activity_id", "push_token", "apns_environment"] + ["tag", "push_token"] ) } @@ -117,7 +117,7 @@ final class LiveActivityContractTests: XCTestCase { func testDismissedWebhookKeys_areFrozen() { XCTAssertEqual( LiveActivityRegistry.dismissedWebhookKeys, - ["activity_id", "live_activity_tag", "reason"] + ["tag"] ) }