Фикс: имя файла в пересланных сообщениях, потеря фоток/файлов при пересылке forwarded-сообщений, Фоллбэк при unwrap forwarded-сообщения, защита БД от перезаписи синком
This commit is contained in:
@@ -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" */;
|
||||
|
||||
@@ -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)
|
||||
|
||||
248
Rosetta/Core/Data/Database/DatabaseManager.swift
Normal file
248
Rosetta/Core/Data/Database/DatabaseManager.swift
Normal 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"))
|
||||
}
|
||||
}
|
||||
89
Rosetta/Core/Data/Database/DatabaseMigrationFromJSON.swift
Normal file
89
Rosetta/Core/Data/Database/DatabaseMigrationFromJSON.swift
Normal 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
|
||||
}
|
||||
}
|
||||
113
Rosetta/Core/Data/Database/DialogRecord.swift
Normal file
113
Rosetta/Core/Data/Database/DialogRecord.swift
Normal 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
|
||||
)
|
||||
}
|
||||
}
|
||||
111
Rosetta/Core/Data/Database/MessageRecord.swift
Normal file
111
Rosetta/Core/Data/Database/MessageRecord.swift
Normal 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
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
100
Rosetta/Features/Chats/ChatDetail/ChatDetailSkeletonView.swift
Normal file
100
Rosetta/Features/Chats/ChatDetail/ChatDetailSkeletonView.swift
Normal 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)
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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] : ""
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user