secant-ios-wallet/modules/Sources/Dependencies/UserMetadataProvider/UserMetadataStorage.swift

261 lines
8.2 KiB
Swift

//
// 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<Int64>.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
}
}