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 981e10d221
commit 168649030b
15 changed files with 251 additions and 108 deletions

View File

@@ -84,7 +84,7 @@ class AppCoordinator: AppCoordinatorProtocol {
wipeUserData(includingSettings: true)
}
ServiceLocator.shared.settings.lastVersionLaunched = currentVersion.description
setupStateMachine()
observeApplicationState()

View File

@@ -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

View File

@@ -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 {

View 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
}

View File

@@ -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)

View File

@@ -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()
}

View File

@@ -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)")

View File

@@ -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 {

View File

@@ -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,

View File

@@ -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)
}
}

View File

@@ -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()
}

View File

@@ -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

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>
}

View File

@@ -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
View File

@@ -0,0 +1 @@
Local notifications support, these can also be decrypted and shown as rich push notifications.