diff --git a/ElementX/Sources/Application/AppCoordinator.swift b/ElementX/Sources/Application/AppCoordinator.swift index 9f2b9e788..6642873d9 100644 --- a/ElementX/Sources/Application/AppCoordinator.swift +++ b/ElementX/Sources/Application/AppCoordinator.swift @@ -14,12 +14,13 @@ // limitations under the License. // +import BackgroundTasks import Combine import MatrixRustSDK import SwiftUI import Version -class AppCoordinator: AppCoordinatorProtocol { +class AppCoordinator: AppCoordinatorProtocol, AuthenticationCoordinatorDelegate, NotificationManagerDelegate { private let stateMachine: AppCoordinatorStateMachine private let navigationRootCoordinator: NavigationRootCoordinator private let userSessionStore: UserSessionStoreProtocol @@ -49,6 +50,7 @@ class AppCoordinator: AppCoordinatorProtocol { private var userSessionObserver: AnyCancellable? private var networkMonitorObserver: AnyCancellable? private var initialSyncObserver: AnyCancellable? + private var backgroundRefreshSyncObserver: AnyCancellable? let notificationManager: NotificationManagerProtocol @@ -93,6 +95,8 @@ class AppCoordinator: AppCoordinatorProtocol { observeApplicationState() observeNetworkState() + + registerBackgroundAppRefresh() } func start() { @@ -111,7 +115,63 @@ class AppCoordinator: AppCoordinatorProtocol { func toPresentable() -> AnyView { ServiceLocator.shared.userIndicatorController.toPresentable() } + + // MARK: - AuthenticationCoordinatorDelegate + + func authenticationCoordinator(_ authenticationCoordinator: AuthenticationCoordinator, didLoginWithSession userSession: UserSessionProtocol) { + self.userSession = userSession + stateMachine.processEvent(.createdUserSession) + } + + // MARK: - NotificationManagerDelegate + + func authorizationStatusUpdated(_ service: NotificationManagerProtocol, granted: Bool) { + if granted { + UIApplication.shared.registerForRemoteNotifications() + } + } + + func shouldDisplayInAppNotification(_ service: NotificationManagerProtocol, content: UNNotificationContent) -> Bool { + guard let roomId = content.userInfo[NotificationConstants.UserInfoKey.roomIdentifier] as? String else { + return true + } + guard let userSessionFlowCoordinator else { + // there is not a user session yet + return false + } + return !userSessionFlowCoordinator.isDisplayingRoomScreen(withRoomId: roomId) + } + + func notificationTapped(_ service: NotificationManagerProtocol, content: UNNotificationContent) async { + MXLog.info("[AppCoordinator] tappedNotification") + guard let roomID = content.roomID, + content.receiverID != nil else { + return + } + + // Handle here the account switching when available + + handleAppRoute(.room(roomID: roomID)) + } + + func handleInlineReply(_ service: NotificationManagerProtocol, content: UNNotificationContent, replyText: String) async { + MXLog.info("[AppCoordinator] handle notification reply") + + guard let roomId = content.userInfo[NotificationConstants.UserInfoKey.roomIdentifier] as? String else { + return + } + let roomProxy = await userSession.clientProxy.roomForIdentifier(roomId) + switch await roomProxy?.sendMessage(replyText) { + case .success: + break + default: + // error or no room proxy + await service.showLocalNotification(with: "⚠️ " + L10n.commonError, + subtitle: L10n.errorSomeMessagesHaveNotBeenSent) + } + } + // MARK: - Private private static func setupServiceLocator(navigationRootCoordinator: NavigationRootCoordinator) { @@ -143,6 +203,7 @@ class AppCoordinator: AppCoordinatorProtocol { userSessionStore.reset() } + // swiftlint:disable:next cyclomatic_complexity private func setupStateMachine() { stateMachine.addTransitionHandler { [weak self] context in guard let self else { return } @@ -349,89 +410,6 @@ class AppCoordinator: AppCoordinatorProtocol { } } - // MARK: Toasts and loading indicators - - private static let loadingIndicatorIdentifier = "AppCoordinatorLoading" - - private func showLoadingIndicator() { - ServiceLocator.shared.userIndicatorController.submitIndicator(UserIndicator(id: Self.loadingIndicatorIdentifier, - type: .modal, - title: L10n.commonLoading, - persistent: true)) - } - - private func hideLoadingIndicator() { - ServiceLocator.shared.userIndicatorController.retractIndicatorWithId(Self.loadingIndicatorIdentifier) - } - - private func showLoginErrorToast() { - ServiceLocator.shared.userIndicatorController.submitIndicator(UserIndicator(title: "Failed logging in")) - } - - // MARK: - Application State - - private func stopSync() { - userSession?.clientProxy.stopSync() - } - - private func startSync() { - userSession?.clientProxy.startSync() - - let identifier = "StaleDataIndicator" - - ServiceLocator.shared.userIndicatorController.submitIndicator(.init(id: identifier, type: .toast, title: L10n.commonLoading, persistent: true)) - - initialSyncObserver = userSession.clientProxy - .callbacks - .receive(on: DispatchQueue.main) - .sink { [weak self] callback in - if case .receivedSyncUpdate = callback { - ServiceLocator.shared.userIndicatorController.retractIndicatorWithId(identifier) - self?.initialSyncObserver?.cancel() - } - } - } - - private func observeApplicationState() { - NotificationCenter.default.addObserver(self, - selector: #selector(applicationWillResignActive), - name: UIApplication.willResignActiveNotification, - object: nil) - NotificationCenter.default.addObserver(self, - selector: #selector(applicationDidBecomeActive), - name: UIApplication.didBecomeActiveNotification, - object: nil) - } - - @objc - private func applicationWillResignActive() { - MXLog.info("Application will resign active") - - guard backgroundTask == nil else { - return - } - - backgroundTask = backgroundTaskService.startBackgroundTask(withName: "SuspendApp: \(UUID().uuidString)") { [weak self] in - self?.stopSync() - - self?.backgroundTask = nil - self?.isSuspended = true - } - } - - @objc - private func applicationDidBecomeActive() { - MXLog.info("Application did become active") - - backgroundTask?.stop() - backgroundTask = nil - - if isSuspended { - isSuspended = false - startSync() - } - } - private func observeNetworkState() { let reachabilityNotificationIdentifier = "io.element.elementx.reachability.notification" networkMonitorObserver = ServiceLocator.shared.networkMonitor.reachabilityPublisher.sink { reachable in @@ -474,64 +452,149 @@ class AppCoordinator: AppCoordinatorProtocol { hideLoadingIndicator() } } -} - -// MARK: - AuthenticationCoordinatorDelegate - -extension AppCoordinator: AuthenticationCoordinatorDelegate { - func authenticationCoordinator(_ authenticationCoordinator: AuthenticationCoordinator, didLoginWithSession userSession: UserSessionProtocol) { - self.userSession = userSession - stateMachine.processEvent(.createdUserSession) + + // MARK: Toasts and loading indicators + + private static let loadingIndicatorIdentifier = "AppCoordinatorLoading" + + private func showLoadingIndicator() { + ServiceLocator.shared.userIndicatorController.submitIndicator(UserIndicator(id: Self.loadingIndicatorIdentifier, + type: .modal, + title: L10n.commonLoading, + persistent: true)) } -} - -// MARK: - NotificationManagerDelegate - -extension AppCoordinator: NotificationManagerDelegate { - func authorizationStatusUpdated(_ service: NotificationManagerProtocol, granted: Bool) { - if granted { - UIApplication.shared.registerForRemoteNotifications() - } + + private func hideLoadingIndicator() { + ServiceLocator.shared.userIndicatorController.retractIndicatorWithId(Self.loadingIndicatorIdentifier) + } + + private func showLoginErrorToast() { + ServiceLocator.shared.userIndicatorController.submitIndicator(UserIndicator(title: "Failed logging in")) } - func shouldDisplayInAppNotification(_ service: NotificationManagerProtocol, content: UNNotificationContent) -> Bool { - guard let roomId = content.userInfo[NotificationConstants.UserInfoKey.roomIdentifier] as? String else { - return true - } - guard let userSessionFlowCoordinator else { - // there is not a user session yet - return false - } - return !userSessionFlowCoordinator.isDisplayingRoomScreen(withRoomId: roomId) + // MARK: - Application State + + private func stopSync() { + userSession?.clientProxy.stopSync() } - func notificationTapped(_ service: NotificationManagerProtocol, content: UNNotificationContent) async { - MXLog.info("[AppCoordinator] tappedNotification") + private func startSync() { + userSession?.clientProxy.startSync() + + let identifier = "StaleDataIndicator" + + ServiceLocator.shared.userIndicatorController.submitIndicator(.init(id: identifier, type: .toast, title: L10n.commonLoading, persistent: true)) + + initialSyncObserver = userSession.clientProxy + .callbacks + .receive(on: DispatchQueue.main) + .filter(\.isSyncUpdate) + .sink { [weak self] _ in + ServiceLocator.shared.userIndicatorController.retractIndicatorWithId(identifier) + self?.initialSyncObserver?.cancel() + } + } - guard let roomID = content.roomID, - content.receiverID != nil else { + private func observeApplicationState() { + NotificationCenter.default.addObserver(self, + selector: #selector(applicationWillResignActive), + name: UIApplication.willResignActiveNotification, + object: nil) + NotificationCenter.default.addObserver(self, + selector: #selector(applicationDidBecomeActive), + name: UIApplication.didBecomeActiveNotification, + object: nil) + } + + @objc + private func applicationWillResignActive() { + MXLog.info("Application will resign active") + + guard backgroundTask == nil else { return } - // Handle here the account switching when available - - handleAppRoute(.room(roomID: roomID)) + backgroundTask = backgroundTaskService.startBackgroundTask(withName: "SuspendApp: \(UUID().uuidString)") { [weak self] in + guard let self else { return } + + stopSync() + + backgroundTask = nil + isSuspended = true + } + + // This does seem to work if scheduled from the background task above + // Schedule it here instead but with an earliest being date of 30 seconds + scheduleBackgroundAppRefresh() } - func handleInlineReply(_ service: NotificationManagerProtocol, content: UNNotificationContent, replyText: String) async { - MXLog.info("[AppCoordinator] handle notification reply") + @objc + private func applicationDidBecomeActive() { + MXLog.info("Application did become active") + + backgroundTask?.stop() + backgroundTask = nil - guard let roomId = content.userInfo[NotificationConstants.UserInfoKey.roomIdentifier] as? String else { - return + if isSuspended { + isSuspended = false + startSync() } - let roomProxy = await userSession.clientProxy.roomForIdentifier(roomId) - switch await roomProxy?.sendMessage(replyText) { - case .success: - break - default: - // error or no room proxy - await service.showLocalNotification(with: "⚠️ " + L10n.commonError, - subtitle: L10n.errorSomeMessagesHaveNotBeenSent) + } + + // MARK: Background app refresh + + private func registerBackgroundAppRefresh() { + let result = BGTaskScheduler.shared.register(forTaskWithIdentifier: ServiceLocator.shared.settings.backgroundAppRefreshTaskIdentifier, using: .main) { [weak self] task in + guard let task = task as? BGAppRefreshTask else { + MXLog.error("Invalid background app refresh configuration") + return + } + + self?.handleBackgroundAppRefresh(task) } + + MXLog.info("Register background app refresh with result: \(result)") + } + + private func scheduleBackgroundAppRefresh() { + let request = BGAppRefreshTaskRequest(identifier: ServiceLocator.shared.settings.backgroundAppRefreshTaskIdentifier) + + // We have other background tasks that keep the app alive + request.earliestBeginDate = Date(timeIntervalSinceNow: 30) + + do { + try BGTaskScheduler.shared.submit(request) + MXLog.info("Successfully scheduled background app refresh task") + } catch { + MXLog.error("Failed scheduling background app refresh with error :\(error)") + } + } + + private func handleBackgroundAppRefresh(_ task: BGAppRefreshTask) { + MXLog.info("Started background app refresh") + + // This is important for the app to keep refreshing in the background + scheduleBackgroundAppRefresh() + + task.expirationHandler = { [weak self] in + MXLog.info("Background app refresh task expired") + self?.stopSync() + task.setTaskCompleted(success: true) + } + + startSync() + + // Be a good citizen, run for a max of 10 SS responses or 10 seconds + // An SS request will time out after 30 seconds if no new data is available + backgroundRefreshSyncObserver = userSession?.clientProxy + .callbacks + .filter(\.isSyncUpdate) + .collect(.byTimeOrCount(DispatchQueue.main, .seconds(10), 10)) + .sink(receiveValue: { [weak self] _ in + MXLog.info("Background app refresh finished") + self?.backgroundRefreshSyncObserver?.cancel() + self?.stopSync() + task.setTaskCompleted(success: true) + }) } } diff --git a/ElementX/Sources/Application/AppSettings.swift b/ElementX/Sources/Application/AppSettings.swift index eb4098ad5..acdd4e40b 100644 --- a/ElementX/Sources/Application/AppSettings.swift +++ b/ElementX/Sources/Application/AppSettings.swift @@ -84,6 +84,9 @@ final class AppSettings { /// that don't yet have an officially trusted proxy configured in their well-known. let slidingSyncProxyURL: URL? = nil + /// The task identifier used for background app refresh. Also used in main target's the Info.plist + let backgroundAppRefreshTaskIdentifier = "io.element.elementx.background.refresh" + // MARK: - Authentication /// The URL that is opened when tapping the Learn more button on the sliding sync alert during authentication. diff --git a/ElementX/Sources/Services/Client/ClientProxyProtocol.swift b/ElementX/Sources/Services/Client/ClientProxyProtocol.swift index 32d55a3ea..6cc2238ee 100644 --- a/ElementX/Sources/Services/Client/ClientProxyProtocol.swift +++ b/ElementX/Sources/Services/Client/ClientProxyProtocol.swift @@ -23,6 +23,14 @@ enum ClientProxyCallback { case receivedAuthError(isSoftLogout: Bool) case receivedNotification(NotificationItemProxyProtocol) case updateRestorationToken + + var isSyncUpdate: Bool { + if case .receivedSyncUpdate = self { + return true + } else { + return false + } + } } enum ClientProxyError: Error { diff --git a/ElementX/Sources/Services/UserSession/UserSessionStoreProtocol.swift b/ElementX/Sources/Services/UserSession/UserSessionStoreProtocol.swift index 15aed892a..ad229843f 100644 --- a/ElementX/Sources/Services/UserSession/UserSessionStoreProtocol.swift +++ b/ElementX/Sources/Services/UserSession/UserSessionStoreProtocol.swift @@ -41,6 +41,7 @@ protocol UserSessionStoreProtocol { func userSession(for client: Client) async -> Result /// Refresh the restore token of the client for a given session. + @discardableResult func refreshRestorationToken(for userSession: UserSessionProtocol) -> Result /// Logs out of the specified session. diff --git a/ElementX/SupportingFiles/Info.plist b/ElementX/SupportingFiles/Info.plist index eef6537f7..4d1683792 100644 --- a/ElementX/SupportingFiles/Info.plist +++ b/ElementX/SupportingFiles/Info.plist @@ -2,6 +2,10 @@ + BGTaskSchedulerPermittedIdentifiers + + io.element.elementx.background.refresh + CFBundleDevelopmentRegion $(DEVELOPMENT_LANGUAGE) CFBundleDisplayName @@ -30,6 +34,10 @@ INSendMessageIntent + UIBackgroundModes + + fetch + UILaunchStoryboardName LaunchScreen UISupportedInterfaceOrientations diff --git a/ElementX/SupportingFiles/target.yml b/ElementX/SupportingFiles/target.yml index a79944099..2623c95b2 100644 --- a/ElementX/SupportingFiles/target.yml +++ b/ElementX/SupportingFiles/target.yml @@ -64,6 +64,12 @@ targets: ] NSCameraUsageDescription: The camera is used to take and upload photos and videos. NSMicrophoneUsageDescription: The microphone is used to take videos. + UIBackgroundModes: [ + fetch + ] + BGTaskSchedulerPermittedIdentifiers: [ + io.element.elementx.background.refresh + ] settings: diff --git a/changelog.d/pr-892.feature b/changelog.d/pr-892.feature new file mode 100644 index 000000000..dda672f18 --- /dev/null +++ b/changelog.d/pr-892.feature @@ -0,0 +1 @@ +Add background app refresh support \ No newline at end of file