diff --git a/Sources/App/Notifications/NotificationManager.swift b/Sources/App/Notifications/NotificationManager.swift index 0a9a49d50e..2dd508be68 100644 --- a/Sources/App/Notifications/NotificationManager.swift +++ b/Sources/App/Notifications/NotificationManager.swift @@ -8,8 +8,18 @@ import SwiftUI import UserNotifications import XCGLogger +#if DEBUG +private let forceDisableLocalPushForLiveActivityTesting = false +#endif + class NotificationManager: NSObject, LocalPushManagerDelegate { lazy var localPushManager: NotificationManagerLocalPushInterface = { + #if DEBUG + if forceDisableLocalPushForLiveActivityTesting { + return NotificationManagerLocalPushInterfaceDisallowed() + } + #endif + #if targetEnvironment(simulator) return NotificationManagerLocalPushInterfaceDirect(delegate: self) #else @@ -464,7 +474,12 @@ extension NotificationManager: UNUserNotificationCenterDelegate { 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([]) + // Play the chime if the notification has sound (non-silent live update), + // but never show a banner — the Live Activity widget is the visual feedback. + let options: UNNotificationPresentationOptions = notification.request.content.sound != nil + ? [.sound] + : [] + completionHandler(options) }.catch { error in // Unknown command — fall through to normal banner presentation so the user isn't silently swallowed. if case NotificationCommandManager.CommandError.unknownCommand = error { diff --git a/Sources/Extensions/Widgets/LiveActivity/HADynamicIslandView.swift b/Sources/Extensions/Widgets/LiveActivity/HADynamicIslandView.swift index 90119144de..17e0958789 100644 --- a/Sources/Extensions/Widgets/LiveActivity/HADynamicIslandView.swift +++ b/Sources/Extensions/Widgets/LiveActivity/HADynamicIslandView.swift @@ -19,7 +19,7 @@ func makeHADynamicIsland( .padding(.leading, DesignSystem.Spaces.one) } DynamicIslandExpandedRegion(.center) { - Text(attributes.title) + Text(state.title ?? attributes.title) .font(.body.weight(.semibold)) .foregroundStyle(.white) .lineLimit(1) diff --git a/Sources/Extensions/Widgets/LiveActivity/HALockScreenView.swift b/Sources/Extensions/Widgets/LiveActivity/HALockScreenView.swift index b6b1d61758..b681e3c6cc 100644 --- a/Sources/Extensions/Widgets/LiveActivity/HALockScreenView.swift +++ b/Sources/Extensions/Widgets/LiveActivity/HALockScreenView.swift @@ -24,7 +24,7 @@ struct HALockScreenView: View { iconContainer VStack(alignment: .leading, spacing: DesignSystem.Spaces.half) { - Text(attributes.title) + Text(state.title ?? attributes.title) .font(.title3.weight(.semibold)) .foregroundStyle(primaryTextColor) .lineLimit(1) diff --git a/Sources/Shared/API/HAAPI.swift b/Sources/Shared/API/HAAPI.swift index df0ea0c105..e00bb5874c 100644 --- a/Sources/Shared/API/HAAPI.swift +++ b/Sources/Shared/API/HAAPI.swift @@ -602,7 +602,7 @@ public class HomeAssistantAPI { // Push-to-start token (stored in Keychain at launch, updated via stream). // The relay server uses this token to start a Live Activity entirely via APNs. if let pushToStartToken = LiveActivityRegistry.storedPushToStartToken { - appData["live_activity_token"] = pushToStartToken + appData[LiveActivityRegistry.pushToStartRegistrationKey] = pushToStartToken appData["live_activity_push_to_start_apns_environment"] = Current.apnsEnvironment } } diff --git a/Sources/Shared/LiveActivity/HALiveActivityAttributes.swift b/Sources/Shared/LiveActivity/HALiveActivityAttributes.swift index 0387d2ce7c..c936f63b9d 100644 --- a/Sources/Shared/LiveActivity/HALiveActivityAttributes.swift +++ b/Sources/Shared/LiveActivity/HALiveActivityAttributes.swift @@ -26,6 +26,9 @@ public struct HALiveActivityAttributes: ActivityAttributes { /// 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 @@ -66,6 +69,7 @@ public struct HALiveActivityAttributes: ActivityAttributes { /// 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 @@ -80,6 +84,7 @@ public struct HALiveActivityAttributes: ActivityAttributes { public init( message: String, + title: String? = nil, criticalText: String? = nil, progress: Int? = nil, progressMax: Int? = nil, @@ -88,6 +93,7 @@ public struct HALiveActivityAttributes: ActivityAttributes { icon: String? = nil, color: String? = nil ) { + self.title = title self.message = message self.criticalText = criticalText self.progress = progress @@ -106,6 +112,7 @@ public struct HALiveActivityAttributes: ActivityAttributes { // 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) @@ -122,6 +129,7 @@ public struct HALiveActivityAttributes: ActivityAttributes { 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) diff --git a/Sources/Shared/LiveActivity/LiveActivityRegistry.swift b/Sources/Shared/LiveActivity/LiveActivityRegistry.swift index 38258d6bdb..c88315530b 100644 --- a/Sources/Shared/LiveActivity/LiveActivityRegistry.swift +++ b/Sources/Shared/LiveActivity/LiveActivityRegistry.swift @@ -45,7 +45,9 @@ public actor LiveActivityRegistry: LiveActivityRegistryProtocol { /// Webhook type for reporting a new per-activity push token to HA. static let webhookTypeToken = "live_activity_token" /// Keys in the token webhook request data dictionary. - static let tokenWebhookKeys: Set = ["tag", "push_token"] + static let tokenWebhookKeys: Set = ["tag", "push_token", "expires_at"] + /// ActivityKit expires Live Activities after eight hours. + static let pushTokenTimeToLive: TimeInterval = 8 * 60 * 60 /// Webhook type for reporting that a Live Activity was dismissed. static let webhookTypeDismissed = "live_activity_dismissed" @@ -181,23 +183,6 @@ public actor LiveActivityRegistry: LiveActivityRegistryProtocol { throw error } - // Immediately update with an AlertConfiguration to trigger the expanded Dynamic Island - // presentation. Activity.request() only shows the compact view (small pill around the - // camera cutout). The expanded "bloom" animation requires an update with an alert config. - let alertContent = ActivityContent( - state: state, - staleDate: computeStaleDate(for: state), - relevanceScore: 0.5 - ) - // iOS 26 SDK changed AlertConfiguration.sound from optional to non-optional. - // Use .default so the expanded Dynamic Island "bloom" has a subtle alert sound. - let alertConfig = AlertConfiguration( - title: LocalizedStringResource(stringLiteral: title), - body: LocalizedStringResource(stringLiteral: state.message), - sound: .default - ) - await activity.update(alertContent, alertConfiguration: alertConfig) - let observationTask = makeObservationTask(for: activity) await confirmReservation(id: tag, entry: Entry(activity: activity, observationTask: observationTask)) Current.Log.verbose("LiveActivityRegistry: started activity for tag \(tag), id=\(activity.id)") @@ -290,7 +275,8 @@ public actor LiveActivityRegistry: LiveActivityRegistryProtocol { AppConstants.Keychain[pushToStartTokenKeychainKey] } - static let pushToStartTokenKeychainKey = "live_activity_token" + static let pushToStartRegistrationKey = "start_live_activity_token" + static let pushToStartTokenKeychainKey = "live_activity_push_to_start_token" // MARK: - Private — Stale Date @@ -355,11 +341,15 @@ public actor LiveActivityRegistry: LiveActivityRegistryProtocol { /// Report a new activity push token to all connected HA servers. /// The token is used by the relay server to send APNs updates directly to this activity. private func reportPushToken(_ tokenHex: String, tag: String) async { + let expiresAt = Current.date() + .addingTimeInterval(Self.pushTokenTimeToLive) + .timeIntervalSince1970.rounded(.down) let request = WebhookRequest( type: Self.webhookTypeToken, data: [ "tag": tag, "push_token": tokenHex, + "expires_at": expiresAt, ] ) for server in Current.servers.all { diff --git a/Sources/Shared/Notifications/NotificationCommands/HandlerLiveActivity.swift b/Sources/Shared/Notifications/NotificationCommands/HandlerLiveActivity.swift index d1729b9921..4663dd25c9 100644 --- a/Sources/Shared/Notifications/NotificationCommands/HandlerLiveActivity.swift +++ b/Sources/Shared/Notifications/NotificationCommands/HandlerLiveActivity.swift @@ -103,6 +103,7 @@ struct HandlerStartOrUpdateLiveActivity: NotificationCommandHandler { // MARK: - Payload Parsing static func contentState(from payload: [String: Any]) -> HALiveActivityAttributes.ContentState { + let title = payload["title"] as? String 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. @@ -126,6 +127,7 @@ struct HandlerStartOrUpdateLiveActivity: NotificationCommandHandler { return HALiveActivityAttributes.ContentState( message: message, + title: title, criticalText: criticalText, progress: progress, progressMax: progressMax, diff --git a/Sources/SharedPush/Sources/NotificationParserLegacy.swift b/Sources/SharedPush/Sources/NotificationParserLegacy.swift index e623a2e335..4eb5efcca2 100644 --- a/Sources/SharedPush/Sources/NotificationParserLegacy.swift +++ b/Sources/SharedPush/Sources/NotificationParserLegacy.swift @@ -232,6 +232,7 @@ public struct LegacyNotificationParserImpl: LegacyNotificationParser { for key in [ "tag", "critical_text", "progress", "progress_max", "chronometer", "when", "when_relative", "notification_icon", "notification_icon_color", + "silent", ] { if let value = data[key] { homeassistant[key] = value @@ -244,6 +245,16 @@ public struct LegacyNotificationParserImpl: LegacyNotificationParser { homeassistant["message"] = message } payload["homeassistant"] = homeassistant + + if data["silent"] as? Bool == true { + payload.mutateInside("aps") { aps in + aps.mutateInside("alert") { alert in + alert["body"] = nil + alert["title"] = "" + } + aps["sound"] = nil + } + } } if registrationInfo["os_version"]?.starts(with: "10.15") == true { diff --git a/Tests/Shared/LiveActivity/HandlerLiveActivityTests.swift b/Tests/Shared/LiveActivity/HandlerLiveActivityTests.swift index a22b77b5bd..a8d94fba70 100644 --- a/Tests/Shared/LiveActivity/HandlerLiveActivityTests.swift +++ b/Tests/Shared/LiveActivity/HandlerLiveActivityTests.swift @@ -76,6 +76,7 @@ final class HandlerStartOrUpdateLiveActivityTests: XCTestCase { func testContentState_minimalPayload_usesDefaults() { let state = HandlerStartOrUpdateLiveActivity.contentState(from: [:]) + XCTAssertNil(state.title) XCTAssertEqual(state.message, "") XCTAssertNil(state.criticalText) XCTAssertNil(state.progress) @@ -88,6 +89,7 @@ final class HandlerStartOrUpdateLiveActivityTests: XCTestCase { func testContentState_fullPayload_mapsAllFields() { let payload: [String: Any] = [ + "title": "Test title", "message": "Test message", "critical_text": "CRITICAL", "progress": 42, @@ -97,6 +99,7 @@ final class HandlerStartOrUpdateLiveActivityTests: XCTestCase { "notification_icon_color": "#FF5733", ] let state = HandlerStartOrUpdateLiveActivity.contentState(from: payload) + XCTAssertEqual(state.title, "Test title") XCTAssertEqual(state.message, "Test message") XCTAssertEqual(state.criticalText, "CRITICAL") XCTAssertEqual(state.progress, 42) diff --git a/Tests/Shared/LiveActivity/LiveActivityContractTests.swift b/Tests/Shared/LiveActivity/LiveActivityContractTests.swift index c28c2373cc..baf8a73b26 100644 --- a/Tests/Shared/LiveActivity/LiveActivityContractTests.swift +++ b/Tests/Shared/LiveActivity/LiveActivityContractTests.swift @@ -25,6 +25,7 @@ final class LiveActivityContractTests: XCTestCase { func testContentState_codingKeys_areFrozen() { let state = HALiveActivityAttributes.ContentState( message: "test", + title: "Title", criticalText: "ct", progress: 1, progressMax: 2, @@ -40,6 +41,7 @@ final class LiveActivityContractTests: XCTestCase { // These keys must match the Android notification field names exactly. let expectedKeys: Set = [ + "title", "message", "critical_text", "progress", @@ -56,6 +58,7 @@ final class LiveActivityContractTests: XCTestCase { func testContentState_roundTrip_preservesAllFields() { let original = HALiveActivityAttributes.ContentState( message: "Cycle in progress", + title: "Washer", criticalText: "45 min", progress: 2700, progressMax: 3600, @@ -81,7 +84,16 @@ final class LiveActivityContractTests: XCTestCase { func testPushToStartTokenKeychainKey_isFrozen() { XCTAssertEqual( LiveActivityRegistry.pushToStartTokenKeychainKey, - "live_activity_push_to_start_token" + "live_activity_token" + ) + } + + /// Registration app_data key for the push-to-start token. + /// Must match what HA core stores for starts before a per-activity token exists. + func testPushToStartRegistrationKey_isFrozen() { + XCTAssertEqual( + LiveActivityRegistry.pushToStartRegistrationKey, + "start_live_activity_token" ) } @@ -99,7 +111,15 @@ final class LiveActivityContractTests: XCTestCase { func testTokenWebhookKeys_areFrozen() { XCTAssertEqual( LiveActivityRegistry.tokenWebhookKeys, - ["tag", "push_token"] + ["tag", "push_token", "expires_at"] + ) + } + + /// The iOS app owns the token expiry window it sends to HA core. + func testTokenWebhookExpiry_isEightHours() { + XCTAssertEqual( + LiveActivityRegistry.pushTokenTimeToLive, + 8 * 60 * 60 ) }