Introduce a local mechanism for managing the app's badge count

This commit is contained in:
Stefan Ceriu
2026-09-08 19:05:44 +03:00
parent 7be93f8da8
commit 58628bd7c5
13 changed files with 164 additions and 6 deletions
@@ -19,6 +19,7 @@ import SwiftUI
nonisolated protocol CommonSettingsProtocol: AnyObject, Sendable { nonisolated protocol CommonSettingsProtocol: AnyObject, Sendable {
var lastNotificationBootTime: TimeInterval? { get set } var lastNotificationBootTime: TimeInterval? { get set }
var selectedNotificationTone: NotificationTone? { get set } var selectedNotificationTone: NotificationTone? { get set }
var lastKnownBadgeCount: Int { get set }
var logLevel: LogLevel { get } var logLevel: LogLevel { get }
var traceLogPacks: Set<TraceLogPack> { get } var traceLogPacks: Set<TraceLogPack> { get }
@@ -30,6 +31,7 @@ nonisolated protocol CommonSettingsProtocol: AnyObject, Sendable {
var enableOnlySignedDeviceIsolationMode: Bool { get } var enableOnlySignedDeviceIsolationMode: Bool { get }
var threadsEnabled: Bool { get } var threadsEnabled: Bool { get }
var hideQuietNotificationAlerts: Bool { get } var hideQuietNotificationAlerts: Bool { get }
var roomListNotificationCountEnabled: Bool { get }
} }
nonisolated enum AppBuildType { nonisolated enum AppBuildType {
@@ -261,6 +263,10 @@ final nonisolated class AppSettings: @unchecked Sendable {
@UserPreference @UserPreference
var lastNotificationBootTime: TimeInterval? var lastNotificationBootTime: TimeInterval?
/// The app icon badge value the app last computed from the SDK's unread notification counts.
@UserPreference(defaultValue: 0)
var lastKnownBadgeCount: Int
/// The sound played when delivering noisy notifications. If nil, use the ElementX default /// The sound played when delivering noisy notifications. If nil, use the ElementX default
@UserPreference @UserPreference
var selectedNotificationTone: NotificationTone? var selectedNotificationTone: NotificationTone?
@@ -341,6 +341,7 @@ class ChatsTabFlowCoordinator: FlowCoordinatorProtocol {
Task { Task {
let roomSummaries = self.userSession.clientProxy.staticRoomSummaryProvider.roomListPublisher.value let roomSummaries = self.userSession.clientProxy.staticRoomSummaryProvider.roomListPublisher.value
await self.flowParameters.notificationManager.removeDeliveredNotificationsForFullyReadRooms(roomSummaries) await self.flowParameters.notificationManager.removeDeliveredNotificationsForFullyReadRooms(roomSummaries)
await self.flowParameters.notificationManager.updateAppBadgeCount()
} }
default: default:
break break
@@ -36,6 +36,8 @@ struct ClientProxyMockConfiguration {
var maxMediaUploadSize: UInt = 100 * 1024 * 1024 var maxMediaUploadSize: UInt = 100 * 1024 * 1024
var totalUnreadNotifications: UInt64 = 0
class Overrides { class Overrides {
var joinedRoomIDs: Set<String> = [] var joinedRoomIDs: Set<String> = []
} }
@@ -56,6 +58,8 @@ extension ClientProxyMock {
homeserver = configuration.homeserver homeserver = configuration.homeserver
userIDServerName = configuration.userIDServerName userIDServerName = configuration.userIDServerName
totalUnreadNotifications = configuration.totalUnreadNotifications
roomSummaryProvider = configuration.roomSummaryProvider roomSummaryProvider = configuration.roomSummaryProvider
alternateRoomSummaryProvider = RoomSummaryProviderMock(.init()) alternateRoomSummaryProvider = RoomSummaryProviderMock(.init())
staticRoomSummaryProvider = RoomSummaryProviderMock(.init()) staticRoomSummaryProvider = RoomSummaryProviderMock(.init())
@@ -2322,6 +2322,11 @@ nonisolated class ClientProxyMock: ClientProxyProtocol, @unchecked Sendable {
} }
nonisolated(unsafe) var underlyingHideInviteAvatarsPublisher: CurrentValuePublisher<Bool, Never>! nonisolated(unsafe) var underlyingHideInviteAvatarsPublisher: CurrentValuePublisher<Bool, Never>!
nonisolated(unsafe) var pusherNotificationClientIdentifier: String? nonisolated(unsafe) var pusherNotificationClientIdentifier: String?
var totalUnreadNotifications: UInt64 {
get { return underlyingTotalUnreadNotifications }
set(value) { underlyingTotalUnreadNotifications = value }
}
nonisolated(unsafe) var underlyingTotalUnreadNotifications: UInt64!
var mediaLoader: MediaLoaderProtocol { var mediaLoader: MediaLoaderProtocol {
get { return underlyingMediaLoader } get { return underlyingMediaLoader }
set(value) { underlyingMediaLoader = value } set(value) { underlyingMediaLoader = value }
@@ -8663,6 +8668,23 @@ nonisolated class NotificationManagerMock: NotificationManagerProtocol, @uncheck
removeDeliveredNotificationsForFullyReadRoomsReceivedInvocationsLock.withLock { removeDeliveredNotificationsForFullyReadRoomsUnderlyingReceivedInvocations.append(rooms) } removeDeliveredNotificationsForFullyReadRoomsReceivedInvocationsLock.withLock { removeDeliveredNotificationsForFullyReadRoomsUnderlyingReceivedInvocations.append(rooms) }
await removeDeliveredNotificationsForFullyReadRoomsClosure?(rooms) await removeDeliveredNotificationsForFullyReadRoomsClosure?(rooms)
} }
//MARK: - updateAppBadgeCount
private let updateAppBadgeCountCallsCountLock = NSLock()
private nonisolated(unsafe) var updateAppBadgeCountUnderlyingCallsCount = 0
var updateAppBadgeCountCallsCount: Int {
get { updateAppBadgeCountCallsCountLock.withLock { updateAppBadgeCountUnderlyingCallsCount } }
set { updateAppBadgeCountCallsCountLock.withLock { updateAppBadgeCountUnderlyingCallsCount = newValue } }
}
var updateAppBadgeCountCalled: Bool {
return updateAppBadgeCountCallsCount > 0
}
nonisolated(unsafe) var updateAppBadgeCountClosure: (() async -> Void)?
@concurrent func updateAppBadgeCount() async {
updateAppBadgeCountCallsCountLock.withLock { updateAppBadgeCountUnderlyingCallsCount += 1 }
await updateAppBadgeCountClosure?()
}
} }
nonisolated class NotificationSettingsProxyMock: NotificationSettingsProxyProtocol, @unchecked Sendable { nonisolated class NotificationSettingsProxyMock: NotificationSettingsProxyProtocol, @unchecked Sendable {
var callbacks: PassthroughSubject<NotificationSettingsProxyCallback, Never> { var callbacks: PassthroughSubject<NotificationSettingsProxyCallback, Never> {
@@ -14103,6 +14125,41 @@ nonisolated class UserNotificationCenterMock: UserNotificationCenterProtocol, @u
removeDeliveredNotificationsWithIdentifiersReceivedInvocationsLock.withLock { removeDeliveredNotificationsWithIdentifiersUnderlyingReceivedInvocations.append(identifiers) } removeDeliveredNotificationsWithIdentifiersReceivedInvocationsLock.withLock { removeDeliveredNotificationsWithIdentifiersUnderlyingReceivedInvocations.append(identifiers) }
removeDeliveredNotificationsWithIdentifiersClosure?(identifiers) removeDeliveredNotificationsWithIdentifiersClosure?(identifiers)
} }
//MARK: - setBadgeCount
nonisolated(unsafe) var setBadgeCountThrowableError: Error?
private let setBadgeCountCallsCountLock = NSLock()
private nonisolated(unsafe) var setBadgeCountUnderlyingCallsCount = 0
var setBadgeCountCallsCount: Int {
get { setBadgeCountCallsCountLock.withLock { setBadgeCountUnderlyingCallsCount } }
set { setBadgeCountCallsCountLock.withLock { setBadgeCountUnderlyingCallsCount = newValue } }
}
var setBadgeCountCalled: Bool {
return setBadgeCountCallsCount > 0
}
private let setBadgeCountReceivedNewBadgeCountLock = NSLock()
private nonisolated(unsafe) var setBadgeCountUnderlyingReceivedNewBadgeCount: Int?
var setBadgeCountReceivedNewBadgeCount: Int? {
get { setBadgeCountReceivedNewBadgeCountLock.withLock { setBadgeCountUnderlyingReceivedNewBadgeCount } }
set { setBadgeCountReceivedNewBadgeCountLock.withLock { setBadgeCountUnderlyingReceivedNewBadgeCount = newValue } }
}
private let setBadgeCountReceivedInvocationsLock = NSLock()
private nonisolated(unsafe) var setBadgeCountUnderlyingReceivedInvocations: [Int] = []
var setBadgeCountReceivedInvocations: [Int] {
get { setBadgeCountReceivedInvocationsLock.withLock { setBadgeCountUnderlyingReceivedInvocations } }
set { setBadgeCountReceivedInvocationsLock.withLock { setBadgeCountUnderlyingReceivedInvocations = newValue } }
}
nonisolated(unsafe) var setBadgeCountClosure: ((Int) async throws -> Void)?
@concurrent func setBadgeCount(_ newBadgeCount: Int) async throws {
if let error = setBadgeCountThrowableError {
throw error
}
setBadgeCountCallsCountLock.withLock { setBadgeCountUnderlyingCallsCount += 1 }
setBadgeCountReceivedNewBadgeCount = newBadgeCount
setBadgeCountReceivedInvocationsLock.withLock { setBadgeCountUnderlyingReceivedInvocations.append(newBadgeCount) }
try await setBadgeCountClosure?(newBadgeCount)
}
//MARK: - setNotificationCategories //MARK: - setNotificationCategories
private let setNotificationCategoriesCallsCountLock = NSLock() private let setNotificationCategoriesCallsCountLock = NSLock()
@@ -69,6 +69,7 @@ struct DeveloperOptionsScreen: View {
Toggle(isOn: $context.roomListNotificationCountEnabled) { Toggle(isOn: $context.roomListNotificationCountEnabled) {
Text("Show unread notification count") Text("Show unread notification count")
Text("Also makes the app icon badge use the SDK's own unread notification count")
} }
Toggle(isOn: $context.fuzzyRoomListSearchEnabled) { Toggle(isOn: $context.fuzzyRoomListSearchEnabled) {
@@ -339,6 +339,10 @@ class ClientProxy: ClientProxyProtocol {
client.canDeactivateAccount() client.canDeactivateAccount()
} }
var totalUnreadNotifications: UInt64 {
client.totalUnreadNotifications()
}
var userIDServerName: String? { var userIDServerName: String? {
do { do {
return try client.userIdServerName() return try client.userIdServerName()
@@ -137,6 +137,9 @@ protocol ClientProxyProtocol: AnyObject {
var pusherNotificationClientIdentifier: String? { get } var pusherNotificationClientIdentifier: String? { get }
/// The total number of unread notifications across all joined, non-muted rooms, as computed by the SDK.
var totalUnreadNotifications: UInt64 { get }
var mediaLoader: MediaLoaderProtocol { get } var mediaLoader: MediaLoaderProtocol { get }
var contentScanner: ContentScannerProxyProtocol? { get } var contentScanner: ContentScannerProxyProtocol? { get }
@@ -158,6 +158,22 @@ final class NotificationManager: NSObject, NotificationManagerProtocol {
notificationCenter.removeDeliveredNotifications(withIdentifiers: notificationsIdentifiers) notificationCenter.removeDeliveredNotifications(withIdentifiers: notificationsIdentifiers)
} }
func updateAppBadgeCount() async {
guard let userSession, appSettings.roomListNotificationCountEnabled else { return }
let badgeCount = Int(userSession.clientProxy.totalUnreadNotifications)
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() { private func removeReceivedWhileOfflineNotification() {
notificationCenter.removeDeliveredNotifications(withIdentifiers: [NotificationServiceExtensionActor.receivedWhileOfflineNotificationID]) notificationCenter.removeDeliveredNotifications(withIdentifiers: [NotificationServiceExtensionActor.receivedWhileOfflineNotificationID])
} }
@@ -36,4 +36,6 @@ protocol NotificationManagerProtocol: AnyObject {
func removeDeliveredMessageNotifications(for roomID: String) async func removeDeliveredMessageNotifications(for roomID: String) async
func removeDeliveredNotificationsForFullyReadRooms(_ rooms: [RoomSummary]) async func removeDeliveredNotificationsForFullyReadRooms(_ rooms: [RoomSummary]) async
func updateAppBadgeCount() async
} }
@@ -15,6 +15,7 @@ protocol UserNotificationCenterProtocol: AnyObject {
func requestAuthorization(options: UNAuthorizationOptions) async throws -> Bool func requestAuthorization(options: UNAuthorizationOptions) async throws -> Bool
func deliveredNotifications() async -> [UNNotification] func deliveredNotifications() async -> [UNNotification]
func removeDeliveredNotifications(withIdentifiers identifiers: [String]) func removeDeliveredNotifications(withIdentifiers identifiers: [String])
func setBadgeCount(_ newBadgeCount: Int) async throws
func setNotificationCategories(_ categories: Set<UNNotificationCategory>) func setNotificationCategories(_ categories: Set<UNNotificationCategory>)
func authorizationStatus() async -> UNAuthorizationStatus func authorizationStatus() async -> UNAuthorizationStatus
func notificationSettings() async -> UNNotificationSettings func notificationSettings() async -> UNNotificationSettings
+35 -5
View File
@@ -20,6 +20,9 @@ nonisolated class NotificationHandler {
private let notificationContentBuilder: NotificationContentBuilder private let notificationContentBuilder: NotificationContentBuilder
/// Whether this handler has already counted its notification towards the app icon badge.
private var hasCountedTowardsBadge = false
init(userSession: NSEUserSession, init(userSession: NSEUserSession,
settings: CommonSettingsProtocol, settings: CommonSettingsProtocol,
contentHandler: @escaping (UNNotificationContent) -> Void, contentHandler: @escaping (UNNotificationContent) -> Void,
@@ -42,9 +45,11 @@ nonisolated class NotificationHandler {
func processEvent(_ eventID: String, roomID: String) async { func processEvent(_ eventID: String, roomID: String) async {
MXLog.info("\(tag) Processing event: \(eventID) in room: \(roomID)") MXLog.info("\(tag) Processing event: \(eventID) in room: \(roomID)")
// Copy over the unread information to the notification badge if !settings.roomListNotificationCountEnabled {
notificationContent.badge = notificationContent.unreadCount as NSNumber? // Copy over the unread information provided by the push payload to the notification badge.
MXLog.info("\(tag) New badge value: \(notificationContent.badge?.stringValue ?? "nil")") notificationContent.badge = notificationContent.unreadCount as NSNumber?
MXLog.info("\(tag) New badge value: \(notificationContent.badge?.stringValue ?? "nil")")
}
guard let notificationItemProxy = await userSession.notificationItemProxy(roomID: roomID, eventID: eventID) else { guard let notificationItemProxy = await userSession.notificationItemProxy(roomID: roomID, eventID: eventID) else {
MXLog.error("\(tag) Failed retrieving notification item") MXLog.error("\(tag) Failed retrieving notification item")
@@ -79,15 +84,37 @@ nonisolated class NotificationHandler {
// MARK: - Private // MARK: - Private
private func deliverNotification() { private func deliverNotification() {
MXLog.info("\(tag) Delivering notification") if settings.roomListNotificationCountEnabled {
notificationContent.badge = NSNumber(value: incrementBadgeCount())
MXLog.info("\(tag) Delivering notification, new badge value: \(settings.lastKnownBadgeCount)")
} else {
MXLog.info("\(tag) Delivering notification")
}
contentHandler(notificationContent) contentHandler(notificationContent)
} }
private func incrementBadgeCount() -> Int {
// `handleTimeExpiration` can deliver a notification this handler already delivered.
guard !hasCountedTowardsBadge else {
return settings.lastKnownBadgeCount
}
hasCountedTowardsBadge = true
settings.lastKnownBadgeCount += 1
return settings.lastKnownBadgeCount
}
private func discardNotification() { private func discardNotification() {
MXLog.info("\(tag) Discarding notification") MXLog.info("\(tag) Discarding notification")
let content = UNMutableNotificationContent() let content = UNMutableNotificationContent()
content.badge = notificationContent.unreadCount as NSNumber? if settings.roomListNotificationCountEnabled {
// Nothing new is shown to the user, so leave the badge where the app last put it.
content.badge = NSNumber(value: settings.lastKnownBadgeCount)
} else {
content.badge = notificationContent.unreadCount as NSNumber?
}
MXLog.info("\(tag) New badge value: \(content.badge?.stringValue ?? "nil")") MXLog.info("\(tag) New badge value: \(content.badge?.stringValue ?? "nil")")
contentHandler(content) contentHandler(content)
@@ -122,6 +149,9 @@ nonisolated class NotificationHandler {
if let targetNotification = deliveredNotifications.first(where: { $0.request.content.eventID == redactedEventID }) { if let targetNotification = deliveredNotifications.first(where: { $0.request.content.eventID == redactedEventID }) {
UNUserNotificationCenter.current().removeDeliveredNotifications(withIdentifiers: [targetNotification.request.identifier]) UNUserNotificationCenter.current().removeDeliveredNotifications(withIdentifiers: [targetNotification.request.identifier])
if settings.roomListNotificationCountEnabled {
settings.lastKnownBadgeCount = max(0, settings.lastKnownBadgeCount - 1)
}
} }
return .processedShouldDiscard return .processedShouldDiscard
@@ -258,7 +258,9 @@ actor NotificationServiceExtensionActor {
let content = UNMutableNotificationContent() let content = UNMutableNotificationContent()
content.body = L10n.notificationReceivedWhileOfflineIos content.body = L10n.notificationReceivedWhileOfflineIos
content.badge = originalRequest.content.unreadCount as NSNumber? if !settings.roomListNotificationCountEnabled {
content.badge = originalRequest.content.unreadCount as NSNumber?
}
content.sound = settings.notificationSound content.sound = settings.notificationSound
let request = UNNotificationRequest(identifier: Self.receivedWhileOfflineNotificationID, content: content, trigger: nil) let request = UNNotificationRequest(identifier: Self.receivedWhileOfflineNotificationID, content: content, trigger: nil)
@@ -237,6 +237,37 @@ final class NotificationManagerTests {
await notificationManager.userNotificationCenter(UNUserNotificationCenter.current(), didReceive: response) await notificationManager.userNotificationCenter(UNUserNotificationCenter.current(), didReceive: response)
#expect(notificationTappedDelegateCalled) #expect(notificationTappedDelegateCalled)
} }
@Test
func updatingAppBadgeCountUsesTheClientSideCount() async {
appSettings.roomListNotificationCountEnabled = true
clientProxy.totalUnreadNotifications = 7
await notificationManager.updateAppBadgeCount()
#expect(notificationCenter.setBadgeCountReceivedNewBadgeCount == 7)
#expect(appSettings.lastKnownBadgeCount == 7)
}
@Test
func updatingAppBadgeCountWithoutASessionDoesNothing() async {
appSettings.roomListNotificationCountEnabled = true
notificationManager.setUserSession(nil)
await notificationManager.updateAppBadgeCount()
#expect(!notificationCenter.setBadgeCountCalled)
}
@Test
func updatingAppBadgeCountWithFeatureDisabledDoesNothing() async {
appSettings.roomListNotificationCountEnabled = false
clientProxy.totalUnreadNotifications = 7
await notificationManager.updateAppBadgeCount()
#expect(!notificationCenter.setBadgeCountCalled)
}
} }
extension NotificationManagerTests: @MainActor NotificationManagerDelegate { extension NotificationManagerTests: @MainActor NotificationManagerDelegate {