diff --git a/Docs/ARCHITECTURE.md b/Docs/ARCHITECTURE.md index 44646e8..6fead05 100644 --- a/Docs/ARCHITECTURE.md +++ b/Docs/ARCHITECTURE.md @@ -196,6 +196,15 @@ trickle ICE candidates накапливаются до успешного нач успешным и получает длительность. Удаление `activeCall` до закрытия Jingle-сессии гарантирует, что ответный callback не создаст дубликат карточки. +Карточка звонка синхронизируется между устройствами: по завершении вызова +устройство отправляет самому себе (собственный bare JID) служебное сообщение с +`` (направление, статус, +длительность, время начала, собеседник, видео). Carbons доставляют его на +остальные устройства, MAM сохраняет в архиве; получатели парсят payload и +создают ту же карточку, а дедупликация идёт по origin-id, совпадающему с +clientID карточки на исходном устройстве. Пропущенный входящий звонок +увеличивает счётчик непрочитанных на каждом устройстве. + Текущая реализация принимает вызов при живом XMPP-соединении. Wake-up завершённого iOS-приложения намеренно не имитируется: production-путь требует VoIP push, PushKit/CallKit и серверной APNs-инфраструктуры. diff --git a/Luma.xcodeproj/project.pbxproj b/Luma.xcodeproj/project.pbxproj index 02632b8..2388281 100644 --- a/Luma.xcodeproj/project.pbxproj +++ b/Luma.xcodeproj/project.pbxproj @@ -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 = ""; }; 60A97545E6E481D45E0BB20F /* ChatView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatView.swift; sourceTree = ""; }; 61380B78824FDB98A933C7E9 /* ArchiveSyncRecoveryPolicyTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ArchiveSyncRecoveryPolicyTests.swift; sourceTree = ""; }; - 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 = ""; }; 6773F87EE60F91BBDCA4B1F5 /* GroupConversationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GroupConversationTests.swift; sourceTree = ""; }; 69F4FFBFD65C218FF6505844 /* SaslFailureMessage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SaslFailureMessage.swift; sourceTree = ""; }; 6DCB924748780145A707D890 /* MediaViewerDismissGestureTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaViewerDismissGestureTests.swift; sourceTree = ""; }; @@ -398,6 +402,7 @@ C35158C37C26CA9ACB6C9FFB /* WatchViews.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WatchViews.swift; sourceTree = ""; }; C37AAE886AA3B8782B89E1A0 /* ChatScrollPositionPolicy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatScrollPositionPolicy.swift; sourceTree = ""; }; C3E21A7D359AF7A7AF97D8DE /* LumaSaslFailureModule.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LumaSaslFailureModule.swift; sourceTree = ""; }; + C3F58259FA8A8DB43464FA0D /* CallHistorySyncTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CallHistorySyncTests.swift; sourceTree = ""; }; C86EBBD15843C6FB110C798E /* MediaFileIO.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaFileIO.swift; sourceTree = ""; }; C90C426133ECC0319483A084 /* ChatTimelineEntryTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatTimelineEntryTests.swift; sourceTree = ""; }; CBA20B768A9C675FABCDC821 /* ServerInfoView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServerInfoView.swift; sourceTree = ""; }; @@ -412,12 +417,12 @@ D8C81B9255E9886FB86E0785 /* InlineVideoPlayer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InlineVideoPlayer.swift; sourceTree = ""; }; D9BBE412EAF54DFE968B2E54 /* ArchiveSyncCheckpoint.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ArchiveSyncCheckpoint.swift; sourceTree = ""; }; D9FCE089ABBF05EF9F55D451 /* VideoNoteRecordingLifecycleTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VideoNoteRecordingLifecycleTests.swift; sourceTree = ""; }; - 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 = ""; }; E6A5C32B4DACD56DA733A0DB /* ServerInformation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServerInformation.swift; sourceTree = ""; }; E9F226A1F8D82B39630F9CEB /* LumaRoomStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LumaRoomStore.swift; sourceTree = ""; }; EA1D94FE541121DAE9DCE3B0 /* LegacyArchiveImporter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LegacyArchiveImporter.swift; sourceTree = ""; }; - 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 = ""; }; F207C1B20DE3780F1754F81D /* ConnectionBanner.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConnectionBanner.swift; sourceTree = ""; }; F26F91180E43DE9C135B68C1 /* EmojiCatalog.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EmojiCatalog.swift; sourceTree = ""; }; @@ -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; diff --git a/Luma.xcodeproj/xcshareddata/xcschemes/Luma.xcscheme b/Luma.xcodeproj/xcshareddata/xcschemes/Luma.xcscheme index b7f0e83..c16868a 100644 --- a/Luma.xcodeproj/xcshareddata/xcschemes/Luma.xcscheme +++ b/Luma.xcodeproj/xcshareddata/xcschemes/Luma.xcscheme @@ -1,11 +1,10 @@ + LastUpgradeVersion = "2660" + version = "1.3"> + buildImplicitDependencies = "YES"> + shouldUseLaunchSchemeArgsEnv = "YES"> - - - - ` +/// 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 + }() +} + diff --git a/Sources/Shared/XMPP/XMPPService.swift b/Sources/Shared/XMPP/XMPPService.swift index 67dfcc2..64dfff5 100644 --- a/Sources/Shared/XMPP/XMPPService.swift +++ b/Sources/Shared/XMPP/XMPPService.swift @@ -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 `` 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, diff --git a/Tests/CallHistorySyncTests.swift b/Tests/CallHistorySyncTests.swift new file mode 100644 index 0000000..7d73c6b --- /dev/null +++ b/Tests/CallHistorySyncTests.swift @@ -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) + } +} +