mirror of
https://github.com/home-assistant/iOS.git
synced 2026-04-12 15:26:45 -05:00
## Summary Follow-up fixes after #4444 merged, addressing issues found during Mac Catalyst testing: - **Fix `›` character in Frequent Updates footer** — `.strings` files don't interpret `\uXXXX` escapes at runtime; replaced with the literal `›` character - **Fix Mac Catalyst compilation** — `ActivityKit` APIs (`ActivityAttributes`, `Activity`, `ActivityUIDismissalPolicy`, etc.) are marked unavailable on Mac Catalyst even though `canImport(ActivityKit)` returns true there. Replaced all `#if canImport(ActivityKit)` and bare `#if os(iOS)` guards around ActivityKit code with `#if os(iOS) && !targetEnvironment(macCatalyst)`. Files affected: - `HALiveActivityAttributes.swift` - `LiveActivityRegistry.swift` - `HandlerLiveActivity.swift` - `LiveActivitySettingsView.swift` - `HADynamicIslandView.swift` - `HALockScreenView.swift` - `HALiveActivityConfiguration.swift` - `Widgets.swift` (three `HALiveActivityConfiguration()` call sites) - `Environment.swift`, `AppDelegate.swift`, `HAAPI.swift`, `NotificationsCommandManager.swift`, `SettingsItem.swift` (inline guards) ## Test plan - [x] iOS builds and runs - [x] macOS (Mac Catalyst) builds and launches - [ ] Live Activities settings entry does not appear on macOS (filtered by `isTestFlight` + `#available(iOS 17.2, *)`) - [ ] Live Activities work as expected on iOS TestFlight build 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
186 lines
7.7 KiB
Swift
186 lines
7.7 KiB
Swift
#if os(iOS) && !targetEnvironment(macCatalyst)
|
|
import ActivityKit
|
|
import Foundation
|
|
import PromiseKit
|
|
|
|
// MARK: - HandlerStartOrUpdateLiveActivity
|
|
|
|
/// Handles `live_update: true` notifications by starting or updating a Live Activity.
|
|
///
|
|
/// Triggered two ways:
|
|
/// 1. `homeassistant.command == "live_activity"` (message: live_activity in YAML)
|
|
/// 2. `homeassistant.live_update == true` (data.live_update: true in YAML)
|
|
///
|
|
/// Notification payload fields mirror the Android companion app:
|
|
/// tag, title, message, critical_text, progress, progress_max,
|
|
/// chronometer, when, when_relative, notification_icon, notification_icon_color
|
|
@available(iOS 17.2, *)
|
|
struct HandlerStartOrUpdateLiveActivity: NotificationCommandHandler {
|
|
private enum ValidationError: Error {
|
|
case missingTag
|
|
case missingTitle
|
|
case invalidTag
|
|
}
|
|
|
|
func handle(_ payload: [String: Any]) -> Promise<Void> {
|
|
// PushProvider (NEAppPushProvider) runs in a separate OS process — ActivityKit is
|
|
// unavailable there. The same notification will be re-delivered to the main app via
|
|
// UNUserNotificationCenter, where it will be handled correctly.
|
|
guard !Current.isAppExtension else {
|
|
Current.Log.verbose("HandlerStartOrUpdateLiveActivity: skipping in app extension, will handle in main app")
|
|
return .value(())
|
|
}
|
|
|
|
return Promise { seal in
|
|
Task {
|
|
do {
|
|
guard let tag = payload["tag"] as? String, !tag.isEmpty else {
|
|
throw ValidationError.missingTag
|
|
}
|
|
|
|
guard Self.isValidTag(tag) else {
|
|
Current.Log
|
|
.error(
|
|
"HandlerStartOrUpdateLiveActivity: invalid tag '\(tag)' — must be [a-zA-Z0-9_-], max 64 chars"
|
|
)
|
|
throw ValidationError.invalidTag
|
|
}
|
|
|
|
guard let title = payload["title"] as? String, !title.isEmpty else {
|
|
throw ValidationError.missingTitle
|
|
}
|
|
|
|
Self.showPrivacyDisclosureIfNeeded()
|
|
|
|
let state = Self.contentState(from: payload)
|
|
|
|
try await Current.liveActivityRegistry?.startOrUpdate(
|
|
tag: tag,
|
|
title: title,
|
|
state: state
|
|
)
|
|
seal.fulfill(())
|
|
} catch {
|
|
Current.Log.error("HandlerStartOrUpdateLiveActivity: \(error)")
|
|
// Fulfill rather than reject for known validation/auth errors so HA
|
|
// doesn't treat them as transient failures and retry indefinitely.
|
|
switch error {
|
|
case ValidationError.missingTag, ValidationError.missingTitle, ValidationError.invalidTag:
|
|
seal.fulfill(())
|
|
default:
|
|
seal.reject(error)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Privacy Disclosure
|
|
|
|
/// Records that the user has started a Live Activity so that the Settings screen
|
|
/// can surface the privacy notice on their next visit.
|
|
/// The permanent disclosure lives in LiveActivitySettingsView's privacy section —
|
|
/// a local notification would silently fail if notification permission is not granted.
|
|
private static func showPrivacyDisclosureIfNeeded() {
|
|
guard !Current.settingsStore.hasSeenLiveActivityDisclosure else { return }
|
|
Current.settingsStore.hasSeenLiveActivityDisclosure = true
|
|
}
|
|
|
|
// MARK: - Validation
|
|
|
|
/// Validates that a Live Activity tag contains only safe characters.
|
|
///
|
|
/// Tags are used as ActivityKit push token topic identifiers and as keys in
|
|
/// the activity registry dictionary. Restricting to `[a-zA-Z0-9_-]` (max 64
|
|
/// characters) ensures they are safe for APNs payloads, UserDefaults keys,
|
|
/// and log output without escaping or truncation issues.
|
|
static func isValidTag(_ tag: String) -> Bool {
|
|
guard tag.count <= 64 else { return false }
|
|
let allowed = CharacterSet(charactersIn: "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_")
|
|
return tag.unicodeScalars.allSatisfy { allowed.contains($0) }
|
|
}
|
|
|
|
// MARK: - Payload Parsing
|
|
|
|
static func contentState(from payload: [String: Any]) -> HALiveActivityAttributes.ContentState {
|
|
let message = payload["message"] as? String ?? ""
|
|
let criticalText = payload["critical_text"] as? String
|
|
// Use NSNumber coercion so both Int and Double JSON values (e.g. 50 vs 50.0) decode correctly.
|
|
let progress = (payload["progress"] as? NSNumber).map { Int(truncating: $0) }
|
|
let progressMax = (payload["progress_max"] as? NSNumber).map { Int(truncating: $0) }
|
|
let chronometer = payload["chronometer"] as? Bool
|
|
let icon = payload["notification_icon"] as? String
|
|
let color = payload["notification_icon_color"] as? String
|
|
|
|
// `when` + `when_relative` → absolute countdown end date.
|
|
// Parsed as Double to preserve sub-second Unix timestamps sent by HA.
|
|
var countdownEnd: Date?
|
|
if let when = (payload["when"] as? NSNumber).map(\.doubleValue) {
|
|
let whenRelative = payload["when_relative"] as? Bool ?? false
|
|
if whenRelative {
|
|
countdownEnd = Date().addingTimeInterval(when)
|
|
} else {
|
|
countdownEnd = Date(timeIntervalSince1970: when)
|
|
}
|
|
}
|
|
|
|
return HALiveActivityAttributes.ContentState(
|
|
message: message,
|
|
criticalText: criticalText,
|
|
progress: progress,
|
|
progressMax: progressMax,
|
|
chronometer: chronometer,
|
|
countdownEnd: countdownEnd,
|
|
icon: icon,
|
|
color: color
|
|
)
|
|
}
|
|
}
|
|
|
|
// MARK: - HandlerEndLiveActivity
|
|
|
|
/// Handles explicit `end_live_activity` commands.
|
|
/// Note: the `clear_notification` + `tag` dismiss flow is handled in `HandlerClearNotification`.
|
|
@available(iOS 17.2, *)
|
|
struct HandlerEndLiveActivity: NotificationCommandHandler {
|
|
func handle(_ payload: [String: Any]) -> Promise<Void> {
|
|
guard !Current.isAppExtension else {
|
|
return .value(())
|
|
}
|
|
|
|
return Promise { seal in
|
|
Task {
|
|
guard let tag = payload["tag"] as? String, !tag.isEmpty,
|
|
HandlerStartOrUpdateLiveActivity.isValidTag(tag) else {
|
|
seal.fulfill(())
|
|
return
|
|
}
|
|
|
|
let policy = Self.dismissalPolicy(from: payload)
|
|
await Current.liveActivityRegistry?.end(tag: tag, dismissalPolicy: policy)
|
|
seal.fulfill(())
|
|
}
|
|
}
|
|
}
|
|
|
|
private static func dismissalPolicy(from payload: [String: Any]) -> ActivityUIDismissalPolicy {
|
|
switch payload["dismissal_policy"] as? String {
|
|
case "default":
|
|
return .default
|
|
case let str where str?.hasPrefix("after:") == true:
|
|
if let timestampStr = str?.dropFirst(6),
|
|
let timestamp = Double(timestampStr) {
|
|
// Cap to 24 hours — iOS enforces its own maximum, but this prevents
|
|
// a far-future date from lingering in the dismissed activities list
|
|
// longer than intended if Apple ever relaxes the OS limit.
|
|
let maxDate = Date().addingTimeInterval(24 * 60 * 60)
|
|
return .after(min(Date(timeIntervalSince1970: timestamp), maxDate))
|
|
}
|
|
return .immediate
|
|
default:
|
|
return .immediate
|
|
}
|
|
}
|
|
}
|
|
#endif
|