Add background app refresh support (#892)
* Add background app refresh support * Cleanup: move methods around so delegates are implemented above privates and privates are ordered better * Add discardableResult on the userSessionStore's refreshRestorationToken
This commit is contained in:
@@ -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)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -41,6 +41,7 @@ protocol UserSessionStoreProtocol {
|
||||
func userSession(for client: Client) async -> Result<UserSessionProtocol, UserSessionStoreError>
|
||||
|
||||
/// Refresh the restore token of the client for a given session.
|
||||
@discardableResult
|
||||
func refreshRestorationToken(for userSession: UserSessionProtocol) -> Result<Void, UserSessionStoreError>
|
||||
|
||||
/// Logs out of the specified session.
|
||||
|
||||
@@ -2,6 +2,10 @@
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>BGTaskSchedulerPermittedIdentifiers</key>
|
||||
<array>
|
||||
<string>io.element.elementx.background.refresh</string>
|
||||
</array>
|
||||
<key>CFBundleDevelopmentRegion</key>
|
||||
<string>$(DEVELOPMENT_LANGUAGE)</string>
|
||||
<key>CFBundleDisplayName</key>
|
||||
@@ -30,6 +34,10 @@
|
||||
<array>
|
||||
<string>INSendMessageIntent</string>
|
||||
</array>
|
||||
<key>UIBackgroundModes</key>
|
||||
<array>
|
||||
<string>fetch</string>
|
||||
</array>
|
||||
<key>UILaunchStoryboardName</key>
|
||||
<string>LaunchScreen</string>
|
||||
<key>UISupportedInterfaceOrientations</key>
|
||||
|
||||
@@ -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:
|
||||
|
||||
1
changelog.d/pr-892.feature
Normal file
1
changelog.d/pr-892.feature
Normal file
@@ -0,0 +1 @@
|
||||
Add background app refresh support
|
||||
Reference in New Issue
Block a user