mirror of
https://github.com/home-assistant/iOS.git
synced 2026-06-24 09:57:05 -05:00
<!-- Thank you for submitting a Pull Request and helping to improve Home Assistant. Please complete the following sections to help the processing and review of your changes. Please do not delete anything from this template. --> ## Summary <!-- Provide a brief summary of the changes you have made and most importantly what they aim to achieve --> Live Activities sometimes flashed the wrong background color when starting: the Lock Screen surface used the adaptive `systemBackground`, which resolves late/incorrectly as the activity appears. This forces a stable **black** background by default, removing the glitch. It also adds **`background_color`** and **`text_color`** notification fields — parsed with the same rules as `notification_icon_color` (CSS names / 3-6-8-digit hex via `UIColor(hex:)`) — so automations can set the Lock Screen card's background and foreground. When `text_color` is omitted the foreground auto-contrasts with the resolved background's luminance, so the black default and any custom `background_color` stay legible. The Dynamic Island background is system-controlled and keeps its white text. Changes: - `HALiveActivityAttributes.ContentState`: new optional `backgroundColor` (`background_color`) and `textColor` (`text_color`). - `HandlerStartOrUpdateLiveActivity`: reads both (local push path). - `HALiveActivityConfiguration` / `HALockScreenView` / `HAActivityVisualStyle`: resolve the background and foreground (explicit `text_color`, else luma auto-contrast). - Tests: contract (wire keys + key set + round-trip) and handler parsing. Remote (FCM relay) passthrough: home-assistant/mobile-apps-fcm-push#325 ## Screenshots <img width="603" height="1311" alt="Screenshot 2026-06-23 at 20 53 50" src="https://github.com/user-attachments/assets/aeec3f70-b9a7-45a7-932e-d3f70a7d00ad" /> ## Link to pull request in Documentation repository <!-- Pull requests that add, change or remove functionality must have a corresponding pull request in the Companion App Documentation repository (https://github.com/home-assistant/companion.home-assistant). Please add the number of this pull request after the "#" --> Documentation: home-assistant/companion.home-assistant# ## Any other notes <!-- If there is any other information of note, like if this Pull Request is part of a bigger change, please include it here. --> - `background_color` defaults to `#000000`; `text_color` is optional and overrides the auto-contrast foreground. - The Dynamic Island background is owned by the system; these fields apply to the Lock Screen presentation. --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
198 lines
8.7 KiB
Swift
198 lines
8.7 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
|
||
|
||
/// Webhook id of the Home Assistant server that started this activity, so a tap can open
|
||
/// the originating server when several are configured. Optional: nil for activities created
|
||
/// before this shipped, or when the start path doesn't supply it.
|
||
public let serverWebhookId: String?
|
||
|
||
/// Static-attribute coding keys. `serverWebhookId` maps to the snake_case `webhook_id` key
|
||
/// carried in the APNs push-to-start `attributes`. Adding optional fields is safe; renaming
|
||
/// or removing breaks in-flight activities.
|
||
enum CodingKeys: String, CodingKey {
|
||
case tag
|
||
case title
|
||
case serverWebhookId = "webhook_id"
|
||
}
|
||
|
||
// 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 {
|
||
/// Dynamic display title. Mirrors top-level `title` so updates can refresh the header.
|
||
public var title: String?
|
||
|
||
/// 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?
|
||
|
||
/// Path or URL opened when the activity is tapped, mirroring the `url` key from
|
||
/// actionable notifications. Resolved like a notification tap: a relative HA path
|
||
/// (e.g. `/lovelace/home`) opens in the frontend, an external URL opens in the
|
||
/// browser. Nil just opens the originating server.
|
||
public var url: String?
|
||
|
||
/// Lock Screen background color, parsed like `notification_icon_color`. Defaults to black;
|
||
/// text auto-contrasts with it. Maps to `background_color`.
|
||
public var backgroundColor: String?
|
||
|
||
/// Lock Screen text/foreground color, parsed like `notification_icon_color`.
|
||
/// Overrides the auto-contrast default. Maps to `text_color`.
|
||
public var textColor: 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 title
|
||
case message
|
||
case criticalText = "critical_text"
|
||
case progress
|
||
case progressMax = "progress_max"
|
||
case chronometer
|
||
case countdownEnd = "countdown_end"
|
||
case icon
|
||
case color
|
||
case url
|
||
case backgroundColor = "background_color"
|
||
case textColor = "text_color"
|
||
}
|
||
|
||
// MARK: - Init
|
||
|
||
public init(
|
||
message: String,
|
||
title: String? = nil,
|
||
criticalText: String? = nil,
|
||
progress: Int? = nil,
|
||
progressMax: Int? = nil,
|
||
chronometer: Bool? = nil,
|
||
countdownEnd: Date? = nil,
|
||
icon: String? = nil,
|
||
color: String? = nil,
|
||
url: String? = nil,
|
||
backgroundColor: String? = nil,
|
||
textColor: String? = nil
|
||
) {
|
||
self.title = title
|
||
self.message = message
|
||
self.criticalText = criticalText
|
||
self.progress = progress
|
||
self.progressMax = progressMax
|
||
self.chronometer = chronometer
|
||
self.countdownEnd = countdownEnd
|
||
self.icon = icon
|
||
self.color = color
|
||
self.url = url
|
||
self.backgroundColor = backgroundColor
|
||
self.textColor = textColor
|
||
}
|
||
|
||
// 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.title = try container.decodeIfPresent(String.self, forKey: .title)
|
||
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)
|
||
self.url = try container.decodeIfPresent(String.self, forKey: .url)
|
||
self.backgroundColor = try container.decodeIfPresent(String.self, forKey: .backgroundColor)
|
||
self.textColor = try container.decodeIfPresent(String.self, forKey: .textColor)
|
||
}
|
||
|
||
public func encode(to encoder: Encoder) throws {
|
||
var container = encoder.container(keyedBy: CodingKeys.self)
|
||
try container.encodeIfPresent(title, forKey: .title)
|
||
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)
|
||
try container.encodeIfPresent(url, forKey: .url)
|
||
try container.encodeIfPresent(backgroundColor, forKey: .backgroundColor)
|
||
try container.encodeIfPresent(textColor, forKey: .textColor)
|
||
}
|
||
}
|
||
|
||
// MARK: - Init
|
||
|
||
public init(tag: String, title: String, serverWebhookId: String? = nil) {
|
||
self.tag = tag
|
||
self.title = title
|
||
self.serverWebhookId = serverWebhookId
|
||
}
|
||
}
|
||
#endif
|