Refactor the NSE so that the original notification content is preserved between all the different processing steps

- building brand new content is probably interfering with how iOS syncs up delivered and seen notifications between devices
- simplify all the various steps and make the flows easier to follow
This commit is contained in:
Stefan Ceriu
2025-04-17 12:21:47 +03:00
committed by Stefan Ceriu
parent 7cf1d08b20
commit 5ad25d8492
6 changed files with 168 additions and 245 deletions
+117 -116
View File
@@ -18,70 +18,78 @@ struct NotificationContentBuilder {
/// - notificationItem: The notification item
/// - mediaProvider: Media provider to process also media. May be passed nil to ignore media operations.
/// - Returns: A notification content object if the notification should be displayed. Otherwise nil.
func content(for notificationItem: NotificationItemProxyProtocol, mediaProvider: MediaProviderProtocol?) async throws -> UNMutableNotificationContent {
func process(notificationContent: UNMutableNotificationContent,
notificationItem: NotificationItemProxyProtocol,
mediaProvider: MediaProviderProtocol) async -> UNMutableNotificationContent {
notificationContent.receiverID = notificationItem.receiverID
notificationContent.roomID = notificationItem.roomID
switch notificationItem.event {
case .timeline(let event):
notificationContent.eventID = event.eventId()
case .invite, .none:
notificationContent.eventID = nil
}
// So that the UI groups notification that are received for the same room but also for the same user
// Removing the @ fixes an iOS bug where the notification crashes if the mute button is tapped
notificationContent.threadIdentifier = "\(notificationItem.receiverID)\(notificationItem.roomID)".replacingOccurrences(of: "@", with: "")
MXLog.info("isNoisy: \(notificationItem.isNoisy)")
notificationContent.sound = notificationItem.isNoisy ? UNNotificationSound(named: UNNotificationSoundName(rawValue: "message.caf")) : nil
switch notificationItem.event {
case .none:
return processEmpty(notificationItem: notificationItem)
return processEmpty(notificationContent)
case .invite:
return try await processInvited(notificationItem: notificationItem, mediaProvider: mediaProvider)
return await processInvited(notificationContent: notificationContent,
notificationItem: notificationItem,
mediaProvider: mediaProvider)
case .timeline(let event):
guard let eventType = try? event.eventType() else {
return processEmpty(notificationItem: notificationItem)
guard let eventType = try? event.eventType(),
case let .messageLike(content) = eventType else {
return processEmpty(notificationContent)
}
switch eventType {
case let .messageLike(content):
switch content {
case .roomMessage(let messageType, _):
return try await processRoomMessage(notificationItem: notificationItem, messageType: messageType, mediaProvider: mediaProvider)
case .poll(let question):
return try await processPollStartEvent(notificationItem: notificationItem, pollQuestion: question, mediaProvider: mediaProvider)
case .callInvite:
return try await processCallInviteEvent(notificationItem: notificationItem, mediaProvider: mediaProvider)
case .callNotify:
return try await processCallNotifyEvent(notificationItem: notificationItem, mediaProvider: mediaProvider)
default:
return processEmpty(notificationItem: notificationItem)
}
case .state:
return processEmpty(notificationItem: notificationItem)
let notificationContent = await processMessageLike(notificationContent: notificationContent,
notificationItem: notificationItem,
mediaProvider: mediaProvider)
switch content {
case .roomMessage(let messageType, _):
return await processRoomMessage(notificationContent: notificationContent,
notificationItem: notificationItem,
messageType: messageType,
mediaProvider: mediaProvider)
case .poll(let question):
notificationContent.body = L10n.commonPollSummary(question)
return notificationContent
case .callInvite:
notificationContent.body = L10n.commonUnsupportedCall
return notificationContent
case .callNotify:
notificationContent.body = L10n.notificationIncomingCall
return notificationContent
default:
return processEmpty(notificationContent)
}
}
}
// MARK: - Private
func baseMutableContent(for notificationItem: NotificationItemProxyProtocol) -> UNMutableNotificationContent {
let notification = UNMutableNotificationContent()
private func processEmpty(_ notificationContent: UNMutableNotificationContent) -> UNMutableNotificationContent {
notificationContent.title = InfoPlistReader(bundle: .app).bundleDisplayName
notificationContent.body = L10n.notification
notificationContent.categoryIdentifier = NotificationConstants.Category.message
notification.receiverID = notificationItem.receiverID
notification.roomID = notificationItem.roomID
notification.eventID = switch notificationItem.event {
case .timeline(let event): event.eventId()
case .invite, .none: nil
}
// So that the UI groups notification that are received for the same room but also for the same user
// Removing the @ fixes an iOS bug where the notification crashes if the mute button is tapped
notification.threadIdentifier = "\(notificationItem.receiverID)\(notificationItem.roomID)".replacingOccurrences(of: "@", with: "")
MXLog.info("isNoisy: \(notificationItem.isNoisy)")
notification.sound = notificationItem.isNoisy ? UNNotificationSound(named: UNNotificationSoundName(rawValue: "message.caf")) : nil
return notification
}
private func processEmpty(notificationItem: NotificationItemProxyProtocol) -> UNMutableNotificationContent {
let notification = baseMutableContent(for: notificationItem)
notification.title = InfoPlistReader(bundle: .app).bundleDisplayName
notification.body = L10n.notification
notification.categoryIdentifier = NotificationConstants.Category.message
return notification
return notificationContent
}
private func processInvited(notificationItem: NotificationItemProxyProtocol, mediaProvider: MediaProviderProtocol?) async throws -> UNMutableNotificationContent {
var notification = baseMutableContent(for: notificationItem)
notification.categoryIdentifier = NotificationConstants.Category.invite
private func processInvited(notificationContent: UNMutableNotificationContent,
notificationItem: NotificationItemProxyProtocol,
mediaProvider: MediaProviderProtocol) async -> UNMutableNotificationContent {
notificationContent.categoryIdentifier = NotificationConstants.Category.invite
let body: String
if !notificationItem.isDM {
@@ -89,83 +97,43 @@ struct NotificationContentBuilder {
} else {
body = L10n.notificationInviteBody
}
notification = try await notification.addSenderIcon(using: mediaProvider,
senderID: notificationItem.senderID,
senderName: notificationItem.senderDisplayName ?? notificationItem.roomDisplayName,
icon: icon(for: notificationItem),
forcePlaceholder: settings.hideInviteAvatars)
notification.body = body
return notification
}
private func processRoomMessage(notificationItem: NotificationItemProxyProtocol, messageType: MessageType, mediaProvider: MediaProviderProtocol?) async throws -> UNMutableNotificationContent {
var notification = try await processCommonRoomMessage(notificationItem: notificationItem, mediaProvider: mediaProvider)
notificationContent.body = body
let displayName = notificationItem.senderDisplayName ?? notificationItem.roomDisplayName
notification.body = String(messageEventStringBuilder.buildAttributedString(for: messageType, senderDisplayName: displayName, isOutgoing: false).characters)
guard settings.timelineMediaVisibility == .always ||
(settings.timelineMediaVisibility == .privateOnly && notificationItem.isRoomPrivate)
else { return notification }
switch messageType {
case .image(content: let content):
notification = await notification.addMediaAttachment(using: mediaProvider,
mediaSource: .init(source: content.source,
mimeType: content.info?.mimetype))
case .audio(content: let content):
notification = await notification.addMediaAttachment(using: mediaProvider,
mediaSource: .init(source: content.source,
mimeType: content.info?.mimetype))
case .video(content: let content):
notification = await notification.addMediaAttachment(using: mediaProvider,
mediaSource: .init(source: content.source,
mimeType: content.info?.mimetype))
default:
break
do {
return try await notificationContent.addSenderIcon(senderID: notificationItem.senderID,
senderName: notificationItem.senderDisplayName ?? notificationItem.roomDisplayName,
icon: icon(for: notificationItem),
forcePlaceholder: settings.hideInviteAvatars,
mediaProvider: mediaProvider)
} catch {
return notificationContent
}
return notification
}
private func processPollStartEvent(notificationItem: NotificationItemProxyProtocol, pollQuestion: String, mediaProvider: MediaProviderProtocol?) async throws -> UNMutableNotificationContent {
let notification = try await processCommonRoomMessage(notificationItem: notificationItem, mediaProvider: mediaProvider)
notification.body = L10n.commonPollSummary(pollQuestion)
return notification
}
private func processCallInviteEvent(notificationItem: NotificationItemProxyProtocol, mediaProvider: MediaProviderProtocol?) async throws -> UNMutableNotificationContent {
let notification = try await processCommonRoomMessage(notificationItem: notificationItem, mediaProvider: mediaProvider)
notification.body = L10n.commonUnsupportedCall
return notification
}
private func processCallNotifyEvent(notificationItem: NotificationItemProxyProtocol, mediaProvider: MediaProviderProtocol?) async throws -> UNMutableNotificationContent {
let notification = try await processCommonRoomMessage(notificationItem: notificationItem, mediaProvider: mediaProvider)
notification.body = L10n.notificationIncomingCall
return notification
}
private func processCommonRoomMessage(notificationItem: NotificationItemProxyProtocol, mediaProvider: MediaProviderProtocol?) async throws -> UNMutableNotificationContent {
var notification = baseMutableContent(for: notificationItem)
notification.title = notificationItem.senderDisplayName ?? notificationItem.roomDisplayName
if notification.title != notificationItem.roomDisplayName {
notification.subtitle = notificationItem.roomDisplayName
private func processMessageLike(notificationContent: UNMutableNotificationContent,
notificationItem: NotificationItemProxyProtocol,
mediaProvider: MediaProviderProtocol) async -> UNMutableNotificationContent {
notificationContent.title = notificationItem.senderDisplayName ?? notificationItem.roomDisplayName
if notificationContent.title != notificationItem.roomDisplayName {
notificationContent.subtitle = notificationItem.roomDisplayName
}
notification.categoryIdentifier = NotificationConstants.Category.message
notificationContent.categoryIdentifier = NotificationConstants.Category.message
let senderName = if let displayName = notificationItem.senderDisplayName {
notificationItem.hasMention ? L10n.notificationSenderMentionReply(displayName) : displayName
} else {
notificationItem.roomDisplayName
}
notification = try await notification.addSenderIcon(using: mediaProvider,
senderID: notificationItem.senderID,
senderName: senderName,
icon: icon(for: notificationItem))
return notification
do {
return try await notificationContent.addSenderIcon(senderID: notificationItem.senderID,
senderName: senderName,
icon: icon(for: notificationItem),
mediaProvider: mediaProvider)
} catch {
return notificationContent
}
}
func icon(for notificationItem: NotificationItemProxyProtocol) -> NotificationIcon {
@@ -176,4 +144,37 @@ struct NotificationContentBuilder {
groupInfo: .init(name: notificationItem.roomDisplayName, id: notificationItem.roomID))
}
}
private func processRoomMessage(notificationContent: UNMutableNotificationContent,
notificationItem: NotificationItemProxyProtocol,
messageType: MessageType,
mediaProvider: MediaProviderProtocol) async -> UNMutableNotificationContent {
let displayName = notificationItem.senderDisplayName ?? notificationItem.roomDisplayName
notificationContent.body = String(messageEventStringBuilder.buildAttributedString(for: messageType, senderDisplayName: displayName, isOutgoing: false).characters)
guard settings.timelineMediaVisibility == .always ||
(settings.timelineMediaVisibility == .privateOnly && notificationItem.isRoomPrivate)
else {
return notificationContent
}
switch messageType {
case .image(content: let content):
await notificationContent.addMediaAttachment(using: mediaProvider,
mediaSource: .init(source: content.source,
mimeType: content.info?.mimetype))
case .audio(content: let content):
await notificationContent.addMediaAttachment(using: mediaProvider,
mediaSource: .init(source: content.source,
mimeType: content.info?.mimetype))
case .video(content: let content):
await notificationContent.addMediaAttachment(using: mediaProvider,
mediaSource: .init(source: content.source,
mimeType: content.info?.mimetype))
default:
break
}
return notificationContent
}
}
+34 -57
View File
@@ -41,8 +41,8 @@ private let notificationContentBuilder = NotificationContentBuilder(messageEvent
settings: settings)
class NotificationServiceExtension: UNNotificationServiceExtension {
private var handler: ((UNNotificationContent) -> Void)?
private var modifiedContent: UNMutableNotificationContent?
private var contentHandler: ((UNNotificationContent) -> Void)!
private var notificationContent: UNMutableNotificationContent!
private let appHooks = AppHooks()
@@ -55,9 +55,9 @@ class NotificationServiceExtension: UNNotificationServiceExtension {
override func didReceive(_ request: UNNotificationRequest,
withContentHandler contentHandler: @escaping (UNNotificationContent) -> Void) {
guard !DataProtectionManager.isDeviceLockedAfterReboot(containerURL: URL.appGroupContainerDirectory),
let roomID = request.roomID,
let eventID = request.eventID,
let clientID = request.pusherNotificationClientIdentifier,
let roomID = request.content.roomID,
let eventID = request.content.eventID,
let clientID = request.content.pusherNotificationClientIdentifier,
let credentials = keychainController.restorationTokens().first(where: { $0.restorationToken.pusherNotificationClientIdentifier == clientID }) else {
// We cannot process this notification, it might be due to one of these:
// - Device rebooted and locked
@@ -67,10 +67,14 @@ class NotificationServiceExtension: UNNotificationServiceExtension {
return contentHandler(request.content)
}
guard let mutableContent = request.content.mutableCopy() as? UNMutableNotificationContent else {
return contentHandler(request.content)
}
Target.nse.configure(logLevel: settings.logLevel, traceLogPacks: settings.traceLogPacks)
handler = contentHandler
modifiedContent = request.content.mutableCopy() as? UNMutableNotificationContent
self.contentHandler = contentHandler
notificationContent = mutableContent
MXLog.info("\(tag) #########################################")
@@ -90,7 +94,6 @@ class NotificationServiceExtension: UNNotificationServiceExtension {
await processEvent(eventID,
roomID: roomID,
unreadCount: request.unreadCount,
userSession: userSession)
} catch {
MXLog.error("Failed creating user session with error: \(error)")
@@ -102,77 +105,51 @@ class NotificationServiceExtension: UNNotificationServiceExtension {
// Called just before the extension will be terminated by the system.
// Use this as an opportunity to deliver your "best attempt" at modified content, otherwise the original push payload will be used.
MXLog.warning("\(tag) Extension time will expire")
notify(unreadCount: nil)
deliverNotification()
}
// MARK: - Private
private func processEvent(_ eventID: String,
roomID: String,
unreadCount: Int?,
userSession: NSEUserSession) async {
MXLog.info("\(tag) Processing event: \(eventID) in room: \(roomID)")
do {
guard let itemProxy = await userSession.notificationItemProxy(roomID: roomID, eventID: eventID) else {
MXLog.error("\(tag) Failed retrieving notification item")
return discard(unreadCount: unreadCount)
}
switch await preprocessNotification(itemProxy) {
case .processedShouldDiscard, .unsupportedShouldDiscard:
return discard(unreadCount: unreadCount)
case .shouldDisplay:
break
}
guard let notificationItemProxy = await userSession.notificationItemProxy(roomID: roomID, eventID: eventID) else {
MXLog.error("\(tag) Failed retrieving notification item")
discardNotification()
return
}
switch await preprocessNotification(notificationItemProxy) {
case .processedShouldDiscard, .unsupportedShouldDiscard:
discardNotification()
case .shouldDisplay:
notificationContent = await notificationContentBuilder.process(notificationContent: notificationContent,
notificationItem: notificationItemProxy,
mediaProvider: userSession.mediaProvider)
modifiedContent = try await notificationContentBuilder.content(for: itemProxy, mediaProvider: nil)
guard itemProxy.hasMedia else {
MXLog.info("\(tag) Notification item doesn't contain media")
return notify(unreadCount: unreadCount)
}
MXLog.info("\(tag) Processing media")
if let latestContent = try? await notificationContentBuilder.content(for: itemProxy, mediaProvider: userSession.mediaProvider) {
modifiedContent = latestContent
} else {
MXLog.error("\(tag) Failed processing notification media")
}
return notify(unreadCount: unreadCount)
} catch {
MXLog.error("Failed processing with error: \(error)")
return discard(unreadCount: unreadCount)
deliverNotification()
}
}
private func notify(unreadCount: Int?) {
guard let modifiedContent else {
MXLog.error("\(tag) Notification modified content invalid")
return discard(unreadCount: unreadCount)
}
private func deliverNotification() {
MXLog.info("\(tag) Displaying notification")
if let unreadCount {
modifiedContent.badge = NSNumber(value: unreadCount)
}
handler?(modifiedContent)
contentHandler(notificationContent)
cleanUp()
}
private func discard(unreadCount: Int?) {
private func discardNotification() {
MXLog.info("\(tag) Discarding notification")
let content = UNMutableNotificationContent()
if let unreadCount {
if let unreadCount = notificationContent.unreadCount {
content.badge = NSNumber(value: unreadCount)
}
handler?(content)
contentHandler(content)
cleanUp()
}
@@ -181,8 +158,8 @@ class NotificationServiceExtension: UNNotificationServiceExtension {
}
private func cleanUp() {
handler = nil
modifiedContent = nil
contentHandler = nil
notificationContent = nil
}
private func preprocessNotification(_ itemProxy: NotificationItemProxyProtocol) async -> NotificationProcessingResult {
@@ -1,27 +0,0 @@
//
// Copyright 2022-2024 New Vector Ltd.
//
// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
// Please see LICENSE files in the repository root for full details.
//
import Foundation
import UserNotifications
extension UNNotificationRequest {
var roomID: String? {
content.userInfo[NotificationConstants.UserInfoKey.roomIdentifier] as? String
}
var eventID: String? {
content.userInfo[NotificationConstants.UserInfoKey.eventIdentifier] as? String
}
var unreadCount: Int? {
content.userInfo[NotificationConstants.UserInfoKey.unreadCount] as? Int
}
var pusherNotificationClientIdentifier: String? {
content.userInfo[NotificationConstants.UserInfoKey.pusherNotificationClientIdentifier] as? String
}
}