diff --git a/ElementX.xcodeproj/project.pbxproj b/ElementX.xcodeproj/project.pbxproj index db034e69a..52fcd6098 100644 --- a/ElementX.xcodeproj/project.pbxproj +++ b/ElementX.xcodeproj/project.pbxproj @@ -36,6 +36,7 @@ 02A92F8F4538CECDFB4F2607 /* RoomDirectorySearchScreenCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1562EAF6231151A675BED7A9 /* RoomDirectorySearchScreenCoordinator.swift */; }; 02D8DF8EB7537EB4E9019DDB /* EventBasedTimelineItemProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 218AB05B4E3889731959C5F1 /* EventBasedTimelineItemProtocol.swift */; }; 02D92774E5F9ED3E06515897 /* test_pdf.pdf in Resources */ = {isa = PBXBuildFile; fileRef = 4010BED936BD1AB8A8F6558C /* test_pdf.pdf */; }; + 02E89A285DC1148E28F660D0 /* NotificationServiceProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 29F5CFB92DCEDAE1B19BF230 /* NotificationServiceProtocol.swift */; }; 0307469D99B5FE6C7043AE39 /* KnockRequestsListScreenCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = C9E8A8EC299E12490588B07C /* KnockRequestsListScreenCoordinator.swift */; }; 03364DE2568860586A713472 /* LiveLocationManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = D17F49E39CC38DAB7B305701 /* LiveLocationManager.swift */; }; 037006FB6DF1374F94E4058D /* Dictionary.swift in Sources */ = {isa = PBXBuildFile; fileRef = BFDCAC6CAAD65A2C24EA9C4B /* Dictionary.swift */; }; @@ -628,6 +629,7 @@ 633501761094E09DFBEBFFAD /* CopyTextButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = B682FE2C44C5E163E7023B05 /* CopyTextButton.swift */; }; 6386EA3C898AD1A4BC1DC8A5 /* TimelineMediaPreviewModifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FD40B92FCF20165658296AD /* TimelineMediaPreviewModifier.swift */; }; 639A0A27383EC655B0E81E95 /* SpaceScreenViewModelProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 459A3921046977CBF4F3C359 /* SpaceScreenViewModelProtocol.swift */; }; + 63C6A9F43F9092976CD24C97 /* NotificationService.swift in Sources */ = {isa = PBXBuildFile; fileRef = B7AC6246B380828C99081971 /* NotificationService.swift */; }; 63CDC201A5980F304F6D0A1C /* WaveformInteractionModifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = BFEE91FB8ABB5F5884B6D940 /* WaveformInteractionModifier.swift */; }; 63DCEBC1DD555E0D645B9E98 /* MapTilerURLBuilderProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = F1033290D99D5BBA1AF3560A /* MapTilerURLBuilderProtocol.swift */; }; 63E46D18B91D08E15FC04125 /* ExpiringTaskRunner.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7B25F959A434BB9923A3223F /* ExpiringTaskRunner.swift */; }; @@ -1029,6 +1031,7 @@ 9DD5AA10E85137140FEA86A3 /* MediaProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = F17EFA1D3D09FC2F9C5E1CB2 /* MediaProvider.swift */; }; 9DD84E014ADFB2DD813022D5 /* RoomDetailsEditScreenViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 00E5B2CBEF8F96424F095508 /* RoomDetailsEditScreenViewModelTests.swift */; }; 9DE801D278AC34737467F937 /* VoiceMessageMediaManagerProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 889DEDD63C68ABDA8AD29812 /* VoiceMessageMediaManagerProtocol.swift */; }; + 9E117D12327BE208706E01AB /* NotificationServiceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7E3F9A5C85DE542994F2A314 /* NotificationServiceTests.swift */; }; 9E838A62918E47BC72D6640D /* UserIndicatorPresenter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6AB54B4F94686CCF0289B72F /* UserIndicatorPresenter.swift */; }; 9EBDC79CAC9B63A0D626E333 /* LegalInformationScreenCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5EB2CAA266B921D128C35710 /* LegalInformationScreenCoordinator.swift */; }; 9ED768A43A2A302F9812C5DF /* ContentScannerProxy.swift in Sources */ = {isa = PBXBuildFile; fileRef = A0013D4F8EA288D589CFBF37 /* ContentScannerProxy.swift */; }; @@ -1372,6 +1375,7 @@ D2DBA32E3CBFBDB519359078 /* MapViewConfiguration.swift in Sources */ = {isa = PBXBuildFile; fileRef = D1B6D13B1D213F8C27395D7A /* MapViewConfiguration.swift */; }; D304343515CE0464C5089537 /* AppSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7BD5523BDEDB247E29228476 /* AppSettings.swift */; }; D33AC79A50DFC26D2498DD28 /* FileRoomTimelineItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5098DA7799946A61E34A2373 /* FileRoomTimelineItem.swift */; }; + D34C818D04B5D16B346F00F4 /* NotificationServiceMock.swift in Sources */ = {isa = PBXBuildFile; fileRef = B86D1A7C9973FCF2861CCD2C /* NotificationServiceMock.swift */; }; D38E59C48BE5499A48D12031 /* CreateRoomScreenCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64AC8FCE224D4185F28636FF /* CreateRoomScreenCoordinator.swift */; }; D3ED5692672892F9F1E7375A /* LiveLocationSession.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA2FEFA393FC7D2870263012 /* LiveLocationSession.swift */; }; D3FD96913D2B1AAA3149DAC7 /* CreateRoomViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 69D42EE0102D2857933625DD /* CreateRoomViewModelTests.swift */; }; @@ -2064,6 +2068,7 @@ 292EEE1F71DCC205C45728F7 /* ReportRoomScreenCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReportRoomScreenCoordinator.swift; sourceTree = ""; }; 29690F342FCBEE48E17129C8 /* ScanStateMediaEventsTimelineViews.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScanStateMediaEventsTimelineViews.swift; sourceTree = ""; }; 29A953B6C0C431DBF4DD00B4 /* RoomSummary.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoomSummary.swift; sourceTree = ""; }; + 29F5CFB92DCEDAE1B19BF230 /* NotificationServiceProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationServiceProtocol.swift; sourceTree = ""; }; 2A2BB38DF61F5100B8723112 /* TimelineMediaPreviewModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelineMediaPreviewModels.swift; sourceTree = ""; }; 2A38B60F40D47397CB3B3C9C /* CollapsedInPlaceModifier.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CollapsedInPlaceModifier.swift; sourceTree = ""; }; 2A5C6FBF97B6EED3D4FA5EFF /* AttributedStringBuilder.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AttributedStringBuilder.swift; sourceTree = ""; }; @@ -2554,6 +2559,7 @@ 7DC017C3CB6B0F7C63F460F2 /* SecureBackupLogoutConfirmationScreenViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SecureBackupLogoutConfirmationScreenViewModel.swift; sourceTree = ""; }; 7DDBF99755A9008CF8C8499E /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = ""; }; 7DDF49CEBC0DFC59C308335F /* RoomMemberDetailsScreenViewModelProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoomMemberDetailsScreenViewModelProtocol.swift; sourceTree = ""; }; + 7E3F9A5C85DE542994F2A314 /* NotificationServiceTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationServiceTests.swift; sourceTree = ""; }; 7E8562F4D7DE073BC32902AB /* EncryptionResetScreenViewModelProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EncryptionResetScreenViewModelProtocol.swift; sourceTree = ""; }; 7EA2AFF6EB59FE25234D29F3 /* ElementCallServiceTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ElementCallServiceTests.swift; sourceTree = ""; }; 7EB58E4E8D6D634C246AD5C2 /* RoomInviterLabel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoomInviterLabel.swift; sourceTree = ""; }; @@ -2913,12 +2919,14 @@ B746EFA112532A7B701FB914 /* RoomNotificationSettingsCustomSectionView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoomNotificationSettingsCustomSectionView.swift; sourceTree = ""; }; B7728AA8046D460145EAC740 /* RoomTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoomTests.swift; sourceTree = ""; }; B7884BD256C091EB511B2EDF /* AppLockSetupPINScreenViewModelProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppLockSetupPINScreenViewModelProtocol.swift; sourceTree = ""; }; + B7AC6246B380828C99081971 /* NotificationService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationService.swift; sourceTree = ""; }; B7F0192CE2F891141A25B49F /* UITestsSignalling.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UITestsSignalling.swift; sourceTree = ""; }; B80D1901BA0B095E27793EDE /* ko */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ko; path = ko.lproj/Localizable.strings; sourceTree = ""; }; B8108C8F0ACF6A7EB72D0117 /* RoomScreenCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoomScreenCoordinator.swift; sourceTree = ""; }; B81B6170DB690013CEB646F4 /* MapLibreModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MapLibreModels.swift; sourceTree = ""; }; B8516302ACCA94A0E680AB3B /* VoiceMessageButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VoiceMessageButton.swift; sourceTree = ""; }; B858A61F2A570DFB8DE570A7 /* AggregratedReaction.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AggregratedReaction.swift; sourceTree = ""; }; + B86D1A7C9973FCF2861CCD2C /* NotificationServiceMock.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationServiceMock.swift; sourceTree = ""; }; B88CE0A058727BC68EEEC6B6 /* ShareExtension.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = ShareExtension.entitlements; sourceTree = ""; }; B8A3B7637DDBD6AA97AC2545 /* CameraPicker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CameraPicker.swift; sourceTree = ""; }; B8F28602AC7AC881AED37EBA /* NavigationCoordinators.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NavigationCoordinators.swift; sourceTree = ""; }; @@ -4146,6 +4154,7 @@ 6F65E4BB9E82EB8373207CF8 /* MediaProviderMock.swift */, 8DA1E8F287680C8ED25EDBAC /* NetworkMonitorMock.swift */, 840182D7A61402D5947DE094 /* NotificationItemProxyMock.swift */, + B86D1A7C9973FCF2861CCD2C /* NotificationServiceMock.swift */, 382B50F7E379B3DBBD174364 /* NotificationSettingsProxyMock.swift */, 18678D0A4177D8F0A255BC36 /* NotificationToneManagerMock.swift */, 4107DE842B9E8625291033AF /* NSEUserSessionMock.swift */, @@ -5233,6 +5242,7 @@ children = ( C830A64609CBD152F06E0457 /* NotificationConstants.swift */, 6EE5E2BBFBC7947CFE789B4D /* Manager */, + BF89F26D9DE50F4F7F490979 /* Service */, ); path = Notification; sourceTree = ""; @@ -5668,6 +5678,7 @@ children = ( 0376C429FAB1687C3D905F3E /* MockCoder.swift */, 30ED584467DB380E3CEFB1DB /* NotificationManagerTests.swift */, + 7E3F9A5C85DE542994F2A314 /* NotificationServiceTests.swift */, DC0AEA686E425F86F6BA0404 /* UNNotification+Creator.swift */, 130ED565A078F7E0B59D9D25 /* UNTextInputNotificationResponse+Creator.swift */, ); @@ -6716,6 +6727,15 @@ path = Search; sourceTree = ""; }; + BF89F26D9DE50F4F7F490979 /* Service */ = { + isa = PBXGroup; + children = ( + B7AC6246B380828C99081971 /* NotificationService.swift */, + 29F5CFB92DCEDAE1B19BF230 /* NotificationServiceProtocol.swift */, + ); + path = Service; + sourceTree = ""; + }; C0937E3B06A8F0E2DB7C8241 /* Other */ = { isa = PBXGroup; children = ( @@ -8547,6 +8567,7 @@ EE17B7154DCA50677D931A94 /* NavigationTabCoordinatorTests.swift in Sources */, 41B84DF733BE6CB0180D1CC1 /* NotificationContentBuilderTests.swift in Sources */, 1B2DADC008EE211AF1DA5292 /* NotificationManagerTests.swift in Sources */, + 9E117D12327BE208706E01AB /* NotificationServiceTests.swift in Sources */, C11939FDC40716C4387275A4 /* NotificationSettingsEditScreenViewModelTests.swift in Sources */, E3AC72E3E58F364EF15C1CC7 /* NotificationSettingsScreenViewModelTests.swift in Sources */, E593841CEBFDDC5387A75EBA /* NotificationToneManagerTests.swift in Sources */, @@ -9296,7 +9317,10 @@ 3E7B65C2C97748D5D65AAA8B /* NotificationPermissionsScreenModels.swift in Sources */, D4D7CCECC6C0AAFC42E165BB /* NotificationPermissionsScreenViewModel.swift in Sources */, C8A9C595038AFA2D707AC8C1 /* NotificationPermissionsScreenViewModelProtocol.swift in Sources */, + 63C6A9F43F9092976CD24C97 /* NotificationService.swift in Sources */, 6C8BAF1E91618D69E7D652B2 /* NotificationServiceExtension.swift in Sources */, + D34C818D04B5D16B346F00F4 /* NotificationServiceMock.swift in Sources */, + 02E89A285DC1148E28F660D0 /* NotificationServiceProtocol.swift in Sources */, C0090506A52A1991BAF4BA68 /* NotificationSettingsChatType.swift in Sources */, AA93B3F9B5DD097DEF79F981 /* NotificationSettingsEditScreen.swift in Sources */, 53A59720F4729D9BBFFB7CAB /* NotificationSettingsEditScreenCoordinator.swift in Sources */, @@ -10554,7 +10578,7 @@ IS_NIGHTLY_BUILD = NO; KEYCHAIN_ACCESS_GROUP_IDENTIFIER = "$(DEVELOPMENT_TEAM).$(BASE_BUNDLE_IDENTIFIER)"; MACOSX_DEPLOYMENT_TARGET = 14.5; - MARKETING_VERSION = 26.10.2; + MARKETING_VERSION = 26.10.3; MTL_ENABLE_DEBUG_INFO = NO; MTL_FAST_MATH = YES; PRODUCTION_APP_NAME = Element; @@ -10636,7 +10660,7 @@ IS_NIGHTLY_BUILD = NO; KEYCHAIN_ACCESS_GROUP_IDENTIFIER = "$(DEVELOPMENT_TEAM).$(BASE_BUNDLE_IDENTIFIER)"; MACOSX_DEPLOYMENT_TARGET = 14.5; - MARKETING_VERSION = 26.10.2; + MARKETING_VERSION = 26.10.3; MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; MTL_FAST_MATH = YES; ONLY_ACTIVE_ARCH = YES; diff --git a/ElementX/Sources/Application/AppCoordinator.swift b/ElementX/Sources/Application/AppCoordinator.swift index ef8ee4d67..48aa8f032 100644 --- a/ElementX/Sources/Application/AppCoordinator.swift +++ b/ElementX/Sources/Application/AppCoordinator.swift @@ -64,7 +64,10 @@ class AppCoordinator: AppCoordinatorProtocol, AuthenticationFlowCoordinatorDeleg private let appRouteURLParser: AppRouteURLParser private var storedAppRoute: AppRoute? - @Consumable private var storedInlineReply: (roomID: String, message: String)? + /// The account of a notification tapped while restoring, its route only opens once that account is the active one. + @Consumable private var storedAppRouteReceiverID: String? + /// A reply made from a notification before its account was live. + private var storedInlineReply: PendingInlineReplyDetails? @Consumable private var storedRoomsToAwait: Set? init(appDelegate: AppDelegate) { @@ -125,7 +128,8 @@ class AppCoordinator: AppCoordinatorProtocol, AuthenticationFlowCoordinatorDeleg analyticsService: analyticsService, appHooks: appHooks, networkMonitor: networkMonitor) - userSessionManager = UserSessionManager(userSessionStore: userSessionStore, appSettings: appSettings) + notificationManager = NotificationManager(notificationCenter: UNUserNotificationCenter.current(), appSettings: appSettings) + userSessionManager = UserSessionManager(userSessionStore: userSessionStore, appSettings: appSettings, notificationManager: notificationManager) let appLockService = AppLockService(keychainController: keychainController, appSettings: appSettings) let appLockNavigationCoordinator = NavigationRootCoordinator() @@ -133,9 +137,6 @@ class AppCoordinator: AppCoordinatorProtocol, AuthenticationFlowCoordinatorDeleg navigationCoordinator: appLockNavigationCoordinator, appSettings: appSettings) - notificationManager = NotificationManager(notificationCenter: UNUserNotificationCenter.current(), - appSettings: appSettings) - bugReportService = BugReportService(rageshakeURLPublisher: appSettings.bugReportRageshakeURL.publisher, applicationID: appSettings.bugReportApplicationID, sdkGitSHA: sdkGitSha(), @@ -147,8 +148,7 @@ class AppCoordinator: AppCoordinatorProtocol, AuthenticationFlowCoordinatorDeleg windowManager.delegate = self - notificationManager.delegate = self - notificationManager.start() + configureNotificationManager() guard let currentVersion = Version(InfoPlistReader(bundle: .main).bundleShortVersionString) else { fatalError("The app's version number **must** use semver for migration purposes.") @@ -407,6 +407,10 @@ class AppCoordinator: AppCoordinatorProtocol, AuthenticationFlowCoordinatorDeleg // there is not a user session yet return false } + // Only the active account's rooms can be on screen. + if let receiverID = content.receiverID, receiverID != userSession?.clientProxy.userID { + return true + } return !userSessionFlowCoordinator.isDisplayingRoomScreen(withRoomID: roomID) } @@ -414,10 +418,20 @@ class AppCoordinator: AppCoordinatorProtocol, AuthenticationFlowCoordinatorDeleg MXLog.info("Tapped Notification") guard let roomID = content.roomID, - content.receiverID != nil else { + let receiverID = content.receiverID else { return } + if let userSession { + guard receiverID == userSession.clientProxy.userID else { + // TODO: Switch to the receiver's account and open the room (element-hq/element-meta#3328). + MXLog.info("Ignoring a notification tap for \(receiverID), it isn't the active account.") + return + } + } else { + storedAppRouteReceiverID = receiverID + } + let eventID = appSettings.focusEventOnNotificationTap ? content.eventID : nil if content.categoryIdentifier == NotificationConstants.Category.invite { if let userSession { @@ -440,17 +454,18 @@ class AppCoordinator: AppCoordinatorProtocol, AuthenticationFlowCoordinatorDeleg func handleInlineReply(_ service: NotificationManagerProtocol, content: UNNotificationContent, replyText: String) async { MXLog.info("Handle notification reply") - guard let roomID = content.roomID else { + guard let roomID = content.roomID, + let receiverID = content.receiverID else { return } - if userSession == nil { - // Store the data so it can be used after the session is established - storedInlineReply = (roomID, replyText) + guard let userSession = userSessionManager.session(for: receiverID) else { + // Store the data so it can be sent once the receiver's session is live. + storedInlineReply = PendingInlineReplyDetails(userID: receiverID, roomID: roomID, message: replyText) return } - await processInlineReply(roomID: roomID, replyText: replyText) + await processInlineReply(replyText, roomID: roomID, userSession: userSession) } // MARK: - Private @@ -559,6 +574,11 @@ class AppCoordinator: AppCoordinatorProtocol, AuthenticationFlowCoordinatorDeleg } } } + + if oldVersion < Version(26, 10, 3) { + MXLog.info("Migrating to version 26.10.3, moving the notifications switch into the account.") + userSession.userSettings.account.migrateAppSettingsValue(\.enableNotificationsKey) + } } /// This could be removed once the adoption of 25.06.x is widespread. @@ -715,8 +735,9 @@ class AppCoordinator: AppCoordinatorProtocol, AuthenticationFlowCoordinatorDeleg await userSessionManager.restoreSessions { [weak self] userSession in await self?.performUserSessionMigrations(userSession) - } didAdd: { [weak self] _ in + } didAdd: { [weak self] userSession in self?.showActiveSessionIfRestoring() + self?.sendStoredInlineReply(from: userSession) } guard !Task.isCancelled else { return } @@ -738,6 +759,16 @@ class AppCoordinator: AppCoordinatorProtocol, AuthenticationFlowCoordinatorDeleg stateMachine.processEvent(.createdUserSession) } + /// Sends a reply made from a notification before its account was live. + private func sendStoredInlineReply(from userSession: UserSessionProtocol) { + guard let storedInlineReply, storedInlineReply.userID == userSession.clientProxy.userID else { return } + self.storedInlineReply = nil + + Task { + await processInlineReply(storedInlineReply.message, roomID: storedInlineReply.roomID, userSession: userSession) + } + } + private func startAuthentication() { let encryptionKeyProvider = EncryptionKeyProvider() let classicAppManager = ClassicAppManager() @@ -771,6 +802,13 @@ class AppCoordinator: AppCoordinatorProtocol, AuthenticationFlowCoordinatorDeleg fatalError("User session not setup") } + if let storedAppRouteReceiverID, storedAppRouteReceiverID != userSession.clientProxy.userID { + // TODO: Switch to the receiver's account and open the room (element-hq/element-meta#3328). + MXLog.info("Dropping a notification tap for \(storedAppRouteReceiverID), it isn't the active account.") + storedAppRoute = nil + storedRoomsToAwait = nil + } + if let storedRoomsToAwait { userSession.clientProxy.roomsToAwait = storedRoomsToAwait } @@ -779,10 +817,6 @@ class AppCoordinator: AppCoordinatorProtocol, AuthenticationFlowCoordinatorDeleg let storedAppRoute = storedAppRoute.take() { userSessionFlowCoordinator.handleAppRoute(storedAppRoute, animated: false) } - - if let storedInlineReply { - await processInlineReply(roomID: storedInlineReply.roomID, replyText: storedInlineReply.message) - } } private func startAuthenticationSoftLogout() { @@ -858,7 +892,6 @@ class AppCoordinator: AppCoordinatorProtocol, AuthenticationFlowCoordinatorDeleg appHooks: appHooks, analytics: analyticsService, userIndicatorController: userIndicatorController, - notificationManager: notificationManager, stateMachineFactory: StateMachineFactory()) let userSessionFlowCoordinator = UserSessionFlowCoordinator(isNewLogin: isNewLogin, @@ -913,14 +946,10 @@ class AppCoordinator: AppCoordinatorProtocol, AuthenticationFlowCoordinatorDeleg return } - // The user will log out, clear any existing notifications and unregister from receving new ones - UNUserNotificationCenter.current().removeAllPendingNotificationRequests() - UNUserNotificationCenter.current().removeAllDeliveredNotifications() - UNUserNotificationCenter.current().setBadgeCount(0) - - unregisterForRemoteNotifications() - Task { + // Delete the pusher while the session can still reach the server. + await userSession.notificationService.tearDown() + // First log out from the server await userSession.clientProxy.logout() @@ -928,6 +957,11 @@ class AppCoordinator: AppCoordinatorProtocol, AuthenticationFlowCoordinatorDeleg userSessionManager.remove(userID: userSession.clientProxy.userID) tearDownUserSession() + // Signed in accounts that aren't live yet still count. + if userSessionManager.userIDs.isEmpty { + notificationManager.reset() + } + userSession.userSettings.account.reset() appHooks.remoteSettingsHook.reset(appSettings) @@ -945,8 +979,6 @@ class AppCoordinator: AppCoordinatorProtocol, AuthenticationFlowCoordinatorDeleg userIndicatorController.retractAllIndicators() userSessionFlowCoordinator = nil - - notificationManager.setUserSession(nil) } private func presentSplashScreen(isSoftLogout: Bool = false, disableAppLock: Bool = false) { @@ -976,15 +1008,14 @@ class AppCoordinator: AppCoordinatorProtocol, AuthenticationFlowCoordinatorDeleg // TODO: Calls only follow the active account, ElementCallService must change to hold every account's session. elementCallService.setUserSession(userSession) - // TODO: Notifications only follow the active account, the NotificationManager must change to handle every account. - configureNotificationManager(for: userSession) Task { await resumeClientServices() } } - private func configureNotificationManager(for userSession: UserSessionProtocol) { - notificationManager.setUserSession(userSession) + private func configureNotificationManager() { + notificationManager.delegate = self + notificationManager.start() appDelegateObserver = appDelegate.callbacks .receive(on: DispatchQueue.main) @@ -1142,11 +1173,7 @@ class AppCoordinator: AppCoordinatorProtocol, AuthenticationFlowCoordinatorDeleg MXLog.info("Sentry configured (enabled: \(options.enabled))") } - private func processInlineReply(roomID: String, replyText: String) async { - guard let userSession else { - fatalError("User session not setup") - } - + private func processInlineReply(_ replyText: String, roomID: String, userSession: UserSessionProtocol) async { guard case let .joined(roomProxy) = await userSession.clientProxy.roomForIdentifier(roomID) else { MXLog.error("Tried to reply in an unjoined room: \(roomID)") return @@ -1486,3 +1513,9 @@ private extension AppCoordinator { task.setTaskCompleted(success: true) } } + +private struct PendingInlineReplyDetails { + let userID: String + let roomID: String + let message: String +} diff --git a/ElementX/Sources/Application/FlowCoordinatorProtocol.swift b/ElementX/Sources/Application/FlowCoordinatorProtocol.swift index e63dc5262..6908b5055 100644 --- a/ElementX/Sources/Application/FlowCoordinatorProtocol.swift +++ b/ElementX/Sources/Application/FlowCoordinatorProtocol.swift @@ -37,7 +37,6 @@ struct CommonFlowParameters { let appHooks: AppHooks let analytics: AnalyticsServiceProtocol let userIndicatorController: UserIndicatorControllerProtocol - let notificationManager: NotificationManagerProtocol let stateMachineFactory: StateMachineFactoryProtocol var windowManager: WindowManagerProtocol { diff --git a/ElementX/Sources/Application/Settings/AccountSettings.swift b/ElementX/Sources/Application/Settings/AccountSettings.swift index d100aef94..9fb9d5dc7 100644 --- a/ElementX/Sources/Application/Settings/AccountSettings.swift +++ b/ElementX/Sources/Application/Settings/AccountSettings.swift @@ -32,6 +32,10 @@ final nonisolated class AccountSettings: @unchecked Sendable { @UserPreference(defaultValue: true) var showAllRoomListActivity: Bool + /// Whether this account's notifications are on. When off, its pusher is deleted from the homeserver. + @UserPreference(defaultValue: true) + var enableNotifications: Bool + /// The tone played when the user hasn't selected one, which the homeserver can configure remotely. let defaultNotificationTone: RemotePreference = .init(.default) diff --git a/ElementX/Sources/Application/Settings/AppSettings.swift b/ElementX/Sources/Application/Settings/AppSettings.swift index a5bd1799b..ecccd99e1 100644 --- a/ElementX/Sources/Application/Settings/AppSettings.swift +++ b/ElementX/Sources/Application/Settings/AppSettings.swift @@ -232,9 +232,6 @@ final nonisolated class AppSettings: @unchecked Sendable { pushGatewayBaseURL.appending(path: "_matrix/push/v1/notify") } - @UserPreference(defaultValue: true) - var enableNotifications: Bool - @UserPreference(defaultValue: false) var hideQuietNotificationAlerts: Bool diff --git a/ElementX/Sources/FlowCoordinators/ChatsTabFlowCoordinator.swift b/ElementX/Sources/FlowCoordinators/ChatsTabFlowCoordinator.swift index 1742f772e..454f2ff77 100644 --- a/ElementX/Sources/FlowCoordinators/ChatsTabFlowCoordinator.swift +++ b/ElementX/Sources/FlowCoordinators/ChatsTabFlowCoordinator.swift @@ -379,7 +379,6 @@ class ChatsTabFlowCoordinator: FlowCoordinatorProtocol { bugReportService: flowParameters.bugReportService, selectedRoomPublisher: selectedRoomSubject.asCurrentValuePublisher(), analyticsService: flowParameters.analytics, - notificationManager: flowParameters.notificationManager, userIndicatorController: flowParameters.userIndicatorController) let coordinator = HomeScreenCoordinator(parameters: parameters) @@ -565,7 +564,7 @@ class ChatsTabFlowCoordinator: FlowCoordinatorProtocol { Task { let _ = await userSession.clientProxy.trackRecentlyVisitedRoom(roomID) - await flowParameters.notificationManager.removeDeliveredMessageNotifications(for: roomID) + userSession.notificationService.removeDeliveredMessageNotifications(for: roomID) } } diff --git a/ElementX/Sources/FlowCoordinators/OnboardingFlowCoordinator.swift b/ElementX/Sources/FlowCoordinators/OnboardingFlowCoordinator.swift index 1eeaef49d..7a2eed931 100644 --- a/ElementX/Sources/FlowCoordinators/OnboardingFlowCoordinator.swift +++ b/ElementX/Sources/FlowCoordinators/OnboardingFlowCoordinator.swift @@ -23,7 +23,6 @@ class OnboardingFlowCoordinator: FlowCoordinatorProtocol { private let appMediator: AppMediatorProtocol private let userSettings: UserSettings private let appHooks: AppHooks - private let notificationManager: NotificationManagerProtocol private let userIndicatorController: UserIndicatorControllerProtocol private let windowManager: WindowManagerProtocol private var isNewLogin: Bool @@ -71,7 +70,6 @@ class OnboardingFlowCoordinator: FlowCoordinatorProtocol { appMediator = flowParameters.appMediator userSettings = flowParameters.userSession.userSettings appHooks = flowParameters.appHooks - notificationManager = flowParameters.notificationManager userIndicatorController = flowParameters.userIndicatorController windowManager = flowParameters.windowManager @@ -399,7 +397,7 @@ class OnboardingFlowCoordinator: FlowCoordinatorProtocol { } private func presentNotificationPermissionsScreen() { - let coordinator = NotificationPermissionsScreenCoordinator(parameters: .init(notificationManager: notificationManager)) + let coordinator = NotificationPermissionsScreenCoordinator(parameters: .init(notificationService: userSession.notificationService)) coordinator.actions .sink { [weak self] action in diff --git a/ElementX/Sources/FlowCoordinators/UserSessionFlowCoordinator.swift b/ElementX/Sources/FlowCoordinators/UserSessionFlowCoordinator.swift index 88b628052..a8a392b5b 100644 --- a/ElementX/Sources/FlowCoordinators/UserSessionFlowCoordinator.swift +++ b/ElementX/Sources/FlowCoordinators/UserSessionFlowCoordinator.swift @@ -529,21 +529,9 @@ class UserSessionFlowCoordinator: FlowCoordinatorProtocol { } private func setupUnreadNotificationsObserver() { - let roomListPublisher = userSession.clientProxy.staticRoomSummaryProvider.roomListPublisher - - Task { [weak self] in - for await roomSummaries in roomListPublisher.values { - guard let self else { return } - - let badgeCount = Int(userSession.clientProxy.totalUnreadNotifications) - - chatsTabDetails.badgeCount = badgeCount - - await flowParameters.notificationManager.removeDeliveredNotificationsForFullyReadRooms(roomSummaries) - await flowParameters.notificationManager.updateAppBadgeCount(badgeCount) - } - } - .store(in: &cancellables) + userSession.notificationService.badgeCountPublisher + .weakAssign(to: \.badgeCount, on: chatsTabDetails) + .store(in: &cancellables) } // MARK: - Native calls diff --git a/ElementX/Sources/Mocks/Generated/GeneratedMocks.swift b/ElementX/Sources/Mocks/Generated/GeneratedMocks.swift index c0961a605..f113ba420 100644 --- a/ElementX/Sources/Mocks/Generated/GeneratedMocks.swift +++ b/ElementX/Sources/Mocks/Generated/GeneratedMocks.swift @@ -3760,6 +3760,41 @@ nonisolated class ClientProxyMock: ClientProxyProtocol, @unchecked Sendable { setPusherWithReceivedInvocationsLock.withLock { setPusherWithUnderlyingReceivedInvocations.append(configuration) } try await setPusherWithClosure?(configuration) } + //MARK: - deletePusher + + nonisolated(unsafe) var deletePusherWithThrowableError: Error? + private let deletePusherWithCallsCountLock = NSLock() + private nonisolated(unsafe) var deletePusherWithUnderlyingCallsCount = 0 + var deletePusherWithCallsCount: Int { + get { deletePusherWithCallsCountLock.withLock { deletePusherWithUnderlyingCallsCount } } + set { deletePusherWithCallsCountLock.withLock { deletePusherWithUnderlyingCallsCount = newValue } } + } + var deletePusherWithCalled: Bool { + return deletePusherWithCallsCount > 0 + } + private let deletePusherWithReceivedIdentifiersLock = NSLock() + private nonisolated(unsafe) var deletePusherWithUnderlyingReceivedIdentifiers: PusherIdentifiers? + var deletePusherWithReceivedIdentifiers: PusherIdentifiers? { + get { deletePusherWithReceivedIdentifiersLock.withLock { deletePusherWithUnderlyingReceivedIdentifiers } } + set { deletePusherWithReceivedIdentifiersLock.withLock { deletePusherWithUnderlyingReceivedIdentifiers = newValue } } + } + private let deletePusherWithReceivedInvocationsLock = NSLock() + private nonisolated(unsafe) var deletePusherWithUnderlyingReceivedInvocations: [PusherIdentifiers] = [] + var deletePusherWithReceivedInvocations: [PusherIdentifiers] { + get { deletePusherWithReceivedInvocationsLock.withLock { deletePusherWithUnderlyingReceivedInvocations } } + set { deletePusherWithReceivedInvocationsLock.withLock { deletePusherWithUnderlyingReceivedInvocations = newValue } } + } + nonisolated(unsafe) var deletePusherWithClosure: ((PusherIdentifiers) async throws -> Void)? + + @concurrent func deletePusher(with identifiers: PusherIdentifiers) async throws { + if let error = deletePusherWithThrowableError { + throw error + } + deletePusherWithCallsCountLock.withLock { deletePusherWithUnderlyingCallsCount += 1 } + deletePusherWithReceivedIdentifiers = identifiers + deletePusherWithReceivedInvocationsLock.withLock { deletePusherWithUnderlyingReceivedInvocations.append(identifiers) } + try await deletePusherWithClosure?(identifiers) + } //MARK: - searchUsers private let searchUsersSearchTermLimitCallsCountLock = NSLock() @@ -8842,24 +8877,13 @@ nonisolated class NotificationManagerMock: NotificationManagerProtocol, @uncheck get { registerWithReceivedInvocationsLock.withLock { registerWithUnderlyingReceivedInvocations } } set { registerWithReceivedInvocationsLock.withLock { registerWithUnderlyingReceivedInvocations = newValue } } } + nonisolated(unsafe) var registerWithClosure: ((Data) async -> Void)? - private let registerWithReturnValueLock = NSLock() - private nonisolated(unsafe) var registerWithUnderlyingReturnValue: Bool! - var registerWithReturnValue: Bool! { - get { registerWithReturnValueLock.withLock { registerWithUnderlyingReturnValue } } - set { registerWithReturnValueLock.withLock { registerWithUnderlyingReturnValue = newValue } } - } - nonisolated(unsafe) var registerWithClosure: ((Data) async -> Bool)? - - @concurrent func register(with deviceToken: Data) async -> Bool { + @concurrent func register(with deviceToken: Data) async { registerWithCallsCountLock.withLock { registerWithUnderlyingCallsCount += 1 } registerWithReceivedDeviceToken = deviceToken registerWithReceivedInvocationsLock.withLock { registerWithUnderlyingReceivedInvocations.append(deviceToken) } - if let registerWithClosure = registerWithClosure { - return await registerWithClosure(deviceToken) - } else { - return registerWithReturnValue - } + await registerWithClosure?(deviceToken) } //MARK: - registrationFailed @@ -8923,36 +8947,138 @@ nonisolated class NotificationManagerMock: NotificationManagerProtocol, @uncheck showLocalNotificationWithSubtitleReceivedInvocationsLock.withLock { showLocalNotificationWithSubtitleUnderlyingReceivedInvocations.append((title: title, subtitle: subtitle)) } await showLocalNotificationWithSubtitleClosure?(title, subtitle) } - //MARK: - setUserSession + //MARK: - add - private let setUserSessionCallsCountLock = NSLock() - private nonisolated(unsafe) var setUserSessionUnderlyingCallsCount = 0 - var setUserSessionCallsCount: Int { - get { setUserSessionCallsCountLock.withLock { setUserSessionUnderlyingCallsCount } } - set { setUserSessionCallsCountLock.withLock { setUserSessionUnderlyingCallsCount = newValue } } + private let addCallsCountLock = NSLock() + private nonisolated(unsafe) var addUnderlyingCallsCount = 0 + var addCallsCount: Int { + get { addCallsCountLock.withLock { addUnderlyingCallsCount } } + set { addCallsCountLock.withLock { addUnderlyingCallsCount = newValue } } } - var setUserSessionCalled: Bool { - return setUserSessionCallsCount > 0 + var addCalled: Bool { + return addCallsCount > 0 } - private let setUserSessionReceivedUserSessionLock = NSLock() - private nonisolated(unsafe) var setUserSessionUnderlyingReceivedUserSession: UserSessionProtocol? - var setUserSessionReceivedUserSession: UserSessionProtocol? { - get { setUserSessionReceivedUserSessionLock.withLock { setUserSessionUnderlyingReceivedUserSession } } - set { setUserSessionReceivedUserSessionLock.withLock { setUserSessionUnderlyingReceivedUserSession = newValue } } + private let addReceivedServiceLock = NSLock() + private nonisolated(unsafe) var addUnderlyingReceivedService: NotificationServiceProtocol? + var addReceivedService: NotificationServiceProtocol? { + get { addReceivedServiceLock.withLock { addUnderlyingReceivedService } } + set { addReceivedServiceLock.withLock { addUnderlyingReceivedService = newValue } } } - private let setUserSessionReceivedInvocationsLock = NSLock() - private nonisolated(unsafe) var setUserSessionUnderlyingReceivedInvocations: [UserSessionProtocol?] = [] - var setUserSessionReceivedInvocations: [UserSessionProtocol?] { - get { setUserSessionReceivedInvocationsLock.withLock { setUserSessionUnderlyingReceivedInvocations } } - set { setUserSessionReceivedInvocationsLock.withLock { setUserSessionUnderlyingReceivedInvocations = newValue } } + private let addReceivedInvocationsLock = NSLock() + private nonisolated(unsafe) var addUnderlyingReceivedInvocations: [NotificationServiceProtocol] = [] + var addReceivedInvocations: [NotificationServiceProtocol] { + get { addReceivedInvocationsLock.withLock { addUnderlyingReceivedInvocations } } + set { addReceivedInvocationsLock.withLock { addUnderlyingReceivedInvocations = newValue } } } - nonisolated(unsafe) var setUserSessionClosure: ((UserSessionProtocol?) -> Void)? + nonisolated(unsafe) var addClosure: ((NotificationServiceProtocol) -> Void)? - func setUserSession(_ userSession: UserSessionProtocol?) { - setUserSessionCallsCountLock.withLock { setUserSessionUnderlyingCallsCount += 1 } - setUserSessionReceivedUserSession = userSession - setUserSessionReceivedInvocationsLock.withLock { setUserSessionUnderlyingReceivedInvocations.append(userSession) } - setUserSessionClosure?(userSession) + func add(_ service: NotificationServiceProtocol) { + addCallsCountLock.withLock { addUnderlyingCallsCount += 1 } + addReceivedService = service + addReceivedInvocationsLock.withLock { addUnderlyingReceivedInvocations.append(service) } + addClosure?(service) + } + //MARK: - remove + + private let removeUserIDCallsCountLock = NSLock() + private nonisolated(unsafe) var removeUserIDUnderlyingCallsCount = 0 + var removeUserIDCallsCount: Int { + get { removeUserIDCallsCountLock.withLock { removeUserIDUnderlyingCallsCount } } + set { removeUserIDCallsCountLock.withLock { removeUserIDUnderlyingCallsCount = newValue } } + } + var removeUserIDCalled: Bool { + return removeUserIDCallsCount > 0 + } + private let removeUserIDReceivedUserIDLock = NSLock() + private nonisolated(unsafe) var removeUserIDUnderlyingReceivedUserID: String? + var removeUserIDReceivedUserID: String? { + get { removeUserIDReceivedUserIDLock.withLock { removeUserIDUnderlyingReceivedUserID } } + set { removeUserIDReceivedUserIDLock.withLock { removeUserIDUnderlyingReceivedUserID = newValue } } + } + private let removeUserIDReceivedInvocationsLock = NSLock() + private nonisolated(unsafe) var removeUserIDUnderlyingReceivedInvocations: [String] = [] + var removeUserIDReceivedInvocations: [String] { + get { removeUserIDReceivedInvocationsLock.withLock { removeUserIDUnderlyingReceivedInvocations } } + set { removeUserIDReceivedInvocationsLock.withLock { removeUserIDUnderlyingReceivedInvocations = newValue } } + } + nonisolated(unsafe) var removeUserIDClosure: ((String) -> Void)? + + func remove(userID: String) { + removeUserIDCallsCountLock.withLock { removeUserIDUnderlyingCallsCount += 1 } + removeUserIDReceivedUserID = userID + removeUserIDReceivedInvocationsLock.withLock { removeUserIDUnderlyingReceivedInvocations.append(userID) } + removeUserIDClosure?(userID) + } + //MARK: - reset + + private let resetCallsCountLock = NSLock() + private nonisolated(unsafe) var resetUnderlyingCallsCount = 0 + var resetCallsCount: Int { + get { resetCallsCountLock.withLock { resetUnderlyingCallsCount } } + set { resetCallsCountLock.withLock { resetUnderlyingCallsCount = newValue } } + } + var resetCalled: Bool { + return resetCallsCount > 0 + } + nonisolated(unsafe) var resetClosure: (() -> Void)? + + func reset() { + resetCallsCountLock.withLock { resetUnderlyingCallsCount += 1 } + resetClosure?() + } +} +nonisolated class NotificationServiceMock: NotificationServiceProtocol, @unchecked Sendable { + var userID: String { + get { return underlyingUserID } + set(value) { underlyingUserID = value } + } + nonisolated(unsafe) var underlyingUserID: String! + var actionsPublisher: AnyPublisher { + get { return underlyingActionsPublisher } + set(value) { underlyingActionsPublisher = value } + } + nonisolated(unsafe) var underlyingActionsPublisher: AnyPublisher! + var isEnabled: Bool { + get { return underlyingIsEnabled } + set(value) { underlyingIsEnabled = value } + } + nonisolated(unsafe) var underlyingIsEnabled: Bool! + var badgeCountPublisher: CurrentValuePublisher { + get { return underlyingBadgeCountPublisher } + set(value) { underlyingBadgeCountPublisher = value } + } + nonisolated(unsafe) var underlyingBadgeCountPublisher: CurrentValuePublisher! + + //MARK: - register + + private let registerDeviceTokenCallsCountLock = NSLock() + private nonisolated(unsafe) var registerDeviceTokenUnderlyingCallsCount = 0 + var registerDeviceTokenCallsCount: Int { + get { registerDeviceTokenCallsCountLock.withLock { registerDeviceTokenUnderlyingCallsCount } } + set { registerDeviceTokenCallsCountLock.withLock { registerDeviceTokenUnderlyingCallsCount = newValue } } + } + var registerDeviceTokenCalled: Bool { + return registerDeviceTokenCallsCount > 0 + } + private let registerDeviceTokenReceivedDeviceTokenLock = NSLock() + private nonisolated(unsafe) var registerDeviceTokenUnderlyingReceivedDeviceToken: Data? + var registerDeviceTokenReceivedDeviceToken: Data? { + get { registerDeviceTokenReceivedDeviceTokenLock.withLock { registerDeviceTokenUnderlyingReceivedDeviceToken } } + set { registerDeviceTokenReceivedDeviceTokenLock.withLock { registerDeviceTokenUnderlyingReceivedDeviceToken = newValue } } + } + private let registerDeviceTokenReceivedInvocationsLock = NSLock() + private nonisolated(unsafe) var registerDeviceTokenUnderlyingReceivedInvocations: [Data] = [] + var registerDeviceTokenReceivedInvocations: [Data] { + get { registerDeviceTokenReceivedInvocationsLock.withLock { registerDeviceTokenUnderlyingReceivedInvocations } } + set { registerDeviceTokenReceivedInvocationsLock.withLock { registerDeviceTokenUnderlyingReceivedInvocations = newValue } } + } + nonisolated(unsafe) var registerDeviceTokenClosure: ((Data) async -> Void)? + + @concurrent func register(deviceToken: Data) async { + registerDeviceTokenCallsCountLock.withLock { registerDeviceTokenUnderlyingCallsCount += 1 } + registerDeviceTokenReceivedDeviceToken = deviceToken + registerDeviceTokenReceivedInvocationsLock.withLock { registerDeviceTokenUnderlyingReceivedInvocations.append(deviceToken) } + await registerDeviceTokenClosure?(deviceToken) } //MARK: - requestAuthorization @@ -8994,75 +9120,30 @@ nonisolated class NotificationManagerMock: NotificationManagerProtocol, @uncheck get { removeDeliveredMessageNotificationsForReceivedInvocationsLock.withLock { removeDeliveredMessageNotificationsForUnderlyingReceivedInvocations } } set { removeDeliveredMessageNotificationsForReceivedInvocationsLock.withLock { removeDeliveredMessageNotificationsForUnderlyingReceivedInvocations = newValue } } } - nonisolated(unsafe) var removeDeliveredMessageNotificationsForClosure: ((String) async -> Void)? + nonisolated(unsafe) var removeDeliveredMessageNotificationsForClosure: ((String) -> Void)? - @concurrent func removeDeliveredMessageNotifications(for roomID: String) async { + func removeDeliveredMessageNotifications(for roomID: String) { removeDeliveredMessageNotificationsForCallsCountLock.withLock { removeDeliveredMessageNotificationsForUnderlyingCallsCount += 1 } removeDeliveredMessageNotificationsForReceivedRoomID = roomID removeDeliveredMessageNotificationsForReceivedInvocationsLock.withLock { removeDeliveredMessageNotificationsForUnderlyingReceivedInvocations.append(roomID) } - await removeDeliveredMessageNotificationsForClosure?(roomID) + removeDeliveredMessageNotificationsForClosure?(roomID) } - //MARK: - removeDeliveredNotificationsForFullyReadRooms + //MARK: - tearDown - private let removeDeliveredNotificationsForFullyReadRoomsCallsCountLock = NSLock() - private nonisolated(unsafe) var removeDeliveredNotificationsForFullyReadRoomsUnderlyingCallsCount = 0 - var removeDeliveredNotificationsForFullyReadRoomsCallsCount: Int { - get { removeDeliveredNotificationsForFullyReadRoomsCallsCountLock.withLock { removeDeliveredNotificationsForFullyReadRoomsUnderlyingCallsCount } } - set { removeDeliveredNotificationsForFullyReadRoomsCallsCountLock.withLock { removeDeliveredNotificationsForFullyReadRoomsUnderlyingCallsCount = newValue } } + private let tearDownCallsCountLock = NSLock() + private nonisolated(unsafe) var tearDownUnderlyingCallsCount = 0 + var tearDownCallsCount: Int { + get { tearDownCallsCountLock.withLock { tearDownUnderlyingCallsCount } } + set { tearDownCallsCountLock.withLock { tearDownUnderlyingCallsCount = newValue } } } - var removeDeliveredNotificationsForFullyReadRoomsCalled: Bool { - return removeDeliveredNotificationsForFullyReadRoomsCallsCount > 0 + var tearDownCalled: Bool { + return tearDownCallsCount > 0 } - private let removeDeliveredNotificationsForFullyReadRoomsReceivedRoomsLock = NSLock() - private nonisolated(unsafe) var removeDeliveredNotificationsForFullyReadRoomsUnderlyingReceivedRooms: [RoomSummary]? - var removeDeliveredNotificationsForFullyReadRoomsReceivedRooms: [RoomSummary]? { - get { removeDeliveredNotificationsForFullyReadRoomsReceivedRoomsLock.withLock { removeDeliveredNotificationsForFullyReadRoomsUnderlyingReceivedRooms } } - set { removeDeliveredNotificationsForFullyReadRoomsReceivedRoomsLock.withLock { removeDeliveredNotificationsForFullyReadRoomsUnderlyingReceivedRooms = newValue } } - } - private let removeDeliveredNotificationsForFullyReadRoomsReceivedInvocationsLock = NSLock() - private nonisolated(unsafe) var removeDeliveredNotificationsForFullyReadRoomsUnderlyingReceivedInvocations: [[RoomSummary]] = [] - var removeDeliveredNotificationsForFullyReadRoomsReceivedInvocations: [[RoomSummary]] { - get { removeDeliveredNotificationsForFullyReadRoomsReceivedInvocationsLock.withLock { removeDeliveredNotificationsForFullyReadRoomsUnderlyingReceivedInvocations } } - set { removeDeliveredNotificationsForFullyReadRoomsReceivedInvocationsLock.withLock { removeDeliveredNotificationsForFullyReadRoomsUnderlyingReceivedInvocations = newValue } } - } - nonisolated(unsafe) var removeDeliveredNotificationsForFullyReadRoomsClosure: (([RoomSummary]) async -> Void)? + nonisolated(unsafe) var tearDownClosure: (() async -> Void)? - @concurrent func removeDeliveredNotificationsForFullyReadRooms(_ rooms: [RoomSummary]) async { - removeDeliveredNotificationsForFullyReadRoomsCallsCountLock.withLock { removeDeliveredNotificationsForFullyReadRoomsUnderlyingCallsCount += 1 } - removeDeliveredNotificationsForFullyReadRoomsReceivedRooms = rooms - removeDeliveredNotificationsForFullyReadRoomsReceivedInvocationsLock.withLock { removeDeliveredNotificationsForFullyReadRoomsUnderlyingReceivedInvocations.append(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 - } - private let updateAppBadgeCountReceivedBadgeCountLock = NSLock() - private nonisolated(unsafe) var updateAppBadgeCountUnderlyingReceivedBadgeCount: Int? - var updateAppBadgeCountReceivedBadgeCount: Int? { - get { updateAppBadgeCountReceivedBadgeCountLock.withLock { updateAppBadgeCountUnderlyingReceivedBadgeCount } } - set { updateAppBadgeCountReceivedBadgeCountLock.withLock { updateAppBadgeCountUnderlyingReceivedBadgeCount = newValue } } - } - private let updateAppBadgeCountReceivedInvocationsLock = NSLock() - private nonisolated(unsafe) var updateAppBadgeCountUnderlyingReceivedInvocations: [Int] = [] - var updateAppBadgeCountReceivedInvocations: [Int] { - get { updateAppBadgeCountReceivedInvocationsLock.withLock { updateAppBadgeCountUnderlyingReceivedInvocations } } - set { updateAppBadgeCountReceivedInvocationsLock.withLock { updateAppBadgeCountUnderlyingReceivedInvocations = newValue } } - } - nonisolated(unsafe) var updateAppBadgeCountClosure: ((Int) async -> Void)? - - @concurrent func updateAppBadgeCount(_ badgeCount: Int) async { - updateAppBadgeCountCallsCountLock.withLock { updateAppBadgeCountUnderlyingCallsCount += 1 } - updateAppBadgeCountReceivedBadgeCount = badgeCount - updateAppBadgeCountReceivedInvocationsLock.withLock { updateAppBadgeCountUnderlyingReceivedInvocations.append(badgeCount) } - await updateAppBadgeCountClosure?(badgeCount) + @concurrent func tearDown() async { + tearDownCallsCountLock.withLock { tearDownUnderlyingCallsCount += 1 } + await tearDownClosure?() } } nonisolated class NotificationSettingsProxyMock: NotificationSettingsProxyProtocol, @unchecked Sendable { @@ -15196,6 +15277,11 @@ nonisolated class UserSessionMock: UserSessionProtocol, @unchecked Sendable { } nonisolated(unsafe) var underlyingLiveLocationManager: LiveLocationManagerProtocol! nonisolated(unsafe) var contentScannerService: ContentScannerServiceProtocol? + var notificationService: NotificationServiceProtocol { + get { return underlyingNotificationService } + set(value) { underlyingNotificationService = value } + } + nonisolated(unsafe) var underlyingNotificationService: NotificationServiceProtocol! var sessionSecurityStatePublisher: CurrentValuePublisher { get { return underlyingSessionSecurityStatePublisher } set(value) { underlyingSessionSecurityStatePublisher = value } diff --git a/ElementX/Sources/Mocks/NotificationServiceMock.swift b/ElementX/Sources/Mocks/NotificationServiceMock.swift new file mode 100644 index 000000000..da9b14dc2 --- /dev/null +++ b/ElementX/Sources/Mocks/NotificationServiceMock.swift @@ -0,0 +1,26 @@ +// +// Copyright 2026 Element Creations 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 + +struct NotificationServiceMockConfiguration { + var userID = "@alice:matrix.org" + var isEnabled = true + var badgeCount = 0 +} + +extension NotificationServiceMock { + convenience init(_ configuration: NotificationServiceMockConfiguration) { + self.init() + + userID = configuration.userID + isEnabled = configuration.isEnabled + actionsPublisher = PassthroughSubject().eraseToAnyPublisher() + badgeCountPublisher = CurrentValueSubject(configuration.badgeCount).asCurrentValuePublisher() + } +} diff --git a/ElementX/Sources/Mocks/SDK/ClientSDKMock.swift b/ElementX/Sources/Mocks/SDK/ClientSDKMock.swift index 1b1302db3..dac6748dd 100644 --- a/ElementX/Sources/Mocks/SDK/ClientSDKMock.swift +++ b/ElementX/Sources/Mocks/SDK/ClientSDKMock.swift @@ -61,6 +61,7 @@ nonisolated extension ClientSDKMock { } userIdReturnValue = configuration.userID + totalUnreadNotificationsReturnValue = 0 deviceIdReturnValue = configuration.deviceID sessionReturnValue = configuration.session getUrlUrlClosure = { url in diff --git a/ElementX/Sources/Mocks/UserSessionMock.swift b/ElementX/Sources/Mocks/UserSessionMock.swift index df8da1b8e..03af2d77d 100644 --- a/ElementX/Sources/Mocks/UserSessionMock.swift +++ b/ElementX/Sources/Mocks/UserSessionMock.swift @@ -15,6 +15,7 @@ struct UserSessionMockConfiguration { var userHooks = UserHooks() var contentScannerService: ContentScannerServiceProtocol? var liveLocationManager: LiveLocationManagerProtocol = LiveLocationManagerMock(.init()) + var notificationService: NotificationServiceProtocol = NotificationServiceMock(.init()) } @MainActor extension UserSessionMock { @@ -33,5 +34,6 @@ struct UserSessionMockConfiguration { sessionSecurityStatePublisher = CurrentValueSubject(.init(verificationState: .verified, recoveryState: .enabled)).asCurrentValuePublisher() liveLocationManager = configuration.liveLocationManager + notificationService = configuration.notificationService } } diff --git a/ElementX/Sources/Screens/HomeScreen/HomeScreenCoordinator.swift b/ElementX/Sources/Screens/HomeScreen/HomeScreenCoordinator.swift index c2483984f..140a48dba 100644 --- a/ElementX/Sources/Screens/HomeScreen/HomeScreenCoordinator.swift +++ b/ElementX/Sources/Screens/HomeScreen/HomeScreenCoordinator.swift @@ -15,7 +15,6 @@ struct HomeScreenCoordinatorParameters { let bugReportService: BugReportServiceProtocol let selectedRoomPublisher: CurrentValuePublisher let analyticsService: AnalyticsServiceProtocol - let notificationManager: NotificationManagerProtocol let userIndicatorController: UserIndicatorControllerProtocol } @@ -53,7 +52,6 @@ final class HomeScreenCoordinator: CoordinatorProtocol { selectedRoomPublisher: parameters.selectedRoomPublisher, analyticsService: parameters.analyticsService, bugReportService: parameters.bugReportService, - notificationManager: parameters.notificationManager, userIndicatorController: parameters.userIndicatorController) viewModel.actions diff --git a/ElementX/Sources/Screens/HomeScreen/HomeScreenViewModel.swift b/ElementX/Sources/Screens/HomeScreen/HomeScreenViewModel.swift index 06d9c228c..944a82bb0 100644 --- a/ElementX/Sources/Screens/HomeScreen/HomeScreenViewModel.swift +++ b/ElementX/Sources/Screens/HomeScreen/HomeScreenViewModel.swift @@ -20,7 +20,6 @@ class HomeScreenViewModel: HomeScreenViewModelType, HomeScreenViewModelProtocol private let analyticsService: AnalyticsServiceProtocol private let bugReportService: BugReportServiceProtocol private let userSettings: UserSettings - private let notificationManager: NotificationManagerProtocol private let userIndicatorController: UserIndicatorControllerProtocol private let roomSummaryProvider: RoomSummaryProviderProtocol? @@ -36,14 +35,12 @@ class HomeScreenViewModel: HomeScreenViewModelType, HomeScreenViewModelProtocol selectedRoomPublisher: CurrentValuePublisher, analyticsService: AnalyticsServiceProtocol, bugReportService: BugReportServiceProtocol, - notificationManager: NotificationManagerProtocol, userIndicatorController: UserIndicatorControllerProtocol) { self.userSession = userSession self.availableSessionsPublisher = availableSessionsPublisher self.analyticsService = analyticsService self.bugReportService = bugReportService userSettings = userSession.userSettings - self.notificationManager = notificationManager self.userIndicatorController = userIndicatorController spaceFilterSubject = CurrentValueSubject(nil) @@ -602,7 +599,7 @@ class HomeScreenViewModel: HomeScreenViewModelType, HomeScreenViewModelProtocol switch result { case .success: - await notificationManager.removeDeliveredMessageNotifications(for: roomID) // Normally handled by the room flow, but that's never presented in this case. + userSession.notificationService.removeDeliveredMessageNotifications(for: roomID) // Normally handled by the room flow, but that's never presented in this case. userSettings.app.seenInvites.remove(roomID) case .failure: displayError() diff --git a/ElementX/Sources/Screens/HomeScreen/View/HomeScreen.swift b/ElementX/Sources/Screens/HomeScreen/View/HomeScreen.swift index e5a29fbb5..667ad3332 100644 --- a/ElementX/Sources/Screens/HomeScreen/View/HomeScreen.swift +++ b/ElementX/Sources/Screens/HomeScreen/View/HomeScreen.swift @@ -240,7 +240,6 @@ struct HomeScreen_Previews: PreviewProvider, TestablePreview { selectedRoomPublisher: CurrentValueSubject(nil).asCurrentValuePublisher(), analyticsService: AnalyticsServiceMock(.init()), bugReportService: BugReportServiceMock(.init()), - notificationManager: NotificationManagerMock(), userIndicatorController: UserIndicatorControllerMock()) } } diff --git a/ElementX/Sources/Screens/HomeScreen/View/HomeScreenEmptyStateView.swift b/ElementX/Sources/Screens/HomeScreen/View/HomeScreenEmptyStateView.swift index 2574550db..3227811eb 100644 --- a/ElementX/Sources/Screens/HomeScreen/View/HomeScreenEmptyStateView.swift +++ b/ElementX/Sources/Screens/HomeScreen/View/HomeScreenEmptyStateView.swift @@ -148,7 +148,6 @@ struct HomeScreenEmptyStateView_Previews: PreviewProvider, TestablePreview { selectedRoomPublisher: CurrentValueSubject(nil).asCurrentValuePublisher(), analyticsService: AnalyticsServiceMock(.init()), bugReportService: BugReportServiceMock(.init()), - notificationManager: NotificationManagerMock(), userIndicatorController: UserIndicatorControllerMock()) }() } diff --git a/ElementX/Sources/Screens/HomeScreen/View/HomeScreenInviteCell.swift b/ElementX/Sources/Screens/HomeScreen/View/HomeScreenInviteCell.swift index 576c826d3..1916d2561 100644 --- a/ElementX/Sources/Screens/HomeScreen/View/HomeScreenInviteCell.swift +++ b/ElementX/Sources/Screens/HomeScreen/View/HomeScreenInviteCell.swift @@ -204,7 +204,6 @@ struct HomeScreenInviteCell_Previews: PreviewProvider, TestablePreview { selectedRoomPublisher: CurrentValueSubject(nil).asCurrentValuePublisher(), analyticsService: AnalyticsServiceMock(.init()), bugReportService: BugReportServiceMock(.init()), - notificationManager: NotificationManagerMock(), userIndicatorController: UserIndicatorControllerMock()) } } diff --git a/ElementX/Sources/Screens/HomeScreen/View/HomeScreenKnockedCell.swift b/ElementX/Sources/Screens/HomeScreen/View/HomeScreenKnockedCell.swift index 4fbf96f0d..a7b087d14 100644 --- a/ElementX/Sources/Screens/HomeScreen/View/HomeScreenKnockedCell.swift +++ b/ElementX/Sources/Screens/HomeScreen/View/HomeScreenKnockedCell.swift @@ -136,7 +136,6 @@ struct HomeScreenKnockedCell_Previews: PreviewProvider, TestablePreview { selectedRoomPublisher: CurrentValueSubject(nil).asCurrentValuePublisher(), analyticsService: AnalyticsServiceMock(.init()), bugReportService: BugReportServiceMock(.init()), - notificationManager: NotificationManagerMock(), userIndicatorController: UserIndicatorControllerMock()) } } diff --git a/ElementX/Sources/Screens/HomeScreen/View/HomeScreenRecoveryKeyConfirmationBanner.swift b/ElementX/Sources/Screens/HomeScreen/View/HomeScreenRecoveryKeyConfirmationBanner.swift index d5cb71c3d..6b975503a 100644 --- a/ElementX/Sources/Screens/HomeScreen/View/HomeScreenRecoveryKeyConfirmationBanner.swift +++ b/ElementX/Sources/Screens/HomeScreen/View/HomeScreenRecoveryKeyConfirmationBanner.swift @@ -125,7 +125,6 @@ struct HomeScreenRecoveryKeyConfirmationBanner_Previews: PreviewProvider, Testab selectedRoomPublisher: CurrentValueSubject(nil).asCurrentValuePublisher(), analyticsService: AnalyticsServiceMock(.init()), bugReportService: BugReportServiceMock(.init()), - notificationManager: NotificationManagerMock(), userIndicatorController: UserIndicatorControllerMock()) } } diff --git a/ElementX/Sources/Screens/Onboarding/NotificationPermissionsScreen/NotificationPermissionsScreenCoordinator.swift b/ElementX/Sources/Screens/Onboarding/NotificationPermissionsScreen/NotificationPermissionsScreenCoordinator.swift index 5d0676731..f8431e283 100644 --- a/ElementX/Sources/Screens/Onboarding/NotificationPermissionsScreen/NotificationPermissionsScreenCoordinator.swift +++ b/ElementX/Sources/Screens/Onboarding/NotificationPermissionsScreen/NotificationPermissionsScreenCoordinator.swift @@ -10,7 +10,7 @@ import Combine import SwiftUI struct NotificationPermissionsScreenCoordinatorParameters { - let notificationManager: NotificationManagerProtocol + let notificationService: NotificationServiceProtocol } enum NotificationPermissionsScreenCoordinatorAction { @@ -27,7 +27,7 @@ final class NotificationPermissionsScreenCoordinator: CoordinatorProtocol { } init(parameters: NotificationPermissionsScreenCoordinatorParameters) { - viewModel = NotificationPermissionsScreenViewModel(notificationManager: parameters.notificationManager) + viewModel = NotificationPermissionsScreenViewModel(notificationService: parameters.notificationService) } // MARK: - Public diff --git a/ElementX/Sources/Screens/Onboarding/NotificationPermissionsScreen/NotificationPermissionsScreenViewModel.swift b/ElementX/Sources/Screens/Onboarding/NotificationPermissionsScreen/NotificationPermissionsScreenViewModel.swift index 35f1d44f7..ce6432d35 100644 --- a/ElementX/Sources/Screens/Onboarding/NotificationPermissionsScreen/NotificationPermissionsScreenViewModel.swift +++ b/ElementX/Sources/Screens/Onboarding/NotificationPermissionsScreen/NotificationPermissionsScreenViewModel.swift @@ -12,15 +12,15 @@ import SwiftUI typealias NotificationPermissionsScreenViewModelType = StateStoreViewModel class NotificationPermissionsScreenViewModel: NotificationPermissionsScreenViewModelType, NotificationPermissionsScreenViewModelProtocol { - private let notificationManager: NotificationManagerProtocol + private let notificationService: NotificationServiceProtocol private var actionsSubject: PassthroughSubject = .init() var actionsPublisher: AnyPublisher { actionsSubject.eraseToAnyPublisher() } - init(notificationManager: NotificationManagerProtocol) { - self.notificationManager = notificationManager + init(notificationService: NotificationServiceProtocol) { + self.notificationService = notificationService super.init(initialViewState: .init()) } @@ -30,7 +30,7 @@ class NotificationPermissionsScreenViewModel: NotificationPermissionsScreenViewM override func process(viewAction: NotificationPermissionsScreenViewAction) { switch viewAction { case .enable: - notificationManager.requestAuthorization() + notificationService.requestAuthorization() actionsSubject.send(.done) case .notNow: diff --git a/ElementX/Sources/Screens/Onboarding/NotificationPermissionsScreen/View/NotificationPermissionsScreen.swift b/ElementX/Sources/Screens/Onboarding/NotificationPermissionsScreen/View/NotificationPermissionsScreen.swift index 7351c6abb..3d6be9beb 100644 --- a/ElementX/Sources/Screens/Onboarding/NotificationPermissionsScreen/View/NotificationPermissionsScreen.swift +++ b/ElementX/Sources/Screens/Onboarding/NotificationPermissionsScreen/View/NotificationPermissionsScreen.swift @@ -67,7 +67,7 @@ struct NotificationPermissionsScreen: View { // MARK: - Previews struct NotificationPermissionsScreen_Previews: PreviewProvider, TestablePreview { - static let viewModel = NotificationPermissionsScreenViewModel(notificationManager: NotificationManagerMock()) + static let viewModel = NotificationPermissionsScreenViewModel(notificationService: NotificationServiceMock(.init())) static var previews: some View { NotificationPermissionsScreen(context: viewModel.context) } diff --git a/ElementX/Sources/Screens/Settings/NotificationSettingsScreen/NotificationSettingsScreenViewModel.swift b/ElementX/Sources/Screens/Settings/NotificationSettingsScreen/NotificationSettingsScreenViewModel.swift index b89fa0c3d..4bbc107fc 100644 --- a/ElementX/Sources/Screens/Settings/NotificationSettingsScreen/NotificationSettingsScreenViewModel.swift +++ b/ElementX/Sources/Screens/Settings/NotificationSettingsScreen/NotificationSettingsScreenViewModel.swift @@ -38,7 +38,7 @@ class NotificationSettingsScreenViewModel: NotificationSettingsScreenViewModelTy notificationTonePreviewer = AudioPlayer() self.notificationToneManager = notificationToneManager - let bindings = NotificationSettingsScreenViewStateBindings(enableNotifications: userSettings.app.enableNotifications, + let bindings = NotificationSettingsScreenViewStateBindings(enableNotifications: userSettings.account.enableNotifications, showAllRoomListActivity: userSettings.account.showAllRoomListActivity) super.init(initialViewState: NotificationSettingsScreenViewState(bindings: bindings, isModallyPresented: isModallyPresented, @@ -48,7 +48,7 @@ class NotificationSettingsScreenViewModel: NotificationSettingsScreenViewModelTy customToneSelectionEnabled: !ProcessInfo.processInfo.isiOSAppOnMac)) // Listen for changes to UserSettings. - userSettings.app.enableNotificationsPublisher + userSettings.account.enableNotificationsPublisher .weakAssign(to: \.state.bindings.enableNotifications, on: self) .store(in: &cancellables) @@ -127,7 +127,7 @@ class NotificationSettingsScreenViewModel: NotificationSettingsScreenViewModelTy } func toggleNotifications() { - userSettings.app.enableNotifications.toggle() + userSettings.account.enableNotifications.toggle() } private func setupDidBecomeActiveSubscription() { diff --git a/ElementX/Sources/Services/Client/ClientProxy.swift b/ElementX/Sources/Services/Client/ClientProxy.swift index b9c789b5d..e71fd1c67 100644 --- a/ElementX/Sources/Services/Client/ClientProxy.swift +++ b/ElementX/Sources/Services/Client/ClientProxy.swift @@ -846,7 +846,11 @@ class ClientProxy: ClientProxyProtocol { deviceDisplayName: configuration.deviceDisplayName, profileTag: configuration.profileTag, lang: configuration.lang, - append: false) + append: configuration.append) + } + + func deletePusher(with identifiers: PusherIdentifiers) async throws { + try await client.deletePusher(identifiers: identifiers) } func searchUsers(searchTerm: String, limit: UInt) async -> Result { diff --git a/ElementX/Sources/Services/Client/ClientProxyProtocol.swift b/ElementX/Sources/Services/Client/ClientProxyProtocol.swift index 0cdfabe0a..e79792da5 100644 --- a/ElementX/Sources/Services/Client/ClientProxyProtocol.swift +++ b/ElementX/Sources/Services/Client/ClientProxyProtocol.swift @@ -75,13 +75,15 @@ enum CreateRoomAccessType: Equatable { /// This struct represents the configuration that we are using to register the application through Pusher to Sygnal /// using the Matrix Rust SDK, more info here: /// https://github.com/matrix-org/sygnal -struct PusherConfiguration { +struct PusherConfiguration: Equatable { let identifiers: PusherIdentifiers let kind: PusherKind let appDisplayName: String let deviceDisplayName: String let profileTag: String? let lang: String + /// Whether to keep the pushers of the other accounts using the same pushkey on this device. + let append: Bool } enum SessionVerificationState { @@ -253,6 +255,8 @@ protocol ClientProxyProtocol: AnyObject { func setPusher(with configuration: PusherConfiguration) async throws + func deletePusher(with identifiers: PusherIdentifiers) async throws + func searchUsers(searchTerm: String, limit: UInt) async -> Result func profile(for userID: String) async -> Result diff --git a/ElementX/Sources/Services/Notification/Manager/NotificationManager.swift b/ElementX/Sources/Services/Notification/Manager/NotificationManager.swift index 22d5cc928..966d9cd46 100644 --- a/ElementX/Sources/Services/Notification/Manager/NotificationManager.swift +++ b/ElementX/Sources/Services/Notification/Manager/NotificationManager.swift @@ -7,21 +7,26 @@ // import Combine -import Foundation -import UIKit +import SwiftUI import UserNotifications +/// Handles the app's notifications: the notification center, the APNs token, the permission and the app badge. +/// Each live account's ``NotificationService`` decides what concerns its account and asks for it through actions. final class NotificationManager: NSObject, NotificationManagerProtocol { private let notificationCenter: UserNotificationCenterProtocol private let appSettings: AppSettings - private var userSession: UserSessionProtocol? - + /// The APNs token, kept for the accounts added after it arrived. + private var deviceToken: Data? + private var services: [String: NotificationServiceProtocol] = [:] + private var serviceCancellables: [String: Set] = [:] private var cancellables = Set() - private var notificationsEnabled = false - init(notificationCenter: UserNotificationCenterProtocol, - appSettings: AppSettings) { + private var isAnyAccountEnabled: Bool { + services.values.contains { $0.isEnabled } + } + + init(notificationCenter: UserNotificationCenterProtocol, appSettings: AppSettings) { self.notificationCenter = notificationCenter self.appSettings = appSettings super.init() @@ -47,66 +52,25 @@ final class NotificationManager: NSObject, NotificationManagerProtocol { 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() - } - } - + Task { let settings = await notificationCenter.notificationSettings() MXLog.info("Notification sound enabled: \(settings.soundSetting == .enabled)") } } + func register(with deviceToken: Data) async { + self.deviceToken = deviceToken + for service in services.values { + await service.register(deviceToken: deviceToken) + } + } + func registrationFailed(with error: Error) { MXLog.error("Device token registration failed with error: \(error)") } @@ -128,106 +92,115 @@ final class NotificationManager: NSObject, NotificationManagerProtocol { } } - 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 add(_ service: NotificationServiceProtocol) { + let userID = service.userID + services[userID] = service + serviceCancellables[userID] = [ + service.actionsPublisher.sink { [weak self] action in + self?.handle(action, from: userID) + }, + service.badgeCountPublisher.sink { [weak self] _ in + self?.updateAppBadgeCount() + } + ] + + if let deviceToken { + Task { await service.register(deviceToken: deviceToken) } + } else { + registerForRemoteNotificationsIfAuthorized() + } } - func removeDeliveredNotificationsForFullyReadRooms(_ rooms: [RoomSummary]) async { - let roomsToLastMessageDates = rooms - .filter { $0.hasUnreadMessages == false } - .reduce(into: [:]) { partialResult, roomSummary in - partialResult[roomSummary.id] = roomSummary.lastMessageDate - } + func remove(userID: String) { + serviceCancellables[userID] = nil + guard services.removeValue(forKey: userID) != nil else { return } - let notificationsIdentifiers = await notificationCenter - .deliveredNotifications() - .filter { notification in - guard let roomID = notification.request.content.roomID, - let lastMessageDate = roomsToLastMessageDates[roomID] else { - return false + if services.isEmpty { + // Signing out of the last account unregisters from APNs, so the next account registers again. + deviceToken = nil + } else { + updateAppBadgeCount() + } + } + + func reset() { + appSettings.lastKnownBadgeCount = 0 + Task { + do { + try await notificationCenter.setBadgeCount(0) + } catch { + MXLog.error("Failed resetting the app badge count with error: \(error)") + } + } + delegate?.unregisterForRemoteNotifications() + } + + // MARK: - Private + + private func handle(_ action: NotificationServiceAction, from userID: String) { + switch action { + case .removeDeliveredNotifications(let isIncluded): + Task { await removeDeliveredNotifications(of: userID, where: isIncluded) } + case .requestAuthorization: + requestAuthorization() + } + } + + private func requestAuthorization() { + guard isAnyAccountEnabled else { return } + Task { + do { + let permissionGranted = try await notificationCenter.requestAuthorization(options: [.alert, .sound, .badge]) + MXLog.info("Permission granted: \(permissionGranted)") + if permissionGranted { + delegate?.registerForRemoteNotifications() } - - // 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 + } catch { + MXLog.error("Request authorization failed: \(error)") } - .map(\.request.identifier) - - notificationCenter.removeDeliveredNotifications(withIdentifiers: notificationsIdentifiers) + } } - func updateAppBadgeCount(_ badgeCount: Int) async { - guard let userSession else { return } + /// Registers again when permission was given before, otherwise the onboarding flow asks for it. + /// Accounts that are turned off need the token too, to delete any pusher left on their homeserver. + private func registerForRemoteNotificationsIfAuthorized() { + Task { + guard await notificationCenter.authorizationStatus() == .authorized else { return } + delegate?.registerForRemoteNotifications() + } + } + + private func updateAppBadgeCount() { + // Signing out of the last account resets the badge. + guard !services.isEmpty else { return } + let badgeCount = services.values.map(\.badgeCountPublisher.value).reduce(0, +) 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)") + Task { + do { + try await notificationCenter.setBadgeCount(badgeCount) + } catch { + MXLog.error("Failed updating the app badge count with error: \(error)") + } } } + private func removeDeliveredNotifications(of userID: String, where isIncluded: (UNNotification) -> Bool) async { + let identifiers = await notificationCenter.deliveredNotifications() + // A room can be shared by several accounts. Notifications without a receiver weren't processed by the extension, so any account may clear them. + .filter { ($0.request.content.receiverID ?? userID) == userID } + .filter(isIncluded) + .map(\.request.identifier) + guard !identifiers.isEmpty else { return } + + notificationCenter.removeDeliveredNotifications(withIdentifiers: identifiers) + } + 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.. Bool + func register(with deviceToken: Data) async func registrationFailed(with error: Error) func showLocalNotification(with title: String, subtitle: String?) async - func setUserSession(_ userSession: UserSessionProtocol?) - func requestAuthorization() - - func removeDeliveredMessageNotifications(for roomID: String) async - - func removeDeliveredNotificationsForFullyReadRooms(_ rooms: [RoomSummary]) async - - func updateAppBadgeCount(_ badgeCount: Int) async + /// Starts handling a live account's notifications, replacing the service it had before (e.g. after clearing the cache). + func add(_ service: NotificationServiceProtocol) + func remove(userID: String) + /// Resets the app badge and stops receiving notifications, once no account is signed in. + func reset() } diff --git a/ElementX/Sources/Services/Notification/Service/NotificationService.swift b/ElementX/Sources/Services/Notification/Service/NotificationService.swift new file mode 100644 index 000000000..2e307cdb3 --- /dev/null +++ b/ElementX/Sources/Services/Notification/Service/NotificationService.swift @@ -0,0 +1,177 @@ +// +// Copyright 2026 Element Creations 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 MatrixRustSDK +import SwiftUI +import UserNotifications + +/// Handles one account's notifications: its pusher, whether they're on, its unread count and which of its +/// delivered notifications to remove. The `NotificationManager` does the removing, through this service's actions. +final class NotificationService: NotificationServiceProtocol { + private let clientProxy: ClientProxyProtocol + private let userSettings: UserSettings + + /// The APNs token, unknown until the app registers for remote notifications. + private var deviceToken: Data? + private var cancellables = Set() + + private let actionsSubject = PassthroughSubject() + var actionsPublisher: AnyPublisher { + actionsSubject.eraseToAnyPublisher() + } + + private let badgeCountSubject: CurrentValueSubject + var badgeCountPublisher: CurrentValuePublisher { + badgeCountSubject.asCurrentValuePublisher() + } + + var userID: String { + clientProxy.userID + } + + var isEnabled: Bool { + userSettings.account.enableNotifications + } + + init(clientProxy: ClientProxyProtocol, userSettings: UserSettings) { + self.clientProxy = clientProxy + self.userSettings = userSettings + badgeCountSubject = .init(Int(clientProxy.totalUnreadNotifications)) + + setupSubscriptions() + } + + func register(deviceToken: Data) async { + self.deviceToken = deviceToken + // A failure is retried on the next launch, which registers the token again. + await updatePusher(isEnabled: isEnabled) + } + + func requestAuthorization() { + actionsSubject.send(.requestAuthorization) + } + + func removeDeliveredMessageNotifications(for roomID: String) { + actionsSubject.send(.removeDeliveredNotifications { $0.request.content.roomID == roomID }) + } + + func removeDeliveredNotificationsForFullyReadRooms(_ rooms: [RoomSummary]) { + let roomsToLastMessageDates = rooms + .filter { $0.hasUnreadMessages == false } + .reduce(into: [:]) { partialResult, roomSummary in + partialResult[roomSummary.id] = roomSummary.lastMessageDate + } + guard !roomsToLastMessageDates.isEmpty else { return } + + actionsSubject.send(.removeDeliveredNotifications { 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 + }) + } + + func tearDown() async { + await updatePusher(isEnabled: false) + actionsSubject.send(.removeDeliveredNotifications { _ in true }) + } + + // MARK: - Private + + private func setupSubscriptions() { + let roomListPublisher = clientProxy.staticRoomSummaryProvider.roomListPublisher + + Task { [weak self] in + for await roomSummaries in roomListPublisher.values { + guard let self else { return } + + updateBadgeCount(Int(clientProxy.totalUnreadNotifications)) + removeDeliveredNotificationsForFullyReadRooms(roomSummaries) + } + } + .store(in: &cancellables) + + // The settings screen turns notifications on and off through `AccountSettings.enableNotifications`. + userSettings.account.enableNotificationsPublisher + .dropFirst() + .sink { [weak self] isEnabled in + self?.enableNotificationsChanged(to: isEnabled) + } + .store(in: &cancellables) + } + + private func enableNotificationsChanged(to isEnabled: Bool) { + MXLog.info("Notifications turned \(isEnabled ? "on" : "off") for \(userID)") + // The extension drops pushes for an account turned off, so a pusher that fails to update is fixed on the next launch. + Task { await updatePusher(isEnabled: isEnabled) } + + if isEnabled { + requestAuthorization() + } + } + + private func updateBadgeCount(_ badgeCount: Int) { + guard badgeCount != badgeCountSubject.value else { return } + badgeCountSubject.send(badgeCount) + } + + /// Sets the pusher when enabled and deletes it otherwise. Nothing to update until the device token is known. + private func updatePusher(isEnabled: Bool) async { + guard let deviceToken else { return } + + let identifiers = PusherIdentifiers(pushkey: deviceToken.base64EncodedString(), appId: userSettings.app.pusherAppID) + do { + if isEnabled { + try await clientProxy.setPusher(with: pusherConfiguration(for: identifiers)) + MXLog.info("Set pusher succeeded") + } else { + try await clientProxy.deletePusher(with: identifiers) + MXLog.info("Delete pusher succeeded") + } + } catch { + MXLog.error("Updating the pusher failed: \(error)") + } + } + + private func pusherConfiguration(for identifiers: PusherIdentifiers) throws -> PusherConfiguration { + let defaultPayload = APNSPayload(aps: APSInfo(mutableContent: 1, + alert: APSAlert(locKey: "Notification", + locArgs: [])), + pusherNotificationClientIdentifier: clientProxy.pusherNotificationClientIdentifier) + + return try PusherConfiguration(identifiers: identifiers, + kind: .http(data: .init(url: userSettings.app.pushGatewayNotifyEndpoint.absoluteString, + format: .eventIdOnly, + defaultPayload: defaultPayload.toJsonString())), + appDisplayName: "\(InfoPlistReader.main.bundleDisplayName) (iOS)", + deviceDisplayName: UIDevice.current.name, + profileTag: pusherProfileTag(), + lang: Bundle.app.preferredLocalizations.first ?? "en", + // Otherwise accounts on the same homeserver delete each other's pusher. + append: userSettings.app.multiAccountEnabled) + } + + private func pusherProfileTag() -> String { + if let currentTag = userSettings.app.pusherProfileTag { + return currentTag + } + let chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789" + let newTag = (0..<16).map { _ in + let offset = Int.random(in: 0.. Bool) + case requestAuthorization +} + +// sourcery: AutoMockable +protocol NotificationServiceProtocol: AnyObject { + var userID: String { get } + var actionsPublisher: AnyPublisher { get } + + /// Whether this account's notifications are on, see `AccountSettings.enableNotifications`. + var isEnabled: Bool { get } + /// This account's unread notification count. + var badgeCountPublisher: CurrentValuePublisher { get } + + /// Sets the pusher when notifications are on, otherwise deletes it. The pusher follows the switch from then on. + func register(deviceToken: Data) async + func requestAuthorization() + func removeDeliveredMessageNotifications(for roomID: String) + /// Deletes the pusher and this account's delivered notifications, before signing out. + func tearDown() async +} diff --git a/ElementX/Sources/Services/Session/UserSession.swift b/ElementX/Sources/Services/Session/UserSession.swift index 839cbbb96..36a3b23f7 100644 --- a/ElementX/Sources/Services/Session/UserSession.swift +++ b/ElementX/Sources/Services/Session/UserSession.swift @@ -23,6 +23,7 @@ class UserSession: UserSessionProtocol { let liveLocationManager: LiveLocationManagerProtocol /// Scans media content, `nil` when no content scanner is configured for the server. let contentScannerService: ContentScannerServiceProtocol? + let notificationService: NotificationServiceProtocol let callbacks = PassthroughSubject() @@ -36,7 +37,8 @@ class UserSession: UserSessionProtocol { userHooks: UserHooks, mediaProvider: MediaProviderProtocol, voiceMessageMediaManager: VoiceMessageMediaManagerProtocol, - liveLocationManager: LiveLocationManagerProtocol) { + liveLocationManager: LiveLocationManagerProtocol, + notificationService: NotificationServiceProtocol) { self.clientProxy = clientProxy self.userSettings = userSettings self.userHooks = userHooks @@ -44,6 +46,7 @@ class UserSession: UserSessionProtocol { self.mediaProvider = mediaProvider self.voiceMessageMediaManager = voiceMessageMediaManager self.liveLocationManager = liveLocationManager + self.notificationService = notificationService contentScannerService = clientProxy.contentScanner.map(ContentScannerService.init) authErrorCancellable = clientProxy.actionsPublisher diff --git a/ElementX/Sources/Services/Session/UserSessionProtocol.swift b/ElementX/Sources/Services/Session/UserSessionProtocol.swift index fb4bfed31..0c0c5a7a0 100644 --- a/ElementX/Sources/Services/Session/UserSessionProtocol.swift +++ b/ElementX/Sources/Services/Session/UserSessionProtocol.swift @@ -29,6 +29,7 @@ protocol UserSessionProtocol: Sendable { var liveLocationManager: LiveLocationManagerProtocol { get } /// Scans media content, `nil` when no content scanner is configured for the server. var contentScannerService: ContentScannerServiceProtocol? { get } + var notificationService: NotificationServiceProtocol { get } var sessionSecurityStatePublisher: CurrentValuePublisher { get } diff --git a/ElementX/Sources/Services/UserSession/UserSessionManager.swift b/ElementX/Sources/Services/UserSession/UserSessionManager.swift index f129cc898..2aea50c12 100644 --- a/ElementX/Sources/Services/UserSession/UserSessionManager.swift +++ b/ElementX/Sources/Services/UserSession/UserSessionManager.swift @@ -17,6 +17,7 @@ import OrderedCollections final class UserSessionManager: UserSessionManagerProtocol { private let userSessionStore: UserSessionStoreProtocol private let appSettings: AppSettings + private let notificationManager: NotificationManagerProtocol private let sessionsSubject = CurrentValueSubject<[UserSessionProtocol], Never>([]) /// Whether the services are running, so that the sessions restored meanwhile are resumed too. @@ -68,9 +69,10 @@ final class UserSessionManager: UserSessionManagerProtocol { accounts.contains { userID, userSession in userSession != nil && !clearedUserIDs.contains(userID) } } - init(userSessionStore: UserSessionStoreProtocol, appSettings: AppSettings) { + init(userSessionStore: UserSessionStoreProtocol, appSettings: AppSettings, notificationManager: NotificationManagerProtocol) { self.userSessionStore = userSessionStore self.appSettings = appSettings + self.notificationManager = notificationManager let userIDs = userSessionStore.userIDs .map { (userID: $0, lastSelectedDate: appSettings.userSettings(for: $0).account.lastSelectedDate) } @@ -138,6 +140,8 @@ final class UserSessionManager: UserSessionManagerProtocol { appSettings.userSettings(for: userID).account.lastSelectedDate = .now accounts.updateValue(userSession, forKey: userID, insertingAt: 0) } + + notificationManager.add(userSession.notificationService) } func remove(userID: String) { @@ -149,9 +153,13 @@ final class UserSessionManager: UserSessionManagerProtocol { userSessionStore.logout(userSession: userSession) clearedUserIDs.remove(userID) accounts.removeValue(forKey: userID) + notificationManager.remove(userID: userID) } func reset() { + for userID in accounts.keys { + notificationManager.remove(userID: userID) + } userSessionStore.reset() clearedUserIDs.removeAll() accounts.removeAll() @@ -202,6 +210,7 @@ final class UserSessionManager: UserSessionManagerProtocol { MXLog.error("Failed restoring \(userID): \(error)") clearedUserIDs.remove(userID) accounts.removeValue(forKey: userID) + notificationManager.remove(userID: userID) return nil } } diff --git a/ElementX/Sources/Services/UserSession/UserSessionStore.swift b/ElementX/Sources/Services/UserSession/UserSessionStore.swift index 7b608c020..16cc44347 100644 --- a/ElementX/Sources/Services/UserSession/UserSessionStore.swift +++ b/ElementX/Sources/Services/UserSession/UserSessionStore.swift @@ -124,12 +124,15 @@ class UserSessionStore: UserSessionStoreProtocol { userSettings: userSettings) } + let notificationService = NotificationService(clientProxy: clientProxy, userSettings: userSettings) + return UserSession(clientProxy: clientProxy, userSettings: userSettings, userHooks: appHooks.userSessionHook.makeUserHooks(for: clientProxy), mediaProvider: mediaProvider, voiceMessageMediaManager: voiceMessageMediaManager, - liveLocationManager: liveLocationManager) + liveLocationManager: liveLocationManager, + notificationService: notificationService) } private func restorePreviousLogin(_ credentials: KeychainCredentials, userSettings: UserSettings) async -> Result { diff --git a/ElementX/Sources/UITests/UITestsAppCoordinator.swift b/ElementX/Sources/UITests/UITestsAppCoordinator.swift index 1a5dcd0a2..2539bd30c 100644 --- a/ElementX/Sources/UITests/UITestsAppCoordinator.swift +++ b/ElementX/Sources/UITests/UITestsAppCoordinator.swift @@ -637,7 +637,6 @@ class MockScreen: Identifiable { appHooks: AppHooks(), analytics: analytics, userIndicatorController: UserIndicatorControllerMock(), - notificationManager: NotificationManagerMock(), stateMachineFactory: StateMachineFactory())) flowCoordinator.start() @@ -690,7 +689,6 @@ class MockScreen: Identifiable { appHooks: AppHooks(), analytics: analytics, userIndicatorController: UserIndicatorControllerMock(), - notificationManager: NotificationManagerMock(), stateMachineFactory: StateMachineFactory())) flowCoordinator.actionsPublisher .sink { [weak self] action in @@ -790,7 +788,6 @@ class MockScreen: Identifiable { appHooks: AppHooks(), analytics: analytics, userIndicatorController: UserIndicatorControllerMock(), - notificationManager: NotificationManagerMock(), stateMachineFactory: StateMachineFactory())) flowCoordinator.actionsPublisher .sink { [weak self] action in @@ -847,7 +844,6 @@ class MockScreen: Identifiable { appHooks: AppHooks(), analytics: analytics, userIndicatorController: UserIndicatorControllerMock(), - notificationManager: NotificationManagerMock(), stateMachineFactory: StateMachineFactory())) flowCoordinator.start() diff --git a/NSE/Sources/NotificationHandler.swift b/NSE/Sources/NotificationHandler.swift index a4c71e8f2..847f3f1ac 100644 --- a/NSE/Sources/NotificationHandler.swift +++ b/NSE/Sources/NotificationHandler.swift @@ -45,6 +45,13 @@ nonisolated class NotificationHandler { func processEvent(_ eventID: String, roomID: String) async { MXLog.info("\(tag) Processing event: \(eventID) in room: \(roomID)") + // The pusher of an account with its notifications off can outlive a failed deletion. + guard userSettings.account.enableNotifications else { + MXLog.error("\(tag) Received a push for an account with notifications off, discarding.") + discardNotification() + return + } + guard let notificationItemProxy = await userSession.notificationItemProxy(roomID: roomID, eventID: eventID) else { MXLog.error("\(tag) Failed retrieving notification item") discardNotification() diff --git a/UnitTests/Sources/ChatsTabFlowCoordinatorTests.swift b/UnitTests/Sources/ChatsTabFlowCoordinatorTests.swift index c9346f2ca..b7fc32018 100644 --- a/UnitTests/Sources/ChatsTabFlowCoordinatorTests.swift +++ b/UnitTests/Sources/ChatsTabFlowCoordinatorTests.swift @@ -17,7 +17,7 @@ struct ChatsTabFlowCoordinatorTests { var timelineControllerFactory: TimelineControllerFactoryMock! var chatsTabFlowCoordinator: ChatsTabFlowCoordinator! var splitCoordinator: NavigationSplitCoordinator! - var notificationManager: NotificationManagerMock! + var notificationService: NotificationServiceMock! let stateMachineFactory = PublishedStateMachineFactory() var detailCoordinator: CoordinatorProtocol? { @@ -34,9 +34,9 @@ struct ChatsTabFlowCoordinatorTests { splitCoordinator = NavigationSplitCoordinator(placeholderCoordinator: PlaceholderScreenCoordinator(hideBrandChrome: false)) - notificationManager = NotificationManagerMock() + notificationService = NotificationServiceMock(.init()) - let userSession = UserSessionMock(.init(clientProxy: clientProxy)) + let userSession = UserSessionMock(.init(clientProxy: clientProxy, notificationService: notificationService)) let flowParameters = CommonFlowParameters(userSession: userSession, availableSessionsPublisher: .init([.init(userSession: userSession)]), bugReportService: BugReportServiceMock(.init()), @@ -48,7 +48,6 @@ struct ChatsTabFlowCoordinatorTests { appHooks: AppHooks(), analytics: AnalyticsServiceMock(.init()), userIndicatorController: UserIndicatorControllerMock(), - notificationManager: notificationManager, stateMachineFactory: stateMachineFactory) chatsTabFlowCoordinator = ChatsTabFlowCoordinator(navigationSplitCoordinator: splitCoordinator, flowParameters: flowParameters) @@ -80,7 +79,7 @@ struct ChatsTabFlowCoordinatorTests { #expect(detailNavigationStack?.rootCoordinator == nil) #expect(detailCoordinator == nil) - #expect(notificationManager.removeDeliveredMessageNotificationsForReceivedInvocations == ["1", "1", "2"]) + #expect(notificationService.removeDeliveredMessageNotificationsForReceivedInvocations == ["1", "1", "2"]) } @Test @@ -109,7 +108,7 @@ struct ChatsTabFlowCoordinatorTests { #expect(detailNavigationStack?.rootCoordinator == nil) #expect(detailCoordinator == nil) - #expect(notificationManager.removeDeliveredMessageNotificationsForReceivedInvocations == ["1", "1", "2"]) + #expect(notificationService.removeDeliveredMessageNotificationsForReceivedInvocations == ["1", "1", "2"]) } @Test diff --git a/UnitTests/Sources/HomeScreenViewModelTests.swift b/UnitTests/Sources/HomeScreenViewModelTests.swift index dfbfd6019..9c17dd172 100644 --- a/UnitTests/Sources/HomeScreenViewModelTests.swift +++ b/UnitTests/Sources/HomeScreenViewModelTests.swift @@ -19,7 +19,7 @@ final class HomeScreenViewModelTests { var clientProxy: ClientProxyMock! var roomSummaryProvider: RoomSummaryProviderMock! - var notificationManager: NotificationManagerMock! + var notificationService: NotificationServiceMock! private let userSettings: UserSettings var cancellables = Set() @@ -297,7 +297,7 @@ final class HomeScreenViewModelTests { try await deferred.fulfill() #expect(userSettings.app.seenInvites == [invitedRoomIDs[1]]) - #expect(!notificationManager.removeDeliveredMessageNotificationsForCalled, "The notification will be dismissed when opening the room.") + #expect(!notificationService.removeDeliveredMessageNotificationsForCalled, "The notification will be dismissed when opening the room.") } @Test @@ -315,7 +315,7 @@ final class HomeScreenViewModelTests { try await deferred.fulfill() #expect(userSettings.app.seenInvites == [invitedRoomIDs[1]]) - #expect(!notificationManager.removeDeliveredMessageNotificationsForCalled, "The notification will be dismissed when opening the room.") + #expect(!notificationService.removeDeliveredMessageNotificationsForCalled, "The notification will be dismissed when opening the room.") } @Test @@ -346,8 +346,8 @@ final class HomeScreenViewModelTests { #expect(rejectCalled) #expect(userSettings.app.seenInvites == [invitedRoomIDs[1]]) - #expect(notificationManager.removeDeliveredMessageNotificationsForCalled) - #expect(notificationManager.removeDeliveredMessageNotificationsForReceivedInvocations == [invitedRoomIDs[0]]) + #expect(notificationService.removeDeliveredMessageNotificationsForCalled) + #expect(notificationService.removeDeliveredMessageNotificationsForReceivedInvocations == [invitedRoomIDs[0]]) } @Test @@ -601,13 +601,12 @@ final class HomeScreenViewModelTests { break } - let userSession = UserSessionMock(.init(clientProxy: clientProxy, userSettings: userSettings)) + notificationService = NotificationServiceMock(.init()) + let userSession = UserSessionMock(.init(clientProxy: clientProxy, userSettings: userSettings, notificationService: notificationService)) if let securityStatePublisher { userSession.sessionSecurityStatePublisher = securityStatePublisher } - notificationManager = NotificationManagerMock() - let sessionDetails = [UserSessionDetails(userSession: userSession)] + otherAccountUserIDs.map { UserSessionDetails(userID: $0) } viewModel = HomeScreenViewModel(userSession: userSession, @@ -615,7 +614,6 @@ final class HomeScreenViewModelTests { selectedRoomPublisher: CurrentValueSubject(nil).asCurrentValuePublisher(), analyticsService: AnalyticsServiceMock(.init()), bugReportService: BugReportServiceMock(.init()), - notificationManager: notificationManager, userIndicatorController: UserIndicatorControllerMock()) } } diff --git a/UnitTests/Sources/NotificationManager/NotificationManagerTests.swift b/UnitTests/Sources/NotificationManager/NotificationManagerTests.swift index 3650e3532..72fa75428 100644 --- a/UnitTests/Sources/NotificationManager/NotificationManagerTests.swift +++ b/UnitTests/Sources/NotificationManager/NotificationManagerTests.swift @@ -15,26 +15,25 @@ import Testing @MainActor final class NotificationManagerTests { var notificationManager: NotificationManager! - private let clientProxy = ClientProxyMock(.init(userID: "@test:user.net")) - private lazy var mockUserSession = UserSessionMock(.init(clientProxy: clientProxy)) private var notificationCenter: UserNotificationCenterMock! + private let appSettings: AppSettings private var authorizationStatusWasGranted = false private var shouldDisplayInAppNotificationReturnValue = false private var handleInlineReplyDelegateCalled = false private var notificationTappedDelegateCalled = false private var registerForRemoteNotificationsDelegateCalled: (() -> Void)? - private let appSettings: AppSettings + private var unregisterForRemoteNotificationsDelegateCalled = false init() { appSettings = AppSettings.volatile() notificationCenter = UserNotificationCenterMock() notificationCenter.requestAuthorizationOptionsReturnValue = true notificationCenter.authorizationStatusReturnValue = .authorized + notificationCenter.deliveredNotificationsReturnValue = [] notificationCenter.notificationSettingsClosure = { await UNUserNotificationCenter.current().notificationSettings() } notificationManager = NotificationManager(notificationCenter: notificationCenter, appSettings: appSettings) notificationManager.start() - notificationManager.setUserSession(mockUserSession) } isolated deinit { @@ -42,73 +41,239 @@ final class NotificationManagerTests { notificationManager = nil } + // MARK: - Device token + @Test - func whenRegistered_pusherIsCalled() async { - _ = await notificationManager.register(with: Data()) + func registeringHandsTheTokenToEveryAccount() async { + let alice = makeService("@alice:example.com") + let bob = makeService("@bob:example.com") + notificationManager.add(alice.service) + notificationManager.add(bob.service) + let deviceToken = Data("1234".utf8) - #expect(clientProxy.setPusherWithCalled) + await notificationManager.register(with: deviceToken) + + #expect(alice.service.registerDeviceTokenReceivedDeviceToken == deviceToken) + #expect(bob.service.registerDeviceTokenReceivedDeviceToken == deviceToken) } @Test - func whenRegisteredSuccess_completionSuccessIsCalled() async { - let success = await notificationManager.register(with: Data()) - #expect(success) - } - - @Test - func whenRegisteredAndPusherThrowsError_completionFalseIsCalled() async { - enum TestError: Error { - case someError + func anAccountAddedAfterwardsGetsTheSavedToken() async { + let deviceToken = Data("1234".utf8) + await notificationManager.register(with: deviceToken) + let alice = makeService("@alice:example.com") + + await waitForConfirmation("The saved token should be registered", timeout: .seconds(10)) { confirm in + alice.service.registerDeviceTokenClosure = { _ in confirm() } + notificationManager.add(alice.service) } - clientProxy.setPusherWithThrowableError = TestError.someError - let success = await notificationManager.register(with: Data()) - #expect(!success) + #expect(alice.service.registerDeviceTokenReceivedDeviceToken == deviceToken) } @Test - func whenRegistered_pusherIsCalledWithCorrectValues() async throws { - let pushkeyData = Data("1234".utf8) - _ = await notificationManager.register(with: pushkeyData) + func aReplacedServiceRegistersAgainAndTheOldOneIsIgnored() async { + let oldBadgeCountSubject = CurrentValueSubject(3) + let oldAlice = makeService("@alice:example.com") + oldAlice.service.badgeCountPublisher = oldBadgeCountSubject.asCurrentValuePublisher() + notificationManager.add(oldAlice.service) + await notificationManager.register(with: Data("1234".utf8)) + let newAlice = makeService("@alice:example.com", badgeCount: 1) - guard let configuration = clientProxy.setPusherWithReceivedInvocations.first else { - Issue.record("Invalid pusher configuration sent") - return + await waitForConfirmation("The new service should register", timeout: .seconds(10)) { confirm in + newAlice.service.registerDeviceTokenClosure = { _ in confirm() } + notificationManager.add(newAlice.service) + } + oldBadgeCountSubject.send(5) + + #expect(oldAlice.service.registerDeviceTokenCallsCount == 1) + #expect(appSettings.lastKnownBadgeCount == 1) + } + + @Test + func theTokenIsForgottenOnceTheLastAccountIsRemoved() async throws { + notificationManager.add(makeService("@alice:example.com").service) + await notificationManager.register(with: Data("1234".utf8)) + notificationManager.remove(userID: "@alice:example.com") + let bob = makeService("@bob:example.com") + + // The app unregisters from APNs when signing out of the last account, so the old token is dead. + notificationManager.add(bob.service) + try await Task.sleep(for: .milliseconds(500)) + + #expect(!bob.service.registerDeviceTokenCalled) + } + + // MARK: - Permission + + @Test + func addingAnAccountRegistersWhenPermissionWasGiven() async { + notificationManager.delegate = self + + await waitForConfirmation("registerForRemoteNotifications should be called", timeout: .seconds(10)) { confirm in + registerForRemoteNotificationsDelegateCalled = { confirm() } + notificationManager.add(makeService("@alice:example.com").service) } - #expect(configuration.identifiers.pushkey == pushkeyData.base64EncodedString()) - #expect(configuration.identifiers.appId == appSettings.pusherAppID) - #expect(configuration.appDisplayName == "\(InfoPlistReader.main.bundleDisplayName) (iOS)") - #expect(configuration.deviceDisplayName == UIDevice.current.name) - #expect(configuration.profileTag != nil) - #expect(configuration.lang == Bundle.app.preferredLocalizations.first) - guard case let .http(data) = configuration.kind else { - Issue.record("Http kind expected") - return + #expect(authorizationStatusWasGranted) + } + + @Test + func addingAnAccountRegistersEvenWhenItIsOff() async { + notificationManager.delegate = self + + // So that accounts turned off delete any pusher left on their homeserver. + await waitForConfirmation("registerForRemoteNotifications should be called", timeout: .seconds(10)) { confirm in + registerForRemoteNotificationsDelegateCalled = { confirm() } + notificationManager.add(makeService("@alice:example.com", isEnabled: false).service) } - #expect(data.url == appSettings.pushGatewayNotifyEndpoint.absoluteString) - #expect(data.format == .eventIdOnly) - let defaultPayload = APNSPayload(aps: APSInfo(mutableContent: 1, - alert: APSAlert(locKey: "Notification", - locArgs: [])), - pusherNotificationClientIdentifier: nil) - #expect(try data.defaultPayload == (defaultPayload.toJsonString())) + + #expect(authorizationStatusWasGranted) } @Test - func whenRegisteredAndPusherTagNotSetInSettings_tagGeneratedAndSavedInSettings() async { - appSettings.pusherProfileTag = nil - _ = await notificationManager.register(with: Data()) - #expect(appSettings.pusherProfileTag != nil) + func addingAnAccountNeverAsksForPermission() async throws { + notificationCenter.authorizationStatusReturnValue = .notDetermined + notificationManager.delegate = self + + notificationManager.add(makeService("@alice:example.com").service) + try await Task.sleep(for: .seconds(1)) + + #expect(!notificationCenter.requestAuthorizationOptionsCalled) + #expect(!authorizationStatusWasGranted) } @Test - func whenRegisteredAndPusherTagIsSetInSettings_tagNotGenerated() async { - appSettings.pusherProfileTag = "12345" - _ = await notificationManager.register(with: Data()) - #expect(appSettings.pusherProfileTag == "12345") + func anAccountAskingForPermissionRequestsAuthorization() async { + let alice = makeService("@alice:example.com") + notificationManager.add(alice.service) + + await waitForConfirmation("requestAuthorization should be called", timeout: .seconds(10)) { confirm in + notificationCenter.requestAuthorizationOptionsClosure = { _ in + confirm() + return true + } + alice.actions.send(.requestAuthorization) + } + + #expect(notificationCenter.requestAuthorizationOptionsReceivedOptions == [.alert, .sound, .badge]) } + @Test + func grantingPermissionRegistersForRemoteNotifications() async { + notificationCenter.authorizationStatusReturnValue = .notDetermined + notificationManager.delegate = self + let alice = makeService("@alice:example.com") + notificationManager.add(alice.service) + + await waitForConfirmation("registerForRemoteNotifications should be called", timeout: .seconds(10)) { confirm in + registerForRemoteNotificationsDelegateCalled = { confirm() } + alice.actions.send(.requestAuthorization) + } + + #expect(notificationCenter.requestAuthorizationOptionsReceivedOptions == [.alert, .sound, .badge]) + } + + @Test + func askingForPermissionWithEveryAccountOffDoesNothing() async throws { + let alice = makeService("@alice:example.com", isEnabled: false) + notificationManager.add(alice.service) + + alice.actions.send(.requestAuthorization) + try await Task.sleep(for: .seconds(1)) + + #expect(!notificationCenter.requestAuthorizationOptionsCalled) + } + + // MARK: - Badge + + @Test + func appBadgeSumsEveryAccount() { + notificationManager.add(makeService("@alice:example.com", badgeCount: 3).service) + notificationManager.add(makeService("@bob:example.com", badgeCount: 2).service) + + #expect(appSettings.lastKnownBadgeCount == 5) + } + + @Test + func appBadgeFollowsAnAccountsCount() { + let badgeCountSubject = CurrentValueSubject(3) + let alice = makeService("@alice:example.com") + alice.service.badgeCountPublisher = badgeCountSubject.asCurrentValuePublisher() + notificationManager.add(alice.service) + + badgeCountSubject.send(7) + + #expect(appSettings.lastKnownBadgeCount == 7) + } + + @Test + func appBadgeDropsARemovedAccount() { + notificationManager.add(makeService("@alice:example.com", badgeCount: 3).service) + notificationManager.add(makeService("@bob:example.com", badgeCount: 2).service) + + notificationManager.remove(userID: "@bob:example.com") + + #expect(appSettings.lastKnownBadgeCount == 3) + } + + @Test + func appBadgeIsLeftAloneWithoutAnAccount() { + appSettings.lastKnownBadgeCount = 4 + + notificationManager.remove(userID: "@alice:example.com") + + #expect(appSettings.lastKnownBadgeCount == 4) + #expect(!notificationCenter.setBadgeCountCalled) + } + + @Test + func updatingTheAppBadge() async { + await waitForConfirmation("The app badge should be set", timeout: .seconds(10)) { confirm in + notificationCenter.setBadgeCountClosure = { _ in confirm() } + notificationManager.add(makeService("@alice:example.com", badgeCount: 7).service) + } + + #expect(notificationCenter.setBadgeCountReceivedNewBadgeCount == 7) + } + + @Test + func resettingStopsReceivingNotifications() async { + notificationManager.delegate = self + appSettings.lastKnownBadgeCount = 4 + + await waitForConfirmation("The app badge should be reset", timeout: .seconds(10)) { confirm in + notificationCenter.setBadgeCountClosure = { _ in confirm() } + notificationManager.reset() + } + + #expect(notificationCenter.setBadgeCountReceivedNewBadgeCount == 0) + #expect(appSettings.lastKnownBadgeCount == 0) + #expect(unregisterForRemoteNotificationsDelegateCalled) + } + + // MARK: - Delivered notifications + + @Test + func removingNotificationsOnlyTouchesThatAccount() async throws { + notificationCenter.deliveredNotificationsReturnValue = try [notification("alice room", roomID: "!room:example.com", receiverID: "@alice:example.com"), + notification("bob room", roomID: "!room:example.com", receiverID: "@bob:example.com"), + notification("alice other", roomID: "!other:example.com", receiverID: "@alice:example.com"), + notification("legacy room", roomID: "!room:example.com", receiverID: nil)] + let alice = makeService("@alice:example.com") + notificationManager.add(alice.service) + + await waitForConfirmation("Notifications should be removed", timeout: .seconds(10)) { confirm in + notificationCenter.removeDeliveredNotificationsWithIdentifiersClosure = { _ in confirm() } + alice.actions.send(.removeDeliveredNotifications { $0.request.content.roomID == "!room:example.com" }) + } + + // Notifications without a receiver weren't processed by the extension, so any account may clear them. + #expect(notificationCenter.removeDeliveredNotificationsWithIdentifiersReceivedIdentifiers == ["alice room", "legacy room"]) + } + + // MARK: - System + @Test func whenShowLocalNotification_notificationRequestGetsAdded() async throws { await notificationManager.showLocalNotification(with: "Title", subtitle: "Subtitle") @@ -140,81 +305,6 @@ final class NotificationManagerTests { #expect(delegate.isEqual(notificationManager)) } - @Test - func whenStart_requestAuthorizationCalledWithCorrectParams() async { - await waitForConfirmation("requestAuthorization should be called", timeout: .seconds(10)) { confirm in - notificationCenter.requestAuthorizationOptionsClosure = { _ in - confirm() - return true - } - notificationManager.requestAuthorization() - } - #expect(notificationCenter.requestAuthorizationOptionsReceivedOptions == [.alert, .sound, .badge]) - } - - @Test - func whenStartAndAuthorizationGranted_delegateCalled() async { - authorizationStatusWasGranted = false - notificationManager.delegate = self - await waitForConfirmation("registerForRemoteNotifications delegate function should be called", timeout: .seconds(10)) { confirm in - registerForRemoteNotificationsDelegateCalled = { - confirm() - } - notificationManager.requestAuthorization() - } - #expect(authorizationStatusWasGranted) - } - - @Test - func whenStartAndAuthorizedAndNotificationDisabled_registerForRemoteNotificationsNotCalled() async throws { - appSettings.enableNotifications = false - notificationCenter.authorizationStatusReturnValue = .authorized - notificationManager.delegate = self - - notificationManager.setUserSession(UserSessionMock(.init())) - try await Task.sleep(for: .seconds(1)) - - #expect(!authorizationStatusWasGranted) - } - - @Test - func whenStartAndAuthorized_registerForRemoteNotificationsCalled() async { - appSettings.enableNotifications = true - notificationCenter.authorizationStatusReturnValue = .authorized - notificationManager.delegate = self - - await waitForConfirmation("registerForRemoteNotifications delegate function should be called", timeout: .seconds(10)) { confirm in - registerForRemoteNotificationsDelegateCalled = { - confirm() - } - notificationManager.setUserSession(UserSessionMock(.init())) - } - - #expect(authorizationStatusWasGranted) - } - - @Test - func removeDeliveredNotificationsForFullyReadRooms() async throws { - let room = RoomSummary(room: RoomSDKMock(), id: "!room:matrix.org", settingsMode: .allMessages, - hasUnreadMessages: false, hasUnreadMentions: false, hasUnreadNotifications: false) - let lastMessageDate = try #require(room.lastMessageDate) - - // Delivered after the last message was sent, which is always the case for the last message's notification. - let readNotification = try UNNotification.with(userInfo: [NotificationConstants.UserInfoKey.roomIdentifier: room.id, - NotificationConstants.UserInfoKey.eventDate: lastMessageDate.addingTimeInterval(0.5)], - identifier: "read", - date: lastMessageDate.addingTimeInterval(5)) - let newerNotification = try UNNotification.with(userInfo: [NotificationConstants.UserInfoKey.roomIdentifier: room.id, - NotificationConstants.UserInfoKey.eventDate: lastMessageDate.addingTimeInterval(1)], - identifier: "newer", - date: lastMessageDate.addingTimeInterval(5)) - notificationCenter.deliveredNotificationsReturnValue = [readNotification, newerNotification] - - await notificationManager.removeDeliveredNotificationsForFullyReadRooms([room]) - - #expect(notificationCenter.removeDeliveredNotificationsWithIdentifiersReceivedIdentifiers == ["read"]) - } - @Test func whenWillPresentNotificationsDelegateNotSet_CorrectPresentationOptionsReturned() async throws { let archiver = MockCoder(requiringSecureCoding: false) @@ -261,21 +351,19 @@ final class NotificationManagerTests { #expect(notificationTappedDelegateCalled) } - @Test - func updatingAppBadgeCount() async { - await notificationManager.updateAppBadgeCount(7) - - #expect(notificationCenter.setBadgeCountReceivedNewBadgeCount == 7) - #expect(appSettings.lastKnownBadgeCount == 7) + // MARK: - Helpers + + private func makeService(_ userID: String, isEnabled: Bool = true, badgeCount: Int = 0) -> (service: NotificationServiceMock, actions: PassthroughSubject) { + let actions = PassthroughSubject() + let service = NotificationServiceMock(.init(userID: userID, isEnabled: isEnabled, badgeCount: badgeCount)) + service.actionsPublisher = actions.eraseToAnyPublisher() + return (service, actions) } - @Test - func updatingAppBadgeCountWithoutASessionDoesNothing() async { - notificationManager.setUserSession(nil) - - await notificationManager.updateAppBadgeCount(7) - - #expect(!notificationCenter.setBadgeCountCalled) + private func notification(_ identifier: String, roomID: String, receiverID: String?) throws -> UNNotification { + var userInfo: [AnyHashable: Any] = [NotificationConstants.UserInfoKey.roomIdentifier: roomID] + userInfo[NotificationConstants.UserInfoKey.receiverIdentifier] = receiverID + return try UNNotification.with(userInfo: userInfo, identifier: identifier) } } @@ -286,7 +374,7 @@ extension NotificationManagerTests: @MainActor NotificationManagerDelegate { } func unregisterForRemoteNotifications() { - authorizationStatusWasGranted = false + unregisterForRemoteNotificationsDelegateCalled = true } func shouldDisplayInAppNotification(content: UNNotificationContent) -> Bool { diff --git a/UnitTests/Sources/NotificationManager/NotificationServiceTests.swift b/UnitTests/Sources/NotificationManager/NotificationServiceTests.swift new file mode 100644 index 000000000..732e3d6de --- /dev/null +++ b/UnitTests/Sources/NotificationManager/NotificationServiceTests.swift @@ -0,0 +1,254 @@ +// +// Copyright 2026 Element Creations 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 +@testable import ElementX +import MatrixRustSDK +import MatrixRustSDKMocks +import SwiftUI +import Testing +import UserNotifications + +@MainActor +final class NotificationServiceTests { + private let clientProxy = ClientProxyMock(.init(userID: "@alice:example.com")) + private let appSettings = AppSettings.volatile() + private let userSettings: UserSettings + private var actions: [NotificationServiceAction] = [] + private var cancellables = Set() + + init() { + userSettings = appSettings.userSettings(for: "@alice:example.com") + } + + // MARK: - Pushers + + @Test + func registeringSetsThePusher() async throws { + let service = makeService() + let deviceToken = Data("1234".utf8) + + await service.register(deviceToken: deviceToken) + + let defaultPayload = APNSPayload(aps: APSInfo(mutableContent: 1, + alert: APSAlert(locKey: "Notification", locArgs: [])), + pusherNotificationClientIdentifier: nil) + let expectedConfiguration = 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: appSettings.pusherProfileTag, + lang: Bundle.app.preferredLocalizations.first ?? "en", + append: false) + #expect(clientProxy.setPusherWithReceivedInvocations == [expectedConfiguration]) + } + + @Test + func pushersAppendWithMultipleAccounts() async throws { + appSettings.multiAccountEnabled = true + let service = makeService() + + await service.register(deviceToken: Data()) + + let configuration = try #require(clientProxy.setPusherWithReceivedInvocations.first) + #expect(configuration.append) + } + + @Test + func registeringGeneratesAndSavesTheProfileTag() async { + appSettings.pusherProfileTag = nil + + await makeService().register(deviceToken: Data()) + + #expect(appSettings.pusherProfileTag != nil) + } + + @Test + func registeringKeepsTheSavedProfileTag() async { + appSettings.pusherProfileTag = "12345" + + await makeService().register(deviceToken: Data()) + + #expect(appSettings.pusherProfileTag == "12345") + } + + @Test + func registeringWhileTurnedOffDeletesThePusher() async throws { + let service = makeService(isEnabled: false) + let deviceToken = Data("1234".utf8) + + await service.register(deviceToken: deviceToken) + + #expect(!clientProxy.setPusherWithCalled) + let identifiers = try #require(clientProxy.deletePusherWithReceivedIdentifiers) + #expect(identifiers.pushkey == deviceToken.base64EncodedString()) + #expect(identifiers.appId == appSettings.pusherAppID) + } + + // MARK: - Switch + + @Test + func turningOffDeletesThePusher() async { + let service = makeService() + await service.register(deviceToken: Data("1234".utf8)) + + await waitForConfirmation("The pusher should be deleted", timeout: .seconds(10)) { confirm in + clientProxy.deletePusherWithClosure = { _ in confirm() } + userSettings.account.enableNotifications = false + } + + #expect(!service.isEnabled) + } + + @Test + func turningOnSetsThePusherAndAsksForPermission() async { + let service = makeService(isEnabled: false) + await service.register(deviceToken: Data("1234".utf8)) + + await waitForConfirmation("The pusher should be set", timeout: .seconds(10)) { confirm in + clientProxy.setPusherWithClosure = { _ in confirm() } + userSettings.account.enableNotifications = true + } + + #expect(service.isEnabled) + #expect(isRequestAuthorization(actions.last)) + } + + @Test + func turningOnBeforeRegisteringOnlyAsksForPermission() { + let service = makeService(isEnabled: false) + + userSettings.account.enableNotifications = true + + #expect(service.isEnabled) + #expect(!clientProxy.setPusherWithCalled) + #expect(isRequestAuthorization(actions.last)) + } + + @Test + func requestingAuthorizationAsksTheManager() { + let service = makeService() + + service.requestAuthorization() + + #expect(isRequestAuthorization(actions.last)) + } + + // MARK: - Badge + + @Test + func badgeCountStartsFromTheUnreadCount() { + clientProxy.totalUnreadNotifications = 3 + + let service = makeService() + + #expect(service.badgeCountPublisher.value == 3) + } + + @Test + func roomListChangesUpdateTheBadgeCount() async throws { + let roomListSubject = CurrentValueSubject<[RoomSummary], Never>([]) + let roomSummaryProvider = RoomSummaryProviderMock(.init()) + roomSummaryProvider.roomListPublisher = roomListSubject.asCurrentValuePublisher() + clientProxy.staticRoomSummaryProvider = roomSummaryProvider + let service = makeService() + + let deferred = deferFulfillment(service.badgeCountPublisher) { $0 == 7 } + clientProxy.totalUnreadNotifications = 7 + roomListSubject.send([]) + try await deferred.fulfill() + } + + // MARK: - Delivered notifications + + @Test + func clearingARoomOnlyMatchesThatRoom() throws { + let service = makeService() + + service.removeDeliveredMessageNotifications(for: "!room:example.com") + + let rule = try #require(removalRules.last) + #expect(try rule(notification(roomID: "!room:example.com"))) + #expect(try !rule(notification(roomID: "!other:example.com"))) + } + + @Test + func clearingFullyReadRoomsOnlyMatchesReadNotifications() throws { + let room = RoomSummary(room: RoomSDKMock(), id: "!room:matrix.org", settingsMode: .allMessages, + hasUnreadMessages: false, hasUnreadMentions: false, hasUnreadNotifications: false) + let lastMessageDate = try #require(room.lastMessageDate) + let service = makeService() + + service.removeDeliveredNotificationsForFullyReadRooms([room]) + + let rule = try #require(removalRules.last) + // Delivered after the last message was sent, which is always the case for the last message's notification. + let readNotification = try UNNotification.with(userInfo: [NotificationConstants.UserInfoKey.roomIdentifier: room.id, + NotificationConstants.UserInfoKey.eventDate: lastMessageDate.addingTimeInterval(0.5)], + date: lastMessageDate.addingTimeInterval(5)) + let newerNotification = try UNNotification.with(userInfo: [NotificationConstants.UserInfoKey.roomIdentifier: room.id, + NotificationConstants.UserInfoKey.eventDate: lastMessageDate.addingTimeInterval(1)], + date: lastMessageDate.addingTimeInterval(5)) + #expect(rule(readNotification)) + #expect(!rule(newerNotification)) + #expect(try !rule(notification(roomID: "!other:example.com"))) + } + + @Test + func clearingWithoutFullyReadRoomsAsksNothing() { + let room = RoomSummary(room: RoomSDKMock(), id: "!room:matrix.org", settingsMode: .allMessages, + hasUnreadMessages: true, hasUnreadMentions: false, hasUnreadNotifications: true) + let service = makeService() + + service.removeDeliveredNotificationsForFullyReadRooms([room]) + + #expect(removalRules.isEmpty) + } + + @Test + func tearingDownDeletesThePusherAndRemovesEveryNotification() async throws { + let service = makeService() + await service.register(deviceToken: Data()) + + await service.tearDown() + + #expect(clientProxy.deletePusherWithCalled) + let rule = try #require(removalRules.last) + #expect(try rule(notification(roomID: "!any:example.com"))) + } + + // MARK: - Helpers + + private func makeService(isEnabled: Bool = true) -> NotificationService { + userSettings.account.enableNotifications = isEnabled + let service = NotificationService(clientProxy: clientProxy, userSettings: userSettings) + service.actionsPublisher + .sink { [weak self] action in + self?.actions.append(action) + } + .store(in: &cancellables) + return service + } + + private func notification(roomID: String) throws -> UNNotification { + try UNNotification.with(userInfo: [NotificationConstants.UserInfoKey.roomIdentifier: roomID]) + } + + private var removalRules: [(UNNotification) -> Bool] { + actions.compactMap { action in + guard case let .removeDeliveredNotifications(rule) = action else { return nil } + return rule + } + } + + private func isRequestAuthorization(_ action: NotificationServiceAction?) -> Bool { + guard case .requestAuthorization = action else { return false } + return true + } +} diff --git a/UnitTests/Sources/NotificationSettingsScreenViewModelTests.swift b/UnitTests/Sources/NotificationSettingsScreenViewModelTests.swift index ee8458aa3..810435ca6 100644 --- a/UnitTests/Sources/NotificationSettingsScreenViewModelTests.swift +++ b/UnitTests/Sources/NotificationSettingsScreenViewModelTests.swift @@ -42,16 +42,16 @@ struct NotificationSettingsScreenViewModelTests { @Test func enableNotifications() { - userSettings.app.enableNotifications = false + userSettings.account.enableNotifications = false context.send(viewAction: .changedEnableNotifications) - #expect(userSettings.app.enableNotifications) + #expect(userSettings.account.enableNotifications) } @Test func disableNotifications() { - userSettings.app.enableNotifications = true + userSettings.account.enableNotifications = true context.send(viewAction: .changedEnableNotifications) - #expect(!userSettings.app.enableNotifications) + #expect(!userSettings.account.enableNotifications) } @Test diff --git a/UnitTests/Sources/RoomFlowCoordinatorTests.swift b/UnitTests/Sources/RoomFlowCoordinatorTests.swift index 17e1f1546..50ad3aad8 100644 --- a/UnitTests/Sources/RoomFlowCoordinatorTests.swift +++ b/UnitTests/Sources/RoomFlowCoordinatorTests.swift @@ -534,7 +534,6 @@ final class RoomFlowCoordinatorTests { appHooks: AppHooks(), analytics: AnalyticsServiceMock(.init()), userIndicatorController: UserIndicatorControllerMock(), - notificationManager: NotificationManagerMock(), stateMachineFactory: StateMachineFactory()) roomFlowCoordinator = RoomFlowCoordinator(roomID: roomID, diff --git a/UnitTests/Sources/RoomMembersFlowCoordinatorTests.swift b/UnitTests/Sources/RoomMembersFlowCoordinatorTests.swift index 32f964901..9a9d49137 100644 --- a/UnitTests/Sources/RoomMembersFlowCoordinatorTests.swift +++ b/UnitTests/Sources/RoomMembersFlowCoordinatorTests.swift @@ -64,7 +64,6 @@ struct RoomMembersFlowCoordinatorTests { appHooks: AppHooks(), analytics: AnalyticsServiceMock(.init()), userIndicatorController: UserIndicatorControllerMock(), - notificationManager: NotificationManagerMock(), stateMachineFactory: stateMachineFactory) let roomProxy = JoinedRoomProxyMock(.init()) diff --git a/UnitTests/Sources/UserSessionFlowCoordinatorTests.swift b/UnitTests/Sources/UserSessionFlowCoordinatorTests.swift index ccdf9a6d1..19307f763 100644 --- a/UnitTests/Sources/UserSessionFlowCoordinatorTests.swift +++ b/UnitTests/Sources/UserSessionFlowCoordinatorTests.swift @@ -61,7 +61,6 @@ struct UserSessionFlowCoordinatorTests { appHooks: AppHooks(), analytics: AnalyticsServiceMock(.init()), userIndicatorController: userIndicatorController, - notificationManager: NotificationManagerMock(), stateMachineFactory: stateMachineFactory) userSessionFlowCoordinator = UserSessionFlowCoordinator(isNewLogin: false, diff --git a/UnitTests/Sources/UserSessionManagerTests.swift b/UnitTests/Sources/UserSessionManagerTests.swift index a372e69b4..a428531cd 100644 --- a/UnitTests/Sources/UserSessionManagerTests.swift +++ b/UnitTests/Sources/UserSessionManagerTests.swift @@ -12,6 +12,7 @@ import Testing struct UserSessionManagerTests { let userSessionStore = UserSessionStoreMock() let appSettings = AppSettings.volatile() + let notificationManager = NotificationManagerMock() @Test func ordersAccountsByWhenTheyWereLastSelected() { @@ -233,6 +234,46 @@ struct UserSessionManagerTests { #expect(manager.activeSession == nil) } + @Test + func addingASessionConnectsItsNotificationService() { + let manager = makeManager(userIDs: []) + let alice = makeUserSession(userID: "@alice:matrix.org") + + manager.add(alice) + + #expect(notificationManager.addReceivedService === alice.notificationService) + } + + @Test + func removingAnAccountDisconnectsItsNotificationService() { + let manager = makeManager(userIDs: ["@alice:matrix.org"]) + manager.add(makeUserSession(userID: "@alice:matrix.org")) + + manager.remove(userID: "@alice:matrix.org") + + #expect(notificationManager.removeUserIDReceivedInvocations == ["@alice:matrix.org"]) + } + + @Test + func resettingDisconnectsEveryNotificationService() { + let manager = makeManager(userIDs: ["@alice:matrix.org", "@bob:matrix.org"]) + manager.add(makeUserSession(userID: "@alice:matrix.org")) + + manager.reset() + + #expect(Set(notificationManager.removeUserIDReceivedInvocations) == ["@alice:matrix.org", "@bob:matrix.org"]) + } + + @Test + func aFailedRestoreDisconnectsTheAccountsNotificationService() async { + let manager = makeManager(userIDs: ["@alice:matrix.org"]) + userSessionStore.restoreUserSessionUserIDReturnValue = .failure(.failedRestoringLogin) + + await manager.restoreSessions { _ in } didAdd: { _ in } + + #expect(notificationManager.removeUserIDReceivedInvocations == ["@alice:matrix.org"]) + } + @Test func resumingAndPausingReachEverySession() async { let manager = makeManager(userIDs: ["@alice:matrix.org", "@bob:matrix.org"]) @@ -272,7 +313,7 @@ struct UserSessionManagerTests { private func makeManager(userIDs: [String]) -> UserSessionManager { userSessionStore.userIDs = userIDs - return UserSessionManager(userSessionStore: userSessionStore, appSettings: appSettings) + return UserSessionManager(userSessionStore: userSessionStore, appSettings: appSettings, notificationManager: notificationManager) } private func makeUserSession(userID: String) -> UserSessionMock { diff --git a/project.yml b/project.yml index cb66b9353..553879b2f 100644 --- a/project.yml +++ b/project.yml @@ -51,7 +51,7 @@ settings: ENABLE_BITCODE: false APP_NAME: ElementX KEYCHAIN_ACCESS_GROUP_IDENTIFIER: "$(DEVELOPMENT_TEAM).$(BASE_BUNDLE_IDENTIFIER)" - MARKETING_VERSION: 26.10.2 + MARKETING_VERSION: 26.10.3 CURRENT_PROJECT_VERSION: 1 SUPPORTS_MACCATALYST: false ARCHS: arm64