diff --git a/Riot/Categories/Publisher+Riot.swift b/Riot/Categories/Publisher+Riot.swift index 98bb522b37..7c70404c6e 100644 --- a/Riot/Categories/Publisher+Riot.swift +++ b/Riot/Categories/Publisher+Riot.swift @@ -33,4 +33,10 @@ extension Publisher { Just($0).delay(for: .seconds(spacingDelay), scheduler: scheduler) } } + + func eraseOutput() -> AnyPublisher { + self + .map { _ in () } + .eraseToAnyPublisher() + } } diff --git a/Riot/Managers/PushRulesUpdater/PushRulesUpdater.swift b/Riot/Managers/PushRulesUpdater/PushRulesUpdater.swift new file mode 100644 index 0000000000..a0ffea92f0 --- /dev/null +++ b/Riot/Managers/PushRulesUpdater/PushRulesUpdater.swift @@ -0,0 +1,62 @@ +// +// Copyright 2023 New Vector Ltd +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import Combine + +final class PushRulesUpdater { + private var cancellables: Set = .init() + private var rules: [NotificationPushRuleType] = [] + private let notificationSettingsService: NotificationSettingsServiceType + + init(notificationSettingsService: NotificationSettingsServiceType) { + self.notificationSettingsService = notificationSettingsService + + notificationSettingsService + .rulesPublisher + .weakAssign(to: \.rules, on: self) + .store(in: &cancellables) + } + + func syncRulesIfNeeded() async { + await withTaskGroup(of: Void.self) { [rules, notificationSettingsService] group in + for rule in rules { + guard let ruleId = rule.pushRuleId else { + continue + } + + let relatedRules = ruleId.syncedRules(in: rules) + + for relatedRule in relatedRules { + guard rule.hasSameContentOf(relatedRule) == false else { + continue + } + + group.addTask { + try? await notificationSettingsService.updatePushRuleActions(for: relatedRule.ruleId, + enabled: rule.enabled, + actions: rule.ruleActions) + } + } + } + } + } +} + +private extension NotificationPushRuleType { + func hasSameContentOf(_ otherRule: NotificationPushRuleType) -> Bool? { + enabled == otherRule.enabled && ruleActions == otherRule.ruleActions + } +} diff --git a/Riot/Modules/Application/AppCoordinator.swift b/Riot/Modules/Application/AppCoordinator.swift index c356d0b863..5bf34b7c81 100755 --- a/Riot/Modules/Application/AppCoordinator.swift +++ b/Riot/Modules/Application/AppCoordinator.swift @@ -14,6 +14,7 @@ limitations under the License. */ +import Combine import Foundation import Intents import MatrixSDK @@ -60,6 +61,8 @@ final class AppCoordinator: NSObject, AppCoordinatorType { } private var currentSpaceId: String? + private var cancellables: Set = .init() + private var pushRulesUpdater: PushRulesUpdater? // MARK: Public @@ -81,9 +84,10 @@ final class AppCoordinator: NSObject, AppCoordinatorType { // MARK: - Public methods func start() { - self.setupLogger() - self.setupTheme() - self.excludeAllItemsFromBackup() + setupLogger() + setupTheme() + excludeAllItemsFromBackup() + setupPushRulesSessionEvents() // Setup navigation router store _ = NavigationRouterStore.shared @@ -259,6 +263,47 @@ final class AppCoordinator: NSObject, AppCoordinatorType { // Reload split view with selected space id self.splitViewCoordinator?.start(with: spaceId) } + + private func setupPushRulesSessionEvents() { + let sessionReady = NotificationCenter.default.publisher(for: .mxSessionStateDidChange) + .compactMap { $0.object as? MXSession } + .filter { $0.state == .running } + .removeDuplicates { session1, session2 in + session1 == session2 + } + + sessionReady + .sink { [weak self] session in + self?.setupPushRulesUpdater(session: session) + } + .store(in: &cancellables) + + + let sessionClosed = NotificationCenter.default.publisher(for: .mxSessionStateDidChange) + .compactMap { $0.object as? MXSession } + .filter { $0.state == .closed } + + sessionClosed + .sink { [weak self] _ in + self?.pushRulesUpdater = nil + } + .store(in: &cancellables) + } + + private func setupPushRulesUpdater(session: MXSession) { + pushRulesUpdater = .init(notificationSettingsService: MXNotificationSettingsService(session: session)) + + let applicationDidBecomeActive = NotificationCenter.default.publisher(for: UIApplication.didBecomeActiveNotification).eraseOutput() + let needsCheckPublisher = applicationDidBecomeActive.merge(with: Just(())).eraseToAnyPublisher() + + needsCheckPublisher + .sink { _ in + Task { @MainActor [weak self] in + await self?.pushRulesUpdater?.syncRulesIfNeeded() + } + } + .store(in: &cancellables) + } } // MARK: - LegacyAppDelegateDelegate diff --git a/RiotSwiftUI/Modules/Settings/Notifications/Model/MatrixSDK/MXNotificationPushRule.swift b/RiotSwiftUI/Modules/Settings/Notifications/Model/MatrixSDK/MXNotificationPushRule.swift index 2c4d16d8b4..337ed45168 100644 --- a/RiotSwiftUI/Modules/Settings/Notifications/Model/MatrixSDK/MXNotificationPushRule.swift +++ b/RiotSwiftUI/Modules/Settings/Notifications/Model/MatrixSDK/MXNotificationPushRule.swift @@ -41,6 +41,10 @@ extension MXPushRule: NotificationPushRuleType { return false } + var ruleActions: NotificationActions? { + .init(notify: notify, highlight: highlight, sound: sound) + } + private func getAction(actionType: MXPushRuleActionType, tweakType: String? = nil) -> MXPushRuleAction? { guard let actions = actions as? [MXPushRuleAction] else { return nil diff --git a/RiotSwiftUI/Modules/Settings/Notifications/Model/Mock/MockNotificationPushRule.swift b/RiotSwiftUI/Modules/Settings/Notifications/Model/Mock/MockNotificationPushRule.swift index ab71926463..64aff03887 100644 --- a/RiotSwiftUI/Modules/Settings/Notifications/Model/Mock/MockNotificationPushRule.swift +++ b/RiotSwiftUI/Modules/Settings/Notifications/Model/Mock/MockNotificationPushRule.swift @@ -16,12 +16,12 @@ import Foundation -struct MockNotificationPushRule: NotificationPushRuleType { +struct MockNotificationPushRule: NotificationPushRuleType, Equatable { var ruleId: String! var enabled: Bool - var actions: NotificationActions? = NotificationStandardActions.notifyDefaultSound.actions + var ruleActions: NotificationActions? = NotificationStandardActions.notifyDefaultSound.actions func matches(standardActions: NotificationStandardActions?) -> Bool { - standardActions?.actions == actions + standardActions?.actions == ruleActions } } diff --git a/RiotSwiftUI/Modules/Settings/Notifications/Model/NotificationPushRuleType.swift b/RiotSwiftUI/Modules/Settings/Notifications/Model/NotificationPushRuleType.swift index 1f98242c70..14ed88e69a 100644 --- a/RiotSwiftUI/Modules/Settings/Notifications/Model/NotificationPushRuleType.swift +++ b/RiotSwiftUI/Modules/Settings/Notifications/Model/NotificationPushRuleType.swift @@ -19,5 +19,13 @@ import Foundation protocol NotificationPushRuleType { var ruleId: String! { get } var enabled: Bool { get } + var ruleActions: NotificationActions? { get } + func matches(standardActions: NotificationStandardActions?) -> Bool } + +extension NotificationPushRuleType { + var pushRuleId: NotificationPushRuleId? { + ruleId.flatMap(NotificationPushRuleId.init(rawValue:)) + } +} diff --git a/RiotSwiftUI/Modules/Settings/Notifications/Service/MatrixSDK/MXNotificationSettingsService.swift b/RiotSwiftUI/Modules/Settings/Notifications/Service/MatrixSDK/MXNotificationSettingsService.swift index 6ccd54c1a4..9bf01ef4c0 100644 --- a/RiotSwiftUI/Modules/Settings/Notifications/Service/MatrixSDK/MXNotificationSettingsService.swift +++ b/RiotSwiftUI/Modules/Settings/Notifications/Service/MatrixSDK/MXNotificationSettingsService.swift @@ -89,10 +89,10 @@ class MXNotificationSettingsService: NotificationSettingsServiceType { // Updating the actions before enabling the rule allows the homeserver to triggers just one sync update try await session.notificationCenter.updatePushRuleActions(ruleId, - kind: rule.kind, - notify: actions.notify, - soundName: actions.sound, - highlight: actions.highlight) + kind: rule.kind, + notify: actions.notify, + soundName: actions.sound, + highlight: actions.highlight) try await session.notificationCenter.enableRule(pushRule: rule, isEnabled: enabled) } diff --git a/RiotSwiftUI/Modules/Settings/Notifications/Service/Mock/MockNotificationSettingsService.swift b/RiotSwiftUI/Modules/Settings/Notifications/Service/Mock/MockNotificationSettingsService.swift index ea4bd640c5..0bff31370c 100644 --- a/RiotSwiftUI/Modules/Settings/Notifications/Service/Mock/MockNotificationSettingsService.swift +++ b/RiotSwiftUI/Modules/Settings/Notifications/Service/Mock/MockNotificationSettingsService.swift @@ -49,6 +49,6 @@ class MockNotificationSettingsService: NotificationSettingsServiceType, Observab return } - rules[ruleIndex] = MockNotificationPushRule(ruleId: ruleId, enabled: enabled, actions: actions) + rules[ruleIndex] = MockNotificationPushRule(ruleId: ruleId, enabled: enabled, ruleActions: actions) } } diff --git a/RiotSwiftUI/Modules/Settings/Notifications/ViewModel/NotificationSettingsViewModel.swift b/RiotSwiftUI/Modules/Settings/Notifications/ViewModel/NotificationSettingsViewModel.swift index f782901b1d..154f926cee 100644 --- a/RiotSwiftUI/Modules/Settings/Notifications/ViewModel/NotificationSettingsViewModel.swift +++ b/RiotSwiftUI/Modules/Settings/Notifications/ViewModel/NotificationSettingsViewModel.swift @@ -218,7 +218,7 @@ private extension NotificationSettingsViewModel { for rule in newRules { guard - let ruleId = NotificationPushRuleId(rawValue: rule.ruleId), + let ruleId = rule.pushRuleId, ruleIds.contains(ruleId) else { continue @@ -248,7 +248,7 @@ private extension NotificationSettingsViewModel { /// - Parameter rule: The push rule type to check. /// - Returns: Wether it should be displayed as checked or not checked. func defaultIsChecked(rule: NotificationPushRuleType) -> Bool { - guard let ruleId = NotificationPushRuleId(rawValue: rule.ruleId) else { + guard let ruleId = rule.pushRuleId else { return false } @@ -264,7 +264,7 @@ private extension NotificationSettingsViewModel { } func isChecked(rule: NotificationPushRuleType, syncedRules: [NotificationPushRuleType]) -> Bool { - guard let ruleId = NotificationPushRuleId(rawValue: rule.ruleId) else { + guard let ruleId = rule.pushRuleId else { return false } @@ -280,7 +280,7 @@ private extension NotificationSettingsViewModel { } func isOutOfSync(rule: NotificationPushRuleType, syncedRules: [NotificationPushRuleType]) -> Bool { - guard let ruleId = NotificationPushRuleId(rawValue: rule.ruleId) else { + guard let ruleId = rule.pushRuleId else { return false } @@ -294,10 +294,10 @@ private extension NotificationSettingsViewModel { } } -private extension NotificationPushRuleId { +extension NotificationPushRuleId { func syncedRules(in rules: [NotificationPushRuleType]) -> [NotificationPushRuleType] { rules.filter { - guard let ruleId = NotificationPushRuleId(rawValue: $0.ruleId) else { + guard let ruleId = $0.pushRuleId else { return false } return syncedRules.contains(ruleId) diff --git a/RiotTests/PushRulesUpdaterTests.swift b/RiotTests/PushRulesUpdaterTests.swift new file mode 100644 index 0000000000..1eec9dda58 --- /dev/null +++ b/RiotTests/PushRulesUpdaterTests.swift @@ -0,0 +1,106 @@ +// +// Copyright 2023 New Vector Ltd +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import Combine +import XCTest +@testable import Element + +final class PushRulesUpdaterTests: XCTestCase { + private var notificationService: MockNotificationSettingsService! + private var pushRulesUpdater: PushRulesUpdater! + + override func setUpWithError() throws { + notificationService = .init() + notificationService.rules = [MockNotificationPushRule].default + pushRulesUpdater = .init(notificationSettingsService: notificationService) + } + + func testNoRuleIsUpdated() async throws { + await pushRulesUpdater.syncRulesIfNeeded() + XCTAssertEqual(notificationService.rules as? [MockNotificationPushRule], [MockNotificationPushRule].default) + } + + func testSingleRuleAffected() async throws { + let targetActions: NotificationActions = .init(notify: true, sound: "default") + let targetRuleIndex = try mockRule(ruleId: .pollStart, enabled: false, actions: targetActions) + + await pushRulesUpdater.syncRulesIfNeeded() + + XCTAssertEqual(self.notificationService.rules[targetRuleIndex].ruleActions, NotificationStandardActions.notifyDefaultSound.actions) + XCTAssertTrue(self.notificationService.rules[targetRuleIndex].enabled) + } + + func testAffectedRulesAreUpdated() async throws { + let targetActions: NotificationActions = .init(notify: true, sound: "abc") + try mockRule(ruleId: .allOtherMessages, enabled: true, actions: targetActions) + let affectedRules: [NotificationPushRuleId] = [.allOtherMessages, .pollStart, .msc3930pollStart, .pollEnd, .msc3930pollEnd] + + await pushRulesUpdater.syncRulesIfNeeded() + + for rule in self.notificationService.rules { + guard let id = rule.pushRuleId else { + continue + } + + if affectedRules.contains(id) { + XCTAssertEqual(rule.ruleActions, targetActions) + } else { + XCTAssertEqual(rule.ruleActions, NotificationStandardActions.notifyDefaultSound.actions) + } + } + } + + func testAffectedOneToOneRulesAreUpdated() async throws { + let targetActions: NotificationActions = .init(notify: true, sound: "abc") + try mockRule(ruleId: .oneToOneRoom, enabled: true, actions: targetActions) + let affectedRules: [NotificationPushRuleId] = [.oneToOneRoom, .oneToOnePollStart, .msc3930oneToOnePollStart, .oneToOnePollEnd, .msc3930oneToOnePollEnd] + + await pushRulesUpdater.syncRulesIfNeeded() + + for rule in self.notificationService.rules { + guard let id = rule.pushRuleId else { + continue + } + + if affectedRules.contains(id) { + XCTAssertEqual(rule.ruleActions, targetActions) + } else { + XCTAssertEqual(rule.ruleActions, NotificationStandardActions.notifyDefaultSound.actions) + } + } + } +} + +private extension PushRulesUpdaterTests { + @discardableResult + func mockRule(ruleId: NotificationPushRuleId, enabled: Bool, actions: NotificationActions) throws -> Int { + guard let ruleIndex = notificationService.rules.firstIndex(where: { $0.pushRuleId == ruleId }) else { + throw NSError(domain: "no ruleIndex found", code: 0) + } + notificationService.rules[ruleIndex] = MockNotificationPushRule(ruleId: ruleId.rawValue, enabled: enabled, ruleActions: actions) + return ruleIndex + } +} + +private extension Array where Element == MockNotificationPushRule { + static var `default`: [MockNotificationPushRule] { + let ids: [NotificationPushRuleId] = [.oneToOneRoom, .allOtherMessages, .pollStart, .msc3930pollStart, .pollEnd, .msc3930pollEnd, .oneToOnePollStart, .msc3930oneToOnePollStart, .oneToOnePollEnd, .msc3930oneToOnePollEnd] + + return ids.map { + MockNotificationPushRule(ruleId: $0.rawValue, enabled: true) + } + } +} diff --git a/changelog.d/pr-7335.change b/changelog.d/pr-7335.change new file mode 100644 index 0000000000..62512e9304 --- /dev/null +++ b/changelog.d/pr-7335.change @@ -0,0 +1 @@ +Polls: add automatic synchronization logic for poll push rules.