518 lines
19 KiB
Swift
518 lines
19 KiB
Swift
import ActivityKit
|
|
import AVFAudio
|
|
import Combine
|
|
import CryptoKit
|
|
import Foundation
|
|
import SwiftUI
|
|
import WebRTC
|
|
|
|
@MainActor
|
|
final class CallManager: NSObject, ObservableObject {
|
|
|
|
static let shared = CallManager()
|
|
|
|
@Published var uiState = CallUiState()
|
|
|
|
var ownPublicKey: String = ""
|
|
var role: CallRole?
|
|
var roomId: String = ""
|
|
var localPrivateKey: Curve25519.KeyAgreement.PrivateKey?
|
|
var localPublicKeyHex: String = ""
|
|
var sharedKey: Data?
|
|
var offerSent = false
|
|
var remoteDescriptionSet = false
|
|
var lastPeerSharedPublicHex = ""
|
|
|
|
var iceServers: [RTCIceServer] = []
|
|
|
|
private var signalToken: UUID?
|
|
private var webRtcToken: UUID?
|
|
private var iceToken: UUID?
|
|
|
|
var peerConnectionFactory: RTCPeerConnectionFactory?
|
|
var peerConnection: RTCPeerConnection?
|
|
var localAudioSource: RTCAudioSource?
|
|
var localAudioTrack: RTCAudioTrack?
|
|
var localAudioSender: RTCRtpSender?
|
|
var bufferedRemoteCandidates: [RTCIceCandidate] = []
|
|
var attachedReceiverIds: Set<String> = []
|
|
|
|
var durationTask: Task<Void, Never>?
|
|
var ringTimeoutTask: Task<Void, Never>?
|
|
var pendingMinimizeTask: Task<Void, Never>?
|
|
var liveActivity: Activity<CallActivityAttributes>?
|
|
|
|
private override init() {
|
|
super.init()
|
|
wireProtocolHandlers()
|
|
}
|
|
|
|
deinit {
|
|
if let signalToken { ProtocolManager.shared.removeSignalPeerHandler(signalToken) }
|
|
if let webRtcToken { ProtocolManager.shared.removeWebRtcHandler(webRtcToken) }
|
|
if let iceToken { ProtocolManager.shared.removeIceServersHandler(iceToken) }
|
|
}
|
|
|
|
// MARK: - Public API
|
|
|
|
func bindAccount(publicKey: String) {
|
|
ownPublicKey = publicKey.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
}
|
|
|
|
func onAuthenticated() {
|
|
ProtocolManager.shared.requestIceServers()
|
|
}
|
|
|
|
func resetForSessionEnd() {
|
|
finishCall(reason: nil, notifyPeer: false)
|
|
}
|
|
|
|
func startOutgoingCall(toPublicKey: String, title: String, username: String) -> CallActionResult {
|
|
let target = toPublicKey.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
guard !target.isEmpty, !DatabaseManager.isGroupDialogKey(target) else { return .invalidTarget }
|
|
guard ownPublicKey.isEmpty == false else { return .accountNotBound }
|
|
guard uiState.phase == .idle else { return .alreadyInCall }
|
|
|
|
beginCallSession(peerPublicKey: target, title: title, username: username)
|
|
role = .caller
|
|
ensureLocalSessionKeys()
|
|
uiState.phase = .outgoing
|
|
uiState.statusText = "Calling..."
|
|
|
|
ProtocolManager.shared.sendCallSignal(
|
|
signalType: .call,
|
|
src: ownPublicKey,
|
|
dst: target
|
|
)
|
|
CallSoundManager.shared.playCalling()
|
|
startRingTimeout()
|
|
startLiveActivity()
|
|
return .started
|
|
}
|
|
|
|
func acceptIncomingCall() -> CallActionResult {
|
|
guard uiState.phase == .incoming else { return .notIncoming }
|
|
guard ownPublicKey.isEmpty == false else { return .accountNotBound }
|
|
guard uiState.peerPublicKey.isEmpty == false else { return .invalidTarget }
|
|
|
|
cancelRingTimeout()
|
|
CallSoundManager.shared.stopAll()
|
|
role = .callee
|
|
ensureLocalSessionKeys()
|
|
guard localPublicKeyHex.isEmpty == false else { return .invalidTarget }
|
|
|
|
ProtocolManager.shared.sendCallSignal(
|
|
signalType: .keyExchange,
|
|
src: ownPublicKey,
|
|
dst: uiState.peerPublicKey,
|
|
sharedPublic: localPublicKeyHex
|
|
)
|
|
|
|
uiState.phase = .keyExchange
|
|
uiState.statusText = "Exchanging keys..."
|
|
return .started
|
|
}
|
|
|
|
func declineIncomingCall() {
|
|
print("[CallBar] declineIncomingCall() — phase=\(uiState.phase.rawValue)")
|
|
guard uiState.phase == .incoming else { return }
|
|
if ownPublicKey.isEmpty == false, uiState.peerPublicKey.isEmpty == false {
|
|
ProtocolManager.shared.sendCallSignal(
|
|
signalType: .endCall,
|
|
src: ownPublicKey,
|
|
dst: uiState.peerPublicKey
|
|
)
|
|
}
|
|
finishCall(reason: nil, notifyPeer: false)
|
|
}
|
|
|
|
func endCall() {
|
|
print("[CallBar] endCall() — phase=\(uiState.phase.rawValue) isMinimized=\(uiState.isMinimized)")
|
|
finishCall(reason: nil, notifyPeer: true)
|
|
}
|
|
|
|
func toggleMute() {
|
|
let nextMuted = !uiState.isMuted
|
|
uiState.isMuted = nextMuted
|
|
localAudioTrack?.isEnabled = !nextMuted
|
|
updateLiveActivity()
|
|
print("[Call] toggleMute: isMuted=\(nextMuted), trackEnabled=\(localAudioTrack?.isEnabled ?? false), trackState=\(localAudioTrack?.readyState.rawValue ?? -1)")
|
|
}
|
|
|
|
func toggleSpeaker() {
|
|
let nextSpeaker = !uiState.isSpeakerOn
|
|
uiState.isSpeakerOn = nextSpeaker
|
|
applyAudioOutputRouting()
|
|
let route = AVAudioSession.sharedInstance().currentRoute
|
|
print("[Call] toggleSpeaker: isSpeakerOn=\(nextSpeaker), outputs=\(route.outputs.map { $0.portName })")
|
|
}
|
|
|
|
func minimizeCall() {
|
|
guard uiState.isVisible else { return }
|
|
pendingMinimizeTask?.cancel()
|
|
pendingMinimizeTask = nil
|
|
print("[CallBar] minimizeCall() — phase=\(uiState.phase.rawValue)")
|
|
withAnimation(.spring(response: 0.4, dampingFraction: 0.85)) {
|
|
uiState.isMinimized = true
|
|
}
|
|
}
|
|
|
|
func expandCall() {
|
|
pendingMinimizeTask?.cancel()
|
|
pendingMinimizeTask = nil
|
|
guard uiState.isVisible else { return }
|
|
print("[CallBar] expandCall() — phase=\(uiState.phase.rawValue)")
|
|
withAnimation(.spring(response: 0.4, dampingFraction: 0.85)) {
|
|
uiState.isMinimized = false
|
|
}
|
|
}
|
|
|
|
// MARK: - Protocol handlers
|
|
|
|
private func wireProtocolHandlers() {
|
|
signalToken = ProtocolManager.shared.addSignalPeerHandler { [weak self] packet in
|
|
Task { @MainActor [weak self] in
|
|
self?.handleSignalPacket(packet)
|
|
}
|
|
}
|
|
webRtcToken = ProtocolManager.shared.addWebRtcHandler { [weak self] packet in
|
|
Task { @MainActor [weak self] in
|
|
await self?.handleWebRtcPacket(packet)
|
|
}
|
|
}
|
|
iceToken = ProtocolManager.shared.addIceServersHandler { [weak self] packet in
|
|
Task { @MainActor [weak self] in
|
|
self?.handleIceServersPacket(packet)
|
|
}
|
|
}
|
|
}
|
|
|
|
private func handleSignalPacket(_ packet: PacketSignalPeer) {
|
|
print("[CallBar] handleSignalPacket: type=\(packet.signalType) phase=\(uiState.phase.rawValue) isMinimized=\(uiState.isMinimized)")
|
|
switch packet.signalType {
|
|
case .endCallBecauseBusy:
|
|
finishCall(reason: "User is busy", notifyPeer: false, skipAttachment: true)
|
|
return
|
|
case .endCallBecausePeerDisconnected:
|
|
finishCall(reason: "Peer disconnected", notifyPeer: false)
|
|
return
|
|
case .endCall:
|
|
finishCall(reason: "Call ended", notifyPeer: false)
|
|
return
|
|
default:
|
|
break
|
|
}
|
|
|
|
if uiState.peerPublicKey.isEmpty == false, packet.src.isEmpty == false {
|
|
if packet.src != uiState.peerPublicKey && packet.src != ownPublicKey {
|
|
return
|
|
}
|
|
}
|
|
|
|
switch packet.signalType {
|
|
case .call:
|
|
let incomingPeer = packet.src.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
guard incomingPeer.isEmpty == false else { return }
|
|
guard uiState.phase == .idle else {
|
|
ProtocolManager.shared.sendCallSignal(
|
|
signalType: .endCallBecauseBusy,
|
|
src: ownPublicKey,
|
|
dst: incomingPeer
|
|
)
|
|
return
|
|
}
|
|
beginCallSession(peerPublicKey: incomingPeer, title: "", username: "")
|
|
role = .callee
|
|
uiState.phase = .incoming
|
|
withAnimation(.spring(response: 0.4, dampingFraction: 0.85)) {
|
|
uiState.isMinimized = false
|
|
}
|
|
uiState.statusText = "Incoming call..."
|
|
hydratePeerIdentity(for: incomingPeer)
|
|
CallSoundManager.shared.playRingtone()
|
|
startRingTimeout()
|
|
startLiveActivity()
|
|
case .keyExchange:
|
|
handleKeyExchange(packet)
|
|
case .createRoom:
|
|
let incomingRoomId = packet.roomId.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
guard incomingRoomId.isEmpty == false else { return }
|
|
roomId = incomingRoomId
|
|
uiState.phase = .webRtcExchange
|
|
uiState.statusText = "Connecting..."
|
|
Task { [weak self] in
|
|
await self?.ensurePeerConnectionAndOffer()
|
|
}
|
|
case .activeCall:
|
|
break
|
|
case .endCall, .endCallBecausePeerDisconnected, .endCallBecauseBusy:
|
|
break
|
|
}
|
|
}
|
|
|
|
private func handleKeyExchange(_ packet: PacketSignalPeer) {
|
|
let peerPublicHex = packet.sharedPublic.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
guard peerPublicHex.isEmpty == false else { return }
|
|
if sharedKey != nil,
|
|
peerPublicHex.caseInsensitiveCompare(lastPeerSharedPublicHex) == .orderedSame {
|
|
return
|
|
}
|
|
lastPeerSharedPublicHex = peerPublicHex
|
|
|
|
ensureLocalSessionKeys()
|
|
guard let localPrivateKey else { return }
|
|
guard let derivedSharedKey = CallMediaCrypto.deriveSharedKey(
|
|
localPrivateKey: localPrivateKey,
|
|
peerPublicHex: peerPublicHex
|
|
) else {
|
|
return
|
|
}
|
|
|
|
sharedKey = derivedSharedKey
|
|
uiState.keyCast = derivedSharedKey.hexString
|
|
applySenderCryptorIfPossible()
|
|
|
|
cancelRingTimeout()
|
|
CallSoundManager.shared.stopAll()
|
|
|
|
switch role {
|
|
case .caller:
|
|
ProtocolManager.shared.sendCallSignal(
|
|
signalType: .keyExchange,
|
|
src: ownPublicKey,
|
|
dst: uiState.peerPublicKey,
|
|
sharedPublic: localPublicKeyHex
|
|
)
|
|
ProtocolManager.shared.sendCallSignal(
|
|
signalType: .createRoom,
|
|
src: ownPublicKey,
|
|
dst: uiState.peerPublicKey
|
|
)
|
|
uiState.phase = .webRtcExchange
|
|
uiState.statusText = "Creating room..."
|
|
case .callee:
|
|
uiState.phase = .keyExchange
|
|
uiState.statusText = "Waiting for room..."
|
|
case .none:
|
|
break
|
|
}
|
|
}
|
|
|
|
private func handleIceServersPacket(_ packet: PacketIceServers) {
|
|
let mapped = packet.iceServers.compactMap { server -> RTCIceServer? in
|
|
let url = server.url.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
guard !url.isEmpty else { return nil }
|
|
|
|
if url.hasPrefix("stun:") || url.hasPrefix("turn:") {
|
|
return RTCIceServer(urlStrings: [url], username: server.username, credential: server.credential)
|
|
}
|
|
|
|
let transport = server.transport.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
if transport.isEmpty {
|
|
return RTCIceServer(urlStrings: ["turn:\(url)"], username: server.username, credential: server.credential)
|
|
}
|
|
return RTCIceServer(urlStrings: ["turn:\(url)?transport=\(transport)"], username: server.username, credential: server.credential)
|
|
}
|
|
iceServers = mapped
|
|
}
|
|
|
|
// MARK: - Internal helpers used by delegate extension
|
|
|
|
func setCallActiveIfNeeded() {
|
|
guard uiState.phase != .active else { return }
|
|
uiState.phase = .active
|
|
uiState.statusText = "Call active"
|
|
cancelRingTimeout()
|
|
CallSoundManager.shared.playConnected()
|
|
startDurationTimerIfNeeded()
|
|
updateLiveActivity()
|
|
}
|
|
|
|
func startRingTimeout() {
|
|
cancelRingTimeout()
|
|
let isIncoming = uiState.phase == .incoming
|
|
let timeout: Duration = isIncoming ? .seconds(45) : .seconds(60)
|
|
ringTimeoutTask = Task { [weak self] in
|
|
try? await Task.sleep(for: timeout)
|
|
guard !Task.isCancelled else { return }
|
|
guard let self else { return }
|
|
// Verify phase hasn't changed during sleep
|
|
if isIncoming, self.uiState.phase == .incoming {
|
|
self.finishCall(reason: "No answer", notifyPeer: true)
|
|
} else if !isIncoming, self.uiState.phase == .outgoing {
|
|
self.endCall()
|
|
}
|
|
}
|
|
}
|
|
|
|
func cancelRingTimeout() {
|
|
ringTimeoutTask?.cancel()
|
|
ringTimeoutTask = nil
|
|
}
|
|
|
|
// MARK: - Live Activity
|
|
|
|
func startLiveActivity() {
|
|
// End any stale activities from previous calls / schema changes
|
|
for activity in Activity<CallActivityAttributes>.activities {
|
|
Task { await activity.end(nil, dismissalPolicy: .immediate) }
|
|
}
|
|
|
|
let authInfo = ActivityAuthorizationInfo()
|
|
print("[Call] LiveActivity: areActivitiesEnabled=\(authInfo.areActivitiesEnabled), frequentPushesEnabled=\(authInfo.frequentPushesEnabled)")
|
|
guard authInfo.areActivitiesEnabled else {
|
|
print("[Call] LiveActivity DISABLED by user settings")
|
|
return
|
|
}
|
|
// Compress avatar to fit ActivityKit 4KB limit while maximizing quality
|
|
var avatarThumb: Data?
|
|
if let avatar = AvatarRepository.shared.loadAvatar(publicKey: uiState.peerPublicKey) {
|
|
avatarThumb = Self.compressAvatarForActivity(avatar)
|
|
print("[Call] Avatar thumb: \(avatarThumb?.count ?? 0) bytes")
|
|
} else {
|
|
print("[Call] No avatar for peer")
|
|
}
|
|
let attributes = CallActivityAttributes(
|
|
peerName: uiState.displayName,
|
|
peerPublicKey: uiState.peerPublicKey,
|
|
colorIndex: RosettaColors.avatarColorIndex(for: uiState.peerTitle, publicKey: uiState.peerPublicKey),
|
|
avatarThumb: avatarThumb
|
|
)
|
|
let state = CallActivityAttributes.ContentState(
|
|
durationSec: uiState.durationSec,
|
|
isActive: uiState.phase == .active,
|
|
isMuted: uiState.isMuted
|
|
)
|
|
print("[Call] LiveActivity starting: peerName=\(uiState.displayName), isActive=\(uiState.phase == .active)")
|
|
do {
|
|
liveActivity = try Activity.request(
|
|
attributes: attributes,
|
|
content: .init(state: state, staleDate: nil),
|
|
pushType: nil
|
|
)
|
|
print("[Call] LiveActivity started: id=\(liveActivity?.id ?? "nil"), state=\(String(describing: liveActivity?.activityState))")
|
|
} catch {
|
|
print("[Call] LiveActivity FAILED: \(error)")
|
|
}
|
|
}
|
|
|
|
func updateLiveActivity() {
|
|
guard let liveActivity, liveActivity.activityState == .active else { return }
|
|
// Avatar is embedded in attributes (set at startLiveActivity)
|
|
let state = CallActivityAttributes.ContentState(
|
|
durationSec: uiState.durationSec,
|
|
isActive: uiState.phase == .active,
|
|
isMuted: uiState.isMuted
|
|
)
|
|
Task {
|
|
await liveActivity.update(.init(state: state, staleDate: nil))
|
|
}
|
|
}
|
|
|
|
// Avatar is embedded directly in CallActivityAttributes (32x32 thumb)
|
|
|
|
func endLiveActivity() {
|
|
guard let liveActivity else { return }
|
|
let finalState = CallActivityAttributes.ContentState(
|
|
durationSec: uiState.durationSec,
|
|
isActive: false,
|
|
isMuted: false
|
|
)
|
|
Task {
|
|
await liveActivity.end(.init(state: finalState, staleDate: nil), dismissalPolicy: .immediate)
|
|
}
|
|
self.liveActivity = nil
|
|
}
|
|
|
|
func attachReceiverCryptor(_ receiver: RTCRtpReceiver) {
|
|
guard let sharedKey, sharedKey.count >= CallMediaCrypto.keyLength else { return }
|
|
guard attachedReceiverIds.contains(receiver.receiverId) == false else { return }
|
|
if WebRTCFrameCryptorBridge.attach(receiver, sharedKey: sharedKey) {
|
|
attachedReceiverIds.insert(receiver.receiverId)
|
|
}
|
|
}
|
|
|
|
func handleGeneratedCandidate(_ candidate: RTCIceCandidate) {
|
|
let payload: [String: Any] = [
|
|
"candidate": candidate.sdp,
|
|
"sdpMid": candidate.sdpMid as Any,
|
|
"sdpMLineIndex": Int(candidate.sdpMLineIndex),
|
|
]
|
|
guard let data = try? JSONSerialization.data(withJSONObject: payload),
|
|
let raw = String(data: data, encoding: .utf8) else {
|
|
return
|
|
}
|
|
ProtocolManager.shared.sendWebRtcSignal(signalType: .iceCandidate, sdpOrCandidate: raw)
|
|
}
|
|
|
|
func handleIceConnectionStateChanged(_ state: RTCIceConnectionState) {
|
|
print("[CallBar] ICE state changed: \(state.rawValue) — phase=\(uiState.phase.rawValue) isMinimized=\(uiState.isMinimized)")
|
|
switch state {
|
|
case .connected, .completed:
|
|
setCallActiveIfNeeded()
|
|
case .failed, .closed, .disconnected:
|
|
print("[CallBar] ICE \(state.rawValue) → finishCall()")
|
|
finishCall(reason: "Connection lost", notifyPeer: false)
|
|
default:
|
|
break
|
|
}
|
|
}
|
|
// MARK: - Adaptive Avatar Compressor
|
|
|
|
/// Compresses avatar to fit ActivityKit's ~4KB attributes limit.
|
|
/// Uses adaptive resolution + quality: starts at 80x80 high quality,
|
|
/// progressively reduces until it fits. Avatar is ALWAYS included
|
|
/// if the source image exists — never falls back to initials.
|
|
static func compressAvatarForActivity(_ image: UIImage) -> Data? {
|
|
let maxBytes = 3000 // safe margin under 4KB limit (other fields use ~500B)
|
|
|
|
// Resolution/quality ladder — ordered from best to smallest.
|
|
// Each step: (pixel size, JPEG qualities to try from high to low)
|
|
let ladder: [(size: Int, qualities: [CGFloat])] = [
|
|
(80, [0.7, 0.5, 0.3]),
|
|
(64, [0.7, 0.5, 0.3]),
|
|
(48, [0.6, 0.4, 0.2]),
|
|
(36, [0.5, 0.3]),
|
|
]
|
|
|
|
for step in ladder {
|
|
let sz = CGSize(width: step.size, height: step.size)
|
|
UIGraphicsBeginImageContextWithOptions(sz, false, 1.0)
|
|
image.draw(in: CGRect(origin: .zero, size: sz))
|
|
let resized = UIGraphicsGetImageFromCurrentImageContext()
|
|
UIGraphicsEndImageContext()
|
|
|
|
guard let resized else { continue }
|
|
|
|
for q in step.qualities {
|
|
if let data = resized.jpegData(compressionQuality: q), data.count <= maxBytes {
|
|
return data
|
|
}
|
|
}
|
|
}
|
|
|
|
// Ultimate fallback: 24x24 at minimum quality — guaranteed < 500 bytes
|
|
let tiny = CGSize(width: 24, height: 24)
|
|
UIGraphicsBeginImageContextWithOptions(tiny, false, 1.0)
|
|
image.draw(in: CGRect(origin: .zero, size: tiny))
|
|
let last = UIGraphicsGetImageFromCurrentImageContext()
|
|
UIGraphicsEndImageContext()
|
|
return last?.jpegData(compressionQuality: 0.1)
|
|
}
|
|
}
|
|
|
|
#if DEBUG
|
|
@MainActor
|
|
extension CallManager {
|
|
/// Test-only hook to drive signal routing without a live transport.
|
|
func testHandleSignalPacket(_ packet: PacketSignalPeer) {
|
|
handleSignalPacket(packet)
|
|
}
|
|
|
|
/// Test-only helper for deterministic state setup in routing tests.
|
|
func testSetUiState(_ state: CallUiState) {
|
|
uiState = state
|
|
}
|
|
}
|
|
#endif
|