From 525d23575caa3774a5b17d3c13269604b53db6f3 Mon Sep 17 00:00:00 2001 From: Lukas Korba Date: Mon, 30 Sep 2024 11:17:33 +0200 Subject: [PATCH] [#1363] Binary address book serialization - Local storage for address book is now primary source of data - Remote storage for address book is used as a backup, syncing is prepared and merge strategy is WIP - The data serialization/deserialization is prepared, no encryption in place - The data are versioned and checked - lastUpdated data are stored as well - Keychain stores a struct for encryption, in case there is more than just 1 key [#1363] Binary address book serialization - Subject cleanup --- modules/Package.swift | 3 +- .../AddressBookInterface.swift | 7 +- .../AddressBookLiveKey.swift | 298 +++++++++++++++--- .../RemoteStorageInterface.swift | 4 +- .../RemoteStorage/RemoteStorageLiveKey.swift | 8 +- .../WalletStorage/WalletStorage.swift | 20 +- .../WalletStorageInterface.swift | 4 +- .../WalletStorage/WalletStorageLiveKey.swift | 8 +- .../WalletStorage/WalletStorageTestKey.swift | 8 +- .../AddressBook/AddressBookContactView.swift | 2 +- .../AddressBook/AddressBookStore.swift | 94 +++--- .../AddressBook/AddressBookView.swift | 6 +- .../Features/Root/RootInitialization.swift | 8 +- .../SendConfirmationStore.swift | 30 +- .../Features/SendFlow/SendFlowStore.swift | 36 +-- .../TransactionListStore.swift | 38 ++- .../Sources/Generated/SharedStateKeys.swift | 2 +- modules/Sources/Models/ABRecord.swift | 20 -- .../Sources/Models/AddressBookContacts.swift | 29 ++ .../Models/AddressBookEncryptionKeys.swift | 23 ++ modules/Sources/Models/Contact.swift | 20 ++ 21 files changed, 468 insertions(+), 200 deletions(-) delete mode 100644 modules/Sources/Models/ABRecord.swift create mode 100644 modules/Sources/Models/AddressBookContacts.swift create mode 100644 modules/Sources/Models/AddressBookEncryptionKeys.swift create mode 100644 modules/Sources/Models/Contact.swift diff --git a/modules/Package.swift b/modules/Package.swift index ae75d231..ba99de39 100644 --- a/modules/Package.swift +++ b/modules/Package.swift @@ -396,7 +396,8 @@ let package = Package( dependencies: [ "Utils", "UIComponents", - .product(name: "MnemonicSwift", package: "MnemonicSwift") + .product(name: "MnemonicSwift", package: "MnemonicSwift"), + .product(name: "ComposableArchitecture", package: "swift-composable-architecture") ], path: "Sources/Models" ), diff --git a/modules/Sources/Dependencies/AddressBookClient/AddressBookInterface.swift b/modules/Sources/Dependencies/AddressBookClient/AddressBookInterface.swift index 12636e4e..6eb6ca58 100644 --- a/modules/Sources/Dependencies/AddressBookClient/AddressBookInterface.swift +++ b/modules/Sources/Dependencies/AddressBookClient/AddressBookInterface.swift @@ -17,7 +17,8 @@ extension DependencyValues { @DependencyClient public struct AddressBookClient { - public let allContacts: () async throws -> IdentifiedArrayOf - public let storeContact: (ABRecord) async throws -> IdentifiedArrayOf - public let deleteContact: (ABRecord) async throws -> IdentifiedArrayOf + public let allLocalContacts: () throws -> AddressBookContacts + public let syncContacts: (AddressBookContacts?) async throws -> AddressBookContacts + public let storeContact: (Contact) throws -> AddressBookContacts + public let deleteContact: (Contact) throws -> AddressBookContacts } diff --git a/modules/Sources/Dependencies/AddressBookClient/AddressBookLiveKey.swift b/modules/Sources/Dependencies/AddressBookClient/AddressBookLiveKey.swift index 1c9cdbd5..6fec46f1 100644 --- a/modules/Sources/Dependencies/AddressBookClient/AddressBookLiveKey.swift +++ b/modules/Sources/Dependencies/AddressBookClient/AddressBookLiveKey.swift @@ -16,106 +16,322 @@ import Combine import WalletStorage extension AddressBookClient: DependencyKey { + private enum Constants { + static let component = "AddressBookData" + } + public enum AddressBookClientError: Error { case missingEncryptionKey + case documentsFolder } public static let liveValue: AddressBookClient = Self.live() public static func live() -> Self { - let latestKnownContacts = CurrentValueSubject?, Never>(nil) + var latestKnownContacts: AddressBookContacts? @Dependency(\.remoteStorage) var remoteStorage return Self( - allContacts: { + allLocalContacts: { // return latest known contacts - guard latestKnownContacts.value == nil else { - if let contacts = latestKnownContacts.value { + guard latestKnownContacts == nil else { + if let contacts = latestKnownContacts { return contacts } else { - return [] + return .empty } } - // contacts haven't been loaded from the remote storage yet, do it + // contacts haven't been loaded from the locale storage yet, do it do { - let data = try await remoteStorage.loadAddressBookContacts() + guard let documentsDirectory = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first else { + throw AddressBookClientError.documentsFolder + } + let fileURL = documentsDirectory.appendingPathComponent(Constants.component) + let encryptedContacts = try Data(contentsOf: fileURL) - let storedContacts = try AddressBookClient.decryptData(data) - latestKnownContacts.value = storedContacts + let decryptedContacts = try AddressBookClient.decryptData(encryptedContacts) + latestKnownContacts = decryptedContacts - return storedContacts - } catch RemoteStorageClient.RemoteStorageError.fileDoesntExist { - return [] + return decryptedContacts } catch { throw error } }, - storeContact: { - var contacts = latestKnownContacts.value ?? [] + syncContacts: { contacts in + // Ensure local contacts are prepared + var localContacts: AddressBookContacts + + if let contacts { + localContacts = contacts + } else { + do { + guard let documentsDirectory = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first else { + throw AddressBookClientError.documentsFolder + } + let fileURL = documentsDirectory.appendingPathComponent(Constants.component) + let encryptedContacts = try Data(contentsOf: fileURL) + let decryptedContacts = try AddressBookClient.decryptData(encryptedContacts) + localContacts = decryptedContacts + + return decryptedContacts + } catch { + throw error + } + } + + let data = try remoteStorage.loadAddressBookContacts() + let remoteContacts = try AddressBookClient.decryptData(data) + + // Ensure remote contacts are prepared + + // Merge strategy + print("__LD SYNCING CONTACTS...") + print("__LD localContacts \(localContacts)") + print("__LD remoteContacts \(remoteContacts)") + + var syncedContacts = localContacts + + // TBD + + return syncedContacts + }, + storeContact: { + var abContacts = latestKnownContacts ?? AddressBookContacts.empty + // if already exists, remove it - if contacts.contains($0) { - contacts.remove($0) + if abContacts.contacts.contains($0) { + abContacts.contacts.remove($0) } - contacts.append($0) + abContacts.contacts.append($0) - // push encrypted data to the remote storage - try await remoteStorage.storeAddressBookContacts(AddressBookClient.encryptContacts(contacts)) + // encrypt data + let encryptedContacts = try AddressBookClient.encryptContacts(abContacts) + //let decryptedContacts = try AddressBookClient.decryptData(encryptedContacts) + + // store encrypted data to the local storage + guard let documentsDirectory = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first else { + throw AddressBookClientError.documentsFolder + } + let fileURL = documentsDirectory.appendingPathComponent(Constants.component) + try encryptedContacts.write(to: fileURL) + // store encrypted data to the remote storage + try remoteStorage.storeAddressBookContacts(encryptedContacts) + // update the latest known contacts - latestKnownContacts.value = contacts + latestKnownContacts = abContacts - return contacts + return abContacts }, deleteContact: { - var contacts = latestKnownContacts.value ?? [] + var abContacts = latestKnownContacts ?? AddressBookContacts.empty // if it doesn't exist, do nothing - guard contacts.contains($0) else { - return contacts + guard abContacts.contacts.contains($0) else { + return abContacts } - contacts.remove($0) + abContacts.contacts.remove($0) - // push encrypted data to the remote storage - try await remoteStorage.storeAddressBookContacts(AddressBookClient.encryptContacts(contacts)) + // encrypt data + let encryptedContacts = try AddressBookClient.encryptContacts(abContacts) + + // store encrypted data to the local storage + guard let documentsDirectory = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first else { + throw AddressBookClientError.documentsFolder + } + let fileURL = documentsDirectory.appendingPathComponent(Constants.component) + try encryptedContacts.write(to: fileURL) + + // store encrypted data to the remote storage + try remoteStorage.storeAddressBookContacts(encryptedContacts) // update the latest known contacts - latestKnownContacts.value = contacts + latestKnownContacts = abContacts - return contacts + return abContacts } ) } - private static func encryptContacts(_ contacts: IdentifiedArrayOf) throws -> Data { + private static func encryptContacts(_ abContacts: AddressBookContacts) throws -> Data { @Dependency(\.walletStorage) var walletStorage - guard let encryptionKey = try? walletStorage.exportAddressBookKey() else { - throw AddressBookClient.AddressBookClientError.missingEncryptionKey - } - // TODO: str4d +// guard let encryptionKeys = try? walletStorage.exportAddressBookEncryptionKeys() else { +// throw AddressBookClient.AddressBookClientError.missingEncryptionKey +// } + // here you have an array of all contacts // you also have a key from the keychain + var data = Data() + + // Serialize `version` + data.append(contentsOf: intToBytes(abContacts.version)) + + // Serialize `lastUpdated` + data.append(contentsOf: AddressBookClient.serializeDate(Date())) - return Data() + // Serialize `contacts.count` + data.append(contentsOf: intToBytes(abContacts.contacts.count)) + + // Serialize `contacts` + abContacts.contacts.forEach { contact in + let serializedContact = serializeContact(contact) + data.append(serializedContact) + } + + return data } - private static func decryptData(_ data: Data) throws -> IdentifiedArrayOf { + private static func decryptData(_ data: Data) throws -> AddressBookContacts { @Dependency(\.walletStorage) var walletStorage - guard let encryptionKey = try? walletStorage.exportAddressBookKey() else { - throw AddressBookClient.AddressBookClientError.missingEncryptionKey - } - // TODO: str4d +// guard let encryptionKeys = try? walletStorage.exportAddressBookEncryptionKeys() else { +// throw AddressBookClient.AddressBookClientError.missingEncryptionKey +// } + // here you have the encrypted data from the cloud, the blob // you also have a key from the keychain + + var offset = 0 + + // Deserialize `version` + let versionBytes = data.subdata(in: offset..<(offset + MemoryLayout.size)) + offset += MemoryLayout.size - return [] + guard let version = AddressBookClient.bytesToInt(Array(versionBytes)) else { + return .empty + } + + guard version == AddressBookContacts.Constants.version else { + return .empty + } + + // Deserialize `lastUpdated` + guard let lastUpdated = AddressBookClient.deserializeDate(from: data, at: &offset) else { + return .empty + } + + // Deserialize `contactsCount` + let contactsCountBytes = data.subdata(in: offset..<(offset + MemoryLayout.size)) + offset += MemoryLayout.size + + guard let contactsCount = AddressBookClient.bytesToInt(Array(contactsCountBytes)) else { + return .empty + } + + var contacts: [Contact] = [] + for _ in 0.. Data { + var data = Data() + + // Serialize `lastUpdated` + data.append(contentsOf: AddressBookClient.serializeDate(contact.lastUpdated)) + + // Serialize `address` (length + UTF-8 bytes) + let addressBytes = stringToBytes(contact.id) + data.append(contentsOf: intToBytes(addressBytes.count)) + data.append(contentsOf: addressBytes) + + // Serialize `name` (length + UTF-8 bytes) + let nameBytes = stringToBytes(contact.name) + data.append(contentsOf: intToBytes(nameBytes.count)) + data.append(contentsOf: nameBytes) + + return data + } + + private static func deserializeContact(from data: Data, at offset: inout Int) -> Contact? { + // Deserialize `lastUpdated` + guard let lastUpdated = AddressBookClient.deserializeDate(from: data, at: &offset) else { + return nil + } + + // Deserialize `address` + guard let address = readString(from: data, at: &offset) else { + return nil + } + + // Deserialize `name` + guard let name = readString(from: data, at: &offset) else { + return nil + } + + return Contact(address: address, name: name, lastUpdated: lastUpdated) + } + + private static func stringToBytes(_ string: String) -> [UInt8] { + return Array(string.utf8) + } + + private static func bytesToString(_ bytes: [UInt8]) -> String? { + return String(bytes: bytes, encoding: .utf8) + } + + private static func intToBytes(_ value: Int) -> [UInt8] { + withUnsafeBytes(of: value.bigEndian, Array.init) + } + + private static func bytesToInt(_ bytes: [UInt8]) -> Int? { + guard bytes.count == MemoryLayout.size else { + return nil + } + + return bytes.withUnsafeBytes { + $0.load(as: Int.self).bigEndian + } + } + + private static func serializeDate(_ date: Date) -> [UInt8] { + // Convert Date to Unix time (number of seconds since 1970) + let timestamp = Int(date.timeIntervalSince1970) + + // Convert the timestamp to bytes + return AddressBookClient.intToBytes(timestamp) + } + + private static func deserializeDate(from data: Data, at offset: inout Int) -> Date? { + // Extract the bytes for the timestamp (assume it's stored as an Int) + let timestampBytes = data.subdata(in: offset..<(offset + MemoryLayout.size)) + offset += MemoryLayout.size + + // Convert the bytes back to an Int + guard let timestamp = AddressBookClient.bytesToInt(Array(timestampBytes)) else { return nil } + + // Convert the timestamp back to a Date + return Date(timeIntervalSince1970: TimeInterval(timestamp)) + } + + // Helper function to read a string from the data using a length prefix + private static func readString(from data: Data, at offset: inout Int) -> String? { + // Read the length first (assumes the length is stored as an Int) + let lengthBytes = data.subdata(in: offset..<(offset + MemoryLayout.size)) + offset += MemoryLayout.size + guard let length = AddressBookClient.bytesToInt(Array(lengthBytes)), length > 0 else { return nil } + + // Read the string bytes + let stringBytes = data.subdata(in: offset..<(offset + length)) + offset += length + return AddressBookClient.bytesToString(Array(stringBytes)) } } diff --git a/modules/Sources/Dependencies/RemoteStorage/RemoteStorageInterface.swift b/modules/Sources/Dependencies/RemoteStorage/RemoteStorageInterface.swift index ef8a409c..b4dde618 100644 --- a/modules/Sources/Dependencies/RemoteStorage/RemoteStorageInterface.swift +++ b/modules/Sources/Dependencies/RemoteStorage/RemoteStorageInterface.swift @@ -17,6 +17,6 @@ extension DependencyValues { @DependencyClient public struct RemoteStorageClient { - public let loadAddressBookContacts: () async throws -> Data - public let storeAddressBookContacts: (Data) async throws -> Void + public let loadAddressBookContacts: () throws -> Data + public let storeAddressBookContacts: (Data) throws -> Void } diff --git a/modules/Sources/Dependencies/RemoteStorage/RemoteStorageLiveKey.swift b/modules/Sources/Dependencies/RemoteStorage/RemoteStorageLiveKey.swift index c17cb9f4..e6e1d96e 100644 --- a/modules/Sources/Dependencies/RemoteStorage/RemoteStorageLiveKey.swift +++ b/modules/Sources/Dependencies/RemoteStorage/RemoteStorageLiveKey.swift @@ -34,9 +34,7 @@ extension RemoteStorageClient: DependencyKey { throw RemoteStorageError.fileDoesntExist } - return try await Task { - return try Data(contentsOf: containerURL) - }.value + return try Data(contentsOf: containerURL) }, storeAddressBookContacts: { data in let fileManager = FileManager.default @@ -45,9 +43,7 @@ extension RemoteStorageClient: DependencyKey { throw RemoteStorageError.containerURL } - try await Task { - try data.write(to: containerURL) - }.value + try data.write(to: containerURL) } ) } diff --git a/modules/Sources/Dependencies/WalletStorage/WalletStorage.swift b/modules/Sources/Dependencies/WalletStorage/WalletStorage.swift index e51629cd..698322c1 100644 --- a/modules/Sources/Dependencies/WalletStorage/WalletStorage.swift +++ b/modules/Sources/Dependencies/WalletStorage/WalletStorage.swift @@ -18,7 +18,7 @@ import Models public struct WalletStorage { public enum Constants { public static let zcashStoredWallet = "zcashStoredWallet" - public static let zcashStoredAdressBookKey = "zcashStoredAdressBookKey" + public static let zcashStoredAdressBookEncryptionKeys = "zcashStoredAdressBookEncryptionKeys" /// Versioning of the stored data public static let zcashKeychainVersion = 1 } @@ -33,7 +33,7 @@ public struct WalletStorage { public enum WalletStorageError: Error { case alreadyImported - case uninitializedAddressBookKey + case uninitializedAddressBookEncryptionKeys case uninitializedWallet case storageError(Error) case unsupportedVersion(Int) @@ -140,13 +140,13 @@ public struct WalletStorage { deleteData(forKey: Constants.zcashStoredWallet) } - public func importAddressBookKey(_ key: String) throws { + public func importAddressBookEncryptionKeys(_ keys: AddressBookEncryptionKeys) throws { do { - guard let data = try encode(object: key) else { + guard let data = try encode(object: keys) else { throw KeychainError.encoding } - try setData(data, forKey: Constants.zcashStoredAdressBookKey) + try setData(data, forKey: Constants.zcashStoredAdressBookEncryptionKeys) } catch KeychainError.duplicate { throw WalletStorageError.alreadyImported } catch { @@ -154,13 +154,13 @@ public struct WalletStorage { } } - public func exportAddressBookKey() throws -> String { - guard let data = data(forKey: Constants.zcashStoredAdressBookKey) else { - throw WalletStorageError.uninitializedAddressBookKey + public func exportAddressBookEncryptionKeys() throws -> AddressBookEncryptionKeys { + guard let data = data(forKey: Constants.zcashStoredAdressBookEncryptionKeys) else { + throw WalletStorageError.uninitializedAddressBookEncryptionKeys } - guard let wallet = try decode(json: data, as: String.self) else { - throw WalletStorageError.uninitializedAddressBookKey + guard let wallet = try decode(json: data, as: AddressBookEncryptionKeys.self) else { + throw WalletStorageError.uninitializedAddressBookEncryptionKeys } return wallet diff --git a/modules/Sources/Dependencies/WalletStorage/WalletStorageInterface.swift b/modules/Sources/Dependencies/WalletStorage/WalletStorageInterface.swift index a76b662a..d58f902f 100644 --- a/modules/Sources/Dependencies/WalletStorage/WalletStorageInterface.swift +++ b/modules/Sources/Dependencies/WalletStorage/WalletStorageInterface.swift @@ -77,6 +77,6 @@ public struct WalletStorageClient { // TODO: str4d // not sure what format the key is, for now I made it a String - public var importAddressBookKey: (String) throws -> Void - public var exportAddressBookKey: () throws -> String + public var importAddressBookEncryptionKeys: (AddressBookEncryptionKeys) throws -> Void + public var exportAddressBookEncryptionKeys: () throws -> AddressBookEncryptionKeys } diff --git a/modules/Sources/Dependencies/WalletStorage/WalletStorageLiveKey.swift b/modules/Sources/Dependencies/WalletStorage/WalletStorageLiveKey.swift index dd37be8c..15993aea 100644 --- a/modules/Sources/Dependencies/WalletStorage/WalletStorageLiveKey.swift +++ b/modules/Sources/Dependencies/WalletStorage/WalletStorageLiveKey.swift @@ -39,11 +39,11 @@ extension WalletStorageClient: DependencyKey { nukeWallet: { walletStorage.nukeWallet() }, - importAddressBookKey: { key in - try walletStorage.importAddressBookKey(key) + importAddressBookEncryptionKeys: { keys in + try walletStorage.importAddressBookEncryptionKeys(keys) }, - exportAddressBookKey: { - try walletStorage.exportAddressBookKey() + exportAddressBookEncryptionKeys: { + try walletStorage.exportAddressBookEncryptionKeys() } ) } diff --git a/modules/Sources/Dependencies/WalletStorage/WalletStorageTestKey.swift b/modules/Sources/Dependencies/WalletStorage/WalletStorageTestKey.swift index ed965755..f5a5dcc6 100644 --- a/modules/Sources/Dependencies/WalletStorage/WalletStorageTestKey.swift +++ b/modules/Sources/Dependencies/WalletStorage/WalletStorageTestKey.swift @@ -16,8 +16,8 @@ extension WalletStorageClient: TestDependencyKey { updateBirthday: unimplemented("\(Self.self).updateBirthday", placeholder: {}()), markUserPassedPhraseBackupTest: unimplemented("\(Self.self).markUserPassedPhraseBackupTest", placeholder: {}()), nukeWallet: unimplemented("\(Self.self).nukeWallet", placeholder: {}()), - importAddressBookKey: unimplemented("\(Self.self).importAddressBookKey", placeholder: {}()), - exportAddressBookKey: unimplemented("\(Self.self).exportAddressBookKey", placeholder: "") + importAddressBookEncryptionKeys: unimplemented("\(Self.self).importAddressBookEncryptionKeys", placeholder: {}()), + exportAddressBookEncryptionKeys: unimplemented("\(Self.self).exportAddressBookEncryptionKeys", placeholder: .empty) ) } @@ -29,7 +29,7 @@ extension WalletStorageClient { updateBirthday: { _ in }, markUserPassedPhraseBackupTest: { _ in }, nukeWallet: { }, - importAddressBookKey: { _ in }, - exportAddressBookKey: { "" } + importAddressBookEncryptionKeys: { _ in }, + exportAddressBookEncryptionKeys: { .empty } ) } diff --git a/modules/Sources/Features/AddressBook/AddressBookContactView.swift b/modules/Sources/Features/AddressBook/AddressBookContactView.swift index d4433c40..eba6f567 100644 --- a/modules/Sources/Features/AddressBook/AddressBookContactView.swift +++ b/modules/Sources/Features/AddressBook/AddressBookContactView.swift @@ -48,7 +48,7 @@ public struct AddressBookContactView: View { ZashiButton(L10n.General.save) { store.send(.saveButtonTapped) } - //.disabled(store.isSaveButtonDisabled) // TODO: FIXME + .disabled(store.isSaveButtonDisabled) .padding(.bottom, isInEditMode ? 0 : 24) if isInEditMode { diff --git a/modules/Sources/Features/AddressBook/AddressBookStore.swift b/modules/Sources/Features/AddressBook/AddressBookStore.swift index db8710d9..4d81ac18 100644 --- a/modules/Sources/Features/AddressBook/AddressBookStore.swift +++ b/modules/Sources/Features/AddressBook/AddressBookStore.swift @@ -30,7 +30,7 @@ public struct AddressBook { public var address = "" public var addressAlreadyExists = false - @Shared(.inMemory(.addressBookRecords)) public var addressBookRecords: IdentifiedArrayOf = [] + @Shared(.inMemory(.addressBookContacts)) public var addressBookContacts: AddressBookContacts = .empty @Presents public var alert: AlertState? public var deleteIdToConfirm: String? public var destination: Destination? @@ -93,8 +93,8 @@ public struct AddressBook { case deleteId(String) case deleteIdConfirmed case editId(String) - case fetchedABRecords(IdentifiedArrayOf) - case fetchABRecordsRequested + case fetchedABContacts(AddressBookContacts, Bool) + case fetchABContactsRequested case onAppear case checkDuplicates case saveButtonTapped @@ -132,7 +132,7 @@ public struct AddressBook { state.addressAlreadyExists = false state.isAddressFocused = false state.isNameFocused = false - return .send(.fetchABRecordsRequested) + return .send(.fetchABContactsRequested) case .alert(.presented(let action)): return Effect.send(action) @@ -155,11 +155,11 @@ public struct AddressBook { case .checkDuplicates: state.nameAlreadyExists = false state.addressAlreadyExists = false - for record in state.addressBookRecords { - if record.name == state.name && state.name != state.originalName { + for contact in state.addressBookContacts.contacts { + if contact.name == state.name && state.name != state.originalName { state.nameAlreadyExists = true } - if record.id == state.address && state.address != state.originalAddress { + if contact.id == state.address && state.address != state.originalAddress { state.addressAlreadyExists = true } } @@ -199,18 +199,18 @@ public struct AddressBook { return .none } - let record = state.addressBookRecords.first { + let contact = state.addressBookContacts.contacts.first { $0.id == deleteIdToConfirm } - if let record { - return .run { send in - do { - let contacts = try await addressBook.deleteContact(record) - await send(.fetchedABRecords(contacts)) - await send(.updateDestination(nil)) - } catch { - // TODO: FIXME - } + if let contact { + do { + let contacts = try addressBook.deleteContact(contact) + return .concatenate( + .send(.fetchedABContacts(contacts, false)), + .send(.updateDestination(nil)) + ) + } catch { + // TODO: FIXME } } return .none @@ -220,7 +220,7 @@ public struct AddressBook { return .none } - let record = state.addressBookRecords.first { + let record = state.addressBookContacts.contacts.first { $0.id == id } guard let record else { @@ -237,18 +237,16 @@ public struct AddressBook { return .send(.updateDestination(.add)) case .saveButtonTapped: - let name = state.name.isEmpty ? "testName" : state.name - let address = state.address.isEmpty ? "testAddress" : state.address - return .run { send in - do { - let contacts = try await addressBook.storeContact(ABRecord(address: address, name: name)) - await send(.fetchedABRecords(contacts)) - await send(.contactStoreSuccess) - } catch { - // TODO: FIXME - print("__LD saveButtonTapped Error: \(error.localizedDescription)") - await send(.updateDestination(nil)) - } + do { + let abContacts = try addressBook.storeContact(Contact(address: state.address, name: state.name)) + return .concatenate( + .send(.fetchedABContacts(abContacts, false)), + .send(.contactStoreSuccess) + ) + } catch { + // TODO: FIXME + print("__LD saveButtonTapped Error: \(error.localizedDescription)") + return .send(.updateDestination(nil)) } case .contactStoreSuccess: @@ -258,21 +256,31 @@ public struct AddressBook { state.isNameFocused = false return .send(.updateDestination(nil)) - case .fetchABRecordsRequested: - return .run { send in - do { - let records = try await addressBook.allContacts() - await send(.fetchedABRecords(records)) - print("__LD updateRecords success") - } catch { - print("__LD updateRecords Error: \(error.localizedDescription)") - // TODO: FIXME + case .fetchABContactsRequested: + do { + let abContacts = try addressBook.allLocalContacts() + return .send(.fetchedABContacts(abContacts, true)) + } catch { + print("__LD fetchABContactsRequested Error: \(error.localizedDescription)") + // TODO: FIXME + return .none + } + + case let .fetchedABContacts(abContacts, requestToSync): + state.addressBookContacts = abContacts + if requestToSync { + return .run { send in + do { + let syncedContacts = try await addressBook.syncContacts(abContacts) + await send(.fetchedABContacts(syncedContacts, false)) + } catch { + print("__LD syncContacts Error: \(error.localizedDescription)") + // TODO: FIXME + } } + } else { + return .none } - - case .fetchedABRecords(let records): - state.addressBookRecords = records - return .none case .updateDestination(let destination): state.destination = destination diff --git a/modules/Sources/Features/AddressBook/AddressBookView.swift b/modules/Sources/Features/AddressBook/AddressBookView.swift index ec03ef7f..5caff541 100644 --- a/modules/Sources/Features/AddressBook/AddressBookView.swift +++ b/modules/Sources/Features/AddressBook/AddressBookView.swift @@ -34,7 +34,7 @@ public struct AddressBookView: View { public var body: some View { WithPerceptionTracking { VStack() { - if store.addressBookRecords.isEmpty { + if store.addressBookContacts.contacts.isEmpty { Spacer() VStack(spacing: 40) { @@ -56,7 +56,7 @@ public struct AddressBookView: View { } List { - ForEach(store.addressBookRecords, id: \.self) { record in + ForEach(store.addressBookContacts.contacts, id: \.self) { record in VStack { ContactView( iconText: record.name.initials, @@ -66,7 +66,7 @@ public struct AddressBookView: View { store.send(.editId(record.id)) } - if let last = store.addressBookRecords.last, last != record { + if let last = store.addressBookContacts.contacts.last, last != record { Design.Surfaces.divider.color .frame(height: 1) .padding(.top, 12) diff --git a/modules/Sources/Features/Root/RootInitialization.swift b/modules/Sources/Features/Root/RootInitialization.swift index caf5121b..c90cf977 100644 --- a/modules/Sources/Features/Root/RootInitialization.swift +++ b/modules/Sources/Features/Root/RootInitialization.swift @@ -285,16 +285,16 @@ extension Root { try mnemonic.isValid(storedWallet.seedPhrase.value()) let seedBytes = try mnemonic.toSeed(storedWallet.seedPhrase.value()) - let addressBookEncryptionKey = try? walletStorage.exportAddressBookKey() - if addressBookEncryptionKey == nil { + let addressBookEncryptionKeys = try? walletStorage.exportAddressBookEncryptionKeys() + if addressBookEncryptionKeys == nil { // TODO: str4d // here you know the encryption key for the address book is missing, we need to generate one // here you have `storedWallet.seedPhrase.seedPhrase`, a seed as String // once the key is prepared, store it - // let key == "" - // try walletStorage.importAddressBookKey(key) + // let keys == AddressBookEncryptionKeys(key: "") + // try walletStorage.importAddressBookEncryptionKeys(keys) } return .run { send in diff --git a/modules/Sources/Features/SendConfirmation/SendConfirmationStore.swift b/modules/Sources/Features/SendConfirmation/SendConfirmationStore.swift index f58c61ee..21207b4a 100644 --- a/modules/Sources/Features/SendConfirmation/SendConfirmationStore.swift +++ b/modules/Sources/Features/SendConfirmation/SendConfirmationStore.swift @@ -29,7 +29,7 @@ public struct SendConfirmation { @ObservableState public struct State: Equatable { public var address: String - @Shared(.inMemory(.addressBookRecords)) public var addressBookRecords: IdentifiedArrayOf = [] + @Shared(.inMemory(.addressBookContacts)) public var addressBookContacts: AddressBookContacts = .empty public var alias: String? @Presents public var alert: AlertState? public var amount: Zatoshi @@ -67,7 +67,7 @@ public struct SendConfirmation { public enum Action: BindableAction, Equatable { case alert(PresentationAction) case binding(BindingAction) - case fetchedABRecords(IdentifiedArrayOf) + case fetchedABContacts(AddressBookContacts) case goBackPressed case onAppear case partialProposalError(PartialProposalError.Action) @@ -98,22 +98,20 @@ public struct SendConfirmation { Reduce { state, action in switch action { case .onAppear: - return .run { send in - do { - let records = try await addressBook.allContacts() - await send(.fetchedABRecords(records)) - print("__LD updateRecords success") - } catch { - print("__LD updateRecords Error: \(error.localizedDescription)") - // TODO: FIXME - } + do { + let abContacts = try addressBook.allLocalContacts() + return .send(.fetchedABContacts(abContacts)) + } catch { + print("__LD fetchABContactsRequested Error: \(error.localizedDescription)") + // TODO: FIXME + return .none } - case .fetchedABRecords(let records): - state.addressBookRecords = records - for record in state.addressBookRecords { - if record.id == state.address { - state.alias = record.name + case .fetchedABContacts(let abContacts): + state.addressBookContacts = abContacts + for contact in state.addressBookContacts.contacts { + if contact.id == state.address { + state.alias = contact.name break } } diff --git a/modules/Sources/Features/SendFlow/SendFlowStore.swift b/modules/Sources/Features/SendFlow/SendFlowStore.swift index 61300f09..a1484322 100644 --- a/modules/Sources/Features/SendFlow/SendFlowStore.swift +++ b/modules/Sources/Features/SendFlow/SendFlowStore.swift @@ -34,7 +34,7 @@ public struct SendFlow { public var cancelId = UUID() public var addMemoState: Bool - @Shared(.inMemory(.addressBookRecords)) public var addressBookRecords: IdentifiedArrayOf = [] + @Shared(.inMemory(.addressBookContacts)) public var addressBookContacts: AddressBookContacts = .empty @Presents public var alert: AlertState? @Shared(.inMemory(.exchangeRate)) public var currencyConversion: CurrencyConversion? = nil public var destination: Destination? @@ -196,7 +196,7 @@ public struct SendFlow { case currencyUpdated(RedactableString) case dismissAddressBookHint case exchangeRateSetupChanged - case fetchedABRecords(IdentifiedArrayOf) + case fetchedABContacts(AddressBookContacts) case memo(MessageEditor.Action) case onAppear case onDisapear @@ -240,22 +240,20 @@ public struct SendFlow { switch action { case .onAppear: state.memoState.charLimit = zcashSDKEnvironment.memoCharLimit - return .merge( - .send(.exchangeRateSetupChanged), - .run { send in - do { - let records = try await addressBook.allContacts() - await send(.fetchedABRecords(records)) - print("__LD updateRecords success") - } catch { - print("__LD updateRecords Error: \(error.localizedDescription)") - // TODO: FIXME - } - } - ) + do { + let abContacts = try addressBook.allLocalContacts() + return .merge( + .send(.exchangeRateSetupChanged), + .send(.fetchedABContacts(abContacts)) + ) + } catch { + // TODO: FIXME + print("__LD fetchABContactsRequested Error: \(error.localizedDescription)") + return .send(.exchangeRateSetupChanged) + } - case .fetchedABRecords(let records): - state.addressBookRecords = records + case .fetchedABContacts(let abContacts): + state.addressBookContacts = abContacts return .none case .onDisapear: @@ -419,8 +417,8 @@ public struct SendFlow { state.isNotAddressInAddressBook = state.isValidAddress var isNotAddressInAddressBook = state.isNotAddressInAddressBook if state.isValidAddress { - for record in state.addressBookRecords { - if record.id == state.address.data { + for contact in state.addressBookContacts.contacts { + if contact.id == state.address.data { state.isNotAddressInAddressBook = false isNotAddressInAddressBook = false break diff --git a/modules/Sources/Features/TransactionList/TransactionListStore.swift b/modules/Sources/Features/TransactionList/TransactionListStore.swift index 6b6db08e..bbb85de8 100644 --- a/modules/Sources/Features/TransactionList/TransactionListStore.swift +++ b/modules/Sources/Features/TransactionList/TransactionListStore.swift @@ -17,7 +17,7 @@ public struct TransactionList { @ObservableState public struct State: Equatable { - @Shared(.inMemory(.addressBookRecords)) public var addressBookRecords: IdentifiedArrayOf = [] + @Shared(.inMemory(.addressBookContacts)) public var addressBookContacts: AddressBookContacts = .empty public var latestMinedHeight: BlockHeight? public var latestTransactionId = "" public var latestTransactionList: [TransactionState] = [] @@ -39,7 +39,7 @@ public struct TransactionList { public enum Action: Equatable { case copyToPastboard(RedactableString) - case fetchedABRecords(IdentifiedArrayOf) + case fetchedABContacts(AddressBookContacts) case foundTransactions case memosFor([Memo], String) case onAppear @@ -67,6 +67,14 @@ public struct TransactionList { switch action { case .onAppear: state.requiredTransactionConfirmations = zcashSDKEnvironment.requiredTransactionConfirmations + do { + let abContacts = try addressBook.allLocalContacts() + state.addressBookContacts = abContacts + } catch { + print("__LD fetchABContactsRequested Error: \(error.localizedDescription)") + // TODO: FIXME + } + return .merge( .publisher { sdkSynchronizer.stateStream() @@ -89,27 +97,17 @@ public struct TransactionList { if let transactions = try? await sdkSynchronizer.getAllTransactions() { await send(.updateTransactionList(transactions)) } - }, - .run { send in - do { - let records = try await addressBook.allContacts() - await send(.fetchedABRecords(records)) - print("__LD updateRecords success") - } catch { - print("__LD updateRecords Error: \(error.localizedDescription)") - // TODO: FIXME - } } ) - case .fetchedABRecords(let records): - state.addressBookRecords = records + case .fetchedABContacts(let abContacts): + state.addressBookContacts = abContacts let modifiedTransactionState = state.transactionList.map { transaction in var copiedTransaction = transaction copiedTransaction.isInAddressBook = false - for record in state.addressBookRecords { - if record.id == transaction.address { + for contact in state.addressBookContacts.contacts { + if contact.id == transaction.address { copiedTransaction.isInAddressBook = true break } @@ -189,8 +187,8 @@ public struct TransactionList { // in address book copiedTransaction.isInAddressBook = false - for record in state.addressBookRecords { - if record.id == transaction.address { + for contact in state.addressBookContacts.contacts { + if contact.id == transaction.address { copiedTransaction.isInAddressBook = true break } @@ -220,8 +218,8 @@ public struct TransactionList { if let index = state.transactionList.index(id: id) { if state.transactionList[index].isExpanded { state.transactionList[index].isAddressExpanded = true - for record in state.addressBookRecords { - if record.id == state.transactionList[index].address { + for contact in state.addressBookContacts.contacts { + if contact.id == state.transactionList[index].address { state.transactionList[index].isInAddressBook = true break } diff --git a/modules/Sources/Generated/SharedStateKeys.swift b/modules/Sources/Generated/SharedStateKeys.swift index c5db5956..4032a0c6 100644 --- a/modules/Sources/Generated/SharedStateKeys.swift +++ b/modules/Sources/Generated/SharedStateKeys.swift @@ -12,6 +12,6 @@ public extension String { static let sensitiveContent = "udHideBalances" static let walletStatus = "sharedStateKey_walletStatus" static let flexaAccountId = "sharedStateKey_flexaAccountId" - static let addressBookRecords = "sharedStateKey_addressBookRecords" + static let addressBookContacts = "sharedStateKey_addressBookContacts" static let toast = "sharedStateKey_toast" } diff --git a/modules/Sources/Models/ABRecord.swift b/modules/Sources/Models/ABRecord.swift deleted file mode 100644 index f47c9c51..00000000 --- a/modules/Sources/Models/ABRecord.swift +++ /dev/null @@ -1,20 +0,0 @@ -// -// ABRecord.swift -// Zashi -// -// Created by Lukáš Korba on 05-28-2024. -// - -import Foundation - -public struct ABRecord: Equatable, Codable, Identifiable, Hashable { - public let id: String - public var name: String - public let timestamp: Date - - public init(address: String, name: String, timestamp: Date = Date()) { - self.id = address - self.name = name - self.timestamp = timestamp - } -} diff --git a/modules/Sources/Models/AddressBookContacts.swift b/modules/Sources/Models/AddressBookContacts.swift new file mode 100644 index 00000000..2d9d995a --- /dev/null +++ b/modules/Sources/Models/AddressBookContacts.swift @@ -0,0 +1,29 @@ +// +// AddressBookContacts.swift +// Zashi +// +// Created by Lukáš Korba on 09-30-2024. +// + +import Foundation +import ComposableArchitecture + +public struct AddressBookContacts: Equatable, Codable { + public enum Constants { + public static let version = 1 + } + + public let lastUpdated: Date + public let version: Int + public var contacts: IdentifiedArrayOf + + public init(lastUpdated: Date, version: Int, contacts: IdentifiedArrayOf) { + self.lastUpdated = lastUpdated + self.version = version + self.contacts = contacts + } +} + +public extension AddressBookContacts { + static let empty = AddressBookContacts(lastUpdated: .distantPast, version: Constants.version, contacts: []) +} diff --git a/modules/Sources/Models/AddressBookEncryptionKeys.swift b/modules/Sources/Models/AddressBookEncryptionKeys.swift new file mode 100644 index 00000000..de3979e4 --- /dev/null +++ b/modules/Sources/Models/AddressBookEncryptionKeys.swift @@ -0,0 +1,23 @@ +// +// AddressBookEncryptionKeys.swift +// Zashi +// +// Created by Lukáš Korba on 09-30-2024. +// + +import Foundation + +/// Representation of the address book encryption keys +public struct AddressBookEncryptionKeys: Codable, Equatable { + public let key: String + + public init(key: String) { + self.key = key + } +} + +extension AddressBookEncryptionKeys { + public static let empty = Self( + key: "" + ) +} diff --git a/modules/Sources/Models/Contact.swift b/modules/Sources/Models/Contact.swift new file mode 100644 index 00000000..ee3ca711 --- /dev/null +++ b/modules/Sources/Models/Contact.swift @@ -0,0 +1,20 @@ +// +// Contact.swift +// Zashi +// +// Created by Lukáš Korba on 05-28-2024. +// + +import Foundation + +public struct Contact: Equatable, Codable, Identifiable, Hashable { + public let id: String + public var name: String + public let lastUpdated: Date + + public init(address: String, name: String, lastUpdated: Date = Date()) { + self.id = address + self.name = name + self.lastUpdated = lastUpdated + } +}