Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 16 additions & 1 deletion Sources/App/Notifications/NotificationManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
2 changes: 1 addition & 1 deletion Sources/Shared/API/HAAPI.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
}
Expand Down
8 changes: 8 additions & 0 deletions Sources/Shared/LiveActivity/HALiveActivityAttributes.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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
Expand All @@ -80,6 +84,7 @@ public struct HALiveActivityAttributes: ActivityAttributes {

public init(
message: String,
title: String? = nil,
criticalText: String? = nil,
progress: Int? = nil,
progressMax: Int? = nil,
Expand All @@ -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
Expand All @@ -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)
Expand All @@ -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)
Expand Down
28 changes: 9 additions & 19 deletions Sources/Shared/LiveActivity/LiveActivityRegistry.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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<String> = ["tag", "push_token"]
static let tokenWebhookKeys: Set<String> = ["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"
Expand Down Expand Up @@ -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)")
Expand Down Expand Up @@ -290,7 +275,8 @@ public actor LiveActivityRegistry: LiveActivityRegistryProtocol {
AppConstants.Keychain[pushToStartTokenKeychainKey]
}

static let pushToStartTokenKeychainKey = "live_activity_token"
static let pushToStartRegistrationKey = "push_to_start_live_activity_token"
static let pushToStartTokenKeychainKey = "live_activity_push_to_start_token"

// MARK: - Private — Stale Date

Expand Down Expand Up @@ -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 {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -126,6 +127,7 @@ struct HandlerStartOrUpdateLiveActivity: NotificationCommandHandler {

return HALiveActivityAttributes.ContentState(
message: message,
title: title,
criticalText: criticalText,
progress: progress,
progressMax: progressMax,
Expand Down
11 changes: 11 additions & 0 deletions Sources/SharedPush/Sources/NotificationParserLegacy.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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 {
Expand Down
3 changes: 3 additions & 0 deletions Tests/Shared/LiveActivity/HandlerLiveActivityTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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,
Expand All @@ -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)
Expand Down
24 changes: 22 additions & 2 deletions Tests/Shared/LiveActivity/LiveActivityContractTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -40,6 +41,7 @@ final class LiveActivityContractTests: XCTestCase {

// These keys must match the Android notification field names exactly.
let expectedKeys: Set<String> = [
"title",
"message",
"critical_text",
"progress",
Expand All @@ -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,
Expand All @@ -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,
"push_to_start_live_activity_token"
)
}

Expand All @@ -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
)
}

Expand Down
Loading