ios_client
This commit is contained in:
200
ios_client 0.8.5/Kecalek/Core/MessageCache.swift
Normal file
200
ios_client 0.8.5/Kecalek/Core/MessageCache.swift
Normal file
@@ -0,0 +1,200 @@
|
||||
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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user