mirror of
https://github.com/Novaloop-AG/element-x-ios.git
synced 2026-10-31 16:24:11 +00:00
ab39d8de06
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.
271 lines
12 KiB
Swift
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 { }
|