diff --git a/ElementX/Sources/Application/AppCoordinator.swift b/ElementX/Sources/Application/AppCoordinator.swift index a5843770c..6f7b8a689 100644 --- a/ElementX/Sources/Application/AppCoordinator.swift +++ b/ElementX/Sources/Application/AppCoordinator.swift @@ -84,7 +84,7 @@ class AppCoordinator: AppCoordinatorProtocol { wipeUserData(includingSettings: true) } ServiceLocator.shared.settings.lastVersionLaunched = currentVersion.description - + setupStateMachine() observeApplicationState() diff --git a/ElementX/Sources/Application/AppSettings.swift b/ElementX/Sources/Application/AppSettings.swift index b362613fb..91e1abf7f 100644 --- a/ElementX/Sources/Application/AppSettings.swift +++ b/ElementX/Sources/Application/AppSettings.swift @@ -18,7 +18,7 @@ import Foundation import SwiftUI /// Store Element specific app settings. -final class AppSettings: ObservableObject { +final class AppSettings { private enum UserDefaultsKeys: String { case lastVersionLaunched case seenInvites @@ -69,6 +69,8 @@ final class AppSettings: ObservableObject { /// deleted between runs so should clear data in the shared container and keychain. @UserPreference(key: UserDefaultsKeys.lastVersionLaunched, storageType: .userDefaults(store)) var lastVersionLaunched: String? + + let lastLaunchDate = Date() /// The Set of room identifiers of invites that the user already saw in the invites list. /// This Set is being used to implement badges for unread invites. @@ -146,11 +148,15 @@ final class AppSettings: ObservableObject { /// Tag describing which set of device specific rules a pusher executes. @UserPreference(key: UserDefaultsKeys.pusherProfileTag, storageType: .userDefaults(store)) var pusherProfileTag: String? + + /// A set of all the notification identifiers that have been served so far, it's reset every time the app is launched + @UserPreference(key: SharedUserDefaultsKeys.servedNotificationIdentifiers, initialValue: [], storageType: .userDefaults(store)) + var servedNotificationIdentifiers: Set // MARK: - Other let permalinkBaseURL = URL(staticString: "https://matrix.to") - + // MARK: - Feature Flags // MARK: Start Chat diff --git a/ElementX/Sources/Other/Extensions/UNNotificationContent.swift b/ElementX/Sources/Other/Extensions/UNNotificationContent.swift index 4efbcca45..ea84ee48f 100644 --- a/ElementX/Sources/Other/Extensions/UNNotificationContent.swift +++ b/ElementX/Sources/Other/Extensions/UNNotificationContent.swift @@ -22,6 +22,18 @@ extension UNNotificationContent { @objc var receiverID: String? { userInfo[NotificationConstants.UserInfoKey.receiverIdentifier] as? String } + + @objc var notificationID: String? { + userInfo[NotificationConstants.UserInfoKey.notificationIdentifier] as? String + } + + @objc var roomID: String? { + userInfo[NotificationConstants.UserInfoKey.roomIdentifier] as? String + } + + @objc var eventID: String? { + userInfo[NotificationConstants.UserInfoKey.eventIdentifier] as? String + } } extension UNMutableNotificationContent { @@ -33,7 +45,34 @@ extension UNMutableNotificationContent { userInfo[NotificationConstants.UserInfoKey.receiverIdentifier] = newValue } } - + + override var roomID: String? { + get { + userInfo[NotificationConstants.UserInfoKey.roomIdentifier] as? String + } + set { + userInfo[NotificationConstants.UserInfoKey.roomIdentifier] = newValue + } + } + + override var eventID: String? { + get { + userInfo[NotificationConstants.UserInfoKey.eventIdentifier] as? String + } + set { + userInfo[NotificationConstants.UserInfoKey.eventIdentifier] = newValue + } + } + + override var notificationID: String? { + get { + userInfo[NotificationConstants.UserInfoKey.notificationIdentifier] as? String + } + set { + userInfo[NotificationConstants.UserInfoKey.notificationIdentifier] = newValue + } + } + func addMediaAttachment(using mediaProvider: MediaProviderProtocol?, mediaSource: MediaSourceProxy) async -> UNMutableNotificationContent { guard let mediaProvider else { diff --git a/ElementX/Sources/Other/SharedUserDefaultsKeys.swift b/ElementX/Sources/Other/SharedUserDefaultsKeys.swift new file mode 100644 index 000000000..8af58921b --- /dev/null +++ b/ElementX/Sources/Other/SharedUserDefaultsKeys.swift @@ -0,0 +1,19 @@ +// +// 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. +// + +enum SharedUserDefaultsKeys: String { + case servedNotificationIdentifiers +} diff --git a/ElementX/Sources/Other/UserPreference.swift b/ElementX/Sources/Other/UserPreference.swift index 8f5c8d076..0aae35339 100644 --- a/ElementX/Sources/Other/UserPreference.swift +++ b/ElementX/Sources/Other/UserPreference.swift @@ -75,6 +75,17 @@ extension UserPreference { convenience init(key: R, defaultValue: T, storageType: StorageType) where R.RawValue == String { self.init(key: key.rawValue, defaultValue: defaultValue, storageType: storageType) } + + /// Convenience initializer that also immediatelly stores the provided initialValue. + /// + /// - Parameters: + /// - key: the raw representable key used to store the value, needs conform also to String + /// - initialValue: the initial value that will be stored, the initialValue is also used as defaultValue + /// - storageType: the storage type where the wrappedValue will be stored. + convenience init(key: R, initialValue: T, storageType: StorageType) where R.RawValue == String { + self.init(key: key, defaultValue: initialValue, storageType: storageType) + wrappedValue = initialValue + } convenience init(key: String, storageType: StorageType) where T: ExpressibleByNilLiteral { self.init(key: key, defaultValue: nil, storageType: storageType) diff --git a/ElementX/Sources/Services/Client/ClientProxy.swift b/ElementX/Sources/Services/Client/ClientProxy.swift index 27a43cdaa..3961cacfb 100644 --- a/ElementX/Sources/Services/Client/ClientProxy.swift +++ b/ElementX/Sources/Services/Client/ClientProxy.swift @@ -45,7 +45,8 @@ private class WeakClientProxyWrapper: ClientDelegate, NotificationDelegate, Slid // MARK: - NotificationDelegate func didReceiveNotification(notification: MatrixRustSDK.NotificationItem) { - clientProxy?.didReceiveNotification(notification: NotificationItemProxy(notificationItem: notification)) + guard let userID = clientProxy?.userID else { return } + clientProxy?.didReceiveNotification(notification: NotificationItemProxy(notificationItem: notification, receiverID: userID)) } } @@ -71,6 +72,8 @@ class ClientProxy: ClientProxyProtocol { var invitesViewProxy: SlidingSyncViewProxy? var invitesSummaryProvider: RoomSummaryProviderProtocol? + var notificationsSlidingSyncView: SlidingSyncList? + private var loadCachedAvatarURLTask: Task? private let avatarURLSubject = CurrentValueSubject(nil) var avatarURLPublisher: AnyPublisher { @@ -103,9 +106,9 @@ class ClientProxy: ClientProxyProtocol { let delegate = WeakClientProxyWrapper(clientProxy: self) client.setDelegate(delegate: delegate) // Uncomment to test local notifications -// await Task.dispatch(on: clientQueue) { -// client.setNotificationDelegate(notificationDelegate: delegate) -// } + await Task.dispatch(on: clientQueue) { + client.setNotificationDelegate(notificationDelegate: delegate) + } configureSlidingSync() @@ -319,6 +322,7 @@ class ClientProxy: ClientProxyProtocol { buildAndConfigureVisibleRoomsSlidingSyncView() buildAndConfigureAllRoomsSlidingSyncView() buildAndConfigureInvitesSlidingSyncView() + buildAndConfigureNotificationSyncView() guard let visibleRoomsSlidingSyncView else { MXLog.error("Visible rooms sliding sync view unavailable") @@ -438,7 +442,28 @@ class ClientProxy: ClientProxyProtocol { MXLog.error("Failed building the invites sliding sync view with error: \(error)") } } - + + private func buildAndConfigureNotificationSyncView() { + guard notificationsSlidingSyncView == nil else { + fatalError("This shouldn't be called more than once") + } + + do { + let notificationsSlidingSyncView = try SlidingSyncListBuilder() + .noTimelineLimit() + .requiredState(requiredState: slidingSyncNotificationsRequiredState) + .filters(filters: slidingSyncNotificationsFilters) + .name(name: "Notifications") + .syncMode(mode: .growing) + .batchSize(batchSize: 100) + .build() + + self.notificationsSlidingSyncView = notificationsSlidingSyncView + } catch { + MXLog.error("Failed building the notification sliding sync view with error: \(error)") + } + } + private func buildRoomSummaryProviders() { guard visibleRoomsSummaryProvider == nil, allRoomsSummaryProvider == nil, invitesSummaryProvider == nil else { fatalError("This shouldn't be called more than once") @@ -459,17 +484,12 @@ 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: "") - // 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 slidingSyncRequiredState = [RequiredState(key: "m.room.avatar", value: ""), + RequiredState(key: "m.room.encryption", value: "")] + + private lazy var slidingSyncNotificationsRequiredState = [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: ""), @@ -486,6 +506,17 @@ class ClientProxy: ClientProxyProtocol { roomNameLike: nil, tags: [], notTags: []) + + private lazy var slidingSyncNotificationsFilters = SlidingSyncRequestListFilters(isDm: nil, + spaces: [], + isEncrypted: nil, + isInvite: nil, + isTombstoned: false, + roomTypes: [], + notRoomTypes: ["m.space"], + roomNameLike: nil, + tags: [], + notTags: []) private lazy var slidingSyncInviteFilters = SlidingSyncRequestListFilters(isDm: nil, spaces: [], @@ -519,6 +550,13 @@ class ClientProxy: ClientProxyProtocol { } else { MXLog.error("Invites sliding sync view unavailable") } + + if let notificationsSlidingSyncView { + MXLog.info("Registering notifications view") + _ = slidingSync?.addList(list: notificationsSlidingSyncView) + } else { + MXLog.error("Notifications sliding sync view unavailable") + } restartSync() } diff --git a/ElementX/Sources/Services/Notification/Manager/NotificationManager.swift b/ElementX/Sources/Services/Notification/Manager/NotificationManager.swift index ad94e5e08..7d2a70aec 100644 --- a/ElementX/Sources/Services/Notification/Manager/NotificationManager.swift +++ b/ElementX/Sources/Services/Notification/Manager/NotificationManager.swift @@ -101,12 +101,19 @@ class NotificationManager: NSObject, NotificationManagerProtocol { } private func showLocalNotification(_ notification: NotificationItemProxyProtocol) async { - guard let userSession else { return } + guard let userSession, + notification.event.timestamp > ServiceLocator.shared.settings.lastLaunchDate else { return } do { - guard let content = try await notification.process(receiverId: userSession.userID, roomId: notification.roomID, mediaProvider: userSession.mediaProvider) else { + guard let content = try await notification.process(mediaProvider: userSession.mediaProvider), + let identifier = notification.id else { return } - let request = UNNotificationRequest(identifier: ProcessInfo.processInfo.globallyUniqueString, content: content, trigger: nil) + let request = UNNotificationRequest(identifier: identifier, content: content, trigger: nil) + guard !ServiceLocator.shared.settings.servedNotificationIdentifiers.contains(identifier) else { + MXLog.info("NotificationManager] local notification discarded because it has already been served") + return + } + ServiceLocator.shared.settings.servedNotificationIdentifiers.insert(identifier) try await notificationCenter.add(request) } catch { MXLog.error("[NotificationManager] show local notification item failed: \(error)") diff --git a/ElementX/Sources/Services/Notification/NotificationConstants.swift b/ElementX/Sources/Services/Notification/NotificationConstants.swift index 882bced9b..612b666fa 100644 --- a/ElementX/Sources/Services/Notification/NotificationConstants.swift +++ b/ElementX/Sources/Services/Notification/NotificationConstants.swift @@ -23,6 +23,7 @@ enum NotificationConstants { static let unreadCount = "unread_count" static let pusherNotificationClientIdentifier = "pusher_notification_client_identifier" static let receiverIdentifier = "receiver_id" + static let notificationIdentifier = "notification_identifier" } enum Category { diff --git a/ElementX/Sources/Services/Notification/Proxy/NotificationItemProxy.swift b/ElementX/Sources/Services/Notification/Proxy/NotificationItemProxy.swift index 88d60f3a4..7b23b7ef0 100644 --- a/ElementX/Sources/Services/Notification/Proxy/NotificationItemProxy.swift +++ b/ElementX/Sources/Services/Notification/Proxy/NotificationItemProxy.swift @@ -14,6 +14,7 @@ // limitations under the License. // +import CryptoKit import Foundation import UserNotifications @@ -24,6 +25,8 @@ protocol NotificationItemProxyProtocol { var roomID: String { get } + var receiverID: String { get } + var senderDisplayName: String? { get } var senderAvatarMediaSource: MediaSourceProxy? { get } @@ -39,8 +42,20 @@ protocol NotificationItemProxyProtocol { var isEncrypted: Bool { get } } +extension NotificationItemProxyProtocol { + var id: String? { + let identifiers = receiverID + roomID + event.eventID + guard let data = identifiers.data(using: .utf8) else { + return nil + } + let digest = SHA256.hash(data: data) + return digest.compactMap { String(format: "%02x", $0) }.joined() + } +} + struct NotificationItemProxy: NotificationItemProxyProtocol { let notificationItem: NotificationItem + let receiverID: String var event: TimelineEventProxyProtocol { TimelineEventProxy(timelineEvent: notificationItem.event) @@ -99,6 +114,8 @@ struct MockNotificationItemProxy: NotificationItemProxyProtocol { let roomID: String + let receiverID: String + var senderDisplayName: String? { nil } var senderAvatarURL: String? { nil } @@ -116,6 +133,8 @@ struct MockNotificationItemProxy: NotificationItemProxyProtocol { var senderAvatarMediaSource: MediaSourceProxy? { nil } var roomAvatarMediaSource: MediaSourceProxy? { nil } + + var notificationIdentifier: String { "" } } extension NotificationItemProxyProtocol { @@ -148,11 +167,9 @@ extension NotificationItemProxyProtocol { /// - 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? { + func process(mediaProvider: MediaProviderProtocol?) async throws -> UNMutableNotificationContent? { if self is MockNotificationItemProxy { - return processMock(receiverId: receiverId, roomId: roomId) + return processMock() } else { switch event.type { case .none, .state: @@ -162,19 +179,19 @@ extension NotificationItemProxyProtocol { 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) + return try await processEmote(content: content, mediaProvider: mediaProvider) case .image(content: let content): - return try await processImage(content: content, receiverId: receiverId, senderId: event.senderID, roomId: roomId, mediaProvider: mediaProvider) + return try await processImage(content: content, mediaProvider: mediaProvider) case .audio(content: let content): - return try await processAudio(content: content, receiverId: receiverId, senderId: event.senderID, roomId: roomId, mediaProvider: mediaProvider) + return try await processAudio(content: content, mediaProvider: mediaProvider) case .video(content: let content): - return try await processVideo(content: content, receiverId: receiverId, senderId: event.senderID, roomId: roomId, mediaProvider: mediaProvider) + return try await processVideo(content: content, mediaProvider: mediaProvider) case .file(content: let content): - return try await processFile(content: content, receiverId: receiverId, senderId: event.senderID, roomId: roomId, mediaProvider: mediaProvider) + return try await processFile(content: content, mediaProvider: mediaProvider) case .notice(content: let content): - return try await processNotice(content: content, receiverId: receiverId, senderId: event.senderID, roomId: roomId, mediaProvider: mediaProvider) + return try await processNotice(content: content, mediaProvider: mediaProvider) case .text(content: let content): - return try await processText(content: content, receiverId: receiverId, senderId: event.senderID, roomId: roomId, mediaProvider: mediaProvider) + return try await processText(content: content, mediaProvider: mediaProvider) } default: return nil @@ -188,32 +205,33 @@ extension NotificationItemProxyProtocol { // MARK: - Private // To be removed once we don't need the mock anymore - private func processMock(receiverId: String, - roomId: String) -> UNMutableNotificationContent { + private func processMock() -> UNMutableNotificationContent { let notification = UNMutableNotificationContent() - notification.receiverID = receiverId + notification.receiverID = receiverID + notification.roomID = roomID + notification.eventID = event.eventID + notification.notificationID = id notification.title = InfoPlistReader(bundle: .app).bundleDisplayName notification.body = L10n.notification - notification.threadIdentifier = roomId + 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 { + private func processCommon(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.receiverID = receiverID + notification.roomID = roomID + notification.eventID = event.eventID + notification.notificationID = id 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.threadIdentifier = roomID notification.categoryIdentifier = NotificationConstants.Category.reply notification.sound = isNoisy ? UNNotificationSound(named: UNNotificationSoundName(rawValue: "message.caf")) : nil @@ -228,40 +246,28 @@ extension NotificationItemProxyProtocol { } notification = try await notification.addSenderIcon(using: mediaProvider, - senderId: senderId, - receiverId: receiverId, + senderId: event.senderID, + receiverId: receiverID, senderName: senderName, groupName: groupName, mediaSource: mediaSource, - roomId: roomId) + 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) + let notification = try await processCommon(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) + var notification = try await processCommon(mediaProvider: mediaProvider) notification.body = "📷 " + content.body notification = await notification.addMediaAttachment(using: mediaProvider, @@ -272,14 +278,8 @@ extension NotificationItemProxyProtocol { } 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) + var notification = try await processCommon(mediaProvider: mediaProvider) notification.body = "📹 " + content.body notification = await notification.addMediaAttachment(using: mediaProvider, @@ -290,56 +290,32 @@ extension NotificationItemProxyProtocol { } 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) + let notification = try await processCommon(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) + let notification = try await processCommon(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) + let notification = try await processCommon(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) + var notification = try await processCommon(mediaProvider: mediaProvider) notification.body = "🔊 " + content.body notification = await notification.addMediaAttachment(using: mediaProvider, diff --git a/ElementX/Sources/Services/Notification/Proxy/NotificationServiceProxy.swift b/ElementX/Sources/Services/Notification/Proxy/NotificationServiceProxy.swift index f561f19f8..a9bf07f1a 100644 --- a/ElementX/Sources/Services/Notification/Proxy/NotificationServiceProxy.swift +++ b/ElementX/Sources/Services/Notification/Proxy/NotificationServiceProxy.swift @@ -18,14 +18,16 @@ import Foundation import MatrixRustSDK class NotificationServiceProxy: NotificationServiceProxyProtocol { + private let userID: String // private let service: NotificationServiceProtocol init(basePath: String, - userId: String) { + userID: String) { + self.userID = userID // service = NotificationService(basePath: basePath, userId: userId) } func notificationItem(roomId: String, eventId: String) async throws -> NotificationItemProxyProtocol? { - MockNotificationItemProxy(eventID: eventId, roomID: roomId) + MockNotificationItemProxy(eventID: eventId, roomID: roomId, receiverID: userID) } } diff --git a/ElementX/Sources/Services/Timeline/TimelineEventProxy.swift b/ElementX/Sources/Services/Timeline/TimelineEventProxy.swift index 76f438d34..7c88b43d5 100644 --- a/ElementX/Sources/Services/Timeline/TimelineEventProxy.swift +++ b/ElementX/Sources/Services/Timeline/TimelineEventProxy.swift @@ -23,6 +23,8 @@ protocol TimelineEventProxyProtocol { var eventID: String { get } var senderID: String { get } + + var timestamp: Date { get } } final class TimelineEventProxy: TimelineEventProxyProtocol { @@ -43,10 +45,15 @@ final class TimelineEventProxy: TimelineEventProxyProtocol { var type: TimelineEventType? { try? timelineEvent.eventType() } + + var timestamp: Date { + Date(timeIntervalSince1970: TimeInterval(timelineEvent.timestamp() / 1000)) + } } struct MockTimelineEventProxy: TimelineEventProxyProtocol { let eventID: String let senderID = "" let type: TimelineEventType? = nil + let timestamp = Date() } diff --git a/NSE/Sources/NotificationServiceExtension.swift b/NSE/Sources/NotificationServiceExtension.swift index 768f027f5..d0d66b0b0 100644 --- a/NSE/Sources/NotificationServiceExtension.swift +++ b/NSE/Sources/NotificationServiceExtension.swift @@ -19,6 +19,7 @@ import MatrixRustSDK import UserNotifications class NotificationServiceExtension: UNNotificationServiceExtension { + private let settings = NSESettings() private lazy var keychainController = KeychainController(service: .sessions, accessGroup: InfoPlistReader.main.keychainAccessGroupIdentifier) var handler: ((UNNotificationContent) -> Void)? @@ -29,8 +30,8 @@ class NotificationServiceExtension: UNNotificationServiceExtension { guard !DataProtectionManager.isDeviceLockedAfterReboot(containerURL: URL.appGroupContainerDirectory), let roomId = request.roomId, let eventId = request.eventId, - let notificationID = request.pusherNotificationClientIdentifier, - let credentials = keychainController.restorationTokens().first(where: { $0.restorationToken.pusherNotificationClientIdentifier == notificationID }) else { + let clientID = request.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 // - Not a Matrix notification @@ -69,7 +70,7 @@ class NotificationServiceExtension: UNNotificationServiceExtension { MXLog.info("\(tag) run with roomId: \(roomId), eventId: \(eventId)") let service = NotificationServiceProxy(basePath: URL.sessionsBaseDirectory.path, - userId: credentials.userID) + userID: credentials.userID) guard let itemProxy = try await service.notificationItem(roomId: roomId, eventId: eventId) else { @@ -81,9 +82,7 @@ class NotificationServiceExtension: UNNotificationServiceExtension { // First process without a media proxy. // After this some properties of the notification should be set, like title, subtitle, sound etc. - guard let firstContent = try await itemProxy.process(receiverId: credentials.userID, - roomId: roomId, - mediaProvider: nil) else { + guard let firstContent = try await itemProxy.process(mediaProvider: nil) else { MXLog.error("\(tag) not even first content") // Notification should be discarded @@ -103,9 +102,7 @@ class NotificationServiceExtension: UNNotificationServiceExtension { MXLog.info("\(tag) process with media") // There is some media to load, process it again - if let latestContent = try await itemProxy.process(receiverId: credentials.userID, - roomId: roomId, - mediaProvider: createMediaProvider(with: credentials)) { + if let latestContent = try await itemProxy.process(mediaProvider: createMediaProvider(with: credentials)) { // Processing finished, hopefully with some media modifiedContent = latestContent return notify() @@ -137,6 +134,15 @@ class NotificationServiceExtension: UNNotificationServiceExtension { MXLog.info("\(tag) notify: no modified content") return } + + guard let identifier = modifiedContent.notificationID, + !settings.servedNotificationIdentifiers.contains(identifier) else { + MXLog.info("\(tag) notify: notification already served") + discard() + return + } + + settings.servedNotificationIdentifiers.insert(identifier) handler?(modifiedContent) handler = nil self.modifiedContent = nil diff --git a/NSE/Sources/Other/NSESettings.swift b/NSE/Sources/Other/NSESettings.swift new file mode 100644 index 000000000..4205e208b --- /dev/null +++ b/NSE/Sources/Other/NSESettings.swift @@ -0,0 +1,28 @@ +// +// 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 + +final class NSESettings { + private static var suiteName: String = InfoPlistReader.main.appGroupIdentifier + + /// UserDefaults to be used on reads and writes. + private static var store: UserDefaults! = UserDefaults(suiteName: suiteName) + + /// A set of all the notification identifiers that have been served so far, it's reset every time the app is launched + @UserPreference(key: SharedUserDefaultsKeys.servedNotificationIdentifiers, defaultValue: [], storageType: .userDefaults(store)) + var servedNotificationIdentifiers: Set +} diff --git a/NSE/SupportingFiles/target.yml b/NSE/SupportingFiles/target.yml index d1e1d21f9..763c5710f 100644 --- a/NSE/SupportingFiles/target.yml +++ b/NSE/SupportingFiles/target.yml @@ -90,3 +90,5 @@ targets: - path: ../../ElementX/Sources/Other/AvatarSize.swift - path: ../../ElementX/Sources/Other/InfoPlistReader.swift - path: ../../ElementX/Sources/Other/Extensions/UNNotificationContent.swift + - path: ../../ElementX/Sources/Other/UserPreference.swift + - path: ../../ElementX/Sources/Other/SharedUserDefaultsKeys.swift diff --git a/changelog.d/813.feature b/changelog.d/813.feature new file mode 100644 index 000000000..9e75a6051 --- /dev/null +++ b/changelog.d/813.feature @@ -0,0 +1 @@ +Local notifications support, these can also be decrypted and shown as rich push notifications. \ No newline at end of file