forked from home-assistant/iOS
-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathHandlerLiveActivity.swift
More file actions
185 lines (163 loc) · 7.66 KB
/
HandlerLiveActivity.swift
File metadata and controls
185 lines (163 loc) · 7.66 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
#if os(iOS) && !targetEnvironment(macCatalyst)
import ActivityKit
import Foundation
import PromiseKit
// MARK: - HandlerStartOrUpdateLiveActivity
/// Handles `live_update: true` notifications by starting or updating a Live Activity.
///
/// Triggered two ways:
/// 1. `homeassistant.command == "live_activity"` (message: live_activity in YAML)
/// 2. `homeassistant.live_update == true` (data.live_update: true in YAML)
///
/// Notification payload fields mirror the Android companion app:
/// tag, title, message, critical_text, progress, progress_max,
/// chronometer, when, when_relative, notification_icon, notification_icon_color
@available(iOS 17.2, *)
struct HandlerStartOrUpdateLiveActivity: NotificationCommandHandler {
private enum ValidationError: Error {
case missingTag
case missingTitle
case invalidTag
}
func handle(_ payload: [String: Any]) -> Promise<Void> {
// PushProvider (NEAppPushProvider) runs in a separate OS process — ActivityKit is
// unavailable there. The same notification will be re-delivered to the main app via
// UNUserNotificationCenter, where it will be handled correctly.
guard !Current.isAppExtension else {
Current.Log.verbose("HandlerStartOrUpdateLiveActivity: skipping in app extension, will handle in main app")
return .value(())
}
return Promise { seal in
Task {
do {
guard let tag = payload["tag"] as? String, !tag.isEmpty else {
throw ValidationError.missingTag
}
guard Self.isValidTag(tag) else {
Current.Log
.error(
"HandlerStartOrUpdateLiveActivity: invalid tag '\(tag)' — must be [a-zA-Z0-9_-], max 64 chars"
)
throw ValidationError.invalidTag
}
guard let title = payload["title"] as? String, !title.isEmpty else {
throw ValidationError.missingTitle
}
Self.showPrivacyDisclosureIfNeeded()
let state = Self.contentState(from: payload)
try await Current.liveActivityRegistry?.startOrUpdate(
tag: tag,
title: title,
state: state
)
seal.fulfill(())
} catch {
Current.Log.error("HandlerStartOrUpdateLiveActivity: \(error)")
// Fulfill rather than reject for known validation/auth errors so HA
// doesn't treat them as transient failures and retry indefinitely.
switch error {
case ValidationError.missingTag, ValidationError.missingTitle, ValidationError.invalidTag:
seal.fulfill(())
default:
seal.reject(error)
}
}
}
}
}
// MARK: - Privacy Disclosure
/// Records that the user has started a Live Activity so that the Settings screen
/// can surface the privacy notice on their next visit.
/// The permanent disclosure lives in LiveActivitySettingsView's privacy section —
/// a local notification would silently fail if notification permission is not granted.
private static func showPrivacyDisclosureIfNeeded() {
guard !Current.settingsStore.hasSeenLiveActivityDisclosure else { return }
Current.settingsStore.hasSeenLiveActivityDisclosure = true
}
// MARK: - Validation
/// Validates that a Live Activity tag contains only safe characters.
///
/// Tags are used as ActivityKit push token topic identifiers and as keys in
/// the activity registry dictionary. Restricting to `[a-zA-Z0-9_-]` (max 64
/// characters) ensures they are safe for APNs payloads, UserDefaults keys,
/// and log output without escaping or truncation issues.
static func isValidTag(_ tag: String) -> Bool {
guard tag.count <= 64 else { return false }
let allowed = CharacterSet(charactersIn: "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_")
return tag.unicodeScalars.allSatisfy { allowed.contains($0) }
}
// MARK: - Payload Parsing
static func contentState(from payload: [String: Any]) -> HALiveActivityAttributes.ContentState {
let message = payload["message"] as? String ?? ""
let criticalText = payload["critical_text"] as? String
// Use NSNumber coercion so both Int and Double JSON values (e.g. 50 vs 50.0) decode correctly.
let progress = (payload["progress"] as? NSNumber).map { Int(truncating: $0) }
let progressMax = (payload["progress_max"] as? NSNumber).map { Int(truncating: $0) }
let chronometer = payload["chronometer"] as? Bool
let icon = payload["notification_icon"] as? String
let color = payload["notification_icon_color"] as? String
// `when` + `when_relative` → absolute countdown end date.
// Parsed as Double to preserve sub-second Unix timestamps sent by HA.
var countdownEnd: Date?
if let when = (payload["when"] as? NSNumber).map(\.doubleValue) {
let whenRelative = payload["when_relative"] as? Bool ?? false
if whenRelative {
countdownEnd = Date().addingTimeInterval(when)
} else {
countdownEnd = Date(timeIntervalSince1970: when)
}
}
return HALiveActivityAttributes.ContentState(
message: message,
criticalText: criticalText,
progress: progress,
progressMax: progressMax,
chronometer: chronometer,
countdownEnd: countdownEnd,
icon: icon,
color: color
)
}
}
// MARK: - HandlerEndLiveActivity
/// Handles explicit `end_live_activity` commands.
/// Note: the `clear_notification` + `tag` dismiss flow is handled in `HandlerClearNotification`.
@available(iOS 17.2, *)
struct HandlerEndLiveActivity: NotificationCommandHandler {
func handle(_ payload: [String: Any]) -> Promise<Void> {
guard !Current.isAppExtension else {
return .value(())
}
return Promise { seal in
Task {
guard let tag = payload["tag"] as? String, !tag.isEmpty,
HandlerStartOrUpdateLiveActivity.isValidTag(tag) else {
seal.fulfill(())
return
}
let policy = Self.dismissalPolicy(from: payload)
await Current.liveActivityRegistry?.end(tag: tag, dismissalPolicy: policy)
seal.fulfill(())
}
}
}
private static func dismissalPolicy(from payload: [String: Any]) -> ActivityUIDismissalPolicy {
switch payload["dismissal_policy"] as? String {
case "default":
return .default
case let str where str?.hasPrefix("after:") == true:
if let timestampStr = str?.dropFirst(6),
let timestamp = Double(timestampStr) {
// Cap to 24 hours — iOS enforces its own maximum, but this prevents
// a far-future date from lingering in the dismissed activities list
// longer than intended if Apple ever relaxes the OS limit.
let maxDate = Date().addingTimeInterval(24 * 60 * 60)
return .after(min(Date(timeIntervalSince1970: timestamp), maxDate))
}
return .immediate
default:
return .immediate
}
}
}
#endif