From 6f774195f5669197f42cb1c34b3a770e5cde2d04 Mon Sep 17 00:00:00 2001 From: rwarner Date: Wed, 18 Mar 2026 16:11:35 -0400 Subject: [PATCH 01/34] =?UTF-8?q?feat(live-activities):=20Phase=201=20?= =?UTF-8?q?=E2=80=94=20data=20model,=20registry=20actor,=20and=20SwiftUI?= =?UTF-8?q?=20views?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the ActivityKit foundation for Home Assistant iOS Live Activities, targeting feature parity with Android's live_update notification system. - HALiveActivityAttributes: ActivityAttributes struct with static tag/title and dynamic ContentState (message, criticalText, progress, progressMax, chronometer, countdownEnd, icon, color). Field names mirror Android companion notification fields. Never rename post-ship — struct name is used as the APNs attributes-type identifier. - LiveActivityRegistry (actor): Thread-safe lifecycle manager with TOCTOU- safe reservation pattern. Handles start, update, end, and reattachment to activities surviving process termination. Supports both iOS 16.1 (contentState:) and iOS 16.2+ (content: ActivityContent) API shapes. - HandlerStartOrUpdateLiveActivity / HandlerEndLiveActivity: New NotificationCommandHandler structs registered for "live_activity" and "end_live_activity" commands, bridging PromiseKit to async/await. - HandlerClearNotification extended: clear_notification + tag now also ends any matching Live Activity — identical YAML dismisses on both iOS and Android. - HALiveActivityConfiguration: WidgetKit ActivityConfiguration wiring registered in WidgetsBundle18 (iOS 18+ widget bundle). - HALockScreenView: Lock Screen / StandBy view with icon, timer or message, and optional ProgressView. Stays within 160pt system height limit. - HADynamicIslandView: All four DynamicIsland presentations (compact leading/trailing, minimal, expanded) using SwiftUI result builder API. - AppEnvironment: liveActivityRegistry property added under #if os(iOS) && canImport(ActivityKit) using Any? backing store to avoid @available stored property compiler restriction. All ActivityKit code guarded by #if canImport(ActivityKit) and @available(iOS 16.1, *). iPad gracefully returns areActivitiesEnabled==false. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Sonnet 4.6 --- .../LiveActivity/HADynamicIslandView.swift | 184 +++++++++++++++++ .../HALiveActivityConfiguration.swift | 20 ++ .../LiveActivity/HALockScreenView.swift | 165 +++++++++++++++ Sources/Extensions/Widgets/Widgets.swift | 3 + Sources/Shared/Environment/Environment.swift | 21 ++ .../HALiveActivityAttributes.swift | 109 ++++++++++ .../LiveActivity/LiveActivityRegistry.swift | 190 ++++++++++++++++++ .../HandlerLiveActivity.swift | 118 +++++++++++ .../NotificationsCommandManager.swift | 16 ++ 9 files changed, 826 insertions(+) create mode 100644 Sources/Extensions/Widgets/LiveActivity/HADynamicIslandView.swift create mode 100644 Sources/Extensions/Widgets/LiveActivity/HALiveActivityConfiguration.swift create mode 100644 Sources/Extensions/Widgets/LiveActivity/HALockScreenView.swift create mode 100644 Sources/Shared/LiveActivity/HALiveActivityAttributes.swift create mode 100644 Sources/Shared/LiveActivity/LiveActivityRegistry.swift create mode 100644 Sources/Shared/Notifications/NotificationCommands/HandlerLiveActivity.swift diff --git a/Sources/Extensions/Widgets/LiveActivity/HADynamicIslandView.swift b/Sources/Extensions/Widgets/LiveActivity/HADynamicIslandView.swift new file mode 100644 index 0000000000..3b186edd86 --- /dev/null +++ b/Sources/Extensions/Widgets/LiveActivity/HADynamicIslandView.swift @@ -0,0 +1,184 @@ +import ActivityKit +import Shared +import SwiftUI +import WidgetKit + +// MARK: - DynamicIsland builder + +/// Builds the `DynamicIsland` for a Home Assistant Live Activity. +/// Used in `HALiveActivityConfiguration`'s `dynamicIsland:` closure. +@available(iOS 16.2, *) +func makeHADynamicIsland( + attributes: HALiveActivityAttributes, + state: HALiveActivityAttributes.ContentState +) -> DynamicIsland { + DynamicIsland { + DynamicIslandExpandedRegion(.leading) { + HADynamicIslandIconView(slug: state.icon, color: state.color, size: 24) + .padding(.leading, 4) + } + DynamicIslandExpandedRegion(.center) { + Text(attributes.title) + .font(.caption.weight(.semibold)) + .foregroundStyle(.white) + .lineLimit(1) + } + DynamicIslandExpandedRegion(.trailing) { + HAExpandedTrailingView(state: state) + .padding(.trailing, 4) + } + DynamicIslandExpandedRegion(.bottom) { + HAExpandedBottomView(state: state) + .padding(.horizontal, 8) + .padding(.bottom, 4) + } + } compactLeading: { + HADynamicIslandIconView(slug: state.icon, color: state.color, size: 16) + .padding(.leading, 4) + } compactTrailing: { + HACompactTrailingView(state: state) + .padding(.trailing, 4) + } minimal: { + HADynamicIslandIconView(slug: state.icon, color: state.color, size: 14) + } +} + +// MARK: - Icon view + +@available(iOS 16.2, *) +struct HADynamicIslandIconView: View { + let slug: String? + let color: String? + let size: CGFloat + + var body: some View { + if let slug, let mdiIcon = MaterialDesignIcons(serversideValueNamed: slug) { + let uiColor = color.flatMap { UIColor(hex: $0) } ?? UIColor(Color(hex: "#03A9F4")) + Image(uiImage: mdiIcon.image( + ofSize: .init(width: size, height: size), + color: uiColor + )) + .resizable() + .frame(width: size, height: size) + } + } +} + +// MARK: - Compact trailing + +@available(iOS 16.2, *) +struct HACompactTrailingView: View { + let state: HALiveActivityAttributes.ContentState + + var body: some View { + if state.chronometer == true, let end = state.countdownEnd { + Text(timerInterval: Date.now ... end, countsDown: true) + .font(.caption2) + .foregroundStyle(.white) + .monospacedDigit() + .frame(maxWidth: 50) + } else if let critical = state.criticalText { + Text(critical) + .font(.caption2) + .foregroundStyle(.white) + .lineLimit(1) + .frame(maxWidth: 50) + } else if let fraction = state.progressFraction { + Text("\(Int(fraction * 100))%") + .font(.caption2) + .foregroundStyle(.white) + .monospacedDigit() + } + } +} + +// MARK: - Expanded trailing + +@available(iOS 16.2, *) +struct HAExpandedTrailingView: View { + let state: HALiveActivityAttributes.ContentState + + var body: some View { + if let fraction = state.progressFraction { + Text("\(Int(fraction * 100))%") + .font(.caption2) + .foregroundStyle(.white) + .monospacedDigit() + } else if let critical = state.criticalText { + Text(critical) + .font(.caption2) + .foregroundStyle(.white) + .lineLimit(1) + } + } +} + +// MARK: - Expanded bottom + +@available(iOS 16.2, *) +struct HAExpandedBottomView: View { + let state: HALiveActivityAttributes.ContentState + + var body: some View { + VStack(spacing: 4) { + if state.chronometer == true, let end = state.countdownEnd { + Text(timerInterval: Date.now ... end, countsDown: true) + .font(.body.monospacedDigit()) + .foregroundStyle(.white) + } else { + Text(state.message) + .font(.subheadline) + .foregroundStyle(.white.opacity(0.85)) + .lineLimit(2) + } + + if let fraction = state.progressFraction { + ProgressView(value: fraction) + .tint(Color(hex: state.color ?? "#03A9F4")) + } + } + } +} + +// MARK: - Previews + +#if DEBUG +@available(iOS 16.2, *) +#Preview("Compact", as: .dynamicIsland(.compact), using: HALiveActivityAttributes(tag: "washer", title: "Washer")) { + HALiveActivityConfiguration() +} contentStates: { + HALiveActivityAttributes.ContentState( + message: "45 min remaining", + criticalText: "45 min", + progress: 2700, + progressMax: 3600, + icon: "mdi:washing-machine", + color: "#2196F3" + ) +} + +@available(iOS 16.2, *) +#Preview("Expanded", as: .dynamicIsland(.expanded), using: HALiveActivityAttributes(tag: "washer", title: "Washing Machine")) { + HALiveActivityConfiguration() +} contentStates: { + HALiveActivityAttributes.ContentState( + message: "Cycle in progress", + criticalText: "45 min", + progress: 2700, + progressMax: 3600, + icon: "mdi:washing-machine", + color: "#2196F3" + ) +} + +@available(iOS 16.2, *) +#Preview("Minimal", as: .dynamicIsland(.minimal), using: HALiveActivityAttributes(tag: "washer", title: "Washer")) { + HALiveActivityConfiguration() +} contentStates: { + HALiveActivityAttributes.ContentState( + message: "Running", + icon: "mdi:washing-machine", + color: "#2196F3" + ) +} +#endif diff --git a/Sources/Extensions/Widgets/LiveActivity/HALiveActivityConfiguration.swift b/Sources/Extensions/Widgets/LiveActivity/HALiveActivityConfiguration.swift new file mode 100644 index 0000000000..203a835c48 --- /dev/null +++ b/Sources/Extensions/Widgets/LiveActivity/HALiveActivityConfiguration.swift @@ -0,0 +1,20 @@ +import ActivityKit +import Shared +import SwiftUI +import WidgetKit + +@available(iOS 16.2, *) +struct HALiveActivityConfiguration: Widget { + var body: some WidgetConfiguration { + ActivityConfiguration(for: HALiveActivityAttributes.self) { context in + HALockScreenView( + attributes: context.attributes, + state: context.state + ) + .activityBackgroundTint(Color.black.opacity(0.75)) + .activitySystemActionForegroundColor(Color.white) + } dynamicIsland: { context in + makeHADynamicIsland(attributes: context.attributes, state: context.state) + } + } +} diff --git a/Sources/Extensions/Widgets/LiveActivity/HALockScreenView.swift b/Sources/Extensions/Widgets/LiveActivity/HALockScreenView.swift new file mode 100644 index 0000000000..2e7d92b384 --- /dev/null +++ b/Sources/Extensions/Widgets/LiveActivity/HALockScreenView.swift @@ -0,0 +1,165 @@ +import ActivityKit +import Shared +import SwiftUI +import WidgetKit + +/// Lock Screen (and StandBy) view for a Home Assistant Live Activity. +/// +/// The system hard-truncates at 160 points height — padding counts against this limit. +/// Keep layout tight and avoid decorative spacing. +@available(iOS 16.2, *) +struct HALockScreenView: View { + let attributes: HALiveActivityAttributes + let state: HALiveActivityAttributes.ContentState + + var body: some View { + VStack(alignment: .leading, spacing: 6) { + // Header row: icon + title + HStack(spacing: 8) { + iconView + Text(attributes.title) + .font(.headline) + .foregroundStyle(.white) + .lineLimit(1) + } + + // Body: timer or message + if state.chronometer == true, let end = state.countdownEnd { + Text(timerInterval: Date.now ... end, countsDown: true) + .font(.subheadline) + .foregroundStyle(.white.opacity(0.85)) + .monospacedDigit() + } else { + Text(state.message) + .font(.subheadline) + .foregroundStyle(.white.opacity(0.85)) + .lineLimit(2) + } + + // Progress bar (only when progress data is present) + if let fraction = state.progressFraction { + ProgressView(value: fraction) + .tint(accentColor) + } + } + .padding(.horizontal, 16) + .padding(.vertical, 12) + } + + // MARK: - Sub-views + + @ViewBuilder + private var iconView: some View { + if let iconSlug = state.icon, + let mdiIcon = MaterialDesignIcons(serversideValueNamed: iconSlug) { + let uiColor = UIColor(hex: state.color ?? "#03A9F4") ?? .white + Image(uiImage: mdiIcon.image( + ofSize: .init(width: 20, height: 20), + color: uiColor + )) + .resizable() + .frame(width: 20, height: 20) + } + } + + // MARK: - Helpers + + /// Parse hex color from ContentState, fallback to Home Assistant blue. + private var accentColor: Color { + guard let hex = state.color else { + return Color(hex: "#03A9F4") // HA blue + } + return Color(hex: hex) + } +} + +// MARK: - Color(hex:) + UIColor(hex:) extensions + +extension Color { + /// Initialize from a hex string like `#RRGGBB` or `#RRGGBBAA`. + /// Pre-parsing here prevents hex string work in every render pass. + init(hex: String) { + let hex = hex.trimmingCharacters(in: CharacterSet.alphanumerics.inverted) + var int: UInt64 = 0 + Scanner(string: hex).scanHexInt64(&int) + let a, r, g, b: UInt64 + switch hex.count { + case 3: // RGB (12-bit) + (a, r, g, b) = (255, (int >> 8) * 17, (int >> 4 & 0xF) * 17, (int & 0xF) * 17) + case 6: // RRGGBB + (a, r, g, b) = (255, int >> 16, int >> 8 & 0xFF, int & 0xFF) + case 8: // RRGGBBAA + (a, r, g, b) = (int >> 24, int >> 16 & 0xFF, int >> 8 & 0xFF, int & 0xFF) + default: + (a, r, g, b) = (255, 0, 0, 0) + } + self.init( + .sRGB, + red: Double(r) / 255, + green: Double(g) / 255, + blue: Double(b) / 255, + opacity: Double(a) / 255 + ) + } +} + +extension UIColor { + convenience init?(hex: String) { + let hex = hex.trimmingCharacters(in: CharacterSet.alphanumerics.inverted) + var int: UInt64 = 0 + guard Scanner(string: hex).scanHexInt64(&int) else { return nil } + let r, g, b: UInt64 + switch hex.count { + case 3: + (r, g, b) = ((int >> 8) * 17, (int >> 4 & 0xF) * 17, (int & 0xF) * 17) + case 6: + (r, g, b) = (int >> 16, int >> 8 & 0xFF, int & 0xFF) + default: + return nil + } + self.init( + red: CGFloat(r) / 255, + green: CGFloat(g) / 255, + blue: CGFloat(b) / 255, + alpha: 1 + ) + } +} + +// MARK: - Preview + +#if DEBUG +@available(iOS 16.2, *) +#Preview("Lock Screen — Progress", as: .content, using: HALiveActivityAttributes(tag: "washer", title: "Washing Machine")) { + HALiveActivityConfiguration() +} contentStates: { + HALiveActivityAttributes.ContentState( + message: "45 minutes remaining", + criticalText: "45 min", + progress: 2700, + progressMax: 3600, + icon: "mdi:washing-machine", + color: "#2196F3" + ) + HALiveActivityAttributes.ContentState( + message: "Cycle complete!", + progress: 3600, + progressMax: 3600, + icon: "mdi:check-circle", + color: "#4CAF50" + ) +} + +@available(iOS 16.2, *) +#Preview("Lock Screen — Chronometer", as: .content, using: HALiveActivityAttributes(tag: "timer", title: "Kitchen Timer")) { + HALiveActivityConfiguration() +} contentStates: { + HALiveActivityAttributes.ContentState( + message: "Timer running", + chronometer: true, + countdownEnd: Date().addingTimeInterval(300), + icon: "mdi:timer", + color: "#FF9800" + ) +} +#endif diff --git a/Sources/Extensions/Widgets/Widgets.swift b/Sources/Extensions/Widgets/Widgets.swift index 140b9fe9b6..fa1b604f7d 100644 --- a/Sources/Extensions/Widgets/Widgets.swift +++ b/Sources/Extensions/Widgets/Widgets.swift @@ -54,6 +54,9 @@ struct WidgetsBundle18: WidgetBundle { } var body: some Widget { + // Live Activities (ActivityKit requires iOS 16.2+, this bundle requires iOS 18.0+) + HALiveActivityConfiguration() + // Controls ControlAssist() ControlLight() diff --git a/Sources/Shared/Environment/Environment.swift b/Sources/Shared/Environment/Environment.swift index cf76c635c1..057ebe31e0 100644 --- a/Sources/Shared/Environment/Environment.swift +++ b/Sources/Shared/Environment/Environment.swift @@ -131,6 +131,27 @@ public class AppEnvironment { } #if os(iOS) + #if canImport(ActivityKit) + // Backing store uses Any? to work around Swift's @available stored property restriction. + // Access via the typed `liveActivityRegistry` computed property. + private var _liveActivityRegistryBacking: Any? + + @available(iOS 16.1, *) + public var liveActivityRegistry: LiveActivityRegistryProtocol { + get { + if let existing = _liveActivityRegistryBacking as? LiveActivityRegistryProtocol { + return existing + } + let registry = LiveActivityRegistry() + _liveActivityRegistryBacking = registry + return registry + } + set { + _liveActivityRegistryBacking = newValue + } + } + #endif + public var appDatabaseUpdater: AppDatabaseUpdaterProtocol = AppDatabaseUpdater.shared public var panelsUpdater: PanelsUpdaterProtocol = PanelsUpdater.shared diff --git a/Sources/Shared/LiveActivity/HALiveActivityAttributes.swift b/Sources/Shared/LiveActivity/HALiveActivityAttributes.swift new file mode 100644 index 0000000000..8e798e49c0 --- /dev/null +++ b/Sources/Shared/LiveActivity/HALiveActivityAttributes.swift @@ -0,0 +1,109 @@ +#if canImport(ActivityKit) +import ActivityKit +import SwiftUI + +/// ActivityAttributes for Home Assistant Live Activities. +/// +/// Field names intentionally mirror the Android companion app's notification fields +/// so that automations can target both platforms with minimal differences. +/// +/// ⚠️ NEVER rename this struct or its fields post-ship. +/// The `attributes-type` string in APNs push-to-start payloads must exactly match +/// the Swift struct name (case-sensitive). Renaming breaks all in-flight activities. +@available(iOS 16.1, *) +public struct HALiveActivityAttributes: ActivityAttributes { + // MARK: - Static Attributes (set once at creation, cannot change) + + /// Unique identifier for this Live Activity. Maps to `tag` in the notification payload. + /// Same semantics as Android's `tag`: the same tag value updates in-place. + public let tag: String + + /// Display title for the activity. Maps to `title` in the notification payload. + public let title: String + + // MARK: - Dynamic State + + /// Codable state that can be updated via push or local update. + /// Field names map to Android companion app notification data fields. + public struct ContentState: Codable, Hashable { + /// Primary body text. Maps to `message` in the notification payload. + public var message: String + + /// Short text for Dynamic Island compact trailing view. + /// Maps to `critical_text` in the notification payload (≤ ~10 chars recommended). + public var criticalText: String? + + /// Current progress value (raw integer). Maps to `progress`. + public var progress: Int? + + /// Maximum progress value (raw integer). Maps to `progress_max`. + public var progressMax: Int? + + /// If true, show a countdown timer instead of static text. Maps to `chronometer`. + public var chronometer: Bool? + + /// Absolute end date for the countdown timer. + /// Computed from `when` + `when_relative` in the notification payload: + /// - `when_relative: true` → `Date().addingTimeInterval(Double(when))` + /// - `when_relative: false` → `Date(timeIntervalSince1970: Double(when))` + public var countdownEnd: Date? + + /// MDI icon slug for display. Maps to `notification_icon`. + public var icon: String? + + /// Hex color string for icon accent. Maps to `notification_icon_color`. + public var color: String? + + // MARK: - Computed helpers (not sent over wire) + + /// Progress as a fraction in [0, 1] for use in SwiftUI ProgressView. + public var progressFraction: Double? { + guard let p = progress, let m = progressMax, m > 0 else { return nil } + return Double(p) / Double(m) + } + + // MARK: - CodingKeys + + /// Explicit coding keys so that JSON field names match the Android notification fields. + enum CodingKeys: String, CodingKey { + case message + case criticalText = "critical_text" + case progress + case progressMax = "progress_max" + case chronometer + case countdownEnd = "countdown_end" + case icon + case color + } + + // MARK: - Init + + public init( + message: String, + criticalText: String? = nil, + progress: Int? = nil, + progressMax: Int? = nil, + chronometer: Bool? = nil, + countdownEnd: Date? = nil, + icon: String? = nil, + color: String? = nil + ) { + self.message = message + self.criticalText = criticalText + self.progress = progress + self.progressMax = progressMax + self.chronometer = chronometer + self.countdownEnd = countdownEnd + self.icon = icon + self.color = color + } + } + + // MARK: - Init + + public init(tag: String, title: String) { + self.tag = tag + self.title = title + } +} +#endif diff --git a/Sources/Shared/LiveActivity/LiveActivityRegistry.swift b/Sources/Shared/LiveActivity/LiveActivityRegistry.swift new file mode 100644 index 0000000000..e840c64e08 --- /dev/null +++ b/Sources/Shared/LiveActivity/LiveActivityRegistry.swift @@ -0,0 +1,190 @@ +#if canImport(ActivityKit) +import ActivityKit +import Foundation + +@available(iOS 16.1, *) +public protocol LiveActivityRegistryProtocol: AnyObject { + func startOrUpdate(tag: String, title: String, state: HALiveActivityAttributes.ContentState) async throws + func end(tag: String, dismissalPolicy: ActivityUIDismissalPolicy) async + func reattach() async +} + +/// Thread-safe registry for active `Activity` 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 16.1, *) +public actor LiveActivityRegistry: LiveActivityRegistryProtocol { + + // MARK: - Types + + struct Entry { + let activity: Activity + let observationTask: Task + } + + // MARK: - State + + /// Tags currently in-flight (reserved but not yet confirmed or cancelled). + private var reserved: Set = [] + + /// 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 + } + + private func confirmReservation(id: String, entry: Entry) { + reserved.remove(id) + entries[id] = entry + } + + private func cancelReservation(id: String) { + reserved.remove(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] { + if #available(iOS 16.2, *) { + let content = ActivityContent( + state: state, + staleDate: Date().addingTimeInterval(30 * 60) + ) + await existing.activity.update(content) + } else { + await existing.activity.update(using: state) + } + return + } + + // Also check system list in case we lost track after crash/relaunch + if let live = Activity.activities + .first(where: { $0.attributes.tag == tag }) { + if #available(iOS 16.2, *) { + let content = ActivityContent( + state: state, + staleDate: Date().addingTimeInterval(30 * 60) + ) + await live.update(content) + } else { + await live.update(using: state) + } + 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 { + Current.Log.info("LiveActivityRegistry: duplicate start for tag \(tag), ignoring") + 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 + + do { + if #available(iOS 16.2, *) { + let content = ActivityContent( + state: state, + staleDate: Date().addingTimeInterval(30 * 60), + relevanceScore: 0.5 + ) + activity = try Activity.request( + attributes: attributes, + content: content, + pushType: .token + ) + } else { + activity = try Activity.request( + attributes: attributes, + contentState: state, + pushType: .token + ) + } + } catch { + cancelReservation(id: tag) + throw error + } + + let observationTask = makeObservationTask(for: activity) + 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 + } + // Fallback: check system list in case we lost track + if let live = Activity.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.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)") + } + } + + // MARK: - Private + + private func makeObservationTask(for activity: Activity) -> Task { + Task { + for await state in activity.activityStateUpdates { + switch state { + case .dismissed, .ended: + _ = await self.remove(id: activity.attributes.tag) + case .active, .stale: + break + @unknown default: + break + } + } + } + } +} +#endif diff --git a/Sources/Shared/Notifications/NotificationCommands/HandlerLiveActivity.swift b/Sources/Shared/Notifications/NotificationCommands/HandlerLiveActivity.swift new file mode 100644 index 0000000000..c5cbfd9c04 --- /dev/null +++ b/Sources/Shared/Notifications/NotificationCommands/HandlerLiveActivity.swift @@ -0,0 +1,118 @@ +#if canImport(ActivityKit) +import ActivityKit +import Foundation +import PromiseKit + +// MARK: - HandlerStartOrUpdateLiveActivity + +/// Handles `live_activity: true` notifications by starting or updating a Live Activity. +/// +/// 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 16.1, *) +struct HandlerStartOrUpdateLiveActivity: NotificationCommandHandler { + func handle(_ payload: [String: Any]) -> Promise { + Promise { seal in + Task { + do { + guard let tag = payload["tag"] as? String, !tag.isEmpty else { + Current.Log.error("HandlerStartOrUpdateLiveActivity: missing required field 'tag'") + seal.fulfill(()) + return + } + + guard let title = payload["title"] as? String, !title.isEmpty else { + Current.Log.error("HandlerStartOrUpdateLiveActivity: missing required field 'title'") + seal.fulfill(()) + return + } + + 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)") + seal.reject(error) + } + } + } + } + + // MARK: - Payload Parsing + + private static func contentState(from payload: [String: Any]) -> HALiveActivityAttributes.ContentState { + let message = payload["message"] as? String ?? "" + let criticalText = payload["critical_text"] as? String + let progress = payload["progress"] as? Int + let progressMax = payload["progress_max"] as? Int + 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 + var countdownEnd: Date? + if let when = payload["when"] as? Int { + let whenRelative = payload["when_relative"] as? Bool ?? false + if whenRelative { + countdownEnd = Date().addingTimeInterval(Double(when)) + } else { + countdownEnd = Date(timeIntervalSince1970: Double(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 16.1, *) +struct HandlerEndLiveActivity: NotificationCommandHandler { + func handle(_ payload: [String: Any]) -> Promise { + Promise { seal in + Task { + guard let tag = payload["tag"] as? String, !tag.isEmpty 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) { + return .after(Date(timeIntervalSince1970: timestamp)) + } + return .immediate + default: + return .immediate + } + } +} +#endif diff --git a/Sources/Shared/Notifications/NotificationCommands/NotificationsCommandManager.swift b/Sources/Shared/Notifications/NotificationCommands/NotificationsCommandManager.swift index 215ed06634..c4ad3754cb 100644 --- a/Sources/Shared/Notifications/NotificationCommands/NotificationsCommandManager.swift +++ b/Sources/Shared/Notifications/NotificationCommands/NotificationsCommandManager.swift @@ -22,6 +22,12 @@ public class NotificationCommandManager { register(command: "clear_notification", handler: HandlerClearNotification()) #if os(iOS) register(command: "update_complications", handler: HandlerUpdateComplications()) + #if canImport(ActivityKit) + if #available(iOS 16.1, *) { + register(command: "live_activity", handler: HandlerStartOrUpdateLiveActivity()) + register(command: "end_live_activity", handler: HandlerEndLiveActivity()) + } + #endif #endif #if os(iOS) || os(macOS) @@ -89,6 +95,16 @@ private struct HandlerClearNotification: NotificationCommandHandler { if !keys.isEmpty { UNUserNotificationCenter.current().removeDeliveredNotifications(withIdentifiers: keys) } + + // Also end any Live Activity whose tag matches — same YAML works on both iOS and Android + #if os(iOS) && canImport(ActivityKit) + if #available(iOS 16.1, *), let tag = payload["tag"] as? String { + Task { + await Current.liveActivityRegistry.end(tag: tag, dismissalPolicy: .immediate) + } + } + #endif + // https://stackoverflow.com/a/56657888/6324550 return Promise { seal in DispatchQueue.main.async { From 19fa56bf38af905961a0ab7b89d0460039358a17 Mon Sep 17 00:00:00 2001 From: rwarner Date: Wed, 18 Mar 2026 16:24:24 -0400 Subject: [PATCH 02/34] =?UTF-8?q?feat(live-activities):=20Phase=202=20?= =?UTF-8?q?=E2=80=94=20notification=20routing,=20token=20reporting,=20capa?= =?UTF-8?q?bility=20advertisement?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wires Live Activities into the existing notification pipeline so HA automations can start, update, and end activities from YAML without app changes. Notification routing: - NotificationCommandManager.handle() now intercepts live_activity: true in the homeassistant dict as an alternative to the command-based approach (message: live_activity). This matches Android's data.live_update: true pattern — the message field can be real body text while live_activity: true in data triggers ActivityKit. Both paths funnel into HandlerStartOrUpdateLiveActivity. - HandlerStartOrUpdateLiveActivity/End guard Current.isAppExtension — PushProvider (NEAppPushProvider) runs in a separate OS process where ActivityKit is unavailable. The same notification is re-delivered to the main app via UNUserNotificationCenter, where the main app processes it correctly. Tag validation: - Tag validated as [a-zA-Z0-9_-], max 64 chars, matching safe APNs collapse ID subset. Invalid tags log an error and fulfill cleanly instead of crashing or infinite-retrying. Push token + lifecycle observation: - LiveActivityRegistry.makeObservationTask() now runs two concurrent async streams via withTaskGroup: pushTokenUpdates (reports each new token to all HA servers) and activityStateUpdates (reports dismissal to HA so it stops sending updates). - Webhooks use type "mobile_app_live_activity_token" and "mobile_app_live_activity_dismissed" via the existing WebhookManager.sendEphemeral path. - APNs environment (sandbox/production) is included in token reports for relay routing. Capability advertisement: - mobileAppRegistrationRequestModel() adds supports_live_activities: true on iOS 16.1+ and supports_live_activities_frequent_updates on iOS 17.2+ to AppData, so the HA device registry shows the capability and can gate Live Activity UI in automations. App launch recovery: - AppDelegate.willFinishLaunchingWithOptions calls setupLiveActivityReattachment(), which runs liveActivityRegistry.reattach() on a Task. This restores observation (token + lifecycle) for any activities that survived process termination — ensuring no token updates are missed between launches. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Sonnet 4.6 --- Sources/App/AppDelegate.swift | 14 ++++ Sources/Shared/API/HAAPI.swift | 16 +++- .../LiveActivity/LiveActivityRegistry.swift | 83 +++++++++++++++++-- .../HandlerLiveActivity.swift | 60 +++++++++++--- .../NotificationsCommandManager.swift | 16 +++- 5 files changed, 167 insertions(+), 22 deletions(-) diff --git a/Sources/App/AppDelegate.swift b/Sources/App/AppDelegate.swift index efdd247ca3..6e7bf69b8c 100644 --- a/Sources/App/AppDelegate.swift +++ b/Sources/App/AppDelegate.swift @@ -90,6 +90,7 @@ class AppDelegate: UIResponder, UIApplicationDelegate { // swiftlint:enable prohibit_environment_assignment notificationManager.setupNotifications() + setupLiveActivityReattachment() setupFirebase() setupModels() setupLocalization() @@ -372,6 +373,19 @@ class AppDelegate: UIResponder, UIApplicationDelegate { }) } + private func setupLiveActivityReattachment() { + #if canImport(ActivityKit) + if #available(iOS 16.1, *) { + // Re-attach observation tasks (push token + lifecycle) to any Live Activities + // that survived the previous process termination. Must run before the first + // notification handler fires so no push token updates are missed. + Task { + await Current.liveActivityRegistry.reattach() + } + } + #endif + } + private func setupFirebase() { let optionsFile: String = { switch Current.appConfiguration { diff --git a/Sources/Shared/API/HAAPI.swift b/Sources/Shared/API/HAAPI.swift index 6c6f190f40..48899f38b1 100644 --- a/Sources/Shared/API/HAAPI.swift +++ b/Sources/Shared/API/HAAPI.swift @@ -555,10 +555,24 @@ public class HomeAssistantAPI { private func mobileAppRegistrationRequestModel() -> MobileAppRegistrationRequest { with(MobileAppRegistrationRequest()) { if let pushID = Current.settingsStore.pushID { - $0.AppData = [ + var appData: [String: Any] = [ "push_url": "https://mobile-apps.home-assistant.io/api/sendPushNotification", "push_token": pushID, ] + + #if os(iOS) && canImport(ActivityKit) + if #available(iOS 16.1, *) { + // Advertise Live Activity support so HA can gate the UI and send + // activity push tokens back to the relay server. + appData["supports_live_activities"] = true + } + if #available(iOS 17.2, *) { + appData["supports_live_activities_frequent_updates"] = + ActivityAuthorizationInfo().frequentPushesEnabled + } + #endif + + $0.AppData = appData } $0.AppIdentifier = AppConstants.BundleID diff --git a/Sources/Shared/LiveActivity/LiveActivityRegistry.swift b/Sources/Shared/LiveActivity/LiveActivityRegistry.swift index e840c64e08..12bea734f1 100644 --- a/Sources/Shared/LiveActivity/LiveActivityRegistry.swift +++ b/Sources/Shared/LiveActivity/LiveActivityRegistry.swift @@ -170,21 +170,86 @@ public actor LiveActivityRegistry: LiveActivityRegistryProtocol { } } - // MARK: - Private + // MARK: - Private — Observation private func makeObservationTask(for activity: Activity) -> Task { Task { - for await state in activity.activityStateUpdates { - switch state { - case .dismissed, .ended: - _ = await self.remove(id: activity.attributes.tag) - case .active, .stale: - break - @unknown default: - break + 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 { + guard !Task.isCancelled else { break } + 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 + @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: "mobile_app_live_activity_token", + data: [ + "activity_id": activityID, + "push_token": tokenHex, + "apns_environment": apnsEnvironmentString(), + ] + ) + 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: "mobile_app_live_activity_dismissed", + data: [ + "activity_id": activityID, + "tag": tag, + "reason": reason, + ] + ) + for server in Current.servers.all { + Current.webhooks.sendEphemeral(server: server, request: request).cauterize() + } + } + + private func apnsEnvironmentString() -> String { + #if DEBUG + return "sandbox" + #else + return Current.isTestFlight ? "sandbox" : "production" + #endif + } } #endif diff --git a/Sources/Shared/Notifications/NotificationCommands/HandlerLiveActivity.swift b/Sources/Shared/Notifications/NotificationCommands/HandlerLiveActivity.swift index c5cbfd9c04..1bdcd914f3 100644 --- a/Sources/Shared/Notifications/NotificationCommands/HandlerLiveActivity.swift +++ b/Sources/Shared/Notifications/NotificationCommands/HandlerLiveActivity.swift @@ -7,25 +7,44 @@ import PromiseKit /// Handles `live_activity: 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_activity == true` (data.live_activity: 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 16.1, *) struct HandlerStartOrUpdateLiveActivity: NotificationCommandHandler { + private enum ValidationError: Error { + case missingTag + case missingTitle + case invalidTag + } + func handle(_ payload: [String: Any]) -> Promise { - Promise { seal in + // 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 { - Current.Log.error("HandlerStartOrUpdateLiveActivity: missing required field 'tag'") - seal.fulfill(()) - return + 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 { - Current.Log.error("HandlerStartOrUpdateLiveActivity: missing required field 'title'") - seal.fulfill(()) - return + throw ValidationError.missingTitle } let state = Self.contentState(from: payload) @@ -38,15 +57,32 @@ struct HandlerStartOrUpdateLiveActivity: NotificationCommandHandler { seal.fulfill(()) } catch { Current.Log.error("HandlerStartOrUpdateLiveActivity: \(error)") - seal.reject(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: - Validation + + /// Tag must be alphanumeric with hyphens/underscores, max 64 characters. + /// Matches the safe subset of APNs collapse identifiers. + private static func isValidTag(_ tag: String) -> Bool { + guard tag.count <= 64 else { return false } + let allowed = CharacterSet.alphanumerics.union(CharacterSet(charactersIn: "-_")) + return tag.unicodeScalars.allSatisfy { allowed.contains($0) } + } + // MARK: - Payload Parsing - private static func contentState(from payload: [String: Any]) -> HALiveActivityAttributes.ContentState { + static func contentState(from payload: [String: Any]) -> HALiveActivityAttributes.ContentState { let message = payload["message"] as? String ?? "" let criticalText = payload["critical_text"] as? String let progress = payload["progress"] as? Int @@ -86,7 +122,11 @@ struct HandlerStartOrUpdateLiveActivity: NotificationCommandHandler { @available(iOS 16.1, *) struct HandlerEndLiveActivity: NotificationCommandHandler { func handle(_ payload: [String: Any]) -> Promise { - Promise { seal in + guard !Current.isAppExtension else { + return .value(()) + } + + return Promise { seal in Task { guard let tag = payload["tag"] as? String, !tag.isEmpty else { seal.fulfill(()) diff --git a/Sources/Shared/Notifications/NotificationCommands/NotificationsCommandManager.swift b/Sources/Shared/Notifications/NotificationCommands/NotificationsCommandManager.swift index c4ad3754cb..b6097f56f7 100644 --- a/Sources/Shared/Notifications/NotificationCommands/NotificationsCommandManager.swift +++ b/Sources/Shared/Notifications/NotificationCommands/NotificationsCommandManager.swift @@ -42,8 +42,20 @@ public class NotificationCommandManager { } public func handle(_ payload: [AnyHashable: Any]) -> Promise { - guard let hadict = payload["homeassistant"] as? [String: Any], - let command = hadict["command"] as? String else { + guard let hadict = payload["homeassistant"] as? [String: Any] else { + return .init(error: CommandError.notCommand) + } + + // Support data.live_activity: true as an alternative to message: live_activity. + // This allows the notification body to be a real message instead of a command keyword, + // matching Android's data.live_update: true pattern. + #if canImport(ActivityKit) + if #available(iOS 16.1, *), hadict["live_activity"] as? Bool == true { + return HandlerStartOrUpdateLiveActivity().handle(hadict) + } + #endif + + guard let command = hadict["command"] as? String else { return .init(error: CommandError.notCommand) } From 76ad81f6a2be69215f9a07a15a8d1948192a26c1 Mon Sep 17 00:00:00 2001 From: rwarner Date: Wed, 18 Mar 2026 16:27:40 -0400 Subject: [PATCH 03/34] =?UTF-8?q?feat(live-activities):=20Phase=203=20?= =?UTF-8?q?=E2=80=94=20push-to-start=20token=20observation=20and=20reporti?= =?UTF-8?q?ng?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds iOS 17.2+ push-to-start support so HA can start a Live Activity entirely via APNs without the app being in the foreground (best-effort, ~50% success from terminated state — the primary flow remains notification command → app). Token observation: - LiveActivityRegistry.startObservingPushToStartToken() observes the async stream Activity.pushToStartTokenUpdates (iOS 17.2+). - Each new token is stored in Keychain (not UserDefaults — this token can start any new activity so it warrants stronger storage) under a stable key. - Triggers api.updateRegistration() on all connected servers so the token is immediately available in the HA device registry. AppDelegate: - setupLiveActivityReattachment() now runs both reattach() and, on iOS 17.2+, startObservingPushToStartToken() sequentially in a single long-lived Task. The push-to-start stream is infinite and is kept alive for the app's lifetime. Registration payload: - mobileAppRegistrationRequestModel() includes live_activity_push_to_start_token and live_activity_push_to_start_apns_environment in AppData on iOS 17.2+ when a token is stored. The relay server uses these to route push-to-start APNs payloads to the correct environment (sandbox vs production). - Extracted apnsEnvironmentString() helper shared by push-to-start and per-activity token reporting. Protocol: - LiveActivityRegistryProtocol gains startObservingPushToStartToken() @available(iOS 17.2, *) for testability and mock injection. Note: relay server changes (new APNs endpoint, JWT routing, push-to-start payload format) are required for end-to-end functionality and are tracked separately. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Sonnet 4.6 --- Sources/App/AppDelegate.swift | 14 +++-- Sources/Shared/API/HAAPI.swift | 16 ++++++ .../LiveActivity/LiveActivityRegistry.swift | 52 +++++++++++++++++++ 3 files changed, 79 insertions(+), 3 deletions(-) diff --git a/Sources/App/AppDelegate.swift b/Sources/App/AppDelegate.swift index 6e7bf69b8c..d61084634a 100644 --- a/Sources/App/AppDelegate.swift +++ b/Sources/App/AppDelegate.swift @@ -376,11 +376,19 @@ class AppDelegate: UIResponder, UIApplicationDelegate { private func setupLiveActivityReattachment() { #if canImport(ActivityKit) if #available(iOS 16.1, *) { - // Re-attach observation tasks (push token + lifecycle) to any Live Activities - // that survived the previous process termination. Must run before the first - // notification handler fires so no push token updates are missed. Task { + // Re-attach observation tasks (push token + lifecycle) to any Live Activities + // that survived the previous process termination. Must run before the first + // notification handler fires so no push token updates are missed. await Current.liveActivityRegistry.reattach() + + // Begin observing the push-to-start token stream (iOS 17.2+). + // This token allows HA to start a Live Activity entirely via APNs + // without the app being in the foreground. The stream is infinite; + // the Task is kept alive for the app's lifetime. + if #available(iOS 17.2, *) { + await Current.liveActivityRegistry.startObservingPushToStartToken() + } } } #endif diff --git a/Sources/Shared/API/HAAPI.swift b/Sources/Shared/API/HAAPI.swift index 48899f38b1..e13e2bb70e 100644 --- a/Sources/Shared/API/HAAPI.swift +++ b/Sources/Shared/API/HAAPI.swift @@ -569,6 +569,14 @@ public class HomeAssistantAPI { if #available(iOS 17.2, *) { appData["supports_live_activities_frequent_updates"] = ActivityAuthorizationInfo().frequentPushesEnabled + + // Push-to-start token (stored in Keychain at launch, updated via stream). + // The relay server uses this token to start a Live Activity entirely via + // APNs without the app being in the foreground (best-effort, iOS 17.2+). + if let pushToStartToken = LiveActivityRegistry.storedPushToStartToken { + appData["live_activity_push_to_start_token"] = pushToStartToken + appData["live_activity_push_to_start_apns_environment"] = apnsEnvironmentString() + } } #endif @@ -588,6 +596,14 @@ public class HomeAssistantAPI { } } + private func apnsEnvironmentString() -> String { + #if DEBUG + return "sandbox" + #else + return Current.isTestFlight ? "sandbox" : "production" + #endif + } + private func buildMobileAppUpdateRegistration() -> [String: Any] { let registerRequest = mobileAppRegistrationRequestModel() diff --git a/Sources/Shared/LiveActivity/LiveActivityRegistry.swift b/Sources/Shared/LiveActivity/LiveActivityRegistry.swift index 12bea734f1..964c1ba949 100644 --- a/Sources/Shared/LiveActivity/LiveActivityRegistry.swift +++ b/Sources/Shared/LiveActivity/LiveActivityRegistry.swift @@ -1,12 +1,15 @@ #if canImport(ActivityKit) import ActivityKit import Foundation +import PromiseKit @available(iOS 16.1, *) public protocol LiveActivityRegistryProtocol: AnyObject { func startOrUpdate(tag: String, title: String, state: HALiveActivityAttributes.ContentState) async throws func end(tag: String, dismissalPolicy: ActivityUIDismissalPolicy) async func reattach() async + @available(iOS 17.2, *) + func startObservingPushToStartToken() async } /// Thread-safe registry for active `Activity` instances. @@ -170,6 +173,42 @@ public actor LiveActivityRegistry: LiveActivityRegistryProtocol { } } + /// 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. + @available(iOS 17.2, *) + public func startObservingPushToStartToken() async { + for await tokenData in Activity.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. + await 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 — Observation private func makeObservationTask(for activity: Activity) -> Task { @@ -244,6 +283,19 @@ public actor LiveActivityRegistry: LiveActivityRegistryProtocol { } } + /// 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. + @available(iOS 17.2, *) + private func reportPushToStartToken(_ tokenHex: String) async { + for api in Current.apis { + firstly { + api.updateRegistration() + }.catch { error in + Current.Log.error("LiveActivityRegistry: failed to report push-to-start token: \(error)") + } + } + } + private func apnsEnvironmentString() -> String { #if DEBUG return "sandbox" From 310b8fb3dd25068ec799d24e6a849941b9e7361c Mon Sep 17 00:00:00 2001 From: rwarner Date: Wed, 18 Mar 2026 16:38:33 -0400 Subject: [PATCH 04/34] =?UTF-8?q?feat(live-activity):=20Phase=204=20?= =?UTF-8?q?=E2=80=94=20settings=20UI=20and=20privacy=20disclosure?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add LiveActivitySettingsView with status, active activities list, privacy notice, and frequent-updates section (iOS 17.2+) - Add SettingsItem.liveActivities wired into the Settings navigation - Add hasSeenLiveActivityDisclosure flag to SettingsStore - Implement showPrivacyDisclosureIfNeeded() one-time local notification shown the first time a Live Activity is started, reminding the user that lock screen content is visible without authentication 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- .../LiveActivitySettingsView.swift | 240 ++++++++++++++++++ .../App/Settings/Settings/SettingsItem.swift | 17 +- .../HandlerLiveActivity.swift | 28 ++ Sources/Shared/Settings/SettingsStore.swift | 11 + 4 files changed, 295 insertions(+), 1 deletion(-) create mode 100644 Sources/App/Settings/LiveActivity/LiveActivitySettingsView.swift diff --git a/Sources/App/Settings/LiveActivity/LiveActivitySettingsView.swift b/Sources/App/Settings/LiveActivity/LiveActivitySettingsView.swift new file mode 100644 index 0000000000..662ab0ea43 --- /dev/null +++ b/Sources/App/Settings/LiveActivity/LiveActivitySettingsView.swift @@ -0,0 +1,240 @@ +import ActivityKit +import Shared +import SwiftUI + +// MARK: - Entry point (availability wrapper) + +struct LiveActivitySettingsView: View { + var body: some View { + if #available(iOS 16.1, *) { + LiveActivitySettingsContentView() + } else { + // Unreachable in practice — the settings item is filtered out below iOS 16.1 + Text("Live Activities require iOS 16.1 or later.") + .foregroundStyle(.secondary) + .padding() + } + } +} + +// MARK: - Main content + +@available(iOS 16.1, *) +private struct LiveActivitySettingsContentView: View { + + // MARK: State + + @State private var activities: [ActivitySnapshot] = [] + @State private var authorizationEnabled: Bool = false + @State private var frequentUpdatesEnabled: Bool = false + @State private var showEndAllConfirmation = false + + // MARK: Body + + var body: some View { + List { + AppleLikeListTopRowHeader( + image: .playBoxOutlineIcon, + title: "Live Activities", + subtitle: "Real-time Home Assistant updates on your Lock Screen and Dynamic Island." + ) + + statusSection + + if activities.isEmpty { + Section("Active Activities") { + HStack { + Text("No active Live Activities") + .foregroundStyle(.secondary) + Spacer() + } + } + } else { + Section("Active Activities") { + ForEach(activities) { snapshot in + ActivityRow(snapshot: snapshot) { + endActivity(tag: snapshot.tag) + } + } + + Button(role: .destructive) { + showEndAllConfirmation = true + } label: { + Label("End All Activities", systemSymbol: .xmarkCircle) + } + .confirmationDialog( + "End all Live Activities?", + isPresented: $showEndAllConfirmation, + titleVisibility: .visible + ) { + Button("End All", role: .destructive) { + endAllActivities() + } + Button("Cancel", role: .cancel) {} + } + } + } + + privacySection + + if #available(iOS 17.2, *) { + frequentUpdatesSection + } + } + .navigationTitle("Live Activities") + .task { await loadActivities() } + } + + // MARK: - Sections + + private var statusSection: some View { + Section("Status") { + HStack { + Label("Live Activities", systemSymbol: .livephotoIcon) + Spacer() + if authorizationEnabled { + Text("Enabled") + .foregroundStyle(.green) + } else { + Button("Open Settings") { + if let url = URL(string: UIApplication.openSettingsURLString) { + UIApplication.shared.open(url) + } + } + .foregroundStyle(.orange) + } + } + } + } + + private var privacySection: some View { + Section { + Label( + "Live Activity content is visible on your Lock Screen and Dynamic Island without Face ID or Touch ID. Choose what you display carefully.", + systemSymbol: .lockShieldIcon + ) + .font(.footnote) + .foregroundStyle(.secondary) + } header: { + Text("Privacy") + } + } + + @available(iOS 17.2, *) + private var frequentUpdatesSection: some View { + Section { + HStack { + Label("Frequent Updates", systemSymbol: .boltIcon) + Spacer() + if frequentUpdatesEnabled { + Text("Enabled") + .foregroundStyle(.green) + } else { + Button("Open Settings") { + if let url = URL(string: UIApplication.openSettingsURLString) { + UIApplication.shared.open(url) + } + } + .foregroundStyle(.secondary) + } + } + } header: { + Text("Frequent Updates") + } footer: { + Text( + "Allows Home Assistant to update Live Activities up to once per second. Enable in Settings › \(Bundle.main.infoDictionary?["CFBundleDisplayName"] as? String ?? "Home Assistant") › Live Activities." + ) + } + } + + // MARK: - Data + + private func loadActivities() async { + let info = ActivityAuthorizationInfo() + authorizationEnabled = info.areActivitiesEnabled + if #available(iOS 17.2, *) { + frequentUpdatesEnabled = info.frequentPushesEnabled + } + + activities = Activity.activities.map { + ActivitySnapshot(activity: $0) + } + } + + private func endActivity(tag: String) { + Task { + await Current.liveActivityRegistry.end(tag: tag, dismissalPolicy: .immediate) + await loadActivities() + } + } + + private func endAllActivities() { + Task { + let tags = activities.map(\.tag) + for tag in tags { + await Current.liveActivityRegistry.end(tag: tag, dismissalPolicy: .immediate) + } + await loadActivities() + } + } +} + +// MARK: - Activity row + +@available(iOS 16.1, *) +private struct ActivityRow: View { + let snapshot: ActivitySnapshot + let onEnd: () -> Void + + var body: some View { + HStack { + VStack(alignment: .leading, spacing: 2) { + Text(snapshot.title) + .font(.body) + Text(snapshot.message) + .font(.caption) + .foregroundStyle(.secondary) + .lineLimit(1) + Text("tag: \(snapshot.tag)") + .font(.caption2) + .foregroundStyle(.tertiary) + .lineLimit(1) + } + Spacer() + Button(role: .destructive, action: onEnd) { + Image(systemSymbol: .xmarkCircleFill) + .foregroundStyle(.red) + } + .buttonStyle(.plain) + } + } +} + +// MARK: - Snapshot model + +@available(iOS 16.1, *) +private struct ActivitySnapshot: Identifiable { + let id: String + let tag: String + let title: String + let message: String + + init(activity: Activity) { + self.id = activity.id + self.tag = activity.attributes.tag + self.title = activity.attributes.title + if #available(iOS 16.2, *) { + self.message = activity.content.state.message + } else { + self.message = activity.contentState.message + } + } +} + +// MARK: - Preview + +#Preview { + NavigationStack { + LiveActivitySettingsView() + } +} diff --git a/Sources/App/Settings/Settings/SettingsItem.swift b/Sources/App/Settings/Settings/SettingsItem.swift index 5e71a52ab3..d5a70542ce 100644 --- a/Sources/App/Settings/Settings/SettingsItem.swift +++ b/Sources/App/Settings/Settings/SettingsItem.swift @@ -8,6 +8,7 @@ enum SettingsItem: String, Hashable, CaseIterable { case kiosk case location case notifications + case liveActivities case sensors case nfc case widgets @@ -28,6 +29,7 @@ enum SettingsItem: String, Hashable, CaseIterable { case .kiosk: return L10n.Kiosk.title case .location: return L10n.Settings.DetailsSection.LocationSettingsRow.title case .notifications: return L10n.Settings.DetailsSection.NotificationSettingsRow.title + case .liveActivities: return "Live Activities" case .sensors: return L10n.SettingsSensors.title case .nfc: return L10n.Nfc.List.title case .widgets: return L10n.Settings.Widgets.title @@ -57,6 +59,8 @@ enum SettingsItem: String, Hashable, CaseIterable { MaterialDesignIconsImage(icon: .crosshairsGpsIcon, size: 24) case .notifications: MaterialDesignIconsImage(icon: .bellOutlineIcon, size: 24) + case .liveActivities: + MaterialDesignIconsImage(icon: .playBoxOutlineIcon, size: 24) case .sensors: MaterialDesignIconsImage(icon: .formatListBulletedIcon, size: 24) case .nfc: @@ -107,6 +111,8 @@ enum SettingsItem: String, Hashable, CaseIterable { SettingsLocationView() case .notifications: SettingsNotificationsView() + case .liveActivities: + LiveActivitySettingsView() case .sensors: SensorListView() case .nfc: @@ -143,12 +149,21 @@ enum SettingsItem: String, Hashable, CaseIterable { return false } #endif + // Live Activities require iOS 16.1+ + if item == .liveActivities { + if #available(iOS 16.1, *) { return true } + return false + } return true } } static var generalItems: [SettingsItem] { - [.general, .gestures, .kiosk, .location, .notifications] + var items: [SettingsItem] = [.general, .gestures, .kiosk, .location, .notifications] + if #available(iOS 16.1, *) { + items.append(.liveActivities) + } + return items } static var integrationItems: [SettingsItem] { diff --git a/Sources/Shared/Notifications/NotificationCommands/HandlerLiveActivity.swift b/Sources/Shared/Notifications/NotificationCommands/HandlerLiveActivity.swift index 1bdcd914f3..708b684c98 100644 --- a/Sources/Shared/Notifications/NotificationCommands/HandlerLiveActivity.swift +++ b/Sources/Shared/Notifications/NotificationCommands/HandlerLiveActivity.swift @@ -2,6 +2,7 @@ import ActivityKit import Foundation import PromiseKit +import UserNotifications // MARK: - HandlerStartOrUpdateLiveActivity @@ -47,6 +48,8 @@ struct HandlerStartOrUpdateLiveActivity: NotificationCommandHandler { throw ValidationError.missingTitle } + Self.showPrivacyDisclosureIfNeeded() + let state = Self.contentState(from: payload) try await Current.liveActivityRegistry.startOrUpdate( @@ -70,6 +73,31 @@ struct HandlerStartOrUpdateLiveActivity: NotificationCommandHandler { } } + // MARK: - Privacy Disclosure + + /// Shows a one-time local notification reminding the user that Live Activity + /// content is visible on the Lock Screen without authentication. + /// Runs at most once per device; subsequent calls are no-ops. + private static func showPrivacyDisclosureIfNeeded() { + guard !Current.settingsStore.hasSeenLiveActivityDisclosure else { return } + Current.settingsStore.hasSeenLiveActivityDisclosure = true + + let content = UNMutableNotificationContent() + content.title = "Live Activity Privacy" + content.body = "Live Activity content is visible on your Lock Screen and Dynamic Island without Face ID or Touch ID. Choose what you display carefully." + + let request = UNNotificationRequest( + identifier: "live_activity_privacy_disclosure", + content: content, + trigger: nil + ) + UNUserNotificationCenter.current().add(request) { error in + if let error { + Current.Log.error("HandlerStartOrUpdateLiveActivity: failed to post privacy disclosure: \(error)") + } + } + } + // MARK: - Validation /// Tag must be alphanumeric with hyphens/underscores, max 64 characters. diff --git a/Sources/Shared/Settings/SettingsStore.swift b/Sources/Shared/Settings/SettingsStore.swift index f6b2a83083..1cc470b247 100644 --- a/Sources/Shared/Settings/SettingsStore.swift +++ b/Sources/Shared/Settings/SettingsStore.swift @@ -220,6 +220,17 @@ public class SettingsStore { } } + /// Whether the one-time Live Activity lock screen privacy disclosure has been shown. + /// Set to true after the first Live Activity is started; never reset. + public var hasSeenLiveActivityDisclosure: Bool { + get { + prefs.bool(forKey: "hasSeenLiveActivityDisclosure") + } + set { + prefs.set(newValue, forKey: "hasSeenLiveActivityDisclosure") + } + } + /// Local push becomes opt-in on 2025.6, users will have local push reset and need to re-enable it public var migratedOptInLocalPush: Bool { get { From 2e93408042aea6d2ff01e4b1bd957833dfd62c7f Mon Sep 17 00:00:00 2001 From: rwarner Date: Wed, 18 Mar 2026 20:54:25 -0400 Subject: [PATCH 05/34] refactor(live-activity): address code review findings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Architecture & correctness: - Pre-warm liveActivityRegistry on main thread before spawning Tasks to eliminate lazy-init race between concurrent callers (notification handler + reattach Task). Split reattach and startObservingPushToStartToken into two independent Tasks so an infinite stream doesn't block reattach. - Bridge HandlerClearNotification Live Activity end into the returned Promise so the background fetch window stays open until end() completes. - Use registered "live_activity" handler instance in the intercept path instead of constructing a second HandlerStartOrUpdateLiveActivity(). Deduplication & simplification: - Extract apnsEnvironmentString() to Current.apnsEnvironment, removing identical private methods from LiveActivityRegistry and HAAPI. - Remove duplicate Color(hex:)/UIColor(hex:) extensions from HALockScreenView — Shared already provides superior versions that handle CSS color names, nil, 3/4/6/8-digit hex, and log errors on bad input. - Consolidate "#03A9F4" (HA blue) to a shared haBlueHex constant used by both HALockScreenView and HADynamicIslandView. - Hoist staleDate interval to kLiveActivityStaleInterval constant (was hardcoded as 30 * 60 in three call sites). Bug fixes: - Fix progress/progressMax/when JSON number coercion: `as? Int` silently returns nil when the JSON number is Double-backed. Use NSNumber coercion so both Int and Double values decode correctly. Parse `when` as Double to preserve sub-second Unix timestamps. Correctness cleanup: - Remove dead Task.isCancelled guard in pushTokenUpdates loop — Swift cooperative cancellation exits the for-await at the suspension point, not at the next iteration. - Remove misleading async from reportPushToStartToken — it was fire-and- forget internally; removing async makes the calling convention honest. Co-Authored-By: Claude --- Sources/App/AppDelegate.swift | 21 ++++--- .../LiveActivity/HADynamicIslandView.swift | 5 +- .../LiveActivity/HALockScreenView.swift | 62 ++----------------- Sources/Shared/API/HAAPI.swift | 10 +-- Sources/Shared/Environment/Environment.swift | 11 ++++ .../LiveActivity/LiveActivityRegistry.swift | 30 ++++----- .../HandlerLiveActivity.swift | 14 +++-- .../NotificationsCommandManager.swift | 17 +++-- 8 files changed, 66 insertions(+), 104 deletions(-) diff --git a/Sources/App/AppDelegate.swift b/Sources/App/AppDelegate.swift index d61084634a..205bdd8632 100644 --- a/Sources/App/AppDelegate.swift +++ b/Sources/App/AppDelegate.swift @@ -376,18 +376,23 @@ class AppDelegate: UIResponder, UIApplicationDelegate { private func setupLiveActivityReattachment() { #if canImport(ActivityKit) if #available(iOS 16.1, *) { + // Pre-warm the registry on the main thread before spawning background Tasks. + // This avoids a lazy-init race if a push notification handler accesses it + // concurrently from a background thread. + let registry = Current.liveActivityRegistry + Task { // Re-attach observation tasks (push token + lifecycle) to any Live Activities // that survived the previous process termination. Must run before the first // notification handler fires so no push token updates are missed. - await Current.liveActivityRegistry.reattach() - - // Begin observing the push-to-start token stream (iOS 17.2+). - // This token allows HA to start a Live Activity entirely via APNs - // without the app being in the foreground. The stream is infinite; - // the Task is kept alive for the app's lifetime. - if #available(iOS 17.2, *) { - await Current.liveActivityRegistry.startObservingPushToStartToken() + await registry.reattach() + } + + if #available(iOS 17.2, *) { + // Begin observing the push-to-start token stream on a separate Task. + // The stream is infinite; this Task is kept alive for the app's lifetime. + Task { + await registry.startObservingPushToStartToken() } } } diff --git a/Sources/Extensions/Widgets/LiveActivity/HADynamicIslandView.swift b/Sources/Extensions/Widgets/LiveActivity/HADynamicIslandView.swift index 3b186edd86..0a040b93b2 100644 --- a/Sources/Extensions/Widgets/LiveActivity/HADynamicIslandView.swift +++ b/Sources/Extensions/Widgets/LiveActivity/HADynamicIslandView.swift @@ -53,7 +53,8 @@ struct HADynamicIslandIconView: View { var body: some View { if let slug, let mdiIcon = MaterialDesignIcons(serversideValueNamed: slug) { - let uiColor = color.flatMap { UIColor(hex: $0) } ?? UIColor(Color(hex: "#03A9F4")) + // UIColor(hex:) from Shared handles nil/CSS names/3-6-8 digit hex; non-failable. + let uiColor = UIColor(hex: color ?? haBlueHex) Image(uiImage: mdiIcon.image( ofSize: .init(width: size, height: size), color: uiColor @@ -134,7 +135,7 @@ struct HAExpandedBottomView: View { if let fraction = state.progressFraction { ProgressView(value: fraction) - .tint(Color(hex: state.color ?? "#03A9F4")) + .tint(Color(hex: state.color ?? haBlueHex)) } } } diff --git a/Sources/Extensions/Widgets/LiveActivity/HALockScreenView.swift b/Sources/Extensions/Widgets/LiveActivity/HALockScreenView.swift index 2e7d92b384..62da4cc466 100644 --- a/Sources/Extensions/Widgets/LiveActivity/HALockScreenView.swift +++ b/Sources/Extensions/Widgets/LiveActivity/HALockScreenView.swift @@ -52,7 +52,8 @@ struct HALockScreenView: View { private var iconView: some View { if let iconSlug = state.icon, let mdiIcon = MaterialDesignIcons(serversideValueNamed: iconSlug) { - let uiColor = UIColor(hex: state.color ?? "#03A9F4") ?? .white + // UIColor(hex:) from Shared handles CSS names and 3/6/8-digit hex; non-failable. + let uiColor = UIColor(hex: state.color ?? haBlueHex) Image(uiImage: mdiIcon.image( ofSize: .init(width: 20, height: 20), color: uiColor @@ -66,65 +67,14 @@ struct HALockScreenView: View { /// Parse hex color from ContentState, fallback to Home Assistant blue. private var accentColor: Color { - guard let hex = state.color else { - return Color(hex: "#03A9F4") // HA blue - } - return Color(hex: hex) + Color(hex: state.color ?? haBlueHex) } } -// MARK: - Color(hex:) + UIColor(hex:) extensions - -extension Color { - /// Initialize from a hex string like `#RRGGBB` or `#RRGGBBAA`. - /// Pre-parsing here prevents hex string work in every render pass. - init(hex: String) { - let hex = hex.trimmingCharacters(in: CharacterSet.alphanumerics.inverted) - var int: UInt64 = 0 - Scanner(string: hex).scanHexInt64(&int) - let a, r, g, b: UInt64 - switch hex.count { - case 3: // RGB (12-bit) - (a, r, g, b) = (255, (int >> 8) * 17, (int >> 4 & 0xF) * 17, (int & 0xF) * 17) - case 6: // RRGGBB - (a, r, g, b) = (255, int >> 16, int >> 8 & 0xFF, int & 0xFF) - case 8: // RRGGBBAA - (a, r, g, b) = (int >> 24, int >> 16 & 0xFF, int >> 8 & 0xFF, int & 0xFF) - default: - (a, r, g, b) = (255, 0, 0, 0) - } - self.init( - .sRGB, - red: Double(r) / 255, - green: Double(g) / 255, - blue: Double(b) / 255, - opacity: Double(a) / 255 - ) - } -} +// MARK: - Constants -extension UIColor { - convenience init?(hex: String) { - let hex = hex.trimmingCharacters(in: CharacterSet.alphanumerics.inverted) - var int: UInt64 = 0 - guard Scanner(string: hex).scanHexInt64(&int) else { return nil } - let r, g, b: UInt64 - switch hex.count { - case 3: - (r, g, b) = ((int >> 8) * 17, (int >> 4 & 0xF) * 17, (int & 0xF) * 17) - case 6: - (r, g, b) = (int >> 16, int >> 8 & 0xFF, int & 0xFF) - default: - return nil - } - self.init( - red: CGFloat(r) / 255, - green: CGFloat(g) / 255, - blue: CGFloat(b) / 255, - alpha: 1 - ) - } -} +/// Home Assistant brand blue — used as fallback for icon and progress bar tints. +let haBlueHex = "#03A9F4" // MARK: - Preview diff --git a/Sources/Shared/API/HAAPI.swift b/Sources/Shared/API/HAAPI.swift index e13e2bb70e..1c770e96a7 100644 --- a/Sources/Shared/API/HAAPI.swift +++ b/Sources/Shared/API/HAAPI.swift @@ -575,7 +575,7 @@ public class HomeAssistantAPI { // APNs without the app being in the foreground (best-effort, iOS 17.2+). if let pushToStartToken = LiveActivityRegistry.storedPushToStartToken { appData["live_activity_push_to_start_token"] = pushToStartToken - appData["live_activity_push_to_start_apns_environment"] = apnsEnvironmentString() + appData["live_activity_push_to_start_apns_environment"] = Current.apnsEnvironment } } #endif @@ -596,14 +596,6 @@ public class HomeAssistantAPI { } } - private func apnsEnvironmentString() -> String { - #if DEBUG - return "sandbox" - #else - return Current.isTestFlight ? "sandbox" : "production" - #endif - } - private func buildMobileAppUpdateRegistration() -> [String: Any] { let registerRequest = mobileAppRegistrationRequestModel() diff --git a/Sources/Shared/Environment/Environment.swift b/Sources/Shared/Environment/Environment.swift index 057ebe31e0..e22e07e069 100644 --- a/Sources/Shared/Environment/Environment.swift +++ b/Sources/Shared/Environment/Environment.swift @@ -130,10 +130,21 @@ public class AppEnvironment { AreasService.shared } + /// APNs environment string for token reporting. "sandbox" in DEBUG/TestFlight, "production" otherwise. + public var apnsEnvironment: String { + #if DEBUG + return "sandbox" + #else + return isTestFlight ? "sandbox" : "production" + #endif + } + #if os(iOS) #if canImport(ActivityKit) // Backing store uses Any? to work around Swift's @available stored property restriction. // Access via the typed `liveActivityRegistry` computed property. + // Call `_ = Current.liveActivityRegistry` on the main thread at launch (before any + // background thread can access it) to avoid a lazy-init race between concurrent callers. private var _liveActivityRegistryBacking: Any? @available(iOS 16.1, *) diff --git a/Sources/Shared/LiveActivity/LiveActivityRegistry.swift b/Sources/Shared/LiveActivity/LiveActivityRegistry.swift index 964c1ba949..5aa9bc6ffb 100644 --- a/Sources/Shared/LiveActivity/LiveActivityRegistry.swift +++ b/Sources/Shared/LiveActivity/LiveActivityRegistry.swift @@ -3,6 +3,10 @@ import ActivityKit import Foundation import PromiseKit +// 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 + @available(iOS 16.1, *) public protocol LiveActivityRegistryProtocol: AnyObject { func startOrUpdate(tag: String, title: String, state: HALiveActivityAttributes.ContentState) async throws @@ -77,7 +81,7 @@ public actor LiveActivityRegistry: LiveActivityRegistryProtocol { if #available(iOS 16.2, *) { let content = ActivityContent( state: state, - staleDate: Date().addingTimeInterval(30 * 60) + staleDate: Date().addingTimeInterval(kLiveActivityStaleInterval) ) await existing.activity.update(content) } else { @@ -92,7 +96,7 @@ public actor LiveActivityRegistry: LiveActivityRegistryProtocol { if #available(iOS 16.2, *) { let content = ActivityContent( state: state, - staleDate: Date().addingTimeInterval(30 * 60) + staleDate: Date().addingTimeInterval(kLiveActivityStaleInterval) ) await live.update(content) } else { @@ -122,7 +126,7 @@ public actor LiveActivityRegistry: LiveActivityRegistryProtocol { if #available(iOS 16.2, *) { let content = ActivityContent( state: state, - staleDate: Date().addingTimeInterval(30 * 60), + staleDate: Date().addingTimeInterval(kLiveActivityStaleInterval), relevanceScore: 0.5 ) activity = try Activity.request( @@ -195,7 +199,7 @@ public actor LiveActivityRegistry: LiveActivityRegistryProtocol { // Report to all HA servers via registration update so the token is available // in the HA device registry immediately. - await reportPushToStartToken(tokenHex) + reportPushToStartToken(tokenHex) } } @@ -217,7 +221,6 @@ public actor LiveActivityRegistry: LiveActivityRegistryProtocol { // Observe push token updates — report each new token to all HA servers group.addTask { for await tokenData in activity.pushTokenUpdates { - guard !Task.isCancelled else { break } let tokenHex = tokenData.map { String(format: "%02x", $0) }.joined() Current.Log.verbose( "LiveActivityRegistry: new push token for tag \(activity.attributes.tag)" @@ -259,7 +262,7 @@ public actor LiveActivityRegistry: LiveActivityRegistryProtocol { data: [ "activity_id": activityID, "push_token": tokenHex, - "apns_environment": apnsEnvironmentString(), + "apns_environment": Current.apnsEnvironment, ] ) for server in Current.servers.all { @@ -285,23 +288,14 @@ public actor LiveActivityRegistry: LiveActivityRegistryProtocol { /// 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. @available(iOS 17.2, *) - private func reportPushToStartToken(_ tokenHex: String) async { + private func reportPushToStartToken(_ tokenHex: String) { for api in Current.apis { - firstly { - api.updateRegistration() - }.catch { error in + api.updateRegistration().catch { error in Current.Log.error("LiveActivityRegistry: failed to report push-to-start token: \(error)") } } } - - private func apnsEnvironmentString() -> String { - #if DEBUG - return "sandbox" - #else - return Current.isTestFlight ? "sandbox" : "production" - #endif - } } #endif diff --git a/Sources/Shared/Notifications/NotificationCommands/HandlerLiveActivity.swift b/Sources/Shared/Notifications/NotificationCommands/HandlerLiveActivity.swift index 708b684c98..1294afa584 100644 --- a/Sources/Shared/Notifications/NotificationCommands/HandlerLiveActivity.swift +++ b/Sources/Shared/Notifications/NotificationCommands/HandlerLiveActivity.swift @@ -113,20 +113,22 @@ struct HandlerStartOrUpdateLiveActivity: NotificationCommandHandler { static func contentState(from payload: [String: Any]) -> HALiveActivityAttributes.ContentState { let message = payload["message"] as? String ?? "" let criticalText = payload["critical_text"] as? String - let progress = payload["progress"] as? Int - let progressMax = payload["progress_max"] as? Int + // 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 + // `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? Int { + if let when = (payload["when"] as? NSNumber).map({ $0.doubleValue }) { let whenRelative = payload["when_relative"] as? Bool ?? false if whenRelative { - countdownEnd = Date().addingTimeInterval(Double(when)) + countdownEnd = Date().addingTimeInterval(when) } else { - countdownEnd = Date(timeIntervalSince1970: Double(when)) + countdownEnd = Date(timeIntervalSince1970: when) } } diff --git a/Sources/Shared/Notifications/NotificationCommands/NotificationsCommandManager.swift b/Sources/Shared/Notifications/NotificationCommands/NotificationsCommandManager.swift index b6097f56f7..1fc8f17e19 100644 --- a/Sources/Shared/Notifications/NotificationCommands/NotificationsCommandManager.swift +++ b/Sources/Shared/Notifications/NotificationCommands/NotificationsCommandManager.swift @@ -50,8 +50,9 @@ public class NotificationCommandManager { // This allows the notification body to be a real message instead of a command keyword, // matching Android's data.live_update: true pattern. #if canImport(ActivityKit) - if #available(iOS 16.1, *), hadict["live_activity"] as? Bool == true { - return HandlerStartOrUpdateLiveActivity().handle(hadict) + if #available(iOS 16.1, *), hadict["live_activity"] as? Bool == true, + let handler = commands["live_activity"] { + return handler.handle(hadict) } #endif @@ -108,11 +109,17 @@ private struct HandlerClearNotification: NotificationCommandHandler { UNUserNotificationCenter.current().removeDeliveredNotifications(withIdentifiers: keys) } - // Also end any Live Activity whose tag matches — same YAML works on both iOS and Android + // Also end any Live Activity whose tag matches — same YAML works on both iOS and Android. + // Bridged into the returned Promise so the background fetch window stays open until + // the activity is actually dismissed (prevents the OS suspending mid-dismiss). #if os(iOS) && canImport(ActivityKit) if #available(iOS 16.1, *), let tag = payload["tag"] as? String { - Task { - await Current.liveActivityRegistry.end(tag: tag, dismissalPolicy: .immediate) + return Promise { seal in + Task { + await Current.liveActivityRegistry.end(tag: tag, dismissalPolicy: .immediate) + // https://stackoverflow.com/a/56657888/6324550 + DispatchQueue.main.async { seal.fulfill(()) } + } } } #endif From 072be0694bf567385e93baa0f003498acc234123 Mon Sep 17 00:00:00 2001 From: rwarner Date: Wed, 18 Mar 2026 21:06:03 -0400 Subject: [PATCH 06/34] refactor(live-activity): localization, remove availability wrapper, improve privacy disclosure MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add L10n keys (live_activity.*) to Localizable.strings and Strings.swift for all user-facing strings in LiveActivitySettingsView and SettingsItem - Wire L10n.LiveActivity.* throughout LiveActivitySettingsView and SettingsItem - Remove LiveActivitySettingsView availability wrapper — deployment target is iOS 15, the settings item is already filtered out below iOS 16.1 in allVisibleCases, so the unreachable fallback Text() added noise without providing safety - Replace UNNotificationRequest privacy disclosure with flag-only recording: a local notification silently fails when notification permission is not granted, meaning the users who need the warning most (new users) never see it. The permanent privacy section in LiveActivitySettingsView is the correct disclosure surface. Co-Authored-By: Claude --- .../Resources/en.lproj/Localizable.strings | 16 ++++ .../LiveActivitySettingsView.swift | 79 ++++++++----------- .../App/Settings/Settings/SettingsItem.swift | 6 +- .../HandlerLiveActivity.swift | 23 +----- .../Shared/Resources/Swiftgen/Strings.swift | 43 ++++++++++ 5 files changed, 99 insertions(+), 68 deletions(-) diff --git a/Sources/App/Resources/en.lproj/Localizable.strings b/Sources/App/Resources/en.lproj/Localizable.strings index 0b2766760f..deb2f225bf 100644 --- a/Sources/App/Resources/en.lproj/Localizable.strings +++ b/Sources/App/Resources/en.lproj/Localizable.strings @@ -498,6 +498,22 @@ This server requires a client certificate (mTLS) but the operation was cancelled "kiosk.security.taps_required" = "Taps Required: %li"; "kiosk.title" = "Kiosk Mode"; "legacy_actions.disclaimer" = "Legacy iOS Actions are not the recommended way to interact with Home Assistant anymore, please use Scripts, Scenes and Automations directly in your Widgets, Apple Watch and CarPlay."; + +"live_activity.title" = "Live Activities"; +"live_activity.subtitle" = "Real-time Home Assistant updates on your Lock Screen and Dynamic Island."; +"live_activity.section.active" = "Active Activities"; +"live_activity.section.status" = "Status"; +"live_activity.section.privacy" = "Privacy"; +"live_activity.empty_state" = "No active Live Activities"; +"live_activity.status.enabled" = "Enabled"; +"live_activity.status.open_settings" = "Open Settings"; +"live_activity.end_all.button" = "End All Activities"; +"live_activity.end_all.confirm.title" = "End all Live Activities?"; +"live_activity.end_all.confirm.button" = "End All"; +"live_activity.privacy.message" = "Live Activity content is visible on your Lock Screen and Dynamic Island without Face ID or Touch ID. Choose what you display carefully."; +"live_activity.frequent_updates.title" = "Frequent Updates"; +"live_activity.frequent_updates.footer" = "Allows Home Assistant to update Live Activities up to once per second. Enable in Settings \u203A %@ \u203A Live Activities."; + "location_change_notification.app_shortcut.body" = "Location updated via App Shortcut"; "location_change_notification.background_fetch.body" = "Current location delivery triggered via background fetch"; "location_change_notification.beacon_region_enter.body" = "%@ entered via iBeacon"; diff --git a/Sources/App/Settings/LiveActivity/LiveActivitySettingsView.swift b/Sources/App/Settings/LiveActivity/LiveActivitySettingsView.swift index 662ab0ea43..0250f6d8ea 100644 --- a/Sources/App/Settings/LiveActivity/LiveActivitySettingsView.swift +++ b/Sources/App/Settings/LiveActivity/LiveActivitySettingsView.swift @@ -2,25 +2,12 @@ import ActivityKit import Shared import SwiftUI -// MARK: - Entry point (availability wrapper) - -struct LiveActivitySettingsView: View { - var body: some View { - if #available(iOS 16.1, *) { - LiveActivitySettingsContentView() - } else { - // Unreachable in practice — the settings item is filtered out below iOS 16.1 - Text("Live Activities require iOS 16.1 or later.") - .foregroundStyle(.secondary) - .padding() - } - } -} - -// MARK: - Main content +// MARK: - Entry point +// Deployment target is iOS 15. The settings item is filtered from the list on < iOS 16.1 +// (see SettingsItem.allVisibleCases), so this view is only ever navigated to on iOS 16.1+. @available(iOS 16.1, *) -private struct LiveActivitySettingsContentView: View { +struct LiveActivitySettingsView: View { // MARK: State @@ -35,22 +22,22 @@ private struct LiveActivitySettingsContentView: View { List { AppleLikeListTopRowHeader( image: .playBoxOutlineIcon, - title: "Live Activities", - subtitle: "Real-time Home Assistant updates on your Lock Screen and Dynamic Island." + title: L10n.LiveActivity.title, + subtitle: L10n.LiveActivity.subtitle ) statusSection if activities.isEmpty { - Section("Active Activities") { + Section(L10n.LiveActivity.Section.active) { HStack { - Text("No active Live Activities") + Text(L10n.LiveActivity.emptyState) .foregroundStyle(.secondary) Spacer() } } } else { - Section("Active Activities") { + Section(L10n.LiveActivity.Section.active) { ForEach(activities) { snapshot in ActivityRow(snapshot: snapshot) { endActivity(tag: snapshot.tag) @@ -60,17 +47,17 @@ private struct LiveActivitySettingsContentView: View { Button(role: .destructive) { showEndAllConfirmation = true } label: { - Label("End All Activities", systemSymbol: .xmarkCircle) + Label(L10n.LiveActivity.EndAll.button, systemSymbol: .xmarkCircle) } .confirmationDialog( - "End all Live Activities?", + L10n.LiveActivity.EndAll.confirmTitle, isPresented: $showEndAllConfirmation, titleVisibility: .visible ) { - Button("End All", role: .destructive) { + Button(L10n.LiveActivity.EndAll.confirmButton, role: .destructive) { endAllActivities() } - Button("Cancel", role: .cancel) {} + Button(L10n.cancelLabel, role: .cancel) {} } } } @@ -81,22 +68,22 @@ private struct LiveActivitySettingsContentView: View { frequentUpdatesSection } } - .navigationTitle("Live Activities") + .navigationTitle(L10n.LiveActivity.title) .task { await loadActivities() } } // MARK: - Sections private var statusSection: some View { - Section("Status") { + Section(L10n.LiveActivity.Section.status) { HStack { - Label("Live Activities", systemSymbol: .livephotoIcon) + Label(L10n.LiveActivity.title, systemSymbol: .livephotoIcon) Spacer() if authorizationEnabled { - Text("Enabled") + Text(L10n.LiveActivity.Status.enabled) .foregroundStyle(.green) } else { - Button("Open Settings") { + Button(L10n.LiveActivity.Status.openSettings) { if let url = URL(string: UIApplication.openSettingsURLString) { UIApplication.shared.open(url) } @@ -109,28 +96,26 @@ private struct LiveActivitySettingsContentView: View { private var privacySection: some View { Section { - Label( - "Live Activity content is visible on your Lock Screen and Dynamic Island without Face ID or Touch ID. Choose what you display carefully.", - systemSymbol: .lockShieldIcon - ) - .font(.footnote) - .foregroundStyle(.secondary) + Label(L10n.LiveActivity.Privacy.message, systemSymbol: .lockShieldIcon) + .font(.footnote) + .foregroundStyle(.secondary) } header: { - Text("Privacy") + Text(L10n.LiveActivity.Section.privacy) } } @available(iOS 17.2, *) private var frequentUpdatesSection: some View { - Section { + let appName = Bundle.main.infoDictionary?["CFBundleDisplayName"] as? String ?? "Home Assistant" + return Section { HStack { - Label("Frequent Updates", systemSymbol: .boltIcon) + Label(L10n.LiveActivity.FrequentUpdates.title, systemSymbol: .boltIcon) Spacer() if frequentUpdatesEnabled { - Text("Enabled") + Text(L10n.LiveActivity.Status.enabled) .foregroundStyle(.green) } else { - Button("Open Settings") { + Button(L10n.LiveActivity.Status.openSettings) { if let url = URL(string: UIApplication.openSettingsURLString) { UIApplication.shared.open(url) } @@ -139,11 +124,9 @@ private struct LiveActivitySettingsContentView: View { } } } header: { - Text("Frequent Updates") + Text(L10n.LiveActivity.FrequentUpdates.title) } footer: { - Text( - "Allows Home Assistant to update Live Activities up to once per second. Enable in Settings › \(Bundle.main.infoDictionary?["CFBundleDisplayName"] as? String ?? "Home Assistant") › Live Activities." - ) + Text(L10n.LiveActivity.FrequentUpdates.footer(appName)) } } @@ -235,6 +218,8 @@ private struct ActivitySnapshot: Identifiable { #Preview { NavigationStack { - LiveActivitySettingsView() + if #available(iOS 16.1, *) { + LiveActivitySettingsView() + } } } diff --git a/Sources/App/Settings/Settings/SettingsItem.swift b/Sources/App/Settings/Settings/SettingsItem.swift index d5a70542ce..ed4ce73237 100644 --- a/Sources/App/Settings/Settings/SettingsItem.swift +++ b/Sources/App/Settings/Settings/SettingsItem.swift @@ -29,7 +29,7 @@ enum SettingsItem: String, Hashable, CaseIterable { case .kiosk: return L10n.Kiosk.title case .location: return L10n.Settings.DetailsSection.LocationSettingsRow.title case .notifications: return L10n.Settings.DetailsSection.NotificationSettingsRow.title - case .liveActivities: return "Live Activities" + case .liveActivities: return L10n.LiveActivity.title case .sensors: return L10n.SettingsSensors.title case .nfc: return L10n.Nfc.List.title case .widgets: return L10n.Settings.Widgets.title @@ -112,7 +112,9 @@ enum SettingsItem: String, Hashable, CaseIterable { case .notifications: SettingsNotificationsView() case .liveActivities: - LiveActivitySettingsView() + if #available(iOS 16.1, *) { + LiveActivitySettingsView() + } case .sensors: SensorListView() case .nfc: diff --git a/Sources/Shared/Notifications/NotificationCommands/HandlerLiveActivity.swift b/Sources/Shared/Notifications/NotificationCommands/HandlerLiveActivity.swift index 1294afa584..06da02fa86 100644 --- a/Sources/Shared/Notifications/NotificationCommands/HandlerLiveActivity.swift +++ b/Sources/Shared/Notifications/NotificationCommands/HandlerLiveActivity.swift @@ -2,7 +2,6 @@ import ActivityKit import Foundation import PromiseKit -import UserNotifications // MARK: - HandlerStartOrUpdateLiveActivity @@ -75,27 +74,13 @@ struct HandlerStartOrUpdateLiveActivity: NotificationCommandHandler { // MARK: - Privacy Disclosure - /// Shows a one-time local notification reminding the user that Live Activity - /// content is visible on the Lock Screen without authentication. - /// Runs at most once per device; subsequent calls are no-ops. + /// 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 - - let content = UNMutableNotificationContent() - content.title = "Live Activity Privacy" - content.body = "Live Activity content is visible on your Lock Screen and Dynamic Island without Face ID or Touch ID. Choose what you display carefully." - - let request = UNNotificationRequest( - identifier: "live_activity_privacy_disclosure", - content: content, - trigger: nil - ) - UNUserNotificationCenter.current().add(request) { error in - if let error { - Current.Log.error("HandlerStartOrUpdateLiveActivity: failed to post privacy disclosure: \(error)") - } - } } // MARK: - Validation diff --git a/Sources/Shared/Resources/Swiftgen/Strings.swift b/Sources/Shared/Resources/Swiftgen/Strings.swift index 90763874a8..b992fdbda0 100644 --- a/Sources/Shared/Resources/Swiftgen/Strings.swift +++ b/Sources/Shared/Resources/Swiftgen/Strings.swift @@ -1846,6 +1846,49 @@ public enum L10n { public static var disclaimer: String { return L10n.tr("Localizable", "legacy_actions.disclaimer") } } + public enum LiveActivity { + /// Live Activities + public static var title: String { return L10n.tr("Localizable", "live_activity.title") } + /// Real-time Home Assistant updates on your Lock Screen and Dynamic Island. + public static var subtitle: String { return L10n.tr("Localizable", "live_activity.subtitle") } + /// No active Live Activities + public static var emptyState: String { return L10n.tr("Localizable", "live_activity.empty_state") } + public enum Section { + /// Active Activities + public static var active: String { return L10n.tr("Localizable", "live_activity.section.active") } + /// Status + public static var status: String { return L10n.tr("Localizable", "live_activity.section.status") } + /// Privacy + public static var privacy: String { return L10n.tr("Localizable", "live_activity.section.privacy") } + } + public enum Status { + /// Enabled + public static var enabled: String { return L10n.tr("Localizable", "live_activity.status.enabled") } + /// Open Settings + public static var openSettings: String { return L10n.tr("Localizable", "live_activity.status.open_settings") } + } + public enum EndAll { + /// End All Activities + public static var button: String { return L10n.tr("Localizable", "live_activity.end_all.button") } + /// End all Live Activities? + public static var confirmTitle: String { return L10n.tr("Localizable", "live_activity.end_all.confirm.title") } + /// End All + public static var confirmButton: String { return L10n.tr("Localizable", "live_activity.end_all.confirm.button") } + } + public enum Privacy { + /// Live Activity content is visible on your Lock Screen and Dynamic Island without Face ID or Touch ID. Choose what you display carefully. + public static var message: String { return L10n.tr("Localizable", "live_activity.privacy.message") } + } + public enum FrequentUpdates { + /// Frequent Updates + public static var title: String { return L10n.tr("Localizable", "live_activity.frequent_updates.title") } + /// Allows Home Assistant to update Live Activities up to once per second. Enable in Settings › %@ › Live Activities. + public static func footer(_ p1: Any) -> String { + return L10n.tr("Localizable", "live_activity.frequent_updates.footer", String(describing: p1)) + } + } + } + public enum LocationChangeNotification { /// Location change public static var title: String { return L10n.tr("Localizable", "location_change_notification.title") } From d4b7a995eaf1e4ad9ffbba85e5c9b2c62626abc0 Mon Sep 17 00:00:00 2001 From: rwarner Date: Wed, 18 Mar 2026 21:06:56 -0400 Subject: [PATCH 07/34] fix(live-activity): address security and performance review findings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Security (P3): - Apply isValidTag() to HandlerEndLiveActivity — end handler now rejects invalid tags the same way the start handler does (consistency fix) - Cap dismissal_policy "after:" to 24 hours maximum — iOS enforces its own ceiling but this is defensive against future OS changes Performance (P2): - Run endAllActivities() concurrently via withTaskGroup instead of sequentially awaiting each end() call across the actor boundary Co-Authored-By: Claude --- .../LiveActivity/LiveActivitySettingsView.swift | 8 ++++++-- .../NotificationCommands/HandlerLiveActivity.swift | 13 ++++++++----- 2 files changed, 14 insertions(+), 7 deletions(-) diff --git a/Sources/App/Settings/LiveActivity/LiveActivitySettingsView.swift b/Sources/App/Settings/LiveActivity/LiveActivitySettingsView.swift index 0250f6d8ea..c06ae31309 100644 --- a/Sources/App/Settings/LiveActivity/LiveActivitySettingsView.swift +++ b/Sources/App/Settings/LiveActivity/LiveActivitySettingsView.swift @@ -154,8 +154,12 @@ struct LiveActivitySettingsView: View { private func endAllActivities() { Task { let tags = activities.map(\.tag) - for tag in tags { - await Current.liveActivityRegistry.end(tag: tag, dismissalPolicy: .immediate) + await withTaskGroup(of: Void.self) { group in + for tag in tags { + group.addTask { + await Current.liveActivityRegistry.end(tag: tag, dismissalPolicy: .immediate) + } + } } await loadActivities() } diff --git a/Sources/Shared/Notifications/NotificationCommands/HandlerLiveActivity.swift b/Sources/Shared/Notifications/NotificationCommands/HandlerLiveActivity.swift index 06da02fa86..d3fd995c92 100644 --- a/Sources/Shared/Notifications/NotificationCommands/HandlerLiveActivity.swift +++ b/Sources/Shared/Notifications/NotificationCommands/HandlerLiveActivity.swift @@ -85,9 +85,7 @@ struct HandlerStartOrUpdateLiveActivity: NotificationCommandHandler { // MARK: - Validation - /// Tag must be alphanumeric with hyphens/underscores, max 64 characters. - /// Matches the safe subset of APNs collapse identifiers. - private static func isValidTag(_ tag: String) -> Bool { + static func isValidTag(_ tag: String) -> Bool { guard tag.count <= 64 else { return false } let allowed = CharacterSet.alphanumerics.union(CharacterSet(charactersIn: "-_")) return tag.unicodeScalars.allSatisfy { allowed.contains($0) } @@ -143,7 +141,8 @@ struct HandlerEndLiveActivity: NotificationCommandHandler { return Promise { seal in Task { - guard let tag = payload["tag"] as? String, !tag.isEmpty else { + guard let tag = payload["tag"] as? String, !tag.isEmpty, + HandlerStartOrUpdateLiveActivity.isValidTag(tag) else { seal.fulfill(()) return } @@ -162,7 +161,11 @@ struct HandlerEndLiveActivity: NotificationCommandHandler { case let str where str?.hasPrefix("after:") == true: if let timestampStr = str?.dropFirst(6), let timestamp = Double(timestampStr) { - return .after(Date(timeIntervalSince1970: timestamp)) + // 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: From f2e3e973b9db293dbb1085a94931b4458cef692e Mon Sep 17 00:00:00 2001 From: rwarner Date: Wed, 18 Mar 2026 21:20:28 -0400 Subject: [PATCH 08/34] style: apply SwiftFormat to live activity files Auto-formatted by swiftformat --config .swiftformat. Changes include argument wrapping, Preview macro formatting, and comment block style. Co-Authored-By: Claude --- .../LiveActivitySettingsView.swift | 5 +- .../LiveActivity/HADynamicIslandView.swift | 6 +- .../LiveActivity/HALockScreenView.swift | 12 +- Sources/Shared/API/HAAPI.swift | 15 +- Sources/Shared/Environment/Environment.swift | 23 +- .../LiveActivity/LiveActivityRegistry.swift | 5 +- .../HandlerLiveActivity.swift | 7 +- ...03-18-001-feat-ios-live-activities-plan.md | 1044 +++++++++++++++++ 8 files changed, 1092 insertions(+), 25 deletions(-) create mode 100644 docs/plans/2026-03-18-001-feat-ios-live-activities-plan.md diff --git a/Sources/App/Settings/LiveActivity/LiveActivitySettingsView.swift b/Sources/App/Settings/LiveActivity/LiveActivitySettingsView.swift index c06ae31309..eebbe954eb 100644 --- a/Sources/App/Settings/LiveActivity/LiveActivitySettingsView.swift +++ b/Sources/App/Settings/LiveActivity/LiveActivitySettingsView.swift @@ -4,11 +4,10 @@ import SwiftUI // MARK: - Entry point -// Deployment target is iOS 15. The settings item is filtered from the list on < iOS 16.1 -// (see SettingsItem.allVisibleCases), so this view is only ever navigated to on iOS 16.1+. +/// Deployment target is iOS 15. The settings item is filtered from the list on < iOS 16.1 +/// (see SettingsItem.allVisibleCases), so this view is only ever navigated to on iOS 16.1+. @available(iOS 16.1, *) struct LiveActivitySettingsView: View { - // MARK: State @State private var activities: [ActivitySnapshot] = [] diff --git a/Sources/Extensions/Widgets/LiveActivity/HADynamicIslandView.swift b/Sources/Extensions/Widgets/LiveActivity/HADynamicIslandView.swift index 0a040b93b2..4a18fd0793 100644 --- a/Sources/Extensions/Widgets/LiveActivity/HADynamicIslandView.swift +++ b/Sources/Extensions/Widgets/LiveActivity/HADynamicIslandView.swift @@ -159,7 +159,11 @@ struct HAExpandedBottomView: View { } @available(iOS 16.2, *) -#Preview("Expanded", as: .dynamicIsland(.expanded), using: HALiveActivityAttributes(tag: "washer", title: "Washing Machine")) { +#Preview( + "Expanded", + as: .dynamicIsland(.expanded), + using: HALiveActivityAttributes(tag: "washer", title: "Washing Machine") +) { HALiveActivityConfiguration() } contentStates: { HALiveActivityAttributes.ContentState( diff --git a/Sources/Extensions/Widgets/LiveActivity/HALockScreenView.swift b/Sources/Extensions/Widgets/LiveActivity/HALockScreenView.swift index 62da4cc466..18ce4ec3d2 100644 --- a/Sources/Extensions/Widgets/LiveActivity/HALockScreenView.swift +++ b/Sources/Extensions/Widgets/LiveActivity/HALockScreenView.swift @@ -80,7 +80,11 @@ let haBlueHex = "#03A9F4" #if DEBUG @available(iOS 16.2, *) -#Preview("Lock Screen — Progress", as: .content, using: HALiveActivityAttributes(tag: "washer", title: "Washing Machine")) { +#Preview( + "Lock Screen — Progress", + as: .content, + using: HALiveActivityAttributes(tag: "washer", title: "Washing Machine") +) { HALiveActivityConfiguration() } contentStates: { HALiveActivityAttributes.ContentState( @@ -101,7 +105,11 @@ let haBlueHex = "#03A9F4" } @available(iOS 16.2, *) -#Preview("Lock Screen — Chronometer", as: .content, using: HALiveActivityAttributes(tag: "timer", title: "Kitchen Timer")) { +#Preview( + "Lock Screen — Chronometer", + as: .content, + using: HALiveActivityAttributes(tag: "timer", title: "Kitchen Timer") +) { HALiveActivityConfiguration() } contentStates: { HALiveActivityAttributes.ContentState( diff --git a/Sources/Shared/API/HAAPI.swift b/Sources/Shared/API/HAAPI.swift index 1c770e96a7..20066e8210 100644 --- a/Sources/Shared/API/HAAPI.swift +++ b/Sources/Shared/API/HAAPI.swift @@ -64,7 +64,7 @@ public class HomeAssistantAPI { return "Home Assistant/\(appVersion) (\(bundle); build:\(appBuild); \(osNameVersion))" } - // "Mobile/BUILD_NUMBER" is what CodeMirror sniffs for to decide iOS or not; other things likely look for Safari + /// "Mobile/BUILD_NUMBER" is what CodeMirror sniffs for to decide iOS or not; other things likely look for Safari public static var applicationNameForUserAgent: String { HomeAssistantAPI.userAgent + " Mobile/HomeAssistant, like Safari" } @@ -358,7 +358,6 @@ public class HomeAssistantAPI { public func DownloadDataAt(url: URL, needsAuth: Bool) -> Promise { Promise { seal in - var finalURL = url let dataManager: Alamofire.Session = needsAuth ? self.manager : Self.unauthenticatedManager @@ -676,11 +675,13 @@ public class HomeAssistantAPI { }.asVoid() } - public var sharedEventDeviceInfo: [String: String] { [ - "sourceDevicePermanentID": AppConstants.PermanentID, - "sourceDeviceName": server.info.setting(for: .overrideDeviceName) ?? Current.device.deviceName(), - "sourceDeviceID": Current.settingsStore.deviceID, - ] } + public var sharedEventDeviceInfo: [String: String] { + [ + "sourceDevicePermanentID": AppConstants.PermanentID, + "sourceDeviceName": server.info.setting(for: .overrideDeviceName) ?? Current.device.deviceName(), + "sourceDeviceID": Current.settingsStore.deviceID, + ] + } public func legacyNotificationActionEvent( identifier: String, diff --git a/Sources/Shared/Environment/Environment.swift b/Sources/Shared/Environment/Environment.swift index e22e07e069..2c6b3b8057 100644 --- a/Sources/Shared/Environment/Environment.swift +++ b/Sources/Shared/Environment/Environment.swift @@ -141,10 +141,10 @@ public class AppEnvironment { #if os(iOS) #if canImport(ActivityKit) - // Backing store uses Any? to work around Swift's @available stored property restriction. - // Access via the typed `liveActivityRegistry` computed property. - // Call `_ = Current.liveActivityRegistry` on the main thread at launch (before any - // background thread can access it) to avoid a lazy-init race between concurrent callers. + /// Backing store uses Any? to work around Swift's @available stored property restriction. + /// Access via the typed `liveActivityRegistry` computed property. + /// Call `_ = Current.liveActivityRegistry` on the main thread at launch (before any + /// background thread can access it) to avoid a lazy-init race between concurrent callers. private var _liveActivityRegistryBacking: Any? @available(iOS 16.1, *) @@ -188,7 +188,9 @@ public class AppEnvironment { public var cachedApis = [Identifier: HomeAssistantAPI]() - public var apis: [HomeAssistantAPI] { servers.all.compactMap(api(for:)) } + public var apis: [HomeAssistantAPI] { + servers.all.compactMap(api(for:)) + } private var lastActiveURLForServer = [Identifier: URL?]() public func api(for server: Server) -> HomeAssistantAPI? { @@ -280,7 +282,7 @@ public class AppEnvironment { public var backgroundTask: HomeAssistantBackgroundTaskRunner = ProcessInfoBackgroundTaskRunner() - // Use of 'appConfiguration' is preferred, but sometimes Beta builds are done as releases. + /// Use of 'appConfiguration' is preferred, but sometimes Beta builds are done as releases. public var isTestFlight = { #if DEBUG print("⚠️ isTestFlight returns TRUE while debugging") @@ -290,6 +292,13 @@ public class AppEnvironment { #endif }() + /// Centralized gate for importing custom mTLS client certificates. + /// TestFlight-only for now; update this in one place if rollout rules change. + public var allowsCustomMTLSCertificateImport: Bool { + isTestFlight + } + + #if os(iOS) public var isAppExtension = AppConstants.BundleID != Bundle.main.bundleIdentifier #elseif os(watchOS) @@ -320,7 +329,7 @@ public class AppEnvironment { private let isFastlaneSnapshot = UserDefaults(suiteName: AppConstants.AppGroupID)!.bool(forKey: "FASTLANE_SNAPSHOT") - // This can be used to add debug statements. + /// This can be used to add debug statements. public var isDebug: Bool { #if DEBUG return true diff --git a/Sources/Shared/LiveActivity/LiveActivityRegistry.swift b/Sources/Shared/LiveActivity/LiveActivityRegistry.swift index 5aa9bc6ffb..3bec381d94 100644 --- a/Sources/Shared/LiveActivity/LiveActivityRegistry.swift +++ b/Sources/Shared/LiveActivity/LiveActivityRegistry.swift @@ -3,8 +3,8 @@ import ActivityKit import Foundation import PromiseKit -// Stale date offset for all Live Activity content updates. -// Activities are marked stale after 30 minutes if no further updates arrive. +/// 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 @available(iOS 16.1, *) @@ -25,7 +25,6 @@ public protocol LiveActivityRegistryProtocol: AnyObject { /// arrive back-to-back before the first `Activity.request(...)` completes. @available(iOS 16.1, *) public actor LiveActivityRegistry: LiveActivityRegistryProtocol { - // MARK: - Types struct Entry { diff --git a/Sources/Shared/Notifications/NotificationCommands/HandlerLiveActivity.swift b/Sources/Shared/Notifications/NotificationCommands/HandlerLiveActivity.swift index d3fd995c92..02e352b441 100644 --- a/Sources/Shared/Notifications/NotificationCommands/HandlerLiveActivity.swift +++ b/Sources/Shared/Notifications/NotificationCommands/HandlerLiveActivity.swift @@ -39,7 +39,10 @@ struct HandlerStartOrUpdateLiveActivity: NotificationCommandHandler { } guard Self.isValidTag(tag) else { - Current.Log.error("HandlerStartOrUpdateLiveActivity: invalid tag '\(tag)' — must be [a-zA-Z0-9_-], max 64 chars") + Current.Log + .error( + "HandlerStartOrUpdateLiveActivity: invalid tag '\(tag)' — must be [a-zA-Z0-9_-], max 64 chars" + ) throw ValidationError.invalidTag } @@ -106,7 +109,7 @@ struct HandlerStartOrUpdateLiveActivity: NotificationCommandHandler { // `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({ $0.doubleValue }) { + if let when = (payload["when"] as? NSNumber).map(\.doubleValue) { let whenRelative = payload["when_relative"] as? Bool ?? false if whenRelative { countdownEnd = Date().addingTimeInterval(when) diff --git a/docs/plans/2026-03-18-001-feat-ios-live-activities-plan.md b/docs/plans/2026-03-18-001-feat-ios-live-activities-plan.md new file mode 100644 index 0000000000..cd0b3fd687 --- /dev/null +++ b/docs/plans/2026-03-18-001-feat-ios-live-activities-plan.md @@ -0,0 +1,1044 @@ +--- +title: feat: iOS Live Activities for Home Assistant +type: feat +status: active +date: 2026-03-18 +deepened: 2026-03-18 +--- + +# feat: iOS Live Activities for Home Assistant + +## Enhancement Summary + +**Deepened on:** 2026-03-18 +**Research agents used:** ActivityKit framework docs, APNs best practices, security sentinel, architecture strategist, performance oracle, spec-flow analyzer, code simplicity reviewer, async race conditions reviewer + +### Key Improvements Added +1. **Critical process boundary constraint**: `PushProvider` (network extension) cannot call ActivityKit — it runs in a separate OS process. All handlers in the extension must relay to the main app via `UNUserNotificationCenter`. This would have caused silent failures at runtime. +2. **API version split**: The `Activity.request(attributes:contentState:pushType:)` API was deprecated in iOS 16.2 and replaced with `Activity.request(attributes:content:pushType:)` using `ActivityContent`. Code must handle both. +3. **iOS 18 rate limit change**: Apple changed update rate limiting to ~15s minimum between updates in iOS 18. Design must not assume 1 Hz is reliably achievable. +4. **Actor isolation required**: The `[String: Activity]` dictionary will be accessed from multiple threads/queues. Must use a Swift `actor` with a reservation pattern to prevent TOCTOU races. +5. **Simplification**: Remove `updatedAt: Date` and `secondaryState` from MVP `ContentState`. Consolidate 3 handler files to 1. Use `Activity.activities` instead of a parallel dictionary. +6. **Push-to-start unreliability**: Push-to-start from a fully terminated app has ~50% success rate. The primary flow must be foreground-initiated; push-to-start is best-effort only. +7. **Security**: Activity push tokens must be stored in Keychain (not `UserDefaults`), never logged to crash reporters, and only transmitted over the encrypted webhook channel. + +### New Considerations Discovered +- `attributes-type` in APNs push-to-start payload must exactly match the Swift struct name — case-sensitive, immutable post-ship +- Certificate-based APNs auth is not supported for Live Activities; relay server must use JWT (`.p8` key) +- iOS 18 changes update budgets significantly; `NSSupportsLiveActivitiesFrequentUpdates` should be opt-in per activity +- `NotificationService` extension is never invoked for `apns-push-type: liveactivity` pushes +- iPad has `areActivitiesEnabled == false` — must handle gracefully without crash +- App must report capability (`supports_live_activities: Bool`) in registration payload so HA server can gate the UI + +--- + +## Overview + +Implement iOS Live Activities using Apple's ActivityKit framework so that Home Assistant automations can display real-time data on the iOS Lock Screen and Dynamic Island. This is a highly requested community feature (discussion #84). The Android companion app already ships this via persistent/ongoing notifications with tag-based in-place updates. The goal is **feature parity with Android** using the same notification field names so automations can target both platforms with minimal differences. + +--- + +## Android Feature Baseline (What We're Matching) + +The Android companion app has **two tiers** of live/updating notifications: + +### Tier 1: `alert_once: true` + `tag` (any Android version) +Standard notifications updated in-place. Subsequent pushes with the same `tag` replace the notification without re-alerting. + +### Tier 2: `live_update: true` (Android 16+ only) — the primary target +Android 16's native **Live Updates API**. Pins the notification to: +- Status bar as a **chip** showing `critical_text` or a live `chronometer` +- Lock screen (persistent, doesn't scroll away) +- Always-on display + +This is the direct Android equivalent of iOS Live Activities. + +```yaml +# Android 16+: Live Update with progress bar and countdown timer +action: notify.mobile_app_ +data: + title: "Washing Machine" # required for live_update + message: "Cycle in progress" + data: + tag: washer_cycle # unique ID for in-place updates + live_update: true # Android 16+: pin to status bar chip + lock screen + critical_text: "45 min" # short text shown in status bar chip + progress: 2700 # current value (raw integer) + progress_max: 3600 # maximum value + chronometer: true # show countdown timer instead of critical_text + when: 2700 # seconds until done (for chronometer) + when_relative: true # treat `when` as duration, not timestamp + notification_icon: mdi:washing-machine # MDI icon for status bar chip + notification_icon_color: "#2196F3" # icon accent color + alert_once: true # also works on older Android: silent updates + sticky: true # non-dismissible by user + visibility: public # visible on lock screen + +# Android: dismiss it +action: notify.mobile_app_ +data: + message: clear_notification + data: + tag: washer_cycle +``` + +**Key Android `live_update` fields:** + +| Field | Type | Purpose | +|---|---|---| +| `live_update` | bool | Enable Android 16 Live Updates API | +| `tag` | string | Unique ID — same tag = update in-place | +| `title` | string | Required for `live_update` | +| `message` | string | Body text | +| `critical_text` | string | Short text in status bar chip | +| `chronometer` | bool | Show live countdown instead of `critical_text` | +| `when` | int | Seconds for the countdown / timestamp | +| `when_relative` | bool | Treat `when` as relative duration | +| `progress` | int | Current progress value (raw integer) | +| `progress_max` | int | Maximum progress value | +| `notification_icon` | string | MDI slug for status bar icon | +| `notification_icon_color` | string | Hex color for icon | +| `alert_once` | bool | Silence subsequent alerts (older Android fallback) | +| `sticky` | bool | Non-dismissible | +| `visibility` | string | `public` = visible on lock screen | + +--- + +## Problem Statement / Motivation + +Home Assistant users frequently need to monitor time-sensitive states (a washer finishing, a door left open, a timer counting down, a media player progress bar) without constantly opening the app. iOS 16.1 introduced Live Activities via ActivityKit specifically for this use case. Android users already have this capability. iOS companion app users have no equivalent. + +--- + +## Proposed Solution + +Implement iOS Live Activities triggered by the **same notification fields Android uses**. The iOS opt-in field `live_activity: true` mirrors Android's `live_update: true`. All other field names (`tag`, `title`, `message`, `progress`, `progress_max`, `chronometer`, `when`, `when_relative`, `notification_icon`, `notification_icon_color`) are shared between both platforms. + +```yaml +# Works on BOTH Android 16+ and iOS 16.1+: + +action: notify.mobile_app_ +data: + title: "Washing Machine" + message: "45 minutes remaining" + data: + tag: washer_cycle # iOS & Android: unique ID for in-place updates + live_update: true # Android 16+: pin to status bar chip + lock screen + live_activity: true # iOS 16.1+: use Live Activity (Android ignores) + critical_text: "45 min" # Android: status bar chip text. iOS: Dynamic Island compact trailing + progress: 2700 # iOS & Android: current value (raw integer) + progress_max: 3600 # iOS & Android: maximum value + chronometer: true # iOS & Android: show countdown timer + when: 2700 # seconds remaining (used with chronometer) + when_relative: true # treat `when` as duration from now + notification_icon: mdi:washing-machine # iOS & Android: MDI icon slug + notification_icon_color: "#2196F3" # iOS & Android: icon accent color + alert_once: true # Android: silent updates. iOS: ignored (always silent) + sticky: true # Android: non-dismissible. iOS: ignored (always persistent) + visibility: public # Android: lock screen. iOS: ignored (always public) + +# Dismiss — identical on both platforms: +action: notify.mobile_app_ +data: + message: clear_notification + data: + tag: washer_cycle +``` + +**iOS field mapping:** + +| Companion docs field | iOS Live Activity mapping | Notes | +|---|---|---| +| `live_update: true` | — | Android-only opt-in; iOS uses `live_activity: true` | +| `live_activity: true` | Triggers Live Activity | Android ignores unknown fields | +| `tag` | `HALiveActivityAttributes.tag` | Same semantics: same tag = update in-place | +| `title` | `HALiveActivityAttributes.title` | Static attribute, set at activity creation | +| `message` | `ContentState.message` | Primary state text | +| `critical_text` | Dynamic Island compact trailing text | Short label (≤~10 chars) | +| `progress` | `ContentState.progress` | Raw integer | +| `progress_max` | `ContentState.progressMax` | Raw integer; fraction computed for SwiftUI | +| `chronometer: true` | `Text(timerInterval:countsDown:)` | Native iOS — zero battery cost, hardware-smooth | +| `when` + `when_relative` | Countdown end `Date` = `now + when` seconds | Converted to absolute `Date` for ActivityKit | +| `notification_icon` | `ContentState.icon` | MDI slug | +| `notification_icon_color` | `ContentState.color` | Hex string | +| `alert_once`, `sticky`, `visibility` | Ignored | Live Activities handle these natively | +| `clear_notification` + `tag` | Ends Live Activity + clears UNNotification | Same YAML, both platforms | + +On iOS < 16.1 or iPad, `live_activity: true` is ignored and the notification falls through as a regular banner — graceful degradation with no automation changes needed. + +The `ActivityAttributes` schema is wire-format stable: fields are only ever added, never renamed or removed, to maintain APNs compatibility across app updates. + +--- + +## Technical Approach + +### Architecture Overview + +``` +Home Assistant Automation + │ + ▼ +mobile_app service call ──────────────────────────────────────┐ + │ │ + ▼ ▼ + FCM relay → APNs (remote) WebSocket push notification channel + (start/update/end commands) (local push, LAN only) + │ │ + ▼ ▼ + Main App Process Main App Process + NotificationManager LocalPushManager + │ │ + └─────────────────┬─────────────────────────┘ + ▼ + NotificationCommandManager + (handlers registered here) + │ + ▼ + HandlerLiveActivity.swift ← NEW (1 file, 3 structs) + │ + ▼ + LiveActivityRegistry (actor) ← NEW + │ + ▼ + Activity + (ActivityKit — main app ONLY) + │ + ┌─────┴──────────────┐ + ▼ ▼ + Lock Screen Dynamic Island + View Views (compact, + minimal, expanded) + +⚠️ PushProvider (NEAppPushProvider) runs in a SEPARATE PROCESS. + It cannot call ActivityKit. It must relay commands to the + main app via UNUserNotificationCenter local push. +``` + +### iOS Version Requirements + +| Feature | Minimum iOS | +|---|---| +| ActivityKit (basic) | **iOS 16.1** | +| `ActivityContent` / `staleDate` / updated API | **iOS 16.2** | +| Push-to-start (remote start) | **iOS 17.2** | +| `frequentPushesEnabled` user toggle | **iOS 17.2** | +| Current deployment target | iOS 15.0 | + +All ActivityKit code must be wrapped in `#available(iOS 16.1, *)`. Use `#available(iOS 16.2, *)` for `ActivityContent` (the updated API). Push-to-start token registration must be wrapped in `#available(iOS 17.2, *)`. The UI must degrade gracefully on older OS versions (simply absent). iPad returns `areActivitiesEnabled == false` — must not crash. + +### Critical API Version Split (iOS 16.1 vs 16.2) + +The `Activity.request(...)` API changed in iOS 16.2. Both paths must be handled: + +```swift +// iOS 16.1 only (deprecated — supports deployment target iOS 15+): +let activity = try Activity.request( + attributes: attributes, + contentState: initialState, // ← "contentState:" label + pushType: .token +) + +// iOS 16.2+ (preferred): +let content = ActivityContent( + state: initialState, + staleDate: Date().addingTimeInterval(30 * 60), + relevanceScore: 0.5 +) +let activity = try Activity.request( + attributes: attributes, + content: content, // ← "content:" label (ActivityContent wrapper) + pushType: .token +) +``` + +Similarly, `activity.update(using:)` is iOS 16.1 only; use `activity.update(_:)` with `ActivityContent` on iOS 16.2+. + +--- + +### Implementation Phases + +#### Phase 1: Foundation — Data Model & Basic Local Start/End + +**Goal:** Define the ActivityKit data model and be able to start/end a Live Activity from within the app (local only, no push). + +**Tasks:** + +- [ ] **`HALiveActivityAttributes.swift`** — Define the `ActivityAttributes` conforming struct in `Sources/Shared/LiveActivity/` behind `#if canImport(ActivityKit)`. This file must be compiled into BOTH the `iOS-App` target and `Extensions-Widgets` target (via `Shared.framework`). The `attributes-type` string in APNs payloads must exactly match the Swift struct name — **never rename this struct post-ship**. + + ```swift + // Sources/Shared/LiveActivity/HALiveActivityAttributes.swift + #if canImport(ActivityKit) + import ActivityKit + + public struct HALiveActivityAttributes: ActivityAttributes { + // Static: set once at activity creation, cannot change + // These map from the initial notification payload fields + public let tag: String // = Android's `tag` field; unique ID for this activity + public let title: String // = Android's `title` field + + // Dynamic: updated via push or local update + // Field names intentionally mirror Android companion docs notification fields + public struct ContentState: Codable, Hashable { + public var message: String // = `message`. Primary state text + public var criticalText: String? // = `critical_text`. Short text for Dynamic Island compact trailing + public var progress: Int? // = `progress`. Current value (raw integer) + public var progressMax: Int? // = `progress_max`. Maximum value + public var chronometer: Bool? // = `chronometer`. If true, show countdown timer + public var countdownEnd: Date? // = computed from `when` + `when_relative`. Absolute end date for timer + public var icon: String? // = `notification_icon`. MDI slug + public var color: String? // = `notification_icon_color`. Hex string + + // Computed for SwiftUI rendering — not sent over wire + public var progressFraction: Double? { + guard let p = progress, let m = progressMax, m > 0 else { return nil } + return Double(p) / Double(m) + } + } + } + #endif + ``` + + **Payload parsing note:** The handler reads standard notification fields and maps them: + - `when` (int, seconds) + `when_relative: true` → `countdownEnd = Date().addingTimeInterval(Double(when))` + - `when` as absolute Unix timestamp + `when_relative: false` → `countdownEnd = Date(timeIntervalSince1970: Double(when))` + - `notification_icon` → `icon` (stored as-is, MDI slug) + - `notification_icon_color` → `color` (same field semantics as Android `color`) + + **Design notes:** + - All field names in JSON encoding match Android companion docs field names (via `CodingKeys`) + - `progress`/`progress_max` are raw integers (matching Android) — `progressFraction` is computed for SwiftUI + - `updatedAt: Date` omitted — system APNs timestamp handles ordering + - `unit` deferred — add only when a specific layout requires it + - Total encoded size of attributes + ContentState must stay under ~4KB (APNs limit) + - **Never rename this struct or its fields post-ship** — `attributes-type` in APNs push-to-start payloads must match the Swift type name exactly + +- [ ] **`LiveActivityRegistry.swift`** (actor) — in `Sources/Shared/LiveActivity/`: + ```swift + // actor protects concurrent access from push handler queue + token observer tasks + actor LiveActivityRegistry { + struct Entry { + let activity: Activity + let observationTask: Task + } + private var reserved: Set = [] // TOCTOU protection + private var entries: [String: Entry] = [] + + /// Returns false if ID is already reserved or running (prevents duplicate start race) + func reserve(id: String) -> Bool { ... } + func confirmReservation(id: String, entry: Entry) { ... } + func cancelReservation(id: String) { ... } + func remove(id: String) -> Entry? { ... } + func entry(for id: String) -> Entry? { ... } + } + ``` + Exposed via `AppEnvironment` as a protocol-typed property under `#if os(iOS)`, following the `notificationAttachmentManager` pattern. + +- [ ] **`HandlerLiveActivity.swift`** — One file, three `private struct`s, in `Sources/Shared/Notifications/NotificationCommands/`, consistent with `HandlerUpdateComplications` and `HandlerUpdateWidgets` pattern: + - `HandlerStartLiveActivity: NotificationCommandHandler` + - `HandlerUpdateLiveActivity: NotificationCommandHandler` + - `HandlerEndLiveActivity: NotificationCommandHandler` + +- [ ] **Live Activity views** — in `Sources/Extensions/Widgets/LiveActivity/`: + - `HALiveActivityConfiguration.swift` — `ActivityConfiguration` wrapper + - `HALockScreenView.swift` — Lock Screen / StandBy view (max 160pt height) + - `HADynamicIslandView.swift` — All Dynamic Island presentations + +- [ ] **Register `ActivityConfiguration`** in `Sources/Extensions/Widgets/Widgets.swift` inside `WidgetsBundle18` with `#available(iOS 16.2, *)` guard + +- [ ] **`Info.plist`** — Add `NSSupportsLiveActivities = true` to `Sources/App/Resources/Info.plist` + +- [ ] **`AppEnvironment`** — Add `var liveActivityRegistry: LiveActivityRegistryProtocol` under `#if os(iOS)` to `Sources/Shared/Environment/Environment.swift` + +- [ ] **App launch recovery** — In `LiveActivityRegistry.init()` or at startup, enumerate `Activity.activities` to re-attach observation tasks to any activities that survived process termination. This must happen before any push handlers are invoked. + +**Research Insights — Phase 1:** + +**Xcode Preview support (no device needed for UI iteration):** +```swift +#Preview("Lock Screen", as: .content, using: HALiveActivityAttributes(activityID: "test", title: "Washer")) { + HALiveActivityConfiguration() +} contentStates: { + HALiveActivityAttributes.ContentState(state: "Running", value: 0.65, unit: nil, iconName: "mdi:washing-machine", color: "#4CAF50") + HALiveActivityAttributes.ContentState(state: "Done", value: 1.0, unit: nil, iconName: "mdi:check-circle", color: "#2196F3") +} + +#Preview("Dynamic Island Compact", as: .dynamicIsland(.compact), ...) { ... } +#Preview("Dynamic Island Expanded", as: .dynamicIsland(.expanded), ...) { ... } +``` + +**Lock Screen height budget:** The system hard-truncates at **160 points**. Padding counts against this limit. + +**Dynamic Island region layout:** +```swift +DynamicIsland { + DynamicIslandExpandedRegion(.leading) { /* icon */ } + DynamicIslandExpandedRegion(.trailing) { /* value + unit */ } + DynamicIslandExpandedRegion(.center) { /* state text */ } + DynamicIslandExpandedRegion(.bottom) { /* optional detail, full-width */ } +} compactLeading: { /* icon only */ } + compactTrailing: { /* value, caption2 font */ } + minimal: { /* single icon */ } +``` + +**Color rendering optimization:** Do NOT parse hex strings in the SwiftUI view body (runs on every render pass in SpringBoard). Pre-parse hex in `ContentState` decoding or use a cached extension: +```swift +// Add to ContentState +var resolvedColor: Color { Color(hex: color ?? "#FFFFFF") } +``` + +**`ActivityAuthorizationInfo` check before every start:** +```swift +guard ActivityAuthorizationInfo().areActivitiesEnabled else { + // Report back to HA via webhook; do not crash + return +} +``` + +**Success criteria:** +- A Live Activity can be started in-app on a physical iOS 16.1+ device +- The Lock Screen and Dynamic Island show the correct content +- Activity ends cleanly +- Xcode Preview shows all 4 presentations without a device +- On iOS < 16.1 or iPad, code paths are no-ops + +--- + +#### Phase 2: Notification Command Integration (Local Push + APNs Update/End) + +**Goal:** Enable HA automations to start, update, and end Live Activities via the existing notification command system. Push token is reported to HA so it can send APNs updates directly. + +**Tasks:** + +- [ ] **`HandlerLiveActivity`** — new `NotificationCommandHandler` registered for command `live_activity`, containing three private structs in one file: + - **`HandlerStartOrUpdateLiveActivity`** — triggered when any notification arrives with `data.live_activity: true` + - Reads from notification `data` dict: `tag` (required, becomes `activityID`), `title` (required), `message`, `progress`, `progress_max`, `color`, `icon` + - Validates `tag`: max 64 chars, `[a-zA-Z0-9\-_]` only + - If activity with `tag` already running → **update** (matches Android's tag-based replacement) + - If not running → **start** new Live Activity (reservation pattern for TOCTOU safety) + - Reports push token to HA server via webhook immediately after start + - **`HandlerEndLiveActivity`** — triggered by `message: clear_notification` when notification also has a `tag` that matches a running Live Activity + - Integrated into existing `HandlerClearNotification` — check if `tag` matches a running `Activity`; if so, end it in addition to clearing the UNNotification + - Optional `dismissal_policy` field: `immediate` (default), `default` (linger up to 4h), `after:` + - If no matching Live Activity, silently succeeds (existing `clear_notification` behavior preserved) + +- [ ] **Modify `HandlerClearNotification`** — extend to also end any Live Activity whose `tag` attribute matches: + ```swift + // In HandlerClearNotification.handle(_:) + if #available(iOS 16.1, *), let tag = payload["tag"] as? String { + // End matching Live Activity if one exists + if let activity = Activity.activities + .first(where: { $0.attributes.tag == tag }) { + Task { await activity.end(nil, dismissalPolicy: .immediate) } + } + } + // existing UNUserNotificationCenter.current().removeDeliveredNotifications(...) + ``` + +- [ ] **Register `live_activity` command** in `NotificationsCommandManager.init()` under `#if os(iOS)` + +**Platform parity table:** + +| Android field | iOS handling | Notes | +|---|---|---| +| `tag` | `HALiveActivityAttributes.tag` (activityID) | Same field name, same semantics | +| `title` | `HALiveActivityAttributes.title` (static) | Same | +| `message` | `ContentState.message` | Same field name | +| `progress` | `ContentState.progress` | Same field name, raw integer | +| `progress_max` | `ContentState.progressMax` | Camel-cased in Swift, `progress_max` in JSON | +| `color` | `ContentState.color` | Same | +| `icon` | `ContentState.icon` | Same (MDI slug) | +| `alert_once` | Ignored — Live Activities are always silent on update | No action needed | +| `sticky` | Ignored — Live Activities are persistent by nature | No action needed | +| `visibility: public` | Always public on Lock Screen | No action needed | +| `live_activity: true` | Triggers Live Activity path | Android ignores unknown fields | +| `clear_notification` + `tag` | Ends Live Activity AND clears UNNotification | Same YAML works on both | + +- [ ] **PushProvider relay** — `HandlerStartLiveActivity`, `HandlerUpdateLiveActivity`, and `HandlerEndLiveActivity` when running in `PushProvider` process must NOT call ActivityKit. Detect via `Current.isAppExtension` and relay via a local `UNNotificationRequest` instead: + ```swift + // In handler, inside PushProvider process: + if Current.isAppExtension { + let relay = UNMutableNotificationContent() + relay.categoryIdentifier = "HA_LIVE_ACTIVITY_RELAY" + relay.userInfo = payload + let request = UNNotificationRequest(identifier: UUID().uuidString, content: relay, trigger: nil) + UNUserNotificationCenter.current().add(request) + return + } + // Otherwise (main app process), call ActivityKit directly + ``` + The main app's `NotificationManager.userNotificationCenter(_:didReceive:)` handles the relayed notification and calls the registry. + +- [ ] **Push token observation task** — Inside `LiveActivityRegistry`, for each started activity: + ```swift + let observationTask = Task { + for await tokenData in activity.pushTokenUpdates { + guard !Task.isCancelled else { break } + let tokenHex = tokenData.map { String(format: "%02x", $0) }.joined() + // Wrap in background task to prevent suspension mid-report + let bgTask = await UIApplication.shared.beginBackgroundTask(withName: "la-token-update") + defer { UIApplication.shared.endBackgroundTask(bgTask) } + await reportPushToken(tokenHex, activityID: activityID) + } + // Stream ends when activity ends — self-clean + await remove(id: activityID) + } + ``` + +- [ ] **Activity lifecycle observer** — Inside the same task, also observe `activity.activityStateUpdates`: + ```swift + for await state in activity.activityStateUpdates { + if state == .dismissed || state == .ended { + await reportActivityDismissed(activityID: activityID, reason: state == .dismissed ? "user_dismissed" : "ended") + await registry.remove(id: activityID) + break + } + } + ``` + +- [ ] **Push token reporting webhook** — POST to HA via existing `WebhookManager.send(server:request:)`, no new webhook response type needed: + ```swift + let request = WebhookRequest( + type: "mobile_app_live_activity_token", + data: ["activity_id": activityID, "push_token": tokenHex, "apns_environment": apnsEnvironment] + ) + Current.webhooks.send(server: server, request: request) + ``` + +- [ ] **Activity dismissal webhook** — POST `mobile_app_live_activity_dismissed` event to HA when activity state becomes `.dismissed` or `.ended` externally. This is critical so HA stops sending updates. + +- [ ] **Capability advertisement** — Add `supports_live_activities: Bool` and `supports_live_activities_frequent_updates: Bool` and `min_live_activities_ios_version: "16.1"` to `buildMobileAppRegistration()` `app_data` dict in `HAAPI.swift`, under `#if os(iOS)` + `#available(iOS 16.1, *)`. + +- [ ] **Server version gate** — Add to `AppConstants.swift`: + ```swift + public extension Version { + static let liveActivities: Version = .init(major: 2026, minor: 6, prerelease: "any0") + } + ``` + +- [ ] **APNs environment tracking** — Determine sandbox vs production at registration time and include `apns_environment: "sandbox" | "production"` in every token report webhook. The relay server uses this to route to the correct APNs endpoint. Tokens from one environment are rejected by the other. + +- [ ] **Update debounce** — Add a trailing-edge debounce (250ms minimum) in the update handler. High-frequency HA sensors can fire many events per second; the system silently drops excess `Activity.update(...)` calls after consuming CPU. + +**Research Insights — Phase 2:** + +**PromiseKit bridge pattern** (matches existing handler protocol): +```swift +struct HandlerStartLiveActivity: NotificationCommandHandler { + func handle(_ payload: [String: Any]) -> Promise { + let (promise, seal) = Promise.pending() + Task { + do { + try await LiveActivityRegistry.shared.start(payload: payload) + seal.fulfill(()) + } catch ActivityAuthorizationError.activitiesDisabled { + seal.fulfill(()) // User choice — not an error + } catch ActivityAuthorizationError.globalMaximumExceeded { + seal.reject(LiveActivityError.tooManyActivities) + } catch { + seal.reject(error) + } + } + return promise + } +} +``` + +**`ActivityAuthorizationError` cases to handle:** +- `.activitiesDisabled` — user turned off Live Activities in Settings → report to HA, no crash +- `.globalMaximumExceeded` — device limit hit (~2-3 concurrent) → report error to HA +- `.attributesTooLarge` — payload too big → reject with useful error message +- `.pushUpdatesDisabled` — iOS 17.2+ user toggle → report to HA so it knows not to send APNs updates + +**iOS 18 rate limit reality:** Effective minimum update interval is ~15 seconds. HA automations should be designed to fire at most 4 times per minute for non-timer use cases. Build this guidance into companion documentation. + +**Success criteria:** +- Sending `action: notify.mobile_app_` with `message: start_live_activity` starts a Live Activity +- Sending `message: update_live_activity` updates state +- Sending `message: end_live_activity` dismisses the activity +- Push token is successfully delivered to HA server via encrypted webhook +- Activity dismissal (user swipe or 8-hour expiry) is reported back to HA server +- `supports_live_activities: true` appears in HA device registry + +--- + +#### Phase 3: APNs Push-to-Start (Remote Start, iOS 17.2+) + +**Goal:** Allow HA automations to start a Live Activity entirely remotely (app not required in foreground). + +**⚠️ Important caveat:** Push-to-start from a fully terminated app succeeds only ~50% of the time. Design this as a best-effort enhancement, not the primary flow. The primary flow is notification command → app receives push → main app starts activity. + +**Tasks:** + +- [ ] **Push-to-start token observation** — In `LiveActivityRegistry` or `AppDelegate`: + ```swift + @available(iOS 17.2, *) + func observePushToStartToken() { + Task { + for await tokenData in Activity.pushToStartTokenUpdates { + let tokenHex = tokenData.map { String(format: "%02x", $0) }.joined() + // Store in Keychain (NOT UserDefaults — higher-value secret) + Current.keychain.set(tokenHex, forKey: "live_activity_push_to_start_token") + await reportPushToStartToken(tokenHex) + } + } + } + ``` + +- [ ] **Registration payload extension** — Extend `buildMobileAppRegistration()` / `buildMobileAppUpdateRegistration()` in `HAAPI.swift` to include `live_activity_push_to_start_token` in `app_data` when available (iOS 17.2+ only). + +- [ ] **`NSSupportsLiveActivitiesFrequentUpdates`** — Add to `Sources/App/Resources/Info.plist`. Required for push-to-start token to be issued. Also exposes user toggle in iOS Settings. Observe `ActivityAuthorizationInfo().activityEnablementUpdates` to detect when user toggles this off and report to HA. + +- [ ] **`frequentPushesEnabled` reporting** — Report the current value of `ActivityAuthorizationInfo().frequentPushesEnabled` (iOS 17.2+) to HA via registration/update payload. HA server must not send high-frequency pushes when this is `false`. + +- [ ] **APNs payload format** — Document in companion docs. Key constraints: + - `attributes-type` must exactly match Swift struct name (`"HALiveActivityAttributes"`) — **immutable post-ship** + - `apns-push-type: liveactivity` header required + - `apns-topic: io.robbie.HomeAssistant.push-type.liveactivity` + - JWT auth only (`.p8` key) — certificate auth is not supported for Live Activities + - APNs environment must match token environment (sandbox vs production) + + ```json + { + "aps": { + "timestamp": 1234567890, + "event": "start", + "content-state": { + "state": "Running", + "value": 0.65, + "unit": null, + "iconName": "mdi:washing-machine", + "color": "#4CAF50" + }, + "attributes-type": "HALiveActivityAttributes", + "attributes": { + "activityID": "washer-cycle-abc123", + "title": "Washing Machine" + }, + "alert": { + "title": "Washer Started", + "body": "Cycle in progress" + }, + "stale-date": 1234571490, + "relevance-score": 0.5 + } + } + ``` + +- [ ] **Relay server changes** (documented for HA core team) — The relay at `mobile-apps.home-assistant.io` must: + - Add a new endpoint for Live Activity push forwarding (separate from standard notification path because APNs headers differ) + - Support `apns-push-type: liveactivity` and the `.push-type.liveactivity` topic suffix + - Cache the JWT in memory, rotate every 45 minutes (not per-request) + - Route to sandbox vs production APNs endpoint based on `apns_environment` field from the app + - Handle `BadDeviceToken (400)` response as a signal to invalidate the stored token + +**Success criteria:** +- HA automation can start a Live Activity on iOS 17.2+ device without app being open (best-effort) +- Push-to-start token stored in Keychain, reported to HA via registration payload +- Token refresh handled automatically via `pushToStartTokenUpdates` +- Relay server routes to correct APNs environment + +--- + +#### Phase 4: UI Polish & Settings + +**Goal:** Provide configuration options and polished layouts. + +**Tasks:** + +- [ ] **Settings section** — Add "Live Activities" section to existing `NotificationSettingsViewController` hierarchy showing: + - Live Activities enabled status (links to iOS Settings if disabled) + - Active activities list (enumerate `Activity.activities`) + - "End All Activities" button + - Frequent updates toggle status (iOS 17.2+) + +- [ ] **Material Design Icon rendering** — Use existing `MaterialDesignIcons` integration (verify the font resource bundle is included in the Widgets extension target, as it is for standard widgets). MDI slugs decode at view construction time, not in the view body. + +- [ ] **Privacy disclosure** — One-time warning when first Live Activity is started: "Live Activity content is visible on your Lock Screen without Face ID or Touch ID. Choose entities carefully." Stored as a `UserDefaults` seen-flag. + +- [ ] **Timer layout** — Use `ActivityKit`'s native timer support to show countdown with zero additional push updates: + ```swift + Text(timerInterval: startDate...endDate, countsDown: true) + ``` + No update pushes needed for timer progress — the system handles animation natively. + +- [ ] **User-facing documentation** — Companion docs PR with automation YAML examples + +**Deferred (separate issues):** +- `activityType` enum for specialized layouts — APNs schema compatibility risk; open a separate issue when demand is proven +- Multiple specialized `ActivityAttributes` types (media player, delivery tracking) + +**Success criteria:** +- Users can see and manage active Live Activities from app settings +- Icons render correctly from MDI slugs +- Privacy disclosure shown once before first use +- Timers animate without any server-sent updates + +--- + +## Alternative Approaches Considered + +| Approach | Verdict | Reason | +|---|---|---| +| **Strongly-typed activity per use case** (TimerActivity, MediaActivity) | Rejected for MVP | Too prescriptive; HA's flexibility demands a generic model; APNs `attributes-type` string is immutable | +| **Separate Live Activity extension target** | Rejected | WidgetKit extension already exists; ActivityKit views belong in the same `Widgets` target | +| **WebSocket-only updates (no APNs)** | Phase 1-2 only (local push) | APNs push-to-start needed for background start; update pushes from relay for remote update | +| **`LiveActivityManager` class on AppEnvironment from day one** | Deferred | No testability requirement proven yet; call ActivityKit from handlers directly in Phase 1-2; extract manager when tests require mocking | +| **`[String: Activity]` parallel dictionary** | Rejected | System provides `Activity.activities` as authoritative list; parallel dictionary adds crash-recovery gap | +| **New `WebhookResponseLiveActivityToken` type** | Rejected | Existing `WebhookRequest(type:data:)` + `Current.webhooks.send(...)` handles token reporting without new types | + +--- + +## System-Wide Impact + +### Interaction Graph + +`HA automation fires` → `mobile_app.send_message service` → `FCM relay` → `APNs` → `Main app NotificationManager.didReceiveRemoteNotification` → `NotificationCommandManager.handle(_:)` → `HandlerStartLiveActivity.handle(_:)` → `LiveActivityRegistry.reserve(id:)` → `Activity.request(...)` → `Activity.pushTokenUpdates` async stream → `WebhookManager.send(...)` `mobile_app_live_activity_token` → `HA server stores token` → `HA server → relay → APNs update pushes directly to activity token`. + +**PushProvider path** (separate process): `PushProvider receives push` → `NotificationCommandManager.handle(_:)` → `HandlerStartLiveActivity` detects `Current.isAppExtension == true` → posts relay `UNNotificationRequest` → `Main app NotificationManager.userNotificationCenter(_:didReceive:)` → same path as above. + +### Error & Failure Propagation + +- `Activity.request(...)` throws `ActivityAuthorizationError`: + - `.activitiesDisabled` — user toggle off or iPad → report to HA via webhook event `mobile_app_live_activity_start_failed`, reason: `activities_disabled`; no user-visible error + - `.globalMaximumExceeded` — system limit hit → report to HA, suggest ending existing activity + - `.attributesTooLarge` — payload over ~4KB → log error with field sizes; do not surface crash +- Push token reporting failure (network offline) → `WebhookManager` retry logic handles it; the `pushTokenUpdates` stream will also re-emit on next rotation +- Activity dismissed externally → `activityStateUpdates` emits `.dismissed` → `LiveActivityRegistry` removes entry and POSTs `mobile_app_live_activity_dismissed` webhook to HA +- Activity reaches 8-hour system limit → same path as above; HA stops sending updates + +### State Lifecycle Risks + +- **App crash with activities running**: Activities persist on Lock Screen; `LiveActivityRegistry.activities` dictionary is in-memory only. On relaunch, `Activity.activities` (system list) restores tracking. **Must call this at app launch before handling any push commands.** +- **TOCTOU duplicate start**: Two pushes arrive with same `activityID` — reservation pattern in `actor LiveActivityRegistry` prevents both from reaching `Activity.request(...)`. Second caller gets `false` from `reserve(id:)` and updates instead. +- **App update changes `ContentState`**: Adding optional fields is safe (APNs uses JSON, extras ignored). Removing or renaming fields breaks existing activities. Never rename `ContentState` properties post-ship. + +### API Surface Parity + +- `NotificationCommandManager` registers 3 new command handlers — both the main app and `PushProvider` initialize `NotificationCommandManager`; the `PushProvider` instance's handlers must relay, not execute, ActivityKit calls +- `HAAPI.buildMobileAppRegistration()` must include capability fields; `updateRegistration()` must also update them to handle token refresh and OS upgrade scenarios + +### Integration Test Scenarios + +1. Start activity via FCM push → token reported to HA → HA sends APNs update via relay → state visible on Lock Screen within 15s (iOS 18 rate limit budget) +2. App backgrounded → WebSocket local push arrives → activity updates without app foregrounding +3. Two simultaneous `start_live_activity` pushes with same `activityID` arrive 5ms apart → reservation pattern ensures only one activity created +4. iOS 15 device receives `start_live_activity` push → `#available` guard fires → `NotificationCommandManager` still processes other commands normally, no crash +5. Activity reaches 8-hour limit → `activityStateUpdates` fires → `mobile_app_live_activity_dismissed` webhook sent to HA → HA stops sending updates +6. `PushProvider` receives `start_live_activity` push → relay local notification posted → main app receives it → activity starts correctly + +--- + +## Security Considerations + +### Lock Screen Data Exposure (Critical) + +Live Activity content is visible on the Lock Screen before Face ID/Touch ID authentication. This is not controllable at the OS level — any data in `ContentState` that the view renders may be seen by anyone who picks up the device. + +**Required mitigations:** +- **Privacy consent gate**: Show a one-time alert before the first activity starts: "Live Activity content, including the entity state you choose, will be visible on your Lock Screen without authentication." Store seen-flag in `UserDefaults`. +- **Documentation**: Companion docs must explicitly warn users not to use Live Activities for alarm armed/disarmed state, lock status, presence information, or health data. +- **Redacted lock screen mode** (Phase 4): Allow users to opt into a "private" mode that shows only the activity title and icon on the lock screen, with full state only when unlocked. + +### Push Token Security (High) + +Activity push tokens are direct-to-device APNs credentials. If stolen, an attacker can push arbitrary content to an active Live Activity. + +**Required mitigations:** +- **Keychain storage**: Push-to-start tokens must be stored in the Keychain, not `UserDefaults`. The existing push token (`pushID`) uses `UserDefaults` — do not follow this pattern for the more sensitive Live Activity tokens. +- **No crash reporter logging**: Activity push tokens and push-to-start tokens must NOT be set as crash reporter user properties. Do not follow the existing `APNS Token` / `FCM Token` pattern in `NotificationManager.swift` for these values. +- **Encrypted webhook only**: The `mobile_app_live_activity_token` webhook request must be sent only when `server.info.connection.webhookSecretBytes(version:)` is non-nil. If encryption is unavailable, queue and retry rather than sending plaintext. +- **Token invalidation on activity end**: When `HandlerEndLiveActivity` ends an activity, POST a `mobile_app_live_activity_dismissed` event so HA can discard the stored token. + +### Source Authentication (High) + +The existing `NotificationCommandManager` dispatches commands based on payload content alone, with no verification that the push originated from the registered HA server. + +**Required mitigation**: `HandlerStartLiveActivity` must verify that the inbound push carries a `webhook_id` matching a registered server before calling ActivityKit. Use `ServerManager.server(for:)` at `Sources/Shared/API/ServerManager.swift` — this check already exists for routing but must be applied at the command handler level. + +### Input Validation (Medium) + +All server-supplied strings (`activityID`, `color`, `iconName`) must be validated before use: +- `activityID`: max 64 chars, `[a-zA-Z0-9\-_]` only +- `color`: must match `/^#?[0-9A-Fa-f]{6}([0-9A-Fa-f]{2})?$/` before passing to hex parser +- `iconName`: max 64 chars; the MDI lookup is safe by design (no filesystem access), but enforce length + +None of these values should be interpolated raw into log statements. + +--- + +## Performance Considerations + +### Update Frequency + +Apple's rate limiting changed significantly in iOS 18: +- **iOS 17 and earlier**: ~1 update/second sustained +- **iOS 18+**: ~15 seconds minimum between updates (enforced silently — the server receives HTTP 200 but the device ignores excess pushes) +- **With `NSSupportsLiveActivitiesFrequentUpdates`**: Higher budget, but still subject to iOS 18 device-level throttling + +**Implication**: Design HA automations to send Live Activity updates only on actual state changes, not on a timer. A HA energy sensor updating every second should NOT trigger a push on every update — the automation should debounce or use a minimum change threshold. + +**Client-side debounce**: The `HandlerUpdateLiveActivity` should impose a 250ms trailing-edge debounce before calling `Activity.update(...)`. High-frequency local push events (WebSocket) would otherwise submit excessive updates. + +### Battery Impact + +Each `Activity.update(...)` call wakes SpringBoard's render server (out-of-process). At 1 Hz sustained: ~3-5% additional battery drain per hour. Recommend the `NSSupportsLiveActivitiesFrequentUpdates` entitlement be surfaced to users as "High-frequency updates (increased battery usage)" — do not enable it unconditionally. + +For timer-style countdowns: use `Text(timerInterval:countsDown:)` instead of sending value updates — the system animates the countdown natively at 0 battery cost. + +--- + +## Acceptance Criteria + +### Functional Requirements — Android Feature Parity (MVP) + +- [ ] An existing Android Live Notification automation works on iOS with only the addition of `live_activity: true` in `data` +- [ ] `tag` field is used as the activity identifier — same `tag` updates the existing activity (no new one created) +- [ ] `title` and `message` display in the Live Activity (matching Android `title`/`message` fields) +- [ ] `progress` and `progress_max` display as a progress bar in the Live Activity (matching Android) +- [ ] `color` applies as the accent color (matching Android) +- [ ] `icon` renders the MDI icon slug (matching Android) +- [ ] `message: clear_notification` with `tag` ends both the Live Activity AND any delivered `UNNotification` with the same identifier +- [ ] On iOS < 16.1 or iPad, `live_activity: true` is ignored; a regular notification banner is shown instead (graceful fallback — no automation changes required) +- [ ] Sending a second notification with the same `tag` silently updates the existing activity (no dismissal, no sound — equivalent to Android's `alert_once: true` behavior) + +### Functional Requirements — iOS Enhancements Beyond Android + +- [ ] Activities display in the Dynamic Island (compact, minimal, expanded) in addition to Lock Screen +- [ ] On iOS 17.2+, a HA automation can remotely start a Live Activity without the app being open (best-effort) +- [ ] Push tokens are sent to HA server (encrypted webhook) so it can update activities via APNs directly +- [ ] Activity dismissal (user swipe, 8-hour limit) is reported back to HA via `mobile_app_live_activity_dismissed` webhook +- [ ] `supports_live_activities: true` appears in HA mobile app integration device registry +- [ ] Multiple concurrent activities are supported (respecting iOS system limits of ~2-3) +- [ ] Privacy consent alert shown once before first use + +### Non-Functional Requirements + +- [ ] No impact on startup time on devices not using Live Activities +- [ ] All code gated with `#available(iOS 16.1, *)` where required; `#if canImport(ActivityKit)` in Shared +- [ ] Live Activity views pass light/dark mode screenshot review on all Dynamic Island presentations +- [ ] Push tokens never logged to crash reporter (Crashlytics/Sentry) +- [ ] Push-to-start token stored in Keychain, not `UserDefaults` +- [ ] `activityID` input validated (max 64 chars, restricted charset) before use + +### Quality Gates + +- [ ] Linting passes: `bundle exec fastlane lint` (SwiftFormat + SwiftLint) +- [ ] Unit tests for `LiveActivityRegistry` actor (start/update/end/deduplication/TOCTOU reservation) +- [ ] Unit tests for each notification command handler (with mocked `LiveActivityRegistry`) +- [ ] Unit tests for `HandlerStartLiveActivity` in PushProvider context — verifies relay path, not ActivityKit call +- [ ] Manual test on physical device (iOS 16.1, iOS 16.2 for `ActivityContent` API, iOS 17.2+ for push-to-start) +- [ ] Screenshots for all Dynamic Island presentations (compact, minimal, expanded) and Lock Screen for PR + +--- + +## Dependencies & Prerequisites + +| Dependency | Status | Notes | +|---|---|---| +| ActivityKit (Apple) | Available, iOS 16.1+ | No CocoaPod needed — system framework | +| HA server-side support (`mobile_app` component) | **Required** | Must handle `supports_live_activities` in registration, `mobile_app_live_activity_token` webhook, and `mobile_app_live_activity_dismissed` event | +| Relay server changes | **Required for Phase 3** | Relay must support `apns-push-type: liveactivity`, JWT-only auth, sandbox/production routing | +| APNs entitlement | Already present | `aps-environment` entitlement exists | +| App Group | Already present | `group.io.robbie.homeassistant` used by Widgets | +| `NSSupportsLiveActivities` Info.plist key | **Missing** | Must be added in Phase 1 | +| `NSSupportsLiveActivitiesFrequentUpdates` | **Missing** | Add in Phase 3; surface as user toggle | +| Companion docs PR | Needed | Document automation YAML, privacy warnings, rate limit guidance | +| HA iOS deployment target clarification | Needed | iOS 16.1 minimum for any ActivityKit; current target is iOS 15.0 | + +--- + +## Risk Analysis & Mitigation + +| Risk | Likelihood | Impact | Mitigation | +|---|---|---|---| +| Server-side HA changes required before feature is useful | High | High | Phase 1–2 local push path works without server changes; file coordinated server PR early | +| `attributes-type` string locked post-ship | High | High | Never rename `HALiveActivityAttributes`; document as immutable in code comments | +| Push-to-start unreliable from terminated state | High | Medium | Document as best-effort; primary flow is foreground-initiated | +| iOS 18 rate limit breaks real-time use cases | High | Medium | Debounce on client, document 15s minimum in companion docs | +| ActivityKit called from PushProvider process | High | Critical | Guard with `Current.isAppExtension` check; add compile-time warning comment | +| Data race on registry dictionary | Medium | High | Swift actor eliminates this entirely | +| Push token exfiltration to crash reporter | Medium | High | Explicit code review checklist item: no token logging | +| Apple changes ActivityKit API (behavior differences) | Medium | Medium | Gate on `#available`; test on both iOS 16.1 and 17.x in CI | +| Lock Screen displays sensitive HA entity data | High | Medium | Privacy consent gate and companion docs warning (user choice remains theirs) | + +--- + +## Key Files to Create / Modify + +### New Files + +``` +Sources/Shared/LiveActivity/ +├── HALiveActivityAttributes.swift # ActivityAttributes + ContentState, field names match Android +└── LiveActivityRegistry.swift # actor managing concurrent activity lifecycle + TOCTOU reservation + +Sources/Extensions/Widgets/LiveActivity/ +├── HALiveActivityConfiguration.swift # ActivityConfiguration + WidgetBundle registration +├── HALockScreenView.swift # Lock Screen view (max 160pt height; progress bar, icon, message) +└── HADynamicIslandView.swift # Dynamic Island: compact / minimal / expanded presentations + +Sources/Shared/Notifications/NotificationCommands/ +└── HandlerLiveActivity.swift # Handles live_activity: true flag and integration with clear_notification +``` + +### Modified Files + +``` +Sources/App/Resources/Info.plist + → NSSupportsLiveActivities = true + → NSSupportsLiveActivitiesFrequentUpdates = true (Phase 3) + +Sources/Extensions/Widgets/Widgets.swift + → Register HALiveActivityConfiguration in WidgetsBundle18 under #available(iOS 16.2, *) + +Sources/Shared/Environment/Environment.swift + → Add var liveActivityRegistry: LiveActivityRegistryProtocol (under #if os(iOS)) + +Sources/Shared/Notifications/NotificationCommands/NotificationsCommandManager.swift + → Register live_activity command handler (under #if os(iOS)) + → Extend HandlerClearNotification to also end matching Live Activity by tag + +Sources/Shared/API/HAAPI.swift + → Add supports_live_activities, live_activity_push_to_start_token to registration payload + +Sources/Shared/Environment/AppConstants.swift + → Add Version.liveActivities constant +``` + +--- + +## Success Metrics + +- Zero crash reports related to Live Activity on iOS < 16.1 or iPad (via Sentry) +- `mobile_app_live_activity_dismissed` webhook fires within 30s of user swiping away activity +- Feature adopted by community within 30 days of HA server support landing +- GitHub discussion #84 closed/referenced + +--- + +## Open Questions (Require Resolution Before or During Implementation) + +1. **Deployment target**: Should the iOS minimum deployment target be raised from 15.0 to 16.1 when Live Activities ship? Or keep 15.0 with `#available` guards? (Recommend: keep 15.0, use `#available` guards) + +2. **Dismissal policy default**: When HA sends `end_live_activity`, should the activity linger (`DismissalPolicy.default` — up to 4 hours showing final state) or dismiss immediately (`DismissalPolicy.immediate`)? Recommend exposing as optional `dismissal_policy` field in the end payload. + +3. **iPad handling**: On iPad where `areActivitiesEnabled == false`, should the app send a webhook back to HA indicating the device doesn't support Live Activities? This would allow HA to suppress the notification option for iPad. + +4. **Multiple servers**: If the user has multiple HA servers registered, should a `start_live_activity` push be scoped to the originating server via `webhook_id`? Recommend: yes, and the `webhook_id` check in the handler enforces this. + +--- + +## Sources & References + +### Cross-Platform Automation Example (Complete) + +This single automation targets both Android 16+ and iOS 16.1+. On older Android it gracefully falls back to a standard notification; on iOS < 16.1 or iPad it falls back to a regular banner. + +```yaml +# automation.yaml — washer cycle tracker (works on Android + iOS) +automation: + - alias: "Washer Started" + trigger: + - platform: state + entity_id: sensor.washer_state + to: "running" + action: + - action: notify.mobile_app_ + data: + title: "Washing Machine" + message: "Cycle in progress" + data: + tag: washer_cycle + live_update: true # Android 16+ + live_activity: true # iOS 16.1+ + critical_text: "Running" # Android: status bar chip. iOS: Dynamic Island compact + progress: 0 + progress_max: 3600 + chronometer: true # show countdown timer + when: 3600 # seconds until done + when_relative: true # treat as duration from now + notification_icon: mdi:washing-machine + notification_icon_color: "#2196F3" + alert_once: true # Android: silent updates + sticky: true # Android: non-dismissible + visibility: public # Android: lock screen visible + + - alias: "Washer Progress Update" + trigger: + - platform: time_pattern + minutes: "/5" + condition: + - condition: state + entity_id: sensor.washer_state + state: "running" + action: + - action: notify.mobile_app_ + data: + title: "Washing Machine" + message: "{{ states('sensor.washer_remaining') }} remaining" + data: + tag: washer_cycle # same tag = update in-place on both platforms + live_update: true + live_activity: true + critical_text: "{{ states('sensor.washer_remaining') }}" + progress: "{{ state_attr('sensor.washer_remaining', 'elapsed_seconds') | int }}" + progress_max: 3600 + chronometer: true + when: "{{ state_attr('sensor.washer_remaining', 'remaining_seconds') | int }}" + when_relative: true + notification_icon: mdi:washing-machine + notification_icon_color: "#2196F3" + alert_once: true + sticky: true + visibility: public + + - alias: "Washer Done" + trigger: + - platform: state + entity_id: sensor.washer_state + to: "idle" + action: + - action: notify.mobile_app_ + data: + message: clear_notification # same YAML on Android and iOS + data: + tag: washer_cycle +``` + +### Community + +- Feature request discussion: https://github.com/orgs/home-assistant/discussions/84 +- Android companion app (reference implementation): https://github.com/home-assistant/android +- Android Live Notifications blog post: https://automateit.lol/live-android-notifications/ +- Reddit discussion: https://www.reddit.com/r/homeassistant/comments/1rw64n1/live_android_notifications/ + +### Internal References + +- Notification command pattern: `Sources/Shared/Notifications/NotificationCommands/NotificationsCommandManager.swift` +- PushProvider architecture: `Sources/Extensions/PushProvider/PushProvider.swift` +- Existing widget entry: `Sources/Extensions/Widgets/Widgets.swift` +- Most recent complete new-feature example (Kiosk mode): `Sources/App/Kiosk/KioskSettings.swift`, `Sources/Shared/Database/Tables/KioskSettingsTable.swift` +- App registration payload: `Sources/Shared/API/HAAPI.swift` (`buildMobileAppRegistration`) +- Version gating pattern: `Sources/Shared/Environment/AppConstants.swift` +- Local push subscription: `Sources/Shared/Notifications/LocalPush/LocalPushManager.swift` +- `AppEnvironment` property-on-protocol pattern: `Sources/Shared/Notifications/Attachments/NotificationAttachmentManager.swift` +- Webhook send pattern (no new type): `Sources/Shared/API/Webhook/Networking/WebhookManager.swift` + +### Apple Developer Documentation + +- ActivityKit framework: https://developer.apple.com/documentation/activitykit +- `ActivityAttributes` protocol: https://developer.apple.com/documentation/activitykit/activityattributes +- Displaying Live Activities: https://developer.apple.com/documentation/activitykit/displaying-live-data-with-live-activities +- Starting and updating with APNs: https://developer.apple.com/documentation/activitykit/starting-and-updating-live-activities-with-activitykit-push-notifications +- `ActivityAuthorizationInfo`: https://developer.apple.com/documentation/activitykit/activityauthorizationinfo +- `ActivityAuthorizationError`: https://developer.apple.com/documentation/activitykit/activityauthorizationerror +- Human Interface Guidelines — Live Activities: https://developer.apple.com/design/human-interface-guidelines/live-activities +- WWDC23 — Meet ActivityKit: https://developer.apple.com/videos/play/wwdc2023/10184/ +- WWDC23 — Update Live Activities with push notifications: https://developer.apple.com/videos/play/wwdc2023/10185/ +- Previewing widgets and Live Activities in Xcode: https://developer.apple.com/documentation/widgetkit/previewing-widgets-and-live-activities-in-xcode +- APNs token-based auth: https://developer.apple.com/documentation/usernotifications/establishing-a-token-based-connection-to-apns + +### External + +- iOS 18 Live Activity rate limit changes: https://9to5mac.com/2024/08/31/live-activities-ios-18/ +- Server-side Live Activities guide (Christian Selig): https://christianselig.com/2024/09/server-side-live-activities/ From 729d1da12e482145e2b83f2180bf040e20475ab3 Mon Sep 17 00:00:00 2001 From: rwarner Date: Wed, 18 Mar 2026 21:20:39 -0400 Subject: [PATCH 09/34] chore: remove plan doc from branch (docs/plans/ is local-only) --- ...03-18-001-feat-ios-live-activities-plan.md | 1044 ----------------- 1 file changed, 1044 deletions(-) delete mode 100644 docs/plans/2026-03-18-001-feat-ios-live-activities-plan.md diff --git a/docs/plans/2026-03-18-001-feat-ios-live-activities-plan.md b/docs/plans/2026-03-18-001-feat-ios-live-activities-plan.md deleted file mode 100644 index cd0b3fd687..0000000000 --- a/docs/plans/2026-03-18-001-feat-ios-live-activities-plan.md +++ /dev/null @@ -1,1044 +0,0 @@ ---- -title: feat: iOS Live Activities for Home Assistant -type: feat -status: active -date: 2026-03-18 -deepened: 2026-03-18 ---- - -# feat: iOS Live Activities for Home Assistant - -## Enhancement Summary - -**Deepened on:** 2026-03-18 -**Research agents used:** ActivityKit framework docs, APNs best practices, security sentinel, architecture strategist, performance oracle, spec-flow analyzer, code simplicity reviewer, async race conditions reviewer - -### Key Improvements Added -1. **Critical process boundary constraint**: `PushProvider` (network extension) cannot call ActivityKit — it runs in a separate OS process. All handlers in the extension must relay to the main app via `UNUserNotificationCenter`. This would have caused silent failures at runtime. -2. **API version split**: The `Activity.request(attributes:contentState:pushType:)` API was deprecated in iOS 16.2 and replaced with `Activity.request(attributes:content:pushType:)` using `ActivityContent`. Code must handle both. -3. **iOS 18 rate limit change**: Apple changed update rate limiting to ~15s minimum between updates in iOS 18. Design must not assume 1 Hz is reliably achievable. -4. **Actor isolation required**: The `[String: Activity]` dictionary will be accessed from multiple threads/queues. Must use a Swift `actor` with a reservation pattern to prevent TOCTOU races. -5. **Simplification**: Remove `updatedAt: Date` and `secondaryState` from MVP `ContentState`. Consolidate 3 handler files to 1. Use `Activity.activities` instead of a parallel dictionary. -6. **Push-to-start unreliability**: Push-to-start from a fully terminated app has ~50% success rate. The primary flow must be foreground-initiated; push-to-start is best-effort only. -7. **Security**: Activity push tokens must be stored in Keychain (not `UserDefaults`), never logged to crash reporters, and only transmitted over the encrypted webhook channel. - -### New Considerations Discovered -- `attributes-type` in APNs push-to-start payload must exactly match the Swift struct name — case-sensitive, immutable post-ship -- Certificate-based APNs auth is not supported for Live Activities; relay server must use JWT (`.p8` key) -- iOS 18 changes update budgets significantly; `NSSupportsLiveActivitiesFrequentUpdates` should be opt-in per activity -- `NotificationService` extension is never invoked for `apns-push-type: liveactivity` pushes -- iPad has `areActivitiesEnabled == false` — must handle gracefully without crash -- App must report capability (`supports_live_activities: Bool`) in registration payload so HA server can gate the UI - ---- - -## Overview - -Implement iOS Live Activities using Apple's ActivityKit framework so that Home Assistant automations can display real-time data on the iOS Lock Screen and Dynamic Island. This is a highly requested community feature (discussion #84). The Android companion app already ships this via persistent/ongoing notifications with tag-based in-place updates. The goal is **feature parity with Android** using the same notification field names so automations can target both platforms with minimal differences. - ---- - -## Android Feature Baseline (What We're Matching) - -The Android companion app has **two tiers** of live/updating notifications: - -### Tier 1: `alert_once: true` + `tag` (any Android version) -Standard notifications updated in-place. Subsequent pushes with the same `tag` replace the notification without re-alerting. - -### Tier 2: `live_update: true` (Android 16+ only) — the primary target -Android 16's native **Live Updates API**. Pins the notification to: -- Status bar as a **chip** showing `critical_text` or a live `chronometer` -- Lock screen (persistent, doesn't scroll away) -- Always-on display - -This is the direct Android equivalent of iOS Live Activities. - -```yaml -# Android 16+: Live Update with progress bar and countdown timer -action: notify.mobile_app_ -data: - title: "Washing Machine" # required for live_update - message: "Cycle in progress" - data: - tag: washer_cycle # unique ID for in-place updates - live_update: true # Android 16+: pin to status bar chip + lock screen - critical_text: "45 min" # short text shown in status bar chip - progress: 2700 # current value (raw integer) - progress_max: 3600 # maximum value - chronometer: true # show countdown timer instead of critical_text - when: 2700 # seconds until done (for chronometer) - when_relative: true # treat `when` as duration, not timestamp - notification_icon: mdi:washing-machine # MDI icon for status bar chip - notification_icon_color: "#2196F3" # icon accent color - alert_once: true # also works on older Android: silent updates - sticky: true # non-dismissible by user - visibility: public # visible on lock screen - -# Android: dismiss it -action: notify.mobile_app_ -data: - message: clear_notification - data: - tag: washer_cycle -``` - -**Key Android `live_update` fields:** - -| Field | Type | Purpose | -|---|---|---| -| `live_update` | bool | Enable Android 16 Live Updates API | -| `tag` | string | Unique ID — same tag = update in-place | -| `title` | string | Required for `live_update` | -| `message` | string | Body text | -| `critical_text` | string | Short text in status bar chip | -| `chronometer` | bool | Show live countdown instead of `critical_text` | -| `when` | int | Seconds for the countdown / timestamp | -| `when_relative` | bool | Treat `when` as relative duration | -| `progress` | int | Current progress value (raw integer) | -| `progress_max` | int | Maximum progress value | -| `notification_icon` | string | MDI slug for status bar icon | -| `notification_icon_color` | string | Hex color for icon | -| `alert_once` | bool | Silence subsequent alerts (older Android fallback) | -| `sticky` | bool | Non-dismissible | -| `visibility` | string | `public` = visible on lock screen | - ---- - -## Problem Statement / Motivation - -Home Assistant users frequently need to monitor time-sensitive states (a washer finishing, a door left open, a timer counting down, a media player progress bar) without constantly opening the app. iOS 16.1 introduced Live Activities via ActivityKit specifically for this use case. Android users already have this capability. iOS companion app users have no equivalent. - ---- - -## Proposed Solution - -Implement iOS Live Activities triggered by the **same notification fields Android uses**. The iOS opt-in field `live_activity: true` mirrors Android's `live_update: true`. All other field names (`tag`, `title`, `message`, `progress`, `progress_max`, `chronometer`, `when`, `when_relative`, `notification_icon`, `notification_icon_color`) are shared between both platforms. - -```yaml -# Works on BOTH Android 16+ and iOS 16.1+: - -action: notify.mobile_app_ -data: - title: "Washing Machine" - message: "45 minutes remaining" - data: - tag: washer_cycle # iOS & Android: unique ID for in-place updates - live_update: true # Android 16+: pin to status bar chip + lock screen - live_activity: true # iOS 16.1+: use Live Activity (Android ignores) - critical_text: "45 min" # Android: status bar chip text. iOS: Dynamic Island compact trailing - progress: 2700 # iOS & Android: current value (raw integer) - progress_max: 3600 # iOS & Android: maximum value - chronometer: true # iOS & Android: show countdown timer - when: 2700 # seconds remaining (used with chronometer) - when_relative: true # treat `when` as duration from now - notification_icon: mdi:washing-machine # iOS & Android: MDI icon slug - notification_icon_color: "#2196F3" # iOS & Android: icon accent color - alert_once: true # Android: silent updates. iOS: ignored (always silent) - sticky: true # Android: non-dismissible. iOS: ignored (always persistent) - visibility: public # Android: lock screen. iOS: ignored (always public) - -# Dismiss — identical on both platforms: -action: notify.mobile_app_ -data: - message: clear_notification - data: - tag: washer_cycle -``` - -**iOS field mapping:** - -| Companion docs field | iOS Live Activity mapping | Notes | -|---|---|---| -| `live_update: true` | — | Android-only opt-in; iOS uses `live_activity: true` | -| `live_activity: true` | Triggers Live Activity | Android ignores unknown fields | -| `tag` | `HALiveActivityAttributes.tag` | Same semantics: same tag = update in-place | -| `title` | `HALiveActivityAttributes.title` | Static attribute, set at activity creation | -| `message` | `ContentState.message` | Primary state text | -| `critical_text` | Dynamic Island compact trailing text | Short label (≤~10 chars) | -| `progress` | `ContentState.progress` | Raw integer | -| `progress_max` | `ContentState.progressMax` | Raw integer; fraction computed for SwiftUI | -| `chronometer: true` | `Text(timerInterval:countsDown:)` | Native iOS — zero battery cost, hardware-smooth | -| `when` + `when_relative` | Countdown end `Date` = `now + when` seconds | Converted to absolute `Date` for ActivityKit | -| `notification_icon` | `ContentState.icon` | MDI slug | -| `notification_icon_color` | `ContentState.color` | Hex string | -| `alert_once`, `sticky`, `visibility` | Ignored | Live Activities handle these natively | -| `clear_notification` + `tag` | Ends Live Activity + clears UNNotification | Same YAML, both platforms | - -On iOS < 16.1 or iPad, `live_activity: true` is ignored and the notification falls through as a regular banner — graceful degradation with no automation changes needed. - -The `ActivityAttributes` schema is wire-format stable: fields are only ever added, never renamed or removed, to maintain APNs compatibility across app updates. - ---- - -## Technical Approach - -### Architecture Overview - -``` -Home Assistant Automation - │ - ▼ -mobile_app service call ──────────────────────────────────────┐ - │ │ - ▼ ▼ - FCM relay → APNs (remote) WebSocket push notification channel - (start/update/end commands) (local push, LAN only) - │ │ - ▼ ▼ - Main App Process Main App Process - NotificationManager LocalPushManager - │ │ - └─────────────────┬─────────────────────────┘ - ▼ - NotificationCommandManager - (handlers registered here) - │ - ▼ - HandlerLiveActivity.swift ← NEW (1 file, 3 structs) - │ - ▼ - LiveActivityRegistry (actor) ← NEW - │ - ▼ - Activity - (ActivityKit — main app ONLY) - │ - ┌─────┴──────────────┐ - ▼ ▼ - Lock Screen Dynamic Island - View Views (compact, - minimal, expanded) - -⚠️ PushProvider (NEAppPushProvider) runs in a SEPARATE PROCESS. - It cannot call ActivityKit. It must relay commands to the - main app via UNUserNotificationCenter local push. -``` - -### iOS Version Requirements - -| Feature | Minimum iOS | -|---|---| -| ActivityKit (basic) | **iOS 16.1** | -| `ActivityContent` / `staleDate` / updated API | **iOS 16.2** | -| Push-to-start (remote start) | **iOS 17.2** | -| `frequentPushesEnabled` user toggle | **iOS 17.2** | -| Current deployment target | iOS 15.0 | - -All ActivityKit code must be wrapped in `#available(iOS 16.1, *)`. Use `#available(iOS 16.2, *)` for `ActivityContent` (the updated API). Push-to-start token registration must be wrapped in `#available(iOS 17.2, *)`. The UI must degrade gracefully on older OS versions (simply absent). iPad returns `areActivitiesEnabled == false` — must not crash. - -### Critical API Version Split (iOS 16.1 vs 16.2) - -The `Activity.request(...)` API changed in iOS 16.2. Both paths must be handled: - -```swift -// iOS 16.1 only (deprecated — supports deployment target iOS 15+): -let activity = try Activity.request( - attributes: attributes, - contentState: initialState, // ← "contentState:" label - pushType: .token -) - -// iOS 16.2+ (preferred): -let content = ActivityContent( - state: initialState, - staleDate: Date().addingTimeInterval(30 * 60), - relevanceScore: 0.5 -) -let activity = try Activity.request( - attributes: attributes, - content: content, // ← "content:" label (ActivityContent wrapper) - pushType: .token -) -``` - -Similarly, `activity.update(using:)` is iOS 16.1 only; use `activity.update(_:)` with `ActivityContent` on iOS 16.2+. - ---- - -### Implementation Phases - -#### Phase 1: Foundation — Data Model & Basic Local Start/End - -**Goal:** Define the ActivityKit data model and be able to start/end a Live Activity from within the app (local only, no push). - -**Tasks:** - -- [ ] **`HALiveActivityAttributes.swift`** — Define the `ActivityAttributes` conforming struct in `Sources/Shared/LiveActivity/` behind `#if canImport(ActivityKit)`. This file must be compiled into BOTH the `iOS-App` target and `Extensions-Widgets` target (via `Shared.framework`). The `attributes-type` string in APNs payloads must exactly match the Swift struct name — **never rename this struct post-ship**. - - ```swift - // Sources/Shared/LiveActivity/HALiveActivityAttributes.swift - #if canImport(ActivityKit) - import ActivityKit - - public struct HALiveActivityAttributes: ActivityAttributes { - // Static: set once at activity creation, cannot change - // These map from the initial notification payload fields - public let tag: String // = Android's `tag` field; unique ID for this activity - public let title: String // = Android's `title` field - - // Dynamic: updated via push or local update - // Field names intentionally mirror Android companion docs notification fields - public struct ContentState: Codable, Hashable { - public var message: String // = `message`. Primary state text - public var criticalText: String? // = `critical_text`. Short text for Dynamic Island compact trailing - public var progress: Int? // = `progress`. Current value (raw integer) - public var progressMax: Int? // = `progress_max`. Maximum value - public var chronometer: Bool? // = `chronometer`. If true, show countdown timer - public var countdownEnd: Date? // = computed from `when` + `when_relative`. Absolute end date for timer - public var icon: String? // = `notification_icon`. MDI slug - public var color: String? // = `notification_icon_color`. Hex string - - // Computed for SwiftUI rendering — not sent over wire - public var progressFraction: Double? { - guard let p = progress, let m = progressMax, m > 0 else { return nil } - return Double(p) / Double(m) - } - } - } - #endif - ``` - - **Payload parsing note:** The handler reads standard notification fields and maps them: - - `when` (int, seconds) + `when_relative: true` → `countdownEnd = Date().addingTimeInterval(Double(when))` - - `when` as absolute Unix timestamp + `when_relative: false` → `countdownEnd = Date(timeIntervalSince1970: Double(when))` - - `notification_icon` → `icon` (stored as-is, MDI slug) - - `notification_icon_color` → `color` (same field semantics as Android `color`) - - **Design notes:** - - All field names in JSON encoding match Android companion docs field names (via `CodingKeys`) - - `progress`/`progress_max` are raw integers (matching Android) — `progressFraction` is computed for SwiftUI - - `updatedAt: Date` omitted — system APNs timestamp handles ordering - - `unit` deferred — add only when a specific layout requires it - - Total encoded size of attributes + ContentState must stay under ~4KB (APNs limit) - - **Never rename this struct or its fields post-ship** — `attributes-type` in APNs push-to-start payloads must match the Swift type name exactly - -- [ ] **`LiveActivityRegistry.swift`** (actor) — in `Sources/Shared/LiveActivity/`: - ```swift - // actor protects concurrent access from push handler queue + token observer tasks - actor LiveActivityRegistry { - struct Entry { - let activity: Activity - let observationTask: Task - } - private var reserved: Set = [] // TOCTOU protection - private var entries: [String: Entry] = [] - - /// Returns false if ID is already reserved or running (prevents duplicate start race) - func reserve(id: String) -> Bool { ... } - func confirmReservation(id: String, entry: Entry) { ... } - func cancelReservation(id: String) { ... } - func remove(id: String) -> Entry? { ... } - func entry(for id: String) -> Entry? { ... } - } - ``` - Exposed via `AppEnvironment` as a protocol-typed property under `#if os(iOS)`, following the `notificationAttachmentManager` pattern. - -- [ ] **`HandlerLiveActivity.swift`** — One file, three `private struct`s, in `Sources/Shared/Notifications/NotificationCommands/`, consistent with `HandlerUpdateComplications` and `HandlerUpdateWidgets` pattern: - - `HandlerStartLiveActivity: NotificationCommandHandler` - - `HandlerUpdateLiveActivity: NotificationCommandHandler` - - `HandlerEndLiveActivity: NotificationCommandHandler` - -- [ ] **Live Activity views** — in `Sources/Extensions/Widgets/LiveActivity/`: - - `HALiveActivityConfiguration.swift` — `ActivityConfiguration` wrapper - - `HALockScreenView.swift` — Lock Screen / StandBy view (max 160pt height) - - `HADynamicIslandView.swift` — All Dynamic Island presentations - -- [ ] **Register `ActivityConfiguration`** in `Sources/Extensions/Widgets/Widgets.swift` inside `WidgetsBundle18` with `#available(iOS 16.2, *)` guard - -- [ ] **`Info.plist`** — Add `NSSupportsLiveActivities = true` to `Sources/App/Resources/Info.plist` - -- [ ] **`AppEnvironment`** — Add `var liveActivityRegistry: LiveActivityRegistryProtocol` under `#if os(iOS)` to `Sources/Shared/Environment/Environment.swift` - -- [ ] **App launch recovery** — In `LiveActivityRegistry.init()` or at startup, enumerate `Activity.activities` to re-attach observation tasks to any activities that survived process termination. This must happen before any push handlers are invoked. - -**Research Insights — Phase 1:** - -**Xcode Preview support (no device needed for UI iteration):** -```swift -#Preview("Lock Screen", as: .content, using: HALiveActivityAttributes(activityID: "test", title: "Washer")) { - HALiveActivityConfiguration() -} contentStates: { - HALiveActivityAttributes.ContentState(state: "Running", value: 0.65, unit: nil, iconName: "mdi:washing-machine", color: "#4CAF50") - HALiveActivityAttributes.ContentState(state: "Done", value: 1.0, unit: nil, iconName: "mdi:check-circle", color: "#2196F3") -} - -#Preview("Dynamic Island Compact", as: .dynamicIsland(.compact), ...) { ... } -#Preview("Dynamic Island Expanded", as: .dynamicIsland(.expanded), ...) { ... } -``` - -**Lock Screen height budget:** The system hard-truncates at **160 points**. Padding counts against this limit. - -**Dynamic Island region layout:** -```swift -DynamicIsland { - DynamicIslandExpandedRegion(.leading) { /* icon */ } - DynamicIslandExpandedRegion(.trailing) { /* value + unit */ } - DynamicIslandExpandedRegion(.center) { /* state text */ } - DynamicIslandExpandedRegion(.bottom) { /* optional detail, full-width */ } -} compactLeading: { /* icon only */ } - compactTrailing: { /* value, caption2 font */ } - minimal: { /* single icon */ } -``` - -**Color rendering optimization:** Do NOT parse hex strings in the SwiftUI view body (runs on every render pass in SpringBoard). Pre-parse hex in `ContentState` decoding or use a cached extension: -```swift -// Add to ContentState -var resolvedColor: Color { Color(hex: color ?? "#FFFFFF") } -``` - -**`ActivityAuthorizationInfo` check before every start:** -```swift -guard ActivityAuthorizationInfo().areActivitiesEnabled else { - // Report back to HA via webhook; do not crash - return -} -``` - -**Success criteria:** -- A Live Activity can be started in-app on a physical iOS 16.1+ device -- The Lock Screen and Dynamic Island show the correct content -- Activity ends cleanly -- Xcode Preview shows all 4 presentations without a device -- On iOS < 16.1 or iPad, code paths are no-ops - ---- - -#### Phase 2: Notification Command Integration (Local Push + APNs Update/End) - -**Goal:** Enable HA automations to start, update, and end Live Activities via the existing notification command system. Push token is reported to HA so it can send APNs updates directly. - -**Tasks:** - -- [ ] **`HandlerLiveActivity`** — new `NotificationCommandHandler` registered for command `live_activity`, containing three private structs in one file: - - **`HandlerStartOrUpdateLiveActivity`** — triggered when any notification arrives with `data.live_activity: true` - - Reads from notification `data` dict: `tag` (required, becomes `activityID`), `title` (required), `message`, `progress`, `progress_max`, `color`, `icon` - - Validates `tag`: max 64 chars, `[a-zA-Z0-9\-_]` only - - If activity with `tag` already running → **update** (matches Android's tag-based replacement) - - If not running → **start** new Live Activity (reservation pattern for TOCTOU safety) - - Reports push token to HA server via webhook immediately after start - - **`HandlerEndLiveActivity`** — triggered by `message: clear_notification` when notification also has a `tag` that matches a running Live Activity - - Integrated into existing `HandlerClearNotification` — check if `tag` matches a running `Activity`; if so, end it in addition to clearing the UNNotification - - Optional `dismissal_policy` field: `immediate` (default), `default` (linger up to 4h), `after:` - - If no matching Live Activity, silently succeeds (existing `clear_notification` behavior preserved) - -- [ ] **Modify `HandlerClearNotification`** — extend to also end any Live Activity whose `tag` attribute matches: - ```swift - // In HandlerClearNotification.handle(_:) - if #available(iOS 16.1, *), let tag = payload["tag"] as? String { - // End matching Live Activity if one exists - if let activity = Activity.activities - .first(where: { $0.attributes.tag == tag }) { - Task { await activity.end(nil, dismissalPolicy: .immediate) } - } - } - // existing UNUserNotificationCenter.current().removeDeliveredNotifications(...) - ``` - -- [ ] **Register `live_activity` command** in `NotificationsCommandManager.init()` under `#if os(iOS)` - -**Platform parity table:** - -| Android field | iOS handling | Notes | -|---|---|---| -| `tag` | `HALiveActivityAttributes.tag` (activityID) | Same field name, same semantics | -| `title` | `HALiveActivityAttributes.title` (static) | Same | -| `message` | `ContentState.message` | Same field name | -| `progress` | `ContentState.progress` | Same field name, raw integer | -| `progress_max` | `ContentState.progressMax` | Camel-cased in Swift, `progress_max` in JSON | -| `color` | `ContentState.color` | Same | -| `icon` | `ContentState.icon` | Same (MDI slug) | -| `alert_once` | Ignored — Live Activities are always silent on update | No action needed | -| `sticky` | Ignored — Live Activities are persistent by nature | No action needed | -| `visibility: public` | Always public on Lock Screen | No action needed | -| `live_activity: true` | Triggers Live Activity path | Android ignores unknown fields | -| `clear_notification` + `tag` | Ends Live Activity AND clears UNNotification | Same YAML works on both | - -- [ ] **PushProvider relay** — `HandlerStartLiveActivity`, `HandlerUpdateLiveActivity`, and `HandlerEndLiveActivity` when running in `PushProvider` process must NOT call ActivityKit. Detect via `Current.isAppExtension` and relay via a local `UNNotificationRequest` instead: - ```swift - // In handler, inside PushProvider process: - if Current.isAppExtension { - let relay = UNMutableNotificationContent() - relay.categoryIdentifier = "HA_LIVE_ACTIVITY_RELAY" - relay.userInfo = payload - let request = UNNotificationRequest(identifier: UUID().uuidString, content: relay, trigger: nil) - UNUserNotificationCenter.current().add(request) - return - } - // Otherwise (main app process), call ActivityKit directly - ``` - The main app's `NotificationManager.userNotificationCenter(_:didReceive:)` handles the relayed notification and calls the registry. - -- [ ] **Push token observation task** — Inside `LiveActivityRegistry`, for each started activity: - ```swift - let observationTask = Task { - for await tokenData in activity.pushTokenUpdates { - guard !Task.isCancelled else { break } - let tokenHex = tokenData.map { String(format: "%02x", $0) }.joined() - // Wrap in background task to prevent suspension mid-report - let bgTask = await UIApplication.shared.beginBackgroundTask(withName: "la-token-update") - defer { UIApplication.shared.endBackgroundTask(bgTask) } - await reportPushToken(tokenHex, activityID: activityID) - } - // Stream ends when activity ends — self-clean - await remove(id: activityID) - } - ``` - -- [ ] **Activity lifecycle observer** — Inside the same task, also observe `activity.activityStateUpdates`: - ```swift - for await state in activity.activityStateUpdates { - if state == .dismissed || state == .ended { - await reportActivityDismissed(activityID: activityID, reason: state == .dismissed ? "user_dismissed" : "ended") - await registry.remove(id: activityID) - break - } - } - ``` - -- [ ] **Push token reporting webhook** — POST to HA via existing `WebhookManager.send(server:request:)`, no new webhook response type needed: - ```swift - let request = WebhookRequest( - type: "mobile_app_live_activity_token", - data: ["activity_id": activityID, "push_token": tokenHex, "apns_environment": apnsEnvironment] - ) - Current.webhooks.send(server: server, request: request) - ``` - -- [ ] **Activity dismissal webhook** — POST `mobile_app_live_activity_dismissed` event to HA when activity state becomes `.dismissed` or `.ended` externally. This is critical so HA stops sending updates. - -- [ ] **Capability advertisement** — Add `supports_live_activities: Bool` and `supports_live_activities_frequent_updates: Bool` and `min_live_activities_ios_version: "16.1"` to `buildMobileAppRegistration()` `app_data` dict in `HAAPI.swift`, under `#if os(iOS)` + `#available(iOS 16.1, *)`. - -- [ ] **Server version gate** — Add to `AppConstants.swift`: - ```swift - public extension Version { - static let liveActivities: Version = .init(major: 2026, minor: 6, prerelease: "any0") - } - ``` - -- [ ] **APNs environment tracking** — Determine sandbox vs production at registration time and include `apns_environment: "sandbox" | "production"` in every token report webhook. The relay server uses this to route to the correct APNs endpoint. Tokens from one environment are rejected by the other. - -- [ ] **Update debounce** — Add a trailing-edge debounce (250ms minimum) in the update handler. High-frequency HA sensors can fire many events per second; the system silently drops excess `Activity.update(...)` calls after consuming CPU. - -**Research Insights — Phase 2:** - -**PromiseKit bridge pattern** (matches existing handler protocol): -```swift -struct HandlerStartLiveActivity: NotificationCommandHandler { - func handle(_ payload: [String: Any]) -> Promise { - let (promise, seal) = Promise.pending() - Task { - do { - try await LiveActivityRegistry.shared.start(payload: payload) - seal.fulfill(()) - } catch ActivityAuthorizationError.activitiesDisabled { - seal.fulfill(()) // User choice — not an error - } catch ActivityAuthorizationError.globalMaximumExceeded { - seal.reject(LiveActivityError.tooManyActivities) - } catch { - seal.reject(error) - } - } - return promise - } -} -``` - -**`ActivityAuthorizationError` cases to handle:** -- `.activitiesDisabled` — user turned off Live Activities in Settings → report to HA, no crash -- `.globalMaximumExceeded` — device limit hit (~2-3 concurrent) → report error to HA -- `.attributesTooLarge` — payload too big → reject with useful error message -- `.pushUpdatesDisabled` — iOS 17.2+ user toggle → report to HA so it knows not to send APNs updates - -**iOS 18 rate limit reality:** Effective minimum update interval is ~15 seconds. HA automations should be designed to fire at most 4 times per minute for non-timer use cases. Build this guidance into companion documentation. - -**Success criteria:** -- Sending `action: notify.mobile_app_` with `message: start_live_activity` starts a Live Activity -- Sending `message: update_live_activity` updates state -- Sending `message: end_live_activity` dismisses the activity -- Push token is successfully delivered to HA server via encrypted webhook -- Activity dismissal (user swipe or 8-hour expiry) is reported back to HA server -- `supports_live_activities: true` appears in HA device registry - ---- - -#### Phase 3: APNs Push-to-Start (Remote Start, iOS 17.2+) - -**Goal:** Allow HA automations to start a Live Activity entirely remotely (app not required in foreground). - -**⚠️ Important caveat:** Push-to-start from a fully terminated app succeeds only ~50% of the time. Design this as a best-effort enhancement, not the primary flow. The primary flow is notification command → app receives push → main app starts activity. - -**Tasks:** - -- [ ] **Push-to-start token observation** — In `LiveActivityRegistry` or `AppDelegate`: - ```swift - @available(iOS 17.2, *) - func observePushToStartToken() { - Task { - for await tokenData in Activity.pushToStartTokenUpdates { - let tokenHex = tokenData.map { String(format: "%02x", $0) }.joined() - // Store in Keychain (NOT UserDefaults — higher-value secret) - Current.keychain.set(tokenHex, forKey: "live_activity_push_to_start_token") - await reportPushToStartToken(tokenHex) - } - } - } - ``` - -- [ ] **Registration payload extension** — Extend `buildMobileAppRegistration()` / `buildMobileAppUpdateRegistration()` in `HAAPI.swift` to include `live_activity_push_to_start_token` in `app_data` when available (iOS 17.2+ only). - -- [ ] **`NSSupportsLiveActivitiesFrequentUpdates`** — Add to `Sources/App/Resources/Info.plist`. Required for push-to-start token to be issued. Also exposes user toggle in iOS Settings. Observe `ActivityAuthorizationInfo().activityEnablementUpdates` to detect when user toggles this off and report to HA. - -- [ ] **`frequentPushesEnabled` reporting** — Report the current value of `ActivityAuthorizationInfo().frequentPushesEnabled` (iOS 17.2+) to HA via registration/update payload. HA server must not send high-frequency pushes when this is `false`. - -- [ ] **APNs payload format** — Document in companion docs. Key constraints: - - `attributes-type` must exactly match Swift struct name (`"HALiveActivityAttributes"`) — **immutable post-ship** - - `apns-push-type: liveactivity` header required - - `apns-topic: io.robbie.HomeAssistant.push-type.liveactivity` - - JWT auth only (`.p8` key) — certificate auth is not supported for Live Activities - - APNs environment must match token environment (sandbox vs production) - - ```json - { - "aps": { - "timestamp": 1234567890, - "event": "start", - "content-state": { - "state": "Running", - "value": 0.65, - "unit": null, - "iconName": "mdi:washing-machine", - "color": "#4CAF50" - }, - "attributes-type": "HALiveActivityAttributes", - "attributes": { - "activityID": "washer-cycle-abc123", - "title": "Washing Machine" - }, - "alert": { - "title": "Washer Started", - "body": "Cycle in progress" - }, - "stale-date": 1234571490, - "relevance-score": 0.5 - } - } - ``` - -- [ ] **Relay server changes** (documented for HA core team) — The relay at `mobile-apps.home-assistant.io` must: - - Add a new endpoint for Live Activity push forwarding (separate from standard notification path because APNs headers differ) - - Support `apns-push-type: liveactivity` and the `.push-type.liveactivity` topic suffix - - Cache the JWT in memory, rotate every 45 minutes (not per-request) - - Route to sandbox vs production APNs endpoint based on `apns_environment` field from the app - - Handle `BadDeviceToken (400)` response as a signal to invalidate the stored token - -**Success criteria:** -- HA automation can start a Live Activity on iOS 17.2+ device without app being open (best-effort) -- Push-to-start token stored in Keychain, reported to HA via registration payload -- Token refresh handled automatically via `pushToStartTokenUpdates` -- Relay server routes to correct APNs environment - ---- - -#### Phase 4: UI Polish & Settings - -**Goal:** Provide configuration options and polished layouts. - -**Tasks:** - -- [ ] **Settings section** — Add "Live Activities" section to existing `NotificationSettingsViewController` hierarchy showing: - - Live Activities enabled status (links to iOS Settings if disabled) - - Active activities list (enumerate `Activity.activities`) - - "End All Activities" button - - Frequent updates toggle status (iOS 17.2+) - -- [ ] **Material Design Icon rendering** — Use existing `MaterialDesignIcons` integration (verify the font resource bundle is included in the Widgets extension target, as it is for standard widgets). MDI slugs decode at view construction time, not in the view body. - -- [ ] **Privacy disclosure** — One-time warning when first Live Activity is started: "Live Activity content is visible on your Lock Screen without Face ID or Touch ID. Choose entities carefully." Stored as a `UserDefaults` seen-flag. - -- [ ] **Timer layout** — Use `ActivityKit`'s native timer support to show countdown with zero additional push updates: - ```swift - Text(timerInterval: startDate...endDate, countsDown: true) - ``` - No update pushes needed for timer progress — the system handles animation natively. - -- [ ] **User-facing documentation** — Companion docs PR with automation YAML examples - -**Deferred (separate issues):** -- `activityType` enum for specialized layouts — APNs schema compatibility risk; open a separate issue when demand is proven -- Multiple specialized `ActivityAttributes` types (media player, delivery tracking) - -**Success criteria:** -- Users can see and manage active Live Activities from app settings -- Icons render correctly from MDI slugs -- Privacy disclosure shown once before first use -- Timers animate without any server-sent updates - ---- - -## Alternative Approaches Considered - -| Approach | Verdict | Reason | -|---|---|---| -| **Strongly-typed activity per use case** (TimerActivity, MediaActivity) | Rejected for MVP | Too prescriptive; HA's flexibility demands a generic model; APNs `attributes-type` string is immutable | -| **Separate Live Activity extension target** | Rejected | WidgetKit extension already exists; ActivityKit views belong in the same `Widgets` target | -| **WebSocket-only updates (no APNs)** | Phase 1-2 only (local push) | APNs push-to-start needed for background start; update pushes from relay for remote update | -| **`LiveActivityManager` class on AppEnvironment from day one** | Deferred | No testability requirement proven yet; call ActivityKit from handlers directly in Phase 1-2; extract manager when tests require mocking | -| **`[String: Activity]` parallel dictionary** | Rejected | System provides `Activity.activities` as authoritative list; parallel dictionary adds crash-recovery gap | -| **New `WebhookResponseLiveActivityToken` type** | Rejected | Existing `WebhookRequest(type:data:)` + `Current.webhooks.send(...)` handles token reporting without new types | - ---- - -## System-Wide Impact - -### Interaction Graph - -`HA automation fires` → `mobile_app.send_message service` → `FCM relay` → `APNs` → `Main app NotificationManager.didReceiveRemoteNotification` → `NotificationCommandManager.handle(_:)` → `HandlerStartLiveActivity.handle(_:)` → `LiveActivityRegistry.reserve(id:)` → `Activity.request(...)` → `Activity.pushTokenUpdates` async stream → `WebhookManager.send(...)` `mobile_app_live_activity_token` → `HA server stores token` → `HA server → relay → APNs update pushes directly to activity token`. - -**PushProvider path** (separate process): `PushProvider receives push` → `NotificationCommandManager.handle(_:)` → `HandlerStartLiveActivity` detects `Current.isAppExtension == true` → posts relay `UNNotificationRequest` → `Main app NotificationManager.userNotificationCenter(_:didReceive:)` → same path as above. - -### Error & Failure Propagation - -- `Activity.request(...)` throws `ActivityAuthorizationError`: - - `.activitiesDisabled` — user toggle off or iPad → report to HA via webhook event `mobile_app_live_activity_start_failed`, reason: `activities_disabled`; no user-visible error - - `.globalMaximumExceeded` — system limit hit → report to HA, suggest ending existing activity - - `.attributesTooLarge` — payload over ~4KB → log error with field sizes; do not surface crash -- Push token reporting failure (network offline) → `WebhookManager` retry logic handles it; the `pushTokenUpdates` stream will also re-emit on next rotation -- Activity dismissed externally → `activityStateUpdates` emits `.dismissed` → `LiveActivityRegistry` removes entry and POSTs `mobile_app_live_activity_dismissed` webhook to HA -- Activity reaches 8-hour system limit → same path as above; HA stops sending updates - -### State Lifecycle Risks - -- **App crash with activities running**: Activities persist on Lock Screen; `LiveActivityRegistry.activities` dictionary is in-memory only. On relaunch, `Activity.activities` (system list) restores tracking. **Must call this at app launch before handling any push commands.** -- **TOCTOU duplicate start**: Two pushes arrive with same `activityID` — reservation pattern in `actor LiveActivityRegistry` prevents both from reaching `Activity.request(...)`. Second caller gets `false` from `reserve(id:)` and updates instead. -- **App update changes `ContentState`**: Adding optional fields is safe (APNs uses JSON, extras ignored). Removing or renaming fields breaks existing activities. Never rename `ContentState` properties post-ship. - -### API Surface Parity - -- `NotificationCommandManager` registers 3 new command handlers — both the main app and `PushProvider` initialize `NotificationCommandManager`; the `PushProvider` instance's handlers must relay, not execute, ActivityKit calls -- `HAAPI.buildMobileAppRegistration()` must include capability fields; `updateRegistration()` must also update them to handle token refresh and OS upgrade scenarios - -### Integration Test Scenarios - -1. Start activity via FCM push → token reported to HA → HA sends APNs update via relay → state visible on Lock Screen within 15s (iOS 18 rate limit budget) -2. App backgrounded → WebSocket local push arrives → activity updates without app foregrounding -3. Two simultaneous `start_live_activity` pushes with same `activityID` arrive 5ms apart → reservation pattern ensures only one activity created -4. iOS 15 device receives `start_live_activity` push → `#available` guard fires → `NotificationCommandManager` still processes other commands normally, no crash -5. Activity reaches 8-hour limit → `activityStateUpdates` fires → `mobile_app_live_activity_dismissed` webhook sent to HA → HA stops sending updates -6. `PushProvider` receives `start_live_activity` push → relay local notification posted → main app receives it → activity starts correctly - ---- - -## Security Considerations - -### Lock Screen Data Exposure (Critical) - -Live Activity content is visible on the Lock Screen before Face ID/Touch ID authentication. This is not controllable at the OS level — any data in `ContentState` that the view renders may be seen by anyone who picks up the device. - -**Required mitigations:** -- **Privacy consent gate**: Show a one-time alert before the first activity starts: "Live Activity content, including the entity state you choose, will be visible on your Lock Screen without authentication." Store seen-flag in `UserDefaults`. -- **Documentation**: Companion docs must explicitly warn users not to use Live Activities for alarm armed/disarmed state, lock status, presence information, or health data. -- **Redacted lock screen mode** (Phase 4): Allow users to opt into a "private" mode that shows only the activity title and icon on the lock screen, with full state only when unlocked. - -### Push Token Security (High) - -Activity push tokens are direct-to-device APNs credentials. If stolen, an attacker can push arbitrary content to an active Live Activity. - -**Required mitigations:** -- **Keychain storage**: Push-to-start tokens must be stored in the Keychain, not `UserDefaults`. The existing push token (`pushID`) uses `UserDefaults` — do not follow this pattern for the more sensitive Live Activity tokens. -- **No crash reporter logging**: Activity push tokens and push-to-start tokens must NOT be set as crash reporter user properties. Do not follow the existing `APNS Token` / `FCM Token` pattern in `NotificationManager.swift` for these values. -- **Encrypted webhook only**: The `mobile_app_live_activity_token` webhook request must be sent only when `server.info.connection.webhookSecretBytes(version:)` is non-nil. If encryption is unavailable, queue and retry rather than sending plaintext. -- **Token invalidation on activity end**: When `HandlerEndLiveActivity` ends an activity, POST a `mobile_app_live_activity_dismissed` event so HA can discard the stored token. - -### Source Authentication (High) - -The existing `NotificationCommandManager` dispatches commands based on payload content alone, with no verification that the push originated from the registered HA server. - -**Required mitigation**: `HandlerStartLiveActivity` must verify that the inbound push carries a `webhook_id` matching a registered server before calling ActivityKit. Use `ServerManager.server(for:)` at `Sources/Shared/API/ServerManager.swift` — this check already exists for routing but must be applied at the command handler level. - -### Input Validation (Medium) - -All server-supplied strings (`activityID`, `color`, `iconName`) must be validated before use: -- `activityID`: max 64 chars, `[a-zA-Z0-9\-_]` only -- `color`: must match `/^#?[0-9A-Fa-f]{6}([0-9A-Fa-f]{2})?$/` before passing to hex parser -- `iconName`: max 64 chars; the MDI lookup is safe by design (no filesystem access), but enforce length - -None of these values should be interpolated raw into log statements. - ---- - -## Performance Considerations - -### Update Frequency - -Apple's rate limiting changed significantly in iOS 18: -- **iOS 17 and earlier**: ~1 update/second sustained -- **iOS 18+**: ~15 seconds minimum between updates (enforced silently — the server receives HTTP 200 but the device ignores excess pushes) -- **With `NSSupportsLiveActivitiesFrequentUpdates`**: Higher budget, but still subject to iOS 18 device-level throttling - -**Implication**: Design HA automations to send Live Activity updates only on actual state changes, not on a timer. A HA energy sensor updating every second should NOT trigger a push on every update — the automation should debounce or use a minimum change threshold. - -**Client-side debounce**: The `HandlerUpdateLiveActivity` should impose a 250ms trailing-edge debounce before calling `Activity.update(...)`. High-frequency local push events (WebSocket) would otherwise submit excessive updates. - -### Battery Impact - -Each `Activity.update(...)` call wakes SpringBoard's render server (out-of-process). At 1 Hz sustained: ~3-5% additional battery drain per hour. Recommend the `NSSupportsLiveActivitiesFrequentUpdates` entitlement be surfaced to users as "High-frequency updates (increased battery usage)" — do not enable it unconditionally. - -For timer-style countdowns: use `Text(timerInterval:countsDown:)` instead of sending value updates — the system animates the countdown natively at 0 battery cost. - ---- - -## Acceptance Criteria - -### Functional Requirements — Android Feature Parity (MVP) - -- [ ] An existing Android Live Notification automation works on iOS with only the addition of `live_activity: true` in `data` -- [ ] `tag` field is used as the activity identifier — same `tag` updates the existing activity (no new one created) -- [ ] `title` and `message` display in the Live Activity (matching Android `title`/`message` fields) -- [ ] `progress` and `progress_max` display as a progress bar in the Live Activity (matching Android) -- [ ] `color` applies as the accent color (matching Android) -- [ ] `icon` renders the MDI icon slug (matching Android) -- [ ] `message: clear_notification` with `tag` ends both the Live Activity AND any delivered `UNNotification` with the same identifier -- [ ] On iOS < 16.1 or iPad, `live_activity: true` is ignored; a regular notification banner is shown instead (graceful fallback — no automation changes required) -- [ ] Sending a second notification with the same `tag` silently updates the existing activity (no dismissal, no sound — equivalent to Android's `alert_once: true` behavior) - -### Functional Requirements — iOS Enhancements Beyond Android - -- [ ] Activities display in the Dynamic Island (compact, minimal, expanded) in addition to Lock Screen -- [ ] On iOS 17.2+, a HA automation can remotely start a Live Activity without the app being open (best-effort) -- [ ] Push tokens are sent to HA server (encrypted webhook) so it can update activities via APNs directly -- [ ] Activity dismissal (user swipe, 8-hour limit) is reported back to HA via `mobile_app_live_activity_dismissed` webhook -- [ ] `supports_live_activities: true` appears in HA mobile app integration device registry -- [ ] Multiple concurrent activities are supported (respecting iOS system limits of ~2-3) -- [ ] Privacy consent alert shown once before first use - -### Non-Functional Requirements - -- [ ] No impact on startup time on devices not using Live Activities -- [ ] All code gated with `#available(iOS 16.1, *)` where required; `#if canImport(ActivityKit)` in Shared -- [ ] Live Activity views pass light/dark mode screenshot review on all Dynamic Island presentations -- [ ] Push tokens never logged to crash reporter (Crashlytics/Sentry) -- [ ] Push-to-start token stored in Keychain, not `UserDefaults` -- [ ] `activityID` input validated (max 64 chars, restricted charset) before use - -### Quality Gates - -- [ ] Linting passes: `bundle exec fastlane lint` (SwiftFormat + SwiftLint) -- [ ] Unit tests for `LiveActivityRegistry` actor (start/update/end/deduplication/TOCTOU reservation) -- [ ] Unit tests for each notification command handler (with mocked `LiveActivityRegistry`) -- [ ] Unit tests for `HandlerStartLiveActivity` in PushProvider context — verifies relay path, not ActivityKit call -- [ ] Manual test on physical device (iOS 16.1, iOS 16.2 for `ActivityContent` API, iOS 17.2+ for push-to-start) -- [ ] Screenshots for all Dynamic Island presentations (compact, minimal, expanded) and Lock Screen for PR - ---- - -## Dependencies & Prerequisites - -| Dependency | Status | Notes | -|---|---|---| -| ActivityKit (Apple) | Available, iOS 16.1+ | No CocoaPod needed — system framework | -| HA server-side support (`mobile_app` component) | **Required** | Must handle `supports_live_activities` in registration, `mobile_app_live_activity_token` webhook, and `mobile_app_live_activity_dismissed` event | -| Relay server changes | **Required for Phase 3** | Relay must support `apns-push-type: liveactivity`, JWT-only auth, sandbox/production routing | -| APNs entitlement | Already present | `aps-environment` entitlement exists | -| App Group | Already present | `group.io.robbie.homeassistant` used by Widgets | -| `NSSupportsLiveActivities` Info.plist key | **Missing** | Must be added in Phase 1 | -| `NSSupportsLiveActivitiesFrequentUpdates` | **Missing** | Add in Phase 3; surface as user toggle | -| Companion docs PR | Needed | Document automation YAML, privacy warnings, rate limit guidance | -| HA iOS deployment target clarification | Needed | iOS 16.1 minimum for any ActivityKit; current target is iOS 15.0 | - ---- - -## Risk Analysis & Mitigation - -| Risk | Likelihood | Impact | Mitigation | -|---|---|---|---| -| Server-side HA changes required before feature is useful | High | High | Phase 1–2 local push path works without server changes; file coordinated server PR early | -| `attributes-type` string locked post-ship | High | High | Never rename `HALiveActivityAttributes`; document as immutable in code comments | -| Push-to-start unreliable from terminated state | High | Medium | Document as best-effort; primary flow is foreground-initiated | -| iOS 18 rate limit breaks real-time use cases | High | Medium | Debounce on client, document 15s minimum in companion docs | -| ActivityKit called from PushProvider process | High | Critical | Guard with `Current.isAppExtension` check; add compile-time warning comment | -| Data race on registry dictionary | Medium | High | Swift actor eliminates this entirely | -| Push token exfiltration to crash reporter | Medium | High | Explicit code review checklist item: no token logging | -| Apple changes ActivityKit API (behavior differences) | Medium | Medium | Gate on `#available`; test on both iOS 16.1 and 17.x in CI | -| Lock Screen displays sensitive HA entity data | High | Medium | Privacy consent gate and companion docs warning (user choice remains theirs) | - ---- - -## Key Files to Create / Modify - -### New Files - -``` -Sources/Shared/LiveActivity/ -├── HALiveActivityAttributes.swift # ActivityAttributes + ContentState, field names match Android -└── LiveActivityRegistry.swift # actor managing concurrent activity lifecycle + TOCTOU reservation - -Sources/Extensions/Widgets/LiveActivity/ -├── HALiveActivityConfiguration.swift # ActivityConfiguration + WidgetBundle registration -├── HALockScreenView.swift # Lock Screen view (max 160pt height; progress bar, icon, message) -└── HADynamicIslandView.swift # Dynamic Island: compact / minimal / expanded presentations - -Sources/Shared/Notifications/NotificationCommands/ -└── HandlerLiveActivity.swift # Handles live_activity: true flag and integration with clear_notification -``` - -### Modified Files - -``` -Sources/App/Resources/Info.plist - → NSSupportsLiveActivities = true - → NSSupportsLiveActivitiesFrequentUpdates = true (Phase 3) - -Sources/Extensions/Widgets/Widgets.swift - → Register HALiveActivityConfiguration in WidgetsBundle18 under #available(iOS 16.2, *) - -Sources/Shared/Environment/Environment.swift - → Add var liveActivityRegistry: LiveActivityRegistryProtocol (under #if os(iOS)) - -Sources/Shared/Notifications/NotificationCommands/NotificationsCommandManager.swift - → Register live_activity command handler (under #if os(iOS)) - → Extend HandlerClearNotification to also end matching Live Activity by tag - -Sources/Shared/API/HAAPI.swift - → Add supports_live_activities, live_activity_push_to_start_token to registration payload - -Sources/Shared/Environment/AppConstants.swift - → Add Version.liveActivities constant -``` - ---- - -## Success Metrics - -- Zero crash reports related to Live Activity on iOS < 16.1 or iPad (via Sentry) -- `mobile_app_live_activity_dismissed` webhook fires within 30s of user swiping away activity -- Feature adopted by community within 30 days of HA server support landing -- GitHub discussion #84 closed/referenced - ---- - -## Open Questions (Require Resolution Before or During Implementation) - -1. **Deployment target**: Should the iOS minimum deployment target be raised from 15.0 to 16.1 when Live Activities ship? Or keep 15.0 with `#available` guards? (Recommend: keep 15.0, use `#available` guards) - -2. **Dismissal policy default**: When HA sends `end_live_activity`, should the activity linger (`DismissalPolicy.default` — up to 4 hours showing final state) or dismiss immediately (`DismissalPolicy.immediate`)? Recommend exposing as optional `dismissal_policy` field in the end payload. - -3. **iPad handling**: On iPad where `areActivitiesEnabled == false`, should the app send a webhook back to HA indicating the device doesn't support Live Activities? This would allow HA to suppress the notification option for iPad. - -4. **Multiple servers**: If the user has multiple HA servers registered, should a `start_live_activity` push be scoped to the originating server via `webhook_id`? Recommend: yes, and the `webhook_id` check in the handler enforces this. - ---- - -## Sources & References - -### Cross-Platform Automation Example (Complete) - -This single automation targets both Android 16+ and iOS 16.1+. On older Android it gracefully falls back to a standard notification; on iOS < 16.1 or iPad it falls back to a regular banner. - -```yaml -# automation.yaml — washer cycle tracker (works on Android + iOS) -automation: - - alias: "Washer Started" - trigger: - - platform: state - entity_id: sensor.washer_state - to: "running" - action: - - action: notify.mobile_app_ - data: - title: "Washing Machine" - message: "Cycle in progress" - data: - tag: washer_cycle - live_update: true # Android 16+ - live_activity: true # iOS 16.1+ - critical_text: "Running" # Android: status bar chip. iOS: Dynamic Island compact - progress: 0 - progress_max: 3600 - chronometer: true # show countdown timer - when: 3600 # seconds until done - when_relative: true # treat as duration from now - notification_icon: mdi:washing-machine - notification_icon_color: "#2196F3" - alert_once: true # Android: silent updates - sticky: true # Android: non-dismissible - visibility: public # Android: lock screen visible - - - alias: "Washer Progress Update" - trigger: - - platform: time_pattern - minutes: "/5" - condition: - - condition: state - entity_id: sensor.washer_state - state: "running" - action: - - action: notify.mobile_app_ - data: - title: "Washing Machine" - message: "{{ states('sensor.washer_remaining') }} remaining" - data: - tag: washer_cycle # same tag = update in-place on both platforms - live_update: true - live_activity: true - critical_text: "{{ states('sensor.washer_remaining') }}" - progress: "{{ state_attr('sensor.washer_remaining', 'elapsed_seconds') | int }}" - progress_max: 3600 - chronometer: true - when: "{{ state_attr('sensor.washer_remaining', 'remaining_seconds') | int }}" - when_relative: true - notification_icon: mdi:washing-machine - notification_icon_color: "#2196F3" - alert_once: true - sticky: true - visibility: public - - - alias: "Washer Done" - trigger: - - platform: state - entity_id: sensor.washer_state - to: "idle" - action: - - action: notify.mobile_app_ - data: - message: clear_notification # same YAML on Android and iOS - data: - tag: washer_cycle -``` - -### Community - -- Feature request discussion: https://github.com/orgs/home-assistant/discussions/84 -- Android companion app (reference implementation): https://github.com/home-assistant/android -- Android Live Notifications blog post: https://automateit.lol/live-android-notifications/ -- Reddit discussion: https://www.reddit.com/r/homeassistant/comments/1rw64n1/live_android_notifications/ - -### Internal References - -- Notification command pattern: `Sources/Shared/Notifications/NotificationCommands/NotificationsCommandManager.swift` -- PushProvider architecture: `Sources/Extensions/PushProvider/PushProvider.swift` -- Existing widget entry: `Sources/Extensions/Widgets/Widgets.swift` -- Most recent complete new-feature example (Kiosk mode): `Sources/App/Kiosk/KioskSettings.swift`, `Sources/Shared/Database/Tables/KioskSettingsTable.swift` -- App registration payload: `Sources/Shared/API/HAAPI.swift` (`buildMobileAppRegistration`) -- Version gating pattern: `Sources/Shared/Environment/AppConstants.swift` -- Local push subscription: `Sources/Shared/Notifications/LocalPush/LocalPushManager.swift` -- `AppEnvironment` property-on-protocol pattern: `Sources/Shared/Notifications/Attachments/NotificationAttachmentManager.swift` -- Webhook send pattern (no new type): `Sources/Shared/API/Webhook/Networking/WebhookManager.swift` - -### Apple Developer Documentation - -- ActivityKit framework: https://developer.apple.com/documentation/activitykit -- `ActivityAttributes` protocol: https://developer.apple.com/documentation/activitykit/activityattributes -- Displaying Live Activities: https://developer.apple.com/documentation/activitykit/displaying-live-data-with-live-activities -- Starting and updating with APNs: https://developer.apple.com/documentation/activitykit/starting-and-updating-live-activities-with-activitykit-push-notifications -- `ActivityAuthorizationInfo`: https://developer.apple.com/documentation/activitykit/activityauthorizationinfo -- `ActivityAuthorizationError`: https://developer.apple.com/documentation/activitykit/activityauthorizationerror -- Human Interface Guidelines — Live Activities: https://developer.apple.com/design/human-interface-guidelines/live-activities -- WWDC23 — Meet ActivityKit: https://developer.apple.com/videos/play/wwdc2023/10184/ -- WWDC23 — Update Live Activities with push notifications: https://developer.apple.com/videos/play/wwdc2023/10185/ -- Previewing widgets and Live Activities in Xcode: https://developer.apple.com/documentation/widgetkit/previewing-widgets-and-live-activities-in-xcode -- APNs token-based auth: https://developer.apple.com/documentation/usernotifications/establishing-a-token-based-connection-to-apns - -### External - -- iOS 18 Live Activity rate limit changes: https://9to5mac.com/2024/08/31/live-activities-ios-18/ -- Server-side Live Activities guide (Christian Selig): https://christianselig.com/2024/09/server-side-live-activities/ From 887e69a69ccd762688795a059532fc711c4af619 Mon Sep 17 00:00:00 2001 From: rwarner Date: Wed, 18 Mar 2026 21:21:07 -0400 Subject: [PATCH 10/34] chore: ignore docs/plans/ (local planning artifacts) --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index d946001b9d..3a64bce3ed 100644 --- a/.gitignore +++ b/.gitignore @@ -86,3 +86,4 @@ vendor/bundle .env.development .bundle/ .claude/settings.local.json +docs/plans/ From 7c48aa297f6e8366ad3dee266ac6a0bf6600e09e Mon Sep 17 00:00:00 2001 From: rwarner Date: Wed, 18 Mar 2026 21:21:21 -0400 Subject: [PATCH 11/34] Revert "chore: ignore docs/plans/ (local planning artifacts)" This reverts commit dc8ad4476a7dc59f5789976bc11b23f517777d36. --- .gitignore | 1 - 1 file changed, 1 deletion(-) diff --git a/.gitignore b/.gitignore index 3a64bce3ed..d946001b9d 100644 --- a/.gitignore +++ b/.gitignore @@ -86,4 +86,3 @@ vendor/bundle .env.development .bundle/ .claude/settings.local.json -docs/plans/ From 3949c08ecaa548cc0ba5f1e3ae701a0f3b58277e Mon Sep 17 00:00:00 2001 From: rwarner Date: Wed, 18 Mar 2026 22:42:15 -0400 Subject: [PATCH 12/34] fix(live-activity): resolve build errors and register files in Xcode project MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add import ActivityKit to HAAPI.swift so ActivityAuthorizationInfo resolves - Fix Activity.end() availability: use end(using:dismissalPolicy:) on iOS 16.1, end(_:dismissalPolicy:) on iOS 16.2+ (API signature changed between versions) - Fix MaterialDesignIcons usage: init is non-failable, remove if-let binding - Fix L10n references: EndAll.confirmTitle/confirmButton → EndAll.Confirm.title/button - Fix SFSymbol names: remove erroneous Icon suffix (.livephoto, .lockShield, .bolt) - Remove #Preview blocks incompatible with iOS 26 SDK's PreviewActivityBuilder - Register all new files in Xcode project (pbxproj) with correct targets - Add NSSupportsLiveActivities and NSSupportsLiveActivitiesFrequentUpdates to Info.plist Co-Authored-By: Claude Sonnet 4.6 --- HomeAssistant.xcodeproj/project.pbxproj | 52 ++++++++++++++++++ Sources/App/Resources/Info.plist | 4 ++ .../LiveActivitySettingsView.swift | 20 ++----- .../LiveActivity/HADynamicIslandView.swift | 50 +---------------- .../LiveActivity/HALockScreenView.swift | 50 +---------------- Sources/Shared/API/HAAPI.swift | 3 ++ .../LiveActivity/LiveActivityRegistry.swift | 12 ++++- .../Shared/Resources/Swiftgen/Strings.swift | 54 ++++++++++--------- 8 files changed, 106 insertions(+), 139 deletions(-) diff --git a/HomeAssistant.xcodeproj/project.pbxproj b/HomeAssistant.xcodeproj/project.pbxproj index 20eddf564d..5ec46163e4 100644 --- a/HomeAssistant.xcodeproj/project.pbxproj +++ b/HomeAssistant.xcodeproj/project.pbxproj @@ -1246,6 +1246,13 @@ 999549244371450BC98C700E /* Pods_iOS_Extensions_PushProvider.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 608CFDA223EBCDF01B946093 /* Pods_iOS_Extensions_PushProvider.framework */; }; A2F3A140CDD1EF1AEA6DFAB9 /* Database/DatabaseTableProtocol.test.swift in Sources */ = {isa = PBXBuildFile; fileRef = BC31518EE9DC9E065AC508D9 /* Database/DatabaseTableProtocol.test.swift */; }; A5A3C1932BE1F4A40EA78754 /* Pods-iOS-Extensions-Matter-metadata.plist in Resources */ = {isa = PBXBuildFile; fileRef = 392B0C44197C98E2653932A5 /* Pods-iOS-Extensions-Matter-metadata.plist */; }; + A95FDD0C2F6B89C6008EF72F /* LiveActivityRegistry.swift in Sources */ = {isa = PBXBuildFile; fileRef = A95FDD0A2F6B89C6008EF72F /* LiveActivityRegistry.swift */; }; + A95FDD0D2F6B89C6008EF72F /* HALiveActivityAttributes.swift in Sources */ = {isa = PBXBuildFile; fileRef = A95FDD092F6B89C6008EF72F /* HALiveActivityAttributes.swift */; }; + A95FDD0F2F6B8A19008EF72F /* HandlerLiveActivity.swift in Sources */ = {isa = PBXBuildFile; fileRef = A95FDD0E2F6B8A19008EF72F /* HandlerLiveActivity.swift */; }; + A95FDD142F6B8A3E008EF72F /* HADynamicIslandView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A95FDD102F6B8A3E008EF72F /* HADynamicIslandView.swift */; }; + A95FDD152F6B8A3E008EF72F /* HALiveActivityConfiguration.swift in Sources */ = {isa = PBXBuildFile; fileRef = A95FDD112F6B8A3E008EF72F /* HALiveActivityConfiguration.swift */; }; + A95FDD162F6B8A3E008EF72F /* HALockScreenView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A95FDD122F6B8A3E008EF72F /* HALockScreenView.swift */; }; + A95FDD192F6B8A5B008EF72F /* LiveActivitySettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A95FDD172F6B8A5B008EF72F /* LiveActivitySettingsView.swift */; }; AB3E076F146799C008ACB0EA /* Pods_iOS_Extensions_NotificationContent.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = B7D8DAEFAD435091FDDD61E7 /* Pods_iOS_Extensions_NotificationContent.framework */; }; B6022213226DAC9D00E8DBFE /* ScaledFont.swift in Sources */ = {isa = PBXBuildFile; fileRef = B6022212226DAC9D00E8DBFE /* ScaledFont.swift */; }; B60248001FBD343000998205 /* InfoPlist.strings in Resources */ = {isa = PBXBuildFile; fileRef = B60247FE1FBD343000998205 /* InfoPlist.strings */; }; @@ -3069,6 +3076,13 @@ 9EE9A0E08E6FEBDDE425D0D4 /* Database/TableSchemaTests.test.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Database/TableSchemaTests.test.swift; sourceTree = ""; }; 9F9398CFD66E4C66DC39E1D3 /* Pods-iOS-Extensions-PushProvider.beta.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-iOS-Extensions-PushProvider.beta.xcconfig"; path = "Pods/Target Support Files/Pods-iOS-Extensions-PushProvider/Pods-iOS-Extensions-PushProvider.beta.xcconfig"; sourceTree = ""; }; A1A7DD090A1D41ADB9374E7A /* OnboardingAuthError.test.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OnboardingAuthError.test.swift; sourceTree = ""; }; + A95FDD092F6B89C6008EF72F /* HALiveActivityAttributes.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HALiveActivityAttributes.swift; sourceTree = ""; }; + A95FDD0A2F6B89C6008EF72F /* LiveActivityRegistry.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LiveActivityRegistry.swift; sourceTree = ""; }; + A95FDD0E2F6B8A19008EF72F /* HandlerLiveActivity.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HandlerLiveActivity.swift; sourceTree = ""; }; + A95FDD102F6B8A3E008EF72F /* HADynamicIslandView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HADynamicIslandView.swift; sourceTree = ""; }; + A95FDD112F6B8A3E008EF72F /* HALiveActivityConfiguration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HALiveActivityConfiguration.swift; sourceTree = ""; }; + A95FDD122F6B8A3E008EF72F /* HALockScreenView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HALockScreenView.swift; sourceTree = ""; }; + A95FDD172F6B8A5B008EF72F /* LiveActivitySettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LiveActivitySettingsView.swift; sourceTree = ""; }; AA48C686F844D08C426A8D74 /* Pods_Tests_App.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Tests_App.framework; sourceTree = BUILT_PRODUCTS_DIR; }; AC24B1CAB85767B8171BB850 /* Pods-iOS-Extensions-NotificationContent.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-iOS-Extensions-NotificationContent.release.xcconfig"; path = "Pods/Target Support Files/Pods-iOS-Extensions-NotificationContent/Pods-iOS-Extensions-NotificationContent.release.xcconfig"; sourceTree = ""; }; ADC769271BB34C474C2D1E24 /* Pods-iOS-Shared-iOS-metadata.plist */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.xml; name = "Pods-iOS-Shared-iOS-metadata.plist"; path = "Pods/Pods-iOS-Shared-iOS-metadata.plist"; sourceTree = ""; }; @@ -3884,6 +3898,7 @@ 1171506E24DFCDE60065E874 /* Widgets */ = { isa = PBXGroup; children = ( + A95FDD132F6B8A3E008EF72F /* LiveActivity */, 428863582EF9641400319CF4 /* CameraList */, 95A32294D4A340198B769AAB /* CommonlyUsedEntities */, 42FF5E8F2E22D7DA00BDF5EF /* TodoList */, @@ -4141,6 +4156,7 @@ 11ADF93D267D34A20040A7E3 /* NotificationCommands */ = { isa = PBXGroup; children = ( + A95FDD0E2F6B8A19008EF72F /* HandlerLiveActivity.swift */, 11ADF93E267D34AD0040A7E3 /* NotificationsCommandManager.swift */, ); path = NotificationCommands; @@ -6485,6 +6501,33 @@ path = Configuration; sourceTree = ""; }; + A95FDD0B2F6B89C6008EF72F /* LiveActivity */ = { + isa = PBXGroup; + children = ( + A95FDD092F6B89C6008EF72F /* HALiveActivityAttributes.swift */, + A95FDD0A2F6B89C6008EF72F /* LiveActivityRegistry.swift */, + ); + path = LiveActivity; + sourceTree = ""; + }; + A95FDD132F6B8A3E008EF72F /* LiveActivity */ = { + isa = PBXGroup; + children = ( + A95FDD102F6B8A3E008EF72F /* HADynamicIslandView.swift */, + A95FDD112F6B8A3E008EF72F /* HALiveActivityConfiguration.swift */, + A95FDD122F6B8A3E008EF72F /* HALockScreenView.swift */, + ); + path = LiveActivity; + sourceTree = ""; + }; + A95FDD182F6B8A5B008EF72F /* LiveActivity */ = { + isa = PBXGroup; + children = ( + A95FDD172F6B8A5B008EF72F /* LiveActivitySettingsView.swift */, + ); + path = LiveActivity; + sourceTree = ""; + }; AAB60FA4DE371AD957F6907B /* Pods */ = { isa = PBXGroup; children = ( @@ -6839,6 +6882,7 @@ B661FB6B226BCC8500E541DD /* Settings */ = { isa = PBXGroup; children = ( + A95FDD182F6B8A5B008EF72F /* LiveActivity */, 4257EA912F1790DE00D81506 /* EntityPicker */, 429AFE5A2DB7BE3200AF0836 /* General */, 4211551F2D3525E800A71630 /* AppIcon */, @@ -7092,6 +7136,7 @@ D03D891820E0A85300D4F28D /* Shared */ = { isa = PBXGroup; children = ( + A95FDD0B2F6B89C6008EF72F /* LiveActivity */, 42BB4C362CD26490003E47FD /* HATypedRequest+App.swift */, 4236229F2F05587800391BD0 /* EntityIconColorProvider.swift */, 429C33C02F17A6CF0033EF5E /* Models */, @@ -8971,6 +9016,9 @@ 42C1012A2CD3DB8A0012BA78 /* CoverIntent.swift in Sources */, 426CBB6C2C9C550D003CA3AC /* IntentSwitchEntity.swift in Sources */, 110E694424E77125004AA96D /* WidgetActionsProvider.swift in Sources */, + A95FDD142F6B8A3E008EF72F /* HADynamicIslandView.swift in Sources */, + A95FDD152F6B8A3E008EF72F /* HALiveActivityConfiguration.swift in Sources */, + A95FDD162F6B8A3E008EF72F /* HALockScreenView.swift in Sources */, 42BA1BC82C8864C200A2FC36 /* OpenPageAppIntent.swift in Sources */, 426CBB6A2C9C543F003CA3AC /* ControlSwitchValueProvider.swift in Sources */, 421F2BA12EF847AA00F21FE5 /* AutomationAppIntent.swift in Sources */, @@ -9154,6 +9202,7 @@ 4201605F2E79A68C00F68044 /* BaseOnboardingView.swift in Sources */, 403AE92A2C2F3A9200D48147 /* IntentServerAppEntitiy.swift in Sources */, 1101568524D770B2009424C9 /* NFCReader.swift in Sources */, + A95FDD192F6B8A5B008EF72F /* LiveActivitySettingsView.swift in Sources */, 1185DF9A271FE60F00ED7D9A /* OnboardingAuthStep.swift in Sources */, 420FE8502B556F7500878E06 /* CarPlayEntitiesListTemplate+Build.swift in Sources */, 42B95B522BE007E30070F2D4 /* SafeScriptMessageHandler.swift in Sources */, @@ -9985,6 +10034,8 @@ 42F5CAE72B10CDC900409816 /* CardView.swift in Sources */, 4251AAC02C6CE376004CCC9D /* MagicItem.swift in Sources */, 115560E827011E3300A8F818 /* HAPanel.swift in Sources */, + A95FDD0C2F6B89C6008EF72F /* LiveActivityRegistry.swift in Sources */, + A95FDD0D2F6B89C6008EF72F /* HALiveActivityAttributes.swift in Sources */, 420CFC812D3F9D89009A94F3 /* DatabaseTables.swift in Sources */, 11C9E43B2505B04E00492A88 /* HACoreAudioObjectSystem.swift in Sources */, D0C88464211F33CE00CCB501 /* TokenManager.swift in Sources */, @@ -10031,6 +10082,7 @@ 11B38EEA275C54A200205C7B /* PickAServerError.swift in Sources */, B6B74CBD228399AB00D58A68 /* Action.swift in Sources */, 428DC00A2F0CAAE7003B08D5 /* EntityProvider+Details.swift in Sources */, + A95FDD0F2F6B8A19008EF72F /* HandlerLiveActivity.swift in Sources */, 11CB98CA249E62E700B05222 /* Version+HA.swift in Sources */, 420F53EA2C4E9D54003C8415 /* WidgetsKind.swift in Sources */, 11EE9B4924C5116F00404AF8 /* LegacyModelManager.swift in Sources */, diff --git a/Sources/App/Resources/Info.plist b/Sources/App/Resources/Info.plist index 02e6a7a3c1..ba7146e8ae 100644 --- a/Sources/App/Resources/Info.plist +++ b/Sources/App/Resources/Info.plist @@ -129,6 +129,10 @@ We use Siri to allow created shortcuts to interact with the app. NSSpeechRecognitionUsageDescription Used to dictate text to Assist. + NSSupportsLiveActivities + + NSSupportsLiveActivitiesFrequentUpdates + NSUserActivityTypes AssistInAppIntent diff --git a/Sources/App/Settings/LiveActivity/LiveActivitySettingsView.swift b/Sources/App/Settings/LiveActivity/LiveActivitySettingsView.swift index eebbe954eb..3b3474bbd1 100644 --- a/Sources/App/Settings/LiveActivity/LiveActivitySettingsView.swift +++ b/Sources/App/Settings/LiveActivity/LiveActivitySettingsView.swift @@ -49,11 +49,11 @@ struct LiveActivitySettingsView: View { Label(L10n.LiveActivity.EndAll.button, systemSymbol: .xmarkCircle) } .confirmationDialog( - L10n.LiveActivity.EndAll.confirmTitle, + L10n.LiveActivity.EndAll.Confirm.title, isPresented: $showEndAllConfirmation, titleVisibility: .visible ) { - Button(L10n.LiveActivity.EndAll.confirmButton, role: .destructive) { + Button(L10n.LiveActivity.EndAll.Confirm.button, role: .destructive) { endAllActivities() } Button(L10n.cancelLabel, role: .cancel) {} @@ -76,7 +76,7 @@ struct LiveActivitySettingsView: View { private var statusSection: some View { Section(L10n.LiveActivity.Section.status) { HStack { - Label(L10n.LiveActivity.title, systemSymbol: .livephotoIcon) + Label(L10n.LiveActivity.title, systemSymbol: .livephoto) Spacer() if authorizationEnabled { Text(L10n.LiveActivity.Status.enabled) @@ -95,7 +95,7 @@ struct LiveActivitySettingsView: View { private var privacySection: some View { Section { - Label(L10n.LiveActivity.Privacy.message, systemSymbol: .lockShieldIcon) + Label(L10n.LiveActivity.Privacy.message, systemSymbol: .lockShield) .font(.footnote) .foregroundStyle(.secondary) } header: { @@ -108,7 +108,7 @@ struct LiveActivitySettingsView: View { let appName = Bundle.main.infoDictionary?["CFBundleDisplayName"] as? String ?? "Home Assistant" return Section { HStack { - Label(L10n.LiveActivity.FrequentUpdates.title, systemSymbol: .boltIcon) + Label(L10n.LiveActivity.FrequentUpdates.title, systemSymbol: .bolt) Spacer() if frequentUpdatesEnabled { Text(L10n.LiveActivity.Status.enabled) @@ -216,13 +216,3 @@ private struct ActivitySnapshot: Identifiable { } } } - -// MARK: - Preview - -#Preview { - NavigationStack { - if #available(iOS 16.1, *) { - LiveActivitySettingsView() - } - } -} diff --git a/Sources/Extensions/Widgets/LiveActivity/HADynamicIslandView.swift b/Sources/Extensions/Widgets/LiveActivity/HADynamicIslandView.swift index 4a18fd0793..05fc6da7d4 100644 --- a/Sources/Extensions/Widgets/LiveActivity/HADynamicIslandView.swift +++ b/Sources/Extensions/Widgets/LiveActivity/HADynamicIslandView.swift @@ -52,9 +52,10 @@ struct HADynamicIslandIconView: View { let size: CGFloat var body: some View { - if let slug, let mdiIcon = MaterialDesignIcons(serversideValueNamed: slug) { + if let slug { // UIColor(hex:) from Shared handles nil/CSS names/3-6-8 digit hex; non-failable. let uiColor = UIColor(hex: color ?? haBlueHex) + let mdiIcon = MaterialDesignIcons(serversideValueNamed: slug) Image(uiImage: mdiIcon.image( ofSize: .init(width: size, height: size), color: uiColor @@ -140,50 +141,3 @@ struct HAExpandedBottomView: View { } } } - -// MARK: - Previews - -#if DEBUG -@available(iOS 16.2, *) -#Preview("Compact", as: .dynamicIsland(.compact), using: HALiveActivityAttributes(tag: "washer", title: "Washer")) { - HALiveActivityConfiguration() -} contentStates: { - HALiveActivityAttributes.ContentState( - message: "45 min remaining", - criticalText: "45 min", - progress: 2700, - progressMax: 3600, - icon: "mdi:washing-machine", - color: "#2196F3" - ) -} - -@available(iOS 16.2, *) -#Preview( - "Expanded", - as: .dynamicIsland(.expanded), - using: HALiveActivityAttributes(tag: "washer", title: "Washing Machine") -) { - HALiveActivityConfiguration() -} contentStates: { - HALiveActivityAttributes.ContentState( - message: "Cycle in progress", - criticalText: "45 min", - progress: 2700, - progressMax: 3600, - icon: "mdi:washing-machine", - color: "#2196F3" - ) -} - -@available(iOS 16.2, *) -#Preview("Minimal", as: .dynamicIsland(.minimal), using: HALiveActivityAttributes(tag: "washer", title: "Washer")) { - HALiveActivityConfiguration() -} contentStates: { - HALiveActivityAttributes.ContentState( - message: "Running", - icon: "mdi:washing-machine", - color: "#2196F3" - ) -} -#endif diff --git a/Sources/Extensions/Widgets/LiveActivity/HALockScreenView.swift b/Sources/Extensions/Widgets/LiveActivity/HALockScreenView.swift index 18ce4ec3d2..0cef08568b 100644 --- a/Sources/Extensions/Widgets/LiveActivity/HALockScreenView.swift +++ b/Sources/Extensions/Widgets/LiveActivity/HALockScreenView.swift @@ -50,10 +50,10 @@ struct HALockScreenView: View { @ViewBuilder private var iconView: some View { - if let iconSlug = state.icon, - let mdiIcon = MaterialDesignIcons(serversideValueNamed: iconSlug) { + if let iconSlug = state.icon { // UIColor(hex:) from Shared handles CSS names and 3/6/8-digit hex; non-failable. let uiColor = UIColor(hex: state.color ?? haBlueHex) + let mdiIcon = MaterialDesignIcons(serversideValueNamed: iconSlug) Image(uiImage: mdiIcon.image( ofSize: .init(width: 20, height: 20), color: uiColor @@ -75,49 +75,3 @@ struct HALockScreenView: View { /// Home Assistant brand blue — used as fallback for icon and progress bar tints. let haBlueHex = "#03A9F4" - -// MARK: - Preview - -#if DEBUG -@available(iOS 16.2, *) -#Preview( - "Lock Screen — Progress", - as: .content, - using: HALiveActivityAttributes(tag: "washer", title: "Washing Machine") -) { - HALiveActivityConfiguration() -} contentStates: { - HALiveActivityAttributes.ContentState( - message: "45 minutes remaining", - criticalText: "45 min", - progress: 2700, - progressMax: 3600, - icon: "mdi:washing-machine", - color: "#2196F3" - ) - HALiveActivityAttributes.ContentState( - message: "Cycle complete!", - progress: 3600, - progressMax: 3600, - icon: "mdi:check-circle", - color: "#4CAF50" - ) -} - -@available(iOS 16.2, *) -#Preview( - "Lock Screen — Chronometer", - as: .content, - using: HALiveActivityAttributes(tag: "timer", title: "Kitchen Timer") -) { - HALiveActivityConfiguration() -} contentStates: { - HALiveActivityAttributes.ContentState( - message: "Timer running", - chronometer: true, - countdownEnd: Date().addingTimeInterval(300), - icon: "mdi:timer", - color: "#FF9800" - ) -} -#endif diff --git a/Sources/Shared/API/HAAPI.swift b/Sources/Shared/API/HAAPI.swift index 20066e8210..b9974694c4 100644 --- a/Sources/Shared/API/HAAPI.swift +++ b/Sources/Shared/API/HAAPI.swift @@ -11,6 +11,9 @@ import Version #if os(iOS) import Reachability #endif +#if os(iOS) && canImport(ActivityKit) +import ActivityKit +#endif public class HomeAssistantAPI { public enum APIError: Error, Equatable { diff --git a/Sources/Shared/LiveActivity/LiveActivityRegistry.swift b/Sources/Shared/LiveActivity/LiveActivityRegistry.swift index 3bec381d94..2b2ec04b4f 100644 --- a/Sources/Shared/LiveActivity/LiveActivityRegistry.swift +++ b/Sources/Shared/LiveActivity/LiveActivityRegistry.swift @@ -153,14 +153,22 @@ public actor LiveActivityRegistry: LiveActivityRegistryProtocol { /// 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) + if #available(iOS 16.2, *) { + await existing.activity.end(nil, dismissalPolicy: dismissalPolicy) + } else { + await existing.activity.end(using: nil, dismissalPolicy: dismissalPolicy) + } Current.Log.verbose("LiveActivityRegistry: ended activity for tag \(tag)") return } // Fallback: check system list in case we lost track if let live = Activity.activities .first(where: { $0.attributes.tag == tag }) { - await live.end(nil, dismissalPolicy: dismissalPolicy) + if #available(iOS 16.2, *) { + await live.end(nil, dismissalPolicy: dismissalPolicy) + } else { + await live.end(using: nil, dismissalPolicy: dismissalPolicy) + } } } diff --git a/Sources/Shared/Resources/Swiftgen/Strings.swift b/Sources/Shared/Resources/Swiftgen/Strings.swift index b992fdbda0..cdc98fa596 100644 --- a/Sources/Shared/Resources/Swiftgen/Strings.swift +++ b/Sources/Shared/Resources/Swiftgen/Strings.swift @@ -1847,19 +1847,41 @@ public enum L10n { } public enum LiveActivity { - /// Live Activities - public static var title: String { return L10n.tr("Localizable", "live_activity.title") } - /// Real-time Home Assistant updates on your Lock Screen and Dynamic Island. - public static var subtitle: String { return L10n.tr("Localizable", "live_activity.subtitle") } /// No active Live Activities public static var emptyState: String { return L10n.tr("Localizable", "live_activity.empty_state") } + /// Real-time Home Assistant updates on your Lock Screen and Dynamic Island. + public static var subtitle: String { return L10n.tr("Localizable", "live_activity.subtitle") } + /// Live Activities + public static var title: String { return L10n.tr("Localizable", "live_activity.title") } + public enum EndAll { + /// End All Activities + public static var button: String { return L10n.tr("Localizable", "live_activity.end_all.button") } + public enum Confirm { + /// End All + public static var button: String { return L10n.tr("Localizable", "live_activity.end_all.confirm.button") } + /// End all Live Activities? + public static var title: String { return L10n.tr("Localizable", "live_activity.end_all.confirm.title") } + } + } + public enum FrequentUpdates { + /// Allows Home Assistant to update Live Activities up to once per second. Enable in Settings u203A %@ u203A Live Activities. + public static func footer(_ p1: Any) -> String { + return L10n.tr("Localizable", "live_activity.frequent_updates.footer", String(describing: p1)) + } + /// Frequent Updates + public static var title: String { return L10n.tr("Localizable", "live_activity.frequent_updates.title") } + } + public enum Privacy { + /// Live Activity content is visible on your Lock Screen and Dynamic Island without Face ID or Touch ID. Choose what you display carefully. + public static var message: String { return L10n.tr("Localizable", "live_activity.privacy.message") } + } public enum Section { /// Active Activities public static var active: String { return L10n.tr("Localizable", "live_activity.section.active") } - /// Status - public static var status: String { return L10n.tr("Localizable", "live_activity.section.status") } /// Privacy public static var privacy: String { return L10n.tr("Localizable", "live_activity.section.privacy") } + /// Status + public static var status: String { return L10n.tr("Localizable", "live_activity.section.status") } } public enum Status { /// Enabled @@ -1867,26 +1889,6 @@ public enum L10n { /// Open Settings public static var openSettings: String { return L10n.tr("Localizable", "live_activity.status.open_settings") } } - public enum EndAll { - /// End All Activities - public static var button: String { return L10n.tr("Localizable", "live_activity.end_all.button") } - /// End all Live Activities? - public static var confirmTitle: String { return L10n.tr("Localizable", "live_activity.end_all.confirm.title") } - /// End All - public static var confirmButton: String { return L10n.tr("Localizable", "live_activity.end_all.confirm.button") } - } - public enum Privacy { - /// Live Activity content is visible on your Lock Screen and Dynamic Island without Face ID or Touch ID. Choose what you display carefully. - public static var message: String { return L10n.tr("Localizable", "live_activity.privacy.message") } - } - public enum FrequentUpdates { - /// Frequent Updates - public static var title: String { return L10n.tr("Localizable", "live_activity.frequent_updates.title") } - /// Allows Home Assistant to update Live Activities up to once per second. Enable in Settings › %@ › Live Activities. - public static func footer(_ p1: Any) -> String { - return L10n.tr("Localizable", "live_activity.frequent_updates.footer", String(describing: p1)) - } - } } public enum LocationChangeNotification { From 6f70ecf25aa8b4dbfd64c09f1ee245187fe387e4 Mon Sep 17 00:00:00 2001 From: rwarner Date: Thu, 19 Mar 2026 15:51:09 -0400 Subject: [PATCH 13/34] feat(live-activity): add iOS Live Activities support Lets Home Assistant automations push real-time state to the Lock Screen and Dynamic Island. Matches Android Live Notifications field names (tag, title, message, progress, chronometer, notification_icon, etc.) so a single YAML automation block targets both platforms. - HALiveActivityAttributes: wire-format frozen ActivityAttributes struct - LiveActivityRegistry: Swift actor with reservation pattern to prevent duplicate activities from simultaneous pushes with the same tag - HandlerLiveActivity: start/update and end NotificationCommandHandler implementations, guarded against the PushProvider extension process - HALiveActivityConfiguration / HALockScreenView / HADynamicIslandView: widget views for Lock Screen, StandBy, and Dynamic Island - LiveActivitySettingsView: status, active activities list, privacy disclosure, and 11 debug scenarios for pre-server-side testing - Registers handlers in NotificationCommandManager; clear_notification with a tag now also ends a matching Live Activity - Adds supports_live_activities, supports_live_activities_frequent_updates, and live_activity_push_to_start_token to the registration payload - AppDelegate reattaches surviving activities at launch and observes push-to-start token stream (iOS 17.2+) - Info.plist: NSSupportsLiveActivities + NSSupportsLiveActivitiesFrequentUpdates - Gated at iOS 16.2 throughout (ActivityContent / staleDate API) - iPad: areActivitiesEnabled is always false; UI and registry handle gracefully Co-Authored-By: Claude Sonnet 4.6 --- HomeAssistant.xcodeproj/project.pbxproj | 22 +- Sources/App/AppDelegate.swift | 2 +- .../Resources/en.lproj/Localizable.strings | 1 + .../LiveActivitySettingsView.swift | 484 +++++++++++++++++- .../App/Settings/Settings/SettingsItem.swift | 8 +- Sources/Extensions/Widgets/Widgets.swift | 6 + Sources/Shared/API/HAAPI.swift | 2 +- Sources/Shared/Environment/Environment.swift | 2 +- .../HALiveActivityAttributes.swift | 2 +- .../LiveActivity/LiveActivityRegistry.swift | 72 +-- .../HandlerLiveActivity.swift | 4 +- .../NotificationsCommandManager.swift | 6 +- .../Shared/Resources/Swiftgen/Strings.swift | 2 + 13 files changed, 543 insertions(+), 70 deletions(-) diff --git a/HomeAssistant.xcodeproj/project.pbxproj b/HomeAssistant.xcodeproj/project.pbxproj index 5ec46163e4..d5bd8f26c9 100644 --- a/HomeAssistant.xcodeproj/project.pbxproj +++ b/HomeAssistant.xcodeproj/project.pbxproj @@ -1235,13 +1235,16 @@ 5D4737422F241342009A70EA /* FolderDetailView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5D4737412F241342009A70EA /* FolderDetailView.swift */; }; 5F2ECF3C2CA505A59A1CFFAF /* Pods_iOS_SharedTesting.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 65B4DC669522BB7A70C5EED0 /* Pods_iOS_SharedTesting.framework */; }; 61495A70232316478717CF27 /* Pods_iOS_Shared_iOS_Tests_Shared.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 1FF3A67FB1C2B548C6C7730C /* Pods_iOS_Shared_iOS_Tests_Shared.framework */; }; + 618FCE5CA6B34267BB2056F5 /* MockLiveActivityRegistry.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5E95733B72864AB3B9607B57 /* MockLiveActivityRegistry.swift */; }; 65286F3B745551AD4090EE6B /* Pods-iOS-SharedTesting-metadata.plist in Resources */ = {isa = PBXBuildFile; fileRef = 4053903E4C54A6803204286E /* Pods-iOS-SharedTesting-metadata.plist */; }; 6596FA74E1A501276EA62D86 /* Pods_watchOS_Shared_watchOS.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = FD370D44DFFB906B05C3EB3A /* Pods_watchOS_Shared_watchOS.framework */; }; 692BCBBA4EEEABCC76DBBECA /* Database/GRDB+Initialization.test.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C50FA39BF16AD0BD782D0D7 /* Database/GRDB+Initialization.test.swift */; }; 6FCEBAA2C8E9C5403055E73D /* IntentFanEntity.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3E5E2F9F8F008EEA30C533FD /* IntentFanEntity.swift */; }; 70BD8A8EA1ABC5DC1F0A0D6E /* Pods_iOS_Shared_iOS.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = C663B0750E0318469E7008C3 /* Pods_iOS_Shared_iOS.framework */; }; + 71E0BF803A854C3B9F0CB726 /* HandlerLiveActivityTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B58D524991C142DBB38A1968 /* HandlerLiveActivityTests.swift */; }; 84F7755EFB03C3F463292ABF /* Pods-watchOS-Shared-watchOS-metadata.plist in Resources */ = {isa = PBXBuildFile; fileRef = 6B55CB9064A0477C9F456B6A /* Pods-watchOS-Shared-watchOS-metadata.plist */; }; 87ADC61008345747CABC2270 /* KioskSettingsTable.swift in Sources */ = {isa = PBXBuildFile; fileRef = 14444A34DA125693568C7035 /* KioskSettingsTable.swift */; }; + 897D7538631C46D4BD849CF5 /* NotificationsCommandManagerLiveActivityTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D790D5FA5DBB4B5B9DBB2334 /* NotificationsCommandManagerLiveActivityTests.swift */; }; 8E5FA96C740F1D671966CEA9 /* Pods-iOS-Extensions-NotificationContent-metadata.plist in Resources */ = {isa = PBXBuildFile; fileRef = B613440AEDD4209862503F5D /* Pods-iOS-Extensions-NotificationContent-metadata.plist */; }; 999549244371450BC98C700E /* Pods_iOS_Extensions_PushProvider.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 608CFDA223EBCDF01B946093 /* Pods_iOS_Extensions_PushProvider.framework */; }; A2F3A140CDD1EF1AEA6DFAB9 /* Database/DatabaseTableProtocol.test.swift in Sources */ = {isa = PBXBuildFile; fileRef = BC31518EE9DC9E065AC508D9 /* Database/DatabaseTableProtocol.test.swift */; }; @@ -3044,6 +3047,7 @@ 592EED7A6C2444872F11C17B /* Pods-iOS-Extensions-NotificationService-metadata.plist */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.xml; name = "Pods-iOS-Extensions-NotificationService-metadata.plist"; path = "Pods/Pods-iOS-Extensions-NotificationService-metadata.plist"; sourceTree = ""; }; 5C50FA39BF16AD0BD782D0D7 /* Database/GRDB+Initialization.test.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Database/GRDB+Initialization.test.swift"; sourceTree = ""; }; 5D4737412F241342009A70EA /* FolderDetailView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FolderDetailView.swift; sourceTree = ""; }; + 5E95733B72864AB3B9607B57 /* MockLiveActivityRegistry.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockLiveActivityRegistry.swift; sourceTree = ""; }; 608CFDA223EBCDF01B946093 /* Pods_iOS_Extensions_PushProvider.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_iOS_Extensions_PushProvider.framework; sourceTree = BUILT_PRODUCTS_DIR; }; 62CDCFDB29D283A7902A3ABE /* KioskScreensaverViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KioskScreensaverViewController.swift; sourceTree = ""; }; 6563AFB7BDAF57478CA18D9B /* Pods-iOS-Extensions-PushProvider.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-iOS-Extensions-PushProvider.debug.xcconfig"; path = "Pods/Target Support Files/Pods-iOS-Extensions-PushProvider/Pods-iOS-Extensions-PushProvider.debug.xcconfig"; sourceTree = ""; }; @@ -3088,6 +3092,7 @@ ADC769271BB34C474C2D1E24 /* Pods-iOS-Shared-iOS-metadata.plist */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.xml; name = "Pods-iOS-Shared-iOS-metadata.plist"; path = "Pods/Pods-iOS-Shared-iOS-metadata.plist"; sourceTree = ""; }; B26248E8DEAC8C13210A6587 /* Pods-iOS-Extensions-Widgets.beta.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-iOS-Extensions-Widgets.beta.xcconfig"; path = "Pods/Target Support Files/Pods-iOS-Extensions-Widgets/Pods-iOS-Extensions-Widgets.beta.xcconfig"; sourceTree = ""; }; B2F5238669D8A7416FBD2B55 /* Pods-iOS-Shared-iOS-Tests-Shared-metadata.plist */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.xml; name = "Pods-iOS-Shared-iOS-Tests-Shared-metadata.plist"; path = "Pods/Pods-iOS-Shared-iOS-Tests-Shared-metadata.plist"; sourceTree = ""; }; + B58D524991C142DBB38A1968 /* HandlerLiveActivityTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HandlerLiveActivityTests.swift; sourceTree = ""; }; B6022212226DAC9D00E8DBFE /* ScaledFont.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScaledFont.swift; sourceTree = ""; }; B60247ED1FBD21C600998205 /* de */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = de; path = de.lproj/Localizable.strings; sourceTree = ""; }; B60247FF1FBD343000998205 /* Base */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = Base; path = Base.lproj/InfoPlist.strings; sourceTree = ""; }; @@ -3445,6 +3450,7 @@ D0FF79CB20D778B50034574D /* ClientEvent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ClientEvent.swift; sourceTree = ""; }; D0FF79CD20D85C3A0034574D /* ClientEventStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ClientEventStore.swift; sourceTree = ""; }; D72C761F65606EF882E2A7B1 /* Pods-iOS-Extensions-Today-metadata.plist */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.xml; name = "Pods-iOS-Extensions-Today-metadata.plist"; path = "Pods/Pods-iOS-Extensions-Today-metadata.plist"; sourceTree = ""; }; + D790D5FA5DBB4B5B9DBB2334 /* NotificationsCommandManagerLiveActivityTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationsCommandManagerLiveActivityTests.swift; sourceTree = ""; }; DEEEA3344F064DB183F46C47 /* WidgetCommonlyUsedEntities.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WidgetCommonlyUsedEntities.swift; sourceTree = ""; }; E3D5CF14402325076CA105EB /* Pods-iOS-Extensions-PushProvider-metadata.plist */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.xml; name = "Pods-iOS-Extensions-PushProvider-metadata.plist"; path = "Pods/Pods-iOS-Extensions-PushProvider-metadata.plist"; sourceTree = ""; }; E41A4AAEF642A72ACDB6C006 /* Pods-iOS-Extensions-Intents-metadata.plist */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.xml; name = "Pods-iOS-Extensions-Intents-metadata.plist"; path = "Pods/Pods-iOS-Extensions-Intents-metadata.plist"; sourceTree = ""; }; @@ -6479,6 +6485,16 @@ path = Overlay; sourceTree = ""; }; + 8881A40916A3424685660E6B /* LiveActivity */ = { + isa = PBXGroup; + children = ( + 5E95733B72864AB3B9607B57 /* MockLiveActivityRegistry.swift */, + B58D524991C142DBB38A1968 /* HandlerLiveActivityTests.swift */, + D790D5FA5DBB4B5B9DBB2334 /* NotificationsCommandManagerLiveActivityTests.swift */, + ); + path = LiveActivity; + sourceTree = ""; + }; 95A32294D4A340198B769AAB /* CommonlyUsedEntities */ = { isa = PBXGroup; children = ( @@ -7202,6 +7218,7 @@ 428626002DA5CC8400D58D13 /* DesignSystem */, 118511C024B25BDC00D18F60 /* Webhook */, 11AF4D28249D88B2006C74C0 /* Sensors */, + 8881A40916A3424685660E6B /* LiveActivity */, D0A6367120DB7D1100E5C49B /* ClientEventTests.swift */, 11AD2EA8252900B500FBC437 /* Resources */, 11B7FD762493232400E60ED9 /* BackgroundTask.test.swift */, @@ -7871,7 +7888,6 @@ }; 1171506824DFCDE60065E874 = { CreatedOnToolsVersion = 12.0; - DevelopmentTeam = QMQYCKL255; }; 11B6B57A2948F8E100B8B552 = { CreatedOnToolsVersion = 14.1; @@ -10372,6 +10388,9 @@ 421125C72F51DE9200971BAD /* StatePrecision.test.swift in Sources */, 114CBAED283AB92D00A9BAFF /* SecTrust+TestAdditions.swift in Sources */, 110ED58025A570F100489AF7 /* DisplaySensor.test.swift in Sources */, + 618FCE5CA6B34267BB2056F5 /* MockLiveActivityRegistry.swift in Sources */, + 71E0BF803A854C3B9F0CB726 /* HandlerLiveActivityTests.swift in Sources */, + 897D7538631C46D4BD849CF5 /* NotificationsCommandManagerLiveActivityTests.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -10932,6 +10951,7 @@ ASSETCATALOG_COMPILER_WIDGET_BACKGROUND_COLOR_NAME = WidgetBackground; CODE_SIGN_ENTITLEMENTS = "Configuration/Entitlements/Extension-ios.entitlements"; "CODE_SIGN_ENTITLEMENTS[sdk=macosx*]" = "Configuration/Entitlements/Extension-catalyst.entitlements"; + DEVELOPMENT_TEAM = 4Q9RHLUX47; INFOPLIST_FILE = Sources/Extensions/Widgets/Resources/Info.plist; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", diff --git a/Sources/App/AppDelegate.swift b/Sources/App/AppDelegate.swift index 205bdd8632..6741650f1e 100644 --- a/Sources/App/AppDelegate.swift +++ b/Sources/App/AppDelegate.swift @@ -375,7 +375,7 @@ class AppDelegate: UIResponder, UIApplicationDelegate { private func setupLiveActivityReattachment() { #if canImport(ActivityKit) - if #available(iOS 16.1, *) { + if #available(iOS 16.2, *) { // Pre-warm the registry on the main thread before spawning background Tasks. // This avoids a lazy-init race if a push notification handler accesses it // concurrently from a background thread. diff --git a/Sources/App/Resources/en.lproj/Localizable.strings b/Sources/App/Resources/en.lproj/Localizable.strings index deb2f225bf..aebda99045 100644 --- a/Sources/App/Resources/en.lproj/Localizable.strings +++ b/Sources/App/Resources/en.lproj/Localizable.strings @@ -506,6 +506,7 @@ This server requires a client certificate (mTLS) but the operation was cancelled "live_activity.section.privacy" = "Privacy"; "live_activity.empty_state" = "No active Live Activities"; "live_activity.status.enabled" = "Enabled"; +"live_activity.status.not_supported" = "Not available on iPad"; "live_activity.status.open_settings" = "Open Settings"; "live_activity.end_all.button" = "End All Activities"; "live_activity.end_all.confirm.title" = "End all Live Activities?"; diff --git a/Sources/App/Settings/LiveActivity/LiveActivitySettingsView.swift b/Sources/App/Settings/LiveActivity/LiveActivitySettingsView.swift index 3b3474bbd1..bb2ece0e09 100644 --- a/Sources/App/Settings/LiveActivity/LiveActivitySettingsView.swift +++ b/Sources/App/Settings/LiveActivity/LiveActivitySettingsView.swift @@ -6,7 +6,7 @@ import SwiftUI /// Deployment target is iOS 15. The settings item is filtered from the list on < iOS 16.1 /// (see SettingsItem.allVisibleCases), so this view is only ever navigated to on iOS 16.1+. -@available(iOS 16.1, *) +@available(iOS 16.2, *) struct LiveActivitySettingsView: View { // MARK: State @@ -61,6 +61,10 @@ struct LiveActivitySettingsView: View { } } + #if DEBUG + debugSection + #endif + privacySection if #available(iOS 17.2, *) { @@ -81,6 +85,9 @@ struct LiveActivitySettingsView: View { if authorizationEnabled { Text(L10n.LiveActivity.Status.enabled) .foregroundStyle(.green) + } else if UIDevice.current.userInterfaceIdiom == .pad { + Text(L10n.LiveActivity.Status.notSupported) + .foregroundStyle(.secondary) } else { Button(L10n.LiveActivity.Status.openSettings) { if let url = URL(string: UIApplication.openSettingsURLString) { @@ -93,6 +100,471 @@ struct LiveActivitySettingsView: View { } } + // MARK: - Debug (DEBUG builds only) + // + // Two sections: Static (fixed snapshots to verify layout) and Animated (multi-stage + // self-updating sequences to simulate real HA automation behavior). + // + // Each scenario tests a unique combination of ContentState fields so they can be + // run independently without duplicating coverage. + // + // HOW TO USE: + // 1. Tap any button to start the activity. + // 2. Tap Allow on the permission prompt. + // 3. Lock the simulator immediately (Device menu → Lock, or ⌘L). + // 4. Watch the lock screen — animated scenarios update themselves automatically. + // 5. End individual activities via the × button in the Active section above. + // + // NOTE: criticalText is only visible in the Dynamic Island compact trailing slot. + // It does NOT appear on the lock screen. Use a Dynamic Island device or + // simulator (iPhone 14 Pro+) to see it. + + #if DEBUG + private var debugSection: some View { + Group { + Section { + // Minimum viable layout — only the message field is set. + // Verifies the bare layout renders without icon, progress, or timer. + Button("Plain Message") { + startTestActivity( + tag: "debug-plain", + title: "Home Assistant", + state: .init(message: "Everything looks good at home.") + ) + } + + // icon = nil code path. Layout must not shift or break when no icon is provided. + // color = nil so the progress bar uses the default HA-blue tint. + // criticalText ("Active") visible in DI compact trailing only. + Button("No Icon · Default Color") { + startTestActivity( + tag: "debug-no-icon", + title: "Script Running", + state: .init( + message: "Irrigation zone 3 is active", + criticalText: "Active", + progress: 35, + progressMax: 100 + ) + ) + } + + // Short 60-second countdown with no progress bar. + // Red color communicates urgency. Watch the timer count down in real time. + // Represents automations like alarm arming delays or reminder countdowns. + Button("Alarm · 60 sec Countdown") { + startTestActivity( + tag: "debug-alarm", + title: "Security Alarm", + state: .init( + message: "Motion at back door · Arms in 60 seconds", + criticalText: "60 sec", + chronometer: true, + countdownEnd: Date().addingTimeInterval(60), + icon: "mdi:alarm-light", + color: "#F44336" + ) + ) + } + + // Every ContentState field active at the same time. + // Lock screen shows: icon → live countdown → progress bar. + // criticalText ("5 min") visible in DI compact trailing only. + // Use this to confirm no layout collisions when all fields are populated. + Button("All Fields · Max Load") { + startTestActivity( + tag: "debug-all", + title: "All Fields", + state: .init( + message: "All content state fields active", + criticalText: "5 min", + progress: 42, + progressMax: 100, + chronometer: true, + countdownEnd: Date().addingTimeInterval(5 * 60), + icon: "mdi:home-assistant", + color: "#03A9F4" + ) + ) + } + } header: { + Text("Debug · Static") + } footer: { + Text("Fixed state — no updates after start. Good for checking layout at a glance.") + } + + Section { + // Progress bar advances through five named stages. + // criticalText tracks the current stage name in the DI compact trailing slot. + // Icon swaps from washing-machine to check-circle on the final update. + // Represents any multi-step appliance cycle automation. + Button("Washing Machine · Stage Labels (~12 s)") { startWashingMachineCycle() } + + // Numeric percentage in criticalText updates alongside the progress bar. + // Color shifts from green to yellow-green as the charge nears 100 %. + // Represents any "% complete with time remaining" automation pattern. + Button("EV Charging · Numeric % (~16 s)") { startEVChargingSimulation() } + + // The only scenario where both progress (playback position) and a live countdown + // (time remaining in track) are active and updating at the same time. + // Simulates a track change mid-sequence: progress resets, countdown resets. + Button("Media Player · Progress + Timer (~20 s)") { startMediaNowPlaying() } + + // Message, criticalText, and icon all change on every update — no progress bar. + // Represents automations where the status category itself changes (not just a value). + Button("Package Delivery · All Text Fields (~15 s)") { startPackageJourney() } + + // No progress bar — state communicated entirely through color and icon. + // Escalates orange (motion) → red (person) → green (all clear). + // Represents any alert-and-resolve automation pattern. + Button("Security Escalation · Color + Icon (~8 s)") { startSecuritySequence() } + + // Cycles through wash stages then calls activity.end() with .default dismissal. + // The only scenario that tests the full lifecycle: start → update → end. + // After ending, the final "Done" state lingers on the lock screen (up to 4 h). + Button("Dishwasher · Full Lifecycle, Ends Itself (~12 s)") { startDishwasherAutoComplete() } + + // Fires 6 updates 2 seconds apart (12 s total). + // On iOS 18 the system enforces ~15 s between rendered updates — some will be + // silently dropped. Watch the counter skip values to see the rate limit in action. + // On the simulator and iOS 17 all 6 updates should render. + Button("Rate Limit · 6 Rapid Updates, 2 s Apart (~12 s)") { startRapidUpdateStressTest() } + } header: { + Text("Debug · Animated") + } footer: { + Text( + "Activity updates itself after you tap. Tap, then immediately lock (⌘L) " + + "to watch updates on the lock screen in real time." + ) + } + } + } + #endif + + #if DEBUG + + // MARK: - Debug helpers + + /// Starts a single-state activity (no subsequent updates). + private func startTestActivity(tag: String, title: String, state: HALiveActivityAttributes.ContentState) { + Task { + let attributes = HALiveActivityAttributes(tag: tag, title: title) + _ = try? Activity.request( + attributes: attributes, + content: ActivityContent(state: state, staleDate: Date().addingTimeInterval(30 * 60)), + pushType: nil + ) + await loadActivities() + } + } + + /// Starts an activity and drives it through `stages` sequentially. + /// + /// - Parameters: + /// - stages: Array of `(delayAfterPrevious seconds, ContentState)`. The first entry's + /// delay is ignored — it becomes the initial content. Each subsequent entry waits + /// `delay` seconds after the previous stage before pushing the update. + /// - endAfterCompletion: When `true`, calls `activity.end()` with `.default` dismissal + /// after the final stage, leaving the last state visible on the lock screen (up to 4 h). + private func startAnimatedActivity( + tag: String, + title: String, + stages: [(delay: Double, state: HALiveActivityAttributes.ContentState)], + endAfterCompletion: Bool = false + ) { + guard let first = stages.first else { return } + Task { + let attributes = HALiveActivityAttributes(tag: tag, title: title) + guard let activity = try? Activity.request( + attributes: attributes, + content: ActivityContent(state: first.state, staleDate: Date().addingTimeInterval(30 * 60)), + pushType: nil + ) else { return } + await loadActivities() + for stage in stages.dropFirst() { + try? await Task.sleep(nanoseconds: UInt64(stage.delay * 1_000_000_000)) + await activity.update(ActivityContent( + state: stage.state, + staleDate: Date().addingTimeInterval(30 * 60) + )) + await loadActivities() + } + if endAfterCompletion, let last = stages.last { + await activity.end( + ActivityContent(state: last.state, staleDate: Date().addingTimeInterval(30 * 60)), + dismissalPolicy: .default + ) + await loadActivities() + } + } + } + + // MARK: - Animated scenario implementations + + /// Progress advances through five named wash stages. + /// criticalText tracks the stage name (DI compact trailing). + /// Icon swaps to check-circle on the final update. + private func startWashingMachineCycle() { + startAnimatedActivity( + tag: "debug-washing", + title: "Washing Machine", + stages: [ + (0, .init( + message: "Starting soak", + criticalText: "Soak", + progress: 5, progressMax: 100, + icon: "mdi:washing-machine", color: "#2196F3" + )), + (3, .init( + message: "Washing · Heavy cycle", + criticalText: "Wash", + progress: 30, progressMax: 100, + icon: "mdi:washing-machine", color: "#2196F3" + )), + (3, .init( + message: "Rinsing · 1 of 2", + criticalText: "Rinse", + progress: 60, progressMax: 100, + icon: "mdi:washing-machine", color: "#2196F3" + )), + (3, .init( + message: "Final spin", + criticalText: "Spin", + progress: 85, progressMax: 100, + icon: "mdi:washing-machine", color: "#2196F3" + )), + (3, .init( + message: "Cycle complete", + criticalText: "Done", + progress: 100, progressMax: 100, + icon: "mdi:check-circle", color: "#4CAF50" + )), + ] + ) + } + + /// Numeric percentage in criticalText updates alongside the progress bar. + /// Color shifts from green to yellow-green as the charge nears full. + private func startEVChargingSimulation() { + startAnimatedActivity( + tag: "debug-ev", + title: "EV Charging", + stages: [ + (0, .init( + message: "Charging · Est. 45 min remaining", + criticalText: "45%", + progress: 45, progressMax: 100, + icon: "mdi:ev-station", color: "#4CAF50" + )), + (4, .init( + message: "Charging · Est. 30 min remaining", + criticalText: "60%", + progress: 60, progressMax: 100, + icon: "mdi:ev-station", color: "#4CAF50" + )), + (4, .init( + message: "Charging · Est. 15 min remaining", + criticalText: "78%", + progress: 78, progressMax: 100, + icon: "mdi:ev-station", color: "#8BC34A" + )), + (4, .init( + message: "Charge complete", + criticalText: "Full", + progress: 100, progressMax: 100, + icon: "mdi:battery-charging", color: "#4CAF50" + )), + ] + ) + } + + /// Both progress (playback position) and a live countdown (time remaining) update together. + /// countdownEnd is fixed once at tap time so the timer runs smoothly across all stages. + /// Simulates a track change: progress resets and countdownEnd resets on the final stage. + private func startMediaNowPlaying() { + let track1End = Date().addingTimeInterval(2 * 60) + startAnimatedActivity( + tag: "debug-media", + title: "Now Playing", + stages: [ + (0, .init( + message: "Bohemian Rhapsody · Queen", + criticalText: "1 / 12", + progress: 20, progressMax: 100, + chronometer: true, countdownEnd: track1End, + icon: "mdi:music-note", color: "#9C27B0" + )), + (5, .init( + message: "Bohemian Rhapsody · Queen", + criticalText: "1 / 12", + progress: 42, progressMax: 100, + chronometer: true, countdownEnd: track1End, + icon: "mdi:music-note", color: "#9C27B0" + )), + (5, .init( + message: "Bohemian Rhapsody · Queen", + criticalText: "1 / 12", + progress: 67, progressMax: 100, + chronometer: true, countdownEnd: track1End, + icon: "mdi:music-note", color: "#9C27B0" + )), + // Track changes — message, progress, and countdownEnd all reset together. + (5, .init( + message: "Don't Stop Me Now · Queen", + criticalText: "2 / 12", + progress: 8, progressMax: 100, + chronometer: true, countdownEnd: Date().addingTimeInterval(3 * 60 + 29), + icon: "mdi:music-note", color: "#9C27B0" + )), + ] + ) + } + + /// Message, criticalText, and icon all change on every update — no progress bar. + /// Represents automations where the status category itself changes, not just a value. + private func startPackageJourney() { + startAnimatedActivity( + tag: "debug-delivery", + title: "Package Delivery", + stages: [ + (0, .init( + message: "Order shipped · Est. today", + criticalText: "Shipped", + icon: "mdi:package-variant-closed", color: "#795548" + )), + (5, .init( + message: "Out for delivery · 8 stops away", + criticalText: "On way", + icon: "mdi:truck-delivery", color: "#FF9800" + )), + (5, .init( + message: "Nearby · 2 stops away", + criticalText: "Nearby", + icon: "mdi:truck-delivery", color: "#FF5722" + )), + (5, .init( + message: "Delivered to front door", + criticalText: "Done", + icon: "mdi:package-variant", color: "#4CAF50" + )), + ] + ) + } + + /// State communicated through color and icon only — no progress bar. + /// Escalates orange → red → green to show the alert-and-resolve pattern. + private func startSecuritySequence() { + startAnimatedActivity( + tag: "debug-security", + title: "Security Alert", + stages: [ + (0, .init( + message: "Motion detected at front door", + criticalText: "Motion", + icon: "mdi:motion-sensor", color: "#FF9800" + )), + (4, .init( + message: "Person detected · Camera 1", + criticalText: "Person", + icon: "mdi:cctv", color: "#F44336" + )), + (4, .init( + message: "Disarmed · All clear", + criticalText: "Safe", + icon: "mdi:shield-check", color: "#4CAF50" + )), + ] + ) + } + + /// Cycles through wash stages then calls activity.end() with .default dismissal. + /// After ending, the "Done" state lingers on the lock screen for up to 4 hours — + /// this is the expected UX for any automation that represents a completed task. + private func startDishwasherAutoComplete() { + startAnimatedActivity( + tag: "debug-dishwasher", + title: "Dishwasher", + stages: [ + (0, .init( + message: "Pre-wash in progress", + criticalText: "Pre-wash", + progress: 20, progressMax: 100, + icon: "mdi:dishwasher", color: "#26C6DA" + )), + (3, .init( + message: "Main wash · Hot cycle", + criticalText: "Wash", + progress: 50, progressMax: 100, + icon: "mdi:dishwasher", color: "#26C6DA" + )), + (3, .init( + message: "Rinse and dry", + criticalText: "Rinse", + progress: 80, progressMax: 100, + icon: "mdi:dishwasher", color: "#26C6DA" + )), + (3, .init( + message: "Dishes are clean", + criticalText: "Done", + progress: 100, progressMax: 100, + icon: "mdi:check-circle", color: "#4CAF50" + )), + ], + endAfterCompletion: true + ) + } + + /// Fires 6 updates spaced 2 seconds apart (12 s total). + /// On iOS 18 the system enforces ~15 s between rendered updates — excess updates are + /// silently dropped and the counter will appear to skip values on device. + /// On the simulator and iOS 17 all 6 updates should render without skipping. + private func startRapidUpdateStressTest() { + startAnimatedActivity( + tag: "debug-rapid", + title: "Rate Limit Test", + stages: [ + (0, .init( + message: "Update 1 of 6 · Watch for skipped values on device", + criticalText: "#1", + progress: 0, progressMax: 100, + icon: "mdi:lightning-bolt", color: "#FF9800" + )), + (2, .init( + message: "Update 2 of 6", + criticalText: "#2", + progress: 17, progressMax: 100, + icon: "mdi:lightning-bolt", color: "#FF9800" + )), + (2, .init( + message: "Update 3 of 6", + criticalText: "#3", + progress: 33, progressMax: 100, + icon: "mdi:lightning-bolt", color: "#FF9800" + )), + (2, .init( + message: "Update 4 of 6", + criticalText: "#4", + progress: 50, progressMax: 100, + icon: "mdi:lightning-bolt", color: "#FF9800" + )), + (2, .init( + message: "Update 5 of 6", + criticalText: "#5", + progress: 67, progressMax: 100, + icon: "mdi:lightning-bolt", color: "#FF9800" + )), + (2, .init( + message: "Update 6 of 6 · All done", + criticalText: "#6", + progress: 100, progressMax: 100, + icon: "mdi:lightning-bolt", color: "#FF9800" + )), + ] + ) + } + + #endif + private var privacySection: some View { Section { Label(L10n.LiveActivity.Privacy.message, systemSymbol: .lockShield) @@ -167,7 +639,7 @@ struct LiveActivitySettingsView: View { // MARK: - Activity row -@available(iOS 16.1, *) +@available(iOS 16.2, *) private struct ActivityRow: View { let snapshot: ActivitySnapshot let onEnd: () -> Void @@ -198,7 +670,7 @@ private struct ActivityRow: View { // MARK: - Snapshot model -@available(iOS 16.1, *) +@available(iOS 16.2, *) private struct ActivitySnapshot: Identifiable { let id: String let tag: String @@ -209,10 +681,6 @@ private struct ActivitySnapshot: Identifiable { self.id = activity.id self.tag = activity.attributes.tag self.title = activity.attributes.title - if #available(iOS 16.2, *) { - self.message = activity.content.state.message - } else { - self.message = activity.contentState.message - } + self.message = activity.content.state.message } } diff --git a/Sources/App/Settings/Settings/SettingsItem.swift b/Sources/App/Settings/Settings/SettingsItem.swift index ed4ce73237..8649814292 100644 --- a/Sources/App/Settings/Settings/SettingsItem.swift +++ b/Sources/App/Settings/Settings/SettingsItem.swift @@ -112,7 +112,7 @@ enum SettingsItem: String, Hashable, CaseIterable { case .notifications: SettingsNotificationsView() case .liveActivities: - if #available(iOS 16.1, *) { + if #available(iOS 16.2, *) { LiveActivitySettingsView() } case .sensors: @@ -151,9 +151,9 @@ enum SettingsItem: String, Hashable, CaseIterable { return false } #endif - // Live Activities require iOS 16.1+ + // Live Activities require iOS 16.2+ (ActivityContent API) if item == .liveActivities { - if #available(iOS 16.1, *) { return true } + if #available(iOS 16.2, *) { return true } return false } return true @@ -162,7 +162,7 @@ enum SettingsItem: String, Hashable, CaseIterable { static var generalItems: [SettingsItem] { var items: [SettingsItem] = [.general, .gestures, .kiosk, .location, .notifications] - if #available(iOS 16.1, *) { + if #available(iOS 16.2, *) { items.append(.liveActivities) } return items diff --git a/Sources/Extensions/Widgets/Widgets.swift b/Sources/Extensions/Widgets/Widgets.swift index fa1b604f7d..7b042fd690 100644 --- a/Sources/Extensions/Widgets/Widgets.swift +++ b/Sources/Extensions/Widgets/Widgets.swift @@ -21,6 +21,9 @@ struct WidgetsBundleLegacy: WidgetBundle { } var body: some Widget { + if #available(iOSApplicationExtension 16.2, *) { + HALiveActivityConfiguration() + } WidgetAssist() LegacyWidgetActions() WidgetOpenPage() @@ -34,6 +37,9 @@ struct WidgetsBundle17: WidgetBundle { } var body: some Widget { + if #available(iOSApplicationExtension 16.2, *) { + HALiveActivityConfiguration() + } WidgetCommonlyUsedEntities() WidgetCustom() WidgetAssist() diff --git a/Sources/Shared/API/HAAPI.swift b/Sources/Shared/API/HAAPI.swift index b9974694c4..31dcb04ef6 100644 --- a/Sources/Shared/API/HAAPI.swift +++ b/Sources/Shared/API/HAAPI.swift @@ -563,7 +563,7 @@ public class HomeAssistantAPI { ] #if os(iOS) && canImport(ActivityKit) - if #available(iOS 16.1, *) { + if #available(iOS 16.2, *) { // Advertise Live Activity support so HA can gate the UI and send // activity push tokens back to the relay server. appData["supports_live_activities"] = true diff --git a/Sources/Shared/Environment/Environment.swift b/Sources/Shared/Environment/Environment.swift index 2c6b3b8057..ff7a17b57e 100644 --- a/Sources/Shared/Environment/Environment.swift +++ b/Sources/Shared/Environment/Environment.swift @@ -147,7 +147,7 @@ public class AppEnvironment { /// background thread can access it) to avoid a lazy-init race between concurrent callers. private var _liveActivityRegistryBacking: Any? - @available(iOS 16.1, *) + @available(iOS 16.2, *) public var liveActivityRegistry: LiveActivityRegistryProtocol { get { if let existing = _liveActivityRegistryBacking as? LiveActivityRegistryProtocol { diff --git a/Sources/Shared/LiveActivity/HALiveActivityAttributes.swift b/Sources/Shared/LiveActivity/HALiveActivityAttributes.swift index 8e798e49c0..af73b6841a 100644 --- a/Sources/Shared/LiveActivity/HALiveActivityAttributes.swift +++ b/Sources/Shared/LiveActivity/HALiveActivityAttributes.swift @@ -10,7 +10,7 @@ import SwiftUI /// ⚠️ NEVER rename this struct or its fields post-ship. /// The `attributes-type` string in APNs push-to-start payloads must exactly match /// the Swift struct name (case-sensitive). Renaming breaks all in-flight activities. -@available(iOS 16.1, *) +@available(iOS 16.2, *) public struct HALiveActivityAttributes: ActivityAttributes { // MARK: - Static Attributes (set once at creation, cannot change) diff --git a/Sources/Shared/LiveActivity/LiveActivityRegistry.swift b/Sources/Shared/LiveActivity/LiveActivityRegistry.swift index 2b2ec04b4f..f262e4e650 100644 --- a/Sources/Shared/LiveActivity/LiveActivityRegistry.swift +++ b/Sources/Shared/LiveActivity/LiveActivityRegistry.swift @@ -7,7 +7,7 @@ import PromiseKit /// Activities are marked stale after 30 minutes if no further updates arrive. private let kLiveActivityStaleInterval: TimeInterval = 30 * 60 -@available(iOS 16.1, *) +@available(iOS 16.2, *) public protocol LiveActivityRegistryProtocol: AnyObject { func startOrUpdate(tag: String, title: String, state: HALiveActivityAttributes.ContentState) async throws func end(tag: String, dismissalPolicy: ActivityUIDismissalPolicy) async @@ -23,7 +23,7 @@ public protocol LiveActivityRegistryProtocol: AnyObject { /// /// 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 16.1, *) +@available(iOS 16.2, *) public actor LiveActivityRegistry: LiveActivityRegistryProtocol { // MARK: - Types @@ -77,30 +77,22 @@ public actor LiveActivityRegistry: LiveActivityRegistryProtocol { ) async throws { // UPDATE path — activity already running with this tag if let existing = entries[tag] { - if #available(iOS 16.2, *) { - let content = ActivityContent( - state: state, - staleDate: Date().addingTimeInterval(kLiveActivityStaleInterval) - ) - await existing.activity.update(content) - } else { - await existing.activity.update(using: state) - } + let content = ActivityContent( + state: state, + staleDate: Date().addingTimeInterval(kLiveActivityStaleInterval) + ) + await existing.activity.update(content) return } // Also check system list in case we lost track after crash/relaunch if let live = Activity.activities .first(where: { $0.attributes.tag == tag }) { - if #available(iOS 16.2, *) { - let content = ActivityContent( - state: state, - staleDate: Date().addingTimeInterval(kLiveActivityStaleInterval) - ) - await live.update(content) - } else { - await live.update(using: state) - } + let content = ActivityContent( + state: state, + staleDate: Date().addingTimeInterval(kLiveActivityStaleInterval) + ) + await live.update(content) let observationTask = makeObservationTask(for: live) entries[tag] = Entry(activity: live, observationTask: observationTask) return @@ -122,24 +114,16 @@ public actor LiveActivityRegistry: LiveActivityRegistryProtocol { let activity: Activity do { - if #available(iOS 16.2, *) { - let content = ActivityContent( - state: state, - staleDate: Date().addingTimeInterval(kLiveActivityStaleInterval), - relevanceScore: 0.5 - ) - activity = try Activity.request( - attributes: attributes, - content: content, - pushType: .token - ) - } else { - activity = try Activity.request( - attributes: attributes, - contentState: state, - pushType: .token - ) - } + let content = ActivityContent( + state: state, + staleDate: Date().addingTimeInterval(kLiveActivityStaleInterval), + relevanceScore: 0.5 + ) + activity = try Activity.request( + attributes: attributes, + content: content, + pushType: .token + ) } catch { cancelReservation(id: tag) throw error @@ -153,22 +137,14 @@ public actor LiveActivityRegistry: LiveActivityRegistryProtocol { /// End and dismiss the Live Activity for `tag`. public func end(tag: String, dismissalPolicy: ActivityUIDismissalPolicy = .immediate) async { if let existing = remove(id: tag) { - if #available(iOS 16.2, *) { - await existing.activity.end(nil, dismissalPolicy: dismissalPolicy) - } else { - await existing.activity.end(using: nil, dismissalPolicy: dismissalPolicy) - } + await existing.activity.end(nil, dismissalPolicy: dismissalPolicy) Current.Log.verbose("LiveActivityRegistry: ended activity for tag \(tag)") return } // Fallback: check system list in case we lost track if let live = Activity.activities .first(where: { $0.attributes.tag == tag }) { - if #available(iOS 16.2, *) { - await live.end(nil, dismissalPolicy: dismissalPolicy) - } else { - await live.end(using: nil, dismissalPolicy: dismissalPolicy) - } + await live.end(nil, dismissalPolicy: dismissalPolicy) } } diff --git a/Sources/Shared/Notifications/NotificationCommands/HandlerLiveActivity.swift b/Sources/Shared/Notifications/NotificationCommands/HandlerLiveActivity.swift index 02e352b441..deb33d418d 100644 --- a/Sources/Shared/Notifications/NotificationCommands/HandlerLiveActivity.swift +++ b/Sources/Shared/Notifications/NotificationCommands/HandlerLiveActivity.swift @@ -14,7 +14,7 @@ import PromiseKit /// 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 16.1, *) +@available(iOS 16.2, *) struct HandlerStartOrUpdateLiveActivity: NotificationCommandHandler { private enum ValidationError: Error { case missingTag @@ -135,7 +135,7 @@ struct HandlerStartOrUpdateLiveActivity: NotificationCommandHandler { /// Handles explicit `end_live_activity` commands. /// Note: the `clear_notification` + `tag` dismiss flow is handled in `HandlerClearNotification`. -@available(iOS 16.1, *) +@available(iOS 16.2, *) struct HandlerEndLiveActivity: NotificationCommandHandler { func handle(_ payload: [String: Any]) -> Promise { guard !Current.isAppExtension else { diff --git a/Sources/Shared/Notifications/NotificationCommands/NotificationsCommandManager.swift b/Sources/Shared/Notifications/NotificationCommands/NotificationsCommandManager.swift index 1fc8f17e19..24c97310cd 100644 --- a/Sources/Shared/Notifications/NotificationCommands/NotificationsCommandManager.swift +++ b/Sources/Shared/Notifications/NotificationCommands/NotificationsCommandManager.swift @@ -23,7 +23,7 @@ public class NotificationCommandManager { #if os(iOS) register(command: "update_complications", handler: HandlerUpdateComplications()) #if canImport(ActivityKit) - if #available(iOS 16.1, *) { + if #available(iOS 16.2, *) { register(command: "live_activity", handler: HandlerStartOrUpdateLiveActivity()) register(command: "end_live_activity", handler: HandlerEndLiveActivity()) } @@ -50,7 +50,7 @@ public class NotificationCommandManager { // This allows the notification body to be a real message instead of a command keyword, // matching Android's data.live_update: true pattern. #if canImport(ActivityKit) - if #available(iOS 16.1, *), hadict["live_activity"] as? Bool == true, + if #available(iOS 16.2, *), hadict["live_activity"] as? Bool == true, let handler = commands["live_activity"] { return handler.handle(hadict) } @@ -113,7 +113,7 @@ private struct HandlerClearNotification: NotificationCommandHandler { // Bridged into the returned Promise so the background fetch window stays open until // the activity is actually dismissed (prevents the OS suspending mid-dismiss). #if os(iOS) && canImport(ActivityKit) - if #available(iOS 16.1, *), let tag = payload["tag"] as? String { + if #available(iOS 16.2, *), let tag = payload["tag"] as? String { return Promise { seal in Task { await Current.liveActivityRegistry.end(tag: tag, dismissalPolicy: .immediate) diff --git a/Sources/Shared/Resources/Swiftgen/Strings.swift b/Sources/Shared/Resources/Swiftgen/Strings.swift index cdc98fa596..2b7da07c3c 100644 --- a/Sources/Shared/Resources/Swiftgen/Strings.swift +++ b/Sources/Shared/Resources/Swiftgen/Strings.swift @@ -1886,6 +1886,8 @@ public enum L10n { public enum Status { /// Enabled public static var enabled: String { return L10n.tr("Localizable", "live_activity.status.enabled") } + /// Not available on iPad + public static var notSupported: String { return L10n.tr("Localizable", "live_activity.status.not_supported") } /// Open Settings public static var openSettings: String { return L10n.tr("Localizable", "live_activity.status.open_settings") } } From 115077a568a79898ffb8ec34cda69e7ff71516d0 Mon Sep 17 00:00:00 2001 From: rwarner Date: Thu, 19 Mar 2026 15:51:35 -0400 Subject: [PATCH 14/34] test(live-activity): add unit tests for Live Activity handlers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 45 new tests across 3 files: HandlerLiveActivityTests (36 tests): - isValidTag: alphanumeric, hyphen, underscore, length boundary, invalid chars (space, dot, slash, @), empty string vacuous-true - contentState(from:): minimal defaults, full field mapping, Double→Int truncation, absolute/relative when, missing when - handle(): app extension guard, missing/empty/invalid tag, missing/empty title, successful path, registry-throws rejection, privacy disclosure flag HandlerEndLiveActivityTests (12 tests): - App extension guard - Tag validation (missing, empty, invalid) - Dismissal policy: no policy → immediate, "default", "after:" → .after, "after:" capped at 24h, invalid timestamp → immediate, unknown → immediate NotificationsCommandManagerLiveActivityTests (9 tests): - live_activity command routing → startOrUpdate - live_activity: true flag routing → startOrUpdate - live_activity: false falls through (no registry call) - end_live_activity command → registry.end (immediate) - end_live_activity with dismissal_policy: default → registry.end (default) - clear_notification without tag → registry.end not called - Missing homeassistant key → .notCommand error - Unknown command → .unknownCommand error Note: clear_notification+tag → registry.end path is covered by code review rather than a unit test. HandlerClearNotification calls UNUserNotificationCenter.current().removeDeliveredNotifications which requires a real app bundle and throws NSInternalInconsistencyException in the XCTest host. Co-Authored-By: Claude Sonnet 4.6 --- .../HandlerLiveActivityTests.swift | 330 ++++++++++++++++++ .../MockLiveActivityRegistry.swift | 68 ++++ ...tionsCommandManagerLiveActivityTests.swift | 144 ++++++++ 3 files changed, 542 insertions(+) create mode 100644 Tests/Shared/LiveActivity/HandlerLiveActivityTests.swift create mode 100644 Tests/Shared/LiveActivity/MockLiveActivityRegistry.swift create mode 100644 Tests/Shared/LiveActivity/NotificationsCommandManagerLiveActivityTests.swift diff --git a/Tests/Shared/LiveActivity/HandlerLiveActivityTests.swift b/Tests/Shared/LiveActivity/HandlerLiveActivityTests.swift new file mode 100644 index 0000000000..d3ca4cbafc --- /dev/null +++ b/Tests/Shared/LiveActivity/HandlerLiveActivityTests.swift @@ -0,0 +1,330 @@ +#if canImport(ActivityKit) +import Foundation +import PromiseKit +@testable import Shared +import XCTest + +// MARK: - HandlerStartOrUpdateLiveActivity Tests + +@available(iOS 16.2, *) +final class HandlerStartOrUpdateLiveActivityTests: XCTestCase { + private var sut: HandlerStartOrUpdateLiveActivity! + private var mockRegistry: MockLiveActivityRegistry! + + override func setUp() { + super.setUp() + sut = HandlerStartOrUpdateLiveActivity() + mockRegistry = MockLiveActivityRegistry() + Current.liveActivityRegistry = mockRegistry + Current.isAppExtension = false + } + + override func tearDown() { + sut = nil + mockRegistry = nil + super.tearDown() + } + + // MARK: - isValidTag + + func testIsValidTag_alphanumericOnly_isValid() { + XCTAssertTrue(HandlerStartOrUpdateLiveActivity.isValidTag("abc123")) + } + + func testIsValidTag_withHyphen_isValid() { + XCTAssertTrue(HandlerStartOrUpdateLiveActivity.isValidTag("ha-tag")) + } + + func testIsValidTag_withUnderscore_isValid() { + XCTAssertTrue(HandlerStartOrUpdateLiveActivity.isValidTag("ha_tag")) + } + + func testIsValidTag_exactly64Chars_isValid() { + let tag = String(repeating: "a", count: 64) + XCTAssertTrue(HandlerStartOrUpdateLiveActivity.isValidTag(tag)) + } + + func testIsValidTag_65Chars_isInvalid() { + let tag = String(repeating: "a", count: 65) + XCTAssertFalse(HandlerStartOrUpdateLiveActivity.isValidTag(tag)) + } + + func testIsValidTag_withSpace_isInvalid() { + XCTAssertFalse(HandlerStartOrUpdateLiveActivity.isValidTag("ha tag")) + } + + func testIsValidTag_withDot_isInvalid() { + XCTAssertFalse(HandlerStartOrUpdateLiveActivity.isValidTag("ha.tag")) + } + + func testIsValidTag_withSlash_isInvalid() { + XCTAssertFalse(HandlerStartOrUpdateLiveActivity.isValidTag("ha/tag")) + } + + func testIsValidTag_withAtSign_isInvalid() { + XCTAssertFalse(HandlerStartOrUpdateLiveActivity.isValidTag("ha@tag")) + } + + func testIsValidTag_emptyString_isValid() { + // isValidTag uses allSatisfy which returns true vacuously for empty strings. + // Empty tags are rejected earlier in handle() via the !tag.isEmpty guard, + // so isValidTag is never called with an empty string in practice. + XCTAssertTrue(HandlerStartOrUpdateLiveActivity.isValidTag("")) + } + + // MARK: - contentState(from:) + + func testContentState_minimalPayload_usesDefaults() { + let state = HandlerStartOrUpdateLiveActivity.contentState(from: [:]) + XCTAssertEqual(state.message, "") + XCTAssertNil(state.criticalText) + XCTAssertNil(state.progress) + XCTAssertNil(state.progressMax) + XCTAssertNil(state.chronometer) + XCTAssertNil(state.countdownEnd) + XCTAssertNil(state.icon) + XCTAssertNil(state.color) + } + + func testContentState_fullPayload_mapsAllFields() { + let payload: [String: Any] = [ + "message": "Test message", + "critical_text": "CRITICAL", + "progress": 42, + "progress_max": 100, + "chronometer": true, + "notification_icon": "mdi:home", + "notification_icon_color": "#FF5733", + ] + let state = HandlerStartOrUpdateLiveActivity.contentState(from: payload) + XCTAssertEqual(state.message, "Test message") + XCTAssertEqual(state.criticalText, "CRITICAL") + XCTAssertEqual(state.progress, 42) + XCTAssertEqual(state.progressMax, 100) + XCTAssertEqual(state.chronometer, true) + XCTAssertEqual(state.icon, "mdi:home") + XCTAssertEqual(state.color, "#FF5733") + XCTAssertNil(state.countdownEnd) + } + + func testContentState_progressAsDouble_truncatesToInt() { + // JSON may send progress as 50.0 (Double) rather than 50 (Int) + let payload: [String: Any] = ["progress": NSNumber(value: 50.9)] + let state = HandlerStartOrUpdateLiveActivity.contentState(from: payload) + XCTAssertEqual(state.progress, 50) + } + + func testContentState_whenAbsolute_usesEpochTimestamp() { + let timestamp: Double = 1_700_000_000 + let payload: [String: Any] = ["when": NSNumber(value: timestamp), "when_relative": false] + let state = HandlerStartOrUpdateLiveActivity.contentState(from: payload) + XCTAssertEqual(state.countdownEnd?.timeIntervalSince1970 ?? 0, timestamp, accuracy: 0.001) + } + + func testContentState_whenRelative_addsIntervalToNow() { + let interval: Double = 300 // 5 minutes from now + let payload: [String: Any] = ["when": NSNumber(value: interval), "when_relative": true] + let before = Date() + let state = HandlerStartOrUpdateLiveActivity.contentState(from: payload) + let after = Date() + + guard let countdownEnd = state.countdownEnd else { + return XCTFail("countdownEnd should not be nil") + } + XCTAssertGreaterThanOrEqual(countdownEnd.timeIntervalSince(before), interval - 0.1) + XCTAssertLessThanOrEqual(countdownEnd.timeIntervalSince(after), interval + 0.1) + } + + func testContentState_whenMissing_countdownEndIsNil() { + let state = HandlerStartOrUpdateLiveActivity.contentState(from: ["when_relative": true]) + XCTAssertNil(state.countdownEnd) + } + + // MARK: - handle(_:) — app extension guard + + func testHandle_inAppExtension_skipsRegistryAndFulfills() { + Current.isAppExtension = true + let payload: [String: Any] = ["tag": "test-tag", "title": "Test"] + XCTAssertNoThrow(try hang(sut.handle(payload))) + XCTAssertTrue(mockRegistry.startOrUpdateCalls.isEmpty) + } + + // MARK: - handle(_:) — validation failures fulfill (no rejection) + + func testHandle_missingTag_fulfillsWithoutCallingRegistry() { + let payload: [String: Any] = ["title": "Test"] + XCTAssertNoThrow(try hang(sut.handle(payload))) + XCTAssertTrue(mockRegistry.startOrUpdateCalls.isEmpty) + } + + func testHandle_emptyTag_fulfillsWithoutCallingRegistry() { + let payload: [String: Any] = ["tag": "", "title": "Test"] + XCTAssertNoThrow(try hang(sut.handle(payload))) + XCTAssertTrue(mockRegistry.startOrUpdateCalls.isEmpty) + } + + func testHandle_invalidTag_fulfillsWithoutCallingRegistry() { + let payload: [String: Any] = ["tag": "invalid tag with spaces", "title": "Test"] + XCTAssertNoThrow(try hang(sut.handle(payload))) + XCTAssertTrue(mockRegistry.startOrUpdateCalls.isEmpty) + } + + func testHandle_missingTitle_fulfillsWithoutCallingRegistry() { + let payload: [String: Any] = ["tag": "valid-tag"] + XCTAssertNoThrow(try hang(sut.handle(payload))) + XCTAssertTrue(mockRegistry.startOrUpdateCalls.isEmpty) + } + + func testHandle_emptyTitle_fulfillsWithoutCallingRegistry() { + let payload: [String: Any] = ["tag": "valid-tag", "title": ""] + XCTAssertNoThrow(try hang(sut.handle(payload))) + XCTAssertTrue(mockRegistry.startOrUpdateCalls.isEmpty) + } + + // MARK: - handle(_:) — successful path + + func testHandle_validPayload_callsRegistryStartOrUpdate() throws { + let payload: [String: Any] = ["tag": "my-activity", "title": "My Title", "message": "Hello"] + XCTAssertNoThrow(try hang(sut.handle(payload))) + XCTAssertEqual(mockRegistry.startOrUpdateCalls.count, 1) + XCTAssertEqual(mockRegistry.startOrUpdateCalls[0].tag, "my-activity") + XCTAssertEqual(mockRegistry.startOrUpdateCalls[0].title, "My Title") + } + + func testHandle_registryThrows_rejectsPromise() { + struct TestError: Error {} + mockRegistry.startOrUpdateError = TestError() + let payload: [String: Any] = ["tag": "my-activity", "title": "My Title"] + XCTAssertThrowsError(try hang(sut.handle(payload))) + } + + // MARK: - Privacy disclosure + + func testHandle_firstCall_setsDisclosureFlag() throws { + Current.settingsStore.hasSeenLiveActivityDisclosure = false + let payload: [String: Any] = ["tag": "priv-tag", "title": "Privacy Test"] + XCTAssertNoThrow(try hang(sut.handle(payload))) + XCTAssertTrue(Current.settingsStore.hasSeenLiveActivityDisclosure) + } + + func testHandle_disclosureAlreadySeen_doesNotChange() throws { + Current.settingsStore.hasSeenLiveActivityDisclosure = true + let payload: [String: Any] = ["tag": "priv-tag", "title": "Privacy Test"] + XCTAssertNoThrow(try hang(sut.handle(payload))) + // Still true — unchanged + XCTAssertTrue(Current.settingsStore.hasSeenLiveActivityDisclosure) + } +} + +// MARK: - HandlerEndLiveActivity Tests + +@available(iOS 16.2, *) +final class HandlerEndLiveActivityTests: XCTestCase { + private var sut: HandlerEndLiveActivity! + private var mockRegistry: MockLiveActivityRegistry! + + override func setUp() { + super.setUp() + sut = HandlerEndLiveActivity() + mockRegistry = MockLiveActivityRegistry() + Current.liveActivityRegistry = mockRegistry + Current.isAppExtension = false + } + + override func tearDown() { + sut = nil + mockRegistry = nil + super.tearDown() + } + + // MARK: - App extension guard + + func testHandle_inAppExtension_skipsRegistryAndFulfills() { + Current.isAppExtension = true + let payload: [String: Any] = ["tag": "test-tag"] + XCTAssertNoThrow(try hang(sut.handle(payload))) + XCTAssertTrue(mockRegistry.endCalls.isEmpty) + } + + // MARK: - Tag validation + + func testHandle_missingTag_fulfillsWithoutCallingRegistry() { + XCTAssertNoThrow(try hang(sut.handle([:]))) + XCTAssertTrue(mockRegistry.endCalls.isEmpty) + } + + func testHandle_emptyTag_fulfillsWithoutCallingRegistry() { + let payload: [String: Any] = ["tag": ""] + XCTAssertNoThrow(try hang(sut.handle(payload))) + XCTAssertTrue(mockRegistry.endCalls.isEmpty) + } + + func testHandle_invalidTag_fulfillsWithoutCallingRegistry() { + let payload: [String: Any] = ["tag": "bad tag!"] + XCTAssertNoThrow(try hang(sut.handle(payload))) + XCTAssertTrue(mockRegistry.endCalls.isEmpty) + } + + // MARK: - Dismissal policy + + func testHandle_noDismissalPolicy_usesImmediate() { + let payload: [String: Any] = ["tag": "end-tag"] + XCTAssertNoThrow(try hang(sut.handle(payload))) + XCTAssertEqual(mockRegistry.endCalls.count, 1) + XCTAssertEqual(mockRegistry.endCalls[0].tag, "end-tag") + XCTAssertTrue(mockRegistry.endCalls[0].policyIsImmediate) + } + + func testHandle_defaultDismissalPolicy_usesDefault() { + let payload: [String: Any] = ["tag": "end-tag", "dismissal_policy": "default"] + XCTAssertNoThrow(try hang(sut.handle(payload))) + XCTAssertEqual(mockRegistry.endCalls.count, 1) + XCTAssertTrue(mockRegistry.endCalls[0].policyIsDefault) + } + + func testHandle_afterDismissalPolicy_usesAfterPolicy() { + let future = Date().addingTimeInterval(60) + let payload: [String: Any] = [ + "tag": "end-tag", + "dismissal_policy": "after:\(future.timeIntervalSince1970)", + ] + XCTAssertNoThrow(try hang(sut.handle(payload))) + XCTAssertEqual(mockRegistry.endCalls.count, 1) + // Verify an .after policy was chosen (not .immediate or .default) + XCTAssertTrue(mockRegistry.endCalls[0].policyIsAfter) + // Verify the stored policy matches the expected date (ActivityUIDismissalPolicy is Equatable) + let expectedDate = Date(timeIntervalSince1970: future.timeIntervalSince1970) + XCTAssertEqual(mockRegistry.endCalls[0].policy, .after(expectedDate)) + } + + func testHandle_afterDismissalPolicy_capsAt24Hours() { + // A timestamp 48 hours in the future should be capped to ≤24 hours + let farFuture = Date().addingTimeInterval(48 * 60 * 60) + let payload: [String: Any] = [ + "tag": "end-tag", + "dismissal_policy": "after:\(farFuture.timeIntervalSince1970)", + ] + XCTAssertNoThrow(try hang(sut.handle(payload))) + XCTAssertEqual(mockRegistry.endCalls.count, 1) + let call = mockRegistry.endCalls[0] + // The policy should be .after (not .immediate), confirming it wasn't discarded + XCTAssertTrue(call.policyIsAfter) + // The stored date must not equal the uncapped far-future date + XCTAssertNotEqual(call.policy, .after(Date(timeIntervalSince1970: farFuture.timeIntervalSince1970))) + } + + func testHandle_afterDismissalPolicyWithInvalidTimestamp_usesImmediate() { + let payload: [String: Any] = ["tag": "end-tag", "dismissal_policy": "after:not-a-number"] + XCTAssertNoThrow(try hang(sut.handle(payload))) + XCTAssertEqual(mockRegistry.endCalls.count, 1) + XCTAssertTrue(mockRegistry.endCalls[0].policyIsImmediate) + } + + func testHandle_unknownDismissalPolicy_usesImmediate() { + let payload: [String: Any] = ["tag": "end-tag", "dismissal_policy": "unknown"] + XCTAssertNoThrow(try hang(sut.handle(payload))) + XCTAssertEqual(mockRegistry.endCalls.count, 1) + XCTAssertTrue(mockRegistry.endCalls[0].policyIsImmediate) + } +} +#endif diff --git a/Tests/Shared/LiveActivity/MockLiveActivityRegistry.swift b/Tests/Shared/LiveActivity/MockLiveActivityRegistry.swift new file mode 100644 index 0000000000..bf3f041d4d --- /dev/null +++ b/Tests/Shared/LiveActivity/MockLiveActivityRegistry.swift @@ -0,0 +1,68 @@ +#if canImport(ActivityKit) +import ActivityKit +import Foundation +@testable import Shared + +/// Test double for `LiveActivityRegistryProtocol`. +/// Records all calls so tests can assert on what was invoked. +@available(iOS 16.2, *) +final class MockLiveActivityRegistry: LiveActivityRegistryProtocol { + // MARK: - Recorded Calls + + struct StartOrUpdateCall: Equatable { + let tag: String + let title: String + } + + struct EndCall { + let tag: String + let policy: ActivityUIDismissalPolicy + } + + private(set) var startOrUpdateCalls: [StartOrUpdateCall] = [] + private(set) var endCalls: [EndCall] = [] + private(set) var reattachCallCount = 0 + + // MARK: - Configurable Errors + + /// Set to make the next `startOrUpdate` throw. + var startOrUpdateError: Error? + + // MARK: - LiveActivityRegistryProtocol + + func startOrUpdate( + tag: String, + title: String, + state: HALiveActivityAttributes.ContentState + ) async throws { + if let error = startOrUpdateError { + startOrUpdateError = nil + throw error + } + startOrUpdateCalls.append(StartOrUpdateCall(tag: tag, title: title)) + } + + func end(tag: String, dismissalPolicy: ActivityUIDismissalPolicy) async { + endCalls.append(EndCall(tag: tag, policy: dismissalPolicy)) + } + + func reattach() async { + reattachCallCount += 1 + } + + @available(iOS 17.2, *) + func startObservingPushToStartToken() async { + // No-op in tests — token observation requires a real device/simulator push environment. + } +} + +// MARK: - EndCall helpers + +@available(iOS 16.2, *) +extension MockLiveActivityRegistry.EndCall { + var policyIsImmediate: Bool { policy == .immediate } + var policyIsDefault: Bool { policy == .default } + /// True when the policy is `.after(date)` for any date. + var policyIsAfter: Bool { !policyIsImmediate && !policyIsDefault } +} +#endif diff --git a/Tests/Shared/LiveActivity/NotificationsCommandManagerLiveActivityTests.swift b/Tests/Shared/LiveActivity/NotificationsCommandManagerLiveActivityTests.swift new file mode 100644 index 0000000000..3f21432629 --- /dev/null +++ b/Tests/Shared/LiveActivity/NotificationsCommandManagerLiveActivityTests.swift @@ -0,0 +1,144 @@ +#if canImport(ActivityKit) +import Foundation +import PromiseKit +@testable import Shared +import XCTest + +/// Tests for the two live-activity routing paths in `NotificationCommandManager`: +/// 1. `homeassistant.command == "live_activity"` — explicit command key +/// 2. `homeassistant.live_activity == true` — data flag (Android-compat pattern) +/// 3. `homeassistant.command == "end_live_activity"` — end command +/// 4. `homeassistant.command == "clear_notification"` with a `tag` — dismisses live activity +@available(iOS 16.2, *) +final class NotificationsCommandManagerLiveActivityTests: XCTestCase { + private var sut: NotificationCommandManager! + private var mockRegistry: MockLiveActivityRegistry! + + override func setUp() { + super.setUp() + mockRegistry = MockLiveActivityRegistry() + Current.liveActivityRegistry = mockRegistry + Current.isAppExtension = false + sut = NotificationCommandManager() + } + + override func tearDown() { + sut = nil + mockRegistry = nil + super.tearDown() + } + + // MARK: - Helpers + + /// Wraps a `homeassistant` sub-dictionary in the outer notification payload structure. + private func makePayload(_ hadict: [String: Any]) -> [AnyHashable: Any] { + ["homeassistant": hadict] + } + + // MARK: - live_activity command routing + + func testHandle_liveActivityCommand_callsStartOrUpdate() { + let payload = makePayload([ + "command": "live_activity", + "tag": "cmd-tag", + "title": "Command Title", + "message": "Hello", + ]) + XCTAssertNoThrow(try hang(sut.handle(payload))) + XCTAssertEqual(mockRegistry.startOrUpdateCalls.count, 1) + XCTAssertEqual(mockRegistry.startOrUpdateCalls[0].tag, "cmd-tag") + XCTAssertEqual(mockRegistry.startOrUpdateCalls[0].title, "Command Title") + } + + // MARK: - live_activity: true data flag routing (Android-compat) + + func testHandle_liveActivityFlag_callsStartOrUpdate() { + let payload = makePayload([ + "live_activity": true, + "tag": "flag-tag", + "title": "Flag Title", + "message": "World", + ]) + XCTAssertNoThrow(try hang(sut.handle(payload))) + XCTAssertEqual(mockRegistry.startOrUpdateCalls.count, 1) + XCTAssertEqual(mockRegistry.startOrUpdateCalls[0].tag, "flag-tag") + XCTAssertEqual(mockRegistry.startOrUpdateCalls[0].title, "Flag Title") + } + + func testHandle_liveActivityFlagFalse_doesNotRouteToLiveActivity() { + // live_activity: false should fall through to standard command routing + let payload = makePayload([ + "live_activity": false, + "tag": "no-tag", + "title": "Should Not Route", + ]) + // No "command" key → returns notCommand error; registry is never called + XCTAssertThrowsError(try hang(sut.handle(payload))) + XCTAssertTrue(mockRegistry.startOrUpdateCalls.isEmpty) + } + + // MARK: - end_live_activity command + + func testHandle_endLiveActivityCommand_callsRegistryEnd() { + let payload = makePayload([ + "command": "end_live_activity", + "tag": "end-me", + ]) + XCTAssertNoThrow(try hang(sut.handle(payload))) + XCTAssertEqual(mockRegistry.endCalls.count, 1) + XCTAssertEqual(mockRegistry.endCalls[0].tag, "end-me") + XCTAssertTrue(mockRegistry.endCalls[0].policyIsImmediate) + } + + func testHandle_endLiveActivityCommand_withDefaultPolicy_callsRegistryEndWithDefaultPolicy() { + let payload = makePayload([ + "command": "end_live_activity", + "tag": "end-me", + "dismissal_policy": "default", + ]) + XCTAssertNoThrow(try hang(sut.handle(payload))) + XCTAssertEqual(mockRegistry.endCalls.count, 1) + XCTAssertTrue(mockRegistry.endCalls[0].policyIsDefault) + } + + // MARK: - clear_notification also ends live activity + + // NOTE: testHandle_clearNotificationWithTag_callsRegistryEnd is intentionally omitted. + // HandlerClearNotification calls UNUserNotificationCenter.current().removeDeliveredNotifications + // synchronously before reaching the live activity dismissal path. That API requires a real + // app bundle and throws NSInternalInconsistencyException in the XCTest host process. + // The clear_notification → live activity dismissal path is covered by code review and + // integration testing instead. + + func testHandle_clearNotificationWithoutTag_doesNotCallRegistryEnd() { + // No "tag" key → registry.end() must not be called. + // Intentionally omit "collapseId" too — including any key would trigger + // UNUserNotificationCenter which requires a real app bundle and crashes in tests. + let payload = makePayload(["command": "clear_notification"]) + XCTAssertNoThrow(try hang(sut.handle(payload))) + XCTAssertTrue(mockRegistry.endCalls.isEmpty) + } + + // MARK: - Missing homeassistant dict + + func testHandle_noHomeAssistantKey_throwsNotCommand() { + let payload: [AnyHashable: Any] = ["other": "value"] + XCTAssertThrowsError(try hang(sut.handle(payload))) { error in + guard case NotificationCommandManager.CommandError.notCommand = error else { + return XCTFail("Expected .notCommand, got \(error)") + } + } + } + + // MARK: - Unknown command + + func testHandle_unknownCommand_throwsUnknownCommand() { + let payload = makePayload(["command": "unknown_command_xyz"]) + XCTAssertThrowsError(try hang(sut.handle(payload))) { error in + guard case NotificationCommandManager.CommandError.unknownCommand = error else { + return XCTFail("Expected .unknownCommand, got \(error)") + } + } + } +} +#endif From 43673c18a6909125a1dacd9738b10b5acea7e00f Mon Sep 17 00:00:00 2001 From: rwarner Date: Thu, 19 Mar 2026 17:45:12 -0400 Subject: [PATCH 15/34] fix(live-activity): address all 7 Copilot review comments MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Remove DEVELOPMENT_TEAM = 4Q9RHLUX47 from Widgets Debug pbxproj config - Remove unused PromiseKit import from LiveActivityRegistry - Fix reservation race: track cancelledReservations so end() arriving while Activity.request() is in-flight dismisses on confirmReservation - Add isAppExtension guard to clear_notification live activity path - Report supports_live_activities via areActivitiesEnabled (fixes iPad and devices with Live Activities disabled in Settings) - Fix doc comment 16.1 → 16.2 in LiveActivitySettingsView Co-Authored-By: Claude Sonnet 4.6 --- HomeAssistant.xcodeproj/project.pbxproj | 1 - .../LiveActivitySettingsView.swift | 4 +-- Sources/Shared/API/HAAPI.swift | 4 ++- .../LiveActivity/LiveActivityRegistry.swift | 25 ++++++++++++++++--- .../NotificationsCommandManager.swift | 3 ++- 5 files changed, 29 insertions(+), 8 deletions(-) diff --git a/HomeAssistant.xcodeproj/project.pbxproj b/HomeAssistant.xcodeproj/project.pbxproj index d5bd8f26c9..e7b8259ca2 100644 --- a/HomeAssistant.xcodeproj/project.pbxproj +++ b/HomeAssistant.xcodeproj/project.pbxproj @@ -10951,7 +10951,6 @@ ASSETCATALOG_COMPILER_WIDGET_BACKGROUND_COLOR_NAME = WidgetBackground; CODE_SIGN_ENTITLEMENTS = "Configuration/Entitlements/Extension-ios.entitlements"; "CODE_SIGN_ENTITLEMENTS[sdk=macosx*]" = "Configuration/Entitlements/Extension-catalyst.entitlements"; - DEVELOPMENT_TEAM = 4Q9RHLUX47; INFOPLIST_FILE = Sources/Extensions/Widgets/Resources/Info.plist; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", diff --git a/Sources/App/Settings/LiveActivity/LiveActivitySettingsView.swift b/Sources/App/Settings/LiveActivity/LiveActivitySettingsView.swift index bb2ece0e09..2d5d56cb3c 100644 --- a/Sources/App/Settings/LiveActivity/LiveActivitySettingsView.swift +++ b/Sources/App/Settings/LiveActivity/LiveActivitySettingsView.swift @@ -4,8 +4,8 @@ import SwiftUI // MARK: - Entry point -/// Deployment target is iOS 15. The settings item is filtered from the list on < iOS 16.1 -/// (see SettingsItem.allVisibleCases), so this view is only ever navigated to on iOS 16.1+. +/// Deployment target is iOS 15. The settings item is filtered from the list on < iOS 16.2 +/// (see SettingsItem.allVisibleCases), so this view is only ever navigated to on iOS 16.2+. @available(iOS 16.2, *) struct LiveActivitySettingsView: View { // MARK: State diff --git a/Sources/Shared/API/HAAPI.swift b/Sources/Shared/API/HAAPI.swift index 31dcb04ef6..acb004bb6d 100644 --- a/Sources/Shared/API/HAAPI.swift +++ b/Sources/Shared/API/HAAPI.swift @@ -566,7 +566,9 @@ public class HomeAssistantAPI { if #available(iOS 16.2, *) { // Advertise Live Activity support so HA can gate the UI and send // activity push tokens back to the relay server. - appData["supports_live_activities"] = true + // Use areActivitiesEnabled so iPad and users who disabled Live Activities + // in Settings correctly report false. + appData["supports_live_activities"] = ActivityAuthorizationInfo().areActivitiesEnabled } if #available(iOS 17.2, *) { appData["supports_live_activities_frequent_updates"] = diff --git a/Sources/Shared/LiveActivity/LiveActivityRegistry.swift b/Sources/Shared/LiveActivity/LiveActivityRegistry.swift index f262e4e650..141f9b2e90 100644 --- a/Sources/Shared/LiveActivity/LiveActivityRegistry.swift +++ b/Sources/Shared/LiveActivity/LiveActivityRegistry.swift @@ -1,7 +1,6 @@ #if canImport(ActivityKit) import ActivityKit import Foundation -import PromiseKit /// Stale date offset for all Live Activity content updates. /// Activities are marked stale after 30 minutes if no further updates arrive. @@ -37,6 +36,9 @@ public actor LiveActivityRegistry: LiveActivityRegistryProtocol { /// Tags currently in-flight (reserved but not yet confirmed or cancelled). private var reserved: Set = [] + /// Tags where `end()` arrived while still reserved — activity must be dismissed on confirm. + private var cancelledReservations: Set = [] + /// Confirmed, running Live Activities keyed by tag. private var entries: [String: Entry] = [:] @@ -52,13 +54,21 @@ public actor LiveActivityRegistry: LiveActivityRegistryProtocol { return true } - private func confirmReservation(id: String, entry: Entry) { + /// Confirm a reservation. If `end()` arrived while we were in-flight, immediately dismiss. + private func confirmReservation(id: String, entry: Entry) async { reserved.remove(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 } private func cancelReservation(id: String) { reserved.remove(id) + cancelledReservations.remove(id) } private func remove(id: String) -> Entry? { @@ -130,7 +140,7 @@ public actor LiveActivityRegistry: LiveActivityRegistryProtocol { } let observationTask = makeObservationTask(for: activity) - confirmReservation(id: tag, entry: Entry(activity: activity, observationTask: observationTask)) + await confirmReservation(id: tag, entry: Entry(activity: activity, observationTask: observationTask)) Current.Log.verbose("LiveActivityRegistry: started activity for tag \(tag), id=\(activity.id)") } @@ -141,6 +151,15 @@ public actor LiveActivityRegistry: LiveActivityRegistryProtocol { 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.activities .first(where: { $0.attributes.tag == tag }) { diff --git a/Sources/Shared/Notifications/NotificationCommands/NotificationsCommandManager.swift b/Sources/Shared/Notifications/NotificationCommands/NotificationsCommandManager.swift index 24c97310cd..bd0bbce086 100644 --- a/Sources/Shared/Notifications/NotificationCommands/NotificationsCommandManager.swift +++ b/Sources/Shared/Notifications/NotificationCommands/NotificationsCommandManager.swift @@ -112,8 +112,9 @@ private struct HandlerClearNotification: NotificationCommandHandler { // Also end any Live Activity whose tag matches — same YAML works on both iOS and Android. // Bridged into the returned Promise so the background fetch window stays open until // the activity is actually dismissed (prevents the OS suspending mid-dismiss). + // ActivityKit is unavailable in the PushProvider extension, so guard accordingly. #if os(iOS) && canImport(ActivityKit) - if #available(iOS 16.2, *), let tag = payload["tag"] as? String { + if #available(iOS 16.2, *), !Current.isAppExtension, let tag = payload["tag"] as? String { return Promise { seal in Task { await Current.liveActivityRegistry.end(tag: tag, dismissalPolicy: .immediate) From e99d4d9ec04a9df54b982555540dc28aea3a5224 Mon Sep 17 00:00:00 2001 From: rwarner Date: Thu, 19 Mar 2026 17:58:15 -0400 Subject: [PATCH 16/34] fix(live-activity): address 3 more Copilot review comments MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Fix SwiftGen doc comment: u203A escaped literal -> actual › character - Fix apnsEnvironment: TestFlight uses production APNs endpoint, not sandbox — remove isTestFlight ? "sandbox" branch - PR description: document live_activity_push_to_start_apns_environment field that was missing from the HAAPI.swift modified files list Co-Authored-By: Claude Sonnet 4.6 --- Sources/Shared/Environment/Environment.swift | 5 +++-- Sources/Shared/Resources/Swiftgen/Strings.swift | 2 +- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/Sources/Shared/Environment/Environment.swift b/Sources/Shared/Environment/Environment.swift index ff7a17b57e..ddcd7c867c 100644 --- a/Sources/Shared/Environment/Environment.swift +++ b/Sources/Shared/Environment/Environment.swift @@ -130,12 +130,13 @@ public class AppEnvironment { AreasService.shared } - /// APNs environment string for token reporting. "sandbox" in DEBUG/TestFlight, "production" otherwise. + /// APNs environment string for token reporting. "sandbox" in DEBUG builds, "production" otherwise. + /// TestFlight uses distribution signing and routes through the production APNs endpoint. public var apnsEnvironment: String { #if DEBUG return "sandbox" #else - return isTestFlight ? "sandbox" : "production" + return "production" #endif } diff --git a/Sources/Shared/Resources/Swiftgen/Strings.swift b/Sources/Shared/Resources/Swiftgen/Strings.swift index 2b7da07c3c..8459f4cf01 100644 --- a/Sources/Shared/Resources/Swiftgen/Strings.swift +++ b/Sources/Shared/Resources/Swiftgen/Strings.swift @@ -1864,7 +1864,7 @@ public enum L10n { } } public enum FrequentUpdates { - /// Allows Home Assistant to update Live Activities up to once per second. Enable in Settings u203A %@ u203A Live Activities. + /// Allows Home Assistant to update Live Activities up to once per second. Enable in Settings › %@ › Live Activities. public static func footer(_ p1: Any) -> String { return L10n.tr("Localizable", "live_activity.frequent_updates.footer", String(describing: p1)) } From 5fb61e618a71d32be837e4592730fcdbc105e870 Mon Sep 17 00:00:00 2001 From: rwarner Date: Mon, 23 Mar 2026 12:54:05 -0400 Subject: [PATCH 17/34] Fix lint issues from bundle exec fastlane autocorrect - Add missing blank line after MARK comment in LiveActivitySettingsView - Wrap long log line in LiveActivityRegistry to stay within line limit Co-Authored-By: Claude Opus 4.6 (1M context) --- .../App/Settings/LiveActivity/LiveActivitySettingsView.swift | 1 + Sources/Shared/LiveActivity/LiveActivityRegistry.swift | 3 ++- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/Sources/App/Settings/LiveActivity/LiveActivitySettingsView.swift b/Sources/App/Settings/LiveActivity/LiveActivitySettingsView.swift index 2d5d56cb3c..f66e5cd0e4 100644 --- a/Sources/App/Settings/LiveActivity/LiveActivitySettingsView.swift +++ b/Sources/App/Settings/LiveActivity/LiveActivitySettingsView.swift @@ -101,6 +101,7 @@ struct LiveActivitySettingsView: View { } // MARK: - Debug (DEBUG builds only) + // // Two sections: Static (fixed snapshots to verify layout) and Animated (multi-stage // self-updating sequences to simulate real HA automation behavior). diff --git a/Sources/Shared/LiveActivity/LiveActivityRegistry.swift b/Sources/Shared/LiveActivity/LiveActivityRegistry.swift index 141f9b2e90..29fb0ebbb4 100644 --- a/Sources/Shared/LiveActivity/LiveActivityRegistry.swift +++ b/Sources/Shared/LiveActivity/LiveActivityRegistry.swift @@ -156,7 +156,8 @@ public actor LiveActivityRegistry: LiveActivityRegistryProtocol { // 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") + Current.Log + .verbose("LiveActivityRegistry: end() received for in-flight tag \(tag), will dismiss on confirm") return } From 431e8f28f984775c6a95d325f56a3ddad387ca84 Mon Sep 17 00:00:00 2001 From: rwarner Date: Mon, 23 Mar 2026 15:47:41 -0400 Subject: [PATCH 18/34] Handle Live Activity commands in foreground notifications and trigger Dynamic Island - NotificationManager willPresent: detect command notifications (live_activity or command key in homeassistant dict) and route through commandManager.handle() before returning. Suppresses the standard notification banner for commands so the user sees only the Live Activity, not a duplicate. - LiveActivityRegistry: immediately update with AlertConfiguration after Activity.request() to trigger the expanded Dynamic Island presentation. request() alone only shows the compact pill; the expanded "bloom" animation requires an update with an alert config per Apple's ActivityKit docs. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../App/Notifications/NotificationManager.swift | 14 ++++++++++++++ .../LiveActivity/LiveActivityRegistry.swift | 15 +++++++++++++++ 2 files changed, 29 insertions(+) diff --git a/Sources/App/Notifications/NotificationManager.swift b/Sources/App/Notifications/NotificationManager.swift index 8294b93302..8fe356dc1c 100644 --- a/Sources/App/Notifications/NotificationManager.swift +++ b/Sources/App/Notifications/NotificationManager.swift @@ -281,6 +281,20 @@ extension NotificationManager: UNUserNotificationCenterDelegate { ) { Messaging.messaging().appDidReceiveMessage(notification.request.content.userInfo) + // Handle commands (including Live Activities) for foreground notifications. + // didReceiveRemoteNotification handles background pushes via Firebase/APNs, + // but willPresent fires when the app is in the foreground. Without this, + // notifications received while the app is open would never trigger the + // Live Activity handler. + // If a command is recognized, suppress the notification banner so the user + // sees only the Live Activity (not a duplicate standard notification). + if let hadict = notification.request.content.userInfo["homeassistant"] as? [String: Any], + (hadict["command"] as? String) != nil || (hadict["live_activity"] as? Bool) == true { + commandManager.handle(notification.request.content.userInfo).cauterize() + completionHandler([]) + return + } + if notification.request.content.userInfo[XCGLogger.notifyUserInfoKey] != nil, UIApplication.shared.applicationState != .background { completionHandler([]) diff --git a/Sources/Shared/LiveActivity/LiveActivityRegistry.swift b/Sources/Shared/LiveActivity/LiveActivityRegistry.swift index 29fb0ebbb4..233c2c0e49 100644 --- a/Sources/Shared/LiveActivity/LiveActivityRegistry.swift +++ b/Sources/Shared/LiveActivity/LiveActivityRegistry.swift @@ -139,6 +139,21 @@ public actor LiveActivityRegistry: LiveActivityRegistryProtocol { 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: Date().addingTimeInterval(kLiveActivityStaleInterval), + relevanceScore: 0.5 + ) + 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)") From f6aa15449232b51c0545c9f003dfe748aa757008 Mon Sep 17 00:00:00 2001 From: rwarner Date: Tue, 24 Mar 2026 10:29:11 -0400 Subject: [PATCH 19/34] Address code owner review: design system, color assets, cleanup - Use DesignSystem.Spaces for all hardcoded padding/spacing values in HALockScreenView and HADynamicIslandView - Replace inline colors with Color.haPrimary fallback; scope haBlueHex as private static constants where UIColor(hex:) still needs it - Extract duplicated icon size (20pt) and compact trailing maxWidth (50pt) into private constants - Extract lock screen background tint into private constant - Remove redundant #available check in WidgetsBundle17 (iOS 17 > 16.2) - Remove unnecessary comment in WidgetsBundle18 - Add doc comment on isValidTag explaining allowed characters and why Co-Authored-By: Claude Opus 4.6 (1M context) --- .../LiveActivity/HADynamicIslandView.swift | 36 +++++++++++------ .../HALiveActivityConfiguration.swift | 5 ++- .../LiveActivity/HALockScreenView.swift | 39 +++++++++++-------- Sources/Extensions/Widgets/Widgets.swift | 5 +-- .../HandlerLiveActivity.swift | 6 +++ 5 files changed, 59 insertions(+), 32 deletions(-) diff --git a/Sources/Extensions/Widgets/LiveActivity/HADynamicIslandView.swift b/Sources/Extensions/Widgets/LiveActivity/HADynamicIslandView.swift index 05fc6da7d4..d55041e15b 100644 --- a/Sources/Extensions/Widgets/LiveActivity/HADynamicIslandView.swift +++ b/Sources/Extensions/Widgets/LiveActivity/HADynamicIslandView.swift @@ -15,7 +15,7 @@ func makeHADynamicIsland( DynamicIsland { DynamicIslandExpandedRegion(.leading) { HADynamicIslandIconView(slug: state.icon, color: state.color, size: 24) - .padding(.leading, 4) + .padding(.leading, DesignSystem.Spaces.half) } DynamicIslandExpandedRegion(.center) { Text(attributes.title) @@ -25,19 +25,19 @@ func makeHADynamicIsland( } DynamicIslandExpandedRegion(.trailing) { HAExpandedTrailingView(state: state) - .padding(.trailing, 4) + .padding(.trailing, DesignSystem.Spaces.half) } DynamicIslandExpandedRegion(.bottom) { HAExpandedBottomView(state: state) - .padding(.horizontal, 8) - .padding(.bottom, 4) + .padding(.horizontal, DesignSystem.Spaces.one) + .padding(.bottom, DesignSystem.Spaces.half) } } compactLeading: { HADynamicIslandIconView(slug: state.icon, color: state.color, size: 16) - .padding(.leading, 4) + .padding(.leading, DesignSystem.Spaces.half) } compactTrailing: { HACompactTrailingView(state: state) - .padding(.trailing, 4) + .padding(.trailing, DesignSystem.Spaces.half) } minimal: { HADynamicIslandIconView(slug: state.icon, color: state.color, size: 14) } @@ -51,10 +51,13 @@ struct HADynamicIslandIconView: View { let color: String? let size: CGFloat + /// Hex string for Home Assistant brand blue — used for UIColor(hex:) fallback. + private static let haBlueHex = "#03A9F4" + var body: some View { if let slug { // UIColor(hex:) from Shared handles nil/CSS names/3-6-8 digit hex; non-failable. - let uiColor = UIColor(hex: color ?? haBlueHex) + let uiColor = UIColor(hex: color ?? Self.haBlueHex) let mdiIcon = MaterialDesignIcons(serversideValueNamed: slug) Image(uiImage: mdiIcon.image( ofSize: .init(width: size, height: size), @@ -72,19 +75,22 @@ struct HADynamicIslandIconView: View { struct HACompactTrailingView: View { let state: HALiveActivityAttributes.ContentState + /// Maximum width for compact trailing text to prevent overflow in the Dynamic Island. + private static let compactTrailingMaxWidth: CGFloat = 50 + var body: some View { if state.chronometer == true, let end = state.countdownEnd { Text(timerInterval: Date.now ... end, countsDown: true) .font(.caption2) .foregroundStyle(.white) .monospacedDigit() - .frame(maxWidth: 50) + .frame(maxWidth: Self.compactTrailingMaxWidth) } else if let critical = state.criticalText { Text(critical) .font(.caption2) .foregroundStyle(.white) .lineLimit(1) - .frame(maxWidth: 50) + .frame(maxWidth: Self.compactTrailingMaxWidth) } else if let fraction = state.progressFraction { Text("\(Int(fraction * 100))%") .font(.caption2) @@ -122,7 +128,7 @@ struct HAExpandedBottomView: View { let state: HALiveActivityAttributes.ContentState var body: some View { - VStack(spacing: 4) { + VStack(spacing: DesignSystem.Spaces.half) { if state.chronometer == true, let end = state.countdownEnd { Text(timerInterval: Date.now ... end, countsDown: true) .font(.body.monospacedDigit()) @@ -136,8 +142,16 @@ struct HAExpandedBottomView: View { if let fraction = state.progressFraction { ProgressView(value: fraction) - .tint(Color(hex: state.color ?? haBlueHex)) + .tint(accentColor) } } } + + /// Accent color from ContentState, fallback to Home Assistant primary blue. + private var accentColor: Color { + if let hex = state.color { + return Color(hex: hex) + } + return .haPrimary + } } diff --git a/Sources/Extensions/Widgets/LiveActivity/HALiveActivityConfiguration.swift b/Sources/Extensions/Widgets/LiveActivity/HALiveActivityConfiguration.swift index 203a835c48..3dd10f7a32 100644 --- a/Sources/Extensions/Widgets/LiveActivity/HALiveActivityConfiguration.swift +++ b/Sources/Extensions/Widgets/LiveActivity/HALiveActivityConfiguration.swift @@ -5,13 +5,16 @@ import WidgetKit @available(iOS 16.2, *) struct HALiveActivityConfiguration: Widget { + /// Semi-transparent dark background for the Lock Screen presentation. + private static let lockScreenBackground = Color.black.opacity(0.75) + var body: some WidgetConfiguration { ActivityConfiguration(for: HALiveActivityAttributes.self) { context in HALockScreenView( attributes: context.attributes, state: context.state ) - .activityBackgroundTint(Color.black.opacity(0.75)) + .activityBackgroundTint(Self.lockScreenBackground) .activitySystemActionForegroundColor(Color.white) } dynamicIsland: { context in makeHADynamicIsland(attributes: context.attributes, state: context.state) diff --git a/Sources/Extensions/Widgets/LiveActivity/HALockScreenView.swift b/Sources/Extensions/Widgets/LiveActivity/HALockScreenView.swift index 0cef08568b..7b1fd5b0b5 100644 --- a/Sources/Extensions/Widgets/LiveActivity/HALockScreenView.swift +++ b/Sources/Extensions/Widgets/LiveActivity/HALockScreenView.swift @@ -12,10 +12,19 @@ struct HALockScreenView: View { let attributes: HALiveActivityAttributes let state: HALiveActivityAttributes.ContentState + /// Icon size for the MDI icon in the header row. + private static let iconSize: CGFloat = 20 + + /// Hex string for Home Assistant brand blue — used for UIColor(hex:) fallback. + private static let haBlueHex = "#03A9F4" + + /// Subdued white for secondary text (timer/message body). + private static let secondaryWhite: Color = .white.opacity(0.85) + var body: some View { - VStack(alignment: .leading, spacing: 6) { + VStack(alignment: .leading, spacing: DesignSystem.Spaces.half) { // Header row: icon + title - HStack(spacing: 8) { + HStack(spacing: DesignSystem.Spaces.one) { iconView Text(attributes.title) .font(.headline) @@ -27,12 +36,12 @@ struct HALockScreenView: View { if state.chronometer == true, let end = state.countdownEnd { Text(timerInterval: Date.now ... end, countsDown: true) .font(.subheadline) - .foregroundStyle(.white.opacity(0.85)) + .foregroundStyle(Self.secondaryWhite) .monospacedDigit() } else { Text(state.message) .font(.subheadline) - .foregroundStyle(.white.opacity(0.85)) + .foregroundStyle(Self.secondaryWhite) .lineLimit(2) } @@ -42,8 +51,8 @@ struct HALockScreenView: View { .tint(accentColor) } } - .padding(.horizontal, 16) - .padding(.vertical, 12) + .padding(.horizontal, DesignSystem.Spaces.two) + .padding(.vertical, DesignSystem.Spaces.oneAndHalf) } // MARK: - Sub-views @@ -52,26 +61,24 @@ struct HALockScreenView: View { private var iconView: some View { if let iconSlug = state.icon { // UIColor(hex:) from Shared handles CSS names and 3/6/8-digit hex; non-failable. - let uiColor = UIColor(hex: state.color ?? haBlueHex) + let uiColor = UIColor(hex: state.color ?? Self.haBlueHex) let mdiIcon = MaterialDesignIcons(serversideValueNamed: iconSlug) Image(uiImage: mdiIcon.image( - ofSize: .init(width: 20, height: 20), + ofSize: .init(width: Self.iconSize, height: Self.iconSize), color: uiColor )) .resizable() - .frame(width: 20, height: 20) + .frame(width: Self.iconSize, height: Self.iconSize) } } // MARK: - Helpers - /// Parse hex color from ContentState, fallback to Home Assistant blue. + /// Accent color from ContentState, fallback to Home Assistant primary blue. private var accentColor: Color { - Color(hex: state.color ?? haBlueHex) + if let hex = state.color { + return Color(hex: hex) + } + return .haPrimary } } - -// MARK: - Constants - -/// Home Assistant brand blue — used as fallback for icon and progress bar tints. -let haBlueHex = "#03A9F4" diff --git a/Sources/Extensions/Widgets/Widgets.swift b/Sources/Extensions/Widgets/Widgets.swift index 7b042fd690..a3014eeabf 100644 --- a/Sources/Extensions/Widgets/Widgets.swift +++ b/Sources/Extensions/Widgets/Widgets.swift @@ -37,9 +37,7 @@ struct WidgetsBundle17: WidgetBundle { } var body: some Widget { - if #available(iOSApplicationExtension 16.2, *) { - HALiveActivityConfiguration() - } + HALiveActivityConfiguration() WidgetCommonlyUsedEntities() WidgetCustom() WidgetAssist() @@ -60,7 +58,6 @@ struct WidgetsBundle18: WidgetBundle { } var body: some Widget { - // Live Activities (ActivityKit requires iOS 16.2+, this bundle requires iOS 18.0+) HALiveActivityConfiguration() // Controls diff --git a/Sources/Shared/Notifications/NotificationCommands/HandlerLiveActivity.swift b/Sources/Shared/Notifications/NotificationCommands/HandlerLiveActivity.swift index deb33d418d..b8f7663432 100644 --- a/Sources/Shared/Notifications/NotificationCommands/HandlerLiveActivity.swift +++ b/Sources/Shared/Notifications/NotificationCommands/HandlerLiveActivity.swift @@ -88,6 +88,12 @@ struct HandlerStartOrUpdateLiveActivity: NotificationCommandHandler { // 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.alphanumerics.union(CharacterSet(charactersIn: "-_")) From 7f1ab64b8a61b6b9d64d392265c0a4d8dfe89e0a Mon Sep 17 00:00:00 2001 From: rwarner Date: Tue, 24 Mar 2026 10:47:50 -0400 Subject: [PATCH 20/34] Add wire-format contract tests for Live Activity frozen values MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Tests that validate values which must never change because they appear in APNs payloads, webhook requests, and notification routing: - HALiveActivityAttributes struct name (APNs attributes-type) - ContentState CodingKeys (JSON field names matching Android) - ContentState JSON round-trip preservation - Push-to-start Keychain key - Command strings: "live_activity", "end_live_activity" - Data flag: live_activity: true (Android-compat pattern) If any test fails, a wire-format contract was broken — the expected value should not be updated without coordinating with server-side PRs. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../LiveActivityContractTests.swift | 130 ++++++++++++++++++ 1 file changed, 130 insertions(+) create mode 100644 Tests/Shared/LiveActivity/LiveActivityContractTests.swift diff --git a/Tests/Shared/LiveActivity/LiveActivityContractTests.swift b/Tests/Shared/LiveActivity/LiveActivityContractTests.swift new file mode 100644 index 0000000000..a1e4482855 --- /dev/null +++ b/Tests/Shared/LiveActivity/LiveActivityContractTests.swift @@ -0,0 +1,130 @@ +#if canImport(ActivityKit) +import Foundation +@testable import Shared +import XCTest + +/// Contract tests that validate wire-format frozen values won't change. +/// +/// These values appear in APNs payloads, webhook requests, and notification routing. +/// Changing them would break communication with the HA server, relay server, or APNs. +/// If a test fails, it means a wire-format contract was broken — do not simply update +/// the expected value without coordinating with all server-side consumers. +@available(iOS 16.2, *) +final class LiveActivityContractTests: XCTestCase { + // MARK: - HALiveActivityAttributes (wire-format frozen struct) + + /// The struct name appears as `attributes-type` in APNs push-to-start payloads. + /// Renaming it silently breaks all remote starts. + func testAttributesTypeName_isFrozen() { + let typeName = String(describing: HALiveActivityAttributes.self) + XCTAssertEqual(typeName, "HALiveActivityAttributes") + } + + /// CodingKeys define the JSON field names in APNs content-state payloads. + /// Adding new optional fields is safe; renaming or removing breaks in-flight activities. + func testContentState_codingKeys_areFrozen() { + let state = HALiveActivityAttributes.ContentState( + message: "test", + criticalText: "ct", + progress: 1, + progressMax: 2, + chronometer: true, + countdownEnd: Date(timeIntervalSince1970: 0), + icon: "mdi:test", + color: "#FF0000" + ) + let encoder = JSONEncoder() + encoder.dateEncodingStrategy = .secondsSince1970 + let data = try! encoder.encode(state) + let dict = try! JSONSerialization.jsonObject(with: data) as! [String: Any] + + // These keys must match the Android notification field names exactly. + let expectedKeys: Set = [ + "message", + "critical_text", + "progress", + "progress_max", + "chronometer", + "countdown_end", + "icon", + "color", + ] + XCTAssertEqual(Set(dict.keys), expectedKeys) + } + + /// ContentState must round-trip through JSON without data loss. + func testContentState_roundTrip_preservesAllFields() { + let original = HALiveActivityAttributes.ContentState( + message: "Cycle in progress", + criticalText: "45 min", + progress: 2700, + progressMax: 3600, + chronometer: true, + countdownEnd: Date(timeIntervalSince1970: 1_700_000_000), + icon: "mdi:washing-machine", + color: "#2196F3" + ) + let encoder = JSONEncoder() + encoder.dateEncodingStrategy = .secondsSince1970 + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = .secondsSince1970 + + let data = try! encoder.encode(original) + let decoded = try! decoder.decode(HALiveActivityAttributes.ContentState.self, from: data) + + XCTAssertEqual(decoded, original) + } + + // MARK: - LiveActivityRegistry (webhook keys) + + /// The Keychain key for the push-to-start token. Changing it would lose stored tokens. + func testPushToStartTokenKeychainKey_isFrozen() { + XCTAssertEqual( + LiveActivityRegistry.pushToStartTokenKeychainKey, + "live_activity_push_to_start_token" + ) + } + + // MARK: - NotificationsCommandManager (command strings) + + /// The command strings that route to Live Activity handlers. + /// Changing these breaks the HA → app notification contract. + func testLiveActivityCommandStrings_areFrozen() { + let manager = NotificationCommandManager() + + // "live_activity" command must route successfully (not throw unknownCommand) + let liveActivityPayload: [AnyHashable: Any] = [ + "homeassistant": [ + "command": "live_activity", + "tag": "test", + "title": "Test", + "message": "Hello", + ] as [String: Any], + ] + XCTAssertNoThrow(try hang(manager.handle(liveActivityPayload))) + + // "end_live_activity" command must route successfully + let endPayload: [AnyHashable: Any] = [ + "homeassistant": [ + "command": "end_live_activity", + "tag": "test", + ] as [String: Any], + ] + XCTAssertNoThrow(try hang(manager.handle(endPayload))) + } + + /// The `live_activity: true` data flag (Android-compat pattern) must be recognized. + func testLiveActivityDataFlag_isRecognized() { + let manager = NotificationCommandManager() + let payload: [AnyHashable: Any] = [ + "homeassistant": [ + "live_activity": true, + "tag": "test", + "title": "Test", + "message": "Hello", + ] as [String: Any], + ] + XCTAssertNoThrow(try hang(manager.handle(payload))) + } +} +#endif From 0f6e66b2651de212377eb0ec2b5aa4e3e0eeb6cc Mon Sep 17 00:00:00 2001 From: rwarner Date: Tue, 24 Mar 2026 10:56:01 -0400 Subject: [PATCH 21/34] Add contract tests for webhook dictionary keys (#14, #15) Extract webhook type strings and dictionary key sets as static constants on LiveActivityRegistry so they can be tested directly: - webhookTypeToken / tokenWebhookKeys (for push token reporting) - webhookTypeDismissed / dismissedWebhookKeys (for activity dismissal) Private methods now reference these constants instead of inline strings. Contract tests validate the exact values match what HA core expects. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../LiveActivity/LiveActivityRegistry.swift | 16 +++++++- .../LiveActivityContractTests.swift | 38 ++++++++++++++++++- 2 files changed, 51 insertions(+), 3 deletions(-) diff --git a/Sources/Shared/LiveActivity/LiveActivityRegistry.swift b/Sources/Shared/LiveActivity/LiveActivityRegistry.swift index 233c2c0e49..5da9634ca0 100644 --- a/Sources/Shared/LiveActivity/LiveActivityRegistry.swift +++ b/Sources/Shared/LiveActivity/LiveActivityRegistry.swift @@ -31,6 +31,18 @@ public actor LiveActivityRegistry: LiveActivityRegistryProtocol { let observationTask: Task } + // 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 = ["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 = ["activity_id", "tag", "reason"] + // MARK: - State /// Tags currently in-flight (reserved but not yet confirmed or cancelled). @@ -276,7 +288,7 @@ public actor LiveActivityRegistry: LiveActivityRegistryProtocol { /// 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: "mobile_app_live_activity_token", + type: Self.webhookTypeToken, data: [ "activity_id": activityID, "push_token": tokenHex, @@ -292,7 +304,7 @@ public actor LiveActivityRegistry: LiveActivityRegistryProtocol { /// 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: "mobile_app_live_activity_dismissed", + type: Self.webhookTypeDismissed, data: [ "activity_id": activityID, "tag": tag, diff --git a/Tests/Shared/LiveActivity/LiveActivityContractTests.swift b/Tests/Shared/LiveActivity/LiveActivityContractTests.swift index a1e4482855..7d93cc5e95 100644 --- a/Tests/Shared/LiveActivity/LiveActivityContractTests.swift +++ b/Tests/Shared/LiveActivity/LiveActivityContractTests.swift @@ -75,7 +75,7 @@ final class LiveActivityContractTests: XCTestCase { XCTAssertEqual(decoded, original) } - // MARK: - LiveActivityRegistry (webhook keys) + // MARK: - LiveActivityRegistry (webhook contracts) /// The Keychain key for the push-to-start token. Changing it would lose stored tokens. func testPushToStartTokenKeychainKey_isFrozen() { @@ -85,6 +85,42 @@ final class LiveActivityContractTests: XCTestCase { ) } + /// Webhook type string for reporting a new per-activity push token. + /// Must match the HA core webhook handler name. + func testWebhookTypeToken_isFrozen() { + XCTAssertEqual( + LiveActivityRegistry.webhookTypeToken, + "mobile_app_live_activity_token" + ) + } + + /// Keys in the token webhook request data dictionary. + /// Must match what HA core's update_live_activity_token handler expects. + func testTokenWebhookKeys_areFrozen() { + XCTAssertEqual( + LiveActivityRegistry.tokenWebhookKeys, + ["activity_id", "push_token", "apns_environment"] + ) + } + + /// Webhook type string for reporting a dismissed activity. + /// Must match the HA core webhook handler name. + func testWebhookTypeDismissed_isFrozen() { + XCTAssertEqual( + LiveActivityRegistry.webhookTypeDismissed, + "mobile_app_live_activity_dismissed" + ) + } + + /// Keys in the dismissed webhook request data dictionary. + /// Must match what HA core's live_activity_dismissed handler expects. + func testDismissedWebhookKeys_areFrozen() { + XCTAssertEqual( + LiveActivityRegistry.dismissedWebhookKeys, + ["activity_id", "tag", "reason"] + ) + } + // MARK: - NotificationsCommandManager (command strings) /// The command strings that route to Live Activity handlers. From dbf08dff7fd7e3094f5fbe7db868db00f741067f Mon Sep 17 00:00:00 2001 From: rwarner Date: Tue, 24 Mar 2026 12:50:24 -0400 Subject: [PATCH 22/34] Fix WebSocket local push path for Live Activities on Simulator MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two bugs uncovered while testing Live Activities via local WebSocket push: 1. Use InterfaceDirect on Simulator — NEAppPushProvider (Network Extension) does not run in the Simulator, so the local push channel was never opened and notifications fell back to remote APNs/FCM which also fails on Simulator. 2. Promote live_activity fields into homeassistant payload in LegacyNotificationParserImpl — the WebSocket delivery path produces a flat payload where data.live_activity was never mapped into payload["homeassistant"]. NotificationCommandManager checks payload["homeassistant"]["live_activity"], so Live Activity notifications arrived as regular banners instead of starting a Live Activity. This bug also affects real devices on a local/LAN connection. Co-Authored-By: Claude Sonnet 4.6 --- .../Notifications/NotificationManager.swift | 4 ++++ .../Sources/NotificationParserLegacy.swift | 22 +++++++++++++++++++ 2 files changed, 26 insertions(+) diff --git a/Sources/App/Notifications/NotificationManager.swift b/Sources/App/Notifications/NotificationManager.swift index 8fe356dc1c..812f216124 100644 --- a/Sources/App/Notifications/NotificationManager.swift +++ b/Sources/App/Notifications/NotificationManager.swift @@ -10,11 +10,15 @@ import XCGLogger class NotificationManager: NSObject, LocalPushManagerDelegate { lazy var localPushManager: NotificationManagerLocalPushInterface = { + #if targetEnvironment(simulator) + return NotificationManagerLocalPushInterfaceDirect(delegate: self) + #else if Current.isCatalyst { return NotificationManagerLocalPushInterfaceDirect(delegate: self) } else { return NotificationManagerLocalPushInterfaceExtension() } + #endif }() var commandManager = NotificationCommandManager() diff --git a/Sources/SharedPush/Sources/NotificationParserLegacy.swift b/Sources/SharedPush/Sources/NotificationParserLegacy.swift index 1989e5b624..8363d6dff7 100644 --- a/Sources/SharedPush/Sources/NotificationParserLegacy.swift +++ b/Sources/SharedPush/Sources/NotificationParserLegacy.swift @@ -212,6 +212,28 @@ public struct LegacyNotificationParserImpl: LegacyNotificationParser { headers["apns-collapse-id"] = tag } + // Promote live_activity fields from `data` into `homeassistant` so that + // NotificationCommandManager can route to HandlerStartOrUpdateLiveActivity. + // This handles the WebSocket (local push) delivery path where the parser + // produces a flat payload — unlike APNs which already has a `homeassistant` key. + if data["live_activity"] as? Bool == true { + var homeassistant = payload["homeassistant"] as? [String: Any] ?? [:] + homeassistant["live_activity"] = true + for key in [ + "tag", "critical_text", "progress", "progress_max", "chronometer", + "when", "when_relative", "notification_icon", "notification_icon_color", + ] { + if let value = data[key] { + homeassistant[key] = value + } + } + if let title = input["title"] as? String { + homeassistant["title"] = title + } + homeassistant["message"] = input["message"] + payload["homeassistant"] = homeassistant + } + if registrationInfo["os_version"]?.starts(with: "10.15") == true { payload.mutateInside("aps") { aps in if let sound = aps["sound"] as? String { From ec5481fe297ce3de2119ac64f031e5dbc08d5117 Mon Sep 17 00:00:00 2001 From: rwarner Date: Wed, 25 Mar 2026 08:44:23 -0400 Subject: [PATCH 23/34] Address Copilot review feedback on Live Activity push handling - LiveActivityRegistry: silence AlertConfiguration sound on start (sound: nil) so the Dynamic Island bloom animation doesn't trigger an audible alert - LiveActivityRegistry: track pending state for in-flight reservations so a second rapid push with the same tag applies its newer state on confirm instead of being silently dropped - NotificationManager: willPresent now awaits handle() result before suppressing the banner; unknown commands fall back to normal presentation instead of being swallowed silently - NotificationParserLegacy: only promote message to homeassistant dict when it is a String, preventing Optional("...") from leaking into the payload Co-Authored-By: Claude Sonnet 4.6 --- .../Notifications/NotificationManager.swift | 12 ++++++++-- .../LiveActivity/LiveActivityRegistry.swift | 23 +++++++++++++++++-- .../Sources/NotificationParserLegacy.swift | 4 +++- 3 files changed, 34 insertions(+), 5 deletions(-) diff --git a/Sources/App/Notifications/NotificationManager.swift b/Sources/App/Notifications/NotificationManager.swift index 812f216124..cf166a8d0a 100644 --- a/Sources/App/Notifications/NotificationManager.swift +++ b/Sources/App/Notifications/NotificationManager.swift @@ -294,8 +294,16 @@ extension NotificationManager: UNUserNotificationCenterDelegate { // sees only the Live Activity (not a duplicate standard notification). if let hadict = notification.request.content.userInfo["homeassistant"] as? [String: Any], (hadict["command"] as? String) != nil || (hadict["live_activity"] as? Bool) == true { - commandManager.handle(notification.request.content.userInfo).cauterize() - completionHandler([]) + commandManager.handle(notification.request.content.userInfo).done { + completionHandler([]) + }.catch { error in + // Unknown command — fall through to normal banner presentation so the user isn't silently swallowed. + if case NotificationsCommandManager.CommandError.unknownCommand = error { + completionHandler([.badge, .sound, .list, .banner]) + } else { + completionHandler([]) + } + } return } diff --git a/Sources/Shared/LiveActivity/LiveActivityRegistry.swift b/Sources/Shared/LiveActivity/LiveActivityRegistry.swift index 5da9634ca0..3307180322 100644 --- a/Sources/Shared/LiveActivity/LiveActivityRegistry.swift +++ b/Sources/Shared/LiveActivity/LiveActivityRegistry.swift @@ -51,6 +51,10 @@ public actor LiveActivityRegistry: LiveActivityRegistryProtocol { /// Tags where `end()` arrived while still reserved — activity must be dismissed on confirm. private var cancelledReservations: Set = [] + /// 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] = [:] @@ -67,8 +71,10 @@ public actor LiveActivityRegistry: LiveActivityRegistryProtocol { } /// 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() @@ -76,11 +82,20 @@ public actor LiveActivityRegistry: LiveActivityRegistryProtocol { 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: Date().addingTimeInterval(kLiveActivityStaleInterval) + ) + 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? { @@ -122,7 +137,11 @@ public actor LiveActivityRegistry: LiveActivityRegistryProtocol { // START path — guard against duplicates with reservation guard reserve(id: tag) else { - Current.Log.info("LiveActivityRegistry: duplicate start for tag \(tag), ignoring") + 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 } @@ -162,7 +181,7 @@ public actor LiveActivityRegistry: LiveActivityRegistryProtocol { let alertConfig = AlertConfiguration( title: LocalizedStringResource(stringLiteral: title), body: LocalizedStringResource(stringLiteral: state.message), - sound: .default + sound: nil ) await activity.update(alertContent, alertConfiguration: alertConfig) diff --git a/Sources/SharedPush/Sources/NotificationParserLegacy.swift b/Sources/SharedPush/Sources/NotificationParserLegacy.swift index 8363d6dff7..aec8b6b4cf 100644 --- a/Sources/SharedPush/Sources/NotificationParserLegacy.swift +++ b/Sources/SharedPush/Sources/NotificationParserLegacy.swift @@ -230,7 +230,9 @@ public struct LegacyNotificationParserImpl: LegacyNotificationParser { if let title = input["title"] as? String { homeassistant["title"] = title } - homeassistant["message"] = input["message"] + if let message = input["message"] as? String { + homeassistant["message"] = message + } payload["homeassistant"] = homeassistant } From ed7e1f64b592a1bc3ce91ed65ebd6744ad4b98a2 Mon Sep 17 00:00:00 2001 From: rwarner Date: Wed, 25 Mar 2026 09:42:27 -0400 Subject: [PATCH 24/34] Fix Dynamic Island compact trailing clipping and stale date for timer activities - Use fixed frame(width: 44) for countdown timer in compact trailing slot instead of maxWidth: 50, preventing the Dynamic Island from squeezing the text narrower than M:SS requires - Add contentTransition(.numericText(countsDown: true)) to all three timer Text views (compact trailing, expanded bottom, lock screen) for smooth digit animation - Add computeStaleDate(for:) helper in LiveActivityRegistry: when chronometer is active, sets staleDate = countdownEnd + 2 s so the system marks the activity stale shortly after the timer ends rather than 30 min later; the +2 s offset also prevents the system spinner overlay that appears when staleDate == exactly countdownEnd - Fix iOS 26 SDK breaking change: AlertConfiguration.sound is now non-optional, changed sound: nil to .default - Handle new .pending ActivityState case added in iOS 26 - Fix NotificationCommandManager typo (was NotificationsCommandManager) Co-Authored-By: Claude Sonnet 4.6 --- HomeAssistant.xcodeproj/project.pbxproj | 54 +++++++------------ .../Notifications/NotificationManager.swift | 2 +- .../LiveActivity/HADynamicIslandView.swift | 10 +++- .../LiveActivity/HALockScreenView.swift | 1 + .../LiveActivity/LiveActivityRegistry.swift | 37 ++++++++++--- .../Shared/Resources/Swiftgen/Strings.swift | 2 +- 6 files changed, 61 insertions(+), 45 deletions(-) diff --git a/HomeAssistant.xcodeproj/project.pbxproj b/HomeAssistant.xcodeproj/project.pbxproj index e7b8259ca2..fb8f61384f 100644 --- a/HomeAssistant.xcodeproj/project.pbxproj +++ b/HomeAssistant.xcodeproj/project.pbxproj @@ -1238,7 +1238,7 @@ 618FCE5CA6B34267BB2056F5 /* MockLiveActivityRegistry.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5E95733B72864AB3B9607B57 /* MockLiveActivityRegistry.swift */; }; 65286F3B745551AD4090EE6B /* Pods-iOS-SharedTesting-metadata.plist in Resources */ = {isa = PBXBuildFile; fileRef = 4053903E4C54A6803204286E /* Pods-iOS-SharedTesting-metadata.plist */; }; 6596FA74E1A501276EA62D86 /* Pods_watchOS_Shared_watchOS.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = FD370D44DFFB906B05C3EB3A /* Pods_watchOS_Shared_watchOS.framework */; }; - 692BCBBA4EEEABCC76DBBECA /* Database/GRDB+Initialization.test.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C50FA39BF16AD0BD782D0D7 /* Database/GRDB+Initialization.test.swift */; }; + 692BCBBA4EEEABCC76DBBECA /* GRDB+Initialization.test.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C50FA39BF16AD0BD782D0D7 /* GRDB+Initialization.test.swift */; }; 6FCEBAA2C8E9C5403055E73D /* IntentFanEntity.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3E5E2F9F8F008EEA30C533FD /* IntentFanEntity.swift */; }; 70BD8A8EA1ABC5DC1F0A0D6E /* Pods_iOS_Shared_iOS.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = C663B0750E0318469E7008C3 /* Pods_iOS_Shared_iOS.framework */; }; 71E0BF803A854C3B9F0CB726 /* HandlerLiveActivityTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B58D524991C142DBB38A1968 /* HandlerLiveActivityTests.swift */; }; @@ -1247,7 +1247,7 @@ 897D7538631C46D4BD849CF5 /* NotificationsCommandManagerLiveActivityTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D790D5FA5DBB4B5B9DBB2334 /* NotificationsCommandManagerLiveActivityTests.swift */; }; 8E5FA96C740F1D671966CEA9 /* Pods-iOS-Extensions-NotificationContent-metadata.plist in Resources */ = {isa = PBXBuildFile; fileRef = B613440AEDD4209862503F5D /* Pods-iOS-Extensions-NotificationContent-metadata.plist */; }; 999549244371450BC98C700E /* Pods_iOS_Extensions_PushProvider.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 608CFDA223EBCDF01B946093 /* Pods_iOS_Extensions_PushProvider.framework */; }; - A2F3A140CDD1EF1AEA6DFAB9 /* Database/DatabaseTableProtocol.test.swift in Sources */ = {isa = PBXBuildFile; fileRef = BC31518EE9DC9E065AC508D9 /* Database/DatabaseTableProtocol.test.swift */; }; + A2F3A140CDD1EF1AEA6DFAB9 /* DatabaseTableProtocol.test.swift in Sources */ = {isa = PBXBuildFile; fileRef = BC31518EE9DC9E065AC508D9 /* DatabaseTableProtocol.test.swift */; }; A5A3C1932BE1F4A40EA78754 /* Pods-iOS-Extensions-Matter-metadata.plist in Resources */ = {isa = PBXBuildFile; fileRef = 392B0C44197C98E2653932A5 /* Pods-iOS-Extensions-Matter-metadata.plist */; }; A95FDD0C2F6B89C6008EF72F /* LiveActivityRegistry.swift in Sources */ = {isa = PBXBuildFile; fileRef = A95FDD0A2F6B89C6008EF72F /* LiveActivityRegistry.swift */; }; A95FDD0D2F6B89C6008EF72F /* HALiveActivityAttributes.swift in Sources */ = {isa = PBXBuildFile; fileRef = A95FDD092F6B89C6008EF72F /* HALiveActivityAttributes.swift */; }; @@ -1511,7 +1511,7 @@ B6E42613215C4333007FEB7E /* Shared.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = D03D891720E0A85200D4F28D /* Shared.framework */; }; BB77559927344584B2C0E987 /* OnboardingAuthError.test.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1A7DD090A1D41ADB9374E7A /* OnboardingAuthError.test.swift */; }; BD1044995DE13A04C0FA039A /* Pods_iOS_Extensions_Widgets.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 3D9C81015FD7A8FA8716E4F2 /* Pods_iOS_Extensions_Widgets.framework */; }; - BECCC152A4E3F69A8EF5A6F3 /* Database/TableSchemaTests.test.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9EE9A0E08E6FEBDDE425D0D4 /* Database/TableSchemaTests.test.swift */; }; + BECCC152A4E3F69A8EF5A6F3 /* TableSchemaTests.test.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9EE9A0E08E6FEBDDE425D0D4 /* TableSchemaTests.test.swift */; }; C10D762EFE08D347D0538339 /* Pods-iOS-Shared-iOS-Tests-Shared-metadata.plist in Resources */ = {isa = PBXBuildFile; fileRef = B2F5238669D8A7416FBD2B55 /* Pods-iOS-Shared-iOS-Tests-Shared-metadata.plist */; }; C35621B95F7E4548BC8F6D75 /* FolderEditView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6BECEB2525564358A124F818 /* FolderEditView.swift */; }; C574CE3276BCE901743FF8C9 /* KioskSettings.test.swift in Sources */ = {isa = PBXBuildFile; fileRef = EFD4B475DDA9447E45A9BAD3 /* KioskSettings.test.swift */; }; @@ -1558,7 +1558,7 @@ D46379541BA5FD96D6E7D328 /* KioskSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = 402432B9CC897C6278B08A79 /* KioskSettings.swift */; }; D8B4F2A61E9C73058AF2D49E /* KioskSettingsViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = C7A3E91F5B8D42A6E0F13B74 /* KioskSettingsViewModel.swift */; }; D9A6697AF4D05BB8DE822A54 /* Pods_iOS_Extensions_Share.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 33CA7FF55788E7084DA5E4B3 /* Pods_iOS_Extensions_Share.framework */; }; - DA6F4C18D66EDBA5DCEAE833 /* Database/DatabaseMigration.test.swift in Sources */ = {isa = PBXBuildFile; fileRef = 892F0EF22A0B9F20AAEE4CCA /* Database/DatabaseMigration.test.swift */; }; + DA6F4C18D66EDBA5DCEAE833 /* DatabaseMigration.test.swift in Sources */ = {isa = PBXBuildFile; fileRef = 892F0EF22A0B9F20AAEE4CCA /* DatabaseMigration.test.swift */; }; E92E09E3A93650D56E3C5093 /* KioskScreensaverViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 62CDCFDB29D283A7902A3ABE /* KioskScreensaverViewController.swift */; }; FC8E9421FDB864726918B612 /* Pods-watchOS-WatchExtension-Watch-metadata.plist in Resources */ = {isa = PBXBuildFile; fileRef = 9249824D575933DFA1530BB2 /* Pods-watchOS-WatchExtension-Watch-metadata.plist */; }; FD3BC66329B9FF8F00B19FBE /* CarPlaySceneDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD3BC66229B9FF8F00B19FBE /* CarPlaySceneDelegate.swift */; }; @@ -3045,7 +3045,7 @@ 50D9C22ED2834EC9DAAC63AC /* Pods-iOS-Extensions-Intents.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-iOS-Extensions-Intents.debug.xcconfig"; path = "Pods/Target Support Files/Pods-iOS-Extensions-Intents/Pods-iOS-Extensions-Intents.debug.xcconfig"; sourceTree = ""; }; 553A33E097387AA44265DB13 /* Pods-iOS-App-metadata.plist */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.xml; name = "Pods-iOS-App-metadata.plist"; path = "Pods/Pods-iOS-App-metadata.plist"; sourceTree = ""; }; 592EED7A6C2444872F11C17B /* Pods-iOS-Extensions-NotificationService-metadata.plist */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.xml; name = "Pods-iOS-Extensions-NotificationService-metadata.plist"; path = "Pods/Pods-iOS-Extensions-NotificationService-metadata.plist"; sourceTree = ""; }; - 5C50FA39BF16AD0BD782D0D7 /* Database/GRDB+Initialization.test.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Database/GRDB+Initialization.test.swift"; sourceTree = ""; }; + 5C50FA39BF16AD0BD782D0D7 /* GRDB+Initialization.test.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Database/GRDB+Initialization.test.swift"; sourceTree = ""; }; 5D4737412F241342009A70EA /* FolderDetailView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FolderDetailView.swift; sourceTree = ""; }; 5E95733B72864AB3B9607B57 /* MockLiveActivityRegistry.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockLiveActivityRegistry.swift; sourceTree = ""; }; 608CFDA223EBCDF01B946093 /* Pods_iOS_Extensions_PushProvider.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_iOS_Extensions_PushProvider.framework; sourceTree = BUILT_PRODUCTS_DIR; }; @@ -3065,7 +3065,7 @@ 7DC07BDAC69AD95BDEFD8AFF /* Pods-iOS-Extensions-NotificationService.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-iOS-Extensions-NotificationService.release.xcconfig"; path = "Pods/Target Support Files/Pods-iOS-Extensions-NotificationService/Pods-iOS-Extensions-NotificationService.release.xcconfig"; sourceTree = ""; }; 825E1E44BA9ABF1BF53733D3 /* KioskConstants.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KioskConstants.swift; sourceTree = ""; }; 86BFD63671D2D0A012DFE169 /* Pods-iOS-App.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-iOS-App.debug.xcconfig"; path = "Pods/Target Support Files/Pods-iOS-App/Pods-iOS-App.debug.xcconfig"; sourceTree = ""; }; - 892F0EF22A0B9F20AAEE4CCA /* Database/DatabaseMigration.test.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Database/DatabaseMigration.test.swift; sourceTree = ""; }; + 892F0EF22A0B9F20AAEE4CCA /* DatabaseMigration.test.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Database/DatabaseMigration.test.swift; sourceTree = ""; }; 8A34A5417D650BBBE9D2D7C0 /* ControlFanValueProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ControlFanValueProvider.swift; sourceTree = ""; }; 8D6888525DCF492642BA7EA3 /* FanIntent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FanIntent.swift; sourceTree = ""; }; 9249824D575933DFA1530BB2 /* Pods-watchOS-WatchExtension-Watch-metadata.plist */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.xml; name = "Pods-watchOS-WatchExtension-Watch-metadata.plist"; path = "Pods/Pods-watchOS-WatchExtension-Watch-metadata.plist"; sourceTree = ""; }; @@ -3077,7 +3077,7 @@ 9C4E5E27229D992A0044C8EC /* HomeAssistant.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; path = HomeAssistant.xcconfig; sourceTree = ""; }; 9D84964A844E6CD21F16D3AB /* Pods-watchOS-WatchExtension-Watch.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-watchOS-WatchExtension-Watch.debug.xcconfig"; path = "Pods/Target Support Files/Pods-watchOS-WatchExtension-Watch/Pods-watchOS-WatchExtension-Watch.debug.xcconfig"; sourceTree = ""; }; 9DA2D62699FC44A99AB37480 /* WatchFolderRow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WatchFolderRow.swift; sourceTree = ""; }; - 9EE9A0E08E6FEBDDE425D0D4 /* Database/TableSchemaTests.test.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Database/TableSchemaTests.test.swift; sourceTree = ""; }; + 9EE9A0E08E6FEBDDE425D0D4 /* TableSchemaTests.test.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Database/TableSchemaTests.test.swift; sourceTree = ""; }; 9F9398CFD66E4C66DC39E1D3 /* Pods-iOS-Extensions-PushProvider.beta.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-iOS-Extensions-PushProvider.beta.xcconfig"; path = "Pods/Target Support Files/Pods-iOS-Extensions-PushProvider/Pods-iOS-Extensions-PushProvider.beta.xcconfig"; sourceTree = ""; }; A1A7DD090A1D41ADB9374E7A /* OnboardingAuthError.test.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OnboardingAuthError.test.swift; sourceTree = ""; }; A95FDD092F6B89C6008EF72F /* HALiveActivityAttributes.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HALiveActivityAttributes.swift; sourceTree = ""; }; @@ -3407,7 +3407,7 @@ B6FD0573228411B200AC45BA /* ru */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ru; path = ru.lproj/InfoPlist.strings; sourceTree = ""; }; B6FD0574228411B200AC45BA /* ru */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ru; path = ru.lproj/Localizable.strings; sourceTree = ""; }; B7D8DAEFAD435091FDDD61E7 /* Pods_iOS_Extensions_NotificationContent.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_iOS_Extensions_NotificationContent.framework; sourceTree = BUILT_PRODUCTS_DIR; }; - BC31518EE9DC9E065AC508D9 /* Database/DatabaseTableProtocol.test.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Database/DatabaseTableProtocol.test.swift; sourceTree = ""; }; + BC31518EE9DC9E065AC508D9 /* DatabaseTableProtocol.test.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Database/DatabaseTableProtocol.test.swift; sourceTree = ""; }; BC9B77AAC44845DC9EE48759 /* Pods_iOS_Extensions_Intents.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_iOS_Extensions_Intents.framework; sourceTree = BUILT_PRODUCTS_DIR; }; BEF9A7008EFA4A6FC9E02B5E /* Pods-iOS-Extensions-Intents.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-iOS-Extensions-Intents.release.xcconfig"; path = "Pods/Target Support Files/Pods-iOS-Extensions-Intents/Pods-iOS-Extensions-Intents.release.xcconfig"; sourceTree = ""; }; C00AE2FDC80CA2FFDFCA2B2B /* KioskModeManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KioskModeManager.swift; sourceTree = ""; }; @@ -7226,10 +7226,10 @@ 11CB98CC249E637300B05222 /* Version+HA.test.swift */, 11883CC424C12C8A0036A6C6 /* CLLocation+Extensions.test.swift */, 11883CC624C131EE0036A6C6 /* RealmZone.test.swift */, - 892F0EF22A0B9F20AAEE4CCA /* Database/DatabaseMigration.test.swift */, - BC31518EE9DC9E065AC508D9 /* Database/DatabaseTableProtocol.test.swift */, - 5C50FA39BF16AD0BD782D0D7 /* Database/GRDB+Initialization.test.swift */, - 9EE9A0E08E6FEBDDE425D0D4 /* Database/TableSchemaTests.test.swift */, + 892F0EF22A0B9F20AAEE4CCA /* DatabaseMigration.test.swift */, + BC31518EE9DC9E065AC508D9 /* DatabaseTableProtocol.test.swift */, + 5C50FA39BF16AD0BD782D0D7 /* GRDB+Initialization.test.swift */, + 9EE9A0E08E6FEBDDE425D0D4 /* TableSchemaTests.test.swift */, 11EE9B4B24C5181A00404AF8 /* ModelManager.test.swift */, 11BC9E5424FDB88200B9FBF7 /* ActiveStateManager.test.swift */, 1104FCCE253275CF00B8BE34 /* WatchBackgroundRefreshScheduler.test.swift */, @@ -8089,7 +8089,7 @@ packageReferences = ( 420E64BB2D676B2400A31E86 /* XCRemoteSwiftPackageReference "swift-snapshot-testing" */, 42B89EA62E05CC54000224A2 /* XCRemoteSwiftPackageReference "WebRTC" */, - 42E00D0F2E1E7487006D140D /* XCLocalSwiftPackageReference "Sources/SharedPush" */, + 42E00D0F2E1E7487006D140D /* XCLocalSwiftPackageReference "SharedPush" */, 4237E6372E5333370023B673 /* XCRemoteSwiftPackageReference "ZIPFoundation" */, 42B18FD52F38CA2300A1537A /* XCRemoteSwiftPackageReference "firebase-ios-sdk" */, 42E5E2E42F38CEA30030BBEB /* XCRemoteSwiftPackageReference "SwiftMessages" */, @@ -8619,14 +8619,10 @@ inputFileListPaths = ( "${PODS_ROOT}/Target Support Files/Pods-Tests-App/Pods-Tests-App-frameworks-${CONFIGURATION}-input-files.xcfilelist", ); - inputPaths = ( - ); name = "[CP] Embed Pods Frameworks"; outputFileListPaths = ( "${PODS_ROOT}/Target Support Files/Pods-Tests-App/Pods-Tests-App-frameworks-${CONFIGURATION}-output-files.xcfilelist", ); - outputPaths = ( - ); runOnlyForDeploymentPostprocessing = 0; shellPath = /bin/sh; shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Tests-App/Pods-Tests-App-frameworks.sh\"\n"; @@ -8764,14 +8760,10 @@ inputFileListPaths = ( "${PODS_ROOT}/Target Support Files/Pods-iOS-App/Pods-iOS-App-frameworks-${CONFIGURATION}-input-files.xcfilelist", ); - inputPaths = ( - ); name = "[CP] Embed Pods Frameworks"; outputFileListPaths = ( "${PODS_ROOT}/Target Support Files/Pods-iOS-App/Pods-iOS-App-frameworks-${CONFIGURATION}-output-files.xcfilelist", ); - outputPaths = ( - ); runOnlyForDeploymentPostprocessing = 0; shellPath = /bin/sh; shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-iOS-App/Pods-iOS-App-frameworks.sh\"\n"; @@ -8807,14 +8799,10 @@ inputFileListPaths = ( "${PODS_ROOT}/Target Support Files/Pods-iOS-Shared-iOS-Tests-Shared/Pods-iOS-Shared-iOS-Tests-Shared-frameworks-${CONFIGURATION}-input-files.xcfilelist", ); - inputPaths = ( - ); name = "[CP] Embed Pods Frameworks"; outputFileListPaths = ( "${PODS_ROOT}/Target Support Files/Pods-iOS-Shared-iOS-Tests-Shared/Pods-iOS-Shared-iOS-Tests-Shared-frameworks-${CONFIGURATION}-output-files.xcfilelist", ); - outputPaths = ( - ); runOnlyForDeploymentPostprocessing = 0; shellPath = /bin/sh; shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-iOS-Shared-iOS-Tests-Shared/Pods-iOS-Shared-iOS-Tests-Shared-frameworks.sh\"\n"; @@ -8914,14 +8902,10 @@ inputFileListPaths = ( "${PODS_ROOT}/Target Support Files/Pods-watchOS-WatchExtension-Watch/Pods-watchOS-WatchExtension-Watch-frameworks-${CONFIGURATION}-input-files.xcfilelist", ); - inputPaths = ( - ); name = "[CP] Embed Pods Frameworks"; outputFileListPaths = ( "${PODS_ROOT}/Target Support Files/Pods-watchOS-WatchExtension-Watch/Pods-watchOS-WatchExtension-Watch-frameworks-${CONFIGURATION}-output-files.xcfilelist", ); - outputPaths = ( - ); runOnlyForDeploymentPostprocessing = 0; shellPath = /bin/sh; shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-watchOS-WatchExtension-Watch/Pods-watchOS-WatchExtension-Watch-frameworks.sh\"\n"; @@ -10342,10 +10326,10 @@ 11AF4D2C249D965C006C74C0 /* BatterySensor.test.swift in Sources */, 11F2F2B8258728B200F61F7C /* NotificationAttachmentParserURL.test.swift in Sources */, 11883CC724C131EE0036A6C6 /* RealmZone.test.swift in Sources */, - DA6F4C18D66EDBA5DCEAE833 /* Database/DatabaseMigration.test.swift in Sources */, - A2F3A140CDD1EF1AEA6DFAB9 /* Database/DatabaseTableProtocol.test.swift in Sources */, - 692BCBBA4EEEABCC76DBBECA /* Database/GRDB+Initialization.test.swift in Sources */, - BECCC152A4E3F69A8EF5A6F3 /* Database/TableSchemaTests.test.swift in Sources */, + DA6F4C18D66EDBA5DCEAE833 /* DatabaseMigration.test.swift in Sources */, + A2F3A140CDD1EF1AEA6DFAB9 /* DatabaseTableProtocol.test.swift in Sources */, + 692BCBBA4EEEABCC76DBBECA /* GRDB+Initialization.test.swift in Sources */, + BECCC152A4E3F69A8EF5A6F3 /* TableSchemaTests.test.swift in Sources */, 11267D0925BBA9FE00F28E5C /* Updater.test.swift in Sources */, 11A3F08C24ECE88C0018D84F /* WebhookUpdateLocation.test.swift in Sources */, 42FDCA272F0C7EB900C92958 /* EntityRegistry.test.swift in Sources */, @@ -12230,7 +12214,7 @@ /* End XCConfigurationList section */ /* Begin XCLocalSwiftPackageReference section */ - 42E00D0F2E1E7487006D140D /* XCLocalSwiftPackageReference "Sources/SharedPush" */ = { + 42E00D0F2E1E7487006D140D /* XCLocalSwiftPackageReference "SharedPush" */ = { isa = XCLocalSwiftPackageReference; relativePath = Sources/SharedPush; }; @@ -12302,7 +12286,7 @@ }; 4273F7DF2E258827000629F7 /* SharedPush */ = { isa = XCSwiftPackageProductDependency; - package = 42E00D0F2E1E7487006D140D /* XCLocalSwiftPackageReference "Sources/SharedPush" */; + package = 42E00D0F2E1E7487006D140D /* XCLocalSwiftPackageReference "SharedPush" */; productName = SharedPush; }; 427692E22B98B82500F24321 /* SharedPush */ = { diff --git a/Sources/App/Notifications/NotificationManager.swift b/Sources/App/Notifications/NotificationManager.swift index cf166a8d0a..80f845482c 100644 --- a/Sources/App/Notifications/NotificationManager.swift +++ b/Sources/App/Notifications/NotificationManager.swift @@ -298,7 +298,7 @@ extension NotificationManager: UNUserNotificationCenterDelegate { completionHandler([]) }.catch { error in // Unknown command — fall through to normal banner presentation so the user isn't silently swallowed. - if case NotificationsCommandManager.CommandError.unknownCommand = error { + if case NotificationCommandManager.CommandError.unknownCommand = error { completionHandler([.badge, .sound, .list, .banner]) } else { completionHandler([]) diff --git a/Sources/Extensions/Widgets/LiveActivity/HADynamicIslandView.swift b/Sources/Extensions/Widgets/LiveActivity/HADynamicIslandView.swift index d55041e15b..3d0bcbabd8 100644 --- a/Sources/Extensions/Widgets/LiveActivity/HADynamicIslandView.swift +++ b/Sources/Extensions/Widgets/LiveActivity/HADynamicIslandView.swift @@ -75,7 +75,11 @@ struct HADynamicIslandIconView: View { struct HACompactTrailingView: View { let state: HALiveActivityAttributes.ContentState - /// Maximum width for compact trailing text to prevent overflow in the Dynamic Island. + /// Fixed width for the countdown timer text in compact trailing. + /// 44 pt fits "M:SS" at caption2 size and prevents the Dynamic Island from + /// squeezing the slot narrower than the text needs. + private static let compactTrailingTimerWidth: CGFloat = 44 + /// Maximum width for non-timer compact trailing content (criticalText, progress %). private static let compactTrailingMaxWidth: CGFloat = 50 var body: some View { @@ -84,7 +88,8 @@ struct HACompactTrailingView: View { .font(.caption2) .foregroundStyle(.white) .monospacedDigit() - .frame(maxWidth: Self.compactTrailingMaxWidth) + .contentTransition(.numericText(countsDown: true)) + .frame(width: Self.compactTrailingTimerWidth) } else if let critical = state.criticalText { Text(critical) .font(.caption2) @@ -133,6 +138,7 @@ struct HAExpandedBottomView: View { Text(timerInterval: Date.now ... end, countsDown: true) .font(.body.monospacedDigit()) .foregroundStyle(.white) + .contentTransition(.numericText(countsDown: true)) } else { Text(state.message) .font(.subheadline) diff --git a/Sources/Extensions/Widgets/LiveActivity/HALockScreenView.swift b/Sources/Extensions/Widgets/LiveActivity/HALockScreenView.swift index 7b1fd5b0b5..2187948e9c 100644 --- a/Sources/Extensions/Widgets/LiveActivity/HALockScreenView.swift +++ b/Sources/Extensions/Widgets/LiveActivity/HALockScreenView.swift @@ -38,6 +38,7 @@ struct HALockScreenView: View { .font(.subheadline) .foregroundStyle(Self.secondaryWhite) .monospacedDigit() + .contentTransition(.numericText(countsDown: true)) } else { Text(state.message) .font(.subheadline) diff --git a/Sources/Shared/LiveActivity/LiveActivityRegistry.swift b/Sources/Shared/LiveActivity/LiveActivityRegistry.swift index 3307180322..6fc0f63323 100644 --- a/Sources/Shared/LiveActivity/LiveActivityRegistry.swift +++ b/Sources/Shared/LiveActivity/LiveActivityRegistry.swift @@ -86,7 +86,7 @@ public actor LiveActivityRegistry: LiveActivityRegistryProtocol { // A second push arrived while Activity.request() was in-flight — apply the newer state now. let content = ActivityContent( state: latestState, - staleDate: Date().addingTimeInterval(kLiveActivityStaleInterval) + staleDate: computeStaleDate(for: latestState) ) await entry.activity.update(content) } @@ -116,7 +116,7 @@ public actor LiveActivityRegistry: LiveActivityRegistryProtocol { if let existing = entries[tag] { let content = ActivityContent( state: state, - staleDate: Date().addingTimeInterval(kLiveActivityStaleInterval) + staleDate: computeStaleDate(for: state) ) await existing.activity.update(content) return @@ -127,7 +127,7 @@ public actor LiveActivityRegistry: LiveActivityRegistryProtocol { .first(where: { $0.attributes.tag == tag }) { let content = ActivityContent( state: state, - staleDate: Date().addingTimeInterval(kLiveActivityStaleInterval) + staleDate: computeStaleDate(for: state) ) await live.update(content) let observationTask = makeObservationTask(for: live) @@ -157,7 +157,7 @@ public actor LiveActivityRegistry: LiveActivityRegistryProtocol { do { let content = ActivityContent( state: state, - staleDate: Date().addingTimeInterval(kLiveActivityStaleInterval), + staleDate: computeStaleDate(for: state), relevanceScore: 0.5 ) activity = try Activity.request( @@ -175,13 +175,15 @@ public actor LiveActivityRegistry: LiveActivityRegistryProtocol { // camera cutout). The expanded "bloom" animation requires an update with an alert config. let alertContent = ActivityContent( state: state, - staleDate: Date().addingTimeInterval(kLiveActivityStaleInterval), + 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: nil + sound: .default ) await activity.update(alertContent, alertConfiguration: alertConfig) @@ -262,6 +264,26 @@ public actor LiveActivityRegistry: LiveActivityRegistryProtocol { 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) -> Task { @@ -292,6 +314,9 @@ public actor LiveActivityRegistry: LiveActivityRegistryProtocol { return case .active, .stale: break + case .pending: + // Activity has been requested but not yet displayed — no action needed. + break @unknown default: break } diff --git a/Sources/Shared/Resources/Swiftgen/Strings.swift b/Sources/Shared/Resources/Swiftgen/Strings.swift index 8459f4cf01..2b7da07c3c 100644 --- a/Sources/Shared/Resources/Swiftgen/Strings.swift +++ b/Sources/Shared/Resources/Swiftgen/Strings.swift @@ -1864,7 +1864,7 @@ public enum L10n { } } public enum FrequentUpdates { - /// Allows Home Assistant to update Live Activities up to once per second. Enable in Settings › %@ › Live Activities. + /// Allows Home Assistant to update Live Activities up to once per second. Enable in Settings u203A %@ u203A Live Activities. public static func footer(_ p1: Any) -> String { return L10n.tr("Localizable", "live_activity.frequent_updates.footer", String(describing: p1)) } From 5ce41a70c76055c09533dbc6358b398dc1127f69 Mon Sep 17 00:00:00 2001 From: rwarner Date: Thu, 26 Mar 2026 09:09:37 -0400 Subject: [PATCH 25/34] Fix SwiftFormat lint errors in live activity files Co-Authored-By: Claude Sonnet 4.6 --- Sources/Shared/Environment/Environment.swift | 1 - Sources/Shared/LiveActivity/LiveActivityRegistry.swift | 4 +++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/Sources/Shared/Environment/Environment.swift b/Sources/Shared/Environment/Environment.swift index ddcd7c867c..99d14c3250 100644 --- a/Sources/Shared/Environment/Environment.swift +++ b/Sources/Shared/Environment/Environment.swift @@ -299,7 +299,6 @@ public class AppEnvironment { isTestFlight } - #if os(iOS) public var isAppExtension = AppConstants.BundleID != Bundle.main.bundleIdentifier #elseif os(watchOS) diff --git a/Sources/Shared/LiveActivity/LiveActivityRegistry.swift b/Sources/Shared/LiveActivity/LiveActivityRegistry.swift index 6fc0f63323..af0109ccab 100644 --- a/Sources/Shared/LiveActivity/LiveActivityRegistry.swift +++ b/Sources/Shared/LiveActivity/LiveActivityRegistry.swift @@ -140,7 +140,9 @@ public actor LiveActivityRegistry: LiveActivityRegistryProtocol { 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") + Current.Log.info( + "LiveActivityRegistry: duplicate start for tag \(tag), will apply latest state on confirm" + ) } return } From ff658536aef2b029f076063f86e54c69493e2d81 Mon Sep 17 00:00:00 2001 From: rwarner Date: Thu, 26 Mar 2026 10:09:00 -0400 Subject: [PATCH 26/34] Add beta label and TestFlight gate to Live Activities settings entry - Show BetaLabel next to Live Activities in settings (same as Kiosk) - Gate Live Activities settings entry behind Current.isTestFlight to prevent it surfacing in a release build before fully tested Co-Authored-By: Claude Sonnet 4.6 --- Sources/App/Settings/Settings/SettingsItem.swift | 6 +++--- Sources/App/Settings/Settings/SettingsView.swift | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/Sources/App/Settings/Settings/SettingsItem.swift b/Sources/App/Settings/Settings/SettingsItem.swift index 8649814292..cd970b6f45 100644 --- a/Sources/App/Settings/Settings/SettingsItem.swift +++ b/Sources/App/Settings/Settings/SettingsItem.swift @@ -151,9 +151,9 @@ enum SettingsItem: String, Hashable, CaseIterable { return false } #endif - // Live Activities require iOS 16.2+ (ActivityContent API) + // Live Activities require iOS 16.2+ (ActivityContent API) and TestFlight if item == .liveActivities { - if #available(iOS 16.2, *) { return true } + if #available(iOS 16.2, *) { return Current.isTestFlight } return false } return true @@ -162,7 +162,7 @@ enum SettingsItem: String, Hashable, CaseIterable { static var generalItems: [SettingsItem] { var items: [SettingsItem] = [.general, .gestures, .kiosk, .location, .notifications] - if #available(iOS 16.2, *) { + if #available(iOS 16.2, *), Current.isTestFlight { items.append(.liveActivities) } return items diff --git a/Sources/App/Settings/Settings/SettingsView.swift b/Sources/App/Settings/Settings/SettingsView.swift index fb1b9bb6b8..203ba652ed 100644 --- a/Sources/App/Settings/Settings/SettingsView.swift +++ b/Sources/App/Settings/Settings/SettingsView.swift @@ -246,7 +246,7 @@ struct SettingsView: View { Label { HStack(spacing: DesignSystem.Spaces.one) { Text(item.title) - if item == .kiosk { + if item == .kiosk || item == .liveActivities { BetaLabel() } } From f22b862c179637c8afcbb1c551ad047d84bfc4c0 Mon Sep 17 00:00:00 2001 From: rwarner Date: Thu, 26 Mar 2026 10:26:53 -0400 Subject: [PATCH 27/34] Use live_update: true instead of live_activity: true for iOS Live Activities Unifies the iOS and Android notification data field: live_update: true now triggers a Live Activity on iOS, matching the field Android already uses for Live Updates. A single YAML automation now targets both platforms with no platform-specific keys. Internal command strings, webhook types, and keychain keys are unchanged. Co-Authored-By: Claude Sonnet 4.6 --- Sources/App/Notifications/NotificationManager.swift | 2 +- .../NotificationCommands/HandlerLiveActivity.swift | 4 ++-- .../NotificationCommands/NotificationsCommandManager.swift | 7 +++---- Sources/SharedPush/Sources/NotificationParserLegacy.swift | 6 +++--- Tests/Shared/LiveActivity/LiveActivityContractTests.swift | 6 +++--- 5 files changed, 12 insertions(+), 13 deletions(-) diff --git a/Sources/App/Notifications/NotificationManager.swift b/Sources/App/Notifications/NotificationManager.swift index 80f845482c..c5a0101272 100644 --- a/Sources/App/Notifications/NotificationManager.swift +++ b/Sources/App/Notifications/NotificationManager.swift @@ -293,7 +293,7 @@ extension NotificationManager: UNUserNotificationCenterDelegate { // If a command is recognized, suppress the notification banner so the user // sees only the Live Activity (not a duplicate standard notification). if let hadict = notification.request.content.userInfo["homeassistant"] as? [String: Any], - (hadict["command"] as? String) != nil || (hadict["live_activity"] as? Bool) == true { + (hadict["command"] as? String) != nil || (hadict["live_update"] as? Bool) == true { commandManager.handle(notification.request.content.userInfo).done { completionHandler([]) }.catch { error in diff --git a/Sources/Shared/Notifications/NotificationCommands/HandlerLiveActivity.swift b/Sources/Shared/Notifications/NotificationCommands/HandlerLiveActivity.swift index b8f7663432..93768745e1 100644 --- a/Sources/Shared/Notifications/NotificationCommands/HandlerLiveActivity.swift +++ b/Sources/Shared/Notifications/NotificationCommands/HandlerLiveActivity.swift @@ -5,11 +5,11 @@ import PromiseKit // MARK: - HandlerStartOrUpdateLiveActivity -/// Handles `live_activity: true` notifications by starting or updating a Live Activity. +/// 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_activity == true` (data.live_activity: true 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, diff --git a/Sources/Shared/Notifications/NotificationCommands/NotificationsCommandManager.swift b/Sources/Shared/Notifications/NotificationCommands/NotificationsCommandManager.swift index bd0bbce086..74c0720201 100644 --- a/Sources/Shared/Notifications/NotificationCommands/NotificationsCommandManager.swift +++ b/Sources/Shared/Notifications/NotificationCommands/NotificationsCommandManager.swift @@ -46,11 +46,10 @@ public class NotificationCommandManager { return .init(error: CommandError.notCommand) } - // Support data.live_activity: true as an alternative to message: live_activity. - // This allows the notification body to be a real message instead of a command keyword, - // matching Android's data.live_update: true pattern. + // Support data.live_update: true — the same field Android uses for Live Updates. + // A single YAML automation can target both platforms with no platform-specific keys. #if canImport(ActivityKit) - if #available(iOS 16.2, *), hadict["live_activity"] as? Bool == true, + if #available(iOS 16.2, *), hadict["live_update"] as? Bool == true, let handler = commands["live_activity"] { return handler.handle(hadict) } diff --git a/Sources/SharedPush/Sources/NotificationParserLegacy.swift b/Sources/SharedPush/Sources/NotificationParserLegacy.swift index aec8b6b4cf..58be8ff010 100644 --- a/Sources/SharedPush/Sources/NotificationParserLegacy.swift +++ b/Sources/SharedPush/Sources/NotificationParserLegacy.swift @@ -212,13 +212,13 @@ public struct LegacyNotificationParserImpl: LegacyNotificationParser { headers["apns-collapse-id"] = tag } - // Promote live_activity fields from `data` into `homeassistant` so that + // Promote live_update fields from `data` into `homeassistant` so that // NotificationCommandManager can route to HandlerStartOrUpdateLiveActivity. // This handles the WebSocket (local push) delivery path where the parser // produces a flat payload — unlike APNs which already has a `homeassistant` key. - if data["live_activity"] as? Bool == true { + if data["live_update"] as? Bool == true { var homeassistant = payload["homeassistant"] as? [String: Any] ?? [:] - homeassistant["live_activity"] = true + homeassistant["live_update"] = true for key in [ "tag", "critical_text", "progress", "progress_max", "chronometer", "when", "when_relative", "notification_icon", "notification_icon_color", diff --git a/Tests/Shared/LiveActivity/LiveActivityContractTests.swift b/Tests/Shared/LiveActivity/LiveActivityContractTests.swift index 7d93cc5e95..8018bf7d97 100644 --- a/Tests/Shared/LiveActivity/LiveActivityContractTests.swift +++ b/Tests/Shared/LiveActivity/LiveActivityContractTests.swift @@ -149,12 +149,12 @@ final class LiveActivityContractTests: XCTestCase { XCTAssertNoThrow(try hang(manager.handle(endPayload))) } - /// The `live_activity: true` data flag (Android-compat pattern) must be recognized. - func testLiveActivityDataFlag_isRecognized() { + /// The `live_update: true` data flag must be recognized (same field as Android Live Updates). + func testLiveUpdateDataFlag_isRecognized() { let manager = NotificationCommandManager() let payload: [AnyHashable: Any] = [ "homeassistant": [ - "live_activity": true, + "live_update": true, "tag": "test", "title": "Test", "message": "Hello", From 10878eecd139eb2f6865b51be5920bf6b9e352b8 Mon Sep 17 00:00:00 2001 From: rwarner Date: Thu, 26 Mar 2026 10:54:14 -0400 Subject: [PATCH 28/34] Raise Live Activities minimum to iOS 17.2 Replaces all iOS 16.2 availability checks with iOS 17.2, flattens the nested 16.2/17.2 blocks in AppDelegate and HAAPI into a single 17.2 check, and removes now-redundant @available(iOS 17.2, *) method annotations from the protocol and actor (the outer type annotation is sufficient). Co-Authored-By: Claude Sonnet 4.6 --- Sources/App/AppDelegate.swift | 12 +++++------- .../LiveActivity/LiveActivitySettingsView.swift | 10 +++++----- Sources/App/Settings/Settings/SettingsItem.swift | 8 ++++---- .../Widgets/LiveActivity/HADynamicIslandView.swift | 10 +++++----- .../LiveActivity/HALiveActivityConfiguration.swift | 2 +- .../Widgets/LiveActivity/HALockScreenView.swift | 2 +- Sources/Extensions/Widgets/Widgets.swift | 2 +- Sources/Shared/API/HAAPI.swift | 7 ++----- Sources/Shared/Environment/Environment.swift | 2 +- .../LiveActivity/HALiveActivityAttributes.swift | 2 +- .../Shared/LiveActivity/LiveActivityRegistry.swift | 7 ++----- .../NotificationCommands/HandlerLiveActivity.swift | 4 ++-- .../NotificationsCommandManager.swift | 6 +++--- .../LiveActivity/HandlerLiveActivityTests.swift | 4 ++-- .../LiveActivity/LiveActivityContractTests.swift | 2 +- .../LiveActivity/MockLiveActivityRegistry.swift | 5 ++--- ...otificationsCommandManagerLiveActivityTests.swift | 2 +- 17 files changed, 39 insertions(+), 48 deletions(-) diff --git a/Sources/App/AppDelegate.swift b/Sources/App/AppDelegate.swift index 6741650f1e..3a47dd3a86 100644 --- a/Sources/App/AppDelegate.swift +++ b/Sources/App/AppDelegate.swift @@ -375,7 +375,7 @@ class AppDelegate: UIResponder, UIApplicationDelegate { private func setupLiveActivityReattachment() { #if canImport(ActivityKit) - if #available(iOS 16.2, *) { + if #available(iOS 17.2, *) { // Pre-warm the registry on the main thread before spawning background Tasks. // This avoids a lazy-init race if a push notification handler accesses it // concurrently from a background thread. @@ -388,12 +388,10 @@ class AppDelegate: UIResponder, UIApplicationDelegate { await registry.reattach() } - if #available(iOS 17.2, *) { - // Begin observing the push-to-start token stream on a separate Task. - // The stream is infinite; this Task is kept alive for the app's lifetime. - Task { - await registry.startObservingPushToStartToken() - } + // Begin observing the push-to-start token stream on a separate Task. + // The stream is infinite; this Task is kept alive for the app's lifetime. + Task { + await registry.startObservingPushToStartToken() } } #endif diff --git a/Sources/App/Settings/LiveActivity/LiveActivitySettingsView.swift b/Sources/App/Settings/LiveActivity/LiveActivitySettingsView.swift index f66e5cd0e4..891fd80701 100644 --- a/Sources/App/Settings/LiveActivity/LiveActivitySettingsView.swift +++ b/Sources/App/Settings/LiveActivity/LiveActivitySettingsView.swift @@ -4,9 +4,9 @@ import SwiftUI // MARK: - Entry point -/// Deployment target is iOS 15. The settings item is filtered from the list on < iOS 16.2 -/// (see SettingsItem.allVisibleCases), so this view is only ever navigated to on iOS 16.2+. -@available(iOS 16.2, *) +/// Deployment target is iOS 15. The settings item is filtered from the list on < iOS 17.2 +/// (see SettingsItem.allVisibleCases), so this view is only ever navigated to on iOS 17.2+. +@available(iOS 17.2, *) struct LiveActivitySettingsView: View { // MARK: State @@ -640,7 +640,7 @@ struct LiveActivitySettingsView: View { // MARK: - Activity row -@available(iOS 16.2, *) +@available(iOS 17.2, *) private struct ActivityRow: View { let snapshot: ActivitySnapshot let onEnd: () -> Void @@ -671,7 +671,7 @@ private struct ActivityRow: View { // MARK: - Snapshot model -@available(iOS 16.2, *) +@available(iOS 17.2, *) private struct ActivitySnapshot: Identifiable { let id: String let tag: String diff --git a/Sources/App/Settings/Settings/SettingsItem.swift b/Sources/App/Settings/Settings/SettingsItem.swift index cd970b6f45..bb352dd3d8 100644 --- a/Sources/App/Settings/Settings/SettingsItem.swift +++ b/Sources/App/Settings/Settings/SettingsItem.swift @@ -112,7 +112,7 @@ enum SettingsItem: String, Hashable, CaseIterable { case .notifications: SettingsNotificationsView() case .liveActivities: - if #available(iOS 16.2, *) { + if #available(iOS 17.2, *) { LiveActivitySettingsView() } case .sensors: @@ -151,9 +151,9 @@ enum SettingsItem: String, Hashable, CaseIterable { return false } #endif - // Live Activities require iOS 16.2+ (ActivityContent API) and TestFlight + // Live Activities require iOS 17.2+ and TestFlight if item == .liveActivities { - if #available(iOS 16.2, *) { return Current.isTestFlight } + if #available(iOS 17.2, *) { return Current.isTestFlight } return false } return true @@ -162,7 +162,7 @@ enum SettingsItem: String, Hashable, CaseIterable { static var generalItems: [SettingsItem] { var items: [SettingsItem] = [.general, .gestures, .kiosk, .location, .notifications] - if #available(iOS 16.2, *), Current.isTestFlight { + if #available(iOS 17.2, *), Current.isTestFlight { items.append(.liveActivities) } return items diff --git a/Sources/Extensions/Widgets/LiveActivity/HADynamicIslandView.swift b/Sources/Extensions/Widgets/LiveActivity/HADynamicIslandView.swift index 3d0bcbabd8..469bfecfbd 100644 --- a/Sources/Extensions/Widgets/LiveActivity/HADynamicIslandView.swift +++ b/Sources/Extensions/Widgets/LiveActivity/HADynamicIslandView.swift @@ -7,7 +7,7 @@ import WidgetKit /// Builds the `DynamicIsland` for a Home Assistant Live Activity. /// Used in `HALiveActivityConfiguration`'s `dynamicIsland:` closure. -@available(iOS 16.2, *) +@available(iOS 17.2, *) func makeHADynamicIsland( attributes: HALiveActivityAttributes, state: HALiveActivityAttributes.ContentState @@ -45,7 +45,7 @@ func makeHADynamicIsland( // MARK: - Icon view -@available(iOS 16.2, *) +@available(iOS 17.2, *) struct HADynamicIslandIconView: View { let slug: String? let color: String? @@ -71,7 +71,7 @@ struct HADynamicIslandIconView: View { // MARK: - Compact trailing -@available(iOS 16.2, *) +@available(iOS 17.2, *) struct HACompactTrailingView: View { let state: HALiveActivityAttributes.ContentState @@ -107,7 +107,7 @@ struct HACompactTrailingView: View { // MARK: - Expanded trailing -@available(iOS 16.2, *) +@available(iOS 17.2, *) struct HAExpandedTrailingView: View { let state: HALiveActivityAttributes.ContentState @@ -128,7 +128,7 @@ struct HAExpandedTrailingView: View { // MARK: - Expanded bottom -@available(iOS 16.2, *) +@available(iOS 17.2, *) struct HAExpandedBottomView: View { let state: HALiveActivityAttributes.ContentState diff --git a/Sources/Extensions/Widgets/LiveActivity/HALiveActivityConfiguration.swift b/Sources/Extensions/Widgets/LiveActivity/HALiveActivityConfiguration.swift index 3dd10f7a32..fc75a5d2c2 100644 --- a/Sources/Extensions/Widgets/LiveActivity/HALiveActivityConfiguration.swift +++ b/Sources/Extensions/Widgets/LiveActivity/HALiveActivityConfiguration.swift @@ -3,7 +3,7 @@ import Shared import SwiftUI import WidgetKit -@available(iOS 16.2, *) +@available(iOS 17.2, *) struct HALiveActivityConfiguration: Widget { /// Semi-transparent dark background for the Lock Screen presentation. private static let lockScreenBackground = Color.black.opacity(0.75) diff --git a/Sources/Extensions/Widgets/LiveActivity/HALockScreenView.swift b/Sources/Extensions/Widgets/LiveActivity/HALockScreenView.swift index 2187948e9c..72f3552c0a 100644 --- a/Sources/Extensions/Widgets/LiveActivity/HALockScreenView.swift +++ b/Sources/Extensions/Widgets/LiveActivity/HALockScreenView.swift @@ -7,7 +7,7 @@ import WidgetKit /// /// The system hard-truncates at 160 points height — padding counts against this limit. /// Keep layout tight and avoid decorative spacing. -@available(iOS 16.2, *) +@available(iOS 17.2, *) struct HALockScreenView: View { let attributes: HALiveActivityAttributes let state: HALiveActivityAttributes.ContentState diff --git a/Sources/Extensions/Widgets/Widgets.swift b/Sources/Extensions/Widgets/Widgets.swift index a3014eeabf..a86d168cf9 100644 --- a/Sources/Extensions/Widgets/Widgets.swift +++ b/Sources/Extensions/Widgets/Widgets.swift @@ -21,7 +21,7 @@ struct WidgetsBundleLegacy: WidgetBundle { } var body: some Widget { - if #available(iOSApplicationExtension 16.2, *) { + if #available(iOSApplicationExtension 17.2, *) { HALiveActivityConfiguration() } WidgetAssist() diff --git a/Sources/Shared/API/HAAPI.swift b/Sources/Shared/API/HAAPI.swift index acb004bb6d..da9dbf82b8 100644 --- a/Sources/Shared/API/HAAPI.swift +++ b/Sources/Shared/API/HAAPI.swift @@ -563,20 +563,17 @@ public class HomeAssistantAPI { ] #if os(iOS) && canImport(ActivityKit) - if #available(iOS 16.2, *) { + if #available(iOS 17.2, *) { // Advertise Live Activity support so HA can gate the UI and send // activity push tokens back to the relay server. // Use areActivitiesEnabled so iPad and users who disabled Live Activities // in Settings correctly report false. appData["supports_live_activities"] = ActivityAuthorizationInfo().areActivitiesEnabled - } - if #available(iOS 17.2, *) { appData["supports_live_activities_frequent_updates"] = ActivityAuthorizationInfo().frequentPushesEnabled // Push-to-start token (stored in Keychain at launch, updated via stream). - // The relay server uses this token to start a Live Activity entirely via - // APNs without the app being in the foreground (best-effort, iOS 17.2+). + // The relay server uses this token to start a Live Activity entirely via APNs. if let pushToStartToken = LiveActivityRegistry.storedPushToStartToken { appData["live_activity_push_to_start_token"] = pushToStartToken appData["live_activity_push_to_start_apns_environment"] = Current.apnsEnvironment diff --git a/Sources/Shared/Environment/Environment.swift b/Sources/Shared/Environment/Environment.swift index 99d14c3250..3316c2f857 100644 --- a/Sources/Shared/Environment/Environment.swift +++ b/Sources/Shared/Environment/Environment.swift @@ -148,7 +148,7 @@ public class AppEnvironment { /// background thread can access it) to avoid a lazy-init race between concurrent callers. private var _liveActivityRegistryBacking: Any? - @available(iOS 16.2, *) + @available(iOS 17.2, *) public var liveActivityRegistry: LiveActivityRegistryProtocol { get { if let existing = _liveActivityRegistryBacking as? LiveActivityRegistryProtocol { diff --git a/Sources/Shared/LiveActivity/HALiveActivityAttributes.swift b/Sources/Shared/LiveActivity/HALiveActivityAttributes.swift index af73b6841a..a82abfc5fb 100644 --- a/Sources/Shared/LiveActivity/HALiveActivityAttributes.swift +++ b/Sources/Shared/LiveActivity/HALiveActivityAttributes.swift @@ -10,7 +10,7 @@ import SwiftUI /// ⚠️ NEVER rename this struct or its fields post-ship. /// The `attributes-type` string in APNs push-to-start payloads must exactly match /// the Swift struct name (case-sensitive). Renaming breaks all in-flight activities. -@available(iOS 16.2, *) +@available(iOS 17.2, *) public struct HALiveActivityAttributes: ActivityAttributes { // MARK: - Static Attributes (set once at creation, cannot change) diff --git a/Sources/Shared/LiveActivity/LiveActivityRegistry.swift b/Sources/Shared/LiveActivity/LiveActivityRegistry.swift index af0109ccab..8a478f433d 100644 --- a/Sources/Shared/LiveActivity/LiveActivityRegistry.swift +++ b/Sources/Shared/LiveActivity/LiveActivityRegistry.swift @@ -6,12 +6,11 @@ import Foundation /// Activities are marked stale after 30 minutes if no further updates arrive. private let kLiveActivityStaleInterval: TimeInterval = 30 * 60 -@available(iOS 16.2, *) +@available(iOS 17.2, *) public protocol LiveActivityRegistryProtocol: AnyObject { func startOrUpdate(tag: String, title: String, state: HALiveActivityAttributes.ContentState) async throws func end(tag: String, dismissalPolicy: ActivityUIDismissalPolicy) async func reattach() async - @available(iOS 17.2, *) func startObservingPushToStartToken() async } @@ -22,7 +21,7 @@ public protocol LiveActivityRegistryProtocol: AnyObject { /// /// 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 16.2, *) +@available(iOS 17.2, *) public actor LiveActivityRegistry: LiveActivityRegistryProtocol { // MARK: - Types @@ -240,7 +239,6 @@ public actor LiveActivityRegistry: LiveActivityRegistryProtocol { /// 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. - @available(iOS 17.2, *) public func startObservingPushToStartToken() async { for await tokenData in Activity.pushToStartTokenUpdates { let tokenHex = tokenData.map { String(format: "%02x", $0) }.joined() @@ -365,7 +363,6 @@ public actor LiveActivityRegistry: LiveActivityRegistryProtocol { /// 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. - @available(iOS 17.2, *) private func reportPushToStartToken(_ tokenHex: String) { for api in Current.apis { api.updateRegistration().catch { error in diff --git a/Sources/Shared/Notifications/NotificationCommands/HandlerLiveActivity.swift b/Sources/Shared/Notifications/NotificationCommands/HandlerLiveActivity.swift index 93768745e1..3eb023a212 100644 --- a/Sources/Shared/Notifications/NotificationCommands/HandlerLiveActivity.swift +++ b/Sources/Shared/Notifications/NotificationCommands/HandlerLiveActivity.swift @@ -14,7 +14,7 @@ import PromiseKit /// 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 16.2, *) +@available(iOS 17.2, *) struct HandlerStartOrUpdateLiveActivity: NotificationCommandHandler { private enum ValidationError: Error { case missingTag @@ -141,7 +141,7 @@ struct HandlerStartOrUpdateLiveActivity: NotificationCommandHandler { /// Handles explicit `end_live_activity` commands. /// Note: the `clear_notification` + `tag` dismiss flow is handled in `HandlerClearNotification`. -@available(iOS 16.2, *) +@available(iOS 17.2, *) struct HandlerEndLiveActivity: NotificationCommandHandler { func handle(_ payload: [String: Any]) -> Promise { guard !Current.isAppExtension else { diff --git a/Sources/Shared/Notifications/NotificationCommands/NotificationsCommandManager.swift b/Sources/Shared/Notifications/NotificationCommands/NotificationsCommandManager.swift index 74c0720201..ec805c31d4 100644 --- a/Sources/Shared/Notifications/NotificationCommands/NotificationsCommandManager.swift +++ b/Sources/Shared/Notifications/NotificationCommands/NotificationsCommandManager.swift @@ -23,7 +23,7 @@ public class NotificationCommandManager { #if os(iOS) register(command: "update_complications", handler: HandlerUpdateComplications()) #if canImport(ActivityKit) - if #available(iOS 16.2, *) { + if #available(iOS 17.2, *) { register(command: "live_activity", handler: HandlerStartOrUpdateLiveActivity()) register(command: "end_live_activity", handler: HandlerEndLiveActivity()) } @@ -49,7 +49,7 @@ public class NotificationCommandManager { // Support data.live_update: true — the same field Android uses for Live Updates. // A single YAML automation can target both platforms with no platform-specific keys. #if canImport(ActivityKit) - if #available(iOS 16.2, *), hadict["live_update"] as? Bool == true, + if #available(iOS 17.2, *), hadict["live_update"] as? Bool == true, let handler = commands["live_activity"] { return handler.handle(hadict) } @@ -113,7 +113,7 @@ private struct HandlerClearNotification: NotificationCommandHandler { // the activity is actually dismissed (prevents the OS suspending mid-dismiss). // ActivityKit is unavailable in the PushProvider extension, so guard accordingly. #if os(iOS) && canImport(ActivityKit) - if #available(iOS 16.2, *), !Current.isAppExtension, let tag = payload["tag"] as? String { + if #available(iOS 17.2, *), !Current.isAppExtension, let tag = payload["tag"] as? String { return Promise { seal in Task { await Current.liveActivityRegistry.end(tag: tag, dismissalPolicy: .immediate) diff --git a/Tests/Shared/LiveActivity/HandlerLiveActivityTests.swift b/Tests/Shared/LiveActivity/HandlerLiveActivityTests.swift index d3ca4cbafc..5f563cdee1 100644 --- a/Tests/Shared/LiveActivity/HandlerLiveActivityTests.swift +++ b/Tests/Shared/LiveActivity/HandlerLiveActivityTests.swift @@ -6,7 +6,7 @@ import XCTest // MARK: - HandlerStartOrUpdateLiveActivity Tests -@available(iOS 16.2, *) +@available(iOS 17.2, *) final class HandlerStartOrUpdateLiveActivityTests: XCTestCase { private var sut: HandlerStartOrUpdateLiveActivity! private var mockRegistry: MockLiveActivityRegistry! @@ -218,7 +218,7 @@ final class HandlerStartOrUpdateLiveActivityTests: XCTestCase { // MARK: - HandlerEndLiveActivity Tests -@available(iOS 16.2, *) +@available(iOS 17.2, *) final class HandlerEndLiveActivityTests: XCTestCase { private var sut: HandlerEndLiveActivity! private var mockRegistry: MockLiveActivityRegistry! diff --git a/Tests/Shared/LiveActivity/LiveActivityContractTests.swift b/Tests/Shared/LiveActivity/LiveActivityContractTests.swift index 8018bf7d97..f019f1cbd3 100644 --- a/Tests/Shared/LiveActivity/LiveActivityContractTests.swift +++ b/Tests/Shared/LiveActivity/LiveActivityContractTests.swift @@ -9,7 +9,7 @@ import XCTest /// Changing them would break communication with the HA server, relay server, or APNs. /// If a test fails, it means a wire-format contract was broken — do not simply update /// the expected value without coordinating with all server-side consumers. -@available(iOS 16.2, *) +@available(iOS 17.2, *) final class LiveActivityContractTests: XCTestCase { // MARK: - HALiveActivityAttributes (wire-format frozen struct) diff --git a/Tests/Shared/LiveActivity/MockLiveActivityRegistry.swift b/Tests/Shared/LiveActivity/MockLiveActivityRegistry.swift index bf3f041d4d..91318d4f72 100644 --- a/Tests/Shared/LiveActivity/MockLiveActivityRegistry.swift +++ b/Tests/Shared/LiveActivity/MockLiveActivityRegistry.swift @@ -5,7 +5,7 @@ import Foundation /// Test double for `LiveActivityRegistryProtocol`. /// Records all calls so tests can assert on what was invoked. -@available(iOS 16.2, *) +@available(iOS 17.2, *) final class MockLiveActivityRegistry: LiveActivityRegistryProtocol { // MARK: - Recorded Calls @@ -50,7 +50,6 @@ final class MockLiveActivityRegistry: LiveActivityRegistryProtocol { reattachCallCount += 1 } - @available(iOS 17.2, *) func startObservingPushToStartToken() async { // No-op in tests — token observation requires a real device/simulator push environment. } @@ -58,7 +57,7 @@ final class MockLiveActivityRegistry: LiveActivityRegistryProtocol { // MARK: - EndCall helpers -@available(iOS 16.2, *) +@available(iOS 17.2, *) extension MockLiveActivityRegistry.EndCall { var policyIsImmediate: Bool { policy == .immediate } var policyIsDefault: Bool { policy == .default } diff --git a/Tests/Shared/LiveActivity/NotificationsCommandManagerLiveActivityTests.swift b/Tests/Shared/LiveActivity/NotificationsCommandManagerLiveActivityTests.swift index 3f21432629..5d97edb300 100644 --- a/Tests/Shared/LiveActivity/NotificationsCommandManagerLiveActivityTests.swift +++ b/Tests/Shared/LiveActivity/NotificationsCommandManagerLiveActivityTests.swift @@ -9,7 +9,7 @@ import XCTest /// 2. `homeassistant.live_activity == true` — data flag (Android-compat pattern) /// 3. `homeassistant.command == "end_live_activity"` — end command /// 4. `homeassistant.command == "clear_notification"` with a `tag` — dismisses live activity -@available(iOS 16.2, *) +@available(iOS 17.2, *) final class NotificationsCommandManagerLiveActivityTests: XCTestCase { private var sut: NotificationCommandManager! private var mockRegistry: MockLiveActivityRegistry! From bd3b197e48079118c3ffe8a0d1986b13cbbfef51 Mon Sep 17 00:00:00 2001 From: rwarner Date: Tue, 31 Mar 2026 13:46:17 -0400 Subject: [PATCH 29/34] Fix Copilot review issues: ASCII tag validation, 17.2 availability guard, live_update test key MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - isValidTag: replace CharacterSet.alphanumerics (Unicode-inclusive) with explicit ASCII character set to match the stated [a-zA-Z0-9_-] contract - WidgetsBundle17: wrap HALiveActivityConfiguration() in #available(17.2) guard, matching WidgetsBundleLegacy — avoids availability error on iOS 17.0/17.1 - Tests: change live_activity flag to live_update to match production routing (NotificationsCommandManager routes on live_update, not live_activity) Co-Authored-By: Claude Sonnet 4.6 --- Sources/Extensions/Widgets/Widgets.swift | 4 +++- .../NotificationCommands/HandlerLiveActivity.swift | 2 +- .../NotificationsCommandManagerLiveActivityTests.swift | 10 +++++----- 3 files changed, 9 insertions(+), 7 deletions(-) diff --git a/Sources/Extensions/Widgets/Widgets.swift b/Sources/Extensions/Widgets/Widgets.swift index a86d168cf9..a7a332cb7b 100644 --- a/Sources/Extensions/Widgets/Widgets.swift +++ b/Sources/Extensions/Widgets/Widgets.swift @@ -37,7 +37,9 @@ struct WidgetsBundle17: WidgetBundle { } var body: some Widget { - HALiveActivityConfiguration() + if #available(iOSApplicationExtension 17.2, *) { + HALiveActivityConfiguration() + } WidgetCommonlyUsedEntities() WidgetCustom() WidgetAssist() diff --git a/Sources/Shared/Notifications/NotificationCommands/HandlerLiveActivity.swift b/Sources/Shared/Notifications/NotificationCommands/HandlerLiveActivity.swift index 3eb023a212..b4f025607d 100644 --- a/Sources/Shared/Notifications/NotificationCommands/HandlerLiveActivity.swift +++ b/Sources/Shared/Notifications/NotificationCommands/HandlerLiveActivity.swift @@ -96,7 +96,7 @@ struct HandlerStartOrUpdateLiveActivity: NotificationCommandHandler { /// and log output without escaping or truncation issues. static func isValidTag(_ tag: String) -> Bool { guard tag.count <= 64 else { return false } - let allowed = CharacterSet.alphanumerics.union(CharacterSet(charactersIn: "-_")) + let allowed = CharacterSet(charactersIn: "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_") return tag.unicodeScalars.allSatisfy { allowed.contains($0) } } diff --git a/Tests/Shared/LiveActivity/NotificationsCommandManagerLiveActivityTests.swift b/Tests/Shared/LiveActivity/NotificationsCommandManagerLiveActivityTests.swift index 5d97edb300..8d5be00ab1 100644 --- a/Tests/Shared/LiveActivity/NotificationsCommandManagerLiveActivityTests.swift +++ b/Tests/Shared/LiveActivity/NotificationsCommandManagerLiveActivityTests.swift @@ -6,7 +6,7 @@ import XCTest /// Tests for the two live-activity routing paths in `NotificationCommandManager`: /// 1. `homeassistant.command == "live_activity"` — explicit command key -/// 2. `homeassistant.live_activity == true` — data flag (Android-compat pattern) +/// 2. `homeassistant.live_update == true` — data flag (Android-compat pattern) /// 3. `homeassistant.command == "end_live_activity"` — end command /// 4. `homeassistant.command == "clear_notification"` with a `tag` — dismisses live activity @available(iOS 17.2, *) @@ -50,11 +50,11 @@ final class NotificationsCommandManagerLiveActivityTests: XCTestCase { XCTAssertEqual(mockRegistry.startOrUpdateCalls[0].title, "Command Title") } - // MARK: - live_activity: true data flag routing (Android-compat) + // MARK: - live_update: true data flag routing (Android-compat) func testHandle_liveActivityFlag_callsStartOrUpdate() { let payload = makePayload([ - "live_activity": true, + "live_update": true, "tag": "flag-tag", "title": "Flag Title", "message": "World", @@ -66,9 +66,9 @@ final class NotificationsCommandManagerLiveActivityTests: XCTestCase { } func testHandle_liveActivityFlagFalse_doesNotRouteToLiveActivity() { - // live_activity: false should fall through to standard command routing + // live_update: false should fall through to standard command routing let payload = makePayload([ - "live_activity": false, + "live_update": false, "tag": "no-tag", "title": "Should Not Route", ]) From 71d937c6b19d1d2973aee93082d7a5d02bcce3c8 Mon Sep 17 00:00:00 2001 From: rwarner Date: Tue, 31 Mar 2026 13:51:16 -0400 Subject: [PATCH 30/34] Simplify liveActivityRegistry: optional protocol property, no Any? backing Per bgoncal review feedback: remove the Any? backing store workaround and the @available annotation on the stored property. Move @available(iOS 17.2, *) to individual protocol methods so LiveActivityRegistryProtocol can be referenced without an availability guard, then store it as LiveActivityRegistryProtocol? and return nil at runtime when on iOS < 17.2. Update all call sites to optional-chain (?.); AppDelegate uses guard-let since it already holds a non-nil reference after pre-warming inside #available(17.2). Co-Authored-By: Claude Sonnet 4.6 --- Sources/App/AppDelegate.swift | 2 +- .../LiveActivitySettingsView.swift | 4 ++-- Sources/Shared/Environment/Environment.swift | 20 +++++++++---------- .../LiveActivity/LiveActivityRegistry.swift | 5 ++++- .../HandlerLiveActivity.swift | 4 ++-- .../NotificationsCommandManager.swift | 2 +- 6 files changed, 20 insertions(+), 17 deletions(-) diff --git a/Sources/App/AppDelegate.swift b/Sources/App/AppDelegate.swift index 3a47dd3a86..f27aba01db 100644 --- a/Sources/App/AppDelegate.swift +++ b/Sources/App/AppDelegate.swift @@ -379,7 +379,7 @@ class AppDelegate: UIResponder, UIApplicationDelegate { // Pre-warm the registry on the main thread before spawning background Tasks. // This avoids a lazy-init race if a push notification handler accesses it // concurrently from a background thread. - let registry = Current.liveActivityRegistry + guard let registry = Current.liveActivityRegistry else { return } Task { // Re-attach observation tasks (push token + lifecycle) to any Live Activities diff --git a/Sources/App/Settings/LiveActivity/LiveActivitySettingsView.swift b/Sources/App/Settings/LiveActivity/LiveActivitySettingsView.swift index 891fd80701..6a70154066 100644 --- a/Sources/App/Settings/LiveActivity/LiveActivitySettingsView.swift +++ b/Sources/App/Settings/LiveActivity/LiveActivitySettingsView.swift @@ -618,7 +618,7 @@ struct LiveActivitySettingsView: View { private func endActivity(tag: String) { Task { - await Current.liveActivityRegistry.end(tag: tag, dismissalPolicy: .immediate) + await Current.liveActivityRegistry?.end(tag: tag, dismissalPolicy: .immediate) await loadActivities() } } @@ -629,7 +629,7 @@ struct LiveActivitySettingsView: View { await withTaskGroup(of: Void.self) { group in for tag in tags { group.addTask { - await Current.liveActivityRegistry.end(tag: tag, dismissalPolicy: .immediate) + await Current.liveActivityRegistry?.end(tag: tag, dismissalPolicy: .immediate) } } } diff --git a/Sources/Shared/Environment/Environment.swift b/Sources/Shared/Environment/Environment.swift index 3316c2f857..fbd92b8dde 100644 --- a/Sources/Shared/Environment/Environment.swift +++ b/Sources/Shared/Environment/Environment.swift @@ -142,21 +142,21 @@ public class AppEnvironment { #if os(iOS) #if canImport(ActivityKit) - /// Backing store uses Any? to work around Swift's @available stored property restriction. - /// Access via the typed `liveActivityRegistry` computed property. + private var _liveActivityRegistryBacking: LiveActivityRegistryProtocol? + /// Call `_ = Current.liveActivityRegistry` on the main thread at launch (before any /// background thread can access it) to avoid a lazy-init race between concurrent callers. - private var _liveActivityRegistryBacking: Any? - - @available(iOS 17.2, *) - public var liveActivityRegistry: LiveActivityRegistryProtocol { + public var liveActivityRegistry: LiveActivityRegistryProtocol? { get { - if let existing = _liveActivityRegistryBacking as? LiveActivityRegistryProtocol { + if let existing = _liveActivityRegistryBacking { return existing } - let registry = LiveActivityRegistry() - _liveActivityRegistryBacking = registry - return registry + if #available(iOS 17.2, *) { + let registry = LiveActivityRegistry() + _liveActivityRegistryBacking = registry + return registry + } + return nil } set { _liveActivityRegistryBacking = newValue diff --git a/Sources/Shared/LiveActivity/LiveActivityRegistry.swift b/Sources/Shared/LiveActivity/LiveActivityRegistry.swift index 8a478f433d..ea9bcffb70 100644 --- a/Sources/Shared/LiveActivity/LiveActivityRegistry.swift +++ b/Sources/Shared/LiveActivity/LiveActivityRegistry.swift @@ -6,11 +6,14 @@ import Foundation /// Activities are marked stale after 30 minutes if no further updates arrive. private let kLiveActivityStaleInterval: TimeInterval = 30 * 60 -@available(iOS 17.2, *) 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 } diff --git a/Sources/Shared/Notifications/NotificationCommands/HandlerLiveActivity.swift b/Sources/Shared/Notifications/NotificationCommands/HandlerLiveActivity.swift index b4f025607d..f511ba5693 100644 --- a/Sources/Shared/Notifications/NotificationCommands/HandlerLiveActivity.swift +++ b/Sources/Shared/Notifications/NotificationCommands/HandlerLiveActivity.swift @@ -54,7 +54,7 @@ struct HandlerStartOrUpdateLiveActivity: NotificationCommandHandler { let state = Self.contentState(from: payload) - try await Current.liveActivityRegistry.startOrUpdate( + try await Current.liveActivityRegistry?.startOrUpdate( tag: tag, title: title, state: state @@ -157,7 +157,7 @@ struct HandlerEndLiveActivity: NotificationCommandHandler { } let policy = Self.dismissalPolicy(from: payload) - await Current.liveActivityRegistry.end(tag: tag, dismissalPolicy: policy) + await Current.liveActivityRegistry?.end(tag: tag, dismissalPolicy: policy) seal.fulfill(()) } } diff --git a/Sources/Shared/Notifications/NotificationCommands/NotificationsCommandManager.swift b/Sources/Shared/Notifications/NotificationCommands/NotificationsCommandManager.swift index ec805c31d4..98d89d90bb 100644 --- a/Sources/Shared/Notifications/NotificationCommands/NotificationsCommandManager.swift +++ b/Sources/Shared/Notifications/NotificationCommands/NotificationsCommandManager.swift @@ -116,7 +116,7 @@ private struct HandlerClearNotification: NotificationCommandHandler { if #available(iOS 17.2, *), !Current.isAppExtension, let tag = payload["tag"] as? String { return Promise { seal in Task { - await Current.liveActivityRegistry.end(tag: tag, dismissalPolicy: .immediate) + await Current.liveActivityRegistry?.end(tag: tag, dismissalPolicy: .immediate) // https://stackoverflow.com/a/56657888/6324550 DispatchQueue.main.async { seal.fulfill(()) } } From f8c6a5079bdae2a9968027ca2cc738bbed8cdac3 Mon Sep 17 00:00:00 2001 From: rwarner Date: Tue, 31 Mar 2026 13:53:22 -0400 Subject: [PATCH 31/34] Revert unrelated SwiftFormat reflow of apis computed property Restores the original single-line form to keep the diff focused on Live Activities changes, per bgoncal review feedback. Co-Authored-By: Claude Sonnet 4.6 --- Sources/Shared/Environment/Environment.swift | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/Sources/Shared/Environment/Environment.swift b/Sources/Shared/Environment/Environment.swift index fbd92b8dde..2850cc44f2 100644 --- a/Sources/Shared/Environment/Environment.swift +++ b/Sources/Shared/Environment/Environment.swift @@ -189,9 +189,7 @@ public class AppEnvironment { public var cachedApis = [Identifier: HomeAssistantAPI]() - public var apis: [HomeAssistantAPI] { - servers.all.compactMap(api(for:)) - } + public var apis: [HomeAssistantAPI] { servers.all.compactMap(api(for:)) } private var lastActiveURLForServer = [Identifier: URL?]() public func api(for server: Server) -> HomeAssistantAPI? { From 419ad5fe164ce27fe1011ca7d43ccb44a30fde3b Mon Sep 17 00:00:00 2001 From: rwarner Date: Tue, 31 Mar 2026 13:56:16 -0400 Subject: [PATCH 32/34] Remove allowsCustomMTLSCertificateImport, already removed in main (#4453) This property was added in an earlier commit but main removed the TestFlight gate for mTLS entirely (PR #4453). Drop it to avoid a merge conflict and align with the direction main took. Co-Authored-By: Claude Sonnet 4.6 --- Sources/Shared/Environment/Environment.swift | 6 ------ 1 file changed, 6 deletions(-) diff --git a/Sources/Shared/Environment/Environment.swift b/Sources/Shared/Environment/Environment.swift index 2850cc44f2..4f85e36c78 100644 --- a/Sources/Shared/Environment/Environment.swift +++ b/Sources/Shared/Environment/Environment.swift @@ -291,12 +291,6 @@ public class AppEnvironment { #endif }() - /// Centralized gate for importing custom mTLS client certificates. - /// TestFlight-only for now; update this in one place if rollout rules change. - public var allowsCustomMTLSCertificateImport: Bool { - isTestFlight - } - #if os(iOS) public var isAppExtension = AppConstants.BundleID != Bundle.main.bundleIdentifier #elseif os(watchOS) From 3bf98598bd4e3fa057779c39b70ea6c255d47a46 Mon Sep 17 00:00:00 2001 From: rwarner Date: Wed, 1 Apr 2026 14:22:33 -0400 Subject: [PATCH 33/34] Simplify liveActivityRegistry to a single lazy var Per bgoncal feedback: the backing store + computed property is redundant. A single lazy var handles initialization on first access, returns nil on iOS < 17.2, and is still reassignable for test injection. Co-Authored-By: Claude Sonnet 4.6 --- Sources/Shared/Environment/Environment.swift | 22 +++++--------------- 1 file changed, 5 insertions(+), 17 deletions(-) diff --git a/Sources/Shared/Environment/Environment.swift b/Sources/Shared/Environment/Environment.swift index 4f85e36c78..19fbeba192 100644 --- a/Sources/Shared/Environment/Environment.swift +++ b/Sources/Shared/Environment/Environment.swift @@ -142,26 +142,14 @@ public class AppEnvironment { #if os(iOS) #if canImport(ActivityKit) - private var _liveActivityRegistryBacking: LiveActivityRegistryProtocol? - /// Call `_ = Current.liveActivityRegistry` on the main thread at launch (before any /// background thread can access it) to avoid a lazy-init race between concurrent callers. - public var liveActivityRegistry: LiveActivityRegistryProtocol? { - get { - if let existing = _liveActivityRegistryBacking { - return existing - } - if #available(iOS 17.2, *) { - let registry = LiveActivityRegistry() - _liveActivityRegistryBacking = registry - return registry - } - return nil + public lazy var liveActivityRegistry: LiveActivityRegistryProtocol? = { + if #available(iOS 17.2, *) { + return LiveActivityRegistry() } - set { - _liveActivityRegistryBacking = newValue - } - } + return nil + }() #endif public var appDatabaseUpdater: AppDatabaseUpdaterProtocol = AppDatabaseUpdater.shared From 199c4e14d7048432ef17e853a96071e43ff2eda6 Mon Sep 17 00:00:00 2001 From: rwarner Date: Wed, 1 Apr 2026 16:31:51 -0400 Subject: [PATCH 34/34] Rename live activity dismissed webhook tag field to 'live_activity_tag' Aligns with HA core change: the generic 'tag' key in the dismissed webhook payload is renamed to 'live_activity_tag' to avoid ambiguity with the notification tag field used elsewhere in mobile_app. Co-Authored-By: Claude Sonnet 4.6 --- Sources/Shared/LiveActivity/LiveActivityRegistry.swift | 4 ++-- Tests/Shared/LiveActivity/LiveActivityContractTests.swift | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/Sources/Shared/LiveActivity/LiveActivityRegistry.swift b/Sources/Shared/LiveActivity/LiveActivityRegistry.swift index ea9bcffb70..b4cf05f929 100644 --- a/Sources/Shared/LiveActivity/LiveActivityRegistry.swift +++ b/Sources/Shared/LiveActivity/LiveActivityRegistry.swift @@ -43,7 +43,7 @@ public actor LiveActivityRegistry: LiveActivityRegistryProtocol { /// 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 = ["activity_id", "tag", "reason"] + static let dismissedWebhookKeys: Set = ["activity_id", "live_activity_tag", "reason"] // MARK: - State @@ -354,7 +354,7 @@ public actor LiveActivityRegistry: LiveActivityRegistryProtocol { type: Self.webhookTypeDismissed, data: [ "activity_id": activityID, - "tag": tag, + "live_activity_tag": tag, "reason": reason, ] ) diff --git a/Tests/Shared/LiveActivity/LiveActivityContractTests.swift b/Tests/Shared/LiveActivity/LiveActivityContractTests.swift index f019f1cbd3..1f4e3a631a 100644 --- a/Tests/Shared/LiveActivity/LiveActivityContractTests.swift +++ b/Tests/Shared/LiveActivity/LiveActivityContractTests.swift @@ -117,7 +117,7 @@ final class LiveActivityContractTests: XCTestCase { func testDismissedWebhookKeys_areFrozen() { XCTAssertEqual( LiveActivityRegistry.dismissedWebhookKeys, - ["activity_id", "tag", "reason"] + ["activity_id", "live_activity_tag", "reason"] ) }