From a8c79fdd46c7405633548e54c350e26b439c84dd Mon Sep 17 00:00:00 2001 From: Matthew Hodgson Date: Mon, 27 Jul 2026 15:31:47 +0100 Subject: [PATCH] Implements push notifications for LLS via MSC4505 (#5872) * Render live location share start push notifications in the NSE Live location sharing starts with a cleartext org.matrix.msc3672.beacon_info STATE event, which the NSE previously rendered as the generic fallback notification because NotificationContentBuilder only handled message-like event content. - Handle .state(.beaconInfo) in NotificationContentBuilder: sender display name as the title (same as other messages) with a new localized body, "Started sharing their live location". - Let beaconInfo state events through NotificationHandler's preprocessing (all other state events are still discarded). - Add notification_live_location_started_body, imported from Localazy. - Unit tests for the new state-event notification paths. Co-Authored-By: Claude Fable 5 * rename processMessageLike in processAsMessage --------- Co-authored-by: Claude Fable 5 Co-authored-by: Mauro Romito --- .../en-US.lproj/Localizable.strings | 1 + .../en.lproj/Localizable.strings | 1 + ElementX/Sources/Generated/Strings.swift | 2 + NSE/Sources/NotificationContentBuilder.swift | 60 +++++++++++-------- NSE/Sources/NotificationHandler.swift | 9 ++- .../NotificationContentBuilderTests.swift | 51 ++++++++++++++++ 6 files changed, 97 insertions(+), 27 deletions(-) diff --git a/ElementX/Resources/Localizations/en-US.lproj/Localizable.strings b/ElementX/Resources/Localizations/en-US.lproj/Localizable.strings index 326315440..7d573d495 100644 --- a/ElementX/Resources/Localizations/en-US.lproj/Localizable.strings +++ b/ElementX/Resources/Localizations/en-US.lproj/Localizable.strings @@ -519,6 +519,7 @@ "notification_invitation_action_reject" = "Reject"; "notification_invite_body" = "Invited you to chat"; "notification_invite_body_with_sender" = "%1$@ invited you to chat"; +"notification_live_location_started_body" = "Started sharing their live location"; "notification_mentioned_you_body" = "Mentioned you: %1$@"; "notification_new_messages" = "New Messages"; "notification_reaction_body" = "Reacted with %1$@"; diff --git a/ElementX/Resources/Localizations/en.lproj/Localizable.strings b/ElementX/Resources/Localizations/en.lproj/Localizable.strings index de9f3fe20..c004691d4 100644 --- a/ElementX/Resources/Localizations/en.lproj/Localizable.strings +++ b/ElementX/Resources/Localizations/en.lproj/Localizable.strings @@ -519,6 +519,7 @@ "notification_invitation_action_reject" = "Reject"; "notification_invite_body" = "Invited you to chat"; "notification_invite_body_with_sender" = "%1$@ invited you to chat"; +"notification_live_location_started_body" = "Started sharing their live location"; "notification_mentioned_you_body" = "Mentioned you: %1$@"; "notification_new_messages" = "New Messages"; "notification_reaction_body" = "Reacted with %1$@"; diff --git a/ElementX/Sources/Generated/Strings.swift b/ElementX/Sources/Generated/Strings.swift index d511f31fe..3abaafd79 100644 --- a/ElementX/Sources/Generated/Strings.swift +++ b/ElementX/Sources/Generated/Strings.swift @@ -1192,6 +1192,8 @@ internal nonisolated enum L10n { internal static func notificationInviteBodyWithSender(_ p1: Any) -> String { return L10n.tr("Localizable", "notification_invite_body_with_sender", String(describing: p1)) } + /// Started sharing their live location + internal static var notificationLiveLocationStartedBody: String { return L10n.tr("Localizable", "notification_live_location_started_body") } /// Mentioned you: %1$@ internal static func notificationMentionedYouBody(_ p1: Any) -> String { return L10n.tr("Localizable", "notification_mentioned_you_body", String(describing: p1)) diff --git a/NSE/Sources/NotificationContentBuilder.swift b/NSE/Sources/NotificationContentBuilder.swift index 4d43c2f2d..93d543932 100644 --- a/NSE/Sources/NotificationContentBuilder.swift +++ b/NSE/Sources/NotificationContentBuilder.swift @@ -59,28 +59,38 @@ nonisolated struct NotificationContentBuilder { notificationItem: notificationItem, mediaProvider: mediaProvider) case .timeline(let event): - guard case let .messageLike(messageContent) = try? event.content() else { - processEmpty(¬ificationContent) - return - } - - await processMessageLike(notificationContent: ¬ificationContent, - notificationItem: notificationItem, - mediaProvider: mediaProvider) - - switch messageContent { - case .roomMessage(let messageType, _): - await processRoomMessage(notificationContent: ¬ificationContent, - notificationItem: notificationItem, - messageType: messageType, - mediaProvider: mediaProvider) - case .poll(let question): - notificationContent.body = L10n.commonPollSummary(question) - case .callInvite: - notificationContent.body = L10n.commonUnsupportedCall - case .rtcNotification: - notificationContent.body = L10n.notificationIncomingCall - default: + switch try? event.content() { + case .messageLike(let messageContent): + await processAsMessage(notificationContent: ¬ificationContent, + notificationItem: notificationItem, + mediaProvider: mediaProvider) + + switch messageContent { + case .roomMessage(let messageType, _): + await processRoomMessage(notificationContent: ¬ificationContent, + notificationItem: notificationItem, + messageType: messageType, + mediaProvider: mediaProvider) + case .poll(let question): + notificationContent.body = L10n.commonPollSummary(question) + case .callInvite: + notificationContent.body = L10n.commonUnsupportedCall + case .rtcNotification: + notificationContent.body = L10n.notificationIncomingCall + default: + processEmpty(¬ificationContent) + } + case .state(let stateContent): + switch stateContent { + case .beaconInfo: + await processAsMessage(notificationContent: ¬ificationContent, + notificationItem: notificationItem, + mediaProvider: mediaProvider) + notificationContent.body = L10n.notificationLiveLocationStartedBody + default: + processEmpty(¬ificationContent) + } + case .none: processEmpty(¬ificationContent) } } @@ -117,9 +127,9 @@ nonisolated struct NotificationContentBuilder { mediaProvider: mediaProvider) } - private func processMessageLike(notificationContent: inout UNMutableNotificationContent, - notificationItem: NotificationItemProxyProtocol, - mediaProvider: MediaProviderProtocol) async { + private func processAsMessage(notificationContent: inout UNMutableNotificationContent, + notificationItem: NotificationItemProxyProtocol, + mediaProvider: MediaProviderProtocol) async { notificationContent.title = notificationItem.senderDisplayName ?? notificationItem.roomDisplayName if notificationContent.title != notificationItem.roomDisplayName { notificationContent.subtitle = notificationItem.roomDisplayName diff --git a/NSE/Sources/NotificationHandler.swift b/NSE/Sources/NotificationHandler.swift index 8cb498f22..b8de20285 100644 --- a/NSE/Sources/NotificationHandler.swift +++ b/NSE/Sources/NotificationHandler.swift @@ -148,8 +148,13 @@ nonisolated class NotificationHandler { .beacon: return .unsupportedShouldDiscard } - case .state: - return .unsupportedShouldDiscard + case .state(let stateContent): + switch stateContent { + case .beaconInfo: + return .shouldDisplay + default: + return .unsupportedShouldDiscard + } case .none: return .unsupportedShouldDiscard } diff --git a/UnitTests/Sources/NotificationContentBuilderTests.swift b/UnitTests/Sources/NotificationContentBuilderTests.swift index f1e646256..ce861fdb2 100644 --- a/UnitTests/Sources/NotificationContentBuilderTests.swift +++ b/UnitTests/Sources/NotificationContentBuilderTests.swift @@ -8,6 +8,7 @@ import Dynamic @testable import ElementX import MatrixRustSDK +import MatrixRustSDKMocks import Testing import UserNotifications @@ -221,6 +222,56 @@ nonisolated struct NotificationContentBuilderTests { #expect(notificationContent.attachments == []) } + @Test + mutating func liveLocationStartNotification() async { + let event = TimelineEventSDKMock() + event.eventIdReturnValue = UUID().uuidString + event.contentReturnValue = .state(content: .beaconInfo) + + let notificationItem = NotificationItemProxyMock(.init(event: .timeline(event: event), + roomID: "!test:matrix.org", + receiverID: "@bob:matrix.org", + senderDisplayName: "Alice", + roomDisplayName: "Alice", + roomJoinedMembers: 2, + isRoomDirect: true, + isRoomPrivate: true, + isNoisy: true)) + + await notificationContentBuilder.process(notificationContent: ¬ificationContent, + notificationItem: notificationItem, + mediaProvider: mediaProvider) + + let communicationContext = Dynamic(notificationContent, memberName: "communicationContext") + #expect(communicationContext.sender.displayName == "Alice") + #expect(notificationContent.body == L10n.notificationLiveLocationStartedBody) + #expect(notificationContent.categoryIdentifier == NotificationConstants.Category.message) + #expect(notificationContent.sound != nil) + } + + @Test + mutating func otherStateEventNotification() async { + let event = TimelineEventSDKMock() + event.eventIdReturnValue = UUID().uuidString + event.contentReturnValue = .state(content: .roomName) + + let notificationItem = NotificationItemProxyMock(.init(event: .timeline(event: event), + roomID: "!test:matrix.org", + receiverID: "@bob:matrix.org", + senderDisplayName: "Alice", + roomDisplayName: "Alice", + roomJoinedMembers: 2, + isRoomDirect: true, + isRoomPrivate: true, + isNoisy: true)) + + await notificationContentBuilder.process(notificationContent: ¬ificationContent, + notificationItem: notificationItem, + mediaProvider: mediaProvider) + + #expect(notificationContent.body == L10n.notification) + } + @Test mutating func roomMessageNotificationWithThreadAndMention() async { let notificationItem = NotificationItemProxyMock(.init(roomID: "!testroom:matrix.org",