import Foundation import UIKit import SwiftSignalKit import TelegramCore import SyncCore import Postbox import TelegramUIPreferences import TgVoip import TgVoipWebrtc private func callConnectionDescription(_ connection: CallSessionConnection) -> OngoingCallConnectionDescription? { switch connection { case let .reflector(reflector): return OngoingCallConnectionDescription(connectionId: reflector.id, ip: reflector.ip, ipv6: reflector.ipv6, port: reflector.port, peerTag: reflector.peerTag) case .webRtcReflector: return nil } } private func callConnectionDescriptionWebrtc(_ connection: CallSessionConnection) -> OngoingCallConnectionDescriptionWebrtc? { switch connection { case .reflector: return nil case let .webRtcReflector(reflector): return OngoingCallConnectionDescriptionWebrtc(connectionId: reflector.id, hasStun: reflector.hasStun, hasTurn: reflector.hasTurn, ip: reflector.ip.isEmpty ? reflector.ipv6 : reflector.ip, port: reflector.port, username: reflector.username, password: reflector.password) } } private let callLogsLimit = 20 public func callLogNameForId(id: Int64, account: Account) -> String? { let path = callLogsPath(account: account) let namePrefix = "\(id)_" if let enumerator = FileManager.default.enumerator(at: URL(fileURLWithPath: path), includingPropertiesForKeys: [], options: [.skipsHiddenFiles, .skipsSubdirectoryDescendants], errorHandler: nil) { for url in enumerator { if let url = url as? URL { if url.lastPathComponent.hasPrefix(namePrefix) { return url.lastPathComponent } } } } return nil } public func callLogsPath(account: Account) -> String { return account.basePath + "/calls" } private func cleanupCallLogs(account: Account) { let path = callLogsPath(account: account) let fileManager = FileManager.default if !fileManager.fileExists(atPath: path, isDirectory: nil) { try? fileManager.createDirectory(atPath: path, withIntermediateDirectories: true, attributes: nil) } var oldest: (URL, Date)? = nil var count = 0 if let enumerator = FileManager.default.enumerator(at: URL(fileURLWithPath: path), includingPropertiesForKeys: [.contentModificationDateKey], options: [.skipsHiddenFiles, .skipsSubdirectoryDescendants], errorHandler: nil) { for url in enumerator { if let url = url as? URL { if let date = (try? url.resourceValues(forKeys: Set([.contentModificationDateKey])))?.contentModificationDate { if let currentOldest = oldest { if date < currentOldest.1 { oldest = (url, date) } } else { oldest = (url, date) } count += 1 } } } } if count > callLogsLimit, let oldest = oldest { try? fileManager.removeItem(atPath: oldest.0.path) } } private let setupLogs: Bool = { OngoingCallThreadLocalContext.setupLoggingFunction({ value in if let value = value { Logger.shared.log("TGVOIP", value) } }) OngoingCallThreadLocalContextWebrtc.setupLoggingFunction({ value in if let value = value { Logger.shared.log("TGVOIP", value) } }) /*OngoingCallThreadLocalContextWebrtcCustom.setupLoggingFunction({ value in if let value = value { Logger.shared.log("TGVOIP", value) } })*/ return true }() public struct OngoingCallContextState: Equatable { public enum State { case initializing case connected case reconnecting case failed } public enum VideoState: Equatable { case notAvailable case inactive case active case paused } public enum RemoteVideoState: Equatable { case inactive case active case paused } public enum RemoteAudioState: Equatable { case active case muted } public enum RemoteBatteryLevel: Equatable { case normal case low } public let state: State public let videoState: VideoState public let remoteVideoState: RemoteVideoState public let remoteAudioState: RemoteAudioState public let remoteBatteryLevel: RemoteBatteryLevel } private final class OngoingCallThreadLocalContextQueueImpl: NSObject, OngoingCallThreadLocalContextQueue, OngoingCallThreadLocalContextQueueWebrtc /*, OngoingCallThreadLocalContextQueueWebrtcCustom*/ { private let queue: Queue init(queue: Queue) { self.queue = queue super.init() } func dispatch(_ f: @escaping () -> Void) { self.queue.async { f() } } func dispatch(after seconds: Double, block f: @escaping () -> Void) { self.queue.after(seconds, f) } func isCurrent() -> Bool { return self.queue.isCurrent() } } private func ongoingNetworkTypeForType(_ type: NetworkType) -> OngoingCallNetworkType { switch type { case .none: return .wifi case .wifi: return .wifi case let .cellular(cellular): switch cellular { case .edge: return .cellularEdge case .gprs: return .cellularGprs case .thirdG, .unknown: return .cellular3g case .lte: return .cellularLte } } } private func ongoingNetworkTypeForTypeWebrtc(_ type: NetworkType) -> OngoingCallNetworkTypeWebrtc { switch type { case .none: return .wifi case .wifi: return .wifi case let .cellular(cellular): switch cellular { case .edge: return .cellularEdge case .gprs: return .cellularGprs case .thirdG, .unknown: return .cellular3g case .lte: return .cellularLte } } } /*private func ongoingNetworkTypeForTypeWebrtcCustom(_ type: NetworkType) -> OngoingCallNetworkTypeWebrtcCustom { switch type { case .none: return .wifi case .wifi: return .wifi case let .cellular(cellular): switch cellular { case .edge: return .cellularEdge case .gprs: return .cellularGprs case .thirdG, .unknown: return .cellular3g case .lte: return .cellularLte } } }*/ private func ongoingDataSavingForType(_ type: VoiceCallDataSaving) -> OngoingCallDataSaving { switch type { case .never: return .never case .cellular: return .cellular case .always: return .always default: return .never } } private func ongoingDataSavingForTypeWebrtc(_ type: VoiceCallDataSaving) -> OngoingCallDataSavingWebrtc { switch type { case .never: return .never case .cellular: return .cellular case .always: return .always default: return .never } } /*private func ongoingDataSavingForTypeWebrtcCustom(_ type: VoiceCallDataSaving) -> OngoingCallDataSavingWebrtcCustom { switch type { case .never: return .never case .cellular: return .cellular case .always: return .always default: return .never } }*/ private protocol OngoingCallThreadLocalContextProtocol: class { func nativeSetNetworkType(_ type: NetworkType) func nativeSetIsMuted(_ value: Bool) func nativeSetIsLowBatteryLevel(_ value: Bool) func nativeRequestVideo(_ capturer: OngoingCallVideoCapturer) func nativeDisableVideo() func nativeStop(_ completion: @escaping (String?, Int64, Int64, Int64, Int64) -> Void) func nativeBeginTermination() func nativeDebugInfo() -> String func nativeVersion() -> String func nativeGetDerivedState() -> Data } private final class OngoingCallThreadLocalContextHolder { let context: OngoingCallThreadLocalContextProtocol init(_ context: OngoingCallThreadLocalContextProtocol) { self.context = context } } extension OngoingCallThreadLocalContext: OngoingCallThreadLocalContextProtocol { func nativeSetNetworkType(_ type: NetworkType) { self.setNetworkType(ongoingNetworkTypeForType(type)) } func nativeStop(_ completion: @escaping (String?, Int64, Int64, Int64, Int64) -> Void) { self.stop(completion) } func nativeBeginTermination() { } func nativeSetIsMuted(_ value: Bool) { self.setIsMuted(value) } func nativeSetIsLowBatteryLevel(_ value: Bool) { } func nativeRequestVideo(_ capturer: OngoingCallVideoCapturer) { } func nativeDisableVideo() { } func nativeSwitchVideoCamera() { } func nativeDebugInfo() -> String { return self.debugInfo() ?? "" } func nativeVersion() -> String { return self.version() ?? "" } func nativeGetDerivedState() -> Data { return self.getDerivedState() } } public final class OngoingCallVideoCapturer { fileprivate let impl: OngoingCallThreadLocalContextVideoCapturer public init() { self.impl = OngoingCallThreadLocalContextVideoCapturer() } public func switchCamera() { self.impl.switchVideoCamera() } public func makeOutgoingVideoView(completion: @escaping (OngoingCallContextPresentationCallVideoView?) -> Void) { self.impl.makeOutgoingVideoView { view in if let view = view { completion(OngoingCallContextPresentationCallVideoView( view: view, setOnFirstFrameReceived: { [weak view] f in view?.setOnFirstFrameReceived(f) }, getOrientation: { return .rotation0 }, setOnOrientationUpdated: { _ in }, setOnIsMirroredUpdated: { [weak view] f in view?.setOnIsMirroredUpdated(f) } )) } else { completion(nil) } } } public func setIsVideoEnabled(_ value: Bool) { self.impl.setIsVideoEnabled(value) } } extension OngoingCallThreadLocalContextWebrtc: OngoingCallThreadLocalContextProtocol { func nativeSetNetworkType(_ type: NetworkType) { self.setNetworkType(ongoingNetworkTypeForTypeWebrtc(type)) } func nativeStop(_ completion: @escaping (String?, Int64, Int64, Int64, Int64) -> Void) { self.stop(completion) } func nativeBeginTermination() { self.beginTermination() } func nativeSetIsMuted(_ value: Bool) { self.setIsMuted(value) } func nativeSetIsLowBatteryLevel(_ value: Bool) { self.setIsLowBatteryLevel(value) } func nativeRequestVideo(_ capturer: OngoingCallVideoCapturer) { self.requestVideo(capturer.impl) } func nativeDisableVideo() { self.disableVideo() } func nativeDebugInfo() -> String { return self.debugInfo() ?? "" } func nativeVersion() -> String { return self.version() ?? "" } func nativeGetDerivedState() -> Data { return self.getDerivedState() } } private extension OngoingCallContextState.State { init(_ state: OngoingCallState) { switch state { case .initializing: self = .initializing case .connected: self = .connected case .failed: self = .failed case .reconnecting: self = .reconnecting default: self = .failed } } } private extension OngoingCallContextState.State { init(_ state: OngoingCallStateWebrtc) { switch state { case .initializing: self = .initializing case .connected: self = .connected case .failed: self = .failed case .reconnecting: self = .reconnecting default: self = .failed } } } public enum OngoingCallVideoOrientation { case rotation0 case rotation90 case rotation180 case rotation270 } private extension OngoingCallVideoOrientation { init(_ orientation: OngoingCallVideoOrientationWebrtc) { switch orientation { case .orientation0: self = .rotation0 case .orientation90: self = .rotation90 case .orientation180: self = .rotation180 case .orientation270: self = .rotation270 @unknown default: self = .rotation0 } } } public final class OngoingCallContextPresentationCallVideoView { public let view: UIView public let setOnFirstFrameReceived: (((Float) -> Void)?) -> Void public let getOrientation: () -> OngoingCallVideoOrientation public let setOnOrientationUpdated: (((OngoingCallVideoOrientation) -> Void)?) -> Void public let setOnIsMirroredUpdated: (((Bool) -> Void)?) -> Void public init( view: UIView, setOnFirstFrameReceived: @escaping (((Float) -> Void)?) -> Void, getOrientation: @escaping () -> OngoingCallVideoOrientation, setOnOrientationUpdated: @escaping (((OngoingCallVideoOrientation) -> Void)?) -> Void, setOnIsMirroredUpdated: @escaping (((Bool) -> Void)?) -> Void ) { self.view = view self.setOnFirstFrameReceived = setOnFirstFrameReceived self.getOrientation = getOrientation self.setOnOrientationUpdated = setOnOrientationUpdated self.setOnIsMirroredUpdated = setOnIsMirroredUpdated } } public final class OngoingCallContext { public struct AuxiliaryServer { public enum Connection { case stun case turn(username: String, password: String) } public let host: String public let port: Int public let connection: Connection public init( host: String, port: Int, connection: Connection ) { self.host = host self.port = port self.connection = connection } } public let internalId: CallSessionInternalId private let queue = Queue() private let account: Account private let callSessionManager: CallSessionManager private let logPath: String private var contextRef: Unmanaged? private let contextState = Promise(nil) public var state: Signal { return self.contextState.get() } private var didReportCallAsVideo: Bool = false private var signalingDataDisposable: Disposable? private let receptionPromise = Promise(nil) public var reception: Signal { return self.receptionPromise.get() } private let audioSessionDisposable = MetaDisposable() private var networkTypeDisposable: Disposable? public static var maxLayer: Int32 { return OngoingCallThreadLocalContext.maxLayer() } public static func versions(includeExperimental: Bool, includeReference: Bool) -> [(version: String, supportsVideo: Bool)] { var result: [(version: String, supportsVideo: Bool)] = [(OngoingCallThreadLocalContext.version(), false)] if includeExperimental { result.append(contentsOf: OngoingCallThreadLocalContextWebrtc.versions(withIncludeReference: includeReference).map { version -> (version: String, supportsVideo: Bool) in return (version, true) }) } return result } public init(account: Account, callSessionManager: CallSessionManager, internalId: CallSessionInternalId, proxyServer: ProxyServerSettings?, initialNetworkType: NetworkType, updatedNetworkType: Signal, serializedData: String?, dataSaving: VoiceCallDataSaving, derivedState: VoipDerivedState, key: Data, isOutgoing: Bool, video: OngoingCallVideoCapturer?, connections: CallSessionConnectionSet, maxLayer: Int32, version: String, allowP2P: Bool, enableHighBitrateVideoCalls: Bool, audioSessionActive: Signal, logName: String) { let _ = setupLogs OngoingCallThreadLocalContext.applyServerConfig(serializedData) self.internalId = internalId self.account = account self.callSessionManager = callSessionManager self.logPath = logName.isEmpty ? "" : callLogsPath(account: self.account) + "/" + logName + ".log" let logPath = self.logPath let queue = self.queue cleanupCallLogs(account: account) self.audioSessionDisposable.set((audioSessionActive |> filter { $0 } |> take(1) |> deliverOn(queue)).start(next: { [weak self] _ in if let strongSelf = self { if OngoingCallThreadLocalContextWebrtc.versions(withIncludeReference: true).contains(version) { var voipProxyServer: VoipProxyServerWebrtc? if let proxyServer = proxyServer { switch proxyServer.connection { case let .socks5(username, password): voipProxyServer = VoipProxyServerWebrtc(host: proxyServer.host, port: proxyServer.port, username: username, password: password) case .mtp: break } } let screenSize = UIScreen.main.bounds.size let portraitSize = CGSize(width: min(screenSize.width, screenSize.height), height: max(screenSize.width, screenSize.height)) let preferredAspectRatio = portraitSize.width / portraitSize.height let unfilteredConnections = [connections.primary] + connections.alternatives var processedConnections: [CallSessionConnection] = [] var filteredConnections: [OngoingCallConnectionDescriptionWebrtc] = [] for connection in unfilteredConnections { if processedConnections.contains(connection) { continue } processedConnections.append(connection) if let mapped = callConnectionDescriptionWebrtc(connection) { if mapped.ip.isEmpty { continue } filteredConnections.append(mapped) } } let context = OngoingCallThreadLocalContextWebrtc(version: version, queue: OngoingCallThreadLocalContextQueueImpl(queue: queue), proxy: voipProxyServer, networkType: ongoingNetworkTypeForTypeWebrtc(initialNetworkType), dataSaving: ongoingDataSavingForTypeWebrtc(dataSaving), derivedState: derivedState.data, key: key, isOutgoing: isOutgoing, connections: filteredConnections, maxLayer: maxLayer, allowP2P: allowP2P, logPath: logPath, sendSignalingData: { [weak callSessionManager] data in callSessionManager?.sendSignalingData(internalId: internalId, data: data) }, videoCapturer: video?.impl, preferredAspectRatio: Float(preferredAspectRatio), enableHighBitrateVideoCalls: enableHighBitrateVideoCalls) strongSelf.contextRef = Unmanaged.passRetained(OngoingCallThreadLocalContextHolder(context)) context.stateChanged = { [weak callSessionManager] state, videoState, remoteVideoState, remoteAudioState, remoteBatteryLevel, _ in queue.async { guard let strongSelf = self else { return } let mappedState = OngoingCallContextState.State(state) let mappedVideoState: OngoingCallContextState.VideoState switch videoState { case .inactive: mappedVideoState = .inactive case .active: mappedVideoState = .active case .paused: mappedVideoState = .paused @unknown default: mappedVideoState = .notAvailable } let mappedRemoteVideoState: OngoingCallContextState.RemoteVideoState switch remoteVideoState { case .inactive: mappedRemoteVideoState = .inactive case .active: mappedRemoteVideoState = .active case .paused: mappedRemoteVideoState = .paused @unknown default: mappedRemoteVideoState = .inactive } let mappedRemoteAudioState: OngoingCallContextState.RemoteAudioState switch remoteAudioState { case .active: mappedRemoteAudioState = .active case .muted: mappedRemoteAudioState = .muted @unknown default: mappedRemoteAudioState = .active } let mappedRemoteBatteryLevel: OngoingCallContextState.RemoteBatteryLevel switch remoteBatteryLevel { case .normal: mappedRemoteBatteryLevel = .normal case .low: mappedRemoteBatteryLevel = .low @unknown default: mappedRemoteBatteryLevel = .normal } if case .active = mappedVideoState, !strongSelf.didReportCallAsVideo { strongSelf.didReportCallAsVideo = true callSessionManager?.updateCallType(internalId: internalId, type: .video) } strongSelf.contextState.set(.single(OngoingCallContextState(state: mappedState, videoState: mappedVideoState, remoteVideoState: mappedRemoteVideoState, remoteAudioState: mappedRemoteAudioState, remoteBatteryLevel: mappedRemoteBatteryLevel))) } } strongSelf.receptionPromise.set(.single(4)) context.signalBarsChanged = { signalBars in self?.receptionPromise.set(.single(signalBars)) } strongSelf.networkTypeDisposable = (updatedNetworkType |> deliverOn(queue)).start(next: { networkType in self?.withContext { context in context.nativeSetNetworkType(networkType) } }) } else { var voipProxyServer: VoipProxyServer? if let proxyServer = proxyServer { switch proxyServer.connection { case let .socks5(username, password): voipProxyServer = VoipProxyServer(host: proxyServer.host, port: proxyServer.port, username: username, password: password) case .mtp: break } } let context = OngoingCallThreadLocalContext(queue: OngoingCallThreadLocalContextQueueImpl(queue: queue), proxy: voipProxyServer, networkType: ongoingNetworkTypeForType(initialNetworkType), dataSaving: ongoingDataSavingForType(dataSaving), derivedState: derivedState.data, key: key, isOutgoing: isOutgoing, primaryConnection: callConnectionDescription(connections.primary)!, alternativeConnections: connections.alternatives.compactMap(callConnectionDescription), maxLayer: maxLayer, allowP2P: allowP2P, logPath: logPath) strongSelf.contextRef = Unmanaged.passRetained(OngoingCallThreadLocalContextHolder(context)) context.stateChanged = { state in self?.contextState.set(.single(OngoingCallContextState(state: OngoingCallContextState.State(state), videoState: .notAvailable, remoteVideoState: .inactive, remoteAudioState: .active, remoteBatteryLevel: .normal))) } context.signalBarsChanged = { signalBars in self?.receptionPromise.set(.single(signalBars)) } strongSelf.networkTypeDisposable = (updatedNetworkType |> deliverOn(queue)).start(next: { networkType in self?.withContext { context in context.nativeSetNetworkType(networkType) } }) } } })) self.signalingDataDisposable = (callSessionManager.callSignalingData(internalId: internalId)).start(next: { [weak self] data in print("data received") queue.async { self?.withContext { context in if let context = context as? OngoingCallThreadLocalContextWebrtc { context.addSignaling(data) } } } }) } deinit { let contextRef = self.contextRef self.queue.async { contextRef?.release() } self.audioSessionDisposable.dispose() self.networkTypeDisposable?.dispose() } private func withContext(_ f: @escaping (OngoingCallThreadLocalContextProtocol) -> Void) { self.queue.async { if let contextRef = self.contextRef { let context = contextRef.takeUnretainedValue() f(context.context) } } } public func beginTermination() { self.withContext { context in context.nativeBeginTermination() } } public func stop(callId: CallId? = nil, sendDebugLogs: Bool = false, debugLogValue: Promise) { let account = self.account let logPath = self.logPath self.withContext { context in context.nativeStop { debugLog, bytesSentWifi, bytesReceivedWifi, bytesSentMobile, bytesReceivedMobile in debugLogValue.set(.single(debugLog)) let delta = NetworkUsageStatsConnectionsEntry( cellular: NetworkUsageStatsDirectionsEntry( incoming: bytesReceivedMobile, outgoing: bytesSentMobile), wifi: NetworkUsageStatsDirectionsEntry( incoming: bytesReceivedWifi, outgoing: bytesSentWifi)) updateAccountNetworkUsageStats(account: self.account, category: .call, delta: delta) if !logPath.isEmpty, let debugLog = debugLog { let logsPath = callLogsPath(account: account) let _ = try? FileManager.default.createDirectory(atPath: logsPath, withIntermediateDirectories: true, attributes: nil) if let data = debugLog.data(using: .utf8) { let _ = try? data.write(to: URL(fileURLWithPath: logPath)) } } if let callId = callId, let debugLog = debugLog { if sendDebugLogs { let _ = saveCallDebugLog(network: self.account.network, callId: callId, log: debugLog).start() } } } let derivedState = context.nativeGetDerivedState() let _ = updateVoipDerivedStateInteractively(postbox: self.account.postbox, { _ in return VoipDerivedState(data: derivedState) }).start() } } public func setIsMuted(_ value: Bool) { self.withContext { context in context.nativeSetIsMuted(value) } } public func setIsLowBatteryLevel(_ value: Bool) { self.withContext { context in context.nativeSetIsLowBatteryLevel(value) } } public func requestVideo(_ capturer: OngoingCallVideoCapturer) { self.withContext { context in context.nativeRequestVideo(capturer) } } public func disableVideo() { self.withContext { context in context.nativeDisableVideo() } } public func debugInfo() -> Signal<(String, String), NoError> { let poll = Signal<(String, String), NoError> { subscriber in self.withContext { context in let version = context.nativeVersion() let debugInfo = context.nativeDebugInfo() subscriber.putNext((version, debugInfo)) subscriber.putCompletion() } return EmptyDisposable } return (poll |> then(.complete() |> delay(0.5, queue: Queue.concurrentDefaultQueue()))) |> restart } public func makeIncomingVideoView(completion: @escaping (OngoingCallContextPresentationCallVideoView?) -> Void) { self.withContext { context in if let context = context as? OngoingCallThreadLocalContextWebrtc { context.makeIncomingVideoView { view in if let view = view { completion(OngoingCallContextPresentationCallVideoView( view: view, setOnFirstFrameReceived: { [weak view] f in view?.setOnFirstFrameReceived(f) }, getOrientation: { [weak view] in if let view = view { return OngoingCallVideoOrientation(view.orientation) } else { return .rotation0 } }, setOnOrientationUpdated: { [weak view] f in view?.setOnOrientationUpdated { value in f?(OngoingCallVideoOrientation(value)) } }, setOnIsMirroredUpdated: { [weak view] f in view?.setOnIsMirroredUpdated { value in f?(value) } } )) } else { completion(nil) } } } else { completion(nil) } } } }