// _____ _ // |_ _| |_ _ _ ___ ___ _ __ __ _ // | | | ' \| '_/ -_) -_) ' \/ _` |_ // |_| |_||_|_| \___\___|_|_|_\__,_(_) // // Threema iOS Client // Copyright (c) 2019-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 Foundation import CocoaLumberjackSwift protocol VoIPCallServiceDelegate: class { func callServiceFinishedProcess() } class VoIPCallService: NSObject { private let kIncomingCallTimeout = 60.0 private let kCallFailedTimeout = 15.0 @objc public enum CallState: Int, RawRepresentable, Equatable { case idle case sendOffer case receivedOffer case outgoingRinging case incomingRinging case sendAnswer case receivedAnswer case initalizing case calling case reconnecting case ended case remoteEnded case rejected case rejectedBusy case rejectedTimeout case rejectedDisabled case rejectedOffHours case rejectedUnknown case microphoneDisabled } weak var delegate: VoIPCallServiceDelegate? private var peerConnectionClient: VoIPCallPeerConnectionClient? private var callKitManager: VoIPCallKitManager? private var threemaVideoCallAvailable: Bool = false private var callViewController: CallViewController? private var state: CallState = .idle { didSet { self.invalidateTimers(state: state) callViewController?.voIPCallStatusChanged(state: state, oldState: oldValue) self.handleLocalNotification() switch state { case .idle: localAddedIceCandidates.removeAll() localRelatedAddresses.removeAll() receivedIcecandidatesMessages.removeAll() case .initalizing: handleLocalIceCandidates([]) default: // do nothing break } self.addCallMessageToConversation(oldCallState: oldValue) handleTones(state: state, oldState: oldValue) } } private var audioPlayer: AVAudioPlayer? private var contact: Contact? private var callId: VoIPCallId? private var alreadyAccepted: Bool = false { didSet { callViewController?.alreadyAccepted = alreadyAccepted } } private var callInitiator: Bool = false { didSet { callViewController?.isCallInitiator = callInitiator } } private var audioMuted: Bool = false private var speakerActive: Bool = false private var videoActive: Bool = false private var isReceivingVideo: Bool = false { didSet { if callViewController != nil { callViewController?.isReceivingRemoteVideo = self.isReceivingVideo } } } private var initCallTimeoutTimer: Timer? private var incomingCallTimeoutTimer: Timer? private var callDurationTimer: Timer? private var callDurationTime: Int = 0 private var callFailedTimer: Timer? private var incomingOffer: VoIPCallOfferMessage? private var iceCandidatesLockQueue = DispatchQueue(label: "VoIPCallIceCandidatesLockQueue") private var iceCandidatesTimer: Timer? private var localAddedIceCandidates = [RTCIceCandidate]() private var localRelatedAddresses: Set = [] private var receivedIceCandidatesLockQueue = DispatchQueue(label: "VoIPCallReceivedIceCandidatesLockQueue") private var receivedIcecandidatesMessages = [VoIPCallIceCandidatesMessage]() private var receivedUnknowCallIcecandidatesMessages = [String: [VoIPCallIceCandidatesMessage]]() private var localRenderer: RTCVideoRenderer? private var remoteRenderer: RTCVideoRenderer? private var reconnectingTimer: Timer? private var iceWasConnected: Bool = false private var isModal : Bool { // Check whether our callViewController is currently in the state presented modally let a = self.callViewController?.presentingViewController?.presentedViewController == self.callViewController // Check whether our callViewController has a navigationController let b1 = self.callViewController?.navigationController != nil // Check whether our callViewController is in the state presented modally as part of a navigation controller let b2 = self.callViewController?.navigationController?.presentingViewController?.presentedViewController == self.callViewController?.navigationController let b = b1 && b2 // Check whether our callViewController has a tabbarcontroller which has a tabbarcontroller. Nesting two // tabBarControllers is only possible in the state presented modally let c = self.callViewController?.tabBarController?.presentingViewController is UITabBarController return a || b || c } required override init() { super.init() NotificationCenter.default.addObserver(forName: AVAudioSession.routeChangeNotification, object: nil, queue: nil) { (n) in if self.state != .idle { var isBluetoothAvailable = false if let inputs = AVAudioSession.sharedInstance().availableInputs { for input in inputs { if input.portType == AVAudioSession.Port.bluetoothA2DP || input.portType == AVAudioSession.Port.bluetoothHFP || input.portType == AVAudioSession.Port.bluetoothLE { isBluetoothAvailable = true } } } guard let info = n.userInfo, let value = info[AVAudioSessionRouteChangeReasonKey] as? UInt, let reason = AVAudioSession.RouteChangeReason(rawValue: value) else { return } switch reason { case .categoryChange: let currentRoute = AVAudioSession.sharedInstance().currentRoute for output in currentRoute.outputs { switch output.portType { case .builtInReceiver: if isBluetoothAvailable { self.speakerActive = false try? AVAudioSession.sharedInstance().overrideOutputAudioPort(.none) } if self.speakerActive { try? AVAudioSession.sharedInstance().overrideOutputAudioPort(.speaker) } break case .builtInSpeaker: if isBluetoothAvailable { self.speakerActive = true try? AVAudioSession.sharedInstance().overrideOutputAudioPort(.speaker) } if !self.speakerActive { try? AVAudioSession.sharedInstance().overrideOutputAudioPort(.none) } break case .headphones: try? AVAudioSession.sharedInstance().overrideOutputAudioPort(self.speakerActive ? .speaker : .none) break case .bluetoothA2DP, .bluetoothHFP, .bluetoothLE: break default:break } } break default: break } } } } } extension VoIPCallService { // MARK: class functions } extension VoIPCallService { // MARK: public functions /** Return the string of the current state for the ValidationLogger - Returns: String of the current state */ func callStateString() -> String { switch state { case .idle: return "idle" case .sendOffer: return "sendOffer" case .receivedOffer: return "receivedOffer" case .outgoingRinging: return "outgoingRinging" case .incomingRinging: return "incomingRinging" case .sendAnswer: return "sendAnswer" case .receivedAnswer: return "receivedAnswer" case .initalizing: return "initalizing" case .calling: return "calling" case .reconnecting: return "reconnecting" case .ended: return "ended" case .remoteEnded: return "remoteEnded" case .rejected: return "rejected" case .rejectedBusy: return "rejectedBusy" case .rejectedTimeout: return "rejectedTimeout" case .rejectedDisabled: return "rejectedDisabled" case .rejectedOffHours: return "rejectedOffHours" case .rejectedUnknown: return "rejectedUnknown" case .microphoneDisabled: return "microphoneDisabled" } } /** Get the localized string for the current state - Returns: Current localized call state string */ func callStateLocalizedString() -> String { switch state { case .idle: return BundleUtil.localizedString(forKey: "call_status_idle") case .sendOffer: return BundleUtil.localizedString(forKey: "call_status_wait_ringing") case .receivedOffer: return BundleUtil.localizedString(forKey: "call_status_wait_ringing") case .outgoingRinging: return BundleUtil.localizedString(forKey: "call_status_ringing") case .incomingRinging: return BundleUtil.localizedString(forKey: "call_status_incom_ringing") case .sendAnswer: return BundleUtil.localizedString(forKey: "call_status_ringing") case .receivedAnswer: return BundleUtil.localizedString(forKey: "call_status_ringing") case .initalizing: return BundleUtil.localizedString(forKey: "call_status_initializing") case .calling: return BundleUtil.localizedString(forKey: "call_status_calling") case .reconnecting: return BundleUtil.localizedString(forKey: "call_status_reconnecting") case .ended: return BundleUtil.localizedString(forKey: "call_end") case .remoteEnded: return BundleUtil.localizedString(forKey: "call_end") case .rejected: return BundleUtil.localizedString(forKey: "call_rejected") case .rejectedBusy: return BundleUtil.localizedString(forKey: "call_rejected_busy") case .rejectedTimeout: return BundleUtil.localizedString(forKey: "call_rejected_timeout") case .rejectedDisabled: return BundleUtil.localizedString(forKey: "call_rejected_disabled") case .rejectedOffHours: return BundleUtil.localizedString(forKey: "call_rejected") case .rejectedUnknown: return BundleUtil.localizedString(forKey: "call_rejected") case .microphoneDisabled: return BundleUtil.localizedString(forKey: "call_microphone_permission_title") } } /** Start process to handle the message - parameter element: Message */ func startProcess(element: Any) { if let action = element as? VoIPCallUserAction { switch action.action { case .call: startCallAsInitiator(action: action, completion: { self.delegate?.callServiceFinishedProcess() action.completion?() }) break case .callWithVideo: startCallAsInitiator(action: action, completion: { self.delegate?.callServiceFinishedProcess() action.completion?() }) break case .accept: self.alreadyAccepted = true self.acceptIncomingCall(action: action) { self.delegate?.callServiceFinishedProcess() action.completion?() } break case .acceptCallKit: self.alreadyAccepted = true self.acceptIncomingCall(action: action) { self.delegate?.callServiceFinishedProcess() action.completion?() } break case .reject: rejectCall(action: action) action.completion?() self.delegate?.callServiceFinishedProcess() break case .rejectDisabled: rejectCall(action: action) action.completion?() self.delegate?.callServiceFinishedProcess() break case .rejectTimeout: rejectCall(action: action) action.completion?() self.delegate?.callServiceFinishedProcess() break case .rejectBusy: rejectCall(action: action) action.completion?() self.delegate?.callServiceFinishedProcess() break case .rejectOffHours: rejectCall(action: action) action.completion?() self.delegate?.callServiceFinishedProcess() break case .rejectUnknown: rejectCall(action: action) action.completion?() self.delegate?.callServiceFinishedProcess() break case .end: DDLogNotice("Threema call: HangupBug -> Send hangup for end action") if state == .sendOffer || state == .outgoingRinging || state == .sendAnswer || state == .receivedAnswer || state == .initalizing || state == .calling || state == .reconnecting { RTCAudioSession.sharedInstance().isAudioEnabled = false let hangupMessage = VoIPCallHangupMessage(contact: action.contact, callId: action.callId!, completion: nil) VoIPCallSender.sendVoIPCallHangup(hangupMessage: hangupMessage, wait: false) state = .ended callKitManager?.endCall() dismissCallView() disconnectPeerConnection() } self.delegate?.callServiceFinishedProcess() action.completion?() break case .speakerOn: speakerActive = true peerConnectionClient?.speakerOn() self.delegate?.callServiceFinishedProcess() action.completion?() break case .speakerOff: speakerActive = false peerConnectionClient?.speakerOff() self.delegate?.callServiceFinishedProcess() action.completion?() break case .muteAudio: peerConnectionClient?.muteAudio(completion: { self.delegate?.callServiceFinishedProcess() action.completion?() }) break case .unmuteAudio: peerConnectionClient?.unmuteAudio(completion: { self.delegate?.callServiceFinishedProcess() action.completion?() }) break case .showCallScreen: if contact != nil { presentCallView(contact: contact!, alreadyAccepted:alreadyAccepted , isCallInitiator: callInitiator, isThreemaVideoCallAvailable: threemaVideoCallAvailable, videoActive: videoActive, receivingVideo: isReceivingVideo, viewWasHidden: true) } self.delegate?.callServiceFinishedProcess() action.completion?() break case .hideCallScreen: dismissCallView() self.delegate?.callServiceFinishedProcess() action.completion?() break } } else if let offer = element as? VoIPCallOfferMessage { handleOfferMessage(offer: offer, completion: { offer.completion?() self.delegate?.callServiceFinishedProcess() }) } else if let answer = element as? VoIPCallAnswerMessage { handleAnswerMessage(answer: answer, completion: { answer.completion?() self.delegate?.callServiceFinishedProcess() }) } else if let ringing = element as? VoIPCallRingingMessage { handleRingingMessage(ringing: ringing, completion: { ringing.completion?() self.delegate?.callServiceFinishedProcess() }) } else if let hangup = element as? VoIPCallHangupMessage { handleHangupMessage(hangup: hangup, completion: { hangup.completion?() self.delegate?.callServiceFinishedProcess() }) } else if let ice = element as? VoIPCallIceCandidatesMessage { handleIceCandidatesMessage(ice: ice) { ice.completion?() self.delegate?.callServiceFinishedProcess() } } else { self.delegate?.callServiceFinishedProcess() } } /** Get the current call state - Returns: CallState */ func currentState() -> CallState { return state } /** Get the current call contact - Returns: Contact or nil */ func currentContact() -> Contact? { return contact } /** Get the current callId - Returns: VoIPCallId or nil */ func currentCallId() -> VoIPCallId? { return callId } /** Is initiator of the current call - Returns: true or false */ func isCallInitiator() -> Bool { return callInitiator } /** Is the current call muted - Returns: true or false */ func isCallMuted() -> Bool { return audioMuted } /** Is the speaker for the current call active - Returns: true or false */ func isSpeakerActive() -> Bool { return speakerActive } /** Is the current call already accepted - Returns: true or false */ func isCallAlreadyAccepted() -> Bool { return alreadyAccepted } /** Present the CallViewController */ func presentCallViewController() { if contact != nil { presentCallView(contact: contact!, alreadyAccepted: alreadyAccepted, isCallInitiator: callInitiator, isThreemaVideoCallAvailable: threemaVideoCallAvailable, videoActive: videoActive, receivingVideo: isReceivingVideo, viewWasHidden: alreadyAccepted) } } /** Dismiss the CallViewController */ func dismissCallViewController() { dismissCallView() } /** Set the RTC audio session from CallKit - parameter callKitAudioSession: AVAudioSession from callkit */ func setRTCAudioSession(_ callKitAudioSession: AVAudioSession) { handleTones(state: .calling, oldState: .calling) RTCAudioSession.sharedInstance().audioSessionDidActivate(callKitAudioSession) } /** Configure the audio session and set RTC audio active */ func activateRTCAudio() { peerConnectionClient?.activateRTCAudio(speakerActive: speakerActive) } /** Start capture local video */ func startCaptureLocalVideo(renderer: RTCVideoRenderer, useBackCamera: Bool, switchCamera: Bool = false) { localRenderer = renderer videoActive = true peerConnectionClient?.startCaptureLocalVideo(renderer: renderer, useBackCamera: useBackCamera, switchCamera: switchCamera) } /** End capture local video */ func endCaptureLocalVideo(switchCamera: Bool = false) { if !switchCamera { videoActive = false } if let renderer = localRenderer { peerConnectionClient?.endCaptureLocalVideo(renderer: renderer, switchCamera: switchCamera) localRenderer = nil } } /** Get local video renderer */ func localVideoRenderer() -> RTCVideoRenderer? { return localRenderer } /** Start render remote video */ func renderRemoteVideo(to renderer: RTCVideoRenderer) { remoteRenderer = renderer peerConnectionClient?.renderRemoteVideo(to: renderer) } /** End remote video */ func endRemoteVideo() { if let renderer = remoteRenderer { peerConnectionClient?.endRemoteVideo(renderer: renderer) remoteRenderer = nil } } /** Get remote video renderer */ func remoteVideoRenderer() -> RTCVideoRenderer? { return remoteRenderer } /** Get peer video quality profile */ func remoteVideoQualityProfile() -> CallsignalingProtocol.ThreemaVideoCallQualityProfile? { return peerConnectionClient?.remoteVideoQualityProfile } /** Get peer is using turn server */ func networkIsRelayed() -> Bool { return peerConnectionClient?.networkIsRelayed ?? false } } extension VoIPCallService { // MARK: private functions /** When the current call state is idle and the permission is granted to the microphone, it will create the peer client and add the offer. If the state is wrong, it will reject the call with the reason unknown. If the permission to the microphone is not granted, it will reject the call with the reason unknown. If Threema Calls are disabled, it will reject the call with the reason disabled. - parameter offer: VoIPCallOfferMessage - parameter completion: Completion block */ private func handleOfferMessage(offer: VoIPCallOfferMessage, completion: @escaping (() -> Void)) { DDLogNotice("Threema call: handle incomming offer from \(offer.contact?.identity ?? "?") with callId \(offer.callId.callId)") if UserSettings.shared().enableThreemaCall == true && is64Bit == 1 { var appRunsInBackground = false DispatchQueue.main.sync { appRunsInBackground = AppDelegate.shared().isAppInBackground() } if state == .idle { if PendingMessagesManager.canMasterDndSendPush() == false { DDLogNotice("Threema call: handleOfferMessage -> Master DND active -> reject call from \(String(describing: offer.contact?.identity))"); self.contact = offer.contact let action = VoIPCallUserAction.init(action: .rejectOffHours, contact: offer.contact!, callId: offer.callId, completion: offer.completion) self.rejectCall(action: action, closeCallView: true) completion() return } AVAudioSession.sharedInstance().requestRecordPermission { (granted) in if granted == true { DDLogNotice("Threema call: handleOfferMessage -> Add offer -> set contact to service \(String(describing: offer.contact?.identity))"); self.contact = offer.contact self.alreadyAccepted = false self.state = .receivedOffer self.incomingOffer = offer self.callId = offer.callId self.videoActive = false self.isReceivingVideo = false self.localRenderer = nil self.remoteRenderer = nil self.threemaVideoCallAvailable = offer.isVideoAvailable self.startIncomingCallTimeoutTimer() if UserSettings.shared()?.enableCallKit == true && Locale.current.regionCode != "CN" { if self.callKitManager == nil { self.callKitManager = VoIPCallKitManager.init() } } else { self.callKitManager = nil } // send ringing message let ringingMessage = VoIPCallRingingMessage(contact: offer.contact!, callId: offer.callId, completion: nil) VoIPCallSender.sendVoIPCallRinging(ringingMessage: ringingMessage) self.callKitManager?.reportIncomingCall(uuid: UUID.init(), contact: offer.contact!) if self.callKitManager == nil { self.presentCallView(contact: offer.contact!, alreadyAccepted: false, isCallInitiator: false, isThreemaVideoCallAvailable: self.threemaVideoCallAvailable, videoActive: false, receivingVideo: false, viewWasHidden: false) } self.state = .incomingRinging // Prefetch ICE/TURN servers so they're likely to be already available when the user accepts the call VoIPIceServerSource.prefetchIceServers() completion() } else { DDLogNotice("Threema call: handleOfferMessage -> Audio is not granted -> reject call from \(String(describing: offer.contact?.identity))"); self.contact = offer.contact self.state = .microphoneDisabled // reject call because there is no permission for the microphone self.state = .rejectedDisabled let action = VoIPCallUserAction.init(action: .rejectUnknown, contact: offer.contact!, callId: offer.callId, completion: offer.completion) self.rejectCall(action: action, closeCallView: false) if appRunsInBackground == true { // show notification that incoming call can't process because mic is not granted self.disconnectPeerConnection() completion() } else { self.presentCallView(contact: offer.contact!, alreadyAccepted: false, isCallInitiator: false, isThreemaVideoCallAvailable: self.threemaVideoCallAvailable, videoActive: false, receivingVideo: false, viewWasHidden: false, completion: { // no access to microphone, stopp call let alertTitle = BundleUtil.localizedString(forKey: "call_microphone_permission_title") let alertMessage = BundleUtil.localizedString(forKey: "call_microphone_permission_text") let alert = UIAlertController.init(title:alertTitle , message: alertMessage, preferredStyle: .alert) alert.addAction(UIAlertAction.init(title: BundleUtil.localizedString(forKey: "settings"), style: .default, handler: { (action) in self.dismissCallView() self.disconnectPeerConnection() UIApplication.shared.open(NSURL.init(string: UIApplication.openSettingsURLString)! as URL, options: [:], completionHandler: nil) })) alert.addAction(UIAlertAction.init(title: BundleUtil.localizedString(forKey: "ok"), style: .default, handler: { (action) in self.dismissCallView() self.disconnectPeerConnection() })) let rootVC = self.callViewController != nil ? self.callViewController! : UIApplication.shared.keyWindow?.rootViewController! DispatchQueue.main.async { rootVC?.present(alert, animated: true, completion: nil) } completion() }) } } } } else { DDLogNotice("Threema call: handleOfferMessage -> State is not idle"); if contact == offer.contact && state == .incomingRinging { DDLogNotice("Threema call: handleOfferMessage -> same contact as the current call"); if PendingMessagesManager.canMasterDndSendPush() == false && appRunsInBackground == true{ DDLogNotice("Threema call: handleOfferMessage -> Master DND active -> reject call from \(String(describing: offer.contact?.identity))"); let action = VoIPCallUserAction.init(action: .rejectOffHours, contact: offer.contact!, callId: offer.callId, completion: offer.completion) self.rejectCall(action: action, closeCallView: true) completion() } else { DDLogNotice("Threema call: handleOfferMessage -> Master DND inactive -> set offer \(String(describing: offer.contact?.identity))"); disconnectPeerConnection() handleOfferMessage(offer: offer, completion: completion) } } else { DDLogNotice("Threema call: handleOfferMessage -> reject call, it's the wrong state"); // reject call because it's the wrong state let reason: VoIPCallUserAction.Action = contact == offer.contact ? .rejectUnknown : .rejectBusy let action = VoIPCallUserAction.init(action: reason, contact: offer.contact!, callId: offer.callId, completion: offer.completion) rejectCall(action: action) completion() } } } else { DDLogNotice("Threema call: handleOfferMessage -> reject all, threema call is disabled"); // reject call because Threema Calls are disabled or unavailable let action = VoIPCallUserAction.init(action: .rejectDisabled, contact: offer.contact!, callId: offer.callId, completion: offer.completion) rejectCall(action: action) completion() } } private func startIncomingCallTimeoutTimer() { DispatchQueue.main.async { if let offer = self.incomingOffer { self.invalidateIncomingCallTimeout() self.incomingCallTimeoutTimer = Timer.scheduledTimer(withTimeInterval: self.kIncomingCallTimeout, repeats: false, block: { (timeout) in BackgroundTaskManager.shared.newBackgroundTask(key: kAppVoIPBackgroundTask, timeout: Int(kAppVoIPBackgroundTaskTime)) { ServerConnector.shared()?.connectWait() let action = VoIPCallUserAction.init(action: .rejectTimeout, contact: offer.contact!, callId: offer.callId, completion: offer.completion) self.state = .rejectedTimeout self.callKitManager?.timeoutCall() self.rejectCall(action: action) self.invalidateIncomingCallTimeout() } }) } } } /** Handle the answer message if the contact in the answer message is the same as in the call service and call state is ringing. Call will cancel if it's rejected and CallViewController will close. - parameter answer: VoIPCallAnswerMessage - parameter completion: Completion block */ private func handleAnswerMessage(answer: VoIPCallAnswerMessage, completion: @escaping (() -> Void)) { DDLogNotice("Threema call: handle incomming answer from \(answer.contact?.identity ?? "?") with callId \(answer.callId.callId)") if contact != nil { if callInitiator == true { if let callId = callId, (state == .sendOffer || state == .outgoingRinging) && contact!.identity == answer.contact?.identity && callId.isSame(answer.callId) { state = .receivedAnswer if answer.action == VoIPCallAnswerMessage.MessageAction.reject { // call is rejected switch answer.rejectReason { case .busy?: state = .rejectedBusy break case .timeout?: state = .rejectedTimeout break case .reject?: state = .rejected break case .disabled?: state = .rejectedDisabled break case .offHours?: state = .rejectedOffHours break case .none: state = .rejected case .some(.unknown): state = .rejectedUnknown } callKitManager?.rejectCall() self.dismissCallView(rejected: true, completion: { self.disconnectPeerConnection() completion() }) } else { // handle answer state = .receivedAnswer if answer.isVideoAvailable && UserSettings.shared().enableVideoCall { self.threemaVideoCallAvailable = true callViewController?.enableThreemaVideoCall() } else { self.threemaVideoCallAvailable = false callViewController?.disableThreemaVideoCall() } if let remoteSdp = answer.answer { peerConnectionClient?.set(remoteSdp: remoteSdp, completion: { (error) in if error == nil { switch self.state { case .idle, .sendOffer, .receivedOffer, .outgoingRinging, .incomingRinging, .sendAnswer, .receivedAnswer: self.state = .initalizing default: break } } else { // can't set remote sdp --> end call DDLogNotice("Threema call: HangupBug -> Can't set remote sdp -> hangup") let hangupMessage = VoIPCallHangupMessage(contact: self.contact!, callId: self.callId!, completion: nil) VoIPCallSender.sendVoIPCallHangup(hangupMessage: hangupMessage, wait: false) self.state = .rejectedUnknown self.dismissCallView() self.disconnectPeerConnection() } completion() }) } else { // remote sdp is empty --> end call DDLogNotice("Threema call: HangupBug -> Remote sdp is empty -> hangup") let hangupMessage = VoIPCallHangupMessage(contact: self.contact!, callId: self.callId!, completion: nil) VoIPCallSender.sendVoIPCallHangup(hangupMessage: hangupMessage, wait: false) self.state = .rejectedUnknown self.dismissCallView() self.disconnectPeerConnection() completion() } } } else { if contact!.identity == answer.contact?.identity { ValidationLogger.shared().logString("Threema call with \(String(describing: contact!.identity)): Can't handle answer message, because \(callStateString()) is the wrong state or answer callId \(answer.callId.callId) is different to \(callId?.callId ?? 0)") } else { ValidationLogger.shared().logString("Threema call with \(String(describing: contact!.identity)): Contact in manager is different to answer message \(String(describing: answer.contact?.identity))") } completion() } } else { // We are not the initiator so we can ignore this message ValidationLogger.shared().logString("Threema call: Not initiator, ignore this message -> answer message from contact \(String(describing: answer.contact?.identity))") completion() } } else { ValidationLogger.shared().logString("Threema call: No contact set in manager -> answer message from contact \(String(describing: answer.contact?.identity))") completion() } } /** Handle the ringing message if the contact in the answer message is the same as in the call service and call state is sendOffer. CallViewController will play the ringing tone - parameter ringing: VoIPCallRingingMessage - parameter completion: Completion block */ private func handleRingingMessage(ringing: VoIPCallRingingMessage, completion: @escaping (() -> Void)) { DDLogNotice("Threema call: handle incoming ringing from \(ringing.contact.identity ?? "?") with callId \(ringing.callId.callId)") if contact != nil { if let callId = callId, contact!.identity == ringing.contact.identity && callId.isSame(ringing.callId) { switch state { case .sendOffer: state = .outgoingRinging break default: ValidationLogger.shared().logString("Threema call with \(String(describing: contact!.identity)): Can't handle ringing message, because \(callStateString()) is the wrong state") } } else { ValidationLogger.shared().logString("Threema call with \(String(describing: contact!.identity)) (\(callId?.callId ?? 0): Contact in manager is different to ringing message \(String(describing: ringing.contact.identity)) (\(ringing.callId.callId)") } } else { ValidationLogger.shared().logString("Threema call: No contact set in manager -> ringing message from contact \(String(describing: ringing.contact.identity))") } completion() } /** Handle add or remove received remote ice candidates (IpV6 candidates will be removed) - parameter ice: VoIPCallIceCandidatesMessage - parameter completion: Completion block */ private func handleIceCandidatesMessage(ice: VoIPCallIceCandidatesMessage, completion: @escaping (() -> Void)) { DDLogNotice("Threema call: handle incoming ice candidates from \(ice.contact?.identity ?? "?") with callId \(ice.callId.callId)") if contact != nil { if let callId = callId, contact!.identity == ice.contact?.identity && callId.isSame(ice.callId) { switch state { case .sendOffer, .outgoingRinging, .sendAnswer, .receivedAnswer, .initalizing, .calling, .reconnecting: if ice.removed == false { for candidate in ice.candidates { if shouldAddLocalCandidate(candidate) == true { peerConnectionClient?.set(addRemoteCandidate: candidate) } } completion() } else { // ICE candidate messages are currently allowed to have a "removed" flag. However, this is non-standard. // When receiving an VoIP ICE Candidate (0x62) message with removed set to true, discard the message completion() } break case .receivedOffer, .incomingRinging: // add to local array receivedIceCandidatesLockQueue.sync { receivedIcecandidatesMessages.append(ice) completion() } break default: ValidationLogger.shared().logString("Threema call with \(String(describing: contact!.identity)): Can't handle ice candidates message, because \(callStateString()) is the wrong state") completion() } } else { addUnknownCallIcecandidatesMessages(message: ice) ValidationLogger.shared().logString("Threema call with \(String(describing: contact!.identity)): Contact in manager is different to ice candidates message \(String(describing: ice.contact?.identity))") completion() } } else { addUnknownCallIcecandidatesMessages(message: ice) ValidationLogger.shared().logString("Threema call: No contact set in manager -> ice candidates message from contact \(String(describing: ice.contact?.identity))") completion() } } /** Handle the hangup message if the contact in the answer message is the same as in the call service and call state is receivedOffer, ringing, sendAnswer, initializing, calling or reconnecting. It will dismiss the CallViewController after the call was ended. - parameter hangup: VoIPCallHangupMessage - parameter completion: Completion block */ private func handleHangupMessage(hangup: VoIPCallHangupMessage, completion: @escaping (() -> Void)) { DDLogNotice("Threema call: handle incoming hangup from \(hangup.contact.identity ?? "?") with callId \(hangup.callId.callId)") if contact != nil { if let callId = callId, contact!.identity == hangup.contact.identity && callId.isSame(hangup.callId) { switch state { case .receivedOffer, .outgoingRinging, .incomingRinging, .sendAnswer, .initalizing, .calling, .reconnecting: RTCAudioSession.sharedInstance().isAudioEnabled = false state = .remoteEnded callKitManager?.endCall() dismissCallView() disconnectPeerConnection() break default: ValidationLogger.shared().logString("Threema call with \(String(describing: contact!.identity)): Can't handle hangup message, because \(callStateString()) is the wrong state") } } else { ValidationLogger.shared().logString("Threema call with \(String(describing: contact!.identity)) (\(callId?.callId ?? 0): Contact in manager is different to hangup message \(String(describing: hangup.contact.identity)) (\(hangup.callId.callId)") } } else { ValidationLogger.shared().logString("Threema call: No contact set in manager -> hangup message contact \(String(describing: hangup.contact.identity))") } completion() } /** Handle a new outgoing call if Threema calls are enabled and permission for microphone is granted. It will present the CallViewController. - parameter action: VoIPCallUserAction - parameter completion: Completion block */ private func startCallAsInitiator(action: VoIPCallUserAction, completion: @escaping (() -> Void)) { if UserSettings.shared().enableThreemaCall == true && is64Bit == 1 { RTCAudioSession.sharedInstance().useManualAudio = true if state == .idle { AVAudioSession.sharedInstance().requestRecordPermission { (granted) in if granted == true { self.callInitiator = true self.contact = action.contact self.createPeerConnectionForInitiator(action: action, completion: completion) } else { // no access to microphone, stop call let alertTitle = BundleUtil.localizedString(forKey: "call_microphone_permission_title") let alertMessage = BundleUtil.localizedString(forKey: "call_microphone_permission_text") let alert = UIAlertController.init(title:alertTitle , message: alertMessage, preferredStyle: .alert) alert.addAction(UIAlertAction.init(title: BundleUtil.localizedString(forKey: "settings"), style: .default, handler: { (action) in UIApplication.shared.open(NSURL.init(string: UIApplication.openSettingsURLString)! as URL, options: [:], completionHandler: nil) })) alert.addAction(UIAlertAction(title: BundleUtil.localizedString(forKey: "ok"), style: .default, handler: nil)) DispatchQueue.main.async { let rootVC = UIApplication.shared.keyWindow?.rootViewController! rootVC?.present(alert, animated: true, completion: nil) } completion() } } } else { // do nothing because it's the wrong state ValidationLogger.shared().logString("Threema call with \(String(describing: contact!.identity)): Can't handle call, because \(callStateString()) is the wrong state") completion() } } else { // do nothing because Threema calls are disabled or unavailable completion() } } /** Accept a incoming call if state is ringing. Will send a answer message to initiator and update CallViewController. It will present the CallViewController. - parameter action: VoIPCallUserAction - parameter completion: Completion block */ private func acceptIncomingCall(action: VoIPCallUserAction, completion: @escaping (() -> Void)) { createPeerConnectionForIncomingCall { RTCAudioSession.sharedInstance().useManualAudio = true if self.state == .incomingRinging { self.state = .sendAnswer if #available(iOS 14.0, *) { self.presentCallViewController() } self.peerConnectionClient?.answer(completion: { (sdp) in if self.threemaVideoCallAvailable && UserSettings.shared().enableVideoCall { self.threemaVideoCallAvailable = true self.callViewController?.enableThreemaVideoCall() } else { self.threemaVideoCallAvailable = false self.callViewController?.disableThreemaVideoCall() } let answerMessage = VoIPCallAnswerMessage.init(action: .call, contact: action.contact, answer: sdp, rejectReason: nil, features: nil, isVideoAvailable: self.threemaVideoCallAvailable, callId: self.callId!, completion: nil) VoIPCallSender.sendVoIPCall(answer: answerMessage) if action.action != .acceptCallKit { self.callKitManager?.callAccepted() } self.receivedIceCandidatesLockQueue.sync { if let receivedCandidatesBeforeCall = self.receivedUnknowCallIcecandidatesMessages[action.contact.identity] { for ice in receivedCandidatesBeforeCall { if ice.callId.callId == self.callId?.callId { self.receivedIcecandidatesMessages.append(ice) } } self.receivedUnknowCallIcecandidatesMessages.removeAll() } for message in self.receivedIcecandidatesMessages { if message.removed == false { for candidate in message.candidates { if self.shouldAddLocalCandidate(candidate) == true { self.peerConnectionClient?.set(addRemoteCandidate: candidate) } } } } self.receivedIcecandidatesMessages.removeAll() } completion() return }) } else { // dismiss call view because it's the wrong state let identity = action.contact.identity ?? "?" ValidationLogger.shared().logString("Threema call with \(identity): Can't handle accept call, because \(self.callStateString()) is the wrong state") self.callKitManager?.answerFailed() self.dismissCallView() self.disconnectPeerConnection() completion() return } } } /** Creates the peer connection for the initiator and set the offer. After this, it will present the CallViewController. - parameter action: VoIPCallUserAction - parameter completion: Completion block */ private func createPeerConnectionForInitiator(action: VoIPCallUserAction, completion: @escaping (() -> Void)) { FeatureMask.check(Int(FEATURE_MASK_VOIP_VIDEO), forContacts: [contact!]) { (unsupportedContacts) in self.threemaVideoCallAvailable = false if unsupportedContacts!.count == 0 && UserSettings.shared().enableVideoCall { self.threemaVideoCallAvailable = true } self.peerConnectionClient?.peerConnection.close() self.peerConnectionClient = nil let forceTurn: Bool = Int(truncating: self.contact!.verificationLevel) == kVerificationLevelUnverified || UserSettings.shared()?.alwaysRelayCalls == true let peerConnectionParameters = VoIPCallPeerConnectionClient.PeerConnectionParameters(isVideoCallAvailable: self.threemaVideoCallAvailable, videoCodecHwAcceleration: self.threemaVideoCallAvailable, forceTurn: forceTurn, gatherContinually: true, allowIpv6: UserSettings.shared().enableIPv6, isDataChannelAvailable: false) VoIPCallPeerConnectionClient.instantiate(contact: self.contact!, peerConnectionParameters: peerConnectionParameters) { (result) in do { self.peerConnectionClient = try result.get() } catch let error { self.callCantCreateOffer(error: error) return } self.peerConnectionClient?.delegate = self if UserSettings.shared()?.enableCallKit == true && Locale.current.regionCode != "CN" { if self.callKitManager == nil { self.callKitManager = VoIPCallKitManager.init() } } else { self.callKitManager = nil } self.peerConnectionClient?.offer(completion: { (sdp, sdpError) in if let error = sdpError { self.callCantCreateOffer(error: error) return } guard let sdp = sdp else { self.callCantCreateOffer(error: nil) return } self.callId = VoIPCallId.generate() let offerMessage = VoIPCallOfferMessage.init(offer: sdp, contact: self.contact!, features: nil, isVideoAvailable: self.threemaVideoCallAvailable, callId: self.callId!, completion: nil) VoIPCallSender.sendVoIPCall(offer: offerMessage) self.state = .sendOffer DispatchQueue.main.async { self.initCallTimeoutTimer = Timer.scheduledTimer(withTimeInterval: self.kIncomingCallTimeout, repeats: false, block: { (timeout) in BackgroundTaskManager.shared.newBackgroundTask(key: kAppVoIPBackgroundTask, timeout: Int(kAppPushBackgroundTaskTime)) { ServerConnector.shared()?.connectWait() RTCAudioSession.sharedInstance().isAudioEnabled = false DDLogNotice("Threema call: HangupBug -> call ringing timeout -> hangup") let hangupMessage = VoIPCallHangupMessage(contact: self.contact!, callId: self.callId!, completion: nil) VoIPCallSender.sendVoIPCallHangup(hangupMessage: hangupMessage, wait: false) self.state = .ended self.disconnectPeerConnection() DispatchQueue.main.asyncAfter(deadline: .now() + .seconds(1), execute: { self.dismissCallView(rejected: false, completion: { self.callKitManager?.endCall() self.invalidateInitCallTimeout() let rootVC = UIApplication.shared.keyWindow?.rootViewController! UIAlertTemplate.showAlert(owner: rootVC!, title: BundleUtil.localizedString(forKey: "call_voip_not_supported_title"), message: BundleUtil.localizedString(forKey: "call_contact_not_reachable")) }) }) } }) } self.alreadyAccepted = true self.presentCallView(contact: self.contact!, alreadyAccepted: true, isCallInitiator: true, isThreemaVideoCallAvailable: self.threemaVideoCallAvailable, videoActive: action.action == .callWithVideo, receivingVideo: false, viewWasHidden: false) self.callKitManager?.startCall(contact: self.contact!) completion() }) } } } /** Creates the peer connection for the incoming call and set the offer if contact is set in the offer. After this, it will present the CallViewController. - parameter action: VoIPCallUserAction - parameter completion: Completion block */ private func createPeerConnectionForIncomingCall(completion: @escaping (() -> Void)) { peerConnectionClient?.peerConnection.close() peerConnectionClient = nil guard let offer = self.incomingOffer, let contact = offer.contact else { self.state = .idle completion() return } FeatureMask.check(Int(FEATURE_MASK_VOIP_VIDEO), forContacts: [contact]) { (unsupportedContacts) in if self.incomingOffer?.isVideoAvailable ?? false && UserSettings.shared().enableVideoCall { self.threemaVideoCallAvailable = true self.callViewController?.enableThreemaVideoCall() } else { self.threemaVideoCallAvailable = false self.callViewController?.disableThreemaVideoCall() } let forceTurn = Int(truncating: contact.verificationLevel) == kVerificationLevelUnverified || UserSettings.shared().alwaysRelayCalls let peerConnectionParameters = VoIPCallPeerConnectionClient.PeerConnectionParameters(isVideoCallAvailable: self.threemaVideoCallAvailable, videoCodecHwAcceleration: self.threemaVideoCallAvailable, forceTurn: forceTurn, gatherContinually: true, allowIpv6: UserSettings.shared().enableIPv6, isDataChannelAvailable: false) VoIPCallPeerConnectionClient.instantiate(contact: contact, peerConnectionParameters: peerConnectionParameters) { (result) in do { self.peerConnectionClient = try result.get() } catch let error { print("Can't instantiate client: \(error)") } self.peerConnectionClient?.delegate = self self.peerConnectionClient?.set(remoteSdp: offer.offer!, completion: { (error) in if error == nil { completion() } else { // reject because we can't add offer print("We can't add the offer \(String(describing: error))") let action = VoIPCallUserAction.init(action: .reject, contact: contact, callId: offer.callId, completion: offer.completion) self.rejectCall(action: action) } }) } } } /** Removes the peer connection, reset the call state and reset all other values */ private func disconnectPeerConnection() { // remove peerConnection func reset() { peerConnectionClient?.peerConnection.close() peerConnectionClient = nil contact = nil callId = nil threemaVideoCallAvailable = false alreadyAccepted = false callInitiator = false audioMuted = false speakerActive = false videoActive = false isReceivingVideo = false state = .idle incomingOffer = nil localRenderer = nil remoteRenderer = nil audioPlayer?.pause() do { RTCAudioSession.sharedInstance().lockForConfiguration() try RTCAudioSession.sharedInstance().setActive(false) RTCAudioSession.sharedInstance().unlockForConfiguration() } catch { DDLogError("Could not set shared session to not active. Error: \(error)") } DispatchQueue.main.async { VoIPHelper.shared()?.isCallActiveInBackground = false VoIPHelper.shared()?.contactName = nil NotificationCenter.default.post(name: NSNotification.Name(kNotificationCallInBackgroundTimeChanged), object: nil) } } if peerConnectionClient != nil { peerConnectionClient!.stopVideoCall() peerConnectionClient?.logDebugEndStats { reset() } } else { reset() } } /** Present the CallViewController in the main thread. - parameter contact: Contact of the call - parameter alreadyAccepted: Set to true if the call was alreay accepted - parameter isCallInitiator: If user is the call initiator */ private func presentCallView(contact: Contact, alreadyAccepted: Bool, isCallInitiator: Bool, isThreemaVideoCallAvailable: Bool, videoActive: Bool, receivingVideo: Bool, viewWasHidden: Bool, completion: (() -> Void)? = nil) { DispatchQueue.main.async { var viewWasHidden = viewWasHidden if self.callViewController == nil { let callStoryboard = UIStoryboard.init(name: "CallStoryboard", bundle: nil) let callVC = callStoryboard.instantiateInitialViewController() as! CallViewController self.callViewController = callVC viewWasHidden = false } let rootVC = UIApplication.shared.keyWindow?.rootViewController var presentingVC = (rootVC?.presentedViewController ?? rootVC) if let navController = presentingVC as? UINavigationController { presentingVC = navController.viewControllers.last } if !(presentingVC?.isKind(of: CallViewController.self))! { if let presentedVC = presentingVC?.presentedViewController { if presentedVC.isKind(of: CallViewController.self) { return } } if UIApplication.shared.applicationState == .active && !self.callViewController!.isBeingPresented && !self.isModal { self.callViewController!.viewWasHidden = viewWasHidden self.callViewController!.voIPCallStatusChanged(state: self.state, oldState: self.state) self.callViewController!.contact = contact self.callViewController!.alreadyAccepted = alreadyAccepted self.callViewController!.isCallInitiator = isCallInitiator self.callViewController!.threemaVideoCallAvailable = isThreemaVideoCallAvailable self.callViewController!.isLocalVideoActive = videoActive self.callViewController!.isReceivingRemoteVideo = receivingVideo if UserDefaults.standard.bool(forKey: "FASTLANE_SNAPSHOT") { self.callViewController!.isTesting = true } self.callViewController!.modalPresentationStyle = .overFullScreen presentingVC?.present(self.callViewController!, animated: false, completion: { if completion != nil { completion!() } }) } } } } /** Dismiss the CallViewController in the main thread. */ private func dismissCallView(rejected: Bool? = false, completion: (() -> Void)? = nil) { DispatchQueue.main.async { if let callVC = self.callViewController { self.callViewController?.resetStatsTimer() if rejected == true { if let callViewController = self.callViewController { callViewController.endButton.isEnabled = false callViewController.speakerButton.isEnabled = false callViewController.muteButton.isEnabled = false } Timer.scheduledTimer(withTimeInterval: 4, repeats: false, block: { (timer) in callVC.dismiss(animated: true, completion: { switch self.state { case .sendOffer, .receivedOffer, .outgoingRinging, .incomingRinging, .sendAnswer, .receivedAnswer, .initalizing, .calling, .reconnecting: break case .idle, .ended, .remoteEnded, .rejected, .rejectedBusy, .rejectedTimeout, .rejectedDisabled, .rejectedOffHours, .rejectedUnknown, .microphoneDisabled: self.callViewController = nil } if AppDelegate.shared()?.isAppLocked == true { AppDelegate .shared()?.presentPasscodeView() } completion?() }) }) } else { callVC.dismiss(animated: true, completion: { switch self.state { case .sendOffer, .receivedOffer, .outgoingRinging, .incomingRinging, .sendAnswer, .receivedAnswer, .initalizing, .calling, .reconnecting: break case .idle, .ended, .remoteEnded, .rejected, .rejectedBusy, .rejectedTimeout, .rejectedDisabled, .rejectedOffHours, .rejectedUnknown, .microphoneDisabled: self.callViewController = nil } if AppDelegate.shared()?.isAppLocked == true { AppDelegate .shared()?.presentPasscodeView() } completion?() }) } } } } /** Reject the call with the reason given in the action. Will end call and dismiss the CallViewController. - parameter action: VoIPCallUserAction with the given reject reason - parameter closeCallView: Default is true. If set false, it will not disconnect the peer connection and will not close the call view */ private func rejectCall(action: VoIPCallUserAction, closeCallView: Bool? = true) { var reason: VoIPCallAnswerMessage.MessageRejectReason = .reject switch action.action { case .rejectDisabled: reason = .disabled if action.contact == contact { state = .rejectedDisabled } break case .rejectTimeout: reason = .timeout if action.contact == contact { state = .rejectedTimeout } break case .rejectBusy: reason = .busy if action.contact == contact { state = .rejectedBusy } break case .rejectOffHours: reason = .offHours if action.contact == contact { state = .rejectedOffHours } break case .rejectUnknown: reason = .unknown if action.contact == contact { state = .rejectedUnknown } break default: if action.contact == contact { state = .rejected } break } let answer = VoIPCallAnswerMessage.init(action: .reject, contact: action.contact, answer: nil, rejectReason: reason, features: nil, isVideoAvailable: UserSettings.shared().enableVideoCall, callId: action.callId!, completion: nil) VoIPCallSender.sendVoIPCall(answer: answer) if contact == action.contact { callKitManager?.rejectCall() if closeCallView == true { // remove peerConnection self.dismissCallView() self.disconnectPeerConnection() } } else { addRejectedMessageToConversation(contact: action.contact, reason: kSystemMessageCallMissed) } } /** It will check the current call state and play the correct tone if it's needed */ private func handleTones(state: VoIPCallService.CallState, oldState: VoIPCallService.CallState) { switch state { case .outgoingRinging, .incomingRinging: if callInitiator == true { let soundFilePath = BundleUtil.path(forResource: "ringing-tone-ch-fade", ofType: "mp3") let soundUrl = URL.init(fileURLWithPath: soundFilePath!) setupAudioSession() playSound(soundUrl: soundUrl, loops: -1) } else { if UserSettings.shared().enableCallKit == false { var voIPSound = UserSettings.shared().voIPSound if voIPSound == "default" { voIPSound = "threema_best" } let soundFilePath = BundleUtil.path(forResource: voIPSound, ofType: "caf") let soundUrl = URL.init(fileURLWithPath: soundFilePath!) setupAudioSession(true) playSound(soundUrl: soundUrl, loops: -1) } else { audioPlayer?.stop() } } break case .rejected, .rejectedBusy, .rejectedTimeout, .rejectedOffHours, .rejectedUnknown, .rejectedDisabled: if PendingMessagesManager.canMasterDndSendPush() == false || self.isCallInitiator() == false { // do not play sound if dnd mode is active and user is not the call initiator audioPlayer?.stop() } else { let soundFilePath = BundleUtil.path(forResource: "busy-4x", ofType: "mp3") let soundUrl = URL.init(fileURLWithPath: soundFilePath!) setupAudioSession() playSound(soundUrl: soundUrl, loops: 0) } break case .ended, .remoteEnded: if oldState != .incomingRinging { let soundFilePath = BundleUtil.path(forResource: "threema_hangup", ofType: "mp3") let soundUrl = URL.init(fileURLWithPath: soundFilePath!) setupAudioSession() playSound(soundUrl: soundUrl, loops: 0) } else { audioPlayer?.stop() } break case .calling: if oldState != .reconnecting { let soundFilePath = BundleUtil.path(forResource: "threema_pickup", ofType: "mp3") let soundUrl = URL.init(fileURLWithPath: soundFilePath!) setupAudioSession() playSound(soundUrl: soundUrl, loops: 0) } else { audioPlayer?.stop() } break case .reconnecting: let soundFilePath = BundleUtil.path(forResource: "threema_problem", ofType: "mp3") let soundUrl = URL.init(fileURLWithPath: soundFilePath!) setupAudioSession() playSound(soundUrl: soundUrl, loops: -1) break case .idle: break case .sendOffer, .receivedOffer, .sendAnswer, .receivedAnswer, .initalizing: // do nothing break case .microphoneDisabled: // do nothing break } } private func setupAudioSession(_ soloAmbient: Bool = false) { let audioSession = AVAudioSession.sharedInstance() if soloAmbient == true { do { try audioSession.setCategory(.soloAmbient, mode: .default, options: [.allowBluetooth, .allowBluetoothA2DP]) try audioSession.overrideOutputAudioPort(speakerActive ? .speaker : .none) try audioSession.setActive(true, options: .notifyOthersOnDeactivation) } catch let error { print(error.localizedDescription) } } else { do { try audioSession.setCategory(.playAndRecord, mode: .voiceChat, options: [.duckOthers, .allowBluetooth, .allowBluetoothA2DP]) try audioSession.overrideOutputAudioPort(speakerActive ? .speaker : .none) try audioSession.setActive(true, options: .notifyOthersOnDeactivation) } catch let error { print(error.localizedDescription) } } } /** It will play the given sound - parameter soundUrl: URL of the sound file - parameter loop: -1 for endless - parameter playOnSpeaker: True or false if should play the tone over the speaker */ private func playSound(soundUrl: URL, loops: Int) { audioPlayer?.stop() do { let player = try AVAudioPlayer(contentsOf: soundUrl, fileTypeHint: AVFileType.mp3.rawValue) player.numberOfLoops = loops audioPlayer = player player.play() } catch let error { print(error.localizedDescription) } } /** Invalidate the timers per call state - parameter state: new set state of the call state */ private func invalidateTimers(state: CallState) { switch state { case .idle: invalidateIncomingCallTimeout() invalidateInitCallTimeout() invalidateCallDuration() invalidateCallFailedTimer() case .sendOffer: invalidateCallFailedTimer() case .receivedOffer: invalidateInitCallTimeout() invalidateCallFailedTimer() case .outgoingRinging, .incomingRinging: invalidateInitCallTimeout() invalidateCallFailedTimer() case .sendAnswer: invalidateInitCallTimeout() invalidateIncomingCallTimeout() invalidateCallFailedTimer() case .receivedAnswer: invalidateInitCallTimeout() invalidateCallFailedTimer() case .initalizing: invalidateInitCallTimeout() invalidateIncomingCallTimeout() case .calling: invalidateInitCallTimeout() invalidateIncomingCallTimeout() invalidateCallFailedTimer() case .reconnecting: invalidateInitCallTimeout() invalidateIncomingCallTimeout() case .ended, .remoteEnded: invalidateInitCallTimeout() invalidateIncomingCallTimeout() invalidateCallFailedTimer() case .rejected, .rejectedBusy, .rejectedTimeout, .rejectedDisabled, .rejectedOffHours, .rejectedUnknown: invalidateInitCallTimeout() invalidateCallDuration() invalidateIncomingCallTimeout() invalidateCallFailedTimer() case .microphoneDisabled: invalidateInitCallTimeout() invalidateCallDuration() invalidateIncomingCallTimeout() invalidateCallFailedTimer() @unknown default: break } } /** Invalidate the incoming call timer */ private func invalidateIncomingCallTimeout() { incomingCallTimeoutTimer?.invalidate() incomingCallTimeoutTimer = nil } /** Invalidate the init call timer */ private func invalidateInitCallTimeout() { initCallTimeoutTimer?.invalidate() initCallTimeoutTimer = nil } /** Invalidate the call duration timer and set the callDurationTime to 0 */ private func invalidateCallDuration() { callDurationTimer?.invalidate() callDurationTimer = nil callDurationTime = 0 } /** Invalidate the call duration timer and set the callDurationTime to 0 */ private func invalidateCallFailedTimer() { callFailedTimer?.invalidate() callFailedTimer = nil } /** Add icecandidate to local array if it's in the correct state. Start a timer to send candidates as packets all 0.05 seconds - parameter candidate: RTCIceCandidate */ private func handleLocalIceCandidates(_ candidates: [RTCIceCandidate]) { func addCandidateToLocalArray(_ addedCadidates: [RTCIceCandidate]) { iceCandidatesLockQueue.sync { for (_, candidate) in addedCadidates.enumerated() { if shouldAddLocalCandidate(candidate) == true { localAddedIceCandidates.append(candidate) } } } } switch state { case .sendOffer, .outgoingRinging, .receivedAnswer, .initalizing, .calling, .reconnecting: addCandidateToLocalArray(candidates) let seperatedCandidates = self.localAddedIceCandidates.take(localAddedIceCandidates.count) if (seperatedCandidates.count > 0) { let message = VoIPCallIceCandidatesMessage.init(removed: false, candidates: seperatedCandidates, contact: self.contact, callId: self.callId!, completion: nil) VoIPCallSender.sendVoIPCall(iceCandidates: message) } self.localAddedIceCandidates.removeAll() break case .idle, .receivedOffer, .incomingRinging, .sendAnswer: addCandidateToLocalArray(candidates) break case .ended, .remoteEnded, .rejected, .rejectedBusy, .rejectedTimeout, .rejectedOffHours, .rejectedUnknown, .rejectedDisabled, .microphoneDisabled: // do nothing break } } /** Check if should add a ice candidate - parameter candidate: RTCIceCandidate - Returns: true or false */ private func shouldAddLocalCandidate(_ candidate: RTCIceCandidate) -> Bool { let parts = candidate.sdp.components(separatedBy: CharacterSet.init(charactersIn: " ")) // Invalid candidate but who knows what they're doing, so we'll just eat it... if parts.count < 8 { return true } // Discard loopback let ip = parts[4] if ip == "172.0.0.1" || ip == "::1" { debugPrint("Call: Discarding loopback candidate: \(candidate.sdp)") return false } // Discard IPv6 if disabled if UserSettings.shared()?.enableIPv6 == false && ip.contains(":") { debugPrint("Call: Discarding local IPv6 candidate: \(candidate.sdp)") return false } // Always add if not relay let type = parts[7] if type != "relay" || parts.count < 10 { return true } // Always add if related address is any let relatedAddress = parts[9] if relatedAddress == "0.0.0.0" { return true } // Discard relay candidates with the same related address // Important: This only works as long as we don't do ICE restarts and don't add further relay transport types! if localRelatedAddresses.contains(relatedAddress) { debugPrint("Call: Discarding local relay candidate (duplicate related address: \(relatedAddress)): \(candidate.sdp)") return false } else { localRelatedAddresses.insert(relatedAddress) } // Add it! return true } /** Check if an IP address is IPv6 - parameter ipToValidate: String of the ip - Returns: true or false */ private func isIPv6Address(_ ipToValidate: String) -> Bool { var sin6 = sockaddr_in6() if ipToValidate.withCString({ cstring in inet_pton(AF_INET6, cstring, &sin6.sin6_addr) }) == 1 { return true } return false } /** Handle notification if needed */ private func handleLocalNotification() { func addIncomCall() { if self.callKitManager == nil { // callkit is disabled --> show local notification DispatchQueue.main.async { if UIApplication.shared.applicationState != .active { let notification = UNMutableNotificationContent.init() notification.categoryIdentifier = "INCOMCALL" if let pushSetting = PushSetting.find(forIdentity: self.contact!.identity) { if pushSetting.canSendPush() && pushSetting.silent == false { var soundName = "threema_best.caf" if UserSettings.shared().voIPSound != "default" { soundName = "\(UserSettings.shared().voIPSound!).caf" } notification.sound = UNNotificationSound.init(named: UNNotificationSoundName.init(soundName)) } } else { var soundName = "threema_best.caf" if UserSettings.shared().voIPSound != "default" { soundName = "\(UserSettings.shared().voIPSound!).caf" } notification.sound = UNNotificationSound.init(named: UNNotificationSoundName.init(soundName)) } notification.userInfo = ["threema": ["cmd": "newcall", "from": self.contact!.displayName ?? "Unknown", "callId": self.callId?.callId ?? 0]] if !UserSettings.shared().pushShowNickname { notification.title = self.contact!.displayName } else { if self.contact!.publicNickname != nil && self.contact!.publicNickname.count > 0 { notification.title = self.contact!.publicNickname } else { notification.title = self.contact!.identity } } notification.body = BundleUtil.localizedString(forKey: "call_incoming_ended") notification.threadIdentifier = "INCOMCALL-\(self.contact?.identity ?? "")" let notificationRequest = UNNotificationRequest.init(identifier: self.contact!.identity, content: notification, trigger: nil) UNUserNotificationCenter.current().add(notificationRequest, withCompletionHandler: { (error) in }) } } } } func removeIncomCall() { if self.callKitManager == nil { // callkit is disabled --> delete local notification if let identity = self.contact?.identity { DispatchQueue.main.async { UNUserNotificationCenter.current().removeDeliveredNotifications(withIdentifiers: [identity]) } } } } func addMissedCall() { if let contact = self.contact { DispatchQueue.main.async { if AppDelegate.shared().isAppInBackground() == true { if self.isCallInitiator() == false && self.callDurationTime == 0 { var canSendPush = true var foundPushSetting: PushSetting? if let pushSetting = PushSetting.find(forIdentity: contact.identity) { foundPushSetting = pushSetting canSendPush = pushSetting.canSendPush() } if canSendPush == true { // callkit is disabled --> show missed notification let notification = UNMutableNotificationContent.init() notification.categoryIdentifier = "CALL" if UserSettings.shared().pushSound != "none" { if foundPushSetting != nil { if foundPushSetting?.silent == false { notification.sound = UNNotificationSound.init(named: UNNotificationSoundName(rawValue: UserSettings.shared().pushSound! + ".caf")) } } else { notification.sound = UNNotificationSound.init(named: UNNotificationSoundName(rawValue: UserSettings.shared().pushSound! + ".caf")) } } notification.userInfo = ["threema": ["cmd": "missedcall", "from": contact.displayName]] if !UserSettings.shared().pushShowNickname { notification.title = contact.displayName } else { if contact.publicNickname != nil && contact.publicNickname.count > 0 { notification.title = contact.publicNickname } else { notification.title = contact.identity } } notification.body = BundleUtil.localizedString(forKey: "call_missed") // Group notification together with others from the same contact notification.threadIdentifier = "SINGLE-\(self.contact?.identity ?? "")" let notificationRequest = UNNotificationRequest.init(identifier: contact.identity, content: notification, trigger: nil) UNUserNotificationCenter.current().add(notificationRequest, withCompletionHandler: { (error) in }) } } } } } } switch state { case .idle: break case .sendOffer: break case .receivedOffer: break case .outgoingRinging: break case .incomingRinging: addIncomCall() break case .sendAnswer: removeIncomCall() break case .receivedAnswer: break case .initalizing: removeIncomCall() break case .calling: break case .reconnecting: break case .ended: removeIncomCall() break case .remoteEnded: removeIncomCall() addMissedCall() break case .rejected, .rejectedOffHours, .rejectedUnknown, .rejectedDisabled: removeIncomCall() break case .rejectedBusy, .rejectedTimeout: removeIncomCall() addMissedCall() break case .microphoneDisabled: removeIncomCall() break } } /** Add call message to conversation */ private func addCallMessageToConversation(oldCallState: CallState) { switch state { case .idle: break case .sendOffer: break case .receivedOffer: break case .outgoingRinging: break case .incomingRinging: break case .sendAnswer: break case .receivedAnswer: break case .initalizing: break case .calling: break case .reconnecting: break case .ended, .remoteEnded: // add call message if (UserDefaults.standard.bool(forKey: "FASTLANE_SNAPSHOT")) { return } // if remoteEnded is incoming at the same time like user tap on end call button if oldCallState == .ended || oldCallState == .remoteEnded { return } let entityManager = EntityManager() let conversation = entityManager.conversation(for: contact!, createIfNotExisting: true) entityManager.performSyncBlockAndSafe({ let systemMessage = entityManager.entityCreator.systemMessage(for: conversation) systemMessage?.type = NSNumber(value: kSystemMessageCallEnded) var callInfo = ["DateString": DateFormatter.shortStyleTimeNoDate(Date()), "CallInitiator": NSNumber(booleanLiteral: self.isCallInitiator())] as [String : Any] if self.callDurationTime > 0 { callInfo["CallTime"] = DateFormatter.timeFormatted(self.callDurationTime) } do { let callInfoData = try JSONSerialization.data(withJSONObject: callInfo, options: .prettyPrinted) systemMessage?.arg = callInfoData systemMessage?.isOwn = NSNumber(booleanLiteral: self.isCallInitiator()) systemMessage?.conversation = conversation conversation?.lastMessage = systemMessage if self.state == .remoteEnded && self.callDurationTime == 0 { conversation?.unreadMessageCount = NSNumber(integerLiteral: (conversation?.unreadMessageCount.intValue)!+1) } } catch let error { print(error) } }) if state == .remoteEnded && self.callDurationTime == 0 { DispatchQueue.main.async { NotificationManager.sharedInstance()?.updateUnreadMessagesCount(false) } } break case .rejected: // add call message if contact != nil { addRejectedMessageToConversation(contact: contact!, reason: kSystemMessageCallRejected) } break case .rejectedTimeout: // add call message let reason = self.isCallInitiator() ? kSystemMessageCallRejectedTimeout : kSystemMessageCallMissed if contact != nil { addRejectedMessageToConversation(contact: contact!, reason: reason) } break case .rejectedBusy: // add call message let reason = self.isCallInitiator() ? kSystemMessageCallRejectedBusy : kSystemMessageCallMissed if contact != nil { addRejectedMessageToConversation(contact: contact!, reason: reason) } break case .rejectedOffHours: // add call message let reason = self.isCallInitiator() ? kSystemMessageCallRejectedOffHours : kSystemMessageCallMissed if contact != nil { addRejectedMessageToConversation(contact: contact!, reason: reason) } break case .rejectedUnknown: // add call message let reason = self.isCallInitiator() ? kSystemMessageCallRejectedUnknown : kSystemMessageCallMissed if contact != nil { addRejectedMessageToConversation(contact: contact!, reason: reason) } break case .rejectedDisabled: // add call message if callInitiator == true { if contact != nil { addRejectedMessageToConversation(contact: contact!, reason: kSystemMessageCallRejectedDisabled) } } break case .microphoneDisabled: break } } private func addRejectedMessageToConversation(contact: Contact, reason: Int) { let entityManager = EntityManager() let conversation = entityManager.conversation(for: contact, createIfNotExisting: true) entityManager.performSyncBlockAndSafe({ let systemMessage = entityManager.entityCreator.systemMessage(for: conversation) systemMessage?.type = NSNumber(value: reason) let callInfo = ["DateString": DateFormatter.shortStyleTimeNoDate(Date()), "CallInitiator": NSNumber(booleanLiteral: self.isCallInitiator())] as [String : Any] do { let callInfoData = try JSONSerialization.data(withJSONObject: callInfo, options: .prettyPrinted) systemMessage?.arg = callInfoData systemMessage?.isOwn = NSNumber(booleanLiteral: self.isCallInitiator()) systemMessage?.conversation = conversation conversation?.lastMessage = systemMessage if reason == kSystemMessageCallMissed || reason == kSystemMessageCallRejectedBusy || reason == kSystemMessageCallRejectedTimeout || reason == kSystemMessageCallRejectedDisabled { conversation?.unreadMessageCount = NSNumber(integerLiteral: (conversation?.unreadMessageCount.intValue)!+1) } else { systemMessage?.read = true systemMessage?.readDate = Date() } } catch let error { print(error) } }) if reason == kSystemMessageCallMissed || reason == kSystemMessageCallRejectedBusy || reason == kSystemMessageCallRejectedTimeout || reason == kSystemMessageCallRejectedDisabled { DispatchQueue.main.async { NotificationManager.sharedInstance()?.updateUnreadMessagesCount(false) } } } private func addUnknownCallIcecandidatesMessages(message: VoIPCallIceCandidatesMessage) { receivedIceCandidatesLockQueue.sync { guard let contact = message.contact else { return } if var contactCandidates = receivedUnknowCallIcecandidatesMessages[contact.identity] { contactCandidates.append(message) } else { receivedUnknowCallIcecandidatesMessages[contact.identity] = [message] } } } private func sdpContainsVideo(sdp: RTCSessionDescription?) -> Bool { guard sdp != nil else { return false } return sdp!.sdp.contains("m=video") } private func callFailed() { DDLogNotice("Threema call: peerconnection new state failed -> close connection") var message = BundleUtil.localizedString(forKey: "call_status_failed_connected_message") if !self.iceWasConnected { // show error as notification if self.contact != nil { let hangupMessage = VoIPCallHangupMessage(contact: self.contact!, callId: self.callId!, completion: nil) VoIPCallSender.sendVoIPCallHangup(hangupMessage: hangupMessage, wait: false) } message = BundleUtil.localizedString(forKey: "call_status_failed_initializing_message") } NotificationBannerHelper.newErrorToast(title: BundleUtil.localizedString(forKey: "call_status_failed_title"), body: message!) invalidateCallFailedTimer() handleTones(state: .ended, oldState: .reconnecting) callKitManager?.endCall() dismissCallView() disconnectPeerConnection() } private func callCantCreateOffer(error: Error?) { DDLogNotice("Threema call: Can't create offer -> \(error?.localizedDescription ?? "error is missing")") let message = BundleUtil.localizedString(forKey: "call_status_failed_sdp_patch_message") NotificationBannerHelper.newErrorToast(title: BundleUtil.localizedString(forKey: "call_status_failed_title"), body: message!) invalidateCallFailedTimer() handleTones(state: .ended, oldState: .reconnecting) callKitManager?.endCall() dismissCallView() disconnectPeerConnection() } } extension VoIPCallService: VoIPCallPeerConnectionClientDelegate { func peerConnectionClient(_ client: VoIPCallPeerConnectionClient, removedCandidates: [RTCIceCandidate]) { // ICE candidate messages are currently allowed to have a "removed" flag. However, this is non-standard. // Ignore generated ICE candidates with removed set to true coming from libwebrtc } func peerConnectionClient(_ client: VoIPCallPeerConnectionClient, addedCandidate: RTCIceCandidate) { if contact != nil { handleLocalIceCandidates([addedCandidate]) } } func peerConnectionClient(_ client: VoIPCallPeerConnectionClient, changeState: CallState) { state = changeState } func peerConnectionClient(_ client: VoIPCallPeerConnectionClient, audioMuted: Bool) { self.audioMuted = audioMuted } func peerConnectionClient(_ client: VoIPCallPeerConnectionClient, speakerActive: Bool) { self.speakerActive = speakerActive let audioSession = AVAudioSession.sharedInstance() do { try audioSession.setCategory(.playAndRecord, mode: speakerActive ? .videoChat : .voiceChat, options: [.duckOthers, .allowBluetooth, .allowBluetoothA2DP]) try audioSession.overrideOutputAudioPort(speakerActive ? .speaker : .none) try audioSession.setActive(true, options: .notifyOthersOnDeactivation) } catch let error { print(error.localizedDescription) } } func peerConnectionClient(_ client: VoIPCallPeerConnectionClient, receivingVideo: Bool) { if self.isReceivingVideo != receivingVideo { self.isReceivingVideo = receivingVideo } } func peerConnectionClient(_ client: VoIPCallPeerConnectionClient, didChangeConnectionState state: RTCIceConnectionState) { DDLogNotice("Threema call: peerConnectionClient state changed: new state \(state.rawValue))") switch state { case .new: break case .checking: self.state = .initalizing case .connected: invalidateCallFailedTimer() iceWasConnected = true if self.state != .reconnecting { self.state = .calling ValidationLogger.shared().logString("Threema call status is calling: \(self.callStateString())") DispatchQueue.main.async { self.callDurationTime = 0 self.callDurationTimer = Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true, block: { (timer) in self.callDurationTime = self.callDurationTime + 1 if self.state == .calling { self.callViewController?.voIPCallDurationChanged(self.callDurationTime) } else { self.callViewController?.voIPCallStatusChanged(state: self.state, oldState: self.state) ValidationLogger.shared().logString("Threema call status is connected, but shows something different: \(self.callStateString())") } if VoIPHelper.shared()?.isCallActiveInBackground == true { NotificationCenter.default.post(name: NSNotification.Name(kNotificationCallInBackgroundTimeChanged), object: self.callDurationTime) } }) } self.callViewController?.startDebugMode(connection: client.peerConnection) callKitManager?.callConnected() } else { ValidationLogger.shared().logString("Threema call status is reconnecting: \(self.callStateString())") self.state = .calling ValidationLogger.shared().logString("Threema call status is calling: \(self.callStateString())") } self.activateRTCAudio() case .completed: break case .failed: if self.state == .reconnecting { callFailed() } else { if self.iceWasConnected { self.state = .reconnecting DDLogNotice("Threema call: peerconnection failed, set state to reconnecting -> start callFailedTimer") } else { self.state = .initalizing DDLogNotice("Threema call: peerconnection failed, set state to initalizing -> start callFailedTimer") } // start timer and wait if state change back to connected DispatchQueue.main.async { self.invalidateCallFailedTimer() self.callFailedTimer = Timer.scheduledTimer(withTimeInterval: self.kCallFailedTimeout, repeats: false, block: { (timeout) in self.callFailed() }) } } case .disconnected: if self.state == .calling || self.state == .initalizing { self.state = .reconnecting } case .closed: break case .count: break @unknown default: break } } func peerConnectionClient(_ client: VoIPCallPeerConnectionClient, didReceiveData: Data) { let threemaVideoCallSignalingMessage = CallsignalingProtocol.decodeThreemaVideoCallSignalingMessage(didReceiveData) if let videoQualityProfile = threemaVideoCallSignalingMessage.videoQualityProfile { peerConnectionClient?.remoteVideoQualityProfile = videoQualityProfile } if let captureState = threemaVideoCallSignalingMessage.captureStateChange { switch captureState.device { case .camera: switch captureState.state { case .off: peerConnectionClient?.isRemoteVideoActivated = false case .on: peerConnectionClient?.isRemoteVideoActivated = true default: break } default: break } } debugPrint(threemaVideoCallSignalingMessage) } } extension Array { func take(_ elementsCount: Int) -> [Element] { let min = Swift.min(elementsCount, count) return Array(self[0..