// // UserMetadataStorage.swift // Zashi // // Created by Lukáš Korba on 2025-01-28. // import Foundation import ZcashLightClientKit import ComposableArchitecture import WalletStorage import RemoteStorage import UserDefaults public class UserMetadataStorage { enum Constants { static let int64Size = MemoryLayout.size static let udUmRTimestamp = "zashi_udUmRTimestamp" } public enum UMError: Error { case documentsFolder case encryptionVersionNotSupported case fileIdentifier case localFileDoesntExist case metadataVersionNotSupported case missingEncryptionKey case subdataRange case serialization } // Bookmarks var bookmarked: [String: UMBookmark] = [:] // Annotations var annotations: [String: UMAnnotation] = [:] // Read var read: [String: String] = [:] public init() { } // MARK: - General func filenameForEncryptedFile(account: Account) throws -> String { @Dependency(\.walletStorage) var walletStorage guard let encryptionKeys = try? walletStorage.exportUserMetadataEncryptionKeys(account), let umKey = encryptionKeys.getCached(account: account) else { throw UMError.missingEncryptionKey } guard let filename = umKey.fileIdentifier(account: account) else { throw UMError.fileIdentifier } return filename } public func reset() throws { bookmarked.removeAll() annotations.removeAll() read.removeAll() @Dependency(\.userDefaults) var userDefaults userDefaults.remove(Constants.udUmRTimestamp) } public func resetAccount(_ account: Account) throws { // store encrypted data to the local storage guard let documentsDirectory = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first else { throw UMError.documentsFolder } let filenameForEncryptedFile = try filenameForEncryptedFile(account: account) let fileURL = documentsDirectory.appendingPathComponent(filenameForEncryptedFile) try FileManager.default.removeItem(at: fileURL) @Dependency(\.remoteStorage) var remoteStorage // try to remove the remote as well try? remoteStorage.removeFile(filenameForEncryptedFile) } public func store(account: Account) throws { // store encrypted data to the local storage guard let documentsDirectory = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first else { throw UMError.documentsFolder } let filenameForEncryptedFile = try filenameForEncryptedFile(account: account) let fileURL = documentsDirectory.appendingPathComponent(filenameForEncryptedFile) let metadata = userMetadataFromMemory() let encryptedUMData = try UserMetadata.encryptUserMetadata(metadata, account: account) try encryptedUMData.write(to: fileURL) @Dependency(\.remoteStorage) var remoteStorage // always push the latest data to the remote try? remoteStorage.storeDataToFile(encryptedUMData, filenameForEncryptedFile) } public func load(account: Account) throws { resolveReadTimestamp() do { guard let localData = try loadLocal(account: account) else { checkRemoteAndEventuallyFillMemory(account: account) return } fillMemoryWith(localData) } catch UMError.localFileDoesntExist { checkRemoteAndEventuallyFillMemory(account: account) } catch { checkRemoteAndEventuallyFillMemory(account: account) } return } func loadLocal(account: Account) throws -> UserMetadata? { // load local data guard let documentsDirectory = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first else { throw UMError.documentsFolder } // Try to find and get the data from the encrypted file with the latest encryption version let encryptedFileURL = documentsDirectory.appendingPathComponent(try filenameForEncryptedFile(account: account)) if !FileManager.default.fileExists(atPath: encryptedFileURL.path) { throw UMError.localFileDoesntExist } if let encryptedUMData = try? Data(contentsOf: encryptedFileURL) { return try UserMetadata.userMetadataFrom(encryptedData: encryptedUMData, account: account) } return nil } func resolveReadTimestamp() { @Dependency(\.userDefaults) var userDefaults guard let _ = userDefaults.objectForKey(Constants.udUmRTimestamp) as? TimeInterval else { userDefaults.setValue(Date().timeIntervalSince1970, Constants.udUmRTimestamp) return } } func checkRemoteAndEventuallyFillMemory(account: Account) { @Dependency(\.remoteStorage) var remoteStorage guard let filenameForEncryptedFile = try? filenameForEncryptedFile(account: account) else { return } if let encryptedUMData = try? remoteStorage.loadDataFromFile(filenameForEncryptedFile), let umData = try? UserMetadata.userMetadataFrom(encryptedData: encryptedUMData, account: account) { fillMemoryWith(umData) try? store(account: account) } } public func fillMemoryWith(_ umData: UserMetadata) { umData.accountMetadata.bookmarked.forEach { bookmark in bookmarked[bookmark.txId] = bookmark } umData.accountMetadata.read.forEach { umRead in read[umRead] = umRead } umData.accountMetadata.annotations.forEach { annotation in annotations[annotation.txId] = annotation } } public func userMetadataFromMemory() -> UserMetadata { let umBookmarked = bookmarked.map { $0.value } let umAnnotations = annotations.map { $0.value } let umRead = read.map { $0.value } let umAccount = UMAccount( bookmarked: umBookmarked, annotations: umAnnotations, read: umRead ) return UserMetadata( version: UserMetadata.Constants.version, lastUpdated: Int64(Date().timeIntervalSince1970 * 1000), accountMetadata: umAccount ) } // MARK: - Bookmarking public func isBookmarked(txId: String) -> Bool { bookmarked[txId]?.isBookmarked ?? false } public func toggleBookmarkFor(txId: String) { guard let existingBookmark = bookmarked[txId] else { bookmarked[txId] = UMBookmark( txId: txId, lastUpdated: Int64(Date().timeIntervalSince1970 * 1000), isBookmarked: true ) return } bookmarked[txId] = UMBookmark( txId: txId, lastUpdated: Int64(Date().timeIntervalSince1970 * 1000), isBookmarked: !existingBookmark.isBookmarked ) } // MARK: - Annotations public func annotationFor(txId: String) -> String? { annotations[txId]?.content } public func add(annotation: String, for txId: String) { annotations[txId] = UMAnnotation( txId: txId, content: annotation, lastUpdated: Int64(Date().timeIntervalSince1970 * 1000) ) } public func deleteAnnotationFor(txId: String) { annotations.removeValue(forKey: txId) } // MARK: - Unread public func isRead(txId: String, txTimestamp: TimeInterval?) -> Bool { @Dependency(\.userDefaults) var userDefaults // read because the transaction happened before user metadata were introduced if let umRTimestamp = userDefaults.objectForKey(Constants.udUmRTimestamp) as? TimeInterval, let txTimestamp { if txTimestamp < umRTimestamp { return true } } return read[txId] != nil } public func readTx(txId: String) { read[txId] = txId } }