diff --git a/Examples/xx-messenger/Package.swift b/Examples/xx-messenger/Package.swift index 8fa55d889db90a858bb04c68ddfdd16f29787b80..4668d629ddf3b295ce57fe54329d28101e94c7ff 100644 --- a/Examples/xx-messenger/Package.swift +++ b/Examples/xx-messenger/Package.swift @@ -117,8 +117,10 @@ let package = Package( .target( name: "BackupFeature", dependencies: [ + .target(name: "AppCore"), .product(name: "ComposableArchitecture", package: "swift-composable-architecture"), .product(name: "XXMessengerClient", package: "elixxir-dapps-sdk-swift"), + .product(name: "XXModels", package: "client-ios-db"), ], swiftSettings: swiftSettings ), diff --git a/Examples/xx-messenger/Sources/AppFeature/AppEnvironment+Live.swift b/Examples/xx-messenger/Sources/AppFeature/AppEnvironment+Live.swift index b6eab6a63a9c40a46b41311a419ee2b350bbae8e..a81b863afa2015e6a7d699ba5c921e0dc810e457 100644 --- a/Examples/xx-messenger/Sources/AppFeature/AppEnvironment+Live.swift +++ b/Examples/xx-messenger/Sources/AppFeature/AppEnvironment+Live.swift @@ -154,7 +154,13 @@ extension AppEnvironment { ) }, backup: { - BackupEnvironment() + BackupEnvironment( + messenger: messenger, + db: dbManager.getDB, + backupStorage: backupStorage, + mainQueue: mainQueue, + bgQueue: bgQueue + ) } ) } diff --git a/Examples/xx-messenger/Sources/BackupFeature/Alerts.swift b/Examples/xx-messenger/Sources/BackupFeature/Alerts.swift new file mode 100644 index 0000000000000000000000000000000000000000..2bd95f770ff5ce1be9e808284d1d50f3d111af1d --- /dev/null +++ b/Examples/xx-messenger/Sources/BackupFeature/Alerts.swift @@ -0,0 +1,10 @@ +import ComposableArchitecture + +extension AlertState where Action == BackupAction { + public static func error(_ error: Error) -> AlertState<BackupAction> { + AlertState( + title: TextState("Error"), + message: TextState(error.localizedDescription) + ) + } +} diff --git a/Examples/xx-messenger/Sources/BackupFeature/BackupFeature.swift b/Examples/xx-messenger/Sources/BackupFeature/BackupFeature.swift index eebbbc0b55fa328d0d30b658726ac3e63cbc7279..128c375b2625193e207a2c3f0ef50b96d00a3ac7 100644 --- a/Examples/xx-messenger/Sources/BackupFeature/BackupFeature.swift +++ b/Examples/xx-messenger/Sources/BackupFeature/BackupFeature.swift @@ -1,28 +1,232 @@ +import AppCore +import Combine import ComposableArchitecture -import XCTestDynamicOverlay +import Foundation +import XXClient +import XXMessengerClient +import XXModels public struct BackupState: Equatable { - public init() {} + public enum Error: String, Swift.Error, Equatable { + case dbContactNotFound + case dbContactUsernameMissing + } + + public init( + isRunning: Bool = false, + isStarting: Bool = false, + isResuming: Bool = false, + isStopping: Bool = false, + backup: BackupStorage.Backup? = nil, + alert: AlertState<BackupAction>? = nil, + passphrase: String = "", + isExporting: Bool = false, + exportData: Data? = nil + ) { + self.isRunning = isRunning + self.isStarting = isStarting + self.isResuming = isResuming + self.isStopping = isStopping + self.backup = backup + self.alert = alert + self.passphrase = passphrase + self.isExporting = isExporting + self.exportData = exportData + } + + public var isRunning: Bool + public var isStarting: Bool + public var isResuming: Bool + public var isStopping: Bool + public var backup: BackupStorage.Backup? + public var alert: AlertState<BackupAction>? + @BindableState public var passphrase: String + @BindableState public var isExporting: Bool + public var exportData: Data? } -public enum BackupAction: Equatable { - case start +public enum BackupAction: Equatable, BindableAction { + case task + case cancelTask + case startTapped + case resumeTapped + case stopTapped + case exportTapped + case alertDismissed + case backupUpdated(BackupStorage.Backup?) + case didStart(failure: NSError?) + case didResume(failure: NSError?) + case didStop(failure: NSError?) + case didExport(failure: NSError?) + case binding(BindingAction<BackupState>) } public struct BackupEnvironment { - public init() {} + public init( + messenger: Messenger, + db: DBManagerGetDB, + backupStorage: BackupStorage, + mainQueue: AnySchedulerOf<DispatchQueue>, + bgQueue: AnySchedulerOf<DispatchQueue> + ) { + self.messenger = messenger + self.db = db + self.backupStorage = backupStorage + self.mainQueue = mainQueue + self.bgQueue = bgQueue + } + + public var messenger: Messenger + public var db: DBManagerGetDB + public var backupStorage: BackupStorage + public var mainQueue: AnySchedulerOf<DispatchQueue> + public var bgQueue: AnySchedulerOf<DispatchQueue> } #if DEBUG extension BackupEnvironment { - public static let unimplemented = BackupEnvironment() + public static let unimplemented = BackupEnvironment( + messenger: .unimplemented, + db: .unimplemented, + backupStorage: .unimplemented, + mainQueue: .unimplemented, + bgQueue: .unimplemented + ) } #endif public let backupReducer = Reducer<BackupState, BackupAction, BackupEnvironment> { state, action, env in + enum TaskEffectId {} + switch action { - case .start: + case .task: + state.isRunning = env.messenger.isBackupRunning() + return Effect.run { subscriber in + let cancellable = env.backupStorage.observe { backup in + subscriber.send(.backupUpdated(backup)) + } + return AnyCancellable { cancellable.cancel() } + } + .subscribe(on: env.bgQueue) + .receive(on: env.mainQueue) + .eraseToEffect() + .cancellable(id: TaskEffectId.self, cancelInFlight: true) + + case .cancelTask: + return .cancel(id: TaskEffectId.self) + + case .startTapped: + state.isStarting = true + return Effect.run { [state] subscriber in + do { + let e2e: E2E = try env.messenger.e2e.tryGet() + let contactID = try e2e.getContact().getId() + let db = try env.db() + let query = XXModels.Contact.Query(id: [contactID]) + guard let contact = try db.fetchContacts(query).first else { + throw BackupState.Error.dbContactNotFound + } + guard let username = contact.username else { + throw BackupState.Error.dbContactUsernameMissing + } + try env.messenger.startBackup( + password: state.passphrase, + params: BackupParams(username: username) + ) + subscriber.send(.didStart(failure: nil)) + } catch { + subscriber.send(.didStart(failure: error as NSError)) + } + subscriber.send(completion: .finished) + return AnyCancellable {} + } + .subscribe(on: env.bgQueue) + .receive(on: env.mainQueue) + .eraseToEffect() + + case .resumeTapped: + state.isResuming = true + return Effect.run { subscriber in + do { + try env.messenger.resumeBackup() + subscriber.send(.didResume(failure: nil)) + } catch { + subscriber.send(.didResume(failure: error as NSError)) + } + subscriber.send(completion: .finished) + return AnyCancellable {} + } + .subscribe(on: env.bgQueue) + .receive(on: env.mainQueue) + .eraseToEffect() + + case .stopTapped: + state.isStopping = true + return Effect.run { subscriber in + do { + try env.messenger.stopBackup() + env.backupStorage.remove() + subscriber.send(.didStop(failure: nil)) + } catch { + subscriber.send(.didStop(failure: error as NSError)) + } + subscriber.send(completion: .finished) + return AnyCancellable {} + } + .subscribe(on: env.bgQueue) + .receive(on: env.mainQueue) + .eraseToEffect() + + case .exportTapped: + state.isExporting = true + state.exportData = state.backup?.data + return .none + + case .alertDismissed: + state.alert = nil + return .none + + case .backupUpdated(let backup): + state.backup = backup + return .none + + case .didStart(let failure): + state.isRunning = env.messenger.isBackupRunning() + state.isStarting = false + if let failure { + state.alert = .error(failure) + } else { + state.passphrase = "" + } + return .none + + case .didResume(let failure): + state.isRunning = env.messenger.isBackupRunning() + state.isResuming = false + if let failure { + state.alert = .error(failure) + } + return .none + + case .didStop(let failure): + state.isRunning = env.messenger.isBackupRunning() + state.isStopping = false + if let failure { + state.alert = .error(failure) + } + return .none + + case .didExport(let failure): + state.isExporting = false + state.exportData = nil + if let failure { + state.alert = .error(failure) + } + return .none + + case .binding(_): return .none } } +.binding() diff --git a/Examples/xx-messenger/Sources/BackupFeature/BackupView.swift b/Examples/xx-messenger/Sources/BackupFeature/BackupView.swift index 874e61374706e977f8cc0067b20d49b55bacad91..13873f8945ea09c62ba12261213da29057dbf075 100644 --- a/Examples/xx-messenger/Sources/BackupFeature/BackupView.swift +++ b/Examples/xx-messenger/Sources/BackupFeature/BackupView.swift @@ -1,5 +1,6 @@ import ComposableArchitecture import SwiftUI +import UniformTypeIdentifiers public struct BackupView: View { public init(store: Store<BackupState, BackupAction>) { @@ -9,20 +10,220 @@ public struct BackupView: View { let store: Store<BackupState, BackupAction> struct ViewState: Equatable { - init(state: BackupState) {} + struct Backup: Equatable { + var date: Date + var size: Int + } + + init(state: BackupState) { + isRunning = state.isRunning + isStarting = state.isStarting + isResuming = state.isResuming + isStopping = state.isStopping + backup = state.backup.map { backup in + Backup(date: backup.date, size: backup.data.count) + } + passphrase = state.passphrase + isExporting = state.isExporting + exportData = state.exportData + } + + var isRunning: Bool + var isStarting: Bool + var isResuming: Bool + var isStopping: Bool + var isLoading: Bool { isStarting || isResuming || isStopping } + var backup: Backup? + var passphrase: String + var isExporting: Bool + var exportData: Data? } public var body: some View { WithViewStore(store, observe: ViewState.init) { viewStore in Form { - + Group { + if !viewStore.isRunning { + newBackupSection(viewStore) + } + if viewStore.isRunning || viewStore.backup != nil { + backupSection(viewStore) + } + } + .disabled(viewStore.isLoading) + .alert( + store.scope(state: \.alert), + dismiss: .alertDismissed + ) } .navigationTitle("Backup") .task { - viewStore.send(.start) + await viewStore.send(.task).finish() + } + } + } + + @ViewBuilder func newBackupSection( + _ viewStore: ViewStore<ViewState, BackupAction> + ) -> some View { + Section { + SecureField( + text: viewStore.binding( + get: \.passphrase, + send: { .set(\.$passphrase, $0) } + ), + prompt: Text("Backup passphrase"), + label: { Text("Backup passphrase") } + ) + Button { + viewStore.send(.startTapped) + } label: { + HStack { + Text("Start") + Spacer() + if viewStore.isStarting { + ProgressView() + } else { + Image(systemName: "play.fill") + } + } + } + } header: { + Text("New backup") + } + } + + @ViewBuilder func backupSection( + _ viewStore: ViewStore<ViewState, BackupAction> + ) -> some View { + Section { + backupView(viewStore) + stopView(viewStore) + resumeView(viewStore) + } header: { + Text("Backup") + } + } + + @ViewBuilder func backupView( + _ viewStore: ViewStore<ViewState, BackupAction> + ) -> some View { + if let backup = viewStore.backup { + HStack { + Text("Date") + Spacer() + Text(backup.date.formatted()) + } + HStack { + Text("Size") + Spacer() + Text(format(bytesCount: backup.size)) + } + Button { + viewStore.send(.exportTapped) + } label: { + HStack { + Text("Export") + Spacer() + if viewStore.isExporting { + ProgressView() + } else { + Image(systemName: "square.and.arrow.up") + } + } + } + .disabled(viewStore.isExporting) + .fileExporter( + isPresented: viewStore.binding( + get: \.isExporting, + send: { .set(\.$isExporting, $0) } + ), + document: viewStore.exportData.map(ExportedDocument.init(data:)), + contentType: .data, + defaultFilename: "backup.xxm", + onCompletion: { result in + switch result { + case .success(_): + viewStore.send(.didExport(failure: nil)) + case .failure(let error): + viewStore.send(.didExport(failure: error as NSError?)) + } + } + ) + } else { + Text("No backup") + } + } + + @ViewBuilder func stopView( + _ viewStore: ViewStore<ViewState, BackupAction> + ) -> some View { + if viewStore.isRunning { + Button { + viewStore.send(.stopTapped) + } label: { + HStack { + Text("Stop") + Spacer() + if viewStore.isStopping { + ProgressView() + } else { + Image(systemName: "stop.fill") + } + } + } + } + } + + @ViewBuilder func resumeView( + _ viewStore: ViewStore<ViewState, BackupAction> + ) -> some View { + if !viewStore.isRunning, viewStore.backup != nil { + Button { + viewStore.send(.resumeTapped) + } label: { + HStack { + Text("Resume") + Spacer() + if viewStore.isResuming { + ProgressView() + } else { + Image(systemName: "playpause.fill") + } + } } } } + + func format(bytesCount bytes: Int) -> String { + let formatter = ByteCountFormatter() + formatter.allowedUnits = [.useMB, .useKB] + formatter.countStyle = .binary + return formatter.string(fromByteCount: Int64(bytes)) + } +} + +private struct ExportedDocument: FileDocument { + enum Error: Swift.Error { + case notAvailable + } + + static var readableContentTypes: [UTType] = [] + static var writableContentTypes: [UTType] = [.data] + + var data: Data + + init(data: Data) { + self.data = data + } + + init(configuration: ReadConfiguration) throws { + throw Error.notAvailable + } + + func fileWrapper(configuration: WriteConfiguration) throws -> FileWrapper { + FileWrapper(regularFileWithContents: data) + } } #if DEBUG diff --git a/Examples/xx-messenger/Tests/BackupFeatureTests/BackupFeatureTests.swift b/Examples/xx-messenger/Tests/BackupFeatureTests/BackupFeatureTests.swift index a95240ca5ce196d4d81e55327e1b86bf3748f9ca..ebb1510bc0c4f8a602db808931fe2c66ee5d3589 100644 --- a/Examples/xx-messenger/Tests/BackupFeatureTests/BackupFeatureTests.swift +++ b/Examples/xx-messenger/Tests/BackupFeatureTests/BackupFeatureTests.swift @@ -1,15 +1,452 @@ import ComposableArchitecture import XCTest +import XXClient +import XXMessengerClient +import XXModels @testable import BackupFeature final class BackupFeatureTests: XCTestCase { - func testStart() { + func testTask() { + var isBackupRunning: [Bool] = [false] + var observers: [UUID: BackupStorage.Observer] = [:] + + let store = TestStore( + initialState: BackupState(), + reducer: backupReducer, + environment: .unimplemented + ) + store.environment.mainQueue = .immediate + store.environment.bgQueue = .immediate + store.environment.messenger.isBackupRunning.run = { + isBackupRunning.removeFirst() + } + store.environment.backupStorage.observe = { + let id = UUID() + observers[id] = $0 + return Cancellable { observers[id] = nil } + } + + store.send(.task) + + XCTAssertNoDifference(observers.count, 1) + + let backup = BackupStorage.Backup( + date: .init(timeIntervalSince1970: 1), + data: "backup".data(using: .utf8)! + ) + observers.values.forEach { $0(backup) } + + store.receive(.backupUpdated(backup)) { + $0.backup = backup + } + + observers.values.forEach { $0(nil) } + + store.receive(.backupUpdated(nil)) { + $0.backup = nil + } + + store.send(.cancelTask) + + XCTAssertNoDifference(observers.count, 0) + } + + func testStartBackup() { + var actions: [Action]! + var isBackupRunning: [Bool] = [true] + let contactID = "contact-id".data(using: .utf8)! + let dbContact = XXModels.Contact( + id: contactID, + username: "db-contact-username" + ) + let passphrase = "backup-password" + + let store = TestStore( + initialState: BackupState(), + reducer: backupReducer, + environment: .unimplemented + ) + store.environment.mainQueue = .immediate + store.environment.bgQueue = .immediate + store.environment.messenger.startBackup.run = { passphrase, params in + actions.append(.didStartBackup(passphrase: passphrase, params: params)) + } + store.environment.messenger.isBackupRunning.run = { + isBackupRunning.removeFirst() + } + store.environment.messenger.e2e.get = { + var e2e: E2E = .unimplemented + e2e.getContact.run = { + var contact: XXClient.Contact = .unimplemented(Data()) + contact.getIdFromContact.run = { _ in contactID } + return contact + } + return e2e + } + store.environment.db.run = { + var db: Database = .unimplemented + db.fetchContacts.run = { _ in return [dbContact] } + return db + } + + actions = [] + store.send(.set(\.$passphrase, passphrase)) { + $0.passphrase = passphrase + } + + XCTAssertNoDifference(actions, []) + + actions = [] + store.send(.startTapped) { + $0.isStarting = true + } + + XCTAssertNoDifference(actions, [ + .didStartBackup( + passphrase: passphrase, + params: .init(username: dbContact.username!) + ) + ]) + + store.receive(.didStart(failure: nil)) { + $0.isRunning = true + $0.isStarting = false + $0.passphrase = "" + } + } + + func testStartBackupWithoutDbContact() { + var isBackupRunning: [Bool] = [false] + let contactID = "contact-id".data(using: .utf8)! + + let store = TestStore( + initialState: BackupState( + passphrase: "1234" + ), + reducer: backupReducer, + environment: .unimplemented + ) + store.environment.mainQueue = .immediate + store.environment.bgQueue = .immediate + store.environment.messenger.isBackupRunning.run = { + isBackupRunning.removeFirst() + } + store.environment.messenger.e2e.get = { + var e2e: E2E = .unimplemented + e2e.getContact.run = { + var contact: XXClient.Contact = .unimplemented(Data()) + contact.getIdFromContact.run = { _ in contactID } + return contact + } + return e2e + } + store.environment.db.run = { + var db: Database = .unimplemented + db.fetchContacts.run = { _ in [] } + return db + } + + store.send(.startTapped) { + $0.isStarting = true + } + + let failure = BackupState.Error.dbContactNotFound + store.receive(.didStart(failure: failure as NSError)) { + $0.isRunning = false + $0.isStarting = false + $0.alert = .error(failure) + } + } + + func testStartBackupWithoutDbContactUsername() { + var isBackupRunning: [Bool] = [false] + let contactID = "contact-id".data(using: .utf8)! + let dbContact = XXModels.Contact( + id: contactID, + username: nil + ) + + let store = TestStore( + initialState: BackupState( + passphrase: "1234" + ), + reducer: backupReducer, + environment: .unimplemented + ) + store.environment.mainQueue = .immediate + store.environment.bgQueue = .immediate + store.environment.messenger.isBackupRunning.run = { + isBackupRunning.removeFirst() + } + store.environment.messenger.e2e.get = { + var e2e: E2E = .unimplemented + e2e.getContact.run = { + var contact: XXClient.Contact = .unimplemented(Data()) + contact.getIdFromContact.run = { _ in contactID } + return contact + } + return e2e + } + store.environment.db.run = { + var db: Database = .unimplemented + db.fetchContacts.run = { _ in [dbContact] } + return db + } + + store.send(.startTapped) { + $0.isStarting = true + } + + let failure = BackupState.Error.dbContactUsernameMissing + store.receive(.didStart(failure: failure as NSError)) { + $0.isRunning = false + $0.isStarting = false + $0.alert = .error(failure) + } + } + + func testStartBackupFailure() { + struct Failure: Error {} + let failure = Failure() + var isBackupRunning: [Bool] = [false] + let contactID = "contact-id".data(using: .utf8)! + let dbContact = XXModels.Contact( + id: contactID, + username: "db-contact-username" + ) + + let store = TestStore( + initialState: BackupState( + passphrase: "1234" + ), + reducer: backupReducer, + environment: .unimplemented + ) + store.environment.mainQueue = .immediate + store.environment.bgQueue = .immediate + store.environment.messenger.startBackup.run = { _, _ in + throw failure + } + store.environment.messenger.isBackupRunning.run = { + isBackupRunning.removeFirst() + } + store.environment.messenger.e2e.get = { + var e2e: E2E = .unimplemented + e2e.getContact.run = { + var contact: XXClient.Contact = .unimplemented(Data()) + contact.getIdFromContact.run = { _ in contactID } + return contact + } + return e2e + } + store.environment.db.run = { + var db: Database = .unimplemented + db.fetchContacts.run = { _ in return [dbContact] } + return db + } + + store.send(.startTapped) { + $0.isStarting = true + } + + store.receive(.didStart(failure: failure as NSError)) { + $0.isRunning = false + $0.isStarting = false + $0.alert = .error(failure) + } + } + + func testResumeBackup() { + var actions: [Action]! + var isBackupRunning: [Bool] = [true] + + let store = TestStore( + initialState: BackupState(), + reducer: backupReducer, + environment: .unimplemented + ) + store.environment.mainQueue = .immediate + store.environment.bgQueue = .immediate + store.environment.messenger.resumeBackup.run = { + actions.append(.didResumeBackup) + } + store.environment.messenger.isBackupRunning.run = { + isBackupRunning.removeFirst() + } + + actions = [] + store.send(.resumeTapped) { + $0.isResuming = true + } + + XCTAssertNoDifference(actions, [.didResumeBackup]) + + actions = [] + store.receive(.didResume(failure: nil)) { + $0.isRunning = true + $0.isResuming = false + } + + XCTAssertNoDifference(actions, []) + } + + func testResumeBackupFailure() { + struct Failure: Error {} + let failure = Failure() + var isBackupRunning: [Bool] = [false] + + let store = TestStore( + initialState: BackupState(), + reducer: backupReducer, + environment: .unimplemented + ) + store.environment.mainQueue = .immediate + store.environment.bgQueue = .immediate + store.environment.messenger.resumeBackup.run = { + throw failure + } + store.environment.messenger.isBackupRunning.run = { + isBackupRunning.removeFirst() + } + + store.send(.resumeTapped) { + $0.isResuming = true + } + + store.receive(.didResume(failure: failure as NSError)) { + $0.isRunning = false + $0.isResuming = false + $0.alert = .error(failure) + } + } + + func testStopBackup() { + var actions: [Action]! + var isBackupRunning: [Bool] = [false] + + let store = TestStore( + initialState: BackupState(), + reducer: backupReducer, + environment: .unimplemented + ) + store.environment.mainQueue = .immediate + store.environment.bgQueue = .immediate + store.environment.messenger.stopBackup.run = { + actions.append(.didStopBackup) + } + store.environment.messenger.isBackupRunning.run = { + isBackupRunning.removeFirst() + } + store.environment.backupStorage.remove = { + actions.append(.didRemoveBackup) + } + + actions = [] + store.send(.stopTapped) { + $0.isStopping = true + } + + XCTAssertNoDifference(actions, [ + .didStopBackup, + .didRemoveBackup, + ]) + + actions = [] + store.receive(.didStop(failure: nil)) { + $0.isRunning = false + $0.isStopping = false + } + + XCTAssertNoDifference(actions, []) + } + + func testStopBackupFailure() { + struct Failure: Error {} + let failure = Failure() + var isBackupRunning: [Bool] = [true] + let store = TestStore( initialState: BackupState(), reducer: backupReducer, environment: .unimplemented ) + store.environment.mainQueue = .immediate + store.environment.bgQueue = .immediate + store.environment.messenger.stopBackup.run = { + throw failure + } + store.environment.messenger.isBackupRunning.run = { + isBackupRunning.removeFirst() + } - store.send(.start) + store.send(.stopTapped) { + $0.isStopping = true + } + + store.receive(.didStop(failure: failure as NSError)) { + $0.isRunning = true + $0.isStopping = false + $0.alert = .error(failure) + } } + + func testAlertDismissed() { + let store = TestStore( + initialState: BackupState( + alert: .error(NSError(domain: "test", code: 0)) + ), + reducer: backupReducer, + environment: .unimplemented + ) + + store.send(.alertDismissed) { + $0.alert = nil + } + } + + func testExportBackup() { + let backupData = "backup-data".data(using: .utf8)! + + let store = TestStore( + initialState: BackupState( + backup: .init( + date: Date(), + data: backupData + ) + ), + reducer: backupReducer, + environment: .unimplemented + ) + + store.send(.exportTapped) { + $0.isExporting = true + $0.exportData = backupData + } + + store.send(.didExport(failure: nil)) { + $0.isExporting = false + $0.exportData = nil + } + + store.send(.exportTapped) { + $0.isExporting = true + $0.exportData = backupData + } + + let failure = NSError(domain: "test", code: 0) + store.send(.didExport(failure: failure)) { + $0.isExporting = false + $0.exportData = nil + $0.alert = .error(failure) + } + } +} + +private enum Action: Equatable { + case didRegisterObserver + case didStartBackup(passphrase: String, params: BackupParams) + case didResumeBackup + case didStopBackup + case didRemoveBackup + case didFetchContacts(XXModels.Contact.Query) }