#12 sync call history between devices via carbons and MAM
iOS CI / Build and Test SwiftUI App (push) Canceled after 0s
Build Unsigned iOS and macOS Apps / Build Unsigned iOS IPA (push) Canceled after 0s
Build Unsigned iOS and macOS Apps / Build macOS ZIP (push) Canceled after 0s

- 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:
wt committed 2026-08-29 20:51:28 +07:00
1 parent 0d78e02d2b
commit 1a26651daa
10 files changed
+269 -18

No files matched your search

+9
View File
@@ -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-инфраструктуры.
+24 -6
View File
@@ -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"
+14
View File
@@ -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
+38
View File
@@ -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?) {
+65
View File
@@ -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
}()
}
+41
View File
@@ -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,
+72
View File
@@ -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)
}
}