From 66491caa6b4a55a9148685c5effde544d0b14ba9 Mon Sep 17 00:00:00 2001 From: Mauro <34335419+Velin92@users.noreply.github.com> Date: Fri, 28 Apr 2023 14:36:16 +0200 Subject: [PATCH] Notification Handler to handle notifications in foreground (#837) * changed how the notifications are handled * local notifications can be decrypted and shown! * Improved the handling for the mock and for the normal messages * Avatar images are also displayed * pr ready, commented some code that is not supposed to be releaaed yet, adjusted some tests and added some source code in the targets * fixing some swiftlint complaints and removed an unused function * better swiftlint disable * addressing PR comments --- .../Sources/Application/AppCoordinator.swift | 4 +- .../Mocks/Generated/GeneratedMocks.swift | 24 +- .../Other/Extensions/FileManager.swift | 4 +- .../Extensions/UNNotificationContent.swift | 91 +++++ .../Sources/Services/Client/ClientProxy.swift | 34 +- .../Services/Client/ClientProxyProtocol.swift | 1 + .../Manager/NotificationManager.swift | 36 +- .../Manager/NotificationManagerProtocol.swift | 2 +- .../Proxy/MockNotificationServiceProxy.swift | 2 +- .../Proxy/NotificationItemProxy.swift | 362 ++++++++++++++++-- .../Proxy/NotificationServiceProxy.swift | 4 +- .../NotificationServiceProxyProtocol.swift | 2 +- .../Timeline/TimelineEventProxy.swift | 52 +++ .../Other/NotificationItemProxy+NSE.swift | 270 ------------- .../Other/UNMutableNotificationContent.swift | 91 ----- NSE/SupportingFiles/target.yml | 1 + .../MediaProvider/MediaLoaderTests.swift | 4 +- .../NotificationManagerTests.swift | 3 +- 18 files changed, 550 insertions(+), 437 deletions(-) create mode 100644 ElementX/Sources/Services/Timeline/TimelineEventProxy.swift delete mode 100644 NSE/Sources/Other/NotificationItemProxy+NSE.swift delete mode 100644 NSE/Sources/Other/UNMutableNotificationContent.swift diff --git a/ElementX/Sources/Application/AppCoordinator.swift b/ElementX/Sources/Application/AppCoordinator.swift index 6c2605769..a5843770c 100644 --- a/ElementX/Sources/Application/AppCoordinator.swift +++ b/ElementX/Sources/Application/AppCoordinator.swift @@ -288,7 +288,7 @@ class AppCoordinator: AppCoordinatorProtocol { userSessionFlowCoordinator = nil - notificationManager.setClientProxy(nil) + notificationManager.setUserSession(nil) } private func presentSplashScreen(isSoftLogout: Bool = false) { @@ -302,7 +302,7 @@ class AppCoordinator: AppCoordinatorProtocol { } private func configureNotificationManager() { - notificationManager.setClientProxy(userSession.clientProxy) + notificationManager.setUserSession(userSession) notificationManager.requestAuthorization() if let appDelegate = AppDelegate.shared { diff --git a/ElementX/Sources/Mocks/Generated/GeneratedMocks.swift b/ElementX/Sources/Mocks/Generated/GeneratedMocks.swift index ac2a498e5..8f0be43fd 100644 --- a/ElementX/Sources/Mocks/Generated/GeneratedMocks.swift +++ b/ElementX/Sources/Mocks/Generated/GeneratedMocks.swift @@ -264,21 +264,21 @@ class NotificationManagerMock: NotificationManagerProtocol { showLocalNotificationWithSubtitleReceivedInvocations.append((title: title, subtitle: subtitle)) await showLocalNotificationWithSubtitleClosure?(title, subtitle) } - //MARK: - setClientProxy + //MARK: - setUserSession - var setClientProxyCallsCount = 0 - var setClientProxyCalled: Bool { - return setClientProxyCallsCount > 0 + var setUserSessionCallsCount = 0 + var setUserSessionCalled: Bool { + return setUserSessionCallsCount > 0 } - var setClientProxyReceivedClientProxy: ClientProxyProtocol? - var setClientProxyReceivedInvocations: [ClientProxyProtocol?] = [] - var setClientProxyClosure: ((ClientProxyProtocol?) -> Void)? + var setUserSessionReceivedUserSession: UserSessionProtocol? + var setUserSessionReceivedInvocations: [UserSessionProtocol?] = [] + var setUserSessionClosure: ((UserSessionProtocol?) -> Void)? - func setClientProxy(_ clientProxy: ClientProxyProtocol?) { - setClientProxyCallsCount += 1 - setClientProxyReceivedClientProxy = clientProxy - setClientProxyReceivedInvocations.append(clientProxy) - setClientProxyClosure?(clientProxy) + func setUserSession(_ userSession: UserSessionProtocol?) { + setUserSessionCallsCount += 1 + setUserSessionReceivedUserSession = userSession + setUserSessionReceivedInvocations.append(userSession) + setUserSessionClosure?(userSession) } //MARK: - requestAuthorization diff --git a/ElementX/Sources/Other/Extensions/FileManager.swift b/ElementX/Sources/Other/Extensions/FileManager.swift index 56bdc3522..a894cda77 100644 --- a/ElementX/Sources/Other/Extensions/FileManager.swift +++ b/ElementX/Sources/Other/Extensions/FileManager.swift @@ -36,8 +36,8 @@ extension FileManager { try createDirectory(at: url, withIntermediateDirectories: withIntermediateDirectories) } - func copyFileToTemporaryDirectory(file url: URL) throws -> URL { - let newURL = URL.temporaryDirectory.appendingPathComponent(url.lastPathComponent) + func copyFileToTemporaryDirectory(file url: URL, with filename: String? = nil) throws -> URL { + let newURL = URL.temporaryDirectory.appendingPathComponent(filename ?? url.lastPathComponent) try? removeItem(at: newURL) try copyItem(at: url, to: newURL) diff --git a/ElementX/Sources/Other/Extensions/UNNotificationContent.swift b/ElementX/Sources/Other/Extensions/UNNotificationContent.swift index 00e372ab8..06e332f31 100644 --- a/ElementX/Sources/Other/Extensions/UNNotificationContent.swift +++ b/ElementX/Sources/Other/Extensions/UNNotificationContent.swift @@ -15,6 +15,7 @@ // import Foundation +import Intents import UserNotifications extension UNNotificationContent { @@ -32,4 +33,94 @@ extension UNMutableNotificationContent { userInfo[NotificationConstants.UserInfoKey.receiverIdentifier] = newValue } } + + func addMediaAttachment(using mediaProvider: MediaProviderProtocol?, + mediaSource: MediaSourceProxy) async -> UNMutableNotificationContent { + guard let mediaProvider else { + return self + } + switch await mediaProvider.loadFileFromSource(mediaSource) { + case .success(let file): + do { + let identifier = ProcessInfo.processInfo.globallyUniqueString + let newURL = try FileManager.default.copyFileToTemporaryDirectory(file: file.url, with: "\(identifier).\(file.url.pathExtension)") + let attachment = try UNNotificationAttachment(identifier: identifier, + url: newURL, + options: nil) + attachments.append(attachment) + } catch { + MXLog.error("Couldn't add media attachment:: \(error)") + return self + } + case .failure(let error): + MXLog.error("Couldn't load the file for media attachment: \(error)") + } + + return self + } + + // swiftlint:disable:next function_parameter_count + func addSenderIcon(using mediaProvider: MediaProviderProtocol?, + senderId: String, + receiverId: String, + senderName: String, + groupName: String?, + mediaSource: MediaSourceProxy?, + roomId: String) async throws -> UNMutableNotificationContent { + var image: INImage? + if let mediaSource { + switch await mediaProvider?.loadFileFromSource(mediaSource) { + case .success(let mediaFile): + image = try INImage(imageData: Data(contentsOf: mediaFile.url)) + case .failure(let error): + MXLog.error("Couldn't add sender icon: \(error)") + case .none: + break + } + } + + let senderHandle = INPersonHandle(value: senderId, type: .unknown) + let sender = INPerson(personHandle: senderHandle, + nameComponents: nil, + displayName: senderName, + image: image, + contactIdentifier: nil, + customIdentifier: nil) + + // These are required to show the group name as subtitle + var speakableGroupName: INSpeakableString? + var recipients: [INPerson]? + if let groupName { + let meHandle = INPersonHandle(value: receiverId, type: .unknown) + let me = INPerson(personHandle: meHandle, nameComponents: nil, displayName: nil, image: nil, contactIdentifier: nil, customIdentifier: nil, isMe: true) + speakableGroupName = INSpeakableString(spokenPhrase: groupName) + recipients = [sender, me] + } + + let intent = INSendMessageIntent(recipients: recipients, + outgoingMessageType: .outgoingMessageText, + content: nil, + speakableGroupName: speakableGroupName, + conversationIdentifier: roomId, + serviceName: nil, + sender: sender, + attachments: nil) + intent.setImage(image, forParameterNamed: \.conversationIdentifier) + + // Use the intent to initialize the interaction. + let interaction = INInteraction(intent: intent, response: nil) + + // Interaction direction is incoming because the user is + // receiving this message. + interaction.direction = .incoming + + // Donate the interaction before updating notification content. + try await interaction.donate() + // Update notification content before displaying the + // communication notification. + let updatedContent = try updating(from: intent) + + // swiftlint:disable:next force_cast + return updatedContent.mutableCopy() as! UNMutableNotificationContent + } } diff --git a/ElementX/Sources/Services/Client/ClientProxy.swift b/ElementX/Sources/Services/Client/ClientProxy.swift index 7ab65022f..27a43cdaa 100644 --- a/ElementX/Sources/Services/Client/ClientProxy.swift +++ b/ElementX/Sources/Services/Client/ClientProxy.swift @@ -19,7 +19,7 @@ import Foundation import MatrixRustSDK import UIKit -private class WeakClientProxyWrapper: ClientDelegate, SlidingSyncObserver { +private class WeakClientProxyWrapper: ClientDelegate, NotificationDelegate, SlidingSyncObserver { private weak var clientProxy: ClientProxy? init(clientProxy: ClientProxy) { @@ -41,6 +41,12 @@ private class WeakClientProxyWrapper: ClientDelegate, SlidingSyncObserver { MXLog.info("Received sliding sync update") clientProxy?.didReceiveSlidingSyncUpdate(summary: summary) } + + // MARK: - NotificationDelegate + + func didReceiveNotification(notification: MatrixRustSDK.NotificationItem) { + clientProxy?.didReceiveNotification(notification: NotificationItemProxy(notificationItem: notification)) + } } class ClientProxy: ClientProxyProtocol { @@ -93,8 +99,13 @@ class ClientProxy: ClientProxyProtocol { clientQueue = .init(label: "ClientProxyQueue", attributes: .concurrent) mediaLoader = MediaLoader(client: client, clientQueue: clientQueue) - - client.setDelegate(delegate: WeakClientProxyWrapper(clientProxy: self)) + + let delegate = WeakClientProxyWrapper(clientProxy: self) + client.setDelegate(delegate: delegate) + // Uncomment to test local notifications +// await Task.dispatch(on: clientQueue) { +// client.setNotificationDelegate(notificationDelegate: delegate) +// } configureSlidingSync() @@ -448,8 +459,17 @@ class ClientProxy: ClientProxyProtocol { eventStringBuilder: RoomEventStringBuilder(stateEventStringBuilder: RoomStateEventStringBuilder(userID: userID))) } - private lazy var slidingSyncRequiredState = [RequiredState(key: "m.room.avatar", value: ""), - RequiredState(key: "m.room.encryption", value: "")] + private lazy var slidingSyncRequiredState = [ + RequiredState(key: "m.room.avatar", value: ""), + RequiredState(key: "m.room.encryption", value: "") + // These are required for notifications + // The idea is to create another SS + // to listen to them separately + // only here for testing purposes when enabling local notifications +// RequiredState(key: "m.room.member", value: "$ME"), +// RequiredState(key: "m.room.power_levels", value: ""), +// RequiredState(key: "m.room.name", value: "") + ] private lazy var slidingSyncInvitesRequiredState = [RequiredState(key: "m.room.avatar", value: ""), RequiredState(key: "m.room.encryption", value: ""), @@ -522,6 +542,10 @@ class ClientProxy: ClientProxyProtocol { fileprivate func didReceiveSlidingSyncUpdate(summary: UpdateSummary) { callbacks.send(.receivedSyncUpdate) } + + fileprivate func didReceiveNotification(notification: NotificationItemProxyProtocol) { + callbacks.send(.receivedNotification(notification)) + } } extension ClientProxy: MediaLoaderProtocol { diff --git a/ElementX/Sources/Services/Client/ClientProxyProtocol.swift b/ElementX/Sources/Services/Client/ClientProxyProtocol.swift index 9631b9cd8..3bb962ea7 100644 --- a/ElementX/Sources/Services/Client/ClientProxyProtocol.swift +++ b/ElementX/Sources/Services/Client/ClientProxyProtocol.swift @@ -21,6 +21,7 @@ import MatrixRustSDK enum ClientProxyCallback { case receivedSyncUpdate case receivedAuthError(isSoftLogout: Bool) + case receivedNotification(NotificationItemProxyProtocol) } enum ClientProxyError: Error { diff --git a/ElementX/Sources/Services/Notification/Manager/NotificationManager.swift b/ElementX/Sources/Services/Notification/Manager/NotificationManager.swift index 128870c1b..ad94e5e08 100644 --- a/ElementX/Sources/Services/Notification/Manager/NotificationManager.swift +++ b/ElementX/Sources/Services/Notification/Manager/NotificationManager.swift @@ -14,13 +14,15 @@ // limitations under the License. // +import Combine import Foundation import UIKit import UserNotifications class NotificationManager: NSObject, NotificationManagerProtocol { private let notificationCenter: UserNotificationCenterProtocol - private var clientProxy: ClientProxyProtocol? + private var userSession: UserSessionProtocol? + var clientCancellable: AnyCancellable? init(notificationCenter: UserNotificationCenterProtocol = UNUserNotificationCenter.current()) { self.notificationCenter = notificationCenter @@ -58,14 +60,25 @@ class NotificationManager: NSObject, NotificationManagerProtocol { } func register(with deviceToken: Data) async -> Bool { - guard let clientProxy else { + guard let userSession else { return false } - return await setPusher(with: deviceToken, clientProxy: clientProxy) + return await setPusher(with: deviceToken, clientProxy: userSession.clientProxy) } - func setClientProxy(_ clientProxy: ClientProxyProtocol?) { - self.clientProxy = clientProxy + func setUserSession(_ userSession: UserSessionProtocol?) { + self.userSession = userSession + clientCancellable = userSession?.clientProxy.callbacks.sink { [weak self] value in + guard let self else { return } + switch value { + case let .receivedNotification(notification): + Task { + await self.showLocalNotification(notification) + } + default: + return + } + } } func registrationFailed(with error: Error) { } @@ -86,6 +99,19 @@ class NotificationManager: NSObject, NotificationManagerProtocol { MXLog.error("[NotificationManager] show local notification failed: \(error)") } } + + private func showLocalNotification(_ notification: NotificationItemProxyProtocol) async { + guard let userSession else { return } + do { + guard let content = try await notification.process(receiverId: userSession.userID, roomId: notification.roomID, mediaProvider: userSession.mediaProvider) else { + return + } + let request = UNNotificationRequest(identifier: ProcessInfo.processInfo.globallyUniqueString, content: content, trigger: nil) + try await notificationCenter.add(request) + } catch { + MXLog.error("[NotificationManager] show local notification item failed: \(error)") + } + } private func setPusher(with deviceToken: Data, clientProxy: ClientProxyProtocol) async -> Bool { do { diff --git a/ElementX/Sources/Services/Notification/Manager/NotificationManagerProtocol.swift b/ElementX/Sources/Services/Notification/Manager/NotificationManagerProtocol.swift index 761df9821..a74f73508 100644 --- a/ElementX/Sources/Services/Notification/Manager/NotificationManagerProtocol.swift +++ b/ElementX/Sources/Services/Notification/Manager/NotificationManagerProtocol.swift @@ -39,6 +39,6 @@ protocol NotificationManagerProtocol: AnyObject { func register(with deviceToken: Data) async -> Bool func registrationFailed(with error: Error) func showLocalNotification(with title: String, subtitle: String?) async - func setClientProxy(_ clientProxy: ClientProxyProtocol?) + func setUserSession(_ userSession: UserSessionProtocol?) func requestAuthorization() } diff --git a/ElementX/Sources/Services/Notification/Proxy/MockNotificationServiceProxy.swift b/ElementX/Sources/Services/Notification/Proxy/MockNotificationServiceProxy.swift index 2b13fcb4d..6dbdf86d9 100644 --- a/ElementX/Sources/Services/Notification/Proxy/MockNotificationServiceProxy.swift +++ b/ElementX/Sources/Services/Notification/Proxy/MockNotificationServiceProxy.swift @@ -17,7 +17,7 @@ import Foundation class MockNotificationServiceProxy: NotificationServiceProxyProtocol { - func notificationItem(roomId: String, eventId: String) async throws -> NotificationItemProxy? { + func notificationItem(roomId: String, eventId: String) async throws -> NotificationItemProxyProtocol? { nil } } diff --git a/ElementX/Sources/Services/Notification/Proxy/NotificationItemProxy.swift b/ElementX/Sources/Services/Notification/Proxy/NotificationItemProxy.swift index 13ebb93f6..88d60f3a4 100644 --- a/ElementX/Sources/Services/Notification/Proxy/NotificationItemProxy.swift +++ b/ElementX/Sources/Services/Notification/Proxy/NotificationItemProxy.swift @@ -15,59 +15,337 @@ // import Foundation +import UserNotifications + import MatrixRustSDK -struct NotificationItemProxy { -// let notificationItem: NotificationItem -// -// init(notificationItem: NotificationItem) { -// self.notificationItem = notificationItem -// } -// -// var timelineItemProxy: TimelineItemProxy { -// .init(item: notificationItem.item) -// } -// -// var title: String { -// notificationItem.title -// } -// -// var subtitle: String? { -// notificationItem.subtitle -// } -// -// var isNoisy: Bool { -// notificationItem.isNoisy -// } -// -// var avatarURL: URL? { -// notificationItem.avatarUrl -// } -// -// var avatarMediaSource: MediaSourceProxy? { -// guard let avatarUrl else { -// return nil -// } -// return .init(urlString: avatarUrl) -// } +protocol NotificationItemProxyProtocol { + var event: TimelineEventProxyProtocol { get } - var title: String { - InfoPlistReader(bundle: .app).bundleDisplayName + var roomID: String { get } + + var senderDisplayName: String? { get } + + var senderAvatarMediaSource: MediaSourceProxy? { get } + + var roomDisplayName: String { get } + + var roomAvatarMediaSource: MediaSourceProxy? { get } + + var isNoisy: Bool { get } + + var isDirect: Bool { get } + + var isEncrypted: Bool { get } +} + +struct NotificationItemProxy: NotificationItemProxyProtocol { + let notificationItem: NotificationItem + + var event: TimelineEventProxyProtocol { + TimelineEventProxy(timelineEvent: notificationItem.event) } - var subtitle: String? { - nil + var roomID: String { + notificationItem.roomId + } + + var senderDisplayName: String? { + notificationItem.senderDisplayName + } + + var roomDisplayName: String { + notificationItem.roomDisplayName } var isNoisy: Bool { - false + notificationItem.isNoisy } - var avatarURL: URL? { - nil + var isDirect: Bool { + notificationItem.isDirect } - var avatarMediaSource: MediaSourceProxy? { - nil + var isEncrypted: Bool { + notificationItem.isEncrypted + } + + var senderAvatarMediaSource: MediaSourceProxy? { + if let senderAvatarURLString = notificationItem.senderAvatarUrl, + let senderAvatarURL = URL(string: senderAvatarURLString) { + return MediaSourceProxy(url: senderAvatarURL, mimeType: nil) + } + return nil + } + + var roomAvatarMediaSource: MediaSourceProxy? { + if let roomAvatarURLString = notificationItem.roomAvatarUrl, + let roomAvatarURL = URL(string: roomAvatarURLString) { + return MediaSourceProxy(url: roomAvatarURL, mimeType: nil) + } + return nil + } +} + +// The mock and the protocol are just temporary until we can handle +// and decrypt notifications both in background and in foreground +// but they should not be necessary in the future +struct MockNotificationItemProxy: NotificationItemProxyProtocol { + let eventID: String + + var event: TimelineEventProxyProtocol { + MockTimelineEventProxy(eventID: eventID) + } + + let roomID: String + + var senderDisplayName: String? { nil } + + var senderAvatarURL: String? { nil } + + var roomDisplayName: String { "" } + + var roomAvatarURL: String? { nil } + + var isNoisy: Bool { false } + + var isDirect: Bool { false } + + var isEncrypted: Bool { false } + + var senderAvatarMediaSource: MediaSourceProxy? { nil } + + var roomAvatarMediaSource: MediaSourceProxy? { nil } +} + +extension NotificationItemProxyProtocol { + var requiresMediaProvider: Bool { + if senderAvatarMediaSource != nil || roomAvatarMediaSource != nil { + return true + } + switch event.type { + case .state, .none: + return false + case let .messageLike(content): + switch content { + case let .roomMessage(messageType): + switch messageType { + case .image, .video, .audio: + return true + default: + return false + } + default: + return false + } + } + } + + // swiftlint: disable cyclomatic_complexity + /// Process the receiver item proxy + /// - Parameters: + /// - receiverId: identifier of the user that has received the notification + /// - roomId: Room identifier + /// - 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 process(receiverId: String, + roomId: String, + mediaProvider: MediaProviderProtocol?) async throws -> UNMutableNotificationContent? { + if self is MockNotificationItemProxy { + return processMock(receiverId: receiverId, roomId: roomId) + } else { + switch event.type { + case .none, .state: + return nil + case let .messageLike(content): + switch content { + case .roomMessage(messageType: let messageType): + switch messageType { + case .emote(content: let content): + return try await processEmote(content: content, receiverId: receiverId, senderId: event.senderID, roomId: roomId, mediaProvider: mediaProvider) + case .image(content: let content): + return try await processImage(content: content, receiverId: receiverId, senderId: event.senderID, roomId: roomId, mediaProvider: mediaProvider) + case .audio(content: let content): + return try await processAudio(content: content, receiverId: receiverId, senderId: event.senderID, roomId: roomId, mediaProvider: mediaProvider) + case .video(content: let content): + return try await processVideo(content: content, receiverId: receiverId, senderId: event.senderID, roomId: roomId, mediaProvider: mediaProvider) + case .file(content: let content): + return try await processFile(content: content, receiverId: receiverId, senderId: event.senderID, roomId: roomId, mediaProvider: mediaProvider) + case .notice(content: let content): + return try await processNotice(content: content, receiverId: receiverId, senderId: event.senderID, roomId: roomId, mediaProvider: mediaProvider) + case .text(content: let content): + return try await processText(content: content, receiverId: receiverId, senderId: event.senderID, roomId: roomId, mediaProvider: mediaProvider) + } + default: + return nil + } + } + } + } + + // swiftlint: enable cyclomatic_complexity + + // MARK: - Private + + // To be removed once we don't need the mock anymore + private func processMock(receiverId: String, + roomId: String) -> UNMutableNotificationContent { + let notification = UNMutableNotificationContent() + notification.receiverID = receiverId + notification.title = InfoPlistReader(bundle: .app).bundleDisplayName + notification.body = L10n.notification + notification.threadIdentifier = roomId + notification.categoryIdentifier = NotificationConstants.Category.reply + notification.sound = isNoisy ? UNNotificationSound(named: UNNotificationSoundName(rawValue: "message.caf")) : nil + return notification + } + + private func processCommon(receiverId: String, + senderId: String, + roomId: String, + mediaProvider: MediaProviderProtocol?) async throws -> UNMutableNotificationContent { + var notification = UNMutableNotificationContent() + notification.receiverID = receiverId + // These are fallbacks since the senderIcon also sets the title and the subtitle + notification.title = senderDisplayName ?? roomDisplayName + if notification.title != roomDisplayName { + notification.subtitle = roomDisplayName + } + // We can store the room identifier into the thread identifier since it's used for notifications + // that belong to the same group + notification.threadIdentifier = roomId + notification.categoryIdentifier = NotificationConstants.Category.reply + notification.sound = isNoisy ? UNNotificationSound(named: UNNotificationSoundName(rawValue: "message.caf")) : nil + + let senderName = senderDisplayName ?? roomDisplayName + var groupName: String? + var mediaSource: MediaSourceProxy? + if !isDirect { + groupName = senderName != roomDisplayName ? roomDisplayName : nil + mediaSource = roomAvatarMediaSource + } else { + mediaSource = senderAvatarMediaSource + } + + notification = try await notification.addSenderIcon(using: mediaProvider, + senderId: senderId, + receiverId: receiverId, + senderName: senderName, + groupName: groupName, + mediaSource: mediaSource, + roomId: roomId) + return notification + } + + // MARK: Message Types + + private func processText(content: TextMessageContent, + receiverId: String, + senderId: String, + roomId: String, + mediaProvider: MediaProviderProtocol?) async throws -> UNMutableNotificationContent { + let notification = try await processCommon(receiverId: receiverId, + senderId: senderId, + roomId: roomId, + mediaProvider: mediaProvider) + notification.body = content.body + + return notification + } + + private func processImage(content: ImageMessageContent, + receiverId: String, + senderId: String, + roomId: String, + mediaProvider: MediaProviderProtocol?) async throws -> UNMutableNotificationContent { + var notification = try await processCommon(receiverId: receiverId, + senderId: senderId, + roomId: roomId, + mediaProvider: mediaProvider) + notification.body = "📷 " + content.body + + notification = await notification.addMediaAttachment(using: mediaProvider, + mediaSource: .init(source: content.source, + mimeType: content.info?.mimetype)) + + return notification + } + + private func processVideo(content: VideoMessageContent, + receiverId: String, + senderId: String, + roomId: String, + mediaProvider: MediaProviderProtocol?) async throws -> UNMutableNotificationContent { + var notification = try await processCommon(receiverId: receiverId, + senderId: senderId, + roomId: roomId, + mediaProvider: mediaProvider) + notification.body = "📹 " + content.body + + notification = await notification.addMediaAttachment(using: mediaProvider, + mediaSource: .init(source: content.source, + mimeType: content.info?.mimetype)) + + return notification + } + + private func processFile(content: FileMessageContent, + receiverId: String, + senderId: String, + roomId: String, + mediaProvider: MediaProviderProtocol?) async throws -> UNMutableNotificationContent { + let notification = try await processCommon(receiverId: receiverId, + senderId: senderId, + roomId: roomId, + mediaProvider: mediaProvider) + notification.body = "📄 " + content.body + + return notification + } + + private func processNotice(content: NoticeMessageContent, + receiverId: String, + senderId: String, + roomId: String, + mediaProvider: MediaProviderProtocol?) async throws -> UNMutableNotificationContent { + let notification = try await processCommon(receiverId: receiverId, + senderId: senderId, + roomId: roomId, + mediaProvider: mediaProvider) + notification.body = "❕ " + content.body + + return notification + } + + private func processEmote(content: EmoteMessageContent, + receiverId: String, + senderId: String, + roomId: String, + mediaProvider: MediaProviderProtocol?) async throws -> UNMutableNotificationContent { + let notification = try await processCommon(receiverId: receiverId, + senderId: senderId, + roomId: roomId, + mediaProvider: mediaProvider) + notification.body = "🫥 " + content.body + + return notification + } + + private func processAudio(content: AudioMessageContent, + receiverId: String, + senderId: String, + roomId: String, + mediaProvider: MediaProviderProtocol?) async throws -> UNMutableNotificationContent { + var notification = try await processCommon(receiverId: receiverId, + senderId: senderId, + roomId: roomId, + mediaProvider: mediaProvider) + notification.body = "🔊 " + content.body + + notification = await notification.addMediaAttachment(using: mediaProvider, + mediaSource: .init(source: content.source, + mimeType: content.info?.mimetype)) + + return notification } } diff --git a/ElementX/Sources/Services/Notification/Proxy/NotificationServiceProxy.swift b/ElementX/Sources/Services/Notification/Proxy/NotificationServiceProxy.swift index f5903130c..f561f19f8 100644 --- a/ElementX/Sources/Services/Notification/Proxy/NotificationServiceProxy.swift +++ b/ElementX/Sources/Services/Notification/Proxy/NotificationServiceProxy.swift @@ -25,7 +25,7 @@ class NotificationServiceProxy: NotificationServiceProxyProtocol { // service = NotificationService(basePath: basePath, userId: userId) } - func notificationItem(roomId: String, eventId: String) async throws -> NotificationItemProxy? { - NotificationItemProxy() + func notificationItem(roomId: String, eventId: String) async throws -> NotificationItemProxyProtocol? { + MockNotificationItemProxy(eventID: eventId, roomID: roomId) } } diff --git a/ElementX/Sources/Services/Notification/Proxy/NotificationServiceProxyProtocol.swift b/ElementX/Sources/Services/Notification/Proxy/NotificationServiceProxyProtocol.swift index b5bda3a9d..043b614b1 100644 --- a/ElementX/Sources/Services/Notification/Proxy/NotificationServiceProxyProtocol.swift +++ b/ElementX/Sources/Services/Notification/Proxy/NotificationServiceProxyProtocol.swift @@ -17,5 +17,5 @@ import Foundation protocol NotificationServiceProxyProtocol { - func notificationItem(roomId: String, eventId: String) async throws -> NotificationItemProxy? + func notificationItem(roomId: String, eventId: String) async throws -> NotificationItemProxyProtocol? } diff --git a/ElementX/Sources/Services/Timeline/TimelineEventProxy.swift b/ElementX/Sources/Services/Timeline/TimelineEventProxy.swift new file mode 100644 index 000000000..76f438d34 --- /dev/null +++ b/ElementX/Sources/Services/Timeline/TimelineEventProxy.swift @@ -0,0 +1,52 @@ +// +// Copyright 2023 New Vector Ltd +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import Foundation +import MatrixRustSDK + +protocol TimelineEventProxyProtocol { + var type: TimelineEventType? { get } + + var eventID: String { get } + + var senderID: String { get } +} + +final class TimelineEventProxy: TimelineEventProxyProtocol { + private let timelineEvent: TimelineEvent + + init(timelineEvent: TimelineEvent) { + self.timelineEvent = timelineEvent + } + + var eventID: String { + timelineEvent.eventId() + } + + var senderID: String { + timelineEvent.senderId() + } + + var type: TimelineEventType? { + try? timelineEvent.eventType() + } +} + +struct MockTimelineEventProxy: TimelineEventProxyProtocol { + let eventID: String + let senderID = "" + let type: TimelineEventType? = nil +} diff --git a/NSE/Sources/Other/NotificationItemProxy+NSE.swift b/NSE/Sources/Other/NotificationItemProxy+NSE.swift deleted file mode 100644 index d0f658e09..000000000 --- a/NSE/Sources/Other/NotificationItemProxy+NSE.swift +++ /dev/null @@ -1,270 +0,0 @@ -// -// Copyright 2022 New Vector Ltd -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. -// - -import Foundation -import MatrixRustSDK -import UserNotifications - -extension NotificationItemProxy { - var requiresMediaProvider: Bool { - false -// if avatarUrl != nil { -// return true -// } -// switch timelineItemProxy { -// case .event(let eventItem): -// guard eventItem.isMessage else { -// // To be handled in the future -// return false -// } -// guard let message = eventItem.content.asMessage() else { -// fatalError("Only handled messages") -// } -// switch message.msgtype() { -// case .image, .video: -// return true -// default: -// return false -// } -// case .virtual: -// return false -// case .other: -// return false -// } - } - - /// Process the receiver item proxy - /// - Parameters: - /// - receiverId: identifier of the user that has received the notification - /// - roomId: Room identifier - /// - 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 process(receiverId: String, - roomId: String, - mediaProvider: MediaProviderProtocol?) async throws -> UNMutableNotificationContent? { -// switch timelineItemProxy { -// case .event(let eventItem): -// guard eventItem.isMessage else { -// // To be handled in the future -// return nil -// } -// guard let message = eventItem.content.asMessage() else { -// fatalError("Item must be a message") -// } -// -// return try await process(message: message, -// senderId: eventItem.sender, -// roomId: roomId, -// mediaProvider: mediaProvider) -// case .virtual: -// return nil -// case .other: -// return nil -// } - // For now we can't solve the sender ID nor get the type of message that we are displaying - // so we are just going to process all of them as a text notification saying "Notification" - let content = TextMessageContent(body: L10n.notification, formatted: nil) - return try await processText(content: content, receiverId: receiverId, senderId: "undefined", roomId: roomId, mediaProvider: mediaProvider) - } - - // MARK: - Private - - // MARK: Common - - private func process(message: Message, - receiverId: String, - senderId: String, - roomId: String, - mediaProvider: MediaProviderProtocol?) async throws -> UNMutableNotificationContent? { - switch message.msgtype() { - case .text(content: let content): - return try await processText(content: content, - receiverId: receiverId, - senderId: senderId, - roomId: roomId, - mediaProvider: mediaProvider) - case .image(content: let content): - return try await processImage(content: content, - receiverId: receiverId, - senderId: senderId, - roomId: roomId, - mediaProvider: mediaProvider) - case .audio(content: let content): - return try await processAudio(content: content, - receiverId: receiverId, - senderId: senderId, - roomId: roomId, - mediaProvider: mediaProvider) - case .video(content: let content): - return try await processVideo(content: content, - receiverId: receiverId, - senderId: senderId, - roomId: roomId, - mediaProvider: mediaProvider) - case .file(content: let content): - return try await processFile(content: content, - receiverId: receiverId, - senderId: senderId, - roomId: roomId, - mediaProvider: mediaProvider) - case .notice(content: let content): - return try await processNotice(content: content, - receiverId: receiverId, - senderId: senderId, - roomId: roomId, - mediaProvider: mediaProvider) - case .emote(content: let content): - return try await processEmote(content: content, - receiverId: receiverId, - senderId: senderId, - roomId: roomId, - mediaProvider: mediaProvider) - case .none: - return nil - } - } - - private func processCommon(receiverId: String, - senderId: String, - roomId: String, - mediaProvider: MediaProviderProtocol?) async throws -> UNMutableNotificationContent { - var notification = UNMutableNotificationContent() - notification.receiverID = receiverId - notification.title = title - if let subtitle = subtitle { - notification.subtitle = subtitle - } - // We can store the room identifier into the thread identifier since it's used for notifications - // that belong to the same group - notification.threadIdentifier = roomId - notification.categoryIdentifier = NotificationConstants.Category.reply - notification.sound = isNoisy ? UNNotificationSound(named: UNNotificationSoundName(rawValue: "message.caf")) : nil - - notification = try await notification.addSenderIcon(using: mediaProvider, - senderId: senderId, - senderName: title, - mediaSource: avatarMediaSource, - roomId: roomId) - - return notification - } - - // MARK: Message Types - - private func processText(content: TextMessageContent, - receiverId: String, - senderId: String, - roomId: String, - mediaProvider: MediaProviderProtocol?) async throws -> UNMutableNotificationContent { - let notification = try await processCommon(receiverId: receiverId, - senderId: senderId, - roomId: roomId, - mediaProvider: mediaProvider) - notification.body = content.body - - return notification - } - - private func processImage(content: ImageMessageContent, - receiverId: String, - senderId: String, - roomId: String, - mediaProvider: MediaProviderProtocol?) async throws -> UNMutableNotificationContent { - var notification = try await processCommon(receiverId: receiverId, - senderId: senderId, - roomId: roomId, - mediaProvider: mediaProvider) - notification.body = "📷 " + content.body - - notification = try await notification.addMediaAttachment(using: mediaProvider, - mediaSource: .init(source: content.source, mimeType: content.info?.mimetype)) - - return notification - } - - private func processVideo(content: VideoMessageContent, - receiverId: String, - senderId: String, - roomId: String, - mediaProvider: MediaProviderProtocol?) async throws -> UNMutableNotificationContent { - var notification = try await processCommon(receiverId: receiverId, - senderId: senderId, - roomId: roomId, - mediaProvider: mediaProvider) - notification.body = "📹 " + content.body - - notification = try await notification.addMediaAttachment(using: mediaProvider, - mediaSource: .init(source: content.source, mimeType: content.info?.mimetype)) - - return notification - } - - private func processFile(content: FileMessageContent, - receiverId: String, - senderId: String, - roomId: String, - mediaProvider: MediaProviderProtocol?) async throws -> UNMutableNotificationContent { - let notification = try await processCommon(receiverId: receiverId, - senderId: senderId, - roomId: roomId, - mediaProvider: mediaProvider) - notification.body = "📄 " + content.body - - return notification - } - - private func processNotice(content: NoticeMessageContent, - receiverId: String, - senderId: String, - roomId: String, - mediaProvider: MediaProviderProtocol?) async throws -> UNMutableNotificationContent { - let notification = try await processCommon(receiverId: receiverId, - senderId: senderId, - roomId: roomId, - mediaProvider: mediaProvider) - notification.body = "❕ " + content.body - - return notification - } - - private func processEmote(content: EmoteMessageContent, - receiverId: String, - senderId: String, - roomId: String, - mediaProvider: MediaProviderProtocol?) async throws -> UNMutableNotificationContent { - let notification = try await processCommon(receiverId: receiverId, - senderId: senderId, - roomId: roomId, - mediaProvider: mediaProvider) - notification.body = "🫥 " + content.body - - return notification - } - - private func processAudio(content: AudioMessageContent, - receiverId: String, - senderId: String, - roomId: String, - mediaProvider: MediaProviderProtocol?) async throws -> UNMutableNotificationContent { - let notification = try await processCommon(receiverId: receiverId, - senderId: senderId, - roomId: roomId, - mediaProvider: mediaProvider) - notification.body = "🔊 " + content.body - - return notification - } -} diff --git a/NSE/Sources/Other/UNMutableNotificationContent.swift b/NSE/Sources/Other/UNMutableNotificationContent.swift deleted file mode 100644 index f96465c0f..000000000 --- a/NSE/Sources/Other/UNMutableNotificationContent.swift +++ /dev/null @@ -1,91 +0,0 @@ -// -// Copyright 2022 New Vector Ltd -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. -// - -import Foundation -import Intents -import UserNotifications - -extension UNMutableNotificationContent { - func addMediaAttachment(using mediaProvider: MediaProviderProtocol?, - mediaSource: MediaSourceProxy) async throws -> UNMutableNotificationContent { - guard let mediaProvider else { - return self - } - switch await mediaProvider.loadFileFromSource(mediaSource) { - case .success(let file): - let attachment = try UNNotificationAttachment(identifier: ProcessInfo.processInfo.globallyUniqueString, - url: file.url, // Needs testing: Does the file get copied before the media handle is be dropped? - options: nil) - attachments.append(attachment) - case .failure(let error): - MXLog.error("Couldn't add media attachment: \(error)") - } - - return self - } - - func addSenderIcon(using mediaProvider: MediaProviderProtocol?, - senderId: String, - senderName: String, - mediaSource: MediaSourceProxy?, - roomId: String) async throws -> UNMutableNotificationContent { - guard let mediaProvider, let mediaSource else { - return self - } - - switch await mediaProvider.loadFileFromSource(mediaSource) { - case .success(let mediaFile): - // Initialize only the sender for a one-to-one message intent. - let handle = INPersonHandle(value: senderId, type: .unknown) - let sender = try INPerson(personHandle: handle, - nameComponents: nil, - displayName: senderName, - image: INImage(imageData: Data(contentsOf: mediaFile.url)), - contactIdentifier: nil, - customIdentifier: nil) - - // Because this communication is incoming, you can infer that the current user is - // a recipient. Don't include the current user when initializing the intent. - let intent = INSendMessageIntent(recipients: nil, - outgoingMessageType: .outgoingMessageText, - content: nil, - speakableGroupName: nil, - conversationIdentifier: roomId, - serviceName: nil, - sender: sender, - attachments: nil) - - // Use the intent to initialize the interaction. - let interaction = INInteraction(intent: intent, response: nil) - - // Interaction direction is incoming because the user is - // receiving this message. - interaction.direction = .incoming - - // Donate the interaction before updating notification content. - try await interaction.donate() - // Update notification content before displaying the - // communication notification. - let updatedContent = try updating(from: intent) - - // swiftlint:disable:next force_cast - return updatedContent.mutableCopy() as! UNMutableNotificationContent - case .failure(let error): - MXLog.error("Couldn't add sender icon: \(error)") - return self - } - } -} diff --git a/NSE/SupportingFiles/target.yml b/NSE/SupportingFiles/target.yml index 8fed7e120..d1e1d21f9 100644 --- a/NSE/SupportingFiles/target.yml +++ b/NSE/SupportingFiles/target.yml @@ -67,6 +67,7 @@ targets: - path: ../SupportingFiles - path: ../../ElementX/Sources/Generated - path: ../../ElementX/Sources/Services/Timeline/TimelineItemProxy.swift + - path: ../../ElementX/Sources/Services/Timeline/TimelineEventProxy.swift - path: ../../ElementX/Sources/Services/Timeline/TimelineItemSender.swift - path: ../../ElementX/Sources/Services/Keychain/KeychainControllerProtocol.swift - path: ../../ElementX/Sources/Services/Keychain/KeychainController.swift diff --git a/UnitTests/Sources/MediaProvider/MediaLoaderTests.swift b/UnitTests/Sources/MediaProvider/MediaLoaderTests.swift index bfb4dfa88..a3d5a2d42 100644 --- a/UnitTests/Sources/MediaProvider/MediaLoaderTests.swift +++ b/UnitTests/Sources/MediaProvider/MediaLoaderTests.swift @@ -55,8 +55,6 @@ final class MediaLoaderTests: XCTestCase { } private class MockMediaLoadingClient: ClientProtocol { - func setNotificationDelegate(notificationDelegate: MatrixRustSDK.NotificationDelegate?) { } - private(set) var numberOfInvocations = 0 func getMediaContent(mediaSource: MatrixRustSDK.MediaSource) throws -> [UInt8] { @@ -146,4 +144,6 @@ private class MockMediaLoadingClient: ClientProtocol { } func searchUsers(searchTerm: String, limit: UInt64) throws -> MatrixRustSDK.SearchUsersResults { fatalError() } + + func setNotificationDelegate(notificationDelegate: MatrixRustSDK.NotificationDelegate?) { } } diff --git a/UnitTests/Sources/NotificationManager/NotificationManagerTests.swift b/UnitTests/Sources/NotificationManager/NotificationManagerTests.swift index ffa1d31f5..6f706e9e0 100644 --- a/UnitTests/Sources/NotificationManager/NotificationManagerTests.swift +++ b/UnitTests/Sources/NotificationManager/NotificationManagerTests.swift @@ -22,6 +22,7 @@ import Combine final class NotificationManagerTests: XCTestCase { var notificationManager: NotificationManager! private let clientProxy = MockClientProxy(userID: "@test:user.net") + private lazy var mockUserSession = MockUserSession(clientProxy: clientProxy, mediaProvider: MockMediaProvider()) private let notificationCenter = UserNotificationCenterSpy() private var authorizationStatusWasGranted = false private var shouldDisplayInAppNotificationReturnValue = false @@ -35,7 +36,7 @@ final class NotificationManagerTests: XCTestCase { notificationManager = NotificationManager(notificationCenter: notificationCenter) notificationManager.start() - notificationManager.setClientProxy(clientProxy) + notificationManager.setUserSession(mockUserSession) } func test_whenRegistered_pusherIsCalled() async {