Skip to content

Commit

Permalink
[Electric-Coin-Company#1363] Binary address book serialization
Browse files Browse the repository at this point in the history
- 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

[Electric-Coin-Company#1363] Binary address book serialization

- Subject cleanup
  • Loading branch information
LukasKorba committed Oct 3, 2024
1 parent b9af5a0 commit 1ef0234
Show file tree
Hide file tree
Showing 20 changed files with 474 additions and 208 deletions.
3 changes: 2 additions & 1 deletion modules/Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -408,7 +408,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"
),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,8 @@ extension DependencyValues {

@DependencyClient
public struct AddressBookClient {
public let allContacts: () async throws -> IdentifiedArrayOf<ABRecord>
public let storeContact: (ABRecord) async throws -> IdentifiedArrayOf<ABRecord>
public let deleteContact: (ABRecord) async throws -> IdentifiedArrayOf<ABRecord>
public let allLocalContacts: () throws -> AddressBookContacts
public let syncContacts: (AddressBookContacts?) async throws -> AddressBookContacts
public let storeContact: (Contact) throws -> AddressBookContacts
public let deleteContact: (Contact) throws -> AddressBookContacts
}
312 changes: 263 additions & 49 deletions modules/Sources/Dependencies/AddressBookClient/AddressBookLiveKey.swift
Original file line number Diff line number Diff line change
Expand Up @@ -16,108 +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<IdentifiedArrayOf<ABRecord>?, 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 locale storage yet, do it
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)
latestKnownContacts = decryptedContacts

return decryptedContacts
} catch {
throw error
}
},
syncContacts: { contacts in
// Ensure local contacts are prepared
var localContacts: AddressBookContacts

// contacts haven't been loaded from the remote storage yet, do it
// do {
// let data = try await remoteStorage.loadAddressBookContacts()
//
// let storedContacts = try AddressBookClient.decryptData(data)
// latestKnownContacts.value = storedContacts
//
// return storedContacts
// } catch RemoteStorageClient.RemoteStorageError.fileDoesntExist {
// return []
// } catch {
// throw error
// }
return []
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 contacts = latestKnownContacts.value ?? []

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)

// encrypt data
let encryptedContacts = try AddressBookClient.encryptContacts(abContacts)
//let decryptedContacts = try AddressBookClient.decryptData(encryptedContacts)

// push encrypted data to the remote storage
//try await remoteStorage.storeAddressBookContacts(AddressBookClient.encryptContacts(contacts))
// TODO: FIXME
// 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<ABRecord>) 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<ABRecord> {
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

return []
// Deserialize `version`
let versionBytes = data.subdata(in: offset..<(offset + MemoryLayout<Int>.size))
offset += MemoryLayout<Int>.size

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<Int>.size))
offset += MemoryLayout<Int>.size

guard let contactsCount = AddressBookClient.bytesToInt(Array(contactsCountBytes)) else {
return .empty
}

var contacts: [Contact] = []
for _ in 0..<contactsCount {
if let contact = AddressBookClient.deserializeContact(from: data, at: &offset) {
contacts.append(contact)
}
}

let abContacts = AddressBookContacts(
lastUpdated: lastUpdated,
version: AddressBookContacts.Constants.version,
contacts: IdentifiedArrayOf(uniqueElements: contacts)
)

return abContacts
}

private static func serializeContact(_ contact: Contact) -> 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<Int>.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<Int>.size))
offset += MemoryLayout<Int>.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<Int>.size))
offset += MemoryLayout<Int>.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))
}
}
Loading

0 comments on commit 1ef0234

Please sign in to comment.