Фикс: имя файла в пересланных сообщениях, потеря фоток/файлов при пересылке forwarded-сообщений, Фоллбэк при unwrap forwarded-сообщения, защита БД от перезаписи синком

This commit is contained in:
2026-03-21 20:28:11 +05:00
parent 224b8a2b54
commit 65e5991f97
24 changed files with 2715 additions and 1037 deletions

View File

@@ -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" */;

View File

@@ -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)

View File

@@ -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<T>(_ 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<T>(_ 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<T>(_ 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"))
}
}

View File

@@ -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
}
}

View File

@@ -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
)
}
}

View File

@@ -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
)
}
}

View File

@@ -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)

View File

@@ -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 {

View File

@@ -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<Void, Never>?
// 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<Void, Never>?
private var lastBadgeTotal: Int = -1
@ObservationIgnored private var badgeUpdateTask: Task<Void, Never>?
@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
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -57,6 +57,23 @@ final class ProtocolManager: @unchecked Sendable {
private var handshakeComplete = false
private var heartbeatTask: Task<Void, Never>?
private var handshakeTimeoutTask: Task<Void, Never>?
private var pingTimeoutTask: Task<Void, Never>?
/// 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)
}
}

View File

@@ -14,6 +14,9 @@ final class WebSocketClient: NSObject, @unchecked Sendable, URLSessionWebSocketD
private var reconnectTask: Task<Void, Never>?
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 }

View File

@@ -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<String> = []
private var requestedUserInfoKeys: Set<String> = []
private var onlineSubscribedKeys: Set<String> = []
private var pendingOutgoingRetryTasks: [String: Task<Void, Never>] = [:]
@@ -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()
}
}

View File

@@ -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..<Self.bubbleSpecs.count, id: \.self) { index in
let spec = Self.bubbleSpecs[index]
skeletonBubble(
outgoing: spec.outgoing,
width: maxBubbleWidth * spec.widthFrac,
height: spec.height,
phase: phase
)
}
}
.padding(.horizontal, 8)
.padding(.top, 8)
.padding(.bottom, 80)
}
.scrollIndicators(.hidden)
.allowsHitTesting(false)
}
}
// MARK: - Skeleton Bubble
@ViewBuilder
private func skeletonBubble(
outgoing: Bool,
width: CGFloat,
height: CGFloat,
phase: CGFloat
) -> 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)
)
}
}

View File

@@ -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
)

View File

@@ -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<AnyCancellable>()
@@ -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)

View File

@@ -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<Content: View>(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] : ""

View File

@@ -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)
}
}

View File

@@ -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)

View File

@@ -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
}
}

View File

@@ -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 {

View File

@@ -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)
}

View File

@@ -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)

View File

@@ -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
}
}