From 3f30ee2ac7e4b2ecf4b734b65ac688a802f6c7be Mon Sep 17 00:00:00 2001 From: rwarner Date: Thu, 7 May 2026 09:42:51 -0400 Subject: [PATCH 1/2] =?UTF-8?q?Remove=20end=5Flive=5Factivity=20command=20?= =?UTF-8?q?=E2=80=94=20clear=5Fnotification=20handles=20it?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit clear_notification with a tag already ends Live Activities via HandlerClearNotification, making end_live_activity redundant. Removes HandlerEndLiveActivity and its registration. Co-Authored-By: Claude Sonnet 4.6 --- .../HandlerLiveActivity.swift | 45 ------------------- .../NotificationsCommandManager.swift | 1 - 2 files changed, 46 deletions(-) diff --git a/Sources/Shared/Notifications/NotificationCommands/HandlerLiveActivity.swift b/Sources/Shared/Notifications/NotificationCommands/HandlerLiveActivity.swift index 0d710150f..d1729b992 100644 --- a/Sources/Shared/Notifications/NotificationCommands/HandlerLiveActivity.swift +++ b/Sources/Shared/Notifications/NotificationCommands/HandlerLiveActivity.swift @@ -137,49 +137,4 @@ struct HandlerStartOrUpdateLiveActivity: NotificationCommandHandler { } } -// MARK: - HandlerEndLiveActivity - -/// Handles explicit `end_live_activity` commands. -/// Note: the `clear_notification` + `tag` dismiss flow is handled in `HandlerClearNotification`. -@available(iOS 17.2, *) -struct HandlerEndLiveActivity: NotificationCommandHandler { - func handle(_ payload: [String: Any]) -> Promise { - guard !Current.isAppExtension else { - return .value(()) - } - - return Promise { seal in - Task { - guard let tag = payload["tag"] as? String, !tag.isEmpty, - HandlerStartOrUpdateLiveActivity.isValidTag(tag) else { - seal.fulfill(()) - return - } - - let policy = Self.dismissalPolicy(from: payload) - await Current.liveActivityRegistry?.end(tag: tag, dismissalPolicy: policy) - seal.fulfill(()) - } - } - } - - private static func dismissalPolicy(from payload: [String: Any]) -> ActivityUIDismissalPolicy { - switch payload["dismissal_policy"] as? String { - case "default": - return .default - case let str where str?.hasPrefix("after:") == true: - if let timestampStr = str?.dropFirst(6), - let timestamp = Double(timestampStr) { - // Cap to 24 hours — iOS enforces its own maximum, but this prevents - // a far-future date from lingering in the dismissed activities list - // longer than intended if Apple ever relaxes the OS limit. - let maxDate = Date().addingTimeInterval(24 * 60 * 60) - return .after(min(Date(timeIntervalSince1970: timestamp), maxDate)) - } - return .immediate - default: - return .immediate - } - } -} #endif diff --git a/Sources/Shared/Notifications/NotificationCommands/NotificationsCommandManager.swift b/Sources/Shared/Notifications/NotificationCommands/NotificationsCommandManager.swift index 9760f49b2..8e52c0112 100644 --- a/Sources/Shared/Notifications/NotificationCommands/NotificationsCommandManager.swift +++ b/Sources/Shared/Notifications/NotificationCommands/NotificationsCommandManager.swift @@ -25,7 +25,6 @@ public class NotificationCommandManager { #if !targetEnvironment(macCatalyst) if #available(iOS 17.2, *) { register(command: "live_activity", handler: HandlerStartOrUpdateLiveActivity()) - register(command: "end_live_activity", handler: HandlerEndLiveActivity()) } #endif #endif From ce051cae5ec9c9c5f84ab2f8ef4fe1ec8a83cfb4 Mon Sep 17 00:00:00 2001 From: rwarner Date: Thu, 7 May 2026 09:49:15 -0400 Subject: [PATCH 2/2] =?UTF-8?q?Remove=20end=5Flive=5Factivity=20tests=20?= =?UTF-8?q?=E2=80=94=20handler=20no=20longer=20exists?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Drops HandlerEndLiveActivityTests and all end_live_activity routing tests. clear_notification with a tag covers the dismissal path. Co-Authored-By: Claude Sonnet 4.6 --- .../HandlerLiveActivityTests.swift | 111 ------------------ .../LiveActivityContractTests.swift | 9 -- ...tionsCommandManagerLiveActivityTests.swift | 27 +---- 3 files changed, 1 insertion(+), 146 deletions(-) diff --git a/Tests/Shared/LiveActivity/HandlerLiveActivityTests.swift b/Tests/Shared/LiveActivity/HandlerLiveActivityTests.swift index 5f563cdee..a22b77b5b 100644 --- a/Tests/Shared/LiveActivity/HandlerLiveActivityTests.swift +++ b/Tests/Shared/LiveActivity/HandlerLiveActivityTests.swift @@ -216,115 +216,4 @@ final class HandlerStartOrUpdateLiveActivityTests: XCTestCase { } } -// MARK: - HandlerEndLiveActivity Tests - -@available(iOS 17.2, *) -final class HandlerEndLiveActivityTests: XCTestCase { - private var sut: HandlerEndLiveActivity! - private var mockRegistry: MockLiveActivityRegistry! - - override func setUp() { - super.setUp() - sut = HandlerEndLiveActivity() - mockRegistry = MockLiveActivityRegistry() - Current.liveActivityRegistry = mockRegistry - Current.isAppExtension = false - } - - override func tearDown() { - sut = nil - mockRegistry = nil - super.tearDown() - } - - // MARK: - App extension guard - - func testHandle_inAppExtension_skipsRegistryAndFulfills() { - Current.isAppExtension = true - let payload: [String: Any] = ["tag": "test-tag"] - XCTAssertNoThrow(try hang(sut.handle(payload))) - XCTAssertTrue(mockRegistry.endCalls.isEmpty) - } - - // MARK: - Tag validation - - func testHandle_missingTag_fulfillsWithoutCallingRegistry() { - XCTAssertNoThrow(try hang(sut.handle([:]))) - XCTAssertTrue(mockRegistry.endCalls.isEmpty) - } - - func testHandle_emptyTag_fulfillsWithoutCallingRegistry() { - let payload: [String: Any] = ["tag": ""] - XCTAssertNoThrow(try hang(sut.handle(payload))) - XCTAssertTrue(mockRegistry.endCalls.isEmpty) - } - - func testHandle_invalidTag_fulfillsWithoutCallingRegistry() { - let payload: [String: Any] = ["tag": "bad tag!"] - XCTAssertNoThrow(try hang(sut.handle(payload))) - XCTAssertTrue(mockRegistry.endCalls.isEmpty) - } - - // MARK: - Dismissal policy - - func testHandle_noDismissalPolicy_usesImmediate() { - let payload: [String: Any] = ["tag": "end-tag"] - XCTAssertNoThrow(try hang(sut.handle(payload))) - XCTAssertEqual(mockRegistry.endCalls.count, 1) - XCTAssertEqual(mockRegistry.endCalls[0].tag, "end-tag") - XCTAssertTrue(mockRegistry.endCalls[0].policyIsImmediate) - } - - func testHandle_defaultDismissalPolicy_usesDefault() { - let payload: [String: Any] = ["tag": "end-tag", "dismissal_policy": "default"] - XCTAssertNoThrow(try hang(sut.handle(payload))) - XCTAssertEqual(mockRegistry.endCalls.count, 1) - XCTAssertTrue(mockRegistry.endCalls[0].policyIsDefault) - } - - func testHandle_afterDismissalPolicy_usesAfterPolicy() { - let future = Date().addingTimeInterval(60) - let payload: [String: Any] = [ - "tag": "end-tag", - "dismissal_policy": "after:\(future.timeIntervalSince1970)", - ] - XCTAssertNoThrow(try hang(sut.handle(payload))) - XCTAssertEqual(mockRegistry.endCalls.count, 1) - // Verify an .after policy was chosen (not .immediate or .default) - XCTAssertTrue(mockRegistry.endCalls[0].policyIsAfter) - // Verify the stored policy matches the expected date (ActivityUIDismissalPolicy is Equatable) - let expectedDate = Date(timeIntervalSince1970: future.timeIntervalSince1970) - XCTAssertEqual(mockRegistry.endCalls[0].policy, .after(expectedDate)) - } - - func testHandle_afterDismissalPolicy_capsAt24Hours() { - // A timestamp 48 hours in the future should be capped to ≤24 hours - let farFuture = Date().addingTimeInterval(48 * 60 * 60) - let payload: [String: Any] = [ - "tag": "end-tag", - "dismissal_policy": "after:\(farFuture.timeIntervalSince1970)", - ] - XCTAssertNoThrow(try hang(sut.handle(payload))) - XCTAssertEqual(mockRegistry.endCalls.count, 1) - let call = mockRegistry.endCalls[0] - // The policy should be .after (not .immediate), confirming it wasn't discarded - XCTAssertTrue(call.policyIsAfter) - // The stored date must not equal the uncapped far-future date - XCTAssertNotEqual(call.policy, .after(Date(timeIntervalSince1970: farFuture.timeIntervalSince1970))) - } - - func testHandle_afterDismissalPolicyWithInvalidTimestamp_usesImmediate() { - let payload: [String: Any] = ["tag": "end-tag", "dismissal_policy": "after:not-a-number"] - XCTAssertNoThrow(try hang(sut.handle(payload))) - XCTAssertEqual(mockRegistry.endCalls.count, 1) - XCTAssertTrue(mockRegistry.endCalls[0].policyIsImmediate) - } - - func testHandle_unknownDismissalPolicy_usesImmediate() { - let payload: [String: Any] = ["tag": "end-tag", "dismissal_policy": "unknown"] - XCTAssertNoThrow(try hang(sut.handle(payload))) - XCTAssertEqual(mockRegistry.endCalls.count, 1) - XCTAssertTrue(mockRegistry.endCalls[0].policyIsImmediate) - } -} #endif diff --git a/Tests/Shared/LiveActivity/LiveActivityContractTests.swift b/Tests/Shared/LiveActivity/LiveActivityContractTests.swift index 1f4e3a631..81c2f8530 100644 --- a/Tests/Shared/LiveActivity/LiveActivityContractTests.swift +++ b/Tests/Shared/LiveActivity/LiveActivityContractTests.swift @@ -138,15 +138,6 @@ final class LiveActivityContractTests: XCTestCase { ] as [String: Any], ] XCTAssertNoThrow(try hang(manager.handle(liveActivityPayload))) - - // "end_live_activity" command must route successfully - let endPayload: [AnyHashable: Any] = [ - "homeassistant": [ - "command": "end_live_activity", - "tag": "test", - ] as [String: Any], - ] - XCTAssertNoThrow(try hang(manager.handle(endPayload))) } /// The `live_update: true` data flag must be recognized (same field as Android Live Updates). diff --git a/Tests/Shared/LiveActivity/NotificationsCommandManagerLiveActivityTests.swift b/Tests/Shared/LiveActivity/NotificationsCommandManagerLiveActivityTests.swift index 8d5be00ab..a70c78670 100644 --- a/Tests/Shared/LiveActivity/NotificationsCommandManagerLiveActivityTests.swift +++ b/Tests/Shared/LiveActivity/NotificationsCommandManagerLiveActivityTests.swift @@ -7,8 +7,7 @@ import XCTest /// Tests for the two live-activity routing paths in `NotificationCommandManager`: /// 1. `homeassistant.command == "live_activity"` — explicit command key /// 2. `homeassistant.live_update == true` — data flag (Android-compat pattern) -/// 3. `homeassistant.command == "end_live_activity"` — end command -/// 4. `homeassistant.command == "clear_notification"` with a `tag` — dismisses live activity +/// 3. `homeassistant.command == "clear_notification"` with a `tag` — dismisses live activity @available(iOS 17.2, *) final class NotificationsCommandManagerLiveActivityTests: XCTestCase { private var sut: NotificationCommandManager! @@ -77,30 +76,6 @@ final class NotificationsCommandManagerLiveActivityTests: XCTestCase { XCTAssertTrue(mockRegistry.startOrUpdateCalls.isEmpty) } - // MARK: - end_live_activity command - - func testHandle_endLiveActivityCommand_callsRegistryEnd() { - let payload = makePayload([ - "command": "end_live_activity", - "tag": "end-me", - ]) - XCTAssertNoThrow(try hang(sut.handle(payload))) - XCTAssertEqual(mockRegistry.endCalls.count, 1) - XCTAssertEqual(mockRegistry.endCalls[0].tag, "end-me") - XCTAssertTrue(mockRegistry.endCalls[0].policyIsImmediate) - } - - func testHandle_endLiveActivityCommand_withDefaultPolicy_callsRegistryEndWithDefaultPolicy() { - let payload = makePayload([ - "command": "end_live_activity", - "tag": "end-me", - "dismissal_policy": "default", - ]) - XCTAssertNoThrow(try hang(sut.handle(payload))) - XCTAssertEqual(mockRegistry.endCalls.count, 1) - XCTAssertTrue(mockRegistry.endCalls[0].policyIsDefault) - } - // MARK: - clear_notification also ends live activity // NOTE: testHandle_clearNotificationWithTag_callsRegistryEnd is intentionally omitted.