diff --git a/Examples/xx-messenger/.swiftpm/xcode/xcshareddata/xcschemes/BackupFeature.xcscheme b/Examples/xx-messenger/.swiftpm/xcode/xcshareddata/xcschemes/BackupFeature.xcscheme new file mode 100644 index 0000000000000000000000000000000000000000..61d3cc5840af204a0b1ac7d0f8fc6ea574d0bce0 --- /dev/null +++ b/Examples/xx-messenger/.swiftpm/xcode/xcshareddata/xcschemes/BackupFeature.xcscheme @@ -0,0 +1,78 @@ +<?xml version="1.0" encoding="UTF-8"?> +<Scheme + LastUpgradeVersion = "1400" + version = "1.3"> + <BuildAction + parallelizeBuildables = "YES" + buildImplicitDependencies = "YES"> + <BuildActionEntries> + <BuildActionEntry + buildForTesting = "YES" + buildForRunning = "YES" + buildForProfiling = "YES" + buildForArchiving = "YES" + buildForAnalyzing = "YES"> + <BuildableReference + BuildableIdentifier = "primary" + BlueprintIdentifier = "BackupFeature" + BuildableName = "BackupFeature" + BlueprintName = "BackupFeature" + ReferencedContainer = "container:"> + </BuildableReference> + </BuildActionEntry> + </BuildActionEntries> + </BuildAction> + <TestAction + buildConfiguration = "Debug" + selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB" + selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB" + shouldUseLaunchSchemeArgsEnv = "YES" + codeCoverageEnabled = "YES"> + <Testables> + <TestableReference + skipped = "NO"> + <BuildableReference + BuildableIdentifier = "primary" + BlueprintIdentifier = "BackupFeatureTests" + BuildableName = "BackupFeatureTests" + BlueprintName = "BackupFeatureTests" + ReferencedContainer = "container:"> + </BuildableReference> + </TestableReference> + </Testables> + </TestAction> + <LaunchAction + buildConfiguration = "Debug" + selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB" + selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB" + launchStyle = "0" + useCustomWorkingDirectory = "NO" + ignoresPersistentStateOnLaunch = "NO" + debugDocumentVersioning = "YES" + debugServiceExtension = "internal" + allowLocationSimulation = "YES"> + </LaunchAction> + <ProfileAction + buildConfiguration = "Release" + shouldUseLaunchSchemeArgsEnv = "YES" + savedToolIdentifier = "" + useCustomWorkingDirectory = "NO" + debugDocumentVersioning = "YES"> + <MacroExpansion> + <BuildableReference + BuildableIdentifier = "primary" + BlueprintIdentifier = "BackupFeature" + BuildableName = "BackupFeature" + BlueprintName = "BackupFeature" + ReferencedContainer = "container:"> + </BuildableReference> + </MacroExpansion> + </ProfileAction> + <AnalyzeAction + buildConfiguration = "Debug"> + </AnalyzeAction> + <ArchiveAction + buildConfiguration = "Release" + revealArchiveInOrganizer = "YES"> + </ArchiveAction> +</Scheme> diff --git a/Examples/xx-messenger/Package.swift b/Examples/xx-messenger/Package.swift index b310549f615363a0f38348b5c79f2d158d00c0e2..4668d629ddf3b295ce57fe54329d28101e94c7ff 100644 --- a/Examples/xx-messenger/Package.swift +++ b/Examples/xx-messenger/Package.swift @@ -15,6 +15,7 @@ let package = Package( products: [ .library(name: "AppCore", targets: ["AppCore"]), .library(name: "AppFeature", targets: ["AppFeature"]), + .library(name: "BackupFeature", targets: ["BackupFeature"]), .library(name: "ChatFeature", targets: ["ChatFeature"]), .library(name: "CheckContactAuthFeature", targets: ["CheckContactAuthFeature"]), .library(name: "ConfirmRequestFeature", targets: ["ConfirmRequestFeature"]), @@ -83,6 +84,7 @@ let package = Package( name: "AppFeature", dependencies: [ .target(name: "AppCore"), + .target(name: "BackupFeature"), .target(name: "ChatFeature"), .target(name: "CheckContactAuthFeature"), .target(name: "ConfirmRequestFeature"), @@ -112,6 +114,23 @@ let package = Package( ], swiftSettings: swiftSettings ), + .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 + ), + .testTarget( + name: "BackupFeatureTests", + dependencies: [ + .target(name: "BackupFeature"), + ], + swiftSettings: swiftSettings + ), .target( name: "ChatFeature", dependencies: [ @@ -215,6 +234,7 @@ let package = Package( name: "HomeFeature", dependencies: [ .target(name: "AppCore"), + .target(name: "BackupFeature"), .target(name: "ContactsFeature"), .target(name: "RegisterFeature"), .target(name: "UserSearchFeature"), diff --git a/Examples/xx-messenger/Project/XXMessenger.xcodeproj/xcshareddata/xcschemes/XXMessenger.xcscheme b/Examples/xx-messenger/Project/XXMessenger.xcodeproj/xcshareddata/xcschemes/XXMessenger.xcscheme index aa97ea7d2a0f1ce2fded6d13e874f8f8caf21769..b50f1c107c6e3f6e79c5c4c22e64b4368732de84 100644 --- a/Examples/xx-messenger/Project/XXMessenger.xcodeproj/xcshareddata/xcschemes/XXMessenger.xcscheme +++ b/Examples/xx-messenger/Project/XXMessenger.xcodeproj/xcshareddata/xcschemes/XXMessenger.xcscheme @@ -49,6 +49,16 @@ ReferencedContainer = "container:.."> </BuildableReference> </TestableReference> + <TestableReference + skipped = "NO"> + <BuildableReference + BuildableIdentifier = "primary" + BlueprintIdentifier = "BackupFeatureTests" + BuildableName = "BackupFeatureTests" + BlueprintName = "BackupFeatureTests" + ReferencedContainer = "container:.."> + </BuildableReference> + </TestableReference> <TestableReference skipped = "NO"> <BuildableReference diff --git a/Examples/xx-messenger/Sources/AppFeature/AppEnvironment+Live.swift b/Examples/xx-messenger/Sources/AppFeature/AppEnvironment+Live.swift index 24e5bd8910a267354a1a608579e5ddb31d1f4cc0..3cf2cb0b065eda90aeff16cf1ce76f14297fd012 100644 --- a/Examples/xx-messenger/Sources/AppFeature/AppEnvironment+Live.swift +++ b/Examples/xx-messenger/Sources/AppFeature/AppEnvironment+Live.swift @@ -1,4 +1,5 @@ import AppCore +import BackupFeature import ChatFeature import CheckContactAuthFeature import ConfirmRequestFeature @@ -27,6 +28,7 @@ extension AppEnvironment { handleConfirm: .live(db: dbManager.getDB), handleReset: .live(db: dbManager.getDB) ) + let backupStorage = BackupStorage.onDisk() let mainQueue = DispatchQueue.main.eraseToAnyScheduler() let bgQueue = DispatchQueue.global(qos: .background).eraseToAnyScheduler() @@ -90,6 +92,7 @@ extension AppEnvironment { messenger: messenger, db: dbManager.getDB ), + backupStorage: backupStorage, log: .live(), mainQueue: mainQueue, bgQueue: bgQueue, @@ -149,6 +152,15 @@ extension AppEnvironment { bgQueue: bgQueue, contact: { contactEnvironment } ) + }, + backup: { + BackupEnvironment( + messenger: messenger, + db: dbManager.getDB, + backupStorage: backupStorage, + mainQueue: mainQueue, + bgQueue: bgQueue + ) } ) } diff --git a/Examples/xx-messenger/Sources/AppFeature/AppFeature.swift b/Examples/xx-messenger/Sources/AppFeature/AppFeature.swift index caae3368d778873e89af41f51a8a9ecce581866c..07be9948b695eb3bb8ecda07fb674ca77e8e7141 100644 --- a/Examples/xx-messenger/Sources/AppFeature/AppFeature.swift +++ b/Examples/xx-messenger/Sources/AppFeature/AppFeature.swift @@ -50,6 +50,7 @@ struct AppEnvironment { var messenger: Messenger var authHandler: AuthCallbackHandler var messageListener: MessageListenerHandler + var backupStorage: BackupStorage var log: Logger var mainQueue: AnySchedulerOf<DispatchQueue> var bgQueue: AnySchedulerOf<DispatchQueue> @@ -64,6 +65,7 @@ extension AppEnvironment { messenger: .unimplemented, authHandler: .unimplemented, messageListener: .unimplemented, + backupStorage: .unimplemented, log: .unimplemented, mainQueue: .unimplemented, bgQueue: .unimplemented, @@ -94,6 +96,9 @@ let appReducer = Reducer<AppState, AppAction, AppEnvironment> cancellables.append(env.messageListener(onError: { error in env.log(.error(error as NSError)) })) + cancellables.append(env.messenger.registerBackupCallback(.init { data in + try? env.backupStorage.store(data) + })) let isLoaded = env.messenger.isLoaded() let isCreated = env.messenger.isCreated() 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 new file mode 100644 index 0000000000000000000000000000000000000000..e1ce8fb201188ac101249d070dc96a75e4ca481a --- /dev/null +++ b/Examples/xx-messenger/Sources/BackupFeature/BackupFeature.swift @@ -0,0 +1,232 @@ +import AppCore +import Combine +import ComposableArchitecture +import Foundation +import XXClient +import XXMessengerClient +import XXModels + +public struct BackupState: Equatable { + 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, 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( + 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( + 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 .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() + try 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 new file mode 100644 index 0000000000000000000000000000000000000000..54fc6567cd07eac6c5649fa5a35e6b28a91fe3eb --- /dev/null +++ b/Examples/xx-messenger/Sources/BackupFeature/BackupView.swift @@ -0,0 +1,241 @@ +import ComposableArchitecture +import SwiftUI +import UniformTypeIdentifiers + +public struct BackupView: View { + public init(store: Store<BackupState, BackupAction>) { + self.store = store + } + + let store: Store<BackupState, BackupAction> + + struct ViewState: Equatable { + 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 || viewStore.backup != nil { + backupSection(viewStore) + } + if !viewStore.isRunning { + newBackupSection(viewStore) + } + } + .disabled(viewStore.isLoading) + .alert( + store.scope(state: \.alert), + dismiss: .alertDismissed + ) + } + .navigationTitle("Backup") + .task { + 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 +public struct BackupView_Previews: PreviewProvider { + public static var previews: some View { + NavigationView { + BackupView(store: Store( + initialState: BackupState(), + reducer: .empty, + environment: () + )) + } + } +} +#endif diff --git a/Examples/xx-messenger/Sources/HomeFeature/HomeFeature.swift b/Examples/xx-messenger/Sources/HomeFeature/HomeFeature.swift index 068cdb4dd77598fede025166b1a5e7505f1a7ba7..87a516000a941d139add614d91b8276da3d5acab 100644 --- a/Examples/xx-messenger/Sources/HomeFeature/HomeFeature.swift +++ b/Examples/xx-messenger/Sources/HomeFeature/HomeFeature.swift @@ -1,4 +1,5 @@ import AppCore +import BackupFeature import Combine import ComposableArchitecture import ComposablePresentation @@ -20,7 +21,8 @@ public struct HomeState: Equatable { alert: AlertState<HomeAction>? = nil, register: RegisterState? = nil, contacts: ContactsState? = nil, - userSearch: UserSearchState? = nil + userSearch: UserSearchState? = nil, + backup: BackupState? = nil ) { self.failure = failure self.isNetworkHealthy = isNetworkHealthy @@ -29,6 +31,7 @@ public struct HomeState: Equatable { self.register = register self.contacts = contacts self.userSearch = userSearch + self.backup = backup } public var failure: String? @@ -39,6 +42,7 @@ public struct HomeState: Equatable { public var register: RegisterState? public var contacts: ContactsState? public var userSearch: UserSearchState? + public var backup: BackupState? } public enum HomeAction: Equatable { @@ -72,9 +76,12 @@ public enum HomeAction: Equatable { case didDismissUserSearch case contactsButtonTapped case didDismissContacts + case backupButtonTapped + case didDismissBackup case register(RegisterAction) case contacts(ContactsAction) case userSearch(UserSearchAction) + case backup(BackupAction) } public struct HomeEnvironment { @@ -85,7 +92,8 @@ public struct HomeEnvironment { bgQueue: AnySchedulerOf<DispatchQueue>, register: @escaping () -> RegisterEnvironment, contacts: @escaping () -> ContactsEnvironment, - userSearch: @escaping () -> UserSearchEnvironment + userSearch: @escaping () -> UserSearchEnvironment, + backup: @escaping () -> BackupEnvironment ) { self.messenger = messenger self.dbManager = dbManager @@ -94,6 +102,7 @@ public struct HomeEnvironment { self.register = register self.contacts = contacts self.userSearch = userSearch + self.backup = backup } public var messenger: Messenger @@ -103,6 +112,7 @@ public struct HomeEnvironment { public var register: () -> RegisterEnvironment public var contacts: () -> ContactsEnvironment public var userSearch: () -> UserSearchEnvironment + public var backup: () -> BackupEnvironment } extension HomeEnvironment { @@ -113,7 +123,8 @@ extension HomeEnvironment { bgQueue: .unimplemented, register: { .unimplemented }, contacts: { .unimplemented }, - userSearch: { .unimplemented } + userSearch: { .unimplemented }, + backup: { .unimplemented } ) } @@ -145,6 +156,10 @@ public let homeReducer = Reducer<HomeState, HomeAction, HomeEnvironment> try env.messenger.logIn() } + if !env.messenger.isBackupRunning() { + try? env.messenger.resumeBackup() + } + return .success(.messenger(.didStartRegistered)) } catch { return .success(.messenger(.failure(error as NSError))) @@ -267,7 +282,15 @@ public let homeReducer = Reducer<HomeState, HomeAction, HomeEnvironment> state.register = nil return Effect(value: .messenger(.start)) - case .register(_), .contacts(_), .userSearch(_): + case .backupButtonTapped: + state.backup = BackupState() + return .none + + case .didDismissBackup: + state.backup = nil + return .none + + case .register(_), .contacts(_), .userSearch(_), .backup(_): return .none } } @@ -292,3 +315,10 @@ public let homeReducer = Reducer<HomeState, HomeAction, HomeEnvironment> action: /HomeAction.userSearch, environment: { $0.userSearch() } ) +.presenting( + backupReducer, + state: .keyPath(\.backup), + id: .notNil(), + action: /HomeAction.backup, + environment: { $0.backup() } +) diff --git a/Examples/xx-messenger/Sources/HomeFeature/HomeView.swift b/Examples/xx-messenger/Sources/HomeFeature/HomeView.swift index 03907b18d08fa6a5e39502060ec3f63b44773404..95bde09c14e3f2674768ccfb1aefc8eb3e594891 100644 --- a/Examples/xx-messenger/Sources/HomeFeature/HomeView.swift +++ b/Examples/xx-messenger/Sources/HomeFeature/HomeView.swift @@ -1,3 +1,4 @@ +import BackupFeature import ComposableArchitecture import ComposablePresentation import ContactsFeature @@ -111,6 +112,16 @@ public struct HomeView: View { } Section { + Button { + viewStore.send(.backupButtonTapped) + } label: { + HStack { + Text("Backup") + Spacer() + Image(systemName: "chevron.forward") + } + } + Button(role: .destructive) { viewStore.send(.deleteAccount(.buttonTapped)) } label: { @@ -152,6 +163,16 @@ public struct HomeView: View { }, destination: UserSearchView.init(store:) )) + .background(NavigationLinkWithStore( + store.scope( + state: \.backup, + action: HomeAction.backup + ), + onDeactivate: { + viewStore.send(.didDismissBackup) + }, + destination: BackupView.init(store:) + )) } .navigationViewStyle(.stack) .task { viewStore.send(.messenger(.start)) } diff --git a/Examples/xx-messenger/Tests/AppFeatureTests/AppFeatureTests.swift b/Examples/xx-messenger/Tests/AppFeatureTests/AppFeatureTests.swift index 5bda4eb3f27c96014321e14766017b81a9f05f4e..098a026c2c665c803b9b1ef00d4c6dbbe9250f0a 100644 --- a/Examples/xx-messenger/Tests/AppFeatureTests/AppFeatureTests.swift +++ b/Examples/xx-messenger/Tests/AppFeatureTests/AppFeatureTests.swift @@ -10,7 +10,7 @@ import XXClient final class AppFeatureTests: XCTestCase { func testStartWithoutMessengerCreated() { - var actions: [Action] = [] + var actions: [Action]! let store = TestStore( initialState: AppState(), @@ -34,24 +34,29 @@ final class AppFeatureTests: XCTestCase { actions.append(.didStartMessageListener) return Cancellable {} } + store.environment.messenger.registerBackupCallback.run = { _ in + actions.append(.didRegisterBackupCallback) + return Cancellable {} + } + actions = [] store.send(.start) store.receive(.set(\.$screen, .welcome(WelcomeState()))) { $0.screen = .welcome(WelcomeState()) } - XCTAssertNoDifference(actions, [ .didMakeDB, .didStartAuthHandler, .didStartMessageListener, + .didRegisterBackupCallback, ]) store.send(.stop) } func testStartWithMessengerCreated() { - var actions: [Action] = [] + var actions: [Action]! let store = TestStore( initialState: AppState(), @@ -78,17 +83,22 @@ final class AppFeatureTests: XCTestCase { actions.append(.didStartMessageListener) return Cancellable {} } + store.environment.messenger.registerBackupCallback.run = { _ in + actions.append(.didRegisterBackupCallback) + return Cancellable {} + } + actions = [] store.send(.start) store.receive(.set(\.$screen, .home(HomeState()))) { $0.screen = .home(HomeState()) } - XCTAssertNoDifference(actions, [ .didMakeDB, .didStartAuthHandler, .didStartMessageListener, + .didRegisterBackupCallback, .didLoadMessenger, ]) @@ -96,7 +106,7 @@ final class AppFeatureTests: XCTestCase { } func testWelcomeFinished() { - var actions: [Action] = [] + var actions: [Action]! let store = TestStore( initialState: AppState( @@ -122,7 +132,12 @@ final class AppFeatureTests: XCTestCase { actions.append(.didStartMessageListener) return Cancellable {} } + store.environment.messenger.registerBackupCallback.run = { _ in + actions.append(.didRegisterBackupCallback) + return Cancellable {} + } + actions = [] store.send(.welcome(.finished)) { $0.screen = .loading } @@ -130,10 +145,10 @@ final class AppFeatureTests: XCTestCase { store.receive(.set(\.$screen, .home(HomeState()))) { $0.screen = .home(HomeState()) } - XCTAssertNoDifference(actions, [ .didStartAuthHandler, .didStartMessageListener, + .didRegisterBackupCallback, .didLoadMessenger, ]) @@ -141,7 +156,7 @@ final class AppFeatureTests: XCTestCase { } func testRestoreFinished() { - var actions: [Action] = [] + var actions: [Action]! let store = TestStore( initialState: AppState( @@ -167,7 +182,12 @@ final class AppFeatureTests: XCTestCase { actions.append(.didStartMessageListener) return Cancellable {} } + store.environment.messenger.registerBackupCallback.run = { _ in + actions.append(.didRegisterBackupCallback) + return Cancellable {} + } + actions = [] store.send(.restore(.finished)) { $0.screen = .loading } @@ -175,10 +195,10 @@ final class AppFeatureTests: XCTestCase { store.receive(.set(\.$screen, .home(HomeState()))) { $0.screen = .home(HomeState()) } - XCTAssertNoDifference(actions, [ .didStartAuthHandler, .didStartMessageListener, + .didRegisterBackupCallback, .didLoadMessenger, ]) @@ -186,7 +206,7 @@ final class AppFeatureTests: XCTestCase { } func testHomeDidDeleteAccount() { - var actions: [Action] = [] + var actions: [Action]! let store = TestStore( initialState: AppState( @@ -209,7 +229,12 @@ final class AppFeatureTests: XCTestCase { actions.append(.didStartMessageListener) return Cancellable {} } + store.environment.messenger.registerBackupCallback.run = { _ in + actions.append(.didRegisterBackupCallback) + return Cancellable {} + } + actions = [] store.send(.home(.deleteAccount(.success))) { $0.screen = .loading } @@ -217,10 +242,10 @@ final class AppFeatureTests: XCTestCase { store.receive(.set(\.$screen, .welcome(WelcomeState()))) { $0.screen = .welcome(WelcomeState()) } - XCTAssertNoDifference(actions, [ .didStartAuthHandler, .didStartMessageListener, + .didRegisterBackupCallback, ]) store.send(.stop) @@ -284,7 +309,7 @@ final class AppFeatureTests: XCTestCase { struct Failure: Error {} let error = Failure() - var actions: [Action] = [] + var actions: [Action]! let store = TestStore( initialState: AppState(), @@ -306,7 +331,12 @@ final class AppFeatureTests: XCTestCase { actions.append(.didStartMessageListener) return Cancellable {} } + store.environment.messenger.registerBackupCallback.run = { _ in + actions.append(.didRegisterBackupCallback) + return Cancellable {} + } + actions = [] store.send(.start) store.receive(.set(\.$screen, .failure(error.localizedDescription))) { @@ -316,15 +346,17 @@ final class AppFeatureTests: XCTestCase { XCTAssertNoDifference(actions, [ .didStartAuthHandler, .didStartMessageListener, + .didRegisterBackupCallback, ]) store.send(.stop) } func testStartHandlersAndListeners() { - var actions: [Action] = [] + var actions: [Action]! var authHandlerOnError: [AuthCallbackHandler.OnError] = [] var messageListenerOnError: [MessageListenerHandler.OnError] = [] + var backupCallback: [UpdateBackupFunc] = [] let store = TestStore( initialState: AppState(), @@ -351,22 +383,33 @@ final class AppFeatureTests: XCTestCase { actions.append(.didCancelMessageListener) } } + store.environment.messenger.registerBackupCallback.run = { callback in + backupCallback.append(callback) + actions.append(.didRegisterBackupCallback) + return Cancellable { + actions.append(.didCancelBackupCallback) + } + } store.environment.log.run = { msg, _, _, _ in actions.append(.didLog(msg)) } + store.environment.backupStorage.store = { data in + actions.append(.didStoreBackup(data)) + } + actions = [] store.send(.start) store.receive(.set(\.$screen, .home(HomeState()))) { $0.screen = .home(HomeState()) } - XCTAssertNoDifference(actions, [ .didStartAuthHandler, .didStartMessageListener, + .didRegisterBackupCallback, ]) - actions = [] + actions = [] store.send(.start) { $0.screen = .loading } @@ -374,15 +417,16 @@ final class AppFeatureTests: XCTestCase { store.receive(.set(\.$screen, .home(HomeState()))) { $0.screen = .home(HomeState()) } - XCTAssertNoDifference(actions, [ .didCancelAuthHandler, .didCancelMessageListener, + .didCancelBackupCallback, .didStartAuthHandler, .didStartMessageListener, + .didRegisterBackupCallback, ]) - actions = [] + actions = [] struct AuthError: Error {} let authError = AuthError() authHandlerOnError.first?(authError) @@ -390,8 +434,8 @@ final class AppFeatureTests: XCTestCase { XCTAssertNoDifference(actions, [ .didLog(.error(authError as NSError)) ]) - actions = [] + actions = [] struct MessageError: Error {} let messageError = MessageError() messageListenerOnError.first?(messageError) @@ -399,13 +443,22 @@ final class AppFeatureTests: XCTestCase { XCTAssertNoDifference(actions, [ .didLog(.error(messageError as NSError)) ]) + actions = [] + let backupData = "backup".data(using: .utf8)! + backupCallback.first?.handle(backupData) + XCTAssertNoDifference(actions, [ + .didStoreBackup(backupData), + ]) + + actions = [] store.send(.stop) XCTAssertNoDifference(actions, [ .didCancelAuthHandler, .didCancelMessageListener, + .didCancelBackupCallback, ]) } } @@ -414,8 +467,11 @@ private enum Action: Equatable { case didMakeDB case didStartAuthHandler case didStartMessageListener + case didRegisterBackupCallback case didLoadMessenger case didCancelAuthHandler case didCancelMessageListener + case didCancelBackupCallback case didLog(Logger.Message) + case didStoreBackup(Data) } diff --git a/Examples/xx-messenger/Tests/BackupFeatureTests/BackupFeatureTests.swift b/Examples/xx-messenger/Tests/BackupFeatureTests/BackupFeatureTests.swift new file mode 100644 index 0000000000000000000000000000000000000000..ebb1510bc0c4f8a602db808931fe2c66ee5d3589 --- /dev/null +++ b/Examples/xx-messenger/Tests/BackupFeatureTests/BackupFeatureTests.swift @@ -0,0 +1,452 @@ +import ComposableArchitecture +import XCTest +import XXClient +import XXMessengerClient +import XXModels +@testable import BackupFeature + +final class BackupFeatureTests: XCTestCase { + 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(.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) +} diff --git a/Examples/xx-messenger/Tests/HomeFeatureTests/HomeFeatureTests.swift b/Examples/xx-messenger/Tests/HomeFeatureTests/HomeFeatureTests.swift index b172793c5095ca4b8e68d1ab5c5e9cbe1c81fc1a..3a57414bb46df0058870adb5078aae2dfc41bf7c 100644 --- a/Examples/xx-messenger/Tests/HomeFeatureTests/HomeFeatureTests.swift +++ b/Examples/xx-messenger/Tests/HomeFeatureTests/HomeFeatureTests.swift @@ -1,4 +1,5 @@ import AppCore +import BackupFeature import ComposableArchitecture import ContactsFeature import CustomDump @@ -55,6 +56,7 @@ final class HomeFeatureTests: XCTestCase { var messengerDidConnect = 0 var messengerDidListenForMessages = 0 var messengerDidLogIn = 0 + var messengerDidResumeBackup = 0 store.environment.bgQueue = .immediate store.environment.mainQueue = .immediate @@ -66,6 +68,8 @@ final class HomeFeatureTests: XCTestCase { store.environment.messenger.isLoggedIn.run = { false } store.environment.messenger.isRegistered.run = { true } store.environment.messenger.logIn.run = { messengerDidLogIn += 1 } + store.environment.messenger.isBackupRunning.run = { false } + store.environment.messenger.resumeBackup.run = { messengerDidResumeBackup += 1 } store.environment.messenger.cMix.get = { var cMix: CMix = .unimplemented cMix.addHealthCallback.run = { _ in Cancellable {} } @@ -82,6 +86,7 @@ final class HomeFeatureTests: XCTestCase { XCTAssertNoDifference(messengerDidConnect, 1) XCTAssertNoDifference(messengerDidListenForMessages, 1) XCTAssertNoDifference(messengerDidLogIn, 1) + XCTAssertNoDifference(messengerDidResumeBackup, 1) store.receive(.networkMonitor(.stop)) store.receive(.messenger(.didStartRegistered)) @@ -110,6 +115,7 @@ final class HomeFeatureTests: XCTestCase { store.environment.messenger.isLoggedIn.run = { false } store.environment.messenger.isRegistered.run = { true } store.environment.messenger.logIn.run = { messengerDidLogIn += 1 } + store.environment.messenger.isBackupRunning.run = { true } store.environment.messenger.cMix.get = { var cMix: CMix = .unimplemented cMix.addHealthCallback.run = { _ in Cancellable {} } @@ -504,4 +510,30 @@ final class HomeFeatureTests: XCTestCase { $0.contacts = nil } } + + func testBackupButtonTapped() { + let store = TestStore( + initialState: HomeState(), + reducer: homeReducer, + environment: .unimplemented + ) + + store.send(.backupButtonTapped) { + $0.backup = BackupState() + } + } + + func testDidDismissBackup() { + let store = TestStore( + initialState: HomeState( + backup: BackupState() + ), + reducer: homeReducer, + environment: .unimplemented + ) + + store.send(.didDismissBackup) { + $0.backup = nil + } + } } diff --git a/Sources/XXMessengerClient/Messenger/Functions/MessengerCreate.swift b/Sources/XXMessengerClient/Messenger/Functions/MessengerCreate.swift index ed9d193d3592a10dd19a5a5d68d81576cb422f71..bee7f47245c6ebaabd1f615b63881abd6437fddc 100644 --- a/Sources/XXMessengerClient/Messenger/Functions/MessengerCreate.swift +++ b/Sources/XXMessengerClient/Messenger/Functions/MessengerCreate.swift @@ -16,7 +16,7 @@ extension MessengerCreate { let password = env.generateSecret() try env.passwordStorage.save(password) let storageDir = env.storageDir - try env.fileManager.removeDirectory(storageDir) + try env.fileManager.removeItem(storageDir) try env.fileManager.createDirectory(storageDir) try env.newCMix( ndfJSON: String(data: ndfData, encoding: .utf8)!, diff --git a/Sources/XXMessengerClient/Messenger/Functions/MessengerDestroy.swift b/Sources/XXMessengerClient/Messenger/Functions/MessengerDestroy.swift index 7d6932badafbae5128694b414df70a32c405fd02..89c472ce7c10a142cd49b559ff149af11997d09b 100644 --- a/Sources/XXMessengerClient/Messenger/Functions/MessengerDestroy.swift +++ b/Sources/XXMessengerClient/Messenger/Functions/MessengerDestroy.swift @@ -23,7 +23,7 @@ extension MessengerDestroy { env.e2e.set(nil) env.cMix.set(nil) env.isListeningForMessages.set(false) - try env.fileManager.removeDirectory(env.storageDir) + try env.fileManager.removeItem(env.storageDir) try env.passwordStorage.remove() } } diff --git a/Sources/XXMessengerClient/Messenger/Functions/MessengerIsBackupRunning.swift b/Sources/XXMessengerClient/Messenger/Functions/MessengerIsBackupRunning.swift index 08453e23cb8eddf2190ac49b5870489980b88765..4300ec19524ecb06596cdc836fa44f5257460140 100644 --- a/Sources/XXMessengerClient/Messenger/Functions/MessengerIsBackupRunning.swift +++ b/Sources/XXMessengerClient/Messenger/Functions/MessengerIsBackupRunning.swift @@ -19,6 +19,6 @@ extension MessengerIsBackupRunning { extension MessengerIsBackupRunning { public static let unimplemented = MessengerIsBackupRunning( - run: XCTUnimplemented("\(Self.self)") + run: XCTUnimplemented("\(Self.self)", placeholder: false) ) } diff --git a/Sources/XXMessengerClient/Messenger/Functions/MessengerRestoreBackup.swift b/Sources/XXMessengerClient/Messenger/Functions/MessengerRestoreBackup.swift index b196faca4b578bf3ea9bc534073be015b24dc816..e1f0ec10df6df4a601153cc4ecc6e8c80cb0419c 100644 --- a/Sources/XXMessengerClient/Messenger/Functions/MessengerRestoreBackup.swift +++ b/Sources/XXMessengerClient/Messenger/Functions/MessengerRestoreBackup.swift @@ -33,7 +33,7 @@ extension MessengerRestoreBackup { let ndfData = try env.downloadNDF(env.ndfEnvironment) let password = env.generateSecret() try env.passwordStorage.save(password) - try env.fileManager.removeDirectory(storageDir) + try env.fileManager.removeItem(storageDir) try env.fileManager.createDirectory(storageDir) let report = try env.newCMixFromBackup( ndfJSON: String(data: ndfData, encoding: .utf8)!, diff --git a/Sources/XXMessengerClient/Messenger/Functions/MessengerStartBackup.swift b/Sources/XXMessengerClient/Messenger/Functions/MessengerStartBackup.swift index 254bc6b9268f03c9c01d6ed8086d2c1e55c1fd89..1512d36100b25a17b2b75175520edf94940ef5a2 100644 --- a/Sources/XXMessengerClient/Messenger/Functions/MessengerStartBackup.swift +++ b/Sources/XXMessengerClient/Messenger/Functions/MessengerStartBackup.swift @@ -1,3 +1,4 @@ +import Foundation import XCTestDynamicOverlay import XXClient @@ -33,24 +34,26 @@ extension MessengerStartBackup { let paramsData = try params.encode() let paramsString = String(data: paramsData, encoding: .utf8)! var didAddParams = false - func addParams() { - guard let backup = env.backup() else { return } - backup.addJSON(paramsString) - didAddParams = true - } + var semaphore: DispatchSemaphore? = .init(value: 0) let backup = try env.initializeBackup( e2eId: e2e.getId(), udId: ud.getId(), password: password, callback: .init { data in + semaphore?.wait() if !didAddParams { - addParams() + if let backup = env.backup() { + backup.addJSON(paramsString) + didAddParams = true + } } else { env.backupCallbacks.registered().handle(data) } } ) env.backup.set(backup) + semaphore?.signal() + semaphore = nil } } } diff --git a/Sources/XXMessengerClient/Utils/BackupStorage.swift b/Sources/XXMessengerClient/Utils/BackupStorage.swift new file mode 100644 index 0000000000000000000000000000000000000000..e85e2e869b6610440b9531f70fd1a7d3fd42bd45 --- /dev/null +++ b/Sources/XXMessengerClient/Utils/BackupStorage.swift @@ -0,0 +1,78 @@ +import Foundation +import XCTestDynamicOverlay +import XXClient + +public struct BackupStorage { + public struct Backup: Equatable { + public init( + date: Date, + data: Data + ) { + self.date = date + self.data = data + } + + public var date: Date + public var data: Data + } + + public typealias Observer = (Backup?) -> Void + + public var store: (Data) throws -> Void + public var observe: (@escaping Observer) -> Cancellable + public var remove: () throws -> Void +} + +extension BackupStorage { + public static func onDisk( + now: @escaping () -> Date = Date.init, + fileManager: MessengerFileManager = .live(), + path: String = FileManager.default + .urls(for: .applicationSupportDirectory, in: .userDomainMask) + .first! + .appendingPathComponent("backup.xxm") + .path + ) -> BackupStorage { + var observers: [UUID: Observer] = [:] + var backup: Backup? + func notifyObservers() { + observers.values.forEach { $0(backup) } + } + if let fileData = try? fileManager.loadFile(path), + let fileDate = try? fileManager.modifiedTime(path) { + backup = Backup(date: fileDate, data: fileData) + } + return BackupStorage( + store: { data in + let newBackup = Backup( + date: now(), + data: data + ) + backup = newBackup + notifyObservers() + try fileManager.saveFile(path, newBackup.data) + }, + observe: { observer in + let id = UUID() + observers[id] = observer + defer { observers[id]?(backup) } + return Cancellable { + observers[id] = nil + } + }, + remove: { + backup = nil + notifyObservers() + try fileManager.removeItem(path) + } + ) + } +} + +extension BackupStorage { + public static let unimplemented = BackupStorage( + store: XCTUnimplemented("\(Self.self).store"), + observe: XCTUnimplemented("\(Self.self).observe", placeholder: Cancellable {}), + remove: XCTUnimplemented("\(Self.self).remove") + ) +} diff --git a/Sources/XXMessengerClient/Utils/MessengerFileManager.swift b/Sources/XXMessengerClient/Utils/MessengerFileManager.swift index 700990ea64b97c92175995ded858de7fa7486381..ad116d82a6f4a15df0021aa5f8b6ebdd845b7298 100644 --- a/Sources/XXMessengerClient/Utils/MessengerFileManager.swift +++ b/Sources/XXMessengerClient/Utils/MessengerFileManager.swift @@ -3,8 +3,11 @@ import XCTestDynamicOverlay public struct MessengerFileManager { public var isDirectoryEmpty: (String) -> Bool - public var removeDirectory: (String) throws -> Void + public var removeItem: (String) throws -> Void public var createDirectory: (String) throws -> Void + public var saveFile: (String, Data) throws -> Void + public var loadFile: (String) throws -> Data? + public var modifiedTime: (String) throws -> Date? } extension MessengerFileManager { @@ -16,7 +19,7 @@ extension MessengerFileManager { let contents = try? fileManager.contentsOfDirectory(atPath: path) return contents?.isEmpty ?? true }, - removeDirectory: { path in + removeItem: { path in if fileManager.fileExists(atPath: path) { try fileManager.removeItem(atPath: path) } @@ -26,6 +29,16 @@ extension MessengerFileManager { atPath: path, withIntermediateDirectories: true ) + }, + saveFile: { path, data in + try data.write(to: URL(fileURLWithPath: path)) + }, + loadFile: { path in + try Data(contentsOf: URL(fileURLWithPath: path)) + }, + modifiedTime: { path in + let attributes = try fileManager.attributesOfItem(atPath: path) + return attributes[.modificationDate] as? Date } ) } @@ -34,7 +47,10 @@ extension MessengerFileManager { extension MessengerFileManager { public static let unimplemented = MessengerFileManager( isDirectoryEmpty: XCTUnimplemented("\(Self.self).isDirectoryEmpty", placeholder: false), - removeDirectory: XCTUnimplemented("\(Self.self).removeDirectory"), - createDirectory: XCTUnimplemented("\(Self.self).createDirectory") + removeItem: XCTUnimplemented("\(Self.self).removeItem"), + createDirectory: XCTUnimplemented("\(Self.self).createDirectory"), + saveFile: XCTUnimplemented("\(Self.self).saveFile"), + loadFile: XCTUnimplemented("\(Self.self).loadFile"), + modifiedTime: XCTUnimplemented("\(Self.self).modifiedTime") ) } diff --git a/Tests/XXMessengerClientTests/Messenger/Functions/MessengerCreateTests.swift b/Tests/XXMessengerClientTests/Messenger/Functions/MessengerCreateTests.swift index 2180dda3db578b74a892b2166e66a9beb20357dd..0c8ce3bab33f2923976b113098da9c124c9cc2bf 100644 --- a/Tests/XXMessengerClientTests/Messenger/Functions/MessengerCreateTests.swift +++ b/Tests/XXMessengerClientTests/Messenger/Functions/MessengerCreateTests.swift @@ -15,7 +15,7 @@ final class MessengerCreateTests: XCTestCase { var didDownloadNDF: [NDFEnvironment] = [] var didGenerateSecret: [Int] = [] var didSavePassword: [Data] = [] - var didRemoveDirectory: [String] = [] + var didRemoveItem: [String] = [] var didCreateDirectory: [String] = [] var didNewCMix: [DidNewCMix] = [] @@ -37,8 +37,8 @@ final class MessengerCreateTests: XCTestCase { didSavePassword.append(password) } env.storageDir = storageDir - env.fileManager.removeDirectory = { path in - didRemoveDirectory.append(path) + env.fileManager.removeItem = { path in + didRemoveItem.append(path) } env.fileManager.createDirectory = { path in didCreateDirectory.append(path) @@ -58,7 +58,7 @@ final class MessengerCreateTests: XCTestCase { XCTAssertNoDifference(didDownloadNDF, [.unimplemented]) XCTAssertNoDifference(didGenerateSecret, [32]) XCTAssertNoDifference(didSavePassword, [password]) - XCTAssertNoDifference(didRemoveDirectory, [storageDir]) + XCTAssertNoDifference(didRemoveItem, [storageDir]) XCTAssertNoDifference(didCreateDirectory, [storageDir]) XCTAssertNoDifference(didNewCMix, [.init( ndfJSON: String(data: ndf, encoding: .utf8)!, @@ -108,7 +108,7 @@ final class MessengerCreateTests: XCTestCase { env.generateSecret.run = { _ in "password".data(using: .utf8)! } env.passwordStorage.save = { _ in } env.storageDir = "storage-dir" - env.fileManager.removeDirectory = { _ in throw error } + env.fileManager.removeItem = { _ in throw error } let create: MessengerCreate = .live(env) XCTAssertThrowsError(try create()) { err in @@ -126,7 +126,7 @@ final class MessengerCreateTests: XCTestCase { env.generateSecret.run = { _ in "password".data(using: .utf8)! } env.passwordStorage.save = { _ in } env.storageDir = "storage-dir" - env.fileManager.removeDirectory = { _ in } + env.fileManager.removeItem = { _ in } env.fileManager.createDirectory = { _ in throw error } let create: MessengerCreate = .live(env) @@ -145,7 +145,7 @@ final class MessengerCreateTests: XCTestCase { env.generateSecret.run = { _ in "password".data(using: .utf8)! } env.passwordStorage.save = { _ in } env.storageDir = "storage-dir" - env.fileManager.removeDirectory = { _ in } + env.fileManager.removeItem = { _ in } env.fileManager.createDirectory = { _ in } env.newCMix.run = { _, _, _, _ in throw error } let create: MessengerCreate = .live(env) diff --git a/Tests/XXMessengerClientTests/Messenger/Functions/MessengerDestroyTests.swift b/Tests/XXMessengerClientTests/Messenger/Functions/MessengerDestroyTests.swift index ccf999e77e6771724d17e27b6b43ce6a811fb8ae..99a52e8402ac9c93e71988adaf99610f21b1cb95 100644 --- a/Tests/XXMessengerClientTests/Messenger/Functions/MessengerDestroyTests.swift +++ b/Tests/XXMessengerClientTests/Messenger/Functions/MessengerDestroyTests.swift @@ -9,7 +9,7 @@ final class MessengerDestroyTests: XCTestCase { var hasRunningProcesses: [Bool] = [true, true, false] var didStopNetworkFollower = 0 var didSleep: [TimeInterval] = [] - var didRemoveDirectory: [String] = [] + var didRemoveItem: [String] = [] var didSetUD: [UserDiscovery?] = [] var didSetE2E: [E2E?] = [] var didSetCMix: [CMix?] = [] @@ -30,7 +30,7 @@ final class MessengerDestroyTests: XCTestCase { env.e2e.set = { didSetE2E.append($0) } env.cMix.set = { didSetCMix.append($0) } env.isListeningForMessages.set = { didSetIsListeningForMessages.append($0) } - env.fileManager.removeDirectory = { didRemoveDirectory.append($0) } + env.fileManager.removeItem = { didRemoveItem.append($0) } env.passwordStorage.remove = { didRemovePassword += 1 } let destroy: MessengerDestroy = .live(env) @@ -42,7 +42,7 @@ final class MessengerDestroyTests: XCTestCase { XCTAssertNoDifference(didSetE2E.map { $0 == nil }, [true]) XCTAssertNoDifference(didSetCMix.map { $0 == nil }, [true]) XCTAssertNoDifference(didSetIsListeningForMessages, [false]) - XCTAssertNoDifference(didRemoveDirectory, [storageDir]) + XCTAssertNoDifference(didRemoveItem, [storageDir]) XCTAssertNoDifference(didRemovePassword, 1) } @@ -78,7 +78,7 @@ final class MessengerDestroyTests: XCTestCase { env.e2e.set = { didSetE2E.append($0) } env.cMix.set = { didSetCMix.append($0) } env.isListeningForMessages.set = { didSetIsListeningForMessages.append($0) } - env.fileManager.removeDirectory = { _ in throw error } + env.fileManager.removeItem = { _ in throw error } let destroy: MessengerDestroy = .live(env) XCTAssertThrowsError(try destroy()) { err in @@ -94,7 +94,7 @@ final class MessengerDestroyTests: XCTestCase { struct Error: Swift.Error, Equatable {} let error = Error() let storageDir = "test-storage-dir" - var didRemoveDirectory: [String] = [] + var didRemoveItem: [String] = [] var didSetUD: [UserDiscovery?] = [] var didSetE2E: [E2E?] = [] var didSetCMix: [CMix?] = [] @@ -107,7 +107,7 @@ final class MessengerDestroyTests: XCTestCase { env.cMix.set = { didSetCMix.append($0) } env.isListeningForMessages.set = { didSetIsListeningForMessages.append($0) } env.storageDir = storageDir - env.fileManager.removeDirectory = { didRemoveDirectory.append($0) } + env.fileManager.removeItem = { didRemoveItem.append($0) } env.passwordStorage.remove = { throw error } let destroy: MessengerDestroy = .live(env) @@ -118,6 +118,6 @@ final class MessengerDestroyTests: XCTestCase { XCTAssertNoDifference(didSetE2E.map { $0 == nil }, [true]) XCTAssertNoDifference(didSetCMix.map { $0 == nil }, [true]) XCTAssertNoDifference(didSetIsListeningForMessages, [false]) - XCTAssertNoDifference(didRemoveDirectory, [storageDir]) + XCTAssertNoDifference(didRemoveItem, [storageDir]) } } diff --git a/Tests/XXMessengerClientTests/Messenger/Functions/MessengerRestoreBackupTests.swift b/Tests/XXMessengerClientTests/Messenger/Functions/MessengerRestoreBackupTests.swift index 672adc37550225bdab6a9ea7953c2b5a0b86b248..7e55ddc330cda16e776b36db8abc796e24b9a3d4 100644 --- a/Tests/XXMessengerClientTests/Messenger/Functions/MessengerRestoreBackupTests.swift +++ b/Tests/XXMessengerClientTests/Messenger/Functions/MessengerRestoreBackupTests.swift @@ -40,7 +40,7 @@ final class MessengerRestoreBackupTests: XCTestCase { env.generateSecret.run = { _ in password } env.passwordStorage.save = { caughtActions.append(.didSavePassword(password: $0)) } env.passwordStorage.load = { password } - env.fileManager.removeDirectory = { caughtActions.append(.didRemoveDirectory(path: $0)) } + env.fileManager.removeItem = { caughtActions.append(.didRemoveItem(path: $0)) } env.fileManager.createDirectory = { caughtActions.append(.didCreateDirectory(path: $0)) } env.newCMixFromBackup.run = { ndfJSON, storageDir, backupPassphrase, sessionPassword, backupFileContents in @@ -114,7 +114,7 @@ final class MessengerRestoreBackupTests: XCTestCase { .didSavePassword( password: password ), - .didRemoveDirectory( + .didRemoveItem( path: env.storageDir ), .didCreateDirectory( @@ -185,7 +185,7 @@ private enum CaughtAction: Equatable { case didSavePassword( password: Data ) - case didRemoveDirectory( + case didRemoveItem( path: String ) case didCreateDirectory( diff --git a/Tests/XXMessengerClientTests/Utils/BackupStorageTests.swift b/Tests/XXMessengerClientTests/Utils/BackupStorageTests.swift new file mode 100644 index 0000000000000000000000000000000000000000..d373ce6e3a88f1644e94ea735a5805dd039631e7 --- /dev/null +++ b/Tests/XXMessengerClientTests/Utils/BackupStorageTests.swift @@ -0,0 +1,113 @@ +import CustomDump +import XCTest +@testable import XXMessengerClient + +final class BackupStorageTests: XCTestCase { + func testStorage() throws { + var actions: [Action]! + + var now: Date = .init(0) + let path = "backup-path" + let fileData = "file-data".data(using: .utf8)! + let fileDate = Date(123) + var fileManager = MessengerFileManager.unimplemented + fileManager.loadFile = { path in + actions.append(.didLoadFile(path)) + return fileData + } + fileManager.modifiedTime = { path in + actions.append(.didGetModifiedTime(path)) + return fileDate + } + fileManager.saveFile = { path, data in + actions.append(.didSaveFile(path, data)) + } + fileManager.removeItem = { path in + actions.append(.didRemoveItem(path)) + } + actions = [] + let storage: BackupStorage = .onDisk( + now: { now }, + fileManager: fileManager, + path: path + ) + + XCTAssertNoDifference(actions, [ + .didLoadFile(path), + .didGetModifiedTime(path), + ]) + + actions = [] + let observerA = storage.observe { backup in + actions.append(.didObserve("A", backup)) + } + + XCTAssertNoDifference(actions, [ + .didObserve("A", .init(date: fileDate, data: fileData)) + ]) + + actions = [] + now = .init(1) + let data1 = "data-1".data(using: .utf8)! + try storage.store(data1) + + XCTAssertNoDifference(actions, [ + .didObserve("A", .init(date: .init(1), data: data1)), + .didSaveFile(path, data1), + ]) + + actions = [] + now = .init(2) + let observerB = storage.observe { backup in + actions.append(.didObserve("B", backup)) + } + + XCTAssertNoDifference(actions, [ + .didObserve("B", .init(date: .init(1), data: data1)) + ]) + + actions = [] + now = .init(3) + observerA.cancel() + let data2 = "data-2".data(using: .utf8)! + try storage.store(data2) + + XCTAssertNoDifference(actions, [ + .didObserve("B", .init(date: .init(3), data: data2)), + .didSaveFile(path, data2), + ]) + + actions = [] + now = .init(4) + try storage.remove() + + XCTAssertNoDifference(actions, [ + .didObserve("B", nil), + .didRemoveItem(path), + ]) + + actions = [] + now = .init(5) + observerB.cancel() + let data3 = "data-3".data(using: .utf8)! + try storage.store(data3) + + XCTAssertNoDifference(actions, [ + .didSaveFile(path, data3), + ]) + } +} + +private extension Date { + init(_ timeIntervalSince1970: TimeInterval) { + self.init(timeIntervalSince1970: timeIntervalSince1970) + } +} + +private enum Action: Equatable { + case didLoadFile(String) + case didGetModifiedTime(String) + case didObserve(String, BackupStorage.Backup?) + case didSaveFile(String, Data) + case didRemoveItem(String) +}