Files
iOS/Sources/Shared/Settings/SettingsStore.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

532 lines
17 KiB
Swift

import CoreLocation
import CoreMotion
import Foundation
import KeychainAccess
import UIKit
import Version
public class SettingsStore {
let keychain = AppConstants.Keychain
let prefs = UserDefaults(suiteName: AppConstants.AppGroupID)!
/// These will only be posted on the main thread
public static let webViewRelatedSettingDidChange: Notification.Name = .init("webViewRelatedSettingDidChange")
public static let menuRelatedSettingDidChange: Notification.Name = .init("menuRelatedSettingDidChange")
public static let locationRelatedSettingDidChange: Notification.Name = .init("locationRelatedSettingDidChange")
public var pushID: String? {
get {
prefs.string(forKey: "pushID")
}
set {
prefs.setValue(newValue, forKeyPath: "pushID")
}
}
public var integrationDeviceID: String {
let baseString = Current.device.identifierForVendor() ?? deviceID
switch Current.appConfiguration {
case .beta:
return "beta_" + baseString
case .debug:
return "debug_" + baseString
case .fastlaneSnapshot, .release:
return baseString
}
}
public var deviceID: String {
get {
keychain["deviceID"] ?? defaultDeviceID
}
set {
keychain["deviceID"] = newValue
}
}
#if os(iOS)
public var matterLastPreferredNetWorkMacExtendedAddress: String? {
get {
keychain["matterLastPreferredNetWorkMacExtendedAddress"]
}
set {
keychain["matterLastPreferredNetWorkMacExtendedAddress"] = newValue
}
}
public var matterLastPreferredNetWorkActiveOperationalDataset: String? {
get {
keychain["matterLastPreferredNetWorkActiveOperationalDataset"]
}
set {
keychain["matterLastPreferredNetWorkActiveOperationalDataset"] = newValue
}
}
public var matterLastPreferredNetWorkExtendedPANID: String? {
get {
keychain["matterLastPreferredNetWorkExtendedPANID"]
}
set {
keychain["matterLastPreferredNetWorkExtendedPANID"] = newValue
}
}
public func isLocationEnabled(for state: UIApplication.State) -> Bool {
let authorizationStatus: CLAuthorizationStatus
let locationManager = CLLocationManager()
authorizationStatus = locationManager.authorizationStatus
switch authorizationStatus {
case .authorizedAlways:
return true
case .authorizedWhenInUse:
switch state {
case .active, .inactive:
return true
case .background:
return false
@unknown default:
return false
}
case .denied, .notDetermined, .restricted:
return false
@unknown default:
return false
}
}
#endif
public struct PageZoom: CaseIterable, Equatable, CustomStringConvertible, Hashable {
public let zoom: Int
init?(preference: Int) {
guard Self.allCases.contains(where: { $0.zoom == preference }) else {
// in case one of the options causes problems, removing it from allCases will kill it
Current.Log.info("disregarding zoom preference for \(preference)")
return nil
}
self.zoom = preference
}
init(_ zoom: IntegerLiteralType) {
self.zoom = zoom
}
public var description: String {
let zoomString = String(format: "%d%%", zoom)
if zoom == 100 {
return L10n.SettingsDetails.General.PageZoom.default(zoomString)
} else {
return zoomString
}
}
public var viewScaleValue: String {
String(format: "%.02f", CGFloat(zoom) / 100.0)
}
public static let `default`: PageZoom = .init(100)
public static let allCases: [PageZoom] = [
// similar zooms to Safari, but with nothing above 200%
.init(50), .init(75), .init(85),
.init(100), .init(115), .init(125), .init(150), .init(175),
.init(200),
]
}
public var pageZoom: PageZoom {
get {
if let pageZoom = PageZoom(preference: prefs.integer(forKey: "page_zoom")) {
return pageZoom
} else {
return .default
}
}
set {
precondition(Thread.isMainThread)
prefs.set(newValue.zoom, forKey: "page_zoom")
NotificationCenter.default.post(name: Self.webViewRelatedSettingDidChange, object: nil)
}
}
public var pinchToZoom: Bool {
get {
prefs.bool(forKey: "pinchToZoom")
}
set {
prefs.set(newValue, forKey: "pinchToZoom")
NotificationCenter.default.post(name: Self.webViewRelatedSettingDidChange, object: nil)
}
}
public var restoreLastURL: Bool {
get {
if let value = prefs.object(forKey: "restoreLastURL") as? NSNumber {
return value.boolValue
} else {
return true
}
}
set {
prefs.set(newValue, forKey: "restoreLastURL")
}
}
public var fullScreen: Bool {
get {
prefs.bool(forKey: "fullScreen")
}
set {
prefs.set(newValue, forKey: "fullScreen")
NotificationCenter.default.post(name: Self.webViewRelatedSettingDidChange, object: nil)
}
}
public var edgeToEdge: Bool {
get {
prefs.bool(forKey: "edgeToEdge_experimental")
}
set {
prefs.set(newValue, forKey: "edgeToEdge_experimental")
NotificationCenter.default.post(name: Self.webViewRelatedSettingDidChange, object: nil)
}
}
public var refreshWebViewAfterInactive: Bool {
get {
if let value = prefs.object(forKey: "refreshWebViewAfterInactive") as? NSNumber {
return value.boolValue
} else {
return true // Default to ON
}
}
set {
prefs.set(newValue, forKey: "refreshWebViewAfterInactive")
}
}
public var macNativeFeaturesOnly: Bool {
get {
prefs.bool(forKey: "macNativeFeaturesOnly")
}
set {
prefs.set(newValue, forKey: "macNativeFeaturesOnly")
}
}
/// Whether the one-time Live Activity lock screen privacy disclosure has been shown.
/// Set to true after the first Live Activity is started; never reset.
public var hasSeenLiveActivityDisclosure: Bool {
get {
prefs.bool(forKey: "hasSeenLiveActivityDisclosure")
}
set {
prefs.set(newValue, forKey: "hasSeenLiveActivityDisclosure")
}
}
/// Local push becomes opt-in on 2025.6, users will have local push reset and need to re-enable it
public var migratedOptInLocalPush: Bool {
get {
prefs.bool(forKey: "migratedOptInLocalPush")
}
set {
prefs.set(newValue, forKey: "migratedOptInLocalPush")
}
}
public var periodicUpdateInterval: TimeInterval? {
get {
if prefs.object(forKey: "periodicUpdateInterval") == nil {
return 300.0
} else {
let doubleValue = prefs.double(forKey: "periodicUpdateInterval")
return doubleValue > 0 ? doubleValue : nil
}
}
set {
if let newValue {
precondition(newValue > 0)
prefs.set(newValue, forKey: "periodicUpdateInterval")
} else {
prefs.set(-1, forKey: "periodicUpdateInterval")
}
}
}
public struct Privacy {
public var messaging: Bool
public var crashes: Bool
public var analytics: Bool
public var alerts: Bool
public var updates: Bool
public var updatesIncludeBetas: Bool
static func key(for keyPath: KeyPath<Privacy, Bool>) -> String {
switch keyPath {
case \.messaging: return "messagingEnabled"
case \.crashes: return "crashesEnabled"
case \.analytics: return "analyticsEnabled"
case \.alerts: return "alertsEnabled"
case \.updates: return "updateCheckingEnabled"
case \.updatesIncludeBetas: return "updatesIncludeBetas"
default: return ""
}
}
static func `default`(for keyPath: KeyPath<Privacy, Bool>) -> Bool {
switch keyPath {
case \.messaging: return true
case \.crashes: return false
case \.analytics: return false
case \.alerts: return true
case \.updates: return true
case \.updatesIncludeBetas: return true
default: return false
}
}
}
public var privacy: Privacy {
get {
func boolValue(for keyPath: KeyPath<Privacy, Bool>) -> Bool {
let key = Privacy.key(for: keyPath)
if prefs.object(forKey: key) == nil {
// value never set, use the default for this one
return Privacy.default(for: keyPath)
}
return prefs.bool(forKey: key)
}
return .init(
messaging: boolValue(for: \.messaging),
crashes: boolValue(for: \.crashes),
analytics: boolValue(for: \.analytics),
alerts: boolValue(for: \.alerts),
updates: boolValue(for: \.updates),
updatesIncludeBetas: boolValue(for: \.updatesIncludeBetas)
)
}
set {
prefs.set(newValue.messaging, forKey: Privacy.key(for: \.messaging))
prefs.set(newValue.crashes, forKey: Privacy.key(for: \.crashes))
prefs.set(newValue.analytics, forKey: Privacy.key(for: \.analytics))
prefs.set(newValue.alerts, forKey: Privacy.key(for: \.alerts))
prefs.set(newValue.updates, forKey: Privacy.key(for: \.updates))
prefs.set(newValue.updatesIncludeBetas, forKey: Privacy.key(for: \.updatesIncludeBetas))
Current.Log.info("privacy updated to \(newValue)")
}
}
public enum LocationVisibility: String, CaseIterable {
case dock
case dockAndMenuBar
case menuBar
public var isStatusItemVisible: Bool {
switch self {
case .dockAndMenuBar, .menuBar: return true
case .dock: return false
}
}
public var isDockVisible: Bool {
switch self {
case .dockAndMenuBar, .dock: return true
case .menuBar: return false
}
}
public var title: String {
switch self {
case .dock: return L10n.SettingsDetails.General.Visibility.Options.dock
case .dockAndMenuBar: return L10n.SettingsDetails.General.Visibility.Options.dockAndMenuBar
case .menuBar: return L10n.SettingsDetails.General.Visibility.Options.menuBar
}
}
}
public var locationVisibility: LocationVisibility {
get {
prefs.string(forKey: "locationVisibility").flatMap(LocationVisibility.init(rawValue:)) ?? .dock
}
set {
prefs.set(newValue.rawValue, forKey: "locationVisibility")
NotificationCenter.default.post(
name: Self.menuRelatedSettingDidChange,
object: nil,
userInfo: nil
)
}
}
public var menuItemTemplate: (server: Server, template: String)? {
get {
let server: Server?
if let serverIdentifier = prefs.string(forKey: "menuItemTemplate-server"),
let configured = Current.servers.server(forServerIdentifier: serverIdentifier) {
server = configured
} else {
// backwards compatibility to before servers, or if the server was deleted
server = Current.servers.all.first
}
if let server {
return (server, prefs.string(forKey: "menuItemTemplate") ?? "")
} else {
return nil
}
}
set {
prefs.setValue(newValue?.0.identifier.rawValue, forKey: "menuItemTemplate-server")
prefs.setValue(newValue?.1, forKey: "menuItemTemplate")
NotificationCenter.default.post(
name: Self.menuRelatedSettingDidChange,
object: nil,
userInfo: nil
)
}
}
public struct LocationSource {
public var zone: Bool
public var backgroundFetch: Bool
public var significantLocationChange: Bool
public var pushNotifications: Bool
static func key(for keyPath: KeyPath<LocationSource, Bool>) -> String {
switch keyPath {
case \.zone: return "locationUpdateOnZone"
case \.backgroundFetch: return "locationUpdateOnBackgroundFetch"
case \.significantLocationChange: return "locationUpdateOnSignificant"
case \.pushNotifications: return "locationUpdateOnNotification"
default: return ""
}
}
}
public var locationSources: LocationSource {
get {
func boolValue(for keyPath: KeyPath<LocationSource, Bool>) -> Bool {
let key = LocationSource.key(for: keyPath)
if prefs.object(forKey: key) == nil {
// default to enabled for location source settings
return true
}
return prefs.bool(forKey: key)
}
return .init(
zone: boolValue(for: \.zone),
backgroundFetch: boolValue(for: \.backgroundFetch),
significantLocationChange: boolValue(for: \.significantLocationChange),
pushNotifications: boolValue(for: \.pushNotifications)
)
}
set {
prefs.set(newValue.zone, forKey: LocationSource.key(for: \.zone))
prefs.set(newValue.backgroundFetch, forKey: LocationSource.key(for: \.backgroundFetch))
prefs.set(newValue.significantLocationChange, forKey: LocationSource.key(for: \.significantLocationChange))
prefs.set(newValue.pushNotifications, forKey: LocationSource.key(for: \.pushNotifications))
Current.Log.info("location sources updated to \(newValue)")
NotificationCenter.default.post(name: Self.locationRelatedSettingDidChange, object: nil)
}
}
public var clearBadgeAutomatically: Bool {
get {
if let value = prefs.object(forKey: "clearBadgeAutomatically") as? NSNumber {
return value.boolValue
} else {
return true
}
}
set {
prefs.set(newValue, forKey: "clearBadgeAutomatically")
}
}
public var widgetAuthenticityToken: String {
let key = "widgetAuthenticityToken"
if let existing = prefs.string(forKey: key) {
return existing
} else {
let string = UUID().uuidString
prefs.set(string, forKey: key)
return string
}
}
#if os(iOS)
public var gestures: [AppGesture: HAGestureAction] {
get {
guard let data = prefs.data(forKey: "gesturesSettings"),
let decodedGestures = try? JSONDecoder().decode([AppGesture: HAGestureAction].self, from: data) else {
Current.Log.error("Failed to decode gestures from settings")
return .defaultGestures
}
return decodedGestures
}
set {
do {
let encoded = try JSONEncoder().encode(newValue)
prefs.set(encoded, forKey: "gesturesSettings")
} catch {
Current.Log.error("Failed to encode gestures for settings, error \(error.localizedDescription)")
}
}
}
#endif
// MARK: - Debug settings
/// Debug options to receive local notifications when something goes wrong
/// e.g. location update in background fails
public var receiveDebugNotifications: Bool {
get {
prefs.bool(forKey: "receiveDebugNotifications")
}
set {
prefs.set(newValue, forKey: "receiveDebugNotifications")
}
}
/// Debug option to enable toasts handled by the app instead of the web frontend
public var toastsHandledByApp: Bool {
get {
prefs.bool(forKey: "toastsHandledByApp")
}
set {
prefs.set(newValue, forKey: "toastsHandledByApp")
}
}
// MARK: - Private helpers
private var defaultDeviceID: String {
let baseID = removeSpecialCharsFromString(text: Current.device.deviceName())
.replacingOccurrences(of: " ", with: "_")
.lowercased()
if Current.appConfiguration != .release {
return "\(baseID)_\(Current.appConfiguration.description.lowercased())"
}
return baseID
}
private func removeSpecialCharsFromString(text: String) -> String {
let okayChars: Set<Character> =
Set("abcdefghijklmnopqrstuvwxyz ABCDEFGHIJKLKMNOPQRSTUVWXYZ1234567890")
return String(text.filter { okayChars.contains($0) })
}
}