Notifications are now handled when the app is in a killed state (#801)

* WIP, added support for receiver id and managed a way to store the app router state

* WIP, added support for receiver id and managed a way to store the app router state

* Notification Manager is now becoming the UNUserNotificationDelegate ASAP

* code improvements + changelog

* fixed Unit Tests

* pr suggestions
This commit is contained in:
Mauro
2023-04-14 14:24:48 +02:00
committed by GitHub
parent 41994baa4f
commit 3d0d883637
19 changed files with 338 additions and 89 deletions
@@ -47,7 +47,9 @@ class AppCoordinator: AppCoordinatorProtocol {
private var userSessionCancellables = Set<AnyCancellable>()
private var cancellables = Set<AnyCancellable>()
private(set) var notificationManager: NotificationManagerProtocol?
let notificationManager: NotificationManagerProtocol
@Consumable private var storedAppRoute: AppRoute?
init() {
navigationRootCoordinator = NavigationRootCoordinator()
@@ -66,6 +68,10 @@ class AppCoordinator: AppCoordinatorProtocol {
}
userSessionStore = UserSessionStore(backgroundTaskService: backgroundTaskService)
notificationManager = NotificationManager()
notificationManager.delegate = self
notificationManager.start()
guard let currentVersion = Version(InfoPlistReader(bundle: .main).bundleShortVersionString) else {
fatalError("The app's version number **must** use semver for migration purposes.")
@@ -259,6 +265,10 @@ class AppCoordinator: AppCoordinatorProtocol {
self.userSessionFlowCoordinator = userSessionFlowCoordinator
navigationRootCoordinator.setRootCoordinator(navigationSplitCoordinator)
if let storedAppRoute {
userSessionFlowCoordinator.handleAppRoute(storedAppRoute)
}
}
private func logout(isSoft: Bool) {
@@ -292,11 +302,10 @@ class AppCoordinator: AppCoordinatorProtocol {
userSession = nil
userSessionFlowCoordinator = nil
notificationManager?.delegate = nil
notificationManager = nil
}
notificationManager.setClientProxy(nil)
}
private func presentSplashScreen(isSoftLogout: Bool = false) {
navigationRootCoordinator.setRootCoordinator(SplashScreenCoordinator())
@@ -308,34 +317,23 @@ class AppCoordinator: AppCoordinatorProtocol {
}
private func configureNotificationManager() {
guard ServiceLocator.shared.settings.enableNotifications else {
return
}
guard notificationManager == nil else {
return
}
notificationManager.setClientProxy(userSession.clientProxy)
notificationManager.requestAuthorization()
let manager = NotificationManager(clientProxy: userSession.clientProxy)
if manager.isAvailable {
manager.delegate = self
notificationManager = manager
manager.start()
if let appDelegate = AppDelegate.shared {
appDelegate.callbacks
.receive(on: DispatchQueue.main)
.sink { [weak self] callback in
switch callback {
case .registeredNotifications(let deviceToken):
Task { await self?.notificationManager?.register(with: deviceToken) }
case .failedToRegisteredNotifications(let error):
self?.notificationManager?.registrationFailed(with: error)
}
if let appDelegate = AppDelegate.shared {
appDelegate.callbacks
.receive(on: DispatchQueue.main)
.sink { [weak self] callback in
switch callback {
case .registeredNotifications(let deviceToken):
Task { await self?.notificationManager.register(with: deviceToken) }
case .failedToRegisteredNotifications(let error):
self?.notificationManager.registrationFailed(with: error)
}
.store(in: &cancellables)
} else {
MXLog.error("Couldn't register to AppDelegate callbacks")
}
}
.store(in: &cancellables)
} else {
MXLog.error("Couldn't register to AppDelegate callbacks")
}
}
@@ -437,6 +435,14 @@ class AppCoordinator: AppCoordinatorProtocol {
}
}.store(in: &cancellables)
}
private func handleAppRoute(_ appRoute: AppRoute) {
if let userSessionFlowCoordinator {
userSessionFlowCoordinator.handleAppRoute(appRoute)
} else {
storedAppRoute = appRoute
}
}
}
// MARK: - AuthenticationCoordinatorDelegate
@@ -472,11 +478,14 @@ extension AppCoordinator: NotificationManagerDelegate {
MXLog.info("[AppCoordinator] tappedNotification")
// We store the room identifier into the thread identifier
guard !content.threadIdentifier.isEmpty else {
guard !content.threadIdentifier.isEmpty,
content.receiverID != nil else {
return
}
userSessionFlowCoordinator?.handleAppRoute(.room(roomID: content.threadIdentifier))
// Handle here the account switching when available
handleAppRoute(.room(roomID: content.threadIdentifier))
}
func handleInlineReply(_ service: NotificationManagerProtocol, content: UNNotificationContent, replyText: String) async {
@@ -17,5 +17,5 @@
import Foundation
protocol AppCoordinatorProtocol: CoordinatorProtocol {
var notificationManager: NotificationManagerProtocol? { get }
var notificationManager: NotificationManagerProtocol { get }
}
@@ -94,9 +94,7 @@ final class AppSettings: ObservableObject {
}
let pushGatewayBaseURL = URL(staticString: "https://matrix.org/_matrix/push/v1/notify")
let enableNotifications = true
// MARK: - Bug report
let bugReportServiceBaseURL = URL(staticString: "https://riot.im/bugreports")
@@ -16,6 +16,40 @@
import Foundation
import URLRouting
enum AppRoute {
case room(roomID: String)
}
struct AppRouterManager {
private let deeplinkRouter = OneOf {
Route(.case(AppRoute.room(roomID:))) {
// Check with product if this is the expect path
Path { "room" }
Query {
Field("id") { Parse(.string) }
}
}
}
private let permalinkRouter = OneOf {
Route(.case(AppRoute.room(roomID:))) {
Host("matrix.to")
Path {
"#"
Parse(.string)
}
}
}
func route(from url: URL) -> AppRoute? {
var route: AppRoute?
if let deeplinkRoute = try? deeplinkRouter.match(url: url) {
route = deeplinkRoute
} else if let permalinkRoute = try? permalinkRouter.match(url: url) {
route = permalinkRoute
}
return route
}
}
@@ -50,6 +50,103 @@ class BugReportServiceMock: BugReportServiceProtocol {
}
}
}
class NotificationManagerMock: NotificationManagerProtocol {
var delegate: NotificationManagerDelegate?
//MARK: - start
var startCallsCount = 0
var startCalled: Bool {
return startCallsCount > 0
}
var startClosure: (() -> Void)?
func start() {
startCallsCount += 1
startClosure?()
}
//MARK: - register
var registerWithCallsCount = 0
var registerWithCalled: Bool {
return registerWithCallsCount > 0
}
var registerWithReceivedDeviceToken: Data?
var registerWithReceivedInvocations: [Data] = []
var registerWithReturnValue: Bool!
var registerWithClosure: ((Data) async -> Bool)?
func register(with deviceToken: Data) async -> Bool {
registerWithCallsCount += 1
registerWithReceivedDeviceToken = deviceToken
registerWithReceivedInvocations.append(deviceToken)
if let registerWithClosure = registerWithClosure {
return await registerWithClosure(deviceToken)
} else {
return registerWithReturnValue
}
}
//MARK: - registrationFailed
var registrationFailedWithCallsCount = 0
var registrationFailedWithCalled: Bool {
return registrationFailedWithCallsCount > 0
}
var registrationFailedWithReceivedError: Error?
var registrationFailedWithReceivedInvocations: [Error] = []
var registrationFailedWithClosure: ((Error) -> Void)?
func registrationFailed(with error: Error) {
registrationFailedWithCallsCount += 1
registrationFailedWithReceivedError = error
registrationFailedWithReceivedInvocations.append(error)
registrationFailedWithClosure?(error)
}
//MARK: - showLocalNotification
var showLocalNotificationWithSubtitleCallsCount = 0
var showLocalNotificationWithSubtitleCalled: Bool {
return showLocalNotificationWithSubtitleCallsCount > 0
}
var showLocalNotificationWithSubtitleReceivedArguments: (title: String, subtitle: String?)?
var showLocalNotificationWithSubtitleReceivedInvocations: [(title: String, subtitle: String?)] = []
var showLocalNotificationWithSubtitleClosure: ((String, String?) async -> Void)?
func showLocalNotification(with title: String, subtitle: String?) async {
showLocalNotificationWithSubtitleCallsCount += 1
showLocalNotificationWithSubtitleReceivedArguments = (title: title, subtitle: subtitle)
showLocalNotificationWithSubtitleReceivedInvocations.append((title: title, subtitle: subtitle))
await showLocalNotificationWithSubtitleClosure?(title, subtitle)
}
//MARK: - setClientProxy
var setClientProxyCallsCount = 0
var setClientProxyCalled: Bool {
return setClientProxyCallsCount > 0
}
var setClientProxyReceivedClientProxy: ClientProxyProtocol?
var setClientProxyReceivedInvocations: [ClientProxyProtocol?] = []
var setClientProxyClosure: ((ClientProxyProtocol?) -> Void)?
func setClientProxy(_ clientProxy: ClientProxyProtocol?) {
setClientProxyCallsCount += 1
setClientProxyReceivedClientProxy = clientProxy
setClientProxyReceivedInvocations.append(clientProxy)
setClientProxyClosure?(clientProxy)
}
//MARK: - requestAuthorization
var requestAuthorizationCallsCount = 0
var requestAuthorizationCalled: Bool {
return requestAuthorizationCallsCount > 0
}
var requestAuthorizationClosure: (() -> Void)?
func requestAuthorization() {
requestAuthorizationCallsCount += 1
requestAuthorizationClosure?()
}
}
class RoomMemberProxyMock: RoomMemberProxyProtocol {
var userID: String {
get { return underlyingUserID }
+37
View File
@@ -0,0 +1,37 @@
//
// 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 Foundation
@propertyWrapper struct Consumable<Value> {
var wrappedValue: Value? {
mutating get {
defer {
value = nil
}
return value
}
set {
value = newValue
}
}
private var value: Value?
init(value: Value? = nil) {
self.value = value
}
}
@@ -1,5 +1,5 @@
//
// Copyright 2022 New Vector Ltd
// 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.
@@ -15,23 +15,21 @@
//
import Foundation
import UserNotifications
class MockNotificationManager: NotificationManagerProtocol {
// MARK: NotificationManagerProtocol
var isAvailable: Bool {
false
extension UNNotificationContent {
@objc var receiverID: String? {
userInfo[NotificationConstants.UserInfoKey.receiverIdentifier] as? String
}
}
extension UNMutableNotificationContent {
override var receiverID: String? {
get {
userInfo[NotificationConstants.UserInfoKey.receiverIdentifier] as? String
}
set {
userInfo[NotificationConstants.UserInfoKey.receiverIdentifier] = newValue
}
}
weak var delegate: NotificationManagerDelegate?
func start() {
delegate?.authorizationStatusUpdated(self, granted: false)
}
func register(with deviceToken: Data) async -> Bool { false }
func registrationFailed(with error: Error) { }
func showLocalNotification(with title: String, subtitle: String?) { }
}
@@ -20,10 +20,9 @@ import UserNotifications
class NotificationManager: NSObject, NotificationManagerProtocol {
private let notificationCenter: UserNotificationCenterProtocol
private let clientProxy: ClientProxyProtocol
private var clientProxy: ClientProxyProtocol?
init(clientProxy: ClientProxyProtocol, notificationCenter: UserNotificationCenterProtocol = UNUserNotificationCenter.current()) {
self.clientProxy = clientProxy
init(notificationCenter: UserNotificationCenterProtocol = UNUserNotificationCenter.current()) {
self.notificationCenter = notificationCenter
super.init()
}
@@ -32,10 +31,6 @@ class NotificationManager: NSObject, NotificationManagerProtocol {
weak var delegate: NotificationManagerDelegate?
var isAvailable: Bool {
true
}
func start() {
let replyAction = UNTextInputNotificationAction(identifier: NotificationConstants.Action.inlineReply,
title: L10n.actionQuickReply,
@@ -46,6 +41,9 @@ class NotificationManager: NSObject, NotificationManagerProtocol {
options: [])
notificationCenter.setNotificationCategories([replyCategory])
notificationCenter.delegate = self
}
func requestAuthorization() {
Task {
do {
let granted = try await notificationCenter.requestAuthorization(options: [.alert, .sound, .badge])
@@ -60,7 +58,14 @@ class NotificationManager: NSObject, NotificationManagerProtocol {
}
func register(with deviceToken: Data) async -> Bool {
await setPusher(with: deviceToken, clientProxy: clientProxy)
guard let clientProxy else {
return false
}
return await setPusher(with: deviceToken, clientProxy: clientProxy)
}
func setClientProxy(_ clientProxy: ClientProxyProtocol?) {
self.clientProxy = clientProxy
}
func registrationFailed(with error: Error) { }
@@ -31,12 +31,14 @@ protocol NotificationManagerDelegate: AnyObject {
// MARK: - NotificationManagerProtocol
protocol NotificationManagerProtocol {
var isAvailable: Bool { get }
// sourcery: AutoMockable
protocol NotificationManagerProtocol: AnyObject {
var delegate: NotificationManagerDelegate? { get set }
func start()
func register(with deviceToken: Data) async -> Bool
func registrationFailed(with error: Error)
func showLocalNotification(with title: String, subtitle: String?) async
func setClientProxy(_ clientProxy: ClientProxyProtocol?)
func requestAuthorization()
}
@@ -22,6 +22,7 @@ enum NotificationConstants {
static let eventIdentifier = "event_id"
static let unreadCount = "unread_count"
static let pusherNotificationClientIdentifier = "pusher_notification_client_identifier"
static let receiverIdentifier = "receiver_id"
}
enum Category {
@@ -20,7 +20,7 @@ import UIKit
class UITestsAppCoordinator: AppCoordinatorProtocol {
private let navigationRootCoordinator: NavigationRootCoordinator
private var mockScreen: MockScreen?
var notificationManager: NotificationManagerProtocol?
let notificationManager: NotificationManagerProtocol = NotificationManagerMock()
init() {
UIView.setAnimationsEnabled(false)