Files
mobile-ios/Rosetta/Core/Services/CallManager.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