diff --git a/Rosetta/Core/Crypto/XChaCha20Engine.swift b/Rosetta/Core/Crypto/XChaCha20Engine.swift index 9a588e7..17778b3 100644 --- a/Rosetta/Core/Crypto/XChaCha20Engine.swift +++ b/Rosetta/Core/Crypto/XChaCha20Engine.swift @@ -7,6 +7,7 @@ import Foundation enum XChaCha20Engine { nonisolated static let poly1305TagSize = 16 + nonisolated private static let isNativeCryptoDisabled = ProcessInfo.processInfo.environment["ROSETTA_DISABLE_NATIVE_CRYPTO"] == "1" // MARK: - XChaCha20-Poly1305 Decrypt @@ -19,12 +20,14 @@ enum XChaCha20Engine { throw CryptoError.invalidData("Key must be 32 bytes, nonce must be 24 bytes") } - if let native = NativeCryptoBridge.xChaCha20Poly1305Decrypt( - ciphertextWithTag, - key: key, - nonce: nonce - ) { - return native + if !isNativeCryptoDisabled { + if let native = NativeCryptoBridge.xChaCha20Poly1305Decrypt( + ciphertextWithTag, + key: key, + nonce: nonce + ) { + return native + } } let ciphertext = ciphertextWithTag[0..<(ciphertextWithTag.count - poly1305TagSize)] @@ -61,12 +64,14 @@ enum XChaCha20Engine { throw CryptoError.invalidData("Key must be 32 bytes, nonce must be 24 bytes") } - if let native = NativeCryptoBridge.xChaCha20Poly1305Encrypt( - plaintext, - key: key, - nonce: nonce - ) { - return native + if !isNativeCryptoDisabled { + if let native = NativeCryptoBridge.xChaCha20Poly1305Encrypt( + plaintext, + key: key, + nonce: nonce + ) { + return native + } } // Step 1: HChaCha20 — derive subkey diff --git a/Rosetta/Core/Data/Database/DatabaseManager.swift b/Rosetta/Core/Data/Database/DatabaseManager.swift index cf84dae..18c6ba9 100644 --- a/Rosetta/Core/Data/Database/DatabaseManager.swift +++ b/Rosetta/Core/Data/Database/DatabaseManager.swift @@ -54,12 +54,22 @@ final class DatabaseManager { t.column("account", .text).notNull() t.column("from_public_key", .text).notNull() t.column("to_public_key", .text).notNull() + // Desktop/Android parity: encrypted payload as received from network. + t.column("content", .text).notNull().defaults(to: "") + t.column("chacha_key", .text).notNull().defaults(to: "") t.column("text", .text).notNull().defaults(to: "") + // Android/Desktop naming parity (alias of `text` in iOS pipeline). + t.column("plain_message", .text).notNull().defaults(to: "") t.column("timestamp", .integer).notNull() t.column("is_read", .integer).notNull().defaults(to: 0) + // Android/Desktop naming parity (alias of `is_read`). + t.column("read", .integer).notNull().defaults(to: 0) t.column("from_me", .integer).notNull().defaults(to: 0) t.column("delivery_status", .integer).notNull().defaults(to: 0) + // Android/Desktop naming parity (alias of `delivery_status`). + t.column("delivered", .integer).notNull().defaults(to: 0) t.column("message_id", .text).notNull() + t.column("reply_to_message_id", .text) t.column("dialog_key", .text).notNull() t.column("attachments", .text).notNull().defaults(to: "[]") t.column("attachment_password", .text) @@ -131,7 +141,418 @@ final class DatabaseManager { try db.execute(sql: "UPDATE messages SET delivery_status = 1 WHERE delivery_status = 3") // Step 3: Add last_message_read to dialogs (Android: lastMessageRead column). - try db.execute(sql: "ALTER TABLE dialogs ADD COLUMN last_message_read INTEGER NOT NULL DEFAULT 0") + let dialogColumns = Set(try db.columns(in: "dialogs").map(\.name)) + if !dialogColumns.contains("last_message_read") { + try db.execute(sql: "ALTER TABLE dialogs ADD COLUMN last_message_read INTEGER NOT NULL DEFAULT 0") + } + } + + // v4: align critical schema gaps with Android/Desktop without breaking existing iOS code. + migrator.registerMigration("v4_schema_parity_android_desktop") { db in + // Android parity index used by participant-pair queries and migration tooling. + try db.execute( + sql: """ + CREATE INDEX IF NOT EXISTS idx_messages_account_from_to_timestamp + ON messages(account, from_public_key, to_public_key, timestamp) + """ + ) + + // Android parity: dialogs cache attachments of the latest message. + let dialogColumns = Set(try db.columns(in: "dialogs").map(\.name)) + if !dialogColumns.contains("last_message_attachments") { + try db.execute( + sql: "ALTER TABLE dialogs ADD COLUMN last_message_attachments TEXT NOT NULL DEFAULT '[]'" + ) + } + + // Backfill attachment cache for existing dialogs from current messages. + try db.execute( + sql: """ + UPDATE dialogs + SET last_message_attachments = COALESCE(( + SELECT m.attachments + FROM messages m + WHERE m.account = dialogs.account + AND m.dialog_key = CASE + WHEN dialogs.account = dialogs.opponent_key THEN dialogs.account + WHEN dialogs.account < dialogs.opponent_key THEN dialogs.account || ':' || dialogs.opponent_key + ELSE dialogs.opponent_key || ':' || dialogs.account + END + ORDER BY m.timestamp DESC, m.message_id DESC + LIMIT 1 + ), '[]') + WHERE last_message_attachments = '[]' + """ + ) + + // Android/Desktop naming parity for sync cursor storage. + try db.execute( + sql: """ + CREATE TABLE IF NOT EXISTS accounts_sync_times ( + account TEXT NOT NULL PRIMARY KEY, + last_sync INTEGER NOT NULL + ) + """ + ) + + if try db.tableExists("sync_cursors") { + try db.execute( + sql: """ + INSERT INTO accounts_sync_times (account, last_sync) + SELECT account, timestamp FROM sync_cursors + ON CONFLICT(account) DO UPDATE SET last_sync = excluded.last_sync + """ + ) + } + + // Android parity tables (prepared for shared features). + try db.execute( + sql: """ + CREATE TABLE IF NOT EXISTS pinned_messages ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + account TEXT NOT NULL, + dialog_key TEXT NOT NULL, + message_id TEXT NOT NULL, + pinned_at INTEGER NOT NULL + ) + """ + ) + try db.execute( + sql: """ + CREATE UNIQUE INDEX IF NOT EXISTS index_pinned_messages_account_dialog_key_message_id + ON pinned_messages(account, dialog_key, message_id) + """ + ) + try db.execute( + sql: """ + CREATE INDEX IF NOT EXISTS index_pinned_messages_account_dialog_key_pinned_at + ON pinned_messages(account, dialog_key, pinned_at) + """ + ) + + try db.execute( + sql: """ + CREATE TABLE IF NOT EXISTS groups ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + account TEXT NOT NULL, + group_id TEXT NOT NULL, + title TEXT NOT NULL, + description TEXT NOT NULL, + "key" TEXT NOT NULL + ) + """ + ) + try db.execute( + sql: """ + CREATE UNIQUE INDEX IF NOT EXISTS index_groups_account_group_id + ON groups(account, group_id) + """ + ) + + try db.execute( + sql: """ + CREATE TABLE IF NOT EXISTS avatar_cache ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + public_key TEXT NOT NULL, + avatar TEXT NOT NULL, + timestamp INTEGER NOT NULL + ) + """ + ) + try db.execute( + sql: """ + CREATE INDEX IF NOT EXISTS index_avatar_cache_public_key_timestamp + ON avatar_cache(public_key, timestamp) + """ + ) + + try db.execute( + sql: """ + CREATE TABLE IF NOT EXISTS blacklist ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + public_key TEXT NOT NULL, + account TEXT NOT NULL + ) + """ + ) + try db.execute( + sql: """ + CREATE UNIQUE INDEX IF NOT EXISTS index_blacklist_account_public_key + ON blacklist(account, public_key) + """ + ) + } + + // v5: full schema superset parity with Android + Desktop, preserving iOS contracts. + migrator.registerMigration("v5_full_schema_superset_parity") { db in + func hasColumn(_ table: String, _ column: String) throws -> Bool { + try db.columns(in: table).contains { $0.name == column } + } + + // MARK: - messages parity columns + + if try !hasColumn("messages", "content") { + try db.execute(sql: "ALTER TABLE messages ADD COLUMN content TEXT NOT NULL DEFAULT ''") + } + if try !hasColumn("messages", "chacha_key") { + try db.execute(sql: "ALTER TABLE messages ADD COLUMN chacha_key TEXT NOT NULL DEFAULT ''") + } + if try !hasColumn("messages", "plain_message") { + try db.execute(sql: "ALTER TABLE messages ADD COLUMN plain_message TEXT NOT NULL DEFAULT ''") + } + if try !hasColumn("messages", "read") { + try db.execute(sql: "ALTER TABLE messages ADD COLUMN read INTEGER NOT NULL DEFAULT 0") + } + if try !hasColumn("messages", "delivered") { + try db.execute(sql: "ALTER TABLE messages ADD COLUMN delivered INTEGER NOT NULL DEFAULT 0") + } + if try !hasColumn("messages", "reply_to_message_id") { + try db.execute(sql: "ALTER TABLE messages ADD COLUMN reply_to_message_id TEXT") + } + + try db.execute( + sql: """ + UPDATE messages + SET read = is_read, + delivered = delivery_status, + plain_message = CASE + WHEN plain_message = '' THEN text + ELSE plain_message + END + """ + ) + + // Keep iOS-native and Android/Desktop alias columns synchronized. + try db.execute(sql: "DROP TRIGGER IF EXISTS trg_messages_ai_sync_aliases") + try db.execute(sql: "DROP TRIGGER IF EXISTS trg_messages_au_sync_aliases") + try db.execute( + sql: """ + CREATE TRIGGER trg_messages_ai_sync_aliases + AFTER INSERT ON messages + BEGIN + UPDATE messages + SET read = NEW.is_read, + delivered = NEW.delivery_status, + plain_message = CASE + WHEN NEW.plain_message = '' THEN NEW.text + ELSE NEW.plain_message + END + WHERE id = NEW.id; + END + """ + ) + try db.execute( + sql: """ + CREATE TRIGGER trg_messages_au_sync_aliases + AFTER UPDATE OF is_read, delivery_status, text ON messages + BEGIN + UPDATE messages + SET read = NEW.is_read, + delivered = NEW.delivery_status, + plain_message = NEW.text + WHERE id = NEW.id; + END + """ + ) + + // MARK: - dialogs parity alias columns + + if try !hasColumn("dialogs", "dialog_id") { + try db.execute(sql: "ALTER TABLE dialogs ADD COLUMN dialog_id TEXT NOT NULL DEFAULT ''") + } + if try !hasColumn("dialogs", "last_message_id") { + try db.execute(sql: "ALTER TABLE dialogs ADD COLUMN last_message_id TEXT NOT NULL DEFAULT ''") + } + if try !hasColumn("dialogs", "last_timestamp") { + try db.execute(sql: "ALTER TABLE dialogs ADD COLUMN last_timestamp INTEGER NOT NULL DEFAULT 0") + } + if try !hasColumn("dialogs", "is_request") { + try db.execute(sql: "ALTER TABLE dialogs ADD COLUMN is_request INTEGER NOT NULL DEFAULT 0") + } + + try db.execute( + sql: """ + UPDATE dialogs + SET dialog_id = CASE + WHEN dialog_id = '' THEN opponent_key + ELSE dialog_id + END, + last_timestamp = last_message_timestamp, + is_request = CASE WHEN i_have_sent = 1 THEN 0 ELSE 1 END + """ + ) + + try db.execute( + sql: """ + UPDATE dialogs + SET last_message_id = COALESCE(( + SELECT m.message_id + FROM messages m + WHERE m.account = dialogs.account + AND m.dialog_key = CASE + WHEN dialogs.account = dialogs.opponent_key THEN dialogs.account + WHEN LOWER(TRIM(dialogs.opponent_key)) LIKE '#group:%' THEN dialogs.opponent_key + WHEN LOWER(TRIM(dialogs.opponent_key)) LIKE 'group:%' THEN dialogs.opponent_key + WHEN dialogs.account < dialogs.opponent_key THEN dialogs.account || ':' || dialogs.opponent_key + ELSE dialogs.opponent_key || ':' || dialogs.account + END + ORDER BY m.timestamp DESC, m.message_id DESC + LIMIT 1 + ), last_message_id) + """ + ) + + try db.execute(sql: "DROP TRIGGER IF EXISTS trg_dialogs_ai_sync_aliases") + try db.execute(sql: "DROP TRIGGER IF EXISTS trg_dialogs_au_sync_aliases") + try db.execute( + sql: """ + CREATE TRIGGER trg_dialogs_ai_sync_aliases + AFTER INSERT ON dialogs + BEGIN + UPDATE dialogs + SET dialog_id = NEW.opponent_key, + last_timestamp = NEW.last_message_timestamp, + is_request = CASE WHEN NEW.i_have_sent = 1 THEN 0 ELSE 1 END, + last_message_id = COALESCE(( + SELECT m.message_id + FROM messages m + WHERE m.account = NEW.account + AND m.dialog_key = CASE + WHEN NEW.account = NEW.opponent_key THEN NEW.account + WHEN LOWER(TRIM(NEW.opponent_key)) LIKE '#group:%' THEN NEW.opponent_key + WHEN LOWER(TRIM(NEW.opponent_key)) LIKE 'group:%' THEN NEW.opponent_key + WHEN NEW.account < NEW.opponent_key THEN NEW.account || ':' || NEW.opponent_key + ELSE NEW.opponent_key || ':' || NEW.account + END + ORDER BY m.timestamp DESC, m.message_id DESC + LIMIT 1 + ), last_message_id), + last_message_attachments = COALESCE(( + SELECT m.attachments + FROM messages m + WHERE m.account = NEW.account + AND m.dialog_key = CASE + WHEN NEW.account = NEW.opponent_key THEN NEW.account + WHEN LOWER(TRIM(NEW.opponent_key)) LIKE '#group:%' THEN NEW.opponent_key + WHEN LOWER(TRIM(NEW.opponent_key)) LIKE 'group:%' THEN NEW.opponent_key + WHEN NEW.account < NEW.opponent_key THEN NEW.account || ':' || NEW.opponent_key + ELSE NEW.opponent_key || ':' || NEW.account + END + ORDER BY m.timestamp DESC, m.message_id DESC + LIMIT 1 + ), '[]') + WHERE id = NEW.id; + END + """ + ) + try db.execute( + sql: """ + CREATE TRIGGER trg_dialogs_au_sync_aliases + AFTER UPDATE OF opponent_key, last_message_timestamp, i_have_sent, account ON dialogs + BEGIN + UPDATE dialogs + SET dialog_id = NEW.opponent_key, + last_timestamp = NEW.last_message_timestamp, + is_request = CASE WHEN NEW.i_have_sent = 1 THEN 0 ELSE 1 END, + last_message_id = COALESCE(( + SELECT m.message_id + FROM messages m + WHERE m.account = NEW.account + AND m.dialog_key = CASE + WHEN NEW.account = NEW.opponent_key THEN NEW.account + WHEN LOWER(TRIM(NEW.opponent_key)) LIKE '#group:%' THEN NEW.opponent_key + WHEN LOWER(TRIM(NEW.opponent_key)) LIKE 'group:%' THEN NEW.opponent_key + WHEN NEW.account < NEW.opponent_key THEN NEW.account || ':' || NEW.opponent_key + ELSE NEW.opponent_key || ':' || NEW.account + END + ORDER BY m.timestamp DESC, m.message_id DESC + LIMIT 1 + ), last_message_id), + last_message_attachments = COALESCE(( + SELECT m.attachments + FROM messages m + WHERE m.account = NEW.account + AND m.dialog_key = CASE + WHEN NEW.account = NEW.opponent_key THEN NEW.account + WHEN LOWER(TRIM(NEW.opponent_key)) LIKE '#group:%' THEN NEW.opponent_key + WHEN LOWER(TRIM(NEW.opponent_key)) LIKE 'group:%' THEN NEW.opponent_key + WHEN NEW.account < NEW.opponent_key THEN NEW.account || ':' || NEW.opponent_key + ELSE NEW.opponent_key || ':' || NEW.account + END + ORDER BY m.timestamp DESC, m.message_id DESC + LIMIT 1 + ), '[]') + WHERE id = NEW.id; + END + """ + ) + + // MARK: - Desktop-only tables (for full schema parity surface) + + try db.execute( + sql: """ + CREATE TABLE IF NOT EXISTS accounts ( + public_key TEXT PRIMARY KEY, + private_key TEXT NOT NULL, + sfen TEXT NOT NULL + ) + """ + ) + + try db.execute( + sql: """ + CREATE TABLE IF NOT EXISTS avatar_delivery ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + public_key TEXT NOT NULL, + account TEXT NOT NULL + ) + """ + ) + + try db.execute( + sql: """ + CREATE TABLE IF NOT EXISTS cached_users ( + public_key TEXT PRIMARY KEY, + title TEXT NOT NULL, + username TEXT NOT NULL, + verified INTEGER NOT NULL DEFAULT 0 + ) + """ + ) + + // Android accounts table parity (iOS still uses Keychain as source of truth). + try db.execute( + sql: """ + CREATE TABLE IF NOT EXISTS encrypted_accounts ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + public_key TEXT NOT NULL, + private_key_encrypted TEXT NOT NULL, + seed_phrase_encrypted TEXT NOT NULL, + created_at TEXT NOT NULL, + last_used TEXT, + is_active INTEGER NOT NULL DEFAULT 1, + username TEXT + ) + """ + ) + try db.execute( + sql: """ + CREATE UNIQUE INDEX IF NOT EXISTS index_encrypted_accounts_public_key + ON encrypted_accounts(public_key) + """ + ) + + // Desktop shape compatibility (optional id while account remains canonical key). + if try !hasColumn("accounts_sync_times", "id") { + try db.execute(sql: "ALTER TABLE accounts_sync_times ADD COLUMN id INTEGER") + try db.execute( + sql: """ + UPDATE accounts_sync_times + SET id = rowid + WHERE id IS NULL OR id = 0 + """ + ) + } } try migrator.migrate(pool) @@ -216,19 +637,29 @@ final class DatabaseManager { func loadSyncCursor(account: String) -> Int64 { do { let stored = try read { db in - try Int64.fetchOne(db, - sql: "SELECT timestamp FROM sync_cursors WHERE account = ?", + if let lastSync = try Int64.fetchOne( + db, + sql: "SELECT last_sync FROM accounts_sync_times WHERE account = ?", arguments: [account] - ) ?? 0 + ) { + return lastSync + } + + if try db.tableExists("sync_cursors") { + return try Int64.fetchOne( + db, + sql: "SELECT timestamp FROM sync_cursors WHERE account = ?", + arguments: [account] + ) ?? 0 + } + + return 0 } // Android parity: normalize seconds → milliseconds if stored > 0, stored < 1_000_000_000_000 { let corrected = stored * 1000 try? writeSync { db in - try db.execute( - sql: "INSERT INTO sync_cursors (account, timestamp) VALUES (?, ?) ON CONFLICT(account) DO UPDATE SET timestamp = excluded.timestamp", - arguments: [account, corrected] - ) + try upsertSyncCursor(db: db, account: account, timestamp: corrected) } return corrected } @@ -243,14 +674,31 @@ final class DatabaseManager { guard timestamp > existing else { return } do { try writeSync { db in - try db.execute( - sql: "INSERT INTO sync_cursors (account, timestamp) VALUES (?, ?) ON CONFLICT(account) DO UPDATE SET timestamp = excluded.timestamp", - arguments: [account, timestamp] - ) + try upsertSyncCursor(db: db, account: account, timestamp: timestamp) } } catch {} } + private func upsertSyncCursor(db: Database, account: String, timestamp: Int64) throws { + try db.execute( + sql: """ + INSERT INTO accounts_sync_times (account, last_sync) VALUES (?, ?) + ON CONFLICT(account) DO UPDATE SET last_sync = excluded.last_sync + """, + arguments: [account, timestamp] + ) + + if try db.tableExists("sync_cursors") { + try db.execute( + sql: """ + INSERT INTO sync_cursors (account, timestamp) VALUES (?, ?) + ON CONFLICT(account) DO UPDATE SET timestamp = excluded.timestamp + """, + arguments: [account, timestamp] + ) + } + } + // MARK: - Delete /// Delete all data for the given account (used on account delete). diff --git a/Rosetta/Core/Data/Database/MessageRecord.swift b/Rosetta/Core/Data/Database/MessageRecord.swift index 9c3b999..38466b0 100644 --- a/Rosetta/Core/Data/Database/MessageRecord.swift +++ b/Rosetta/Core/Data/Database/MessageRecord.swift @@ -10,12 +10,18 @@ struct MessageRecord: Codable, FetchableRecord, MutablePersistableRecord, Sendab var account: String var fromPublicKey: String var toPublicKey: String + var content: String + var chachaKey: String var text: String + var plainMessage: String var timestamp: Int64 var isRead: Int + var readAlias: Int var fromMe: Int var deliveryStatus: Int + var deliveredAlias: Int var messageId: String + var replyToMessageId: String? var dialogKey: String var attachments: String var attachmentPassword: String? @@ -26,11 +32,17 @@ struct MessageRecord: Codable, FetchableRecord, MutablePersistableRecord, Sendab case id, account case fromPublicKey = "from_public_key" case toPublicKey = "to_public_key" + case content + case chachaKey = "chacha_key" case text, timestamp + case plainMessage = "plain_message" case isRead = "is_read" + case readAlias = "read" case fromMe = "from_me" case deliveryStatus = "delivery_status" + case deliveredAlias = "delivered" case messageId = "message_id" + case replyToMessageId = "reply_to_message_id" case dialogKey = "dialog_key" case attachments case attachmentPassword = "attachment_password" @@ -40,11 +52,17 @@ struct MessageRecord: Codable, FetchableRecord, MutablePersistableRecord, Sendab case id, account case fromPublicKey = "from_public_key" case toPublicKey = "to_public_key" + case content + case chachaKey = "chacha_key" case text, timestamp + case plainMessage = "plain_message" case isRead = "is_read" + case readAlias = "read" case fromMe = "from_me" case deliveryStatus = "delivery_status" + case deliveredAlias = "delivered" case messageId = "message_id" + case replyToMessageId = "reply_to_message_id" case dialogKey = "dialog_key" case attachments case attachmentPassword = "attachment_password" @@ -72,10 +90,10 @@ struct MessageRecord: Codable, FetchableRecord, MutablePersistableRecord, Sendab id: messageId, fromPublicKey: fromPublicKey, toPublicKey: toPublicKey, - text: overrideText ?? text, + text: overrideText ?? (plainMessage.isEmpty ? text : plainMessage), timestamp: timestamp, deliveryStatus: DeliveryStatus(rawValue: deliveryStatus) ?? .waiting, - isRead: isRead != 0, + isRead: (isRead != 0) || (readAlias != 0), attachments: parsedAttachments, attachmentPassword: attachmentPassword ) @@ -97,12 +115,18 @@ struct MessageRecord: Codable, FetchableRecord, MutablePersistableRecord, Sendab account: account, fromPublicKey: message.fromPublicKey, toPublicKey: message.toPublicKey, + content: "", + chachaKey: "", text: message.text, + plainMessage: message.text, timestamp: message.timestamp, isRead: message.isRead ? 1 : 0, + readAlias: message.isRead ? 1 : 0, fromMe: fromMe ? 1 : 0, deliveryStatus: message.deliveryStatus.rawValue, + deliveredAlias: message.deliveryStatus.rawValue, messageId: message.id, + replyToMessageId: nil, dialogKey: DatabaseManager.dialogKey(account: account, opponentKey: fromMe ? message.toPublicKey : message.fromPublicKey), attachments: attachmentsJSON, attachmentPassword: message.attachmentPassword diff --git a/Rosetta/Core/Data/Repositories/MessageRepository.swift b/Rosetta/Core/Data/Repositories/MessageRepository.swift index 253f683..b3de786 100644 --- a/Rosetta/Core/Data/Repositories/MessageRepository.swift +++ b/Rosetta/Core/Data/Repositories/MessageRepository.swift @@ -360,7 +360,9 @@ final class MessageRepository: ObservableObject { // Update existing message (store encrypted text) try db.execute( sql: """ - UPDATE messages SET text = ?, timestamp = ?, attachments = ?, + UPDATE messages SET content = COALESCE(NULLIF(?, ''), content), + chacha_key = COALESCE(NULLIF(?, ''), chacha_key), + text = ?, plain_message = ?, timestamp = ?, attachments = ?, attachment_password = COALESCE(?, attachment_password), delivery_status = CASE WHEN from_me = 1 AND delivery_status = 2 THEN ? @@ -370,7 +372,8 @@ final class MessageRepository: ObservableObject { WHERE account = ? AND message_id = ? """, arguments: [ - storedText, timestamp, finalAttachments, + packet.content, packet.chachaKey, + storedText, storedText, timestamp, finalAttachments, attachmentPassword, fromSync ? DeliveryStatus.delivered.rawValue : DeliveryStatus.waiting.rawValue, incomingRead ? 1 : 0, @@ -387,12 +390,18 @@ final class MessageRepository: ObservableObject { account: myPublicKey, fromPublicKey: packet.fromPublicKey, toPublicKey: packet.toPublicKey, + content: packet.content, + chachaKey: packet.chachaKey, text: storedText, + plainMessage: storedText, timestamp: timestamp, isRead: isRead ? 1 : 0, + readAlias: isRead ? 1 : 0, fromMe: fromMe ? 1 : 0, deliveryStatus: outgoingStatus.rawValue, + deliveredAlias: outgoingStatus.rawValue, messageId: messageId, + replyToMessageId: nil, dialogKey: dialogKey, attachments: attachmentsJSON, attachmentPassword: attachmentPassword diff --git a/Rosetta/Core/Utils/BlurHash.swift b/Rosetta/Core/Utils/BlurHash.swift index 28b597c..d914dc8 100644 --- a/Rosetta/Core/Utils/BlurHash.swift +++ b/Rosetta/Core/Utils/BlurHash.swift @@ -189,6 +189,7 @@ enum BlurHashEncoder { /// - Buffer: CFDataCreateMutable (canonical) /// - Punch parameter for color intensity control (Android parity) enum BlurHashDecoder { + nonisolated private static let isNativeBlurHashDisabled = ProcessInfo.processInfo.environment["ROSETTA_DISABLE_NATIVE_BLURHASH"] == "1" /// Decodes a BlurHash string into a UIImage. /// @@ -201,14 +202,16 @@ enum BlurHashDecoder { nonisolated static func decode(blurHash: String, width: Int = 32, height: Int = 32, punch: Float = 1) -> UIImage? { guard blurHash.count >= 6 else { return nil } - if let nativeRGB = NativeBlurHashBridge.decodeBlurHash( - blurHash, - width: width, - height: height, - punch: punch - ), - let nativeImage = makeImageFromRGBData(nativeRGB, width: width, height: height) { - return nativeImage + if !isNativeBlurHashDisabled { + if let nativeRGB = NativeBlurHashBridge.decodeBlurHash( + blurHash, + width: width, + height: height, + punch: punch + ), + let nativeImage = makeImageFromRGBData(nativeRGB, width: width, height: height) { + return nativeImage + } } let sizeFlag = decodeBase83(blurHash, from: 0, length: 1) diff --git a/Rosetta/Core/Utils/DebugPerformanceBenchmarks.swift b/Rosetta/Core/Utils/DebugPerformanceBenchmarks.swift new file mode 100644 index 0000000..4e6eda7 --- /dev/null +++ b/Rosetta/Core/Utils/DebugPerformanceBenchmarks.swift @@ -0,0 +1,145 @@ +import Foundation +import os +import UIKit + +#if DEBUG +/// Lightweight startup benchmarks for local before/after performance checks. +/// Run with launch env: +/// `ROSETTA_RUN_BENCHMARKS=1` and optionally `ROSETTA_BENCHMARK_EXIT=1`. +enum DebugPerformanceBenchmarks { + nonisolated private static let logger = Logger(subsystem: "com.rosetta.messenger", category: "Bench") + + nonisolated static func runIfRequested() { + guard ProcessInfo.processInfo.environment["ROSETTA_RUN_BENCHMARKS"] == "1" else { return } + + Task.detached(priority: .userInitiated) { + let nativeCryptoDisabled = ProcessInfo.processInfo.environment["ROSETTA_DISABLE_NATIVE_CRYPTO"] == "1" + let nativeBlurHashDisabled = ProcessInfo.processInfo.environment["ROSETTA_DISABLE_NATIVE_BLURHASH"] == "1" + + let beginLine = "BENCH_BEGIN native_crypto_disabled=\(nativeCryptoDisabled) native_blurhash_disabled=\(nativeBlurHashDisabled)" + print(beginLine) + logger.info("\(beginLine)") + + do { + let cryptoResult = try benchmarkXChaCha20() + let blurResult = benchmarkBlurHash() + let streamResult = await MainActor.run { + benchmarkStream() + } + + let cryptoLine = + "BENCH_CRYPTO encrypt_ms_total=\(fmt(cryptoResult.encryptTotalMs)) encrypt_ms_per_op=\(fmt(cryptoResult.encryptMsPerOp)) decrypt_ms_total=\(fmt(cryptoResult.decryptTotalMs)) decrypt_ms_per_op=\(fmt(cryptoResult.decryptMsPerOp))" + let blurLine = + "BENCH_BLURHASH decode_ms_total=\(fmt(blurResult.totalMs)) decode_ms_per_op=\(fmt(blurResult.msPerOp))" + let streamLine = + "BENCH_STREAM write_ms_total=\(fmt(streamResult.writeTotalMs)) write_ms_per_op=\(fmt(streamResult.writeMsPerOp)) read_ms_total=\(fmt(streamResult.readTotalMs)) read_ms_per_op=\(fmt(streamResult.readMsPerOp))" + print(cryptoLine) + print(blurLine) + print(streamLine) + print("BENCH_END") + logger.info("\(cryptoLine)") + logger.info("\(blurLine)") + logger.info("\(streamLine)") + logger.info("BENCH_END") + } catch { + let errorLine = "BENCH_ERROR \(error)" + print(errorLine) + logger.error("\(errorLine)") + } + + if ProcessInfo.processInfo.environment["ROSETTA_BENCHMARK_EXIT"] == "1" { + exit(0) + } + } + } + + nonisolated private static func benchmarkXChaCha20() throws -> ( + encryptTotalMs: Double, + encryptMsPerOp: Double, + decryptTotalMs: Double, + decryptMsPerOp: Double + ) { + let payload = Data((0..<1024).map { UInt8($0 & 0xFF) }) + let key = Data((0..<32).map { UInt8(($0 + 1) & 0xFF) }) + let nonce = Data((0..<24).map { UInt8(($0 + 2) & 0xFF) }) + let iterations = 1000 + + let encryptTotalMs = try measureMs(iterations: iterations) { + _ = try XChaCha20Engine.encrypt(plaintext: payload, key: key, nonce: nonce) + } + + let ciphertext = try XChaCha20Engine.encrypt(plaintext: payload, key: key, nonce: nonce) + let decryptTotalMs = try measureMs(iterations: iterations) { + _ = try XChaCha20Engine.decrypt(ciphertextWithTag: ciphertext, key: key, nonce: nonce) + } + + return ( + encryptTotalMs: encryptTotalMs, + encryptMsPerOp: encryptTotalMs / Double(iterations), + decryptTotalMs: decryptTotalMs, + decryptMsPerOp: decryptTotalMs / Double(iterations) + ) + } + + nonisolated private static func benchmarkBlurHash() -> (totalMs: Double, msPerOp: Double) { + let blurHash = "LEHV6nWB2yk8pyo0adR*.7kCMdnj" + let iterations = 800 + + let totalMs = measureMs(iterations: iterations) { + _ = UIImage.fromBlurHash(blurHash, width: 32, height: 32, punch: 1) + } + return (totalMs: totalMs, msPerOp: totalMs / Double(iterations)) + } + + @MainActor + private static func benchmarkStream() -> ( + writeTotalMs: Double, + writeMsPerOp: Double, + readTotalMs: Double, + readMsPerOp: Double + ) { + let iterations = 20_000 + var packet = Data() + + let writeTotalMs = measureMs(iterations: iterations) { + let stream = Stream() + stream.writeInt16(0x19) + stream.writeInt64(1_741_129_600_123) + stream.writeString("bench_message") + stream.writeBoolean(true) + stream.writeBytes(Data([1, 2, 3, 4, 5, 6, 7, 8])) + packet = stream.toData() + } + + let readTotalMs = measureMs(iterations: iterations) { + let stream = Stream(data: packet) + _ = stream.readInt16() + _ = stream.readInt64() + _ = stream.readString() + _ = stream.readBoolean() + _ = stream.readBytes() + } + + return ( + writeTotalMs: writeTotalMs, + writeMsPerOp: writeTotalMs / Double(iterations), + readTotalMs: readTotalMs, + readMsPerOp: readTotalMs / Double(iterations) + ) + } + + nonisolated private static func measureMs(iterations: Int, block: () throws -> Void) rethrows -> Double { + let start = DispatchTime.now().uptimeNanoseconds + for _ in 0.. String { + String(format: "%.3f", value) + } +} +#endif diff --git a/Rosetta/RosettaApp.swift b/Rosetta/RosettaApp.swift index c72564e..7cf29af 100644 --- a/Rosetta/RosettaApp.swift +++ b/Rosetta/RosettaApp.swift @@ -305,6 +305,7 @@ struct RosettaApp: App { } // Avoid heavy startup work on MainActor; Lottie assets load lazily on first use. + DebugPerformanceBenchmarks.runIfRequested() } @AppStorage("hasCompletedOnboarding") private var hasCompletedOnboarding = false