#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

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