import Foundation /// Encrypted local message cache. /// Matches Python: chat_core.py message cache (message_cache/{conv_id}.json) enum MessageCache { /// Save messages for a conversation (encrypted with local storage key) static func save(email: String, convId: String, messages: [[String: Any]], cacheKey: Data?) throws { let dir = try KeyStorage.getKeyDir(email: email).appendingPathComponent("message_cache") try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) let jsonData = try JSONSerialization.data(withJSONObject: messages) guard let cacheKey = cacheKey else { return // Refuse to save plaintext message cache } let dataToWrite = try CryptoUtils.encryptLocal(jsonData, key: cacheKey) try dataToWrite.write(to: dir.appendingPathComponent("\(convId).json"), options: .completeFileProtection) } /// Load messages for a conversation static func load(email: String, convId: String, cacheKey: Data?) -> [[String: Any]]? { guard let dir = try? KeyStorage.getKeyDir(email: email) else { return nil } let path = dir.appendingPathComponent("message_cache").appendingPathComponent("\(convId).json") guard let raw = try? Data(contentsOf: path) else { return nil } let jsonData: Data if let cacheKey = cacheKey { if let decrypted = try? CryptoUtils.decryptLocal(raw, key: cacheKey) { jsonData = decrypted } else if let parsed = try? JSONSerialization.jsonObject(with: raw) as? [[String: Any]] { // Migration: re-encrypt plaintext cache and return try? save(email: email, convId: convId, messages: parsed, cacheKey: cacheKey) return parsed } else { // Corrupted — delete stale cache try? FileManager.default.removeItem(at: path) return nil } } else { jsonData = raw } return try? JSONSerialization.jsonObject(with: jsonData) as? [[String: Any]] } /// Search messages in a conversation static func search(email: String, convId: String, query: String, cacheKey: Data?) -> [[String: Any]] { guard let messages = load(email: email, convId: convId, cacheKey: cacheKey) else { return [] } let lowerQuery = query.lowercased() return messages.filter { msg in if let text = msg["text"] as? String, text.lowercased().contains(lowerQuery) { return true } return false } } /// Delete cache for a conversation static func delete(email: String, convId: String) { guard let dir = try? KeyStorage.getKeyDir(email: email) else { return } let path = dir.appendingPathComponent("message_cache").appendingPathComponent("\(convId).json") try? FileManager.default.removeItem(at: path) } // MARK: - Per-Message Cache (for Double Ratchet - messages can only be decrypted once) /// Cache a decrypted message by its ID static func cacheDecryptedMessage(email: String, convId: String, messageId: String, plaintext: Data, cacheKey: Data?) { guard let dir = try? KeyStorage.getKeyDir(email: email) else { return } let cacheDir = dir.appendingPathComponent("decrypted_messages").appendingPathComponent(convId) try? FileManager.default.createDirectory(at: cacheDir, withIntermediateDirectories: true) let path = cacheDir.appendingPathComponent("\(messageId).bin") do { guard let cacheKey = cacheKey else { return } // Refuse plaintext let dataToWrite = try CryptoUtils.encryptLocal(plaintext, key: cacheKey) try dataToWrite.write(to: path, options: .completeFileProtection) } catch { #if DEBUG print("DEBUG MessageCache: failed to cache message \(messageId): \(error)") #endif } } /// Load all cached decrypted messages for a conversation. /// Returns array of (messageId, plaintext) tuples. static func loadAllCachedMessages(email: String, convId: String, cacheKey: Data?) -> [(String, Data)] { guard let dir = try? KeyStorage.getKeyDir(email: email) else { return [] } let cacheDir = dir.appendingPathComponent("decrypted_messages").appendingPathComponent(convId) guard let files = try? FileManager.default.contentsOfDirectory(at: cacheDir, includingPropertiesForKeys: nil) else { return [] } var result: [(String, Data)] = [] for file in files where file.pathExtension == "bin" { let messageId = file.deletingPathExtension().lastPathComponent guard let raw = try? Data(contentsOf: file) else { continue } if let cacheKey = cacheKey, let decrypted = try? CryptoUtils.decryptLocal(raw, key: cacheKey) { result.append((messageId, decrypted)) } else if cacheKey == nil { result.append((messageId, raw)) } } return result } /// Get a cached decrypted message by ID static func getCachedMessage(email: String, convId: String, messageId: String, cacheKey: Data?) -> Data? { guard let dir = try? KeyStorage.getKeyDir(email: email) else { return nil } let path = dir.appendingPathComponent("decrypted_messages").appendingPathComponent(convId).appendingPathComponent("\(messageId).bin") guard let raw = try? Data(contentsOf: path) else { return nil } if let cacheKey = cacheKey { if let decrypted = try? CryptoUtils.decryptLocal(raw, key: cacheKey) { return decrypted } // Migration: try as plaintext, re-encrypt if let _ = try? JSONSerialization.jsonObject(with: raw) { cacheDecryptedMessage(email: email, convId: convId, messageId: messageId, plaintext: raw, cacheKey: cacheKey) return raw } // Corrupted — delete try? FileManager.default.removeItem(at: path) return nil } return raw } // MARK: - Conversation List Cache /// Save conversation list to disk (encrypted with local key) static func saveConversations(email: String, conversations: [Conversation], cacheKey: Data?) { guard let cacheKey = cacheKey else { return } guard let dir = try? KeyStorage.getKeyDir(email: email) else { return } do { let jsonData = try JSONEncoder().encode(conversations) let encrypted = try CryptoUtils.encryptLocal(jsonData, key: cacheKey) try encrypted.write(to: dir.appendingPathComponent("conversation_cache.json"), options: .completeFileProtection) } catch { #if DEBUG print("DEBUG MessageCache: failed to save conversations: \(error)") #endif } } /// Load conversation list from disk static func loadConversations(email: String, cacheKey: Data?) -> [Conversation]? { guard let cacheKey = cacheKey else { return nil } guard let dir = try? KeyStorage.getKeyDir(email: email) else { return nil } let path = dir.appendingPathComponent("conversation_cache.json") guard let raw = try? Data(contentsOf: path) else { return nil } guard let decrypted = try? CryptoUtils.decryptLocal(raw, key: cacheKey) else { return nil } return try? JSONDecoder().decode([Conversation].self, from: decrypted) } // MARK: - Avatar Disk Cache /// Save avatar data to disk (encrypted with local key) static func saveAvatar(email: String, key: String, data: Data, cacheKey: Data?) { guard let cacheKey = cacheKey else { return } guard let dir = try? KeyStorage.getKeyDir(email: email) else { return } let cacheDir = dir.appendingPathComponent("avatar_cache") try? FileManager.default.createDirectory(at: cacheDir, withIntermediateDirectories: true) do { let encrypted = try CryptoUtils.encryptLocal(data, key: cacheKey) try encrypted.write(to: cacheDir.appendingPathComponent("\(key).dat"), options: .completeFileProtection) } catch { #if DEBUG print("DEBUG MessageCache: failed to save avatar \(key): \(error)") #endif } } /// Load avatar data from disk static func loadAvatar(email: String, key: String, cacheKey: Data?) -> Data? { guard let cacheKey = cacheKey else { return nil } guard let dir = try? KeyStorage.getKeyDir(email: email) else { return nil } let path = dir.appendingPathComponent("avatar_cache").appendingPathComponent("\(key).dat") guard let raw = try? Data(contentsOf: path) else { return nil } return try? CryptoUtils.decryptLocal(raw, key: cacheKey) } /// Load all cached avatars from disk static func loadAllAvatars(email: String, cacheKey: Data?) -> [String: Data] { guard let cacheKey = cacheKey else { return [:] } guard let dir = try? KeyStorage.getKeyDir(email: email) else { return [:] } let cacheDir = dir.appendingPathComponent("avatar_cache") guard let files = try? FileManager.default.contentsOfDirectory(at: cacheDir, includingPropertiesForKeys: nil) else { return [:] } var result: [String: Data] = [:] for file in files where file.pathExtension == "dat" { let key = file.deletingPathExtension().lastPathComponent guard let raw = try? Data(contentsOf: file), let decrypted = try? CryptoUtils.decryptLocal(raw, key: cacheKey) else { continue } result[key] = decrypted } return result } }