Files
iOS/Sources/App/Notifications/NotificationManager.swift
Ryan Warner c3fd24aa0b Adds iOS Live Activities support (#4444)
## Summary

> For architecture decisions, data model details, iOS version strategy,
push token flow, and rate limiting — see

[technical-brief.pdf](https://github.com/user-attachments/files/26280274/technical-brief.pdf)

Adds iOS Live Activities support, letting Home Assistant automations
push real-time state to the Lock Screen — washing machine countdowns, EV
charging progress, delivery tracking, alarm states, or anything
time-sensitive that benefits from glanceable visibility without
unlocking the phone.

When an automation sends a notification with `live_update: true` in the
data payload, the companion app starts a Live Activity instead of (or in
addition to) a standard notification banner. Subsequent pushes with the
same `tag` update it in-place silently. `clear_notification` + `tag`
ends it.

Field names (`tag`, `title`, `message`, `progress`, `progress_max`,
`chronometer`, `when`, `when_relative`, `notification_icon`,
`notification_icon_color`) are intentionally shared with Android's Live
Notifications API. Both platforms use the same `live_update: true`
trigger — a single YAML block targets iOS 17.2+ and Android 16+ with no
platform-specific keys.

```yaml
data:
  title: "Washing Machine"
  message: "Cycle in progress"
  data:
    tag: washer_cycle
    live_update: true           # Android 16+ and iOS 17.2+
    progress: 2700
    progress_max: 3600
    chronometer: true
    when: 2700
    when_relative: true
    notification_icon: mdi:washing-machine
    notification_icon_color: "#2196F3"
```

**New files:**
- `Sources/Shared/LiveActivity/HALiveActivityAttributes.swift` — the
`ActivityAttributes` type. Field names match the Android payload spec.
**Struct name and `CodingKeys` are wire-format frozen** — APNs
push-to-start payloads reference the Swift type name directly.
- `Sources/Shared/LiveActivity/LiveActivityRegistry.swift` — Swift
`actor` managing `Activity<HALiveActivityAttributes>` lifecycle. Uses a
reservation pattern to prevent duplicate activities when two pushes with
the same `tag` arrive simultaneously.
-
`Sources/Shared/Notifications/NotificationCommands/HandlerLiveActivity.swift`
— start/update and end `NotificationCommandHandler` implementations,
guarded against the `PushProvider` extension process where ActivityKit
is unavailable.
- `Sources/Extensions/Widgets/LiveActivity/` — `ActivityConfiguration`
wrapper, Lock Screen / StandBy view, and compact / minimal / expanded
Dynamic Island views.
- `Sources/App/Settings/LiveActivity/LiveActivitySettingsView.swift` —
activity status, active list, privacy disclosure, and 11 debug scenarios
for pre-server-side testing.

**Modified files:**
- `Widgets.swift` — registers `HALiveActivityConfiguration` in all three
`WidgetBundle` variants behind `#available(iOSApplicationExtension 17.2,
*)`
- `NotificationsCommandManager.swift` — registers new handlers;
`HandlerClearNotification` now also ends a matching Live Activity when
`tag` is present
- `HAAPI.swift` — adds `supports_live_activities`,
`supports_live_activities_frequent_updates`,
`live_activity_push_to_start_token`,
`live_activity_push_to_start_apns_environment` to registration payload
under a single `#available(iOS 17.2, *)` check
- `AppDelegate.swift` — reattaches surviving activities at launch and
starts observing push-to-start tokens under a single `#available(iOS
17.2, *)` check
- `Info.plist` — `NSSupportsLiveActivities` +
`NSSupportsLiveActivitiesFrequentUpdates`
- `SettingsItem.swift` / `SettingsView.swift` — Live Activities settings
row is gated behind `Current.isTestFlight` and shows a `BetaLabel` badge

**Tests:** 
 - *Unit Tests*
- 45 new tests (36 handler tests, 9 command manager routing tests). All
490 existing tests continue to pass.
 - *Device Tests*
- I tried to test with my physical device, however I do not have a paid
account. Turns out I could not deploy without disabling a lot of
entitlements for compilation. Even so, once I did get it deployed and
running Live Activities wouldn't show unless some of those settings that
I disable were re-enable and leaving me in a particular spot.
   - I mainly tested with the simulator which is what is shown below

## Screenshots


![full-debug-scenarios](https://github.com/user-attachments/assets/b6f61b15-8f41-4fb5-b89d-75b5de719056)

## Link to pull request in Documentation repository

Documentation: home-assistant/companion.home-assistant#1303

## Link to pull request in push relay repository

Relay server: home-assistant/mobile-apps-fcm-push#278

## Link to pull request in HA core

Core: home-assistant/core#166072

## Any other notes

**iOS version gating at 17.2.** The entire feature is gated at
`#available(iOS 17.2, *)` — this is the minimum for push-to-start (the
primary server-side start mechanism). A single availability check now
covers all Live Activity APIs: `supports_live_activities`,
`frequentPushesEnabled`, push-to-start token, and all ActivityKit usage.
This eliminates the nested 16.2/17.2 check pattern.

**Push-to-start (iOS 17.2+) is client-complete.** The token is observed,
stored in Keychain, and included in registration payloads. All companion
server-side PRs are now open — relay server at
home-assistant/mobile-apps-fcm-push#278 and HA core webhook handlers at
home-assistant/core#166072. The relay server uses FCM's native
`apns.liveActivityToken` support (Firebase Admin SDK v13.5.0+) — no
custom APNs client or credentials needed.

> **Server-side work** — all PRs now open:
> - ~~`supports_live_activities` field handling in device registration~~
✓ home-assistant/core#166072
> - ~~`mobile_app_live_activity_token` webhook handler~~ ✓
home-assistant/core#166072
> - ~~`mobile_app_live_activity_dismissed` webhook handler~~ ✓
home-assistant/core#166072
> - ~~Relay server: Live Activity delivery via FCM
`apns.liveActivityToken`~~ ✓ home-assistant/mobile-apps-fcm-push#278
> - ~~`notify.py` routing: includes Live Activity APNs token alongside
FCM token~~ ✓ home-assistant/core#166072

**Live Activities entry in Settings is gated behind TestFlight.** The
settings row only appears when `Current.isTestFlight` is true,
preventing it from surfacing in a release build before the feature is
fully tested. A `BetaLabel` badge is shown alongside the row title.

**iPad:** `areActivitiesEnabled` is always `false` on iPad — Apple
system restriction. The Settings screen shows "Not available on iPad."
The registry silently no-ops. HA receives `supports_live_activities:
false` in the device registration for iPad.

**`HALiveActivityAttributes` is frozen post-ship.** The struct name
appears as `attributes-type` in APNs push-to-start payloads. Renaming it
silently breaks all remote starts. The `ContentState` `CodingKeys` are
equally frozen — only additions are safe. Both have comments in the
source calling this out.

**The debug section in Settings is intentional.** Gated behind `#if
DEBUG` so it only appears in debug builds — it never ships to TestFlight
or the App Store. It exercises the full ActivityKit lifecycle without
requiring the server-side chain.

**`UNUserNotificationCenter` in tests.** The `clear_notification` +
`tag` → Live Activity dismissal path is covered by code review rather
than a unit test. `HandlerClearNotification` calls
`UNUserNotificationCenter.current().removeDeliveredNotifications`
synchronously, which requires a real app bundle and throws
`NSInternalInconsistencyException` in the XCTest host. A comment in the
test file explains this.

**Rate limiting on iOS 18.** Apple throttles Live Activity updates to
~15 seconds between renders. Automations should trigger on state change
events, not polling timers.

**Related:**
- Community discussion:
https://github.com/orgs/home-assistant/discussions/84
- Android companion reference: https://github.com/home-assistant/android
- Roadmap: https://github.com/home-assistant/roadmap/issues/52

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-authored-by: Bruno Pantaleão Gonçalves <5808343+bgoncal@users.noreply.github.com>
2026-04-07 14:50:49 +02:00

376 lines
15 KiB
Swift

import CallbackURLKit
import Communicator
import FirebaseMessaging
import Foundation
import PromiseKit
import Shared
import SwiftUI
import UserNotifications
import XCGLogger
class NotificationManager: NSObject, LocalPushManagerDelegate {
lazy var localPushManager: NotificationManagerLocalPushInterface = {
#if targetEnvironment(simulator)
return NotificationManagerLocalPushInterfaceDirect(delegate: self)
#else
if Current.isCatalyst {
return NotificationManagerLocalPushInterfaceDirect(delegate: self)
} else {
return NotificationManagerLocalPushInterfaceExtension()
}
#endif
}()
var commandManager = NotificationCommandManager()
override init() {
super.init()
NotificationCenter.default.addObserver(
self,
selector: #selector(didBecomeActive),
name: UIApplication.didBecomeActiveNotification,
object: nil
)
}
func setupNotifications() {
UNUserNotificationCenter.current().delegate = self
_ = localPushManager
}
@objc private func didBecomeActive() {
if Current.settingsStore.clearBadgeAutomatically {
UIApplication.shared.applicationIconBadgeNumber = 0
}
}
func resetPushID() -> Promise<String> {
firstly {
Promise<Void> { seal in
Messaging.messaging().deleteToken(completion: seal.resolve)
}
}.then {
Promise<String> { seal in
Messaging.messaging().token(completion: seal.resolve)
}
}
}
func setupFirebase() {
Current.Log.verbose("Calling UIApplication.shared.registerForRemoteNotifications()")
UIApplication.shared.registerForRemoteNotifications()
Messaging.messaging().delegate = self
Messaging.messaging().isAutoInitEnabled = Current.settingsStore.privacy.messaging
}
func didFailToRegisterForRemoteNotifications(error: Error) {
Current.Log.error("failed to register for remote notifications: \(error)")
}
func didRegisterForRemoteNotifications(deviceToken: Data) {
let apnsToken = deviceToken.map { String(format: "%02.2hhx", $0) }.joined()
Current.Log.verbose("Successfully registered for push notifications! APNS token: \(apnsToken)")
Current.crashReporter.setUserProperty(value: apnsToken, name: "APNS Token")
var tokenType: MessagingAPNSTokenType = .prod
if Current.appConfiguration == .debug {
tokenType = .sandbox
}
Messaging.messaging().setAPNSToken(deviceToken, type: tokenType)
}
func didReceiveRemoteNotification(
userInfo: [AnyHashable: Any],
fetchCompletionHandler completionHandler: @escaping (UIBackgroundFetchResult) -> Void
) {
Messaging.messaging().appDidReceiveMessage(userInfo)
firstly {
handleRemoteNotification(userInfo: userInfo)
}.done(
completionHandler
)
}
func localPushManager(
_ manager: LocalPushManager,
didReceiveRemoteNotification userInfo: [AnyHashable: Any]
) {
handleRemoteNotification(userInfo: userInfo).cauterize()
}
private func handleRemoteNotification(userInfo: [AnyHashable: Any]) -> Guarantee<UIBackgroundFetchResult> {
Current.Log.verbose("remote notification: \(userInfo)")
return commandManager.handle(userInfo).map {
UIBackgroundFetchResult.newData
}.recover { _ in
Guarantee<UIBackgroundFetchResult>.value(.failed)
}
}
fileprivate func handleShortcutNotification(
_ shortcutName: String,
_ shortcutDict: [String: String]
) {
var inputParams: CallbackURLKit.Parameters = shortcutDict
inputParams["name"] = shortcutName
Current.Log.verbose("Sending params in shortcut \(inputParams)")
let eventName = "ios.shortcut_run"
let deviceDict: [String: String] = [
"sourceDevicePermanentID": AppConstants.PermanentID, "sourceDeviceName": UIDevice.current.name,
"sourceDeviceID": Current.settingsStore.deviceID,
]
var eventData: [String: Any] = ["name": shortcutName, "input": shortcutDict, "device": deviceDict]
var successHandler: CallbackURLKit.SuccessCallback?
if shortcutDict["ignore_result"] == nil {
successHandler = { params in
Current.Log.verbose("Received params from shortcut run \(String(describing: params))")
eventData["status"] = "success"
eventData["result"] = params?["result"]
Current.Log.verbose("Success, sending data \(eventData)")
when(fulfilled: Current.apis.map { api in
api.CreateEvent(eventType: eventName, eventData: eventData)
}).catch { error in
Current.Log.error("Received error from createEvent during shortcut run \(error)")
}
}
}
let failureHandler: CallbackURLKit.FailureCallback = { error in
eventData["status"] = "failure"
eventData["error"] = error.XCUErrorParameters
when(fulfilled: Current.apis.map { api in
api.CreateEvent(eventType: eventName, eventData: eventData)
}).catch { error in
Current.Log.error("Received error from createEvent during shortcut run \(error)")
}
}
let cancelHandler: CallbackURLKit.CancelCallback = {
eventData["status"] = "cancelled"
when(fulfilled: Current.apis.map { api in
api.CreateEvent(eventType: eventName, eventData: eventData)
}).catch { error in
Current.Log.error("Received error from createEvent during shortcut run \(error)")
}
}
do {
try Manager.shared.perform(
action: "run-shortcut",
urlScheme: "shortcuts",
parameters: inputParams,
onSuccess: successHandler,
onFailure: failureHandler,
onCancel: cancelHandler
)
} catch let error as NSError {
Current.Log.error("Running shortcut failed \(error)")
eventData["status"] = "error"
eventData["error"] = error.localizedDescription
when(fulfilled: Current.apis.map { api in
api.CreateEvent(eventType: eventName, eventData: eventData)
}).catch { error in
Current.Log.error("Received error from CallbackURLKit perform \(error)")
}
}
}
}
extension NotificationManager: UNUserNotificationCenterDelegate {
private func urlString(from response: UNNotificationResponse) -> String? {
let content = response.notification.request.content
let urlValue = ["url", "uri", "clickAction"].compactMap { content.userInfo[$0] }.first
if let action = content.userInfoActionConfigs.first(
where: { $0.identifier.lowercased() == response.actionIdentifier.lowercased() }
), let url = action.url {
// we only allow the action-specific one to override global if it's set
return url
} else if let openURLRaw = urlValue as? String {
// global url [string], always do it if we aren't picking a specific action
return openURLRaw
} else if let openURLDictionary = urlValue as? [String: String] {
// old-style, per-action url -- for before we could define actions in the notification dynamically
return openURLDictionary.compactMap { key, value -> String? in
if response.actionIdentifier == UNNotificationDefaultActionIdentifier,
key.lowercased() == NotificationCategory.FallbackActionIdentifier {
return value
} else if key.lowercased() == response.actionIdentifier.lowercased() {
return value
} else {
return nil
}
}.first
} else {
return nil
}
}
public func userNotificationCenter(
_ center: UNUserNotificationCenter,
didReceive response: UNNotificationResponse,
withCompletionHandler completionHandler: @escaping () -> Void
) {
Messaging.messaging().appDidReceiveMessage(response.notification.request.content.userInfo)
guard response.actionIdentifier != UNNotificationDismissActionIdentifier else {
Current.Log.info("ignoring dismiss action for notification")
completionHandler()
return
}
let userInfo = response.notification.request.content.userInfo
Current.Log.verbose("User info in incoming notification \(userInfo) with response \(response)")
guard let server = Current.servers.server(for: response.notification.request.content) else {
Current.Log.info("ignoring push when unable to find server")
completionHandler()
return
}
if let shortcutDict = userInfo["shortcut"] as? [String: String],
let shortcutName = shortcutDict["name"] {
handleShortcutNotification(shortcutName, shortcutDict)
}
if let url = urlString(from: response) {
Current.Log.info("launching URL \(url)")
Current.sceneManager.webViewWindowControllerPromise.done {
$0.open(from: .notification, server: server, urlString: url, isComingFromAppIntent: false)
}
}
if let info = HomeAssistantAPI.PushActionInfo(response: response) {
Current.backgroundTask(withName: BackgroundTask.handlePushAction.rawValue) { _ in
Current.api(for: server)?
.handlePushAction(for: info) ?? .init(error: HomeAssistantAPI.APIError.noAPIAvailable)
}.ensure {
completionHandler()
}.catch { err in
Current.Log.error("Error when handling push action: \(err)")
}
} else {
completionHandler()
}
if response.notification.request.identifier == NotificationIdentifier.carPlayIntro.rawValue {
Current.Log.info("Launching CarPlay configuration screen")
Current.sceneManager.webViewWindowControllerPromise.done {
let carPlayView = CarPlayConfigurationView().embeddedInHostingController()
$0.present(carPlayView)
}
}
}
public func userNotificationCenter(
_ center: UNUserNotificationCenter,
willPresent notification: UNNotification,
withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void
) {
Messaging.messaging().appDidReceiveMessage(notification.request.content.userInfo)
// Handle commands (including Live Activities) for foreground notifications.
// didReceiveRemoteNotification handles background pushes via Firebase/APNs,
// but willPresent fires when the app is in the foreground. Without this,
// notifications received while the app is open would never trigger the
// Live Activity handler.
// If a command is recognized, suppress the notification banner so the user
// sees only the Live Activity (not a duplicate standard notification).
if let hadict = notification.request.content.userInfo["homeassistant"] as? [String: Any],
(hadict["command"] as? String) != nil || (hadict["live_update"] as? Bool) == true {
commandManager.handle(notification.request.content.userInfo).done {
completionHandler([])
}.catch { error in
// Unknown command fall through to normal banner presentation so the user isn't silently swallowed.
if case NotificationCommandManager.CommandError.unknownCommand = error {
completionHandler([.badge, .sound, .list, .banner])
} else {
completionHandler([])
}
}
return
}
if notification.request.content.userInfo[XCGLogger.notifyUserInfoKey] != nil,
UIApplication.shared.applicationState != .background {
completionHandler([])
return
}
var methods: UNNotificationPresentationOptions = [.badge, .sound, .list, .banner]
if let presentationOptions = notification.request.content.userInfo["presentation_options"] as? [String] {
methods = []
if presentationOptions.contains("sound") || notification.request.content.sound != nil {
methods.insert(.sound)
}
if presentationOptions.contains("badge") {
methods.insert(.badge)
}
if presentationOptions.contains("list") {
methods.insert(.list)
}
if presentationOptions.contains("banner") {
methods.insert(.banner)
}
}
return completionHandler(methods)
}
public func userNotificationCenter(
_ center: UNUserNotificationCenter,
openSettingsFor notification: UNNotification?
) {
let view = NotificationSettingsViewController()
view.doneButton = true
Current.sceneManager.webViewWindowControllerPromise.done {
var rootViewController = $0.window.rootViewController
if let navigationController = rootViewController as? UINavigationController {
rootViewController = navigationController.viewControllers.first
}
rootViewController?.dismiss(animated: false, completion: {
let navController = UINavigationController(rootViewController: view)
rootViewController?.present(navController, animated: true, completion: nil)
})
}
}
}
extension NotificationManager: MessagingDelegate {
func messaging(_ messaging: Messaging, didReceiveRegistrationToken fcmToken: String?) {
let loggableCurrent = Current.settingsStore.pushID ?? "(null)"
let loggableNew = fcmToken ?? "(null)"
Current.Log.info("Firebase registration token refreshed, new token: \(loggableNew)")
if loggableCurrent != loggableNew {
Current.Log.warning("FCM token has changed from \(loggableCurrent) to \(loggableNew)")
}
Current.crashReporter.setUserProperty(value: fcmToken, name: "FCM Token")
Current.settingsStore.pushID = fcmToken
Current.backgroundTask(withName: BackgroundTask.notificationManagerDidReceiveRegistrationToken.rawValue) { _ in
when(fulfilled: Current.apis.map { api in
api.updateRegistration()
})
}.cauterize()
}
}