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
Original file line number Diff line number Diff line change
Expand Up @@ -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<Void> {
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
Original file line number Diff line number Diff line change
Expand Up @@ -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
Comment thread
rwarner marked this conversation as resolved.
#endif
Expand Down
111 changes: 0 additions & 111 deletions Tests/Shared/LiveActivity/HandlerLiveActivityTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
9 changes: 0 additions & 9 deletions Tests/Shared/LiveActivity/LiveActivityContractTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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!
Expand Down Expand Up @@ -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.
Expand Down
Loading