Files
Luma/Sources/Shared/XMPP/CallHistorySync.swift
T
wt 1a26651daa
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
#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.
2026-08-29 20:51:28 +07:00

66 lines
2.7 KiB
Swift

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
}()
}