// _____ _ // |_ _| |_ _ _ ___ ___ _ __ __ _ // | | | ' \| '_/ -_) -_) ' \/ _` |_ // |_| |_||_|_| \___\___|_|_|_\__,_(_) // // Threema iOS Client // Copyright (c) 2017-2020 Threema GmbH // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU Affero General Public License, version 3, // as published by the Free Software Foundation. // // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU Affero General Public License for more details. // // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . import UIKit import ThreemaFramework import WebRTC class CallDiagnosticViewController: UIViewController, RTCPeerConnectionDelegate { @IBOutlet weak var descriptionLabel: UILabel! @IBOutlet weak var startButton: UIButton! @IBOutlet weak var copyButton: UIButton! @IBOutlet weak var diagnosticTextView: UITextView! @IBOutlet weak var finishLabel: UILabel! @IBOutlet weak var activityIndicator: UIActivityIndicatorView! var connection:RTCPeerConnection? var factory: RTCPeerConnectionFactory = RTCPeerConnectionFactory() var isDiagnosticRunning: Bool = false internal var kCANDIDATE_ATTRIBUTE: NSRegularExpression? internal var kSP: String = "\\s" internal var kICE_CHAR: String = "[a-zA-Z\\d\\+\\/]" internal var kFOUNDATION: String internal var kCOMPONENT_ID: String = "\\d{1,5}" internal var kTRANSPORT: String = "[uU][dD][pP]" internal var kPRIORITY: String = "\\d{1,10}" internal var kCANDIDATE_TYPES: String = "(host|srflx|prflx|relay)" internal var kCAND_TYPE: String internal var kCONNECTION_ADDRESS: String = "\\S+" internal var kREL_ADDR: String internal var kPORT: String = "\\d{1,5}" internal var kREL_PORT: String internal var kBYTE_STRING: String = "\\S+" internal var kEXTENSION_ATT_NAME: String internal var kEXTENSION_ATT_VALUE: String required init?(coder aDecoder: NSCoder) { kFOUNDATION = String.init(format: "%@{1,32}", kICE_CHAR) kCAND_TYPE = String.init(format: "typ%@%@", kSP, kCANDIDATE_TYPES) kREL_ADDR = String.init(format: "raddr%@(%@)", kSP, kCONNECTION_ADDRESS) kREL_PORT = String.init(format: "rport%@(%@)", kSP, kPORT) kEXTENSION_ATT_NAME = kBYTE_STRING kEXTENSION_ATT_VALUE = kBYTE_STRING do { kCANDIDATE_ATTRIBUTE = try NSRegularExpression(pattern: String.init(format: "candidate:(%@)%@(%@)%@(%@)%@(%@)%@(%@)%@(%@)%@%@(%@%@)?(%@%@)?((%@%@%@%@)*)", kFOUNDATION, kSP, kCOMPONENT_ID, kSP, kTRANSPORT, kSP, kPRIORITY, kSP, kCONNECTION_ADDRESS, kSP, kPORT, kSP, kCAND_TYPE, kSP, kREL_ADDR, kSP, kREL_PORT, kSP, kEXTENSION_ATT_NAME, kSP, kEXTENSION_ATT_VALUE), options: []) } catch { } super.init(coder: aDecoder) } override func viewDidLoad() { super.viewDidLoad() setupColors() setupLocalizables() activityIndicator.stopAnimating() activityIndicator.isHidden = true if diagnosticTextView.text.count == 0 { finishLabel.isHidden = true diagnosticTextView.isHidden = true copyButton.isHidden = true } } override func viewDidDisappear(_ animated: Bool) { super.viewDidDisappear(animated) DispatchQueue.main.async { if self.isDiagnosticRunning { self.printStatus("Diagnostic cancelled") } if self.connection != nil { self.connection?.close() self.connection = nil } } } // MARK: Private functions private func setupColors() { descriptionLabel.font = UIFont.systemFont(ofSize: 17.0) finishLabel.font = UIFont.systemFont(ofSize: 17.0) descriptionLabel.textColor = Colors.fontNormal() finishLabel.textColor = Colors.fontNormal() startButton.setTitleColor(Colors.main(), for: .normal) startButton.setTitleColor(Colors.main(), for: .highlighted) startButton.setTitleColor(Colors.main(), for: .selected) copyButton.setTitleColor(Colors.main(), for: .normal) copyButton.setTitleColor(Colors.main(), for: .highlighted) copyButton.setTitleColor(Colors.main(), for: .selected) self.view.backgroundColor = Colors.background() switch Colors.getTheme() { case ColorThemeDark, ColorThemeDarkWork: diagnosticTextView.textColor = UIColor(red: 0.5, green: 0.5, blue: 0.5, alpha: 1.0) activityIndicator.style = .white break case ColorThemeUndefined, ColorThemeLight, ColorThemeLightWork: diagnosticTextView.textColor = Colors.fontLight() activityIndicator.style = .gray break default: diagnosticTextView.textColor = Colors.fontLight() activityIndicator.style = .gray break } diagnosticTextView.font = UIFont.systemFont(ofSize: 15.0) diagnosticTextView.layer.borderWidth = 1.0 diagnosticTextView.layer.borderColor = Colors.fontVeryLight().cgColor } private func setupLocalizables() { self.title = NSLocalizedString("webrtc_diagnostics.title", comment: "") descriptionLabel.text = NSLocalizedString("webrtc_diagnostics.description", comment: "") finishLabel.text = NSLocalizedString("webrtc_diagnostics.done", comment: "") startButton.setTitle(NSLocalizedString("webrtc_diagnostics.start", comment: ""), for: .normal) copyButton.setTitle(NSLocalizedString("webrtc_diagnostics.copyToClipboard", comment: ""), for: .normal) } private func startDiagnostic() { isDiagnosticRunning = true diagnosticTextView.text = "" let ipv6 = UserSettings.shared().enableIPv6 ? "IPv6 enabled" : "IPv6 disabled" let relay = UserSettings.shared().alwaysRelayCalls ? "Always relay enabled" : "Always relay disabled" printStatus("Start diagnostic (\(ipv6), \(relay))") let constraints = defaultPeerConnectionConstraints() defaultRTCConfiguration { [self] (result) in guard case .success(let configuration) = result else { printStatus("Cannot obtain TURN servers: \(result)") return } connection = factory.peerConnection(with: configuration, constraints: constraints, delegate: self) let localStream = createLocalMediaStreamWithFactory(factory: factory) connection?.add(localStream) connection?.offer(for: constraints) { (sdp, error) in if (sdp != nil) { self.connection?.setLocalDescription(sdp!, completionHandler: { (error) in }) } } } } private func defaultPeerConnectionConstraints() -> RTCMediaConstraints { let optionalConstraints = ["DtlsSrtpKeyAgreement": "true"] let constraints = RTCMediaConstraints.init(mandatoryConstraints: nil, optionalConstraints: optionalConstraints) return constraints } private func defaultAudioConstraints() -> RTCMediaConstraints { let constraints = RTCMediaConstraints.init(mandatoryConstraints: nil, optionalConstraints: nil) return constraints } private func defaultRTCConfiguration(completion: @escaping (Result) -> Void) { VoIPIceServerSource.obtainIceServers(dualStack: false) { (result) in do { let configuration = RTCConfiguration() configuration.iceServers = [try result.get()] configuration.iceTransportPolicy = .all configuration.bundlePolicy = .maxBundle configuration.rtcpMuxPolicy = .require configuration.tcpCandidatePolicy = .disabled configuration.continualGatheringPolicy = .gatherOnce completion(.success(configuration)) } catch let error { completion(.failure(error)) } } } private func createLocalMediaStreamWithFactory(factory:RTCPeerConnectionFactory) -> RTCMediaStream { let source = factory.audioSource(with: defaultAudioConstraints()) let localStream = factory.mediaStream(withStreamId: "AMACALL") localStream.addAudioTrack(factory.audioTrack(with: source, trackId: "AMACALLa0")) return localStream } private func printCandidate(candidate: RTCIceCandidate) { DispatchQueue.main.sync { var diagnosticString = "" if diagnosticTextView.text.count > 0 { diagnosticString = diagnosticTextView.text + "\n" + "--------------------" + "\n" } let candidateDatas = self.parseCandidates(sdp: candidate.sdp) for candidateData in candidateDatas { diagnosticString = diagnosticString + "[" + candidateData.candType + "] " + candidateData.transport + " " + candidateData.connectionAddress + ":" if candidateData.port != nil { diagnosticString = diagnosticString + String(candidateData.port!) } if candidateData.relAddr != nil && candidateData.relPort != nil { diagnosticString = diagnosticString + " via " + candidateData.relAddr! + ":" + String(candidateData.relPort!) } diagnosticTextView.text = diagnosticString } } } private func parseCandidates(sdp: String) -> [CandidateData] { if kCANDIDATE_ATTRIBUTE != nil { let matches = kCANDIDATE_ATTRIBUTE!.matches(in: sdp, options: [], range: NSRange(location: 0, length: sdp.count)) let candidateData = matches.map { result -> CandidateData in var foundation: String var componentId: Int? var transport: String var priority: Int? var connectionAddress: String var port: Int? var candType: String var relAddr: String? var relPort: Int? var extensions: [String: String] = [String: String]() var range = result.range(at:1) var swiftRange = Range(range, in: sdp) foundation = String(sdp[swiftRange!]) range = result.range(at:2) swiftRange = Range(range, in: sdp) if swiftRange != nil { componentId = Int(String(sdp[swiftRange!])) } range = result.range(at:3) swiftRange = Range(range, in: sdp) transport = String(sdp[swiftRange!]) range = result.range(at:4) swiftRange = Range(range, in: sdp) if swiftRange != nil { priority = Int(String(sdp[swiftRange!])) } range = result.range(at:5) swiftRange = Range(range, in: sdp) connectionAddress = String(sdp[swiftRange!]) range = result.range(at:6) swiftRange = Range(range, in: sdp) if swiftRange != nil { port = Int(String(sdp[swiftRange!])) } range = result.range(at:7) swiftRange = Range(range, in: sdp) candType = String(sdp[swiftRange!]) range = result.range(at:9) swiftRange = Range(range, in: sdp) if swiftRange != nil { relAddr = String(sdp[swiftRange!]) } range = result.range(at:11) swiftRange = Range(range, in: sdp) if swiftRange != nil { relPort = Int(String(sdp[swiftRange!]))! } range = result.range(at:12) swiftRange = Range(range, in: sdp) if swiftRange != nil { let extensionsString = String(sdp[swiftRange!]) let extensionsArray = extensionsString.components(separatedBy: " ") var key: String? = nil for extensionValue in extensionsArray { if extensionValue.count > 0 { if key == nil { key = extensionValue } else { extensions.updateValue(extensionValue, forKey: key!) key = nil } } } } return CandidateData.init(theFoundation: foundation, theComponentId: componentId, theTransport: transport, thePriority: priority, theConnectionAddress: connectionAddress, thePort: port, theCandType: candType, theRelAddr: relAddr, theRelPort: relPort, theExtensions: extensions) } return candidateData } else { return [] } } private func printStatus(_ status: String) { var diagnosticString = "" if diagnosticTextView.text.count > 0 { diagnosticString = diagnosticTextView.text + "\n\n" } diagnosticTextView.text = diagnosticString + status } // MARK: IBActions @IBAction func startButtonTapped(_ sender: AnyObject) { diagnosticTextView.isHidden = false activityIndicator.startAnimating() activityIndicator.isHidden = false startButton.isHidden = true startDiagnostic() } @IBAction func copyButtonTapped(_ sender: AnyObject) { UIPasteboard.general.string = self.diagnosticTextView.text UIAlertTemplate.showAlert(owner: self, title: nil, message: NSLocalizedString("webrtc_diagnostics.copy", comment: "")) } // MARK: RTCPeerConnectionDelegate func peerConnection(_ peerConnection: RTCPeerConnection, didChange newState: RTCIceGatheringState) { if newState == .complete { isDiagnosticRunning = false DispatchQueue.main.async { self.printStatus("IceGathering complete") self.activityIndicator.stopAnimating() self.activityIndicator.isHidden = true self.finishLabel.isHidden = false self.copyButton.isHidden = false } } if newState == .gathering { DispatchQueue.main.async { self.printStatus("IceGathering start") } } } func peerConnection(_ peerConnection: RTCPeerConnection, didGenerate candidate: RTCIceCandidate) { printCandidate(candidate: candidate) } func peerConnection(_ peerConnection: RTCPeerConnection, didChange newState: RTCIceConnectionState) { } func peerConnection(_ peerConnection: RTCPeerConnection, didChange stateChanged: RTCSignalingState) { } func peerConnection(_ peerConnection: RTCPeerConnection, didOpen dataChannel: RTCDataChannel) { } func peerConnection(_ peerConnection: RTCPeerConnection, didRemove candidates: [RTCIceCandidate]) { } func peerConnectionShouldNegotiate(_ peerConnection: RTCPeerConnection) { } func peerConnection(_ peerConnection: RTCPeerConnection, didAdd stream: RTCMediaStream) { } func peerConnection(_ peerConnection: RTCPeerConnection, didRemove stream: RTCMediaStream) { } } struct CandidateData { var foundation: String var componentId: Int? var transport: String var priority: Int? var connectionAddress: String var port: Int? var candType: String var relAddr: String? var relPort: Int? var extensions: [String: String] init(theFoundation: String, theComponentId: Int?, theTransport: String, thePriority: Int?, theConnectionAddress: String, thePort: Int?, theCandType: String, theRelAddr: String?, theRelPort: Int?, theExtensions:[String: String]) { foundation = theFoundation componentId = theComponentId transport = theTransport priority = thePriority connectionAddress = theConnectionAddress port = thePort candType = theCandType relAddr = theRelAddr relPort = theRelPort extensions = theExtensions } }