Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Automatic poll rules sync (PSG-1135) #7335

Merged
merged 17 commits into from
Feb 3, 2023
Merged
6 changes: 6 additions & 0 deletions Riot/Categories/Publisher+Riot.swift
Original file line number Diff line number Diff line change
Expand Up @@ -33,4 +33,10 @@ extension Publisher {
Just($0).delay(for: .seconds(spacingDelay), scheduler: scheduler)
}
}

func eraseOutput() -> AnyPublisher<Void, Failure> {
self
.map { _ in () }
.eraseToAnyPublisher()
}
}
62 changes: 62 additions & 0 deletions Riot/Managers/PushRulesUpdater/PushRulesUpdater.swift
Original file line number Diff line number Diff line change
@@ -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<AnyCancellable> = .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
}
}
51 changes: 48 additions & 3 deletions Riot/Modules/Application/AppCoordinator.swift
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
limitations under the License.
*/

import Combine
import Foundation
import Intents
import MatrixSDK
Expand Down Expand Up @@ -60,6 +61,8 @@ final class AppCoordinator: NSObject, AppCoordinatorType {
}

private var currentSpaceId: String?
private var cancellables: Set<AnyCancellable> = .init()
private var pushRulesUpdater: PushRulesUpdater?

// MARK: Public

Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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:))
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
}

Expand All @@ -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
}

Expand All @@ -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
}

Expand All @@ -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)
Expand Down
106 changes: 106 additions & 0 deletions RiotTests/PushRulesUpdaterTests.swift
Original file line number Diff line number Diff line change
@@ -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)
}
}
}
1 change: 1 addition & 0 deletions changelog.d/pr-7335.change
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Polls: add automatic synchronization logic for poll push rules.