diff --git a/Examples/xx-messenger/Package.swift b/Examples/xx-messenger/Package.swift index c0a4f9054a911c82a801544c2ff17e4629c0ecfb..f552abe40f22920d1358f3487de7207633d272af 100644 --- a/Examples/xx-messenger/Package.swift +++ b/Examples/xx-messenger/Package.swift @@ -273,6 +273,7 @@ let package = Package( name: "RestoreFeature", dependencies: [ .product(name: "ComposableArchitecture", package: "swift-composable-architecture"), + .product(name: "XXMessengerClient", package: "elixxir-dapps-sdk-swift"), ], swiftSettings: swiftSettings ), diff --git a/Examples/xx-messenger/Sources/AppFeature/AppEnvironment+Live.swift b/Examples/xx-messenger/Sources/AppFeature/AppEnvironment+Live.swift index d824d6d400b402270d341bb43fbd5f363c84a879..e00d9d8231fd35509eabc64d79c22f2de715646b 100644 --- a/Examples/xx-messenger/Sources/AppFeature/AppEnvironment+Live.swift +++ b/Examples/xx-messenger/Sources/AppFeature/AppEnvironment+Live.swift @@ -95,7 +95,12 @@ extension AppEnvironment { ) }, restore: { - RestoreEnvironment() + RestoreEnvironment( + messenger: messenger, + loadData: .live, + mainQueue: mainQueue, + bgQueue: bgQueue + ) }, home: { HomeEnvironment( diff --git a/Examples/xx-messenger/Sources/RestoreFeature/RestoreFeature.swift b/Examples/xx-messenger/Sources/RestoreFeature/RestoreFeature.swift index 6ce31e5d67acfde939a1bc9ba03e28226f4e0761..5339c89bbd3dba39edaf2cc8f0181e673b396f4b 100644 --- a/Examples/xx-messenger/Sources/RestoreFeature/RestoreFeature.swift +++ b/Examples/xx-messenger/Sources/RestoreFeature/RestoreFeature.swift @@ -1,19 +1,147 @@ +import Combine import ComposableArchitecture +import Foundation +import XCTestDynamicOverlay +import XXMessengerClient public struct RestoreState: Equatable { - public init() {} + public enum Field: String, Hashable { + case passphrase + } + + public struct File: Equatable { + public init(name: String, data: Data) { + self.name = name + self.data = data + } + + public var name: String + public var data: Data + } + + public init( + file: File? = nil, + fileImportFailure: String? = nil, + restoreFailure: String? = nil, + focusedField: Field? = nil, + isImportingFile: Bool = false, + passphrase: String = "", + isRestoring: Bool = false + ) { + self.file = file + self.fileImportFailure = fileImportFailure + self.restoreFailure = restoreFailure + self.focusedField = focusedField + self.isImportingFile = isImportingFile + self.passphrase = passphrase + self.isRestoring = isRestoring + } + + public var file: File? + public var fileImportFailure: String? + public var restoreFailure: String? + @BindableState public var focusedField: Field? + @BindableState public var isImportingFile: Bool + @BindableState public var passphrase: String + @BindableState public var isRestoring: Bool } -public enum RestoreAction: Equatable { +public enum RestoreAction: Equatable, BindableAction { + case importFileTapped + case fileImport(Result<URL, NSError>) + case restoreTapped case finished + case failed(NSError) + case binding(BindingAction<RestoreState>) } public struct RestoreEnvironment { - public init() {} + public init( + messenger: Messenger, + loadData: URLDataLoader, + mainQueue: AnySchedulerOf<DispatchQueue>, + bgQueue: AnySchedulerOf<DispatchQueue> + ) { + self.messenger = messenger + self.loadData = loadData + self.mainQueue = mainQueue + self.bgQueue = bgQueue + } + + public var messenger: Messenger + public var loadData: URLDataLoader + public var mainQueue: AnySchedulerOf<DispatchQueue> + public var bgQueue: AnySchedulerOf<DispatchQueue> } extension RestoreEnvironment { - public static let unimplemented = RestoreEnvironment() + public static let unimplemented = RestoreEnvironment( + messenger: .unimplemented, + loadData: .unimplemented, + mainQueue: .unimplemented, + bgQueue: .unimplemented + ) } -public let restoreReducer = Reducer<RestoreState, RestoreAction, RestoreEnvironment>.empty +public let restoreReducer = Reducer<RestoreState, RestoreAction, RestoreEnvironment> +{ state, action, env in + switch action { + case .importFileTapped: + state.isImportingFile = true + state.fileImportFailure = nil + return .none + + case .fileImport(.success(let url)): + state.isImportingFile = false + do { + state.file = .init( + name: url.lastPathComponent, + data: try env.loadData(url) + ) + state.fileImportFailure = nil + } catch { + state.file = nil + state.fileImportFailure = error.localizedDescription + } + return .none + + case .fileImport(.failure(let error)): + state.isImportingFile = false + state.file = nil + state.fileImportFailure = error.localizedDescription + return .none + + case .restoreTapped: + guard let backupData = state.file?.data, backupData.count > 0 else { return .none } + let backupPassphrase = state.passphrase + state.isRestoring = true + state.restoreFailure = nil + return Effect.result { + do { + _ = try env.messenger.restoreBackup( + backupData: backupData, + backupPassphrase: backupPassphrase + ) + return .success(.finished) + } catch { + return .success(.failed(error as NSError)) + } + } + .subscribe(on: env.bgQueue) + .receive(on: env.mainQueue) + .eraseToEffect() + + case .finished: + state.isRestoring = false + return .none + + case .failed(let error): + state.isRestoring = false + state.restoreFailure = error.localizedDescription + return .none + + case .binding(_): + return .none + } +} +.binding() diff --git a/Examples/xx-messenger/Sources/RestoreFeature/RestoreView.swift b/Examples/xx-messenger/Sources/RestoreFeature/RestoreView.swift index b2cf3e86cdaa2db59c2ce37eb3c8b33f0da38868..06f7b91515725dcd81157dd876b3bcba2783a8e1 100644 --- a/Examples/xx-messenger/Sources/RestoreFeature/RestoreView.swift +++ b/Examples/xx-messenger/Sources/RestoreFeature/RestoreView.swift @@ -7,9 +7,31 @@ public struct RestoreView: View { } let store: Store<RestoreState, RestoreAction> + @FocusState var focusedField: RestoreState.Field? struct ViewState: Equatable { - init(state: RestoreState) {} + struct File: Equatable { + var name: String + var size: Int + } + + var file: File? + var isImportingFile: Bool + var passphrase: String + var isRestoring: Bool + var focusedField: RestoreState.Field? + var fileImportFailure: String? + var restoreFailure: String? + + init(state: RestoreState) { + file = state.file.map { .init(name: $0.name, size: $0.data.count) } + isImportingFile = state.isImportingFile + passphrase = state.passphrase + isRestoring = state.isRestoring + focusedField = state.focusedField + fileImportFailure = state.fileImportFailure + restoreFailure = state.restoreFailure + } } public var body: some View { @@ -17,23 +39,94 @@ public struct RestoreView: View { NavigationView { Form { Section { - Text("Not implemented") + if let file = viewStore.file { + HStack(alignment: .bottom) { + Text(file.name) + Spacer() + Text(format(byteCount: file.size)) + } + } + + Button { + viewStore.send(.importFileTapped) + } label: { + Text("Import backup file") + } + .fileImporter( + isPresented: viewStore.binding( + get: \.isImportingFile, + send: { .set(\.$isImportingFile, $0) } + ), + allowedContentTypes: [.data], + onCompletion: { result in + viewStore.send(.fileImport(result.mapError { $0 as NSError })) + } + ) + + if let failure = viewStore.fileImportFailure { + Text("Error: \(failure)") + } + } header: { + Text("File") } + .disabled(viewStore.isRestoring) - Section { + if viewStore.file != nil { + Section { + SecureField("Passphrase", text: viewStore.binding( + get: \.passphrase, + send: { .set(\.$passphrase, $0) } + )) + .textContentType(.password) + .textInputAutocapitalization(.never) + .disableAutocorrection(true) + .focused($focusedField, equals: .passphrase) + + Button { + viewStore.send(.restoreTapped) + } label: { + HStack { + Text("Restore") + Spacer() + if viewStore.isRestoring { + ProgressView() + } + } + } + + if let failure = viewStore.restoreFailure { + Text("Error: \(failure)") + } + } header: { + Text("Backup") + } + .disabled(viewStore.isRestoring) + } + } + .toolbar { + ToolbarItem(placement: .cancellationAction) { Button { viewStore.send(.finished) } label: { - Text("OK") - .frame(maxWidth: .infinity) + Text("Cancel") } + .disabled(viewStore.isRestoring) } } .navigationTitle("Restore") + .onChange(of: viewStore.focusedField) { focusedField = $0 } + .onChange(of: focusedField) { viewStore.send(.set(\.$focusedField, $0)) } } .navigationViewStyle(.stack) } } + + func format(byteCount: Int) -> String { + let formatter = ByteCountFormatter() + formatter.allowedUnits = [.useMB, .useKB, .useBytes] + formatter.countStyle = .binary + return formatter.string(fromByteCount: Int64(byteCount)) + } } #if DEBUG diff --git a/Examples/xx-messenger/Sources/RestoreFeature/URLDataLoader.swift b/Examples/xx-messenger/Sources/RestoreFeature/URLDataLoader.swift new file mode 100644 index 0000000000000000000000000000000000000000..bca96e58f59d9880aca1f38d100192fdf9c97f47 --- /dev/null +++ b/Examples/xx-messenger/Sources/RestoreFeature/URLDataLoader.swift @@ -0,0 +1,22 @@ +import Foundation +import XCTestDynamicOverlay + +public struct URLDataLoader { + public var load: (URL) throws -> Data + + public func callAsFunction(_ url: URL) throws -> Data { + try load(url) + } +} + +extension URLDataLoader { + public static let live = URLDataLoader { url in + try Data(contentsOf: url) + } +} + +extension URLDataLoader { + public static let unimplemented = URLDataLoader( + load: XCTUnimplemented("\(Self.self)") + ) +} diff --git a/Examples/xx-messenger/Tests/RestoreFeatureTests/RestoreFeatureTests.swift b/Examples/xx-messenger/Tests/RestoreFeatureTests/RestoreFeatureTests.swift index c3e77edbf64de2d8df6e3cfb518b610055928397..9a3cde3227357eb11d878c8c8f7fd7cc76f00fb5 100644 --- a/Examples/xx-messenger/Tests/RestoreFeatureTests/RestoreFeatureTests.swift +++ b/Examples/xx-messenger/Tests/RestoreFeatureTests/RestoreFeatureTests.swift @@ -1,15 +1,161 @@ +import CustomDump import ComposableArchitecture import XCTest @testable import RestoreFeature +import XXMessengerClient final class RestoreFeatureTests: XCTestCase { - func testFinish() { + func testFileImport() { + let fileURL = URL(string: "file-url")! + var didLoadDataFromURL: [URL] = [] + let dataFromURL = "data-from-url".data(using: .utf8)! + let store = TestStore( initialState: RestoreState(), reducer: restoreReducer, environment: .unimplemented ) - store.send(.finished) + store.environment.loadData.load = { url in + didLoadDataFromURL.append(url) + return dataFromURL + } + + store.send(.importFileTapped) { + $0.isImportingFile = true + } + + store.send(.fileImport(.success(fileURL))) { + $0.isImportingFile = false + $0.file = .init(name: fileURL.lastPathComponent, data: dataFromURL) + $0.fileImportFailure = nil + } + + XCTAssertNoDifference(didLoadDataFromURL, [fileURL]) + } + + func testFileImportFailure() { + struct Failure: Error {} + let failure = Failure() + + let store = TestStore( + initialState: RestoreState( + isImportingFile: true + ), + reducer: restoreReducer, + environment: .unimplemented + ) + + store.send(.fileImport(.failure(failure as NSError))) { + $0.isImportingFile = false + $0.file = nil + $0.fileImportFailure = failure.localizedDescription + } + } + + func testFileImportLoadingFailure() { + struct Failure: Error {} + let failure = Failure() + + let store = TestStore( + initialState: RestoreState( + isImportingFile: true + ), + reducer: restoreReducer, + environment: .unimplemented + ) + + store.environment.loadData.load = { _ in throw failure } + + store.send(.fileImport(.success(URL(string: "test")!))) { + $0.isImportingFile = false + $0.file = nil + $0.fileImportFailure = failure.localizedDescription + } + } + + func testRestore() { + let backupData = "backup-data".data(using: .utf8)! + let backupPassphrase = "backup-passphrase" + let restoreResult = MessengerRestoreBackup.Result( + restoredParams: BackupParams.init( + username: "", + email: nil, + phone: nil + ), + restoredContacts: [] + ) + + var didRestoreWithData: [Data] = [] + var didRestoreWithPassphrase: [String] = [] + + let store = TestStore( + initialState: RestoreState( + file: .init(name: "file-name", data: backupData) + ), + reducer: restoreReducer, + environment: .unimplemented + ) + + store.environment.bgQueue = .immediate + store.environment.mainQueue = .immediate + store.environment.messenger.restoreBackup.run = { data, passphrase in + didRestoreWithData.append(data) + didRestoreWithPassphrase.append(passphrase) + return restoreResult + } + + store.send(.set(\.$passphrase, backupPassphrase)) { + $0.passphrase = backupPassphrase + } + + store.send(.restoreTapped) { + $0.isRestoring = true + } + + XCTAssertNoDifference(didRestoreWithData, [backupData]) + XCTAssertNoDifference(didRestoreWithPassphrase, [backupPassphrase]) + + store.receive(.finished) { + $0.isRestoring = false + } + } + + func testRestoreWithoutFile() { + let store = TestStore( + initialState: RestoreState( + file: nil + ), + reducer: restoreReducer, + environment: .unimplemented + ) + + store.send(.restoreTapped) + } + + func testRestoreFailure() { + struct Failure: Error {} + let failure = Failure() + + let store = TestStore( + initialState: RestoreState( + file: .init(name: "name", data: "data".data(using: .utf8)!) + ), + reducer: restoreReducer, + environment: .unimplemented + ) + + store.environment.bgQueue = .immediate + store.environment.mainQueue = .immediate + store.environment.messenger.restoreBackup.run = { _, _ in throw failure } + + store.send(.restoreTapped) { + $0.isRestoring = true + } + + store.receive(.failed(failure as NSError)) { + $0.isRestoring = false + $0.restoreFailure = failure.localizedDescription + } } }