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