Avoid race and duplication conditions between local and remote notifications (#848)

* added timestamp control of the local notification

* improved the code, and implemented the solution, just needs some testing

* sdk bump

* UserPreference init with initialValue instead of defaultValue

* pr suggestions

* changelog

* notifications sliding sync view added
This commit is contained in:
Mauro
2023-05-03 16:28:07 +02:00
committed by GitHub
parent 60c2692a26
commit 95492d7e10
15 changed files with 251 additions and 108 deletions
@@ -84,7 +84,7 @@ class AppCoordinator: AppCoordinatorProtocol {
wipeUserData(includingSettings: true) wipeUserData(includingSettings: true)
} }
ServiceLocator.shared.settings.lastVersionLaunched = currentVersion.description ServiceLocator.shared.settings.lastVersionLaunched = currentVersion.description
setupStateMachine() setupStateMachine()
observeApplicationState() observeApplicationState()
@@ -18,7 +18,7 @@ import Foundation
import SwiftUI import SwiftUI
/// Store Element specific app settings. /// Store Element specific app settings.
final class AppSettings: ObservableObject { final class AppSettings {
private enum UserDefaultsKeys: String { private enum UserDefaultsKeys: String {
case lastVersionLaunched case lastVersionLaunched
case seenInvites case seenInvites
@@ -69,6 +69,8 @@ final class AppSettings: ObservableObject {
/// deleted between runs so should clear data in the shared container and keychain. /// deleted between runs so should clear data in the shared container and keychain.
@UserPreference(key: UserDefaultsKeys.lastVersionLaunched, storageType: .userDefaults(store)) @UserPreference(key: UserDefaultsKeys.lastVersionLaunched, storageType: .userDefaults(store))
var lastVersionLaunched: String? var lastVersionLaunched: String?
let lastLaunchDate = Date()
/// The Set of room identifiers of invites that the user already saw in the invites list. /// 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. /// 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. /// Tag describing which set of device specific rules a pusher executes.
@UserPreference(key: UserDefaultsKeys.pusherProfileTag, storageType: .userDefaults(store)) @UserPreference(key: UserDefaultsKeys.pusherProfileTag, storageType: .userDefaults(store))
var pusherProfileTag: String? 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<String>
// MARK: - Other // MARK: - Other
let permalinkBaseURL = URL(staticString: "https://matrix.to") let permalinkBaseURL = URL(staticString: "https://matrix.to")
// MARK: - Feature Flags // MARK: - Feature Flags
// MARK: Start Chat // MARK: Start Chat
@@ -22,6 +22,18 @@ extension UNNotificationContent {
@objc var receiverID: String? { @objc var receiverID: String? {
userInfo[NotificationConstants.UserInfoKey.receiverIdentifier] as? 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 { extension UNMutableNotificationContent {
@@ -33,7 +45,34 @@ extension UNMutableNotificationContent {
userInfo[NotificationConstants.UserInfoKey.receiverIdentifier] = newValue 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?, func addMediaAttachment(using mediaProvider: MediaProviderProtocol?,
mediaSource: MediaSourceProxy) async -> UNMutableNotificationContent { mediaSource: MediaSourceProxy) async -> UNMutableNotificationContent {
guard let mediaProvider else { guard let mediaProvider else {
@@ -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
}
@@ -75,6 +75,17 @@ extension UserPreference {
convenience init<R: RawRepresentable>(key: R, defaultValue: T, storageType: StorageType) where R.RawValue == String { convenience init<R: RawRepresentable>(key: R, defaultValue: T, storageType: StorageType) where R.RawValue == String {
self.init(key: key.rawValue, defaultValue: defaultValue, storageType: storageType) 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<R: RawRepresentable>(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 { convenience init(key: String, storageType: StorageType) where T: ExpressibleByNilLiteral {
self.init(key: key, defaultValue: nil, storageType: storageType) self.init(key: key, defaultValue: nil, storageType: storageType)
@@ -45,7 +45,8 @@ private class WeakClientProxyWrapper: ClientDelegate, NotificationDelegate, Slid
// MARK: - NotificationDelegate // MARK: - NotificationDelegate
func didReceiveNotification(notification: MatrixRustSDK.NotificationItem) { 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 invitesViewProxy: SlidingSyncViewProxy?
var invitesSummaryProvider: RoomSummaryProviderProtocol? var invitesSummaryProvider: RoomSummaryProviderProtocol?
var notificationsSlidingSyncView: SlidingSyncList?
private var loadCachedAvatarURLTask: Task<Void, Never>? private var loadCachedAvatarURLTask: Task<Void, Never>?
private let avatarURLSubject = CurrentValueSubject<URL?, Never>(nil) private let avatarURLSubject = CurrentValueSubject<URL?, Never>(nil)
var avatarURLPublisher: AnyPublisher<URL?, Never> { var avatarURLPublisher: AnyPublisher<URL?, Never> {
@@ -103,9 +106,9 @@ class ClientProxy: ClientProxyProtocol {
let delegate = WeakClientProxyWrapper(clientProxy: self) let delegate = WeakClientProxyWrapper(clientProxy: self)
client.setDelegate(delegate: delegate) client.setDelegate(delegate: delegate)
// Uncomment to test local notifications // Uncomment to test local notifications
// await Task.dispatch(on: clientQueue) { await Task.dispatch(on: clientQueue) {
// client.setNotificationDelegate(notificationDelegate: delegate) client.setNotificationDelegate(notificationDelegate: delegate)
// } }
configureSlidingSync() configureSlidingSync()
@@ -319,6 +322,7 @@ class ClientProxy: ClientProxyProtocol {
buildAndConfigureVisibleRoomsSlidingSyncView() buildAndConfigureVisibleRoomsSlidingSyncView()
buildAndConfigureAllRoomsSlidingSyncView() buildAndConfigureAllRoomsSlidingSyncView()
buildAndConfigureInvitesSlidingSyncView() buildAndConfigureInvitesSlidingSyncView()
buildAndConfigureNotificationSyncView()
guard let visibleRoomsSlidingSyncView else { guard let visibleRoomsSlidingSyncView else {
MXLog.error("Visible rooms sliding sync view unavailable") 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)") 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() { private func buildRoomSummaryProviders() {
guard visibleRoomsSummaryProvider == nil, allRoomsSummaryProvider == nil, invitesSummaryProvider == nil else { guard visibleRoomsSummaryProvider == nil, allRoomsSummaryProvider == nil, invitesSummaryProvider == nil else {
fatalError("This shouldn't be called more than once") fatalError("This shouldn't be called more than once")
@@ -459,17 +484,12 @@ class ClientProxy: ClientProxyProtocol {
eventStringBuilder: RoomEventStringBuilder(stateEventStringBuilder: RoomStateEventStringBuilder(userID: userID))) eventStringBuilder: RoomEventStringBuilder(stateEventStringBuilder: RoomStateEventStringBuilder(userID: userID)))
} }
private lazy var slidingSyncRequiredState = [ private lazy var slidingSyncRequiredState = [RequiredState(key: "m.room.avatar", value: ""),
RequiredState(key: "m.room.avatar", value: ""), RequiredState(key: "m.room.encryption", value: "")]
RequiredState(key: "m.room.encryption", value: "")
// These are required for notifications private lazy var slidingSyncNotificationsRequiredState = [RequiredState(key: "m.room.member", value: "$ME"),
// The idea is to create another SS RequiredState(key: "m.room.power_levels", value: ""),
// to listen to them separately RequiredState(key: "m.room.name", value: "")]
// 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: ""), private lazy var slidingSyncInvitesRequiredState = [RequiredState(key: "m.room.avatar", value: ""),
RequiredState(key: "m.room.encryption", value: ""), RequiredState(key: "m.room.encryption", value: ""),
@@ -486,6 +506,17 @@ class ClientProxy: ClientProxyProtocol {
roomNameLike: nil, roomNameLike: nil,
tags: [], tags: [],
notTags: []) 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, private lazy var slidingSyncInviteFilters = SlidingSyncRequestListFilters(isDm: nil,
spaces: [], spaces: [],
@@ -519,6 +550,13 @@ class ClientProxy: ClientProxyProtocol {
} else { } else {
MXLog.error("Invites sliding sync view unavailable") 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() restartSync()
} }
@@ -101,12 +101,19 @@ class NotificationManager: NSObject, NotificationManagerProtocol {
} }
private func showLocalNotification(_ notification: NotificationItemProxyProtocol) async { private func showLocalNotification(_ notification: NotificationItemProxyProtocol) async {
guard let userSession else { return } guard let userSession,
notification.event.timestamp > ServiceLocator.shared.settings.lastLaunchDate else { return }
do { 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 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) try await notificationCenter.add(request)
} catch { } catch {
MXLog.error("[NotificationManager] show local notification item failed: \(error)") MXLog.error("[NotificationManager] show local notification item failed: \(error)")
@@ -23,6 +23,7 @@ enum NotificationConstants {
static let unreadCount = "unread_count" static let unreadCount = "unread_count"
static let pusherNotificationClientIdentifier = "pusher_notification_client_identifier" static let pusherNotificationClientIdentifier = "pusher_notification_client_identifier"
static let receiverIdentifier = "receiver_id" static let receiverIdentifier = "receiver_id"
static let notificationIdentifier = "notification_identifier"
} }
enum Category { enum Category {
@@ -14,6 +14,7 @@
// limitations under the License. // limitations under the License.
// //
import CryptoKit
import Foundation import Foundation
import UserNotifications import UserNotifications
@@ -24,6 +25,8 @@ protocol NotificationItemProxyProtocol {
var roomID: String { get } var roomID: String { get }
var receiverID: String { get }
var senderDisplayName: String? { get } var senderDisplayName: String? { get }
var senderAvatarMediaSource: MediaSourceProxy? { get } var senderAvatarMediaSource: MediaSourceProxy? { get }
@@ -39,8 +42,20 @@ protocol NotificationItemProxyProtocol {
var isEncrypted: Bool { get } 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 { struct NotificationItemProxy: NotificationItemProxyProtocol {
let notificationItem: NotificationItem let notificationItem: NotificationItem
let receiverID: String
var event: TimelineEventProxyProtocol { var event: TimelineEventProxyProtocol {
TimelineEventProxy(timelineEvent: notificationItem.event) TimelineEventProxy(timelineEvent: notificationItem.event)
@@ -99,6 +114,8 @@ struct MockNotificationItemProxy: NotificationItemProxyProtocol {
let roomID: String let roomID: String
let receiverID: String
var senderDisplayName: String? { nil } var senderDisplayName: String? { nil }
var senderAvatarURL: String? { nil } var senderAvatarURL: String? { nil }
@@ -116,6 +133,8 @@ struct MockNotificationItemProxy: NotificationItemProxyProtocol {
var senderAvatarMediaSource: MediaSourceProxy? { nil } var senderAvatarMediaSource: MediaSourceProxy? { nil }
var roomAvatarMediaSource: MediaSourceProxy? { nil } var roomAvatarMediaSource: MediaSourceProxy? { nil }
var notificationIdentifier: String { "" }
} }
extension NotificationItemProxyProtocol { extension NotificationItemProxyProtocol {
@@ -148,11 +167,9 @@ extension NotificationItemProxyProtocol {
/// - roomId: Room identifier /// - roomId: Room identifier
/// - mediaProvider: Media provider to process also media. May be passed nil to ignore media operations. /// - 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. /// - Returns: A notification content object if the notification should be displayed. Otherwise nil.
func process(receiverId: String, func process(mediaProvider: MediaProviderProtocol?) async throws -> UNMutableNotificationContent? {
roomId: String,
mediaProvider: MediaProviderProtocol?) async throws -> UNMutableNotificationContent? {
if self is MockNotificationItemProxy { if self is MockNotificationItemProxy {
return processMock(receiverId: receiverId, roomId: roomId) return processMock()
} else { } else {
switch event.type { switch event.type {
case .none, .state: case .none, .state:
@@ -162,19 +179,19 @@ extension NotificationItemProxyProtocol {
case .roomMessage(messageType: let messageType): case .roomMessage(messageType: let messageType):
switch messageType { switch messageType {
case .emote(content: let content): 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): 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): 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): 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): 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): 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): 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: default:
return nil return nil
@@ -188,32 +205,33 @@ extension NotificationItemProxyProtocol {
// MARK: - Private // MARK: - Private
// To be removed once we don't need the mock anymore // To be removed once we don't need the mock anymore
private func processMock(receiverId: String, private func processMock() -> UNMutableNotificationContent {
roomId: String) -> UNMutableNotificationContent {
let notification = 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.title = InfoPlistReader(bundle: .app).bundleDisplayName
notification.body = L10n.notification notification.body = L10n.notification
notification.threadIdentifier = roomId notification.threadIdentifier = roomID
notification.categoryIdentifier = NotificationConstants.Category.reply notification.categoryIdentifier = NotificationConstants.Category.reply
notification.sound = isNoisy ? UNNotificationSound(named: UNNotificationSoundName(rawValue: "message.caf")) : nil notification.sound = isNoisy ? UNNotificationSound(named: UNNotificationSoundName(rawValue: "message.caf")) : nil
return notification return notification
} }
private func processCommon(receiverId: String, private func processCommon(mediaProvider: MediaProviderProtocol?) async throws -> UNMutableNotificationContent {
senderId: String,
roomId: String,
mediaProvider: MediaProviderProtocol?) async throws -> UNMutableNotificationContent {
var notification = UNMutableNotificationContent() var notification = UNMutableNotificationContent()
notification.receiverID = receiverId notification.receiverID = receiverID
// These are fallbacks since the senderIcon also sets the title and the subtitle notification.roomID = roomID
notification.eventID = event.eventID
notification.notificationID = id
notification.title = senderDisplayName ?? roomDisplayName notification.title = senderDisplayName ?? roomDisplayName
if notification.title != roomDisplayName { if notification.title != roomDisplayName {
notification.subtitle = roomDisplayName notification.subtitle = roomDisplayName
} }
// We can store the room identifier into the thread identifier since it's used for notifications // We can store the room identifier into the thread identifier since it's used for notifications
// that belong to the same group // that belong to the same group
notification.threadIdentifier = roomId notification.threadIdentifier = roomID
notification.categoryIdentifier = NotificationConstants.Category.reply notification.categoryIdentifier = NotificationConstants.Category.reply
notification.sound = isNoisy ? UNNotificationSound(named: UNNotificationSoundName(rawValue: "message.caf")) : nil notification.sound = isNoisy ? UNNotificationSound(named: UNNotificationSoundName(rawValue: "message.caf")) : nil
@@ -228,40 +246,28 @@ extension NotificationItemProxyProtocol {
} }
notification = try await notification.addSenderIcon(using: mediaProvider, notification = try await notification.addSenderIcon(using: mediaProvider,
senderId: senderId, senderId: event.senderID,
receiverId: receiverId, receiverId: receiverID,
senderName: senderName, senderName: senderName,
groupName: groupName, groupName: groupName,
mediaSource: mediaSource, mediaSource: mediaSource,
roomId: roomId) roomId: roomID)
return notification return notification
} }
// MARK: Message Types // MARK: Message Types
private func processText(content: TextMessageContent, private func processText(content: TextMessageContent,
receiverId: String,
senderId: String,
roomId: String,
mediaProvider: MediaProviderProtocol?) async throws -> UNMutableNotificationContent { mediaProvider: MediaProviderProtocol?) async throws -> UNMutableNotificationContent {
let notification = try await processCommon(receiverId: receiverId, let notification = try await processCommon(mediaProvider: mediaProvider)
senderId: senderId,
roomId: roomId,
mediaProvider: mediaProvider)
notification.body = content.body notification.body = content.body
return notification return notification
} }
private func processImage(content: ImageMessageContent, private func processImage(content: ImageMessageContent,
receiverId: String,
senderId: String,
roomId: String,
mediaProvider: MediaProviderProtocol?) async throws -> UNMutableNotificationContent { mediaProvider: MediaProviderProtocol?) async throws -> UNMutableNotificationContent {
var notification = try await processCommon(receiverId: receiverId, var notification = try await processCommon(mediaProvider: mediaProvider)
senderId: senderId,
roomId: roomId,
mediaProvider: mediaProvider)
notification.body = "📷 " + content.body notification.body = "📷 " + content.body
notification = await notification.addMediaAttachment(using: mediaProvider, notification = await notification.addMediaAttachment(using: mediaProvider,
@@ -272,14 +278,8 @@ extension NotificationItemProxyProtocol {
} }
private func processVideo(content: VideoMessageContent, private func processVideo(content: VideoMessageContent,
receiverId: String,
senderId: String,
roomId: String,
mediaProvider: MediaProviderProtocol?) async throws -> UNMutableNotificationContent { mediaProvider: MediaProviderProtocol?) async throws -> UNMutableNotificationContent {
var notification = try await processCommon(receiverId: receiverId, var notification = try await processCommon(mediaProvider: mediaProvider)
senderId: senderId,
roomId: roomId,
mediaProvider: mediaProvider)
notification.body = "📹 " + content.body notification.body = "📹 " + content.body
notification = await notification.addMediaAttachment(using: mediaProvider, notification = await notification.addMediaAttachment(using: mediaProvider,
@@ -290,56 +290,32 @@ extension NotificationItemProxyProtocol {
} }
private func processFile(content: FileMessageContent, private func processFile(content: FileMessageContent,
receiverId: String,
senderId: String,
roomId: String,
mediaProvider: MediaProviderProtocol?) async throws -> UNMutableNotificationContent { mediaProvider: MediaProviderProtocol?) async throws -> UNMutableNotificationContent {
let notification = try await processCommon(receiverId: receiverId, let notification = try await processCommon(mediaProvider: mediaProvider)
senderId: senderId,
roomId: roomId,
mediaProvider: mediaProvider)
notification.body = "📄 " + content.body notification.body = "📄 " + content.body
return notification return notification
} }
private func processNotice(content: NoticeMessageContent, private func processNotice(content: NoticeMessageContent,
receiverId: String,
senderId: String,
roomId: String,
mediaProvider: MediaProviderProtocol?) async throws -> UNMutableNotificationContent { mediaProvider: MediaProviderProtocol?) async throws -> UNMutableNotificationContent {
let notification = try await processCommon(receiverId: receiverId, let notification = try await processCommon(mediaProvider: mediaProvider)
senderId: senderId,
roomId: roomId,
mediaProvider: mediaProvider)
notification.body = "❕ " + content.body notification.body = "❕ " + content.body
return notification return notification
} }
private func processEmote(content: EmoteMessageContent, private func processEmote(content: EmoteMessageContent,
receiverId: String,
senderId: String,
roomId: String,
mediaProvider: MediaProviderProtocol?) async throws -> UNMutableNotificationContent { mediaProvider: MediaProviderProtocol?) async throws -> UNMutableNotificationContent {
let notification = try await processCommon(receiverId: receiverId, let notification = try await processCommon(mediaProvider: mediaProvider)
senderId: senderId,
roomId: roomId,
mediaProvider: mediaProvider)
notification.body = "🫥 " + content.body notification.body = "🫥 " + content.body
return notification return notification
} }
private func processAudio(content: AudioMessageContent, private func processAudio(content: AudioMessageContent,
receiverId: String,
senderId: String,
roomId: String,
mediaProvider: MediaProviderProtocol?) async throws -> UNMutableNotificationContent { mediaProvider: MediaProviderProtocol?) async throws -> UNMutableNotificationContent {
var notification = try await processCommon(receiverId: receiverId, var notification = try await processCommon(mediaProvider: mediaProvider)
senderId: senderId,
roomId: roomId,
mediaProvider: mediaProvider)
notification.body = "🔊 " + content.body notification.body = "🔊 " + content.body
notification = await notification.addMediaAttachment(using: mediaProvider, notification = await notification.addMediaAttachment(using: mediaProvider,
@@ -18,14 +18,16 @@ import Foundation
import MatrixRustSDK import MatrixRustSDK
class NotificationServiceProxy: NotificationServiceProxyProtocol { class NotificationServiceProxy: NotificationServiceProxyProtocol {
private let userID: String
// private let service: NotificationServiceProtocol // private let service: NotificationServiceProtocol
init(basePath: String, init(basePath: String,
userId: String) { userID: String) {
self.userID = userID
// service = NotificationService(basePath: basePath, userId: userId) // service = NotificationService(basePath: basePath, userId: userId)
} }
func notificationItem(roomId: String, eventId: String) async throws -> NotificationItemProxyProtocol? { func notificationItem(roomId: String, eventId: String) async throws -> NotificationItemProxyProtocol? {
MockNotificationItemProxy(eventID: eventId, roomID: roomId) MockNotificationItemProxy(eventID: eventId, roomID: roomId, receiverID: userID)
} }
} }
@@ -23,6 +23,8 @@ protocol TimelineEventProxyProtocol {
var eventID: String { get } var eventID: String { get }
var senderID: String { get } var senderID: String { get }
var timestamp: Date { get }
} }
final class TimelineEventProxy: TimelineEventProxyProtocol { final class TimelineEventProxy: TimelineEventProxyProtocol {
@@ -43,10 +45,15 @@ final class TimelineEventProxy: TimelineEventProxyProtocol {
var type: TimelineEventType? { var type: TimelineEventType? {
try? timelineEvent.eventType() try? timelineEvent.eventType()
} }
var timestamp: Date {
Date(timeIntervalSince1970: TimeInterval(timelineEvent.timestamp() / 1000))
}
} }
struct MockTimelineEventProxy: TimelineEventProxyProtocol { struct MockTimelineEventProxy: TimelineEventProxyProtocol {
let eventID: String let eventID: String
let senderID = "" let senderID = ""
let type: TimelineEventType? = nil let type: TimelineEventType? = nil
let timestamp = Date()
} }
+15 -9
View File
@@ -19,6 +19,7 @@ import MatrixRustSDK
import UserNotifications import UserNotifications
class NotificationServiceExtension: UNNotificationServiceExtension { class NotificationServiceExtension: UNNotificationServiceExtension {
private let settings = NSESettings()
private lazy var keychainController = KeychainController(service: .sessions, private lazy var keychainController = KeychainController(service: .sessions,
accessGroup: InfoPlistReader.main.keychainAccessGroupIdentifier) accessGroup: InfoPlistReader.main.keychainAccessGroupIdentifier)
var handler: ((UNNotificationContent) -> Void)? var handler: ((UNNotificationContent) -> Void)?
@@ -29,8 +30,8 @@ class NotificationServiceExtension: UNNotificationServiceExtension {
guard !DataProtectionManager.isDeviceLockedAfterReboot(containerURL: URL.appGroupContainerDirectory), guard !DataProtectionManager.isDeviceLockedAfterReboot(containerURL: URL.appGroupContainerDirectory),
let roomId = request.roomId, let roomId = request.roomId,
let eventId = request.eventId, let eventId = request.eventId,
let notificationID = request.pusherNotificationClientIdentifier, let clientID = request.pusherNotificationClientIdentifier,
let credentials = keychainController.restorationTokens().first(where: { $0.restorationToken.pusherNotificationClientIdentifier == notificationID }) else { let credentials = keychainController.restorationTokens().first(where: { $0.restorationToken.pusherNotificationClientIdentifier == clientID }) else {
// We cannot process this notification, it might be due to one of these: // We cannot process this notification, it might be due to one of these:
// - Device rebooted and locked // - Device rebooted and locked
// - Not a Matrix notification // - Not a Matrix notification
@@ -69,7 +70,7 @@ class NotificationServiceExtension: UNNotificationServiceExtension {
MXLog.info("\(tag) run with roomId: \(roomId), eventId: \(eventId)") MXLog.info("\(tag) run with roomId: \(roomId), eventId: \(eventId)")
let service = NotificationServiceProxy(basePath: URL.sessionsBaseDirectory.path, let service = NotificationServiceProxy(basePath: URL.sessionsBaseDirectory.path,
userId: credentials.userID) userID: credentials.userID)
guard let itemProxy = try await service.notificationItem(roomId: roomId, guard let itemProxy = try await service.notificationItem(roomId: roomId,
eventId: eventId) else { eventId: eventId) else {
@@ -81,9 +82,7 @@ class NotificationServiceExtension: UNNotificationServiceExtension {
// First process without a media proxy. // First process without a media proxy.
// After this some properties of the notification should be set, like title, subtitle, sound etc. // 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, guard let firstContent = try await itemProxy.process(mediaProvider: nil) else {
roomId: roomId,
mediaProvider: nil) else {
MXLog.error("\(tag) not even first content") MXLog.error("\(tag) not even first content")
// Notification should be discarded // Notification should be discarded
@@ -103,9 +102,7 @@ class NotificationServiceExtension: UNNotificationServiceExtension {
MXLog.info("\(tag) process with media") MXLog.info("\(tag) process with media")
// There is some media to load, process it again // There is some media to load, process it again
if let latestContent = try await itemProxy.process(receiverId: credentials.userID, if let latestContent = try await itemProxy.process(mediaProvider: createMediaProvider(with: credentials)) {
roomId: roomId,
mediaProvider: createMediaProvider(with: credentials)) {
// Processing finished, hopefully with some media // Processing finished, hopefully with some media
modifiedContent = latestContent modifiedContent = latestContent
return notify() return notify()
@@ -137,6 +134,15 @@ class NotificationServiceExtension: UNNotificationServiceExtension {
MXLog.info("\(tag) notify: no modified content") MXLog.info("\(tag) notify: no modified content")
return 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?(modifiedContent)
handler = nil handler = nil
self.modifiedContent = nil self.modifiedContent = nil
+28
View File
@@ -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<String>
}
+2
View File
@@ -90,3 +90,5 @@ targets:
- path: ../../ElementX/Sources/Other/AvatarSize.swift - path: ../../ElementX/Sources/Other/AvatarSize.swift
- path: ../../ElementX/Sources/Other/InfoPlistReader.swift - path: ../../ElementX/Sources/Other/InfoPlistReader.swift
- path: ../../ElementX/Sources/Other/Extensions/UNNotificationContent.swift - path: ../../ElementX/Sources/Other/Extensions/UNNotificationContent.swift
- path: ../../ElementX/Sources/Other/UserPreference.swift
- path: ../../ElementX/Sources/Other/SharedUserDefaultsKeys.swift
+1
View File
@@ -0,0 +1 @@
Local notifications support, these can also be decrypted and shown as rich push notifications.