Files
iOS/Sources/Shared/LiveActivity/HALiveActivityAttributes.swift
Ryan Warner 9779d0576a Fix Live Activity ContentState decoding and Direct push delegate (#4677)
## 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.
2026-06-01 11:36:33 +02:00

146 lines
6.0 KiB
Swift
Raw Permalink Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#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