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:
@@ -84,7 +84,7 @@ class AppCoordinator: AppCoordinatorProtocol {
|
||||
wipeUserData(includingSettings: true)
|
||||
}
|
||||
ServiceLocator.shared.settings.lastVersionLaunched = currentVersion.description
|
||||
|
||||
|
||||
setupStateMachine()
|
||||
|
||||
observeApplicationState()
|
||||
|
||||
@@ -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<String>
|
||||
|
||||
// MARK: - Other
|
||||
|
||||
let permalinkBaseURL = URL(staticString: "https://matrix.to")
|
||||
|
||||
|
||||
// MARK: - Feature Flags
|
||||
|
||||
// MARK: Start Chat
|
||||
|
||||
@@ -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 {
|
||||
|
||||
19
ElementX/Sources/Other/SharedUserDefaultsKeys.swift
Normal file
19
ElementX/Sources/Other/SharedUserDefaultsKeys.swift
Normal file
@@ -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 {
|
||||
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 {
|
||||
self.init(key: key, defaultValue: nil, storageType: storageType)
|
||||
|
||||
@@ -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<Void, Never>?
|
||||
private let avatarURLSubject = CurrentValueSubject<URL?, Never>(nil)
|
||||
var avatarURLPublisher: AnyPublisher<URL?, Never> {
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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)")
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
28
NSE/Sources/Other/NSESettings.swift
Normal file
28
NSE/Sources/Other/NSESettings.swift
Normal 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>
|
||||
}
|
||||
@@ -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
|
||||
|
||||
1
changelog.d/813.feature
Normal file
1
changelog.d/813.feature
Normal file
@@ -0,0 +1 @@
|
||||
Local notifications support, these can also be decrypted and shown as rich push notifications.
|
||||
Reference in New Issue
Block a user