#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
@@ -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,
|
||||
|
||||
Reference in new issue
Block a user