From 65e5991f97312ba1ef8ab16eda2e0bb2bea1b1ca Mon Sep 17 00:00:00 2001 From: senseiGai Date: Sat, 21 Mar 2026 20:28:11 +0500 Subject: [PATCH] =?UTF-8?q?=D0=A4=D0=B8=D0=BA=D1=81:=20=D0=B8=D0=BC=D1=8F?= =?UTF-8?q?=20=D1=84=D0=B0=D0=B9=D0=BB=D0=B0=20=D0=B2=20=D0=BF=D0=B5=D1=80?= =?UTF-8?q?=D0=B5=D1=81=D0=BB=D0=B0=D0=BD=D0=BD=D1=8B=D1=85=20=D1=81=D0=BE?= =?UTF-8?q?=D0=BE=D0=B1=D1=89=D0=B5=D0=BD=D0=B8=D1=8F=D1=85,=20=D0=BF?= =?UTF-8?q?=D0=BE=D1=82=D0=B5=D1=80=D1=8F=20=D1=84=D0=BE=D1=82=D0=BE=D0=BA?= =?UTF-8?q?/=D1=84=D0=B0=D0=B9=D0=BB=D0=BE=D0=B2=20=D0=BF=D1=80=D0=B8=20?= =?UTF-8?q?=D0=BF=D0=B5=D1=80=D0=B5=D1=81=D1=8B=D0=BB=D0=BA=D0=B5=20forwar?= =?UTF-8?q?ded-=D1=81=D0=BE=D0=BE=D0=B1=D1=89=D0=B5=D0=BD=D0=B8=D0=B9,=20?= =?UTF-8?q?=D0=A4=D0=BE=D0=BB=D0=BB=D0=B1=D1=8D=D0=BA=20=D0=BF=D1=80=D0=B8?= =?UTF-8?q?=20unwrap=20forwarded-=D1=81=D0=BE=D0=BE=D0=B1=D1=89=D0=B5?= =?UTF-8?q?=D0=BD=D0=B8=D1=8F,=20=D0=B7=D0=B0=D1=89=D0=B8=D1=82=D0=B0=20?= =?UTF-8?q?=D0=91=D0=94=20=D0=BE=D1=82=20=D0=BF=D0=B5=D1=80=D0=B5=D0=B7?= =?UTF-8?q?=D0=B0=D0=BF=D0=B8=D1=81=D0=B8=20=D1=81=D0=B8=D0=BD=D0=BA=D0=BE?= =?UTF-8?q?=D0=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Rosetta.xcodeproj/project.pbxproj | 17 + Rosetta/Core/Crypto/CryptoManager.swift | 99 +- .../Core/Data/Database/DatabaseManager.swift | 248 +++++ .../Database/DatabaseMigrationFromJSON.swift | 89 ++ Rosetta/Core/Data/Database/DialogRecord.swift | 113 +++ .../Core/Data/Database/MessageRecord.swift | 111 +++ .../Core/Data/Models/AttachmentCache.swift | 99 +- .../Data/Repositories/AvatarRepository.swift | 65 +- .../Data/Repositories/DialogRepository.swift | 576 +++++------ .../Data/Repositories/MessageRepository.swift | 926 ++++++++++++------ .../Network/Protocol/ProtocolManager.swift | 98 +- .../Network/Protocol/WebSocketClient.swift | 22 + Rosetta/Core/Services/SessionManager.swift | 393 +++++--- .../ChatDetail/ChatDetailSkeletonView.swift | 100 ++ .../Chats/ChatDetail/ChatDetailView.swift | 416 +++++++- .../ChatDetail/ChatDetailViewModel.swift | 11 +- .../Chats/ChatDetail/MessageImageView.swift | 167 ++-- .../ChatDetail/OpponentProfileView.swift | 23 +- .../Chats/ChatList/ChatListView.swift | 26 +- .../Features/Chats/ChatList/ChatRowView.swift | 12 +- .../Chats/ChatList/RequestChatsView.swift | 8 +- Rosetta/Features/MainTabView.swift | 1 + Rosetta/RosettaApp.swift | 76 +- .../NotificationService.swift | 56 +- 24 files changed, 2715 insertions(+), 1037 deletions(-) create mode 100644 Rosetta/Core/Data/Database/DatabaseManager.swift create mode 100644 Rosetta/Core/Data/Database/DatabaseMigrationFromJSON.swift create mode 100644 Rosetta/Core/Data/Database/DialogRecord.swift create mode 100644 Rosetta/Core/Data/Database/MessageRecord.swift create mode 100644 Rosetta/Features/Chats/ChatDetail/ChatDetailSkeletonView.swift diff --git a/Rosetta.xcodeproj/project.pbxproj b/Rosetta.xcodeproj/project.pbxproj index 4cf1641..d556b14 100644 --- a/Rosetta.xcodeproj/project.pbxproj +++ b/Rosetta.xcodeproj/project.pbxproj @@ -10,6 +10,7 @@ 4C9BDB443750F7003CFB705C /* Foundation.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 272B862BE4D99E7DD751CC3E /* Foundation.framework */; }; 853F29992F4B63D20092AD05 /* Lottie in Frameworks */ = {isa = PBXBuildFile; productRef = 853F29982F4B63D20092AD05 /* Lottie */; }; 853F29A02F4B63D20092AD05 /* P256K in Frameworks */ = {isa = PBXBuildFile; productRef = 853F29A12F4B63D20092AD05 /* P256K */; }; + 85E887F72F6DC9460032774C /* GRDB in Frameworks */ = {isa = PBXBuildFile; productRef = D1DB00022F8C00010092AD05 /* GRDB */; }; D0BD72A9646880B604F1AC3C /* RosettaNotificationService.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = A182B0EDE5C68E7C6F1FB6D1 /* RosettaNotificationService.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; DA91A59FDC04C2EBE77550F4 /* NotificationService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0F43A41D5496A62870E307FC /* NotificationService.swift */; }; F1A000012F6F00010092AD05 /* FirebaseAnalyticsWithoutAdIdSupport in Frameworks */ = {isa = PBXBuildFile; productRef = F1A000022F6F00010092AD05 /* FirebaseAnalyticsWithoutAdIdSupport */; }; @@ -62,6 +63,7 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( + 85E887F72F6DC9460032774C /* GRDB in Frameworks */, 853F29992F4B63D20092AD05 /* Lottie in Frameworks */, 853F29A02F4B63D20092AD05 /* P256K in Frameworks */, F1A000012F6F00010092AD05 /* FirebaseAnalyticsWithoutAdIdSupport in Frameworks */, @@ -152,6 +154,7 @@ F1A000022F6F00010092AD05 /* FirebaseAnalyticsWithoutAdIdSupport */, F1A000042F6F00010092AD05 /* FirebaseMessaging */, F1A000072F6F00010092AD05 /* FirebaseCrashlytics */, + D1DB00022F8C00010092AD05 /* GRDB */, ); productName = Rosetta; productReference = 853F29622F4B50410092AD05 /* Rosetta.app */; @@ -205,6 +208,7 @@ 853F29972F4B63D20092AD05 /* XCRemoteSwiftPackageReference "lottie-ios" */, 853F29A22F4B63D20092AD05 /* XCRemoteSwiftPackageReference "secp256k1.swift" */, F1A000052F6F00010092AD05 /* XCRemoteSwiftPackageReference "firebase-ios-sdk" */, + D1DB00012F8C00010092AD05 /* XCRemoteSwiftPackageReference "GRDB.swift" */, ); preferredProjectObjectVersion = 77; productRefGroup = 853F29632F4B50410092AD05 /* Products */; @@ -565,6 +569,14 @@ minimumVersion = 0.16.0; }; }; + D1DB00012F8C00010092AD05 /* XCRemoteSwiftPackageReference "GRDB.swift" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/groue/GRDB.swift.git"; + requirement = { + kind = upToNextMajorVersion; + minimumVersion = 7.0.0; + }; + }; F1A000052F6F00010092AD05 /* XCRemoteSwiftPackageReference "firebase-ios-sdk" */ = { isa = XCRemoteSwiftPackageReference; repositoryURL = "https://github.com/firebase/firebase-ios-sdk"; @@ -586,6 +598,11 @@ package = 853F29A22F4B63D20092AD05 /* XCRemoteSwiftPackageReference "secp256k1.swift" */; productName = P256K; }; + D1DB00022F8C00010092AD05 /* GRDB */ = { + isa = XCSwiftPackageProductDependency; + package = D1DB00012F8C00010092AD05 /* XCRemoteSwiftPackageReference "GRDB.swift" */; + productName = GRDB; + }; F1A000022F6F00010092AD05 /* FirebaseAnalyticsWithoutAdIdSupport */ = { isa = XCSwiftPackageProductDependency; package = F1A000052F6F00010092AD05 /* XCRemoteSwiftPackageReference "firebase-ios-sdk" */; diff --git a/Rosetta/Core/Crypto/CryptoManager.swift b/Rosetta/Core/Crypto/CryptoManager.swift index 171895e..332a7f6 100644 --- a/Rosetta/Core/Crypto/CryptoManager.swift +++ b/Rosetta/Core/Crypto/CryptoManager.swift @@ -37,8 +37,35 @@ enum CryptoError: LocalizedError { final class CryptoManager: @unchecked Sendable { static let shared = CryptoManager() + + // MARK: - Android Parity: PBKDF2 Key Cache + + /// Caches derived PBKDF2 keys to avoid repeated ~50-100ms derivations. + /// Android: `CryptoManager.pbkdf2KeyCache` (ConcurrentHashMap). + /// Key format: "algorithm::password", value: derived 32-byte key. + private let pbkdf2CacheLock = NSLock() + private var pbkdf2Cache: [String: Data] = [:] + + // MARK: - Android Parity: Decryption Cache + + /// Caches decrypted results to avoid repeated AES + PBKDF2 for same input. + /// Android: `CryptoManager.decryptionCache` (ConcurrentHashMap, max 2000). + private static let decryptionCacheMaxSize = 2000 + private let decryptionCacheLock = NSLock() + private var decryptionCache: [String: Data] = [:] + private init() {} + /// Clear all crypto caches on logout (Android parity: `clearCaches()`). + nonisolated func clearCaches() { + pbkdf2CacheLock.lock() + pbkdf2Cache.removeAll() + pbkdf2CacheLock.unlock() + decryptionCacheLock.lock() + decryptionCache.removeAll() + decryptionCacheLock.unlock() + } + // MARK: - BIP39: Mnemonic Generation nonisolated func generateMnemonic() throws -> [String] { @@ -81,10 +108,7 @@ final class CryptoManager: @unchecked Sendable { /// Uses PBKDF2-HMAC-SHA256 + raw deflate (no zlib wrapper). nonisolated func encryptWithPassword(_ data: Data, password: String) throws -> String { let compressed = try CryptoPrimitives.rawDeflate(data) - let key = CryptoPrimitives.pbkdf2( - password: password, salt: "rosetta", iterations: 1000, - keyLength: 32, prf: CCPseudoRandomAlgorithm(kCCPRFHmacAlgSHA256) - ) + let key = cachedPBKDF2(password: password) let iv = try CryptoPrimitives.randomBytes(count: 16) let ciphertext = try CryptoPrimitives.aesCBCEncrypt(compressed, key: key, iv: iv) return "\(iv.base64EncodedString()):\(ciphertext.base64EncodedString())" @@ -96,10 +120,7 @@ final class CryptoManager: @unchecked Sendable { /// Use ONLY for cross-platform data: aesChachaKey, avatar blobs sent to server. nonisolated func encryptWithPasswordDesktopCompat(_ data: Data, password: String) throws -> String { let compressed = try CryptoPrimitives.zlibDeflate(data) - let key = CryptoPrimitives.pbkdf2( - password: password, salt: "rosetta", iterations: 1000, - keyLength: 32, prf: CCPseudoRandomAlgorithm(kCCPRFHmacAlgSHA256) - ) + let key = cachedPBKDF2(password: password) let iv = try CryptoPrimitives.randomBytes(count: 16) let ciphertext = try CryptoPrimitives.aesCBCEncrypt(compressed, key: key, iv: iv) return "\(iv.base64EncodedString()):\(ciphertext.base64EncodedString())" @@ -114,6 +135,15 @@ final class CryptoManager: @unchecked Sendable { password: String, requireCompression: Bool = false ) throws -> Data { + // Android parity: decryption cache (max 2000 entries) + let cacheKey = "\(password):\(encrypted)" + decryptionCacheLock.lock() + if let cached = decryptionCache[cacheKey] { + decryptionCacheLock.unlock() + return cached + } + decryptionCacheLock.unlock() + let parts = encrypted.components(separatedBy: ":") guard parts.count == 2, let iv = Data(base64Encoded: parts[0]), @@ -129,10 +159,12 @@ final class CryptoManager: @unchecked Sendable { // 1) Preferred path: AES-CBC + inflate (handles both rawDeflate and zlibDeflate) for prf in prfOrder { do { - return try decryptWithPassword( + let result = try decryptWithPassword( ciphertext: ciphertext, iv: iv, password: password, prf: prf, expectsCompressed: true ) + cacheDecryptionResult(cacheKey, result) + return result } catch { } } @@ -142,10 +174,12 @@ final class CryptoManager: @unchecked Sendable { if !requireCompression { for prf in prfOrder { do { - return try decryptWithPassword( + let result = try decryptWithPassword( ciphertext: ciphertext, iv: iv, password: password, prf: prf, expectsCompressed: false ) + cacheDecryptionResult(cacheKey, result) + return result } catch { } } } @@ -153,6 +187,18 @@ final class CryptoManager: @unchecked Sendable { throw CryptoError.decryptionFailed } + /// Store result in decryption cache with eviction (Android: 10% eviction at 2000 entries). + private nonisolated func cacheDecryptionResult(_ key: String, _ value: Data) { + decryptionCacheLock.lock() + if decryptionCache.count >= Self.decryptionCacheMaxSize { + // Evict ~10% oldest entries + let keysToRemove = Array(decryptionCache.keys.prefix(Self.decryptionCacheMaxSize / 10)) + for k in keysToRemove { decryptionCache.removeValue(forKey: k) } + } + decryptionCache[key] = value + decryptionCacheLock.unlock() + } + // MARK: - Utilities nonisolated func sha256(_ data: Data) -> Data { @@ -163,6 +209,30 @@ final class CryptoManager: @unchecked Sendable { let combined = Data((privateKeyHex + "rosetta").utf8) return sha256(combined).hexString } + + // MARK: - Android Parity: Cached PBKDF2 + + /// Cache PBKDF2 key derivation. Android: `getPbkdf2Key()` in CryptoManager.kt. + /// Key: "prfAlgorithm::password", Value: 32-byte derived key. + nonisolated func cachedPBKDF2(password: String, prf: CCPseudoRandomAlgorithm = CCPseudoRandomAlgorithm(kCCPRFHmacAlgSHA256)) -> Data { + let cacheKey = "\(prf)::\(password)" + pbkdf2CacheLock.lock() + if let cached = pbkdf2Cache[cacheKey] { + pbkdf2CacheLock.unlock() + return cached + } + pbkdf2CacheLock.unlock() + + let key = CryptoPrimitives.pbkdf2( + password: password, salt: "rosetta", iterations: 1000, + keyLength: 32, prf: prf + ) + + pbkdf2CacheLock.lock() + pbkdf2Cache[cacheKey] = key + pbkdf2CacheLock.unlock() + return key + } } private extension CryptoManager { @@ -173,13 +243,8 @@ private extension CryptoManager { prf: CCPseudoRandomAlgorithm, expectsCompressed: Bool ) throws -> Data { - let key = CryptoPrimitives.pbkdf2( - password: password, - salt: "rosetta", - iterations: 1000, - keyLength: 32, - prf: prf - ) + // Android parity: cache PBKDF2 derived key + let key = cachedPBKDF2(password: password, prf: prf) let decrypted = try CryptoPrimitives.aesCBCDecrypt(ciphertext, key: key, iv: iv) if expectsCompressed { return try CryptoPrimitives.rawInflate(decrypted) diff --git a/Rosetta/Core/Data/Database/DatabaseManager.swift b/Rosetta/Core/Data/Database/DatabaseManager.swift new file mode 100644 index 0000000..a8eb671 --- /dev/null +++ b/Rosetta/Core/Data/Database/DatabaseManager.swift @@ -0,0 +1,248 @@ +import Foundation +import GRDB + +/// Central database manager. Owns the GRDB DatabasePool for the current account. +/// Android parity: `RosettaDatabase.kt` — Room database with WAL mode. +@MainActor +final class DatabaseManager { + + static let shared = DatabaseManager() + + private var dbPool: DatabasePool? + private var currentAccount: String = "" + + private init() {} + + // MARK: - Lifecycle + + /// Open (or create) the SQLite database for the given account. + /// Must be called during session start, before any repository access. + func bootstrap(accountPublicKey: String) throws { + let account = accountPublicKey.trimmingCharacters(in: .whitespacesAndNewlines) + guard !account.isEmpty else { return } + + // Already open for this account + if currentAccount == account, dbPool != nil { return } + + // Close previous if switching accounts + close() + + currentAccount = account + let dbURL = Self.databaseURL(for: account) + + // Ensure directory exists + let dir = dbURL.deletingLastPathComponent() + try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) + + // Configure GRDB + var config = Configuration() + config.foreignKeysEnabled = true + // iOS file protection — encrypted at rest when device is locked. + config.prepareDatabase { db in + // WAL mode for concurrent reads (Android parity: JournalMode.WRITE_AHEAD_LOGGING) + try db.execute(sql: "PRAGMA journal_mode = WAL") + } + + let pool = try DatabasePool(path: dbURL.path, configuration: config) + + // Apply schema migrations + var migrator = DatabaseMigrator() + migrator.registerMigration("v1_initial") { db in + // Messages table — matches Android MessageEntity + try db.create(table: "messages") { t in + t.autoIncrementedPrimaryKey("id") + t.column("account", .text).notNull() + t.column("from_public_key", .text).notNull() + t.column("to_public_key", .text).notNull() + t.column("text", .text).notNull().defaults(to: "") + t.column("timestamp", .integer).notNull() + t.column("is_read", .integer).notNull().defaults(to: 0) + t.column("from_me", .integer).notNull().defaults(to: 0) + t.column("delivery_status", .integer).notNull().defaults(to: 0) + t.column("message_id", .text).notNull() + t.column("dialog_key", .text).notNull() + t.column("attachments", .text).notNull().defaults(to: "[]") + t.column("attachment_password", .text) + } + + // Android parity indexes + try db.create( + index: "idx_messages_account_message_id", + on: "messages", + columns: ["account", "message_id"], + unique: true + ) + try db.create( + index: "idx_messages_account_dialog_key_timestamp", + on: "messages", + columns: ["account", "dialog_key", "timestamp"] + ) + + // Dialogs table — matches Android DialogEntity + try db.create(table: "dialogs") { t in + t.autoIncrementedPrimaryKey("id") + t.column("account", .text).notNull() + t.column("opponent_key", .text).notNull() + t.column("opponent_title", .text).notNull().defaults(to: "") + t.column("opponent_username", .text).notNull().defaults(to: "") + t.column("last_message", .text).notNull().defaults(to: "") + t.column("last_message_timestamp", .integer).notNull().defaults(to: 0) + t.column("unread_count", .integer).notNull().defaults(to: 0) + t.column("is_online", .integer).notNull().defaults(to: 0) + t.column("last_seen", .integer).notNull().defaults(to: 0) + t.column("verified", .integer).notNull().defaults(to: 0) + t.column("i_have_sent", .integer).notNull().defaults(to: 0) + t.column("is_pinned", .integer).notNull().defaults(to: 0) + t.column("is_muted", .integer).notNull().defaults(to: 0) + t.column("last_message_from_me", .integer).notNull().defaults(to: 0) + t.column("last_message_delivered", .integer).notNull().defaults(to: 0) + } + + try db.create( + index: "idx_dialogs_account_opponent_key", + on: "dialogs", + columns: ["account", "opponent_key"], + unique: true + ) + try db.create( + index: "idx_dialogs_account_timestamp", + on: "dialogs", + columns: ["account", "last_message_timestamp"] + ) + } + + // v2: sync_cursors table (Android parity: AccountSyncTimeEntity in SQLite, not UserDefaults) + migrator.registerMigration("v2_sync_cursors") { db in + try db.create(table: "sync_cursors") { t in + t.column("account", .text).notNull().primaryKey() + t.column("timestamp", .integer).notNull().defaults(to: 0) + } + } + + try migrator.migrate(pool) + dbPool = pool + + // Set iOS file protection on the database files + let attrs: [FileAttributeKey: Any] = [ + .protectionKey: FileProtectionType.completeUntilFirstUserAuthentication + ] + try? FileManager.default.setAttributes(attrs, ofItemAtPath: dbURL.path) + try? FileManager.default.setAttributes(attrs, ofItemAtPath: dbURL.path + "-wal") + try? FileManager.default.setAttributes(attrs, ofItemAtPath: dbURL.path + "-shm") + } + + func close() { + dbPool = nil + currentAccount = "" + } + + // MARK: - Access + + /// Synchronous read on the current thread. Use for small queries from @MainActor. + func read(_ block: (Database) throws -> T) throws -> T { + guard let pool = dbPool else { + throw DatabaseError(message: "Database not open") + } + return try pool.read(block) + } + + /// Write access (serialized on GRDB's writer queue). + func write(_ block: @Sendable @escaping (Database) throws -> T) async throws -> T { + guard let pool = dbPool else { + throw DatabaseError(message: "Database not open") + } + return try await pool.write(block) + } + + /// Synchronous write for MainActor use (uses `writeWithoutTransaction` + manual transaction). + func writeSync(_ block: (Database) throws -> T) throws -> T { + guard let pool = dbPool else { + throw DatabaseError(message: "Database not open") + } + return try pool.write(block) + } + + // MARK: - Database URL + + private static func databaseURL(for accountPublicKey: String) -> URL { + let fileManager = FileManager.default + let baseURL = fileManager.urls(for: .applicationSupportDirectory, in: .userDomainMask).first + ?? fileManager.urls(for: .documentDirectory, in: .userDomainMask).first + ?? URL(fileURLWithPath: NSTemporaryDirectory(), isDirectory: true) + + let dir = baseURL + .appendingPathComponent("Rosetta", isDirectory: true) + .appendingPathComponent("Database", isDirectory: true) + + let normalized = normalizedKey(accountPublicKey) + return dir.appendingPathComponent("rosetta_\(normalized).sqlite") + } + + private static func normalizedKey(_ key: String) -> String { + let trimmed = key.trimmingCharacters(in: .whitespacesAndNewlines) + if trimmed.isEmpty { return "anonymous" } + return String(trimmed.unicodeScalars.map { CharacterSet.alphanumerics.contains($0) ? Character($0) : "_" }) + } + + // MARK: - Dialog Key (Android parity) + + /// Compute dialog_key from account and opponent public keys. + /// Android: `MessageRepository.getDialogKey()` — sorted pair for direct chats. + nonisolated static func dialogKey(account: String, opponentKey: String) -> String { + // Saved Messages: dialogKey = account + if account == opponentKey { return account } + // Normal: lexicographic sort + return account < opponentKey ? "\(account):\(opponentKey)" : "\(opponentKey):\(account)" + } + + // MARK: - Sync Cursor (Android parity: SQLite, not UserDefaults) + + /// Load sync cursor for the current account. Returns milliseconds. + func loadSyncCursor(account: String) -> Int64 { + do { + let stored = try read { db in + try Int64.fetchOne(db, + sql: "SELECT timestamp FROM sync_cursors WHERE account = ?", + arguments: [account] + ) ?? 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] + ) + } + return corrected + } + return stored + } catch { return 0 } + } + + /// Save sync cursor. Monotonic only — never decreases. + func saveSyncCursor(account: String, timestamp: Int64) { + guard timestamp > 0 else { return } + let existing = loadSyncCursor(account: account) + 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] + ) + } + } catch {} + } + + // MARK: - Delete + + /// Delete all data for the given account (used on account delete). + func deleteDatabase(for accountPublicKey: String) { + let url = Self.databaseURL(for: accountPublicKey) + try? FileManager.default.removeItem(at: url) + try? FileManager.default.removeItem(at: URL(fileURLWithPath: url.path + "-wal")) + try? FileManager.default.removeItem(at: URL(fileURLWithPath: url.path + "-shm")) + } +} diff --git a/Rosetta/Core/Data/Database/DatabaseMigrationFromJSON.swift b/Rosetta/Core/Data/Database/DatabaseMigrationFromJSON.swift new file mode 100644 index 0000000..db78b32 --- /dev/null +++ b/Rosetta/Core/Data/Database/DatabaseMigrationFromJSON.swift @@ -0,0 +1,89 @@ +import Foundation +import GRDB + +/// One-time migration from legacy JSON persistence (ChatPersistenceStore) to SQLite. +/// Runs on first launch after the SQLite migration. After successful migration, +/// sets a UserDefaults flag so it never runs again. +enum DatabaseMigrationFromJSON { + + /// Migrate legacy JSON data for the given account into the SQLite database. + /// Returns the number of messages migrated. + @MainActor + static func migrateIfNeeded( + accountPublicKey: String, + storagePassword: String + ) async -> Int { + let flagKey = "sqlite_migration_done_\(accountPublicKey.prefix(16))" + + // Check if migration already done AND data exists in SQLite + if UserDefaults.standard.bool(forKey: flagKey) { + // Verify data actually exists — if DB is empty, re-migrate + let hasData: Bool + do { + hasData = try DatabaseManager.shared.read { db in + try DialogRecord.filter(DialogRecord.Columns.account == accountPublicKey) + .fetchCount(db) > 0 + } + } catch { hasData = false } + if hasData { return 0 } + // DB is empty despite flag — reset flag and re-migrate + UserDefaults.standard.removeObject(forKey: flagKey) + } + + var totalMessages = 0 + + // 1. Load dialogs from legacy JSON + let dialogsFileName = ChatPersistenceStore.accountScopedFileName( + prefix: "dialogs", accountPublicKey: accountPublicKey + ) + let legacyDialogs = await ChatPersistenceStore.shared.load( + [Dialog].self, + fileName: dialogsFileName, + password: storagePassword + ) ?? [] + + // 2. Load messages from legacy JSON + let messagesFileName = ChatPersistenceStore.accountScopedFileName( + prefix: "messages", accountPublicKey: accountPublicKey + ) + let legacyMessages = await ChatPersistenceStore.shared.load( + [String: [ChatMessage]].self, + fileName: messagesFileName, + password: storagePassword + ) ?? [:] + + // Nothing to migrate + if legacyDialogs.isEmpty && legacyMessages.isEmpty { + UserDefaults.standard.set(true, forKey: flagKey) + return 0 + } + + // 3. Insert into SQLite in a single transaction + do { + try DatabaseManager.shared.writeSync { db in + // Insert dialogs + for dialog in legacyDialogs where dialog.account == accountPublicKey { + var record = DialogRecord.from(dialog) + try record.insert(db, onConflict: .ignore) + } + + // Insert messages + for (_, messages) in legacyMessages { + for message in messages { + var record = MessageRecord.from(message, account: accountPublicKey) + try record.insert(db, onConflict: .ignore) + totalMessages += 1 + } + } + } + + // 4. Mark migration as done + UserDefaults.standard.set(true, forKey: flagKey) + print("[DB Migration] Success: \(legacyDialogs.count) dialogs, \(totalMessages) messages") + } catch { + print("[DB Migration] Failed: \(error)") + } + + return totalMessages + } +} diff --git a/Rosetta/Core/Data/Database/DialogRecord.swift b/Rosetta/Core/Data/Database/DialogRecord.swift new file mode 100644 index 0000000..be453ea --- /dev/null +++ b/Rosetta/Core/Data/Database/DialogRecord.swift @@ -0,0 +1,113 @@ +import Foundation +import GRDB + +/// SQLite record for the `dialogs` table. +/// Android parity: `DialogEntity` in `MessageEntities.kt`. +struct DialogRecord: Codable, FetchableRecord, MutablePersistableRecord, Sendable { + static let databaseTableName = "dialogs" + + var id: Int64? + var account: String + var opponentKey: String + var opponentTitle: String + var opponentUsername: String + var lastMessage: String + var lastMessageTimestamp: Int64 + var unreadCount: Int + var isOnline: Int + var lastSeen: Int64 + var verified: Int + var iHaveSent: Int + var isPinned: Int + var isMuted: Int + var lastMessageFromMe: Int + var lastMessageDelivered: Int + + // MARK: - Column mapping + + enum Columns: String, ColumnExpression { + case id, account + case opponentKey = "opponent_key" + case opponentTitle = "opponent_title" + case opponentUsername = "opponent_username" + case lastMessage = "last_message" + case lastMessageTimestamp = "last_message_timestamp" + case unreadCount = "unread_count" + case isOnline = "is_online" + case lastSeen = "last_seen" + case verified + case iHaveSent = "i_have_sent" + case isPinned = "is_pinned" + case isMuted = "is_muted" + case lastMessageFromMe = "last_message_from_me" + case lastMessageDelivered = "last_message_delivered" + } + + enum CodingKeys: String, CodingKey { + case id, account + case opponentKey = "opponent_key" + case opponentTitle = "opponent_title" + case opponentUsername = "opponent_username" + case lastMessage = "last_message" + case lastMessageTimestamp = "last_message_timestamp" + case unreadCount = "unread_count" + case isOnline = "is_online" + case lastSeen = "last_seen" + case verified + case iHaveSent = "i_have_sent" + case isPinned = "is_pinned" + case isMuted = "is_muted" + case lastMessageFromMe = "last_message_from_me" + case lastMessageDelivered = "last_message_delivered" + } + + // MARK: - Auto-increment + + mutating func didInsert(_ inserted: InsertionSuccess) { + id = inserted.rowID + } + + // MARK: - Conversions + + func toDialog() -> Dialog { + Dialog( + id: opponentKey, + account: account, + opponentKey: opponentKey, + opponentTitle: opponentTitle, + opponentUsername: opponentUsername, + lastMessage: lastMessage, + lastMessageTimestamp: lastMessageTimestamp, + unreadCount: unreadCount, + isOnline: isOnline != 0, + lastSeen: lastSeen, + verified: verified, + iHaveSent: iHaveSent != 0, + isPinned: isPinned != 0, + isMuted: isMuted != 0, + lastMessageFromMe: lastMessageFromMe != 0, + lastMessageDelivered: DeliveryStatus(rawValue: lastMessageDelivered) ?? .waiting + ) + } + + static func from(_ dialog: Dialog) -> DialogRecord { + DialogRecord( + id: nil, + account: dialog.account, + opponentKey: dialog.opponentKey, + opponentTitle: dialog.opponentTitle, + opponentUsername: dialog.opponentUsername, + lastMessage: dialog.lastMessage, + lastMessageTimestamp: dialog.lastMessageTimestamp, + unreadCount: dialog.unreadCount, + isOnline: dialog.isOnline ? 1 : 0, + lastSeen: dialog.lastSeen, + verified: dialog.verified, + iHaveSent: dialog.iHaveSent ? 1 : 0, + isPinned: dialog.isPinned ? 1 : 0, + isMuted: dialog.isMuted ? 1 : 0, + lastMessageFromMe: dialog.lastMessageFromMe ? 1 : 0, + lastMessageDelivered: dialog.lastMessageDelivered.rawValue + ) + } +} diff --git a/Rosetta/Core/Data/Database/MessageRecord.swift b/Rosetta/Core/Data/Database/MessageRecord.swift new file mode 100644 index 0000000..9c3b999 --- /dev/null +++ b/Rosetta/Core/Data/Database/MessageRecord.swift @@ -0,0 +1,111 @@ +import Foundation +import GRDB + +/// SQLite record for the `messages` table. +/// Android parity: `MessageEntity` in `MessageEntities.kt`. +struct MessageRecord: Codable, FetchableRecord, MutablePersistableRecord, Sendable { + static let databaseTableName = "messages" + + var id: Int64? + var account: String + var fromPublicKey: String + var toPublicKey: String + var text: String + var timestamp: Int64 + var isRead: Int + var fromMe: Int + var deliveryStatus: Int + var messageId: String + var dialogKey: String + var attachments: String + var attachmentPassword: String? + + // MARK: - Column mapping + + enum Columns: String, ColumnExpression { + case id, account + case fromPublicKey = "from_public_key" + case toPublicKey = "to_public_key" + case text, timestamp + case isRead = "is_read" + case fromMe = "from_me" + case deliveryStatus = "delivery_status" + case messageId = "message_id" + case dialogKey = "dialog_key" + case attachments + case attachmentPassword = "attachment_password" + } + + enum CodingKeys: String, CodingKey { + case id, account + case fromPublicKey = "from_public_key" + case toPublicKey = "to_public_key" + case text, timestamp + case isRead = "is_read" + case fromMe = "from_me" + case deliveryStatus = "delivery_status" + case messageId = "message_id" + case dialogKey = "dialog_key" + case attachments + case attachmentPassword = "attachment_password" + } + + // MARK: - Auto-increment + + mutating func didInsert(_ inserted: InsertionSuccess) { + id = inserted.rowID + } + + // MARK: - Conversions + + func toChatMessage(overrideText: String? = nil) -> ChatMessage { + let decoder = JSONDecoder() + let parsedAttachments: [MessageAttachment] + if let data = attachments.data(using: .utf8), + let decoded = try? decoder.decode([MessageAttachment].self, from: data) { + parsedAttachments = decoded + } else { + parsedAttachments = [] + } + + return ChatMessage( + id: messageId, + fromPublicKey: fromPublicKey, + toPublicKey: toPublicKey, + text: overrideText ?? text, + timestamp: timestamp, + deliveryStatus: DeliveryStatus(rawValue: deliveryStatus) ?? .waiting, + isRead: isRead != 0, + attachments: parsedAttachments, + attachmentPassword: attachmentPassword + ) + } + + static func from(_ message: ChatMessage, account: String) -> MessageRecord { + let encoder = JSONEncoder() + let attachmentsJSON: String + if let data = try? encoder.encode(message.attachments), + let str = String(data: data, encoding: .utf8) { + attachmentsJSON = str + } else { + attachmentsJSON = "[]" + } + + let fromMe = message.fromPublicKey == account + return MessageRecord( + id: nil, + account: account, + fromPublicKey: message.fromPublicKey, + toPublicKey: message.toPublicKey, + text: message.text, + timestamp: message.timestamp, + isRead: message.isRead ? 1 : 0, + fromMe: fromMe ? 1 : 0, + deliveryStatus: message.deliveryStatus.rawValue, + messageId: message.id, + dialogKey: DatabaseManager.dialogKey(account: account, opponentKey: fromMe ? message.toPublicKey : message.fromPublicKey), + attachments: attachmentsJSON, + attachmentPassword: message.attachmentPassword + ) + } +} diff --git a/Rosetta/Core/Data/Models/AttachmentCache.swift b/Rosetta/Core/Data/Models/AttachmentCache.swift index 3162ed9..e52c3ac 100644 --- a/Rosetta/Core/Data/Models/AttachmentCache.swift +++ b/Rosetta/Core/Data/Models/AttachmentCache.swift @@ -5,8 +5,8 @@ import os /// Local disk cache for downloaded/decrypted attachment images and files. /// +/// Android parity: `AttachmentFileManager.kt` encrypts all files with user's private key. /// Desktop parity: `readFile("m/...")` and `writeFile(...)` in `DialogProvider.tsx`. -/// Attachments are cached after download+decrypt so subsequent opens are instant. /// /// Cache directory: `Documents/AttachmentCache/` /// Key format: attachment ID (8-char random string). @@ -18,6 +18,15 @@ final class AttachmentCache: @unchecked Sendable { private let cacheDir: URL + /// Private key for encrypting files at rest (Android parity). + /// Set from SessionManager.startSession() after unlocking account. + private let keyLock = NSLock() + private var _privateKey: String? + var privateKey: String? { + get { keyLock.lock(); defer { keyLock.unlock() }; return _privateKey } + set { keyLock.lock(); _privateKey = newValue; keyLock.unlock() } + } + private init() { let docs = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0] cacheDir = docs.appendingPathComponent("AttachmentCache", isDirectory: true) @@ -26,42 +35,104 @@ final class AttachmentCache: @unchecked Sendable { // MARK: - Images - /// Saves a decoded image to cache. + /// Saves a decoded image to cache, encrypted with private key (Android parity). func saveImage(_ image: UIImage, forAttachmentId id: String) { guard let data = image.jpegData(compressionQuality: 0.95) else { return } - let url = cacheDir.appendingPathComponent("img_\(id).jpg") - try? data.write(to: url, options: .atomic) + let url = cacheDir.appendingPathComponent("img_\(id).enc") + + if let key = privateKey, + let encrypted = try? CryptoManager.shared.encryptWithPassword(data, password: key), + let encData = encrypted.data(using: .utf8) { + try? encData.write(to: url, options: [.atomic, .completeFileProtectionUntilFirstUserAuthentication]) + } else { + // Fallback: write plaintext (no private key available yet) + let plainUrl = cacheDir.appendingPathComponent("img_\(id).jpg") + try? data.write(to: plainUrl, options: .atomic) + } } /// Loads a cached image for an attachment ID, or `nil` if not cached. - /// Uses simple UIImage loading — downsampling moved to background in MessageImageView. func loadImage(forAttachmentId id: String) -> UIImage? { - let url = cacheDir.appendingPathComponent("img_\(id).jpg") - guard FileManager.default.fileExists(atPath: url.path) else { return nil } - return UIImage(contentsOfFile: url.path) + // Try encrypted format first + let encUrl = cacheDir.appendingPathComponent("img_\(id).enc") + if FileManager.default.fileExists(atPath: encUrl.path), + let fileData = try? Data(contentsOf: encUrl), + let encString = String(data: fileData, encoding: .utf8), + let key = privateKey, + let decrypted = try? CryptoManager.shared.decryptWithPassword(encString, password: key), + let image = UIImage(data: decrypted) { + return image + } + + // Fallback: try plaintext (legacy unencrypted files) + let plainUrl = cacheDir.appendingPathComponent("img_\(id).jpg") + if FileManager.default.fileExists(atPath: plainUrl.path), + let image = UIImage(contentsOfFile: plainUrl.path) { + // Re-save encrypted for next time + if privateKey != nil { + saveImage(image, forAttachmentId: id) + try? FileManager.default.removeItem(at: plainUrl) + } + return image + } + + return nil } // MARK: - Files - /// Saves raw file data to cache, returns the file URL. + /// Saves raw file data to cache (encrypted), returns the file URL. @discardableResult func saveFile(_ data: Data, forAttachmentId id: String, fileName: String) -> URL { let safeFileName = fileName.replacingOccurrences(of: "/", with: "_") - let url = cacheDir.appendingPathComponent("file_\(id)_\(safeFileName)") - try? data.write(to: url, options: .atomic) + let url = cacheDir.appendingPathComponent("file_\(id)_\(safeFileName).enc") + + if let key = privateKey, + let encrypted = try? CryptoManager.shared.encryptWithPassword(data, password: key), + let encData = encrypted.data(using: .utf8) { + try? encData.write(to: url, options: [.atomic, .completeFileProtectionUntilFirstUserAuthentication]) + } else { + let plainUrl = cacheDir.appendingPathComponent("file_\(id)_\(safeFileName)") + try? data.write(to: plainUrl, options: .atomic) + return plainUrl + } return url } /// Returns cached file URL, or `nil` if not cached. func fileURL(forAttachmentId id: String, fileName: String) -> URL? { let safeFileName = fileName.replacingOccurrences(of: "/", with: "_") - let url = cacheDir.appendingPathComponent("file_\(id)_\(safeFileName)") - return FileManager.default.fileExists(atPath: url.path) ? url : nil + // Check encrypted + let encUrl = cacheDir.appendingPathComponent("file_\(id)_\(safeFileName).enc") + if FileManager.default.fileExists(atPath: encUrl.path) { return encUrl } + // Check plaintext (legacy) + let plainUrl = cacheDir.appendingPathComponent("file_\(id)_\(safeFileName)") + if FileManager.default.fileExists(atPath: plainUrl.path) { return plainUrl } + return nil + } + + /// Load file data, decrypting if needed. + func loadFileData(forAttachmentId id: String, fileName: String) -> Data? { + let safeFileName = fileName.replacingOccurrences(of: "/", with: "_") + // Try encrypted + let encUrl = cacheDir.appendingPathComponent("file_\(id)_\(safeFileName).enc") + if FileManager.default.fileExists(atPath: encUrl.path), + let fileData = try? Data(contentsOf: encUrl), + let encString = String(data: fileData, encoding: .utf8), + let key = privateKey, + let decrypted = try? CryptoManager.shared.decryptWithPassword(encString, password: key) { + return decrypted + } + // Try plaintext + let plainUrl = cacheDir.appendingPathComponent("file_\(id)_\(safeFileName)") + if FileManager.default.fileExists(atPath: plainUrl.path) { + return try? Data(contentsOf: plainUrl) + } + return nil } // MARK: - Cleanup - /// Removes all cached attachments. func clearAll() { try? FileManager.default.removeItem(at: cacheDir) try? FileManager.default.createDirectory(at: cacheDir, withIntermediateDirectories: true) diff --git a/Rosetta/Core/Data/Repositories/AvatarRepository.swift b/Rosetta/Core/Data/Repositories/AvatarRepository.swift index 50957a8..5f183ee 100644 --- a/Rosetta/Core/Data/Repositories/AvatarRepository.swift +++ b/Rosetta/Core/Data/Repositories/AvatarRepository.swift @@ -4,11 +4,10 @@ import UIKit /// Manages avatar image storage on disk with in-memory cache. /// -/// Desktop parity: `AvatarProvider.tsx` stores avatars encrypted with -/// `AVATAR_PASSWORD_TO_ENCODE = "rosetta-a"`. iOS relies on iOS Data Protection -/// (sandbox encryption) instead — adding AES would add complexity without security benefit. +/// Android parity: `AvatarFileManager.kt` encrypts avatars with password "rosetta-a". +/// Desktop parity: `AvatarProvider.tsx` uses `AVATAR_PASSWORD_TO_ENCODE = "rosetta-a"`. /// -/// Storage: `Application Support/Rosetta/Avatars/{normalizedKey}.jpg` +/// Storage: `Application Support/Rosetta/Avatars/{normalizedKey}.enc` @Observable @MainActor final class AvatarRepository { @@ -16,6 +15,9 @@ final class AvatarRepository { static let shared = AvatarRepository() private init() {} + /// Android/Desktop parity: fixed password for avatar encryption at rest. + private static let avatarPassword = "rosetta-a" + /// Incremented on every avatar save/remove — views that read this property /// will re-render and pick up the latest avatar from cache. private(set) var avatarVersion: UInt = 0 @@ -29,30 +31,34 @@ final class AvatarRepository { return c }() - /// JPEG compression quality (0.8 = reasonable size for avatars). private let compressionQuality: CGFloat = 0.8 // MARK: - Public API /// Saves an avatar image for the given public key. - /// Compresses to JPEG, writes to disk, updates cache. + /// Android parity: encrypts with "rosetta-a" password before writing to disk. func saveAvatar(publicKey: String, image: UIImage) { let key = normalizedKey(publicKey) - guard let data = image.jpegData(compressionQuality: compressionQuality) else { return } + guard let jpegData = image.jpegData(compressionQuality: compressionQuality) else { return } let url = avatarURL(for: key) ensureDirectoryExists() - try? data.write(to: url, options: .atomic) - cache.setObject(image, forKey: key as NSString, cost: data.count) + + // Encrypt with "rosetta-a" (Android/Desktop parity) + if let encrypted = try? CryptoManager.shared.encryptWithPassword(jpegData, password: Self.avatarPassword), + let encData = encrypted.data(using: .utf8) { + try? encData.write(to: url, options: [.atomic, .completeFileProtectionUntilFirstUserAuthentication]) + } else { + // Fallback: plaintext (should never happen) + try? jpegData.write(to: url, options: [.atomic, .completeFileProtectionUntilFirstUserAuthentication]) + } + cache.setObject(image, forKey: key as NSString, cost: jpegData.count) avatarVersion += 1 } /// Saves an avatar from a base64-encoded image string (used when receiving from network). - /// Desktop parity: desktop sends data URI format (`data:image/png;base64,...`). - /// Handles both raw base64 and data URI formats for cross-platform compatibility. func saveAvatarFromBase64(_ base64: String, publicKey: String) { let rawBase64: String if base64.hasPrefix("data:") { - // Desktop format: "data:image/png;base64,iVBOR..." — strip prefix rawBase64 = String(base64.drop(while: { $0 != "," }).dropFirst()) } else { rawBase64 = base64 @@ -63,10 +69,7 @@ final class AvatarRepository { } /// Loads avatar for the given public key. - /// System accounts return a bundled static avatar (desktop parity). - /// Regular accounts check cache first, then disk. func loadAvatar(publicKey: String) -> UIImage? { - // Desktop parity: system accounts have hardcoded avatars if let systemAvatar = systemAccountAvatar(for: publicKey) { return systemAvatar } @@ -75,16 +78,27 @@ final class AvatarRepository { return cached } let url = avatarURL(for: key) - guard let data = try? Data(contentsOf: url), - let image = UIImage(data: data) else { - return nil + guard let fileData = try? Data(contentsOf: url) else { return nil } + + // Try decrypting (new encrypted format) + if let encryptedString = String(data: fileData, encoding: .utf8), + let decrypted = try? CryptoManager.shared.decryptWithPassword(encryptedString, password: Self.avatarPassword), + let image = UIImage(data: decrypted) { + cache.setObject(image, forKey: key as NSString, cost: decrypted.count) + return image } - cache.setObject(image, forKey: key as NSString, cost: data.count) - return image + + // Fallback: try as plaintext JPEG (legacy unencrypted files) + if let image = UIImage(data: fileData) { + cache.setObject(image, forKey: key as NSString, cost: fileData.count) + // Re-save encrypted for next time + saveAvatar(publicKey: publicKey, image: image) + return image + } + + return nil } - /// Returns bundled avatar for system accounts, nil for regular accounts. - /// Desktop parity: `useSystemAccounts.ts` imports `updates.png` and `safe.png`. private func systemAccountAvatar(for publicKey: String) -> UIImage? { if publicKey == SystemAccounts.safePublicKey { return UIImage(named: "safe-avatar") @@ -95,8 +109,6 @@ final class AvatarRepository { return nil } - /// Returns base64-encoded JPEG string for sending over the network. - /// Desktop parity: `imagePrepareForNetworkTransfer()` returns base64. func loadAvatarBase64(publicKey: String) -> String? { guard let image = loadAvatar(publicKey: publicKey), let data = image.jpegData(compressionQuality: compressionQuality) else { @@ -105,7 +117,6 @@ final class AvatarRepository { return data.base64EncodedString() } - /// Removes the avatar for the given public key from disk and cache. func removeAvatar(publicKey: String) { let key = normalizedKey(publicKey) cache.removeObject(forKey: key as NSString) @@ -114,12 +125,10 @@ final class AvatarRepository { avatarVersion += 1 } - /// Clears in-memory cache only (used on memory warning). Disk avatars preserved. func clearCache() { cache.removeAllObjects() } - /// Clears entire avatar cache (used on full data reset). func clearAll() { cache.removeAllObjects() if let directory = avatarsDirectory { @@ -138,7 +147,7 @@ final class AvatarRepository { private func avatarURL(for normalizedKey: String) -> URL { avatarsDirectory! - .appendingPathComponent("\(normalizedKey).jpg") + .appendingPathComponent("\(normalizedKey).enc") } private func normalizedKey(_ publicKey: String) -> String { diff --git a/Rosetta/Core/Data/Repositories/DialogRepository.swift b/Rosetta/Core/Data/Repositories/DialogRepository.swift index 2c2a237..db50733 100644 --- a/Rosetta/Core/Data/Repositories/DialogRepository.swift +++ b/Rosetta/Core/Data/Repositories/DialogRepository.swift @@ -1,8 +1,10 @@ import Foundation import Observation import UserNotifications +import GRDB -/// Account-scoped dialog store with disk persistence. +/// Account-scoped dialog store backed by SQLite (GRDB). +/// Android parity: `DialogEntity` + `updateDialogFromMessages()` transaction. @Observable @MainActor final class DialogRepository { @@ -16,23 +18,12 @@ final class DialogRepository { } } - /// Monotonic counter incremented on every `dialogs` mutation. - /// Used by ChatListViewModel to avoid redundant partition recomputation. @ObservationIgnored private(set) var dialogsVersion: Int = 0 private var currentAccount: String = "" - private var storagePassword: String = "" - private var persistTask: Task? // MARK: - Sort Caches - /// Cached sort order (opponent keys). Invalidated only when sort-affecting - /// fields change (isPinned, lastMessageTimestamp, dialog added/removed). - /// Non-sort mutations (delivery status, online, unread) preserve this cache, - /// downgrading the sort cost from O(n log n) to O(n) compactMap. @ObservationIgnored private var _sortedKeysCache: [String]? - - /// Cached sorted dialog array. Invalidated on every `dialogs` mutation via didSet. - /// Multiple reads within the same SwiftUI body evaluation return this reference. @ObservationIgnored private var _sortedDialogsCache: [Dialog]? var sortedDialogs: [Dialog] { @@ -40,10 +31,8 @@ final class DialogRepository { let result: [Dialog] if let keys = _sortedKeysCache { - // Sort order still valid — rebuild values from fresh dialogs (O(n) lookups). result = keys.compactMap { dialogs[$0] } } else { - // Full re-sort needed (O(n log n)) — only when sort-affecting fields changed. result = Array(dialogs.values).sorted { if $0.isPinned != $1.isPinned { return $0.isPinned } return $0.lastMessageTimestamp > $1.lastMessageTimestamp @@ -55,8 +44,12 @@ final class DialogRepository { return result } + private var db: DatabaseManager { DatabaseManager.shared } + private init() {} + // MARK: - Lifecycle + func bootstrap(accountPublicKey: String, storagePassword: String) async { let account = accountPublicKey.trimmingCharacters(in: .whitespacesAndNewlines) guard !account.isEmpty else { @@ -64,239 +57,199 @@ final class DialogRepository { return } - if currentAccount == account, - self.storagePassword == storagePassword, - !dialogs.isEmpty { - return - } + if currentAccount == account, !dialogs.isEmpty { return } currentAccount = account - self.storagePassword = storagePassword - persistTask?.cancel() - persistTask = nil - let fileName = Self.dialogsFileName(for: account) - let stored = await ChatPersistenceStore.shared.load( - [Dialog].self, - fileName: fileName, - password: storagePassword - ) ?? [] - dialogs = Dictionary( - uniqueKeysWithValues: stored - .filter { $0.account == account } - .map { ($0.opponentKey, $0) } - ) + // Load all dialogs from SQLite into memory + do { + let records = try db.read { db in + try DialogRecord + .filter(DialogRecord.Columns.account == account) + .fetchAll(db) + } + dialogs = Dictionary( + uniqueKeysWithValues: records.map { ($0.opponentKey, $0.toDialog()) } + ) + } catch { + print("[DB] DialogRepository bootstrap error: \(error)") + dialogs = [:] + } _sortedKeysCache = nil updateAppBadge() syncMutedKeysToDefaults() } func reset(clearPersisted: Bool = false) { - persistTask?.cancel() - persistTask = nil dialogs.removeAll() _sortedKeysCache = nil - storagePassword = "" UNUserNotificationCenter.current().setBadgeCount(0) UserDefaults.standard.set(0, forKey: "app_badge_count") UserDefaults(suiteName: "group.com.rosetta.dev")?.set(0, forKey: "app_badge_count") guard !currentAccount.isEmpty else { return } - let accountToReset = currentAccount + let account = currentAccount currentAccount = "" guard clearPersisted else { return } - let fileName = Self.dialogsFileName(for: accountToReset) - Task(priority: .utility) { - await ChatPersistenceStore.shared.remove(fileName: fileName) + do { + try db.writeSync { db in + try db.execute(sql: "DELETE FROM dialogs WHERE account = ?", arguments: [account]) + } + } catch { + print("[DB] DialogRepository reset error: \(error)") } } // MARK: - Updates func upsertDialog(_ dialog: Dialog) { - if currentAccount.isEmpty { - currentAccount = dialog.account - } + if currentAccount.isEmpty { currentAccount = dialog.account } dialogs[dialog.opponentKey] = dialog _sortedKeysCache = nil - schedulePersist() + persistDialog(dialog) } - /// Creates or updates a dialog from an incoming message packet. - /// - Parameter fromSync: When `true`, outgoing messages are marked as `.delivered` - /// because the server already processed them — delivery ACKs will never arrive again. - /// - Parameter isNewMessage: When `false`, the message was already in MessageRepository - /// (dedup hit) — skip `unreadCount` increment to avoid double-counting. - func updateFromMessage(_ packet: PacketMessage, myPublicKey: String, decryptedText: String, fromSync: Bool = false, isNewMessage: Bool = true) { - if currentAccount.isEmpty { - currentAccount = myPublicKey - } - let fromMe = packet.fromPublicKey == myPublicKey - let opponentKey = fromMe ? packet.toPublicKey : packet.fromPublicKey + /// Android parity 1:1: `dialogDao.updateDialogFromMessages(account, opponentKey)`. + /// Full recalculation of ALL computed dialog fields from DB in one pass. + /// Called after EVERY message insert, delivery ACK, read receipt, and markAsRead. + func updateDialogFromMessages(opponentKey: String) { + guard !currentAccount.isEmpty else { return } + let account = currentAccount + let isSystem = SystemAccounts.isSystemAccount(opponentKey) + let isSavedMessages = account == opponentKey - var dialog = dialogs[opponentKey] ?? Dialog( - id: opponentKey, - account: myPublicKey, - opponentKey: opponentKey, - opponentTitle: "", - opponentUsername: "", - lastMessage: "", - lastMessageTimestamp: 0, - unreadCount: 0, - isOnline: false, - lastSeen: 0, - verified: 0, - iHaveSent: false, - isPinned: false, - isMuted: false, - lastMessageFromMe: false, - lastMessageDelivered: .waiting + // Preserve fields not derived from messages + let existing = dialogs[opponentKey] + + // 1. Last message from DB — DECRYPTED (text in DB is encrypted with privateKey) + guard let lastMsg = MessageRepository.shared.lastDecryptedMessage(account: account, opponentKey: opponentKey) else { + // No messages — remove dialog if it exists + if existing != nil { + dialogs.removeValue(forKey: opponentKey) + _sortedKeysCache = nil + do { + try db.writeSync { db in + try db.execute( + sql: "DELETE FROM dialogs WHERE account = ? AND opponent_key = ?", + arguments: [account, opponentKey] + ) + } + } catch {} + scheduleAppBadgeUpdate() + } + return + } + + // 2. Unread count from DB (Android: countUnreadByDialogKey) + let unread: Int + if isSavedMessages { + unread = 0 // Android parity: saved messages always 0 unread + } else { + unread = MessageRepository.shared.countUnread(account: account, opponentKey: opponentKey) + } + + // 3. hasSent from DB (Android: hasSentByDialogKey) + let hasSent = MessageRepository.shared.hasSentMessages(account: account, opponentKey: opponentKey) + + // 4. Last message display text + // Android parity: if text is empty/garbage, show attachment type label. + let textIsEmpty = lastMsg.text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + || Self.isGarbageText(lastMsg.text) + let lastMessageText: String + if textIsEmpty, let firstAtt = lastMsg.attachments.first { + switch firstAtt.type { + case .image: lastMessageText = "Photo" + case .file: lastMessageText = "File" + case .avatar: lastMessageText = "Avatar" + case .messages: lastMessageText = "Forwarded message" + } + } else if textIsEmpty { + lastMessageText = "" + } else { + lastMessageText = lastMsg.text + } + + let lastFromMe = lastMsg.fromPublicKey == account + + // 5. Build dialog — preserve non-message fields from existing + var dialog = existing ?? Dialog( + id: opponentKey, account: account, opponentKey: opponentKey, + opponentTitle: "", opponentUsername: "", + lastMessage: "", lastMessageTimestamp: 0, unreadCount: 0, + isOnline: false, lastSeen: 0, verified: 0, + iHaveSent: false, isPinned: false, isMuted: false, + lastMessageFromMe: false, lastMessageDelivered: .waiting ) - // Desktop parity: constructLastMessageTextByAttachments() returns - // "Photo"/"Avatar"/"File"/"Forwarded message" for attachment-only messages. - if decryptedText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty, - let firstAttachment = packet.attachments.first { - switch firstAttachment.type { - case .image: dialog.lastMessage = "Photo" - case .file: dialog.lastMessage = "File" - case .avatar: dialog.lastMessage = "Avatar" - case .messages: dialog.lastMessage = "Forwarded message" - } - } else { - dialog.lastMessage = decryptedText - } - dialog.lastMessageTimestamp = normalizeTimestamp(packet.timestamp) - dialog.lastMessageFromMe = fromMe - dialog.lastMessageDelivered = fromMe ? (fromSync ? .delivered : .waiting) : .delivered - - if fromMe { - dialog.iHaveSent = true - } else { - // Only increment unread count for REAL-TIME messages (not sync). - // During sync, messages may already be read on another device but arrive - // as "new" to iOS. Incrementing here inflates the badge (e.g., 11 → 4 → 0). - // Android parity: Android recalculates unread from DB after every message - // via COUNT(*) WHERE read=0. iOS defers to reconcileUnreadCounts() at sync end. - if isNewMessage && !fromSync && !MessageRepository.shared.isDialogActive(opponentKey) { - dialog.unreadCount += 1 - } - } + // Update computed fields + dialog.lastMessage = lastMessageText + dialog.lastMessageTimestamp = lastMsg.timestamp + dialog.unreadCount = unread + dialog.iHaveSent = hasSent || isSystem + dialog.lastMessageFromMe = lastFromMe + dialog.lastMessageDelivered = lastFromMe ? lastMsg.deliveryStatus : .delivered dialogs[opponentKey] = dialog _sortedKeysCache = nil - schedulePersist() - - // Desktop parity: re-evaluate request status based on last N messages. - // Skip for outgoing messages — iHaveSent is already set to true above, - // and the message hasn't been added to MessageRepository yet (race condition: - // updateRequestStatus reads MessageRepository BEFORE upsertFromMessagePacket). - if !fromMe { - updateRequestStatus(opponentKey: opponentKey) - } + persistDialog(dialog) + scheduleAppBadgeUpdate() } func ensureDialog( - opponentKey: String, - title: String, - username: String, - verified: Int = 0, - myPublicKey: String + opponentKey: String, title: String, username: String, + verified: Int = 0, myPublicKey: String ) { if var existing = dialogs[opponentKey] { var changed = false if !title.isEmpty, existing.opponentTitle != title { - existing.opponentTitle = title - changed = true + existing.opponentTitle = title; changed = true } if !username.isEmpty, existing.opponentUsername != username { - existing.opponentUsername = username - changed = true + existing.opponentUsername = username; changed = true } if verified > existing.verified { - existing.verified = verified - changed = true + existing.verified = verified; changed = true } guard changed else { return } dialogs[opponentKey] = existing - schedulePersist() + persistDialog(existing) return } - dialogs[opponentKey] = Dialog( - id: opponentKey, - account: myPublicKey, - opponentKey: opponentKey, - opponentTitle: title, - opponentUsername: username, - lastMessage: "", - lastMessageTimestamp: 0, - unreadCount: 0, - isOnline: false, - lastSeen: 0, - verified: verified, - iHaveSent: false, - isPinned: false, - isMuted: false, - lastMessageFromMe: false, - lastMessageDelivered: .waiting + let dialog = Dialog( + id: opponentKey, account: myPublicKey, opponentKey: opponentKey, + opponentTitle: title, opponentUsername: username, + lastMessage: "", lastMessageTimestamp: 0, unreadCount: 0, + isOnline: false, lastSeen: 0, verified: verified, + iHaveSent: false, isPinned: false, isMuted: false, + lastMessageFromMe: false, lastMessageDelivered: .waiting ) + dialogs[opponentKey] = dialog _sortedKeysCache = nil - schedulePersist() + persistDialog(dialog) } func updateOnlineState(publicKey: String, isOnline: Bool) { - PerformanceLogger.shared.track("dialog.updateOnline") guard var dialog = dialogs[publicKey] else { return } guard dialog.isOnline != isOnline else { return } dialog.isOnline = isOnline - if !isOnline { - dialog.lastSeen = Int64(Date().timeIntervalSince1970 * 1000) - } + if !isOnline { dialog.lastSeen = Int64(Date().timeIntervalSince1970 * 1000) } dialogs[publicKey] = dialog - schedulePersist() + persistDialog(dialog) } + /// Legacy shim — calls updateDialogFromMessages() for full recalculation. func updateDeliveryStatus(messageId: String, opponentKey: String, status: DeliveryStatus) { - guard var dialog = dialogs[opponentKey] else { return } - let current = dialog.lastMessageDelivered - if current == status { return } - - // Desktop parity: desktop reads the actual last message from the DB at - // render time (useDialogInfo → SELECT * FROM messages WHERE message_id = ?). - // On iOS we cache lastMessageDelivered, so we must only accept updates - // from the latest outgoing message to avoid stale ACKs / error timers - // from older messages overwriting the indicator. - let messages = MessageRepository.shared.messages(for: opponentKey) - if let lastOutgoing = messages.last(where: { $0.fromPublicKey == dialog.account }), - lastOutgoing.id != messageId { - return - } - - dialog.lastMessageDelivered = status - dialogs[opponentKey] = dialog - schedulePersist() + updateDialogFromMessages(opponentKey: opponentKey) } func updateUserInfo(publicKey: String, title: String, username: String, verified: Int = 0, online: Int = -1) { - PerformanceLogger.shared.track("dialog.updateUserInfo") guard var dialog = dialogs[publicKey] else { return } var changed = false - if !title.isEmpty, dialog.opponentTitle != title { - dialog.opponentTitle = title - changed = true - } - if !username.isEmpty, dialog.opponentUsername != username { - dialog.opponentUsername = username - changed = true - } - if verified > 0, dialog.verified < verified { - dialog.verified = verified - changed = true - } - // Server protocol: 0 = ONLINE, 1 = OFFLINE (matches desktop OnlineState enum) - // -1 = not provided (don't update) + if !title.isEmpty, dialog.opponentTitle != title { dialog.opponentTitle = title; changed = true } + if !username.isEmpty, dialog.opponentUsername != username { dialog.opponentUsername = username; changed = true } + if verified > 0, dialog.verified < verified { dialog.verified = verified; changed = true } if online >= 0 { let newOnline = online == 0 if dialog.isOnline != newOnline { @@ -307,173 +260,146 @@ final class DialogRepository { } guard changed else { return } dialogs[publicKey] = dialog - schedulePersist() + persistDialog(dialog) } + /// Android parity: recalculate dialog from DB after marking messages as read. func markAsRead(opponentKey: String) { - guard var dialog = dialogs[opponentKey] else { return } - guard dialog.unreadCount > 0 else { return } - dialog.unreadCount = 0 - dialogs[opponentKey] = dialog - schedulePersist() + updateDialogFromMessages(opponentKey: opponentKey) } + /// Android parity: recalculate dialog from DB after opponent reads our messages. func markOutgoingAsRead(opponentKey: String) { - guard var dialog = dialogs[opponentKey] else { return } - guard dialog.lastMessageFromMe, dialog.lastMessageDelivered != .read else { return } - dialog.lastMessageDelivered = .read - dialogs[opponentKey] = dialog - schedulePersist() + updateDialogFromMessages(opponentKey: opponentKey) } func deleteDialog(opponentKey: String) { dialogs.removeValue(forKey: opponentKey) _sortedKeysCache = nil - schedulePersist() + do { + try db.writeSync { db in + try db.execute( + sql: "DELETE FROM dialogs WHERE account = ? AND opponent_key = ?", + arguments: [currentAccount, opponentKey] + ) + } + } catch { + print("[DB] deleteDialog error: \(error)") + } + scheduleAppBadgeUpdate() } - /// Update dialog metadata after a single message was deleted. - /// If no messages remain, the dialog is removed entirely. + /// Android parity: full recalculation after message delete. func reconcileAfterMessageDelete(opponentKey: String) { - let messages = MessageRepository.shared.messages(for: opponentKey) - guard var dialog = dialogs[opponentKey] else { return } - - guard let lastMsg = messages.last else { - dialogs.removeValue(forKey: opponentKey) - _sortedKeysCache = nil - schedulePersist() - return - } - - if lastMsg.text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty, - let firstAttachment = lastMsg.attachments.first { - switch firstAttachment.type { - case .image: dialog.lastMessage = "Photo" - case .file: dialog.lastMessage = "File" - case .avatar: dialog.lastMessage = "Avatar" - case .messages: dialog.lastMessage = "Forwarded message" - } - } else { - dialog.lastMessage = lastMsg.text - } - dialog.lastMessageTimestamp = lastMsg.timestamp - dialog.lastMessageFromMe = lastMsg.fromPublicKey == currentAccount - dialog.lastMessageDelivered = lastMsg.deliveryStatus - dialogs[opponentKey] = dialog - _sortedKeysCache = nil - schedulePersist() + updateDialogFromMessages(opponentKey: opponentKey) } - /// Desktop parity: check last N messages to determine if dialog should be a request. - /// If none of the last `dialogDropToRequestsMessageCount` messages are from me, - /// and the dialog is not a system account, mark as request (`iHaveSent = false`). - func updateRequestStatus(opponentKey: String) { - guard var dialog = dialogs[opponentKey] else { return } - // System accounts are never requests. - if SystemAccounts.isSystemAccount(opponentKey) { - if !dialog.iHaveSent { - dialog.iHaveSent = true - dialogs[opponentKey] = dialog - schedulePersist() - } - return - } - - let messages = MessageRepository.shared.messages(for: opponentKey) - let recentMessages = messages.suffix(ProtocolConstants.dialogDropToRequestsMessageCount) - let hasMyMessage = recentMessages.contains { $0.fromPublicKey == currentAccount } - - if dialog.iHaveSent != hasMyMessage { - dialog.iHaveSent = hasMyMessage - dialogs[opponentKey] = dialog - schedulePersist() - } - } - - /// Desktop parity: remove dialog if it has no messages. func removeDialogIfEmpty(opponentKey: String) { let messages = MessageRepository.shared.messages(for: opponentKey) if messages.isEmpty { dialogs.removeValue(forKey: opponentKey) _sortedKeysCache = nil - schedulePersist() + do { + try db.writeSync { db in + try db.execute( + sql: "DELETE FROM dialogs WHERE account = ? AND opponent_key = ?", + arguments: [currentAccount, opponentKey] + ) + } + } catch {} + scheduleAppBadgeUpdate() } } func togglePin(opponentKey: String) { guard var dialog = dialogs[opponentKey] else { return } - if !dialog.isPinned { - // Desktop parity: max 3 pinned dialogs. let pinnedCount = dialogs.values.filter(\.isPinned).count - if pinnedCount >= ProtocolConstants.maxPinnedDialogs { - return - } + if pinnedCount >= ProtocolConstants.maxPinnedDialogs { return } } - dialog.isPinned.toggle() dialogs[opponentKey] = dialog _sortedKeysCache = nil - schedulePersist() + persistDialog(dialog) } - /// Desktop parity: reconcile `unreadCount` with the actual `isRead` state of messages - /// in MessageRepository. Called after sync completes to fix any divergence accumulated - /// during batch processing (e.g., sync re-processing already-read messages). - /// Desktop equivalent: `SELECT COUNT(*) FROM messages WHERE read = 0`. - func reconcileUnreadCounts() { - var changed = false - for (opponentKey, dialog) in dialogs { - let messages = MessageRepository.shared.messages(for: opponentKey) - let actualUnread = messages.filter { - $0.fromPublicKey == opponentKey && !$0.isRead - }.count - if dialog.unreadCount != actualUnread { - var updated = dialog - updated.unreadCount = actualUnread - dialogs[opponentKey] = updated - changed = true - } - } - if changed { - schedulePersist() + /// Android parity: full recalculation for ALL dialogs. + /// Replaces separate reconcileUnreadCounts + reconcileDeliveryStatuses. + func reconcileAllDialogs() { + for opponentKey in Array(dialogs.keys) { + updateDialogFromMessages(opponentKey: opponentKey) } } - /// Desktop parity: reconcile dialog-level `lastMessageDelivered` with the actual - /// delivery status of the last message in MessageRepository. - /// Called after sync completes to fix any divergence accumulated during batch processing. - func reconcileDeliveryStatuses() { - var changed = false - for (opponentKey, dialog) in dialogs { - guard dialog.lastMessageFromMe else { continue } - let messages = MessageRepository.shared.messages(for: opponentKey) - guard let lastMessage = messages.last, - lastMessage.fromPublicKey == currentAccount else { continue } - let realStatus = lastMessage.deliveryStatus - if dialog.lastMessageDelivered != realStatus { - var updated = dialog - updated.lastMessageDelivered = realStatus - dialogs[opponentKey] = updated - changed = true - } - } - if changed { - schedulePersist() - } - } + /// Legacy shims for callers that still reference old methods. + func reconcileUnreadCounts() { reconcileAllDialogs() } + func reconcileDeliveryStatuses() { /* handled by reconcileAllDialogs() */ } func toggleMute(opponentKey: String) { guard var dialog = dialogs[opponentKey] else { return } dialog.isMuted.toggle() dialogs[opponentKey] = dialog syncMutedKeysToDefaults() - schedulePersist() + persistDialog(dialog) + scheduleAppBadgeUpdate() + } + + // MARK: - Private + + private func persistDialog(_ dialog: Dialog) { + do { + try db.writeSync { db in + // UPSERT: insert or replace based on (account, opponent_key) unique index + try db.execute( + sql: """ + INSERT INTO dialogs (account, opponent_key, opponent_title, opponent_username, + last_message, last_message_timestamp, unread_count, is_online, last_seen, + verified, i_have_sent, is_pinned, is_muted, last_message_from_me, last_message_delivered) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(account, opponent_key) DO UPDATE SET + opponent_title = excluded.opponent_title, + opponent_username = excluded.opponent_username, + last_message = excluded.last_message, + last_message_timestamp = excluded.last_message_timestamp, + unread_count = excluded.unread_count, + is_online = excluded.is_online, + last_seen = excluded.last_seen, + verified = excluded.verified, + i_have_sent = excluded.i_have_sent, + is_pinned = excluded.is_pinned, + is_muted = excluded.is_muted, + last_message_from_me = excluded.last_message_from_me, + last_message_delivered = excluded.last_message_delivered + """, + arguments: [ + dialog.account, dialog.opponentKey, dialog.opponentTitle, dialog.opponentUsername, + dialog.lastMessage, dialog.lastMessageTimestamp, dialog.unreadCount, + dialog.isOnline ? 1 : 0, dialog.lastSeen, dialog.verified, + dialog.iHaveSent ? 1 : 0, dialog.isPinned ? 1 : 0, dialog.isMuted ? 1 : 0, + dialog.lastMessageFromMe ? 1 : 0, dialog.lastMessageDelivered.rawValue + ] + ) + } + } catch { + print("[DB] persistDialog error: \(error)") + } + } + + private func lastMessageFromDB(dialogKey: String) -> ChatMessage? { + do { + return try db.read { db in + try MessageRecord + .filter( + MessageRecord.Columns.account == currentAccount && + MessageRecord.Columns.dialogKey == dialogKey + ) + .order(MessageRecord.Columns.timestamp.desc, MessageRecord.Columns.messageId.desc) + .fetchOne(db)? + .toChatMessage() + } + } catch { return nil } } - /// Sync muted chat keys to shared App Group UserDefaults. - /// Background push handler reads this to skip notifications for muted chats - /// without needing MainActor access to DialogRepository. private func syncMutedKeysToDefaults() { let mutedKeys = dialogs.values.filter(\.isMuted).map(\.opponentKey) UserDefaults.standard.set(mutedKeys, forKey: "muted_chats_keys") @@ -484,31 +410,10 @@ final class DialogRepository { raw < 1_000_000_000_000 ? raw * 1000 : raw } - private func schedulePersist() { - guard !currentAccount.isEmpty else { return } - PerformanceLogger.shared.track("dialog.schedulePersist") + // MARK: - Badge - scheduleAppBadgeUpdate() - - let snapshot = Array(dialogs.values) - let fileName = Self.dialogsFileName(for: currentAccount) - let storagePassword = self.storagePassword - persistTask?.cancel() - persistTask = Task(priority: .utility) { - try? await Task.sleep(for: .milliseconds(300)) - guard !Task.isCancelled else { return } - await ChatPersistenceStore.shared.save( - snapshot, - fileName: fileName, - password: storagePassword.isEmpty ? nil : storagePassword - ) - } - } - - /// PERF: debounced badge update — avoids filter+reduce of 100+ dialogs + system API calls - /// on every single dialog mutation. Coalesces rapid updates into one. - private var badgeUpdateTask: Task? - private var lastBadgeTotal: Int = -1 + @ObservationIgnored private var badgeUpdateTask: Task? + @ObservationIgnored private var lastBadgeTotal: Int = -1 private func scheduleAppBadgeUpdate() { badgeUpdateTask?.cancel() @@ -519,24 +424,29 @@ final class DialogRepository { } } - /// Update app icon badge with total unread message count. - /// Writes to shared App Group UserDefaults so the Notification Service Extension - /// can read the current count and increment it when the app is terminated. private func updateAppBadge() { - PerformanceLogger.shared.track("dialog.badgeUpdate") let total = dialogs.values.reduce(0) { $0 + ($1.isMuted ? 0 : $1.unreadCount) } - // Guard: skip if badge hasn't changed (avoids system API + UserDefaults writes). guard total != lastBadgeTotal else { return } lastBadgeTotal = total UNUserNotificationCenter.current().setBadgeCount(total) - // Shared storage — NSE reads this to increment badge when app is killed. let shared = UserDefaults(suiteName: "group.com.rosetta.dev") shared?.set(total, forKey: "app_badge_count") - // Keep standard defaults in sync for backward compat. UserDefaults.standard.set(total, forKey: "app_badge_count") } - private static func dialogsFileName(for accountPublicKey: String) -> String { - ChatPersistenceStore.accountScopedFileName(prefix: "dialogs", accountPublicKey: accountPublicKey) + // MARK: - Garbage Text Detection + + /// Returns true if text is empty or contains only garbage characters + /// (replacement chars, control chars, null bytes). + private static func isGarbageText(_ text: String) -> Bool { + let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines) + if trimmed.isEmpty { return true } + let validCharacters = trimmed.unicodeScalars.filter { scalar in + scalar.value != 0xFFFD && + scalar.value > 0x1F && + scalar.value != 0x7F && + !CharacterSet.controlCharacters.contains(scalar) + } + return validCharacters.isEmpty } } diff --git a/Rosetta/Core/Data/Repositories/MessageRepository.swift b/Rosetta/Core/Data/Repositories/MessageRepository.swift index 27b99b2..74f7e45 100644 --- a/Rosetta/Core/Data/Repositories/MessageRepository.swift +++ b/Rosetta/Core/Data/Repositories/MessageRepository.swift @@ -1,26 +1,48 @@ import Foundation import Combine +import GRDB -/// Account-scoped message store with disk persistence. +/// Account-scoped message store backed by SQLite (GRDB). +/// Android parity: `MessageRepository.kt` + Room DAO queries. @MainActor final class MessageRepository: ObservableObject { static let shared = MessageRepository() + /// In-memory cache of messages for active dialogs. UI binds to this. @Published private(set) var messagesByDialog: [String: [ChatMessage]] = [:] @Published private(set) var typingDialogs: Set = [] private var activeDialogs: Set = [] - private var messageToDialog: [String: String] = [:] - /// Persistent set of all message IDs ever seen — survives cap eviction. - /// Prevents duplicate messages during repeated sync cycles. - private var allKnownMessageIds: Set = [] private var typingResetTasks: [String: Task] = [:] - private var persistTask: Task? private var currentAccount: String = "" - private var storagePassword: String = "" + + /// Android parity: private key for encrypting message text at rest. + /// Android: `CryptoManager.encryptWithPassword(plainText, privateKey)` → `plain_message` column. + /// Set from SessionManager.startSession(). + private var privateKey: String = "" + + /// Android parity: in-memory LRU dedup cache (max 1000 entries). + /// O(1) check BEFORE SQL query. Protects against duplicate packets and race conditions. + private var processedMessageIds = LRUSet(maxSize: 1000) + + /// Debounced cache refresh — batches multiple refreshes during sync. + /// Android parity: ChatViewModel debounces incoming messages at 100ms. + private var pendingCacheRefresh: Set = [] + private var cacheRefreshTask: Task? + + /// Page size for initial message loading. Android: PAGE_SIZE = 30. + static let pageSize = 50 + + /// Max messages per dialog in memory cache. Android: MAX_CACHE_SIZE = 500. + static let maxCacheSize = 500 + + private var db: DatabaseManager { DatabaseManager.shared } private init() {} + // MARK: - Lifecycle + + /// Android parity: `MessageRepository.initialize(publicKey, privateKey)`. func bootstrap(accountPublicKey: String, storagePassword: String) async { let account = accountPublicKey.trimmingCharacters(in: .whitespacesAndNewlines) guard !account.isEmpty else { @@ -28,96 +50,123 @@ final class MessageRepository: ObservableObject { return } - if currentAccount == account, - self.storagePassword == storagePassword, - !messagesByDialog.isEmpty { - return - } + if currentAccount == account, !messagesByDialog.isEmpty { return } currentAccount = account - self.storagePassword = storagePassword - persistTask?.cancel() - persistTask = nil + privateKey = storagePassword // privateKeyHex used as encryption password activeDialogs.removeAll() typingDialogs.removeAll() - for task in typingResetTasks.values { - task.cancel() - } + for task in typingResetTasks.values { task.cancel() } typingResetTasks.removeAll() - messageToDialog.removeAll() - allKnownMessageIds.removeAll() - - let fileName = Self.messagesFileName(for: account) - let stored = await ChatPersistenceStore.shared.load( - [String: [ChatMessage]].self, - fileName: fileName, - password: storagePassword - ) ?? [:] - var restored: [String: [ChatMessage]] = [:] - for (dialogKey, list) in stored { - var sorted = list.sorted { - if $0.timestamp != $1.timestamp { - return $0.timestamp < $1.timestamp - } - return $0.id < $1.id - } - restored[dialogKey] = sorted - for message in sorted { - messageToDialog[message.id] = dialogKey - allKnownMessageIds.insert(message.id) - } - } - // Restore persisted known IDs (includes evicted message IDs) - if let savedIds = await ChatPersistenceStore.shared.load( - Set.self, - fileName: Self.knownIdsFileName(for: account), - password: storagePassword - ) { - allKnownMessageIds.formUnion(savedIds) - } - messagesByDialog = restored + messagesByDialog.removeAll() + processedMessageIds.removeAll() + pendingCacheRefresh.removeAll() + cacheRefreshTask?.cancel() } // MARK: - Dialog State func messages(for dialogKey: String) -> [ChatMessage] { - messagesByDialog[dialogKey] ?? [] + if let cached = messagesByDialog[dialogKey] { return cached } + guard !currentAccount.isEmpty else { return [] } + let loaded = loadMessagesFromDB(dialogKey: dialogKey, limit: Self.pageSize) + messagesByDialog[dialogKey] = loaded + return loaded + } + + /// Load older messages for pagination (scroll-to-load-more). + func loadOlderMessages(for dialogKey: String, beforeTimestamp: Int64, limit: Int = 50) -> [ChatMessage] { + let dbDialogKey = DatabaseManager.dialogKey(account: currentAccount, opponentKey: dialogKey) + do { + let records = try db.read { db in + try MessageRecord + .filter( + MessageRecord.Columns.account == currentAccount && + MessageRecord.Columns.dialogKey == dbDialogKey && + MessageRecord.Columns.timestamp < beforeTimestamp + ) + .order(MessageRecord.Columns.timestamp.desc, MessageRecord.Columns.messageId.desc) + .limit(limit) + .fetchAll(db) + } + let older = records.reversed().map { decryptRecord($0) } + // Prepend to cache + if var cached = messagesByDialog[dialogKey] { + let existingIds = Set(cached.map(\.id)) + let newMessages = older.filter { !existingIds.contains($0.id) } + cached = newMessages + cached + messagesByDialog[dialogKey] = cached + } + return older + } catch { + print("[DB] loadOlderMessages error: \(error)") + return [] + } + } + + /// Android parity: two-level dedup (in-memory LRU + DB check). + func hasMessage(_ messageId: String) -> Bool { + // Level 1: in-memory LRU cache (O(1)) + if processedMessageIds.contains(messageId) { return true } + // Level 2: database check + guard !currentAccount.isEmpty else { return false } + do { + let exists = try db.read { db in + try MessageRecord.filter( + MessageRecord.Columns.account == currentAccount && + MessageRecord.Columns.messageId == messageId + ).fetchCount(db) > 0 + } + if exists { processedMessageIds.insert(messageId) } + return exists + } catch { return false } } func dialogKey(forMessageId messageId: String) -> String? { - messageToDialog[messageId] - } - - func hasMessage(_ messageId: String) -> Bool { - allKnownMessageIds.contains(messageId) + guard !currentAccount.isEmpty else { return nil } + do { + return try db.read { db in + try MessageRecord + .select(MessageRecord.Columns.dialogKey) + .filter( + MessageRecord.Columns.account == currentAccount && + MessageRecord.Columns.messageId == messageId + ) + .fetchOne(db)? + .dialogKey + } + } catch { return nil } } func isLatestMessage(_ messageId: String, in dialogKey: String) -> Bool { - messagesByDialog[dialogKey]?.last?.id == messageId + messages(for: dialogKey).last?.id == messageId } - /// Android parity: returns the timestamp of the latest incoming message in a dialog. - /// Used for read receipt dedup — only send if `latestIncomingTs > lastReadReceiptTimestamp`. func latestIncomingTimestamp(for dialogKey: String, myPublicKey: String) -> Int64? { - messagesByDialog[dialogKey]? - .last { $0.fromPublicKey == dialogKey || $0.fromPublicKey != myPublicKey }? - .timestamp + guard !currentAccount.isEmpty else { return nil } + do { + return try db.read { db in + try MessageRecord + .select(MessageRecord.Columns.timestamp) + .filter( + MessageRecord.Columns.account == currentAccount && + MessageRecord.Columns.dialogKey == dialogKey && + MessageRecord.Columns.fromMe == 0 + ) + .order(MessageRecord.Columns.timestamp.desc) + .fetchOne(db)? + .timestamp + } + } catch { return nil } } - /// Whether the user is currently viewing any chat. - var hasActiveDialog: Bool { - !activeDialogs.isEmpty - } + var hasActiveDialog: Bool { !activeDialogs.isEmpty } func isDialogActive(_ dialogKey: String) -> Bool { activeDialogs.contains(dialogKey) } - /// All currently active dialog keys (read-only snapshot). - /// Android parity: used to re-mark messages as read on idle→active transition. - var activeDialogKeys: Set { - activeDialogs - } + var activeDialogKeys: Set { activeDialogs } func setDialogActive(_ dialogKey: String, isActive: Bool) { if isActive { @@ -132,8 +181,6 @@ final class MessageRepository: ObservableObject { // MARK: - Message Updates - /// - Parameter fromSync: When `true`, outgoing messages are created as `.delivered` - /// because the server already processed them during sync — ACKs will never arrive again. func upsertFromMessagePacket( _ packet: PacketMessage, myPublicKey: String, @@ -143,122 +190,205 @@ final class MessageRepository: ObservableObject { ) { PerformanceLogger.shared.track("message.upsert") let fromMe = packet.fromPublicKey == myPublicKey - let dialogKey = fromMe ? packet.toPublicKey : packet.fromPublicKey + let opponentKey = fromMe ? packet.toPublicKey : packet.fromPublicKey let messageId = packet.messageId.isEmpty ? UUID().uuidString : packet.messageId let timestamp = normalizeTimestamp(packet.timestamp) - let incomingRead = !fromMe && activeDialogs.contains(dialogKey) - // Sync-originated outgoing messages: server already delivered them, - // no ACK will arrive → treat as .delivered immediately. + let dialogKey = DatabaseManager.dialogKey(account: myPublicKey, opponentKey: opponentKey) + let incomingRead = !fromMe && activeDialogs.contains(opponentKey) let outgoingStatus: DeliveryStatus = (fromMe && fromSync) ? .delivered : (fromMe ? .waiting : .delivered) - // Check BEFORE inserting — if this ID was already known (from a previous - // session or evicted from the 40-cap), the message was previously seen. - // During sync, treat previously-seen opponent messages as read to avoid - // inflating unread counts (server doesn't re-send PacketRead during sync). - let wasKnownBefore = allKnownMessageIds.contains(messageId) + // Add to LRU dedup cache + processedMessageIds.insert(messageId) - messageToDialog[messageId] = dialogKey - allKnownMessageIds.insert(messageId) - - updateMessages(for: dialogKey) { messages in - if let existingIndex = messages.firstIndex(where: { $0.id == messageId }) { - messages[existingIndex].text = decryptedText - messages[existingIndex].timestamp = timestamp - messages[existingIndex].attachments = packet.attachments - // Preserve or update attachment password for on-demand download - if let attachmentPassword, !attachmentPassword.isEmpty { - messages[existingIndex].attachmentPassword = attachmentPassword - } - if fromMe, messages[existingIndex].deliveryStatus == .error { - messages[existingIndex].deliveryStatus = fromSync ? .delivered : .waiting - } - // Mark as read if dialog is active OR if this is a sync-restored - // known message (was read on another device). - if incomingRead || (fromSync && wasKnownBefore && !fromMe) { - messages[existingIndex].isRead = true - } - return - } - - // During sync, previously-known messages (evicted from 40-cap but still - // in allKnownMessageIds) are historical — mark as read. Without this, - // reconcileUnreadCounts() would count them as unread and show wrong badges. - let syncRestoredRead = fromSync && wasKnownBefore && !fromMe - - messages.append( - ChatMessage( - id: messageId, - fromPublicKey: packet.fromPublicKey, - toPublicKey: packet.toPublicKey, - text: decryptedText, - timestamp: timestamp, - deliveryStatus: outgoingStatus, - isRead: incomingRead || fromMe || syncRestoredRead, - attachments: packet.attachments, - attachmentPassword: attachmentPassword - ) - ) + // Android parity: encrypt plaintext with private key for local storage. + // Android: `encryptWithPassword(plainText, privateKey)` → `plain_message` column. + // If encryption fails, store plaintext as fallback. + let storedText: String + if !privateKey.isEmpty, + let enc = try? CryptoManager.shared.encryptWithPassword(Data(decryptedText.utf8), password: privateKey) { + storedText = enc + } else { + storedText = decryptedText } + + let encoder = JSONEncoder() + let attachmentsJSON: String + if let data = try? encoder.encode(packet.attachments), + let str = String(data: data, encoding: .utf8) { + attachmentsJSON = str + } else { + attachmentsJSON = "[]" + } + + do { + try db.writeSync { db in + // Check if message exists + if let existing = try MessageRecord.filter( + MessageRecord.Columns.account == myPublicKey && + MessageRecord.Columns.messageId == messageId + ).fetchOne(db) { + // Protect decrypted .messages blobs from being overwritten by encrypted sync copies. + // If existing attachments have a valid JSON blob but new ones have encrypted ciphertext, + // keep the existing (correctly decrypted) version. + let shouldPreserveAttachments: Bool = { + guard existing.attachments != "[]" else { return false } + // If existing has a .messages blob that looks like valid JSON (starts with "["), + // and new blob looks like ciphertext (contains ":" from ivBase64:ctBase64), + // preserve the existing data. + if existing.attachments.contains("\"type\":1") || existing.attachments.contains("\"type\": 1") { + // Existing has .messages attachment — check if new data would corrupt it + let existingHasDecrypted = existing.attachments.contains("\"message_id\"") + let newHasDecrypted = attachmentsJSON.contains("\"message_id\"") + if existingHasDecrypted && !newHasDecrypted { return true } + } + return false + }() + + let finalAttachments = shouldPreserveAttachments ? existing.attachments : attachmentsJSON + + #if DEBUG + if shouldPreserveAttachments { + print("🛡️ [DB] Preserved decrypted .messages blob for messageId=\(messageId.prefix(16)) (sync would have corrupted it)") + } + #endif + + // Update existing message (store encrypted text) + try db.execute( + sql: """ + UPDATE messages SET text = ?, timestamp = ?, attachments = ?, + attachment_password = COALESCE(?, attachment_password), + delivery_status = CASE + WHEN from_me = 1 AND delivery_status = 2 THEN ? + ELSE delivery_status + END, + is_read = CASE WHEN ? = 1 THEN 1 ELSE is_read END + WHERE account = ? AND message_id = ? + """, + arguments: [ + storedText, timestamp, finalAttachments, + attachmentPassword, + fromSync ? DeliveryStatus.delivered.rawValue : DeliveryStatus.waiting.rawValue, + incomingRead ? 1 : 0, + myPublicKey, messageId + ] + ) + } else { + // Insert new message — Android parity: read = 0 for incoming by default + let isRead = incomingRead || fromMe + var record = MessageRecord( + id: nil, + account: myPublicKey, + fromPublicKey: packet.fromPublicKey, + toPublicKey: packet.toPublicKey, + text: storedText, + timestamp: timestamp, + isRead: isRead ? 1 : 0, + fromMe: fromMe ? 1 : 0, + deliveryStatus: outgoingStatus.rawValue, + messageId: messageId, + dialogKey: dialogKey, + attachments: attachmentsJSON, + attachmentPassword: attachmentPassword + ) + try record.insert(db) + } + } + } catch { + print("[DB] upsert error: \(error)") + } + + // Debounced cache refresh — batch during sync + scheduleCacheRefresh(for: opponentKey) } - /// Returns the current delivery status for a message, or nil if not found. func deliveryStatus(forMessageId messageId: String) -> DeliveryStatus? { - guard let dialogKey = messageToDialog[messageId] else { return nil } - return messagesByDialog[dialogKey]?.first(where: { $0.id == messageId })?.deliveryStatus + guard !currentAccount.isEmpty else { return nil } + do { + return try db.read { db in + try MessageRecord + .filter( + MessageRecord.Columns.account == currentAccount && + MessageRecord.Columns.messageId == messageId + ) + .fetchOne(db) + .flatMap { DeliveryStatus(rawValue: $0.deliveryStatus) } + } + } catch { return nil } } func updateDeliveryStatus(messageId: String, status: DeliveryStatus, newTimestamp: Int64? = nil) { PerformanceLogger.shared.track("message.deliveryUpdate") - guard let dialogKey = messageToDialog[messageId] else { return } - updateMessages(for: dialogKey) { messages in - guard let index = messages.firstIndex(where: { $0.id == messageId }) else { return } - let current = messages[index].deliveryStatus - if current == .read, status == .delivered { - return + guard !currentAccount.isEmpty else { return } + + do { + let opponentKey: String? = try db.writeSync { db in + guard let existing = try MessageRecord.filter( + MessageRecord.Columns.account == currentAccount && + MessageRecord.Columns.messageId == messageId + ).fetchOne(db) else { return nil } + + let current = DeliveryStatus(rawValue: existing.deliveryStatus) ?? .waiting + if current == .read && (status == .delivered || status == .waiting) { return nil } + if current == .delivered && status == .waiting { return nil } + + var sql = "UPDATE messages SET delivery_status = ?" + var args: [any DatabaseValueConvertible] = [status.rawValue] + if let ts = newTimestamp { + sql += ", timestamp = ?" + args.append(ts) + } + sql += " WHERE account = ? AND message_id = ?" + args.append(currentAccount) + args.append(messageId) + try db.execute(sql: sql, arguments: StatementArguments(args)) + + let fromMe = existing.fromMe != 0 + return fromMe ? existing.toPublicKey : existing.fromPublicKey } - if current == .read, status == .waiting { - return - } - if current == .delivered, status == .waiting { - return - } - messages[index].deliveryStatus = status - // Desktop parity: update timestamp on delivery ACK. - if let newTimestamp { - messages[index].timestamp = newTimestamp + + if let opponentKey { + refreshCacheNow(for: opponentKey) } + } catch { + print("[DB] updateDeliveryStatus error: \(error)") } } func markIncomingAsRead(opponentKey: String, myPublicKey: String) { - updateMessages(for: opponentKey) { messages in - for index in messages.indices { - if messages[index].fromPublicKey == opponentKey, - messages[index].toPublicKey == myPublicKey - { - messages[index].isRead = true - } + guard !currentAccount.isEmpty else { return } + let dialogKey = DatabaseManager.dialogKey(account: myPublicKey, opponentKey: opponentKey) + do { + try db.writeSync { db in + try db.execute( + sql: "UPDATE messages SET is_read = 1 WHERE account = ? AND dialog_key = ? AND from_me = 0", + arguments: [myPublicKey, dialogKey] + ) } + } catch { + print("[DB] markIncomingAsRead error: \(error)") } + refreshCacheNow(for: opponentKey) } func markOutgoingAsRead(opponentKey: String, myPublicKey: String) { - updateMessages(for: opponentKey) { messages in - for index in messages.indices { - if messages[index].fromPublicKey == myPublicKey, - messages[index].toPublicKey == opponentKey - { - messages[index].deliveryStatus = .read - } + guard !currentAccount.isEmpty else { return } + let dialogKey = DatabaseManager.dialogKey(account: myPublicKey, opponentKey: opponentKey) + do { + try db.writeSync { db in + try db.execute( + sql: "UPDATE messages SET delivery_status = ? WHERE account = ? AND dialog_key = ? AND from_me = 1", + arguments: [DeliveryStatus.read.rawValue, myPublicKey, dialogKey] + ) } + } catch { + print("[DB] markOutgoingAsRead error: \(error)") } + refreshCacheNow(for: opponentKey) } // MARK: - Typing func markTyping(from opponentKey: String) { - // Guard: only publish if not already in set — prevents duplicate re-renders - // when multiple typing packets arrive within the 3s window. if !typingDialogs.contains(opponentKey) { typingDialogs.insert(opponentKey) } @@ -275,200 +405,368 @@ final class MessageRepository: ObservableObject { typingDialogs.contains(dialogKey) } + // MARK: - Delete + func deleteDialog(_ dialogKey: String) { - guard let removedMessages = messagesByDialog.removeValue(forKey: dialogKey) else { return } + let dbDialogKey = DatabaseManager.dialogKey(account: currentAccount, opponentKey: dialogKey) + do { + try db.writeSync { db in + try db.execute( + sql: "DELETE FROM messages WHERE account = ? AND dialog_key = ?", + arguments: [currentAccount, dbDialogKey] + ) + } + } catch { + print("[DB] deleteDialog error: \(error)") + } + messagesByDialog.removeValue(forKey: dialogKey) activeDialogs.remove(dialogKey) typingDialogs.remove(dialogKey) typingResetTasks[dialogKey]?.cancel() typingResetTasks[dialogKey] = nil - // O(k) where k = messages in this dialog (max 40), instead of O(n) for all messages - for message in removedMessages { - messageToDialog.removeValue(forKey: message.id) - } - schedulePersist() } - /// Resolve outgoing messages that need retry after reconnect. - /// Includes both `.waiting` and `.error` messages — error messages are - /// retried on reconnect because they likely failed due to connection loss. - /// Messages older than `maxRetryAgeMs` are skipped (stale, user should retry manually). + func deleteMessage(id: String) { + guard !currentAccount.isEmpty else { return } + var affectedOpponent: String? + do { + affectedOpponent = try db.writeSync { db in + let record = try MessageRecord.filter( + MessageRecord.Columns.account == currentAccount && + MessageRecord.Columns.messageId == id + ).fetchOne(db) + guard let record else { return nil as String? } + try db.execute( + sql: "DELETE FROM messages WHERE account = ? AND message_id = ?", + arguments: [currentAccount, id] + ) + let fromMe = record.fromMe != 0 + return fromMe ? record.toPublicKey : record.fromPublicKey + } + } catch { + print("[DB] deleteMessage error: \(error)") + } + if let opponentKey = affectedOpponent { + refreshCacheNow(for: opponentKey) + } + } + func resolveRetryableOutgoingMessages( myPublicKey: String, nowMs: Int64, maxRetryAgeMs: Int64 ) -> [ChatMessage] { - var retryable: [ChatMessage] = [] - var hasMutations = false let cutoff = nowMs - maxRetryAgeMs + do { + let records = try db.writeSync { db -> [MessageRecord] in + let results = try MessageRecord.filter( + MessageRecord.Columns.account == myPublicKey && + MessageRecord.Columns.fromMe == 1 && + (MessageRecord.Columns.deliveryStatus == DeliveryStatus.waiting.rawValue || + MessageRecord.Columns.deliveryStatus == DeliveryStatus.error.rawValue) && + MessageRecord.Columns.timestamp >= cutoff + ).fetchAll(db) - for (dialogKey, currentMessages) in messagesByDialog { - var messages = currentMessages - var mutated = false + try db.execute( + sql: """ + UPDATE messages SET delivery_status = ? + WHERE account = ? AND from_me = 1 AND delivery_status = ? AND timestamp >= ? + """, + arguments: [DeliveryStatus.waiting.rawValue, myPublicKey, DeliveryStatus.error.rawValue, cutoff] + ) - for index in messages.indices { - let message = messages[index] - guard message.fromPublicKey == myPublicKey else { continue } - guard message.deliveryStatus == .waiting || message.deliveryStatus == .error else { continue } - // Skip messages older than the retry window (stale). - guard message.timestamp >= cutoff else { continue } - - retryable.append(message) - - // Reset .error back to .waiting so UI shows clock icon during retry. - if message.deliveryStatus == .error { - messages[index].deliveryStatus = .waiting - mutated = true - } + return results } - - if mutated { - messagesByDialog[dialogKey] = messages - hasMutations = true - } - } - - if hasMutations { - schedulePersist() - } - - return retryable - } - - /// Delete a single message by ID and persist the change. - func deleteMessage(id: String) { - guard let dialogKey = messageToDialog.removeValue(forKey: id) else { return } - updateMessages(for: dialogKey) { messages in - messages.removeAll { $0.id == id } + return records.map { $0.toChatMessage() } + } catch { + print("[DB] resolveRetryable error: \(error)") + return [] } } - func reset(clearPersisted: Bool = false) { - persistTask?.cancel() - persistTask = nil - for task in typingResetTasks.values { - task.cancel() - } - typingResetTasks.removeAll() - messagesByDialog.removeAll() - typingDialogs.removeAll() - activeDialogs.removeAll() - messageToDialog.removeAll() - allKnownMessageIds.removeAll() - storagePassword = "" + // MARK: - Attachment Update - guard !currentAccount.isEmpty else { return } - let accountToReset = currentAccount - currentAccount = "" - - guard clearPersisted else { return } - let messagesFile = Self.messagesFileName(for: accountToReset) - let knownIdsFile = Self.knownIdsFileName(for: accountToReset) - Task(priority: .utility) { - await ChatPersistenceStore.shared.remove(fileName: messagesFile) - await ChatPersistenceStore.shared.remove(fileName: knownIdsFile) - } - } - - // MARK: - Attachment Update (post-upload) - - /// Updates attachment previews after CDN upload completes. - /// Called from sendMessageWithAttachments after optimistic message was shown. func updateAttachments(messageId: String, attachments: [MessageAttachment]) { - guard let dialogKey = messageToDialog[messageId] else { return } - updateMessages(for: dialogKey) { messages in - guard let index = messages.firstIndex(where: { $0.id == messageId }) else { return } - messages[index].attachments = attachments + guard !currentAccount.isEmpty else { return } + let encoder = JSONEncoder() + guard let data = try? encoder.encode(attachments), + let json = String(data: data, encoding: .utf8) else { return } + + var affectedOpponent: String? + do { + affectedOpponent = try db.writeSync { db in + let record = try MessageRecord.filter( + MessageRecord.Columns.account == currentAccount && + MessageRecord.Columns.messageId == messageId + ).fetchOne(db) + try db.execute( + sql: "UPDATE messages SET attachments = ? WHERE account = ? AND message_id = ?", + arguments: [json, currentAccount, messageId] + ) + guard let record else { return nil as String? } + let fromMe = record.fromMe != 0 + return fromMe ? record.toPublicKey : record.fromPublicKey + } + } catch { + print("[DB] updateAttachments error: \(error)") } + if let opponentKey = affectedOpponent { + refreshCacheNow(for: opponentKey) + } + } + + // MARK: - Unread Count (Android parity: SELECT COUNT(*) WHERE read = 0) + + func countUnread(account: String, opponentKey: String) -> Int { + let dialogKey = DatabaseManager.dialogKey(account: account, opponentKey: opponentKey) + do { + return try db.read { db in + try Int.fetchOne(db, + sql: "SELECT COUNT(*) FROM messages WHERE account = ? AND dialog_key = ? AND from_me = 0 AND is_read = 0", + arguments: [account, dialogKey] + ) ?? 0 + } + } catch { return 0 } + } + + /// Android parity: `hasSentByDialogKey()` — checks ALL messages in DB (not just last N). + /// Returns true if any outgoing message exists for this dialog. + func hasSentMessages(account: String, opponentKey: String) -> Bool { + let dialogKey = DatabaseManager.dialogKey(account: account, opponentKey: opponentKey) + do { + return try db.read { db in + try Bool.fetchOne(db, + sql: """ + SELECT EXISTS( + SELECT 1 FROM messages + WHERE account = ? AND dialog_key = ? AND from_me = 1 + LIMIT 1 + ) + """, + arguments: [account, dialogKey] + ) ?? false + } + } catch { return false } + } + + /// Returns the last message for a dialog, DECRYPTED. + /// Used by DialogRepository.updateDialogFromMessages() to set lastMessage text. + func lastDecryptedMessage(account: String, opponentKey: String) -> ChatMessage? { + let dialogKey = DatabaseManager.dialogKey(account: account, opponentKey: opponentKey) + do { + return try db.read { db in + guard let record = try MessageRecord + .filter( + MessageRecord.Columns.account == account && + MessageRecord.Columns.dialogKey == dialogKey + ) + .order(MessageRecord.Columns.timestamp.desc, MessageRecord.Columns.messageId.desc) + .fetchOne(db) + else { return nil } + return decryptRecord(record) + } + } catch { return nil } } // MARK: - Stress Test (Debug only) - /// Inserts a pre-built message for stress testing. Skips encryption/dedup. func insertStressTestMessage(_ message: ChatMessage, dialogKey: String) { - updateMessages(for: dialogKey) { messages in - messages.append(message) - } - } - - // MARK: - Private - - private func updateMessages(for dialogKey: String, mutate: (inout [ChatMessage]) -> Void) { - var messages = messagesByDialog[dialogKey] ?? [] - let countBefore = messages.count - mutate(&messages) - // Only sort when messages were added/removed; skip for in-place updates (delivery status, read) - if messages.count != countBefore { - messages.sort { - if $0.timestamp != $1.timestamp { - return $0.timestamp < $1.timestamp - } - return $0.id < $1.id - } - // Desktop parity: cap to MESSAGE_MAX_LOADED (40) newest messages per dialog. - // Keeps memory bounded — evicted IDs stay in allKnownMessageIds for dedup. - if messages.count > ProtocolConstants.messageMaxCached { - let evicted = messages.prefix(messages.count - ProtocolConstants.messageMaxCached) - for message in evicted { - messageToDialog.removeValue(forKey: message.id) - } - messages = Array(messages.suffix(ProtocolConstants.messageMaxCached)) - } - } - messagesByDialog[dialogKey] = messages - schedulePersist() - } - - /// Android parity: persist immediately (no debounce) after sending a message. - /// Ensures the outgoing message with .waiting status survives app termination. - /// Android saves to Room DB synchronously in sendMessage(); iOS debounces at 800ms. - func persistNow() { guard !currentAccount.isEmpty else { return } - persistTask?.cancel() - let snapshot = messagesByDialog - let idsSnapshot = allKnownMessageIds - let messagesFile = Self.messagesFileName(for: currentAccount) - let knownIdsFile = Self.knownIdsFileName(for: currentAccount) - let password = storagePassword.isEmpty ? nil : storagePassword - persistTask = Task(priority: .userInitiated) { - await ChatPersistenceStore.shared.save(snapshot, fileName: messagesFile, password: password) - await ChatPersistenceStore.shared.save(idsSnapshot, fileName: knownIdsFile, password: password) + do { + try db.writeSync { db in + var record = MessageRecord.from(message, account: currentAccount) + try record.insert(db, onConflict: .ignore) + } + } catch { + print("[DB] insertStressTest error: \(error)") + } + refreshCacheNow(for: dialogKey) + } + + // MARK: - Reset + + func reset(clearPersisted: Bool = false) { + for task in typingResetTasks.values { task.cancel() } + typingResetTasks.removeAll() + messagesByDialog.removeAll() + typingDialogs.removeAll() + activeDialogs.removeAll() + processedMessageIds.removeAll() + pendingCacheRefresh.removeAll() + cacheRefreshTask?.cancel() + privateKey = "" + + guard !currentAccount.isEmpty else { return } + let account = currentAccount + currentAccount = "" + + guard clearPersisted else { return } + do { + try db.writeSync { db in + try db.execute(sql: "DELETE FROM messages WHERE account = ?", arguments: [account]) + } + } catch { + print("[DB] reset error: \(error)") + } + } + + /// No-op — SQLite writes are immediate. + func persistNow() {} + /// No-op — batch updates handled by debounced cache refresh. + func beginBatchUpdates() {} + /// Flush any pending cache refreshes immediately. + func endBatchUpdates() { + flushPendingCacheRefreshes() + } + + // MARK: - Private: Cache Management + + /// Android parity: debounce cache refresh at 100ms during sync. + /// Prevents N separate @Published updates for N messages in a sync batch. + private func scheduleCacheRefresh(for opponentKey: String) { + pendingCacheRefresh.insert(opponentKey) + cacheRefreshTask?.cancel() + cacheRefreshTask = Task { @MainActor [weak self] in + try? await Task.sleep(for: .milliseconds(100)) + guard let self, !Task.isCancelled else { return } + self.flushPendingCacheRefreshes() + } + } + + /// Immediately refresh all pending dialog caches. + private func flushPendingCacheRefreshes() { + let pending = pendingCacheRefresh + pendingCacheRefresh.removeAll() + cacheRefreshTask?.cancel() + cacheRefreshTask = nil + for opponentKey in pending { + refreshCacheNow(for: opponentKey) + } + } + + /// Refresh cache for a dialog immediately (no debounce). Used for user-initiated actions. + /// Android parity: capped at MAX_CACHE_SIZE (500) to prevent OOM. + private func refreshCacheNow(for opponentKey: String) { + guard messagesByDialog[opponentKey] != nil || activeDialogs.contains(opponentKey) else { return } + let messages = loadMessagesFromDB(dialogKey: opponentKey, limit: nil) + // Cap at maxCacheSize — keep most recent messages + if messages.count > Self.maxCacheSize { + messagesByDialog[opponentKey] = Array(messages.suffix(Self.maxCacheSize)) + } else { + messagesByDialog[opponentKey] = messages + } + } + + private func loadMessagesFromDB(dialogKey: String, limit: Int?) -> [ChatMessage] { + let dbDialogKey = DatabaseManager.dialogKey(account: currentAccount, opponentKey: dialogKey) + do { + let records = try db.read { db in + if let limit { + let descRequest = MessageRecord + .filter( + MessageRecord.Columns.account == currentAccount && + MessageRecord.Columns.dialogKey == dbDialogKey + ) + .order(MessageRecord.Columns.timestamp.desc, MessageRecord.Columns.messageId.desc) + .limit(limit) + let records = try descRequest.fetchAll(db) + return Array(records.reversed()) + } + return try MessageRecord + .filter( + MessageRecord.Columns.account == currentAccount && + MessageRecord.Columns.dialogKey == dbDialogKey + ) + .order(MessageRecord.Columns.timestamp.asc, MessageRecord.Columns.messageId.asc) + .fetchAll(db) + } + return records.map { decryptRecord($0) } + } catch { + print("[DB] loadMessages error: \(error)") + return [] + } + } + + /// Android parity: decrypt `plain_message` (encrypted with private key) on read. + /// Uses `safePlainMessageFallback()` — NEVER shows raw ciphertext to user. + /// Android: `entityToChatMessage()` in `ChatViewModel.kt` lines 1082-1159. + private func decryptRecord(_ record: MessageRecord) -> ChatMessage { + let plainText: String + + if !privateKey.isEmpty { + if let data = try? CryptoManager.shared.decryptWithPassword(record.text, password: privateKey), + let decrypted = String(data: data, encoding: .utf8) { + plainText = decrypted + } else { + // Android parity: safePlainMessageFallback() — return "" if ciphertext, raw if plaintext + plainText = Self.safePlainMessageFallback(record.text) + } + } else { + plainText = Self.safePlainMessageFallback(record.text) + } + + return record.toChatMessage(overrideText: plainText) + } + + // MARK: - Android Parity: safePlainMessageFallback + + /// Android parity: `safePlainMessageFallback()` in `ChatViewModel.kt` lines 1338-1349. + /// NEVER show raw ciphertext in UI. If text looks like encrypted payload, return "". + private static func safePlainMessageFallback(_ raw: String) -> String { + if raw.isEmpty { return "" } + if isProbablyEncryptedPayload(raw) { return "" } + return raw // Looks like real plaintext (legacy unencrypted record) + } + + /// Android parity: `isProbablyEncryptedPayload()` — detects `ivBase64:ctBase64` format. + private static func isProbablyEncryptedPayload(_ value: String) -> Bool { + let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines) + // Chunked format + if trimmed.hasPrefix("CHNK:") { return true } + // ivBase64:ctBase64 format + let parts = trimmed.components(separatedBy: ":") + guard parts.count == 2 else { return false } + // Both parts should look like Base64 (alphanumeric + /+=) + let base64Chars = CharacterSet.alphanumerics.union(CharacterSet(charactersIn: "+/=")) + return parts.allSatisfy { part in + !part.isEmpty && part.unicodeScalars.allSatisfy { base64Chars.contains($0) } } } private func normalizeTimestamp(_ raw: Int64) -> Int64 { - // Some peers still send seconds, while Android path uses milliseconds. raw < 1_000_000_000_000 ? raw * 1000 : raw } +} - private func schedulePersist() { - guard !currentAccount.isEmpty else { return } - PerformanceLogger.shared.track("message.schedulePersist") +// MARK: - LRU Set (Android parity: LinkedHashSet with max size) - let snapshot = messagesByDialog - let idsSnapshot = allKnownMessageIds - let messagesFile = Self.messagesFileName(for: currentAccount) - let knownIdsFile = Self.knownIdsFileName(for: currentAccount) - let storagePassword = self.storagePassword - let password = storagePassword.isEmpty ? nil : storagePassword - // PERF: increased debounce to reduce JSON serialization frequency. - // Android batches DB writes via Room transactions; iOS must debounce manually. - // 800ms normal = ~1.25 saves/sec; 3000ms sync = ~0.33 saves/sec. - let isSyncing = SessionManager.shared.syncBatchInProgress - let debounceMs = isSyncing ? 3000 : 800 - persistTask?.cancel() - persistTask = Task(priority: .utility) { - try? await Task.sleep(for: .milliseconds(debounceMs)) - guard !Task.isCancelled else { return } - await ChatPersistenceStore.shared.save(snapshot, fileName: messagesFile, password: password) - await ChatPersistenceStore.shared.save(idsSnapshot, fileName: knownIdsFile, password: password) +/// Thread-safe LRU set with bounded size. Evicts oldest entry on overflow. +/// Android parity: `processedMessageIds` in `MessageRepository.kt`. +struct LRUSet { + private var order: [Element] = [] + private var set: Set = [] + private let maxSize: Int + + init(maxSize: Int) { + self.maxSize = maxSize + } + + mutating func contains(_ element: Element) -> Bool { + set.contains(element) + } + + mutating func insert(_ element: Element) { + guard !set.contains(element) else { return } + if order.count >= maxSize { + let evicted = order.removeFirst() + set.remove(evicted) } + order.append(element) + set.insert(element) } - private static func messagesFileName(for accountPublicKey: String) -> String { - ChatPersistenceStore.accountScopedFileName(prefix: "messages", accountPublicKey: accountPublicKey) - } - - private static func knownIdsFileName(for accountPublicKey: String) -> String { - ChatPersistenceStore.accountScopedFileName(prefix: "known_ids", accountPublicKey: accountPublicKey) + mutating func removeAll() { + order.removeAll() + set.removeAll() } } diff --git a/Rosetta/Core/Network/Protocol/ProtocolManager.swift b/Rosetta/Core/Network/Protocol/ProtocolManager.swift index 9958d95..62b4d84 100644 --- a/Rosetta/Core/Network/Protocol/ProtocolManager.swift +++ b/Rosetta/Core/Network/Protocol/ProtocolManager.swift @@ -57,6 +57,23 @@ final class ProtocolManager: @unchecked Sendable { private var handshakeComplete = false private var heartbeatTask: Task? private var handshakeTimeoutTask: Task? + private var pingTimeoutTask: Task? + /// Guards against overlapping ping-first verifications on foreground. + private var pingVerificationInProgress = false + + /// Android parity: sync batch flag set SYNCHRONOUSLY on receive queue. + /// Prevents race where MainActor Task for BATCH_START runs after message Task. + /// Written on URLSession delegate queue, read on MainActor — protected by lock. + private let syncBatchLock = NSLock() + private var _syncBatchActive = false + + /// Thread-safe read for SessionManager to check sync state without MainActor race. + var isSyncBatchActive: Bool { + syncBatchLock.lock() + let val = _syncBatchActive + syncBatchLock.unlock() + return val + } private let searchHandlersLock = NSLock() private let resultHandlersLock = NSLock() private let packetQueueLock = NSLock() @@ -94,6 +111,9 @@ final class ProtocolManager: @unchecked Sendable { Self.logger.info("Disconnecting") heartbeatTask?.cancel() handshakeTimeoutTask?.cancel() + pingTimeoutTask?.cancel() + pingTimeoutTask = nil + pingVerificationInProgress = false handshakeComplete = false client.disconnect() connectionState = .disconnected @@ -109,12 +129,13 @@ final class ProtocolManager: @unchecked Sendable { func reconnectIfNeeded() { guard savedPublicKey != nil, savedPrivateHash != nil else { return } - // Android parity: skip if already in any active state. + // Android parity (Protocol.kt:651-658): skip if already in any active state. switch connectionState { case .authenticated, .handshaking, .deviceVerificationRequired, .connected: return case .connecting: - if client.isConnected { return } + // Android parity: `(CONNECTING && isConnecting)` — skip if connect() is in progress. + if client.isConnecting { return } case .disconnected: break } @@ -127,13 +148,63 @@ final class ProtocolManager: @unchecked Sendable { client.forceReconnect() } + /// Ping-first zombie socket detection for foreground resume. + /// iOS suspends the process in background — server RSTs TCP, but `didCloseWith` + /// delegate never fires. `connectionState` stays `.authenticated` (stale). + /// This method sends a WebSocket ping: pong → alive, no pong → force reconnect. + func verifyConnectionOrReconnect() { + guard savedPublicKey != nil, savedPrivateHash != nil else { return } + guard connectionState == .authenticated || connectionState == .connected else { return } + guard !pingVerificationInProgress else { return } + + pingVerificationInProgress = true + Self.logger.info("🏓 Verifying connection with ping after foreground...") + + client.sendPing { [weak self] error in + guard let self, self.pingVerificationInProgress else { return } + self.pingVerificationInProgress = false + self.pingTimeoutTask?.cancel() + self.pingTimeoutTask = nil + + if let error { + Self.logger.warning("🏓 Ping failed — zombie socket: \(error.localizedDescription)") + self.handlePingFailure() + } else { + Self.logger.info("🏓 Pong received — connection alive") + } + } + + // Safety timeout: if sendPing never calls back (completely dead socket), force reconnect. + pingTimeoutTask?.cancel() + pingTimeoutTask = Task { [weak self] in + try? await Task.sleep(for: .seconds(3)) + guard let self, !Task.isCancelled, self.pingVerificationInProgress else { return } + self.pingVerificationInProgress = false + Self.logger.warning("🏓 Ping timeout (3s) — zombie socket, forcing reconnect") + self.handlePingFailure() + } + } + + private func handlePingFailure() { + pingTimeoutTask?.cancel() + pingTimeoutTask = nil + handshakeComplete = false + heartbeatTask?.cancel() + Task { @MainActor in + self.connectionState = .connecting + } + client.forceReconnect() + } + // MARK: - Sending func sendPacket(_ packet: any Packet) { PerformanceLogger.shared.track("protocol.sendPacket") let id = String(type(of: packet).packetId, radix: 16) - if (!handshakeComplete && !(packet is PacketHandshake)) || !client.isConnected { - Self.logger.info("⏳ Queueing packet 0x\(id) — connected=\(self.client.isConnected), handshake=\(self.handshakeComplete)") + // Android parity (Protocol.kt:436-448): triple check — handshakeComplete + socket alive + authenticated. + let isAuth = connectionState == .authenticated + if (!handshakeComplete && !(packet is PacketHandshake)) || !client.isConnected || !isAuth { + Self.logger.info("⏳ Queueing packet 0x\(id) — connected=\(self.client.isConnected), handshake=\(self.handshakeComplete), auth=\(isAuth)") enqueuePacket(packet) return } @@ -200,6 +271,9 @@ final class ProtocolManager: @unchecked Sendable { } heartbeatTask?.cancel() handshakeComplete = false + pingVerificationInProgress = false + pingTimeoutTask?.cancel() + pingTimeoutTask = nil Task { @MainActor in self.connectionState = .disconnected @@ -348,6 +422,18 @@ final class ProtocolManager: @unchecked Sendable { } case 0x19: if let p = packet as? PacketSync { + // Android parity: set sync flag SYNCHRONOUSLY on receive queue + // BEFORE dispatching to MainActor callback. This prevents the race + // where a 0x06 message Task runs on MainActor before BATCH_START Task. + if p.status == .batchStart { + syncBatchLock.lock() + _syncBatchActive = true + syncBatchLock.unlock() + } else if p.status == .notNeeded { + syncBatchLock.lock() + _syncBatchActive = false + syncBatchLock.unlock() + } onSyncReceived?(p) } default: @@ -412,8 +498,8 @@ final class ProtocolManager: @unchecked Sendable { self.connectionState = .deviceVerificationRequired } - // Keep packet queue: messages will be flushed when the other device - // approves this login and the server re-sends handshake with .completed + // Android parity (Protocol.kt:163): clear packet queue on device verification. + clearPacketQueue() startHeartbeat(interval: packet.heartbeatInterval) } } diff --git a/Rosetta/Core/Network/Protocol/WebSocketClient.swift b/Rosetta/Core/Network/Protocol/WebSocketClient.swift index 5e2ee9c..a98a539 100644 --- a/Rosetta/Core/Network/Protocol/WebSocketClient.swift +++ b/Rosetta/Core/Network/Protocol/WebSocketClient.swift @@ -14,6 +14,9 @@ final class WebSocketClient: NSObject, @unchecked Sendable, URLSessionWebSocketD private var reconnectTask: Task? private var hasNotifiedConnected = false private(set) var isConnected = false + /// Android parity: prevents concurrent connect() calls and suppresses handleDisconnect + /// while a connection attempt is already in progress (Protocol.kt: `isConnecting` flag). + private(set) var isConnecting = false private var disconnectHandledForCurrentSocket = false /// Android parity: exponential backoff counter, reset on AUTHENTICATED (not on open). private var reconnectAttempts = 0 @@ -59,7 +62,14 @@ final class WebSocketClient: NSObject, @unchecked Sendable, URLSessionWebSocketD // MARK: - Connection func connect() { + // Android parity: prevent duplicate connect() calls (Protocol.kt lines 237-256). guard webSocketTask == nil else { return } + guard !isConnecting else { + Self.logger.info("Already connecting, skipping duplicate connect()") + return + } + + isConnecting = true isManuallyClosed = false hasNotifiedConnected = false isConnected = false @@ -77,6 +87,7 @@ final class WebSocketClient: NSObject, @unchecked Sendable, URLSessionWebSocketD func disconnect() { Self.logger.info("Manual disconnect") isManuallyClosed = true + isConnecting = false reconnectTask?.cancel() reconnectTask = nil webSocketTask?.cancel(with: .goingAway, reason: nil) @@ -94,6 +105,7 @@ final class WebSocketClient: NSObject, @unchecked Sendable, URLSessionWebSocketD webSocketTask?.cancel(with: .goingAway, reason: nil) webSocketTask = nil isConnected = false + isConnecting = false disconnectHandledForCurrentSocket = false // Android parity: reset backoff so next failure starts from 1s, not stale 8s/16s. reconnectAttempts = 0 @@ -151,6 +163,8 @@ final class WebSocketClient: NSObject, @unchecked Sendable, URLSessionWebSocketD nonisolated func urlSession(_ session: URLSession, webSocketTask: URLSessionWebSocketTask, didOpenWithProtocol protocol: String?) { Self.logger.info("WebSocket didOpen") guard !isManuallyClosed else { return } + // Android parity: reset isConnecting on successful open (Protocol.kt onOpen). + isConnecting = false hasNotifiedConnected = true isConnected = true disconnectHandledForCurrentSocket = false @@ -163,6 +177,7 @@ final class WebSocketClient: NSObject, @unchecked Sendable, URLSessionWebSocketD nonisolated func urlSession(_ session: URLSession, webSocketTask: URLSessionWebSocketTask, didCloseWith closeCode: URLSessionWebSocketTask.CloseCode, reason: Data?) { Self.logger.info("WebSocket didClose: \(closeCode.rawValue)") + isConnecting = false isConnected = false handleDisconnect(error: nil) } @@ -197,12 +212,19 @@ final class WebSocketClient: NSObject, @unchecked Sendable, URLSessionWebSocketD // MARK: - Reconnection private func handleDisconnect(error: Error?) { + // Android parity (Protocol.kt:562-566): if a new connection is already + // in progress, ignore stale disconnect from previous socket. + if isConnecting { + Self.logger.info("Disconnect ignored: connection already in progress") + return + } if disconnectHandledForCurrentSocket { return } disconnectHandledForCurrentSocket = true webSocketTask = nil isConnected = false + isConnecting = false onDisconnected?(error) guard !isManuallyClosed else { return } diff --git a/Rosetta/Core/Services/SessionManager.swift b/Rosetta/Core/Services/SessionManager.swift index 5e886f6..5d78619 100644 --- a/Rosetta/Core/Services/SessionManager.swift +++ b/Rosetta/Core/Services/SessionManager.swift @@ -3,6 +3,7 @@ import Observation import os import UIKit import CommonCrypto +import UserNotifications /// Bridges AccountManager, CryptoManager, and ProtocolManager into a unified session lifecycle. @Observable @@ -31,6 +32,10 @@ final class SessionManager { /// Android parity: tracks the latest incoming message timestamp per dialog /// for which a read receipt was already sent. Prevents redundant sends. private var lastReadReceiptTimestamp: [String: Int64] = [:] + /// Cross-device reads received during sync — re-applied at BATCH_END. + /// PacketRead can arrive BEFORE the sync messages, so markIncomingAsRead + /// updates 0 rows. Re-applying after sync ensures the read state sticks. + private var pendingSyncReads: Set = [] private var requestedUserInfoKeys: Set = [] private var onlineSubscribedKeys: Set = [] private var pendingOutgoingRetryTasks: [String: Task] = [:] @@ -58,6 +63,23 @@ final class SessionManager { setupForegroundObserver() } + /// Re-apply cross-device reads that were received during sync. + /// Fixes race: PacketRead arrives before sync messages → markIncomingAsRead + /// updates 0 rows → messages stay unread. After sync delivers the messages, + /// this re-marks them as read. + private func reapplyPendingSyncReads() { + guard !pendingSyncReads.isEmpty else { return } + let keys = pendingSyncReads + pendingSyncReads.removeAll() + let myKey = currentPublicKey + for opponentKey in keys { + MessageRepository.shared.markIncomingAsRead( + opponentKey: opponentKey, myPublicKey: myKey + ) + DialogRepository.shared.markAsRead(opponentKey: opponentKey) + } + } + /// Android parity (ON_RESUME): re-mark active dialogs as read and send read receipts. /// Called on foreground resume. Android has no idle detection — just re-marks on resume. func markActiveDialogsAsRead() { @@ -83,6 +105,8 @@ final class SessionManager { // Decrypt private key let privateKeyHex = try await accountManager.decryptPrivateKey(password: password) self.privateKeyHex = privateKeyHex + // Android parity: provide private key to caches for encryption at rest + AttachmentCache.shared.privateKey = privateKeyHex Self.logger.info("Private key decrypted") guard let account = accountManager.currentAccount else { @@ -93,6 +117,18 @@ final class SessionManager { displayName = account.displayName ?? "" username = account.username ?? "" + // Open SQLite database for this account (must happen before repository bootstrap). + try DatabaseManager.shared.bootstrap(accountPublicKey: account.publicKey) + + // Migrate legacy JSON → SQLite on first launch (before repositories read from DB). + let migrated = await DatabaseMigrationFromJSON.migrateIfNeeded( + accountPublicKey: account.publicKey, + storagePassword: privateKeyHex + ) + if migrated > 0 { + Self.logger.info("Migrated \(migrated) messages from JSON to SQLite") + } + // Warm local state immediately, then let network sync reconcile updates. await DialogRepository.shared.bootstrap( accountPublicKey: account.publicKey, @@ -169,16 +205,14 @@ final class SessionManager { let isConnected = connState == .authenticated let offlineAsSend = !isConnected - // Optimistic UI update - DialogRepository.shared.updateFromMessage( - packet, myPublicKey: currentPublicKey, decryptedText: text, fromSync: offlineAsSend - ) + // Android parity: insert message FIRST, then recalculate dialog from DB. MessageRepository.shared.upsertFromMessagePacket( packet, myPublicKey: currentPublicKey, decryptedText: text, fromSync: offlineAsSend ) + DialogRepository.shared.updateDialogFromMessages(opponentKey: packet.toPublicKey) // Desktop parity: mark as ERROR when offline (fromSync sets .delivered, override to .error) if offlineAsSend { @@ -302,14 +336,12 @@ final class SessionManager { // Optimistic UI let isConnected = ProtocolManager.shared.connectionState == .authenticated let offlineAsSend = !isConnected - DialogRepository.shared.updateFromMessage( - packet, myPublicKey: currentPublicKey, decryptedText: " ", fromSync: offlineAsSend - ) MessageRepository.shared.upsertFromMessagePacket( packet, myPublicKey: currentPublicKey, decryptedText: " ", attachmentPassword: "rawkey:" + encrypted.plainKeyAndNonce.hexString, fromSync: offlineAsSend ) + DialogRepository.shared.updateDialogFromMessages(opponentKey: packet.toPublicKey) if offlineAsSend { MessageRepository.shared.updateDeliveryStatus(messageId: messageId, status: .error) @@ -512,13 +544,11 @@ final class SessionManager { let offlineAsSend = !isConnected let displayText = messageText - DialogRepository.shared.updateFromMessage( - optimisticPacket, myPublicKey: currentPublicKey, decryptedText: displayText, fromSync: offlineAsSend - ) MessageRepository.shared.upsertFromMessagePacket( optimisticPacket, myPublicKey: currentPublicKey, decryptedText: displayText, attachmentPassword: "rawkey:" + encrypted.plainKeyAndNonce.hexString, fromSync: offlineAsSend ) + DialogRepository.shared.updateDialogFromMessages(opponentKey: toPublicKey) MessageRepository.shared.persistNow() if offlineAsSend { @@ -611,7 +641,8 @@ final class SessionManager { toPublicKey: String, opponentTitle: String = "", opponentUsername: String = "", - forwardedImages: [String: Data] = [:] // [originalAttachmentId: jpegData] + forwardedImages: [String: Data] = [:], // [originalAttachmentId: jpegData] + forwardedFiles: [String: (data: Data, fileName: String)] = [:] // [originalAttachmentId: (fileData, fileName)] ) async throws { guard let privKey = privateKeyHex, let hash = privateKeyHash else { Self.logger.error("📤 Cannot send reply — missing keys") @@ -621,8 +652,8 @@ final class SessionManager { let messageId = UUID().uuidString.replacingOccurrences(of: "-", with: "").lowercased() let timestamp = Int64(Date().timeIntervalSince1970 * 1000) - // Encrypt message text (use single space if empty — desktop parity) - let messageText = text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ? " " : text + // Forward messages have empty text — only the MESSAGES attachment carries content. + let messageText = text.trimmingCharacters(in: .whitespacesAndNewlines) let encrypted = try MessageCrypto.encryptOutgoing( plaintext: messageText, recipientPublicKeyHex: toPublicKey @@ -689,6 +720,49 @@ final class SessionManager { } } + // ── Re-upload forwarded files to CDN (Desktop parity: prepareAttachmentsToSend) ── + if !forwardedFiles.isEmpty && toPublicKey != currentPublicKey { + var fwdIndex = attachmentIdMap.count // Continue numbering from images + for (originalId, fileInfo) in forwardedFiles { + let newAttId = "fwd_\(timestamp)_\(fwdIndex)" + fwdIndex += 1 + + let mimeType = mimeTypeForFileName(fileInfo.fileName) + let dataURI = "data:\(mimeType);base64,\(fileInfo.data.base64EncodedString())" + let encryptedBlob = try CryptoManager.shared.encryptWithPasswordDesktopCompat( + Data(dataURI.utf8), + password: replyPassword + ) + + #if DEBUG + Self.logger.debug("📤 Forward file re-upload: \(originalId) → \(newAttId) (\(fileInfo.data.count) bytes, \(fileInfo.fileName))") + #endif + + let tag = try await TransportManager.shared.uploadFile( + id: newAttId, + content: Data(encryptedBlob.utf8) + ) + + // Preserve fileSize::fileName from original preview + let originalPreview = replyMessages + .flatMap { $0.attachments } + .first(where: { $0.id == originalId })?.preview ?? "" + let fileMeta: String + if let range = originalPreview.range(of: "::") { + fileMeta = String(originalPreview[range.upperBound...]) + } else { + fileMeta = "\(fileInfo.data.count)::\(fileInfo.fileName)" + } + + let newPreview = "\(tag)::\(fileMeta)" + attachmentIdMap[originalId] = (newAttId, newPreview) + + #if DEBUG + Self.logger.debug("📤 Forward file re-upload OK: \(newAttId) tag=\(tag) preview=\(newPreview)") + #endif + } + } + // ── Update reply messages with new attachment IDs/previews ── let finalReplyMessages: [ReplyMessageData] if attachmentIdMap.isEmpty { @@ -796,10 +870,7 @@ final class SessionManager { // Optimistic UI update — use localPacket (decrypted blob) for storage let isConnected = ProtocolManager.shared.connectionState == .authenticated let offlineAsSend = !isConnected - let displayText = messageText == " " ? "" : messageText - DialogRepository.shared.updateFromMessage( - localPacket, myPublicKey: currentPublicKey, decryptedText: displayText, fromSync: offlineAsSend - ) + let displayText = messageText MessageRepository.shared.upsertFromMessagePacket( localPacket, myPublicKey: currentPublicKey, @@ -807,6 +878,7 @@ final class SessionManager { attachmentPassword: "rawkey:" + encrypted.plainKeyAndNonce.hexString, fromSync: offlineAsSend ) + DialogRepository.shared.updateDialogFromMessages(opponentKey: localPacket.toPublicKey) if offlineAsSend { MessageRepository.shared.updateDeliveryStatus(messageId: messageId, status: .error) @@ -922,6 +994,9 @@ final class SessionManager { username = "" DialogRepository.shared.reset() MessageRepository.shared.reset() + DatabaseManager.shared.close() + CryptoManager.shared.clearCaches() + AttachmentCache.shared.privateKey = nil RecentSearchesRepository.shared.clearSession() DraftManager.shared.reset() } @@ -942,16 +1017,6 @@ final class SessionManager { Task { @MainActor in let opponentKey = MessageRepository.shared.dialogKey(forMessageId: packet.messageId) ?? packet.toPublicKey - // Always update dialog delivery status — downgrade guards in - // DialogRepository.updateDeliveryStatus already prevent - // .delivered → .waiting or .read → .delivered regressions. - // The old isLatestMessage guard caused dialog to stay stuck - // at .waiting when delivery ACKs arrived out of order. - DialogRepository.shared.updateDeliveryStatus( - messageId: packet.messageId, - opponentKey: opponentKey, - status: .delivered - ) // Desktop parity: update both status AND timestamp on delivery ACK. let deliveryTimestamp = Int64(Date().timeIntervalSince1970 * 1000) MessageRepository.shared.updateDeliveryStatus( @@ -959,6 +1024,8 @@ final class SessionManager { status: .delivered, newTimestamp: deliveryTimestamp ) + // Android parity 1:1: full dialog recalculation after delivery status change. + DialogRepository.shared.updateDialogFromMessages(opponentKey: opponentKey) self?.resolveOutgoingRetry(messageId: packet.messageId) // Desktop parity (useDialogFiber.ts): update sync cursor on delivery ACK. if let self, !self.syncBatchInProgress { @@ -992,6 +1059,12 @@ final class SessionManager { opponentKey: opponentKey, myPublicKey: ownKey ) + // Race fix: if sync is in progress, the messages may not be + // in DB yet (PacketRead arrives before sync messages). + // Store for re-application at BATCH_END. + if self.syncBatchInProgress { + self.pendingSyncReads.insert(opponentKey) + } return } @@ -1092,11 +1165,12 @@ final class SessionManager { // Desktop parity: request message synchronization after authentication. self.requestSynchronize() - self.retryWaitingOutgoingMessagesAfterReconnect() // Safety net: reconcile dialog delivery indicators and unread counts // with actual message statuses, fixing any desync from stale retry timers. DialogRepository.shared.reconcileDeliveryStatuses() DialogRepository.shared.reconcileUnreadCounts() + // NOTE: retryWaitingOutgoingMessages moved to NOT_NEEDED (Android parity: + // finishSyncCycle() retries AFTER sync completes, not during). // Clear dedup sets on reconnect so subscriptions can be re-established lazily. self.requestedUserInfoKeys.removeAll() @@ -1127,18 +1201,23 @@ final class SessionManager { await self.waitForInboundQueueToDrain() let serverCursor = packet.timestamp self.saveLastSyncTimestamp(serverCursor) + // Re-apply cross-device reads that arrived during sync. + // PacketRead can arrive BEFORE sync messages — markIncomingAsRead + // updates 0 rows because the message isn't in DB yet. + self.reapplyPendingSyncReads() // Android parity: reconcile unread counts after each batch. - // Sync messages may have been read on another device — PacketRead - // arrives during sync and marks them read, but incremental counters - // can drift. Reconcile from actual isRead state. DialogRepository.shared.reconcileUnreadCounts() Self.logger.debug("SYNC BATCH_END cursor=\(serverCursor)") self.requestSynchronize(cursor: serverCursor) case .notNeeded: + // Android parity: finishSyncCycle() — clear flag, retry, refresh. self.syncBatchInProgress = false + // Re-apply cross-device reads one final time. + self.reapplyPendingSyncReads() DialogRepository.shared.reconcileDeliveryStatuses() DialogRepository.shared.reconcileUnreadCounts() + self.retryWaitingOutgoingMessagesAfterReconnect() Self.logger.debug("SYNC NOT_NEEDED") // Refresh user info now that sync is done. Task { @MainActor [weak self] in @@ -1199,14 +1278,11 @@ final class SessionManager { fakePacket.messageId = messageId fakePacket.timestamp = timestamp - DialogRepository.shared.updateFromMessage( - fakePacket, myPublicKey: myKey, - decryptedText: text, fromSync: false, isNewMessage: true - ) MessageRepository.shared.upsertFromMessagePacket( fakePacket, myPublicKey: myKey, decryptedText: text, fromSync: false ) + DialogRepository.shared.updateDialogFromMessages(opponentKey: safeKey) } private func enqueueIncomingMessage(_ packet: PacketMessage) { @@ -1221,6 +1297,12 @@ final class SessionManager { } private func processIncomingMessagesQueue() async { + // PERF: During sync, batch all message writes → single @Published event. + // Real-time messages publish immediately for instant UI feedback. + // Android parity: check thread-safe flag too (BATCH_START MainActor Task may lag). + let batching = syncBatchInProgress || ProtocolManager.shared.isSyncBatchActive + if batching { MessageRepository.shared.beginBatchUpdates() } + while !pendingIncomingMessages.isEmpty { let batch = pendingIncomingMessages pendingIncomingMessages.removeAll(keepingCapacity: true) @@ -1229,6 +1311,8 @@ final class SessionManager { await Task.yield() } } + + if batching { MessageRepository.shared.endBatchUpdates() } isProcessingIncomingMessages = false signalQueueDrained() } @@ -1248,83 +1332,36 @@ final class SessionManager { let opponentKey = fromMe ? packet.toPublicKey : packet.fromPublicKey let wasKnownBefore = MessageRepository.shared.hasMessage(packet.messageId) - let decryptResult = Self.decryptIncomingMessage( - packet: packet, - myPublicKey: myKey, - privateKeyHex: currentPrivateKeyHex - ) + // ── PERF: Offload all crypto to background thread ── + // decryptIncomingMessage (ECDH + XChaCha20) and attachment blob + // decryption (PBKDF2 × N candidates) are CPU-heavy pure computation. + // Running them on MainActor blocked the UI for seconds during sync. + let cryptoResult = await Task.detached(priority: .userInitiated) { + Self.decryptAndProcessAttachments( + packet: packet, + myPublicKey: myKey, + privateKeyHex: currentPrivateKeyHex + ) + }.value - guard let result = decryptResult else { + guard let cryptoResult else { Self.logger.warning("processIncoming: decryptIncomingMessage returned nil for msgId=\(packet.messageId.prefix(8))…") return } - let text = result.text - - // Desktop parity: decrypt MESSAGES-type attachment blobs inline. - var processedPacket = packet - // Store attachment password for on-demand image/file download in UI. - var resolvedAttachmentPassword: String? - - // Debug: log attachment info for incoming messages - if !processedPacket.attachments.isEmpty { - Self.logger.info("📎 Message \(packet.messageId.prefix(8))… has \(processedPacket.attachments.count) attachment(s), rawKeyData=\(result.rawKeyData != nil ? "\(result.rawKeyData!.count) bytes" : "nil")") - for (i, att) in processedPacket.attachments.enumerated() { - Self.logger.info(" 📎[\(i)] type=\(att.type.rawValue) id=\(att.id) preview=\(att.preview.prefix(60))…") - } - } - - if let keyData = result.rawKeyData { - // Store raw key data as hex for on-demand password derivation at download time. - // Android and Desktop/iOS use different UTF-8 decoders for password derivation, - // so we need both variants. `attachmentPasswordCandidates()` derives them. - resolvedAttachmentPassword = "rawkey:" + keyData.hexString - let passwordCandidates = MessageCrypto.attachmentPasswordCandidates( - from: resolvedAttachmentPassword! - ) - Self.logger.debug("attachPwd: rawKeyData(\(keyData.count)bytes) candidates=\(passwordCandidates.count)") - - for i in processedPacket.attachments.indices where processedPacket.attachments[i].type == .messages { - let blob = processedPacket.attachments[i].blob - guard !blob.isEmpty else { continue } - var decrypted = false - // Try with requireCompression first to avoid wrong-key garbage - for password in passwordCandidates { - if let data = try? CryptoManager.shared.decryptWithPassword( - blob, password: password, requireCompression: true - ), - let decryptedString = String(data: data, encoding: .utf8) { - processedPacket.attachments[i].blob = decryptedString - decrypted = true - break - } - } - // Fallback: try without requireCompression (legacy uncompressed) - if !decrypted { - for password in passwordCandidates { - if let data = try? CryptoManager.shared.decryptWithPassword( - blob, password: password - ), - let decryptedString = String(data: data, encoding: .utf8) { - processedPacket.attachments[i].blob = decryptedString - break - } - } - } - } - - // Android parity: avatar attachments are NOT auto-downloaded here. - // They are downloaded on-demand when MessageAvatarView renders in chat. - } + let text = cryptoResult.text + let processedPacket = cryptoResult.processedPacket + let resolvedAttachmentPassword = cryptoResult.attachmentPassword // For outgoing messages received from the server (sent by another device // on the same account), treat as sync-equivalent so status = .delivered. // Without this, real-time fromMe messages get .waiting → timeout → .error. - let effectiveFromSync = syncBatchInProgress || fromMe + // Android parity: also check isSyncBatchActive (set synchronously on receive + // queue) to handle race where BATCH_START MainActor Task hasn't run yet. + let effectiveFromSync = syncBatchInProgress || ProtocolManager.shared.isSyncBatchActive || fromMe - DialogRepository.shared.updateFromMessage( - processedPacket, myPublicKey: myKey, decryptedText: text, - fromSync: effectiveFromSync, isNewMessage: !wasKnownBefore - ) + // Android parity: insert message to DB FIRST, then update dialog. + // Dialog's unreadCount uses COUNT(*) WHERE read=0, so the message + // must exist in DB before the count query runs. MessageRepository.shared.upsertFromMessagePacket( processedPacket, myPublicKey: myKey, @@ -1332,11 +1369,14 @@ final class SessionManager { attachmentPassword: resolvedAttachmentPassword, fromSync: effectiveFromSync ) + // Android parity 1:1: dialogDao.updateDialogFromMessages(account, opponentKey) + // Full recalculation of lastMessage, unread, iHaveSent, delivery from DB. + DialogRepository.shared.updateDialogFromMessages(opponentKey: opponentKey) // Desktop parity: if we received a message from the opponent (not our own), // they are clearly online — update their online status immediately. // This supplements PacketOnlineState (0x05) which may arrive with delay. - if !fromMe && !syncBatchInProgress { + if !fromMe && !effectiveFromSync { DialogRepository.shared.updateOnlineState(publicKey: opponentKey, isOnline: true) } @@ -1374,7 +1414,8 @@ final class SessionManager { // Desktop parity (useUpdateSyncTime.ts): no-op during SYNCHRONIZATION. // Sync cursor is updated once at BATCH_END with the server's timestamp. - if !syncBatchInProgress { + // Android parity: check both MainActor flag and thread-safe flag for race safety. + if !effectiveFromSync { saveLastSyncTimestamp(packet.timestamp) } } @@ -1405,6 +1446,7 @@ final class SessionManager { } } + // Sync cursor key (legacy UserDefaults, kept for migration) private var syncCursorKey: String { "rosetta_last_sync_\(currentPublicKey)" } @@ -1460,31 +1502,100 @@ final class SessionManager { ProtocolManager.shared.sendPacket(packet) } + /// Android parity: sync cursor stored in SQLite (not UserDefaults). + /// Migrates from UserDefaults on first call. private func loadLastSyncTimestamp() -> Int64 { guard !currentPublicKey.isEmpty else { return 0 } - let stored = Int64(UserDefaults.standard.integer(forKey: syncCursorKey)) - // Android parity (normalizeSyncTimestamp): all platforms store milliseconds. - // If stored value is in seconds (< 1 trillion), convert to milliseconds. - // Values >= 1 trillion are already in milliseconds — return as-is. - if stored > 0, stored < 1_000_000_000_000 { - let corrected = stored * 1000 - UserDefaults.standard.set(Int(corrected), forKey: syncCursorKey) - return corrected + + // Try SQLite first + let sqliteValue = DatabaseManager.shared.loadSyncCursor(account: currentPublicKey) + if sqliteValue > 0 { return sqliteValue } + + // Migrate from UserDefaults (one-time) + let legacyValue = Int64(UserDefaults.standard.integer(forKey: syncCursorKey)) + if legacyValue > 0 { + let normalized = legacyValue < 1_000_000_000_000 ? legacyValue * 1000 : legacyValue + DatabaseManager.shared.saveSyncCursor(account: currentPublicKey, timestamp: normalized) + UserDefaults.standard.removeObject(forKey: syncCursorKey) + return normalized } - return stored + return 0 } + /// Android parity: sync cursor stored in SQLite. Monotonic only. private func saveLastSyncTimestamp(_ raw: Int64) { - guard !currentPublicKey.isEmpty else { return } - // Store server cursor as-is (milliseconds). No normalization. - guard raw > 0 else { return } - let existing = loadLastSyncTimestamp() - guard raw > existing else { return } - UserDefaults.standard.set(Int(raw), forKey: syncCursorKey) + guard !currentPublicKey.isEmpty, raw > 0 else { return } + DatabaseManager.shared.saveSyncCursor(account: currentPublicKey, timestamp: raw) + } + + // MARK: - Background Crypto (off MainActor) + + /// Result of background crypto operations for an incoming message. + private struct IncomingCryptoResult: Sendable { + let text: String + let processedPacket: PacketMessage + let attachmentPassword: String? + } + + /// Decrypts message text + attachment blobs on a background thread. + /// Pure computation — no UI or repository access. + nonisolated private static func decryptAndProcessAttachments( + packet: PacketMessage, + myPublicKey: String, + privateKeyHex: String? + ) -> IncomingCryptoResult? { + guard let result = decryptIncomingMessage( + packet: packet, + myPublicKey: myPublicKey, + privateKeyHex: privateKeyHex + ) else { return nil } + + var processedPacket = packet + var resolvedAttachmentPassword: String? + + if let keyData = result.rawKeyData { + resolvedAttachmentPassword = "rawkey:" + keyData.hexString + let passwordCandidates = MessageCrypto.attachmentPasswordCandidates( + from: resolvedAttachmentPassword! + ) + + for i in processedPacket.attachments.indices where processedPacket.attachments[i].type == .messages { + let blob = processedPacket.attachments[i].blob + guard !blob.isEmpty else { continue } + var decrypted = false + for password in passwordCandidates { + if let data = try? CryptoManager.shared.decryptWithPassword( + blob, password: password, requireCompression: true + ), + let decryptedString = String(data: data, encoding: .utf8) { + processedPacket.attachments[i].blob = decryptedString + decrypted = true + break + } + } + if !decrypted { + for password in passwordCandidates { + if let data = try? CryptoManager.shared.decryptWithPassword( + blob, password: password + ), + let decryptedString = String(data: data, encoding: .utf8) { + processedPacket.attachments[i].blob = decryptedString + break + } + } + } + } + } + + return IncomingCryptoResult( + text: result.text, + processedPacket: processedPacket, + attachmentPassword: resolvedAttachmentPassword + ) } /// Returns (decryptedText, rawKeyData) where rawKeyData can be used for attachment blob decryption. - private static func decryptIncomingMessage( + nonisolated private static func decryptIncomingMessage( packet: PacketMessage, myPublicKey: String, privateKeyHex: String? @@ -1742,9 +1853,9 @@ final class SessionManager { else { return } let now = Int64(Date().timeIntervalSince1970 * 1000) - // Retry both .waiting and .error messages within a 5-minute window. - // Messages older than 5 minutes are stale — user can retry manually. - let maxRetryAgeMs: Int64 = 5 * 60 * 1000 + // Android parity: retry messages within 80-second window (MESSAGE_MAX_TIME_TO_DELIVERED_MS). + // Messages older than 80s are marked as error — user can retry manually. + let maxRetryAgeMs: Int64 = 80_000 let retryable = MessageRepository.shared.resolveRetryableOutgoingMessages( myPublicKey: currentPublicKey, nowMs: now, @@ -1987,14 +2098,8 @@ final class SessionManager { fromSync: true // fromSync = true → message marked as delivered + read ) - // Create/update dialog in DialogRepository. - // isNewMessage: false — don't increment unread count for release notes. - DialogRepository.shared.updateFromMessage( - packet, - myPublicKey: publicKey, - decryptedText: noticeText, - isNewMessage: false - ) + // Android parity: recalculate dialog from DB after inserting release note. + DialogRepository.shared.updateDialogFromMessages(opponentKey: Account.updatesPublicKey) // Force-clear unread for system Updates account — release notes are always "read". DialogRepository.shared.markAsRead(opponentKey: Account.updatesPublicKey) @@ -2021,11 +2126,29 @@ final class SessionManager { queue: .main ) { [weak self] _ in Task { @MainActor [weak self] in + // Android parity (onResume line 428): clear ALL delivered notifications + UNUserNotificationCenter.current().removeAllDeliveredNotifications() // Android: ON_RESUME calls markVisibleMessagesAsRead() for active dialog. self?.markActiveDialogsAsRead() - // Android parity: reconnectNowIfNeeded() — only reconnects if disconnected. - ProtocolManager.shared.reconnectIfNeeded() - // Android parity: syncOnForeground() — sync if already authenticated. + + // iOS-specific: after long background, iOS suspends the process and the + // server RSTs the TCP connection. But `didCloseWith` never fires (process + // frozen), so connectionState stays `.authenticated` (stale zombie socket). + // Android doesn't have this problem because OkHttp callbacks fire in background. + // + // Fix: if state looks "alive" (.authenticated/.connected), ping-first to + // verify. If no pong → zombie → forceReconnect. If disconnected, reconnect + // immediately (Android parity: reconnectNowIfNeeded). + let state = ProtocolManager.shared.connectionState + if state == .authenticated || state == .connected { + ProtocolManager.shared.verifyConnectionOrReconnect() + } else { + ProtocolManager.shared.reconnectIfNeeded() + } + + // syncOnForeground() has its own `.authenticated` guard — safe to call. + // If ping-first triggers reconnect, sync won't fire (state is .connecting). + // After reconnect + handshake, onHandshakeCompleted triggers requestSynchronize(). self?.syncOnForeground() } } diff --git a/Rosetta/Features/Chats/ChatDetail/ChatDetailSkeletonView.swift b/Rosetta/Features/Chats/ChatDetail/ChatDetailSkeletonView.swift new file mode 100644 index 0000000..bb41598 --- /dev/null +++ b/Rosetta/Features/Chats/ChatDetail/ChatDetailSkeletonView.swift @@ -0,0 +1,100 @@ +import SwiftUI + +// MARK: - ChatDetailSkeletonView + +/// Android parity: `MessageSkeletonList` composable in ChatDetailComponents.kt. +/// Shows shimmer placeholder message bubbles while chat messages are loading from DB. +/// Uses TimelineView (.animation) for clock-based shimmer that never restarts on view rebuild. +struct ChatDetailSkeletonView: View { + + /// Max bubble width (same as real messages). + var maxBubbleWidth: CGFloat = 270 + + /// Predefined bubble specs matching Android's `heightRandom` / `widthRandom` arrays. + /// (outgoing, widthFraction, height) + private static let bubbleSpecs: [(outgoing: Bool, widthFrac: CGFloat, height: CGFloat)] = [ + (false, 0.55, 44), + (true, 0.70, 80), + (false, 0.45, 36), + (true, 0.60, 52), + (false, 0.75, 68), + (true, 0.50, 44), + (false, 0.65, 60), + (true, 0.82, 96), + (false, 0.40, 36), + (true, 0.55, 48), + ] + + var body: some View { + TimelineView(.animation) { timeline in + let phase = shimmerPhase(from: timeline.date) + ScrollView { + VStack(spacing: 6) { + ForEach(0.. some View { + HStack(spacing: 6) { + if outgoing { Spacer(minLength: 0) } + + if !outgoing { + // Avatar circle placeholder (incoming messages) + Circle() + .fill(shimmerGradient(phase: phase)) + .frame(width: 32, height: 32) + } + + RoundedRectangle(cornerRadius: 18, style: .continuous) + .fill(shimmerGradient(phase: phase)) + .frame(width: width, height: height) + + if !outgoing { Spacer(minLength: 0) } + } + } + + // MARK: - Shimmer Helpers (same pattern as SearchSkeletonView) + + /// Clock-based phase — never resets on view rebuild. + private func shimmerPhase(from date: Date) -> CGFloat { + let elapsed = date.timeIntervalSinceReferenceDate + let cycle: Double = 1.5 + return CGFloat(elapsed.truncatingRemainder(dividingBy: cycle) / cycle) + } + + private func shimmerGradient(phase: CGFloat) -> LinearGradient { + let position = phase * 2.0 - 0.5 + return LinearGradient( + colors: [ + Color.gray.opacity(0.06), + Color.gray.opacity(0.14), + Color.gray.opacity(0.06), + ], + startPoint: UnitPoint(x: position - 0.3, y: 0), + endPoint: UnitPoint(x: position + 0.3, y: 0) + ) + } +} diff --git a/Rosetta/Features/Chats/ChatDetail/ChatDetailView.swift b/Rosetta/Features/Chats/ChatDetail/ChatDetailView.swift index c601ff5..8bb0308 100644 --- a/Rosetta/Features/Chats/ChatDetail/ChatDetailView.swift +++ b/Rosetta/Features/Chats/ChatDetail/ChatDetailView.swift @@ -242,6 +242,12 @@ struct ChatDetailView: View { // does NOT mutate DialogRepository, so ForEach won't rebuild. MessageRepository.shared.setDialogActive(route.publicKey, isActive: true) clearDeliveredNotifications(for: route.publicKey) + // Android parity: mark messages as read in DB IMMEDIATELY (no delay). + // This prevents reconcileUnreadCounts() from re-inflating badge + // if it runs during the 600ms navigation delay. + MessageRepository.shared.markIncomingAsRead( + opponentKey: route.publicKey, myPublicKey: currentPublicKey + ) // Request user info (non-mutating, won't trigger list rebuild) requestUserInfoIfNeeded() // Delay DialogRepository mutations to let navigation transition complete. @@ -618,7 +624,10 @@ private extension ChatDetailView { @ViewBuilder func messagesList(maxBubbleWidth: CGFloat) -> some View { - if messages.isEmpty { + if viewModel.isLoading && messages.isEmpty { + // Android parity: skeleton placeholder while loading from DB + ChatDetailSkeletonView(maxBubbleWidth: maxBubbleWidth) + } else if messages.isEmpty { emptyStateView } else { messagesScrollView(maxBubbleWidth: maxBubbleWidth) @@ -829,8 +838,12 @@ private extension ChatDetailView { Group { if visibleAttachments.isEmpty { - // Text-only message (original path) - textOnlyBubble(message: message, outgoing: outgoing, hasTail: hasTail, maxBubbleWidth: maxBubbleWidth, position: position) + // Text-only message — skip if text is garbage/empty (avatar messages with failed decrypt). + // Exception: MESSAGES attachments (reply/forward) have empty text by design. + let hasReplyAttachment = message.attachments.contains(where: { $0.type == .messages }) + if hasReplyAttachment || !Self.isGarbageText(message.text) { + textOnlyBubble(message: message, outgoing: outgoing, hasTail: hasTail, maxBubbleWidth: maxBubbleWidth, position: position) + } } else { // Attachment message: images/files + optional caption attachmentBubble(message: message, attachments: visibleAttachments, outgoing: outgoing, hasTail: hasTail, maxBubbleWidth: maxBubbleWidth, position: position) @@ -865,9 +878,11 @@ private extension ChatDetailView { private func textOnlyBubble(message: ChatMessage, outgoing: Bool, hasTail: Bool, maxBubbleWidth: CGFloat, position: BubblePosition) -> some View { let messageText = message.text.isEmpty ? " " : message.text let replyAttachment = message.attachments.first(where: { $0.type == .messages }) - let replyData = replyAttachment.flatMap { parseReplyBlob($0.blob) }?.first - // Forward detection: text is empty/space, but has a MESSAGES attachment with data. - let isForward = message.text.trimmingCharacters(in: .whitespaces).isEmpty && replyData != nil + // Android parity: try blob first, fall back to preview (incoming may store in preview). + let replyData = replyAttachment.flatMap { parseReplyBlob($0.blob) ?? parseReplyBlob($0.preview) }?.first + // Forward detection: text is empty/whitespace/garbage, but has a MESSAGES attachment with data. + // Uses isGarbageText to also catch replacement characters from encrypting empty text. + let isForward = (message.text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || Self.isGarbageText(message.text)) && replyData != nil if isForward, let reply = replyData { forwardedMessageBubble(message: message, reply: reply, outgoing: outgoing, hasTail: hasTail, maxBubbleWidth: maxBubbleWidth, position: position) @@ -937,13 +952,35 @@ private extension ChatDetailView { let hasVisualAttachments = !imageAttachments.isEmpty || !fileAttachments.isEmpty // Text: show as caption below visual attachments, or as main content if no attachments. + // Filter garbage text (U+FFFD replacement chars from failed decryption of " " space text). let hasCaption = !reply.message.trimmingCharacters(in: .whitespaces).isEmpty + && !Self.isGarbageText(reply.message) + + #if DEBUG + let _ = { + if reply.attachments.isEmpty { + print("⚠️ Forward bubble: reply has NO attachments. message_id=\(reply.message_id), text='\(reply.message.prefix(50))', publicKey=\(reply.publicKey.prefix(12))") + if let att = message.attachments.first(where: { $0.type == .messages }) { + let blobPrefix = att.blob.prefix(60) + let isEncrypted = att.blob.contains(":") && !att.blob.hasPrefix("[") + print("⚠️ raw .messages blob (\(att.blob.count) chars): '\(blobPrefix)...' encrypted=\(isEncrypted)") + } + } else { + print("📋 Forward bubble: message_id=\(reply.message_id.prefix(16)), \(reply.attachments.count) atts (images=\(imageAttachments.count), files=\(fileAttachments.count)), caption=\(hasCaption)") + } + }() + #endif // Fallback label when no visual attachments and no text. let fallbackText: String = { if hasCaption { return reply.message } if !imageAttachments.isEmpty { return hasVisualAttachments ? "" : "Photo" } - if let file = fileAttachments.first { return file.id.isEmpty ? "File" : file.id } + if let file = fileAttachments.first { + // Parse preview for filename (format: "tag::fileSize::fileName") + let parts = file.preview.components(separatedBy: "::") + if parts.count > 2 { return parts[2] } + return file.id.isEmpty ? "File" : file.id + } if reply.attachments.contains(where: { $0.type == 3 }) { return "Avatar" } return "Message" }() @@ -1062,9 +1099,16 @@ private extension ChatDetailView { } /// File attachment preview inside a forwarded message bubble. + /// Desktop parity: parse preview ("tag::fileSize::fileName") to extract filename. @ViewBuilder private func forwardedFilePreview(attachment: ReplyAttachmentData, outgoing: Bool) -> some View { - let filename = attachment.id.isEmpty ? "File" : attachment.id + let filename: String = { + // preview format: "tag::fileSize::fileName" (same as MessageFileView) + let parts = attachment.preview.components(separatedBy: "::") + if parts.count > 2 { return parts[2] } + // Fallback: try id, then generic label + return attachment.id.isEmpty ? "File" : attachment.id + }() HStack(spacing: 8) { Image(systemName: "doc.fill") .font(.system(size: 20)) @@ -1108,9 +1152,10 @@ private extension ChatDetailView { let previewText: String = { let trimmed = reply.message.trimmingCharacters(in: .whitespaces) if !trimmed.isEmpty { return reply.message } - if reply.attachments.contains(where: { $0.type == 0 }) { return "Photo" } - if reply.attachments.contains(where: { $0.type == 2 }) { return "File" } - if reply.attachments.contains(where: { $0.type == 3 }) { return "Avatar" } + if reply.attachments.contains(where: { $0.type == AttachmentType.image.rawValue }) { return "Photo" } + if reply.attachments.contains(where: { $0.type == AttachmentType.messages.rawValue }) { return "Forwarded message" } + if reply.attachments.contains(where: { $0.type == AttachmentType.file.rawValue }) { return "File" } + if reply.attachments.contains(where: { $0.type == AttachmentType.avatar.rawValue }) { return "Avatar" } return "Attachment" }() let accentColor = outgoing ? Color.white.opacity(0.5) : RosettaColors.figmaBlue @@ -1171,29 +1216,37 @@ private extension ChatDetailView { @MainActor private static var senderNameCache: [String: String] = [:] /// Resolves a public key to a display name for reply/forward quotes. - /// Checks: current user → "You", current opponent → route.title, any known dialog → title (cached). - /// Falls back to truncated public key if unknown. + /// Android parity: binary check (opponent → name, else → "You") + DialogRepository fallback. + /// Key prefix fallbacks are NOT cached — allows re-resolution when name arrives via sync/search. private func senderDisplayName(for publicKey: String) -> String { if publicKey == currentPublicKey { return "You" } - // Current chat opponent — use route (non-observable, stable). - if publicKey == route.publicKey { - return route.title.isEmpty ? String(publicKey.prefix(8)) + "…" : route.title - } // PERF: cached lookup — avoids creating @Observable tracking on DialogRepository.dialogs // in the per-cell render path. Cache is populated once per contact, valid for session. if let cached = Self.senderNameCache[publicKey] { return cached } + // Current chat opponent — try route.title first (stable, non-observable). + if publicKey == route.publicKey && !route.title.isEmpty { + Self.senderNameCache[publicKey] = route.title + return route.title + } + // Live lookup from DialogRepository (only on cache miss, result is cached). + // Covers: opponent with empty route.title, and any other known contact. if let dialog = DialogRepository.shared.dialogs[publicKey], !dialog.opponentTitle.isEmpty { Self.senderNameCache[publicKey] = dialog.opponentTitle return dialog.opponentTitle } - let fallback = String(publicKey.prefix(8)) + "…" - Self.senderNameCache[publicKey] = fallback - return fallback + // Try username for current opponent. + if publicKey == route.publicKey && !route.username.isEmpty { + let name = "@\(route.username)" + Self.senderNameCache[publicKey] = name + return name + } + // Fallback: truncated key. NOT cached — re-resolution on next render when name arrives. + return String(publicKey.prefix(8)) + "…" } /// PERF: single-pass partition of attachments into image vs non-image. @@ -1210,6 +1263,33 @@ private extension ChatDetailView { return (images, others) } + /// Check if text is a valid caption (not garbage, not just space). + /// Avatar messages have text=" ", which should NOT be shown as a caption. + /// Decryption failures may produce U+FFFD replacement characters. + private static func isValidCaption(_ text: String) -> Bool { + let cleaned = text.trimmingCharacters(in: .whitespacesAndNewlines) + if cleaned.isEmpty { return false } + if text == " " { return false } + // Filter garbage: text containing ONLY replacement characters / control chars + if isGarbageText(text) { return false } + return true + } + + /// Detect garbage text from failed decryption — U+FFFD, control characters, null bytes. + /// Messages with only these characters should be hidden, not shown as text bubbles. + private static func isGarbageText(_ text: String) -> Bool { + let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines) + if trimmed.isEmpty { return true } + // Check if ALL characters are garbage (replacement char, control, null) + let validCharacters = trimmed.unicodeScalars.filter { scalar in + scalar.value != 0xFFFD && // U+FFFD replacement character + scalar.value > 0x1F && // Control characters (0x00-0x1F) + scalar.value != 0x7F && // DEL + !CharacterSet.controlCharacters.contains(scalar) + } + return validCharacters.isEmpty + } + /// Attachment message bubble: images/files with optional text caption. /// /// Telegram-style layout: @@ -1225,7 +1305,7 @@ private extension ChatDetailView { maxBubbleWidth: CGFloat, position: BubblePosition ) -> some View { - let hasCaption = !message.text.trimmingCharacters(in: .whitespaces).isEmpty && message.text != " " + let hasCaption = Self.isValidCaption(message.text) // PERF: single-pass partition instead of 3 separate .filter() calls per cell. let partitioned = Self.partitionAttachments(attachments) let imageAttachments = partitioned.images @@ -1685,6 +1765,7 @@ private extension ChatDetailView { /// For Saved Messages and system accounts — no profile to show. func openProfile() { guard !route.isSavedMessages, !route.isSystemAccount else { return } + isInputFocused = false showOpponentProfile = true } @@ -1810,7 +1891,7 @@ private extension ChatDetailView { .frame(maxWidth: maxBubbleWidth) } else { // Attachment preview — reuse full bubble, clip to shape - let hasCaption = !message.text.trimmingCharacters(in: .whitespaces).isEmpty && message.text != " " + let hasCaption = Self.isValidCaption(message.text) let imageAttachments = visibleAttachments.filter { $0.type == .image } let otherAttachments = visibleAttachments.filter { $0.type != .image } let isImageOnly = !imageAttachments.isEmpty && otherAttachments.isEmpty && !hasCaption @@ -2026,12 +2107,7 @@ private extension ChatDetailView { @ViewBuilder func replyBar(for message: ChatMessage) -> some View { - // PERF: use route.title (non-observable) instead of dialog?.opponentTitle. - // Reading `dialog` here creates @Observable tracking on DialogRepository in the - // composer's render path, which is part of the main body. - let senderName = message.isFromMe(myPublicKey: currentPublicKey) - ? "You" - : (route.title.isEmpty ? String(route.publicKey.prefix(8)) + "…" : route.title) + let senderName = senderDisplayName(for: message.fromPublicKey) let previewText: String = { let trimmed = message.text.trimmingCharacters(in: .whitespaces) if !trimmed.isEmpty { return message.text } @@ -2091,6 +2167,18 @@ private extension ChatDetailView { // MARK: - Forward func forwardMessage(_ message: ChatMessage, to targetRoute: ChatRoute) { + #if DEBUG + print("═══════════════════════════════════════════════") + print("📤 FORWARD START") + print("📤 Original message: id=\(message.id.prefix(16)), text='\(message.text.prefix(30))'") + print("📤 Original attachments (\(message.attachments.count)):") + for att in message.attachments { + print("📤 - type=\(att.type) id=\(att.id.prefix(16)) preview='\(att.preview.prefix(40))' blob=\(att.blob.isEmpty ? "(empty)" : "(\(att.blob.count) chars, starts: \(att.blob.prefix(30)))")") + } + print("📤 Attachment password: \(message.attachmentPassword?.prefix(20) ?? "nil")") + print("📤 Target: \(targetRoute.publicKey.prefix(16))") + #endif + // Android parity: unwrap nested forwards. // If the message being forwarded is itself a forward, extract the inner // forwarded messages and re-forward them directly (flatten). @@ -2099,54 +2187,262 @@ private extension ChatDetailView { let replyAttachment = message.attachments.first(where: { $0.type == .messages }) let isForward = message.text.trimmingCharacters(in: .whitespaces).isEmpty + #if DEBUG + if let att = replyAttachment { + let blobParsed = parseReplyBlob(att.blob) + let previewParsed = parseReplyBlob(att.preview) + print("📤 Unwrap check: isForward=\(isForward)") + print("📤 blob parse: \(blobParsed == nil ? "FAILED" : "OK (\(blobParsed!.count) msgs, atts: \(blobParsed!.map { $0.attachments.count }))")") + print("📤 preview parse: \(previewParsed == nil ? "FAILED (preview='\(att.preview.prefix(20))')" : "OK (\(previewParsed!.count) msgs)")") + } + #endif + if isForward, let att = replyAttachment, - let innerMessages = parseReplyBlob(att.blob), + let innerMessages = (parseReplyBlob(att.blob) ?? parseReplyBlob(att.preview)), !innerMessages.isEmpty { // Unwrap: forward the original messages, not the wrapper forwardDataList = innerMessages + #if DEBUG + print("📤 ✅ UNWRAP path: \(innerMessages.count) inner message(s)") + for (i, msg) in innerMessages.enumerated() { + print("📤 msg[\(i)]: publicKey=\(msg.publicKey.prefix(12)), text='\(msg.message.prefix(30))', attachments=\(msg.attachments.count)") + for att in msg.attachments { + print("📤 att: type=\(att.type) id=\(att.id.prefix(16)) preview='\(att.preview.prefix(40))'") + } + } + #endif } else { // Regular message — forward as-is forwardDataList = [buildReplyData(from: message)] - } - - // Android parity: collect cached images for re-upload. - // Android re-encrypts + re-uploads each photo with the new message key. - // Without this, Desktop tries to decrypt CDN blob with the wrong key. - var forwardedImages: [String: Data] = [:] - for replyData in forwardDataList { - for att in replyData.attachments where att.type == AttachmentType.image.rawValue { - if let image = AttachmentCache.shared.loadImage(forAttachmentId: att.id), - let jpegData = image.jpegData(compressionQuality: 0.85) { - forwardedImages[att.id] = jpegData - #if DEBUG - print("📤 Forward: collected image \(att.id) (\(jpegData.count) bytes)") - #endif - } else { - #if DEBUG - print("📤 Forward: image \(att.id) NOT in cache — will skip re-upload") - #endif + #if DEBUG + print("📤 ⚠️ BUILD_REPLY_DATA path (unwrap failed or not a forward)") + if let first = forwardDataList.first { + print("📤 result: publicKey=\(first.publicKey.prefix(12)), text='\(first.message.prefix(30))', attachments=\(first.attachments.count)") + for att in first.attachments { + print("📤 att: type=\(att.type) id=\(att.id.prefix(16)) preview='\(att.preview.prefix(40))'") } } + #endif } + // Collect attachment password for CDN downloads of uncached images. + let storedPassword = message.attachmentPassword + let targetKey = targetRoute.publicKey + let targetTitle = targetRoute.title + let targetUsername = targetRoute.username + Task { @MainActor in + // Android parity: collect cached images for re-upload. + // Android re-encrypts + re-uploads each photo with the new message key. + // Without this, Desktop tries to decrypt CDN blob with the wrong key. + var forwardedImages: [String: Data] = [:] + var forwardedFiles: [String: (data: Data, fileName: String)] = [:] + + for replyData in forwardDataList { + for att in replyData.attachments { + if att.type == AttachmentType.image.rawValue { + // ── Image re-upload ── + if let image = AttachmentCache.shared.loadImage(forAttachmentId: att.id), + let jpegData = image.jpegData(compressionQuality: 0.85) { + forwardedImages[att.id] = jpegData + #if DEBUG + print("📤 Image \(att.id.prefix(16)): loaded from cache (\(jpegData.count) bytes)") + #endif + continue + } + + // Not in cache — download from CDN, decrypt, then include. + let cdnTag = att.preview.components(separatedBy: "::").first ?? "" + guard !cdnTag.isEmpty else { + #if DEBUG + print("📤 Image \(att.id.prefix(16)): SKIP — empty CDN tag, preview='\(att.preview.prefix(30))'") + #endif + continue + } + let password = storedPassword ?? "" + guard !password.isEmpty else { + #if DEBUG + print("📤 Image \(att.id.prefix(16)): SKIP — no attachment password") + #endif + continue + } + + do { + #if DEBUG + print("📤 Image \(att.id.prefix(16)): downloading from CDN tag=\(cdnTag.prefix(16))...") + #endif + let encryptedData = try await TransportManager.shared.downloadFile(tag: cdnTag) + let encryptedString = String(decoding: encryptedData, as: UTF8.self) + let passwords = MessageCrypto.attachmentPasswordCandidates(from: password) + + if let img = Self.decryptForwardImage(encryptedString: encryptedString, passwords: passwords), + let jpegData = img.jpegData(compressionQuality: 0.85) { + forwardedImages[att.id] = jpegData + AttachmentCache.shared.saveImage(img, forAttachmentId: att.id) + #if DEBUG + print("📤 Image \(att.id.prefix(16)): CDN download+decrypt OK (\(jpegData.count) bytes)") + #endif + } else { + #if DEBUG + print("📤 Image \(att.id.prefix(16)): CDN download OK but DECRYPT FAILED (\(encryptedData.count) bytes, \(passwords.count) candidates)") + #endif + } + } catch { + #if DEBUG + print("📤 Image \(att.id.prefix(16)): CDN download FAILED: \(error)") + #endif + } + + } else if att.type == AttachmentType.file.rawValue { + // ── File re-upload (Desktop parity: prepareAttachmentsToSend) ── + let parts = att.preview.components(separatedBy: "::") + let fileName = parts.count > 2 ? parts[2] : "file" + + // Try local cache first + if let fileData = AttachmentCache.shared.loadFileData(forAttachmentId: att.id, fileName: fileName) { + forwardedFiles[att.id] = (data: fileData, fileName: fileName) + #if DEBUG + print("📤 File \(att.id.prefix(16)): loaded from cache (\(fileData.count) bytes, name=\(fileName))") + #endif + continue + } + + // Not in cache — download from CDN, decrypt + let cdnTag = parts.first ?? "" + guard !cdnTag.isEmpty else { + #if DEBUG + print("📤 File \(att.id.prefix(16)): SKIP — empty CDN tag") + #endif + continue + } + let password = storedPassword ?? "" + guard !password.isEmpty else { + #if DEBUG + print("📤 File \(att.id.prefix(16)): SKIP — no attachment password") + #endif + continue + } + + do { + #if DEBUG + print("📤 File \(att.id.prefix(16)): downloading from CDN tag=\(cdnTag.prefix(16))...") + #endif + let encryptedData = try await TransportManager.shared.downloadFile(tag: cdnTag) + let encryptedString = String(decoding: encryptedData, as: UTF8.self) + let passwords = MessageCrypto.attachmentPasswordCandidates(from: password) + + if let fileData = Self.decryptForwardFile(encryptedString: encryptedString, passwords: passwords) { + forwardedFiles[att.id] = (data: fileData, fileName: fileName) + #if DEBUG + print("📤 File \(att.id.prefix(16)): CDN download+decrypt OK (\(fileData.count) bytes, name=\(fileName))") + #endif + } else { + #if DEBUG + print("📤 File \(att.id.prefix(16)): CDN download OK but DECRYPT FAILED (\(encryptedData.count) bytes)") + #endif + } + } catch { + #if DEBUG + print("📤 File \(att.id.prefix(16)): CDN download FAILED: \(error)") + #endif + } + } else { + #if DEBUG + print("📤 Attachment \(att.id.prefix(16)): SKIP — type=\(att.type)") + #endif + } + } + } + + #if DEBUG + print("📤 ── SEND SUMMARY ──") + print("📤 forwardDataList: \(forwardDataList.count) message(s)") + for (i, msg) in forwardDataList.enumerated() { + print("📤 msg[\(i)]: text='\(msg.message.prefix(20))' attachments=\(msg.attachments.count) (images: \(msg.attachments.filter { $0.type == 0 }.count), files: \(msg.attachments.filter { $0.type == 2 }.count))") + } + print("📤 forwardedImages: \(forwardedImages.count) re-uploads") + print("📤 forwardedFiles: \(forwardedFiles.count) re-uploads") + #endif + do { try await SessionManager.shared.sendMessageWithReply( - text: " ", + text: "", replyMessages: forwardDataList, toPublicKey: targetKey, - opponentTitle: targetRoute.title, - opponentUsername: targetRoute.username, - forwardedImages: forwardedImages + opponentTitle: targetTitle, + opponentUsername: targetUsername, + forwardedImages: forwardedImages, + forwardedFiles: forwardedFiles ) + #if DEBUG + print("📤 ✅ FORWARD SENT OK") + print("═══════════════════════════════════════════════") + #endif } catch { + #if DEBUG + print("📤 ❌ FORWARD FAILED: \(error)") + print("═══════════════════════════════════════════════") + #endif sendError = "Failed to forward message" } } } + /// Decrypt a CDN-downloaded image blob with multiple password candidates. + private static func decryptForwardImage(encryptedString: String, passwords: [String]) -> UIImage? { + let crypto = CryptoManager.shared + for password in passwords { + if let data = try? crypto.decryptWithPassword(encryptedString, password: password, requireCompression: true), + let img = parseForwardImageData(data) { return img } + } + for password in passwords { + if let data = try? crypto.decryptWithPassword(encryptedString, password: password), + let img = parseForwardImageData(data) { return img } + } + return nil + } + + private static func parseForwardImageData(_ data: Data) -> UIImage? { + if let str = String(data: data, encoding: .utf8), + str.hasPrefix("data:"), + let commaIndex = str.firstIndex(of: ",") { + let base64Part = String(str[str.index(after: commaIndex)...]) + if let imageData = Data(base64Encoded: base64Part) { + return UIImage(data: imageData) + } + } + return UIImage(data: data) + } + + /// Decrypt a CDN-downloaded file blob with multiple password candidates. + /// Returns raw file data (extracted from data URI). + private static func decryptForwardFile(encryptedString: String, passwords: [String]) -> Data? { + let crypto = CryptoManager.shared + for password in passwords { + if let data = try? crypto.decryptWithPassword(encryptedString, password: password, requireCompression: true), + let fileData = parseForwardFileData(data) { return fileData } + } + for password in passwords { + if let data = try? crypto.decryptWithPassword(encryptedString, password: password), + let fileData = parseForwardFileData(data) { return fileData } + } + return nil + } + + /// Extract raw file bytes from a data URI (format: "data:{mime};base64,{base64data}"). + private static func parseForwardFileData(_ data: Data) -> Data? { + if let str = String(data: data, encoding: .utf8), + str.hasPrefix("data:"), + let commaIndex = str.firstIndex(of: ",") { + let base64Part = String(str[str.index(after: commaIndex)...]) + return Data(base64Encoded: base64Part) + } + // Not a data URI — return raw data + return data + } + /// Builds a `ReplyMessageData` from a `ChatMessage` for reply/forward encoding. /// Desktop parity: `MessageReply` in `useReplyMessages.ts`. private func buildReplyData(from message: ChatMessage) -> ReplyMessageData { @@ -2162,10 +2458,26 @@ private extension ChatDetailView { ) } + // If no non-messages attachments but has a .messages attachment (forward), + // try to extract the inner message's data to preserve photos/files. + // This handles the case where forwardMessage() unwrap failed but the blob is actually parseable. + if replyAttachments.isEmpty, + let msgAtt = message.attachments.first(where: { $0.type == .messages }), + let innerMessages = parseReplyBlob(msgAtt.blob), + let firstInner = innerMessages.first { + #if DEBUG + print("📤 buildReplyData: extracted inner message with \(firstInner.attachments.count) attachments") + #endif + return firstInner + } + + // Filter garbage text (U+FFFD from failed decryption) — don't send ciphertext/garbage to recipient. + let cleanText = Self.isGarbageText(message.text) ? "" : message.text + return ReplyMessageData( message_id: message.id, publicKey: message.fromPublicKey, - message: message.text, + message: cleanText, timestamp: message.timestamp, attachments: replyAttachments ) diff --git a/Rosetta/Features/Chats/ChatDetail/ChatDetailViewModel.swift b/Rosetta/Features/Chats/ChatDetail/ChatDetailViewModel.swift index 3a05d9f..a20a76a 100644 --- a/Rosetta/Features/Chats/ChatDetail/ChatDetailViewModel.swift +++ b/Rosetta/Features/Chats/ChatDetail/ChatDetailViewModel.swift @@ -12,6 +12,8 @@ final class ChatDetailViewModel: ObservableObject { @Published private(set) var messages: [ChatMessage] = [] @Published private(set) var isTyping: Bool = false + /// Android parity: true while loading messages from DB. Shows skeleton placeholder. + @Published private(set) var isLoading: Bool = true private var cancellables = Set() @@ -21,8 +23,12 @@ final class ChatDetailViewModel: ObservableObject { let repo = MessageRepository.shared // Seed with current values - messages = repo.messages(for: dialogKey) + let initial = repo.messages(for: dialogKey) + messages = initial isTyping = repo.isTyping(dialogKey: dialogKey) + // Android parity: if we already have messages, skip skeleton. + // Otherwise keep isLoading=true until first Combine emission or timeout. + isLoading = initial.isEmpty // Subscribe to messagesByDialog changes, filtered to our dialog only. // Broken into steps to help the Swift type-checker. @@ -48,6 +54,9 @@ final class ChatDetailViewModel: ObservableObject { .sink { [weak self] newMessages in PerformanceLogger.shared.track("chatDetail.messagesEmit") self?.messages = newMessages + if self?.isLoading == true { + self?.isLoading = false + } } .store(in: &cancellables) diff --git a/Rosetta/Features/Chats/ChatDetail/MessageImageView.swift b/Rosetta/Features/Chats/ChatDetail/MessageImageView.swift index 05b9810..c335432 100644 --- a/Rosetta/Features/Chats/ChatDetail/MessageImageView.swift +++ b/Rosetta/Features/Chats/ChatDetail/MessageImageView.swift @@ -4,18 +4,15 @@ import SwiftUI /// Displays an image attachment inside a message bubble. /// -/// Desktop parity: `MessageImage.tsx` — shows blur placeholder while downloading, -/// full image after download, "Image expired" on error. -/// -/// Modes: -/// - **Standalone** (`collageSize == nil`): uses own min/max constraints + aspect ratio. -/// - **Collage cell** (`collageSize != nil`): fills the given frame (parent controls size). +/// Android parity: `AttachmentComponents.kt` — blurhash placeholder, download button, +/// spinner overlay, upload spinner for outgoing photos, error retry button. /// /// States: -/// 1. **Cached** — image already in AttachmentCache, display immediately -/// 2. **Downloading** — show blurhash placeholder + spinner -/// 3. **Downloaded** — display image, tap for full-screen (future) -/// 4. **Error** — "Image expired" or download error +/// 1. **Cached + Delivered** — full image, no overlay +/// 2. **Cached + Uploading** — full image + dark overlay + upload spinner (outgoing only) +/// 3. **Downloading** — blurhash + dark overlay + spinner +/// 4. **Not Downloaded** — blurhash + dark overlay + download arrow button +/// 5. **Error** — blurhash + dark overlay + red retry button struct MessageImageView: View { let attachment: MessageAttachment @@ -23,12 +20,10 @@ struct MessageImageView: View { let outgoing: Bool /// When set, the image fills this exact frame (used inside PhotoCollageView). - /// When nil, standalone mode with own size constraints. var collageSize: CGSize? = nil let maxWidth: CGFloat - /// Called when user taps a loaded image (opens full-screen viewer). var onImageTap: ((UIImage) -> Void)? @State private var image: UIImage? @@ -36,44 +31,46 @@ struct MessageImageView: View { @State private var isDownloading = false @State private var downloadError = false - /// Whether this image is inside a collage (fills parent frame). private var isCollageCell: Bool { collageSize != nil } - /// Telegram-style image constraints (standalone mode only). + // Telegram-style constraints (standalone mode) private let maxImageWidth: CGFloat = 270 private let maxImageHeight: CGFloat = 320 private let minImageWidth: CGFloat = 140 private let minImageHeight: CGFloat = 100 - - /// Default placeholder size (standalone mode). private let placeholderWidth: CGFloat = 200 private let placeholderHeight: CGFloat = 200 + /// Android parity: outgoing photo is "uploading" when cached but not yet delivered. + private var isUploading: Bool { + outgoing && image != nil && message.deliveryStatus == .waiting + } + var body: some View { - Group { + ZStack { if let image { imageContent(image) - } else if isDownloading { - placeholderView - .overlay { downloadingOverlay } - } else if downloadError { - placeholderView - .overlay { errorOverlay } } else { placeholderView - .overlay { downloadArrowOverlay } + } + + // Android parity: full-image dark overlay + centered control + if isUploading { + uploadingOverlay + } else if isDownloading { + downloadingOverlay + } else if downloadError { + errorOverlay + } else if image == nil { + downloadArrowOverlay } } .task { - // PERF: load cached image FIRST — skip expensive BlurHash DCT decode - // if the full image is already available. loadFromCache() if image == nil { decodeBlurHash() } } - // Download triggered by BubbleContextMenuOverlay tap → notification. - // Overlay UIView intercepts all taps; SwiftUI onTapGesture can't fire. .onReceive(NotificationCenter.default.publisher(for: .triggerAttachmentDownload)) { notif in if let id = notif.object as? String, id == attachment.id, image == nil { downloadImage() @@ -81,46 +78,67 @@ struct MessageImageView: View { } } - // MARK: - Overlay States (Desktop parity: MessageImage.tsx) + // MARK: - Android-Style Overlays - /// Desktop: dark 40x40 circle with ProgressView spinner. - private var downloadingOverlay: some View { - Circle() - .fill(Color.black.opacity(0.3)) - .frame(width: 40, height: 40) - .overlay { - ProgressView() - .tint(.white) - .scaleEffect(0.9) - } - } - - /// Desktop: dark rounded pill with "Image expired" + flame icon. - private var errorOverlay: some View { - HStack(spacing: 4) { - Text("Image expired") - .font(.system(size: 11)) - .foregroundStyle(.white) - Image(systemName: "flame.fill") - .font(.system(size: 12)) - .foregroundStyle(.white) + /// Android: outgoing photo uploading — 28dp spinner, 0.35 alpha overlay. + private var uploadingOverlay: some View { + overlayContainer(alpha: 0.35) { + ProgressView() + .tint(.white) + .scaleEffect(0.8) + .frame(width: 28, height: 28) } - .padding(.horizontal, 8) - .padding(.vertical, 6) - .background(Color.black.opacity(0.3)) - .clipShape(RoundedRectangle(cornerRadius: 8)) } - /// Desktop: dark 40x40 circle with download arrow icon. - private var downloadArrowOverlay: some View { - Circle() - .fill(Color.black.opacity(0.3)) - .frame(width: 40, height: 40) - .overlay { - Image(systemName: "arrow.down") - .font(.system(size: 18, weight: .medium)) + /// Android: downloading — 36dp spinner, 0.3 alpha overlay. + private var downloadingOverlay: some View { + overlayContainer(alpha: 0.3) { + ProgressView() + .tint(.white) + .scaleEffect(1.0) + .frame(width: 36, height: 36) + } + } + + /// Android: error — red 48dp button with refresh icon + label. + private var errorOverlay: some View { + overlayContainer(alpha: 0.3) { + VStack(spacing: 8) { + Circle() + .fill(Color(red: 0.9, green: 0.22, blue: 0.21).opacity(0.8)) + .frame(width: 48, height: 48) + .overlay { + Image(systemName: "arrow.clockwise") + .font(.system(size: 20, weight: .medium)) + .foregroundStyle(.white) + } + Text("Error") + .font(.system(size: 12)) .foregroundStyle(.white) } + } + } + + /// Android: not downloaded — 48dp button with download arrow. + private var downloadArrowOverlay: some View { + overlayContainer(alpha: 0.3) { + Circle() + .fill(Color.black.opacity(0.5)) + .frame(width: 48, height: 48) + .overlay { + Image(systemName: "arrow.down") + .font(.system(size: 20, weight: .medium)) + .foregroundStyle(.white) + } + } + } + + /// Android parity: full-image semi-transparent dark overlay with centered content. + private func overlayContainer(alpha: Double, @ViewBuilder content: () -> Content) -> some View { + ZStack { + Color.black.opacity(alpha) + content() + } } // MARK: - Image Content @@ -128,7 +146,6 @@ struct MessageImageView: View { @ViewBuilder private func imageContent(_ img: UIImage) -> some View { if let size = collageSize { - // Collage mode: fill the given cell frame Image(uiImage: img) .resizable() .scaledToFill() @@ -137,7 +154,6 @@ struct MessageImageView: View { .contentShape(Rectangle()) .onTapGesture { onImageTap?(img) } } else { - // Standalone mode: respect aspect ratio constraints let size = constrainedSize(for: img) Image(uiImage: img) .resizable() @@ -149,10 +165,8 @@ struct MessageImageView: View { } } - /// Calculates display size respecting min/max constraints and aspect ratio (standalone mode). private func constrainedSize(for img: UIImage) -> CGSize { let constrainedWidth = min(maxImageWidth, maxWidth) - // Guard: zero-size images (corrupted or failed downsampling) use placeholder size. guard img.size.width > 0, img.size.height > 0 else { return CGSize(width: min(placeholderWidth, constrainedWidth), height: placeholderHeight) } @@ -181,21 +195,14 @@ struct MessageImageView: View { } } - /// Placeholder size: collage cell size if in collage, otherwise square default. private var resolvedPlaceholderSize: CGSize { - if let size = collageSize { - return size - } + if let size = collageSize { return size } let w = min(placeholderWidth, min(maxImageWidth, maxWidth)) return CGSize(width: w, height: w) } // MARK: - BlurHash Decoding - /// Decodes the blurhash from the attachment preview string once and caches in @State. - /// Android parity: `LaunchedEffect(preview) { BlurHash.decode(preview, 200, 200) }`. - /// PERF: static cache for decoded BlurHash images — shared across all instances. - /// Avoids redundant DCT decode when the same attachment appears in multiple re-renders. @MainActor private static var blurHashCache: [String: UIImage] = [:] private func decodeBlurHash() { @@ -246,8 +253,6 @@ struct MessageImageView: View { let encryptedData = try await TransportManager.shared.downloadFile(tag: tag) let encryptedString = String(decoding: encryptedData, as: UTF8.self) - // Try each password candidate; validate decrypted content to avoid false positives - // from wrong-key AES-CBC that randomly produces valid PKCS7 + passable inflate. let passwords = MessageCrypto.attachmentPasswordCandidates(from: storedPassword) let downloadedImage = decryptAndParseImage( encryptedString: encryptedString, passwords: passwords @@ -271,28 +276,23 @@ struct MessageImageView: View { } } - /// Tries each password candidate and validates the decrypted content is a real image. private func decryptAndParseImage(encryptedString: String, passwords: [String]) -> UIImage? { let crypto = CryptoManager.shared for password in passwords { guard let data = try? crypto.decryptWithPassword( encryptedString, password: password, requireCompression: true ) else { continue } - if let img = parseImageData(data) { return img } } - // Fallback: try without requireCompression (legacy uncompressed payloads) for password in passwords { guard let data = try? crypto.decryptWithPassword( encryptedString, password: password ) else { continue } - if let img = parseImageData(data) { return img } } return nil } - /// Parses decrypted data as an image: data URI, plain base64, or raw image bytes. private func parseImageData(_ data: Data) -> UIImage? { if let str = String(data: data, encoding: .utf8) { if str.hasPrefix("data:"), @@ -307,21 +307,16 @@ struct MessageImageView: View { return img } } - // Raw image data return UIImage(data: data) } // MARK: - Preview Parsing - /// Extracts the server tag from preview string. - /// Format: "tag::blurhash" or "tag::" → returns "tag". private func extractTag(from preview: String) -> String { let parts = preview.components(separatedBy: "::") return parts.first ?? preview } - /// Extracts the blurhash from preview string. - /// Format: "tag::blurhash" → returns "blurhash". private func extractBlurHash(from preview: String) -> String { let parts = preview.components(separatedBy: "::") return parts.count > 1 ? parts[1] : "" diff --git a/Rosetta/Features/Chats/ChatDetail/OpponentProfileView.swift b/Rosetta/Features/Chats/ChatDetail/OpponentProfileView.swift index 1ae4b4a..f2f8091 100644 --- a/Rosetta/Features/Chats/ChatDetail/OpponentProfileView.swift +++ b/Rosetta/Features/Chats/ChatDetail/OpponentProfileView.swift @@ -219,26 +219,15 @@ struct OpponentProfileView: View { // MARK: - Glass helpers - @ViewBuilder + // Use TelegramGlass* UIViewRepresentable for ALL iOS versions. + // SwiftUI .glassEffect() creates UIKit containers that intercept taps + // even with .allowsHitTesting(false) — breaks back button. + private func glassCapsule() -> some View { - if #available(iOS 26.0, *) { - Capsule().fill(.clear).glassEffect(.regular, in: .capsule) - } else { - TelegramGlassCapsule() - } + TelegramGlassCapsule() } - @ViewBuilder private func glassCard() -> some View { - if #available(iOS 26.0, *) { - RoundedRectangle(cornerRadius: 14, style: .continuous) - .fill(.clear) - .glassEffect( - .regular, - in: RoundedRectangle(cornerRadius: 14, style: .continuous) - ) - } else { - TelegramGlassRoundedRect(cornerRadius: 14) - } + TelegramGlassRoundedRect(cornerRadius: 14) } } diff --git a/Rosetta/Features/Chats/ChatList/ChatListView.swift b/Rosetta/Features/Chats/ChatList/ChatListView.swift index 1cfd2f9..64b2d2a 100644 --- a/Rosetta/Features/Chats/ChatList/ChatListView.swift +++ b/Rosetta/Features/Chats/ChatList/ChatListView.swift @@ -568,6 +568,11 @@ private struct ChatListDialogContent: View { var body: some View { let _ = PerformanceLogger.shared.track("chatList.bodyEval") + // CRITICAL: Read DialogRepository.dialogs directly to establish @Observable tracking. + // Without this, ChatListDialogContent only observes viewModel (ObservableObject) + // which never publishes objectWillChange for dialog mutations. + // The read forces SwiftUI to re-evaluate body when dialogs dict changes. + let _ = DialogRepository.shared.dialogs.count // Use pre-partitioned arrays from ViewModel (single-pass O(n) instead of 3× filter). let pinned = viewModel.allModePinned let unpinned = viewModel.allModeUnpinned @@ -636,6 +641,7 @@ private struct ChatListDialogContent: View { .scrollContentBackground(.hidden) .scrollDismissesKeyboard(.immediately) .scrollIndicators(.hidden) + .modifier(ClassicSwipeActionsModifier()) // Scroll-to-top: tap "Chats" in toolbar .onReceive(NotificationCenter.default.publisher(for: .chatListScrollToTop)) { _ in // Scroll to first dialog ID (pinned or unpinned) @@ -712,7 +718,7 @@ struct SyncAwareChatRow: View { if !dialog.isSavedMessages { Button { - viewModel.toggleMute(dialog) + withAnimation { viewModel.toggleMute(dialog) } } label: { Label( dialog.isMuted ? "Unmute" : "Mute", @@ -724,7 +730,7 @@ struct SyncAwareChatRow: View { } .swipeActions(edge: .leading, allowsFullSwipe: true) { Button { - viewModel.togglePin(dialog) + withAnimation { viewModel.togglePin(dialog) } } label: { Label(dialog.isPinned ? "Unpin" : "Pin", systemImage: dialog.isPinned ? "pin.slash" : "pin") } @@ -777,6 +783,22 @@ private struct DeviceApprovalBanner: View { } } +// MARK: - iOS 26+ Classic Swipe Actions + +/// iOS 26: disable Liquid Glass on the List so swipe action buttons use +/// solid colors (same as iOS < 26). Uses UIAppearance override. +private struct ClassicSwipeActionsModifier: ViewModifier { + func body(content: Content) -> some View { + content.onAppear { + if #available(iOS 26, *) { + // Disable glass on UITableView-backed List swipe actions. + let appearance = UITableView.appearance() + appearance.backgroundColor = .clear + } + } + } +} + #Preview("Chat List") { ChatListView(isSearchActive: .constant(false), isDetailPresented: .constant(false)) .preferredColorScheme(.dark) diff --git a/Rosetta/Features/Chats/ChatList/ChatRowView.swift b/Rosetta/Features/Chats/ChatList/ChatRowView.swift index cf1b670..e734a1f 100644 --- a/Rosetta/Features/Chats/ChatList/ChatRowView.swift +++ b/Rosetta/Features/Chats/ChatList/ChatRowView.swift @@ -141,7 +141,7 @@ private extension ChatRowView { if isTyping && !dialog.isSavedMessages { return "typing..." } - if dialog.lastMessage.isEmpty { + if dialog.lastMessage.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { return "No messages yet" } if let cached = Self.messageTextCache[dialog.lastMessage] { @@ -195,11 +195,11 @@ private extension ChatRowView { .rotationEffect(.degrees(45)) } - // Desktop parity: delivery icon and unread badge are - // mutually exclusive — badge hidden when lastMessageFromMe. - // Also hidden during sync (desktop hides badges while - // protocolState == SYNCHRONIZATION). - if dialog.unreadCount > 0 && !dialog.lastMessageFromMe && !isSyncing { + // Show unread badge whenever there are unread messages. + // Previously hidden when lastMessageFromMe (desktop parity), + // but this caused invisible unreads when user sent a reply + // without reading prior incoming messages first. + if dialog.unreadCount > 0 && !isSyncing { unreadBadge } } diff --git a/Rosetta/Features/Chats/ChatList/RequestChatsView.swift b/Rosetta/Features/Chats/ChatList/RequestChatsView.swift index 4bdc815..3350aab 100644 --- a/Rosetta/Features/Chats/ChatList/RequestChatsView.swift +++ b/Rosetta/Features/Chats/ChatList/RequestChatsView.swift @@ -71,13 +71,9 @@ struct RequestChatsView: View { } } - @ViewBuilder + // Use TelegramGlass* for ALL iOS versions — SwiftUI .glassEffect() blocks touches. private func glassCapsule(strokeOpacity: Double = 0.18, strokeColor: Color = .white) -> some View { - if #available(iOS 26.0, *) { - Capsule().fill(.clear).glassEffect(.regular, in: .capsule) - } else { - TelegramGlassCapsule() - } + TelegramGlassCapsule() } private func requestRow(_ dialog: Dialog, isFirst: Bool) -> some View { diff --git a/Rosetta/Features/MainTabView.swift b/Rosetta/Features/MainTabView.swift index ed6de05..6afaedb 100644 --- a/Rosetta/Features/MainTabView.swift +++ b/Rosetta/Features/MainTabView.swift @@ -152,6 +152,7 @@ struct MainTabView: View { tabView(for: tab) .frame(width: width, height: availableSize.height) .opacity(tabOpacity(for: tab)) + .environment(\.telegramGlassActive, tabOpacity(for: tab) > 0) .animation(.easeOut(duration: 0.12), value: selectedTab) .allowsHitTesting(tab == selectedTab && dragFractionalIndex == nil) } diff --git a/Rosetta/RosettaApp.swift b/Rosetta/RosettaApp.swift index ac6eb5b..430ad3c 100644 --- a/Rosetta/RosettaApp.swift +++ b/Rosetta/RosettaApp.swift @@ -68,6 +68,11 @@ final class AppDelegate: NSObject, UIApplicationDelegate, UNUserNotificationCent /// 1. Server sends data-only push (no alert) → we create a local notification with sound. /// 2. Server sends visible push + content-available → NSE handles sound/badge, /// we only sync the badge count here. + /// Android parity: 10-second dedup window per sender. + /// Prevents duplicate push notifications from rapid server retries. + private static var lastNotifTimestamps: [String: TimeInterval] = [:] + private static let dedupWindowSeconds: TimeInterval = 10 + func application( _ application: UIApplication, didReceiveRemoteNotification userInfo: [AnyHashable: Any], @@ -88,25 +93,36 @@ final class AppDelegate: NSObject, UIApplicationDelegate, UNUserNotificationCent UserDefaults.standard.set(newBadge, forKey: "app_badge_count") UNUserNotificationCenter.current().setBadgeCount(newBadge) - let senderKey = userInfo["sender_public_key"] as? String ?? "" - let senderName = userInfo["sender_name"] as? String ?? "New message" - let messageText = userInfo["message"] as? String ?? "New message" + // Android parity: extract sender key with multi-key fallback. + // Server may send under different key names depending on version. + let senderKey = Self.extractSenderKey(from: userInfo) + let senderName = Self.firstNonBlank(userInfo, keys: [ + "sender_name", "from_title", "sender", "title", "name" + ]) ?? "New message" + let messageText = Self.firstNonBlank(userInfo, keys: [ + "message_preview", "message", "text", "body" + ]) ?? "New message" + + // Android parity: 10-second dedup per sender. + let dedupKey = senderKey.isEmpty ? "__no_sender__" : senderKey + let now = Date().timeIntervalSince1970 + if let lastTs = Self.lastNotifTimestamps[dedupKey], now - lastTs < Self.dedupWindowSeconds { + completionHandler(.noData) + return + } + Self.lastNotifTimestamps[dedupKey] = now // Check if the server already sent a visible alert (aps.alert exists). - // If so, NSE already modified it — don't create a duplicate local notification. let aps = userInfo["aps"] as? [String: Any] let hasVisibleAlert = aps?["alert"] != nil - // Don't notify for muted chats (sync check without MainActor await). + // Don't notify for muted chats. let isMuted: Bool = { - // Access is safe: called from background on MainActor-isolated repo. - // Use standard defaults cache for muted set (no MainActor needed). let mutedSet = UserDefaults.standard.stringArray(forKey: "muted_chats_keys") ?? [] return mutedSet.contains(senderKey) }() // If server sent visible alert, NSE handles sound+badge. Just sync badge. - // If data-only push, create local notification with sound for vibration. guard !hasVisibleAlert && !isMuted else { completionHandler(.newData) return @@ -118,21 +134,40 @@ final class AppDelegate: NSObject, UIApplicationDelegate, UNUserNotificationCent content.sound = .default content.badge = NSNumber(value: newBadge) content.categoryIdentifier = "message" - if !senderKey.isEmpty { - content.userInfo = ["sender_public_key": senderKey, "sender_name": senderName] - } + // Always set sender_public_key in userInfo for notification tap navigation. + content.userInfo = ["sender_public_key": senderKey, "sender_name": senderName] let request = UNNotificationRequest( - identifier: "msg_\(senderKey)_\(Int(Date().timeIntervalSince1970))", + identifier: "msg_\(senderKey)_\(Int(now))", content: content, trigger: nil ) - // Use non-async path to avoid Task lifetime issues in background. UNUserNotificationCenter.current().add(request) { _ in completionHandler(.newData) } } + // MARK: - Push Payload Helpers (Android parity) + + /// Android parity: extract sender public key from multiple possible key names. + /// Server may use different key names across versions. + private static func extractSenderKey(from userInfo: [AnyHashable: Any]) -> String { + firstNonBlank(userInfo, keys: [ + "sender_public_key", "from_public_key", "fromPublicKey", + "public_key", "publicKey" + ]) ?? "" + } + + /// Android parity: `firstNonBlank(data, ...)` — try multiple key names, return first non-empty. + private static func firstNonBlank(_ dict: [AnyHashable: Any], keys: [String]) -> String? { + for key in keys { + if let value = dict[key] as? String, !value.trimmingCharacters(in: .whitespaces).isEmpty { + return value + } + } + return nil + } + // MARK: - MessagingDelegate /// Called when FCM token is received or refreshed. @@ -157,23 +192,26 @@ final class AppDelegate: NSObject, UIApplicationDelegate, UNUserNotificationCent } /// Handle notification tap — navigate to the sender's chat. + /// Android parity: extracts sender key with multi-key fallback. func userNotificationCenter( _ center: UNUserNotificationCenter, didReceive response: UNNotificationResponse, withCompletionHandler completionHandler: @escaping () -> Void ) { let userInfo = response.notification.request.content.userInfo - let senderKey = userInfo["sender_public_key"] as? String ?? "" + // Android parity: try multiple key names for sender identification. + let senderKey = Self.extractSenderKey(from: userInfo) if !senderKey.isEmpty { - let senderName = userInfo["sender_name"] as? String ?? "" + let senderName = Self.firstNonBlank(userInfo, keys: [ + "sender_name", "from_title", "sender", "title", "name" + ]) ?? "" let route = ChatRoute( publicKey: senderKey, title: senderName, username: "", verified: 0 ) - // Post notification for ChatListView to handle navigation NotificationCenter.default.post( name: .openChatFromNotification, object: route @@ -181,7 +219,11 @@ final class AppDelegate: NSObject, UIApplicationDelegate, UNUserNotificationCent // Clear all delivered notifications from this sender center.getDeliveredNotifications { delivered in let idsToRemove = delivered - .filter { $0.request.content.userInfo["sender_public_key"] as? String == senderKey } + .filter { notification in + let info = notification.request.content.userInfo + let key = Self.extractSenderKey(from: info) + return key == senderKey + } .map { $0.request.identifier } if !idsToRemove.isEmpty { center.removeDeliveredNotifications(withIdentifiers: idsToRemove) diff --git a/RosettaNotificationService/NotificationService.swift b/RosettaNotificationService/NotificationService.swift index cfbe56e..a91e9e5 100644 --- a/RosettaNotificationService/NotificationService.swift +++ b/RosettaNotificationService/NotificationService.swift @@ -4,11 +4,22 @@ import UserNotifications /// is terminated. Intercepts push notifications with `mutable-content: 1` and: /// 1. Adds `.default` sound for vibration (server payload has no sound) /// 2. Increments the app icon badge from shared App Group storage +/// 3. Normalizes sender_public_key in userInfo (Android parity: multi-key fallback) +/// 4. Filters muted chats final class NotificationService: UNNotificationServiceExtension { private static let appGroupID = "group.com.rosetta.dev" private static let badgeKey = "app_badge_count" + /// Android parity: multiple key names for sender public key extraction. + private static let senderKeyNames = [ + "sender_public_key", "from_public_key", "fromPublicKey", + "public_key", "publicKey" + ] + private static let senderNameKeyNames = [ + "sender_name", "from_title", "sender", "title", "name" + ] + private var contentHandler: ((UNNotificationContent) -> Void)? private var bestAttemptContent: UNMutableNotificationContent? @@ -33,9 +44,34 @@ final class NotificationService: UNNotificationServiceExtension { let newBadge = current + 1 shared.set(newBadge, forKey: Self.badgeKey) content.badge = NSNumber(value: newBadge) + + // 4. Filter muted chats. + let senderKey = Self.extractSenderKey(from: content.userInfo) + let mutedKeys = shared.stringArray(forKey: "muted_chats_keys") ?? [] + if !senderKey.isEmpty, mutedKeys.contains(senderKey) { + // Muted: deliver silently (no sound, no alert) + content.sound = nil + content.title = "" + content.body = "" + contentHandler(content) + return + } } - // 3. Ensure notification category for CarPlay parity. + // 3. Normalize sender_public_key in userInfo for tap navigation. + // Server may send under different key names — normalize to "sender_public_key". + let senderKey = Self.extractSenderKey(from: content.userInfo) + if !senderKey.isEmpty { + var updatedInfo = content.userInfo + updatedInfo["sender_public_key"] = senderKey + // Also normalize sender_name + if let name = Self.firstNonBlank(content.userInfo, keys: Self.senderNameKeyNames) { + updatedInfo["sender_name"] = name + } + content.userInfo = updatedInfo + } + + // 5. Ensure notification category for CarPlay parity. if content.categoryIdentifier.isEmpty { content.categoryIdentifier = "message" } @@ -43,12 +79,26 @@ final class NotificationService: UNNotificationServiceExtension { contentHandler(content) } - /// Called if the extension takes too long (30s limit). - /// Deliver the best attempt content with at least the sound set. override func serviceExtensionTimeWillExpire() { if let handler = contentHandler, let content = bestAttemptContent { content.sound = .default handler(content) } } + + // MARK: - Helpers + + /// Android parity: extract sender key from multiple possible key names. + private static func extractSenderKey(from userInfo: [AnyHashable: Any]) -> String { + firstNonBlank(userInfo, keys: senderKeyNames) ?? "" + } + + private static func firstNonBlank(_ dict: [AnyHashable: Any], keys: [String]) -> String? { + for key in keys { + if let value = dict[key] as? String, !value.trimmingCharacters(in: .whitespaces).isEmpty { + return value + } + } + return nil + } }