Files
element-x-ios/ElementX/Sources/Services/Notification/Manager/NotificationManager.swift
T
Stefan Ceriu ab39d8de06 Update the app badge and clear read notifications on room list updates
The sync update action is sent from the room list service state listener,
which fires before the SDK has applied the new unread counts, leaving the
app badge one sync behind. Drive both the badge and the clearing of
delivered notifications for fully read rooms from the room list publisher
instead, moving the observer from the ChatsTabFlowCoordinator into the
UserSessionFlowCoordinator.

The NotificationManager now receives the badge count from the caller so the
same value can be reused elsewhere.
2026-10-09 11:44:39 +03:00

271 lines
12 KiB
Swift

//
// Copyright 2025 Element Creations Ltd.
// Copyright 2022-2025 New Vector Ltd.
//
// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
// Please see LICENSE files in the repository root for full details.
//
import Combine
import Foundation
import UIKit
import UserNotifications
final class NotificationManager: NSObject, NotificationManagerProtocol {
private let notificationCenter: UserNotificationCenterProtocol
private let appSettings: AppSettings
private var userSession: UserSessionProtocol?
private var cancellables = Set<AnyCancellable>()
private var notificationsEnabled = false
init(notificationCenter: UserNotificationCenterProtocol,
appSettings: AppSettings) {
self.notificationCenter = notificationCenter
self.appSettings = appSettings
super.init()
}
// MARK: NotificationManagerProtocol
weak var delegate: NotificationManagerDelegate?
func start() {
let replyAction = UNTextInputNotificationAction(identifier: NotificationConstants.Action.inlineReply,
title: L10n.actionQuickReply,
options: [])
let messageCategory = UNNotificationCategory(identifier: NotificationConstants.Category.message,
actions: [replyAction],
intentIdentifiers: [],
options: [])
let inviteCategory = UNNotificationCategory(identifier: NotificationConstants.Category.invite,
actions: [],
intentIdentifiers: [],
options: [])
notificationCenter.setNotificationCategories([messageCategory, inviteCategory])
notificationCenter.delegate = self
notificationsEnabled = appSettings.enableNotifications
MXLog.info("App setting 'enableNotifications' is '\(notificationsEnabled)'")
// Listen for changes to AppSettings.enableNotifications
appSettings.enableNotificationsPublisher
.sink { [weak self] newValue in
self?.enableNotifications(newValue)
}
.store(in: &cancellables)
NotificationCenter.default.publisher(for: UIApplication.didBecomeActiveNotification)
.sink { [weak self] _ in
self?.removeReceivedWhileOfflineNotification()
}
.store(in: &cancellables)
}
func requestAuthorization() {
guard appSettings.enableNotifications, !userSession.isNil else { return }
Task {
do {
let permissionGranted = try await notificationCenter.requestAuthorization(options: [.alert, .sound, .badge])
MXLog.info("Permission granted: \(permissionGranted)")
await MainActor.run {
if permissionGranted {
self.delegate?.registerForRemoteNotifications()
}
}
} catch {
MXLog.error("Request authorization failed: \(error)")
}
}
}
func register(with deviceToken: Data) async -> Bool {
guard let userSession else {
return false
}
return await setPusher(with: deviceToken, clientProxy: userSession.clientProxy)
}
func setUserSession(_ userSession: UserSessionProtocol?) {
self.userSession = userSession
// If notification permissions were given previously then attempt re-registering
// for remote notifications on startup. Otherwise let the onboarding flow handle it
Task { [weak self] in
guard let self else { return }
if await notificationCenter.authorizationStatus() == .authorized, appSettings.enableNotifications {
await MainActor.run { [weak self] in
self?.delegate?.registerForRemoteNotifications()
}
}
let settings = await notificationCenter.notificationSettings()
MXLog.info("Notification sound enabled: \(settings.soundSetting == .enabled)")
}
}
func registrationFailed(with error: Error) {
MXLog.error("Device token registration failed with error: \(error)")
}
func showLocalNotification(with title: String, subtitle: String?) async {
let content = UNMutableNotificationContent()
content.title = title
if let subtitle {
content.subtitle = subtitle
}
let request = UNNotificationRequest(identifier: ProcessInfo.processInfo.globallyUniqueString,
content: content,
trigger: nil)
do {
try await notificationCenter.add(request)
MXLog.info("Show local notification succeeded")
} catch {
MXLog.error("Show local notification failed: \(error)")
}
}
func removeDeliveredMessageNotifications(for roomID: String) async {
let notificationsIdentifiers = await notificationCenter
.deliveredNotifications()
.filter { $0.request.content.roomID == roomID }
.map(\.request.identifier)
notificationCenter.removeDeliveredNotifications(withIdentifiers: notificationsIdentifiers)
}
func removeDeliveredNotificationsForFullyReadRooms(_ rooms: [RoomSummary]) async {
let roomsToLastMessageDates = rooms
.filter { $0.hasUnreadMessages == false }
.reduce(into: [:]) { partialResult, roomSummary in
partialResult[roomSummary.id] = roomSummary.lastMessageDate
}
let notificationsIdentifiers = await notificationCenter
.deliveredNotifications()
.filter { notification in
guard let roomID = notification.request.content.roomID,
let lastMessageDate = roomsToLastMessageDates[roomID] else {
return false
}
// The delivery date is later than the event's so only use it for notifications without an event date.
let notificationDate = notification.request.content.eventDate ?? notification.date
// Truncated to seconds to match `RoomSummary.lastMessageDate`.
return notificationDate.timeIntervalSince1970.rounded(.down) <= lastMessageDate.timeIntervalSince1970
}
.map(\.request.identifier)
notificationCenter.removeDeliveredNotifications(withIdentifiers: notificationsIdentifiers)
}
func updateAppBadgeCount(_ badgeCount: Int) async {
guard let userSession else { return }
appSettings.lastKnownBadgeCount = badgeCount
MXLog.debug("Updating app badge count to \(badgeCount)")
do {
try await notificationCenter.setBadgeCount(badgeCount)
} catch {
MXLog.error("Failed updating the app badge count with error: \(error)")
}
}
private func removeReceivedWhileOfflineNotification() {
notificationCenter.removeDeliveredNotifications(withIdentifiers: [NotificationServiceExtensionActor.receivedWhileOfflineNotificationID])
}
private func setPusher(with deviceToken: Data, clientProxy: ClientProxyProtocol) async -> Bool {
do {
let defaultPayload = APNSPayload(aps: APSInfo(mutableContent: 1,
alert: APSAlert(locKey: "Notification",
locArgs: [])),
pusherNotificationClientIdentifier: clientProxy.pusherNotificationClientIdentifier)
let configuration = try PusherConfiguration(identifiers: .init(pushkey: deviceToken.base64EncodedString(),
appId: appSettings.pusherAppID),
kind: .http(data: .init(url: appSettings.pushGatewayNotifyEndpoint.absoluteString,
format: .eventIdOnly,
defaultPayload: defaultPayload.toJsonString())),
appDisplayName: "\(InfoPlistReader.main.bundleDisplayName) (iOS)",
deviceDisplayName: UIDevice.current.name,
profileTag: pusherProfileTag(),
lang: Bundle.app.preferredLocalizations.first ?? "en")
try await clientProxy.setPusher(with: configuration)
MXLog.info("Set pusher succeeded")
return true
} catch {
MXLog.error("Set pusher failed: \(error)")
return false
}
}
private func pusherProfileTag() -> String {
if let currentTag = appSettings.pusherProfileTag {
return currentTag
}
let chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"
let newTag = (0..<16).map { _ in
let offset = Int.random(in: 0..<chars.count)
return String(chars[chars.index(chars.startIndex, offsetBy: offset)])
}.joined()
appSettings.pusherProfileTag = newTag
return newTag
}
private func enableNotifications(_ enable: Bool) {
guard notificationsEnabled != enable else { return }
notificationsEnabled = enable
MXLog.info("App setting 'enableNotifications' changed to '\(enable)'")
if enable {
requestAuthorization()
} else {
delegate?.unregisterForRemoteNotifications()
}
}
}
// MARK: - UNUserNotificationCenterDelegate
extension NotificationManager: UNUserNotificationCenterDelegate {
func userNotificationCenter(_ center: UNUserNotificationCenter,
willPresent notification: UNNotification) async -> UNNotificationPresentationOptions {
guard appSettings.enableInAppNotifications else {
return []
}
guard let delegate else {
return [.badge, .sound, .list, .banner]
}
guard delegate.shouldDisplayInAppNotification(content: notification.request.content) else {
return []
}
return [.badge, .sound, .list, .banner]
}
func userNotificationCenter(_ center: UNUserNotificationCenter,
didReceive response: UNNotificationResponse) async {
switch response.actionIdentifier {
case NotificationConstants.Action.inlineReply:
guard let response = response as? UNTextInputNotificationResponse else {
return
}
await delegate?.handleInlineReply(self,
content: response.notification.request.content,
replyText: response.userText)
case UNNotificationDefaultActionIdentifier:
await delegate?.notificationTapped(content: response.notification.request.content)
default:
break
}
}
}
extension UNUserNotificationCenter: UserNotificationCenterProtocol { }