From 6e220b88eca8b6ba42dc2cbd73f1c2b99a73bd99 Mon Sep 17 00:00:00 2001 From: Dariusz Rybicki <dariusz@elixxir.io> Date: Fri, 30 Sep 2022 00:15:46 +0200 Subject: [PATCH] Restore contacts from backup --- .../RestoreFeature/RestoreFeature.swift | 41 ++++- .../Sources/RestoreFeature/RestoreView.swift | 162 +++++++++++------- .../RestoreFeatureTests.swift | 84 +++++++-- 3 files changed, 202 insertions(+), 85 deletions(-) diff --git a/Examples/xx-messenger/Sources/RestoreFeature/RestoreFeature.swift b/Examples/xx-messenger/Sources/RestoreFeature/RestoreFeature.swift index 66d7df2f..7179264a 100644 --- a/Examples/xx-messenger/Sources/RestoreFeature/RestoreFeature.swift +++ b/Examples/xx-messenger/Sources/RestoreFeature/RestoreFeature.swift @@ -24,7 +24,7 @@ public struct RestoreState: Equatable { public init( file: File? = nil, fileImportFailure: String? = nil, - restoreFailure: String? = nil, + restoreFailures: [String] = [], focusedField: Field? = nil, isImportingFile: Bool = false, passphrase: String = "", @@ -32,7 +32,7 @@ public struct RestoreState: Equatable { ) { self.file = file self.fileImportFailure = fileImportFailure - self.restoreFailure = restoreFailure + self.restoreFailures = restoreFailures self.focusedField = focusedField self.isImportingFile = isImportingFile self.passphrase = passphrase @@ -41,7 +41,7 @@ public struct RestoreState: Equatable { public var file: File? public var fileImportFailure: String? - public var restoreFailure: String? + public var restoreFailures: [String] @BindableState public var focusedField: Field? @BindableState public var isImportingFile: Bool @BindableState public var passphrase: String @@ -53,7 +53,7 @@ public enum RestoreAction: Equatable, BindableAction { case fileImport(Result<URL, NSError>) case restoreTapped case finished - case failed(NSError) + case failed([NSError]) case binding(BindingAction<RestoreState>) } @@ -125,7 +125,7 @@ public let restoreReducer = Reducer<RestoreState, RestoreAction, RestoreEnvironm guard let backupData = state.file?.data, backupData.count > 0 else { return .none } let backupPassphrase = state.passphrase state.isRestoring = true - state.restoreFailure = nil + state.restoreFailures = [] return Effect.result { do { let result = try env.messenger.restoreBackup( @@ -140,10 +140,33 @@ public let restoreReducer = Reducer<RestoreState, RestoreAction, RestoreEnvironm phone: facts.get(.phone)?.value, createdAt: env.now() )) + let lookupResult = try env.messenger.lookupContacts.callAsFunction( + ids: result.restoredContacts + ) + try lookupResult.contacts.forEach { contact in + let facts = try contact.getFacts() + try env.db().saveContact(XXModels.Contact( + id: try contact.getId(), + marshaled: contact.data, + username: facts.get(.username)?.value, + email: facts.get(.email)?.value, + phone: facts.get(.phone)?.value, + authStatus: .friend, + createdAt: env.now() + )) + } + guard lookupResult.errors.isEmpty else { + return .success(.failed(lookupResult.errors)) + } return .success(.finished) } catch { - try? env.messenger.destroy() - return .success(.failed(error as NSError)) + var errors = [error as NSError] + do { + try env.messenger.destroy() + } catch { + errors.append(error as NSError) + } + return .success(.failed(errors)) } } .subscribe(on: env.bgQueue) @@ -154,9 +177,9 @@ public let restoreReducer = Reducer<RestoreState, RestoreAction, RestoreEnvironm state.isRestoring = false return .none - case .failed(let error): + case .failed(let errors): state.isRestoring = false - state.restoreFailure = error.localizedDescription + state.restoreFailures = errors.map(\.localizedDescription) return .none case .binding(_): diff --git a/Examples/xx-messenger/Sources/RestoreFeature/RestoreView.swift b/Examples/xx-messenger/Sources/RestoreFeature/RestoreView.swift index 06f7b915..4dcaa5dc 100644 --- a/Examples/xx-messenger/Sources/RestoreFeature/RestoreView.swift +++ b/Examples/xx-messenger/Sources/RestoreFeature/RestoreView.swift @@ -21,7 +21,7 @@ public struct RestoreView: View { var isRestoring: Bool var focusedField: RestoreState.Field? var fileImportFailure: String? - var restoreFailure: String? + var restoreFailures: [String] init(state: RestoreState) { file = state.file.map { .init(name: $0.name, size: $0.data.count) } @@ -30,7 +30,7 @@ public struct RestoreView: View { isRestoring = state.isRestoring focusedField = state.focusedField fileImportFailure = state.fileImportFailure - restoreFailure = state.restoreFailure + restoreFailures = state.restoreFailures } } @@ -38,69 +38,9 @@ public struct RestoreView: View { WithViewStore(store, observe: ViewState.init) { viewStore in NavigationView { Form { - Section { - 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) - + fileSection(viewStore) 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) + restoreSection(viewStore) } } .toolbar { @@ -110,7 +50,7 @@ public struct RestoreView: View { } label: { Text("Cancel") } - .disabled(viewStore.isRestoring) + .disabled(viewStore.isImportingFile || viewStore.isRestoring) } } .navigationTitle("Restore") @@ -121,6 +61,84 @@ public struct RestoreView: View { } } + @ViewBuilder func fileSection(_ viewStore: ViewStore<ViewState, RestoreAction>) -> some View { + Section { + if let file = viewStore.file { + HStack(alignment: .bottom) { + Text(file.name) + Spacer() + Text(format(byteCount: file.size)) + } + } else { + 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 })) + } + ) + .disabled(viewStore.isRestoring) + } + } header: { + Text("File") + } + + if let failure = viewStore.fileImportFailure { + Section { + Text(failure) + } header: { + Text("Error") + } + } + } + + @ViewBuilder func restoreSection(_ viewStore: ViewStore<ViewState, RestoreAction>) -> some View { + Section { + SecureField("Passphrase", text: viewStore.binding( + get: \.passphrase, + send: { .set(\.$passphrase, $0) } + )) + .textContentType(.password) + .textInputAutocapitalization(.never) + .disableAutocorrection(true) + .focused($focusedField, equals: .passphrase) + .disabled(viewStore.isRestoring) + + Button { + viewStore.send(.restoreTapped) + } label: { + HStack { + Text("Restore") + Spacer() + if viewStore.isRestoring { + ProgressView() + } + } + } + } header: { + Text("Restore") + } + + if !viewStore.restoreFailures.isEmpty { + Section { + ForEach(Array(viewStore.restoreFailures.enumerated()), id: \.offset) { _, failure in + Text(failure) + } + .font(.footnote) + } header: { + Text("Error") + } + } + } + func format(byteCount: Int) -> String { let formatter = ByteCountFormatter() formatter.allowedUnits = [.useMB, .useKB, .useBytes] @@ -133,7 +151,19 @@ public struct RestoreView: View { public struct RestoreView_Previews: PreviewProvider { public static var previews: some View { RestoreView(store: Store( - initialState: RestoreState(), + initialState: RestoreState( + file: .init(name: "preview", data: Data()), + fileImportFailure: nil, + restoreFailures: [ + "Preview failure 1", + "Preview failure 2", + "Preview failure 3", + ], + focusedField: nil, + isImportingFile: false, + passphrase: "", + isRestoring: false + ), reducer: .empty, environment: () )) diff --git a/Examples/xx-messenger/Tests/RestoreFeatureTests/RestoreFeatureTests.swift b/Examples/xx-messenger/Tests/RestoreFeatureTests/RestoreFeatureTests.swift index 9b0a7b87..5ec8409e 100644 --- a/Examples/xx-messenger/Tests/RestoreFeatureTests/RestoreFeatureTests.swift +++ b/Examples/xx-messenger/Tests/RestoreFeatureTests/RestoreFeatureTests.swift @@ -85,15 +85,22 @@ final class RestoreFeatureTests: XCTestCase { ] let restoreResult = MessengerRestoreBackup.Result( restoredParams: BackupParams(username: "restored-username"), - restoredContacts: [] + restoredContacts: [ + "contact-1-id".data(using: .utf8)!, + "contact-2-id".data(using: .utf8)!, + "contact-3-id".data(using: .utf8)!, + ] ) + let lookedUpContacts = [XXClient.Contact.stub(1), .stub(2), .stub(3)] let now = Date() let contactId = "contact-id".data(using: .utf8)! + var udFacts: [Fact] = [] var didRestoreWithData: [Data] = [] var didRestoreWithPassphrase: [String] = [] var didSaveContact: [XXModels.Contact] = [] + var didLookupContactIds: [[Data]] = [] let store = TestStore( initialState: RestoreState( @@ -126,6 +133,14 @@ final class RestoreFeatureTests: XCTestCase { ud.getFacts.run = { udFacts } return ud } + store.environment.messenger.lookupContacts.run = { contactIds in + didLookupContactIds.append(contactIds) + return .init( + contacts: lookedUpContacts, + failedIds: [], + errors: [] + ) + } store.environment.db.run = { var db: Database = .unimplemented db.saveContact.run = { contact in @@ -145,13 +160,43 @@ final class RestoreFeatureTests: XCTestCase { XCTAssertNoDifference(didRestoreWithData, [backupData]) XCTAssertNoDifference(didRestoreWithPassphrase, [backupPassphrase]) - XCTAssertNoDifference(didSaveContact, [Contact( - id: contactId, - username: restoreResult.restoredParams.username, - email: restoredFacts.get(.email)?.value, - phone: restoredFacts.get(.phone)?.value, - createdAt: now - )]) + XCTAssertNoDifference(didLookupContactIds, [restoreResult.restoredContacts]) + XCTAssertNoDifference(didSaveContact, [ + Contact( + id: contactId, + username: restoreResult.restoredParams.username, + email: restoredFacts.get(.email)?.value, + phone: restoredFacts.get(.phone)?.value, + createdAt: now + ), + Contact( + id: "contact-\(1)-id".data(using: .utf8)!, + marshaled: "contact-\(1)-data".data(using: .utf8)!, + username: "contact-\(1)-username", + email: "contact-\(1)-email", + phone: "contact-\(1)-phone", + authStatus: .friend, + createdAt: now + ), + Contact( + id: "contact-\(2)-id".data(using: .utf8)!, + marshaled: "contact-\(2)-data".data(using: .utf8)!, + username: "contact-\(2)-username", + email: "contact-\(2)-email", + phone: "contact-\(2)-phone", + authStatus: .friend, + createdAt: now + ), + Contact( + id: "contact-\(3)-id".data(using: .utf8)!, + marshaled: "contact-\(3)-data".data(using: .utf8)!, + username: "contact-\(3)-username", + email: "contact-\(3)-email", + phone: "contact-\(3)-phone", + authStatus: .friend, + createdAt: now + ), + ]) store.receive(.finished) { $0.isRestoring = false @@ -195,9 +240,28 @@ final class RestoreFeatureTests: XCTestCase { XCTAssertEqual(didDestroyMessenger, 1) - store.receive(.failed(failure as NSError)) { + store.receive(.failed([failure as NSError])) { $0.isRestoring = false - $0.restoreFailure = failure.localizedDescription + $0.restoreFailures = [failure.localizedDescription] + } + } +} + +private extension XXClient.Contact { + static func stub(_ id: Int) -> XXClient.Contact { + var contact = XXClient.Contact.unimplemented( + "contact-\(id)-data".data(using: .utf8)! + ) + contact.getIdFromContact.run = { _ in + "contact-\(id)-id".data(using: .utf8)! + } + contact.getFactsFromContact.run = { _ in + [ + Fact(type: .username, value: "contact-\(id)-username"), + Fact(type: .email, value: "contact-\(id)-email"), + Fact(type: .phone, value: "contact-\(id)-phone"), + ] } + return contact } } -- GitLab