mirror of
https://github.com/home-assistant/iOS.git
synced 2026-06-16 23:33:36 -05:00
## Summary Follow-up fixes to two issues surfaced while testing #4671 end-to-end. 1. **`HALiveActivityAttributes.ContentState.countdownEnd` decoded via Unix epoch.** ActivityKit decodes the `content-state` JSON arriving via APNs with the default `JSONDecoder`, whose `Date` strategy is `.deferredToDate` (seconds since the 2001 reference date). HA core sends `countdown_end` as Unix epoch seconds, matching the documented `data.when` / `data.when_relative` user contract and the in-app handler that already does `Date(timeIntervalSince1970:)`. Without a manual decode the APNs push path renders countdowns ~31 years in the future. Adds explicit `init(from:)` and `encode(to:)` that map `countdownEnd` via `timeIntervalSince1970`. All other fields use `container.decodeIfPresent` so behavior is unchanged for them. 2. **`NotificationManagerLocalPushInterfaceDirect` assigns `LocalPushManager.delegate`.** The Extension path assigns the delegate at line 197; the Direct path (used on simulator and Mac Catalyst) never did. On those platforms that meant silent commands such as `clear_notification` (no alert title/body) were dropped: iOS doesn't fire `willPresent` for content-less notifications, and the delegate is the only fallback that routes into `commandManager`. One-line fix that brings the Direct factory into line with the Extension's behavior. Real-device verification (iPhone 13 Mini, iOS 26.5, paid Developer account): chronometer countdown rendered correctly 60→0 with the Codable fix in place. Without it the timer rendered as if `Date` were seconds-since-2001. ## Screenshots n/a — both fixes affect decode / message routing behavior, not UI rendering. ## Link to pull request in Documentation repository Documentation: home-assistant/companion.home-assistant#1303 ## Any other notes Part of the Live Activities effort tracked in home-assistant/epics#61. Companion server PR: home-assistant/core#166072.
146 lines
6.0 KiB
Swift
146 lines
6.0 KiB
Swift
#if os(iOS) && !targetEnvironment(macCatalyst)
|
||
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 17.2, *)
|
||
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: - Codable
|
||
|
||
// ActivityKit decodes content-state with the default JSONDecoder, which
|
||
// treats `Date` as seconds since 2001-01-01. HA core sends Unix epoch
|
||
// seconds, so map countdownEnd manually via timeIntervalSince1970 to
|
||
// avoid a ~31-year offset. The encoder is symmetric for round-tripping.
|
||
public init(from decoder: Decoder) throws {
|
||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||
self.message = try container.decode(String.self, forKey: .message)
|
||
self.criticalText = try container.decodeIfPresent(String.self, forKey: .criticalText)
|
||
self.progress = try container.decodeIfPresent(Int.self, forKey: .progress)
|
||
self.progressMax = try container.decodeIfPresent(Int.self, forKey: .progressMax)
|
||
self.chronometer = try container.decodeIfPresent(Bool.self, forKey: .chronometer)
|
||
if let timestamp = try container.decodeIfPresent(Double.self, forKey: .countdownEnd) {
|
||
self.countdownEnd = Date(timeIntervalSince1970: timestamp)
|
||
} else {
|
||
self.countdownEnd = nil
|
||
}
|
||
self.icon = try container.decodeIfPresent(String.self, forKey: .icon)
|
||
self.color = try container.decodeIfPresent(String.self, forKey: .color)
|
||
}
|
||
|
||
public func encode(to encoder: Encoder) throws {
|
||
var container = encoder.container(keyedBy: CodingKeys.self)
|
||
try container.encode(message, forKey: .message)
|
||
try container.encodeIfPresent(criticalText, forKey: .criticalText)
|
||
try container.encodeIfPresent(progress, forKey: .progress)
|
||
try container.encodeIfPresent(progressMax, forKey: .progressMax)
|
||
try container.encodeIfPresent(chronometer, forKey: .chronometer)
|
||
if let countdownEnd {
|
||
try container.encode(countdownEnd.timeIntervalSince1970, forKey: .countdownEnd)
|
||
}
|
||
try container.encodeIfPresent(icon, forKey: .icon)
|
||
try container.encodeIfPresent(color, forKey: .color)
|
||
}
|
||
}
|
||
|
||
// MARK: - Init
|
||
|
||
public init(tag: String, title: String) {
|
||
self.tag = tag
|
||
self.title = title
|
||
}
|
||
}
|
||
#endif
|