#12 sync call history between devices via carbons and MAM
- After a call ends, send a plaintext <call-history/> service message to the user's own bare JID with direction, status, duration, start time, peer and video flag plus the call id as origin-id. - Intercept the payload on live, carbon and MAM paths and upsert the same system call card on every device; deduplication reuses the origin-id as clientID, and missed incoming calls bump unread. - Cover payload parsing and round-trip in CallHistorySyncTests, guard the namespace and interception in verify.sh, and document the flow in ARCHITECTURE.md.
This commit is contained in:
1 parent
0d78e02d2b
commit
1a26651daa
10 files changed
+269
-18
No files matched your search
@@ -196,6 +196,15 @@ trickle ICE candidates накапливаются до успешного нач
|
||||
успешным и получает длительность. Удаление `activeCall` до закрытия Jingle-сессии
|
||||
гарантирует, что ответный callback не создаст дубликат карточки.
|
||||
|
||||
Карточка звонка синхронизируется между устройствами: по завершении вызова
|
||||
устройство отправляет самому себе (собственный bare JID) служебное сообщение с
|
||||
`<call-history xmlns='https://luma.chat/call-history'>` (направление, статус,
|
||||
длительность, время начала, собеседник, видео). Carbons доставляют его на
|
||||
остальные устройства, MAM сохраняет в архиве; получатели парсят payload и
|
||||
создают ту же карточку, а дедупликация идёт по origin-id, совпадающему с
|
||||
clientID карточки на исходном устройстве. Пропущенный входящий звонок
|
||||
увеличивает счётчик непрочитанных на каждом устройстве.
|
||||
|
||||
Текущая реализация принимает вызов при живом XMPP-соединении. Wake-up завершённого
|
||||
iOS-приложения намеренно не имитируется: production-путь требует VoIP push,
|
||||
PushKit/CallKit и серверной APNs-инфраструктуры.
|
||||
|
||||
@@ -111,6 +111,7 @@
|
||||
66613FF7E056788222552D6C /* NotificationPolicy.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6E25F876C75CC5FCDDCB5906 /* NotificationPolicy.swift */; };
|
||||
67143F44FF20A87E8875CFFB /* MediaPreviewProcessor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3C0E3C6F60C3D4D6C565CA0A /* MediaPreviewProcessor.swift */; };
|
||||
67438C8911F9B7362073B591 /* ReplyThreadOverlay.swift in Sources */ = {isa = PBXBuildFile; fileRef = 19C556A41E640592AF6376B6 /* ReplyThreadOverlay.swift */; };
|
||||
6950D2E35897C3A6C537AE08 /* CallHistorySyncTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C3F58259FA8A8DB43464FA0D /* CallHistorySyncTests.swift */; };
|
||||
6AA0AC6E8E25E8A85D63E7CC /* WatchSessionModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 83A5787450609B0C14093679 /* WatchSessionModel.swift */; };
|
||||
6AB86E72201EB1A61898AD5F /* InlineVideoPlayer.swift in Sources */ = {isa = PBXBuildFile; fileRef = D8C81B9255E9886FB86E0785 /* InlineVideoPlayer.swift */; };
|
||||
6B0B8F3CAC1E5854C6F81E70 /* AccountConfigurationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 101587DDD2BB1B2C2073B926 /* AccountConfigurationTests.swift */; };
|
||||
@@ -137,11 +138,13 @@
|
||||
7F4C00E98574C8E5193F442E /* LumaRosterStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = F92830FB64B5BD0A06BC828D /* LumaRosterStore.swift */; };
|
||||
7F7BA6805C1D1EBCEAFDD3AD /* ChatTimelineEntry.swift in Sources */ = {isa = PBXBuildFile; fileRef = 13E35F7F0CB0243311FCDD61 /* ChatTimelineEntry.swift */; };
|
||||
7FBD6C44C2CA9D50E28E823A /* AppLockVault.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6EF29003DFA43387FC2FB6F4 /* AppLockVault.swift */; };
|
||||
81091B6BA7D793CA233812ED /* CallHistorySync.swift in Sources */ = {isa = PBXBuildFile; fileRef = 63E4D099BDCB2311ADFE39E6 /* CallHistorySync.swift */; };
|
||||
814FE6C4CB53207F236E9236 /* LocationPickerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2786A15415F65305F78D892C /* LocationPickerView.swift */; };
|
||||
822F3F8A5C87C493A8974D6B /* ArchiveMessageBatchPolicyTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0D93B7CC521421C23459A9A4 /* ArchiveMessageBatchPolicyTests.swift */; };
|
||||
8629497F7160C95747886EE4 /* ForwardMessageView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 81213335D275609C38056220 /* ForwardMessageView.swift */; };
|
||||
86D39FA124355A8B53903164 /* Martin in Frameworks */ = {isa = PBXBuildFile; productRef = 6EDEC6EEE315B77491214398 /* Martin */; };
|
||||
8714A5A67956D6A78D4D3E4B /* ChatMessage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 12C6A6B83DA076867481CCF3 /* ChatMessage.swift */; };
|
||||
8831E0A88F3759766F9BCC34 /* CallHistorySync.swift in Sources */ = {isa = PBXBuildFile; fileRef = 63E4D099BDCB2311ADFE39E6 /* CallHistorySync.swift */; };
|
||||
89712B216A98861B5D994587 /* LocationProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1C67A8A07F9DC9DFB7FB35DA /* LocationProvider.swift */; };
|
||||
89E2B0373E2DA2B25863A866 /* VideoNoteStopPolicyTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 33F90AC63C822D9DF95D7B5C /* VideoNoteStopPolicyTests.swift */; };
|
||||
8B98B818AC4379F03907B0E0 /* ChatScrollPositionPolicy.swift in Sources */ = {isa = PBXBuildFile; fileRef = C37AAE886AA3B8782B89E1A0 /* ChatScrollPositionPolicy.swift */; };
|
||||
@@ -341,7 +344,8 @@
|
||||
6079CE75BAB995515A8D5460 /* CallSnapshot.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CallSnapshot.swift; sourceTree = "<group>"; };
|
||||
60A97545E6E481D45E0BB20F /* ChatView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatView.swift; sourceTree = "<group>"; };
|
||||
61380B78824FDB98A933C7E9 /* ArchiveSyncRecoveryPolicyTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ArchiveSyncRecoveryPolicyTests.swift; sourceTree = "<group>"; };
|
||||
6317FAA29F22A3EA8450208F /* LumaTests.xctest */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.cfbundle; path = LumaTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
6317FAA29F22A3EA8450208F /* LumaTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = LumaTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
63E4D099BDCB2311ADFE39E6 /* CallHistorySync.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CallHistorySync.swift; sourceTree = "<group>"; };
|
||||
6773F87EE60F91BBDCA4B1F5 /* GroupConversationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GroupConversationTests.swift; sourceTree = "<group>"; };
|
||||
69F4FFBFD65C218FF6505844 /* SaslFailureMessage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SaslFailureMessage.swift; sourceTree = "<group>"; };
|
||||
6DCB924748780145A707D890 /* MediaViewerDismissGestureTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaViewerDismissGestureTests.swift; sourceTree = "<group>"; };
|
||||
@@ -398,6 +402,7 @@
|
||||
C35158C37C26CA9ACB6C9FFB /* WatchViews.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WatchViews.swift; sourceTree = "<group>"; };
|
||||
C37AAE886AA3B8782B89E1A0 /* ChatScrollPositionPolicy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatScrollPositionPolicy.swift; sourceTree = "<group>"; };
|
||||
C3E21A7D359AF7A7AF97D8DE /* LumaSaslFailureModule.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LumaSaslFailureModule.swift; sourceTree = "<group>"; };
|
||||
C3F58259FA8A8DB43464FA0D /* CallHistorySyncTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CallHistorySyncTests.swift; sourceTree = "<group>"; };
|
||||
C86EBBD15843C6FB110C798E /* MediaFileIO.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaFileIO.swift; sourceTree = "<group>"; };
|
||||
C90C426133ECC0319483A084 /* ChatTimelineEntryTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatTimelineEntryTests.swift; sourceTree = "<group>"; };
|
||||
CBA20B768A9C675FABCDC821 /* ServerInfoView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServerInfoView.swift; sourceTree = "<group>"; };
|
||||
@@ -412,12 +417,12 @@
|
||||
D8C81B9255E9886FB86E0785 /* InlineVideoPlayer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InlineVideoPlayer.swift; sourceTree = "<group>"; };
|
||||
D9BBE412EAF54DFE968B2E54 /* ArchiveSyncCheckpoint.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ArchiveSyncCheckpoint.swift; sourceTree = "<group>"; };
|
||||
D9FCE089ABBF05EF9F55D451 /* VideoNoteRecordingLifecycleTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VideoNoteRecordingLifecycleTests.swift; sourceTree = "<group>"; };
|
||||
DF8C531E464C9C4EDE2648C6 /* Luma.app */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.application; path = Luma.app; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
DF8C531E464C9C4EDE2648C6 /* Luma.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Luma.app; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
E619B2C85B0DD2E80653D6D3 /* VideoNoteRecordingCompletionPolicy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VideoNoteRecordingCompletionPolicy.swift; sourceTree = "<group>"; };
|
||||
E6A5C32B4DACD56DA733A0DB /* ServerInformation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServerInformation.swift; sourceTree = "<group>"; };
|
||||
E9F226A1F8D82B39630F9CEB /* LumaRoomStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LumaRoomStore.swift; sourceTree = "<group>"; };
|
||||
EA1D94FE541121DAE9DCE3B0 /* LegacyArchiveImporter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LegacyArchiveImporter.swift; sourceTree = "<group>"; };
|
||||
EA54340DD785335237158BEF /* LumaMac.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = LumaMac.app; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
EA54340DD785335237158BEF /* Luma.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Luma.app; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
F0A35C9A7B52458996513382 /* WatchVoiceRecorder.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WatchVoiceRecorder.swift; sourceTree = "<group>"; };
|
||||
F207C1B20DE3780F1754F81D /* ConnectionBanner.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConnectionBanner.swift; sourceTree = "<group>"; };
|
||||
F26F91180E43DE9C135B68C1 /* EmojiCatalog.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EmojiCatalog.swift; sourceTree = "<group>"; };
|
||||
@@ -455,6 +460,7 @@
|
||||
0A7F1E12AC8E617F12AC1410 /* XMPP */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
63E4D099BDCB2311ADFE39E6 /* CallHistorySync.swift */,
|
||||
1A017D8B164B1107299F6781 /* CryptoKitAESGCMEngine.swift */,
|
||||
72EC7DC2FE873C0BA4321496 /* LumaCallEngine.swift */,
|
||||
8F8DE3CE0E26DEA21A27CF67 /* LumaConnectionStatsModule.swift */,
|
||||
@@ -566,6 +572,7 @@
|
||||
A862822F2507303EBB27EFCC /* ArchiveSyncPaginationTests.swift */,
|
||||
61380B78824FDB98A933C7E9 /* ArchiveSyncRecoveryPolicyTests.swift */,
|
||||
22A045770C3C55BC5ADC409B /* ArchiveSyncWorkBudgetTests.swift */,
|
||||
C3F58259FA8A8DB43464FA0D /* CallHistorySyncTests.swift */,
|
||||
C0590E38A89EC4CC78791C22 /* CallSDPOrderingTests.swift */,
|
||||
375633EF5E4AEC9D5F284C6C /* CallSnapshotTests.swift */,
|
||||
24F0A8A1646FDEE9C07FD9F0 /* ChatScrollPositionPolicyTests.swift */,
|
||||
@@ -676,7 +683,7 @@
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
DF8C531E464C9C4EDE2648C6 /* Luma.app */,
|
||||
EA54340DD785335237158BEF /* LumaMac.app */,
|
||||
EA54340DD785335237158BEF /* Luma.app */,
|
||||
6317FAA29F22A3EA8450208F /* LumaTests.xctest */,
|
||||
19D4E9404712E8643E31CB93 /* LumaWatch.app */,
|
||||
);
|
||||
@@ -760,7 +767,7 @@
|
||||
2A745A82DD6F34F0CB46B52B /* WebRTC */,
|
||||
);
|
||||
productName = LumaMac;
|
||||
productReference = EA54340DD785335237158BEF /* LumaMac.app */;
|
||||
productReference = EA54340DD785335237158BEF /* Luma.app */;
|
||||
productType = "com.apple.product-type.application";
|
||||
};
|
||||
BAB78463D7DE07A8F7B3D27D /* LumaWatch */ = {
|
||||
@@ -806,7 +813,7 @@
|
||||
isa = PBXProject;
|
||||
attributes = {
|
||||
BuildIndependentTargetsInParallel = YES;
|
||||
LastUpgradeCheck = 1600;
|
||||
LastUpgradeCheck = 2660;
|
||||
TargetAttributes = {
|
||||
0565F6C7DA26F0482928704E = {
|
||||
DevelopmentTeam = K3AS449A74;
|
||||
@@ -902,6 +909,7 @@
|
||||
E9FA22C965454FA6890DC3D5 /* AvatarImageProcessor.swift in Sources */,
|
||||
A2AF2B19A1E9D9B8D6E1CD63 /* AvatarView.swift in Sources */,
|
||||
FC5986CE50C84FA77983767C /* BubbleShapes.swift in Sources */,
|
||||
81091B6BA7D793CA233812ED /* CallHistorySync.swift in Sources */,
|
||||
2C963DA7FDA2A2BCA075507F /* CallSnapshot.swift in Sources */,
|
||||
6FB79BEEEF151D297F4EBB33 /* CallView.swift in Sources */,
|
||||
73A21AFC42D6C00E6C2C5082 /* CertificateTrustEvaluator.swift in Sources */,
|
||||
@@ -1014,6 +1022,7 @@
|
||||
203D6124DF78461D8E86FDA8 /* AvatarImageProcessor.swift in Sources */,
|
||||
A29C828AC4B571941BF0B8E9 /* AvatarView.swift in Sources */,
|
||||
6DC36AA33268FC6457FADAE2 /* BubbleShapes.swift in Sources */,
|
||||
8831E0A88F3759766F9BCC34 /* CallHistorySync.swift in Sources */,
|
||||
4CD33DF0D193A7EED84A673F /* CallSnapshot.swift in Sources */,
|
||||
087E600676B3F6134BB26112 /* CallView.swift in Sources */,
|
||||
3D74BDDE2D978A2CC795E33A /* CertificateTrustEvaluator.swift in Sources */,
|
||||
@@ -1102,6 +1111,7 @@
|
||||
7425493304D731FA218E9151 /* ArchiveSyncPaginationTests.swift in Sources */,
|
||||
481F1C57B07AF615A3629C7E /* ArchiveSyncRecoveryPolicyTests.swift in Sources */,
|
||||
10273AF92A952B707C58AEE6 /* ArchiveSyncWorkBudgetTests.swift in Sources */,
|
||||
6950D2E35897C3A6C537AE08 /* CallHistorySyncTests.swift in Sources */,
|
||||
B7ED810A1DD43B717DDC40B3 /* CallSDPOrderingTests.swift in Sources */,
|
||||
61D41048E5021EEAC434DD90 /* CallSnapshotTests.swift in Sources */,
|
||||
12D3BEB345B65FB73DD3F560 /* ChatScrollPositionPolicyTests.swift in Sources */,
|
||||
@@ -1159,6 +1169,7 @@
|
||||
ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS = YES;
|
||||
CODE_SIGN_ENTITLEMENTS = Config/LumaMac.entitlements;
|
||||
COMBINE_HIDPI_IMAGES = YES;
|
||||
DEAD_CODE_STRIPPING = YES;
|
||||
DEVELOPMENT_TEAM = K3AS449A74;
|
||||
INFOPLIST_FILE = "Config/LumaMac-Info.plist";
|
||||
INFOPLIST_KEY_CFBundleIconName = AppIcon;
|
||||
@@ -1181,6 +1192,7 @@
|
||||
ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS = YES;
|
||||
CODE_SIGN_ENTITLEMENTS = Config/LumaMac.entitlements;
|
||||
COMBINE_HIDPI_IMAGES = YES;
|
||||
DEAD_CODE_STRIPPING = YES;
|
||||
DEVELOPMENT_TEAM = K3AS449A74;
|
||||
INFOPLIST_FILE = "Config/LumaMac-Info.plist";
|
||||
INFOPLIST_KEY_CFBundleIconName = AppIcon;
|
||||
@@ -1278,6 +1290,7 @@
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
ALWAYS_SEARCH_USER_PATHS = NO;
|
||||
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
|
||||
CLANG_ANALYZER_LOCALIZABILITY_NONLOCALIZED = YES;
|
||||
CLANG_ANALYZER_NONNULL = YES;
|
||||
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
|
||||
@@ -1311,6 +1324,7 @@
|
||||
COMPANION_BUNDLE_IDENTIFIER = app.luma.chat;
|
||||
COPY_PHASE_STRIP = NO;
|
||||
CURRENT_PROJECT_VERSION = 31;
|
||||
DEAD_CODE_STRIPPING = YES;
|
||||
DEBUG_INFORMATION_FORMAT = dwarf;
|
||||
ENABLE_STRICT_OBJC_MSGSEND = YES;
|
||||
ENABLE_TESTABILITY = YES;
|
||||
@@ -1334,6 +1348,7 @@
|
||||
MTL_FAST_MATH = YES;
|
||||
ONLY_ACTIVE_ARCH = YES;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
STRING_CATALOG_GENERATE_SYMBOLS = YES;
|
||||
SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG;
|
||||
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
|
||||
SWIFT_VERSION = 5.9;
|
||||
@@ -1363,6 +1378,7 @@
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
ALWAYS_SEARCH_USER_PATHS = NO;
|
||||
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
|
||||
CLANG_ANALYZER_LOCALIZABILITY_NONLOCALIZED = YES;
|
||||
CLANG_ANALYZER_NONNULL = YES;
|
||||
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
|
||||
@@ -1396,6 +1412,7 @@
|
||||
COMPANION_BUNDLE_IDENTIFIER = app.luma.chat;
|
||||
COPY_PHASE_STRIP = NO;
|
||||
CURRENT_PROJECT_VERSION = 31;
|
||||
DEAD_CODE_STRIPPING = YES;
|
||||
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
|
||||
ENABLE_NS_ASSERTIONS = NO;
|
||||
ENABLE_STRICT_OBJC_MSGSEND = YES;
|
||||
@@ -1412,6 +1429,7 @@
|
||||
MTL_ENABLE_DEBUG_INFO = NO;
|
||||
MTL_FAST_MATH = YES;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
STRING_CATALOG_GENERATE_SYMBOLS = YES;
|
||||
SWIFT_COMPILATION_MODE = wholemodule;
|
||||
SWIFT_OPTIMIZATION_LEVEL = "-O";
|
||||
SWIFT_VERSION = 5.9;
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Scheme
|
||||
LastUpgradeVersion = "1600"
|
||||
version = "1.7">
|
||||
LastUpgradeVersion = "2660"
|
||||
version = "1.3">
|
||||
<BuildAction
|
||||
parallelizeBuildables = "YES"
|
||||
buildImplicitDependencies = "YES"
|
||||
runPostActionsOnFailure = "NO">
|
||||
buildImplicitDependencies = "YES">
|
||||
<BuildActionEntries>
|
||||
<BuildActionEntry
|
||||
buildForTesting = "YES"
|
||||
@@ -41,8 +40,7 @@
|
||||
buildConfiguration = "Debug"
|
||||
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
|
||||
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
||||
shouldUseLaunchSchemeArgsEnv = "YES"
|
||||
onlyGenerateCoverageForSpecifiedTargets = "NO">
|
||||
shouldUseLaunchSchemeArgsEnv = "YES">
|
||||
<MacroExpansion>
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
@@ -65,8 +63,6 @@
|
||||
</BuildableReference>
|
||||
</TestableReference>
|
||||
</Testables>
|
||||
<CommandLineArguments>
|
||||
</CommandLineArguments>
|
||||
</TestAction>
|
||||
<LaunchAction
|
||||
buildConfiguration = "Debug"
|
||||
@@ -88,8 +84,6 @@
|
||||
ReferencedContainer = "container:Luma.xcodeproj">
|
||||
</BuildableReference>
|
||||
</BuildableProductRunnable>
|
||||
<CommandLineArguments>
|
||||
</CommandLineArguments>
|
||||
</LaunchAction>
|
||||
<ProfileAction
|
||||
buildConfiguration = "Release"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Scheme
|
||||
LastUpgradeVersion = "1600"
|
||||
LastUpgradeVersion = "2660"
|
||||
version = "1.3">
|
||||
<BuildAction
|
||||
parallelizeBuildables = "YES"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Scheme
|
||||
LastUpgradeVersion = "1600"
|
||||
LastUpgradeVersion = "2660"
|
||||
version = "1.3">
|
||||
<BuildAction
|
||||
parallelizeBuildables = "YES"
|
||||
|
||||
@@ -86,7 +86,9 @@ required=(
|
||||
Tests/MediaPreviewProcessorVideoTests.swift
|
||||
Tests/SCRAMSHA512Tests.swift
|
||||
Tests/AppLockPolicyTests.swift
|
||||
Tests/CallHistorySyncTests.swift
|
||||
Sources/Shared/XMPP/LumaScramSha512Mechanism.swift
|
||||
Sources/Shared/XMPP/CallHistorySync.swift
|
||||
Sources/Shared/Security/AppLockVault.swift
|
||||
Sources/Shared/Models/AppLockPolicy.swift
|
||||
Sources/Shared/UI/AppLockView.swift
|
||||
@@ -351,6 +353,18 @@ grep -q 'Блокировка приложения' Sources/Shared/UI/SettingsVi
|
||||
echo "Settings must offer the app lock toggle"
|
||||
exit 1
|
||||
}
|
||||
grep -q 'https://luma.chat/call-history' Sources/Shared/XMPP/CallHistorySync.swift || {
|
||||
echo "Call history must sync through its service namespace"
|
||||
exit 1
|
||||
}
|
||||
grep -q 'syncCallHistory' Sources/Shared/Models/AppModel.swift || {
|
||||
echo "Call cards must be synced to the user's other devices"
|
||||
exit 1
|
||||
}
|
||||
grep -q 'CallHistorySync.envelope' Sources/Shared/XMPP/XMPPService.swift || {
|
||||
echo "Incoming call-history payloads must be intercepted"
|
||||
exit 1
|
||||
}
|
||||
grep -q 'func deleteGroupChat' Sources/Shared/Models/AppModel.swift || {
|
||||
echo "Group chats must support local deletion"
|
||||
exit 1
|
||||
|
||||
@@ -2090,6 +2090,8 @@ final class AppModel: ObservableObject {
|
||||
activeCall = call
|
||||
case .callHistory(let entry):
|
||||
recordCallHistory(entry)
|
||||
case .callHistorySync(let envelope):
|
||||
recordSyncedCallHistory(envelope)
|
||||
case .callError(let message):
|
||||
errorMessage = message
|
||||
case .recoverableError(let message):
|
||||
@@ -2295,6 +2297,42 @@ final class AppModel: ObservableObject {
|
||||
&& entry.outcome == .missed
|
||||
&& selectedConversationID != entry.peerJID.lowercased()
|
||||
_ = upsertMessage(message, incrementUnread: isUnreadMissedCall)
|
||||
// Sync the card to the user's other devices via Carbons/MAM.
|
||||
xmpp.syncCallHistory(entry)
|
||||
}
|
||||
|
||||
/// Applies a call card received from another of the user's devices. The
|
||||
/// envelope id equals the origin-id of the sync message, which also equals
|
||||
/// the originating device's local clientID, so Carbons and MAM copies
|
||||
/// deduplicate into a single card.
|
||||
private func recordSyncedCallHistory(_ entry: CallHistorySync.Envelope) {
|
||||
guard let account else { return }
|
||||
let direction: ChatMessage.Direction =
|
||||
entry.direction == .outgoing
|
||||
? .outgoing
|
||||
: .incoming
|
||||
let metadata = CallHistoryMetadata(
|
||||
isVideo: entry.isVideo,
|
||||
outcome: entry.outcome
|
||||
)
|
||||
let message = ChatMessage(
|
||||
id: entry.id,
|
||||
conversationID: entry.peerJID,
|
||||
senderJID: direction == .outgoing ? account.normalizedJID : entry.peerJID,
|
||||
body: entry.isVideo ? "Видеозвонок" : "Аудиозвонок",
|
||||
timestamp: entry.startedAt,
|
||||
direction: direction,
|
||||
delivery: direction == .outgoing ? .sent : .delivered,
|
||||
security: .plaintext,
|
||||
kind: .system,
|
||||
duration: entry.duration,
|
||||
callHistory: metadata
|
||||
)
|
||||
let isUnreadMissedCall =
|
||||
direction == .incoming
|
||||
&& entry.outcome == .missed
|
||||
&& selectedConversationID != entry.peerJID.lowercased()
|
||||
_ = upsertMessage(message, incrementUnread: isUnreadMissedCall)
|
||||
}
|
||||
|
||||
private func upsertConversation(jid: String, name: String?) {
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
import Foundation
|
||||
import Martin
|
||||
|
||||
/// Service messages that sync call-history cards between the user's own
|
||||
/// devices. After a call ends the originating device sends a message to its
|
||||
/// own bare JID carrying a `<call-history xmlns='https://luma.chat/call-history'>`
|
||||
/// payload; Carbons deliver it to the other devices and MAM archives it.
|
||||
/// Receivers parse the payload and upsert the same card, deduplicating by
|
||||
/// the origin-id that matches the local card's clientID.
|
||||
enum CallHistorySync {
|
||||
static let namespace = "https://luma.chat/call-history"
|
||||
|
||||
struct Envelope: Equatable, Sendable {
|
||||
let id: String
|
||||
let peerJID: String
|
||||
let direction: CallDirection
|
||||
let isVideo: Bool
|
||||
let startedAt: Date
|
||||
let duration: TimeInterval?
|
||||
let outcome: CallHistoryOutcome
|
||||
}
|
||||
|
||||
static func envelope(from message: Message) -> Envelope? {
|
||||
guard let element = message.element.findChild(
|
||||
name: "call-history",
|
||||
xmlns: namespace
|
||||
) else {
|
||||
return nil
|
||||
}
|
||||
let value = { (name: String) -> String? in
|
||||
element.findChild(name: name)?.value
|
||||
}
|
||||
guard let rawPeer = value("with"), !rawPeer.isEmpty else { return nil }
|
||||
let durationSeconds = value("duration").flatMap { TimeInterval($0) }
|
||||
return Envelope(
|
||||
id: message.originId ?? message.id ?? UUID().uuidString,
|
||||
peerJID: rawPeer.lowercased(),
|
||||
direction: value("direction") == "outgoing" ? .outgoing : .incoming,
|
||||
isVideo: value("video") == "true",
|
||||
startedAt: value("start").flatMap { startFormatter.date(from: $0) } ?? Date(),
|
||||
duration: (durationSeconds ?? 0) > 0 ? durationSeconds : nil,
|
||||
outcome: value("status").flatMap(CallHistoryOutcome.init(rawValue:)) ?? .failed
|
||||
)
|
||||
}
|
||||
|
||||
static func payloadElement(entry: CallHistoryEntry) -> Element {
|
||||
let element = Element(name: "call-history", xmlns: namespace)
|
||||
element.addChild(Element(name: "direction", cdata: entry.direction.rawValue))
|
||||
element.addChild(Element(name: "status", cdata: entry.outcome.rawValue))
|
||||
element.addChild(
|
||||
Element(name: "duration", cdata: String(Int((entry.duration ?? 0).rounded())))
|
||||
)
|
||||
element.addChild(Element(name: "start", cdata: startFormatter.string(from: entry.startedAt)))
|
||||
element.addChild(Element(name: "with", cdata: entry.peerJID.lowercased()))
|
||||
element.addChild(Element(name: "video", cdata: entry.isVideo ? "true" : "false"))
|
||||
return element
|
||||
}
|
||||
|
||||
private static let startFormatter: ISO8601DateFormatter = {
|
||||
let formatter = ISO8601DateFormatter()
|
||||
formatter.formatOptions = [.withInternetDateTime]
|
||||
return formatter
|
||||
}()
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import Combine
|
||||
import Foundation
|
||||
@preconcurrency import Martin
|
||||
@preconcurrency import MartinOMEMO
|
||||
import os
|
||||
import WebRTC
|
||||
|
||||
private struct BufferedArchiveStanza {
|
||||
@@ -254,6 +255,7 @@ final class XMPPService {
|
||||
case mucArchiveSyncCompleted(archive: MAMArchiveKey, checkpoint: MAMArchiveCheckpoint)
|
||||
case call(CallSnapshot?)
|
||||
case callHistory(CallHistoryEntry)
|
||||
case callHistorySync(CallHistorySync.Envelope)
|
||||
case callError(String)
|
||||
case recoverableError(String)
|
||||
}
|
||||
@@ -766,6 +768,37 @@ final class XMPPService {
|
||||
}
|
||||
}
|
||||
|
||||
/// Sends a `<call-history/>` service message to the user's own bare JID
|
||||
/// so Carbons deliver it to the other devices and MAM archives it. The
|
||||
/// message is plaintext and never reaches the call counterpart.
|
||||
func syncCallHistory(_ entry: CallHistoryEntry) {
|
||||
guard let client, client.state == .connected(), let account else { return }
|
||||
let message = Message()
|
||||
message.to = JID(account.normalizedJID)
|
||||
message.type = .chat
|
||||
message.id = entry.id
|
||||
message.body = Self.callHistoryBody(entry)
|
||||
addOriginID(entry.id, to: message)
|
||||
message.addChild(CallHistorySync.payloadElement(entry: entry))
|
||||
client.context.writer.write(message, writeCompleted: nil)
|
||||
Logger(subsystem: "Luma", category: "call-sync")
|
||||
.info("sent call-history id=\(entry.id) peer=\(entry.peerJID) status=\(entry.outcome.rawValue)")
|
||||
}
|
||||
|
||||
private static func callHistoryBody(_ entry: CallHistoryEntry) -> String {
|
||||
let kind = entry.isVideo ? "Видеозвонок" : "Аудиозвонок"
|
||||
switch entry.outcome {
|
||||
case .completed: return "\(kind) завершён"
|
||||
case .missed: return "Пропущенный \(kind.lowercased())"
|
||||
case .declined: return "Отклонённый \(kind.lowercased())"
|
||||
case .cancelled: return "Отменённый \(kind.lowercased())"
|
||||
case .unanswered: return "\(kind) без ответа"
|
||||
case .failed: return "Неудачный \(kind.lowercased())"
|
||||
case .answeredElsewhere: return "\(kind) принят на другом устройстве"
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
func sendText(
|
||||
_ text: String,
|
||||
to recipient: String,
|
||||
@@ -1895,6 +1928,14 @@ final class XMPPService {
|
||||
isArchived: Bool = false,
|
||||
archivedPeerJID: BareJID? = nil
|
||||
) async {
|
||||
// Call-history service messages sync call cards between the user's
|
||||
// devices; they never become ordinary chat messages.
|
||||
if let sync = CallHistorySync.envelope(from: message) {
|
||||
Logger(subsystem: "Luma", category: "call-sync")
|
||||
.info("received call-history id=\(sync.id) peer=\(sync.peerJID) status=\(sync.outcome.rawValue)")
|
||||
eventHandler?(.callHistorySync(sync))
|
||||
return
|
||||
}
|
||||
guard message.type != .error,
|
||||
message.type != .groupchat,
|
||||
let client,
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
import Martin
|
||||
import XCTest
|
||||
@testable import Luma
|
||||
|
||||
final class CallHistorySyncTests: XCTestCase {
|
||||
private func makePayloadMessage(id: String = "call-42") -> Message {
|
||||
let message = Message()
|
||||
message.id = id
|
||||
let payload = Element(name: "call-history", xmlns: CallHistorySync.namespace)
|
||||
payload.addChild(Element(name: "direction", cdata: "incoming"))
|
||||
payload.addChild(Element(name: "status", cdata: "missed"))
|
||||
payload.addChild(Element(name: "duration", cdata: "0"))
|
||||
payload.addChild(Element(name: "start", cdata: "2026-08-27T10:00:00Z"))
|
||||
payload.addChild(Element(name: "with", cdata: "Bob@Example.org"))
|
||||
payload.addChild(Element(name: "video", cdata: "false"))
|
||||
message.addChild(payload)
|
||||
return message
|
||||
}
|
||||
|
||||
func testEnvelopeParsesPayload() throws {
|
||||
let envelope = try XCTUnwrap(CallHistorySync.envelope(from: makePayloadMessage()))
|
||||
XCTAssertEqual(envelope.id, "call-42")
|
||||
XCTAssertEqual(envelope.peerJID, "bob@example.org")
|
||||
XCTAssertEqual(envelope.direction, .incoming)
|
||||
XCTAssertEqual(envelope.outcome, .missed)
|
||||
XCTAssertFalse(envelope.isVideo)
|
||||
XCTAssertNil(envelope.duration)
|
||||
XCTAssertEqual(
|
||||
envelope.startedAt,
|
||||
ISO8601DateFormatter().date(from: "2026-08-27T10:00:00Z")
|
||||
)
|
||||
}
|
||||
|
||||
func testEnvelopePrefersOriginID() throws {
|
||||
let message = makePayloadMessage()
|
||||
let origin = Element(name: "origin-id", xmlns: "urn:xmpp:sid:0")
|
||||
origin.setAttribute("id", value: "call-uuid")
|
||||
message.addChild(origin)
|
||||
XCTAssertEqual(CallHistorySync.envelope(from: message)?.id, "call-uuid")
|
||||
}
|
||||
|
||||
func testEnvelopeIgnoresOrdinaryMessages() {
|
||||
let message = Message()
|
||||
message.body = "hello"
|
||||
XCTAssertNil(CallHistorySync.envelope(from: message))
|
||||
}
|
||||
|
||||
func testPayloadRoundTripPreservesCallDetails() throws {
|
||||
let entry = CallHistoryEntry(
|
||||
id: "call-1",
|
||||
peerJID: "bob@example.org",
|
||||
direction: .outgoing,
|
||||
isVideo: true,
|
||||
startedAt: Date(timeIntervalSince1970: 1_700_000_000),
|
||||
endedAt: Date(timeIntervalSince1970: 1_700_000_042),
|
||||
duration: 42,
|
||||
outcome: .completed
|
||||
)
|
||||
let message = Message()
|
||||
message.id = entry.id
|
||||
message.addChild(CallHistorySync.payloadElement(entry: entry))
|
||||
|
||||
let envelope = try XCTUnwrap(CallHistorySync.envelope(from: message))
|
||||
XCTAssertEqual(envelope.id, "call-1")
|
||||
XCTAssertEqual(envelope.peerJID, "bob@example.org")
|
||||
XCTAssertEqual(envelope.direction, .outgoing)
|
||||
XCTAssertTrue(envelope.isVideo)
|
||||
XCTAssertEqual(envelope.duration, 42)
|
||||
XCTAssertEqual(envelope.outcome, .completed)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user