forked from home-assistant/iOS
-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathLiveActivityRegistry.swift
More file actions
377 lines (330 loc) · 16.3 KB
/
LiveActivityRegistry.swift
File metadata and controls
377 lines (330 loc) · 16.3 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
#if os(iOS) && !targetEnvironment(macCatalyst)
import ActivityKit
import Foundation
/// Stale date offset for all Live Activity content updates.
/// Activities are marked stale after 30 minutes if no further updates arrive.
private let kLiveActivityStaleInterval: TimeInterval = 30 * 60
public protocol LiveActivityRegistryProtocol: AnyObject {
@available(iOS 17.2, *)
func startOrUpdate(tag: String, title: String, state: HALiveActivityAttributes.ContentState) async throws
@available(iOS 17.2, *)
func end(tag: String, dismissalPolicy: ActivityUIDismissalPolicy) async
@available(iOS 17.2, *)
func reattach() async
@available(iOS 17.2, *)
func startObservingPushToStartToken() async
}
/// Thread-safe registry for active `Activity<HALiveActivityAttributes>` instances.
///
/// Uses Swift's actor isolation to protect the `[String: Entry]` dictionary from
/// concurrent access by push handler queues, token observer tasks, and the main app.
///
/// The reservation pattern prevents TOCTOU races where two pushes with the same `tag`
/// arrive back-to-back before the first `Activity.request(...)` completes.
@available(iOS 17.2, *)
public actor LiveActivityRegistry: LiveActivityRegistryProtocol {
// MARK: - Types
struct Entry {
let activity: Activity<HALiveActivityAttributes>
let observationTask: Task<Void, Never>
}
// MARK: - Webhook Constants (wire-format frozen — tested in LiveActivityContractTests)
/// Webhook type for reporting a new per-activity push token to HA.
static let webhookTypeToken = "mobile_app_live_activity_token"
/// Keys in the token webhook request data dictionary.
static let tokenWebhookKeys: Set<String> = ["activity_id", "push_token", "apns_environment"]
/// Webhook type for reporting that a Live Activity was dismissed.
static let webhookTypeDismissed = "mobile_app_live_activity_dismissed"
/// Keys in the dismissed webhook request data dictionary.
static let dismissedWebhookKeys: Set<String> = ["activity_id", "live_activity_tag", "reason"]
// MARK: - State
/// Tags currently in-flight (reserved but not yet confirmed or cancelled).
private var reserved: Set<String> = []
/// Tags where `end()` arrived while still reserved — activity must be dismissed on confirm.
private var cancelledReservations: Set<String> = []
/// Latest state received for a tag while it was still reserved (in-flight start).
/// Applied to the activity immediately after `confirmReservation` completes.
private var pendingState: [String: HALiveActivityAttributes.ContentState] = [:]
/// Confirmed, running Live Activities keyed by tag.
private var entries: [String: Entry] = [:]
// MARK: - Init
public init() {}
// MARK: - Reservation (internal — called within actor context)
private func reserve(id: String) -> Bool {
guard entries[id] == nil, !reserved.contains(id) else { return false }
reserved.insert(id)
return true
}
/// Confirm a reservation. If `end()` arrived while we were in-flight, immediately dismiss.
/// If a newer state arrived while we were in-flight, apply it after confirming.
private func confirmReservation(id: String, entry: Entry) async {
reserved.remove(id)
let pending = pendingState.removeValue(forKey: id)
if cancelledReservations.remove(id) != nil {
// end() was called before Activity.request() completed — dismiss immediately.
entry.observationTask.cancel()
await entry.activity.end(nil, dismissalPolicy: .immediate)
return
}
entries[id] = entry
if let latestState = pending {
// A second push arrived while Activity.request() was in-flight — apply the newer state now.
let content = ActivityContent(
state: latestState,
staleDate: computeStaleDate(for: latestState)
)
await entry.activity.update(content)
}
}
private func cancelReservation(id: String) {
reserved.remove(id)
cancelledReservations.remove(id)
pendingState.removeValue(forKey: id)
}
private func remove(id: String) -> Entry? {
let entry = entries.removeValue(forKey: id)
entry?.observationTask.cancel()
return entry
}
// MARK: - Public API
/// Start a new Live Activity for `tag`, or update the existing one if already running.
public func startOrUpdate(
tag: String,
title: String,
state: HALiveActivityAttributes.ContentState
) async throws {
// UPDATE path — activity already running with this tag
if let existing = entries[tag] {
let content = ActivityContent(
state: state,
staleDate: computeStaleDate(for: state)
)
await existing.activity.update(content)
return
}
// Also check system list in case we lost track after crash/relaunch
if let live = Activity<HALiveActivityAttributes>.activities
.first(where: { $0.attributes.tag == tag }) {
let content = ActivityContent(
state: state,
staleDate: computeStaleDate(for: state)
)
await live.update(content)
let observationTask = makeObservationTask(for: live)
entries[tag] = Entry(activity: live, observationTask: observationTask)
return
}
// START path — guard against duplicates with reservation
guard reserve(id: tag) else {
if reserved.contains(tag) {
// Activity.request() is in-flight — save this state so confirmReservation applies it.
pendingState[tag] = state
Current.Log.info(
"LiveActivityRegistry: duplicate start for tag \(tag), will apply latest state on confirm"
)
}
return
}
guard ActivityAuthorizationInfo().areActivitiesEnabled else {
cancelReservation(id: tag)
Current.Log.info("LiveActivityRegistry: activities disabled on this device, skipping start for tag \(tag)")
return
}
let attributes = HALiveActivityAttributes(tag: tag, title: title)
let activity: Activity<HALiveActivityAttributes>
do {
let content = ActivityContent(
state: state,
staleDate: computeStaleDate(for: state),
relevanceScore: 0.5
)
activity = try Activity<HALiveActivityAttributes>.request(
attributes: attributes,
content: content,
pushType: .token
)
} catch {
cancelReservation(id: tag)
throw error
}
// Immediately update with an AlertConfiguration to trigger the expanded Dynamic Island
// presentation. Activity.request() only shows the compact view (small pill around the
// camera cutout). The expanded "bloom" animation requires an update with an alert config.
let alertContent = ActivityContent(
state: state,
staleDate: computeStaleDate(for: state),
relevanceScore: 0.5
)
// iOS 26 SDK changed AlertConfiguration.sound from optional to non-optional.
// Use .default so the expanded Dynamic Island "bloom" has a subtle alert sound.
let alertConfig = AlertConfiguration(
title: LocalizedStringResource(stringLiteral: title),
body: LocalizedStringResource(stringLiteral: state.message),
sound: .default
)
await activity.update(alertContent, alertConfiguration: alertConfig)
let observationTask = makeObservationTask(for: activity)
await confirmReservation(id: tag, entry: Entry(activity: activity, observationTask: observationTask))
Current.Log.verbose("LiveActivityRegistry: started activity for tag \(tag), id=\(activity.id)")
}
/// End and dismiss the Live Activity for `tag`.
public func end(tag: String, dismissalPolicy: ActivityUIDismissalPolicy = .immediate) async {
if let existing = remove(id: tag) {
await existing.activity.end(nil, dismissalPolicy: dismissalPolicy)
Current.Log.verbose("LiveActivityRegistry: ended activity for tag \(tag)")
return
}
// Tag is still being started (Activity.request in-flight) — mark it so confirmReservation
// dismisses the activity immediately once the request completes.
if reserved.contains(tag) {
cancelledReservations.insert(tag)
Current.Log
.verbose("LiveActivityRegistry: end() received for in-flight tag \(tag), will dismiss on confirm")
return
}
// Fallback: check system list in case we lost track
if let live = Activity<HALiveActivityAttributes>.activities
.first(where: { $0.attributes.tag == tag }) {
await live.end(nil, dismissalPolicy: dismissalPolicy)
}
}
/// Re-attach observation tasks to any Live Activities that survived process termination.
/// Call this at app launch before any notification handlers are invoked.
public func reattach() async {
for activity in Activity<HALiveActivityAttributes>.activities {
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: reattached activity for tag \(tag), id=\(activity.id)")
}
}
/// Observe the push-to-start token stream for `HALiveActivityAttributes`.
///
/// Push-to-start (iOS 17.2+) allows HA to start a Live Activity entirely via APNs
/// without the app being in the foreground. This is best-effort (~50% success from
/// terminated state) — the primary flow remains notification command → app starts activity.
///
/// The token is stored in Keychain and reported to HA via registration update so the
/// relay server can use it to send push-to-start APNs payloads.
///
/// Call this once at app launch; the stream is infinite and self-managing.
public func startObservingPushToStartToken() async {
for await tokenData in Activity<HALiveActivityAttributes>.pushToStartTokenUpdates {
let tokenHex = tokenData.map { String(format: "%02x", $0) }.joined()
Current.Log.verbose("LiveActivityRegistry: new push-to-start token")
// Store in Keychain — this token is higher-value than a per-activity token
// (it can start any new activity) so UserDefaults is intentionally avoided.
AppConstants.Keychain[LiveActivityRegistry.pushToStartTokenKeychainKey] = tokenHex
// Report to all HA servers via registration update so the token is available
// in the HA device registry immediately.
reportPushToStartToken(tokenHex)
}
}
// MARK: - Public Helpers
/// The stored push-to-start token for inclusion in registration payloads.
/// Returns nil if the device hasn't received a token yet (pre-iOS 17.2 or not yet issued).
public static var storedPushToStartToken: String? {
AppConstants.Keychain[pushToStartTokenKeychainKey]
}
static let pushToStartTokenKeychainKey = "live_activity_push_to_start_token"
// MARK: - Private — Stale Date
/// Compute the appropriate stale date for a Live Activity content update.
///
/// When a countdown timer is active, set staleDate = countdownEnd + 2 s so that:
/// 1. The system marks the activity stale shortly after the timer reaches zero,
/// prompting HA to send a follow-up update.
/// 2. staleDate is never exactly equal to countdownEnd — that causes the system
/// to show a spinner overlay on the lock screen presentation.
///
/// For non-timer activities, fall back to the standard 30-minute freshness window.
private func computeStaleDate(for state: HALiveActivityAttributes.ContentState) -> Date {
if state.chronometer == true, let end = state.countdownEnd {
// +2 s offset avoids staleDate == countdownEnd (system spinner bug).
// max(..., now + 2) guards against a countdownEnd that is already in the past.
return max(end.addingTimeInterval(2), Date().addingTimeInterval(2))
}
return Date().addingTimeInterval(kLiveActivityStaleInterval)
}
// MARK: - Private — Observation
private func makeObservationTask(for activity: Activity<HALiveActivityAttributes>) -> Task<Void, Never> {
Task {
await withTaskGroup(of: Void.self) { group in
// Observe push token updates — report each new token to all HA servers
group.addTask {
for await tokenData in activity.pushTokenUpdates {
let tokenHex = tokenData.map { String(format: "%02x", $0) }.joined()
Current.Log.verbose(
"LiveActivityRegistry: new push token for tag \(activity.attributes.tag)"
)
await self.reportPushToken(tokenHex, activityID: activity.id)
}
}
// Observe activity lifecycle — clean up and notify HA when dismissed
group.addTask {
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.remove(id: activity.attributes.tag)
return
case .active, .stale:
break
case .pending:
// Activity has been requested but not yet displayed — no action needed.
break
@unknown default:
break
}
}
}
}
}
}
// MARK: - Private — Webhook Reporting
/// 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 {
let request = WebhookRequest(
type: Self.webhookTypeToken,
data: [
"activity_id": activityID,
"push_token": tokenHex,
"apns_environment": Current.apnsEnvironment,
]
)
for server in Current.servers.all {
Current.webhooks.sendEphemeral(server: server, request: request).cauterize()
}
}
/// 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 {
let request = WebhookRequest(
type: Self.webhookTypeDismissed,
data: [
"activity_id": activityID,
"live_activity_tag": tag,
"reason": reason,
]
)
for server in Current.servers.all {
Current.webhooks.sendEphemeral(server: server, request: request).cauterize()
}
}
/// Report the push-to-start token to all HA servers via registration update.
/// HA stores this alongside the FCM push token in the device registry.
/// Fire-and-forget: errors are logged but do not block the token observation loop.
private func reportPushToStartToken(_ tokenHex: String) {
for api in Current.apis {
api.updateRegistration().catch { error in
Current.Log.error("LiveActivityRegistry: failed to report push-to-start token: \(error)")
}
}
}
}
#endif