Паритет вложений и поиска на iOS (desktop/server/android), новые autotests и аудит

This commit is contained in:
2026-03-28 18:21:55 +05:00
parent 8314318a8a
commit 5af28b68a8
40 changed files with 3990 additions and 892 deletions

1
.gitignore vendored
View File

@@ -7,6 +7,7 @@ CLAUDE.md
.claude.local.md
desktop
server
docs
Telegram-iOS
AGENTS.md

View File

@@ -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 = "<group>"; };
1A2B3C4D5E6F708192A3B4C5 /* AttachmentParityTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = AttachmentParityTests.swift; sourceTree = "<group>"; };
2B3C4D5E6F708192A3B4C5D6 /* SearchParityTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = SearchParityTests.swift; sourceTree = "<group>"; };
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 = "<group>"; };
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 = "<group>"; };
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 = "<group>"; };
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 = "<group>"; };
DBAA4AD95B61886B5A22EF0D /* MigrationHarnessTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = MigrationHarnessTests.swift; sourceTree = "<group>"; };
/* 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 = "<group>";
};
32A246700D4A2618B3F81039 /* iOS */ = {
isa = PBXGroup;
children = (
@@ -98,6 +140,7 @@
853F29632F4B50410092AD05 /* Products */,
95676C1A4D239B1FF9E73782 /* Frameworks */,
BA35C0165A0371B32DD8B4C0 /* RosettaNotificationService */,
0D5BD0581AA976925F688CDA /* RosettaTests */,
);
sourceTree = "<group>";
};
@@ -106,6 +149,7 @@
children = (
853F29622F4B50410092AD05 /* Rosetta.app */,
A182B0EDE5C68E7C6F1FB6D1 /* RosettaNotificationService.appex */,
75BA8A97FE297E450BB1452E /* RosettaTests.xctest */,
);
name = Products;
sourceTree = "<group>";
@@ -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 = (

View File

@@ -1,7 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<Scheme
LastUpgradeVersion = "2620"
version = "1.7">
version = "1.3">
<BuildAction
parallelizeBuildables = "YES"
buildImplicitDependencies = "YES"
@@ -21,14 +21,49 @@
ReferencedContainer = "container:Rosetta.xcodeproj">
</BuildableReference>
</BuildActionEntry>
<BuildActionEntry
buildForTesting = "YES"
buildForRunning = "NO"
buildForProfiling = "NO"
buildForArchiving = "NO"
buildForAnalyzing = "NO">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "219188CF4FCBF8E8CF11BEC2"
BuildableName = "RosettaTests.xctest"
BlueprintName = "RosettaTests"
ReferencedContainer = "container:Rosetta.xcodeproj">
</BuildableReference>
</BuildActionEntry>
</BuildActionEntries>
</BuildAction>
<TestAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
shouldUseLaunchSchemeArgsEnv = "YES"
shouldAutocreateTestPlan = "YES">
shouldUseLaunchSchemeArgsEnv = "YES">
<MacroExpansion>
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "853F29612F4B50410092AD05"
BuildableName = "Rosetta.app"
BlueprintName = "Rosetta"
ReferencedContainer = "container:Rosetta.xcodeproj">
</BuildableReference>
</MacroExpansion>
<Testables>
<TestableReference
skipped = "NO"
parallelizable = "YES">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "219188CF4FCBF8E8CF11BEC2"
BuildableName = "RosettaTests.xctest"
BlueprintName = "RosettaTests"
ReferencedContainer = "container:Rosetta.xcodeproj">
</BuildableReference>
</TestableReference>
</Testables>
</TestAction>
<LaunchAction
buildConfiguration = "Debug"

View File

@@ -7,6 +7,17 @@ import GRDB
final class DatabaseManager {
static let shared = DatabaseManager()
/// Internal migration identifiers exposed for migration harness tests.
nonisolated static let migrationIdentifiers: [String] = [
"v1_initial",
"v2_sync_cursors",
"v3_split_read_status",
"v4_schema_parity_android_desktop",
"v5_full_schema_superset_parity",
"v6_bidirectional_alias_sync",
"v7_sync_cursor_reconcile_and_perf_indexes",
]
nonisolated static let migrationV7SyncCursorReconcile = "v7_sync_cursor_reconcile_and_perf_indexes"
private var dbPool: DatabasePool?
private var currentAccount: String = ""
@@ -175,6 +186,9 @@ final class DatabaseManager {
WHERE m.account = dialogs.account
AND m.dialog_key = CASE
WHEN dialogs.account = dialogs.opponent_key THEN dialogs.account
WHEN LOWER(TRIM(dialogs.opponent_key)) LIKE '#group:%' THEN dialogs.opponent_key
WHEN LOWER(TRIM(dialogs.opponent_key)) LIKE 'group:%' THEN dialogs.opponent_key
WHEN 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
@@ -198,9 +212,22 @@ final class DatabaseManager {
if try db.tableExists("sync_cursors") {
try db.execute(
sql: """
INSERT INTO accounts_sync_times (account, last_sync)
INSERT OR IGNORE INTO accounts_sync_times (account, last_sync)
SELECT account, timestamp FROM sync_cursors
ON CONFLICT(account) DO UPDATE SET last_sync = excluded.last_sync
"""
)
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)
"""
)
}
@@ -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: ":")
)
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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..<count {
parsed.append(stream.readString())
}
members = parsed
}
}
struct PacketGroupInviteInfo: Packet {
static let packetId = 0x13
var groupId: String = ""
var membersCount: Int = 0
var status: GroupStatus = .notJoined
func write(to stream: Stream) {
stream.writeString(groupId)
stream.writeInt16(membersCount)
stream.writeInt8(status.rawValue)
}
mutating func read(from stream: Stream) {
groupId = stream.readString()
membersCount = max(stream.readInt16(), 0)
status = GroupStatus(value: stream.readInt8())
}
}
struct PacketGroupJoin: Packet {
static let packetId = 0x14
var groupId: String = ""
var status: GroupStatus = .notJoined
var groupString: String = ""
func write(to stream: Stream) {
stream.writeString(groupId)
stream.writeInt8(status.rawValue)
stream.writeString(groupString)
}
mutating func read(from stream: Stream) {
groupId = stream.readString()
status = GroupStatus(value: stream.readInt8())
groupString = stream.readString()
}
}
struct PacketGroupLeave: Packet {
static let packetId = 0x15
var groupId: String = ""
func write(to stream: Stream) {
stream.writeString(groupId)
}
mutating func read(from stream: Stream) {
groupId = stream.readString()
}
}
struct PacketGroupBan: Packet {
static let packetId = 0x16
var groupId: String = ""
var publicKey: String = ""
func write(to stream: Stream) {
stream.writeString(groupId)
stream.writeString(publicKey)
}
mutating func read(from stream: Stream) {
groupId = stream.readString()
publicKey = stream.readString()
}
}

View File

@@ -45,6 +45,13 @@ final class ProtocolManager: @unchecked Sendable {
var onUserInfoReceived: ((PacketUserInfo) -> 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)")

View File

@@ -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..<bytes.count, with: bytes)
return data
}
// MARK: - Bit-Level I/O
@@ -37,6 +45,9 @@ final class Stream: @unchecked Sendable {
}
func readBit() -> 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..<length {
result.append(UInt8(truncatingIfNeeded: readInt8()))

View File

@@ -0,0 +1,58 @@
import Foundation
protocol AttachmentFlowTransporting {
func uploadFile(id: String, content: Data) async throws -> 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)
}
}

View File

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

View File

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

View File

@@ -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<String> = [
"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<String>()
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<String>()
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
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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<NSString, UIImage> = {
let cache = NSCache<NSString, UIImage>()
@@ -197,6 +110,7 @@ final class NativeMessageCell: UICollectionViewCell, UIContextMenuInteractionDel
private var totalPhotoAttachmentCount = 0
private var photoLoadTasks: [String: Task<Void, Never>] = [:]
private var photoDownloadTasks: [String: Task<Void, Never>] = [:]
private var photoBlurHashTasks: [String: Task<Void, Never>] = [:]
private var downloadingAttachmentIds: Set<String> = []
private var failedAttachmentIds: Set<String> = []
@@ -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..<photoTileImageViews.count {
let isActiveTile = index < photoAttachments.count
@@ -789,7 +738,7 @@ final class NativeMessageCell: UICollectionViewCell, UIContextMenuInteractionDel
}
let attachment = photoAttachments[index]
if let cached = AttachmentCache.shared.loadImage(forAttachmentId: attachment.id) {
if let cached = AttachmentCache.shared.cachedImage(forAttachmentId: attachment.id) {
failedAttachmentIds.remove(attachment.id)
setPhotoTileImage(cached, at: index, animated: false)
placeholderView.isHidden = true
@@ -797,7 +746,12 @@ final class NativeMessageCell: UICollectionViewCell, UIContextMenuInteractionDel
indicator.isHidden = true
errorView.isHidden = true
} else {
setPhotoTileImage(Self.blurHashImage(from: attachment.preview), at: index, animated: false)
if let blur = Self.cachedBlurHashImage(from: attachment.preview) {
setPhotoTileImage(blur, at: index, animated: false)
} else {
setPhotoTileImage(nil, at: index, animated: false)
startPhotoBlurHashTask(attachment: attachment)
}
placeholderView.isHidden = imageView.image != nil
let hasFailed = failedAttachmentIds.contains(attachment.id)
if hasFailed {
@@ -823,8 +777,8 @@ final class NativeMessageCell: UICollectionViewCell, UIContextMenuInteractionDel
}
private func layoutPhotoTiles() {
guard !photoAttachments.isEmpty else { return }
updatePhotoContainerMask()
guard let layout = currentLayout, !photoAttachments.isEmpty else { return }
updatePhotoContainerMask(layout: layout)
let frames = Self.photoTileFrames(count: photoAttachments.count, in: photoContainer.bounds)
for (index, frame) in frames.enumerated() where index < photoTileImageViews.count {
photoTileImageViews[index].frame = frame
@@ -845,58 +799,61 @@ final class NativeMessageCell: UICollectionViewCell, UIContextMenuInteractionDel
photoContainer.bringSubviewToFront(photoUploadingIndicator)
photoContainer.bringSubviewToFront(photoOverflowOverlayView)
layoutPhotoOverflowOverlay(frames: frames)
applyPhotoLastTileMask(frames: frames, layout: layout)
}
private func updatePhotoContainerMask() {
guard let layout = currentLayout else {
private func updatePhotoContainerMask(layout: MessageCellLayout? = nil) {
guard let layout = layout ?? currentLayout else {
photoContainer.layer.mask = nil
return
}
photoContainer.layer.mask = MediaBubbleCornerMaskFactory.containerMask(
bounds: photoContainer.bounds,
mergeType: layout.mergeType,
outgoing: layout.isOutgoing
)
}
let inset: CGFloat = 2
let r: CGFloat = max(16 - inset, 0)
let s: CGFloat = max(5 - inset, 0)
let tailJoin: CGFloat = max(10 - inset, 0)
let rect = photoContainer.bounds
let (tl, tr, bl, br): (CGFloat, CGFloat, CGFloat, CGFloat) = {
switch layout.position {
case .single:
return layout.isOutgoing
? (r, r, r, tailJoin)
: (r, r, tailJoin, r)
case .top: return layout.isOutgoing ? (r, r, r, s) : (r, r, s, r)
case .mid: return layout.isOutgoing ? (r, s, r, s) : (s, r, s, r)
case .bottom:
return layout.isOutgoing
? (r, s, r, tailJoin)
: (s, r, tailJoin, r)
}
}()
private func applyPhotoLastTileMask(frames: [CGRect], layout: MessageCellLayout) {
guard !frames.isEmpty else { return }
let maxR = min(rect.width, rect.height) / 2
let cTL = min(tl, maxR), cTR = min(tr, maxR)
let cBL = min(bl, maxR), cBR = min(br, maxR)
// Reset per-tile masks first.
for index in 0..<photoTileImageViews.count {
photoTileImageViews[index].layer.mask = nil
photoTilePlaceholderViews[index].layer.mask = nil
photoTileButtons[index].layer.mask = nil
}
photoOverflowOverlayView.layer.mask = nil
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()
let lastVisibleIndex = photoAttachments.count - 1
guard lastVisibleIndex >= 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..<photoTileImageViews.count {
photoTileImageViews[index].image = nil
photoTileImageViews[index].isHidden = true
photoTileImageViews[index].layer.mask = nil
photoTilePlaceholderViews[index].isHidden = true
photoTilePlaceholderViews[index].layer.mask = nil
photoTileActivityIndicators[index].stopAnimating()
photoTileActivityIndicators[index].isHidden = true
photoTileErrorViews[index].isHidden = true
photoTileButtons[index].isHidden = true
photoTileButtons[index].layer.mask = nil
}
photoOverflowOverlayView.layer.mask = nil
}
private static func extractTag(from preview: String) -> 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()
}
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,334 @@
import Foundation
import SQLite3
import XCTest
@testable import Rosetta
struct SchemaSnapshot {
let tables: Set<String>
let columnsByTable: [String: Set<String>]
let indexes: Set<String>
}
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<Int8>?
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..<columnCount {
let name = String(cString: sqlite3_column_name(statement, column))
if let valuePtr = sqlite3_column_text(statement, column) {
row[name] = String(cString: valuePtr)
} else {
row[name] = ""
}
}
rows.append(row)
}
return rows
}
enum Binding {
case text(String)
case int64(Int64)
}
}
private let SQLITE_TRANSIENT = unsafeBitCast(-1, to: sqlite3_destructor_type.self)
@MainActor
final class DBTestContext {
let account: String
let storagePassword = "test-storage-password"
init(account: String = "02_ios_sql_test_\(UUID().uuidString.replacingOccurrences(of: "-", with: ""))") {
self.account = account
}
var databaseURL: URL {
DatabaseManager.databaseURLForTesting(accountPublicKey: account)
}
func bootstrap() async throws {
try DatabaseManager.shared.bootstrap(accountPublicKey: account)
await MessageRepository.shared.bootstrap(accountPublicKey: account, storagePassword: storagePassword)
await DialogRepository.shared.bootstrap(accountPublicKey: account, storagePassword: storagePassword)
}
func teardown() {
MessageRepository.shared.reset()
DialogRepository.shared.reset()
DatabaseManager.shared.close()
DatabaseManager.shared.deleteDatabase(for: account)
}
func openSQLite() throws -> 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<String>] = [:]
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)
}
}
}
}

View File

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

View File

@@ -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<String> = [
"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<String> = [
"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<String> = [
"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<String> = [
"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)
}
}

View File

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