diff --git a/ElementX/Sources/Application/AppCoordinator.swift b/ElementX/Sources/Application/AppCoordinator.swift index af8518466..4fae5b962 100644 --- a/ElementX/Sources/Application/AppCoordinator.swift +++ b/ElementX/Sources/Application/AppCoordinator.swift @@ -22,6 +22,14 @@ class AppCoordinator: AppCoordinatorProtocol { private let stateMachine: AppCoordinatorStateMachine private let navigationController: NavigationController private let userSessionStore: UserSessionStoreProtocol + /// Common background task to resume long-running tasks in the background. + /// When this task expiring, we'll try to suspend the state machine by `suspend` event. + private var backgroundTask: BackgroundTaskProtocol? + private var isSuspended = false { + didSet { + MXLog.debug("didSet to: \(isSuspended)") + } + } private var userSession: UserSessionProtocol! { didSet { @@ -52,7 +60,9 @@ class AppCoordinator: AppCoordinatorProtocol { ServiceLocator.shared.register(userNotificationController: UserNotificationController(rootCoordinator: navigationController)) - backgroundTaskService = UIKitBackgroundTaskService(withApplication: UIApplication.shared) + backgroundTaskService = UIKitBackgroundTaskService { + UIApplication.shared + } userSessionStore = UserSessionStore(backgroundTaskService: backgroundTaskService) @@ -61,6 +71,8 @@ class AppCoordinator: AppCoordinatorProtocol { setupLogging() Bundle.elementFallbackLanguage = "en" + + startObservingApplicationState() // Benchmark.trackingEnabled = true } @@ -324,6 +336,52 @@ class AppCoordinator: AppCoordinatorProtocol { private func showLoginErrorToast() { ServiceLocator.shared.userNotificationController.submitNotification(UserNotification(title: "Failed logging in")) } + + // MARK: - Application State + + private func pause() { + userSession?.clientProxy.stopSync() + } + + private func resume() { + userSession?.clientProxy.startSync() + } + + private func startObservingApplicationState() { + 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() { + guard backgroundTask == nil else { + return + } + + backgroundTask = backgroundTaskService.startBackgroundTask(withName: "SuspendApp: \(UUID().uuidString)") { [weak self] in + self?.pause() + + self?.backgroundTask = nil + self?.isSuspended = true + } + } + + @objc + private func applicationDidBecomeActive() { + backgroundTask?.stop() + backgroundTask = nil + + if isSuspended { + isSuspended = false + resume() + } + } } // MARK: - AuthenticationCoordinatorDelegate diff --git a/ElementX/Sources/Application/AppCoordinatorStateMachine.swift b/ElementX/Sources/Application/AppCoordinatorStateMachine.swift index a11f8a1f2..dc8afb0e2 100644 --- a/ElementX/Sources/Application/AppCoordinatorStateMachine.swift +++ b/ElementX/Sources/Application/AppCoordinatorStateMachine.swift @@ -62,27 +62,38 @@ class AppCoordinatorStateMachine { private let stateMachine: StateMachine init() { - stateMachine = StateMachine(state: .initial) { machine in - machine.addRoutes(event: .startWithAuthentication, transitions: [.initial => .signedOut]) - machine.addRoutes(event: .succeededSigningIn, transitions: [.signedOut => .signedIn]) - - machine.addRoutes(event: .startWithExistingSession, transitions: [.initial => .restoringSession]) - machine.addRoutes(event: .succeededRestoringSession, transitions: [.restoringSession => .signedIn]) - machine.addRoutes(event: .failedRestoringSession, transitions: [.restoringSession => .signedOut]) - - machine.addRoutes(event: .signOut, transitions: [.any => .signingOut]) - machine.addRoutes(event: .completedSigningOut, transitions: [.signingOut => .signedOut]) - - // Transitions with associated values need to be handled through `addRouteMapping` - machine.addRouteMapping { event, fromState, _ in - switch (event, fromState) { - case (.remoteSignOut(let isSoft), _): - return .remoteSigningOut(isSoft: isSoft) - case (.completedSigningOut, .remoteSigningOut): - return .signedOut - default: - return nil - } + stateMachine = StateMachine(state: .initial) + configure() + } + + private func configure() { + stateMachine.addRoutes(event: .startWithAuthentication, transitions: [.initial => .signedOut]) + stateMachine.addRoutes(event: .succeededSigningIn, transitions: [.signedOut => .signedIn]) + + stateMachine.addRoutes(event: .startWithExistingSession, transitions: [.initial => .restoringSession]) + stateMachine.addRoutes(event: .succeededRestoringSession, transitions: [.restoringSession => .signedIn]) + stateMachine.addRoutes(event: .failedRestoringSession, transitions: [.restoringSession => .signedOut]) + + stateMachine.addRoutes(event: .signOut, transitions: [.any => .signingOut]) + stateMachine.addRoutes(event: .completedSigningOut, transitions: [.signingOut => .signedOut]) + + // Transitions with associated values need to be handled through `addRouteMapping` + stateMachine.addRouteMapping { event, fromState, _ in + switch (event, fromState) { + case (.remoteSignOut(let isSoft), _): + return .remoteSigningOut(isSoft: isSoft) + case (.completedSigningOut, .remoteSigningOut): + return .signedOut + default: + return nil + } + } + + addTransitionHandler { context in + if let event = context.event { + MXLog.info("Transitioning from `\(context.fromState)` to `\(context.toState)` with event `\(event)`") + } else { + MXLog.info("Transitioning from \(context.fromState)` to `\(context.toState)`") } } } diff --git a/ElementX/Sources/Screens/Other/ActivityCoordinator.swift b/ElementX/Sources/Screens/Other/ActivityCoordinator.swift index 849259bad..0c01e2de4 100644 --- a/ElementX/Sources/Screens/Other/ActivityCoordinator.swift +++ b/ElementX/Sources/Screens/Other/ActivityCoordinator.swift @@ -20,7 +20,7 @@ struct ActivityCoordinator: CoordinatorProtocol { let items: [Any] func toPresentable() -> AnyView { - return AnyView(UIActivityViewControllerWrapper(activityItems: items) + AnyView(UIActivityViewControllerWrapper(activityItems: items) .presentationDetents([.medium]) .ignoresSafeArea()) } diff --git a/ElementX/Sources/Screens/VideoPlayer/VideoPlayerCoordinator.swift b/ElementX/Sources/Screens/VideoPlayer/VideoPlayerCoordinator.swift index 4374410c5..26b71f623 100644 --- a/ElementX/Sources/Screens/VideoPlayer/VideoPlayerCoordinator.swift +++ b/ElementX/Sources/Screens/VideoPlayer/VideoPlayerCoordinator.swift @@ -36,6 +36,7 @@ final class VideoPlayerCoordinator: CoordinatorProtocol { self.parameters = parameters viewModel = VideoPlayerViewModel(videoURL: parameters.videoURL, + autoplay: UIApplication.shared.applicationState == .active, isModallyPresented: parameters.isModallyPresented) } @@ -67,8 +68,7 @@ final class VideoPlayerCoordinator: CoordinatorProtocol { private func configureAudioSession(_ session: AVAudioSession) { do { try session.setCategory(.playback, - mode: .default, - options: [.allowBluetooth, .allowBluetoothA2DP]) + mode: .default) try session.setActive(true) } catch { MXLog.debug("Configure audio session failed: \(error)") diff --git a/ElementX/Sources/Services/Background/UIKitBackgroundTask.swift b/ElementX/Sources/Services/Background/UIKitBackgroundTask.swift index 14f99ad4b..8ef3cb293 100644 --- a/ElementX/Sources/Services/Background/UIKitBackgroundTask.swift +++ b/ElementX/Sources/Services/Background/UIKitBackgroundTask.swift @@ -53,6 +53,7 @@ class UIKitBackgroundTask: BackgroundTaskProtocol { // attempt to start identifier = application.beginBackgroundTask(withName: name) { [weak self] in guard let self else { return } + self.stop() self.expirationHandler?(self) } diff --git a/ElementX/Sources/Services/Background/UIKitBackgroundTaskService.swift b/ElementX/Sources/Services/Background/UIKitBackgroundTaskService.swift index 0fae91954..c4ff2e400 100644 --- a/ElementX/Sources/Services/Background/UIKitBackgroundTaskService.swift +++ b/ElementX/Sources/Services/Background/UIKitBackgroundTaskService.swift @@ -19,13 +19,17 @@ import UIKit /// /// UIKitBackgroundTaskService is a concrete implementation of BackgroundTaskServiceProtocol using a given `ApplicationProtocol` instance. class UIKitBackgroundTaskService: BackgroundTaskServiceProtocol { - private let application: ApplicationProtocol? + private let applicationBlock: () -> ApplicationProtocol? private var reusableTasks: WeakDictionary = WeakDictionary() + private var application: ApplicationProtocol? { + applicationBlock() + } + /// Initializer - /// - Parameter application: application instance to use. Defaults to `UIApplication.extensionSafeShared`. - init(withApplication application: ApplicationProtocol? = UIApplication.extensionSafeShared) { - self.application = application + /// - Parameter applicationBlock: block returning the application instance to use. Defaults to a block returning `UIApplication.extensionSafeShared`. + init(withApplicationBlock applicationBlock: @escaping () -> ApplicationProtocol? = { UIApplication.extensionSafeShared }) { + self.applicationBlock = applicationBlock } func startBackgroundTask(withName name: String, diff --git a/ElementX/Sources/Services/UserSession/UserSessionFlowCoordinator.swift b/ElementX/Sources/Services/UserSession/UserSessionFlowCoordinator.swift index ec3dc614e..c2751cce9 100644 --- a/ElementX/Sources/Services/UserSession/UserSessionFlowCoordinator.swift +++ b/ElementX/Sources/Services/UserSession/UserSessionFlowCoordinator.swift @@ -36,7 +36,6 @@ class UserSessionFlowCoordinator: CoordinatorProtocol { self.bugReportService = bugReportService setupStateMachine() - startObservingApplicationState() } func start() { @@ -84,11 +83,6 @@ class UserSessionFlowCoordinator: CoordinatorProtocol { case (.feedbackScreen, .dismissedFeedbackScreen, .homeScreen): break - case (_, .resignActive, .suspended): - self.pause() - case (_, .becomeActive, _): - self.resume() - default: fatalError("Unknown transition: \(context)") } @@ -98,17 +92,6 @@ class UserSessionFlowCoordinator: CoordinatorProtocol { fatalError("Failed transition with context: \(context)") } } - - private func startObservingApplicationState() { - NotificationCenter.default.addObserver(self, - selector: #selector(applicationWillResignActive), - name: UIApplication.willResignActiveNotification, - object: nil) - NotificationCenter.default.addObserver(self, - selector: #selector(applicationDidBecomeActive), - name: UIApplication.didBecomeActiveNotification, - object: nil) - } private func presentHomeScreen() { userSession.clientProxy.startSync() @@ -247,24 +230,4 @@ class UserSessionFlowCoordinator: CoordinatorProtocol { self?.stateMachine.processEvent(.dismissedFeedbackScreen) } } - - // MARK: - Application State - - private func pause() { - userSession.clientProxy.stopSync() - } - - private func resume() { - userSession.clientProxy.startSync() - } - - @objc - private func applicationWillResignActive() { - stateMachine.processEvent(.resignActive) - } - - @objc - private func applicationDidBecomeActive() { - stateMachine.processEvent(.becomeActive) - } } diff --git a/ElementX/Sources/Services/UserSession/UserSessionFlowCoordinatorStateMachine.swift b/ElementX/Sources/Services/UserSession/UserSessionFlowCoordinatorStateMachine.swift index bbea9f7be..e7253e09a 100644 --- a/ElementX/Sources/Services/UserSession/UserSessionFlowCoordinatorStateMachine.swift +++ b/ElementX/Sources/Services/UserSession/UserSessionFlowCoordinatorStateMachine.swift @@ -38,9 +38,6 @@ class UserSessionFlowCoordinatorStateMachine { /// Showing the settings screen case settingsScreen - - /// Application has been suspended - case suspended } /// Events that can be triggered on the AppCoordinator state machine @@ -68,22 +65,15 @@ class UserSessionFlowCoordinatorStateMachine { case showSessionVerificationScreen /// Session verification has finished case dismissedSessionVerificationScreen - - /// Application goes into inactive state - case resignActive - /// Application goes into active state - case becomeActive } private let stateMachine: StateMachine - private var stateBeforeSuspension: State? init() { stateMachine = StateMachine(state: .initial) configure() } - // swiftlint:disable:next cyclomatic_complexity private func configure() { stateMachine.addRoutes(event: .start, transitions: [.initial => .homeScreen]) @@ -109,18 +99,6 @@ class UserSessionFlowCoordinatorStateMachine { case (.dismissedSessionVerificationScreen, .sessionVerificationScreen): return .homeScreen - case (.resignActive, _): - self.stateBeforeSuspension = fromState - return .suspended - case (.becomeActive, _): - // Cannot become active if not previously suspended - // Happens when the app is backgrounded before the session is setup - guard let previousState = self.stateBeforeSuspension else { - return self.stateMachine.state - } - - return previousState - default: return nil } diff --git a/UnitTests/Sources/BackgroundTaskTests.swift b/UnitTests/Sources/BackgroundTaskTests.swift index 073bf0773..b28d8bbb4 100644 --- a/UnitTests/Sources/BackgroundTaskTests.swift +++ b/UnitTests/Sources/BackgroundTaskTests.swift @@ -25,14 +25,18 @@ class BackgroundTaskTests: XCTestCase { } func testInAnExtension() { - let service = UIKitBackgroundTaskService(withApplication: nil) + let service = UIKitBackgroundTaskService { + nil + } let task = service.startBackgroundTask(withName: Constants.bgTaskName) XCTAssertNil(task, "Task should not be created") } func testInitAndStop() { - let service = UIKitBackgroundTaskService(withApplication: UIApplication.mockHealty) + let service = UIKitBackgroundTaskService { + UIApplication.mockHealty + } guard let task = service.startBackgroundTask(withName: Constants.bgTaskName) else { XCTFail("Failed to setup test conditions") return @@ -48,7 +52,9 @@ class BackgroundTaskTests: XCTestCase { } func testNotReusableInit() { - let service = UIKitBackgroundTaskService(withApplication: UIApplication.mockHealty) + let service = UIKitBackgroundTaskService { + UIApplication.mockHealty + } // create two not reusable task with the same name guard let task1 = service.startBackgroundTask(withName: Constants.bgTaskName), @@ -63,7 +69,9 @@ class BackgroundTaskTests: XCTestCase { } func testReusableInit() { - let service = UIKitBackgroundTaskService(withApplication: UIApplication.mockHealty) + let service = UIKitBackgroundTaskService { + UIApplication.mockHealty + } // create two reusable task with the same name guard let task1 = service.startBackgroundTask(withName: Constants.bgTaskName, isReusable: true), @@ -82,7 +90,9 @@ class BackgroundTaskTests: XCTestCase { } func testMultipleStops() { - let service = UIKitBackgroundTaskService(withApplication: UIApplication.mockHealty) + let service = UIKitBackgroundTaskService { + UIApplication.mockHealty + } // create two reusable task with the same name guard let task = service.startBackgroundTask(withName: Constants.bgTaskName, isReusable: true), @@ -103,7 +113,9 @@ class BackgroundTaskTests: XCTestCase { } func testNotValidReuse() { - let service = UIKitBackgroundTaskService(withApplication: UIApplication.mockHealty) + let service = UIKitBackgroundTaskService { + UIApplication.mockHealty + } // create two reusable task with the same name guard let task = service.startBackgroundTask(withName: Constants.bgTaskName, isReusable: true) else { @@ -123,7 +135,9 @@ class BackgroundTaskTests: XCTestCase { } func testValidReuse() { - let service = UIKitBackgroundTaskService(withApplication: UIApplication.mockHealty) + let service = UIKitBackgroundTaskService { + UIApplication.mockHealty + } // create two reusable task with the same name guard let task = service.startBackgroundTask(withName: Constants.bgTaskName, isReusable: true) else { @@ -147,7 +161,9 @@ class BackgroundTaskTests: XCTestCase { } func testBrokenApp() { - let service = UIKitBackgroundTaskService(withApplication: UIApplication.mockBroken) + let service = UIKitBackgroundTaskService { + UIApplication.mockBroken + } // create two reusable task with the same name let task = service.startBackgroundTask(withName: Constants.bgTaskName) @@ -156,7 +172,9 @@ class BackgroundTaskTests: XCTestCase { } func testNoTimeApp() { - let service = UIKitBackgroundTaskService(withApplication: UIApplication.mockAboutToSuspend) + let service = UIKitBackgroundTaskService { + UIApplication.mockAboutToSuspend + } // create two reusable task with the same name let task = service.startBackgroundTask(withName: Constants.bgTaskName) diff --git a/changelog.d/341.bugfix b/changelog.d/341.bugfix new file mode 100644 index 000000000..1302f2547 --- /dev/null +++ b/changelog.d/341.bugfix @@ -0,0 +1 @@ +Application: Fix background tasks & state machine crashes.