diff --git a/.gitignore b/.gitignore index b629bd8..f407a48 100644 --- a/.gitignore +++ b/.gitignore @@ -7,6 +7,7 @@ CLAUDE.md .claude.local.md desktop server +docs Telegram-iOS AGENTS.md diff --git a/Rosetta.xcodeproj/project.pbxproj b/Rosetta.xcodeproj/project.pbxproj index 4254828..9b1b9e4 100644 --- a/Rosetta.xcodeproj/project.pbxproj +++ b/Rosetta.xcodeproj/project.pbxproj @@ -7,12 +7,19 @@ objects = { /* Begin PBXBuildFile section */ + 3146EDCE68162995CB5D1034 /* BehaviorParityFixtureTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C9FC5C4F7E26FAFEC47C1D51 /* BehaviorParityFixtureTests.swift */; }; + 3C4D5E6F708192A3B4C5D6E7 /* AttachmentParityTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1A2B3C4D5E6F708192A3B4C5 /* AttachmentParityTests.swift */; }; 4C9BDB443750F7003CFB705C /* Foundation.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 272B862BE4D99E7DD751CC3E /* Foundation.framework */; }; + 4D5E6F708192A3B4C5D6E7F8 /* SearchParityTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2B3C4D5E6F708192A3B4C5D6 /* SearchParityTests.swift */; }; + 806C964D76E024430307C151 /* 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 */; }; + CC5AD9236E3B3BA95A0C29EC /* DBTestSupport.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4D3AF08B754B66DE17AF486D /* DBTestSupport.swift */; }; D0BD72A9646880B604F1AC3C /* RosettaNotificationService.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = A182B0EDE5C68E7C6F1FB6D1 /* RosettaNotificationService.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; + D60B2E657D691F256B5B7FD4 /* SchemaParityTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7F4769EEC8ABADB3AA98D3A5 /* SchemaParityTests.swift */; }; DA91A59FDC04C2EBE77550F4 /* NotificationService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0F43A41D5496A62870E307FC /* NotificationService.swift */; }; + EC5DFA298C697AE235323240 /* MigrationHarnessTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBAA4AD95B61886B5A22EF0D /* MigrationHarnessTests.swift */; }; F1A000012F6F00010092AD05 /* FirebaseAnalyticsWithoutAdIdSupport in Frameworks */ = {isa = PBXBuildFile; productRef = F1A000022F6F00010092AD05 /* FirebaseAnalyticsWithoutAdIdSupport */; }; F1A000032F6F00010092AD05 /* FirebaseMessaging in Frameworks */ = {isa = PBXBuildFile; productRef = F1A000042F6F00010092AD05 /* FirebaseMessaging */; }; F1A000062F6F00010092AD05 /* FirebaseCrashlytics in Frameworks */ = {isa = PBXBuildFile; productRef = F1A000072F6F00010092AD05 /* FirebaseCrashlytics */; }; @@ -26,6 +33,13 @@ remoteGlobalIDString = E47730762E9823BA2D02A197; remoteInfo = RosettaNotificationService; }; + D1E9D598009C8306B116CA87 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 853F295A2F4B50410092AD05 /* Project object */; + proxyType = 1; + remoteGlobalIDString = 853F29612F4B50410092AD05; + remoteInfo = Rosetta; + }; /* End PBXContainerItemProxy section */ /* Begin PBXCopyFilesBuildPhase section */ @@ -44,10 +58,17 @@ /* Begin PBXFileReference section */ 0F43A41D5496A62870E307FC /* NotificationService.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = NotificationService.swift; sourceTree = ""; }; + 1A2B3C4D5E6F708192A3B4C5 /* AttachmentParityTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = AttachmentParityTests.swift; sourceTree = ""; }; + 2B3C4D5E6F708192A3B4C5D6 /* SearchParityTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = SearchParityTests.swift; sourceTree = ""; }; 272B862BE4D99E7DD751CC3E /* Foundation.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = Foundation.framework; path = Platforms/iPhoneOS.platform/Developer/SDKs/iPhoneOS18.0.sdk/System/Library/Frameworks/Foundation.framework; sourceTree = DEVELOPER_DIR; }; + 4D3AF08B754B66DE17AF486D /* DBTestSupport.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = DBTestSupport.swift; sourceTree = ""; }; + 75BA8A97FE297E450BB1452E /* RosettaTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = RosettaTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; + 7F4769EEC8ABADB3AA98D3A5 /* SchemaParityTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = SchemaParityTests.swift; sourceTree = ""; }; 853F29622F4B50410092AD05 /* Rosetta.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Rosetta.app; sourceTree = BUILT_PRODUCTS_DIR; }; 93685A4F330DCD1B63EF121F /* Info.plist */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; A182B0EDE5C68E7C6F1FB6D1 /* RosettaNotificationService.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = RosettaNotificationService.appex; sourceTree = BUILT_PRODUCTS_DIR; }; + C9FC5C4F7E26FAFEC47C1D51 /* BehaviorParityFixtureTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = BehaviorParityFixtureTests.swift; sourceTree = ""; }; + DBAA4AD95B61886B5A22EF0D /* MigrationHarnessTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = MigrationHarnessTests.swift; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFileSystemSynchronizedRootGroup section */ @@ -59,6 +80,14 @@ /* End PBXFileSystemSynchronizedRootGroup section */ /* Begin PBXFrameworksBuildPhase section */ + 392BE571D30FB1DDB2423F0D /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + 806C964D76E024430307C151 /* Foundation.framework in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; 853F295F2F4B50410092AD05 /* Frameworks */ = { isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; @@ -83,6 +112,19 @@ /* End PBXFrameworksBuildPhase section */ /* Begin PBXGroup section */ + 0D5BD0581AA976925F688CDA /* RosettaTests */ = { + isa = PBXGroup; + children = ( + 1A2B3C4D5E6F708192A3B4C5 /* AttachmentParityTests.swift */, + C9FC5C4F7E26FAFEC47C1D51 /* BehaviorParityFixtureTests.swift */, + 4D3AF08B754B66DE17AF486D /* DBTestSupport.swift */, + DBAA4AD95B61886B5A22EF0D /* MigrationHarnessTests.swift */, + 7F4769EEC8ABADB3AA98D3A5 /* SchemaParityTests.swift */, + 2B3C4D5E6F708192A3B4C5D6 /* SearchParityTests.swift */, + ); + path = RosettaTests; + sourceTree = ""; + }; 32A246700D4A2618B3F81039 /* iOS */ = { isa = PBXGroup; children = ( @@ -98,6 +140,7 @@ 853F29632F4B50410092AD05 /* Products */, 95676C1A4D239B1FF9E73782 /* Frameworks */, BA35C0165A0371B32DD8B4C0 /* RosettaNotificationService */, + 0D5BD0581AA976925F688CDA /* RosettaTests */, ); sourceTree = ""; }; @@ -106,6 +149,7 @@ children = ( 853F29622F4B50410092AD05 /* Rosetta.app */, A182B0EDE5C68E7C6F1FB6D1 /* RosettaNotificationService.appex */, + 75BA8A97FE297E450BB1452E /* RosettaTests.xctest */, ); name = Products; sourceTree = ""; @@ -130,6 +174,24 @@ /* End PBXGroup section */ /* Begin PBXNativeTarget section */ + 219188CF4FCBF8E8CF11BEC2 /* RosettaTests */ = { + isa = PBXNativeTarget; + buildConfigurationList = 2C9787043011C6880A9B5CFD /* Build configuration list for PBXNativeTarget "RosettaTests" */; + buildPhases = ( + 898AFF6966F70D158AA7D3A5 /* Sources */, + 392BE571D30FB1DDB2423F0D /* Frameworks */, + 7158F51EC726FE216D7D8374 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + 48C7E25DF2079AC460C3DCE2 /* PBXTargetDependency */, + ); + name = RosettaTests; + productName = RosettaTests; + productReference = 75BA8A97FE297E450BB1452E /* RosettaTests.xctest */; + productType = "com.apple.product-type.bundle.unit-test"; + }; 853F29612F4B50410092AD05 /* Rosetta */ = { isa = PBXNativeTarget; buildConfigurationList = 853F296D2F4B50420092AD05 /* Build configuration list for PBXNativeTarget "Rosetta" */; @@ -217,11 +279,19 @@ targets = ( 853F29612F4B50410092AD05 /* Rosetta */, E47730762E9823BA2D02A197 /* RosettaNotificationService */, + 219188CF4FCBF8E8CF11BEC2 /* RosettaTests */, ); }; /* End PBXProject section */ /* Begin PBXResourcesBuildPhase section */ + 7158F51EC726FE216D7D8374 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; 853F29602F4B50410092AD05 /* Resources */ = { isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; @@ -246,6 +316,19 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + 898AFF6966F70D158AA7D3A5 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 3C4D5E6F708192A3B4C5D6E7 /* AttachmentParityTests.swift in Sources */, + 3146EDCE68162995CB5D1034 /* BehaviorParityFixtureTests.swift in Sources */, + CC5AD9236E3B3BA95A0C29EC /* DBTestSupport.swift in Sources */, + EC5DFA298C697AE235323240 /* MigrationHarnessTests.swift in Sources */, + D60B2E657D691F256B5B7FD4 /* SchemaParityTests.swift in Sources */, + 4D5E6F708192A3B4C5D6E7F8 /* SearchParityTests.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; A624149985830F8CA8C2E52D /* Sources */ = { isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; @@ -263,6 +346,12 @@ target = E47730762E9823BA2D02A197 /* RosettaNotificationService */; targetProxy = 6AADF4618CA423BB75F12BF1 /* PBXContainerItemProxy */; }; + 48C7E25DF2079AC460C3DCE2 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + name = Rosetta; + target = 853F29612F4B50410092AD05 /* Rosetta */; + targetProxy = D1E9D598009C8306B116CA87 /* PBXContainerItemProxy */; + }; /* End PBXTargetDependency section */ /* Begin XCBuildConfiguration section */ @@ -522,9 +611,53 @@ }; name = Release; }; + 9CC9EC814343CC91E1C020A3 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CLANG_ENABLE_OBJC_WEAK = NO; + CODE_SIGN_STYLE = Automatic; + DEVELOPMENT_TEAM = QN8Z263QGX; + GENERATE_INFOPLIST_FILE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 17.0; + PRODUCT_BUNDLE_IDENTIFIER = com.rosetta.devTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = iphoneos; + SWIFT_VERSION = 5.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Rosetta.app/Rosetta"; + }; + name = Debug; + }; + C19929D9466573F31997B2C0 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CLANG_ENABLE_OBJC_WEAK = NO; + CODE_SIGN_STYLE = Automatic; + DEVELOPMENT_TEAM = QN8Z263QGX; + GENERATE_INFOPLIST_FILE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 17.0; + PRODUCT_BUNDLE_IDENTIFIER = com.rosetta.devTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = iphoneos; + SWIFT_VERSION = 5.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Rosetta.app/Rosetta"; + VALIDATE_PRODUCT = YES; + }; + name = Release; + }; /* End XCBuildConfiguration section */ /* Begin XCConfigurationList section */ + 2C9787043011C6880A9B5CFD /* Build configuration list for PBXNativeTarget "RosettaTests" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 9CC9EC814343CC91E1C020A3 /* Debug */, + C19929D9466573F31997B2C0 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Debug; + }; 853F295D2F4B50410092AD05 /* Build configuration list for PBXProject "Rosetta" */ = { isa = XCConfigurationList; buildConfigurations = ( diff --git a/Rosetta.xcodeproj/xcshareddata/xcschemes/Rosetta.xcscheme b/Rosetta.xcodeproj/xcshareddata/xcschemes/Rosetta.xcscheme index 104d1d6..39a5209 100644 --- a/Rosetta.xcodeproj/xcshareddata/xcschemes/Rosetta.xcscheme +++ b/Rosetta.xcodeproj/xcshareddata/xcschemes/Rosetta.xcscheme @@ -1,7 +1,7 @@ + version = "1.3"> + + + + + shouldUseLaunchSchemeArgsEnv = "YES"> + + + + + + + + + + last_sync + THEN (SELECT s.timestamp FROM sync_cursors s WHERE s.account = accounts_sync_times.account) + ELSE last_sync + END + WHERE account IN (SELECT account FROM sync_cursors) """ ) } @@ -393,6 +420,7 @@ final class DatabaseManager { WHEN dialogs.account = dialogs.opponent_key THEN dialogs.account WHEN LOWER(TRIM(dialogs.opponent_key)) LIKE '#group:%' THEN dialogs.opponent_key WHEN LOWER(TRIM(dialogs.opponent_key)) LIKE 'group:%' THEN dialogs.opponent_key + WHEN LOWER(TRIM(dialogs.opponent_key)) LIKE 'conversation:%' THEN dialogs.opponent_key WHEN dialogs.account < dialogs.opponent_key THEN dialogs.account || ':' || dialogs.opponent_key ELSE dialogs.opponent_key || ':' || dialogs.account END @@ -421,6 +449,7 @@ final class DatabaseManager { WHEN NEW.account = NEW.opponent_key THEN NEW.account WHEN LOWER(TRIM(NEW.opponent_key)) LIKE '#group:%' THEN NEW.opponent_key WHEN LOWER(TRIM(NEW.opponent_key)) LIKE 'group:%' THEN NEW.opponent_key + WHEN LOWER(TRIM(NEW.opponent_key)) LIKE 'conversation:%' THEN NEW.opponent_key WHEN NEW.account < NEW.opponent_key THEN NEW.account || ':' || NEW.opponent_key ELSE NEW.opponent_key || ':' || NEW.account END @@ -435,6 +464,7 @@ final class DatabaseManager { WHEN NEW.account = NEW.opponent_key THEN NEW.account WHEN LOWER(TRIM(NEW.opponent_key)) LIKE '#group:%' THEN NEW.opponent_key WHEN LOWER(TRIM(NEW.opponent_key)) LIKE 'group:%' THEN NEW.opponent_key + WHEN LOWER(TRIM(NEW.opponent_key)) LIKE 'conversation:%' THEN NEW.opponent_key WHEN NEW.account < NEW.opponent_key THEN NEW.account || ':' || NEW.opponent_key ELSE NEW.opponent_key || ':' || NEW.account END @@ -462,6 +492,7 @@ final class DatabaseManager { WHEN NEW.account = NEW.opponent_key THEN NEW.account WHEN LOWER(TRIM(NEW.opponent_key)) LIKE '#group:%' THEN NEW.opponent_key WHEN LOWER(TRIM(NEW.opponent_key)) LIKE 'group:%' THEN NEW.opponent_key + WHEN LOWER(TRIM(NEW.opponent_key)) LIKE 'conversation:%' THEN NEW.opponent_key WHEN NEW.account < NEW.opponent_key THEN NEW.account || ':' || NEW.opponent_key ELSE NEW.opponent_key || ':' || NEW.account END @@ -476,6 +507,7 @@ final class DatabaseManager { WHEN NEW.account = NEW.opponent_key THEN NEW.account WHEN LOWER(TRIM(NEW.opponent_key)) LIKE '#group:%' THEN NEW.opponent_key WHEN LOWER(TRIM(NEW.opponent_key)) LIKE 'group:%' THEN NEW.opponent_key + WHEN LOWER(TRIM(NEW.opponent_key)) LIKE 'conversation:%' THEN NEW.opponent_key WHEN NEW.account < NEW.opponent_key THEN NEW.account || ':' || NEW.opponent_key ELSE NEW.opponent_key || ':' || NEW.account END @@ -555,6 +587,202 @@ final class DatabaseManager { } } + // v6: enforce bidirectional alias sync so canonical and iOS-native writes stay semantically identical. + migrator.registerMigration("v6_bidirectional_alias_sync") { db in + // MARK: - messages alias <-> native sync + + try db.execute(sql: "DROP TRIGGER IF EXISTS trg_messages_ai_sync_aliases") + try db.execute(sql: "DROP TRIGGER IF EXISTS trg_messages_au_sync_aliases") + try db.execute(sql: "DROP TRIGGER IF EXISTS trg_messages_au_sync_native") + + // INSERT normalization: supports both native-first and canonical-first writes. + try db.execute( + sql: """ + CREATE TRIGGER trg_messages_ai_sync_aliases + AFTER INSERT ON messages + BEGIN + UPDATE messages + SET is_read = CASE + WHEN NEW.is_read != NEW.read THEN + CASE WHEN NEW.read != 0 THEN NEW.read ELSE NEW.is_read END + ELSE NEW.is_read + END, + read = CASE + WHEN NEW.is_read != NEW.read THEN + CASE WHEN NEW.read != 0 THEN NEW.read ELSE NEW.is_read END + ELSE NEW.read + END, + delivery_status = CASE + WHEN NEW.delivery_status = 0 AND NEW.delivered != 0 THEN NEW.delivered + ELSE NEW.delivery_status + END, + delivered = CASE + WHEN NEW.delivery_status = 0 AND NEW.delivered != 0 THEN NEW.delivered + ELSE NEW.delivery_status + END, + text = CASE + WHEN NEW.text = '' AND NEW.plain_message != '' THEN NEW.plain_message + ELSE NEW.text + END, + plain_message = CASE + WHEN NEW.plain_message = '' THEN NEW.text + ELSE NEW.plain_message + END + WHERE id = NEW.id; + END + """ + ) + + // Native -> canonical sync. + try db.execute( + sql: """ + CREATE TRIGGER trg_messages_au_sync_aliases + AFTER UPDATE OF is_read, delivery_status, text ON messages + BEGIN + UPDATE messages + SET read = NEW.is_read, + delivered = NEW.delivery_status, + plain_message = CASE + WHEN NEW.plain_message = '' THEN NEW.text + ELSE NEW.plain_message + END + WHERE id = NEW.id + AND (read != NEW.is_read OR delivered != NEW.delivery_status OR plain_message != CASE + WHEN NEW.plain_message = '' THEN NEW.text + ELSE NEW.plain_message + END); + END + """ + ) + + // Canonical -> native sync. + try db.execute( + sql: """ + CREATE TRIGGER trg_messages_au_sync_native + AFTER UPDATE OF read, delivered, plain_message ON messages + BEGIN + UPDATE messages + SET is_read = NEW.read, + delivery_status = NEW.delivered, + text = CASE + WHEN NEW.plain_message = '' THEN NEW.text + ELSE NEW.plain_message + END + WHERE id = NEW.id + AND (is_read != NEW.read OR delivery_status != NEW.delivered OR text != CASE + WHEN NEW.plain_message = '' THEN NEW.text + ELSE NEW.plain_message + END); + END + """ + ) + + // MARK: - dialogs alias -> native compatibility mapping + + try db.execute(sql: "DROP TRIGGER IF EXISTS trg_dialogs_au_sync_native") + try db.execute( + sql: """ + CREATE TRIGGER trg_dialogs_au_sync_native + AFTER UPDATE OF dialog_id, last_timestamp, is_request ON dialogs + BEGIN + UPDATE dialogs + SET opponent_key = CASE + WHEN NEW.dialog_id = '' THEN NEW.opponent_key + ELSE NEW.dialog_id + END, + last_message_timestamp = CASE + WHEN NEW.last_timestamp = 0 THEN NEW.last_message_timestamp + ELSE NEW.last_timestamp + END, + i_have_sent = CASE + WHEN NEW.is_request = 1 THEN 0 + ELSE 1 + END + WHERE id = NEW.id + AND ( + opponent_key != CASE + WHEN NEW.dialog_id = '' THEN NEW.opponent_key + ELSE NEW.dialog_id + END + OR last_message_timestamp != CASE + WHEN NEW.last_timestamp = 0 THEN NEW.last_message_timestamp + ELSE NEW.last_timestamp + END + OR i_have_sent != CASE + WHEN NEW.is_request = 1 THEN 0 + ELSE 1 + END + ); + END + """ + ) + } + + // v7: reconcile sync cursor data safely on all SQLite variants and add perf indexes. + migrator.registerMigration("v7_sync_cursor_reconcile_and_perf_indexes") { db in + func hasColumn(_ table: String, _ column: String) throws -> Bool { + try db.columns(in: table).contains { $0.name == column } + } + + try db.execute( + sql: """ + CREATE TABLE IF NOT EXISTS accounts_sync_times ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + account TEXT NOT NULL UNIQUE, + last_sync INTEGER NOT NULL + ) + """ + ) + + if try !hasColumn("accounts_sync_times", "id") { + try db.execute(sql: "ALTER TABLE accounts_sync_times ADD COLUMN id INTEGER") + } + + if try db.tableExists("sync_cursors") { + try db.execute( + sql: """ + INSERT OR IGNORE INTO accounts_sync_times (account, last_sync) + SELECT account, timestamp FROM sync_cursors + """ + ) + try db.execute( + sql: """ + UPDATE accounts_sync_times + SET last_sync = CASE + WHEN COALESCE( + (SELECT s.timestamp FROM sync_cursors s WHERE s.account = accounts_sync_times.account), + last_sync + ) > last_sync + THEN (SELECT s.timestamp FROM sync_cursors s WHERE s.account = accounts_sync_times.account) + ELSE last_sync + END + WHERE account IN (SELECT account FROM sync_cursors) + """ + ) + } + + try db.execute( + sql: """ + UPDATE accounts_sync_times + SET id = rowid + WHERE id IS NULL OR id = 0 + """ + ) + + try db.execute( + sql: """ + CREATE INDEX IF NOT EXISTS idx_messages_account_dialog_fromme_isread + ON messages(account, dialog_key, from_me, is_read) + """ + ) + try db.execute( + sql: """ + CREATE INDEX IF NOT EXISTS idx_messages_account_dialog_fromme_timestamp + ON messages(account, dialog_key, from_me, timestamp) + """ + ) + } + try migrator.migrate(pool) dbPool = pool @@ -600,7 +828,7 @@ final class DatabaseManager { // MARK: - Database URL - private static func databaseURL(for accountPublicKey: String) -> URL { + nonisolated 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 @@ -614,7 +842,12 @@ final class DatabaseManager { return dir.appendingPathComponent("rosetta_\(normalized).sqlite") } - private static func normalizedKey(_ key: String) -> String { + /// Internal test-support accessor for deterministic DB path setup in unit tests. + nonisolated static func databaseURLForTesting(accountPublicKey: String) -> URL { + databaseURL(for: accountPublicKey) + } + + nonisolated 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) : "_" }) @@ -622,13 +855,29 @@ final class DatabaseManager { // MARK: - Dialog Key (Android parity) + /// Returns true for group/conversation dialog identifiers that should not be pair-sorted. + nonisolated static func isGroupDialogKey(_ value: String) -> Bool { + let normalized = value.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + guard !normalized.isEmpty else { return false } + return normalized.hasPrefix("#group:") + || normalized.hasPrefix("group:") + || normalized.hasPrefix("conversation:") + } + /// 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 { + let normalizedAccount = account.trimmingCharacters(in: .whitespacesAndNewlines) + let normalizedOpponent = opponentKey.trimmingCharacters(in: .whitespacesAndNewlines) + + // Group/conversation dialogs keep stable server-provided identity. + if isGroupDialogKey(normalizedOpponent) { return normalizedOpponent } // Saved Messages: dialogKey = account - if account == opponentKey { return account } + if normalizedAccount == normalizedOpponent { return normalizedAccount } // Normal: lexicographic sort - return account < opponentKey ? "\(account):\(opponentKey)" : "\(opponentKey):\(account)" + return normalizedAccount < normalizedOpponent + ? "\(normalizedAccount):\(normalizedOpponent)" + : "\(normalizedOpponent):\(normalizedAccount)" } // MARK: - Sync Cursor (Android parity: SQLite, not UserDefaults) @@ -684,10 +933,21 @@ final class DatabaseManager { sql: """ INSERT INTO accounts_sync_times (account, last_sync) VALUES (?, ?) ON CONFLICT(account) DO UPDATE SET last_sync = excluded.last_sync - """, + """, arguments: [account, timestamp] ) + if try db.columns(in: "accounts_sync_times").contains(where: { $0.name == "id" }) { + try db.execute( + sql: """ + UPDATE accounts_sync_times + SET id = rowid + WHERE account = ? AND (id IS NULL OR id = 0) + """, + arguments: [account] + ) + } + if try db.tableExists("sync_cursors") { try db.execute( sql: """ @@ -709,3 +969,195 @@ final class DatabaseManager { try? FileManager.default.removeItem(at: URL(fileURLWithPath: url.path + "-shm")) } } + +// MARK: - Group Repository (SQLite) + +@MainActor +final class GroupRepository { + static let shared = GroupRepository() + + private static let groupInvitePassword = "rosetta_group" + private let db = DatabaseManager.shared + + private init() {} + + struct GroupMetadata: Equatable { + let title: String + let description: String + } + + private struct ParsedGroupInvite { + let groupId: String + let title: String + let encryptKey: String + let description: String + } + + func isGroupDialog(_ value: String) -> Bool { + DatabaseManager.isGroupDialogKey(value) + } + + func normalizeGroupId(_ value: String) -> String { + let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines) + let lower = trimmed.lowercased() + if lower.hasPrefix("#group:") { + return String(trimmed.dropFirst("#group:".count)).trimmingCharacters(in: .whitespacesAndNewlines) + } + if lower.hasPrefix("group:") { + return String(trimmed.dropFirst("group:".count)).trimmingCharacters(in: .whitespacesAndNewlines) + } + if lower.hasPrefix("conversation:") { + return String(trimmed.dropFirst("conversation:".count)).trimmingCharacters(in: .whitespacesAndNewlines) + } + return trimmed + } + + func toGroupDialogKey(_ value: String) -> String { + let normalized = normalizeGroupId(value) + guard !normalized.isEmpty else { + return value.trimmingCharacters(in: .whitespacesAndNewlines) + } + return "#group:\(normalized)" + } + + func groupKey(account: String, privateKeyHex: String, groupDialogKey: String) -> String? { + let groupId = normalizeGroupId(groupDialogKey) + guard !groupId.isEmpty else { return nil } + + do { + let storedKey = try db.read { db in + try String.fetchOne( + db, + sql: """ + SELECT "key" + FROM groups + WHERE account = ? AND group_id = ? + LIMIT 1 + """, + arguments: [account, groupId] + ) + } + guard let storedKey, !storedKey.isEmpty else { return nil } + + if let decrypted = try? CryptoManager.shared.decryptWithPassword(storedKey, password: privateKeyHex), + let plain = String(data: decrypted, encoding: .utf8), + !plain.isEmpty { + return plain + } + + // Backward compatibility: tolerate legacy plain stored keys. + return storedKey + } catch { + return nil + } + } + + func groupMetadata(account: String, groupDialogKey: String) -> GroupMetadata? { + let groupId = normalizeGroupId(groupDialogKey) + guard !groupId.isEmpty else { return nil } + + do { + return try db.read { db in + guard let row = try Row.fetchOne( + db, + sql: """ + SELECT title, description + FROM groups + WHERE account = ? AND group_id = ? + LIMIT 1 + """, + arguments: [account, groupId] + ) else { + return nil + } + return GroupMetadata( + title: row["title"], + description: row["description"] + ) + } + } catch { + return nil + } + } + + @discardableResult + func upsertFromGroupJoin( + account: String, + privateKeyHex: String, + packet: PacketGroupJoin + ) -> String? { + guard packet.status == .joined else { return nil } + guard !packet.groupString.isEmpty else { return nil } + + guard let decryptedInviteData = try? CryptoManager.shared.decryptWithPassword( + packet.groupString, + password: privateKeyHex + ), let inviteString = String(data: decryptedInviteData, encoding: .utf8), + let parsed = parseGroupInviteString(inviteString) + else { + return nil + } + + guard let encryptedGroupKey = try? CryptoManager.shared.encryptWithPassword( + Data(parsed.encryptKey.utf8), + password: privateKeyHex + ) else { + return nil + } + + do { + try db.writeSync { db in + try db.execute( + sql: """ + INSERT INTO groups (account, group_id, title, description, "key") + VALUES (?, ?, ?, ?, ?) + ON CONFLICT(account, group_id) DO UPDATE SET + title = excluded.title, + description = excluded.description, + "key" = excluded."key" + """, + arguments: [account, parsed.groupId, parsed.title, parsed.description, encryptedGroupKey] + ) + } + } catch { + return nil + } + + return toGroupDialogKey(parsed.groupId) + } + + private func parseGroupInviteString(_ inviteString: String) -> ParsedGroupInvite? { + let trimmed = inviteString.trimmingCharacters(in: .whitespacesAndNewlines) + let lower = trimmed.lowercased() + + let encodedPayload: String + if lower.hasPrefix("#group:") { + encodedPayload = String(trimmed.dropFirst("#group:".count)) + } else if lower.hasPrefix("group:") { + encodedPayload = String(trimmed.dropFirst("group:".count)) + } else { + return nil + } + guard !encodedPayload.isEmpty else { return nil } + + guard let decryptedPayload = try? CryptoManager.shared.decryptWithPassword( + encodedPayload, + password: Self.groupInvitePassword + ), let payload = String(data: decryptedPayload, encoding: .utf8) else { + return nil + } + + let parts = payload.split(separator: ":", omittingEmptySubsequences: false).map(String.init) + guard parts.count >= 3 else { return nil } + + let groupId = normalizeGroupId(parts[0]) + guard !groupId.isEmpty else { return nil } + + return ParsedGroupInvite( + groupId: groupId, + title: parts[1], + encryptKey: parts[2], + description: parts.dropFirst(3).joined(separator: ":") + ) + } +} diff --git a/Rosetta/Core/Data/Models/AttachmentCache.swift b/Rosetta/Core/Data/Models/AttachmentCache.swift index 3c8d2f5..37e37ae 100644 --- a/Rosetta/Core/Data/Models/AttachmentCache.swift +++ b/Rosetta/Core/Data/Models/AttachmentCache.swift @@ -103,6 +103,12 @@ final class AttachmentCache: @unchecked Sendable { // MARK: - Images + /// Returns an image only if it's already in in-memory cache. + /// Never touches disk/crypto — safe for hot UI paths (scrolling/layout). + nonisolated func cachedImage(forAttachmentId id: String) -> UIImage? { + imageCache.object(forKey: id as NSString) + } + /// Saves a decoded image to cache, encrypted with private key (Android parity). nonisolated func saveImage(_ image: UIImage, forAttachmentId id: String) { guard let data = image.jpegData(compressionQuality: 0.95) else { return } diff --git a/Rosetta/Core/Data/Repositories/DialogRepository.swift b/Rosetta/Core/Data/Repositories/DialogRepository.swift index ba24f4c..fd658c6 100644 --- a/Rosetta/Core/Data/Repositories/DialogRepository.swift +++ b/Rosetta/Core/Data/Repositories/DialogRepository.swift @@ -118,6 +118,7 @@ final class DialogRepository { let account = currentAccount let isSystem = SystemAccounts.isSystemAccount(opponentKey) let isSavedMessages = account == opponentKey + let isGroupDialog = DatabaseManager.isGroupDialogKey(opponentKey) // Preserve fields not derived from messages let existing = dialogs[opponentKey] @@ -163,6 +164,7 @@ final class DialogRepository { case .file: lastMessageText = "File" case .avatar: lastMessageText = "Avatar" case .messages: lastMessageText = "Forwarded message" + case .call: lastMessageText = "Call" } } else if textIsEmpty { lastMessageText = "" @@ -187,7 +189,8 @@ final class DialogRepository { dialog.lastMessage = lastMessageText dialog.lastMessageTimestamp = lastMsg.timestamp dialog.unreadCount = unread - dialog.iHaveSent = hasSent || isSystem + // DB+Flow Safe: group dialogs are always treated as chat (not request). + dialog.iHaveSent = hasSent || isSystem || isGroupDialog dialog.lastMessageFromMe = lastFromMe dialog.lastMessageDelivered = lastFromMe ? lastMsg.deliveryStatus : .delivered // Android parity: separate read flag from last outgoing message's is_read column. @@ -203,6 +206,7 @@ final class DialogRepository { opponentKey: String, title: String, username: String, verified: Int = 0, myPublicKey: String ) { + let isGroupDialog = DatabaseManager.isGroupDialogKey(opponentKey) if var existing = dialogs[opponentKey] { var changed = false if !title.isEmpty, existing.opponentTitle != title { @@ -214,6 +218,9 @@ final class DialogRepository { if verified > existing.verified { existing.verified = verified; changed = true } + if isGroupDialog, !existing.iHaveSent { + existing.iHaveSent = true; changed = true + } guard changed else { return } dialogs[opponentKey] = existing persistDialog(existing) @@ -225,7 +232,7 @@ final class DialogRepository { opponentTitle: title, opponentUsername: username, lastMessage: "", lastMessageTimestamp: 0, unreadCount: 0, isOnline: false, lastSeen: 0, verified: verified, - iHaveSent: false, isPinned: false, isMuted: false, + iHaveSent: isGroupDialog, isPinned: false, isMuted: false, lastMessageFromMe: false, lastMessageDelivered: .waiting, lastMessageRead: false ) diff --git a/Rosetta/Core/Data/Repositories/MessageRepository.swift b/Rosetta/Core/Data/Repositories/MessageRepository.swift index b3de786..656cdad 100644 --- a/Rosetta/Core/Data/Repositories/MessageRepository.swift +++ b/Rosetta/Core/Data/Repositories/MessageRepository.swift @@ -280,11 +280,18 @@ final class MessageRepository: ObservableObject { myPublicKey: String, decryptedText: String, attachmentPassword: String? = nil, - fromSync: Bool = false + fromSync: Bool = false, + dialogIdentityOverride: String? = nil ) { PerformanceLogger.shared.track("message.upsert") let fromMe = packet.fromPublicKey == myPublicKey - let opponentKey = fromMe ? packet.toPublicKey : packet.fromPublicKey + let opponentKey: String = { + if let override = dialogIdentityOverride?.trimmingCharacters(in: .whitespacesAndNewlines), + !override.isEmpty { + return override + } + return fromMe ? packet.toPublicKey : packet.fromPublicKey + }() let messageId = packet.messageId.isEmpty ? UUID().uuidString : packet.messageId let timestamp = normalizeTimestamp(packet.timestamp) let dialogKey = DatabaseManager.dialogKey(account: myPublicKey, opponentKey: opponentKey) diff --git a/Rosetta/Core/Layout/BubbleGeometryEngine+Tail.swift b/Rosetta/Core/Layout/BubbleGeometryEngine+Tail.swift new file mode 100644 index 0000000..ae77348 --- /dev/null +++ b/Rosetta/Core/Layout/BubbleGeometryEngine+Tail.swift @@ -0,0 +1,89 @@ +import UIKit + +extension BubbleGeometryEngine { + static func addFigmaTail( + to path: UIBezierPath, + bodyRect: CGRect, + outgoing: Bool, + tailProtrusion: CGFloat + ) { + let svgStraightX: CGFloat = 5.59961 + let svgMaxY: CGFloat = 33.2305 + let scale = tailProtrusion / svgStraightX + let tailHeight = svgMaxY * scale + + let bodyEdge = outgoing ? bodyRect.maxX : bodyRect.minX + let bottom = bodyRect.maxY + let top = bottom - tailHeight + let direction: CGFloat = outgoing ? 1 : -1 + + func point(_ svgX: CGFloat, _ svgY: CGFloat) -> CGPoint { + let dx = (svgStraightX - svgX) * scale * direction + return CGPoint(x: bodyEdge + dx, y: top + svgY * scale) + } + + if outgoing { + path.move(to: point(5.59961, 24.2305)) + path.addCurve( + to: point(0, 33.0244), + controlPoint1: point(5.42042, 28.0524), + controlPoint2: point(3.19779, 31.339) + ) + path.addCurve( + to: point(2.6123, 33.2305), + controlPoint1: point(0.851596, 33.1596), + controlPoint2: point(1.72394, 33.2305) + ) + path.addCurve( + to: point(13.0293, 29.5596), + controlPoint1: point(6.53776, 33.2305), + controlPoint2: point(10.1517, 31.8599) + ) + path.addCurve( + to: point(7.57422, 23.1719), + controlPoint1: point(10.7434, 27.898), + controlPoint2: point(8.86922, 25.7134) + ) + path.addCurve( + to: point(5.6123, 4.2002), + controlPoint1: point(5.61235, 19.3215), + controlPoint2: point(5.6123, 14.281) + ) + path.addLine(to: point(5.6123, 0)) + path.addLine(to: point(5.59961, 0)) + path.addLine(to: point(5.59961, 24.2305)) + path.close() + } else { + path.move(to: point(5.59961, 24.2305)) + path.addLine(to: point(5.59961, 0)) + path.addLine(to: point(5.6123, 0)) + path.addLine(to: point(5.6123, 4.2002)) + path.addCurve( + to: point(7.57422, 23.1719), + controlPoint1: point(5.6123, 14.281), + controlPoint2: point(5.61235, 19.3215) + ) + path.addCurve( + to: point(13.0293, 29.5596), + controlPoint1: point(8.86922, 25.7134), + controlPoint2: point(10.7434, 27.898) + ) + path.addCurve( + to: point(2.6123, 33.2305), + controlPoint1: point(10.1517, 31.8599), + controlPoint2: point(6.53776, 33.2305) + ) + path.addCurve( + to: point(0, 33.0244), + controlPoint1: point(1.72394, 33.2305), + controlPoint2: point(0.851596, 33.1596) + ) + path.addCurve( + to: point(5.59961, 24.2305), + controlPoint1: point(3.19779, 31.339), + controlPoint2: point(5.42042, 28.0524) + ) + path.close() + } + } +} diff --git a/Rosetta/Core/Layout/BubbleGeometryEngine.swift b/Rosetta/Core/Layout/BubbleGeometryEngine.swift new file mode 100644 index 0000000..2134575 --- /dev/null +++ b/Rosetta/Core/Layout/BubbleGeometryEngine.swift @@ -0,0 +1,223 @@ +import SwiftUI +import UIKit + +enum BubblePosition: Sendable, Equatable { + case single, top, mid, bottom +} + +enum BubbleMergeType: Sendable, Hashable { + case none + case top(side: Bool) + case bottom + case both + case side + case extracted +} + +struct BubbleMetrics: Sendable { + let mainRadius: CGFloat + let auxiliaryRadius: CGFloat + let tailProtrusion: CGFloat + let defaultSpacing: CGFloat + let mergedSpacing: CGFloat + let textInsets: UIEdgeInsets + let mediaStatusInsets: UIEdgeInsets + + static func telegram(screenScale: CGFloat = max(UIScreen.main.scale, 1)) -> BubbleMetrics { + let screenPixel = 1.0 / screenScale + return BubbleMetrics( + mainRadius: 16, + auxiliaryRadius: 8, + tailProtrusion: 6, + defaultSpacing: 2 + screenPixel, + mergedSpacing: 0, + textInsets: UIEdgeInsets(top: 6 + screenPixel, left: 11, bottom: 6 - screenPixel, right: 11), + mediaStatusInsets: UIEdgeInsets(top: 2, left: 7, bottom: 2, right: 7) + ) + } +} + +enum BubbleGeometryEngine { + + static func mergeType(for position: BubblePosition) -> BubbleMergeType { + switch position { + case .single: + return .none + case .top: + return .top(side: false) + case .mid: + return .both + case .bottom: + return .bottom + } + } + + static func hasTail(for mergeType: BubbleMergeType) -> Bool { + switch mergeType { + case .none, .bottom: + return true + case .top, .both, .side, .extracted: + return false + } + } + + static func hasTail(for position: BubblePosition) -> Bool { + hasTail(for: mergeType(for: position)) + } + + static func cornerRadii( + mergeType: BubbleMergeType, + outgoing: Bool, + metrics: BubbleMetrics + ) -> (topLeft: CGFloat, topRight: CGFloat, bottomLeft: CGFloat, bottomRight: CGFloat) { + var topLeftRadius: CGFloat + var topRightRadius: CGFloat + var bottomLeftRadius: CGFloat + var bottomRightRadius: CGFloat + + switch mergeType { + case .none: + topLeftRadius = metrics.mainRadius + topRightRadius = metrics.mainRadius + bottomLeftRadius = metrics.mainRadius + bottomRightRadius = metrics.mainRadius + case .both: + topLeftRadius = metrics.mainRadius + topRightRadius = metrics.auxiliaryRadius + bottomLeftRadius = metrics.mainRadius + bottomRightRadius = metrics.auxiliaryRadius + case .bottom: + topLeftRadius = metrics.mainRadius + topRightRadius = metrics.auxiliaryRadius + bottomLeftRadius = metrics.mainRadius + bottomRightRadius = metrics.mainRadius + case .side: + topLeftRadius = metrics.mainRadius + topRightRadius = metrics.mainRadius + bottomLeftRadius = metrics.auxiliaryRadius + bottomRightRadius = metrics.auxiliaryRadius + case let .top(side): + topLeftRadius = metrics.mainRadius + topRightRadius = metrics.mainRadius + bottomLeftRadius = side ? metrics.auxiliaryRadius : metrics.mainRadius + bottomRightRadius = metrics.auxiliaryRadius + case .extracted: + topLeftRadius = metrics.mainRadius + topRightRadius = metrics.mainRadius + bottomLeftRadius = metrics.mainRadius + bottomRightRadius = metrics.mainRadius + } + + if outgoing { + return (topLeftRadius, topRightRadius, bottomLeftRadius, bottomRightRadius) + } + return (topRightRadius, topLeftRadius, bottomRightRadius, bottomLeftRadius) + } + + static func bodyRect( + in rect: CGRect, + mergeType: BubbleMergeType, + outgoing: Bool, + metrics: BubbleMetrics + ) -> CGRect { + guard hasTail(for: mergeType) else { + return rect + } + if outgoing { + return CGRect( + x: rect.minX, + y: rect.minY, + width: rect.width - metrics.tailProtrusion, + height: rect.height + ) + } + return CGRect( + x: rect.minX + metrics.tailProtrusion, + y: rect.minY, + width: rect.width - metrics.tailProtrusion, + height: rect.height + ) + } + + static func makeBezierPath( + in rect: CGRect, + mergeType: BubbleMergeType, + outgoing: Bool, + metrics: BubbleMetrics = .telegram() + ) -> UIBezierPath { + let path = UIBezierPath() + let bodyRect = bodyRect(in: rect, mergeType: mergeType, outgoing: outgoing, metrics: metrics) + let radii = cornerRadii(mergeType: mergeType, outgoing: outgoing, metrics: metrics) + + let maxRadius = min(bodyRect.width, bodyRect.height) / 2 + let cTL = min(radii.topLeft, maxRadius) + let cTR = min(radii.topRight, maxRadius) + let cBL = min(radii.bottomLeft, maxRadius) + let cBR = min(radii.bottomRight, maxRadius) + + path.move(to: CGPoint(x: bodyRect.minX + cTL, y: bodyRect.minY)) + path.addLine(to: CGPoint(x: bodyRect.maxX - cTR, y: bodyRect.minY)) + path.addArc( + withCenter: CGPoint(x: bodyRect.maxX - cTR, y: bodyRect.minY + cTR), + radius: cTR, + startAngle: -.pi / 2, + endAngle: 0, + clockwise: true + ) + path.addLine(to: CGPoint(x: bodyRect.maxX, y: bodyRect.maxY - cBR)) + path.addArc( + withCenter: CGPoint(x: bodyRect.maxX - cBR, y: bodyRect.maxY - cBR), + radius: cBR, + startAngle: 0, + endAngle: .pi / 2, + clockwise: true + ) + path.addLine(to: CGPoint(x: bodyRect.minX + cBL, y: bodyRect.maxY)) + path.addArc( + withCenter: CGPoint(x: bodyRect.minX + cBL, y: bodyRect.maxY - cBL), + radius: cBL, + startAngle: .pi / 2, + endAngle: .pi, + clockwise: true + ) + path.addLine(to: CGPoint(x: bodyRect.minX, y: bodyRect.minY + cTL)) + path.addArc( + withCenter: CGPoint(x: bodyRect.minX + cTL, y: bodyRect.minY + cTL), + radius: cTL, + startAngle: .pi, + endAngle: -.pi / 2, + clockwise: true + ) + path.close() + + if hasTail(for: mergeType) { + addFigmaTail( + to: path, + bodyRect: bodyRect, + outgoing: outgoing, + tailProtrusion: metrics.tailProtrusion + ) + } + + return path + } + + static func makeCGPath( + in rect: CGRect, + mergeType: BubbleMergeType, + outgoing: Bool, + metrics: BubbleMetrics = .telegram() + ) -> CGPath { + makeBezierPath(in: rect, mergeType: mergeType, outgoing: outgoing, metrics: metrics).cgPath + } + + static func makeSwiftUIPath( + in rect: CGRect, + mergeType: BubbleMergeType, + outgoing: Bool, + metrics: BubbleMetrics = .telegram() + ) -> Path { + Path(makeBezierPath(in: rect, mergeType: mergeType, outgoing: outgoing, metrics: metrics).cgPath) + } + +} diff --git a/Rosetta/Core/Layout/MessageCellLayout.swift b/Rosetta/Core/Layout/MessageCellLayout.swift index 1978838..cb19325 100644 --- a/Rosetta/Core/Layout/MessageCellLayout.swift +++ b/Rosetta/Core/Layout/MessageCellLayout.swift @@ -23,6 +23,7 @@ struct MessageCellLayout: Sendable { let bubbleFrame: CGRect // Bubble view frame in cell coords let bubbleSize: CGSize // Bubble size (for shape path) + let mergeType: BubbleMergeType let hasTail: Bool // MARK: - Text @@ -88,6 +89,7 @@ extension MessageCellLayout { let position: BubblePosition let deliveryStatus: DeliveryStatus let text: String + let timestampText: String let hasReplyQuote: Bool let replyName: String? let replyText: String? @@ -100,6 +102,36 @@ extension MessageCellLayout { let forwardCaption: String? } + private struct MediaDimensions { + let maxWidth: CGFloat + let maxHeight: CGFloat + let minWidth: CGFloat + let minHeight: CGFloat + } + + private struct TextStatusLaneMetrics { + let textToMetadataGap: CGFloat + let timeToCheckGap: CGFloat + let textStatusRightInset: CGFloat + let statusWidth: CGFloat + let checkOffset: CGFloat + let verticalOffset: CGFloat + let checkBaselineOffset: CGFloat + + static func telegram(fontPointSize: CGFloat, screenPixel: CGFloat) -> TextStatusLaneMetrics { + TextStatusLaneMetrics( + textToMetadataGap: 5, + timeToCheckGap: 5, + textStatusRightInset: 5, + statusWidth: floor(floor(fontPointSize * 13.0 / 17.0)), + checkOffset: floor(fontPointSize * 6.0 / 17.0), + // Lift text status lane by one pixel to match Telegram vertical seating. + verticalOffset: -screenPixel, + checkBaselineOffset: 3.0 - screenPixel + ) + } + } + /// Calculate complete cell layout on ANY thread. /// Uses CoreText for text measurement (thread-safe). /// Returns layout with all frame rects + cached CoreTextTextLayout for rendering. @@ -109,14 +141,10 @@ extension MessageCellLayout { static func calculate(config: Config) -> (layout: MessageCellLayout, textLayout: CoreTextTextLayout?) { let font = UIFont.systemFont(ofSize: 17, weight: .regular) let tsFont = UIFont.systemFont(ofSize: floor(font.pointSize * 11.0 / 17.0), weight: .regular) - let screenScale = max(UIScreen.main.scale, 1) - let screenPixel = 1.0 / screenScale - - let hasTail = (config.position == .single || config.position == .bottom) - let isTopOrSingle = (config.position == .single || config.position == .top) - // Keep a visible separator between grouped bubbles in native UIKit mode. - // A single-screen-pixel gap was too tight and visually merged into one blob. - let groupGap: CGFloat = isTopOrSingle ? (2 + screenPixel) : (1 + screenPixel) + let screenPixel = 1.0 / max(UIScreen.main.scale, 1) + let metrics = BubbleMetrics.telegram() + let mergeType = BubbleGeometryEngine.mergeType(for: config.position) + let hasTail = BubbleGeometryEngine.hasTail(for: mergeType) let isOutgoingFailed = config.isOutgoing && config.deliveryStatus == .error let deliveryFailedInset: CGFloat = isOutgoingFailed ? 24 : 0 let effectiveMaxBubbleWidth = max(40, config.maxBubbleWidth - deliveryFailedInset) @@ -137,12 +165,29 @@ extension MessageCellLayout { messageType = .text } let isTextMessage = (messageType == .text || messageType == .textWithReply) + let textStatusLaneMetrics = TextStatusLaneMetrics.telegram( + fontPointSize: font.pointSize, + screenPixel: screenPixel + ) + let groupGap: CGFloat = { + guard config.position == .mid || config.position == .bottom else { + return metrics.defaultSpacing + } + // Keep grouped text bubbles compact, but still visually split. + if isTextMessage { + return screenPixel + } + return metrics.mergedSpacing + }() // ── STEP 1: Asymmetric paddings + base text measurement (full width) ── - let topPad: CGFloat = 6 + screenPixel - let bottomPad: CGFloat = 6 - screenPixel - let leftPad: CGFloat = 11 - let rightPad: CGFloat = 11 + let topPad: CGFloat = metrics.textInsets.top + let bottomPad: CGFloat = metrics.textInsets.bottom + let leftPad: CGFloat = metrics.textInsets.left + let rightPad: CGFloat = metrics.textInsets.right + let statusTrailingCompensation: CGFloat = isTextMessage + ? max(0, rightPad - textStatusLaneMetrics.textStatusRightInset) + : 0 // maxTextWidth = effectiveMaxBubbleWidth - (leftPad + rightPad) // Text is measured at the WIDEST possible constraint. @@ -165,22 +210,21 @@ extension MessageCellLayout { } // ── STEP 2: Meta-info dimensions ── - let tsSize = measureText("00:00", maxWidth: 60, font: tsFont) + let timestampText = config.timestampText.isEmpty ? "00:00" : config.timestampText + let tsSize = measureText(timestampText, maxWidth: 60, font: tsFont) let hasStatusIcon = config.isOutgoing && !isOutgoingFailed - let isMediaOnly = config.imageCount > 0 && config.text.isEmpty + let isMediaMessage = config.imageCount > 0 let statusWidth: CGFloat = hasStatusIcon - ? floor(floor(font.pointSize * 13.0 / 17.0)) + ? textStatusLaneMetrics.statusWidth : 0 let checkW: CGFloat = statusWidth - // Telegram date/status lane keeps a wider visual gap before checks. - let timeGap: CGFloat = hasStatusIcon ? 5 : 0 - let statusGap: CGFloat = 2 - let metadataWidth = tsSize.width + timeGap + checkW + let timeToCheckGap: CGFloat = hasStatusIcon + ? textStatusLaneMetrics.timeToCheckGap + : 0 + let metadataWidth = tsSize.width + timeToCheckGap + checkW - // ── STEP 3: Inline vs Wrapped determination ── - let timestampInline: Bool + let trailingWidthForStatus: CGFloat if isTextMessage && !config.text.isEmpty { - let trailingWidthForStatus: CGFloat if let cachedTextLayout { if cachedTextLayout.lastLineHasRTL { trailingWidthForStatus = 10_000 @@ -192,7 +236,34 @@ extension MessageCellLayout { } else { trailingWidthForStatus = textMeasurement.trailingLineWidth } - timestampInline = trailingWidthForStatus + statusGap + metadataWidth <= maxTextWidth + } else { + trailingWidthForStatus = 0 + } + + let inlineStatusContentWidth = max( + 0, + trailingWidthForStatus + + textStatusLaneMetrics.textToMetadataGap + + metadataWidth + - statusTrailingCompensation + ) + let wrappedStatusContentWidth = max( + 0, + metadataWidth - statusTrailingCompensation + ) + let isShortSingleLineText: Bool = { + guard messageType == .text, !config.text.isEmpty else { return false } + guard !config.text.contains("\n") else { return false } + if let cachedTextLayout { + return cachedTextLayout.lines.count == 1 + } + return textMeasurement.size.height <= ceil(font.lineHeight + 1.0) + }() + + // ── STEP 3: Inline vs Wrapped determination ── + let timestampInline: Bool + if isTextMessage && !config.text.isEmpty { + timestampInline = inlineStatusContentWidth <= maxTextWidth } else { timestampInline = true } @@ -200,10 +271,16 @@ extension MessageCellLayout { // ── STEP 4: Bubble dimensions (unified width + height) ── // Content blocks above the text area + let mediaDimensions = Self.mediaDimensions(for: UIScreen.main.bounds.width) let replyH: CGFloat = config.hasReplyQuote ? 46 : 0 var photoH: CGFloat = 0 if config.imageCount > 0 { - photoH = Self.collageHeight(count: config.imageCount, width: effectiveMaxBubbleWidth - 8) + photoH = Self.collageHeight( + count: config.imageCount, + width: effectiveMaxBubbleWidth - 8, + maxHeight: mediaDimensions.maxHeight, + minHeight: mediaDimensions.minHeight + ) } let forwardHeaderH: CGFloat = config.isForward ? 40 : 0 let fileH: CGFloat = CGFloat(config.fileCount) * 56 @@ -211,30 +288,21 @@ extension MessageCellLayout { // Tiny floor just to prevent zero-width collapse. // Telegram does NOT force a large minW — short messages get tight bubbles. let minW: CGFloat = 40 - let mediaWidthFraction: CGFloat - let mediaAbsoluteCap: CGFloat - if config.imageCount == 1 { - mediaWidthFraction = 0.64 - mediaAbsoluteCap = 288 - } else if config.imageCount == 2 { - mediaWidthFraction = 0.67 - mediaAbsoluteCap = 300 - } else { - mediaWidthFraction = 0.7 - mediaAbsoluteCap = 312 - } - let mediaBubbleMaxWidth = min( - effectiveMaxBubbleWidth, - min(mediaAbsoluteCap, max(200, UIScreen.main.bounds.width * mediaWidthFraction)) - ) + let mediaBubbleMaxWidth = min(effectiveMaxBubbleWidth, mediaDimensions.maxWidth) + let mediaBubbleMinWidth = min(mediaDimensions.minWidth, mediaBubbleMaxWidth) var bubbleW: CGFloat var bubbleH: CGFloat = replyH + forwardHeaderH + fileH if config.imageCount > 0 { // Media bubbles should not stretch edge-to-edge; keep Telegram-like cap. - bubbleW = mediaBubbleMaxWidth - photoH = Self.collageHeight(count: config.imageCount, width: bubbleW - 8) + bubbleW = max(mediaBubbleMinWidth, mediaBubbleMaxWidth) + photoH = Self.collageHeight( + count: config.imageCount, + width: bubbleW - 8, + maxHeight: mediaDimensions.maxHeight, + minHeight: mediaDimensions.minHeight + ) bubbleH += photoH if !config.text.isEmpty { bubbleH += topPad + textMeasurement.size.height + bottomPad @@ -243,16 +311,18 @@ extension MessageCellLayout { } else if isTextMessage && !config.text.isEmpty { // ── EXACT TELEGRAM MATH — no other modifiers ── let actualTextW = textMeasurement.size.width - let lastLineW = textMeasurement.trailingLineWidth let finalContentW: CGFloat if timestampInline { // INLINE: width = max(widest line, last line + gap + status) - finalContentW = max(actualTextW, lastLineW + statusGap + metadataWidth) + finalContentW = max( + actualTextW, + inlineStatusContentWidth + ) bubbleH += topPad + textMeasurement.size.height + bottomPad } else { // WRAPPED: status drops to new line below text - finalContentW = max(actualTextW, metadataWidth) + finalContentW = max(actualTextW, wrappedStatusContentWidth) bubbleH += topPad + textMeasurement.size.height + 15 + bottomPad } @@ -284,6 +354,63 @@ extension MessageCellLayout { // ── STEP 5: Geometry assignment ── + // Metadata frames: + // checkFrame.maxX = bubbleW - textStatusRightInset for text bubbles + // tsFrame.maxX = checkFrame.minX - timeToCheckGap + // checkFrame.minX = bubbleW - inset - checkW + let metadataRightInset: CGFloat = isMediaMessage + ? 6 + : (isTextMessage ? textStatusLaneMetrics.textStatusRightInset : rightPad) + let metadataBottomInset: CGFloat = isMediaMessage ? 6 : bottomPad + let statusEndX = bubbleW - metadataRightInset + let statusEndY = bubbleH - metadataBottomInset + let statusVerticalOffset: CGFloat = isTextMessage + ? textStatusLaneMetrics.verticalOffset + : 0 + + let tsFrame: CGRect + if config.isOutgoing { + // [timestamp][timeGap][checkW] anchored right at statusEndX + tsFrame = CGRect( + x: statusEndX - checkW - timeToCheckGap - tsSize.width, + y: statusEndY - tsSize.height + statusVerticalOffset, + width: tsSize.width, height: tsSize.height + ) + } else { + // Incoming: [timestamp] anchored right at statusEndX + tsFrame = CGRect( + x: statusEndX - tsSize.width, + y: statusEndY - tsSize.height + statusVerticalOffset, + width: tsSize.width, height: tsSize.height + ) + } + + let checkSentFrame: CGRect + let checkReadFrame: CGRect + let clockFrame: CGRect + if hasStatusIcon { + let checkImgW: CGFloat = floor(floor(font.pointSize * 11.0 / 17.0)) + let checkImgH: CGFloat = floor(checkImgW * 9.0 / 11.0) + let checkOffset: CGFloat = isTextMessage + ? textStatusLaneMetrics.checkOffset + : floor(font.pointSize * 6.0 / 17.0) + let checkReadX = statusEndX - checkImgW + let checkSentX = checkReadX - checkOffset + let checkBaselineOffset: CGFloat = isTextMessage + ? textStatusLaneMetrics.checkBaselineOffset + : (3 - screenPixel) + let checkY = tsFrame.minY + checkBaselineOffset + checkSentFrame = CGRect(x: checkSentX, y: checkY, width: checkImgW, height: checkImgH) + checkReadFrame = CGRect(x: checkReadX, y: checkY, width: checkImgW, height: checkImgH) + // Telegram DateAndStatusNode: + // clock origin X = dateFrame.maxX + 3.0, center Y aligned with checks. + clockFrame = CGRect(x: tsFrame.maxX + 3.0, y: checkY - 1.0, width: 11, height: 11) + } else { + checkSentFrame = .zero + checkReadFrame = .zero + clockFrame = .zero + } + // Text frame — MUST fill bubbleW - leftPad - rightPad (the content area), // NOT textMeasurement.size.width. Using the measured width causes UILabel to // re-wrap at a narrower constraint than CoreText measured, producing different @@ -297,56 +424,20 @@ extension MessageCellLayout { } if fileH > 0 { textY = fileH + (config.hasReplyQuote ? replyH : 0) + topPad } - let textFrame = CGRect(x: leftPad, y: textY, - width: bubbleW - leftPad - rightPad, - height: textMeasurement.size.height) - - // Metadata frames: - // checkFrame.maxX = bubbleW - rightPad (inset from bubble edge, NOT glued) - // tsFrame.maxX = checkFrame.minX - timeGap - // checkFrame.minX = bubbleW - rightPad - checkW - let metadataRightInset: CGFloat = isMediaOnly ? 6 : rightPad - let metadataBottomInset: CGFloat = isMediaOnly ? 6 : bottomPad - let statusEndX = bubbleW - metadataRightInset - let statusEndY = bubbleH - metadataBottomInset - - let tsFrame: CGRect - if config.isOutgoing { - // [timestamp][timeGap][checkW] anchored right at statusEndX - tsFrame = CGRect( - x: statusEndX - checkW - timeGap - tsSize.width, - y: statusEndY - tsSize.height, - width: tsSize.width, height: tsSize.height - ) - } else { - // Incoming: [timestamp] anchored right at statusEndX - tsFrame = CGRect( - x: statusEndX - tsSize.width, - y: statusEndY - tsSize.height, - width: tsSize.width, height: tsSize.height - ) + if isShortSingleLineText && timestampInline { + // Optical centering for short one-line text without inflating bubble height. + let maxTextY = tsFrame.minY - textStatusLaneMetrics.textToMetadataGap - textMeasurement.size.height + if maxTextY > textY { + textY = min(textY + 1.5, maxTextY) + } } - let checkSentFrame: CGRect - let checkReadFrame: CGRect - let clockFrame: CGRect - if hasStatusIcon { - let checkImgW: CGFloat = floor(floor(font.pointSize * 11.0 / 17.0)) - let checkImgH: CGFloat = floor(checkImgW * 9.0 / 11.0) - let checkOffset: CGFloat = floor(font.pointSize * 6.0 / 17.0) - let checkReadX = statusEndX - checkImgW - let checkSentX = checkReadX - checkOffset - let checkY = tsFrame.minY + (3 - screenPixel) - checkSentFrame = CGRect(x: checkSentX, y: checkY, width: checkImgW, height: checkImgH) - checkReadFrame = CGRect(x: checkReadX, y: checkY, width: checkImgW, height: checkImgH) - // Telegram DateAndStatusNode: - // clock origin X = dateFrame.maxX + 3.0, center Y aligned with checks. - clockFrame = CGRect(x: tsFrame.maxX + 3.0, y: checkY - 1.0, width: 11, height: 11) - } else { - checkSentFrame = .zero - checkReadFrame = .zero - clockFrame = .zero - } + let textFrame = CGRect( + x: leftPad, + y: textY, + width: bubbleW - leftPad - rightPad, + height: textMeasurement.size.height + ) // Accessory frames (reply, photo, file, forward) let replyContainerFrame = CGRect(x: 5, y: 5, width: bubbleW - 10, height: 41) @@ -369,6 +460,7 @@ extension MessageCellLayout { messageType: messageType, bubbleFrame: bubbleFrame, bubbleSize: CGSize(width: bubbleW, height: bubbleH), + mergeType: mergeType, hasTail: hasTail, textFrame: textFrame, textSize: textMeasurement.size, @@ -400,26 +492,38 @@ extension MessageCellLayout { // MARK: - Collage Height (Thread-Safe) /// Photo collage height — same formulas as PhotoCollageView.swift. - private static func collageHeight(count: Int, width: CGFloat) -> CGFloat { + private static func mediaDimensions(for screenWidth: CGFloat) -> MediaDimensions { + if screenWidth > 680 { + return MediaDimensions(maxWidth: 440, maxHeight: 440, minWidth: 170, minHeight: 74) + } + return MediaDimensions(maxWidth: 300, maxHeight: 380, minWidth: 170, minHeight: 74) + } + + private static func collageHeight( + count: Int, + width: CGFloat, + maxHeight: CGFloat, + minHeight: CGFloat + ) -> CGFloat { guard count > 0 else { return 0 } - if count == 1 { return max(180, min(width * 0.93, 340)) } + if count == 1 { return min(max(width * 0.93, minHeight), maxHeight) } if count == 2 { let cellW = (width - 2) / 2 - return min(cellW * 1.28, 330) + return min(max(cellW * 1.28, minHeight), maxHeight) } if count == 3 { let leftW = width * 0.66 - return min(leftW * 1.16, 330) + return min(max(leftW * 1.16, minHeight), maxHeight) } if count == 4 { let cellW = (width - 2) / 2 - let cellH = min(cellW * 0.85, 160) - return cellH * 2 + 2 + let cellH = min(max(cellW * 0.85, minHeight / 2), maxHeight / 2) + return min(max(cellH * 2 + 2, minHeight), maxHeight) } // 5+ let topH = min(width / 2 * 0.85, 176) let botH = min(width / 3 * 0.85, 144) - return topH + 2 + botH + return min(max(topH + 2 + botH, minHeight), maxHeight) } // MARK: - Text Measurement (Thread-Safe) @@ -579,9 +683,16 @@ extension MessageCellLayout { ) -> (layouts: [String: MessageCellLayout], textLayouts: [String: CoreTextTextLayout]) { var result: [String: MessageCellLayout] = [:] var textResult: [String: CoreTextTextLayout] = [:] + let timestampFormatter = DateFormatter() + timestampFormatter.dateFormat = "HH:mm" + timestampFormatter.locale = .autoupdatingCurrent + timestampFormatter.timeZone = .autoupdatingCurrent for (index, message) in messages.enumerated() { let isOutgoing = message.fromPublicKey == currentPublicKey + let timestampText = timestampFormatter.string( + from: Date(timeIntervalSince1970: TimeInterval(message.timestamp) / 1000) + ) // Filter garbage/encrypted text (UIKit path parity with SwiftUI MessageCellView) let displayText = isGarbageOrEncrypted(message.text) ? "" : message.text @@ -633,6 +744,7 @@ extension MessageCellLayout { position: position, deliveryStatus: message.deliveryStatus, text: displayText, + timestampText: timestampText, hasReplyQuote: hasReply && !displayText.isEmpty, replyName: nil, replyText: nil, diff --git a/Rosetta/Core/Network/Protocol/Packets/Packet.swift b/Rosetta/Core/Network/Protocol/Packets/Packet.swift index a9353ef..24633d9 100644 --- a/Rosetta/Core/Network/Protocol/Packets/Packet.swift +++ b/Rosetta/Core/Network/Protocol/Packets/Packet.swift @@ -23,9 +23,16 @@ enum PacketRegistry { 0x07: { PacketRead() }, 0x08: { PacketDelivery() }, 0x09: { PacketDeviceNew() }, + 0x0A: { PacketRequestUpdate() }, 0x0B: { PacketTyping() }, 0x0F: { PacketRequestTransport() }, 0x10: { PacketPushNotification() }, + 0x11: { PacketCreateGroup() }, + 0x12: { PacketGroupInfo() }, + 0x13: { PacketGroupInviteInfo() }, + 0x14: { PacketGroupJoin() }, + 0x15: { PacketGroupLeave() }, + 0x16: { PacketGroupBan() }, 0x17: { PacketDeviceList() }, 0x18: { PacketDeviceResolve() }, 0x19: { PacketSync() }, @@ -61,6 +68,7 @@ enum AttachmentType: Int, Codable { case messages = 1 case file = 2 case avatar = 3 + case call = 4 } struct MessageAttachment: Codable, Equatable { @@ -100,3 +108,145 @@ struct SearchUser { var verified: Int = 0 var online: Int = 0 } + +// MARK: - Update Server Packet (0x0A) + +/// Request/response packet for update server discovery. +/// Mirrors Android/Desktop `PacketRequestUpdate`. +struct PacketRequestUpdate: Packet { + static let packetId = 0x0A + + var updateServer: String = "" + + func write(to stream: Stream) { + stream.writeString(updateServer) + } + + mutating func read(from stream: Stream) { + updateServer = stream.readString() + } +} + +// MARK: - Group Packets (0x11...0x16) + +enum GroupStatus: Int, Codable { + case joined = 0 + case invalid = 1 + case notJoined = 2 + case banned = 3 + + init(value: Int) { + self = GroupStatus(rawValue: value) ?? .notJoined + } +} + +struct PacketCreateGroup: Packet { + static let packetId = 0x11 + + var groupId: String = "" + + func write(to stream: Stream) { + stream.writeString(groupId) + } + + mutating func read(from stream: Stream) { + groupId = stream.readString() + } +} + +struct PacketGroupInfo: Packet { + static let packetId = 0x12 + + var groupId: String = "" + var members: [String] = [] + + func write(to stream: Stream) { + stream.writeString(groupId) + stream.writeInt16(members.count) + for member in members { + stream.writeString(member) + } + } + + mutating func read(from stream: Stream) { + groupId = stream.readString() + let count = max(stream.readInt16(), 0) + var parsed: [String] = [] + parsed.reserveCapacity(count) + for _ in 0.. Void)? var onSearchResult: ((PacketSearch) -> Void)? var onTypingReceived: ((PacketTyping) -> Void)? + var onRequestUpdateReceived: ((PacketRequestUpdate) -> Void)? + var onCreateGroupReceived: ((PacketCreateGroup) -> Void)? + var onGroupInfoReceived: ((PacketGroupInfo) -> Void)? + var onGroupInviteInfoReceived: ((PacketGroupInviteInfo) -> Void)? + var onGroupJoinReceived: ((PacketGroupJoin) -> Void)? + var onGroupLeaveReceived: ((PacketGroupLeave) -> Void)? + var onGroupBanReceived: ((PacketGroupBan) -> Void)? var onSyncReceived: ((PacketSync) -> Void)? var onDeviceNewReceived: ((PacketDeviceNew) -> Void)? var onHandshakeCompleted: ((PacketHandshake) -> Void)? @@ -482,10 +489,38 @@ final class ProtocolManager: @unchecked Sendable { if let p = packet as? PacketDeviceNew { onDeviceNewReceived?(p) } + case 0x0A: + if let p = packet as? PacketRequestUpdate { + onRequestUpdateReceived?(p) + } case 0x0B: if let p = packet as? PacketTyping { onTypingReceived?(p) } + case 0x11: + if let p = packet as? PacketCreateGroup { + onCreateGroupReceived?(p) + } + case 0x12: + if let p = packet as? PacketGroupInfo { + onGroupInfoReceived?(p) + } + case 0x13: + if let p = packet as? PacketGroupInviteInfo { + onGroupInviteInfoReceived?(p) + } + case 0x14: + if let p = packet as? PacketGroupJoin { + onGroupJoinReceived?(p) + } + case 0x15: + if let p = packet as? PacketGroupLeave { + onGroupLeaveReceived?(p) + } + case 0x16: + if let p = packet as? PacketGroupBan { + onGroupBanReceived?(p) + } case 0x0F: if let p = packet as? PacketRequestTransport { Self.logger.info("📥 Transport server: \(p.transportServer)") diff --git a/Rosetta/Core/Network/Protocol/Stream.swift b/Rosetta/Core/Network/Protocol/Stream.swift index 683fc55..b06a903 100644 --- a/Rosetta/Core/Network/Protocol/Stream.swift +++ b/Rosetta/Core/Network/Protocol/Stream.swift @@ -1,8 +1,10 @@ import Foundation +typealias Stream = PacketBitStream + /// Bit-aligned binary stream for protocol packets. /// Matches the React Native / Android implementation exactly. -final class Stream: @unchecked Sendable { +final class PacketBitStream: NSObject { private var bytes: [UInt8] private var readPointer: Int = 0 @@ -10,19 +12,25 @@ final class Stream: @unchecked Sendable { // MARK: - Init - init() { + override init() { bytes = [] - bytes.reserveCapacity(256) + super.init() } init(data: Data) { bytes = Array(data) + super.init() } // MARK: - Output func toData() -> Data { - Data(bytes) + guard bytes.isEmpty == false else { + return Data() + } + var data = Data(count: bytes.count) + data.replaceSubrange(0.. Int { + guard readPointer < bytes.count * 8 else { + return 0 + } let byteIndex = readPointer >> 3 let shift = 7 - (readPointer & 7) let bit = (bytes[byteIndex] >> shift) & 1 @@ -76,6 +87,11 @@ final class Stream: @unchecked Sendable { } func readInt8() -> Int { + guard readPointer + 9 <= bytes.count * 8 else { + readPointer = bytes.count * 8 + return 0 + } + var value = 0 let signShift = 7 - (readPointer & 7) let negationBit = Int((bytes[readPointer >> 3] >> signShift) & 1) @@ -171,6 +187,16 @@ final class Stream: @unchecked Sendable { func readBytes() -> Data { let length = readInt32() + guard length >= 0 else { + return Data() + } + + let bitsAvailable = bytes.count * 8 - readPointer + let bytesAvailable = max(bitsAvailable / 9, 0) + guard length <= bytesAvailable else { + return Data() + } + var result = Data(capacity: length) for _ in 0.. String + func downloadFile(tag: String) async throws -> Data +} + +protocol PacketFlowSending { + func sendPacket(_ packet: any Packet) +} + +protocol SearchResultDispatching { + var connectionState: ConnectionState { get } + var privateHash: String? { get } + func sendSearchPacket(_ packet: PacketSearch) + @discardableResult + func addSearchResultHandler(_ handler: @escaping (PacketSearch) -> Void) -> UUID + func removeSearchResultHandler(_ id: UUID) +} + +struct LiveAttachmentFlowTransport: AttachmentFlowTransporting { + func uploadFile(id: String, content: Data) async throws -> String { + try await TransportManager.shared.uploadFile(id: id, content: content) + } + + func downloadFile(tag: String) async throws -> Data { + try await TransportManager.shared.downloadFile(tag: tag) + } +} + +struct LivePacketFlowSender: PacketFlowSending { + func sendPacket(_ packet: any Packet) { + ProtocolManager.shared.sendPacket(packet) + } +} + +struct LiveSearchResultDispatcher: SearchResultDispatching { + var connectionState: ConnectionState { + ProtocolManager.shared.connectionState + } + + var privateHash: String? { + ProtocolManager.shared.privateHash + } + + func sendSearchPacket(_ packet: PacketSearch) { + ProtocolManager.shared.sendPacket(packet) + } + + @discardableResult + func addSearchResultHandler(_ handler: @escaping (PacketSearch) -> Void) -> UUID { + ProtocolManager.shared.addSearchResultHandler(handler) + } + + func removeSearchResultHandler(_ id: UUID) { + ProtocolManager.shared.removeSearchResultHandler(id) + } +} diff --git a/Rosetta/Core/Services/SessionManager.swift b/Rosetta/Core/Services/SessionManager.swift index 5ea9a72..97edfa0 100644 --- a/Rosetta/Core/Services/SessionManager.swift +++ b/Rosetta/Core/Services/SessionManager.swift @@ -14,6 +14,20 @@ final class SessionManager { nonisolated private static let logger = Logger(subsystem: "com.rosetta.messenger", category: "Session") + enum StartSessionError: LocalizedError { + case invalidCredentials + case databaseBootstrapFailed(underlying: Error) + + var errorDescription: String? { + switch self { + case .invalidCredentials: + return "Wrong password. Please try again." + case .databaseBootstrapFailed: + return "Database migration failed. Please restart the app and try again." + } + } + } + private(set) var isAuthenticated = false private(set) var currentPublicKey: String = "" private(set) var displayName: String = "" @@ -49,6 +63,8 @@ final class SessionManager { private var pendingOutgoingAttempts: [String: Int] = [:] private let maxOutgoingRetryAttempts = ProtocolConstants.maxOutgoingRetryAttempts private let maxOutgoingWaitingLifetimeMs: Int64 = ProtocolConstants.messageDeliveryTimeoutS * 1000 + var attachmentFlowTransport: AttachmentFlowTransporting = LiveAttachmentFlowTransport() + var packetFlowSender: PacketFlowSending = LivePacketFlowSender() // MARK: - Foreground Detection (Android parity) @@ -127,7 +143,12 @@ final class SessionManager { let crypto = CryptoManager.shared // Decrypt private key - let privateKeyHex = try await accountManager.decryptPrivateKey(password: password) + let privateKeyHex: String + do { + privateKeyHex = try await accountManager.decryptPrivateKey(password: password) + } catch { + throw StartSessionError.invalidCredentials + } self.privateKeyHex = privateKeyHex // Android parity: provide private key to caches for encryption at rest AttachmentCache.shared.privateKey = privateKeyHex @@ -137,13 +158,20 @@ final class SessionManager { throw CryptoError.decryptionFailed } + // Open SQLite database for this account (must happen before repository bootstrap). + do { + try DatabaseManager.shared.bootstrap(accountPublicKey: account.publicKey) + } catch { + self.privateKeyHex = nil + AttachmentCache.shared.privateKey = nil + Self.logger.error("Database bootstrap failed: \(error.localizedDescription)") + throw StartSessionError.databaseBootstrapFailed(underlying: error) + } + currentPublicKey = account.publicKey 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, @@ -219,14 +247,27 @@ final class SessionManager { privateKeyHex: privKey, privateKeyHash: hash ) + let targetDialogKey = packet.toPublicKey // Prefer caller-provided title/username (from ChatDetailView route), // fall back to existing dialog data, then empty. - let existingDialog = DialogRepository.shared.dialogs[toPublicKey] - let title = !opponentTitle.isEmpty ? opponentTitle : (existingDialog?.opponentTitle ?? "") - let username = !opponentUsername.isEmpty ? opponentUsername : (existingDialog?.opponentUsername ?? "") + let existingDialog = DialogRepository.shared.dialogs[targetDialogKey] + let groupMetadata = GroupRepository.shared.groupMetadata( + account: currentPublicKey, + groupDialogKey: targetDialogKey + ) + let title = !opponentTitle.isEmpty + ? opponentTitle + : (existingDialog?.opponentTitle.isEmpty == false + ? (existingDialog?.opponentTitle ?? "") + : (groupMetadata?.title ?? "")) + let username = !opponentUsername.isEmpty + ? opponentUsername + : (existingDialog?.opponentUsername.isEmpty == false + ? (existingDialog?.opponentUsername ?? "") + : (groupMetadata?.description ?? "")) DialogRepository.shared.ensureDialog( - opponentKey: toPublicKey, + opponentKey: targetDialogKey, title: title, username: username, myPublicKey: currentPublicKey @@ -241,9 +282,10 @@ final class SessionManager { packet, myPublicKey: currentPublicKey, decryptedText: text, - fromSync: false + fromSync: false, + dialogIdentityOverride: targetDialogKey ) - DialogRepository.shared.updateDialogFromMessages(opponentKey: packet.toPublicKey) + DialogRepository.shared.updateDialogFromMessages(opponentKey: targetDialogKey) // Android parity: persist IMMEDIATELY after inserting outgoing message. // Without this, if app is killed within 800ms debounce window, @@ -252,9 +294,9 @@ final class SessionManager { MessageRepository.shared.persistNow() // Saved Messages: local-only, no server send - if toPublicKey == currentPublicKey { + if targetDialogKey == currentPublicKey { MessageRepository.shared.updateDeliveryStatus(messageId: messageId, status: .delivered) - DialogRepository.shared.updateDeliveryStatus(messageId: messageId, opponentKey: toPublicKey, status: .delivered) + DialogRepository.shared.updateDeliveryStatus(messageId: messageId, opponentKey: targetDialogKey, status: .delivered) return } @@ -368,7 +410,7 @@ final class SessionManager { // Upload encrypted blob to transport server in background (desktop: uploadFile) let tag: String do { - tag = try await TransportManager.shared.uploadFile( + tag = try await attachmentFlowTransport.uploadFile( id: attachmentId, content: Data(encryptedBlob.utf8) ) @@ -398,12 +440,12 @@ final class SessionManager { MessageRepository.shared.updateDeliveryStatus(messageId: messageId, status: .delivered) DialogRepository.shared.updateDeliveryStatus(messageId: messageId, opponentKey: toPublicKey, status: .delivered) // Send to server for multi-device sync (unlike text Saved Messages) - ProtocolManager.shared.sendPacket(packet) + packetFlowSender.sendPacket(packet) Self.logger.info("📤 Avatar synced to Saved Messages (multi-device) tag=\(tag)") return } - ProtocolManager.shared.sendPacket(packet) + packetFlowSender.sendPacket(packet) registerOutgoingRetry(for: packet) MessageRepository.shared.persistNow() Self.logger.info("📤 Avatar sent to \(toPublicKey.prefix(12))… tag=\(tag)") @@ -595,18 +637,32 @@ final class SessionManager { MessageRepository.shared.persistNow() if toPublicKey == currentPublicKey { + // Keep self/saved files immediately openable without transport tag/download. + for item in encryptedAttachments where item.original.type == .file { + let fallback = AttachmentPreviewCodec.parseFilePreview( + item.preview, + fallbackFileName: item.original.fileName ?? "file", + fallbackFileSize: item.original.fileSize ?? item.original.data.count + ) + _ = AttachmentCache.shared.saveFile( + item.original.data, + forAttachmentId: item.original.id, + fileName: fallback.fileName + ) + } MessageRepository.shared.updateDeliveryStatus(messageId: messageId, status: .delivered) DialogRepository.shared.updateDeliveryStatus(messageId: messageId, opponentKey: toPublicKey, status: .delivered) return } // ── Phase 2: Upload in background, then send packet ── + let flowTransport = attachmentFlowTransport let messageAttachments: [MessageAttachment] = try await withThrowingTaskGroup( of: (Int, String).self ) { group in for (index, item) in encryptedAttachments.enumerated() { group.addTask { - let tag = try await TransportManager.shared.uploadFile( + let tag = try await flowTransport.uploadFile( id: item.original.id, content: item.encryptedData ) return (index, tag) @@ -616,7 +672,7 @@ final class SessionManager { for try await (index, tag) in group { tags[index] = tag } return encryptedAttachments.enumerated().map { index, item in let tag = tags[index] ?? "" - let preview = item.preview.isEmpty ? "\(tag)::" : "\(tag)::\(item.preview)" + let preview = AttachmentPreviewCodec.compose(downloadTag: tag, payload: item.preview) Self.logger.info("📤 Attachment uploaded: type=\(String(describing: item.original.type)), tag=\(tag)") return MessageAttachment(id: item.original.id, preview: preview, blob: "", type: item.original.type) } @@ -629,7 +685,7 @@ final class SessionManager { var packet = optimisticPacket packet.attachments = messageAttachments - ProtocolManager.shared.sendPacket(packet) + packetFlowSender.sendPacket(packet) registerOutgoingRetry(for: packet) MessageRepository.shared.persistNow() Self.logger.info("📤 Message with \(attachments.count) attachment(s) sent to \(toPublicKey.prefix(12))…") @@ -729,7 +785,7 @@ final class SessionManager { Self.logger.debug("📤 Forward re-upload: \(originalId) → \(newAttId) (\(jpegData.count) bytes JPEG, \(encryptedBlob.count) encrypted)") #endif - let tag = try await TransportManager.shared.uploadFile( + let tag = try await attachmentFlowTransport.uploadFile( id: newAttId, content: Data(encryptedBlob.utf8) ) @@ -738,14 +794,8 @@ final class SessionManager { let originalPreview = replyMessages .flatMap { $0.attachments } .first(where: { $0.id == originalId })?.preview ?? "" - let blurhash: String - if let range = originalPreview.range(of: "::") { - blurhash = String(originalPreview[range.upperBound...]) - } else { - blurhash = "" - } - - let newPreview = "\(tag)::\(blurhash)" + let blurhash = AttachmentPreviewCodec.blurHash(from: originalPreview) + let newPreview = AttachmentPreviewCodec.compose(downloadTag: tag, payload: blurhash) attachmentIdMap[originalId] = (newAttId, newPreview) // Cache locally under new ID for ForwardedImagePreviewCell @@ -777,7 +827,7 @@ final class SessionManager { Self.logger.debug("📤 Forward file re-upload: \(originalId) → \(newAttId) (\(fileInfo.data.count) bytes, \(fileInfo.fileName))") #endif - let tag = try await TransportManager.shared.uploadFile( + let tag = try await attachmentFlowTransport.uploadFile( id: newAttId, content: Data(encryptedBlob.utf8) ) @@ -786,14 +836,13 @@ final class SessionManager { 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)" + let filePreview = AttachmentPreviewCodec.parseFilePreview( + originalPreview, + fallbackFileName: fileInfo.fileName, + fallbackFileSize: fileInfo.data.count + ) + let fileMeta = "\(filePreview.fileSize)::\(filePreview.fileName)" + let newPreview = AttachmentPreviewCodec.compose(downloadTag: tag, payload: fileMeta) attachmentIdMap[originalId] = (newAttId, newPreview) #if DEBUG @@ -927,7 +976,7 @@ final class SessionManager { return } - ProtocolManager.shared.sendPacket(packet) + packetFlowSender.sendPacket(packet) registerOutgoingRetry(for: packet) MessageRepository.shared.persistNow() Self.logger.info("📤 Reply message sent to \(toPublicKey.prefix(12))… with \(replyMessages.count) quoted message(s), \(forwardedImages.count) re-uploaded photos") @@ -935,30 +984,38 @@ final class SessionManager { /// Sends typing indicator with throttling (desktop parity: max once per 3s per dialog). func sendTypingIndicator(toPublicKey: String) { - guard toPublicKey != currentPublicKey, + let base = toPublicKey.trimmingCharacters(in: .whitespacesAndNewlines) + let normalized = DatabaseManager.isGroupDialogKey(base) + ? Self.normalizedGroupDialogIdentity(base) + : base + guard normalized != currentPublicKey, + !normalized.isEmpty, let hash = privateKeyHash, ProtocolManager.shared.connectionState == .authenticated else { return } let now = Int64(Date().timeIntervalSince1970 * 1000) - let lastSent = lastTypingSentAt[toPublicKey] ?? 0 + let lastSent = lastTypingSentAt[normalized] ?? 0 if now - lastSent < ProtocolConstants.typingThrottleMs { return } - lastTypingSentAt[toPublicKey] = now + lastTypingSentAt[normalized] = now var packet = PacketTyping() packet.privateKey = hash packet.fromPublicKey = currentPublicKey - packet.toPublicKey = toPublicKey + packet.toPublicKey = normalized ProtocolManager.shared.sendPacket(packet) } - /// Android parity: sends read receipt for direct dialog. + /// Android parity: sends read receipt for direct/group dialog. /// Uses timestamp dedup (not time-based throttle) — only sends if latest incoming /// message timestamp > last sent read receipt timestamp. Retries once after 2s. func sendReadReceipt(toPublicKey: String) { - let normalized = toPublicKey.trimmingCharacters(in: .whitespacesAndNewlines) + let base = toPublicKey.trimmingCharacters(in: .whitespacesAndNewlines) + let normalized = DatabaseManager.isGroupDialogKey(base) + ? Self.normalizedGroupDialogIdentity(base) + : base let connState = ProtocolManager.shared.connectionState guard normalized != currentPublicKey, !normalized.isEmpty, @@ -1070,21 +1127,17 @@ final class SessionManager { proto.onReadReceived = { [weak self] packet in guard let self else { return } Task { @MainActor in - guard Self.isSupportedDirectReadPacket(packet, ownKey: self.currentPublicKey) else { + guard let context = Self.resolveReadPacketContext(packet, ownKey: self.currentPublicKey) else { Self.logger.debug( "Skipping unsupported read packet: from=\(packet.fromPublicKey), to=\(packet.toPublicKey)" ) return } - let fromKey = packet.fromPublicKey.trimmingCharacters(in: .whitespacesAndNewlines) - let toKey = packet.toPublicKey.trimmingCharacters(in: .whitespacesAndNewlines) let ownKey = self.currentPublicKey - let isOwnReadSync = fromKey == ownKey - let opponentKey = isOwnReadSync ? toKey : fromKey - guard !opponentKey.isEmpty else { return } + let opponentKey = context.dialogKey - if isOwnReadSync { + if context.fromMe { // Android parity: read sync from another own device means // incoming messages in this dialog should become read locally. DialogRepository.shared.markAsRead(opponentKey: opponentKey) @@ -1127,8 +1180,11 @@ final class SessionManager { proto.onTypingReceived = { [weak self] packet in guard let self else { return } Task { @MainActor in - guard packet.toPublicKey == self.currentPublicKey else { return } - MessageRepository.shared.markTyping(from: packet.fromPublicKey) + guard let context = Self.resolveTypingPacketContext(packet, ownKey: self.currentPublicKey) else { + return + } + if context.fromMe { return } + MessageRepository.shared.markTyping(from: context.dialogKey) } } @@ -1270,6 +1326,67 @@ final class SessionManager { } } + proto.onRequestUpdateReceived = { packet in + Self.logger.debug("RequestUpdate packet received: server=\(packet.updateServer)") + } + + proto.onCreateGroupReceived = { packet in + Self.logger.debug("CreateGroup packet received: groupId=\(packet.groupId)") + } + + proto.onGroupInfoReceived = { packet in + Self.logger.debug("GroupInfo packet received: groupId=\(packet.groupId), members=\(packet.members.count)") + } + + proto.onGroupInviteInfoReceived = { packet in + Self.logger.debug( + "GroupInviteInfo packet received: groupId=\(packet.groupId), members=\(packet.membersCount), status=\(packet.status.rawValue)" + ) + } + + proto.onGroupJoinReceived = { [weak self] packet in + guard let self else { return } + Task { @MainActor in + guard let privateKeyHex = self.privateKeyHex else { return } + guard !self.currentPublicKey.isEmpty else { return } + + guard let dialogKey = GroupRepository.shared.upsertFromGroupJoin( + account: self.currentPublicKey, + privateKeyHex: privateKeyHex, + packet: packet + ) else { + return + } + + let metadata = GroupRepository.shared.groupMetadata( + account: self.currentPublicKey, + groupDialogKey: dialogKey + ) + + DialogRepository.shared.ensureDialog( + opponentKey: dialogKey, + title: metadata?.title ?? "", + username: metadata?.description ?? "", + myPublicKey: self.currentPublicKey + ) + + if MessageRepository.shared.lastDecryptedMessage( + account: self.currentPublicKey, + opponentKey: dialogKey + ) != nil { + DialogRepository.shared.updateDialogFromMessages(opponentKey: dialogKey) + } + } + } + + proto.onGroupLeaveReceived = { packet in + Self.logger.debug("GroupLeave packet received: groupId=\(packet.groupId)") + } + + proto.onGroupBanReceived = { packet in + Self.logger.debug("GroupBan packet received: groupId=\(packet.groupId), publicKey=\(packet.publicKey)") + } + proto.onDeviceNewReceived = { [weak self] packet in Task { @MainActor in self?.handleDeviceNewLogin(packet) @@ -1365,14 +1482,26 @@ final class SessionManager { let currentPrivateKeyHex = self.privateKeyHex let currentPrivateKeyHash = self.privateKeyHash - let fromMe = packet.fromPublicKey == myKey - - guard Self.isSupportedDirectMessagePacket(packet, ownKey: myKey) else { + guard let context = Self.resolveMessagePacketContext(packet, ownKey: myKey) else { return } + let fromMe = context.fromMe - let opponentKey = fromMe ? packet.toPublicKey : packet.fromPublicKey + let opponentKey = context.dialogKey + let isGroupDialog = context.kind == .group let wasKnownBefore = MessageRepository.shared.hasMessage(packet.messageId) + let groupKey: String? = { + guard isGroupDialog, let currentPrivateKeyHex else { return nil } + return GroupRepository.shared.groupKey( + account: myKey, + privateKeyHex: currentPrivateKeyHex, + groupDialogKey: opponentKey + ) + }() + if isGroupDialog, groupKey == nil { + Self.logger.warning("processIncoming: group key not found for \(opponentKey)") + return + } // ── PERF: Offload all crypto to background thread ── // decryptIncomingMessage (ECDH + XChaCha20) and attachment blob @@ -1382,7 +1511,8 @@ final class SessionManager { Self.decryptAndProcessAttachments( packet: packet, myPublicKey: myKey, - privateKeyHex: currentPrivateKeyHex + privateKeyHex: currentPrivateKeyHex, + groupKey: groupKey ) }.value @@ -1409,22 +1539,32 @@ final class SessionManager { myPublicKey: myKey, decryptedText: text, attachmentPassword: resolvedAttachmentPassword, - fromSync: effectiveFromSync + fromSync: effectiveFromSync, + dialogIdentityOverride: opponentKey ) // Android parity 1:1: dialogDao.updateDialogFromMessages(account, opponentKey) // Full recalculation of lastMessage, unread, iHaveSent, delivery from DB. DialogRepository.shared.updateDialogFromMessages(opponentKey: opponentKey) + if isGroupDialog, + let metadata = GroupRepository.shared.groupMetadata(account: myKey, groupDialogKey: opponentKey) { + DialogRepository.shared.ensureDialog( + opponentKey: opponentKey, + title: metadata.title, + username: metadata.description, + myPublicKey: myKey + ) + } // 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 && !effectiveFromSync { + if !fromMe && !effectiveFromSync && !isGroupDialog { DialogRepository.shared.updateOnlineState(publicKey: opponentKey, isOnline: true) } let dialog = DialogRepository.shared.dialogs[opponentKey] - if dialog?.opponentTitle.isEmpty == true { + if !isGroupDialog, dialog?.opponentTitle.isEmpty == true { requestUserInfoIfNeeded(opponentKey: opponentKey, privateKeyHash: currentPrivateKeyHash) } @@ -1585,12 +1725,14 @@ final class SessionManager { nonisolated private static func decryptAndProcessAttachments( packet: PacketMessage, myPublicKey: String, - privateKeyHex: String? + privateKeyHex: String?, + groupKey: String? ) -> IncomingCryptoResult? { guard let result = decryptIncomingMessage( packet: packet, myPublicKey: myPublicKey, - privateKeyHex: privateKeyHex + privateKeyHex: privateKeyHex, + groupKey: groupKey ) else { return nil } var processedPacket = packet @@ -1628,6 +1770,15 @@ final class SessionManager { } } } + } else if let groupKey { + for i in processedPacket.attachments.indices where processedPacket.attachments[i].type == .messages { + let blob = processedPacket.attachments[i].blob + guard !blob.isEmpty else { continue } + if let data = try? CryptoManager.shared.decryptWithPassword(blob, password: groupKey), + let decryptedString = String(data: data, encoding: .utf8) { + processedPacket.attachments[i].blob = decryptedString + } + } } return IncomingCryptoResult( @@ -1641,10 +1792,25 @@ final class SessionManager { nonisolated private static func decryptIncomingMessage( packet: PacketMessage, myPublicKey: String, - privateKeyHex: String? + privateKeyHex: String?, + groupKey: String? ) -> (text: String, rawKeyData: Data?)? { let isOwnMessage = packet.fromPublicKey == myPublicKey + if let groupKey { + if packet.content.isEmpty { + return ("", nil) + } + if let data = try? CryptoManager.shared.decryptWithPassword( + packet.content, + password: groupKey + ), let text = String(data: data, encoding: .utf8) { + return (text, nil) + } + Self.logger.warning("Group decrypt failed for msgId=\(packet.messageId.prefix(8))…") + return nil + } + guard let privateKeyHex, !packet.content.isEmpty else { return nil } @@ -1689,12 +1855,36 @@ final class SessionManager { } } + private enum PacketDialogKind { + case direct + case saved + case group + } + + private struct PacketDialogContext { + let kind: PacketDialogKind + let dialogKey: String + let fromKey: String + let toKey: String + let fromMe: Bool + let toMe: Bool + } + + private static func normalizedGroupDialogIdentity(_ value: String) -> String { + let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines) + let lower = trimmed.lowercased() + if lower.hasPrefix("group:") { + let id = String(trimmed.dropFirst("group:".count)).trimmingCharacters(in: .whitespacesAndNewlines) + return id.isEmpty ? trimmed : "#group:\(id)" + } + return trimmed + } + private static func isUnsupportedDialogKey(_ value: String) -> Bool { let normalized = value.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() if normalized.isEmpty { return true } + if DatabaseManager.isGroupDialogKey(normalized) { return false } return normalized.hasPrefix("#") - || normalized.hasPrefix("group:") - || normalized.hasPrefix("conversation:") } private static func isSupportedDirectPeerKey(_ peerKey: String, ownKey: String) -> Bool { @@ -1705,34 +1895,117 @@ final class SessionManager { return !isUnsupportedDialogKey(normalized) } - private static func isSupportedDirectMessagePacket(_ packet: PacketMessage, ownKey: String) -> Bool { - if ownKey.isEmpty { return false } - let from = packet.fromPublicKey.trimmingCharacters(in: .whitespacesAndNewlines) - let to = packet.toPublicKey.trimmingCharacters(in: .whitespacesAndNewlines) - if from.isEmpty || to.isEmpty { return false } + private static func resolveDialogContext(from: String, to: String, ownKey: String) -> PacketDialogContext? { + if ownKey.isEmpty { return nil } + let fromKey = from.trimmingCharacters(in: .whitespacesAndNewlines) + let toKey = to.trimmingCharacters(in: .whitespacesAndNewlines) + if fromKey.isEmpty || toKey.isEmpty { return nil } - if from == ownKey { - return isSupportedDirectPeerKey(to, ownKey: ownKey) + if DatabaseManager.isGroupDialogKey(toKey) { + return PacketDialogContext( + kind: .group, + dialogKey: normalizedGroupDialogIdentity(toKey), + fromKey: fromKey, + toKey: toKey, + fromMe: fromKey == ownKey, + toMe: toKey == ownKey + ) } - if to == ownKey { - return isSupportedDirectPeerKey(from, ownKey: ownKey) + + if fromKey == ownKey { + guard isSupportedDirectPeerKey(toKey, ownKey: ownKey) else { return nil } + return PacketDialogContext( + kind: toKey == ownKey ? .saved : .direct, + dialogKey: toKey, + fromKey: fromKey, + toKey: toKey, + fromMe: true, + toMe: toKey == ownKey + ) } - return false + if toKey == ownKey { + guard isSupportedDirectPeerKey(fromKey, ownKey: ownKey) else { return nil } + return PacketDialogContext( + kind: fromKey == ownKey ? .saved : .direct, + dialogKey: fromKey, + fromKey: fromKey, + toKey: toKey, + fromMe: false, + toMe: true + ) + } + return nil } - private static func isSupportedDirectReadPacket(_ packet: PacketRead, ownKey: String) -> Bool { - if ownKey.isEmpty { return false } - let from = packet.fromPublicKey.trimmingCharacters(in: .whitespacesAndNewlines) - let to = packet.toPublicKey.trimmingCharacters(in: .whitespacesAndNewlines) - if from.isEmpty || to.isEmpty { return false } + private static func resolveMessagePacketContext(_ packet: PacketMessage, ownKey: String) -> PacketDialogContext? { + resolveDialogContext(from: packet.fromPublicKey, to: packet.toPublicKey, ownKey: ownKey) + } - if from == ownKey { - return isSupportedDirectPeerKey(to, ownKey: ownKey) + private static func resolveReadPacketContext(_ packet: PacketRead, ownKey: String) -> PacketDialogContext? { + resolveDialogContext(from: packet.fromPublicKey, to: packet.toPublicKey, ownKey: ownKey) + } + + private static func resolveTypingPacketContext(_ packet: PacketTyping, ownKey: String) -> PacketDialogContext? { + resolveDialogContext(from: packet.fromPublicKey, to: packet.toPublicKey, ownKey: ownKey) + } + + // MARK: - Test Support + + static func testResolveMessagePacketContext( + _ packet: PacketMessage, + ownKey: String + ) -> (kind: String, dialogKey: String, fromMe: Bool)? { + guard let context = resolveMessagePacketContext(packet, ownKey: ownKey) else { return nil } + let kind: String + switch context.kind { + case .direct: kind = "direct" + case .saved: kind = "saved" + case .group: kind = "group" } - if to == ownKey { - return isSupportedDirectPeerKey(from, ownKey: ownKey) + return (kind, context.dialogKey, context.fromMe) + } + + static func testResolveReadPacketContext( + _ packet: PacketRead, + ownKey: String + ) -> (kind: String, dialogKey: String, fromMe: Bool)? { + guard let context = resolveReadPacketContext(packet, ownKey: ownKey) else { return nil } + let kind: String + switch context.kind { + case .direct: kind = "direct" + case .saved: kind = "saved" + case .group: kind = "group" } - return false + return (kind, context.dialogKey, context.fromMe) + } + + static func testResolveTypingPacketContext( + _ packet: PacketTyping, + ownKey: String + ) -> (kind: String, dialogKey: String, fromMe: Bool)? { + guard let context = resolveTypingPacketContext(packet, ownKey: ownKey) else { return nil } + let kind: String + switch context.kind { + case .direct: kind = "direct" + case .saved: kind = "saved" + case .group: kind = "group" + } + return (kind, context.dialogKey, context.fromMe) + } + + func testConfigureSessionForParityFlows( + currentPublicKey: String, + privateKeyHex: String, + privateKeyHash: String? = nil + ) { + self.currentPublicKey = currentPublicKey + self.privateKeyHex = privateKeyHex + self.privateKeyHash = privateKeyHash ?? CryptoManager.shared.generatePrivateKeyHash(privateKeyHex: privateKeyHex) + } + + func testResetParityFlowDependencies() { + attachmentFlowTransport = LiveAttachmentFlowTransport() + packetFlowSender = LivePacketFlowSender() } /// Public convenience for views that need to trigger a user-info fetch. @@ -1744,6 +2017,7 @@ final class SessionManager { guard let privateKeyHash else { return } let normalized = opponentKey.trimmingCharacters(in: .whitespacesAndNewlines) guard !normalized.isEmpty else { return } + guard !DatabaseManager.isGroupDialogKey(normalized) else { return } guard !requestedUserInfoKeys.contains(normalized) else { return } requestedUserInfoKeys.insert(normalized) @@ -1769,6 +2043,7 @@ final class SessionManager { var hasName: [String] = [] for (key, dialog) in dialogs { guard key != ownKey, !key.isEmpty else { continue } + guard !DatabaseManager.isGroupDialogKey(key) else { continue } if dialog.opponentTitle.isEmpty { missingName.append(key) } else { @@ -1863,9 +2138,37 @@ final class SessionManager { privateKeyHex: String, privateKeyHash: String ) throws -> PacketMessage { + let normalizedTarget = toPublicKey.trimmingCharacters(in: .whitespacesAndNewlines) + if DatabaseManager.isGroupDialogKey(normalizedTarget) { + let normalizedGroupTarget = Self.normalizedGroupDialogIdentity(normalizedTarget) + guard let groupKey = GroupRepository.shared.groupKey( + account: currentPublicKey, + privateKeyHex: privateKeyHex, + groupDialogKey: normalizedGroupTarget + ) else { + throw CryptoError.invalidData("Missing group key for \(normalizedGroupTarget)") + } + + let encryptedContent = try CryptoManager.shared.encryptWithPasswordDesktopCompat( + Data(text.utf8), + password: groupKey + ) + + var packet = PacketMessage() + packet.fromPublicKey = currentPublicKey + packet.toPublicKey = normalizedGroupTarget + packet.content = encryptedContent + packet.chachaKey = "" + packet.timestamp = timestamp + packet.privateKey = privateKeyHash + packet.messageId = messageId + packet.aesChachaKey = "" + return packet + } + let encrypted = try MessageCrypto.encryptOutgoing( plaintext: text, - recipientPublicKeyHex: toPublicKey + recipientPublicKeyHex: normalizedTarget ) guard let latin1String = String(data: encrypted.plainKeyAndNonce, encoding: .isoLatin1) else { @@ -1879,7 +2182,7 @@ final class SessionManager { var packet = PacketMessage() packet.fromPublicKey = currentPublicKey - packet.toPublicKey = toPublicKey + packet.toPublicKey = normalizedTarget packet.content = encrypted.content packet.chachaKey = encrypted.chachaKey packet.timestamp = timestamp diff --git a/Rosetta/Core/Utils/AttachmentPreviewCodec.swift b/Rosetta/Core/Utils/AttachmentPreviewCodec.swift new file mode 100644 index 0000000..227adc6 --- /dev/null +++ b/Rosetta/Core/Utils/AttachmentPreviewCodec.swift @@ -0,0 +1,98 @@ +import Foundation + +/// Shared parser/composer for attachment preview fields. +/// +/// Cross-platform canonical formats: +/// - image/avatar: `tag::blurhash` or `::blurhash` (placeholder before upload) +/// - file: `tag::size::name` or `size::name` +/// - legacy/local-only: raw preview payload without `tag::` prefix +enum AttachmentPreviewCodec { + + struct ParsedFilePreview: Equatable { + let downloadTag: String + let fileSize: Int + let fileName: String + let payload: String + } + + static func isDownloadTag(_ value: String) -> Bool { + UUID(uuidString: value.trimmingCharacters(in: .whitespacesAndNewlines)) != nil + } + + static func downloadTag(from preview: String) -> String { + let firstPart = preview.components(separatedBy: "::").first ?? "" + return isDownloadTag(firstPart) ? firstPart : "" + } + + static func payload(from preview: String) -> String { + let parts = preview.components(separatedBy: "::") + guard parts.isEmpty == false else { return "" } + + if isDownloadTag(parts[0]) { + return normalizePayload(parts.dropFirst().joined(separator: "::")) + } + + // Placeholder preview before upload (`::blurhash` / `::size::name`). + if parts[0].isEmpty { + return normalizePayload(parts.dropFirst().joined(separator: "::")) + } + + return normalizePayload(preview) + } + + static func blurHash(from preview: String) -> String { + payload(from: preview) + } + + static func parseFilePreview( + _ preview: String, + fallbackFileName: String = "file", + fallbackFileSize: Int = 0 + ) -> ParsedFilePreview { + let tag = downloadTag(from: preview) + let normalizedPayload = payload(from: preview) + let components = normalizedPayload.components(separatedBy: "::") + + var fileSize = max(fallbackFileSize, 0) + var fileName = fallbackFileName + + if components.count >= 2, let parsedSize = Int(components[0]) { + fileSize = max(parsedSize, 0) + let joinedName = components.dropFirst().joined(separator: "::") + if joinedName.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false { + fileName = joinedName + } + } else if components.count >= 2 { + // Legacy payload without explicit size. + let joinedName = components.joined(separator: "::") + if joinedName.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false { + fileName = joinedName + } + } else if let onlyComponent = components.first, + onlyComponent.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false { + fileName = onlyComponent + } + + return ParsedFilePreview( + downloadTag: tag, + fileSize: fileSize, + fileName: fileName, + payload: normalizedPayload + ) + } + + static func compose(downloadTag: String, payload: String) -> String { + let tag = downloadTag.trimmingCharacters(in: .whitespacesAndNewlines) + let normalizedPayload = normalizePayload(payload) + if tag.isEmpty { return normalizedPayload } + return "\(tag)::\(normalizedPayload)" + } + + private static func normalizePayload(_ payload: String) -> String { + var value = payload.trimmingCharacters(in: .whitespacesAndNewlines) + while value.hasPrefix("::") { + value.removeFirst(2) + } + return value + } +} diff --git a/Rosetta/Core/Utils/SearchParityPolicy.swift b/Rosetta/Core/Utils/SearchParityPolicy.swift new file mode 100644 index 0000000..cb03327 --- /dev/null +++ b/Rosetta/Core/Utils/SearchParityPolicy.swift @@ -0,0 +1,120 @@ +import Foundation + +/// Shared normalization and merge policy for user search flows. +/// +/// Canonical semantics: +/// - server query: `username contains` + `publicKey exact` +/// - local augmentation: only Saved Messages aliases + exact known key fallback +enum SearchParityPolicy { + + private static let savedAliases: Set = [ + "saved", + "saved messages", + "savedmessages", + "notes", + "self", + "me", + ] + + static func sanitizeInput(_ query: String) -> String { + query.replacingOccurrences(of: "@", with: "") + .trimmingCharacters(in: .whitespacesAndNewlines) + } + + static func normalizedQuery(_ query: String) -> String { + sanitizeInput(query).lowercased() + } + + static func normalizedPublicKey(_ key: String) -> String { + let trimmed = key.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + if trimmed.hasPrefix("0x") { + return String(trimmed.dropFirst(2)) + } + return trimmed + } + + static func isExactPublicKeyQuery(_ query: String) -> Bool { + let normalized = normalizedPublicKey(query) + guard normalized.count == 66 else { return false } + return normalized.allSatisfy(\.isHexDigit) + } + + static func isSavedMessagesAlias(_ query: String) -> Bool { + savedAliases.contains(normalizedQuery(query)) + } + + static func localAugmentedUsers( + query: String, + currentPublicKey: String, + dialogs: [Dialog] + ) -> [SearchUser] { + let normalized = normalizedQuery(query) + guard !normalized.isEmpty else { return [] } + + var results: [SearchUser] = [] + + if isSavedMessagesAlias(normalized) || matchesExactPublicKey(query: normalized, publicKey: currentPublicKey) { + results.append(SearchUser( + username: "", + title: "Saved Messages", + publicKey: currentPublicKey, + verified: 0, + online: 0 + )) + } + + guard isExactPublicKeyQuery(normalized) else { + return dedupByPublicKey(results) + } + + for dialog in dialogs where matchesExactPublicKey(query: normalized, publicKey: dialog.opponentKey) { + guard dialog.opponentKey != currentPublicKey else { continue } + results.append(SearchUser( + username: dialog.opponentUsername, + title: dialog.opponentTitle, + publicKey: dialog.opponentKey, + verified: dialog.verified, + online: dialog.isOnline ? 0 : 1 + )) + } + + return dedupByPublicKey(results) + } + + static func mergeServerAndLocal(server: [SearchUser], local: [SearchUser]) -> [SearchUser] { + var merged: [SearchUser] = [] + var seen = Set() + + for user in server { + let key = normalizedPublicKey(user.publicKey) + guard !seen.contains(key) else { continue } + seen.insert(key) + merged.append(user) + } + + for user in local { + let key = normalizedPublicKey(user.publicKey) + guard !seen.contains(key) else { continue } + seen.insert(key) + merged.append(user) + } + + return merged + } + + private static func matchesExactPublicKey(query: String, publicKey: String) -> Bool { + normalizedPublicKey(query) == normalizedPublicKey(publicKey) + } + + private static func dedupByPublicKey(_ users: [SearchUser]) -> [SearchUser] { + var seen = Set() + var deduped: [SearchUser] = [] + for user in users { + let key = normalizedPublicKey(user.publicKey) + guard !seen.contains(key) else { continue } + seen.insert(key) + deduped.append(user) + } + return deduped + } +} diff --git a/Rosetta/Features/Auth/UnlockView.swift b/Rosetta/Features/Auth/UnlockView.swift index cce3ee5..4393642 100644 --- a/Rosetta/Features/Auth/UnlockView.swift +++ b/Rosetta/Features/Auth/UnlockView.swift @@ -310,9 +310,14 @@ private extension UnlockView { } onUnlocked() + } catch let sessionError as SessionManager.StartSessionError { + withAnimation(.easeInOut(duration: 0.2)) { + errorMessage = unlockMessage(for: sessionError) + } + isUnlocking = false } catch { withAnimation(.easeInOut(duration: 0.2)) { - errorMessage = "Wrong password. Please try again." + errorMessage = unlockMessage(for: error) } isUnlocking = false } @@ -398,11 +403,23 @@ private extension UnlockView { } catch { // SessionManager.startSession failed — stored password might be wrong withAnimation(.easeInOut(duration: 0.2)) { - errorMessage = "Wrong password. Please try again." + errorMessage = unlockMessage(for: error) } isUnlocking = false } } + + func unlockMessage(for error: Error) -> String { + if let sessionError = error as? SessionManager.StartSessionError { + switch sessionError { + case .invalidCredentials: + return "Wrong password. Please try again." + case .databaseBootstrapFailed: + return "Database migration failed. Please restart the app and try again." + } + } + return "Wrong password. Please try again." + } } // MARK: - UIKit Password Field diff --git a/Rosetta/Features/Chats/ChatDetail/BubbleTailShape.swift b/Rosetta/Features/Chats/ChatDetail/BubbleTailShape.swift index 15600d1..4eec3fc 100644 --- a/Rosetta/Features/Chats/ChatDetail/BubbleTailShape.swift +++ b/Rosetta/Features/Chats/ChatDetail/BubbleTailShape.swift @@ -1,224 +1,30 @@ import SwiftUI -// MARK: - Bubble Position - -enum BubblePosition: Sendable, Equatable { - case single, top, mid, bottom -} - // MARK: - Message Bubble Shape -/// Unified message bubble shape: rounded-rect body + tail drawn as a **single fill**. -/// -/// The body and tail are two closed subpaths inside one `Path`. -/// Non-zero winding rule fills the overlap area seamlessly — -/// no anti-aliasing seam between body and tail. -/// -/// For positions without a tail (`.top`, `.mid`), only the body subpath is drawn. -/// For positions with a tail (`.single`, `.bottom`), both subpaths are drawn. -/// -/// The shape's `rect` includes space for the tail protrusion on the near side. -/// The body is inset from that side; the tail fills the protrusion area. struct MessageBubbleShape: Shape { let position: BubblePosition let outgoing: Bool - let hasTail: Bool - - /// How far the tail protrudes beyond the bubble body edge (points). - static let tailProtrusion: CGFloat = 6 + private let mergeType: BubbleMergeType + private let metrics: BubbleMetrics init(position: BubblePosition, outgoing: Bool) { self.position = position self.outgoing = outgoing - switch position { - case .single, .bottom: self.hasTail = true - case .top, .mid: self.hasTail = false - } + self.mergeType = BubbleGeometryEngine.mergeType(for: position) + self.metrics = .telegram() + } + + static var tailProtrusion: CGFloat { + BubbleMetrics.telegram().tailProtrusion } func path(in rect: CGRect) -> Path { - var p = Path() - - // Body rect: inset on the near side when tail is present - let bodyRect: CGRect - if hasTail { - if outgoing { - bodyRect = CGRect(x: rect.minX, y: rect.minY, - width: rect.width - Self.tailProtrusion, height: rect.height) - } else { - bodyRect = CGRect(x: rect.minX + Self.tailProtrusion, y: rect.minY, - width: rect.width - Self.tailProtrusion, height: rect.height) - } - } else { - bodyRect = rect - } - - addBody(to: &p, rect: bodyRect) - - if hasTail { - addTail(to: &p, bodyRect: bodyRect) - } - - return p - } - - // MARK: - Body (Rounded Rect with Per-Corner Radii) - - private func addBody(to p: inout Path, rect: CGRect) { - let r: CGFloat = 16 - let s: CGFloat = 5 - let (tl, tr, bl, br) = cornerRadii(r: r, s: s) - - // Clamp to half the smallest dimension - let maxR = min(rect.width, rect.height) / 2 - let cTL = min(tl, maxR) - let cTR = min(tr, maxR) - let cBL = min(bl, maxR) - let cBR = min(br, maxR) - - p.move(to: CGPoint(x: rect.minX + cTL, y: rect.minY)) - - // Top edge → top-right corner - p.addLine(to: CGPoint(x: rect.maxX - cTR, y: rect.minY)) - p.addArc(tangent1End: CGPoint(x: rect.maxX, y: rect.minY), - tangent2End: CGPoint(x: rect.maxX, y: rect.minY + cTR), - radius: cTR) - - // Right edge → bottom-right corner - p.addLine(to: CGPoint(x: rect.maxX, y: rect.maxY - cBR)) - p.addArc(tangent1End: CGPoint(x: rect.maxX, y: rect.maxY), - tangent2End: CGPoint(x: rect.maxX - cBR, y: rect.maxY), - radius: cBR) - - // Bottom edge → bottom-left corner - p.addLine(to: CGPoint(x: rect.minX + cBL, y: rect.maxY)) - p.addArc(tangent1End: CGPoint(x: rect.minX, y: rect.maxY), - tangent2End: CGPoint(x: rect.minX, y: rect.maxY - cBL), - radius: cBL) - - // Left edge → top-left corner - p.addLine(to: CGPoint(x: rect.minX, y: rect.minY + cTL)) - p.addArc(tangent1End: CGPoint(x: rect.minX, y: rect.minY), - tangent2End: CGPoint(x: rect.minX + cTL, y: rect.minY), - radius: cTL) - - p.closeSubpath() - } - - /// Figma corner radii: 8px on "connecting" side, 18px elsewhere. - private func cornerRadii(r: CGFloat, s: CGFloat) - -> (topLeading: CGFloat, topTrailing: CGFloat, - bottomLeading: CGFloat, bottomTrailing: CGFloat) { - switch position { - case .single: - return (r, r, r, r) - case .top: - return outgoing - ? (r, r, r, s) - : (r, r, s, r) - case .mid: - return outgoing - ? (r, s, r, s) - : (s, r, s, r) - case .bottom: - return outgoing - ? (r, s, r, r) - : (s, r, r, r) - } - } - - // MARK: - Tail (Figma SVG — separate subpath) - - /// Draws the tail as a second closed subpath that overlaps the body at the - /// bottom-near corner. Both subpaths are filled together in one `.fill()` call, - /// so the overlapping area has no visible seam. - /// - /// Uses the exact Figma SVG path (viewBox 0 0 13.6216 33.3). - /// Raw SVG: straight edge at x≈5.6, tip protrudes LEFT to x=0. - /// The `dir` multiplier flips the protrusion direction for outgoing. - private func addTail(to p: inout Path, bodyRect: CGRect) { - // Figma SVG straight edge X — defines the body attachment line - let svgStraightX: CGFloat = 5.59961 - let svgMaxY: CGFloat = 33.2305 - - // Uniform scale: maps SVG protrusion (5.6 units) to screen protrusion - let sc = Self.tailProtrusion / svgStraightX - - // Tail height in points - let tailH = svgMaxY * sc - - let bodyEdge = outgoing ? bodyRect.maxX : bodyRect.minX - let bottom = bodyRect.maxY - let top = bottom - tailH - - // +1 = protrude RIGHT (outgoing), −1 = protrude LEFT (incoming) - let dir: CGFloat = outgoing ? 1 : -1 - - // Map raw Figma SVG coord → screen coord - func tp(_ svgX: CGFloat, _ svgY: CGFloat) -> CGPoint { - let dx = (svgStraightX - svgX) * sc * dir - return CGPoint(x: bodyEdge + dx, y: top + svgY * sc) - } - - // -- Exact Figma SVG path (from Figma API, viewBox 0 0 13.6216 33.3) -- - // M5.59961 24.2305 - // C5.42042 28.0524 3.19779 31.339 0 33.0244 - // C0.851596 33.1596 1.72394 33.2305 2.6123 33.2305 - // C6.53776 33.2305 10.1517 31.8599 13.0293 29.5596 - // C10.7434 27.898 8.86922 25.7134 7.57422 23.1719 - // C5.61235 19.3215 5.6123 14.281 5.6123 4.2002 - // V0 H5.59961 V24.2305 Z - - if outgoing { - // Forward order — clockwise winding (matches body) - p.move(to: tp(5.59961, 24.2305)) - p.addCurve(to: tp(0, 33.0244), - control1: tp(5.42042, 28.0524), - control2: tp(3.19779, 31.339)) - p.addCurve(to: tp(2.6123, 33.2305), - control1: tp(0.851596, 33.1596), - control2: tp(1.72394, 33.2305)) - p.addCurve(to: tp(13.0293, 29.5596), - control1: tp(6.53776, 33.2305), - control2: tp(10.1517, 31.8599)) - p.addCurve(to: tp(7.57422, 23.1719), - control1: tp(10.7434, 27.898), - control2: tp(8.86922, 25.7134)) - p.addCurve(to: tp(5.6123, 4.2002), - control1: tp(5.61235, 19.3215), - control2: tp(5.6123, 14.281)) - p.addLine(to: tp(5.6123, 0)) - p.addLine(to: tp(5.59961, 0)) - p.addLine(to: tp(5.59961, 24.2305)) - p.closeSubpath() - } else { - // Reversed order — clockwise winding for incoming - // (mirroring X flips winding; reversing path order restores it) - p.move(to: tp(5.59961, 24.2305)) - p.addLine(to: tp(5.59961, 0)) - p.addLine(to: tp(5.6123, 0)) - p.addLine(to: tp(5.6123, 4.2002)) - // Curve 5 reversed (swap control points) - p.addCurve(to: tp(7.57422, 23.1719), - control1: tp(5.6123, 14.281), - control2: tp(5.61235, 19.3215)) - // Curve 4 reversed - p.addCurve(to: tp(13.0293, 29.5596), - control1: tp(8.86922, 25.7134), - control2: tp(10.7434, 27.898)) - // Curve 3 reversed - p.addCurve(to: tp(2.6123, 33.2305), - control1: tp(10.1517, 31.8599), - control2: tp(6.53776, 33.2305)) - // Curve 2 reversed - p.addCurve(to: tp(0, 33.0244), - control1: tp(1.72394, 33.2305), - control2: tp(0.851596, 33.1596)) - // Curve 1 reversed - p.addCurve(to: tp(5.59961, 24.2305), - control1: tp(3.19779, 31.339), - control2: tp(5.42042, 28.0524)) - p.closeSubpath() - } + BubbleGeometryEngine.makeSwiftUIPath( + in: rect, + mergeType: mergeType, + outgoing: outgoing, + metrics: metrics + ) } } diff --git a/Rosetta/Features/Chats/ChatDetail/ChatDetailView.swift b/Rosetta/Features/Chats/ChatDetail/ChatDetailView.swift index 9e4d47f..595bfb2 100644 --- a/Rosetta/Features/Chats/ChatDetail/ChatDetailView.swift +++ b/Rosetta/Features/Chats/ChatDetail/ChatDetailView.swift @@ -127,11 +127,21 @@ struct ChatDetailView: View { } private var maxBubbleWidth: CGFloat { - let w = UIScreen.main.bounds.width - if w <= 500 { - return max(224, min(w * 0.72, w - 104)) - } - return min(w * 0.66, 460) + let screenWidth = UIScreen.main.bounds.width + let listHorizontalInsets: CGFloat = 20 // NativeMessageList section insets: leading/trailing 10 + let bubbleHorizontalMargins: CGFloat = 16 // 8pt left + 8pt right bubble lane reserves + let availableWidth = max(40, screenWidth - listHorizontalInsets - bubbleHorizontalMargins) + + // Telegram ChatMessageItemWidthFill: + // compactInset = 36, compactWidthBoundary = 500, freeMaximumFillFactor = 0.85/0.65. + let compactInset: CGFloat = 36 + let freeFillFactor: CGFloat = screenWidth > 680 ? 0.65 : 0.85 + + let widthByInset = availableWidth - compactInset + let widthByFactor = availableWidth * freeFillFactor + let width = min(widthByInset, widthByFactor) + + return max(40, width) } /// Visual chat content: messages list + gradient overlays + background. @@ -1096,8 +1106,8 @@ private extension ChatDetailView { if let file = message.attachments.first(where: { $0.type == .file }) { let caption = message.text.trimmingCharacters(in: .whitespacesAndNewlines) if !caption.isEmpty { return caption } - let parts = file.preview.components(separatedBy: "::") - if parts.count >= 3 { return parts[2] } + let parsed = AttachmentPreviewCodec.parseFilePreview(file.preview) + if !parsed.fileName.isEmpty { return parsed.fileName } return file.id.isEmpty ? "File" : file.id } if message.attachments.contains(where: { $0.type == .avatar }) { return "Avatar" } @@ -1121,9 +1131,8 @@ private extension ChatDetailView { if let file = message.attachments.first(where: { $0.type == .file }) { let caption = message.text.trimmingCharacters(in: .whitespacesAndNewlines) if !caption.isEmpty { return caption } - // Parse filename from preview (tag::fileSize::fileName) - let parts = file.preview.components(separatedBy: "::") - if parts.count >= 3 { return parts[2] } + let parsed = AttachmentPreviewCodec.parseFilePreview(file.preview) + if !parsed.fileName.isEmpty { return parsed.fileName } return file.id.isEmpty ? "File" : file.id } if message.attachments.contains(where: { $0.type == .avatar }) { return "Avatar" } @@ -1261,7 +1270,7 @@ private extension ChatDetailView { for att in replyData.attachments { if att.type == AttachmentType.image.rawValue { // ── Image re-upload ── - if let image = AttachmentCache.shared.loadImage(forAttachmentId: att.id) { + if let image = AttachmentCache.shared.cachedImage(forAttachmentId: att.id) { // JPEG encoding (10-50ms) off main thread let jpegData = await Task.detached(priority: .userInitiated) { image.jpegData(compressionQuality: 0.85) @@ -1269,14 +1278,35 @@ private extension ChatDetailView { if let jpegData { forwardedImages[att.id] = jpegData #if DEBUG - print("📤 Image \(att.id.prefix(16)): loaded from cache (\(jpegData.count) bytes)") + print("📤 Image \(att.id.prefix(16)): loaded from memory cache (\(jpegData.count) bytes)") + #endif + continue + } + } + + // Slow path: disk I/O + decrypt off main thread. + await ImageLoadLimiter.shared.acquire() + let image = await Task.detached(priority: .userInitiated) { + AttachmentCache.shared.loadImage(forAttachmentId: att.id) + }.value + await ImageLoadLimiter.shared.release() + + if let image { + // JPEG encoding (10-50ms) off main thread + let jpegData = await Task.detached(priority: .userInitiated) { + image.jpegData(compressionQuality: 0.85) + }.value + if let jpegData { + forwardedImages[att.id] = jpegData + #if DEBUG + print("📤 Image \(att.id.prefix(16)): loaded from disk cache (\(jpegData.count) bytes)") #endif continue } } // Not in cache — download from CDN, decrypt, then include. - let cdnTag = att.preview.components(separatedBy: "::").first ?? "" + let cdnTag = AttachmentPreviewCodec.downloadTag(from: att.preview) guard !cdnTag.isEmpty else { #if DEBUG print("📤 Image \(att.id.prefix(16)): SKIP — empty CDN tag, preview='\(att.preview.prefix(30))'") @@ -1333,8 +1363,11 @@ private extension ChatDetailView { } 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" + let parsedFile = AttachmentPreviewCodec.parseFilePreview( + att.preview, + fallbackFileName: "file" + ) + let fileName = parsedFile.fileName // Try local cache first if let fileData = AttachmentCache.shared.loadFileData(forAttachmentId: att.id, fileName: fileName) { @@ -1346,7 +1379,7 @@ private extension ChatDetailView { } // Not in cache — download from CDN, decrypt - let cdnTag = parts.first ?? "" + let cdnTag = parsedFile.downloadTag guard !cdnTag.isEmpty else { #if DEBUG print("📤 File \(att.id.prefix(16)): SKIP — empty CDN tag") @@ -1947,18 +1980,28 @@ struct ForwardedImagePreviewCell: View { blurImage = MessageCellView.cachedBlurHash(hash, width: 64, height: 64) } - // Check cache immediately — image may already be there. - if let img = AttachmentCache.shared.loadImage(forAttachmentId: attachment.id) { + // Fast path: memory cache only (no disk/crypto on UI path). + if let img = AttachmentCache.shared.cachedImage(forAttachmentId: attachment.id) { cachedImage = img return } - // Retry: the original MessageImageView may still be downloading. - // Poll up to 5 times with 500ms intervals (2.5s total) — covers most download durations. + // Slow path: one background disk/decrypt attempt. + await ImageLoadLimiter.shared.acquire() + let loaded = await Task.detached(priority: .utility) { + AttachmentCache.shared.loadImage(forAttachmentId: attachment.id) + }.value + await ImageLoadLimiter.shared.release() + if let loaded, !Task.isCancelled { + cachedImage = loaded + return + } + + // Retry memory cache only: original MessageImageView may still be downloading. for _ in 0..<5 { try? await Task.sleep(for: .milliseconds(500)) if Task.isCancelled { return } - if let img = AttachmentCache.shared.loadImage(forAttachmentId: attachment.id) { + if let img = AttachmentCache.shared.cachedImage(forAttachmentId: attachment.id) { cachedImage = img return } @@ -1968,8 +2011,7 @@ struct ForwardedImagePreviewCell: View { private func extractBlurHash() -> String? { guard !attachment.preview.isEmpty else { return nil } - let parts = attachment.preview.components(separatedBy: "::") - let hash = parts.count > 1 ? parts[1] : attachment.preview + let hash = AttachmentPreviewCodec.blurHash(from: attachment.preview) return hash.isEmpty ? nil : hash } } @@ -2003,16 +2045,28 @@ struct ReplyQuoteThumbnail: View { } } .task { - // Check AttachmentCache for the actual downloaded photo. - if let cached = AttachmentCache.shared.loadImage(forAttachmentId: attachment.id) { + // Fast path: memory cache only. + if let cached = AttachmentCache.shared.cachedImage(forAttachmentId: attachment.id) { cachedImage = cached return } - // Retry — image may be downloading in MessageImageView. + + // Slow path: one background disk/decrypt attempt. + await ImageLoadLimiter.shared.acquire() + let loaded = await Task.detached(priority: .utility) { + AttachmentCache.shared.loadImage(forAttachmentId: attachment.id) + }.value + await ImageLoadLimiter.shared.release() + if let loaded, !Task.isCancelled { + cachedImage = loaded + return + } + + // Retry memory cache only — image may still be downloading in MessageImageView. for _ in 0..<5 { try? await Task.sleep(for: .milliseconds(500)) if Task.isCancelled { return } - if let cached = AttachmentCache.shared.loadImage(forAttachmentId: attachment.id) { + if let cached = AttachmentCache.shared.cachedImage(forAttachmentId: attachment.id) { cachedImage = cached return } diff --git a/Rosetta/Features/Chats/ChatDetail/FullScreenImageViewer.swift b/Rosetta/Features/Chats/ChatDetail/FullScreenImageViewer.swift index 0b4eee4..4a41e27 100644 --- a/Rosetta/Features/Chats/ChatDetail/FullScreenImageViewer.swift +++ b/Rosetta/Features/Chats/ChatDetail/FullScreenImageViewer.swift @@ -169,21 +169,33 @@ struct FullScreenImageViewer: View { struct FullScreenImageFromCache: View { let attachmentId: String let onDismiss: () -> Void + @State private var image: UIImage? + @State private var isLoading = true var body: some View { - if let image = AttachmentCache.shared.loadImage(forAttachmentId: attachmentId) { + if let image { FullScreenImageViewer(image: image, onDismiss: onDismiss) } else { - // Cache miss — show error with close button + // Cache miss/loading state — show placeholder with close button. ZStack { Color.black.ignoresSafeArea() - VStack(spacing: 16) { - Image(systemName: "photo") - .font(.system(size: 48)) - .foregroundStyle(.white.opacity(0.3)) - Text("Image not available") - .font(.system(size: 15)) - .foregroundStyle(.white.opacity(0.5)) + if isLoading { + VStack(spacing: 16) { + ProgressView() + .tint(.white) + Text("Loading...") + .font(.system(size: 15)) + .foregroundStyle(.white.opacity(0.5)) + } + } else { + VStack(spacing: 16) { + Image(systemName: "photo") + .font(.system(size: 48)) + .foregroundStyle(.white.opacity(0.3)) + Text("Image not available") + .font(.system(size: 15)) + .foregroundStyle(.white.opacity(0.5)) + } } VStack { HStack { @@ -204,6 +216,21 @@ struct FullScreenImageFromCache: View { Spacer() } } + .task(id: attachmentId) { + if let cached = AttachmentCache.shared.cachedImage(forAttachmentId: attachmentId) { + image = cached + isLoading = false + return + } + await ImageLoadLimiter.shared.acquire() + let loaded = await Task.detached(priority: .userInitiated) { + AttachmentCache.shared.loadImage(forAttachmentId: attachmentId) + }.value + await ImageLoadLimiter.shared.release() + guard !Task.isCancelled else { return } + image = loaded + isLoading = false + } } } } diff --git a/Rosetta/Features/Chats/ChatDetail/ImageGalleryViewer.swift b/Rosetta/Features/Chats/ChatDetail/ImageGalleryViewer.swift index 6913391..293597b 100644 --- a/Rosetta/Features/Chats/ChatDetail/ImageGalleryViewer.swift +++ b/Rosetta/Features/Chats/ChatDetail/ImageGalleryViewer.swift @@ -325,7 +325,13 @@ struct ImageGalleryViewer: View { for offset in [-2, -1, 1, 2] { let i = index + offset guard i >= 0, i < state.images.count else { continue } - _ = AttachmentCache.shared.loadImage(forAttachmentId: state.images[i].attachmentId) + let attachmentId = state.images[i].attachmentId + guard AttachmentCache.shared.cachedImage(forAttachmentId: attachmentId) == nil else { continue } + Task.detached(priority: .utility) { + await ImageLoadLimiter.shared.acquire() + _ = AttachmentCache.shared.loadImage(forAttachmentId: attachmentId) + await ImageLoadLimiter.shared.release() + } } } diff --git a/Rosetta/Features/Chats/ChatDetail/MediaBubbleCornerMaskFactory.swift b/Rosetta/Features/Chats/ChatDetail/MediaBubbleCornerMaskFactory.swift new file mode 100644 index 0000000..f5332be --- /dev/null +++ b/Rosetta/Features/Chats/ChatDetail/MediaBubbleCornerMaskFactory.swift @@ -0,0 +1,160 @@ +import UIKit + +enum MediaBubbleCornerMaskFactory { + private static let mainRadius: CGFloat = 16 + private static let mergedRadius: CGFloat = 8 + private static let inset: CGFloat = 2 + + static func containerMask( + bounds: CGRect, + mergeType: BubbleMergeType, + outgoing: Bool + ) -> CAShapeLayer { + let radii = mediaCornerRadii(mergeType: mergeType, outgoing: outgoing) + return makeMaskLayer( + in: bounds, + topLeft: radii.topLeft, + topRight: radii.topRight, + bottomLeft: radii.bottomLeft, + bottomRight: radii.bottomRight + ) + } + + static func tileMask( + tileFrame: CGRect, + containerBounds: CGRect, + mergeType: BubbleMergeType, + outgoing: Bool + ) -> CAShapeLayer? { + let eps: CGFloat = 0.5 + let touchesLeft = abs(tileFrame.minX - containerBounds.minX) <= eps + let touchesRight = abs(tileFrame.maxX - containerBounds.maxX) <= eps + let touchesTop = abs(tileFrame.minY - containerBounds.minY) <= eps + let touchesBottom = abs(tileFrame.maxY - containerBounds.maxY) <= eps + guard touchesLeft || touchesRight || touchesTop || touchesBottom else { + return nil + } + + let containerRadii = mediaCornerRadii(mergeType: mergeType, outgoing: outgoing) + let tl = (touchesTop && touchesLeft) ? containerRadii.topLeft : 0 + let tr = (touchesTop && touchesRight) ? containerRadii.topRight : 0 + let bl = (touchesBottom && touchesLeft) ? containerRadii.bottomLeft : 0 + let br = (touchesBottom && touchesRight) ? containerRadii.bottomRight : 0 + + let local = CGRect(origin: .zero, size: tileFrame.size) + return makeMaskLayer( + in: local, + topLeft: tl, + topRight: tr, + bottomLeft: bl, + bottomRight: br + ) + } + + private static func mediaCornerRadii( + mergeType: BubbleMergeType, + outgoing: Bool + ) -> (topLeft: CGFloat, topRight: CGFloat, bottomLeft: CGFloat, bottomRight: CGFloat) { + let metrics = BubbleMetrics( + mainRadius: mainRadius, + auxiliaryRadius: mergedRadius, + tailProtrusion: 6, + defaultSpacing: 0, + mergedSpacing: 0, + textInsets: .zero, + mediaStatusInsets: .zero + ) + let base = BubbleGeometryEngine.cornerRadii( + mergeType: mergeType, + outgoing: outgoing, + metrics: metrics + ) + + var adjusted = base + if BubbleGeometryEngine.hasTail(for: mergeType) { + if outgoing { + adjusted.bottomRight = min(adjusted.bottomRight, mergedRadius) + } else { + adjusted.bottomLeft = min(adjusted.bottomLeft, mergedRadius) + } + } + + return ( + topLeft: max(adjusted.topLeft - inset, 0), + topRight: max(adjusted.topRight - inset, 0), + bottomLeft: max(adjusted.bottomLeft - inset, 0), + bottomRight: max(adjusted.bottomRight - inset, 0) + ) + } + + private static func makeMaskLayer( + in rect: CGRect, + topLeft: CGFloat, + topRight: CGFloat, + bottomLeft: CGFloat, + bottomRight: CGFloat + ) -> CAShapeLayer { + let path = roundedPath( + in: rect, + topLeft: topLeft, + topRight: topRight, + bottomLeft: bottomLeft, + bottomRight: bottomRight + ) + let mask = CAShapeLayer() + mask.frame = rect + mask.path = path.cgPath + return mask + } + + private static func roundedPath( + in rect: CGRect, + topLeft: CGFloat, + topRight: CGFloat, + bottomLeft: CGFloat, + bottomRight: CGFloat + ) -> UIBezierPath { + let maxRadius = min(rect.width, rect.height) / 2 + let cTL = min(topLeft, maxRadius) + let cTR = min(topRight, maxRadius) + let cBL = min(bottomLeft, maxRadius) + let cBR = min(bottomRight, maxRadius) + + let path = UIBezierPath() + path.move(to: CGPoint(x: rect.minX + cTL, y: rect.minY)) + path.addLine(to: CGPoint(x: rect.maxX - cTR, y: rect.minY)) + path.addArc( + withCenter: CGPoint(x: rect.maxX - cTR, y: rect.minY + cTR), + radius: cTR, + startAngle: -.pi / 2, + endAngle: 0, + clockwise: true + ) + path.addLine(to: CGPoint(x: rect.maxX, y: rect.maxY - cBR)) + path.addArc( + withCenter: CGPoint(x: rect.maxX - cBR, y: rect.maxY - cBR), + radius: cBR, + startAngle: 0, + endAngle: .pi / 2, + clockwise: true + ) + path.addLine(to: CGPoint(x: rect.minX + cBL, y: rect.maxY)) + path.addArc( + withCenter: CGPoint(x: rect.minX + cBL, y: rect.maxY - cBL), + radius: cBL, + startAngle: .pi / 2, + endAngle: .pi, + clockwise: true + ) + path.addLine(to: CGPoint(x: rect.minX, y: rect.minY + cTL)) + path.addArc( + withCenter: CGPoint(x: rect.minX + cTL, y: rect.minY + cTL), + radius: cTL, + startAngle: .pi, + endAngle: -.pi / 2, + clockwise: true + ) + path.close() + return path + } +} diff --git a/Rosetta/Features/Chats/ChatDetail/MessageAvatarView.swift b/Rosetta/Features/Chats/ChatDetail/MessageAvatarView.swift index 5412d5b..ef90498 100644 --- a/Rosetta/Features/Chats/ChatDetail/MessageAvatarView.swift +++ b/Rosetta/Features/Chats/ChatDetail/MessageAvatarView.swift @@ -193,15 +193,14 @@ struct MessageAvatarView: View { /// 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] : "" + AttachmentPreviewCodec.blurHash(from: preview) } // MARK: - Download private func loadFromCache() async { - // Fast path: NSCache hit (synchronous, sub-microsecond) - if let cached = AttachmentCache.shared.loadImage(forAttachmentId: attachment.id) { + // Fast path: memory-only NSCache hit (no disk/crypto on main thread). + if let cached = AttachmentCache.shared.cachedImage(forAttachmentId: attachment.id) { avatarImage = cached showAvatar = true // No animation for cached — show immediately return @@ -322,7 +321,6 @@ struct MessageAvatarView: View { /// Extracts the server tag from preview string. /// Format: "tag::blurhash" → returns "tag". private func extractTag(from preview: String) -> String { - let parts = preview.components(separatedBy: "::") - return parts.first ?? preview + AttachmentPreviewCodec.downloadTag(from: preview) } } diff --git a/Rosetta/Features/Chats/ChatDetail/MessageCellView.swift b/Rosetta/Features/Chats/ChatDetail/MessageCellView.swift index 699776d..8104a35 100644 --- a/Rosetta/Features/Chats/ChatDetail/MessageCellView.swift +++ b/Rosetta/Features/Chats/ChatDetail/MessageCellView.swift @@ -172,8 +172,8 @@ struct MessageCellView: View, Equatable { if hasCaption { return reply.message } if !imageAttachments.isEmpty { return hasVisualAttachments ? "" : "Photo" } if let file = fileAttachments.first { - let parts = file.preview.components(separatedBy: "::") - if parts.count > 2 { return parts[2] } + let parsed = AttachmentPreviewCodec.parseFilePreview(file.preview) + if !parsed.fileName.isEmpty { return parsed.fileName } return file.id.isEmpty ? "File" : file.id } if reply.attachments.contains(where: { $0.type == 3 }) { return "Avatar" } @@ -581,8 +581,7 @@ struct MessageCellView: View, Equatable { let imageAttachment = reply.attachments.first(where: { $0.type == 0 }) let blurHash: String? = { guard let att = imageAttachment, !att.preview.isEmpty else { return nil } - let parts = att.preview.components(separatedBy: "::") - let hash = parts.count > 1 ? parts[1] : att.preview + let hash = AttachmentPreviewCodec.blurHash(from: att.preview) return hash.isEmpty ? nil : hash }() @@ -628,8 +627,8 @@ struct MessageCellView: View, Equatable { @ViewBuilder private func forwardedFilePreview(attachment: ReplyAttachmentData, outgoing: Bool) -> some View { let filename: String = { - let parts = attachment.preview.components(separatedBy: "::") - if parts.count > 2 { return parts[2] } + let parsed = AttachmentPreviewCodec.parseFilePreview(attachment.preview) + if !parsed.fileName.isEmpty { return parsed.fileName } return attachment.id.isEmpty ? "File" : attachment.id }() HStack(spacing: 8) { diff --git a/Rosetta/Features/Chats/ChatDetail/MessageFileView.swift b/Rosetta/Features/Chats/ChatDetail/MessageFileView.swift index 0cfbee3..29fb880 100644 --- a/Rosetta/Features/Chats/ChatDetail/MessageFileView.swift +++ b/Rosetta/Features/Chats/ChatDetail/MessageFileView.swift @@ -93,11 +93,8 @@ struct MessageFileView: View { /// Parses "tag::filesize::filename" preview format. private var fileMetadata: (tag: String, size: Int, name: String) { - let parts = attachment.preview.components(separatedBy: "::") - let tag = parts.first ?? "" - let size = parts.count > 1 ? Int(parts[1]) ?? 0 : 0 - let name = parts.count > 2 ? parts[2] : "file" - return (tag, size, name) + let parsed = AttachmentPreviewCodec.parseFilePreview(attachment.preview) + return (parsed.downloadTag, parsed.fileSize, parsed.fileName) } private var fileName: String { fileMetadata.name } diff --git a/Rosetta/Features/Chats/ChatDetail/MessageImageView.swift b/Rosetta/Features/Chats/ChatDetail/MessageImageView.swift index 92fa9c6..178fe8b 100644 --- a/Rosetta/Features/Chats/ChatDetail/MessageImageView.swift +++ b/Rosetta/Features/Chats/ChatDetail/MessageImageView.swift @@ -230,8 +230,8 @@ struct MessageImageView: View { private func loadFromCache() async { PerformanceLogger.shared.track("image.cacheLoad") - // Fast path: NSCache hit (synchronous, sub-microsecond) - if let cached = AttachmentCache.shared.loadImage(forAttachmentId: attachment.id) { + // Fast path: memory-only NSCache hit (no disk/crypto on main thread). + if let cached = AttachmentCache.shared.cachedImage(forAttachmentId: attachment.id) { image = cached return } @@ -331,12 +331,10 @@ struct MessageImageView: View { // MARK: - Preview Parsing private func extractTag(from preview: String) -> String { - let parts = preview.components(separatedBy: "::") - return parts.first ?? preview + AttachmentPreviewCodec.downloadTag(from: preview) } private func extractBlurHash(from preview: String) -> String { - let parts = preview.components(separatedBy: "::") - return parts.count > 1 ? parts[1] : "" + AttachmentPreviewCodec.blurHash(from: preview) } } diff --git a/Rosetta/Features/Chats/ChatDetail/NativeMessageCell.swift b/Rosetta/Features/Chats/ChatDetail/NativeMessageCell.swift index 5cfcca4..0408851 100644 --- a/Rosetta/Features/Chats/ChatDetail/NativeMessageCell.swift +++ b/Rosetta/Features/Chats/ChatDetail/NativeMessageCell.swift @@ -23,110 +23,23 @@ final class NativeMessageCell: UICollectionViewCell, UIContextMenuInteractionDel private static let forwardNameFont = UIFont.systemFont(ofSize: 14, weight: .semibold) private static let fileNameFont = UIFont.systemFont(ofSize: 14, weight: .medium) private static let fileSizeFont = UIFont.systemFont(ofSize: 12, weight: .regular) - private static let statusBubbleInsets = UIEdgeInsets(top: 3, left: 7, bottom: 3, right: 7) + private static let bubbleMetrics = BubbleMetrics.telegram() + private static let statusBubbleInsets = bubbleMetrics.mediaStatusInsets private static let sendingClockAnimationKey = "clockFrameAnimation" - // MARK: - Telegram Check Images (CGContext — ported from PresentationThemeEssentialGraphics.swift) - - /// Telegram-exact checkmark image via CGContext stroke. - /// `partial: true` → single arm (/), `partial: false` → full V (✓). - /// Canvas: 11-unit coordinate space scaled to `width` pt. - private static func generateTelegramCheck(partial: Bool, color: UIColor, width: CGFloat = 11) -> UIImage? { - let height = floor(width * 9.0 / 11.0) - let renderer = UIGraphicsImageRenderer(size: CGSize(width: width, height: height)) - return renderer.image { ctx in - let gc = ctx.cgContext - // Keep UIKit default Y-down coordinates; Telegram check path points - // are already authored for this orientation in our renderer. - gc.clear(CGRect(x: 0, y: 0, width: width, height: height)) - gc.scaleBy(x: width / 11.0, y: width / 11.0) - gc.translateBy(x: 1.0, y: 1.0) - gc.setStrokeColor(color.cgColor) - gc.setLineWidth(0.99) - gc.setLineCap(.round) - gc.setLineJoin(.round) - if partial { - // Single arm: bottom-left → top-right diagonal - gc.move(to: CGPoint(x: 0.5, y: 7)) - gc.addLine(to: CGPoint(x: 7, y: 0)) - } else { - // Full V: left → bottom-center (rounded tip) → top-right - gc.move(to: CGPoint(x: 0, y: 4)) - gc.addLine(to: CGPoint(x: 2.95157047, y: 6.95157047)) - gc.addCurve(to: CGPoint(x: 3.04490857, y: 6.95157047), - control1: CGPoint(x: 2.97734507, y: 6.97734507), - control2: CGPoint(x: 3.01913396, y: 6.97734507)) - gc.addCurve(to: CGPoint(x: 3.04660389, y: 6.9498112), - control1: CGPoint(x: 3.04548448, y: 6.95099456), - control2: CGPoint(x: 3.04604969, y: 6.95040803)) - gc.addLine(to: CGPoint(x: 9.5, y: 0)) - } - gc.strokePath() - } - } - - /// Telegram-exact clock frame image. - private static func generateTelegramClockFrame(color: UIColor) -> UIImage? { - let size = CGSize(width: 11, height: 11) - let renderer = UIGraphicsImageRenderer(size: size) - return renderer.image { ctx in - let gc = ctx.cgContext - // Telegram uses `generateImage(contextGenerator:)` (non-rotated context). - // Flip UIKit context to the same Y-up coordinate space. - gc.translateBy(x: 0, y: size.height) - gc.scaleBy(x: 1, y: -1) - gc.clear(CGRect(origin: .zero, size: size)) - gc.setStrokeColor(color.cgColor) - gc.setFillColor(color.cgColor) - gc.setLineWidth(1.0) - gc.strokeEllipse(in: CGRect(x: 0.5, y: 0.5, width: 10, height: 10)) - gc.fill(CGRect(x: 5.0, y: 3.0, width: 1.0, height: 2.5)) - } - } - - /// Telegram-exact clock minute/hour image. - private static func generateTelegramClockMin(color: UIColor) -> UIImage? { - let size = CGSize(width: 11, height: 11) - let renderer = UIGraphicsImageRenderer(size: size) - return renderer.image { ctx in - let gc = ctx.cgContext - // Match Telegram's non-rotated drawing context coordinates. - gc.translateBy(x: 0, y: size.height) - gc.scaleBy(x: 1, y: -1) - gc.clear(CGRect(origin: .zero, size: size)) - gc.setFillColor(color.cgColor) - gc.fill(CGRect(x: 5.0, y: 5.0, width: 4.5, height: 1.0)) - } - } - - /// Error indicator (circle with exclamation mark). - private static func generateErrorIcon(color: UIColor, width: CGFloat = 20) -> UIImage? { - let size = CGSize(width: width, height: width) - let renderer = UIGraphicsImageRenderer(size: size) - return renderer.image { ctx in - let gc = ctx.cgContext - gc.scaleBy(x: width / 11.0, y: width / 11.0) - gc.setFillColor(color.cgColor) - gc.fillEllipse(in: CGRect(x: 0.0, y: 0.0, width: 11.0, height: 11.0)) - gc.setFillColor(UIColor.white.cgColor) - gc.fill(CGRect(x: 5.0, y: 2.5, width: 1.0, height: 4.25)) - gc.fillEllipse(in: CGRect(x: 4.75, y: 7.8, width: 1.5, height: 1.5)) - } - } - // Pre-rendered images (cached at class load — Telegram caches in PrincipalThemeEssentialGraphics) private static let outgoingCheckColor = UIColor.white private static let outgoingClockColor = UIColor.white.withAlphaComponent(0.5) private static let mediaMetaColor = UIColor.white - private static let fullCheckImage = generateTelegramCheck(partial: false, color: outgoingCheckColor) - private static let partialCheckImage = generateTelegramCheck(partial: true, color: outgoingCheckColor) - private static let clockFrameImage = generateTelegramClockFrame(color: outgoingClockColor) - private static let clockMinImage = generateTelegramClockMin(color: outgoingClockColor) - private static let mediaFullCheckImage = generateTelegramCheck(partial: false, color: mediaMetaColor) - private static let mediaPartialCheckImage = generateTelegramCheck(partial: true, color: mediaMetaColor) - private static let mediaClockFrameImage = generateTelegramClockFrame(color: mediaMetaColor) - private static let mediaClockMinImage = generateTelegramClockMin(color: mediaMetaColor) - private static let errorIcon = generateErrorIcon(color: .systemRed) + private static let fullCheckImage = StatusIconRenderer.makeCheckImage(partial: false, color: outgoingCheckColor) + private static let partialCheckImage = StatusIconRenderer.makeCheckImage(partial: true, color: outgoingCheckColor) + private static let clockFrameImage = StatusIconRenderer.makeClockFrameImage(color: outgoingClockColor) + private static let clockMinImage = StatusIconRenderer.makeClockMinImage(color: outgoingClockColor) + private static let mediaFullCheckImage = StatusIconRenderer.makeCheckImage(partial: false, color: mediaMetaColor) + private static let mediaPartialCheckImage = StatusIconRenderer.makeCheckImage(partial: true, color: mediaMetaColor) + private static let mediaClockFrameImage = StatusIconRenderer.makeClockFrameImage(color: mediaMetaColor) + private static let mediaClockMinImage = StatusIconRenderer.makeClockMinImage(color: mediaMetaColor) + private static let errorIcon = StatusIconRenderer.makeErrorIcon(color: .systemRed) private static let maxVisiblePhotoTiles = 5 private static let blurHashCache: NSCache = { let cache = NSCache() @@ -197,6 +110,7 @@ final class NativeMessageCell: UICollectionViewCell, UIContextMenuInteractionDel private var totalPhotoAttachmentCount = 0 private var photoLoadTasks: [String: Task] = [:] private var photoDownloadTasks: [String: Task] = [:] + private var photoBlurHashTasks: [String: Task] = [:] private var downloadingAttachmentIds: Set = [] private var failedAttachmentIds: Set = [] @@ -394,7 +308,10 @@ final class NativeMessageCell: UICollectionViewCell, UIContextMenuInteractionDel self.actions = actions let isOutgoing = currentLayout?.isOutgoing ?? false - let isMediaStatus = currentLayout?.messageType == .photo + let isMediaStatus: Bool = { + guard let type = currentLayout?.messageType else { return false } + return type == .photo || type == .photoWithCaption + }() // Text — use cached CoreTextTextLayout from measurement phase. // Same CTTypesetter pipeline → identical line breaks, zero recomputation. @@ -482,7 +399,11 @@ final class NativeMessageCell: UICollectionViewCell, UIContextMenuInteractionDel if let layout = currentLayout, layout.hasFile { fileContainer.isHidden = false let fileAtt = message.attachments.first { $0.type == .file } - fileNameLabel.text = fileAtt?.preview.components(separatedBy: "::").last ?? "File" + if let fileAtt { + fileNameLabel.text = AttachmentPreviewCodec.parseFilePreview(fileAtt.preview).fileName + } else { + fileNameLabel.text = "File" + } fileSizeLabel.text = "" } else { fileContainer.isHidden = true @@ -502,14 +423,15 @@ final class NativeMessageCell: UICollectionViewCell, UIContextMenuInteractionDel guard let layout = currentLayout else { return } let cellW = contentView.bounds.width - let tailW: CGFloat = layout.hasTail ? 6 : 0 + let tailProtrusion = Self.bubbleMetrics.tailProtrusion + let tailW: CGFloat = layout.hasTail ? tailProtrusion : 0 // Rule 2: Tail reserve (6pt) + margin (2pt) — strict vertical body alignment let bubbleX: CGFloat if layout.isOutgoing { - bubbleX = cellW - layout.bubbleSize.width - 6 - 2 - layout.deliveryFailedInset + bubbleX = cellW - layout.bubbleSize.width - tailProtrusion - 2 - layout.deliveryFailedInset } else { - bubbleX = 6 + 2 + bubbleX = tailProtrusion + 2 } bubbleView.frame = CGRect( @@ -522,17 +444,19 @@ final class NativeMessageCell: UICollectionViewCell, UIContextMenuInteractionDel if layout.hasTail { if layout.isOutgoing { shapeRect = CGRect(x: 0, y: 0, - width: layout.bubbleSize.width + 6, height: layout.bubbleSize.height) + width: layout.bubbleSize.width + tailProtrusion, height: layout.bubbleSize.height) } else { - shapeRect = CGRect(x: -6, y: 0, - width: layout.bubbleSize.width + 6, height: layout.bubbleSize.height) + shapeRect = CGRect(x: -tailProtrusion, y: 0, + width: layout.bubbleSize.width + tailProtrusion, height: layout.bubbleSize.height) } } else { shapeRect = CGRect(origin: .zero, size: layout.bubbleSize) } bubbleLayer.path = BubblePathCache.shared.path( size: shapeRect.size, origin: shapeRect.origin, - position: layout.position, isOutgoing: layout.isOutgoing, hasTail: layout.hasTail + mergeType: layout.mergeType, + isOutgoing: layout.isOutgoing, + metrics: Self.bubbleMetrics ) bubbleLayer.shadowPath = bubbleLayer.path bubbleOutlineLayer.frame = bubbleView.bounds @@ -569,6 +493,9 @@ final class NativeMessageCell: UICollectionViewCell, UIContextMenuInteractionDel checkReadView.frame = layout.checkReadFrame clockFrameView.frame = layout.clockFrame clockMinView.frame = layout.clockFrame + #if DEBUG + assertStatusLaneFramesValid(layout: layout) + #endif // Telegram-style date/status pill on media-only bubbles. updateStatusBackgroundFrame() @@ -734,12 +661,30 @@ final class NativeMessageCell: UICollectionViewCell, UIContextMenuInteractionDel } let attachment = photoAttachments[sender.tag] - if AttachmentCache.shared.loadImage(forAttachmentId: attachment.id) != nil { + if AttachmentCache.shared.cachedImage(forAttachmentId: attachment.id) != nil { actions.onImageTap(attachment.id) return } - downloadPhotoAttachment(attachment: attachment, message: message) + Task { [weak self] in + await ImageLoadLimiter.shared.acquire() + let loaded = await Task.detached(priority: .userInitiated) { + AttachmentCache.shared.loadImage(forAttachmentId: attachment.id) + }.value + await ImageLoadLimiter.shared.release() + guard !Task.isCancelled else { return } + await MainActor.run { + guard let self, + self.message?.id == message.id else { + return + } + if loaded != nil { + actions.onImageTap(attachment.id) + } else { + self.downloadPhotoAttachment(attachment: attachment, message: message) + } + } + } } private func configurePhoto(for message: ChatMessage) { @@ -767,6 +712,10 @@ final class NativeMessageCell: UICollectionViewCell, UIContextMenuInteractionDel downloadingAttachmentIds.remove(attachmentId) failedAttachmentIds.remove(attachmentId) } + for (attachmentId, task) in photoBlurHashTasks where !activeIds.contains(attachmentId) { + task.cancel() + photoBlurHashTasks.removeValue(forKey: attachmentId) + } for index in 0..= 0, lastVisibleIndex < frames.count else { return } + let tileFrame = frames[lastVisibleIndex] + guard let prototypeMask = MediaBubbleCornerMaskFactory.tileMask( + tileFrame: tileFrame, + containerBounds: photoContainer.bounds, + mergeType: layout.mergeType, + outgoing: layout.isOutgoing + ) else { + return + } + applyMaskPrototype(prototypeMask, to: photoTileImageViews[lastVisibleIndex]) + applyMaskPrototype(prototypeMask, to: photoTilePlaceholderViews[lastVisibleIndex]) + applyMaskPrototype(prototypeMask, to: photoTileButtons[lastVisibleIndex]) + + // Keep overflow badge clipping aligned with the same rounded corner. + applyMaskPrototype(prototypeMask, to: photoOverflowOverlayView) + } + + private func applyMaskPrototype(_ prototype: CAShapeLayer, to view: UIView) { + guard let path = prototype.path else { + view.layer.mask = nil + return + } let mask = CAShapeLayer() - mask.frame = rect - mask.path = path.cgPath - photoContainer.layer.mask = mask + mask.frame = CGRect(origin: .zero, size: view.bounds.size) + mask.path = path + view.layer.mask = mask } private static func photoTileFrames(count: Int, in bounds: CGRect) -> [CGRect] { @@ -1032,6 +989,41 @@ final class NativeMessageCell: UICollectionViewCell, UIContextMenuInteractionDel photoAttachments.firstIndex(where: { $0.id == attachmentId }) } + private func startPhotoBlurHashTask(attachment: MessageAttachment) { + let attachmentId = attachment.id + guard photoBlurHashTasks[attachmentId] == nil else { return } + let hash = Self.extractBlurHash(from: attachment.preview) + guard !hash.isEmpty else { return } + if let cached = Self.blurHashCache.object(forKey: hash as NSString) { + if let tileIndex = tileIndex(for: attachmentId), tileIndex < photoTileImageViews.count { + setPhotoTileImage(cached, at: tileIndex, animated: false) + photoTilePlaceholderViews[tileIndex].isHidden = true + } + return + } + + photoBlurHashTasks[attachmentId] = Task { [weak self] in + let decoded = await Task.detached(priority: .utility) { + UIImage.fromBlurHash(hash, width: 48, height: 48) + }.value + guard !Task.isCancelled else { return } + await MainActor.run { + guard let self else { return } + self.photoBlurHashTasks.removeValue(forKey: attachmentId) + guard let decoded, + let tileIndex = self.tileIndex(for: attachmentId), + tileIndex < self.photoTileImageViews.count else { + return + } + Self.blurHashCache.setObject(decoded, forKey: hash as NSString) + // Do not override already loaded real image. + guard self.photoTileImageViews[tileIndex].image == nil else { return } + self.setPhotoTileImage(decoded, at: tileIndex, animated: false) + self.photoTilePlaceholderViews[tileIndex].isHidden = true + } + } + } + private func startPhotoLoadTask(attachment: MessageAttachment) { if photoLoadTasks[attachment.id] != nil { return } let attachmentId = attachment.id @@ -1140,6 +1132,10 @@ final class NativeMessageCell: UICollectionViewCell, UIContextMenuInteractionDel task.cancel() } photoDownloadTasks.removeAll() + for task in photoBlurHashTasks.values { + task.cancel() + } + photoBlurHashTasks.removeAll() downloadingAttachmentIds.removeAll() failedAttachmentIds.removeAll() photoContainer.layer.mask = nil @@ -1151,35 +1147,30 @@ final class NativeMessageCell: UICollectionViewCell, UIContextMenuInteractionDel for index in 0.. String { - let parts = preview.components(separatedBy: "::") - return parts.first ?? preview + AttachmentPreviewCodec.downloadTag(from: preview) } private static func extractBlurHash(from preview: String) -> String { - let parts = preview.components(separatedBy: "::") - return parts.count > 1 ? parts[1] : "" + AttachmentPreviewCodec.blurHash(from: preview) } - private static func blurHashImage(from preview: String) -> UIImage? { + private static func cachedBlurHashImage(from preview: String) -> UIImage? { let hash = extractBlurHash(from: preview) guard !hash.isEmpty else { return nil } - if let cached = blurHashCache.object(forKey: hash as NSString) { - return cached - } - guard let image = UIImage.fromBlurHash(hash, width: 48, height: 48) else { - return nil - } - blurHashCache.setObject(image, forKey: hash as NSString) - return image + return blurHashCache.object(forKey: hash as NSString) } private static func decryptAndParseImage(encryptedString: String, passwords: [String]) -> UIImage? { @@ -1243,14 +1234,13 @@ final class NativeMessageCell: UICollectionViewCell, UIContextMenuInteractionDel private func animateCheckAppearanceIfNeeded(isSentVisible: Bool, isReadVisible: Bool) { if isSentVisible && !wasSentCheckVisible { - checkSentView.alpha = 0 - checkSentView.transform = CGAffineTransform(translationX: 2, y: 0).scaledBy(x: 0.9, y: 0.9) + checkSentView.alpha = 1 + checkSentView.transform = CGAffineTransform(scaleX: 1.3, y: 1.3) UIView.animate( - withDuration: 0.16, + withDuration: 0.1, delay: 0, options: [.curveEaseOut, .beginFromCurrentState] ) { - self.checkSentView.alpha = 1 self.checkSentView.transform = .identity } } else if !isSentVisible { @@ -1259,14 +1249,13 @@ final class NativeMessageCell: UICollectionViewCell, UIContextMenuInteractionDel } if isReadVisible && !wasReadCheckVisible { - checkReadView.alpha = 0 - checkReadView.transform = CGAffineTransform(translationX: 2, y: 0).scaledBy(x: 0.9, y: 0.9) + checkReadView.alpha = 1 + checkReadView.transform = CGAffineTransform(scaleX: 1.3, y: 1.3) UIView.animate( - withDuration: 0.16, - delay: 0.02, + withDuration: 0.1, + delay: 0, options: [.curveEaseOut, .beginFromCurrentState] ) { - self.checkReadView.alpha = 1 self.checkReadView.transform = .identity } } else if !isReadVisible { @@ -1312,6 +1301,30 @@ final class NativeMessageCell: UICollectionViewCell, UIContextMenuInteractionDel bubbleView.bringSubviewToFront(clockMinView) } + #if DEBUG + private func assertStatusLaneFramesValid(layout: MessageCellLayout) { + let bubbleBounds = CGRect(origin: .zero, size: layout.bubbleSize) + let frames = [ + ("timestamp", layout.timestampFrame), + ("checkSent", layout.checkSentFrame), + ("checkRead", layout.checkReadFrame), + ("clock", layout.clockFrame) + ] + + for (name, frame) in frames { + assert(frame.origin.x.isFinite && frame.origin.y.isFinite + && frame.size.width.isFinite && frame.size.height.isFinite, + "Status frame \(name) has non-finite values: \(frame)") + assert(frame.width >= 0 && frame.height >= 0, + "Status frame \(name) has negative size: \(frame)") + guard !frame.isEmpty else { continue } + let insetBounds = bubbleBounds.insetBy(dx: -1.0, dy: -1.0) + assert(insetBounds.contains(frame), + "Status frame \(name) is outside bubble bounds. frame=\(frame), bubble=\(bubbleBounds)") + } + } + #endif + // MARK: - Reuse override func prepareForReuse() { @@ -1369,18 +1382,35 @@ extension NativeMessageCell: UIGestureRecognizerDelegate { final class BubblePathCache { static let shared = BubblePathCache() - private let pathVersion = 8 + private let pathVersion = 9 private var cache: [String: CGPath] = [:] func path( size: CGSize, origin: CGPoint, - position: BubblePosition, isOutgoing: Bool, hasTail: Bool + mergeType: BubbleMergeType, + isOutgoing: Bool, + metrics: BubbleMetrics ) -> CGPath { - let key = "v\(pathVersion)_\(Int(size.width))x\(Int(size.height))_\(Int(origin.x))_\(position)_\(isOutgoing)_\(hasTail)" + let key = [ + "v\(pathVersion)", + "\(Int(size.width))x\(Int(size.height))", + "ox\(Int(origin.x))", + "oy\(Int(origin.y))", + "\(mergeType)", + "\(isOutgoing)", + "r\(Int(metrics.mainRadius))", + "m\(Int(metrics.auxiliaryRadius))", + "t\(Int(metrics.tailProtrusion))", + ].joined(separator: "_") if let cached = cache[key] { return cached } let rect = CGRect(origin: origin, size: size) - let path = makeBubblePath(in: rect, position: position, isOutgoing: isOutgoing, hasTail: hasTail) + let path = BubbleGeometryEngine.makeCGPath( + in: rect, + mergeType: mergeType, + outgoing: isOutgoing, + metrics: metrics + ) cache[key] = path // Evict if cache grows too large @@ -1390,101 +1420,4 @@ final class BubblePathCache { return path } - - private func makeBubblePath( - in rect: CGRect, position: BubblePosition, isOutgoing: Bool, hasTail: Bool - ) -> CGPath { - let r: CGFloat = 16, s: CGFloat = 5, tailW: CGFloat = 6 - - // Body rect - let bodyRect: CGRect - if hasTail { - bodyRect = isOutgoing - ? CGRect(x: rect.minX, y: rect.minY, width: rect.width - tailW, height: rect.height) - : CGRect(x: rect.minX + tailW, y: rect.minY, width: rect.width - tailW, height: rect.height) - } else { - bodyRect = rect - } - - // Corner radii - let (tl, tr, bl, br): (CGFloat, CGFloat, CGFloat, CGFloat) = { - switch position { - case .single: return (r, r, r, r) - case .top: return isOutgoing ? (r, r, r, s) : (r, r, s, r) - case .mid: return isOutgoing ? (r, s, r, s) : (s, r, s, r) - case .bottom: return isOutgoing ? (r, s, r, r) : (s, r, r, r) - } - }() - - let maxR = min(bodyRect.width, bodyRect.height) / 2 - let cTL = min(tl, maxR), cTR = min(tr, maxR) - let cBL = min(bl, maxR), cBR = min(br, maxR) - - let path = CGMutablePath() - - // Rounded rect body - path.move(to: CGPoint(x: bodyRect.minX + cTL, y: bodyRect.minY)) - path.addLine(to: CGPoint(x: bodyRect.maxX - cTR, y: bodyRect.minY)) - path.addArc(tangent1End: CGPoint(x: bodyRect.maxX, y: bodyRect.minY), - tangent2End: CGPoint(x: bodyRect.maxX, y: bodyRect.minY + cTR), radius: cTR) - path.addLine(to: CGPoint(x: bodyRect.maxX, y: bodyRect.maxY - cBR)) - path.addArc(tangent1End: CGPoint(x: bodyRect.maxX, y: bodyRect.maxY), - tangent2End: CGPoint(x: bodyRect.maxX - cBR, y: bodyRect.maxY), radius: cBR) - path.addLine(to: CGPoint(x: bodyRect.minX + cBL, y: bodyRect.maxY)) - path.addArc(tangent1End: CGPoint(x: bodyRect.minX, y: bodyRect.maxY), - tangent2End: CGPoint(x: bodyRect.minX, y: bodyRect.maxY - cBL), radius: cBL) - path.addLine(to: CGPoint(x: bodyRect.minX, y: bodyRect.minY + cTL)) - path.addArc(tangent1End: CGPoint(x: bodyRect.minX, y: bodyRect.minY), - tangent2End: CGPoint(x: bodyRect.minX + cTL, y: bodyRect.minY), radius: cTL) - path.closeSubpath() - - // Stable Figma tail (previous behavior) - if hasTail { - addFigmaTail(to: path, bodyRect: bodyRect, isOutgoing: isOutgoing) - } - - return path - } - - /// Figma SVG tail path (stable shape used before recent experiments). - private func addFigmaTail(to path: CGMutablePath, bodyRect: CGRect, isOutgoing: Bool) { - let svgStraightX: CGFloat = 5.59961 - let svgMaxY: CGFloat = 33.2305 - let scale: CGFloat = 6.0 / svgStraightX - let tailH = svgMaxY * scale - - let bodyEdge = isOutgoing ? bodyRect.maxX : bodyRect.minX - let bottom = bodyRect.maxY - let top = bottom - tailH - let dir: CGFloat = isOutgoing ? 1 : -1 - - func tp(_ svgX: CGFloat, _ svgY: CGFloat) -> CGPoint { - let dx = (svgStraightX - svgX) * scale * dir - return CGPoint(x: bodyEdge + dx, y: top + svgY * scale) - } - - if isOutgoing { - path.move(to: tp(5.59961, 24.2305)) - path.addCurve(to: tp(0, 33.0244), control1: tp(5.42042, 28.0524), control2: tp(3.19779, 31.339)) - path.addCurve(to: tp(2.6123, 33.2305), control1: tp(0.851596, 33.1596), control2: tp(1.72394, 33.2305)) - path.addCurve(to: tp(13.0293, 29.5596), control1: tp(6.53776, 33.2305), control2: tp(10.1517, 31.8599)) - path.addCurve(to: tp(7.57422, 23.1719), control1: tp(10.7434, 27.898), control2: tp(8.86922, 25.7134)) - path.addCurve(to: tp(5.6123, 4.2002), control1: tp(5.61235, 19.3215), control2: tp(5.6123, 14.281)) - path.addLine(to: tp(5.6123, 0)) - path.addLine(to: tp(5.59961, 0)) - path.addLine(to: tp(5.59961, 24.2305)) - path.closeSubpath() - } else { - path.move(to: tp(5.59961, 24.2305)) - path.addLine(to: tp(5.59961, 0)) - path.addLine(to: tp(5.6123, 0)) - path.addLine(to: tp(5.6123, 4.2002)) - path.addCurve(to: tp(7.57422, 23.1719), control1: tp(5.6123, 14.281), control2: tp(5.61235, 19.3215)) - path.addCurve(to: tp(13.0293, 29.5596), control1: tp(8.86922, 25.7134), control2: tp(10.7434, 27.898)) - path.addCurve(to: tp(2.6123, 33.2305), control1: tp(10.1517, 31.8599), control2: tp(6.53776, 33.2305)) - path.addCurve(to: tp(0, 33.0244), control1: tp(1.72394, 33.2305), control2: tp(0.851596, 33.1596)) - path.addCurve(to: tp(5.59961, 24.2305), control1: tp(3.19779, 31.339), control2: tp(5.42042, 28.0524)) - path.closeSubpath() - } - } } diff --git a/Rosetta/Features/Chats/ChatDetail/NativeMessageList.swift b/Rosetta/Features/Chats/ChatDetail/NativeMessageList.swift index deab269..bce40f1 100644 --- a/Rosetta/Features/Chats/ChatDetail/NativeMessageList.swift +++ b/Rosetta/Features/Chats/ChatDetail/NativeMessageList.swift @@ -13,6 +13,15 @@ import UIKit @MainActor final class NativeMessageListController: UIViewController { + private enum UIConstants { + static let messageToComposerGap: CGFloat = 16 + static let scrollButtonSize: CGFloat = 40 + static let scrollButtonIconCanvas: CGFloat = 38 + static let scrollButtonBaseTrailing: CGFloat = 8 + static let scrollButtonCompactExtraTrailing: CGFloat = 18 + static let scrollButtonBottomOffset: CGFloat = 20 + } + // MARK: - Configuration struct Config { @@ -73,6 +82,11 @@ final class NativeMessageListController: UIViewController { // MARK: - Scroll-to-Bottom Button private var scrollToBottomButton: UIButton? + private var scrollToBottomButtonContainer: UIView? + private var scrollToBottomTrailingConstraint: NSLayoutConstraint? + private var scrollToBottomBottomConstraint: NSLayoutConstraint? + private var scrollToBottomBadgeView: UIView? + private var scrollToBottomBadgeLabel: UILabel? /// Dedup for scrollViewDidScroll → onScrollToBottomVisibilityChange callback. private var lastReportedAtBottom: Bool = true @@ -142,6 +156,7 @@ final class NativeMessageListController: UIViewController { override func viewSafeAreaInsetsDidChange() { super.viewSafeAreaInsetsDidChange() applyInsets() + updateScrollToBottomButtonConstraints() // Update composer bottom when keyboard is hidden if currentKeyboardHeight == 0 { composerBottomConstraint?.constant = -view.safeAreaInsets.bottom @@ -327,7 +342,7 @@ final class NativeMessageListController: UIViewController { // MARK: - Scroll-to-Bottom Button (UIKit, pinned to composer) private func setupScrollToBottomButton(above composer: UIView) { - let size: CGFloat = 42 + let size = UIConstants.scrollButtonSize let rect = CGRect(x: 0, y: 0, width: size, height: size) // Container: Auto Layout positions it, clipsToBounds prevents overflow. @@ -339,41 +354,66 @@ final class NativeMessageListController: UIViewController { container.isUserInteractionEnabled = true view.addSubview(container) + let trailing = container.trailingAnchor.constraint( + equalTo: view.safeAreaLayoutGuide.trailingAnchor, + constant: -UIConstants.scrollButtonBaseTrailing + ) + let bottom = container.bottomAnchor.constraint( + equalTo: view.keyboardLayoutGuide.topAnchor, + constant: -(lastComposerHeight + UIConstants.scrollButtonBottomOffset) + ) NSLayoutConstraint.activate([ container.widthAnchor.constraint(equalToConstant: size), container.heightAnchor.constraint(equalToConstant: size), - container.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -16), - container.bottomAnchor.constraint(equalTo: view.keyboardLayoutGuide.topAnchor, constant: -76), + trailing, + bottom, ]) + scrollToBottomTrailingConstraint = trailing + scrollToBottomBottomConstraint = bottom + scrollToBottomButtonContainer = container - // Button: hardcoded 42×42 frame. NO UIView.transform — scale is done + // Button: hardcoded 40×40 frame. NO UIView.transform — scale is done // at CALayer level so UIKit never recalculates bounds through the // transform matrix during interactive keyboard dismiss. let button = UIButton(type: .custom) button.frame = rect button.clipsToBounds = true button.alpha = 0 - button.layer.transform = CATransform3DMakeScale(0.01, 0.01, 1.0) + button.layer.transform = CATransform3DMakeScale(0.2, 0.2, 1.0) button.layer.allowsEdgeAntialiasing = true container.addSubview(button) - // Glass circle background: hardcoded 42×42 frame, no autoresizingMask. + // Glass circle background: hardcoded 40×40 frame, no autoresizingMask. let glass = TelegramGlassUIView(frame: rect) glass.isCircle = true glass.isUserInteractionEnabled = false button.addSubview(glass) - // Chevron down icon: hardcoded 42×42 frame, centered contentMode. - let config = UIImage.SymbolConfiguration(pointSize: 14, weight: .semibold) - let chevron = UIImage(systemName: "chevron.down", withConfiguration: config) - let imageView = UIImageView(image: chevron) - imageView.tintColor = .white + // Telegram-style down icon (canvas 38×38, line width 1.5). + let imageView = UIImageView(image: Self.makeTelegramDownButtonImage()) imageView.contentMode = .center imageView.frame = rect button.addSubview(imageView) + let badgeView = UIView(frame: .zero) + badgeView.backgroundColor = UIColor(red: 0.2, green: 0.565, blue: 0.925, alpha: 1) + badgeView.layer.cornerCurve = .continuous + badgeView.layer.cornerRadius = 10 + badgeView.isHidden = true + button.addSubview(badgeView) + scrollToBottomBadgeView = badgeView + + let badgeLabel = UILabel(frame: .zero) + badgeLabel.font = UIFont.monospacedDigitSystemFont(ofSize: 13, weight: .regular) + badgeLabel.textColor = .white + badgeLabel.textAlignment = .center + badgeView.addSubview(badgeLabel) + scrollToBottomBadgeLabel = badgeLabel + button.addTarget(self, action: #selector(scrollToBottomTapped), for: .touchUpInside) scrollToBottomButton = button + updateScrollToBottomButtonConstraints() + updateScrollToBottomBadge() } @objc private func scrollToBottomTapped() { @@ -382,7 +422,7 @@ final class NativeMessageListController: UIViewController { } /// Show/hide the scroll-to-bottom button with CALayer-level scaling. - /// UIView.bounds stays 42×42 at ALL times — only rendered pixels scale. + /// UIView.bounds stays 40×40 at ALL times — only rendered pixels scale. /// No UIView.transform, no layoutIfNeeded — completely bypasses the /// Auto Layout ↔ transform race condition during interactive dismiss. func setScrollToBottomVisible(_ visible: Bool) { @@ -390,13 +430,79 @@ final class NativeMessageListController: UIViewController { let isCurrentlyVisible = button.alpha > 0.5 guard visible != isCurrentlyVisible else { return } - UIView.animate(withDuration: visible ? 0.25 : 0.2, delay: 0, - usingSpringWithDamping: 0.8, initialSpringVelocity: 0, + UIView.animate(withDuration: 0.3, delay: 0, + usingSpringWithDamping: 0.82, initialSpringVelocity: 0, options: .beginFromCurrentState) { button.alpha = visible ? 1 : 0 button.layer.transform = visible ? CATransform3DIdentity - : CATransform3DMakeScale(0.01, 0.01, 1.0) + : CATransform3DMakeScale(0.2, 0.2, 1.0) + } + updateScrollToBottomBadge() + } + + private func updateScrollToBottomButtonConstraints() { + let safeBottom = view.safeAreaInsets.bottom + let compactShift = safeBottom <= 32 ? UIConstants.scrollButtonCompactExtraTrailing : 0 + scrollToBottomTrailingConstraint?.constant = -(UIConstants.scrollButtonBaseTrailing + compactShift) + scrollToBottomBottomConstraint?.constant = -(lastComposerHeight + UIConstants.scrollButtonBottomOffset) + } + + private func updateScrollToBottomBadge() { + guard let badgeView = scrollToBottomBadgeView, + let badgeLabel = scrollToBottomBadgeLabel else { + return + } + let unreadCount = DialogRepository.shared.dialogs[config.opponentPublicKey]?.unreadCount ?? 0 + guard unreadCount > 0 else { + badgeView.isHidden = true + badgeLabel.text = nil + return + } + + let badgeText = Self.compactUnreadCountString(unreadCount) + badgeLabel.text = badgeText + let badgeFont = badgeLabel.font ?? UIFont.monospacedDigitSystemFont(ofSize: 13, weight: .regular) + let textWidth = ceil((badgeText as NSString).size(withAttributes: [.font: badgeFont]).width) + let badgeWidth = max(20, textWidth + 11) + let badgeFrame = CGRect( + x: floor((UIConstants.scrollButtonSize - badgeWidth) / 2), + y: -7, + width: badgeWidth, + height: 20 + ) + badgeView.frame = badgeFrame + badgeView.layer.cornerRadius = badgeFrame.height * 0.5 + badgeLabel.frame = badgeView.bounds + badgeView.isHidden = false + } + + private static func makeTelegramDownButtonImage() -> UIImage? { + let size = CGSize(width: UIConstants.scrollButtonIconCanvas, height: UIConstants.scrollButtonIconCanvas) + let renderer = UIGraphicsImageRenderer(size: size) + return renderer.image { ctx in + let gc = ctx.cgContext + gc.clear(CGRect(origin: .zero, size: size)) + gc.setStrokeColor(UIColor.white.cgColor) + gc.setLineWidth(1.5) + gc.setLineCap(.round) + gc.setLineJoin(.round) + + let position = CGPoint(x: 9.0 - 0.5, y: 23.0) + gc.move(to: CGPoint(x: position.x + 1.0, y: position.y - 1.0)) + gc.addLine(to: CGPoint(x: position.x + 10.0, y: position.y - 10.0)) + gc.addLine(to: CGPoint(x: position.x + 19.0, y: position.y - 1.0)) + gc.strokePath() + }.withRenderingMode(.alwaysOriginal) + } + + private static func compactUnreadCountString(_ count: Int) -> String { + if count >= 1_000_000 { + return "\(count / 1_000_000)M" + } else if count >= 1_000 { + return "\(count / 1_000)K" + } else { + return "\(count)" } } @@ -425,6 +531,7 @@ final class NativeMessageListController: UIViewController { } dataSource.apply(snapshot, animatingDifferences: animated) + updateScrollToBottomBadge() } // MARK: - Layout Calculation (Telegram asyncLayout pattern) @@ -473,10 +580,11 @@ final class NativeMessageListController: UIViewController { /// would double the adjustment → content teleports upward. private func applyInsets() { guard collectionView != nil else { return } + updateScrollToBottomButtonConstraints() let composerBottom = max(currentKeyboardHeight, view.safeAreaInsets.bottom) let composerHeight = lastComposerHeight - let newInsetTop = composerHeight + composerBottom + let newInsetTop = composerHeight + composerBottom + UIConstants.messageToComposerGap let topInset = view.safeAreaInsets.top + 6 let oldInsetTop = collectionView.contentInset.top @@ -496,6 +604,7 @@ final class NativeMessageListController: UIViewController { if shouldCompensate { collectionView.contentOffset.y = oldOffset - delta } + updateScrollToBottomBadge() } /// Scroll to the newest message (visual bottom = offset 0 in inverted scroll). @@ -588,7 +697,7 @@ final class NativeMessageListController: UIViewController { // Telegram pattern: animate composer position + content insets in ONE block. // Explicit composerHeightConstraint prevents the 372pt inflation bug. let composerH = lastComposerHeight - let newInsetTop = composerH + composerBottom + let newInsetTop = composerH + composerBottom + UIConstants.messageToComposerGap let topInset = view.safeAreaInsets.top + 6 let oldInsetTop = collectionView.contentInset.top let delta = newInsetTop - oldInsetTop @@ -655,6 +764,7 @@ extension NativeMessageListController: UICollectionViewDelegate { func scrollViewDidScroll(_ scrollView: UIScrollView) { let offsetFromBottom = scrollView.contentOffset.y + scrollView.contentInset.top let isAtBottom = offsetFromBottom < 50 + updateScrollToBottomBadge() // Dedup — only fire when value actually changes. // Without this, callback fires 60fps during keyboard animation diff --git a/Rosetta/Features/Chats/ChatDetail/StatusIconRenderer.swift b/Rosetta/Features/Chats/ChatDetail/StatusIconRenderer.swift new file mode 100644 index 0000000..ccdc259 --- /dev/null +++ b/Rosetta/Features/Chats/ChatDetail/StatusIconRenderer.swift @@ -0,0 +1,81 @@ +import UIKit + +enum StatusIconRenderer { + + static func makeCheckImage(partial: Bool, color: UIColor, width: CGFloat = 11) -> UIImage? { + let height = floor(width * 9.0 / 11.0) + let renderer = UIGraphicsImageRenderer(size: CGSize(width: width, height: height)) + return renderer.image { ctx in + let gc = ctx.cgContext + gc.clear(CGRect(x: 0, y: 0, width: width, height: height)) + gc.scaleBy(x: width / 11.0, y: width / 11.0) + gc.translateBy(x: 1.0, y: 1.0) + gc.setStrokeColor(color.cgColor) + gc.setLineWidth(0.99) + gc.setLineCap(.round) + gc.setLineJoin(.round) + if partial { + gc.move(to: CGPoint(x: 0.5, y: 7)) + gc.addLine(to: CGPoint(x: 7, y: 0)) + } else { + gc.move(to: CGPoint(x: 0, y: 4)) + gc.addLine(to: CGPoint(x: 2.95157047, y: 6.95157047)) + gc.addCurve( + to: CGPoint(x: 3.04490857, y: 6.95157047), + control1: CGPoint(x: 2.97734507, y: 6.97734507), + control2: CGPoint(x: 3.01913396, y: 6.97734507) + ) + gc.addCurve( + to: CGPoint(x: 3.04660389, y: 6.9498112), + control1: CGPoint(x: 3.04548448, y: 6.95099456), + control2: CGPoint(x: 3.04604969, y: 6.95040803) + ) + gc.addLine(to: CGPoint(x: 9.5, y: 0)) + } + gc.strokePath() + } + } + + static func makeClockFrameImage(color: UIColor) -> UIImage? { + let size = CGSize(width: 11, height: 11) + let renderer = UIGraphicsImageRenderer(size: size) + return renderer.image { ctx in + let gc = ctx.cgContext + gc.translateBy(x: 0, y: size.height) + gc.scaleBy(x: 1, y: -1) + gc.clear(CGRect(origin: .zero, size: size)) + gc.setStrokeColor(color.cgColor) + gc.setFillColor(color.cgColor) + gc.setLineWidth(1.0) + gc.strokeEllipse(in: CGRect(x: 0.5, y: 0.5, width: 10, height: 10)) + gc.fill(CGRect(x: 5.0, y: 3.0, width: 1.0, height: 2.5)) + } + } + + static func makeClockMinImage(color: UIColor) -> UIImage? { + let size = CGSize(width: 11, height: 11) + let renderer = UIGraphicsImageRenderer(size: size) + return renderer.image { ctx in + let gc = ctx.cgContext + gc.translateBy(x: 0, y: size.height) + gc.scaleBy(x: 1, y: -1) + gc.clear(CGRect(origin: .zero, size: size)) + gc.setFillColor(color.cgColor) + gc.fill(CGRect(x: 5.0, y: 5.0, width: 4.5, height: 1.0)) + } + } + + static func makeErrorIcon(color: UIColor, width: CGFloat = 20) -> UIImage? { + let size = CGSize(width: width, height: width) + let renderer = UIGraphicsImageRenderer(size: size) + return renderer.image { ctx in + let gc = ctx.cgContext + gc.scaleBy(x: width / 11.0, y: width / 11.0) + gc.setFillColor(color.cgColor) + gc.fillEllipse(in: CGRect(x: 0.0, y: 0.0, width: 11.0, height: 11.0)) + gc.setFillColor(UIColor.white.cgColor) + gc.fill(CGRect(x: 5.0, y: 2.5, width: 1.0, height: 4.25)) + gc.fillEllipse(in: CGRect(x: 4.75, y: 7.8, width: 1.5, height: 1.5)) + } + } +} diff --git a/Rosetta/Features/Chats/ChatDetail/ZoomableImagePage.swift b/Rosetta/Features/Chats/ChatDetail/ZoomableImagePage.swift index 6c9162f..38d4def 100644 --- a/Rosetta/Features/Chats/ChatDetail/ZoomableImagePage.swift +++ b/Rosetta/Features/Chats/ChatDetail/ZoomableImagePage.swift @@ -106,7 +106,18 @@ struct ZoomableImagePage: View { } } .task { - image = AttachmentCache.shared.loadImage(forAttachmentId: attachmentId) + if let cached = AttachmentCache.shared.cachedImage(forAttachmentId: attachmentId) { + image = cached + return + } + await ImageLoadLimiter.shared.acquire() + let loaded = await Task.detached(priority: .utility) { + AttachmentCache.shared.loadImage(forAttachmentId: attachmentId) + }.value + await ImageLoadLimiter.shared.release() + if !Task.isCancelled { + image = loaded + } } } diff --git a/Rosetta/Features/Chats/ChatList/ChatListSearchContent.swift b/Rosetta/Features/Chats/ChatList/ChatListSearchContent.swift index b63564d..d7f831b 100644 --- a/Rosetta/Features/Chats/ChatList/ChatListSearchContent.swift +++ b/Rosetta/Features/Chats/ChatList/ChatListSearchContent.swift @@ -24,27 +24,17 @@ struct ChatListSearchContent: View { private extension ChatListSearchContent { /// Desktop-parity: skeleton ↔ empty ↔ results — only one visible at a time. - /// Local filtering uses `searchText` directly (NOT viewModel.searchQuery) - /// to avoid @Published re-render cascade through ChatListView. + /// Uses unified search policy from ChatListViewModel callback merge. @ViewBuilder var activeSearchContent: some View { - let query = searchText.trimmingCharacters(in: .whitespaces).lowercased() - // Local results: match by username ONLY (desktop parity — server matches usernames) - let localResults = DialogRepository.shared.sortedDialogs.filter { dialog in - !query.isEmpty && dialog.opponentUsername.lowercased().contains(query) - } - let localKeys = Set(localResults.map(\.opponentKey)) - let serverOnly = viewModel.serverSearchResults.filter { - !localKeys.contains($0.publicKey) - } - let hasAnyResult = !localResults.isEmpty || !serverOnly.isEmpty + let hasAnyResult = !viewModel.serverSearchResults.isEmpty if viewModel.isServerSearching && !hasAnyResult { SearchSkeletonView() } else if !viewModel.isServerSearching && !hasAnyResult { noResultsState } else { - resultsList(localResults: localResults, serverOnly: serverOnly) + resultsList(results: viewModel.serverSearchResults) } } @@ -68,23 +58,14 @@ private extension ChatListSearchContent { .frame(maxWidth: .infinity, maxHeight: .infinity) } - /// Scrollable list of local dialogs + server results. + /// Scrollable list of merged search results. /// Shows skeleton rows at the bottom while server is still searching. - func resultsList(localResults: [Dialog], serverOnly: [SearchUser]) -> some View { + func resultsList(results: [SearchUser]) -> some View { ScrollView { LazyVStack(spacing: 0) { - ForEach(localResults) { dialog in - Button { - onOpenDialog(ChatRoute(dialog: dialog)) - } label: { - ChatRowView(dialog: dialog) - } - .buttonStyle(.plain) - } - - ForEach(serverOnly, id: \.publicKey) { user in + ForEach(results, id: \.publicKey) { user in serverUserRow(user) - if user.publicKey != serverOnly.last?.publicKey { + if user.publicKey != results.last?.publicKey { Divider() .padding(.leading, 76) .foregroundStyle(RosettaColors.Adaptive.divider) diff --git a/Rosetta/Features/Chats/ChatList/ChatListViewModel.swift b/Rosetta/Features/Chats/ChatList/ChatListViewModel.swift index f034cfb..dabcfdc 100644 --- a/Rosetta/Features/Chats/ChatList/ChatListViewModel.swift +++ b/Rosetta/Features/Chats/ChatList/ChatListViewModel.swift @@ -30,10 +30,12 @@ final class ChatListViewModel: ObservableObject { private var searchHandlerToken: UUID? private var recentSearchesCancellable: AnyCancellable? private let recentRepository = RecentSearchesRepository.shared + private let searchDispatcher: SearchResultDispatching // MARK: - Init - init() { + init(searchDispatcher: SearchResultDispatching = LiveSearchResultDispatcher()) { + self.searchDispatcher = searchDispatcher configureRecentSearches() setupSearchCallback() } @@ -107,7 +109,7 @@ final class ChatListViewModel: ObservableObject { // MARK: - Actions func setSearchQuery(_ query: String) { - searchQuery = normalizeSearchInput(query) + searchQuery = SearchParityPolicy.sanitizeInput(query) triggerServerSearch() } @@ -132,11 +134,11 @@ final class ChatListViewModel: ObservableObject { private func setupSearchCallback() { if let token = searchHandlerToken { - ProtocolManager.shared.removeSearchResultHandler(token) + searchDispatcher.removeSearchResultHandler(token) } Self.logger.debug("Setting up search callback") - searchHandlerToken = ProtocolManager.shared.addSearchResultHandler { [weak self] packet in + searchHandlerToken = searchDispatcher.addSearchResultHandler { [weak self] packet in DispatchQueue.main.async { [weak self] in guard let self else { Self.logger.debug("Search callback: self is nil") @@ -147,7 +149,16 @@ final class ChatListViewModel: ObservableObject { return } Self.logger.debug("📥 Search results received: \(packet.users.count) users") - self.serverSearchResults = packet.users + let query = SearchParityPolicy.normalizedQuery(self.searchQuery) + let localMatches = SearchParityPolicy.localAugmentedUsers( + query: query, + currentPublicKey: SessionManager.shared.currentPublicKey, + dialogs: Array(DialogRepository.shared.dialogs.values) + ) + self.serverSearchResults = SearchParityPolicy.mergeServerAndLocal( + server: packet.users, + local: localMatches + ) self.isServerSearching = false Self.logger.debug("📥 isServerSearching=\(self.isServerSearching), count=\(self.serverSearchResults.count)") for user in packet.users { @@ -169,7 +180,7 @@ final class ChatListViewModel: ObservableObject { searchRetryTask?.cancel() searchRetryTask = nil - let trimmed = searchQuery.trimmingCharacters(in: .whitespaces) + let trimmed = SearchParityPolicy.normalizedQuery(searchQuery) if trimmed.isEmpty { // Guard: only publish if value actually changes (avoids extra re-renders) if !serverSearchResults.isEmpty { serverSearchResults = [] } @@ -184,7 +195,7 @@ final class ChatListViewModel: ObservableObject { try? await Task.sleep(for: .seconds(1)) guard let self, !Task.isCancelled else { return } - let currentQuery = self.searchQuery.trimmingCharacters(in: .whitespaces) + let currentQuery = SearchParityPolicy.normalizedQuery(self.searchQuery) guard !currentQuery.isEmpty, currentQuery == trimmed else { return } self.sendSearchPacket(query: currentQuery) @@ -193,8 +204,8 @@ final class ChatListViewModel: ObservableObject { /// Sends PacketSearch if authenticated, otherwise waits for authentication (up to 10s). private func sendSearchPacket(query: String) { - let connState = ProtocolManager.shared.connectionState - let hash = SessionManager.shared.privateKeyHash ?? ProtocolManager.shared.privateHash + let connState = searchDispatcher.connectionState + let hash = SessionManager.shared.privateKeyHash ?? searchDispatcher.privateHash guard connState == .authenticated, let hash else { // Not authenticated — wait for reconnect then send @@ -205,9 +216,9 @@ final class ChatListViewModel: ObservableObject { for _ in 0..<20 { try? await Task.sleep(for: .milliseconds(500)) guard let self, !Task.isCancelled else { return } - let current = self.searchQuery.trimmingCharacters(in: .whitespaces) + let current = SearchParityPolicy.normalizedQuery(self.searchQuery) guard current == query else { return } // Query changed, abort - if ProtocolManager.shared.connectionState == .authenticated { + if self.searchDispatcher.connectionState == .authenticated { Self.logger.debug("Connection restored — sending pending search") self.sendSearchPacket(query: query) return @@ -223,14 +234,9 @@ final class ChatListViewModel: ObservableObject { var packet = PacketSearch() packet.privateKey = hash - packet.search = query.lowercased() + packet.search = query Self.logger.debug("📤 Sending search packet for '\(query)'") - ProtocolManager.shared.sendPacket(packet) - } - - private func normalizeSearchInput(_ input: String) -> String { - input.replacingOccurrences(of: "@", with: "") - .trimmingCharacters(in: .whitespacesAndNewlines) + searchDispatcher.sendSearchPacket(packet) } // MARK: - Recent Searches diff --git a/Rosetta/Features/Chats/Search/SearchViewModel.swift b/Rosetta/Features/Chats/Search/SearchViewModel.swift index 12637f3..ad12a01 100644 --- a/Rosetta/Features/Chats/Search/SearchViewModel.swift +++ b/Rosetta/Features/Chats/Search/SearchViewModel.swift @@ -30,10 +30,12 @@ final class SearchViewModel: ObservableObject { private var searchHandlerToken: UUID? private var recentSearchesCancellable: AnyCancellable? private let recentRepository = RecentSearchesRepository.shared + private let searchDispatcher: SearchResultDispatching // MARK: - Init - init() { + init(searchDispatcher: SearchResultDispatching = LiveSearchResultDispatcher()) { + self.searchDispatcher = searchDispatcher configureRecentSearches() setupSearchCallback() } @@ -41,7 +43,7 @@ final class SearchViewModel: ObservableObject { // MARK: - Search Logic func setSearchQuery(_ query: String) { - searchQuery = normalizeSearchInput(query) + searchQuery = SearchParityPolicy.sanitizeInput(query) onSearchQueryChanged() } @@ -49,15 +51,15 @@ final class SearchViewModel: ObservableObject { searchTask?.cancel() searchTask = nil - let trimmed = searchQuery.trimmingCharacters(in: .whitespaces) - if trimmed.isEmpty { + let normalized = SearchParityPolicy.normalizedQuery(searchQuery) + if normalized.isEmpty { searchResults = [] isSearching = false lastSearchedText = "" return } - if trimmed == lastSearchedText { + if normalized == lastSearchedText { return } @@ -71,13 +73,13 @@ final class SearchViewModel: ObservableObject { return } - let currentQuery = self.searchQuery.trimmingCharacters(in: .whitespaces) - guard !currentQuery.isEmpty, currentQuery == trimmed else { + let currentQuery = SearchParityPolicy.normalizedQuery(self.searchQuery) + guard !currentQuery.isEmpty, currentQuery == normalized else { return } - let connState = ProtocolManager.shared.connectionState - let hash = SessionManager.shared.privateKeyHash ?? ProtocolManager.shared.privateHash + let connState = self.searchDispatcher.connectionState + let hash = SessionManager.shared.privateKeyHash ?? self.searchDispatcher.privateHash guard connState == .authenticated, let hash else { self.isSearching = false @@ -88,9 +90,9 @@ final class SearchViewModel: ObservableObject { var packet = PacketSearch() packet.privateKey = hash - packet.search = currentQuery.lowercased() + packet.search = currentQuery - ProtocolManager.shared.sendPacket(packet) + self.searchDispatcher.sendSearchPacket(packet) } } @@ -107,30 +109,27 @@ final class SearchViewModel: ObservableObject { private func setupSearchCallback() { if let token = searchHandlerToken { - ProtocolManager.shared.removeSearchResultHandler(token) + searchDispatcher.removeSearchResultHandler(token) } - searchHandlerToken = ProtocolManager.shared.addSearchResultHandler { [weak self] packet in + searchHandlerToken = searchDispatcher.addSearchResultHandler { [weak self] packet in DispatchQueue.main.async { [weak self] in guard let self else { return } - let query = self.searchQuery.trimmingCharacters(in: .whitespacesAndNewlines) + let query = SearchParityPolicy.normalizedQuery(self.searchQuery) guard !query.isEmpty else { self.isSearching = false return } - // Merge server results with client-side public key matches. - // Server only matches by username; public key matching is local - // (same approach as Android). - var merged = packet.users - let serverKeys = Set(merged.map(\.publicKey)) - - let localMatches = self.findLocalPublicKeyMatches(query: query) - for match in localMatches where !serverKeys.contains(match.publicKey) { - merged.append(match) - } - - self.searchResults = merged + let localMatches = SearchParityPolicy.localAugmentedUsers( + query: query, + currentPublicKey: SessionManager.shared.currentPublicKey, + dialogs: Array(DialogRepository.shared.dialogs.values) + ) + self.searchResults = SearchParityPolicy.mergeServerAndLocal( + server: packet.users, + local: localMatches + ) self.isSearching = false // Update dialog info from server results @@ -147,57 +146,6 @@ final class SearchViewModel: ObservableObject { } } - // MARK: - Client-Side Public Key Matching - - /// Matches the query against local dialogs' public keys and the user's own - /// key (Saved Messages). The server only searches by username, so public - /// key look-ups must happen on the client (matches Android behaviour). - private func findLocalPublicKeyMatches(query: String) -> [SearchUser] { - let normalized = query.lowercased().replacingOccurrences(of: "0x", with: "") - - // Only treat as a public key search when every character is hex - guard !normalized.isEmpty, normalized.allSatisfy(\.isHexDigit) else { - return [] - } - - var results: [SearchUser] = [] - - // Check own public key → Saved Messages - let ownKey = SessionManager.shared.currentPublicKey.lowercased().replacingOccurrences(of: "0x", with: "") - if ownKey.hasPrefix(normalized) || ownKey == normalized { - results.append(SearchUser( - username: "", - title: "Saved Messages", - publicKey: SessionManager.shared.currentPublicKey, - verified: 0, - online: 0 - )) - } - - // Check local dialogs - for dialog in DialogRepository.shared.dialogs.values { - let dialogKey = dialog.opponentKey.lowercased().replacingOccurrences(of: "0x", with: "") - guard dialogKey.hasPrefix(normalized) || dialogKey == normalized else { continue } - // Skip if it's our own key (already handled as Saved Messages) - guard dialog.opponentKey != SessionManager.shared.currentPublicKey else { continue } - - results.append(SearchUser( - username: dialog.opponentUsername, - title: dialog.opponentTitle, - publicKey: dialog.opponentKey, - verified: dialog.verified, - online: dialog.isOnline ? 0 : 1 - )) - } - - return results - } - - private func normalizeSearchInput(_ input: String) -> String { - input.replacingOccurrences(of: "@", with: "") - .trimmingCharacters(in: .whitespacesAndNewlines) - } - // MARK: - Recent Searches func addToRecent(_ user: SearchUser) { diff --git a/RosettaTests/AttachmentParityTests.swift b/RosettaTests/AttachmentParityTests.swift new file mode 100644 index 0000000..4bf7afb --- /dev/null +++ b/RosettaTests/AttachmentParityTests.swift @@ -0,0 +1,181 @@ +import UIKit +import XCTest +@testable import Rosetta + +@MainActor +final class AttachmentParityTests: XCTestCase { + private var ctx: DBTestContext! + private var transportMock: MockAttachmentFlowTransport! + private var senderMock: MockPacketFlowSender! + + private var ownPrivateKeyHex: String = "" + private var ownPublicKey: String = "" + private var peerPublicKey: String = "" + + override func setUpWithError() throws { + let ownPair = try Self.makeKeyPair() + let peerPair = try Self.makeKeyPair() + + ownPrivateKeyHex = ownPair.privateKeyHex + ownPublicKey = ownPair.publicKeyHex + peerPublicKey = peerPair.publicKeyHex + + ctx = DBTestContext(account: ownPublicKey) + transportMock = MockAttachmentFlowTransport() + senderMock = MockPacketFlowSender() + + SessionManager.shared.testConfigureSessionForParityFlows( + currentPublicKey: ownPublicKey, + privateKeyHex: ownPrivateKeyHex + ) + SessionManager.shared.attachmentFlowTransport = transportMock + SessionManager.shared.packetFlowSender = senderMock + AttachmentCache.shared.privateKey = ownPrivateKeyHex + } + + override func tearDownWithError() throws { + ctx?.teardown() + ctx = nil + transportMock = nil + senderMock = nil + AttachmentCache.shared.privateKey = nil + SessionManager.shared.testResetParityFlowDependencies() + } + + func testAttachmentPreviewParserMatrix() { + let tag = "aaaaaaaa-1111-2222-3333-bbbbbbbbbbbb" + + XCTAssertEqual(AttachmentPreviewCodec.downloadTag(from: "\(tag)::LKO2"), tag) + XCTAssertEqual(AttachmentPreviewCodec.blurHash(from: "\(tag)::LKO2"), "LKO2") + + XCTAssertEqual(AttachmentPreviewCodec.downloadTag(from: "::LKO2"), "") + XCTAssertEqual(AttachmentPreviewCodec.blurHash(from: "::LKO2"), "LKO2") + + let taggedFile = AttachmentPreviewCodec.parseFilePreview("\(tag)::2048::report.pdf") + XCTAssertEqual(taggedFile.downloadTag, tag) + XCTAssertEqual(taggedFile.fileSize, 2048) + XCTAssertEqual(taggedFile.fileName, "report.pdf") + + let localFile = AttachmentPreviewCodec.parseFilePreview("512::notes.txt") + XCTAssertEqual(localFile.downloadTag, "") + XCTAssertEqual(localFile.fileSize, 512) + XCTAssertEqual(localFile.fileName, "notes.txt") + + XCTAssertEqual(AttachmentPreviewCodec.payload(from: "legacy_preview"), "legacy_preview") + } + + func testOutgoingAttachmentPacketShapeClearsBlobAndUsesTaggedPreview() async throws { + try await ctx.bootstrap() + + let image = Self.makeSolidImage(color: .systemBlue) + let imageAttachment = PendingAttachment.fromImage(image) + let fileData = Data("hello parity".utf8) + let fileAttachment = PendingAttachment.fromFile(data: fileData, fileName: "notes.txt") + + let imageTag = "11111111-1111-1111-1111-111111111111" + let fileTag = "22222222-2222-2222-2222-222222222222" + transportMock.tagsById[imageAttachment.id] = imageTag + transportMock.tagsById[fileAttachment.id] = fileTag + + try await SessionManager.shared.sendMessageWithAttachments( + text: "", + attachments: [imageAttachment, fileAttachment], + toPublicKey: peerPublicKey, + opponentTitle: "Peer", + opponentUsername: "peer" + ) + + XCTAssertEqual(transportMock.uploadedIds.count, 2) + XCTAssertEqual(senderMock.sentMessages.count, 1) + + guard let sent = senderMock.sentMessages.first else { + XCTFail("No outgoing packet captured") + return + } + XCTAssertEqual(sent.attachments.count, 2) + XCTAssertTrue(sent.attachments.allSatisfy { $0.blob.isEmpty }) + + guard let sentImage = sent.attachments.first(where: { $0.id == imageAttachment.id }) else { + XCTFail("Missing image attachment in packet") + return + } + XCTAssertEqual(AttachmentPreviewCodec.downloadTag(from: sentImage.preview), imageTag) + + guard let sentFile = sent.attachments.first(where: { $0.id == fileAttachment.id }) else { + XCTFail("Missing file attachment in packet") + return + } + let parsedFile = AttachmentPreviewCodec.parseFilePreview(sentFile.preview) + XCTAssertEqual(parsedFile.downloadTag, fileTag) + XCTAssertEqual(parsedFile.fileSize, fileData.count) + XCTAssertEqual(parsedFile.fileName, "notes.txt") + } + + func testSavedSelfFileFlowKeepsLocalFileOpenableWithoutUpload() async throws { + try await ctx.bootstrap() + + let fileData = Data("self file payload".utf8) + let fileAttachment = PendingAttachment.fromFile(data: fileData, fileName: "local.txt") + + try await SessionManager.shared.sendMessageWithAttachments( + text: "", + attachments: [fileAttachment], + toPublicKey: ownPublicKey + ) + + XCTAssertTrue(transportMock.uploadedIds.isEmpty) + XCTAssertTrue(senderMock.sentMessages.isEmpty) + + let cachedURL = AttachmentCache.shared.fileURL( + forAttachmentId: fileAttachment.id, + fileName: "local.txt" + ) + XCTAssertNotNil(cachedURL) + + let loaded = AttachmentCache.shared.loadFileData( + forAttachmentId: fileAttachment.id, + fileName: "local.txt" + ) + XCTAssertEqual(loaded, fileData) + } +} + +private extension AttachmentParityTests { + static func makeSolidImage(color: UIColor) -> UIImage { + let renderer = UIGraphicsImageRenderer(size: CGSize(width: 32, height: 32)) + return renderer.image { ctx in + color.setFill() + ctx.fill(CGRect(x: 0, y: 0, width: 32, height: 32)) + } + } + + static func makeKeyPair() throws -> (privateKeyHex: String, publicKeyHex: String) { + let mnemonic = try CryptoManager.shared.generateMnemonic() + let pair = try CryptoManager.shared.deriveKeyPair(from: mnemonic) + return (pair.privateKey.hexString, pair.publicKey.hexString) + } +} + +private final class MockAttachmentFlowTransport: AttachmentFlowTransporting { + var tagsById: [String: String] = [:] + private(set) var uploadedIds: [String] = [] + + func uploadFile(id: String, content: Data) async throws -> String { + uploadedIds.append(id) + return tagsById[id] ?? UUID().uuidString.lowercased() + } + + func downloadFile(tag: String) async throws -> Data { + Data() + } +} + +private final class MockPacketFlowSender: PacketFlowSending { + private(set) var sentMessages: [PacketMessage] = [] + + func sendPacket(_ packet: any Packet) { + if let message = packet as? PacketMessage { + sentMessages.append(message) + } + } +} diff --git a/RosettaTests/BehaviorParityFixtureTests.swift b/RosettaTests/BehaviorParityFixtureTests.swift new file mode 100644 index 0000000..344a1e6 --- /dev/null +++ b/RosettaTests/BehaviorParityFixtureTests.swift @@ -0,0 +1,194 @@ +import XCTest +@testable import Rosetta + +@MainActor +final class BehaviorParityFixtureTests: XCTestCase { + private var ctx: DBTestContext! + + override func setUpWithError() throws { + ctx = DBTestContext() + } + + override func tearDownWithError() throws { + ctx.teardown() + ctx = nil + } + + func testIncomingDirectFixture() async throws { + try await ctx.bootstrap() + + try await ctx.runScenario(FixtureScenario(name: "incoming direct", events: [ + .incoming(opponent: "02peer_direct", messageId: "in-1", timestamp: 100, text: "hello"), + ])) + + let snapshot = try ctx.normalizedSnapshot() + XCTAssertEqual(snapshot.messages.count, 1) + XCTAssertEqual(snapshot.messages.first?.messageId, "in-1") + XCTAssertEqual(snapshot.messages.first?.delivered, DeliveryStatus.delivered.rawValue) + XCTAssertEqual(snapshot.messages.first?.read, false) + + XCTAssertEqual(snapshot.dialogs.count, 1) + XCTAssertEqual(snapshot.dialogs.first?.opponentKey, "02peer_direct") + XCTAssertEqual(snapshot.dialogs.first?.unreadCount, 1) + XCTAssertEqual(snapshot.dialogs.first?.iHaveSent, false) + XCTAssertEqual(snapshot.dialogs.first?.isRequest, true) + } + + func testOutgoingDeliveredReadFixture() async throws { + try await ctx.bootstrap() + + try await ctx.runScenario(FixtureScenario(name: "outgoing delivered read", events: [ + .outgoing(opponent: "02peer_ack", messageId: "out-1", timestamp: 200, text: "yo"), + .markDelivered(opponent: "02peer_ack", messageId: "out-1"), + .markOutgoingRead(opponent: "02peer_ack"), + ])) + + let snapshot = try ctx.normalizedSnapshot() + XCTAssertEqual(snapshot.messages.count, 1) + XCTAssertEqual(snapshot.messages.first?.fromMe, true) + XCTAssertEqual(snapshot.messages.first?.delivered, DeliveryStatus.delivered.rawValue) + XCTAssertEqual(snapshot.messages.first?.read, true) + + XCTAssertEqual(snapshot.dialogs.first?.lastMessageFromMe, true) + XCTAssertEqual(snapshot.dialogs.first?.lastMessageDelivered, DeliveryStatus.delivered.rawValue) + XCTAssertEqual(snapshot.dialogs.first?.lastMessageRead, true) + } + + func testSyncBatchDedupFixture() async throws { + try await ctx.bootstrap() + + try await ctx.runScenario(FixtureScenario(name: "sync dedup", events: [ + .incoming(opponent: "02peer_dedup", messageId: "dup-1", timestamp: 300, text: "a"), + .incoming(opponent: "02peer_dedup", messageId: "dup-1", timestamp: 300, text: "a"), + ])) + + let snapshot = try ctx.normalizedSnapshot() + XCTAssertEqual(snapshot.messages.count, 1) + XCTAssertEqual(snapshot.messages.first?.messageId, "dup-1") + } + + func testSavedMessagesFixture() async throws { + try await ctx.bootstrap() + + try await ctx.runScenario(FixtureScenario(name: "saved", events: [ + .outgoing(opponent: ctx.account, messageId: "self-1", timestamp: 400, text: "note"), + .markDelivered(opponent: ctx.account, messageId: "self-1"), + ])) + + let snapshot = try ctx.normalizedSnapshot() + XCTAssertEqual(snapshot.messages.count, 1) + XCTAssertEqual(snapshot.messages.first?.dialogKey, ctx.account) + XCTAssertEqual(snapshot.dialogs.first?.opponentKey, ctx.account) + XCTAssertEqual(snapshot.dialogs.first?.unreadCount, 0) + } + + func testGroupConversationDbFlowSafeFixture() async throws { + try await ctx.bootstrap() + + try await ctx.runScenario(FixtureScenario(name: "group safe", events: [ + .incomingPacket( + from: "02group_member_a", + to: "#group:alpha", + messageId: "g-1", + timestamp: 500, + text: "group hi" + ), + .incomingPacket( + from: "02conversation_member", + to: "conversation:room42", + messageId: "c-1", + timestamp: 501, + text: "conv hi" + ), + ])) + + let snapshot = try ctx.normalizedSnapshot() + let groupMessage = snapshot.messages.first { $0.messageId == "g-1" } + let conversationMessage = snapshot.messages.first { $0.messageId == "c-1" } + + XCTAssertEqual(groupMessage?.dialogKey, "#group:alpha") + XCTAssertEqual(conversationMessage?.dialogKey, "conversation:room42") + + let groupDialog = snapshot.dialogs.first { $0.opponentKey == "#group:alpha" } + let conversationDialog = snapshot.dialogs.first { $0.opponentKey == "conversation:room42" } + XCTAssertEqual(groupDialog?.iHaveSent, true) + XCTAssertEqual(groupDialog?.isRequest, false) + XCTAssertEqual(conversationDialog?.iHaveSent, true) + XCTAssertEqual(conversationDialog?.isRequest, false) + } + + func testAttachmentsOnlyLastMessageFixture() async throws { + try await ctx.bootstrap() + + let imageAttachment = MessageAttachment(id: "att-1", preview: "", blob: "", type: .image) + try await ctx.runScenario(FixtureScenario(name: "attachments only", events: [ + .incoming(opponent: "02peer_media", messageId: "media-1", timestamp: 600, text: "", attachments: [imageAttachment]), + ])) + + let snapshot = try ctx.normalizedSnapshot() + XCTAssertEqual(snapshot.messages.first?.hasAttachments, true) + XCTAssertEqual(snapshot.dialogs.first?.lastMessage, "Photo") + } + + func testGroupReadPacketMarksOutgoingAsReadFixture() async throws { + try await ctx.bootstrap() + + try await ctx.runScenario(FixtureScenario(name: "group read", events: [ + .outgoing(opponent: "#group:alpha", messageId: "g-out-1", timestamp: 610, text: "hello group"), + .markDelivered(opponent: "#group:alpha", messageId: "g-out-1"), + .applyReadPacket(from: "02group_member_b", to: "#group:alpha"), + ])) + + let snapshot = try ctx.normalizedSnapshot() + let message = snapshot.messages.first { $0.messageId == "g-out-1" } + let dialog = snapshot.dialogs.first { $0.opponentKey == "#group:alpha" } + + XCTAssertEqual(message?.fromMe, true) + XCTAssertEqual(message?.read, true) + XCTAssertEqual(dialog?.lastMessageRead, true) + } + + func testCallAttachmentDecodeAndStorageFixture() async throws { + try await ctx.bootstrap() + + let callAttachment = MessageAttachment( + id: "call-1", + preview: "", + blob: "", + type: .call + ) + + try await ctx.runScenario(FixtureScenario(name: "call attachment", events: [ + .incomingPacket( + from: "02peer_call", + to: ctx.account, + messageId: "call-msg-1", + timestamp: 620, + text: "", + attachments: [callAttachment] + ), + ])) + + let snapshot = try ctx.normalizedSnapshot() + XCTAssertEqual(snapshot.messages.first?.hasAttachments, true) + XCTAssertEqual(snapshot.dialogs.first?.lastMessage, "Call") + } + + func testRequestToChatPromotionAndCursorMonotonicityFixture() async throws { + try await ctx.bootstrap() + + try await ctx.runScenario(FixtureScenario(name: "request to chat", events: [ + .incoming(opponent: "02peer_promote", messageId: "rq-1", timestamp: 700, text: "ping"), + .outgoing(opponent: "02peer_promote", messageId: "rq-2", timestamp: 701, text: "pong"), + .saveSyncCursor(1_700_000_001_000), + .saveSyncCursor(1_700_000_000_900), + .saveSyncCursor(1_700_000_001_200), + ])) + + let snapshot = try ctx.normalizedSnapshot() + let dialog = snapshot.dialogs.first { $0.opponentKey == "02peer_promote" } + XCTAssertEqual(dialog?.iHaveSent, true) + XCTAssertEqual(dialog?.isRequest, false) + XCTAssertEqual(snapshot.syncCursor, 1_700_000_001_200) + } +} diff --git a/RosettaTests/DBTestSupport.swift b/RosettaTests/DBTestSupport.swift new file mode 100644 index 0000000..8b461ec --- /dev/null +++ b/RosettaTests/DBTestSupport.swift @@ -0,0 +1,334 @@ +import Foundation +import SQLite3 +import XCTest +@testable import Rosetta + +struct SchemaSnapshot { + let tables: Set + let columnsByTable: [String: Set] + let indexes: Set +} + +struct NormalizedDbSnapshot: Equatable { + struct Message: Equatable { + let account: String + let dialogKey: String + let messageId: String + let fromMe: Bool + let read: Bool + let delivered: Int + let plainMessage: String + let timestamp: Int64 + let hasAttachments: Bool + } + + struct Dialog: Equatable { + let opponentKey: String + let lastMessage: String + let lastMessageTimestamp: Int64 + let unreadCount: Int + let iHaveSent: Bool + let isRequest: Bool + let lastMessageFromMe: Bool + let lastMessageDelivered: Int + let lastMessageRead: Bool + } + + let messages: [Message] + let dialogs: [Dialog] + let syncCursor: Int64 +} + +enum FixtureEvent { + case incoming(opponent: String, messageId: String, timestamp: Int64, text: String, attachments: [MessageAttachment] = []) + case incomingPacket(from: String, to: String, messageId: String, timestamp: Int64, text: String, attachments: [MessageAttachment] = []) + case outgoing(opponent: String, messageId: String, timestamp: Int64, text: String, attachments: [MessageAttachment] = []) + case markDelivered(opponent: String, messageId: String) + case markOutgoingRead(opponent: String) + case applyReadPacket(from: String, to: String) + case saveSyncCursor(Int64) +} + +struct FixtureScenario { + let name: String + let events: [FixtureEvent] +} + +final class SQLiteTestDB { + private var handle: OpaquePointer? + + init(path: String) throws { + if sqlite3_open(path, &handle) != SQLITE_OK { + let message = String(cString: sqlite3_errmsg(handle)) + sqlite3_close(handle) + throw NSError(domain: "SQLiteTestDB", code: 1, userInfo: [NSLocalizedDescriptionKey: message]) + } + } + + deinit { + sqlite3_close(handle) + } + + func execute(_ sql: String) throws { + var errorMessage: UnsafeMutablePointer? + if sqlite3_exec(handle, sql, nil, nil, &errorMessage) != SQLITE_OK { + let message = errorMessage.map { String(cString: $0) } ?? "Unknown sqlite error" + sqlite3_free(errorMessage) + throw NSError(domain: "SQLiteTestDB", code: 2, userInfo: [NSLocalizedDescriptionKey: message, "sql": sql]) + } + } + + func query(_ sql: String, _ bindings: [Binding] = []) throws -> [[String: String]] { + var statement: OpaquePointer? + guard sqlite3_prepare_v2(handle, sql, -1, &statement, nil) == SQLITE_OK else { + let message = String(cString: sqlite3_errmsg(handle)) + throw NSError(domain: "SQLiteTestDB", code: 3, userInfo: [NSLocalizedDescriptionKey: message, "sql": sql]) + } + defer { sqlite3_finalize(statement) } + + for (index, binding) in bindings.enumerated() { + let idx = Int32(index + 1) + switch binding { + case .text(let value): + sqlite3_bind_text(statement, idx, value, -1, SQLITE_TRANSIENT) + case .int64(let value): + sqlite3_bind_int64(statement, idx, value) + } + } + + var rows: [[String: String]] = [] + while sqlite3_step(statement) == SQLITE_ROW { + let columnCount = sqlite3_column_count(statement) + var row: [String: String] = [:] + for column in 0.. SQLiteTestDB { + try SQLiteTestDB(path: databaseURL.path) + } + + func schemaSnapshot() throws -> SchemaSnapshot { + let sqlite = try openSQLite() + let tableRows = try sqlite.query("SELECT name FROM sqlite_master WHERE type='table'") + let tables = Set(tableRows.compactMap { $0["name"] }) + + var columnsByTable: [String: Set] = [:] + for table in tables { + let rows = try sqlite.query("PRAGMA table_info('\(table)')") + columnsByTable[table] = Set(rows.compactMap { $0["name"] }) + } + + let indexRows = try sqlite.query("SELECT name FROM sqlite_master WHERE type='index'") + let indexes = Set(indexRows.compactMap { $0["name"] }) + + return SchemaSnapshot(tables: tables, columnsByTable: columnsByTable, indexes: indexes) + } + + func normalizedSnapshot() throws -> NormalizedDbSnapshot { + let sqlite = try openSQLite() + + let messageRows = try sqlite.query( + """ + SELECT account, dialog_key, message_id, from_me, is_read, delivery_status, + COALESCE(NULLIF(plain_message, ''), text) AS plain_message, + timestamp, attachments + FROM messages + WHERE account = ? + ORDER BY dialog_key, timestamp, message_id + """, + [.text(account)] + ) + + let messages = messageRows.map { row in + NormalizedDbSnapshot.Message( + account: row["account", default: ""], + dialogKey: row["dialog_key", default: ""], + messageId: row["message_id", default: ""], + fromMe: row["from_me"] == "1", + read: row["is_read"] == "1", + delivered: Int(row["delivery_status", default: "0"]) ?? 0, + plainMessage: row["plain_message", default: ""], + timestamp: Int64(row["timestamp", default: "0"]) ?? 0, + hasAttachments: row["attachments", default: "[]"] != "[]" + ) + } + + let dialogRows = try sqlite.query( + """ + SELECT opponent_key, last_message, last_message_timestamp, unread_count, + i_have_sent, is_request, last_message_from_me, + last_message_delivered, last_message_read + FROM dialogs + WHERE account = ? + ORDER BY opponent_key + """, + [.text(account)] + ) + + let dialogs = dialogRows.map { row in + NormalizedDbSnapshot.Dialog( + opponentKey: row["opponent_key", default: ""], + lastMessage: row["last_message", default: ""], + lastMessageTimestamp: Int64(row["last_message_timestamp", default: "0"]) ?? 0, + unreadCount: Int(row["unread_count", default: "0"]) ?? 0, + iHaveSent: row["i_have_sent"] == "1", + isRequest: row["is_request"] == "1", + lastMessageFromMe: row["last_message_from_me"] == "1", + lastMessageDelivered: Int(row["last_message_delivered", default: "0"]) ?? 0, + lastMessageRead: row["last_message_read"] == "1" + ) + } + + let cursorRow = try sqlite.query( + "SELECT last_sync FROM accounts_sync_times WHERE account = ? LIMIT 1", + [.text(account)] + ).first + let syncCursor = Int64(cursorRow?["last_sync"] ?? "0") ?? 0 + + return NormalizedDbSnapshot(messages: messages, dialogs: dialogs, syncCursor: syncCursor) + } + + func runScenario(_ scenario: FixtureScenario) async throws { + func normalizeGroupDialogKey(_ value: String) -> String { + let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines) + let lower = trimmed.lowercased() + if lower.hasPrefix("group:") { + let id = String(trimmed.dropFirst("group:".count)).trimmingCharacters(in: .whitespacesAndNewlines) + return id.isEmpty ? trimmed : "#group:\(id)" + } + return trimmed + } + + func resolveDialogIdentity(from: String, to: String) -> String? { + let fromKey = from.trimmingCharacters(in: .whitespacesAndNewlines) + let toKey = to.trimmingCharacters(in: .whitespacesAndNewlines) + guard !fromKey.isEmpty, !toKey.isEmpty else { return nil } + if DatabaseManager.isGroupDialogKey(toKey) { + return normalizeGroupDialogKey(toKey) + } + if fromKey == account { return toKey } + if toKey == account { return fromKey } + return nil + } + + for event in scenario.events { + switch event { + case .incoming(let opponent, let messageId, let timestamp, let text, let attachments): + var packet = PacketMessage() + packet.fromPublicKey = opponent + packet.toPublicKey = account + packet.messageId = messageId + packet.timestamp = timestamp + packet.attachments = attachments + MessageRepository.shared.upsertFromMessagePacket(packet, myPublicKey: account, decryptedText: text) + DialogRepository.shared.updateDialogFromMessages(opponentKey: opponent) + + case .incomingPacket(let from, let to, let messageId, let timestamp, let text, let attachments): + guard let dialogIdentity = resolveDialogIdentity(from: from, to: to) else { continue } + var packet = PacketMessage() + packet.fromPublicKey = from + packet.toPublicKey = to + packet.messageId = messageId + packet.timestamp = timestamp + packet.attachments = attachments + MessageRepository.shared.upsertFromMessagePacket( + packet, + myPublicKey: account, + decryptedText: text, + dialogIdentityOverride: dialogIdentity + ) + DialogRepository.shared.updateDialogFromMessages(opponentKey: dialogIdentity) + + case .outgoing(let opponent, let messageId, let timestamp, let text, let attachments): + var packet = PacketMessage() + packet.fromPublicKey = account + packet.toPublicKey = opponent + packet.messageId = messageId + packet.timestamp = timestamp + packet.attachments = attachments + MessageRepository.shared.upsertFromMessagePacket(packet, myPublicKey: account, decryptedText: text) + DialogRepository.shared.updateDialogFromMessages(opponentKey: opponent) + + case .markDelivered(let opponent, let messageId): + MessageRepository.shared.updateDeliveryStatus(messageId: messageId, status: .delivered) + DialogRepository.shared.updateDialogFromMessages(opponentKey: opponent) + + case .markOutgoingRead(let opponent): + MessageRepository.shared.markOutgoingAsRead(opponentKey: opponent, myPublicKey: account) + DialogRepository.shared.updateDialogFromMessages(opponentKey: opponent) + + case .applyReadPacket(let from, let to): + let fromKey = from.trimmingCharacters(in: .whitespacesAndNewlines) + let toKey = to.trimmingCharacters(in: .whitespacesAndNewlines) + guard !fromKey.isEmpty, !toKey.isEmpty else { continue } + + if DatabaseManager.isGroupDialogKey(toKey) { + let dialogIdentity = normalizeGroupDialogKey(toKey) + if fromKey == account { + MessageRepository.shared.markIncomingAsRead(opponentKey: dialogIdentity, myPublicKey: account) + DialogRepository.shared.markAsRead(opponentKey: dialogIdentity) + } else { + MessageRepository.shared.markOutgoingAsRead(opponentKey: dialogIdentity, myPublicKey: account) + DialogRepository.shared.markOutgoingAsRead(opponentKey: dialogIdentity) + } + continue + } + + if fromKey == account { + MessageRepository.shared.markIncomingAsRead(opponentKey: toKey, myPublicKey: account) + DialogRepository.shared.markAsRead(opponentKey: toKey) + } else if toKey == account { + MessageRepository.shared.markOutgoingAsRead(opponentKey: fromKey, myPublicKey: account) + DialogRepository.shared.markOutgoingAsRead(opponentKey: fromKey) + } + + case .saveSyncCursor(let timestamp): + DatabaseManager.shared.saveSyncCursor(account: account, timestamp: timestamp) + } + } + } +} diff --git a/RosettaTests/MigrationHarnessTests.swift b/RosettaTests/MigrationHarnessTests.swift new file mode 100644 index 0000000..b1c58fc --- /dev/null +++ b/RosettaTests/MigrationHarnessTests.swift @@ -0,0 +1,85 @@ +import XCTest +@testable import Rosetta + +@MainActor +final class MigrationHarnessTests: XCTestCase { + private var ctx: DBTestContext! + + override func setUpWithError() throws { + ctx = DBTestContext() + } + + override func tearDownWithError() throws { + ctx.teardown() + ctx = nil + } + + func testLegacySyncOnlyMigrationReconcilesWithoutSQLiteUpsertSyntaxFailure() async throws { + try await ctx.bootstrap() + + DatabaseManager.shared.close() + let sqlite = try ctx.openSQLite() + let rerunMigrations = DatabaseManager.migrationIdentifiers.dropFirst(3) + let deleteList = rerunMigrations.map { "'\($0)'" }.joined(separator: ",") + try sqlite.execute("DELETE FROM grdb_migrations WHERE identifier IN (\(deleteList))") + try sqlite.execute("DROP TABLE IF EXISTS accounts_sync_times") + try sqlite.execute("DELETE FROM sync_cursors") + try sqlite.execute("INSERT INTO sync_cursors(account, timestamp) VALUES ('\(ctx.account)', 1234567890123)") + + try DatabaseManager.shared.bootstrap(accountPublicKey: ctx.account) + let cursor = DatabaseManager.shared.loadSyncCursor(account: ctx.account) + XCTAssertEqual(cursor, 1_234_567_890_123) + } + + func testPartialReconcileBackfillsNullIds() async throws { + try await ctx.bootstrap() + DatabaseManager.shared.saveSyncCursor(account: ctx.account, timestamp: 9_001) + + DatabaseManager.shared.close() + let sqlite = try ctx.openSQLite() + try sqlite.execute("UPDATE accounts_sync_times SET id = NULL WHERE account = '\(ctx.account)'") + try sqlite.execute("DELETE FROM grdb_migrations WHERE identifier = '\(DatabaseManager.migrationV7SyncCursorReconcile)'") + + try DatabaseManager.shared.bootstrap(accountPublicKey: ctx.account) + + let check = try ctx.openSQLite() + let rows = try check.query( + "SELECT id, last_sync FROM accounts_sync_times WHERE account = ? LIMIT 1", + [.text(ctx.account)] + ) + XCTAssertEqual(rows.count, 1) + XCTAssertNotEqual(rows.first?["id"], "") + XCTAssertNotEqual(rows.first?["id"], "0") + XCTAssertEqual(rows.first?["last_sync"], "9001") + } + + func testMonotonicSyncCursorNeverDecreases() async throws { + try await ctx.bootstrap() + + DatabaseManager.shared.saveSyncCursor(account: ctx.account, timestamp: 1_700_000_005_000) + DatabaseManager.shared.saveSyncCursor(account: ctx.account, timestamp: 1_700_000_004_999) + DatabaseManager.shared.saveSyncCursor(account: ctx.account, timestamp: 1_700_000_006_500) + + XCTAssertEqual(DatabaseManager.shared.loadSyncCursor(account: ctx.account), 1_700_000_006_500) + } + + func testCompatibilityMirrorWritesAccountsSyncTimesAndSyncCursors() async throws { + try await ctx.bootstrap() + DatabaseManager.shared.saveSyncCursor(account: ctx.account, timestamp: 77_777) + + let sqlite = try ctx.openSQLite() + let accountsRows = try sqlite.query( + "SELECT last_sync, id FROM accounts_sync_times WHERE account = ? LIMIT 1", + [.text(ctx.account)] + ) + let legacyRows = try sqlite.query( + "SELECT timestamp FROM sync_cursors WHERE account = ? LIMIT 1", + [.text(ctx.account)] + ) + + XCTAssertEqual(accountsRows.first?["last_sync"], "77777") + XCTAssertNotEqual(accountsRows.first?["id"], "") + XCTAssertNotEqual(accountsRows.first?["id"], "0") + XCTAssertEqual(legacyRows.first?["timestamp"], "77777") + } +} diff --git a/RosettaTests/SchemaParityTests.swift b/RosettaTests/SchemaParityTests.swift new file mode 100644 index 0000000..b6802fc --- /dev/null +++ b/RosettaTests/SchemaParityTests.swift @@ -0,0 +1,191 @@ +import XCTest +@testable import Rosetta + +@MainActor +final class SchemaParityTests: XCTestCase { + private var ctx: DBTestContext! + + override func setUpWithError() throws { + ctx = DBTestContext() + } + + override func tearDownWithError() throws { + ctx.teardown() + ctx = nil + } + + func testSchemaContainsRequiredTablesColumnsAndIndexes() async throws { + try await ctx.bootstrap() + let schema = try ctx.schemaSnapshot() + + let requiredTables: Set = [ + "messages", + "dialogs", + "accounts_sync_times", + "sync_cursors", + "groups", + "pinned_messages", + "avatar_cache", + "blacklist", + ] + XCTAssertTrue(requiredTables.isSubset(of: schema.tables), "Missing required tables") + + let messagesColumns = schema.columnsByTable["messages"] ?? [] + let requiredMessageColumns: Set = [ + "account", "from_public_key", "to_public_key", "message_id", "dialog_key", + "timestamp", "is_read", "read", "delivery_status", "delivered", "text", "plain_message", + "attachments", "reply_to_message_id", + ] + XCTAssertTrue(requiredMessageColumns.isSubset(of: messagesColumns), "Missing messages columns") + + let dialogsColumns = schema.columnsByTable["dialogs"] ?? [] + let requiredDialogColumns: Set = [ + "account", "opponent_key", "dialog_id", "is_request", "last_timestamp", "last_message_id", + "last_message_timestamp", "i_have_sent", "unread_count", + ] + XCTAssertTrue(requiredDialogColumns.isSubset(of: dialogsColumns), "Missing dialogs columns") + + let requiredIndexes: Set = [ + "idx_messages_account_message_id", + "idx_messages_account_dialog_key_timestamp", + "idx_messages_account_dialog_fromme_isread", + "idx_messages_account_dialog_fromme_timestamp", + "idx_dialogs_account_opponent_key", + ] + XCTAssertTrue(requiredIndexes.isSubset(of: schema.indexes), "Missing required indexes") + } + + func testUnreadAndSentQueriesUseParityIndexes() async throws { + try await ctx.bootstrap() + try await ctx.runScenario(FixtureScenario(name: "seed", events: [ + .incoming(opponent: "02peer_a", messageId: "m1", timestamp: 1, text: "hello"), + .outgoing(opponent: "02peer_a", messageId: "m2", timestamp: 2, text: "yo"), + ])) + + let sqlite = try ctx.openSQLite() + let unreadPlanRows = try sqlite.query( + """ + EXPLAIN QUERY PLAN + SELECT COUNT(*) FROM messages + WHERE account = ? AND dialog_key = ? AND from_me = 0 AND is_read = 0 + """, + [.text(ctx.account), .text(DatabaseManager.dialogKey(account: ctx.account, opponentKey: "02peer_a"))] + ) + let unreadPlan = unreadPlanRows.compactMap { $0["detail"] }.joined(separator: " | ") + XCTAssertTrue( + unreadPlan.contains("idx_messages_account_dialog_fromme_isread"), + "Unread query plan did not use idx_messages_account_dialog_fromme_isread: \(unreadPlan)" + ) + + let sentPlanRows = try sqlite.query( + """ + EXPLAIN QUERY PLAN + SELECT message_id FROM messages + WHERE account = ? AND dialog_key = ? AND from_me = 1 + ORDER BY timestamp DESC + LIMIT 1 + """, + [.text(ctx.account), .text(DatabaseManager.dialogKey(account: ctx.account, opponentKey: "02peer_a"))] + ) + let sentPlan = sentPlanRows.compactMap { $0["detail"] }.joined(separator: " | ") + XCTAssertTrue( + sentPlan.contains("idx_messages_account_dialog_fromme_timestamp"), + "Sent query plan did not use idx_messages_account_dialog_fromme_timestamp: \(sentPlan)" + ) + } + + func testPacketRegistrySupportsMessagingAndGroupsCoreIds() throws { + let packets: [(Int, any Packet)] = [ + (0x00, PacketHandshake()), + (0x01, PacketUserInfo()), + (0x02, PacketResult()), + (0x03, PacketSearch()), + (0x04, PacketOnlineSubscribe()), + (0x05, PacketOnlineState()), + (0x06, PacketMessage()), + (0x07, PacketRead()), + (0x08, PacketDelivery()), + (0x09, PacketDeviceNew()), + (0x0A, PacketRequestUpdate()), + (0x0B, PacketTyping()), + (0x0F, PacketRequestTransport()), + (0x10, PacketPushNotification()), + (0x11, PacketCreateGroup()), + (0x12, PacketGroupInfo()), + (0x13, PacketGroupInviteInfo()), + (0x14, PacketGroupJoin()), + (0x15, PacketGroupLeave()), + (0x16, PacketGroupBan()), + (0x17, PacketDeviceList()), + (0x18, PacketDeviceResolve()), + (0x19, PacketSync()), + ] + + for (expectedId, packet) in packets { + let encoded = PacketRegistry.encode(packet) + guard let decoded = PacketRegistry.decode(from: encoded) else { + XCTFail("Failed to decode packet 0x\(String(expectedId, radix: 16))") + continue + } + XCTAssertEqual(decoded.packetId, expectedId) + } + } + + func testAttachmentTypeCallRoundTripDecoding() throws { + var packet = PacketMessage() + packet.fromPublicKey = "02from" + packet.toPublicKey = "02to" + packet.content = "" + packet.chachaKey = "" + packet.timestamp = 123 + packet.privateKey = "hash" + packet.messageId = "msg-call" + packet.attachments = [MessageAttachment(id: "call-1", preview: "", blob: "", type: .call)] + packet.aesChachaKey = "" + + let encoded = PacketRegistry.encode(packet) + guard let decoded = PacketRegistry.decode(from: encoded), + let decodedMessage = decoded.packet as? PacketMessage + else { + XCTFail("Failed to decode call attachment packet") + return + } + + XCTAssertEqual(decoded.packetId, 0x06) + XCTAssertEqual(decodedMessage.attachments.first?.type, .call) + } + + func testSessionPacketContextResolverAcceptsGroupWireShape() throws { + let own = "02my_account" + + var groupMessage = PacketMessage() + groupMessage.fromPublicKey = "02group_member" + groupMessage.toPublicKey = "#group:alpha" + let messageContext = SessionManager.testResolveMessagePacketContext(groupMessage, ownKey: own) + XCTAssertEqual(messageContext?.kind, "group") + XCTAssertEqual(messageContext?.dialogKey, "#group:alpha") + XCTAssertEqual(messageContext?.fromMe, false) + + var groupRead = PacketRead() + groupRead.fromPublicKey = "02group_member" + groupRead.toPublicKey = "#group:alpha" + let readContext = SessionManager.testResolveReadPacketContext(groupRead, ownKey: own) + XCTAssertEqual(readContext?.kind, "group") + XCTAssertEqual(readContext?.dialogKey, "#group:alpha") + XCTAssertEqual(readContext?.fromMe, false) + + var groupTyping = PacketTyping() + groupTyping.fromPublicKey = "02group_member" + groupTyping.toPublicKey = "#group:alpha" + let typingContext = SessionManager.testResolveTypingPacketContext(groupTyping, ownKey: own) + XCTAssertEqual(typingContext?.kind, "group") + XCTAssertEqual(typingContext?.dialogKey, "#group:alpha") + XCTAssertEqual(typingContext?.fromMe, false) + } + + func testStreamEncodingSmoke() { + let stream = Rosetta.Stream() + _ = stream + XCTAssertTrue(true) + } +} diff --git a/RosettaTests/SearchParityTests.swift b/RosettaTests/SearchParityTests.swift new file mode 100644 index 0000000..5247250 --- /dev/null +++ b/RosettaTests/SearchParityTests.swift @@ -0,0 +1,126 @@ +import XCTest +@testable import Rosetta + +@MainActor +final class SearchParityTests: XCTestCase { + func testSearchViewModelAndChatListUseSameQueryNormalization() async { + let searchDispatcher = MockSearchDispatcher() + let chatDispatcher = MockSearchDispatcher() + let searchVM = SearchViewModel(searchDispatcher: searchDispatcher) + let chatVM = ChatListViewModel(searchDispatcher: chatDispatcher) + + searchVM.setSearchQuery(" @Alice ") + chatVM.setSearchQuery(" @Alice ") + + try? await Task.sleep(for: .milliseconds(1200)) + + XCTAssertEqual(searchDispatcher.sentQueries, ["alice"]) + XCTAssertEqual(chatDispatcher.sentQueries, ["alice"]) + } + + func testSavedAliasesAndExactPublicKeyFallback() throws { + let ownPair = try Self.makeKeyPair() + let peerPair = try Self.makeKeyPair() + + let dialog = Self.makeDialog( + account: ownPair.publicKeyHex, + opponentKey: peerPair.publicKeyHex, + username: "peer_user", + title: "Peer User" + ) + + let saved = SearchParityPolicy.localAugmentedUsers( + query: "Saved Messages", + currentPublicKey: ownPair.publicKeyHex, + dialogs: [dialog] + ) + XCTAssertEqual(saved.count, 1) + XCTAssertEqual(saved.first?.publicKey, ownPair.publicKeyHex) + XCTAssertEqual(saved.first?.title, "Saved Messages") + + let exactPeer = SearchParityPolicy.localAugmentedUsers( + query: "0x" + peerPair.publicKeyHex, + currentPublicKey: ownPair.publicKeyHex, + dialogs: [dialog] + ) + XCTAssertEqual(exactPeer.count, 1) + XCTAssertEqual(exactPeer.first?.publicKey, peerPair.publicKeyHex) + XCTAssertEqual(exactPeer.first?.username, "peer_user") + } + + func testServerAndLocalMergeDedupesByPublicKeyWithServerPriority() { + let key = "021111111111111111111111111111111111111111111111111111111111111111" + let localOnlyKey = "022222222222222222222222222222222222222222222222222222222222222222" + + let server = [ + SearchUser(username: "server_u", title: "Server Name", publicKey: key, verified: 2, online: 0), + ] + let local = [ + SearchUser(username: "local_u", title: "Local Name", publicKey: key, verified: 0, online: 1), + SearchUser(username: "local_only", title: "Local Only", publicKey: localOnlyKey, verified: 0, online: 1), + ] + + let merged = SearchParityPolicy.mergeServerAndLocal(server: server, local: local) + XCTAssertEqual(merged.count, 2) + XCTAssertEqual(merged[0].publicKey, key) + XCTAssertEqual(merged[0].title, "Server Name") + XCTAssertEqual(merged[1].publicKey, localOnlyKey) + } +} + +private extension SearchParityTests { + static func makeKeyPair() throws -> (privateKeyHex: String, publicKeyHex: String) { + let mnemonic = try CryptoManager.shared.generateMnemonic() + let pair = try CryptoManager.shared.deriveKeyPair(from: mnemonic) + return (pair.privateKey.hexString, pair.publicKey.hexString) + } + + static func makeDialog( + account: String, + opponentKey: String, + username: String, + title: String + ) -> Dialog { + Dialog( + id: UUID().uuidString, + account: account, + opponentKey: opponentKey, + opponentTitle: title, + opponentUsername: username, + lastMessage: "", + lastMessageTimestamp: 0, + unreadCount: 0, + isOnline: true, + lastSeen: 0, + verified: 0, + iHaveSent: true, + isPinned: false, + isMuted: false, + lastMessageFromMe: false, + lastMessageDelivered: .delivered, + lastMessageRead: true + ) + } +} + +private final class MockSearchDispatcher: SearchResultDispatching { + var connectionState: ConnectionState = .authenticated + var privateHash: String? = "mock-private-hash" + private(set) var sentQueries: [String] = [] + private var handlers: [UUID: (PacketSearch) -> Void] = [:] + + func sendSearchPacket(_ packet: PacketSearch) { + sentQueries.append(packet.search) + } + + @discardableResult + func addSearchResultHandler(_ handler: @escaping (PacketSearch) -> Void) -> UUID { + let id = UUID() + handlers[id] = handler + return id + } + + func removeSearchResultHandler(_ id: UUID) { + handlers.removeValue(forKey: id) + } +}