Files
iOS/Sources/Shared/Notifications/NotificationCommands/HandlerLiveActivity.swift
Ryan Warner c80602e643 Remove end_live_activity command (#4616)
## Summary

- Removes `HandlerEndLiveActivity` and its registration in
`NotificationCommandManager`
- `clear_notification` with a `tag` already ends Live Activities via
`HandlerClearNotification` — no separate command needed
- Aligns iOS with Android, which uses `clear_notification` exclusively

Companion docs PR: home-assistant/companion.home-assistant#1303

## Test plan

- [ ] Send a `live_update: true` notification to start a Live Activity
- [ ] Send `clear_notification` with the same `tag` — activity ends
correctly
- [ ] Confirm no regression on standard notification clearing

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Part of epic: https://github.com/home-assistant/epics/issues/61
Fixes: https://github.com/home-assistant/iOS/issues/4623

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-27 15:03:57 +02:00

141 lines
5.9 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
)
}
}
#endif