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

Add background app refresh support #892

Merged
merged 3 commits into from
May 15, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
265 changes: 164 additions & 101 deletions ElementX/Sources/Application/AppCoordinator.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -93,6 +95,8 @@ class AppCoordinator: AppCoordinatorProtocol {

observeApplicationState()
observeNetworkState()

registerBackgroundAppRefresh()
}

func start() {
Expand All @@ -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) {
Expand Down Expand Up @@ -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 }
Expand Down Expand Up @@ -349,6 +410,49 @@ class AppCoordinator: AppCoordinatorProtocol {
}
}

private func observeNetworkState() {
let reachabilityNotificationIdentifier = "io.element.elementx.reachability.notification"
networkMonitorObserver = ServiceLocator.shared.networkMonitor.reachabilityPublisher.sink { reachable in
MXLog.info("Reachability changed to \(reachable)")

if reachable {
ServiceLocator.shared.userIndicatorController.retractIndicatorWithId(reachabilityNotificationIdentifier)
} else {
ServiceLocator.shared.userIndicatorController.submitIndicator(.init(id: reachabilityNotificationIdentifier,
title: L10n.commonOffline,
persistent: true))
}
}
}

private func handleAppRoute(_ appRoute: AppRoute) {
if let userSessionFlowCoordinator {
userSessionFlowCoordinator.handleAppRoute(appRoute, animated: UIApplication.shared.applicationState == .active)
} else {
storedAppRoute = appRoute
}
}

private func clearCache() {
showLoadingIndicator()

navigationRootCoordinator.setRootCoordinator(SplashScreenCoordinator())

userSession.clientProxy.stopSync()
userSessionFlowCoordinator?.stop()

let userID = userSession.userID
tearDownUserSession()

// Allow for everything to deallocate properly
Task {
try await Task.sleep(for: .seconds(2))
userSessionStore.clearCache(for: userID)
stateMachine.processEvent(.startWithExistingSession)
hideLoadingIndicator()
}
}

// MARK: Toasts and loading indicators

private static let loadingIndicatorIdentifier = "AppCoordinatorLoading"
Expand Down Expand Up @@ -384,11 +488,10 @@ class AppCoordinator: AppCoordinatorProtocol {
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()
}
.filter(\.isSyncUpdate)
.sink { [weak self] _ in
ServiceLocator.shared.userIndicatorController.retractIndicatorWithId(identifier)
self?.initialSyncObserver?.cancel()
}
}

Expand All @@ -412,11 +515,17 @@ class AppCoordinator: AppCoordinatorProtocol {
}

backgroundTask = backgroundTaskService.startBackgroundTask(withName: "SuspendApp: \(UUID().uuidString)") { [weak self] in
self?.stopSync()
guard let self else { return }

self?.backgroundTask = nil
self?.isSuspended = true
stopSync()

backgroundTask = nil
isSuspended = true
stefanceriu marked this conversation as resolved.
Show resolved Hide resolved
}

// 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()
}

@objc
Expand All @@ -432,106 +541,60 @@ class AppCoordinator: AppCoordinatorProtocol {
}
}

private func observeNetworkState() {
let reachabilityNotificationIdentifier = "io.element.elementx.reachability.notification"
networkMonitorObserver = ServiceLocator.shared.networkMonitor.reachabilityPublisher.sink { reachable in
MXLog.info("Reachability changed to \(reachable)")

if reachable {
ServiceLocator.shared.userIndicatorController.retractIndicatorWithId(reachabilityNotificationIdentifier)
} else {
ServiceLocator.shared.userIndicatorController.submitIndicator(.init(id: reachabilityNotificationIdentifier,
title: L10n.commonOffline,
persistent: true))
// 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)
}
}

private func handleAppRoute(_ appRoute: AppRoute) {
if let userSessionFlowCoordinator {
userSessionFlowCoordinator.handleAppRoute(appRoute, animated: UIApplication.shared.applicationState == .active)
} else {
storedAppRoute = appRoute
}

MXLog.info("Register background app refresh with result: \(result)")
}

private func clearCache() {
showLoadingIndicator()
private func scheduleBackgroundAppRefresh() {
let request = BGAppRefreshTaskRequest(identifier: ServiceLocator.shared.settings.backgroundAppRefreshTaskIdentifier)

navigationRootCoordinator.setRootCoordinator(SplashScreenCoordinator())
// We have other background tasks that keep the app alive
request.earliestBeginDate = Date(timeIntervalSinceNow: 30)

userSession.clientProxy.stopSync()
userSessionFlowCoordinator?.stop()

let userID = userSession.userID
tearDownUserSession()

// Allow for everything to deallocate properly
Task {
try await Task.sleep(for: .seconds(2))
userSessionStore.clearCache(for: userID)
stateMachine.processEvent(.startWithExistingSession)
hideLoadingIndicator()
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)")
}
}
}

// MARK: - AuthenticationCoordinatorDelegate

extension AppCoordinator: AuthenticationCoordinatorDelegate {
func authenticationCoordinator(_ authenticationCoordinator: AuthenticationCoordinator, didLoginWithSession userSession: UserSessionProtocol) {
self.userSession = userSession
stateMachine.processEvent(.createdUserSession)
}
}

// MARK: - NotificationManagerDelegate

extension AppCoordinator: 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)

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)
})
}
}
3 changes: 3 additions & 0 deletions ElementX/Sources/Application/AppSettings.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

shouldn't we use the InfoPlistReader for this value here? So the string is always tied (don't think we are changing bundle name, but for for forks could be useful)

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I thought about it but with multiple task identifiers in that plist array you wouldn't actually know which one's which

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Wha if we make sure that the plist arrat is always ordered in a certain way?
I guess a comment in the .yml on the expected order could help

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think that's finicky and can lead to pretty bad bugs. Better to be safe than sorry.


// MARK: - Authentication

/// The URL that is opened when tapping the Learn more button on the sliding sync alert during authentication.
Expand Down
8 changes: 8 additions & 0 deletions ElementX/Sources/Services/Client/ClientProxyProtocol.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
8 changes: 8 additions & 0 deletions ElementX/SupportingFiles/Info.plist
Original file line number Diff line number Diff line change
Expand Up @@ -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>
Expand Down Expand Up @@ -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>
Expand Down
Loading