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:
Stefan Ceriu
2023-05-15 13:06:25 +03:00
committed by GitHub
parent 230ce8286f
commit c3b029f1a0
7 changed files with 219 additions and 129 deletions

View File

@@ -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)
})
}
}

View File

@@ -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.

View File

@@ -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 {

View File

@@ -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.

View File

@@ -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>

View File

@@ -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:

View File

@@ -0,0 +1 @@
Add background app refresh support