Fix state machine crashes & background tasks (#343)

* Fix `UIApplication.shared` after moving to SwiftUI app

* Do not autoplay videos on background

* Move app state changes into the app coordinator

* Add application background task, move into the suspended state more accurately

* Add changelog

* Fix most of the linter errors

* Strip suspended state from state machine

* Fix build

* Clear audio session warning

* Update AppCoordinator.swift

* Update AppCoordinator.swift

* Swift format
This commit is contained in:
ismailgulek 2022-11-28 18:42:49 +03:00 committed by GitHub
parent bd530333df
commit cb5db22b7f
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
10 changed files with 131 additions and 97 deletions

View File

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

View File

@ -62,27 +62,38 @@ class AppCoordinatorStateMachine {
private let stateMachine: StateMachine<State, Event>
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)`")
}
}
}

View File

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

View File

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

View File

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

View File

@ -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<String, UIKitBackgroundTask> = 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,

View File

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

View File

@ -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<State, Event>
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
}

View File

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

1
changelog.d/341.bugfix Normal file
View File

@ -0,0 +1 @@
Application: Fix background tasks & state machine crashes.